# 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