From eb0ddb56d3e21b512ea65b01cd3f74bfff85984a Mon Sep 17 00:00:00 2001 From: Mika Kuns Date: Tue, 23 Jun 2026 08:52:49 +0200 Subject: [PATCH] refactor(agent-config): single AgentConfigEditor for list + task scopes --- .../plans/2026-06-19-unify-agent-config.md | 131 +++++++++ src/ClaudeDo.Localization/locales/de.json | 11 +- src/ClaudeDo.Localization/locales/en.json | 11 +- src/ClaudeDo.Ui/CLAUDE.md | 7 +- .../Agent/AgentConfigEditorViewModel.cs | 259 ++++++++++++++++++ .../Islands/AgentSettingsSectionViewModel.cs | 196 ------------- .../Islands/DetailsIslandViewModel.cs | 10 +- .../Modals/ListSettingsModalViewModel.cs | 92 +------ .../Views/Controls/AgentConfigEditor.axaml | 85 ++++++ .../Views/Controls/AgentConfigEditor.axaml.cs | 75 +++++ .../Views/Islands/Detail/TaskHeaderBar.axaml | 59 +--- .../Views/Modals/ListSettingsModalView.axaml | 66 +---- .../Modals/ListSettingsModalView.axaml.cs | 52 ---- .../AgentConfigEditorViewModelTests.cs | 189 +++++++++++++ 14 files changed, 761 insertions(+), 482 deletions(-) create mode 100644 docs/superpowers/plans/2026-06-19-unify-agent-config.md create mode 100644 src/ClaudeDo.Ui/ViewModels/Agent/AgentConfigEditorViewModel.cs delete mode 100644 src/ClaudeDo.Ui/ViewModels/Islands/AgentSettingsSectionViewModel.cs create mode 100644 src/ClaudeDo.Ui/Views/Controls/AgentConfigEditor.axaml create mode 100644 src/ClaudeDo.Ui/Views/Controls/AgentConfigEditor.axaml.cs create mode 100644 tests/ClaudeDo.Ui.Tests/ViewModels/AgentConfigEditorViewModelTests.cs diff --git a/docs/superpowers/plans/2026-06-19-unify-agent-config.md b/docs/superpowers/plans/2026-06-19-unify-agent-config.md new file mode 100644 index 0000000..96b607d --- /dev/null +++ b/docs/superpowers/plans/2026-06-19-unify-agent-config.md @@ -0,0 +1,131 @@ +# Phase 4 — AgentConfigEditor (A2) + +Date: 2026-06-23 (picked up after reordering Phase 3 ↔ 4) +Umbrella: `docs/superpowers/plans/2026-06-19-feature-unification-plan.md` +Design: `docs/superpowers/specs/2026-06-19-feature-unification-design.md` (A2) + +## Reordering note + +Phase 3 (WorktreeActions) was deferred. Its premise — overview rows and the Details +merge section each owning duplicate worktree commands — only half-holds: Details has +no Discard/Keep/ForceRemove, and the two Diff doors open different VMs (`WorktreeModal` +vs `DiffModal`) that only Phase 5 unifies. So Phase 3's clean form depends on Phase 5 +(Diff) and a fuller MergeCoordinator (Merge); doing it now would build throwaway +per-surface delegates. **Phase 3 is folded into Phase 5.** Phase 4 (independent, clean +dedup) runs now. + +## Scope decision: List + Task only (global left as-is) + +The design names three scopes (Global | List | Task). Verified against the tree on +2026-06-23, only **List and Task genuinely duplicate**: + +- **List** (`ListSettingsModalViewModel`, "AGENT" section): Model / MaxTurns / + SystemPrompt / AgentFile, each with `InheritedBadge` + `↺` reset; 2-tier + (list→global) badges computed with inline logic (does **not** use the existing + `InheritanceResolver.ResolveList` — which is currently dead code); explicit Save. +- **Task** (`AgentSettingsSectionViewModel`, TaskHeaderBar gear flyout): same four + fields; 3-tier (task→list→global) badges via `InheritanceResolver.Resolve`; + `EffectiveMaxTurns` + `EffectiveSystemPromptHint`; `IsRunning` gate; debounced + auto-save. + +**Global** (`GeneralSettingsTabViewModel`, Settings → General) is the root: no +inheritance, no badges, no agent file, no reset — three plain controls (model combo, +max-turns numeric, instructions textbox) plus a global-only PermissionMode, interleaved +with unrelated settings (Language, parallelism, report paths, standup weekday) and +saved batched into one `AppSettingsDto` via the modal Save. Embedding the shared editor +there buys ~3 plain fields at the cost of a degenerate no-badges/no-agent/no-reset mode +plus surgery on the settings save path and a relayout of the most settings-dense view. +**Not worth it — global stays as-is.** (Confirmed with Mika 2026-06-23.) + +The real maintenance hazard is the **VM logic** (two copies of badge/reset/inheritance +that already drifted), and the **view** (3 of 4 field blocks are pixel-identical). Both +collapse cleanly for List+Task. + +## Target + +One `AgentConfigEditorViewModel` + one `AgentConfigEditor` UserControl, instantiated +per surface with a scope. The two host VMs keep only their non-agent concerns and host +the editor as a child. + +### `ViewModels/Agent/AgentConfigEditorViewModel.cs` (new) + +- `enum AgentConfigScope { List, Task }` +- ctor `(IWorkerClient worker, AgentConfigScope scope)` +- Unified bindable surface (single names both views bind to): + `Model` (string?), `MaxTurns` (decimal?), `SystemPrompt` (string), + `SelectedAgent` (AgentInfo?); `ModelOptions`, `Agents`; + `ModelBadge`/`TurnsBadge`/`AgentBadge`, `ModelInheritedHint`/`TurnsInheritedHint`, + `EffectiveSystemPromptHint`; `EffectiveMaxTurns` (int), `IsRunning`/`IsEnabled`. +- Reset commands: `ResetModel`, `ResetTurns`, `ResetAgent`, `ResetAll`. +- Badges via `InheritanceResolver`: scope==Task → `Resolve(own, list, global)`; + scope==List → `ResolveList(own, global)` (adopts the dead method). One `BadgeFor` + helper covers both (List scope never yields the `List` source). +- Load: `LoadForListAsync(listId)` and `LoadForTaskAsync(TaskEntity entity)` — both + pull agents + app-settings (global defaults); Task also pulls the list tier + + `EffectiveSystemPromptHint`. Localizer-change re-badges (port the `Loc.LanguageChanged` + handler + `IDisposable`). +- Save: `SaveAsync()` is scope-aware — List builds `UpdateListConfigDto` → + `UpdateListConfigAsync`; Task builds `UpdateTaskAgentSettingsDto` → + `UpdateTaskAgentSettingsAsync`. Task scope also auto-saves debounced (300ms) on field + changes; List does not (the modal Save button calls `SaveAsync`). `SaveAsync` is + directly callable (tests bypass the debounce). +- Task-only `Clear()` + `TaskId`. + +### `Views/Controls/AgentConfigEditor.axaml` (+ .axaml.cs) (new) + +- `x:DataType` = `AgentConfigEditorViewModel`; host sets `DataContext="{Binding Agent}"`. +- The four field blocks (model/turns/systemprompt/agent) with `InheritedBadge` + `↺` + reset, lifted verbatim from the existing two views (they already match). Agent combo + shows Name + Description (both scopes; harmless for task). `EffectiveSystemPromptHint` + line gated on non-empty (hides for List). +- `StyledProperty ShowAgentBrowse` (default false). True → render the Browse + button + path line; the browse file-picker code-behind lives here (moved from + `ListSettingsModalView`). +- Shared localization namespace `settings.agentEditor.*` (model/maxTurns/systemPrompt/ + agentFile/promptPrepended). Reset tooltip reuses `settings.inherit.resetToInherited`. + +### Re-point hosts + +- `ListSettingsModalViewModel`: drop the agent fields/badges/resets/option-lists; add + `public AgentConfigEditorViewModel Agent { get; }` (scope=List). `LoadAsync` → + `Agent.LoadForListAsync(listId)`. `SaveAsync` keeps `UpdateListAsync` (name/dir) and + adds `await Agent.SaveAsync()`. Keep working-dir browse (`BrowseClicked`). +- `ListSettingsModalView.axaml`: replace the AGENT section body with + ``; the + section-header "Reset agent settings" button binds `Agent.ResetAllCommand`. Remove the + agent browse code-behind (moved into the control). +- `DetailsIslandViewModel`: `AgentSettings` becomes `AgentConfigEditorViewModel` + (scope=Task). Preserve the call sites: ctor, `EffectiveMaxTurns`→`TurnsText` + PropertyChanged hook, `IsRunning` push, `Dispose`, `Clear`, `TaskId`, + `LoadForTaskAsync(entity, ct)`. +- `TaskHeaderBar.axaml`: replace the flyout field blocks with + `` (ShowAgentBrowse=false). + Keep the gear button + heading. +- Delete `AgentSettingsSectionViewModel.cs`. + +## Tests + +- New `tests/ClaudeDo.Ui.Tests/ViewModels/AgentConfigEditorViewModelTests.cs`: + - List scope: badges resolve override-vs-global; resets clear; `SaveAsync` builds the + right `UpdateListConfigDto` (via `StubWorkerClient`). + - Task scope: badges resolve override/list/global; `EffectiveMaxTurns`/ + `EffectiveSystemPromptHint` from list tier; resets clear; `SaveAsync` builds the right + `UpdateTaskAgentSettingsDto`. +- `InheritanceResolverTests` unchanged (resolver untouched). +- Existing DetailsIsland* tests must stay green (they construct the VM but don't name the + moved members). + +## Acceptance + +- `dotnet build -c Release` clean for Ui (+ App). +- `Ui.Tests` + `Localization.Tests` green. +- One editor VM + one control drive both List and Task; duplicated field/badge/reset + logic deleted; `ResolveList` now has a real caller. +- Visual gap flagged: open List Settings → Agent, and a task's gear flyout — verify + badges, ↺ resets, reset-all, agent browse (list only), system-prompt hint (task), and + that list Save persists + task auto-saves. + +## Commit + +`refactor(agent-config): single AgentConfigEditor for list + task scopes`. Stage by +path. Commit this plan with it. diff --git a/src/ClaudeDo.Localization/locales/de.json b/src/ClaudeDo.Localization/locales/de.json index 4a02803..2407f09 100644 --- a/src/ClaudeDo.Localization/locales/de.json +++ b/src/ClaudeDo.Localization/locales/de.json @@ -169,11 +169,6 @@ "starTip": "Favorit", "agentSettingsTip": "Agent-Einstellungen", "agentSettingsHeading": "Agent-Einstellungen (Überschreibungen)", - "modelLabel": "Modell", - "maxTurnsLabel": "Max. Durchläufe", - "systemPromptLabel": "System-Prompt (angehängt)", - "systemPromptPrepended": "Wird automatisch vorangestellt:", - "agentFileLabel": "Agent-Datei", "mergeLabel": "MERGE", "mergeTargetLabel": "Merge-Ziel", "reviewCombinedDiff": "Kombiniertes Diff prüfen", @@ -264,11 +259,7 @@ "browse": "Durchsuchen...", "defaultCommitType": "Standard-Commit-Typ", "sectionAgent": "AGENT", - "resetAgentSettings": "Agent-Einstellungen zurücksetzen", - "model": "Modell", - "maxTurns": "Max. Durchläufe", - "systemPrompt": "System-Prompt (angehängt)", - "agentFile": "Agent-Datei" + "resetAgentSettings": "Agent-Einstellungen zurücksetzen" }, "merge": { "title": "WORKTREE MERGEN", diff --git a/src/ClaudeDo.Localization/locales/en.json b/src/ClaudeDo.Localization/locales/en.json index 6acf54f..89bfeea 100644 --- a/src/ClaudeDo.Localization/locales/en.json +++ b/src/ClaudeDo.Localization/locales/en.json @@ -169,11 +169,6 @@ "starTip": "Star", "agentSettingsTip": "Agent settings", "agentSettingsHeading": "Agent settings (overrides)", - "modelLabel": "Model", - "maxTurnsLabel": "Max turns", - "systemPromptLabel": "System prompt (appended)", - "systemPromptPrepended": "Prepended automatically:", - "agentFileLabel": "Agent file", "mergeLabel": "MERGE", "mergeTargetLabel": "Merge target", "reviewCombinedDiff": "Review combined diff", @@ -264,11 +259,7 @@ "browse": "Browse...", "defaultCommitType": "Default commit type", "sectionAgent": "AGENT", - "resetAgentSettings": "Reset agent settings", - "model": "Model", - "maxTurns": "Max turns", - "systemPrompt": "System prompt (appended)", - "agentFile": "Agent file" + "resetAgentSettings": "Reset agent settings" }, "merge": { "title": "MERGE WORKTREE", diff --git a/src/ClaudeDo.Ui/CLAUDE.md b/src/ClaudeDo.Ui/CLAUDE.md index aff10c7..329353f 100644 --- a/src/ClaudeDo.Ui/CLAUDE.md +++ b/src/ClaudeDo.Ui/CLAUDE.md @@ -19,6 +19,7 @@ ViewModels/ IslandsShellViewModel.cs — root coordinator Islands/ — ListsIsland, TasksIsland, DetailsIsland, TaskRow, ListNavItem, NotesEditor, MergePreviewPresenter + Agent/ — AgentConfigEditorViewModel (scope-parameterized: List | Task) Modals/ — About, Diff, ListSettings, Merge, RepoImport, Settings (+ Settings/ tab VMs), UnfinishedPlanning, WeeklyReport, WorkerConnection, Worktree, WorktreesOverview, UnifiedDiffParser @@ -26,7 +27,7 @@ ViewModels/ Conflicts/ — ConflictResolverViewModel + ConflictModels (MergeFile/MergeFileSegment/MergeConflictBlock) Views/ — mirrors the VM layout; Islands/Detail/ holds TaskHeaderBar, DescriptionStepsCard, WorkConsole; plus AgentStripView, SessionTerminalView -Views/Controls/ — MarkdownView, ModalShell, ThemedDatePicker, DiffLinesView, InheritedBadge +Views/Controls/ — MarkdownView, ModalShell, ThemedDatePicker, DiffLinesView, InheritedBadge, AgentConfigEditor Design/ — Tokens.axaml (design tokens; merged before styles) + IslandStyles.axaml (component styles + the filled icon geometry library) ``` @@ -36,10 +37,10 @@ Design/ — Tokens.axaml (design tokens; merged before styles) + IslandStyle - **IslandsShellViewModel** — root coordinator; owns the three island VMs and the `WorkerClient`, wires cross-island events (selection, notes/prep mode, conflict resolution), owns connection state, the update banner, the inline worker-log strip, responsive-layout flags (`ShowLists`/`ShowDetails` by window width), `PrimeStatus` flash, and the modal openers (About, RepoImport, WeeklyReport, WorktreesOverview, WorkerConnection help) plus `RestartWorkerAsync`/`CheckForUpdatesAsync`. Hosts `UpdateCheckService`. - **ListsIslandViewModel** — smart lists (My Day, Important, Planned, virtual queued/running/review), user lists, selection, list CRUD, drag-reorder, badge counts, opens list settings / repo import / worktrees overview, `OpenInExplorer`/`OpenInTerminal`. - **TasksIslandViewModel** — open/overdue/completed groups for the selected list with hierarchy-aware regrouping; task CRUD, drag-reorder, toggle done/star, schedule, enqueue/dequeue, cancel; review actions (approve, reject-rerun, reject-park, cancel); planning session lifecycle (open/resume/discard/finalize, `QueuePlanningSubtasksAsync`); `RunInteractivelyAsync`, `RefineTask`; MyDay extras (`IsMyDayList`, `ClearDayCommand`, `ShowPrepLogCommand`) and the pinned Notes pseudo-row (`ShowNotesRow`, `OpenNotesCommand`). Raises `NotesRequested`/`PrepRequested` events consumed by the shell. -- **DetailsIslandViewModel** — the detail pane for a bound `TaskRowViewModel`. Owns live-log streaming (`Log` via `StreamLineFormatter`), debounced title/description editing, subtasks, session-outcome/roadblock split (splits `Result` at the roadblock marker into two cards), the three-tab work console (`output`/`git`/`session`), child surfacing (`ChildOutcomes` rows plus `ChildrenNeedingAttention`/`HasChildrenNeedingAttention` — children that failed, were cancelled, await review, or reported roadblocks — drive an attention band on the Session tab, which is only visible when `HasChildOutcomes`), and the modes: `IsNotesMode` (hosts `NotesEditorViewModel`), `IsPrepMode`, computed `IsTaskDetailVisible = !IsNotesMode && !IsPrepMode`. Three concerns are extracted into section VMs exposed as properties: **AgentSettingsSectionViewModel** (per-task Model/MaxTurns/AgentPath overrides with `InheritedBadge` + `InheritanceResolver`, additive SystemPrompt, debounced save), **MergeSectionViewModel** (merge-target selection, mergeability indicator via `MergePreviewPresenter` over `PreviewMergeAsync`, `OpenDiffAsync` — live worktree or commit range after merge —, `ReviewCombinedDiffCommand` → `PlanningDiffViewModel`), **PrepPanelViewModel** (daily-prep panel: `PrepLog`, `PlanDayCommand` → `RunDailyPrepNowAsync`, persisted last run via `GetLastPrepLogAsync`). Attachments: `Attachments` (`ObservableCollection`), `IsDragOver`, `DropStatus`, `CanAcceptDrop`, `AddFilesAsync`, `RemoveAttachmentCommand`; loads on task change; `ComposedPreview` includes attachment paths. Writes directly via `new AttachmentStore()` + `new TaskAttachmentRepository(ctx)`. Helper rows (`ChildOutcomeRowViewModel`, `SubtaskRowViewModel`, `LogLineViewModel`, `AttachmentRowViewModel`) live in the same file. +- **DetailsIslandViewModel** — the detail pane for a bound `TaskRowViewModel`. Owns live-log streaming (`Log` via `StreamLineFormatter`), debounced title/description editing, subtasks, session-outcome/roadblock split (splits `Result` at the roadblock marker into two cards), the three-tab work console (`output`/`git`/`session`), child surfacing (`ChildOutcomes` rows plus `ChildrenNeedingAttention`/`HasChildrenNeedingAttention` — children that failed, were cancelled, await review, or reported roadblocks — drive an attention band on the Session tab, which is only visible when `HasChildOutcomes`), and the modes: `IsNotesMode` (hosts `NotesEditorViewModel`), `IsPrepMode`, computed `IsTaskDetailVisible = !IsNotesMode && !IsPrepMode`. Three concerns are extracted into section VMs exposed as properties: **AgentConfigEditorViewModel** (scope=Task; per-task Model/MaxTurns/AgentPath overrides with `InheritedBadge` + `InheritanceResolver`, additive SystemPrompt, debounced auto-save; exposed as `AgentSettings`), **MergeSectionViewModel** (merge-target selection, mergeability indicator via `MergePreviewPresenter` over `PreviewMergeAsync`, `OpenDiffAsync` — live worktree or commit range after merge —, `ReviewCombinedDiffCommand` → `PlanningDiffViewModel`), **PrepPanelViewModel** (daily-prep panel: `PrepLog`, `PlanDayCommand` → `RunDailyPrepNowAsync`, persisted last run via `GetLastPrepLogAsync`). Attachments: `Attachments` (`ObservableCollection`), `IsDragOver`, `DropStatus`, `CanAcceptDrop`, `AddFilesAsync`, `RemoveAttachmentCommand`; loads on task change; `ComposedPreview` includes attachment paths. Writes directly via `new AttachmentStore()` + `new TaskAttachmentRepository(ctx)`. Helper rows (`ChildOutcomeRowViewModel`, `SubtaskRowViewModel`, `LogLineViewModel`, `AttachmentRowViewModel`) live in the same file. - **TaskRowViewModel** / **ListNavItemViewModel** — lightweight display VMs (task row: status, planning phase, parent/blocked links, roadblock count, computed `IsDraft`/`IsPlanned`/`IsChild`/`IsPlanningParent`/`CanRefine`; list row: kind Smart/Virtual/User, count, icon/dot keys, drop hints). - **NotesEditorViewModel** — day navigator + bullet CRUD for daily notes via `INotesApi`. -- **Modal VMs** — `SettingsModalViewModel` (four tabs: General, Worktrees, Files prompt-paths, Prime Claude incl. `DailyPrepMaxTasks` + prime-schedule rows), `ListSettingsModalViewModel` (name, working dir, commit type, per-list Model/SystemPrompt/AgentPath/MaxTurns with inherited-badge + reset, delete list), `RepoImportModalViewModel` (bulk-create lists from git repos found under chosen parents; already-wired repos disabled), `WeeklyReportModalViewModel` (range pickers default "since last standup weekday → today", cached per range, markdown via MarkdownView), `MergeModalViewModel` (single-task merge form, called from the diff modal), `WorktreesOverviewModalViewModel` (global/per-list worktree rows, batch merge + state ops), `UnfinishedPlanningModalViewModel` (Resume/FinalizeNow/Discard for a draft planning session), `WorkerConnectionModalViewModel` (offline help), `AboutModalViewModel`. +- **Modal VMs** — `SettingsModalViewModel` (four tabs: General, Worktrees, Files prompt-paths, Prime Claude incl. `DailyPrepMaxTasks` + prime-schedule rows), `ListSettingsModalViewModel` (name, working dir, commit type, delete list; hosts shared `AgentConfigEditorViewModel` as `Agent` property (scope=List) — save delegates to `Agent.SaveAsync()`), `RepoImportModalViewModel` (bulk-create lists from git repos found under chosen parents; already-wired repos disabled), `WeeklyReportModalViewModel` (range pickers default "since last standup weekday → today", cached per range, markdown via MarkdownView), `MergeModalViewModel` (single-task merge form, called from the diff modal), `WorktreesOverviewModalViewModel` (global/per-list worktree rows, batch merge + state ops), `UnfinishedPlanningModalViewModel` (Resume/FinalizeNow/Discard for a draft planning session), `WorkerConnectionModalViewModel` (offline help), `AboutModalViewModel`. - **Diff stack** — `UnifiedDiffParser` (static; parses `git diff` output into `DiffFileViewModel`s, detecting added/deleted/renamed/binary files and per-line numbers; `Flatten` injects file-header rows for a combined single-pane view). `DiffModalViewModel` has two modes: live worktree (branch diff vs base, with a Merge action) and commit-range `base..head` (`FromCommitRange = true` — shows a merged task's diff after its worktree is gone; no merge action). The view renders a file list with binary/empty placeholders via `DiffLinesView`. - **Planning/Conflicts** — `PlanningDiffViewModel` (per-subtask diffs via `GetPlanningAggregateAsync`, toggle to combined integration-branch diff, conflict warnings), `ConflictResolverViewModel` (in-app **Rider-style 3-pane merge editor** for both single-task and planning unit-merge conflicts: single-task starts the conflict merge, parses each conflicted file into stable/conflict `MergeFileSegment`s via the worker's `GetMergeConflictDocuments`; exposes the active file's three reconstructed documents — `ActiveOursText` / `ActiveResultText` / `ActiveTheirsText` (from `MergeFile.OursText/ResultText/TheirsText`; Result seeds unresolved conflicts with Ours) — plus `ActiveFile`/`SelectFileCommand` (multi-file switcher), `Current`/`Next`/`Previous` (focused-conflict nav), a per-active-file `PositionText` readout, per-block `AcceptOurs/Theirs/Both/Base` + `MergeFile.Compose`, and `CanContinue` gated on every file resolved + no binary; writes each file via `WriteConflictResolution`, continue/abort; **planning mode** via `OpenForPlanningAsync(parentId, subtaskId)` loads the current subtask's mid-merge conflicts without re-starting the merge and routes continue/abort to `ContinuePlanningMerge`/`AbortPlanningMerge`, so a unit-merge conflict re-opens the editor per subtask via the `PlanningMergeConflict` broadcast). The view (`Views/Conflicts/ConflictResolverView`) shows the whole file in three **AvaloniaEdit** panes — MAIN/ours (read-only) | editable Result | INCOMING/theirs (read-only) — with TextMate highlighting by extension (theme `StyleInclude` in `App.axaml`); a code-behind `IBackgroundRenderer` tints each conflict block (unresolved/resolved) across panes, an `IReadOnlySectionProvider` + `TextAnchor` regions keep only conflict spans editable in Result (edits flow back to the block); each unresolved conflict starts EMPTY (a thin marker bar); the between-pane gutter controls **toggle** each side in/out of the result — `›`/`‹` add MAIN/INCOMING in click order (first pick on top), clicking again removes that side — so a conflict can take main, incoming, both, or neither; a `FilesSummary` readout shows how many files still have conflicts, and the three panes share a proportional synced vertical scroll. A conflict overview ruler right of the Result pane (`ConflictMap`) maps every conflict in the file proportionally (click a tick to jump) — handy for long files. Conflict block tints live in `Tokens.axaml` (`Merge*TintBrush`). The editor is reached from review **Approve** on conflict and from the **Merge** button in the Diff window (a conflicting `MergeTask` hands off to the resolver via `RequestConflictResolution`). diff --git a/src/ClaudeDo.Ui/ViewModels/Agent/AgentConfigEditorViewModel.cs b/src/ClaudeDo.Ui/ViewModels/Agent/AgentConfigEditorViewModel.cs new file mode 100644 index 0000000..ff6dee4 --- /dev/null +++ b/src/ClaudeDo.Ui/ViewModels/Agent/AgentConfigEditorViewModel.cs @@ -0,0 +1,259 @@ +using System.Collections.ObjectModel; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using ClaudeDo.Data.Models; +using ClaudeDo.Ui.Localization; +using ClaudeDo.Ui.Services; + +namespace ClaudeDo.Ui.ViewModels.Agent; + +public enum AgentConfigScope { List, Task } + +/// +/// One agent-config editor (Model / MaxTurns / SystemPrompt / AgentFile with inherited +/// badges + reset) shared by the List Settings modal and the per-task gear flyout. +/// Scope picks the inheritance depth (List: list→global; Task: task→list→global) and the +/// persistence (List: explicit ; Task: debounced auto-save). +/// +public sealed partial class AgentConfigEditorViewModel : ViewModelBase, IDisposable +{ + private readonly IWorkerClient _worker; + private readonly AgentConfigScope _scope; + private readonly EventHandler _langChangedHandler; + + /// scope==List ⇒ the list id; scope==Task ⇒ the task id. Null ⇒ no save target. + internal string? TargetId { get; set; } + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(IsEnabled))] + private bool _isRunning; + + // Task scope gates the editor while the run is live; List scope is always enabled. + public bool IsEnabled => !IsRunning; + + [ObservableProperty] private string? _model; + [ObservableProperty] private decimal? _maxTurns; + [ObservableProperty] private string _systemPrompt = ""; + [ObservableProperty] private AgentInfo? _selectedAgent; + + [ObservableProperty] private string _modelBadge = ""; + [ObservableProperty] private string _modelInheritedHint = ""; + [ObservableProperty] private string _turnsBadge = ""; + [ObservableProperty] private string _turnsInheritedHint = ""; + [ObservableProperty] private string _agentBadge = ""; + [ObservableProperty] private string _effectiveSystemPromptHint = ""; + + private string _globalModel = ModelRegistry.DefaultAlias; + private int _globalMaxTurns = 100; + private string? _listModel; // Task scope only + private int? _listMaxTurns; // Task scope only + private string? _listAgentName; // Task scope only + + private bool _suppressSave; + private CancellationTokenSource? _saveCts; + + public int EffectiveMaxTurns => + MaxTurns is decimal t ? (int)t : (_listMaxTurns ?? _globalMaxTurns); + + public ObservableCollection ModelOptions { get; } = new(ModelRegistry.Aliases); + public ObservableCollection Agents { get; } = new(); + + public AgentConfigEditorViewModel(IWorkerClient worker, AgentConfigScope scope) + { + _worker = worker; + _scope = scope; + _langChangedHandler = (_, _) => RecomputeBadges(); + // Only the long-lived Task editor needs live re-badging; the List editor is a + // short-lived modal recreated with the current language on each open. + if (scope == AgentConfigScope.Task) + Loc.LanguageChanged += _langChangedHandler; + } + + public void Dispose() => Loc.LanguageChanged -= _langChangedHandler; + + partial void OnModelChanged(string? value) { RecomputeModelBadge(); QueueSave(); } + + partial void OnMaxTurnsChanged(decimal? value) + { + RecomputeTurnsBadge(); + OnPropertyChanged(nameof(EffectiveMaxTurns)); + QueueSave(); + } + + partial void OnSystemPromptChanged(string value) => QueueSave(); + partial void OnSelectedAgentChanged(AgentInfo? value) { RecomputeAgentBadge(); QueueSave(); } + + private void RecomputeBadges() + { + RecomputeModelBadge(); + RecomputeTurnsBadge(); + RecomputeAgentBadge(); + } + + private void RecomputeModelBadge() + { + var own = string.IsNullOrWhiteSpace(Model) ? null : Model; + var (value, source) = _scope == AgentConfigScope.Task + ? InheritanceResolver.Resolve(own, _listModel, _globalModel) + : InheritanceResolver.ResolveList(own, _globalModel); + ModelInheritedHint = value; + ModelBadge = BadgeFor(source, own is not null); + } + + private void RecomputeTurnsBadge() + { + var own = MaxTurns?.ToString(); + var (value, source) = _scope == AgentConfigScope.Task + ? InheritanceResolver.Resolve(own, _listMaxTurns?.ToString(), _globalMaxTurns.ToString()) + : InheritanceResolver.ResolveList(own, _globalMaxTurns.ToString()); + TurnsInheritedHint = value; + TurnsBadge = BadgeFor(source, MaxTurns is not null); + } + + private void RecomputeAgentBadge() + { + var agentSet = SelectedAgent is not null && !string.IsNullOrWhiteSpace(SelectedAgent.Path); + var own = agentSet ? SelectedAgent!.Path : null; + var (_, source) = _scope == AgentConfigScope.Task + ? InheritanceResolver.Resolve(own, _listAgentName, null) + : InheritanceResolver.ResolveList(own, null); + AgentBadge = BadgeFor(source, agentSet); + } + + private static string BadgeFor(InheritSource source, bool isSet) => isSet + ? Loc.T("settings.inherit.overrideBadge") + : source == InheritSource.List + ? Loc.T("settings.inherit.inheritedFromList") + : Loc.T("settings.inherit.inheritedFromGlobal"); + + private void QueueSave() + { + // List scope persists on the modal Save button; only Task auto-saves. + if (_suppressSave || _scope != AgentConfigScope.Task || TargetId is null) return; + _saveCts?.Cancel(); + _saveCts = new CancellationTokenSource(); + _ = DebouncedSaveAsync(_saveCts.Token); + } + + private async System.Threading.Tasks.Task DebouncedSaveAsync(CancellationToken ct) + { + try + { + await System.Threading.Tasks.Task.Delay(300, ct); + if (TargetId is null) return; + await SaveAsync(); + } + catch (OperationCanceledException) { } + catch { } + } + + public async System.Threading.Tasks.Task SaveAsync() + { + if (TargetId is null) return; + var model = string.IsNullOrWhiteSpace(Model) ? null : Model; + 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; + + if (_scope == AgentConfigScope.Task) + await _worker.UpdateTaskAgentSettingsAsync(new UpdateTaskAgentSettingsDto(TargetId, model, sp, ap, turns)); + else + await _worker.UpdateListConfigAsync(new UpdateListConfigDto(TargetId, model, sp, ap, turns)); + } + + public async System.Threading.Tasks.Task LoadForListAsync(string listId, CancellationToken ct = default) + { + _suppressSave = true; + try + { + TargetId = listId; + await ReloadAgentsAsync("(none)"); + await LoadGlobalDefaultsAsync(); + + var cfg = await _worker.GetListConfigAsync(listId); + ApplyConfig(cfg?.Model, cfg?.MaxTurns, cfg?.SystemPrompt, cfg?.AgentPath); + + _listModel = null; _listMaxTurns = null; _listAgentName = null; + EffectiveSystemPromptHint = ""; + RecomputeBadges(); + OnPropertyChanged(nameof(EffectiveMaxTurns)); + } + finally { _suppressSave = false; } + } + + public async System.Threading.Tasks.Task LoadForTaskAsync(TaskEntity entity, CancellationToken ct = default) + { + _suppressSave = true; + try + { + TargetId = entity.Id; + await ReloadAgentsAsync("(inherited)"); + ApplyConfig(entity.Model, entity.MaxTurns, entity.SystemPrompt, entity.AgentPath); + + var listCfg = await _worker.GetListConfigAsync(entity.ListId); + await LoadGlobalDefaultsAsync(); + _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!; + + RecomputeBadges(); + OnPropertyChanged(nameof(EffectiveMaxTurns)); + } + finally { _suppressSave = false; } + } + + public void Clear() + { + _suppressSave = true; + try + { + Model = null; + MaxTurns = null; + SystemPrompt = ""; + SelectedAgent = null; + } + finally { _suppressSave = false; } + EffectiveSystemPromptHint = ""; + TargetId = null; + } + + private async System.Threading.Tasks.Task ReloadAgentsAsync(string placeholderName) + { + Agents.Clear(); + Agents.Add(new AgentInfo(placeholderName, "", "")); + foreach (var a in await _worker.GetAgentsAsync()) Agents.Add(a); + } + + private async System.Threading.Tasks.Task LoadGlobalDefaultsAsync() + { + var app = await _worker.GetAppSettingsAsync(); + _globalModel = app?.DefaultModel ?? ModelRegistry.DefaultAlias; + _globalMaxTurns = app?.DefaultMaxTurns ?? 100; + } + + private void ApplyConfig(string? model, int? maxTurns, string? systemPrompt, string? agentPath) + { + Model = string.IsNullOrWhiteSpace(model) ? null : model!; + MaxTurns = maxTurns is int mt ? mt : (decimal?)null; + SystemPrompt = systemPrompt ?? ""; + SelectedAgent = string.IsNullOrWhiteSpace(agentPath) + ? Agents[0] + : (Agents.FirstOrDefault(a => a.Path == agentPath) ?? Agents[0]); + } + + [RelayCommand] private void ResetModel() => Model = null; + [RelayCommand] private void ResetTurns() => MaxTurns = null; + [RelayCommand] private void ResetAgent() => SelectedAgent = Agents.Count > 0 ? Agents[0] : null; + + [RelayCommand] + private void ResetAll() + { + Model = null; + MaxTurns = null; + SystemPrompt = ""; + SelectedAgent = Agents.Count > 0 ? Agents[0] : null; + } +} diff --git a/src/ClaudeDo.Ui/ViewModels/Islands/AgentSettingsSectionViewModel.cs b/src/ClaudeDo.Ui/ViewModels/Islands/AgentSettingsSectionViewModel.cs deleted file mode 100644 index f4327e9..0000000 --- a/src/ClaudeDo.Ui/ViewModels/Islands/AgentSettingsSectionViewModel.cs +++ /dev/null @@ -1,196 +0,0 @@ -using System.Collections.ObjectModel; -using CommunityToolkit.Mvvm.ComponentModel; -using CommunityToolkit.Mvvm.Input; -using ClaudeDo.Data.Models; -using ClaudeDo.Ui.Helpers; -using ClaudeDo.Ui.Localization; -using ClaudeDo.Ui.Services; - -namespace ClaudeDo.Ui.ViewModels.Islands; - -public sealed partial class AgentSettingsSectionViewModel : ViewModelBase, IDisposable -{ - private readonly IWorkerClient _worker; - private readonly EventHandler _langChangedHandler; - - internal string? TaskId { get; set; } - - [ObservableProperty] - [NotifyPropertyChangedFor(nameof(IsAgentSectionEnabled))] - private bool _isRunning; - - public bool IsAgentSectionEnabled => !IsRunning; - - [ObservableProperty] private string? _taskModelSelection; - [ObservableProperty] private string _taskSystemPrompt = ""; - [ObservableProperty] private AgentInfo? _taskSelectedAgent; - [ObservableProperty] private decimal? _taskMaxTurns; - [ObservableProperty] private string _modelBadge = ""; - [ObservableProperty] private string _modelInheritedHint = ""; - [ObservableProperty] private string _turnsBadge = ""; - [ObservableProperty] private string _turnsInheritedHint = ""; - [ObservableProperty] private string _agentBadge = ""; - [ObservableProperty] private string _effectiveSystemPromptHint = ""; - - private string _globalModel = ModelRegistry.DefaultAlias; - private int _globalMaxTurns = 100; - private string? _listModel; - private int? _listMaxTurns; - private string? _listAgentName; - - private bool _suppressAgentSave; - private CancellationTokenSource? _agentSaveCts; - - public int EffectiveMaxTurns => - TaskMaxTurns is decimal t ? (int)t : (_listMaxTurns ?? _globalMaxTurns); - - public ObservableCollection TaskModelOptions { get; } = new(ModelRegistry.Aliases); - public ObservableCollection TaskAgentOptions { get; } = new(); - - public AgentSettingsSectionViewModel(IWorkerClient worker) - { - _worker = worker; - _langChangedHandler = (_, _) => - { - RecomputeModelBadge(); - RecomputeTurnsBadge(); - RecomputeAgentBadge(); - }; - Loc.LanguageChanged += _langChangedHandler; - } - - public void Dispose() => Loc.LanguageChanged -= _langChangedHandler; - - partial void OnTaskModelSelectionChanged(string? value) { RecomputeModelBadge(); QueueAgentSave(); } - - partial void OnTaskMaxTurnsChanged(decimal? value) - { - RecomputeTurnsBadge(); - OnPropertyChanged(nameof(EffectiveMaxTurns)); - QueueAgentSave(); - } - - partial void OnTaskSystemPromptChanged(string value) => QueueAgentSave(); - partial void OnTaskSelectedAgentChanged(AgentInfo? value) { RecomputeAgentBadge(); QueueAgentSave(); } - - private void RecomputeModelBadge() - { - var (value, source) = InheritanceResolver.Resolve(TaskModelSelection, _listModel, _globalModel); - ModelInheritedHint = value; - ModelBadge = BadgeFor(source, !string.IsNullOrWhiteSpace(TaskModelSelection)); - } - - private void RecomputeTurnsBadge() - { - var (value, source) = InheritanceResolver.Resolve( - TaskMaxTurns?.ToString(), _listMaxTurns?.ToString(), _globalMaxTurns.ToString()); - TurnsInheritedHint = value; - TurnsBadge = BadgeFor(source, 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"); - - private void QueueAgentSave() - { - if (_suppressAgentSave || TaskId is null) return; - _agentSaveCts?.Cancel(); - _agentSaveCts = new CancellationTokenSource(); - _ = SaveAgentSettingsAsync(_agentSaveCts.Token); - } - - private async System.Threading.Tasks.Task SaveAgentSettingsAsync(CancellationToken ct) - { - try - { - await System.Threading.Tasks.Task.Delay(300, ct); - if (TaskId is null) return; - - 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 UpdateTaskAgentSettingsDto(TaskId, model, sp, ap, turns)); - } - catch (OperationCanceledException) { } - catch { } - } - - internal async System.Threading.Tasks.Task LoadAsync( - ClaudeDo.Data.Models.TaskEntity entity, CancellationToken ct) - { - _suppressAgentSave = true; - try - { - TaskAgentOptions.Clear(); - TaskAgentOptions.Add(new AgentInfo("(inherited)", "", "")); - var agents = await _worker.GetAgentsAsync(); - foreach (var a in agents) TaskAgentOptions.Add(a); - - 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(); - OnPropertyChanged(nameof(EffectiveMaxTurns)); - } - finally - { - _suppressAgentSave = false; - } - } - - internal void Clear() - { - _suppressAgentSave = true; - try - { - TaskModelSelection = null; - TaskMaxTurns = null; - TaskSystemPrompt = ""; - TaskSelectedAgent = null; - } - finally - { - _suppressAgentSave = false; - } - EffectiveSystemPromptHint = ""; - TaskId = null; - } - - [RelayCommand] private void ResetTaskModel() => TaskModelSelection = null; - [RelayCommand] private void ResetTaskTurns() => TaskMaxTurns = null; - [RelayCommand] private void ResetTaskAgent() => - TaskSelectedAgent = TaskAgentOptions.Count > 0 ? TaskAgentOptions[0] : null; -} diff --git a/src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs b/src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs index 2fac4db..5e9599d 100644 --- a/src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs +++ b/src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs @@ -9,6 +9,7 @@ using ClaudeDo.Ui.Helpers; using ClaudeDo.Ui.Localization; using ClaudeDo.Ui.Services; using ClaudeDo.Ui.Services.Interfaces; +using ClaudeDo.Ui.ViewModels.Agent; using ClaudeDo.Ui.ViewModels.Modals; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; @@ -56,7 +57,7 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable private readonly IMergeCoordinator _merge; // ── Section view models ─────────────────────────────────────────────────── - public AgentSettingsSectionViewModel AgentSettings { get; } + public AgentConfigEditorViewModel AgentSettings { get; } public MergeSectionViewModel Merge { get; } public PrepPanelViewModel Prep { get; } @@ -425,7 +426,7 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable _notesApi = notesApi; _merge = merge; - AgentSettings = new AgentSettingsSectionViewModel(worker); + AgentSettings = new AgentConfigEditorViewModel(worker, AgentConfigScope.Task); Merge = new MergeSectionViewModel(worker, services); Prep = new PrepPanelViewModel(worker); @@ -436,7 +437,7 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable AgentSettings.PropertyChanged += (_, e) => { - if (e.PropertyName == nameof(AgentSettingsSectionViewModel.EffectiveMaxTurns)) + if (e.PropertyName == nameof(AgentConfigEditorViewModel.EffectiveMaxTurns)) OnPropertyChanged(nameof(TurnsText)); }; @@ -685,8 +686,7 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable Merge.SyncWorktree(WorktreePath, WorktreeBaseCommit, WorktreeHeadCommit, WorktreeStateLabel, _listWorkingDir); - AgentSettings.TaskId = row.Id; - await AgentSettings.LoadAsync(entity, ct); + await AgentSettings.LoadForTaskAsync(entity, ct); ct.ThrowIfCancellationRequested(); var runRepo = new TaskRunRepository(ctx); diff --git a/src/ClaudeDo.Ui/ViewModels/Modals/ListSettingsModalViewModel.cs b/src/ClaudeDo.Ui/ViewModels/Modals/ListSettingsModalViewModel.cs index 90c4ee4..92492ff 100644 --- a/src/ClaudeDo.Ui/ViewModels/Modals/ListSettingsModalViewModel.cs +++ b/src/ClaudeDo.Ui/ViewModels/Modals/ListSettingsModalViewModel.cs @@ -4,6 +4,7 @@ using ClaudeDo.Data.Models; using ClaudeDo.Data.Repositories; using ClaudeDo.Ui.Localization; using ClaudeDo.Ui.Services; +using ClaudeDo.Ui.ViewModels.Agent; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; using Microsoft.EntityFrameworkCore; @@ -28,25 +29,11 @@ public sealed partial class ListSettingsModalViewModel : ViewModelBase [ObservableProperty] private string _workingDir = ""; [ObservableProperty] private string _defaultCommitType = CommitTypeRegistry.DefaultType; - [ObservableProperty] private string? _selectedModel; // null = inherit from global - [ObservableProperty] private decimal? _maxTurns; // null = inherit from global - [ObservableProperty] private string _modelInheritedHint = ""; // resolved value placeholder, e.g. "sonnet" - [ObservableProperty] private string _modelBadge = ""; - [ObservableProperty] private string _turnsInheritedHint = ""; - [ObservableProperty] private string _turnsBadge = ""; - [ObservableProperty] private string _agentBadge = ""; - - [ObservableProperty] private string _systemPrompt = ""; - [ObservableProperty] private AgentInfo? _selectedAgent; - - private string _globalModel = ModelRegistry.DefaultAlias; - private int _globalMaxTurns = 100; - - public ObservableCollection ModelOptions { get; } = new(ModelRegistry.Aliases); - public ObservableCollection CommitTypeOptions { get; } = new(CommitTypeRegistry.Types); - public ObservableCollection Agents { get; } = new(); + // The shared agent-config editor (Model / MaxTurns / SystemPrompt / AgentFile), + // scoped to this list (list → global inheritance). + public AgentConfigEditorViewModel Agent { get; } public Action? CloseAction { get; set; } @@ -54,34 +41,7 @@ public sealed partial class ListSettingsModalViewModel : ViewModelBase { _worker = worker; _dbFactory = dbFactory; - } - - partial void OnSelectedModelChanged(string? value) => RecomputeModelBadge(); - partial void OnMaxTurnsChanged(decimal? value) => RecomputeTurnsBadge(); - partial void OnSelectedAgentChanged(AgentInfo? value) => RecomputeAgentBadge(); - - private void RecomputeModelBadge() - { - ModelInheritedHint = _globalModel; - ModelBadge = !string.IsNullOrWhiteSpace(SelectedModel) - ? Loc.T("settings.inherit.overrideBadge") - : Loc.T("settings.inherit.inheritedFromGlobal"); - } - - private void RecomputeTurnsBadge() - { - TurnsInheritedHint = _globalMaxTurns.ToString(); - TurnsBadge = MaxTurns is not null - ? Loc.T("settings.inherit.overrideBadge") - : Loc.T("settings.inherit.inheritedFromGlobal"); - } - - private void RecomputeAgentBadge() - { - var overridden = SelectedAgent is not null && !string.IsNullOrWhiteSpace(SelectedAgent.Path); - AgentBadge = overridden - ? Loc.T("settings.inherit.overrideBadge") - : Loc.T("settings.inherit.inheritedFromGlobal"); + Agent = new AgentConfigEditorViewModel(worker, AgentConfigScope.List); } public async Task LoadAsync( @@ -96,44 +56,19 @@ public sealed partial class ListSettingsModalViewModel : ViewModelBase WorkingDir = workingDir ?? ""; DefaultCommitType = string.IsNullOrWhiteSpace(defaultCommitType) ? CommitTypeRegistry.DefaultType : 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); - - 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(); + await Agent.LoadForListAsync(listId, ct); } [RelayCommand] private async Task SaveAsync() { - 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)); + await Agent.SaveAsync(); CloseAction?.Invoke(); } @@ -171,17 +106,4 @@ public sealed partial class ListSettingsModalViewModel : ViewModelBase [RelayCommand] private void Cancel() => CloseAction?.Invoke(); - - [RelayCommand] private void ResetModel() => SelectedModel = null; - [RelayCommand] private void ResetTurns() => MaxTurns = null; - [RelayCommand] private void ResetAgent() => SelectedAgent = Agents.Count > 0 ? Agents[0] : null; - - [RelayCommand] - private void ResetAgentSettings() - { - SelectedModel = null; - MaxTurns = null; - SystemPrompt = ""; - SelectedAgent = Agents.Count > 0 ? Agents[0] : null; - } } diff --git a/src/ClaudeDo.Ui/Views/Controls/AgentConfigEditor.axaml b/src/ClaudeDo.Ui/Views/Controls/AgentConfigEditor.axaml new file mode 100644 index 0000000..30dda3b --- /dev/null +++ b/src/ClaudeDo.Ui/Views/Controls/AgentConfigEditor.axaml @@ -0,0 +1,85 @@ + + + + + + + + +