Files
ClaudeDo/docs/superpowers/plans/2026-04-14-ui-fixes.md
Mika Kuns a6fe91d106 docs(ui): add implementation plan for UI fixes
23 tasks covering: StreamLineFormatter (TDD), LiveText display,
start feedback, log reload, modal theming, and config editors.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 16:15:15 +02:00

44 KiB

UI Fixes 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: Fix four post-integration issues: raw NDJSON display, missing start feedback, lost live output, and missing config editors + modal theming.

Architecture: A new StreamLineFormatter in the UI layer parses NDJSON for display. TaskDetailViewModel switches from ObservableCollection<string> to a single string property. Optimistic UI feedback via a local RunNowRequestedEvent. Existing editor dialogs get config sections and proper theming.

Tech Stack: .NET 8, Avalonia 12 (Fluent dark theme), CommunityToolkit.Mvvm, System.Text.Json, xUnit


File Structure

New Files

  • src/ClaudeDo.Ui/Helpers/StreamLineFormatter.cs — NDJSON-to-text parser for UI display
  • tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj — test project for UI helpers
  • tests/ClaudeDo.Ui.Tests/Helpers/StreamLineFormatterTests.cs — formatter unit tests

Modified Files

  • src/ClaudeDo.Ui/ViewModels/TaskDetailViewModel.cs — LiveText, formatter, start feedback, log reload
  • src/ClaudeDo.Ui/Views/TaskDetailView.axaml — TextBox replaces ItemsControl, auto-scroll
  • src/ClaudeDo.Ui/Views/TaskDetailView.axaml.cs — auto-scroll handler
  • src/ClaudeDo.Ui/Services/WorkerClient.cs — RunNowRequestedEvent, GetAgentsAsync, AgentInfo DTO
  • src/ClaudeDo.Ui/ViewModels/TaskItemViewModel.cs — IsStarting property
  • src/ClaudeDo.Ui/ViewModels/TaskListViewModel.cs — wire RunNowRequestedEvent
  • src/ClaudeDo.Ui/Views/TaskListView.axaml — starting state visual
  • src/ClaudeDo.Ui/ViewModels/ListEditorViewModel.cs — config fields, agent loading
  • src/ClaudeDo.Ui/Views/ListEditorView.axaml — config section, theming
  • src/ClaudeDo.Ui/ViewModels/TaskEditorViewModel.cs — config override fields
  • src/ClaudeDo.Ui/Views/TaskEditorView.axaml — config section, theming
  • src/ClaudeDo.Worker/Runner/TaskRunner.cs — default model fallback
  • ClaudeDo.slnx — add new test project

Task 1: Create UI test project

Files:

  • Create: tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj

  • Step 1: Create the test project

<!-- tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj -->
<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <TargetFramework>net8.0</TargetFramework>
    <Nullable>enable</Nullable>
    <IsPackable>false</IsPackable>
  </PropertyGroup>
  <ItemGroup>
    <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
    <PackageReference Include="xunit" Version="2.9.3" />
    <PackageReference Include="xunit.runner.visualstudio" Version="3.0.1" />
  </ItemGroup>
  <ItemGroup>
    <ProjectReference Include="../../src/ClaudeDo.Ui/ClaudeDo.Ui.csproj" />
  </ItemGroup>
</Project>
  • Step 2: Add to solution

Run: dotnet sln ClaudeDo.slnx add tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj Expected: Project added successfully.

  • Step 3: Verify build

Run: dotnet build tests/ClaudeDo.Ui.Tests Expected: Build succeeded.

  • Step 4: Commit
git add tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj ClaudeDo.slnx
git commit -m "chore(tests): add ClaudeDo.Ui.Tests project"

Task 2: StreamLineFormatter — text deltas (TDD)

Files:

  • Create: src/ClaudeDo.Ui/Helpers/StreamLineFormatter.cs

  • Create: tests/ClaudeDo.Ui.Tests/Helpers/StreamLineFormatterTests.cs

  • Step 1: Write failing tests for text delta extraction

// tests/ClaudeDo.Ui.Tests/Helpers/StreamLineFormatterTests.cs
using ClaudeDo.Ui.Helpers;

namespace ClaudeDo.Ui.Tests.Helpers;

public class StreamLineFormatterTests
{
    private readonly StreamLineFormatter _sut = new();

    [Fact]
    public void FormatLine_TextDelta_ReturnsTextContent()
    {
        var line = """{"type":"stream_event","event":{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"Hello world"}}}""";
        var result = _sut.FormatLine(line);
        Assert.Equal("Hello world", result);
    }

    [Fact]
    public void FormatLine_ConsecutiveTextDeltas_ReturnEachDelta()
    {
        var line1 = """{"type":"stream_event","event":{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"Hello "}}}""";
        var line2 = """{"type":"stream_event","event":{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"world"}}}""";
        Assert.Equal("Hello ", _sut.FormatLine(line1));
        Assert.Equal("world", _sut.FormatLine(line2));
    }

    [Fact]
    public void FormatLine_ContentBlockStop_ReturnsNewline()
    {
        var delta = """{"type":"stream_event","event":{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"Hi"}}}""";
        var stop = """{"type":"stream_event","event":{"type":"content_block_stop","index":0}}""";
        _sut.FormatLine(delta);
        Assert.Equal("\n", _sut.FormatLine(stop));
    }
}
  • Step 2: Run tests to verify they fail

Run: dotnet test tests/ClaudeDo.Ui.Tests --filter "StreamLineFormatterTests" -v quiet Expected: Build error — StreamLineFormatter does not exist.

  • Step 3: Write minimal StreamLineFormatter
// src/ClaudeDo.Ui/Helpers/StreamLineFormatter.cs
using System.Text.Json;

namespace ClaudeDo.Ui.Helpers;

public sealed class StreamLineFormatter
{
    public string? FormatLine(string ndjsonLine)
    {
        if (string.IsNullOrWhiteSpace(ndjsonLine)) return null;

        try
        {
            using var doc = JsonDocument.Parse(ndjsonLine);
            var root = doc.RootElement;

            if (!root.TryGetProperty("type", out var typeProp)) return null;
            var type = typeProp.GetString();

            return type switch
            {
                "stream_event" => HandleStreamEvent(root),
                _ => null,
            };
        }
        catch (JsonException)
        {
            return ndjsonLine; // Fallback: show raw line
        }
    }

    private static string? HandleStreamEvent(JsonElement root)
    {
        if (!root.TryGetProperty("event", out var evt)) return null;
        if (!evt.TryGetProperty("type", out var evtTypeProp)) return null;
        var evtType = evtTypeProp.GetString();

        return evtType switch
        {
            "content_block_delta" => HandleDelta(evt),
            "content_block_stop" => "\n",
            _ => null,
        };
    }

    private static string? HandleDelta(JsonElement evt)
    {
        if (!evt.TryGetProperty("delta", out var delta)) return null;
        if (!delta.TryGetProperty("type", out var deltaType)) return null;

        return deltaType.GetString() switch
        {
            "text_delta" => delta.TryGetProperty("text", out var text) ? text.GetString() : null,
            _ => null, // input_json_delta etc. — skip
        };
    }
}
  • Step 4: Run tests to verify they pass

Run: dotnet test tests/ClaudeDo.Ui.Tests --filter "StreamLineFormatterTests" -v quiet Expected: 3 passed.

  • Step 5: Commit
git add src/ClaudeDo.Ui/Helpers/StreamLineFormatter.cs tests/ClaudeDo.Ui.Tests/Helpers/StreamLineFormatterTests.cs
git commit -m "feat(ui): add StreamLineFormatter with text delta parsing (TDD)"

Task 3: StreamLineFormatter — tool use, result, system, fallback (TDD)

Files:

  • Modify: src/ClaudeDo.Ui/Helpers/StreamLineFormatter.cs

  • Modify: tests/ClaudeDo.Ui.Tests/Helpers/StreamLineFormatterTests.cs

  • Step 1: Write failing tests for remaining event types

Append to StreamLineFormatterTests.cs:

[Fact]
public void FormatLine_ToolUseStart_ReturnsToolNameLine()
{
    var line = """{"type":"stream_event","event":{"type":"content_block_start","index":1,"content_block":{"type":"tool_use","id":"toolu_xxx","name":"Read","input":{}}}}""";
    var result = _sut.FormatLine(line);
    Assert.Equal("\n[Tool: Read]\n", result);
}

[Fact]
public void FormatLine_InputJsonDelta_ReturnsNull()
{
    var line = """{"type":"stream_event","event":{"type":"content_block_delta","index":1,"delta":{"type":"input_json_delta","partial_json":"{\"file\":"}}}""";
    Assert.Null(_sut.FormatLine(line));
}

[Fact]
public void FormatLine_Result_ReturnsFormattedResult()
{
    var line = """{"type":"result","result":"Task completed successfully.","session_id":"sess_123"}""";
    var result = _sut.FormatLine(line);
    Assert.Equal("\n--- Result ---\nTask completed successfully.\n", result);
}

[Fact]
public void FormatLine_ApiRetry_ReturnsRetryNotice()
{
    var line = """{"type":"system","subtype":"api_retry","message":"Retrying..."}""";
    var result = _sut.FormatLine(line);
    Assert.Equal("\n[Retrying API call...]\n", result);
}

[Fact]
public void FormatLine_SystemNonRetry_ReturnsNull()
{
    var line = """{"type":"system","subtype":"init"}""";
    Assert.Null(_sut.FormatLine(line));
}

[Fact]
public void FormatLine_AssistantType_ReturnsNull()
{
    var line = """{"type":"assistant","message":{"role":"assistant","content":[]}}""";
    Assert.Null(_sut.FormatLine(line));
}

[Fact]
public void FormatLine_MalformedJson_ReturnsRawLine()
{
    var line = "this is not json";
    Assert.Equal("this is not json", _sut.FormatLine(line));
}

[Fact]
public void FormatLine_MessageStartAndDelta_ReturnsNull()
{
    var start = """{"type":"stream_event","event":{"type":"message_start","message":{"id":"msg_xxx"}}}""";
    var delta = """{"type":"stream_event","event":{"type":"message_delta","delta":{"stop_reason":"end_turn"},"usage":{"output_tokens":50}}}""";
    Assert.Null(_sut.FormatLine(start));
    Assert.Null(_sut.FormatLine(delta));
}
  • Step 2: Run tests to verify new tests fail

Run: dotnet test tests/ClaudeDo.Ui.Tests --filter "StreamLineFormatterTests" -v quiet Expected: Several failures — FormatLine_ToolUseStart, FormatLine_Result, FormatLine_ApiRetry return null instead of expected strings.

  • Step 3: Extend StreamLineFormatter to handle all event types

Update the FormatLine method's switch expression in StreamLineFormatter.cs:

return type switch
{
    "stream_event" => HandleStreamEvent(root),
    "result" => HandleResult(root),
    "system" => HandleSystem(root),
    "assistant" => null,
    _ => null,
};

Add HandleStreamEvent case for content_block_start:

private static string? HandleStreamEvent(JsonElement root)
{
    if (!root.TryGetProperty("event", out var evt)) return null;
    if (!evt.TryGetProperty("type", out var evtTypeProp)) return null;
    var evtType = evtTypeProp.GetString();

    return evtType switch
    {
        "content_block_start" => HandleBlockStart(evt),
        "content_block_delta" => HandleDelta(evt),
        "content_block_stop" => "\n",
        _ => null,
    };
}

Add new methods:

private static string? HandleBlockStart(JsonElement evt)
{
    if (!evt.TryGetProperty("content_block", out var block)) return null;
    if (!block.TryGetProperty("type", out var blockType)) return null;

    if (blockType.GetString() == "tool_use" &&
        block.TryGetProperty("name", out var name))
    {
        return $"\n[Tool: {name.GetString()}]\n";
    }
    return null;
}

private static string? HandleResult(JsonElement root)
{
    if (root.TryGetProperty("result", out var resultProp))
    {
        var text = resultProp.GetString();
        if (text is not null)
            return $"\n--- Result ---\n{text}\n";
    }
    return null;
}

private static string? HandleSystem(JsonElement root)
{
    if (root.TryGetProperty("subtype", out var subtype) &&
        subtype.GetString() == "api_retry")
    {
        return "\n[Retrying API call...]\n";
    }
    return null;
}
  • Step 4: Run tests to verify all pass

Run: dotnet test tests/ClaudeDo.Ui.Tests --filter "StreamLineFormatterTests" -v quiet Expected: 11 passed.

  • Step 5: Commit
git add src/ClaudeDo.Ui/Helpers/StreamLineFormatter.cs tests/ClaudeDo.Ui.Tests/Helpers/StreamLineFormatterTests.cs
git commit -m "feat(ui): complete StreamLineFormatter with tool use, result, system events"

Task 4: StreamLineFormatter — FormatFile method (TDD)

Files:

  • Modify: src/ClaudeDo.Ui/Helpers/StreamLineFormatter.cs

  • Modify: tests/ClaudeDo.Ui.Tests/Helpers/StreamLineFormatterTests.cs

  • Step 1: Write failing test for FormatFile

Append to StreamLineFormatterTests.cs:

[Fact]
public void FormatFile_ParsesAllLinesAndReturnsFormattedText()
{
    var dir = Path.Combine(Path.GetTempPath(), "claudedo_test_" + Guid.NewGuid().ToString("N")[..8]);
    Directory.CreateDirectory(dir);
    var filePath = Path.Combine(dir, "test.ndjson");
    try
    {
        var lines = new[]
        {
            """{"type":"stream_event","event":{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"Hello"}}}""",
            """{"type":"stream_event","event":{"type":"content_block_stop","index":0}}""",
            """{"type":"stream_event","event":{"type":"content_block_start","index":1,"content_block":{"type":"tool_use","id":"t1","name":"Edit","input":{}}}}""",
            """{"type":"stream_event","event":{"type":"content_block_stop","index":1}}""",
            """{"type":"result","result":"Done.","session_id":"s1"}""",
        };
        File.WriteAllLines(filePath, lines);

        var result = _sut.FormatFile(filePath);
        Assert.Contains("Hello", result);
        Assert.Contains("[Tool: Edit]", result);
        Assert.Contains("--- Result ---", result);
        Assert.Contains("Done.", result);
    }
    finally
    {
        Directory.Delete(dir, true);
    }
}

[Fact]
public void FormatFile_TrimsLargeContent()
{
    var dir = Path.Combine(Path.GetTempPath(), "claudedo_test_" + Guid.NewGuid().ToString("N")[..8]);
    Directory.CreateDirectory(dir);
    var filePath = Path.Combine(dir, "large.ndjson");
    try
    {
        // Generate enough text deltas to exceed 50k chars
        var lines = new List<string>();
        for (int i = 0; i < 600; i++)
        {
            var chunk = new string('x', 100);
            lines.Add($$"""{"type":"stream_event","event":{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"{{chunk}}\n"}}}""");
        }
        File.WriteAllLines(filePath, lines);

        var result = _sut.FormatFile(filePath);
        Assert.True(result.Length <= 50_000 + 200); // some tolerance for trimming at newline boundary
    }
    finally
    {
        Directory.Delete(dir, true);
    }
}
  • Step 2: Run tests to verify they fail

Run: dotnet test tests/ClaudeDo.Ui.Tests --filter "FormatFile" -v quiet Expected: Build error — FormatFile method does not exist.

  • Step 3: Implement FormatFile and trimming

Add to StreamLineFormatter.cs:

private const int MaxLength = 50_000;

public string FormatFile(string filePath)
{
    var sb = new System.Text.StringBuilder();
    foreach (var line in File.ReadLines(filePath))
    {
        var formatted = FormatLine(line);
        if (formatted is not null)
            sb.Append(formatted);
    }
    return Trim(sb.ToString());
}

public static string Trim(string text)
{
    if (text.Length <= MaxLength) return text;
    var trimStart = text.Length - MaxLength;
    var newlineAfter = text.IndexOf('\n', trimStart);
    if (newlineAfter >= 0 && newlineAfter < trimStart + 200)
        trimStart = newlineAfter + 1;
    return text[trimStart..];
}

Add using System.Text; to the top of the file.

  • Step 4: Run tests to verify all pass

Run: dotnet test tests/ClaudeDo.Ui.Tests --filter "StreamLineFormatterTests" -v quiet Expected: 13 passed.

  • Step 5: Commit
git add src/ClaudeDo.Ui/Helpers/StreamLineFormatter.cs tests/ClaudeDo.Ui.Tests/Helpers/StreamLineFormatterTests.cs
git commit -m "feat(ui): add FormatFile and text trimming to StreamLineFormatter"

Task 5: TaskDetailViewModel — replace LiveLines with LiveText

Files:

  • Modify: src/ClaudeDo.Ui/ViewModels/TaskDetailViewModel.cs

  • Step 1: Replace LiveLines with LiveText and wire formatter

In TaskDetailViewModel.cs, make these changes:

  1. Add using at top:
using ClaudeDo.Ui.Helpers;
  1. Replace the LiveLines property (line 40) and MaxLiveLines constant (line 47):

Remove:

public ObservableCollection<string> LiveLines { get; } = new();

and:

private const int MaxLiveLines = 500;

Add:

[ObservableProperty] private string _liveText = "";
private StreamLineFormatter _formatter = new();
  1. Update LoadAsync (line 69) — replace LiveLines.Clear() with:
LiveText = "";
_formatter = new StreamLineFormatter();
  1. Update Clear method — replace LiveLines.Clear() with:
LiveText = "";
_formatter = new StreamLineFormatter();
  1. Update OnTaskMessage (lines 259-265):

Replace entire method:

private void OnTaskMessage(string taskId, string line)
{
    if (taskId != _taskId) return;
    var formatted = _formatter.FormatLine(line);
    if (formatted is not null)
    {
        LiveText += formatted;
        if (LiveText.Length > 50_000)
            LiveText = StreamLineFormatter.Trim(LiveText);
    }
}
  • Step 2: Verify build

Run: dotnet build src/ClaudeDo.Ui Expected: Build succeeded.

  • Step 3: Commit
git add src/ClaudeDo.Ui/ViewModels/TaskDetailViewModel.cs
git commit -m "refactor(ui): replace LiveLines with LiveText + StreamLineFormatter"

Task 6: TaskDetailView — TextBox replaces ItemsControl + auto-scroll

Files:

  • Modify: src/ClaudeDo.Ui/Views/TaskDetailView.axaml

  • Modify: src/ClaudeDo.Ui/Views/TaskDetailView.axaml.cs

  • Step 1: Replace ItemsControl with TextBox in TaskDetailView.axaml

Replace lines 107-122 (the "Live Output" section heading through the Border/ItemsControl):

Old:

<TextBlock Text="Live Output" FontWeight="SemiBold" FontSize="12"
           Foreground="{StaticResource TextSecondaryBrush}" Margin="0,8,0,2"/>
<Border BorderBrush="{StaticResource BorderSubtleBrush}" BorderThickness="1"
        CornerRadius="6" Padding="6" MaxHeight="200">
    <ScrollViewer>
        <ItemsControl ItemsSource="{Binding LiveLines}">
            <ItemsControl.ItemTemplate>
                <DataTemplate>
                    <TextBlock Text="{Binding}" FontFamily="Consolas,Courier New,monospace"
                               FontSize="11" TextWrapping="NoWrap"
                               Foreground="{StaticResource TextPrimaryBrush}"/>
                </DataTemplate>
            </ItemsControl.ItemTemplate>
        </ItemsControl>
    </ScrollViewer>
</Border>

New:

<TextBlock Text="Live Output" FontWeight="SemiBold" FontSize="12"
           Foreground="{StaticResource TextSecondaryBrush}" Margin="0,8,0,2"/>
<Border BorderBrush="{StaticResource BorderSubtleBrush}" BorderThickness="1"
        CornerRadius="6" Padding="6" MaxHeight="300">
    <ScrollViewer x:Name="LiveOutputScroll">
        <TextBox x:Name="LiveOutputBox"
                 Text="{Binding LiveText, Mode=OneWay}"
                 IsReadOnly="True"
                 AcceptsReturn="True"
                 TextWrapping="NoWrap"
                 FontFamily="Consolas,Courier New,monospace"
                 FontSize="11"
                 Foreground="{StaticResource TextPrimaryBrush}"
                 Background="Transparent"
                 BorderThickness="0"
                 Padding="0"/>
    </ScrollViewer>
</Border>
  • Step 2: Add auto-scroll in code-behind

In TaskDetailView.axaml.cs, add an OnDataContextChanged override and property-change handler:

Add using:

using System.ComponentModel;

Add after the FocusTitle method:

protected override void OnDataContextChanged(EventArgs e)
{
    base.OnDataContextChanged(e);
    if (DataContext is TaskDetailViewModel vm)
    {
        vm.PropertyChanged += OnViewModelPropertyChanged;
    }
}

private void OnViewModelPropertyChanged(object? sender, PropertyChangedEventArgs e)
{
    if (e.PropertyName == nameof(TaskDetailViewModel.LiveText))
    {
        var scroll = this.FindControl<ScrollViewer>("LiveOutputScroll");
        scroll?.ScrollToEnd();
    }
}
  • Step 3: Verify build

Run: dotnet build src/ClaudeDo.Ui Expected: Build succeeded.

  • Step 4: Commit
git add src/ClaudeDo.Ui/Views/TaskDetailView.axaml src/ClaudeDo.Ui/Views/TaskDetailView.axaml.cs
git commit -m "feat(ui): replace ItemsControl with TextBox for formatted live output"

Task 7: Log reload from disk

Files:

  • Modify: src/ClaudeDo.Ui/ViewModels/TaskDetailViewModel.cs

  • Step 1: Add log reload to LoadAsync

In TaskDetailViewModel.cs, in LoadAsync, after LogPath = task.LogPath; (around line 81), add:

// Load historical log for completed tasks
if (task.LogPath is not null
    && task.Status is Data.Models.TaskStatus.Done or Data.Models.TaskStatus.Failed
    && File.Exists(task.LogPath))
{
    _formatter = new StreamLineFormatter();
    LiveText = _formatter.FormatFile(task.LogPath);
}

Add using System.IO; at the top if not already present.

  • Step 2: Verify build

Run: dotnet build src/ClaudeDo.Ui Expected: Build succeeded.

  • Step 3: Commit
git add src/ClaudeDo.Ui/ViewModels/TaskDetailViewModel.cs
git commit -m "feat(ui): reload formatted log from disk for completed tasks"

Task 8: WorkerClient — RunNowRequestedEvent

Files:

  • Modify: src/ClaudeDo.Ui/Services/WorkerClient.cs

  • Step 1: Add RunNowRequestedEvent and fire it in RunNowAsync

In WorkerClient.cs:

Add event declaration after the existing events (after line 44):

public event Action<string>? RunNowRequestedEvent;

Update RunNowAsync method (lines 163-166):

public async Task RunNowAsync(string taskId)
{
    RunNowRequestedEvent?.Invoke(taskId);
    await _hub.InvokeAsync("RunNow", taskId);
}
  • Step 2: Verify build

Run: dotnet build src/ClaudeDo.Ui Expected: Build succeeded.

  • Step 3: Commit
git add src/ClaudeDo.Ui/Services/WorkerClient.cs
git commit -m "feat(ui): add RunNowRequestedEvent for optimistic UI feedback"

Task 9: TaskItemViewModel — IsStarting state

Files:

  • Modify: src/ClaudeDo.Ui/ViewModels/TaskItemViewModel.cs

  • Step 1: Add IsStarting property and update CanRunNow

In TaskItemViewModel.cs:

Add property after line 16:

[ObservableProperty] private bool _isStarting;

Update CanRunNow (line 83-84):

private bool CanRunNow() =>
    _canRunNow() && Status != TaskStatus.Running && !IsStarting;

Add method to set starting state (after Refresh method):

public void SetStarting()
{
    IsStarting = true;
    StatusText = "starting...";
    RunNowCommand.NotifyCanExecuteChanged();
}

public void ClearStarting()
{
    IsStarting = false;
    RunNowCommand.NotifyCanExecuteChanged();
}

Update Refresh method — add after OnPropertyChanged(nameof(IsRunning)) (line 68):

IsStarting = false;
  • Step 2: Verify build

Run: dotnet build src/ClaudeDo.Ui Expected: Build succeeded.

  • Step 3: Commit
git add src/ClaudeDo.Ui/ViewModels/TaskItemViewModel.cs
git commit -m "feat(ui): add IsStarting state to TaskItemViewModel"

Task 10: TaskListViewModel — wire RunNowRequested to TaskItemViewModels

Files:

  • Modify: src/ClaudeDo.Ui/ViewModels/TaskListViewModel.cs

  • Step 1: Subscribe to RunNowRequestedEvent and TaskStartedEvent

In TaskListViewModel.cs constructor, after the existing worker.PropertyChanged subscription (after line 57):

worker.RunNowRequestedEvent += taskId =>
{
    var item = Tasks.FirstOrDefault(t => t.Id == taskId);
    item?.SetStarting();
};

worker.TaskStartedEvent += (_, taskId, _) =>
{
    var item = Tasks.FirstOrDefault(t => t.Id == taskId);
    item?.ClearStarting();
};
  • Step 2: Verify build

Run: dotnet build src/ClaudeDo.Ui Expected: Build succeeded.

  • Step 3: Commit
git add src/ClaudeDo.Ui/ViewModels/TaskListViewModel.cs
git commit -m "feat(ui): wire RunNowRequested to TaskItemViewModel starting state"

Task 11: TaskDetailViewModel — start feedback

Files:

  • Modify: src/ClaudeDo.Ui/ViewModels/TaskDetailViewModel.cs

  • Step 1: Subscribe to RunNowRequestedEvent and TaskStartedEvent

In TaskDetailViewModel.cs constructor, after worker.TaskUpdatedEvent += OnTaskUpdated; (line 63):

worker.RunNowRequestedEvent += OnRunNowRequested;
worker.TaskStartedEvent += OnTaskStarted;

Add the handler methods before OnTaskMessage:

private void OnRunNowRequested(string taskId)
{
    if (taskId != _taskId) return;
    StatusText = "starting...";
    LiveText = "";
    _formatter = new StreamLineFormatter();
}

private void OnTaskStarted(string slot, string taskId, DateTime startedAt)
{
    if (taskId != _taskId) return;
    StatusText = "running";
}
  • Step 2: Verify build

Run: dotnet build src/ClaudeDo.Ui Expected: Build succeeded.

  • Step 3: Commit
git add src/ClaudeDo.Ui/ViewModels/TaskDetailViewModel.cs
git commit -m "feat(ui): add optimistic start feedback to TaskDetailViewModel"

Task 12: TaskListView — starting state visual

Files:

  • Modify: src/ClaudeDo.Ui/Views/TaskListView.axaml

  • Step 1: Add starting indicator next to running indicator

In TaskListView.axaml, find the running indicator Ellipse (the orange dot visible when IsRunning). After it, add a similar indicator for the starting state. The exact location depends on the layout, but it should be adjacent to the existing status indicators.

Find the IsRunning Ellipse and add a sibling for IsStarting:

<!-- Starting indicator (pulsing or different shade) -->
<Ellipse Width="8" Height="8" Fill="#FFD700"
         IsVisible="{Binding IsStarting}"
         HorizontalAlignment="Center" VerticalAlignment="Center"/>

Use a gold/yellow color (#FFD700) to distinguish "starting" from "running" (orange).

  • Step 2: Verify build

Run: dotnet build src/ClaudeDo.Ui Expected: Build succeeded.

  • Step 3: Commit
git add src/ClaudeDo.Ui/Views/TaskListView.axaml
git commit -m "feat(ui): add starting state indicator in task list"

Task 13: Modal theming — ListEditorView

Files:

  • Modify: src/ClaudeDo.Ui/Views/ListEditorView.axaml

  • Step 1: Apply theme resources to ListEditorView

In ListEditorView.axaml, update the <Window> element — add Background:

<Window xmlns="https://github.com/avaloniaui"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        xmlns:vm="using:ClaudeDo.Ui.ViewModels"
        x:Class="ClaudeDo.Ui.Views.ListEditorView"
        x:DataType="vm:ListEditorViewModel"
        Title="{Binding WindowTitle}"
        Width="450" Height="280"
        WindowStartupLocation="CenterOwner"
        CanResize="False"
        Background="{StaticResource WindowBgBrush}">

Add Foreground="{StaticResource TextSecondaryBrush}" to each label TextBlock ("Name", "Working Directory", "Default Commit Type").

Style the Save button with accent:

<Button Content="Save" Command="{Binding SaveCommand}" IsDefault="True" MinWidth="80"
        Background="{StaticResource AccentBrush}" Foreground="{StaticResource TextPrimaryBrush}"/>
  • Step 2: Verify build

Run: dotnet build src/ClaudeDo.Ui Expected: Build succeeded.

  • Step 3: Commit
git add src/ClaudeDo.Ui/Views/ListEditorView.axaml
git commit -m "fix(ui): apply dark theme to ListEditorView modal"

Task 14: Modal theming — TaskEditorView

Files:

  • Modify: src/ClaudeDo.Ui/Views/TaskEditorView.axaml

  • Step 1: Apply theme resources to TaskEditorView

In TaskEditorView.axaml, update the <Window> element — add Background:

<Window xmlns="https://github.com/avaloniaui"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        xmlns:vm="using:ClaudeDo.Ui.ViewModels"
        x:Class="ClaudeDo.Ui.Views.TaskEditorView"
        x:DataType="vm:TaskEditorViewModel"
        Title="{Binding WindowTitle}"
        Width="500" Height="420"
        WindowStartupLocation="CenterOwner"
        CanResize="False"
        Background="{StaticResource WindowBgBrush}">

Add Foreground="{StaticResource TextSecondaryBrush}" to each label TextBlock ("Title", "Description", "Status", "Commit Type", "Tags (comma-separated)").

Style the Save button:

<Button Content="Save" Command="{Binding SaveCommand}" IsDefault="True" MinWidth="80"
        Background="{StaticResource AccentBrush}" Foreground="{StaticResource TextPrimaryBrush}"/>
  • Step 2: Verify build

Run: dotnet build src/ClaudeDo.Ui Expected: Build succeeded.

  • Step 3: Commit
git add src/ClaudeDo.Ui/Views/TaskEditorView.axaml
git commit -m "fix(ui): apply dark theme to TaskEditorView modal"

Task 15: WorkerClient — GetAgentsAsync + AgentInfo DTO

Files:

  • Modify: src/ClaudeDo.Ui/Services/WorkerClient.cs

  • Step 1: Add AgentInfo DTO and hub methods

In WorkerClient.cs:

Add the DTO record at the bottom of the file (outside the WorkerClient class, inside the namespace):

public record AgentInfo(string Name, string Description, string Path);

Add methods after WakeQueueAsync (after line 175):

public async Task<List<AgentInfo>> GetAgentsAsync()
{
    try
    {
        var agents = await _hub.InvokeAsync<List<AgentInfo>>("GetAgents");
        return agents ?? [];
    }
    catch
    {
        return [];
    }
}

public async Task RefreshAgentsAsync()
{
    await _hub.InvokeAsync("RefreshAgents");
}
  • Step 2: Verify build

Run: dotnet build src/ClaudeDo.Ui Expected: Build succeeded.

  • Step 3: Commit
git add src/ClaudeDo.Ui/Services/WorkerClient.cs
git commit -m "feat(ui): add GetAgentsAsync and AgentInfo DTO to WorkerClient"

Task 16: ListEditorViewModel — config fields

Files:

  • Modify: src/ClaudeDo.Ui/ViewModels/ListEditorViewModel.cs

  • Step 1: Add config properties and model mapping

In ListEditorViewModel.cs:

Add using:

using ClaudeDo.Ui.Services;

Add static model mapping after CommitTypes:

public static string[] ModelDisplayNames { get; } = ["Sonnet", "Opus", "Haiku"];

private static readonly Dictionary<string, string> ModelToId = new()
{
    ["Sonnet"] = "claude-sonnet-4-6",
    ["Opus"] = "claude-opus-4-6",
    ["Haiku"] = "claude-haiku-4-5",
};

private static readonly Dictionary<string, string> IdToModel =
    ModelToId.ToDictionary(kv => kv.Value, kv => kv.Key);

public static string ModelIdToDisplay(string? modelId) =>
    modelId is not null && IdToModel.TryGetValue(modelId, out var display) ? display : "Sonnet";

public static string? ModelDisplayToId(string display) =>
    ModelToId.TryGetValue(display, out var id) ? id : null;

Add config properties after _windowTitle:

[ObservableProperty] private string _model = "Sonnet";
[ObservableProperty] private string? _systemPrompt;
[ObservableProperty] private AgentInfo? _selectedAgent;

public List<AgentInfo> AvailableAgents { get; set; } = [];

Add a WorkerClient field and update constructor — but since the ViewModel is created via factory (Func<ListEditorViewModel>), the WorkerClient needs to be injected. Add a method to load agents:

public async Task LoadAgentsAsync(WorkerClient worker)
{
    AvailableAgents = await worker.GetAgentsAsync();
}

Update InitForEdit to accept config:

public void InitForEdit(ListEntity entity, Data.Models.ListConfigEntity? config)
{
    _editId = entity.Id;
    _createdAt = entity.CreatedAt;
    Name = entity.Name;
    WorkingDir = entity.WorkingDir;
    DefaultCommitType = entity.DefaultCommitType;
    WindowTitle = $"Edit List: {entity.Name}";

    if (config is not null)
    {
        Model = ModelIdToDisplay(config.Model);
        SystemPrompt = config.SystemPrompt;
        SelectedAgentPath = config.AgentPath;
    }
}

Add a method to build the config entity for saving:

public Data.Models.ListConfigEntity? BuildConfig(string listId)
{
    var modelId = ModelDisplayToId(Model);
    if (modelId is null && SystemPrompt is null && SelectedAgentPath is null)
        return null;

    return new Data.Models.ListConfigEntity
    {
        ListId = listId,
        Model = modelId,
        SystemPrompt = string.IsNullOrWhiteSpace(SystemPrompt) ? null : SystemPrompt.Trim(),
        AgentPath = SelectedAgentPath,
    };
}
  • Step 2: Verify build

Run: dotnet build src/ClaudeDo.Ui Expected: Build succeeded.

  • Step 3: Commit
git add src/ClaudeDo.Ui/ViewModels/ListEditorViewModel.cs
git commit -m "feat(ui): add model/prompt/agent config fields to ListEditorViewModel"

Task 17: ListEditorView — config section XAML

Files:

  • Modify: src/ClaudeDo.Ui/Views/ListEditorView.axaml

  • Step 1: Add config section and increase window height

In ListEditorView.axaml, update Height="280" to Height="480".

Before the Save/Cancel button StackPanel, add a config section:

<!-- Divider -->
<Border Height="1" Background="{StaticResource BorderSubtleBrush}" Margin="0,6,0,2"/>

<TextBlock Text="Agent Config" FontWeight="Bold" FontSize="13"
           Foreground="{StaticResource TextPrimaryBrush}"/>

<TextBlock Text="Model" FontWeight="SemiBold"
           Foreground="{StaticResource TextSecondaryBrush}"/>
<ComboBox ItemsSource="{Binding ModelDisplayNames}"
          SelectedItem="{Binding Model}"
          MinWidth="150"/>

<TextBlock Text="System Prompt" FontWeight="SemiBold"
           Foreground="{StaticResource TextSecondaryBrush}"/>
<TextBox Text="{Binding SystemPrompt}"
         PlaceholderText="(optional) Additional system instructions..."
         AcceptsReturn="True" TextWrapping="Wrap" MinHeight="50"/>

<TextBlock Text="Agent File" FontWeight="SemiBold"
           Foreground="{StaticResource TextSecondaryBrush}"/>
<ComboBox x:Name="AgentCombo"
          SelectedValue="{Binding SelectedAgentPath}"
          SelectedValueBinding="{Binding Path}"
          DisplayMemberBinding="{Binding Name}"
          MinWidth="150">
    <ComboBox.Items>
        <!-- Populated from code-behind -->
    </ComboBox.Items>
</ComboBox>

Note: The agent ComboBox will need to be populated from code-behind or via ItemsSource binding to AvailableAgents. Since AvailableAgents is a List<AgentInfo>, bind it:

<ComboBox ItemsSource="{Binding AvailableAgents}"
          SelectedValue="{Binding SelectedAgentPath}"
          MinWidth="150">
    <ComboBox.ItemTemplate>
        <DataTemplate>
            <TextBlock Text="{Binding Name}"/>
        </DataTemplate>
    </ComboBox.ItemTemplate>
</ComboBox>

However, since SelectedValue binding with a path property requires SelectedValueBinding, and Avalonia's ComboBox support differs from WPF, the simpler approach is to bind to a list of agent path strings with display names handled in the ViewModel. Use a list of display strings and map in the VM.

Simpler approach — replace AvailableAgents with two parallel properties in the VM:

Actually, the cleanest Avalonia pattern: use ItemsSource and handle selection via SelectedItem where items are AgentInfo records, and map to path in the VM.

Update the ComboBox to:

<ComboBox ItemsSource="{Binding AvailableAgents}"
          SelectedItem="{Binding SelectedAgent}"
          MinWidth="150">
    <ComboBox.ItemTemplate>
        <DataTemplate x:DataType="svc:AgentInfo">
            <TextBlock Text="{Binding Name}"/>
        </DataTemplate>
    </ComboBox.ItemTemplate>
</ComboBox>

This requires adding xmlns:svc="using:ClaudeDo.Ui.Services" to the Window element.

And in the ViewModel, change SelectedAgentPath to SelectedAgent:

[ObservableProperty] private AgentInfo? _selectedAgent;

Map to/from path in InitForEdit and BuildConfig:

  • InitForEdit: SelectedAgent = AvailableAgents.FirstOrDefault(a => a.Path == config?.AgentPath);
  • BuildConfig: use SelectedAgent?.Path

Update ListEditorViewModel to use SelectedAgent instead of SelectedAgentPath in BuildConfig:

AgentPath = SelectedAgent?.Path,
  • Step 2: Verify build

Run: dotnet build src/ClaudeDo.Ui Expected: Build succeeded.

  • Step 3: Commit
git add src/ClaudeDo.Ui/Views/ListEditorView.axaml src/ClaudeDo.Ui/ViewModels/ListEditorViewModel.cs
git commit -m "feat(ui): add config section to ListEditorView"

Task 18: Wire ListEditor config loading/saving in MainWindowViewModel

Files:

  • Modify: src/ClaudeDo.Ui/ViewModels/MainWindowViewModel.cs (or wherever list editor is opened)

The list editor is opened from MainWindowViewModel (or wherever the "Edit List" action lives). Find where ListEditorViewModel.InitForEdit is called and update it to:

  1. Load config before opening: var config = await _listRepo.GetConfigAsync(entity.Id);
  2. Load agents: await editor.LoadAgentsAsync(_worker);
  3. Call updated InitForEdit(entity, config)
  4. After save, persist config:
var configEntity = editor.BuildConfig(saved.Id);
if (configEntity is not null)
    await _listRepo.SetConfigAsync(configEntity);
  • Step 1: Find and update list editor call sites

Search for InitForEdit and InitForCreate calls for ListEditorViewModel in the codebase. Update each call site to load config and agents.

For InitForCreate: agents still need to be loaded. Add await editor.LoadAgentsAsync(_worker); after InitForCreate().

For InitForEdit: add config loading before init:

var config = await _listRepo.GetConfigAsync(entity.Id);
await editor.LoadAgentsAsync(_worker);
editor.InitForEdit(entity, config);

After save, persist config:

var configEntity = editor.BuildConfig(saved.Id);
if (configEntity is not null)
    await _listRepo.SetConfigAsync(configEntity);
  • Step 2: Verify build

Run: dotnet build src/ClaudeDo.Ui Expected: Build succeeded.

  • Step 3: Commit
git add src/ClaudeDo.Ui/ViewModels/MainWindowViewModel.cs
git commit -m "feat(ui): wire config loading/saving in list editor flow"

Task 19: TaskEditorViewModel — config override fields

Files:

  • Modify: src/ClaudeDo.Ui/ViewModels/TaskEditorViewModel.cs

  • Step 1: Add config properties with inheritance

In TaskEditorViewModel.cs:

Add using:

using ClaudeDo.Ui.Services;

Add static model choices (reuse the mapping from ListEditorViewModel):

public static string[] ModelChoices { get; } = ["(list default)", "Sonnet", "Opus", "Haiku"];

Add config properties after _windowTitle:

[ObservableProperty] private string _modelChoice = "(list default)";
[ObservableProperty] private string? _systemPromptOverride;
[ObservableProperty] private AgentInfo? _selectedAgent;
public List<AgentInfo> AvailableAgents { get; set; } = [];

Add agent loading method:

public async Task LoadAgentsAsync(WorkerClient worker)
{
    AvailableAgents = await worker.GetAgentsAsync();
}

Update InitForEdit to load config overrides (add after TagsInput = ... line):

ModelChoice = entity.Model is not null
    ? ListEditorViewModel.ModelIdToDisplay(entity.Model)
    : "(list default)";
SystemPromptOverride = entity.SystemPrompt;
SelectedAgent = entity.AgentPath is not null
    ? AvailableAgents.FirstOrDefault(a => a.Path == entity.AgentPath)
    : null;

Update Save to include config on the entity — in the Save method, after building the entity, add:

entity.Model = ModelChoice != "(list default)"
    ? ListEditorViewModel.ModelDisplayToId(ModelChoice)
    : null;
entity.SystemPrompt = string.IsNullOrWhiteSpace(SystemPromptOverride) ? null : SystemPromptOverride.Trim();
entity.AgentPath = SelectedAgent?.Path;
  • Step 2: Verify build

Run: dotnet build src/ClaudeDo.Ui Expected: Build succeeded.

  • Step 3: Commit
git add src/ClaudeDo.Ui/ViewModels/TaskEditorViewModel.cs
git commit -m "feat(ui): add model/prompt/agent override fields to TaskEditorViewModel"

Task 20: TaskEditorView — config section XAML

Files:

  • Modify: src/ClaudeDo.Ui/Views/TaskEditorView.axaml

  • Step 1: Add config section and increase height

In TaskEditorView.axaml, update Height="420" to Height="600".

Add xmlns:svc="using:ClaudeDo.Ui.Services" to the Window element.

Before the Save/Cancel button StackPanel, add:

<!-- Divider -->
<Border Height="1" Background="{StaticResource BorderSubtleBrush}" Margin="0,6,0,2"/>

<TextBlock Text="Agent Config (overrides)" FontWeight="Bold" FontSize="13"
           Foreground="{StaticResource TextPrimaryBrush}"/>

<TextBlock Text="Model" FontWeight="SemiBold"
           Foreground="{StaticResource TextSecondaryBrush}"/>
<ComboBox ItemsSource="{Binding ModelChoices}"
          SelectedItem="{Binding ModelChoice}"
          MinWidth="150"/>

<TextBlock Text="System Prompt" FontWeight="SemiBold"
           Foreground="{StaticResource TextSecondaryBrush}"/>
<TextBox Text="{Binding SystemPromptOverride}"
         PlaceholderText="(inherits from list)"
         AcceptsReturn="True" TextWrapping="Wrap" MinHeight="50"/>

<TextBlock Text="Agent File" FontWeight="SemiBold"
           Foreground="{StaticResource TextSecondaryBrush}"/>
<ComboBox ItemsSource="{Binding AvailableAgents}"
          SelectedItem="{Binding SelectedAgent}"
          MinWidth="150">
    <ComboBox.ItemTemplate>
        <DataTemplate x:DataType="svc:AgentInfo">
            <TextBlock Text="{Binding Name}"/>
        </DataTemplate>
    </ComboBox.ItemTemplate>
</ComboBox>
  • Step 2: Verify build

Run: dotnet build src/ClaudeDo.Ui Expected: Build succeeded.

  • Step 3: Commit
git add src/ClaudeDo.Ui/Views/TaskEditorView.axaml
git commit -m "feat(ui): add config override section to TaskEditorView"

Task 21: Wire TaskEditor agents loading

Files:

  • Modify: src/ClaudeDo.Ui/ViewModels/TaskListViewModel.cs

  • Step 1: Load agents in AddTask and EditTask

In TaskListViewModel.cs:

In AddTask method (line 130), after editor.InitForCreate(CurrentListId, defaultCommitType);, add:

await editor.LoadAgentsAsync(_worker);

In EditTask method (line 173), after editor.InitForEdit(entity, taskTags);, add:

await editor.LoadAgentsAsync(_worker);

Note: LoadAgentsAsync must be called BEFORE InitForEdit for the task editor so that AvailableAgents is populated when InitForEdit tries to find the matching SelectedAgent. Reorder:

var editor = _editorFactory();
await editor.LoadAgentsAsync(_worker);
editor.InitForEdit(entity, taskTags);

Same for AddTask:

var editor = _editorFactory();
await editor.LoadAgentsAsync(_worker);
editor.InitForCreate(CurrentListId, defaultCommitType);
  • Step 2: Verify build

Run: dotnet build src/ClaudeDo.Ui Expected: Build succeeded.

  • Step 3: Commit
git add src/ClaudeDo.Ui/ViewModels/TaskListViewModel.cs
git commit -m "feat(ui): load available agents when opening task editor"

Task 22: TaskRunner — default model fallback

Files:

  • Modify: src/ClaudeDo.Worker/Runner/TaskRunner.cs

  • Step 1: Add Sonnet as default model in config resolution

In TaskRunner.cs, find the config resolution (around line 82-83):

var resolvedConfig = new ClaudeRunConfig(
    Model: task.Model ?? listConfig?.Model,

Change to:

var resolvedConfig = new ClaudeRunConfig(
    Model: task.Model ?? listConfig?.Model ?? "claude-sonnet-4-6",
  • Step 2: Verify build

Run: dotnet build src/ClaudeDo.Worker Expected: Build succeeded.

  • Step 3: Run existing Worker tests

Run: dotnet test tests/ClaudeDo.Worker.Tests -v quiet Expected: All tests pass.

  • Step 4: Commit
git add src/ClaudeDo.Worker/Runner/TaskRunner.cs
git commit -m "feat(worker): default to claude-sonnet-4-6 when no model configured"

Task 23: Full build and test verification

  • Step 1: Build entire solution

Run: dotnet build ClaudeDo.slnx Expected: Build succeeded with 0 errors.

  • Step 2: Run all tests

Run: dotnet test ClaudeDo.slnx -v quiet Expected: All tests pass (existing Worker tests + new Ui tests).

  • Step 3: Verify no compiler warnings related to changes

Run: dotnet build ClaudeDo.slnx -warnaserror Expected: Build succeeded (or only pre-existing warnings unrelated to our changes).