1108 lines
50 KiB
Markdown
1108 lines
50 KiB
Markdown
# Inherited Settings Display, Overrides, and Turns — Implementation Plan
|
|
|
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
|
|
|
**Goal:** Make inherited config values visible in-place with a source-aware marker (`inherited · List` / `inherited · Global`), let users override them, and add **Max turns** as a list- and task-level overridable setting.
|
|
|
|
**Architecture:** Add a nullable `MaxTurns` override to `ListConfigEntity` and `TaskEntity` resolved task → list → global in `TaskRunner`. Thread it through repositories, SignalR DTOs, the SignalR hub, and the MCP config tools. In the UI, a pure resolution helper computes `(value, source)`; a small reusable badge control plus per-field reset buttons render the marker in both the List settings modal and the task overrides flyout.
|
|
|
|
**Tech Stack:** .NET 8, EF Core (SQLite), ASP.NET Core SignalR, Avalonia 12 + CommunityToolkit.Mvvm, xUnit. ModelContextProtocol MCP server.
|
|
|
|
**Build/test reminders (see CLAUDE.md):** build individual csproj with `-c Release` (a running Worker locks Debug). Subagents use the `sonnet` model and stage files explicitly by path — never `git add -A`. `locales/en.json` and `locales/de.json` keys must stay in parity. Changing `IWorkerClient` / DTOs breaks hand-rolled fakes — update `tests/ClaudeDo.Ui.Tests/StubWorkerClient.cs`.
|
|
|
|
---
|
|
|
|
## File Structure
|
|
|
|
**Created:**
|
|
- `src/ClaudeDo.Data/Migrations/<timestamp>_InheritableMaxTurns.cs` (+ `.Designer.cs`, generated by `dotnet ef`)
|
|
- `src/ClaudeDo.Ui/Services/InheritanceResolver.cs` — pure helper: `(value, source)` resolution + `InheritSource` enum
|
|
- `src/ClaudeDo.Ui/Views/Controls/InheritedBadge.axaml` (+ `.axaml.cs`) — label + source badge control
|
|
- `tests/ClaudeDo.Ui.Tests/InheritanceResolverTests.cs`
|
|
- `tests/ClaudeDo.Worker.Tests/Runner/MaxTurnsResolutionTests.cs` (or extend an existing runner-config test file)
|
|
- `tests/ClaudeDo.Data.Tests/MaxTurnsRoundTripTests.cs`
|
|
|
|
**Modified:**
|
|
- `src/ClaudeDo.Data/Models/ListConfigEntity.cs`, `TaskEntity.cs`
|
|
- `src/ClaudeDo.Data/Configuration/ListConfigEntityConfiguration.cs`, `TaskEntityConfiguration.cs`
|
|
- `src/ClaudeDo.Data/Repositories/ListRepository.cs`, `TaskRepository.cs`
|
|
- `src/ClaudeDo.Worker/Runner/TaskRunner.cs`
|
|
- `src/ClaudeDo.Worker/Hub/WorkerHub.cs`
|
|
- `src/ClaudeDo.Worker/External/ConfigMcpTools.cs`
|
|
- `src/ClaudeDo.Ui/Services/WorkerClient.cs`, `Services/Interfaces/IWorkerClient.cs`
|
|
- `tests/ClaudeDo.Ui.Tests/StubWorkerClient.cs`
|
|
- `src/ClaudeDo.Ui/ViewModels/Modals/ListSettingsModalViewModel.cs`, `Views/Modals/ListSettingsModalView.axaml`
|
|
- `src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs`, `Views/Islands/DetailsIslandView.axaml`
|
|
- `locales/en.json`, `locales/de.json`
|
|
|
|
---
|
|
|
|
## Task 1: Add `MaxTurns` to entities, EF config, and migration
|
|
|
|
**Files:**
|
|
- Modify: `src/ClaudeDo.Data/Models/ListConfigEntity.cs`
|
|
- Modify: `src/ClaudeDo.Data/Models/TaskEntity.cs`
|
|
- Modify: `src/ClaudeDo.Data/Configuration/ListConfigEntityConfiguration.cs`
|
|
- Modify: `src/ClaudeDo.Data/Configuration/TaskEntityConfiguration.cs:83-85`
|
|
- Create: migration (generated)
|
|
|
|
- [ ] **Step 1: Add the property to `ListConfigEntity`**
|
|
|
|
In `src/ClaudeDo.Data/Models/ListConfigEntity.cs`, after the `AgentPath` line:
|
|
|
|
```csharp
|
|
public sealed class ListConfigEntity
|
|
{
|
|
public required string ListId { get; init; }
|
|
public string? Model { get; set; }
|
|
public string? SystemPrompt { get; set; }
|
|
public string? AgentPath { get; set; }
|
|
public int? MaxTurns { get; set; }
|
|
|
|
// Navigation property
|
|
public ListEntity List { get; set; } = null!;
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Add the property to `TaskEntity`**
|
|
|
|
In `src/ClaudeDo.Data/Models/TaskEntity.cs`, beside the existing `Model` / `SystemPrompt` / `AgentPath` override properties, add:
|
|
|
|
```csharp
|
|
public int? MaxTurns { get; set; }
|
|
```
|
|
|
|
- [ ] **Step 3: Map the columns in both entity configurations**
|
|
|
|
In `ListConfigEntityConfiguration.cs`, after the `AgentPath` mapping:
|
|
|
|
```csharp
|
|
builder.Property(c => c.MaxTurns).HasColumnName("max_turns");
|
|
```
|
|
|
|
In `TaskEntityConfiguration.cs`, after line 85 (`AgentPath` mapping):
|
|
|
|
```csharp
|
|
builder.Property(t => t.MaxTurns).HasColumnName("max_turns");
|
|
```
|
|
|
|
- [ ] **Step 4: Generate the migration**
|
|
|
|
Run:
|
|
```bash
|
|
dotnet ef migrations add InheritableMaxTurns --project src/ClaudeDo.Data --startup-project src/ClaudeDo.Worker
|
|
```
|
|
Expected: creates `src/ClaudeDo.Data/Migrations/<timestamp>_InheritableMaxTurns.cs` (+ Designer) and updates `ClaudeDoDbContextModelSnapshot.cs`. The `Up` method should `AddColumn<int>("max_turns", "list_config", nullable: true)` and `AddColumn<int>("max_turns", "tasks", nullable: true)`.
|
|
|
|
If the `dotnet ef` tool is unavailable, hand-author the migration mirroring `Migrations/20260603141020_DailyPrepMaxTasks.cs` but with `nullable: true` and no `defaultValue`, adding both columns, and add matching `Property<int?>("MaxTurns").HasColumnName("max_turns")` entries to the snapshot for the `ListConfigEntity` and `TaskEntity` builders.
|
|
|
|
- [ ] **Step 5: Verify the migration is sane**
|
|
|
|
Open the generated `_InheritableMaxTurns.cs` and confirm both `AddColumn` calls use `nullable: true` and there is **no** `defaultValue` (null = inherit). Confirm `Down` drops both columns.
|
|
|
|
- [ ] **Step 6: Build Data**
|
|
|
|
Run: `dotnet build src/ClaudeDo.Data/ClaudeDo.Data.csproj -c Release`
|
|
Expected: build succeeds.
|
|
|
|
- [ ] **Step 7: Commit**
|
|
|
|
```bash
|
|
git add src/ClaudeDo.Data/Models/ListConfigEntity.cs src/ClaudeDo.Data/Models/TaskEntity.cs src/ClaudeDo.Data/Configuration/ListConfigEntityConfiguration.cs src/ClaudeDo.Data/Configuration/TaskEntityConfiguration.cs src/ClaudeDo.Data/Migrations/
|
|
git commit -m "feat(data): add nullable max_turns override to list_config and tasks"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 2: Repositories persist `MaxTurns`
|
|
|
|
**Files:**
|
|
- Modify: `src/ClaudeDo.Data/Repositories/ListRepository.cs:56-70`
|
|
- Modify: `src/ClaudeDo.Data/Repositories/TaskRepository.cs:157-170`
|
|
- Test: `tests/ClaudeDo.Data.Tests/MaxTurnsRoundTripTests.cs`
|
|
|
|
- [ ] **Step 1: Write the failing test**
|
|
|
|
Create `tests/ClaudeDo.Data.Tests/MaxTurnsRoundTripTests.cs`. Use the same in-memory/temp-SQLite context setup as a neighboring Data.Tests file (open one, e.g. `ListRepositoryTests.cs`, and copy its context-creation helper exactly — it builds a `ClaudeDoDbContext` against a temp SQLite file and calls `Database.Migrate()` or `EnsureCreated()`).
|
|
|
|
```csharp
|
|
using ClaudeDo.Data.Models;
|
|
using ClaudeDo.Data.Repositories;
|
|
using Xunit;
|
|
|
|
namespace ClaudeDo.Data.Tests;
|
|
|
|
public class MaxTurnsRoundTripTests
|
|
{
|
|
[Fact]
|
|
public async Task ListConfig_persists_max_turns()
|
|
{
|
|
await using var ctx = TestDb.NewContext(); // use the helper pattern from existing Data.Tests
|
|
var lists = new ListRepository(ctx);
|
|
await ctx.Lists.AddAsync(new ListEntity { Id = "L1", Name = "L1" });
|
|
await ctx.SaveChangesAsync();
|
|
|
|
await lists.SetConfigAsync(new ListConfigEntity { ListId = "L1", MaxTurns = 42 });
|
|
|
|
var cfg = await lists.GetConfigAsync("L1");
|
|
Assert.Equal(42, cfg!.MaxTurns);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Task_agent_settings_persist_and_clear_max_turns()
|
|
{
|
|
await using var ctx = TestDb.NewContext();
|
|
var tasks = new TaskRepository(ctx);
|
|
await ctx.Tasks.AddAsync(new TaskEntity { Id = "T1", ListId = "L1", Title = "t" });
|
|
await ctx.SaveChangesAsync();
|
|
|
|
await tasks.UpdateAgentSettingsAsync("T1", model: null, systemPrompt: null, agentPath: null, maxTurns: 7);
|
|
var t = await tasks.GetByIdAsync("T1");
|
|
Assert.Equal(7, t!.MaxTurns);
|
|
|
|
await tasks.UpdateAgentSettingsAsync("T1", null, null, null, maxTurns: null);
|
|
var cleared = await tasks.GetByIdAsync("T1");
|
|
Assert.Null(cleared!.MaxTurns);
|
|
}
|
|
}
|
|
```
|
|
|
|
> NOTE: replace `TestDb.NewContext()` with whatever the existing Data.Tests use (they may have a fixture or a static helper). Match the existing pattern exactly; do not invent a new harness. `ListEntity` requires only `Id` + `Name` (other fields have defaults) — verify against the existing tests.
|
|
|
|
- [ ] **Step 2: Run the test to verify it fails**
|
|
|
|
Run: `dotnet test tests/ClaudeDo.Data.Tests/ClaudeDo.Data.Tests.csproj -c Release --filter MaxTurnsRoundTripTests`
|
|
Expected: FAIL — `UpdateAgentSettingsAsync` has no `maxTurns` parameter (compile error), and `SetConfigAsync` does not copy `MaxTurns` onto existing rows.
|
|
|
|
- [ ] **Step 3: Update `ListRepository.SetConfigAsync`**
|
|
|
|
In `src/ClaudeDo.Data/Repositories/ListRepository.cs`, in the `else` branch that updates an existing row (after `existing.AgentPath = config.AgentPath;`):
|
|
|
|
```csharp
|
|
existing.MaxTurns = config.MaxTurns;
|
|
```
|
|
|
|
(The `Add` branch already stores the full entity, so no change there.)
|
|
|
|
- [ ] **Step 4: Update `TaskRepository.UpdateAgentSettingsAsync`**
|
|
|
|
Replace the method body in `src/ClaudeDo.Data/Repositories/TaskRepository.cs:157-170` with:
|
|
|
|
```csharp
|
|
public async Task UpdateAgentSettingsAsync(
|
|
string taskId,
|
|
string? model,
|
|
string? systemPrompt,
|
|
string? agentPath,
|
|
int? maxTurns = null,
|
|
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)
|
|
.SetProperty(t => t.MaxTurns, maxTurns), ct);
|
|
}
|
|
```
|
|
|
|
> `maxTurns` is placed before `ct` with a default so existing 4-arg callers still compile; we update those callers explicitly in Task 4.
|
|
|
|
- [ ] **Step 5: Run the test to verify it passes**
|
|
|
|
Run: `dotnet test tests/ClaudeDo.Data.Tests/ClaudeDo.Data.Tests.csproj -c Release --filter MaxTurnsRoundTripTests`
|
|
Expected: PASS (2 tests).
|
|
|
|
- [ ] **Step 6: Commit**
|
|
|
|
```bash
|
|
git add src/ClaudeDo.Data/Repositories/ListRepository.cs src/ClaudeDo.Data/Repositories/TaskRepository.cs tests/ClaudeDo.Data.Tests/MaxTurnsRoundTripTests.cs
|
|
git commit -m "feat(data): persist max_turns in list and task repositories"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 3: Runner resolves `MaxTurns` task → list → global
|
|
|
|
**Files:**
|
|
- Modify: `src/ClaudeDo.Worker/Runner/TaskRunner.cs:388-394`
|
|
- Test: `tests/ClaudeDo.Worker.Tests/Runner/MaxTurnsResolutionTests.cs`
|
|
|
|
> Context: `BuildRunConfig` currently returns `MaxTurns: global.DefaultMaxTurns`. `ClaudeRunConfig.MaxTurns` is `int?` and `ClaudeArgsBuilder` already emits `--max-turns` when `> 0`. Only the resolution line changes.
|
|
|
|
- [ ] **Step 1: Inspect the method signature**
|
|
|
|
Open `src/ClaudeDo.Worker/Runner/TaskRunner.cs` around line 360-395 and confirm the names of the in-scope variables: the task entity (`task`), the list config (`listConfig`), and the app settings (`global`). If the config-building logic is not directly unit-testable (private method, lots of dependencies), extract a tiny pure static helper for just the MaxTurns precedence and test that:
|
|
|
|
```csharp
|
|
internal static int? ResolveMaxTurns(int? taskTurns, int? listTurns, int globalDefault)
|
|
=> taskTurns ?? listTurns ?? globalDefault;
|
|
```
|
|
|
|
Place it in `TaskRunner` as an `internal static` method.
|
|
|
|
- [ ] **Step 2: Write the failing test**
|
|
|
|
Create `tests/ClaudeDo.Worker.Tests/Runner/MaxTurnsResolutionTests.cs`:
|
|
|
|
```csharp
|
|
using ClaudeDo.Worker.Runner;
|
|
using Xunit;
|
|
|
|
namespace ClaudeDo.Worker.Tests.Runner;
|
|
|
|
public class MaxTurnsResolutionTests
|
|
{
|
|
[Fact]
|
|
public void Task_override_wins()
|
|
=> Assert.Equal(5, TaskRunner.ResolveMaxTurns(taskTurns: 5, listTurns: 20, globalDefault: 100));
|
|
|
|
[Fact]
|
|
public void List_override_used_when_no_task_override()
|
|
=> Assert.Equal(20, TaskRunner.ResolveMaxTurns(taskTurns: null, listTurns: 20, globalDefault: 100));
|
|
|
|
[Fact]
|
|
public void Falls_back_to_global_default()
|
|
=> Assert.Equal(100, TaskRunner.ResolveMaxTurns(taskTurns: null, listTurns: null, globalDefault: 100));
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 3: Run the test to verify it fails**
|
|
|
|
Run: `dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release --filter MaxTurnsResolutionTests`
|
|
Expected: FAIL — `ResolveMaxTurns` not defined.
|
|
|
|
- [ ] **Step 4: Add the helper and use it in `BuildRunConfig`**
|
|
|
|
Add the `ResolveMaxTurns` helper from Step 1 to `TaskRunner`. Then change the `MaxTurns:` argument in the `ClaudeRunConfig` construction (currently `MaxTurns: global.DefaultMaxTurns,`) to:
|
|
|
|
```csharp
|
|
MaxTurns: ResolveMaxTurns(task.MaxTurns, listConfig?.MaxTurns, global.DefaultMaxTurns),
|
|
```
|
|
|
|
- [ ] **Step 5: Run the test to verify it passes**
|
|
|
|
Run: `dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release --filter MaxTurnsResolutionTests`
|
|
Expected: PASS (3 tests).
|
|
|
|
- [ ] **Step 6: Build Worker**
|
|
|
|
Run: `dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj -c Release`
|
|
Expected: build succeeds.
|
|
|
|
- [ ] **Step 7: Commit**
|
|
|
|
```bash
|
|
git add src/ClaudeDo.Worker/Runner/TaskRunner.cs tests/ClaudeDo.Worker.Tests/Runner/MaxTurnsResolutionTests.cs
|
|
git commit -m "feat(worker): resolve max-turns from task then list then global default"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 4: Thread `MaxTurns` through Worker transport (DTOs, Hub, MCP)
|
|
|
|
**Files:**
|
|
- Modify: `src/ClaudeDo.Worker/Hub/WorkerHub.cs:58-60, 334-368, 406-417`
|
|
- Modify: `src/ClaudeDo.Worker/External/ConfigMcpTools.cs`
|
|
|
|
- [ ] **Step 1: Extend the Worker-side DTO records**
|
|
|
|
In `src/ClaudeDo.Worker/Hub/WorkerHub.cs` (lines 58-60), add a trailing nullable `int?` to each record (defaulted so existing positional usages elsewhere keep compiling):
|
|
|
|
```csharp
|
|
public record UpdateListConfigDto(string ListId, string? Model, string? SystemPrompt, string? AgentPath, int? MaxTurns = null);
|
|
public record UpdateTaskAgentSettingsDto(string TaskId, string? Model, string? SystemPrompt, string? AgentPath, int? MaxTurns = null);
|
|
public record ListConfigDto(string? Model, string? SystemPrompt, string? AgentPath, int? MaxTurns = null);
|
|
```
|
|
|
|
- [ ] **Step 2: Persist `MaxTurns` in `UpdateListConfig`**
|
|
|
|
In `WorkerHub.UpdateListConfig` (lines 334-359): include MaxTurns in the "is everything empty?" delete check and in the `SetConfigAsync` call.
|
|
|
|
```csharp
|
|
public async Task UpdateListConfig(UpdateListConfigDto dto)
|
|
{
|
|
using var ctx = _dbFactory.CreateDbContext();
|
|
var repo = new ListRepository(ctx);
|
|
|
|
var model = dto.Model.NullIfBlank();
|
|
var systemPrompt = dto.SystemPrompt.NullIfBlank();
|
|
var agentPath = dto.AgentPath.NullIfBlank();
|
|
|
|
if (model is null && systemPrompt is null && agentPath is null && dto.MaxTurns is null)
|
|
{
|
|
await repo.DeleteConfigAsync(dto.ListId);
|
|
}
|
|
else
|
|
{
|
|
await repo.SetConfigAsync(new ListConfigEntity
|
|
{
|
|
ListId = dto.ListId,
|
|
Model = model,
|
|
SystemPrompt = systemPrompt,
|
|
AgentPath = agentPath,
|
|
MaxTurns = dto.MaxTurns,
|
|
});
|
|
}
|
|
|
|
await _broadcaster.ListUpdated(dto.ListId);
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 3: Return `MaxTurns` from `GetListConfig`**
|
|
|
|
In `WorkerHub.GetListConfig` (line 367), change the return to include MaxTurns:
|
|
|
|
```csharp
|
|
return new ListConfigDto(config.Model, config.SystemPrompt, config.AgentPath, config.MaxTurns);
|
|
```
|
|
|
|
- [ ] **Step 4: Persist `MaxTurns` in `UpdateTaskAgentSettings`**
|
|
|
|
In `WorkerHub.UpdateTaskAgentSettings` (lines 406-417), pass `dto.MaxTurns` to the repository (note the new `maxTurns` parameter sits before `ct`):
|
|
|
|
```csharp
|
|
await repo.UpdateAgentSettingsAsync(
|
|
dto.TaskId,
|
|
dto.Model.NullIfBlank(),
|
|
dto.SystemPrompt.NullIfBlank(),
|
|
dto.AgentPath.NullIfBlank(),
|
|
dto.MaxTurns);
|
|
```
|
|
|
|
- [ ] **Step 5: Extend the MCP config tools**
|
|
|
|
In `src/ClaudeDo.Worker/External/ConfigMcpTools.cs`:
|
|
|
|
Update the DTO (line 9):
|
|
```csharp
|
|
public sealed record TaskConfigDto(string? Model, string? SystemPrompt, string? AgentPath, int? MaxTurns);
|
|
```
|
|
|
|
Update `GetListConfig` (line 29):
|
|
```csharp
|
|
return cfg is null ? null : new TaskConfigDto(cfg.Model, cfg.SystemPrompt, cfg.AgentPath, cfg.MaxTurns);
|
|
```
|
|
|
|
Update `SetListConfig` signature + body — add an `int? maxTurns` parameter (before `CancellationToken`), include it in the clear-check and the upsert:
|
|
```csharp
|
|
[McpServerTool, Description("Set a list's default model/system prompt/agent path/max turns. Passing model, systemPrompt, agentPath, and maxTurns all null clears the list config.")]
|
|
public async Task SetListConfig(
|
|
string listId, string? model, string? systemPrompt, string? agentPath, int? maxTurns, CancellationToken cancellationToken)
|
|
{
|
|
_ = await _lists.GetByIdAsync(listId, cancellationToken)
|
|
?? throw new InvalidOperationException($"List {listId} not found.");
|
|
|
|
var m = model.NullIfBlank();
|
|
var sp = systemPrompt.NullIfBlank();
|
|
var ap = agentPath.NullIfBlank();
|
|
|
|
if (m is null && sp is null && ap is null && maxTurns is null)
|
|
await _lists.DeleteConfigAsync(listId, cancellationToken);
|
|
else
|
|
await _lists.SetConfigAsync(new ListConfigEntity
|
|
{
|
|
ListId = listId, Model = m, SystemPrompt = sp, AgentPath = ap, MaxTurns = maxTurns,
|
|
}, cancellationToken);
|
|
|
|
await _broadcaster.ListUpdated(listId);
|
|
}
|
|
```
|
|
|
|
Update `SetTaskConfig` signature + body — add `int? maxTurns` (before `CancellationToken`) and pass it through:
|
|
```csharp
|
|
[McpServerTool, Description("Set per-task config overrides (model/system prompt/agent path/max turns). Pass null for any field to clear that override.")]
|
|
public async Task SetTaskConfig(
|
|
string taskId, string? model, string? systemPrompt, string? agentPath, int? maxTurns, CancellationToken cancellationToken)
|
|
{
|
|
_ = await _tasks.GetByIdAsync(taskId, cancellationToken)
|
|
?? throw new InvalidOperationException($"Task {taskId} not found.");
|
|
|
|
await _tasks.UpdateAgentSettingsAsync(taskId, model.NullIfBlank(), systemPrompt.NullIfBlank(), agentPath.NullIfBlank(), maxTurns, cancellationToken);
|
|
await _broadcaster.TaskUpdated(taskId);
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 6: Build Worker**
|
|
|
|
Run: `dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj -c Release`
|
|
Expected: build succeeds. (If any other caller constructed these DTOs positionally and now mismatches, fix it — the new params are defaulted, so this should be clean.)
|
|
|
|
- [ ] **Step 7: Commit**
|
|
|
|
```bash
|
|
git add src/ClaudeDo.Worker/Hub/WorkerHub.cs src/ClaudeDo.Worker/External/ConfigMcpTools.cs
|
|
git commit -m "feat(worker): expose max-turns override over signalr and mcp config tools"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 5: Mirror DTO changes in the UI transport layer
|
|
|
|
**Files:**
|
|
- Modify: `src/ClaudeDo.Ui/Services/WorkerClient.cs:524-526`
|
|
- Modify: `tests/ClaudeDo.Ui.Tests/StubWorkerClient.cs` (only if a signature/record change breaks it)
|
|
|
|
> The UI keeps its own copies of `UpdateListConfigDto`, `UpdateTaskAgentSettingsDto`, `ListConfigDto`. They must mirror the Worker exactly or SignalR (de)serialization drops the field. `IWorkerClient` method signatures don't change (they take whole DTOs), so the interface and stub need no new members — but verify the stub still compiles.
|
|
|
|
- [ ] **Step 1: Extend the UI DTO records**
|
|
|
|
In `src/ClaudeDo.Ui/Services/WorkerClient.cs` (lines 524-526):
|
|
|
|
```csharp
|
|
public sealed record UpdateListConfigDto(string ListId, string? Model, string? SystemPrompt, string? AgentPath, int? MaxTurns = null);
|
|
public sealed record UpdateTaskAgentSettingsDto(string TaskId, string? Model, string? SystemPrompt, string? AgentPath, int? MaxTurns = null);
|
|
public sealed record ListConfigDto(string? Model, string? SystemPrompt, string? AgentPath, int? MaxTurns = null);
|
|
```
|
|
|
|
- [ ] **Step 2: Build the UI project**
|
|
|
|
Run: `dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj -c Release`
|
|
Expected: build succeeds (this pulls in Ui + Data).
|
|
|
|
- [ ] **Step 3: Build the UI test project**
|
|
|
|
Run: `dotnet build tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj -c Release`
|
|
Expected: build succeeds. `StubWorkerClient` returns `null` for `GetListConfigAsync` and ignores `UpdateTaskAgentSettingsAsync(dto)`, so the defaulted record params keep it compiling. If it breaks, fix only the affected line.
|
|
|
|
- [ ] **Step 4: Commit**
|
|
|
|
```bash
|
|
git add src/ClaudeDo.Ui/Services/WorkerClient.cs
|
|
git commit -m "feat(ui): mirror max-turns field on signalr config dtos"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 6: Localization keys (en + de parity)
|
|
|
|
**Files:**
|
|
- Modify: `locales/en.json`
|
|
- Modify: `locales/de.json`
|
|
|
|
> Localization.Tests enforces key parity. Find the right nesting by searching for the existing sibling keys (`modals.listSettings.model`, `details.modelLabel`, `vm.details.effectiveIfInherited`).
|
|
|
|
- [ ] **Step 1: Add the new keys to `locales/en.json`**
|
|
|
|
Add (place each next to its siblings; values shown):
|
|
- under `modals.listSettings`: `"maxTurns": "Max turns"`
|
|
- under `details`: `"maxTurnsLabel": "Max turns"`, `"systemPromptPrepended": "Prepended automatically:"`
|
|
- a new/extended `settings.inherit` group (or wherever shared UI strings live — match existing convention):
|
|
- `"inheritedFromList": "inherited · List"`
|
|
- `"inheritedFromGlobal": "inherited · Global"`
|
|
- `"overrideBadge": "override"`
|
|
- `"resetToInherited": "Reset to inherited"`
|
|
|
|
- [ ] **Step 2: Add the same keys to `locales/de.json`**
|
|
|
|
Same key paths, German values:
|
|
- `modals.listSettings.maxTurns`: `"Max. Turns"`
|
|
- `details.maxTurnsLabel`: `"Max. Turns"`
|
|
- `details.systemPromptPrepended`: `"Wird automatisch vorangestellt:"`
|
|
- `settings.inherit.inheritedFromList`: `"geerbt · Liste"`
|
|
- `settings.inherit.inheritedFromGlobal`: `"geerbt · Global"`
|
|
- `settings.inherit.overrideBadge`: `"überschrieben"`
|
|
- `settings.inherit.resetToInherited`: `"Auf geerbt zurücksetzen"`
|
|
|
|
- [ ] **Step 3: Remove the now-unused `effectiveIfInherited` keys**
|
|
|
|
Remove `vm.details.effectiveIfInherited` from BOTH `en.json` and `de.json` (it is replaced by the badge approach in Tasks 8-9; this plan deletes its only usages there). Removing from both keeps parity.
|
|
|
|
- [ ] **Step 4: Run the localization tests**
|
|
|
|
Run: `dotnet test tests/ClaudeDo.Localization.Tests/ClaudeDo.Localization.Tests.csproj -c Release`
|
|
Expected: PASS (en/de key parity holds).
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
git add locales/en.json locales/de.json
|
|
git commit -m "feat(i18n): add inherited-marker, turns, and prepended-prompt strings"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 7: `InheritanceResolver` helper (pure, unit-tested)
|
|
|
|
**Files:**
|
|
- Create: `src/ClaudeDo.Ui/Services/InheritanceResolver.cs`
|
|
- Test: `tests/ClaudeDo.Ui.Tests/InheritanceResolverTests.cs`
|
|
|
|
- [ ] **Step 1: Write the failing test**
|
|
|
|
Create `tests/ClaudeDo.Ui.Tests/InheritanceResolverTests.cs`:
|
|
|
|
```csharp
|
|
using ClaudeDo.Ui.Services;
|
|
using Xunit;
|
|
|
|
namespace ClaudeDo.Ui.Tests;
|
|
|
|
public class InheritanceResolverTests
|
|
{
|
|
[Fact]
|
|
public void Task_value_is_an_override()
|
|
{
|
|
var (value, source) = InheritanceResolver.Resolve("opus", "sonnet", "haiku");
|
|
Assert.Equal("opus", value);
|
|
Assert.Equal(InheritSource.Override, source);
|
|
}
|
|
|
|
[Fact]
|
|
public void Falls_through_to_list()
|
|
{
|
|
var (value, source) = InheritanceResolver.Resolve(null, "sonnet", "haiku");
|
|
Assert.Equal("sonnet", value);
|
|
Assert.Equal(InheritSource.List, source);
|
|
}
|
|
|
|
[Fact]
|
|
public void Falls_through_to_global()
|
|
{
|
|
var (value, source) = InheritanceResolver.Resolve(" ", null, "haiku");
|
|
Assert.Equal("haiku", value);
|
|
Assert.Equal(InheritSource.Global, source);
|
|
}
|
|
|
|
[Fact]
|
|
public void List_scope_treats_list_value_as_override()
|
|
{
|
|
var (value, source) = InheritanceResolver.ResolveList("sonnet", "haiku");
|
|
Assert.Equal("sonnet", value);
|
|
Assert.Equal(InheritSource.Override, source);
|
|
|
|
var (value2, source2) = InheritanceResolver.ResolveList(null, "haiku");
|
|
Assert.Equal("haiku", value2);
|
|
Assert.Equal(InheritSource.Global, source2);
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Run the test to verify it fails**
|
|
|
|
Run: `dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj -c Release --filter InheritanceResolverTests`
|
|
Expected: FAIL — type does not exist.
|
|
|
|
- [ ] **Step 3: Implement the helper**
|
|
|
|
Create `src/ClaudeDo.Ui/Services/InheritanceResolver.cs`:
|
|
|
|
```csharp
|
|
namespace ClaudeDo.Ui.Services;
|
|
|
|
public enum InheritSource { Override, List, Global }
|
|
|
|
public static class InheritanceResolver
|
|
{
|
|
// Task-scope fields: task -> list -> global.
|
|
public static (string Value, InheritSource Source) Resolve(
|
|
string? taskValue, string? listValue, string? globalValue)
|
|
{
|
|
if (!string.IsNullOrWhiteSpace(taskValue)) return (taskValue!, InheritSource.Override);
|
|
if (!string.IsNullOrWhiteSpace(listValue)) return (listValue!, InheritSource.List);
|
|
return (globalValue ?? "", InheritSource.Global);
|
|
}
|
|
|
|
// List-scope fields: list -> global (lists have no tier above them).
|
|
public static (string Value, InheritSource Source) ResolveList(
|
|
string? listValue, string? globalValue)
|
|
{
|
|
if (!string.IsNullOrWhiteSpace(listValue)) return (listValue!, InheritSource.Override);
|
|
return (globalValue ?? "", InheritSource.Global);
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 4: Run the test to verify it passes**
|
|
|
|
Run: `dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj -c Release --filter InheritanceResolverTests`
|
|
Expected: PASS (4 tests).
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
git add src/ClaudeDo.Ui/Services/InheritanceResolver.cs tests/ClaudeDo.Ui.Tests/InheritanceResolverTests.cs
|
|
git commit -m "feat(ui): add inheritance resolver returning value and source"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 8: `InheritedBadge` reusable control
|
|
|
|
**Files:**
|
|
- Create: `src/ClaudeDo.Ui/Views/Controls/InheritedBadge.axaml`
|
|
- Create: `src/ClaudeDo.Ui/Views/Controls/InheritedBadge.axaml.cs`
|
|
|
|
> A tiny `UserControl` rendering a single badge whose text comes from a bound string. The consuming VM supplies the already-localized badge text (e.g. "inherited · List" / "override") and a bool for muted styling. Keeping localization in the VM avoids `Loc` lookups inside the control. No `ICommand` lives in the control — reset buttons are placed by each consumer (Tasks 9-10).
|
|
|
|
- [ ] **Step 1: Create the control XAML**
|
|
|
|
`src/ClaudeDo.Ui/Views/Controls/InheritedBadge.axaml`:
|
|
|
|
```xml
|
|
<UserControl xmlns="https://github.com/avaloniaui"
|
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
|
x:Class="ClaudeDo.Ui.Views.Controls.InheritedBadge"
|
|
x:Name="Root">
|
|
<Border Background="{DynamicResource SubtleFillBrush}"
|
|
CornerRadius="4" Padding="6,1"
|
|
IsVisible="{Binding #Root.BadgeText, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"
|
|
VerticalAlignment="Center" HorizontalAlignment="Left">
|
|
<TextBlock Classes="meta" FontSize="11" Opacity="0.75"
|
|
Text="{Binding #Root.BadgeText}"/>
|
|
</Border>
|
|
</UserControl>
|
|
```
|
|
|
|
> If `SubtleFillBrush` is not a defined token, use an existing subtle background brush from `Design/Tokens.axaml` (open it and pick the closest — e.g. a hover/overlay brush). The user will do the final visual pass.
|
|
|
|
- [ ] **Step 2: Create the code-behind with a `BadgeText` StyledProperty**
|
|
|
|
`src/ClaudeDo.Ui/Views/Controls/InheritedBadge.axaml.cs`:
|
|
|
|
```csharp
|
|
using Avalonia;
|
|
using Avalonia.Controls;
|
|
|
|
namespace ClaudeDo.Ui.Views.Controls;
|
|
|
|
public partial class InheritedBadge : UserControl
|
|
{
|
|
public static readonly StyledProperty<string?> BadgeTextProperty =
|
|
AvaloniaProperty.Register<InheritedBadge, string?>(nameof(BadgeText));
|
|
|
|
public string? BadgeText
|
|
{
|
|
get => GetValue(BadgeTextProperty);
|
|
set => SetValue(BadgeTextProperty, value);
|
|
}
|
|
|
|
public InheritedBadge() => InitializeComponent();
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 3: Build the UI project**
|
|
|
|
Run: `dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj -c Release`
|
|
Expected: build succeeds (control compiles, even though nothing consumes it yet).
|
|
|
|
- [ ] **Step 4: Commit**
|
|
|
|
```bash
|
|
git add src/ClaudeDo.Ui/Views/Controls/InheritedBadge.axaml src/ClaudeDo.Ui/Views/Controls/InheritedBadge.axaml.cs
|
|
git commit -m "feat(ui): add reusable inherited-source badge control"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 9: List settings modal — Turns field + inherited markers
|
|
|
|
**Files:**
|
|
- Modify: `src/ClaudeDo.Ui/ViewModels/Modals/ListSettingsModalViewModel.cs`
|
|
- Modify: `src/ClaudeDo.Ui/Views/Modals/ListSettingsModalView.axaml`
|
|
|
|
> Lists inherit only from Global. The ViewModel loads the global defaults (via `GetAppSettingsAsync`) to render the muted resolved value + `inherited · Global` badge. For Model we drop the `(default)` sentinel row and use `SelectedItem == null` to mean "inherited", with `PlaceholderText` showing the resolved value. Turns uses a `NumericUpDown` whose `Value` is `decimal?` (null = inherit) with a `Watermark` showing the global default.
|
|
|
|
- [ ] **Step 1: Add fields + load logic to the ViewModel**
|
|
|
|
In `ListSettingsModalViewModel.cs`:
|
|
|
|
1. Add a `using ClaudeDo.Ui.Services;` if not present (for `InheritanceResolver` / `InheritSource`).
|
|
2. Change the Model collection to **not** include the sentinel:
|
|
```csharp
|
|
public ObservableCollection<string> ModelOptions { get; } = new(ModelRegistry.Aliases);
|
|
```
|
|
3. Change `SelectedModel` to nullable (null = inherit) and add the badge + Turns properties:
|
|
```csharp
|
|
[ObservableProperty] private string? _selectedModel; // null = inherit from global
|
|
[ObservableProperty] private decimal? _maxTurns; // null = inherit from global
|
|
[ObservableProperty] private string _modelInheritedHint = ""; // muted resolved value, e.g. "sonnet"
|
|
[ObservableProperty] private string _modelBadge = ""; // localized badge text
|
|
[ObservableProperty] private string _turnsInheritedHint = ""; // muted resolved value, e.g. "100"
|
|
[ObservableProperty] private string _turnsBadge = "";
|
|
[ObservableProperty] private string _agentBadge = "";
|
|
[ObservableProperty] private string _agentInheritedHint = "";
|
|
```
|
|
4. Add badge recompute helpers and wire them to property changes:
|
|
```csharp
|
|
partial void OnSelectedModelChanged(string? value) => RecomputeModelBadge();
|
|
partial void OnMaxTurnsChanged(decimal? value) => RecomputeTurnsBadge();
|
|
partial void OnSelectedAgentChanged(AgentInfo? value) => RecomputeAgentBadge();
|
|
|
|
private string _globalModel = ModelRegistry.DefaultAlias;
|
|
private int _globalMaxTurns = 100;
|
|
|
|
private void RecomputeModelBadge()
|
|
{
|
|
var overridden = !string.IsNullOrWhiteSpace(SelectedModel);
|
|
ModelInheritedHint = _globalModel;
|
|
ModelBadge = overridden ? Loc.T("settings.inherit.overrideBadge") : Loc.T("settings.inherit.inheritedFromGlobal");
|
|
}
|
|
|
|
private void RecomputeTurnsBadge()
|
|
{
|
|
var overridden = MaxTurns is not null;
|
|
TurnsInheritedHint = _globalMaxTurns.ToString();
|
|
TurnsBadge = overridden ? Loc.T("settings.inherit.overrideBadge") : Loc.T("settings.inherit.inheritedFromGlobal");
|
|
}
|
|
|
|
private void RecomputeAgentBadge()
|
|
{
|
|
var overridden = SelectedAgent is not null && !string.IsNullOrWhiteSpace(SelectedAgent.Path);
|
|
// Agent has no global default; inherited means "(none)".
|
|
AgentInheritedHint = "(none)";
|
|
AgentBadge = overridden ? Loc.T("settings.inherit.overrideBadge") : Loc.T("settings.inherit.inheritedFromGlobal");
|
|
}
|
|
```
|
|
5. In `LoadAsync`, load globals and seed the new fields. After the `GetListConfigAsync` block, replace the Model/Agent seeding with the null-sentinel approach and add MaxTurns + globals:
|
|
```csharp
|
|
var app = await _worker.GetAppSettingsAsync();
|
|
_globalModel = app?.DefaultModel ?? ModelRegistry.DefaultAlias;
|
|
_globalMaxTurns = app?.DefaultMaxTurns ?? 100;
|
|
|
|
SelectedModel = string.IsNullOrWhiteSpace(config?.Model) ? null : config!.Model!;
|
|
MaxTurns = config?.MaxTurns is int mt ? mt : (decimal?)null;
|
|
SystemPrompt = config?.SystemPrompt ?? "";
|
|
SelectedAgent = string.IsNullOrWhiteSpace(config?.AgentPath)
|
|
? Agents[0]
|
|
: (Agents.FirstOrDefault(a => a.Path == config!.AgentPath) ?? Agents[0]);
|
|
|
|
RecomputeModelBadge();
|
|
RecomputeTurnsBadge();
|
|
RecomputeAgentBadge();
|
|
```
|
|
(Remove the old line that set `SelectedModel = ... ListDefaultSentinel ...`.)
|
|
|
|
6. In `SaveAsync`, compute the override values and include MaxTurns:
|
|
```csharp
|
|
var model = string.IsNullOrWhiteSpace(SelectedModel) ? null : SelectedModel;
|
|
var sp = string.IsNullOrWhiteSpace(SystemPrompt) ? null : SystemPrompt;
|
|
var ap = SelectedAgent is null || string.IsNullOrWhiteSpace(SelectedAgent.Path) ? null : SelectedAgent.Path;
|
|
var turns = MaxTurns is decimal d ? (int?)d : null;
|
|
|
|
await _worker.UpdateListAsync(new UpdateListDto(
|
|
ListId,
|
|
string.IsNullOrWhiteSpace(Name) ? Loc.T("vm.listSettings.untitled") : Name,
|
|
string.IsNullOrWhiteSpace(WorkingDir) ? null : WorkingDir,
|
|
DefaultCommitType));
|
|
|
|
await _worker.UpdateListConfigAsync(new UpdateListConfigDto(
|
|
ListId, model, sp, ap, turns));
|
|
```
|
|
|
|
7. Add reset commands and update `ResetAgentSettings`:
|
|
```csharp
|
|
[RelayCommand] private void ResetModel() => SelectedModel = null;
|
|
[RelayCommand] private void ResetTurns() => MaxTurns = null;
|
|
[RelayCommand] private void ResetAgent() => SelectedAgent = Agents.Count > 0 ? Agents[0] : null;
|
|
```
|
|
And in the existing `ResetAgentSettings`, replace `SelectedModel = ModelRegistry.ListDefaultSentinel;` with `SelectedModel = null;` and add `MaxTurns = null;`.
|
|
|
|
- [ ] **Step 2: Update the modal XAML — Model field**
|
|
|
|
In `ListSettingsModalView.axaml`, add `xmlns:ctl="using:ClaudeDo.Ui.Views.Controls"` to the root if not already present (it is). Replace the Model `StackPanel` (lines ~76-81) with a label-row that carries the badge + reset, and a ComboBox using null-selection + placeholder:
|
|
|
|
```xml
|
|
<StackPanel Spacing="4">
|
|
<Grid ColumnDefinitions="Auto,Auto,*,Auto" ColumnSpacing="6">
|
|
<TextBlock Grid.Column="0" Classes="field-label" Text="{loc:Tr modals.listSettings.model}" VerticalAlignment="Center"/>
|
|
<ctl:InheritedBadge Grid.Column="1" BadgeText="{Binding ModelBadge}"/>
|
|
<Button Grid.Column="3" Classes="btn" Content="↺" ToolTip.Tip="{loc:Tr settings.inherit.resetToInherited}"
|
|
Command="{Binding ResetModelCommand}" Padding="6,1"/>
|
|
</Grid>
|
|
<ComboBox ItemsSource="{Binding ModelOptions}"
|
|
SelectedItem="{Binding SelectedModel, Mode=TwoWay}"
|
|
PlaceholderText="{Binding ModelInheritedHint}"
|
|
HorizontalAlignment="Left" MinWidth="160" />
|
|
</StackPanel>
|
|
```
|
|
|
|
- [ ] **Step 3: Update the modal XAML — add the Turns field**
|
|
|
|
Add a new `StackPanel` inside the AGENT section's inner `StackPanel` (after the Model field, before System prompt):
|
|
|
|
```xml
|
|
<StackPanel Spacing="4">
|
|
<Grid ColumnDefinitions="Auto,Auto,*,Auto" ColumnSpacing="6">
|
|
<TextBlock Grid.Column="0" Classes="field-label" Text="{loc:Tr modals.listSettings.maxTurns}" VerticalAlignment="Center"/>
|
|
<ctl:InheritedBadge Grid.Column="1" BadgeText="{Binding TurnsBadge}"/>
|
|
<Button Grid.Column="3" Classes="btn" Content="↺" ToolTip.Tip="{loc:Tr settings.inherit.resetToInherited}"
|
|
Command="{Binding ResetTurnsCommand}" Padding="6,1"/>
|
|
</Grid>
|
|
<NumericUpDown Value="{Binding MaxTurns, Mode=TwoWay}"
|
|
Watermark="{Binding TurnsInheritedHint}"
|
|
Minimum="1" Maximum="200" Increment="1" FormatString="0"
|
|
HorizontalAlignment="Left" Width="160"/>
|
|
</StackPanel>
|
|
```
|
|
|
|
- [ ] **Step 4: Update the modal XAML — Agent field badge + reset**
|
|
|
|
Wrap the existing Agent `field-label` (line ~91) in the same label-row pattern so it shows `AgentBadge` and a reset button bound to `ResetAgentCommand`. Keep the existing ComboBox + browse button + path TextBlock unchanged below it.
|
|
|
|
- [ ] **Step 5: Build the UI project**
|
|
|
|
Run: `dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj -c Release`
|
|
Expected: build succeeds. Fix any binding/compile errors (compiled bindings require the VM properties to exist — they were added in Step 1).
|
|
|
|
- [ ] **Step 6: Commit**
|
|
|
|
```bash
|
|
git add src/ClaudeDo.Ui/ViewModels/Modals/ListSettingsModalViewModel.cs src/ClaudeDo.Ui/Views/Modals/ListSettingsModalView.axaml
|
|
git commit -m "feat(ui): show inherited markers and max-turns override in list settings"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 10: Task overrides flyout — Turns field + inherited markers
|
|
|
|
**Files:**
|
|
- Modify: `src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs`
|
|
- Modify: `src/ClaudeDo.Ui/Views/Islands/DetailsIslandView.axaml:90-122`
|
|
|
|
> Task scope falls through task → list → global. Model resolves to global default; Agent has no global tier (inherited = list value or "(none)"); Turns resolves to list value or global default. Reuse `InheritanceResolver.Resolve`. Drop the `(inherit)` sentinel from the Model dropdown and the Agent dropdown's first row; null selection = inherited.
|
|
|
|
- [ ] **Step 1: Replace the effective-hint fields with badge fields in the ViewModel**
|
|
|
|
In `DetailsIslandViewModel.cs`:
|
|
|
|
1. Remove `EffectiveModelLabel` / `EffectiveAgentLabel` computed props and the `OnEffective*HintChanged` partials (lines ~155-159), and the `OnPropertyChanged(nameof(EffectiveModelLabel))` calls at lines ~141-142 and ~291-292.
|
|
2. Change the Model options to drop the sentinel:
|
|
```csharp
|
|
public System.Collections.ObjectModel.ObservableCollection<string> TaskModelOptions { get; } = new(ModelRegistry.Aliases);
|
|
```
|
|
3. Change `TaskModelSelection` to nullable and add new observable fields:
|
|
```csharp
|
|
[ObservableProperty] private string? _taskModelSelection; // null = inherit
|
|
[ObservableProperty] private decimal? _taskMaxTurns; // null = inherit
|
|
[ObservableProperty] private string _modelBadge = "";
|
|
[ObservableProperty] private string _modelInheritedHint = "";
|
|
[ObservableProperty] private string _turnsBadge = "";
|
|
[ObservableProperty] private string _turnsInheritedHint = "";
|
|
[ObservableProperty] private string _agentBadge = "";
|
|
// keep existing _taskSystemPrompt; EffectiveSystemPromptHint stays as the prepended-prompt hint
|
|
```
|
|
Keep `EffectiveSystemPromptHint` (used as the read-only "prepended automatically" hint). Remove `EffectiveModelHint` and `EffectiveAgentHint` if nothing else references them after this task.
|
|
4. Track globals and recompute badges:
|
|
```csharp
|
|
private string _globalModel = ModelRegistry.DefaultAlias;
|
|
private int _globalMaxTurns = 100;
|
|
|
|
partial void OnTaskModelSelectionChanged(string? value) => RecomputeModelBadge();
|
|
partial void OnTaskMaxTurnsChanged(decimal? value) => RecomputeTurnsBadge();
|
|
partial void OnTaskSelectedAgentChanged(AgentInfo? value) => RecomputeAgentBadge();
|
|
|
|
private string? _listModel, _listAgentName;
|
|
private int? _listMaxTurns;
|
|
|
|
private void RecomputeModelBadge()
|
|
{
|
|
var (value, source) = InheritanceResolver.Resolve(TaskModelSelection, _listModel, _globalModel);
|
|
ModelInheritedHint = value;
|
|
ModelBadge = BadgeFor(source, taskSet: !string.IsNullOrWhiteSpace(TaskModelSelection));
|
|
}
|
|
|
|
private void RecomputeTurnsBadge()
|
|
{
|
|
var (value, source) = InheritanceResolver.Resolve(
|
|
TaskMaxTurns?.ToString(), _listMaxTurns?.ToString(), _globalMaxTurns.ToString());
|
|
TurnsInheritedHint = value;
|
|
TurnsBadge = BadgeFor(source, taskSet: TaskMaxTurns is not null);
|
|
}
|
|
|
|
private void RecomputeAgentBadge()
|
|
{
|
|
var taskSet = TaskSelectedAgent is not null && !string.IsNullOrWhiteSpace(TaskSelectedAgent.Path);
|
|
var (_, source) = InheritanceResolver.Resolve(
|
|
taskSet ? TaskSelectedAgent!.Path : null, _listAgentName, null);
|
|
AgentBadge = BadgeFor(source, taskSet);
|
|
}
|
|
|
|
private static string BadgeFor(InheritSource source, bool taskSet) => taskSet
|
|
? Loc.T("settings.inherit.overrideBadge")
|
|
: source == InheritSource.List
|
|
? Loc.T("settings.inherit.inheritedFromList")
|
|
: Loc.T("settings.inherit.inheritedFromGlobal");
|
|
```
|
|
|
|
- [ ] **Step 2: Update `LoadAgentSettingsAsync` to seed the new fields**
|
|
|
|
Replace the seeding block (lines ~504-514) with:
|
|
|
|
```csharp
|
|
TaskModelSelection = string.IsNullOrWhiteSpace(entity.Model) ? null : entity.Model!;
|
|
TaskMaxTurns = entity.MaxTurns is int tmt ? tmt : (decimal?)null;
|
|
TaskSystemPrompt = entity.SystemPrompt ?? "";
|
|
TaskSelectedAgent = string.IsNullOrWhiteSpace(entity.AgentPath)
|
|
? TaskAgentOptions[0]
|
|
: (TaskAgentOptions.FirstOrDefault(a => a.Path == entity.AgentPath) ?? TaskAgentOptions[0]);
|
|
|
|
var listCfg = await _worker.GetListConfigAsync(entity.ListId);
|
|
var app = await _worker.GetAppSettingsAsync();
|
|
_globalModel = app?.DefaultModel ?? ModelRegistry.DefaultAlias;
|
|
_globalMaxTurns = app?.DefaultMaxTurns ?? 100;
|
|
_listModel = listCfg?.Model;
|
|
_listMaxTurns = listCfg?.MaxTurns;
|
|
_listAgentName = string.IsNullOrWhiteSpace(listCfg?.AgentPath)
|
|
? null : System.IO.Path.GetFileName(listCfg!.AgentPath!);
|
|
|
|
EffectiveSystemPromptHint = string.IsNullOrWhiteSpace(listCfg?.SystemPrompt) ? "" : listCfg!.SystemPrompt!;
|
|
|
|
RecomputeModelBadge();
|
|
RecomputeTurnsBadge();
|
|
RecomputeAgentBadge();
|
|
```
|
|
|
|
Also drop the `(inherit)` sentinel row added to `TaskAgentOptions` (line ~500): replace
|
|
`TaskAgentOptions.Add(new AgentInfo(ModelRegistry.TaskInheritSentinel, "", ""));`
|
|
with a "(none)"/inherit placeholder row (path empty) so `TaskAgentOptions[0]` still means "inherited":
|
|
`TaskAgentOptions.Add(new AgentInfo("(inherited)", "", ""));`
|
|
|
|
- [ ] **Step 3: Update the save + clear paths**
|
|
|
|
In the agent-save method (line ~481), include MaxTurns:
|
|
```csharp
|
|
var model = string.IsNullOrWhiteSpace(TaskModelSelection) ? null : TaskModelSelection;
|
|
var sp = string.IsNullOrWhiteSpace(TaskSystemPrompt) ? null : TaskSystemPrompt;
|
|
var ap = TaskSelectedAgent is null || string.IsNullOrWhiteSpace(TaskSelectedAgent.Path)
|
|
? null : TaskSelectedAgent.Path;
|
|
var turns = TaskMaxTurns is decimal d ? (int?)d : null;
|
|
|
|
await _worker.UpdateTaskAgentSettingsAsync(
|
|
new ClaudeDo.Ui.Services.UpdateTaskAgentSettingsDto(Task.Id, model, sp, ap, turns));
|
|
```
|
|
|
|
In the `Bind(null)` reset block (lines ~583-596), set the new fields:
|
|
```csharp
|
|
TaskModelSelection = null;
|
|
TaskMaxTurns = null;
|
|
TaskSystemPrompt = "";
|
|
TaskSelectedAgent = null;
|
|
```
|
|
and remove the now-deleted `EffectiveModelHint`/`EffectiveAgentHint` assignments; keep `EffectiveSystemPromptHint = "";`.
|
|
|
|
- [ ] **Step 4: Add reset commands**
|
|
|
|
```csharp
|
|
[RelayCommand] private void ResetTaskModel() => TaskModelSelection = null;
|
|
[RelayCommand] private void ResetTaskTurns() => TaskMaxTurns = null;
|
|
[RelayCommand] private void ResetTaskAgent() => TaskSelectedAgent = TaskAgentOptions.Count > 0 ? TaskAgentOptions[0] : null;
|
|
```
|
|
|
|
- [ ] **Step 5: Update the flyout XAML**
|
|
|
|
In `DetailsIslandView.axaml`, add `xmlns:ctl="using:ClaudeDo.Ui.Views.Controls"` to the root if absent. Replace the Model block (lines ~90-98) with the badge+reset label row + null-select ComboBox:
|
|
|
|
```xml
|
|
<StackPanel Spacing="2">
|
|
<Grid ColumnDefinitions="Auto,Auto,*,Auto" ColumnSpacing="6">
|
|
<TextBlock Grid.Column="0" Classes="field-label" Text="{loc:Tr details.modelLabel}" VerticalAlignment="Center"/>
|
|
<ctl:InheritedBadge Grid.Column="1" BadgeText="{Binding ModelBadge}"/>
|
|
<Button Grid.Column="3" Classes="icon-btn" Content="↺" ToolTip.Tip="{loc:Tr settings.inherit.resetToInherited}"
|
|
Command="{Binding ResetTaskModelCommand}"/>
|
|
</Grid>
|
|
<ComboBox ItemsSource="{Binding TaskModelOptions}"
|
|
SelectedItem="{Binding TaskModelSelection, Mode=TwoWay}"
|
|
PlaceholderText="{Binding ModelInheritedHint}"
|
|
HorizontalAlignment="Stretch"/>
|
|
</StackPanel>
|
|
|
|
<StackPanel Spacing="2">
|
|
<Grid ColumnDefinitions="Auto,Auto,*,Auto" ColumnSpacing="6">
|
|
<TextBlock Grid.Column="0" Classes="field-label" Text="{loc:Tr details.maxTurnsLabel}" VerticalAlignment="Center"/>
|
|
<ctl:InheritedBadge Grid.Column="1" BadgeText="{Binding TurnsBadge}"/>
|
|
<Button Grid.Column="3" Classes="icon-btn" Content="↺" ToolTip.Tip="{loc:Tr settings.inherit.resetToInherited}"
|
|
Command="{Binding ResetTaskTurnsCommand}"/>
|
|
</Grid>
|
|
<NumericUpDown Value="{Binding TaskMaxTurns, Mode=TwoWay}"
|
|
Watermark="{Binding TurnsInheritedHint}"
|
|
Minimum="1" Maximum="200" Increment="1" FormatString="0"
|
|
HorizontalAlignment="Stretch"/>
|
|
</StackPanel>
|
|
```
|
|
|
|
For the System prompt block (lines ~100-105), keep the TextBox but change the placeholder hint label to use the new "prepended automatically" key by adding a small caption above/below it:
|
|
```xml
|
|
<StackPanel Spacing="2">
|
|
<TextBlock Classes="field-label" Text="{loc:Tr details.systemPromptLabel}"/>
|
|
<TextBox Text="{Binding TaskSystemPrompt, Mode=TwoWay}"
|
|
AcceptsReturn="True" TextWrapping="Wrap" MinHeight="70"/>
|
|
<TextBlock Classes="meta" Opacity="0.6"
|
|
Text="{loc:Tr details.systemPromptPrepended}"
|
|
IsVisible="{Binding EffectiveSystemPromptHint, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
|
|
<TextBlock Classes="meta" Opacity="0.6" TextWrapping="Wrap"
|
|
Text="{Binding EffectiveSystemPromptHint}"
|
|
IsVisible="{Binding EffectiveSystemPromptHint, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
|
|
</StackPanel>
|
|
```
|
|
|
|
For the Agent block (lines ~107-121), wrap the `field-label` in the badge+reset row (badge bound to `AgentBadge`, reset to `ResetTaskAgentCommand`); remove the old `EffectiveAgentLabel` `<TextBlock>` at lines ~118-120; keep the ComboBox.
|
|
|
|
- [ ] **Step 6: Build the UI project**
|
|
|
|
Run: `dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj -c Release`
|
|
Expected: build succeeds. Resolve any leftover references to the removed `Effective*Label`/`Effective*Hint` members.
|
|
|
|
- [ ] **Step 7: Run the full UI test suite**
|
|
|
|
Run: `dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj -c Release`
|
|
Expected: PASS. If `StubWorkerClient.GetAppSettingsAsync` returning null causes a VM test NRE, the VM already null-coalesces (`app?.DefaultModel ?? ...`) — but verify any DetailsIsland VM test still passes; adjust the stub to return a minimal `AppSettingsDto` if a test needs non-null globals.
|
|
|
|
- [ ] **Step 8: Commit**
|
|
|
|
```bash
|
|
git add src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs src/ClaudeDo.Ui/Views/Islands/DetailsIslandView.axaml
|
|
git commit -m "feat(ui): show inherited markers and max-turns override in task flyout"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 11: Full build + test sweep, docs, visual-verification flag
|
|
|
|
**Files:**
|
|
- Modify: `src/ClaudeDo.Ui/CLAUDE.md` (one-line note on the new flyout/modal fields), `src/ClaudeDo.Data/CLAUDE.md` (note `max_turns` on ListConfig/TaskEntity)
|
|
|
|
- [ ] **Step 1: Build everything that matters**
|
|
|
|
Run, in order:
|
|
```bash
|
|
dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj -c Release
|
|
dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj -c Release
|
|
```
|
|
Expected: both succeed.
|
|
|
|
- [ ] **Step 2: Run the test suites**
|
|
|
|
Run:
|
|
```bash
|
|
dotnet test tests/ClaudeDo.Data.Tests/ClaudeDo.Data.Tests.csproj -c Release
|
|
dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release
|
|
dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj -c Release
|
|
dotnet test tests/ClaudeDo.Localization.Tests/ClaudeDo.Localization.Tests.csproj -c Release
|
|
```
|
|
Expected: all PASS.
|
|
|
|
- [ ] **Step 3: Update the two CLAUDE.md notes**
|
|
|
|
In `src/ClaudeDo.Data/CLAUDE.md`, update the `ListConfigEntity` and `TaskEntity` lines to mention `MaxTurns` (nullable override). In `src/ClaudeDo.Ui/CLAUDE.md`, update the `ListSettingsModalView` and `DetailsIslandView` descriptions to mention the Max-turns field and source-aware inherited markers.
|
|
|
|
- [ ] **Step 4: Commit docs**
|
|
|
|
```bash
|
|
git add src/ClaudeDo.Data/CLAUDE.md src/ClaudeDo.Ui/CLAUDE.md
|
|
git commit -m "docs: note max-turns override and inherited markers in module docs"
|
|
```
|
|
|
|
- [ ] **Step 5: Flag the visual-verification gap (do NOT claim the UI works)**
|
|
|
|
The badge styling, muted placeholder appearance, NumericUpDown watermark, and reset-button layout in both the List settings modal and the task overrides flyout require a human visual pass — they cannot be asserted programmatically. Report to the user: build + automated tests pass; the visual rendering of the inherited markers, placeholders, and reset buttons needs manual confirmation in the running app (open a list's settings and a task's ⚙ flyout, toggle override vs inherited for Model / Turns / Agent).
|
|
|
|
---
|
|
|
|
## Self-Review notes
|
|
|
|
- **Spec coverage:** marker shows resolved value muted (placeholder/watermark) + source-aware badge (Tasks 9-10 via Task 7 resolver) ✓; override semantics with reset (reset commands, Tasks 9-10) ✓; Turns at list + task levels (Tasks 1-5, 9-10) ✓; SystemPrompt stays plain + "prepended automatically" hint (Task 10 Step 5) ✓; MCP parity (Task 4) ✓; localization parity + retire old key (Task 6) ✓; test-fake sync (Task 5) ✓.
|
|
- **Naming consistency:** badge VM props named `ModelBadge` / `TurnsBadge` / `AgentBadge`; hints `*InheritedHint`; reset commands `Reset*Command`; resolver `Resolve` / `ResolveList` returning `(string Value, InheritSource Source)`; control property `BadgeText`. These names are used identically across tasks.
|
|
- **Known risk:** `AppSettingsDto` already carries `DefaultModel` and `DefaultMaxTurns` (confirmed) — UI globals load works. `NumericUpDown.Value` is `decimal?`, hence the `decimal?` VM properties with `int?` conversion at save.
|