@elabs-ai/components-ai 4.0.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/LICENSE +21 -0
  2. package/README.md +133 -0
  3. package/dist/_audio-player-media-chrome-KA5DY54G.js +81 -0
  4. package/dist/_audio-player-media-chrome-KA5DY54G.js.map +1 -0
  5. package/dist/_flow-boundary-D63PJ65S.js +186 -0
  6. package/dist/_flow-boundary-D63PJ65S.js.map +1 -0
  7. package/dist/_interactive-terminal-xterm-ATJ5EW3G.js +11 -0
  8. package/dist/_interactive-terminal-xterm-ATJ5EW3G.js.map +1 -0
  9. package/dist/_persona-rive-RFR2EUWP.js +164 -0
  10. package/dist/_persona-rive-RFR2EUWP.js.map +1 -0
  11. package/dist/index.d.ts +4079 -0
  12. package/dist/index.js +11770 -0
  13. package/dist/index.js.map +1 -0
  14. package/package.json +87 -0
  15. package/src/_audio-player-media-chrome.tsx +146 -0
  16. package/src/_chat-shell-rail.tsx +18 -0
  17. package/src/_code-block-theme.test.ts +134 -0
  18. package/src/_code-block-theme.ts +184 -0
  19. package/src/_flow-boundary.tsx +237 -0
  20. package/src/_flow-lazy.ts +34 -0
  21. package/src/_interactive-terminal-xterm.ts +32 -0
  22. package/src/_lazy-mermaid.test.ts +101 -0
  23. package/src/_lazy-mermaid.ts +117 -0
  24. package/src/_persona-rive.tsx +252 -0
  25. package/src/_streamdown-i18n.ts +119 -0
  26. package/src/agent-timeline.stories.tsx +66 -0
  27. package/src/agent-timeline.test.tsx +74 -0
  28. package/src/agent-timeline.tsx +106 -0
  29. package/src/agent.tsx +113 -0
  30. package/src/agentic-workspace.stories.tsx +1099 -0
  31. package/src/artifact.stories.tsx +62 -0
  32. package/src/artifact.test.tsx +112 -0
  33. package/src/artifact.tsx +167 -0
  34. package/src/asset-preview.stories.tsx +100 -0
  35. package/src/asset-preview.test.tsx +126 -0
  36. package/src/asset-preview.tsx +218 -0
  37. package/src/attachments.tsx +393 -0
  38. package/src/audio-player.stories.tsx +99 -0
  39. package/src/audio-player.tsx +170 -0
  40. package/src/blocks-ai-composer.stories.tsx +83 -0
  41. package/src/canvas.stories.tsx +142 -0
  42. package/src/canvas.tsx +29 -0
  43. package/src/chain-of-thought.tsx +193 -0
  44. package/src/chat-greeting.stories.tsx +96 -0
  45. package/src/chat-greeting.test.tsx +91 -0
  46. package/src/chat-greeting.tsx +91 -0
  47. package/src/chat-shell.stories.tsx +123 -0
  48. package/src/chat-shell.test.tsx +113 -0
  49. package/src/chat-shell.tsx +97 -0
  50. package/src/chat.stories.tsx +99 -0
  51. package/src/checkpoint.tsx +54 -0
  52. package/src/code-block.stories.tsx +81 -0
  53. package/src/code-block.test.tsx +191 -0
  54. package/src/code-block.tsx +673 -0
  55. package/src/commit.tsx +343 -0
  56. package/src/composer.stories.tsx +188 -0
  57. package/src/composer.test.tsx +182 -0
  58. package/src/composer.tsx +202 -0
  59. package/src/confirmation.stories.tsx +61 -0
  60. package/src/confirmation.test.tsx +136 -0
  61. package/src/confirmation.tsx +273 -0
  62. package/src/connection.tsx +23 -0
  63. package/src/context-panel.stories.tsx +137 -0
  64. package/src/context-panel.test.tsx +198 -0
  65. package/src/context-panel.tsx +609 -0
  66. package/src/context.stories.tsx +16 -0
  67. package/src/context.tsx +363 -0
  68. package/src/controls.tsx +20 -0
  69. package/src/conversation.stories.tsx +26 -0
  70. package/src/conversation.test.tsx +123 -0
  71. package/src/conversation.tsx +143 -0
  72. package/src/dark-theme-variant.test.ts +47 -0
  73. package/src/edge.tsx +33 -0
  74. package/src/environment-variables.tsx +304 -0
  75. package/src/file-tree.stories.tsx +55 -0
  76. package/src/file-tree.test.tsx +101 -0
  77. package/src/file-tree.tsx +390 -0
  78. package/src/gallery.stories.tsx +182 -0
  79. package/src/gallery.test.tsx +150 -0
  80. package/src/gallery.tsx +737 -0
  81. package/src/grouped-parts.stories.tsx +104 -0
  82. package/src/grouped-parts.test.tsx +167 -0
  83. package/src/grouped-parts.tsx +247 -0
  84. package/src/image.stories.tsx +67 -0
  85. package/src/image.test.tsx +105 -0
  86. package/src/image.tsx +115 -0
  87. package/src/index.ts +93 -0
  88. package/src/inline-citation.stories.tsx +91 -0
  89. package/src/inline-citation.test.tsx +96 -0
  90. package/src/inline-citation.tsx +314 -0
  91. package/src/interactive-terminal.stories.tsx +165 -0
  92. package/src/interactive-terminal.test.tsx +448 -0
  93. package/src/interactive-terminal.tsx +444 -0
  94. package/src/jsx-preview.stories.tsx +265 -0
  95. package/src/jsx-preview.test.tsx +75 -0
  96. package/src/jsx-preview.tsx +409 -0
  97. package/src/markdown-view.stories.tsx +57 -0
  98. package/src/markdown-view.test.tsx +36 -0
  99. package/src/markdown-view.tsx +113 -0
  100. package/src/message-edit.stories.tsx +150 -0
  101. package/src/message-edit.test.tsx +115 -0
  102. package/src/message-edit.tsx +336 -0
  103. package/src/message-feedback.stories.tsx +53 -0
  104. package/src/message-feedback.test.tsx +62 -0
  105. package/src/message-feedback.tsx +117 -0
  106. package/src/message-form-spec.ts +349 -0
  107. package/src/message-form.stories.tsx +182 -0
  108. package/src/message-form.test.tsx +227 -0
  109. package/src/message-form.tsx +845 -0
  110. package/src/message-table-spec.ts +281 -0
  111. package/src/message-table.stories.tsx +176 -0
  112. package/src/message-table.test.tsx +144 -0
  113. package/src/message-table.tsx +366 -0
  114. package/src/message.stories.tsx +262 -0
  115. package/src/message.test.tsx +508 -0
  116. package/src/message.tsx +619 -0
  117. package/src/mic-selector.tsx +339 -0
  118. package/src/microcopy.test.tsx +123 -0
  119. package/src/model-selector.stories.tsx +73 -0
  120. package/src/model-selector.test.tsx +55 -0
  121. package/src/model-selector.tsx +225 -0
  122. package/src/motion-config.tsx +49 -0
  123. package/src/node.tsx +71 -0
  124. package/src/open-in-chat.tsx +340 -0
  125. package/src/package-info.tsx +205 -0
  126. package/src/panel.tsx +20 -0
  127. package/src/part-groups.ts +258 -0
  128. package/src/persona-sources.ts +59 -0
  129. package/src/persona.stories.tsx +111 -0
  130. package/src/persona.test.tsx +67 -0
  131. package/src/persona.tsx +148 -0
  132. package/src/plan.tsx +135 -0
  133. package/src/prompt-input.stories.tsx +261 -0
  134. package/src/prompt-input.test.tsx +392 -0
  135. package/src/prompt-input.tsx +1668 -0
  136. package/src/queue.tsx +237 -0
  137. package/src/reasoning.stories.tsx +81 -0
  138. package/src/reasoning.test.tsx +50 -0
  139. package/src/reasoning.tsx +229 -0
  140. package/src/sandbox.stories.tsx +62 -0
  141. package/src/sandbox.test.tsx +42 -0
  142. package/src/sandbox.tsx +149 -0
  143. package/src/schema-display.tsx +412 -0
  144. package/src/selection-toolbar.stories.tsx +86 -0
  145. package/src/selection-toolbar.test.tsx +89 -0
  146. package/src/selection-toolbar.tsx +192 -0
  147. package/src/shimmer.stories.tsx +10 -0
  148. package/src/shimmer.tsx +81 -0
  149. package/src/snippet.stories.tsx +19 -0
  150. package/src/snippet.test.tsx +116 -0
  151. package/src/snippet.tsx +138 -0
  152. package/src/sources.stories.tsx +34 -0
  153. package/src/sources.test.tsx +129 -0
  154. package/src/sources.tsx +93 -0
  155. package/src/speech-input.tsx +312 -0
  156. package/src/stack-trace.tsx +479 -0
  157. package/src/streamdown-i18n.test.tsx +163 -0
  158. package/src/suggestion.stories.tsx +75 -0
  159. package/src/suggestion.test.tsx +72 -0
  160. package/src/suggestion.tsx +120 -0
  161. package/src/task.stories.tsx +22 -0
  162. package/src/task.test.tsx +36 -0
  163. package/src/task.tsx +89 -0
  164. package/src/templates-ai-assistant.stories.tsx +142 -0
  165. package/src/terminal.tsx +244 -0
  166. package/src/test-results.stories.tsx +177 -0
  167. package/src/test-results.test.tsx +241 -0
  168. package/src/test-results.tsx +408 -0
  169. package/src/tool-result-card.stories.tsx +143 -0
  170. package/src/tool-result-card.test.tsx +72 -0
  171. package/src/tool-result-card.tsx +86 -0
  172. package/src/tool.stories.tsx +60 -0
  173. package/src/tool.test.tsx +63 -0
  174. package/src/tool.tsx +235 -0
  175. package/src/toolbar.tsx +44 -0
  176. package/src/transcription.tsx +118 -0
  177. package/src/voice-selector.tsx +469 -0
  178. package/src/web-preview.stories.tsx +67 -0
  179. package/src/web-preview.test.tsx +117 -0
  180. package/src/web-preview.tsx +283 -0
@@ -0,0 +1,366 @@
1
+ "use client";
2
+
3
+ /**
4
+ * MessageTable — a model-emittable data table rendered inside a chat message.
5
+ *
6
+ * The model emits a serializable, column-oriented `TableSpec` (see
7
+ * `message-table-spec.ts`); MessageTable renders a lightweight table (NOT the
8
+ * app-chrome `@elabs-ai/components-data` DataTable — this is message content).
9
+ *
10
+ * Design bar (mirrors AutoChart):
11
+ * - Spec-driven, zod-validated; the model never chooses the look.
12
+ * - Never throws. A malformed spec → `MessageTableFallback`; an unknown format
13
+ * falls back to text; a missing cell renders an em-dash.
14
+ * - Streaming-tolerant. Half-arrived columns/rows are dropped or render
15
+ * progressively; a spec with columns but no rows yet shows skeleton rows.
16
+ * - Tokens only; numeric columns are end-aligned + `tabular-nums`.
17
+ * - Optional client-side sort (controlled OR uncontrolled) and an optional
18
+ * per-cell `renderCell` override for consumer composition.
19
+ *
20
+ * Composes the `@elabs-ai/components-ui` `Table` primitive.
21
+ */
22
+
23
+ import { forwardRef, useId, useMemo, useState, type HTMLAttributes, type ReactNode } from "react";
24
+ import { ChevronDown, ChevronUp, ChevronsUpDown } from "lucide-react";
25
+ import {
26
+ Badge,
27
+ Skeleton,
28
+ Table,
29
+ TableBody,
30
+ TableCaption,
31
+ TableCell,
32
+ TableHead,
33
+ TableHeader,
34
+ TableRow,
35
+ } from "@elabs-ai/components-ui";
36
+ import { cn } from "@elabs-ai/components-ui/lib/cn";
37
+ import { useLocale } from "@elabs-ai/components-ui";
38
+
39
+ import {
40
+ badgeToneForValue,
41
+ compareCellValues,
42
+ EMPTY_CELL,
43
+ formatCellValue,
44
+ isEmptyCell,
45
+ isNumericFormat,
46
+ normalizeTableSpec,
47
+ type ColumnSpec,
48
+ type TableRowData,
49
+ type TableSpec,
50
+ } from "./message-table-spec";
51
+
52
+ // ─── Sort ─────────────────────────────────────────────────────────────────────
53
+
54
+ export interface TableSort {
55
+ /** The sorted column's key. */
56
+ key: string;
57
+ direction: "asc" | "desc";
58
+ }
59
+
60
+ /** Per-cell render override — return `undefined` to fall back to default rendering. */
61
+ export type RenderCell = (args: {
62
+ value: unknown;
63
+ column: ColumnSpec;
64
+ row: TableRowData;
65
+ rowIndex: number;
66
+ }) => ReactNode;
67
+
68
+ // ─── Fallback ─────────────────────────────────────────────────────────────────
69
+
70
+ export interface MessageTableFallbackProps extends HTMLAttributes<HTMLDivElement> {
71
+ /** Short human reason the table could not render. */
72
+ message?: string;
73
+ }
74
+
75
+ /** Shown when the spec is unusable. Mirrors `ChartFallback` semantics. */
76
+ export const MessageTableFallback = forwardRef<HTMLDivElement, MessageTableFallbackProps>(
77
+ function MessageTableFallback(
78
+ { message = "This table could not be displayed.", className, ...props },
79
+ ref,
80
+ ) {
81
+ return (
82
+ <div
83
+ ref={ref}
84
+ role="status"
85
+ aria-live="polite"
86
+ className={cn(
87
+ // Sole structural cue is the border (no fill) — reads in every theme,
88
+ // incl. themes where surface-muted ≈ background. border-strong is
89
+ // contrast-guaranteed vs the background.
90
+ "flex items-center justify-center rounded-md border border-border-strong px-4 py-6 text-center text-body text-muted-foreground",
91
+ className,
92
+ )}
93
+ {...props}
94
+ >
95
+ {message}
96
+ </div>
97
+ );
98
+ },
99
+ );
100
+
101
+ // ─── MessageTable ─────────────────────────────────────────────────────────────
102
+
103
+ export interface MessageTableProps extends Omit<HTMLAttributes<HTMLDivElement>, "title"> {
104
+ /** The serializable table specification produced by an LLM tool-call. */
105
+ spec: TableSpec | unknown;
106
+ /** Enable click-to-sort column headers. @default false */
107
+ sortable?: boolean;
108
+ /** Controlled sort. When provided, `onSortChange` is the only way to update it. */
109
+ sort?: TableSort | null;
110
+ /** Uncontrolled initial sort. */
111
+ defaultSort?: TableSort | null;
112
+ /** Called with the next sort (or `null`) when a header is clicked. */
113
+ onSortChange?: (sort: TableSort | null) => void;
114
+ /** Cap the number of rendered rows; shows a "Showing N of M rows" notice. */
115
+ maxRows?: number;
116
+ /** Per-cell render override for custom composition. */
117
+ renderCell?: RenderCell;
118
+ /** The spec is still streaming (columns-but-no-rows shows skeleton rows). */
119
+ streaming?: boolean;
120
+ }
121
+
122
+ /**
123
+ * MessageTable — renders a `TableSpec` as a lightweight message-body table.
124
+ *
125
+ * Intentionally bare: no toolbar, no expand chrome. Growing one here would be
126
+ * `ChartFrame` re-implemented inside this package.
127
+ *
128
+ * **To make it expandable, wrap it in `ExpandDialog`**
129
+ * (`@elabs-ai/components-ui`) — the shared two-pane expand surface,
130
+ * the same one a chart opens. Do NOT hand-roll a lightbox or a full-screen
131
+ * breakout; four of those already existed before the component was shared, and
132
+ * each one taught users a different meaning for the same button.
133
+ *
134
+ * The context pane for a table is its SHAPE, not statistics: row count,
135
+ * "showing N of M", column count, each column's declared `format`. A
136
+ * model-emitted table's numerics may already be formatted strings, so min/max/avg
137
+ * over them is wrong more often than right.
138
+ */
139
+ export const MessageTable = forwardRef<HTMLDivElement, MessageTableProps>(function MessageTable(
140
+ {
141
+ spec,
142
+ sortable = false,
143
+ sort: sortProp,
144
+ defaultSort = null,
145
+ onSortChange,
146
+ maxRows,
147
+ renderCell,
148
+ streaming = false,
149
+ className,
150
+ ...props
151
+ },
152
+ ref,
153
+ ) {
154
+ const { t } = useLocale();
155
+ const reactId = useId();
156
+ const isControlled = sortProp !== undefined;
157
+ const [internalSort, setInternalSort] = useState<TableSort | null>(defaultSort);
158
+ const resolvedSort = isControlled ? (sortProp as TableSort | null) : internalSort;
159
+
160
+ const result = normalizeTableSpec(spec);
161
+
162
+ const normalized = result.ok ? result.spec : null;
163
+
164
+ const sortedRows = useMemo<TableRowData[]>(() => {
165
+ if (!normalized || !resolvedSort) return normalized?.rows ?? [];
166
+ const column = normalized.columns.find((c) => c.key === resolvedSort.key);
167
+ if (!column) return normalized.rows;
168
+ const direction = resolvedSort.direction === "asc" ? 1 : -1;
169
+ return [...normalized.rows].sort(
170
+ (a, b) => direction * compareCellValues(a[column.key], b[column.key], column.format),
171
+ );
172
+ }, [normalized, resolvedSort]);
173
+
174
+ if (!result.ok) {
175
+ return (
176
+ <MessageTableFallback ref={ref} message={result.reason} className={className} {...props} />
177
+ );
178
+ }
179
+
180
+ const { columns, title, emptyText } = result.spec;
181
+
182
+ // No columns: skeleton while streaming, otherwise a fallback.
183
+ if (columns.length === 0) {
184
+ if (streaming) {
185
+ return (
186
+ <div ref={ref} className={cn("w-full", className)} {...props}>
187
+ <span className="sr-only" role="status" aria-live="polite">
188
+ Loading table…
189
+ </span>
190
+ <div
191
+ aria-hidden="true"
192
+ className="flex flex-col gap-2 rounded-md border border-border p-3"
193
+ >
194
+ <Skeleton className="h-5 w-40" />
195
+ <Skeleton className="h-4 w-full" />
196
+ <Skeleton className="h-4 w-full" />
197
+ <Skeleton className="h-4 w-3/4" />
198
+ </div>
199
+ </div>
200
+ );
201
+ }
202
+ return (
203
+ <MessageTableFallback
204
+ ref={ref}
205
+ message="This table has no columns to display."
206
+ className={className}
207
+ {...props}
208
+ />
209
+ );
210
+ }
211
+
212
+ const total = sortedRows.length;
213
+ const cappedRows =
214
+ maxRows !== undefined && maxRows >= 0 ? sortedRows.slice(0, maxRows) : sortedRows;
215
+ const truncatedCount = total - cappedRows.length;
216
+
217
+ const titleId = title ? `${reactId}-title` : undefined;
218
+
219
+ const setSort = (next: TableSort | null) => {
220
+ if (!isControlled) setInternalSort(next);
221
+ onSortChange?.(next);
222
+ };
223
+
224
+ const toggleSort = (column: ColumnSpec) => {
225
+ if (resolvedSort?.key === column.key) {
226
+ setSort({ key: column.key, direction: resolvedSort.direction === "asc" ? "desc" : "asc" });
227
+ } else {
228
+ setSort({ key: column.key, direction: "asc" });
229
+ }
230
+ };
231
+
232
+ const renderCellContent = (
233
+ column: ColumnSpec,
234
+ row: TableRowData,
235
+ rowIndex: number,
236
+ ): ReactNode => {
237
+ const value = row[column.key];
238
+ if (renderCell) {
239
+ const overridden = renderCell({ value, column, row, rowIndex });
240
+ if (overridden !== undefined) return overridden;
241
+ }
242
+ if (isEmptyCell(value)) {
243
+ return <span className="text-muted-foreground">{EMPTY_CELL}</span>;
244
+ }
245
+ if (column.format === "badge") {
246
+ return <Badge variant={badgeToneForValue(value)}>{String(value)}</Badge>;
247
+ }
248
+ return formatCellValue(value, column.format);
249
+ };
250
+
251
+ return (
252
+ <div ref={ref} className={cn("w-full", className)} {...props}>
253
+ {title && (
254
+ <p id={titleId} className="mb-1 text-body font-semibold text-foreground text-balance">
255
+ {title}
256
+ </p>
257
+ )}
258
+ <div className="overflow-hidden rounded-md border border-border">
259
+ {streaming && cappedRows.length === 0 && (
260
+ <span className="sr-only" role="status" aria-live="polite">
261
+ Loading table…
262
+ </span>
263
+ )}
264
+ <Table
265
+ aria-labelledby={titleId}
266
+ aria-label={title ? undefined : t("ai.messageTable.label")}
267
+ aria-busy={(streaming && cappedRows.length === 0) || undefined}
268
+ >
269
+ {truncatedCount > 0 && (
270
+ <TableCaption className="mb-2 mt-3 px-3 text-caption">
271
+ Showing {cappedRows.length} of {total} rows
272
+ </TableCaption>
273
+ )}
274
+ <TableHeader>
275
+ <TableRow>
276
+ {columns.map((column) => {
277
+ const numeric = isNumericFormat(column.format);
278
+ const active = resolvedSort?.key === column.key;
279
+ const ariaSort = active
280
+ ? resolvedSort?.direction === "asc"
281
+ ? "ascending"
282
+ : "descending"
283
+ : sortable
284
+ ? "none"
285
+ : undefined;
286
+ const label = column.label ?? column.key;
287
+ return (
288
+ <TableHead
289
+ key={column.key}
290
+ scope="col"
291
+ aria-sort={ariaSort}
292
+ className={cn(numeric && "text-end")}
293
+ >
294
+ {sortable ? (
295
+ <button
296
+ type="button"
297
+ onClick={() => toggleSort(column)}
298
+ className={cn(
299
+ "inline-flex items-center gap-1 rounded-sm font-medium",
300
+ "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background",
301
+ "hover:text-foreground transition-colors duration-fast ease-standard motion-reduce:transition-none",
302
+ numeric && "flex-row-reverse",
303
+ )}
304
+ >
305
+ <span>{label}</span>
306
+ {active ? (
307
+ resolvedSort?.direction === "asc" ? (
308
+ <ChevronUp aria-hidden="true" className="size-3.5" />
309
+ ) : (
310
+ <ChevronDown aria-hidden="true" className="size-3.5" />
311
+ )
312
+ ) : (
313
+ <ChevronsUpDown aria-hidden="true" className="size-3.5 opacity-50" />
314
+ )}
315
+ </button>
316
+ ) : (
317
+ label
318
+ )}
319
+ </TableHead>
320
+ );
321
+ })}
322
+ </TableRow>
323
+ </TableHeader>
324
+ <TableBody>
325
+ {cappedRows.length === 0 && streaming ? (
326
+ [0, 1, 2].map((i) => (
327
+ <TableRow key={`skeleton-${i}`} aria-hidden="true">
328
+ {columns.map((column) => (
329
+ <TableCell key={column.key}>
330
+ <Skeleton className="h-4 w-full" />
331
+ </TableCell>
332
+ ))}
333
+ </TableRow>
334
+ ))
335
+ ) : cappedRows.length === 0 ? (
336
+ <TableRow>
337
+ <TableCell
338
+ colSpan={columns.length}
339
+ className="py-6 text-center text-muted-foreground"
340
+ >
341
+ {emptyText ?? "No rows to display"}
342
+ </TableCell>
343
+ </TableRow>
344
+ ) : (
345
+ cappedRows.map((row, rowIndex) => (
346
+ <TableRow key={rowIndex}>
347
+ {columns.map((column) => {
348
+ const numeric = isNumericFormat(column.format);
349
+ return (
350
+ <TableCell
351
+ key={column.key}
352
+ className={cn(numeric && "text-end tabular-nums")}
353
+ >
354
+ {renderCellContent(column, row, rowIndex)}
355
+ </TableCell>
356
+ );
357
+ })}
358
+ </TableRow>
359
+ ))
360
+ )}
361
+ </TableBody>
362
+ </Table>
363
+ </div>
364
+ </div>
365
+ );
366
+ });
@@ -0,0 +1,262 @@
1
+ import { useState } from "react";
2
+ import type { Meta, StoryObj } from "@storybook/react-vite";
3
+ import { expect, userEvent } from "storybook/test";
4
+ import {
5
+ Copy as CopyIcon,
6
+ Pencil as PencilIcon,
7
+ Pin as PinIcon,
8
+ RotateCcw as RotateCcwIcon,
9
+ } from "lucide-react";
10
+ import {
11
+ AgentMessage,
12
+ Message,
13
+ MessageAction,
14
+ MessageActions,
15
+ MessageAvatar,
16
+ MessageBranch,
17
+ MessageBranchContent,
18
+ MessageBranchNext,
19
+ MessageBranchPage,
20
+ MessageBranchPrevious,
21
+ MessageBranchSelector,
22
+ MessageContent,
23
+ MessageHeader,
24
+ MessageResponse,
25
+ UserMessage,
26
+ } from "./message";
27
+ import { SourceList } from "./sources";
28
+ const meta = {
29
+ title: "AI/Message",
30
+ component: Message,
31
+ parameters: { layout: "padded" },
32
+ } satisfies Meta<typeof Message>;
33
+ export default meta;
34
+ type Story = StoryObj<typeof meta>;
35
+ export const Conversation_: Story = {
36
+ name: "User + Assistant",
37
+ render: () => (
38
+ <div className="space-y-4">
39
+ <Message from="user">
40
+ <MessageContent>Summarize last week's deploys.</MessageContent>
41
+ </Message>
42
+ <Message from="assistant">
43
+ <MessageContent>Three services shipped; billing is degraded.</MessageContent>
44
+ </Message>
45
+ </div>
46
+ ),
47
+ };
48
+ // The named preset wrappers (#191, research 11 §B.2): UserMessage / AgentMessage
49
+ // with the MessageHeader identity slot.
50
+ export const Presets: Story = {
51
+ name: "UserMessage + AgentMessage",
52
+ render: () => (
53
+ <div className="space-y-4">
54
+ <UserMessage>
55
+ <MessageContent>How did churn develop this quarter?</MessageContent>
56
+ </UserMessage>
57
+ <AgentMessage>
58
+ <MessageHeader>
59
+ <MessageAvatar name="Atlas" role="agent" />
60
+ <span>Atlas</span>
61
+ </MessageHeader>
62
+ <MessageContent>Pulling the retention cohorts now…</MessageContent>
63
+ </AgentMessage>
64
+ </div>
65
+ ),
66
+ };
67
+ // emphasis="answer" = the green left rail on the wrapper; prose + grounding
68
+ // footer are COMPOSED children, not baked-in layout.
69
+ export const FinalAnswer: Story = {
70
+ name: "AgentMessage emphasis=answer",
71
+ render: () => (
72
+ <AgentMessage emphasis="answer">
73
+ <MessageHeader>
74
+ <MessageAvatar name="Atlas" role="agent" />
75
+ <span>Atlas</span>
76
+ </MessageHeader>
77
+ <MessageContent>
78
+ <MessageResponse>{`## Churn improved this quarter
79
+
80
+ Enterprise churn fell **0.4pp** to 1.8%, driven by the renewal-desk rollout:
81
+
82
+ - 12 at-risk accounts recovered
83
+ - \`auto-renew\` adoption up 9pp`}</MessageResponse>
84
+ <SourceList
85
+ sources={[
86
+ { href: "https://warehouse.acme.com/q3-retention", title: "Q3 retention cohort" },
87
+ { href: "https://crm.acme.com/renewals", title: "Renewal desk log" },
88
+ ]}
89
+ />
90
+ </MessageContent>
91
+ </AgentMessage>
92
+ ),
93
+ };
94
+ // LOADING — no tokens have arrived yet (loading-states.md `loading`).
95
+ // `MessageResponse` renders skeleton lines at the body line-height instead of
96
+ // `<Streamdown>`, so the bubble reserves its space before the first token.
97
+ export const Loading: Story = {
98
+ name: "MessageResponse loading",
99
+ render: () => (
100
+ <AgentMessage>
101
+ <MessageHeader>
102
+ <MessageAvatar name="Atlas" role="agent" />
103
+ <span>Atlas</span>
104
+ </MessageHeader>
105
+ <MessageContent>
106
+ <MessageResponse loading />
107
+ </MessageContent>
108
+ </AgentMessage>
109
+ ),
110
+ };
111
+ // Controlled MessageBranch (#361) — an external control ("Jump to reply N")
112
+ // drives the SAME `branch` state that the in-component Previous/Next controls
113
+ // mutate via `onBranchChange`, proving the host can own branch selection
114
+ // (e.g. restore it from a URL param) without remounting the subtree.
115
+ export const BranchControlled: Story = {
116
+ name: "MessageBranch — controlled",
117
+ render: function Render() {
118
+ const [branch, setBranch] = useState(0);
119
+ return (
120
+ <div className="space-y-3">
121
+ <div className="flex gap-2">
122
+ <button
123
+ className="rounded-md border px-2 py-1 text-caption"
124
+ onClick={() => setBranch(0)}
125
+ type="button"
126
+ >
127
+ Jump to reply 1
128
+ </button>
129
+ <button
130
+ className="rounded-md border px-2 py-1 text-caption"
131
+ onClick={() => setBranch(2)}
132
+ type="button"
133
+ >
134
+ Jump to reply 3
135
+ </button>
136
+ </div>
137
+ <MessageBranch branch={branch} onBranchChange={setBranch}>
138
+ <MessageBranchContent>
139
+ <AgentMessage key="concise">
140
+ <MessageContent>Here's a concise answer.</MessageContent>
141
+ </AgentMessage>
142
+ <AgentMessage key="detailed">
143
+ <MessageContent>Here's a longer answer, with more context.</MessageContent>
144
+ </AgentMessage>
145
+ <AgentMessage key="alternate">
146
+ <MessageContent>Here's a third, differently-phrased answer.</MessageContent>
147
+ </AgentMessage>
148
+ </MessageBranchContent>
149
+ <MessageBranchSelector>
150
+ <MessageBranchPrevious />
151
+ <MessageBranchPage />
152
+ <MessageBranchNext />
153
+ </MessageBranchSelector>
154
+ </MessageBranch>
155
+ </div>
156
+ );
157
+ },
158
+ play: async ({ canvas }) => {
159
+ await expect(canvas.getByText("1 of 3")).toBeInTheDocument();
160
+
161
+ await userEvent.click(canvas.getByRole("button", { name: "Jump to reply 3" }));
162
+ await expect(canvas.getByText("3 of 3")).toBeInTheDocument();
163
+
164
+ // The component's own Previous control still drives the SAME external
165
+ // state — it doesn't fall back to internal tracking in controlled mode.
166
+ await userEvent.click(canvas.getByRole("button", { name: "Previous branch" }));
167
+ await expect(canvas.getByText("2 of 3")).toBeInTheDocument();
168
+ },
169
+ };
170
+ // ACTIONS, always visible (`appearance="plain" reveal="always"` — the defaults,
171
+ // set explicitly here so both default values are exercised in a RENDERED
172
+ // position, not only in the controls panel).
173
+ export const ActionsPlain: Story = {
174
+ name: "MessageActions — inline",
175
+ render: () => (
176
+ <AgentMessage>
177
+ <MessageContent>Three services shipped; billing is degraded.</MessageContent>
178
+ <MessageActions appearance="plain" reveal="always">
179
+ <MessageAction onClick={() => {}} tooltip="Copy">
180
+ <CopyIcon aria-hidden="true" size={14} />
181
+ </MessageAction>
182
+ <MessageAction onClick={() => {}} tooltip="Pin">
183
+ <PinIcon aria-hidden="true" size={14} />
184
+ </MessageAction>
185
+ </MessageActions>
186
+ </AgentMessage>
187
+ ),
188
+ };
189
+ // ACTIONS, hover-revealed floating pill — the chat-client shape. The row is
190
+ // `opacity-0` at rest on a FINE pointer only and fades in from the parent
191
+ // `Message`'s hover; on a coarse (touch) pointer it never hides, since nothing
192
+ // there could reveal it.
193
+ //
194
+ // The controls stay in the tab order the whole time — `focus-within` is what
195
+ // brings the pill back for a keyboard user, which is why the row is NOT
196
+ // `hidden`/`invisible`/unmounted at rest.
197
+ export const ActionsHoverBar: Story = {
198
+ name: "MessageActions — hover pill",
199
+ render: function Render() {
200
+ const [pinned, setPinned] = useState(false);
201
+ return (
202
+ <div className="space-y-4">
203
+ <AgentMessage>
204
+ <MessageHeader>
205
+ <MessageAvatar name="Atlas" role="agent" />
206
+ <span>Atlas</span>
207
+ </MessageHeader>
208
+ <MessageContent>
209
+ Retention held at 94% — the dip you saw was a reporting lag, not churn.
210
+ </MessageContent>
211
+ <MessageActions appearance="bar" reveal="hover">
212
+ <MessageAction onClick={() => {}} tooltip="Copy">
213
+ <CopyIcon aria-hidden="true" size={14} />
214
+ </MessageAction>
215
+ {/* Pin is a TOGGLE the host owns — the component ships no pin state.
216
+ `aria-pressed` + the filled glyph are the two channels (a11y.md:
217
+ colour is never the only one). */}
218
+ <MessageAction
219
+ aria-pressed={pinned}
220
+ onClick={() => setPinned((v) => !v)}
221
+ tooltip={pinned ? "Unpin" : "Pin"}
222
+ >
223
+ <PinIcon
224
+ aria-hidden="true"
225
+ className={pinned ? "fill-current" : undefined}
226
+ size={14}
227
+ />
228
+ </MessageAction>
229
+ <MessageAction onClick={() => {}} tooltip="Retry">
230
+ <RotateCcwIcon aria-hidden="true" size={14} />
231
+ </MessageAction>
232
+ </MessageActions>
233
+ </AgentMessage>
234
+ <UserMessage>
235
+ <MessageContent>Can you break that down by region?</MessageContent>
236
+ <MessageActions appearance="bar" reveal="hover">
237
+ <MessageAction onClick={() => {}} tooltip="Copy">
238
+ <CopyIcon aria-hidden="true" size={14} />
239
+ </MessageAction>
240
+ <MessageAction onClick={() => {}} tooltip="Edit">
241
+ <PencilIcon aria-hidden="true" size={14} />
242
+ </MessageAction>
243
+ </MessageActions>
244
+ </UserMessage>
245
+ </div>
246
+ );
247
+ },
248
+ play: async ({ canvas }) => {
249
+ // Hover-revealed ≠ removed: the controls are real, named, reachable buttons
250
+ // at rest, which is the whole reason the row fades rather than unmounts.
251
+ const groups = canvas.getAllByRole("group", { name: "Message actions" });
252
+ await expect(groups).toHaveLength(2);
253
+
254
+ const pin = canvas.getByRole("button", { name: "Pin" });
255
+ await expect(pin).toHaveAttribute("aria-pressed", "false");
256
+ await userEvent.click(pin);
257
+ await expect(canvas.getByRole("button", { name: "Unpin" })).toHaveAttribute(
258
+ "aria-pressed",
259
+ "true",
260
+ );
261
+ },
262
+ };