@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,363 @@
1
+ "use client";
2
+
3
+ import { Button } from "@elabs-ai/components-ui";
4
+ import { HoverCard, HoverCardContent, HoverCardTrigger } from "@elabs-ai/components-ui";
5
+ import { Progress } from "@elabs-ai/components-ui";
6
+ import { useLocale } from "@elabs-ai/components-ui";
7
+ import { cn } from "@elabs-ai/components-ui/lib/cn";
8
+ import type { LanguageModelUsage } from "ai";
9
+ import type { ComponentProps } from "react";
10
+ import { createContext, useContext, useMemo } from "react";
11
+ import { getUsage } from "tokenlens";
12
+
13
+ const PERCENT_MAX = 100;
14
+ const ICON_RADIUS = 10;
15
+ const ICON_VIEWBOX = 24;
16
+ const ICON_CENTER = 12;
17
+ const ICON_STROKE_WIDTH = 2;
18
+
19
+ type ModelId = string;
20
+
21
+ interface ContextSchema {
22
+ usedTokens: number;
23
+ maxTokens: number;
24
+ usage?: LanguageModelUsage;
25
+ modelId?: ModelId;
26
+ }
27
+
28
+ const ContextContext = createContext<ContextSchema | null>(null);
29
+
30
+ const useContextValue = () => {
31
+ const context = useContext(ContextContext);
32
+
33
+ if (!context) {
34
+ throw new Error("Context components must be used within Context");
35
+ }
36
+
37
+ return context;
38
+ };
39
+
40
+ export type ContextProps = ComponentProps<typeof HoverCard> & ContextSchema;
41
+
42
+ export const Context = ({ usedTokens, maxTokens, usage, modelId, ...props }: ContextProps) => {
43
+ const contextValue = useMemo(
44
+ () => ({ maxTokens, modelId, usage, usedTokens }),
45
+ [maxTokens, modelId, usage, usedTokens],
46
+ );
47
+
48
+ return (
49
+ <ContextContext.Provider value={contextValue}>
50
+ <HoverCard closeDelay={0} openDelay={0} {...props} />
51
+ </ContextContext.Provider>
52
+ );
53
+ };
54
+
55
+ const ContextIcon = () => {
56
+ const { t } = useLocale();
57
+ const { usedTokens, maxTokens } = useContextValue();
58
+ const circumference = 2 * Math.PI * ICON_RADIUS;
59
+ const usedPercent = usedTokens / maxTokens;
60
+ const dashOffset = circumference * (1 - usedPercent);
61
+
62
+ return (
63
+ <svg
64
+ aria-label={t("ai.context.usage")}
65
+ height="20"
66
+ role="img"
67
+ style={{ color: "currentcolor" }}
68
+ viewBox={`0 0 ${ICON_VIEWBOX} ${ICON_VIEWBOX}`}
69
+ width="20"
70
+ >
71
+ <circle
72
+ cx={ICON_CENTER}
73
+ cy={ICON_CENTER}
74
+ fill="none"
75
+ opacity="0.25"
76
+ r={ICON_RADIUS}
77
+ stroke="currentColor"
78
+ strokeWidth={ICON_STROKE_WIDTH}
79
+ />
80
+ <circle
81
+ cx={ICON_CENTER}
82
+ cy={ICON_CENTER}
83
+ fill="none"
84
+ opacity="0.7"
85
+ r={ICON_RADIUS}
86
+ stroke="currentColor"
87
+ strokeDasharray={`${circumference} ${circumference}`}
88
+ strokeDashoffset={dashOffset}
89
+ strokeLinecap="round"
90
+ strokeWidth={ICON_STROKE_WIDTH}
91
+ style={{ transform: "rotate(-90deg)", transformOrigin: "center" }}
92
+ />
93
+ </svg>
94
+ );
95
+ };
96
+
97
+ export type ContextTriggerProps = ComponentProps<typeof Button>;
98
+
99
+ export const ContextTrigger = ({ children, ...props }: ContextTriggerProps) => {
100
+ const { formatNumber } = useLocale();
101
+ const { usedTokens, maxTokens } = useContextValue();
102
+ const usedPercent = usedTokens / maxTokens;
103
+ const renderedPercent = formatNumber(usedPercent, {
104
+ maximumFractionDigits: 1,
105
+ style: "percent",
106
+ });
107
+
108
+ return (
109
+ <HoverCardTrigger asChild>
110
+ {children ?? (
111
+ <Button type="button" variant="ghost" {...props}>
112
+ <span className="font-medium text-muted-foreground">{renderedPercent}</span>
113
+ <ContextIcon />
114
+ </Button>
115
+ )}
116
+ </HoverCardTrigger>
117
+ );
118
+ };
119
+
120
+ export type ContextContentProps = ComponentProps<typeof HoverCardContent>;
121
+
122
+ export const ContextContent = ({ className, ...props }: ContextContentProps) => (
123
+ <HoverCardContent className={cn("min-w-60 divide-y overflow-hidden p-0", className)} {...props} />
124
+ );
125
+
126
+ export type ContextContentHeaderProps = ComponentProps<"div">;
127
+
128
+ export const ContextContentHeader = ({
129
+ children,
130
+ className,
131
+ ...props
132
+ }: ContextContentHeaderProps) => {
133
+ const { formatNumber } = useLocale();
134
+ const { usedTokens, maxTokens } = useContextValue();
135
+ const usedPercent = usedTokens / maxTokens;
136
+ const displayPct = formatNumber(usedPercent, {
137
+ maximumFractionDigits: 1,
138
+ style: "percent",
139
+ });
140
+ const used = formatNumber(usedTokens, {
141
+ notation: "compact",
142
+ });
143
+ const total = formatNumber(maxTokens, {
144
+ notation: "compact",
145
+ });
146
+
147
+ return (
148
+ <div className={cn("w-full space-y-2 p-3", className)} {...props}>
149
+ {children ?? (
150
+ <>
151
+ <div className="flex items-center justify-between gap-3 text-xs">
152
+ <p>{displayPct}</p>
153
+ <p className="font-mono text-muted-foreground">
154
+ {used} / {total}
155
+ </p>
156
+ </div>
157
+ <div className="space-y-2">
158
+ <Progress className="bg-muted" value={usedPercent * PERCENT_MAX} />
159
+ </div>
160
+ </>
161
+ )}
162
+ </div>
163
+ );
164
+ };
165
+
166
+ export type ContextContentBodyProps = ComponentProps<"div">;
167
+
168
+ export const ContextContentBody = ({ children, className, ...props }: ContextContentBodyProps) => (
169
+ <div className={cn("w-full p-3", className)} {...props}>
170
+ {children}
171
+ </div>
172
+ );
173
+
174
+ export type ContextContentFooterProps = ComponentProps<"div">;
175
+
176
+ export const ContextContentFooter = ({
177
+ children,
178
+ className,
179
+ ...props
180
+ }: ContextContentFooterProps) => {
181
+ const { formatNumber } = useLocale();
182
+ const { modelId, usage } = useContextValue();
183
+ const costUSD = modelId
184
+ ? getUsage({
185
+ modelId,
186
+ usage: {
187
+ input: usage?.inputTokens ?? 0,
188
+ output: usage?.outputTokens ?? 0,
189
+ },
190
+ }).costUSD?.totalUSD
191
+ : undefined;
192
+ const totalCost = formatNumber(costUSD ?? 0, {
193
+ currency: "USD",
194
+ style: "currency",
195
+ });
196
+
197
+ return (
198
+ <div
199
+ className={cn(
200
+ "flex w-full items-center justify-between gap-3 bg-secondary p-3 text-xs",
201
+ className,
202
+ )}
203
+ {...props}
204
+ >
205
+ {children ?? (
206
+ <>
207
+ <span className="text-muted-foreground">Total cost</span>
208
+ <span>{totalCost}</span>
209
+ </>
210
+ )}
211
+ </div>
212
+ );
213
+ };
214
+
215
+ const TokensWithCost = ({ tokens, costText }: { tokens?: number; costText?: string }) => {
216
+ const { formatNumber } = useLocale();
217
+ return (
218
+ <span>
219
+ {tokens === undefined ? "—" : formatNumber(tokens, { notation: "compact" })}
220
+ {costText ? <span className="ms-2 text-muted-foreground">• {costText}</span> : null}
221
+ </span>
222
+ );
223
+ };
224
+
225
+ export type ContextInputUsageProps = ComponentProps<"div">;
226
+
227
+ export const ContextInputUsage = ({ className, children, ...props }: ContextInputUsageProps) => {
228
+ const { formatNumber } = useLocale();
229
+ const { usage, modelId } = useContextValue();
230
+ const inputTokens = usage?.inputTokens ?? 0;
231
+
232
+ if (children) {
233
+ return children;
234
+ }
235
+
236
+ if (!inputTokens) {
237
+ return null;
238
+ }
239
+
240
+ const inputCost = modelId
241
+ ? getUsage({
242
+ modelId,
243
+ usage: { input: inputTokens, output: 0 },
244
+ }).costUSD?.totalUSD
245
+ : undefined;
246
+ const inputCostText = formatNumber(inputCost ?? 0, {
247
+ currency: "USD",
248
+ style: "currency",
249
+ });
250
+
251
+ return (
252
+ <div className={cn("flex items-center justify-between text-xs", className)} {...props}>
253
+ <span className="text-muted-foreground">Input</span>
254
+ <TokensWithCost costText={inputCostText} tokens={inputTokens} />
255
+ </div>
256
+ );
257
+ };
258
+
259
+ export type ContextOutputUsageProps = ComponentProps<"div">;
260
+
261
+ export const ContextOutputUsage = ({ className, children, ...props }: ContextOutputUsageProps) => {
262
+ const { formatNumber } = useLocale();
263
+ const { usage, modelId } = useContextValue();
264
+ const outputTokens = usage?.outputTokens ?? 0;
265
+
266
+ if (children) {
267
+ return children;
268
+ }
269
+
270
+ if (!outputTokens) {
271
+ return null;
272
+ }
273
+
274
+ const outputCost = modelId
275
+ ? getUsage({
276
+ modelId,
277
+ usage: { input: 0, output: outputTokens },
278
+ }).costUSD?.totalUSD
279
+ : undefined;
280
+ const outputCostText = formatNumber(outputCost ?? 0, {
281
+ currency: "USD",
282
+ style: "currency",
283
+ });
284
+
285
+ return (
286
+ <div className={cn("flex items-center justify-between text-xs", className)} {...props}>
287
+ <span className="text-muted-foreground">Output</span>
288
+ <TokensWithCost costText={outputCostText} tokens={outputTokens} />
289
+ </div>
290
+ );
291
+ };
292
+
293
+ export type ContextReasoningUsageProps = ComponentProps<"div">;
294
+
295
+ export const ContextReasoningUsage = ({
296
+ className,
297
+ children,
298
+ ...props
299
+ }: ContextReasoningUsageProps) => {
300
+ const { formatNumber } = useLocale();
301
+ const { usage, modelId } = useContextValue();
302
+ const reasoningTokens = usage?.reasoningTokens ?? 0;
303
+
304
+ if (children) {
305
+ return children;
306
+ }
307
+
308
+ if (!reasoningTokens) {
309
+ return null;
310
+ }
311
+
312
+ const reasoningCost = modelId
313
+ ? getUsage({
314
+ modelId,
315
+ usage: { reasoningTokens },
316
+ }).costUSD?.totalUSD
317
+ : undefined;
318
+ const reasoningCostText = formatNumber(reasoningCost ?? 0, {
319
+ currency: "USD",
320
+ style: "currency",
321
+ });
322
+
323
+ return (
324
+ <div className={cn("flex items-center justify-between text-xs", className)} {...props}>
325
+ <span className="text-muted-foreground">Reasoning</span>
326
+ <TokensWithCost costText={reasoningCostText} tokens={reasoningTokens} />
327
+ </div>
328
+ );
329
+ };
330
+
331
+ export type ContextCacheUsageProps = ComponentProps<"div">;
332
+
333
+ export const ContextCacheUsage = ({ className, children, ...props }: ContextCacheUsageProps) => {
334
+ const { formatNumber } = useLocale();
335
+ const { usage, modelId } = useContextValue();
336
+ const cacheTokens = usage?.cachedInputTokens ?? 0;
337
+
338
+ if (children) {
339
+ return children;
340
+ }
341
+
342
+ if (!cacheTokens) {
343
+ return null;
344
+ }
345
+
346
+ const cacheCost = modelId
347
+ ? getUsage({
348
+ modelId,
349
+ usage: { cacheReads: cacheTokens, input: 0, output: 0 },
350
+ }).costUSD?.totalUSD
351
+ : undefined;
352
+ const cacheCostText = formatNumber(cacheCost ?? 0, {
353
+ currency: "USD",
354
+ style: "currency",
355
+ });
356
+
357
+ return (
358
+ <div className={cn("flex items-center justify-between text-xs", className)} {...props}>
359
+ <span className="text-muted-foreground">Cache</span>
360
+ <TokensWithCost costText={cacheCostText} tokens={cacheTokens} />
361
+ </div>
362
+ );
363
+ };
@@ -0,0 +1,20 @@
1
+ "use client";
2
+
3
+ import type { Controls as ControlsPrimitive } from "@xyflow/react";
4
+ import type { ComponentProps } from "react";
5
+ import { Suspense } from "react";
6
+
7
+ import { lazyFlowPart } from "./_flow-lazy";
8
+
9
+ export type ControlsProps = ComponentProps<typeof ControlsPrimitive>;
10
+
11
+ /** React Flow lives behind a dynamic import — see `_flow-lazy.ts` and ADR 0019. */
12
+ const ControlsImpl = lazyFlowPart<ControlsProps>((m) => m.Controls);
13
+
14
+ export const Controls = (props: ControlsProps) => (
15
+ // An overlay inside an already-loaded `<Canvas>`, so there is no box to
16
+ // reserve — the engine chunk it needs is the one the canvas already fetched.
17
+ <Suspense fallback={null}>
18
+ <ControlsImpl {...props} />
19
+ </Suspense>
20
+ );
@@ -0,0 +1,26 @@
1
+ import type { Meta, StoryObj } from "@storybook/react-vite";
2
+ import { Conversation, ConversationContent } from "./conversation";
3
+ import { Message, MessageContent } from "./message";
4
+ const meta = {
5
+ title: "AI/Conversation",
6
+ component: Conversation,
7
+ parameters: { layout: "padded" },
8
+ } satisfies Meta<typeof Conversation>;
9
+ export default meta;
10
+ type Story = StoryObj<typeof meta>;
11
+ export const Default: Story = {
12
+ render: () => (
13
+ <div className="flex h-72 flex-col rounded-lg border">
14
+ <Conversation>
15
+ <ConversationContent>
16
+ <Message from="user">
17
+ <MessageContent>Hello</MessageContent>
18
+ </Message>
19
+ <Message from="assistant">
20
+ <MessageContent>Hi! How can I help with your deploys today?</MessageContent>
21
+ </Message>
22
+ </ConversationContent>
23
+ </Conversation>
24
+ </div>
25
+ ),
26
+ };
@@ -0,0 +1,123 @@
1
+ /**
2
+ * conversation.test.tsx — smoke + a11y/serialization lock for the transcript (#59).
3
+ *
4
+ * `Conversation` is the scroll container every chat message renders into. Two
5
+ * things must not regress: the container keeps `role="log"` (an incrementally
6
+ * updated region — this is what makes a streaming transcript announceable at
7
+ * all), and `messagesToMarkdown` — the download path's serializer — flattens
8
+ * `UIMessage.parts` correctly, since a broken export silently ships an empty or
9
+ * mangled file rather than throwing.
10
+ *
11
+ * Nothing is mocked: the real `StickToBottom`-backed component is rendered.
12
+ */
13
+ import { describe, expect, it } from "vitest";
14
+ import { render, screen } from "@testing-library/react";
15
+ import type { UIMessage } from "ai";
16
+ import {
17
+ Conversation,
18
+ ConversationContent,
19
+ ConversationEmptyState,
20
+ messagesToMarkdown,
21
+ } from "./conversation";
22
+
23
+ const messages: UIMessage[] = [
24
+ { id: "1", role: "user", parts: [{ type: "text", text: "What changed?" }] },
25
+ {
26
+ id: "2",
27
+ role: "assistant",
28
+ parts: [
29
+ { type: "text", text: "Three services shipped." },
30
+ { type: "text", text: " Billing is degraded." },
31
+ ],
32
+ },
33
+ ];
34
+
35
+ describe("Conversation — transcript region", () => {
36
+ it('exposes the transcript as role="log" (an incrementally updated region)', () => {
37
+ render(
38
+ <Conversation>
39
+ <ConversationContent>
40
+ <p>hello</p>
41
+ </ConversationContent>
42
+ </Conversation>,
43
+ );
44
+ expect(screen.getByRole("log")).toBeInTheDocument();
45
+ });
46
+
47
+ it("renders its children inside the log, in order", () => {
48
+ render(
49
+ <Conversation>
50
+ <ConversationContent>
51
+ <p>first</p>
52
+ <p>second</p>
53
+ </ConversationContent>
54
+ </Conversation>,
55
+ );
56
+ const log = screen.getByRole("log");
57
+ expect(log).toHaveTextContent("first");
58
+ expect(log).toHaveTextContent("second");
59
+ const [first, second] = [screen.getByText("first"), screen.getByText("second")];
60
+ expect(first.compareDocumentPosition(second) & 4).toBeTruthy();
61
+ });
62
+
63
+ it("merges a caller className without dropping the flex-fill layout", () => {
64
+ render(
65
+ <Conversation className="extra">
66
+ <ConversationContent>x</ConversationContent>
67
+ </Conversation>,
68
+ );
69
+ expect(screen.getByRole("log")).toHaveClass("extra");
70
+ expect(screen.getByRole("log")).toHaveClass("flex-1");
71
+ });
72
+ });
73
+
74
+ describe("ConversationEmptyState", () => {
75
+ it("renders a real empty state by default, not a blank region", () => {
76
+ render(<ConversationEmptyState />);
77
+ expect(screen.getByRole("heading", { name: "No messages yet" })).toBeInTheDocument();
78
+ expect(screen.getByText("Start a conversation to see messages here")).toBeInTheDocument();
79
+ });
80
+
81
+ it("accepts an overriding title/description", () => {
82
+ render(<ConversationEmptyState title="Ask anything" description="Try a question." />);
83
+ expect(screen.getByRole("heading", { name: "Ask anything" })).toBeInTheDocument();
84
+ expect(screen.getByText("Try a question.")).toBeInTheDocument();
85
+ });
86
+
87
+ it("lets children replace the default anatomy entirely", () => {
88
+ render(
89
+ <ConversationEmptyState>
90
+ <p>custom</p>
91
+ </ConversationEmptyState>,
92
+ );
93
+ expect(screen.getByText("custom")).toBeInTheDocument();
94
+ expect(screen.queryByRole("heading", { name: "No messages yet" })).toBeNull();
95
+ });
96
+ });
97
+
98
+ describe("messagesToMarkdown — the download serializer", () => {
99
+ it("labels each message by role and joins every text part", () => {
100
+ expect(messagesToMarkdown(messages)).toBe(
101
+ "**User:** What changed?\n\n**Assistant:** Three services shipped. Billing is degraded.",
102
+ );
103
+ });
104
+
105
+ it("skips non-text parts rather than emitting [object Object]", () => {
106
+ const withTool: UIMessage[] = [
107
+ {
108
+ id: "1",
109
+ role: "assistant",
110
+ parts: [{ type: "step-start" }, { type: "text", text: "Done." }],
111
+ },
112
+ ];
113
+ expect(messagesToMarkdown(withTool)).toBe("**Assistant:** Done.");
114
+ });
115
+
116
+ it("honours a custom formatter", () => {
117
+ expect(messagesToMarkdown(messages, (m) => m.role)).toBe("user\n\nassistant");
118
+ });
119
+
120
+ it("returns an empty string for an empty transcript", () => {
121
+ expect(messagesToMarkdown([])).toBe("");
122
+ });
123
+ });
@@ -0,0 +1,143 @@
1
+ "use client";
2
+
3
+ import { Button, downloadBlob } from "@elabs-ai/components-ui";
4
+ import { cn } from "@elabs-ai/components-ui/lib/cn";
5
+ import type { UIMessage } from "ai";
6
+ import { ArrowDownIcon, DownloadIcon } from "lucide-react";
7
+ import type { ComponentProps } from "react";
8
+ import { useCallback } from "react";
9
+ import { StickToBottom, useStickToBottomContext } from "use-stick-to-bottom";
10
+
11
+ export type ConversationProps = ComponentProps<typeof StickToBottom>;
12
+
13
+ export const Conversation = ({ className, ...props }: ConversationProps) => (
14
+ <StickToBottom
15
+ className={cn("relative flex-1 overflow-y-hidden", className)}
16
+ initial="smooth"
17
+ resize="smooth"
18
+ role="log"
19
+ {...props}
20
+ />
21
+ );
22
+
23
+ export type ConversationContentProps = ComponentProps<typeof StickToBottom.Content>;
24
+
25
+ export const ConversationContent = ({ className, ...props }: ConversationContentProps) => (
26
+ <StickToBottom.Content className={cn("flex flex-col gap-8 p-4", className)} {...props} />
27
+ );
28
+
29
+ export type ConversationEmptyStateProps = ComponentProps<"div"> & {
30
+ title?: string;
31
+ description?: string;
32
+ icon?: React.ReactNode;
33
+ };
34
+
35
+ export const ConversationEmptyState = ({
36
+ className,
37
+ title = "No messages yet",
38
+ description = "Start a conversation to see messages here",
39
+ icon,
40
+ children,
41
+ ...props
42
+ }: ConversationEmptyStateProps) => (
43
+ <div
44
+ className={cn(
45
+ "flex size-full flex-col items-center justify-center gap-3 p-8 text-center",
46
+ className,
47
+ )}
48
+ {...props}
49
+ >
50
+ {children ?? (
51
+ <>
52
+ {icon && <div className="text-muted-foreground">{icon}</div>}
53
+ <div className="space-y-1">
54
+ <h3 className="font-medium text-sm">{title}</h3>
55
+ {description && <p className="text-muted-foreground text-sm">{description}</p>}
56
+ </div>
57
+ </>
58
+ )}
59
+ </div>
60
+ );
61
+
62
+ export type ConversationScrollButtonProps = ComponentProps<typeof Button>;
63
+
64
+ export const ConversationScrollButton = ({
65
+ className,
66
+ ...props
67
+ }: ConversationScrollButtonProps) => {
68
+ const { isAtBottom, scrollToBottom } = useStickToBottomContext();
69
+
70
+ const handleScrollToBottom = useCallback(() => {
71
+ scrollToBottom();
72
+ }, [scrollToBottom]);
73
+
74
+ return (
75
+ !isAtBottom && (
76
+ <Button
77
+ className={cn(
78
+ "absolute bottom-4 left-[50%] translate-x-[-50%] rounded-full dark:bg-background dark:hover:bg-muted",
79
+ className,
80
+ )}
81
+ onClick={handleScrollToBottom}
82
+ size="icon"
83
+ type="button"
84
+ variant="outline"
85
+ {...props}
86
+ >
87
+ <ArrowDownIcon className="size-4" />
88
+ </Button>
89
+ )
90
+ );
91
+ };
92
+
93
+ const getMessageText = (message: UIMessage): string =>
94
+ message.parts
95
+ .filter((part) => part.type === "text")
96
+ .map((part) => part.text)
97
+ .join("");
98
+
99
+ export type ConversationDownloadProps = Omit<ComponentProps<typeof Button>, "onClick"> & {
100
+ messages: UIMessage[];
101
+ filename?: string;
102
+ formatMessage?: (message: UIMessage, index: number) => string;
103
+ };
104
+
105
+ const defaultFormatMessage = (message: UIMessage): string => {
106
+ const roleLabel = message.role.charAt(0).toUpperCase() + message.role.slice(1);
107
+ return `**${roleLabel}:** ${getMessageText(message)}`;
108
+ };
109
+
110
+ export const messagesToMarkdown = (
111
+ messages: UIMessage[],
112
+ formatMessage: (message: UIMessage, index: number) => string = defaultFormatMessage,
113
+ ): string => messages.map((msg, i) => formatMessage(msg, i)).join("\n\n");
114
+
115
+ export const ConversationDownload = ({
116
+ messages,
117
+ filename = "conversation.md",
118
+ formatMessage = defaultFormatMessage,
119
+ className,
120
+ children,
121
+ ...props
122
+ }: ConversationDownloadProps) => {
123
+ const handleDownload = useCallback(() => {
124
+ const markdown = messagesToMarkdown(messages, formatMessage);
125
+ downloadBlob(new Blob([markdown], { type: "text/markdown" }), filename);
126
+ }, [messages, filename, formatMessage]);
127
+
128
+ return (
129
+ <Button
130
+ className={cn(
131
+ "absolute top-4 end-4 rounded-full dark:bg-background dark:hover:bg-muted",
132
+ className,
133
+ )}
134
+ onClick={handleDownload}
135
+ size="icon"
136
+ type="button"
137
+ variant="outline"
138
+ {...props}
139
+ >
140
+ {children ?? <DownloadIcon className="size-4" />}
141
+ </Button>
142
+ );
143
+ };