8.1 KiB
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/ inheritSystemPrompt— free-text, appended to Claude's system promptAgentPath— 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) -> ListConfigEntityDeleteAsync(string listId, CancellationToken) -> bool
New method on ListRepository:
UpdateAsync(ListEntity, CancellationToken)— updatesName,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) -> boolnullvalues 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)— callsListRepository.UpdateAsync, then broadcastsListUpdated(listId).UpdateListConfig(UpdateListConfigDto dto)— upserts viaListConfigRepository.UpsertAsync, broadcastsListUpdated(listId). If all three fields are null, callsDeleteAsyncinstead so the row doesn't linger empty.UpdateTaskAgentSettings(UpdateTaskAgentSettingsDto dto)— callsTaskRepository.UpdateAgentSettingsAsync, broadcastsTaskUpdated(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, ornullif no row. Used byListSettingsModaland byDetailsIslandViewModelfor effective-value inheritance display. ExistingGetLists/GetTasksalready cover the rest.
3. UI — ListSettingsModal
New files:
src/ClaudeDo.Ui/Views/Modals/ListSettingsModalView.axaml+.axaml.cssrc/ClaudeDo.Ui/ViewModels/Modals/ListSettingsModalViewModel.cs
Entry points:
- Right-click on a list row in
ListsIslandView→ContextMenuwith "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 withchore, feat, fix, refactor, docs, test, ci, perf, style, build
Agent
Model— ComboBox:(default),sonnet,opus,haiku(selecting(default)sendsnull)System prompt— multi-line TextBox with 4-row min height; empty =nullAgent file— ComboBox populated viaWorkerClient.GetAgentsAsync(), first item(none); tooltip shows each agent'sDescription. Empty selection =null.Reset agent settingsbutton — clears Model/SystemPrompt/AgentPath in the form (save then sends null triple → backendDeleteAsync).
Commands:
SaveCommand— validates, callsUpdateListthenUpdateListConfig, 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 stateSystem prompt— TextBox with watermark showing effective inherited value when emptyAgent 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). DetailsIslandViewModelalready has access to the selectedTaskDto+ 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:
ListConfigRepositoryTestsUpsertAsync_InsertsWhenAbsentUpsertAsync_UpdatesWhenPresentDeleteAsync_RemovesRowGetByListIdAsync_ReturnsNullWhenAbsent
ListRepositoryTests.UpdateAsync_UpdatesMutableFieldsTaskRepositoryTests.UpdateAgentSettingsAsync_NullsClearColumnsWorkerHubTests(if present pattern; otherwise via direct service call):UpdateListConfig_AllNull_DeletesRowUpdateTaskAgentSettings_PersistsAndBroadcasts
No UI tests — project has no UI test project. Build-time compile check is the only UI gate.
Manual verification checklist
- Open app, right-click a list → "Settings…" opens modal with correct current values.
- Change model to
opus, save, reopen → model persists. - Set system prompt on list, create task in list, run it → log confirms
--append-system-promptwas passed. - Select task, set per-task Model =
haiku, run → log confirms--model haikuoverrides list value. - Unset per-task Model → effective falls back to list's model.
- Click "Reset agent settings" on list → row removed, tasks fall back to global defaults.
- Running task: DetailsIsland agent fields disabled.
Risks / open questions
- Refresh propagation:
ListUpdatedis a new event;IslandsShellViewModelmust subscribe and re-fetch. Any missed subscriber means stale UI. Mitigated by following the existingTaskUpdatedpattern exactly. - Working-dir browser: Avalonia folder picker API needs a
TopLevel; pass viaStorageProvider. Standard pattern in Avalonia 12. - Conventional-commit-type list: hardcoded in ComboBox — acceptable, matches existing
CommitTypedefaults.