refactor(agent-config): single AgentConfigEditor for list + task scopes
This commit is contained in:
131
docs/superpowers/plans/2026-06-19-unify-agent-config.md
Normal file
131
docs/superpowers/plans/2026-06-19-unify-agent-config.md
Normal file
@@ -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<bool> 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
|
||||||
|
`<ctl:AgentConfigEditor DataContext="{Binding Agent}" ShowAgentBrowse="True"/>`; 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
|
||||||
|
`<ctl:AgentConfigEditor DataContext="{Binding AgentSettings}"/>` (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.
|
||||||
@@ -169,11 +169,6 @@
|
|||||||
"starTip": "Favorit",
|
"starTip": "Favorit",
|
||||||
"agentSettingsTip": "Agent-Einstellungen",
|
"agentSettingsTip": "Agent-Einstellungen",
|
||||||
"agentSettingsHeading": "Agent-Einstellungen (Überschreibungen)",
|
"agentSettingsHeading": "Agent-Einstellungen (Überschreibungen)",
|
||||||
"modelLabel": "Modell",
|
|
||||||
"maxTurnsLabel": "Max. Durchläufe",
|
|
||||||
"systemPromptLabel": "System-Prompt (angehängt)",
|
|
||||||
"systemPromptPrepended": "Wird automatisch vorangestellt:",
|
|
||||||
"agentFileLabel": "Agent-Datei",
|
|
||||||
"mergeLabel": "MERGE",
|
"mergeLabel": "MERGE",
|
||||||
"mergeTargetLabel": "Merge-Ziel",
|
"mergeTargetLabel": "Merge-Ziel",
|
||||||
"reviewCombinedDiff": "Kombiniertes Diff prüfen",
|
"reviewCombinedDiff": "Kombiniertes Diff prüfen",
|
||||||
@@ -264,11 +259,7 @@
|
|||||||
"browse": "Durchsuchen...",
|
"browse": "Durchsuchen...",
|
||||||
"defaultCommitType": "Standard-Commit-Typ",
|
"defaultCommitType": "Standard-Commit-Typ",
|
||||||
"sectionAgent": "AGENT",
|
"sectionAgent": "AGENT",
|
||||||
"resetAgentSettings": "Agent-Einstellungen zurücksetzen",
|
"resetAgentSettings": "Agent-Einstellungen zurücksetzen"
|
||||||
"model": "Modell",
|
|
||||||
"maxTurns": "Max. Durchläufe",
|
|
||||||
"systemPrompt": "System-Prompt (angehängt)",
|
|
||||||
"agentFile": "Agent-Datei"
|
|
||||||
},
|
},
|
||||||
"merge": {
|
"merge": {
|
||||||
"title": "WORKTREE MERGEN",
|
"title": "WORKTREE MERGEN",
|
||||||
|
|||||||
@@ -169,11 +169,6 @@
|
|||||||
"starTip": "Star",
|
"starTip": "Star",
|
||||||
"agentSettingsTip": "Agent settings",
|
"agentSettingsTip": "Agent settings",
|
||||||
"agentSettingsHeading": "Agent settings (overrides)",
|
"agentSettingsHeading": "Agent settings (overrides)",
|
||||||
"modelLabel": "Model",
|
|
||||||
"maxTurnsLabel": "Max turns",
|
|
||||||
"systemPromptLabel": "System prompt (appended)",
|
|
||||||
"systemPromptPrepended": "Prepended automatically:",
|
|
||||||
"agentFileLabel": "Agent file",
|
|
||||||
"mergeLabel": "MERGE",
|
"mergeLabel": "MERGE",
|
||||||
"mergeTargetLabel": "Merge target",
|
"mergeTargetLabel": "Merge target",
|
||||||
"reviewCombinedDiff": "Review combined diff",
|
"reviewCombinedDiff": "Review combined diff",
|
||||||
@@ -264,11 +259,7 @@
|
|||||||
"browse": "Browse...",
|
"browse": "Browse...",
|
||||||
"defaultCommitType": "Default commit type",
|
"defaultCommitType": "Default commit type",
|
||||||
"sectionAgent": "AGENT",
|
"sectionAgent": "AGENT",
|
||||||
"resetAgentSettings": "Reset agent settings",
|
"resetAgentSettings": "Reset agent settings"
|
||||||
"model": "Model",
|
|
||||||
"maxTurns": "Max turns",
|
|
||||||
"systemPrompt": "System prompt (appended)",
|
|
||||||
"agentFile": "Agent file"
|
|
||||||
},
|
},
|
||||||
"merge": {
|
"merge": {
|
||||||
"title": "MERGE WORKTREE",
|
"title": "MERGE WORKTREE",
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ ViewModels/
|
|||||||
IslandsShellViewModel.cs — root coordinator
|
IslandsShellViewModel.cs — root coordinator
|
||||||
Islands/ — ListsIsland, TasksIsland, DetailsIsland, TaskRow, ListNavItem,
|
Islands/ — ListsIsland, TasksIsland, DetailsIsland, TaskRow, ListNavItem,
|
||||||
NotesEditor, MergePreviewPresenter
|
NotesEditor, MergePreviewPresenter
|
||||||
|
Agent/ — AgentConfigEditorViewModel (scope-parameterized: List | Task)
|
||||||
Modals/ — About, Diff, ListSettings, Merge, RepoImport, Settings (+ Settings/ tab VMs),
|
Modals/ — About, Diff, ListSettings, Merge, RepoImport, Settings (+ Settings/ tab VMs),
|
||||||
UnfinishedPlanning, WeeklyReport, WorkerConnection, Worktree,
|
UnfinishedPlanning, WeeklyReport, WorkerConnection, Worktree,
|
||||||
WorktreesOverview, UnifiedDiffParser
|
WorktreesOverview, UnifiedDiffParser
|
||||||
@@ -26,7 +27,7 @@ ViewModels/
|
|||||||
Conflicts/ — ConflictResolverViewModel + ConflictModels (MergeFile/MergeFileSegment/MergeConflictBlock)
|
Conflicts/ — ConflictResolverViewModel + ConflictModels (MergeFile/MergeFileSegment/MergeConflictBlock)
|
||||||
Views/ — mirrors the VM layout; Islands/Detail/ holds TaskHeaderBar,
|
Views/ — mirrors the VM layout; Islands/Detail/ holds TaskHeaderBar,
|
||||||
DescriptionStepsCard, WorkConsole; plus AgentStripView, SessionTerminalView
|
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
|
Design/ — Tokens.axaml (design tokens; merged before styles) + IslandStyles.axaml
|
||||||
(component styles + the filled icon geometry library)
|
(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`.
|
- **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`.
|
- **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.
|
- **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<AttachmentRowViewModel>`), `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<AttachmentRowViewModel>`), `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).
|
- **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`.
|
- **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`.
|
- **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`).
|
- **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`).
|
||||||
|
|
||||||
|
|||||||
259
src/ClaudeDo.Ui/ViewModels/Agent/AgentConfigEditorViewModel.cs
Normal file
259
src/ClaudeDo.Ui/ViewModels/Agent/AgentConfigEditorViewModel.cs
Normal file
@@ -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 }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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 <see cref="SaveAsync"/>; Task: debounced auto-save).
|
||||||
|
/// </summary>
|
||||||
|
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<string> ModelOptions { get; } = new(ModelRegistry.Aliases);
|
||||||
|
public ObservableCollection<AgentInfo> 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<string> TaskModelOptions { get; } = new(ModelRegistry.Aliases);
|
|
||||||
public ObservableCollection<AgentInfo> 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;
|
|
||||||
}
|
|
||||||
@@ -9,6 +9,7 @@ using ClaudeDo.Ui.Helpers;
|
|||||||
using ClaudeDo.Ui.Localization;
|
using ClaudeDo.Ui.Localization;
|
||||||
using ClaudeDo.Ui.Services;
|
using ClaudeDo.Ui.Services;
|
||||||
using ClaudeDo.Ui.Services.Interfaces;
|
using ClaudeDo.Ui.Services.Interfaces;
|
||||||
|
using ClaudeDo.Ui.ViewModels.Agent;
|
||||||
using ClaudeDo.Ui.ViewModels.Modals;
|
using ClaudeDo.Ui.ViewModels.Modals;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
@@ -56,7 +57,7 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
|
|||||||
private readonly IMergeCoordinator _merge;
|
private readonly IMergeCoordinator _merge;
|
||||||
|
|
||||||
// ── Section view models ───────────────────────────────────────────────────
|
// ── Section view models ───────────────────────────────────────────────────
|
||||||
public AgentSettingsSectionViewModel AgentSettings { get; }
|
public AgentConfigEditorViewModel AgentSettings { get; }
|
||||||
public MergeSectionViewModel Merge { get; }
|
public MergeSectionViewModel Merge { get; }
|
||||||
public PrepPanelViewModel Prep { get; }
|
public PrepPanelViewModel Prep { get; }
|
||||||
|
|
||||||
@@ -425,7 +426,7 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
|
|||||||
_notesApi = notesApi;
|
_notesApi = notesApi;
|
||||||
_merge = merge;
|
_merge = merge;
|
||||||
|
|
||||||
AgentSettings = new AgentSettingsSectionViewModel(worker);
|
AgentSettings = new AgentConfigEditorViewModel(worker, AgentConfigScope.Task);
|
||||||
Merge = new MergeSectionViewModel(worker, services);
|
Merge = new MergeSectionViewModel(worker, services);
|
||||||
Prep = new PrepPanelViewModel(worker);
|
Prep = new PrepPanelViewModel(worker);
|
||||||
|
|
||||||
@@ -436,7 +437,7 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
|
|||||||
|
|
||||||
AgentSettings.PropertyChanged += (_, e) =>
|
AgentSettings.PropertyChanged += (_, e) =>
|
||||||
{
|
{
|
||||||
if (e.PropertyName == nameof(AgentSettingsSectionViewModel.EffectiveMaxTurns))
|
if (e.PropertyName == nameof(AgentConfigEditorViewModel.EffectiveMaxTurns))
|
||||||
OnPropertyChanged(nameof(TurnsText));
|
OnPropertyChanged(nameof(TurnsText));
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -685,8 +686,7 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
|
|||||||
Merge.SyncWorktree(WorktreePath, WorktreeBaseCommit, WorktreeHeadCommit,
|
Merge.SyncWorktree(WorktreePath, WorktreeBaseCommit, WorktreeHeadCommit,
|
||||||
WorktreeStateLabel, _listWorkingDir);
|
WorktreeStateLabel, _listWorkingDir);
|
||||||
|
|
||||||
AgentSettings.TaskId = row.Id;
|
await AgentSettings.LoadForTaskAsync(entity, ct);
|
||||||
await AgentSettings.LoadAsync(entity, ct);
|
|
||||||
ct.ThrowIfCancellationRequested();
|
ct.ThrowIfCancellationRequested();
|
||||||
|
|
||||||
var runRepo = new TaskRunRepository(ctx);
|
var runRepo = new TaskRunRepository(ctx);
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ using ClaudeDo.Data.Models;
|
|||||||
using ClaudeDo.Data.Repositories;
|
using ClaudeDo.Data.Repositories;
|
||||||
using ClaudeDo.Ui.Localization;
|
using ClaudeDo.Ui.Localization;
|
||||||
using ClaudeDo.Ui.Services;
|
using ClaudeDo.Ui.Services;
|
||||||
|
using ClaudeDo.Ui.ViewModels.Agent;
|
||||||
using CommunityToolkit.Mvvm.ComponentModel;
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
using CommunityToolkit.Mvvm.Input;
|
using CommunityToolkit.Mvvm.Input;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
@@ -28,25 +29,11 @@ public sealed partial class ListSettingsModalViewModel : ViewModelBase
|
|||||||
[ObservableProperty] private string _workingDir = "";
|
[ObservableProperty] private string _workingDir = "";
|
||||||
[ObservableProperty] private string _defaultCommitType = CommitTypeRegistry.DefaultType;
|
[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<string> ModelOptions { get; } = new(ModelRegistry.Aliases);
|
|
||||||
|
|
||||||
public ObservableCollection<string> CommitTypeOptions { get; } = new(CommitTypeRegistry.Types);
|
public ObservableCollection<string> CommitTypeOptions { get; } = new(CommitTypeRegistry.Types);
|
||||||
|
|
||||||
public ObservableCollection<AgentInfo> 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; }
|
public Action? CloseAction { get; set; }
|
||||||
|
|
||||||
@@ -54,34 +41,7 @@ public sealed partial class ListSettingsModalViewModel : ViewModelBase
|
|||||||
{
|
{
|
||||||
_worker = worker;
|
_worker = worker;
|
||||||
_dbFactory = dbFactory;
|
_dbFactory = dbFactory;
|
||||||
}
|
Agent = new AgentConfigEditorViewModel(worker, AgentConfigScope.List);
|
||||||
|
|
||||||
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");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task LoadAsync(
|
public async Task LoadAsync(
|
||||||
@@ -96,44 +56,19 @@ public sealed partial class ListSettingsModalViewModel : ViewModelBase
|
|||||||
WorkingDir = workingDir ?? "";
|
WorkingDir = workingDir ?? "";
|
||||||
DefaultCommitType = string.IsNullOrWhiteSpace(defaultCommitType) ? CommitTypeRegistry.DefaultType : defaultCommitType;
|
DefaultCommitType = string.IsNullOrWhiteSpace(defaultCommitType) ? CommitTypeRegistry.DefaultType : defaultCommitType;
|
||||||
|
|
||||||
Agents.Clear();
|
await Agent.LoadForListAsync(listId, ct);
|
||||||
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();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
private async Task SaveAsync()
|
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(
|
await _worker.UpdateListAsync(new UpdateListDto(
|
||||||
ListId,
|
ListId,
|
||||||
string.IsNullOrWhiteSpace(Name) ? Loc.T("vm.listSettings.untitled") : Name,
|
string.IsNullOrWhiteSpace(Name) ? Loc.T("vm.listSettings.untitled") : Name,
|
||||||
string.IsNullOrWhiteSpace(WorkingDir) ? null : WorkingDir,
|
string.IsNullOrWhiteSpace(WorkingDir) ? null : WorkingDir,
|
||||||
DefaultCommitType));
|
DefaultCommitType));
|
||||||
|
|
||||||
await _worker.UpdateListConfigAsync(new UpdateListConfigDto(ListId, model, sp, ap, turns));
|
await Agent.SaveAsync();
|
||||||
|
|
||||||
CloseAction?.Invoke();
|
CloseAction?.Invoke();
|
||||||
}
|
}
|
||||||
@@ -171,17 +106,4 @@ public sealed partial class ListSettingsModalViewModel : ViewModelBase
|
|||||||
|
|
||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
private void Cancel() => CloseAction?.Invoke();
|
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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
85
src/ClaudeDo.Ui/Views/Controls/AgentConfigEditor.axaml
Normal file
85
src/ClaudeDo.Ui/Views/Controls/AgentConfigEditor.axaml
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
<UserControl xmlns="https://github.com/avaloniaui"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
|
xmlns:vm="using:ClaudeDo.Ui.ViewModels.Agent"
|
||||||
|
xmlns:ctl="using:ClaudeDo.Ui.Views.Controls"
|
||||||
|
xmlns:loc="using:ClaudeDo.Ui.Localization"
|
||||||
|
x:Class="ClaudeDo.Ui.Views.Controls.AgentConfigEditor"
|
||||||
|
x:DataType="vm:AgentConfigEditorViewModel"
|
||||||
|
x:Name="Root">
|
||||||
|
<StackPanel Spacing="12">
|
||||||
|
|
||||||
|
<!-- Model -->
|
||||||
|
<StackPanel Spacing="4">
|
||||||
|
<Grid ColumnDefinitions="Auto,Auto,*,Auto" ColumnSpacing="6">
|
||||||
|
<TextBlock Grid.Column="0" Classes="field-label" Text="{loc:Tr settings.agentEditor.model}" 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 ResetModelCommand}"/>
|
||||||
|
</Grid>
|
||||||
|
<ComboBox ItemsSource="{Binding ModelOptions}"
|
||||||
|
SelectedItem="{Binding Model, Mode=TwoWay}"
|
||||||
|
PlaceholderText="{Binding ModelInheritedHint}"
|
||||||
|
HorizontalAlignment="Stretch"/>
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
|
<!-- Max turns -->
|
||||||
|
<StackPanel Spacing="4">
|
||||||
|
<Grid ColumnDefinitions="Auto,Auto,*,Auto" ColumnSpacing="6">
|
||||||
|
<TextBlock Grid.Column="0" Classes="field-label" Text="{loc:Tr settings.agentEditor.maxTurns}" 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 ResetTurnsCommand}"/>
|
||||||
|
</Grid>
|
||||||
|
<NumericUpDown Value="{Binding MaxTurns, Mode=TwoWay}"
|
||||||
|
PlaceholderText="{Binding TurnsInheritedHint}"
|
||||||
|
Minimum="1" Maximum="200" Increment="1" FormatString="0"
|
||||||
|
HorizontalAlignment="Stretch"/>
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
|
<!-- System prompt -->
|
||||||
|
<StackPanel Spacing="4">
|
||||||
|
<TextBlock Classes="field-label" Text="{loc:Tr settings.agentEditor.systemPrompt}"/>
|
||||||
|
<TextBox Text="{Binding SystemPrompt, Mode=TwoWay}"
|
||||||
|
AcceptsReturn="True" TextWrapping="Wrap" MinHeight="80"/>
|
||||||
|
<TextBlock Classes="meta" Opacity="0.6"
|
||||||
|
Text="{loc:Tr settings.agentEditor.promptPrepended}"
|
||||||
|
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>
|
||||||
|
|
||||||
|
<!-- Agent file -->
|
||||||
|
<StackPanel Spacing="4">
|
||||||
|
<Grid ColumnDefinitions="Auto,Auto,*,Auto" ColumnSpacing="6">
|
||||||
|
<TextBlock Grid.Column="0" Classes="field-label" Text="{loc:Tr settings.agentEditor.agentFile}" VerticalAlignment="Center"/>
|
||||||
|
<ctl:InheritedBadge Grid.Column="1" BadgeText="{Binding AgentBadge}"/>
|
||||||
|
<Button Grid.Column="3" Classes="icon-btn" Content="↺" ToolTip.Tip="{loc:Tr settings.inherit.resetToInherited}"
|
||||||
|
Command="{Binding ResetAgentCommand}"/>
|
||||||
|
</Grid>
|
||||||
|
<Grid ColumnDefinitions="*,Auto">
|
||||||
|
<ComboBox Grid.Column="0"
|
||||||
|
ItemsSource="{Binding Agents}"
|
||||||
|
SelectedItem="{Binding SelectedAgent, Mode=TwoWay}"
|
||||||
|
HorizontalAlignment="Stretch">
|
||||||
|
<ComboBox.ItemTemplate>
|
||||||
|
<DataTemplate>
|
||||||
|
<StackPanel>
|
||||||
|
<TextBlock Classes="title" Text="{Binding Name}"/>
|
||||||
|
<TextBlock Classes="meta" Text="{Binding Description}"
|
||||||
|
IsVisible="{Binding Description, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
|
||||||
|
</StackPanel>
|
||||||
|
</DataTemplate>
|
||||||
|
</ComboBox.ItemTemplate>
|
||||||
|
</ComboBox>
|
||||||
|
<Button Classes="btn" Grid.Column="1" Content="{loc:Tr settings.agentEditor.browse}"
|
||||||
|
Margin="8,0,0,0" Click="BrowseAgentClicked"
|
||||||
|
IsVisible="{Binding #Root.ShowAgentBrowse}"/>
|
||||||
|
</Grid>
|
||||||
|
<TextBlock Classes="path-mono" Text="{Binding SelectedAgent.Path}"
|
||||||
|
TextTrimming="PrefixCharacterEllipsis"
|
||||||
|
IsVisible="{Binding SelectedAgent.Path, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
|
</StackPanel>
|
||||||
|
</UserControl>
|
||||||
75
src/ClaudeDo.Ui/Views/Controls/AgentConfigEditor.axaml.cs
Normal file
75
src/ClaudeDo.Ui/Views/Controls/AgentConfigEditor.axaml.cs
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
using Avalonia;
|
||||||
|
using Avalonia.Controls;
|
||||||
|
using Avalonia.Interactivity;
|
||||||
|
using Avalonia.Platform.Storage;
|
||||||
|
using ClaudeDo.Data.Models;
|
||||||
|
using ClaudeDo.Ui.ViewModels.Agent;
|
||||||
|
|
||||||
|
namespace ClaudeDo.Ui.Views.Controls;
|
||||||
|
|
||||||
|
public partial class AgentConfigEditor : UserControl
|
||||||
|
{
|
||||||
|
// List scope shows a file picker for ad-hoc agent files; the task flyout only
|
||||||
|
// picks from discovered agents, so it leaves this off (default).
|
||||||
|
public static readonly StyledProperty<bool> ShowAgentBrowseProperty =
|
||||||
|
AvaloniaProperty.Register<AgentConfigEditor, bool>(nameof(ShowAgentBrowse));
|
||||||
|
|
||||||
|
public bool ShowAgentBrowse
|
||||||
|
{
|
||||||
|
get => GetValue(ShowAgentBrowseProperty);
|
||||||
|
set => SetValue(ShowAgentBrowseProperty, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
public AgentConfigEditor() => InitializeComponent();
|
||||||
|
|
||||||
|
private async void BrowseAgentClicked(object? sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
if (DataContext is not AgentConfigEditorViewModel vm) return;
|
||||||
|
var top = TopLevel.GetTopLevel(this);
|
||||||
|
if (top is null) return;
|
||||||
|
|
||||||
|
var files = await top.StorageProvider.OpenFilePickerAsync(new FilePickerOpenOptions
|
||||||
|
{
|
||||||
|
Title = "Choose agent file",
|
||||||
|
AllowMultiple = false,
|
||||||
|
FileTypeFilter = new[]
|
||||||
|
{
|
||||||
|
new FilePickerFileType("Agent files (*.md)") { Patterns = new[] { "*.md" } },
|
||||||
|
new FilePickerFileType("All files") { Patterns = new[] { "*" } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (files.Count == 0) return;
|
||||||
|
|
||||||
|
var path = files[0].Path.LocalPath;
|
||||||
|
var existing = vm.Agents.FirstOrDefault(a => string.Equals(a.Path, path, StringComparison.OrdinalIgnoreCase));
|
||||||
|
if (existing is not null)
|
||||||
|
{
|
||||||
|
vm.SelectedAgent = existing;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var (name, description) = ReadFrontmatter(path);
|
||||||
|
var agent = new AgentInfo(name, description, path);
|
||||||
|
vm.Agents.Add(agent);
|
||||||
|
vm.SelectedAgent = agent;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static (string name, string description) ReadFrontmatter(string filePath)
|
||||||
|
{
|
||||||
|
var fallback = System.IO.Path.GetFileNameWithoutExtension(filePath);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var reader = new System.IO.StreamReader(filePath);
|
||||||
|
if (reader.ReadLine()?.Trim() != "---") return (fallback, "");
|
||||||
|
string name = fallback, description = "";
|
||||||
|
while (reader.ReadLine() is { } line)
|
||||||
|
{
|
||||||
|
if (line.Trim() == "---") break;
|
||||||
|
if (line.StartsWith("name:")) name = line["name:".Length..].Trim();
|
||||||
|
else if (line.StartsWith("description:")) description = line["description:".Length..].Trim();
|
||||||
|
}
|
||||||
|
return (name, description);
|
||||||
|
}
|
||||||
|
catch { return (fallback, ""); }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -52,7 +52,7 @@
|
|||||||
<!-- Column 2: gear button with agent settings flyout -->
|
<!-- Column 2: gear button with agent settings flyout -->
|
||||||
<Button Grid.Column="2" Classes="icon-btn"
|
<Button Grid.Column="2" Classes="icon-btn"
|
||||||
ToolTip.Tip="{loc:Tr details.agentSettingsTip}"
|
ToolTip.Tip="{loc:Tr details.agentSettingsTip}"
|
||||||
IsEnabled="{Binding AgentSettings.IsAgentSectionEnabled}"
|
IsEnabled="{Binding AgentSettings.IsEnabled}"
|
||||||
VerticalAlignment="Top"
|
VerticalAlignment="Top"
|
||||||
Margin="6,0,0,0">
|
Margin="6,0,0,0">
|
||||||
<TextBlock Text="⚙" FontSize="{StaticResource FontSizeTaskTitle}"/>
|
<TextBlock Text="⚙" FontSize="{StaticResource FontSizeTaskTitle}"/>
|
||||||
@@ -60,62 +60,7 @@
|
|||||||
<Flyout Placement="BottomEdgeAlignedRight" ShowMode="Standard">
|
<Flyout Placement="BottomEdgeAlignedRight" ShowMode="Standard">
|
||||||
<StackPanel Width="340" Spacing="10" Margin="4">
|
<StackPanel Width="340" Spacing="10" Margin="4">
|
||||||
<TextBlock Text="{loc:Tr details.agentSettingsHeading}" FontWeight="SemiBold"/>
|
<TextBlock Text="{loc:Tr details.agentSettingsHeading}" FontWeight="SemiBold"/>
|
||||||
|
<ctl:AgentConfigEditor DataContext="{Binding AgentSettings}"/>
|
||||||
<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 AgentSettings.ModelBadge}"/>
|
|
||||||
<Button Grid.Column="3" Classes="icon-btn" Content="↺" ToolTip.Tip="{loc:Tr settings.inherit.resetToInherited}"
|
|
||||||
Command="{Binding AgentSettings.ResetTaskModelCommand}"/>
|
|
||||||
</Grid>
|
|
||||||
<ComboBox ItemsSource="{Binding AgentSettings.TaskModelOptions}"
|
|
||||||
SelectedItem="{Binding AgentSettings.TaskModelSelection, Mode=TwoWay}"
|
|
||||||
PlaceholderText="{Binding AgentSettings.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 AgentSettings.TurnsBadge}"/>
|
|
||||||
<Button Grid.Column="3" Classes="icon-btn" Content="↺" ToolTip.Tip="{loc:Tr settings.inherit.resetToInherited}"
|
|
||||||
Command="{Binding AgentSettings.ResetTaskTurnsCommand}"/>
|
|
||||||
</Grid>
|
|
||||||
<NumericUpDown Value="{Binding AgentSettings.TaskMaxTurns, Mode=TwoWay}"
|
|
||||||
PlaceholderText="{Binding AgentSettings.TurnsInheritedHint}"
|
|
||||||
Minimum="1" Maximum="200" Increment="1" FormatString="0"
|
|
||||||
HorizontalAlignment="Stretch"/>
|
|
||||||
</StackPanel>
|
|
||||||
|
|
||||||
<StackPanel Spacing="2">
|
|
||||||
<TextBlock Classes="field-label" Text="{loc:Tr details.systemPromptLabel}"/>
|
|
||||||
<TextBox Text="{Binding AgentSettings.TaskSystemPrompt, Mode=TwoWay}"
|
|
||||||
AcceptsReturn="True" TextWrapping="Wrap" MinHeight="70"/>
|
|
||||||
<TextBlock Classes="meta" Opacity="0.6"
|
|
||||||
Text="{loc:Tr details.systemPromptPrepended}"
|
|
||||||
IsVisible="{Binding AgentSettings.EffectiveSystemPromptHint, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
|
|
||||||
<TextBlock Classes="meta" Opacity="0.6" TextWrapping="Wrap"
|
|
||||||
Text="{Binding AgentSettings.EffectiveSystemPromptHint}"
|
|
||||||
IsVisible="{Binding AgentSettings.EffectiveSystemPromptHint, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
|
|
||||||
</StackPanel>
|
|
||||||
|
|
||||||
<StackPanel Spacing="2">
|
|
||||||
<Grid ColumnDefinitions="Auto,Auto,*,Auto" ColumnSpacing="6">
|
|
||||||
<TextBlock Grid.Column="0" Classes="field-label" Text="{loc:Tr details.agentFileLabel}" VerticalAlignment="Center"/>
|
|
||||||
<ctl:InheritedBadge Grid.Column="1" BadgeText="{Binding AgentSettings.AgentBadge}"/>
|
|
||||||
<Button Grid.Column="3" Classes="icon-btn" Content="↺" ToolTip.Tip="{loc:Tr settings.inherit.resetToInherited}"
|
|
||||||
Command="{Binding AgentSettings.ResetTaskAgentCommand}"/>
|
|
||||||
</Grid>
|
|
||||||
<ComboBox ItemsSource="{Binding AgentSettings.TaskAgentOptions}"
|
|
||||||
SelectedItem="{Binding AgentSettings.TaskSelectedAgent, Mode=TwoWay}"
|
|
||||||
HorizontalAlignment="Stretch">
|
|
||||||
<ComboBox.ItemTemplate>
|
|
||||||
<DataTemplate>
|
|
||||||
<TextBlock Text="{Binding Name}"/>
|
|
||||||
</DataTemplate>
|
|
||||||
</ComboBox.ItemTemplate>
|
|
||||||
</ComboBox>
|
|
||||||
</StackPanel>
|
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
</Flyout>
|
</Flyout>
|
||||||
</Button.Flyout>
|
</Button.Flyout>
|
||||||
|
|||||||
@@ -69,72 +69,10 @@
|
|||||||
<Grid ColumnDefinitions="*,Auto" Margin="4,0,0,6">
|
<Grid ColumnDefinitions="*,Auto" Margin="4,0,0,6">
|
||||||
<TextBlock Classes="section-label" Text="{loc:Tr modals.listSettings.sectionAgent}" Margin="0"/>
|
<TextBlock Classes="section-label" Text="{loc:Tr modals.listSettings.sectionAgent}" Margin="0"/>
|
||||||
<Button Classes="btn" Grid.Column="1" Content="{loc:Tr modals.listSettings.resetAgentSettings}"
|
<Button Classes="btn" Grid.Column="1" Content="{loc:Tr modals.listSettings.resetAgentSettings}"
|
||||||
Command="{Binding ResetAgentSettingsCommand}" />
|
Command="{Binding Agent.ResetAllCommand}" />
|
||||||
</Grid>
|
</Grid>
|
||||||
<Border Classes="section">
|
<Border Classes="section">
|
||||||
<StackPanel Spacing="12">
|
<ctl:AgentConfigEditor DataContext="{Binding Agent}" ShowAgentBrowse="True"/>
|
||||||
<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>
|
|
||||||
|
|
||||||
<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}"
|
|
||||||
PlaceholderText="{Binding TurnsInheritedHint}"
|
|
||||||
Minimum="1" Maximum="200" Increment="1" FormatString="0"
|
|
||||||
HorizontalAlignment="Left" Width="160"/>
|
|
||||||
</StackPanel>
|
|
||||||
|
|
||||||
<StackPanel Spacing="4">
|
|
||||||
<TextBlock Classes="field-label" Text="{loc:Tr modals.listSettings.systemPrompt}"/>
|
|
||||||
<TextBox Text="{Binding SystemPrompt, Mode=TwoWay}"
|
|
||||||
AcceptsReturn="True" TextWrapping="Wrap"
|
|
||||||
MinHeight="80" />
|
|
||||||
</StackPanel>
|
|
||||||
|
|
||||||
<StackPanel Spacing="4">
|
|
||||||
<Grid ColumnDefinitions="Auto,Auto,*,Auto" ColumnSpacing="6">
|
|
||||||
<TextBlock Grid.Column="0" Classes="field-label" Text="{loc:Tr modals.listSettings.agentFile}" VerticalAlignment="Center"/>
|
|
||||||
<ctl:InheritedBadge Grid.Column="1" BadgeText="{Binding AgentBadge}"/>
|
|
||||||
<Button Grid.Column="3" Classes="btn" Content="↺" ToolTip.Tip="{loc:Tr settings.inherit.resetToInherited}"
|
|
||||||
Command="{Binding ResetAgentCommand}" Padding="6,1"/>
|
|
||||||
</Grid>
|
|
||||||
<Grid ColumnDefinitions="*,Auto">
|
|
||||||
<ComboBox Grid.Column="0"
|
|
||||||
ItemsSource="{Binding Agents}"
|
|
||||||
SelectedItem="{Binding SelectedAgent, Mode=TwoWay}"
|
|
||||||
HorizontalAlignment="Stretch">
|
|
||||||
<ComboBox.ItemTemplate>
|
|
||||||
<DataTemplate>
|
|
||||||
<StackPanel>
|
|
||||||
<TextBlock Classes="title" Text="{Binding Name}"/>
|
|
||||||
<TextBlock Classes="meta" Text="{Binding Description}"/>
|
|
||||||
</StackPanel>
|
|
||||||
</DataTemplate>
|
|
||||||
</ComboBox.ItemTemplate>
|
|
||||||
</ComboBox>
|
|
||||||
<Button Classes="btn" Grid.Column="1" Content="{loc:Tr modals.listSettings.browse}"
|
|
||||||
Margin="8,0,0,0" Click="BrowseAgentClicked" />
|
|
||||||
</Grid>
|
|
||||||
<TextBlock Classes="path-mono" Text="{Binding SelectedAgent.Path}"
|
|
||||||
TextTrimming="PrefixCharacterEllipsis"
|
|
||||||
IsVisible="{Binding SelectedAgent.Path, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
|
|
||||||
</StackPanel>
|
|
||||||
</StackPanel>
|
|
||||||
</Border>
|
</Border>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
using Avalonia.Controls;
|
using Avalonia.Controls;
|
||||||
using Avalonia.Interactivity;
|
using Avalonia.Interactivity;
|
||||||
using Avalonia.Platform.Storage;
|
using Avalonia.Platform.Storage;
|
||||||
using ClaudeDo.Data.Models;
|
|
||||||
using ClaudeDo.Ui.ViewModels.Modals;
|
using ClaudeDo.Ui.ViewModels.Modals;
|
||||||
|
|
||||||
namespace ClaudeDo.Ui.Views.Modals;
|
namespace ClaudeDo.Ui.Views.Modals;
|
||||||
@@ -13,57 +12,6 @@ public partial class ListSettingsModalView : Window
|
|||||||
InitializeComponent();
|
InitializeComponent();
|
||||||
}
|
}
|
||||||
|
|
||||||
private async void BrowseAgentClicked(object? sender, RoutedEventArgs e)
|
|
||||||
{
|
|
||||||
if (DataContext is not ListSettingsModalViewModel vm) return;
|
|
||||||
var top = TopLevel.GetTopLevel(this);
|
|
||||||
if (top is null) return;
|
|
||||||
|
|
||||||
var files = await top.StorageProvider.OpenFilePickerAsync(new FilePickerOpenOptions
|
|
||||||
{
|
|
||||||
Title = "Choose agent file",
|
|
||||||
AllowMultiple = false,
|
|
||||||
FileTypeFilter = new[]
|
|
||||||
{
|
|
||||||
new FilePickerFileType("Agent files (*.md)") { Patterns = new[] { "*.md" } },
|
|
||||||
new FilePickerFileType("All files") { Patterns = new[] { "*" } },
|
|
||||||
},
|
|
||||||
});
|
|
||||||
if (files.Count == 0) return;
|
|
||||||
|
|
||||||
var path = files[0].Path.LocalPath;
|
|
||||||
var existing = vm.Agents.FirstOrDefault(a => string.Equals(a.Path, path, StringComparison.OrdinalIgnoreCase));
|
|
||||||
if (existing is not null)
|
|
||||||
{
|
|
||||||
vm.SelectedAgent = existing;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
var (name, description) = ReadFrontmatter(path);
|
|
||||||
var agent = new AgentInfo(name, description, path);
|
|
||||||
vm.Agents.Add(agent);
|
|
||||||
vm.SelectedAgent = agent;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static (string name, string description) ReadFrontmatter(string filePath)
|
|
||||||
{
|
|
||||||
var fallback = System.IO.Path.GetFileNameWithoutExtension(filePath);
|
|
||||||
try
|
|
||||||
{
|
|
||||||
using var reader = new System.IO.StreamReader(filePath);
|
|
||||||
if (reader.ReadLine()?.Trim() != "---") return (fallback, "");
|
|
||||||
string name = fallback, description = "";
|
|
||||||
while (reader.ReadLine() is { } line)
|
|
||||||
{
|
|
||||||
if (line.Trim() == "---") break;
|
|
||||||
if (line.StartsWith("name:")) name = line["name:".Length..].Trim();
|
|
||||||
else if (line.StartsWith("description:")) description = line["description:".Length..].Trim();
|
|
||||||
}
|
|
||||||
return (name, description);
|
|
||||||
}
|
|
||||||
catch { return (fallback, ""); }
|
|
||||||
}
|
|
||||||
|
|
||||||
private async void BrowseClicked(object? sender, RoutedEventArgs e)
|
private async void BrowseClicked(object? sender, RoutedEventArgs e)
|
||||||
{
|
{
|
||||||
if (DataContext is not ListSettingsModalViewModel vm) return;
|
if (DataContext is not ListSettingsModalViewModel vm) return;
|
||||||
|
|||||||
@@ -0,0 +1,189 @@
|
|||||||
|
using ClaudeDo.Data.Models;
|
||||||
|
using ClaudeDo.Ui.Localization;
|
||||||
|
using ClaudeDo.Ui.Services;
|
||||||
|
using ClaudeDo.Ui.ViewModels.Agent;
|
||||||
|
using Xunit;
|
||||||
|
using TaskEntity = ClaudeDo.Data.Models.TaskEntity;
|
||||||
|
|
||||||
|
namespace ClaudeDo.Ui.Tests.ViewModels;
|
||||||
|
|
||||||
|
public class AgentConfigEditorViewModelTests
|
||||||
|
{
|
||||||
|
private sealed class FakeWorker : StubWorkerClient
|
||||||
|
{
|
||||||
|
public AppSettingsDto? App;
|
||||||
|
public ListConfigDto? ListCfg;
|
||||||
|
public List<AgentInfo> AgentList = new();
|
||||||
|
public UpdateListConfigDto? SavedListConfig;
|
||||||
|
public UpdateTaskAgentSettingsDto? SavedTaskSettings;
|
||||||
|
|
||||||
|
public override Task<AppSettingsDto?> GetAppSettingsAsync() => Task.FromResult(App);
|
||||||
|
public override Task<ListConfigDto?> GetListConfigAsync(string listId) => Task.FromResult(ListCfg);
|
||||||
|
public override Task<List<AgentInfo>> GetAgentsAsync() => Task.FromResult(AgentList);
|
||||||
|
public override Task UpdateListConfigAsync(UpdateListConfigDto dto) { SavedListConfig = dto; return Task.CompletedTask; }
|
||||||
|
public override Task UpdateTaskAgentSettingsAsync(UpdateTaskAgentSettingsDto dto) { SavedTaskSettings = dto; return Task.CompletedTask; }
|
||||||
|
}
|
||||||
|
|
||||||
|
private static AppSettingsDto AppWith(string model, int turns) =>
|
||||||
|
new(DefaultClaudeInstructions: "", DefaultModel: model, DefaultMaxTurns: turns,
|
||||||
|
DefaultPermissionMode: "auto", MaxParallelExecutions: 1, WorktreeStrategy: "sibling",
|
||||||
|
CentralWorktreeRoot: null, WorktreeAutoCleanupEnabled: false, WorktreeAutoCleanupDays: 30,
|
||||||
|
ReportExcludedPaths: null, StandupWeekday: 3, DailyPrepMaxTasks: 5);
|
||||||
|
|
||||||
|
private static TaskEntity TaskWith(string? model, int? turns, string? sp, string? agentPath) =>
|
||||||
|
new() { Id = "t1", ListId = "l1", Title = "t", CreatedAt = DateTime.UtcNow,
|
||||||
|
Model = model, MaxTurns = turns, SystemPrompt = sp, AgentPath = agentPath };
|
||||||
|
|
||||||
|
private static string Override => Loc.T("settings.inherit.overrideBadge");
|
||||||
|
private static string FromList => Loc.T("settings.inherit.inheritedFromList");
|
||||||
|
private static string FromGlobal => Loc.T("settings.inherit.inheritedFromGlobal");
|
||||||
|
|
||||||
|
// ── List scope ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task List_loads_config_and_global_defaults()
|
||||||
|
{
|
||||||
|
var w = new FakeWorker { App = AppWith("haiku", 50), ListCfg = new ListConfigDto("opus", "sp", null, 80) };
|
||||||
|
var vm = new AgentConfigEditorViewModel(w, AgentConfigScope.List);
|
||||||
|
|
||||||
|
await vm.LoadForListAsync("l1");
|
||||||
|
|
||||||
|
Assert.Equal("opus", vm.Model);
|
||||||
|
Assert.Equal(80m, vm.MaxTurns);
|
||||||
|
Assert.Equal("sp", vm.SystemPrompt);
|
||||||
|
Assert.Equal(Override, vm.ModelBadge);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task List_unset_model_inherits_from_global_only()
|
||||||
|
{
|
||||||
|
var w = new FakeWorker { App = AppWith("haiku", 50), ListCfg = new ListConfigDto(null, null, null, null) };
|
||||||
|
var vm = new AgentConfigEditorViewModel(w, AgentConfigScope.List);
|
||||||
|
|
||||||
|
await vm.LoadForListAsync("l1");
|
||||||
|
|
||||||
|
Assert.Null(vm.Model);
|
||||||
|
Assert.Equal(FromGlobal, vm.ModelBadge); // never "from list" at list scope
|
||||||
|
Assert.Equal("haiku", vm.ModelInheritedHint);
|
||||||
|
Assert.Equal(FromGlobal, vm.TurnsBadge);
|
||||||
|
Assert.Equal("50", vm.TurnsInheritedHint);
|
||||||
|
Assert.Equal("", vm.EffectiveSystemPromptHint); // list scope shows no inherited prompt
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task List_reset_clears_to_inherited()
|
||||||
|
{
|
||||||
|
var w = new FakeWorker { App = AppWith("haiku", 50), ListCfg = new ListConfigDto("opus", "sp", null, 80) };
|
||||||
|
var vm = new AgentConfigEditorViewModel(w, AgentConfigScope.List);
|
||||||
|
await vm.LoadForListAsync("l1");
|
||||||
|
|
||||||
|
vm.ResetModelCommand.Execute(null);
|
||||||
|
vm.ResetTurnsCommand.Execute(null);
|
||||||
|
|
||||||
|
Assert.Null(vm.Model);
|
||||||
|
Assert.Null(vm.MaxTurns);
|
||||||
|
Assert.Equal(FromGlobal, vm.ModelBadge);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task List_save_builds_list_config_dto_and_does_not_auto_save()
|
||||||
|
{
|
||||||
|
var w = new FakeWorker { App = AppWith("haiku", 50), ListCfg = new ListConfigDto("opus", "sp", null, 80) };
|
||||||
|
var vm = new AgentConfigEditorViewModel(w, AgentConfigScope.List);
|
||||||
|
await vm.LoadForListAsync("l1");
|
||||||
|
|
||||||
|
vm.Model = "sonnet";
|
||||||
|
Assert.Null(w.SavedListConfig); // list scope never auto-saves
|
||||||
|
|
||||||
|
await vm.SaveAsync();
|
||||||
|
|
||||||
|
Assert.NotNull(w.SavedListConfig);
|
||||||
|
Assert.Equal("l1", w.SavedListConfig!.ListId);
|
||||||
|
Assert.Equal("sonnet", w.SavedListConfig.Model);
|
||||||
|
Assert.Equal("sp", w.SavedListConfig.SystemPrompt);
|
||||||
|
Assert.Null(w.SavedListConfig.AgentPath);
|
||||||
|
Assert.Equal(80, w.SavedListConfig.MaxTurns);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Task scope ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Task_override_model_shows_override_badge()
|
||||||
|
{
|
||||||
|
var w = new FakeWorker { App = AppWith("haiku", 50), ListCfg = new ListConfigDto("opus", "list-sp", "/x/list-agent.md", 80) };
|
||||||
|
var vm = new AgentConfigEditorViewModel(w, AgentConfigScope.Task);
|
||||||
|
|
||||||
|
await vm.LoadForTaskAsync(TaskWith("sonnet", null, "", null));
|
||||||
|
|
||||||
|
Assert.Equal("sonnet", vm.Model);
|
||||||
|
Assert.Equal(Override, vm.ModelBadge);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Task_unset_falls_through_to_list_then_global()
|
||||||
|
{
|
||||||
|
var w = new FakeWorker { App = AppWith("haiku", 50), ListCfg = new ListConfigDto("opus", "list-sp", null, 80) };
|
||||||
|
var vm = new AgentConfigEditorViewModel(w, AgentConfigScope.Task);
|
||||||
|
|
||||||
|
await vm.LoadForTaskAsync(TaskWith(null, null, "", null));
|
||||||
|
|
||||||
|
// model + turns inherit from the LIST tier
|
||||||
|
Assert.Equal(FromList, vm.ModelBadge);
|
||||||
|
Assert.Equal("opus", vm.ModelInheritedHint);
|
||||||
|
Assert.Equal(FromList, vm.TurnsBadge);
|
||||||
|
Assert.Equal("80", vm.TurnsInheritedHint);
|
||||||
|
Assert.Equal(80, vm.EffectiveMaxTurns);
|
||||||
|
Assert.Equal("list-sp", vm.EffectiveSystemPromptHint);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Task_unset_with_no_list_tier_inherits_global()
|
||||||
|
{
|
||||||
|
var w = new FakeWorker { App = AppWith("haiku", 50), ListCfg = new ListConfigDto(null, null, null, null) };
|
||||||
|
var vm = new AgentConfigEditorViewModel(w, AgentConfigScope.Task);
|
||||||
|
|
||||||
|
await vm.LoadForTaskAsync(TaskWith(null, null, "", null));
|
||||||
|
|
||||||
|
Assert.Equal(FromGlobal, vm.ModelBadge);
|
||||||
|
Assert.Equal("haiku", vm.ModelInheritedHint);
|
||||||
|
Assert.Equal(50, vm.EffectiveMaxTurns);
|
||||||
|
Assert.Equal("", vm.EffectiveSystemPromptHint);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Task_save_builds_task_agent_settings_dto()
|
||||||
|
{
|
||||||
|
var w = new FakeWorker { App = AppWith("haiku", 50), ListCfg = new ListConfigDto(null, null, null, null) };
|
||||||
|
var vm = new AgentConfigEditorViewModel(w, AgentConfigScope.Task);
|
||||||
|
await vm.LoadForTaskAsync(TaskWith("opus", 120, "task-sp", null));
|
||||||
|
|
||||||
|
await vm.SaveAsync();
|
||||||
|
|
||||||
|
Assert.NotNull(w.SavedTaskSettings);
|
||||||
|
Assert.Equal("t1", w.SavedTaskSettings!.TaskId);
|
||||||
|
Assert.Equal("opus", w.SavedTaskSettings.Model);
|
||||||
|
Assert.Equal("task-sp", w.SavedTaskSettings.SystemPrompt);
|
||||||
|
Assert.Null(w.SavedTaskSettings.AgentPath);
|
||||||
|
Assert.Equal(120, w.SavedTaskSettings.MaxTurns);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Clear_resets_state_and_target()
|
||||||
|
{
|
||||||
|
var w = new FakeWorker { App = AppWith("haiku", 50), ListCfg = new ListConfigDto(null, null, null, null) };
|
||||||
|
var vm = new AgentConfigEditorViewModel(w, AgentConfigScope.Task);
|
||||||
|
await vm.LoadForTaskAsync(TaskWith("opus", 120, "task-sp", null));
|
||||||
|
|
||||||
|
vm.Clear();
|
||||||
|
|
||||||
|
Assert.Null(vm.Model);
|
||||||
|
Assert.Null(vm.MaxTurns);
|
||||||
|
Assert.Equal("", vm.SystemPrompt);
|
||||||
|
Assert.Equal("", vm.EffectiveSystemPromptHint);
|
||||||
|
|
||||||
|
// After Clear, SaveAsync is a no-op (no target).
|
||||||
|
w.SavedTaskSettings = null;
|
||||||
|
await vm.SaveAsync();
|
||||||
|
Assert.Null(w.SavedTaskSettings);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user