54 KiB
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 servicesrc/ClaudeDo.Ui/Converters/WorktreeStateColorConverter.cs— badge colorsrc/ClaudeDo.Ui/ViewModels/Modals/WorktreesOverviewModalViewModel.cssrc/ClaudeDo.Ui/Views/Modals/WorktreesOverviewModalView.axamlsrc/ClaudeDo.Ui/Views/Modals/WorktreesOverviewModalView.axaml.cs
Modified:
src/ClaudeDo.Worker/Worktrees/WorktreeMaintenanceService.cssrc/ClaudeDo.Worker/Hub/WorkerHub.cssrc/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(wireShowWorktreesOverviewModal)src/ClaudeDo.Ui/Views/MainWindow.axaml(Help menu item)src/ClaudeDo.Ui/Views/MainWindow.axaml.cs(wireShowWorktreesOverviewModalfor 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:
[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
CleanupFinishedAsyncsignature and add list filter
Replace the existing CleanupFinishedAsync (currently at WorktreeMaintenanceService.cs:27-45) with:
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
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:
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:
[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
GetOverviewAsynctoWorktreeMaintenanceService
Add this method to WorktreeMaintenanceService (e.g. directly after the existing CleanupFinishedAsync):
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
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:
[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
ForceRemoveResultrecord +ForceRemoveAsyncmethod
Add to WorktreeMaintenanceService.cs, alongside the existing nested records (line 10–11 area):
public sealed record ForceRemoveResult(bool Removed, string? Reason);
Add this method to WorktreeMaintenanceService (after ResetAllAsync):
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
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:
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
CleanupFinishedWorktreeswith the list-scoped version
Replace the current method (around line 223–227):
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):
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:
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
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:
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
CleanupFinishedWorktreesAsyncto accept optionallistId
Replace lines around 398–408 in WorkerClient.cs:
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):
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
IWorkerClientinterface
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
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:
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
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:
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
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:
<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 andWorktreeRowTemplatetogether. Adjust if Avalonia complains about duplicateResources— merge into a single<Window.Resources>near the top.
- Step 2: Create the code-behind
Create src/ClaudeDo.Ui/Views/Modals/WorktreesOverviewModalView.axaml.cs:
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
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:
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):
public Func<WorktreesOverviewModalViewModel, Task>? ShowWorktreesOverviewModal { get; set; }
Add a new relay command after OpenListSettingsAsync (around line 50):
[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 131–135) to add a second item:
<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
ShowWorktreesOverviewModalinListsIslandView.axaml.cs
Inside the DataContextChanged handler (after ShowListSettingsModal = … around line 27 of the file), add:
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
_servicesis private onListsIslandViewModeland inaccessible from the view, expose it asinternal 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 theListSettingsModalwiring 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:
public Func<WorktreesOverviewModalViewModel, Task>? ShowWorktreesOverviewModal { get; set; }
Add this command (e.g., right after OpenAbout around line 250):
[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
IslandsShellViewModeldoes not have_services, follow the same DI accessor pattern it already uses forOpenAbout(e.g., aFunc<AboutModalViewModel>factory injected via constructor). Find howAboutModalViewModelis 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 65–73), add an item before "About…":
<MenuItem Header="Worktrees…"
Command="{Binding OpenWorktreesOverviewGlobalCommand}"/>
- Step 7: Wire
ShowWorktreesOverviewModalfor global mode inMainWindow.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:
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.Servicesis not the actual DI accessor, use whatever the codebase uses (search for an existing place that retrieves a VM after construction — e.g., a staticApp.Current.Servicesor a property on the window). The diff VM must be a transient. Keep the pattern simple — if no clean accessor exists inMainWindow.axaml.cs, omitShowDiffActionhere and only wire it fromListsIslandView(where_servicesis 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
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:
- Create a user list with a working directory pointing at a git repo. Create a task and let it produce a worktree.
- Right-click the user list → "Worktrees anzeigen…" → modal opens, lists this list's worktrees only, no group headers.
- Help menu → "Worktrees…" → modal opens, lists grouped by list with expanders.
- 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
ConfirmActionis wired), then row vanishes, branch + directory removed.
- With a task in
Runningstate, "Force remove" setsStatusMessage = "Cannot force-remove a running task.". - "Cleanup finished" in filtered mode → only that list's Merged/Discarded rows vanish.
- "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 (1–9 for code; 1, 2, 3 for tests).
- Type consistency:
WorktreeOverviewDtoshape 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:
ConfirmActionon 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
IslandsShellViewModelalready uses forOpenAbout, not invent a new one. If the existing shell uses a constructor-injected factory, do the same.