@schlessera/brain-ui-react 0.30.0 → 0.31.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 (69) hide show
  1. package/dist/components/chat/brain-markdown.d.ts +6 -1
  2. package/dist/components/chat/brain-markdown.d.ts.map +1 -1
  3. package/dist/components/chat/brain-markdown.js +66 -7
  4. package/dist/components/chat/brain-markdown.js.map +1 -1
  5. package/dist/components/chat/chat-page.d.ts +9 -0
  6. package/dist/components/chat/chat-page.d.ts.map +1 -1
  7. package/dist/components/chat/chat-page.js +75 -388
  8. package/dist/components/chat/chat-page.js.map +1 -1
  9. package/dist/components/chat/composer.d.ts +18 -0
  10. package/dist/components/chat/composer.d.ts.map +1 -0
  11. package/dist/components/chat/composer.js +319 -0
  12. package/dist/components/chat/composer.js.map +1 -0
  13. package/dist/components/chat/message-bubble.d.ts +13 -2
  14. package/dist/components/chat/message-bubble.d.ts.map +1 -1
  15. package/dist/components/chat/message-bubble.js +16 -5
  16. package/dist/components/chat/message-bubble.js.map +1 -1
  17. package/dist/components/chat/use-chat-commands.d.ts +12 -0
  18. package/dist/components/chat/use-chat-commands.d.ts.map +1 -0
  19. package/dist/components/chat/use-chat-commands.js +82 -0
  20. package/dist/components/chat/use-chat-commands.js.map +1 -0
  21. package/dist/components/files/file-panel.d.ts.map +1 -1
  22. package/dist/components/files/file-panel.js +16 -5
  23. package/dist/components/files/file-panel.js.map +1 -1
  24. package/dist/components/layout/slide-panel.d.ts.map +1 -1
  25. package/dist/components/layout/slide-panel.js +11 -1
  26. package/dist/components/layout/slide-panel.js.map +1 -1
  27. package/dist/components/settings/settings-panel.d.ts.map +1 -1
  28. package/dist/components/settings/settings-panel.js +12 -4
  29. package/dist/components/settings/settings-panel.js.map +1 -1
  30. package/dist/components/settings/web-search-settings.d.ts.map +1 -1
  31. package/dist/components/settings/web-search-settings.js +12 -1
  32. package/dist/components/settings/web-search-settings.js.map +1 -1
  33. package/dist/hooks/use-deferred-unmount.d.ts +12 -0
  34. package/dist/hooks/use-deferred-unmount.d.ts.map +1 -0
  35. package/dist/hooks/use-deferred-unmount.js +24 -0
  36. package/dist/hooks/use-deferred-unmount.js.map +1 -0
  37. package/dist/hooks/use-websocket.d.ts +8 -0
  38. package/dist/hooks/use-websocket.d.ts.map +1 -1
  39. package/dist/hooks/use-websocket.js +66 -2
  40. package/dist/hooks/use-websocket.js.map +1 -1
  41. package/dist/index.d.ts +1 -2
  42. package/dist/index.d.ts.map +1 -1
  43. package/dist/index.js +3 -2
  44. package/dist/index.js.map +1 -1
  45. package/dist/lazy-pages.d.ts +3 -0
  46. package/dist/lazy-pages.d.ts.map +1 -0
  47. package/dist/lazy-pages.js +27 -0
  48. package/dist/lazy-pages.js.map +1 -0
  49. package/dist/lib/api-client.d.ts +5 -0
  50. package/dist/lib/api-client.d.ts.map +1 -1
  51. package/dist/lib/api-client.js.map +1 -1
  52. package/dist/styles.css +1 -1
  53. package/dist/theme.css +58 -0
  54. package/package.json +2 -2
  55. package/src/components/chat/brain-markdown.tsx +81 -9
  56. package/src/components/chat/chat-page.tsx +119 -709
  57. package/src/components/chat/composer.tsx +617 -0
  58. package/src/components/chat/message-bubble.tsx +24 -6
  59. package/src/components/chat/use-chat-commands.ts +87 -0
  60. package/src/components/files/file-panel.tsx +25 -8
  61. package/src/components/layout/slide-panel.tsx +13 -1
  62. package/src/components/settings/settings-panel.tsx +21 -10
  63. package/src/components/settings/web-search-settings.tsx +22 -0
  64. package/src/hooks/use-deferred-unmount.ts +24 -0
  65. package/src/hooks/use-websocket.ts +79 -2
  66. package/src/index.ts +3 -2
  67. package/src/lazy-pages.tsx +41 -0
  68. package/src/lib/api-client.ts +5 -0
  69. package/src/theme.css +58 -0
@@ -1,37 +1,12 @@
1
- import { useState, useRef, useEffect } from "react";
2
- import {
3
- ArrowUp,
4
- ArrowDown,
5
- Square,
6
- CornerLeftUp,
7
- Paperclip,
8
- Camera,
9
- X,
10
- Check,
11
- ChevronDown,
12
- Lock,
13
- } from "lucide-react";
14
- import {
15
- useChatStore,
16
- activeChat,
17
- type MessageAttachment,
18
- } from "../../stores/chat-store.js";
19
- import type { MessageSource, AskUserAnnotation } from "@schlessera/brain-ui-sdk/protocol";
20
- import { uiConfig } from "../../config.js";
21
- import { useConnectionStore } from "../../stores/connection-store.js";
22
- import { useProviderStore } from "../../stores/provider-store.js";
1
+ import { useState, useRef, useEffect, useLayoutEffect, useCallback } from "react";
2
+ import { ArrowDown, ChevronUp } from "lucide-react";
3
+ import { useChatStore, activeChat } from "../../stores/chat-store.js";
4
+ import type { AskUserAnnotation } from "@schlessera/brain-ui-sdk/protocol";
23
5
  import { useUIStore } from "../../stores/ui-store.js";
24
6
  import { useWebSocket } from "../../hooks/use-websocket.js";
25
7
  import { MaskEditor } from "../images/mask-editor.js";
26
- import {
27
- fileToAttachment,
28
- validateAttachments,
29
- type PendingAttachment,
30
- } from "../../lib/image-attachments.js";
31
8
  import { MessageBubble } from "./message-bubble.js";
32
9
  import { WelcomeState } from "./welcome-state.js";
33
- import { ShareIntake } from "./share-card.js";
34
- import { CommandPalette } from "./command-palette.js";
35
10
  import { SessionDrawer } from "./session-drawer.js";
36
11
  import { SubagentView } from "./subagent-view.js";
37
12
  import { DigestCard } from "../activity/digest-card.js";
@@ -41,89 +16,47 @@ import { WhatsupPanel } from "../quick-actions/whatsup-modal.js";
41
16
  import { SearchPanel } from "../quick-actions/search-modal.js";
42
17
  import { AddPanel } from "../quick-actions/add-modal.js";
43
18
  import { FilePanel } from "../files/file-panel.js";
44
- import { MicButton } from "../voice/mic-button.js";
45
- import { DictationSheet } from "../voice/dictation-sheet.js";
46
- import { ReviewCard } from "../voice/review-card.js";
47
- import { useDictation } from "../../voice/use-dictation.js";
48
- import { useVoiceStore } from "../../voice/voice-store.js";
49
- import { api } from "../../lib/api-client.js";
19
+ import { Composer } from "./composer.js";
20
+ import { useChatCommands } from "./use-chat-commands.js";
50
21
  import { getBackendUrl } from "../../lib/backend.js";
51
22
  import {
52
- detectClientEnvironment,
53
23
  primeClientEnvironment,
54
24
  READING_COLUMN_ATTR,
55
25
  } from "../../lib/client-environment.js";
56
- import { cn } from "../../lib/utils.js";
57
26
 
27
+ /**
28
+ * How much of a long transcript is rendered at once, and how much more each
29
+ * "show earlier" reveals.
30
+ *
31
+ * The transcript is the one part of this app with no natural size limit: a
32
+ * resumed session can replay hundreds of messages, and every one of them is a
33
+ * parsed markdown tree that the browser then has to lay out and keep. Rendering
34
+ * a window instead bounds the DOM without a virtualiser — messages are wildly
35
+ * variable in height, and estimating that is where virtualisers get scroll
36
+ * position wrong.
37
+ */
38
+ const WINDOW_SIZE = 40;
39
+ const WINDOW_STEP = 40;
40
+
41
+ /**
42
+ * The chat surface: transcript, panels, and the composer.
43
+ *
44
+ * This component holds transcript-scale state only. The draft lives one level
45
+ * down in <Composer>, so a keystroke never reaches the message list — see the
46
+ * note there. Every callback handed to a message is wrapped in useCallback for
47
+ * the same reason: MessageBubble is memoized, and an unstable prop would undo
48
+ * that on the first re-render.
49
+ */
58
50
  export function ChatPage() {
59
- const [input, setInput] = useState("");
60
- const [lastPrompt, setLastPrompt] = useState("");
61
- const [showCommandPalette, setShowCommandPalette] = useState(false);
62
51
  const scrollRef = useRef<HTMLDivElement>(null);
63
52
  const autoScrollRef = useRef(true);
64
53
  const [showScrollButton, setShowScrollButton] = useState(false);
65
- const textareaRef = useRef<HTMLTextAreaElement>(null);
66
54
  const { send } = useWebSocket();
67
55
 
68
- // Image attachments. `attachmentsRef` mirrors state so async add/merge logic
69
- // reads the current set synchronously (avoids stale closures / updater races).
70
- const [attachments, setAttachments] = useState<PendingAttachment[]>([]);
71
- const attachmentsRef = useRef<PendingAttachment[]>([]);
72
- const [attachErrors, setAttachErrors] = useState<string[]>([]);
73
- const libraryInputRef = useRef<HTMLInputElement>(null);
74
- const cameraInputRef = useRef<HTMLInputElement>(null);
75
- useEffect(() => {
76
- attachmentsRef.current = attachments;
77
- }, [attachments]);
78
- // Revoke preview URLs of attachments that were never sent (unmount cleanup;
79
- // sent attachments transfer URL ownership to the message in the chat store).
80
- useEffect(
81
- () => () => {
82
- for (const a of attachmentsRef.current) URL.revokeObjectURL(a.previewUrl);
83
- },
84
- []
85
- );
86
-
87
- // Provider picker
88
- const [providerMenuOpen, setProviderMenuOpen] = useState(false);
89
- const providerMenuRef = useRef<HTMLDivElement>(null);
90
-
91
56
  const messages = useChatStore((s) => activeChat(s).messages);
92
- const isStreaming = useChatStore((s) => activeChat(s).isStreaming);
93
57
  const sessionId = useChatStore((s) => s.activeSessionId);
94
58
  const clearMessages = useChatStore((s) => s.clearMessages);
95
59
  const setActiveSession = useChatStore((s) => s.setActiveSession);
96
- // Key-bound buffer actions: this page always operates on the buffer in view
97
- // (the active session, or the draft when no session is bound yet).
98
- const startAssistantMessage = () =>
99
- useChatStore.getState().startAssistantMessage(sessionId);
100
- const addUserMessage = (
101
- text: string,
102
- source?: MessageSource,
103
- atts?: MessageAttachment[]
104
- ) => useChatStore.getState().addUserMessage(sessionId, text, source, atts);
105
- const resolveToolApproval = (toolUseId: string, approved: boolean) =>
106
- useChatStore.getState().resolveToolApproval(sessionId, toolUseId, approved);
107
- const submitAskUserAnswers = (
108
- requestId: string,
109
- answers: Record<string, string>,
110
- annotations?: Record<string, AskUserAnnotation>
111
- ) =>
112
- useChatStore.getState().submitAskUserAnswers(sessionId, requestId, answers, annotations);
113
- const cancelAskUser = (requestId: string) =>
114
- useChatStore.getState().cancelAskUser(sessionId, requestId);
115
- const appendText = (text: string) =>
116
- useChatStore.getState().appendText(sessionId, text);
117
- const finishAssistantMessage = () =>
118
- useChatStore.getState().finishAssistantMessage(sessionId);
119
- const wsStatus = useConnectionStore((s) => s.wsStatus);
120
-
121
- const providers = useProviderStore((s) => s.available);
122
- const selectedProviderId = useProviderStore((s) => s.selectedId);
123
- const pinnedProviderId = useProviderStore((s) => s.pinnedId);
124
- const setSelectedProvider = useProviderStore((s) => s.setSelected);
125
- const loadProviders = useProviderStore((s) => s.loadProviders);
126
- const backends = useProviderStore((s) => s.backends);
127
60
 
128
61
  const sessionPanelOpen = useUIStore((s) => s.sessionPanelOpen);
129
62
  const setSessionPanelOpen = useUIStore((s) => s.setSessionPanelOpen);
@@ -141,11 +74,38 @@ export function ChatPage() {
141
74
  const settingsPanelOpen = useUIStore((s) => s.settingsPanelOpen);
142
75
  const setSettingsPanelOpen = useUIStore((s) => s.setSettingsPanelOpen);
143
76
 
144
- // Voice dictation state
145
- const voiceMode = useVoiceStore((s) => s.mode);
146
- const reviewText = useVoiceStore((s) => s.reviewText);
147
- const clearReview = useVoiceStore((s) => s.clearReview);
148
- const dictation = useDictation();
77
+ const runCommand = useChatCommands();
78
+
79
+ // Only the tail of a long transcript is rendered; the rest is one tap away.
80
+ const [visibleCount, setVisibleCount] = useState(WINDOW_SIZE);
81
+ // Switching sessions starts a fresh window. Adjusted during render rather
82
+ // than in an effect, so the new session never paints with the old window.
83
+ const [windowedSession, setWindowedSession] = useState(sessionId);
84
+ if (windowedSession !== sessionId) {
85
+ setWindowedSession(sessionId);
86
+ setVisibleCount(WINDOW_SIZE);
87
+ }
88
+ const hiddenCount = Math.max(0, messages.length - visibleCount);
89
+ const visibleMessages = hiddenCount > 0 ? messages.slice(hiddenCount) : messages;
90
+
91
+ // Revealing earlier messages prepends content, which would otherwise shove
92
+ // the view down by the height of everything added. Remember the distance to
93
+ // the BOTTOM across the change and restore it, so the message the user was
94
+ // reading stays where it was.
95
+ const scrollAnchorRef = useRef<number | null>(null);
96
+ const showEarlier = useCallback(() => {
97
+ const el = scrollRef.current;
98
+ scrollAnchorRef.current = el ? el.scrollHeight - el.scrollTop : null;
99
+ setVisibleCount((n) => n + WINDOW_STEP);
100
+ }, []);
101
+ useLayoutEffect(() => {
102
+ const el = scrollRef.current;
103
+ const anchor = scrollAnchorRef.current;
104
+ if (el && anchor !== null) {
105
+ el.scrollTop = el.scrollHeight - anchor;
106
+ scrollAnchorRef.current = null;
107
+ }
108
+ });
149
109
 
150
110
  // Auto-scroll on new content when tailing is active
151
111
  useEffect(() => {
@@ -177,341 +137,57 @@ export function ChatPage() {
177
137
  return () => el.removeEventListener("scroll", handleScroll);
178
138
  }, [hasMessages]);
179
139
 
180
- // Auto-grow textarea
181
- useEffect(() => {
182
- const el = textareaRef.current;
183
- if (el) {
184
- el.style.height = "auto";
185
- el.style.height = Math.min(el.scrollHeight, 200) + "px";
186
- }
187
- }, [input]);
188
-
189
- // Show/hide command palette based on input
190
- useEffect(() => {
191
- if (input.startsWith("/")) {
192
- setShowCommandPalette(true);
193
- } else {
194
- setShowCommandPalette(false);
195
- }
196
- }, [input]);
197
-
198
- // Load the available provider combos once on mount.
199
- useEffect(() => {
200
- void loadProviders();
201
- }, [loadProviders]);
202
-
203
- // Dismiss the provider popover on outside-click / Escape.
204
- useEffect(() => {
205
- if (!providerMenuOpen) return;
206
- const onDocClick = (e: MouseEvent) => {
207
- if (!providerMenuRef.current?.contains(e.target as Node)) {
208
- setProviderMenuOpen(false);
209
- }
210
- };
211
- const onKey = (e: KeyboardEvent) => {
212
- if (e.key === "Escape") setProviderMenuOpen(false);
213
- };
214
- document.addEventListener("mousedown", onDocClick);
215
- document.addEventListener("keydown", onKey);
216
- return () => {
217
- document.removeEventListener("mousedown", onDocClick);
218
- document.removeEventListener("keydown", onKey);
219
- };
220
- }, [providerMenuOpen]);
221
-
222
- /**
223
- * The draft is whatever is visible above the send button: composer text
224
- * plus any voice text under review. Both send paths submit the combined
225
- * draft so nothing is silently dropped.
226
- */
227
- function draftText() {
228
- return [input.trim(), reviewText.trim()].filter(Boolean).join("\n");
229
- }
230
-
231
- /**
232
- * Decode, downscale and validate incoming image files, merging them with any
233
- * already-pending attachments. Rejected files (bad type / oversize / over the
234
- * per-message caps) surface as inline error lines; their object URLs are
235
- * revoked so nothing leaks.
236
- */
237
- async function addFiles(files: FileList | File[]) {
238
- const list = Array.from(files).filter((f) => f.type.startsWith("image/"));
239
- if (list.length === 0) return;
240
-
241
- const results = await Promise.all(list.map((f) => fileToAttachment(f)));
242
- const fresh: PendingAttachment[] = [];
243
- const errors: string[] = [];
244
- results.forEach((r, i) => {
245
- if ("error" in r) {
246
- errors.push(r.error);
140
+ const handleToolApproval = useCallback(
141
+ (toolUseId: string, approved: boolean, always?: boolean) => {
142
+ useChatStore.getState().resolveToolApproval(sessionId, toolUseId, approved);
143
+ if (approved) {
144
+ send({ type: "tool_approval", toolUseId, ...(always ? { always: true } : {}) });
247
145
  } else {
248
- fresh.push({ ...r, name: list[i].name });
146
+ send({ type: "tool_denial", toolUseId, message: "Denied by user" });
249
147
  }
250
- });
251
-
252
- const { accepted } = validateAttachments([
253
- ...attachmentsRef.current,
254
- ...fresh,
255
- ]);
256
- // Revoke URLs of freshly-decoded images that didn't make the cut.
257
- for (const f of fresh) {
258
- if (!accepted.includes(f)) {
259
- URL.revokeObjectURL(f.previewUrl);
260
- errors.push(`${f.name}: not added (message limit reached)`);
261
- }
262
- }
263
- attachmentsRef.current = accepted;
264
- setAttachments(accepted);
265
- setAttachErrors(errors);
266
- }
267
-
268
- function removeAttachment(index: number) {
269
- const next = attachmentsRef.current.filter((item, i) => {
270
- if (i === index) URL.revokeObjectURL(item.previewUrl);
271
- return i !== index;
272
- });
273
- attachmentsRef.current = next;
274
- setAttachments(next);
275
- }
276
-
277
- async function onFilePick(e: React.ChangeEvent<HTMLInputElement>) {
278
- const files = e.target.files;
279
- if (files && files.length > 0) await addFiles(files);
280
- // Reset so picking the same file again still fires onChange.
281
- e.target.value = "";
282
- }
283
-
284
- function handleSubmit() {
285
- const text = draftText();
286
- const hasAttachments = attachments.length > 0;
287
- if ((!text && !hasAttachments) || wsStatus !== "connected") return;
288
-
289
- if (text) setLastPrompt(text);
290
- const messageAttachments = attachments.map((a) => ({
291
- previewUrl: a.previewUrl,
292
- mediaType: a.attachment.mediaType,
293
- }));
294
- addUserMessage(
295
- text,
296
- reviewText.trim() ? "voice-dictate" : "typed",
297
- messageAttachments.length > 0 ? messageAttachments : undefined
298
- );
299
- // A send while the session is already streaming is a follow-up — the server
300
- // queues it or delivers it live; don't pre-start a second assistant bubble
301
- // (the backend's next frames start it).
302
- if (!isStreaming) startAssistantMessage();
303
- // Correlate this turn when it is starting a NEW conversation, so its
304
- // session_info can be told apart from a background turn's.
305
- const draftId = sessionId ? undefined : useChatStore.getState().startDraftTurn();
306
- send({
307
- type: "chat_message",
308
- text,
309
- sessionId: sessionId ?? undefined,
310
- ...(draftId ? { draftId } : {}),
311
- // Provider only applies to new conversations; resumed sessions are
312
- // pinned server-side to their original combo.
313
- // Only send a provider the server actually offers — a stale persisted
314
- // id (e.g. key removed server-side) falls back to the server default
315
- // instead of erroring with PROVIDER_UNAVAILABLE.
316
- providerId:
317
- sessionId || !providers.some((p) => p.id === selectedProviderId)
318
- ? undefined
319
- : selectedProviderId,
320
- attachments: hasAttachments
321
- ? attachments.map((a) => a.attachment)
322
- : undefined,
323
- // Measured per send, not once per session: the same tab can rotate,
324
- // move to an external display, or be installed as a PWA mid-conversation.
325
- client: detectClientEnvironment(),
326
- });
327
- setInput("");
328
- clearReview();
329
- // Ownership of the preview URLs transfers to the rendered user message
330
- // (revoked later by the chat store on clear/resume) — don't revoke here.
331
- attachmentsRef.current = [];
332
- setAttachments([]);
333
- setAttachErrors([]);
334
- }
335
-
336
- function handleMicTap() {
337
- if (voiceMode === "dictate") {
338
- void dictation.stop(true);
339
- } else {
340
- // Defensive: blur composer so the keyboard never fights the mic sheet on Android
341
- textareaRef.current?.blur();
342
- // Any existing review text stays put — the new capture appends to it.
343
- void dictation.start();
344
- }
345
- }
346
-
347
- function handleVoiceSend() {
348
- if (!draftText() || isStreaming || wsStatus !== "connected") {
349
- // If we cannot send right now, fall back to editing
350
- handleVoiceEdit();
351
- return;
352
- }
353
- handleSubmit();
354
- }
355
-
356
- function handleVoiceEdit() {
357
- setInput((prev) => (prev ? `${prev}\n${reviewText}` : reviewText));
358
- clearReview();
359
- setTimeout(() => textareaRef.current?.focus(), 50);
360
- }
361
-
362
- function handleVoiceAppend() {
363
- // Review text stays in place; the next capture appends to it on stop.
364
- void dictation.start();
365
- }
366
-
367
- function handleRecall() {
368
- if (lastPrompt && !input.trim()) {
369
- setInput(lastPrompt);
370
- setTimeout(() => textareaRef.current?.focus(), 50);
371
- }
372
- }
373
-
374
- function handleCancel() {
375
- // Scope the cancel to the session in view so it is unambiguous when several
376
- // sessions are running.
377
- send({ type: "cancel", sessionId: sessionId ?? undefined });
378
- }
379
-
380
- function handleToolApproval(toolUseId: string, approved: boolean, always?: boolean) {
381
- resolveToolApproval(toolUseId, approved);
382
- if (approved) {
383
- send({ type: "tool_approval", toolUseId, ...(always ? { always: true } : {}) });
384
- } else {
385
- send({ type: "tool_denial", toolUseId, message: "Denied by user" });
386
- }
387
- }
388
-
389
- function handleAskUserSubmit(
390
- requestId: string,
391
- answers: Record<string, string>,
392
- annotations?: Record<string, AskUserAnnotation>
393
- ) {
394
- submitAskUserAnswers(requestId, answers, annotations);
395
- send({ type: "ask_user_response", requestId, answers, annotations });
396
- }
397
-
398
- function handleAskUserCancel(requestId: string) {
399
- cancelAskUser(requestId);
400
- send({ type: "ask_user_cancel", requestId, reason: "User dismissed" });
401
- }
402
-
403
- function handleSessionResume(resumeSessionId: string) {
404
- clearMessages();
405
- setActiveSession(resumeSessionId);
406
- send({ type: "session_resume", sessionId: resumeSessionId });
407
- }
408
-
409
- function handleCommand(command: string) {
410
- setInput("");
411
- setShowCommandPalette(false);
412
-
413
- // Search and add talk to the brain CLI over REST, not to the agent — they
414
- // stay available while a turn streams or the socket is down.
415
- switch (command) {
416
- case "search":
417
- setSearchPanelOpen(true);
418
- return;
419
- case "add":
420
- setAddPanelOpen(true);
421
- return;
422
- }
148
+ },
149
+ [send, sessionId]
150
+ );
423
151
 
424
- const disabled = wsStatus !== "connected" || isStreaming;
425
- if (disabled) return;
152
+ const handleAskUserSubmit = useCallback(
153
+ (
154
+ requestId: string,
155
+ answers: Record<string, string>,
156
+ annotations?: Record<string, AskUserAnnotation>
157
+ ) => {
158
+ useChatStore
159
+ .getState()
160
+ .submitAskUserAnswers(sessionId, requestId, answers, annotations);
161
+ send({ type: "ask_user_response", requestId, answers, annotations });
162
+ },
163
+ [send, sessionId]
164
+ );
426
165
 
427
- switch (command) {
428
- case "sync":
429
- setSyncPanelOpen(true);
430
- break;
431
- case "whatsup":
432
- setWhatsupPanelOpen(true);
433
- break;
434
- case "stats":
435
- runStatsAction();
436
- break;
437
- }
438
- }
166
+ const handleAskUserCancel = useCallback(
167
+ (requestId: string) => {
168
+ useChatStore.getState().cancelAskUser(sessionId, requestId);
169
+ send({ type: "ask_user_cancel", requestId, reason: "User dismissed" });
170
+ },
171
+ [send, sessionId]
172
+ );
439
173
 
440
- async function runStatsAction() {
441
- addUserMessage("Stats");
442
- startAssistantMessage();
443
- try {
444
- const stats = await api.brainStats();
445
- const result = [
446
- `**Brain Statistics**`,
447
- `- Documents: ${stats.documents}`,
448
- `- Tags: ${stats.tags}`,
449
- `- Links: ${stats.links}`,
450
- ``,
451
- `**By Type:** ${Object.entries(stats.byType)
452
- .sort(([, a], [, b]) => b - a)
453
- .map(([t, n]) => `${t} (${n})`)
454
- .join(", ")}`,
455
- ``,
456
- `**By Status:** ${Object.entries(stats.byStatus)
457
- .map(([s, n]) => `${s} (${n})`)
458
- .join(", ")}`,
459
- ].join("\n");
460
- appendText(result);
461
- } catch (err) {
462
- appendText(
463
- `**Error:** ${err instanceof Error ? err.message : "Action failed"}`
464
- );
465
- } finally {
466
- finishAssistantMessage();
467
- }
468
- }
174
+ const handleSessionResume = useCallback(
175
+ (resumeSessionId: string) => {
176
+ clearMessages();
177
+ setActiveSession(resumeSessionId);
178
+ send({ type: "session_resume", sessionId: resumeSessionId });
179
+ },
180
+ [send, clearMessages, setActiveSession]
181
+ );
469
182
 
470
- function scrollToBottom() {
183
+ const scrollToBottom = useCallback(() => {
471
184
  const el = scrollRef.current;
472
185
  if (el) {
473
186
  el.scrollTop = el.scrollHeight;
474
187
  autoScrollRef.current = true;
475
188
  setShowScrollButton(false);
476
189
  }
477
- }
478
-
479
- function handleWelcomeAction(action: string) {
480
- handleCommand(action);
481
- }
482
-
483
- const hasDraft = Boolean(
484
- input.trim() || reviewText.trim() || attachments.length > 0
485
- );
486
- // A send while a session is running is a follow-up (not blocked by streaming).
487
- const canSend = hasDraft && wsStatus === "connected";
488
-
489
- // Provider picker: locked to the pinned combo once a session is live.
490
- // Lock while streaming too: the first send of a new conversation pins the
491
- // provider server-side before session_info delivers the sessionId.
492
- const providerLocked = sessionId != null || isStreaming;
493
- const displayProviderId = providerLocked
494
- ? pinnedProviderId ?? selectedProviderId
495
- : selectedProviderId;
496
- const displayProvider = providers.find((p) => p.id === displayProviderId);
497
- // A session can be pinned to a profile the user has since hidden — it is gone
498
- // from the picker but still running the turn. Show its id rather than
499
- // claiming "Default model", which would name a different model than the one
500
- // actually answering.
501
- const displayProviderLabel =
502
- displayProvider?.label ?? displayProviderId ?? "Default model";
503
- const showProviderPicker = providers.length > 1;
504
- // What happens if the user sends into the currently-running session.
505
- const displayBackendId = displayProvider?.backendId;
506
- const followUpLive = displayBackendId
507
- ? backends[displayBackendId]?.capabilities.followUp ?? false
508
- : false;
509
- const followUpHint =
510
- isStreaming && sessionId
511
- ? followUpLive
512
- ? "Follows up live"
513
- : "Will queue"
514
- : null;
190
+ }, []);
515
191
 
516
192
  return (
517
193
  <div className="flex flex-1 flex-col overflow-hidden">
@@ -575,7 +251,7 @@ export function ChatPage() {
575
251
  <div className="px-4 pt-3 md:px-6">
576
252
  <DigestCard />
577
253
  </div>
578
- <WelcomeState onAction={handleWelcomeAction} />
254
+ <WelcomeState onAction={runCommand} />
579
255
  </div>
580
256
  ) : (
581
257
  <div ref={scrollRef} className="h-full overflow-y-auto px-4 md:px-6">
@@ -585,7 +261,23 @@ export function ChatPage() {
585
261
  {...{ [READING_COLUMN_ATTR]: "" }}
586
262
  className="mx-auto max-w-3xl divide-y divide-border/20"
587
263
  >
588
- {messages.map((msg) => (
264
+ {hiddenCount > 0 && (
265
+ <div className="flex justify-center py-3">
266
+ <button
267
+ type="button"
268
+ onClick={showEarlier}
269
+ className="flex items-center gap-1.5 rounded-lg border border-border bg-surface px-3 py-1.5 text-xs text-muted-foreground transition-colors hover:text-foreground"
270
+ >
271
+ <ChevronUp className="h-3.5 w-3.5" />
272
+ Show {Math.min(hiddenCount, WINDOW_STEP)} earlier message
273
+ {Math.min(hiddenCount, WINDOW_STEP) === 1 ? "" : "s"}
274
+ <span className="text-muted-foreground/50">
275
+ ({hiddenCount} hidden)
276
+ </span>
277
+ </button>
278
+ </div>
279
+ )}
280
+ {visibleMessages.map((msg) => (
589
281
  <MessageBubble
590
282
  key={msg.id}
591
283
  message={msg}
@@ -610,289 +302,7 @@ export function ChatPage() {
610
302
  )}
611
303
  </div>
612
304
 
613
- {/* Dictation sheet */}
614
- <DictationSheet
615
- open={voiceMode === "dictate"}
616
- onStop={() => dictation.stop(true)}
617
- onCancel={() => dictation.cancel()}
618
- />
619
-
620
- {/* Input composer */}
621
- <div className="px-4 pt-2 pb-4 md:px-6 md:pb-6">
622
- <div className="mx-auto max-w-3xl">
623
- {/* Anything shared from the OS waits here for a tap. Above the
624
- composer, so it reads as something to act on rather than a
625
- notification that has already happened. */}
626
- <ShareIntake />
627
-
628
- {/* Voice review card sits above the composer */}
629
- <ReviewCard
630
- text={reviewText}
631
- onSend={handleVoiceSend}
632
- onEdit={handleVoiceEdit}
633
- onDiscard={clearReview}
634
- onAppend={handleVoiceAppend}
635
- />
636
-
637
- {/* Rejected-file errors */}
638
- {attachErrors.length > 0 && (
639
- <div className="mb-2 flex items-start gap-2 rounded-lg border border-destructive/30 bg-destructive/5 px-3 py-2 text-[11px] text-destructive">
640
- <div className="flex-1 space-y-0.5">
641
- {attachErrors.map((e, i) => (
642
- <div key={i}>{e}</div>
643
- ))}
644
- </div>
645
- <button
646
- type="button"
647
- onClick={() => setAttachErrors([])}
648
- title="Dismiss"
649
- className="shrink-0 rounded p-0.5 transition-colors hover:text-foreground"
650
- >
651
- <X className="h-3.5 w-3.5" />
652
- </button>
653
- </div>
654
- )}
655
-
656
- {/* Image attachment preview strip */}
657
- {attachments.length > 0 && (
658
- <div className="mb-2 flex flex-wrap gap-2">
659
- {attachments.map((a, i) => (
660
- <div key={i} className="relative h-16 w-16 shrink-0">
661
- <img
662
- src={a.previewUrl}
663
- alt={a.name}
664
- className="h-16 w-16 rounded-lg border border-border object-cover"
665
- />
666
- <button
667
- type="button"
668
- onClick={() => removeAttachment(i)}
669
- title="Remove"
670
- className="absolute -right-1.5 -top-1.5 flex h-5 w-5 items-center justify-center rounded-full border border-border bg-surface text-muted-foreground shadow transition-colors hover:text-destructive"
671
- >
672
- <X className="h-3 w-3" />
673
- </button>
674
- </div>
675
- ))}
676
- </div>
677
- )}
678
-
679
- <div
680
- className={cn(
681
- "relative rounded-2xl border border-border bg-surface shadow-lg transition-all duration-200",
682
- "focus-within:border-primary/40 focus-within:shadow-[0_0_20px_rgba(224,159,62,0.05)]"
683
- )}
684
- >
685
- {/* Command palette */}
686
- {showCommandPalette && (
687
- <CommandPalette
688
- filter={input.slice(1)}
689
- onSelect={handleCommand}
690
- />
691
- )}
692
-
693
- {/* Hidden file inputs for the paperclip / camera buttons */}
694
- <input
695
- ref={libraryInputRef}
696
- type="file"
697
- accept="image/*"
698
- multiple
699
- hidden
700
- onChange={onFilePick}
701
- />
702
- <input
703
- ref={cameraInputRef}
704
- type="file"
705
- accept="image/*"
706
- capture="environment"
707
- hidden
708
- onChange={onFilePick}
709
- />
710
-
711
- <textarea
712
- ref={textareaRef}
713
- value={input}
714
- onChange={(e) => setInput(e.target.value)}
715
- onPaste={(e) => {
716
- const files = e.clipboardData?.files;
717
- if (files && files.length > 0) {
718
- const images = Array.from(files).filter((f) =>
719
- f.type.startsWith("image/")
720
- );
721
- if (images.length > 0) {
722
- e.preventDefault();
723
- void addFiles(images);
724
- }
725
- }
726
- }}
727
- onKeyDown={(e) => {
728
- if (e.key === "Enter" && !e.shiftKey) {
729
- if (showCommandPalette) return;
730
- // On desktop (>=768px), Enter sends. On mobile, Enter inserts newline.
731
- if (window.matchMedia("(min-width: 768px)").matches) {
732
- e.preventDefault();
733
- handleSubmit();
734
- }
735
- }
736
- if (e.key === "ArrowUp" && !input.trim()) {
737
- handleRecall();
738
- }
739
- if (e.key === "Escape") {
740
- setShowCommandPalette(false);
741
- }
742
- }}
743
- placeholder={
744
- wsStatus !== "connected"
745
- ? "Connecting..."
746
- : uiConfig.composerPlaceholder
747
- }
748
- disabled={wsStatus !== "connected"}
749
- rows={1}
750
- className="w-full resize-none bg-transparent px-5 py-4 text-sm text-foreground placeholder:text-muted-foreground/40 focus:outline-none disabled:opacity-50"
751
- style={{ minHeight: "56px", maxHeight: "200px" }}
752
- />
753
-
754
- <div className="flex items-center justify-between px-4 pb-3">
755
- {/* Provider picker + hints */}
756
- <div className="flex min-w-0 items-center gap-3 text-[11px] text-muted-foreground/50">
757
- {showProviderPicker && (
758
- <div ref={providerMenuRef} className="relative">
759
- <button
760
- type="button"
761
- onClick={() =>
762
- !providerLocked && setProviderMenuOpen((v) => !v)
763
- }
764
- disabled={providerLocked}
765
- title={
766
- providerLocked
767
- ? "Provider is fixed for this conversation"
768
- : "Choose model"
769
- }
770
- className={cn(
771
- "flex max-w-[9rem] items-center gap-1 rounded-lg px-2 py-1 text-[11px] transition-colors md:max-w-[14rem]",
772
- providerLocked
773
- ? "cursor-default text-muted-foreground/60"
774
- : "text-muted-foreground hover:bg-surface-raised hover:text-foreground"
775
- )}
776
- >
777
- {providerLocked && (
778
- <Lock className="h-3 w-3 shrink-0 opacity-70" />
779
- )}
780
- <span className="truncate">
781
- {displayProviderLabel}
782
- </span>
783
- {!providerLocked && (
784
- <ChevronDown className="h-3 w-3 shrink-0 opacity-70" />
785
- )}
786
- </button>
787
- {providerMenuOpen && !providerLocked && (
788
- <div
789
- role="menu"
790
- className="absolute bottom-full left-0 z-50 mb-1 min-w-[14rem] overflow-hidden rounded-xl border border-border bg-surface-overlay py-1 shadow-2xl"
791
- >
792
- {providers.map((p) => (
793
- <button
794
- key={p.id}
795
- role="menuitem"
796
- type="button"
797
- onClick={() => {
798
- setSelectedProvider(p.id);
799
- setProviderMenuOpen(false);
800
- }}
801
- className="flex w-full items-center gap-2 px-3 py-2 text-left text-xs text-foreground transition-colors hover:bg-surface-raised"
802
- >
803
- <Check
804
- className={cn(
805
- "h-3.5 w-3.5 shrink-0 text-primary",
806
- p.id === selectedProviderId
807
- ? "opacity-100"
808
- : "opacity-0"
809
- )}
810
- />
811
- <span className="truncate">{p.label}</span>
812
- </button>
813
- ))}
814
- </div>
815
- )}
816
- </div>
817
- )}
818
- {followUpHint ? (
819
- <span className="text-primary/70">{followUpHint}</span>
820
- ) : (
821
- <>
822
- <span className="hidden sm:inline">
823
- <span className="font-[family-name:var(--font-mono)]">/</span>{" "}
824
- for commands
825
- </span>
826
- <span className="hidden md:inline">Shift+Enter for newline</span>
827
- </>
828
- )}
829
- </div>
830
-
831
- {/* Attach + Recall + Mic + Send / Cancel */}
832
- <div className="flex items-center gap-1.5">
833
- <button
834
- type="button"
835
- onClick={() => libraryInputRef.current?.click()}
836
- disabled={isStreaming || wsStatus !== "connected"}
837
- title="Attach images"
838
- aria-label="Attach images"
839
- className="flex h-8 w-8 items-center justify-center rounded-lg text-muted-foreground transition-all duration-150 hover:bg-surface-raised hover:text-foreground disabled:cursor-not-allowed disabled:opacity-30"
840
- >
841
- <Paperclip className="h-4 w-4" />
842
- </button>
843
- <button
844
- type="button"
845
- onClick={() => cameraInputRef.current?.click()}
846
- disabled={isStreaming || wsStatus !== "connected"}
847
- title="Take a photo"
848
- aria-label="Take a photo"
849
- className="flex h-8 w-8 items-center justify-center rounded-lg text-muted-foreground transition-all duration-150 hover:bg-surface-raised hover:text-foreground disabled:cursor-not-allowed disabled:opacity-30"
850
- >
851
- <Camera className="h-4 w-4" />
852
- </button>
853
- {lastPrompt && !input.trim() && !isStreaming && (
854
- <button
855
- type="button"
856
- onClick={handleRecall}
857
- title="Recall last prompt"
858
- className="flex h-8 w-8 items-center justify-center rounded-lg text-muted-foreground transition-all duration-150 hover:bg-surface-raised hover:text-foreground"
859
- >
860
- <CornerLeftUp className="h-4 w-4" />
861
- </button>
862
- )}
863
- <MicButton
864
- active={voiceMode === "dictate"}
865
- disabled={isStreaming || wsStatus !== "connected"}
866
- onTap={handleMicTap}
867
- />
868
- {isStreaming && !hasDraft ? (
869
- // Streaming with nothing drafted: the primary action is cancel.
870
- <button
871
- type="button"
872
- onClick={handleCancel}
873
- title="Stop the running turn"
874
- className="flex h-8 w-8 items-center justify-center rounded-lg bg-destructive text-primary-foreground transition-all duration-150"
875
- >
876
- <Square className="h-3.5 w-3.5" />
877
- </button>
878
- ) : (
879
- // A draft always sends — as a new turn, or a follow-up when a
880
- // session is already running.
881
- <button
882
- type="button"
883
- onClick={handleSubmit}
884
- disabled={!canSend}
885
- title={followUpHint ?? "Send"}
886
- className="flex h-8 w-8 items-center justify-center rounded-lg bg-primary text-primary-foreground transition-all duration-150 hover:brightness-110 disabled:opacity-30"
887
- >
888
- <ArrowUp className="h-4 w-4" />
889
- </button>
890
- )}
891
- </div>
892
- </div>
893
- </div>
894
- </div>
895
- </div>
305
+ <Composer send={send} />
896
306
  </div>
897
307
  );
898
308
  }