@elabs-ai/components-ai 4.0.0 → 4.1.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 (128) hide show
  1. package/README.md +61 -11
  2. package/dist/{_audio-player-media-chrome-KA5DY54G.js → _audio-player-media-chrome-T3XVXWRZ.js} +8 -4
  3. package/dist/_audio-player-media-chrome-T3XVXWRZ.js.map +1 -0
  4. package/dist/{_flow-boundary-D63PJ65S.js → _flow-boundary-SHNWLQG5.js} +32 -43
  5. package/dist/_flow-boundary-SHNWLQG5.js.map +1 -0
  6. package/dist/{_persona-rive-RFR2EUWP.js → _persona-rive-JEG44YHX.js} +9 -5
  7. package/dist/_persona-rive-JEG44YHX.js.map +1 -0
  8. package/dist/index.d.ts +1109 -202
  9. package/dist/index.js +4867 -2878
  10. package/dist/index.js.map +1 -1
  11. package/package.json +31 -16
  12. package/src/_audio-player-media-chrome.tsx +102 -15
  13. package/src/_flow-boundary.tsx +68 -49
  14. package/src/_lazy-boundary-conformance.ts +38 -0
  15. package/src/_lazy-engine-boundary.tsx +61 -0
  16. package/src/_lazy-mermaid-absent.test.ts +53 -0
  17. package/src/_lazy-mermaid.test.ts +15 -0
  18. package/src/_lazy-mermaid.ts +24 -1
  19. package/src/_mermaid-error-panel.test.tsx +50 -0
  20. package/src/_mermaid-error-panel.tsx +66 -0
  21. package/src/_persona-rive.tsx +62 -10
  22. package/src/_streamdown-i18n.ts +21 -1
  23. package/src/_streamdown-safety.ts +170 -0
  24. package/src/agent-event.stories.tsx +97 -0
  25. package/src/agent-event.test.tsx +145 -0
  26. package/src/agent-event.tsx +187 -0
  27. package/src/agent-timeline.stories.tsx +9 -1
  28. package/src/agent.test.tsx +47 -0
  29. package/src/agent.tsx +28 -14
  30. package/src/agentic-workspace.stories.tsx +27 -20
  31. package/src/artifact.tsx +15 -12
  32. package/src/audio-player.test.tsx +78 -0
  33. package/src/audio-player.tsx +243 -56
  34. package/src/audio-visualizer.stories.tsx +126 -0
  35. package/src/audio-visualizer.test.tsx +438 -0
  36. package/src/audio-visualizer.tsx +367 -0
  37. package/src/canvas.stories.tsx +150 -1
  38. package/src/chat-shell.stories.tsx +18 -3
  39. package/src/chat.stories.tsx +16 -2
  40. package/src/code-block.stories.tsx +9 -1
  41. package/src/composer.stories.tsx +372 -9
  42. package/src/composer.test.tsx +357 -6
  43. package/src/composer.tsx +217 -35
  44. package/src/confirmation.stories.tsx +72 -1
  45. package/src/confirmation.test.tsx +216 -2
  46. package/src/confirmation.tsx +263 -3
  47. package/src/context-panel.stories.tsx +9 -1
  48. package/src/context-panel.tsx +2 -1
  49. package/src/conversation.stories.tsx +63 -2
  50. package/src/conversation.test.tsx +13 -0
  51. package/src/conversation.tsx +20 -2
  52. package/src/diff-view.stories.tsx +196 -0
  53. package/src/diff-view.test.tsx +188 -0
  54. package/src/diff-view.tsx +642 -0
  55. package/src/gallery.tsx +4 -2
  56. package/src/index.ts +14 -4
  57. package/src/jsx-preview.stories.tsx +2 -2
  58. package/src/markdown-view.stories.tsx +92 -1
  59. package/src/markdown-view.test.tsx +232 -1
  60. package/src/markdown-view.tsx +150 -6
  61. package/src/message-compare.stories.tsx +175 -0
  62. package/src/message-compare.test.tsx +207 -0
  63. package/src/message-compare.tsx +453 -0
  64. package/src/message-form.stories.tsx +29 -1
  65. package/src/message.stories.tsx +9 -1
  66. package/src/message.test.tsx +176 -0
  67. package/src/message.tsx +90 -4
  68. package/src/microcopy.test.tsx +40 -0
  69. package/src/{model-selector.stories.tsx → model-provider-logo.stories.tsx} +17 -8
  70. package/src/{model-selector.test.tsx → model-provider-logo.test.tsx} +10 -10
  71. package/src/model-provider-logo.tsx +149 -0
  72. package/src/permission-mode-select.stories.tsx +82 -0
  73. package/src/permission-mode-select.test.tsx +100 -0
  74. package/src/permission-mode-select.tsx +137 -0
  75. package/src/persona-missing-peer.test.tsx +54 -0
  76. package/src/persona.tsx +68 -22
  77. package/src/plan.stories.tsx +166 -0
  78. package/src/plan.test.tsx +267 -0
  79. package/src/plan.tsx +182 -20
  80. package/src/prompt-input-effort.stories.tsx +123 -0
  81. package/src/prompt-input-effort.test.tsx +83 -0
  82. package/src/prompt-input-effort.tsx +136 -0
  83. package/src/prompt-input-mode.stories.tsx +108 -0
  84. package/src/prompt-input-mode.test.tsx +99 -0
  85. package/src/prompt-input-mode.tsx +169 -0
  86. package/src/prompt-input-slash.stories.tsx +211 -0
  87. package/src/prompt-input-slash.test.tsx +262 -0
  88. package/src/prompt-input-slash.tsx +541 -0
  89. package/src/prompt-input.stories.tsx +2 -2
  90. package/src/reasoning.tsx +27 -13
  91. package/src/sandbox.stories.tsx +9 -1
  92. package/src/schema-display.tsx +5 -2
  93. package/src/selection-toolbar.stories.tsx +9 -1
  94. package/src/session-header.stories.tsx +128 -0
  95. package/src/session-header.test.tsx +138 -0
  96. package/src/session-header.tsx +243 -0
  97. package/src/session-status-bar.stories.tsx +73 -0
  98. package/src/session-status-bar.test.tsx +94 -0
  99. package/src/session-status-bar.tsx +165 -0
  100. package/src/snippet.stories.tsx +9 -1
  101. package/src/stack-trace.tsx +6 -2
  102. package/src/streamdown-i18n.test.tsx +1 -1
  103. package/src/task.stories.tsx +13 -3
  104. package/src/templates-ai-assistant.stories.tsx +21 -1
  105. package/src/token-usage.stories.tsx +24 -0
  106. package/src/token-usage.test.tsx +92 -0
  107. package/src/{context.tsx → token-usage.tsx} +67 -53
  108. package/src/tool-result-card.stories.tsx +9 -1
  109. package/src/tool.stories.tsx +13 -3
  110. package/src/tool.tsx +15 -8
  111. package/src/turn-status.stories.tsx +124 -0
  112. package/src/turn-status.test.tsx +74 -0
  113. package/src/turn-status.tsx +174 -0
  114. package/src/use-audio-level.ts +104 -0
  115. package/src/web-preview.tsx +8 -3
  116. package/dist/_audio-player-media-chrome-KA5DY54G.js.map +0 -1
  117. package/dist/_flow-boundary-D63PJ65S.js.map +0 -1
  118. package/dist/_interactive-terminal-xterm-ATJ5EW3G.js +0 -11
  119. package/dist/_interactive-terminal-xterm-ATJ5EW3G.js.map +0 -1
  120. package/dist/_persona-rive-RFR2EUWP.js.map +0 -1
  121. package/src/_interactive-terminal-xterm.ts +0 -32
  122. package/src/blocks-ai-composer.stories.tsx +0 -83
  123. package/src/context.stories.tsx +0 -16
  124. package/src/interactive-terminal.stories.tsx +0 -165
  125. package/src/interactive-terminal.test.tsx +0 -448
  126. package/src/interactive-terminal.tsx +0 -444
  127. package/src/model-selector.tsx +0 -225
  128. package/src/terminal.tsx +0 -244
@@ -0,0 +1,453 @@
1
+ "use client";
2
+
3
+ /**
4
+ * MessageCompare — side-by-side comparison of 2-4 model responses to the same
5
+ * prompt (issue #23). The one-at-a-time sibling of `MessageBranch`
6
+ * (`message.tsx`): `MessageBranch` switches between responses; `MessageCompare`
7
+ * shows several at once, each with its own status, its own scroll position, and
8
+ * its own `MessageFeedback`.
9
+ *
10
+ * Architecture (lifted state, INTERNAL-ONLY — not the ChartFrame/PromptInputProvider
11
+ * PUBLIC-provider shape): `MessageCompareProvider` + `useMessageCompare()` hold the
12
+ * `state`/`actions`/`meta` that back the grid, but both are unexported implementation
13
+ * details, like `ChartFrameProvider` (`.claude/rules/chart-components.md` — "not
14
+ * exported publicly"). `MessageCompare` always mounts its own private instance and
15
+ * treats every child as a column (`Children.toArray`), so there is no seam where an
16
+ * ambient provider or a sibling control could attach to that SAME instance without
17
+ * corrupting the grid (an extra `ResizablePanel`, or a spurious tab with no model
18
+ * name). The supported way to add a "Sync scroll" toggle beside the grid is the
19
+ * ordinary controlled-prop pattern — external `useState` plus `syncScroll`/
20
+ * `onSyncScrollChange` on `MessageCompare` (see `SyncedScrollDemo` in
21
+ * `message-compare.stories.tsx`) — which needs neither export. Column registration
22
+ * and the scroll-sync broadcast live on a second, also-unexported context — the same
23
+ * internal-context-splitting shape `context-panel.tsx` uses for its own refs.
24
+ *
25
+ * Independent streaming/scroll (the issue's core requirement): a column never
26
+ * auto-scrolls itself or a sibling — there is no shared "stick to bottom"
27
+ * driver here (that's `Conversation`'s job for a single transcript). Content
28
+ * growing in one column therefore cannot move a sibling's scroll position by
29
+ * construction; the only way scroll moves across columns is the opt-in
30
+ * `syncScroll` broadcast below.
31
+ *
32
+ * Responsive collapse: under the `md` breakpoint (768px, the same threshold
33
+ * `useIsMobile` already drives for `ContextPanel`'s Sheet fallback) the
34
+ * side-by-side grid becomes a `Tabs` strip, per the issue. Every column stays
35
+ * mounted (`forceMount` + Radix's own `hidden` attribute on the inactive
36
+ * panel) rather than unmounting on tab switch, so a column's scroll position
37
+ * survives flipping between tabs.
38
+ */
39
+
40
+ import {
41
+ createContext,
42
+ forwardRef,
43
+ useCallback,
44
+ useEffect,
45
+ useId,
46
+ useMemo,
47
+ useRef,
48
+ useState,
49
+ use,
50
+ Children,
51
+ Fragment,
52
+ type HTMLAttributes,
53
+ type ReactElement,
54
+ type ReactNode,
55
+ } from "react";
56
+ import type { ChatStatus } from "ai";
57
+ import { AlertTriangleIcon } from "lucide-react";
58
+ import {
59
+ ResizableHandle,
60
+ ResizablePanel,
61
+ ResizablePanelGroup,
62
+ Tabs,
63
+ TabsContent,
64
+ TabsList,
65
+ TabsTrigger,
66
+ useIsMobile,
67
+ useLocale,
68
+ } from "@elabs-ai/components-ui";
69
+ import { cn } from "@elabs-ai/components-ui/lib/cn";
70
+ import { Shimmer } from "./shimmer";
71
+
72
+ // ── Types ─────────────────────────────────────────────────────────────────────
73
+
74
+ /** The minimum a column needs to identify itself — mainly its accessible name. */
75
+ export interface MessageCompareModel {
76
+ /** Rendered in the column header AND used as the region's `aria-label`. */
77
+ name: string;
78
+ /** Optional stable id, e.g. for analytics — not used for layout or a11y. */
79
+ id?: string;
80
+ }
81
+
82
+ /** `MessageCompare` supports 2-4 side-by-side responses (issue #23). */
83
+ export type MessageCompareColumnsCount = 2 | 3 | 4;
84
+
85
+ interface MessageCompareState {
86
+ syncScroll: boolean;
87
+ }
88
+
89
+ interface MessageCompareActions {
90
+ setSyncScroll: (value: boolean) => void;
91
+ }
92
+
93
+ interface MessageCompareMeta {
94
+ columns: MessageCompareColumnsCount;
95
+ isMobile: boolean;
96
+ }
97
+
98
+ interface MessageCompareContextValue {
99
+ state: MessageCompareState;
100
+ actions: MessageCompareActions;
101
+ meta: MessageCompareMeta;
102
+ }
103
+
104
+ // ── Internal state context (NOT exported — see the file's architecture comment) ─
105
+
106
+ const MessageCompareContext = createContext<MessageCompareContextValue | null>(null);
107
+
108
+ /**
109
+ * INTERNAL — reads the `state`/`actions`/`meta` `MessageCompareProvider` computes.
110
+ * Not exported: `MessageCompare` always owns a private `MessageCompareProvider`
111
+ * instance, so there is no ambient instance for an outside caller to read.
112
+ */
113
+ function useMessageCompare(): MessageCompareContextValue {
114
+ const ctx = use(MessageCompareContext);
115
+ if (!ctx) {
116
+ throw new Error("useMessageCompare must be used within a MessageCompareProvider.");
117
+ }
118
+ return ctx;
119
+ }
120
+
121
+ // ── Internal context (column registry + scroll broadcast — not public API) ───
122
+
123
+ interface MessageCompareColumnEntry {
124
+ node: HTMLDivElement;
125
+ /** Set right before a programmatic scroll so the column's own handler knows
126
+ * to swallow the resulting `scroll` event instead of re-broadcasting it. */
127
+ suppressNextScroll: boolean;
128
+ }
129
+
130
+ interface MessageCompareInternalValue {
131
+ registerColumn: (id: string, node: HTMLDivElement | null) => void;
132
+ reportScroll: (id: string) => void;
133
+ }
134
+
135
+ const MessageCompareInternalContext = createContext<MessageCompareInternalValue | null>(null);
136
+
137
+ function useMessageCompareInternal(): MessageCompareInternalValue {
138
+ const ctx = use(MessageCompareInternalContext);
139
+ if (!ctx) {
140
+ throw new Error("MessageCompareColumn must be used within a MessageCompare.");
141
+ }
142
+ return ctx;
143
+ }
144
+
145
+ // ── Provider (INTERNAL — not exported; see the file's architecture comment) ────
146
+
147
+ interface MessageCompareProviderProps {
148
+ children: ReactNode;
149
+ columns: MessageCompareColumnsCount;
150
+ /** Controlled sync-scroll flag. Omit for the uncontrolled default. */
151
+ syncScroll?: boolean;
152
+ /** Uncontrolled initial value. @default false */
153
+ defaultSyncScroll?: boolean;
154
+ onSyncScrollChange?: (value: boolean) => void;
155
+ }
156
+
157
+ function MessageCompareProvider({
158
+ children,
159
+ columns,
160
+ syncScroll: syncScrollProp,
161
+ defaultSyncScroll = false,
162
+ onSyncScrollChange,
163
+ }: MessageCompareProviderProps) {
164
+ const isControlled = syncScrollProp !== undefined;
165
+ const [internalSyncScroll, setInternalSyncScroll] = useState(defaultSyncScroll);
166
+ const syncScroll = isControlled ? syncScrollProp : internalSyncScroll;
167
+ const isMobile = useIsMobile();
168
+
169
+ const setSyncScroll = useCallback(
170
+ (value: boolean) => {
171
+ if (!isControlled) setInternalSyncScroll(value);
172
+ onSyncScrollChange?.(value);
173
+ },
174
+ [isControlled, onSyncScrollChange],
175
+ );
176
+
177
+ // A ref, not state: scroll happens far too often to route through a render.
178
+ const columnsRef = useRef<Map<string, MessageCompareColumnEntry>>(new Map());
179
+ const syncScrollRef = useRef(syncScroll);
180
+ useEffect(() => {
181
+ syncScrollRef.current = syncScroll;
182
+ }, [syncScroll]);
183
+
184
+ const registerColumn = useCallback((id: string, node: HTMLDivElement | null) => {
185
+ if (node) columnsRef.current.set(id, { node, suppressNextScroll: false });
186
+ else columnsRef.current.delete(id);
187
+ }, []);
188
+
189
+ const reportScroll = useCallback((id: string) => {
190
+ const entry = columnsRef.current.get(id);
191
+ if (!entry) return;
192
+
193
+ // This scroll was OUR OWN programmatic write (a broadcast landing on this
194
+ // column) — swallow it once rather than re-broadcasting, which is what
195
+ // would otherwise ping-pong two synced columns back and forth.
196
+ if (entry.suppressNextScroll) {
197
+ entry.suppressNextScroll = false;
198
+ return;
199
+ }
200
+
201
+ if (!syncScrollRef.current) return;
202
+
203
+ const { node } = entry;
204
+ const range = node.scrollHeight - node.clientHeight;
205
+ const ratio = range > 0 ? node.scrollTop / range : 0;
206
+
207
+ columnsRef.current.forEach((other, otherId) => {
208
+ if (otherId === id) return;
209
+ const otherRange = other.node.scrollHeight - other.node.clientHeight;
210
+ if (otherRange <= 0) return;
211
+ // Assign first, THEN compare the browser's actual (possibly rounded)
212
+ // scrollTop to what it was before — never predict equality from the
213
+ // unrounded `ratio * otherRange` math, since scrollTop is an integer
214
+ // pixel value and the destination can already sit at that pixel. Only
215
+ // arm suppression when the write really changed something: if it
216
+ // didn't, no scroll event will fire to clear the flag, and the
217
+ // destination's NEXT real user scroll (a single keyboard PageDown/Home
218
+ // in particular) would otherwise be silently swallowed instead of
219
+ // propagating to its siblings (#12/#53 review, P2).
220
+ const previousScrollTop = other.node.scrollTop;
221
+ other.node.scrollTop = ratio * otherRange;
222
+ if (other.node.scrollTop !== previousScrollTop) {
223
+ other.suppressNextScroll = true;
224
+ }
225
+ });
226
+ }, []);
227
+
228
+ const state = useMemo<MessageCompareState>(() => ({ syncScroll }), [syncScroll]);
229
+ const actions = useMemo<MessageCompareActions>(() => ({ setSyncScroll }), [setSyncScroll]);
230
+ const meta = useMemo<MessageCompareMeta>(() => ({ columns, isMobile }), [columns, isMobile]);
231
+ const contextValue = useMemo<MessageCompareContextValue>(
232
+ () => ({ state, actions, meta }),
233
+ [state, actions, meta],
234
+ );
235
+ const internalValue = useMemo<MessageCompareInternalValue>(
236
+ () => ({ registerColumn, reportScroll }),
237
+ [registerColumn, reportScroll],
238
+ );
239
+
240
+ return (
241
+ <MessageCompareContext value={contextValue}>
242
+ <MessageCompareInternalContext value={internalValue}>
243
+ {children}
244
+ </MessageCompareInternalContext>
245
+ </MessageCompareContext>
246
+ );
247
+ }
248
+
249
+ // ── MessageCompare (layout) ───────────────────────────────────────────────────
250
+
251
+ function columnKey(child: ReactElement, index: number): string {
252
+ return child.key ?? String(index);
253
+ }
254
+
255
+ export type MessageCompareProps = Omit<HTMLAttributes<HTMLDivElement>, "children"> & {
256
+ /** `MessageCompareColumn` elements — 2 to 4. */
257
+ children: ReactNode;
258
+ columns: MessageCompareColumnsCount;
259
+ /** Controlled sync-scroll flag. Omit for the uncontrolled default (off). */
260
+ syncScroll?: boolean;
261
+ /** Uncontrolled initial value. @default false */
262
+ defaultSyncScroll?: boolean;
263
+ onSyncScrollChange?: (value: boolean) => void;
264
+ /** aria-label for the narrow-viewport tab strip. Defaults to microcopy. */
265
+ tabsLabel?: string;
266
+ };
267
+
268
+ /**
269
+ * Responsive side-by-side comparison of 2-4 `MessageCompareColumn`s. Desktop:
270
+ * resizable columns (`ResizablePanelGroup`). Under `md`: a `Tabs` strip, per
271
+ * the issue's named narrow-viewport treatment.
272
+ */
273
+ export const MessageCompare = forwardRef<HTMLDivElement, MessageCompareProps>(
274
+ function MessageCompare(
275
+ {
276
+ children,
277
+ columns,
278
+ syncScroll,
279
+ defaultSyncScroll,
280
+ onSyncScrollChange,
281
+ tabsLabel,
282
+ className,
283
+ ...props
284
+ },
285
+ ref,
286
+ ) {
287
+ return (
288
+ <MessageCompareProvider
289
+ columns={columns}
290
+ defaultSyncScroll={defaultSyncScroll}
291
+ onSyncScrollChange={onSyncScrollChange}
292
+ syncScroll={syncScroll}
293
+ >
294
+ <MessageCompareLayout ref={ref} className={className} tabsLabel={tabsLabel} {...props}>
295
+ {children}
296
+ </MessageCompareLayout>
297
+ </MessageCompareProvider>
298
+ );
299
+ },
300
+ );
301
+
302
+ type MessageCompareLayoutProps = Omit<HTMLAttributes<HTMLDivElement>, "children"> & {
303
+ children: ReactNode;
304
+ tabsLabel?: string;
305
+ };
306
+
307
+ const MessageCompareLayout = forwardRef<HTMLDivElement, MessageCompareLayoutProps>(
308
+ function MessageCompareLayout({ children, tabsLabel, className, ...props }, ref) {
309
+ const { meta } = useMessageCompare();
310
+ const { t } = useLocale();
311
+ const items = Children.toArray(children) as ReactElement[];
312
+
313
+ if (meta.isMobile) {
314
+ const firstKey = items[0] ? columnKey(items[0], 0) : undefined;
315
+ return (
316
+ <div
317
+ ref={ref}
318
+ className={cn("flex h-full min-h-0 flex-col", className)}
319
+ data-slot="message-compare"
320
+ {...props}
321
+ >
322
+ <Tabs className="flex h-full min-h-0 flex-col" defaultValue={firstKey}>
323
+ <TabsList
324
+ aria-label={tabsLabel ?? t("ai.messageCompare.tabs")}
325
+ className="w-full shrink-0"
326
+ >
327
+ {items.map((child, index) => {
328
+ const key = columnKey(child, index);
329
+ const props = child.props as { model?: MessageCompareModel };
330
+ return (
331
+ <TabsTrigger className="flex-1" key={key} value={key}>
332
+ {props.model?.name ?? key}
333
+ </TabsTrigger>
334
+ );
335
+ })}
336
+ </TabsList>
337
+ {items.map((child, index) => {
338
+ const key = columnKey(child, index);
339
+ return (
340
+ <TabsContent
341
+ className="mt-2 min-h-0 flex-1 data-[state=active]:flex"
342
+ forceMount
343
+ key={key}
344
+ value={key}
345
+ >
346
+ {child}
347
+ </TabsContent>
348
+ );
349
+ })}
350
+ </Tabs>
351
+ </div>
352
+ );
353
+ }
354
+
355
+ return (
356
+ <div
357
+ ref={ref}
358
+ className={cn("h-full min-h-0", className)}
359
+ data-slot="message-compare"
360
+ {...props}
361
+ >
362
+ <ResizablePanelGroup direction="horizontal">
363
+ {items.map((child, index) => (
364
+ <Fragment key={columnKey(child, index)}>
365
+ {index > 0 && <ResizableHandle withHandle />}
366
+ <ResizablePanel defaultSize={100 / items.length} minSize={15}>
367
+ {child}
368
+ </ResizablePanel>
369
+ </Fragment>
370
+ ))}
371
+ </ResizablePanelGroup>
372
+ </div>
373
+ );
374
+ },
375
+ );
376
+
377
+ // ── MessageCompareColumn ──────────────────────────────────────────────────────
378
+
379
+ export type MessageCompareColumnProps = Omit<HTMLAttributes<HTMLDivElement>, "children"> & {
380
+ /** Identifies the column — its `name` becomes the region's accessible name. */
381
+ model: MessageCompareModel;
382
+ /** In-flight state for this column's OWN response, independent of siblings. */
383
+ status?: ChatStatus;
384
+ /** The consumer's own `Message`/`MessageResponse` composition for this column. */
385
+ children?: ReactNode;
386
+ };
387
+
388
+ /**
389
+ * One labelled, independently-scrolling response column. `role="region"` +
390
+ * `aria-label={model.name}` so a screen-reader user can tell which answer
391
+ * they're in (issue #23 a11y requirement).
392
+ */
393
+ export const MessageCompareColumn = forwardRef<HTMLDivElement, MessageCompareColumnProps>(
394
+ function MessageCompareColumn({ model, status, children, className, id: idProp, ...props }, ref) {
395
+ const { registerColumn, reportScroll } = useMessageCompareInternal();
396
+ const { t } = useLocale();
397
+ const generatedId = useId();
398
+ const id = idProp ?? generatedId;
399
+
400
+ const setScrollRef = useCallback(
401
+ (node: HTMLDivElement | null) => registerColumn(id, node),
402
+ [id, registerColumn],
403
+ );
404
+ const handleScroll = useCallback(() => reportScroll(id), [id, reportScroll]);
405
+
406
+ const isBusy = status === "streaming" || status === "submitted";
407
+ const isError = status === "error";
408
+
409
+ return (
410
+ <div
411
+ ref={ref}
412
+ aria-label={model.name}
413
+ className={cn("flex h-full min-h-0 min-w-0 flex-col", className)}
414
+ data-slot="message-compare-column"
415
+ id={id}
416
+ role="region"
417
+ {...props}
418
+ >
419
+ <div
420
+ className="flex shrink-0 items-center justify-between gap-2 border-b px-3 py-2"
421
+ data-slot="message-compare-column-header"
422
+ >
423
+ <span className="truncate text-body font-medium">{model.name}</span>
424
+ {isBusy && (
425
+ <span className="shrink-0" role="status" aria-live="polite">
426
+ <Shimmer as="span" className="text-meta" duration={1.5}>
427
+ {t("loading")}
428
+ </Shimmer>
429
+ </span>
430
+ )}
431
+ {isError && (
432
+ <span
433
+ className="flex shrink-0 items-center gap-1 text-meta text-destructive-text"
434
+ role="alert"
435
+ >
436
+ <AlertTriangleIcon aria-hidden="true" className="size-3.5" />
437
+ {t("ai.messageCompare.error")}
438
+ </span>
439
+ )}
440
+ </div>
441
+ <div
442
+ className="min-h-0 flex-1 overflow-y-auto p-4 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset"
443
+ data-slot="message-compare-column-body"
444
+ onScroll={handleScroll}
445
+ ref={setScrollRef}
446
+ tabIndex={0}
447
+ >
448
+ {children}
449
+ </div>
450
+ </div>
451
+ );
452
+ },
453
+ );
@@ -1,3 +1,20 @@
1
+ /**
2
+ * MessageForm — **not a chat input.** A model-emitted form rendered INSIDE a
3
+ * message.
4
+ *
5
+ * It sits next to `Composer` in the sidebar and reads like a sibling of it; it
6
+ * is not one. The composer is the field a person types their turn into
7
+ * ([AI/Composer](?path=/docs/ai-composer--docs)); this is a form the MODEL
8
+ * authored — it emits a serializable `FormSpec` (`message-form-spec.ts`), the
9
+ * user fills it in place in the transcript, and the app receives structured
10
+ * `{ formName, values }` on submit.
11
+ *
12
+ * Spec-driven and zod-validated: the model is the author, it never chooses the
13
+ * look. A malformed spec degrades to `MessageFormFallback` rather than
14
+ * throwing, half-arrived fields are dropped while streaming, and a submitted
15
+ * form renders inert with its values still visible — a chat message is a
16
+ * historical record.
17
+ */
1
18
  import type { Meta, StoryObj } from "@storybook/react-vite";
2
19
  import { useState } from "react";
3
20
  import { MessageForm } from "./message-form";
@@ -7,7 +24,18 @@ const meta = {
7
24
  title: "AI/MessageForm",
8
25
  component: MessageForm,
9
26
  tags: ["autodocs"],
10
- parameters: { layout: "padded" },
27
+ parameters: {
28
+ layout: "padded",
29
+ docs: {
30
+ description: {
31
+ component:
32
+ "Not a chat input. A model-emitted form rendered inside a message. " +
33
+ "The composer is the field a person types their turn into ([AI/Composer](?path=/docs/ai-composer--docs)); this is a form the MODEL authored — it emits a serializable FormSpec, the user fills it in place in the transcript, and the app receives structured `{ formName, values }` on submit. " +
34
+ "Spec-driven and zod-validated (the model is the author, it never chooses the look): a malformed spec degrades to MessageFormFallback rather than throwing, half-arrived fields are dropped while streaming, and a submitted form renders inert with its values still visible, because a chat message is a historical record. " +
35
+ "Composes @elabs-ai/components-ui inputs; semantic tokens only; reads in all themes.",
36
+ },
37
+ },
38
+ },
11
39
  } satisfies Meta<typeof MessageForm>;
12
40
  export default meta;
13
41
  type Story = StoryObj<typeof meta>;
@@ -28,7 +28,15 @@ import { SourceList } from "./sources";
28
28
  const meta = {
29
29
  title: "AI/Message",
30
30
  component: Message,
31
- parameters: { layout: "padded" },
31
+ parameters: {
32
+ layout: "padded",
33
+ docs: {
34
+ description: {
35
+ component:
36
+ "One turn of a CHAT transcript; one line of a CONSOLE transcript is `Terminal/TerminalTranscriptRow` — see [Choosing between similar components](?path=/docs/docs-choosing-between-similar-components--docs). `UserMessage` and `AgentMessage` are named presets over this same component and keep its `message` slot, so one consumer selector matches every entry point.",
37
+ },
38
+ },
39
+ },
32
40
  } satisfies Meta<typeof Message>;
33
41
  export default meta;
34
42
  type Story = StoryObj<typeof meta>;