# Worktree Overview Modal Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Build a modal that lists every worktree with quick info and context-menu actions, openable per-list (from List context menu) or globally (from Help menu).

**Architecture:** Backend additions go on existing `WorktreeMaintenanceService` (overview query, list-scoped cleanup, single-row force remove). New SignalR DTOs + hub methods route data to a new `WorktreesOverviewModalViewModel` + Avalonia view. The existing single-worktree file-tree modal (`WorktreeModalView`) is reused for the "Show diff" action. No new services, no new tables.

**Tech Stack:** .NET 8, Avalonia 12, EF Core (SQLite), SignalR, xUnit, CommunityToolkit.Mvvm.

**Reference spec:** `docs/superpowers/specs/2026-05-19-worktree-overview-modal-design.md`

---

## File Structure

**New:**
- `src/ClaudeDo.Worker/Worktrees/WorktreeOverviewRow.cs` — record returned from service
- `src/ClaudeDo.Ui/Converters/WorktreeStateColorConverter.cs` — badge color
- `src/ClaudeDo.Ui/ViewModels/Modals/WorktreesOverviewModalViewModel.cs`
- `src/ClaudeDo.Ui/Views/Modals/WorktreesOverviewModalView.axaml`
- `src/ClaudeDo.Ui/Views/Modals/WorktreesOverviewModalView.axaml.cs`

**Modified:**
- `src/ClaudeDo.Worker/Worktrees/WorktreeMaintenanceService.cs`
- `src/ClaudeDo.Worker/Hub/WorkerHub.cs`
- `src/ClaudeDo.Ui/Services/WorkerClient.cs` (+ DTOs at file bottom)
- `src/ClaudeDo.Ui/Services/IWorkerClient.cs` (if interface defines the changed methods — check first)
- `src/ClaudeDo.Ui/ViewModels/Islands/ListsIslandViewModel.cs` (list context menu command)
- `src/ClaudeDo.Ui/ViewModels/IslandsShellViewModel.cs` (Help-menu global command)
- `src/ClaudeDo.Ui/Views/Islands/ListsIslandView.axaml` (context-menu entry)
- `src/ClaudeDo.Ui/Views/Islands/ListsIslandView.axaml.cs` (wire `ShowWorktreesOverviewModal`)
- `src/ClaudeDo.Ui/Views/MainWindow.axaml` (Help menu item)
- `src/ClaudeDo.Ui/Views/MainWindow.axaml.cs` (wire `ShowWorktreesOverviewModal` for global)
- `src/ClaudeDo.App/Program.cs` (DI registration)
- `tests/ClaudeDo.Worker.Tests/Services/WorktreeMaintenanceServiceTests.cs` (new tests)

---

## Task 1: Extend `CleanupFinishedAsync` to accept optional `listId`

**Files:**
- Modify: `src/ClaudeDo.Worker/Worktrees/WorktreeMaintenanceService.cs:27-45`
- Test: `tests/ClaudeDo.Worker.Tests/Services/WorktreeMaintenanceServiceTests.cs`

- [ ] **Step 1: Write the failing test**

Append to `WorktreeMaintenanceServiceTests`:

```csharp
[Fact]
public async Task CleanupFinished_With_ListId_Only_Removes_That_Lists_Rows()
{
    if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }

    var repo = NewRepo();
    var git = new GitService();
    var db = NewDb();

    var (listA, taskA) = MakeEntities(repo.RepoDir);
    var (listB, taskB) = MakeEntities(repo.RepoDir);

    var wtA = await CreateWorktreeAsync(git, repo.RepoDir, taskA.Id);
    var wtB = await CreateWorktreeAsync(git, repo.RepoDir, taskB.Id);

    using (var ctx = db.CreateContext())
    {
        await new ListRepository(ctx).AddAsync(listA);
        await new ListRepository(ctx).AddAsync(listB);
        var taskRepo = new TaskRepository(ctx);
        await taskRepo.AddAsync(taskA);
        await taskRepo.AddAsync(taskB);
        var wtRepo = new WorktreeRepository(ctx);
        await wtRepo.AddAsync(new WorktreeEntity
        {
            TaskId = taskA.Id, Path = wtA, BranchName = $"test/{taskA.Id}",
            BaseCommit = repo.BaseCommit, State = WorktreeState.Merged, CreatedAt = DateTime.UtcNow,
        });
        await wtRepo.AddAsync(new WorktreeEntity
        {
            TaskId = taskB.Id, Path = wtB, BranchName = $"test/{taskB.Id}",
            BaseCommit = repo.BaseCommit, State = WorktreeState.Merged, CreatedAt = DateTime.UtcNow,
        });
    }

    var svc = new WorktreeMaintenanceService(
        db.CreateFactory(), git, NullLogger<WorktreeMaintenanceService>.Instance);

    var result = await svc.CleanupFinishedAsync(listA.Id, CancellationToken.None);

    Assert.Equal(1, result.Removed);
    Assert.False(Directory.Exists(wtA));
    Assert.True(Directory.Exists(wtB));

    using var checkCtx = db.CreateContext();
    var remaining = await new WorktreeRepository(checkCtx).GetAllAsync();
    Assert.Single(remaining);
    Assert.Equal(taskB.Id, remaining[0].TaskId);
}
```

- [ ] **Step 2: Run test to verify it fails**

Run: `dotnet test tests/ClaudeDo.Worker.Tests --filter "FullyQualifiedName~CleanupFinished_With_ListId_Only_Removes_That_Lists_Rows"`
Expected: FAIL — method signature does not accept a `listId`.

- [ ] **Step 3: Change `CleanupFinishedAsync` signature and add list filter**

Replace the existing `CleanupFinishedAsync` (currently at `WorktreeMaintenanceService.cs:27-45`) with:

```csharp
public async Task<CleanupResult> CleanupFinishedAsync(string? listId = null, CancellationToken ct = default)
{
    using var context = _dbFactory.CreateDbContext();
    var query = from w in context.Worktrees
                join t in context.Tasks on w.TaskId equals t.Id
                join l in context.Lists on t.ListId equals l.Id
                where w.State == WorktreeState.Merged || w.State == WorktreeState.Discarded
                select new { Row = new WorktreeRow(w.TaskId, w.Path, w.BranchName, l.WorkingDir), ListId = t.ListId };

    if (!string.IsNullOrEmpty(listId))
        query = query.Where(x => x.ListId == listId);

    var rows = await query.AsNoTracking().Select(x => x.Row).ToListAsync(ct);

    int removed = 0;
    foreach (var row in rows)
    {
        if (await TryRemoveAsync(row, force: false, ct))
            removed++;
    }
    return new CleanupResult(removed);
}
```

- [ ] **Step 4: Run all tests in the file to verify pass and no regression**

Run: `dotnet test tests/ClaudeDo.Worker.Tests --filter "FullyQualifiedName~WorktreeMaintenanceServiceTests"`
Expected: PASS (all four tests, existing three plus new one).

- [ ] **Step 5: Commit**

```bash
git add src/ClaudeDo.Worker/Worktrees/WorktreeMaintenanceService.cs tests/ClaudeDo.Worker.Tests/Services/WorktreeMaintenanceServiceTests.cs
git commit -m "feat(worktrees): allow CleanupFinishedAsync to filter by list"
```

---

## Task 2: Add `GetOverviewAsync` to `WorktreeMaintenanceService`

**Files:**
- Create: `src/ClaudeDo.Worker/Worktrees/WorktreeOverviewRow.cs`
- Modify: `src/ClaudeDo.Worker/Worktrees/WorktreeMaintenanceService.cs`
- Test: `tests/ClaudeDo.Worker.Tests/Services/WorktreeMaintenanceServiceTests.cs`

- [ ] **Step 1: Create the row record**

Create `src/ClaudeDo.Worker/Worktrees/WorktreeOverviewRow.cs`:

```csharp
using ClaudeDo.Data.Models;
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;

namespace ClaudeDo.Worker.Worktrees;

public sealed record WorktreeOverviewRow(
    string TaskId,
    string TaskTitle,
    TaskStatus TaskStatus,
    string ListId,
    string ListName,
    string Path,
    string BranchName,
    WorktreeState State,
    string? DiffStat,
    DateTime CreatedAt,
    bool PathExistsOnDisk);
```

- [ ] **Step 2: Write the failing tests**

Append to `WorktreeMaintenanceServiceTests`:

```csharp
[Fact]
public async Task GetOverview_Returns_All_When_ListId_Null()
{
    if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }

    var repo = NewRepo();
    var git = new GitService();
    var db = NewDb();

    var (listA, taskA) = MakeEntities(repo.RepoDir);
    var (listB, taskB) = MakeEntities(repo.RepoDir);
    var wtA = await CreateWorktreeAsync(git, repo.RepoDir, taskA.Id);
    var wtB = await CreateWorktreeAsync(git, repo.RepoDir, taskB.Id);

    using (var ctx = db.CreateContext())
    {
        await new ListRepository(ctx).AddAsync(listA);
        await new ListRepository(ctx).AddAsync(listB);
        await new TaskRepository(ctx).AddAsync(taskA);
        await new TaskRepository(ctx).AddAsync(taskB);
        var wtRepo = new WorktreeRepository(ctx);
        await wtRepo.AddAsync(new WorktreeEntity
        {
            TaskId = taskA.Id, Path = wtA, BranchName = $"test/{taskA.Id}",
            BaseCommit = repo.BaseCommit, State = WorktreeState.Active, CreatedAt = DateTime.UtcNow,
        });
        await wtRepo.AddAsync(new WorktreeEntity
        {
            TaskId = taskB.Id, Path = wtB, BranchName = $"test/{taskB.Id}",
            BaseCommit = repo.BaseCommit, State = WorktreeState.Merged, CreatedAt = DateTime.UtcNow,
        });
    }

    var svc = new WorktreeMaintenanceService(
        db.CreateFactory(), git, NullLogger<WorktreeMaintenanceService>.Instance);

    var rows = await svc.GetOverviewAsync(null, CancellationToken.None);

    Assert.Equal(2, rows.Count);
    Assert.Contains(rows, r => r.TaskId == taskA.Id && r.PathExistsOnDisk);
    Assert.Contains(rows, r => r.TaskId == taskB.Id && r.PathExistsOnDisk);
}

[Fact]
public async Task GetOverview_Filters_By_ListId()
{
    if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }

    var repo = NewRepo();
    var git = new GitService();
    var db = NewDb();

    var (listA, taskA) = MakeEntities(repo.RepoDir);
    var (listB, taskB) = MakeEntities(repo.RepoDir);
    var wtA = await CreateWorktreeAsync(git, repo.RepoDir, taskA.Id);
    var wtB = await CreateWorktreeAsync(git, repo.RepoDir, taskB.Id);

    using (var ctx = db.CreateContext())
    {
        await new ListRepository(ctx).AddAsync(listA);
        await new ListRepository(ctx).AddAsync(listB);
        await new TaskRepository(ctx).AddAsync(taskA);
        await new TaskRepository(ctx).AddAsync(taskB);
        var wtRepo = new WorktreeRepository(ctx);
        await wtRepo.AddAsync(new WorktreeEntity
        {
            TaskId = taskA.Id, Path = wtA, BranchName = $"test/{taskA.Id}",
            BaseCommit = repo.BaseCommit, State = WorktreeState.Active, CreatedAt = DateTime.UtcNow,
        });
        await wtRepo.AddAsync(new WorktreeEntity
        {
            TaskId = taskB.Id, Path = wtB, BranchName = $"test/{taskB.Id}",
            BaseCommit = repo.BaseCommit, State = WorktreeState.Active, CreatedAt = DateTime.UtcNow,
        });
    }

    var svc = new WorktreeMaintenanceService(
        db.CreateFactory(), git, NullLogger<WorktreeMaintenanceService>.Instance);

    var rows = await svc.GetOverviewAsync(listA.Id, CancellationToken.None);

    Assert.Single(rows);
    Assert.Equal(taskA.Id, rows[0].TaskId);
}

[Fact]
public async Task GetOverview_Flags_PathExistsOnDisk_False_For_Phantom_Row()
{
    if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }

    var repo = NewRepo();
    var git = new GitService();
    var db = NewDb();

    var (list, task) = MakeEntities(repo.RepoDir);
    var wt = await CreateWorktreeAsync(git, repo.RepoDir, task.Id);

    // Delete the worktree directory but keep the DB row → phantom.
    try { await git.WorktreeRemoveAsync(repo.RepoDir, wt, force: true); } catch { }
    if (Directory.Exists(wt)) Directory.Delete(wt, recursive: true);

    using (var ctx = db.CreateContext())
    {
        await new ListRepository(ctx).AddAsync(list);
        await new TaskRepository(ctx).AddAsync(task);
        await new WorktreeRepository(ctx).AddAsync(new WorktreeEntity
        {
            TaskId = task.Id, Path = wt, BranchName = $"test/{task.Id}",
            BaseCommit = repo.BaseCommit, State = WorktreeState.Active, CreatedAt = DateTime.UtcNow,
        });
    }

    var svc = new WorktreeMaintenanceService(
        db.CreateFactory(), git, NullLogger<WorktreeMaintenanceService>.Instance);

    var rows = await svc.GetOverviewAsync(null, CancellationToken.None);

    Assert.Single(rows);
    Assert.False(rows[0].PathExistsOnDisk);
}
```

- [ ] **Step 3: Run tests to verify they fail**

Run: `dotnet test tests/ClaudeDo.Worker.Tests --filter "FullyQualifiedName~GetOverview"`
Expected: FAIL — `GetOverviewAsync` does not exist.

- [ ] **Step 4: Add `GetOverviewAsync` to `WorktreeMaintenanceService`**

Add this method to `WorktreeMaintenanceService` (e.g. directly after the existing `CleanupFinishedAsync`):

```csharp
public async Task<IReadOnlyList<WorktreeOverviewRow>> GetOverviewAsync(
    string? listId, CancellationToken ct = default)
{
    using var context = _dbFactory.CreateDbContext();
    var query = from w in context.Worktrees
                join t in context.Tasks on w.TaskId equals t.Id
                join l in context.Lists on t.ListId equals l.Id
                select new
                {
                    w.TaskId, t.Title, t.Status, ListId = l.Id, ListName = l.Name,
                    w.Path, w.BranchName, w.State, w.DiffStat, w.CreatedAt,
                };

    if (!string.IsNullOrEmpty(listId))
        query = query.Where(x => x.ListId == listId);

    var rows = await query.AsNoTracking().ToListAsync(ct);

    return rows.Select(x => new WorktreeOverviewRow(
        x.TaskId, x.Title, x.Status, x.ListId, x.ListName,
        x.Path, x.BranchName, x.State, x.DiffStat, x.CreatedAt,
        PathExistsOnDisk: !string.IsNullOrWhiteSpace(x.Path) && Directory.Exists(x.Path))).ToList();
}
```

- [ ] **Step 5: Run tests to verify they pass**

Run: `dotnet test tests/ClaudeDo.Worker.Tests --filter "FullyQualifiedName~WorktreeMaintenanceServiceTests"`
Expected: PASS.

- [ ] **Step 6: Commit**

```bash
git add src/ClaudeDo.Worker/Worktrees/WorktreeOverviewRow.cs src/ClaudeDo.Worker/Worktrees/WorktreeMaintenanceService.cs tests/ClaudeDo.Worker.Tests/Services/WorktreeMaintenanceServiceTests.cs
git commit -m "feat(worktrees): add GetOverviewAsync for overview modal"
```

---

## Task 3: Add `ForceRemoveAsync` to `WorktreeMaintenanceService`

**Files:**
- Modify: `src/ClaudeDo.Worker/Worktrees/WorktreeMaintenanceService.cs`
- Test: `tests/ClaudeDo.Worker.Tests/Services/WorktreeMaintenanceServiceTests.cs`

- [ ] **Step 1: Write the failing tests**

Append to `WorktreeMaintenanceServiceTests`:

```csharp
[Fact]
public async Task ForceRemove_Removes_Active_Worktree()
{
    if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }

    var repo = NewRepo();
    var git = new GitService();
    var db = NewDb();

    var (list, task) = MakeEntities(repo.RepoDir, status: ClaudeDo.Data.Models.TaskStatus.Done);
    var wt = await CreateWorktreeAsync(git, repo.RepoDir, task.Id);

    using (var ctx = db.CreateContext())
    {
        await new ListRepository(ctx).AddAsync(list);
        await new TaskRepository(ctx).AddAsync(task);
        await new WorktreeRepository(ctx).AddAsync(new WorktreeEntity
        {
            TaskId = task.Id, Path = wt, BranchName = $"test/{task.Id}",
            BaseCommit = repo.BaseCommit, State = WorktreeState.Active, CreatedAt = DateTime.UtcNow,
        });
    }

    var svc = new WorktreeMaintenanceService(
        db.CreateFactory(), git, NullLogger<WorktreeMaintenanceService>.Instance);

    var result = await svc.ForceRemoveAsync(task.Id, CancellationToken.None);

    Assert.True(result.Removed);
    Assert.Null(result.Reason);
    Assert.False(Directory.Exists(wt));

    using var checkCtx = db.CreateContext();
    var remaining = await new WorktreeRepository(checkCtx).GetAllAsync();
    Assert.Empty(remaining);
}

[Fact]
public async Task ForceRemove_Blocked_When_Task_Running()
{
    if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }

    var repo = NewRepo();
    var git = new GitService();
    var db = NewDb();

    var (list, task) = MakeEntities(repo.RepoDir, status: ClaudeDo.Data.Models.TaskStatus.Running);
    var wt = await CreateWorktreeAsync(git, repo.RepoDir, task.Id);

    using (var ctx = db.CreateContext())
    {
        await new ListRepository(ctx).AddAsync(list);
        await new TaskRepository(ctx).AddAsync(task);
        await new WorktreeRepository(ctx).AddAsync(new WorktreeEntity
        {
            TaskId = task.Id, Path = wt, BranchName = $"test/{task.Id}",
            BaseCommit = repo.BaseCommit, State = WorktreeState.Active, CreatedAt = DateTime.UtcNow,
        });
    }

    var svc = new WorktreeMaintenanceService(
        db.CreateFactory(), git, NullLogger<WorktreeMaintenanceService>.Instance);

    var result = await svc.ForceRemoveAsync(task.Id, CancellationToken.None);

    Assert.False(result.Removed);
    Assert.Equal("task is currently running", result.Reason);
    Assert.True(Directory.Exists(wt));

    try { await git.WorktreeRemoveAsync(repo.RepoDir, wt, force: true); } catch { }
}

[Fact]
public async Task ForceRemove_Removes_Phantom_Row()
{
    if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }

    var repo = NewRepo();
    var git = new GitService();
    var db = NewDb();

    var (list, task) = MakeEntities(repo.RepoDir);
    var phantomPath = Path.Combine(Path.GetTempPath(), $"wt_phantom_{Guid.NewGuid():N}");
    // Note: never created on disk.

    using (var ctx = db.CreateContext())
    {
        await new ListRepository(ctx).AddAsync(list);
        await new TaskRepository(ctx).AddAsync(task);
        await new WorktreeRepository(ctx).AddAsync(new WorktreeEntity
        {
            TaskId = task.Id, Path = phantomPath, BranchName = $"test/{task.Id}-phantom",
            BaseCommit = repo.BaseCommit, State = WorktreeState.Active, CreatedAt = DateTime.UtcNow,
        });
    }

    var svc = new WorktreeMaintenanceService(
        db.CreateFactory(), git, NullLogger<WorktreeMaintenanceService>.Instance);

    var result = await svc.ForceRemoveAsync(task.Id, CancellationToken.None);

    Assert.True(result.Removed);

    using var checkCtx = db.CreateContext();
    var remaining = await new WorktreeRepository(checkCtx).GetAllAsync();
    Assert.Empty(remaining);
}
```

- [ ] **Step 2: Run tests to verify they fail**

Run: `dotnet test tests/ClaudeDo.Worker.Tests --filter "FullyQualifiedName~ForceRemove"`
Expected: FAIL — `ForceRemoveAsync` does not exist; `ForceRemoveResult` does not exist.

- [ ] **Step 3: Add `ForceRemoveResult` record + `ForceRemoveAsync` method**

Add to `WorktreeMaintenanceService.cs`, alongside the existing nested records (line 10–11 area):

```csharp
public sealed record ForceRemoveResult(bool Removed, string? Reason);
```

Add this method to `WorktreeMaintenanceService` (after `ResetAllAsync`):

```csharp
public async Task<ForceRemoveResult> ForceRemoveAsync(string taskId, CancellationToken ct = default)
{
    using var context = _dbFactory.CreateDbContext();

    var row = await (from w in context.Worktrees
                     join t in context.Tasks on w.TaskId equals t.Id
                     join l in context.Lists on t.ListId equals l.Id
                     where w.TaskId == taskId
                     select new { Row = new WorktreeRow(w.TaskId, w.Path, w.BranchName, l.WorkingDir),
                                  Status = t.Status })
                    .AsNoTracking()
                    .FirstOrDefaultAsync(ct);

    if (row is null)
        return new ForceRemoveResult(false, "worktree not found");

    if (row.Status == ClaudeDo.Data.Models.TaskStatus.Running)
        return new ForceRemoveResult(false, "task is currently running");

    var ok = await TryRemoveAsync(row.Row, force: true, ct);
    return new ForceRemoveResult(ok, ok ? null : "remove failed");
}
```

- [ ] **Step 4: Run tests to verify they pass**

Run: `dotnet test tests/ClaudeDo.Worker.Tests --filter "FullyQualifiedName~WorktreeMaintenanceServiceTests"`
Expected: PASS (all tests).

- [ ] **Step 5: Commit**

```bash
git add src/ClaudeDo.Worker/Worktrees/WorktreeMaintenanceService.cs tests/ClaudeDo.Worker.Tests/Services/WorktreeMaintenanceServiceTests.cs
git commit -m "feat(worktrees): add ForceRemoveAsync for targeted removal"
```

---

## Task 4: Add hub DTOs + new hub methods

**Files:**
- Modify: `src/ClaudeDo.Worker/Hub/WorkerHub.cs`

- [ ] **Step 1: Add DTOs at the top of the file (alongside existing records)**

In `WorkerHub.cs`, add after `record WorktreeResetDto`:

```csharp
public record WorktreeOverviewDto(
    string TaskId,
    string TaskTitle,
    ClaudeDo.Data.Models.TaskStatus TaskStatus,
    string ListId,
    string ListName,
    string Path,
    string BranchName,
    WorktreeState State,
    string? DiffStat,
    DateTime CreatedAt,
    bool PathExistsOnDisk);

public record ForceRemoveResultDto(bool Removed, string? Reason);
```

- [ ] **Step 2: Replace existing `CleanupFinishedWorktrees` with the list-scoped version**

Replace the current method (around line 223–227):

```csharp
public async Task<WorktreeCleanupDto> CleanupFinishedWorktrees(string? listId = null)
{
    var result = await _wtMaintenance.CleanupFinishedAsync(listId, CancellationToken.None);
    return new WorktreeCleanupDto(result.Removed);
}
```

- [ ] **Step 3: Add `GetWorktreesOverview`, `SetWorktreeState`, `ForceRemoveWorktree`**

Add after `ResetAllWorktrees` (around line 233):

```csharp
public async Task<List<WorktreeOverviewDto>> GetWorktreesOverview(string? listId)
{
    var rows = await _wtMaintenance.GetOverviewAsync(listId, Context.ConnectionAborted);
    return rows.Select(r => new WorktreeOverviewDto(
        r.TaskId, r.TaskTitle, r.TaskStatus, r.ListId, r.ListName,
        r.Path, r.BranchName, r.State, r.DiffStat, r.CreatedAt, r.PathExistsOnDisk)).ToList();
}

public async Task<bool> SetWorktreeState(string taskId, WorktreeState newState)
{
    using var ctx = _dbFactory.CreateDbContext();
    var repo = new WorktreeRepository(ctx);
    var existing = await repo.GetByTaskIdAsync(taskId, Context.ConnectionAborted);
    if (existing is null) throw new HubException("worktree not found");
    await repo.SetStateAsync(taskId, newState, Context.ConnectionAborted);
    await _broadcaster.WorktreeUpdated(taskId);
    return true;
}

public async Task<ForceRemoveResultDto> ForceRemoveWorktree(string taskId)
{
    var result = await _wtMaintenance.ForceRemoveAsync(taskId, Context.ConnectionAborted);
    if (result.Removed)
        await _broadcaster.WorktreeUpdated(taskId);
    return new ForceRemoveResultDto(result.Removed, result.Reason);
}
```

Note: `HubBroadcaster.WorktreeUpdated(string taskId)` already exists (see `WorkerClient`'s `WorktreeUpdated` handler). If for some reason it does not, add it to `HubBroadcaster` as:

```csharp
public Task WorktreeUpdated(string taskId) => _hub.Clients.All.SendAsync("WorktreeUpdated", taskId);
```

- [ ] **Step 4: Build the Worker project**

Run: `dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj`
Expected: build succeeds.

- [ ] **Step 5: Commit**

```bash
git add src/ClaudeDo.Worker/Hub/WorkerHub.cs
git commit -m "feat(hub): expose worktree overview, state mutation, force-remove"
```

---

## Task 5: Add `WorkerClient` wrappers

**Files:**
- Modify: `src/ClaudeDo.Ui/Services/WorkerClient.cs`

- [ ] **Step 1: Add UI-side DTOs at the bottom of `WorkerClient.cs`**

Append to the existing DTO section at the bottom of the file:

```csharp
public sealed record WorktreeOverviewDto(
    string TaskId,
    string TaskTitle,
    ClaudeDo.Data.Models.TaskStatus TaskStatus,
    string ListId,
    string ListName,
    string Path,
    string BranchName,
    WorktreeState State,
    string? DiffStat,
    DateTime CreatedAt,
    bool PathExistsOnDisk);

public sealed record ForceRemoveResultDto(bool Removed, string? Reason);
```

- [ ] **Step 2: Replace the existing `CleanupFinishedWorktreesAsync` to accept optional `listId`**

Replace lines around 398–408 in `WorkerClient.cs`:

```csharp
public async Task<WorktreeCleanupDto?> CleanupFinishedWorktreesAsync(string? listId = null)
{
    try
    {
        return await _hub.InvokeAsync<WorktreeCleanupDto>("CleanupFinishedWorktrees", listId);
    }
    catch
    {
        return null;
    }
}
```

- [ ] **Step 3: Add the three new wrapper methods**

Add after `ResetAllWorktreesAsync` (around line 420):

```csharp
public async Task<List<WorktreeOverviewDto>> GetWorktreesOverviewAsync(string? listId)
{
    try
    {
        var rows = await _hub.InvokeAsync<List<WorktreeOverviewDto>>("GetWorktreesOverview", listId);
        return rows ?? new List<WorktreeOverviewDto>();
    }
    catch
    {
        return new List<WorktreeOverviewDto>();
    }
}

public async Task<bool> SetWorktreeStateAsync(string taskId, WorktreeState newState)
{
    try
    {
        return await _hub.InvokeAsync<bool>("SetWorktreeState", taskId, newState);
    }
    catch
    {
        return false;
    }
}

public async Task<ForceRemoveResultDto?> ForceRemoveWorktreeAsync(string taskId)
{
    try
    {
        return await _hub.InvokeAsync<ForceRemoveResultDto>("ForceRemoveWorktree", taskId);
    }
    catch
    {
        return null;
    }
}
```

- [ ] **Step 4: Check for `IWorkerClient` interface**

Run: `grep -n "CleanupFinishedWorktreesAsync\|interface IWorkerClient" src/ClaudeDo.Ui/Services/IWorkerClient.cs`

If `IWorkerClient` defines `CleanupFinishedWorktreesAsync`, update its signature to match. If it does not declare it, skip.

- [ ] **Step 5: Build the Ui project**

Run: `dotnet build src/ClaudeDo.Ui/ClaudeDo.Ui.csproj`
Expected: build succeeds.

- [ ] **Step 6: Commit**

```bash
git add src/ClaudeDo.Ui/Services/WorkerClient.cs src/ClaudeDo.Ui/Services/IWorkerClient.cs
git commit -m "feat(ui): expose worktree overview client methods"
```

---

## Task 6: Add `WorktreeStateColorConverter`

**Files:**
- Create: `src/ClaudeDo.Ui/Converters/WorktreeStateColorConverter.cs`

- [ ] **Step 1: Create the converter**

Create `src/ClaudeDo.Ui/Converters/WorktreeStateColorConverter.cs`:

```csharp
using System.Globalization;
using Avalonia.Data.Converters;
using Avalonia.Media;
using ClaudeDo.Data.Models;

namespace ClaudeDo.Ui.Converters;

public sealed class WorktreeStateColorConverter : IValueConverter
{
    public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture) =>
        value is WorktreeState state
            ? state switch
            {
                WorktreeState.Active    => new SolidColorBrush(Color.Parse("#42A5F5")), // blue
                WorktreeState.Merged    => new SolidColorBrush(Color.Parse("#66BB6A")), // green
                WorktreeState.Discarded => new SolidColorBrush(Color.Parse("#9E9E9E")), // gray
                WorktreeState.Kept      => new SolidColorBrush(Color.Parse("#FFA726")), // orange
                _                       => new SolidColorBrush(Colors.Gray),
            }
            : new SolidColorBrush(Colors.Gray);

    public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
        => throw new NotSupportedException();
}
```

- [ ] **Step 2: Build to confirm**

Run: `dotnet build src/ClaudeDo.Ui/ClaudeDo.Ui.csproj`
Expected: build succeeds.

- [ ] **Step 3: Commit**

```bash
git add src/ClaudeDo.Ui/Converters/WorktreeStateColorConverter.cs
git commit -m "feat(ui): add WorktreeStateColorConverter"
```

---

## Task 7: Add `WorktreesOverviewModalViewModel`

**Files:**
- Create: `src/ClaudeDo.Ui/ViewModels/Modals/WorktreesOverviewModalViewModel.cs`

- [ ] **Step 1: Create the row VM and the modal VM**

Create `src/ClaudeDo.Ui/ViewModels/Modals/WorktreesOverviewModalViewModel.cs`:

```csharp
using System.Collections.ObjectModel;
using System.Diagnostics;
using Avalonia;
using Avalonia.Controls.ApplicationLifetimes;
using Avalonia.Input.Platform;
using ClaudeDo.Data.Models;
using ClaudeDo.Ui.Services;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;

namespace ClaudeDo.Ui.ViewModels.Modals;

public sealed partial class WorktreeOverviewRowViewModel : ViewModelBase
{
    [ObservableProperty] private string _taskId = "";
    [ObservableProperty] private string _taskTitle = "";
    [ObservableProperty] private TaskStatus _taskStatus;
    [ObservableProperty] private string _listId = "";
    [ObservableProperty] private string _listName = "";
    [ObservableProperty] private string _path = "";
    [ObservableProperty] private string _branchName = "";
    [ObservableProperty] private WorktreeState _state;
    [ObservableProperty] private string? _diffStat;
    [ObservableProperty] private DateTime _createdAt;
    [ObservableProperty] private bool _pathExistsOnDisk;

    public string AgeText => FormatAge(DateTime.UtcNow - CreatedAt);
    public bool IsActive => State == WorktreeState.Active;
    public bool IsRunning => TaskStatus == TaskStatus.Running;

    private static string FormatAge(TimeSpan ts)
    {
        if (ts.TotalDays >= 1)  return $"{(int)ts.TotalDays}d ago";
        if (ts.TotalHours >= 1) return $"{(int)ts.TotalHours}h ago";
        if (ts.TotalMinutes >= 1) return $"{(int)ts.TotalMinutes}m ago";
        return "just now";
    }
}

public sealed partial class WorktreesGroupViewModel : ViewModelBase
{
    public required string ListId { get; init; }
    public required string ListName { get; init; }
    public ObservableCollection<WorktreeOverviewRowViewModel> Rows { get; } = new();
}

public sealed partial class WorktreesOverviewModalViewModel : ViewModelBase
{
    private readonly WorkerClient _worker;

    [ObservableProperty] private string? _listIdFilter;
    [ObservableProperty] private string _title = "Worktrees";
    [ObservableProperty] private bool _isGlobal;
    [ObservableProperty] private bool _isBusy;
    [ObservableProperty] private string? _statusMessage;

    // Filtered (per-list) mode populates this directly.
    public ObservableCollection<WorktreeOverviewRowViewModel> Rows { get; } = new();
    // Global mode populates this.
    public ObservableCollection<WorktreesGroupViewModel> Groups { get; } = new();

    public Action? CloseAction { get; set; }
    public Action<WorktreeOverviewRowViewModel>? ShowDiffAction { get; set; }
    public Action<string, string>? JumpToTaskAction { get; set; }   // (listId, taskId)
    public Func<string, Task<bool>>? ConfirmAction { get; set; }    // message → confirmed?

    public WorktreesOverviewModalViewModel(WorkerClient worker)
    {
        _worker = worker;
    }

    public void Configure(string? listId, string? listName)
    {
        ListIdFilter = listId;
        IsGlobal = listId is null;
        Title = listId is null ? "Worktrees" : $"Worktrees — {listName ?? "list"}";
    }

    public async Task LoadAsync(CancellationToken ct = default)
    {
        IsBusy = true;
        StatusMessage = null;
        try
        {
            var dtos = await _worker.GetWorktreesOverviewAsync(ListIdFilter);
            var ordered = dtos
                .OrderBy(d => d.State == WorktreeState.Active ? 0 : 1)
                .ThenByDescending(d => d.CreatedAt)
                .Select(Map)
                .ToList();

            Rows.Clear();
            Groups.Clear();
            if (IsGlobal)
            {
                foreach (var grp in ordered.GroupBy(r => (r.ListId, r.ListName)).OrderBy(g => g.Key.ListName))
                {
                    var group = new WorktreesGroupViewModel { ListId = grp.Key.ListId, ListName = grp.Key.ListName };
                    foreach (var row in grp) group.Rows.Add(row);
                    Groups.Add(group);
                }
            }
            else
            {
                foreach (var row in ordered) Rows.Add(row);
            }
        }
        finally
        {
            IsBusy = false;
        }
    }

    [RelayCommand]
    private Task Refresh() => LoadAsync();

    [RelayCommand]
    private async Task CleanupFinished()
    {
        IsBusy = true;
        try
        {
            var result = await _worker.CleanupFinishedWorktreesAsync(ListIdFilter);
            StatusMessage = result is null ? "Cleanup failed." : $"Removed {result.Removed} worktree(s).";
            await LoadAsync();
        }
        finally { IsBusy = false; }
    }

    [RelayCommand]
    private void Close() => CloseAction?.Invoke();

    [RelayCommand]
    private void ShowDiff(WorktreeOverviewRowViewModel? row)
    {
        if (row is null) return;
        ShowDiffAction?.Invoke(row);
    }

    [RelayCommand]
    private void OpenInExplorer(WorktreeOverviewRowViewModel? row)
    {
        if (row is null || !row.PathExistsOnDisk) return;
        try { Process.Start(new ProcessStartInfo { FileName = "explorer.exe", Arguments = $"\"{row.Path}\"", UseShellExecute = true }); }
        catch { /* best-effort */ }
    }

    [RelayCommand]
    private void JumpToTask(WorktreeOverviewRowViewModel? row)
    {
        if (row is null) return;
        JumpToTaskAction?.Invoke(row.ListId, row.TaskId);
        CloseAction?.Invoke();
    }

    [RelayCommand]
    private async Task Discard(WorktreeOverviewRowViewModel? row)
    {
        if (row is null || row.State != WorktreeState.Active) return;
        if (await _worker.SetWorktreeStateAsync(row.TaskId, WorktreeState.Discarded))
        {
            row.State = WorktreeState.Discarded;
        }
    }

    [RelayCommand]
    private async Task Keep(WorktreeOverviewRowViewModel? row)
    {
        if (row is null || row.State != WorktreeState.Active) return;
        if (await _worker.SetWorktreeStateAsync(row.TaskId, WorktreeState.Kept))
        {
            row.State = WorktreeState.Kept;
        }
    }

    [RelayCommand]
    private async Task ForceRemove(WorktreeOverviewRowViewModel? row)
    {
        if (row is null) return;
        if (row.IsRunning) { StatusMessage = "Cannot force-remove a running task."; return; }
        if (ConfirmAction is not null && !await ConfirmAction($"Force remove worktree for '{row.TaskTitle}'? This deletes the directory and branch.")) return;

        var result = await _worker.ForceRemoveWorktreeAsync(row.TaskId);
        if (result is null || !result.Removed)
        {
            StatusMessage = result?.Reason ?? "Force remove failed.";
            return;
        }
        // Remove the row locally.
        if (IsGlobal)
        {
            foreach (var grp in Groups)
            {
                var idx = grp.Rows.IndexOf(row);
                if (idx >= 0) { grp.Rows.RemoveAt(idx); break; }
            }
        }
        else
        {
            Rows.Remove(row);
        }
    }

    [RelayCommand]
    private async Task CopyBranch(WorktreeOverviewRowViewModel? row) => await CopyToClipboardAsync(row?.BranchName);

    [RelayCommand]
    private async Task CopyPath(WorktreeOverviewRowViewModel? row) => await CopyToClipboardAsync(row?.Path);

    private static async Task CopyToClipboardAsync(string? text)
    {
        if (string.IsNullOrEmpty(text)) return;
        if (Application.Current?.ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop &&
            desktop.MainWindow?.Clipboard is { } clipboard)
        {
            try { await clipboard.SetTextAsync(text); } catch { }
        }
    }

    private static WorktreeOverviewRowViewModel Map(WorktreeOverviewDto d) => new()
    {
        TaskId = d.TaskId, TaskTitle = d.TaskTitle, TaskStatus = d.TaskStatus,
        ListId = d.ListId, ListName = d.ListName,
        Path = d.Path, BranchName = d.BranchName, State = d.State,
        DiffStat = d.DiffStat, CreatedAt = d.CreatedAt, PathExistsOnDisk = d.PathExistsOnDisk,
    };
}
```

- [ ] **Step 2: Build to confirm**

Run: `dotnet build src/ClaudeDo.Ui/ClaudeDo.Ui.csproj`
Expected: build succeeds.

- [ ] **Step 3: Commit**

```bash
git add src/ClaudeDo.Ui/ViewModels/Modals/WorktreesOverviewModalViewModel.cs
git commit -m "feat(ui): add WorktreesOverviewModalViewModel"
```

---

## Task 8: Add `WorktreesOverviewModalView`

**Files:**
- Create: `src/ClaudeDo.Ui/Views/Modals/WorktreesOverviewModalView.axaml`
- Create: `src/ClaudeDo.Ui/Views/Modals/WorktreesOverviewModalView.axaml.cs`

- [ ] **Step 1: Create the XAML**

Create `src/ClaudeDo.Ui/Views/Modals/WorktreesOverviewModalView.axaml`:

```xml
<Window xmlns="https://github.com/avaloniaui"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        xmlns:vm="using:ClaudeDo.Ui.ViewModels.Modals"
        xmlns:converters="using:ClaudeDo.Ui.Converters"
        x:Class="ClaudeDo.Ui.Views.Modals.WorktreesOverviewModalView"
        x:DataType="vm:WorktreesOverviewModalViewModel"
        Title="{Binding Title}"
        Width="900" Height="560" MinWidth="640" MinHeight="360"
        WindowStartupLocation="CenterOwner"
        Background="{DynamicResource VoidBrush}">
  <Window.Resources>
    <converters:WorktreeStateColorConverter x:Key="WorktreeStateColor"/>
  </Window.Resources>

  <DockPanel LastChildFill="True" Margin="12">
    <!-- Toolbar -->
    <StackPanel DockPanel.Dock="Top" Orientation="Horizontal" Spacing="8" Margin="0,0,0,8">
      <Button Content="Refresh" Command="{Binding RefreshCommand}" IsEnabled="{Binding !IsBusy}"/>
      <Button Content="Cleanup finished" Command="{Binding CleanupFinishedCommand}" IsEnabled="{Binding !IsBusy}"/>
      <TextBlock Text="{Binding StatusMessage}" VerticalAlignment="Center" Margin="12,0,0,0"
                 Foreground="{DynamicResource TextDimBrush}"/>
    </StackPanel>

    <!-- Bottom bar -->
    <StackPanel DockPanel.Dock="Bottom" Orientation="Horizontal" HorizontalAlignment="Right" Margin="0,8,0,0">
      <Button Content="Close" Command="{Binding CloseCommand}"/>
    </StackPanel>

    <!-- Content: either a flat list (filtered) or groups (global) -->
    <ScrollViewer>
      <Grid>
        <!-- Filtered (single-list) mode -->
        <ItemsControl ItemsSource="{Binding Rows}" IsVisible="{Binding !IsGlobal}">
          <ItemsControl.ItemTemplate>
            <DataTemplate DataType="vm:WorktreeOverviewRowViewModel">
              <ContentControl ContentTemplate="{StaticResource WorktreeRowTemplate}" Content="{Binding}"/>
            </DataTemplate>
          </ItemsControl.ItemTemplate>
        </ItemsControl>

        <!-- Global mode -->
        <ItemsControl ItemsSource="{Binding Groups}" IsVisible="{Binding IsGlobal}">
          <ItemsControl.ItemTemplate>
            <DataTemplate DataType="vm:WorktreesGroupViewModel">
              <Expander Header="{Binding ListName}" IsExpanded="True" Margin="0,0,0,6">
                <ItemsControl ItemsSource="{Binding Rows}">
                  <ItemsControl.ItemTemplate>
                    <DataTemplate DataType="vm:WorktreeOverviewRowViewModel">
                      <ContentControl ContentTemplate="{StaticResource WorktreeRowTemplate}" Content="{Binding}"/>
                    </DataTemplate>
                  </ItemsControl.ItemTemplate>
                </ItemsControl>
              </Expander>
            </DataTemplate>
          </ItemsControl.ItemTemplate>
        </ItemsControl>
      </Grid>
    </ScrollViewer>
  </DockPanel>

  <Window.Styles>
    <Style Selector="Border.wt-row">
      <Setter Property="Background" Value="{DynamicResource DeepBrush}"/>
      <Setter Property="BorderBrush" Value="{DynamicResource LineBrush}"/>
      <Setter Property="BorderThickness" Value="0,0,0,1"/>
      <Setter Property="Padding" Value="10,8"/>
    </Style>
  </Window.Styles>

  <Window.Resources>
    <!-- Row template, shared by both modes -->
    <DataTemplate x:Key="WorktreeRowTemplate" x:DataType="vm:WorktreeOverviewRowViewModel">
      <Border Classes="wt-row">
        <Border.ContextMenu>
          <ContextMenu>
            <MenuItem Header="Show diff"
                      Command="{Binding $parent[Window].((vm:WorktreesOverviewModalViewModel)DataContext).ShowDiffCommand}"
                      CommandParameter="{Binding}"/>
            <MenuItem Header="Open in Explorer"
                      IsEnabled="{Binding PathExistsOnDisk}"
                      Command="{Binding $parent[Window].((vm:WorktreesOverviewModalViewModel)DataContext).OpenInExplorerCommand}"
                      CommandParameter="{Binding}"/>
            <MenuItem Header="Jump to task"
                      Command="{Binding $parent[Window].((vm:WorktreesOverviewModalViewModel)DataContext).JumpToTaskCommand}"
                      CommandParameter="{Binding}"/>
            <Separator/>
            <MenuItem Header="Discard"
                      IsEnabled="{Binding IsActive}"
                      Command="{Binding $parent[Window].((vm:WorktreesOverviewModalViewModel)DataContext).DiscardCommand}"
                      CommandParameter="{Binding}"/>
            <MenuItem Header="Keep"
                      IsEnabled="{Binding IsActive}"
                      Command="{Binding $parent[Window].((vm:WorktreesOverviewModalViewModel)DataContext).KeepCommand}"
                      CommandParameter="{Binding}"/>
            <Separator/>
            <MenuItem Header="Copy branch"
                      Command="{Binding $parent[Window].((vm:WorktreesOverviewModalViewModel)DataContext).CopyBranchCommand}"
                      CommandParameter="{Binding}"/>
            <MenuItem Header="Copy path"
                      Command="{Binding $parent[Window].((vm:WorktreesOverviewModalViewModel)DataContext).CopyPathCommand}"
                      CommandParameter="{Binding}"/>
            <Separator/>
            <MenuItem Header="Force remove"
                      Foreground="#EF5350"
                      Command="{Binding $parent[Window].((vm:WorktreesOverviewModalViewModel)DataContext).ForceRemoveCommand}"
                      CommandParameter="{Binding}"/>
          </ContextMenu>
        </Border.ContextMenu>
        <Grid ColumnDefinitions="*,200,90,80,80">
          <StackPanel Grid.Column="0" Orientation="Vertical" Spacing="2">
            <TextBlock Text="{Binding TaskTitle}" FontWeight="SemiBold"/>
            <StackPanel Orientation="Horizontal" Spacing="4">
              <TextBlock Text="{Binding TaskStatus}" FontSize="10"
                         Foreground="{DynamicResource TextFaintBrush}"/>
              <TextBlock Text="•" FontSize="10" Foreground="{DynamicResource TextFaintBrush}"
                         IsVisible="{Binding !PathExistsOnDisk}"/>
              <TextBlock Text="phantom" FontSize="10" Foreground="#EF5350"
                         IsVisible="{Binding !PathExistsOnDisk}"
                         ToolTip.Tip="Directory missing on disk"/>
            </StackPanel>
          </StackPanel>
          <TextBlock Grid.Column="1" Text="{Binding BranchName}"
                     FontFamily="{DynamicResource MonoFont}" FontSize="11"
                     VerticalAlignment="Center" TextTrimming="CharacterEllipsis"/>
          <Border Grid.Column="2" CornerRadius="3" Padding="6,2" VerticalAlignment="Center"
                  Background="{Binding State, Converter={StaticResource WorktreeStateColor}}">
            <TextBlock Text="{Binding State}" FontSize="10" Foreground="White"
                       HorizontalAlignment="Center"/>
          </Border>
          <TextBlock Grid.Column="3" Text="{Binding DiffStat}" VerticalAlignment="Center"
                     FontFamily="{DynamicResource MonoFont}" FontSize="11"
                     Foreground="{DynamicResource TextDimBrush}"/>
          <TextBlock Grid.Column="4" Text="{Binding AgeText}" VerticalAlignment="Center"
                     FontSize="11" Foreground="{DynamicResource TextDimBrush}"/>
        </Grid>
      </Border>
    </DataTemplate>
  </Window.Resources>
</Window>
```

> Note: keep only ONE `<Window.Resources>` block. The block above declares the converter and `WorktreeRowTemplate` together. Adjust if Avalonia complains about duplicate `Resources` — merge into a single `<Window.Resources>` near the top.

- [ ] **Step 2: Create the code-behind**

Create `src/ClaudeDo.Ui/Views/Modals/WorktreesOverviewModalView.axaml.cs`:

```csharp
using Avalonia.Controls;

namespace ClaudeDo.Ui.Views.Modals;

public partial class WorktreesOverviewModalView : Window
{
    public WorktreesOverviewModalView() => InitializeComponent();
}
```

- [ ] **Step 3: Build to confirm XAML compiles**

Run: `dotnet build src/ClaudeDo.Ui/ClaudeDo.Ui.csproj`
Expected: build succeeds. If duplicate `Window.Resources` is reported, merge the two blocks into one at the top of the file.

- [ ] **Step 4: Commit**

```bash
git add src/ClaudeDo.Ui/Views/Modals/WorktreesOverviewModalView.axaml src/ClaudeDo.Ui/Views/Modals/WorktreesOverviewModalView.axaml.cs
git commit -m "feat(ui): add WorktreesOverviewModalView"
```

---

## Task 9: Wire entry points (DI, ListsIslandViewModel, IslandsShellViewModel, views)

**Files:**
- Modify: `src/ClaudeDo.App/Program.cs`
- Modify: `src/ClaudeDo.Ui/ViewModels/Islands/ListsIslandViewModel.cs`
- Modify: `src/ClaudeDo.Ui/Views/Islands/ListsIslandView.axaml`
- Modify: `src/ClaudeDo.Ui/Views/Islands/ListsIslandView.axaml.cs`
- Modify: `src/ClaudeDo.Ui/ViewModels/IslandsShellViewModel.cs`
- Modify: `src/ClaudeDo.Ui/Views/MainWindow.axaml`
- Modify: `src/ClaudeDo.Ui/Views/MainWindow.axaml.cs`

- [ ] **Step 1: Register the new VM in DI**

In `src/ClaudeDo.App/Program.cs`, find line 97 (`sc.AddTransient<WorktreeModalViewModel>();`) and add right after:

```csharp
sc.AddTransient<WorktreesOverviewModalViewModel>();
```

- [ ] **Step 2: Add the list-scoped open command to `ListsIslandViewModel`**

In `src/ClaudeDo.Ui/ViewModels/Islands/ListsIslandViewModel.cs`, add a new modal hook next to `ShowListSettingsModal` (around line 30):

```csharp
public Func<WorktreesOverviewModalViewModel, Task>? ShowWorktreesOverviewModal { get; set; }
```

Add a new relay command after `OpenListSettingsAsync` (around line 50):

```csharp
[RelayCommand]
private async Task OpenWorktreesOverviewAsync(ListNavItemViewModel? row)
{
    if (row is null || ShowWorktreesOverviewModal is null || _services is null) return;
    if (row.Kind != ListKind.User) return;
    var rawId = row.Id.StartsWith("user:", StringComparison.Ordinal) ? row.Id["user:".Length..] : row.Id;
    var vm = _services.GetRequiredService<WorktreesOverviewModalViewModel>();
    vm.Configure(rawId, row.Name);
    await vm.LoadAsync();
    await ShowWorktreesOverviewModal(vm);
}
```

- [ ] **Step 3: Add the context-menu entry in the list row template**

In `src/ClaudeDo.Ui/Views/Islands/ListsIslandView.axaml`, modify the existing `ContextMenu` block (around line 131–135) to add a second item:

```xml
<ContextMenu>
  <MenuItem Header="Settings..."
            Command="{Binding $parent[UserControl].((vm:ListsIslandViewModel)DataContext).OpenListSettingsCommand}"
            CommandParameter="{Binding}"/>
  <MenuItem Header="Worktrees anzeigen…"
            Command="{Binding $parent[UserControl].((vm:ListsIslandViewModel)DataContext).OpenWorktreesOverviewCommand}"
            CommandParameter="{Binding}"/>
</ContextMenu>
```

- [ ] **Step 4: Wire `ShowWorktreesOverviewModal` in `ListsIslandView.axaml.cs`**

Inside the `DataContextChanged` handler (after `ShowListSettingsModal = …` around line 27 of the file), add:

```csharp
vm.ShowWorktreesOverviewModal = async modal =>
{
    var window = new WorktreesOverviewModalView { DataContext = modal };
    modal.CloseAction = () => window.Close();
    modal.ShowDiffAction = row =>
    {
        var diffVm = vm._services?.GetRequiredService<WorktreeModalViewModel>();
        // Fallback: ignore if DI not reachable from this codepath.
        if (diffVm is null) return;
        diffVm.WorktreePath = row.Path;
        var diffWindow = new WorktreeModalView { DataContext = diffVm };
        diffVm.CloseAction = () => diffWindow.Close();
        _ = diffVm.LoadAsync();
        _ = diffWindow.ShowDialog(window);
    };
    var top = TopLevel.GetTopLevel(this) as Window;
    if (top is null) window.Show();
    else await window.ShowDialog(top);
};
```

> If `_services` is private on `ListsIslandViewModel` and inaccessible from the view, expose it as `internal IServiceProvider? Services => _services;` in that VM file, OR construct the diff VM via a different DI accessor available on the view. Verify the access pattern matches existing code; mirror whatever the `ListSettingsModal` wiring does for service-locator-style DI lookups, or expose an internal helper. Note: this internal exposure stays internal-only (no public surface).

- [ ] **Step 5: Add the global Help-menu command in `IslandsShellViewModel`**

In `src/ClaudeDo.Ui/ViewModels/IslandsShellViewModel.cs`, near the existing `ShowAboutModal` declaration, add:

```csharp
public Func<WorktreesOverviewModalViewModel, Task>? ShowWorktreesOverviewModal { get; set; }
```

Add this command (e.g., right after `OpenAbout` around line 250):

```csharp
[RelayCommand]
private async Task OpenWorktreesOverviewGlobalAsync()
{
    if (ShowWorktreesOverviewModal is null || _services is null) return;
    var vm = _services.GetRequiredService<WorktreesOverviewModalViewModel>();
    vm.Configure(null, null);
    await vm.LoadAsync();
    await ShowWorktreesOverviewModal(vm);
}
```

> If `IslandsShellViewModel` does not have `_services`, follow the same DI accessor pattern it already uses for `OpenAbout` (e.g., a `Func<AboutModalViewModel>` factory injected via constructor). Find how `AboutModalViewModel` is obtained — the new command must use the same pattern.

- [ ] **Step 6: Add the Help-menu entry in `MainWindow.axaml`**

In `src/ClaudeDo.Ui/Views/MainWindow.axaml`, inside the existing Help `MenuItem` (line 65–73), add an item before "About…":

```xml
<MenuItem Header="Worktrees…"
          Command="{Binding OpenWorktreesOverviewGlobalCommand}"/>
```

- [ ] **Step 7: Wire `ShowWorktreesOverviewModal` for global mode in `MainWindow.axaml.cs`**

In `src/ClaudeDo.Ui/Views/MainWindow.axaml.cs`, find where the existing modal hooks on `IslandsShellViewModel` are wired (look for `ShowAboutModal = ...`). Add right after:

```csharp
shell.ShowWorktreesOverviewModal = async modal =>
{
    var window = new WorktreesOverviewModalView { DataContext = modal };
    modal.CloseAction = () => window.Close();
    modal.ShowDiffAction = row =>
    {
        // Reuse the existing diff modal.
        var diffVm = App.Services?.GetService(typeof(WorktreeModalViewModel)) as WorktreeModalViewModel;
        if (diffVm is null) return;
        diffVm.WorktreePath = row.Path;
        var diffWindow = new WorktreeModalView { DataContext = diffVm };
        diffVm.CloseAction = () => diffWindow.Close();
        _ = diffVm.LoadAsync();
        _ = diffWindow.ShowDialog(window);
    };
    modal.JumpToTaskAction = (listId, taskId) =>
    {
        // Best-effort: select the list, leave task selection to whatever existing event handlers do.
        if (DataContext is IslandsShellViewModel s)
        {
            var item = s.Lists.UserLists.FirstOrDefault(l => l.Id == $"user:{listId}");
            if (item is not null) s.Lists.SelectedList = item;
        }
    };
    await window.ShowDialog(this);
};
```

> If `App.Services` is not the actual DI accessor, use whatever the codebase uses (search for an existing place that retrieves a VM after construction — e.g., a static `App.Current.Services` or a property on the window). The diff VM must be a transient. Keep the pattern simple — if no clean accessor exists in `MainWindow.axaml.cs`, omit `ShowDiffAction` here and only wire it from `ListsIslandView` (where `_services` is reachable on the VM). The user can still close-and-reopen the modal from the list context menu for the diff feature.

- [ ] **Step 8: Build the whole solution**

Run: `dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj`
Expected: build succeeds. If any DI or ViewModel access errors appear, fix the local accessor pattern to match the existing one in `IslandsShellViewModel` (look at `OpenAbout` for the exact DI shape).

- [ ] **Step 9: Commit**

```bash
git add src/ClaudeDo.App/Program.cs src/ClaudeDo.Ui/ViewModels src/ClaudeDo.Ui/Views/Islands src/ClaudeDo.Ui/Views/MainWindow.axaml src/ClaudeDo.Ui/Views/MainWindow.axaml.cs
git commit -m "feat(ui): wire worktree overview modal entry points"
```

---

## Task 10: Final verification

- [ ] **Step 1: Run full test suite**

Run: `dotnet test tests/ClaudeDo.Worker.Tests`
Expected: all tests pass.

- [ ] **Step 2: Build whole app**

Run: `dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj`
Expected: build succeeds, no warnings about missing XAML resources or unbound commands.

- [ ] **Step 3: Manual smoke test (record findings — no commit needed)**

Start the Worker (`dotnet run --project src/ClaudeDo.Worker`) and the App (`dotnet run --project src/ClaudeDo.App`). Then exercise:

1. Create a user list with a working directory pointing at a git repo. Create a task and let it produce a worktree.
2. Right-click the user list → "Worktrees anzeigen…" → modal opens, lists this list's worktrees only, no group headers.
3. Help menu → "Worktrees…" → modal opens, lists grouped by list with expanders.
4. Right-click a row:
   - "Show diff" → opens the existing file-tree diff modal.
   - "Open in Explorer" → opens the directory (greyed out for phantom rows).
   - "Jump to task" → closes modal, selects the list (best-effort).
   - "Discard" / "Keep" → state badge changes color.
   - "Copy branch" / "Copy path" → clipboard contains expected text.
   - "Force remove" → confirmation dialog (if `ConfirmAction` is wired), then row vanishes, branch + directory removed.
5. With a task in `Running` state, "Force remove" sets `StatusMessage = "Cannot force-remove a running task."`.
6. "Cleanup finished" in filtered mode → only that list's Merged/Discarded rows vanish.
7. "Refresh" reloads from worker.

Report any failures in the conversation; do not auto-fix without confirmation.

---

## Self-Review Notes

- **Spec coverage**: All UI sections, SignalR contract, backend changes, force-remove semantics, and testing requirements from the spec map to specific tasks (1–9 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.
