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/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/ai/test/abort.test.ts b/packages/ai/test/abort.test.ts index 2f456a5a..e31d85d1 100644 --- a/packages/ai/test/abort.test.ts +++ b/packages/ai/test/abort.test.ts @@ -172,7 +172,7 @@ describe("AI Providers Abort Tests", () => { describe.skipIf( process.env.DREB_SKIP_LIVE_API === "1" || (!process.env.ANTHROPIC_API_KEY && !process.env.ANTHROPIC_OAUTH_TOKEN), )("Anthropic Provider Abort", () => { - const llm = getModel("anthropic", "claude-opus-4-1-20250805"); + const llm = getModel("anthropic", "claude-opus-4-6"); it("should abort mid-stream", { retry: 3 }, async () => { await testAbortSignal(llm, { thinkingEnabled: true, thinkingBudgetTokens: 2048 }); diff --git a/packages/ai/test/supports-xhigh.test.ts b/packages/ai/test/supports-xhigh.test.ts index 5517b461..6512e9c4 100644 --- a/packages/ai/test/supports-xhigh.test.ts +++ b/packages/ai/test/supports-xhigh.test.ts @@ -37,9 +37,8 @@ describe("supportsXhigh", () => { }); it("returns false for Opus 4.1 (below threshold)", () => { - const model = getModel("anthropic", "claude-opus-4-1"); - expect(model).toBeDefined(); - expect(supportsXhigh(model!)).toBe(false); + const base = getModel("anthropic", "claude-opus-4-6"); + expect(supportsXhigh({ ...base, id: "claude-opus-4-1" })).toBe(false); }); it("returns false for Opus 4.5 (below threshold)", () => { 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..a8e9a57c 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 | @@ -410,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"] } ``` 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/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..577666ed 100644 --- a/packages/coding-agent/src/modes/rpc/rpc-mode.ts +++ b/packages/coding-agent/src/modes/rpc/rpc-mode.ts @@ -41,7 +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 { parseModelPattern } 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"; @@ -386,6 +386,8 @@ type SettingsReader = Pick< | "getHideThinkingBlock" | "getAgentModels" | "getGlobalSubagentArbiterSettings" + | "getEnabledModels" + | "hasProjectEnabledModelsOverride" >; type SettingsRefresher = SettingsReader & @@ -416,6 +418,7 @@ type SettingsWriter = SettingsRefresher & | "removeAgentModelsForAgent" | "hasProjectAgentModelOverride" | "setGlobalSubagentArbiterSettings" + | "setEnabledModels" >; /** @@ -426,9 +429,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 +456,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 +496,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 +534,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 +559,7 @@ const SETTINGS_UPDATE_KEYS = [ "transport", "hideThinkingBlock", "agentModels", + "enabledModels", "subagentArbiter", ] as const; @@ -618,6 +656,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 +689,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 +701,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 +716,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 +734,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 +748,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 +762,7 @@ export async function untrustContextFolderForRpc( roots.filter((root) => root !== removedRoot), evaluated.evaluation.canonicalTarget, { removedRoot }, + modelRegistry, ); }); } @@ -716,6 +771,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 +805,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 +909,55 @@ 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 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)}` }; + } + 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..e94e2a1e 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", () => { @@ -179,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"); @@ -535,7 +600,7 @@ describe("setSettingsForRpc validation", () => { }, }, }); - expect(registry.getAvailable).not.toHaveBeenCalled(); + expect(registry.getAvailable).toHaveBeenCalledTimes(1); const disabledPolicy = { enabled: false, model: "malformed-model-id", @@ -551,6 +616,70 @@ 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"], + ["gpt-5"], + ["openai / gpt-5"], + ["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("does not persist valid enabledModels when a later field is invalid", async () => { + const dir = await createTempDir(); + const agentDir = join(dir, "agent"); + const projectDir = join(dir, "project"); + const settingsPath = join(agentDir, "settings.json"); + mkdirSync(agentDir, { recursive: true }); + mkdirSync(projectDir, { recursive: true }); + writeFileSync( + settingsPath, + JSON.stringify({ enabledModels: ["anthropic/claude-sonnet-4-5"], retry: { enabled: true } }), + ); + const manager = SettingsManager.create(projectDir, agentDir); + + const result = await setSettingsForRpc( + manager, + stubRegistry([anthropicSonnet, { provider: "openai", id: "gpt-5" }]), + { + enabledModels: ["openai/gpt-5"], + retryEnabled: false, + defaultProvider: "anthropic", + }, + ); + + expect(result).toMatchObject({ ok: false, error: expect.stringContaining("must be set together") }); + expect(manager.getEnabledModels()).toEqual(["anthropic/claude-sonnet-4-5"]); + expect(manager.getRetryEnabled()).toBe(true); + expect(JSON.parse(readFileSync(settingsPath, "utf8"))).toEqual({ + enabledModels: ["anthropic/claude-sonnet-4-5"], + retry: { enabled: 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 +696,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", "OPENROUTER/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 +866,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 +1011,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 +1660,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/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/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..3d577508 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..52c10c67 --- /dev/null +++ b/packages/dashboard/src/client/components/scoped-models-editor.tsx @@ -0,0 +1,358 @@ +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 appliedCwd: string | undefined; + let appliedSettings: SettingsDto | undefined; + + 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(() => { + if (data.error) return undefined; + const loaded = data(); + return loaded?.cwd === contextKey() ? loaded : undefined; + }); + + 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); + setSaved(false); + setSaveError(undefined); + setSaveWarnings([]); + } + + let observedCwd = contextKey(); + createEffect(() => { + 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); + }); + + 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 = currentData()?.models ?? []; + return groupedModels( + models.filter((model) => !q || `${model.provider}/${model.id} ${model.name ?? ""}`.toLowerCase().includes(q)), + ); + }); + const validationError = createMemo(() => { + 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 = currentData()?.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 = currentData()?.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() ? (currentData()?.models.map(modelKey) ?? []) : ordered(); + markPartial(current.includes(key) ? current.filter((item) => item !== key) : [...current, key]); + } + + function toggleProvider(provider: string): void { + 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 + ? 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 = currentData(); + if (!loaded) return; + applySnapshot(loaded.settings, loaded.models); + } + + async function save(): Promise { + const loaded = currentData(); + if (!loaded || !dirty() || validationError()) return; + setSaving(true); + setSaveError(undefined); + setSaveWarnings([]); + setSaved(false); + const saveCwd = loaded.cwd || undefined; + try { + const result: SettingsSaveResultDto = await api.saveSettings( + { enabledModels: implicitAll() ? null : ordered() }, + saveCwd, + ); + 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) { + if (contextKey() === loaded.cwd) { + 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..eeb91290 100644 --- a/packages/dashboard/src/client/screens/settings.tsx +++ b/packages/dashboard/src/client/screens/settings.tsx @@ -3,16 +3,28 @@ * 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, PairingCodeDto, SettingsDto, + SettingsUpdateDto, SubagentArbiterSettingsDto, } 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 +186,23 @@ function ModelPickerModal(props: { ); } -export function SettingsScreen(props: { store: AppStore }): JSX.Element { +export function SettingsScreen(props: { + store: AppStore; + target?: "scoped-models"; + routeScopedModelsCwd?: 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.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< @@ -297,7 +319,7 @@ export function SettingsScreen(props: { store: AppStore }): JSX.Element { } } - async function save(update: Partial) { + async function save(update: SettingsUpdateDto) { setError(undefined); setWarnings([]); setSaved(false); @@ -516,6 +538,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..841fda82 100644 --- a/packages/dashboard/src/shared/protocol.ts +++ b/packages/dashboard/src/shared/protocol.ts @@ -458,7 +458,38 @@ 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< + Pick< + SettingsDto, + | "defaultProvider" + | "defaultModel" + | "defaultThinkingLevel" + | "steeringMode" + | "followUpMode" + | "compactionEnabled" + | "retryEnabled" + | "imageAutoResize" + | "blockImages" + | "enableSkillCommands" + | "autoLoadNestedContext" + | "trustedContextFolders" + | "transport" + | "hideThinkingBlock" + | "agentModels" + | "subagentArbiter" + > +> & { enabledModels?: string[] | null }; export type SettingsSaveResultDto = SettingsDto & { warnings?: string[] }; 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/fixtures/settings-layout.html b/packages/dashboard/test/client/fixtures/settings-layout.html new file mode 100644 index 00000000..85670887 --- /dev/null +++ b/packages/dashboard/test/client/fixtures/settings-layout.html @@ -0,0 +1,12 @@ + + + + + + Settings layout fixture + + +

+ + + diff --git a/packages/dashboard/test/client/fixtures/settings-layout.tsx b/packages/dashboard/test/client/fixtures/settings-layout.tsx new file mode 100644 index 00000000..2e43ffac --- /dev/null +++ b/packages/dashboard/test/client/fixtures/settings-layout.tsx @@ -0,0 +1,47 @@ +import { render } from "solid-js/web"; +import { ScopedModelsEditor } from "../../../src/client/components/scoped-models-editor.js"; +import "../../../src/client/styles/tokens.css"; +import "../../../src/client/styles/app.css"; +import "../../../src/client/styles/themes.css"; + +const longPath = `/home/actors/${"deeply-nested/project-layer/".repeat(4)}dreb`; + +function LayoutFixture() { + return ( +
+
+

agent models

+
+ + agent definition context + choose a project to include its .dreb/agents definitions + + + + +
+
+ + appearance + short control regression guard + + + + +
+
+ {}} /> +
+ ); +} + +render(() => , document.getElementById("root")!); 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/scoped-models-editor.test.tsx b/packages/dashboard/test/client/scoped-models-editor.test.tsx new file mode 100644 index 00000000..726c12a7 --- /dev/null +++ b/packages/dashboard/test/client/scoped-models-editor.test.tsx @@ -0,0 +1,528 @@ +// @vitest-environment jsdom + +import { createSignal } from "solid-js"; +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, SettingsSaveResultDto } 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 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); + 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("keeps a persisted legacy empty scope visibly invalid through reset", async () => { + apiMocks.settings.mockResolvedValue( + snapshot({ + enabledModels: [], + enabledModelsSource: "global", + resolvedScopedModels: [], + }), + ); + const root = mount(); + await flush(); + + expect(root.querySelector(".scoped-models-order")?.textContent).not.toContain("All available models"); + expect(root.querySelector(".scoped-models-order")?.textContent).toContain("0 enabled models"); + expect(root.textContent).toContain("At least one model must remain enabled"); + expect(button(root, "save").disabled).toBe(true); + + modelCheckbox(root, "sonnet").click(); + expect(root.textContent).not.toContain("At least one model must remain enabled"); + button(root, "reset").click(); + + expect(root.querySelector(".scoped-models-order")?.textContent).not.toContain("All available models"); + expect(root.querySelector(".scoped-models-order")?.textContent).toContain("0 enabled models"); + expect(root.textContent).toContain("At least one model must remain enabled"); + expect(button(root, "save").disabled).toBe(true); + expect(apiMocks.saveSettings).not.toHaveBeenCalled(); + }); + + 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: ["openai/gpt"], + enabledModelsSource: "project", + hasProjectEnabledModelsOverride: true, + resolvedScopedModels: [{ 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"); + expect(root.querySelector(".scoped-models-order")?.textContent).toContain("openai/gpt"); + expect(root.querySelector(".scoped-models-order")?.textContent).not.toContain("anthropic/sonnet"); + expect(modelCheckbox(root, "gpt").checked).toBe(true); + expect(modelCheckbox(root, "sonnet").checked).toBe(false); + expect(root.textContent).not.toContain("unsaved changes"); + + 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.each(["settings", "inventory"] as const)( + "shows initial %s load failures verbatim without exposing controls", + async (failure) => { + const message = `${failure} unavailable`; + if (failure === "settings") apiMocks.settings.mockRejectedValue(new Error(message)); + else apiMocks.settingsModels.mockRejectedValue(new Error(message)); + + const root = mount(); + await flush(); + + expect(root.textContent).toContain(message); + expect(root.querySelector(".scoped-models-grid")).toBeNull(); + expect( + [...root.querySelectorAll("button")].some((candidate) => candidate.textContent?.trim() === "save"), + ).toBe(false); + expect(apiMocks.saveSettings).not.toHaveBeenCalled(); + }, + ); + + it.each(["settings", "inventory"] as const)( + "hides stale project controls when the next context's %s load fails", + async (failure) => { + const projectA = snapshot({ + enabledModels: ["anthropic/sonnet"], + enabledModelsSource: "project", + hasProjectEnabledModelsOverride: true, + resolvedScopedModels: [{ provider: "anthropic", id: "sonnet" }], + }); + const message = `project b ${failure} unavailable`; + apiMocks.settings.mockImplementation((cwd?: string) => + cwd === "/project/b" && failure === "settings" + ? Promise.reject(new Error(message)) + : Promise.resolve(projectA), + ); + apiMocks.settingsModels.mockImplementation((cwd?: string) => + cwd === "/project/b" && failure === "inventory" + ? Promise.reject(new Error(message)) + : Promise.resolve({ models }), + ); + + 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(); + expect(root.textContent).toContain("unsaved changes"); + expect(root.textContent).toContain("shadows global writes"); + + setCwd("/project/b"); + await flush(); + + expect(root.textContent).toContain(message); + expect(root.querySelector(".scoped-models-grid")).toBeNull(); + expect(root.textContent).not.toContain("unsaved changes"); + expect(root.textContent).not.toContain("shadows global writes"); + expect( + [...root.querySelectorAll("button")].some((candidate) => candidate.textContent?.trim() === "save"), + ).toBe(false); + expect(apiMocks.saveSettings).not.toHaveBeenCalled(); + }, + ); + + it("hides stale controls and status while a different project context loads", async () => { + 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" }], + }); + let resolveSettingsB!: (settings: SettingsDto) => void; + let resolveModelsB!: (inventory: { models: ModelInfoDto[] }) => void; + apiMocks.settings.mockImplementation((cwd?: string) => + cwd === "/project/b" + ? new Promise((resolve) => { + resolveSettingsB = resolve; + }) + : Promise.resolve(projectA), + ); + apiMocks.settingsModels.mockImplementation((cwd?: string) => + cwd === "/project/b" + ? new Promise<{ models: ModelInfoDto[] }>((resolve) => { + resolveModelsB = resolve; + }) + : Promise.resolve({ models }), + ); + apiMocks.saveSettings.mockResolvedValueOnce({ + ...projectA, + warnings: ["warning from project a"], + }); + + 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(); + await flush(); + expect(root.textContent).toContain("warning from project a"); + modelCheckbox(root, "opus").click(); + expect(root.textContent).toContain("unsaved changes"); + + setCwd("/project/b"); + await Promise.resolve(); + expect(root.textContent).toContain("Loading scoped models"); + expect(root.querySelector(".scoped-models-grid")).toBeNull(); + expect(root.textContent).not.toContain("warning from project a"); + expect(root.textContent).not.toContain("unsaved changes"); + + resolveSettingsB(projectB); + resolveModelsB({ models }); + await flush(); + expect(modelCheckbox(root, "gpt").checked).toBe(true); + expect(modelCheckbox(root, "sonnet").checked).toBe(false); + 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(); + 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..c1ff5336 100644 --- a/packages/dashboard/test/client/screens.test.tsx +++ b/packages/dashboard/test/client/screens.test.tsx @@ -188,6 +188,7 @@ vi.mock("../../src/client/api.js", () => ({ })); import { api, connectEvents, type EventStreamHandlers } from "../../src/client/api.js"; +import { App } from "../../src/client/app.js"; import { ConnectionIndicator, Topbar } from "../../src/client/components/common.js"; import { TRANSCRIPT_WINDOW_SIZE, @@ -217,7 +218,12 @@ import { type UserEntry, } from "../../src/client/state/reducer.js"; import { createAppStore } from "../../src/client/state/store.js"; -import { type CommandDto, MAX_TOTAL_IMAGE_BYTES, type RuntimeInfoDto } from "../../src/shared/protocol.js"; +import { + type CommandDto, + MAX_TOTAL_IMAGE_BYTES, + type RuntimeInfoDto, + type SettingsDto, +} from "../../src/shared/protocol.js"; const disposers: Array<() => void> = []; @@ -226,6 +232,13 @@ const disposers: Array<() => void> = []; // logging its (correct) "ResizeObserver unavailable" warning on every screen // mount. Tests that exercise the observer path override this with a capturing // fake and restore it afterward. +if (!HTMLElement.prototype.scrollIntoView) { + Object.defineProperty(HTMLElement.prototype, "scrollIntoView", { + configurable: true, + value: vi.fn(), + }); +} + if (!(globalThis as { ResizeObserver?: typeof ResizeObserver }).ResizeObserver) { class NoopResizeObserver { observe(): void {} @@ -436,7 +449,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 +2709,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"); }); @@ -3002,6 +3018,101 @@ describe("screen smoke tests", () => { expect(select.getAttribute("title")).toBe("/home/test/project-alpha"); }); + it("routes scoped-model context through App and reloads and saves after context selection", async () => { + const available = [ + { provider: "anthropic", id: "sonnet", name: "Sonnet", contextWindow: 1, reasoning: true }, + { provider: "openai", id: "gpt", name: "GPT", contextWindow: 1, reasoning: false }, + { provider: "google", id: "gemini", name: "Gemini", contextWindow: 1, reasoning: true }, + ]; + const scopedSnapshot = (provider: string, id: string): SettingsDto => ({ + enabledModels: [`${provider}/${id}`], + resolvedScopedModels: [{ provider, id }], + scopeWarnings: [], + hasProjectEnabledModelsOverride: true, + enabledModelsSource: "project", + }); + vi.mocked(api.settings).mockClear(); + vi.mocked(api.settingsModels).mockClear(); + vi.mocked(api.saveSettings).mockClear(); + vi.mocked(api.fleet).mockResolvedValue({ + runtimes: [{ cwd: "/project/b" } as RuntimeInfoDto], + diskSessions: [], + }); + vi.mocked(api.settings).mockImplementation(async (cwd?: string) => + cwd === "/project/b" ? scopedSnapshot("openai", "gpt") : scopedSnapshot("anthropic", "sonnet"), + ); + vi.mocked(api.settingsModels).mockResolvedValue({ models: available }); + vi.mocked(api.saveSettings).mockImplementation(async (update, cwd) => ({ + ...scopedSnapshot("openai", "gpt"), + enabledModels: update.enabledModels ?? undefined, + resolvedScopedModels: (update.enabledModels ?? []).map((key) => { + const [provider, ...id] = key.split("/"); + return { provider: provider!, id: id.join("/") }; + }), + warnings: cwd === "/project/b" ? ["project b shadow warning"] : [], + })); + window.location.hash = "#/settings/scoped-models?cwd=%2Fproject%2Fa"; + + const el = mount(() => ); + + 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"); + await vi.waitFor(() => expect(context.querySelector('option[value="/project/b"]')).not.toBeNull()); + + context.value = "/project/b"; + context.dispatchEvent(new Event("change", { bubbles: true })); + 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"), + )!; + sonnet.querySelector('input[type="checkbox"]')!.click(); + [...el.querySelectorAll("button")] + .find((candidate) => candidate.textContent?.trim() === "save")! + .click(); + await vi.waitFor(() => { + 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", () => { const store = makeStore() as any; const fakeStore = { @@ -4157,6 +4268,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 +4320,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 +4415,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..21dcc8ea 100644 --- a/packages/dashboard/test/client/settings-layout.browser.test.ts +++ b/packages/dashboard/test/client/settings-layout.browser.test.ts @@ -1,92 +1,110 @@ /** - * Real-browser layout regression coverage for the settings "agent definition - * context" row (issue 378). + * Real-browser layout regression coverage for Settings. * - * The row's - - - - - -
- - appearance - short control regression guard - - - - -
-
- - -`; +const longProvider = "openrouter-provider-with-a-deliberately-long-name"; +const longModelId = `organization/${"nested-model-segment/".repeat(4)}reasoning:model`; +const secondModelId = `organization/${"another-long-segment/".repeat(3)}fast:model`; +let vite: ViteDevServer; let browser: Browser; let page: Page; +let baseUrl: string; beforeAll(async () => { + vite = await createViteServer({ + root: fileURLToPath(new URL("../..", import.meta.url)), + plugins: [solid({ include: [/src\/client\/.*\.[jt]sx$/, /test\/client\/fixtures\/.*\.tsx$/] })], + logLevel: "error", + optimizeDeps: { noDiscovery: true }, + server: { host: "127.0.0.1", port: 0, strictPort: false, hmr: false }, + }); + await vite.listen(); + const address = vite.httpServer?.address(); + if (address === null || address === undefined || typeof address === "string") { + throw new Error("settings layout Vite server did not bind a TCP port"); + } + baseUrl = `http://127.0.0.1:${address.port}`; + browser = await chromium.launch(); page = await browser.newPage({ viewport: { width: 1024, height: 800 } }); + await page.route("**/api/settings**", async (route) => { + const url = new URL(route.request().url()); + if (url.pathname === "/api/settings/models") { + await route.fulfill({ + contentType: "application/json", + body: JSON.stringify({ + models: [ + { + provider: longProvider, + id: longModelId, + name: "A deliberately long model display name that must wrap safely", + contextWindow: 200_000, + reasoning: true, + }, + { + provider: longProvider, + id: secondModelId, + name: "Another long model display name for zero-selection validation", + contextWindow: 100_000, + reasoning: false, + }, + ], + }), + }); + return; + } + if (url.pathname === "/api/settings") { + await route.fulfill({ + contentType: "application/json", + body: JSON.stringify({ + enabledModels: [`${longProvider}/${longModelId}`, `${longProvider}/${secondModelId}`], + resolvedScopedModels: [ + { provider: longProvider, id: longModelId }, + { provider: longProvider, id: secondModelId }, + ], + scopeWarnings: [ + { + pattern: "missing/*", + message: + "A deliberately long resolver warning must wrap inside the viewport without hiding controls or model identifiers.", + }, + ], + hasProjectEnabledModelsOverride: true, + enabledModelsSource: "project", + }), + }); + return; + } + await route.abort(); + }); }, 60_000); afterAll(async () => { + await page?.close(); await browser?.close(); -}); + await vite?.close(); +}, 60_000); beforeEach(async () => { - await page.setContent(HARNESS_HTML); -}); + await page.setViewportSize({ width: 1024, height: 800 }); + 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; @@ -97,6 +115,8 @@ type SettingsMeasurements = { agentValueClipped: boolean; shortSelectWithinRow: boolean; shortSelectNaturalSize: boolean; + scopedContentFitsViewport: boolean; + scopedTapTargetsUsable: boolean; }; async function measurements(): Promise { @@ -112,10 +132,6 @@ async function measurements(): Promise { childRect.bottom <= parentRect.bottom + tolerance ); }; - // Width the element would take with no max-width constraint — the - // baseline for "natural size" and "is the value clipped" checks. The - // clone is appended to the SAME PARENT so scoped rules (e.g. - // .setting-control select padding/font-size) apply identically. const intrinsicWidth = (element: HTMLElement) => { const clone = element.cloneNode(true) as HTMLElement; clone.style.maxWidth = "none"; @@ -137,30 +153,34 @@ async function measurements(): Promise { const nameLineHeight = Number.parseFloat(nameStyle.lineHeight); const controlRect = control.getBoundingClientRect(); const labelRect = label.getBoundingClientRect(); + const scopedElements = document.querySelectorAll( + ".scoped-models-editor, .scoped-models-editor h2, .scoped-models-editor h3, .scoped-models-editor .settings-warning, .scoped-models-editor .settings-error, .scoped-models-editor select, .scoped-models-editor input, .scoped-models-editor button, .scoped-models-editor .model-id, .scoped-models-editor .model-name, .scoped-model-order-row, .scoped-model-order-row > span", + ); + 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, selectWithinRow: rectWithin(select, row), - // The setting's primary text must not wrap; the hint may wrap (it is - // secondary and wraps in narrow layouts elsewhere by design). Pre-fix - // the unshrinkable select crushes the label until even the name wraps. nameOnOneLine: name.getBoundingClientRect().height <= nameLineHeight + tolerance, - // Row layout is active above the 700px breakpoint: the control sits - // beside the label, not below it. rowIsHorizontal: controlRect.left > labelRect.left && controlRect.top < labelRect.bottom && controlRect.bottom > labelRect.top, controlBelowLabel: controlRect.top > labelRect.top + tolerance, - // The capped select must visibly clip its value: rendered width is - // smaller than the unconstrained intrinsic width. Pre-fix the wide - // layout renders the select AT intrinsic width (no clipping, label - // squished instead). agentValueClipped: intrinsicWidth(select) - select.getBoundingClientRect().width > 2, shortSelectWithinRow: rectWithin(shortSelect, shortRow), - // 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; + }), }; }); } @@ -171,12 +191,8 @@ 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.) it.each([701, 1024])("keeps the label readable and the row horizontal at %ipx", async (width) => { const measured = await measurementsAt(width); - expect(measured.documentFits).toBe(true); expect(measured.selectWithinRow).toBe(true); expect(measured.nameOnOneLine).toBe(true); @@ -186,18 +202,38 @@ describe("settings agent-context row layout in a real browser", () => { it.each([360, 700])("caps the select to the container at %ipx without horizontal overflow", async (width) => { const measured = await measurementsAt(width); - expect(measured.documentFits).toBe(true); expect(measured.selectWithinRow).toBe(true); - // Column layout is active at these widths: the control stacks below the label. expect(measured.controlBelowLabel).toBe(true); expect(measured.agentValueClipped).toBe(true); }); it.each([360, 1024])("leaves short controls at their natural size at %ipx", async (width) => { const measured = await measurementsAt(width); - expect(measured.shortSelectWithinRow).toBe(true); expect(measured.shortSelectNaturalSize).toBe(true); }); + + it.each([360, 700, 701, 1024])("keeps the shipped scoped-model editor usable at %ipx", async (width) => { + await page.setViewportSize({ width, height: 800 }); + expect(await page.locator(".scoped-models-editor").isVisible()).toBe(true); + expect(await page.getByText(longProvider, { exact: true }).isVisible()).toBe(true); + expect(await page.getByText(longModelId, { exact: true }).first().isVisible()).toBe(true); + expect(await page.getByText(/deliberately long resolver warning/).isVisible()).toBe(true); + expect(await page.getByRole("button", { name: "save", exact: true }).isVisible()).toBe(true); + expect(await page.getByRole("button", { name: "reset", exact: true }).isVisible()).toBe(true); + + let measured = await measurements(); + expect(measured.documentFits).toBe(true); + expect(measured.scopedContentFitsViewport).toBe(true); + expect(measured.scopedTapTargetsUsable).toBe(true); + + const choices = page.locator('.scoped-model-choice input[type="checkbox"]'); + await choices.nth(0).uncheck(); + await choices.nth(1).uncheck(); + expect(await page.getByText("At least one model must remain enabled.").isVisible()).toBe(true); + measured = await measurements(); + expect(measured.documentFits).toBe(true); + expect(measured.scopedContentFitsViewport).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`); 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",