Files
ClaudeDo/docs/superpowers/plans/2026-08-07-handler-run-links.md
T

38 KiB

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.csSetHandlerTaskIdAsync
  • src/ClaudeDo.Worker/Runner/InteractiveLaunchSpecService.cs — stamp after creating the handler task
  • src/ClaudeDo.Ui/ViewModels/Islands/TaskRowViewModel.csHandlerBaseCommit, IsHandlerRun, HandlerBadge, ManualBadge precedence
  • src/ClaudeDo.Ui/Views/Islands/TaskRowView.axaml — HANDLER badge border
  • src/ClaudeDo.Ui/Design/IslandStyles.axamlHandlerBadgeBrush + Border.badge.handler
  • src/ClaudeDo.Localization/locales/en.json + de.jsontasks.badgeHandler, tasks.handlerTip
  • src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.csHandledTasks 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:


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

        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:

        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:

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

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:

    // 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
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 → IWorkerClientWorkerHub 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:

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

        await taskRepo.AddAsync(handlerTask, ct);

        return handlerTask.Id;

with:

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

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:

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

    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:

    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:

        OnPropertyChanged(nameof(ShowManualBadge));

In UpdateFromEntity, after the line IsManual = t.IsManual; add:

        HandlerBaseCommit = t.HandlerBaseCommit;

Also add HandlerBadge to RefreshLocalized, next to the existing PlanningBadge line:

        OnPropertyChanged(nameof(HandlerBadge));
        OnPropertyChanged(nameof(ManualBadge));
  • Step 4: Add the locale keys

In src/ClaudeDo.Localization/locales/en.json, after "manualTip": ... (line 164) add:

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

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

    <SolidColorBrush x:Key="HandlerBadgeBrush" Color="{StaticResource PeatSoftColor}"/>

After the existing Border.badge.manual style block add:

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

              <Border Classes="badge manual" IsVisible="{Binding IsManual}"
                      ToolTip.Tip="{loc:Tr tasks.manualTip}">
                <TextBlock Text="{Binding ManualBadge}"/>
              </Border>

with:

              <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 (IsManualShowManualBadge); 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
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:

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:

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

    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:

        HandledTasks.Clear();

and after OnPropertyChanged(nameof(HasChildOutcomes)); in that same block add:

        OnPropertyChanged(nameof(HasHandledTasks));
  • Step 5: Load it on bind

In the same file, directly after the line await LoadChildOutcomesAsync(row.Id, ct); add:

            await LoadHandledTasksAsync(row.Id, ct);

Then add the loader immediately after the closing brace of LoadChildOutcomesAsync:

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

        var row = ChildOutcomes.FirstOrDefault(c => c.Id == childTaskId);
        if (row is null) return;

with:

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

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

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:

  - `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, viaHandlerTaskId).

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:

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