Files
ClaudeDo/docs/superpowers/plans/2026-04-22-agent-settings-ui.md
2026-04-22 12:06:31 +02:00

46 KiB

Agent Settings UI Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Restore the ability to configure Model / SystemPrompt / AgentPath per list (via a new modal) and per task (via an expander in DetailsIsland), persisting through SignalR hub methods to the existing DB schema.

Architecture: UI → new WorkerHub methods (UpdateList, UpdateListConfig, UpdateTaskAgentSettings, GetListConfig) → existing repositories in ClaudeDo.Data (schema already in place). Worker broadcasts ListUpdated so the lists island refreshes. Per-task settings auto-save on change, debounced. The DB columns/tables already exist; TaskRunner + ClaudeArgsBuilder already consume them.

Tech Stack: .NET 8, Avalonia 12, CommunityToolkit.Mvvm, EF Core, SignalR (hub on 127.0.0.1:47821), xUnit integration tests with real SQLite.

Build tip (from project memory): dotnet build ClaudeDo.slnx fails on .NET 8. Build individual csproj files:

dotnet build src/ClaudeDo.Data/ClaudeDo.Data.csproj
dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj
dotnet build src/ClaudeDo.Ui/ClaudeDo.Ui.csproj
dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj
dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj

File Structure

New files:

  • src/ClaudeDo.Ui/Views/Modals/ListSettingsModalView.axaml
  • src/ClaudeDo.Ui/Views/Modals/ListSettingsModalView.axaml.cs
  • src/ClaudeDo.Ui/ViewModels/Modals/ListSettingsModalViewModel.cs
  • tests/ClaudeDo.Worker.Tests/Repositories/ListRepositoryDeleteConfigTests.cs
  • tests/ClaudeDo.Worker.Tests/Repositories/TaskRepositoryAgentSettingsTests.cs
  • tests/ClaudeDo.Worker.Tests/Hub/AgentSettingsHubTests.cs

Modified files:

  • src/ClaudeDo.Data/Repositories/ListRepository.cs — add DeleteConfigAsync
  • src/ClaudeDo.Data/Repositories/TaskRepository.cs — add UpdateAgentSettingsAsync
  • src/ClaudeDo.Worker/Hub/WorkerHub.cs — add 4 methods + DTOs
  • src/ClaudeDo.Worker/Hub/HubBroadcaster.cs — add ListUpdatedAsync
  • src/ClaudeDo.Ui/Services/WorkerClient.cs — add 4 methods + ListUpdatedEvent
  • src/ClaudeDo.Ui/ViewModels/Modals/SettingsModalViewModel.cs (no changes expected, only for reference)
  • src/ClaudeDo.App/App.axaml.cs (or DI extension file) — register ListSettingsModalViewModel
  • src/ClaudeDo.Ui/Views/Islands/ListsIslandView.axaml — add context menu + gear button
  • src/ClaudeDo.Ui/ViewModels/Islands/ListsIslandViewModel.cs — add OpenSettingsCommand, subscribe ListUpdated
  • src/ClaudeDo.Ui/ViewModels/Islands/ListNavItemViewModel.cs — expose list fields needed by modal
  • src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs — add agent-settings fields + auto-save
  • src/ClaudeDo.Ui/Views/Islands/DetailsIslandView.axaml — add "Agent settings" expander
  • src/ClaudeDo.Data/CLAUDE.md — refresh with new repo methods + ListConfigEntity
  • src/ClaudeDo.Worker/CLAUDE.md — document new hub methods + ListUpdated
  • src/ClaudeDo.Ui/CLAUDE.md — document list settings modal + details expander

Task 1: Add ListRepository.DeleteConfigAsync

Files:

  • Modify: src/ClaudeDo.Data/Repositories/ListRepository.cs
  • Test: tests/ClaudeDo.Worker.Tests/Repositories/ListRepositoryDeleteConfigTests.cs

The existing ListRepository.SetConfigAsync upserts but never deletes. The UI needs a way to fully remove a list config row (when the user clears all three agent fields).

  • Step 1: Write the failing test

Create tests/ClaudeDo.Worker.Tests/Repositories/ListRepositoryDeleteConfigTests.cs:

using ClaudeDo.Data.Models;
using ClaudeDo.Data.Repositories;
using ClaudeDo.Worker.Tests.Infrastructure;
using Xunit;

namespace ClaudeDo.Worker.Tests.Repositories;

public sealed class ListRepositoryDeleteConfigTests : IAsyncLifetime
{
    private readonly TempDatabase _db = new();

    public async Task InitializeAsync() => await _db.InitializeAsync();
    public async Task DisposeAsync() => await _db.DisposeAsync();

    [Fact]
    public async Task DeleteConfigAsync_RemovesExistingRow()
    {
        await using var ctx = _db.CreateContext();
        var repo = new ListRepository(ctx);

        var listId = Guid.NewGuid().ToString();
        await repo.AddAsync(new ListEntity
        {
            Id = listId,
            Name = "L",
            CreatedAt = DateTime.UtcNow,
        });
        await repo.SetConfigAsync(new ListConfigEntity
        {
            ListId = listId,
            Model = "opus",
            SystemPrompt = "hello",
            AgentPath = "/tmp/a.md",
        });

        var removed = await repo.DeleteConfigAsync(listId);

        Assert.True(removed);
        Assert.Null(await repo.GetConfigAsync(listId));
    }

    [Fact]
    public async Task DeleteConfigAsync_ReturnsFalseWhenAbsent()
    {
        await using var ctx = _db.CreateContext();
        var repo = new ListRepository(ctx);

        var removed = await repo.DeleteConfigAsync(Guid.NewGuid().ToString());

        Assert.False(removed);
    }
}

If TempDatabase / Infrastructure namespace differs, match the existing test infrastructure pattern seen in other *RepositoryTests files in tests/ClaudeDo.Worker.Tests/Repositories/. Read one existing test file first to confirm the exact helper class name and SQLite setup call.

  • Step 2: Run test to verify it fails
dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj --filter "FullyQualifiedName~ListRepositoryDeleteConfigTests"

Expected: FAIL with 'ListRepository' does not contain a definition for 'DeleteConfigAsync'.

  • Step 3: Add the method

In src/ClaudeDo.Data/Repositories/ListRepository.cs, add below SetConfigAsync:

public async Task<bool> DeleteConfigAsync(string listId, CancellationToken ct = default)
{
    var affected = await _context.ListConfigs
        .Where(c => c.ListId == listId)
        .ExecuteDeleteAsync(ct);
    return affected > 0;
}
  • Step 4: Run test to verify it passes
dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj --filter "FullyQualifiedName~ListRepositoryDeleteConfigTests"

Expected: 2 tests passed.

  • Step 5: Commit
git add src/ClaudeDo.Data/Repositories/ListRepository.cs tests/ClaudeDo.Worker.Tests/Repositories/ListRepositoryDeleteConfigTests.cs
git commit -m "feat(data): add ListRepository.DeleteConfigAsync"

Task 2: Add TaskRepository.UpdateAgentSettingsAsync

Files:

  • Modify: src/ClaudeDo.Data/Repositories/TaskRepository.cs
  • Test: tests/ClaudeDo.Worker.Tests/Repositories/TaskRepositoryAgentSettingsTests.cs

Focused method so the UI can update the three override columns without loading the entity.

  • Step 1: Write the failing test

Create tests/ClaudeDo.Worker.Tests/Repositories/TaskRepositoryAgentSettingsTests.cs:

using ClaudeDo.Data.Models;
using ClaudeDo.Data.Repositories;
using ClaudeDo.Worker.Tests.Infrastructure;
using Xunit;

namespace ClaudeDo.Worker.Tests.Repositories;

public sealed class TaskRepositoryAgentSettingsTests : IAsyncLifetime
{
    private readonly TempDatabase _db = new();

    public async Task InitializeAsync() => await _db.InitializeAsync();
    public async Task DisposeAsync() => await _db.DisposeAsync();

    private async Task<string> SeedTaskAsync()
    {
        await using var ctx = _db.CreateContext();
        var listId = Guid.NewGuid().ToString();
        var taskId = Guid.NewGuid().ToString();
        await new ListRepository(ctx).AddAsync(new ListEntity
        {
            Id = listId, Name = "L", CreatedAt = DateTime.UtcNow,
        });
        await new TaskRepository(ctx).AddAsync(new TaskEntity
        {
            Id = taskId, ListId = listId, Title = "T", CreatedAt = DateTime.UtcNow,
        });
        return taskId;
    }

    [Fact]
    public async Task UpdateAgentSettingsAsync_SetsAllThreeFields()
    {
        var taskId = await SeedTaskAsync();

        await using var ctx = _db.CreateContext();
        var repo = new TaskRepository(ctx);

        await repo.UpdateAgentSettingsAsync(taskId, "opus", "system!", "/tmp/a.md");

        var entity = await repo.GetByIdAsync(taskId);
        Assert.NotNull(entity);
        Assert.Equal("opus", entity!.Model);
        Assert.Equal("system!", entity.SystemPrompt);
        Assert.Equal("/tmp/a.md", entity.AgentPath);
    }

    [Fact]
    public async Task UpdateAgentSettingsAsync_NullsClearColumns()
    {
        var taskId = await SeedTaskAsync();

        await using (var ctx = _db.CreateContext())
        {
            await new TaskRepository(ctx).UpdateAgentSettingsAsync(taskId, "opus", "s", "/a.md");
        }

        await using (var ctx = _db.CreateContext())
        {
            var repo = new TaskRepository(ctx);
            await repo.UpdateAgentSettingsAsync(taskId, null, null, null);

            var entity = await repo.GetByIdAsync(taskId);
            Assert.NotNull(entity);
            Assert.Null(entity!.Model);
            Assert.Null(entity.SystemPrompt);
            Assert.Null(entity.AgentPath);
        }
    }
}
  • Step 2: Run test to verify it fails
dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj --filter "FullyQualifiedName~TaskRepositoryAgentSettingsTests"

Expected: FAIL with 'TaskRepository' does not contain a definition for 'UpdateAgentSettingsAsync'.

  • Step 3: Add the method

In src/ClaudeDo.Data/Repositories/TaskRepository.cs, add at the end of the #region Status transitions block (or a new #region Agent settings right after):

public async Task UpdateAgentSettingsAsync(
    string taskId,
    string? model,
    string? systemPrompt,
    string? agentPath,
    CancellationToken ct = default)
{
    await _context.Tasks
        .Where(t => t.Id == taskId)
        .ExecuteUpdateAsync(s => s
            .SetProperty(t => t.Model, model)
            .SetProperty(t => t.SystemPrompt, systemPrompt)
            .SetProperty(t => t.AgentPath, agentPath), ct);
}
  • Step 4: Run test to verify it passes
dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj --filter "FullyQualifiedName~TaskRepositoryAgentSettingsTests"

Expected: 2 tests passed.

  • Step 5: Commit
git add src/ClaudeDo.Data/Repositories/TaskRepository.cs tests/ClaudeDo.Worker.Tests/Repositories/TaskRepositoryAgentSettingsTests.cs
git commit -m "feat(data): add TaskRepository.UpdateAgentSettingsAsync"

Task 3: Hub methods + DTOs + ListUpdated broadcast

Files:

  • Modify: src/ClaudeDo.Worker/Hub/HubBroadcaster.cs
  • Modify: src/ClaudeDo.Worker/Hub/WorkerHub.cs
  • Test: tests/ClaudeDo.Worker.Tests/Hub/AgentSettingsHubTests.cs

Add four hub methods (UpdateList, UpdateListConfig, UpdateTaskAgentSettings, GetListConfig) plus a ListUpdated broadcast event.

  • Step 1: Write the failing test

Create tests/ClaudeDo.Worker.Tests/Hub/AgentSettingsHubTests.cs:

Pattern notes: the worker test project usually calls hub methods by invoking repositories/services directly when no SignalR host is spun up. Read an existing hub-adjacent test (if one exists) to confirm. If no precedent, assert the underlying persistence via repo calls — the hub methods are thin wrappers.

using ClaudeDo.Data.Models;
using ClaudeDo.Data.Repositories;
using ClaudeDo.Worker.Tests.Infrastructure;
using Xunit;

namespace ClaudeDo.Worker.Tests.Hub;

// These tests exercise the hub behavior by invoking the underlying repository
// chain the hub calls. The hub itself is a thin wrapper around these repos;
// full SignalR integration testing is covered manually per the spec.
public sealed class AgentSettingsHubTests : IAsyncLifetime
{
    private readonly TempDatabase _db = new();
    public async Task InitializeAsync() => await _db.InitializeAsync();
    public async Task DisposeAsync() => await _db.DisposeAsync();

    [Fact]
    public async Task UpdateListConfig_AllNull_DeletesRow()
    {
        await using var ctx = _db.CreateContext();
        var repo = new ListRepository(ctx);

        var listId = Guid.NewGuid().ToString();
        await repo.AddAsync(new ListEntity { Id = listId, Name = "L", CreatedAt = DateTime.UtcNow });
        await repo.SetConfigAsync(new ListConfigEntity
        {
            ListId = listId, Model = "opus", SystemPrompt = null, AgentPath = null,
        });

        // Simulate hub: all three null => delete.
        const string? model = null; const string? sp = null; const string? ap = null;
        if (model is null && sp is null && ap is null)
            await repo.DeleteConfigAsync(listId);
        else
            await repo.SetConfigAsync(new ListConfigEntity
            {
                ListId = listId, Model = model, SystemPrompt = sp, AgentPath = ap,
            });

        Assert.Null(await repo.GetConfigAsync(listId));
    }
}
  • Step 2: Run test to verify it fails

It actually passes since it only calls existing code. The purpose of this test is to encode the hub's "all-null → delete" contract so that Task 3's code changes don't break it.

dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj --filter "FullyQualifiedName~AgentSettingsHubTests"

Expected: 1 passed (the test is a spec/guardrail, not a red-green).

  • Step 3: Add the broadcaster event

In src/ClaudeDo.Worker/Hub/HubBroadcaster.cs, add a new method alongside the existing TaskUpdatedAsync:

public Task ListUpdatedAsync(string listId) =>
    _hub.Clients.All.SendAsync("ListUpdated", listId);
  • Step 4: Add DTOs + hub methods

In src/ClaudeDo.Worker/Hub/WorkerHub.cs, add these record DTOs at the top (alongside ActiveTaskDto, AppSettingsDto, etc.):

public record UpdateListDto(string Id, string Name, string? WorkingDir, string DefaultCommitType);
public record UpdateListConfigDto(string ListId, string? Model, string? SystemPrompt, string? AgentPath);
public record UpdateTaskAgentSettingsDto(string TaskId, string? Model, string? SystemPrompt, string? AgentPath);
public record ListConfigDto(string? Model, string? SystemPrompt, string? AgentPath);

Then add these methods to the WorkerHub class (place at the end, before the closing brace):

public async Task UpdateList(UpdateListDto dto)
{
    using var ctx = _dbFactory.CreateDbContext();
    var repo = new ListRepository(ctx);
    var entity = await repo.GetByIdAsync(dto.Id);
    if (entity is null) throw new HubException("list not found");

    entity.Name = dto.Name;
    entity.WorkingDir = string.IsNullOrWhiteSpace(dto.WorkingDir) ? null : dto.WorkingDir;
    entity.DefaultCommitType = string.IsNullOrWhiteSpace(dto.DefaultCommitType) ? "chore" : dto.DefaultCommitType;
    await repo.UpdateAsync(entity);

    await _broadcaster.ListUpdatedAsync(dto.Id);
}

public async Task UpdateListConfig(UpdateListConfigDto dto)
{
    using var ctx = _dbFactory.CreateDbContext();
    var repo = new ListRepository(ctx);

    var model = Nullify(dto.Model);
    var systemPrompt = Nullify(dto.SystemPrompt);
    var agentPath = Nullify(dto.AgentPath);

    if (model is null && systemPrompt is null && agentPath is null)
    {
        await repo.DeleteConfigAsync(dto.ListId);
    }
    else
    {
        await repo.SetConfigAsync(new ListConfigEntity
        {
            ListId = dto.ListId,
            Model = model,
            SystemPrompt = systemPrompt,
            AgentPath = agentPath,
        });
    }

    await _broadcaster.ListUpdatedAsync(dto.ListId);
}

public async Task<ListConfigDto?> GetListConfig(string listId)
{
    using var ctx = _dbFactory.CreateDbContext();
    var repo = new ListRepository(ctx);
    var config = await repo.GetConfigAsync(listId);
    if (config is null) return null;
    return new ListConfigDto(config.Model, config.SystemPrompt, config.AgentPath);
}

public async Task UpdateTaskAgentSettings(UpdateTaskAgentSettingsDto dto)
{
    using var ctx = _dbFactory.CreateDbContext();
    var repo = new TaskRepository(ctx);
    await repo.UpdateAgentSettingsAsync(
        dto.TaskId,
        Nullify(dto.Model),
        Nullify(dto.SystemPrompt),
        Nullify(dto.AgentPath));

    await _broadcaster.TaskUpdatedAsync(dto.TaskId);
}

private static string? Nullify(string? s) => string.IsNullOrWhiteSpace(s) ? null : s;

If _broadcaster.TaskUpdatedAsync has a different signature in the existing broadcaster, adapt the call to match — read HubBroadcaster.cs for the exact name.

  • Step 5: Build worker
dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj

Expected: build succeeded, 0 errors.

  • Step 6: Run the guardrail test
dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj --filter "FullyQualifiedName~AgentSettingsHubTests"

Expected: passed.

  • Step 7: Commit
git add src/ClaudeDo.Worker/Hub/WorkerHub.cs src/ClaudeDo.Worker/Hub/HubBroadcaster.cs tests/ClaudeDo.Worker.Tests/Hub/AgentSettingsHubTests.cs
git commit -m "feat(worker): add hub methods for list and task agent settings"

Task 4: WorkerClient — 4 new methods + ListUpdatedEvent

Files:

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

Add client-side DTOs (mirroring the hub DTOs, or import from ClaudeDo.Worker if cross-project references allow — else redeclare as simple records in the UI project), client methods, and subscribe to the new ListUpdated event.

  • Step 1: Inspect current WorkerClient shape

Open src/ClaudeDo.Ui/Services/WorkerClient.cs and find where UpdateAppSettings, TaskUpdatedEvent, and the hub event subscriptions are declared. The new code will mirror the exact pattern used there (naming, event signature, async wrapper).

  • Step 2: Add client DTOs

At the top of WorkerClient.cs (or in a nested namespace if the file already has DTO records, e.g. AppSettingsDto), add:

public sealed record UpdateListClientDto(string Id, string Name, string? WorkingDir, string DefaultCommitType);
public sealed record UpdateListConfigClientDto(string ListId, string? Model, string? SystemPrompt, string? AgentPath);
public sealed record UpdateTaskAgentSettingsClientDto(string TaskId, string? Model, string? SystemPrompt, string? AgentPath);
public sealed record ListConfigClientDto(string? Model, string? SystemPrompt, string? AgentPath);

Use the existing file's naming convention instead of "ClientDto" if the file already has a convention (e.g., AppSettingsDto with no suffix — then follow suit and just name them UpdateListDto, UpdateListConfigDto, etc. in the UI namespace).

  • Step 3: Add the four client methods

Inside the WorkerClient class, alongside UpdateAppSettings:

public async Task UpdateListAsync(UpdateListClientDto dto, CancellationToken ct = default)
{
    await _hub.InvokeAsync("UpdateList", dto, ct);
}

public async Task UpdateListConfigAsync(UpdateListConfigClientDto dto, CancellationToken ct = default)
{
    await _hub.InvokeAsync("UpdateListConfig", dto, ct);
}

public async Task<ListConfigClientDto?> GetListConfigAsync(string listId, CancellationToken ct = default)
{
    return await _hub.InvokeAsync<ListConfigClientDto?>("GetListConfig", listId, ct);
}

public async Task UpdateTaskAgentSettingsAsync(UpdateTaskAgentSettingsClientDto dto, CancellationToken ct = default)
{
    await _hub.InvokeAsync("UpdateTaskAgentSettings", dto, ct);
}
  • Step 4: Subscribe to ListUpdated event

Find where other hub events are subscribed (usually in WorkerClient's ConfigureHub / StartAsync / constructor-equivalent setup). Add a line mirroring the pattern used for TaskUpdated:

_hub.On<string>("ListUpdated", listId => ListUpdatedEvent?.Invoke(listId));

And declare the event alongside the other events (e.g. TaskUpdatedEvent):

public event Action<string>? ListUpdatedEvent;
  • Step 5: Build UI
dotnet build src/ClaudeDo.Ui/ClaudeDo.Ui.csproj

Expected: build succeeded, 0 errors.

  • Step 6: Commit
git add src/ClaudeDo.Ui/Services/WorkerClient.cs
git commit -m "feat(ui): WorkerClient supports list/task agent settings + ListUpdated event"

Task 5: ListSettingsModalViewModel

Files:

  • Create: src/ClaudeDo.Ui/ViewModels/Modals/ListSettingsModalViewModel.cs

  • Modify: src/ClaudeDo.App/App.axaml.cs (or wherever DI is registered — look for AddTransient<WorktreeModalViewModel> / AddSingleton<MergeModalViewModel> to find the registration block)

  • Step 1: Create the ViewModel

Write src/ClaudeDo.Ui/ViewModels/Modals/ListSettingsModalViewModel.cs:

using System.Collections.ObjectModel;
using ClaudeDo.Data.Models;
using ClaudeDo.Ui.Services;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;

namespace ClaudeDo.Ui.ViewModels.Modals;

public sealed partial class ListSettingsModalViewModel : ViewModelBase
{
    private readonly WorkerClient _worker;

    // Set by caller before Show
    public string ListId { get; set; } = "";

    [ObservableProperty] private string _name = "";
    [ObservableProperty] private string _workingDir = "";
    [ObservableProperty] private string _defaultCommitType = "chore";

    [ObservableProperty] private string _selectedModel = "(default)";
    [ObservableProperty] private string _systemPrompt = "";
    [ObservableProperty] private AgentInfo? _selectedAgent;

    public ObservableCollection<string> ModelOptions { get; } = new()
    {
        "(default)", "sonnet", "opus", "haiku",
    };

    public ObservableCollection<string> CommitTypeOptions { get; } = new()
    {
        "chore", "feat", "fix", "refactor", "docs", "test", "ci", "perf", "style", "build",
    };

    public ObservableCollection<AgentInfo> Agents { get; } = new();

    public Action? CloseAction { get; set; }

    public ListSettingsModalViewModel(WorkerClient worker)
    {
        _worker = worker;
    }

    public async Task LoadAsync(
        string listId,
        string name,
        string? workingDir,
        string defaultCommitType,
        CancellationToken ct = default)
    {
        ListId = listId;
        Name = name;
        WorkingDir = workingDir ?? "";
        DefaultCommitType = string.IsNullOrWhiteSpace(defaultCommitType) ? "chore" : defaultCommitType;

        Agents.Clear();
        Agents.Add(new AgentInfo("(none)", "", ""));
        var agents = await _worker.GetAgentsAsync();
        foreach (var a in agents) Agents.Add(a);

        var config = await _worker.GetListConfigAsync(listId, ct);
        SelectedModel = string.IsNullOrWhiteSpace(config?.Model) ? "(default)" : config!.Model!;
        SystemPrompt = config?.SystemPrompt ?? "";
        SelectedAgent = string.IsNullOrWhiteSpace(config?.AgentPath)
            ? Agents[0]
            : (Agents.FirstOrDefault(a => a.Path == config!.AgentPath) ?? Agents[0]);
    }

    [RelayCommand]
    private async Task SaveAsync()
    {
        var model = SelectedModel == "(default)" ? null : SelectedModel;
        var sp = string.IsNullOrWhiteSpace(SystemPrompt) ? null : SystemPrompt;
        var ap = SelectedAgent is null || string.IsNullOrWhiteSpace(SelectedAgent.Path) ? null : SelectedAgent.Path;

        await _worker.UpdateListAsync(new UpdateListClientDto(
            ListId,
            string.IsNullOrWhiteSpace(Name) ? "Untitled" : Name,
            string.IsNullOrWhiteSpace(WorkingDir) ? null : WorkingDir,
            DefaultCommitType));

        await _worker.UpdateListConfigAsync(new UpdateListConfigClientDto(
            ListId, model, sp, ap));

        CloseAction?.Invoke();
    }

    [RelayCommand]
    private void Cancel() => CloseAction?.Invoke();

    [RelayCommand]
    private void ResetAgentSettings()
    {
        SelectedModel = "(default)";
        SystemPrompt = "";
        SelectedAgent = Agents.Count > 0 ? Agents[0] : null;
    }
}

Match the exact DTO record names used in WorkerClient.cs from Task 4. If you named them UpdateListDto (without Client suffix), update the new UpdateListClientDto(...) calls above to new UpdateListDto(...).

  • Step 2: Register in DI

Find the file that registers modal ViewModels (search the repo for AddTransient<WorktreeModalViewModel> or AddSingleton<MergeModalViewModel> — likely in src/ClaudeDo.App/App.axaml.cs or a DI extension). Add:

services.AddTransient<ListSettingsModalViewModel>();
  • Step 3: Build UI
dotnet build src/ClaudeDo.Ui/ClaudeDo.Ui.csproj
dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj

Expected: both build succeeded, 0 errors.

  • Step 4: Commit
git add src/ClaudeDo.Ui/ViewModels/Modals/ListSettingsModalViewModel.cs src/ClaudeDo.App/App.axaml.cs
git commit -m "feat(ui): add ListSettingsModalViewModel"

Task 6: ListSettingsModalView

Files:

  • Create: src/ClaudeDo.Ui/Views/Modals/ListSettingsModalView.axaml

  • Create: src/ClaudeDo.Ui/Views/Modals/ListSettingsModalView.axaml.cs

  • Step 1: Create the view XAML

Write src/ClaudeDo.Ui/Views/Modals/ListSettingsModalView.axaml:

<Window xmlns="https://github.com/avaloniaui"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        xmlns:vm="using:ClaudeDo.Ui.ViewModels.Modals"
        x:Class="ClaudeDo.Ui.Views.Modals.ListSettingsModalView"
        x:DataType="vm:ListSettingsModalViewModel"
        Title="List settings"
        Width="520" Height="600"
        WindowStartupLocation="CenterOwner"
        CanResize="False">
    <DockPanel Margin="16">
        <StackPanel DockPanel.Dock="Bottom" Orientation="Horizontal" HorizontalAlignment="Right" Spacing="8" Margin="0,16,0,0">
            <Button Content="Cancel" Command="{Binding CancelCommand}" />
            <Button Content="Save" Command="{Binding SaveCommand}" Classes="accent" />
        </StackPanel>

        <ScrollViewer>
            <StackPanel Spacing="16">
                <TextBlock Text="General" FontSize="16" FontWeight="SemiBold" />

                <StackPanel Spacing="4">
                    <TextBlock Text="Name" />
                    <TextBox Text="{Binding Name}" />
                </StackPanel>

                <StackPanel Spacing="4">
                    <TextBlock Text="Working directory" />
                    <Grid ColumnDefinitions="*,Auto" >
                        <TextBox Grid.Column="0" Text="{Binding WorkingDir}" Watermark="(none)" />
                        <Button Grid.Column="1" Content="Browse…" Margin="8,0,0,0" Click="BrowseClicked" />
                    </Grid>
                </StackPanel>

                <StackPanel Spacing="4">
                    <TextBlock Text="Default commit type" />
                    <ComboBox ItemsSource="{Binding CommitTypeOptions}"
                              SelectedItem="{Binding DefaultCommitType, Mode=TwoWay}"
                              HorizontalAlignment="Left" MinWidth="160" />
                </StackPanel>

                <Separator Margin="0,8,0,8" />

                <Grid ColumnDefinitions="*,Auto">
                    <TextBlock Grid.Column="0" Text="Agent" FontSize="16" FontWeight="SemiBold" />
                    <Button Grid.Column="1" Content="Reset agent settings"
                            Command="{Binding ResetAgentSettingsCommand}" />
                </Grid>

                <StackPanel Spacing="4">
                    <TextBlock Text="Model" />
                    <ComboBox ItemsSource="{Binding ModelOptions}"
                              SelectedItem="{Binding SelectedModel, Mode=TwoWay}"
                              HorizontalAlignment="Left" MinWidth="160" />
                </StackPanel>

                <StackPanel Spacing="4">
                    <TextBlock Text="System prompt (appended)" />
                    <TextBox Text="{Binding SystemPrompt, Mode=TwoWay}"
                             AcceptsReturn="True" TextWrapping="Wrap"
                             MinHeight="80" />
                </StackPanel>

                <StackPanel Spacing="4">
                    <TextBlock Text="Agent file" />
                    <ComboBox ItemsSource="{Binding Agents}"
                              SelectedItem="{Binding SelectedAgent, Mode=TwoWay}"
                              HorizontalAlignment="Left" MinWidth="240">
                        <ComboBox.ItemTemplate>
                            <DataTemplate>
                                <StackPanel>
                                    <TextBlock Text="{Binding Name}" />
                                    <TextBlock Text="{Binding Description}" Opacity="0.6" FontSize="11" />
                                </StackPanel>
                            </DataTemplate>
                        </ComboBox.ItemTemplate>
                    </ComboBox>
                </StackPanel>
            </StackPanel>
        </ScrollViewer>
    </DockPanel>
</Window>
  • Step 2: Create the code-behind

Write src/ClaudeDo.Ui/Views/Modals/ListSettingsModalView.axaml.cs:

using Avalonia.Controls;
using Avalonia.Interactivity;
using Avalonia.Platform.Storage;
using ClaudeDo.Ui.ViewModels.Modals;

namespace ClaudeDo.Ui.Views.Modals;

public partial class ListSettingsModalView : Window
{
    public ListSettingsModalView()
    {
        InitializeComponent();
    }

    private async void BrowseClicked(object? sender, RoutedEventArgs e)
    {
        if (DataContext is not ListSettingsModalViewModel vm) return;
        var top = TopLevel.GetTopLevel(this);
        if (top is null) return;

        var folders = await top.StorageProvider.OpenFolderPickerAsync(new FolderPickerOpenOptions
        {
            Title = "Choose working directory",
            AllowMultiple = false,
        });
        if (folders.Count > 0)
        {
            vm.WorkingDir = folders[0].Path.LocalPath;
        }
    }
}
  • Step 3: Build UI
dotnet build src/ClaudeDo.Ui/ClaudeDo.Ui.csproj

Expected: build succeeded, 0 errors.

  • Step 4: Commit
git add src/ClaudeDo.Ui/Views/Modals/ListSettingsModalView.axaml src/ClaudeDo.Ui/Views/Modals/ListSettingsModalView.axaml.cs
git commit -m "feat(ui): add ListSettingsModalView"

Task 7: Wire context menu + gear button in ListsIslandView

Files:

  • Modify: src/ClaudeDo.Ui/Views/Islands/ListsIslandView.axaml

  • Modify: src/ClaudeDo.Ui/ViewModels/Islands/ListsIslandViewModel.cs

  • Modify: src/ClaudeDo.Ui/ViewModels/Islands/ListNavItemViewModel.cs (expose fields needed by modal)

  • Step 1: Ensure ListNavItemViewModel carries list fields

Read src/ClaudeDo.Ui/ViewModels/Islands/ListNavItemViewModel.cs. Confirm it has Id, Name. Add WorkingDir (string?) and DefaultCommitType (string) as [ObservableProperty] fields if missing. Also ensure the loader that populates ListNavItemViewModels reads these from the DB.

  • Step 2: Add OpenSettingsAsync command to ListsIslandViewModel

Read src/ClaudeDo.Ui/ViewModels/Islands/ListsIslandViewModel.cs. Add a field ShowListSettingsModal (view wires it) and a command:

// Set by the view so the command can show the modal as a dialog.
public Func<ListSettingsModalViewModel, System.Threading.Tasks.Task>? ShowListSettingsModal { get; set; }

[RelayCommand]
private async System.Threading.Tasks.Task OpenSettingsAsync(ListNavItemViewModel? row)
{
    if (row is null || ShowListSettingsModal is null) return;
    var vm = _services.GetRequiredService<ListSettingsModalViewModel>();
    await vm.LoadAsync(row.Id, row.Name, row.WorkingDir, row.DefaultCommitType);
    await ShowListSettingsModal(vm);
    // Refresh this row from DB in case name/working-dir changed
    await RefreshRowAsync(row.Id);
}

Add an _services constructor dependency (IServiceProvider) if not already present. Add RefreshRowAsync(string listId) that re-queries the list entity and updates the row's observable fields. Also subscribe to _worker.ListUpdatedEvent += id => _ = RefreshRowAsync(id); in the constructor (dispatch to UI thread if needed per existing pattern).

  • Step 3: Update ListsIslandView.axaml

Open src/ClaudeDo.Ui/Views/Islands/ListsIslandView.axaml and find the DataTemplate for list rows. Add a context menu + a gear button. Example additions (adapt to the current DataTemplate/ItemsControl structure):

<Grid ColumnDefinitions="*,Auto">
    <!-- existing name cell -->
    <TextBlock Grid.Column="0" Text="{Binding Name}" VerticalAlignment="Center" />
    <!-- NEW gear button, visible on hover via opacity trigger or always visible -->
    <Button Grid.Column="1"
            Content="⚙"
            ToolTip.Tip="Settings…"
            Command="{Binding $parent[UserControl].((vm:ListsIslandViewModel)DataContext).OpenSettingsCommand}"
            CommandParameter="{Binding}" />
    <Grid.ContextMenu>
        <ContextMenu>
            <MenuItem Header="Settings…"
                      Command="{Binding $parent[UserControl].((vm:ListsIslandViewModel)DataContext).OpenSettingsCommand}"
                      CommandParameter="{Binding}" />
        </ContextMenu>
    </Grid.ContextMenu>
</Grid>

The exact ancestor navigation syntax depends on how other commands are bound in this view — match whatever pattern ListsIslandView already uses for its commands.

Also wire ShowListSettingsModal in ListsIslandView.axaml.cs OnDataContextChanged (mirror how other islands set their modal-show callbacks):

if (DataContext is ListsIslandViewModel vm)
{
    vm.ShowListSettingsModal = async modal =>
    {
        var window = new ListSettingsModalView { DataContext = modal };
        modal.CloseAction = () => window.Close();
        var top = TopLevel.GetTopLevel(this) as Window;
        if (top is null) window.Show();
        else await window.ShowDialog(top);
    };
}
  • Step 4: Build
dotnet build src/ClaudeDo.Ui/ClaudeDo.Ui.csproj
dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj

Expected: 0 errors.

  • Step 5: Commit
git add src/ClaudeDo.Ui/Views/Islands/ListsIslandView.axaml src/ClaudeDo.Ui/Views/Islands/ListsIslandView.axaml.cs src/ClaudeDo.Ui/ViewModels/Islands/ListsIslandViewModel.cs src/ClaudeDo.Ui/ViewModels/Islands/ListNavItemViewModel.cs
git commit -m "feat(ui): open ListSettingsModal via context menu and gear button"

Task 8: Per-task agent settings in DetailsIsland

Files:

  • Modify: src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs
  • Modify: src/ClaudeDo.Ui/Views/Islands/DetailsIslandView.axaml

Per-task overrides with inherit-fallback display. Auto-save on change (debounced 300ms).

  • Step 1: Extend DetailsIslandViewModel

In src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs, add (alongside the existing _model field):

[ObservableProperty] private string _taskModelSelection = "(inherit)";
[ObservableProperty] private string _taskSystemPrompt = "";
[ObservableProperty] private AgentInfo? _taskSelectedAgent;

[ObservableProperty] private string _effectiveModelHint = "";
[ObservableProperty] private string _effectiveSystemPromptHint = "";
[ObservableProperty] private string _effectiveAgentHint = "";

public ObservableCollection<string> TaskModelOptions { get; } = new()
{
    "(inherit)", "sonnet", "opus", "haiku",
};

public ObservableCollection<AgentInfo> TaskAgentOptions { get; } = new();

private bool _suppressAgentSave;
private CancellationTokenSource? _agentSaveCts;

Add an async helper LoadAgentSettingsAsync(TaskEntity taskEntity) called from BindAsync after the entity is loaded:

private async Task LoadAgentSettingsAsync(TaskEntity entity, CancellationToken ct)
{
    _suppressAgentSave = true;
    try
    {
        TaskAgentOptions.Clear();
        TaskAgentOptions.Add(new AgentInfo("(inherit)", "", ""));
        var agents = await _worker.GetAgentsAsync();
        foreach (var a in agents) TaskAgentOptions.Add(a);

        TaskModelSelection = string.IsNullOrWhiteSpace(entity.Model) ? "(inherit)" : entity.Model!;
        TaskSystemPrompt = entity.SystemPrompt ?? "";
        TaskSelectedAgent = string.IsNullOrWhiteSpace(entity.AgentPath)
            ? TaskAgentOptions[0]
            : (TaskAgentOptions.FirstOrDefault(a => a.Path == entity.AgentPath) ?? TaskAgentOptions[0]);

        // Effective-value hints — load list's config for display
        var listCfg = await _worker.GetListConfigAsync(entity.ListId, ct);
        EffectiveModelHint = listCfg?.Model ?? "(global default)";
        EffectiveSystemPromptHint = string.IsNullOrWhiteSpace(listCfg?.SystemPrompt) ? "(none)" : listCfg!.SystemPrompt!;
        EffectiveAgentHint = string.IsNullOrWhiteSpace(listCfg?.AgentPath) ? "(none)" : System.IO.Path.GetFileName(listCfg!.AgentPath!);
    }
    finally
    {
        _suppressAgentSave = false;
    }
}

Add partial void handlers that trigger a debounced save:

partial void OnTaskModelSelectionChanged(string value) => QueueAgentSave();
partial void OnTaskSystemPromptChanged(string value) => QueueAgentSave();
partial void OnTaskSelectedAgentChanged(AgentInfo? value) => QueueAgentSave();

private void QueueAgentSave()
{
    if (_suppressAgentSave || Task is null) return;
    _agentSaveCts?.Cancel();
    _agentSaveCts = new CancellationTokenSource();
    var ct = _agentSaveCts.Token;
    _ = SaveAgentSettingsAsync(ct);
}

private async Task SaveAgentSettingsAsync(CancellationToken ct)
{
    try
    {
        await Task.Delay(300, ct);
        if (Task is null) return;

        var model = TaskModelSelection == "(inherit)" ? null : TaskModelSelection;
        var sp = string.IsNullOrWhiteSpace(TaskSystemPrompt) ? null : TaskSystemPrompt;
        var ap = TaskSelectedAgent is null || string.IsNullOrWhiteSpace(TaskSelectedAgent.Path)
            ? null : TaskSelectedAgent.Path;

        await _worker.UpdateTaskAgentSettingsAsync(
            new UpdateTaskAgentSettingsClientDto(Task.Id, model, sp, ap), ct);
    }
    catch (OperationCanceledException) { /* a newer change superseded this one */ }
    catch { /* best-effort; live log will surface worker errors */ }
}

Also add a computed bool IsAgentSectionEnabled => !IsRunning; (already partial for IsRunning; raise property change when it flips by adding to the existing OnAgentStatusLabelChanged method):

OnPropertyChanged(nameof(IsAgentSectionEnabled));

Note: System.Threading.Tasks.Task.Delay — the file uses Task as an [ObservableProperty] name so Task.Delay resolves to the property, not the type. Fully qualify as await System.Threading.Tasks.Task.Delay(300, ct);.

  • Step 2: Add the expander to DetailsIslandView.axaml

Open the view and add, somewhere inside the main scroll/stack layout (after the existing notes / before or after other sections — pick a natural location in the current layout):

<Expander Header="Agent settings (overrides)"
          IsExpanded="False"
          IsEnabled="{Binding IsAgentSectionEnabled}">
    <StackPanel Spacing="8" Margin="0,8,0,0">

        <StackPanel Spacing="2">
            <TextBlock Text="Model" />
            <ComboBox ItemsSource="{Binding TaskModelOptions}"
                      SelectedItem="{Binding TaskModelSelection, Mode=TwoWay}"
                      MinWidth="160" HorizontalAlignment="Left" />
            <TextBlock Text="{Binding EffectiveModelHint, StringFormat='Effective if inherited: {0}'}"
                       Opacity="0.6" FontSize="11" />
        </StackPanel>

        <StackPanel Spacing="2">
            <TextBlock Text="System prompt (appended)" />
            <TextBox Text="{Binding TaskSystemPrompt, Mode=TwoWay}"
                     AcceptsReturn="True" TextWrapping="Wrap" MinHeight="60"
                     Watermark="{Binding EffectiveSystemPromptHint}" />
        </StackPanel>

        <StackPanel Spacing="2">
            <TextBlock Text="Agent file" />
            <ComboBox ItemsSource="{Binding TaskAgentOptions}"
                      SelectedItem="{Binding TaskSelectedAgent, Mode=TwoWay}"
                      MinWidth="240" HorizontalAlignment="Left">
                <ComboBox.ItemTemplate>
                    <DataTemplate>
                        <TextBlock Text="{Binding Name}" />
                    </DataTemplate>
                </ComboBox.ItemTemplate>
            </ComboBox>
            <TextBlock Text="{Binding EffectiveAgentHint, StringFormat='Effective if inherited: {0}'}"
                       Opacity="0.6" FontSize="11" />
        </StackPanel>

    </StackPanel>
</Expander>
  • Step 3: Call LoadAgentSettingsAsync from BindAsync

In DetailsIslandViewModel.BindAsync, after AgentStatusLabel = entity.Status.ToString();, add:

await LoadAgentSettingsAsync(entity, ct);
ct.ThrowIfCancellationRequested();

In Bind(TaskRowViewModel? row) — the row == null branch — reset the new fields:

TaskModelSelection = "(inherit)";
TaskSystemPrompt = "";
TaskSelectedAgent = null;
EffectiveModelHint = "";
EffectiveSystemPromptHint = "";
EffectiveAgentHint = "";
  • Step 4: Build
dotnet build src/ClaudeDo.Ui/ClaudeDo.Ui.csproj
dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj

Expected: 0 errors.

  • Step 5: Commit
git add src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs src/ClaudeDo.Ui/Views/Islands/DetailsIslandView.axaml
git commit -m "feat(ui): per-task agent settings in DetailsIsland"

Task 9: Documentation refresh

Files:

  • Modify: src/ClaudeDo.Data/CLAUDE.md
  • Modify: src/ClaudeDo.Worker/CLAUDE.md
  • Modify: src/ClaudeDo.Ui/CLAUDE.md

The project CLAUDE.md files are stale — Data/CLAUDE.md doesn't mention ListConfigEntity, TaskRunEntity, or the Model/SystemPrompt/AgentPath fields that already exist. Bring them up to date now that the full per-task/per-list path is wired end-to-end.

  • Step 1: Update ClaudeDo.Data/CLAUDE.md

Add to the Models section:

- **ListConfigEntity** — ListId (PK, 1:1 with list), Model, SystemPrompt, AgentPath (all nullable)
- **TaskRunEntity** — per-run record (session_id, tokens, turns, result, structured output, exit code, log path)
- **SubtaskEntity**, **AgentInfo** — existing helpers
- **TaskEntity** — add Model / SystemPrompt / AgentPath override fields (nullable), IsStarred, IsMyDay, Notes

Add to Repositories section:

- **ListRepository** — also manages `list_config` via `GetConfigAsync`, `SetConfigAsync` (upsert), `DeleteConfigAsync`
- **TaskRepository** — also has `UpdateAgentSettingsAsync` (model/system-prompt/agent-path)
- **TaskRunRepository**, **SubtaskRepository**, **AppSettingsRepository** — existing

Schema section: bump to 9+ tables including list_config, task_runs, subtasks, app_settings.

  • Step 2: Update ClaudeDo.Worker/CLAUDE.md

In the SignalR Hub section, extend the method list:

**WorkerHub** methods: Ping, GetActive, RunNow, CancelTask, WakeQueue, ContinueTask, ResetTask, GetAgents, RefreshAgents,
GetAppSettings, UpdateAppSettings, CleanupFinishedWorktrees, ResetAllWorktrees, MergeTask, GetMergeTargets,
UpdateList, UpdateListConfig, GetListConfig, UpdateTaskAgentSettings

In events list, add ListUpdated(listId) alongside TaskUpdated.

  • Step 3: Update ClaudeDo.Ui/CLAUDE.md

In the "Views" section, add:

- **ListSettingsModalView** — edits list name, working dir, default commit type, and per-list Model/SystemPrompt/AgentPath. Opened via context menu or gear button on a list row.
- **DetailsIslandView** — now contains an "Agent settings (overrides)" expander with per-task Model/SystemPrompt/AgentPath, showing inherited effective values. Disabled while task is running.

In the "Services" section, extend WorkerClient method list to include UpdateListAsync, UpdateListConfigAsync, GetListConfigAsync, UpdateTaskAgentSettingsAsync. Events: add ListUpdatedEvent.

  • Step 4: Commit
git add src/ClaudeDo.Data/CLAUDE.md src/ClaudeDo.Worker/CLAUDE.md src/ClaudeDo.Ui/CLAUDE.md
git commit -m "docs: refresh CLAUDE.md files for agent settings UI"

Task 10: Full build + test + manual smoke checklist

  • Step 1: Full build of all non-installer projects
dotnet build src/ClaudeDo.Data/ClaudeDo.Data.csproj
dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj
dotnet build src/ClaudeDo.Ui/ClaudeDo.Ui.csproj
dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj

Expected: all four build with 0 errors.

  • Step 2: Run full test suite
dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj

Expected: all tests pass.

  • Step 3: Record manual smoke-test checklist

This step is documentation only — the smoke test must be run by a human after merging (UI). Record the following in the PR description (or a file like docs/smoke-tests/2026-04-22-agent-settings.md):

  1. Open ClaudeDo.App.
  2. Right-click a list → Settings… opens modal.
  3. Enter a Name, choose a Model (e.g. opus), enter a system prompt, pick an agent → Save.
  4. Reopen modal → values persist.
  5. Select a task in that list → DetailsIsland shows "Agent settings (overrides)" expander, expand it, confirm fields show (inherit) and the hint line reflects the list's chosen Model.
  6. Set task Model to haiku → run the task → verify log shows --model haiku was passed.
  7. Click Reset agent settings in list modal → Save → DetailsIsland now shows (global default) hint.
  8. Start a task run → verify DetailsIsland agent expander is disabled while Running.
  • Step 4: Final status check
git status
git log --oneline -15

Expected: working tree clean, 8 feature/test commits since the start + 1 doc commit + 1 spec commit.


Notes for the implementing subagents

  • Sonnet only (project preference).
  • Minimal diff: do NOT refactor unrelated code. Only the files listed per task.
  • No summaries, no comments added to unchanged code — the user dislikes over-commenting.
  • If the project's file/naming pattern diverges from what's shown (e.g. DI registration file, hub broadcaster signature), match the existing pattern. The plan's code is illustrative but naming alignment with the codebase overrides it.
  • If a test file references helpers (TempDatabase) that don't exist, mirror the helper actually used in the project's other *RepositoryTests. Do not invent new test infrastructure.
  • Commit after each completed task (as listed in the plan).