diff --git a/docs/superpowers/plans/2026-08-07-handler-run-links.md b/docs/superpowers/plans/2026-08-07-handler-run-links.md new file mode 100644 index 00000000..141b4672 --- /dev/null +++ b/docs/superpowers/plans/2026-08-07-handler-run-links.md @@ -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/_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. diff --git a/docs/superpowers/specs/2026-08-07-handler-run-links-design.md b/docs/superpowers/specs/2026-08-07-handler-run-links-design.md index 38b8cd49..3702d674 100644 --- a/docs/superpowers/specs/2026-08-07-handler-run-links-design.md +++ b/docs/superpowers/specs/2026-08-07-handler-run-links-design.md @@ -134,7 +134,9 @@ Kinder-Panel: - Refresh über dieselben `TaskUpdated`-Events wie `ChildOutcomes`; der bestehende `RefreshChildOutcomeAsync`-Pfad (`:814-833`) wird um die zweite Collection erweitert. - Sichtbar nur wenn `HandledTasks.Count > 0`. -- Klick auf eine Zeile springt zum Task — gleiche Interaktion wie bei den Kindern. +- **Keine Klick-Interaktion** — das bestehende `ChildOutcomes`-Template ist eine reine Anzeige + (Titel / Roadblock / Status, kein Tapped-Handler). Die neue Liste bleibt identisch; "zum Task + springen" wäre neues Verhalten und ist hier nicht enthalten. ### 5. Fehlerfälle