@schlessera/brain-ui-react 0.30.1 → 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 (61) 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/hooks/use-deferred-unmount.d.ts +12 -0
  31. package/dist/hooks/use-deferred-unmount.d.ts.map +1 -0
  32. package/dist/hooks/use-deferred-unmount.js +24 -0
  33. package/dist/hooks/use-deferred-unmount.js.map +1 -0
  34. package/dist/hooks/use-websocket.d.ts +8 -0
  35. package/dist/hooks/use-websocket.d.ts.map +1 -1
  36. package/dist/hooks/use-websocket.js +66 -2
  37. package/dist/hooks/use-websocket.js.map +1 -1
  38. package/dist/index.d.ts +1 -2
  39. package/dist/index.d.ts.map +1 -1
  40. package/dist/index.js +3 -2
  41. package/dist/index.js.map +1 -1
  42. package/dist/lazy-pages.d.ts +3 -0
  43. package/dist/lazy-pages.d.ts.map +1 -0
  44. package/dist/lazy-pages.js +27 -0
  45. package/dist/lazy-pages.js.map +1 -0
  46. package/dist/styles.css +1 -1
  47. package/dist/theme.css +58 -0
  48. package/package.json +2 -2
  49. package/src/components/chat/brain-markdown.tsx +81 -9
  50. package/src/components/chat/chat-page.tsx +119 -709
  51. package/src/components/chat/composer.tsx +617 -0
  52. package/src/components/chat/message-bubble.tsx +24 -6
  53. package/src/components/chat/use-chat-commands.ts +87 -0
  54. package/src/components/files/file-panel.tsx +25 -8
  55. package/src/components/layout/slide-panel.tsx +13 -1
  56. package/src/components/settings/settings-panel.tsx +21 -10
  57. package/src/hooks/use-deferred-unmount.ts +24 -0
  58. package/src/hooks/use-websocket.ts +79 -2
  59. package/src/index.ts +3 -2
  60. package/src/lazy-pages.tsx +41 -0
  61. package/src/theme.css +58 -0
@@ -0,0 +1,87 @@
1
+ import { useCallback } from "react";
2
+ import { useChatStore } from "../../stores/chat-store.js";
3
+ import { useConnectionStore } from "../../stores/connection-store.js";
4
+ import { useUIStore } from "../../stores/ui-store.js";
5
+ import { api } from "../../lib/api-client.js";
6
+
7
+ /**
8
+ * Slash-command dispatch, shared by the composer's palette and the welcome
9
+ * screen's shortcut buttons.
10
+ *
11
+ * Every piece of state a command needs is read through `getState()` at the
12
+ * moment it runs rather than subscribed to, so the returned callback is stable
13
+ * for the life of the component. That matters because the composer is the
14
+ * component that re-renders on every keystroke: a command handler that changed
15
+ * identity per character would defeat the memoization around it.
16
+ */
17
+ export function useChatCommands(): (command: string) => void {
18
+ return useCallback((command: string) => {
19
+ const ui = useUIStore.getState();
20
+
21
+ // Search and add talk to the brain CLI over REST, not to the agent — they
22
+ // stay available while a turn streams or the socket is down.
23
+ switch (command) {
24
+ case "search":
25
+ ui.setSearchPanelOpen(true);
26
+ return;
27
+ case "add":
28
+ ui.setAddPanelOpen(true);
29
+ return;
30
+ }
31
+
32
+ const chat = useChatStore.getState();
33
+ const sessionId = chat.activeSessionId;
34
+ const buffer = sessionId ? chat.buffers[sessionId] : chat.draft;
35
+ const disabled =
36
+ useConnectionStore.getState().wsStatus !== "connected" ||
37
+ Boolean(buffer?.isStreaming);
38
+ if (disabled) return;
39
+
40
+ switch (command) {
41
+ case "sync":
42
+ ui.setSyncPanelOpen(true);
43
+ break;
44
+ case "whatsup":
45
+ ui.setWhatsupPanelOpen(true);
46
+ break;
47
+ case "stats":
48
+ void runStats(sessionId);
49
+ break;
50
+ }
51
+ }, []);
52
+ }
53
+
54
+ /** Brain statistics rendered into the transcript as an assistant turn. */
55
+ async function runStats(sessionId: string | null): Promise<void> {
56
+ const chat = useChatStore.getState();
57
+ chat.addUserMessage(sessionId, "Stats");
58
+ chat.startAssistantMessage(sessionId);
59
+ try {
60
+ const stats = await api.brainStats();
61
+ const result = [
62
+ `**Brain Statistics**`,
63
+ `- Documents: ${stats.documents}`,
64
+ `- Tags: ${stats.tags}`,
65
+ `- Links: ${stats.links}`,
66
+ ``,
67
+ `**By Type:** ${Object.entries(stats.byType)
68
+ .sort(([, a], [, b]) => b - a)
69
+ .map(([t, n]) => `${t} (${n})`)
70
+ .join(", ")}`,
71
+ ``,
72
+ `**By Status:** ${Object.entries(stats.byStatus)
73
+ .map(([s, n]) => `${s} (${n})`)
74
+ .join(", ")}`,
75
+ ].join("\n");
76
+ useChatStore.getState().appendText(sessionId, result);
77
+ } catch (err) {
78
+ useChatStore
79
+ .getState()
80
+ .appendText(
81
+ sessionId,
82
+ `**Error:** ${err instanceof Error ? err.message : "Action failed"}`
83
+ );
84
+ } finally {
85
+ useChatStore.getState().finishAssistantMessage(sessionId);
86
+ }
87
+ }
@@ -1,10 +1,20 @@
1
- import { useEffect } from "react";
1
+ import { Suspense, lazy, useEffect } from "react";
2
2
  import { ChevronDown, ChevronRight, X } from "lucide-react";
3
3
  import { useFileStore } from "../../stores/file-store.js";
4
- import { FileTree } from "./file-tree.js";
5
- import { FileViewer } from "./file-viewer.js";
4
+ import { useDeferredUnmount } from "../../hooks/use-deferred-unmount.js";
6
5
  import { cn } from "../../lib/utils.js";
7
6
 
7
+ /** Matches the `duration-300` slide-out below. */
8
+ const SLIDE_OUT_MS = 300;
9
+
10
+ /**
11
+ * The tree and the viewers load the first time the panel is opened. Together
12
+ * they carry the markdown, HTML, image and PDF viewers, none of which the chat
13
+ * surface needs to have on hand.
14
+ */
15
+ const FileTree = lazy(() => import("./file-tree.js").then((m) => ({ default: m.FileTree })));
16
+ const FileViewer = lazy(() => import("./file-viewer.js").then((m) => ({ default: m.FileViewer })));
17
+
8
18
  export function FilePanel({ open, onClose }: { open: boolean; onClose: () => void }) {
9
19
  const currentPath = useFileStore((s) => s.currentPath);
10
20
  const treeExpanded = useFileStore((s) => s.treeExpanded);
@@ -40,7 +50,10 @@ export function FilePanel({ open, onClose }: { open: boolean; onClose: () => voi
40
50
  }
41
51
  }, [open, currentPath]);
42
52
 
43
- const showTree = !currentPath || treeExpanded;
53
+ // The body outlives `open` by the slide-out, then unmounts: a closed panel
54
+ // was otherwise keeping the whole file tree mounted behind the chat page.
55
+ const showContent = useDeferredUnmount(open, SLIDE_OUT_MS);
56
+ const showTree = showContent && (!currentPath || treeExpanded);
44
57
 
45
58
  return (
46
59
  <>
@@ -73,7 +86,7 @@ export function FilePanel({ open, onClose }: { open: boolean; onClose: () => voi
73
86
 
74
87
  <div className="flex flex-1 flex-col overflow-hidden">
75
88
  {/* Tree toggle strip — visible when a file is open */}
76
- {currentPath && (
89
+ {showContent && currentPath && (
77
90
  <div className="flex items-center gap-1 border-b border-border bg-surface-raised/40 px-3 py-1.5">
78
91
  <button
79
92
  onClick={() => setTreeExpanded(!treeExpanded)}
@@ -99,14 +112,18 @@ export function FilePanel({ open, onClose }: { open: boolean; onClose: () => voi
99
112
  currentPath ? "max-h-[45vh] border-b border-border" : "flex-1"
100
113
  )}
101
114
  >
102
- <FileTree />
115
+ <Suspense fallback={null}>
116
+ <FileTree />
117
+ </Suspense>
103
118
  </div>
104
119
  )}
105
120
 
106
121
  {/* Viewer area */}
107
- {currentPath && (
122
+ {showContent && currentPath && (
108
123
  <div className="flex-1 overflow-hidden">
109
- <FileViewer />
124
+ <Suspense fallback={null}>
125
+ <FileViewer />
126
+ </Suspense>
110
127
  </div>
111
128
  )}
112
129
  </div>
@@ -1,7 +1,11 @@
1
1
  import { useEffect, type ReactNode } from "react";
2
2
  import { X } from "lucide-react";
3
+ import { useDeferredUnmount } from "../../hooks/use-deferred-unmount.js";
3
4
  import { cn } from "../../lib/utils.js";
4
5
 
6
+ /** Matches the `duration-300` slide-out below. */
7
+ const SLIDE_OUT_MS = 300;
8
+
5
9
  export function SlidePanel({
6
10
  open,
7
11
  onClose,
@@ -24,6 +28,14 @@ export function SlidePanel({
24
28
  return () => document.removeEventListener("keydown", handler);
25
29
  }, [open, onClose]);
26
30
 
31
+ /**
32
+ * A closed panel renders nothing. The shell (the sliding frame and its
33
+ * header) stays mounted so the CSS transform still animates; the contents do
34
+ * not. Every panel already rebuilds its state when it opens, so a close never
35
+ * preserved anything worth keeping.
36
+ */
37
+ const showContent = useDeferredUnmount(open, SLIDE_OUT_MS);
38
+
27
39
  return (
28
40
  <>
29
41
  {/* Backdrop */}
@@ -57,7 +69,7 @@ export function SlidePanel({
57
69
  </div>
58
70
 
59
71
  {/* Content */}
60
- <div className="flex-1 overflow-y-auto">{children}</div>
72
+ <div className="flex-1 overflow-y-auto">{showContent ? children : null}</div>
61
73
  </div>
62
74
  </>
63
75
  );
@@ -1,10 +1,19 @@
1
+ import { Suspense, lazy } from "react";
1
2
  import { KeyRound, Puzzle, SlidersHorizontal } from "lucide-react";
2
3
  import { useUIStore, type SettingsTab } from "../../stores/ui-store.js";
3
4
  import { SlidePanel } from "../layout/slide-panel.js";
4
5
  import { cn } from "../../lib/utils.js";
5
- import { ModelsTab } from "./models-tab.js";
6
- import { PasskeyTab } from "./passkey-tab.js";
7
- import { SkillsTab } from "./skills-tab.js";
6
+
7
+ /**
8
+ * Each tab is fetched the first time it is opened. Settings is the largest
9
+ * surface in the app and most sessions never open it, so none of it belongs in
10
+ * the bundle that has to arrive before the first message can be shown. The
11
+ * panel frame and its tab strip stay eager, so opening settings is immediate
12
+ * and only the body fills in.
13
+ */
14
+ const ModelsTab = lazy(() => import("./models-tab.js").then((m) => ({ default: m.ModelsTab })));
15
+ const PasskeyTab = lazy(() => import("./passkey-tab.js").then((m) => ({ default: m.PasskeyTab })));
16
+ const SkillsTab = lazy(() => import("./skills-tab.js").then((m) => ({ default: m.SkillsTab })));
8
17
 
9
18
  const TABS: Array<{ id: SettingsTab; label: string; icon: typeof KeyRound }> = [
10
19
  { id: "models", label: "Models", icon: SlidersHorizontal },
@@ -51,13 +60,15 @@ export function SettingsPanel({
51
60
  </div>
52
61
 
53
62
  <div className="min-h-0 flex-1">
54
- {tab === "models" ? (
55
- <ModelsTab active={open && tab === "models"} />
56
- ) : tab === "skills" ? (
57
- <SkillsTab active={open && tab === "skills"} />
58
- ) : (
59
- <PasskeyTab active={open && tab === "security"} />
60
- )}
63
+ <Suspense fallback={null}>
64
+ {tab === "models" ? (
65
+ <ModelsTab active={open && tab === "models"} />
66
+ ) : tab === "skills" ? (
67
+ <SkillsTab active={open && tab === "skills"} />
68
+ ) : (
69
+ <PasskeyTab active={open && tab === "security"} />
70
+ )}
71
+ </Suspense>
61
72
  </div>
62
73
  </div>
63
74
  </SlidePanel>
@@ -0,0 +1,24 @@
1
+ import { useEffect, useState } from "react";
2
+
3
+ /**
4
+ * True while a panel should render its contents: immediately when it opens,
5
+ * and until `delayMs` after it closes.
6
+ *
7
+ * Panels slide out rather than disappearing, so their contents have to outlive
8
+ * the `open` flag by the length of that transition — unmounting on the flag
9
+ * would empty the panel on its way off screen. Everything after that is dead
10
+ * weight: a panel is a child of the page it opens over, so a mounted one
11
+ * re-renders with the page and keeps its whole subtree alive for nothing.
12
+ */
13
+ export function useDeferredUnmount(open: boolean, delayMs: number): boolean {
14
+ const [mounted, setMounted] = useState(open);
15
+ useEffect(() => {
16
+ if (open) {
17
+ setMounted(true);
18
+ return;
19
+ }
20
+ const timer = setTimeout(() => setMounted(false), delayMs);
21
+ return () => clearTimeout(timer);
22
+ }, [open, delayMs]);
23
+ return mounted;
24
+ }
@@ -153,7 +153,76 @@ function ensureActivitySubscription(sessionId: string | null | undefined): void
153
153
  void loadSessionActivityHistory(sessionId);
154
154
  }
155
155
 
156
+ /**
157
+ * Text and thinking deltas waiting to be applied, per buffer.
158
+ *
159
+ * A model streams tokens far faster than the display refreshes, and each socket
160
+ * frame arrives in its own macrotask, so React cannot batch them: one token was
161
+ * one store write, one render and one markdown re-parse of the growing message.
162
+ * Coalescing a frame's worth of tokens into a single write costs nothing
163
+ * visually — the screen could not have shown the intermediate states anyway.
164
+ *
165
+ * Consecutive same-kind chunks merge, so a burst of text becomes ONE appendText
166
+ * rather than one per token. Kind changes start a new chunk, which is what keeps
167
+ * interleaved thinking and text in their true chronological order.
168
+ */
169
+ type PendingDelta = { kind: "text" | "thinking"; text: string };
170
+ const pendingDeltas = new Map<ChatKey, PendingDelta[]>();
171
+ let flushScheduled = false;
172
+
173
+ /**
174
+ * Apply every buffered delta, in arrival order, and clear the buffer.
175
+ *
176
+ * Exported so tests can force the frame rather than wait for one; production
177
+ * code never needs to call it, because every path that reads the transcript
178
+ * flushes first.
179
+ */
180
+ export function flushChatDeltas(): void {
181
+ flushDeltas();
182
+ }
183
+
184
+ function flushDeltas(): void {
185
+ flushScheduled = false;
186
+ if (pendingDeltas.size === 0) return;
187
+ const batches = [...pendingDeltas.entries()];
188
+ pendingDeltas.clear();
189
+ const state = useChatStore.getState();
190
+ for (const [key, chunks] of batches) {
191
+ for (const chunk of chunks) {
192
+ if (chunk.kind === "text") state.appendText(key, chunk.text);
193
+ else state.appendThinking(key, chunk.text);
194
+ }
195
+ }
196
+ }
197
+
198
+ function enqueueDelta(key: ChatKey, kind: PendingDelta["kind"], text: string): void {
199
+ const chunks = pendingDeltas.get(key);
200
+ if (!chunks) {
201
+ pendingDeltas.set(key, [{ kind, text }]);
202
+ } else {
203
+ const last = chunks[chunks.length - 1];
204
+ if (last.kind === kind) last.text += text;
205
+ else chunks.push({ kind, text });
206
+ }
207
+ // Outside a browser (unit tests, SSR) there is no frame to wait for, so apply
208
+ // straight away and keep the handler's behaviour synchronous.
209
+ if (typeof requestAnimationFrame !== "function") {
210
+ flushDeltas();
211
+ return;
212
+ }
213
+ if (!flushScheduled) {
214
+ flushScheduled = true;
215
+ requestAnimationFrame(flushDeltas);
216
+ }
217
+ }
218
+
156
219
  export function handleServerMessage(msg: ServerMessage) {
220
+ // Every frame that is not itself a delta must see the transcript fully
221
+ // applied: the demux below reads buffer state, and the store records parts in
222
+ // arrival order, so a tool call landing ahead of buffered text would reorder
223
+ // the message.
224
+ if (msg.type !== "text_delta" && msg.type !== "thinking_delta") flushDeltas();
225
+
157
226
  const state = useChatStore.getState();
158
227
 
159
228
  // Activity stream frames feed their own store and never touch chat state.
@@ -223,17 +292,19 @@ export function handleServerMessage(msg: ServerMessage) {
223
292
 
224
293
  switch (msg.type) {
225
294
  case "text_delta":
295
+ // Opening the bubble stays immediate — the first token should show a
296
+ // message starting, and every later delta needs isStreaming to be true.
226
297
  if (!buffer()?.isStreaming) {
227
298
  state.startAssistantMessage(key);
228
299
  }
229
- state.appendText(key, msg.text);
300
+ enqueueDelta(key, "text", msg.text);
230
301
  break;
231
302
 
232
303
  case "thinking_delta":
233
304
  if (!buffer()?.isStreaming) {
234
305
  state.startAssistantMessage(key);
235
306
  }
236
- state.appendThinking(key, msg.text);
307
+ enqueueDelta(key, "thinking", msg.text);
237
308
  break;
238
309
 
239
310
  case "tool_use_start":
@@ -463,6 +534,12 @@ function coldResumeIfNeeded(sessionId: string | null, messageCount: number) {
463
534
  function handleStatusChange(status: "connecting" | "connected" | "disconnected") {
464
535
  useConnectionStore.getState().setWsStatus(status);
465
536
 
537
+ // A status change can be followed by a history replay that rewrites the
538
+ // buffer, so land whatever is still buffered before anything reads it. In a
539
+ // hidden tab requestAnimationFrame does not run at all, which is exactly the
540
+ // case where deltas could otherwise sit unapplied across a reconnect.
541
+ flushDeltas();
542
+
466
543
  if (status === "disconnected") {
467
544
  wasDisconnected = true;
468
545
  // Allow a fresh cold-resume attempt after we reconnect.
package/src/index.ts CHANGED
@@ -15,8 +15,9 @@ export { configureBrainUi, uiConfig, type BrainUiConfig } from "./config.js";
15
15
  export { ConnectionGate } from "./components/connectivity/connection-gate.js";
16
16
  export { AppShell } from "./components/layout/app-shell.js";
17
17
  export { ChatPage } from "./components/chat/chat-page.js";
18
- export { GraphPage } from "./components/graph/graph-page.js";
19
- export { ActivityPage } from "./components/activity/activity-page.js";
18
+ // Loaded on first use, with their own Suspense boundary, so a shell that
19
+ // renders them keeps working unchanged — see lazy-pages.tsx.
20
+ export { GraphPage, ActivityPage } from "./lazy-pages.js";
20
21
 
21
22
  // Markdown renderer (also useful standalone, e.g. for a dev kitchen sink).
22
23
  export { BrainMarkdown } from "./components/chat/brain-markdown.js";
@@ -0,0 +1,41 @@
1
+ import { Suspense, lazy } from "react";
2
+
3
+ /**
4
+ * The two secondary surfaces, loaded on first use.
5
+ *
6
+ * Chat is what the app opens on; the graph (which drags in sigma and
7
+ * graphology) and the activity record are reached from the rail, often never in
8
+ * a given session. Splitting them keeps their code out of the bundle that has
9
+ * to arrive before anything can be shown.
10
+ *
11
+ * The Suspense boundary lives here rather than in the consuming shell so these
12
+ * stay drop-in components: the shell renders <GraphPage /> exactly as before
13
+ * and does not have to know that it suspends. The fallback is deliberately
14
+ * empty — the chunk resolves in a frame or two on any warm cache, and a
15
+ * spinner that flashes for one frame reads as a glitch rather than as progress.
16
+ */
17
+ const GraphPageLazy = lazy(() =>
18
+ import("./components/graph/graph-page.js").then((m) => ({ default: m.GraphPage }))
19
+ );
20
+
21
+ const ActivityPageLazy = lazy(() =>
22
+ import("./components/activity/activity-page.js").then((m) => ({
23
+ default: m.ActivityPage,
24
+ }))
25
+ );
26
+
27
+ export function GraphPage() {
28
+ return (
29
+ <Suspense fallback={null}>
30
+ <GraphPageLazy />
31
+ </Suspense>
32
+ );
33
+ }
34
+
35
+ export function ActivityPage() {
36
+ return (
37
+ <Suspense fallback={null}>
38
+ <ActivityPageLazy />
39
+ </Suspense>
40
+ );
41
+ }
package/src/theme.css CHANGED
@@ -433,3 +433,61 @@
433
433
  .brain-tree-highlight {
434
434
  animation: brain-tree-flash 1.6s ease-out;
435
435
  }
436
+
437
+ /* Auto-growing composer textarea.
438
+ *
439
+ * The wrapper is a one-cell grid holding the textarea and a hidden ::after that
440
+ * mirrors the draft; the mirror sizes the row, so the textarea follows without
441
+ * anyone reading scrollHeight. That read is a forced synchronous layout of the
442
+ * whole document, and it ran on every keystroke — its cost grew with the size
443
+ * of the transcript rendered behind the composer.
444
+ *
445
+ * The mirror and the textarea MUST agree on every box and typography value
446
+ * below, or the two will disagree about where the text wraps. */
447
+ .composer-grow {
448
+ display: grid;
449
+ }
450
+
451
+ .composer-grow::after,
452
+ .composer-grow > textarea {
453
+ grid-area: 1 / 1 / 2 / 2;
454
+ min-height: 56px;
455
+ max-height: 200px;
456
+ padding: 1rem 1.25rem;
457
+ font: inherit;
458
+ font-size: 0.875rem;
459
+ line-height: 1.25rem;
460
+ white-space: pre-wrap;
461
+ overflow-wrap: anywhere;
462
+ border: 0;
463
+ }
464
+
465
+ /* The trailing space the composer appends keeps a trailing newline visible. */
466
+ .composer-grow::after {
467
+ content: attr(data-value);
468
+ visibility: hidden;
469
+ overflow: hidden;
470
+ }
471
+
472
+ .composer-grow > textarea {
473
+ resize: none;
474
+ overflow-y: auto;
475
+ }
476
+
477
+ /* Offscreen transcript prose is skipped by the browser entirely — no style,
478
+ * layout or paint until it scrolls near the viewport. A long conversation is
479
+ * mostly offscreen, so this is where the cost of a big DOM goes.
480
+ *
481
+ * Scoped to the message BODY rather than the whole message on purpose:
482
+ * content-visibility implies paint containment, which clips descendants to the
483
+ * border box and makes the element a containing block for fixed positioning.
484
+ * The share dropdown opens past the bottom of its message, so it stays outside
485
+ * this wrapper. Full-screen viewers are unaffected — they portal to the body.
486
+ *
487
+ * The `auto` in contain-intrinsic-size makes the browser remember each block's
488
+ * real height once measured, so scroll position stays honest after the first
489
+ * pass; 6rem is only the guess for blocks it has never rendered. */
490
+ .chat-message-body {
491
+ content-visibility: auto;
492
+ contain-intrinsic-size: auto 6rem;
493
+ }