@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.
- package/dist/components/chat/chat-page.d.ts.map +1 -1
- package/dist/components/chat/chat-page.js +23 -11
- package/dist/components/chat/chat-page.js.map +1 -1
- package/dist/components/layout/kbd.d.ts +6 -0
- package/dist/components/layout/kbd.d.ts.map +1 -0
- package/dist/components/layout/kbd.js +6 -0
- package/dist/components/layout/kbd.js.map +1 -0
- package/dist/components/quick-actions/add-modal.d.ts +5 -0
- package/dist/components/quick-actions/add-modal.d.ts.map +1 -0
- package/dist/components/quick-actions/add-modal.js +91 -0
- package/dist/components/quick-actions/add-modal.js.map +1 -0
- package/dist/components/quick-actions/search-modal.d.ts +5 -0
- package/dist/components/quick-actions/search-modal.d.ts.map +1 -0
- package/dist/components/quick-actions/search-modal.js +134 -0
- package/dist/components/quick-actions/search-modal.js.map +1 -0
- package/dist/components/settings/models-tab.d.ts.map +1 -1
- package/dist/components/settings/models-tab.js +9 -0
- package/dist/components/settings/models-tab.js.map +1 -1
- package/dist/lib/api-client.d.ts +22 -9
- package/dist/lib/api-client.d.ts.map +1 -1
- package/dist/lib/api-client.js +9 -1
- package/dist/lib/api-client.js.map +1 -1
- package/dist/lib/search-snippet.d.ts +15 -0
- package/dist/lib/search-snippet.d.ts.map +1 -0
- package/dist/lib/search-snippet.js +45 -0
- package/dist/lib/search-snippet.js.map +1 -0
- package/dist/stores/provider-store.d.ts.map +1 -1
- package/dist/stores/provider-store.js +10 -1
- package/dist/stores/provider-store.js.map +1 -1
- package/dist/stores/ui-store.d.ts +6 -0
- package/dist/stores/ui-store.d.ts.map +1 -1
- package/dist/stores/ui-store.js +6 -0
- package/dist/stores/ui-store.js.map +1 -1
- package/dist/styles.css +1 -1
- package/package.json +2 -2
- package/src/components/chat/chat-page.tsx +32 -10
- package/src/components/layout/kbd.tsx +10 -0
- package/src/components/quick-actions/add-modal.tsx +231 -0
- package/src/components/quick-actions/search-modal.tsx +285 -0
- package/src/components/settings/models-tab.tsx +9 -0
- package/src/lib/api-client.ts +40 -6
- package/src/lib/search-snippet.ts +64 -0
- package/src/stores/provider-store.ts +12 -1
- 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
|
-
{
|
|
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 {
|