docs(specs): agent settings per list and per task UI reimplementation

This commit is contained in:
Mika Kuns
2026-04-22 12:01:20 +02:00
parent cfb410dd4d
commit 68f461d0e1

View File

@@ -0,0 +1,162 @@
# Design: Agent settings per list and per task (UI reimplementation)
Date: 2026-04-22
Status: Approved by user, implementation pending
## Problem
During the recent UI rework, the editors for per-list and per-task agent settings were lost. The data layer and Worker still support them (`TaskEntity.Model/SystemPrompt/AgentPath`, `ListConfigEntity`, `TaskRunner` + `ClaudeArgsBuilder`), but the UI has zero references to these fields. Users currently cannot set model, custom system prompt, or agent file from the app.
## Goal
Restore the ability to configure, per **list** and per **task**:
- `Model``opus` / `sonnet` / `haiku` / inherit
- `SystemPrompt` — free-text, appended to Claude's system prompt
- `AgentPath` — selection from agent files discovered by the Worker under `~/.todo-app/agents/*.md`
Per-task values override per-list values. Per-list values override global defaults from `worker.config.json`. This cascade is already implemented in `TaskRunner`.
## Non-goals
- Agent file CRUD in the UI (read-only picker only)
- `--allowedTools`, `--bare`, permission modes (deferred, matches existing worker design notes)
- Any schema migration — the DB already has the required columns/tables
## Approach
**Update-and-broadcast over SignalR.** UI never touches the DB directly; all writes go through new `WorkerHub` methods. Worker persists via repositories, then broadcasts `ListUpdated` / `TaskUpdated` so connected clients refresh.
## Sections
### 1. Data layer additions
New repository `src/ClaudeDo.Data/Repositories/ListConfigRepository.cs`:
- `GetByListIdAsync(string listId, CancellationToken) -> ListConfigEntity?`
- `UpsertAsync(ListConfigEntity, CancellationToken) -> ListConfigEntity`
- `DeleteAsync(string listId, CancellationToken) -> bool`
New method on `ListRepository`:
- `UpdateAsync(ListEntity, CancellationToken)` — updates `Name`, `WorkingDir`, `DefaultCommitType`. Included because the consolidated list-settings modal edits these alongside agent fields.
New method on `TaskRepository`:
- `UpdateAgentSettingsAsync(string taskId, string? model, string? systemPrompt, string? agentPath, CancellationToken) -> bool`
- `null` values mean "inherit" (column is nulled out in DB).
- Kept as a narrow method to avoid widening `UpdateAsync`.
DI: register `ListConfigRepository` alongside other repos.
No migration — all columns/tables already exist.
### 2. SignalR hub surface
New DTOs in `src/ClaudeDo.Data/Dtos/` (project existing DTO pattern):
- `UpdateListDto(string Id, string Name, string? WorkingDir, string DefaultCommitType)`
- `UpdateListConfigDto(string ListId, string? Model, string? SystemPrompt, string? AgentPath)`
- `UpdateTaskAgentSettingsDto(string TaskId, string? Model, string? SystemPrompt, string? AgentPath)`
New methods on `WorkerHub`:
- `UpdateList(UpdateListDto dto)` — calls `ListRepository.UpdateAsync`, then broadcasts `ListUpdated(listId)`.
- `UpdateListConfig(UpdateListConfigDto dto)` — upserts via `ListConfigRepository.UpsertAsync`, broadcasts `ListUpdated(listId)`. If all three fields are null, calls `DeleteAsync` instead so the row doesn't linger empty.
- `UpdateTaskAgentSettings(UpdateTaskAgentSettingsDto dto)` — calls `TaskRepository.UpdateAgentSettingsAsync`, broadcasts `TaskUpdated(taskId)` (existing event).
New broadcast method on `HubBroadcaster`:
- `Task ListUpdatedAsync(string listId) => _hub.Clients.All.SendAsync("ListUpdated", listId);`
Loader endpoint to add:
- `GetListConfig(string listId)` — returns `(string? Model, string? SystemPrompt, string? AgentPath)` record, or `null` if no row. Used by `ListSettingsModal` and by `DetailsIslandViewModel` for effective-value inheritance display. Existing `GetLists` / `GetTasks` already cover the rest.
### 3. UI — ListSettingsModal
New files:
- `src/ClaudeDo.Ui/Views/Modals/ListSettingsModalView.axaml` + `.axaml.cs`
- `src/ClaudeDo.Ui/ViewModels/Modals/ListSettingsModalViewModel.cs`
Entry points:
- **Right-click** on a list row in `ListsIslandView``ContextMenu` with "Settings…" item
- **Gear button** on the list row (visible on hover/selected)
Layout: vertical stack with two grouped sections.
**General**
- `Name` — TextBox (required, non-empty)
- `Working directory` — TextBox + "Browse…" button (folder picker)
- `Default commit type` — ComboBox populated with `chore, feat, fix, refactor, docs, test, ci, perf, style, build`
**Agent**
- `Model` — ComboBox: `(default)`, `sonnet`, `opus`, `haiku` (selecting `(default)` sends `null`)
- `System prompt` — multi-line TextBox with 4-row min height; empty = `null`
- `Agent file` — ComboBox populated via `WorkerClient.GetAgentsAsync()`, first item `(none)`; tooltip shows each agent's `Description`. Empty selection = `null`.
- `Reset agent settings` button — clears Model/SystemPrompt/AgentPath in the form (save then sends null triple → backend `DeleteAsync`).
Commands:
- `SaveCommand` — validates, calls `UpdateList` then `UpdateListConfig`, closes modal on success.
- `CancelCommand` — closes without saving.
Loading: on open, ViewModel calls `GetListConfig` and populates fields; missing row means all three agent fields start empty.
ViewModel uses `[ObservableProperty]` / `[RelayCommand]` per project convention.
### 4. UI — DetailsIsland per-task agent section
Modify `DetailsIslandView.axaml` + `DetailsIslandViewModel.cs`.
Add an `Expander` titled **"Agent settings (overrides)"**, collapsed by default, below the existing task detail content.
Fields (same control types as ListSettingsModal's Agent section):
- `Model` — ComboBox prepended with `(inherit: <effective>)` option as the unset state
- `System prompt` — TextBox with watermark showing effective inherited value when empty
- `Agent file` — ComboBox prepended with `(inherit: <effective>)`
Effective-value computation:
- Server-side would be more accurate but requires a new hub call. For v1, UI computes locally: if task field is null, show the list's config value; if that's also null, show `(global default)`.
- `DetailsIslandViewModel` already has access to the selected `TaskDto` + list; add list-config loading when task selection changes.
Persistence: auto-save on field change (debounced 300ms) calling `UpdateTaskAgentSettings`. No separate Save button — matches "settings" feel.
If the task is currently `Running`, fields are **read-only** (disabling controls). Agent settings only apply to the next invocation.
### 5. Testing
xUnit integration tests in `tests/ClaudeDo.Worker.Tests` against a real SQLite temp DB:
- `ListConfigRepositoryTests`
- `UpsertAsync_InsertsWhenAbsent`
- `UpsertAsync_UpdatesWhenPresent`
- `DeleteAsync_RemovesRow`
- `GetByListIdAsync_ReturnsNullWhenAbsent`
- `ListRepositoryTests.UpdateAsync_UpdatesMutableFields`
- `TaskRepositoryTests.UpdateAgentSettingsAsync_NullsClearColumns`
- `WorkerHubTests` (if present pattern; otherwise via direct service call):
- `UpdateListConfig_AllNull_DeletesRow`
- `UpdateTaskAgentSettings_PersistsAndBroadcasts`
No UI tests — project has no UI test project. Build-time compile check is the only UI gate.
## Manual verification checklist
1. Open app, right-click a list → "Settings…" opens modal with correct current values.
2. Change model to `opus`, save, reopen → model persists.
3. Set system prompt on list, create task in list, run it → log confirms `--append-system-prompt` was passed.
4. Select task, set per-task Model = `haiku`, run → log confirms `--model haiku` overrides list value.
5. Unset per-task Model → effective falls back to list's model.
6. Click "Reset agent settings" on list → row removed, tasks fall back to global defaults.
7. Running task: DetailsIsland agent fields disabled.
## Risks / open questions
- **Refresh propagation**: `ListUpdated` is a new event; `IslandsShellViewModel` must subscribe and re-fetch. Any missed subscriber means stale UI. Mitigated by following the existing `TaskUpdated` pattern exactly.
- **Working-dir browser**: Avalonia folder picker API needs a `TopLevel`; pass via `StorageProvider`. Standard pattern in Avalonia 12.
- **Conventional-commit-type list**: hardcoded in ComboBox — acceptable, matches existing `CommitType` defaults.