From b944597af456ccc4308321e98a570c3de12b59e8 Mon Sep 17 00:00:00 2001 From: mika kuns Date: Tue, 19 May 2026 09:27:19 +0200 Subject: [PATCH 01/18] docs: add worktree overview modal spec and plan --- .../2026-05-19-worktree-overview-modal.md | 1423 +++++++++++++++++ ...26-05-19-worktree-overview-modal-design.md | 206 +++ 2 files changed, 1629 insertions(+) create mode 100644 docs/superpowers/plans/2026-05-19-worktree-overview-modal.md create mode 100644 docs/superpowers/specs/2026-05-19-worktree-overview-modal-design.md diff --git a/docs/superpowers/plans/2026-05-19-worktree-overview-modal.md b/docs/superpowers/plans/2026-05-19-worktree-overview-modal.md new file mode 100644 index 0000000..8fd83ce --- /dev/null +++ b/docs/superpowers/plans/2026-05-19-worktree-overview-modal.md @@ -0,0 +1,1423 @@ +# Worktree Overview Modal 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 a modal that lists every worktree with quick info and context-menu actions, openable per-list (from List context menu) or globally (from Help menu). + +**Architecture:** Backend additions go on existing `WorktreeMaintenanceService` (overview query, list-scoped cleanup, single-row force remove). New SignalR DTOs + hub methods route data to a new `WorktreesOverviewModalViewModel` + Avalonia view. The existing single-worktree file-tree modal (`WorktreeModalView`) is reused for the "Show diff" action. No new services, no new tables. + +**Tech Stack:** .NET 8, Avalonia 12, EF Core (SQLite), SignalR, xUnit, CommunityToolkit.Mvvm. + +**Reference spec:** `docs/superpowers/specs/2026-05-19-worktree-overview-modal-design.md` + +--- + +## File Structure + +**New:** +- `src/ClaudeDo.Worker/Worktrees/WorktreeOverviewRow.cs` — record returned from service +- `src/ClaudeDo.Ui/Converters/WorktreeStateColorConverter.cs` — badge color +- `src/ClaudeDo.Ui/ViewModels/Modals/WorktreesOverviewModalViewModel.cs` +- `src/ClaudeDo.Ui/Views/Modals/WorktreesOverviewModalView.axaml` +- `src/ClaudeDo.Ui/Views/Modals/WorktreesOverviewModalView.axaml.cs` + +**Modified:** +- `src/ClaudeDo.Worker/Worktrees/WorktreeMaintenanceService.cs` +- `src/ClaudeDo.Worker/Hub/WorkerHub.cs` +- `src/ClaudeDo.Ui/Services/WorkerClient.cs` (+ DTOs at file bottom) +- `src/ClaudeDo.Ui/Services/IWorkerClient.cs` (if interface defines the changed methods — check first) +- `src/ClaudeDo.Ui/ViewModels/Islands/ListsIslandViewModel.cs` (list context menu command) +- `src/ClaudeDo.Ui/ViewModels/IslandsShellViewModel.cs` (Help-menu global command) +- `src/ClaudeDo.Ui/Views/Islands/ListsIslandView.axaml` (context-menu entry) +- `src/ClaudeDo.Ui/Views/Islands/ListsIslandView.axaml.cs` (wire `ShowWorktreesOverviewModal`) +- `src/ClaudeDo.Ui/Views/MainWindow.axaml` (Help menu item) +- `src/ClaudeDo.Ui/Views/MainWindow.axaml.cs` (wire `ShowWorktreesOverviewModal` for global) +- `src/ClaudeDo.App/Program.cs` (DI registration) +- `tests/ClaudeDo.Worker.Tests/Services/WorktreeMaintenanceServiceTests.cs` (new tests) + +--- + +## Task 1: Extend `CleanupFinishedAsync` to accept optional `listId` + +**Files:** +- Modify: `src/ClaudeDo.Worker/Worktrees/WorktreeMaintenanceService.cs:27-45` +- Test: `tests/ClaudeDo.Worker.Tests/Services/WorktreeMaintenanceServiceTests.cs` + +- [ ] **Step 1: Write the failing test** + +Append to `WorktreeMaintenanceServiceTests`: + +```csharp +[Fact] +public async Task CleanupFinished_With_ListId_Only_Removes_That_Lists_Rows() +{ + if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; } + + var repo = NewRepo(); + var git = new GitService(); + var db = NewDb(); + + var (listA, taskA) = MakeEntities(repo.RepoDir); + var (listB, taskB) = MakeEntities(repo.RepoDir); + + var wtA = await CreateWorktreeAsync(git, repo.RepoDir, taskA.Id); + var wtB = await CreateWorktreeAsync(git, repo.RepoDir, taskB.Id); + + using (var ctx = db.CreateContext()) + { + await new ListRepository(ctx).AddAsync(listA); + await new ListRepository(ctx).AddAsync(listB); + var taskRepo = new TaskRepository(ctx); + await taskRepo.AddAsync(taskA); + await taskRepo.AddAsync(taskB); + var wtRepo = new WorktreeRepository(ctx); + await wtRepo.AddAsync(new WorktreeEntity + { + TaskId = taskA.Id, Path = wtA, BranchName = $"test/{taskA.Id}", + BaseCommit = repo.BaseCommit, State = WorktreeState.Merged, CreatedAt = DateTime.UtcNow, + }); + await wtRepo.AddAsync(new WorktreeEntity + { + TaskId = taskB.Id, Path = wtB, BranchName = $"test/{taskB.Id}", + BaseCommit = repo.BaseCommit, State = WorktreeState.Merged, CreatedAt = DateTime.UtcNow, + }); + } + + var svc = new WorktreeMaintenanceService( + db.CreateFactory(), git, NullLogger.Instance); + + var result = await svc.CleanupFinishedAsync(listA.Id, CancellationToken.None); + + Assert.Equal(1, result.Removed); + Assert.False(Directory.Exists(wtA)); + Assert.True(Directory.Exists(wtB)); + + using var checkCtx = db.CreateContext(); + var remaining = await new WorktreeRepository(checkCtx).GetAllAsync(); + Assert.Single(remaining); + Assert.Equal(taskB.Id, remaining[0].TaskId); +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `dotnet test tests/ClaudeDo.Worker.Tests --filter "FullyQualifiedName~CleanupFinished_With_ListId_Only_Removes_That_Lists_Rows"` +Expected: FAIL — method signature does not accept a `listId`. + +- [ ] **Step 3: Change `CleanupFinishedAsync` signature and add list filter** + +Replace the existing `CleanupFinishedAsync` (currently at `WorktreeMaintenanceService.cs:27-45`) with: + +```csharp +public async Task CleanupFinishedAsync(string? listId = null, CancellationToken ct = default) +{ + using var context = _dbFactory.CreateDbContext(); + var query = from w in context.Worktrees + join t in context.Tasks on w.TaskId equals t.Id + join l in context.Lists on t.ListId equals l.Id + where w.State == WorktreeState.Merged || w.State == WorktreeState.Discarded + select new { Row = new WorktreeRow(w.TaskId, w.Path, w.BranchName, l.WorkingDir), ListId = t.ListId }; + + if (!string.IsNullOrEmpty(listId)) + query = query.Where(x => x.ListId == listId); + + var rows = await query.AsNoTracking().Select(x => x.Row).ToListAsync(ct); + + int removed = 0; + foreach (var row in rows) + { + if (await TryRemoveAsync(row, force: false, ct)) + removed++; + } + return new CleanupResult(removed); +} +``` + +- [ ] **Step 4: Run all tests in the file to verify pass and no regression** + +Run: `dotnet test tests/ClaudeDo.Worker.Tests --filter "FullyQualifiedName~WorktreeMaintenanceServiceTests"` +Expected: PASS (all four tests, existing three plus new one). + +- [ ] **Step 5: Commit** + +```bash +git add src/ClaudeDo.Worker/Worktrees/WorktreeMaintenanceService.cs tests/ClaudeDo.Worker.Tests/Services/WorktreeMaintenanceServiceTests.cs +git commit -m "feat(worktrees): allow CleanupFinishedAsync to filter by list" +``` + +--- + +## Task 2: Add `GetOverviewAsync` to `WorktreeMaintenanceService` + +**Files:** +- Create: `src/ClaudeDo.Worker/Worktrees/WorktreeOverviewRow.cs` +- Modify: `src/ClaudeDo.Worker/Worktrees/WorktreeMaintenanceService.cs` +- Test: `tests/ClaudeDo.Worker.Tests/Services/WorktreeMaintenanceServiceTests.cs` + +- [ ] **Step 1: Create the row record** + +Create `src/ClaudeDo.Worker/Worktrees/WorktreeOverviewRow.cs`: + +```csharp +using ClaudeDo.Data.Models; +using TaskStatus = ClaudeDo.Data.Models.TaskStatus; + +namespace ClaudeDo.Worker.Worktrees; + +public sealed record WorktreeOverviewRow( + string TaskId, + string TaskTitle, + TaskStatus TaskStatus, + string ListId, + string ListName, + string Path, + string BranchName, + WorktreeState State, + string? DiffStat, + DateTime CreatedAt, + bool PathExistsOnDisk); +``` + +- [ ] **Step 2: Write the failing tests** + +Append to `WorktreeMaintenanceServiceTests`: + +```csharp +[Fact] +public async Task GetOverview_Returns_All_When_ListId_Null() +{ + if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; } + + var repo = NewRepo(); + var git = new GitService(); + var db = NewDb(); + + var (listA, taskA) = MakeEntities(repo.RepoDir); + var (listB, taskB) = MakeEntities(repo.RepoDir); + var wtA = await CreateWorktreeAsync(git, repo.RepoDir, taskA.Id); + var wtB = await CreateWorktreeAsync(git, repo.RepoDir, taskB.Id); + + using (var ctx = db.CreateContext()) + { + await new ListRepository(ctx).AddAsync(listA); + await new ListRepository(ctx).AddAsync(listB); + await new TaskRepository(ctx).AddAsync(taskA); + await new TaskRepository(ctx).AddAsync(taskB); + var wtRepo = new WorktreeRepository(ctx); + await wtRepo.AddAsync(new WorktreeEntity + { + TaskId = taskA.Id, Path = wtA, BranchName = $"test/{taskA.Id}", + BaseCommit = repo.BaseCommit, State = WorktreeState.Active, CreatedAt = DateTime.UtcNow, + }); + await wtRepo.AddAsync(new WorktreeEntity + { + TaskId = taskB.Id, Path = wtB, BranchName = $"test/{taskB.Id}", + BaseCommit = repo.BaseCommit, State = WorktreeState.Merged, CreatedAt = DateTime.UtcNow, + }); + } + + var svc = new WorktreeMaintenanceService( + db.CreateFactory(), git, NullLogger.Instance); + + var rows = await svc.GetOverviewAsync(null, CancellationToken.None); + + Assert.Equal(2, rows.Count); + Assert.Contains(rows, r => r.TaskId == taskA.Id && r.PathExistsOnDisk); + Assert.Contains(rows, r => r.TaskId == taskB.Id && r.PathExistsOnDisk); +} + +[Fact] +public async Task GetOverview_Filters_By_ListId() +{ + if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; } + + var repo = NewRepo(); + var git = new GitService(); + var db = NewDb(); + + var (listA, taskA) = MakeEntities(repo.RepoDir); + var (listB, taskB) = MakeEntities(repo.RepoDir); + var wtA = await CreateWorktreeAsync(git, repo.RepoDir, taskA.Id); + var wtB = await CreateWorktreeAsync(git, repo.RepoDir, taskB.Id); + + using (var ctx = db.CreateContext()) + { + await new ListRepository(ctx).AddAsync(listA); + await new ListRepository(ctx).AddAsync(listB); + await new TaskRepository(ctx).AddAsync(taskA); + await new TaskRepository(ctx).AddAsync(taskB); + var wtRepo = new WorktreeRepository(ctx); + await wtRepo.AddAsync(new WorktreeEntity + { + TaskId = taskA.Id, Path = wtA, BranchName = $"test/{taskA.Id}", + BaseCommit = repo.BaseCommit, State = WorktreeState.Active, CreatedAt = DateTime.UtcNow, + }); + await wtRepo.AddAsync(new WorktreeEntity + { + TaskId = taskB.Id, Path = wtB, BranchName = $"test/{taskB.Id}", + BaseCommit = repo.BaseCommit, State = WorktreeState.Active, CreatedAt = DateTime.UtcNow, + }); + } + + var svc = new WorktreeMaintenanceService( + db.CreateFactory(), git, NullLogger.Instance); + + var rows = await svc.GetOverviewAsync(listA.Id, CancellationToken.None); + + Assert.Single(rows); + Assert.Equal(taskA.Id, rows[0].TaskId); +} + +[Fact] +public async Task GetOverview_Flags_PathExistsOnDisk_False_For_Phantom_Row() +{ + if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; } + + var repo = NewRepo(); + var git = new GitService(); + var db = NewDb(); + + var (list, task) = MakeEntities(repo.RepoDir); + var wt = await CreateWorktreeAsync(git, repo.RepoDir, task.Id); + + // Delete the worktree directory but keep the DB row → phantom. + try { await git.WorktreeRemoveAsync(repo.RepoDir, wt, force: true); } catch { } + if (Directory.Exists(wt)) Directory.Delete(wt, recursive: true); + + using (var ctx = db.CreateContext()) + { + await new ListRepository(ctx).AddAsync(list); + await new TaskRepository(ctx).AddAsync(task); + await new WorktreeRepository(ctx).AddAsync(new WorktreeEntity + { + TaskId = task.Id, Path = wt, BranchName = $"test/{task.Id}", + BaseCommit = repo.BaseCommit, State = WorktreeState.Active, CreatedAt = DateTime.UtcNow, + }); + } + + var svc = new WorktreeMaintenanceService( + db.CreateFactory(), git, NullLogger.Instance); + + var rows = await svc.GetOverviewAsync(null, CancellationToken.None); + + Assert.Single(rows); + Assert.False(rows[0].PathExistsOnDisk); +} +``` + +- [ ] **Step 3: Run tests to verify they fail** + +Run: `dotnet test tests/ClaudeDo.Worker.Tests --filter "FullyQualifiedName~GetOverview"` +Expected: FAIL — `GetOverviewAsync` does not exist. + +- [ ] **Step 4: Add `GetOverviewAsync` to `WorktreeMaintenanceService`** + +Add this method to `WorktreeMaintenanceService` (e.g. directly after the existing `CleanupFinishedAsync`): + +```csharp +public async Task> GetOverviewAsync( + string? listId, CancellationToken ct = default) +{ + using var context = _dbFactory.CreateDbContext(); + var query = from w in context.Worktrees + join t in context.Tasks on w.TaskId equals t.Id + join l in context.Lists on t.ListId equals l.Id + select new + { + w.TaskId, t.Title, t.Status, ListId = l.Id, ListName = l.Name, + w.Path, w.BranchName, w.State, w.DiffStat, w.CreatedAt, + }; + + if (!string.IsNullOrEmpty(listId)) + query = query.Where(x => x.ListId == listId); + + var rows = await query.AsNoTracking().ToListAsync(ct); + + return rows.Select(x => new WorktreeOverviewRow( + x.TaskId, x.Title, x.Status, x.ListId, x.ListName, + x.Path, x.BranchName, x.State, x.DiffStat, x.CreatedAt, + PathExistsOnDisk: !string.IsNullOrWhiteSpace(x.Path) && Directory.Exists(x.Path))).ToList(); +} +``` + +- [ ] **Step 5: Run tests to verify they pass** + +Run: `dotnet test tests/ClaudeDo.Worker.Tests --filter "FullyQualifiedName~WorktreeMaintenanceServiceTests"` +Expected: PASS. + +- [ ] **Step 6: Commit** + +```bash +git add src/ClaudeDo.Worker/Worktrees/WorktreeOverviewRow.cs src/ClaudeDo.Worker/Worktrees/WorktreeMaintenanceService.cs tests/ClaudeDo.Worker.Tests/Services/WorktreeMaintenanceServiceTests.cs +git commit -m "feat(worktrees): add GetOverviewAsync for overview modal" +``` + +--- + +## Task 3: Add `ForceRemoveAsync` to `WorktreeMaintenanceService` + +**Files:** +- Modify: `src/ClaudeDo.Worker/Worktrees/WorktreeMaintenanceService.cs` +- Test: `tests/ClaudeDo.Worker.Tests/Services/WorktreeMaintenanceServiceTests.cs` + +- [ ] **Step 1: Write the failing tests** + +Append to `WorktreeMaintenanceServiceTests`: + +```csharp +[Fact] +public async Task ForceRemove_Removes_Active_Worktree() +{ + if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; } + + var repo = NewRepo(); + var git = new GitService(); + var db = NewDb(); + + var (list, task) = MakeEntities(repo.RepoDir, status: ClaudeDo.Data.Models.TaskStatus.Done); + var wt = await CreateWorktreeAsync(git, repo.RepoDir, task.Id); + + using (var ctx = db.CreateContext()) + { + await new ListRepository(ctx).AddAsync(list); + await new TaskRepository(ctx).AddAsync(task); + await new WorktreeRepository(ctx).AddAsync(new WorktreeEntity + { + TaskId = task.Id, Path = wt, BranchName = $"test/{task.Id}", + BaseCommit = repo.BaseCommit, State = WorktreeState.Active, CreatedAt = DateTime.UtcNow, + }); + } + + var svc = new WorktreeMaintenanceService( + db.CreateFactory(), git, NullLogger.Instance); + + var result = await svc.ForceRemoveAsync(task.Id, CancellationToken.None); + + Assert.True(result.Removed); + Assert.Null(result.Reason); + Assert.False(Directory.Exists(wt)); + + using var checkCtx = db.CreateContext(); + var remaining = await new WorktreeRepository(checkCtx).GetAllAsync(); + Assert.Empty(remaining); +} + +[Fact] +public async Task ForceRemove_Blocked_When_Task_Running() +{ + if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; } + + var repo = NewRepo(); + var git = new GitService(); + var db = NewDb(); + + var (list, task) = MakeEntities(repo.RepoDir, status: ClaudeDo.Data.Models.TaskStatus.Running); + var wt = await CreateWorktreeAsync(git, repo.RepoDir, task.Id); + + using (var ctx = db.CreateContext()) + { + await new ListRepository(ctx).AddAsync(list); + await new TaskRepository(ctx).AddAsync(task); + await new WorktreeRepository(ctx).AddAsync(new WorktreeEntity + { + TaskId = task.Id, Path = wt, BranchName = $"test/{task.Id}", + BaseCommit = repo.BaseCommit, State = WorktreeState.Active, CreatedAt = DateTime.UtcNow, + }); + } + + var svc = new WorktreeMaintenanceService( + db.CreateFactory(), git, NullLogger.Instance); + + var result = await svc.ForceRemoveAsync(task.Id, CancellationToken.None); + + Assert.False(result.Removed); + Assert.Equal("task is currently running", result.Reason); + Assert.True(Directory.Exists(wt)); + + try { await git.WorktreeRemoveAsync(repo.RepoDir, wt, force: true); } catch { } +} + +[Fact] +public async Task ForceRemove_Removes_Phantom_Row() +{ + if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; } + + var repo = NewRepo(); + var git = new GitService(); + var db = NewDb(); + + var (list, task) = MakeEntities(repo.RepoDir); + var phantomPath = Path.Combine(Path.GetTempPath(), $"wt_phantom_{Guid.NewGuid():N}"); + // Note: never created on disk. + + using (var ctx = db.CreateContext()) + { + await new ListRepository(ctx).AddAsync(list); + await new TaskRepository(ctx).AddAsync(task); + await new WorktreeRepository(ctx).AddAsync(new WorktreeEntity + { + TaskId = task.Id, Path = phantomPath, BranchName = $"test/{task.Id}-phantom", + BaseCommit = repo.BaseCommit, State = WorktreeState.Active, CreatedAt = DateTime.UtcNow, + }); + } + + var svc = new WorktreeMaintenanceService( + db.CreateFactory(), git, NullLogger.Instance); + + var result = await svc.ForceRemoveAsync(task.Id, CancellationToken.None); + + Assert.True(result.Removed); + + using var checkCtx = db.CreateContext(); + var remaining = await new WorktreeRepository(checkCtx).GetAllAsync(); + Assert.Empty(remaining); +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `dotnet test tests/ClaudeDo.Worker.Tests --filter "FullyQualifiedName~ForceRemove"` +Expected: FAIL — `ForceRemoveAsync` does not exist; `ForceRemoveResult` does not exist. + +- [ ] **Step 3: Add `ForceRemoveResult` record + `ForceRemoveAsync` method** + +Add to `WorktreeMaintenanceService.cs`, alongside the existing nested records (line 10–11 area): + +```csharp +public sealed record ForceRemoveResult(bool Removed, string? Reason); +``` + +Add this method to `WorktreeMaintenanceService` (after `ResetAllAsync`): + +```csharp +public async Task ForceRemoveAsync(string taskId, CancellationToken ct = default) +{ + using var context = _dbFactory.CreateDbContext(); + + var row = await (from w in context.Worktrees + join t in context.Tasks on w.TaskId equals t.Id + join l in context.Lists on t.ListId equals l.Id + where w.TaskId == taskId + select new { Row = new WorktreeRow(w.TaskId, w.Path, w.BranchName, l.WorkingDir), + Status = t.Status }) + .AsNoTracking() + .FirstOrDefaultAsync(ct); + + if (row is null) + return new ForceRemoveResult(false, "worktree not found"); + + if (row.Status == ClaudeDo.Data.Models.TaskStatus.Running) + return new ForceRemoveResult(false, "task is currently running"); + + var ok = await TryRemoveAsync(row.Row, force: true, ct); + return new ForceRemoveResult(ok, ok ? null : "remove failed"); +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `dotnet test tests/ClaudeDo.Worker.Tests --filter "FullyQualifiedName~WorktreeMaintenanceServiceTests"` +Expected: PASS (all tests). + +- [ ] **Step 5: Commit** + +```bash +git add src/ClaudeDo.Worker/Worktrees/WorktreeMaintenanceService.cs tests/ClaudeDo.Worker.Tests/Services/WorktreeMaintenanceServiceTests.cs +git commit -m "feat(worktrees): add ForceRemoveAsync for targeted removal" +``` + +--- + +## Task 4: Add hub DTOs + new hub methods + +**Files:** +- Modify: `src/ClaudeDo.Worker/Hub/WorkerHub.cs` + +- [ ] **Step 1: Add DTOs at the top of the file (alongside existing records)** + +In `WorkerHub.cs`, add after `record WorktreeResetDto`: + +```csharp +public record WorktreeOverviewDto( + string TaskId, + string TaskTitle, + ClaudeDo.Data.Models.TaskStatus TaskStatus, + string ListId, + string ListName, + string Path, + string BranchName, + WorktreeState State, + string? DiffStat, + DateTime CreatedAt, + bool PathExistsOnDisk); + +public record ForceRemoveResultDto(bool Removed, string? Reason); +``` + +- [ ] **Step 2: Replace existing `CleanupFinishedWorktrees` with the list-scoped version** + +Replace the current method (around line 223–227): + +```csharp +public async Task CleanupFinishedWorktrees(string? listId = null) +{ + var result = await _wtMaintenance.CleanupFinishedAsync(listId, CancellationToken.None); + return new WorktreeCleanupDto(result.Removed); +} +``` + +- [ ] **Step 3: Add `GetWorktreesOverview`, `SetWorktreeState`, `ForceRemoveWorktree`** + +Add after `ResetAllWorktrees` (around line 233): + +```csharp +public async Task> GetWorktreesOverview(string? listId) +{ + var rows = await _wtMaintenance.GetOverviewAsync(listId, Context.ConnectionAborted); + return rows.Select(r => new WorktreeOverviewDto( + r.TaskId, r.TaskTitle, r.TaskStatus, r.ListId, r.ListName, + r.Path, r.BranchName, r.State, r.DiffStat, r.CreatedAt, r.PathExistsOnDisk)).ToList(); +} + +public async Task SetWorktreeState(string taskId, WorktreeState newState) +{ + using var ctx = _dbFactory.CreateDbContext(); + var repo = new WorktreeRepository(ctx); + var existing = await repo.GetByTaskIdAsync(taskId, Context.ConnectionAborted); + if (existing is null) throw new HubException("worktree not found"); + await repo.SetStateAsync(taskId, newState, Context.ConnectionAborted); + await _broadcaster.WorktreeUpdated(taskId); + return true; +} + +public async Task ForceRemoveWorktree(string taskId) +{ + var result = await _wtMaintenance.ForceRemoveAsync(taskId, Context.ConnectionAborted); + if (result.Removed) + await _broadcaster.WorktreeUpdated(taskId); + return new ForceRemoveResultDto(result.Removed, result.Reason); +} +``` + +Note: `HubBroadcaster.WorktreeUpdated(string taskId)` already exists (see `WorkerClient`'s `WorktreeUpdated` handler). If for some reason it does not, add it to `HubBroadcaster` as: + +```csharp +public Task WorktreeUpdated(string taskId) => _hub.Clients.All.SendAsync("WorktreeUpdated", taskId); +``` + +- [ ] **Step 4: Build the Worker project** + +Run: `dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj` +Expected: build succeeds. + +- [ ] **Step 5: Commit** + +```bash +git add src/ClaudeDo.Worker/Hub/WorkerHub.cs +git commit -m "feat(hub): expose worktree overview, state mutation, force-remove" +``` + +--- + +## Task 5: Add `WorkerClient` wrappers + +**Files:** +- Modify: `src/ClaudeDo.Ui/Services/WorkerClient.cs` + +- [ ] **Step 1: Add UI-side DTOs at the bottom of `WorkerClient.cs`** + +Append to the existing DTO section at the bottom of the file: + +```csharp +public sealed record WorktreeOverviewDto( + string TaskId, + string TaskTitle, + ClaudeDo.Data.Models.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); +``` + +- [ ] **Step 2: Replace the existing `CleanupFinishedWorktreesAsync` to accept optional `listId`** + +Replace lines around 398–408 in `WorkerClient.cs`: + +```csharp +public async Task CleanupFinishedWorktreesAsync(string? listId = null) +{ + try + { + return await _hub.InvokeAsync("CleanupFinishedWorktrees", listId); + } + catch + { + return null; + } +} +``` + +- [ ] **Step 3: Add the three new wrapper methods** + +Add after `ResetAllWorktreesAsync` (around line 420): + +```csharp +public async Task> GetWorktreesOverviewAsync(string? listId) +{ + try + { + var rows = await _hub.InvokeAsync>("GetWorktreesOverview", listId); + return rows ?? new List(); + } + catch + { + return new List(); + } +} + +public async Task SetWorktreeStateAsync(string taskId, WorktreeState newState) +{ + try + { + return await _hub.InvokeAsync("SetWorktreeState", taskId, newState); + } + catch + { + return false; + } +} + +public async Task ForceRemoveWorktreeAsync(string taskId) +{ + try + { + return await _hub.InvokeAsync("ForceRemoveWorktree", taskId); + } + catch + { + return null; + } +} +``` + +- [ ] **Step 4: Check for `IWorkerClient` interface** + +Run: `grep -n "CleanupFinishedWorktreesAsync\|interface IWorkerClient" src/ClaudeDo.Ui/Services/IWorkerClient.cs` + +If `IWorkerClient` defines `CleanupFinishedWorktreesAsync`, update its signature to match. If it does not declare it, skip. + +- [ ] **Step 5: Build the Ui project** + +Run: `dotnet build src/ClaudeDo.Ui/ClaudeDo.Ui.csproj` +Expected: build succeeds. + +- [ ] **Step 6: Commit** + +```bash +git add src/ClaudeDo.Ui/Services/WorkerClient.cs src/ClaudeDo.Ui/Services/IWorkerClient.cs +git commit -m "feat(ui): expose worktree overview client methods" +``` + +--- + +## Task 6: Add `WorktreeStateColorConverter` + +**Files:** +- Create: `src/ClaudeDo.Ui/Converters/WorktreeStateColorConverter.cs` + +- [ ] **Step 1: Create the converter** + +Create `src/ClaudeDo.Ui/Converters/WorktreeStateColorConverter.cs`: + +```csharp +using System.Globalization; +using Avalonia.Data.Converters; +using Avalonia.Media; +using ClaudeDo.Data.Models; + +namespace ClaudeDo.Ui.Converters; + +public sealed class WorktreeStateColorConverter : IValueConverter +{ + public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture) => + value is WorktreeState state + ? state switch + { + WorktreeState.Active => new SolidColorBrush(Color.Parse("#42A5F5")), // blue + WorktreeState.Merged => new SolidColorBrush(Color.Parse("#66BB6A")), // green + WorktreeState.Discarded => new SolidColorBrush(Color.Parse("#9E9E9E")), // gray + WorktreeState.Kept => new SolidColorBrush(Color.Parse("#FFA726")), // orange + _ => new SolidColorBrush(Colors.Gray), + } + : new SolidColorBrush(Colors.Gray); + + public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) + => throw new NotSupportedException(); +} +``` + +- [ ] **Step 2: Build to confirm** + +Run: `dotnet build src/ClaudeDo.Ui/ClaudeDo.Ui.csproj` +Expected: build succeeds. + +- [ ] **Step 3: Commit** + +```bash +git add src/ClaudeDo.Ui/Converters/WorktreeStateColorConverter.cs +git commit -m "feat(ui): add WorktreeStateColorConverter" +``` + +--- + +## Task 7: Add `WorktreesOverviewModalViewModel` + +**Files:** +- Create: `src/ClaudeDo.Ui/ViewModels/Modals/WorktreesOverviewModalViewModel.cs` + +- [ ] **Step 1: Create the row VM and the modal VM** + +Create `src/ClaudeDo.Ui/ViewModels/Modals/WorktreesOverviewModalViewModel.cs`: + +```csharp +using System.Collections.ObjectModel; +using System.Diagnostics; +using Avalonia; +using Avalonia.Controls.ApplicationLifetimes; +using Avalonia.Input.Platform; +using ClaudeDo.Data.Models; +using ClaudeDo.Ui.Services; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using TaskStatus = ClaudeDo.Data.Models.TaskStatus; + +namespace ClaudeDo.Ui.ViewModels.Modals; + +public sealed partial class WorktreeOverviewRowViewModel : ViewModelBase +{ + [ObservableProperty] private string _taskId = ""; + [ObservableProperty] private string _taskTitle = ""; + [ObservableProperty] private TaskStatus _taskStatus; + [ObservableProperty] private string _listId = ""; + [ObservableProperty] private string _listName = ""; + [ObservableProperty] private string _path = ""; + [ObservableProperty] private string _branchName = ""; + [ObservableProperty] private WorktreeState _state; + [ObservableProperty] private string? _diffStat; + [ObservableProperty] private DateTime _createdAt; + [ObservableProperty] private bool _pathExistsOnDisk; + + public string AgeText => FormatAge(DateTime.UtcNow - CreatedAt); + public bool IsActive => State == WorktreeState.Active; + public bool IsRunning => TaskStatus == TaskStatus.Running; + + private static string FormatAge(TimeSpan ts) + { + if (ts.TotalDays >= 1) return $"{(int)ts.TotalDays}d ago"; + if (ts.TotalHours >= 1) return $"{(int)ts.TotalHours}h ago"; + if (ts.TotalMinutes >= 1) return $"{(int)ts.TotalMinutes}m ago"; + return "just now"; + } +} + +public sealed partial class WorktreesGroupViewModel : ViewModelBase +{ + public required string ListId { get; init; } + public required string ListName { get; init; } + public ObservableCollection Rows { get; } = new(); +} + +public sealed partial class WorktreesOverviewModalViewModel : ViewModelBase +{ + private readonly WorkerClient _worker; + + [ObservableProperty] private string? _listIdFilter; + [ObservableProperty] private string _title = "Worktrees"; + [ObservableProperty] private bool _isGlobal; + [ObservableProperty] private bool _isBusy; + [ObservableProperty] private string? _statusMessage; + + // Filtered (per-list) mode populates this directly. + public ObservableCollection Rows { get; } = new(); + // Global mode populates this. + public ObservableCollection Groups { get; } = new(); + + public Action? CloseAction { get; set; } + public Action? ShowDiffAction { get; set; } + public Action? JumpToTaskAction { get; set; } // (listId, taskId) + public Func>? ConfirmAction { get; set; } // message → confirmed? + + public WorktreesOverviewModalViewModel(WorkerClient worker) + { + _worker = worker; + } + + public void Configure(string? listId, string? listName) + { + ListIdFilter = listId; + IsGlobal = listId is null; + Title = listId is null ? "Worktrees" : $"Worktrees — {listName ?? "list"}"; + } + + public async Task LoadAsync(CancellationToken ct = default) + { + IsBusy = true; + StatusMessage = null; + try + { + var dtos = await _worker.GetWorktreesOverviewAsync(ListIdFilter); + var ordered = dtos + .OrderBy(d => d.State == WorktreeState.Active ? 0 : 1) + .ThenByDescending(d => d.CreatedAt) + .Select(Map) + .ToList(); + + Rows.Clear(); + Groups.Clear(); + if (IsGlobal) + { + foreach (var grp in ordered.GroupBy(r => (r.ListId, r.ListName)).OrderBy(g => g.Key.ListName)) + { + var group = new WorktreesGroupViewModel { ListId = grp.Key.ListId, ListName = grp.Key.ListName }; + foreach (var row in grp) group.Rows.Add(row); + Groups.Add(group); + } + } + else + { + foreach (var row in ordered) Rows.Add(row); + } + } + finally + { + IsBusy = false; + } + } + + [RelayCommand] + private Task Refresh() => LoadAsync(); + + [RelayCommand] + private async Task CleanupFinished() + { + IsBusy = true; + try + { + var result = await _worker.CleanupFinishedWorktreesAsync(ListIdFilter); + StatusMessage = result is null ? "Cleanup failed." : $"Removed {result.Removed} worktree(s)."; + await LoadAsync(); + } + finally { IsBusy = false; } + } + + [RelayCommand] + private void Close() => CloseAction?.Invoke(); + + [RelayCommand] + private void ShowDiff(WorktreeOverviewRowViewModel? row) + { + if (row is null) return; + ShowDiffAction?.Invoke(row); + } + + [RelayCommand] + private void OpenInExplorer(WorktreeOverviewRowViewModel? row) + { + if (row is null || !row.PathExistsOnDisk) return; + try { Process.Start(new ProcessStartInfo { FileName = "explorer.exe", Arguments = $"\"{row.Path}\"", UseShellExecute = true }); } + catch { /* best-effort */ } + } + + [RelayCommand] + private void JumpToTask(WorktreeOverviewRowViewModel? row) + { + if (row is null) return; + JumpToTaskAction?.Invoke(row.ListId, row.TaskId); + CloseAction?.Invoke(); + } + + [RelayCommand] + private async Task Discard(WorktreeOverviewRowViewModel? row) + { + if (row is null || row.State != WorktreeState.Active) return; + if (await _worker.SetWorktreeStateAsync(row.TaskId, WorktreeState.Discarded)) + { + row.State = WorktreeState.Discarded; + } + } + + [RelayCommand] + private async Task Keep(WorktreeOverviewRowViewModel? row) + { + if (row is null || row.State != WorktreeState.Active) return; + if (await _worker.SetWorktreeStateAsync(row.TaskId, WorktreeState.Kept)) + { + row.State = WorktreeState.Kept; + } + } + + [RelayCommand] + private async Task ForceRemove(WorktreeOverviewRowViewModel? row) + { + if (row is null) return; + if (row.IsRunning) { StatusMessage = "Cannot force-remove a running task."; return; } + if (ConfirmAction is not null && !await ConfirmAction($"Force remove worktree for '{row.TaskTitle}'? This deletes the directory and branch.")) return; + + var result = await _worker.ForceRemoveWorktreeAsync(row.TaskId); + if (result is null || !result.Removed) + { + StatusMessage = result?.Reason ?? "Force remove failed."; + return; + } + // Remove the row locally. + if (IsGlobal) + { + foreach (var grp in Groups) + { + var idx = grp.Rows.IndexOf(row); + if (idx >= 0) { grp.Rows.RemoveAt(idx); break; } + } + } + else + { + Rows.Remove(row); + } + } + + [RelayCommand] + private async Task CopyBranch(WorktreeOverviewRowViewModel? row) => await CopyToClipboardAsync(row?.BranchName); + + [RelayCommand] + private async Task CopyPath(WorktreeOverviewRowViewModel? row) => await CopyToClipboardAsync(row?.Path); + + private static async Task CopyToClipboardAsync(string? text) + { + if (string.IsNullOrEmpty(text)) return; + if (Application.Current?.ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop && + desktop.MainWindow?.Clipboard is { } clipboard) + { + try { await clipboard.SetTextAsync(text); } catch { } + } + } + + private static WorktreeOverviewRowViewModel Map(WorktreeOverviewDto d) => new() + { + TaskId = d.TaskId, TaskTitle = d.TaskTitle, TaskStatus = d.TaskStatus, + ListId = d.ListId, ListName = d.ListName, + Path = d.Path, BranchName = d.BranchName, State = d.State, + DiffStat = d.DiffStat, CreatedAt = d.CreatedAt, PathExistsOnDisk = d.PathExistsOnDisk, + }; +} +``` + +- [ ] **Step 2: Build to confirm** + +Run: `dotnet build src/ClaudeDo.Ui/ClaudeDo.Ui.csproj` +Expected: build succeeds. + +- [ ] **Step 3: Commit** + +```bash +git add src/ClaudeDo.Ui/ViewModels/Modals/WorktreesOverviewModalViewModel.cs +git commit -m "feat(ui): add WorktreesOverviewModalViewModel" +``` + +--- + +## Task 8: Add `WorktreesOverviewModalView` + +**Files:** +- Create: `src/ClaudeDo.Ui/Views/Modals/WorktreesOverviewModalView.axaml` +- Create: `src/ClaudeDo.Ui/Views/Modals/WorktreesOverviewModalView.axaml.cs` + +- [ ] **Step 1: Create the XAML** + +Create `src/ClaudeDo.Ui/Views/Modals/WorktreesOverviewModalView.axaml`: + +```xml + + + + + + + + +