@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,619 @@
1
+ "use client";
2
+
3
+ import { Avatar, AvatarFallback, AvatarImage, Button } from "@elabs-ai/components-ui";
4
+ import { ButtonGroup, ButtonGroupText, Skeleton } from "@elabs-ai/components-ui";
5
+ import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@elabs-ai/components-ui";
6
+ import { cn } from "@elabs-ai/components-ui/lib/cn";
7
+ import { useLocale } from "@elabs-ai/components-ui";
8
+ import { cva, type VariantProps } from "class-variance-authority";
9
+ import { useStreamdownPlugins, useStreamdownTranslations } from "./_streamdown-i18n";
10
+ import type { UIMessage } from "ai";
11
+ import { BotIcon, ChevronLeftIcon, ChevronRightIcon } from "lucide-react";
12
+ import type { ComponentProps, HTMLAttributes, ReactElement } from "react";
13
+ import { createContext, memo, useCallback, useContext, useEffect, useMemo, useState } from "react";
14
+ import { Streamdown } from "streamdown";
15
+
16
+ export type MessageProps = HTMLAttributes<HTMLDivElement> & {
17
+ from: UIMessage["role"];
18
+ };
19
+
20
+ export const Message = ({ className, from, ...props }: MessageProps) => (
21
+ <div
22
+ className={cn(
23
+ "group flex w-full max-w-[95%] flex-col gap-2",
24
+ // NOTE `is-user` / `is-assistant` are LOAD-BEARING, not decorative: they
25
+ // are what `MessageContent`'s `group-[.is-user]:…` selectors compile
26
+ // against (`.group.is-user &`). `data-role` below is their semantic twin
27
+ // for querying/testing — don't dedupe the two, removing the class would
28
+ // silently kill the `--chat-user` fill in every theme.
29
+ from === "user" ? "is-user ms-auto justify-end" : "is-assistant",
30
+ className,
31
+ )}
32
+ data-role={from}
33
+ data-slot="message"
34
+ {...props}
35
+ />
36
+ );
37
+
38
+ export type MessageContentProps = HTMLAttributes<HTMLDivElement>;
39
+
40
+ export const MessageContent = ({ children, className, ...props }: MessageContentProps) => (
41
+ <div
42
+ className={cn(
43
+ "flex w-fit min-w-0 max-w-full flex-col gap-2 overflow-hidden text-body",
44
+ // The user turn is the 08 fill channel: the revalued `--chat-user` tint +
45
+ // its AA-validated foreground (research 11 §B.1 MSG-1). The assistant
46
+ // branch stays bare — its separation is the AgentMessage rail.
47
+ "group-[.is-user]:ms-auto group-[.is-user]:rounded-lg group-[.is-user]:bg-chat-user group-[.is-user]:px-4 group-[.is-user]:py-3 group-[.is-user]:text-chat-user-foreground",
48
+ "group-[.is-assistant]:text-foreground",
49
+ className,
50
+ )}
51
+ data-slot="message-content"
52
+ {...props}
53
+ >
54
+ {children}
55
+ </div>
56
+ );
57
+
58
+ export type MessageHeaderProps = HTMLAttributes<HTMLDivElement>;
59
+
60
+ /**
61
+ * Optional identity row above the bubble — avatar + name + timestamp
62
+ * (research 11 §B.1 MSG-2). A slot, not baked structure.
63
+ */
64
+ export const MessageHeader = ({ className, ...props }: MessageHeaderProps) => (
65
+ <div
66
+ className={cn("flex items-center gap-2 text-meta text-muted-foreground", className)}
67
+ data-slot="message-header"
68
+ {...props}
69
+ />
70
+ );
71
+
72
+ export type MessageAvatarProps = ComponentProps<typeof Avatar> & {
73
+ /** Image source for the participant. */
74
+ src?: string;
75
+ /** Participant name — drives the image alt + the fallback initial, and the accessible label. */
76
+ name?: string;
77
+ /**
78
+ * Visual identity role.
79
+ * - `"user"` (default): shows the initial letter as a fallback.
80
+ * - `"agent"`: shows a branded Bot icon on a primary (green) ground instead
81
+ * of a text initial, matching the agent identity convention.
82
+ */
83
+ role?: "user" | "agent";
84
+ };
85
+
86
+ /**
87
+ * Thin preset over the `@elabs-ai/components-ui` Avatar sized for inline chat (NOT a new
88
+ * avatar primitive — research 11 §B.1 MSG-2).
89
+ *
90
+ * When `role="agent"`, renders a filled primary-green circle with a centered
91
+ * Bot icon (lucide-react `BotIcon`) and an accessible label derived from
92
+ * `name`. The primary/primary-foreground token pair is the agent identity
93
+ * colour across both themes (green on light/dark; theme-adapted
94
+ * on others).
95
+ */
96
+ export const MessageAvatar = ({
97
+ src,
98
+ name,
99
+ role = "user",
100
+ className,
101
+ children,
102
+ ...props
103
+ }: MessageAvatarProps) => {
104
+ if (role === "agent") {
105
+ return (
106
+ <Avatar
107
+ aria-label={name ? `${name} (agent)` : "Agent"}
108
+ // `role="img"` is LOAD-BEARING, not decoration (#386). Radix's
109
+ // `Avatar.Root` renders a plain `<span>`, whose implicit role is
110
+ // `generic` — and ARIA PROHIBITS `aria-label` on `generic`, so axe's
111
+ // `aria-prohibited-attr` reds the story AND assistive tech never
112
+ // announces the name. `img` is the role permitted to carry a name for a
113
+ // graphic whose contents (the Bot glyph) are not text — the same
114
+ // precedent a titled `@elabs-ai/components-icons` `Icon`
115
+ // uses (.claude/rules/icons.md). Only the agent branch takes it: the
116
+ // user branch's fallback initial is real text content, so naming it too
117
+ // would add an `img` per message for no gain.
118
+ role="img"
119
+ className={cn("size-6 bg-primary text-primary-foreground", className)}
120
+ data-role="agent"
121
+ data-slot="message-avatar"
122
+ {...props}
123
+ >
124
+ {src ? <AvatarImage alt={name ?? ""} src={src} /> : null}
125
+ <AvatarFallback className="bg-primary text-primary-foreground">
126
+ <BotIcon aria-hidden="true" size={14} />
127
+ </AvatarFallback>
128
+ </Avatar>
129
+ );
130
+ }
131
+
132
+ return (
133
+ <Avatar
134
+ className={cn("size-6", className)}
135
+ data-role={role}
136
+ data-slot="message-avatar"
137
+ {...props}
138
+ >
139
+ {src ? <AvatarImage alt={name ?? ""} src={src} /> : null}
140
+ <AvatarFallback className="text-meta">
141
+ {children ?? name?.slice(0, 1).toUpperCase() ?? "?"}
142
+ </AvatarFallback>
143
+ </Avatar>
144
+ );
145
+ };
146
+
147
+ export type UserMessageProps = Omit<MessageProps, "from">;
148
+
149
+ /**
150
+ * Named front door for a user turn (research 11 §B.2) — a thin preset over
151
+ * `Message from="user"`; the chat-user fill comes from `MessageContent`.
152
+ */
153
+ export const UserMessage = (props: UserMessageProps) => <Message from="user" {...props} />;
154
+
155
+ export const agentMessageVariants = cva("", {
156
+ variants: {
157
+ /**
158
+ * "Final answer" is a VISUAL axis, not a behavioural mode (research 11
159
+ * §B.2): `answer` applies the 08 green left rail. It sits on the message
160
+ * wrapper — never on `MessageContent`, whose `overflow-hidden` would clip
161
+ * it (research 11 §G.1).
162
+ */
163
+ emphasis: {
164
+ default: "",
165
+ answer: "border-s-4 border-s-primary ps-4",
166
+ },
167
+ },
168
+ defaultVariants: { emphasis: "default" },
169
+ });
170
+
171
+ export type AgentMessageEmphasis = NonNullable<
172
+ VariantProps<typeof agentMessageVariants>["emphasis"]
173
+ >;
174
+
175
+ export type AgentMessageProps = Omit<MessageProps, "from"> &
176
+ VariantProps<typeof agentMessageVariants>;
177
+
178
+ /**
179
+ * Named front door for an assistant turn (research 11 §B.2). KPI band / prose /
180
+ * footer are COMPOSED children (`MetricGrid` + `MessageResponse` +
181
+ * `SourceList`), not baked-in layout.
182
+ */
183
+ export const AgentMessage = ({ className, emphasis, ...props }: AgentMessageProps) => (
184
+ <Message
185
+ from="assistant"
186
+ className={cn(agentMessageVariants({ emphasis }), className)}
187
+ {...props}
188
+ />
189
+ );
190
+
191
+ export const messageActionsVariants = cva(
192
+ [
193
+ "flex items-center gap-1",
194
+ // Follow the bubble to the right on a user turn — the SAME selector
195
+ // `MessageContent` uses for the `--chat-user` fill above. Without it the row
196
+ // sits at the flex-column's cross-start (left) edge while the bubble it acts
197
+ // on is `ms-auto`'d to the right, so a `bar` pill renders under the PREVIOUS
198
+ // (assistant) message: "Edit" appears to edit the agent's answer. It was
199
+ // invisible before only because a `plain` row stretched full-width and had
200
+ // no drawn box; `w-fit` is what made the misplacement visible.
201
+ "group-[.is-user]:ms-auto",
202
+ ],
203
+ {
204
+ variants: {
205
+ /**
206
+ * How the row presents itself. Two VISUAL values, per `cva`'s remit — no
207
+ * behaviour forks on this axis.
208
+ * - `plain` (default): a bare inline row, the pre-#existing shape. Unchanged
209
+ * for every consumer that never sets the prop.
210
+ * - `bar`: a floating pill that reads as chrome sitting ABOVE the transcript
211
+ * rather than as more message content. Per ADR 0020 a floating surface
212
+ * takes `shadow-ring-*` and NO border — the ring bakes the 1px edge in as
213
+ * the shadow's last layer, so there is no second crisp stroke beside it,
214
+ * and because the hairline sits OUTSIDE `--shadow-strength` the pill keeps
215
+ * a drawn edge at high decoration / `data-decoration="8|9|10"` where the lift
216
+ * is zeroed.
217
+ */
218
+ appearance: {
219
+ plain: "",
220
+ bar: "w-fit rounded-full bg-popover p-0.5 shadow-ring-sm",
221
+ },
222
+ /**
223
+ * When the row is visible.
224
+ * - `always` (default): in flow, always shown.
225
+ * - `hover`: faded out at rest, revealed when the pointer is anywhere over
226
+ * the parent `Message`, when focus lands inside the row, or while a menu
227
+ * inside it is open.
228
+ */
229
+ reveal: {
230
+ always: "",
231
+ hover: [
232
+ "transition-opacity duration-fast ease-standard motion-reduce:transition-none",
233
+ // Hover-reveal is gated on the POINTER, not on a breakpoint: a touch
234
+ // device has no hover state at all, so hiding the row there would make
235
+ // it unreachable (you cannot tap what you cannot see, and there is no
236
+ // gesture that reveals it first). `pointer-coarse` therefore never
237
+ // takes the `opacity-0` at all and the row stays visible. Note this is
238
+ // a stronger guarantee than the `md:` breakpoint proxy used by the
239
+ // `@elabs-ai/components-ui` Sidebar — a ≥md tablet is
240
+ // exactly the case that proxy gets wrong.
241
+ "pointer-fine:opacity-0",
242
+ // Reveal on: the parent Message (`Message` sets the bare `group` class
243
+ // — see the load-bearing note there), the row itself, keyboard focus
244
+ // landing inside it, and an open menu launched from it (a `⋯ more`
245
+ // trigger carries `data-state="open"`; without this the row fades out
246
+ // from under the menu the moment the pointer travels to it).
247
+ "pointer-fine:group-hover:opacity-100",
248
+ "pointer-fine:hover:opacity-100",
249
+ "pointer-fine:focus-within:opacity-100",
250
+ "pointer-fine:has-[[data-state=open]]:opacity-100",
251
+ ],
252
+ },
253
+ },
254
+ defaultVariants: { appearance: "plain", reveal: "always" },
255
+ },
256
+ );
257
+
258
+ export type MessageActionsProps = ComponentProps<"div"> &
259
+ VariantProps<typeof messageActionsVariants> & {
260
+ /**
261
+ * Accessible name for the group. Defaults to the `ai.message.actions`
262
+ * microcopy key. Naming matters more here than on an always-visible row:
263
+ * assistive tech reaches these controls whether or not a pointer ever
264
+ * revealed them, so the group has to say what they act on.
265
+ */
266
+ label?: string;
267
+ };
268
+
269
+ /**
270
+ * A row of per-message controls (copy, pin, retry, a `⋯` menu, …).
271
+ *
272
+ * **Presentational only** — it lays the controls out and decides when they are
273
+ * visible; it ships no clipboard call, no pin state and no persistence. Every
274
+ * `MessageAction` inside it is a bare `Button`: give it an `onClick`, and for a
275
+ * toggle (pin, bookmark) an `aria-pressed` the host owns. That is the D5
276
+ * boundary (`docs/DECISIONS.md` §D5) — brand-ui renders, the app acts.
277
+ *
278
+ * `reveal="hover"` reads the parent `Message`'s `group` class, so the row must
279
+ * be rendered INSIDE a `Message` for pointer-reveal to fire. Standing alone it
280
+ * still reveals on its own hover and on focus-within, but not from the bubble.
281
+ */
282
+ export const MessageActions = ({
283
+ appearance,
284
+ className,
285
+ children,
286
+ label,
287
+ reveal,
288
+ ...props
289
+ }: MessageActionsProps) => {
290
+ const { t } = useLocale();
291
+
292
+ return (
293
+ <div
294
+ aria-label={label ?? t("ai.message.actions")}
295
+ className={cn(messageActionsVariants({ appearance, reveal }), className)}
296
+ data-slot="message-actions"
297
+ role="group"
298
+ {...props}
299
+ >
300
+ {children}
301
+ </div>
302
+ );
303
+ };
304
+
305
+ export type MessageActionProps = ComponentProps<typeof Button> & {
306
+ tooltip?: string;
307
+ label?: string;
308
+ };
309
+
310
+ export const MessageAction = ({
311
+ tooltip,
312
+ children,
313
+ label,
314
+ variant = "ghost",
315
+ size = "icon-sm",
316
+ ...props
317
+ }: MessageActionProps) => {
318
+ const button = (
319
+ <Button data-slot="message-action" size={size} type="button" variant={variant} {...props}>
320
+ {children}
321
+ <span className="sr-only">{label || tooltip}</span>
322
+ </Button>
323
+ );
324
+
325
+ if (tooltip) {
326
+ return (
327
+ <TooltipProvider>
328
+ <Tooltip>
329
+ <TooltipTrigger asChild>{button}</TooltipTrigger>
330
+ <TooltipContent>
331
+ <p>{tooltip}</p>
332
+ </TooltipContent>
333
+ </Tooltip>
334
+ </TooltipProvider>
335
+ );
336
+ }
337
+
338
+ return button;
339
+ };
340
+
341
+ interface MessageBranchContextType {
342
+ currentBranch: number;
343
+ totalBranches: number;
344
+ goToPrevious: () => void;
345
+ goToNext: () => void;
346
+ branches: ReactElement[];
347
+ setBranches: (branches: ReactElement[]) => void;
348
+ }
349
+
350
+ const MessageBranchContext = createContext<MessageBranchContextType | null>(null);
351
+
352
+ const useMessageBranch = () => {
353
+ const context = useContext(MessageBranchContext);
354
+
355
+ if (!context) {
356
+ throw new Error("MessageBranch components must be used within MessageBranch");
357
+ }
358
+
359
+ return context;
360
+ };
361
+
362
+ export type MessageBranchProps = HTMLAttributes<HTMLDivElement> & {
363
+ /**
364
+ * Controlled current branch index. When set, `MessageBranch` no longer
365
+ * tracks its own state — the host owns it and must update `branch` from
366
+ * `onBranchChange` (or elsewhere) to move the selection. Omit for the
367
+ * uncontrolled default (see `defaultBranch`).
368
+ */
369
+ branch?: number;
370
+ defaultBranch?: number;
371
+ onBranchChange?: (branchIndex: number) => void;
372
+ };
373
+
374
+ export const MessageBranch = ({
375
+ branch: branchProp,
376
+ defaultBranch = 0,
377
+ onBranchChange,
378
+ className,
379
+ ...props
380
+ }: MessageBranchProps) => {
381
+ // Derived once and never flipped, per the repo's controlled/uncontrolled
382
+ // convention (component-api.md) — mirrors MessageFeedback/MessageEdit.
383
+ const isControlled = branchProp !== undefined;
384
+ const [internalBranch, setInternalBranch] = useState(defaultBranch);
385
+ const currentBranch = isControlled ? branchProp : internalBranch;
386
+ const [branches, setBranches] = useState<ReactElement[]>([]);
387
+
388
+ const handleBranchChange = useCallback(
389
+ (newBranch: number) => {
390
+ if (!isControlled) setInternalBranch(newBranch);
391
+ onBranchChange?.(newBranch);
392
+ },
393
+ [isControlled, onBranchChange],
394
+ );
395
+
396
+ const goToPrevious = useCallback(() => {
397
+ const newBranch = currentBranch > 0 ? currentBranch - 1 : branches.length - 1;
398
+ handleBranchChange(newBranch);
399
+ }, [currentBranch, branches.length, handleBranchChange]);
400
+
401
+ const goToNext = useCallback(() => {
402
+ const newBranch = currentBranch < branches.length - 1 ? currentBranch + 1 : 0;
403
+ handleBranchChange(newBranch);
404
+ }, [currentBranch, branches.length, handleBranchChange]);
405
+
406
+ const contextValue = useMemo<MessageBranchContextType>(
407
+ () => ({
408
+ branches,
409
+ currentBranch,
410
+ goToNext,
411
+ goToPrevious,
412
+ setBranches,
413
+ totalBranches: branches.length,
414
+ }),
415
+ [branches, currentBranch, goToNext, goToPrevious],
416
+ );
417
+
418
+ return (
419
+ <MessageBranchContext.Provider value={contextValue}>
420
+ <div
421
+ className={cn("grid w-full gap-2 [&>div]:pb-0", className)}
422
+ data-slot="message-branch"
423
+ {...props}
424
+ />
425
+ </MessageBranchContext.Provider>
426
+ );
427
+ };
428
+
429
+ export type MessageBranchContentProps = HTMLAttributes<HTMLDivElement>;
430
+
431
+ export const MessageBranchContent = ({ children, ...props }: MessageBranchContentProps) => {
432
+ const { currentBranch, setBranches, branches } = useMessageBranch();
433
+ const childrenArray = useMemo(
434
+ () => (Array.isArray(children) ? children : [children]),
435
+ [children],
436
+ );
437
+
438
+ // Use useEffect to update branches when they change
439
+ useEffect(() => {
440
+ if (branches.length !== childrenArray.length) {
441
+ setBranches(childrenArray);
442
+ }
443
+ }, [childrenArray, branches, setBranches]);
444
+
445
+ return childrenArray.map((branch, index) => (
446
+ <div
447
+ className={cn(
448
+ "grid gap-2 overflow-hidden [&>div]:pb-0",
449
+ index === currentBranch ? "block" : "hidden",
450
+ )}
451
+ data-slot="message-branch-content"
452
+ key={branch.key}
453
+ {...props}
454
+ >
455
+ {branch}
456
+ </div>
457
+ ));
458
+ };
459
+
460
+ export type MessageBranchSelectorProps = ComponentProps<typeof ButtonGroup>;
461
+
462
+ export const MessageBranchSelector = ({ className, ...props }: MessageBranchSelectorProps) => {
463
+ const { totalBranches } = useMessageBranch();
464
+
465
+ // Don't render if there's only one branch
466
+ if (totalBranches <= 1) {
467
+ return null;
468
+ }
469
+
470
+ return (
471
+ <ButtonGroup
472
+ className={cn(
473
+ "[&>*:not(:first-child)]:rounded-s-md [&>*:not(:last-child)]:rounded-e-md",
474
+ className,
475
+ )}
476
+ data-slot="message-branch-selector"
477
+ orientation="horizontal"
478
+ {...props}
479
+ />
480
+ );
481
+ };
482
+
483
+ export type MessageBranchPreviousProps = ComponentProps<typeof Button>;
484
+
485
+ export const MessageBranchPrevious = ({ children, ...props }: MessageBranchPreviousProps) => {
486
+ const { t } = useLocale();
487
+ const { goToPrevious, totalBranches } = useMessageBranch();
488
+
489
+ return (
490
+ <Button
491
+ aria-label={t("ai.message.previousBranch")}
492
+ data-slot="message-branch-previous"
493
+ disabled={totalBranches <= 1}
494
+ onClick={goToPrevious}
495
+ size="icon-sm"
496
+ type="button"
497
+ variant="ghost"
498
+ {...props}
499
+ >
500
+ {children ?? <ChevronLeftIcon size={14} data-rtl-flip />}
501
+ </Button>
502
+ );
503
+ };
504
+
505
+ export type MessageBranchNextProps = ComponentProps<typeof Button>;
506
+
507
+ export const MessageBranchNext = ({ children, ...props }: MessageBranchNextProps) => {
508
+ const { t } = useLocale();
509
+ const { goToNext, totalBranches } = useMessageBranch();
510
+
511
+ return (
512
+ <Button
513
+ aria-label={t("ai.message.nextBranch")}
514
+ data-slot="message-branch-next"
515
+ disabled={totalBranches <= 1}
516
+ onClick={goToNext}
517
+ size="icon-sm"
518
+ type="button"
519
+ variant="ghost"
520
+ {...props}
521
+ >
522
+ {children ?? <ChevronRightIcon size={14} data-rtl-flip />}
523
+ </Button>
524
+ );
525
+ };
526
+
527
+ export type MessageBranchPageProps = HTMLAttributes<HTMLSpanElement>;
528
+
529
+ export const MessageBranchPage = ({ className, ...props }: MessageBranchPageProps) => {
530
+ const { currentBranch, totalBranches } = useMessageBranch();
531
+
532
+ return (
533
+ <ButtonGroupText
534
+ className={cn("border-none bg-transparent text-muted-foreground shadow-none", className)}
535
+ data-slot="message-branch-page"
536
+ {...props}
537
+ >
538
+ {currentBranch + 1} of {totalBranches}
539
+ </ButtonGroupText>
540
+ );
541
+ };
542
+
543
+ export type MessageResponseProps = ComponentProps<typeof Streamdown> & {
544
+ /**
545
+ * No content has arrived yet (loading-states.md `loading`) — renders
546
+ * skeleton lines at the body line-height instead of `<Streamdown>`, so the
547
+ * bubble reserves its space before the first token.
548
+ * @default false
549
+ */
550
+ loading?: boolean;
551
+ };
552
+
553
+ export const MessageResponse = memo(
554
+ ({ className, loading = false, ...props }: MessageResponseProps) => {
555
+ // Streamdown renders its own chrome (code copy, table menus, Mermaid
556
+ // toolbar); route its labels through the locale seam (#310). Spread AFTER
557
+ // so an explicit `translations` prop still wins (ADR 0017 override chain).
558
+ const translations = useStreamdownTranslations();
559
+ const { t } = useLocale();
560
+ // Brand-token-derived `code` plugin, not the package's static github-*
561
+ // default (#315 follow-up) — re-derives when the active theme changes.
562
+ const plugins = useStreamdownPlugins();
563
+
564
+ if (loading) {
565
+ return (
566
+ <div
567
+ className={cn("space-y-2", className)}
568
+ data-slot="message-response"
569
+ role="status"
570
+ aria-live="polite"
571
+ >
572
+ <span className="sr-only">{t("loading")}</span>
573
+ {/* Fixed (not percentage) widths: MessageContent is `w-fit`, so a
574
+ skeleton row sized in `%` has nothing definite to shrink-wrap
575
+ around and collapses to zero width. */}
576
+ <Skeleton className="h-5 w-64" />
577
+ <Skeleton className="h-5 w-52" />
578
+ <Skeleton className="h-5 w-40" />
579
+ </div>
580
+ );
581
+ }
582
+
583
+ return (
584
+ <Streamdown
585
+ className={cn(
586
+ "size-full [&>*:first-child]:mt-0 [&>*:last-child]:mb-0",
587
+ // Brand prose scale (research 11 §B.1 MSG-3): map the streamed markdown
588
+ // element tree onto the 07 type roles. Descendant-selector className —
589
+ // NOT a `components` map — so streaming stays cheap.
590
+ "[&_h1]:text-title [&_h2]:text-subtitle [&_h3]:text-subtitle",
591
+ "[&_p]:text-body [&_li]:text-body [&_code]:text-code",
592
+ className,
593
+ )}
594
+ data-slot="message-response"
595
+ plugins={plugins}
596
+ translations={translations}
597
+ {...props}
598
+ />
599
+ );
600
+ },
601
+ (prevProps, nextProps) =>
602
+ prevProps.children === nextProps.children &&
603
+ nextProps.isAnimating === prevProps.isAnimating &&
604
+ nextProps.loading === prevProps.loading,
605
+ );
606
+
607
+ MessageResponse.displayName = "MessageResponse";
608
+
609
+ export type MessageToolbarProps = ComponentProps<"div">;
610
+
611
+ export const MessageToolbar = ({ className, children, ...props }: MessageToolbarProps) => (
612
+ <div
613
+ className={cn("mt-4 flex w-full items-center justify-between gap-4", className)}
614
+ data-slot="message-toolbar"
615
+ {...props}
616
+ >
617
+ {children}
618
+ </div>
619
+ );