diff options
Diffstat (limited to 'src/features/search-settings/ui')
| -rw-r--r-- | src/features/search-settings/ui/SearchBackendField.svelte | 142 | ||||
| -rw-r--r-- | src/features/search-settings/ui/SearchBackendField.test.ts | 119 |
2 files changed, 261 insertions, 0 deletions
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)"]); + }); +}); |
