Files
ClaudeDo/docs/superpowers/plans/2026-05-19-worktree-overview-modal.md
2026-05-19 09:27:19 +02:00

1424 lines
54 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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<WorktreeMaintenanceService>.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<CleanupResult> 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<WorktreeMaintenanceService>.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<WorktreeMaintenanceService>.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<WorktreeMaintenanceService>.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<IReadOnlyList<WorktreeOverviewRow>> 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<WorktreeMaintenanceService>.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<WorktreeMaintenanceService>.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<WorktreeMaintenanceService>.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 1011 area):
```csharp
public sealed record ForceRemoveResult(bool Removed, string? Reason);
```
Add this method to `WorktreeMaintenanceService` (after `ResetAllAsync`):
```csharp
public async Task<ForceRemoveResult> 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 223227):
```csharp
public async Task<WorktreeCleanupDto> 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<List<WorktreeOverviewDto>> 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<bool> 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<ForceRemoveResultDto> 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 398408 in `WorkerClient.cs`:
```csharp
public async Task<WorktreeCleanupDto?> CleanupFinishedWorktreesAsync(string? listId = null)
{
try
{
return await _hub.InvokeAsync<WorktreeCleanupDto>("CleanupFinishedWorktrees", listId);
}
catch
{
return null;
}
}
```
- [ ] **Step 3: Add the three new wrapper methods**
Add after `ResetAllWorktreesAsync` (around line 420):
```csharp
public async Task<List<WorktreeOverviewDto>> GetWorktreesOverviewAsync(string? listId)
{
try
{
var rows = await _hub.InvokeAsync<List<WorktreeOverviewDto>>("GetWorktreesOverview", listId);
return rows ?? new List<WorktreeOverviewDto>();
}
catch
{
return new List<WorktreeOverviewDto>();
}
}
public async Task<bool> SetWorktreeStateAsync(string taskId, WorktreeState newState)
{
try
{
return await _hub.InvokeAsync<bool>("SetWorktreeState", taskId, newState);
}
catch
{
return false;
}
}
public async Task<ForceRemoveResultDto?> ForceRemoveWorktreeAsync(string taskId)
{
try
{
return await _hub.InvokeAsync<ForceRemoveResultDto>("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<WorktreeOverviewRowViewModel> 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<WorktreeOverviewRowViewModel> Rows { get; } = new();
// Global mode populates this.
public ObservableCollection<WorktreesGroupViewModel> Groups { get; } = new();
public Action? CloseAction { get; set; }
public Action<WorktreeOverviewRowViewModel>? ShowDiffAction { get; set; }
public Action<string, string>? JumpToTaskAction { get; set; } // (listId, taskId)
public Func<string, Task<bool>>? 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
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:ClaudeDo.Ui.ViewModels.Modals"
xmlns:converters="using:ClaudeDo.Ui.Converters"
x:Class="ClaudeDo.Ui.Views.Modals.WorktreesOverviewModalView"
x:DataType="vm:WorktreesOverviewModalViewModel"
Title="{Binding Title}"
Width="900" Height="560" MinWidth="640" MinHeight="360"
WindowStartupLocation="CenterOwner"
Background="{DynamicResource VoidBrush}">
<Window.Resources>
<converters:WorktreeStateColorConverter x:Key="WorktreeStateColor"/>
</Window.Resources>
<DockPanel LastChildFill="True" Margin="12">
<!-- Toolbar -->
<StackPanel DockPanel.Dock="Top" Orientation="Horizontal" Spacing="8" Margin="0,0,0,8">
<Button Content="Refresh" Command="{Binding RefreshCommand}" IsEnabled="{Binding !IsBusy}"/>
<Button Content="Cleanup finished" Command="{Binding CleanupFinishedCommand}" IsEnabled="{Binding !IsBusy}"/>
<TextBlock Text="{Binding StatusMessage}" VerticalAlignment="Center" Margin="12,0,0,0"
Foreground="{DynamicResource TextDimBrush}"/>
</StackPanel>
<!-- Bottom bar -->
<StackPanel DockPanel.Dock="Bottom" Orientation="Horizontal" HorizontalAlignment="Right" Margin="0,8,0,0">
<Button Content="Close" Command="{Binding CloseCommand}"/>
</StackPanel>
<!-- Content: either a flat list (filtered) or groups (global) -->
<ScrollViewer>
<Grid>
<!-- Filtered (single-list) mode -->
<ItemsControl ItemsSource="{Binding Rows}" IsVisible="{Binding !IsGlobal}">
<ItemsControl.ItemTemplate>
<DataTemplate DataType="vm:WorktreeOverviewRowViewModel">
<ContentControl ContentTemplate="{StaticResource WorktreeRowTemplate}" Content="{Binding}"/>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<!-- Global mode -->
<ItemsControl ItemsSource="{Binding Groups}" IsVisible="{Binding IsGlobal}">
<ItemsControl.ItemTemplate>
<DataTemplate DataType="vm:WorktreesGroupViewModel">
<Expander Header="{Binding ListName}" IsExpanded="True" Margin="0,0,0,6">
<ItemsControl ItemsSource="{Binding Rows}">
<ItemsControl.ItemTemplate>
<DataTemplate DataType="vm:WorktreeOverviewRowViewModel">
<ContentControl ContentTemplate="{StaticResource WorktreeRowTemplate}" Content="{Binding}"/>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</Expander>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</Grid>
</ScrollViewer>
</DockPanel>
<Window.Styles>
<Style Selector="Border.wt-row">
<Setter Property="Background" Value="{DynamicResource DeepBrush}"/>
<Setter Property="BorderBrush" Value="{DynamicResource LineBrush}"/>
<Setter Property="BorderThickness" Value="0,0,0,1"/>
<Setter Property="Padding" Value="10,8"/>
</Style>
</Window.Styles>
<Window.Resources>
<!-- Row template, shared by both modes -->
<DataTemplate x:Key="WorktreeRowTemplate" x:DataType="vm:WorktreeOverviewRowViewModel">
<Border Classes="wt-row">
<Border.ContextMenu>
<ContextMenu>
<MenuItem Header="Show diff"
Command="{Binding $parent[Window].((vm:WorktreesOverviewModalViewModel)DataContext).ShowDiffCommand}"
CommandParameter="{Binding}"/>
<MenuItem Header="Open in Explorer"
IsEnabled="{Binding PathExistsOnDisk}"
Command="{Binding $parent[Window].((vm:WorktreesOverviewModalViewModel)DataContext).OpenInExplorerCommand}"
CommandParameter="{Binding}"/>
<MenuItem Header="Jump to task"
Command="{Binding $parent[Window].((vm:WorktreesOverviewModalViewModel)DataContext).JumpToTaskCommand}"
CommandParameter="{Binding}"/>
<Separator/>
<MenuItem Header="Discard"
IsEnabled="{Binding IsActive}"
Command="{Binding $parent[Window].((vm:WorktreesOverviewModalViewModel)DataContext).DiscardCommand}"
CommandParameter="{Binding}"/>
<MenuItem Header="Keep"
IsEnabled="{Binding IsActive}"
Command="{Binding $parent[Window].((vm:WorktreesOverviewModalViewModel)DataContext).KeepCommand}"
CommandParameter="{Binding}"/>
<Separator/>
<MenuItem Header="Copy branch"
Command="{Binding $parent[Window].((vm:WorktreesOverviewModalViewModel)DataContext).CopyBranchCommand}"
CommandParameter="{Binding}"/>
<MenuItem Header="Copy path"
Command="{Binding $parent[Window].((vm:WorktreesOverviewModalViewModel)DataContext).CopyPathCommand}"
CommandParameter="{Binding}"/>
<Separator/>
<MenuItem Header="Force remove"
Foreground="#EF5350"
Command="{Binding $parent[Window].((vm:WorktreesOverviewModalViewModel)DataContext).ForceRemoveCommand}"
CommandParameter="{Binding}"/>
</ContextMenu>
</Border.ContextMenu>
<Grid ColumnDefinitions="*,200,90,80,80">
<StackPanel Grid.Column="0" Orientation="Vertical" Spacing="2">
<TextBlock Text="{Binding TaskTitle}" FontWeight="SemiBold"/>
<StackPanel Orientation="Horizontal" Spacing="4">
<TextBlock Text="{Binding TaskStatus}" FontSize="10"
Foreground="{DynamicResource TextFaintBrush}"/>
<TextBlock Text="•" FontSize="10" Foreground="{DynamicResource TextFaintBrush}"
IsVisible="{Binding !PathExistsOnDisk}"/>
<TextBlock Text="phantom" FontSize="10" Foreground="#EF5350"
IsVisible="{Binding !PathExistsOnDisk}"
ToolTip.Tip="Directory missing on disk"/>
</StackPanel>
</StackPanel>
<TextBlock Grid.Column="1" Text="{Binding BranchName}"
FontFamily="{DynamicResource MonoFont}" FontSize="11"
VerticalAlignment="Center" TextTrimming="CharacterEllipsis"/>
<Border Grid.Column="2" CornerRadius="3" Padding="6,2" VerticalAlignment="Center"
Background="{Binding State, Converter={StaticResource WorktreeStateColor}}">
<TextBlock Text="{Binding State}" FontSize="10" Foreground="White"
HorizontalAlignment="Center"/>
</Border>
<TextBlock Grid.Column="3" Text="{Binding DiffStat}" VerticalAlignment="Center"
FontFamily="{DynamicResource MonoFont}" FontSize="11"
Foreground="{DynamicResource TextDimBrush}"/>
<TextBlock Grid.Column="4" Text="{Binding AgeText}" VerticalAlignment="Center"
FontSize="11" Foreground="{DynamicResource TextDimBrush}"/>
</Grid>
</Border>
</DataTemplate>
</Window.Resources>
</Window>
```
> Note: keep only ONE `<Window.Resources>` block. The block above declares the converter and `WorktreeRowTemplate` together. Adjust if Avalonia complains about duplicate `Resources` — merge into a single `<Window.Resources>` near the top.
- [ ] **Step 2: Create the code-behind**
Create `src/ClaudeDo.Ui/Views/Modals/WorktreesOverviewModalView.axaml.cs`:
```csharp
using Avalonia.Controls;
namespace ClaudeDo.Ui.Views.Modals;
public partial class WorktreesOverviewModalView : Window
{
public WorktreesOverviewModalView() => InitializeComponent();
}
```
- [ ] **Step 3: Build to confirm XAML compiles**
Run: `dotnet build src/ClaudeDo.Ui/ClaudeDo.Ui.csproj`
Expected: build succeeds. If duplicate `Window.Resources` is reported, merge the two blocks into one at the top of the file.
- [ ] **Step 4: Commit**
```bash
git add src/ClaudeDo.Ui/Views/Modals/WorktreesOverviewModalView.axaml src/ClaudeDo.Ui/Views/Modals/WorktreesOverviewModalView.axaml.cs
git commit -m "feat(ui): add WorktreesOverviewModalView"
```
---
## Task 9: Wire entry points (DI, ListsIslandViewModel, IslandsShellViewModel, views)
**Files:**
- Modify: `src/ClaudeDo.App/Program.cs`
- Modify: `src/ClaudeDo.Ui/ViewModels/Islands/ListsIslandViewModel.cs`
- Modify: `src/ClaudeDo.Ui/Views/Islands/ListsIslandView.axaml`
- Modify: `src/ClaudeDo.Ui/Views/Islands/ListsIslandView.axaml.cs`
- Modify: `src/ClaudeDo.Ui/ViewModels/IslandsShellViewModel.cs`
- Modify: `src/ClaudeDo.Ui/Views/MainWindow.axaml`
- Modify: `src/ClaudeDo.Ui/Views/MainWindow.axaml.cs`
- [ ] **Step 1: Register the new VM in DI**
In `src/ClaudeDo.App/Program.cs`, find line 97 (`sc.AddTransient<WorktreeModalViewModel>();`) and add right after:
```csharp
sc.AddTransient<WorktreesOverviewModalViewModel>();
```
- [ ] **Step 2: Add the list-scoped open command to `ListsIslandViewModel`**
In `src/ClaudeDo.Ui/ViewModels/Islands/ListsIslandViewModel.cs`, add a new modal hook next to `ShowListSettingsModal` (around line 30):
```csharp
public Func<WorktreesOverviewModalViewModel, Task>? ShowWorktreesOverviewModal { get; set; }
```
Add a new relay command after `OpenListSettingsAsync` (around line 50):
```csharp
[RelayCommand]
private async Task OpenWorktreesOverviewAsync(ListNavItemViewModel? row)
{
if (row is null || ShowWorktreesOverviewModal is null || _services is null) return;
if (row.Kind != ListKind.User) return;
var rawId = row.Id.StartsWith("user:", StringComparison.Ordinal) ? row.Id["user:".Length..] : row.Id;
var vm = _services.GetRequiredService<WorktreesOverviewModalViewModel>();
vm.Configure(rawId, row.Name);
await vm.LoadAsync();
await ShowWorktreesOverviewModal(vm);
}
```
- [ ] **Step 3: Add the context-menu entry in the list row template**
In `src/ClaudeDo.Ui/Views/Islands/ListsIslandView.axaml`, modify the existing `ContextMenu` block (around line 131135) to add a second item:
```xml
<ContextMenu>
<MenuItem Header="Settings..."
Command="{Binding $parent[UserControl].((vm:ListsIslandViewModel)DataContext).OpenListSettingsCommand}"
CommandParameter="{Binding}"/>
<MenuItem Header="Worktrees anzeigen…"
Command="{Binding $parent[UserControl].((vm:ListsIslandViewModel)DataContext).OpenWorktreesOverviewCommand}"
CommandParameter="{Binding}"/>
</ContextMenu>
```
- [ ] **Step 4: Wire `ShowWorktreesOverviewModal` in `ListsIslandView.axaml.cs`**
Inside the `DataContextChanged` handler (after `ShowListSettingsModal = …` around line 27 of the file), add:
```csharp
vm.ShowWorktreesOverviewModal = async modal =>
{
var window = new WorktreesOverviewModalView { DataContext = modal };
modal.CloseAction = () => window.Close();
modal.ShowDiffAction = row =>
{
var diffVm = vm._services?.GetRequiredService<WorktreeModalViewModel>();
// Fallback: ignore if DI not reachable from this codepath.
if (diffVm is null) return;
diffVm.WorktreePath = row.Path;
var diffWindow = new WorktreeModalView { DataContext = diffVm };
diffVm.CloseAction = () => diffWindow.Close();
_ = diffVm.LoadAsync();
_ = diffWindow.ShowDialog(window);
};
var top = TopLevel.GetTopLevel(this) as Window;
if (top is null) window.Show();
else await window.ShowDialog(top);
};
```
> If `_services` is private on `ListsIslandViewModel` and inaccessible from the view, expose it as `internal IServiceProvider? Services => _services;` in that VM file, OR construct the diff VM via a different DI accessor available on the view. Verify the access pattern matches existing code; mirror whatever the `ListSettingsModal` wiring does for service-locator-style DI lookups, or expose an internal helper. Note: this internal exposure stays internal-only (no public surface).
- [ ] **Step 5: Add the global Help-menu command in `IslandsShellViewModel`**
In `src/ClaudeDo.Ui/ViewModels/IslandsShellViewModel.cs`, near the existing `ShowAboutModal` declaration, add:
```csharp
public Func<WorktreesOverviewModalViewModel, Task>? ShowWorktreesOverviewModal { get; set; }
```
Add this command (e.g., right after `OpenAbout` around line 250):
```csharp
[RelayCommand]
private async Task OpenWorktreesOverviewGlobalAsync()
{
if (ShowWorktreesOverviewModal is null || _services is null) return;
var vm = _services.GetRequiredService<WorktreesOverviewModalViewModel>();
vm.Configure(null, null);
await vm.LoadAsync();
await ShowWorktreesOverviewModal(vm);
}
```
> If `IslandsShellViewModel` does not have `_services`, follow the same DI accessor pattern it already uses for `OpenAbout` (e.g., a `Func<AboutModalViewModel>` factory injected via constructor). Find how `AboutModalViewModel` is obtained — the new command must use the same pattern.
- [ ] **Step 6: Add the Help-menu entry in `MainWindow.axaml`**
In `src/ClaudeDo.Ui/Views/MainWindow.axaml`, inside the existing Help `MenuItem` (line 6573), add an item before "About…":
```xml
<MenuItem Header="Worktrees…"
Command="{Binding OpenWorktreesOverviewGlobalCommand}"/>
```
- [ ] **Step 7: Wire `ShowWorktreesOverviewModal` for global mode in `MainWindow.axaml.cs`**
In `src/ClaudeDo.Ui/Views/MainWindow.axaml.cs`, find where the existing modal hooks on `IslandsShellViewModel` are wired (look for `ShowAboutModal = ...`). Add right after:
```csharp
shell.ShowWorktreesOverviewModal = async modal =>
{
var window = new WorktreesOverviewModalView { DataContext = modal };
modal.CloseAction = () => window.Close();
modal.ShowDiffAction = row =>
{
// Reuse the existing diff modal.
var diffVm = App.Services?.GetService(typeof(WorktreeModalViewModel)) as WorktreeModalViewModel;
if (diffVm is null) return;
diffVm.WorktreePath = row.Path;
var diffWindow = new WorktreeModalView { DataContext = diffVm };
diffVm.CloseAction = () => diffWindow.Close();
_ = diffVm.LoadAsync();
_ = diffWindow.ShowDialog(window);
};
modal.JumpToTaskAction = (listId, taskId) =>
{
// Best-effort: select the list, leave task selection to whatever existing event handlers do.
if (DataContext is IslandsShellViewModel s)
{
var item = s.Lists.UserLists.FirstOrDefault(l => l.Id == $"user:{listId}");
if (item is not null) s.Lists.SelectedList = item;
}
};
await window.ShowDialog(this);
};
```
> If `App.Services` is not the actual DI accessor, use whatever the codebase uses (search for an existing place that retrieves a VM after construction — e.g., a static `App.Current.Services` or a property on the window). The diff VM must be a transient. Keep the pattern simple — if no clean accessor exists in `MainWindow.axaml.cs`, omit `ShowDiffAction` here and only wire it from `ListsIslandView` (where `_services` is reachable on the VM). The user can still close-and-reopen the modal from the list context menu for the diff feature.
- [ ] **Step 8: Build the whole solution**
Run: `dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj`
Expected: build succeeds. If any DI or ViewModel access errors appear, fix the local accessor pattern to match the existing one in `IslandsShellViewModel` (look at `OpenAbout` for the exact DI shape).
- [ ] **Step 9: Commit**
```bash
git add src/ClaudeDo.App/Program.cs src/ClaudeDo.Ui/ViewModels src/ClaudeDo.Ui/Views/Islands src/ClaudeDo.Ui/Views/MainWindow.axaml src/ClaudeDo.Ui/Views/MainWindow.axaml.cs
git commit -m "feat(ui): wire worktree overview modal entry points"
```
---
## Task 10: Final verification
- [ ] **Step 1: Run full test suite**
Run: `dotnet test tests/ClaudeDo.Worker.Tests`
Expected: all tests pass.
- [ ] **Step 2: Build whole app**
Run: `dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj`
Expected: build succeeds, no warnings about missing XAML resources or unbound commands.
- [ ] **Step 3: Manual smoke test (record findings — no commit needed)**
Start the Worker (`dotnet run --project src/ClaudeDo.Worker`) and the App (`dotnet run --project src/ClaudeDo.App`). Then exercise:
1. Create a user list with a working directory pointing at a git repo. Create a task and let it produce a worktree.
2. Right-click the user list → "Worktrees anzeigen…" → modal opens, lists this list's worktrees only, no group headers.
3. Help menu → "Worktrees…" → modal opens, lists grouped by list with expanders.
4. Right-click a row:
- "Show diff" → opens the existing file-tree diff modal.
- "Open in Explorer" → opens the directory (greyed out for phantom rows).
- "Jump to task" → closes modal, selects the list (best-effort).
- "Discard" / "Keep" → state badge changes color.
- "Copy branch" / "Copy path" → clipboard contains expected text.
- "Force remove" → confirmation dialog (if `ConfirmAction` is wired), then row vanishes, branch + directory removed.
5. With a task in `Running` state, "Force remove" sets `StatusMessage = "Cannot force-remove a running task."`.
6. "Cleanup finished" in filtered mode → only that list's Merged/Discarded rows vanish.
7. "Refresh" reloads from worker.
Report any failures in the conversation; do not auto-fix without confirmation.
---
## Self-Review Notes
- **Spec coverage**: All UI sections, SignalR contract, backend changes, force-remove semantics, and testing requirements from the spec map to specific tasks (19 for code; 1, 2, 3 for tests).
- **Type consistency**: `WorktreeOverviewDto` shape matches between hub (Task 4) and client (Task 5); `ForceRemoveResult` (service) vs. `ForceRemoveResultDto` (hub/client) is intentional — service uses internal type, hub exposes serializable record.
- **Open dependency**: `ConfirmAction` on the modal VM is optional; if the view does not wire it, force-remove proceeds without confirmation. That is acceptable for v1; a follow-up can add a styled confirm dialog. Documented in spec as design tradeoff (not gating).
- **Diff-modal accessor**: Task 9 step 4 and 7 contain a fallback note for DI access — the implementing agent should mirror whatever pattern `IslandsShellViewModel` already uses for `OpenAbout`, not invent a new one. If the existing shell uses a constructor-injected factory, do the same.