@schlessera/brain-ui-react 0.5.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/dist/components/chat/chat-page.d.ts.map +1 -1
  2. package/dist/components/chat/chat-page.js +23 -11
  3. package/dist/components/chat/chat-page.js.map +1 -1
  4. package/dist/components/layout/kbd.d.ts +6 -0
  5. package/dist/components/layout/kbd.d.ts.map +1 -0
  6. package/dist/components/layout/kbd.js +6 -0
  7. package/dist/components/layout/kbd.js.map +1 -0
  8. package/dist/components/quick-actions/add-modal.d.ts +5 -0
  9. package/dist/components/quick-actions/add-modal.d.ts.map +1 -0
  10. package/dist/components/quick-actions/add-modal.js +91 -0
  11. package/dist/components/quick-actions/add-modal.js.map +1 -0
  12. package/dist/components/quick-actions/search-modal.d.ts +5 -0
  13. package/dist/components/quick-actions/search-modal.d.ts.map +1 -0
  14. package/dist/components/quick-actions/search-modal.js +134 -0
  15. package/dist/components/quick-actions/search-modal.js.map +1 -0
  16. package/dist/components/settings/models-tab.d.ts.map +1 -1
  17. package/dist/components/settings/models-tab.js +9 -0
  18. package/dist/components/settings/models-tab.js.map +1 -1
  19. package/dist/lib/api-client.d.ts +22 -9
  20. package/dist/lib/api-client.d.ts.map +1 -1
  21. package/dist/lib/api-client.js +9 -1
  22. package/dist/lib/api-client.js.map +1 -1
  23. package/dist/lib/search-snippet.d.ts +15 -0
  24. package/dist/lib/search-snippet.d.ts.map +1 -0
  25. package/dist/lib/search-snippet.js +45 -0
  26. package/dist/lib/search-snippet.js.map +1 -0
  27. package/dist/stores/provider-store.d.ts.map +1 -1
  28. package/dist/stores/provider-store.js +10 -1
  29. package/dist/stores/provider-store.js.map +1 -1
  30. package/dist/stores/ui-store.d.ts +6 -0
  31. package/dist/stores/ui-store.d.ts.map +1 -1
  32. package/dist/stores/ui-store.js +6 -0
  33. package/dist/stores/ui-store.js.map +1 -1
  34. package/dist/styles.css +1 -1
  35. package/package.json +2 -2
  36. package/src/components/chat/chat-page.tsx +32 -10
  37. package/src/components/layout/kbd.tsx +10 -0
  38. package/src/components/quick-actions/add-modal.tsx +231 -0
  39. package/src/components/quick-actions/search-modal.tsx +285 -0
  40. package/src/components/settings/models-tab.tsx +9 -0
  41. package/src/lib/api-client.ts +40 -6
  42. package/src/lib/search-snippet.ts +64 -0
  43. package/src/stores/provider-store.ts +12 -1
  44. package/src/stores/ui-store.ts +14 -0
@@ -34,6 +34,8 @@ import { SessionDrawer } from "./session-drawer.js";
34
34
  import { SettingsPanel } from "../settings/settings-panel.js";
35
35
  import { StreamingPanel } from "../quick-actions/streaming-modal.js";
36
36
  import { WhatsupPanel } from "../quick-actions/whatsup-modal.js";
37
+ import { SearchPanel } from "../quick-actions/search-modal.js";
38
+ import { AddPanel } from "../quick-actions/add-modal.js";
37
39
  import { FilePanel } from "../files/file-panel.js";
38
40
  import { MicButton } from "../voice/mic-button.js";
39
41
  import { DictationSheet } from "../voice/dictation-sheet.js";
@@ -120,6 +122,10 @@ export function ChatPage() {
120
122
  const setSyncPanelOpen = useUIStore((s) => s.setSyncPanelOpen);
121
123
  const whatsupPanelOpen = useUIStore((s) => s.whatsupPanelOpen);
122
124
  const setWhatsupPanelOpen = useUIStore((s) => s.setWhatsupPanelOpen);
125
+ const searchPanelOpen = useUIStore((s) => s.searchPanelOpen);
126
+ const setSearchPanelOpen = useUIStore((s) => s.setSearchPanelOpen);
127
+ const addPanelOpen = useUIStore((s) => s.addPanelOpen);
128
+ const setAddPanelOpen = useUIStore((s) => s.setAddPanelOpen);
123
129
  const filePanelOpen = useUIStore((s) => s.filePanelOpen);
124
130
  const setFilePanelOpen = useUIStore((s) => s.setFilePanelOpen);
125
131
  const settingsPanelOpen = useUIStore((s) => s.settingsPanelOpen);
@@ -381,6 +387,17 @@ export function ChatPage() {
381
387
  setInput("");
382
388
  setShowCommandPalette(false);
383
389
 
390
+ // Search and add talk to the brain CLI over REST, not to the agent — they
391
+ // stay available while a turn streams or the socket is down.
392
+ switch (command) {
393
+ case "search":
394
+ setSearchPanelOpen(true);
395
+ return;
396
+ case "add":
397
+ setAddPanelOpen(true);
398
+ return;
399
+ }
400
+
384
401
  const disabled = wsStatus !== "connected" || isStreaming;
385
402
  if (disabled) return;
386
403
 
@@ -391,15 +408,6 @@ export function ChatPage() {
391
408
  case "whatsup":
392
409
  setWhatsupPanelOpen(true);
393
410
  break;
394
- case "search":
395
- setInput("");
396
- // Focus the input with a placeholder hint
397
- setTimeout(() => textareaRef.current?.focus(), 50);
398
- break;
399
- case "add":
400
- setInput("");
401
- setTimeout(() => textareaRef.current?.focus(), 50);
402
- break;
403
411
  case "stats":
404
412
  runStatsAction();
405
413
  break;
@@ -463,6 +471,12 @@ export function ChatPage() {
463
471
  ? pinnedProviderId ?? selectedProviderId
464
472
  : selectedProviderId;
465
473
  const displayProvider = providers.find((p) => p.id === displayProviderId);
474
+ // A session can be pinned to a profile the user has since hidden — it is gone
475
+ // from the picker but still running the turn. Show its id rather than
476
+ // claiming "Default model", which would name a different model than the one
477
+ // actually answering.
478
+ const displayProviderLabel =
479
+ displayProvider?.label ?? displayProviderId ?? "Default model";
466
480
  const showProviderPicker = providers.length > 1;
467
481
  // What happens if the user sends into the currently-running session.
468
482
  const displayBackendId = displayProvider?.backendId;
@@ -498,6 +512,14 @@ export function ChatPage() {
498
512
  open={whatsupPanelOpen}
499
513
  onClose={() => setWhatsupPanelOpen(false)}
500
514
  />
515
+ <SearchPanel
516
+ open={searchPanelOpen}
517
+ onClose={() => setSearchPanelOpen(false)}
518
+ />
519
+ <AddPanel
520
+ open={addPanelOpen}
521
+ onClose={() => setAddPanelOpen(false)}
522
+ />
501
523
  <FilePanel
502
524
  open={filePanelOpen}
503
525
  onClose={() => setFilePanelOpen(false)}
@@ -700,7 +722,7 @@ export function ChatPage() {
700
722
  <Lock className="h-3 w-3 shrink-0 opacity-70" />
701
723
  )}
702
724
  <span className="truncate">
703
- {displayProvider?.label ?? "Default model"}
725
+ {displayProviderLabel}
704
726
  </span>
705
727
  {!providerLocked && (
706
728
  <ChevronDown className="h-3 w-3 shrink-0 opacity-70" />
@@ -0,0 +1,10 @@
1
+ import type { ReactNode } from "react";
2
+
3
+ /** A key cap for inline keyboard hints ("↵ to open"). */
4
+ export function Kbd({ children }: { children: ReactNode }) {
5
+ return (
6
+ <kbd className="rounded border border-border bg-surface-raised px-1 py-0.5 font-mono text-[10px] text-muted-foreground">
7
+ {children}
8
+ </kbd>
9
+ );
10
+ }
@@ -0,0 +1,231 @@
1
+ import { useState, useEffect, useRef } from "react";
2
+ import { Loader2, CheckCircle2, XCircle } from "lucide-react";
3
+ import { SlidePanel } from "../layout/slide-panel.js";
4
+ import { Kbd } from "../layout/kbd.js";
5
+ import { api } from "../../lib/api-client.js";
6
+
7
+ type State = "editing" | "saving" | "saved" | "error";
8
+
9
+ /** Split the tags field on commas; empties dropped so "a,,b," is clean. */
10
+ function parseTags(raw: string): string[] {
11
+ return raw
12
+ .split(",")
13
+ .map((t) => t.trim())
14
+ .filter(Boolean);
15
+ }
16
+
17
+ export function AddPanel({
18
+ open,
19
+ onClose,
20
+ }: {
21
+ open: boolean;
22
+ onClose: () => void;
23
+ }) {
24
+ const [content, setContent] = useState("");
25
+ const [title, setTitle] = useState("");
26
+ const [type, setType] = useState("");
27
+ const [tags, setTags] = useState("");
28
+ const [state, setState] = useState<State>("editing");
29
+ const [error, setError] = useState("");
30
+ // Types already in the brain, offered as completions — `brain add` accepts
31
+ // any string, so this is a hint list, not a closed set.
32
+ const [knownTypes, setKnownTypes] = useState<string[]>([]);
33
+
34
+ const contentRef = useRef<HTMLTextAreaElement>(null);
35
+
36
+ useEffect(() => {
37
+ if (!open) return;
38
+ setContent("");
39
+ setTitle("");
40
+ setType("");
41
+ setTags("");
42
+ setError("");
43
+ setState("editing");
44
+
45
+ const t = setTimeout(() => contentRef.current?.focus(), 120);
46
+
47
+ let cancelled = false;
48
+ api
49
+ .brainStats()
50
+ .then((s) => {
51
+ if (!cancelled) setKnownTypes(Object.keys(s.byType).sort());
52
+ })
53
+ .catch(() => {
54
+ // Completions are a nicety; a failed stats call must not block adding.
55
+ });
56
+
57
+ return () => {
58
+ cancelled = true;
59
+ clearTimeout(t);
60
+ };
61
+ }, [open]);
62
+
63
+ async function handleSave() {
64
+ const body = content.trim();
65
+ if (!body || state === "saving") return;
66
+
67
+ setState("saving");
68
+ setError("");
69
+ try {
70
+ await api.brainAdd(body, {
71
+ type: type.trim() || undefined,
72
+ title: title.trim() || undefined,
73
+ tags: parseTags(tags).length ? parseTags(tags) : undefined,
74
+ });
75
+ setState("saved");
76
+ } catch (err: any) {
77
+ setError(err?.message || "Add failed");
78
+ setState("error");
79
+ }
80
+ }
81
+
82
+ function handleAddAnother() {
83
+ setContent("");
84
+ setTitle("");
85
+ setType("");
86
+ setTags("");
87
+ setState("editing");
88
+ setTimeout(() => contentRef.current?.focus(), 0);
89
+ }
90
+
91
+ function handleKeyDown(e: React.KeyboardEvent) {
92
+ // Cmd/Ctrl+Enter saves from anywhere in the form.
93
+ if ((e.metaKey || e.ctrlKey) && e.key === "Enter") {
94
+ e.preventDefault();
95
+ void handleSave();
96
+ }
97
+ }
98
+
99
+ const saving = state === "saving";
100
+ const canSave = content.trim().length > 0 && !saving;
101
+
102
+ return (
103
+ <SlidePanel open={open} onClose={onClose} title="Add to brain" wide>
104
+ <div className="flex h-full flex-col" onKeyDown={handleKeyDown}>
105
+ {state === "saved" ? (
106
+ <div className="flex flex-1 flex-col items-center justify-center gap-4 px-8 text-center">
107
+ <CheckCircle2 className="h-8 w-8 text-primary" />
108
+ <p className="text-sm text-foreground">Added to your brain.</p>
109
+ <p className="text-xs text-muted-foreground">
110
+ Run a sync to index and push it.
111
+ </p>
112
+ <div className="flex gap-2">
113
+ <button
114
+ onClick={handleAddAnother}
115
+ className="rounded-lg bg-surface-raised px-4 py-1.5 text-xs font-medium text-foreground transition-colors hover:bg-surface-overlay"
116
+ >
117
+ Add another
118
+ </button>
119
+ <button
120
+ onClick={onClose}
121
+ className="rounded-lg bg-primary px-4 py-1.5 text-xs font-medium text-primary-foreground transition-opacity hover:opacity-90"
122
+ >
123
+ Done
124
+ </button>
125
+ </div>
126
+ </div>
127
+ ) : (
128
+ <>
129
+ <div className="flex-1 space-y-4 overflow-y-auto p-5">
130
+ <Field label="Note">
131
+ <textarea
132
+ ref={contentRef}
133
+ value={content}
134
+ onChange={(e) => setContent(e.target.value)}
135
+ rows={8}
136
+ placeholder="What do you want to remember?"
137
+ className="w-full resize-y rounded-lg border border-border bg-surface-raised px-3 py-2 text-sm text-foreground outline-none transition-colors placeholder:text-muted-foreground focus:border-primary/40"
138
+ />
139
+ </Field>
140
+
141
+ <Field label="Title" hint="optional, derived from the note if empty">
142
+ <input
143
+ value={title}
144
+ onChange={(e) => setTitle(e.target.value)}
145
+ className="w-full rounded-lg border border-border bg-surface-raised px-3 py-2 text-sm text-foreground outline-none transition-colors focus:border-primary/40"
146
+ />
147
+ </Field>
148
+
149
+ <div className="grid grid-cols-2 gap-3">
150
+ <Field label="Type" hint="optional">
151
+ <input
152
+ value={type}
153
+ onChange={(e) => setType(e.target.value)}
154
+ list="brain-add-types"
155
+ placeholder="note"
156
+ className="w-full rounded-lg border border-border bg-surface-raised px-3 py-2 text-sm text-foreground outline-none transition-colors placeholder:text-muted-foreground focus:border-primary/40"
157
+ />
158
+ <datalist id="brain-add-types">
159
+ {knownTypes.map((t) => (
160
+ <option key={t} value={t} />
161
+ ))}
162
+ </datalist>
163
+ </Field>
164
+
165
+ <Field label="Tags" hint="comma-separated">
166
+ <input
167
+ value={tags}
168
+ onChange={(e) => setTags(e.target.value)}
169
+ placeholder="idea, reading"
170
+ className="w-full rounded-lg border border-border bg-surface-raised px-3 py-2 text-sm text-foreground outline-none transition-colors placeholder:text-muted-foreground focus:border-primary/40"
171
+ />
172
+ </Field>
173
+ </div>
174
+
175
+ {state === "error" && (
176
+ <div className="flex items-start gap-2 rounded-lg border border-destructive/30 bg-destructive/10 px-3 py-2">
177
+ <XCircle className="mt-0.5 h-3.5 w-3.5 shrink-0 text-destructive" />
178
+ <span className="text-xs text-destructive">{error}</span>
179
+ </div>
180
+ )}
181
+ </div>
182
+
183
+ <div className="flex items-center justify-between border-t border-border px-5 py-3">
184
+ <span className="text-[11px] text-muted-foreground">
185
+ <Kbd>Ctrl</Kbd>/<Kbd>Cmd</Kbd> + <Kbd>↵</Kbd> to save
186
+ </span>
187
+ <div className="flex gap-2">
188
+ <button
189
+ onClick={onClose}
190
+ className="rounded-lg bg-surface-raised px-4 py-1.5 text-xs font-medium text-foreground transition-colors hover:bg-surface-overlay"
191
+ >
192
+ Cancel
193
+ </button>
194
+ <button
195
+ onClick={handleSave}
196
+ disabled={!canSave}
197
+ className="flex items-center gap-1.5 rounded-lg bg-primary px-4 py-1.5 text-xs font-medium text-primary-foreground transition-opacity hover:opacity-90 disabled:opacity-40"
198
+ >
199
+ {saving && <Loader2 className="h-3.5 w-3.5 animate-spin" />}
200
+ {saving ? "Saving..." : "Add"}
201
+ </button>
202
+ </div>
203
+ </div>
204
+ </>
205
+ )}
206
+ </div>
207
+ </SlidePanel>
208
+ );
209
+ }
210
+
211
+ function Field({
212
+ label,
213
+ hint,
214
+ children,
215
+ }: {
216
+ label: string;
217
+ hint?: string;
218
+ children: React.ReactNode;
219
+ }) {
220
+ return (
221
+ <label className="block space-y-1.5">
222
+ <span className="text-xs font-medium text-foreground">
223
+ {label}
224
+ {hint && (
225
+ <span className="font-normal text-muted-foreground"> — {hint}</span>
226
+ )}
227
+ </span>
228
+ {children}
229
+ </label>
230
+ );
231
+ }
@@ -0,0 +1,285 @@
1
+ import { useState, useEffect, useRef, useCallback } from "react";
2
+ import { Loader2, Search, X, AlertTriangle } from "lucide-react";
3
+ import { SlidePanel } from "../layout/slide-panel.js";
4
+ import { Kbd } from "../layout/kbd.js";
5
+ import { useFileStore } from "../../stores/file-store.js";
6
+ import { useUIStore } from "../../stores/ui-store.js";
7
+ import { api, type BrainSearchHit } from "../../lib/api-client.js";
8
+ import { parseSnippet } from "../../lib/search-snippet.js";
9
+ import { cn } from "../../lib/utils.js";
10
+
11
+ /** Wait this long after the last keystroke before hitting the CLI. */
12
+ const DEBOUNCE_MS = 250;
13
+ const MIN_QUERY = 2;
14
+ const LIMIT = 25;
15
+
16
+ type State = "idle" | "loading" | "done" | "error";
17
+
18
+ export function SearchPanel({
19
+ open,
20
+ onClose,
21
+ }: {
22
+ open: boolean;
23
+ onClose: () => void;
24
+ }) {
25
+ const [query, setQuery] = useState("");
26
+ const [state, setState] = useState<State>("idle");
27
+ const [results, setResults] = useState<BrainSearchHit[]>([]);
28
+ const [warnings, setWarnings] = useState<string[]>([]);
29
+ const [error, setError] = useState("");
30
+ const [selected, setSelected] = useState(0);
31
+
32
+ const inputRef = useRef<HTMLInputElement>(null);
33
+ const listRef = useRef<HTMLDivElement>(null);
34
+ const controllerRef = useRef<AbortController | null>(null);
35
+
36
+ const openFile = useFileStore((s) => s.openFile);
37
+ const setFilePanelOpen = useUIStore((s) => s.setFilePanelOpen);
38
+
39
+ const trimmed = query.trim();
40
+
41
+ const runSearch = useCallback(async (q: string) => {
42
+ controllerRef.current?.abort();
43
+ const controller = new AbortController();
44
+ controllerRef.current = controller;
45
+
46
+ setState("loading");
47
+ try {
48
+ const res = await api.brainSearch(q, {
49
+ limit: LIMIT,
50
+ signal: controller.signal,
51
+ });
52
+ if (controller.signal.aborted) return;
53
+ setResults(res.results);
54
+ setWarnings(res.warnings ?? []);
55
+ setSelected(0);
56
+ setState("done");
57
+ } catch (err: any) {
58
+ // A superseded request is not a failure — the newer one owns the state.
59
+ if (err?.name === "AbortError" || controller.signal.aborted) return;
60
+ setError(err?.message || "Search failed");
61
+ setResults([]);
62
+ setState("error");
63
+ } finally {
64
+ if (controllerRef.current === controller) controllerRef.current = null;
65
+ }
66
+ }, []);
67
+
68
+ // Fresh panel every time it opens: stale results from a previous query would
69
+ // otherwise flash before the first search lands.
70
+ useEffect(() => {
71
+ if (!open) {
72
+ controllerRef.current?.abort();
73
+ controllerRef.current = null;
74
+ return;
75
+ }
76
+ setQuery("");
77
+ setResults([]);
78
+ setWarnings([]);
79
+ setError("");
80
+ setState("idle");
81
+ setSelected(0);
82
+ // Focus after the slide-in starts; focusing mid-transform fights the
83
+ // mobile keyboard on some browsers.
84
+ const t = setTimeout(() => inputRef.current?.focus(), 120);
85
+ return () => clearTimeout(t);
86
+ }, [open]);
87
+
88
+ // Debounced search on typing.
89
+ useEffect(() => {
90
+ if (!open) return;
91
+ if (trimmed.length < MIN_QUERY) {
92
+ controllerRef.current?.abort();
93
+ controllerRef.current = null;
94
+ setResults([]);
95
+ setWarnings([]);
96
+ setState("idle");
97
+ return;
98
+ }
99
+ const t = setTimeout(() => void runSearch(trimmed), DEBOUNCE_MS);
100
+ return () => clearTimeout(t);
101
+ }, [open, trimmed, runSearch]);
102
+
103
+ function handleOpenResult(hit: BrainSearchHit) {
104
+ // The file panel takes the same right-hand slot, so hand the screen over
105
+ // rather than stacking two sheets.
106
+ onClose();
107
+ setFilePanelOpen(true);
108
+ void openFile(hit.path);
109
+ }
110
+
111
+ function handleKeyDown(e: React.KeyboardEvent<HTMLInputElement>) {
112
+ if (e.key === "ArrowDown" || e.key === "ArrowUp") {
113
+ if (results.length === 0) return;
114
+ e.preventDefault();
115
+ const delta = e.key === "ArrowDown" ? 1 : -1;
116
+ const next = (selected + delta + results.length) % results.length;
117
+ setSelected(next);
118
+ listRef.current
119
+ ?.querySelectorAll("[data-result]")
120
+ [next]?.scrollIntoView({ block: "nearest" });
121
+ return;
122
+ }
123
+ if (e.key === "Enter") {
124
+ e.preventDefault();
125
+ const hit = results[selected];
126
+ if (hit) handleOpenResult(hit);
127
+ // Enter with nothing selected jumps the debounce.
128
+ else if (trimmed.length >= MIN_QUERY) void runSearch(trimmed);
129
+ }
130
+ }
131
+
132
+ return (
133
+ <SlidePanel open={open} onClose={onClose} title="Search" wide>
134
+ <div className="flex h-full flex-col">
135
+ {/* Query field */}
136
+ <div className="border-b border-border px-5 py-3">
137
+ <div className="flex items-center gap-2 rounded-lg border border-border bg-surface-raised px-3 py-2 focus-within:border-primary/40">
138
+ <Search className="h-4 w-4 shrink-0 text-muted-foreground" />
139
+ <input
140
+ ref={inputRef}
141
+ value={query}
142
+ onChange={(e) => setQuery(e.target.value)}
143
+ onKeyDown={handleKeyDown}
144
+ placeholder="Search your brain..."
145
+ className="min-w-0 flex-1 bg-transparent text-sm text-foreground outline-none placeholder:text-muted-foreground"
146
+ autoComplete="off"
147
+ autoCorrect="off"
148
+ spellCheck={false}
149
+ />
150
+ {state === "loading" && (
151
+ <Loader2 className="h-4 w-4 shrink-0 animate-spin text-primary" />
152
+ )}
153
+ {query && state !== "loading" && (
154
+ <button
155
+ onClick={() => {
156
+ setQuery("");
157
+ inputRef.current?.focus();
158
+ }}
159
+ className="shrink-0 rounded p-0.5 text-muted-foreground transition-colors hover:text-foreground"
160
+ title="Clear"
161
+ >
162
+ <X className="h-3.5 w-3.5" />
163
+ </button>
164
+ )}
165
+ </div>
166
+ </div>
167
+
168
+ {/* Degraded-mode notices from the CLI (e.g. FTS-only, no vectors) */}
169
+ {warnings.length > 0 && (
170
+ <div className="flex items-start gap-2 border-b border-border bg-surface-raised/40 px-5 py-2">
171
+ <AlertTriangle className="mt-0.5 h-3.5 w-3.5 shrink-0 text-muted-foreground" />
172
+ <div className="text-[11px] leading-relaxed text-muted-foreground">
173
+ {warnings.map((w, i) => (
174
+ <div key={i}>{w}</div>
175
+ ))}
176
+ </div>
177
+ </div>
178
+ )}
179
+
180
+ {/* Results */}
181
+ <div ref={listRef} className="flex-1 overflow-y-auto">
182
+ {state === "error" ? (
183
+ <Placeholder>{error}</Placeholder>
184
+ ) : trimmed.length < MIN_QUERY ? (
185
+ <Placeholder>
186
+ <span>
187
+ Type at least {MIN_QUERY} characters. <Kbd>↑</Kbd> <Kbd>↓</Kbd> to
188
+ pick, <Kbd>↵</Kbd> to open.
189
+ </span>
190
+ </Placeholder>
191
+ ) : state === "done" && results.length === 0 ? (
192
+ <Placeholder>No results for “{trimmed}”.</Placeholder>
193
+ ) : (
194
+ <div className="divide-y divide-border/40">
195
+ {results.map((hit, i) => (
196
+ <ResultRow
197
+ key={hit.path}
198
+ hit={hit}
199
+ active={i === selected}
200
+ onMouseEnter={() => setSelected(i)}
201
+ onClick={() => handleOpenResult(hit)}
202
+ />
203
+ ))}
204
+ </div>
205
+ )}
206
+ </div>
207
+
208
+ {/* Footer */}
209
+ <div className="flex items-center justify-between border-t border-border px-5 py-3">
210
+ <span className="text-[11px] text-muted-foreground">
211
+ {state === "done" && results.length > 0
212
+ ? `${results.length}${results.length === LIMIT ? "+" : ""} result${results.length === 1 ? "" : "s"}`
213
+ : ""}
214
+ </span>
215
+ <button
216
+ onClick={onClose}
217
+ className="rounded-lg bg-surface-raised px-4 py-1.5 text-xs font-medium text-foreground transition-colors hover:bg-surface-overlay"
218
+ >
219
+ Close
220
+ </button>
221
+ </div>
222
+ </div>
223
+ </SlidePanel>
224
+ );
225
+ }
226
+
227
+ function ResultRow({
228
+ hit,
229
+ active,
230
+ onClick,
231
+ onMouseEnter,
232
+ }: {
233
+ hit: BrainSearchHit;
234
+ active: boolean;
235
+ onClick: () => void;
236
+ onMouseEnter: () => void;
237
+ }) {
238
+ return (
239
+ <button
240
+ data-result
241
+ onClick={onClick}
242
+ onMouseEnter={onMouseEnter}
243
+ title={hit.path}
244
+ className={cn(
245
+ "flex w-full flex-col gap-1 px-5 py-3 text-left transition-colors",
246
+ active ? "bg-surface-raised" : "hover:bg-surface-raised/60"
247
+ )}
248
+ >
249
+ <div className="flex items-baseline gap-2">
250
+ {hit.type && (
251
+ <span className="shrink-0 rounded bg-surface-overlay px-1.5 py-0.5 text-[10px] uppercase tracking-wide text-muted-foreground">
252
+ {hit.type}
253
+ </span>
254
+ )}
255
+ <span className="min-w-0 flex-1 truncate text-sm font-medium text-foreground">
256
+ {hit.title || hit.path}
257
+ </span>
258
+ </div>
259
+ {hit.snippet && (
260
+ <p className="line-clamp-2 text-xs leading-relaxed text-muted-foreground">
261
+ {parseSnippet(hit.snippet).map((seg, i) =>
262
+ seg.hit ? (
263
+ <span key={i} className="text-foreground">
264
+ {seg.text}
265
+ </span>
266
+ ) : (
267
+ <span key={i}>{seg.text}</span>
268
+ )
269
+ )}
270
+ </p>
271
+ )}
272
+ <span className="truncate font-mono text-[10px] text-muted-foreground/60">
273
+ {hit.path}
274
+ </span>
275
+ </button>
276
+ );
277
+ }
278
+
279
+ function Placeholder({ children }: { children: React.ReactNode }) {
280
+ return (
281
+ <div className="flex h-32 items-center justify-center px-8 text-center text-xs text-muted-foreground">
282
+ {children}
283
+ </div>
284
+ );
285
+ }
@@ -5,6 +5,7 @@ import type {
5
5
  ModelCatalogResponse,
6
6
  } from "@schlessera/brain-ui-sdk/protocol";
7
7
  import { api } from "../../lib/api-client.js";
8
+ import { useProviderStore } from "../../stores/provider-store.js";
8
9
  import { cn } from "../../lib/utils.js";
9
10
 
10
11
  /**
@@ -19,6 +20,7 @@ export function ModelsTab({ active }: { active: boolean }) {
19
20
  const [loading, setLoading] = useState(false);
20
21
  const [refreshing, setRefreshing] = useState(false);
21
22
  const [error, setError] = useState<string | null>(null);
23
+ const loadProviders = useProviderStore((s) => s.loadProviders);
22
24
 
23
25
  useEffect(() => {
24
26
  if (!active) return;
@@ -63,6 +65,10 @@ export function ModelsTab({ active }: { active: boolean }) {
63
65
  setError(null);
64
66
  try {
65
67
  setCatalog(await api.setHiddenModels(hidden));
68
+ // The composer's picker holds its own copy of the roster, fetched once on
69
+ // mount — without this it keeps offering a model the user just hid until
70
+ // the page is reloaded.
71
+ void loadProviders();
66
72
  } catch (err) {
67
73
  setCatalog(previous);
68
74
  setError(err instanceof Error ? err.message : "Could not save");
@@ -75,6 +81,9 @@ export function ModelsTab({ active }: { active: boolean }) {
75
81
  setError(null);
76
82
  try {
77
83
  setCatalog(await api.refreshModels());
84
+ // A refresh can surface newly released models — put them in the picker
85
+ // now, not on next load.
86
+ void loadProviders();
78
87
  } catch (err) {
79
88
  setError(err instanceof Error ? err.message : "Refresh failed");
80
89
  } finally {