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. --- 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 +++++++++++++++++ 5 files changed, 515 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 (limited to 'src/features') 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 `