9.1 KiB
Worktree Overview Modal — Design
Status: Approved Date: 2026-05-19
Problem
Worktree management is becoming hard to oversee. The current UI only exposes per-task worktree actions (merge / keep / discard) from TaskDetailView, plus two global maintenance buttons (CleanupFinishedWorktrees, ResetAllWorktrees). There is no view that shows all existing worktrees at a glance with their state, age, branch, and diff stat. Stale or "phantom" worktrees (DB row but missing directory, or vice versa) have no targeted recovery path.
Goals
- A modal that lists every worktree row from the DB, joined with task + list metadata.
- Two entry points: filtered to one list (List context menu), and global grouped by list (Help menu).
- Quick per-row actions hidden behind a right-click context menu.
- Targeted force-remove for stuck / phantom worktrees.
- Manual refresh only; no live SignalR subscription needed.
Non-Goals
- No auto-refresh / live updates from SignalR events.
- No UI tests (the project has none for the Ui project).
- No changes to
WorktreeManager,TaskRunner, or the existing per-worktree file-tree modal (WorktreeModalView) — it gets reused as the "Show diff" target.
UI
New view pair
WorktreesOverviewModalView + WorktreesOverviewModalViewModel, parallel to existing WorktreeModalView (which shows the file tree inside one worktree).
Layout
┌─ Worktrees [List "Foo"] or Worktrees (all) ───────────────┐
│ [ Refresh ] [ Cleanup finished ] │
│ │
│ ▼ List Foo (global mode only) │
│ Title Branch State +/- Age │
│ Fix login bug claudedo/ab… Active +42-7 3h ago │
│ Add API … claudedo/cd… Merged +8 -0 1d ago │
│ ▼ List Bar │
│ … │
└──────────────────────────────────────────────────────────────┘
DataGrid(orItemsControlwith Grid template) for rows.- List-filtered mode: no group headers, just the table.
- Global mode:
Expanderper list with list name as header (default expanded). - State as a colored badge — new
WorktreeStateColorConverteranalogous toStatusColorConverter:- Active=Blue, Merged=Green, Discarded=Gray, Kept=Orange.
- Right-click on a row opens a
MenuFlyoutwith all actions. - Phantom rows (
PathExistsOnDisk == false) get a small warning icon in the Path tooltip area.
Default sort
State (Active first), then CreatedAt descending. Same inside each list group in global mode.
Per-row context menu
| Item | Enabled when | Behavior |
|---|---|---|
| Show diff | always | Opens existing WorktreeModalView with WorktreePath set |
| Open in Explorer | PathExistsOnDisk == true |
Process.Start("explorer.exe", path) |
| Jump to task | always | Closes modal, selects list + task in main window |
| Merge | State == Active |
Calls existing MergeTask hub method |
| Discard | State == Active |
SetWorktreeState(taskId, Discarded) |
| Keep | State == Active |
SetWorktreeState(taskId, Kept) |
| Copy branch | always | Clipboard |
| Copy path | always | Clipboard |
| —————— | (separator) | |
| Force remove | Task.Status != Running |
Confirmation dialog → ForceRemoveWorktree(taskId) (red label) |
Bulk buttons (toolbar)
- Refresh — re-runs
GetWorktreesOverview. - Cleanup finished —
CleanupFinishedWorktrees(listId); in list-filtered mode acts on that list, in global mode on all.
Entry points
- List context menu → "Worktrees anzeigen…" → opens modal in filtered mode (
listId= the list). - Help menu → "Worktrees" → opens modal in global mode (
listId = null).
MainWindowViewModel gets OpenWorktreesOverviewCommand(listId) and OpenWorktreesOverviewGlobalCommand(), both using a DI Func<WorktreesOverviewModalViewModel> factory analogous to existing editor patterns.
SignalR Contract
New WorkerHub methods
Task<IReadOnlyList<WorktreeOverviewDto>> GetWorktreesOverview(string? listId);
Task<bool> SetWorktreeState(string taskId, WorktreeState newState);
Task<ForceRemoveResultDto> ForceRemoveWorktree(string taskId);
CleanupFinishedWorktrees already exists — extend its signature to accept an optional listId:
Task<CleanupResult> CleanupFinishedWorktrees(string? listId); // was: ()
MergeTask is reused unchanged.
DTOs
public sealed record WorktreeOverviewDto(
string TaskId,
string TaskTitle,
TaskStatus TaskStatus,
string ListId,
string ListName,
string Path,
string BranchName,
WorktreeState State,
string? DiffStat,
DateTime CreatedAt,
bool PathExistsOnDisk);
public sealed record ForceRemoveResultDto(bool Removed, string? Reason);
Broadcasts
After successful SetWorktreeState and ForceRemoveWorktree, fire HubBroadcaster.WorktreeUpdated(taskId) so TaskDetailView (if open) refreshes. CleanupFinishedWorktrees already broadcasts; keep behavior, optionally batch.
WorkerClient (UI)
Add wrapper methods for the four new/changed hub calls.
Backend Changes
WorktreeMaintenanceService
public sealed record ForceRemoveResult(bool Removed, string? Reason);
public Task<IReadOnlyList<WorktreeOverviewRow>> GetOverviewAsync(string? listId, CancellationToken ct);
public Task<CleanupResult> CleanupFinishedAsync(string? listId, CancellationToken ct); // signature extended
public Task<ForceRemoveResult> ForceRemoveAsync(string taskId, CancellationToken ct);
GetOverviewAsync— joinsworktrees × tasks × lists(AsNoTracking), maps to DTO includingPathExistsOnDisk = Directory.Exists(path).CleanupFinishedAsync(listId)— same join as today but also filterst.ListId == listIdwhen not null.ForceRemoveAsync— refactors existingTryRemoveAsync(row, force: true, …)into a single-row entry point shared withResetAllAsync. Refuses when the task is currentlyRunning, returningForceRemoveResult(false, "task is currently running"). Otherwise removes the worktree directory, prunes, deletes the branch, deletes the DB row.
WorktreeRepository
SetStateAsync(string taskId, WorktreeState newState, CancellationToken ct) already documented in CLAUDE.md. If absent, add it; if present, just expose it via the hub.
Unchanged
WorktreeManager, TaskRunner, WorktreeModalView, all existing merge / cleanup flows.
Data Flow
- User opens modal →
WorkerClient.GetWorktreesOverviewAsync(listId)→ bind rows. - Refresh button → same call.
- Per-row action → corresponding hub call → on success, update the affected row locally (no full reload).
- Bulk Cleanup → hub call → full reload.
Force-Remove Semantics
| Initial state | Result |
|---|---|
| Active, task not Running | Worktree dir removed, branch deleted, DB row deleted. Task remains in current status (Done/Failed/Idle). |
| Active, task Running | Refused with reason "task is currently running". |
| Merged / Discarded / Kept | Same removal path. |
| Phantom (dir missing) | DB row deleted, branch best-effort deleted. |
Testing
New tests in tests/ClaudeDo.Worker.Tests/Services/WorktreeMaintenanceServiceTests.cs (real SQLite, real git):
GetOverviewAsync_returns_all_when_listId_nullGetOverviewAsync_filters_by_listIdGetOverviewAsync_flags_PathExistsOnDisk_false_for_phantom_rowCleanupFinishedAsync_filters_by_listIdForceRemoveAsync_removes_active_worktree(happy path incl. branch delete)ForceRemoveAsync_blocked_when_task_runningForceRemoveAsync_removes_phantom_row
UI verification (manual):
- Open from list context menu → only that list's rows.
- Open from Help menu → all lists grouped, default expanded.
- Force-remove an Active worktree → row vanishes, DB row gone, branch deleted.
- Force-remove while task Running → toast / dialog with reason, row unchanged.
- Cleanup finished in filtered mode → only finished rows of the selected list disappear.
- "Show diff" reuses existing
WorktreeModalView.
Files Touched
New:
src/ClaudeDo.Ui/ViewModels/Modals/WorktreesOverviewModalViewModel.cssrc/ClaudeDo.Ui/Views/Modals/WorktreesOverviewModalView.axamlsrc/ClaudeDo.Ui/Views/Modals/WorktreesOverviewModalView.axaml.cssrc/ClaudeDo.Ui/Converters/WorktreeStateColorConverter.cssrc/ClaudeDo.Worker/Worktrees/WorktreeOverviewDto.cs(or extend an existing DTOs file)
Modified:
src/ClaudeDo.Worker/Worktrees/WorktreeMaintenanceService.cssrc/ClaudeDo.Worker/Hub/WorkerHub.cssrc/ClaudeDo.Ui/Services/WorkerClient.cssrc/ClaudeDo.Ui/ViewModels/MainWindowViewModel.cssrc/ClaudeDo.Ui/Views/MainWindow.axaml(Help menu entry, list context menu entry)src/ClaudeDo.App/Program.cs(DI registration of new VM)tests/ClaudeDo.Worker.Tests/Services/WorktreeMaintenanceServiceTests.cs