Compare commits
105 Commits
19435b2d48
...
v1.9.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
23a93ce0bb | ||
|
|
29a294b7f3 | ||
|
|
ca4377e641 | ||
|
|
d5eec75bea | ||
|
|
18479c023e | ||
|
|
869dd25a23 | ||
|
|
c4d1acc75b | ||
|
|
378a92c156 | ||
|
|
983c177c9a | ||
|
|
3e4e4a03f7 | ||
|
|
92767c646e | ||
|
|
e779e13654 | ||
|
|
4847c5c0a4 | ||
|
|
43fb506e87 | ||
|
|
b75a7b1b5a | ||
|
|
824f785fd0 | ||
|
|
0d1475cb7a | ||
|
|
cfe23cdd23 | ||
|
|
cee051bb6d | ||
|
|
23c3065f20 | ||
|
|
80a2de6c74 | ||
|
|
17c7ff517a | ||
|
|
8b347de131 | ||
|
|
619bc0c38d | ||
|
|
96da9fbae5 | ||
|
|
1ac9ced0bd | ||
|
|
8cbe1adb32 | ||
|
|
23ff3916cc | ||
|
|
360ff77e18 | ||
|
|
e272053e72 | ||
|
|
74ca2e0dcd | ||
|
|
0cba9f9640 | ||
|
|
c6534165b2 | ||
|
|
290b4a602a | ||
|
|
fe73f45b74 | ||
|
|
d2a08d2cda | ||
|
|
8194dadb6a | ||
|
|
fb1d799b82 | ||
|
|
12fdb55a8e | ||
|
|
eee5c99e2f | ||
|
|
37df51475e | ||
|
|
53b666dfbd | ||
|
|
cd5501e6a6 | ||
|
|
b5417f6b09 | ||
|
|
7e739afafb | ||
|
|
e9e4ad8fbc | ||
|
|
d4af345ac3 | ||
|
|
ddeded988a | ||
|
|
c27a179d2b | ||
|
|
1448794748 | ||
|
|
51ef488d2f | ||
|
|
49046310ef | ||
|
|
f8f20bf6ed | ||
|
|
f21c65be18 | ||
|
|
c300f8c313 | ||
|
|
d6e0953293 | ||
|
|
a8b86e25e6 | ||
|
|
1abb429f12 | ||
|
|
803c04d9e0 | ||
|
|
12732d6dc9 | ||
|
|
b3a2daf40d | ||
|
|
8f49ebb248 | ||
|
|
f56cc617c3 | ||
|
|
ca8326c4c5 | ||
|
|
f5d165baae | ||
|
|
61a40d549b | ||
|
|
5723b81992 | ||
|
|
7f1a14ab80 | ||
|
|
33bdff8a6e | ||
|
|
b5cf19b19a | ||
|
|
9f19a714f7 | ||
|
|
b672c9aaf3 | ||
|
|
384e058812 | ||
|
|
01e0c1d794 | ||
|
|
00a065bf7f | ||
|
|
763732a9b3 | ||
|
|
a41b8de47a | ||
|
|
18b777a712 | ||
|
|
7f173daecb | ||
|
|
e71c0ed24f | ||
|
|
d450153183 | ||
|
|
72687e9b30 | ||
|
|
d52243ccd1 | ||
|
|
8cafad370e | ||
|
|
d8a973d0e1 | ||
|
|
0b623b8e4a | ||
|
|
5edb433755 | ||
|
|
c8f82ed3c2 | ||
|
|
1aa06077a8 | ||
|
|
cb20877620 | ||
|
|
dcbf67c63b | ||
|
|
02b11c727c | ||
|
|
74afc46909 | ||
|
|
ef3fba1690 | ||
|
|
ef2f5c51e4 | ||
|
|
3060cb0242 | ||
|
|
3596053512 | ||
|
|
4bf4a27036 | ||
|
|
de4ad5dcf3 | ||
|
|
2dfc4559b1 | ||
|
|
dd3b03b9e4 | ||
|
|
f4416ee1c3 | ||
|
|
42bb79e2b7 | ||
|
|
561028e67b | ||
|
|
07a9d07cf6 |
101
.gitea/workflows/audit.yml
Normal file
101
.gitea/workflows/audit.yml
Normal file
@@ -0,0 +1,101 @@
|
||||
name: Dependency Audit
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '0 6 * * 1' # Mondays 06:00 UTC
|
||||
workflow_dispatch: {}
|
||||
|
||||
jobs:
|
||||
audit:
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
DOTNET_ROOT: /home/mika/.dotnet
|
||||
GITEA_API: https://git.kuns.dev/api/v1
|
||||
REPO: releases/ClaudeDo
|
||||
ISSUE_TITLE: 'Dependency audit: vulnerable packages detected'
|
||||
steps:
|
||||
- name: Checkout main
|
||||
env:
|
||||
TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
git clone --depth 1 --branch main \
|
||||
"https://oauth2:${TOKEN}@git.kuns.dev/${REPO}.git" src
|
||||
|
||||
- name: Scan for vulnerable / outdated packages
|
||||
run: |
|
||||
set -euo pipefail
|
||||
export PATH="$DOTNET_ROOT:$PATH"
|
||||
cd src
|
||||
|
||||
: > audit.log
|
||||
: > vuln.md
|
||||
found=0
|
||||
|
||||
# .slnx tooling needs .NET 9; iterate per-project to stay on .NET 8.
|
||||
while IFS= read -r proj; do
|
||||
echo "==== $proj ====" | tee -a audit.log
|
||||
dotnet restore "$proj" >/dev/null
|
||||
|
||||
vuln="$(dotnet list "$proj" package --vulnerable --include-transitive 2>&1)"
|
||||
echo "$vuln" | tee -a audit.log
|
||||
if echo "$vuln" | grep -qi "has the following vulnerable"; then
|
||||
found=1
|
||||
{
|
||||
printf '#### `%s`\n\n```\n' "$proj"
|
||||
echo "$vuln"
|
||||
printf '```\n\n'
|
||||
} >> vuln.md
|
||||
fi
|
||||
|
||||
# Outdated is informational only — never fails the run.
|
||||
dotnet list "$proj" package --outdated 2>&1 | tee -a audit.log || true
|
||||
echo "" | tee -a audit.log
|
||||
done < <(find . -name '*.csproj' | sort)
|
||||
|
||||
if [ "$found" -ne 0 ]; then
|
||||
echo "::error::Vulnerable packages detected — see log above." >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "No vulnerable packages found."
|
||||
|
||||
- name: Report vulnerabilities to a Gitea issue
|
||||
if: failure()
|
||||
env:
|
||||
TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
cd src
|
||||
|
||||
if [ -s vuln.md ]; then
|
||||
DETAILS="$(cat vuln.md)"
|
||||
else
|
||||
DETAILS="The audit job failed before producing findings — check the run log."
|
||||
fi
|
||||
BODY="$(printf 'Automated weekly dependency audit found vulnerable packages.\n\n%s\n\n[View workflow run](%s)' \
|
||||
"$DETAILS" "$RUN_URL")"
|
||||
|
||||
# Reuse an existing open issue if one is already tracking this.
|
||||
EXISTING="$(curl -sS \
|
||||
-H "Authorization: token ${TOKEN}" \
|
||||
"${GITEA_API}/repos/${REPO}/issues?state=open&type=issues&limit=50" \
|
||||
| jq -r --arg t "$ISSUE_TITLE" '.[] | select(.title==$t) | .number' | head -n1)"
|
||||
|
||||
if [ -n "$EXISTING" ]; then
|
||||
echo "Commenting on existing issue #$EXISTING"
|
||||
jq -n --arg body "$BODY" '{body:$body}' \
|
||||
| curl -sS --fail-with-body -X POST \
|
||||
-H "Authorization: token ${TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d @- \
|
||||
"${GITEA_API}/repos/${REPO}/issues/${EXISTING}/comments" >/dev/null
|
||||
else
|
||||
echo "Creating new issue"
|
||||
jq -n --arg title "$ISSUE_TITLE" --arg body "$BODY" '{title:$title, body:$body}' \
|
||||
| curl -sS --fail-with-body -X POST \
|
||||
-H "Authorization: token ${TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d @- \
|
||||
"${GITEA_API}/repos/${REPO}/issues" >/dev/null
|
||||
fi
|
||||
85
.gitea/workflows/changelog.yml
Normal file
85
.gitea/workflows/changelog.yml
Normal file
@@ -0,0 +1,85 @@
|
||||
name: Changelog
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
|
||||
jobs:
|
||||
changelog:
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
REPO: releases/ClaudeDo
|
||||
steps:
|
||||
- name: Checkout main (full history)
|
||||
env:
|
||||
TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
git clone "https://oauth2:${TOKEN}@git.kuns.dev/${REPO}.git" src
|
||||
cd src
|
||||
git fetch --tags --force
|
||||
git checkout main
|
||||
|
||||
- name: Regenerate CHANGELOG.md
|
||||
run: |
|
||||
set -euo pipefail
|
||||
cd src
|
||||
|
||||
emit_group() {
|
||||
# $1 range, $2 conventional-type, $3 heading
|
||||
local range="$1" type="$2" title="$3" lines
|
||||
lines="$(git log "$range" --no-merges --pretty=format:'%s|%h' \
|
||||
| grep -E "^${type}(\([^)]*\))?(!)?: " || true)"
|
||||
[ -z "$lines" ] && return 0
|
||||
printf '### %s\n\n' "$title"
|
||||
while IFS='|' read -r subject hash; do
|
||||
printf -- '- %s (%s)\n' "${subject#*: }" "$hash"
|
||||
done <<< "$lines"
|
||||
printf '\n'
|
||||
}
|
||||
|
||||
emit_section() {
|
||||
# $1 range, $2 tag, $3 date
|
||||
printf '## %s — %s\n\n' "$2" "$3"
|
||||
emit_group "$1" feat "Features"
|
||||
emit_group "$1" fix "Fixes"
|
||||
emit_group "$1" perf "Performance"
|
||||
emit_group "$1" refactor "Refactoring"
|
||||
emit_group "$1" docs "Documentation"
|
||||
}
|
||||
|
||||
# Tags ascending by semver so we can pair each with its predecessor.
|
||||
mapfile -t TAGS < <(git tag --sort=v:refname | grep -E '^v' || true)
|
||||
|
||||
{
|
||||
printf '# Changelog\n\n'
|
||||
for ((i=${#TAGS[@]}-1; i>=0; i--)); do
|
||||
TAG="${TAGS[$i]}"
|
||||
DATE="$(git log -1 --format=%ad --date=short "$TAG")"
|
||||
if (( i > 0 )); then
|
||||
RANGE="${TAGS[$((i-1))]}..${TAG}"
|
||||
else
|
||||
RANGE="$TAG"
|
||||
fi
|
||||
emit_section "$RANGE" "$TAG" "$DATE"
|
||||
done
|
||||
} > CHANGELOG.md
|
||||
|
||||
cat CHANGELOG.md
|
||||
|
||||
- name: Commit and push if changed
|
||||
env:
|
||||
TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
cd src
|
||||
if git diff --quiet -- CHANGELOG.md; then
|
||||
echo "CHANGELOG.md unchanged; nothing to commit."
|
||||
exit 0
|
||||
fi
|
||||
git config user.name "ClaudeDo CI"
|
||||
git config user.email "ci@kuns.dev"
|
||||
git add CHANGELOG.md
|
||||
git commit -m "docs(changelog): update for ${GITHUB_REF_NAME}"
|
||||
git push origin main
|
||||
920
CHANGELOG.md
Normal file
920
CHANGELOG.md
Normal file
@@ -0,0 +1,920 @@
|
||||
# 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)
|
||||
|
||||
16
CLAUDE.md
16
CLAUDE.md
@@ -10,7 +10,11 @@ Two-process system communicating over SignalR (`127.0.0.1:47821`):
|
||||
- **ClaudeDo.Ui** — Views, ViewModels, SignalR client (MVVM with CommunityToolkit.Mvvm)
|
||||
- **ClaudeDo.Data** — SQLite data layer, repositories, models, GitService
|
||||
- **ClaudeDo.Worker** — ASP.NET Core hosted service, task queue, Claude CLI runner
|
||||
- **ClaudeDo.Worker.Tests** — xUnit integration tests with real SQLite and real git
|
||||
- **ClaudeDo.Localization** — `locales/en.json` + `locales/de.json` and the lookup service
|
||||
- **ClaudeDo.Installer** — WPF (`UseWPF`) setup app; install/update/uninstall step pipeline
|
||||
- **tests/** — six xUnit projects (Worker, Data, Ui, Localization, Installer, Releases); Worker.Tests run real SQLite and real git
|
||||
|
||||
Each project has its own `CLAUDE.md` — those are the living per-project docs.
|
||||
|
||||
## Tech Stack
|
||||
|
||||
@@ -35,7 +39,7 @@ Two-process system communicating over SignalR (`127.0.0.1:47821`):
|
||||
- EF Core migrations manage schema (Migrations/ folder in ClaudeDo.Data)
|
||||
- `IDbContextFactory<ClaudeDoDbContext>` used by singleton consumers (e.g. Worker)
|
||||
- Entity configuration via `IEntityTypeConfiguration<T>` in Configuration/ folder
|
||||
- Task status flow: Idle | Queued -> Running -> WaitingForReview -> Done | Failed | Cancelled. A standalone task's successful run lands in WaitingForReview (planning children go straight to Done); from review you can approve (merges the worktree into the target branch, then Done; conflicts keep it in WaitingForReview), reject-rerun (Queued, resumes the session with feedback), reject-park (Idle), or cancel (Cancelled). Tasks with no active worktree (sandbox run / improvement parent) are approved straight to Done.
|
||||
- Task status flow: Idle | Queued -> Running -> WaitingForReview -> Done | Failed | Cancelled. A task that spawns/has children passes through WaitingForChildren first, then surfaces for review once every child is terminal — this is the single parent model for both planning and improvement parents (planning/improvement *children* themselves go straight to Done, only the parent is reviewed). From review you can approve, reject-rerun (Queued, resumes the session with feedback), reject-park (Idle), or cancel. Approve is the single review+merge action: a childless task merges its own worktree then Done (conflicts keep it in WaitingForReview); a task with children drives the unit merge (parent worktree if any + each Done child in order, with conflict continue/abort). Tasks with no active worktree (sandbox run) approve straight to Done.
|
||||
- Worktree state flow: Active -> Merged | Discarded | Kept
|
||||
- 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)
|
||||
@@ -75,6 +79,8 @@ dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release
|
||||
|
||||
## Docs
|
||||
|
||||
- `docs/plan.md` — full architecture and design spec
|
||||
- `docs/open.md` — verification checklist and improvement backlog
|
||||
- `docs/improvement-plan.md` — prioritized improvement items
|
||||
- `docs/open.md` — open verification items and remaining code TODOs (the only doc kept current besides the CLAUDE.md files)
|
||||
- `docs/plan.md` — original design spec (historical; tag-queue/schema.sql parts are outdated)
|
||||
- `docs/improvement-plan.md` — improvement snapshot from 2026-04-13 (historical)
|
||||
- `docs/prompts-inventory.md`, `docs/mailbox-proposal.md` — reference material (mailbox integration is parked)
|
||||
- `CHANGELOG.md` — Keep a Changelog format, maintained on release
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
# ClaudeDo — Improvement Plan (Session 2026-04-13)
|
||||
|
||||
> **Hinweis (2026-06-09):** Historischer Snapshot — bewusst nicht nachgepflegt. U.a. erledigt/überholt: IP-1 (Auto-Reconnect ist implementiert), `schema.sql` → EF-Core-Migrations, `StatusBarViewModel` existiert nicht mehr (Connection-State lebt in `IslandsShellViewModel`), Tags sind Junction-Tabellen statt JSON-Spalten. Offene Punkte stehen in `open.md`.
|
||||
|
||||
Erfasst während manuellem Walkthrough der App. Priorisiert nach Schmerz/Aufwand.
|
||||
|
||||
---
|
||||
|
||||
173
docs/online-inbox-api-contract.md
Normal file
173
docs/online-inbox-api-contract.md
Normal file
@@ -0,0 +1,173 @@
|
||||
# ClaudeDo Online Inbox — API Contract & VPS build prompt
|
||||
|
||||
Status: handoff doc. The **server side** (API + minimal web client) is built and deployed
|
||||
VPS-side by a separate Claude instance. This file is the source of truth for the contract
|
||||
both ends implement against. The desktop client in this repo is built to match it.
|
||||
|
||||
---
|
||||
|
||||
## 1. Concept
|
||||
|
||||
ClaudeDo is a local desktop app that runs tasks autonomously via the Claude CLI; it is
|
||||
normally fully local (SQLite). The **Online Inbox** is an optional service that lets the
|
||||
single owner view their task lists and add new tasks from a phone/browser. The desktop app
|
||||
syncs against it.
|
||||
|
||||
**Governing rule:** the online store mirrors EXACTLY the desktop's `Idle` backlog — nothing
|
||||
else. A task is present online only while it is `Idle` on the desktop. The moment the user
|
||||
queues it locally, the desktop removes it from the online store. Running / WaitingForReview /
|
||||
Done / Failed / Cancelled tasks never appear online.
|
||||
|
||||
Sync directions (each one-way per entity → no conflict resolution needed):
|
||||
|
||||
- **Lists**: desktop → online only. Desktop is the source of truth (full-replace catalog).
|
||||
- **Idle tasks**: desktop mirrors its Idle backlog up; the web can create new ones, which the
|
||||
desktop pulls down and then owns.
|
||||
|
||||
Single user today. Both the desktop and the web client authenticate as the **same Zitadel
|
||||
user**.
|
||||
|
||||
**Multi-user readiness (`ownerId`).** Each resource is owned by a Zitadel subject (`sub`).
|
||||
`RemoteList`, `RemoteTask`, and `MirrorTask` carry an optional `ownerId` field. The desktop
|
||||
stamps its own `sub` (decoded from the access token) onto everything it pushes, and
|
||||
defensively ignores any pulled task whose `ownerId` is set to a *different* user; an absent
|
||||
`ownerId` is treated as unowned/legacy and still syncs. This keeps the contract ready for
|
||||
multiple users **without enforcing isolation client-side** — the server remains the
|
||||
authority that scopes every request by the token's `sub`. When the server goes multi-user it
|
||||
should partition all rows by owner and ignore (or validate) the client-supplied `ownerId`.
|
||||
|
||||
**Access control (as of 2026-06-10).** Access is granted by assigning the **"user" project
|
||||
role** in the Zitadel project "ClaudeDo" (id `376787351902355727`, issuer
|
||||
`https://auth.kuns.dev`) — there is no app-side allowlist (the former `ALLOWED_USER_IDS`
|
||||
env var is gone). The access token carries the role in the claim
|
||||
`urn:zitadel:iam:org:project:roles` (or the project-scoped variant
|
||||
`urn:zitadel:iam:org:project:376787351902355727:roles`), an object keyed by role key, e.g.
|
||||
`{ "user": { "<orgId>": "<orgDomain>" } }`. The desktop OIDC client
|
||||
(id `376787352137302287`) has `accessTokenRoleAssertion` enabled, so any token issued
|
||||
after login/refresh includes the claim automatically — no extra scopes are needed.
|
||||
Granting/revoking access is purely a Zitadel role grant, nothing app-side.
|
||||
|
||||
## 2. Idle backlog definition (desktop side)
|
||||
|
||||
The desktop mirrors only "real" backlog items, not planning internals:
|
||||
|
||||
- `Status == Idle`
|
||||
- `ParentTaskId == null` (no planning/improvement children)
|
||||
- `PlanningPhase == None`
|
||||
- `BlockedByTaskId == null`
|
||||
|
||||
## 3. Data model (Postgres)
|
||||
|
||||
```
|
||||
lists
|
||||
id text primary key -- GUID supplied by the desktop; reuse verbatim
|
||||
name text not null
|
||||
updated_at timestamptz not null default now()
|
||||
|
||||
tasks
|
||||
id text primary key -- GUID; SHARED id space (see below)
|
||||
list_id text not null references lists(id) on delete cascade
|
||||
title text not null
|
||||
description text
|
||||
imported boolean not null default false -- false = web-created, awaiting desktop pull
|
||||
-- true = desktop-owned (mirrored or handed off)
|
||||
created_at timestamptz not null default now()
|
||||
updated_at timestamptz not null default now()
|
||||
```
|
||||
|
||||
**Shared GUID id space.** Web-created tasks get a server-generated GUID; the desktop imports
|
||||
under that SAME id, so it never duplicates. Desktop-mirrored tasks arrive with their own GUID.
|
||||
All task writes are idempotent upserts keyed on id.
|
||||
|
||||
**`imported` flag = ownership.**
|
||||
- Web `POST /tasks` inserts `imported=false`.
|
||||
- Desktop pulls `imported=false`, creates the task locally (reusing the id), then `POST
|
||||
/tasks/{id}/imported` flips it to `true`. From then on the task belongs to the desktop
|
||||
mirror.
|
||||
- `PUT /tasks/mirror` only ever inserts/updates/deletes within the `imported=true` partition.
|
||||
It never touches `imported=false` rows (those are pending handoff).
|
||||
|
||||
## 4. Endpoints
|
||||
|
||||
All endpoints require a valid Zitadel access token (`Authorization: Bearer <token>`) that
|
||||
carries the **"user" project role** (see §1). Missing/invalid/expired token, or a valid
|
||||
token without the role → `401`. No anonymous access (imported tasks can trigger code
|
||||
execution on the user's machine). The desktop client treats a `401` as: force a
|
||||
refresh-token exchange and retry once; if a freshly issued token is still rejected, it
|
||||
surfaces "missing 'user' role in Zitadel" and pauses sync until the user signs in again.
|
||||
|
||||
> **Auth (VPS/.NET):** use the in-house `KunsZitadel` nuget package (feed
|
||||
> `https://git.kuns.dev/api/packages/kuns/nuget/index.json`) — call `AddKunsZitadel(...)`
|
||||
> with the Zitadel authority/audience/client id to wire `JwtBearer` validation + CORS for
|
||||
> the web client origin. (`KunsZitadel` is server-side token *validation* only; the desktop
|
||||
> client acquires tokens via its own OIDC flow.)
|
||||
|
||||
| Method & path | Caller | Body | Response |
|
||||
|---|---|---|---|
|
||||
| `PUT /lists` | desktop | `[{ "id", "name", "ownerId"? }]` — the FULL catalog | `200` |
|
||||
| `GET /lists` | web | — | `200 [{ "id", "name", "ownerId"? }]` |
|
||||
| `GET /lists/{id}/tasks` | web | — | `200` tasks in that list (`404` if list unknown) |
|
||||
| `POST /tasks` | web | `{ "title", "description"?, "listId" }` | `201` created task incl. `id` |
|
||||
| `GET /tasks?imported=false` | desktop | — | `200 [{ "id","listId","title","description","createdAt","ownerId"? }]` |
|
||||
| `POST /tasks/{id}/imported` | desktop | — | `200` (`404` if unknown) |
|
||||
| `PUT /tasks/mirror` | desktop | `[{ "id","listId","title","description","ownerId"? }]` — full Idle set | `200` |
|
||||
|
||||
`ownerId` (optional, see §1) is the Zitadel `sub` of the owner. The desktop sends it on push
|
||||
and ignores pulled tasks owned by a different user; the server should derive/validate it from
|
||||
the token rather than trust the client value.
|
||||
|
||||
Semantics:
|
||||
|
||||
- **`PUT /lists`** — full replace: upsert all supplied, DELETE any list not in the payload
|
||||
(cascades its tasks). Idempotent.
|
||||
- **`POST /tasks`** — `listId` must exist (`400`/`404` otherwise). Server generates the id.
|
||||
- **`PUT /tasks/mirror`** — full replace of the `imported=true` partition: upsert every task
|
||||
in the payload (insert with `imported=true`, or update), and DELETE any `imported=true`
|
||||
task whose id is not in the payload. `imported=false` rows are untouched. Idempotent.
|
||||
- All task ids are client-trusted within the shared space; the server never rewrites an id.
|
||||
|
||||
## 5. Reconcile loop (desktop, runs each poll cycle)
|
||||
|
||||
```
|
||||
1. PULL: GET /tasks?imported=false
|
||||
for each: if no local task with that id → create local TaskEntity
|
||||
{ Id = remote.id, ListId = remote.listId, Title, Description,
|
||||
Status = Idle, CreatedBy = "online" }
|
||||
(skip + log if remote.listId has no local list)
|
||||
then POST /tasks/{id}/imported
|
||||
2. PUSH LISTS: PUT /lists with the full local catalog [{id, name}]
|
||||
3. PUSH TASKS: PUT /tasks/mirror with the current local Idle backlog set (§2)
|
||||
```
|
||||
|
||||
Ordering matters: pull+import+flag first, so the just-imported tasks are part of the local
|
||||
Idle set computed in step 3 and survive the mirror replace.
|
||||
|
||||
## 6. Minimal web client
|
||||
|
||||
Integrate into the existing Nuxt app at claudedo.kuns.dev if present; else a minimal page.
|
||||
|
||||
- Zitadel login.
|
||||
- Show lists (`GET /lists`); select one to see its Idle tasks (`GET /lists/{id}/tasks`).
|
||||
- Add-task form → `POST /tasks`.
|
||||
- Mobile-first (main use: jotting ideas from a phone).
|
||||
- **Create + read only.** No editing, reordering, status changes, or deletes.
|
||||
|
||||
## 7. Security
|
||||
|
||||
- Every route auth-gated (`401` on bad token); only static assets / login are public.
|
||||
- Validate `listId` on task creation; parameterized queries only.
|
||||
- CORS restricted to the web client origin.
|
||||
- Don't log task titles/descriptions at info level (user content).
|
||||
|
||||
## 8. Deliverables from the VPS build
|
||||
|
||||
Report back so the desktop can be configured:
|
||||
|
||||
1. **API base URL.**
|
||||
2. **Zitadel app/client config the desktop must use**: issuer/authority, client id, scopes,
|
||||
and the OAuth flow to use for a desktop app (device-code or auth-code + PKCE), plus how
|
||||
refresh tokens are issued.
|
||||
3. Any env vars / README.
|
||||
|
||||
Out of scope server-side: task execution (the desktop runs Claude), any task state other
|
||||
than the Idle mirror, multi-user / sharing / notifications.
|
||||
28
docs/open.md
28
docs/open.md
@@ -1,6 +1,6 @@
|
||||
# ClaudeDo — Offene Punkte
|
||||
|
||||
Stand: 2026-06-04. **Nur noch offene Punkte.** Was erledigt ist, steht in den Commits und im Code — nicht hier.
|
||||
Stand: 2026-06-10. **Nur noch offene Punkte.** Was erledigt ist, steht in den Commits und im Code — nicht hier.
|
||||
|
||||
---
|
||||
|
||||
@@ -13,11 +13,37 @@ Kein Code-Aufwand, nur Durchspielen mit explizit notiertem Pass-Kriterium. Der G
|
||||
- No-Changes-Run → `status='Done'`, `head_commit IS NULL`, `diff_stat IS NULL`.
|
||||
- Kein Git-Repo (`working_dir=C:\Temp`) → `status='Failed'`, **keine** `worktrees`-Row, Git-Fehler im Log.
|
||||
- **Feature-Walkthroughs:** Planning-Session-Flow (Draft→Finalize→Chain), Prime/Daily-Prep-Trigger, Weekly-Report-Generierung, Self-Update (Banner → Update → „up to date").
|
||||
- **UI-Sichtprüfung (neu, 2026-06-09):** Diff-Viewer (Dateiliste, Added/Deleted/Renamed/Binary-Erkennung, Commit-Range-Diff nach Merge) und das „children need attention"-Band auf dem Session-Tab des Parents.
|
||||
- **UI-Sichtprüfung (neu, 2026-06-10, nach Refactoring-Merges):** Detail-Insel komplett durchklicken (Output/Git/Session-Tabs, Merge-Sektion, Agent-Settings-Overrides, Prep-Panel) — `DetailsIslandViewModel` wurde in Sektions-VMs aufgeteilt, Bindings angepasst. Außerdem: DiffModal-Fehler-State „Diff nicht mehr verfügbar" (Commit-Range ohne aufgezeichnete Commits) und der In-App-Konflikt-Resolver (Hub-Methoden umbenannt).
|
||||
- **UI-Sichtprüfung (neu, 2026-06-19, Rider-Style 3-Pane Merge-Editor):** Echten Konflikt auslösen (Single-Task-Approve mit Konflikt **und** Planning-Unit-Merge) und prüfen: drei Panes (Ours read-only | Result editierbar | Theirs read-only), Konfliktblöcke rot / aufgelöst grün in allen Panes, Inline-Accept `›`/`‹` in den Zwischen-Guttern landen die jeweilige Seite im Result, nur Konfliktregionen im Result editierbar (Stable read-only), synchrones vertikales Scrollen, File-Switcher bei mehreren Dateien, `M conflicts · K resolved`-Readout, Continue erst bei allen Konflikten gelöst, Binär-Guard. **Bekannte Kanten:** (1) Konflikt mit leerer Ours-Seite → Result-Region ist null-lang (Gutter via 1-Zeichen-Probe positioniert, Accept funktioniert; nur Hand-Tippen in die leere Region ist fummelig). (2) Gutter-Y nutzt `TranslatePoint` vom Result-`TextView` — bei sehr hohen Fenstern / großen Scrollständen die Ausrichtung gegenprüfen. (3) Blöcke richten sich nur über Stable-Text aus; nach einem Konflikt mit unterschiedlicher Zeilenzahl je Seite driften nachfolgende Blöcke vertikal (aligned/virtual-space Scroll ist bewusst zurückgestellt).
|
||||
- **Worker-Autostart am Gerät:** Logoff/Logon-Autostart, Update-Pfad, Uninstall entfernt die Startup-`.lnk`.
|
||||
|
||||
## Offene Code-Punkte
|
||||
|
||||
- **Status-Bar Live-Update:** Prüfen, ob `RunNow`-Enable/Disable pro Task-Row bei Connection-Change sauber re-evaluiert. Connection-Status lebt in `IslandsShellViewModel` / `WorkerConnectionModalViewModel` (es gibt keinen `StatusBarViewModel` mehr). Erst messen, dann ggf. fixen. Klein.
|
||||
- **`AgentMcpTools` liegt in `LifecycleMcpTools.cs`** — beim Suchen irreführend; in eigene Datei verschieben. Ein-Minuten-Fix, lohnt keinen Agent-Lauf — beim nächsten Worker-Touch mitnehmen.
|
||||
|
||||
## Nachklapp Refactoring-/Bug-Runde (2026-06-09/10)
|
||||
|
||||
Alle 9 Review-Tasks (5 Refactorings, 4 Bugfixes) sind umgesetzt und gemerged; Details in den Commits. Offen geblieben:
|
||||
|
||||
- **`DetailsIslandViewModel` ist nach dem Split noch 1258 Zeilen** (Ziel war ~800) — die drei Sektions-VMs (AgentSettings, Merge, Prep) sind extrahiert, weitere Extraktion (z.B. ChildOutcomes/Subtasks-Sektion) lohnt erst, wenn die Datei wieder wächst.
|
||||
- **Bewusst zurückgestellt:** WorkerHub-Split nach Concern (~60 Methoden in einer Hub-Klasse). Die Interface-Parität löst das akute Testbarkeits-Problem; ein Hub-Split ist eine größere Architekturentscheidung → erst besprechen.
|
||||
- **Lessons learned:** Der `StartRunningAsync`-Guard-Task hat isoliert grün getestet, aber den Queue-Pfad gebrochen (Picker claimt vor dem Dispatch) — Integrationsfix `74ca2e0`. Bei parallelen Tasks, die denselben Pfad berühren, nach JEDEM Merge-Schwung die volle Suite auf main fahren.
|
||||
|
||||
## Bug-Befunde (Korrektheits-Review 2026-06-09)
|
||||
|
||||
**Plausibel, noch nicht einzeln verifiziert (bei Gelegenheit prüfen):**
|
||||
|
||||
- Cancel eines `WaitingForChildren`-Parents kaskadiert nicht auf laufende/queued Kinder (verwaiste Worktree-Commits).
|
||||
- Ketten-Kaskade stoppt an einem `Idle`-Mittelglied (`OnChildFinishedAsync` prüft `CancelAsync`-Ergebnis nicht) → Rest bleibt `Queued+blocked`.
|
||||
- Delete des *letzten* nicht-terminalen Kindes triggert kein `TryAdvanceParentAsync` → Parent kann in `WaitingForChildren` hängen (FK `SET NULL` rettet nur die Blocked-Kette).
|
||||
- `ContinueMergeAsync` staged per `git add -A` vor dem Konflikt-Check (Marker im Index, Abort danach ggf. unsauber).
|
||||
- `HasChangesAsync` zählt untracked Files → blockiert Merges unnötig (`--untracked-files=no`).
|
||||
- `UnifiedDiffParser`: Pfade mit Leerzeichen / git-gequotete Pfade aus `diff --git` falsch geparst.
|
||||
- Kleinkram: MergePreview-Race bei schnellem Target-Wechsel, CTS-Dispose-Leak in Debounce-Saves, `Environment.CurrentDirectory`-Fallback im Konflikt-Dialog, Doppel-Continue-Fenster im Orchestrator.
|
||||
|
||||
**Geprüft und verworfen (keine Bugs):** ReviewFeedback-„Endlosschleife" (Fallback existiert), Cross-Thread-Crashes im DetailsIslandViewModel (Dispatcher-Marshalling im WorkerClient), Chain-Wedge nach Child-Delete (FK `ON DELETE SET NULL`), `\ No newline`-Parsing.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
# ToDo-App mit autonomem Agent-Worker — Design
|
||||
|
||||
> **Hinweis (2026-06-09):** Historisches Design-Dokument vom Projektstart — bewusst nicht nachgepflegt. Überholt sind insbesondere: die Tag-basierte Queue (entfernt; der Picker nutzt `Status=Queued` + `BlockedByTaskId IS NULL`), `schema.sql` (Schema läuft über EF-Core-Migrations) und das Projektlayout (inzwischen sechs Testprojekte). Lebende Doku sind die `CLAUDE.md`-Dateien pro Projekt.
|
||||
|
||||
## Context
|
||||
|
||||
Ziel: eine persönliche ToDo-App als Desktop-Anwendung, in der mehrere Listen verwaltet werden können. Ein Teil der Tasks soll autonom von Claude abgearbeitet werden (z.B. Recherche, Code-Aufgaben, Notizen-Verarbeitung). Die Autonomie läuft in einem getrennten Hintergrund-Prozess, damit die UI davon entkoppelt bleibt.
|
||||
|
||||
@@ -0,0 +1,432 @@
|
||||
# Git Merge/Review — Shared Foundation + Layer A Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Build the shared worker conflict contract (so parallel Layer B/C sessions branch from frozen interfaces) and rework the Git tab into a single Approve+merge cockpit.
|
||||
|
||||
**Architecture:** Phase 0 adds the conflict-resolution contract to `IWorkerClient`/`WorkerClient` (real `_hub.InvokeAsync` bodies — the worker hub methods are implemented later by Layer C; calls simply fail at runtime until then) plus client-side DTOs and test-fake updates, then commits + pushes so B and C branch from it. Phase A reworks `WorkConsole.axaml`'s Git tab and routes single-task merge/approve conflicts into a `RequestConflictResolution` seam (wired to Layer C's resolver by the integrator at merge time).
|
||||
|
||||
**Tech Stack:** .NET 8, Avalonia 12 (Fluent), CommunityToolkit.Mvvm, SignalR, xUnit. Build individual csproj with `-c Release` (`.slnx` needs .NET 9; a running Worker locks `Debug`).
|
||||
|
||||
**Reference spec:** `docs/superpowers/specs/2026-06-05-git-merge-review-rework-design.md`
|
||||
|
||||
**Note on the canonical diff renderer:** the unified diff model/control already exists — `DiffFileViewModel`/`DiffLineViewModel`/`UnifiedDiffParser` (in `src/ClaudeDo.Ui/ViewModels/Modals/`) rendered by `DiffLinesView` (`src/ClaudeDo.Ui/Views/Controls/DiffLinesView.axaml`). `DiffModalView` and `PlanningDiffView` already use it. So "consolidate diff renderers" for this scope is just verifying that (Task A.3); migrating `WorktreeModalView`'s bespoke diff onto `DiffLinesView` is Layer B's job.
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
**Phase 0 (foundation — pushed before B/C branch):**
|
||||
- Modify `src/ClaudeDo.Ui/Services/Interfaces/IWorkerClient.cs` — 5 new method signatures.
|
||||
- Modify `src/ClaudeDo.Ui/Services/WorkerClient.cs` — 5 `InvokeAsync` bodies + 3 new DTO records.
|
||||
- Modify `tests/ClaudeDo.Ui.Tests/StubWorkerClient.cs` — 5 new `virtual` no-op methods.
|
||||
- Modify `tests/ClaudeDo.Worker.Tests/UiVm/TasksIslandViewModelPlanningTests.cs` — 5 new methods on `FakeWorkerClient`.
|
||||
|
||||
**Phase A (Layer A — this session, after foundation commit):**
|
||||
- Modify `src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs` — `RequestConflictResolution` seam; route Approve/Merge conflicts into it.
|
||||
- Modify `src/ClaudeDo.Ui/Views/Islands/Detail/WorkConsole.axaml` — fuse REVIEW + MERGE sections into one cockpit block.
|
||||
- Test: `tests/ClaudeDo.Ui.Tests/ViewModels/DetailsIslandPlanningTests.cs` (or a sibling test file in the same folder).
|
||||
|
||||
---
|
||||
|
||||
## Phase 0 — Shared Foundation
|
||||
|
||||
### Task 0.1: Add the conflict contract (interface + client + DTOs)
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/ClaudeDo.Ui/Services/Interfaces/IWorkerClient.cs`
|
||||
- Modify: `src/ClaudeDo.Ui/Services/WorkerClient.cs`
|
||||
|
||||
- [ ] **Step 1: Add the 5 method signatures to `IWorkerClient`**
|
||||
|
||||
In `src/ClaudeDo.Ui/Services/Interfaces/IWorkerClient.cs`, after the existing
|
||||
`Task CancelReviewAsync(string taskId);` line (line 45), add:
|
||||
|
||||
```csharp
|
||||
// ── Conflict resolution (worker hub side implemented by Layer C) ──
|
||||
Task<MergeResultDto> StartConflictMergeAsync(string taskId, string targetBranch);
|
||||
Task<MergeConflictsDto> GetMergeConflictsAsync(string taskId);
|
||||
Task WriteConflictResolutionAsync(string taskId, string path, string resolvedContent);
|
||||
Task<MergeResultDto> ContinueMergeAsync(string taskId);
|
||||
Task AbortMergeAsync(string taskId);
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add the 3 DTO records to `WorkerClient.cs`**
|
||||
|
||||
In `src/ClaudeDo.Ui/Services/WorkerClient.cs`, immediately after line 534
|
||||
(`public record MergeTargetsDto(...)`), add:
|
||||
|
||||
```csharp
|
||||
public record MergeConflictsDto(string TaskId, IReadOnlyList<ConflictFileDto> Files);
|
||||
public record ConflictFileDto(string Path, IReadOnlyList<ConflictHunkDto> Hunks);
|
||||
public record ConflictHunkDto(string Ours, string Theirs, string? Base);
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Add the 5 client method bodies to `WorkerClient.cs`**
|
||||
|
||||
In `src/ClaudeDo.Ui/Services/WorkerClient.cs`, right after the `MergeTaskAsync`
|
||||
method (ends at line 270), add:
|
||||
|
||||
```csharp
|
||||
public Task<MergeResultDto> StartConflictMergeAsync(string taskId, string targetBranch)
|
||||
=> _hub.InvokeAsync<MergeResultDto>("StartConflictMerge", taskId, targetBranch);
|
||||
|
||||
public Task<MergeConflictsDto> GetMergeConflictsAsync(string taskId)
|
||||
=> _hub.InvokeAsync<MergeConflictsDto>("GetMergeConflicts", taskId);
|
||||
|
||||
public Task WriteConflictResolutionAsync(string taskId, string path, string resolvedContent)
|
||||
=> _hub.InvokeAsync("WriteConflictResolution", taskId, path, resolvedContent);
|
||||
|
||||
public Task<MergeResultDto> ContinueMergeAsync(string taskId)
|
||||
=> _hub.InvokeAsync<MergeResultDto>("ContinueMerge", taskId);
|
||||
|
||||
public Task AbortMergeAsync(string taskId)
|
||||
=> _hub.InvokeAsync("AbortMerge", taskId);
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Build the UI project**
|
||||
|
||||
Run: `dotnet build src/ClaudeDo.Ui/ClaudeDo.Ui.csproj -c Release`
|
||||
Expected: build FAILS — the two test projects won't compile yet, but the UI project
|
||||
itself should succeed. If the UI project reports "does not implement interface member"
|
||||
it means a body is missing; fix before continuing. (Test projects are fixed in 0.2.)
|
||||
|
||||
### Task 0.2: Update the hand-rolled test fakes
|
||||
|
||||
**Files:**
|
||||
- Modify: `tests/ClaudeDo.Ui.Tests/StubWorkerClient.cs`
|
||||
- Modify: `tests/ClaudeDo.Worker.Tests/UiVm/TasksIslandViewModelPlanningTests.cs`
|
||||
|
||||
- [ ] **Step 1: Add 5 virtual no-ops to `StubWorkerClient`**
|
||||
|
||||
In `tests/ClaudeDo.Ui.Tests/StubWorkerClient.cs`, after the `MergeTaskAsync` override
|
||||
(line 57), add:
|
||||
|
||||
```csharp
|
||||
public virtual Task<MergeResultDto> StartConflictMergeAsync(string taskId, string targetBranch) => Task.FromResult(new MergeResultDto("conflict", System.Array.Empty<string>(), null));
|
||||
public virtual Task<MergeConflictsDto> GetMergeConflictsAsync(string taskId) => Task.FromResult(new MergeConflictsDto(taskId, System.Array.Empty<ConflictFileDto>()));
|
||||
public virtual Task WriteConflictResolutionAsync(string taskId, string path, string resolvedContent) => Task.CompletedTask;
|
||||
public virtual Task<MergeResultDto> ContinueMergeAsync(string taskId) => Task.FromResult(new MergeResultDto("merged", System.Array.Empty<string>(), null));
|
||||
public virtual Task AbortMergeAsync(string taskId) => Task.CompletedTask;
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add 5 methods to `FakeWorkerClient`**
|
||||
|
||||
In `tests/ClaudeDo.Worker.Tests/UiVm/TasksIslandViewModelPlanningTests.cs`, after the
|
||||
`MergeTaskAsync` method (line 47), add:
|
||||
|
||||
```csharp
|
||||
public Task<MergeResultDto> StartConflictMergeAsync(string taskId, string targetBranch) => Task.FromResult(new MergeResultDto("conflict", System.Array.Empty<string>(), null));
|
||||
public Task<MergeConflictsDto> GetMergeConflictsAsync(string taskId) => Task.FromResult(new MergeConflictsDto(taskId, System.Array.Empty<ConflictFileDto>()));
|
||||
public Task WriteConflictResolutionAsync(string taskId, string path, string resolvedContent) => Task.CompletedTask;
|
||||
public Task<MergeResultDto> ContinueMergeAsync(string taskId) => Task.FromResult(new MergeResultDto("merged", System.Array.Empty<string>(), null));
|
||||
public Task AbortMergeAsync(string taskId) => Task.CompletedTask;
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Build both test projects**
|
||||
|
||||
Run: `dotnet build tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj -c Release && dotnet build tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release`
|
||||
Expected: both BUILD succeed.
|
||||
|
||||
- [ ] **Step 4: Run the UI test suite to confirm green baseline**
|
||||
|
||||
Run: `dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj -c Release`
|
||||
Expected: PASS (no behavior changed yet).
|
||||
|
||||
### Task 0.3: Commit and push the foundation
|
||||
|
||||
- [ ] **Step 1: Commit**
|
||||
|
||||
```bash
|
||||
git add src/ClaudeDo.Ui/Services/Interfaces/IWorkerClient.cs src/ClaudeDo.Ui/Services/WorkerClient.cs tests/ClaudeDo.Ui.Tests/StubWorkerClient.cs tests/ClaudeDo.Worker.Tests/UiVm/TasksIslandViewModelPlanningTests.cs
|
||||
git commit -m "feat(ui): add conflict-resolution worker contract (foundation for merge rework)"
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Push so Layer B/C can branch from this commit**
|
||||
|
||||
Run: `git push`
|
||||
Expected: pushed to `main`. (First push to git.kuns.dev may fail auth — retry once.)
|
||||
**This commit is the branch point for the Layer B and Layer C kickoff prompts.**
|
||||
|
||||
---
|
||||
|
||||
## Phase A — Layer A Review/Merge Cockpit
|
||||
|
||||
### Task A.1: Conflict-resolution seam + route Approve/Merge conflicts into it (TDD)
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs`
|
||||
- Test: `tests/ClaudeDo.Ui.Tests/ViewModels/DetailsIslandConflictSeamTests.cs` (new)
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
Create `tests/ClaudeDo.Ui.Tests/ViewModels/DetailsIslandConflictSeamTests.cs`. Mirror
|
||||
the VM-construction harness used in
|
||||
`tests/ClaudeDo.Ui.Tests/ViewModels/DetailsIslandPlanningTests.cs` (same folder) —
|
||||
construct `DetailsIslandViewModel` exactly as that file does, including its
|
||||
`StubWorkerClient` subclass pattern. The test:
|
||||
|
||||
```csharp
|
||||
[Fact]
|
||||
public async Task ApproveReview_OnConflict_InvokesConflictResolutionSeam()
|
||||
{
|
||||
string? resolvedTaskId = null;
|
||||
string? resolvedTarget = null;
|
||||
|
||||
// Construct the VM as in DetailsIslandPlanningTests, with a worker stub whose
|
||||
// ApproveReviewAsync returns a conflict result:
|
||||
// public override Task<MergeResultDto?> ApproveReviewAsync(string id, string target)
|
||||
// => Task.FromResult<MergeResultDto?>(new MergeResultDto("conflict", new[]{"a.cs"}, null));
|
||||
var vm = CreateVm(/* worker stub above */);
|
||||
vm.RequestConflictResolution = (taskId, target) =>
|
||||
{
|
||||
resolvedTaskId = taskId; resolvedTarget = target;
|
||||
return System.Threading.Tasks.Task.CompletedTask;
|
||||
};
|
||||
// assign a task in WaitingForReview + a SelectedMergeTarget = "main" via the same
|
||||
// helpers DetailsIslandPlanningTests uses.
|
||||
|
||||
await vm.ApproveReviewCommand.ExecuteAsync(null);
|
||||
|
||||
Assert.Equal(/* the seeded task id */, resolvedTaskId);
|
||||
Assert.Equal("main", resolvedTarget);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run the test to verify it fails**
|
||||
|
||||
Run: `dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj -c Release --filter ApproveReview_OnConflict_InvokesConflictResolutionSeam`
|
||||
Expected: FAIL — `RequestConflictResolution` property does not exist (compile error).
|
||||
|
||||
- [ ] **Step 3: Add the seam property**
|
||||
|
||||
In `src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs`, beside the other
|
||||
view-wired delegates (`ShowDiffModal`, `ShowMergeModal` around line 387–390), add:
|
||||
|
||||
```csharp
|
||||
// Invoked when a single-task merge/approve hits a conflict. Wired by the
|
||||
// integrator to Layer C's conflict resolver. Args: (taskId, targetBranch).
|
||||
public Func<string, string, System.Threading.Tasks.Task>? RequestConflictResolution { get; set; }
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Route the Approve conflict branch into the seam**
|
||||
|
||||
In `ApproveReviewAsync` (around line 1453), replace the conflict branch body so it
|
||||
prefers the seam, falling back to the current preview-text behavior:
|
||||
|
||||
```csharp
|
||||
var result = await _worker.ApproveReviewAsync(Task.Id, SelectedMergeTarget ?? "");
|
||||
if (result?.Status == "conflict")
|
||||
{
|
||||
if (RequestConflictResolution is not null)
|
||||
{
|
||||
await RequestConflictResolution(Task.Id, SelectedMergeTarget ?? "");
|
||||
}
|
||||
else
|
||||
{
|
||||
var (text, _, _) = MergePreviewPresenter.Describe(
|
||||
new MergePreviewDto("conflict", result.ConflictFiles, 0));
|
||||
MergePreviewText = text; MergeIsClean = false; MergeIsConflict = true;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Route the manual Merge conflict branch into the seam**
|
||||
|
||||
In `MergeAsync` (around line 1170), apply the same pattern to its conflict branch:
|
||||
|
||||
```csharp
|
||||
var result = await _worker.MergeTaskAsync(Task.Id, SelectedMergeTarget ?? "", false, "Merge task");
|
||||
if (result.Status == "conflict")
|
||||
{
|
||||
if (RequestConflictResolution is not null)
|
||||
{
|
||||
await RequestConflictResolution(Task.Id, SelectedMergeTarget ?? "");
|
||||
}
|
||||
else
|
||||
{
|
||||
var (text, _, _) = MergePreviewPresenter.Describe(
|
||||
new MergePreviewDto("conflict", result.ConflictFiles, 0));
|
||||
MergePreviewText = text; MergeIsClean = false; MergeIsConflict = true;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
await RefreshMergePreviewAsync();
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Run the test to verify it passes**
|
||||
|
||||
Run: `dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj -c Release --filter ApproveReview_OnConflict_InvokesConflictResolutionSeam`
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 7: Run the full UI suite (no regressions)**
|
||||
|
||||
Run: `dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj -c Release`
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 8: Commit**
|
||||
|
||||
```bash
|
||||
git add src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs tests/ClaudeDo.Ui.Tests/ViewModels/DetailsIslandConflictSeamTests.cs
|
||||
git commit -m "feat(ui): route single-task merge conflicts into a resolution seam"
|
||||
```
|
||||
|
||||
### Task A.2: Fuse the Git tab into one Approve+merge cockpit
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/ClaudeDo.Ui/Views/Islands/Detail/WorkConsole.axaml`
|
||||
|
||||
- [ ] **Step 1: Replace the two Git-tab sections with one cockpit block**
|
||||
|
||||
In `src/ClaudeDo.Ui/Views/Islands/Detail/WorkConsole.axaml`, replace the entire Git
|
||||
`ScrollViewer` body (lines 255–313 — the `<!-- Git: ... -->` block containing the
|
||||
separate `REVIEW` `StackPanel` and the `MERGE & WORKTREE` `StackPanel`) with a single
|
||||
cockpit where Approve sits with the merge target/preview/actions. Keep the existing
|
||||
control class names (`section-label`, `field-label`, `btn`, `btn accent`, `meta`) and
|
||||
the existing bindings (`SelectedMergeTarget`, `MergeTargetBranches`, `MergePreviewText`,
|
||||
`MergeIsClean`, `MergeIsConflict`, `ShowMergePreviewMuted`, `OpenDiffCommand`,
|
||||
`ApproveReviewCommand`, `MergeCommand`, `ShowSingleMerge`, `OpenWorktreeCommand`,
|
||||
`ReviewCombinedDiffCommand`, `MergeAllCommand`, `CanMergeAll`, `MergeAllDisabledReason`,
|
||||
`MergeAllError`):
|
||||
|
||||
```xml
|
||||
<!-- Git: one Approve + merge cockpit -->
|
||||
<ScrollViewer IsVisible="{Binding IsGitTab}" Padding="14,10">
|
||||
<StackPanel Spacing="12" IsVisible="{Binding ShowMergeSection}">
|
||||
<TextBlock Classes="section-label" Text="MERGE" />
|
||||
|
||||
<StackPanel Spacing="4">
|
||||
<TextBlock Classes="field-label" Text="Target branch" />
|
||||
<ComboBox ItemsSource="{Binding MergeTargetBranches}"
|
||||
SelectedItem="{Binding SelectedMergeTarget, Mode=TwoWay}"
|
||||
HorizontalAlignment="Stretch" />
|
||||
</StackPanel>
|
||||
|
||||
<StackPanel Spacing="0">
|
||||
<TextBlock Classes="meta" Text="{Binding MergePreviewText}" TextWrapping="Wrap"
|
||||
Foreground="{DynamicResource MossBrush}"
|
||||
IsVisible="{Binding MergeIsClean}" />
|
||||
<TextBlock Classes="meta" Text="{Binding MergePreviewText}" TextWrapping="Wrap"
|
||||
Foreground="{DynamicResource BloodBrush}"
|
||||
IsVisible="{Binding MergeIsConflict}" />
|
||||
<TextBlock Classes="meta" Text="{Binding MergePreviewText}" TextWrapping="Wrap"
|
||||
Foreground="{DynamicResource TextMuteBrush}"
|
||||
IsVisible="{Binding ShowMergePreviewMuted}" />
|
||||
</StackPanel>
|
||||
|
||||
<!-- Primary action: Approve flows straight into the merge.
|
||||
Approve is the review-gated path; the plain Merge button covers
|
||||
already-reviewed / kept worktrees. -->
|
||||
<WrapPanel Orientation="Horizontal">
|
||||
<Button Classes="btn accent" Content="Approve & Merge" Margin="0,0,8,8"
|
||||
Command="{Binding ApproveReviewCommand}"
|
||||
IsVisible="{Binding IsWaitingForReview}" />
|
||||
<Button Classes="btn accent" Content="Merge" Margin="0,0,8,8"
|
||||
Command="{Binding MergeCommand}"
|
||||
IsVisible="{Binding ShowSingleMerge}" />
|
||||
<Button Classes="btn" Content="Open Diff" Margin="0,0,8,8"
|
||||
Command="{Binding OpenDiffCommand}" />
|
||||
<Button Classes="btn" Margin="0,0,8,8"
|
||||
Command="{Binding OpenWorktreeCommand}">
|
||||
<StackPanel Orientation="Horizontal" Spacing="5">
|
||||
<TextBlock Text="Worktree" />
|
||||
<PathIcon Data="{StaticResource Icon.ArrowOut}" Width="11" Height="11" />
|
||||
</StackPanel>
|
||||
</Button>
|
||||
<Button Classes="btn" Content="Review Combined Diff" Margin="0,0,8,8"
|
||||
Command="{Binding ReviewCombinedDiffCommand}" />
|
||||
<Button Classes="btn accent" Content="Merge All Subtasks" Margin="0,0,0,8"
|
||||
Command="{Binding MergeAllCommand}"
|
||||
IsEnabled="{Binding CanMergeAll}"
|
||||
ToolTip.Tip="{Binding MergeAllDisabledReason}" />
|
||||
</WrapPanel>
|
||||
|
||||
<TextBlock Text="{Binding MergeAllError}"
|
||||
Foreground="{DynamicResource BloodBrush}"
|
||||
TextWrapping="Wrap"
|
||||
IsVisible="{Binding MergeAllError,
|
||||
Converter={x:Static ObjectConverters.IsNotNull}}" />
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
```
|
||||
|
||||
Note: the cockpit now shows whenever `ShowMergeSection` is true. `ShowMergeSection`
|
||||
(DetailsIslandViewModel line 161) must be true while `IsWaitingForReview` so the
|
||||
Approve button appears. Check its expression in Step 2.
|
||||
|
||||
- [ ] **Step 2: Verify `ShowMergeSection` covers the review state**
|
||||
|
||||
Read `src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs` line 161. If
|
||||
`ShowMergeSection` is false while `IsWaitingForReview` (e.g. it requires a non-review
|
||||
state), widen it to also be true when `IsWaitingForReview && WorktreePath != null`, and
|
||||
ensure `OnPropertyChanged(nameof(ShowMergeSection))` already fires on the relevant state
|
||||
transitions (it is notified via `NotifySessionSections`). Make the minimal change needed
|
||||
so the Approve button is visible in review state. If it already covers review, change
|
||||
nothing.
|
||||
|
||||
- [ ] **Step 3: Build the app project**
|
||||
|
||||
Run: `dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj -c Release`
|
||||
Expected: BUILD succeeds (pulls in Ui + Data).
|
||||
|
||||
- [ ] **Step 4: Visual verification (manual — flag for the user)**
|
||||
|
||||
This is an AXAML layout change with no automated coverage. Launch the app, open a task
|
||||
in `WaitingForReview`, open the Git tab, and confirm: the single MERGE block shows the
|
||||
target combo, the colored preview line, an "Approve & Merge" button (review state), and
|
||||
the diff/worktree/combined/merge-all actions. **Explicitly tell the user this needs a
|
||||
visual pass — do not claim it works without running it.**
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/ClaudeDo.Ui/Views/Islands/Detail/WorkConsole.axaml src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs
|
||||
git commit -m "feat(ui): fuse git tab into one approve+merge cockpit"
|
||||
```
|
||||
|
||||
### Task A.3: Verify diff-renderer consolidation
|
||||
|
||||
**Files:** none modified (verification only).
|
||||
|
||||
- [ ] **Step 1: Confirm DiffModal + Planning already use the canonical renderer**
|
||||
|
||||
Run: `rg -l "DiffLinesView" src/ClaudeDo.Ui/Views`
|
||||
Expected: matches in `Modals/DiffModalView.axaml` and `Planning/PlanningDiffView.axaml`.
|
||||
If `PlanningDiffView.axaml` does NOT use `DiffLinesView`, change its diff `ItemsControl`
|
||||
to a `<controls:DiffLinesView Lines="{Binding SelectedFile.Lines}" />` (matching
|
||||
`DiffModalView.axaml`'s usage) and rebuild the App project. If both already use it, this
|
||||
task is a no-op — record that and move on. (`WorktreeModalView`'s bespoke diff is
|
||||
intentionally left for Layer B.)
|
||||
|
||||
---
|
||||
|
||||
## Self-Review
|
||||
|
||||
- **Spec coverage:** Foundation contract (spec §"Frozen worker conflict contract") →
|
||||
Task 0.1. Test fakes (spec parallel-boundaries row) → Task 0.2. Branch point (spec
|
||||
§"built & pushed this session") → Task 0.3. Layer A cockpit + Approve/merge flow
|
||||
together (spec §"Layer A") → Task A.2. Single-task approve-on-conflict opens resolver
|
||||
via seam (spec §"Layer A" + §"integration seams") → Task A.1. Diff consolidation
|
||||
(spec §"One diff model") → Task A.3. Output-footer feedback unchanged → not touched
|
||||
(correct). No spec requirement left unmapped for this session's scope.
|
||||
- **Placeholder scan:** none — every code step has concrete code; the only "mirror the
|
||||
existing harness" reference (Task A.1 Step 1) points at a real file with a working
|
||||
pattern, not a TODO.
|
||||
- **Type consistency:** `MergeConflictsDto`/`ConflictFileDto`/`ConflictHunkDto` and the
|
||||
5 method names match between `IWorkerClient` (0.1 Step 1), `WorkerClient` (0.1 Steps
|
||||
2–3), and both fakes (0.2). The seam `RequestConflictResolution` is
|
||||
`Func<string,string,Task>?` everywhere (A.1 Steps 1, 3–5). DTO field names match the
|
||||
spec.
|
||||
|
||||
---
|
||||
|
||||
## Integration notes (for the integrator merging A + B + C)
|
||||
|
||||
- Wire `DetailsIslandViewModel.RequestConflictResolution` and Layer B's equivalent
|
||||
callback to Layer C's `ConflictResolverViewModel` factory + `ShowConflictResolver`
|
||||
dialog delegate.
|
||||
- Layer C implements the worker hub methods `StartConflictMerge`, `GetMergeConflicts`,
|
||||
`WriteConflictResolution`, `ContinueMerge`, `AbortMerge`; the client side from Task
|
||||
0.1 already calls them by name.
|
||||
139
docs/superpowers/plans/2026-06-05-git-merge-review-prompts.md
Normal file
139
docs/superpowers/plans/2026-06-05-git-merge-review-prompts.md
Normal file
@@ -0,0 +1,139 @@
|
||||
# Git Merge/Review Rework — Parallel Kickoff Prompts (Layer B & Layer C)
|
||||
|
||||
These are self-contained prompts to paste into two fresh ClaudeDo sessions, each in its
|
||||
own git worktree, run **in parallel** with the main session's Layer A work.
|
||||
|
||||
**Prerequisite — branch point:** Both sessions must branch from `main` **at or after**
|
||||
the foundation commit `feat(ui): add conflict-resolution worker contract (foundation for
|
||||
merge rework)` (Phase 0, Task 0.3 of
|
||||
`docs/superpowers/plans/2026-06-05-git-merge-review-foundation-layerA.md`). That commit
|
||||
adds the frozen `IWorkerClient` conflict contract both layers rely on. Do not start B/C
|
||||
until that commit is pushed.
|
||||
|
||||
**Integration:** Neither session pushes to `main` or merges. Each leaves its branch/
|
||||
worktree for the orchestrator (the main session) to review and merge.
|
||||
|
||||
Design reference for both: `docs/superpowers/specs/2026-06-05-git-merge-review-rework-design.md`
|
||||
|
||||
---
|
||||
|
||||
## Layer B — Multi-worktree merge cockpit
|
||||
|
||||
```
|
||||
We're reworking ClaudeDo's merge/review UX. Your job is Layer B: a multi-worktree merge
|
||||
cockpit. The overall design is in docs/superpowers/specs/2026-06-05-git-merge-review-rework-design.md
|
||||
(read the "Layer B" section and "Parallel boundaries" table first). A shared foundation
|
||||
commit ("add conflict-resolution worker contract") is already on main — branch from it.
|
||||
|
||||
First, create an isolated worktree for this work (use the superpowers:using-git-worktrees
|
||||
skill). Then write a plan (superpowers:writing-plans) for just Layer B and implement it
|
||||
with superpowers:subagent-driven-development (sonnet subagents, TDD, commit per task).
|
||||
|
||||
Scope:
|
||||
- Rework WorktreesOverviewModalView + WorktreesOverviewModalViewModel into a batch-merge
|
||||
cockpit: list mergeable worktrees, multi-select N, pick ONE target branch, "Merge all".
|
||||
- Skip-and-continue: loop the EXISTING IWorkerClient.MergeTaskAsync(taskId, target,
|
||||
removeWorktree:false, msg) over the selected tasks. Clean ones merge; conflicting ones
|
||||
(MergeTaskAsync returns Status=="conflict", auto-aborts leaving the tree clean) are
|
||||
collected into a "needs resolution" list shown with live progress.
|
||||
- Each conflict row gets a "Resolve" button that invokes a seam:
|
||||
public Func<string, string, Task>? RequestConflictResolution { get; set; } // (taskId, targetBranch)
|
||||
Define this callback property on the cockpit VM; leave it unwired (the orchestrator
|
||||
wires it to Layer C's resolver at merge time). Do NOT reference any ConflictResolver
|
||||
type.
|
||||
- Migrate WorktreeModalView's bespoke inline diff onto the canonical DiffLinesView
|
||||
control (src/ClaudeDo.Ui/Views/Controls/DiffLinesView.axaml) using DiffFileViewModel/
|
||||
DiffLineViewModel/UnifiedDiffParser (src/ClaudeDo.Ui/ViewModels/Modals/). This removes
|
||||
the last duplicate diff renderer.
|
||||
|
||||
Reuse these existing IWorkerClient methods (already implemented): MergeTaskAsync,
|
||||
GetMergeTargetsAsync, GetWorktreesOverviewAsync, SetWorktreeStateAsync,
|
||||
CleanupFinishedWorktreesAsync, ForceRemoveWorktreeAsync.
|
||||
|
||||
Do NOT touch (other layers own them): any worker-side files (WorkerHub, TaskMergeService,
|
||||
GitService), IWorkerClient.cs / WorkerClient.cs, WorkConsole.axaml,
|
||||
DetailsIslandViewModel.cs, or create the ConflictResolver UI.
|
||||
|
||||
Build with: dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj -c Release (a running
|
||||
Worker locks Debug — use Release). Keep locales/en.json and de.json keys in parity if you
|
||||
add any. If you change IWorkerClient (you shouldn't need to), update the hand-rolled fakes
|
||||
in tests/ClaudeDo.Ui.Tests/StubWorkerClient.cs and
|
||||
tests/ClaudeDo.Worker.Tests/UiVm/TasksIslandViewModelPlanningTests.cs. No tests that spawn
|
||||
the real claude CLI.
|
||||
|
||||
Commit per task with Conventional Commits. Do NOT push to main and do NOT merge — leave
|
||||
your worktree/branch for the orchestrator. Flag any AXAML layout for visual verification
|
||||
rather than claiming it works.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Layer C — Inline conflict resolver
|
||||
|
||||
```
|
||||
We're reworking ClaudeDo's merge/review UX. Your job is Layer C: an in-app, VSCode-style
|
||||
inline conflict resolver, plus the worker plumbing it needs. The overall design is in
|
||||
docs/superpowers/specs/2026-06-05-git-merge-review-rework-design.md (read the "Layer C",
|
||||
"Frozen worker conflict contract", and "Parallel boundaries" sections first). A shared
|
||||
foundation commit ("add conflict-resolution worker contract") is already on main — branch
|
||||
from it. That commit already wired the CLIENT side (IWorkerClient + WorkerClient call
|
||||
these hub methods by name); your job includes implementing the matching WORKER hub methods.
|
||||
|
||||
First, create an isolated worktree (superpowers:using-git-worktrees). Then write a plan
|
||||
(superpowers:writing-plans) for Layer C and implement it with
|
||||
superpowers:subagent-driven-development (sonnet subagents, TDD, commit per task).
|
||||
|
||||
Worker side — implement these 5 hub methods in WorkerHub (names/params/returns MUST match
|
||||
the client calls already shipped in the foundation):
|
||||
- StartConflictMerge(string taskId, string targetBranch) -> MergeResultDto
|
||||
Calls TaskMergeService.MergeAsync with leaveConflictsInTree:true (the overload/flag
|
||||
already exists — used today by PlanningMergeOrchestrator). Leaves .git/MERGE_HEAD in
|
||||
the list's WorkingDir, returns Status="conflict" + conflict file list.
|
||||
- GetMergeConflicts(string taskId) -> MergeConflictsDto
|
||||
For each conflicted file (git diff --name-only --diff-filter=U), read ours/theirs/base
|
||||
via `git show :2:<path>` / `:3:<path>` / `:1:<path>`. Add GitService helpers as needed.
|
||||
- WriteConflictResolution(string taskId, string path, string resolvedContent) -> void
|
||||
Write resolvedContent to the file in WorkingDir and `git add` it.
|
||||
- ContinueMerge(string taskId) -> MergeResultDto
|
||||
Wrap the EXISTING TaskMergeService.ContinueMergeAsync (git add -A → re-check
|
||||
diff --diff-filter=U → git commit). Currently service-level only; expose it on the hub.
|
||||
- AbortMerge(string taskId) -> void
|
||||
Wrap the EXISTING TaskMergeService.AbortMergeAsync (git merge --abort).
|
||||
|
||||
Define worker-side DTO records that serialize identically to the client records already in
|
||||
WorkerClient.cs:
|
||||
MergeConflictsDto(string TaskId, IReadOnlyList<ConflictFileDto> Files)
|
||||
ConflictFileDto(string Path, IReadOnlyList<ConflictHunkDto> Hunks)
|
||||
ConflictHunkDto(string Ours, string Theirs, string? Base)
|
||||
(place beside the other hub DTOs in WorkerHub.cs). MergeResultDto already exists.
|
||||
|
||||
UI side — new files only:
|
||||
- ConflictResolverViewModel + ConflictResolverView. On open: StartConflictMergeAsync then
|
||||
GetMergeConflictsAsync(taskId). Per conflict hunk show ours vs theirs stacked with
|
||||
buttons Accept Current / Accept Incoming / Accept Both / Edit manually, plus a free-text
|
||||
box for the merged result of that hunk. Use the UI conflict model from the design
|
||||
(ConflictFile { Path, Hunks[] }, ConflictHunk { Ours, Theirs, Base, Resolution }) —
|
||||
shape it so a future 3-way pane needs no model change.
|
||||
- When every file is resolved: WriteConflictResolutionAsync per file, then
|
||||
ContinueMergeAsync(taskId) (Status "merged" closes; "conflict" means not fully resolved,
|
||||
stay open). AbortMergeAsync(taskId) cancels.
|
||||
- Expose a factory Func<string, ConflictResolverViewModel> and a
|
||||
Func<ConflictResolverViewModel, Task> ShowConflictResolver dialog delegate for the
|
||||
orchestrator to wire to Layer A/B's RequestConflictResolution(taskId, target) seams.
|
||||
|
||||
Do NOT touch (other layers own them): WorkerClient.cs, IWorkerClient.cs (already wired),
|
||||
WorkConsole.axaml, DetailsIslandViewModel.cs, WorktreesOverviewModalView/VM. You WILL need
|
||||
to add the 5 worker hub methods + GitService conflict reads.
|
||||
|
||||
Tests: add worker tests for the conflict reads / continue / abort using real SQLite + real
|
||||
git (follow existing GitService/TaskMergeService test patterns). NEVER spawn the real
|
||||
claude CLI. If you change IWorkerClient (you should NOT — client is frozen), update the
|
||||
fakes in both test projects.
|
||||
|
||||
Build with: dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj -c Release and
|
||||
dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj -c Release (a running Worker locks
|
||||
Debug). Keep locales/en.json and de.json in parity for any new UI strings.
|
||||
|
||||
Commit per task with Conventional Commits. Do NOT push to main and do NOT merge — leave
|
||||
your worktree/branch for the orchestrator. Flag the resolver UI for visual verification.
|
||||
```
|
||||
@@ -0,0 +1,920 @@
|
||||
# Layer C — Inline Conflict Resolver Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Build the worker-side conflict plumbing (5 frozen hub methods + GitService reads) and a VSCode-style in-app inline conflict resolver UI for ClaudeDo's merge rework.
|
||||
|
||||
**Architecture:** The worker performs a real merge that leaves conflicts in the list's working tree (`leaveConflictsInTree:true`), exposes ours/theirs/base per conflicted file via `git show :2:/:3:/:1:`, accepts written resolutions, and finishes via the existing `ContinueMergeAsync`/`AbortMergeAsync`. The UI presents each conflicted file's hunk with Accept Current/Incoming/Both/Edit-manually controls plus a free-text merged box, then writes resolutions and continues.
|
||||
|
||||
**Tech Stack:** .NET 8, ASP.NET Core SignalR (WorkerHub), EF Core/SQLite, Avalonia MVVM (CommunityToolkit), xUnit + real git/SQLite fixtures.
|
||||
|
||||
**Frozen client contract (already shipped in foundation commit `2dfc455`, DO NOT edit):**
|
||||
- `IWorkerClient` / `WorkerClient.cs` already call hub methods by name: `StartConflictMerge`, `GetMergeConflicts`, `WriteConflictResolution`, `ContinueMerge`, `AbortMerge`.
|
||||
- Client DTOs already exist in `WorkerClient.cs`: `MergeConflictsDto(string TaskId, IReadOnlyList<ConflictFileDto> Files)`, `ConflictFileDto(string Path, IReadOnlyList<ConflictHunkDto> Hunks)`, `ConflictHunkDto(string Ours, string Theirs, string? Base)`, plus existing `MergeResultDto(string Status, IReadOnlyList<string> ConflictFiles, string? ErrorMessage)`.
|
||||
- Worker-side DTOs must serialize identically (same record shape) and live in `WorkerHub.cs`.
|
||||
|
||||
**Do NOT touch:** `WorkerClient.cs`, `Interfaces/IWorkerClient.cs`, `WorkConsole.axaml`, `DetailsIslandViewModel.cs`, `WorktreesOverviewModalView/VM`, `WorktreeModalView`. Test fakes for `IWorkerClient` already implement the 5 methods as no-op stubs (`StubWorkerClient` is `virtual` in Ui.Tests) — subclass/override, never edit the interface.
|
||||
|
||||
**Build/test commands (.NET 8 — running Worker locks `Debug`, always `-c Release`):**
|
||||
```bash
|
||||
dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj -c Release
|
||||
dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj -c Release
|
||||
dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release
|
||||
dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj -c Release
|
||||
dotnet test tests/ClaudeDo.Localization.Tests/ClaudeDo.Localization.Tests.csproj -c Release
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
**Worker / Data (create + modify):**
|
||||
- Modify `src/ClaudeDo.Data/Git/GitService.cs` — add `ShowStageAsync` (untrimmed blob read) + `AddPathAsync`; add `trimOutput` param to `RunGitAsync`.
|
||||
- Modify `src/ClaudeDo.Worker/Lifecycle/TaskMergeService.cs` — add records `MergeConflicts`/`ConflictFileContent`; add `GetConflictsAsync` + `WriteResolutionAsync`.
|
||||
- Modify `src/ClaudeDo.Worker/Hub/WorkerHub.cs` — add DTOs `MergeConflictsDto`/`ConflictFileDto`/`ConflictHunkDto` + 5 hub methods.
|
||||
|
||||
**UI (create new only):**
|
||||
- Create `src/ClaudeDo.Ui/ViewModels/Conflicts/ConflictModels.cs` — `ConflictFile`, `ConflictHunk`.
|
||||
- Create `src/ClaudeDo.Ui/ViewModels/Conflicts/ConflictResolverViewModel.cs`.
|
||||
- Create `src/ClaudeDo.Ui/Views/Conflicts/ConflictResolverView.axaml` + `.axaml.cs`.
|
||||
|
||||
**Wiring (modify):**
|
||||
- Modify `src/ClaudeDo.App/Program.cs` — register `ConflictResolverViewModel` + `Func<string, ConflictResolverViewModel>`.
|
||||
- Modify `src/ClaudeDo.Ui/ViewModels/IslandsShellViewModel.cs` — additive seam (`ConflictResolverFactory`, `ShowConflictResolver`, `RequestConflictResolutionAsync`).
|
||||
- Modify `src/ClaudeDo.Ui/Views/MainWindow.axaml.cs` — wire `ShowConflictResolver` dialog delegate.
|
||||
- Modify `src/ClaudeDo.Localization/locales/en.json` + `de.json` — `conflictResolver.*` keys (parity enforced by Localization.Tests).
|
||||
|
||||
**Tests (create + modify):**
|
||||
- Modify `tests/ClaudeDo.Worker.Tests/Services/TaskMergeServiceTests.cs` — conflict-read / write-resolution / round-trip tests.
|
||||
- Create `tests/ClaudeDo.Ui.Tests/ViewModels/ConflictResolverViewModelTests.cs`.
|
||||
|
||||
---
|
||||
|
||||
## Task 1: GitService conflict-blob reads
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/ClaudeDo.Data/Git/GitService.cs`
|
||||
- Test: `tests/ClaudeDo.Worker.Tests/Services/TaskMergeServiceTests.cs` (GitService exercised here via real repo; add focused tests in Task 2 round-trip)
|
||||
|
||||
- [ ] **Step 1: Add `trimOutput` param to `RunGitAsync`** so blob reads keep exact bytes.
|
||||
|
||||
In `RunGitAsync` signature add `bool trimOutput = true`, and change the return to:
|
||||
```csharp
|
||||
return (proc.ExitCode, trimOutput ? stdout.TrimEnd() : stdout, stderr.TrimEnd());
|
||||
```
|
||||
(All existing callers keep the default `true`.)
|
||||
|
||||
- [ ] **Step 2: Add `ShowStageAsync` + `AddPathAsync`** (place after `ListConflictedFilesAsync`):
|
||||
|
||||
```csharp
|
||||
/// <summary>
|
||||
/// Reads a conflicted file's blob at a merge stage: 1=base, 2=ours, 3=theirs.
|
||||
/// Returns null when the stage doesn't exist (e.g. add/add conflict has no base).
|
||||
/// Output is NOT trimmed so file content round-trips exactly.
|
||||
/// </summary>
|
||||
public async Task<string?> ShowStageAsync(string repoDir, int stage, string path, CancellationToken ct = default)
|
||||
{
|
||||
var (exitCode, stdout, _) = await RunGitAsync(repoDir, ["show", $":{stage}:{path}"], ct, trimOutput: false);
|
||||
return exitCode == 0 ? stdout : null;
|
||||
}
|
||||
|
||||
public async Task AddPathAsync(string repoDir, string path, CancellationToken ct = default)
|
||||
{
|
||||
var (exitCode, _, stderr) = await RunGitAsync(repoDir, ["add", "--", path], ct);
|
||||
if (exitCode != 0)
|
||||
throw new InvalidOperationException($"git add '{path}' failed (exit {exitCode}): {stderr}");
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Build the Data + Worker projects to verify compilation.**
|
||||
|
||||
Run: `dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj -c Release`
|
||||
Expected: Build succeeded, 0 errors.
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add src/ClaudeDo.Data/Git/GitService.cs
|
||||
git commit -m "feat(git): add conflict-stage blob reads and single-path staging"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: TaskMergeService conflict reads + resolution writes
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/ClaudeDo.Worker/Lifecycle/TaskMergeService.cs`
|
||||
- Test: `tests/ClaudeDo.Worker.Tests/Services/TaskMergeServiceTests.cs`
|
||||
|
||||
- [ ] **Step 1: Write failing tests** (append inside `TaskMergeServiceTests`, before `#region Test doubles`). Reuse the existing helpers `SeedListAndTask`, `SeedWorktree`, `BuildService`, and the `GitRepoFixture` conflict setup pattern from `ContinueMergeAsync_AfterUserResolves...`.
|
||||
|
||||
```csharp
|
||||
[Fact]
|
||||
public async Task GetConflictsAsync_AfterConflictMerge_ReturnsOursAndTheirs()
|
||||
{
|
||||
if (!GitRepoFixture.IsGitAvailable()) return;
|
||||
|
||||
var db = NewDb();
|
||||
var repo = NewRepo();
|
||||
GitRepoFixture.RunGit(repo.RepoDir, "branch", "-m", "main");
|
||||
File.WriteAllText(Path.Combine(repo.RepoDir, "README.md"), "# main change\n");
|
||||
GitRepoFixture.RunGit(repo.RepoDir, "commit", "-am", "main change");
|
||||
|
||||
var wtPath = Path.Combine(Path.GetTempPath(), $"wt_{Guid.NewGuid():N}");
|
||||
_wtCleanups.Add((repo.RepoDir, wtPath));
|
||||
GitRepoFixture.RunGit(repo.RepoDir, "worktree", "add", "-b", "claudedo/c1", wtPath, repo.BaseCommit);
|
||||
File.WriteAllText(Path.Combine(wtPath, "README.md"), "# branch change\n");
|
||||
GitRepoFixture.RunGit(wtPath, "commit", "-am", "branch change");
|
||||
|
||||
var (_, task) = await SeedListAndTask(db, workingDir: repo.RepoDir, status: TaskStatus.WaitingForReview);
|
||||
await SeedWorktree(db, task.Id, wtPath, "claudedo/c1", repo.BaseCommit);
|
||||
|
||||
var (svc, _) = BuildService(db);
|
||||
var start = await svc.MergeAsync(task.Id, "main", false, "msg", leaveConflictsInTree: true, CancellationToken.None);
|
||||
Assert.Equal(TaskMergeService.StatusConflict, start.Status);
|
||||
|
||||
var conflicts = await svc.GetConflictsAsync(task.Id, CancellationToken.None);
|
||||
|
||||
Assert.Equal(task.Id, conflicts.TaskId);
|
||||
var file = Assert.Single(conflicts.Files);
|
||||
Assert.Equal("README.md", file.Path);
|
||||
Assert.Contains("main change", file.Ours); // ours = target (main) side after checkout
|
||||
Assert.Contains("branch change", file.Theirs); // theirs = merged-in branch
|
||||
Assert.NotNull(file.Base);
|
||||
|
||||
GitRepoFixture.RunGit(repo.RepoDir, "merge", "--abort");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task WriteResolutionAsync_ThenContinue_CompletesMerge()
|
||||
{
|
||||
if (!GitRepoFixture.IsGitAvailable()) return;
|
||||
|
||||
var db = NewDb();
|
||||
var repo = NewRepo();
|
||||
GitRepoFixture.RunGit(repo.RepoDir, "branch", "-m", "main");
|
||||
File.WriteAllText(Path.Combine(repo.RepoDir, "README.md"), "# main change\n");
|
||||
GitRepoFixture.RunGit(repo.RepoDir, "commit", "-am", "main change");
|
||||
|
||||
var wtPath = Path.Combine(Path.GetTempPath(), $"wt_{Guid.NewGuid():N}");
|
||||
_wtCleanups.Add((repo.RepoDir, wtPath));
|
||||
GitRepoFixture.RunGit(repo.RepoDir, "worktree", "add", "-b", "claudedo/c2", wtPath, repo.BaseCommit);
|
||||
File.WriteAllText(Path.Combine(wtPath, "README.md"), "# branch change\n");
|
||||
GitRepoFixture.RunGit(wtPath, "commit", "-am", "branch change");
|
||||
|
||||
var (_, task) = await SeedListAndTask(db, workingDir: repo.RepoDir, status: TaskStatus.WaitingForReview);
|
||||
await SeedWorktree(db, task.Id, wtPath, "claudedo/c2", repo.BaseCommit);
|
||||
|
||||
var (svc, _) = BuildService(db);
|
||||
await svc.MergeAsync(task.Id, "main", false, "msg", leaveConflictsInTree: true, CancellationToken.None);
|
||||
|
||||
await svc.WriteResolutionAsync(task.Id, "README.md", "# resolved by user\n", CancellationToken.None);
|
||||
var result = await svc.ContinueMergeAsync(task.Id, CancellationToken.None);
|
||||
|
||||
Assert.Equal(TaskMergeService.StatusMerged, result.Status);
|
||||
Assert.Equal("# resolved by user\n", File.ReadAllText(Path.Combine(repo.RepoDir, "README.md")));
|
||||
Assert.False(await new GitService().IsMidMergeAsync(repo.RepoDir));
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run tests to verify they fail** (no such methods).
|
||||
|
||||
Run: `dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release --filter "GetConflictsAsync_AfterConflictMerge_ReturnsOursAndTheirs|WriteResolutionAsync_ThenContinue_CompletesMerge"`
|
||||
Expected: compile error / FAIL (methods don't exist).
|
||||
|
||||
- [ ] **Step 3: Add records + methods to `TaskMergeService.cs`.**
|
||||
|
||||
Add records beside `MergeResult` (top of file, after the existing record declarations):
|
||||
```csharp
|
||||
public sealed record MergeConflicts(
|
||||
string TaskId,
|
||||
IReadOnlyList<ConflictFileContent> Files);
|
||||
|
||||
public sealed record ConflictFileContent(
|
||||
string Path,
|
||||
string Ours,
|
||||
string Theirs,
|
||||
string? Base);
|
||||
```
|
||||
|
||||
Add methods inside the class (after `AbortMergeAsync`):
|
||||
```csharp
|
||||
public async Task<MergeConflicts> GetConflictsAsync(string taskId, CancellationToken ct)
|
||||
{
|
||||
var (_, list, _) = await LoadMergeContextAsync(taskId, ct);
|
||||
if (string.IsNullOrWhiteSpace(list.WorkingDir))
|
||||
throw new InvalidOperationException("list has no working directory");
|
||||
|
||||
var files = await _git.ListConflictedFilesAsync(list.WorkingDir, ct);
|
||||
var result = new List<ConflictFileContent>(files.Count);
|
||||
foreach (var path in files)
|
||||
{
|
||||
var ours = await _git.ShowStageAsync(list.WorkingDir, 2, path, ct) ?? "";
|
||||
var theirs = await _git.ShowStageAsync(list.WorkingDir, 3, path, ct) ?? "";
|
||||
var @base = await _git.ShowStageAsync(list.WorkingDir, 1, path, ct);
|
||||
result.Add(new ConflictFileContent(path, ours, theirs, @base));
|
||||
}
|
||||
return new MergeConflicts(taskId, result);
|
||||
}
|
||||
|
||||
public async Task WriteResolutionAsync(string taskId, string path, string content, CancellationToken ct)
|
||||
{
|
||||
var (_, list, _) = await LoadMergeContextAsync(taskId, ct);
|
||||
if (string.IsNullOrWhiteSpace(list.WorkingDir))
|
||||
throw new InvalidOperationException("list has no working directory");
|
||||
|
||||
var full = Path.Combine(list.WorkingDir, path.Replace('/', Path.DirectorySeparatorChar));
|
||||
await File.WriteAllTextAsync(full, content, ct);
|
||||
await _git.AddPathAsync(list.WorkingDir, path, ct);
|
||||
}
|
||||
```
|
||||
(Note: `Path` is `System.IO.Path` — the file already uses it via other helpers; the record property `Path` does not shadow it inside these methods because it's accessed as a static type, not an instance member.)
|
||||
|
||||
- [ ] **Step 4: Run the tests to verify they pass.**
|
||||
|
||||
Run: `dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release --filter "GetConflictsAsync_AfterConflictMerge_ReturnsOursAndTheirs|WriteResolutionAsync_ThenContinue_CompletesMerge"`
|
||||
Expected: PASS (2 tests). If git unavailable they no-op.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/ClaudeDo.Worker/Lifecycle/TaskMergeService.cs tests/ClaudeDo.Worker.Tests/Services/TaskMergeServiceTests.cs
|
||||
git commit -m "feat(merge): read conflict stages and write user resolutions"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: WorkerHub conflict methods + DTOs
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/ClaudeDo.Worker/Hub/WorkerHub.cs`
|
||||
|
||||
- [ ] **Step 1: Add DTOs** beside the existing merge DTOs (after `public record MergeTargetsDto(...)`):
|
||||
|
||||
```csharp
|
||||
public record MergeConflictsDto(string TaskId, IReadOnlyList<ConflictFileDto> Files);
|
||||
public record ConflictFileDto(string Path, IReadOnlyList<ConflictHunkDto> Hunks);
|
||||
public record ConflictHunkDto(string Ours, string Theirs, string? Base);
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add the 5 hub methods** (after `PreviewMerge`). Names/params/returns MUST match the frozen client calls.
|
||||
|
||||
```csharp
|
||||
public Task<MergeResultDto> StartConflictMerge(string taskId, string targetBranch)
|
||||
=> HubGuard(async () =>
|
||||
{
|
||||
var r = await _mergeService.MergeAsync(
|
||||
taskId, targetBranch ?? "", removeWorktree: false, "Merge task",
|
||||
leaveConflictsInTree: true, CancellationToken.None);
|
||||
if (r.Status == TaskMergeService.StatusBlocked)
|
||||
throw new HubException(r.ErrorMessage ?? "merge blocked");
|
||||
return new MergeResultDto(r.Status, r.ConflictFiles, r.ErrorMessage);
|
||||
});
|
||||
|
||||
public Task<MergeConflictsDto> GetMergeConflicts(string taskId)
|
||||
=> HubGuard(async () =>
|
||||
{
|
||||
var c = await _mergeService.GetConflictsAsync(taskId, CancellationToken.None);
|
||||
return new MergeConflictsDto(
|
||||
c.TaskId,
|
||||
c.Files.Select(f => new ConflictFileDto(
|
||||
f.Path,
|
||||
new[] { new ConflictHunkDto(f.Ours, f.Theirs, f.Base) })).ToList());
|
||||
});
|
||||
|
||||
public Task WriteConflictResolution(string taskId, string path, string resolvedContent)
|
||||
=> HubGuard(() => _mergeService.WriteResolutionAsync(
|
||||
taskId, path, resolvedContent ?? "", CancellationToken.None));
|
||||
|
||||
public Task<MergeResultDto> ContinueMerge(string taskId)
|
||||
=> HubGuard(async () =>
|
||||
{
|
||||
var r = await _mergeService.ContinueMergeAsync(taskId, CancellationToken.None);
|
||||
if (r.Status == TaskMergeService.StatusBlocked)
|
||||
throw new HubException(r.ErrorMessage ?? "continue failed");
|
||||
return new MergeResultDto(r.Status, r.ConflictFiles, r.ErrorMessage);
|
||||
});
|
||||
|
||||
public Task AbortMerge(string taskId)
|
||||
=> HubGuard(async () =>
|
||||
{
|
||||
var r = await _mergeService.AbortMergeAsync(taskId, CancellationToken.None);
|
||||
if (r.Status == TaskMergeService.StatusBlocked)
|
||||
throw new HubException(r.ErrorMessage ?? "abort failed");
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Build the Worker project to verify compilation.**
|
||||
|
||||
Run: `dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj -c Release`
|
||||
Expected: Build succeeded, 0 errors.
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add src/ClaudeDo.Worker/Hub/WorkerHub.cs
|
||||
git commit -m "feat(hub): expose conflict-resolution merge methods"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: Conflict UI model
|
||||
|
||||
**Files:**
|
||||
- Create: `src/ClaudeDo.Ui/ViewModels/Conflicts/ConflictModels.cs`
|
||||
- Test: `tests/ClaudeDo.Ui.Tests/ViewModels/ConflictResolverViewModelTests.cs` (model tests added here in Task 5; this task is build-verified)
|
||||
|
||||
- [ ] **Step 1: Create the model file.** Shaped so a 3-way pane needs no model change (`Base` retained per hunk).
|
||||
|
||||
```csharp
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
|
||||
namespace ClaudeDo.Ui.ViewModels.Conflicts;
|
||||
|
||||
public sealed partial class ConflictHunk : ObservableObject
|
||||
{
|
||||
public string Ours { get; }
|
||||
public string Theirs { get; }
|
||||
public string? Base { get; }
|
||||
|
||||
[ObservableProperty] private string? _resolution;
|
||||
|
||||
public bool IsResolved => Resolution is not null;
|
||||
|
||||
public ConflictHunk(string ours, string theirs, string? @base)
|
||||
{
|
||||
Ours = ours;
|
||||
Theirs = theirs;
|
||||
Base = @base;
|
||||
}
|
||||
|
||||
partial void OnResolutionChanged(string? value) => OnPropertyChanged(nameof(IsResolved));
|
||||
|
||||
[RelayCommand] private void AcceptCurrent() => Resolution = Ours;
|
||||
[RelayCommand] private void AcceptIncoming() => Resolution = Theirs;
|
||||
[RelayCommand] private void AcceptBoth() => Resolution = Ours + Theirs;
|
||||
[RelayCommand] private void EditManually() => Resolution ??= Ours;
|
||||
}
|
||||
|
||||
public sealed class ConflictFile
|
||||
{
|
||||
public string Path { get; }
|
||||
public IReadOnlyList<ConflictHunk> Hunks { get; }
|
||||
|
||||
public ConflictFile(string path, IReadOnlyList<ConflictHunk> hunks)
|
||||
{
|
||||
Path = path;
|
||||
Hunks = hunks;
|
||||
}
|
||||
|
||||
public bool AllHunksResolved => Hunks.Count > 0 && Hunks.All(h => h.IsResolved);
|
||||
|
||||
/// <summary>The merged file content: concatenation of each hunk's resolution
|
||||
/// (single whole-file hunk today; concatenation keeps it correct for multi-hunk later).</summary>
|
||||
public string ComposeResolvedContent() => string.Concat(Hunks.Select(h => h.Resolution));
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Build the Ui project to verify compilation.**
|
||||
|
||||
Run: `dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj -c Release`
|
||||
Expected: Build succeeded.
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add src/ClaudeDo.Ui/ViewModels/Conflicts/ConflictModels.cs
|
||||
git commit -m "feat(ui): add inline conflict model (file/hunk with resolution)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 5: ConflictResolverViewModel
|
||||
|
||||
**Files:**
|
||||
- Create: `src/ClaudeDo.Ui/ViewModels/Conflicts/ConflictResolverViewModel.cs`
|
||||
- Test: `tests/ClaudeDo.Ui.Tests/ViewModels/ConflictResolverViewModelTests.cs`
|
||||
|
||||
- [ ] **Step 1: Write failing tests.** Subclass the existing `StubWorkerClient` (its conflict methods are `virtual`).
|
||||
|
||||
```csharp
|
||||
using System.Collections.Generic;
|
||||
using System.Threading.Tasks;
|
||||
using ClaudeDo.Ui.Services;
|
||||
using ClaudeDo.Ui.ViewModels.Conflicts;
|
||||
using Xunit;
|
||||
|
||||
namespace ClaudeDo.Ui.Tests.ViewModels;
|
||||
|
||||
public class ConflictResolverViewModelTests
|
||||
{
|
||||
private sealed class FakeWorker : StubWorkerClient
|
||||
{
|
||||
public string? WrittenPath;
|
||||
public string? WrittenContent;
|
||||
public bool Continued;
|
||||
public bool Aborted;
|
||||
public string ContinueStatus = "merged";
|
||||
|
||||
public override Task<MergeResultDto> StartConflictMergeAsync(string taskId, string targetBranch)
|
||||
=> Task.FromResult(new MergeResultDto("conflict", new[] { "README.md" }, null));
|
||||
|
||||
public override Task<MergeConflictsDto> GetMergeConflictsAsync(string taskId)
|
||||
=> Task.FromResult(new MergeConflictsDto(taskId, new[]
|
||||
{
|
||||
new ConflictFileDto("README.md", new[] { new ConflictHunkDto("ours\n", "theirs\n", "base\n") })
|
||||
}));
|
||||
|
||||
public override Task WriteConflictResolutionAsync(string taskId, string path, string resolvedContent)
|
||||
{
|
||||
WrittenPath = path; WrittenContent = resolvedContent; return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public override Task<MergeResultDto> ContinueMergeAsync(string taskId)
|
||||
{
|
||||
Continued = true;
|
||||
return Task.FromResult(new MergeResultDto(ContinueStatus, System.Array.Empty<string>(), null));
|
||||
}
|
||||
|
||||
public override Task AbortMergeAsync(string taskId) { Aborted = true; return Task.CompletedTask; }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task OpenAsync_LoadsConflicts_AndBlocksContinueUntilResolved()
|
||||
{
|
||||
var vm = new ConflictResolverViewModel(new FakeWorker(), "task-1");
|
||||
var hasConflicts = await vm.OpenAsync("main");
|
||||
|
||||
Assert.True(hasConflicts);
|
||||
var file = Assert.Single(vm.Files);
|
||||
Assert.Equal("README.md", file.Path);
|
||||
Assert.False(vm.CanContinue); // nothing resolved yet
|
||||
|
||||
file.Hunks[0].AcceptIncomingCommand.Execute(null);
|
||||
Assert.True(vm.CanContinue); // every hunk resolved
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Continue_WritesComposedResolution_AndClosesOnMerged()
|
||||
{
|
||||
var worker = new FakeWorker();
|
||||
var vm = new ConflictResolverViewModel(worker, "task-1");
|
||||
var closed = false;
|
||||
vm.CloseRequested = () => closed = true;
|
||||
|
||||
await vm.OpenAsync("main");
|
||||
vm.Files[0].Hunks[0].AcceptCurrentCommand.Execute(null); // resolution = "ours\n"
|
||||
await vm.ContinueCommand.ExecuteAsync(null);
|
||||
|
||||
Assert.Equal("README.md", worker.WrittenPath);
|
||||
Assert.Equal("ours\n", worker.WrittenContent);
|
||||
Assert.True(worker.Continued);
|
||||
Assert.True(closed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Continue_StaysOpenAndReportsError_WhenStillConflicted()
|
||||
{
|
||||
var worker = new FakeWorker { ContinueStatus = "conflict" };
|
||||
var vm = new ConflictResolverViewModel(worker, "task-1");
|
||||
var closed = false;
|
||||
vm.CloseRequested = () => closed = true;
|
||||
|
||||
await vm.OpenAsync("main");
|
||||
vm.Files[0].Hunks[0].AcceptBothCommand.Execute(null);
|
||||
await vm.ContinueCommand.ExecuteAsync(null);
|
||||
|
||||
Assert.False(closed);
|
||||
Assert.NotNull(vm.Error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Abort_CallsWorkerAndCloses()
|
||||
{
|
||||
var worker = new FakeWorker();
|
||||
var vm = new ConflictResolverViewModel(worker, "task-1");
|
||||
var closed = false;
|
||||
vm.CloseRequested = () => closed = true;
|
||||
|
||||
await vm.AbortCommand.ExecuteAsync(null);
|
||||
|
||||
Assert.True(worker.Aborted);
|
||||
Assert.True(closed);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run tests to verify they fail** (VM not defined).
|
||||
|
||||
Run: `dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj -c Release --filter "ConflictResolverViewModelTests"`
|
||||
Expected: compile error / FAIL.
|
||||
|
||||
- [ ] **Step 3: Implement the ViewModel.**
|
||||
|
||||
```csharp
|
||||
using System;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.ComponentModel;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using ClaudeDo.Ui.Services;
|
||||
|
||||
namespace ClaudeDo.Ui.ViewModels.Conflicts;
|
||||
|
||||
public sealed partial class ConflictResolverViewModel : ObservableObject
|
||||
{
|
||||
private readonly IWorkerClient _worker;
|
||||
private readonly string _taskId;
|
||||
|
||||
public ObservableCollection<ConflictFile> Files { get; } = new();
|
||||
|
||||
[ObservableProperty] private bool _isBusy;
|
||||
[ObservableProperty] private string? _error;
|
||||
[ObservableProperty] private bool _canContinue;
|
||||
|
||||
public string TaskId => _taskId;
|
||||
public Action? CloseRequested { get; set; }
|
||||
|
||||
public ConflictResolverViewModel(IWorkerClient worker, string taskId)
|
||||
{
|
||||
_worker = worker;
|
||||
_taskId = taskId;
|
||||
}
|
||||
|
||||
/// <summary>Starts the conflict merge and loads ours/theirs/base per file.
|
||||
/// Returns true when there are conflicts to resolve (caller should show the dialog).</summary>
|
||||
public async Task<bool> OpenAsync(string targetBranch)
|
||||
{
|
||||
IsBusy = true;
|
||||
Error = null;
|
||||
try
|
||||
{
|
||||
var start = await _worker.StartConflictMergeAsync(_taskId, targetBranch);
|
||||
if (!string.Equals(start.Status, "conflict", StringComparison.Ordinal))
|
||||
{
|
||||
if (string.Equals(start.Status, "blocked", StringComparison.Ordinal))
|
||||
Error = start.ErrorMessage;
|
||||
return false;
|
||||
}
|
||||
|
||||
var conflicts = await _worker.GetMergeConflictsAsync(_taskId);
|
||||
Files.Clear();
|
||||
foreach (var f in conflicts.Files)
|
||||
{
|
||||
var hunks = f.Hunks.Select(h =>
|
||||
{
|
||||
var hk = new ConflictHunk(h.Ours, h.Theirs, h.Base);
|
||||
hk.PropertyChanged += OnHunkChanged;
|
||||
return hk;
|
||||
}).ToList();
|
||||
Files.Add(new ConflictFile(f.Path, hunks));
|
||||
}
|
||||
RecomputeCanContinue();
|
||||
return Files.Count > 0;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Error = ex.Message;
|
||||
return false;
|
||||
}
|
||||
finally { IsBusy = false; }
|
||||
}
|
||||
|
||||
private void OnHunkChanged(object? sender, PropertyChangedEventArgs e)
|
||||
{
|
||||
if (e.PropertyName is nameof(ConflictHunk.IsResolved) or nameof(ConflictHunk.Resolution))
|
||||
RecomputeCanContinue();
|
||||
}
|
||||
|
||||
private void RecomputeCanContinue()
|
||||
=> CanContinue = Files.Count > 0 && Files.All(f => f.AllHunksResolved);
|
||||
|
||||
[RelayCommand]
|
||||
private async Task ContinueAsync()
|
||||
{
|
||||
if (!CanContinue) return;
|
||||
IsBusy = true;
|
||||
Error = null;
|
||||
try
|
||||
{
|
||||
foreach (var file in Files)
|
||||
await _worker.WriteConflictResolutionAsync(_taskId, file.Path, file.ComposeResolvedContent());
|
||||
|
||||
var result = await _worker.ContinueMergeAsync(_taskId);
|
||||
if (string.Equals(result.Status, "merged", StringComparison.Ordinal))
|
||||
CloseRequested?.Invoke();
|
||||
else
|
||||
Error = result.ErrorMessage ?? "Conflicts not fully resolved — review and retry.";
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Error = ex.Message;
|
||||
}
|
||||
finally { IsBusy = false; }
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private async Task AbortAsync()
|
||||
{
|
||||
IsBusy = true;
|
||||
try { await _worker.AbortMergeAsync(_taskId); }
|
||||
catch (Exception ex) { Error = ex.Message; }
|
||||
finally
|
||||
{
|
||||
IsBusy = false;
|
||||
CloseRequested?.Invoke();
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run tests to verify they pass.**
|
||||
|
||||
Run: `dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj -c Release --filter "ConflictResolverViewModelTests"`
|
||||
Expected: PASS (4 tests).
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/ClaudeDo.Ui/ViewModels/Conflicts/ConflictResolverViewModel.cs tests/ClaudeDo.Ui.Tests/ViewModels/ConflictResolverViewModelTests.cs
|
||||
git commit -m "feat(ui): add inline conflict resolver view-model"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 6: ConflictResolverView + localization
|
||||
|
||||
**Files:**
|
||||
- Create: `src/ClaudeDo.Ui/Views/Conflicts/ConflictResolverView.axaml`
|
||||
- Create: `src/ClaudeDo.Ui/Views/Conflicts/ConflictResolverView.axaml.cs`
|
||||
- Modify: `src/ClaudeDo.Localization/locales/en.json`
|
||||
- Modify: `src/ClaudeDo.Localization/locales/de.json`
|
||||
|
||||
- [ ] **Step 1: Add localization keys** to `en.json` as a new top-level section (sibling of `"planning"`):
|
||||
|
||||
```json
|
||||
"conflictResolver": {
|
||||
"windowTitle": "Resolve merge conflicts",
|
||||
"modalTitle": "RESOLVE CONFLICTS",
|
||||
"loading": "Loading conflicts…",
|
||||
"current": "Current (ours)",
|
||||
"incoming": "Incoming (theirs)",
|
||||
"mergedResult": "Merged result",
|
||||
"acceptCurrent": "Accept Current",
|
||||
"acceptIncoming": "Accept Incoming",
|
||||
"acceptBoth": "Accept Both",
|
||||
"editManually": "Edit manually",
|
||||
"continue": "Resolve & continue",
|
||||
"abort": "Abort merge"
|
||||
},
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add the SAME keys to `de.json`** (German values, identical key set — parity enforced by Localization.Tests):
|
||||
|
||||
```json
|
||||
"conflictResolver": {
|
||||
"windowTitle": "Merge-Konflikte lösen",
|
||||
"modalTitle": "KONFLIKTE LÖSEN",
|
||||
"loading": "Konflikte werden geladen…",
|
||||
"current": "Aktuell (unsere)",
|
||||
"incoming": "Eingehend (ihre)",
|
||||
"mergedResult": "Zusammengeführtes Ergebnis",
|
||||
"acceptCurrent": "Aktuelle übernehmen",
|
||||
"acceptIncoming": "Eingehende übernehmen",
|
||||
"acceptBoth": "Beide übernehmen",
|
||||
"editManually": "Manuell bearbeiten",
|
||||
"continue": "Lösen & fortfahren",
|
||||
"abort": "Merge abbrechen"
|
||||
},
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Create the View** (`ConflictResolverView.axaml`). A `Window` using `ModalShell`, mirroring `ConflictResolutionView.axaml`. Two stacked read-only boxes (ours/theirs), a button row, and a two-way merged-result box per hunk.
|
||||
|
||||
```xml
|
||||
<Window xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:vm="using:ClaudeDo.Ui.ViewModels.Conflicts"
|
||||
xmlns:ctl="using:ClaudeDo.Ui.Views.Controls"
|
||||
xmlns:loc="using:ClaudeDo.Ui.Localization"
|
||||
x:DataType="vm:ConflictResolverViewModel"
|
||||
x:Class="ClaudeDo.Ui.Views.Conflicts.ConflictResolverView"
|
||||
Title="{loc:Tr conflictResolver.windowTitle}"
|
||||
Width="760" Height="640" MinWidth="560" MinHeight="420"
|
||||
CanResize="True"
|
||||
WindowDecorations="BorderOnly"
|
||||
ExtendClientAreaToDecorationsHint="True"
|
||||
ExtendClientAreaTitleBarHeightHint="-1"
|
||||
WindowStartupLocation="CenterOwner"
|
||||
Background="{DynamicResource SurfaceBrush}">
|
||||
|
||||
<Window.KeyBindings>
|
||||
<KeyBinding Gesture="Escape" Command="{Binding AbortCommand}"/>
|
||||
</Window.KeyBindings>
|
||||
|
||||
<ctl:ModalShell Title="{loc:Tr conflictResolver.modalTitle}" CloseCommand="{Binding AbortCommand}">
|
||||
<ctl:ModalShell.Footer>
|
||||
<StackPanel Orientation="Horizontal" Spacing="8"
|
||||
HorizontalAlignment="Right" VerticalAlignment="Center">
|
||||
<Button Classes="btn" Content="{loc:Tr conflictResolver.continue}"
|
||||
Command="{Binding ContinueCommand}" IsEnabled="{Binding CanContinue}"/>
|
||||
<Button Classes="btn" Content="{loc:Tr conflictResolver.abort}" Command="{Binding AbortCommand}"/>
|
||||
</StackPanel>
|
||||
</ctl:ModalShell.Footer>
|
||||
|
||||
<Grid RowDefinitions="Auto,*" Margin="16,12">
|
||||
<TextBlock Grid.Row="0" Classes="meta" Margin="0,0,0,8"
|
||||
Text="{loc:Tr conflictResolver.loading}"
|
||||
IsVisible="{Binding IsBusy}"/>
|
||||
<TextBlock Grid.Row="0" Classes="meta" Foreground="{DynamicResource BloodBrush}"
|
||||
Text="{Binding Error}" TextWrapping="Wrap"
|
||||
IsVisible="{Binding Error, Converter={x:Static ObjectConverters.IsNotNull}}"/>
|
||||
|
||||
<ScrollViewer Grid.Row="1">
|
||||
<ItemsControl ItemsSource="{Binding Files}">
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:ConflictFile">
|
||||
<StackPanel Spacing="8" Margin="0,0,0,16">
|
||||
<TextBlock Classes="path-mono heading" Text="{Binding Path}"/>
|
||||
<ItemsControl ItemsSource="{Binding Hunks}">
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:ConflictHunk">
|
||||
<Border BorderBrush="{DynamicResource BorderBrush}" BorderThickness="1"
|
||||
CornerRadius="6" Padding="10" Margin="0,0,0,8">
|
||||
<StackPanel Spacing="6">
|
||||
<TextBlock Classes="meta" Text="{loc:Tr conflictResolver.current}"/>
|
||||
<TextBox Text="{Binding Ours, Mode=OneWay}" IsReadOnly="True"
|
||||
TextWrapping="NoWrap" AcceptsReturn="True" MaxHeight="120"
|
||||
FontFamily="{DynamicResource MonoFont}"/>
|
||||
<TextBlock Classes="meta" Text="{loc:Tr conflictResolver.incoming}"/>
|
||||
<TextBox Text="{Binding Theirs, Mode=OneWay}" IsReadOnly="True"
|
||||
TextWrapping="NoWrap" AcceptsReturn="True" MaxHeight="120"
|
||||
FontFamily="{DynamicResource MonoFont}"/>
|
||||
<StackPanel Orientation="Horizontal" Spacing="6">
|
||||
<Button Classes="btn" Content="{loc:Tr conflictResolver.acceptCurrent}"
|
||||
Command="{Binding AcceptCurrentCommand}"/>
|
||||
<Button Classes="btn" Content="{loc:Tr conflictResolver.acceptIncoming}"
|
||||
Command="{Binding AcceptIncomingCommand}"/>
|
||||
<Button Classes="btn" Content="{loc:Tr conflictResolver.acceptBoth}"
|
||||
Command="{Binding AcceptBothCommand}"/>
|
||||
<Button Classes="btn" Content="{loc:Tr conflictResolver.editManually}"
|
||||
Command="{Binding EditManuallyCommand}"/>
|
||||
</StackPanel>
|
||||
<TextBlock Classes="meta" Text="{loc:Tr conflictResolver.mergedResult}"/>
|
||||
<TextBox Text="{Binding Resolution, Mode=TwoWay}"
|
||||
TextWrapping="NoWrap" AcceptsReturn="True" MinHeight="80" MaxHeight="200"
|
||||
FontFamily="{DynamicResource MonoFont}"/>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
</StackPanel>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
</ScrollViewer>
|
||||
</Grid>
|
||||
</ctl:ModalShell>
|
||||
</Window>
|
||||
```
|
||||
**Note for the implementer:** if `MonoFont` / `path-mono` / `heading` / `meta` / `btn` resource keys or style classes don't resolve at build, drop the `FontFamily` attribute and unknown `Classes` (keep `btn`) — match whatever the existing `ConflictResolutionView.axaml` and app styles actually expose. Verify against `src/ClaudeDo.Ui/Views/Planning/ConflictResolutionView.axaml` and the app's style resources before finalizing.
|
||||
|
||||
- [ ] **Step 4: Create the code-behind** (`ConflictResolverView.axaml.cs`):
|
||||
|
||||
```csharp
|
||||
using Avalonia.Controls;
|
||||
using ClaudeDo.Ui.ViewModels.Conflicts;
|
||||
|
||||
namespace ClaudeDo.Ui.Views.Conflicts;
|
||||
|
||||
public partial class ConflictResolverView : Window
|
||||
{
|
||||
public ConflictResolverView()
|
||||
{
|
||||
InitializeComponent();
|
||||
}
|
||||
|
||||
protected override void OnDataContextChanged(System.EventArgs e)
|
||||
{
|
||||
base.OnDataContextChanged(e);
|
||||
if (DataContext is ConflictResolverViewModel vm)
|
||||
vm.CloseRequested = Close;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Build the App + run Localization tests.**
|
||||
|
||||
Run: `dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj -c Release && dotnet test tests/ClaudeDo.Localization.Tests/ClaudeDo.Localization.Tests.csproj -c Release`
|
||||
Expected: Build succeeded; localization parity tests PASS.
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add src/ClaudeDo.Ui/Views/Conflicts/ConflictResolverView.axaml src/ClaudeDo.Ui/Views/Conflicts/ConflictResolverView.axaml.cs src/ClaudeDo.Localization/locales/en.json src/ClaudeDo.Localization/locales/de.json
|
||||
git commit -m "feat(ui): add inline conflict resolver view and localization"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 7: Wire factory + dialog seam for the integrator
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/ClaudeDo.App/Program.cs`
|
||||
- Modify: `src/ClaudeDo.Ui/ViewModels/IslandsShellViewModel.cs`
|
||||
- Modify: `src/ClaudeDo.Ui/Views/MainWindow.axaml.cs`
|
||||
|
||||
These are additive seams only. The integrator connects Layer A/B's `RequestConflictResolution(taskId, target)` callback to `IslandsShellViewModel.RequestConflictResolutionAsync`.
|
||||
|
||||
- [ ] **Step 1: Register the factory in `Program.cs`** (in the ViewModels region, near the other `Func<>` factories). Only the `Func<>` factory is needed — the VM is never resolved directly:
|
||||
|
||||
```csharp
|
||||
sc.AddSingleton<Func<string, ClaudeDo.Ui.ViewModels.Conflicts.ConflictResolverViewModel>>(sp =>
|
||||
taskId => new ClaudeDo.Ui.ViewModels.Conflicts.ConflictResolverViewModel(
|
||||
sp.GetRequiredService<WorkerClient>(), taskId));
|
||||
```
|
||||
Then, after `IslandsShellViewModel` is registered, set the factory on it once resolved. Replace the existing `sc.AddSingleton<IslandsShellViewModel>();` registration with a factory that injects the conflict-resolver factory:
|
||||
|
||||
```csharp
|
||||
sc.AddSingleton<IslandsShellViewModel>(sp =>
|
||||
{
|
||||
var shell = ActivatorUtilities.CreateInstance<IslandsShellViewModel>(sp);
|
||||
shell.ConflictResolverFactory =
|
||||
sp.GetRequiredService<Func<string, ClaudeDo.Ui.ViewModels.Conflicts.ConflictResolverViewModel>>();
|
||||
return shell;
|
||||
});
|
||||
```
|
||||
(`ActivatorUtilities.CreateInstance` resolves the existing big constructor + its `Func<>` deps exactly as the default registration did.)
|
||||
|
||||
- [ ] **Step 2: Add the additive seam to `IslandsShellViewModel`** (near the other `Show*` delegate properties):
|
||||
|
||||
```csharp
|
||||
// Layer C seam: composition root sets the factory; MainWindow sets the dialog opener.
|
||||
// The integrator connects Layer A/B's RequestConflictResolution(taskId, target) to this method.
|
||||
public Func<string, ClaudeDo.Ui.ViewModels.Conflicts.ConflictResolverViewModel>? ConflictResolverFactory { get; set; }
|
||||
public Func<ClaudeDo.Ui.ViewModels.Conflicts.ConflictResolverViewModel, Task>? ShowConflictResolver { get; set; }
|
||||
|
||||
public async Task RequestConflictResolutionAsync(string taskId, string targetBranch)
|
||||
{
|
||||
if (ConflictResolverFactory is null || ShowConflictResolver is null) return;
|
||||
var vm = ConflictResolverFactory(taskId);
|
||||
var hasConflicts = await vm.OpenAsync(targetBranch);
|
||||
if (hasConflicts)
|
||||
await ShowConflictResolver(vm);
|
||||
}
|
||||
```
|
||||
(Add `using ClaudeDo.Ui.ViewModels.Conflicts;` or use fully-qualified names as above.)
|
||||
|
||||
- [ ] **Step 3: Wire the dialog opener in `MainWindow.axaml.cs`** inside `OnDataContextChanged`, alongside the other `vm.Show*` assignments:
|
||||
|
||||
```csharp
|
||||
vm.ShowConflictResolver = async (resolverVm) =>
|
||||
{
|
||||
var dlg = new ClaudeDo.Ui.Views.Conflicts.ConflictResolverView { DataContext = resolverVm };
|
||||
await dlg.ShowDialog(this);
|
||||
};
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Build the App to verify compilation.**
|
||||
|
||||
Run: `dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj -c Release`
|
||||
Expected: Build succeeded.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/ClaudeDo.App/Program.cs src/ClaudeDo.Ui/ViewModels/IslandsShellViewModel.cs src/ClaudeDo.Ui/Views/MainWindow.axaml.cs
|
||||
git commit -m "feat(ui): expose conflict-resolver factory and dialog seam for integrator"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 8: Full verification
|
||||
|
||||
- [ ] **Step 1: Build both head projects.**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj -c Release
|
||||
dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj -c Release
|
||||
```
|
||||
Expected: both Build succeeded, 0 errors/warnings.
|
||||
|
||||
- [ ] **Step 2: Run the full relevant test suites.**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release
|
||||
dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj -c Release
|
||||
dotnet test tests/ClaudeDo.Localization.Tests/ClaudeDo.Localization.Tests.csproj -c Release
|
||||
```
|
||||
Expected: all PASS.
|
||||
|
||||
- [ ] **Step 3: Flag visual verification.** The resolver dialog cannot be opened end-to-end until the integrator wires Layer A/B's `RequestConflictResolution(taskId, target)` → `IslandsShellViewModel.RequestConflictResolutionAsync`. Report this as a visual-verification gap for the user/integrator: open a real conflicting merge, confirm hunks render, Accept buttons populate the merged box, Resolve & continue closes on success, Abort restores the tree.
|
||||
|
||||
- [ ] **Step 4: Leave the branch for the orchestrator.** Do NOT push, do NOT merge to main.
|
||||
837
docs/superpowers/plans/2026-06-05-merge-cockpit-layer-b.md
Normal file
837
docs/superpowers/plans/2026-06-05-merge-cockpit-layer-b.md
Normal file
@@ -0,0 +1,837 @@
|
||||
# Layer B — Multi-Worktree Merge Cockpit Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Turn the worktrees-overview modal into a batch-merge cockpit (multi-select N worktrees → one target branch → "Merge all" with skip-and-continue conflict collection), and migrate `WorktreeModalView`'s bespoke inline diff onto the canonical `DiffLinesView`.
|
||||
|
||||
**Architecture:** The cockpit VM keeps depending on the concrete `WorkerClient` (the overview/cleanup/state methods live only on `WorkerClient`, not `IWorkerClient`). The batch loop is extracted into a delegate-driven method `MergeSelectedAsync(Func<...> mergeFn)` so it is unit-testable with a fake merge function and a never-connected `WorkerClient`. Clean merges (`Status=="merged"`) update the row; conflicts (`Status=="conflict"`, which `MergeTaskAsync` already auto-aborts) are collected into a `ConflictRows` list whose rows expose a `Resolve` button wired to an inert `RequestConflictResolution(taskId, targetBranch)` seam. The diff migration replaces the right-pane `ItemsControl` in `WorktreeModalView` with `DiffLinesView`, feeding it `DiffLineViewModel`s produced by `UnifiedDiffParser`, and deletes the now-dead `WorktreeDiffLineViewModel`/`WorktreeDiffLineKind`.
|
||||
|
||||
**Tech Stack:** .NET 8, Avalonia 12, CommunityToolkit.Mvvm source generators, xUnit. Build UI with `dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj -c Release`; run `dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj -c Release`.
|
||||
|
||||
**Frozen contracts reused (do NOT modify):**
|
||||
- `WorkerClient.MergeTaskAsync(string taskId, string targetBranch, bool removeWorktree, string commitMessage) -> Task<MergeResultDto>`
|
||||
- `WorkerClient.GetMergeTargetsAsync(string taskId) -> Task<MergeTargetsDto?>` (`MergeTargetsDto(string DefaultBranch, IReadOnlyList<string> LocalBranches)`)
|
||||
- `MergeResultDto(string Status, IReadOnlyList<string> ConflictFiles, string? ErrorMessage)` — `Status` is `"merged" | "conflict" | "blocked" | <other>`
|
||||
- `WorkerClient.GetWorktreesOverviewAsync`, `CleanupFinishedWorktreesAsync`, `SetWorktreeStateAsync`, `ForceRemoveWorktreeAsync`
|
||||
- `GitService.GetFileDiffAsync(worktreePath, baseCommit?, relativePath)` returns a `git diff` blob including the `diff --git` header (so `UnifiedDiffParser.Parse` handles it)
|
||||
- `DiffLinesView` (`Lines` styled property, `IEnumerable?`), `DiffLineViewModel`, `DiffFileViewModel`, `UnifiedDiffParser.Parse` / `.Flatten`
|
||||
|
||||
**Do NOT touch:** any worker-side files (`WorkerHub`, `TaskMergeService`, `GitService`), `IWorkerClient.cs` / `WorkerClient.cs`, `WorkConsole.axaml`, `DetailsIslandViewModel.cs`, and do not create any `ConflictResolver` UI or reference any `ConflictResolver` type.
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
- `src/ClaudeDo.Ui/ViewModels/Modals/WorktreesOverviewModalViewModel.cs` — **modify.** Add `BatchMergeOutcome` enum; add `IsChecked`/`MergeOutcome` (+ derived) to the row VM; add `MergeTargets`, `SelectedTarget`, `SelectedCount`, `IsMerging`, `BatchProgress`, `ConflictRows`, the `RequestConflictResolution` seam, `MergeSelectedAsync`, `MergeAllCommand`, `ResolveConflictCommand`, `ToggleSelectAllCommand`, target loading, and per-row check subscription. Keep all existing context-menu commands/wiring intact.
|
||||
- `src/ClaudeDo.Ui/Views/Modals/WorktreesOverviewModalView.axaml` — **modify.** Add a per-row checkbox + outcome badge, a target `ComboBox` + "Merge all" button + progress text in the toolbar, and a "Needs resolution" panel listing `ConflictRows` with `Resolve` buttons.
|
||||
- `src/ClaudeDo.Ui/ViewModels/Modals/WorktreeModalViewModel.cs` — **modify.** Replace `SelectedFileDiffLines` element type with `DiffLineViewModel` produced via `UnifiedDiffParser`; delete `WorktreeDiffLineKind` and `WorktreeDiffLineViewModel`.
|
||||
- `src/ClaudeDo.Ui/Views/Modals/WorktreeModalView.axaml` — **modify.** Replace the right-pane `ItemsControl` with `ctl:DiffLinesView`; drop the `DiffLineKindToBrushConverter` resource.
|
||||
- `src/ClaudeDo.Localization/locales/en.json` + `de.json` — **modify.** Add new `modals.worktreesOverview.*` and `vm.worktreesOverview.*` keys (keep parity).
|
||||
- `tests/ClaudeDo.Ui.Tests/ViewModels/WorktreesOverviewBatchMergeTests.cs` — **create.** Unit tests for `MergeSelectedAsync` skip-and-continue, conflict collection, progress, selection gating, and the resolve seam.
|
||||
|
||||
No `IWorkerClient` change → no test-fake updates needed.
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Row-level batch state (outcome enum + row VM fields)
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/ClaudeDo.Ui/ViewModels/Modals/WorktreesOverviewModalViewModel.cs`
|
||||
- Test: `tests/ClaudeDo.Ui.Tests/ViewModels/WorktreesOverviewBatchMergeTests.cs`
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
Create `tests/ClaudeDo.Ui.Tests/ViewModels/WorktreesOverviewBatchMergeTests.cs`:
|
||||
|
||||
```csharp
|
||||
using ClaudeDo.Data.Models;
|
||||
using ClaudeDo.Ui.ViewModels.Modals;
|
||||
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
|
||||
using Xunit;
|
||||
|
||||
namespace ClaudeDo.Ui.Tests.ViewModels;
|
||||
|
||||
public class WorktreesOverviewBatchMergeTests
|
||||
{
|
||||
private static WorktreeOverviewRowViewModel ActiveRow(string id) => new()
|
||||
{
|
||||
TaskId = id,
|
||||
TaskTitle = $"Task {id}",
|
||||
TaskStatus = TaskStatus.WaitingForReview,
|
||||
State = WorktreeState.Active,
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public void Row_outcome_helpers_reflect_state()
|
||||
{
|
||||
var row = ActiveRow("a");
|
||||
Assert.Equal(BatchMergeOutcome.None, row.MergeOutcome);
|
||||
Assert.False(row.IsConflict);
|
||||
|
||||
row.MergeOutcome = BatchMergeOutcome.Conflict;
|
||||
Assert.True(row.IsConflict);
|
||||
|
||||
row.MergeOutcome = BatchMergeOutcome.Merged;
|
||||
Assert.False(row.IsConflict);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj -c Release --filter WorktreesOverviewBatchMergeTests`
|
||||
Expected: FAIL — `BatchMergeOutcome` and `MergeOutcome`/`IsConflict` do not exist (compile error).
|
||||
|
||||
- [ ] **Step 3: Add the enum and row fields**
|
||||
|
||||
In `WorktreesOverviewModalViewModel.cs`, add the enum just above `WorktreeOverviewRowViewModel`:
|
||||
|
||||
```csharp
|
||||
public enum BatchMergeOutcome { None, Merging, Merged, Conflict, Blocked, Failed }
|
||||
```
|
||||
|
||||
Inside `WorktreeOverviewRowViewModel`, add after the existing `_isSelected` field:
|
||||
|
||||
```csharp
|
||||
[ObservableProperty] private bool _isChecked;
|
||||
[ObservableProperty]
|
||||
[NotifyPropertyChangedFor(nameof(IsConflict))]
|
||||
[NotifyPropertyChangedFor(nameof(HasOutcome))]
|
||||
private BatchMergeOutcome _mergeOutcome;
|
||||
|
||||
public bool IsConflict => MergeOutcome == BatchMergeOutcome.Conflict;
|
||||
public bool HasOutcome => MergeOutcome != BatchMergeOutcome.None;
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run test to verify it passes**
|
||||
|
||||
Run: `dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj -c Release --filter WorktreesOverviewBatchMergeTests`
|
||||
Expected: PASS (1 test).
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/ClaudeDo.Ui/ViewModels/Modals/WorktreesOverviewModalViewModel.cs tests/ClaudeDo.Ui.Tests/ViewModels/WorktreesOverviewBatchMergeTests.cs
|
||||
git commit -m "feat(ui): add batch-merge row state to worktrees cockpit VM"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Batch orchestration (`MergeSelectedAsync` skip-and-continue)
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/ClaudeDo.Ui/ViewModels/Modals/WorktreesOverviewModalViewModel.cs`
|
||||
- Test: `tests/ClaudeDo.Ui.Tests/ViewModels/WorktreesOverviewBatchMergeTests.cs`
|
||||
|
||||
- [ ] **Step 1: Write the failing tests**
|
||||
|
||||
Append to `WorktreesOverviewBatchMergeTests.cs`. The helper builds a VM with a never-connected `WorkerClient` (the loop never touches it) and seeds `Rows` directly:
|
||||
|
||||
```csharp
|
||||
private static WorktreesOverviewModalViewModel NewVm() =>
|
||||
new(new ClaudeDo.Ui.Services.WorkerClient("http://127.0.0.1:1/hub"), () => null!);
|
||||
|
||||
private static MergeResultDto Merged() => new("merged", System.Array.Empty<string>(), null);
|
||||
private static MergeResultDto Conflict() => new("conflict", new[] { "f.cs" }, null);
|
||||
private static MergeResultDto Blocked() => new("blocked", System.Array.Empty<string>(), "blocked");
|
||||
|
||||
[Fact]
|
||||
public async System.Threading.Tasks.Task MergeSelected_only_processes_checked_active_rows()
|
||||
{
|
||||
var vm = NewVm();
|
||||
var a = ActiveRow("a"); a.IsChecked = true;
|
||||
var b = ActiveRow("b"); b.IsChecked = false; // unchecked -> skipped
|
||||
var c = ActiveRow("c"); c.IsChecked = true; c.State = WorktreeState.Merged; // not active -> skipped
|
||||
vm.Rows.Add(a); vm.Rows.Add(b); vm.Rows.Add(c);
|
||||
vm.SelectedTarget = "main";
|
||||
|
||||
var seen = new System.Collections.Generic.List<string>();
|
||||
await vm.MergeSelectedAsync((id, target, remove, msg) =>
|
||||
{
|
||||
seen.Add(id);
|
||||
Assert.Equal("main", target);
|
||||
Assert.False(remove); // removeWorktree must be false
|
||||
return System.Threading.Tasks.Task.FromResult(Merged());
|
||||
});
|
||||
|
||||
Assert.Equal(new[] { "a" }, seen);
|
||||
Assert.Equal(BatchMergeOutcome.Merged, a.MergeOutcome);
|
||||
Assert.False(a.IsChecked); // cleared after merge
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async System.Threading.Tasks.Task MergeSelected_continues_past_conflict_and_collects_it()
|
||||
{
|
||||
var vm = NewVm();
|
||||
var a = ActiveRow("a"); a.IsChecked = true;
|
||||
var b = ActiveRow("b"); b.IsChecked = true;
|
||||
var c = ActiveRow("c"); c.IsChecked = true;
|
||||
vm.Rows.Add(a); vm.Rows.Add(b); vm.Rows.Add(c);
|
||||
vm.SelectedTarget = "main";
|
||||
|
||||
await vm.MergeSelectedAsync((id, target, remove, msg) =>
|
||||
System.Threading.Tasks.Task.FromResult(id == "b" ? Conflict() : Merged()));
|
||||
|
||||
Assert.Equal(BatchMergeOutcome.Merged, a.MergeOutcome);
|
||||
Assert.Equal(BatchMergeOutcome.Conflict, b.MergeOutcome);
|
||||
Assert.Equal(BatchMergeOutcome.Merged, c.MergeOutcome); // continued past the conflict
|
||||
Assert.Contains(b, vm.ConflictRows);
|
||||
Assert.Single(vm.ConflictRows);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async System.Threading.Tasks.Task MergeSelected_maps_blocked_and_exception_to_failure_outcomes()
|
||||
{
|
||||
var vm = NewVm();
|
||||
var a = ActiveRow("a"); a.IsChecked = true;
|
||||
var b = ActiveRow("b"); b.IsChecked = true;
|
||||
vm.Rows.Add(a); vm.Rows.Add(b);
|
||||
vm.SelectedTarget = "main";
|
||||
|
||||
await vm.MergeSelectedAsync((id, target, remove, msg) => id == "a"
|
||||
? System.Threading.Tasks.Task.FromResult(Blocked())
|
||||
: throw new System.InvalidOperationException("boom"));
|
||||
|
||||
Assert.Equal(BatchMergeOutcome.Blocked, a.MergeOutcome);
|
||||
Assert.Equal(BatchMergeOutcome.Failed, b.MergeOutcome);
|
||||
Assert.Empty(vm.ConflictRows);
|
||||
Assert.False(vm.IsMerging);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async System.Threading.Tasks.Task MergeSelected_noop_when_no_target()
|
||||
{
|
||||
var vm = NewVm();
|
||||
var a = ActiveRow("a"); a.IsChecked = true;
|
||||
vm.Rows.Add(a);
|
||||
vm.SelectedTarget = null;
|
||||
|
||||
var called = false;
|
||||
await vm.MergeSelectedAsync((id, t, r, m) => { called = true; return System.Threading.Tasks.Task.FromResult(Merged()); });
|
||||
|
||||
Assert.False(called);
|
||||
Assert.Equal(BatchMergeOutcome.None, a.MergeOutcome);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run tests to verify they fail**
|
||||
|
||||
Run: `dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj -c Release --filter WorktreesOverviewBatchMergeTests`
|
||||
Expected: FAIL — `MergeSelectedAsync`, `ConflictRows`, `IsMerging`, `SelectedTarget` do not exist (compile error).
|
||||
|
||||
- [ ] **Step 3: Implement the orchestration + cockpit fields**
|
||||
|
||||
In `WorktreesOverviewModalViewModel.cs`, add these `using`s if missing: `using ClaudeDo.Ui.Services;` (already present). Add fields/properties to `WorktreesOverviewModalViewModel` (after the existing `_selectedRow` field):
|
||||
|
||||
```csharp
|
||||
[ObservableProperty][NotifyCanExecuteChangedFor(nameof(MergeAllCommand))] private string? _selectedTarget;
|
||||
[ObservableProperty][NotifyCanExecuteChangedFor(nameof(MergeAllCommand))] private int _selectedCount;
|
||||
[ObservableProperty][NotifyCanExecuteChangedFor(nameof(MergeAllCommand))] private bool _isMerging;
|
||||
[ObservableProperty] private string? _batchProgress;
|
||||
|
||||
public ObservableCollection<string> MergeTargets { get; } = new();
|
||||
public ObservableCollection<WorktreeOverviewRowViewModel> ConflictRows { get; } = new();
|
||||
|
||||
/// Inert seam wired by the integrator to Layer C's resolver at merge time. (taskId, targetBranch)
|
||||
public Func<string, string, Task>? RequestConflictResolution { get; set; }
|
||||
```
|
||||
|
||||
Add a helper to enumerate rows regardless of grouped/flat mode, plus the orchestration method:
|
||||
|
||||
```csharp
|
||||
public IEnumerable<WorktreeOverviewRowViewModel> AllRows =>
|
||||
IsGlobal ? Groups.SelectMany(g => g.Rows) : Rows;
|
||||
|
||||
public async Task MergeSelectedAsync(
|
||||
Func<string, string, bool, string, Task<MergeResultDto>> mergeFn,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var target = SelectedTarget;
|
||||
if (string.IsNullOrWhiteSpace(target)) return;
|
||||
|
||||
var selected = AllRows.Where(r => r.IsChecked && r.IsActive).ToList();
|
||||
if (selected.Count == 0) return;
|
||||
|
||||
IsMerging = true;
|
||||
ConflictRows.Clear();
|
||||
var done = 0;
|
||||
try
|
||||
{
|
||||
foreach (var row in selected)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
row.MergeOutcome = BatchMergeOutcome.Merging;
|
||||
BatchProgress = Loc.T("vm.worktreesOverview.batchProgress", ++done, selected.Count);
|
||||
|
||||
MergeResultDto result;
|
||||
try
|
||||
{
|
||||
result = await mergeFn(row.TaskId, target!, false,
|
||||
Loc.T("vm.merge.commitMessage", row.TaskTitle));
|
||||
}
|
||||
catch
|
||||
{
|
||||
row.MergeOutcome = BatchMergeOutcome.Failed;
|
||||
continue;
|
||||
}
|
||||
|
||||
switch (result.Status)
|
||||
{
|
||||
case "merged":
|
||||
row.MergeOutcome = BatchMergeOutcome.Merged;
|
||||
row.State = WorktreeState.Merged;
|
||||
row.IsChecked = false;
|
||||
break;
|
||||
case "conflict":
|
||||
row.MergeOutcome = BatchMergeOutcome.Conflict;
|
||||
ConflictRows.Add(row);
|
||||
break;
|
||||
case "blocked":
|
||||
row.MergeOutcome = BatchMergeOutcome.Blocked;
|
||||
break;
|
||||
default:
|
||||
row.MergeOutcome = BatchMergeOutcome.Failed;
|
||||
break;
|
||||
}
|
||||
}
|
||||
BatchProgress = Loc.T("vm.worktreesOverview.batchDone",
|
||||
selected.Count(r => r.MergeOutcome == BatchMergeOutcome.Merged), ConflictRows.Count);
|
||||
}
|
||||
finally
|
||||
{
|
||||
IsMerging = false;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
> Note: `Loc.T` keys are added in Task 5; they resolve to the key name (harmless) until then, so tests pass now.
|
||||
|
||||
- [ ] **Step 4: Run tests to verify they pass**
|
||||
|
||||
Run: `dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj -c Release --filter WorktreesOverviewBatchMergeTests`
|
||||
Expected: PASS (5 tests).
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/ClaudeDo.Ui/ViewModels/Modals/WorktreesOverviewModalViewModel.cs tests/ClaudeDo.Ui.Tests/ViewModels/WorktreesOverviewBatchMergeTests.cs
|
||||
git commit -m "feat(ui): add skip-and-continue batch merge orchestration"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Selection tracking, target loading, commands + resolve seam
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/ClaudeDo.Ui/ViewModels/Modals/WorktreesOverviewModalViewModel.cs`
|
||||
- Test: `tests/ClaudeDo.Ui.Tests/ViewModels/WorktreesOverviewBatchMergeTests.cs`
|
||||
|
||||
- [ ] **Step 1: Write the failing tests**
|
||||
|
||||
Append to `WorktreesOverviewBatchMergeTests.cs`:
|
||||
|
||||
```csharp
|
||||
[Fact]
|
||||
public void SelectedCount_tracks_checked_active_rows()
|
||||
{
|
||||
var vm = NewVm();
|
||||
var a = ActiveRow("a");
|
||||
var b = ActiveRow("b");
|
||||
var merged = ActiveRow("c"); merged.State = WorktreeState.Merged;
|
||||
vm.AddRowForTest(a); vm.AddRowForTest(b); vm.AddRowForTest(merged);
|
||||
|
||||
Assert.Equal(0, vm.SelectedCount);
|
||||
a.IsChecked = true;
|
||||
Assert.Equal(1, vm.SelectedCount);
|
||||
b.IsChecked = true;
|
||||
merged.IsChecked = true; // not active -> not counted
|
||||
Assert.Equal(2, vm.SelectedCount);
|
||||
a.IsChecked = false;
|
||||
Assert.Equal(1, vm.SelectedCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolveConflict_invokes_seam_with_task_and_target()
|
||||
{
|
||||
var vm = NewVm();
|
||||
vm.SelectedTarget = "release";
|
||||
var row = ActiveRow("x"); row.MergeOutcome = BatchMergeOutcome.Conflict;
|
||||
|
||||
(string Task, string Target)? captured = null;
|
||||
vm.RequestConflictResolution = (taskId, target) => { captured = (taskId, target); return System.Threading.Tasks.Task.CompletedTask; };
|
||||
|
||||
vm.ResolveConflictCommand.Execute(row);
|
||||
|
||||
Assert.Equal(("x", "release"), captured);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MergeAll_canExecute_requires_target_selection_and_idle()
|
||||
{
|
||||
var vm = NewVm();
|
||||
var a = ActiveRow("a");
|
||||
vm.AddRowForTest(a);
|
||||
|
||||
Assert.False(vm.MergeAllCommand.CanExecute(null)); // no selection, no target
|
||||
a.IsChecked = true;
|
||||
Assert.False(vm.MergeAllCommand.CanExecute(null)); // still no target
|
||||
vm.SelectedTarget = "main";
|
||||
Assert.True(vm.MergeAllCommand.CanExecute(null));
|
||||
vm.IsMerging = true;
|
||||
Assert.False(vm.MergeAllCommand.CanExecute(null)); // busy
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run tests to verify they fail**
|
||||
|
||||
Run: `dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj -c Release --filter WorktreesOverviewBatchMergeTests`
|
||||
Expected: FAIL — `AddRowForTest`, `ResolveConflictCommand`, `MergeAllCommand` do not exist (compile error).
|
||||
|
||||
- [ ] **Step 3: Implement subscription, commands, target loading**
|
||||
|
||||
In `WorktreesOverviewModalViewModel.cs`:
|
||||
|
||||
(a) Add a row-hook that recomputes `SelectedCount` when a row's `IsChecked` changes, and a test seam to add a hooked row. Add these methods to the class:
|
||||
|
||||
```csharp
|
||||
private void HookRow(WorktreeOverviewRowViewModel row)
|
||||
{
|
||||
row.PropertyChanged += (_, e) =>
|
||||
{
|
||||
if (e.PropertyName is nameof(WorktreeOverviewRowViewModel.IsChecked)
|
||||
or nameof(WorktreeOverviewRowViewModel.State))
|
||||
RecomputeSelected();
|
||||
};
|
||||
}
|
||||
|
||||
private void RecomputeSelected() =>
|
||||
SelectedCount = AllRows.Count(r => r.IsChecked && r.IsActive);
|
||||
|
||||
// Test seam: adds a row to the flat list with selection tracking wired up.
|
||||
internal void AddRowForTest(WorktreeOverviewRowViewModel row)
|
||||
{
|
||||
HookRow(row);
|
||||
Rows.Add(row);
|
||||
}
|
||||
```
|
||||
|
||||
(b) In `LoadAsync`, call `HookRow(row)` everywhere a row is added. Replace the two add sites:
|
||||
|
||||
In the grouped branch, change `foreach (var row in grp) group.Rows.Add(row);` to:
|
||||
|
||||
```csharp
|
||||
foreach (var row in grp) { HookRow(row); group.Rows.Add(row); }
|
||||
```
|
||||
|
||||
In the flat branch, change `foreach (var row in ordered) Rows.Add(row);` to:
|
||||
|
||||
```csharp
|
||||
foreach (var row in ordered) { HookRow(row); Rows.Add(row); }
|
||||
```
|
||||
|
||||
Also, at the start of `LoadAsync` after `IsBusy = true;`, reset batch UI state and (re)load merge targets at the end of the `try`:
|
||||
|
||||
After `Rows.Clear(); Groups.Clear();` add:
|
||||
|
||||
```csharp
|
||||
ConflictRows.Clear();
|
||||
SelectedCount = 0;
|
||||
BatchProgress = null;
|
||||
```
|
||||
|
||||
At the very end of the `try` block (after the if/else that fills rows/groups) add:
|
||||
|
||||
```csharp
|
||||
await LoadMergeTargetsAsync();
|
||||
```
|
||||
|
||||
(c) Add target loading. The branch list is repo-level, so query it from the first active row:
|
||||
|
||||
```csharp
|
||||
private async Task LoadMergeTargetsAsync()
|
||||
{
|
||||
var anchor = AllRows.FirstOrDefault(r => r.IsActive);
|
||||
if (anchor is null) { MergeTargets.Clear(); SelectedTarget = null; return; }
|
||||
try
|
||||
{
|
||||
var targets = await _worker.GetMergeTargetsAsync(anchor.TaskId);
|
||||
MergeTargets.Clear();
|
||||
if (targets is null) { SelectedTarget = null; return; }
|
||||
foreach (var b in targets.LocalBranches) MergeTargets.Add(b);
|
||||
SelectedTarget = MergeTargets.Contains(targets.DefaultBranch)
|
||||
? targets.DefaultBranch
|
||||
: MergeTargets.FirstOrDefault();
|
||||
}
|
||||
catch { MergeTargets.Clear(); SelectedTarget = null; }
|
||||
}
|
||||
```
|
||||
|
||||
(d) Add the commands:
|
||||
|
||||
```csharp
|
||||
private bool CanMergeAll() => !IsMerging && SelectedCount > 0 && !string.IsNullOrWhiteSpace(SelectedTarget);
|
||||
|
||||
[RelayCommand(CanExecute = nameof(CanMergeAll))]
|
||||
private Task MergeAll() => MergeSelectedAsync(_worker.MergeTaskAsync);
|
||||
|
||||
[RelayCommand]
|
||||
private void ResolveConflict(WorktreeOverviewRowViewModel? row)
|
||||
{
|
||||
if (row is null) return;
|
||||
RequestConflictResolution?.Invoke(row.TaskId, SelectedTarget ?? "");
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void ToggleSelectAll()
|
||||
{
|
||||
var actives = AllRows.Where(r => r.IsActive).ToList();
|
||||
var allChecked = actives.Count > 0 && actives.All(r => r.IsChecked);
|
||||
foreach (var r in actives) r.IsChecked = !allChecked;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run tests to verify they pass**
|
||||
|
||||
Run: `dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj -c Release --filter WorktreesOverviewBatchMergeTests`
|
||||
Expected: PASS (8 tests total in this file).
|
||||
|
||||
- [ ] **Step 5: Build the app project to confirm the VM compiles against generated commands**
|
||||
|
||||
Run: `dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj -c Release`
|
||||
Expected: Build succeeded.
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add src/ClaudeDo.Ui/ViewModels/Modals/WorktreesOverviewModalViewModel.cs tests/ClaudeDo.Ui.Tests/ViewModels/WorktreesOverviewBatchMergeTests.cs
|
||||
git commit -m "feat(ui): wire batch selection, target loading and resolve seam"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: Cockpit view — checkboxes, target picker, Merge all, conflicts panel
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/ClaudeDo.Ui/Views/Modals/WorktreesOverviewModalView.axaml`
|
||||
|
||||
This task is AXAML only (no logic) → no new unit test; flag for visual verification.
|
||||
|
||||
- [ ] **Step 1: Add the batch toolbar controls**
|
||||
|
||||
In `WorktreesOverviewModalView.axaml`, replace the toolbar `StackPanel` (currently containing Refresh, Cleanup finished, StatusMessage) with one that adds select-all, the target picker, the Merge-all button and progress text. Replace the inner `<StackPanel Orientation="Horizontal" Spacing="8">...</StackPanel>` of the toolbar `Border` with:
|
||||
|
||||
```xml
|
||||
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||
<Button Classes="btn" Content="{loc:Tr modals.worktreesOverview.refresh}" Command="{Binding RefreshCommand}" IsEnabled="{Binding !IsBusy}"/>
|
||||
<Button Classes="btn" Content="{loc:Tr modals.worktreesOverview.cleanupFinished}" Command="{Binding CleanupFinishedCommand}" IsEnabled="{Binding !IsBusy}"/>
|
||||
<Button Classes="btn" Content="{loc:Tr modals.worktreesOverview.selectAll}" Command="{Binding ToggleSelectAllCommand}"/>
|
||||
<Border Width="1" Background="{DynamicResource LineBrush}" Margin="4,2"/>
|
||||
<TextBlock Text="{loc:Tr modals.worktreesOverview.targetLabel}" VerticalAlignment="Center" Foreground="{DynamicResource TextDimBrush}"/>
|
||||
<ComboBox MinWidth="160"
|
||||
ItemsSource="{Binding MergeTargets}"
|
||||
SelectedItem="{Binding SelectedTarget, Mode=TwoWay}"/>
|
||||
<Button Classes="btn accent"
|
||||
Content="{loc:Tr modals.worktreesOverview.mergeAll}"
|
||||
Command="{Binding MergeAllCommand}"/>
|
||||
<TextBlock Text="{Binding SelectedCount, StringFormat='{}{0} selected'}"
|
||||
VerticalAlignment="Center" Foreground="{DynamicResource TextDimBrush}"/>
|
||||
<TextBlock Text="{Binding BatchProgress}" VerticalAlignment="Center" Margin="8,0,0,0"
|
||||
Foreground="{DynamicResource TextDimBrush}"/>
|
||||
<TextBlock Text="{Binding StatusMessage}" VerticalAlignment="Center" Margin="8,0,0,0"
|
||||
Foreground="{DynamicResource TextDimBrush}"/>
|
||||
</StackPanel>
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add a checkbox + outcome badge to the row template**
|
||||
|
||||
In the `WorktreeRowTemplate` `DataTemplate`, change the row `Grid` to add a leading checkbox column and a trailing outcome column. Replace the `<Grid ColumnDefinitions="*,90,80,80">...</Grid>` (the whole grid, lines for Task/State/Diff/Age) with:
|
||||
|
||||
```xml
|
||||
<Grid ColumnDefinitions="Auto,*,90,90,80,80">
|
||||
<CheckBox Grid.Column="0" VerticalAlignment="Center" Margin="0,0,8,0"
|
||||
IsChecked="{Binding IsChecked, Mode=TwoWay}"
|
||||
IsEnabled="{Binding IsActive}"
|
||||
IsVisible="{Binding IsActive}"/>
|
||||
<StackPanel Grid.Column="1" Orientation="Vertical" Spacing="2">
|
||||
<TextBlock Classes="title" Text="{Binding TaskTitle}"/>
|
||||
<StackPanel Orientation="Horizontal" Spacing="4">
|
||||
<TextBlock Classes="meta" Text="{Binding TaskStatus}"/>
|
||||
<TextBlock Classes="meta" Text="•"
|
||||
IsVisible="{Binding !PathExistsOnDisk}"/>
|
||||
<TextBlock Classes="meta" Text="{loc:Tr modals.worktreesOverview.phantom}" Foreground="{DynamicResource StatusErrorBrush}"
|
||||
IsVisible="{Binding !PathExistsOnDisk}"
|
||||
ToolTip.Tip="{loc:Tr modals.worktreesOverview.phantomTooltip}"/>
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
<TextBlock Grid.Column="2" Classes="meta" VerticalAlignment="Center"
|
||||
Text="{Binding MergeOutcome}"
|
||||
IsVisible="{Binding HasOutcome}"/>
|
||||
<Border Grid.Column="3" CornerRadius="3" Padding="6,2" VerticalAlignment="Center"
|
||||
Background="{Binding State, Converter={StaticResource WorktreeStateColor}}">
|
||||
<TextBlock Classes="meta" Text="{Binding State}" Foreground="{DynamicResource TextBrush}"
|
||||
HorizontalAlignment="Center"/>
|
||||
</Border>
|
||||
<TextBlock Grid.Column="4" Classes="meta" Text="{Binding DiffStat}" VerticalAlignment="Center"/>
|
||||
<TextBlock Grid.Column="5" Classes="meta" Text="{Binding AgeText}" VerticalAlignment="Center"/>
|
||||
</Grid>
|
||||
```
|
||||
|
||||
Then update the column-header `Grid` (the one with `ColumnDefinitions="*,90,80,80"` near the ScrollViewer top) to match the new column layout:
|
||||
|
||||
```xml
|
||||
<Grid ColumnDefinitions="Auto,*,90,90,80,80" Margin="12,0,12,4">
|
||||
<TextBlock Grid.Column="1" Classes="eyebrow" Text="{loc:Tr modals.worktreesOverview.columnTask}"/>
|
||||
<TextBlock Grid.Column="2" Classes="eyebrow" Text="{loc:Tr modals.worktreesOverview.columnOutcome}"/>
|
||||
<TextBlock Grid.Column="3" Classes="eyebrow" Text="{loc:Tr modals.worktreesOverview.columnState}"/>
|
||||
<TextBlock Grid.Column="4" Classes="eyebrow" Text="{loc:Tr modals.worktreesOverview.columnDiff}"/>
|
||||
<TextBlock Grid.Column="5" Classes="eyebrow" Text="{loc:Tr modals.worktreesOverview.columnAge}"/>
|
||||
</Grid>
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Add the "Needs resolution" panel**
|
||||
|
||||
Inside the content `ScrollViewer`'s root `StackPanel`, at the very top (before the column-header `Grid`), add a conflicts panel that only shows when there are conflicts:
|
||||
|
||||
```xml
|
||||
<Border IsVisible="{Binding ConflictRows.Count}"
|
||||
Background="{DynamicResource ErrorTintBrush}"
|
||||
BorderBrush="{DynamicResource StatusErrorBrush}"
|
||||
BorderThickness="1" CornerRadius="6" Padding="12,8" Margin="0,0,0,12">
|
||||
<StackPanel Spacing="6">
|
||||
<TextBlock Classes="eyebrow" Text="{loc:Tr modals.worktreesOverview.needsResolution}"/>
|
||||
<ItemsControl ItemsSource="{Binding ConflictRows}">
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:WorktreeOverviewRowViewModel">
|
||||
<Grid ColumnDefinitions="*,Auto" Margin="0,2">
|
||||
<TextBlock Grid.Column="0" Classes="meta" VerticalAlignment="Center"
|
||||
Text="{Binding TaskTitle}"/>
|
||||
<Button Grid.Column="1" Classes="btn"
|
||||
Content="{loc:Tr modals.worktreesOverview.resolve}"
|
||||
Command="{Binding $parent[Window].((vm:WorktreesOverviewModalViewModel)DataContext).ResolveConflictCommand}"
|
||||
CommandParameter="{Binding}"/>
|
||||
</Grid>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
```
|
||||
|
||||
> `IsVisible="{Binding ConflictRows.Count}"` uses Avalonia's int→bool coercion (0 = false). If the build flags this, change to a value converter already present, but int→bool is supported.
|
||||
|
||||
- [ ] **Step 4: Build the app to verify the AXAML compiles**
|
||||
|
||||
Run: `dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj -c Release`
|
||||
Expected: Build succeeded (compiled bindings resolve against the new VM members).
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/ClaudeDo.Ui/Views/Modals/WorktreesOverviewModalView.axaml
|
||||
git commit -m "feat(ui): batch-merge cockpit view with checkboxes and conflicts panel"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 5: Localization keys (en + de parity)
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/ClaudeDo.Localization/locales/en.json`
|
||||
- Modify: `src/ClaudeDo.Localization/locales/de.json`
|
||||
|
||||
- [ ] **Step 1: Add the new keys to `en.json`**
|
||||
|
||||
Under `modals.worktreesOverview`, add:
|
||||
|
||||
```json
|
||||
"columnOutcome": "RESULT",
|
||||
"selectAll": "Select all",
|
||||
"targetLabel": "Target",
|
||||
"mergeAll": "Merge all",
|
||||
"needsResolution": "NEEDS RESOLUTION",
|
||||
"resolve": "Resolve"
|
||||
```
|
||||
|
||||
Under `vm.worktreesOverview`, add:
|
||||
|
||||
```json
|
||||
"batchProgress": "Merging {0}/{1}…",
|
||||
"batchDone": "Merged {0}, {1} need resolution."
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add the matching keys to `de.json`**
|
||||
|
||||
Under `modals.worktreesOverview`:
|
||||
|
||||
```json
|
||||
"columnOutcome": "ERGEBNIS",
|
||||
"selectAll": "Alle auswählen",
|
||||
"targetLabel": "Ziel",
|
||||
"mergeAll": "Alle mergen",
|
||||
"needsResolution": "ZU LÖSEN",
|
||||
"resolve": "Lösen"
|
||||
```
|
||||
|
||||
Under `vm.worktreesOverview`:
|
||||
|
||||
```json
|
||||
"batchProgress": "Merge {0}/{1}…",
|
||||
"batchDone": "{0} gemergt, {1} zu lösen."
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Run the localization parity test**
|
||||
|
||||
Run: `dotnet test tests/ClaudeDo.Localization.Tests/ClaudeDo.Localization.Tests.csproj -c Release`
|
||||
Expected: PASS (en/de key parity holds).
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add src/ClaudeDo.Localization/locales/en.json src/ClaudeDo.Localization/locales/de.json
|
||||
git commit -m "feat(i18n): add batch-merge cockpit strings (en/de)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 6: Migrate `WorktreeModalView` diff onto `DiffLinesView`
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/ClaudeDo.Ui/ViewModels/Modals/WorktreeModalViewModel.cs`
|
||||
- Modify: `src/ClaudeDo.Ui/Views/Modals/WorktreeModalView.axaml`
|
||||
|
||||
- [ ] **Step 1: Switch the VM to the canonical diff model**
|
||||
|
||||
In `WorktreeModalViewModel.cs`:
|
||||
|
||||
(a) Delete the now-dead types at the top of the file:
|
||||
|
||||
```csharp
|
||||
public enum WorktreeDiffLineKind { Header, Hunk, Added, Removed, Context }
|
||||
|
||||
public sealed partial class WorktreeDiffLineViewModel : ViewModelBase
|
||||
{
|
||||
public required string Text { get; init; }
|
||||
public required WorktreeDiffLineKind Kind { get; init; }
|
||||
}
|
||||
```
|
||||
|
||||
(b) Change the collection declaration from:
|
||||
|
||||
```csharp
|
||||
public ObservableCollection<WorktreeDiffLineViewModel> SelectedFileDiffLines { get; } = new();
|
||||
```
|
||||
|
||||
to:
|
||||
|
||||
```csharp
|
||||
public ObservableCollection<DiffLineViewModel> SelectedFileDiffLines { get; } = new();
|
||||
```
|
||||
|
||||
(c) Replace the body of `LoadFileDiffAsync` (the `foreach (var line in diff.Split('\n'))` block) so it parses via `UnifiedDiffParser`. The method becomes:
|
||||
|
||||
```csharp
|
||||
private async Task LoadFileDiffAsync(WorktreeNodeViewModel? node)
|
||||
{
|
||||
SelectedFileDiffLines.Clear();
|
||||
|
||||
if (node is null || node.IsDirectory || string.IsNullOrEmpty(node.RelativePath))
|
||||
return;
|
||||
|
||||
string diff;
|
||||
try
|
||||
{
|
||||
diff = await _git.GetFileDiffAsync(WorktreePath, BaseCommit, node.RelativePath);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var line in UnifiedDiffParser.Flatten(UnifiedDiffParser.Parse(diff)))
|
||||
SelectedFileDiffLines.Add(line);
|
||||
}
|
||||
```
|
||||
|
||||
(`DiffLineViewModel`, `DiffFileViewModel`, and `UnifiedDiffParser` are all in the same `ClaudeDo.Ui.ViewModels.Modals` namespace, so no new `using` is required.)
|
||||
|
||||
- [ ] **Step 2: Build to confirm the VM compiles and nothing else referenced the deleted types**
|
||||
|
||||
Run: `dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj -c Release`
|
||||
Expected: Build succeeded. (If a compile error names `WorktreeDiffLineViewModel`/`WorktreeDiffLineKind` outside this file or the view, that reference must be migrated too — there should be none besides `WorktreeModalView.axaml`, handled next.)
|
||||
|
||||
- [ ] **Step 3: Swap the view's inline diff for `DiffLinesView`**
|
||||
|
||||
In `WorktreeModalView.axaml`:
|
||||
|
||||
(a) Remove the now-unused converter resource. Delete:
|
||||
|
||||
```xml
|
||||
<Window.Resources>
|
||||
<converters:DiffLineKindToBrushConverter x:Key="DiffLineKindToBrush"/>
|
||||
</Window.Resources>
|
||||
```
|
||||
|
||||
(b) Replace the right-pane `ScrollViewer`'s `ItemsControl` (the `SelectableTextBlock` template bound to `SelectedFileDiffLines`) with the canonical control. Replace:
|
||||
|
||||
```xml
|
||||
<ItemsControl ItemsSource="{Binding SelectedFileDiffLines}">
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate DataType="vm:WorktreeDiffLineViewModel">
|
||||
<SelectableTextBlock Text="{Binding Text}"
|
||||
FontFamily="{DynamicResource MonoFont}"
|
||||
FontSize="{StaticResource FontSizeMono}"
|
||||
Foreground="{Binding Kind, Converter={StaticResource DiffLineKindToBrush}}"
|
||||
TextWrapping="NoWrap"/>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
```
|
||||
|
||||
with:
|
||||
|
||||
```xml
|
||||
<ctl:DiffLinesView Lines="{Binding SelectedFileDiffLines}"/>
|
||||
```
|
||||
|
||||
(The `xmlns:ctl="using:ClaudeDo.Ui.Views.Controls"` namespace is already declared at the top of this file.)
|
||||
|
||||
- [ ] **Step 4: Build the app to verify the AXAML compiles**
|
||||
|
||||
Run: `dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj -c Release`
|
||||
Expected: Build succeeded.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/ClaudeDo.Ui/ViewModels/Modals/WorktreeModalViewModel.cs src/ClaudeDo.Ui/Views/Modals/WorktreeModalView.axaml
|
||||
git commit -m "refactor(ui): render worktree modal diff via canonical DiffLinesView"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 7: Full build + test sweep
|
||||
|
||||
**Files:** none (verification only).
|
||||
|
||||
- [ ] **Step 1: Build the whole app**
|
||||
|
||||
Run: `dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj -c Release`
|
||||
Expected: Build succeeded, 0 errors.
|
||||
|
||||
- [ ] **Step 2: Run the UI + localization test projects**
|
||||
|
||||
Run: `dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj -c Release`
|
||||
Then: `dotnet test tests/ClaudeDo.Localization.Tests/ClaudeDo.Localization.Tests.csproj -c Release`
|
||||
Expected: PASS (all green, including the 8 new batch-merge tests).
|
||||
|
||||
- [ ] **Step 3: Flag visual-verification gaps**
|
||||
|
||||
The cockpit toolbar/checkbox/conflicts-panel layout and the migrated `WorktreeModalView` diff rendering are AXAML changes that cannot be verified headlessly. Report to the user that these need a visual pass (run the app, open the worktrees overview, select several worktrees, pick a target, "Merge all", and open a worktree diff).
|
||||
|
||||
---
|
||||
|
||||
## Self-Review Notes
|
||||
|
||||
- **Spec coverage:** batch-merge cockpit (Tasks 1–4), skip-and-continue + conflict collection (Task 2), single target picker (Tasks 3–4), Resolve → `RequestConflictResolution(taskId, targetBranch)` seam left unwired (Tasks 3–4), `WorktreeModalView` diff migration to `DiffLinesView` (Task 6), no worker files touched, no `IWorkerClient` change, locales in parity (Task 5). ✔
|
||||
- **No ConflictResolver reference:** the seam is a bare `Func<string,string,Task>?`; no Layer C type is named. ✔
|
||||
- **Type consistency:** `BatchMergeOutcome`, `MergeOutcome`, `IsConflict`, `HasOutcome`, `MergeSelectedAsync`, `ConflictRows`, `SelectedTarget`, `SelectedCount`, `IsMerging`, `BatchProgress`, `RequestConflictResolution`, `MergeAllCommand`, `ResolveConflictCommand`, `ToggleSelectAllCommand`, `AddRowForTest`, `AllRows` are used consistently across tasks. ✔
|
||||
@@ -0,0 +1,55 @@
|
||||
# Plan: Per-task model override via MCP + cheapest-model prompt guidance
|
||||
|
||||
Spec: `docs/superpowers/specs/2026-06-09-per-task-model-override-design.md`
|
||||
|
||||
TDD, one focused commit per task. Build with `-c Release` per project; run
|
||||
`ClaudeDo.Worker.Tests` (and `Data.Tests` if touched).
|
||||
|
||||
## Task 1 — ModelRegistry: cost ordering + alias validation
|
||||
|
||||
- Add `ByCostAscending = ["haiku","sonnet","opus"]`.
|
||||
- Add `string? NormalizeAlias(string? model)`: trim; null/blank → null;
|
||||
case-insensitive match against `Aliases` → canonical lowercase; else throw
|
||||
`ArgumentException($"Unknown model '{model}'. Allowed: {join(Aliases)}.")`.
|
||||
- Tests (Data.Tests): "sonnet"/"OPUS"/" haiku " → normalized; ""/null/" " →
|
||||
null; "gpt4" → throws.
|
||||
|
||||
## Task 2 — CreateChildAsync accepts model
|
||||
|
||||
- `TaskRepository.CreateChildAsync`: add `string? model = null` (before the
|
||||
trailing `CancellationToken ct = default`); set
|
||||
`child.Model = ModelRegistry.NormalizeAlias(model)`.
|
||||
- Update the two existing callers to compile (named pass-through added in
|
||||
Tasks 3–4; keep default null here).
|
||||
|
||||
## Task 3 — Planning + improvement MCP tools forward model
|
||||
|
||||
- `PlanningMcpService.CreateChildTask`: add `string? model` param after
|
||||
`commitType`; pass to `CreateChildAsync`. Extend `[Description]` to document
|
||||
the model arg (haiku/sonnet/opus; cheapest capable).
|
||||
- `TaskRunMcpService.SuggestImprovement`: add `string? model` param after
|
||||
`description`; pass to `CreateChildAsync`. Extend `[Description]`.
|
||||
- Tests: each tool persists the model; invalid value throws.
|
||||
|
||||
## Task 4 — External AddTask forwards model
|
||||
|
||||
- `ExternalMcpService.AddTask`: add `string? model = null` param (before the
|
||||
trailing `CancellationToken`); `entity.Model = ModelRegistry.NormalizeAlias(model)`.
|
||||
Extend `[Description]`.
|
||||
- Test: AddTask persists model; invalid value rejected.
|
||||
|
||||
## Task 5 — Prompt guidance
|
||||
|
||||
- `PromptFiles.PlanningSystemDefault`: add a short paragraph — assign each
|
||||
subtask the cheapest model that does it well, with ordering haiku < sonnet <
|
||||
opus and the heuristic; pass it as `CreateChildTask(model=...)`.
|
||||
- `PromptFiles.SystemDefault` Out-of-scope section: when filing via
|
||||
`SuggestImprovement`, pass the cheapest capable `model`.
|
||||
- `PromptFiles.ImprovementChildDefault`: one-line minimality reminder.
|
||||
- No test (static prompt text); verify build only.
|
||||
|
||||
## Task 6 — Verify
|
||||
|
||||
- Build App + Worker `-c Release`; run Worker.Tests + Data.Tests.
|
||||
- Update `ClaudeDo.Worker/CLAUDE.md` (ConfigMcpTools/creation-tool notes) and
|
||||
`ClaudeDo.Data/CLAUDE.md` (ModelRegistry) if needed.
|
||||
@@ -0,0 +1,90 @@
|
||||
# Plan — Unify the parent-task model
|
||||
|
||||
Spec: `docs/superpowers/specs/2026-06-09-unify-parent-task-model-design.md`
|
||||
|
||||
Subagents: `sonnet`. Stage files explicitly by path (never `git add -A`). TDD.
|
||||
Build with `-c Release` per project. Commit per task (Conventional Commits).
|
||||
|
||||
## Task 1 — Single parent-advance path
|
||||
|
||||
- Rename `TaskStateService.TryAdvanceImprovementParentAsync` → `TryAdvanceParentAsync`.
|
||||
- Make it advance **any** `WaitingForChildren` parent → `WaitingForReview` when all
|
||||
children are terminal, and advance a parent with **zero** children straight to
|
||||
`WaitingForReview`.
|
||||
- In `OnChildTerminalAsync`: drop the `TryCompleteParentAsync` call; keep
|
||||
`_chain.OnChildFinishedAsync`; call the renamed advance method for all parents.
|
||||
- Tests: extend `WaitingForChildrenLifecycleTests` — (a) improvement parent still
|
||||
advances; (b) a `WaitingForChildren` parent whose children are a *sequential chain*
|
||||
advances only after the last one is terminal; (c) zero-children parent advances.
|
||||
|
||||
## Task 2 — Delete `TryCompleteParentAsync`
|
||||
|
||||
- Remove `TaskRepository.TryCompleteParentAsync` (`TaskRepository.cs:477-502`) and
|
||||
any remaining references.
|
||||
- Update `src/ClaudeDo.Data/CLAUDE.md` (drop it from the TaskRepository helper list).
|
||||
- Build Data + Worker; fix references.
|
||||
|
||||
## Task 3 — Planning finalize enters `WaitingForChildren`
|
||||
|
||||
- `TaskStateService.FinalizePlanningAsync`: in the same `ExecuteUpdateAsync`, set
|
||||
`Status = WaitingForChildren` alongside `PlanningPhase = Finalized` /
|
||||
`PlanningFinalizedAt`.
|
||||
- Verify `PlanningSessionManager.FinalizeAsync` ordering: finalize (→ WaitingForChildren)
|
||||
**before** `SetupChainAsync` enqueues child[0]. Adjust only if ordering is wrong.
|
||||
- Tests: finalizing a planning parent with N children leaves it `WaitingForChildren`;
|
||||
after the chain completes it is `WaitingForReview` (not `Done`); a planning parent
|
||||
with zero finalized children lands in `WaitingForReview`.
|
||||
|
||||
## Task 4 — Approve merges the whole unit
|
||||
|
||||
**Decision: full UX consolidation.** Approve becomes the single entry for reviewing
|
||||
*and* merging any task; the separate planning-merge views are folded into the review
|
||||
panel. The `PlanningMergeOrchestrator` (which already merges the unit + sets the
|
||||
parent `Done` for both planning and improvement, with conflict continue/abort) is
|
||||
reused as the engine; only its *entry/UI* moves.
|
||||
|
||||
Backend:
|
||||
- `WorkerHub.ApproveReview`: for a parent that **has children**, drive
|
||||
`PlanningMergeOrchestrator.StartAsync` (event-based: `PlanningMergeStarted` /
|
||||
`PlanningSubtaskMerged` / `PlanningMergeConflict` / `PlanningMergeAborted` /
|
||||
`PlanningCompleted`) instead of the one-shot `ApproveAndMergeAsync`. Childless tasks
|
||||
keep `ApproveAndMergeAsync`. Conflict resolution still goes through
|
||||
`ContinuePlanningMerge` / `AbortPlanningMerge`.
|
||||
- Keep the orchestrator, `ContinuePlanningMerge`, `AbortPlanningMerge`,
|
||||
`GetPlanningAggregate`, `BuildPlanningIntegrationBranch`. Remove the now-redundant
|
||||
standalone `MergeAllPlanning` hub method (approve is the entry).
|
||||
- (Optional cleanup) route the orchestrator's `FinalizeParentDoneAsync` through
|
||||
`TaskStateService` so `Status` writes stay centralized; low priority.
|
||||
|
||||
UI (Avalonia, MVVM — visual-verification gaps, flag for user):
|
||||
- The review panel (`DetailsIslandViewModel` / its view) is the single approve+merge
|
||||
surface. For a child-bearing parent in `WaitingForReview`, approve shows the
|
||||
unit-merge progress + per-subtask state, the aggregate/integration diff preview, and
|
||||
conflict continue/abort — all inline in the review panel.
|
||||
- Remove the separate planning-merge view(s)/commands and the standalone "Merge all"
|
||||
button; re-wire their `PlanningMerge*` event handlers into the review panel VM.
|
||||
- Sync `IWorkerClient` + hand-rolled test fakes in both UI/Worker test projects.
|
||||
|
||||
Tests: approving a parent with two `Done` children merges both then sets `Done`; a
|
||||
conflicting second child surfaces the conflict and pauses (continue/abort) without
|
||||
losing the parent's `WaitingForReview`/merge state.
|
||||
|
||||
## Task 5 — Cancellable `WaitingForChildren` parent
|
||||
|
||||
- Add `TaskStatus.WaitingForChildren` to the `CancelAsync` guard.
|
||||
- Test: a parent in `WaitingForChildren` can be cancelled.
|
||||
|
||||
## Task 6 — Docs
|
||||
|
||||
- `src/ClaudeDo.Worker/CLAUDE.md`: add `WaitingForChildren` to the Status table +
|
||||
transition diagram; document the unified parent flow and approve-merges-unit;
|
||||
remove `MergeAllPlanning` from the Hub method list.
|
||||
- `src/ClaudeDo.Data/CLAUDE.md`: add `WaitingForChildren` to the TaskEntity status list.
|
||||
- Root `CLAUDE.md`: update the "Task status flow" convention line.
|
||||
|
||||
## Verify
|
||||
|
||||
- `dotnet test` for Worker.Tests + Data.Tests (`-c Release`).
|
||||
- UI flows (planning finalize → review → approve-merge; improvement parent;
|
||||
retired MergeAllPlanning button) are **visual-verification gaps** — flag for the
|
||||
user to run the app; do not claim they work from tests alone.
|
||||
72
docs/superpowers/plans/2026-06-10-online-inbox.md
Normal file
72
docs/superpowers/plans/2026-06-10-online-inbox.md
Normal file
@@ -0,0 +1,72 @@
|
||||
# Online Inbox — implementation plan
|
||||
|
||||
Date: 2026-06-10
|
||||
Spec: `docs/superpowers/specs/2026-06-10-online-inbox-design.md`
|
||||
Contract: `docs/online-inbox-api-contract.md`
|
||||
|
||||
TDD, one commit per task, Conventional Commits. Build with `-c Release` per CLAUDE.md.
|
||||
|
||||
## Phase 1 — Worker sync engine (buildable now, no Zitadel package needed)
|
||||
|
||||
### Task 1 — Config
|
||||
- Add `OnlineInboxConfig` + nested `ZitadelClientConfig` records.
|
||||
- Add `online_inbox` (`OnlineInbox`) property to `WorkerConfig`; default `enabled=false`.
|
||||
- `Load` leaves it untouched when absent (defaults = disabled).
|
||||
- Test: missing section → disabled defaults; populated section round-trips.
|
||||
|
||||
### Task 2 — DTOs + Idle-backlog helper
|
||||
- `Online/Dtos.cs`: `RemoteList(Id, Name)`, `RemoteTask(Id, ListId, Title, Description, CreatedAt)`,
|
||||
`MirrorTask(Id, ListId, Title, Description)`.
|
||||
- `Online/OnlineBacklog.cs`: `static Task<List<MirrorTask>> CurrentAsync(TaskRepository/ctx)` +
|
||||
the filter predicate (Idle, no parent, PlanningPhase None, BlockedBy null).
|
||||
- Test the filter against real SQLite seeded with mixed tasks.
|
||||
|
||||
### Task 3 — Auth abstraction + token store
|
||||
- `Online/Interfaces/IOnlineAuthProvider.cs`.
|
||||
- `Online/OnlineTokenStore.cs`: DPAPI CurrentUser persistence at `~/.todo-app/online-inbox.token`;
|
||||
`Save(refreshToken)`, `Read()`, `Clear()`. (Windows-only encryption; thin + guarded.)
|
||||
- A trivial `StaticTokenAuthProvider` (returns a configured token or null) for tests + as the
|
||||
temporary default until Zitadel is wired.
|
||||
- Test: token store round-trip (Windows); static provider returns/omits token.
|
||||
|
||||
### Task 4 — API client
|
||||
- `Online/IOnlineInboxApi.cs` + `Online/OnlineInboxApiClient.cs` (typed `HttpClient`).
|
||||
- Attaches `Authorization: Bearer` from `IOnlineAuthProvider`; refuses non-HTTPS non-loopback
|
||||
base URLs; throws a typed `OnlineInboxException` on non-2xx.
|
||||
- Test with a stubbed `HttpMessageHandler`: each method hits the right path/verb/body; 401
|
||||
surfaces; bearer attached.
|
||||
|
||||
### Task 5 — Sync service
|
||||
- `Online/OnlineSyncService.cs` (`BackgroundService`) implementing the §5 reconcile loop.
|
||||
- DI: register only when `enabled`; resolve repos per-cycle via a scope.
|
||||
- Per-cycle try/catch + structured logging; skip when no token; unknown-list skip.
|
||||
- Test against a **fake `IOnlineInboxApi`** + real SQLite: pull→import→flag creates local Idle
|
||||
tasks; mirror payload == Idle backlog; lists pushed; unknown list skipped & not flagged;
|
||||
disabled/no-token = no api calls.
|
||||
|
||||
### Task 6 — Wire-up + docs
|
||||
- Register the stack in `Program.cs` behind the enabled flag.
|
||||
- Update `src/ClaudeDo.Worker/CLAUDE.md` (new `Online/` area) and `src/ClaudeDo.Worker/Config`
|
||||
notes. Add `online_inbox` to the config section.
|
||||
|
||||
## Phase 2 — UI + real auth (AFTER the VPS reports client config)
|
||||
|
||||
### Task 7 — Hub + config plumbing
|
||||
- Hub: `GetOnlineInboxConfig` / `SetOnlineInboxConfig` / `SetOnlineInboxAuth(refreshToken)` /
|
||||
`ClearOnlineInboxAuth`. Update `IWorkerClient` + `WorkerClient` + test fakes (both test
|
||||
projects — see the IWorkerClient-fakes memory).
|
||||
|
||||
### Task 8 — Settings UI
|
||||
- "Online Inbox" section in `SettingsModalViewModel`: enable toggle, base URL, Sign in/out,
|
||||
status. Localized keys in en.json + de.json (parity).
|
||||
- Visual verification = manual (flag it).
|
||||
|
||||
### Task 9 — ZitadelAuthProvider
|
||||
- Add the Zitadel package reference; implement `ZitadelAuthProvider` (refresh-token → access
|
||||
token, cached to expiry) using the reported authority/client-id/flow.
|
||||
- Swap it in for `StaticTokenAuthProvider` in DI when enabled.
|
||||
- Manual smoke against the live VPS API (tracked, not an automated test).
|
||||
|
||||
## Notes
|
||||
- No real network / no real Zitadel / no real Claude in any automated test.
|
||||
- Stage files by explicit path in subagents; sonnet model; build+test+commit by the orchestrator.
|
||||
92
docs/superpowers/plans/2026-06-19-rider-merge-editor.md
Normal file
92
docs/superpowers/plans/2026-06-19-rider-merge-editor.md
Normal file
@@ -0,0 +1,92 @@
|
||||
# Plan: Rider-style 3-pane merge editor
|
||||
|
||||
Spec: `docs/superpowers/specs/2026-06-19-rider-merge-editor-design.md`
|
||||
|
||||
TDD, one focused commit per task (Conventional Commits, `feat(merge): …`).
|
||||
Build with `-c Release` per project (a running Worker locks `Debug`).
|
||||
Run `ClaudeDo.Ui.Tests` (and `Localization.Tests` for Task 6). No real `claude` CLI in tests.
|
||||
Stage ONLY the files each task touches, by explicit path (parallel sessions leave WIP).
|
||||
Backend + seam stay unchanged. Implementer/reviewer subagents use **sonnet**.
|
||||
|
||||
## Task 1 — VM: active-file model + 3-pane reconstruction + readout
|
||||
|
||||
`ConflictResolverViewModel` / `ConflictModels.cs`, additive (seam untouched).
|
||||
|
||||
- Add `ActiveFile` (`MergeFile?`), `SelectFileCommand(MergeFile)`, default to first file
|
||||
after load. Keep `Files`, `Current`/`CurrentIndex`/`Next`/`Previous` (focused conflict
|
||||
for the header arrows), `CanContinue`, binary guard, planning routing — all unchanged.
|
||||
- Add computed, per `ActiveFile`:
|
||||
- `ActiveOursText` = concat(stable.Text | conflict.Ours)
|
||||
- `ActiveTheirsText` = concat(stable.Text | conflict.Theirs)
|
||||
- `ActiveResultText` = concat(stable.Text | conflict.Resolution ?? conflict.Ours)
|
||||
- `ActiveConflicts` = ordered descriptors (block + segment index) for the view.
|
||||
- `PositionText` → `"{conflicts} conflicts · {resolved} resolved"` for the active file;
|
||||
keep `CanContinue` = every file resolved AND no binary.
|
||||
- Switching files raises a change event the view listens to (reuse/extend
|
||||
`CurrentChanged` → e.g. `ActiveFileChanged`).
|
||||
- Tests (Ui.Tests): reconstruction text for ours/theirs/result (result seeds unresolved
|
||||
with Ours); resolving a block updates `ActiveResultText` + readout; switching files
|
||||
preserves each block's `Resolution`; `CanContinue` blocks until all files resolved;
|
||||
binary file still blocks. Keep all existing tests green.
|
||||
|
||||
## Task 2 — View: 3-pane AXAML shell + document assembly + synced scroll
|
||||
|
||||
`Views/Conflicts/ConflictResolverView.axaml(.cs)`. Visual — verified by running.
|
||||
|
||||
- Replace AXAML: ModalShell host kept; header row (◀/▶ focus arrows bound to
|
||||
Previous/Next, file switcher `ItemsControl`/`ComboBox` over `Files` bound to
|
||||
`SelectFileCommand`, right-aligned `PositionText`); `Grid ColumnDefinitions="*,*,*"`
|
||||
of three bordered panes with headers **Ours · current (merge target)** /
|
||||
**Result** / **Theirs · incoming (task)** (drop Base); footer Continue
|
||||
(`IsEnabled=CanContinue`) / Abort; binary banner (kept); `Escape`→Abort (kept).
|
||||
- Code-behind: build three `TextDocument`s from `ActiveFile` segments, recording each
|
||||
conflict's start line + line count per document; install TextMate per pane by file
|
||||
extension; rebuild on `ActiveFileChanged`; Ours/Theirs `IsReadOnly=true`.
|
||||
- Proportional synced vertical scroll across the three panes (re-entrancy guard).
|
||||
- Push Result edits back to the active block `Resolution` (refined in Task 4).
|
||||
|
||||
## Task 3 — Result pane: read-only stable, editable conflicts
|
||||
|
||||
`ConflictResolverView.axaml.cs` + a small `IReadOnlySectionProvider` helper.
|
||||
|
||||
- Track each conflict's result span in a `TextSegmentCollection<…>` over the Result
|
||||
document (anchors auto-adjust on edit).
|
||||
- `IReadOnlySectionProvider`: `CanInsert` only strictly inside a conflict span;
|
||||
`GetDeletableSegments` intersects with conflict spans only. Stable text becomes
|
||||
immutable; conflict regions stay editable.
|
||||
- Editing inside a conflict span writes the span text back to the block `Resolution`
|
||||
and flips it resolved (updates readout + `CanContinue`).
|
||||
|
||||
## Task 4 — Color blocks (IBackgroundRenderer) + accept overlay
|
||||
|
||||
`ConflictResolverView.axaml.cs` + renderer/overlay helpers.
|
||||
|
||||
- `IBackgroundRenderer` per pane: unresolved conflict = red (Blood tint), resolved =
|
||||
green/muted, Ours side = Moss tint, Theirs side = Accent tint — driven by recorded
|
||||
spans + block `IsResolved`.
|
||||
- Between-pane overlay Canvas (Ours|Result and Result|Theirs): `›` accept-ours / `‹`
|
||||
accept-theirs + `✕` dismiss per conflict, positioned at the block's `TextView` visual
|
||||
top, recomputed on scroll/resize. Click → `block.AcceptOurs/AcceptTheirs` and replace
|
||||
the tracked Result span; resolved blocks recolor.
|
||||
|
||||
## Task 5 — Polish: readout, focus arrows scroll-to-conflict, resolved styling
|
||||
|
||||
- ◀/▶ arrows move `Current` and scroll all three panes to that conflict.
|
||||
- `M conflicts · K resolved` live readout; Continue tooltip/hint when blocked.
|
||||
- Resolved conflict recolors and drops its accept overlay; unresolved stays red.
|
||||
(Fold into Task 4 if small.)
|
||||
|
||||
## Task 6 — Localization + tokens
|
||||
|
||||
- Add `conflictResolver.*` keys (pane headers, readout, accept tooltips, hints) to
|
||||
`locales/en.json` AND `locales/de.json` (keep key parity).
|
||||
- Add Tokens.axaml color tokens only if a needed conflict/resolved shade is missing.
|
||||
- Run Localization.Tests (parity) + a quick scan for hard-coded strings in the view.
|
||||
|
||||
## Task 7 — Verify
|
||||
|
||||
- Build `ClaudeDo.App` + `ClaudeDo.Ui` `-c Release`; run `Ui.Tests` + `Localization.Tests`.
|
||||
- Update `src/ClaudeDo.Ui/CLAUDE.md` (Planning/Conflicts paragraph → new 3-pane editor).
|
||||
- **Visual verification gap (flag to Mika):** run the app, trigger a real conflict
|
||||
(single-task approve + planning unit-merge) and confirm panes/colors/accept/scroll/
|
||||
gating/binary render correctly — cannot be asserted in tests.
|
||||
@@ -0,0 +1,197 @@
|
||||
# Git Tab / Merge & Review Rework — Design
|
||||
|
||||
Date: 2026-06-05
|
||||
Status: Approved
|
||||
|
||||
## Goal
|
||||
|
||||
Make handling merges and reviews as simple as possible in the Terminal component's
|
||||
Git tab, and rework the diff viewers and worktree modals along the way. The work is
|
||||
split into three layers built across separate sessions, with a shared foundation that
|
||||
is built and pushed first so the parallel sessions branch from frozen contracts.
|
||||
|
||||
The user mostly trusts task output but wants the diff one click away for important
|
||||
work, and wants to land several independently-queued worktrees without per-task
|
||||
hopping or hand-resolving conflicts in an external editor.
|
||||
|
||||
## Layers
|
||||
|
||||
- **Layer A — Review/merge cockpit** (this session). Single-task review + merge UX in
|
||||
the Git tab; consolidate the four diff renderers into one `DiffView`.
|
||||
- **Layer B — Multi-worktree merge cockpit** (parallel session). Batch-merge N
|
||||
worktrees into one target, skip-and-continue, conflicts collected for resolution.
|
||||
- **Layer C — Inline conflict resolver** (parallel session). VSCode-style inline hunk
|
||||
resolver plus the worker-side conflict plumbing it needs.
|
||||
|
||||
They stack: A defines the single-task flow, B reuses it for many tasks, both funnel
|
||||
conflicts into C.
|
||||
|
||||
## Shared foundation (built & pushed this session, before B/C branch)
|
||||
|
||||
Everything B and C depend on lands first on `main`. B and C branch from that commit.
|
||||
|
||||
### 1. One diff model + one `DiffView` control
|
||||
|
||||
Today there are four diff renderers and two parallel diff models:
|
||||
|
||||
- `DiffLinesView.axaml` (used by `DiffModalView`)
|
||||
- the inline diff `ItemsControl` in `WorktreeModalView.axaml`
|
||||
- `PlanningDiffView.axaml`
|
||||
- their backing models: `DiffFileViewModel`/`DiffLineViewModel` (+ `UnifiedDiffParser`)
|
||||
vs `WorktreeNodeViewModel`/`WorktreeDiffLineViewModel`
|
||||
|
||||
Collapse into a single canonical diff model + parser + a `DiffView` UserControl. All
|
||||
diff rendering across the app goes through `DiffView`.
|
||||
|
||||
- Model: `DiffFileViewModel { Path, AddCount, DelCount, Lines }`,
|
||||
`DiffLineViewModel { OldNo, NewNo, Kind (Add|Del|Ctx|File|Hunk), Text }`.
|
||||
- Parser: one static `UnifiedDiffParser.Parse(rawUnifiedDiff)` returning the model.
|
||||
- `DiffView` exposes a `Files` styled property (file list + selected-file lines), or a
|
||||
simpler `Lines` property for single-file use — Layer A decides the exact surface
|
||||
while building it, but the type names above are frozen so B and C can bind to them.
|
||||
|
||||
### 2. Frozen worker conflict contract
|
||||
|
||||
Added to `IWorkerClient` (and `WorkerClient` with stub bodies that throw
|
||||
`NotSupportedException`) plus new DTOs, so A and B compile against the interface while
|
||||
C provides the real worker-side implementation.
|
||||
|
||||
```csharp
|
||||
// IWorkerClient additions (signatures frozen this session)
|
||||
Task<MergeResultDto> StartConflictMergeAsync(string taskId, string targetBranch);
|
||||
Task<MergeConflictsDto> GetMergeConflictsAsync(string taskId);
|
||||
Task WriteConflictResolutionAsync(string taskId, string path, string resolvedContent);
|
||||
Task<MergeResultDto> ContinueMergeAsync(string taskId);
|
||||
Task AbortMergeAsync(string taskId);
|
||||
```
|
||||
|
||||
- `StartConflictMergeAsync` performs the merge with `leaveConflictsInTree: true` (the
|
||||
worker already supports this flag — used today by the planning orchestrator) and
|
||||
returns `MergeResultDto` with `Status="conflict"` and the conflict file list, leaving
|
||||
`.git/MERGE_HEAD` in place in the list's `WorkingDir`.
|
||||
- `GetMergeConflictsAsync` returns each conflicted file with ours/theirs/base content,
|
||||
read via `git show :2:<path>` (ours), `:3:<path>` (theirs), `:1:<path>` (base).
|
||||
- `WriteConflictResolutionAsync` writes resolved content to the file in `WorkingDir`
|
||||
and `git add`s it.
|
||||
- `ContinueMergeAsync` wraps the existing `TaskMergeService.ContinueMergeAsync`
|
||||
(`git add -A` → re-check `git diff --name-only --diff-filter=U` → `git commit`).
|
||||
- `AbortMergeAsync` wraps the existing `TaskMergeService.AbortMergeAsync`
|
||||
(`git merge --abort`).
|
||||
|
||||
New DTOs (defined in the worker hub DTO file, mirrored client-side):
|
||||
|
||||
```csharp
|
||||
public record MergeConflictsDto(string TaskId, IReadOnlyList<ConflictFileDto> Files);
|
||||
public record ConflictFileDto(string Path, IReadOnlyList<ConflictHunkDto> Hunks);
|
||||
public record ConflictHunkDto(string Ours, string Theirs, string? Base);
|
||||
```
|
||||
|
||||
Existing DTOs reused unchanged: `MergeResultDto(Status, ConflictFiles, ErrorMessage)`,
|
||||
`MergePreviewDto`, `MergeTargetsDto`.
|
||||
|
||||
### 3. Conflict data model (UI)
|
||||
|
||||
`ConflictFile { Path, Hunks[] }`, `ConflictHunk { Ours, Theirs, Base, Resolution }`.
|
||||
Shaped so a future 3-way merge pane needs no model change (Layer C is the inline
|
||||
resolver now; the model leaves room for 3-way later).
|
||||
|
||||
### 4. Integration seams (delegates, wired by the integrator at merge)
|
||||
|
||||
A's and B's cockpits hold a `RequestConflictResolution(string taskId)` callback (an
|
||||
`Action<string>` or `Func<string, Task>`). They never reference Layer C's resolver
|
||||
types. The integrator connects these callbacks to C's `ConflictResolverViewModel`
|
||||
factory when merging the three branches together.
|
||||
|
||||
## Parallel boundaries (verified disjoint)
|
||||
|
||||
| Area | A (this session) | B (parallel) | C (parallel) |
|
||||
|---|---|---|---|
|
||||
| `DiffView` + diff model/parser | builds | reuses | reuses |
|
||||
| `WorkConsole.axaml` / `DetailsIslandViewModel` | owns | — | — |
|
||||
| `DiffModalView` + `PlanningDiffView` | migrates to `DiffView` | — | — |
|
||||
| `WorktreesOverviewModalView/VM` + `WorktreeModalView` | — | owns | — |
|
||||
| `WorkerHub` / `TaskMergeService` / `GitService` | — | — | owns |
|
||||
| New `ConflictResolverView/VM` + conflict UI model | — | — | owns |
|
||||
| `IWorkerClient` / `WorkerClient` | adds frozen stubs + DTOs | reuses `MergeTaskAsync` | fills stub bodies |
|
||||
| Test fakes (`IWorkerClient`) in both test projects | adds new no-op methods | — | makes them functional if needed |
|
||||
|
||||
The only file C and A both touch is `WorkerClient.cs` (C replaces the stub bodies A
|
||||
wrote). Contained; reconciled at integration. Everything else is disjoint.
|
||||
|
||||
## Layer A — review/merge cockpit (this session)
|
||||
|
||||
- The Git tab becomes the single Approve + merge surface. `Approve` and the merge
|
||||
target / preview / diff flow together as one block (no separate REVIEW vs
|
||||
MERGE & WORKTREE sections).
|
||||
- `Continue` (reject → requeue with feedback) and `Reset` (reject → idle) **stay** in
|
||||
the Output tab footer — unchanged.
|
||||
- The diff is shown via the unified `DiffView` opened as a modal from the cockpit. No
|
||||
inline diff recap in the tab (the island is too small).
|
||||
- On a single-task **Approve that conflicts**: instead of today's auto-abort, call
|
||||
`StartConflictMergeAsync` and fire `RequestConflictResolution(taskId)`. This leaves
|
||||
the main checkout mid-merge until the user resolves or aborts (behavior change,
|
||||
intended). The callback is inert until Layer C is merged; the integrator wires it.
|
||||
- Migrate `DiffModalView` and `PlanningDiffView` onto the new `DiffView`.
|
||||
|
||||
### Behavior change accepted
|
||||
|
||||
Today `MergeTask`/`ApproveReview` use `leaveConflictsInTree: false` (auto-abort on
|
||||
conflict). Under this design, an Approve that conflicts leaves the merge in progress
|
||||
and opens the resolver. The mid-merge guard (`IsMidMergeAsync`) still prevents a second
|
||||
concurrent merge.
|
||||
|
||||
## Layer B — multi-worktree merge cockpit (parallel)
|
||||
|
||||
- Rework `WorktreesOverviewModalView`/`WorktreesOverviewModalViewModel` into a
|
||||
batch-merge cockpit: list mergeable worktrees, select N, choose one target branch
|
||||
(single target — 99% of the time everything goes to the same branch), "Merge all".
|
||||
- **Skip-and-continue**: client-side loop calling the existing
|
||||
`MergeTaskAsync(taskId, target, removeWorktree, msg)` per selected task. Clean merges
|
||||
apply; conflicting ones are collected (existing `MergeTaskAsync` auto-aborts on
|
||||
conflict, leaving the tree clean) into a "needs resolution" list with live progress.
|
||||
- Each conflict row exposes a **Resolve** action → `RequestConflictResolution(taskId)`
|
||||
(wired to Layer C at integration).
|
||||
- Per-task diff via the shared `DiffView`; migrate `WorktreeModalView`'s inline diff
|
||||
onto it.
|
||||
- B touches no worker files — keeps it parallel-safe.
|
||||
|
||||
## Layer C — inline conflict resolver (parallel)
|
||||
|
||||
### Worker side
|
||||
|
||||
Implement the five frozen contract methods:
|
||||
|
||||
- Add hub methods `StartConflictMerge`, `GetMergeConflicts`, `WriteConflictResolution`,
|
||||
`ContinueMerge`, `AbortMerge` in `WorkerHub`.
|
||||
- `StartConflictMerge` calls the existing `TaskMergeService.MergeAsync` overload with
|
||||
`leaveConflictsInTree: true`.
|
||||
- `ContinueMerge` / `AbortMerge` wrap the existing `TaskMergeService.ContinueMergeAsync`
|
||||
/ `AbortMergeAsync` (currently service-level only, not hub-exposed).
|
||||
- `GetMergeConflicts` reads ours/theirs/base per conflicted file via
|
||||
`git show :2:/:3:/:1:`; add the `GitService` helpers needed.
|
||||
- `WriteConflictResolution` writes the resolved content to `WorkingDir` and stages it.
|
||||
- Fill the `WorkerClient` stub bodies (real SignalR `InvokeAsync` calls).
|
||||
- Update the hand-rolled `IWorkerClient` fakes in both test projects.
|
||||
|
||||
### UI
|
||||
|
||||
- New `ConflictResolverView` + `ConflictResolverViewModel`. Per conflict hunk, show
|
||||
ours vs theirs stacked, with buttons **Accept Current / Accept Incoming / Accept Both
|
||||
/ Edit manually** plus a free-text box for the merged result of that hunk.
|
||||
- When every file's hunks are resolved → `ContinueMergeAsync(taskId)` → `MergeResultDto`
|
||||
(`merged` closes the resolver; `conflict` means not fully resolved, stay open).
|
||||
- `AbortMergeAsync(taskId)` cancels and aborts the merge.
|
||||
- Expose a factory (`Func<string, ConflictResolverViewModel>`) the integrator wires to
|
||||
A's and B's `RequestConflictResolution` callbacks.
|
||||
|
||||
## Build / test
|
||||
|
||||
`.slnx` needs .NET 9; on .NET 8 build individual csproj with `-c Release` (a running
|
||||
Worker locks `Debug`). Run the relevant test projects. No tests that spawn the real
|
||||
`claude` CLI. Keep `en.json`/`de.json` localization keys in parity.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Full 3-way synchronized merge editor (model leaves room; not built now).
|
||||
- Per-task differing merge targets in the batch (single target only).
|
||||
- Any CI/PR tooling (direct push-to-main workflow).
|
||||
@@ -0,0 +1,80 @@
|
||||
# Per-task model override via MCP + cheapest-model prompt guidance
|
||||
|
||||
Date: 2026-06-09
|
||||
|
||||
## Goal
|
||||
|
||||
Let Claude pick the model for each task it generates (planning subtasks,
|
||||
improvement follow-ups, external task creation) directly at creation time via
|
||||
MCP, and instruct Claude — in the relevant prompts — to choose the *cheapest*
|
||||
model that can do the job well.
|
||||
|
||||
## Background
|
||||
|
||||
- `TaskEntity.Model` (nullable) already exists and is resolved
|
||||
task → list-config → global default in `TaskRunner.ResolveConfigAsync`, then
|
||||
passed to the CLI as `--model` by `ClaudeArgsBuilder`.
|
||||
- Today the model can only be set *after* creation via `set_task_config`
|
||||
(`ConfigMcpTools.SetTaskConfig`). The creation tools (`CreateChildTask`,
|
||||
`SuggestImprovement`, `AddTask`) accept no model, so assigning one is a
|
||||
two-call dance.
|
||||
- `ModelRegistry.Aliases = ["sonnet","opus","haiku"]`; no cost ordering or
|
||||
validation helper exists.
|
||||
|
||||
No schema change is required — only plumbing a `model` argument through the
|
||||
creation paths plus prompt edits.
|
||||
|
||||
## Decisions
|
||||
|
||||
- **Validation:** strict alias-only. `model` must be one of haiku/sonnet/opus
|
||||
(case-insensitive); blank/null means "inherit" (no override); anything else
|
||||
throws an MCP error so Claude self-corrects immediately rather than the task
|
||||
failing later at CLI runtime.
|
||||
- **`AddSubtask` is out of scope:** it creates a `SubtaskEntity` (a checklist
|
||||
step), which is never independently executed — a model there is a no-op.
|
||||
- **Improvement-child prompt:** the child's model is fixed at filing time and
|
||||
it cannot re-pick, so only a one-line "this is an intentionally small/cheap
|
||||
unit — stay minimal" reminder is added. The real model-choice instruction
|
||||
lives in the main system prompt's SuggestImprovement guidance.
|
||||
|
||||
## Cost ordering & heuristic (single source: `ModelRegistry.ByCostAscending`)
|
||||
|
||||
`haiku < sonnet < opus`
|
||||
|
||||
- **haiku** — trivial/mechanical: doc tweaks, simple renames, small localized edits.
|
||||
- **sonnet** — normal coding work (default).
|
||||
- **opus** — complex architecture, cross-cutting changes, hard debugging.
|
||||
|
||||
## Changes
|
||||
|
||||
1. **`ClaudeDo.Data/Models/ModelRegistry.cs`**
|
||||
- `ByCostAscending = ["haiku","sonnet","opus"]`.
|
||||
- `string? NormalizeAlias(string? model)` — trim; null/blank → null;
|
||||
case-insensitive match → canonical lowercase alias; else throw
|
||||
`ArgumentException` with the allowed list.
|
||||
|
||||
2. **`TaskRepository.CreateChildAsync`** — add optional `string? model = null`;
|
||||
set `child.Model = ModelRegistry.NormalizeAlias(model)`. Single choke-point
|
||||
for both child-creation MCP tools.
|
||||
|
||||
3. **MCP creation tools** (add `model` param, document in `[Description]`):
|
||||
- `PlanningMcpService.CreateChildTask` → forward to `CreateChildAsync`.
|
||||
- `TaskRunMcpService.SuggestImprovement` → forward to `CreateChildAsync`.
|
||||
- `ExternalMcpService.AddTask` → `NormalizeAlias` then set `entity.Model`.
|
||||
|
||||
4. **Prompts (`PromptFiles.cs`)**
|
||||
- `PlanningSystemDefault` — instruct the planner to pass each
|
||||
`CreateChildTask` the cheapest capable model (with the ordering/heuristic).
|
||||
- `SystemDefault` (Out-of-scope improvements) — when filing via
|
||||
`SuggestImprovement`, pass the cheapest capable `model`.
|
||||
- `ImprovementChildDefault` — one-line minimality reminder.
|
||||
|
||||
5. **Tests** (no real CLI):
|
||||
- `NormalizeAlias`: valid aliases (any case), blank/null → null, unknown → throws.
|
||||
- `CreateChildTask` / `SuggestImprovement` / `AddTask` persist the model;
|
||||
invalid model is rejected.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- No DB migration. No locale changes (prompts and MCP descriptions are not
|
||||
localized). No UI changes (existing per-task model display already covers it).
|
||||
@@ -0,0 +1,148 @@
|
||||
# Unify the parent-task model (planning · improvement · normal)
|
||||
|
||||
**Date:** 2026-06-09
|
||||
**Status:** Approved-pending-implementation
|
||||
|
||||
## Problem
|
||||
|
||||
ClaudeDo has three ways a task produces and waits on work, grown as separate
|
||||
mechanisms that represent the *same shape* — "a task runs, may emit children,
|
||||
and once it + its children are terminal it surfaces for review":
|
||||
|
||||
| | children authored | scheduling | parent flow today | merge of children |
|
||||
|---|---|---|---|---|
|
||||
| **Normal** | none | — | `Running → WaitingForReview → Done` | own worktree on approve |
|
||||
| **Improvement** | autonomously *during* run (`suggest_improvement`) | parallel (no blockers) | `Running → WaitingForChildren → WaitingForReview → Done` | separate `MergeAllPlanning` |
|
||||
| **Planning** | interactively *before* run (planning session) | sequential chain (`BlockedByTaskId`) | `Idle →(Active→Finalized)→ Done` (skips review) | separate `MergeAllPlanning` |
|
||||
|
||||
The incidental divergence we want to remove:
|
||||
|
||||
1. **Two "parent is waiting on children" representations** — improvement uses
|
||||
`Status=WaitingForChildren`; planning uses `PlanningPhase=Finalized` with the
|
||||
parent's `Status` jumping `Idle → Done`, never passing through the waiting/review
|
||||
states at all.
|
||||
2. **Two parent-advance methods** doing the same job —
|
||||
`TaskRepository.TryCompleteParentAsync` (planning → `Done`, no review) vs
|
||||
`TaskStateService.TryAdvanceImprovementParentAsync` (improvement → `WaitingForReview`).
|
||||
3. **A separate merge action** — `MergeAllPlanning` / `PlanningMergeOrchestrator`
|
||||
merges children, decoupled from the parent's `approve`. Approving a parent and
|
||||
merging its unit are two clicks.
|
||||
|
||||
What is **genuinely unique and kept**: `PlanningPhase.Active` — the interactive,
|
||||
human-in-the-loop authoring gate where children are drafted and cannot run until
|
||||
finalize. Improvement has no equivalent. The two *authoring* entry points
|
||||
(`PlanningMcpService.CreateChildTask` vs `TaskRunMcpService.SuggestImprovement`)
|
||||
also stay distinct — they already share `CreateChildAsync`; unifying the authoring
|
||||
UX is explicitly out of scope.
|
||||
|
||||
## Decisions (locked)
|
||||
|
||||
- **All parents get review.** A planning parent now surfaces in `WaitingForReview`
|
||||
after its children finish, instead of auto-completing to `Done`.
|
||||
- **Approve merges the whole unit — full UX consolidation.** Approve is the single
|
||||
entry for reviewing *and* merging any task. For a parent with children it drives the
|
||||
existing `PlanningMergeOrchestrator` (unit merge + parent→`Done` + conflict
|
||||
continue/abort, all already implemented); the standalone "Merge All" button is
|
||||
removed and the orchestrator's conflict dialog + combined-diff preview are reused
|
||||
in-place. Childless tasks keep `ApproveAndMergeAsync`.
|
||||
- **Scope = state model + code paths.** Internal refactor; authoring UX and child
|
||||
base-commit resolution are unchanged.
|
||||
|
||||
## Target model
|
||||
|
||||
**One parent-with-children lifecycle, used by every parent regardless of how its
|
||||
children were authored:**
|
||||
|
||||
```
|
||||
┌─ (no children) ──────────────┐
|
||||
Idle → Queued → Running ──┤ ├→ WaitingForReview → Done
|
||||
└─ (has/spawns children) ─┐ │ (approve =
|
||||
│ │ merge unit)
|
||||
WaitingForChildren ─┘ │
|
||||
│ │
|
||||
(all children terminal) ───────┘
|
||||
```
|
||||
|
||||
Planning parent (never runs as an agent — it runs an interactive session):
|
||||
|
||||
```
|
||||
Idle (PlanningPhase None)
|
||||
→[StartPlanning] Idle (PlanningPhase Active) ← authoring gate (KEPT)
|
||||
→[FinalizePlanning] WaitingForChildren (Finalized) ← children chain runs
|
||||
→[all children terminal] WaitingForReview
|
||||
→[approve] merge unit → Done
|
||||
```
|
||||
|
||||
Children (planning **and** improvement) keep going straight to `Done` with no
|
||||
individual review; they accumulate on their branches and merge as a unit when the
|
||||
parent is approved.
|
||||
|
||||
### State machine after the change
|
||||
|
||||
- `WaitingForChildren` is the **single** "parent waiting on children" state, used by
|
||||
both planning and improvement parents.
|
||||
- `WaitingForReview` is reached by every parent before `Done`.
|
||||
- `PlanningPhase`: `None | Active | Finalized` — unchanged; `Active` remains the
|
||||
authoring gate, `Finalized` marks "was a planning parent" and is set together with
|
||||
`Status=WaitingForChildren`.
|
||||
|
||||
## Code changes
|
||||
|
||||
1. **Single parent-advance path.** Rename
|
||||
`TaskStateService.TryAdvanceImprovementParentAsync` →
|
||||
`TryAdvanceParentAsync`; it already only checks `Status==WaitingForChildren` +
|
||||
"all children terminal" → `WaitingForReview` (with the failed/cancelled
|
||||
annotation on `Result`). It becomes the only path for both systems.
|
||||
- Handle **zero children**: a finalized planning parent with no children must go
|
||||
straight to `WaitingForReview` (today `TryComplete`/`TryAdvance` both `return`
|
||||
on `Count == 0`).
|
||||
|
||||
2. **Delete `TaskRepository.TryCompleteParentAsync`** (`TaskRepository.cs:477`) and
|
||||
its invocation in `TaskStateService.OnChildTerminalAsync`. Planning parents now
|
||||
advance via `TryAdvanceParentAsync` to `WaitingForReview` instead of `Done`.
|
||||
- Keep `_chain.OnChildFinishedAsync` (inter-child unblock — planning-only effect).
|
||||
|
||||
3. **`FinalizePlanningAsync`** (`TaskStateService.cs:289`) sets the parent
|
||||
`Status = WaitingForChildren` in the same update that sets
|
||||
`PlanningPhase = Finalized`. This happens before `SetupChainAsync` enqueues
|
||||
child[0], so the parent is in `WaitingForChildren` before any child can finish.
|
||||
|
||||
4. **Approve merges the unit.** `WorkerHub.ApproveReview` (and the MCP
|
||||
`ReviewTask` approve path): when the approved task has children, run
|
||||
`PlanningMergeOrchestrator` (parent worktree if `Active` + each `Done` child in
|
||||
order), then transition the parent to `Done`. On a child merge conflict, the
|
||||
parent stays in `WaitingForReview` (mirrors current single-task approve-conflict
|
||||
behavior). Retire the `MergeAllPlanning` Hub method + UI button.
|
||||
|
||||
5. **Allow cancelling a `WaitingForChildren` parent.** Add `WaitingForChildren` to
|
||||
the `CancelAsync` guard so a parent waiting on children can be cancelled (today it
|
||||
cannot — minor gap).
|
||||
|
||||
6. **Docs.** Fix the `WaitingForChildren`-missing drift in
|
||||
`src/ClaudeDo.Data/CLAUDE.md` and `src/ClaudeDo.Worker/CLAUDE.md`, and update the
|
||||
transition diagram + the root `CLAUDE.md` status-flow line to the unified model.
|
||||
|
||||
## Out of scope (unchanged)
|
||||
|
||||
- Authoring UX: planning session vs `suggest_improvement` stay as two distinct
|
||||
entry points (both already call `CreateChildAsync`).
|
||||
- `WorktreeManager.ResolveBaseCommitAsync` base-commit divergence (planning children
|
||||
branch from list HEAD; improvement children from parent head) — left as-is.
|
||||
- Sequential-vs-parallel scheduling — already shared infrastructure
|
||||
(`BlockedByTaskId`); planning chains, improvement doesn't. No change.
|
||||
|
||||
## Risks / edge cases
|
||||
|
||||
- **Ordering on finalize** — parent must be `WaitingForChildren` before the first
|
||||
child can reach terminal. Guaranteed by setting it inside `FinalizePlanningAsync`,
|
||||
which runs before `SetupChainAsync`.
|
||||
- **Zero-children planning parent** — must advance to `WaitingForReview`, not stick
|
||||
in `WaitingForChildren`. Explicit branch in `TryAdvanceParentAsync` /
|
||||
`FinalizePlanningAsync`.
|
||||
- **Failed/cancelled children** — parent still advances to `WaitingForReview` with
|
||||
the existing `⚠ Children: N failed, M cancelled` annotation; no wedge.
|
||||
- **Approve-merge conflict** — keep parent in `WaitingForReview`; surface the
|
||||
conflicting child like the current merge-conflict path.
|
||||
- **Existing rows** — planning parents currently sitting at `Idle`+`Finalized` with
|
||||
live children: behavior change is forward-only (new finalizes use the new flow);
|
||||
no migration needed since `Status`/`PlanningPhase` columns already exist.
|
||||
142
docs/superpowers/specs/2026-06-10-online-inbox-design.md
Normal file
142
docs/superpowers/specs/2026-06-10-online-inbox-design.md
Normal file
@@ -0,0 +1,142 @@
|
||||
# Online Inbox — desktop-side design
|
||||
|
||||
Date: 2026-06-10
|
||||
Status: approved, implementing
|
||||
Related: `docs/online-inbox-api-contract.md` (the API both ends share)
|
||||
|
||||
## Goal
|
||||
|
||||
Let the owner add task ideas and view their Idle backlog from a phone/browser. The desktop
|
||||
ClaudeDo opts in to an online service, syncs its list catalog + Idle backlog up, and pulls
|
||||
web-created tasks down as local `Idle` tasks. Execution stays 100% local.
|
||||
|
||||
This spec covers only the **desktop side** (this repo). The API + web client are built
|
||||
VPS-side against the shared contract.
|
||||
|
||||
## Non-goals
|
||||
|
||||
- No remote execution; the Worker still runs everything locally.
|
||||
- No syncing of any task state other than the `Idle` mirror.
|
||||
- No multi-user. Single Zitadel user = the owner.
|
||||
- Web client is create + read only.
|
||||
|
||||
## Opt-in & where things live
|
||||
|
||||
- **Off by default.** When disabled: zero network, zero auth — byte-for-byte today's
|
||||
behaviour. Auth only matters once enabled.
|
||||
- Sync runs in the **Worker** (it owns the DB and already hosts `BackgroundService`s). The
|
||||
opt-in config and the stored refresh token live in `worker.config.json`-adjacent state.
|
||||
- Interactive Zitadel login happens in the **UI** (browser flow), which hands the resulting
|
||||
refresh token to the Worker over SignalR; the Worker persists it (DPAPI) and uses it for
|
||||
headless token refresh during polling.
|
||||
|
||||
## Config (`WorkerConfig`, new `online_inbox` section)
|
||||
|
||||
```jsonc
|
||||
"online_inbox": {
|
||||
"enabled": false,
|
||||
"api_base_url": "", // e.g. https://inbox.claudedo.kuns.dev
|
||||
"poll_interval_seconds": 60,
|
||||
"zitadel": {
|
||||
"authority": "", // issuer URL (from VPS report)
|
||||
"client_id": "",
|
||||
"scopes": "openid offline_access" // offline_access → refresh token
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The refresh token is NOT stored in this file. It lives encrypted via
|
||||
`System.Security.Cryptography.ProtectedData` (DPAPI, CurrentUser) at
|
||||
`~/.todo-app/online-inbox.token` and is read/written only by the Worker.
|
||||
|
||||
## Components (Worker, new `Online/` folder)
|
||||
|
||||
```
|
||||
Worker/Online/
|
||||
OnlineInboxConfig.cs — the config record (bound from WorkerConfig.OnlineInbox)
|
||||
Dtos.cs — RemoteList, RemoteTask, MirrorTask DTOs (match the contract)
|
||||
IOnlineInboxApi.cs — typed client surface (one method per endpoint)
|
||||
OnlineInboxApiClient.cs — HttpClient impl; attaches bearer via IOnlineAuthProvider
|
||||
Interfaces/IOnlineAuthProvider.cs — Task<string?> GetAccessTokenAsync(ct)
|
||||
ZitadelAuthProvider.cs — concrete (PENDING: needs the Zitadel package + client config)
|
||||
OnlineTokenStore.cs — DPAPI-backed refresh-token persistence
|
||||
OnlineSyncService.cs — BackgroundService: the reconcile loop (§contract 5)
|
||||
OnlineBacklog.cs — static helper: the Idle-backlog query/filter (§contract 2)
|
||||
```
|
||||
|
||||
### `IOnlineInboxApi`
|
||||
```
|
||||
Task PutListsAsync(IReadOnlyList<RemoteList> lists, ct)
|
||||
Task<IReadOnlyList<RemoteTask>> GetUnimportedTasksAsync(ct) // GET /tasks?imported=false
|
||||
Task MarkImportedAsync(string id, ct) // POST /tasks/{id}/imported
|
||||
Task PutMirrorAsync(IReadOnlyList<MirrorTask> tasks, ct) // PUT /tasks/mirror
|
||||
```
|
||||
(The desktop never calls `POST /tasks`, `GET /lists`, or `GET /lists/{id}/tasks` — those are
|
||||
web-only.)
|
||||
|
||||
### `IOnlineAuthProvider`
|
||||
Single method `Task<string?> GetAccessTokenAsync(CancellationToken)` returning a bearer token
|
||||
(refreshing transparently), or `null` if not logged in / refresh failed. Abstracting it lets
|
||||
us:
|
||||
- ship and test the sync engine now with a fake provider,
|
||||
- wire the real `ZitadelAuthProvider` once the VPS reports authority/client-id and we add the
|
||||
Zitadel package reference.
|
||||
|
||||
`ZitadelAuthProvider` reads the refresh token from `OnlineTokenStore`, exchanges it for an
|
||||
access token, caches the access token until near expiry. **Marked with a
|
||||
`// TODO(online-inbox)` until the flow is wired.**
|
||||
|
||||
> **Auth correction (2026-06-10):** the `KunsZitadel` nuget package is a *server-side*
|
||||
> resource-server helper (`AddKunsZitadel` → `JwtBearer` token *validation*). It belongs on
|
||||
> the VPS API, NOT the desktop. The desktop must *acquire* tokens, so `ZitadelAuthProvider`
|
||||
> uses a client OIDC flow — `IdentityModel.OidcClient` (auth-code + PKCE, loopback redirect)
|
||||
> or the device-authorization grant — against Zitadel's OIDC endpoints, then persists the
|
||||
> refresh token via `OnlineTokenStore`.
|
||||
|
||||
### `OnlineSyncService` (the loop)
|
||||
- Hosted only when `online_inbox.enabled == true` (guarded at registration).
|
||||
- Every `poll_interval_seconds`: create a DI scope, resolve `TaskRepository` + `ListRepository`
|
||||
(same pattern as the External MCP app), run the §5 reconcile loop.
|
||||
- Skips a cycle (logs at debug) if `GetAccessTokenAsync` returns null (not logged in).
|
||||
- All failures are caught per-cycle and logged; never crashes the Worker. Network errors back
|
||||
off to the next interval.
|
||||
- Import safety: a pulled task whose `listId` has no local list is skipped + logged (not
|
||||
imported), and NOT marked imported, so it retries once the list exists. Imported tasks land
|
||||
as `Status=Idle, CreatedBy="online"` — they never auto-run; the user queues them locally.
|
||||
|
||||
## UI (later increment, after VPS report)
|
||||
|
||||
- Settings modal → new "Online Inbox" section: enable toggle, API base URL, **Sign in /
|
||||
Sign out** (Zitadel browser/device flow via the OIDC client lib), connection status.
|
||||
- Login produces a refresh token; UI sends it to the Worker via a new hub method
|
||||
`SetOnlineInboxAuth(refreshToken)` → Worker writes it through `OnlineTokenStore`.
|
||||
- Config read/write via hub methods `GetOnlineInboxConfig` / `SetOnlineInboxConfig`
|
||||
(mirrors the existing `GetAppSettings`/`UpdateAppSettings` pattern).
|
||||
- Visual verification is a manual step (flagged — never claimed working without a run).
|
||||
|
||||
## Security
|
||||
|
||||
- Disabled → no network, no token read.
|
||||
- Bearer attached only over HTTPS `api_base_url`; refuse `http://` non-loopback base URLs.
|
||||
- Refresh token encrypted at rest (DPAPI CurrentUser). Never logged.
|
||||
- Imported tasks are `Idle` only — no auto-execution path from the web.
|
||||
|
||||
## Testing
|
||||
|
||||
- `OnlineSyncService` reconcile logic tested against a **fake `IOnlineInboxApi`** + real
|
||||
SQLite (Worker.Tests style): pull→import→flag, mirror set = Idle backlog, list catalog push,
|
||||
unknown-list skip, disabled = no calls, not-logged-in = skipped cycle.
|
||||
- `OnlineBacklog` filter tested directly (excludes children/planning/blocked/non-Idle).
|
||||
- **No real network and no real Zitadel** in tests — fake the api + auth provider. (Consistent
|
||||
with the no-real-Claude-in-tests rule.)
|
||||
- DPAPI token store: round-trip test is Windows-only; guard or keep as a thin wrapper.
|
||||
|
||||
## Open items (need the VPS report)
|
||||
|
||||
- Exact Zitadel authority/issuer, client id, scopes, and **which grant the Zitadel app is
|
||||
registered for** (auth-code+PKCE with which loopback redirect URI, or device-code). This
|
||||
drives the desktop OIDC client implementation.
|
||||
- Final API base URL.
|
||||
- Desktop client OIDC library decision: `IdentityModel.OidcClient` (recommended) vs
|
||||
hand-rolled device-code. (`KunsZitadel` is server-side only — see the auth correction
|
||||
above; it's for the VPS API.)
|
||||
132
docs/superpowers/specs/2026-06-19-rider-merge-editor-design.md
Normal file
132
docs/superpowers/specs/2026-06-19-rider-merge-editor-design.md
Normal file
@@ -0,0 +1,132 @@
|
||||
# Rider-style 3-pane merge editor (conflict resolver redesign)
|
||||
|
||||
Date: 2026-06-19
|
||||
|
||||
## Goal
|
||||
|
||||
Replace ClaudeDo's current conflict resolver (3 read-only columns Base|Ours|Theirs,
|
||||
one conflict at a time, accept buttons + editable result below) with a JetBrains
|
||||
Rider-style **3-pane merge editor**:
|
||||
|
||||
- LEFT = **Ours** (read-only) · current branch / merge target
|
||||
- MIDDLE = **Result** (editable) · the merged file being assembled
|
||||
- RIGHT = **Theirs** (read-only) · incoming task branch
|
||||
|
||||
Whole file per pane (not one conflict at a time), color-coded conflict blocks,
|
||||
inline per-hunk accept controls (`›` accept a side into the result, `✕` dismiss),
|
||||
a `M conflicts · K resolved` readout, synced scrolling, Continue gated until every
|
||||
conflict is resolved, Abort, and a binary-file guard. Visual reference: the
|
||||
attached "Merge Revisions" screenshot.
|
||||
|
||||
## Background
|
||||
|
||||
- Avalonia 12 desktop app; the conflict editor already uses **AvaloniaEdit 12.0.0**
|
||||
+ `AvaloniaEdit.TextMate` (theme `StyleInclude` in `src/ClaudeDo.App/App.axaml`).
|
||||
- **Backend is kept unchanged.** `WorkerHub.GetMergeConflictDocuments(taskId)` returns
|
||||
each conflicted file as ordered `MergeSegment`s: *stable* text (git's already
|
||||
auto-merged content) interleaved with *conflict* blocks carrying `Ours/Base/Theirs`.
|
||||
`StartConflictMerge` / `WriteConflictResolution` / `Continue[Planning]ConflictMerge` /
|
||||
`Abort[Planning]ConflictMerge` and their `IWorkerClient` mirrors stay as-is.
|
||||
`ConflictMarkerParser` (Data) already produces the segments. **ours = merge target
|
||||
(current branch); theirs = incoming task branch.** Merges are LOCAL-only (no push).
|
||||
- **Seam kept unchanged** so single-task AND planning conflict paths keep working:
|
||||
`IslandsShellViewModel.ConflictResolverFactory` + `ShowConflictResolver`
|
||||
(wired in `MainWindow.axaml.cs`), VM ctor `(IWorkerClient, taskId)`,
|
||||
`OpenAsync(targetBranch)`, `OpenForPlanningAsync(parentId, subtaskId)`, `CloseRequested`.
|
||||
The planning-path WIP currently uncommitted in the tree (`OpenForPlanningAsync`,
|
||||
`_conflictTaskId`, `LoadDocumentsAsync`) is part of this seam and is preserved.
|
||||
|
||||
### Key insight: the segments already line the panes up
|
||||
|
||||
Because every conflicted file is split into *stable* (identical on both sides, git
|
||||
auto-merged) and *conflict* (divergent) segments, reconstructing three documents —
|
||||
|
||||
- **Ours** = Σ over segments of (stable.Text | conflict.Ours)
|
||||
- **Theirs** = Σ over segments of (stable.Text | conflict.Theirs)
|
||||
- **Result** = Σ over segments of (stable.Text | conflict.Resolution ?? conflict.Ours)
|
||||
|
||||
— yields three documents that are byte-identical in their stable regions and differ
|
||||
only inside conflict blocks. So the panes align line-for-line for free, and a real
|
||||
client-side 3-way diff is **not** needed for the core feature.
|
||||
|
||||
## Decisions
|
||||
|
||||
- **Data source = segment-based (no backend change, no DiffPlex).** The worker already
|
||||
applied git's auto-merge; only conflicts remain actionable. The screenshot's
|
||||
"N changes" (non-conflicting hunks shown as separately flippable) are already merged
|
||||
and have nothing to accept, so the readout is **`M conflicts · K resolved`**. True
|
||||
"N changes" parity (raw `:1/:2/:3` blobs + DiffPlex 3-way) is an explicit later
|
||||
add-on that does not touch the seam — see *Out of scope / fast-follow*.
|
||||
- **One file at a time + file switcher.** Like Rider's title bar ("Merge Revisions for
|
||||
…file"). When more than one file conflicts, a compact switcher selects the active
|
||||
file; Continue still requires *all* files resolved. (Replaces today's cross-file
|
||||
flattened one-at-a-time navigation as the primary model.)
|
||||
- **Result-pane editing model.** The middle document is the merged file. Stable text is
|
||||
read-only via `IReadOnlySectionProvider`; only conflict regions are editable. Each
|
||||
conflict's result span is tracked in a `TextSegmentCollection` (anchors auto-adjust on
|
||||
edit). Accepting `›`(ours)/`‹`(theirs) replaces that span; editing inside it or
|
||||
accepting flips the block to **resolved**. Unresolved regions are seeded with the Ours
|
||||
text and painted red until acted on.
|
||||
- **Accept controls = overlay between panes** (not an AvaloniaEdit margin). A thin Canvas
|
||||
overlay between Ours|Result and Result|Theirs hosts `›`/`✕` (and `‹`) per conflict,
|
||||
positioned at each block's visual Y (recomputed on scroll/resize). This matches the
|
||||
screenshot's between-pane gutters and avoids the lack of a built-in right-side margin.
|
||||
- **Synced scroll = proportional (Green).** Mirror each pane's vertical scroll offset to
|
||||
the other two with a re-entrancy guard. Aligned/virtual-space scroll + bezier connector
|
||||
curves are a deferred stretch.
|
||||
- **Seam + existing VM tests preserved.** Keep `MergeConflictBlock` with its
|
||||
`AcceptOurs/Theirs/Both/Base` commands and `MergeFile.Compose`; keep
|
||||
`Current`/`CurrentIndex`/`Next`/`Previous` repurposed as the focused-conflict the top
|
||||
arrows jump to. New state (active file, readout) is additive.
|
||||
|
||||
## Architecture
|
||||
|
||||
### ViewModel (`ConflictResolverViewModel`, `ConflictModels.cs`)
|
||||
|
||||
Unchanged seam: ctor, `OpenAsync`, `OpenForPlanningAsync`, `CloseRequested`,
|
||||
`Continue`/`Abort` (incl. planning routing), `CanContinue` gating, binary guard.
|
||||
|
||||
Additive:
|
||||
- `ActiveFile` (`MergeFile`) + the switcher list (`Files`) + `SelectFileCommand`.
|
||||
- Per-active-file reconstruction exposed for the view and for tests:
|
||||
`ActiveOursText`, `ActiveTheirsText`, `ActiveResultText` (result seeds unresolved =
|
||||
Ours), plus an ordered list of conflict descriptors (the block + its segment index)
|
||||
so the view can compute offsets/spans as it assembles each document.
|
||||
- Readout `PositionText` → `"{M} conflicts · {K} resolved"` (active file and/or total);
|
||||
`CanContinue` stays "all files resolved AND no binary".
|
||||
- On switching files, block `Resolution` persists (state lives on `MergeConflictBlock`),
|
||||
so progress survives navigation; the view rebuilds documents from the active file.
|
||||
|
||||
### View (`Views/Conflicts/ConflictResolverView.axaml` + `.cs`)
|
||||
|
||||
- AXAML: ModalShell host (kept), header (prev/next arrows, file switcher, readout),
|
||||
`Grid` of three bordered panes with headers, two between-pane overlay Canvases,
|
||||
footer (Continue/Abort), binary banner, `Escape`→Abort. Drop the Base column.
|
||||
- Code-behind builds three `TextDocument`s from `ActiveFile`'s segments, recording each
|
||||
conflict's line span per document; installs TextMate by file extension on all three;
|
||||
rebuilds on file switch; pushes result-pane edits back into the active block's
|
||||
`Resolution` and flips resolved.
|
||||
- `IReadOnlySectionProvider` on the Result `TextArea` (stable = read-only, conflicts =
|
||||
editable) backed by a `TextSegmentCollection` of the conflict result-spans.
|
||||
- One `IBackgroundRenderer` per pane painting unresolved-conflict (red), resolved
|
||||
(green/muted), and ours/theirs side tints, driven by the recorded spans + block state.
|
||||
- Overlay accept controls positioned at each block's `TextView` visual top; click →
|
||||
`block.AcceptOurs/AcceptTheirs` and the code-behind replaces the tracked result span.
|
||||
- Proportional synced vertical scroll across the three panes.
|
||||
|
||||
### Localization / tokens
|
||||
|
||||
- New `conflictResolver.*` keys (pane headers, readout, accept tooltips) in
|
||||
`en.json` + `de.json` (parity enforced by Localization.Tests).
|
||||
- Block colors from `Tokens.axaml` (reuse Blood/Moss/Accent tints; add tokens only if a
|
||||
needed shade is missing).
|
||||
|
||||
## Out of scope / fast-follow (not in this plan)
|
||||
|
||||
- **Raw 3-way diff "N changes" parity (Option B):** a new worker method returning raw
|
||||
`:1/:2/:3` blobs per conflicted file + DiffPlex client-side 3-way diff so
|
||||
non-conflicting changes also appear as accept-able hunks. Seam-preserving; later.
|
||||
- **Intra-conflict word/line highlighting** (Rider's "Highlight words") via a line
|
||||
transformer.
|
||||
- **Bezier connector curves + aligned / virtual-space synced scroll** (Red stretch).
|
||||
- No DB migration, no backend/seam changes, no push.
|
||||
@@ -31,6 +31,7 @@
|
||||
|
||||
<Application.Styles>
|
||||
<FluentTheme />
|
||||
<StyleInclude Source="avares://AvaloniaEdit/Themes/Fluent/AvaloniaEdit.xaml" />
|
||||
<StyleInclude Source="avares://ClaudeDo.Ui/Design/IslandStyles.axaml" />
|
||||
<!-- Global defaults: every Window inherits Inter Tight + body size.
|
||||
Controls that need mono opt in via their own class/style. -->
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 44 KiB After Width: | Height: | Size: 55 KiB |
@@ -19,8 +19,8 @@ Desktop entry point for the ClaudeDo application. Configures DI, initializes the
|
||||
|
||||
## DI Registration Pattern
|
||||
|
||||
- **Singletons**: `IDbContextFactory`, all Repositories, GitService, WorkerClient, `IReleaseClient`, `InstallerLocator` / `WorkerLocator`, the island VMs (`ListsIslandViewModel`, `TasksIslandViewModel`, `DetailsIslandViewModel`) and `IslandsShellViewModel` (the window's DataContext)
|
||||
- **Transients**: modal VMs (`SettingsModalViewModel`, `MergeModalViewModel`, `ListSettingsModalViewModel`, `RepoImportModalViewModel`, `WorktreeModalViewModel`, `WorktreesOverviewModalViewModel`, `PrimeClaudeTabViewModel`), several exposed as `Func<T>` factories for on-demand dialog creation
|
||||
- **Singletons**: `IDbContextFactory`, all Repositories, GitService, WorkerClient, `IReleaseClient`, `UpdateCheckService`, `IPrimeScheduleApi`/`WorkerPrimeScheduleApi`, `INotesApi`/`WorkerNotesApi`, `InstallerLocator` / `WorkerLocator`, the island VMs (`ListsIslandViewModel`, `TasksIslandViewModel`, `DetailsIslandViewModel`) and `IslandsShellViewModel` (the window's DataContext)
|
||||
- **Transients**: modal VMs (`SettingsModalViewModel`, `MergeModalViewModel`, `ListSettingsModalViewModel`, `RepoImportModalViewModel`, `WeeklyReportModalViewModel`, `WorktreeModalViewModel`, `WorktreesOverviewModalViewModel`, `PrimeClaudeTabViewModel`), several exposed as `Func<T>` factories for on-demand dialog creation; `ConflictResolverViewModel` via a `Func<string, ConflictResolverViewModel>` factory keyed by taskId (singleton factory, handed to `IslandsShellViewModel.ConflictResolverFactory`)
|
||||
|
||||
## Notes
|
||||
|
||||
|
||||
@@ -14,10 +14,12 @@
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Avalonia" Version="12.0.0" />
|
||||
<PackageReference Include="Avalonia.Desktop" Version="12.0.0" />
|
||||
<PackageReference Include="Avalonia.Themes.Fluent" Version="12.0.0" />
|
||||
<PackageReference Include="Avalonia.Fonts.Inter" Version="12.0.0" />
|
||||
<PackageReference Include="Avalonia" Version="12.0.4" />
|
||||
<PackageReference Include="Avalonia.Desktop" Version="12.0.4" />
|
||||
<PackageReference Include="Avalonia.Themes.Fluent" Version="12.0.4" />
|
||||
<PackageReference Include="Avalonia.Fonts.Inter" Version="12.0.4" />
|
||||
<!-- Direct ref so the App.axaml AvaloniaEdit theme (avares://AvaloniaEdit/...) resolves at runtime. -->
|
||||
<PackageReference Include="Avalonia.AvaloniaEdit" Version="12.0.0" />
|
||||
<PackageReference Include="AvaloniaUI.DiagnosticsSupport" Version="2.2.0">
|
||||
<IncludeAssets Condition="'$(Configuration)' != 'Debug'">None</IncludeAssets>
|
||||
<PrivateAssets Condition="'$(Configuration)' != 'Debug'">All</PrivateAssets>
|
||||
|
||||
@@ -123,6 +123,7 @@ sealed class Program
|
||||
sc.AddTransient<Func<WorktreesOverviewModalViewModel>>(sp => () => sp.GetRequiredService<WorktreesOverviewModalViewModel>());
|
||||
sc.AddSingleton<IPrimeScheduleApi, WorkerPrimeScheduleApi>();
|
||||
sc.AddSingleton<INotesApi, WorkerNotesApi>();
|
||||
sc.AddSingleton<IOnlineLoginService, OnlineLoginService>();
|
||||
sc.AddTransient<PrimeClaudeTabViewModel>();
|
||||
sc.AddTransient<SettingsModalViewModel>();
|
||||
sc.AddTransient<MergeModalViewModel>();
|
||||
@@ -132,24 +133,33 @@ sealed class Program
|
||||
sc.AddTransient<Func<RepoImportModalViewModel>>(sp => () => sp.GetRequiredService<RepoImportModalViewModel>());
|
||||
sc.AddTransient<WeeklyReportModalViewModel>();
|
||||
sc.AddTransient<Func<WeeklyReportModalViewModel>>(sp => () => sp.GetRequiredService<WeeklyReportModalViewModel>());
|
||||
sc.AddSingleton<Func<string, ClaudeDo.Ui.ViewModels.Conflicts.ConflictResolverViewModel>>(sp =>
|
||||
taskId => new ClaudeDo.Ui.ViewModels.Conflicts.ConflictResolverViewModel(
|
||||
sp.GetRequiredService<IWorkerClient>(), taskId));
|
||||
|
||||
// Islands shell VMs
|
||||
sc.AddSingleton<ListsIslandViewModel>(sp =>
|
||||
new ListsIslandViewModel(
|
||||
sp.GetRequiredService<IDbContextFactory<ClaudeDoDbContext>>(),
|
||||
sp,
|
||||
sp.GetRequiredService<WorkerClient>()));
|
||||
sp.GetRequiredService<IWorkerClient>()));
|
||||
sc.AddSingleton<TasksIslandViewModel>(sp =>
|
||||
new TasksIslandViewModel(
|
||||
sp.GetRequiredService<IDbContextFactory<ClaudeDoDbContext>>(),
|
||||
sp.GetRequiredService<WorkerClient>()));
|
||||
sp.GetRequiredService<IWorkerClient>()));
|
||||
sc.AddSingleton<DetailsIslandViewModel>(sp =>
|
||||
new DetailsIslandViewModel(
|
||||
sp.GetRequiredService<IDbContextFactory<ClaudeDoDbContext>>(),
|
||||
sp.GetRequiredService<WorkerClient>(),
|
||||
sp.GetRequiredService<IWorkerClient>(),
|
||||
sp,
|
||||
sp.GetRequiredService<INotesApi>()));
|
||||
sc.AddSingleton<IslandsShellViewModel>();
|
||||
sc.AddSingleton<IslandsShellViewModel>(sp =>
|
||||
{
|
||||
var shell = ActivatorUtilities.CreateInstance<IslandsShellViewModel>(sp);
|
||||
shell.ConflictResolverFactory =
|
||||
sp.GetRequiredService<Func<string, ClaudeDo.Ui.ViewModels.Conflicts.ConflictResolverViewModel>>();
|
||||
return shell;
|
||||
});
|
||||
|
||||
return sc.BuildServiceProvider();
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ Shared data layer: models, repositories, SQLite infrastructure, and git operatio
|
||||
|
||||
## Models
|
||||
|
||||
- **TaskEntity** — Id, ListId, Title, Description, Status (`Idle|Queued|Running|WaitingForReview|Done|Failed|Cancelled`), PlanningPhase (`None|Active|Finalized` — parent-only), BlockedByTaskId (nullable FK to predecessor in a chain), ScheduledFor, Result, ReviewFeedback (nullable; reviewer's rejection comment, consumed and cleared by the runner on the next re-run), LogPath, timestamps, CommitType, Model / SystemPrompt / AgentPath / MaxTurns (nullable overrides), IsStarred, IsMyDay, Notes, ParentTaskId, PlanningSessionId, PlanningSessionToken, PlanningFinalizedAt, CreatedBy. Legacy values `Manual`/`Planning`/`Planned`/`Draft`/`Waiting` were retired; existing rows backfill automatically via the `RetireLegacyTaskStatus` migration.
|
||||
- **TaskEntity** — Id, ListId, Title, Description, Status (`Idle|Queued|Running|WaitingForChildren|WaitingForReview|Done|Failed|Cancelled`), PlanningPhase (`None|Active|Finalized` — parent-only), BlockedByTaskId (nullable FK to predecessor in a chain), ScheduledFor, Result, ReviewFeedback (nullable; reviewer's rejection comment, consumed and cleared by the runner on the next re-run), LogPath, timestamps, CommitType, Model / SystemPrompt / AgentPath / MaxTurns (nullable overrides), IsStarred, IsMyDay, Notes, ParentTaskId, PlanningSessionId, PlanningSessionToken, PlanningFinalizedAt, CreatedBy. Legacy values `Manual`/`Planning`/`Planned`/`Draft`/`Waiting` were retired; existing rows backfill automatically via the `RetireLegacyTaskStatus` migration.
|
||||
- **ListEntity** — Id, Name, WorkingDir, DefaultCommitType, CreatedAt
|
||||
- **ListConfigEntity** — ListId (PK, 1:1 with list), Model, SystemPrompt, AgentPath, MaxTurns (all nullable)
|
||||
- **WorktreeEntity** — TaskId (PK, 1:1 with task), Path, BranchName, BaseCommit, HeadCommit, DiffStat, State (Active|Merged|Discarded|Kept)
|
||||
@@ -19,7 +19,7 @@ Shared data layer: models, repositories, SQLite infrastructure, and git operatio
|
||||
|
||||
All repositories use EF Core LINQ queries via `ClaudeDoDbContext`. The atomic `Queued -> Running` claim lives in the Worker's `QueuePicker` (uses `FromSqlRaw`), not here.
|
||||
|
||||
- **TaskRepository** — CRUD, planning helpers (`CreateChildAsync`, `SetPlanningStartedAsync`, `DiscardPlanningAsync`, `TryCompleteParentAsync`, `UpdateChildAsync`), `UpdateAgentSettingsAsync` (model / system-prompt / agent-path overrides). Status-mutation primitives `MarkRunningAsync` / `MarkDoneAsync` / `MarkFailedAsync` / `FlipAllRunningToFailedAsync` are `internal` and called only by `TaskStateService` in the worker. `CreateChildAsync` produces children with `Status=Idle, PlanningPhase=None`; once their parent's `PlanningPhase` becomes `Finalized`, the chain coordinator queues them.
|
||||
- **TaskRepository** — CRUD, planning helpers (`CreateChildAsync`, `SetPlanningStartedAsync`, `DiscardPlanningAsync`, `UpdateChildAsync`), `UpdateAgentSettingsAsync` (model / system-prompt / agent-path overrides). Status-mutation primitives `MarkRunningAsync` / `MarkDoneAsync` / `MarkFailedAsync` / `FlipAllRunningToFailedAsync` are `internal` and called only by `TaskStateService` in the worker. `CreateChildAsync` produces children with `Status=Idle, PlanningPhase=None`; once their parent's `PlanningPhase` becomes `Finalized`, the chain coordinator queues them.
|
||||
- **ListRepository** — CRUD, `GetConfigAsync` / `SetConfigAsync` (upsert) / `DeleteConfigAsync` for `list_config`
|
||||
- **WorktreeRepository** — CRUD, `UpdateHeadAsync`, `SetStateAsync`
|
||||
- **TaskRunRepository**, **SubtaskRepository**, **AppSettingsRepository**
|
||||
@@ -35,7 +35,7 @@ All repositories use EF Core LINQ queries via `ClaudeDoDbContext`. The atomic `Q
|
||||
|
||||
## Git
|
||||
|
||||
- **GitService** — async wrapper around git CLI (ProcessStartInfo, no shell). Operations: worktree add/remove, add all, commit (stdin for message), merge ff-only, rev-parse, diff-stat, has-changes, is-git-repo, `PreviewMergeAsync` (non-destructive mergeability check via `git merge-tree --write-tree`), `CountChangedFilesAsync`
|
||||
- **GitService** — async wrapper around git CLI (ProcessStartInfo, no shell). Worktree ops (add — serialized to avoid a commondir race —, remove, prune, list paths for branch), branch ops (current, list local, checkout, delete), staging/commit (status porcelain, add-all, add-path, commit via stdin), diffs (working tree, branch vs base, commit range `base..head` — used to show a merged task's diff after the worktree is gone —, per-file, diff-stat, committed files, has-changes), merge (ff-only, no-ff, abort, mid-merge detection, conflicted files, show-stage for conflict hunks), `PreviewMergeAsync` (non-destructive mergeability check via `git merge-tree --write-tree`), `CountChangedFilesAsync`, rev-parse, is-git-repo
|
||||
|
||||
## Schema
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
using System.Data.Common;
|
||||
using ClaudeDo.Data.Models;
|
||||
using ClaudeDo.Data.Seeding;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Diagnostics;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Storage;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
@@ -9,8 +11,35 @@ namespace ClaudeDo.Data;
|
||||
|
||||
public class ClaudeDoDbContext : DbContext
|
||||
{
|
||||
// Runs PRAGMA foreign_keys=ON on every EF-managed connection open so FK
|
||||
// enforcement is active for all IDbContextFactory-created contexts, not
|
||||
// just the single context used in MigrateAndConfigure.
|
||||
private sealed class SqliteForeignKeyInterceptor : DbConnectionInterceptor
|
||||
{
|
||||
internal static readonly SqliteForeignKeyInterceptor Instance = new();
|
||||
|
||||
public override void ConnectionOpened(DbConnection connection, ConnectionEndEventData eventData)
|
||||
=> Apply(connection);
|
||||
|
||||
public override Task ConnectionOpenedAsync(DbConnection connection, ConnectionEndEventData eventData, CancellationToken cancellationToken = default)
|
||||
{
|
||||
Apply(connection);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private static void Apply(DbConnection connection)
|
||||
{
|
||||
using var cmd = connection.CreateCommand();
|
||||
cmd.CommandText = "PRAGMA foreign_keys=ON;";
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
}
|
||||
|
||||
public ClaudeDoDbContext(DbContextOptions<ClaudeDoDbContext> options) : base(options) { }
|
||||
|
||||
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
|
||||
=> optionsBuilder.AddInterceptors(SqliteForeignKeyInterceptor.Instance);
|
||||
|
||||
public DbSet<TaskEntity> Tasks => Set<TaskEntity>();
|
||||
public DbSet<ListEntity> Lists => Set<ListEntity>();
|
||||
public DbSet<ListConfigEntity> ListConfigs => Set<ListConfigEntity>();
|
||||
|
||||
@@ -7,8 +7,7 @@ public sealed class ReviewFilter : ITaskListFilter
|
||||
{
|
||||
public string Id => "virtual:review";
|
||||
public bool Matches(TaskEntity t) =>
|
||||
t.Status == TaskStatus.Done &&
|
||||
t.Worktree is { State: WorktreeState.Active };
|
||||
t.Status == TaskStatus.WaitingForReview;
|
||||
public bool ShouldCount(TaskEntity t) => Matches(t);
|
||||
public bool MatchesAsContext(TaskEntity t, IReadOnlyList<TaskEntity> all) => false;
|
||||
}
|
||||
|
||||
134
src/ClaudeDo.Data/Git/ConflictMarkerParser.cs
Normal file
134
src/ClaudeDo.Data/Git/ConflictMarkerParser.cs
Normal file
@@ -0,0 +1,134 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
|
||||
namespace ClaudeDo.Data.Git;
|
||||
|
||||
/// <summary>
|
||||
/// One piece of a conflicted file: either common ("stable") text both sides agree on,
|
||||
/// or a conflict region holding the two — or, with diff3 markers, three — competing versions.
|
||||
/// </summary>
|
||||
public sealed record MergeSegment
|
||||
{
|
||||
public bool IsConflict { get; init; }
|
||||
|
||||
/// <summary>Stable text (verbatim, line endings preserved) when <see cref="IsConflict"/> is false.</summary>
|
||||
public string Text { get; init; } = "";
|
||||
|
||||
/// <summary>"Ours" side (the target branch) when <see cref="IsConflict"/> is true.</summary>
|
||||
public string Ours { get; init; } = "";
|
||||
|
||||
/// <summary>Merge base, present only when the merge used diff3 conflict style; null otherwise.</summary>
|
||||
public string? Base { get; init; }
|
||||
|
||||
/// <summary>"Theirs" side (the incoming branch) when <see cref="IsConflict"/> is true.</summary>
|
||||
public string Theirs { get; init; } = "";
|
||||
|
||||
public static MergeSegment Stable(string text) => new() { Text = text };
|
||||
|
||||
public static MergeSegment Conflict(string ours, string? @base, string theirs) =>
|
||||
new() { IsConflict = true, Ours = ours, Base = @base, Theirs = theirs };
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parses a conflicted file's text into ordered stable / conflict segments and reassembles it.
|
||||
/// Reads git conflict markers verbatim, so a file with no markers yields a single stable
|
||||
/// segment, and reassembling the stable text plus one chosen resolution per conflict
|
||||
/// round-trips the file exactly (line endings included).
|
||||
/// </summary>
|
||||
public static class ConflictMarkerParser
|
||||
{
|
||||
private const string OursMarker = "<<<<<<<";
|
||||
private const string BaseMarker = "|||||||";
|
||||
private const string SepMarker = "=======";
|
||||
private const string TheirsMarker = ">>>>>>>";
|
||||
|
||||
public static IReadOnlyList<MergeSegment> Parse(string fileText)
|
||||
{
|
||||
var segments = new List<MergeSegment>();
|
||||
var lines = SplitKeepLineEndings(fileText);
|
||||
var stable = new StringBuilder();
|
||||
var i = 0;
|
||||
|
||||
while (i < lines.Count)
|
||||
{
|
||||
if (!IsMarker(lines[i], OursMarker))
|
||||
{
|
||||
stable.Append(lines[i++]);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (stable.Length > 0)
|
||||
{
|
||||
segments.Add(MergeSegment.Stable(stable.ToString()));
|
||||
stable.Clear();
|
||||
}
|
||||
|
||||
i++; // consume "<<<<<<<"
|
||||
var ours = new StringBuilder();
|
||||
while (i < lines.Count && !IsMarker(lines[i], BaseMarker) && !IsMarker(lines[i], SepMarker))
|
||||
ours.Append(lines[i++]);
|
||||
|
||||
string? @base = null;
|
||||
if (i < lines.Count && IsMarker(lines[i], BaseMarker))
|
||||
{
|
||||
i++; // consume "|||||||"
|
||||
var baseText = new StringBuilder();
|
||||
while (i < lines.Count && !IsMarker(lines[i], SepMarker))
|
||||
baseText.Append(lines[i++]);
|
||||
@base = baseText.ToString();
|
||||
}
|
||||
|
||||
if (i < lines.Count && IsMarker(lines[i], SepMarker)) i++; // consume "======="
|
||||
|
||||
var theirs = new StringBuilder();
|
||||
while (i < lines.Count && !IsMarker(lines[i], TheirsMarker))
|
||||
theirs.Append(lines[i++]);
|
||||
|
||||
if (i < lines.Count && IsMarker(lines[i], TheirsMarker)) i++; // consume ">>>>>>>"
|
||||
|
||||
segments.Add(MergeSegment.Conflict(ours.ToString(), @base, theirs.ToString()));
|
||||
}
|
||||
|
||||
if (stable.Length > 0)
|
||||
segments.Add(MergeSegment.Stable(stable.ToString()));
|
||||
|
||||
return segments;
|
||||
}
|
||||
|
||||
/// <summary>True when the file still contains an opening conflict marker.</summary>
|
||||
public static bool HasConflicts(string fileText) =>
|
||||
SplitKeepLineEndings(fileText).Any(l => IsMarker(l, OursMarker));
|
||||
|
||||
/// <summary>
|
||||
/// Reassembles a file from its segments. Stable segments emit their text verbatim;
|
||||
/// each conflict segment emits whatever <paramref name="resolveConflict"/> returns for it.
|
||||
/// </summary>
|
||||
public static string Compose(
|
||||
IEnumerable<MergeSegment> segments, Func<MergeSegment, string> resolveConflict) =>
|
||||
string.Concat(segments.Select(s => s.IsConflict ? resolveConflict(s) : s.Text));
|
||||
|
||||
// A marker line starts with exactly the 7-char marker, then end-of-line or whitespace/label.
|
||||
private static bool IsMarker(string line, string marker)
|
||||
{
|
||||
if (!line.StartsWith(marker, StringComparison.Ordinal)) return false;
|
||||
if (line.Length == marker.Length) return true;
|
||||
return line[marker.Length] is ' ' or '\t' or '\r' or '\n';
|
||||
}
|
||||
|
||||
// Splits into physical lines, each retaining its trailing "\n" (and "\r" if present).
|
||||
private static List<string> SplitKeepLineEndings(string s)
|
||||
{
|
||||
var lines = new List<string>();
|
||||
var i = 0;
|
||||
while (i < s.Length)
|
||||
{
|
||||
var nl = s.IndexOf('\n', i);
|
||||
if (nl < 0) { lines.Add(s[i..]); break; }
|
||||
lines.Add(s[i..(nl + 1)]);
|
||||
i = nl + 1;
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,11 @@ public sealed record MergePreview(bool Supported, bool Clean, IReadOnlyList<stri
|
||||
|
||||
public sealed class GitService
|
||||
{
|
||||
// git mutates shared .git/worktrees/ metadata during `worktree add`; concurrent adds
|
||||
// race and fail with "failed to read .git/worktrees/<other>/commondir". Serialize them
|
||||
// process-wide so parallel task starts don't collide.
|
||||
private static readonly SemaphoreSlim WorktreeAddGate = new(1, 1);
|
||||
|
||||
public async Task<bool> IsGitRepoAsync(string dir, CancellationToken ct = default)
|
||||
{
|
||||
var (exitCode, _, _) = await RunGitAsync(dir, ["rev-parse", "--git-dir"], ct);
|
||||
@@ -23,10 +28,30 @@ public sealed class GitService
|
||||
|
||||
public async Task WorktreeAddAsync(string repoDir, string branchName, string worktreePath, string baseCommit, CancellationToken ct = default)
|
||||
{
|
||||
var (exitCode, _, stderr) = await RunGitAsync(repoDir,
|
||||
["worktree", "add", "-b", branchName, worktreePath, baseCommit], ct);
|
||||
if (exitCode != 0)
|
||||
throw new InvalidOperationException($"git worktree add failed (exit {exitCode}): {stderr}");
|
||||
await WorktreeAddGate.WaitAsync(ct);
|
||||
try
|
||||
{
|
||||
const int maxAttempts = 3;
|
||||
for (var attempt = 1; ; attempt++)
|
||||
{
|
||||
var (exitCode, _, stderr) = await RunGitAsync(repoDir,
|
||||
["worktree", "add", "-b", branchName, worktreePath, baseCommit], ct);
|
||||
if (exitCode == 0)
|
||||
return;
|
||||
|
||||
// Transient races leave a half-written worktree metadata dir; retry briefly.
|
||||
var transient = stderr.Contains("commondir", StringComparison.OrdinalIgnoreCase)
|
||||
|| stderr.Contains("failed to read", StringComparison.OrdinalIgnoreCase);
|
||||
if (!transient || attempt >= maxAttempts)
|
||||
throw new InvalidOperationException($"git worktree add failed (exit {exitCode}): {stderr}");
|
||||
|
||||
await Task.Delay(150 * attempt, ct);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
WorktreeAddGate.Release();
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<string> GetStatusPorcelainAsync(string workingDirectory, CancellationToken ct = default)
|
||||
@@ -99,6 +124,20 @@ public sealed class GitService
|
||||
return await GetDiffAsync(worktreePath, ct);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Diff between two commits, run in any repo that can reach them. Used to view a
|
||||
/// task's changes after its worktree has been merged away (the commits survive on
|
||||
/// the target branch even though the worktree directory and branch ref are gone).
|
||||
/// </summary>
|
||||
public async Task<string> GetCommitRangeDiffAsync(string repoDir, string baseCommit, string headCommit, CancellationToken ct = default)
|
||||
{
|
||||
var (exitCode, stdout, stderr) = await RunGitAsync(repoDir,
|
||||
["diff", $"{baseCommit}..{headCommit}"], ct);
|
||||
if (exitCode != 0)
|
||||
throw new InvalidOperationException($"git diff {baseCommit}..{headCommit} failed (exit {exitCode}): {stderr}");
|
||||
return stdout;
|
||||
}
|
||||
|
||||
public async Task<string> DiffStatAsync(string worktreePath, string baseCommit, string headCommit, CancellationToken ct = default)
|
||||
{
|
||||
var (exitCode, stdout, stderr) = await RunGitAsync(worktreePath,
|
||||
@@ -213,8 +252,11 @@ public sealed class GitService
|
||||
public async Task<(int ExitCode, string Stderr)> MergeNoFfAsync(
|
||||
string repoDir, string sourceBranch, string message, CancellationToken ct = default)
|
||||
{
|
||||
// diff3 conflict style writes the merge base (|||||||) into conflict markers so the
|
||||
// in-app resolver can show a true three-way view. It only enriches conflicted hunks;
|
||||
// clean merges are unaffected.
|
||||
var (exitCode, _, stderr) = await RunGitAsync(repoDir,
|
||||
["merge", "--no-ff", "-m", message, sourceBranch], ct);
|
||||
["-c", "merge.conflictStyle=diff3", "merge", "--no-ff", "-m", message, sourceBranch], ct);
|
||||
return (exitCode, stderr);
|
||||
}
|
||||
|
||||
@@ -238,6 +280,24 @@ public sealed class GitService
|
||||
.ToList();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads a conflicted file's blob at a merge stage: 1=base, 2=ours, 3=theirs.
|
||||
/// Returns null when the stage doesn't exist (e.g. add/add conflict has no base).
|
||||
/// Output is NOT trimmed so file content round-trips exactly.
|
||||
/// </summary>
|
||||
public async Task<string?> ShowStageAsync(string repoDir, int stage, string path, CancellationToken ct = default)
|
||||
{
|
||||
var (exitCode, stdout, _) = await RunGitAsync(repoDir, ["show", $":{stage}:{path}"], ct, trimOutput: false);
|
||||
return exitCode == 0 ? stdout : null;
|
||||
}
|
||||
|
||||
public async Task AddPathAsync(string repoDir, string path, CancellationToken ct = default)
|
||||
{
|
||||
var (exitCode, _, stderr) = await RunGitAsync(repoDir, ["add", "--", path], ct);
|
||||
if (exitCode != 0)
|
||||
throw new InvalidOperationException($"git add '{path}' failed (exit {exitCode}): {stderr}");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Non-destructive mergeability probe via `git merge-tree --write-tree`. Writes only
|
||||
/// loose objects — the working tree, index, and refs are left untouched.
|
||||
@@ -289,7 +349,7 @@ public sealed class GitService
|
||||
}
|
||||
|
||||
private static async Task<(int ExitCode, string Stdout, string Stderr)> RunGitAsync(
|
||||
string workDir, IEnumerable<string> args, CancellationToken ct, string? stdinData = null)
|
||||
string workDir, IEnumerable<string> args, CancellationToken ct, string? stdinData = null, bool trimOutput = true)
|
||||
{
|
||||
var psi = new ProcessStartInfo
|
||||
{
|
||||
@@ -338,6 +398,6 @@ public sealed class GitService
|
||||
|
||||
ct.ThrowIfCancellationRequested();
|
||||
|
||||
return (proc.ExitCode, stdout.TrimEnd(), stderr.TrimEnd());
|
||||
return (proc.ExitCode, trimOutput ? stdout.TrimEnd() : stdout, stderr.TrimEnd());
|
||||
}
|
||||
}
|
||||
|
||||
696
src/ClaudeDo.Data/Migrations/20260609000000_UniqueListName.Designer.cs
generated
Normal file
696
src/ClaudeDo.Data/Migrations/20260609000000_UniqueListName.Designer.cs
generated
Normal file
@@ -0,0 +1,696 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using ClaudeDo.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace ClaudeDo.Data.Migrations
|
||||
{
|
||||
[DbContext(typeof(ClaudeDoDbContext))]
|
||||
[Migration("20260609000000_UniqueListName")]
|
||||
partial class UniqueListName
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder.HasAnnotation("ProductVersion", "8.0.11");
|
||||
|
||||
modelBuilder.Entity("ClaudeDo.Data.Models.AppSettingsEntity", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<string>("CentralWorktreeRoot")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("central_worktree_root");
|
||||
|
||||
b.Property<int>("DailyPrepMaxTasks")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(5)
|
||||
.HasColumnName("daily_prep_max_tasks");
|
||||
|
||||
b.Property<string>("DefaultClaudeInstructions")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT")
|
||||
.HasDefaultValue("")
|
||||
.HasColumnName("default_claude_instructions");
|
||||
|
||||
b.Property<int>("DefaultMaxTurns")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(30)
|
||||
.HasColumnName("default_max_turns");
|
||||
|
||||
b.Property<string>("DefaultModel")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT")
|
||||
.HasDefaultValue("sonnet")
|
||||
.HasColumnName("default_model");
|
||||
|
||||
b.Property<string>("DefaultPermissionMode")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT")
|
||||
.HasDefaultValue("bypassPermissions")
|
||||
.HasColumnName("default_permission_mode");
|
||||
|
||||
b.Property<int>("MaxParallelExecutions")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(1)
|
||||
.HasColumnName("max_parallel_executions");
|
||||
|
||||
b.Property<string>("RepoImportFolders")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("repo_import_folders");
|
||||
|
||||
b.Property<string>("ReportExcludedPaths")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("report_excluded_paths");
|
||||
|
||||
b.Property<int>("StandupWeekday")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(3)
|
||||
.HasColumnName("standup_weekday");
|
||||
|
||||
b.Property<int>("WorktreeAutoCleanupDays")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(7)
|
||||
.HasColumnName("worktree_auto_cleanup_days");
|
||||
|
||||
b.Property<bool>("WorktreeAutoCleanupEnabled")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(false)
|
||||
.HasColumnName("worktree_auto_cleanup_enabled");
|
||||
|
||||
b.Property<string>("WorktreeStrategy")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT")
|
||||
.HasDefaultValue("sibling")
|
||||
.HasColumnName("worktree_strategy");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("app_settings", (string)null);
|
||||
|
||||
b.HasData(
|
||||
new
|
||||
{
|
||||
Id = 1,
|
||||
DailyPrepMaxTasks = 5,
|
||||
DefaultClaudeInstructions = "",
|
||||
DefaultMaxTurns = 100,
|
||||
DefaultModel = "sonnet",
|
||||
DefaultPermissionMode = "auto",
|
||||
MaxParallelExecutions = 1,
|
||||
StandupWeekday = 3,
|
||||
WorktreeAutoCleanupDays = 7,
|
||||
WorktreeAutoCleanupEnabled = false,
|
||||
WorktreeStrategy = "sibling"
|
||||
});
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ClaudeDo.Data.Models.DailyNoteEntity", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("created_at");
|
||||
|
||||
b.Property<DateOnly>("Date")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("note_date");
|
||||
|
||||
b.Property<int>("SortOrder")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("sort_order");
|
||||
|
||||
b.Property<string>("Text")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("text");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Date");
|
||||
|
||||
b.ToTable("daily_notes", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ClaudeDo.Data.Models.ListConfigEntity", b =>
|
||||
{
|
||||
b.Property<string>("ListId")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("list_id");
|
||||
|
||||
b.Property<string>("AgentPath")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("agent_path");
|
||||
|
||||
b.Property<int?>("MaxTurns")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("max_turns");
|
||||
|
||||
b.Property<string>("Model")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("model");
|
||||
|
||||
b.Property<string>("SystemPrompt")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("system_prompt");
|
||||
|
||||
b.HasKey("ListId");
|
||||
|
||||
b.ToTable("list_config", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ClaudeDo.Data.Models.ListEntity", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("created_at");
|
||||
|
||||
b.Property<string>("DefaultCommitType")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT")
|
||||
.HasDefaultValue("chore")
|
||||
.HasColumnName("default_commit_type");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("name");
|
||||
|
||||
b.Property<int>("SortOrder")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(0)
|
||||
.HasColumnName("sort_order");
|
||||
|
||||
b.Property<string>("WorkingDir")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("working_dir");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("SortOrder")
|
||||
.HasDatabaseName("idx_lists_sort");
|
||||
|
||||
b.ToTable("lists", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ClaudeDo.Data.Models.PrimeScheduleEntity", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("created_at");
|
||||
|
||||
b.Property<int>("Days")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(31)
|
||||
.HasColumnName("days_of_week");
|
||||
|
||||
b.Property<bool>("Enabled")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(true)
|
||||
.HasColumnName("enabled");
|
||||
|
||||
b.Property<DateTimeOffset?>("LastRunAt")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("last_run_at");
|
||||
|
||||
b.Property<string>("PromptOverride")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("prompt_override");
|
||||
|
||||
b.Property<TimeSpan>("TimeOfDay")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("time_of_day");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("prime_schedules", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ClaudeDo.Data.Models.SubtaskEntity", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<bool>("Completed")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(false)
|
||||
.HasColumnName("completed");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("created_at");
|
||||
|
||||
b.Property<int>("OrderNum")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("order_num");
|
||||
|
||||
b.Property<string>("TaskId")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("task_id");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("title");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("TaskId")
|
||||
.HasDatabaseName("idx_subtasks_task_id");
|
||||
|
||||
b.ToTable("subtasks", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ClaudeDo.Data.Models.TaskEntity", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<string>("AgentPath")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("agent_path");
|
||||
|
||||
b.Property<string>("BlockedByTaskId")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("blocked_by_task_id");
|
||||
|
||||
b.Property<string>("CommitType")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT")
|
||||
.HasDefaultValue("chore")
|
||||
.HasColumnName("commit_type");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("created_at");
|
||||
|
||||
b.Property<string>("CreatedBy")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("created_by");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("description");
|
||||
|
||||
b.Property<DateTime?>("FinishedAt")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("finished_at");
|
||||
|
||||
b.Property<bool>("IsMyDay")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(false)
|
||||
.HasColumnName("is_my_day");
|
||||
|
||||
b.Property<bool>("IsStarred")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(false)
|
||||
.HasColumnName("is_starred");
|
||||
|
||||
b.Property<string>("ListId")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("list_id");
|
||||
|
||||
b.Property<string>("LogPath")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("log_path");
|
||||
|
||||
b.Property<int?>("MaxTurns")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("max_turns");
|
||||
|
||||
b.Property<string>("Model")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("model");
|
||||
|
||||
b.Property<string>("Notes")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("notes");
|
||||
|
||||
b.Property<string>("ParentTaskId")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("parent_task_id");
|
||||
|
||||
b.Property<DateTime?>("PlanningFinalizedAt")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("planning_finalized_at");
|
||||
|
||||
b.Property<string>("PlanningPhase")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT")
|
||||
.HasDefaultValue("none")
|
||||
.HasColumnName("planning_phase");
|
||||
|
||||
b.Property<string>("PlanningSessionId")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("planning_session_id");
|
||||
|
||||
b.Property<string>("PlanningSessionToken")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("planning_session_token");
|
||||
|
||||
b.Property<string>("Result")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("result");
|
||||
|
||||
b.Property<string>("ReviewFeedback")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("review_feedback");
|
||||
|
||||
b.Property<int>("RoadblockCount")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(0)
|
||||
.HasColumnName("roadblock_count");
|
||||
|
||||
b.Property<DateTime?>("ScheduledFor")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("scheduled_for");
|
||||
|
||||
b.Property<int>("SortOrder")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(0)
|
||||
.HasColumnName("sort_order");
|
||||
|
||||
b.Property<DateTime?>("StartedAt")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("started_at");
|
||||
|
||||
b.Property<string>("Status")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("status");
|
||||
|
||||
b.Property<string>("SystemPrompt")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("system_prompt");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("title");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("BlockedByTaskId")
|
||||
.HasDatabaseName("idx_tasks_blocked_by");
|
||||
|
||||
b.HasIndex("ListId")
|
||||
.HasDatabaseName("idx_tasks_list_id");
|
||||
|
||||
b.HasIndex("ParentTaskId")
|
||||
.HasDatabaseName("idx_tasks_parent_task_id");
|
||||
|
||||
b.HasIndex("Status")
|
||||
.HasDatabaseName("idx_tasks_status");
|
||||
|
||||
b.HasIndex("ListId", "SortOrder")
|
||||
.HasDatabaseName("idx_tasks_list_sort");
|
||||
|
||||
b.ToTable("tasks", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ClaudeDo.Data.Models.TaskRunEntity", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<string>("ErrorMarkdown")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("error_markdown");
|
||||
|
||||
b.Property<int?>("ExitCode")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("exit_code");
|
||||
|
||||
b.Property<DateTime?>("FinishedAt")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("finished_at");
|
||||
|
||||
b.Property<bool>("IsRetry")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(false)
|
||||
.HasColumnName("is_retry");
|
||||
|
||||
b.Property<string>("LogPath")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("log_path");
|
||||
|
||||
b.Property<string>("Prompt")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("prompt");
|
||||
|
||||
b.Property<string>("ResultMarkdown")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("result_markdown");
|
||||
|
||||
b.Property<int>("RunNumber")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("run_number");
|
||||
|
||||
b.Property<string>("SessionId")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("session_id");
|
||||
|
||||
b.Property<DateTime?>("StartedAt")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("started_at");
|
||||
|
||||
b.Property<string>("StructuredOutputJson")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("structured_output");
|
||||
|
||||
b.Property<string>("TaskId")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("task_id");
|
||||
|
||||
b.Property<int?>("TokensIn")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("tokens_in");
|
||||
|
||||
b.Property<int?>("TokensOut")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("tokens_out");
|
||||
|
||||
b.Property<int?>("TurnCount")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("turn_count");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("TaskId")
|
||||
.HasDatabaseName("idx_task_runs_task_id");
|
||||
|
||||
b.ToTable("task_runs", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ClaudeDo.Data.Models.WeekReportEntity", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<DateOnly>("EndDate")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("end_date");
|
||||
|
||||
b.Property<DateTime>("GeneratedAt")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("generated_at");
|
||||
|
||||
b.Property<string>("Markdown")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("markdown");
|
||||
|
||||
b.Property<DateOnly>("StartDate")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("start_date");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("StartDate", "EndDate")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("week_reports", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ClaudeDo.Data.Models.WorktreeEntity", b =>
|
||||
{
|
||||
b.Property<string>("TaskId")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("task_id");
|
||||
|
||||
b.Property<string>("BaseCommit")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("base_commit");
|
||||
|
||||
b.Property<string>("BranchName")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("branch_name");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("created_at");
|
||||
|
||||
b.Property<string>("DiffStat")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("diff_stat");
|
||||
|
||||
b.Property<string>("HeadCommit")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("head_commit");
|
||||
|
||||
b.Property<string>("Path")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("path");
|
||||
|
||||
b.Property<string>("State")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT")
|
||||
.HasDefaultValue("active")
|
||||
.HasColumnName("state");
|
||||
|
||||
b.HasKey("TaskId");
|
||||
|
||||
b.ToTable("worktrees", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ClaudeDo.Data.Models.ListConfigEntity", b =>
|
||||
{
|
||||
b.HasOne("ClaudeDo.Data.Models.ListEntity", "List")
|
||||
.WithOne("Config")
|
||||
.HasForeignKey("ClaudeDo.Data.Models.ListConfigEntity", "ListId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("List");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ClaudeDo.Data.Models.SubtaskEntity", b =>
|
||||
{
|
||||
b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Task")
|
||||
.WithMany("Subtasks")
|
||||
.HasForeignKey("TaskId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Task");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ClaudeDo.Data.Models.TaskEntity", b =>
|
||||
{
|
||||
b.HasOne("ClaudeDo.Data.Models.TaskEntity", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("BlockedByTaskId")
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
|
||||
b.HasOne("ClaudeDo.Data.Models.ListEntity", "List")
|
||||
.WithMany("Tasks")
|
||||
.HasForeignKey("ListId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Parent")
|
||||
.WithMany("Children")
|
||||
.HasForeignKey("ParentTaskId")
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
b.Navigation("List");
|
||||
|
||||
b.Navigation("Parent");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ClaudeDo.Data.Models.TaskRunEntity", b =>
|
||||
{
|
||||
b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Task")
|
||||
.WithMany("Runs")
|
||||
.HasForeignKey("TaskId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Task");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ClaudeDo.Data.Models.WorktreeEntity", b =>
|
||||
{
|
||||
b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Task")
|
||||
.WithOne("Worktree")
|
||||
.HasForeignKey("ClaudeDo.Data.Models.WorktreeEntity", "TaskId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Task");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ClaudeDo.Data.Models.ListEntity", b =>
|
||||
{
|
||||
b.Navigation("Config");
|
||||
|
||||
b.Navigation("Tasks");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ClaudeDo.Data.Models.TaskEntity", b =>
|
||||
{
|
||||
b.Navigation("Children");
|
||||
|
||||
b.Navigation("Runs");
|
||||
|
||||
b.Navigation("Subtasks");
|
||||
|
||||
b.Navigation("Worktree");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace ClaudeDo.Data.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class UniqueListName : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
// Remove duplicate list rows that have no tasks — keep the oldest rowid.
|
||||
// This handles the startup-race case where both App and Worker seeded
|
||||
// the same default list names concurrently.
|
||||
migrationBuilder.Sql("""
|
||||
DELETE FROM lists
|
||||
WHERE (SELECT COUNT(*) FROM tasks WHERE list_id = lists.id) = 0
|
||||
AND rowid NOT IN (
|
||||
SELECT MIN(l2.rowid) FROM lists l2 WHERE l2.name = lists.name
|
||||
)
|
||||
""");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,9 +4,26 @@ public static class ModelRegistry
|
||||
{
|
||||
public static readonly IReadOnlyList<string> Aliases = new[] { "sonnet", "opus", "haiku" };
|
||||
|
||||
/// <summary>Model aliases ordered cheapest → most capable. Single source for prompt cost guidance.</summary>
|
||||
public static readonly IReadOnlyList<string> ByCostAscending = new[] { "haiku", "sonnet", "opus" };
|
||||
|
||||
public const string DefaultAlias = "sonnet";
|
||||
public const string PlanningAlias = "opus";
|
||||
|
||||
public const string ListDefaultSentinel = "(default)";
|
||||
public const string TaskInheritSentinel = "(inherit)";
|
||||
|
||||
/// <summary>
|
||||
/// Validate a model alias from external input. Null/blank → null (inherit).
|
||||
/// Returns the canonical lowercase alias; throws on an unknown value.
|
||||
/// </summary>
|
||||
public static string? NormalizeAlias(string? model)
|
||||
{
|
||||
var m = model?.Trim();
|
||||
if (string.IsNullOrEmpty(m)) return null;
|
||||
foreach (var alias in Aliases)
|
||||
if (string.Equals(alias, m, StringComparison.OrdinalIgnoreCase))
|
||||
return alias;
|
||||
throw new ArgumentException($"Unknown model '{model}'. Allowed: {string.Join(", ", Aliases)}.");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -82,7 +82,10 @@ public static class PromptFiles
|
||||
## Out-of-scope improvements
|
||||
If you notice worthwhile work that is genuinely outside this task's scope
|
||||
(a refactor, a follow-up, tech debt), do NOT do it here. File it with
|
||||
SuggestImprovement(title, description) and stay focused on the task at hand.
|
||||
SuggestImprovement(title, description, model) and stay focused on the task at hand.
|
||||
Set `model` to the cheapest model that can do the follow-up well — 'haiku' for
|
||||
trivial/mechanical work, 'sonnet' for normal coding, 'opus' only for genuinely
|
||||
complex work (cheapest to most capable: haiku < sonnet < opus).
|
||||
|
||||
## Working in the repo
|
||||
- Read a file before editing it. Match the conventions already in this codebase —
|
||||
@@ -122,8 +125,8 @@ public static class PromptFiles
|
||||
# Out-of-scope follow-up
|
||||
|
||||
You are an improvement follow-up that another task filed via SuggestImprovement.
|
||||
It was deliberately scoped narrow. Do EXACTLY what this task's title and
|
||||
description ask — nothing more.
|
||||
It was deliberately scoped narrow, and is intentionally a small, cheap unit of
|
||||
work. Do EXACTLY what this task's title and description ask — nothing more.
|
||||
|
||||
- Make the smallest change that satisfies the task. No opportunistic refactors,
|
||||
renames, reformatting, or "while I'm here" cleanup beyond what is asked.
|
||||
@@ -150,6 +153,14 @@ public static class PromptFiles
|
||||
Once the design is approved, create the child tasks with CreateChildTask, then
|
||||
call Finalize. Keep each subtask concrete and self-contained with a clear
|
||||
done-state, ordered so dependencies come first.
|
||||
|
||||
For each subtask, pass CreateChildTask's `model` argument set to the CHEAPEST
|
||||
model that can do that subtask well. Models, cheapest to most capable:
|
||||
haiku < sonnet < opus.
|
||||
- haiku — trivial/mechanical work: doc tweaks, simple renames, small localized edits.
|
||||
- sonnet — normal coding work; the sensible default when unsure.
|
||||
- opus — only for genuinely complex, cross-cutting, or hard-to-debug work.
|
||||
Do not default everything to opus — most subtasks are haiku or sonnet.
|
||||
""";
|
||||
|
||||
private const string PlanningInitialDefault = """
|
||||
|
||||
@@ -18,8 +18,18 @@ public sealed class AppSettingsRepository
|
||||
|
||||
row = new AppSettingsEntity { Id = AppSettingsEntity.SingletonId };
|
||||
_context.AppSettings.Add(row);
|
||||
await _context.SaveChangesAsync(ct);
|
||||
_context.Entry(row).State = EntityState.Detached;
|
||||
try
|
||||
{
|
||||
await _context.SaveChangesAsync(ct);
|
||||
_context.Entry(row).State = EntityState.Detached;
|
||||
}
|
||||
catch (DbUpdateException)
|
||||
{
|
||||
// Concurrent process already inserted the singleton — discard our attempt and re-read.
|
||||
_context.Entry(row).State = EntityState.Detached;
|
||||
row = await _context.AppSettings.AsNoTracking()
|
||||
.FirstAsync(s => s.Id == AppSettingsEntity.SingletonId, ct);
|
||||
}
|
||||
return row;
|
||||
}
|
||||
|
||||
|
||||
@@ -87,6 +87,22 @@ public sealed class TaskRepository
|
||||
.ToListAsync(ct);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns all tasks that qualify as "real" Idle backlog items for online mirroring:
|
||||
/// Status==Idle, no parent, PlanningPhase==None, not blocked.
|
||||
/// </summary>
|
||||
public async Task<List<TaskEntity>> GetAllIdleBacklogAsync(CancellationToken ct = default)
|
||||
{
|
||||
return await _context.Tasks
|
||||
.AsNoTracking()
|
||||
.Where(t => t.Status == TaskStatus.Idle
|
||||
&& t.ParentTaskId == null
|
||||
&& t.PlanningPhase == PlanningPhase.None
|
||||
&& t.BlockedByTaskId == null)
|
||||
.OrderBy(t => t.SortOrder).ThenBy(t => t.CreatedAt)
|
||||
.ToListAsync(ct);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Status transitions
|
||||
@@ -197,6 +213,7 @@ public sealed class TaskRepository
|
||||
string? description,
|
||||
string? commitType,
|
||||
string? createdBy = null,
|
||||
string? model = null,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
// AsNoTracking: SetPlanningStartedAsync mutates via ExecuteUpdate which
|
||||
@@ -223,6 +240,7 @@ public sealed class TaskRepository
|
||||
ParentTaskId = parentId,
|
||||
SortOrder = (maxSort ?? -1) + 1,
|
||||
CreatedBy = createdBy,
|
||||
Model = ModelRegistry.NormalizeAlias(model),
|
||||
};
|
||||
_context.Tasks.Add(child);
|
||||
await _context.SaveChangesAsync(ct);
|
||||
@@ -474,32 +492,5 @@ public sealed class TaskRepository
|
||||
return chainIds.Count;
|
||||
}
|
||||
|
||||
public async Task TryCompleteParentAsync(
|
||||
string parentId,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var parent = await _context.Tasks.AsNoTracking().FirstOrDefaultAsync(t => t.Id == parentId, ct);
|
||||
if (parent is null || parent.PlanningPhase != PlanningPhase.Finalized) return;
|
||||
|
||||
var children = await _context.Tasks
|
||||
.Where(t => t.ParentTaskId == parentId)
|
||||
.Select(t => t.Status)
|
||||
.ToListAsync(ct);
|
||||
|
||||
if (children.Count == 0) return;
|
||||
|
||||
bool allTerminal = children.All(s => s == TaskStatus.Done || s == TaskStatus.Failed);
|
||||
if (!allTerminal) return;
|
||||
|
||||
bool anyFailed = children.Any(s => s == TaskStatus.Failed);
|
||||
var finalStatus = anyFailed ? TaskStatus.Failed : TaskStatus.Done;
|
||||
var finishedAt = DateTime.UtcNow;
|
||||
await _context.Tasks
|
||||
.Where(t => t.Id == parentId)
|
||||
.ExecuteUpdateAsync(s => s
|
||||
.SetProperty(t => t.Status, finalStatus)
|
||||
.SetProperty(t => t.FinishedAt, finishedAt), ct);
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
using ClaudeDo.Data.Models;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ClaudeDo.Data.Seeding;
|
||||
@@ -9,17 +8,18 @@ public static class DefaultListsSeeder
|
||||
|
||||
public static async Task SeedAsync(ClaudeDoDbContext ctx, CancellationToken ct = default)
|
||||
{
|
||||
var existing = await ctx.Lists.Select(l => l.Name).ToListAsync(ct);
|
||||
var now = DateTime.UtcNow;
|
||||
foreach (var name in Defaults.Where(n => !existing.Contains(n)))
|
||||
foreach (var name in Defaults)
|
||||
{
|
||||
ctx.Lists.Add(new ListEntity
|
||||
{
|
||||
Id = Guid.NewGuid().ToString(),
|
||||
Name = name,
|
||||
CreatedAt = now,
|
||||
});
|
||||
var id = Guid.NewGuid().ToString();
|
||||
// Atomic conditional insert: the SELECT ... WHERE NOT EXISTS is a single
|
||||
// SQLite statement and cannot race — only one writer holds the lock.
|
||||
await ctx.Database.ExecuteSqlAsync(
|
||||
$"""
|
||||
INSERT INTO lists (id, name, created_at, default_commit_type, sort_order)
|
||||
SELECT {id}, {name}, {now}, 'chore', 0
|
||||
WHERE NOT EXISTS (SELECT 1 FROM lists WHERE name = {name})
|
||||
""", ct);
|
||||
}
|
||||
await ctx.SaveChangesAsync(ct);
|
||||
}
|
||||
}
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 374 B After Width: | Height: | Size: 57 KiB |
@@ -53,6 +53,7 @@
|
||||
"prime": {
|
||||
"description": "Bereite dein Claude-Nutzungsfenster vor, indem an den von dir gewählten Tagen zu einer bestimmten Zeit ein einzelner nicht-interaktiver Ping ausgelöst wird. Läuft nur, solange ClaudeDo geöffnet ist. Wenn die App innerhalb von 30 Minuten vor der Zielzeit startet, wird der Ping sofort ausgelöst.",
|
||||
"addSchedule": "+ Zeitplan hinzufügen",
|
||||
"removeScheduleTip": "Zeitplan entfernen",
|
||||
"dailyPrepMaxTasks": "Max. Aufgaben pro Tag",
|
||||
"dayMo": "Mo",
|
||||
"dayTu": "Di",
|
||||
@@ -62,6 +63,26 @@
|
||||
"daySa": "Sa",
|
||||
"daySu": "So"
|
||||
},
|
||||
"onlineInbox": {
|
||||
"tabHeader": "Online-Posteingang",
|
||||
"enabledLabel": "Online-Posteingang-Sync aktivieren",
|
||||
"restartHint": "Aktivieren oder Deaktivieren wird erst nach einem Worker-Neustart wirksam.",
|
||||
"apiBaseUrlLabel": "API-Basis-URL",
|
||||
"apiBaseUrlPlaceholder": "https://inbox.claudedo.kuns.dev",
|
||||
"authorityLabel": "Zitadel-Authority (Issuer-URL)",
|
||||
"authorityPlaceholder": "https://auth.example.com",
|
||||
"clientIdLabel": "Client-ID",
|
||||
"scopesLabel": "Scopes",
|
||||
"redirectUriLabel": "Redirect-URI",
|
||||
"pollIntervalLabel": "Abfrageintervall (Sekunden)",
|
||||
"statusSection": "AUTH-STATUS",
|
||||
"signedInStatus": "Angemeldet",
|
||||
"signedOutStatus": "Nicht angemeldet",
|
||||
"signInButton": "Im Browser anmelden",
|
||||
"signOutButton": "Abmelden",
|
||||
"configSection": "KONFIGURATION",
|
||||
"saveButton": "Konfiguration speichern"
|
||||
},
|
||||
"inherit": {
|
||||
"inheritedFromList": "geerbt · Liste",
|
||||
"inheritedFromGlobal": "geerbt · Global",
|
||||
@@ -89,10 +110,12 @@
|
||||
"ctxRunInteractively": "Interaktiv ausführen",
|
||||
"ctxOpenPlanningSession": "Planungssitzung öffnen",
|
||||
"ctxResumePlanningSession": "Planungssitzung fortsetzen",
|
||||
"ctxFinalizePlanningSession": "Plan finalisieren",
|
||||
"ctxDiscardPlanningSession": "Planungssitzung verwerfen",
|
||||
"ctxQueueSubtasks": "Teilaufgaben nacheinander einreihen",
|
||||
"ctxScheduleFor": "Planen für...",
|
||||
"ctxClearSchedule": "Zeitplan entfernen",
|
||||
"ctxRemoveFromMyDay": "Aus Mein Tag entfernen",
|
||||
"ctxAddToMyDay": "Zu Mein Tag hinzufügen",
|
||||
"badgeDraft": "ENTWURF",
|
||||
"badgePlanned": "GEPLANT",
|
||||
"approve": "Genehmigen",
|
||||
@@ -104,6 +127,8 @@
|
||||
"cancel": "Abbrechen",
|
||||
"cancelTip": "Diese Aufgabe abbrechen",
|
||||
"removeFromQueueTip": "Aus Warteschlange entfernen",
|
||||
"toggleSubtasksTip": "Unteraufgaben ein-/ausklappen",
|
||||
"agentSuggestedTip": "Vom Agenten vorgeschlagen",
|
||||
"scheduleTitle": "Aufgabe planen",
|
||||
"scheduleWhen": "WANN",
|
||||
"scheduleConfirm": "Planen",
|
||||
@@ -130,6 +155,7 @@
|
||||
},
|
||||
"details": {
|
||||
"deleteTaskTip": "Aufgabe löschen",
|
||||
"killSessionTip": "Laufende Sitzung beenden",
|
||||
"closeTip": "Schließen",
|
||||
"copyTaskIdTip": "Aufgaben-ID kopieren",
|
||||
"starTip": "Favorit",
|
||||
@@ -149,6 +175,7 @@
|
||||
"addStepPlaceholder": "Schritt hinzufügen...",
|
||||
"detailsLabel": "DETAILS",
|
||||
"copyDescriptionTip": "Beschreibung in die Zwischenablage kopieren",
|
||||
"copyFormattedTip": "Titel, Beschreibung und offene Schritte kopieren",
|
||||
"toggleEditPreviewTip": "Bearbeiten/Vorschau umschalten",
|
||||
"previewBtn": "Vorschau",
|
||||
"editBtn": "Bearbeiten",
|
||||
@@ -184,7 +211,9 @@
|
||||
"session": {
|
||||
"chipLive": "LIVE",
|
||||
"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": {
|
||||
"about": {
|
||||
@@ -231,7 +260,10 @@
|
||||
"diff": {
|
||||
"title": "DIFF",
|
||||
"windowTitle": "Diff",
|
||||
"merge": "Mergen…"
|
||||
"merge": "Mergen…",
|
||||
"filesHeader": "Dateien",
|
||||
"binary": "Binärdatei — kein Text-Diff",
|
||||
"empty": "Kein Inhalt"
|
||||
},
|
||||
"worktree": {
|
||||
"title": "Worktree"
|
||||
@@ -243,6 +275,12 @@
|
||||
"columnState": "STATUS",
|
||||
"columnDiff": "DIFF",
|
||||
"columnAge": "ALTER",
|
||||
"columnOutcome": "ERGEBNIS",
|
||||
"selectAll": "Alle auswählen",
|
||||
"targetLabel": "Ziel",
|
||||
"mergeAll": "Alle mergen",
|
||||
"needsResolution": "ZU LÖSEN",
|
||||
"resolve": "Lösen",
|
||||
"phantom": "Phantom",
|
||||
"phantomTooltip": "Verzeichnis fehlt auf der Festplatte",
|
||||
"ctxShowDiff": "Diff anzeigen",
|
||||
@@ -358,6 +396,24 @@
|
||||
"loading": "Wird geladen…"
|
||||
}
|
||||
},
|
||||
"conflictResolver": {
|
||||
"windowTitle": "Merge-Konflikte lösen",
|
||||
"modalTitle": "KONFLIKTE LÖSEN",
|
||||
"loading": "Konflikte werden geladen…",
|
||||
"ours": "MAIN · Ziel-Branch",
|
||||
"result": "ERGEBNIS",
|
||||
"theirs": "INCOMING · Task-Branch",
|
||||
"binaryHint": "Binärdateien können hier nicht zusammengeführt werden — brich ab und löse sie in deinem Editor:",
|
||||
"prevConflict": "Vorheriger Konflikt (Umschalt+F8)",
|
||||
"nextConflict": "Nächster Konflikt (F8)",
|
||||
"conflictMap": "Konflikte in dieser Datei — Marker anklicken zum Springen",
|
||||
"acceptOurs": "Main hinzufügen",
|
||||
"acceptTheirs": "Incoming hinzufügen",
|
||||
"removeOurs": "Main entfernen",
|
||||
"removeTheirs": "Incoming entfernen",
|
||||
"continue": "Lösen & fortfahren",
|
||||
"abort": "Merge abbrechen"
|
||||
},
|
||||
"controls": {
|
||||
"datePicker": {
|
||||
"today": "Heute",
|
||||
@@ -371,6 +427,8 @@
|
||||
"shell": {
|
||||
"menu": {
|
||||
"help": "Hilfe",
|
||||
"worker": "Worker",
|
||||
"repositories": "Repositories",
|
||||
"checkForUpdates": "Nach Updates suchen",
|
||||
"restartWorker": "Worker neu starten",
|
||||
"worktrees": "Worktrees…",
|
||||
@@ -388,19 +446,20 @@
|
||||
"connection": { "online": "Online", "connecting": "Verbinden…", "offline": "Offline" },
|
||||
"shell": { "restartingWorker": "Worker wird neu gestartet…" },
|
||||
"agentStatus": { "idle": "Leerlauf", "queued": "In Warteschlange", "running": "Läuft", "review": "Prüfung", "children": "Wartet auf Verbesserungen", "done": "Fertig", "failed": "Fehlgeschlagen", "cancelled": "Abgebrochen" },
|
||||
"taskStatus": { "idle": "Leerlauf", "queued": "In Warteschlange", "running": "Läuft", "waitingForReview": "Wartet auf Prüfung", "waitingForChildren": "Wartet auf Verbesserungen", "done": "Fertig", "failed": "Fehlgeschlagen", "cancelled": "Abgebrochen" },
|
||||
"taskStatus": { "idle": "Leerlauf", "queued": "In Warteschlange", "running": "Läuft", "waitingForReview": "Wartet auf Prüfung", "waitingForChildren": "Wartet auf Verbesserungen", "done": "Fertig", "failed": "Fehlgeschlagen", "cancelled": "Abgebrochen", "parked": "Geparkt" },
|
||||
"planningBadge": { "active": "PLANUNG", "finalized": "GEPLANT" },
|
||||
"taskRow": { "createdPrefix": "Erstellt {0}", "stepsText": "{0}/{1} Schritte" },
|
||||
"tasksIsland": { "completedHeader": "ABGESCHLOSSEN", "completedHeaderCount": "ABGESCHLOSSEN · {0}" },
|
||||
"diff": { "loadFailed": "Diff konnte nicht geladen werden: {0}", "noChanges": "Keine Änderungen anzuzeigen." },
|
||||
"diff": { "loadFailed": "Diff konnte nicht geladen werden: {0}", "noChanges": "Keine Änderungen anzuzeigen.", "unavailable": "Diff nicht mehr verfügbar — Commit-Bereich unvollständig." },
|
||||
"planningDiff": { "hubError": "Kombinierte Vorschau konnte nicht erstellt werden (Hub-Fehler).", "conflict": "Kombinierte Vorschau nicht möglich: Teilaufgabe {0} steht im Konflikt mit einer früheren Teilaufgabe ({1} Dateien)." },
|
||||
"merge": { "commitMessage": "Merge-Aufgabe: {0}", "workerOfflineBranches": "Worker offline — Branches können nicht aufgelistet werden.", "loadBranchesFailed": "Branches konnten nicht geladen werden: {0}", "merged": "Zusammengeführt.", "conflict": "Merge-Konflikt — Ziel-Branch wiederhergestellt. Manuell oder über Fortsetzen lösen, dann erneut versuchen.", "blocked": "Blockiert: {0}", "unknownStatus": "Unbekannter Status: {0}", "mergeFailed": "Merge fehlgeschlagen: {0}" },
|
||||
"conflictResolution": { "vsCodeError": "VS Code konnte nicht gestartet werden: {0}. Die Pfade sind oben aufgeführt — kopiere sie manuell.", "subtaskPrefix": "Konflikte in Teilaufgabe: {0}", "targetPrefix": "Zusammenführen in: {0}" },
|
||||
"settingsModal": { "workerOffline": "Worker offline — Einstellungen schreibgeschützt.", "saveFailed": "Speichern fehlgeschlagen: {0}" },
|
||||
"onlineInbox": { "workerOffline": "Worker offline — Konfiguration kann nicht geladen werden.", "saved": "Konfiguration gespeichert.", "saveFailed": "Speichern fehlgeschlagen: {0}", "signedIn": "Erfolgreich angemeldet.", "signedInNoRole": "Angemeldet, aber diesem Konto fehlt die Rolle 'user' in Zitadel — die Online-Synchronisierung wird abgelehnt, bis die Rolle im ClaudeDo-Projekt zugewiesen wird.", "signInFailed": "Anmeldung fehlgeschlagen: {0}", "signedOut": "Abgemeldet.", "signOutFailed": "Abmeldung fehlgeschlagen: {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}" },
|
||||
"worktreesTab": { "workerOffline": "Worker offline.", "removed": "{0} Worktree(s) entfernt.", "blocked": "Zwangsentfernung nicht möglich: {0} Aufgabe(n) laufen noch. Brich sie zuerst ab.", "removedFrom": "{0} Worktree(s) von {1} Aufgabe(n) entfernt." },
|
||||
"worktreesOverview": { "titleAll": "Worktrees", "titleList": "Worktrees — {0}", "listFallback": "Liste", "cleanupFailed": "Aufräumen fehlgeschlagen.", "removed": "{0} Worktree(s) entfernt.", "discardFailed": "Worktree konnte nicht verworfen werden.", "keepFailed": "Worktree konnte nicht behalten werden.", "cannotForceRunning": "Eine laufende Aufgabe kann nicht zwangsweise entfernt werden.", "forceRemoveFailed": "Zwangsentfernung fehlgeschlagen." },
|
||||
"worktreesOverview": { "titleAll": "Worktrees", "titleList": "Worktrees — {0}", "listFallback": "Liste", "cleanupFailed": "Aufräumen fehlgeschlagen.", "removed": "{0} Worktree(s) entfernt.", "discardFailed": "Worktree konnte nicht verworfen werden.", "keepFailed": "Worktree konnte nicht behalten werden.", "cannotForceRunning": "Eine laufende Aufgabe kann nicht zwangsweise entfernt werden.", "forceRemoveFailed": "Zwangsentfernung fehlgeschlagen.", "batchProgress": "Merge {0}/{1}…", "batchDone": "{0} gemergt, {1} zu lösen." },
|
||||
"listSettings": { "untitled": "Unbenannt" },
|
||||
"lists": { "localSuffix": "{0} / lokal", "smartMyDay": "Mein Tag", "smartImportant": "Wichtig", "smartPlanned": "Geplant", "virtualQueue": "Warteschlange", "virtualRunning": "Läuft", "virtualReview": "Prüfung", "newList": "Neue Liste" }
|
||||
}
|
||||
|
||||
@@ -53,6 +53,7 @@
|
||||
"prime": {
|
||||
"description": "Prime your Claude usage window by firing a single non-interactive ping on the days you choose, at a chosen time. Only runs while ClaudeDo is open. If the app starts within 30 minutes of the target time, the ping fires immediately.",
|
||||
"addSchedule": "+ Add schedule",
|
||||
"removeScheduleTip": "Remove schedule",
|
||||
"dailyPrepMaxTasks": "Max tasks per day",
|
||||
"dayMo": "Mo",
|
||||
"dayTu": "Tu",
|
||||
@@ -62,6 +63,26 @@
|
||||
"daySa": "Sa",
|
||||
"daySu": "Su"
|
||||
},
|
||||
"onlineInbox": {
|
||||
"tabHeader": "Online Inbox",
|
||||
"enabledLabel": "Enable online inbox sync",
|
||||
"restartHint": "Enabling or disabling takes effect after a Worker restart.",
|
||||
"apiBaseUrlLabel": "API base URL",
|
||||
"apiBaseUrlPlaceholder": "https://inbox.claudedo.kuns.dev",
|
||||
"authorityLabel": "Zitadel authority (issuer URL)",
|
||||
"authorityPlaceholder": "https://auth.example.com",
|
||||
"clientIdLabel": "Client ID",
|
||||
"scopesLabel": "Scopes",
|
||||
"redirectUriLabel": "Redirect URI",
|
||||
"pollIntervalLabel": "Poll interval (seconds)",
|
||||
"statusSection": "AUTH STATUS",
|
||||
"signedInStatus": "Signed in",
|
||||
"signedOutStatus": "Not signed in",
|
||||
"signInButton": "Sign in via browser",
|
||||
"signOutButton": "Sign out",
|
||||
"configSection": "CONFIGURATION",
|
||||
"saveButton": "Save config"
|
||||
},
|
||||
"inherit": {
|
||||
"inheritedFromList": "inherited · List",
|
||||
"inheritedFromGlobal": "inherited · Global",
|
||||
@@ -89,10 +110,12 @@
|
||||
"ctxRunInteractively": "Run interactively",
|
||||
"ctxOpenPlanningSession": "Open planning Session",
|
||||
"ctxResumePlanningSession": "Resume planning Session",
|
||||
"ctxFinalizePlanningSession": "Finalize plan",
|
||||
"ctxDiscardPlanningSession": "Discard planning session",
|
||||
"ctxQueueSubtasks": "Queue subtasks sequentially",
|
||||
"ctxScheduleFor": "Schedule for...",
|
||||
"ctxClearSchedule": "Clear schedule",
|
||||
"ctxRemoveFromMyDay": "Remove from My Day",
|
||||
"ctxAddToMyDay": "Add to My Day",
|
||||
"badgeDraft": "DRAFT",
|
||||
"badgePlanned": "PLANNED",
|
||||
"approve": "Approve",
|
||||
@@ -104,6 +127,8 @@
|
||||
"cancel": "Cancel",
|
||||
"cancelTip": "Cancel this task",
|
||||
"removeFromQueueTip": "Remove from queue",
|
||||
"toggleSubtasksTip": "Expand / collapse subtasks",
|
||||
"agentSuggestedTip": "Suggested by the agent",
|
||||
"scheduleTitle": "Schedule task",
|
||||
"scheduleWhen": "WHEN",
|
||||
"scheduleConfirm": "Schedule",
|
||||
@@ -130,6 +155,7 @@
|
||||
},
|
||||
"details": {
|
||||
"deleteTaskTip": "Delete task",
|
||||
"killSessionTip": "Kill the running session",
|
||||
"closeTip": "Close",
|
||||
"copyTaskIdTip": "Copy task ID",
|
||||
"starTip": "Star",
|
||||
@@ -149,6 +175,7 @@
|
||||
"addStepPlaceholder": "Add a step...",
|
||||
"detailsLabel": "DETAILS",
|
||||
"copyDescriptionTip": "Copy description to clipboard",
|
||||
"copyFormattedTip": "Copy title, description and open steps",
|
||||
"toggleEditPreviewTip": "Toggle edit/preview",
|
||||
"previewBtn": "Preview",
|
||||
"editBtn": "Edit",
|
||||
@@ -184,7 +211,9 @@
|
||||
"session": {
|
||||
"chipLive": "LIVE",
|
||||
"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": {
|
||||
"about": {
|
||||
@@ -231,7 +260,10 @@
|
||||
"diff": {
|
||||
"title": "DIFF",
|
||||
"windowTitle": "Diff",
|
||||
"merge": "Merge…"
|
||||
"merge": "Merge…",
|
||||
"filesHeader": "Files",
|
||||
"binary": "Binary file — no text diff",
|
||||
"empty": "No content"
|
||||
},
|
||||
"worktree": {
|
||||
"title": "Worktree"
|
||||
@@ -243,6 +275,12 @@
|
||||
"columnState": "STATE",
|
||||
"columnDiff": "DIFF",
|
||||
"columnAge": "AGE",
|
||||
"columnOutcome": "RESULT",
|
||||
"selectAll": "Select all",
|
||||
"targetLabel": "Target",
|
||||
"mergeAll": "Merge all",
|
||||
"needsResolution": "NEEDS RESOLUTION",
|
||||
"resolve": "Resolve",
|
||||
"phantom": "phantom",
|
||||
"phantomTooltip": "Directory missing on disk",
|
||||
"ctxShowDiff": "Show diff",
|
||||
@@ -358,6 +396,24 @@
|
||||
"loading": "Loading…"
|
||||
}
|
||||
},
|
||||
"conflictResolver": {
|
||||
"windowTitle": "Resolve merge conflicts",
|
||||
"modalTitle": "RESOLVE CONFLICTS",
|
||||
"loading": "Loading conflicts…",
|
||||
"ours": "MAIN · merge target",
|
||||
"result": "RESULT",
|
||||
"theirs": "INCOMING · task branch",
|
||||
"binaryHint": "Binary files can't be merged here — abort and resolve them in your editor:",
|
||||
"prevConflict": "Previous conflict (Shift+F8)",
|
||||
"nextConflict": "Next conflict (F8)",
|
||||
"conflictMap": "Conflicts in this file — click a marker to jump",
|
||||
"acceptOurs": "Add main",
|
||||
"acceptTheirs": "Add incoming",
|
||||
"removeOurs": "Remove main",
|
||||
"removeTheirs": "Remove incoming",
|
||||
"continue": "Resolve & continue",
|
||||
"abort": "Abort merge"
|
||||
},
|
||||
"controls": {
|
||||
"datePicker": {
|
||||
"today": "Today",
|
||||
@@ -371,6 +427,8 @@
|
||||
"shell": {
|
||||
"menu": {
|
||||
"help": "Help",
|
||||
"worker": "Worker",
|
||||
"repositories": "Repositories",
|
||||
"checkForUpdates": "Check for updates",
|
||||
"restartWorker": "Restart worker",
|
||||
"worktrees": "Worktrees…",
|
||||
@@ -388,19 +446,20 @@
|
||||
"connection": { "online": "Online", "connecting": "Connecting…", "offline": "Offline" },
|
||||
"shell": { "restartingWorker": "Restarting worker…" },
|
||||
"agentStatus": { "idle": "Idle", "queued": "Queued", "running": "Running", "review": "Review", "children": "Waiting for Improvements", "done": "Done", "failed": "Failed", "cancelled": "Cancelled" },
|
||||
"taskStatus": { "idle": "Idle", "queued": "Queued", "running": "Running", "waitingForReview": "Waiting for Review", "waitingForChildren": "Waiting for Improvements", "done": "Done", "failed": "Failed", "cancelled": "Cancelled" },
|
||||
"taskStatus": { "idle": "Idle", "queued": "Queued", "running": "Running", "waitingForReview": "Waiting for Review", "waitingForChildren": "Waiting for Improvements", "done": "Done", "failed": "Failed", "cancelled": "Cancelled", "parked": "Parked" },
|
||||
"planningBadge": { "active": "PLANNING", "finalized": "PLANNED" },
|
||||
"taskRow": { "createdPrefix": "Created {0}", "stepsText": "{0}/{1} steps" },
|
||||
"tasksIsland": { "completedHeader": "COMPLETED", "completedHeaderCount": "COMPLETED · {0}" },
|
||||
"diff": { "loadFailed": "Failed to load diff: {0}", "noChanges": "No changes to show." },
|
||||
"diff": { "loadFailed": "Failed to load diff: {0}", "noChanges": "No changes to show.", "unavailable": "Diff no longer available — commit range incomplete." },
|
||||
"planningDiff": { "hubError": "Could not build combined preview (hub error).", "conflict": "Cannot build combined preview: subtask {0} conflicts with an earlier subtask ({1} files)." },
|
||||
"merge": { "commitMessage": "Merge task: {0}", "workerOfflineBranches": "Worker offline — cannot list branches.", "loadBranchesFailed": "Failed to load branches: {0}", "merged": "Merged.", "conflict": "Merge conflict — target branch restored. Resolve manually or via Continue, then retry.", "blocked": "Blocked: {0}", "unknownStatus": "Unknown status: {0}", "mergeFailed": "Merge failed: {0}" },
|
||||
"conflictResolution": { "vsCodeError": "Could not launch VS Code: {0}. Paths are listed above — copy them manually.", "subtaskPrefix": "Conflicts in subtask: {0}", "targetPrefix": "Merging into: {0}" },
|
||||
"settingsModal": { "workerOffline": "Worker offline — settings read-only.", "saveFailed": "Save failed: {0}" },
|
||||
"onlineInbox": { "workerOffline": "Worker offline — cannot load config.", "saved": "Config saved.", "saveFailed": "Save failed: {0}", "signedIn": "Signed in successfully.", "signedInNoRole": "Signed in, but this account is missing the 'user' role in Zitadel — online sync will be rejected until the role is granted in the ClaudeDo project.", "signInFailed": "Sign-in failed: {0}", "signedOut": "Signed out.", "signOutFailed": "Sign-out failed: {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}" },
|
||||
"worktreesTab": { "workerOffline": "Worker offline.", "removed": "Removed {0} worktree(s).", "blocked": "Cannot force-remove: {0} task(s) still running. Cancel them first.", "removedFrom": "Removed {0} worktree(s) from {1} task(s)." },
|
||||
"worktreesOverview": { "titleAll": "Worktrees", "titleList": "Worktrees — {0}", "listFallback": "list", "cleanupFailed": "Cleanup failed.", "removed": "Removed {0} worktree(s).", "discardFailed": "Failed to discard worktree.", "keepFailed": "Failed to keep worktree.", "cannotForceRunning": "Cannot force-remove a running task.", "forceRemoveFailed": "Force remove failed." },
|
||||
"worktreesOverview": { "titleAll": "Worktrees", "titleList": "Worktrees — {0}", "listFallback": "list", "cleanupFailed": "Cleanup failed.", "removed": "Removed {0} worktree(s).", "discardFailed": "Failed to discard worktree.", "keepFailed": "Failed to keep worktree.", "cannotForceRunning": "Cannot force-remove a running task.", "forceRemoveFailed": "Force remove failed.", "batchProgress": "Merging {0}/{1}…", "batchDone": "Merged {0}, {1} need resolution." },
|
||||
"listSettings": { "untitled": "Untitled" },
|
||||
"lists": { "localSuffix": "{0} / local", "smartMyDay": "My Day", "smartImportant": "Important", "smartPlanned": "Planned", "virtualQueue": "Queue", "virtualRunning": "Running", "virtualReview": "Review", "newList": "New list" }
|
||||
}
|
||||
|
||||
@@ -8,56 +8,61 @@ MVVM with CommunityToolkit.Mvvm source generators:
|
||||
- `[ObservableProperty]` for bindable properties
|
||||
- `[RelayCommand]` for commands (supports async and CanExecute)
|
||||
- All ViewModels inherit `ViewModelBase` (extends `ObservableObject`)
|
||||
- All views use compiled bindings (`x:DataType`)
|
||||
|
||||
## Views
|
||||
## Layout: Islands
|
||||
|
||||
- **MainWindow** — 3-column DockPanel layout (lists | tasks | detail) with GridSplitter, status bar at bottom
|
||||
- **TaskListView** — ListBox of tasks with add/edit/delete toolbar
|
||||
- **TaskDetailView** — Task info, live log output, worktree section (merge/keep/discard)
|
||||
- **TaskEditorView** — Modal dialog for task create/edit
|
||||
- **ListEditorView** — Modal dialog for list create/edit
|
||||
- **StatusBarView** — Connection status indicator, active task display
|
||||
- **ListSettingsModalView** — edits list name, working dir, default commit type, and per-list Model/SystemPrompt/AgentPath/MaxTurns, each showing the inherited (global) value with a source-aware "inherited · Global / override" badge and a reset-to-inherited button; also deletes the list (and its tasks) via a confirmed "Delete list" button. Opened via context menu or gear button on a list row.
|
||||
- **RepoImportModalView** — bulk-creates lists from git repos discovered under chosen parent folders. Opened via the folder button beside "New list" in the Lists island, or the "Add repos as lists…" Help-menu item. Repos already wired to a list show as disabled/"(already added)".
|
||||
- **DetailsIslandView** — contains an "Agent settings (overrides)" expander with per-task Model/MaxTurns/AgentPath (override semantics, each showing the resolved inherited value as a placeholder plus a source-aware "inherited · List / inherited · Global / override" badge and reset button via the reusable `InheritedBadge` control + `InheritanceResolver`) and a SystemPrompt text box (additive — shows the inherited prompt as a "prepended automatically" note). Disabled while task is running. When notes mode is active (`IsNotesMode`), it hosts **NotesEditorView** instead of the task detail. When prep mode is active (`IsPrepMode`), it hosts the daily-prep panel (Plan day button, empty-state hint, embedded **SessionTerminalView**). The task header, metadata footer (delete/close), and **AgentStripView** are gated on `IsTaskDetailVisible` — they are hidden in both notes and prep mode.
|
||||
- **WeeklyReportModalView** — opened from Help menu ("Wochenbericht…"); date-range pickers default to "since last standup weekday → today"; Generate/Regenerate button; renders markdown via MarkdownView; reports are cached per range.
|
||||
- **NotesEditorView** — day navigator (prev/next/date-picker/Today), bullet add/edit/delete for daily notes.
|
||||
- **SessionTerminalView** — reusable log terminal; exposes StyledProperties `Entries`, `Label`, `IsRunning`, `IsDone`, `IsFailed`. Used for both the task `Log` and the prep `PrepLog`.
|
||||
- **SettingsModalView** — Prime Claude tab contains a `DailyPrepMaxTasks` numeric editor.
|
||||
- **TasksIslandView** (MyDay header) — icon buttons visible only when `IsMyDayList`: broom icon = `ClearDayCommand`, stroked-sun icon ("Plan My Day") = `ShowPrepLogCommand`.
|
||||
`MainWindow` hosts three "islands" (lists | tasks | details). There is no MainWindowViewModel, StatusBarView, or task/list editor modal — the root coordinator is **IslandsShellViewModel**, and task/list editing happens inline in the islands.
|
||||
|
||||
All views use compiled bindings (`x:DataType`).
|
||||
```
|
||||
ViewModels/
|
||||
IslandsShellViewModel.cs — root coordinator
|
||||
Islands/ — ListsIsland, TasksIsland, DetailsIsland, TaskRow, ListNavItem,
|
||||
NotesEditor, MergePreviewPresenter
|
||||
Modals/ — About, Diff, ListSettings, Merge, RepoImport, Settings (+ Settings/ tab VMs),
|
||||
UnfinishedPlanning, WeeklyReport, WorkerConnection, Worktree,
|
||||
WorktreesOverview, UnifiedDiffParser
|
||||
Planning/ — PlanningDiffViewModel
|
||||
Conflicts/ — ConflictResolverViewModel + ConflictModels (MergeFile/MergeFileSegment/MergeConflictBlock)
|
||||
Views/ — mirrors the VM layout; Islands/Detail/ holds TaskHeaderBar,
|
||||
DescriptionStepsCard, WorkConsole; plus AgentStripView, SessionTerminalView
|
||||
Views/Controls/ — MarkdownView, ModalShell, ThemedDatePicker, DiffLinesView, InheritedBadge
|
||||
Design/ — Tokens.axaml (design tokens; merged before styles) + IslandStyles.axaml
|
||||
(component styles + the filled icon geometry library)
|
||||
```
|
||||
|
||||
## ViewModels
|
||||
|
||||
- **MainWindowViewModel** — root coordinator; manages list collection, selected list, dialog creation via `Func<T>` factories
|
||||
- **TaskListViewModel** — manages task collection for selected list; handles CRUD, "Run Now"
|
||||
- **TaskDetailViewModel** — displays task details, streams live log, controls worktree operations
|
||||
- **TaskItemViewModel** / **ListItemViewModel** — lightweight display VMs
|
||||
- **TaskEditorViewModel** / **ListEditorViewModel** — dialog VMs with validation
|
||||
- **StatusBarViewModel** — connection state and active tasks
|
||||
- **WeeklyReportModalViewModel** — drives the weekly report modal
|
||||
- **NotesEditorViewModel** — manages daily note bullet CRUD for the selected day
|
||||
- **DetailsIslandViewModel** gains `IsNotesMode`, `ShowNotes()`, and hosts `NotesEditorViewModel`. Also gains daily-prep mode: `IsPrepMode`, `PrepLog` (`ObservableCollection<LogLineViewModel>`), `ShowPrep()`, `PlanDayCommand` (calls `RunDailyPrepNowAsync`), `ShowPrepEmptyState`, and computed `IsTaskDetailVisible` (= `!IsNotesMode && !IsPrepMode`). Subscribes to `PrepStartedEvent`, `PrepLineEvent`, `PrepFinishedEvent`; streams lines into `PrepLog` via `StreamLineFormatter` (same path as task logs). On open, if log is empty and no run is in progress, loads persisted last run via `GetLastPrepLogAsync`. The WorkConsole Session tab gains a mergeability indicator (`MergePreviewPresenter`) and a single-task Merge button; indicator is populated via `PreviewMergeAsync` and displayed for tasks in WaitingForReview.
|
||||
- **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`).
|
||||
- **IslandsShellViewModel** — root coordinator; owns the three island VMs and the `WorkerClient`, wires cross-island events (selection, notes/prep mode, conflict resolution), owns connection state, the update banner, the inline worker-log strip, responsive-layout flags (`ShowLists`/`ShowDetails` by window width), `PrimeStatus` flash, and the modal openers (About, RepoImport, WeeklyReport, WorktreesOverview, WorkerConnection help) plus `RestartWorkerAsync`/`CheckForUpdatesAsync`. Hosts `UpdateCheckService`.
|
||||
- **ListsIslandViewModel** — smart lists (My Day, Important, Planned, virtual queued/running/review), user lists, selection, list CRUD, drag-reorder, badge counts, opens list settings / repo import / worktrees overview, `OpenInExplorer`/`OpenInTerminal`.
|
||||
- **TasksIslandViewModel** — open/overdue/completed groups for the selected list with hierarchy-aware regrouping; task CRUD, drag-reorder, toggle done/star, schedule, enqueue/dequeue, cancel; review actions (approve, reject-rerun, reject-park, cancel); planning session lifecycle (open/resume/discard/finalize, `QueuePlanningSubtasksAsync`); `RunInteractivelyAsync`, `RefineTask`; MyDay extras (`IsMyDayList`, `ClearDayCommand`, `ShowPrepLogCommand`) and the pinned Notes pseudo-row (`ShowNotesRow`, `OpenNotesCommand`). Raises `NotesRequested`/`PrepRequested` events consumed by the shell.
|
||||
- **DetailsIslandViewModel** — the detail pane for a bound `TaskRowViewModel`. Owns live-log streaming (`Log` via `StreamLineFormatter`), debounced title/description editing, subtasks, session-outcome/roadblock split (splits `Result` at the roadblock marker into two cards), the three-tab work console (`output`/`git`/`session`), child surfacing (`ChildOutcomes` rows plus `ChildrenNeedingAttention`/`HasChildrenNeedingAttention` — children that failed, were cancelled, await review, or reported roadblocks — drive an attention band on the Session tab, which is only visible when `HasChildOutcomes`), and the modes: `IsNotesMode` (hosts `NotesEditorViewModel`), `IsPrepMode`, computed `IsTaskDetailVisible = !IsNotesMode && !IsPrepMode`. Three concerns are extracted into section VMs exposed as properties: **AgentSettingsSectionViewModel** (per-task Model/MaxTurns/AgentPath overrides with `InheritedBadge` + `InheritanceResolver`, additive SystemPrompt, debounced save), **MergeSectionViewModel** (merge-target selection, mergeability indicator via `MergePreviewPresenter` over `PreviewMergeAsync`, `OpenDiffAsync` — live worktree or commit range after merge —, `ReviewCombinedDiffCommand` → `PlanningDiffViewModel`), **PrepPanelViewModel** (daily-prep panel: `PrepLog`, `PlanDayCommand` → `RunDailyPrepNowAsync`, persisted last run via `GetLastPrepLogAsync`). Helper rows (`ChildOutcomeRowViewModel`, `SubtaskRowViewModel`, `LogLineViewModel`) live in the same file.
|
||||
- **TaskRowViewModel** / **ListNavItemViewModel** — lightweight display VMs (task row: status, planning phase, parent/blocked links, roadblock count, computed `IsDraft`/`IsPlanned`/`IsChild`/`IsPlanningParent`/`CanRefine`; list row: kind Smart/Virtual/User, count, icon/dot keys, drop hints).
|
||||
- **NotesEditorViewModel** — day navigator + bullet CRUD for daily notes via `INotesApi`.
|
||||
- **Modal VMs** — `SettingsModalViewModel` (four tabs: General, Worktrees, Files prompt-paths, Prime Claude incl. `DailyPrepMaxTasks` + prime-schedule rows), `ListSettingsModalViewModel` (name, working dir, commit type, per-list Model/SystemPrompt/AgentPath/MaxTurns with inherited-badge + reset, delete list), `RepoImportModalViewModel` (bulk-create lists from git repos found under chosen parents; already-wired repos disabled), `WeeklyReportModalViewModel` (range pickers default "since last standup weekday → today", cached per range, markdown via MarkdownView), `MergeModalViewModel` (single-task merge form, called from the diff modal), `WorktreesOverviewModalViewModel` (global/per-list worktree rows, batch merge + state ops), `UnfinishedPlanningModalViewModel` (Resume/FinalizeNow/Discard for a draft planning session), `WorkerConnectionModalViewModel` (offline help), `AboutModalViewModel`.
|
||||
- **Diff stack** — `UnifiedDiffParser` (static; parses `git diff` output into `DiffFileViewModel`s, detecting added/deleted/renamed/binary files and per-line numbers; `Flatten` injects file-header rows for a combined single-pane view). `DiffModalViewModel` has two modes: live worktree (branch diff vs base, with a Merge action) and commit-range `base..head` (`FromCommitRange = true` — shows a merged task's diff after its worktree is gone; no merge action). The view renders a file list with binary/empty placeholders via `DiffLinesView`.
|
||||
- **Planning/Conflicts** — `PlanningDiffViewModel` (per-subtask diffs via `GetPlanningAggregateAsync`, toggle to combined integration-branch diff, conflict warnings), `ConflictResolverViewModel` (in-app **Rider-style 3-pane merge editor** for both single-task and planning unit-merge conflicts: single-task starts the conflict merge, parses each conflicted file into stable/conflict `MergeFileSegment`s via the worker's `GetMergeConflictDocuments`; exposes the active file's three reconstructed documents — `ActiveOursText` / `ActiveResultText` / `ActiveTheirsText` (from `MergeFile.OursText/ResultText/TheirsText`; Result seeds unresolved conflicts with Ours) — plus `ActiveFile`/`SelectFileCommand` (multi-file switcher), `Current`/`Next`/`Previous` (focused-conflict nav), a per-active-file `PositionText` readout, per-block `AcceptOurs/Theirs/Both/Base` + `MergeFile.Compose`, and `CanContinue` gated on every file resolved + no binary; writes each file via `WriteConflictResolution`, continue/abort; **planning mode** via `OpenForPlanningAsync(parentId, subtaskId)` loads the current subtask's mid-merge conflicts without re-starting the merge and routes continue/abort to `ContinuePlanningMerge`/`AbortPlanningMerge`, so a unit-merge conflict re-opens the editor per subtask via the `PlanningMergeConflict` broadcast). The view (`Views/Conflicts/ConflictResolverView`) shows the whole file in three **AvaloniaEdit** panes — MAIN/ours (read-only) | editable Result | INCOMING/theirs (read-only) — with TextMate highlighting by extension (theme `StyleInclude` in `App.axaml`); a code-behind `IBackgroundRenderer` tints each conflict block (unresolved/resolved) across panes, an `IReadOnlySectionProvider` + `TextAnchor` regions keep only conflict spans editable in Result (edits flow back to the block); each unresolved conflict starts EMPTY (a thin marker bar); the between-pane gutter controls **toggle** each side in/out of the result — `›`/`‹` add MAIN/INCOMING in click order (first pick on top), clicking again removes that side — so a conflict can take main, incoming, both, or neither; a `FilesSummary` readout shows how many files still have conflicts, and the three panes share a proportional synced vertical scroll. A conflict overview ruler right of the Result pane (`ConflictMap`) maps every conflict in the file proportionally (click a tick to jump) — handy for long files. Conflict block tints live in `Tokens.axaml` (`Merge*TintBrush`). The editor is reached from review **Approve** on conflict and from the **Merge** button in the Diff window (a conflicting `MergeTask` hands off to the resolver via `RequestConflictResolution`).
|
||||
|
||||
## 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`
|
||||
- **INotesApi** / **WorkerNotesApi** — thin wrapper over the daily-note WorkerClient methods (mirrors `IPrimeScheduleApi`). UI DTO: `DailyNoteDto(Id, Date, Text, SortOrder)`.
|
||||
- **WorkerClient** / **IWorkerClient** — SignalR client connecting to `http://127.0.0.1:47821/hub`, auto-reconnect with exponential backoff. The surface tracks `WorkerHub` (see `src/ClaudeDo.Worker/CLAUDE.md` for the canonical method/event list); groups: task execution (RunNow/Cancel/Continue/Reset/SetTaskStatus), review (`ApproveReviewAsync(taskId, targetBranch) -> MergeResultDto`, reject-to-queue/idle, cancel review, `PreviewMergeAsync -> MergePreviewDto`), planning sessions (start/resume/discard/finalize, queue subtasks, pending draft count, interactive terminal, refine), planning aggregate/integration-branch diffs, unit-merge continue/abort, single-task conflict resolving (start/get-conflicts/write-resolution/continue/abort), worktrees (overview, set state, force remove, cleanup, reset all), agents, app settings, lists/config, weekly report, daily notes, daily prep (`RunDailyPrepNowAsync`, `ClearMyDayAsync`, `GetLastPrepLogAsync`), prime schedules. Events mirror `HubBroadcaster` (task/worktree/list/run updates, prep events, planning-merge events, refine events, worker log). Lifecycle (`StartAsync`/`StopAsync`) and a few admin methods live only on the concrete `WorkerClient`.
|
||||
- **INotesApi** / **WorkerNotesApi** — daily-note CRUD (`ListAsync(day)`, `AddAsync`, `UpdateAsync`, `DeleteAsync`); UI DTO `DailyNoteDto(Id, Date, Text, SortOrder)`.
|
||||
- **IPrimeScheduleApi** — prime-schedule CRUD (`ListAsync`, `UpsertAsync`, `DeleteAsync`).
|
||||
- **UpdateCheckService** — polls releases, exposes `LastCheckStatus`/`LatestVersion`/`CheckNowAsync` (feeds the shell's update banner).
|
||||
- **InheritanceResolver** — resolves the task → list → global override chain to `(value, source)` for the inherited badges.
|
||||
- **RepoScanner**, **InstallArtifactLocator**/**InstallerLocator**/**WorkerLocator**, **ForegroundHelper** (Win32 foreground before launching a terminal), **FocusClearing**.
|
||||
|
||||
## Converters
|
||||
|
||||
- **StatusColorConverter** — task status string -> color (Queued=Blue, Running=Orange, Done=Green, Failed=Red, Manual=Gray)
|
||||
- **ConnectionColorConverter** — connection state -> color (Online=Green, Offline=Red)
|
||||
`StatusColorConverter` (+ `ConnectionColorConverter` in the same file), `WorktreeStateColorConverter`, `WorkerLogLevelToBrushConverter`, `DotBrushConverter`, `EqStatusConverter`, `IconKeyConverter`, `CheckboxBorderConverter`, `StrikeIfTrueConverter`, `BoolToItalicConverter`, `BoolToDraftOpacityConverter`, `NotNullToBoolConverter`, `UpperCaseConverter`, `DateOnlyToDateTimeConverter`.
|
||||
|
||||
## Dialog Pattern
|
||||
|
||||
Editor dialogs use `TaskCompletionSource<bool>` — the dialog sets the result on save/cancel, and the caller awaits the TCS.
|
||||
Modals use `TaskCompletionSource` results behind the reusable `ModalShell` control — the dialog sets the result on save/cancel, and the caller awaits the TCS.
|
||||
|
||||
## Notes
|
||||
|
||||
- Context menus are on both list items and task items
|
||||
- Right-click selects the item before showing the context menu
|
||||
- Context menus exist on both list rows and task rows; right-click selects before opening the menu
|
||||
- "Run Now" CanExecute re-evaluates when worker connection state changes
|
||||
- Icon gotcha: `PathIcon` fills geometry. Line-art/stroke icons must be defined as filled geometry or rendered as a stroked `Path` (e.g. `Icon.PlanDay` via the `Path.plan-icon` style); a pure stroke path used with `PathIcon` is invisible.
|
||||
- `SessionTerminalView` is the reusable log terminal (StyledProperties `Entries`, `Label`, `IsRunning`, `IsDone`, `IsFailed`) used for both the task `Log` and the prep `PrepLog`.
|
||||
|
||||
@@ -7,11 +7,15 @@
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Avalonia" Version="12.0.0" />
|
||||
<PackageReference Include="Avalonia" Version="12.0.4" />
|
||||
<PackageReference Include="Avalonia.AvaloniaEdit" Version="12.0.0" />
|
||||
<PackageReference Include="AvaloniaEdit.TextMate" Version="12.0.0" />
|
||||
<PackageReference Include="TextMateSharp.Grammars" Version="2.0.3" />
|
||||
<PackageReference Include="CommunityToolkit.Mvvm" Version="8.4.1" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.SignalR.Client" Version="8.0.11" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="8.0.1" />
|
||||
<PackageReference Include="Microsoft.Win32.Registry" Version="5.0.0" />
|
||||
<PackageReference Include="Duende.IdentityModel.OidcClient" Version="7.1.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
using System.Globalization;
|
||||
using Avalonia.Data.Converters;
|
||||
using Avalonia.Media;
|
||||
using ClaudeDo.Ui.ViewModels.Modals;
|
||||
|
||||
namespace ClaudeDo.Ui.Converters;
|
||||
|
||||
public sealed class DiffLineKindToBrushConverter : IValueConverter
|
||||
{
|
||||
private static readonly ISolidColorBrush Added = new SolidColorBrush(Color.Parse("#66BB6A"));
|
||||
private static readonly ISolidColorBrush Removed = new SolidColorBrush(Color.Parse("#EF5350"));
|
||||
private static readonly ISolidColorBrush Hunk = new SolidColorBrush(Color.Parse("#42A5F5"));
|
||||
private static readonly ISolidColorBrush Header = new SolidColorBrush(Color.Parse("#9E9E9E"));
|
||||
private static readonly ISolidColorBrush Default = new SolidColorBrush(Color.Parse("#CFD8DC"));
|
||||
|
||||
public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture) =>
|
||||
value is WorktreeDiffLineKind kind
|
||||
? kind switch
|
||||
{
|
||||
WorktreeDiffLineKind.Added => Added,
|
||||
WorktreeDiffLineKind.Removed => Removed,
|
||||
WorktreeDiffLineKind.Hunk => Hunk,
|
||||
WorktreeDiffLineKind.Header => Header,
|
||||
_ => Default,
|
||||
}
|
||||
: Default;
|
||||
|
||||
public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
|
||||
=> throw new NotSupportedException();
|
||||
}
|
||||
@@ -76,8 +76,8 @@
|
||||
<!-- Icon.PlanDay (stroke-rendered via Path.plan-icon — sun over horizon) -->
|
||||
<StreamGeometry x:Key="Icon.PlanDay">M3,20 L21,20 M8.4,11 a3.6,3.6 0 1,0 7.2,0 a3.6,3.6 0 1,0 -7.2,0 M12,4.5 L12,3 M6,11 L4.5,11 M18,11 L19.5,11 M7.5,6.5 L6.4,5.4 M16.5,6.5 L17.6,5.4</StreamGeometry>
|
||||
|
||||
<!-- Icon.Refine (stroke-rendered via Path.plan-icon — list lines + sparkle + edit tail) -->
|
||||
<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>
|
||||
<!-- Icon.Refine (stroke-rendered via Path.plan-icon — list lines + two sparkles) -->
|
||||
<StreamGeometry x:Key="Icon.Refine">M3,6 L13,6 M3,11 L11,11 M3,16 L9,16 M18.5,3 L19.28,5.22 L21.5,6 L19.28,6.78 L18.5,9 L17.72,6.78 L15.5,6 L17.72,5.22 Z M19.5,14.9 L19.85,16.15 L21.1,16.5 L19.85,16.85 L19.5,18.1 L19.15,16.85 L17.9,16.5 L19.15,16.15 Z</StreamGeometry>
|
||||
|
||||
<!-- Icon.X -->
|
||||
<StreamGeometry x:Key="Icon.X">M6 6l12 12M18 6L6 18</StreamGeometry>
|
||||
@@ -229,6 +229,15 @@
|
||||
<Setter Property="Foreground" Value="{StaticResource TextMuteBrush}" />
|
||||
</Style>
|
||||
|
||||
<!-- parked → slate-blue: an Idle task still holding its Active worktree -->
|
||||
<Style Selector="Border.chip.parked">
|
||||
<Setter Property="Background" Value="#22303A" />
|
||||
<Setter Property="BorderBrush" Value="#3A5060" />
|
||||
</Style>
|
||||
<Style Selector="Border.chip.parked > TextBlock">
|
||||
<Setter Property="Foreground" Value="#8FB9D6" />
|
||||
</Style>
|
||||
|
||||
<!-- ============================================================ -->
|
||||
<!-- BUTTONS -->
|
||||
<!-- ============================================================ -->
|
||||
@@ -574,6 +583,13 @@
|
||||
<Style Selector="Border[Tag=?] > TextBlock">
|
||||
<Setter Property="Foreground" Value="{StaticResource TextFaintBrush}"/>
|
||||
</Style>
|
||||
<!-- R → rename (sage) -->
|
||||
<Style Selector="Border[Tag=R]">
|
||||
<Setter Property="Background" Value="#268B9D7A"/>
|
||||
</Style>
|
||||
<Style Selector="Border[Tag=R] > TextBlock">
|
||||
<Setter Property="Foreground" Value="{StaticResource SageBrush}"/>
|
||||
</Style>
|
||||
|
||||
<!-- ============================================================ -->
|
||||
<!-- LIST NAV ITEM -->
|
||||
@@ -864,14 +880,9 @@
|
||||
<Setter Property="Padding" Value="8,5" />
|
||||
<Setter Property="CornerRadius" Value="6" />
|
||||
<Setter Property="Background" Value="Transparent" />
|
||||
<Setter Property="Transitions">
|
||||
<Transitions>
|
||||
<BrushTransition Property="Background" Duration="0:0:0.10"/>
|
||||
</Transitions>
|
||||
</Setter>
|
||||
</Style>
|
||||
<Style Selector="Border.subtask-row:pointerover">
|
||||
<Setter Property="Background" Value="{StaticResource Surface2Brush}" />
|
||||
<Setter Property="Background" Value="{StaticResource Surface3Brush}" />
|
||||
</Style>
|
||||
<Style Selector="Border.subtask-row.done TextBlock.subtask-title">
|
||||
<Setter Property="Opacity" Value="0.5" />
|
||||
|
||||
@@ -100,6 +100,15 @@
|
||||
<SolidColorBrush x:Key="DoneTintBrush" Color="#1F6FA86B" />
|
||||
<SolidColorBrush x:Key="DoneTintBorderBrush" Color="#4C6FA86B" />
|
||||
|
||||
<!-- Merge editor (3-pane conflict resolver) block tints -->
|
||||
<SolidColorBrush x:Key="MergeOursTintBrush" Color="#1F7C9166" /> <!-- ours side (moss) -->
|
||||
<SolidColorBrush x:Key="MergeTheirsTintBrush" Color="#1FD4A574" /> <!-- theirs side (amber) -->
|
||||
<SolidColorBrush x:Key="MergeConflictTintBrush" Color="#28C87060" /> <!-- unresolved conflict (blood) -->
|
||||
<SolidColorBrush x:Key="MergeConflictEdgeBrush" Color="#80C87060" /> <!-- unresolved conflict gutter edge / map tick -->
|
||||
<SolidColorBrush x:Key="MergeResolvedTintBrush" Color="#206FA86B" /> <!-- resolved conflict (green) -->
|
||||
<SolidColorBrush x:Key="MergeResolvedEdgeBrush" Color="#806FA86B" /> <!-- resolved conflict map tick -->
|
||||
<SolidColorBrush x:Key="AmberBrush" Color="#FFD4A574" /> <!-- solid amber (theirs label) -->
|
||||
|
||||
<!-- Window-body gradient layers (apply as LinearGradientBrush in the main content Border) -->
|
||||
<LinearGradientBrush x:Key="DesktopBackgroundBrush" StartPoint="0%,0%" EndPoint="0%,100%">
|
||||
<GradientStop Offset="0" Color="#FF05070A" />
|
||||
|
||||
10
src/ClaudeDo.Ui/Services/Interfaces/IOnlineLoginService.cs
Normal file
10
src/ClaudeDo.Ui/Services/Interfaces/IOnlineLoginService.cs
Normal file
@@ -0,0 +1,10 @@
|
||||
namespace ClaudeDo.Ui.Services;
|
||||
|
||||
public sealed record OnlineLoginResult(bool Success, string? RefreshToken, string? Error, string? Warning = null);
|
||||
|
||||
public interface IOnlineLoginService
|
||||
{
|
||||
Task<OnlineLoginResult> LoginAsync(
|
||||
string authority, string clientId, string scope, string redirectUri,
|
||||
CancellationToken ct = default);
|
||||
}
|
||||
@@ -8,6 +8,7 @@ namespace ClaudeDo.Ui.Services;
|
||||
public interface IWorkerClient : INotifyPropertyChanged
|
||||
{
|
||||
bool IsConnected { get; }
|
||||
bool IsReconnecting { get; }
|
||||
|
||||
event Action<string, string, DateTime>? TaskStartedEvent;
|
||||
event Action<string, string, string, DateTime>? TaskFinishedEvent;
|
||||
@@ -17,6 +18,7 @@ public interface IWorkerClient : INotifyPropertyChanged
|
||||
event Action<string>? WorktreeUpdatedEvent;
|
||||
event Action<string>? ListUpdatedEvent;
|
||||
event Action<string, string>? TaskMessageEvent;
|
||||
event Action<WorkerLogEntry>? WorkerLogReceivedEvent;
|
||||
|
||||
event Action? PrepStartedEvent;
|
||||
event Action<string>? PrepLineEvent;
|
||||
@@ -28,12 +30,18 @@ public interface IWorkerClient : INotifyPropertyChanged
|
||||
event Action<string>? PlanningMergeAbortedEvent;
|
||||
event Action<string>? PlanningCompletedEvent;
|
||||
|
||||
event Action<PrimeFiredEvent>? PrimeFired;
|
||||
|
||||
string? LastApproveTarget { get; }
|
||||
|
||||
Task WakeQueueAsync();
|
||||
Task RunNowAsync(string taskId);
|
||||
Task ContinueTaskAsync(string taskId, string followUpPrompt);
|
||||
Task ResetTaskAsync(string taskId);
|
||||
Task CancelTaskAsync(string taskId);
|
||||
Task<List<AgentInfo>> GetAgentsAsync();
|
||||
Task RefreshAgentsAsync();
|
||||
Task<SeedResultDto?> RestoreDefaultAgentsAsync();
|
||||
Task<ListConfigDto?> GetListConfigAsync(string listId);
|
||||
Task UpdateTaskAgentSettingsAsync(UpdateTaskAgentSettingsDto dto);
|
||||
Task SetTaskStatusAsync(string taskId, TaskStatus status);
|
||||
@@ -43,6 +51,14 @@ public interface IWorkerClient : INotifyPropertyChanged
|
||||
Task RejectReviewToQueueAsync(string taskId, string feedback);
|
||||
Task RejectReviewToIdleAsync(string taskId);
|
||||
Task CancelReviewAsync(string taskId);
|
||||
|
||||
// ── Conflict resolution (worker hub side implemented by Layer C) ──
|
||||
Task<MergeResultDto> StartConflictMergeAsync(string taskId, string targetBranch);
|
||||
Task<MergeConflictsDto> GetMergeConflictsAsync(string taskId);
|
||||
Task<MergeConflictDocumentsDto> GetMergeConflictDocumentsAsync(string taskId);
|
||||
Task WriteConflictResolutionAsync(string taskId, string path, string resolvedContent);
|
||||
Task<MergeResultDto> ContinueConflictMergeAsync(string taskId);
|
||||
Task AbortConflictMergeAsync(string taskId);
|
||||
Task StartPlanningSessionAsync(string taskId, CancellationToken ct = default);
|
||||
Task OpenInteractiveTerminalAsync(string taskId, CancellationToken ct = default);
|
||||
Task ResumePlanningSessionAsync(string taskId, CancellationToken ct = default);
|
||||
@@ -52,7 +68,6 @@ public interface IWorkerClient : INotifyPropertyChanged
|
||||
Task<MergeTargetsDto?> GetMergeTargetsAsync(string taskId);
|
||||
Task<IReadOnlyList<SubtaskDiffDto>> GetPlanningAggregateAsync(string planningTaskId);
|
||||
Task<CombinedDiffResultDto?> BuildPlanningIntegrationBranchAsync(string planningTaskId, string targetBranch);
|
||||
Task MergeAllPlanningAsync(string planningTaskId, string targetBranch);
|
||||
Task ContinuePlanningMergeAsync(string planningTaskId);
|
||||
Task AbortPlanningMergeAsync(string planningTaskId);
|
||||
Task QueuePlanningSubtasksAsync(string parentTaskId, CancellationToken ct = default);
|
||||
@@ -65,9 +80,28 @@ public interface IWorkerClient : INotifyPropertyChanged
|
||||
event Action<string, bool, string?>? RefineFinishedEvent;
|
||||
Task ClearMyDayAsync();
|
||||
Task<AppSettingsDto?> GetAppSettingsAsync();
|
||||
Task UpdateAppSettingsAsync(AppSettingsDto dto);
|
||||
Task<List<DailyNoteDto>> GetDailyNotesAsync(DateOnly day);
|
||||
Task<DailyNoteDto?> AddDailyNoteAsync(DateOnly day, string text);
|
||||
Task UpdateDailyNoteAsync(string id, string text);
|
||||
Task DeleteDailyNoteAsync(string id);
|
||||
Task<string> GetLastPrepLogAsync();
|
||||
|
||||
Task<List<PrimeScheduleDto>> GetPrimeSchedulesAsync();
|
||||
Task<PrimeScheduleDto?> UpsertPrimeScheduleAsync(PrimeScheduleDto dto);
|
||||
Task DeletePrimeScheduleAsync(Guid id);
|
||||
|
||||
Task UpdateListAsync(UpdateListDto dto);
|
||||
Task UpdateListConfigAsync(UpdateListConfigDto dto);
|
||||
|
||||
Task<WorktreeCleanupDto?> CleanupFinishedWorktreesAsync(string? listId = null);
|
||||
Task<WorktreeResetDto?> ResetAllWorktreesAsync();
|
||||
Task<List<WorktreeOverviewDto>> GetWorktreesOverviewAsync(string? listId);
|
||||
Task<(bool Ok, string? Error)> SetWorktreeStateAsync(string taskId, WorktreeState newState);
|
||||
Task<ForceRemoveResultDto?> ForceRemoveWorktreeAsync(string taskId);
|
||||
|
||||
Task<OnlineInboxStateDto?> GetOnlineInboxStateAsync();
|
||||
Task SetOnlineInboxConfigAsync(OnlineInboxConfigInputDto input);
|
||||
Task SetOnlineInboxAuthAsync(string refreshToken);
|
||||
Task ClearOnlineInboxAuthAsync();
|
||||
}
|
||||
|
||||
143
src/ClaudeDo.Ui/Services/OnlineLoginService.cs
Normal file
143
src/ClaudeDo.Ui/Services/OnlineLoginService.cs
Normal file
@@ -0,0 +1,143 @@
|
||||
using System.Diagnostics;
|
||||
using System.Net;
|
||||
using System.Text;
|
||||
using Duende.IdentityModel.OidcClient;
|
||||
using Duende.IdentityModel.OidcClient.Browser;
|
||||
|
||||
namespace ClaudeDo.Ui.Services;
|
||||
|
||||
public sealed class OnlineLoginService : IOnlineLoginService
|
||||
{
|
||||
public async Task<OnlineLoginResult> LoginAsync(
|
||||
string authority, string clientId, string scope, string redirectUri,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
var browser = new LoopbackBrowser(redirectUri);
|
||||
var options = new OidcClientOptions
|
||||
{
|
||||
Authority = authority,
|
||||
ClientId = clientId,
|
||||
Scope = scope,
|
||||
RedirectUri = redirectUri,
|
||||
Browser = browser,
|
||||
};
|
||||
|
||||
var client = new OidcClient(options);
|
||||
var result = await client.LoginAsync(new LoginRequest(), ct);
|
||||
|
||||
if (result.IsError)
|
||||
return new OnlineLoginResult(false, null, result.Error);
|
||||
|
||||
if (string.IsNullOrEmpty(result.RefreshToken))
|
||||
return new OnlineLoginResult(false, null,
|
||||
"No refresh token returned. Ensure 'offline_access' is in scope and the client allows it.");
|
||||
|
||||
// Early heads-up: if the access token lacks the "user" project role the server will
|
||||
// reject sync with a 401. Login still succeeds; surface this as a warning, not an error.
|
||||
var warning = ZitadelTokenInspector.HasUserRole(result.AccessToken)
|
||||
? null
|
||||
: "missing-user-role";
|
||||
|
||||
return new OnlineLoginResult(true, result.RefreshToken, null, warning);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
return new OnlineLoginResult(false, null, "Login cancelled.");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return new OnlineLoginResult(false, null, ex.Message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// IBrowser implementation: opens the system browser and captures the authorization
|
||||
/// response via a loopback HttpListener on the redirect URI's host/port.
|
||||
/// </summary>
|
||||
sealed class LoopbackBrowser : IBrowser
|
||||
{
|
||||
private static readonly TimeSpan Timeout = TimeSpan.FromMinutes(3);
|
||||
private readonly string _redirectUri;
|
||||
|
||||
public LoopbackBrowser(string redirectUri) => _redirectUri = redirectUri;
|
||||
|
||||
public async Task<BrowserResult> InvokeAsync(BrowserOptions options, CancellationToken ct = default)
|
||||
{
|
||||
// Derive the listener prefix from the redirect URI
|
||||
var uri = new Uri(_redirectUri);
|
||||
var prefix = $"{uri.Scheme}://{uri.Host}:{uri.Port}/";
|
||||
|
||||
using var listener = new HttpListener();
|
||||
listener.Prefixes.Add(prefix);
|
||||
|
||||
try
|
||||
{
|
||||
listener.Start();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return new BrowserResult
|
||||
{
|
||||
ResultType = BrowserResultType.UnknownError,
|
||||
Error = $"Could not start loopback listener on {prefix}: {ex.Message}"
|
||||
};
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
Process.Start(new ProcessStartInfo(options.StartUrl) { UseShellExecute = true });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return new BrowserResult
|
||||
{
|
||||
ResultType = BrowserResultType.UnknownError,
|
||||
Error = $"Could not open browser: {ex.Message}"
|
||||
};
|
||||
}
|
||||
|
||||
using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||
cts.CancelAfter(Timeout);
|
||||
|
||||
try
|
||||
{
|
||||
var context = await listener.GetContextAsync().WaitAsync(cts.Token);
|
||||
|
||||
var responseBody = Encoding.UTF8.GetBytes(
|
||||
"<html><body style=\"font-family:sans-serif;background:#0D1311;color:#E4EBE4;padding:40px\">" +
|
||||
"<h2>Login successful</h2><p>You may close this tab.</p></body></html>");
|
||||
|
||||
context.Response.ContentLength64 = responseBody.Length;
|
||||
context.Response.ContentType = "text/html; charset=utf-8";
|
||||
await context.Response.OutputStream.WriteAsync(responseBody, cts.Token);
|
||||
context.Response.OutputStream.Close();
|
||||
|
||||
// rawUrl already includes the redirect path (e.g. "/callback?code=..."),
|
||||
// so build the full URL from the scheme://host:port base — NOT the full
|
||||
// redirect URI, or the path would be doubled (".../callback/callback").
|
||||
var rawUrl = context.Request.RawUrl ?? "";
|
||||
var fullUri = prefix.TrimEnd('/') + rawUrl;
|
||||
|
||||
return new BrowserResult
|
||||
{
|
||||
ResultType = BrowserResultType.Success,
|
||||
Response = fullUri
|
||||
};
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
return new BrowserResult
|
||||
{
|
||||
ResultType = BrowserResultType.Timeout,
|
||||
Error = "Login timed out waiting for browser callback."
|
||||
};
|
||||
}
|
||||
finally
|
||||
{
|
||||
listener.Stop();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -66,7 +66,7 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
|
||||
|
||||
public event Action<PrimeFiredEvent>? PrimeFired;
|
||||
|
||||
public string? LastMergeAllTarget { get; private set; }
|
||||
public string? LastApproveTarget { get; private set; }
|
||||
|
||||
public WorkerClient(string signalRUrl)
|
||||
{
|
||||
@@ -269,6 +269,24 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
|
||||
"MergeTask", taskId, targetBranch, removeWorktree, commitMessage);
|
||||
}
|
||||
|
||||
public Task<MergeResultDto> StartConflictMergeAsync(string taskId, string targetBranch)
|
||||
=> _hub.InvokeAsync<MergeResultDto>("StartConflictMerge", taskId, targetBranch);
|
||||
|
||||
public Task<MergeConflictsDto> GetMergeConflictsAsync(string taskId)
|
||||
=> _hub.InvokeAsync<MergeConflictsDto>("GetMergeConflicts", taskId);
|
||||
|
||||
public Task<MergeConflictDocumentsDto> GetMergeConflictDocumentsAsync(string taskId)
|
||||
=> _hub.InvokeAsync<MergeConflictDocumentsDto>("GetMergeConflictDocuments", taskId);
|
||||
|
||||
public Task WriteConflictResolutionAsync(string taskId, string path, string resolvedContent)
|
||||
=> _hub.InvokeAsync("WriteConflictResolution", taskId, path, resolvedContent);
|
||||
|
||||
public Task<MergeResultDto> ContinueConflictMergeAsync(string taskId)
|
||||
=> _hub.InvokeAsync<MergeResultDto>("ContinueConflictMerge", taskId);
|
||||
|
||||
public Task AbortConflictMergeAsync(string taskId)
|
||||
=> _hub.InvokeAsync("AbortConflictMerge", taskId);
|
||||
|
||||
public Task<MergeTargetsDto?> GetMergeTargetsAsync(string taskId)
|
||||
=> TryInvokeAsync<MergeTargetsDto>("GetMergeTargets", taskId);
|
||||
|
||||
@@ -397,7 +415,10 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
|
||||
}
|
||||
|
||||
public Task<MergeResultDto?> ApproveReviewAsync(string taskId, string targetBranch)
|
||||
=> TryInvokeAsync<MergeResultDto>("ApproveReview", taskId, targetBranch);
|
||||
{
|
||||
LastApproveTarget = targetBranch;
|
||||
return TryInvokeAsync<MergeResultDto>("ApproveReview", taskId, targetBranch);
|
||||
}
|
||||
|
||||
public Task<MergePreviewDto?> PreviewMergeAsync(string taskId, string targetBranch)
|
||||
=> TryInvokeAsync<MergePreviewDto>("PreviewMerge", taskId, targetBranch);
|
||||
@@ -471,12 +492,6 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
|
||||
public Task<CombinedDiffResultDto?> BuildPlanningIntegrationBranchAsync(string planningTaskId, string targetBranch)
|
||||
=> TryInvokeAsync<CombinedDiffResultDto>("BuildPlanningIntegrationBranch", planningTaskId, targetBranch);
|
||||
|
||||
public async Task MergeAllPlanningAsync(string planningTaskId, string targetBranch)
|
||||
{
|
||||
LastMergeAllTarget = targetBranch;
|
||||
await _hub.InvokeAsync("MergeAllPlanning", planningTaskId, targetBranch);
|
||||
}
|
||||
|
||||
public async Task ContinuePlanningMergeAsync(string planningTaskId)
|
||||
{
|
||||
await _hub.InvokeAsync("ContinuePlanningMerge", planningTaskId);
|
||||
@@ -492,6 +507,18 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
|
||||
await _hub.InvokeAsync("QueuePlanningSubtasksAsync", parentTaskId, ct);
|
||||
}
|
||||
|
||||
public Task<OnlineInboxStateDto?> GetOnlineInboxStateAsync()
|
||||
=> TryInvokeAsync<OnlineInboxStateDto>("GetOnlineInboxState");
|
||||
|
||||
public async Task SetOnlineInboxConfigAsync(OnlineInboxConfigInputDto input)
|
||||
=> await _hub.InvokeAsync("SetOnlineInboxConfig", input);
|
||||
|
||||
public async Task SetOnlineInboxAuthAsync(string refreshToken)
|
||||
=> await _hub.InvokeAsync("SetOnlineInboxAuth", refreshToken);
|
||||
|
||||
public async Task ClearOnlineInboxAuthAsync()
|
||||
=> await _hub.InvokeAsync("ClearOnlineInboxAuth");
|
||||
|
||||
// IWorkerClient explicit implementations (drop typed return values)
|
||||
async Task IWorkerClient.StartPlanningSessionAsync(string taskId, CancellationToken ct)
|
||||
=> await StartPlanningSessionAsync(taskId, ct);
|
||||
@@ -532,6 +559,12 @@ public sealed record WorktreeResetDto(int Removed, int TasksAffected, bool Block
|
||||
public record MergeResultDto(string Status, IReadOnlyList<string> ConflictFiles, string? ErrorMessage);
|
||||
public record MergePreviewDto(string Status, IReadOnlyList<string> ConflictFiles, int ChangedFileCount);
|
||||
public record MergeTargetsDto(string DefaultBranch, IReadOnlyList<string> LocalBranches);
|
||||
public record MergeConflictsDto(string TaskId, IReadOnlyList<ConflictFileDto> Files);
|
||||
public record ConflictFileDto(string Path, IReadOnlyList<ConflictHunkDto> Hunks);
|
||||
public record ConflictHunkDto(string Ours, string Theirs, string? Base);
|
||||
public record MergeConflictDocumentsDto(string TaskId, IReadOnlyList<ConflictDocumentDto> Files);
|
||||
public record ConflictDocumentDto(string Path, bool IsBinary, IReadOnlyList<MergeSegmentDto> Segments);
|
||||
public record MergeSegmentDto(bool IsConflict, string Text, string Ours, string? Base, string Theirs);
|
||||
public sealed record UpdateListDto(string Id, string Name, string? WorkingDir, string DefaultCommitType);
|
||||
public sealed record UpdateListConfigDto(string ListId, string? Model, string? SystemPrompt, string? AgentPath, int? MaxTurns = null);
|
||||
public sealed record UpdateTaskAgentSettingsDto(string TaskId, string? Model, string? SystemPrompt, string? AgentPath, int? MaxTurns = null);
|
||||
@@ -553,3 +586,22 @@ public sealed record WorktreeOverviewDto(
|
||||
bool PathExistsOnDisk);
|
||||
|
||||
public sealed record ForceRemoveResultDto(bool Removed, string? Reason);
|
||||
|
||||
public sealed record OnlineInboxStateDto(
|
||||
bool Enabled,
|
||||
string ApiBaseUrl,
|
||||
string Authority,
|
||||
string ClientId,
|
||||
string Scopes,
|
||||
string RedirectUri,
|
||||
bool SignedIn,
|
||||
int PollIntervalSeconds);
|
||||
|
||||
public sealed record OnlineInboxConfigInputDto(
|
||||
bool Enabled,
|
||||
string ApiBaseUrl,
|
||||
int PollIntervalSeconds,
|
||||
string Authority,
|
||||
string ClientId,
|
||||
string Scopes,
|
||||
string RedirectUri);
|
||||
|
||||
@@ -4,8 +4,8 @@ namespace ClaudeDo.Ui.Services;
|
||||
|
||||
public sealed class WorkerNotesApi : INotesApi
|
||||
{
|
||||
private readonly WorkerClient _client;
|
||||
public WorkerNotesApi(WorkerClient client) => _client = client;
|
||||
private readonly IWorkerClient _client;
|
||||
public WorkerNotesApi(IWorkerClient client) => _client = client;
|
||||
public Task<List<DailyNoteDto>> ListAsync(DateOnly day) => _client.GetDailyNotesAsync(day);
|
||||
public Task<DailyNoteDto?> AddAsync(DateOnly day, string text) => _client.AddDailyNoteAsync(day, text);
|
||||
public Task UpdateAsync(string id, string text) => _client.UpdateDailyNoteAsync(id, text);
|
||||
|
||||
@@ -2,8 +2,8 @@ namespace ClaudeDo.Ui.Services;
|
||||
|
||||
public sealed class WorkerPrimeScheduleApi : IPrimeScheduleApi
|
||||
{
|
||||
private readonly WorkerClient _client;
|
||||
public WorkerPrimeScheduleApi(WorkerClient client) => _client = client;
|
||||
private readonly IWorkerClient _client;
|
||||
public WorkerPrimeScheduleApi(IWorkerClient client) => _client = client;
|
||||
public Task<List<PrimeScheduleDto>> ListAsync() => _client.GetPrimeSchedulesAsync();
|
||||
public Task<PrimeScheduleDto?> UpsertAsync(PrimeScheduleDto dto) => _client.UpsertPrimeScheduleAsync(dto);
|
||||
public Task DeleteAsync(Guid id) => _client.DeletePrimeScheduleAsync(id);
|
||||
|
||||
64
src/ClaudeDo.Ui/Services/ZitadelTokenInspector.cs
Normal file
64
src/ClaudeDo.Ui/Services/ZitadelTokenInspector.cs
Normal file
@@ -0,0 +1,64 @@
|
||||
using System;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace ClaudeDo.Ui.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Minimal, dependency-free inspection of a Zitadel JWT access token. Used to warn early when
|
||||
/// a freshly issued token lacks the "user" project role (the server otherwise rejects sync
|
||||
/// with a 401). The server remains the source of truth — this check fails open.
|
||||
/// </summary>
|
||||
public static class ZitadelTokenInspector
|
||||
{
|
||||
private const string ProjectRolesClaim = "urn:zitadel:iam:org:project:roles";
|
||||
private const string ProjectRolesClaimPrefix = "urn:zitadel:iam:org:project:";
|
||||
private const string ProjectRolesClaimSuffix = ":roles";
|
||||
private const string UserRole = "user";
|
||||
|
||||
/// <summary>
|
||||
/// Returns true if the access token carries the "user" role in either the generic or
|
||||
/// project-scoped Zitadel roles claim. Returns true (fail-open) if the token is absent or
|
||||
/// cannot be parsed — never block login on a decode hiccup.
|
||||
/// </summary>
|
||||
public static bool HasUserRole(string? accessToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(accessToken))
|
||||
return true;
|
||||
|
||||
var parts = accessToken.Split('.');
|
||||
if (parts.Length < 2)
|
||||
return true;
|
||||
|
||||
try
|
||||
{
|
||||
using var doc = JsonDocument.Parse(Base64UrlDecode(parts[1]));
|
||||
foreach (var claim in doc.RootElement.EnumerateObject())
|
||||
{
|
||||
if (claim.Name != ProjectRolesClaim &&
|
||||
!(claim.Name.StartsWith(ProjectRolesClaimPrefix, StringComparison.Ordinal) &&
|
||||
claim.Name.EndsWith(ProjectRolesClaimSuffix, StringComparison.Ordinal)))
|
||||
continue;
|
||||
|
||||
if (claim.Value.ValueKind == JsonValueKind.Object &&
|
||||
claim.Value.TryGetProperty(UserRole, out _))
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
private static byte[] Base64UrlDecode(string input)
|
||||
{
|
||||
var s = input.Replace('-', '+').Replace('_', '/');
|
||||
switch (s.Length % 4)
|
||||
{
|
||||
case 2: s += "=="; break;
|
||||
case 3: s += "="; break;
|
||||
}
|
||||
return Convert.FromBase64String(s);
|
||||
}
|
||||
}
|
||||
93
src/ClaudeDo.Ui/ViewModels/Conflicts/ConflictModels.cs
Normal file
93
src/ClaudeDo.Ui/ViewModels/Conflicts/ConflictModels.cs
Normal file
@@ -0,0 +1,93 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
|
||||
namespace ClaudeDo.Ui.ViewModels.Conflicts;
|
||||
|
||||
/// <summary>
|
||||
/// One conflict region in a file: the two competing versions (and the merge base when the
|
||||
/// merge used diff3 style), plus the chosen <see cref="Resolution"/> (null until resolved).
|
||||
/// </summary>
|
||||
public sealed partial class MergeConflictBlock : ObservableObject
|
||||
{
|
||||
public string Ours { get; }
|
||||
public string? Base { get; }
|
||||
public string Theirs { get; }
|
||||
|
||||
[ObservableProperty] private string? _resolution;
|
||||
|
||||
public bool IsResolved => Resolution is not null;
|
||||
public bool HasBase => Base is not null;
|
||||
|
||||
public MergeConflictBlock(string ours, string? @base, string theirs)
|
||||
{
|
||||
Ours = ours;
|
||||
Base = @base;
|
||||
Theirs = theirs;
|
||||
}
|
||||
|
||||
partial void OnResolutionChanged(string? value) => OnPropertyChanged(nameof(IsResolved));
|
||||
|
||||
[RelayCommand] private void AcceptOurs() => Resolution = Ours;
|
||||
[RelayCommand] private void AcceptTheirs() => Resolution = Theirs;
|
||||
[RelayCommand] private void AcceptBoth() => Resolution = Ours + Theirs;
|
||||
[RelayCommand] private void AcceptBase() => Resolution = Base ?? "";
|
||||
}
|
||||
|
||||
/// <summary>An ordered piece of a conflicted file: either stable common text or a conflict block.</summary>
|
||||
public sealed class MergeFileSegment
|
||||
{
|
||||
public bool IsConflict { get; }
|
||||
public string StableText { get; }
|
||||
public MergeConflictBlock? Conflict { get; }
|
||||
|
||||
private MergeFileSegment(bool isConflict, string stableText, MergeConflictBlock? conflict)
|
||||
{
|
||||
IsConflict = isConflict;
|
||||
StableText = stableText;
|
||||
Conflict = conflict;
|
||||
}
|
||||
|
||||
public static MergeFileSegment Stable(string text) => new(false, text, null);
|
||||
public static MergeFileSegment FromConflict(MergeConflictBlock block) => new(true, "", block);
|
||||
}
|
||||
|
||||
/// <summary>A conflicted file: its ordered segments (for reassembly) and just its conflict blocks.</summary>
|
||||
public sealed class MergeFile
|
||||
{
|
||||
public string Path { get; }
|
||||
public bool IsBinary { get; }
|
||||
public IReadOnlyList<MergeFileSegment> Segments { get; }
|
||||
public IReadOnlyList<MergeConflictBlock> Conflicts { get; }
|
||||
|
||||
public MergeFile(string path, bool isBinary, IReadOnlyList<MergeFileSegment> segments)
|
||||
{
|
||||
Path = path;
|
||||
IsBinary = isBinary;
|
||||
Segments = segments;
|
||||
Conflicts = segments.Where(s => s.IsConflict).Select(s => s.Conflict!).ToList();
|
||||
}
|
||||
|
||||
/// <summary>A binary file can't be resolved in-app; a text file is done once every block is resolved.</summary>
|
||||
public bool AllResolved => !IsBinary && Conflicts.All(c => c.IsResolved);
|
||||
|
||||
/// <summary>Reassemble the file: stable text verbatim, each conflict replaced by its resolution
|
||||
/// (empty when unresolved — the same "empty start" the editor shows; Continue is gated on
|
||||
/// <see cref="AllResolved"/> so an unresolved conflict never actually reaches here).</summary>
|
||||
public string Compose() => string.Concat(
|
||||
Segments.Select(s => s.IsConflict ? (s.Conflict!.Resolution ?? "") : s.StableText));
|
||||
|
||||
/// <summary>Left pane document: stable regions verbatim, conflict regions show Ours text.</summary>
|
||||
public string OursText => string.Concat(
|
||||
Segments.Select(s => s.IsConflict ? s.Conflict!.Ours : s.StableText));
|
||||
|
||||
/// <summary>Right pane document: stable regions verbatim, conflict regions show Theirs text.</summary>
|
||||
public string TheirsText => string.Concat(
|
||||
Segments.Select(s => s.IsConflict ? s.Conflict!.Theirs : s.StableText));
|
||||
|
||||
/// <summary>Middle (result) pane document: stable regions verbatim, conflict regions show the
|
||||
/// chosen Resolution, or empty when unresolved (the editor builds each conflict up from empty).</summary>
|
||||
public string ResultText => string.Concat(
|
||||
Segments.Select(s => s.IsConflict ? (s.Conflict!.Resolution ?? "") : s.StableText));
|
||||
}
|
||||
@@ -0,0 +1,300 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
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;
|
||||
|
||||
// The task whose conflicted working tree is read/written. For a single-task merge this is
|
||||
// _taskId; for a planning unit-merge it's the subtask currently being merged.
|
||||
private string _conflictTaskId;
|
||||
|
||||
// When set, this is a planning unit-merge: continue/abort drive the orchestrator on the parent.
|
||||
private string? _planningParentId;
|
||||
|
||||
public ObservableCollection<MergeFile> Files { get; } = new();
|
||||
|
||||
// All text conflicts across all files, flattened for one-at-a-time navigation.
|
||||
private readonly List<(MergeFile File, MergeConflictBlock Block)> _flat = new();
|
||||
|
||||
[ObservableProperty] private bool _isBusy;
|
||||
[ObservableProperty] private string? _error;
|
||||
|
||||
[ObservableProperty]
|
||||
[NotifyPropertyChangedFor(nameof(ContinueHint))]
|
||||
private bool _canContinue;
|
||||
|
||||
[ObservableProperty]
|
||||
[NotifyPropertyChangedFor(nameof(HasCurrent))]
|
||||
private MergeConflictBlock? _current;
|
||||
|
||||
[ObservableProperty]
|
||||
[NotifyPropertyChangedFor(nameof(PositionText))]
|
||||
[NotifyPropertyChangedFor(nameof(CurrentPath))]
|
||||
[NotifyCanExecuteChangedFor(nameof(NextCommand))]
|
||||
[NotifyCanExecuteChangedFor(nameof(PreviousCommand))]
|
||||
private int _currentIndex = -1;
|
||||
|
||||
[ObservableProperty] private MergeFile? _activeFile;
|
||||
|
||||
/// <summary>Raised when the active file changes so the view can rebuild its three documents.</summary>
|
||||
public event Action? ActiveFileChanged;
|
||||
|
||||
partial void OnActiveFileChanged(MergeFile? value)
|
||||
{
|
||||
ActiveFileChanged?.Invoke();
|
||||
OnPropertyChanged(nameof(ActiveOursText));
|
||||
OnPropertyChanged(nameof(ActiveTheirsText));
|
||||
OnPropertyChanged(nameof(ActiveResultText));
|
||||
OnPropertyChanged(nameof(PositionText));
|
||||
// Keep the focused conflict inside the active file (e.g. when switched via the file picker).
|
||||
if (value is not null && (Current is null || !value.Conflicts.Contains(Current)))
|
||||
{
|
||||
var idx = _flat.FindIndex(x => x.File == value);
|
||||
if (idx >= 0) MoveTo(idx);
|
||||
}
|
||||
}
|
||||
|
||||
public string ActiveOursText => ActiveFile?.OursText ?? "";
|
||||
public string ActiveTheirsText => ActiveFile?.TheirsText ?? "";
|
||||
public string ActiveResultText => ActiveFile?.ResultText ?? "";
|
||||
|
||||
public bool HasCurrent => Current is not null;
|
||||
public int TotalConflicts => _flat.Count;
|
||||
public int ResolvedCount => _flat.Count(x => x.Block.IsResolved);
|
||||
public string? CurrentPath => InRange ? _flat[CurrentIndex].File.Path : null;
|
||||
|
||||
public string PositionText
|
||||
{
|
||||
get
|
||||
{
|
||||
if (ActiveFile is null || ActiveFile.Conflicts.Count == 0) return "No text conflicts";
|
||||
var count = ActiveFile.Conflicts.Count;
|
||||
var resolved = ActiveFile.Conflicts.Count(c => c.IsResolved);
|
||||
return $"{count} {(count == 1 ? "conflict" : "conflicts")} · {resolved} resolved";
|
||||
}
|
||||
}
|
||||
|
||||
public IReadOnlyList<string> BinaryFilePaths => Files.Where(f => f.IsBinary).Select(f => f.Path).ToList();
|
||||
public bool HasBinaryFiles => Files.Any(f => f.IsBinary);
|
||||
|
||||
public bool HasMultipleFiles => Files.Count > 1;
|
||||
|
||||
/// <summary>Cross-file progress shown in the editor: how many files still have unresolved
|
||||
/// (or binary) conflicts, so you can see how many more need attention.</summary>
|
||||
public string FilesSummary
|
||||
{
|
||||
get
|
||||
{
|
||||
var total = Files.Count;
|
||||
if (total == 0) return "";
|
||||
var unresolved = Files.Count(f => !f.AllResolved);
|
||||
return unresolved == 0 ? $"All {total} files resolved" : $"{unresolved} of {total} files unresolved";
|
||||
}
|
||||
}
|
||||
|
||||
public string ContinueHint => HasBinaryFiles
|
||||
? "Binary conflicts must be resolved externally — abort and resolve in your editor."
|
||||
: "";
|
||||
|
||||
private bool InRange => CurrentIndex >= 0 && CurrentIndex < _flat.Count;
|
||||
|
||||
public string TaskId => _taskId;
|
||||
public Action? CloseRequested { get; set; }
|
||||
|
||||
/// <summary>Raised when the current conflict changes so the view can reload its editors.</summary>
|
||||
public event Action? CurrentChanged;
|
||||
|
||||
public ConflictResolverViewModel(IWorkerClient worker, string taskId)
|
||||
{
|
||||
_worker = worker;
|
||||
_taskId = taskId;
|
||||
_conflictTaskId = taskId;
|
||||
}
|
||||
|
||||
/// <summary>Starts the conflict merge and loads the conflicted files as line-level segments.
|
||||
/// Returns true when there is something 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;
|
||||
}
|
||||
return await LoadDocumentsAsync();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Error = ex.Message;
|
||||
return false;
|
||||
}
|
||||
finally { IsBusy = false; }
|
||||
}
|
||||
|
||||
/// <summary>Resolves a planning unit-merge conflict for <paramref name="subtaskId"/>. The merge is
|
||||
/// already mid-conflict (driven by the orchestrator), so this only loads the conflicted files;
|
||||
/// continue/abort hand back to the orchestrator on <paramref name="planningParentId"/>.</summary>
|
||||
public async Task<bool> OpenForPlanningAsync(string planningParentId, string subtaskId)
|
||||
{
|
||||
_planningParentId = planningParentId;
|
||||
_conflictTaskId = subtaskId;
|
||||
IsBusy = true;
|
||||
Error = null;
|
||||
try
|
||||
{
|
||||
return await LoadDocumentsAsync();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Error = ex.Message;
|
||||
return false;
|
||||
}
|
||||
finally { IsBusy = false; }
|
||||
}
|
||||
|
||||
private async Task<bool> LoadDocumentsAsync()
|
||||
{
|
||||
var docs = await _worker.GetMergeConflictDocumentsAsync(_conflictTaskId);
|
||||
Files.Clear();
|
||||
_flat.Clear();
|
||||
foreach (var f in docs.Files)
|
||||
{
|
||||
var segments = f.Segments.Select(s => s.IsConflict
|
||||
? MergeFileSegment.FromConflict(Hook(new MergeConflictBlock(s.Ours, s.Base, s.Theirs)))
|
||||
: MergeFileSegment.Stable(s.Text)).ToList();
|
||||
var file = new MergeFile(f.Path, f.IsBinary, segments);
|
||||
Files.Add(file);
|
||||
foreach (var c in file.Conflicts) _flat.Add((file, c));
|
||||
}
|
||||
|
||||
OnPropertyChanged(nameof(TotalConflicts));
|
||||
OnPropertyChanged(nameof(BinaryFilePaths));
|
||||
OnPropertyChanged(nameof(HasBinaryFiles));
|
||||
OnPropertyChanged(nameof(HasMultipleFiles));
|
||||
OnPropertyChanged(nameof(FilesSummary));
|
||||
RecomputeCanContinue();
|
||||
if (_flat.Count > 0)
|
||||
MoveTo(0); // also sets ActiveFile via MoveTo
|
||||
else if (Files.Count > 0)
|
||||
ActiveFile = Files[0];
|
||||
return Files.Count > 0;
|
||||
}
|
||||
|
||||
private MergeConflictBlock Hook(MergeConflictBlock block)
|
||||
{
|
||||
block.PropertyChanged += OnBlockChanged;
|
||||
return block;
|
||||
}
|
||||
|
||||
private void OnBlockChanged(object? sender, PropertyChangedEventArgs e)
|
||||
{
|
||||
if (e.PropertyName is nameof(MergeConflictBlock.IsResolved) or nameof(MergeConflictBlock.Resolution))
|
||||
{
|
||||
RecomputeCanContinue();
|
||||
OnPropertyChanged(nameof(ResolvedCount));
|
||||
OnPropertyChanged(nameof(PositionText));
|
||||
OnPropertyChanged(nameof(ActiveResultText));
|
||||
OnPropertyChanged(nameof(FilesSummary));
|
||||
}
|
||||
}
|
||||
|
||||
private void RecomputeCanContinue() =>
|
||||
CanContinue = Files.Count > 0 && Files.All(f => f.AllResolved);
|
||||
|
||||
private void MoveTo(int index)
|
||||
{
|
||||
CurrentIndex = index;
|
||||
Current = _flat[index].Block;
|
||||
ActiveFile = _flat[index].File;
|
||||
OnPropertyChanged(nameof(CurrentPath));
|
||||
CurrentChanged?.Invoke();
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void SelectFile(MergeFile file)
|
||||
{
|
||||
// Jump to the first conflict in this file (if any); otherwise just switch the active file.
|
||||
var idx = _flat.FindIndex(x => x.File == file);
|
||||
if (idx >= 0)
|
||||
MoveTo(idx);
|
||||
else
|
||||
ActiveFile = file;
|
||||
}
|
||||
|
||||
private bool CanGoNext() => CurrentIndex >= 0 && CurrentIndex < _flat.Count - 1;
|
||||
private bool CanGoPrevious() => CurrentIndex > 0;
|
||||
|
||||
[RelayCommand(CanExecute = nameof(CanGoNext))]
|
||||
private void Next() => MoveTo(CurrentIndex + 1);
|
||||
|
||||
[RelayCommand(CanExecute = nameof(CanGoPrevious))]
|
||||
private void Previous() => MoveTo(CurrentIndex - 1);
|
||||
|
||||
[RelayCommand]
|
||||
private async Task ContinueAsync()
|
||||
{
|
||||
if (!CanContinue) return;
|
||||
IsBusy = true;
|
||||
Error = null;
|
||||
try
|
||||
{
|
||||
foreach (var file in Files.Where(f => !f.IsBinary))
|
||||
await _worker.WriteConflictResolutionAsync(_conflictTaskId, file.Path, file.Compose());
|
||||
|
||||
if (_planningParentId is not null)
|
||||
{
|
||||
// Hand back to the orchestrator: it commits this subtask and drains the rest.
|
||||
// A later subtask conflict re-opens this editor via the PlanningMergeConflict broadcast.
|
||||
await _worker.ContinuePlanningMergeAsync(_planningParentId);
|
||||
CloseRequested?.Invoke();
|
||||
return;
|
||||
}
|
||||
|
||||
var result = await _worker.ContinueConflictMergeAsync(_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
|
||||
{
|
||||
if (_planningParentId is not null)
|
||||
await _worker.AbortPlanningMergeAsync(_planningParentId);
|
||||
else
|
||||
await _worker.AbortConflictMergeAsync(_taskId);
|
||||
}
|
||||
catch (Exception ex) { Error = ex.Message; }
|
||||
finally
|
||||
{
|
||||
IsBusy = false;
|
||||
CloseRequested?.Invoke();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
using System.Collections.ObjectModel;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using ClaudeDo.Data.Models;
|
||||
using ClaudeDo.Ui.Helpers;
|
||||
using ClaudeDo.Ui.Localization;
|
||||
using ClaudeDo.Ui.Services;
|
||||
|
||||
namespace ClaudeDo.Ui.ViewModels.Islands;
|
||||
|
||||
public sealed partial class AgentSettingsSectionViewModel : ViewModelBase, IDisposable
|
||||
{
|
||||
private readonly IWorkerClient _worker;
|
||||
private readonly EventHandler _langChangedHandler;
|
||||
|
||||
internal string? TaskId { get; set; }
|
||||
|
||||
[ObservableProperty]
|
||||
[NotifyPropertyChangedFor(nameof(IsAgentSectionEnabled))]
|
||||
private bool _isRunning;
|
||||
|
||||
public bool IsAgentSectionEnabled => !IsRunning;
|
||||
|
||||
[ObservableProperty] private string? _taskModelSelection;
|
||||
[ObservableProperty] private string _taskSystemPrompt = "";
|
||||
[ObservableProperty] private AgentInfo? _taskSelectedAgent;
|
||||
[ObservableProperty] private decimal? _taskMaxTurns;
|
||||
[ObservableProperty] private string _modelBadge = "";
|
||||
[ObservableProperty] private string _modelInheritedHint = "";
|
||||
[ObservableProperty] private string _turnsBadge = "";
|
||||
[ObservableProperty] private string _turnsInheritedHint = "";
|
||||
[ObservableProperty] private string _agentBadge = "";
|
||||
[ObservableProperty] private string _effectiveSystemPromptHint = "";
|
||||
|
||||
private string _globalModel = ModelRegistry.DefaultAlias;
|
||||
private int _globalMaxTurns = 100;
|
||||
private string? _listModel;
|
||||
private int? _listMaxTurns;
|
||||
private string? _listAgentName;
|
||||
|
||||
private bool _suppressAgentSave;
|
||||
private CancellationTokenSource? _agentSaveCts;
|
||||
|
||||
public int EffectiveMaxTurns =>
|
||||
TaskMaxTurns is decimal t ? (int)t : (_listMaxTurns ?? _globalMaxTurns);
|
||||
|
||||
public ObservableCollection<string> TaskModelOptions { get; } = new(ModelRegistry.Aliases);
|
||||
public ObservableCollection<AgentInfo> TaskAgentOptions { get; } = new();
|
||||
|
||||
public AgentSettingsSectionViewModel(IWorkerClient worker)
|
||||
{
|
||||
_worker = worker;
|
||||
_langChangedHandler = (_, _) =>
|
||||
{
|
||||
RecomputeModelBadge();
|
||||
RecomputeTurnsBadge();
|
||||
RecomputeAgentBadge();
|
||||
};
|
||||
Loc.LanguageChanged += _langChangedHandler;
|
||||
}
|
||||
|
||||
public void Dispose() => Loc.LanguageChanged -= _langChangedHandler;
|
||||
|
||||
partial void OnTaskModelSelectionChanged(string? value) { RecomputeModelBadge(); QueueAgentSave(); }
|
||||
|
||||
partial void OnTaskMaxTurnsChanged(decimal? value)
|
||||
{
|
||||
RecomputeTurnsBadge();
|
||||
OnPropertyChanged(nameof(EffectiveMaxTurns));
|
||||
QueueAgentSave();
|
||||
}
|
||||
|
||||
partial void OnTaskSystemPromptChanged(string value) => QueueAgentSave();
|
||||
partial void OnTaskSelectedAgentChanged(AgentInfo? value) { RecomputeAgentBadge(); QueueAgentSave(); }
|
||||
|
||||
private void RecomputeModelBadge()
|
||||
{
|
||||
var (value, source) = InheritanceResolver.Resolve(TaskModelSelection, _listModel, _globalModel);
|
||||
ModelInheritedHint = value;
|
||||
ModelBadge = BadgeFor(source, !string.IsNullOrWhiteSpace(TaskModelSelection));
|
||||
}
|
||||
|
||||
private void RecomputeTurnsBadge()
|
||||
{
|
||||
var (value, source) = InheritanceResolver.Resolve(
|
||||
TaskMaxTurns?.ToString(), _listMaxTurns?.ToString(), _globalMaxTurns.ToString());
|
||||
TurnsInheritedHint = value;
|
||||
TurnsBadge = BadgeFor(source, TaskMaxTurns is not null);
|
||||
}
|
||||
|
||||
private void RecomputeAgentBadge()
|
||||
{
|
||||
var taskSet = TaskSelectedAgent is not null && !string.IsNullOrWhiteSpace(TaskSelectedAgent.Path);
|
||||
var (_, source) = InheritanceResolver.Resolve(
|
||||
taskSet ? TaskSelectedAgent!.Path : null, _listAgentName, null);
|
||||
AgentBadge = BadgeFor(source, taskSet);
|
||||
}
|
||||
|
||||
private static string BadgeFor(InheritSource source, bool taskSet) => taskSet
|
||||
? Loc.T("settings.inherit.overrideBadge")
|
||||
: source == InheritSource.List
|
||||
? Loc.T("settings.inherit.inheritedFromList")
|
||||
: Loc.T("settings.inherit.inheritedFromGlobal");
|
||||
|
||||
private void QueueAgentSave()
|
||||
{
|
||||
if (_suppressAgentSave || TaskId is null) return;
|
||||
_agentSaveCts?.Cancel();
|
||||
_agentSaveCts = new CancellationTokenSource();
|
||||
_ = SaveAgentSettingsAsync(_agentSaveCts.Token);
|
||||
}
|
||||
|
||||
private async System.Threading.Tasks.Task SaveAgentSettingsAsync(CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
await System.Threading.Tasks.Task.Delay(300, ct);
|
||||
if (TaskId is null) return;
|
||||
|
||||
var model = string.IsNullOrWhiteSpace(TaskModelSelection) ? null : TaskModelSelection;
|
||||
var sp = string.IsNullOrWhiteSpace(TaskSystemPrompt) ? null : TaskSystemPrompt;
|
||||
var ap = TaskSelectedAgent is null || string.IsNullOrWhiteSpace(TaskSelectedAgent.Path)
|
||||
? null : TaskSelectedAgent.Path;
|
||||
var turns = TaskMaxTurns is decimal d ? (int?)d : null;
|
||||
|
||||
await _worker.UpdateTaskAgentSettingsAsync(
|
||||
new UpdateTaskAgentSettingsDto(TaskId, model, sp, ap, turns));
|
||||
}
|
||||
catch (OperationCanceledException) { }
|
||||
catch { }
|
||||
}
|
||||
|
||||
internal async System.Threading.Tasks.Task LoadAsync(
|
||||
ClaudeDo.Data.Models.TaskEntity entity, CancellationToken ct)
|
||||
{
|
||||
_suppressAgentSave = true;
|
||||
try
|
||||
{
|
||||
TaskAgentOptions.Clear();
|
||||
TaskAgentOptions.Add(new AgentInfo("(inherited)", "", ""));
|
||||
var agents = await _worker.GetAgentsAsync();
|
||||
foreach (var a in agents) TaskAgentOptions.Add(a);
|
||||
|
||||
TaskModelSelection = string.IsNullOrWhiteSpace(entity.Model) ? null : entity.Model!;
|
||||
TaskMaxTurns = entity.MaxTurns is int tmt ? tmt : (decimal?)null;
|
||||
TaskSystemPrompt = entity.SystemPrompt ?? "";
|
||||
TaskSelectedAgent = string.IsNullOrWhiteSpace(entity.AgentPath)
|
||||
? TaskAgentOptions[0]
|
||||
: (TaskAgentOptions.FirstOrDefault(a => a.Path == entity.AgentPath) ?? TaskAgentOptions[0]);
|
||||
|
||||
var listCfg = await _worker.GetListConfigAsync(entity.ListId);
|
||||
var app = await _worker.GetAppSettingsAsync();
|
||||
_globalModel = app?.DefaultModel ?? ModelRegistry.DefaultAlias;
|
||||
_globalMaxTurns = app?.DefaultMaxTurns ?? 100;
|
||||
_listModel = listCfg?.Model;
|
||||
_listMaxTurns = listCfg?.MaxTurns;
|
||||
_listAgentName = string.IsNullOrWhiteSpace(listCfg?.AgentPath)
|
||||
? null : System.IO.Path.GetFileName(listCfg!.AgentPath!);
|
||||
|
||||
EffectiveSystemPromptHint = string.IsNullOrWhiteSpace(listCfg?.SystemPrompt)
|
||||
? "" : listCfg!.SystemPrompt!;
|
||||
|
||||
RecomputeModelBadge();
|
||||
RecomputeTurnsBadge();
|
||||
RecomputeAgentBadge();
|
||||
OnPropertyChanged(nameof(EffectiveMaxTurns));
|
||||
}
|
||||
finally
|
||||
{
|
||||
_suppressAgentSave = false;
|
||||
}
|
||||
}
|
||||
|
||||
internal void Clear()
|
||||
{
|
||||
_suppressAgentSave = true;
|
||||
try
|
||||
{
|
||||
TaskModelSelection = null;
|
||||
TaskMaxTurns = null;
|
||||
TaskSystemPrompt = "";
|
||||
TaskSelectedAgent = null;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_suppressAgentSave = false;
|
||||
}
|
||||
EffectiveSystemPromptHint = "";
|
||||
TaskId = null;
|
||||
}
|
||||
|
||||
[RelayCommand] private void ResetTaskModel() => TaskModelSelection = null;
|
||||
[RelayCommand] private void ResetTaskTurns() => TaskMaxTurns = null;
|
||||
[RelayCommand] private void ResetTaskAgent() =>
|
||||
TaskSelectedAgent = TaskAgentOptions.Count > 0 ? TaskAgentOptions[0] : null;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -16,11 +16,11 @@ namespace ClaudeDo.Ui.ViewModels.Islands;
|
||||
|
||||
public enum ListKind { Smart, Virtual, User }
|
||||
|
||||
public sealed partial class ListsIslandViewModel : ViewModelBase
|
||||
public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable
|
||||
{
|
||||
private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory;
|
||||
private readonly IServiceProvider? _services;
|
||||
private readonly WorkerClient? _worker;
|
||||
private readonly IWorkerClient? _worker;
|
||||
private static readonly TaskListFilterRegistry _filters = new();
|
||||
|
||||
public event EventHandler? SelectionChanged;
|
||||
@@ -141,7 +141,9 @@ public sealed partial class ListsIslandViewModel : ViewModelBase
|
||||
public string MachineNameLocal => Loc.T("vm.lists.localSuffix", MachineName);
|
||||
public string UserInitials { get; }
|
||||
|
||||
public ListsIslandViewModel(IDbContextFactory<ClaudeDoDbContext> dbFactory, IServiceProvider? services = null, WorkerClient? worker = null)
|
||||
private readonly EventHandler _langChangedHandler;
|
||||
|
||||
public ListsIslandViewModel(IDbContextFactory<ClaudeDoDbContext> dbFactory, IServiceProvider? services = null, IWorkerClient? worker = null)
|
||||
{
|
||||
_dbFactory = dbFactory;
|
||||
_services = services;
|
||||
@@ -163,7 +165,13 @@ public sealed partial class ListsIslandViewModel : ViewModelBase
|
||||
_worker.ConnectionRestoredEvent += () => _ = RefreshCountsAsync();
|
||||
}
|
||||
|
||||
Loc.LanguageChanged += (_, _) => RefreshLocalizedLabels();
|
||||
_langChangedHandler = (_, _) => RefreshLocalizedLabels();
|
||||
Loc.LanguageChanged += _langChangedHandler;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Loc.LanguageChanged -= _langChangedHandler;
|
||||
}
|
||||
|
||||
private static string? SmartListNameKey(string id) => id switch
|
||||
|
||||
201
src/ClaudeDo.Ui/ViewModels/Islands/MergeSectionViewModel.cs
Normal file
201
src/ClaudeDo.Ui/ViewModels/Islands/MergeSectionViewModel.cs
Normal file
@@ -0,0 +1,201 @@
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using System.Collections.ObjectModel;
|
||||
using ClaudeDo.Ui.Services;
|
||||
using ClaudeDo.Ui.ViewModels.Modals;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace ClaudeDo.Ui.ViewModels.Islands;
|
||||
|
||||
public sealed partial class MergeSectionViewModel : ViewModelBase
|
||||
{
|
||||
private readonly IWorkerClient _worker;
|
||||
private readonly IServiceProvider _services;
|
||||
|
||||
// Context mirrored from parent, updated via Sync* methods
|
||||
internal string? TaskId { get; private set; }
|
||||
internal string? TaskTitle { get; private set; }
|
||||
private string? _worktreePath;
|
||||
private string? _worktreeBaseCommit;
|
||||
private string? _worktreeHeadCommit;
|
||||
private string? _worktreeStateLabel;
|
||||
private string? _listWorkingDir;
|
||||
private bool _isPlanningParent;
|
||||
private int _subtaskCount;
|
||||
private bool _hasChildOutcomes;
|
||||
|
||||
[ObservableProperty] private ObservableCollection<string> _mergeTargetBranches = new();
|
||||
[ObservableProperty] private string? _selectedMergeTarget;
|
||||
|
||||
[ObservableProperty]
|
||||
[NotifyPropertyChangedFor(nameof(ShowMergePreviewMuted))]
|
||||
private string _mergePreviewText = "";
|
||||
|
||||
[ObservableProperty]
|
||||
[NotifyPropertyChangedFor(nameof(ShowMergePreviewMuted))]
|
||||
private bool _mergeIsClean;
|
||||
|
||||
[ObservableProperty]
|
||||
[NotifyPropertyChangedFor(nameof(ShowMergePreviewMuted))]
|
||||
private bool _mergeIsConflict;
|
||||
|
||||
public bool ShowMergePreviewMuted =>
|
||||
!MergeIsClean && !MergeIsConflict && !string.IsNullOrEmpty(MergePreviewText);
|
||||
|
||||
public bool ShowMergeSection =>
|
||||
_worktreePath != null || _isPlanningParent || _hasChildOutcomes;
|
||||
|
||||
public Func<DiffModalViewModel, System.Threading.Tasks.Task>? ShowDiffModal { get; set; }
|
||||
public Func<MergeModalViewModel, System.Threading.Tasks.Task>? ShowMergeModal { get; set; }
|
||||
public Func<ViewModels.Planning.PlanningDiffViewModel, System.Threading.Tasks.Task>? ShowPlanningDiffModal { get; set; }
|
||||
public Func<string, string, System.Threading.Tasks.Task>? RequestConflictResolution { get; set; }
|
||||
|
||||
public MergeSectionViewModel(IWorkerClient worker, IServiceProvider services)
|
||||
{
|
||||
_worker = worker;
|
||||
_services = services;
|
||||
}
|
||||
|
||||
partial void OnSelectedMergeTargetChanged(string? value) => _ = RefreshMergePreviewAsync();
|
||||
|
||||
internal void SyncWorktree(
|
||||
string? worktreePath,
|
||||
string? worktreeBase,
|
||||
string? worktreeHead,
|
||||
string? worktreeState,
|
||||
string? listWorkDir)
|
||||
{
|
||||
_worktreePath = worktreePath;
|
||||
_worktreeBaseCommit = worktreeBase;
|
||||
_worktreeHeadCommit = worktreeHead;
|
||||
_worktreeStateLabel = worktreeState;
|
||||
_listWorkingDir = listWorkDir;
|
||||
OnPropertyChanged(nameof(ShowMergeSection));
|
||||
OpenDiffCommand.NotifyCanExecuteChanged();
|
||||
OpenWorktreeCommand.NotifyCanExecuteChanged();
|
||||
}
|
||||
|
||||
internal void SyncTaskContext(string? taskId, string? taskTitle, bool isPlanningParent)
|
||||
{
|
||||
TaskId = taskId;
|
||||
TaskTitle = taskTitle;
|
||||
_isPlanningParent = isPlanningParent;
|
||||
OnPropertyChanged(nameof(ShowMergeSection));
|
||||
}
|
||||
|
||||
internal void SyncChildOutcomes(bool hasChildOutcomes, int subtaskCount)
|
||||
{
|
||||
_hasChildOutcomes = hasChildOutcomes;
|
||||
_subtaskCount = subtaskCount;
|
||||
OnPropertyChanged(nameof(ShowMergeSection));
|
||||
ReviewCombinedDiffCommand.NotifyCanExecuteChanged();
|
||||
}
|
||||
|
||||
internal async System.Threading.Tasks.Task RefreshMergePreviewAsync()
|
||||
{
|
||||
if (TaskId is null || _worktreePath is null)
|
||||
{
|
||||
MergePreviewText = ""; MergeIsClean = false; MergeIsConflict = false;
|
||||
return;
|
||||
}
|
||||
if (_worktreeStateLabel is { } label && label != "Active")
|
||||
{
|
||||
MergePreviewText = label; MergeIsClean = false; MergeIsConflict = false;
|
||||
return;
|
||||
}
|
||||
var capturedTaskId = TaskId;
|
||||
var capturedTarget = SelectedMergeTarget;
|
||||
var dto = await _worker.PreviewMergeAsync(capturedTaskId, capturedTarget ?? "");
|
||||
if (TaskId != capturedTaskId || SelectedMergeTarget != capturedTarget) return;
|
||||
var (text, clean, conflict) = MergePreviewPresenter.Describe(dto);
|
||||
MergePreviewText = text; MergeIsClean = clean; MergeIsConflict = conflict;
|
||||
}
|
||||
|
||||
internal void Clear()
|
||||
{
|
||||
MergeTargetBranches.Clear();
|
||||
SelectedMergeTarget = null;
|
||||
MergePreviewText = "";
|
||||
MergeIsClean = false;
|
||||
MergeIsConflict = false;
|
||||
SyncWorktree(null, null, null, null, null);
|
||||
SyncTaskContext(null, null, false);
|
||||
SyncChildOutcomes(false, 0);
|
||||
}
|
||||
|
||||
[RelayCommand(CanExecute = nameof(CanReviewDiff))]
|
||||
private async System.Threading.Tasks.Task ReviewCombinedDiffAsync()
|
||||
{
|
||||
if (TaskId is null || ShowPlanningDiffModal is null) return;
|
||||
var vm = new ViewModels.Planning.PlanningDiffViewModel(_worker, TaskId, SelectedMergeTarget ?? "main");
|
||||
await vm.InitializeAsync();
|
||||
await ShowPlanningDiffModal(vm);
|
||||
}
|
||||
|
||||
private bool CanReviewDiff() => (_isPlanningParent && _subtaskCount > 0) || _hasChildOutcomes;
|
||||
|
||||
[RelayCommand(CanExecute = nameof(CanOpenDiff))]
|
||||
private async System.Threading.Tasks.Task OpenDiffAsync()
|
||||
{
|
||||
if (ShowDiffModal is null) return;
|
||||
var git = _services.GetRequiredService<ClaudeDo.Data.Git.GitService>();
|
||||
|
||||
var hasLiveWorktree =
|
||||
_worktreePath != null
|
||||
&& _worktreeStateLabel == "Active"
|
||||
&& System.IO.Directory.Exists(_worktreePath);
|
||||
|
||||
DiffModalViewModel diffVm;
|
||||
if (hasLiveWorktree)
|
||||
{
|
||||
diffVm = new DiffModalViewModel(git)
|
||||
{
|
||||
WorktreePath = _worktreePath!,
|
||||
BaseRef = _worktreeBaseCommit,
|
||||
TaskId = TaskId,
|
||||
TaskTitle = TaskTitle ?? "",
|
||||
ShowMergeModal = ShowMergeModal,
|
||||
ResolveMergeVm = () => _services.GetRequiredService<MergeModalViewModel>(),
|
||||
RequestConflictResolution = RequestConflictResolution,
|
||||
};
|
||||
}
|
||||
else if (CanDiffMergedRange)
|
||||
{
|
||||
diffVm = new DiffModalViewModel(git)
|
||||
{
|
||||
WorktreePath = _listWorkingDir!,
|
||||
BaseRef = _worktreeBaseCommit,
|
||||
HeadCommit = _worktreeHeadCommit,
|
||||
FromCommitRange = true,
|
||||
TaskId = TaskId,
|
||||
TaskTitle = TaskTitle ?? "",
|
||||
};
|
||||
}
|
||||
else return;
|
||||
|
||||
await diffVm.LoadAsync();
|
||||
await ShowDiffModal(diffVm);
|
||||
}
|
||||
|
||||
private bool CanDiffMergedRange =>
|
||||
_worktreeBaseCommit != null && _worktreeHeadCommit != null && _listWorkingDir != null;
|
||||
|
||||
private bool CanOpenDiff() => _worktreePath != null || CanDiffMergedRange;
|
||||
|
||||
[RelayCommand(CanExecute = nameof(CanOpenWorktree))]
|
||||
private void OpenWorktree()
|
||||
{
|
||||
if (_worktreePath is null) return;
|
||||
try
|
||||
{
|
||||
System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo
|
||||
{
|
||||
FileName = _worktreePath,
|
||||
UseShellExecute = true,
|
||||
});
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
|
||||
private bool CanOpenWorktree() => _worktreePath != null;
|
||||
}
|
||||
@@ -7,24 +7,15 @@ namespace ClaudeDo.Ui.ViewModels.Islands;
|
||||
|
||||
public sealed partial class NoteBulletViewModel : ViewModelBase
|
||||
{
|
||||
private readonly Func<NoteBulletViewModel, Task> _save;
|
||||
private readonly Func<NoteBulletViewModel, Task> _delete;
|
||||
|
||||
public string Id { get; }
|
||||
|
||||
[ObservableProperty] private string _text;
|
||||
|
||||
public NoteBulletViewModel(string id, string text,
|
||||
Func<NoteBulletViewModel, Task> save, Func<NoteBulletViewModel, Task> delete)
|
||||
public NoteBulletViewModel(string id, string text)
|
||||
{
|
||||
Id = id;
|
||||
_text = text;
|
||||
_save = save;
|
||||
_delete = delete;
|
||||
}
|
||||
|
||||
[RelayCommand] private Task Save() => _save(this);
|
||||
[RelayCommand] private Task Delete() => _delete(this);
|
||||
}
|
||||
|
||||
public sealed partial class NotesEditorViewModel : ViewModelBase
|
||||
@@ -57,7 +48,7 @@ public sealed partial class NotesEditorViewModel : ViewModelBase
|
||||
}
|
||||
|
||||
private NoteBulletViewModel MakeBullet(string id, string text) =>
|
||||
new(id, text, SaveBulletAsync, DeleteBulletAsync);
|
||||
new(id, text);
|
||||
|
||||
[RelayCommand]
|
||||
private async Task AddBullet()
|
||||
@@ -73,11 +64,17 @@ public sealed partial class NotesEditorViewModel : ViewModelBase
|
||||
[RelayCommand] private Task NextDay() => LoadDayAsync(CurrentDay.AddDays(1));
|
||||
[RelayCommand] private Task Today() => LoadDayAsync(DateOnly.FromDateTime(DateTime.Today));
|
||||
|
||||
private Task SaveBulletAsync(NoteBulletViewModel b) => _api.UpdateAsync(b.Id, b.Text);
|
||||
|
||||
private async Task DeleteBulletAsync(NoteBulletViewModel b)
|
||||
[RelayCommand]
|
||||
private async Task CommitBullet(NoteBulletViewModel? b)
|
||||
{
|
||||
await _api.DeleteAsync(b.Id);
|
||||
Bullets.Remove(b);
|
||||
if (b is null) return;
|
||||
var text = b.Text?.Trim() ?? "";
|
||||
if (text.Length == 0)
|
||||
{
|
||||
await _api.DeleteAsync(b.Id);
|
||||
Bullets.Remove(b);
|
||||
return;
|
||||
}
|
||||
await _api.UpdateAsync(b.Id, text);
|
||||
}
|
||||
}
|
||||
|
||||
102
src/ClaudeDo.Ui/ViewModels/Islands/PrepPanelViewModel.cs
Normal file
102
src/ClaudeDo.Ui/ViewModels/Islands/PrepPanelViewModel.cs
Normal file
@@ -0,0 +1,102 @@
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Text;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using ClaudeDo.Ui.Helpers;
|
||||
using ClaudeDo.Ui.Services;
|
||||
|
||||
namespace ClaudeDo.Ui.ViewModels.Islands;
|
||||
|
||||
public sealed partial class PrepPanelViewModel : ViewModelBase, IDisposable
|
||||
{
|
||||
private readonly IWorkerClient _worker;
|
||||
private readonly StreamLineFormatter _formatter = new();
|
||||
private readonly StringBuilder _prepClaudeBuf = new();
|
||||
|
||||
private readonly Action _onPrepStartedHandler;
|
||||
private readonly Action<string> _onPrepLineHandler;
|
||||
private readonly Action<bool> _onPrepFinishedHandler;
|
||||
|
||||
[ObservableProperty] private bool _isPrepRunning;
|
||||
|
||||
public ObservableCollection<LogLineViewModel> PrepLog { get; } = new();
|
||||
|
||||
public bool ShowPrepEmptyState => !IsPrepRunning && PrepLog.Count == 0;
|
||||
|
||||
partial void OnIsPrepRunningChanged(bool value) => OnPropertyChanged(nameof(ShowPrepEmptyState));
|
||||
|
||||
public PrepPanelViewModel(IWorkerClient worker)
|
||||
{
|
||||
_worker = worker;
|
||||
_onPrepStartedHandler = OnPrepStarted;
|
||||
_onPrepLineHandler = OnPrepLine;
|
||||
_onPrepFinishedHandler = OnPrepFinished;
|
||||
|
||||
_worker.PrepStartedEvent += _onPrepStartedHandler;
|
||||
_worker.PrepLineEvent += _onPrepLineHandler;
|
||||
_worker.PrepFinishedEvent += _onPrepFinishedHandler;
|
||||
|
||||
PrepLog.CollectionChanged += (_, _) => OnPropertyChanged(nameof(ShowPrepEmptyState));
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_worker.PrepStartedEvent -= _onPrepStartedHandler;
|
||||
_worker.PrepLineEvent -= _onPrepLineHandler;
|
||||
_worker.PrepFinishedEvent -= _onPrepFinishedHandler;
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private async System.Threading.Tasks.Task PlanDayAsync()
|
||||
{
|
||||
try { await _worker.RunDailyPrepNowAsync(); }
|
||||
catch { }
|
||||
}
|
||||
|
||||
public async System.Threading.Tasks.Task LoadLastPrepLogIfEmptyAsync()
|
||||
{
|
||||
if (IsPrepRunning || PrepLog.Count > 0) return;
|
||||
string text;
|
||||
try { text = await _worker.GetLastPrepLogAsync(); }
|
||||
catch { return; }
|
||||
if (IsPrepRunning || PrepLog.Count > 0) return;
|
||||
foreach (var line in text.Split('\n'))
|
||||
{
|
||||
var trimmed = line.TrimEnd('\r');
|
||||
if (trimmed.Length > 0) AppendStdoutLine(trimmed);
|
||||
}
|
||||
}
|
||||
|
||||
private void OnPrepStarted()
|
||||
{
|
||||
PrepLog.Clear();
|
||||
IsPrepRunning = true;
|
||||
}
|
||||
|
||||
private void OnPrepLine(string line) => AppendStdoutLine(line);
|
||||
|
||||
private void OnPrepFinished(bool success) => IsPrepRunning = false;
|
||||
|
||||
private void AppendStdoutLine(string line)
|
||||
{
|
||||
var formatted = _formatter.FormatLine(line);
|
||||
if (formatted is null) return;
|
||||
AppendClaudeText(formatted);
|
||||
}
|
||||
|
||||
private void AppendClaudeText(string chunk)
|
||||
{
|
||||
_prepClaudeBuf.Append(chunk);
|
||||
while (true)
|
||||
{
|
||||
var text = _prepClaudeBuf.ToString();
|
||||
var nl = text.IndexOf('\n');
|
||||
if (nl < 0) break;
|
||||
var piece = text[..nl].TrimEnd('\r');
|
||||
if (!string.IsNullOrWhiteSpace(piece))
|
||||
PrepLog.Add(new LogLineViewModel { Kind = LogKind.Claude, Text = piece });
|
||||
_prepClaudeBuf.Clear();
|
||||
_prepClaudeBuf.Append(text[(nl + 1)..]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,7 @@ public sealed partial class TaskRowViewModel : ViewModelBase
|
||||
[ObservableProperty] private PlanningPhase _planningPhase;
|
||||
[ObservableProperty] private string? _branch;
|
||||
[ObservableProperty] private string? _diffStat;
|
||||
[ObservableProperty] private ClaudeDo.Data.Models.WorktreeState? _worktreeState;
|
||||
[ObservableProperty] private DateTime? _scheduledFor;
|
||||
[ObservableProperty] private int _diffAdditions;
|
||||
[ObservableProperty] private int _diffDeletions;
|
||||
@@ -31,6 +32,7 @@ public sealed partial class TaskRowViewModel : ViewModelBase
|
||||
[ObservableProperty] private bool _hasQueuedSubtasks;
|
||||
[ObservableProperty] private bool _showListChip = true;
|
||||
[ObservableProperty] private bool _parentFinalized;
|
||||
[ObservableProperty] private bool _parentInView = true;
|
||||
[ObservableProperty] private int _roadblockCount;
|
||||
[ObservableProperty] private bool _isRefining;
|
||||
|
||||
@@ -46,9 +48,13 @@ public sealed partial class TaskRowViewModel : ViewModelBase
|
||||
public bool IsAgentSuggested => IsChild && !string.IsNullOrEmpty(CreatedBy) && CreatedBy == ParentTaskId;
|
||||
public bool IsPlanningParent => PlanningPhase != PlanningPhase.None
|
||||
|| HasPlanningChildren;
|
||||
// A child only reads as a child while its parent shares the current view. When the parent is
|
||||
// absent (removed from My Day, or daily-prep placed a lone child there), the row renders as a
|
||||
// normal top-level task instead of an orphaned, indented Draft.
|
||||
public bool ShowAsChild => IsChild && ParentInView;
|
||||
// A subtask is Draft until its planning parent is finalized, then Planned (queueable).
|
||||
public bool IsDraft => IsChild && Status == TaskStatus.Idle && !ParentFinalized;
|
||||
public bool IsPlanned => IsChild && Status == TaskStatus.Idle && ParentFinalized;
|
||||
public bool IsDraft => ShowAsChild && Status == TaskStatus.Idle && !ParentFinalized;
|
||||
public bool IsPlanned => ShowAsChild && Status == TaskStatus.Idle && ParentFinalized;
|
||||
|
||||
public bool CanOpenPlanningSession => Status == TaskStatus.Idle
|
||||
&& PlanningPhase == PlanningPhase.None
|
||||
@@ -71,16 +77,28 @@ public sealed partial class TaskRowViewModel : ViewModelBase
|
||||
public bool IsOverdue => ScheduledFor is { } d && d.Date < DateTime.Today && !Done;
|
||||
public bool IsRunning => Status == TaskStatus.Running;
|
||||
public bool IsWaitingForReview => Status == TaskStatus.WaitingForReview;
|
||||
// Parked = set aside from review: Idle but still holding its Active worktree (vs a plain Idle task).
|
||||
public bool IsParked => Status == TaskStatus.Idle && WorktreeState == ClaudeDo.Data.Models.WorktreeState.Active;
|
||||
public bool IsQueued => Status == TaskStatus.Queued && string.IsNullOrEmpty(BlockedByTaskId);
|
||||
public bool IsWaiting => Status == TaskStatus.Queued && !string.IsNullOrEmpty(BlockedByTaskId);
|
||||
public bool CanRemoveFromQueue => IsQueued || HasQueuedSubtasks;
|
||||
// "Send to queue" is the single queue entry. On a finalized planning parent it queues the
|
||||
// plan (children) via CanQueuePlan; an Active (not-yet-finalized) planning parent is hidden —
|
||||
// it must be finalized first.
|
||||
public bool CanSendToQueue => !IsRunning && !IsQueued && !IsWaitingForReview && !HasQueuedSubtasks
|
||||
&& (!IsChild || ParentFinalized);
|
||||
&& (!IsChild || ParentFinalized)
|
||||
&& PlanningPhase != PlanningPhase.Active;
|
||||
// Parent-level "send plan to queue" — only once the plan is finalized (children Planned).
|
||||
// Drives the routing inside SendToQueue, not a separate menu entry.
|
||||
public bool CanQueuePlan => !IsChild && HasPlanningChildren
|
||||
&& PlanningPhase == PlanningPhase.Finalized
|
||||
&& !HasQueuedSubtasks;
|
||||
// User-triggered finalize for a planning parent whose session was closed before finalizing.
|
||||
public bool CanFinalizePlanning => PlanningPhase == PlanningPhase.Active && !IsChild;
|
||||
public bool HasSchedule => ScheduledFor.HasValue;
|
||||
// "Add to My Day" — shown on any task not already in My Day; a Done task has no place in
|
||||
// today's focus list. The mirror of "Remove from My Day" (gated on IsMyDay).
|
||||
public bool CanAddToMyDay => !IsMyDay && !Done;
|
||||
public bool HasRoadblock => RoadblockCount > 0;
|
||||
public string RoadblockTooltip => RoadblockCount == 1
|
||||
? "1 roadblock reported during the run — see details"
|
||||
@@ -90,7 +108,7 @@ public sealed partial class TaskRowViewModel : ViewModelBase
|
||||
public string DiffDeletionsText => $"−{DiffDeletions}";
|
||||
public string StepsText => Loc.T("vm.taskRow.stepsText", StepsCompleted, StepsCount);
|
||||
|
||||
public string StatusLabel => Status switch
|
||||
public string StatusLabel => IsParked ? Loc.T("vm.taskStatus.parked") : Status switch
|
||||
{
|
||||
TaskStatus.Idle => Loc.T("vm.taskStatus.idle"),
|
||||
TaskStatus.Queued => Loc.T("vm.taskStatus.queued"),
|
||||
@@ -121,6 +139,7 @@ public sealed partial class TaskRowViewModel : ViewModelBase
|
||||
OnPropertyChanged(nameof(StatusLabel));
|
||||
OnPropertyChanged(nameof(IsRunning));
|
||||
OnPropertyChanged(nameof(IsWaitingForReview));
|
||||
OnPropertyChanged(nameof(IsParked));
|
||||
OnPropertyChanged(nameof(IsQueued));
|
||||
OnPropertyChanged(nameof(IsWaiting));
|
||||
OnPropertyChanged(nameof(IsDraft));
|
||||
@@ -135,12 +154,20 @@ public sealed partial class TaskRowViewModel : ViewModelBase
|
||||
{
|
||||
OnPropertyChanged(nameof(IsChild));
|
||||
OnPropertyChanged(nameof(IsAgentSuggested));
|
||||
OnPropertyChanged(nameof(ShowAsChild));
|
||||
OnPropertyChanged(nameof(IsDraft));
|
||||
OnPropertyChanged(nameof(IsPlanned));
|
||||
OnPropertyChanged(nameof(CanSendToQueue));
|
||||
OnPropertyChanged(nameof(CanOpenPlanningSession));
|
||||
}
|
||||
|
||||
partial void OnParentInViewChanged(bool value)
|
||||
{
|
||||
OnPropertyChanged(nameof(ShowAsChild));
|
||||
OnPropertyChanged(nameof(IsDraft));
|
||||
OnPropertyChanged(nameof(IsPlanned));
|
||||
}
|
||||
|
||||
partial void OnCreatedByChanged(string? value) => OnPropertyChanged(nameof(IsAgentSuggested));
|
||||
|
||||
partial void OnParentFinalizedChanged(bool value)
|
||||
@@ -159,6 +186,8 @@ public sealed partial class TaskRowViewModel : ViewModelBase
|
||||
OnPropertyChanged(nameof(CanOpenPlanningSession));
|
||||
OnPropertyChanged(nameof(CanResumeOrDiscardPlanning));
|
||||
OnPropertyChanged(nameof(CanQueuePlan));
|
||||
OnPropertyChanged(nameof(CanSendToQueue));
|
||||
OnPropertyChanged(nameof(CanFinalizePlanning));
|
||||
OnPropertyChanged(nameof(CanRefine));
|
||||
}
|
||||
|
||||
@@ -185,7 +214,17 @@ public sealed partial class TaskRowViewModel : ViewModelBase
|
||||
}
|
||||
|
||||
partial void OnBranchChanged(string? value) => OnPropertyChanged(nameof(HasBranch));
|
||||
partial void OnDoneChanged(bool value) => OnPropertyChanged(nameof(IsOverdue));
|
||||
partial void OnWorktreeStateChanged(ClaudeDo.Data.Models.WorktreeState? value)
|
||||
{
|
||||
OnPropertyChanged(nameof(IsParked));
|
||||
OnPropertyChanged(nameof(StatusLabel));
|
||||
}
|
||||
partial void OnDoneChanged(bool value)
|
||||
{
|
||||
OnPropertyChanged(nameof(IsOverdue));
|
||||
OnPropertyChanged(nameof(CanAddToMyDay));
|
||||
}
|
||||
partial void OnIsMyDayChanged(bool value) => OnPropertyChanged(nameof(CanAddToMyDay));
|
||||
partial void OnScheduledForChanged(DateTime? value)
|
||||
{
|
||||
OnPropertyChanged(nameof(IsOverdue));
|
||||
@@ -222,6 +261,7 @@ public sealed partial class TaskRowViewModel : ViewModelBase
|
||||
PlanningPhase = t.PlanningPhase;
|
||||
Branch = t.Worktree?.BranchName;
|
||||
DiffStat = t.Worktree?.DiffStat;
|
||||
WorktreeState = t.Worktree?.State;
|
||||
ScheduledFor = t.ScheduledFor;
|
||||
DiffAdditions = add;
|
||||
DiffDeletions = del;
|
||||
|
||||
@@ -14,7 +14,7 @@ using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
|
||||
|
||||
namespace ClaudeDo.Ui.ViewModels.Islands;
|
||||
|
||||
public sealed partial class TasksIslandViewModel : ViewModelBase
|
||||
public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
|
||||
{
|
||||
private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory;
|
||||
private readonly IWorkerClient? _worker;
|
||||
@@ -71,6 +71,8 @@ public sealed partial class TasksIslandViewModel : ViewModelBase
|
||||
|
||||
public Func<UnfinishedPlanningModalViewModel, Task>? ShowUnfinishedPlanningModal { get; set; }
|
||||
|
||||
private readonly EventHandler _langChangedHandler;
|
||||
|
||||
public TasksIslandViewModel(IDbContextFactory<ClaudeDoDbContext> dbFactory, IWorkerClient? worker = null)
|
||||
{
|
||||
_dbFactory = dbFactory;
|
||||
@@ -85,7 +87,13 @@ public sealed partial class TasksIslandViewModel : ViewModelBase
|
||||
_worker.RefineStartedEvent += OnRefineStarted;
|
||||
_worker.RefineFinishedEvent += OnRefineFinished;
|
||||
}
|
||||
Loc.LanguageChanged += (_, _) => RefreshLocalizedText();
|
||||
_langChangedHandler = (_, _) => RefreshLocalizedText();
|
||||
Loc.LanguageChanged += _langChangedHandler;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Loc.LanguageChanged -= _langChangedHandler;
|
||||
}
|
||||
|
||||
private void RefreshLocalizedText()
|
||||
@@ -178,7 +186,7 @@ public sealed partial class TasksIslandViewModel : ViewModelBase
|
||||
ListKind.Smart when list.Id == "smart:my-day" => t.IsMyDay,
|
||||
ListKind.Smart when list.Id == "smart:important" => t.IsStarred,
|
||||
ListKind.Smart when list.Id == "smart:planned" => t.ScheduledFor != null,
|
||||
ListKind.Virtual when list.Id == "virtual:review" => t.Status == TaskStatus.Done && t.Worktree?.State == WorktreeState.Active && t.ParentTaskId == null,
|
||||
ListKind.Virtual when list.Id == "virtual:review" => t.Status == TaskStatus.WaitingForReview,
|
||||
ListKind.User => $"user:{t.ListId}" == list.Id,
|
||||
_ => false,
|
||||
};
|
||||
@@ -326,6 +334,10 @@ public sealed partial class TasksIslandViewModel : ViewModelBase
|
||||
// Items is already ordered by SortOrder from the DB query.
|
||||
// Treat rows whose ParentTaskId is not in the current view as orphans -> top-level.
|
||||
var visibleIds = Items.Select(r => r.Id).ToHashSet();
|
||||
// A child reads as a child only while its parent is in the view. Flag orphans so they
|
||||
// render flat (no indent, no Draft/Planned badge) instead of breaking the layout.
|
||||
foreach (var r in Items)
|
||||
r.ParentInView = string.IsNullOrEmpty(r.ParentTaskId) || visibleIds.Contains(r.ParentTaskId!);
|
||||
bool IsTopLevel(TaskRowViewModel r) =>
|
||||
!r.IsChild
|
||||
|| string.IsNullOrEmpty(r.ParentTaskId)
|
||||
@@ -563,6 +575,52 @@ public sealed partial class TasksIslandViewModel : ViewModelBase
|
||||
TasksChanged?.Invoke(this, EventArgs.Empty);
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private async Task AddToMyDayAsync(TaskRowViewModel? row)
|
||||
{
|
||||
if (row is null || row.IsMyDay) return;
|
||||
row.IsMyDay = true;
|
||||
await using var db = await _dbFactory.CreateDbContextAsync();
|
||||
var entity = await db.Tasks.FirstOrDefaultAsync(t => t.Id == row.Id);
|
||||
if (entity != null)
|
||||
{
|
||||
entity.IsMyDay = true;
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
Regroup();
|
||||
UpdateSubtitle();
|
||||
TasksChanged?.Invoke(this, EventArgs.Empty);
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private async Task RemoveFromMyDayAsync(TaskRowViewModel? row)
|
||||
{
|
||||
if (row is null) return;
|
||||
row.IsMyDay = false;
|
||||
await using var db = await _dbFactory.CreateDbContextAsync();
|
||||
// Removing a parent takes its whole plan off My Day: clear the task and every child, so no
|
||||
// orphaned child is left behind (independently-IsMyDay children included). A leaf child has
|
||||
// no children of its own, so this collapses to just clearing the row itself.
|
||||
var affected = await db.Tasks
|
||||
.Where(t => t.Id == row.Id || t.ParentTaskId == row.Id)
|
||||
.ToListAsync();
|
||||
foreach (var t in affected)
|
||||
t.IsMyDay = false;
|
||||
if (affected.Count > 0)
|
||||
await db.SaveChangesAsync();
|
||||
if (_currentList?.Id == "smart:my-day")
|
||||
{
|
||||
var drop = Items
|
||||
.Where(r => r.Id == row.Id || r.ParentTaskId == row.Id)
|
||||
.ToList();
|
||||
foreach (var r in drop)
|
||||
Items.Remove(r);
|
||||
}
|
||||
Regroup();
|
||||
UpdateSubtitle();
|
||||
TasksChanged?.Invoke(this, EventArgs.Empty);
|
||||
}
|
||||
|
||||
public async Task SetStatusOnRowAsync(TaskRowViewModel row, TaskStatus status)
|
||||
{
|
||||
if (_worker is null) return;
|
||||
@@ -574,6 +632,12 @@ public sealed partial class TasksIslandViewModel : ViewModelBase
|
||||
private async Task SendToQueueAsync(TaskRowViewModel? row)
|
||||
{
|
||||
if (row is null || row.IsRunning) return;
|
||||
// A finalized planning parent queues its plan (children sequentially), not itself.
|
||||
if (row.CanQueuePlan)
|
||||
{
|
||||
await QueuePlanningSubtasksAsync(row);
|
||||
return;
|
||||
}
|
||||
await using var db = await _dbFactory.CreateDbContextAsync();
|
||||
var entity = await db.Tasks.FirstOrDefaultAsync(t => t.Id == row.Id);
|
||||
if (entity is null) return;
|
||||
|
||||
@@ -15,12 +15,12 @@ using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ClaudeDo.Ui.ViewModels;
|
||||
|
||||
public sealed partial class IslandsShellViewModel : ViewModelBase
|
||||
public sealed partial class IslandsShellViewModel : ViewModelBase, IDisposable
|
||||
{
|
||||
public ListsIslandViewModel? Lists { get; }
|
||||
public TasksIslandViewModel? Tasks { get; }
|
||||
public DetailsIslandViewModel? Details { get; }
|
||||
public WorkerClient? Worker { get; }
|
||||
public IWorkerClient? Worker { get; }
|
||||
public UpdateCheckService UpdateCheck => _updateCheck;
|
||||
|
||||
public string ConnectionText =>
|
||||
@@ -41,8 +41,19 @@ public sealed partial class IslandsShellViewModel : ViewModelBase
|
||||
|
||||
public Func<MergeModalViewModel> ResolveMergeVm => _mergeVmFactory;
|
||||
|
||||
// Set by MainWindow to open the conflict resolution dialog.
|
||||
public Func<ConflictResolutionViewModel, Task>? ShowConflictDialog { get; set; }
|
||||
// Layer C seam: composition root sets the factory; MainWindow sets the dialog opener.
|
||||
// The integrator connects Layer A/B's RequestConflictResolution(taskId, target) to this method.
|
||||
public Func<string, ClaudeDo.Ui.ViewModels.Conflicts.ConflictResolverViewModel>? ConflictResolverFactory { get; set; }
|
||||
public Func<ClaudeDo.Ui.ViewModels.Conflicts.ConflictResolverViewModel, Task>? ShowConflictResolver { get; set; }
|
||||
|
||||
public async Task RequestConflictResolutionAsync(string taskId, string targetBranch)
|
||||
{
|
||||
if (ConflictResolverFactory is null || ShowConflictResolver is null) return;
|
||||
var vm = ConflictResolverFactory(taskId);
|
||||
var hasConflicts = await vm.OpenAsync(targetBranch);
|
||||
if (hasConflicts)
|
||||
await ShowConflictResolver(vm);
|
||||
}
|
||||
|
||||
// Set by MainWindow to open the About dialog.
|
||||
public Func<AboutModalViewModel, Task>? ShowAboutModal { get; set; }
|
||||
@@ -132,42 +143,17 @@ public sealed partial class IslandsShellViewModel : ViewModelBase
|
||||
private void OnPlanningMergeConflict(string planningTaskId, string subtaskId, IReadOnlyList<string> conflictedFiles)
|
||||
{
|
||||
// Already on UI thread (WorkerClient dispatches via Dispatcher.UIThread.Post).
|
||||
_ = OpenConflictDialogAsync(planningTaskId, subtaskId, conflictedFiles);
|
||||
// A unit-merge conflict resolves in the same in-app 3-way editor as a single-task merge.
|
||||
_ = OpenPlanningConflictAsync(planningTaskId, subtaskId);
|
||||
}
|
||||
|
||||
private async Task OpenConflictDialogAsync(string planningTaskId, string subtaskId, IReadOnlyList<string> conflictedFiles)
|
||||
private async Task OpenPlanningConflictAsync(string planningTaskId, string subtaskId)
|
||||
{
|
||||
if (ShowConflictDialog == null || _dbFactory == null) return;
|
||||
|
||||
string subtaskTitle = subtaskId;
|
||||
string worktreePath = System.Environment.CurrentDirectory;
|
||||
string targetBranch = Worker?.LastMergeAllTarget ?? "main";
|
||||
|
||||
try
|
||||
{
|
||||
await using var ctx = await _dbFactory.CreateDbContextAsync();
|
||||
var entity = await ctx.Tasks
|
||||
.Include(t => t.Worktree)
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(t => t.Id == subtaskId);
|
||||
if (entity != null)
|
||||
{
|
||||
subtaskTitle = entity.Title;
|
||||
if (entity.Worktree?.Path is { } p)
|
||||
worktreePath = p;
|
||||
}
|
||||
}
|
||||
catch { /* Non-fatal: fall back to subtaskId and cwd */ }
|
||||
|
||||
var vm = new ConflictResolutionViewModel(
|
||||
Worker!,
|
||||
planningTaskId,
|
||||
subtaskTitle,
|
||||
targetBranch,
|
||||
conflictedFiles,
|
||||
worktreePath);
|
||||
|
||||
await ShowConflictDialog(vm);
|
||||
if (ConflictResolverFactory is null || ShowConflictResolver is null) return;
|
||||
var vm = ConflictResolverFactory(subtaskId);
|
||||
var hasConflicts = await vm.OpenForPlanningAsync(planningTaskId, subtaskId);
|
||||
if (hasConflicts)
|
||||
await ShowConflictResolver(vm);
|
||||
}
|
||||
|
||||
// For tests only — does NOT wire up events.
|
||||
@@ -177,7 +163,7 @@ public sealed partial class IslandsShellViewModel : ViewModelBase
|
||||
ListsIslandViewModel lists,
|
||||
TasksIslandViewModel tasks,
|
||||
DetailsIslandViewModel details,
|
||||
WorkerClient worker,
|
||||
IWorkerClient worker,
|
||||
UpdateCheckService updateCheck,
|
||||
InstallerLocator installerLocator,
|
||||
WorkerLocator workerLocator,
|
||||
@@ -213,9 +199,10 @@ public sealed partial class IslandsShellViewModel : ViewModelBase
|
||||
_ = Lists.RefreshCountsAsync();
|
||||
return System.Threading.Tasks.Task.CompletedTask;
|
||||
};
|
||||
Details.RequestConflictResolution = RequestConflictResolutionAsync;
|
||||
Worker.PropertyChanged += (_, e) =>
|
||||
{
|
||||
if (e.PropertyName is nameof(WorkerClient.IsConnected) or nameof(WorkerClient.IsReconnecting))
|
||||
if (e.PropertyName is nameof(IWorkerClient.IsConnected) or nameof(IWorkerClient.IsReconnecting))
|
||||
{
|
||||
OnPropertyChanged(nameof(ConnectionText));
|
||||
OnPropertyChanged(nameof(IsOffline));
|
||||
@@ -253,6 +240,16 @@ public sealed partial class IslandsShellViewModel : ViewModelBase
|
||||
});
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_clearTimer.Stop();
|
||||
_clearTimer.Dispose();
|
||||
_connectTimer.Stop();
|
||||
_connectTimer.Dispose();
|
||||
_primeStatusTimer.Stop();
|
||||
_primeStatusTimer.Dispose();
|
||||
}
|
||||
|
||||
private void RefreshBannerFromStatus()
|
||||
{
|
||||
switch (_updateCheck.LastCheckStatus)
|
||||
|
||||
@@ -8,6 +8,8 @@ namespace ClaudeDo.Ui.ViewModels.Modals;
|
||||
|
||||
public enum DiffLineKind { Add, Del, Ctx, File }
|
||||
|
||||
public enum DiffFileStatus { Modified, Added, Deleted, Renamed }
|
||||
|
||||
public sealed class DiffLineViewModel
|
||||
{
|
||||
public required DiffLineKind Kind { get; init; }
|
||||
@@ -32,10 +34,27 @@ public sealed class DiffLineViewModel
|
||||
|
||||
public sealed class DiffFileViewModel
|
||||
{
|
||||
public required string Path { get; init; }
|
||||
public required string Path { get; set; }
|
||||
public string? OldPath { get; set; }
|
||||
public DiffFileStatus Status { get; set; } = DiffFileStatus.Modified;
|
||||
public bool IsBinary { get; set; }
|
||||
public int Additions { get; set; }
|
||||
public int Deletions { get; set; }
|
||||
public ObservableCollection<DiffLineViewModel> Lines { get; } = new();
|
||||
|
||||
/// Single-letter badge for the file's change kind (A/M/D/R).
|
||||
public string StatusCode => Status switch
|
||||
{
|
||||
DiffFileStatus.Added => "A",
|
||||
DiffFileStatus.Deleted => "D",
|
||||
DiffFileStatus.Renamed => "R",
|
||||
_ => "M",
|
||||
};
|
||||
|
||||
public bool HasLines => Lines.Count > 0;
|
||||
|
||||
/// A text file that produced no diff hunks (e.g. a newly added empty file).
|
||||
public bool IsEmptyContent => !IsBinary && Lines.Count == 0;
|
||||
}
|
||||
|
||||
public sealed partial class DiffModalViewModel : ViewModelBase
|
||||
@@ -44,10 +63,16 @@ public sealed partial class DiffModalViewModel : ViewModelBase
|
||||
|
||||
public required string WorktreePath { get; init; }
|
||||
public string? BaseRef { get; init; }
|
||||
/// When set together with <see cref="FromCommitRange"/>, the diff is computed as
|
||||
/// <c>BaseRef..HeadCommit</c> inside <see cref="WorktreePath"/> (used as the repo
|
||||
/// dir) — lets a merged task's diff be viewed after its worktree is gone.
|
||||
public string? HeadCommit { get; init; }
|
||||
public bool FromCommitRange { get; init; }
|
||||
public string? TaskId { get; init; }
|
||||
public string TaskTitle { get; init; } = "";
|
||||
public Func<MergeModalViewModel, Task>? ShowMergeModal { get; set; }
|
||||
public Func<MergeModalViewModel>? ResolveMergeVm { get; set; }
|
||||
public Func<string, string, Task>? RequestConflictResolution { get; set; }
|
||||
|
||||
public ObservableCollection<DiffFileViewModel> Files { get; } = new();
|
||||
|
||||
@@ -75,8 +100,11 @@ public sealed partial class DiffModalViewModel : ViewModelBase
|
||||
{
|
||||
if (TaskId is null || ShowMergeModal is null || ResolveMergeVm is null) return;
|
||||
var vm = ResolveMergeVm();
|
||||
vm.RequestConflictResolution = RequestConflictResolution;
|
||||
await vm.InitializeAsync(TaskId, TaskTitle);
|
||||
await ShowMergeModal(vm);
|
||||
// The diff is stale once the worktree merged away or a conflict opened the editor.
|
||||
if (vm.Merged || vm.RoutedToResolver) CloseAction?.Invoke();
|
||||
}
|
||||
|
||||
public async Task LoadAsync(CancellationToken ct = default)
|
||||
@@ -84,12 +112,20 @@ public sealed partial class DiffModalViewModel : ViewModelBase
|
||||
Files.Clear();
|
||||
StatusMessage = null;
|
||||
|
||||
if (FromCommitRange && (BaseRef is null || HeadCommit is null))
|
||||
{
|
||||
StatusMessage = Loc.T("vm.diff.unavailable");
|
||||
return;
|
||||
}
|
||||
|
||||
string raw;
|
||||
try
|
||||
{
|
||||
raw = BaseRef is not null
|
||||
? await _git.GetBranchDiffAsync(WorktreePath, BaseRef, ct)
|
||||
: await _git.GetDiffAsync(WorktreePath, ct);
|
||||
raw = FromCommitRange && BaseRef is not null && HeadCommit is not null
|
||||
? await _git.GetCommitRangeDiffAsync(WorktreePath, BaseRef, HeadCommit, ct)
|
||||
: BaseRef is not null
|
||||
? await _git.GetBranchDiffAsync(WorktreePath, BaseRef, ct)
|
||||
: await _git.GetDiffAsync(WorktreePath, ct);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
|
||||
@@ -12,7 +12,7 @@ namespace ClaudeDo.Ui.ViewModels.Modals;
|
||||
|
||||
public sealed partial class ListSettingsModalViewModel : ViewModelBase
|
||||
{
|
||||
private readonly WorkerClient _worker;
|
||||
private readonly IWorkerClient _worker;
|
||||
private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory;
|
||||
|
||||
public string ListId { get; set; } = "";
|
||||
@@ -50,7 +50,7 @@ public sealed partial class ListSettingsModalViewModel : ViewModelBase
|
||||
|
||||
public Action? CloseAction { get; set; }
|
||||
|
||||
public ListSettingsModalViewModel(WorkerClient worker, IDbContextFactory<ClaudeDoDbContext> dbFactory)
|
||||
public ListSettingsModalViewModel(IWorkerClient worker, IDbContextFactory<ClaudeDoDbContext> dbFactory)
|
||||
{
|
||||
_worker = worker;
|
||||
_dbFactory = dbFactory;
|
||||
|
||||
@@ -8,7 +8,7 @@ namespace ClaudeDo.Ui.ViewModels.Modals;
|
||||
|
||||
public sealed partial class MergeModalViewModel : ViewModelBase
|
||||
{
|
||||
private readonly WorkerClient _worker;
|
||||
private readonly IWorkerClient _worker;
|
||||
|
||||
public string TaskId { get; set; } = "";
|
||||
public string TaskTitle { get; set; } = "";
|
||||
@@ -28,7 +28,18 @@ public sealed partial class MergeModalViewModel : ViewModelBase
|
||||
|
||||
public Action? CloseAction { get; set; }
|
||||
|
||||
public MergeModalViewModel(WorkerClient worker)
|
||||
/// Set by the caller to hand a conflicting merge off to the in-app 3-pane editor
|
||||
/// instead of dead-ending on the conflict message.
|
||||
public Func<string, string, Task>? RequestConflictResolution { 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; }
|
||||
|
||||
/// True once a conflict has been handed off to the resolver — also a cue to close the diff window.
|
||||
public bool RoutedToResolver { get; private set; }
|
||||
|
||||
public MergeModalViewModel(IWorkerClient worker)
|
||||
{
|
||||
_worker = worker;
|
||||
}
|
||||
@@ -80,6 +91,7 @@ public sealed partial class MergeModalViewModel : ViewModelBase
|
||||
switch (result.Status)
|
||||
{
|
||||
case "merged":
|
||||
Merged = true;
|
||||
SuccessMessage = result.ErrorMessage is not null
|
||||
? $"Merged with warning: {result.ErrorMessage}"
|
||||
: Loc.T("vm.merge.merged");
|
||||
@@ -91,9 +103,21 @@ public sealed partial class MergeModalViewModel : ViewModelBase
|
||||
});
|
||||
break;
|
||||
case "conflict":
|
||||
HasConflict = true;
|
||||
ConflictFiles = result.ConflictFiles;
|
||||
ErrorMessage = Loc.T("vm.merge.conflict");
|
||||
// Hand off to the in-app 3-pane merge editor when wired (MergeTask aborted
|
||||
// cleanly, so the resolver re-starts the merge leaving conflicts in the tree).
|
||||
if (RequestConflictResolution is not null)
|
||||
{
|
||||
var branch = SelectedBranch!;
|
||||
RoutedToResolver = true;
|
||||
CloseAction?.Invoke();
|
||||
await RequestConflictResolution(TaskId, branch);
|
||||
}
|
||||
else
|
||||
{
|
||||
HasConflict = true;
|
||||
ConflictFiles = result.ConflictFiles;
|
||||
ErrorMessage = Loc.T("vm.merge.conflict");
|
||||
}
|
||||
break;
|
||||
case "blocked":
|
||||
ErrorMessage = Loc.T("vm.merge.blocked", result.ErrorMessage ?? "");
|
||||
|
||||
@@ -9,7 +9,7 @@ namespace ClaudeDo.Ui.ViewModels.Modals.Settings;
|
||||
|
||||
public sealed partial class FilesSettingsTabViewModel : ViewModelBase
|
||||
{
|
||||
private readonly WorkerClient _worker;
|
||||
private readonly IWorkerClient _worker;
|
||||
|
||||
[ObservableProperty] private string _statusMessage = "";
|
||||
[ObservableProperty] private bool _isBusy;
|
||||
@@ -21,7 +21,7 @@ public sealed partial class FilesSettingsTabViewModel : ViewModelBase
|
||||
public string DailyPrepPromptPath { get; } = PromptFiles.PathFor(PromptKind.DailyPrep);
|
||||
public string WeeklyReportPromptPath { get; } = PromptFiles.PathFor(PromptKind.WeeklyReport);
|
||||
|
||||
public FilesSettingsTabViewModel(WorkerClient worker) => _worker = worker;
|
||||
public FilesSettingsTabViewModel(IWorkerClient worker) => _worker = worker;
|
||||
|
||||
[RelayCommand]
|
||||
private async Task RestoreDefaultAgents()
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
using ClaudeDo.Ui.Localization;
|
||||
using ClaudeDo.Ui.Services;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
|
||||
namespace ClaudeDo.Ui.ViewModels.Modals.Settings;
|
||||
|
||||
public sealed partial class OnlineInboxSettingsViewModel : ViewModelBase
|
||||
{
|
||||
private readonly IWorkerClient _worker;
|
||||
private readonly IOnlineLoginService _loginService;
|
||||
|
||||
[ObservableProperty] private bool _enabled;
|
||||
[ObservableProperty] private string _apiBaseUrl = "";
|
||||
[ObservableProperty] private string _authority = "";
|
||||
[ObservableProperty] private string _clientId = "";
|
||||
[ObservableProperty] private string _scopes = "openid offline_access";
|
||||
[ObservableProperty] private string _redirectUri = "http://localhost:8765/callback";
|
||||
[ObservableProperty] private int _pollIntervalSeconds = 60;
|
||||
[ObservableProperty] private bool _signedIn;
|
||||
[ObservableProperty] private bool _isBusy;
|
||||
[ObservableProperty] private string _statusMessage = "";
|
||||
|
||||
public OnlineInboxSettingsViewModel(IWorkerClient worker, IOnlineLoginService loginService)
|
||||
{
|
||||
_worker = worker;
|
||||
_loginService = loginService;
|
||||
}
|
||||
|
||||
public async Task LoadAsync()
|
||||
{
|
||||
IsBusy = true;
|
||||
StatusMessage = "";
|
||||
try
|
||||
{
|
||||
var dto = await _worker.GetOnlineInboxStateAsync();
|
||||
if (dto is null)
|
||||
{
|
||||
StatusMessage = Loc.T("vm.onlineInbox.workerOffline");
|
||||
return;
|
||||
}
|
||||
|
||||
Enabled = dto.Enabled;
|
||||
ApiBaseUrl = dto.ApiBaseUrl;
|
||||
Authority = dto.Authority;
|
||||
ClientId = dto.ClientId;
|
||||
Scopes = dto.Scopes;
|
||||
RedirectUri = dto.RedirectUri;
|
||||
SignedIn = dto.SignedIn;
|
||||
PollIntervalSeconds = dto.PollIntervalSeconds;
|
||||
}
|
||||
finally { IsBusy = false; }
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private async Task Save()
|
||||
{
|
||||
IsBusy = true;
|
||||
StatusMessage = "";
|
||||
try
|
||||
{
|
||||
await _worker.SetOnlineInboxConfigAsync(new OnlineInboxConfigInputDto(
|
||||
Enabled,
|
||||
ApiBaseUrl,
|
||||
PollIntervalSeconds,
|
||||
Authority,
|
||||
ClientId,
|
||||
Scopes,
|
||||
RedirectUri));
|
||||
StatusMessage = Loc.T("vm.onlineInbox.saved");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
StatusMessage = Loc.T("vm.onlineInbox.saveFailed", ex.Message);
|
||||
}
|
||||
finally { IsBusy = false; }
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private async Task SignIn()
|
||||
{
|
||||
IsBusy = true;
|
||||
StatusMessage = "";
|
||||
try
|
||||
{
|
||||
var result = await _loginService.LoginAsync(Authority, ClientId, Scopes, RedirectUri);
|
||||
if (!result.Success)
|
||||
{
|
||||
StatusMessage = Loc.T("vm.onlineInbox.signInFailed", result.Error ?? "Unknown error");
|
||||
return;
|
||||
}
|
||||
|
||||
await _worker.SetOnlineInboxAuthAsync(result.RefreshToken!);
|
||||
SignedIn = true;
|
||||
StatusMessage = result.Warning == "missing-user-role"
|
||||
? Loc.T("vm.onlineInbox.signedInNoRole")
|
||||
: Loc.T("vm.onlineInbox.signedIn");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
StatusMessage = Loc.T("vm.onlineInbox.signInFailed", ex.Message);
|
||||
}
|
||||
finally { IsBusy = false; }
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private async Task SignOut()
|
||||
{
|
||||
IsBusy = true;
|
||||
StatusMessage = "";
|
||||
try
|
||||
{
|
||||
await _worker.ClearOnlineInboxAuthAsync();
|
||||
SignedIn = false;
|
||||
StatusMessage = Loc.T("vm.onlineInbox.signedOut");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
StatusMessage = Loc.T("vm.onlineInbox.signOutFailed", ex.Message);
|
||||
}
|
||||
finally { IsBusy = false; }
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,7 @@ namespace ClaudeDo.Ui.ViewModels.Modals.Settings;
|
||||
|
||||
public sealed partial class WorktreesSettingsTabViewModel : ViewModelBase
|
||||
{
|
||||
private readonly WorkerClient _worker;
|
||||
private readonly IWorkerClient _worker;
|
||||
|
||||
[ObservableProperty] private string _worktreeStrategy = "sibling";
|
||||
[ObservableProperty] private string? _centralWorktreeRoot;
|
||||
@@ -21,7 +21,7 @@ public sealed partial class WorktreesSettingsTabViewModel : ViewModelBase
|
||||
|
||||
public IReadOnlyList<string> WorktreeStrategies { get; } = new[] { "sibling", "central" };
|
||||
|
||||
public WorktreesSettingsTabViewModel(WorkerClient worker) => _worker = worker;
|
||||
public WorktreesSettingsTabViewModel(IWorkerClient worker) => _worker = worker;
|
||||
|
||||
public string? Validate()
|
||||
{
|
||||
|
||||
@@ -11,12 +11,13 @@ namespace ClaudeDo.Ui.ViewModels.Modals;
|
||||
|
||||
public sealed partial class SettingsModalViewModel : ViewModelBase
|
||||
{
|
||||
private readonly WorkerClient _worker;
|
||||
private readonly IWorkerClient _worker;
|
||||
|
||||
public GeneralSettingsTabViewModel General { get; }
|
||||
public WorktreesSettingsTabViewModel Worktrees { get; }
|
||||
public FilesSettingsTabViewModel Files { get; }
|
||||
public PrimeClaudeTabViewModel Prime { get; }
|
||||
public OnlineInboxSettingsViewModel OnlineInbox { get; }
|
||||
|
||||
[ObservableProperty] private string _validationError = "";
|
||||
[ObservableProperty] private bool _isBusy;
|
||||
@@ -24,7 +25,8 @@ public sealed partial class SettingsModalViewModel : ViewModelBase
|
||||
|
||||
public Action? CloseAction { get; set; }
|
||||
|
||||
public SettingsModalViewModel(WorkerClient worker, PrimeClaudeTabViewModel prime,
|
||||
public SettingsModalViewModel(IWorkerClient worker, PrimeClaudeTabViewModel prime,
|
||||
IOnlineLoginService onlineLoginService,
|
||||
ILocalizer localizer, AppSettings appSettings)
|
||||
{
|
||||
_worker = worker;
|
||||
@@ -36,6 +38,7 @@ public sealed partial class SettingsModalViewModel : ViewModelBase
|
||||
Worktrees = new WorktreesSettingsTabViewModel(worker);
|
||||
Files = new FilesSettingsTabViewModel(worker);
|
||||
Prime = prime;
|
||||
OnlineInbox = new OnlineInboxSettingsViewModel(worker, onlineLoginService);
|
||||
}
|
||||
|
||||
public async Task LoadAsync()
|
||||
@@ -65,6 +68,7 @@ public sealed partial class SettingsModalViewModel : ViewModelBase
|
||||
else StatusMessage = Loc.T("vm.settingsModal.workerOffline");
|
||||
|
||||
await Prime.LoadAsync();
|
||||
await OnlineInbox.LoadAsync();
|
||||
}
|
||||
finally { IsBusy = false; }
|
||||
}
|
||||
|
||||
@@ -27,6 +27,36 @@ public static class UnifiedDiffParser
|
||||
|
||||
if (current == null) continue;
|
||||
|
||||
// File-level metadata that carries the change kind.
|
||||
if (line.StartsWith("new file", StringComparison.Ordinal))
|
||||
{
|
||||
current.Status = DiffFileStatus.Added;
|
||||
continue;
|
||||
}
|
||||
if (line.StartsWith("deleted file", StringComparison.Ordinal))
|
||||
{
|
||||
current.Status = DiffFileStatus.Deleted;
|
||||
continue;
|
||||
}
|
||||
if (line.StartsWith("rename from ", StringComparison.Ordinal))
|
||||
{
|
||||
current.Status = DiffFileStatus.Renamed;
|
||||
current.OldPath = line["rename from ".Length..];
|
||||
continue;
|
||||
}
|
||||
if (line.StartsWith("rename to ", StringComparison.Ordinal))
|
||||
{
|
||||
current.Status = DiffFileStatus.Renamed;
|
||||
current.Path = line["rename to ".Length..];
|
||||
continue;
|
||||
}
|
||||
if (line.StartsWith("Binary files", StringComparison.Ordinal) ||
|
||||
line.StartsWith("GIT binary patch", StringComparison.Ordinal))
|
||||
{
|
||||
current.IsBinary = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (line.StartsWith("@@ ", StringComparison.Ordinal))
|
||||
{
|
||||
// e.g. "@@ -10,7 +10,9 @@"
|
||||
@@ -34,13 +64,15 @@ public static class UnifiedDiffParser
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip diff metadata lines
|
||||
// Skip remaining diff metadata lines
|
||||
if (line.StartsWith("--- ", StringComparison.Ordinal) ||
|
||||
line.StartsWith("+++ ", StringComparison.Ordinal) ||
|
||||
line.StartsWith("index ", StringComparison.Ordinal) ||
|
||||
line.StartsWith("new file", StringComparison.Ordinal) ||
|
||||
line.StartsWith("deleted file", StringComparison.Ordinal) ||
|
||||
line.StartsWith("Binary ", StringComparison.Ordinal))
|
||||
line.StartsWith("old mode", StringComparison.Ordinal) ||
|
||||
line.StartsWith("new mode", StringComparison.Ordinal) ||
|
||||
line.StartsWith("similarity index", StringComparison.Ordinal) ||
|
||||
line.StartsWith("copy from", StringComparison.Ordinal) ||
|
||||
line.StartsWith("copy to", StringComparison.Ordinal))
|
||||
continue;
|
||||
|
||||
if (line.StartsWith('+'))
|
||||
|
||||
@@ -5,14 +5,6 @@ using ClaudeDo.Data.Git;
|
||||
|
||||
namespace ClaudeDo.Ui.ViewModels.Modals;
|
||||
|
||||
public enum WorktreeDiffLineKind { Header, Hunk, Added, Removed, Context }
|
||||
|
||||
public sealed partial class WorktreeDiffLineViewModel : ViewModelBase
|
||||
{
|
||||
public required string Text { get; init; }
|
||||
public required WorktreeDiffLineKind Kind { get; init; }
|
||||
}
|
||||
|
||||
public sealed partial class WorktreeNodeViewModel : ViewModelBase
|
||||
{
|
||||
public required string Name { get; init; }
|
||||
@@ -28,7 +20,7 @@ public sealed partial class WorktreeModalViewModel : ViewModelBase
|
||||
private readonly GitService _git;
|
||||
|
||||
public ObservableCollection<WorktreeNodeViewModel> Root { get; } = new();
|
||||
public ObservableCollection<WorktreeDiffLineViewModel> SelectedFileDiffLines { get; } = new();
|
||||
public ObservableCollection<DiffLineViewModel> SelectedFileDiffLines { get; } = new();
|
||||
|
||||
[ObservableProperty] private string _worktreePath = "";
|
||||
[ObservableProperty] private string? _baseCommit;
|
||||
@@ -64,19 +56,8 @@ public sealed partial class WorktreeModalViewModel : ViewModelBase
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var line in diff.Split('\n'))
|
||||
{
|
||||
var kind = line switch
|
||||
{
|
||||
_ when line.StartsWith("+++") || line.StartsWith("---") => WorktreeDiffLineKind.Header,
|
||||
_ when line.StartsWith("@@") => WorktreeDiffLineKind.Hunk,
|
||||
_ when line.StartsWith('+') => WorktreeDiffLineKind.Added,
|
||||
_ when line.StartsWith('-') => WorktreeDiffLineKind.Removed,
|
||||
_ when line.StartsWith("diff ") || line.StartsWith("index ") || line.StartsWith("\\ ") => WorktreeDiffLineKind.Header,
|
||||
_ => WorktreeDiffLineKind.Context,
|
||||
};
|
||||
SelectedFileDiffLines.Add(new WorktreeDiffLineViewModel { Text = line, Kind = kind });
|
||||
}
|
||||
foreach (var line in UnifiedDiffParser.Flatten(UnifiedDiffParser.Parse(diff)))
|
||||
SelectedFileDiffLines.Add(line);
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
|
||||
@@ -12,6 +12,8 @@ using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
|
||||
|
||||
namespace ClaudeDo.Ui.ViewModels.Modals;
|
||||
|
||||
public enum BatchMergeOutcome { None, Merging, Merged, Conflict, Blocked, Failed }
|
||||
|
||||
public sealed partial class WorktreeOverviewRowViewModel : ViewModelBase
|
||||
{
|
||||
[ObservableProperty] private string _taskId = "";
|
||||
@@ -27,6 +29,14 @@ public sealed partial class WorktreeOverviewRowViewModel : ViewModelBase
|
||||
[ObservableProperty][NotifyPropertyChangedFor(nameof(AgeText))] private DateTime _createdAt;
|
||||
[ObservableProperty] private bool _pathExistsOnDisk;
|
||||
[ObservableProperty] private bool _isSelected;
|
||||
[ObservableProperty] private bool _isChecked;
|
||||
[ObservableProperty]
|
||||
[NotifyPropertyChangedFor(nameof(IsConflict))]
|
||||
[NotifyPropertyChangedFor(nameof(HasOutcome))]
|
||||
private BatchMergeOutcome _mergeOutcome;
|
||||
|
||||
public bool IsConflict => MergeOutcome == BatchMergeOutcome.Conflict;
|
||||
public bool HasOutcome => MergeOutcome != BatchMergeOutcome.None;
|
||||
|
||||
public string AgeText => FormatAge(DateTime.UtcNow - CreatedAt);
|
||||
public bool IsActive => State == WorktreeState.Active;
|
||||
@@ -50,7 +60,7 @@ public sealed partial class WorktreesGroupViewModel : ViewModelBase
|
||||
|
||||
public sealed partial class WorktreesOverviewModalViewModel : ViewModelBase
|
||||
{
|
||||
private readonly WorkerClient _worker;
|
||||
private readonly IWorkerClient _worker;
|
||||
private readonly Func<WorktreeModalViewModel> _diffVmFactory;
|
||||
|
||||
[ObservableProperty] private string? _listIdFilter;
|
||||
@@ -59,9 +69,18 @@ public sealed partial class WorktreesOverviewModalViewModel : ViewModelBase
|
||||
[ObservableProperty] private bool _isBusy;
|
||||
[ObservableProperty] private string? _statusMessage;
|
||||
[ObservableProperty] private WorktreeOverviewRowViewModel? _selectedRow;
|
||||
[ObservableProperty][NotifyCanExecuteChangedFor(nameof(MergeAllCommand))] private string? _selectedTarget;
|
||||
[ObservableProperty][NotifyCanExecuteChangedFor(nameof(MergeAllCommand))] private int _selectedCount;
|
||||
[ObservableProperty][NotifyCanExecuteChangedFor(nameof(MergeAllCommand))] private bool _isMerging;
|
||||
[ObservableProperty] private string? _batchProgress;
|
||||
|
||||
public ObservableCollection<WorktreeOverviewRowViewModel> Rows { get; } = new();
|
||||
public ObservableCollection<WorktreesGroupViewModel> Groups { get; } = new();
|
||||
public ObservableCollection<string> MergeTargets { get; } = new();
|
||||
public ObservableCollection<WorktreeOverviewRowViewModel> ConflictRows { get; } = new();
|
||||
|
||||
/// Inert seam wired by the integrator to Layer C's resolver at merge time. (taskId, targetBranch)
|
||||
public Func<string, string, Task>? RequestConflictResolution { get; set; }
|
||||
|
||||
public Action? CloseAction { get; set; }
|
||||
public Action<WorktreeModalViewModel>? ShowDiffAction { get; set; }
|
||||
@@ -70,7 +89,7 @@ public sealed partial class WorktreesOverviewModalViewModel : ViewModelBase
|
||||
public Func<MergeModalViewModel>? ResolveMergeVm { get; set; }
|
||||
public Func<MergeModalViewModel, Task>? ShowMergeAction { get; set; }
|
||||
|
||||
public WorktreesOverviewModalViewModel(WorkerClient worker, Func<WorktreeModalViewModel> diffVmFactory)
|
||||
public WorktreesOverviewModalViewModel(IWorkerClient worker, Func<WorktreeModalViewModel> diffVmFactory)
|
||||
{
|
||||
_worker = worker;
|
||||
_diffVmFactory = diffVmFactory;
|
||||
@@ -106,20 +125,24 @@ public sealed partial class WorktreesOverviewModalViewModel : ViewModelBase
|
||||
|
||||
Rows.Clear();
|
||||
Groups.Clear();
|
||||
ConflictRows.Clear();
|
||||
SelectedCount = 0;
|
||||
BatchProgress = null;
|
||||
if (IsGlobal)
|
||||
{
|
||||
foreach (var grp in ordered.GroupBy(r => (r.ListId, r.ListName))
|
||||
.OrderBy(g => g.Key.ListName, StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
var group = new WorktreesGroupViewModel { ListId = grp.Key.ListId, ListName = grp.Key.ListName };
|
||||
foreach (var row in grp) group.Rows.Add(row);
|
||||
foreach (var row in grp) { HookRow(row); group.Rows.Add(row); }
|
||||
Groups.Add(group);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
foreach (var row in ordered) Rows.Add(row);
|
||||
foreach (var row in ordered) { HookRow(row); Rows.Add(row); }
|
||||
}
|
||||
await LoadMergeTargetsAsync();
|
||||
}
|
||||
finally
|
||||
{
|
||||
@@ -255,4 +278,125 @@ public sealed partial class WorktreesOverviewModalViewModel : ViewModelBase
|
||||
Path = d.Path, BranchName = d.BranchName, BaseCommit = d.BaseCommit, State = d.State,
|
||||
DiffStat = d.DiffStat, CreatedAt = d.CreatedAt, PathExistsOnDisk = d.PathExistsOnDisk,
|
||||
};
|
||||
|
||||
public IEnumerable<WorktreeOverviewRowViewModel> AllRows =>
|
||||
IsGlobal ? Groups.SelectMany(g => g.Rows) : Rows;
|
||||
|
||||
private void HookRow(WorktreeOverviewRowViewModel row)
|
||||
{
|
||||
row.PropertyChanged += (_, e) =>
|
||||
{
|
||||
if (e.PropertyName is nameof(WorktreeOverviewRowViewModel.IsChecked)
|
||||
or nameof(WorktreeOverviewRowViewModel.State))
|
||||
RecomputeSelected();
|
||||
};
|
||||
}
|
||||
|
||||
private void RecomputeSelected() =>
|
||||
SelectedCount = AllRows.Count(r => r.IsChecked && r.IsActive);
|
||||
|
||||
// Test seam: adds a row to the flat list with selection tracking wired up.
|
||||
internal void AddRowForTest(WorktreeOverviewRowViewModel row)
|
||||
{
|
||||
HookRow(row);
|
||||
Rows.Add(row);
|
||||
}
|
||||
|
||||
private async Task LoadMergeTargetsAsync()
|
||||
{
|
||||
var anchor = AllRows.FirstOrDefault(r => r.IsActive);
|
||||
if (anchor is null) { MergeTargets.Clear(); SelectedTarget = null; return; }
|
||||
try
|
||||
{
|
||||
var targets = await _worker.GetMergeTargetsAsync(anchor.TaskId);
|
||||
MergeTargets.Clear();
|
||||
if (targets is null) { SelectedTarget = null; return; }
|
||||
foreach (var b in targets.LocalBranches) MergeTargets.Add(b);
|
||||
SelectedTarget = MergeTargets.Contains(targets.DefaultBranch)
|
||||
? targets.DefaultBranch
|
||||
: MergeTargets.FirstOrDefault();
|
||||
}
|
||||
catch { MergeTargets.Clear(); SelectedTarget = null; }
|
||||
}
|
||||
|
||||
private bool CanMergeAll() => !IsMerging && SelectedCount > 0 && !string.IsNullOrWhiteSpace(SelectedTarget);
|
||||
|
||||
[RelayCommand(CanExecute = nameof(CanMergeAll))]
|
||||
private Task MergeAll() => MergeSelectedAsync(_worker.MergeTaskAsync);
|
||||
|
||||
[RelayCommand]
|
||||
private void ResolveConflict(WorktreeOverviewRowViewModel? row)
|
||||
{
|
||||
if (row is null) return;
|
||||
RequestConflictResolution?.Invoke(row.TaskId, SelectedTarget ?? "");
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void ToggleSelectAll()
|
||||
{
|
||||
var actives = AllRows.Where(r => r.IsActive).ToList();
|
||||
var allChecked = actives.Count > 0 && actives.All(r => r.IsChecked);
|
||||
foreach (var r in actives) r.IsChecked = !allChecked;
|
||||
}
|
||||
|
||||
public async Task MergeSelectedAsync(
|
||||
Func<string, string, bool, string, Task<MergeResultDto>> mergeFn,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var target = SelectedTarget;
|
||||
if (string.IsNullOrWhiteSpace(target)) return;
|
||||
|
||||
var selected = AllRows.Where(r => r.IsChecked && r.IsActive).ToList();
|
||||
if (selected.Count == 0) return;
|
||||
|
||||
IsMerging = true;
|
||||
ConflictRows.Clear();
|
||||
var done = 0;
|
||||
try
|
||||
{
|
||||
foreach (var row in selected)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
row.MergeOutcome = BatchMergeOutcome.Merging;
|
||||
BatchProgress = Loc.T("vm.worktreesOverview.batchProgress", ++done, selected.Count);
|
||||
|
||||
MergeResultDto result;
|
||||
try
|
||||
{
|
||||
result = await mergeFn(row.TaskId, target!, false,
|
||||
Loc.T("vm.merge.commitMessage", row.TaskTitle));
|
||||
}
|
||||
catch
|
||||
{
|
||||
row.MergeOutcome = BatchMergeOutcome.Failed;
|
||||
continue;
|
||||
}
|
||||
|
||||
switch (result.Status)
|
||||
{
|
||||
case "merged":
|
||||
row.MergeOutcome = BatchMergeOutcome.Merged;
|
||||
row.State = WorktreeState.Merged;
|
||||
row.IsChecked = false;
|
||||
break;
|
||||
case "conflict":
|
||||
row.MergeOutcome = BatchMergeOutcome.Conflict;
|
||||
ConflictRows.Add(row);
|
||||
break;
|
||||
case "blocked":
|
||||
row.MergeOutcome = BatchMergeOutcome.Blocked;
|
||||
break;
|
||||
default:
|
||||
row.MergeOutcome = BatchMergeOutcome.Failed;
|
||||
break;
|
||||
}
|
||||
}
|
||||
BatchProgress = Loc.T("vm.worktreesOverview.batchDone",
|
||||
selected.Count(r => r.MergeOutcome == BatchMergeOutcome.Merged), ConflictRows.Count);
|
||||
}
|
||||
finally
|
||||
{
|
||||
IsMerging = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,86 +0,0 @@
|
||||
using System.Diagnostics;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using ClaudeDo.Ui.Localization;
|
||||
using ClaudeDo.Ui.Services;
|
||||
|
||||
namespace ClaudeDo.Ui.ViewModels.Planning;
|
||||
|
||||
public sealed partial class ConflictResolutionViewModel : ObservableObject
|
||||
{
|
||||
private readonly IWorkerClient _worker;
|
||||
private readonly string _planningTaskId;
|
||||
private readonly string _worktreePath;
|
||||
|
||||
public string SubtaskTitle { get; }
|
||||
public string TargetBranch { get; }
|
||||
public IReadOnlyList<string> ConflictedFiles { get; }
|
||||
public string SubtaskLabel => Loc.T("vm.conflictResolution.subtaskPrefix", SubtaskTitle);
|
||||
public string TargetLabel => Loc.T("vm.conflictResolution.targetPrefix", TargetBranch);
|
||||
|
||||
[ObservableProperty] private string? _vsCodeError;
|
||||
[ObservableProperty] private string? _actionError;
|
||||
|
||||
public Action? CloseRequested { get; set; }
|
||||
|
||||
public ConflictResolutionViewModel(
|
||||
IWorkerClient worker,
|
||||
string planningTaskId,
|
||||
string subtaskTitle,
|
||||
string targetBranch,
|
||||
IReadOnlyList<string> conflictedFiles,
|
||||
string worktreePath)
|
||||
{
|
||||
_worker = worker;
|
||||
_planningTaskId = planningTaskId;
|
||||
_worktreePath = worktreePath;
|
||||
SubtaskTitle = subtaskTitle;
|
||||
TargetBranch = targetBranch;
|
||||
ConflictedFiles = conflictedFiles;
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void OpenInVsCode()
|
||||
{
|
||||
try
|
||||
{
|
||||
var args = string.Join(" ", ConflictedFiles.Select(f => $"\"{f}\""));
|
||||
Process.Start(new ProcessStartInfo
|
||||
{
|
||||
FileName = "code",
|
||||
Arguments = args,
|
||||
WorkingDirectory = _worktreePath,
|
||||
UseShellExecute = true,
|
||||
});
|
||||
VsCodeError = null;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
VsCodeError = Loc.T("vm.conflictResolution.vsCodeError", ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private async Task ContinueAsync()
|
||||
{
|
||||
ActionError = null;
|
||||
try
|
||||
{
|
||||
await _worker.ContinuePlanningMergeAsync(_planningTaskId);
|
||||
CloseRequested?.Invoke();
|
||||
}
|
||||
catch (Exception ex) { ActionError = ex.Message; }
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private async Task AbortAsync()
|
||||
{
|
||||
ActionError = null;
|
||||
try
|
||||
{
|
||||
await _worker.AbortPlanningMergeAsync(_planningTaskId);
|
||||
CloseRequested?.Invoke();
|
||||
}
|
||||
catch (Exception ex) { ActionError = ex.Message; }
|
||||
}
|
||||
}
|
||||
173
src/ClaudeDo.Ui/Views/Conflicts/ConflictResolverView.axaml
Normal file
173
src/ClaudeDo.Ui/Views/Conflicts/ConflictResolverView.axaml
Normal file
@@ -0,0 +1,173 @@
|
||||
<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:ae="clr-namespace:AvaloniaEdit;assembly=AvaloniaEdit"
|
||||
xmlns:loc="using:ClaudeDo.Ui.Localization"
|
||||
x:DataType="vm:ConflictResolverViewModel"
|
||||
x:Class="ClaudeDo.Ui.Views.Conflicts.ConflictResolverView"
|
||||
Title="{loc:Tr conflictResolver.windowTitle}"
|
||||
Width="1280" Height="820" MinWidth="960" MinHeight="560"
|
||||
CanResize="True"
|
||||
WindowDecorations="BorderOnly"
|
||||
ExtendClientAreaToDecorationsHint="True"
|
||||
ExtendClientAreaTitleBarHeightHint="-1"
|
||||
WindowStartupLocation="CenterOwner"
|
||||
Background="{DynamicResource SurfaceBrush}">
|
||||
|
||||
<Window.KeyBindings>
|
||||
<KeyBinding Gesture="Escape" Command="{Binding AbortCommand}"/>
|
||||
<KeyBinding Gesture="F8" Command="{Binding NextCommand}"/>
|
||||
<KeyBinding Gesture="Shift+F8" Command="{Binding PreviousCommand}"/>
|
||||
</Window.KeyBindings>
|
||||
|
||||
<Window.Styles>
|
||||
<Style Selector="ae|TextEditor">
|
||||
<Setter Property="FontFamily" Value="{StaticResource MonoFont}" />
|
||||
<Setter Property="FontSize" Value="{StaticResource FontSizeMono}" />
|
||||
<Setter Property="Background" Value="{DynamicResource Surface2Brush}" />
|
||||
<Setter Property="Padding" Value="4,2" />
|
||||
<Setter Property="WordWrap" Value="False" />
|
||||
</Style>
|
||||
<Style Selector="Border.col-head">
|
||||
<Setter Property="Padding" Value="8,4" />
|
||||
<Setter Property="BorderThickness" Value="0,0,0,1" />
|
||||
<Setter Property="BorderBrush" Value="{DynamicResource LineBrush}" />
|
||||
<Setter Property="Background" Value="{DynamicResource Surface2Brush}" />
|
||||
</Style>
|
||||
<Style Selector="Border.pane">
|
||||
<Setter Property="BorderBrush" Value="{DynamicResource LineBrush}" />
|
||||
<Setter Property="BorderThickness" Value="1" />
|
||||
<Setter Property="CornerRadius" Value="6" />
|
||||
<Setter Property="ClipToBounds" Value="True" />
|
||||
</Style>
|
||||
<!-- Inline accept controls in the between-pane gutters -->
|
||||
<Style Selector="Button.accept-gutter">
|
||||
<Setter Property="Width" Value="22" />
|
||||
<Setter Property="Height" Value="20" />
|
||||
<Setter Property="Padding" Value="0" />
|
||||
<Setter Property="FontSize" Value="13" />
|
||||
<Setter Property="FontWeight" Value="Bold" />
|
||||
<Setter Property="HorizontalContentAlignment" Value="Center" />
|
||||
<Setter Property="VerticalContentAlignment" Value="Center" />
|
||||
<Setter Property="Background" Value="{DynamicResource Surface2Brush}" />
|
||||
<Setter Property="BorderBrush" Value="{DynamicResource MergeConflictEdgeBrush}" />
|
||||
<Setter Property="BorderThickness" Value="1" />
|
||||
<Setter Property="CornerRadius" Value="4" />
|
||||
<Setter Property="Foreground" Value="{DynamicResource TextBrush}" />
|
||||
</Style>
|
||||
<Style Selector="Button.accept-gutter:pointerover /template/ ContentPresenter">
|
||||
<Setter Property="Background" Value="{DynamicResource MergeConflictTintBrush}" />
|
||||
</Style>
|
||||
</Window.Styles>
|
||||
|
||||
<ctl:ModalShell Title="{loc:Tr conflictResolver.modalTitle}" CloseCommand="{Binding AbortCommand}">
|
||||
<ctl:ModalShell.Footer>
|
||||
<Grid ColumnDefinitions="*,Auto">
|
||||
<TextBlock Grid.Column="0" Classes="meta" VerticalAlignment="Center"
|
||||
Foreground="{DynamicResource BloodBrush}"
|
||||
Text="{Binding ContinueHint}"
|
||||
IsVisible="{Binding HasBinaryFiles}"/>
|
||||
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="8">
|
||||
<Button Classes="btn accent" Content="{loc:Tr conflictResolver.continue}"
|
||||
Command="{Binding ContinueCommand}" IsEnabled="{Binding CanContinue}"/>
|
||||
<Button Classes="btn" Content="{loc:Tr conflictResolver.abort}" Command="{Binding AbortCommand}"/>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</ctl:ModalShell.Footer>
|
||||
|
||||
<Grid Margin="14,10" RowDefinitions="Auto,Auto,Auto,*">
|
||||
|
||||
<!-- Busy / error -->
|
||||
<TextBlock Grid.Row="0" Classes="meta" Margin="0,0,0,6"
|
||||
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}}"/>
|
||||
|
||||
<!-- Binary-conflict banner -->
|
||||
<Border Grid.Row="1" Margin="0,0,0,8" Padding="10,7" CornerRadius="6"
|
||||
Background="{DynamicResource ErrorTintBrush}"
|
||||
BorderBrush="{DynamicResource BloodBrush}" BorderThickness="1"
|
||||
IsVisible="{Binding HasBinaryFiles}">
|
||||
<StackPanel Spacing="3">
|
||||
<TextBlock Classes="meta" Foreground="{DynamicResource BloodBrush}"
|
||||
Text="{loc:Tr conflictResolver.binaryHint}"/>
|
||||
<ItemsControl ItemsSource="{Binding BinaryFilePaths}">
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate x:DataType="x:String">
|
||||
<TextBlock Classes="path-mono" Text="{Binding}"/>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!-- Toolbar: change nav · file switcher · readout -->
|
||||
<Grid Grid.Row="2" ColumnDefinitions="Auto,Auto,Auto,*,Auto,Auto" Margin="0,0,0,8"
|
||||
IsVisible="{Binding HasCurrent}">
|
||||
<Button Grid.Column="0" Classes="btn" Content="↑" Margin="0,0,4,0" Padding="10,4"
|
||||
ToolTip.Tip="{loc:Tr conflictResolver.prevConflict}"
|
||||
Command="{Binding PreviousCommand}"/>
|
||||
<Button Grid.Column="1" Classes="btn" Content="↓" Margin="0,0,12,0" Padding="10,4"
|
||||
ToolTip.Tip="{loc:Tr conflictResolver.nextConflict}"
|
||||
Command="{Binding NextCommand}"/>
|
||||
<ComboBox Grid.Column="2" MinWidth="240" MaxWidth="520"
|
||||
ItemsSource="{Binding Files}"
|
||||
SelectedItem="{Binding ActiveFile, Mode=TwoWay}">
|
||||
<ComboBox.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:MergeFile">
|
||||
<TextBlock Classes="path-mono" Text="{Binding Path}" TextTrimming="CharacterEllipsis"/>
|
||||
</DataTemplate>
|
||||
</ComboBox.ItemTemplate>
|
||||
</ComboBox>
|
||||
<TextBlock Grid.Column="4" Classes="meta" VerticalAlignment="Center" Margin="0,0,14,0"
|
||||
Foreground="{DynamicResource AmberBrush}"
|
||||
IsVisible="{Binding HasMultipleFiles}"
|
||||
Text="{Binding FilesSummary}"/>
|
||||
<TextBlock Grid.Column="5" Classes="meta" VerticalAlignment="Center"
|
||||
Foreground="{DynamicResource TextDimBrush}"
|
||||
Text="{Binding PositionText}"/>
|
||||
</Grid>
|
||||
|
||||
<!-- Three panes: Ours | (gutter) | Result | (gutter) | Theirs -->
|
||||
<Grid Grid.Row="3" ColumnDefinitions="*,26,*,26,*" IsVisible="{Binding HasCurrent}">
|
||||
<Border Grid.Column="0" Classes="pane">
|
||||
<DockPanel>
|
||||
<Border Classes="col-head" DockPanel.Dock="Top">
|
||||
<TextBlock Classes="eyebrow" Text="{loc:Tr conflictResolver.ours}"
|
||||
Foreground="{DynamicResource MossBrush}"/>
|
||||
</Border>
|
||||
<ae:TextEditor Name="OursEditor" IsReadOnly="True" ShowLineNumbers="True"/>
|
||||
</DockPanel>
|
||||
</Border>
|
||||
|
||||
<Canvas Grid.Column="1" Name="LeftGutter" Background="Transparent"/>
|
||||
|
||||
<Border Grid.Column="2" Classes="pane">
|
||||
<DockPanel>
|
||||
<Border Classes="col-head" DockPanel.Dock="Top">
|
||||
<TextBlock Classes="eyebrow" Text="{loc:Tr conflictResolver.result}"/>
|
||||
</Border>
|
||||
<Canvas Name="ConflictMap" DockPanel.Dock="Right" Width="13"
|
||||
Background="{DynamicResource Surface2Brush}"
|
||||
ToolTip.Tip="{loc:Tr conflictResolver.conflictMap}"/>
|
||||
<ae:TextEditor Name="ResultEditor" ShowLineNumbers="True"/>
|
||||
</DockPanel>
|
||||
</Border>
|
||||
|
||||
<Canvas Grid.Column="3" Name="RightGutter" Background="Transparent"/>
|
||||
|
||||
<Border Grid.Column="4" Classes="pane">
|
||||
<DockPanel>
|
||||
<Border Classes="col-head" DockPanel.Dock="Top">
|
||||
<TextBlock Classes="eyebrow" Text="{loc:Tr conflictResolver.theirs}"
|
||||
Foreground="{DynamicResource AmberBrush}"/>
|
||||
</Border>
|
||||
<ae:TextEditor Name="TheirsEditor" IsReadOnly="True" ShowLineNumbers="True"/>
|
||||
</DockPanel>
|
||||
</Border>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</ctl:ModalShell>
|
||||
</Window>
|
||||
488
src/ClaudeDo.Ui/Views/Conflicts/ConflictResolverView.axaml.cs
Normal file
488
src/ClaudeDo.Ui/Views/Conflicts/ConflictResolverView.axaml.cs
Normal file
@@ -0,0 +1,488 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.ComponentModel;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Controls.Shapes;
|
||||
using Avalonia.Media;
|
||||
using Avalonia.Threading;
|
||||
using Avalonia.VisualTree;
|
||||
using AvaloniaEdit;
|
||||
using AvaloniaEdit.Document;
|
||||
using AvaloniaEdit.Editing;
|
||||
using AvaloniaEdit.Rendering;
|
||||
using AvaloniaEdit.TextMate;
|
||||
using ClaudeDo.Ui.ViewModels.Conflicts;
|
||||
using TextMateSharp.Grammars;
|
||||
|
||||
namespace ClaudeDo.Ui.Views.Conflicts;
|
||||
|
||||
public partial class ConflictResolverView : Window
|
||||
{
|
||||
private ConflictResolverViewModel? _vm;
|
||||
private RegistryOptions? _registry;
|
||||
private TextMate.Installation? _oursTm, _resultTm, _theirsTm;
|
||||
|
||||
// Fixed conflict spans for the read-only side panes (recomputed each rebuild).
|
||||
private List<(int Offset, int Length, MergeConflictBlock Block)> _oursSpans = new();
|
||||
private List<(int Offset, int Length, MergeConflictBlock Block)> _theirsSpans = new();
|
||||
|
||||
// Live, edit-tracked conflict regions in the editable result document.
|
||||
private readonly List<ResultRegion> _resultRegions = new();
|
||||
private readonly List<MergeConflictBlock> _hookedBlocks = new();
|
||||
|
||||
private ScrollViewer?[] _scrollViewers = Array.Empty<ScrollViewer?>();
|
||||
private bool _wired;
|
||||
private bool _rebuilding;
|
||||
private bool _applyingAccept;
|
||||
private bool _syncing;
|
||||
private bool _gutterPending;
|
||||
private int _gutterRetries;
|
||||
|
||||
public ConflictResolverView()
|
||||
{
|
||||
InitializeComponent();
|
||||
}
|
||||
|
||||
protected override void OnDataContextChanged(EventArgs e)
|
||||
{
|
||||
base.OnDataContextChanged(e);
|
||||
|
||||
if (_vm is not null)
|
||||
{
|
||||
_vm.ActiveFileChanged -= Rebuild;
|
||||
_vm.CurrentChanged -= ScrollToCurrent;
|
||||
}
|
||||
// The editors persist across a DataContext swap, so drop stale scroll-sync hooks first.
|
||||
foreach (var sv in _scrollViewers)
|
||||
if (sv is not null) sv.ScrollChanged -= OnPaneScroll;
|
||||
_scrollViewers = Array.Empty<ScrollViewer?>();
|
||||
_wired = false;
|
||||
|
||||
_vm = DataContext as ConflictResolverViewModel;
|
||||
if (_vm is null) return;
|
||||
|
||||
_vm.CloseRequested = Close;
|
||||
EnsureEditors();
|
||||
_vm.ActiveFileChanged += Rebuild;
|
||||
_vm.CurrentChanged += ScrollToCurrent;
|
||||
Rebuild();
|
||||
}
|
||||
|
||||
// ── One-time editor setup ────────────────────────────────────────────────
|
||||
|
||||
private void EnsureEditors()
|
||||
{
|
||||
if (_registry is not null) return;
|
||||
_registry = new RegistryOptions(ThemeName.DarkPlus);
|
||||
_oursTm = OursEditor.InstallTextMate(_registry);
|
||||
_resultTm = ResultEditor.InstallTextMate(_registry);
|
||||
_theirsTm = TheirsEditor.InstallTextMate(_registry);
|
||||
|
||||
ResultEditor.Document ??= new TextDocument();
|
||||
ResultEditor.Document.Changed += OnResultDocumentChanged;
|
||||
ResultEditor.TextArea.ReadOnlySectionProvider =
|
||||
new ConflictReadOnlyProvider(() => _resultRegions.Select(r => (r.Start.Offset, r.End.Offset)));
|
||||
|
||||
var conflict = BrushRes("MergeConflictTintBrush", Color.Parse("#28C87060"));
|
||||
var resolved = BrushRes("MergeResolvedTintBrush", Color.Parse("#206FA86B"));
|
||||
OursEditor.TextArea.TextView.BackgroundRenderers.Add(new MergeBlockRenderer(
|
||||
() => _oursSpans.Select(s => (s.Offset, s.Length, s.Block.IsResolved)), conflict, resolved));
|
||||
ResultEditor.TextArea.TextView.BackgroundRenderers.Add(new MergeBlockRenderer(
|
||||
() => _resultRegions.Select(r => (r.Start.Offset, r.End.Offset - r.Start.Offset, r.Block.IsResolved)), conflict, resolved));
|
||||
TheirsEditor.TextArea.TextView.BackgroundRenderers.Add(new MergeBlockRenderer(
|
||||
() => _theirsSpans.Select(s => (s.Offset, s.Length, s.Block.IsResolved)), conflict, resolved));
|
||||
}
|
||||
|
||||
private IBrush BrushRes(string key, Color fallback)
|
||||
{
|
||||
if (this.TryGetResource(key, null, out var v) && v is IBrush b)
|
||||
return b;
|
||||
return new SolidColorBrush(fallback);
|
||||
}
|
||||
|
||||
// ── Rebuild the three documents for the active file ───────────────────────
|
||||
|
||||
private void Rebuild()
|
||||
{
|
||||
if (_vm is null) return;
|
||||
_rebuilding = true;
|
||||
_gutterRetries = 0; // fresh retry budget for this file's gutter layout
|
||||
try
|
||||
{
|
||||
ClearGutters();
|
||||
UnhookBlocks();
|
||||
_resultRegions.Clear();
|
||||
|
||||
var file = _vm.ActiveFile;
|
||||
if (file is null || file.IsBinary)
|
||||
{
|
||||
OursEditor.Text = TheirsEditor.Text = "";
|
||||
if (ResultEditor.Document is { } d0) d0.Text = "";
|
||||
_oursSpans = new(); _theirsSpans = new();
|
||||
InvalidateRenderers();
|
||||
return;
|
||||
}
|
||||
|
||||
var (oursText, oursSpans) = BuildSide(file, b => b.Ours);
|
||||
var (theirsText, theirsSpans) = BuildSide(file, b => b.Theirs);
|
||||
// Unresolved conflicts start EMPTY — the user builds the result by appending sides.
|
||||
var (resultText, resultSpans) = BuildSide(file, b => b.Resolution ?? "");
|
||||
_oursSpans = oursSpans;
|
||||
_theirsSpans = theirsSpans;
|
||||
|
||||
OursEditor.Text = oursText;
|
||||
TheirsEditor.Text = theirsText;
|
||||
ResultEditor.Document ??= new TextDocument();
|
||||
ResultEditor.Document.Text = resultText;
|
||||
|
||||
var doc = ResultEditor.Document;
|
||||
foreach (var (offset, length, block) in resultSpans)
|
||||
{
|
||||
var start = doc.CreateAnchor(offset);
|
||||
start.MovementType = AnchorMovementType.BeforeInsertion;
|
||||
var end = doc.CreateAnchor(offset + length);
|
||||
end.MovementType = AnchorMovementType.AfterInsertion;
|
||||
_resultRegions.Add(new ResultRegion(block, start, end));
|
||||
block.PropertyChanged += OnBlockChanged;
|
||||
_hookedBlocks.Add(block);
|
||||
}
|
||||
|
||||
ApplyGrammar(file.Path);
|
||||
InvalidateRenderers();
|
||||
}
|
||||
finally { _rebuilding = false; }
|
||||
|
||||
if (!_wired)
|
||||
{
|
||||
_wired = true;
|
||||
Dispatcher.UIThread.Post(HookScrollSync, DispatcherPriority.Loaded);
|
||||
}
|
||||
QueueGutters();
|
||||
}
|
||||
|
||||
private static (string Text, List<(int Offset, int Length, MergeConflictBlock Block)> Spans) BuildSide(
|
||||
MergeFile file, Func<MergeConflictBlock, string> pick)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
var spans = new List<(int, int, MergeConflictBlock)>();
|
||||
foreach (var seg in file.Segments)
|
||||
{
|
||||
if (seg.IsConflict)
|
||||
{
|
||||
var text = pick(seg.Conflict!);
|
||||
spans.Add((sb.Length, text.Length, seg.Conflict!));
|
||||
sb.Append(text);
|
||||
}
|
||||
else
|
||||
{
|
||||
sb.Append(seg.StableText);
|
||||
}
|
||||
}
|
||||
return (sb.ToString(), spans);
|
||||
}
|
||||
|
||||
private void UnhookBlocks()
|
||||
{
|
||||
foreach (var b in _hookedBlocks) b.PropertyChanged -= OnBlockChanged;
|
||||
_hookedBlocks.Clear();
|
||||
}
|
||||
|
||||
private void OnBlockChanged(object? sender, PropertyChangedEventArgs e)
|
||||
{
|
||||
if (e.PropertyName is nameof(MergeConflictBlock.IsResolved) or nameof(MergeConflictBlock.Resolution))
|
||||
{
|
||||
InvalidateRenderers();
|
||||
QueueGutters();
|
||||
}
|
||||
}
|
||||
|
||||
// ── User edits in the result document flow back to the owning conflict ────
|
||||
|
||||
private void OnResultDocumentChanged(object? sender, DocumentChangeEventArgs e)
|
||||
{
|
||||
if (_rebuilding || _applyingAccept) return;
|
||||
foreach (var r in _resultRegions)
|
||||
{
|
||||
if (e.Offset >= r.Start.Offset && e.Offset <= r.End.Offset)
|
||||
{
|
||||
r.Block.Resolution = ResultEditor.Document.GetText(r.Start.Offset, Math.Max(0, r.End.Offset - r.Start.Offset));
|
||||
break;
|
||||
}
|
||||
}
|
||||
QueueGutters();
|
||||
}
|
||||
|
||||
// ── Toggle a side in/out of the result region ────────────────────────────
|
||||
|
||||
// Each side can be included at most once. Clicking adds it (in click order, first on
|
||||
// top); clicking again removes it. The region content is rebuilt from the included set.
|
||||
private void ToggleSide(ResultRegion region, char side)
|
||||
{
|
||||
if (region.Order.Contains(side)) region.Order.Remove(side);
|
||||
else region.Order.Add(side);
|
||||
|
||||
var text = string.Concat(region.Order.Select(c => c == 'o' ? region.Block.Ours : region.Block.Theirs));
|
||||
_applyingAccept = true;
|
||||
try { ResultEditor.Document.Replace(region.Start.Offset, region.End.Offset - region.Start.Offset, text); }
|
||||
finally { _applyingAccept = false; }
|
||||
|
||||
region.Block.Resolution = region.Order.Count == 0 ? null : text;
|
||||
InvalidateRenderers();
|
||||
PositionGutters();
|
||||
}
|
||||
|
||||
// ── Inline accept controls in the between-pane gutters ────────────────────
|
||||
|
||||
private void ClearGutters()
|
||||
{
|
||||
LeftGutter.Children.Clear();
|
||||
RightGutter.Children.Clear();
|
||||
}
|
||||
|
||||
// Coalesce gutter re-layouts so repeated change/scroll events can't flood the dispatcher.
|
||||
private void QueueGutters()
|
||||
{
|
||||
if (_gutterPending) return;
|
||||
_gutterPending = true;
|
||||
Dispatcher.UIThread.Post(() => { _gutterPending = false; PositionGutters(); }, DispatcherPriority.Background);
|
||||
}
|
||||
|
||||
private void PositionGutters()
|
||||
{
|
||||
ClearGutters();
|
||||
PopulateConflictMap();
|
||||
if (_vm?.ActiveFile is null) return;
|
||||
var tv = ResultEditor.TextArea.TextView;
|
||||
if (!tv.VisualLinesValid)
|
||||
{
|
||||
// Retry until the editor is laid out, but bounded so a never-laid-out editor
|
||||
// (e.g. minimized window) can't busy-loop the dispatcher.
|
||||
if (_gutterRetries++ < 40) QueueGutters();
|
||||
return;
|
||||
}
|
||||
_gutterRetries = 0;
|
||||
|
||||
var doc = ResultEditor.Document;
|
||||
foreach (var region in _resultRegions)
|
||||
{
|
||||
// Controls stay visible whether or not a side is included, so either can be toggled.
|
||||
var len = region.End.Offset - region.Start.Offset;
|
||||
ISegment probe = len > 0
|
||||
? new Seg(region.Start.Offset, len)
|
||||
: new Seg(region.Start.Offset, region.Start.Offset < doc.TextLength ? 1 : 0);
|
||||
var rects = BackgroundGeometryBuilder.GetRectsForSegment(tv, probe).ToList();
|
||||
if (rects.Count == 0) continue;
|
||||
var y = rects[0].Top;
|
||||
|
||||
var r = region;
|
||||
var oursIn = region.Order.Contains('o');
|
||||
var theirsIn = region.Order.Contains('t');
|
||||
|
||||
if (tv.TranslatePoint(new Point(0, y), LeftGutter) is { } pl &&
|
||||
pl.Y > -24 && pl.Y < LeftGutter.Bounds.Height + 24)
|
||||
AddAcceptButton(LeftGutter, pl.Y, oursIn ? "−" : "›", () => ToggleSide(r, 'o'),
|
||||
Tr(oursIn ? "conflictResolver.removeOurs" : "conflictResolver.acceptOurs"));
|
||||
|
||||
if (tv.TranslatePoint(new Point(0, y), RightGutter) is { } pr &&
|
||||
pr.Y > -24 && pr.Y < RightGutter.Bounds.Height + 24)
|
||||
AddAcceptButton(RightGutter, pr.Y, theirsIn ? "−" : "‹", () => ToggleSide(r, 't'),
|
||||
Tr(theirsIn ? "conflictResolver.removeTheirs" : "conflictResolver.acceptTheirs"));
|
||||
}
|
||||
}
|
||||
|
||||
private void AddAcceptButton(Canvas canvas, double y, string glyph, Action onClick, string tip)
|
||||
{
|
||||
var b = new Button { Content = glyph };
|
||||
b.Classes.Add("accept-gutter");
|
||||
ToolTip.SetTip(b, tip);
|
||||
b.Click += (_, _) => onClick();
|
||||
Canvas.SetLeft(b, 1);
|
||||
Canvas.SetTop(b, Math.Max(0, y));
|
||||
canvas.Children.Add(b);
|
||||
}
|
||||
|
||||
// ── Conflict overview ruler (right of the result pane) ───────────────────
|
||||
|
||||
// A proportional map of every conflict in the active file so they're findable in
|
||||
// long files without scrolling; ticks recolor by resolved state and jump on click.
|
||||
private void PopulateConflictMap()
|
||||
{
|
||||
ConflictMap.Children.Clear();
|
||||
if (_vm?.ActiveFile is null || _resultRegions.Count == 0) return;
|
||||
var h = ConflictMap.Bounds.Height;
|
||||
if (h <= 1) return;
|
||||
var doc = ResultEditor.Document;
|
||||
var totalLines = Math.Max(1, doc.LineCount);
|
||||
var unresolved = BrushRes("MergeConflictEdgeBrush", Color.Parse("#80C87060"));
|
||||
var resolved = BrushRes("MergeResolvedEdgeBrush", Color.Parse("#806FA86B"));
|
||||
|
||||
foreach (var region in _resultRegions)
|
||||
{
|
||||
var line = doc.GetLineByOffset(region.Start.Offset).LineNumber;
|
||||
var y = (line - 1) / (double)totalLines * h;
|
||||
var tick = new Rectangle
|
||||
{
|
||||
Width = 9,
|
||||
Height = 4,
|
||||
Fill = region.Block.IsResolved ? resolved : unresolved,
|
||||
Cursor = new Avalonia.Input.Cursor(Avalonia.Input.StandardCursorType.Hand),
|
||||
};
|
||||
Canvas.SetLeft(tick, 2);
|
||||
Canvas.SetTop(tick, Math.Min(h - 4, Math.Max(0, y)));
|
||||
var r = region;
|
||||
tick.PointerPressed += (_, _) => JumpToRegion(r);
|
||||
ConflictMap.Children.Add(tick);
|
||||
}
|
||||
}
|
||||
|
||||
private void JumpToRegion(ResultRegion region)
|
||||
{
|
||||
var line = ResultEditor.Document.GetLineByOffset(region.Start.Offset).LineNumber;
|
||||
ResultEditor.ScrollToLine(line);
|
||||
QueueGutters();
|
||||
}
|
||||
|
||||
private static string Tr(string key) => ClaudeDo.Ui.Localization.Loc.T(key);
|
||||
|
||||
// ── Synced vertical scroll across the three panes ─────────────────────────
|
||||
|
||||
private void HookScrollSync()
|
||||
{
|
||||
_scrollViewers = new[] { OursEditor, ResultEditor, TheirsEditor }
|
||||
.Select(ed => ed.FindDescendantOfType<ScrollViewer>())
|
||||
.ToArray();
|
||||
foreach (var sv in _scrollViewers)
|
||||
if (sv is not null) sv.ScrollChanged += OnPaneScroll;
|
||||
}
|
||||
|
||||
private void OnPaneScroll(object? sender, ScrollChangedEventArgs e)
|
||||
{
|
||||
if (_syncing || sender is not ScrollViewer src) return;
|
||||
_syncing = true;
|
||||
try
|
||||
{
|
||||
foreach (var sv in _scrollViewers)
|
||||
if (sv is not null && !ReferenceEquals(sv, src) && Math.Abs(sv.Offset.Y - src.Offset.Y) > 0.5)
|
||||
sv.Offset = new Vector(sv.Offset.X, src.Offset.Y);
|
||||
}
|
||||
finally { _syncing = false; }
|
||||
PositionGutters();
|
||||
}
|
||||
|
||||
private void ScrollToCurrent()
|
||||
{
|
||||
if (_vm?.Current is not { } block) return;
|
||||
var region = _resultRegions.FirstOrDefault(r => ReferenceEquals(r.Block, block));
|
||||
if (region is null) return;
|
||||
var line = ResultEditor.Document.GetLineByOffset(region.Start.Offset).LineNumber;
|
||||
ResultEditor.ScrollToLine(line);
|
||||
QueueGutters();
|
||||
}
|
||||
|
||||
private void InvalidateRenderers()
|
||||
{
|
||||
OursEditor.TextArea.TextView.InvalidateVisual();
|
||||
ResultEditor.TextArea.TextView.InvalidateVisual();
|
||||
TheirsEditor.TextArea.TextView.InvalidateVisual();
|
||||
}
|
||||
|
||||
private void ApplyGrammar(string? path)
|
||||
{
|
||||
if (_registry is null || string.IsNullOrEmpty(path)) return;
|
||||
var ext = System.IO.Path.GetExtension(path);
|
||||
if (string.IsNullOrEmpty(ext)) return;
|
||||
var language = _registry.GetLanguageByExtension(ext);
|
||||
if (language is null) return;
|
||||
var scope = _registry.GetScopeByLanguageId(language.Id);
|
||||
_oursTm?.SetGrammar(scope);
|
||||
_resultTm?.SetGrammar(scope);
|
||||
_theirsTm?.SetGrammar(scope);
|
||||
}
|
||||
|
||||
// ── Helper types (single-consumer; live with their consumer per repo style) ─
|
||||
|
||||
/// <summary>A minimal <see cref="ISegment"/> for geometry/read-only queries.</summary>
|
||||
private readonly struct Seg : ISegment
|
||||
{
|
||||
public Seg(int offset, int length) { Offset = offset; Length = length; }
|
||||
public int Offset { get; }
|
||||
public int Length { get; }
|
||||
public int EndOffset => Offset + Length;
|
||||
}
|
||||
|
||||
/// <summary>An editable conflict region in the result document, tracking which sides are
|
||||
/// currently included (in click order — <c>'o'</c> = ours/main, <c>'t'</c> = theirs/incoming).</summary>
|
||||
private sealed class ResultRegion
|
||||
{
|
||||
public ResultRegion(MergeConflictBlock block, TextAnchor start, TextAnchor end)
|
||||
{
|
||||
Block = block; Start = start; End = end;
|
||||
}
|
||||
public MergeConflictBlock Block { get; }
|
||||
public TextAnchor Start { get; }
|
||||
public TextAnchor End { get; }
|
||||
public List<char> Order { get; } = new();
|
||||
}
|
||||
|
||||
/// <summary>Paints each conflict block with the unresolved/resolved tint across a pane.</summary>
|
||||
private sealed class MergeBlockRenderer : IBackgroundRenderer
|
||||
{
|
||||
private readonly Func<IEnumerable<(int Offset, int Length, bool Resolved)>> _spans;
|
||||
private readonly IBrush _conflict;
|
||||
private readonly IBrush _resolved;
|
||||
|
||||
public MergeBlockRenderer(Func<IEnumerable<(int, int, bool)>> spans, IBrush conflict, IBrush resolved)
|
||||
{
|
||||
_spans = spans; _conflict = conflict; _resolved = resolved;
|
||||
}
|
||||
|
||||
public KnownLayer Layer => KnownLayer.Background;
|
||||
|
||||
public void Draw(TextView textView, DrawingContext drawingContext)
|
||||
{
|
||||
if (!textView.VisualLinesValid) return;
|
||||
foreach (var (offset, length, resolved) in _spans())
|
||||
{
|
||||
var brush = resolved ? _resolved : _conflict;
|
||||
if (length > 0)
|
||||
{
|
||||
var builder = new BackgroundGeometryBuilder { AlignToWholePixels = true, CornerRadius = 2 };
|
||||
builder.AddSegment(textView, new Seg(offset, length));
|
||||
var geo = builder.CreateGeometry();
|
||||
if (geo is not null) drawingContext.DrawGeometry(brush, null, geo);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Empty region (nothing accepted yet): a thin marker bar marks the spot.
|
||||
var at = offset < textView.Document.TextLength ? offset : Math.Max(0, offset - 1);
|
||||
var rects = BackgroundGeometryBuilder.GetRectsForSegment(textView, new Seg(at, 1)).ToList();
|
||||
if (rects.Count > 0)
|
||||
drawingContext.FillRectangle(brush, new Rect(0, rects[0].Top, textView.Bounds.Width, 3));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Makes everything read-only except the live conflict regions in the result document.</summary>
|
||||
private sealed class ConflictReadOnlyProvider : IReadOnlySectionProvider
|
||||
{
|
||||
private readonly Func<IEnumerable<(int Start, int End)>> _regions;
|
||||
public ConflictReadOnlyProvider(Func<IEnumerable<(int, int)>> regions) => _regions = regions;
|
||||
|
||||
public bool CanInsert(int offset) => _regions().Any(r => offset >= r.Start && offset <= r.End);
|
||||
|
||||
public IEnumerable<ISegment> GetDeletableSegments(ISegment segment)
|
||||
{
|
||||
foreach (var (start, end) in _regions())
|
||||
{
|
||||
var s = Math.Max(segment.Offset, start);
|
||||
var e = Math.Min(segment.EndOffset, end);
|
||||
if (e > s) yield return new Seg(s, e - s);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -138,8 +138,8 @@
|
||||
|
||||
<!-- Action buttons -->
|
||||
<StackPanel Orientation="Horizontal" Spacing="6" Margin="0,4,0,0">
|
||||
<Button Classes="btn" Content="{loc:Tr agent.openDiff}" Command="{Binding OpenDiffCommand}"/>
|
||||
<Button Classes="btn" Command="{Binding OpenWorktreeCommand}"
|
||||
<Button Classes="btn" Content="{loc:Tr agent.openDiff}" Command="{Binding Merge.OpenDiffCommand}"/>
|
||||
<Button Classes="btn" Command="{Binding Merge.OpenWorktreeCommand}"
|
||||
ToolTip.Tip="{loc:Tr agent.openWorktreeTip}">
|
||||
<StackPanel Orientation="Horizontal" Spacing="6" VerticalAlignment="Center">
|
||||
<PathIcon Data="{StaticResource Icon.ArrowOut}"
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
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">
|
||||
|
||||
@@ -21,7 +22,7 @@
|
||||
<Button Grid.Column="2"
|
||||
Classes="icon-btn"
|
||||
Margin="0,0,4,0"
|
||||
ToolTip.Tip="Copy formatted (title + description + open steps)"
|
||||
ToolTip.Tip="{loc:Tr details.copyFormattedTip}"
|
||||
Click="OnCopyClick">
|
||||
<PathIcon Data="{StaticResource Icon.Copy}" Width="11" Height="11"/>
|
||||
</Button>
|
||||
@@ -30,6 +31,7 @@
|
||||
<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}"/>
|
||||
@@ -40,7 +42,8 @@
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<!-- Body -->
|
||||
<!-- 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) -->
|
||||
@@ -159,6 +162,7 @@
|
||||
</Border>
|
||||
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
</DockPanel>
|
||||
</Border>
|
||||
|
||||
|
||||
@@ -29,7 +29,9 @@ public partial class DescriptionStepsCard : UserControl
|
||||
|
||||
private void OnSubtaskEditLostFocus(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (sender is TextBox { DataContext: SubtaskRowViewModel row })
|
||||
row.IsEditing = false;
|
||||
if (sender is TextBox { DataContext: SubtaskRowViewModel row }
|
||||
&& DataContext is DetailsIslandViewModel vm
|
||||
&& vm.CommitSubtaskEditCommand.CanExecute(row))
|
||||
vm.CommitSubtaskEditCommand.Execute(row);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,7 +30,7 @@
|
||||
<!-- Column 1: trash button (not running) -->
|
||||
<Button Grid.Column="1" Classes="icon-btn"
|
||||
Command="{Binding DeleteTaskCommand}"
|
||||
ToolTip.Tip="Delete task"
|
||||
ToolTip.Tip="{loc:Tr details.deleteTaskTip}"
|
||||
IsVisible="{Binding !IsRunning}"
|
||||
VerticalAlignment="Top"
|
||||
Margin="6,0,0,0">
|
||||
@@ -41,7 +41,7 @@
|
||||
<!-- Column 1: skull button (running) -->
|
||||
<Button Grid.Column="1" Classes="icon-btn"
|
||||
Command="{Binding StopCommand}"
|
||||
ToolTip.Tip="Kill session"
|
||||
ToolTip.Tip="{loc:Tr details.killSessionTip}"
|
||||
IsVisible="{Binding IsRunning}"
|
||||
VerticalAlignment="Top"
|
||||
Margin="6,0,0,0">
|
||||
@@ -52,7 +52,7 @@
|
||||
<!-- Column 2: gear button with agent settings flyout -->
|
||||
<Button Grid.Column="2" Classes="icon-btn"
|
||||
ToolTip.Tip="{loc:Tr details.agentSettingsTip}"
|
||||
IsEnabled="{Binding IsAgentSectionEnabled}"
|
||||
IsEnabled="{Binding AgentSettings.IsAgentSectionEnabled}"
|
||||
VerticalAlignment="Top"
|
||||
Margin="6,0,0,0">
|
||||
<TextBlock Text="⚙" FontSize="{StaticResource FontSizeTaskTitle}"/>
|
||||
@@ -64,50 +64,50 @@
|
||||
<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}"/>
|
||||
<ctl:InheritedBadge Grid.Column="1" BadgeText="{Binding AgentSettings.ModelBadge}"/>
|
||||
<Button Grid.Column="3" Classes="icon-btn" Content="↺" ToolTip.Tip="{loc:Tr settings.inherit.resetToInherited}"
|
||||
Command="{Binding ResetTaskModelCommand}"/>
|
||||
Command="{Binding AgentSettings.ResetTaskModelCommand}"/>
|
||||
</Grid>
|
||||
<ComboBox ItemsSource="{Binding TaskModelOptions}"
|
||||
SelectedItem="{Binding TaskModelSelection, Mode=TwoWay}"
|
||||
PlaceholderText="{Binding ModelInheritedHint}"
|
||||
<ComboBox ItemsSource="{Binding AgentSettings.TaskModelOptions}"
|
||||
SelectedItem="{Binding AgentSettings.TaskModelSelection, Mode=TwoWay}"
|
||||
PlaceholderText="{Binding AgentSettings.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}"/>
|
||||
<ctl:InheritedBadge Grid.Column="1" BadgeText="{Binding AgentSettings.TurnsBadge}"/>
|
||||
<Button Grid.Column="3" Classes="icon-btn" Content="↺" ToolTip.Tip="{loc:Tr settings.inherit.resetToInherited}"
|
||||
Command="{Binding ResetTaskTurnsCommand}"/>
|
||||
Command="{Binding AgentSettings.ResetTaskTurnsCommand}"/>
|
||||
</Grid>
|
||||
<NumericUpDown Value="{Binding TaskMaxTurns, Mode=TwoWay}"
|
||||
PlaceholderText="{Binding TurnsInheritedHint}"
|
||||
<NumericUpDown Value="{Binding AgentSettings.TaskMaxTurns, Mode=TwoWay}"
|
||||
PlaceholderText="{Binding AgentSettings.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}"
|
||||
<TextBox Text="{Binding AgentSettings.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}}"/>
|
||||
IsVisible="{Binding AgentSettings.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}}"/>
|
||||
Text="{Binding AgentSettings.EffectiveSystemPromptHint}"
|
||||
IsVisible="{Binding AgentSettings.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}"/>
|
||||
<ctl:InheritedBadge Grid.Column="1" BadgeText="{Binding AgentSettings.AgentBadge}"/>
|
||||
<Button Grid.Column="3" Classes="icon-btn" Content="↺" ToolTip.Tip="{loc:Tr settings.inherit.resetToInherited}"
|
||||
Command="{Binding ResetTaskAgentCommand}"/>
|
||||
Command="{Binding AgentSettings.ResetTaskAgentCommand}"/>
|
||||
</Grid>
|
||||
<ComboBox ItemsSource="{Binding TaskAgentOptions}"
|
||||
SelectedItem="{Binding TaskSelectedAgent, Mode=TwoWay}"
|
||||
<ComboBox ItemsSource="{Binding AgentSettings.TaskAgentOptions}"
|
||||
SelectedItem="{Binding AgentSettings.TaskSelectedAgent, Mode=TwoWay}"
|
||||
HorizontalAlignment="Stretch">
|
||||
<ComboBox.ItemTemplate>
|
||||
<DataTemplate>
|
||||
|
||||
@@ -24,6 +24,18 @@
|
||||
<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" />
|
||||
@@ -36,14 +48,15 @@
|
||||
<Setter Property="Foreground" Value="{StaticResource TextMuteBrush}" />
|
||||
</Style>
|
||||
<Style Selector="Button.prompt-action /template/ ContentPresenter">
|
||||
<Setter Property="Background" Value="Transparent" />
|
||||
<Setter Property="Background" Value="Transparent" />
|
||||
<Setter Property="TextElement.Foreground" Value="{StaticResource TextMuteBrush}" />
|
||||
</Style>
|
||||
<Style Selector="Button.prompt-action:pointerover /template/ ContentPresenter">
|
||||
<Setter Property="Background" Value="Transparent" />
|
||||
<Setter Property="TextElement.Foreground" Value="{StaticResource TextBrush}" />
|
||||
</Style>
|
||||
<Style Selector="Button.prompt-action.accent">
|
||||
<Setter Property="Foreground" Value="{StaticResource AccentBrush}" />
|
||||
<Style 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}" />
|
||||
@@ -59,7 +72,7 @@
|
||||
<Grid DockPanel.Dock="Top" ColumnDefinitions="Auto,*,Auto"
|
||||
Background="{DynamicResource Surface2Brush}" Height="28">
|
||||
|
||||
<!-- Traffic-light dots -->
|
||||
<!-- Traffic-light dots (decorative) -->
|
||||
<StackPanel Grid.Column="0" Orientation="Horizontal" Spacing="6"
|
||||
Margin="12,0" VerticalAlignment="Center">
|
||||
<Ellipse Classes="dot-red" />
|
||||
@@ -154,12 +167,20 @@
|
||||
CommandParameter="output" />
|
||||
<Button Classes="tab-btn"
|
||||
Classes.active="{Binding IsGitTab}"
|
||||
Content="Git"
|
||||
Command="{Binding SelectTabCommand}"
|
||||
CommandParameter="git" />
|
||||
CommandParameter="git">
|
||||
<StackPanel Orientation="Horizontal" Spacing="6">
|
||||
<TextBlock Text="Git" VerticalAlignment="Center" />
|
||||
<!-- Review-pending dot: where to act when a task awaits review -->
|
||||
<Ellipse Width="6" Height="6" VerticalAlignment="Center"
|
||||
Fill="{DynamicResource AccentBrush}"
|
||||
IsVisible="{Binding IsWaitingForReview}" />
|
||||
</StackPanel>
|
||||
</Button>
|
||||
<Button Classes="tab-btn"
|
||||
Classes.active="{Binding IsSessionTab}"
|
||||
Content="Session"
|
||||
IsVisible="{Binding HasChildOutcomes}"
|
||||
Command="{Binding SelectTabCommand}"
|
||||
CommandParameter="session" />
|
||||
</StackPanel>
|
||||
@@ -171,39 +192,47 @@
|
||||
<!-- Output: log + review footer, both gated on IsOutputTab -->
|
||||
<DockPanel IsVisible="{Binding IsOutputTab}" LastChildFill="True">
|
||||
|
||||
<!-- Review prompt — sits directly on the terminal, like a shell input line;
|
||||
only while awaiting review. No border/fill so it reads as part of the log. -->
|
||||
<Grid DockPanel.Dock="Bottom"
|
||||
IsVisible="{Binding IsWaitingForReview}"
|
||||
ColumnDefinitions="Auto,*,Auto"
|
||||
Margin="12,2,12,8">
|
||||
<TextBlock Grid.Column="0" Text="❯"
|
||||
FontFamily="{StaticResource MonoFont}"
|
||||
FontSize="{StaticResource FontSizeMono}"
|
||||
Foreground="{DynamicResource AccentBrush}"
|
||||
VerticalAlignment="Top" Margin="0,2,8,0" />
|
||||
<TextBox Grid.Column="1"
|
||||
Name="ReviewInput"
|
||||
KeyDown="OnReviewInputKeyDown"
|
||||
Text="{Binding ReviewFeedback, Mode=TwoWay}"
|
||||
AcceptsReturn="True"
|
||||
TextWrapping="Wrap"
|
||||
MaxHeight="160"
|
||||
PlaceholderText="Feedback for the next run…"
|
||||
Background="Transparent"
|
||||
BorderThickness="0"
|
||||
Padding="0"
|
||||
VerticalContentAlignment="Center"
|
||||
FontFamily="{StaticResource MonoFont}"
|
||||
FontSize="{StaticResource FontSizeMono}" />
|
||||
<StackPanel Grid.Column="2" Orientation="Horizontal" Spacing="10"
|
||||
VerticalAlignment="Top" Margin="12,2,0,0">
|
||||
<Button Classes="prompt-action accent" Content="[Retry]"
|
||||
Command="{Binding RejectReviewCommand}" />
|
||||
<Button Classes="prompt-action" Content="[Reset]"
|
||||
Command="{Binding ParkReviewCommand}" />
|
||||
<!-- 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>
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<!-- Review footer: feedback + Resume session, shown while awaiting review.
|
||||
Lives here (with the live log) rather than the Git tab. -->
|
||||
<Border DockPanel.Dock="Bottom"
|
||||
IsVisible="{Binding IsWaitingForReview}"
|
||||
Margin="12,6,12,2">
|
||||
<StackPanel Spacing="8">
|
||||
<TextBox Name="ReviewInput"
|
||||
KeyDown="OnReviewInputKeyDown"
|
||||
Text="{Binding ReviewFeedback, Mode=TwoWay}"
|
||||
AcceptsReturn="True"
|
||||
TextWrapping="Wrap"
|
||||
MaxHeight="120"
|
||||
PlaceholderText="Feedback for a re-run…"
|
||||
FontFamily="{StaticResource MonoFont}"
|
||||
FontSize="{StaticResource FontSizeMono}" />
|
||||
<Button Classes="btn" Content="Resume session"
|
||||
HorizontalAlignment="Left"
|
||||
ToolTip.Tip="{loc:Tr session.reviewContinueTip}"
|
||||
Command="{Binding RejectReviewCommand}" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<ScrollViewer Name="LogScroll"
|
||||
VerticalScrollBarVisibility="Visible"
|
||||
@@ -228,64 +257,82 @@
|
||||
|
||||
</DockPanel>
|
||||
|
||||
<!-- Git: merge target, approve, diff, worktree -->
|
||||
<!-- Git: the review + merge cockpit -->
|
||||
<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 controls — shown whenever there's a worktree / unit to merge.
|
||||
Header reads REVIEW while a decision is pending, otherwise MERGE. -->
|
||||
<StackPanel Spacing="14" IsVisible="{Binding Merge.ShowMergeSection}">
|
||||
<TextBlock Classes="section-label" Text="REVIEW"
|
||||
IsVisible="{Binding IsWaitingForReview}" />
|
||||
<TextBlock Classes="section-label" Text="MERGE"
|
||||
IsVisible="{Binding !IsWaitingForReview}" />
|
||||
|
||||
<!-- Merge & worktree management (moved from Session tab) -->
|
||||
<StackPanel Spacing="10" IsVisible="{Binding ShowMergeSection}">
|
||||
<TextBlock Classes="section-label" Text="MERGE & WORKTREE" />
|
||||
<!-- Change summary (review only) -->
|
||||
<StackPanel Orientation="Horizontal" Spacing="6"
|
||||
IsVisible="{Binding IsWaitingForReview}">
|
||||
<TextBlock Classes="diff-add" Text="{Binding DiffAddText}" />
|
||||
<TextBlock Classes="diff-del" Text="{Binding DiffDelText}" />
|
||||
</StackPanel>
|
||||
|
||||
<!-- Target branch + pre-flight mergeability -->
|
||||
<StackPanel Spacing="4">
|
||||
<TextBlock Classes="field-label" Text="Merge target" />
|
||||
<ComboBox ItemsSource="{Binding MergeTargetBranches}"
|
||||
SelectedItem="{Binding SelectedMergeTarget, Mode=TwoWay}"
|
||||
<TextBlock Classes="field-label" Text="Target branch" />
|
||||
<ComboBox ItemsSource="{Binding Merge.MergeTargetBranches}"
|
||||
SelectedItem="{Binding Merge.SelectedMergeTarget, Mode=TwoWay}"
|
||||
HorizontalAlignment="Stretch" />
|
||||
</StackPanel>
|
||||
|
||||
<StackPanel Spacing="0">
|
||||
<TextBlock Classes="meta" Text="{Binding MergePreviewText}" TextWrapping="Wrap"
|
||||
<TextBlock Classes="meta" Text="{Binding Merge.MergePreviewText}" TextWrapping="Wrap"
|
||||
Foreground="{DynamicResource MossBrush}"
|
||||
IsVisible="{Binding MergeIsClean}" />
|
||||
<TextBlock Classes="meta" Text="{Binding MergePreviewText}" TextWrapping="Wrap"
|
||||
IsVisible="{Binding Merge.MergeIsClean}" />
|
||||
<TextBlock Classes="meta" Text="{Binding Merge.MergePreviewText}" TextWrapping="Wrap"
|
||||
Foreground="{DynamicResource BloodBrush}"
|
||||
IsVisible="{Binding MergeIsConflict}" />
|
||||
<TextBlock Classes="meta" Text="{Binding MergePreviewText}" TextWrapping="Wrap"
|
||||
IsVisible="{Binding Merge.MergeIsConflict}" />
|
||||
<TextBlock Classes="meta" Text="{Binding Merge.MergePreviewText}" TextWrapping="Wrap"
|
||||
Foreground="{DynamicResource TextMuteBrush}"
|
||||
IsVisible="{Binding ShowMergePreviewMuted}" />
|
||||
IsVisible="{Binding Merge.ShowMergePreviewMuted}" />
|
||||
</StackPanel>
|
||||
|
||||
<!-- Inspect: diff / worktree / combined diff -->
|
||||
<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}" />
|
||||
Command="{Binding Merge.OpenDiffCommand}" />
|
||||
<Button Classes="btn" Margin="0,0,8,8"
|
||||
Command="{Binding OpenWorktreeCommand}">
|
||||
ToolTip.Tip="{loc:Tr agent.openWorktreeTip}"
|
||||
Command="{Binding Merge.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}" />
|
||||
Command="{Binding Merge.ReviewCombinedDiffCommand}" />
|
||||
</WrapPanel>
|
||||
<TextBlock Text="{Binding MergeAllError}"
|
||||
Foreground="{DynamicResource BloodBrush}"
|
||||
TextWrapping="Wrap"
|
||||
IsVisible="{Binding MergeAllError,
|
||||
Converter={x:Static ObjectConverters.IsNotNull}}" />
|
||||
</StackPanel>
|
||||
|
||||
<!-- Review decision — the merge verbs. Feedback + Resume session moved to the
|
||||
Output tab. Present while awaiting review, even for sandbox runs. -->
|
||||
<StackPanel Spacing="10" IsVisible="{Binding IsWaitingForReview}">
|
||||
<Border Height="1" Background="{DynamicResource LineBrush}"
|
||||
IsVisible="{Binding Merge.ShowMergeSection}" />
|
||||
|
||||
<WrapPanel Orientation="Horizontal">
|
||||
<Button Classes="btn accent" Content="Approve & Merge" Margin="0,0,8,8"
|
||||
Command="{Binding ApproveReviewCommand}" />
|
||||
<Button Classes="btn" Content="Park" Margin="0,0,8,8"
|
||||
ToolTip.Tip="Set aside — back to Idle, keeps the worktree"
|
||||
Command="{Binding ParkReviewCommand}" />
|
||||
<Button Classes="btn" Content="Cancel" Margin="0,0,8,8"
|
||||
Command="{Binding CancelReviewCommand}" />
|
||||
</WrapPanel>
|
||||
|
||||
<Button Classes="prompt-action" Content="Reset (discard branch)…"
|
||||
ToolTip.Tip="{loc:Tr session.reviewResetTip}"
|
||||
Command="{Binding ResetReviewCommand}" />
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
|
||||
@@ -293,6 +340,22 @@
|
||||
<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" />
|
||||
@@ -315,13 +378,6 @@
|
||||
</ItemsControl>
|
||||
</StackPanel>
|
||||
|
||||
<!-- Empty state: nothing to manage yet -->
|
||||
<TextBlock IsVisible="{Binding ShowSessionEmpty}"
|
||||
Classes="meta"
|
||||
Foreground="{DynamicResource TextMuteBrush}"
|
||||
TextWrapping="Wrap"
|
||||
Text="Nothing to manage yet — subtask outcomes appear here once the run finishes. Review in the Output tab, merge in the Git tab." />
|
||||
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
|
||||
|
||||
@@ -39,21 +39,59 @@
|
||||
<Grid>
|
||||
|
||||
<!-- Task detail: description/steps card (upper) + pinned work console (lower) -->
|
||||
<Grid IsVisible="{Binding IsTaskDetailVisible}"
|
||||
Margin="14,12,14,12"
|
||||
RowDefinitions="2*,*">
|
||||
<ScrollViewer Grid.Row="0" VerticalScrollBarVisibility="Auto">
|
||||
<detail:DescriptionStepsCard VerticalAlignment="Top"/>
|
||||
</ScrollViewer>
|
||||
<detail:WorkConsole Grid.Row="1" Margin="0,10,0,0"/>
|
||||
<Grid x:Name="DetailBodyGrid"
|
||||
IsVisible="{Binding IsTaskDetailVisible}"
|
||||
Margin="14,12,14,12">
|
||||
<Grid.RowDefinitions>
|
||||
<!-- Auto: the description sizes to its content so the console takes
|
||||
every spare pixel when it's short. Row limits are proportional
|
||||
and set in code-behind (UpdateRowLimits): the description row is
|
||||
capped at 2/3 of the island and the console row floored at 1/3,
|
||||
so the console can be dragged down to (but not below) 1/3 and a
|
||||
long description never spills over the footer. -->
|
||||
<RowDefinition Height="Auto" MinHeight="90"/>
|
||||
<RowDefinition Height="*"/>
|
||||
</Grid.RowDefinitions>
|
||||
<detail:DescriptionStepsCard x:Name="DescriptionCard" Grid.Row="0"/>
|
||||
|
||||
<!-- Console row also hosts the roadblock card (docked above the console)
|
||||
so it surfaces at a glance between Details and Output. Keeping it
|
||||
inside row 1 leaves the desc/console resize model untouched. -->
|
||||
<DockPanel Grid.Row="1" Margin="0,10,0,0">
|
||||
<Border DockPanel.Dock="Top"
|
||||
IsVisible="{Binding ShowRoadblockCard}"
|
||||
Margin="0,0,0,10" Padding="12,10"
|
||||
Background="{DynamicResource ReviewTintBrush}"
|
||||
BorderBrush="{DynamicResource ReviewTintBorderBrush}"
|
||||
BorderThickness="1" CornerRadius="10">
|
||||
<StackPanel Spacing="6">
|
||||
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||
<PathIcon Data="{StaticResource Icon.Warning}"
|
||||
Foreground="{DynamicResource StatusReviewBrush}"
|
||||
Width="14" Height="14" VerticalAlignment="Center"/>
|
||||
<TextBlock Classes="section-label" Text="ROADBLOCK"
|
||||
Foreground="{DynamicResource StatusReviewBrush}"
|
||||
VerticalAlignment="Center"/>
|
||||
</StackPanel>
|
||||
<SelectableTextBlock Text="{Binding Roadblocks}"
|
||||
TextWrapping="Wrap"
|
||||
Foreground="{DynamicResource TextDimBrush}"/>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
<detail:WorkConsole/>
|
||||
</DockPanel>
|
||||
|
||||
<!-- Resize by dragging the console's top edge — a transparent splitter
|
||||
over the gap above the console; no standalone separator bar. -->
|
||||
<GridSplitter Grid.Row="1"
|
||||
over the gap above the console; no standalone separator bar.
|
||||
Stays draggable while maximized. -->
|
||||
<GridSplitter x:Name="DetailSplitter" Grid.Row="1"
|
||||
VerticalAlignment="Top"
|
||||
Height="10"
|
||||
HorizontalAlignment="Stretch"
|
||||
ResizeDirection="Rows"
|
||||
Background="Transparent"/>
|
||||
Background="Transparent"
|
||||
DragStarted="OnSplitterDragStarted"
|
||||
DragCompleted="OnSplitterDragCompleted"/>
|
||||
</Grid>
|
||||
|
||||
<!-- Notes mode -->
|
||||
@@ -66,16 +104,16 @@
|
||||
<DockPanel>
|
||||
<Border DockPanel.Dock="Top" Padding="12,8">
|
||||
<Button Classes="btn primary"
|
||||
Command="{Binding PlanDayCommand}"
|
||||
IsEnabled="{Binding !IsPrepRunning}"
|
||||
Command="{Binding Prep.PlanDayCommand}"
|
||||
IsEnabled="{Binding !Prep.IsPrepRunning}"
|
||||
Content="{loc:Tr details.planDay}"/>
|
||||
</Border>
|
||||
<Panel>
|
||||
<islands:SessionTerminalView
|
||||
Margin="18,8,18,0"
|
||||
Entries="{Binding PrepLog}" Label="daily-prep"
|
||||
IsRunning="{Binding IsPrepRunning}"/>
|
||||
<TextBlock IsVisible="{Binding ShowPrepEmptyState}"
|
||||
Entries="{Binding Prep.PrepLog}" Label="daily-prep"
|
||||
IsRunning="{Binding Prep.IsPrepRunning}"/>
|
||||
<TextBlock IsVisible="{Binding Prep.ShowPrepEmptyState}"
|
||||
HorizontalAlignment="Center" VerticalAlignment="Center"
|
||||
Foreground="{DynamicResource TextMuteBrush}"
|
||||
Text="{loc:Tr details.prepEmpty}"/>
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Input;
|
||||
using Avalonia.Layout;
|
||||
using Avalonia.Media;
|
||||
using Avalonia.Reactive;
|
||||
using ClaudeDo.Ui.ViewModels.Islands;
|
||||
using ClaudeDo.Ui.Views.Modals;
|
||||
using ClaudeDo.Ui.Views.Planning;
|
||||
@@ -10,17 +12,50 @@ namespace ClaudeDo.Ui.Views.Islands;
|
||||
|
||||
public partial class DetailsIslandView : UserControl
|
||||
{
|
||||
// Per-task description height (pixels) once the user drags the splitter.
|
||||
// Keyed by task id so each task keeps its own resize; tasks that were
|
||||
// never dragged stay dynamic (Auto-sized description).
|
||||
private readonly Dictionary<string, double> _descriptionHeights = new();
|
||||
private DetailsIslandViewModel? _vm;
|
||||
|
||||
public DetailsIslandView()
|
||||
{
|
||||
InitializeComponent();
|
||||
DataContextChanged += OnDataContextChanged;
|
||||
// Keep the row limits proportional to the island height: description
|
||||
// capped at 2/3, console floored at 1/3. The GridSplitter honours these
|
||||
// row Min/Max during a drag, so the console stops shrinking at 1/3.
|
||||
DetailBodyGrid.GetObservable(BoundsProperty)
|
||||
.Subscribe(new AnonymousObserver<Rect>(_ => UpdateRowLimits()));
|
||||
}
|
||||
|
||||
private void UpdateRowLimits()
|
||||
{
|
||||
var h = DetailBodyGrid.Bounds.Height;
|
||||
if (h <= 0) return;
|
||||
DetailBodyGrid.RowDefinitions[0].MaxHeight = h * 2.0 / 3.0;
|
||||
DetailBodyGrid.RowDefinitions[1].MinHeight = h / 3.0;
|
||||
// The description sits in an Auto row, which measures its cell with
|
||||
// infinite height — so the card's inner ScrollViewer thinks everything
|
||||
// fits and never scrolls. Bounding the card itself gives that
|
||||
// ScrollViewer a finite measure constraint so it engages once the
|
||||
// content exceeds 2/3 of the island. (RowDefinition.MaxHeight above only
|
||||
// clamps the drag and the final row height, not the measure constraint.)
|
||||
DescriptionCard.MaxHeight = h * 2.0 / 3.0;
|
||||
}
|
||||
|
||||
private void OnDataContextChanged(object? sender, EventArgs e)
|
||||
{
|
||||
if (_vm != null)
|
||||
_vm.PropertyChanged -= OnViewModelPropertyChanged;
|
||||
|
||||
if (DataContext is DetailsIslandViewModel vm)
|
||||
{
|
||||
vm.ShowDiffModal = async (diffVm) =>
|
||||
_vm = vm;
|
||||
vm.PropertyChanged += OnViewModelPropertyChanged;
|
||||
ApplyResizeStateForCurrentTask();
|
||||
|
||||
vm.Merge.ShowDiffModal = async (diffVm) =>
|
||||
{
|
||||
var owner = TopLevel.GetTopLevel(this) as Window;
|
||||
if (owner == null) return;
|
||||
@@ -28,7 +63,7 @@ public partial class DetailsIslandView : UserControl
|
||||
await modal.ShowDialog(owner);
|
||||
};
|
||||
|
||||
vm.ShowMergeModal = async (mergeVm) =>
|
||||
vm.Merge.ShowMergeModal = async (mergeVm) =>
|
||||
{
|
||||
var owner = TopLevel.GetTopLevel(this) as Window;
|
||||
if (owner == null) return;
|
||||
@@ -36,7 +71,7 @@ public partial class DetailsIslandView : UserControl
|
||||
await modal.ShowDialog(owner);
|
||||
};
|
||||
|
||||
vm.ShowPlanningDiffModal = async (planningDiffVm) =>
|
||||
vm.Merge.ShowPlanningDiffModal = async (planningDiffVm) =>
|
||||
{
|
||||
var owner = TopLevel.GetTopLevel(this) as Window;
|
||||
if (owner == null) return;
|
||||
@@ -49,6 +84,41 @@ public partial class DetailsIslandView : UserControl
|
||||
}
|
||||
}
|
||||
|
||||
// Restores the resize state for the currently-selected task: a task the
|
||||
// user has dragged before gets its pinned pixel height (cap lifted); a task
|
||||
// never dragged falls back to dynamic sizing (Auto row + the bound cap).
|
||||
private void OnViewModelPropertyChanged(object? sender, System.ComponentModel.PropertyChangedEventArgs e)
|
||||
{
|
||||
if (e.PropertyName == nameof(DetailsIslandViewModel.Task))
|
||||
ApplyResizeStateForCurrentTask();
|
||||
}
|
||||
|
||||
private void ApplyResizeStateForCurrentTask()
|
||||
{
|
||||
// A task dragged before keeps its pixel height (clamped by the row's
|
||||
// 2/3 MaxHeight); a task never dragged stays Auto-sized.
|
||||
DetailBodyGrid.RowDefinitions[0].Height = _vm?.Task?.Id is string id && _descriptionHeights.TryGetValue(id, out var h)
|
||||
? new GridLength(h, GridUnitType.Pixel)
|
||||
: GridLength.Auto;
|
||||
}
|
||||
|
||||
// Pin the (until now Auto-sized) description row to its current pixel
|
||||
// height so the splitter resizes smoothly from there.
|
||||
private void OnSplitterDragStarted(object? sender, VectorEventArgs e)
|
||||
{
|
||||
var descRow = DetailBodyGrid.RowDefinitions[0];
|
||||
if (descRow.Height.IsAuto)
|
||||
descRow.Height = new GridLength(DescriptionCard.Bounds.Height, GridUnitType.Pixel);
|
||||
}
|
||||
|
||||
// Remember the dragged height for this task so switching tasks keeps each
|
||||
// task's resize independent.
|
||||
private void OnSplitterDragCompleted(object? sender, VectorEventArgs e)
|
||||
{
|
||||
if (_vm?.Task?.Id is string id)
|
||||
_descriptionHeights[id] = DetailBodyGrid.RowDefinitions[0].Height.Value;
|
||||
}
|
||||
|
||||
private async System.Threading.Tasks.Task ShowErrorDialogAsync(string message)
|
||||
{
|
||||
var owner = TopLevel.GetTopLevel(this) as Window;
|
||||
|
||||
@@ -28,11 +28,8 @@
|
||||
<ItemsControl ItemsSource="{Binding Bullets}">
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:NoteBulletViewModel">
|
||||
<Grid ColumnDefinitions="*,Auto,Auto" Margin="0,2" ColumnSpacing="6">
|
||||
<TextBox Grid.Column="0" Text="{Binding Text}"/>
|
||||
<Button Grid.Column="1" Classes="btn" Content="{loc:Tr notes.save}" Command="{Binding SaveCommand}"/>
|
||||
<Button Grid.Column="2" Classes="btn" Content="{loc:Tr notes.delete}" Command="{Binding DeleteCommand}"/>
|
||||
</Grid>
|
||||
<TextBox Text="{Binding Text}" Margin="0,2"
|
||||
LostFocus="OnBulletLostFocus"/>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
|
||||
@@ -1,8 +1,18 @@
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Interactivity;
|
||||
using ClaudeDo.Ui.ViewModels.Islands;
|
||||
|
||||
namespace ClaudeDo.Ui.Views.Islands;
|
||||
|
||||
public partial class NotesEditorView : UserControl
|
||||
{
|
||||
public NotesEditorView() => InitializeComponent();
|
||||
|
||||
private void OnBulletLostFocus(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (sender is TextBox { DataContext: NoteBulletViewModel bullet }
|
||||
&& DataContext is NotesEditorViewModel vm
|
||||
&& vm.CommitBulletCommand.CanExecute(bullet))
|
||||
vm.CommitBulletCommand.Execute(bullet);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,8 +20,8 @@
|
||||
<!-- Indent wrapper: col 0 = 24px child indent track, col 1 = content -->
|
||||
<Grid Grid.Row="1" ColumnDefinitions="Auto,*">
|
||||
|
||||
<!-- Indent track (only visible for child tasks) -->
|
||||
<Border Grid.Column="0" Width="24" IsVisible="{Binding IsChild}" VerticalAlignment="Stretch">
|
||||
<!-- Indent track (only while the parent shares this view; orphaned children render flat) -->
|
||||
<Border Grid.Column="0" Width="24" IsVisible="{Binding ShowAsChild}" VerticalAlignment="Stretch">
|
||||
<Rectangle Width="1" Fill="{DynamicResource LineBrush}"
|
||||
HorizontalAlignment="Right" Margin="0,4"/>
|
||||
</Border>
|
||||
@@ -56,17 +56,23 @@
|
||||
<MenuItem Header="{loc:Tr tasks.ctxResumePlanningSession}"
|
||||
Click="OnResumePlanningSessionClick"
|
||||
IsVisible="{Binding CanResumeOrDiscardPlanning}"/>
|
||||
<MenuItem Header="{loc:Tr tasks.ctxFinalizePlanningSession}"
|
||||
Click="OnFinalizePlanningSessionClick"
|
||||
IsVisible="{Binding CanFinalizePlanning}"/>
|
||||
<MenuItem Header="{loc:Tr tasks.ctxDiscardPlanningSession}"
|
||||
Click="OnDiscardPlanningSessionClick"
|
||||
IsVisible="{Binding CanResumeOrDiscardPlanning}"/>
|
||||
<MenuItem Header="{loc:Tr tasks.ctxQueueSubtasks}"
|
||||
Click="OnQueuePlanningSubtasksClick"
|
||||
IsVisible="{Binding CanQueuePlan}"/>
|
||||
<Separator/>
|
||||
<MenuItem Header="{loc:Tr tasks.ctxScheduleFor}" Click="OnScheduleForClick"/>
|
||||
<MenuItem Header="{loc:Tr tasks.ctxClearSchedule}"
|
||||
IsVisible="{Binding HasSchedule}"
|
||||
Click="OnClearScheduleClick"/>
|
||||
<MenuItem Header="{loc:Tr tasks.ctxAddToMyDay}"
|
||||
IsVisible="{Binding CanAddToMyDay}"
|
||||
Click="OnAddToMyDayClick"/>
|
||||
<MenuItem Header="{loc:Tr tasks.ctxRemoveFromMyDay}"
|
||||
IsVisible="{Binding IsMyDay}"
|
||||
Click="OnRemoveFromMyDayClick"/>
|
||||
</ContextMenu>
|
||||
</Border.ContextMenu>
|
||||
<Grid ColumnDefinitions="0,18,32,*,Auto,Auto,32" Margin="6,8,10,8">
|
||||
@@ -78,7 +84,8 @@
|
||||
CommandParameter="{Binding}"
|
||||
Classes="icon-btn"
|
||||
Width="18" Height="18"
|
||||
VerticalAlignment="Center">
|
||||
VerticalAlignment="Center"
|
||||
ToolTip.Tip="{loc:Tr tasks.toggleSubtasksTip}">
|
||||
<Panel>
|
||||
<TextBlock Classes="meta" Text="▾" IsVisible="{Binding IsExpanded}"
|
||||
VerticalAlignment="Center" HorizontalAlignment="Center"/>
|
||||
@@ -141,10 +148,11 @@
|
||||
Data="{StaticResource Icon.AgentSuggested}"
|
||||
Foreground="#5C8FA8"
|
||||
IsVisible="{Binding IsAgentSuggested}"
|
||||
ToolTip.Tip="Suggested by the agent"/>
|
||||
ToolTip.Tip="{loc:Tr tasks.agentSuggestedTip}"/>
|
||||
|
||||
<!-- Status chip -->
|
||||
<Border Classes="chip"
|
||||
Classes.parked="{Binding IsParked}"
|
||||
Classes.running="{Binding Status, Converter={StaticResource EqStatus}, ConverterParameter=Running}"
|
||||
Classes.review="{Binding Status, Converter={StaticResource EqStatus}, ConverterParameter=WaitingForReview}"
|
||||
Classes.children="{Binding Status, Converter={StaticResource EqStatus}, ConverterParameter=WaitingForChildren}"
|
||||
@@ -197,6 +205,7 @@
|
||||
<!-- 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}">
|
||||
@@ -210,7 +219,8 @@
|
||||
Classes.on="{Binding IsStarred}"
|
||||
VerticalAlignment="Top" Margin="0,2,0,0"
|
||||
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}"/>
|
||||
</Button>
|
||||
</Grid>
|
||||
|
||||
@@ -48,6 +48,18 @@ public partial class TaskRowView : UserControl
|
||||
await vm.ClearScheduleCommand.ExecuteAsync(row);
|
||||
}
|
||||
|
||||
private async void OnAddToMyDayClick(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (DataContext is TaskRowViewModel row && FindTasksVm() is { } vm)
|
||||
await vm.AddToMyDayCommand.ExecuteAsync(row);
|
||||
}
|
||||
|
||||
private async void OnRemoveFromMyDayClick(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (DataContext is TaskRowViewModel row && FindTasksVm() is { } vm)
|
||||
await vm.RemoveFromMyDayCommand.ExecuteAsync(row);
|
||||
}
|
||||
|
||||
private async void OnOpenPlanningSessionClick(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (DataContext is TaskRowViewModel row && FindTasksVm() is { } vm)
|
||||
@@ -72,10 +84,10 @@ public partial class TaskRowView : UserControl
|
||||
await vm.DiscardPlanningSessionCommand.ExecuteAsync(row);
|
||||
}
|
||||
|
||||
private async void OnQueuePlanningSubtasksClick(object? sender, RoutedEventArgs e)
|
||||
private async void OnFinalizePlanningSessionClick(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (DataContext is TaskRowViewModel row && FindTasksVm() is { } vm)
|
||||
await vm.QueuePlanningSubtasksCommand.ExecuteAsync(row);
|
||||
await vm.FinalizePlanningSessionCommand.ExecuteAsync(row);
|
||||
}
|
||||
|
||||
private async void OnSetStatusClick(object? sender, RoutedEventArgs e)
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
<KeyBinding Gesture="Shift+OemQuestion" Command="{Binding FocusSearchCommand}"/>
|
||||
<KeyBinding Gesture="Ctrl+N" Command="{Binding FocusAddTaskCommand}"/>
|
||||
</Window.KeyBindings>
|
||||
<Grid RowDefinitions="36,Auto,*,22">
|
||||
<Grid x:Name="RootGrid" RowDefinitions="36,Auto,*,22">
|
||||
<!-- Custom title bar -->
|
||||
<Border Grid.Row="0"
|
||||
Background="{DynamicResource DeepBrush}"
|
||||
@@ -57,18 +57,26 @@
|
||||
<Menu Margin="12,0,0,0"
|
||||
Background="Transparent"
|
||||
VerticalAlignment="Center">
|
||||
<MenuItem Header="{loc:Tr shell.menu.worker}"
|
||||
FontSize="{StaticResource FontSizeMono}"
|
||||
Foreground="{DynamicResource TextDimBrush}">
|
||||
<MenuItem Header="{loc:Tr shell.menu.restartWorker}"
|
||||
Command="{Binding RestartWorkerCommand}"/>
|
||||
<MenuItem Header="{loc:Tr shell.menu.checkForUpdates}"
|
||||
Command="{Binding CheckForUpdatesCommand}"/>
|
||||
</MenuItem>
|
||||
<MenuItem Header="{loc:Tr shell.menu.repositories}"
|
||||
FontSize="{StaticResource FontSizeMono}"
|
||||
Foreground="{DynamicResource TextDimBrush}">
|
||||
<MenuItem Header="{loc:Tr shell.menu.addRepos}" Command="{Binding OpenRepoImportCommand}"/>
|
||||
<MenuItem Header="{loc:Tr shell.menu.worktrees}"
|
||||
Command="{Binding OpenWorktreesOverviewGlobalCommand}"/>
|
||||
</MenuItem>
|
||||
<MenuItem Header="{loc:Tr shell.menu.help}"
|
||||
FontSize="{StaticResource FontSizeMono}"
|
||||
Foreground="{DynamicResource TextDimBrush}">
|
||||
<MenuItem Header="{loc:Tr shell.menu.checkForUpdates}"
|
||||
Command="{Binding CheckForUpdatesCommand}"/>
|
||||
<MenuItem Header="{loc:Tr shell.menu.restartWorker}"
|
||||
Command="{Binding RestartWorkerCommand}"/>
|
||||
<MenuItem Header="{loc:Tr shell.menu.worktrees}"
|
||||
Command="{Binding OpenWorktreesOverviewGlobalCommand}"/>
|
||||
<MenuItem Header="{loc:Tr shell.menu.weeklyReport}" Command="{Binding OpenWeeklyReportCommand}"/>
|
||||
<MenuItem Header="{loc:Tr shell.menu.about}" Command="{Binding OpenAboutCommand}"/>
|
||||
<MenuItem Header="{loc:Tr shell.menu.addRepos}" Command="{Binding OpenRepoImportCommand}"/>
|
||||
</MenuItem>
|
||||
</Menu>
|
||||
</StackPanel>
|
||||
|
||||
@@ -27,6 +27,8 @@ public partial class MainWindow : Window
|
||||
base.OnPropertyChanged(change);
|
||||
if (change.Property == WindowStateProperty)
|
||||
UpdateMaxIcon();
|
||||
if (change.Property == OffScreenMarginProperty)
|
||||
RootGrid.Margin = OffScreenMargin;
|
||||
}
|
||||
|
||||
private void UpdateMaxIcon()
|
||||
@@ -40,11 +42,6 @@ public partial class MainWindow : Window
|
||||
{
|
||||
if (DataContext is IslandsShellViewModel vm)
|
||||
{
|
||||
vm.ShowConflictDialog = async (conflictVm) =>
|
||||
{
|
||||
var modal = new ConflictResolutionView { DataContext = conflictVm };
|
||||
await modal.ShowDialog(this);
|
||||
};
|
||||
vm.ShowAboutModal = async (aboutVm) =>
|
||||
{
|
||||
var dlg = new AboutModalView { DataContext = aboutVm };
|
||||
@@ -81,6 +78,10 @@ public partial class MainWindow : Window
|
||||
var mergeDlg = new MergeModalView { DataContext = mergeVm };
|
||||
await mergeDlg.ShowDialog(this);
|
||||
};
|
||||
modal.RequestConflictResolution = (taskId, target) =>
|
||||
DataContext is IslandsShellViewModel s
|
||||
? s.RequestConflictResolutionAsync(taskId, target)
|
||||
: System.Threading.Tasks.Task.CompletedTask;
|
||||
await dlg.ShowDialog(this);
|
||||
};
|
||||
vm.ShowRepoImportModal = async (modal) =>
|
||||
@@ -95,6 +96,11 @@ public partial class MainWindow : Window
|
||||
connVm.CloseAction = () => dlg.Close();
|
||||
await dlg.ShowDialog(this);
|
||||
};
|
||||
vm.ShowConflictResolver = async (resolverVm) =>
|
||||
{
|
||||
var dlg = new ClaudeDo.Ui.Views.Conflicts.ConflictResolverView { DataContext = resolverVm };
|
||||
await dlg.ShowDialog(this);
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -26,51 +26,100 @@
|
||||
</StackPanel>
|
||||
</ctl:ModalShell.Footer>
|
||||
|
||||
<!-- Body: sidebar + diff content -->
|
||||
<Grid ColumnDefinitions="240,*">
|
||||
<!-- Body: two islands — file list | diff content -->
|
||||
<Grid ColumnDefinitions="280,12,*" Margin="16">
|
||||
|
||||
<!-- File sidebar -->
|
||||
<Border Grid.Column="0"
|
||||
Classes="sidebar-pane">
|
||||
<ListBox ItemsSource="{Binding Files}"
|
||||
SelectedItem="{Binding SelectedFile, Mode=TwoWay}"
|
||||
Background="Transparent"
|
||||
BorderThickness="0"
|
||||
ScrollViewer.HorizontalScrollBarVisibility="Disabled">
|
||||
<ListBox.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:DiffFileViewModel">
|
||||
<Border Padding="10,8" Background="Transparent">
|
||||
<StackPanel Spacing="4">
|
||||
<TextBlock Classes="path-mono" Text="{Binding Path}"
|
||||
TextTrimming="PrefixCharacterEllipsis"/>
|
||||
<StackPanel Orientation="Horizontal" Spacing="6">
|
||||
<Border Classes="chip" Padding="5,2">
|
||||
<TextBlock Foreground="{DynamicResource MossBrightBrush}"
|
||||
Text="{Binding Additions, StringFormat='+{0}'}"/>
|
||||
</Border>
|
||||
<Border Classes="chip" Padding="5,2">
|
||||
<TextBlock Foreground="{DynamicResource BloodBrush}"
|
||||
Text="{Binding Deletions, StringFormat='−{0}'}"/>
|
||||
</Border>
|
||||
<!-- Files island -->
|
||||
<Border Grid.Column="0" Classes="island">
|
||||
<DockPanel>
|
||||
<Border DockPanel.Dock="Top" Classes="island-header">
|
||||
<TextBlock Classes="eyebrow" Text="{loc:Tr modals.diff.filesHeader}"/>
|
||||
</Border>
|
||||
<ListBox ItemsSource="{Binding Files}"
|
||||
SelectedItem="{Binding SelectedFile, Mode=TwoWay}"
|
||||
Background="Transparent"
|
||||
BorderThickness="0"
|
||||
ScrollViewer.HorizontalScrollBarVisibility="Disabled">
|
||||
<ListBox.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:DiffFileViewModel">
|
||||
<Border Padding="10,8" Background="Transparent">
|
||||
<StackPanel Spacing="4">
|
||||
<Grid ColumnDefinitions="Auto,*">
|
||||
<Border Grid.Column="0" Tag="{Binding StatusCode}"
|
||||
CornerRadius="3" Padding="4,0" Margin="0,0,8,0"
|
||||
VerticalAlignment="Center">
|
||||
<TextBlock Text="{Binding StatusCode}"
|
||||
FontFamily="{DynamicResource MonoFont}"
|
||||
FontSize="{StaticResource FontSizeEyebrow}"
|
||||
Foreground="{DynamicResource TextBrush}"/>
|
||||
</Border>
|
||||
<TextBlock Grid.Column="1" Classes="path-mono" Text="{Binding Path}"
|
||||
VerticalAlignment="Center"
|
||||
TextTrimming="PrefixCharacterEllipsis"/>
|
||||
</Grid>
|
||||
<StackPanel Orientation="Horizontal" Spacing="6"
|
||||
IsVisible="{Binding !IsBinary}">
|
||||
<Border Classes="chip" Padding="5,2">
|
||||
<TextBlock Foreground="{DynamicResource MossBrightBrush}"
|
||||
Text="{Binding Additions, StringFormat='+{0}'}"/>
|
||||
</Border>
|
||||
<Border Classes="chip" Padding="5,2">
|
||||
<TextBlock Foreground="{DynamicResource BloodBrush}"
|
||||
Text="{Binding Deletions, StringFormat='−{0}'}"/>
|
||||
</Border>
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</DataTemplate>
|
||||
</ListBox.ItemTemplate>
|
||||
</ListBox>
|
||||
</Border>
|
||||
</DataTemplate>
|
||||
</ListBox.ItemTemplate>
|
||||
</ListBox>
|
||||
</DockPanel>
|
||||
</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>
|
||||
<!-- 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>
|
||||
<TextBlock Grid.Column="1" Classes="path-mono" Text="{Binding SelectedFile.Path}"
|
||||
VerticalAlignment="Center"
|
||||
TextTrimming="PrefixCharacterEllipsis"/>
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<Grid Background="{DynamicResource VoidBrush}">
|
||||
<!-- Load / no-changes message -->
|
||||
<TextBlock Classes="body" Text="{Binding StatusMessage}"
|
||||
IsVisible="{Binding StatusMessage, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"
|
||||
HorizontalAlignment="Center" VerticalAlignment="Center"/>
|
||||
<!-- Binary file -->
|
||||
<TextBlock Classes="body" Text="{loc:Tr modals.diff.binary}"
|
||||
Foreground="{DynamicResource TextMuteBrush}"
|
||||
IsVisible="{Binding SelectedFile.IsBinary}"
|
||||
HorizontalAlignment="Center" VerticalAlignment="Center"/>
|
||||
<!-- Empty / no-content file -->
|
||||
<TextBlock Classes="body" Text="{loc:Tr modals.diff.empty}"
|
||||
Foreground="{DynamicResource TextMuteBrush}"
|
||||
IsVisible="{Binding SelectedFile.IsEmptyContent}"
|
||||
HorizontalAlignment="Center" VerticalAlignment="Center"/>
|
||||
<!-- Diff content -->
|
||||
<ScrollViewer HorizontalScrollBarVisibility="Auto"
|
||||
VerticalScrollBarVisibility="Auto"
|
||||
IsVisible="{Binding SelectedFile.HasLines}">
|
||||
<ctl:DiffLinesView Lines="{Binding SelectedFile.Lines}"/>
|
||||
</ScrollViewer>
|
||||
</Grid>
|
||||
</DockPanel>
|
||||
</Border>
|
||||
</Grid>
|
||||
</ctl:ModalShell>
|
||||
</Window>
|
||||
|
||||
@@ -243,6 +243,7 @@
|
||||
<TextBlock Classes="meta" Grid.Column="3" Text="{Binding LastRunLabel}" VerticalAlignment="Center"
|
||||
MinWidth="80"/>
|
||||
<Button Classes="icon-btn" Grid.Column="4" Content="✕"
|
||||
ToolTip.Tip="{loc:Tr settings.prime.removeScheduleTip}"
|
||||
Command="{Binding $parent[ItemsControl].((vm:SettingsModalViewModel)DataContext).Prime.RemoveScheduleCommand}"
|
||||
CommandParameter="{Binding}"/>
|
||||
</Grid>
|
||||
@@ -260,6 +261,99 @@
|
||||
</ScrollViewer>
|
||||
</TabItem>
|
||||
|
||||
<TabItem Header="{loc:Tr settings.onlineInbox.tabHeader}">
|
||||
<ScrollViewer>
|
||||
<StackPanel Spacing="14" Margin="0,8,0,0">
|
||||
|
||||
<!-- Enable toggle + restart hint -->
|
||||
<StackPanel Spacing="4">
|
||||
<CheckBox IsChecked="{Binding OnlineInbox.Enabled, Mode=TwoWay}"
|
||||
Content="{loc:Tr settings.onlineInbox.enabledLabel}"/>
|
||||
<TextBlock Classes="meta" Text="{loc:Tr settings.onlineInbox.restartHint}"
|
||||
TextWrapping="Wrap" Opacity="0.6"/>
|
||||
</StackPanel>
|
||||
|
||||
<Border BorderBrush="{DynamicResource LineBrush}" BorderThickness="0,1,0,0" Margin="0,2,0,0"/>
|
||||
|
||||
<!-- Auth status section -->
|
||||
<StackPanel Spacing="8">
|
||||
<TextBlock Classes="section-label" Text="{loc:Tr settings.onlineInbox.statusSection}"/>
|
||||
<StackPanel Orientation="Horizontal" Spacing="10" VerticalAlignment="Center"
|
||||
IsVisible="{Binding OnlineInbox.SignedIn}">
|
||||
<Border Width="8" Height="8" CornerRadius="4"
|
||||
Background="{DynamicResource StatusRunningBrush}"/>
|
||||
<TextBlock Classes="body" VerticalAlignment="Center"
|
||||
Text="{loc:Tr settings.onlineInbox.signedInStatus}"/>
|
||||
</StackPanel>
|
||||
<StackPanel Orientation="Horizontal" Spacing="10" VerticalAlignment="Center"
|
||||
IsVisible="{Binding !OnlineInbox.SignedIn}">
|
||||
<Border Width="8" Height="8" CornerRadius="4"
|
||||
Background="{DynamicResource StatusIdleBrush}"/>
|
||||
<TextBlock Classes="body" VerticalAlignment="Center"
|
||||
Text="{loc:Tr settings.onlineInbox.signedOutStatus}"/>
|
||||
</StackPanel>
|
||||
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||
<Button Classes="btn"
|
||||
Content="{loc:Tr settings.onlineInbox.signInButton}"
|
||||
Command="{Binding OnlineInbox.SignInCommand}"
|
||||
IsEnabled="{Binding !OnlineInbox.IsBusy}"
|
||||
IsVisible="{Binding !OnlineInbox.SignedIn}"/>
|
||||
<Button Classes="btn danger"
|
||||
Content="{loc:Tr settings.onlineInbox.signOutButton}"
|
||||
Command="{Binding OnlineInbox.SignOutCommand}"
|
||||
IsEnabled="{Binding !OnlineInbox.IsBusy}"
|
||||
IsVisible="{Binding OnlineInbox.SignedIn}"/>
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
|
||||
<Border BorderBrush="{DynamicResource LineBrush}" BorderThickness="0,1,0,0" Margin="0,2,0,0"/>
|
||||
|
||||
<!-- Config fields -->
|
||||
<StackPanel Spacing="10">
|
||||
<TextBlock Classes="section-label" Text="{loc:Tr settings.onlineInbox.configSection}"/>
|
||||
<StackPanel Spacing="4">
|
||||
<TextBlock Classes="field-label" Text="{loc:Tr settings.onlineInbox.apiBaseUrlLabel}"/>
|
||||
<TextBox Text="{Binding OnlineInbox.ApiBaseUrl, Mode=TwoWay}"
|
||||
PlaceholderText="{loc:Tr settings.onlineInbox.apiBaseUrlPlaceholder}"/>
|
||||
</StackPanel>
|
||||
<StackPanel Spacing="4">
|
||||
<TextBlock Classes="field-label" Text="{loc:Tr settings.onlineInbox.authorityLabel}"/>
|
||||
<TextBox Text="{Binding OnlineInbox.Authority, Mode=TwoWay}"
|
||||
PlaceholderText="{loc:Tr settings.onlineInbox.authorityPlaceholder}"/>
|
||||
</StackPanel>
|
||||
<Grid ColumnDefinitions="*,12,*">
|
||||
<StackPanel Grid.Column="0" Spacing="4">
|
||||
<TextBlock Classes="field-label" Text="{loc:Tr settings.onlineInbox.clientIdLabel}"/>
|
||||
<TextBox Text="{Binding OnlineInbox.ClientId, Mode=TwoWay}"/>
|
||||
</StackPanel>
|
||||
<StackPanel Grid.Column="2" Spacing="4">
|
||||
<TextBlock Classes="field-label" Text="{loc:Tr settings.onlineInbox.scopesLabel}"/>
|
||||
<TextBox Text="{Binding OnlineInbox.Scopes, Mode=TwoWay}"/>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
<StackPanel Spacing="4">
|
||||
<TextBlock Classes="field-label" Text="{loc:Tr settings.onlineInbox.redirectUriLabel}"/>
|
||||
<TextBox Text="{Binding OnlineInbox.RedirectUri, Mode=TwoWay}"/>
|
||||
</StackPanel>
|
||||
<StackPanel Spacing="4">
|
||||
<TextBlock Classes="field-label" Text="{loc:Tr settings.onlineInbox.pollIntervalLabel}"/>
|
||||
<NumericUpDown Value="{Binding OnlineInbox.PollIntervalSeconds, Mode=TwoWay}"
|
||||
Minimum="10" Maximum="3600" Increment="10" FormatString="0"
|
||||
HorizontalAlignment="Left" Width="140"/>
|
||||
</StackPanel>
|
||||
<Button Classes="btn"
|
||||
Content="{loc:Tr settings.onlineInbox.saveButton}"
|
||||
Command="{Binding OnlineInbox.SaveCommand}"
|
||||
IsEnabled="{Binding !OnlineInbox.IsBusy}"
|
||||
HorizontalAlignment="Left"/>
|
||||
</StackPanel>
|
||||
|
||||
<TextBlock Classes="meta" Text="{Binding OnlineInbox.StatusMessage}"
|
||||
IsVisible="{Binding OnlineInbox.StatusMessage, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
</TabItem>
|
||||
|
||||
</TabControl>
|
||||
</DockPanel>
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:vm="using:ClaudeDo.Ui.ViewModels.Modals"
|
||||
xmlns:converters="using:ClaudeDo.Ui.Converters"
|
||||
xmlns:ctl="using:ClaudeDo.Ui.Views.Controls"
|
||||
xmlns:loc="using:ClaudeDo.Ui.Localization"
|
||||
x:Class="ClaudeDo.Ui.Views.Modals.WorktreeModalView"
|
||||
x:DataType="vm:WorktreeModalViewModel"
|
||||
@@ -16,10 +17,6 @@
|
||||
CanResize="True"
|
||||
TransparencyLevelHint="AcrylicBlur">
|
||||
|
||||
<Window.Resources>
|
||||
<converters:DiffLineKindToBrushConverter x:Key="DiffLineKindToBrush"/>
|
||||
</Window.Resources>
|
||||
|
||||
<Window.KeyBindings>
|
||||
<KeyBinding Gesture="Escape" Command="{Binding CloseCommand}"/>
|
||||
</Window.KeyBindings>
|
||||
@@ -89,17 +86,7 @@
|
||||
HorizontalScrollBarVisibility="Auto"
|
||||
VerticalScrollBarVisibility="Auto"
|
||||
Margin="4,0,8,8">
|
||||
<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>
|
||||
<ctl:DiffLinesView Lines="{Binding SelectedFileDiffLines}"/>
|
||||
</ScrollViewer>
|
||||
|
||||
</Grid>
|
||||
|
||||
@@ -60,8 +60,12 @@
|
||||
CommandParameter="{Binding}"/>
|
||||
</ContextMenu>
|
||||
</Border.ContextMenu>
|
||||
<Grid ColumnDefinitions="*,90,80,80">
|
||||
<StackPanel Grid.Column="0" Orientation="Vertical" Spacing="2">
|
||||
<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}"/>
|
||||
@@ -72,13 +76,16 @@
|
||||
ToolTip.Tip="{loc:Tr modals.worktreesOverview.phantomTooltip}"/>
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
<Border Grid.Column="1" CornerRadius="3" Padding="6,2" VerticalAlignment="Center"
|
||||
<TextBlock Grid.Column="2" Classes="meta" VerticalAlignment="Center"
|
||||
Text="{Binding MergeOutcome}"
|
||||
IsVisible="{Binding HasOutcome}"/>
|
||||
<Border Grid.Column="3" CornerRadius="3" Padding="6,2" VerticalAlignment="Center"
|
||||
Background="{Binding State, Converter={StaticResource WorktreeStateColor}}">
|
||||
<TextBlock Classes="meta" Text="{Binding State}" Foreground="{DynamicResource TextBrush}"
|
||||
<TextBlock Classes="meta" Text="{Binding State}" Foreground="{DynamicResource DeepBrush}"
|
||||
HorizontalAlignment="Center"/>
|
||||
</Border>
|
||||
<TextBlock Grid.Column="2" Classes="meta" Text="{Binding DiffStat}" VerticalAlignment="Center"/>
|
||||
<TextBlock Grid.Column="3" Classes="meta" Text="{Binding AgeText}" VerticalAlignment="Center"/>
|
||||
<TextBlock Grid.Column="4" Classes="meta" Text="{Binding DiffStat}" VerticalAlignment="Center"/>
|
||||
<TextBlock Grid.Column="5" Classes="meta" Text="{Binding AgeText}" VerticalAlignment="Center"/>
|
||||
</Grid>
|
||||
</Border>
|
||||
</DataTemplate>
|
||||
@@ -98,7 +105,20 @@
|
||||
<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}"/>
|
||||
<TextBlock Text="{Binding StatusMessage}" VerticalAlignment="Center" Margin="12,0,0,0"
|
||||
<Button Classes="btn" Content="{loc:Tr modals.worktreesOverview.selectAll}" Command="{Binding ToggleSelectAllCommand}"/>
|
||||
<Border Width="1" Background="{DynamicResource LineBrush}" Margin="4,2"/>
|
||||
<TextBlock Text="{loc:Tr modals.worktreesOverview.targetLabel}" VerticalAlignment="Center" Foreground="{DynamicResource TextDimBrush}"/>
|
||||
<ComboBox MinWidth="160"
|
||||
ItemsSource="{Binding MergeTargets}"
|
||||
SelectedItem="{Binding SelectedTarget, Mode=TwoWay}"/>
|
||||
<Button Classes="btn accent"
|
||||
Content="{loc:Tr modals.worktreesOverview.mergeAll}"
|
||||
Command="{Binding MergeAllCommand}"/>
|
||||
<TextBlock Text="{Binding SelectedCount, StringFormat='{}{0} selected'}"
|
||||
VerticalAlignment="Center" Foreground="{DynamicResource TextDimBrush}"/>
|
||||
<TextBlock Text="{Binding BatchProgress}" VerticalAlignment="Center" Margin="8,0,0,0"
|
||||
Foreground="{DynamicResource TextDimBrush}"/>
|
||||
<TextBlock Text="{Binding StatusMessage}" VerticalAlignment="Center" Margin="8,0,0,0"
|
||||
Foreground="{DynamicResource TextDimBrush}"/>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
@@ -106,12 +126,35 @@
|
||||
<!-- Content -->
|
||||
<ScrollViewer Padding="20,16">
|
||||
<StackPanel>
|
||||
<Border IsVisible="{Binding ConflictRows.Count}"
|
||||
Background="{DynamicResource ErrorTintBrush}"
|
||||
BorderBrush="{DynamicResource StatusErrorBrush}"
|
||||
BorderThickness="1" CornerRadius="6" Padding="12,8" Margin="0,0,0,12">
|
||||
<StackPanel Spacing="6">
|
||||
<TextBlock Classes="eyebrow" Text="{loc:Tr modals.worktreesOverview.needsResolution}"/>
|
||||
<ItemsControl ItemsSource="{Binding ConflictRows}">
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:WorktreeOverviewRowViewModel">
|
||||
<Grid ColumnDefinitions="*,Auto" Margin="0,2">
|
||||
<TextBlock Grid.Column="0" Classes="meta" VerticalAlignment="Center"
|
||||
Text="{Binding TaskTitle}"/>
|
||||
<Button Grid.Column="1" Classes="btn"
|
||||
Content="{loc:Tr modals.worktreesOverview.resolve}"
|
||||
Command="{Binding $parent[Window].((vm:WorktreesOverviewModalViewModel)DataContext).ResolveConflictCommand}"
|
||||
CommandParameter="{Binding}"/>
|
||||
</Grid>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
<!-- Column headers -->
|
||||
<Grid ColumnDefinitions="*,90,80,80" Margin="12,0,12,4">
|
||||
<TextBlock Grid.Column="0" Classes="eyebrow" Text="{loc:Tr modals.worktreesOverview.columnTask}"/>
|
||||
<TextBlock Grid.Column="1" Classes="eyebrow" Text="{loc:Tr modals.worktreesOverview.columnState}"/>
|
||||
<TextBlock Grid.Column="2" Classes="eyebrow" Text="{loc:Tr modals.worktreesOverview.columnDiff}"/>
|
||||
<TextBlock Grid.Column="3" Classes="eyebrow" Text="{loc:Tr modals.worktreesOverview.columnAge}"/>
|
||||
<Grid 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>
|
||||
<Border Height="1" Background="{DynamicResource LineBrush}" Margin="0,0,0,8"/>
|
||||
|
||||
|
||||
@@ -1,52 +0,0 @@
|
||||
<Window xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:vm="using:ClaudeDo.Ui.ViewModels.Planning"
|
||||
xmlns:ctl="using:ClaudeDo.Ui.Views.Controls"
|
||||
xmlns:loc="using:ClaudeDo.Ui.Localization"
|
||||
x:DataType="vm:ConflictResolutionViewModel"
|
||||
x:Class="ClaudeDo.Ui.Views.Planning.ConflictResolutionView"
|
||||
Title="{loc:Tr planning.conflict.windowTitle}"
|
||||
Width="560" SizeToContent="Height" MinWidth="460"
|
||||
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 planning.conflict.modalTitle}" CloseCommand="{Binding AbortCommand}">
|
||||
<ctl:ModalShell.Footer>
|
||||
<StackPanel Orientation="Horizontal" Spacing="8"
|
||||
HorizontalAlignment="Right" VerticalAlignment="Center">
|
||||
<Button Classes="btn" Content="{loc:Tr planning.conflict.openInVsCode}" Command="{Binding OpenInVsCodeCommand}"/>
|
||||
<Button Classes="btn" Content="{loc:Tr planning.conflict.resolved}" Command="{Binding ContinueCommand}"/>
|
||||
<Button Classes="btn" Content="{loc:Tr planning.conflict.abort}" Command="{Binding AbortCommand}"/>
|
||||
</StackPanel>
|
||||
</ctl:ModalShell.Footer>
|
||||
|
||||
<!-- Content -->
|
||||
<StackPanel Spacing="12" Margin="20,16" MinWidth="520">
|
||||
<TextBlock Classes="heading"
|
||||
Text="{Binding SubtaskLabel}"/>
|
||||
<TextBlock Classes="body" Text="{Binding TargetLabel}"/>
|
||||
<ItemsControl ItemsSource="{Binding ConflictedFiles}">
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate>
|
||||
<TextBlock Classes="path-mono" Text="{Binding}"/>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
<TextBlock Classes="meta" Text="{Binding VsCodeError}" Foreground="{DynamicResource BloodBrush}"
|
||||
IsVisible="{Binding VsCodeError, Converter={x:Static ObjectConverters.IsNotNull}}"
|
||||
TextWrapping="Wrap"/>
|
||||
<TextBlock Classes="meta" Text="{Binding ActionError}" Foreground="{DynamicResource BloodBrush}"
|
||||
IsVisible="{Binding ActionError, Converter={x:Static ObjectConverters.IsNotNull}}"
|
||||
TextWrapping="Wrap"/>
|
||||
</StackPanel>
|
||||
|
||||
</ctl:ModalShell>
|
||||
</Window>
|
||||
@@ -1,19 +0,0 @@
|
||||
using Avalonia.Controls;
|
||||
using ClaudeDo.Ui.ViewModels.Planning;
|
||||
|
||||
namespace ClaudeDo.Ui.Views.Planning;
|
||||
|
||||
public partial class ConflictResolutionView : Window
|
||||
{
|
||||
public ConflictResolutionView()
|
||||
{
|
||||
InitializeComponent();
|
||||
}
|
||||
|
||||
protected override void OnDataContextChanged(EventArgs e)
|
||||
{
|
||||
base.OnDataContextChanged(e);
|
||||
if (DataContext is ConflictResolutionViewModel vm)
|
||||
vm.CloseRequested = Close;
|
||||
}
|
||||
}
|
||||
@@ -8,15 +8,18 @@ ASP.NET Core hosted service that executes tasks via Claude CLI in isolated envir
|
||||
Worker/
|
||||
State/ — TaskStateService + TransitionResult (sole owner of Status/PlanningPhase/BlockedBy writes)
|
||||
Queue/ — IQueueWaker, IQueuePicker, QueueService (BackgroundService), OverrideSlotService
|
||||
Lifecycle/ — StaleTaskRecovery, TaskResetService, TaskMergeService
|
||||
Lifecycle/ — StaleTaskRecovery, TaskResetService, TaskMergeService, ClaudeCliPreflight, OrphanRecovery, PlanningLineageRecovery
|
||||
Worktrees/ — WorktreeMaintenanceService
|
||||
Agents/ — AgentFileService, DefaultAgentSeeder
|
||||
Runner/ — TaskRunner + Claude CLI integration
|
||||
Planning/ — PlanningSessionManager, PlanningChainCoordinator, PlanningMcpService
|
||||
External/ — ExternalMcpService
|
||||
Runner/ — TaskRunner + Claude CLI integration; TaskRunMcpService/TaskRunMcpContext/TaskRunTokenRegistry (in-task MCP wired during execution)
|
||||
Planning/ — PlanningSessionManager, PlanningChainCoordinator, PlanningMcpService, PlanningMergeOrchestrator, PlanningAggregator, PlanningSessionContext/PlanningTokenAuth/PlanningMcpContextAccessor, WindowsTerminalPlanningLauncher (IPlanningTerminalLauncher)
|
||||
Refine/ — RefineRunner + RefinePrompt (hub `RefineTask`; broadcasts RefineStarted/RefineFinished)
|
||||
External/ — ExternalMcpService + sibling tool classes
|
||||
Config/ — WorkerConfig
|
||||
Hub/ — WorkerHub, HubBroadcaster
|
||||
Report/ — ClaudeHistoryReader, WeekReportPromptBuilder, WeekReportService; interfaces in Report/Interfaces/
|
||||
Prime/ — daily-prep ("Prime Claude"): PrimeScheduler (BackgroundService), PrimeRunner (runs the daily prep), DailyPrepPrompt (fixed prompt + CLI args + LogPath() helper), NextDueCalculator, PrimeScheduleSignal; interfaces in Prime/Interfaces/ (IPrimeRunner, IPrimeClock, IPrimeScheduleSignal, IPrimeBroadcaster)
|
||||
Online/ — optional Online Inbox sync: OnlineInboxConfig (config record), Dtos (RemoteList/RemoteTask/MirrorTask), IOnlineInboxApi, OnlineInboxApiClient (typed HttpClient, bearer auth, HTTPS guard), OnlineTokenStore (DPAPI refresh-token store, Windows-only), StaticTokenAuthProvider (default/test IOnlineAuthProvider), ZitadelAuthProvider (stub — TODO(online-inbox) Phase 2), OnlineSyncService (BackgroundService: reconcile loop), OnlineBacklog (Idle-backlog filter/query); interface in Online/Interfaces/ (IOnlineAuthProvider)
|
||||
```
|
||||
|
||||
Interfaces (e.g. `IQueueWaker`, `IPrimeClock`, `ITaskStateService`) live in an `Interfaces/` subfolder within their area; the namespace stays the area namespace.
|
||||
@@ -29,11 +32,11 @@ Interfaces (e.g. `IQueueWaker`, `IPrimeClock`, `ITaskStateService`) live in an `
|
||||
- **OverrideSlotService** — owns `RunNow` / `ContinueTask`; goes through `TaskStateService.StartRunningAsync` (caller-driven, serialized by slot lock).
|
||||
- **StaleTaskRecovery** — startup-only service; calls `TaskStateService.RecoverStaleRunningAsync` to flip orphaned `Running` rows to `Failed`.
|
||||
- **External/*** — always-on MCP tools for general Claude sessions, scoped to *starting* and *observing* sessions (no worktree/merge, multi-turn, planning, or app-settings writes). Auth via optional `X-ClaudeDo-Key` header. Registered explicitly in `Program.cs`'s external app via `.WithTools<T>()`. Organized by concern:
|
||||
- `ExternalMcpService` — task CRUD + execution: `ListTaskLists`, `ListTasks`, `GetTask`, `AddTask`, `UpdateTask`, `UpdateTaskStatus` (`Idle` / `Queued`), `ReviewTask` (`approve` / `reject_rerun` / `reject_park` / `cancel` for a WaitingForReview task), `RunTaskNow`, `CancelTask`, `DeleteTask`
|
||||
- `ExternalMcpService` — task CRUD + execution: `ListTaskLists`, `ListTasks`, `GetTask`, `AddTask`, `AddSubtask`, `UpdateTask`, `UpdateTaskStatus` (`Idle` / `Queued`), `GetTaskStatusValues`, `ReviewTask` (`approve` / `reject_rerun` / `reject_park` / `cancel` for a WaitingForReview task), `RunTaskNow`, `ContinueTask`, `CancelTask`, `DeleteTask`; worktree/git: `GetTaskWorktree`, `GetTaskDiff`, `MergeTask`, `ListWorktrees`, `CleanupTaskWorktree`
|
||||
- `ListMcpTools` — `CreateList`, `UpdateList`, `DeleteList`
|
||||
- `ConfigMcpTools` — `GetListConfig`, `SetListConfig`, `SetTaskConfig`
|
||||
- `ConfigMcpTools` — `GetListConfig`, `SetListConfig`, `GetTaskConfig`, `SetTaskConfig`
|
||||
- `RunHistoryMcpTools` — `ListRuns`, `GetRun`, `GetTaskLog` (latest run's log, tail-capped at 256 KB)
|
||||
- `AgentMcpTools` — `ListAgents`
|
||||
- `AgentMcpTools` — `ListAgents` (class lives in `LifecycleMcpTools.cs`)
|
||||
- `LifecycleMcpTools` — `ResetFailedTask`
|
||||
- `AppSettingsMcpTools` — `GetAppSettings` (read-only)
|
||||
- `ExternalMcpService` also exposes two daily-prep tools:
|
||||
@@ -58,7 +61,7 @@ Interfaces (e.g. `IQueueWaker`, `IPrimeClock`, `ITaskStateService`) live in an `
|
||||
|
||||
| Field | Values | Meaning |
|
||||
|---|---|---|
|
||||
| `Status` | `Idle`, `Queued`, `Running`, `WaitingForReview`, `Done`, `Failed`, `Cancelled` | Lifecycle only. |
|
||||
| `Status` | `Idle`, `Queued`, `Running`, `WaitingForChildren`, `WaitingForReview`, `Done`, `Failed`, `Cancelled` | Lifecycle only. `WaitingForChildren` = parent's own work is done, waiting on its children. |
|
||||
| `PlanningPhase` | `None`, `Active`, `Finalized` | Parent-only marker. `Active` ≈ legacy `Planning`; `Finalized` ≈ legacy `Planned`. |
|
||||
| `BlockedByTaskId` | nullable FK | Replaces legacy `Waiting`. A queued row with `BlockedByTaskId != NULL` is skipped by the picker. |
|
||||
| `ReviewFeedback` | nullable string | Reviewer's rejection comment. Set by `RejectToQueueAsync`; consumed and cleared by `QueueService` on the next re-run (resumes the Claude session with it as the next-turn prompt). |
|
||||
@@ -66,24 +69,46 @@ Interfaces (e.g. `IQueueWaker`, `IPrimeClock`, `ITaskStateService`) live in an `
|
||||
Allowed transitions (enforced by `TaskStateService`):
|
||||
|
||||
```
|
||||
Idle → Queued | Running (RunNow)
|
||||
Queued → Running | Cancelled | Idle
|
||||
Running → WaitingForReview (standalone success) | Done (planning child success) | Failed | Cancelled
|
||||
WaitingForReview → Done (approve: merges worktree first; conflicts keep it in WaitingForReview) | Queued (reject-rerun, +feedback) | Idle (reject-park) | Cancelled
|
||||
Done → Idle (re-run)
|
||||
Failed → Idle | Queued
|
||||
Cancelled → Idle | Queued
|
||||
Idle → Queued | Running (RunNow)
|
||||
Queued → Running | Cancelled | Idle | Failed (OverrideSlotService preflight gap: RunAsync can fail before StartRunningAsync is called)
|
||||
Running → WaitingForReview (standalone success, no children)
|
||||
| WaitingForChildren (parent with pending children)
|
||||
| Done (planning/improvement child success) | Failed | Cancelled
|
||||
WaitingForChildren → WaitingForReview (all children terminal) | Cancelled
|
||||
WaitingForReview → Done (approve) | Queued (reject-rerun, +feedback) | Idle (reject-park) | Cancelled
|
||||
Done → Idle (re-run)
|
||||
Failed → Idle | Queued
|
||||
Cancelled → Idle | Queued
|
||||
```
|
||||
|
||||
Only standalone tasks (`ParentTaskId == null`) route to `WaitingForReview` on success. Planning children go straight to `Done` so the sequential chain (which advances on terminal states) is unaffected. `TaskRunner.HandleSuccess` makes this choice; review transitions live in `TaskStateService` (`SubmitForReviewAsync`, `ApproveReviewAsync`, `RejectToQueueAsync`, `RejectToIdleAsync`, `ClearReviewFeedbackAsync`).
|
||||
**Unified parent model.** Every parent — planning *or* improvement — flows
|
||||
`… → WaitingForChildren → WaitingForReview → Done`, advanced by the single
|
||||
`TaskStateService.TryAdvanceParentAsync` (surfaces any `WaitingForChildren` parent for
|
||||
review once all children are terminal; failed/cancelled children are annotated on the
|
||||
result, not wedged). A planning parent enters `WaitingForChildren` at
|
||||
`FinalizePlanningAsync` (or `WaitingForReview` directly if it has no children); an
|
||||
improvement parent enters it from `TaskRunner.HandleSuccess` when its run spawned
|
||||
children. Planning/improvement **children** still go straight to `Done` (no individual
|
||||
review) — only the parent is reviewed.
|
||||
|
||||
**Approve = merge the whole unit.** `ApproveReview`/`review_task` approve, for a task
|
||||
that has children, drives `PlanningMergeOrchestrator` (merges the parent worktree if
|
||||
Active + each `Done` child in order, sets the parent `Done`, and on a mid-merge
|
||||
conflict pauses for `ContinuePlanningMerge`/`AbortPlanningMerge`). Childless tasks use
|
||||
`TaskMergeService.ApproveAndMergeAsync`. There is no separate "Merge all" entry —
|
||||
approve is the single review+merge action. Review transitions live in `TaskStateService`
|
||||
(`SubmitForReviewAsync`, `SubmitForChildrenAsync`, `ApproveReviewAsync`,
|
||||
`RejectToQueueAsync`, `RejectToIdleAsync`, `ClearReviewFeedbackAsync`).
|
||||
|
||||
## Planning Flow
|
||||
|
||||
`PlanningSessionManager.FinalizeAsync` is the single path:
|
||||
|
||||
1. `_state.FinalizePlanningAsync(parent)` flips parent `PlanningPhase` to `Finalized`.
|
||||
2. `PlanningChainCoordinator.SetupChainAsync` attaches the `agent` tag to every child, enqueues child[0], and `BlockOn`s child[i] → child[i-1].
|
||||
3. The first child is woken automatically; successors unblock as their predecessor reaches a terminal state via `OnChildFinishedAsync`.
|
||||
1. `_state.FinalizePlanningAsync(parent)` flips parent `PlanningPhase` to `Finalized` and sets `Status` to `WaitingForChildren` (or `WaitingForReview` if the parent has no children).
|
||||
2. `PlanningChainCoordinator.SetupChainAsync(parent, enqueue: false)` establishes the blocked-by chain (`BlockOn`s child[i] → child[i-1]) but **leaves children `Idle`** — finalize never auto-queues. Queueing is a deliberate user action: `QueuePlanAsync` (hub `QueuePlanningSubtasksAsync`, the "Queue plan" button) calls `SetupChainAsync(parent, enqueue: true)`, which sets every non-terminal child `Queued` and re-applies the chain.
|
||||
3. Once queued, the first child is woken automatically; successors unblock as their predecessor reaches a terminal state via `OnChildFinishedAsync`.
|
||||
|
||||
A child that hits a roadblock (fails, or reports `CLAUDEDO_BLOCKED` roadblocks) does **not** advance the parent — the parent stays in `WaitingForChildren` until every child is terminal. The UI surfaces blocked children on the parent's Session tab (`ChildOutcomes` + a "children need attention" band) so the roadblock is visible without forcing a transition.
|
||||
|
||||
`TaskRepository.FinalizePlanningAsync` no longer exists. The `Mark*Async` repository helpers are `internal` — only `TaskStateService` calls them.
|
||||
|
||||
@@ -121,9 +146,17 @@ Each CLI invocation is recorded in the `task_runs` table via `TaskRunRepository`
|
||||
|
||||
## SignalR Hub
|
||||
|
||||
**WorkerHub** methods: `Ping`, `GetActive`, `RunNow`, `CancelTask`, `WakeQueue`, `ContinueTask`, `ResetTask`, `ApproveReview(taskId, targetBranch) -> MergeResultDto` (merges worktree then transitions to Done; on conflict stays WaitingForReview), `PreviewMerge(taskId, targetBranch) -> MergePreviewDto` (non-destructive mergeability check), `RejectReviewToQueue`, `RejectReviewToIdle`, `CancelReview`, `GetAgents`, `RefreshAgents`, `GetAppSettings`, `UpdateAppSettings`, `CleanupFinishedWorktrees`, `ResetAllWorktrees`, `MergeTask`, `GetMergeTargets`, `UpdateList`, `UpdateListConfig`, `GetListConfig`, `UpdateTaskAgentSettings`, `GetWeekReport`, `GenerateWeekReport`, `GetDailyNotes`, `AddDailyNote`, `UpdateDailyNote`, `DeleteDailyNote`, `RunDailyPrepNow`, `ClearMyDay`, `GetLastPrepLog`
|
||||
**WorkerHub** methods, grouped:
|
||||
|
||||
**HubBroadcaster** events: `TaskStarted`, `TaskFinished`, `TaskMessage`, `WorktreeUpdated`, `TaskUpdated`, `RunCreated`, `ListUpdated`, `PrimeFired`, `PrepStarted`, `PrepLine`, `PrepFinished`
|
||||
- Execution: `Ping`, `GetActive`, `RunNow`, `CancelTask`, `WakeQueue`, `ContinueTask`, `ResetTask`, `SetTaskStatus`, `RefineTask`
|
||||
- Review/merge: `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`, `MergeTask`, `GetMergeTargets`
|
||||
- Single-task conflict resolver (Layer C): `StartConflictMerge`, `GetMergeConflicts` (hunks), `WriteConflictResolution`, `ContinueConflictMerge`, `AbortConflictMerge` (service-level `TaskMergeService.ContinueMergeAsync`/`AbortMergeAsync` keep their names)
|
||||
- Planning sessions: `StartPlanningSession`, `ResumePlanningSession`, `DiscardPlanningSession`, `FinalizePlanningSession`, `QueuePlanningSubtasks`, `GetPendingDraftCount`, `OpenInteractiveTerminal`, `GetPlanningAggregate` (per-subtask diffs), `BuildPlanningIntegrationBranch` (combined diff)
|
||||
- Worktrees: `CleanupFinishedWorktrees`, `ResetAllWorktrees`, `GetWorktreesOverview`, `SetWorktreeState`, `ForceRemoveWorktree`
|
||||
- Agents/settings/lists: `GetAgents`, `RefreshAgents`, `RestoreDefaultAgents`, `GetAppSettings`, `UpdateAppSettings`, `UpdateList`, `UpdateListConfig`, `GetListConfig`, `UpdateTaskAgentSettings`
|
||||
- Reports/notes/prep: `GetWeekReport`, `GenerateWeekReport`, `GetDailyNotes`, `AddDailyNote`, `UpdateDailyNote`, `DeleteDailyNote`, `RunDailyPrepNow`, `ClearMyDay`, `GetLastPrepLog`, `ListPrimeSchedules`, `UpsertPrimeSchedule`, `DeletePrimeSchedule`
|
||||
|
||||
**HubBroadcaster** events: `TaskStarted`, `TaskFinished`, `TaskMessage`, `WorktreeUpdated`, `TaskUpdated`, `RunCreated`, `ListUpdated`, `WorkerLog`, `PrimeFired`, `PrepStarted`, `PrepLine`, `PrepFinished`, `PlanningMergeStarted`, `PlanningSubtaskMerged`, `PlanningMergeConflict`, `PlanningMergeAborted`, `PlanningCompleted`, `RefineStarted`, `RefineFinished`
|
||||
|
||||
## Config
|
||||
|
||||
@@ -133,8 +166,14 @@ Loaded from `~/.todo-app/worker.config.json`:
|
||||
- `queue_backstop_interval_ms` (default 30000)
|
||||
- `signalr_port` (default 47821)
|
||||
- `claude_bin` (path to claude CLI)
|
||||
- `online_inbox` — Online Inbox config (default: `enabled=false`, zero network when disabled):
|
||||
- `enabled` (bool, default false) — when false the entire `Online/` stack is not registered
|
||||
- `api_base_url` (string) — must be HTTPS or loopback; validated at startup when enabled
|
||||
- `poll_interval_seconds` (int, default 60)
|
||||
- `zitadel.authority`, `zitadel.client_id`, `zitadel.scopes` (Phase 2; not used until ZitadelAuthProvider is wired)
|
||||
- The refresh token is NOT in this file — stored encrypted via DPAPI at `~/.todo-app/online-inbox.token`
|
||||
|
||||
Per-list config (`list_config` in DB) provides defaults for `model`, `system_prompt`, `agent_path`; tasks can override each individually.
|
||||
Per-list config (`list_config` in DB) provides defaults for `model`, `system_prompt`, `agent_path`; tasks can override each individually. Task-generating MCP tools (`AddTask`, planning `CreateChildTask`, `SuggestImprovement`) accept an optional `model` (alias-validated via `ModelRegistry.NormalizeAlias` — `haiku`/`sonnet`/`opus`, blank = inherit) so Claude assigns the cheapest capable model at creation time; the planning/system/improvement prompts instruct it to do so (`ModelRegistry.ByCostAscending` = the cost order).
|
||||
|
||||
## Notes
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
<PackageReference Include="Serilog.Sinks.File" Version="6.0.0" />
|
||||
<PackageReference Include="ModelContextProtocol" Version="1.2.0" />
|
||||
<PackageReference Include="ModelContextProtocol.AspNetCore" Version="1.2.0" />
|
||||
<PackageReference Include="System.Security.Cryptography.ProtectedData" Version="8.0.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
@@ -27,6 +28,7 @@
|
||||
<OutputType>WinExe</OutputType>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<ApplicationIcon>ClaudeTaskWorker.ico</ApplicationIcon>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
BIN
src/ClaudeDo.Worker/ClaudeTaskWorker.ico
Normal file
BIN
src/ClaudeDo.Worker/ClaudeTaskWorker.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 58 KiB |
@@ -1,6 +1,8 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
using System.Text.Json.Serialization;
|
||||
using ClaudeDo.Data;
|
||||
using ClaudeDo.Worker.Online;
|
||||
|
||||
namespace ClaudeDo.Worker.Config;
|
||||
|
||||
@@ -39,6 +41,9 @@ public sealed class WorkerConfig
|
||||
[JsonPropertyName("external_mcp_api_key")]
|
||||
public string? ExternalMcpApiKey { get; set; }
|
||||
|
||||
[JsonPropertyName("online_inbox")]
|
||||
public OnlineInboxConfig OnlineInbox { get; set; } = new();
|
||||
|
||||
public static string DefaultConfigPath =>
|
||||
Path.Combine(Paths.AppDataRoot(), "worker.config.json");
|
||||
|
||||
@@ -70,9 +75,38 @@ public sealed class WorkerConfig
|
||||
return cfg;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Persists ONLY the <c>online_inbox</c> section back to <paramref name="path"/>
|
||||
/// (defaults to <see cref="DefaultConfigPath"/>) without rewriting any other fields.
|
||||
/// Reads the existing JSON, replaces the <c>online_inbox</c> node, and writes back indented.
|
||||
/// </summary>
|
||||
public void SaveOnlineInbox(string? path = null)
|
||||
{
|
||||
path ??= DefaultConfigPath;
|
||||
|
||||
var root = File.Exists(path)
|
||||
? JsonNode.Parse(File.ReadAllText(path)) as JsonObject ?? new JsonObject()
|
||||
: new JsonObject();
|
||||
|
||||
root["online_inbox"] = JsonSerializer.SerializeToNode(OnlineInbox, InboxSerializerOpts);
|
||||
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
|
||||
File.WriteAllText(path, root.ToJsonString(WriteOpts));
|
||||
}
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOpts = new()
|
||||
{
|
||||
ReadCommentHandling = JsonCommentHandling.Skip,
|
||||
AllowTrailingCommas = true,
|
||||
};
|
||||
|
||||
private static readonly JsonSerializerOptions InboxSerializerOpts = new()
|
||||
{
|
||||
WriteIndented = false,
|
||||
};
|
||||
|
||||
private static readonly JsonSerializerOptions WriteOpts = new()
|
||||
{
|
||||
WriteIndented = true,
|
||||
};
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user