summaryrefslogtreecommitdiffhomepage
path: root/src/features
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-09-24 19:20:37 +0900
committerAdam Malczewski <[email protected]>2026-09-24 19:20:37 +0900
commit591e2dbe5cbf2283d58debdf92943a075edc4569 (patch)
tree11ee2481ed79f257cd6303f44992471ec9282237 /src/features
parent6556e70e396ec7cc9b2311ef60e00c81722777c7 (diff)
downloaddispatch-web-591e2dbe5cbf2283d58debdf92943a075edc4569.tar.gz
dispatch-web-591e2dbe5cbf2283d58debdf92943a075edc4569.zip
feat(search-backend-selection): Search backend dropdown in Settings
- 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.
Diffstat (limited to 'src/features')
-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
5 files changed, 515 insertions, 0 deletions
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)"]);
+ });
+});