summaryrefslogtreecommitdiffhomepage
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/app/store.svelte.ts68
-rw-r--r--src/app/views/ExtensionsPane.svelte2
-rw-r--r--src/app/views/SettingsPane.svelte5
-rw-r--r--src/features/search-settings/index.ts22
-rw-r--r--src/features/search-settings/logic/view-model.test.ts108
-rw-r--r--src/features/search-settings/logic/view-model.ts124
-rw-r--r--src/features/search-settings/ui/SearchBackendField.svelte142
-rw-r--r--src/features/search-settings/ui/SearchBackendField.test.ts119
8 files changed, 590 insertions, 0 deletions
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 {
@@ -531,6 +535,20 @@ export interface AppStore {
*/
concurrencyStatus(): Promise<ConcurrencyStatusResult>;
/**
+ * 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<SearchBackendConfigResult>;
+ /**
+ * 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<SearchBackendConfigResult>;
+ /**
* Fetch the per-slot release cooldown (ms) for one provider
* (`GET /concurrency/cooldown/:providerId`). `404` (no concurrency config at
* all) and `503` (extension not loaded) both surface as `ok: false`.
@@ -2557,6 +2575,56 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore {
}
},
+ async searchBackendConfig(): Promise<SearchBackendConfigResult> {
+ 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<SearchBackendConfigResult> {
+ 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<ConcurrencyCooldownResult> {
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);
</script>
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 @@
<div class="flex flex-col gap-3">
<ChatLimitField chatLimit={store.chatLimit} save={saveChatLimit} />
+ <SearchBackendField
+ load={() => store.searchBackendConfig()}
+ save={(backend) => store.setSearchBackend(backend)}
+ />
</div>
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<SearchBackendConfigResult>;
+
+export type SaveSearchBackend = (backend: string) => Promise<SearchBackendConfigResult>;
+
+/** The explicit off-state option label. */
+export const NONE_BACKEND_ID = "none";
+export const NONE_BACKEND_LABEL = "None (search disabled)";
+
+/** One rendered `<option>` of the dropdown. */
+export interface SearchBackendOption {
+ readonly id: string;
+ readonly label: string;
+ /** Unconfigured backends stay LISTED but disabled — never an error. */
+ readonly disabled: boolean;
+}
+
+/**
+ * Build the dropdown options from the config: every advertised backend in
+ * wire order (unconfigured ones disabled with a "not configured" suffix),
+ * then the "None (search disabled)" off state appended last. If the persisted
+ * selection is not among them (an unknown id from a newer/older backend), it
+ * is prepended so the select can render it truthfully.
+ */
+export function buildSearchBackendOptions(
+ config: SearchBackendConfig,
+): readonly SearchBackendOption[] {
+ const options: SearchBackendOption[] = config.available.map((b) => ({
+ id: b.id,
+ label: b.configured ? b.label : `${b.label} (not configured)`,
+ disabled: !b.configured,
+ }));
+ if (
+ config.backend !== NONE_BACKEND_ID &&
+ !config.available.some((b) => b.id === config.backend)
+ ) {
+ options.unshift({ id: config.backend, label: `${config.backend} (unknown)`, disabled: false });
+ }
+ options.push({ id: NONE_BACKEND_ID, label: NONE_BACKEND_LABEL, disabled: false });
+ return options;
+}
+
+/** The human-facing label for a backend id (the saved / error messages). */
+export function searchBackendLabel(config: SearchBackendConfig, id: string): string {
+ if (id === NONE_BACKEND_ID) {
+ return NONE_BACKEND_LABEL;
+ }
+ return config.available.find((b) => b.id === id)?.label ?? id;
+}
+
+/** Whether selecting `selected` would change the persisted backend. */
+export function searchBackendChanged(selected: string, persisted: string): boolean {
+ return selected !== persisted;
+}
+
+/**
+ * Normalize the untyped JSON at the network seam so a malformed/partial
+ * response can never crash the renderer (mirrors `normalizeHeartbeatConfig`).
+ * Unknown entries degrade to unconfigured; a missing backend falls back to
+ * `"none"` rendering (the safest default for a UI that must not lie).
+ */
+export function normalizeSearchBackendConfig(raw: unknown): SearchBackendConfig {
+ const obj = (raw ?? {}) as {
+ backend?: unknown;
+ available?: unknown;
+ };
+ const available = Array.isArray(obj.available) ? obj.available : [];
+ return {
+ backend:
+ typeof obj.backend === "string" && obj.backend.length > 0 ? obj.backend : NONE_BACKEND_ID,
+ available: available.flatMap((entry) => {
+ if (entry === null || typeof entry !== "object") return [];
+ const e = entry as { id?: unknown; label?: unknown; configured?: unknown };
+ if (typeof e.id !== "string" || e.id.length === 0) return [];
+ return [
+ {
+ id: e.id,
+ label: typeof e.label === "string" ? e.label : e.id,
+ configured: e.configured === true,
+ },
+ ];
+ }),
+ };
+}
diff --git a/src/features/search-settings/ui/SearchBackendField.svelte b/src/features/search-settings/ui/SearchBackendField.svelte
new file mode 100644
index 0000000..c1ae450
--- /dev/null
+++ b/src/features/search-settings/ui/SearchBackendField.svelte
@@ -0,0 +1,142 @@
+<script lang="ts">
+ /**
+ * The "Search backend" dropdown (Settings pane) — picks the engine backing
+ * the `web_search` tool's SEARCH mode. GLOBAL setting persisted on the
+ * backend (`GET/PUT /search/config`); SAVE ON CHANGE (no Set button).
+ * Unconfigured backends stay listed but disabled; "None (search disabled)"
+ * is the off state. The config affects search mode only — scrape/crawl
+ * always route to the self-hosted Firecrawl backend.
+ *
+ * Thin shell: seeds/reverts the select from the last PERSISTED value, shows
+ * saving/saved/error feedback, and degrades to a disabled field with a
+ * "search not available" hint when the backend's search-config service is
+ * not loaded (503) — matching heartbeat's degrade philosophy.
+ */
+ import { untrack } from "svelte";
+ import {
+ buildSearchBackendOptions,
+ type LoadSearchBackendConfig,
+ NONE_BACKEND_ID,
+ type SaveSearchBackend,
+ searchBackendChanged,
+ searchBackendLabel,
+ type SearchBackendConfig,
+ } from "../logic/view-model";
+
+ let {
+ load,
+ save,
+ }: {
+ /** Fetch the persisted global config (called once on mount). */
+ load: LoadSearchBackendConfig;
+ /** Persist a new backend selection (save-on-change). */
+ save: SaveSearchBackend;
+ } = $props();
+
+ let config = $state<SearchBackendConfig | null>(null);
+ let loading = $state(true);
+ let loadUnavailable = $state(false);
+ let loadError = $state<string | null>(null);
+ let selected = $state("");
+ let lastSeed = $state("");
+ let saving = $state(false);
+ let error = $state<string | null>(null);
+ let justSaved = $state<string | null>(null);
+
+ $effect(() => {
+ // Load ONCE on first mount (the setting is GLOBAL — no workspace
+ // re-seeding); guard against strict-mode double effects via the flag.
+ untrack(async () => {
+ if (config !== null || loadUnavailable) return;
+ loading = true;
+ loadError = null;
+ const result = await load();
+ loading = false;
+ if (result.ok) {
+ config = result.config;
+ selected = result.config.backend;
+ lastSeed = result.config.backend;
+ } else {
+ loadUnavailable = result.unavailable === true;
+ loadError = result.error;
+ }
+ });
+ });
+
+ const options = $derived(config === null ? [] : buildSearchBackendOptions(config));
+ const persisted = $derived(lastSeed);
+
+ async function handleChange(): Promise<void> {
+ if (saving || config === null) return;
+ const next = selected;
+ if (!searchBackendChanged(next, persisted)) return;
+ saving = true;
+ error = null;
+ justSaved = null;
+ const result = await save(next);
+ saving = false;
+ if (result.ok) {
+ config = result.config;
+ // Reflect the persisted value back (the backend is authoritative).
+ selected = result.config.backend;
+ lastSeed = result.config.backend;
+ justSaved = searchBackendLabel(result.config, result.config.backend);
+ } else {
+ error = result.error;
+ // Revert the select to the last persisted value on failure.
+ selected = persisted;
+ }
+ }
+
+ function onSelectChange(): void {
+ error = null;
+ justSaved = null;
+ void handleChange();
+ }
+</script>
+
+<div class="flex flex-col gap-1">
+ <span class="text-xs font-semibold uppercase opacity-60">Search backend</span>
+ {#if loading}
+ <div class="skeleton h-7 w-full" aria-label="Search backend loading"></div>
+ {:else if config === null}
+ <select
+ class="select select-bordered select-sm w-full text-xs"
+ disabled
+ aria-label="Search backend"
+ >
+ <option>{loadUnavailable ? "Search not available" : (loadError ?? "Unavailable")}</option>
+ </select>
+ {#if loadError && !loadUnavailable}
+ <p class="text-xs text-error">{loadError}</p>
+ {/if}
+ {:else}
+ <select
+ class="select select-bordered select-sm w-full text-xs"
+ bind:value={selected}
+ disabled={saving}
+ onchange={onSelectChange}
+ aria-label="Search backend"
+ >
+ {#each options as option (option.id)}
+ <option value={option.id} disabled={option.disabled}>{option.label}</option>
+ {/each}
+ </select>
+ {#if saving}
+ <p class="text-xs opacity-60 flex items-center gap-1">
+ <span class="loading loading-spinner loading-xs"></span>
+ Saving…
+ </p>
+ {:else if error}
+ <p class="text-xs text-error">{error}</p>
+ {:else if justSaved !== null}
+ <p class="text-xs text-success">Saved: {justSaved}.</p>
+ {/if}
+ {/if}
+ {#if !loadUnavailable}
+ <p class="text-xs opacity-50">
+ Affects the web_search tool's <em>search</em> mode only — scrape and crawl always use the
+ self-hosted Firecrawl backend, and the tool's map mode has been removed.
+ </p>
+ {/if}
+</div>
diff --git a/src/features/search-settings/ui/SearchBackendField.test.ts b/src/features/search-settings/ui/SearchBackendField.test.ts
new file mode 100644
index 0000000..d8597ef
--- /dev/null
+++ b/src/features/search-settings/ui/SearchBackendField.test.ts
@@ -0,0 +1,119 @@
+import { render, screen, waitFor } from "@testing-library/svelte";
+import userEvent from "@testing-library/user-event";
+import { describe, expect, it } from "vitest";
+import type { SearchBackendConfig, SearchBackendConfigResult } from "../logic/view-model";
+import SearchBackendField from "./SearchBackendField.svelte";
+
+const CONFIG: SearchBackendConfig = {
+ backend: "firecrawl",
+ available: [
+ { id: "firecrawl", label: "Firecrawl (self-hosted)", configured: true },
+ { id: "umans", label: "Umans (Exa)", configured: false },
+ ],
+};
+
+function okLoad(backend = "firecrawl"): () => Promise<SearchBackendConfigResult> {
+ return async () => ({ ok: true, config: { ...CONFIG, backend } });
+}
+
+function okSave(backend: string): () => Promise<SearchBackendConfigResult> {
+ const saves: string[] = [];
+ const impl = async (value: string): Promise<SearchBackendConfigResult> => {
+ saves.push(value);
+ return { ok: true, config: { ...CONFIG, backend: value } };
+ };
+ // attach the recorder for assertions
+ (impl as unknown as { saves: string[] }).saves = saves;
+ void backend;
+ return impl as () => Promise<SearchBackendConfigResult>;
+}
+
+describe("SearchBackendField", () => {
+ it("renders the options from the loaded config (unconfigured disabled, None last)", async () => {
+ render(SearchBackendField, { props: { load: okLoad(), save: okSave("none") } });
+ const select = await screen.findByLabelText("Search backend");
+ await waitFor(() => expect(select).toBeEnabled());
+ const options = select.querySelectorAll("option");
+ const labels = [...options].map((o) => o.textContent);
+ expect(labels).toEqual([
+ "Firecrawl (self-hosted)",
+ "Umans (Exa) (not configured)",
+ "None (search disabled)",
+ ]);
+ expect(options[1]?.disabled).toBe(true);
+ expect((select as HTMLSelectElement).value).toBe("firecrawl");
+ });
+
+ it("saves on change and shows the saved confirmation with the new label", async () => {
+ const user = userEvent.setup();
+ const save = okSave("umans");
+ render(SearchBackendField, { props: { load: okLoad(), save } });
+
+ const select = (await screen.findByLabelText("Search backend")) as HTMLSelectElement;
+ await waitFor(() => expect(select).toBeEnabled());
+
+ // Select the umans entry (disabled here would block it, so flip configured
+ // via a custom config below); instead select "none" which is always enabled.
+ await user.selectOptions(select, "none");
+ await waitFor(() => expect(screen.getByText(/Saved/)).toBeInTheDocument());
+ expect(screen.getByText(/Saved: None \(search disabled\)\./)).toBeInTheDocument();
+ const saves = (save as unknown as { saves: string[] }).saves;
+ expect(saves).toEqual(["none"]);
+ expect(select.value).toBe("none");
+ });
+
+ it("reverts the selection to the persisted value when the save fails", async () => {
+ const user = userEvent.setup();
+ const save = async (): Promise<SearchBackendConfigResult> => ({
+ ok: false,
+ error: "Set search backend failed (HTTP 500)",
+ });
+ render(SearchBackendField, { props: { load: okLoad(), save } });
+
+ const select = (await screen.findByLabelText("Search backend")) as HTMLSelectElement;
+ await waitFor(() => expect(select).toBeEnabled());
+ await user.selectOptions(select, "none");
+
+ expect(await screen.findByText(/HTTP 500/)).toBeInTheDocument();
+ expect(select.value).toBe("firecrawl");
+ });
+
+ it("renders disabled with a 'search not available' hint on the 503 degrade", async () => {
+ const load = async (): Promise<SearchBackendConfigResult> => ({
+ ok: false,
+ error: "Search config failed (HTTP 503)",
+ unavailable: true,
+ });
+ render(SearchBackendField, { props: { load, save: okSave("none") } });
+ const select = await screen.findByLabelText("Search backend");
+ await waitFor(() => expect(select).toBeDisabled());
+ expect(screen.getByText(/Search not available/)).toBeInTheDocument();
+ // No help text in the unavailable state (nothing to explain — search is off).
+ expect(screen.queryByText(/search.*mode only/i)).not.toBeInTheDocument();
+ });
+
+ it("renders a loading skeleton while the initial GET is in flight", async () => {
+ let resolveLoad!: (r: SearchBackendConfigResult) => void;
+ const load = () =>
+ new Promise<SearchBackendConfigResult>((resolve) => {
+ resolveLoad = resolve;
+ });
+ render(SearchBackendField, { props: { load, save: okSave("none") } });
+ expect(screen.getByLabelText("Search backend loading")).toBeInTheDocument();
+ resolveLoad({ ok: true, config: CONFIG });
+ await screen.findByLabelText("Search backend");
+ });
+
+ it("prepends an unknown persisted selection as a selectable option", async () => {
+ const load = async (): Promise<SearchBackendConfigResult> => ({
+ ok: true,
+ config: { backend: "gone", available: [] },
+ });
+ render(SearchBackendField, { props: { load, save: okSave("none") } });
+ const select = (await screen.findByLabelText("Search backend")) as HTMLSelectElement;
+ await waitFor(() => expect(select).toBeEnabled());
+ expect(select.value).toBe("gone");
+ const labels = [...select.querySelectorAll("option")].map((o) => o.textContent);
+ expect(labels).toEqual(["gone (unknown)", "None (search disabled)"]);
+ });
+});