@schlessera/brain-ui-react 0.30.1 → 0.32.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 (64) 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/passkey-tab.js +1 -1
  28. package/dist/components/settings/passkey-tab.js.map +1 -1
  29. package/dist/components/settings/settings-panel.d.ts.map +1 -1
  30. package/dist/components/settings/settings-panel.js +12 -4
  31. package/dist/components/settings/settings-panel.js.map +1 -1
  32. package/dist/hooks/use-deferred-unmount.d.ts +12 -0
  33. package/dist/hooks/use-deferred-unmount.d.ts.map +1 -0
  34. package/dist/hooks/use-deferred-unmount.js +24 -0
  35. package/dist/hooks/use-deferred-unmount.js.map +1 -0
  36. package/dist/hooks/use-websocket.d.ts +8 -0
  37. package/dist/hooks/use-websocket.d.ts.map +1 -1
  38. package/dist/hooks/use-websocket.js +66 -2
  39. package/dist/hooks/use-websocket.js.map +1 -1
  40. package/dist/index.d.ts +1 -2
  41. package/dist/index.d.ts.map +1 -1
  42. package/dist/index.js +3 -2
  43. package/dist/index.js.map +1 -1
  44. package/dist/lazy-pages.d.ts +3 -0
  45. package/dist/lazy-pages.d.ts.map +1 -0
  46. package/dist/lazy-pages.js +27 -0
  47. package/dist/lazy-pages.js.map +1 -0
  48. package/dist/styles.css +1 -1
  49. package/dist/theme.css +58 -0
  50. package/package.json +2 -2
  51. package/src/components/chat/brain-markdown.tsx +81 -9
  52. package/src/components/chat/chat-page.tsx +119 -709
  53. package/src/components/chat/composer.tsx +617 -0
  54. package/src/components/chat/message-bubble.tsx +24 -6
  55. package/src/components/chat/use-chat-commands.ts +87 -0
  56. package/src/components/files/file-panel.tsx +25 -8
  57. package/src/components/layout/slide-panel.tsx +13 -1
  58. package/src/components/settings/passkey-tab.tsx +2 -2
  59. package/src/components/settings/settings-panel.tsx +21 -10
  60. package/src/hooks/use-deferred-unmount.ts +24 -0
  61. package/src/hooks/use-websocket.ts +79 -2
  62. package/src/index.ts +3 -2
  63. package/src/lazy-pages.tsx +41 -0
  64. package/src/theme.css +58 -0
@@ -0,0 +1,617 @@
1
+ import { useState, useRef, useEffect } from "react";
2
+ import {
3
+ ArrowUp,
4
+ Square,
5
+ CornerLeftUp,
6
+ Paperclip,
7
+ Camera,
8
+ X,
9
+ Check,
10
+ ChevronDown,
11
+ Lock,
12
+ } from "lucide-react";
13
+ import type { ClientMessage } from "@schlessera/brain-ui-sdk/protocol";
14
+ import { useChatStore, activeChat } from "../../stores/chat-store.js";
15
+ import { uiConfig } from "../../config.js";
16
+ import { useConnectionStore } from "../../stores/connection-store.js";
17
+ import { useProviderStore } from "../../stores/provider-store.js";
18
+ import {
19
+ fileToAttachment,
20
+ validateAttachments,
21
+ type PendingAttachment,
22
+ } from "../../lib/image-attachments.js";
23
+ import { ShareIntake } from "./share-card.js";
24
+ import { CommandPalette } from "./command-palette.js";
25
+ import { MicButton } from "../voice/mic-button.js";
26
+ import { DictationSheet } from "../voice/dictation-sheet.js";
27
+ import { ReviewCard } from "../voice/review-card.js";
28
+ import { useDictation } from "../../voice/use-dictation.js";
29
+ import { useVoiceStore } from "../../voice/voice-store.js";
30
+ import { detectClientEnvironment } from "../../lib/client-environment.js";
31
+ import { useChatCommands } from "./use-chat-commands.js";
32
+ import { cn } from "../../lib/utils.js";
33
+
34
+ /**
35
+ * The composer — everything below the transcript: draft text, attachments,
36
+ * voice review, provider picker, send/cancel.
37
+ *
38
+ * It is its own component for one reason: it owns the draft, and the draft
39
+ * changes on every keystroke. While this lived inside ChatPage, a character
40
+ * typed re-rendered the entire transcript, so typing cost grew with the length
41
+ * of the conversation. Keeping the state here bounds a keystroke to this
42
+ * subtree. Nothing above it re-renders, whatever the transcript holds.
43
+ *
44
+ * The corollary is a rule for future edits: transcript-scale state does not
45
+ * belong in this file, and draft state does not belong above it.
46
+ */
47
+ export function Composer({ send }: { send: (msg: ClientMessage) => void }) {
48
+ const [input, setInput] = useState("");
49
+ const [lastPrompt, setLastPrompt] = useState("");
50
+ const textareaRef = useRef<HTMLTextAreaElement>(null);
51
+
52
+ // Image attachments. `attachmentsRef` mirrors state so async add/merge logic
53
+ // reads the current set synchronously (avoids stale closures / updater races).
54
+ const [attachments, setAttachments] = useState<PendingAttachment[]>([]);
55
+ const attachmentsRef = useRef<PendingAttachment[]>([]);
56
+ const [attachErrors, setAttachErrors] = useState<string[]>([]);
57
+ const libraryInputRef = useRef<HTMLInputElement>(null);
58
+ const cameraInputRef = useRef<HTMLInputElement>(null);
59
+ useEffect(() => {
60
+ attachmentsRef.current = attachments;
61
+ }, [attachments]);
62
+ // Revoke preview URLs of attachments that were never sent (unmount cleanup;
63
+ // sent attachments transfer URL ownership to the message in the chat store).
64
+ useEffect(
65
+ () => () => {
66
+ for (const a of attachmentsRef.current) URL.revokeObjectURL(a.previewUrl);
67
+ },
68
+ []
69
+ );
70
+
71
+ // Provider picker
72
+ const [providerMenuOpen, setProviderMenuOpen] = useState(false);
73
+ const providerMenuRef = useRef<HTMLDivElement>(null);
74
+
75
+ const isStreaming = useChatStore((s) => activeChat(s).isStreaming);
76
+ const sessionId = useChatStore((s) => s.activeSessionId);
77
+ const wsStatus = useConnectionStore((s) => s.wsStatus);
78
+
79
+ const providers = useProviderStore((s) => s.available);
80
+ const selectedProviderId = useProviderStore((s) => s.selectedId);
81
+ const pinnedProviderId = useProviderStore((s) => s.pinnedId);
82
+ const setSelectedProvider = useProviderStore((s) => s.setSelected);
83
+ const loadProviders = useProviderStore((s) => s.loadProviders);
84
+ const backends = useProviderStore((s) => s.backends);
85
+
86
+ // Voice dictation state
87
+ const voiceMode = useVoiceStore((s) => s.mode);
88
+ const reviewText = useVoiceStore((s) => s.reviewText);
89
+ const clearReview = useVoiceStore((s) => s.clearReview);
90
+ const dictation = useDictation();
91
+
92
+ const runCommand = useChatCommands();
93
+
94
+ // Derived during render, not synchronised through an effect: an effect would
95
+ // cost a second render pass on every single keystroke. Escape dismisses the
96
+ // palette without clearing the draft; typing brings it back.
97
+ const [paletteDismissed, setPaletteDismissed] = useState(false);
98
+ const showCommandPalette = input.startsWith("/") && !paletteDismissed;
99
+
100
+ // Load the available provider combos once on mount.
101
+ useEffect(() => {
102
+ void loadProviders();
103
+ }, [loadProviders]);
104
+
105
+ // Dismiss the provider popover on outside-click / Escape.
106
+ useEffect(() => {
107
+ if (!providerMenuOpen) return;
108
+ const onDocClick = (e: MouseEvent) => {
109
+ if (!providerMenuRef.current?.contains(e.target as Node)) {
110
+ setProviderMenuOpen(false);
111
+ }
112
+ };
113
+ const onKey = (e: KeyboardEvent) => {
114
+ if (e.key === "Escape") setProviderMenuOpen(false);
115
+ };
116
+ document.addEventListener("mousedown", onDocClick);
117
+ document.addEventListener("keydown", onKey);
118
+ return () => {
119
+ document.removeEventListener("mousedown", onDocClick);
120
+ document.removeEventListener("keydown", onKey);
121
+ };
122
+ }, [providerMenuOpen]);
123
+
124
+ /**
125
+ * The draft is whatever is visible above the send button: composer text
126
+ * plus any voice text under review. Both send paths submit the combined
127
+ * draft so nothing is silently dropped.
128
+ */
129
+ function draftText() {
130
+ return [input.trim(), reviewText.trim()].filter(Boolean).join("\n");
131
+ }
132
+
133
+ /**
134
+ * Decode, downscale and validate incoming image files, merging them with any
135
+ * already-pending attachments. Rejected files (bad type / oversize / over the
136
+ * per-message caps) surface as inline error lines; their object URLs are
137
+ * revoked so nothing leaks.
138
+ */
139
+ async function addFiles(files: FileList | File[]) {
140
+ const list = Array.from(files).filter((f) => f.type.startsWith("image/"));
141
+ if (list.length === 0) return;
142
+
143
+ const results = await Promise.all(list.map((f) => fileToAttachment(f)));
144
+ const fresh: PendingAttachment[] = [];
145
+ const errors: string[] = [];
146
+ results.forEach((r, i) => {
147
+ if ("error" in r) {
148
+ errors.push(r.error);
149
+ } else {
150
+ fresh.push({ ...r, name: list[i].name });
151
+ }
152
+ });
153
+
154
+ const { accepted } = validateAttachments([
155
+ ...attachmentsRef.current,
156
+ ...fresh,
157
+ ]);
158
+ // Revoke URLs of freshly-decoded images that didn't make the cut.
159
+ for (const f of fresh) {
160
+ if (!accepted.includes(f)) {
161
+ URL.revokeObjectURL(f.previewUrl);
162
+ errors.push(`${f.name}: not added (message limit reached)`);
163
+ }
164
+ }
165
+ attachmentsRef.current = accepted;
166
+ setAttachments(accepted);
167
+ setAttachErrors(errors);
168
+ }
169
+
170
+ function removeAttachment(index: number) {
171
+ const next = attachmentsRef.current.filter((item, i) => {
172
+ if (i === index) URL.revokeObjectURL(item.previewUrl);
173
+ return i !== index;
174
+ });
175
+ attachmentsRef.current = next;
176
+ setAttachments(next);
177
+ }
178
+
179
+ async function onFilePick(e: React.ChangeEvent<HTMLInputElement>) {
180
+ const files = e.target.files;
181
+ if (files && files.length > 0) await addFiles(files);
182
+ // Reset so picking the same file again still fires onChange.
183
+ e.target.value = "";
184
+ }
185
+
186
+ function handleSubmit() {
187
+ const text = draftText();
188
+ const hasAttachments = attachments.length > 0;
189
+ if ((!text && !hasAttachments) || wsStatus !== "connected") return;
190
+
191
+ if (text) setLastPrompt(text);
192
+ const messageAttachments = attachments.map((a) => ({
193
+ previewUrl: a.previewUrl,
194
+ mediaType: a.attachment.mediaType,
195
+ }));
196
+ const chat = useChatStore.getState();
197
+ chat.addUserMessage(
198
+ sessionId,
199
+ text,
200
+ reviewText.trim() ? "voice-dictate" : "typed",
201
+ messageAttachments.length > 0 ? messageAttachments : undefined
202
+ );
203
+ // A send while the session is already streaming is a follow-up — the server
204
+ // queues it or delivers it live; don't pre-start a second assistant bubble
205
+ // (the backend's next frames start it).
206
+ if (!isStreaming) chat.startAssistantMessage(sessionId);
207
+ // Correlate this turn when it is starting a NEW conversation, so its
208
+ // session_info can be told apart from a background turn's.
209
+ const draftId = sessionId ? undefined : chat.startDraftTurn();
210
+ send({
211
+ type: "chat_message",
212
+ text,
213
+ sessionId: sessionId ?? undefined,
214
+ ...(draftId ? { draftId } : {}),
215
+ // Provider only applies to new conversations; resumed sessions are
216
+ // pinned server-side to their original combo.
217
+ // Only send a provider the server actually offers — a stale persisted
218
+ // id (e.g. key removed server-side) falls back to the server default
219
+ // instead of erroring with PROVIDER_UNAVAILABLE.
220
+ providerId:
221
+ sessionId || !providers.some((p) => p.id === selectedProviderId)
222
+ ? undefined
223
+ : selectedProviderId,
224
+ attachments: hasAttachments
225
+ ? attachments.map((a) => a.attachment)
226
+ : undefined,
227
+ // Measured per send, not once per session: the same tab can rotate,
228
+ // move to an external display, or be installed as a PWA mid-conversation.
229
+ client: detectClientEnvironment(),
230
+ });
231
+ setInput("");
232
+ clearReview();
233
+ // Ownership of the preview URLs transfers to the rendered user message
234
+ // (revoked later by the chat store on clear/resume) — don't revoke here.
235
+ attachmentsRef.current = [];
236
+ setAttachments([]);
237
+ setAttachErrors([]);
238
+ }
239
+
240
+ function handleMicTap() {
241
+ if (voiceMode === "dictate") {
242
+ void dictation.stop(true);
243
+ } else {
244
+ // Defensive: blur composer so the keyboard never fights the mic sheet on Android
245
+ textareaRef.current?.blur();
246
+ // Any existing review text stays put — the new capture appends to it.
247
+ void dictation.start();
248
+ }
249
+ }
250
+
251
+ function handleVoiceSend() {
252
+ if (!draftText() || isStreaming || wsStatus !== "connected") {
253
+ // If we cannot send right now, fall back to editing
254
+ handleVoiceEdit();
255
+ return;
256
+ }
257
+ handleSubmit();
258
+ }
259
+
260
+ function handleVoiceEdit() {
261
+ setInput((prev) => (prev ? `${prev}\n${reviewText}` : reviewText));
262
+ clearReview();
263
+ setTimeout(() => textareaRef.current?.focus(), 50);
264
+ }
265
+
266
+ function handleVoiceAppend() {
267
+ // Review text stays in place; the next capture appends to it on stop.
268
+ void dictation.start();
269
+ }
270
+
271
+ function handleRecall() {
272
+ if (lastPrompt && !input.trim()) {
273
+ setInput(lastPrompt);
274
+ setTimeout(() => textareaRef.current?.focus(), 50);
275
+ }
276
+ }
277
+
278
+ function handleCancel() {
279
+ // Scope the cancel to the session in view so it is unambiguous when several
280
+ // sessions are running.
281
+ send({ type: "cancel", sessionId: sessionId ?? undefined });
282
+ }
283
+
284
+ function handleCommand(command: string) {
285
+ setInput("");
286
+ runCommand(command);
287
+ }
288
+
289
+ const hasDraft = Boolean(
290
+ input.trim() || reviewText.trim() || attachments.length > 0
291
+ );
292
+ // A send while a session is running is a follow-up (not blocked by streaming).
293
+ const canSend = hasDraft && wsStatus === "connected";
294
+
295
+ // Provider picker: locked to the pinned combo once a session is live.
296
+ // Lock while streaming too: the first send of a new conversation pins the
297
+ // provider server-side before session_info delivers the sessionId.
298
+ const providerLocked = sessionId != null || isStreaming;
299
+ const displayProviderId = providerLocked
300
+ ? pinnedProviderId ?? selectedProviderId
301
+ : selectedProviderId;
302
+ const displayProvider = providers.find((p) => p.id === displayProviderId);
303
+ // A session can be pinned to a profile the user has since hidden — it is gone
304
+ // from the picker but still running the turn. Show its id rather than
305
+ // claiming "Default model", which would name a different model than the one
306
+ // actually answering.
307
+ const displayProviderLabel =
308
+ displayProvider?.label ?? displayProviderId ?? "Default model";
309
+ const showProviderPicker = providers.length > 1;
310
+ // What happens if the user sends into the currently-running session.
311
+ const displayBackendId = displayProvider?.backendId;
312
+ const followUpLive = displayBackendId
313
+ ? backends[displayBackendId]?.capabilities.followUp ?? false
314
+ : false;
315
+ const followUpHint =
316
+ isStreaming && sessionId
317
+ ? followUpLive
318
+ ? "Follows up live"
319
+ : "Will queue"
320
+ : null;
321
+
322
+ return (
323
+ <>
324
+ {/* Dictation sheet */}
325
+ <DictationSheet
326
+ open={voiceMode === "dictate"}
327
+ onStop={() => dictation.stop(true)}
328
+ onCancel={() => dictation.cancel()}
329
+ />
330
+
331
+ <div className="px-4 pt-2 pb-4 md:px-6 md:pb-6">
332
+ <div className="mx-auto max-w-3xl">
333
+ {/* Anything shared from the OS waits here for a tap. Above the
334
+ composer, so it reads as something to act on rather than a
335
+ notification that has already happened. */}
336
+ <ShareIntake />
337
+
338
+ {/* Voice review card sits above the composer */}
339
+ <ReviewCard
340
+ text={reviewText}
341
+ onSend={handleVoiceSend}
342
+ onEdit={handleVoiceEdit}
343
+ onDiscard={clearReview}
344
+ onAppend={handleVoiceAppend}
345
+ />
346
+
347
+ {/* Rejected-file errors */}
348
+ {attachErrors.length > 0 && (
349
+ <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">
350
+ <div className="flex-1 space-y-0.5">
351
+ {attachErrors.map((e, i) => (
352
+ <div key={i}>{e}</div>
353
+ ))}
354
+ </div>
355
+ <button
356
+ type="button"
357
+ onClick={() => setAttachErrors([])}
358
+ title="Dismiss"
359
+ className="shrink-0 rounded p-0.5 transition-colors hover:text-foreground"
360
+ >
361
+ <X className="h-3.5 w-3.5" />
362
+ </button>
363
+ </div>
364
+ )}
365
+
366
+ {/* Image attachment preview strip */}
367
+ {attachments.length > 0 && (
368
+ <div className="mb-2 flex flex-wrap gap-2">
369
+ {attachments.map((a, i) => (
370
+ <div key={i} className="relative h-16 w-16 shrink-0">
371
+ <img
372
+ src={a.previewUrl}
373
+ alt={a.name}
374
+ className="h-16 w-16 rounded-lg border border-border object-cover"
375
+ />
376
+ <button
377
+ type="button"
378
+ onClick={() => removeAttachment(i)}
379
+ title="Remove"
380
+ 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"
381
+ >
382
+ <X className="h-3 w-3" />
383
+ </button>
384
+ </div>
385
+ ))}
386
+ </div>
387
+ )}
388
+
389
+ <div
390
+ className={cn(
391
+ "relative rounded-2xl border border-border bg-surface shadow-lg transition-all duration-200",
392
+ "focus-within:border-primary/40 focus-within:shadow-[0_0_20px_rgba(224,159,62,0.05)]"
393
+ )}
394
+ >
395
+ {/* Command palette */}
396
+ {showCommandPalette && (
397
+ <CommandPalette
398
+ filter={input.slice(1)}
399
+ onSelect={handleCommand}
400
+ />
401
+ )}
402
+
403
+ {/* Hidden file inputs for the paperclip / camera buttons */}
404
+ <input
405
+ ref={libraryInputRef}
406
+ type="file"
407
+ accept="image/*"
408
+ multiple
409
+ hidden
410
+ onChange={onFilePick}
411
+ />
412
+ <input
413
+ ref={cameraInputRef}
414
+ type="file"
415
+ accept="image/*"
416
+ capture="environment"
417
+ hidden
418
+ onChange={onFilePick}
419
+ />
420
+
421
+ {/* The textarea grows by CSS, not by JavaScript: the wrapper's
422
+ ::after mirrors the value and sets the row height, so the
423
+ composer never reads scrollHeight. That read forced a full
424
+ document layout on every keystroke, and its cost scaled with
425
+ the size of the transcript behind it. */}
426
+ <div className="composer-grow" data-value={input + " "}>
427
+ <textarea
428
+ ref={textareaRef}
429
+ value={input}
430
+ onChange={(e) => {
431
+ setInput(e.target.value);
432
+ setPaletteDismissed(false);
433
+ }}
434
+ onPaste={(e) => {
435
+ const files = e.clipboardData?.files;
436
+ if (files && files.length > 0) {
437
+ const images = Array.from(files).filter((f) =>
438
+ f.type.startsWith("image/")
439
+ );
440
+ if (images.length > 0) {
441
+ e.preventDefault();
442
+ void addFiles(images);
443
+ }
444
+ }
445
+ }}
446
+ onKeyDown={(e) => {
447
+ if (e.key === "Enter" && !e.shiftKey) {
448
+ if (showCommandPalette) return;
449
+ // On desktop (>=768px), Enter sends. On mobile, Enter inserts newline.
450
+ if (window.matchMedia("(min-width: 768px)").matches) {
451
+ e.preventDefault();
452
+ handleSubmit();
453
+ }
454
+ }
455
+ if (e.key === "ArrowUp" && !input.trim()) {
456
+ handleRecall();
457
+ }
458
+ if (e.key === "Escape") {
459
+ setPaletteDismissed(true);
460
+ }
461
+ }}
462
+ placeholder={
463
+ wsStatus !== "connected"
464
+ ? "Connecting..."
465
+ : uiConfig.composerPlaceholder
466
+ }
467
+ disabled={wsStatus !== "connected"}
468
+ rows={1}
469
+ className="w-full resize-none bg-transparent text-sm text-foreground placeholder:text-muted-foreground/40 focus:outline-none disabled:opacity-50"
470
+ />
471
+ </div>
472
+
473
+ <div className="flex items-center justify-between px-4 pb-3">
474
+ {/* Provider picker + hints */}
475
+ <div className="flex min-w-0 items-center gap-3 text-[11px] text-muted-foreground/50">
476
+ {showProviderPicker && (
477
+ <div ref={providerMenuRef} className="relative">
478
+ <button
479
+ type="button"
480
+ onClick={() =>
481
+ !providerLocked && setProviderMenuOpen((v) => !v)
482
+ }
483
+ disabled={providerLocked}
484
+ title={
485
+ providerLocked
486
+ ? "Provider is fixed for this conversation"
487
+ : "Choose model"
488
+ }
489
+ className={cn(
490
+ "flex max-w-[9rem] items-center gap-1 rounded-lg px-2 py-1 text-[11px] transition-colors md:max-w-[14rem]",
491
+ providerLocked
492
+ ? "cursor-default text-muted-foreground/60"
493
+ : "text-muted-foreground hover:bg-surface-raised hover:text-foreground"
494
+ )}
495
+ >
496
+ {providerLocked && (
497
+ <Lock className="h-3 w-3 shrink-0 opacity-70" />
498
+ )}
499
+ <span className="truncate">
500
+ {displayProviderLabel}
501
+ </span>
502
+ {!providerLocked && (
503
+ <ChevronDown className="h-3 w-3 shrink-0 opacity-70" />
504
+ )}
505
+ </button>
506
+ {providerMenuOpen && !providerLocked && (
507
+ <div
508
+ role="menu"
509
+ 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"
510
+ >
511
+ {providers.map((p) => (
512
+ <button
513
+ key={p.id}
514
+ role="menuitem"
515
+ type="button"
516
+ onClick={() => {
517
+ setSelectedProvider(p.id);
518
+ setProviderMenuOpen(false);
519
+ }}
520
+ className="flex w-full items-center gap-2 px-3 py-2 text-left text-xs text-foreground transition-colors hover:bg-surface-raised"
521
+ >
522
+ <Check
523
+ className={cn(
524
+ "h-3.5 w-3.5 shrink-0 text-primary",
525
+ p.id === selectedProviderId
526
+ ? "opacity-100"
527
+ : "opacity-0"
528
+ )}
529
+ />
530
+ <span className="truncate">{p.label}</span>
531
+ </button>
532
+ ))}
533
+ </div>
534
+ )}
535
+ </div>
536
+ )}
537
+ {followUpHint ? (
538
+ <span className="text-primary/70">{followUpHint}</span>
539
+ ) : (
540
+ <>
541
+ <span className="hidden sm:inline">
542
+ <span className="font-[family-name:var(--font-mono)]">/</span>{" "}
543
+ for commands
544
+ </span>
545
+ <span className="hidden md:inline">Shift+Enter for newline</span>
546
+ </>
547
+ )}
548
+ </div>
549
+
550
+ {/* Attach + Recall + Mic + Send / Cancel */}
551
+ <div className="flex items-center gap-1.5">
552
+ <button
553
+ type="button"
554
+ onClick={() => libraryInputRef.current?.click()}
555
+ disabled={isStreaming || wsStatus !== "connected"}
556
+ title="Attach images"
557
+ aria-label="Attach images"
558
+ 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"
559
+ >
560
+ <Paperclip className="h-4 w-4" />
561
+ </button>
562
+ <button
563
+ type="button"
564
+ onClick={() => cameraInputRef.current?.click()}
565
+ disabled={isStreaming || wsStatus !== "connected"}
566
+ title="Take a photo"
567
+ aria-label="Take a photo"
568
+ 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"
569
+ >
570
+ <Camera className="h-4 w-4" />
571
+ </button>
572
+ {lastPrompt && !input.trim() && !isStreaming && (
573
+ <button
574
+ type="button"
575
+ onClick={handleRecall}
576
+ title="Recall last prompt"
577
+ 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"
578
+ >
579
+ <CornerLeftUp className="h-4 w-4" />
580
+ </button>
581
+ )}
582
+ <MicButton
583
+ active={voiceMode === "dictate"}
584
+ disabled={isStreaming || wsStatus !== "connected"}
585
+ onTap={handleMicTap}
586
+ />
587
+ {isStreaming && !hasDraft ? (
588
+ // Streaming with nothing drafted: the primary action is cancel.
589
+ <button
590
+ type="button"
591
+ onClick={handleCancel}
592
+ title="Stop the running turn"
593
+ className="flex h-8 w-8 items-center justify-center rounded-lg bg-destructive text-primary-foreground transition-all duration-150"
594
+ >
595
+ <Square className="h-3.5 w-3.5" />
596
+ </button>
597
+ ) : (
598
+ // A draft always sends — as a new turn, or a follow-up when a
599
+ // session is already running.
600
+ <button
601
+ type="button"
602
+ onClick={handleSubmit}
603
+ disabled={!canSend}
604
+ title={followUpHint ?? "Send"}
605
+ 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"
606
+ >
607
+ <ArrowUp className="h-4 w-4" />
608
+ </button>
609
+ )}
610
+ </div>
611
+ </div>
612
+ </div>
613
+ </div>
614
+ </div>
615
+ </>
616
+ );
617
+ }
@@ -1,5 +1,5 @@
1
1
  import { uiConfig } from "../../config.js";
2
- import { useEffect, useRef, useState } from "react";
2
+ import { memo, useEffect, useRef, useState } from "react";
3
3
  import { ChevronDown, Sparkles, Mic, Image as ImageIcon } from "lucide-react";
4
4
  import type {
5
5
  ChatMessage,
@@ -18,7 +18,18 @@ import { buildMessageShareOptions } from "./message-share.js";
18
18
  import { ShareMenu } from "../share/share-menu.js";
19
19
  import { ZoomableImage } from "../images/zoomable-image.js";
20
20
 
21
- export function MessageBubble({
21
+ /**
22
+ * One message in the transcript.
23
+ *
24
+ * Memoized, and the memo is load-bearing rather than a micro-optimisation: the
25
+ * chat store replaces only the message it touches, so every OTHER message keeps
26
+ * its object identity across a store write. Without the memo, one streamed
27
+ * token re-rendered — and re-parsed the markdown of — the whole conversation.
28
+ *
29
+ * The contract that keeps it working: every callback prop must be stable.
30
+ * ChatPage wraps all three in useCallback for exactly this reason.
31
+ */
32
+ export const MessageBubble = memo(function MessageBubble({
22
33
  message,
23
34
  onToolApproval,
24
35
  onAskUserSubmit,
@@ -78,7 +89,7 @@ export function MessageBubble({
78
89
  <div className="space-y-2">
79
90
  <UserAttachments message={message} />
80
91
  {message.content && (
81
- <div className="text-sm leading-relaxed text-foreground whitespace-pre-wrap">
92
+ <div className="chat-message-body text-sm leading-relaxed text-foreground whitespace-pre-wrap">
82
93
  {linkifyPaths(message.content)}
83
94
  </div>
84
95
  )}
@@ -93,7 +104,7 @@ export function MessageBubble({
93
104
  )}
94
105
  </motion.div>
95
106
  );
96
- }
107
+ });
97
108
 
98
109
  /**
99
110
  * User-message image attachments. Live messages render real thumbnails from
@@ -270,7 +281,12 @@ function AssistantContent({
270
281
  // share formats still use the full message content.
271
282
  return group.isLastText ? (
272
283
  <div key={i} className="group relative" ref={contentRef}>
273
- <MarkdownContent content={group.text} />
284
+ {/* The share menu is deliberately OUTSIDE the skip-render
285
+ wrapper: content-visibility implies paint containment,
286
+ which would clip a dropdown that opens past the box. */}
287
+ <div className="chat-message-body">
288
+ <MarkdownContent content={group.text} />
289
+ </div>
274
290
  {showShare && shareOptions.length > 0 && (
275
291
  <div className="mt-1 flex justify-end opacity-0 transition-opacity group-hover:opacity-100 focus-within:opacity-100">
276
292
  <ShareMenu options={shareOptions} title="Share message" />
@@ -278,7 +294,9 @@ function AssistantContent({
278
294
  )}
279
295
  </div>
280
296
  ) : (
281
- <MarkdownContent key={i} content={group.text} />
297
+ <div key={i} className="chat-message-body">
298
+ <MarkdownContent content={group.text} />
299
+ </div>
282
300
  );
283
301
  }
284
302
  })}