# 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/_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/_InheritableMaxTurns.cs` (+ Designer) and updates `ClaudeDoDbContextModelSnapshot.cs`. The `Up` method should `AddColumn("max_turns", "list_config", nullable: true)` and `AddColumn("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("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 ``` > 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 BadgeTextProperty = AvaloniaProperty.Register(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 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