@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,408 @@
1
+ "use client";
2
+
3
+ import { StatusBadge, StatusIcon, type Status } from "@elabs-ai/components-ui";
4
+ import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@elabs-ai/components-ui";
5
+ import { cn } from "@elabs-ai/components-ui/lib/cn";
6
+ import { ChevronRightIcon } from "lucide-react";
7
+ import type { ComponentProps, HTMLAttributes } from "react";
8
+ import { createContext, useContext, useMemo } from "react";
9
+
10
+ type TestStatus = "passed" | "failed" | "skipped" | "running";
11
+
12
+ /** Test status → the canonical 7-state vocabulary (#189, research 10 §B.1). */
13
+ const testStatusToCanonical: Record<TestStatus, Status> = {
14
+ passed: "complete",
15
+ failed: "failed",
16
+ running: "running",
17
+ skipped: "skipped",
18
+ };
19
+
20
+ interface TestResultsSummary {
21
+ passed: number;
22
+ failed: number;
23
+ skipped: number;
24
+ total: number;
25
+ duration?: number;
26
+ }
27
+
28
+ interface TestResultsContextType {
29
+ summary?: TestResultsSummary;
30
+ }
31
+
32
+ const TestResultsContext = createContext<TestResultsContextType>({});
33
+
34
+ const formatDuration = (ms: number) => {
35
+ if (ms < 1000) {
36
+ return `${ms}ms`;
37
+ }
38
+ return `${(ms / 1000).toFixed(2)}s`;
39
+ };
40
+
41
+ export type TestResultsHeaderProps = HTMLAttributes<HTMLDivElement>;
42
+
43
+ export const TestResultsHeader = ({ className, children, ...props }: TestResultsHeaderProps) => (
44
+ <div
45
+ className={cn("flex min-w-0 items-center justify-between border-b px-4 py-3", className)}
46
+ {...props}
47
+ >
48
+ {children}
49
+ </div>
50
+ );
51
+
52
+ export type TestResultsDurationProps = HTMLAttributes<HTMLSpanElement>;
53
+
54
+ export const TestResultsDuration = ({
55
+ className,
56
+ children,
57
+ ...props
58
+ }: TestResultsDurationProps) => {
59
+ const { summary } = useContext(TestResultsContext);
60
+
61
+ if (!summary?.duration) {
62
+ return null;
63
+ }
64
+
65
+ return (
66
+ <span
67
+ className={cn("shrink-0 text-muted-foreground text-sm tabular-nums", className)}
68
+ {...props}
69
+ >
70
+ {children ?? formatDuration(summary.duration)}
71
+ </span>
72
+ );
73
+ };
74
+
75
+ export type TestResultsSummaryProps = HTMLAttributes<HTMLDivElement>;
76
+
77
+ export const TestResultsSummary = ({ className, children, ...props }: TestResultsSummaryProps) => {
78
+ const { summary } = useContext(TestResultsContext);
79
+
80
+ if (!summary) {
81
+ return null;
82
+ }
83
+
84
+ return (
85
+ <div className={cn("flex items-center gap-2", className)} {...props}>
86
+ {children ?? (
87
+ <>
88
+ <StatusBadge size="sm" status="complete">
89
+ {summary.passed} passed
90
+ </StatusBadge>
91
+ {summary.failed > 0 && (
92
+ <StatusBadge size="sm" status="failed">
93
+ {summary.failed} failed
94
+ </StatusBadge>
95
+ )}
96
+ {summary.skipped > 0 && (
97
+ <StatusBadge size="sm" status="skipped">
98
+ {summary.skipped} skipped
99
+ </StatusBadge>
100
+ )}
101
+ </>
102
+ )}
103
+ </div>
104
+ );
105
+ };
106
+
107
+ export type TestResultsProps = HTMLAttributes<HTMLDivElement> & {
108
+ summary?: TestResultsSummary;
109
+ };
110
+
111
+ export const TestResults = ({ summary, className, children, ...props }: TestResultsProps) => {
112
+ const contextValue = useMemo(() => ({ summary }), [summary]);
113
+
114
+ return (
115
+ <TestResultsContext.Provider value={contextValue}>
116
+ <div className={cn("overflow-hidden rounded-lg border bg-background", className)} {...props}>
117
+ {children ??
118
+ (summary && (
119
+ <TestResultsHeader>
120
+ <TestResultsSummary />
121
+ <TestResultsDuration />
122
+ </TestResultsHeader>
123
+ ))}
124
+ </div>
125
+ </TestResultsContext.Provider>
126
+ );
127
+ };
128
+
129
+ export type TestResultsProgressProps = HTMLAttributes<HTMLDivElement>;
130
+
131
+ export const TestResultsProgress = ({
132
+ className,
133
+ children,
134
+ ...props
135
+ }: TestResultsProgressProps) => {
136
+ const { summary } = useContext(TestResultsContext);
137
+
138
+ if (!summary) {
139
+ return null;
140
+ }
141
+
142
+ const passedPercent = (summary.passed / summary.total) * 100;
143
+ const failedPercent = (summary.failed / summary.total) * 100;
144
+
145
+ return (
146
+ <div className={cn("space-y-1.5", className)} {...props}>
147
+ {children ?? (
148
+ <>
149
+ <div className="flex h-1.5 overflow-hidden rounded-full bg-muted">
150
+ <div
151
+ className="bg-success transition-[width] duration-base ease-standard motion-reduce:transition-none"
152
+ style={{ width: `${passedPercent}%` }}
153
+ />
154
+ <div
155
+ className="bg-destructive transition-[width] duration-base ease-standard motion-reduce:transition-none"
156
+ style={{ width: `${failedPercent}%` }}
157
+ />
158
+ </div>
159
+ <div className="flex justify-between text-muted-foreground text-xs tabular-nums">
160
+ <span>
161
+ {summary.passed}/{summary.total} tests passed
162
+ </span>
163
+ <span>{passedPercent.toFixed(0)}%</span>
164
+ </div>
165
+ </>
166
+ )}
167
+ </div>
168
+ );
169
+ };
170
+
171
+ /**
172
+ * TestResultsMeta — the summary badges + progress bar block that sits between
173
+ * the header and the suite list. Provides consistent `px-4 pt-3 pb-4` padding
174
+ * so callers do not need ad-hoc wrapper divs.
175
+ */
176
+ export type TestResultsMetaProps = HTMLAttributes<HTMLDivElement>;
177
+
178
+ export const TestResultsMeta = ({ className, children, ...props }: TestResultsMetaProps) => (
179
+ <div className={cn("space-y-3 px-4 pb-4 pt-3", className)} {...props}>
180
+ {children}
181
+ </div>
182
+ );
183
+
184
+ /**
185
+ * TestResultsContent — the suite list. Uses `divide-y` instead of `space-y`
186
+ * so suites are separated by a single structural divider, not nested boxes.
187
+ */
188
+ export type TestResultsContentProps = HTMLAttributes<HTMLDivElement>;
189
+
190
+ export const TestResultsContent = ({ className, children, ...props }: TestResultsContentProps) => (
191
+ <div className={cn("divide-y", className)} {...props}>
192
+ {children}
193
+ </div>
194
+ );
195
+
196
+ interface TestSuiteContextType {
197
+ name: string;
198
+ status: TestStatus;
199
+ }
200
+
201
+ const TestSuiteContext = createContext<TestSuiteContextType>({
202
+ name: "",
203
+ status: "passed",
204
+ });
205
+
206
+ // The local status icon/color records were the 5th status idiom (#189);
207
+ // re-pointed to the canonical `StatusIcon` vocabulary from @elabs-ai/components-ui.
208
+ const TestStatusIcon = ({ status }: { status: TestStatus }) => (
209
+ <StatusIcon className="shrink-0" status={testStatusToCanonical[status]} />
210
+ );
211
+
212
+ export type TestSuiteProps = ComponentProps<typeof Collapsible> & {
213
+ name: string;
214
+ status: TestStatus;
215
+ };
216
+
217
+ /**
218
+ * TestSuite — one expandable file/suite row. Uses a quiet left rail
219
+ * (`border-s-2`) as the sole focal separation gesture inside the divider-
220
+ * separated list. No nested rounded box: that was redundant border-on-region
221
+ * with no fill change (separation grammar).
222
+ */
223
+ export const TestSuite = ({ name, status, className, children, ...props }: TestSuiteProps) => {
224
+ const contextValue = useMemo(() => ({ name, status }), [name, status]);
225
+
226
+ return (
227
+ <TestSuiteContext.Provider value={contextValue}>
228
+ <Collapsible className={cn("group/suite", className)} {...props}>
229
+ {children}
230
+ </Collapsible>
231
+ </TestSuiteContext.Provider>
232
+ );
233
+ };
234
+
235
+ export type TestSuiteNameProps = ComponentProps<typeof CollapsibleTrigger>;
236
+
237
+ export const TestSuiteName = ({ className, children, ...props }: TestSuiteNameProps) => {
238
+ const { name, status } = useContext(TestSuiteContext);
239
+
240
+ return (
241
+ <CollapsibleTrigger
242
+ className={cn(
243
+ "group flex w-full items-center gap-2 px-4 py-2.5 text-start transition-colors duration-fast ease-standard motion-reduce:transition-none hover:bg-muted/50",
244
+ className,
245
+ )}
246
+ {...props}
247
+ >
248
+ <ChevronRightIcon className="size-4 shrink-0 text-muted-foreground transition-transform duration-fast ease-standard motion-reduce:transition-none group-data-[state=open]:rotate-90" />
249
+ <TestStatusIcon status={status} />
250
+ <span className="min-w-0 flex-1 truncate font-medium text-sm">{children ?? name}</span>
251
+ </CollapsibleTrigger>
252
+ );
253
+ };
254
+
255
+ export type TestSuiteStatsProps = HTMLAttributes<HTMLDivElement> & {
256
+ passed?: number;
257
+ failed?: number;
258
+ skipped?: number;
259
+ };
260
+
261
+ export const TestSuiteStats = ({
262
+ passed = 0,
263
+ failed = 0,
264
+ skipped = 0,
265
+ className,
266
+ children,
267
+ ...props
268
+ }: TestSuiteStatsProps) => (
269
+ <div
270
+ className={cn("ms-auto flex shrink-0 items-center gap-3 text-xs tabular-nums", className)}
271
+ {...props}
272
+ >
273
+ {children ?? (
274
+ <>
275
+ {passed > 0 && <span className="text-success-text">{passed} passed</span>}
276
+ {failed > 0 && <span className="text-destructive-text">{failed} failed</span>}
277
+ {skipped > 0 && <span className="text-muted-foreground">{skipped} skipped</span>}
278
+ </>
279
+ )}
280
+ </div>
281
+ );
282
+
283
+ export type TestSuiteContentProps = ComponentProps<typeof CollapsibleContent>;
284
+
285
+ /**
286
+ * TestSuiteContent — the rows inside an expanded suite. A left rail
287
+ * (`border-s-2 border-s-border ms-6`) indents the block under the suite
288
+ * trigger and separates it structurally without a box. Rows are divided by
289
+ * `divide-y`.
290
+ */
291
+ export const TestSuiteContent = ({ className, children, ...props }: TestSuiteContentProps) => (
292
+ <CollapsibleContent className={cn("border-t", className)} {...props}>
293
+ <div className="divide-y">{children}</div>
294
+ </CollapsibleContent>
295
+ );
296
+
297
+ interface TestContextType {
298
+ name: string;
299
+ status: TestStatus;
300
+ duration?: number;
301
+ }
302
+
303
+ const TestContext = createContext<TestContextType>({
304
+ name: "",
305
+ status: "passed",
306
+ });
307
+
308
+ export type TestNameProps = HTMLAttributes<HTMLSpanElement>;
309
+
310
+ export const TestName = ({ className, children, ...props }: TestNameProps) => {
311
+ const { name } = useContext(TestContext);
312
+
313
+ return (
314
+ <span className={cn("min-w-0 flex-1 truncate", className)} {...props}>
315
+ {children ?? name}
316
+ </span>
317
+ );
318
+ };
319
+
320
+ export type TestDurationProps = HTMLAttributes<HTMLSpanElement>;
321
+
322
+ export const TestDuration = ({ className, children, ...props }: TestDurationProps) => {
323
+ const { duration } = useContext(TestContext);
324
+
325
+ if (duration === undefined) {
326
+ return null;
327
+ }
328
+
329
+ return (
330
+ <span
331
+ className={cn("shrink-0 text-muted-foreground text-xs tabular-nums", className)}
332
+ {...props}
333
+ >
334
+ {children ?? `${duration}ms`}
335
+ </span>
336
+ );
337
+ };
338
+
339
+ export type TestStatusProps = HTMLAttributes<HTMLSpanElement>;
340
+
341
+ export const TestStatus = ({ className, children, ...props }: TestStatusProps) => {
342
+ const { status } = useContext(TestContext);
343
+
344
+ return (
345
+ <span className={cn("shrink-0", className)} {...props}>
346
+ {children ?? <StatusIcon status={testStatusToCanonical[status]} />}
347
+ </span>
348
+ );
349
+ };
350
+
351
+ export type TestProps = HTMLAttributes<HTMLDivElement> & {
352
+ name: string;
353
+ status: TestStatus;
354
+ duration?: number;
355
+ };
356
+
357
+ /**
358
+ * Test — a single test-case row. `ps-10` aligns the status icon with the
359
+ * suite's status icon (after the trigger's chevron + gap), giving a clean
360
+ * left-column across the expansion.
361
+ */
362
+ export const Test = ({ name, status, duration, className, children, ...props }: TestProps) => {
363
+ const contextValue = useMemo(() => ({ duration, name, status }), [duration, name, status]);
364
+
365
+ return (
366
+ <TestContext.Provider value={contextValue}>
367
+ <div
368
+ className={cn("flex min-w-0 items-center gap-2 ps-10 pe-4 py-2 text-sm", className)}
369
+ {...props}
370
+ >
371
+ {children ?? (
372
+ <>
373
+ <TestStatus />
374
+ <TestName />
375
+ {duration !== undefined && <TestDuration />}
376
+ </>
377
+ )}
378
+ </div>
379
+ </TestContext.Provider>
380
+ );
381
+ };
382
+
383
+ export type TestErrorProps = HTMLAttributes<HTMLDivElement>;
384
+
385
+ export const TestError = ({ className, children, ...props }: TestErrorProps) => (
386
+ <div className={cn("rounded-md bg-destructive/10 p-3", className)} {...props}>
387
+ {children}
388
+ </div>
389
+ );
390
+
391
+ export type TestErrorMessageProps = HTMLAttributes<HTMLParagraphElement>;
392
+
393
+ export const TestErrorMessage = ({ className, children, ...props }: TestErrorMessageProps) => (
394
+ <p className={cn("font-medium text-destructive-text text-sm", className)} {...props}>
395
+ {children}
396
+ </p>
397
+ );
398
+
399
+ export type TestErrorStackProps = HTMLAttributes<HTMLPreElement>;
400
+
401
+ export const TestErrorStack = ({ className, children, ...props }: TestErrorStackProps) => (
402
+ <pre
403
+ className={cn("mt-2 overflow-auto font-mono text-destructive-text text-xs", className)}
404
+ {...props}
405
+ >
406
+ {children}
407
+ </pre>
408
+ );
@@ -0,0 +1,143 @@
1
+ import type { Meta, StoryObj } from "@storybook/react-vite";
2
+ import { Maximize2 } from "lucide-react";
3
+ import { Button, Dialog, DialogTrigger, ExpandDialog } from "@elabs-ai/components-ui";
4
+ import { MessageTable } from "./message-table";
5
+ import type { TableSpec } from "./message-table-spec";
6
+ import { ToolDetails, ToolInput } from "./tool";
7
+ import { ToolResultCard } from "./tool-result-card";
8
+
9
+ const meta = {
10
+ title: "AI/ToolResultCard",
11
+ component: ToolResultCard,
12
+ parameters: { layout: "padded" },
13
+ tags: ["autodocs"],
14
+ } satisfies Meta<typeof ToolResultCard>;
15
+ export default meta;
16
+ type Story = StoryObj<typeof meta>;
17
+
18
+ // The elevation channel (#192, research 10 §B.4): the produced artifact IS
19
+ // the headline. The host is presentational — pass any node (an AutoChart,
20
+ // DataTable, file preview, …) as children; here a plain token-styled table.
21
+ export const Default: Story = {
22
+ render: () => (
23
+ <ToolResultCard
24
+ className="max-w-prose"
25
+ title="Revenue by region — Q3 vs Q2"
26
+ summary="Total $48.2M · +12.4% QoQ"
27
+ status="complete"
28
+ details={
29
+ <ToolDetails>
30
+ <ToolInput input={{ chart: "bar", x: "region", series: ["q2", "q3"] }} />
31
+ </ToolDetails>
32
+ }
33
+ >
34
+ <table className="w-full text-body tabular-nums">
35
+ <thead>
36
+ <tr className="border-b border-border-strong text-start text-meta text-muted-foreground">
37
+ <th className="py-1.5 text-start">Region</th>
38
+ <th className="py-1.5 text-end">Q2</th>
39
+ <th className="py-1.5 text-end">Q3</th>
40
+ </tr>
41
+ </thead>
42
+ <tbody>
43
+ <tr>
44
+ <td className="py-1.5">EMEA</td>
45
+ <td className="py-1.5 text-end">$16.1M</td>
46
+ <td className="py-1.5 text-end">$18.4M</td>
47
+ </tr>
48
+ <tr>
49
+ <td className="py-1.5">Americas</td>
50
+ <td className="py-1.5 text-end">$19.6M</td>
51
+ <td className="py-1.5 text-end">$21.2M</td>
52
+ </tr>
53
+ <tr>
54
+ <td className="py-1.5">APAC</td>
55
+ <td className="py-1.5 text-end">$7.2M</td>
56
+ <td className="py-1.5 text-end">$8.6M</td>
57
+ </tr>
58
+ </tbody>
59
+ </table>
60
+ </ToolResultCard>
61
+ ),
62
+ };
63
+
64
+ const invoicesSpec: TableSpec = {
65
+ columns: [
66
+ { key: "id", label: "Invoice" },
67
+ { key: "customer", label: "Customer" },
68
+ { key: "amount", label: "Amount", format: "currency" },
69
+ { key: "margin", label: "Margin", format: "percent" },
70
+ { key: "status", label: "Status", format: "badge" },
71
+ ],
72
+ rows: [
73
+ { id: "INV-1042", customer: "Acme", amount: 12400, margin: 0.32, status: "Paid" },
74
+ { id: "INV-1043", customer: "Globex", amount: 8800, margin: 0.21, status: "Pending" },
75
+ { id: "INV-1044", customer: "Initech", amount: 21500, margin: 0.44, status: "Overdue" },
76
+ { id: "INV-1045", customer: "Umbrella", amount: 3600, margin: 0.12, status: "Draft" },
77
+ ],
78
+ };
79
+
80
+ /**
81
+ * The whole point of the `actions` slot, and of `ExpandDialog` living in
82
+ * `@elabs-ai/components-ui`: a produced table expands through the
83
+ * SAME surface a produced chart does, so "make this bigger" means one thing
84
+ * across the whole chat. Never hand-roll a lightbox or an in-place breakout
85
+ * here — that is how a chat ends up teaching four different meanings for one
86
+ * button.
87
+ *
88
+ * Note what the context pane holds: the table's SHAPE (row count, columns and
89
+ * their declared formats), not min/max/avg. A model-emitted table's numerics
90
+ * may already be formatted strings, so statistics over them are wrong more
91
+ * often than right — a chart's `DefaultDetail` can compute them, this cannot.
92
+ */
93
+ export const ExpandableTable: Story = {
94
+ render: () => (
95
+ <Dialog>
96
+ <ToolResultCard
97
+ className="max-w-prose"
98
+ title="Recent invoices"
99
+ summary="4 rows · billed in USD"
100
+ status="complete"
101
+ actions={
102
+ <DialogTrigger asChild>
103
+ <Button aria-label="Expand table" size="icon-sm" variant="ghost">
104
+ <Maximize2 aria-hidden="true" className="size-4" />
105
+ </Button>
106
+ </DialogTrigger>
107
+ }
108
+ >
109
+ <MessageTable spec={invoicesSpec} />
110
+ </ToolResultCard>
111
+ <ExpandDialog
112
+ title="Recent invoices"
113
+ detail={<TableShape spec={invoicesSpec} />}
114
+ detailLabel="Table shape"
115
+ >
116
+ <MessageTable spec={invoicesSpec} sortable />
117
+ </ExpandDialog>
118
+ </Dialog>
119
+ ),
120
+ };
121
+
122
+ /** The context pane for a table: what it IS, not what it averages. */
123
+ function TableShape({ spec }: { spec: TableSpec }) {
124
+ return (
125
+ <div className="flex h-full flex-col gap-4 p-4">
126
+ <h3 className="text-subtitle text-card-foreground">Shape</h3>
127
+ <dl className="grid grid-cols-[auto_1fr] gap-x-4 gap-y-1 text-meta">
128
+ <dt className="text-muted-foreground">Rows</dt>
129
+ <dd className="tabular-nums">{spec.rows.length}</dd>
130
+ <dt className="text-muted-foreground">Columns</dt>
131
+ <dd className="tabular-nums">{spec.columns.length}</dd>
132
+ </dl>
133
+ <div className="flex flex-col gap-1">
134
+ {spec.columns.map((column) => (
135
+ <div key={column.key} className="flex items-baseline justify-between gap-3 text-meta">
136
+ <span className="truncate">{column.label ?? column.key}</span>
137
+ <span className="shrink-0 text-muted-foreground">{column.format ?? "text"}</span>
138
+ </div>
139
+ ))}
140
+ </div>
141
+ </div>
142
+ );
143
+ }
@@ -0,0 +1,72 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { render, screen } from "@testing-library/react";
3
+ import { ToolResultCard } from "./tool-result-card";
4
+
5
+ describe("ToolResultCard (#192, research 10 §B.4)", () => {
6
+ it("hosts the artifact as children on the elevation channel (fill + shadow, no border)", () => {
7
+ render(
8
+ <ToolResultCard
9
+ data-testid="card"
10
+ title="Revenue by region — Q3 vs Q2"
11
+ summary="Total $48.2M · +12.4% QoQ"
12
+ status="complete"
13
+ >
14
+ <div data-testid="artifact">chart</div>
15
+ </ToolResultCard>,
16
+ );
17
+ const card = screen.getByTestId("card");
18
+ expect(card.className).toContain("bg-surface-elevated");
19
+ expect(card.className).toContain("shadow-sm");
20
+ expect(card.className.split(" ")).not.toContain("border");
21
+ expect(screen.getByTestId("artifact")).toBeInTheDocument();
22
+ expect(screen.getByText("Revenue by region — Q3 vs Q2")).toBeInTheDocument();
23
+ expect(screen.getByText("Total $48.2M · +12.4% QoQ")).toBeInTheDocument();
24
+ expect(screen.getByText("Complete")).toBeInTheDocument(); // StatusBadge
25
+ });
26
+
27
+ it("renders the details slot, merges className and spreads props", () => {
28
+ render(
29
+ <ToolResultCard
30
+ data-testid="card"
31
+ className="custom-card"
32
+ aria-label="result"
33
+ details={<div data-testid="details">technical details</div>}
34
+ >
35
+ body
36
+ </ToolResultCard>,
37
+ );
38
+ const card = screen.getByTestId("card");
39
+ expect(card).toHaveClass("custom-card");
40
+ expect(card).toHaveAttribute("aria-label", "result");
41
+ expect(screen.getByTestId("details")).toBeInTheDocument();
42
+ });
43
+
44
+ /*
45
+ * The artifact's own chrome shares the title row — it never gets a second
46
+ * strip above the heading. A hover-revealed toolbar row costs a whole line of
47
+ * vertical space in a chat transcript and hides the controls until you find
48
+ * them.
49
+ */
50
+ it("puts artifact actions on the title row, between the title and the status", () => {
51
+ render(
52
+ <ToolResultCard
53
+ title="Recent invoices"
54
+ status="complete"
55
+ actions={<button type="button">Expand</button>}
56
+ >
57
+ body
58
+ </ToolResultCard>,
59
+ );
60
+ const row = screen.getByRole("heading", { name: "Recent invoices" }).parentElement!
61
+ .parentElement!;
62
+ const text = row.textContent ?? "";
63
+ expect(text.indexOf("Recent invoices")).toBeLessThan(text.indexOf("Expand"));
64
+ expect(text.indexOf("Expand")).toBeLessThan(text.indexOf("Complete"));
65
+ });
66
+
67
+ /* An actions-only card still gets its header — `hasHeader` must see the slot. */
68
+ it("renders the header for an actions-only card", () => {
69
+ render(<ToolResultCard actions={<button type="button">Download</button>}>body</ToolResultCard>);
70
+ expect(screen.getByRole("button", { name: "Download" })).toBeInTheDocument();
71
+ });
72
+ });