1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
|
# search-backend-selection — Frontend Plan (research + planning, NOT implemented)
Goal: a "Search backend" dropdown under Settings that lets the user pick the active
search backend. GLOBAL setting persisted on the backend. Decided: exactly two
backend entries — "Firecrawl (self-hosted)" and "Umans (Exa)" — plus "None" as the
off state; save-on-change. Mirrors the backend plan in
`worktrees/search-backend-selection/backend/notes/`.
## 1. Current state (verified in this worktree)
- `src/app/views/SettingsPane.svelte` is currently FE-local only: a single
`ChatLimitField` (chat limit, persisted browser-side). It takes `ports` and calls
`store.setChatLimit` — so the pane already has a store seam for persistence.
- Server-persisted per-workspace settings today follow the heartbeat pattern: own
feature dir (`src/features/heartbeat/`) with `logic/view-model.ts` + `ui/*.svelte`,
store methods `heartbeatConfig()` / `setHeartbeatConfig()` (store.svelte.ts ~2303),
own view pane (`HeartbeatPane.svelte`), registered via `register-views.ts`.
- `ChatLimitField.svelte` is the canonical settings-field pattern: `$state`
value/saving/error/justSaved, `$effect` re-seed while untouched, dirty-guarded
save button, Enter-to-save, spinner while saving.
- Dropdown precedent: `features/chat/ui/ModelSelector.svelte`,
`ReasoningEffortSelector.svelte`, `HeartbeatView.svelte` all use native `<select>`
with DaisyUI classes.
- Each FE feature exposes a `manifest` (name/description) listed in
`ExtensionsPane.svelte`'s frontend-modules table — a new feature dir adds its
manifest there.
## 2. Proposed UI
New feature `src/features/search-settings/`:
```
features/search-settings/
├── index.ts # public exports + manifest
├── logic/view-model.ts # pure: options list, dirty check, validation
├── logic/view-model.test.ts
└── ui/SearchBackendField.svelte
└── SearchBackendField.test.ts
```
**`SearchBackendField.svelte`** — modeled on `ChatLimitField`:
- Native `<select class="select select-bordered select-sm">` bound to local `$state`.
- Options: "Firecrawl (self-hosted)" and "Umans (Exa)" (disabled with a "not
configured" suffix when `!configured` — i.e. no `UMANS_API_KEY`), **plus** a
`"none"` option labeled e.g. "None (search disabled)".
- **Save on change** (decided): a select commits immediately — no separate Set
button. Saving/saved/error feedback around the change.
- States: `saving` (spinner), `saved` ("Saved: <label>."), `error` (inline
`text-error`, revert select to last persisted value on failure).
- Loading state: skeleton/disabled select while `GET` is in flight.
- Help text below: one line explaining the selection affects the `web_search`
tool's *search* mode only — scrape/crawl still route to the self-hosted
Firecrawl backend (and `map` is being removed from the tool entirely).
**Placement**: inside `SettingsPane.svelte`, under the Chat limit section (user
asked for "a dropdown under settings"). The config is GLOBAL, so no
workspace-switch re-seeding is needed — load once on first mount (or on settings
view open) and cache in the store; the field still re-seeds from the persisted
value after a successful save (ChatLimitField's `$effect` pattern).
## 3. Store / API wiring
`store.svelte.ts` (port contract + implementation):
```ts
searchBackendConfig(): Promise<SearchBackendConfigResult>;
setSearchBackend(backend: string): Promise<SearchBackendConfigResult>;
// state: searchBackendConfig | null (loaded), loading flag, last error
```
Calls the backend contract (see backend plan §2.4 — GLOBAL routes):
```
GET /search/config → { backend, available: [{id,label,configured}] }
PUT /search/config { backend } → { backend } | 400 | 503
```
(No capabilities field — the selection affects search mode only; scrape/crawl
always route to Firecrawl. `map` is removed from the tool entirely, per the
backend plan.)
- Wire types live in `@dispatch/transport-contract` / `wire` (backend plan) — FE
types mirror them via the existing wire type imports.
- Load when the Settings pane mounts (lazy) or on workspace switch; cache like
heartbeatConfig does.
- 503 (extension not loaded) → render the field disabled with "search not
available" hint — graceful degradation, matching heartbeat's degrade philosophy.
## 4. Registering the module
- `features/search-settings/index.ts` exports `manifest` (name "Search Settings",
description) → add to the `ExtensionsPane.svelte` frontend-modules list so the
table doesn't drift from what's composed.
## 5. Tests / verification
- `view-model.test.ts`: options construction (available ∪ none, configured flag →
disabled option), dirty check, revert-on-error value computation.
- `SearchBackendField.test.ts`: render options from config, save-on-change fires
`save`, error state reverts selection, loading state disables.
- Store tests: `searchBackendConfig`/`setSearchBackend` wiring against a fake port,
workspace-switch re-seed.
- Full `bun run typecheck` / `bun run test` / `bun run check` per AGENTS.md.
## 6. Decisions (shared with backend plan §4)
1. Dropdown entries: exactly "Firecrawl (self-hosted)" + "Umans (Exa)", plus
"None (search disabled)". Umans sub-provider fixed to `exa`.
2. GLOBAL setting persisted to the backend (`GET/PUT /search/config`) — no
workspace-scoped behavior.
3. Save-on-change with saved/error feedback (no Set button).
4. Relay output decided: hits only — the tool returns parsed `SearchHit[]`;
the relay model's prose answer is discarded. No open questions remain.
|