1224 lines
46 KiB
Markdown
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).
|