From 753adb8ba33750902c008659f93231d49a2d55dc Mon Sep 17 00:00:00 2001 From: m-aebrer Date: Wed, 5 Aug 2026 09:58:22 -0400 Subject: [PATCH 1/9] chore: open PR for issue 404 From b56bddca1270f4ae79ce3b99a267c9e822f459ec Mon Sep 17 00:00:00 2001 From: m-aebrer Date: Wed, 5 Aug 2026 11:23:10 -0400 Subject: [PATCH 2/9] Dashboard: add scoped-model settings editor --- README.md | 4 +- packages/coding-agent/README.md | 4 +- packages/coding-agent/docs/dashboard.md | 4 +- packages/coding-agent/docs/rpc.md | 26 +- packages/coding-agent/docs/settings.md | 8 +- .../coding-agent/src/core/model-resolver.ts | 54 ++- .../coding-agent/src/core/settings-manager.ts | 5 + .../coding-agent/src/modes/rpc/rpc-mode.ts | 162 ++++++++- .../coding-agent/src/modes/rpc/rpc-types.ts | 19 + .../coding-agent/test/model-resolver.test.ts | 48 ++- .../test/rpc-settings-commands.test.ts | 174 ++++++++- .../test/settings-manager.test.ts | 26 ++ packages/dashboard/README.md | 12 +- packages/dashboard/src/client/api.ts | 13 +- packages/dashboard/src/client/app.tsx | 6 +- .../components/scoped-models-editor.tsx | 339 ++++++++++++++++++ .../dashboard/src/client/screens/session.tsx | 6 + .../dashboard/src/client/screens/settings.tsx | 22 +- packages/dashboard/src/client/state/store.ts | 17 +- packages/dashboard/src/client/styles/app.css | 134 ++++++- packages/dashboard/src/server/server.ts | 40 ++- packages/dashboard/src/shared/protocol.ts | 22 +- .../test/client/scoped-models-editor.test.tsx | 263 ++++++++++++++ .../dashboard/test/client/screens.test.tsx | 17 +- .../client/settings-layout.browser.test.ts | 36 +- packages/dashboard/test/client/store.test.ts | 16 +- packages/dashboard/test/runtime-pool.test.ts | 8 + packages/dashboard/test/server.test.ts | 36 ++ 28 files changed, 1455 insertions(+), 66 deletions(-) create mode 100644 packages/dashboard/src/client/components/scoped-models-editor.tsx create mode 100644 packages/dashboard/test/client/scoped-models-editor.test.tsx diff --git a/README.md b/README.md index 0c7a9e1b..b0e29f45 100644 --- a/README.md +++ b/README.md @@ -129,7 +129,9 @@ The dashboard is the visual face of dreb: every agent session on the host, live **Fleet overview.** Home base is every session across every project: live sessions with status chips (running / needs-attention / idle / error), activity lines, running subagents, task progress, context usage, and model — plus past sessions grouped by project, resumable with one tap. Terminal provider/API failures show their reason on the fleet card; transient failures clear that terminal card state when automatic retry begins. Live cards update through compact SSE snapshots instead of re-fetching the full cross-project inventory on every turn, keeping weak mobile links responsive. When a session needs input, the browser tab badges and (opt-in) sends a service-worker notification (installable PWA, works on Android and iOS). For opt-in local mobile transport profiling, see the [dashboard docs](packages/coding-agent/docs/dashboard.md#mobile-transport-profiling). -**Full-parity session view.** Not a reduced chat client: streaming markdown, thinking blocks, bespoke tool cards (read/write/edit/bash and markdown-rendering tools), sanitized inline PNG/JPEG/GIF/WebP images returned by any tool, task panels, queued-message chips, image attach/paste with sent-image transcript previews, built-in slash-command autocomplete and execution (including model/settings, session tree, fork, compact, import/export, dream, resume/reload, and new/quit), model/thinking switchers, fork-from-message, HTML export. Built-ins are intercepted generically and rejected fail-closed at the RPC prompt boundary, so unsupported or future commands show guidance instead of leaking into model input. Provider/API failures render inline on the failed assistant attempt with any partial output preserved, including after refresh or recovery; transient failures then switch the session status to retrying without erasing that history. Tool-result images remain visible to the human even when the active model is text-only. While the agent works you can **steer** (inject into the running turn), **queue follow-ups**, or **stop** — the same queue semantics as the TUI. +**Full-parity session view.** Not a reduced chat client: streaming markdown, thinking blocks, bespoke tool cards (read/write/edit/bash and markdown-rendering tools), sanitized inline PNG/JPEG/GIF/WebP images returned by any tool, task panels, queued-message chips, image attach/paste with sent-image transcript previews, built-in slash-command autocomplete and execution (including model/settings, scoped-models, session tree, fork, compact, import/export, dream, resume/reload, and new/quit), model/thinking switchers, fork-from-message, HTML export. `/scoped-models` opens the Settings editor for the session's current project context. Built-ins are intercepted generically and rejected fail-closed at the RPC prompt boundary, so unsupported or future commands show guidance instead of leaking into model input. Provider/API failures render inline on the failed assistant attempt with any partial output preserved, including after refresh or recovery; transient failures then switch the session status to retrying without erasing that history. Tool-result images remain visible to the human even when the active model is text-only. While the agent works you can **steer** (inject into the running turn), **queue follow-ups**, or **stop** — the same queue semantics as the TUI. + +**Scoped models.** Dashboard Settings includes an editor for the persistent model-cycling scope. It searches provider-grouped available models; offers model, provider, and all-model toggles; shows accessible up/down controls for the ordered partial scope; and has save/reset actions that work on mobile. An absent `enabledModels` value means implicit all models in registry order, including future registry additions, and that all-model view cannot be reordered. A saved partial scope is a non-empty ordered list of exact canonical `provider/model` references; editing a legacy glob, fuzzy, or thinking-suffix scope normalizes it to that form. The selected project context reads effective global plus project settings, but dashboard writes remain global and warn when a project value shadows the result. Changes seed new sessions only and never mutate running sessions. See [Settings](packages/coding-agent/docs/settings.md#model-cycling) and the [RPC settings contract](packages/coding-agent/docs/rpc.md#settings). **Low-data transcript images.** Tool results and images uploaded with user turns remain visible through content-addressed browser references rather than full base64 in events and transcript JSON. The default browser-local mode lazily requests a preview bounded to 1024 × 1024 and 256 KiB; clicking enlarges that same preview without downloading the original. Settings also offers request-free placeholders and informed-opt-in automatic originals. Explicit originals disclose their size and confirm above 1 MiB. Authenticated same-origin routes accept only signature-matching PNG/JPEG/GIF/WebP, send `nosniff`, and recover evicted entries from authoritative session data; static GIF previews preserve animated originals behind the original route. Image bytes therefore cannot cause an SSE oversized-event resync. Full-resolution HTML exports remain self-contained and unchanged. diff --git a/packages/coding-agent/README.md b/packages/coding-agent/README.md index 9b884d24..2e509c4c 100644 --- a/packages/coding-agent/README.md +++ b/packages/coding-agent/README.md @@ -168,7 +168,7 @@ Type `/` in the editor to trigger commands. [Extensions](#extensions) can regist |---------|-------------| | `/login`, `/logout` | OAuth authentication | | `/model` | Switch models | -| `/scoped-models` | Enable/disable models for cycling | +| `/scoped-models` | Set the ordered model-cycling scope; in dashboard sessions, opens the scoped-models Settings editor for the current project context | | `/settings` | Thinking level, thinking summaries (adaptive Claude models), theme, message delivery, transport | | `/resume` | Pick from previous sessions | | `/new` | Start a new session | @@ -278,7 +278,7 @@ Use `/settings` to modify common options, or edit JSON files directly: | `~/.dreb/agent/settings.json` | Global (all projects) | | `.dreb/settings.json` | Project (overrides global) | -See [docs/settings.md](docs/settings.md) for all options. Dashboard Settings re-entry reloads durable global and project settings after flushing pending writes, so it sees external file edits; unreadable, invalid, or failed writes are surfaced as errors rather than showing stale values. See the [RPC settings contract](docs/rpc.md#get_settings). +See [docs/settings.md](docs/settings.md) for all options. Dashboard Settings re-entry reloads durable global and project settings after flushing pending writes, so it sees external file edits; unreadable, invalid, or failed writes are surfaced as errors rather than showing stale values. Its scoped-models editor provides provider-grouped search, model/provider/all toggles, accessible ordered partial-scope controls, and explicit save/reset on desktop and mobile. Absent `enabledModels` means future-inclusive all models in registry order; saved partial scopes are non-empty ordered canonical references, and editing a legacy pattern scope normalizes it. A selected project context shows effective merged settings, while writes remain global and warn when project settings shadow them. Changes seed new sessions only; `/scoped-models` opens the editor for the current dashboard session cwd. See the [RPC settings contract](docs/rpc.md#get_settings). --- diff --git a/packages/coding-agent/docs/dashboard.md b/packages/coding-agent/docs/dashboard.md index 1d2f9f7d..5a8118fd 100644 --- a/packages/coding-agent/docs/dashboard.md +++ b/packages/coding-agent/docs/dashboard.md @@ -120,10 +120,10 @@ networking window above. | Screen | What it does | |---|---| | **Fleet** | Home. Live-first: one grid of every live session at the top — status chip (● running / ◆ needs-attention / ○ idle / ✕ error), project path, activity line, live subagent lines, tasks progress, ctx%, model, terminal provider-error reason, last activity. Live cards keep a deterministic order by project path, then session start time; needs-attention cards badge the browser tab without jumping around. Below the grid: past sessions grouped by project, three compact rows per group with an "all N on disk" expander, resume and delete. | -| **Session view** | Full chat drill-in. Markdown streaming transcript (text, thinking blocks with expand preference, inline provider/API failures with partial output preserved, agent-result cards, tool cards with bespoke read/write/edit/bash bodies plus full expandable inputs, markdown-rendered results for markdown-contract tools like subagent/skill/web_fetch/suggest_next, and inline tool-result images, compaction/branch summaries, custom messages), per-message copy, tasks panel, a bounded scrollable subagent panel that lists every retained agent newest-first with full running/done counts, status line with elapsed time plus ■ stop and compaction/retry aborts, a persistent session-header live indicator, and an info bar with cwd, branch, session name, token breakdown, cost/(sub)/daily rollup, ctx%, median tok/s, and a stats popover. Composer supports auto-grow, history, `/` autocomplete from `get_commands`, image attach/paste with sent images retained as user-message previews, queued-message chips with restore-all, steer/follow-up modes, and suggest-next. Registered built-in slash commands are discovered generically, deduplicated ahead of colliding resource commands, and intercepted before prompting: dashboard actions cover settings, model, export/import, name/session stats, fork/tree, new/compact/dream, resume/reload, and quit; login/logout/scoped-models show an explicit not-yet-implemented notice, while copy/hotkeys/buddy give terminal-only guidance. Future built-ins are intercepted automatically. The RPC prompt boundary rejects any built-in that reaches it during command-loading races or failures, so slash text cannot leak to the model. Attachments are retained and the command is visibly rejected rather than silently discarded. The ⋯ menu covers export HTML, compact, rename, fork-from-message, loaded context, and tool expand/collapse. Session names update live from manual rename or auto-naming. Extension UI requests for select/confirm/input/editor render as modals; a rich `ask`/`ask_user` request renders inline as a single wizard that presents all its questions together — each with Markdown-formatted question text, choices, optional free text — plus an in-card Stop agent action, Escape-to-stop, and the authoritative auto-stop countdown, and is answered as one batch submit. Pending questions set needs-attention state and use the existing hidden-page notification path. Extension notifications render as toasts. | +| **Session view** | Full chat drill-in. Markdown streaming transcript (text, thinking blocks with expand preference, inline provider/API failures with partial output preserved, agent-result cards, tool cards with bespoke read/write/edit/bash bodies plus full expandable inputs, markdown-rendered results for markdown-contract tools like subagent/skill/web_fetch/suggest_next, and inline tool-result images, compaction/branch summaries, custom messages), per-message copy, tasks panel, a bounded scrollable subagent panel that lists every retained agent newest-first with full running/done counts, status line with elapsed time plus ■ stop and compaction/retry aborts, a persistent session-header live indicator, and an info bar with cwd, branch, session name, token breakdown, cost/(sub)/daily rollup, ctx%, median tok/s, and a stats popover. Composer supports auto-grow, history, `/` autocomplete from `get_commands`, image attach/paste with sent images retained as user-message previews, queued-message chips with restore-all, steer/follow-up modes, and suggest-next. Registered built-in slash commands are discovered generically, deduplicated ahead of colliding resource commands, and intercepted before prompting: dashboard actions cover settings, model, scoped-models, export/import, name/session stats, fork/tree, new/compact/dream, resume/reload, and quit. `/scoped-models` deep-links to the Settings editor with the session's current cwd as project context; login/logout show an explicit not-yet-implemented notice, while copy/hotkeys/buddy give terminal-only guidance. Future built-ins are intercepted automatically. The RPC prompt boundary rejects any built-in that reaches it during command-loading races or failures, so slash text cannot leak to the model. Attachments are retained and the command is visibly rejected rather than silently discarded. The ⋯ menu covers export HTML, compact, rename, fork-from-message, loaded context, and tool expand/collapse. Session names update live from manual rename or auto-naming. Extension UI requests for select/confirm/input/editor render as modals; a rich `ask`/`ask_user` request renders inline as a single wizard that presents all its questions together — each with Markdown-formatted question text, choices, optional free text — plus an in-card Stop agent action, Escape-to-stop, and the authoritative auto-stop countdown, and is answered as one batch submit. Pending questions set needs-attention state and use the existing hidden-page notification path. Extension notifications render as toasts. | | **Subagent view** | Read-only transcript of a background agent: live events via the RPC relay, hydrated from the agent's on-disk session log (`/subagents/:agentId/messages`) so the transcript survives browser reloads. Shows the task, streaming output, tool activity, and any safe Dispatch Arbiter changed/unchanged/failure records with the final agent/model/thinking. No raw arbiter output is displayed or transported. No composer — subagents can't be steered yet; the parent session controls them. | | **Files** | Host-wide browser with places shortcuts (home, /tmp, project roots), breadcrumbs to `/`, new-folder, download, drop-zone/picker upload with explicit collision prompts, and "new session here" on any directory. It also shows the **effective global nested-context trust** for the displayed canonical directory: untrusted, trusted by that root, inherited from a granting root, or global expert trust-all. You can trust the displayed folder and descendants, or untrust the actual granting root; untrusting an inherited folder removes that root's trust for all descendants. | -| **Settings** | Persistent defaults (default model, thinking level, steering/follow-up queue modes, auto-compaction, auto-retry) via `get_settings`/`set_settings` — validation errors are shown verbatim. The global-only Dispatch Arbiter card exposes enable/disable, exact authenticated model selection, thinking, guide path, and readiness guidance; model-less enablement is blocked and RPC/runtime validation remains fail-closed. Entering Settings flushes pending writes and reloads durable global + project settings, so external edits appear; read, parse, or write failures fail loudly instead of showing stale settings. The global-only nested-context policy lists every explicit trusted root for audit and revoke, offers a simple add-by-path control, and includes a prominently warned expert trust-all toggle; the Files view remains the primary place to grant trust while browsing. Most defaults seed new sessions; context-trust changes are observed by active main/subagent processes for future lazy loads, but cannot remove already injected content. Dashboard-local preferences (always expand thinking, transcript image display mode, needs-attention notification permission) live in the browser, alongside an appearance section: a theme gallery of eight curated themes (entropist.ca, Dim, Solarized, Gruvbox, Caves of Qud, Van Gogh, and the colorblind-safe Okabe-Ito and Paul Tol) with live preview cards and a system/light/dark mode selector, saved per browser. Shows the current rotating pairing code on the host/local dashboard, plus the paired-devices list with unpair. | +| **Settings** | Persistent defaults (default model, thinking level, steering/follow-up queue modes, auto-compaction, auto-retry) via `get_settings`/`set_settings` — validation errors are shown verbatim. The scoped-models editor controls model cycling for new sessions only: grouped search, model/provider/all toggles, responsive controls, accessible up/down partial-scope ordering, and save/reset. An absent `enabledModels` is future-inclusive all models in registry order and cannot be reordered; a partial scope is a non-empty ordered list of canonical `provider/model` references. Editing legacy glob, fuzzy, or thinking-suffix values saves normalized exact references. The selected context reads effective global + project settings but writes global; a project-level `enabledModels` shadow is warned. The global-only Dispatch Arbiter card exposes enable/disable, exact authenticated model selection, thinking, guide path, and readiness guidance; model-less enablement is blocked and RPC/runtime validation remains fail-closed. Entering Settings flushes pending writes and reloads durable global + project settings, so external edits appear; read, parse, or write failures fail loudly instead of showing stale settings. The global-only nested-context policy lists every explicit trusted root for audit and revoke, offers a simple add-by-path control, and includes a prominently warned expert trust-all toggle; the Files view remains the primary place to grant trust while browsing. Most defaults seed new sessions; context-trust changes are observed by active main/subagent processes for future lazy loads, but cannot remove already injected content. Dashboard-local preferences (always expand thinking, transcript image display mode, needs-attention notification permission) live in the browser, alongside an appearance section: a theme gallery of eight curated themes (entropist.ca, Dim, Solarized, Gruvbox, Caves of Qud, Van Gogh, and the colorblind-safe Okabe-Ito and Paul Tol) with live preview cards and a system/light/dark mode selector, saved per browser. Shows the current rotating pairing code on the host/local dashboard, plus the paired-devices list with unpair. | | **Pairing** | Remote first-login: identity echo, rotating-code entry, and the security copy explaining what pairing grants. | ### Dispatch Arbiter observability diff --git a/packages/coding-agent/docs/rpc.md b/packages/coding-agent/docs/rpc.md index a12ec71d..a848d4c2 100644 --- a/packages/coding-agent/docs/rpc.md +++ b/packages/coding-agent/docs/rpc.md @@ -1286,7 +1286,7 @@ Note: with `summarize: true` the command is LLM-bound and can take a while. `Rpc Persistent settings, backed by the settings file (see [settings.md](settings.md)). They are normally distinct from live session state, with global-only control/security-policy exceptions: -- **Persistent defaults** (`get_settings` / `set_settings`): provider/model, thinking level, queue modes, compaction/retry/image/skill/thinking-display/transport toggles, and per-agent model fallback lists seed fresh runtimes. Writing these ordinary defaults does **not** change a running session. +- **Persistent defaults** (`get_settings` / `set_settings`): provider/model, thinking level, queue modes, compaction/retry/image/skill/thinking-display/transport toggles, `enabledModels`, and per-agent model fallback lists seed fresh runtimes. Writing these ordinary defaults does **not** change a running session. - **Global nested-context trust policy** (`autoLoadNestedContext`, `trustedContextFolders`, `effectiveTrustedContextRoots`, and the trust commands below): this is read from `~/.dreb/agent/settings.json` only, never project settings. Active main/subagent processes observe it for **future lazy nested/out-of-cwd loads**; it cannot remove content already injected into a conversation. It does not govern the separate initial upward context scan from the launch cwd. - **Global Dispatch Arbiter policy** (`subagentArbiter`): the complete object is read/written globally and project settings cannot shadow it. Enabled runtimes consume it before future subagent spawns; it does not rewrite already-started children. - **Runtime state** (`get_state` / `set_model` / `set_thinking_level` / `set_steering_mode` / `set_follow_up_mode` / `set_auto_compaction` / `set_auto_retry`): the state of the live session. Note that the runtime setters also persist their values as new defaults as a side effect. @@ -1326,6 +1326,14 @@ Response: "agentModels": { "Explore": ["anthropic/sonnet", "openai/gpt-5"] }, + "enabledModels": ["anthropic/claude-sonnet-4-5", "openai/gpt-5"], + "resolvedScopedModels": [ + {"provider": "anthropic", "id": "claude-sonnet-4-5", "name": "Claude Sonnet 4.5", "reasoning": true}, + {"provider": "openai", "id": "gpt-5", "name": "GPT-5", "reasoning": true} + ], + "scopeWarnings": [], + "hasProjectEnabledModelsOverride": false, + "enabledModelsSource": "global", "subagentArbiter": { "enabled": true, "model": "anthropic/claude-sonnet-4-5", @@ -1338,6 +1346,8 @@ Response: `defaultProvider`, `defaultModel`, and `defaultThinkingLevel` are absent if never set. `agentModels` is the merged global + project view; project entries win per agent name. +`enabledModels` is the raw effective persisted value: absent means the implicit all-model scope, while a present value may be a legacy pattern list. `resolvedScopedModels` is that scope resolved in model-cycling order, and `scopeWarnings` reports legacy-resolution diagnostics. `hasProjectEnabledModelsOverride` and `enabledModelsSource` (`"default"`, `"global"`, or `"project"`) identify whether a selected project shadows global `enabledModels`. These fields let a dashboard show both the source value and its effective scope before normalizing an edited legacy scope. + `trustedContextFolders` is the raw global configured list, including invalid legacy paths that are ignored fail-closed. `effectiveTrustedContextRoots` is the canonical, existing root set actually enforced after `~` expansion, native `realpath`, deduplication, and ancestor subsumption. `autoLoadNestedContext` defaults to `false`; when `true` it is global expert trust-all for every resolvable target, not a project override. Project `.dreb/settings.json` cannot affect any of these three fields. `subagentArbiter` is absent when unconfigured. It is always the global object; project `.dreb/settings.json` cannot enable, disable, or alter it. @@ -1358,6 +1368,18 @@ Replace the global trusted-root list atomically (paths must be existing director Set `autoLoadNestedContext: true` only as an expert global trust-all choice: it permits lazy context from any resolvable directory, including untrusted prompt-injection content. `set_settings` writes this policy globally even when the RPC session has project settings; project `.dreb/settings.json` cannot add, override, or enable it. Active processes use the result for later lazy loads, not to retract prior injections. The separate initial upward scan from the launch cwd is unaffected. +Set an ordered partial model-cycling scope with a non-empty array of available exact `provider/model` references. The complete payload is validated atomically, including canonical matching and duplicate detection; legacy glob, fuzzy, and thinking-suffix patterns are rejected here. Use explicit `null`, not `[]`, to clear `enabledModels` and restore implicit all models in registry order (including future models): + +```json +{"type": "set_settings", "settings": {"enabledModels": ["anthropic/claude-sonnet-4-5", "openai/gpt-5"]}} +``` + +```json +{"type": "set_settings", "settings": {"enabledModels": null}} +``` + +A write always targets global settings. If the session's project defines `enabledModels` in `.dreb/settings.json`, the response includes a warning that the effective project scope still shadows the global write. + Setting the default model (both keys required together, validated against available models — the provider must have credentials configured, same rule as `set_model`): ```json @@ -1464,6 +1486,7 @@ Valid keys and values: | `transport` | `"sse"`, `"websocket"`, `"auto"` | | `hideThinkingBlock` | boolean | | `agentModels` | Plain object mapping agent names to arrays of non-empty model id strings; empty arrays remove the global entry for that agent | +| `enabledModels` | Non-empty ordered array of available exact `provider/model` references, or explicit `null` to remove the global filter and restore implicit all. Duplicate, glob, fuzzy, and thinking-suffix entries are rejected. | | `subagentArbiter` | Complete global-only object or `null`. Keys: `enabled` boolean, exact available `model`, optional valid/capability-supported `thinking`, non-empty `guidePath`. Enabling requires `model`. Unknown nested keys are rejected. | Errors are explicit `success: false` responses (nothing is applied on any of them): @@ -1475,6 +1498,7 @@ Errors are explicit `success: false` responses (nothing is applied on any of the - Non-boolean toggle: `Invalid retryEnabled: "yes". Must be a boolean` - Invalid `agentModels` object: `Invalid agentModels: must be a plain object mapping agent names to model fallback arrays` - Invalid `agentModels` entry (the offending agent key is named): `Invalid agentModels["Explore"]: expected an array of non-empty strings` +- Invalid `enabledModels`: empty arrays are rejected (`at least one model must remain enabled; use null to enable all`); entries must be non-empty exact available provider/model references with no duplicates - Invalid trusted-root list: `trustedContextFolders must be an array of non-empty path strings` or `Invalid trustedContextFolders[0]: path must be absolute after ~ expansion` / `path must be an existing directory` - Provider without model (or vice versa): `defaultProvider and defaultModel must be set together` - Unavailable model: `Model not found: provider/model-id` diff --git a/packages/coding-agent/docs/settings.md b/packages/coding-agent/docs/settings.md index bc9493f5..61f7ba1d 100644 --- a/packages/coding-agent/docs/settings.md +++ b/packages/coding-agent/docs/settings.md @@ -336,14 +336,18 @@ When multiple sources specify a session directory, `--session-dir` CLI flag take | Setting | Type | Default | Description | |---------|------|---------|-------------| -| `enabledModels` | string[] | - | Model patterns for cycling (same format as `--models` CLI flag) | +| `enabledModels` | string[] | - | Ordered model-cycling scope. When absent, all registry models are available in registry order, including future additions. A non-empty explicit list uses canonical `provider/model` references. | ```json { - "enabledModels": ["claude-*", "gpt-4o", "gemini-2*"] + "enabledModels": ["anthropic/claude-sonnet-4-5", "openai/gpt-5"] } ``` +An absent value is the future-inclusive implicit-all scope, not an empty list, and is not reorderable. An explicit scope must contain at least one model. Existing configurations may use glob, fuzzy, or thinking-suffix patterns like the `--models` CLI flag; the Dashboard scoped-models editor resolves those legacy values and saves an edited scope as exact canonical references in its selected order. + +Dashboard Settings provides a responsive scoped-models editor with provider-grouped search, model/provider/all toggles, accessible up/down ordering controls, and save/reset. It reads the effective global + selected-project value, but saves `enabledModels` to global settings; it warns when `.dreb/settings.json` shadows that global value. Scoped-model changes seed new sessions and do not mutate a running session. See [dashboard.md](dashboard.md) and the [`get_settings` / `set_settings` RPC contract](rpc.md#settings). + ### Markdown | Setting | Type | Default | Description | diff --git a/packages/coding-agent/src/core/model-resolver.ts b/packages/coding-agent/src/core/model-resolver.ts index 14bc0622..c86941b3 100644 --- a/packages/coding-agent/src/core/model-resolver.ts +++ b/packages/coding-agent/src/core/model-resolver.ts @@ -44,6 +44,19 @@ export interface ScopedModel { thinkingLevel?: ThinkingLevel; } +/** A resolver diagnostic tied to the raw scope pattern that produced it. */ +export interface ModelScopeWarning { + pattern: string; + /** Warning text without terminal styling or a leading `Warning:` label. */ + message: string; +} + +/** Ordered scope resolution result without terminal output side effects. */ +export interface ModelScopeResolution { + models: ScopedModel[]; + warnings: ModelScopeWarning[]; +} + // isAlias logic moved to @dreb/ai as isModelAlias /** @@ -212,9 +225,9 @@ export function parseModelPattern( * The algorithm tries to match the full pattern first, then progressively * strips colon-suffixes to find a match. */ -export async function resolveModelScope(patterns: string[], modelRegistry: ModelRegistry): Promise { - const availableModels = await modelRegistry.getAvailable(); - const scopedModels: ScopedModel[] = []; +export function resolveModelScopePatterns(patterns: string[], availableModels: Model[]): ModelScopeResolution { + const models: ScopedModel[] = []; + const warnings: ModelScopeWarning[] = []; for (const pattern of patterns) { // Check if pattern contains glob characters @@ -240,13 +253,13 @@ export async function resolveModelScope(patterns: string[], modelRegistry: Model }); if (matchingModels.length === 0) { - console.warn(chalk.yellow(`Warning: No models match pattern "${pattern}"`)); + warnings.push({ pattern, message: `No models match pattern "${pattern}"` }); continue; } for (const model of matchingModels) { - if (!scopedModels.find((sm) => modelsAreEqual(sm.model, model))) { - scopedModels.push({ model, thinkingLevel }); + if (!models.find((scoped) => modelsAreEqual(scoped.model, model))) { + models.push({ model, thinkingLevel }); } } continue; @@ -255,21 +268,38 @@ export async function resolveModelScope(patterns: string[], modelRegistry: Model const { model, thinkingLevel, warning } = parseModelPattern(pattern, availableModels); if (warning) { - console.warn(chalk.yellow(`Warning: ${warning}`)); + warnings.push({ pattern, message: warning }); } if (!model) { - console.warn(chalk.yellow(`Warning: No models match pattern "${pattern}"`)); + warnings.push({ pattern, message: `No models match pattern "${pattern}"` }); continue; } - // Avoid duplicates - if (!scopedModels.find((sm) => modelsAreEqual(sm.model, model))) { - scopedModels.push({ model, thinkingLevel }); + // Avoid duplicates while preserving first-pattern/registry order. + if (!models.find((scoped) => modelsAreEqual(scoped.model, model))) { + models.push({ model, thinkingLevel }); } } - return scopedModels; + return { models, warnings }; +} + +/** Resolve model scope patterns without emitting terminal warnings. */ +export async function resolveModelScopeWithDiagnostics( + patterns: string[], + modelRegistry: Pick, +): Promise { + return resolveModelScopePatterns(patterns, await modelRegistry.getAvailable()); +} + +/** Resolve model scope patterns and retain the legacy terminal warning behavior. */ +export async function resolveModelScope(patterns: string[], modelRegistry: ModelRegistry): Promise { + const resolution = await resolveModelScopeWithDiagnostics(patterns, modelRegistry); + for (const warning of resolution.warnings) { + console.warn(chalk.yellow(`Warning: ${warning.message}`)); + } + return resolution.models; } export interface ResolveCliModelResult { diff --git a/packages/coding-agent/src/core/settings-manager.ts b/packages/coding-agent/src/core/settings-manager.ts index 348e1ca9..63745d8a 100644 --- a/packages/coding-agent/src/core/settings-manager.ts +++ b/packages/coding-agent/src/core/settings-manager.ts @@ -1179,6 +1179,11 @@ export class SettingsManager { this.save(); } + /** Whether project settings explicitly replace the global enabled-model scope. */ + hasProjectEnabledModelsOverride(): boolean { + return this.projectSettings.enabledModels !== undefined; + } + getDoubleEscapeAction(): "fork" | "tree" | "none" { return this.settings.doubleEscapeAction ?? "tree"; } diff --git a/packages/coding-agent/src/modes/rpc/rpc-mode.ts b/packages/coding-agent/src/modes/rpc/rpc-mode.ts index d71e9a56..b40720e4 100644 --- a/packages/coding-agent/src/modes/rpc/rpc-mode.ts +++ b/packages/coding-agent/src/modes/rpc/rpc-mode.ts @@ -41,7 +41,11 @@ import type { } from "../../core/extensions/index.js"; import { getGitBranch } from "../../core/git-branch.js"; import type { ModelRegistry } from "../../core/model-registry.js"; -import { parseModelPattern } from "../../core/model-resolver.js"; +import { + findExactModelReferenceMatch, + parseModelPattern, + resolveModelScopePatterns, +} from "../../core/model-resolver.js"; import { takeOverStdout, writeRawStdout } from "../../core/output-guard.js"; import type { SessionInfo, SessionTreeNode } from "../../core/session-manager.js"; import { SessionManager } from "../../core/session-manager.js"; @@ -386,6 +390,8 @@ type SettingsReader = Pick< | "getHideThinkingBlock" | "getAgentModels" | "getGlobalSubagentArbiterSettings" + | "getEnabledModels" + | "hasProjectEnabledModelsOverride" >; type SettingsRefresher = SettingsReader & @@ -416,6 +422,7 @@ type SettingsWriter = SettingsRefresher & | "removeAgentModelsForAgent" | "hasProjectAgentModelOverride" | "setGlobalSubagentArbiterSettings" + | "setEnabledModels" >; /** @@ -426,9 +433,15 @@ type SettingsWriter = SettingsRefresher & * These seed fresh runtimes, NOT the live session state (`get_state` reports that). Extracted * (like {@link deleteSessionForRpc}) so it is unit-testable without a live RPC session. */ -export function getSettingsForRpc(settingsManager: SettingsReader): RpcSettingsSnapshot { +export function getSettingsForRpc( + settingsManager: SettingsReader, + availableModels: Awaited> = [], +): RpcSettingsSnapshot { const contextTrust = settingsManager.getGlobalContextTrustPolicy(); const configuredTrustedFolders = settingsManager.getConfiguredTrustedContextFolders(); + const enabledModels = settingsManager.getEnabledModels(); + const hasProjectEnabledModelsOverride = settingsManager.hasProjectEnabledModelsOverride(); + const scopeResolution = resolveModelScopePatterns(enabledModels ?? [], availableModels); return { defaultProvider: settingsManager.getDefaultProvider(), defaultModel: settingsManager.getDefaultModel(), @@ -447,9 +460,31 @@ export function getSettingsForRpc(settingsManager: SettingsReader): RpcSettingsS hideThinkingBlock: settingsManager.getHideThinkingBlock(), agentModels: settingsManager.getAgentModels(), subagentArbiter: settingsManager.getGlobalSubagentArbiterSettings(), + enabledModels, + resolvedScopedModels: scopeResolution.models.map(({ model, thinkingLevel }) => ({ + provider: model.provider, + id: model.id, + name: model.name, + reasoning: model.reasoning, + ...(thinkingLevel !== undefined ? { thinkingLevel } : {}), + })), + scopeWarnings: scopeResolution.warnings, + hasProjectEnabledModelsOverride, + enabledModelsSource: hasProjectEnabledModelsOverride + ? "project" + : enabledModels === undefined + ? "default" + : "global", }; } +async function buildSettingsSnapshotForRpc( + settingsManager: SettingsReader, + modelRegistry?: Pick, +): Promise { + return getSettingsForRpc(settingsManager, modelRegistry ? await modelRegistry.getAvailable() : []); +} + function formatSettingsErrors(errors: Array<{ scope: string; error: Error }>): string { return errors.map((entry) => `${entry.scope}: ${entry.error.message}`).join("; "); } @@ -465,6 +500,7 @@ function formatSettingsErrors(errors: Array<{ scope: string; error: Error }>): s */ export async function getFreshSettingsForRpc( settingsManager: SettingsRefresher, + modelRegistry?: Pick, ): Promise<{ ok: true; settings: RpcSettingsSnapshot } | { ok: false; error: string }> { return settingsWriteLock(async () => { try { @@ -502,7 +538,12 @@ export async function getFreshSettingsForRpc( }; } - return { ok: true as const, settings: getSettingsForRpc(settingsManager) }; + try { + const availableModels = modelRegistry ? await modelRegistry.getAvailable() : []; + return { ok: true as const, settings: getSettingsForRpc(settingsManager, availableModels) }; + } catch (error) { + return { ok: false as const, error: `Failed to load available models: ${(error as Error).message}` }; + } }); } @@ -522,6 +563,7 @@ const SETTINGS_UPDATE_KEYS = [ "transport", "hideThinkingBlock", "agentModels", + "enabledModels", "subagentArbiter", ] as const; @@ -618,6 +660,7 @@ async function persistContextTrustMutationForRpc( folders: string[], targetPath: string, mutation: Pick, + modelRegistry?: Pick, ): Promise<{ ok: true; result: RpcContextTrustMutationResult } | { ok: false; error: string }> { settingsManager.drainErrors(); if (settingsManager.hasGlobalSettingsLoadError()) { @@ -650,7 +693,11 @@ async function persistContextTrustMutationForRpc( if (!evaluated.ok) return evaluated; return { ok: true as const, - result: { evaluation: evaluated.evaluation, settings: getSettingsForRpc(settingsManager), ...mutation }, + result: { + evaluation: evaluated.evaluation, + settings: await buildSettingsSnapshotForRpc(settingsManager, modelRegistry), + ...mutation, + }, }; } @@ -658,6 +705,7 @@ async function persistContextTrustMutationForRpc( export async function trustContextFolderForRpc( settingsManager: SettingsWriter, path: unknown, + modelRegistry?: Pick, ): Promise<{ ok: true; result: RpcContextTrustMutationResult } | { ok: false; error: string }> { return settingsWriteLock(async () => { const evaluated = evaluateContextTrustForRpc(settingsManager, path); @@ -672,11 +720,17 @@ export async function trustContextFolderForRpc( } catch (error) { return { ok: false as const, error: (error as Error).message }; } - return persistContextTrustMutationForRpc(settingsManager, folders, evaluated.evaluation.canonicalTarget, { - ...(folders.includes(evaluated.evaluation.canonicalTarget) - ? { addedRoot: evaluated.evaluation.canonicalTarget } - : {}), - }); + return persistContextTrustMutationForRpc( + settingsManager, + folders, + evaluated.evaluation.canonicalTarget, + { + ...(folders.includes(evaluated.evaluation.canonicalTarget) + ? { addedRoot: evaluated.evaluation.canonicalTarget } + : {}), + }, + modelRegistry, + ); }); } @@ -684,6 +738,7 @@ export async function trustContextFolderForRpc( export async function untrustContextFolderForRpc( settingsManager: SettingsWriter, path: unknown, + modelRegistry?: Pick, ): Promise<{ ok: true; result: RpcContextTrustMutationResult } | { ok: false; error: string }> { return settingsWriteLock(async () => { const evaluated = evaluateContextTrustForRpc(settingsManager, path); @@ -697,7 +752,10 @@ export async function untrustContextFolderForRpc( if (evaluated.evaluation.state === "untrusted") { return { ok: true, - result: { evaluation: evaluated.evaluation, settings: getSettingsForRpc(settingsManager) }, + result: { + evaluation: evaluated.evaluation, + settings: await buildSettingsSnapshotForRpc(settingsManager, modelRegistry), + }, }; } @@ -708,6 +766,7 @@ export async function untrustContextFolderForRpc( roots.filter((root) => root !== removedRoot), evaluated.evaluation.canonicalTarget, { removedRoot }, + modelRegistry, ); }); } @@ -716,6 +775,7 @@ export async function untrustContextFolderForRpc( export async function removeTrustedContextFolderForRpc( settingsManager: SettingsWriter, path: unknown, + modelRegistry?: Pick, ): Promise<{ ok: true; result: RpcTrustedFolderRemovalResult } | { ok: false; error: string }> { return settingsWriteLock(async () => { if (typeof path !== "string" || path.length === 0) { @@ -749,7 +809,13 @@ export async function removeTrustedContextFolderForRpc( return { ok: false as const, error: `Failed to persist settings: ${detail}` }; } - return { ok: true as const, result: { settings: getSettingsForRpc(settingsManager), removedFolder: path } }; + return { + ok: true as const, + result: { + settings: await buildSettingsSnapshotForRpc(settingsManager, modelRegistry), + removedFolder: path, + }, + }; }); } @@ -847,6 +913,51 @@ export async function setSettingsForRpc( } } + let canonicalEnabledModels: string[] | undefined; + if (update.enabledModels !== undefined && update.enabledModels !== null) { + if (!Array.isArray(update.enabledModels)) { + return { + ok: false, + error: "Invalid enabledModels: must be a non-empty array of exact model references or null", + }; + } + if (update.enabledModels.length === 0) { + return { + ok: false, + error: "Invalid enabledModels: at least one model must remain enabled; use null to enable all", + }; + } + + const availableModels = await modelRegistry.getAvailable(); + const seen = new Set(); + canonicalEnabledModels = []; + for (const reference of update.enabledModels) { + if (typeof reference !== "string" || reference.trim().length === 0 || reference !== reference.trim()) { + return { ok: false, error: "Invalid enabledModels: every entry must be a non-empty exact model reference" }; + } + const match = findExactModelReferenceMatch(reference, availableModels); + if (!match) { + return { + ok: false, + error: `Invalid enabledModels entry ${JSON.stringify(reference)}: expected an available exact provider/model reference`, + }; + } + const canonical = `${match.provider}/${match.id}`; + if (seen.has(canonical)) { + return { ok: false, error: `Invalid enabledModels: duplicate model ${JSON.stringify(canonical)}` }; + } + seen.add(canonical); + canonicalEnabledModels.push(canonical); + } + + if ( + canonicalEnabledModels.length === availableModels.length && + availableModels.every((model) => seen.has(`${model.provider}/${model.id}`)) + ) { + canonicalEnabledModels = undefined; + } + } + let subagentArbiter = update.subagentArbiter === null ? undefined : update.subagentArbiter; if (subagentArbiter !== undefined) { if (!isPlainObject(subagentArbiter)) { @@ -999,6 +1110,14 @@ export async function setSettingsForRpc( } const warnings: string[] = []; + if (update.enabledModels !== undefined) { + if (settingsManager.hasProjectEnabledModelsOverride()) { + warnings.push( + "A project-level enabledModels value (.dreb/settings.json) takes precedence — this change to global settings will have no effect. Edit the project settings file to change it.", + ); + } + settingsManager.setEnabledModels(canonicalEnabledModels); + } if (update.subagentArbiter !== undefined) { settingsManager.setGlobalSubagentArbiterSettings(subagentArbiter); } @@ -1046,9 +1165,10 @@ export async function setSettingsForRpc( } } + const snapshot = getSettingsForRpc(settingsManager, await modelRegistry.getAvailable()); return warnings.length > 0 - ? { ok: true as const, settings: getSettingsForRpc(settingsManager), warnings } - : { ok: true as const, settings: getSettingsForRpc(settingsManager) }; + ? { ok: true as const, settings: snapshot, warnings } + : { ok: true as const, settings: snapshot }; } catch (error) { if (updatesContextTrustPolicy) { // Setters and flush() can also fail synchronously. The same fail-closed @@ -2082,7 +2202,7 @@ export async function runRpcMode(session: AgentSession, modelFallbackMessage?: s // ================================================================= case "get_settings": { - const result = await getFreshSettingsForRpc(session.settingsManager); + const result = await getFreshSettingsForRpc(session.settingsManager, session.modelRegistry); return result.ok ? success(id, "get_settings", result.settings) : error(id, "get_settings", result.error); } @@ -2108,21 +2228,29 @@ export async function runRpcMode(session: AgentSession, modelFallbackMessage?: s } case "trust_context_folder": { - const result = await trustContextFolderForRpc(session.settingsManager, command.path); + const result = await trustContextFolderForRpc(session.settingsManager, command.path, session.modelRegistry); return result.ok ? success(id, "trust_context_folder", result.result) : error(id, "trust_context_folder", result.error); } case "untrust_context_folder": { - const result = await untrustContextFolderForRpc(session.settingsManager, command.path); + const result = await untrustContextFolderForRpc( + session.settingsManager, + command.path, + session.modelRegistry, + ); return result.ok ? success(id, "untrust_context_folder", result.result) : error(id, "untrust_context_folder", result.error); } case "remove_trusted_context_folder": { - const result = await removeTrustedContextFolderForRpc(session.settingsManager, command.path); + const result = await removeTrustedContextFolderForRpc( + session.settingsManager, + command.path, + session.modelRegistry, + ); return result.ok ? success(id, "remove_trusted_context_folder", result.result) : error(id, "remove_trusted_context_folder", result.error); diff --git a/packages/coding-agent/src/modes/rpc/rpc-types.ts b/packages/coding-agent/src/modes/rpc/rpc-types.ts index 060f4340..ad27e8f7 100644 --- a/packages/coding-agent/src/modes/rpc/rpc-types.ts +++ b/packages/coding-agent/src/modes/rpc/rpc-types.ts @@ -160,6 +160,13 @@ export interface RpcScopedModel { thinkingLevel?: string; } +export interface RpcModelScopeWarning { + /** Raw persisted pattern that produced this diagnostic. */ + pattern: string; + /** Resolver warning text without terminal styling. */ + message: string; +} + export interface RpcResources { contextFiles: Array<{ path: string }>; skills: Array<{ name: string; description: string }>; @@ -596,6 +603,16 @@ export interface RpcSettingsSnapshot { agentModels?: Record; /** Global-only fail-closed Dispatch Arbiter configuration. */ subagentArbiter?: SubagentArbiterSettings; + /** Raw effective persisted model patterns. Absent means the future-inclusive all-model scope. */ + enabledModels?: string[]; + /** Effective patterns resolved by coding-agent core in model-cycling order. */ + resolvedScopedModels: RpcScopedModel[]; + /** Structured diagnostics from resolving legacy persisted patterns. */ + scopeWarnings: RpcModelScopeWarning[]; + /** True when project settings shadow global enabledModels writes. */ + hasProjectEnabledModelsOverride: boolean; + /** Source of the effective raw enabledModels value. */ + enabledModelsSource: "default" | "global" | "project"; } /** Settings snapshot returned by `set_settings`; warnings are present for loud shadowing notices. */ @@ -651,6 +668,8 @@ export interface RpcSettingsUpdate { transport?: Transport; hideThinkingBlock?: boolean; agentModels?: Record; + /** Ordered exact model references; null removes the filter and restores implicit all. */ + enabledModels?: string[] | null; /** Replaces the complete global-only arbiter configuration. */ subagentArbiter?: SubagentArbiterSettings | null; } diff --git a/packages/coding-agent/test/model-resolver.test.ts b/packages/coding-agent/test/model-resolver.test.ts index 7011351a..b155fe8b 100644 --- a/packages/coding-agent/test/model-resolver.test.ts +++ b/packages/coding-agent/test/model-resolver.test.ts @@ -1,10 +1,12 @@ import type { Model } from "@dreb/ai"; -import { describe, expect, test } from "vitest"; +import { describe, expect, test, vi } from "vitest"; import { defaultModelPerProvider, findInitialModel, parseModelPattern, resolveCliModel, + resolveModelScope, + resolveModelScopePatterns, } from "../src/core/model-resolver.js"; // Mock models for testing @@ -206,6 +208,50 @@ describe("parseModelPattern", () => { }); }); +describe("resolveModelScopePatterns", () => { + test("returns stable pattern and registry order with structured diagnostics", () => { + const result = resolveModelScopePatterns( + ["openai/gpt-4o", "openrouter/**", "sonnet:bogus", "missing", "anthropic/*"], + allModels, + ); + + expect(result.models.map(({ model }) => `${model.provider}/${model.id}`)).toEqual([ + "openai/gpt-4o", + "openrouter/qwen/qwen3-coder:exacto", + "openrouter/openai/gpt-4o:extended", + "anthropic/claude-sonnet-4-5", + ]); + expect(result.warnings).toEqual([ + { + pattern: "sonnet:bogus", + message: 'Invalid thinking level "bogus" in pattern "sonnet:bogus". Using default instead.', + }, + { pattern: "missing", message: 'No models match pattern "missing"' }, + ]); + }); + + test("deduplicates matches while preserving the first thinking suffix", () => { + const result = resolveModelScopePatterns(["sonnet:high", "anthropic/*:low"], allModels); + expect(result.models).toHaveLength(1); + expect(result.models[0]?.thinkingLevel).toBe("high"); + }); + + test("handles exact references whose model ids contain slashes and colons", () => { + const result = resolveModelScopePatterns(["openrouter/qwen/qwen3-coder:exacto:high"], allModels); + expect(result.models[0]?.model.id).toBe("qwen/qwen3-coder:exacto"); + expect(result.models[0]?.thinkingLevel).toBe("high"); + expect(result.warnings).toEqual([]); + }); + + test("legacy async wrapper emits the same warning text", async () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + const registry = { getAvailable: vi.fn().mockResolvedValue(allModels) }; + await resolveModelScope(["missing"], registry as any); + expect(warn).toHaveBeenCalledWith(expect.stringContaining('Warning: No models match pattern "missing"')); + warn.mockRestore(); + }); +}); + describe("resolveCliModel", () => { test("resolves --model provider/id without --provider", () => { const registry = { diff --git a/packages/coding-agent/test/rpc-settings-commands.test.ts b/packages/coding-agent/test/rpc-settings-commands.test.ts index 0273812c..0b277c71 100644 --- a/packages/coding-agent/test/rpc-settings-commands.test.ts +++ b/packages/coding-agent/test/rpc-settings-commands.test.ts @@ -69,6 +69,11 @@ describe("getSettingsForRpc", () => { hideThinkingBlock: false, agentModels: {}, subagentArbiter: undefined, + enabledModels: undefined, + resolvedScopedModels: [], + scopeWarnings: [], + hasProjectEnabledModelsOverride: false, + enabledModelsSource: "default", }); }); @@ -108,6 +113,11 @@ describe("getSettingsForRpc", () => { hideThinkingBlock: true, agentModels: { Explore: ["anthropic/sonnet", "openai/gpt-5"] }, subagentArbiter: { enabled: true, model: "anthropic/claude-sonnet-4-5", thinking: "high" }, + enabledModels: undefined, + resolvedScopedModels: [], + scopeWarnings: [], + hasProjectEnabledModelsOverride: false, + enabledModelsSource: "default", }); }); @@ -157,6 +167,31 @@ describe("getSettingsForRpc", () => { expect(snapshot.trustedContextFolders).toEqual([rootA, rootB]); expect(snapshot.effectiveTrustedContextRoots).toEqual([]); }); + + it("keeps a legacy persisted empty scope distinct from implicit all", () => { + const snapshot = getSettingsForRpc(SettingsManager.inMemory({ enabledModels: [] }), []); + expect(snapshot.enabledModels).toEqual([]); + expect(snapshot.enabledModelsSource).toBe("global"); + expect(snapshot.resolvedScopedModels).toEqual([]); + }); + + it("includes raw patterns, ordered core resolution diagnostics, and project source metadata", () => { + const manager = SettingsManager.inMemory({ enabledModels: ["anthropic/*", "missing"] }); + vi.spyOn(manager, "hasProjectEnabledModelsOverride").mockReturnValue(true); + const snapshot = getSettingsForRpc(manager, [ + { provider: "anthropic", id: "second", name: "Second", reasoning: false }, + { provider: "anthropic", id: "first", name: "First", reasoning: true }, + ] as Model[]); + + expect(snapshot.enabledModels).toEqual(["anthropic/*", "missing"]); + expect(snapshot.resolvedScopedModels.map((model) => `${model.provider}/${model.id}`)).toEqual([ + "anthropic/second", + "anthropic/first", + ]); + expect(snapshot.scopeWarnings).toEqual([{ pattern: "missing", message: 'No models match pattern "missing"' }]); + expect(snapshot.hasProjectEnabledModelsOverride).toBe(true); + expect(snapshot.enabledModelsSource).toBe("project"); + }); }); describe("getFreshSettingsForRpc", () => { @@ -535,7 +570,7 @@ describe("setSettingsForRpc validation", () => { }, }, }); - expect(registry.getAvailable).not.toHaveBeenCalled(); + expect(registry.getAvailable).toHaveBeenCalledTimes(1); const disabledPolicy = { enabled: false, model: "malformed-model-id", @@ -551,6 +586,36 @@ describe("setSettingsForRpc validation", () => { expect(manager.getGlobalSubagentArbiterSettings()).toEqual(disabledPolicy); }); + it("rejects invalid enabledModels forms and leaves the prior scope unchanged", async () => { + const manager = SettingsManager.inMemory({ enabledModels: ["anthropic/claude-sonnet-4-5"] }); + const registry = stubRegistry([anthropicSonnet, { provider: "openai", id: "gpt-5" }]); + const invalidValues = [ + [], + ["sonnet"], + ["anthropic/*"], + ["missing/model"], + ["anthropic/claude-sonnet-4-5", "ANTHROPIC/CLAUDE-SONNET-4-5"], + [""], + [" openai/gpt-5 "], + ] as unknown[]; + + for (const enabledModels of invalidValues) { + const result = await setSettingsForRpc(manager, registry, { enabledModels: enabledModels as never }); + expect(result.ok).toBe(false); + expect(manager.getEnabledModels()).toEqual(["anthropic/claude-sonnet-4-5"]); + } + }); + + it("validates enabledModels before applying unrelated fields", async () => { + const manager = SettingsManager.inMemory({ retry: { enabled: true } }); + const result = await setSettingsForRpc(manager, stubRegistry([anthropicSonnet]), { + retryEnabled: false, + enabledModels: [], + }); + expect(result.ok).toBe(false); + expect(manager.getRetryEnabled()).toBe(true); + }); + it("applies nothing when any field is invalid (atomicity)", async () => { const manager = SettingsManager.inMemory({ retry: { enabled: true }, images: { autoResize: true } }); const result = await setSettingsForRpc(manager, stubRegistry([]), { @@ -567,6 +632,79 @@ describe("setSettingsForRpc validation", () => { }); describe("setSettingsForRpc writes", () => { + it("canonicalizes ordered partial enabledModels and clears with null", async () => { + const manager = SettingsManager.inMemory(); + const registry = stubRegistry([ + { provider: "anthropic", id: "claude-sonnet-4-5" }, + { provider: "openai", id: "gpt-5" }, + { provider: "openrouter", id: "org/model:exact" }, + ]); + + const partial = await setSettingsForRpc(manager, registry, { + enabledModels: ["OPENAI/GPT-5", "org/model:exact"], + }); + expect(partial).toMatchObject({ + ok: true, + settings: { + enabledModels: ["openai/gpt-5", "openrouter/org/model:exact"], + resolvedScopedModels: [ + { provider: "openai", id: "gpt-5" }, + { provider: "openrouter", id: "org/model:exact" }, + ], + }, + }); + expect(manager.getEnabledModels()).toEqual(["openai/gpt-5", "openrouter/org/model:exact"]); + + const cleared = await setSettingsForRpc(manager, registry, { enabledModels: null }); + expect(cleared).toMatchObject({ ok: true, settings: { enabledModelsSource: "default" } }); + expect(manager.getEnabledModels()).toBeUndefined(); + }); + + it("normalizes the complete authoritative inventory to implicit all", async () => { + const manager = SettingsManager.inMemory(); + const models = [anthropicSonnet, { provider: "openai", id: "gpt-5" }]; + const result = await setSettingsForRpc(manager, stubRegistry(models), { + enabledModels: ["openai/gpt-5", "anthropic/claude-sonnet-4-5"], + }); + expect(result).toMatchObject({ ok: true, settings: { enabledModelsSource: "default" } }); + expect(manager.getEnabledModels()).toBeUndefined(); + }); + + it("writes globally but returns the exact warning and effective project scope when shadowed", async () => { + const dir = await createTempDir(); + const agentDir = join(dir, "agent"); + const projectDir = join(dir, "project"); + mkdirSync(agentDir, { recursive: true }); + mkdirSync(join(projectDir, ".dreb"), { recursive: true }); + writeFileSync( + join(projectDir, ".dreb", "settings.json"), + JSON.stringify({ enabledModels: ["anthropic/claude-sonnet-4-5"] }), + ); + const manager = SettingsManager.create(projectDir, agentDir); + const result = await setSettingsForRpc( + manager, + stubRegistry([anthropicSonnet, { provider: "openai", id: "gpt-5" }]), + { + enabledModels: ["openai/gpt-5"], + }, + ); + + expect(result).toMatchObject({ + ok: true, + settings: { + enabledModels: ["anthropic/claude-sonnet-4-5"], + enabledModelsSource: "project", + hasProjectEnabledModelsOverride: true, + }, + warnings: [ + "A project-level enabledModels value (.dreb/settings.json) takes precedence — this change to global settings will have no effect. Edit the project settings file to change it.", + ], + }); + expect(JSON.parse(readFileSync(join(agentDir, "settings.json"), "utf-8")).enabledModels).toEqual([ + "openai/gpt-5", + ]); + }); + it("persists and clears the complete global-only arbiter policy", async () => { const manager = SettingsManager.inMemory(); const enabled = await setSettingsForRpc(manager, stubRegistry([anthropicSonnet]), { @@ -664,6 +802,12 @@ describe("setSettingsForRpc writes", () => { transport: "auto", hideThinkingBlock: true, agentModels: { Explore: ["anthropic/sonnet", "openai/gpt-5"] }, + subagentArbiter: undefined, + enabledModels: undefined, + resolvedScopedModels: [], + scopeWarnings: [], + hasProjectEnabledModelsOverride: false, + enabledModelsSource: "default", }); // Reflected in subsequent reads. expect(getSettingsForRpc(manager)).toEqual(result.settings); @@ -803,6 +947,29 @@ describe("setSettingsForRpc writes", () => { expect(result.error).toContain("disk full"); }); + it("leaves the prior durable enabledModels scope when its write fails", async () => { + let globalSettings = JSON.stringify({ enabledModels: ["anthropic/old"] }); + const storage: SettingsStorage = { + withLock(scope, fn) { + const next = fn(scope === "global" ? globalSettings : undefined); + if (next === undefined || scope !== "global") return; + if (JSON.parse(next).enabledModels?.includes("openai/gpt-5")) throw new Error("disk full"); + globalSettings = next; + }, + }; + const manager = SettingsManager.fromStorage(storage); + const result = await setSettingsForRpc( + manager, + stubRegistry([ + { provider: "anthropic", id: "old" }, + { provider: "openai", id: "gpt-5" }, + ]), + { enabledModels: ["openai/gpt-5"] }, + ); + expect(result).toMatchObject({ ok: false, error: expect.stringContaining("disk full") }); + expect(JSON.parse(globalSettings).enabledModels).toEqual(["anthropic/old"]); + }); + it("does not durably enable context trust when an ordinary-settings write fails", async () => { let globalSettings: string | undefined; const storage: SettingsStorage = { @@ -1429,6 +1596,11 @@ describe("RpcClient settings methods", () => { transport: "sse", hideThinkingBlock: false, agentModels: {}, + resolvedScopedModels: [{ provider: "anthropic", id: "claude-sonnet-4-5" }], + scopeWarnings: [], + hasProjectEnabledModelsOverride: false, + enabledModelsSource: "global", + enabledModels: ["anthropic/claude-sonnet-4-5"], }; it("getSettings sends the get_settings command and unwraps the snapshot", async () => { diff --git a/packages/coding-agent/test/settings-manager.test.ts b/packages/coding-agent/test/settings-manager.test.ts index 8a0f28f4..31bb8ca1 100644 --- a/packages/coding-agent/test/settings-manager.test.ts +++ b/packages/coding-agent/test/settings-manager.test.ts @@ -802,6 +802,32 @@ describe("SettingsManager", () => { }); }); + describe("enabledModels project override metadata", () => { + it("uses the project array and detects even an explicit empty override", () => { + writeFileSync(join(agentDir, "settings.json"), JSON.stringify({ enabledModels: ["global/model"] })); + writeFileSync(join(projectDir, ".dreb", "settings.json"), JSON.stringify({ enabledModels: [] })); + const manager = SettingsManager.create(projectDir, agentDir); + + expect(manager.getEnabledModels()).toEqual([]); + expect(manager.hasProjectEnabledModelsOverride()).toBe(true); + }); + + it("clearing the global key does not change a project override", async () => { + writeFileSync(join(agentDir, "settings.json"), JSON.stringify({ enabledModels: ["global/model"] })); + writeFileSync( + join(projectDir, ".dreb", "settings.json"), + JSON.stringify({ enabledModels: ["project/model"] }), + ); + const manager = SettingsManager.create(projectDir, agentDir); + manager.setEnabledModels(undefined); + await manager.flush(); + + expect(JSON.parse(readFileSync(join(agentDir, "settings.json"), "utf-8")).enabledModels).toBeUndefined(); + expect(manager.getEnabledModels()).toEqual(["project/model"]); + expect(manager.hasProjectEnabledModelsOverride()).toBe(true); + }); + }); + describe("modelSettings (per-model thinking display)", () => { it("should roundtrip set then getModelThinkingDisplay", () => { const manager = SettingsManager.create(projectDir, agentDir); diff --git a/packages/dashboard/README.md b/packages/dashboard/README.md index a08e1c18..2546ded4 100644 --- a/packages/dashboard/README.md +++ b/packages/dashboard/README.md @@ -44,7 +44,7 @@ Open `http://127.0.0.1:5343`. per-message copy, tasks panel, a bounded scrollable panel listing every retained subagent newest-first (collapsed by default on mobile), suggest-next chip, generic built-in slash-command discovery and fail-closed execution (including - settings/model, import/export, session tree, fork, new/compact/dream, + settings/model/scoped-models, import/export, session tree, fork, new/compact/dream, resume/reload, and quit), image attach/paste with sent-image previews retained in user transcript entries, queued-message restore, persistent session-header live indicator, footer-parity info bar (branch, tokens, cost, ctx%, median tok/s), stats/loaded-context/fork modals, steer/follow-up composer @@ -61,8 +61,8 @@ Open `http://127.0.0.1:5343`. its inherited descendants). - **Settings** — persistent defaults (provider-grouped model dropdown, thinking, queue modes, image handling, skill commands, transport, - hide-thinking, compaction/retry), per-agent model fallback editor, and the - global-only nested-context policy: an auditable trusted-roots list with + hide-thinking, compaction/retry), a scoped-models editor, per-agent model + fallback editor, and the global-only nested-context policy: an auditable trusted-roots list with revoke and simple add-by-path controls, plus a prominent expert trust-all warning. The Files view remains the primary trust-grant flow. Most defaults seed new sessions; opening Settings flushes pending writes and reloads durable @@ -77,6 +77,12 @@ Open `http://127.0.0.1:5343`. and paired-devices management. - **Pairing** — remote first-login rotating-code flow. +### Scoped models + +The Settings scoped-models editor manages the persistent model-cycling scope. Search is grouped by provider, with individual model, provider, and all-model toggles; non-empty partial scopes have accessible up/down ordering controls plus save/reset, and controls remain usable on mobile. An absent `enabledModels` means implicit all available registry models in registry order, including future additions, so that view cannot be reordered. A saved partial scope is an ordered list of exact canonical `provider/model` references; editing legacy glob, fuzzy, or thinking-suffix values normalizes them to exact references. + +The selected project context reads effective global + project settings, but saves always write the global setting and warn if `.dreb/settings.json` shadows it. Changes seed new sessions only and never modify a running session. Running `/scoped-models` in a dashboard session opens this editor with that session's cwd selected. For persisted-setting and RPC details, see [Model Cycling](../coding-agent/docs/settings.md#model-cycling) and [`get_settings` / `set_settings`](../coding-agent/docs/rpc.md#settings). + ### Transcript images Image blocks returned by any tool and images uploaded with a user turn render diff --git a/packages/dashboard/src/client/api.ts b/packages/dashboard/src/client/api.ts index 09a091f1..09f4721f 100644 --- a/packages/dashboard/src/client/api.ts +++ b/packages/dashboard/src/client/api.ts @@ -30,6 +30,7 @@ import type { SessionTreeNodeDto, SettingsDto, SettingsSaveResultDto, + SettingsUpdateDto, TrustedFolderRemovalResultDto, } from "../shared/protocol.js"; @@ -64,6 +65,10 @@ function json(body: unknown): RequestInit { return { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body) }; } +function withCwd(path: string, cwd?: string): string { + return cwd ? `${path}?cwd=${encodeURIComponent(cwd)}` : path; +} + const DASHBOARD_IMAGE_ID_PATTERN = /^[0-9a-f]{64}$/; /** Build an encoded same-origin URL only for a validated content-addressed ID. */ @@ -160,14 +165,14 @@ export const api = { body: JSON.stringify({ path }), }), - settings: () => request("/api/settings"), - saveSettings: (settings: SettingsDto) => - request("/api/settings", { + settings: (cwd?: string) => request(withCwd("/api/settings", cwd)), + saveSettings: (settings: SettingsUpdateDto, cwd?: string) => + request(withCwd("/api/settings", cwd), { method: "PUT", headers: { "content-type": "application/json" }, body: JSON.stringify(settings), }), - settingsModels: () => request<{ models: ModelInfoDto[] }>("/api/settings/models"), + settingsModels: (cwd?: string) => request<{ models: ModelInfoDto[] }>(withCwd("/api/settings/models", cwd)), agentTypes: (cwd?: string) => request<{ agentTypes: AgentTypeDto[] }>( cwd ? `/api/settings/agent-types?cwd=${encodeURIComponent(cwd)}` : "/api/settings/agent-types", diff --git a/packages/dashboard/src/client/app.tsx b/packages/dashboard/src/client/app.tsx index c0bbfe5b..37214b04 100644 --- a/packages/dashboard/src/client/app.tsx +++ b/packages/dashboard/src/client/app.tsx @@ -170,7 +170,11 @@ export function App(): JSX.Element { - + diff --git a/packages/dashboard/src/client/components/scoped-models-editor.tsx b/packages/dashboard/src/client/components/scoped-models-editor.tsx new file mode 100644 index 00000000..0d7e1942 --- /dev/null +++ b/packages/dashboard/src/client/components/scoped-models-editor.tsx @@ -0,0 +1,339 @@ +import { createEffect, createMemo, createResource, createSignal, For, type JSX, Show } from "solid-js"; +import type { ModelInfoDto, SettingsDto, SettingsSaveResultDto } from "../../shared/protocol.js"; +import { api } from "../api.js"; + +function modelKey(model: Pick): string { + return `${model.provider}/${model.id}`; +} + +function groupedModels(models: ModelInfoDto[]): Array<{ provider: string; models: ModelInfoDto[] }> { + const groups = new Map(); + for (const model of models) { + const group = groups.get(model.provider) ?? []; + group.push(model); + groups.set(model.provider, group); + } + return [...groups].map(([provider, providerModels]) => ({ provider, models: providerModels })); +} + +export interface ScopedModelsEditorProps { + cwd?: string; + projectRoots: string[]; + onCwdChange: (cwd: string | undefined) => void; + focused?: boolean; +} + +export function ScopedModelsEditor(props: ScopedModelsEditorProps): JSX.Element { + const [query, setQuery] = createSignal(""); + const [implicitAll, setImplicitAll] = createSignal(true); + const [ordered, setOrdered] = createSignal([]); + const [dirty, setDirty] = createSignal(false); + const [saving, setSaving] = createSignal(false); + const [saved, setSaved] = createSignal(false); + const [saveError, setSaveError] = createSignal(); + const [saveWarnings, setSaveWarnings] = createSignal([]); + let section: HTMLElement | undefined; + let appliedSettings: SettingsDto | undefined; + + const [data, { mutate }] = createResource( + () => props.cwd ?? "", + async (cwd) => { + const context = cwd || undefined; + const [settings, inventory] = await Promise.all([api.settings(context), api.settingsModels(context)]); + return { settings, models: inventory.models }; + }, + ); + + function applySnapshot(settings: SettingsDto, models: ModelInfoDto[]): void { + const all = settings.enabledModels === undefined; + setImplicitAll(all); + setOrdered( + all ? models.map(modelKey) : settings.resolvedScopedModels.map((model) => `${model.provider}/${model.id}`), + ); + setDirty(false); + setSaveError(undefined); + } + + createEffect(() => { + const loaded = data(); + if (!loaded || loaded.settings === appliedSettings) return; + appliedSettings = loaded.settings; + applySnapshot(loaded.settings, loaded.models); + }); + + createEffect(() => { + if (!props.focused || !section) return; + section.scrollIntoView({ block: "start" }); + section.focus({ preventScroll: true }); + }); + + const selected = createMemo(() => new Set(ordered())); + const projectRoots = createMemo(() => { + const roots = new Set(props.projectRoots); + if (props.cwd) roots.add(props.cwd); + return [...roots].sort((a, b) => a.localeCompare(b)); + }); + const filteredGroups = createMemo(() => { + const q = query().trim().toLowerCase(); + const models = data()?.models ?? []; + return groupedModels( + models.filter((model) => !q || `${model.provider}/${model.id} ${model.name ?? ""}`.toLowerCase().includes(q)), + ); + }); + const validationError = createMemo(() => { + if ((data()?.models.length ?? 0) === 0) return "No available models were reported for this context."; + if (!implicitAll() && ordered().length === 0) return "At least one model must remain enabled."; + return undefined; + }); + const normalizationNotice = createMemo(() => { + const settings = data()?.settings; + if (!settings?.enabledModels) return undefined; + const canonical = settings.resolvedScopedModels.map((model) => `${model.provider}/${model.id}`); + const alreadyCanonical = + settings.enabledModels.length === canonical.length && + settings.enabledModels.every((pattern, index) => pattern === canonical[index]); + return alreadyCanonical && settings.scopeWarnings.length === 0 + ? undefined + : "Saving an edited legacy scope replaces patterns and per-pattern thinking suffixes with exact provider/model references."; + }); + + function markPartial(next: string[]): void { + const inventory = data()?.models.map(modelKey) ?? []; + const allSelected = inventory.length > 0 && inventory.every((key) => next.includes(key)); + setImplicitAll(allSelected); + setOrdered(allSelected ? inventory : next); + setDirty(true); + setSaved(false); + setSaveError(undefined); + } + + function toggleModel(key: string): void { + const current = implicitAll() ? (data()?.models.map(modelKey) ?? []) : ordered(); + markPartial(current.includes(key) ? current.filter((item) => item !== key) : [...current, key]); + } + + function toggleProvider(provider: string): void { + const keys = (data()?.models ?? []).filter((model) => model.provider === provider).map(modelKey); + const current = implicitAll() ? (data()?.models.map(modelKey) ?? []) : ordered(); + const remove = keys.every((key) => current.includes(key)); + markPartial( + remove + ? current.filter((key) => !keys.includes(key)) + : [...current, ...keys.filter((key) => !current.includes(key))], + ); + } + + function move(index: number, delta: -1 | 1): void { + if (implicitAll()) return; + const target = index + delta; + if (target < 0 || target >= ordered().length) return; + const next = [...ordered()]; + [next[index], next[target]] = [next[target]!, next[index]!]; + setOrdered(next); + setDirty(true); + setSaved(false); + } + + function reset(): void { + const loaded = data(); + if (!loaded) return; + applySnapshot(loaded.settings, loaded.models); + setSaveWarnings([]); + setSaved(false); + } + + async function save(): Promise { + if (!dirty() || validationError()) return; + setSaving(true); + setSaveError(undefined); + setSaveWarnings([]); + setSaved(false); + const saveCwd = props.cwd; + try { + const result: SettingsSaveResultDto = await api.saveSettings( + { enabledModels: implicitAll() ? null : ordered() }, + saveCwd, + ); + if (props.cwd !== saveCwd) return; + const loaded = data(); + if (loaded) { + appliedSettings = result; + mutate({ settings: result, models: loaded.models }); + applySnapshot(result, loaded.models); + } + setSaveWarnings(result.warnings ?? []); + setSaved(true); + } catch (error) { + setSaveError(error instanceof Error ? error.message : String(error)); + } finally { + setSaving(false); + } + } + + return ( +
+

scoped models

+

+ Controls model cycling for new sessions only; running sessions are never changed. Writes always update + global settings, while the selected context shows effective global-plus-project settings. +

+ + + +
{data.error instanceof Error ? data.error.message : String(data.error)}
+
+ +

Loading scoped models…

+
+ + {(loaded) => ( + <> + +
+ This project defines enabledModels in .dreb/settings.json and shadows global writes. +
+
+ {(notice) =>
{notice()}
}
+ + {(warning) =>
{warning.message}
} +
+ {(warning) =>
{warning}
}
+ {(message) =>
{message()}
}
+ {(message) =>
{message()}
}
+ +
+ setQuery(event.currentTarget.value)} + /> + +
+ +
+
+

cycling order

+

+ {implicitAll() + ? "All available models, in registry order (future models included)." + : `${ordered().length} enabled model${ordered().length === 1 ? "" : "s"}.`} +

+ + {(key, index) => ( +
+ {key} +
+ + +
+
+ )} +
+
+ +
+

available models

+ 0} + fallback={

No matching models.

} + > + + {(group) => ( +
+ + + {(model) => { + const key = modelKey(model); + return ( + + ); + }} + +
+ )} +
+
+
+
+ +
+ + + + unsaved changes + + + ✓ saved + +
+ + )} +
+
+ ); +} diff --git a/packages/dashboard/src/client/screens/session.tsx b/packages/dashboard/src/client/screens/session.tsx index de3072af..853c704f 100644 --- a/packages/dashboard/src/client/screens/session.tsx +++ b/packages/dashboard/src/client/screens/session.tsx @@ -1411,6 +1411,12 @@ export function SessionScreen(props: { store: AppStore; sessionKey: string }): J settings: async (args: string) => { if (!rejectArguments("settings", args)) props.store.navigate({ screen: "settings" }); }, + "scoped-models": async (args: string) => { + if (rejectArguments("scoped-models", args)) return; + const cwd = runtime()?.cwd; + if (!cwd) throw new Error("Cannot open scoped models: the runtime project directory is unavailable"); + props.store.navigate({ screen: "settings", target: "scoped-models", cwd }); + }, model: async (args: string) => { setModelFilter(args); setShowModelSelector(true); diff --git a/packages/dashboard/src/client/screens/settings.tsx b/packages/dashboard/src/client/screens/settings.tsx index 5bef6412..7bf4a46e 100644 --- a/packages/dashboard/src/client/screens/settings.tsx +++ b/packages/dashboard/src/client/screens/settings.tsx @@ -13,6 +13,7 @@ import type { } from "../../shared/protocol.js"; import { api } from "../api.js"; import { Modal, relativeTime, Topbar } from "../components/common.js"; +import { ScopedModelsEditor } from "../components/scoped-models-editor.js"; import { ThemeGallery } from "../components/theme-gallery.js"; import { expandThinking, @@ -174,13 +175,18 @@ function ModelPickerModal(props: { ); } -export function SettingsScreen(props: { store: AppStore }): JSX.Element { +export function SettingsScreen(props: { + store: AppStore; + target?: "scoped-models"; + initialScopedModelsCwd?: string; +}): JSX.Element { const [error, setError] = createSignal(); const [warnings, setWarnings] = createSignal([]); const [saved, setSaved] = createSignal(false); const [modelPickerTarget, setModelPickerTarget] = createSignal(); const [editingAgent, setEditingAgent] = createSignal(); const [agentContextCwd, setAgentContextCwd] = createSignal(); + const [scopedModelsCwd, setScopedModelsCwd] = createSignal(props.initialScopedModelsCwd); const [trustedContextFolderPath, setTrustedContextFolderPath] = createSignal(""); const [contextTrustMutating, setContextTrustMutating] = createSignal(false); const [notificationPermission, setNotificationPermission] = createSignal< @@ -516,6 +522,20 @@ export function SettingsScreen(props: { store: AppStore }): JSX.Element { + { + setScopedModelsCwd(cwd); + props.store.navigate({ + screen: "settings", + target: "scoped-models", + ...(cwd ? { cwd } : {}), + }); + }} + /> +

dispatch arbiter

diff --git a/packages/dashboard/src/client/state/store.ts b/packages/dashboard/src/client/state/store.ts index 1777a0c2..e92b9f0f 100644 --- a/packages/dashboard/src/client/state/store.ts +++ b/packages/dashboard/src/client/state/store.ts @@ -38,18 +38,25 @@ export type Route = | { screen: "session"; key: string } | { screen: "subagent"; key: string; agentId: string } | { screen: "files"; path?: string } - | { screen: "settings" } + | { screen: "settings"; target?: "scoped-models"; cwd?: string } | { screen: "pairing" }; function parseHash(): Route { const hash = window.location.hash.replace(/^#\/?/, ""); - const [head, ...rest] = hash.split("/"); + const [path, query = ""] = hash.split("?", 2); + const [head, ...rest] = path.split("/"); if (head === "session" && rest[0]) { if (rest[1] === "subagent" && rest[2]) return { screen: "subagent", key: rest[0], agentId: rest[2] }; return { screen: "session", key: rest[0] }; } if (head === "files") return { screen: "files", path: rest.length ? decodeURIComponent(rest.join("/")) : undefined }; - if (head === "settings") return { screen: "settings" }; + if (head === "settings") { + if (rest[0] === "scoped-models") { + const cwd = new URLSearchParams(query).get("cwd") || undefined; + return { screen: "settings", target: "scoped-models", ...(cwd ? { cwd } : {}) }; + } + return { screen: "settings" }; + } if (head === "pairing") return { screen: "pairing" }; return { screen: "fleet" }; } @@ -65,7 +72,9 @@ export function routeToHash(route: Route): string { case "files": return route.path ? `#/files/${encodeURIComponent(route.path)}` : "#/files"; case "settings": - return "#/settings"; + return route.target === "scoped-models" + ? `#/settings/scoped-models${route.cwd ? `?cwd=${encodeURIComponent(route.cwd)}` : ""}` + : "#/settings"; case "pairing": return "#/pairing"; } diff --git a/packages/dashboard/src/client/styles/app.css b/packages/dashboard/src/client/styles/app.css index 569735f2..11f9162a 100644 --- a/packages/dashboard/src/client/styles/app.css +++ b/packages/dashboard/src/client/styles/app.css @@ -1654,7 +1654,7 @@ details.thinking .thinking-body { /* ---------------------------------------------------------------- settings */ .settings-wrap { - max-width: 720px; + max-width: 960px; } .settings-intro { @@ -1753,6 +1753,138 @@ details.thinking .thinking-body { white-space: pre-wrap; } +.scoped-models-editor:focus { + outline: none; +} + +.scoped-models-context { + display: grid; + gap: var(--space-1); + margin: var(--space-3) 0; + font-size: var(--fs-secondary); +} + +.scoped-models-context select, +.scoped-models-toolbar input { + min-width: 0; + max-width: 100%; + border: var(--hairline); + border-radius: var(--radius); + background: var(--bg); + color: var(--text); + font: inherit; + padding: var(--space-2); +} + +.scoped-models-toolbar, +.scoped-models-actions { + display: flex; + align-items: center; + gap: var(--space-2); + flex-wrap: wrap; + margin: var(--space-3) 0; +} + +.scoped-models-toolbar input { + flex: 1 1 260px; +} + +.scoped-models-grid { + display: grid; + gap: var(--space-4); + min-width: 0; +} + +.scoped-models-grid h3 { + font-size: var(--fs-secondary); + margin-bottom: var(--space-2); +} + +.scoped-models-order, +.scoped-models-available { + min-width: 0; +} + +.scoped-model-order-row, +.scoped-model-choice, +.scoped-model-provider-heading { + display: flex; + align-items: center; + gap: var(--space-2); + min-width: 0; + font-size: var(--fs-secondary); +} + +.scoped-model-order-row { + justify-content: space-between; + border-bottom: var(--hairline); + padding: var(--space-1) 0; +} + +.scoped-model-order-row > span, +.scoped-model-choice .model-id, +.scoped-model-choice .model-name, +.scoped-model-provider-heading span { + min-width: 0; + overflow-wrap: anywhere; +} + +.scoped-model-choice .model-name { + color: var(--muted); + font-size: var(--fs-small); +} + +.scoped-model-move-controls { + display: flex; + gap: var(--space-1); + flex-shrink: 0; +} + +.scoped-model-move-controls button { + min-width: 36px; + min-height: 36px; + border: var(--hairline); + border-radius: var(--radius); + background: var(--bg-elevated); + color: var(--text); +} + +.scoped-model-provider { + border-bottom: var(--hairline); + padding: var(--space-2) 0; +} + +.scoped-model-provider-heading { + font-weight: 600; + margin-bottom: var(--space-1); +} + +.scoped-model-choice { + padding: var(--space-1) 0 var(--space-1) var(--space-4); +} + +.scoped-model-choice input, +.scoped-model-provider-heading input { + flex: 0 0 auto; +} + +@media (min-width: 701px) { + .scoped-models-grid { + grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); + } +} + +@media (max-width: 700px) { + .scoped-model-move-controls button { + min-width: 44px; + min-height: 44px; + } + + .scoped-model-choice { + min-height: 44px; + } +} + .model-picker-button { max-width: min(100%, 520px); min-width: 0; diff --git a/packages/dashboard/src/server/server.ts b/packages/dashboard/src/server/server.ts index a66b3d59..17bdbbc2 100644 --- a/packages/dashboard/src/server/server.ts +++ b/packages/dashboard/src/server/server.ts @@ -8,7 +8,7 @@ */ import { randomUUID } from "node:crypto"; -import { existsSync } from "node:fs"; +import { existsSync, statSync } from "node:fs"; import { homedir } from "node:os"; import { basename, join } from "node:path"; import type { NextFunction, Request, Response } from "express"; @@ -941,12 +941,38 @@ export function createDashboardServer(options: DashboardServerOptions): Dashboar }); } - app.get("/api/settings", (_req, res) => { - withAnyRuntime(res, (h) => h.client.getSettings()); + function optionalSettingsCwd(req: Request, res: Response): string | undefined | null { + if (req.query.cwd === undefined) return undefined; + if (typeof req.query.cwd !== "string" || !req.query.cwd.trim()) { + res.status(400).json({ error: "cwd must be a non-empty path" }); + return null; + } + if (!existsSync(req.query.cwd)) { + res.status(400).json({ error: `cwd does not exist: ${req.query.cwd}` }); + return null; + } + try { + if (!statSync(req.query.cwd).isDirectory()) { + res.status(400).json({ error: `cwd is not a directory: ${req.query.cwd}` }); + return null; + } + } catch (error) { + res.status(400).json({ error: `cannot access cwd ${req.query.cwd}: ${(error as Error).message}` }); + return null; + } + return req.query.cwd; + } + + app.get("/api/settings", (req, res) => { + const cwd = optionalSettingsCwd(req, res); + if (cwd === null) return; + withAnyRuntime(res, (h) => h.client.getSettings(), cwd); }); - app.get("/api/settings/models", (_req, res) => { - withAnyRuntime(res, async (h) => ({ models: await h.client.getAvailableModels() })); + app.get("/api/settings/models", (req, res) => { + const cwd = optionalSettingsCwd(req, res); + if (cwd === null) return; + withAnyRuntime(res, async (h) => ({ models: await h.client.getAvailableModels() }), cwd); }); app.get("/api/settings/agent-types", (req, res) => { @@ -963,7 +989,9 @@ export function createDashboardServer(options: DashboardServerOptions): Dashboar }); app.put("/api/settings", (req, res) => { - withAnyRuntime(res, (h) => h.client.setSettings(req.body ?? {})); + const cwd = optionalSettingsCwd(req, res); + if (cwd === null) return; + withAnyRuntime(res, (h) => h.client.setSettings(req.body ?? {}), cwd); }); app.get("/api/version", (_req, res) => { diff --git a/packages/dashboard/src/shared/protocol.ts b/packages/dashboard/src/shared/protocol.ts index bc650bd3..5e87e90a 100644 --- a/packages/dashboard/src/shared/protocol.ts +++ b/packages/dashboard/src/shared/protocol.ts @@ -458,7 +458,27 @@ export interface SettingsDto { agentModels?: Record; /** Global-only Dispatch Arbiter configuration. */ subagentArbiter?: SubagentArbiterSettingsDto | null; -} + /** Raw effective persisted patterns; absent means future-inclusive implicit all. */ + enabledModels?: string[]; + /** Effective persistent scope resolved by coding-agent core in cycling order. */ + resolvedScopedModels: ScopedModelDto[]; + /** Resolver diagnostics for legacy persisted patterns. */ + scopeWarnings: Array<{ pattern: string; message: string }>; + hasProjectEnabledModelsOverride: boolean; + enabledModelsSource: "default" | "global" | "project"; +} + +/** Dashboard settings mutation payload. Unlike a snapshot, null explicitly clears enabledModels. */ +export type SettingsUpdateDto = Partial< + Omit< + SettingsDto, + | "resolvedScopedModels" + | "scopeWarnings" + | "hasProjectEnabledModelsOverride" + | "enabledModelsSource" + | "enabledModels" + > +> & { enabledModels?: string[] | null }; export type SettingsSaveResultDto = SettingsDto & { warnings?: string[] }; diff --git a/packages/dashboard/test/client/scoped-models-editor.test.tsx b/packages/dashboard/test/client/scoped-models-editor.test.tsx new file mode 100644 index 00000000..aea08abe --- /dev/null +++ b/packages/dashboard/test/client/scoped-models-editor.test.tsx @@ -0,0 +1,263 @@ +// @vitest-environment jsdom + +import { render } from "solid-js/web/dist/web.js"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const apiMocks = vi.hoisted(() => ({ + settings: vi.fn(), + settingsModels: vi.fn(), + saveSettings: vi.fn(), +})); + +vi.mock("../../src/client/api.js", () => ({ api: apiMocks })); + +import { ScopedModelsEditor } from "../../src/client/components/scoped-models-editor.js"; +import type { ModelInfoDto, SettingsDto } from "../../src/shared/protocol.js"; + +const models: ModelInfoDto[] = [ + { provider: "anthropic", id: "sonnet", name: "Sonnet", contextWindow: 1, reasoning: true }, + { provider: "anthropic", id: "opus", name: "Opus", contextWindow: 1, reasoning: true }, + { provider: "openai", id: "gpt", name: "GPT", contextWindow: 1, reasoning: false }, +]; + +function snapshot(update: Partial = {}): SettingsDto { + return { + steeringMode: "one-at-a-time", + followUpMode: "one-at-a-time", + compactionEnabled: true, + retryEnabled: true, + resolvedScopedModels: [], + scopeWarnings: [], + hasProjectEnabledModelsOverride: false, + enabledModelsSource: "default", + ...update, + }; +} + +const disposers: Array<() => void> = []; + +async function flush(): Promise { + await Promise.resolve(); + await new Promise((resolve) => setTimeout(resolve, 0)); +} + +function mount(props: { cwd?: string; onCwdChange?: (cwd: string | undefined) => void } = {}): HTMLElement { + const root = document.createElement("div"); + document.body.append(root); + disposers.push( + render( + () => ( + {})} + /> + ), + root, + ), + ); + return root; +} + +function button(root: HTMLElement, text: string): HTMLButtonElement { + const match = [...root.querySelectorAll("button")].find( + (candidate) => candidate.textContent?.trim() === text, + ); + if (!match) throw new Error(`button not found: ${text}`); + return match; +} + +function modelCheckbox(root: HTMLElement, id: string): HTMLInputElement { + const label = [...root.querySelectorAll(".scoped-model-choice")].find((candidate) => + candidate.textContent?.includes(id), + ); + const input = label?.querySelector('input[type="checkbox"]'); + if (!input) throw new Error(`model checkbox not found: ${id}`); + return input; +} + +beforeEach(() => { + apiMocks.settings.mockReset().mockResolvedValue(snapshot()); + apiMocks.settingsModels.mockReset().mockResolvedValue({ models }); + apiMocks.saveSettings.mockReset().mockImplementation(async (update: { enabledModels?: string[] | null }) => + update.enabledModels === null + ? snapshot() + : snapshot({ + enabledModels: update.enabledModels ?? [], + enabledModelsSource: "global", + resolvedScopedModels: (update.enabledModels ?? []).map((key) => { + const [provider, ...id] = key.split("/"); + return { provider: provider!, id: id.join("/") }; + }), + }), + ); +}); + +afterEach(() => { + for (const dispose of disposers.splice(0)) dispose(); + document.body.replaceChildren(); +}); + +describe("ScopedModelsEditor", () => { + it("keeps implicit all in registry order and materializes a partial scope on disable", async () => { + const root = mount(); + await flush(); + + expect(root.querySelector(".scoped-models-order")?.textContent).toContain("All available models"); + expect(root.querySelectorAll(".scoped-model-move-controls button")[0]?.disabled).toBe(true); + + modelCheckbox(root, "opus").click(); + button(root, "save").click(); + await flush(); + + expect(apiMocks.saveSettings).toHaveBeenCalledWith( + { enabledModels: ["anthropic/sonnet", "openai/gpt"] }, + undefined, + ); + }); + + it("toggles a whole provider even when search filters its visible models", async () => { + const root = mount(); + await flush(); + const search = root.querySelector('input[type="search"]')!; + search.value = "Sonnet"; + search.dispatchEvent(new InputEvent("input", { bubbles: true })); + const providerToggle = root.querySelector( + '.scoped-model-provider-heading input[type="checkbox"]', + )!; + providerToggle.click(); + button(root, "save").click(); + await flush(); + expect(apiMocks.saveSettings).toHaveBeenCalledWith({ enabledModels: ["openai/gpt"] }, undefined); + }); + + it("saves accessible partial reordering and leaves boundary controls disabled", async () => { + apiMocks.settings.mockResolvedValue( + snapshot({ + enabledModels: ["openai/gpt", "anthropic/sonnet"], + enabledModelsSource: "global", + resolvedScopedModels: [ + { provider: "openai", id: "gpt" }, + { provider: "anthropic", id: "sonnet" }, + ], + }), + ); + const root = mount(); + await flush(); + const moveUp = root.querySelector('[aria-label="Move openai/gpt up"]')!; + expect(moveUp.disabled).toBe(true); + root.querySelector('[aria-label="Move openai/gpt down"]')!.click(); + button(root, "save").click(); + await flush(); + expect(apiMocks.saveSettings).toHaveBeenCalledWith( + { enabledModels: ["anthropic/sonnet", "openai/gpt"] }, + undefined, + ); + }); + + it("collapses a restored complete inventory to an explicit null clear", async () => { + apiMocks.settings.mockResolvedValue( + snapshot({ + enabledModels: ["anthropic/sonnet"], + enabledModelsSource: "global", + resolvedScopedModels: [{ provider: "anthropic", id: "sonnet" }], + }), + ); + const root = mount(); + await flush(); + + button(root, "enable all").click(); + button(root, "save").click(); + await flush(); + expect(apiMocks.saveSettings).toHaveBeenCalledWith({ enabledModels: null }, undefined); + }); + + it("blocks zero-model saves, supports reset, and preserves failed staged edits", async () => { + apiMocks.settings.mockResolvedValue( + snapshot({ + enabledModels: ["anthropic/sonnet"], + enabledModelsSource: "global", + resolvedScopedModels: [{ provider: "anthropic", id: "sonnet" }], + }), + ); + const root = mount(); + await flush(); + + modelCheckbox(root, "sonnet").click(); + expect(root.textContent).toContain("At least one model must remain enabled"); + expect(button(root, "save").disabled).toBe(true); + expect(apiMocks.saveSettings).not.toHaveBeenCalled(); + + button(root, "reset").click(); + modelCheckbox(root, "gpt").click(); + apiMocks.saveSettings.mockRejectedValueOnce(new Error("disk full")); + button(root, "save").click(); + await flush(); + expect(root.textContent).toContain("disk full"); + expect(modelCheckbox(root, "gpt").checked).toBe(true); + expect(root.textContent).toContain("unsaved changes"); + }); + + it("shows legacy diagnostics and returned project-shadow warnings verbatim", async () => { + apiMocks.settings.mockResolvedValue( + snapshot({ + enabledModels: ["anthropic/*", "missing"], + enabledModelsSource: "project", + hasProjectEnabledModelsOverride: true, + resolvedScopedModels: [{ provider: "anthropic", id: "sonnet" }], + scopeWarnings: [{ pattern: "missing", message: 'No models match pattern "missing"' }], + }), + ); + apiMocks.saveSettings.mockResolvedValueOnce({ + ...snapshot({ + enabledModels: ["anthropic/sonnet", "openai/gpt"], + enabledModelsSource: "project", + hasProjectEnabledModelsOverride: true, + resolvedScopedModels: [ + { provider: "anthropic", id: "sonnet" }, + { provider: "openai", id: "gpt" }, + ], + }), + warnings: ["project shadow warning, verbatim"], + }); + const root = mount({ cwd: "/project/a" }); + await flush(); + + expect(apiMocks.settings).toHaveBeenCalledWith("/project/a"); + expect(apiMocks.settingsModels).toHaveBeenCalledWith("/project/a"); + expect(root.textContent).toContain('No models match pattern "missing"'); + expect(root.textContent).toContain("Saving an edited legacy scope"); + modelCheckbox(root, "gpt").click(); + button(root, "save").click(); + await flush(); + expect(apiMocks.saveSettings).toHaveBeenCalledWith( + { enabledModels: ["anthropic/sonnet", "openai/gpt"] }, + "/project/a", + ); + expect(root.textContent).toContain("project shadow warning, verbatim"); + }); + + it("shows a loud no-inventory state and cannot save", async () => { + apiMocks.settingsModels.mockResolvedValue({ models: [] }); + const root = mount(); + await flush(); + expect(root.textContent).toContain("No available models were reported for this context"); + expect(button(root, "save").disabled).toBe(true); + expect(apiMocks.saveSettings).not.toHaveBeenCalled(); + }); + + it("filters by provider/name and reports context selection", async () => { + const onCwdChange = vi.fn(); + const root = mount({ onCwdChange }); + await flush(); + const search = root.querySelector('input[type="search"]')!; + search.value = "GPT"; + search.dispatchEvent(new InputEvent("input", { bubbles: true })); + expect(root.querySelectorAll(".scoped-model-choice")).toHaveLength(1); + + const select = root.querySelector(".scoped-models-context select")!; + select.value = "/project/b"; + select.dispatchEvent(new Event("change", { bubbles: true })); + expect(onCwdChange).toHaveBeenCalledWith("/project/b"); + }); +}); diff --git a/packages/dashboard/test/client/screens.test.tsx b/packages/dashboard/test/client/screens.test.tsx index 4e01e1c0..4bde6659 100644 --- a/packages/dashboard/test/client/screens.test.tsx +++ b/packages/dashboard/test/client/screens.test.tsx @@ -436,7 +436,10 @@ async function mountCommandComposer(commands: CommandDto[]) { const store = { ...baseStore, sessions: { k1: createSessionViewState("k1") }, - fleet: () => ({ runtimes: [], diskSessions: [] }), + fleet: () => ({ + runtimes: commands.some((command) => command.name === "scoped-models") ? [runtimeInfo("k1")] : [], + diskSessions: [], + }), hydrateSession: vi.fn(async () => {}), refreshDiskSessions: vi.fn(async () => {}), removeRuntime: vi.fn(async () => {}), @@ -2693,7 +2696,7 @@ describe("screen smoke tests", () => { expect(api.saveSettings).toHaveBeenCalledWith({ subagentArbiter: { enabled: true, model: "provider/router", thinking: "off" }, }); - expect(api.settings).toHaveBeenCalledTimes(2); + expect(api.settings).toHaveBeenCalledTimes(3); // main + scoped editor reads, then main rollback refetch expect(enabled.value).toBe("off"); expect(el.querySelector("[data-testid='dispatch-arbiter-readiness']")?.textContent).toContain("status: disabled"); }); @@ -4157,6 +4160,7 @@ describe("dashboard client regressions", () => { const mappedBuiltinCases = [ { command: "/settings", name: "settings", expected: "settings" }, + { command: "/scoped-models", name: "scoped-models", expected: "scoped-models" }, { command: "/model claude", name: "model", expected: "model" }, { command: "/export", name: "export", expected: "export" }, { command: "/import /tmp/session.jsonl", name: "import", expected: "import" }, @@ -4208,6 +4212,13 @@ describe("dashboard client regressions", () => { case "settings": expect(store.navigate).toHaveBeenCalledWith({ screen: "settings" }); break; + case "scoped-models": + expect(store.navigate).toHaveBeenCalledWith({ + screen: "settings", + target: "scoped-models", + cwd: "/home/test/project", + }); + break; case "model": expect((element.querySelector('input[placeholder="search models…"]') as HTMLInputElement).value).toBe( "claude", @@ -4296,7 +4307,7 @@ describe("dashboard client regressions", () => { expect(api.dream).toHaveBeenCalledWith("k1", args); }); - it.each(["settings", "export", "session", "fork", "tree", "new", "resume", "reload", "quit"])( + it.each(["settings", "scoped-models", "export", "session", "fork", "tree", "new", "resume", "reload", "quit"])( "rejects arguments for /%s with visible usage guidance", async (name) => { const { element, store, textarea } = await mountCommandComposer([ diff --git a/packages/dashboard/test/client/settings-layout.browser.test.ts b/packages/dashboard/test/client/settings-layout.browser.test.ts index 9bdb513f..71910f7a 100644 --- a/packages/dashboard/test/client/settings-layout.browser.test.ts +++ b/packages/dashboard/test/client/settings-layout.browser.test.ts @@ -68,6 +68,16 @@ const HARNESS_HTML = `

+
+

scoped models

+
A project-level enabledModels value shadows this global write without changing the running session.
+ +
+
+

cycling order

openrouter/${longPath}
+

available models

+
+
`; @@ -97,6 +107,8 @@ type SettingsMeasurements = { agentValueClipped: boolean; shortSelectWithinRow: boolean; shortSelectNaturalSize: boolean; + scopedContentFitsViewport: boolean; + scopedTapTargetsUsable: boolean; }; async function measurements(): Promise { @@ -137,6 +149,14 @@ async function measurements(): Promise { const nameLineHeight = Number.parseFloat(nameStyle.lineHeight); const controlRect = control.getBoundingClientRect(); const labelRect = label.getBoundingClientRect(); + const scopedElements = document.querySelectorAll( + "[data-scoped-editor], [data-scoped-editor] h2, [data-scoped-editor] h3, [data-scoped-editor] .settings-warning, [data-scoped-editor] select, [data-scoped-editor] input, [data-scoped-editor] button, [data-scoped-editor] .model-id, [data-scoped-editor] .model-name", + ); + const scopedContentFitsViewport = [...scopedElements].every((element) => { + const rect = element.getBoundingClientRect(); + return rect.left >= -tolerance && rect.right <= window.innerWidth + tolerance; + }); + const moveButtons = document.querySelectorAll(".scoped-model-move-controls button"); return { documentFits: document.documentElement.scrollWidth <= window.innerWidth + tolerance, @@ -161,6 +181,13 @@ async function measurements(): Promise { // Natural size = rendered width matches the unconstrained baseline: // neither stretched nor clipped by the new constraints. shortSelectNaturalSize: Math.abs(shortSelect.getBoundingClientRect().width - intrinsicWidth(shortSelect)) <= 2, + scopedContentFitsViewport, + scopedTapTargetsUsable: + window.innerWidth > 700 || + [...moveButtons].every((button) => { + const rect = button.getBoundingClientRect(); + return rect.width >= 44 && rect.height >= 44; + }), }; }); } @@ -172,8 +199,7 @@ async function measurementsAt(width: number): Promise { describe("settings agent-context row layout in a real browser", () => { // 701px is the first viewport where the desktop row layout applies; 1024px - // exercises the same layout with slack. (.settings-wrap caps at 720px, so - // intermediate widths add no new geometry.) + // exercises the same layout with slack. it.each([701, 1024])("keeps the label readable and the row horizontal at %ipx", async (width) => { const measured = await measurementsAt(width); @@ -200,4 +226,10 @@ describe("settings agent-context row layout in a real browser", () => { expect(measured.shortSelectWithinRow).toBe(true); expect(measured.shortSelectNaturalSize).toBe(true); }); + + it.each([360, 700, 701, 1024])("keeps scoped-model content and controls usable at %ipx", async (width) => { + const measured = await measurementsAt(width); + expect(measured.scopedContentFitsViewport).toBe(true); + expect(measured.scopedTapTargetsUsable).toBe(true); + }); }); diff --git a/packages/dashboard/test/client/store.test.ts b/packages/dashboard/test/client/store.test.ts index 8ccf839e..fe069532 100644 --- a/packages/dashboard/test/client/store.test.ts +++ b/packages/dashboard/test/client/store.test.ts @@ -28,7 +28,7 @@ import { setComposerDraft, } from "../../src/client/state/composer-memory.js"; import { MAX_COMPLETED_BACKGROUND_AGENTS } from "../../src/client/state/reducer.js"; -import { createAppStore } from "../../src/client/state/store.js"; +import { createAppStore, routeToHash } from "../../src/client/state/store.js"; let eventHandlers: EventStreamHandlers | undefined; let seq = 0; @@ -152,6 +152,20 @@ afterEach(() => { vi.clearAllMocks(); }); +describe("settings routes", () => { + it("preserves the legacy settings hash and round-trips scoped-model context", () => { + expect(routeToHash({ screen: "settings" })).toBe("#/settings"); + expect(routeToHash({ screen: "settings", target: "scoped-models", cwd: "/tmp/a b" })).toBe( + "#/settings/scoped-models?cwd=%2Ftmp%2Fa%20b", + ); + + window.location.hash = "#/settings/scoped-models?cwd=%2Ftmp%2Fa%20b"; + const store = createAppStore(); + expect(store.route()).toEqual({ screen: "settings", target: "scoped-models", cwd: "/tmp/a b" }); + store.stop(); + }); +}); + describe("composer memory", () => { it("sets and evicts per-session draft and history", () => { const key = "composer-round-trip"; diff --git a/packages/dashboard/test/runtime-pool.test.ts b/packages/dashboard/test/runtime-pool.test.ts index 12159b53..aad2370c 100644 --- a/packages/dashboard/test/runtime-pool.test.ts +++ b/packages/dashboard/test/runtime-pool.test.ts @@ -100,6 +100,10 @@ export function makeFakeClient() { autoLoadNestedContext: false, trustedContextFolders: [], effectiveTrustedContextRoots: [], + resolvedScopedModels: [], + scopeWarnings: [], + hasProjectEnabledModelsOverride: false, + enabledModelsSource: "default" as const, })), setSettings: vi.fn(async (settings: Record) => ({ defaultProvider: "test", @@ -111,6 +115,10 @@ export function makeFakeClient() { autoLoadNestedContext: false, trustedContextFolders: [], effectiveTrustedContextRoots: [], + resolvedScopedModels: [], + scopeWarnings: [], + hasProjectEnabledModelsOverride: false, + enabledModelsSource: "default" as const, ...settings, })), evaluateContextTrust: vi.fn(async (path: string) => ({ canonicalTarget: path, state: "untrusted" as const })), diff --git a/packages/dashboard/test/server.test.ts b/packages/dashboard/test/server.test.ts index 4b1954ba..7e74e98c 100644 --- a/packages/dashboard/test/server.test.ts +++ b/packages/dashboard/test/server.test.ts @@ -834,6 +834,42 @@ describe("dashboard server — fleet and runtimes", () => { expect(clients[1].listAgentTypes).toHaveBeenCalled(); }); + it("routes scoped-model settings reads, inventory, and writes through the selected cwd utility runtime", async () => { + const dir = await createTempProject(); + const { base, clients } = await startServer(); + const query = `?cwd=${encodeURIComponent(dir)}`; + + const settings = await fetch(`${base}/api/settings${query}`); + const models = await fetch(`${base}/api/settings/models${query}`); + const saved = await fetch(`${base}/api/settings${query}`, { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ enabledModels: null }), + }); + + expect(settings.status).toBe(200); + expect(models.status).toBe(200); + expect(saved.status).toBe(200); + expect(clients).toHaveLength(1); + expect(clients[0].getSettings).toHaveBeenCalled(); + expect(clients[0].getAvailableModels).toHaveBeenCalled(); + expect(clients[0].setSettings).toHaveBeenCalledWith({ enabledModels: null }); + + for (const path of ["/api/settings", "/api/settings/models"]) { + const missing = await fetch(`${base}${path}?cwd=${encodeURIComponent(`${dir}/missing`)}`); + expect(missing.status).toBe(400); + await expect(missing.json()).resolves.toEqual({ error: `cwd does not exist: ${dir}/missing` }); + } + const empty = await fetch(`${base}/api/settings?cwd=`); + expect(empty.status).toBe(400); + const file = join(dir, "not-a-directory"); + await writeFile(file, "x"); + const notDirectory = await fetch(`${base}/api/settings?cwd=${encodeURIComponent(file)}`); + expect(notDirectory.status).toBe(400); + await expect(notDirectory.json()).resolves.toEqual({ error: `cwd is not a directory: ${file}` }); + expect(clients).toHaveLength(1); + }); + it("settings model metadata endpoints use a utility runtime when no user runtime is live", async () => { const { base, clients } = await startServer(); const models = await fetch(`${base}/api/settings/models`); From a118fb851fae2011be038591d16c9388a85b9546 Mon Sep 17 00:00:00 2001 From: m-aebrer Date: Wed, 5 Aug 2026 13:59:52 -0400 Subject: [PATCH 3/9] Fix dashboard scoped-model review findings --- .../coding-agent/src/modes/rpc/rpc-mode.ts | 14 +- .../test/rpc-settings-commands.test.ts | 34 ++- .../components/scoped-models-editor.tsx | 84 +++++--- .../dashboard/src/client/screens/settings.tsx | 3 +- packages/dashboard/src/shared/protocol.ts | 23 +- .../test/client/fixtures/settings-layout.html | 12 ++ .../test/client/fixtures/settings-layout.tsx | 47 ++++ .../test/client/scoped-models-editor.test.tsx | 87 +++++++- .../dashboard/test/client/screens.test.tsx | 82 ++++++- .../client/settings-layout.browser.test.ts | 203 +++++++++--------- 10 files changed, 434 insertions(+), 155 deletions(-) create mode 100644 packages/dashboard/test/client/fixtures/settings-layout.html create mode 100644 packages/dashboard/test/client/fixtures/settings-layout.tsx diff --git a/packages/coding-agent/src/modes/rpc/rpc-mode.ts b/packages/coding-agent/src/modes/rpc/rpc-mode.ts index b40720e4..577666ed 100644 --- a/packages/coding-agent/src/modes/rpc/rpc-mode.ts +++ b/packages/coding-agent/src/modes/rpc/rpc-mode.ts @@ -41,11 +41,7 @@ import type { } from "../../core/extensions/index.js"; import { getGitBranch } from "../../core/git-branch.js"; import type { ModelRegistry } from "../../core/model-registry.js"; -import { - findExactModelReferenceMatch, - parseModelPattern, - resolveModelScopePatterns, -} from "../../core/model-resolver.js"; +import { parseModelPattern, resolveModelScopePatterns } from "../../core/model-resolver.js"; import { takeOverStdout, writeRawStdout } from "../../core/output-guard.js"; import type { SessionInfo, SessionTreeNode } from "../../core/session-manager.js"; import { SessionManager } from "../../core/session-manager.js"; @@ -935,13 +931,17 @@ export async function setSettingsForRpc( if (typeof reference !== "string" || reference.trim().length === 0 || reference !== reference.trim()) { return { ok: false, error: "Invalid enabledModels: every entry must be a non-empty exact model reference" }; } - const match = findExactModelReferenceMatch(reference, availableModels); - if (!match) { + const normalizedReference = reference.toLowerCase(); + const matches = availableModels.filter( + (model) => `${model.provider}/${model.id}`.toLowerCase() === normalizedReference, + ); + if (matches.length !== 1) { return { ok: false, error: `Invalid enabledModels entry ${JSON.stringify(reference)}: expected an available exact provider/model reference`, }; } + const match = matches[0]!; const canonical = `${match.provider}/${match.id}`; if (seen.has(canonical)) { return { ok: false, error: `Invalid enabledModels: duplicate model ${JSON.stringify(canonical)}` }; diff --git a/packages/coding-agent/test/rpc-settings-commands.test.ts b/packages/coding-agent/test/rpc-settings-commands.test.ts index 0b277c71..9cf3f89e 100644 --- a/packages/coding-agent/test/rpc-settings-commands.test.ts +++ b/packages/coding-agent/test/rpc-settings-commands.test.ts @@ -214,6 +214,36 @@ describe("getFreshSettingsForRpc", () => { }); }); + it("resolves durable legacy model patterns with the refreshed registry inventory", async () => { + const dir = await createTempDir(); + const agentDir = join(dir, "agent"); + const projectDir = join(dir, "project"); + mkdirSync(agentDir, { recursive: true }); + mkdirSync(projectDir, { recursive: true }); + writeFileSync(join(agentDir, "settings.json"), JSON.stringify({ enabledModels: ["anthropic/*", "missing"] })); + const manager = SettingsManager.create(projectDir, agentDir); + const registry = stubRegistry([ + { provider: "anthropic", id: "second" }, + { provider: "openai", id: "gpt-5" }, + { provider: "anthropic", id: "first" }, + ]); + + const result = await getFreshSettingsForRpc(manager, registry); + + expect(registry.getAvailable).toHaveBeenCalledOnce(); + expect(result).toMatchObject({ + ok: true, + settings: { + enabledModels: ["anthropic/*", "missing"], + resolvedScopedModels: [ + { provider: "anthropic", id: "second" }, + { provider: "anthropic", id: "first" }, + ], + scopeWarnings: [{ pattern: "missing", message: 'No models match pattern "missing"' }], + }, + }); + }); + it("flushes queued writes before reload so they are not discarded", async () => { const manager = SettingsManager.inMemory(); manager.setDefaultProvider("anthropic"); @@ -592,6 +622,8 @@ describe("setSettingsForRpc validation", () => { const invalidValues = [ [], ["sonnet"], + ["gpt-5"], + ["openai / gpt-5"], ["anthropic/*"], ["missing/model"], ["anthropic/claude-sonnet-4-5", "ANTHROPIC/CLAUDE-SONNET-4-5"], @@ -641,7 +673,7 @@ describe("setSettingsForRpc writes", () => { ]); const partial = await setSettingsForRpc(manager, registry, { - enabledModels: ["OPENAI/GPT-5", "org/model:exact"], + enabledModels: ["OPENAI/GPT-5", "OPENROUTER/org/model:exact"], }); expect(partial).toMatchObject({ ok: true, diff --git a/packages/dashboard/src/client/components/scoped-models-editor.tsx b/packages/dashboard/src/client/components/scoped-models-editor.tsx index 0d7e1942..2ad693dc 100644 --- a/packages/dashboard/src/client/components/scoped-models-editor.tsx +++ b/packages/dashboard/src/client/components/scoped-models-editor.tsx @@ -33,16 +33,19 @@ export function ScopedModelsEditor(props: ScopedModelsEditorProps): JSX.Element const [saveError, setSaveError] = createSignal(); const [saveWarnings, setSaveWarnings] = createSignal([]); let section: HTMLElement | undefined; + let appliedCwd: string | undefined; let appliedSettings: SettingsDto | undefined; - const [data, { mutate }] = createResource( - () => props.cwd ?? "", - async (cwd) => { - const context = cwd || undefined; - const [settings, inventory] = await Promise.all([api.settings(context), api.settingsModels(context)]); - return { settings, models: inventory.models }; - }, - ); + const contextKey = () => props.cwd ?? ""; + const [data, { mutate }] = createResource(contextKey, async (cwd) => { + const context = cwd || undefined; + const [settings, inventory] = await Promise.all([api.settings(context), api.settingsModels(context)]); + return { cwd, settings, models: inventory.models }; + }); + const currentData = createMemo(() => { + const loaded = data(); + return loaded?.cwd === contextKey() ? loaded : undefined; + }); function applySnapshot(settings: SettingsDto, models: ModelInfoDto[]): void { const all = settings.enabledModels === undefined; @@ -51,12 +54,28 @@ export function ScopedModelsEditor(props: ScopedModelsEditorProps): JSX.Element all ? models.map(modelKey) : settings.resolvedScopedModels.map((model) => `${model.provider}/${model.id}`), ); setDirty(false); + setSaved(false); setSaveError(undefined); + setSaveWarnings([]); } + let observedCwd = contextKey(); createEffect(() => { - const loaded = data(); - if (!loaded || loaded.settings === appliedSettings) return; + const cwd = contextKey(); + if (cwd === observedCwd) return; + observedCwd = cwd; + appliedCwd = undefined; + appliedSettings = undefined; + setDirty(false); + setSaved(false); + setSaveError(undefined); + setSaveWarnings([]); + }); + + createEffect(() => { + const loaded = currentData(); + if (!loaded || (loaded.cwd === appliedCwd && loaded.settings === appliedSettings)) return; + appliedCwd = loaded.cwd; appliedSettings = loaded.settings; applySnapshot(loaded.settings, loaded.models); }); @@ -75,18 +94,18 @@ export function ScopedModelsEditor(props: ScopedModelsEditorProps): JSX.Element }); const filteredGroups = createMemo(() => { const q = query().trim().toLowerCase(); - const models = data()?.models ?? []; + const models = currentData()?.models ?? []; return groupedModels( models.filter((model) => !q || `${model.provider}/${model.id} ${model.name ?? ""}`.toLowerCase().includes(q)), ); }); const validationError = createMemo(() => { - if ((data()?.models.length ?? 0) === 0) return "No available models were reported for this context."; + if ((currentData()?.models.length ?? 0) === 0) return "No available models were reported for this context."; if (!implicitAll() && ordered().length === 0) return "At least one model must remain enabled."; return undefined; }); const normalizationNotice = createMemo(() => { - const settings = data()?.settings; + const settings = currentData()?.settings; if (!settings?.enabledModels) return undefined; const canonical = settings.resolvedScopedModels.map((model) => `${model.provider}/${model.id}`); const alreadyCanonical = @@ -98,7 +117,7 @@ export function ScopedModelsEditor(props: ScopedModelsEditorProps): JSX.Element }); function markPartial(next: string[]): void { - const inventory = data()?.models.map(modelKey) ?? []; + const inventory = currentData()?.models.map(modelKey) ?? []; const allSelected = inventory.length > 0 && inventory.every((key) => next.includes(key)); setImplicitAll(allSelected); setOrdered(allSelected ? inventory : next); @@ -108,13 +127,13 @@ export function ScopedModelsEditor(props: ScopedModelsEditorProps): JSX.Element } function toggleModel(key: string): void { - const current = implicitAll() ? (data()?.models.map(modelKey) ?? []) : ordered(); + const current = implicitAll() ? (currentData()?.models.map(modelKey) ?? []) : ordered(); markPartial(current.includes(key) ? current.filter((item) => item !== key) : [...current, key]); } function toggleProvider(provider: string): void { - const keys = (data()?.models ?? []).filter((model) => model.provider === provider).map(modelKey); - const current = implicitAll() ? (data()?.models.map(modelKey) ?? []) : ordered(); + const keys = (currentData()?.models ?? []).filter((model) => model.provider === provider).map(modelKey); + const current = implicitAll() ? (currentData()?.models.map(modelKey) ?? []) : ordered(); const remove = keys.every((key) => current.includes(key)); markPartial( remove @@ -135,36 +154,35 @@ export function ScopedModelsEditor(props: ScopedModelsEditorProps): JSX.Element } function reset(): void { - const loaded = data(); + const loaded = currentData(); if (!loaded) return; applySnapshot(loaded.settings, loaded.models); - setSaveWarnings([]); - setSaved(false); } async function save(): Promise { - if (!dirty() || validationError()) return; + const loaded = currentData(); + if (!loaded || !dirty() || validationError()) return; setSaving(true); setSaveError(undefined); setSaveWarnings([]); setSaved(false); - const saveCwd = props.cwd; + const saveCwd = loaded.cwd || undefined; try { const result: SettingsSaveResultDto = await api.saveSettings( { enabledModels: implicitAll() ? null : ordered() }, saveCwd, ); - if (props.cwd !== saveCwd) return; - const loaded = data(); - if (loaded) { - appliedSettings = result; - mutate({ settings: result, models: loaded.models }); - applySnapshot(result, loaded.models); - } + if (contextKey() !== loaded.cwd) return; + appliedCwd = loaded.cwd; + appliedSettings = result; + mutate({ cwd: loaded.cwd, settings: result, models: loaded.models }); + applySnapshot(result, loaded.models); setSaveWarnings(result.warnings ?? []); setSaved(true); } catch (error) { - setSaveError(error instanceof Error ? error.message : String(error)); + if (contextKey() === loaded.cwd) { + setSaveError(error instanceof Error ? error.message : String(error)); + } } finally { setSaving(false); } @@ -199,7 +217,7 @@ export function ScopedModelsEditor(props: ScopedModelsEditorProps): JSX.Element

Loading scoped models…

- + {(loaded) => ( <> @@ -280,8 +298,8 @@ export function ScopedModelsEditor(props: ScopedModelsEditorProps): JSX.Element
diff --git a/packages/dashboard/src/client/screens/settings.tsx b/packages/dashboard/src/client/screens/settings.tsx index a4673bdb..eeb91290 100644 --- a/packages/dashboard/src/client/screens/settings.tsx +++ b/packages/dashboard/src/client/screens/settings.tsx @@ -3,7 +3,17 @@ * shown verbatim) + paired-devices management + version footer. */ -import { createMemo, createResource, createSignal, For, type JSX, onCleanup, onMount, Show } from "solid-js"; +import { + createEffect, + createMemo, + createResource, + createSignal, + For, + type JSX, + onCleanup, + onMount, + Show, +} from "solid-js"; import type { AgentTypeDto, ModelInfoDto, @@ -179,7 +189,7 @@ function ModelPickerModal(props: { export function SettingsScreen(props: { store: AppStore; target?: "scoped-models"; - initialScopedModelsCwd?: string; + routeScopedModelsCwd?: string; }): JSX.Element { const [error, setError] = createSignal(); const [warnings, setWarnings] = createSignal([]); @@ -187,7 +197,12 @@ export function SettingsScreen(props: { const [modelPickerTarget, setModelPickerTarget] = createSignal(); const [editingAgent, setEditingAgent] = createSignal(); const [agentContextCwd, setAgentContextCwd] = createSignal(); - const [scopedModelsCwd, setScopedModelsCwd] = createSignal(props.initialScopedModelsCwd); + const [scopedModelsCwd, setScopedModelsCwd] = createSignal( + props.target === "scoped-models" ? props.routeScopedModelsCwd : undefined, + ); + createEffect(() => { + setScopedModelsCwd(props.target === "scoped-models" ? props.routeScopedModelsCwd : undefined); + }); const [trustedContextFolderPath, setTrustedContextFolderPath] = createSignal(""); const [contextTrustMutating, setContextTrustMutating] = createSignal(false); const [notificationPermission, setNotificationPermission] = createSignal< diff --git a/packages/dashboard/test/client/api.test.ts b/packages/dashboard/test/client/api.test.ts index b1ba86f9..f0c7fae1 100644 --- a/packages/dashboard/test/client/api.test.ts +++ b/packages/dashboard/test/client/api.test.ts @@ -2,6 +2,7 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { + api, connectEvents, dashboardImageUrl, type EventConnectionStatus, @@ -98,7 +99,10 @@ function setup(overrides: Partial = {}) { }; } -afterEach(() => vi.useRealTimers()); +afterEach(() => { + vi.useRealTimers(); + vi.unstubAllGlobals(); +}); describe("dashboardImageUrl", () => { it("builds encoded same-origin parent and subagent URLs only for validated IDs", () => { @@ -111,6 +115,51 @@ describe("dashboardImageUrl", () => { }); }); +describe("settings project context transport", () => { + it("encodes cwd for settings reads and writes while preserving global URLs", async () => { + const fetchMock = vi.fn().mockImplementation( + async () => + new Response("{}", { + status: 200, + headers: { "content-type": "application/json" }, + }), + ); + vi.stubGlobal("fetch", fetchMock); + const cwd = "/projects/one space?branch=a&mode=b#scope"; + const encoded = "%2Fprojects%2Fone%20space%3Fbranch%3Da%26mode%3Db%23scope"; + + await api.settings(cwd); + await api.settingsModels(cwd); + await api.saveSettings({ enabledModels: ["openai/gpt"] }, cwd); + await api.settings(); + await api.settingsModels(); + await api.saveSettings({ enabledModels: null }); + + expect(fetchMock.mock.calls).toEqual([ + [`/api/settings?cwd=${encoded}`, undefined], + [`/api/settings/models?cwd=${encoded}`, undefined], + [ + `/api/settings?cwd=${encoded}`, + { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ enabledModels: ["openai/gpt"] }), + }, + ], + ["/api/settings", undefined], + ["/api/settings/models", undefined], + [ + "/api/settings", + { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ enabledModels: null }), + }, + ], + ]); + }); +}); + describe("connectEvents lifecycle", () => { it("applies before advancing the cursor and preserves it across a guarded retry", async () => { vi.useFakeTimers(); diff --git a/packages/dashboard/test/client/scoped-models-editor.test.tsx b/packages/dashboard/test/client/scoped-models-editor.test.tsx index 3cc34462..726c12a7 100644 --- a/packages/dashboard/test/client/scoped-models-editor.test.tsx +++ b/packages/dashboard/test/client/scoped-models-editor.test.tsx @@ -13,7 +13,7 @@ const apiMocks = vi.hoisted(() => ({ vi.mock("../../src/client/api.js", () => ({ api: apiMocks })); import { ScopedModelsEditor } from "../../src/client/components/scoped-models-editor.js"; -import type { ModelInfoDto, SettingsDto } from "../../src/shared/protocol.js"; +import type { ModelInfoDto, SettingsDto, SettingsSaveResultDto } from "../../src/shared/protocol.js"; const models: ModelInfoDto[] = [ { provider: "anthropic", id: "sonnet", name: "Sonnet", contextWindow: 1, reasoning: true }, @@ -42,6 +42,16 @@ async function flush(): Promise { await new Promise((resolve) => setTimeout(resolve, 0)); } +function deferred() { + let resolve!: (value: T) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((resolvePromise, rejectPromise) => { + resolve = resolvePromise; + reject = rejectPromise; + }); + return { promise, resolve, reject }; +} + function mount(props: { cwd?: string; onCwdChange?: (cwd: string | undefined) => void } = {}): HTMLElement { const root = document.createElement("div"); document.body.append(root); @@ -412,6 +422,86 @@ describe("ScopedModelsEditor", () => { expect(root.querySelector(".scoped-models-order")?.textContent).toContain("openai/gpt"); }); + it.each(["resolve", "reject"] as const)( + "ignores a project save that %s after another project loads", + async (outcome) => { + const projectA = snapshot({ + enabledModels: ["anthropic/sonnet"], + enabledModelsSource: "project", + hasProjectEnabledModelsOverride: true, + resolvedScopedModels: [{ provider: "anthropic", id: "sonnet" }], + }); + const projectB = snapshot({ + enabledModels: ["openai/gpt"], + enabledModelsSource: "project", + hasProjectEnabledModelsOverride: true, + resolvedScopedModels: [{ provider: "openai", id: "gpt" }], + }); + apiMocks.settings.mockImplementation((cwd?: string) => + Promise.resolve(cwd === "/project/b" ? projectB : projectA), + ); + const pendingSave = deferred(); + apiMocks.saveSettings.mockReturnValueOnce(pendingSave.promise); + + const [cwd, setCwd] = createSignal("/project/a"); + const root = document.createElement("div"); + document.body.append(root); + disposers.push( + render( + () => ( + + ), + root, + ), + ); + await flush(); + + modelCheckbox(root, "gpt").click(); + button(root, "save").click(); + expect(apiMocks.saveSettings).toHaveBeenCalledWith( + { enabledModels: ["anthropic/sonnet", "openai/gpt"] }, + "/project/a", + ); + + setCwd("/project/b"); + await flush(); + expect(root.querySelector(".scoped-models-order")?.textContent).toContain("openai/gpt"); + expect(root.querySelector(".scoped-models-order")?.textContent).not.toContain("anthropic/sonnet"); + + if (outcome === "resolve") { + pendingSave.resolve({ + ...snapshot({ + enabledModels: ["anthropic/opus"], + enabledModelsSource: "project", + hasProjectEnabledModelsOverride: true, + resolvedScopedModels: [{ provider: "anthropic", id: "opus" }], + }), + warnings: ["late project a warning"], + }); + } else { + pendingSave.reject(new Error("late project a failure")); + } + await flush(); + + expect(root.querySelector(".scoped-models-order")?.textContent).toContain("openai/gpt"); + expect(root.querySelector(".scoped-models-order")?.textContent).not.toContain("anthropic/sonnet"); + expect(root.querySelector(".scoped-models-order")?.textContent).not.toContain("anthropic/opus"); + expect(modelCheckbox(root, "gpt").checked).toBe(true); + expect(modelCheckbox(root, "sonnet").checked).toBe(false); + expect(root.textContent).not.toContain("late project a warning"); + expect(root.textContent).not.toContain("late project a failure"); + expect(root.textContent).not.toContain("unsaved changes"); + expect(root.textContent).not.toContain("✓ saved"); + + modelCheckbox(root, "sonnet").click(); + expect(root.textContent).toContain("unsaved changes"); + button(root, "reset").click(); + expect(modelCheckbox(root, "gpt").checked).toBe(true); + expect(modelCheckbox(root, "sonnet").checked).toBe(false); + expect(root.textContent).not.toContain("unsaved changes"); + }, + ); + it("shows a loud no-inventory state and cannot save", async () => { apiMocks.settingsModels.mockResolvedValue({ models: [] }); const root = mount(); diff --git a/packages/dashboard/test/client/screens.test.tsx b/packages/dashboard/test/client/screens.test.tsx index 2f88b9a7..c1ff5336 100644 --- a/packages/dashboard/test/client/screens.test.tsx +++ b/packages/dashboard/test/client/screens.test.tsx @@ -3054,21 +3054,31 @@ describe("screen smoke tests", () => { window.location.hash = "#/settings/scoped-models?cwd=%2Fproject%2Fa"; const el = mount(() => ); - await new Promise((resolve) => setTimeout(resolve, 25)); - expect(api.settings).toHaveBeenCalledWith("/project/a"); - expect(api.settingsModels).toHaveBeenCalledWith("/project/a"); + await vi.waitFor(() => { + expect(api.settings).toHaveBeenCalledWith("/project/a"); + expect(api.settingsModels).toHaveBeenCalledWith("/project/a"); + }); const context = el.querySelector(".scoped-models-context select")!; expect(context.value).toBe("/project/a"); - expect(context.querySelector('option[value="/project/b"]')).not.toBeNull(); + await vi.waitFor(() => expect(context.querySelector('option[value="/project/b"]')).not.toBeNull()); context.value = "/project/b"; context.dispatchEvent(new Event("change", { bubbles: true })); - await new Promise((resolve) => setTimeout(resolve, 25)); - expect(window.location.hash).toContain("cwd=%2Fproject%2Fb"); - expect(api.settings).toHaveBeenCalledWith("/project/b"); - expect(api.settingsModels).toHaveBeenCalledWith("/project/b"); + await vi.waitFor(() => { + expect(window.location.hash).toContain("cwd=%2Fproject%2Fb"); + expect(api.settings).toHaveBeenCalledWith("/project/b"); + expect(api.settingsModels).toHaveBeenCalledWith("/project/b"); + expect(context.value).toBe("/project/b"); + }); + await vi.waitFor(() => + expect( + [...el.querySelectorAll(".scoped-model-choice")].some((label) => + label.textContent?.includes("sonnet"), + ), + ).toBe(true), + ); const sonnet = [...el.querySelectorAll(".scoped-model-choice")].find((label) => label.textContent?.includes("sonnet"), )!; @@ -3076,13 +3086,31 @@ describe("screen smoke tests", () => { [...el.querySelectorAll("button")] .find((candidate) => candidate.textContent?.trim() === "save")! .click(); - await new Promise((resolve) => setTimeout(resolve, 10)); + await vi.waitFor(() => { + expect(api.saveSettings).toHaveBeenCalledWith( + { enabledModels: ["openai/gpt", "anthropic/sonnet"] }, + "/project/b", + ); + expect(el.textContent).toContain("project b shadow warning"); + }); - expect(api.saveSettings).toHaveBeenCalledWith( - { enabledModels: ["openai/gpt", "anthropic/sonnet"] }, - "/project/b", - ); - expect(el.textContent).toContain("project b shadow warning"); + const callsBeforeDirectRoute = vi.mocked(api.settingsModels).mock.calls.length; + window.location.hash = "#/settings/scoped-models?cwd=%2Fproject%2Fa"; + window.dispatchEvent(new HashChangeEvent("hashchange")); + await vi.waitFor(() => { + expect(context.value).toBe("/project/a"); + expect(api.settingsModels).toHaveBeenCalledTimes(callsBeforeDirectRoute + 1); + expect(api.settingsModels).toHaveBeenLastCalledWith("/project/a"); + }); + + const callsBeforeGlobalRoute = vi.mocked(api.settingsModels).mock.calls.length; + window.location.hash = "#/settings"; + window.dispatchEvent(new HashChangeEvent("hashchange")); + await vi.waitFor(() => { + expect(context.value).toBe(""); + expect(api.settingsModels).toHaveBeenCalledTimes(callsBeforeGlobalRoute + 1); + expect(api.settingsModels).toHaveBeenLastCalledWith(undefined); + }); }); it("pairing renders the PIN flow with both security copy blocks", () => { From d8ad940af7e463a9c1f3ac87aa59475257f62ff8 Mon Sep 17 00:00:00 2001 From: m-aebrer Date: Thu, 6 Aug 2026 09:35:31 -0400 Subject: [PATCH 6/9] Stabilize browser layout test setup --- packages/dashboard/test/client/settings-layout.browser.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/dashboard/test/client/settings-layout.browser.test.ts b/packages/dashboard/test/client/settings-layout.browser.test.ts index 98df70dc..124bb411 100644 --- a/packages/dashboard/test/client/settings-layout.browser.test.ts +++ b/packages/dashboard/test/client/settings-layout.browser.test.ts @@ -101,7 +101,7 @@ beforeEach(async () => { await page.setViewportSize({ width: 1024, height: 800 }); await page.goto(`${baseUrl}/test/client/fixtures/settings-layout.html`, { waitUntil: "domcontentloaded" }); await page.locator(".scoped-models-grid").waitFor({ state: "visible" }); -}); +}, 60_000); type SettingsMeasurements = { documentFits: boolean; From caa9ba3a0975bca80e9cc8cd07338e9e30afa1ba Mon Sep 17 00:00:00 2001 From: m-aebrer Date: Thu, 6 Aug 2026 09:37:42 -0400 Subject: [PATCH 7/9] chore: bump version to 2.54.0 --- package-lock.json | 18 +++++++++--------- package.json | 2 +- packages/agent/package.json | 2 +- packages/ai/package.json | 2 +- packages/coding-agent/package.json | 2 +- packages/dashboard/package.json | 2 +- .../semantic-search/.claude-plugin/plugin.json | 2 +- packages/semantic-search/package.json | 2 +- packages/telegram/package.json | 2 +- packages/tui/package.json | 2 +- 10 files changed, 18 insertions(+), 18 deletions(-) diff --git a/package-lock.json b/package-lock.json index 6508daf0..7f29cee7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "dreb", - "version": "2.53.1", + "version": "2.54.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "dreb", - "version": "2.53.1", + "version": "2.54.0", "workspaces": [ "packages/*", "packages/coding-agent/examples/extensions/with-deps", @@ -10955,7 +10955,7 @@ }, "packages/agent": { "name": "@dreb/agent-core", - "version": "2.53.1", + "version": "2.54.0", "license": "MIT", "dependencies": { "@dreb/ai": "*" @@ -10984,7 +10984,7 @@ }, "packages/ai": { "name": "@dreb/ai", - "version": "2.53.1", + "version": "2.54.0", "license": "MIT", "dependencies": { "@anthropic-ai/sdk": "^0.73.0", @@ -11040,7 +11040,7 @@ }, "packages/coding-agent": { "name": "@dreb/coding-agent", - "version": "2.53.1", + "version": "2.54.0", "license": "MIT", "dependencies": { "@dreb/agent-core": "*", @@ -11169,7 +11169,7 @@ }, "packages/dashboard": { "name": "@dreb/dashboard", - "version": "2.53.1", + "version": "2.54.0", "license": "MIT", "dependencies": { "@dreb/coding-agent": "*", @@ -11401,7 +11401,7 @@ }, "packages/semantic-search": { "name": "@dreb/semantic-search", - "version": "2.53.1", + "version": "2.54.0", "license": "MIT", "dependencies": { "@huggingface/transformers": "^4.0.1", @@ -11450,7 +11450,7 @@ }, "packages/telegram": { "name": "@dreb/telegram", - "version": "2.53.1", + "version": "2.54.0", "license": "MIT", "dependencies": { "@dreb/coding-agent": "*", @@ -11483,7 +11483,7 @@ }, "packages/tui": { "name": "@dreb/tui", - "version": "2.53.1", + "version": "2.54.0", "license": "MIT", "dependencies": { "@types/mime-types": "^2.1.4", diff --git a/package.json b/package.json index 39e34ac6..c26f46b3 100644 --- a/package.json +++ b/package.json @@ -39,7 +39,7 @@ "node": "22.x" }, "packageManager": "npm@11.5.1", - "version": "2.53.1", + "version": "2.54.0", "dependencies": { "@dreb/coding-agent": "*", "@mariozechner/jiti": "^2.6.5", diff --git a/packages/agent/package.json b/packages/agent/package.json index 0f61effb..2cfcb5cb 100644 --- a/packages/agent/package.json +++ b/packages/agent/package.json @@ -1,6 +1,6 @@ { "name": "@dreb/agent-core", - "version": "2.53.1", + "version": "2.54.0", "description": "General-purpose agent with transport abstraction, state management, and attachment support", "type": "module", "main": "./dist/index.js", diff --git a/packages/ai/package.json b/packages/ai/package.json index 64f5f680..2b2127bc 100644 --- a/packages/ai/package.json +++ b/packages/ai/package.json @@ -1,6 +1,6 @@ { "name": "@dreb/ai", - "version": "2.53.1", + "version": "2.54.0", "description": "Unified LLM API with automatic model discovery and provider configuration", "type": "module", "main": "./dist/index.js", diff --git a/packages/coding-agent/package.json b/packages/coding-agent/package.json index d266d9bf..85e59127 100644 --- a/packages/coding-agent/package.json +++ b/packages/coding-agent/package.json @@ -1,6 +1,6 @@ { "name": "@dreb/coding-agent", - "version": "2.53.1", + "version": "2.54.0", "description": "Coding agent CLI with read, bash, edit, write tools and session management", "type": "module", "drebConfig": { diff --git a/packages/dashboard/package.json b/packages/dashboard/package.json index d7b4efaf..39269041 100644 --- a/packages/dashboard/package.json +++ b/packages/dashboard/package.json @@ -1,6 +1,6 @@ { "name": "@dreb/dashboard", - "version": "2.53.1", + "version": "2.54.0", "description": "Web dashboard for dreb — fleet overview, chat parity, subagent observability", "license": "MIT", "type": "module", diff --git a/packages/semantic-search/.claude-plugin/plugin.json b/packages/semantic-search/.claude-plugin/plugin.json index 5a411d1e..75ad4dc3 100644 --- a/packages/semantic-search/.claude-plugin/plugin.json +++ b/packages/semantic-search/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "semantic-search", "description": "Semantic codebase search — natural language queries over code and docs using embeddings, tree-sitter parsing, and POEM multi-signal ranking", - "version": "2.53.1", + "version": "2.54.0", "author": { "name": "Drew Brereton" }, diff --git a/packages/semantic-search/package.json b/packages/semantic-search/package.json index 27ece397..bce6b373 100644 --- a/packages/semantic-search/package.json +++ b/packages/semantic-search/package.json @@ -1,6 +1,6 @@ { "name": "@dreb/semantic-search", - "version": "2.53.1", + "version": "2.54.0", "description": "Semantic codebase search engine with embedding-based ranking and MCP server", "publishConfig": { "access": "public" diff --git a/packages/telegram/package.json b/packages/telegram/package.json index 7168a30f..55dc31a2 100644 --- a/packages/telegram/package.json +++ b/packages/telegram/package.json @@ -1,6 +1,6 @@ { "name": "@dreb/telegram", - "version": "2.53.1", + "version": "2.54.0", "description": "Telegram bot frontend for dreb coding agent", "license": "MIT", "type": "module", diff --git a/packages/tui/package.json b/packages/tui/package.json index 2a715e23..8bad0a56 100644 --- a/packages/tui/package.json +++ b/packages/tui/package.json @@ -1,6 +1,6 @@ { "name": "@dreb/tui", - "version": "2.53.1", + "version": "2.54.0", "description": "Terminal User Interface library with differential rendering for efficient text-based applications", "type": "module", "main": "dist/index.js", From c496342a16f7f691c06859c490cafbc4e129cdf2 Mon Sep 17 00:00:00 2001 From: m-aebrer Date: Thu, 6 Aug 2026 09:48:00 -0400 Subject: [PATCH 8/9] Stabilize browser tests under coverage load --- .../dashboard/test/client/fleet-mobile.browser.test.ts | 8 ++++---- .../test/client/settings-layout.browser.test.ts | 9 ++++++--- 2 files changed, 10 insertions(+), 7 deletions(-) diff --git a/packages/dashboard/test/client/fleet-mobile.browser.test.ts b/packages/dashboard/test/client/fleet-mobile.browser.test.ts index efebf81b..408f5850 100644 --- a/packages/dashboard/test/client/fleet-mobile.browser.test.ts +++ b/packages/dashboard/test/client/fleet-mobile.browser.test.ts @@ -192,11 +192,11 @@ describe("mobile fleet SSE snapshots in a throttled real browser", () => { } }); - await page.goto(baseUrl, { waitUntil: "domcontentloaded" }); + await page.goto(baseUrl, { waitUntil: "domcontentloaded", timeout: 60_000 }); const card = page.locator("article.session-card"); - await card.waitFor({ state: "visible", timeout: 10_000 }); + await card.waitFor({ state: "visible", timeout: 60_000 }); expect(await card.textContent()).toContain("mobile acceptance session"); - await page.locator(".connection-indicator .chip-idle").waitFor({ state: "visible", timeout: 10_000 }); + await page.locator(".connection-indicator .chip-idle").waitFor({ state: "visible", timeout: 30_000 }); expect(requests.filter((request) => request.url === "/api/fleet")).toHaveLength(1); await card.locator(".chip-idle").waitFor({ state: "visible" }); @@ -241,5 +241,5 @@ describe("mobile fleet SSE snapshots in a throttled real browser", () => { await context?.close(); await browser.close(); } - }, 30_000); + }, 90_000); }); diff --git a/packages/dashboard/test/client/settings-layout.browser.test.ts b/packages/dashboard/test/client/settings-layout.browser.test.ts index 124bb411..21dcc8ea 100644 --- a/packages/dashboard/test/client/settings-layout.browser.test.ts +++ b/packages/dashboard/test/client/settings-layout.browser.test.ts @@ -99,9 +99,12 @@ afterAll(async () => { beforeEach(async () => { await page.setViewportSize({ width: 1024, height: 800 }); - await page.goto(`${baseUrl}/test/client/fixtures/settings-layout.html`, { waitUntil: "domcontentloaded" }); - await page.locator(".scoped-models-grid").waitFor({ state: "visible" }); -}, 60_000); + await page.goto(`${baseUrl}/test/client/fixtures/settings-layout.html`, { + waitUntil: "domcontentloaded", + timeout: 60_000, + }); + await page.locator(".scoped-models-grid").waitFor({ state: "visible", timeout: 60_000 }); +}, 90_000); type SettingsMeasurements = { documentFits: boolean; From 7803c0932e689c28a879f6178526cf2135437ad1 Mon Sep 17 00:00:00 2001 From: m-aebrer Date: Thu, 6 Aug 2026 09:55:01 -0400 Subject: [PATCH 9/9] docs: canonicalize scoped model example --- packages/coding-agent/docs/settings.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/coding-agent/docs/settings.md b/packages/coding-agent/docs/settings.md index 61f7ba1d..a8e9a57c 100644 --- a/packages/coding-agent/docs/settings.md +++ b/packages/coding-agent/docs/settings.md @@ -414,7 +414,7 @@ See [packages.md](packages.md) for package management details. "enabled": true, "maxRetries": 3 }, - "enabledModels": ["claude-*", "gpt-4o"], + "enabledModels": ["anthropic/claude-sonnet-4-5", "openai/gpt-5"], "packages": ["dreb-skills"] } ```