@elabs-ai/components-ai 4.0.0 → 4.2.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 (180) hide show
  1. package/README.md +62 -12
  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 +1152 -218
  9. package/dist/index.js +5601 -3241
  10. package/dist/index.js.map +1 -1
  11. package/package.json +30 -15
  12. package/src/__contract__/audio-visualizer.contract.test.tsx +49 -0
  13. package/src/__contract__/chat-shell.contract.test.tsx +49 -0
  14. package/src/__contract__/grouped-parts.contract.test.tsx +49 -0
  15. package/src/__contract__/image.contract.test.tsx +49 -0
  16. package/src/__contract__/markdown-view.contract.test.tsx +49 -0
  17. package/src/__contract__/message-feedback.contract.test.tsx +49 -0
  18. package/src/__contract__/message-form.contract.test.tsx +49 -0
  19. package/src/__contract__/message-table.contract.test.tsx +49 -0
  20. package/src/__contract__/model-provider-logo.contract.test.tsx +49 -0
  21. package/src/__contract__/persona.contract.test.tsx +49 -0
  22. package/src/__contract__/prompt-input-effort.contract.test.tsx +49 -0
  23. package/src/__contract__/prompt-input-mode.contract.test.tsx +49 -0
  24. package/src/_audio-player-media-chrome.tsx +102 -15
  25. package/src/_chat-shell-rail.tsx +2 -2
  26. package/src/_flow-boundary.tsx +68 -49
  27. package/src/_lazy-boundary-conformance.ts +38 -0
  28. package/src/_lazy-cjk.test.ts +43 -0
  29. package/src/_lazy-cjk.ts +73 -0
  30. package/src/_lazy-engine-boundary.tsx +61 -0
  31. package/src/_lazy-math.test.ts +63 -0
  32. package/src/_lazy-math.ts +90 -0
  33. package/src/_lazy-mermaid-absent.test.ts +53 -0
  34. package/src/_lazy-mermaid.test.ts +15 -0
  35. package/src/_lazy-mermaid.ts +24 -1
  36. package/src/_mermaid-error-panel.test.tsx +50 -0
  37. package/src/_mermaid-error-panel.tsx +66 -0
  38. package/src/_persona-rive.tsx +62 -10
  39. package/src/_streamdown-i18n.ts +94 -22
  40. package/src/_streamdown-safety.ts +170 -0
  41. package/src/_theme-scope-store.test.ts +83 -0
  42. package/src/_theme-scope-store.ts +103 -0
  43. package/src/agent-event.stories.tsx +97 -0
  44. package/src/agent-event.test.tsx +145 -0
  45. package/src/agent-event.tsx +187 -0
  46. package/src/agent-timeline.stories.tsx +9 -1
  47. package/src/agent.test.tsx +47 -0
  48. package/src/agent.tsx +43 -24
  49. package/src/agentic-workspace.stories.tsx +27 -20
  50. package/src/artifact.tsx +17 -14
  51. package/src/asset-preview.test.tsx +40 -0
  52. package/src/asset-preview.tsx +83 -12
  53. package/src/attachments.tsx +7 -4
  54. package/src/audio-player.test.tsx +78 -0
  55. package/src/audio-player.tsx +243 -56
  56. package/src/audio-visualizer.stories.tsx +126 -0
  57. package/src/audio-visualizer.test.tsx +438 -0
  58. package/src/audio-visualizer.tsx +367 -0
  59. package/src/canvas.stories.tsx +150 -1
  60. package/src/chat-shell.stories.tsx +18 -3
  61. package/src/chat.stories.tsx +16 -2
  62. package/src/code-block.stories.tsx +9 -1
  63. package/src/code-block.test.tsx +100 -1
  64. package/src/code-block.tsx +166 -103
  65. package/src/commit.tsx +30 -41
  66. package/src/composer.stories.tsx +372 -9
  67. package/src/composer.test.tsx +357 -6
  68. package/src/composer.tsx +217 -35
  69. package/src/confirmation.stories.tsx +72 -1
  70. package/src/confirmation.test.tsx +216 -2
  71. package/src/confirmation.tsx +264 -4
  72. package/src/context-panel.stories.tsx +9 -1
  73. package/src/context-panel.tsx +3 -2
  74. package/src/conversation.stories.tsx +86 -2
  75. package/src/conversation.test.tsx +66 -0
  76. package/src/conversation.tsx +52 -9
  77. package/src/diff-view.stories.tsx +196 -0
  78. package/src/diff-view.test.tsx +238 -0
  79. package/src/diff-view.tsx +697 -0
  80. package/src/environment-variables.tsx +20 -37
  81. package/src/file-tree.test.tsx +21 -0
  82. package/src/file-tree.tsx +12 -2
  83. package/src/gallery.tsx +4 -2
  84. package/src/index.ts +14 -4
  85. package/src/inline-citation.tsx +5 -5
  86. package/src/jsx-preview.stories.tsx +2 -2
  87. package/src/jsx-preview.tsx +151 -43
  88. package/src/markdown-view.stories.tsx +92 -1
  89. package/src/markdown-view.test.tsx +237 -2
  90. package/src/markdown-view.tsx +157 -6
  91. package/src/message-compare.stories.tsx +175 -0
  92. package/src/message-compare.test.tsx +207 -0
  93. package/src/message-compare.tsx +453 -0
  94. package/src/message-form.stories.tsx +65 -4
  95. package/src/message-form.test.tsx +8 -2
  96. package/src/message-form.tsx +15 -7
  97. package/src/message-table.stories.tsx +2 -2
  98. package/src/message-table.test.tsx +7 -0
  99. package/src/message-table.tsx +8 -4
  100. package/src/message.stories.tsx +9 -1
  101. package/src/message.test.tsx +247 -1
  102. package/src/message.tsx +125 -15
  103. package/src/microcopy.test.tsx +40 -0
  104. package/src/{model-selector.stories.tsx → model-provider-logo.stories.tsx} +17 -8
  105. package/src/model-provider-logo.test.tsx +109 -0
  106. package/src/model-provider-logo.tsx +183 -0
  107. package/src/open-in-chat.tsx +50 -29
  108. package/src/package-info.tsx +12 -12
  109. package/src/permission-mode-select.stories.tsx +82 -0
  110. package/src/permission-mode-select.test.tsx +100 -0
  111. package/src/permission-mode-select.tsx +137 -0
  112. package/src/persona-missing-peer.test.tsx +54 -0
  113. package/src/persona.tsx +68 -22
  114. package/src/plan.stories.tsx +166 -0
  115. package/src/plan.test.tsx +267 -0
  116. package/src/plan.tsx +182 -20
  117. package/src/prompt-input-effort.stories.tsx +123 -0
  118. package/src/prompt-input-effort.test.tsx +83 -0
  119. package/src/prompt-input-effort.tsx +136 -0
  120. package/src/prompt-input-mode.stories.tsx +108 -0
  121. package/src/prompt-input-mode.test.tsx +99 -0
  122. package/src/prompt-input-mode.tsx +169 -0
  123. package/src/prompt-input-slash.stories.tsx +211 -0
  124. package/src/prompt-input-slash.test.tsx +262 -0
  125. package/src/prompt-input-slash.tsx +541 -0
  126. package/src/prompt-input.stories.tsx +2 -2
  127. package/src/prompt-input.test.tsx +67 -1
  128. package/src/prompt-input.tsx +42 -4
  129. package/src/queue.tsx +4 -4
  130. package/src/reasoning.tsx +42 -17
  131. package/src/sandbox.stories.tsx +9 -1
  132. package/src/sandbox.tsx +3 -3
  133. package/src/schema-display.test.tsx +56 -0
  134. package/src/schema-display.tsx +71 -37
  135. package/src/selection-toolbar.stories.tsx +9 -1
  136. package/src/session-header.stories.tsx +128 -0
  137. package/src/session-header.test.tsx +138 -0
  138. package/src/session-header.tsx +243 -0
  139. package/src/session-status-bar.stories.tsx +73 -0
  140. package/src/session-status-bar.test.tsx +94 -0
  141. package/src/session-status-bar.tsx +165 -0
  142. package/src/snippet.stories.tsx +9 -1
  143. package/src/snippet.test.tsx +6 -2
  144. package/src/snippet.tsx +14 -34
  145. package/src/speech-input.test.tsx +109 -0
  146. package/src/speech-input.tsx +31 -3
  147. package/src/stack-trace.tsx +24 -36
  148. package/src/streamdown-i18n.test.tsx +24 -1
  149. package/src/task.stories.tsx +13 -3
  150. package/src/templates-ai-assistant.stories.tsx +21 -1
  151. package/src/test-results.tsx +47 -27
  152. package/src/token-usage.stories.tsx +24 -0
  153. package/src/token-usage.test.tsx +92 -0
  154. package/src/{context.tsx → token-usage.tsx} +73 -59
  155. package/src/tool-result-card.stories.tsx +9 -1
  156. package/src/tool.stories.tsx +13 -3
  157. package/src/tool.test.tsx +65 -0
  158. package/src/tool.tsx +73 -30
  159. package/src/transcription.tsx +1 -1
  160. package/src/turn-status.stories.tsx +124 -0
  161. package/src/turn-status.test.tsx +74 -0
  162. package/src/turn-status.tsx +174 -0
  163. package/src/use-audio-level.ts +104 -0
  164. package/src/voice-selector.tsx +5 -5
  165. package/src/web-preview.test.tsx +51 -1
  166. package/src/web-preview.tsx +46 -10
  167. package/dist/_audio-player-media-chrome-KA5DY54G.js.map +0 -1
  168. package/dist/_flow-boundary-D63PJ65S.js.map +0 -1
  169. package/dist/_interactive-terminal-xterm-ATJ5EW3G.js +0 -11
  170. package/dist/_interactive-terminal-xterm-ATJ5EW3G.js.map +0 -1
  171. package/dist/_persona-rive-RFR2EUWP.js.map +0 -1
  172. package/src/_interactive-terminal-xterm.ts +0 -32
  173. package/src/blocks-ai-composer.stories.tsx +0 -83
  174. package/src/context.stories.tsx +0 -16
  175. package/src/interactive-terminal.stories.tsx +0 -165
  176. package/src/interactive-terminal.test.tsx +0 -448
  177. package/src/interactive-terminal.tsx +0 -444
  178. package/src/model-selector.test.tsx +0 -55
  179. package/src/model-selector.tsx +0 -225
  180. 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,13 +1,48 @@
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
- import { MessageForm } from "./message-form";
20
+ import {
21
+ MessageForm,
22
+ MessageFormFields,
23
+ MessageFormProvider,
24
+ MessageFormRoot,
25
+ MessageFormSubmit,
26
+ MessageFormTitle,
27
+ } from "./message-form";
4
28
  import type { FormSpec, FormValues } from "./message-form-spec";
5
29
 
6
30
  const meta = {
7
31
  title: "AI/MessageForm",
8
32
  component: MessageForm,
9
33
  tags: ["autodocs"],
10
- parameters: { layout: "padded" },
34
+ parameters: {
35
+ layout: "padded",
36
+ docs: {
37
+ description: {
38
+ component:
39
+ "Not a chat input. A model-emitted form rendered inside a message. " +
40
+ "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. " +
41
+ "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. " +
42
+ "Composes @elabs-ai/components-ui inputs; semantic tokens only; reads in all themes.",
43
+ },
44
+ },
45
+ },
11
46
  } satisfies Meta<typeof MessageForm>;
12
47
  export default meta;
13
48
  type Story = StoryObj<typeof meta>;
@@ -67,7 +102,7 @@ export const Default: Story = {
67
102
  */
68
103
  export const StreamingPartial: Story = {
69
104
  args: {
70
- streaming: true,
105
+ isStreaming: true,
71
106
  spec: {
72
107
  formName: "book_demo",
73
108
  title: "Book a demo",
@@ -83,11 +118,37 @@ export const StreamingPartial: Story = {
83
118
  /** Streaming with nothing parsed yet → a layout-shaped skeleton. */
84
119
  export const StreamingSkeleton: Story = {
85
120
  args: {
86
- streaming: true,
121
+ isStreaming: true,
87
122
  spec: { formName: "book_demo", title: "Book a demo", fields: [] },
88
123
  },
89
124
  };
90
125
 
126
+ /**
127
+ * The compound composition — `MessageFormProvider` + the parts directly,
128
+ * instead of the `MessageForm` convenience wrapper (see the file-level doc
129
+ * comment's "compound structure" section). `isStreaming` lives on the
130
+ * provider itself since it is the one that owns the lifted form state; a
131
+ * custom layout that skips `MessageForm` still needs to pass it through here.
132
+ */
133
+ export const ProviderStreaming: Story = {
134
+ render: () => (
135
+ <MessageFormProvider
136
+ isStreaming
137
+ spec={{
138
+ formName: "book_demo",
139
+ title: "Book a demo",
140
+ fields: [{ type: "string", name: "fullName", label: "Full name", required: true }],
141
+ }}
142
+ >
143
+ <MessageFormRoot>
144
+ <MessageFormTitle />
145
+ <MessageFormFields />
146
+ <MessageFormSubmit />
147
+ </MessageFormRoot>
148
+ </MessageFormProvider>
149
+ ),
150
+ };
151
+
91
152
  /** Empty (not streaming) → a calm fallback, never broken UI. */
92
153
  export const Empty: Story = {
93
154
  args: {
@@ -116,16 +116,22 @@ describe("MessageForm — never throws / fallback", () => {
116
116
  });
117
117
 
118
118
  it("renders a skeleton (not a fallback) for an empty spec while streaming", () => {
119
- const { container } = render(<MessageForm spec={{ formName: "x", fields: [] }} streaming />);
119
+ const { container } = render(<MessageForm spec={{ formName: "x", fields: [] }} isStreaming />);
120
120
  expect(screen.queryByText(/no fields/i)).not.toBeInTheDocument();
121
121
  // Skeleton placeholders are aria-hidden decoration.
122
122
  expect(container.querySelector('[aria-hidden="true"] .animate-pulse')).not.toBeNull();
123
123
  });
124
124
 
125
+ it("still honors the deprecated `streaming` alias", () => {
126
+ const { container } = render(<MessageForm spec={{ formName: "x", fields: [] }} streaming />);
127
+ expect(screen.queryByText(/no fields/i)).not.toBeInTheDocument();
128
+ expect(container.querySelector('[aria-hidden="true"] .animate-pulse')).not.toBeNull();
129
+ });
130
+
125
131
  it("drops half-arrived fields while streaming instead of crashing", () => {
126
132
  render(
127
133
  <MessageForm
128
- streaming
134
+ isStreaming
129
135
  spec={{
130
136
  formName: "x",
131
137
  fields: [
@@ -88,7 +88,7 @@ interface MessageFormContextValue {
88
88
  submitted: boolean;
89
89
  submitting: boolean;
90
90
  disabled: boolean;
91
- streaming: boolean;
91
+ isStreaming: boolean;
92
92
  formId: string;
93
93
  headingId: string;
94
94
  }
@@ -145,6 +145,8 @@ export interface MessageFormProviderProps {
145
145
  /** In-flight submit: controls disabled, the submit button shows a spinner. */
146
146
  submitting?: boolean;
147
147
  /** The spec is still streaming in (renders a skeleton when no fields yet). */
148
+ isStreaming?: boolean;
149
+ /** @deprecated Use `isStreaming`. */
148
150
  streaming?: boolean;
149
151
  children: ReactNode;
150
152
  }
@@ -161,9 +163,11 @@ export function MessageFormProvider({
161
163
  disabled = false,
162
164
  submitted = false,
163
165
  submitting = false,
166
+ isStreaming,
164
167
  streaming = false,
165
168
  children,
166
169
  }: MessageFormProviderProps) {
170
+ const resolvedStreaming = isStreaming ?? streaming;
167
171
  const formId = useId();
168
172
  const headingId = `${formId}-title`;
169
173
 
@@ -231,7 +235,7 @@ export function MessageFormProvider({
231
235
  submitted,
232
236
  submitting,
233
237
  disabled,
234
- streaming,
238
+ isStreaming: resolvedStreaming,
235
239
  formId,
236
240
  headingId,
237
241
  }),
@@ -246,7 +250,7 @@ export function MessageFormProvider({
246
250
  submitted,
247
251
  submitting,
248
252
  disabled,
249
- streaming,
253
+ resolvedStreaming,
250
254
  formId,
251
255
  headingId,
252
256
  ],
@@ -570,9 +574,9 @@ export type MessageFormFieldsProps = HTMLAttributes<HTMLDivElement>;
570
574
  /** Renders every field in the spec, in order. A skeleton while streaming empty. */
571
575
  export const MessageFormFields = forwardRef<HTMLDivElement, MessageFormFieldsProps>(
572
576
  function MessageFormFields({ className, ...props }, ref) {
573
- const { spec, streaming } = useMessageFormContext();
577
+ const { spec, isStreaming } = useMessageFormContext();
574
578
 
575
- if (spec.fields.length === 0 && streaming) {
579
+ if (spec.fields.length === 0 && isStreaming) {
576
580
  return (
577
581
  <div ref={ref} className={cn("flex flex-col gap-4", className)} {...props}>
578
582
  <span className="sr-only" role="status" aria-live="polite">
@@ -771,6 +775,8 @@ export interface MessageFormProps extends Omit<
771
775
  /** In-flight submit: controls disabled, spinner on submit. */
772
776
  submitting?: boolean;
773
777
  /** The spec is still streaming (renders a skeleton when no fields yet). */
778
+ isStreaming?: boolean;
779
+ /** @deprecated Use `isStreaming`. */
774
780
  streaming?: boolean;
775
781
  }
776
782
 
@@ -789,12 +795,14 @@ export const MessageForm = forwardRef<HTMLDivElement, MessageFormProps>(function
789
795
  disabled,
790
796
  submitted,
791
797
  submitting,
798
+ isStreaming,
792
799
  streaming,
793
800
  className,
794
801
  ...props
795
802
  },
796
803
  ref,
797
804
  ) {
805
+ const resolvedStreaming = isStreaming ?? streaming;
798
806
  const result = normalizeFormSpec(spec);
799
807
  if (!result.ok) {
800
808
  return (
@@ -806,7 +814,7 @@ export const MessageForm = forwardRef<HTMLDivElement, MessageFormProps>(function
806
814
  const empty = normalized.fields.length === 0;
807
815
 
808
816
  // Not streaming + no fields → the spec is structurally valid but useless.
809
- if (empty && !streaming) {
817
+ if (empty && !resolvedStreaming) {
810
818
  return (
811
819
  <MessageFormFallback
812
820
  ref={ref}
@@ -826,7 +834,7 @@ export const MessageForm = forwardRef<HTMLDivElement, MessageFormProps>(function
826
834
  disabled={disabled}
827
835
  submitted={submitted}
828
836
  submitting={submitting}
829
- streaming={streaming}
837
+ isStreaming={resolvedStreaming}
830
838
  >
831
839
  <div ref={ref} className={cn("w-full", className)} {...props}>
832
840
  <MessageFormRoot>
@@ -105,7 +105,7 @@ export const Truncated: Story = {
105
105
  /** Streaming / partial — columns known, rows still arriving → skeleton rows. */
106
106
  export const StreamingRows: Story = {
107
107
  args: {
108
- streaming: true,
108
+ isStreaming: true,
109
109
  spec: { title: "Loading results", columns: invoicesSpec.columns, rows: [] },
110
110
  },
111
111
  };
@@ -113,7 +113,7 @@ export const StreamingRows: Story = {
113
113
  /** Missing cells render an em-dash — a half-arrived row never crashes. */
114
114
  export const PartialRows: Story = {
115
115
  args: {
116
- streaming: true,
116
+ isStreaming: true,
117
117
  spec: {
118
118
  title: "Streaming rows",
119
119
  columns: invoicesSpec.columns,