docs(handler): implementation plan for handler-run task links

This commit is contained in:
mika kuns
2026-08-07 10:12:33 +02:00
parent fb29e8a871
commit 231b063751
2 changed files with 942 additions and 1 deletions
@@ -0,0 +1,939 @@
# Handler-Run Links 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:** A "Let Claude handle it" run records which tasks it processed, shows them as a list on the handler task's detail pane, and wears a HANDLER badge instead of MANUAL.
**Architecture:** One new nullable column `TaskEntity.HandlerTaskId` (1:n, last run wins) stamped at handler-task creation from the selection the UI already passes down. The badge is a display-only computed property on `TaskRowViewModel`, driven by the existing `HandlerBaseCommit`. The panel reuses `ChildOutcomeRowViewModel` and the existing refresh path.
**Tech Stack:** .NET 8, EF Core (SQLite), Avalonia 12 + CommunityToolkit.Mvvm, xUnit.
**Spec:** `docs/superpowers/specs/2026-08-07-handler-run-links-design.md`
---
## File Structure
**Modified:**
- `src/ClaudeDo.Data/Models/TaskEntity.cs` — new `HandlerTaskId` property
- `src/ClaudeDo.Data/Configuration/TaskEntityConfiguration.cs` — column mapping + index
- `src/ClaudeDo.Data/Repositories/TaskRepository.cs``SetHandlerTaskIdAsync`
- `src/ClaudeDo.Worker/Runner/InteractiveLaunchSpecService.cs` — stamp after creating the handler task
- `src/ClaudeDo.Ui/ViewModels/Islands/TaskRowViewModel.cs``HandlerBaseCommit`, `IsHandlerRun`, `HandlerBadge`, `ManualBadge` precedence
- `src/ClaudeDo.Ui/Views/Islands/TaskRowView.axaml` — HANDLER badge border
- `src/ClaudeDo.Ui/Design/IslandStyles.axaml``HandlerBadgeBrush` + `Border.badge.handler`
- `src/ClaudeDo.Localization/locales/en.json` + `de.json``tasks.badgeHandler`, `tasks.handlerTip`
- `src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs``HandledTasks` collection, loader, clear, refresh
- `src/ClaudeDo.Ui/Views/Islands/Detail/WorkConsole.axaml` — HANDLED TASKS panel
- `src/ClaudeDo.Data/CLAUDE.md`, `src/ClaudeDo.Ui/CLAUDE.md`, `docs/explore-notes/conpty-sessions.md` — docs
**Created:**
- `src/ClaudeDo.Data/Migrations/<timestamp>_AddHandlerTaskId.cs` (+ Designer, + snapshot update) — generated
- `tests/ClaudeDo.Worker.Tests/Repositories/TaskRepositoryHandlerLinkTests.cs`
- `tests/ClaudeDo.Ui.Tests/ViewModels/TaskRowViewModelHandlerBadgeTests.cs`
- `tests/ClaudeDo.Ui.Tests/ViewModels/DetailsIslandHandledTasksTests.cs`
---
## Task 1: Data — `HandlerTaskId` column and migration
**Files:**
- Modify: `src/ClaudeDo.Data/Models/TaskEntity.cs:60-61`
- Modify: `src/ClaudeDo.Data/Configuration/TaskEntityConfiguration.cs:96-97` and `:127-131`
- Create: `src/ClaudeDo.Data/Migrations/<timestamp>_AddHandlerTaskId.cs` (generated)
- [ ] **Step 1: Add the property**
In `src/ClaudeDo.Data/Models/TaskEntity.cs`, directly after the existing `HandlerHeadCommit` line (`public string? HandlerHeadCommit { get; set; }`), add:
```csharp
// Id of the "list handler" run task that processed this task ("Let Claude handle it").
// 1:n and last-run-wins -- a second handler run over the same task overwrites it. Deliberately
// NOT ParentTaskId: that is the planning-child relation and drives the indented tree rendering.
// No FK: a deleted handler task must not cascade into the tasks it merely touched.
public string? HandlerTaskId { get; set; }
```
- [ ] **Step 2: Map the column and index it**
In `src/ClaudeDo.Data/Configuration/TaskEntityConfiguration.cs`, after the line
`builder.Property(t => t.HandlerHeadCommit).HasColumnName("handler_head_commit");` add:
```csharp
builder.Property(t => t.HandlerTaskId).HasColumnName("handler_task_id");
```
At the end of `Configure`, after the line
`builder.HasIndex(t => t.BlockedByTaskId).HasDatabaseName("idx_tasks_blocked_by");` add:
```csharp
builder.HasIndex(t => t.HandlerTaskId).HasDatabaseName("idx_tasks_handler_task_id");
```
Do **not** add a `HasOne`/`HasForeignKey` relationship — the column is intentionally FK-less.
- [ ] **Step 3: Generate the migration**
Run from the repo root:
```bash
dotnet ef migrations add AddHandlerTaskId --project src/ClaudeDo.Data/ClaudeDo.Data.csproj --startup-project src/ClaudeDo.Worker/ClaudeDo.Worker.csproj
```
Expected: creates `src/ClaudeDo.Data/Migrations/<timestamp>_AddHandlerTaskId.cs` + `.Designer.cs` and updates `ClaudeDoDbContextModelSnapshot.cs`. The `Up` method must contain exactly one `AddColumn<string>(name: "handler_task_id", table: "tasks", nullable: true)` and one `CreateIndex(name: "idx_tasks_handler_task_id", table: "tasks", column: "handler_task_id")`. If it contains anything else, another agent's uncommitted model change leaked in — delete the migration, coordinate, retry.
If `dotnet ef` is unavailable, hand-author the migration + Designer mirroring
`src/ClaudeDo.Data/Migrations/20260806111454_AddInteractiveSessionId.cs`, and add
`Property<string>("HandlerTaskId").HasColumnType("TEXT").HasColumnName("handler_task_id");`
plus the index to the `TaskEntity` builder in `ClaudeDoDbContextModelSnapshot.cs`.
- [ ] **Step 4: Build**
Run: `dotnet build src/ClaudeDo.Data/ClaudeDo.Data.csproj -c Release`
Expected: `Build succeeded`, 0 errors.
- [ ] **Step 5: Commit**
```bash
git add src/ClaudeDo.Data/Models/TaskEntity.cs src/ClaudeDo.Data/Configuration/TaskEntityConfiguration.cs src/ClaudeDo.Data/Migrations
git commit -- src/ClaudeDo.Data/Models/TaskEntity.cs src/ClaudeDo.Data/Configuration/TaskEntityConfiguration.cs src/ClaudeDo.Data/Migrations -m "feat(data): add handler_task_id to link handled tasks to their handler run"
```
⚠️ Always commit with explicit paths (`git commit -- <paths>`), never a bare `git commit` — the
main checkout is shared with concurrent sessions.
---
## Task 2: Data — `SetHandlerTaskIdAsync` repository method
**Files:**
- Modify: `src/ClaudeDo.Data/Repositories/TaskRepository.cs` (after `SetHandlerHeadCommitAsync`, currently `:394-403`)
- Test: `tests/ClaudeDo.Worker.Tests/Repositories/TaskRepositoryHandlerLinkTests.cs` (create)
- [ ] **Step 1: Write the failing test**
Create `tests/ClaudeDo.Worker.Tests/Repositories/TaskRepositoryHandlerLinkTests.cs`:
```csharp
using ClaudeDo.Data;
using ClaudeDo.Data.Models;
using ClaudeDo.Data.Repositories;
using ClaudeDo.Worker.Tests.Infrastructure;
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
namespace ClaudeDo.Worker.Tests.Repositories;
/// Covers the handler-run link: SetHandlerTaskIdAsync stamps the tasks a "Let Claude handle it"
/// run processed, so the handler task's detail pane can list them after the run.
public sealed class TaskRepositoryHandlerLinkTests : IDisposable
{
private readonly DbFixture _db = new();
private readonly ClaudeDoDbContext _ctx;
private readonly TaskRepository _tasks;
private readonly ListRepository _lists;
public TaskRepositoryHandlerLinkTests()
{
_ctx = _db.CreateContext();
_tasks = new TaskRepository(_ctx);
_lists = new ListRepository(_ctx);
}
public void Dispose()
{
_ctx.Dispose();
_db.Dispose();
}
private async Task<string> CreateListAsync()
{
var listId = Guid.NewGuid().ToString();
await _lists.AddAsync(new ListEntity
{
Id = listId,
Name = "Test List",
CreatedAt = DateTime.UtcNow,
});
return listId;
}
private async Task<string> AddTaskAsync(string listId)
{
var id = Guid.NewGuid().ToString();
await _tasks.AddAsync(new TaskEntity
{
Id = id,
ListId = listId,
Title = "T",
Status = TaskStatus.Idle,
CreatedAt = DateTime.UtcNow,
});
return id;
}
[Fact]
public async Task SetHandlerTaskIdAsync_StampsAllGivenTasks()
{
var listId = await CreateListAsync();
var a = await AddTaskAsync(listId);
var b = await AddTaskAsync(listId);
var handlerId = await AddTaskAsync(listId);
var affected = await _tasks.SetHandlerTaskIdAsync(new[] { a, b }, handlerId);
Assert.Equal(2, affected);
Assert.Equal(handlerId, (await _tasks.GetByIdAsync(a))!.HandlerTaskId);
Assert.Equal(handlerId, (await _tasks.GetByIdAsync(b))!.HandlerTaskId);
Assert.Null((await _tasks.GetByIdAsync(handlerId))!.HandlerTaskId);
}
[Fact]
public async Task SetHandlerTaskIdAsync_IgnoresUnknownIds()
{
var listId = await CreateListAsync();
var a = await AddTaskAsync(listId);
var handlerId = await AddTaskAsync(listId);
var affected = await _tasks.SetHandlerTaskIdAsync(
new[] { a, "does-not-exist" }, handlerId);
Assert.Equal(1, affected);
Assert.Equal(handlerId, (await _tasks.GetByIdAsync(a))!.HandlerTaskId);
}
[Fact]
public async Task SetHandlerTaskIdAsync_SecondRunOverwrites()
{
var listId = await CreateListAsync();
var a = await AddTaskAsync(listId);
var firstHandler = await AddTaskAsync(listId);
var secondHandler = await AddTaskAsync(listId);
await _tasks.SetHandlerTaskIdAsync(new[] { a }, firstHandler);
await _tasks.SetHandlerTaskIdAsync(new[] { a }, secondHandler);
Assert.Equal(secondHandler, (await _tasks.GetByIdAsync(a))!.HandlerTaskId);
}
[Fact]
public async Task SetHandlerTaskIdAsync_EmptyList_IsNoOp()
{
var listId = await CreateListAsync();
var handlerId = await AddTaskAsync(listId);
var affected = await _tasks.SetHandlerTaskIdAsync(Array.Empty<string>(), handlerId);
Assert.Equal(0, affected);
}
}
```
- [ ] **Step 2: Run the tests to verify they fail**
Run: `dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release --filter "FullyQualifiedName~TaskRepositoryHandlerLinkTests"`
Expected: compile error — `TaskRepository` does not contain a definition for `SetHandlerTaskIdAsync`.
- [ ] **Step 3: Implement the method**
In `src/ClaudeDo.Data/Repositories/TaskRepository.cs`, directly after `SetHandlerHeadCommitAsync`, add:
```csharp
// Links the tasks a "list handler" run processed back to the handler's own task, so the
// handler's detail pane can list them after the run. Stamped from the user's selection at
// creation time -- that way tasks the handler later cancels as duplicates stay visible.
// Unknown ids are silently skipped. Returns the number of rows actually stamped.
public async Task<int> SetHandlerTaskIdAsync(
IReadOnlyList<string> taskIds,
string handlerTaskId,
CancellationToken ct = default)
{
if (taskIds.Count == 0) return 0;
var ids = taskIds.Where(id => id != handlerTaskId).Distinct().ToList();
if (ids.Count == 0) return 0;
return await _context.Tasks
.Where(t => ids.Contains(t.Id))
.ExecuteUpdateAsync(s => s
.SetProperty(t => t.HandlerTaskId, handlerTaskId), ct);
}
```
- [ ] **Step 4: Run the tests to verify they pass**
Run: `dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release --filter "FullyQualifiedName~TaskRepositoryHandlerLinkTests"`
Expected: `Passed! - Failed: 0, Passed: 4`.
- [ ] **Step 5: Commit**
```bash
git add src/ClaudeDo.Data/Repositories/TaskRepository.cs tests/ClaudeDo.Worker.Tests/Repositories/TaskRepositoryHandlerLinkTests.cs
git commit -- src/ClaudeDo.Data/Repositories/TaskRepository.cs tests/ClaudeDo.Worker.Tests/Repositories/TaskRepositoryHandlerLinkTests.cs -m "feat(data): add SetHandlerTaskIdAsync to stamp handled tasks"
```
---
## Task 3: Worker — stamp the selection when the handler task is created
**Files:**
- Modify: `src/ClaudeDo.Worker/Runner/InteractiveLaunchSpecService.cs:409-450`
- Test: `tests/ClaudeDo.Worker.Tests/Runner/InteractiveLaunchSpecServiceTests.cs` (append a `[Fact]` in the `── CreateMergeHelperTaskAsync ──` region, currently starting at `:747`)
Note: `CreateMergeHelperTaskAsync` already receives `IReadOnlyList<string> taskIds` — the UI →
`IWorkerClient``WorkerHub` chain needs **no** change.
- [ ] **Step 1: Write the failing test**
Append to `tests/ClaudeDo.Worker.Tests/Runner/InteractiveLaunchSpecServiceTests.cs` inside the same test class, after the existing `CreateMergeHelperTaskAsync_CreatesIdleManualTask_StampsHandlerBaseCommit` test:
```csharp
[Fact]
public async Task CreateMergeHelperTaskAsync_StampsHandlerTaskIdOnSelectedTasks()
{
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
var repo = CreateRepo();
var listId = await SeedListAsync(workingDir: repo.RepoDir, name: "Alpha");
var t1 = Guid.NewGuid().ToString();
var t2 = Guid.NewGuid().ToString();
await SeedTaskAsync(t1, listId, TaskStatus.WaitingForReview, title: "First task");
await SeedTaskAsync(t2, listId, TaskStatus.Idle, title: "Second task");
var svc = BuildService();
var handlerId = await svc.CreateMergeHelperTaskAsync(
new[] { t1, t2 }, listId, "List handler: Alpha", "Tasks handled by this run:", CancellationToken.None);
using var readCtx = _db.CreateContext();
var tasks = new TaskRepository(readCtx);
Assert.Equal(handlerId, (await tasks.GetByIdAsync(t1))!.HandlerTaskId);
Assert.Equal(handlerId, (await tasks.GetByIdAsync(t2))!.HandlerTaskId);
// The handler never links to itself.
Assert.Null((await tasks.GetByIdAsync(handlerId))!.HandlerTaskId);
}
```
- [ ] **Step 2: Run the test to verify it fails**
Run: `dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release --filter "FullyQualifiedName~CreateMergeHelperTaskAsync_StampsHandlerTaskIdOnSelectedTasks"`
Expected: FAIL — `Assert.Equal() Failure: Values differ … Actual: null`.
- [ ] **Step 3: Stamp the selection**
In `src/ClaudeDo.Worker/Runner/InteractiveLaunchSpecService.cs`, in `CreateMergeHelperTaskAsync`, replace:
```csharp
await taskRepo.AddAsync(handlerTask, ct);
return handlerTask.Id;
```
with:
```csharp
await taskRepo.AddAsync(handlerTask, ct);
// Link the selection back to this run BEFORE the session starts: the handler cancels
// duplicates in phase 1, and those still belong in the "what was this run supposed to do"
// list. Stamping later (e.g. at handoff) would lose them.
await taskRepo.SetHandlerTaskIdAsync(taskIds, handlerTask.Id, ct);
return handlerTask.Id;
```
- [ ] **Step 4: Run the test to verify it passes**
Run: `dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release --filter "FullyQualifiedName~CreateMergeHelperTaskAsync"`
Expected: `Passed! - Failed: 0` (all five `CreateMergeHelperTaskAsync` tests).
- [ ] **Step 5: Commit**
```bash
git add src/ClaudeDo.Worker/Runner/InteractiveLaunchSpecService.cs tests/ClaudeDo.Worker.Tests/Runner/InteractiveLaunchSpecServiceTests.cs
git commit -- src/ClaudeDo.Worker/Runner/InteractiveLaunchSpecService.cs tests/ClaudeDo.Worker.Tests/Runner/InteractiveLaunchSpecServiceTests.cs -m "feat(handler): link the selected tasks to the handler run task"
```
---
## Task 4: Ui — HANDLER badge instead of MANUAL
**Files:**
- Modify: `src/ClaudeDo.Ui/ViewModels/Islands/TaskRowViewModel.cs:40,51,234-240,308-329`
- Modify: `src/ClaudeDo.Ui/Views/Islands/TaskRowView.axaml:141-144`
- Modify: `src/ClaudeDo.Ui/Design/IslandStyles.axaml:114-118` and `:987-990`
- Modify: `src/ClaudeDo.Localization/locales/en.json:163-164`, `src/ClaudeDo.Localization/locales/de.json:163-164`
- Test: `tests/ClaudeDo.Ui.Tests/ViewModels/TaskRowViewModelHandlerBadgeTests.cs` (create)
- [ ] **Step 1: Write the failing test**
Create `tests/ClaudeDo.Ui.Tests/ViewModels/TaskRowViewModelHandlerBadgeTests.cs`:
```csharp
using ClaudeDo.Data.Models;
using ClaudeDo.Ui.ViewModels.Islands;
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
namespace ClaudeDo.Ui.Tests.ViewModels;
/// A "list handler" host task is IsManual=true so automation skips it, but MANUAL reads wrong on
/// it -- the HANDLER badge must win and MANUAL must disappear.
public class TaskRowViewModelHandlerBadgeTests
{
[Fact]
public void HandlerTask_ShowsHandlerBadge_AndSuppressesManualBadge()
{
var row = new TaskRowViewModel { Id = "t1" };
row.IsManual = true;
row.HandlerBaseCommit = "base123";
Assert.True(row.IsHandlerRun);
Assert.NotNull(row.HandlerBadge);
Assert.Null(row.ManualBadge);
}
[Fact]
public void PlainManualTask_StillShowsManualBadge()
{
var row = new TaskRowViewModel { Id = "t2" };
row.IsManual = true;
Assert.False(row.IsHandlerRun);
Assert.Null(row.HandlerBadge);
Assert.NotNull(row.ManualBadge);
}
[Fact]
public void UpdateFromEntity_MirrorsHandlerBaseCommit()
{
var row = new TaskRowViewModel { Id = "t3" };
row.UpdateFromEntity(new TaskEntity
{
Id = "t3",
ListId = "l1",
Title = "List handler: Alpha",
Status = TaskStatus.Idle,
IsManual = true,
HandlerBaseCommit = "base123",
CreatedAt = DateTime.UtcNow,
});
Assert.Equal("base123", row.HandlerBaseCommit);
Assert.True(row.IsHandlerRun);
}
}
```
- [ ] **Step 2: Run the test to verify it fails**
Run: `dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj -c Release --filter "FullyQualifiedName~TaskRowViewModelHandlerBadgeTests"`
Expected: compile error — `TaskRowViewModel` has no `HandlerBaseCommit` / `IsHandlerRun` / `HandlerBadge`.
- [ ] **Step 3: Add the properties**
In `src/ClaudeDo.Ui/ViewModels/Islands/TaskRowViewModel.cs`, after the `_isManual` field
declaration (`[ObservableProperty] private bool _isManual;`), add:
```csharp
// Mirror of TaskEntity.HandlerBaseCommit -- non-null marks this row as a "list handler" run
// host task ("Let Claude handle it"), which wears HANDLER instead of MANUAL.
[ObservableProperty] private string? _handlerBaseCommit;
```
Replace the `ManualBadge` line (currently `public string? ManualBadge => IsManual ? Loc.T("tasks.badgeManual") : null;`) with:
```csharp
public bool IsHandlerRun => !string.IsNullOrEmpty(HandlerBaseCommit);
public string? HandlerBadge => IsHandlerRun ? Loc.T("tasks.badgeHandler") : null;
// HANDLER outranks MANUAL: a handler host task is IsManual only so automation skips it, and
// "MANUAL" would read as a hand-written reminder. The two badges never show together.
public bool ShowManualBadge => IsManual && !IsHandlerRun;
public string? ManualBadge => ShowManualBadge ? Loc.T("tasks.badgeManual") : null;
```
Add a change hook next to the existing `OnIsManualChanged` partial method:
```csharp
partial void OnHandlerBaseCommitChanged(string? value)
{
OnPropertyChanged(nameof(IsHandlerRun));
OnPropertyChanged(nameof(HandlerBadge));
OnPropertyChanged(nameof(ShowManualBadge));
OnPropertyChanged(nameof(ManualBadge));
}
```
Inside the existing `OnIsManualChanged`, next to the existing `OnPropertyChanged(nameof(ManualBadge));` line, add:
```csharp
OnPropertyChanged(nameof(ShowManualBadge));
```
In `UpdateFromEntity`, after the line `IsManual = t.IsManual;` add:
```csharp
HandlerBaseCommit = t.HandlerBaseCommit;
```
Also add `HandlerBadge` to `RefreshLocalized`, next to the existing `PlanningBadge` line:
```csharp
OnPropertyChanged(nameof(HandlerBadge));
OnPropertyChanged(nameof(ManualBadge));
```
- [ ] **Step 4: Add the locale keys**
In `src/ClaudeDo.Localization/locales/en.json`, after `"manualTip": ...` (line 164) add:
```json
"badgeHandler": "HANDLER",
"handlerTip": "Handler run — see the tasks it processed in the detail pane",
```
In `src/ClaudeDo.Localization/locales/de.json`, after `"manualTip": ...` (line 164) add:
```json
"badgeHandler": "HANDLER",
"handlerTip": "Handler-Run — die bearbeiteten Tasks stehen im Detailbereich",
```
- [ ] **Step 5: Add the badge style and brush**
In `src/ClaudeDo.Ui/Design/IslandStyles.axaml`, after the line
`<SolidColorBrush x:Key="ManualBadgeBrush" Color="{StaticResource TextFaintColor}"/>` add:
```xml
<SolidColorBrush x:Key="HandlerBadgeBrush" Color="{StaticResource PeatSoftColor}"/>
```
After the existing `Border.badge.manual` style block add:
```xml
<!-- handler → peat: a "Let Claude handle it" run host, not a hand-written reminder -->
<Style Selector="Border.badge.handler">
<Setter Property="Background" Value="{DynamicResource HandlerBadgeBrush}"/>
</Style>
```
- [ ] **Step 6: Render the badge**
In `src/ClaudeDo.Ui/Views/Islands/TaskRowView.axaml`, replace the manual badge block (lines 141-144):
```xml
<Border Classes="badge manual" IsVisible="{Binding IsManual}"
ToolTip.Tip="{loc:Tr tasks.manualTip}">
<TextBlock Text="{Binding ManualBadge}"/>
</Border>
```
with:
```xml
<Border Classes="badge manual" IsVisible="{Binding ShowManualBadge}"
ToolTip.Tip="{loc:Tr tasks.manualTip}">
<TextBlock Text="{Binding ManualBadge}"/>
</Border>
<Border Classes="badge handler" IsVisible="{Binding IsHandlerRun}"
ToolTip.Tip="{loc:Tr tasks.handlerTip}">
<TextBlock Text="{Binding HandlerBadge}"/>
</Border>
```
Only the `IsVisible` binding changed on the manual border (`IsManual``ShowManualBadge`); the
handler border is new. No converter is needed — `ShowManualBadge` is already a `bool`.
- [ ] **Step 7: Run tests to verify they pass**
Run: `dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj -c Release --filter "FullyQualifiedName~TaskRowViewModelHandlerBadgeTests"`
Expected: `Passed! - Failed: 0, Passed: 3`.
Run: `dotnet test tests/ClaudeDo.Localization.Tests/ClaudeDo.Localization.Tests.csproj -c Release`
Expected: `Passed! - Failed: 0` (en/de key parity).
Run: `dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj -c Release`
Expected: `Build succeeded` — this compiles the AXAML.
- [ ] **Step 8: Commit**
```bash
git add src/ClaudeDo.Ui/ViewModels/Islands/TaskRowViewModel.cs src/ClaudeDo.Ui/Views/Islands/TaskRowView.axaml src/ClaudeDo.Ui/Design/IslandStyles.axaml src/ClaudeDo.Localization/locales/en.json src/ClaudeDo.Localization/locales/de.json tests/ClaudeDo.Ui.Tests/ViewModels/TaskRowViewModelHandlerBadgeTests.cs
git commit -- src/ClaudeDo.Ui/ViewModels/Islands/TaskRowViewModel.cs src/ClaudeDo.Ui/Views/Islands/TaskRowView.axaml src/ClaudeDo.Ui/Design/IslandStyles.axaml src/ClaudeDo.Localization/locales/en.json src/ClaudeDo.Localization/locales/de.json tests/ClaudeDo.Ui.Tests/ViewModels/TaskRowViewModelHandlerBadgeTests.cs -m "feat(ui): show a HANDLER badge on list-handler run tasks"
```
---
## Task 5: Ui — "HANDLED TASKS" panel on the handler's detail pane
**Files:**
- Modify: `src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs:248-255`, `:581-584`, `:685`, `:814-833`
- Modify: `src/ClaudeDo.Ui/Views/Islands/Detail/WorkConsole.axaml:414-434`
- Test: `tests/ClaudeDo.Ui.Tests/ViewModels/DetailsIslandHandledTasksTests.cs` (create)
- [ ] **Step 1: Write the failing test**
Create `tests/ClaudeDo.Ui.Tests/ViewModels/DetailsIslandHandledTasksTests.cs`:
```csharp
using ClaudeDo.Data;
using ClaudeDo.Data.Models;
using ClaudeDo.Ui.Services;
using ClaudeDo.Ui.ViewModels.Islands;
using Microsoft.EntityFrameworkCore;
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
namespace ClaudeDo.Ui.Tests.ViewModels;
/// Covers the handler-run link: binding a "list handler" host task lists every task stamped with
/// its id, including ones the handler cancelled as duplicates.
public class DetailsIslandHandledTasksTests : IDisposable
{
private readonly string _dbPath;
public DetailsIslandHandledTasksTests()
{
_dbPath = Path.Combine(Path.GetTempPath(), $"claudedo_details_handled_test_{Guid.NewGuid():N}.db");
using var ctx = NewContext();
ctx.Database.EnsureCreated();
}
public void Dispose()
{
try { File.Delete(_dbPath); } catch { }
try { File.Delete(_dbPath + "-wal"); } catch { }
try { File.Delete(_dbPath + "-shm"); } catch { }
}
private ClaudeDoDbContext NewContext()
{
var opts = new DbContextOptionsBuilder<ClaudeDoDbContext>()
.UseSqlite($"Data Source={_dbPath}")
.Options;
return new ClaudeDoDbContext(opts);
}
private sealed class TestDbFactory : IDbContextFactory<ClaudeDoDbContext>
{
private readonly Func<ClaudeDoDbContext> _create;
public TestDbFactory(Func<ClaudeDoDbContext> create) => _create = create;
public ClaudeDoDbContext CreateDbContext() => _create();
}
private sealed class NullServiceProvider : IServiceProvider
{
public object? GetService(Type serviceType) => null;
}
private sealed class StubNotesApi : ClaudeDo.Ui.Services.Interfaces.INotesApi
{
public Task<List<DailyNoteDto>> ListAsync(DateOnly day) =>
Task.FromResult(new List<DailyNoteDto>());
public Task<DailyNoteDto?> AddAsync(DateOnly day, string text) =>
Task.FromResult<DailyNoteDto?>(null);
public Task UpdateAsync(string id, string text) => Task.CompletedTask;
public Task DeleteAsync(string id) => Task.CompletedTask;
}
private sealed class FakeWorkerClient : StubWorkerClient
{
public override bool IsConnected => true;
}
private DetailsIslandViewModel BuildVm()
{
var factory = new TestDbFactory(NewContext);
return new DetailsIslandViewModel(
factory, new FakeWorkerClient(), new NullServiceProvider(), new StubNotesApi(), new MergeCoordinator());
}
[Fact]
public async Task Bind_HandlerTask_ListsHandledTasksWithTheirStatus()
{
const string listId = "list-1";
const string handlerId = "handler-task-1";
await using (var ctx = NewContext())
{
ctx.Lists.Add(new ListEntity { Id = listId, Name = "L", WorkingDir = @"C:\repo", CreatedAt = DateTime.UtcNow });
ctx.Tasks.Add(new TaskEntity
{
Id = handlerId, ListId = listId, Title = "List handler: L",
Status = TaskStatus.WaitingForReview, IsManual = true,
HandlerBaseCommit = "base123", HandlerHeadCommit = "head456",
CreatedAt = DateTime.UtcNow,
});
ctx.Tasks.Add(new TaskEntity
{
Id = "done-1", ListId = listId, Title = "Merged task",
Status = TaskStatus.Done, HandlerTaskId = handlerId,
SortOrder = 0, CreatedAt = DateTime.UtcNow,
});
ctx.Tasks.Add(new TaskEntity
{
Id = "dupe-1", ListId = listId, Title = "Duplicate the handler cancelled",
Status = TaskStatus.Cancelled, HandlerTaskId = handlerId,
SortOrder = 1, CreatedAt = DateTime.UtcNow,
});
ctx.Tasks.Add(new TaskEntity
{
Id = "unrelated-1", ListId = listId, Title = "Not part of the run",
Status = TaskStatus.Idle, CreatedAt = DateTime.UtcNow,
});
await ctx.SaveChangesAsync();
}
var vm = BuildVm();
vm.Bind(new TaskRowViewModel { Id = handlerId, Status = TaskStatus.WaitingForReview });
var deadline = DateTime.UtcNow.AddSeconds(5);
while (DateTime.UtcNow < deadline && vm.HandledTasks.Count == 0)
await Task.Delay(20);
Assert.Equal(2, vm.HandledTasks.Count);
Assert.True(vm.HasHandledTasks);
Assert.Equal("Merged task", vm.HandledTasks[0].Title);
Assert.Equal(TaskStatus.Done, vm.HandledTasks[0].Status);
Assert.Equal(TaskStatus.Cancelled, vm.HandledTasks[1].Status);
Assert.DoesNotContain(vm.HandledTasks, r => r.Id == "unrelated-1");
}
[Fact]
public async Task Bind_PlainTask_HasNoHandledTasks()
{
const string listId = "list-1";
const string taskId = "plain-1";
await using (var ctx = NewContext())
{
ctx.Lists.Add(new ListEntity { Id = listId, Name = "L", CreatedAt = DateTime.UtcNow });
ctx.Tasks.Add(new TaskEntity
{
Id = taskId, ListId = listId, Title = "Plain",
Status = TaskStatus.Idle, CreatedAt = DateTime.UtcNow,
});
await ctx.SaveChangesAsync();
}
var vm = BuildVm();
vm.Bind(new TaskRowViewModel { Id = taskId, Status = TaskStatus.Idle });
await Task.Delay(300);
Assert.Empty(vm.HandledTasks);
Assert.False(vm.HasHandledTasks);
}
}
```
- [ ] **Step 2: Run the tests to verify they fail**
Run: `dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj -c Release --filter "FullyQualifiedName~DetailsIslandHandledTasksTests"`
Expected: compile error — `DetailsIslandViewModel` has no `HandledTasks` / `HasHandledTasks`.
- [ ] **Step 3: Add the collection**
In `src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs`, after the line
`public ObservableCollection<ChildOutcomeRowViewModel> ChildOutcomes { get; } = new();` add:
```csharp
// Tasks a "list handler" run processed ("Let Claude handle it"), linked via
// TaskEntity.HandlerTaskId. Separate from ChildOutcomes on purpose: that collection is the
// planning/improvement parent's children and feeds the merge card's combined diff, which a
// handler run must not touch (it commits straight to the list's working dir).
public ObservableCollection<ChildOutcomeRowViewModel> HandledTasks { get; } = new();
```
After the line `public bool HasChildOutcomes => ChildOutcomes.Count > 0;` add:
```csharp
public bool HasHandledTasks => HandledTasks.Count > 0;
```
- [ ] **Step 4: Clear it on rebind**
In the same file, in the rebind reset block, after the line `ChildOutcomes.Clear();` add:
```csharp
HandledTasks.Clear();
```
and after `OnPropertyChanged(nameof(HasChildOutcomes));` in that same block add:
```csharp
OnPropertyChanged(nameof(HasHandledTasks));
```
- [ ] **Step 5: Load it on bind**
In the same file, directly after the line `await LoadChildOutcomesAsync(row.Id, ct);` add:
```csharp
await LoadHandledTasksAsync(row.Id, ct);
```
Then add the loader immediately after the closing brace of `LoadChildOutcomesAsync`:
```csharp
// Tasks stamped with this handler run's id. Ordered like the task list itself so the panel
// reads in the same order the user picked them.
private async System.Threading.Tasks.Task LoadHandledTasksAsync(string handlerTaskId, CancellationToken ct)
{
try
{
await using var ctx = await _dbFactory.CreateDbContextAsync(ct);
var handled = await ctx.Tasks
.AsNoTracking()
.Include(t => t.Worktree)
.Where(t => t.HandlerTaskId == handlerTaskId)
.OrderBy(t => t.SortOrder).ThenBy(t => t.CreatedAt)
.ToListAsync(ct);
ct.ThrowIfCancellationRequested();
if (handled.Count == 0) return;
HandledTasks.Clear();
foreach (var h in handled)
HandledTasks.Add(new ChildOutcomeRowViewModel
{
Id = h.Id,
Title = h.Title,
Status = h.Status,
RoadblockCount = h.RoadblockCount,
WorktreeState = h.Worktree?.State ?? ClaudeDo.Data.Models.WorktreeState.Active,
});
OnPropertyChanged(nameof(HasHandledTasks));
}
catch (OperationCanceledException) { }
catch { /* best-effort */ }
}
```
- [ ] **Step 6: Keep the rows live**
In the same file, in `RefreshChildOutcomeAsync`, replace:
```csharp
var row = ChildOutcomes.FirstOrDefault(c => c.Id == childTaskId);
if (row is null) return;
```
with:
```csharp
// The same refresh serves both lists: a planning parent's children and a handler run's
// handled tasks. Only one of them can hold a given id.
var row = ChildOutcomes.FirstOrDefault(c => c.Id == childTaskId)
?? HandledTasks.FirstOrDefault(c => c.Id == childTaskId);
if (row is null) return;
```
- [ ] **Step 7: Render the panel**
In `src/ClaudeDo.Ui/Views/Islands/Detail/WorkConsole.axaml`, directly after the closing
`</StackPanel>` of the existing `<!-- Child outcomes -->` block, add:
```xml
<!-- Handled tasks (list handler run) -->
<StackPanel Spacing="6" IsVisible="{Binding HasHandledTasks}">
<TextBlock Classes="section-label" Text="HANDLED TASKS" />
<ItemsControl ItemsSource="{Binding HandledTasks}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:ChildOutcomeRowViewModel">
<Grid ColumnDefinitions="*,Auto,Auto" Margin="0,2">
<TextBlock Grid.Column="0" Text="{Binding Title}"
TextTrimming="CharacterEllipsis"
VerticalAlignment="Center" />
<TextBlock Grid.Column="1" Text="{Binding RoadblockText}"
IsVisible="{Binding HasRoadblock}"
Foreground="#E0A030"
Margin="8,0" VerticalAlignment="Center" />
<TextBlock Grid.Column="2" Text="{Binding StatusLabel}"
Opacity="0.75" VerticalAlignment="Center" />
</Grid>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</StackPanel>
```
- [ ] **Step 8: Run the tests to verify they pass**
Run: `dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj -c Release --filter "FullyQualifiedName~DetailsIslandHandledTasksTests"`
Expected: `Passed! - Failed: 0, Passed: 2`.
Run: `dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj -c Release`
Expected: `Build succeeded`.
- [ ] **Step 9: Commit**
```bash
git add src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs src/ClaudeDo.Ui/Views/Islands/Detail/WorkConsole.axaml tests/ClaudeDo.Ui.Tests/ViewModels/DetailsIslandHandledTasksTests.cs
git commit -- src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs src/ClaudeDo.Ui/Views/Islands/Detail/WorkConsole.axaml tests/ClaudeDo.Ui.Tests/ViewModels/DetailsIslandHandledTasksTests.cs -m "feat(ui): list the tasks a handler run processed on its detail pane"
```
---
## Task 6: Full verification and docs
**Files:**
- Modify: `src/ClaudeDo.Data/CLAUDE.md` (TaskEntity field list)
- Modify: `src/ClaudeDo.Ui/CLAUDE.md` (TaskRowViewModel + DetailsIslandViewModel bullets)
- Modify: `docs/explore-notes/conpty-sessions.md` (list handler → host task section)
- [ ] **Step 1: Run every affected test project**
```bash
dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release
dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj -c Release
dotnet test tests/ClaudeDo.Data.Tests/ClaudeDo.Data.Tests.csproj -c Release
dotnet test tests/ClaudeDo.Localization.Tests/ClaudeDo.Localization.Tests.csproj -c Release
```
Expected: `Failed: 0` in all four. If a hand-rolled fake in a test project fails to compile,
it is one of the known `IWorkerClient`/ViewModel-ctor fakes — update it; do not skip the test.
- [ ] **Step 2: Update `src/ClaudeDo.Data/CLAUDE.md`**
In the `TaskEntity` bullet, append `HandlerTaskId` to the field enumeration (after
`HandlerBaseCommit / HandlerHeadCommit`), and add a sub-bullet under the existing
`HandlerBaseCommit`/`HandlerHeadCommit` sub-bullet:
```markdown
- `HandlerTaskId` = back-link from a task to the **list handler run** that processed it (1:n, last run wins, no FK). Stamped from the user's selection when the handler task is created, so tasks the handler later cancels as duplicates stay listed. Deliberately not `ParentTaskId` — that is the planning-child relation and drives the indented tree.
```
- [ ] **Step 3: Update `src/ClaudeDo.Ui/CLAUDE.md`**
In the `DetailsIslandViewModel` bullet, after the `ChildOutcomes` mention, add
`, plus `HandledTasks` (tasks a list-handler run processed, via `HandlerTaskId`)`.
In the `TaskRowViewModel` sentence, after the `IsManual` clause, add
`, `IsHandlerRun` (→ HANDLER badge, which outranks MANUAL)`.
- [ ] **Step 4: Update `docs/explore-notes/conpty-sessions.md`**
In the "The host task and its commit range" section, add after the existing description:
```markdown
`CreateMergeHelperTaskAsync` also stamps `TaskEntity.HandlerTaskId` on every selected task
(`TaskRepository.SetHandlerTaskIdAsync`) before the session starts, so the handler task's detail
pane can list what the run was meant to process — including tasks phase 1 cancels as duplicates.
The handler never links to itself.
```
Bump that note's "verified against" commit line to the current HEAD.
- [ ] **Step 5: Commit**
```bash
git add src/ClaudeDo.Data/CLAUDE.md src/ClaudeDo.Ui/CLAUDE.md docs/explore-notes/conpty-sessions.md
git commit -- src/ClaudeDo.Data/CLAUDE.md src/ClaudeDo.Ui/CLAUDE.md docs/explore-notes/conpty-sessions.md -m "docs(handler): document the handler-run task link"
```
- [ ] **Step 6: Report the visual-verification gap**
The build and tests cannot confirm any of this renders correctly. Explicitly hand these to Mika:
1. HANDLER badge colour and legibility on a handler task row (light **and** dark theme), and that MANUAL is gone from that row while still present on a normal manual reminder.
2. The HANDLED TASKS panel on the handler task's Session tab: position relative to OUTCOMES, spacing, and behaviour with ~20 handled tasks (scroll).
3. That a real "Let Claude handle it" run over a multi-task selection produces a populated panel after the run, including a phase-1-cancelled duplicate.