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.axamlsrc/ClaudeDo.Ui/Views/Modals/ListSettingsModalView.axaml.cssrc/ClaudeDo.Ui/ViewModels/Modals/ListSettingsModalViewModel.cstests/ClaudeDo.Worker.Tests/Repositories/ListRepositoryDeleteConfigTests.cstests/ClaudeDo.Worker.Tests/Repositories/TaskRepositoryAgentSettingsTests.cstests/ClaudeDo.Worker.Tests/Hub/AgentSettingsHubTests.cs
Modified files:
src/ClaudeDo.Data/Repositories/ListRepository.cs— addDeleteConfigAsyncsrc/ClaudeDo.Data/Repositories/TaskRepository.cs— addUpdateAgentSettingsAsyncsrc/ClaudeDo.Worker/Hub/WorkerHub.cs— add 4 methods + DTOssrc/ClaudeDo.Worker/Hub/HubBroadcaster.cs— addListUpdatedAsyncsrc/ClaudeDo.Ui/Services/WorkerClient.cs— add 4 methods +ListUpdatedEventsrc/ClaudeDo.Ui/ViewModels/Modals/SettingsModalViewModel.cs(no changes expected, only for reference)src/ClaudeDo.App/App.axaml.cs(or DI extension file) — registerListSettingsModalViewModelsrc/ClaudeDo.Ui/Views/Islands/ListsIslandView.axaml— add context menu + gear buttonsrc/ClaudeDo.Ui/ViewModels/Islands/ListsIslandViewModel.cs— addOpenSettingsCommand, subscribeListUpdatedsrc/ClaudeDo.Ui/ViewModels/Islands/ListNavItemViewModel.cs— expose list fields needed by modalsrc/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs— add agent-settings fields + auto-savesrc/ClaudeDo.Ui/Views/Islands/DetailsIslandView.axaml— add "Agent settings" expandersrc/ClaudeDo.Data/CLAUDE.md— refresh with new repo methods + ListConfigEntitysrc/ClaudeDo.Worker/CLAUDE.md— document new hub methods +ListUpdatedsrc/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/Infrastructurenamespace differs, match the existing test infrastructure pattern seen in other*RepositoryTestsfiles intests/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
ListUpdatedevent
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 forAddTransient<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.csfrom Task 4. If you named themUpdateListDto(withoutClientsuffix), update thenew UpdateListClientDto(...)calls above tonew 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
ListNavItemViewModelcarries 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
OpenSettingsAsynccommand toListsIslandViewModel
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 usesTaskas an[ObservableProperty]name soTask.Delayresolves to the property, not the type. Fully qualify asawait 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
LoadAgentSettingsAsyncfromBindAsync
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):
- Open ClaudeDo.App.
- Right-click a list → Settings… opens modal.
- Enter a Name, choose a Model (e.g.
opus), enter a system prompt, pick an agent → Save. - Reopen modal → values persist.
- 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. - Set task Model to
haiku→ run the task → verify log shows--model haikuwas passed. - Click Reset agent settings in list modal → Save → DetailsIsland now shows
(global default)hint. - 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).