From 591e2dbe5cbf2283d58debdf92943a075edc4569 Mon Sep 17 00:00:00 2001 From: Adam Malczewski Date: Thu, 24 Sep 2026 19:20:37 +0900 Subject: feat(search-backend-selection): Search backend dropdown in Settings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - New features/search-settings: pure view-model (options = available backends ∪ 'None (search disabled)', unconfigured entries disabled with a suffix, unknown persisted selection prepended, network-seam normalization) + the SearchBackendField dropdown (save-on-change, spinner/saved/error states, revert-to-persisted on failure, 503 -> disabled field with a 'search not available' hint, loading skeleton, help text noting search-mode-only scope). - store.svelte.ts: searchBackendConfig() / setSearchBackend() over the global GET/PUT /search/config routes (503 marked unavailable on the failure variant). - SettingsPane renders the field under the chat limit; ExtensionsPane lists the new module manifest. - .dispatch mirrors: wire + transport-contract deltas for the new types/routes. --- .dispatch/transport-contract.reference.md | 17 +++ .dispatch/wire.reference.md | 30 +++++ src/app/store.svelte.ts | 68 ++++++++++ src/app/views/ExtensionsPane.svelte | 2 + src/app/views/SettingsPane.svelte | 5 + src/features/search-settings/index.ts | 22 ++++ .../search-settings/logic/view-model.test.ts | 108 ++++++++++++++++ src/features/search-settings/logic/view-model.ts | 124 ++++++++++++++++++ .../search-settings/ui/SearchBackendField.svelte | 142 +++++++++++++++++++++ .../search-settings/ui/SearchBackendField.test.ts | 119 +++++++++++++++++ 10 files changed, 637 insertions(+) create mode 100644 src/features/search-settings/index.ts create mode 100644 src/features/search-settings/logic/view-model.test.ts create mode 100644 src/features/search-settings/logic/view-model.ts create mode 100644 src/features/search-settings/ui/SearchBackendField.svelte create mode 100644 src/features/search-settings/ui/SearchBackendField.test.ts diff --git a/.dispatch/transport-contract.reference.md b/.dispatch/transport-contract.reference.md index e0f8f52..0a3a347 100644 --- a/.dispatch/transport-contract.reference.md +++ b/.dispatch/transport-contract.reference.md @@ -1236,3 +1236,20 @@ export interface SetConcurrencyCooldownRequest { } ``` + +--- + +## 2026-09-24 delta (search-backend-selection — ADDITIVE, no version bump) + +GLOBAL search-backend selection routes (the `web_search` tool's search-mode engine; scrape/crawl +ALWAYS route to the self-hosted Firecrawl client; the tool's `map` mode is REMOVED entirely): + +- `GET /search/config` → `SearchBackendConfigResponse` (`{ backend, available: SearchBackendAvailability[] }` + — re-exported from `@dispatch/wire`, see the wire delta below). When the `tool-web-search` extension + is NOT loaded the route degrades like heartbeat: 200 with the defaults (`backend: "firecrawl"`, + firecrawl-only availability). +- `PUT /search/config` body `UpdateSearchConfigRequest` (`{ backend: string }`) → 200 + `SearchBackendConfigResponse` | 400 (`{ error }` — unknown or not-configured backend) | 503 + (service not loaded). The umans entry appears in `available` only when the backend has + `UMANS_API_KEY`; an unconfigured backend is listed but `configured: false` — the UI disables it, + it is never an error. diff --git a/.dispatch/wire.reference.md b/.dispatch/wire.reference.md index 5379b11..6e8daff 100644 --- a/.dispatch/wire.reference.md +++ b/.dispatch/wire.reference.md @@ -822,3 +822,33 @@ export interface ComputerEntry extends Computer { readonly usageCount: number; } ``` + +--- + +## 2026-09-24 delta (search-backend-selection — ADDITIVE) + +The global search-backend selection wire types (`GET/PUT /search/config`, GLOBAL — not +workspace-scoped) shipped in `@dispatch/wire` (transport-contract re-exports them): + +```ts +/** The selected backend id. `"none"` = search off (scrape/crawl keep working). */ +export type SearchBackendId = "firecrawl" | "umans" | "none"; + +/** One dropdown entry; `configured` is the availability gate (umans: UMANS_API_KEY resolves; firecrawl: always). */ +export interface SearchBackendAvailability { + readonly id: string; + readonly label: string; + readonly configured: boolean; +} + +/** Response of `GET /search/config` — the selection + every registered backend's availability. */ +export interface SearchBackendConfigResponse { + readonly backend: string; + readonly available: readonly SearchBackendAvailability[]; +} + +/** Body of `PUT /search/config` — select a backend by id (or `"none"`). */ +export interface UpdateSearchConfigRequest { + readonly backend: string; +} +``` diff --git a/src/app/store.svelte.ts b/src/app/store.svelte.ts index a858927..73b19bc 100644 --- a/src/app/store.svelte.ts +++ b/src/app/store.svelte.ts @@ -102,6 +102,10 @@ import type { import { normalizeNamedAgentRecord, normalizeNamedAgents } from "../features/named-agents"; import type { PluginsStore } from "../features/plugins"; import { createPluginsHttp, createPluginsStore } from "../features/plugins"; +import { + normalizeSearchBackendConfig, + type SearchBackendConfigResult, +} from "../features/search-settings"; import type { Tab, TabsState } from "../features/tabs"; import { createTabsStore, deriveTitle, type TabsStore } from "../features/tabs"; import { @@ -530,6 +534,20 @@ export interface AppStore { * an empty list when the extension isn't loaded (`{ providers: [] }`). */ concurrencyStatus(): Promise; + /** + * Load the GLOBAL search-backend selection + * (`GET /search/config`): which engine backs the `web_search` tool's search + * mode. Not workspace-scoped — the selection is global. `unavailable: true` + * on the failure variant marks the 503 degrade (the backend's + * search-config service not loaded) so the UI can disable the field. + */ + searchBackendConfig(): Promise; + /** + * Persist a new search-backend selection + * (`PUT /search/config`, body `{ backend }`). `400` (unknown/unconfigured + * backend) and `503` (service not loaded) both surface as `ok: false`. + */ + setSearchBackend(backend: string): Promise; /** * Fetch the per-slot release cooldown (ms) for one provider * (`GET /concurrency/cooldown/:providerId`). `404` (no concurrency config at @@ -2557,6 +2575,56 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore { } }, + async searchBackendConfig(): Promise { + try { + const res = await fetchImpl(`${httpBase}/search/config`); + if (!res.ok) { + const errBody = (await res.json().catch(() => null)) as { error?: string } | null; + return { + ok: false, + error: errBody?.error ?? `Search config failed (HTTP ${res.status})`, + // The 503 degrade = the backend's search-config service not loaded + // (the tool-web-search extension absent) — a distinct UI state. + ...(res.status === 503 ? { unavailable: true } : {}), + }; + } + // Normalize the untyped JSON at this network seam (pure helper) so a + // malformed/partial response can never crash the renderer. + const config = normalizeSearchBackendConfig(await res.json()); + return { ok: true, config }; + } catch (err) { + return { + ok: false, + error: err instanceof Error ? err.message : "Search config request failed", + }; + } + }, + + async setSearchBackend(backend: string): Promise { + try { + const res = await fetchImpl(`${httpBase}/search/config`, { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ backend }), + }); + if (!res.ok) { + const errBody = (await res.json().catch(() => null)) as { error?: string } | null; + return { + ok: false, + error: errBody?.error ?? `Set search backend failed (HTTP ${res.status})`, + ...(res.status === 503 ? { unavailable: true } : {}), + }; + } + const config = normalizeSearchBackendConfig(await res.json()); + return { ok: true, config }; + } catch (err) { + return { + ok: false, + error: err instanceof Error ? err.message : "Set search backend request failed", + }; + } + }, + async getConcurrencyCooldown(providerId: string): Promise { try { const res = await fetchImpl( diff --git a/src/app/views/ExtensionsPane.svelte b/src/app/views/ExtensionsPane.svelte index 7a2343e..6afc2bf 100644 --- a/src/app/views/ExtensionsPane.svelte +++ b/src/app/views/ExtensionsPane.svelte @@ -17,6 +17,7 @@ import { manifest as markdownManifest } from "../../features/markdown"; import { manifest as mcpManifest } from "../../features/mcp"; import { manifest as namedAgentsManifest } from "../../features/named-agents"; + import { manifest as searchSettingsManifest } from "../../features/search-settings"; import { manifest as settingsManifest } from "../../features/settings"; import { manifest as smartScrollManifest } from "../../features/smart-scroll"; import { SurfaceView } from "../../features/surface-host"; @@ -60,6 +61,7 @@ concurrencyManifest, visionManifest, namedAgentsManifest, + searchSettingsManifest, ].map((m) => [m.name, m.description] as const); diff --git a/src/app/views/SettingsPane.svelte b/src/app/views/SettingsPane.svelte index 67a88ec..f119103 100644 --- a/src/app/views/SettingsPane.svelte +++ b/src/app/views/SettingsPane.svelte @@ -8,6 +8,7 @@ */ import { tick } from "svelte"; import { ChatLimitField, type ChatLimitSaveResult } from "../../features/settings"; + import { SearchBackendField } from "../../features/search-settings"; import type { AppViewPorts } from "../view-ports"; let { ports }: { ports: AppViewPorts } = $props(); @@ -31,4 +32,8 @@
+ store.searchBackendConfig()} + save={(backend) => store.setSearchBackend(backend)} + />
diff --git a/src/features/search-settings/index.ts b/src/features/search-settings/index.ts new file mode 100644 index 0000000..a6ea160 --- /dev/null +++ b/src/features/search-settings/index.ts @@ -0,0 +1,22 @@ +export type { + LoadSearchBackendConfig, + SaveSearchBackend, + SearchBackendAvailability, + SearchBackendConfig, + SearchBackendConfigResult, +} from "./logic/view-model"; +export { + buildSearchBackendOptions, + NONE_BACKEND_ID, + NONE_BACKEND_LABEL, + normalizeSearchBackendConfig, + searchBackendChanged, + searchBackendLabel, +} from "./logic/view-model"; +export { default as SearchBackendField } from "./ui/SearchBackendField.svelte"; + +/** Public module manifest — aggregated by the shell's "Loaded Modules" view. */ +export const manifest = { + name: "Search Settings", + description: "Global search backend selection for the web_search tool (GET/PUT /search/config)", +} as const; diff --git a/src/features/search-settings/logic/view-model.test.ts b/src/features/search-settings/logic/view-model.test.ts new file mode 100644 index 0000000..7f3ceb5 --- /dev/null +++ b/src/features/search-settings/logic/view-model.test.ts @@ -0,0 +1,108 @@ +import { describe, expect, it } from "vitest"; +import { + buildSearchBackendOptions, + NONE_BACKEND_ID, + NONE_BACKEND_LABEL, + normalizeSearchBackendConfig, + type SearchBackendConfig, + searchBackendChanged, + searchBackendLabel, +} from "./view-model"; + +const CONFIG: SearchBackendConfig = { + backend: "firecrawl", + available: [ + { id: "firecrawl", label: "Firecrawl (self-hosted)", configured: true }, + { id: "umans", label: "Umans (Exa)", configured: true }, + ], +}; + +describe("buildSearchBackendOptions", () => { + it("lists the available backends in wire order plus the None off state last", () => { + expect(buildSearchBackendOptions(CONFIG)).toEqual([ + { id: "firecrawl", label: "Firecrawl (self-hosted)", disabled: false }, + { id: "umans", label: "Umans (Exa)", disabled: false }, + { id: "none", label: NONE_BACKEND_LABEL, disabled: false }, + ]); + }); + + it("disables unconfigured backends with a suffix (listed, never an error)", () => { + const config: SearchBackendConfig = { + backend: "firecrawl", + available: [ + { id: "firecrawl", label: "Firecrawl (self-hosted)", configured: true }, + { id: "umans", label: "Umans (Exa)", configured: false }, + ], + }; + const options = buildSearchBackendOptions(config); + expect(options[1]).toEqual({ + id: "umans", + label: "Umans (Exa) (not configured)", + disabled: true, + }); + expect(options[2]?.id).toBe(NONE_BACKEND_ID); + }); + + it("prepends an unknown persisted selection so the select can render it", () => { + const config: SearchBackendConfig = { backend: "gone", available: [] }; + const options = buildSearchBackendOptions(config); + expect(options[0]).toEqual({ id: "gone", label: "gone (unknown)", disabled: false }); + expect(options.at(-1)?.id).toBe(NONE_BACKEND_ID); + }); + + it("does not duplicate the none option when 'none' is selected", () => { + const config: SearchBackendConfig = { backend: NONE_BACKEND_ID, available: [] }; + const options = buildSearchBackendOptions(config); + expect(options.filter((o) => o.id === NONE_BACKEND_ID)).toHaveLength(1); + }); +}); + +describe("searchBackendLabel", () => { + it("maps a backend id to its advertised label", () => { + expect(searchBackendLabel(CONFIG, "umans")).toBe("Umans (Exa)"); + }); + + it("maps 'none' to the off-state label", () => { + expect(searchBackendLabel(CONFIG, NONE_BACKEND_ID)).toBe(NONE_BACKEND_LABEL); + }); + + it("falls back to the raw id for an unknown backend", () => { + expect(searchBackendLabel(CONFIG, "gone")).toBe("gone"); + }); +}); + +describe("searchBackendChanged", () => { + it("detects a real change and a no-op reselect", () => { + expect(searchBackendChanged("umans", "firecrawl")).toBe(true); + expect(searchBackendChanged("firecrawl", "firecrawl")).toBe(false); + expect(searchBackendChanged(NONE_BACKEND_ID, NONE_BACKEND_ID)).toBe(false); + }); +}); + +describe("normalizeSearchBackendConfig", () => { + it("normalizes a well-formed response", () => { + expect( + normalizeSearchBackendConfig({ + backend: "umans", + available: [{ id: "firecrawl", label: "Firecrawl (self-hosted)", configured: true }], + }), + ).toEqual({ + backend: "umans", + available: [{ id: "firecrawl", label: "Firecrawl (self-hosted)", configured: true }], + }); + }); + + it("degrades a malformed/partial response instead of crashing the renderer", () => { + expect(normalizeSearchBackendConfig(null)).toEqual({ backend: NONE_BACKEND_ID, available: [] }); + expect(normalizeSearchBackendConfig({})).toEqual({ backend: NONE_BACKEND_ID, available: [] }); + expect( + normalizeSearchBackendConfig({ + backend: "firecrawl", + available: [null, 42, { id: "" }, { label: "no id" }, { id: "umans" }], + }), + ).toEqual({ + backend: "firecrawl", + available: [{ id: "umans", label: "umans", configured: false }], + }); + }); +}); diff --git a/src/features/search-settings/logic/view-model.ts b/src/features/search-settings/logic/view-model.ts new file mode 100644 index 0000000..5d172c9 --- /dev/null +++ b/src/features/search-settings/logic/view-model.ts @@ -0,0 +1,124 @@ +/** + * Pure core for the search-settings feature — zero DOM, zero effects, zero + * Svelte. + * + * The "Search backend" dropdown (Settings pane) picks the engine backing the + * `web_search` tool's SEARCH mode. The selection is GLOBAL and persisted on + * the backend (`GET/PUT /search/config`); decided entries are exactly + * "Firecrawl (self-hosted)" + "Umans (Exa)" plus "None (search disabled)" as + * the off state. Scrape/crawl always route to the self-hosted Firecrawl + * backend regardless of the selection; the tool's `map` mode is removed + * entirely (backend plan). + */ + +/** + * One selectable backend as the backend's wire advertises it (mirrors + * `@dispatch/wire`'s `SearchBackendAvailability`). + */ +export interface SearchBackendAvailability { + readonly id: string; + readonly label: string; + /** The availability gate (umans: `UMANS_API_KEY` resolves; firecrawl: always). */ + readonly configured: boolean; +} + +/** The global search-backend config (`GET /search/config` response). */ +export interface SearchBackendConfig { + /** The selected backend id, or `"none"` (search off). */ + readonly backend: string; + readonly available: readonly SearchBackendAvailability[]; +} + +// ── Injected ports (consumer-defines-port; the composition root adapts the +// store's HTTP calls to these shapes). ────────────────────────────────────── + +/** Outcome of `GET/PUT /search/config`. `unavailable` marks the 503 degrade + * (the backend's search-config service not loaded) so the UI can disable the + * field with a "search not available" hint instead of a generic error. */ +export type SearchBackendConfigResult = + | { readonly ok: true; readonly config: SearchBackendConfig } + | { readonly ok: false; readonly error: string; readonly unavailable?: boolean }; + +export type LoadSearchBackendConfig = () => Promise; + +export type SaveSearchBackend = (backend: string) => Promise; + +/** The explicit off-state option label. */ +export const NONE_BACKEND_ID = "none"; +export const NONE_BACKEND_LABEL = "None (search disabled)"; + +/** One rendered `