summaryrefslogtreecommitdiffhomepage
path: root/src/features/search-settings/logic
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/search-settings/logic
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/search-settings/logic')
-rw-r--r--src/features/search-settings/logic/view-model.test.ts108
-rw-r--r--src/features/search-settings/logic/view-model.ts124
2 files changed, 232 insertions, 0 deletions
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,
+ },
+ ];
+ }),
+ };
+}