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

1224 lines
46 KiB
Markdown

# 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`:
```csharp
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`:
```csharp
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`:
```csharp
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):
```csharp
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.
```csharp
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`:
```csharp
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.):
```csharp
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):
```csharp
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:
```csharp
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`:
```csharp
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`:
```csharp
_hub.On<string>("ListUpdated", listId => ListUpdatedEvent?.Invoke(listId));
```
And declare the event alongside the other events (e.g. `TaskUpdatedEvent`):
```csharp
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`:
```csharp
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:
```csharp
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`:
```xml
<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`:
```csharp
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 `ListNavItemViewModel`s 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:
```csharp
// 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):
```xml
<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):
```csharp
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):
```csharp
[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:
```csharp
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:
```csharp
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):
```csharp
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):
```xml
<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:
```csharp
await LoadAgentSettingsAsync(entity, ct);
ct.ThrowIfCancellationRequested();
```
In `Bind(TaskRowViewModel? row)` — the `row == null` branch — reset the new fields:
```csharp
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).