# 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/_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/_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/_AddHandlerTaskId.cs` + `.Designer.cs` and updates `ClaudeDoDbContextModelSnapshot.cs`. The `Up` method must contain exactly one `AddColumn(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("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 -- `), 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 CreateListAsync() { var listId = Guid.NewGuid().ToString(); await _lists.AddAsync(new ListEntity { Id = listId, Name = "Test List", CreatedAt = DateTime.UtcNow, }); return listId; } private async Task 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(), 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 SetHandlerTaskIdAsync( IReadOnlyList 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 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 `` add: ```xml ``` After the existing `Border.badge.manual` style block add: ```xml ``` - [ ] **Step 6: Render the badge** In `src/ClaudeDo.Ui/Views/Islands/TaskRowView.axaml`, replace the manual badge block (lines 141-144): ```xml ``` with: ```xml ``` 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() .UseSqlite($"Data Source={_dbPath}") .Options; return new ClaudeDoDbContext(opts); } private sealed class TestDbFactory : IDbContextFactory { private readonly Func _create; public TestDbFactory(Func 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> ListAsync(DateOnly day) => Task.FromResult(new List()); public Task AddAsync(DateOnly day, string text) => Task.FromResult(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 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 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 `` of the existing `` block, add: ```xml ``` - [ ] **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.