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

54 KiB
Raw Blame History

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:

[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:

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 GetOverviewAsync to WorktreeMaintenanceService

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 ForceRemoveResult record + ForceRemoveAsync method

Add to WorktreeMaintenanceService.cs, alongside the existing nested records (line 1011 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 CleanupFinishedWorktrees with the list-scoped version

Replace the current method (around line 223227):

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 CleanupFinishedWorktreesAsync to accept optional listId

Replace lines around 398408 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 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
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 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:

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 131135) 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 ShowWorktreesOverviewModal in ListsIslandView.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 _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:

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 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…":

<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:

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
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.