@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,508 @@
1
+ import { useState } from "react";
2
+ import { describe, expect, it, vi } from "vitest";
3
+ import { render, screen } from "@testing-library/react";
4
+ import userEvent from "@testing-library/user-event";
5
+ import {
6
+ AgentMessage,
7
+ Message,
8
+ MessageAction,
9
+ MessageActions,
10
+ MessageAvatar,
11
+ MessageBranch,
12
+ MessageBranchContent,
13
+ MessageBranchNext,
14
+ MessageBranchPage,
15
+ MessageBranchPrevious,
16
+ MessageBranchSelector,
17
+ MessageContent,
18
+ MessageHeader,
19
+ MessageResponse,
20
+ UserMessage,
21
+ } from "./message";
22
+
23
+ describe("Message chat-user channel (#191, research 11 §B.1)", () => {
24
+ it("gives the user bubble the chat-user fill + foreground", () => {
25
+ render(
26
+ <Message from="user">
27
+ <MessageContent data-testid="content">Hi</MessageContent>
28
+ </Message>,
29
+ );
30
+ const content = screen.getByTestId("content");
31
+ expect(content.className).toContain("group-[.is-user]:bg-chat-user");
32
+ expect(content.className).toContain("group-[.is-user]:text-chat-user-foreground");
33
+ expect(content.className).not.toContain("bg-secondary");
34
+ });
35
+
36
+ it("drops the dead is-user:dark vendored class and uses the body role", () => {
37
+ render(
38
+ <Message from="assistant">
39
+ <MessageContent data-testid="content">Hello</MessageContent>
40
+ </Message>,
41
+ );
42
+ const content = screen.getByTestId("content");
43
+ expect(content.className).not.toContain("is-user:dark");
44
+ expect(content.className).toContain("text-body");
45
+ expect(content.className).not.toContain("text-sm");
46
+ });
47
+ });
48
+
49
+ describe("UserMessage / AgentMessage presets (#191, research 11 §B.2)", () => {
50
+ it("UserMessage is a Message from=user preset", () => {
51
+ render(
52
+ <UserMessage data-testid="msg">
53
+ <MessageContent>Hi</MessageContent>
54
+ </UserMessage>,
55
+ );
56
+ expect(screen.getByTestId("msg").className).toContain("is-user");
57
+ });
58
+
59
+ it("AgentMessage defaults to no rail", () => {
60
+ render(
61
+ <AgentMessage data-testid="msg">
62
+ <MessageContent>Working on it.</MessageContent>
63
+ </AgentMessage>,
64
+ );
65
+ const msg = screen.getByTestId("msg");
66
+ expect(msg.className).toContain("is-assistant");
67
+ expect(msg.className).not.toContain("border-s-primary");
68
+ });
69
+
70
+ it("AgentMessage emphasis=answer applies the green rail on the wrapper (not the clipped content)", () => {
71
+ render(
72
+ <AgentMessage data-testid="msg" emphasis="answer">
73
+ <MessageContent data-testid="content">Done.</MessageContent>
74
+ </AgentMessage>,
75
+ );
76
+ const msg = screen.getByTestId("msg");
77
+ expect(msg.className).toContain("border-s-4");
78
+ expect(msg.className).toContain("border-s-primary");
79
+ expect(screen.getByTestId("content").className).not.toContain("border-s-primary");
80
+ });
81
+ });
82
+
83
+ describe("MessageHeader / MessageAvatar slot (#191, research 11 §B.1 MSG-2)", () => {
84
+ it("renders the identity row with the meta role", () => {
85
+ render(
86
+ <AgentMessage>
87
+ <MessageHeader data-testid="header">
88
+ <MessageAvatar name="Atlas" role="user" />
89
+ <span>Atlas</span>
90
+ </MessageHeader>
91
+ <MessageContent>Hi</MessageContent>
92
+ </AgentMessage>,
93
+ );
94
+ expect(screen.getByTestId("header").className).toContain("text-meta");
95
+ // user avatar shows the text initial
96
+ expect(screen.getByText("A")).toBeInTheDocument();
97
+ expect(screen.getByText("Atlas")).toBeInTheDocument();
98
+ });
99
+ });
100
+
101
+ describe("MessageAvatar role=agent — branded Bot icon, not a text initial", () => {
102
+ it("renders a Bot icon (svg) instead of a text initial for role=agent", () => {
103
+ const { container } = render(
104
+ <AgentMessage>
105
+ <MessageHeader>
106
+ <MessageAvatar data-testid="agent-avatar" name="Atlas" role="agent" />
107
+ </MessageHeader>
108
+ <MessageContent>Working.</MessageContent>
109
+ </AgentMessage>,
110
+ );
111
+ const avatar = container.querySelector("[data-testid='agent-avatar']");
112
+ expect(avatar).not.toBeNull();
113
+ // Must NOT show the bare letter initial "A"
114
+ expect(screen.queryByText("A")).toBeNull();
115
+ // Must have an accessible label
116
+ expect(avatar?.getAttribute("aria-label")).toBe("Atlas (agent)");
117
+ // Must contain an SVG icon (the BotIcon) that is aria-hidden
118
+ const svg = avatar?.querySelector("svg[aria-hidden='true']");
119
+ expect(svg).not.toBeNull();
120
+ });
121
+
122
+ it("renders branded primary-green classes on the agent avatar", () => {
123
+ const { container } = render(
124
+ <MessageAvatar data-testid="agent-avatar" name="Atlas" role="agent" />,
125
+ );
126
+ const avatar = container.querySelector("[data-testid='agent-avatar']");
127
+ expect(avatar?.className).toContain("bg-primary");
128
+ expect(avatar?.className).toContain("text-primary-foreground");
129
+ });
130
+
131
+ it("uses a fallback accessible label when no name is given", () => {
132
+ const { container } = render(<MessageAvatar data-testid="agent-avatar" role="agent" />);
133
+ const avatar = container.querySelector("[data-testid='agent-avatar']");
134
+ expect(avatar?.getAttribute("aria-label")).toBe("Agent");
135
+ });
136
+
137
+ // #386 — Radix's `Avatar.Root` renders a plain `<span>` (implicit role
138
+ // `generic`), and ARIA PROHIBITS `aria-label` on `generic`: axe's
139
+ // `aria-prohibited-attr` rejected it and the name was never exposed. The name
140
+ // has to sit on an element permitted to carry one — `role="img"`, the same
141
+ // precedent a titled `@elabs-ai/components-icons` `Icon` uses
142
+ // (.claude/rules/icons.md). Deliberately NOT mocking `Avatar`: a stand-in that
143
+ // happens to honour `aria-label` masks exactly this bug (#34/#46).
144
+ it("exposes the agent avatar's name on an element allowed to carry one (role=img)", () => {
145
+ render(<MessageAvatar name="Atlas" role="agent" />);
146
+ expect(screen.getByRole("img", { name: "Atlas (agent)" })).toBeInTheDocument();
147
+ });
148
+
149
+ it("exposes the unnamed agent avatar's fallback name the same way", () => {
150
+ render(<MessageAvatar role="agent" />);
151
+ expect(screen.getByRole("img", { name: "Agent" })).toBeInTheDocument();
152
+ });
153
+
154
+ // The user branch carries no `aria-label` — its fallback initial is real text
155
+ // content, so a bare `<span>` is legitimate there and must NOT gain a role
156
+ // (an `img` per message would only add noise to a transcript).
157
+ it("leaves the user avatar as a plain container with no img role", () => {
158
+ render(<MessageAvatar name="Ada" role="user" />);
159
+ expect(screen.queryByRole("img")).toBeNull();
160
+ });
161
+ });
162
+
163
+ describe("MessageResponse loading (#269, loading-states.md)", () => {
164
+ it("renders a skeleton, single live region — not Streamdown — while loading", () => {
165
+ const { container } = render(<MessageResponse loading>{"## Hello"}</MessageResponse>);
166
+ // No markdown content rendered yet.
167
+ expect(screen.queryByText("Hello")).not.toBeInTheDocument();
168
+ // Exactly one status live region for the region, not one per skeleton box.
169
+ const statuses = container.querySelectorAll('[role="status"]');
170
+ expect(statuses).toHaveLength(1);
171
+ expect(statuses[0]).toHaveAttribute("aria-live", "polite");
172
+ // Skeleton boxes stay decorative.
173
+ const skeletons = container.querySelectorAll(".animate-pulse");
174
+ expect(skeletons.length).toBeGreaterThan(0);
175
+ for (const skeleton of skeletons) {
176
+ expect(skeleton).toHaveAttribute("aria-hidden", "true");
177
+ }
178
+ });
179
+
180
+ it("renders the real Streamdown content once loading clears", () => {
181
+ render(<MessageResponse>{"Hello world"}</MessageResponse>);
182
+ expect(screen.getByText("Hello world")).toBeInTheDocument();
183
+ });
184
+ });
185
+
186
+ describe("Message data-slot / data-role contract", () => {
187
+ it("marks the root and its parts with kebab-case data-slots", () => {
188
+ const { container } = render(
189
+ <Message from="assistant">
190
+ <MessageHeader>
191
+ <MessageAvatar name="Ada" role="agent" />
192
+ </MessageHeader>
193
+ <MessageContent>Hello</MessageContent>
194
+ </Message>,
195
+ );
196
+
197
+ expect(container.querySelector('[data-slot="message"]')).not.toBeNull();
198
+ expect(container.querySelector('[data-slot="message-header"]')).not.toBeNull();
199
+ expect(container.querySelector('[data-slot="message-avatar"]')).not.toBeNull();
200
+ expect(container.querySelector('[data-slot="message-content"]')).not.toBeNull();
201
+ });
202
+
203
+ it("carries data-role for both turns", () => {
204
+ const { container } = render(
205
+ <>
206
+ <Message from="user">
207
+ <MessageContent>Hi</MessageContent>
208
+ </Message>
209
+ <Message from="assistant">
210
+ <MessageContent>Hello</MessageContent>
211
+ </Message>
212
+ </>,
213
+ );
214
+
215
+ expect(container.querySelector('[data-slot="message"][data-role="user"]')).not.toBeNull();
216
+ expect(container.querySelector('[data-slot="message"][data-role="assistant"]')).not.toBeNull();
217
+ });
218
+
219
+ it("gives UserMessage/AgentMessage the SAME message root slot (they are presets)", () => {
220
+ // A consumer selector `[data-slot="message"]` must match all three entry
221
+ // points; `data-role` is what disambiguates them.
222
+ const { container } = render(
223
+ <>
224
+ <UserMessage>
225
+ <MessageContent>Hi</MessageContent>
226
+ </UserMessage>
227
+ <AgentMessage>
228
+ <MessageContent>Hello</MessageContent>
229
+ </AgentMessage>
230
+ </>,
231
+ );
232
+
233
+ expect(container.querySelectorAll('[data-slot="message"]')).toHaveLength(2);
234
+ expect(container.querySelector('[data-slot="message"][data-role="user"]')).not.toBeNull();
235
+ expect(container.querySelector('[data-slot="message"][data-role="assistant"]')).not.toBeNull();
236
+ });
237
+
238
+ it("keeps the is-user / is-assistant classes the group-[] selectors depend on", () => {
239
+ // The styling regression this refactor could have caused: `data-role` is the
240
+ // semantic twin of these classes, NOT a replacement. MessageContent's
241
+ // `group-[.is-user]:bg-chat-user` compiles to `.group.is-user &` — drop the
242
+ // class and the user fill silently disappears in every theme.
243
+ const { container } = render(
244
+ <Message from="user">
245
+ <MessageContent>Hi</MessageContent>
246
+ </Message>,
247
+ );
248
+
249
+ const root = container.querySelector('[data-slot="message"]');
250
+ expect(root?.className).toContain("is-user");
251
+ expect(root?.className).toContain("group");
252
+ });
253
+
254
+ it("lets a consumer override the slot", () => {
255
+ const { container } = render(
256
+ <Message data-slot="custom" from="user">
257
+ <MessageContent>Hi</MessageContent>
258
+ </Message>,
259
+ );
260
+ expect(container.querySelector('[data-slot="custom"]')).not.toBeNull();
261
+ });
262
+ });
263
+
264
+ function BranchHarness(props: { branch?: number; onBranchChange?: (n: number) => void }) {
265
+ return (
266
+ <MessageBranch {...props}>
267
+ <MessageBranchContent>
268
+ <div key="a">Branch A</div>
269
+ <div key="b">Branch B</div>
270
+ <div key="c">Branch C</div>
271
+ </MessageBranchContent>
272
+ <MessageBranchSelector>
273
+ <MessageBranchPrevious />
274
+ <MessageBranchPage />
275
+ <MessageBranchNext />
276
+ </MessageBranchSelector>
277
+ </MessageBranch>
278
+ );
279
+ }
280
+
281
+ describe("MessageBranch — controlled mode (#361)", () => {
282
+ it("is uncontrolled by default: defaultBranch + internal Next/Previous navigation are unaffected", async () => {
283
+ const onBranchChange = vi.fn();
284
+ const { container } = render(
285
+ <BranchHarness onBranchChange={onBranchChange} />, // no `branch` prop
286
+ );
287
+
288
+ expect(screen.getByText("1 of 3")).toBeInTheDocument();
289
+
290
+ await userEvent.click(screen.getByRole("button", { name: "Next branch" }));
291
+
292
+ // Internal state drove the move — MessageBranchPage reflects it directly,
293
+ // with no external `branch` prop involved.
294
+ expect(screen.getByText("2 of 3")).toBeInTheDocument();
295
+ expect(onBranchChange).toHaveBeenCalledWith(1);
296
+
297
+ const contentDivs = container.querySelectorAll('[data-slot="message-branch-content"]');
298
+ expect(contentDivs[1]?.className).toContain("block");
299
+ expect(contentDivs[0]?.className).toContain("hidden");
300
+ });
301
+
302
+ it("follows an externally-owned `branch` prop, with no `key` remount, and still fires onBranchChange", async () => {
303
+ const onBranchChange = vi.fn();
304
+
305
+ function ControlledHost() {
306
+ const [branch, setBranch] = useState(0);
307
+ return (
308
+ <div>
309
+ {/* Sibling, uncontrolled local state — proves MessageBranch isn't
310
+ being torn down and remounted via a changed `key` (the documented
311
+ uncontrolled-only workaround this issue removes the need for). */}
312
+ <input aria-label="sibling state" />
313
+ <button type="button" onClick={() => setBranch(2)}>
314
+ jump to branch 3
315
+ </button>
316
+ <BranchHarness
317
+ branch={branch}
318
+ onBranchChange={(n) => {
319
+ setBranch(n);
320
+ onBranchChange(n);
321
+ }}
322
+ />
323
+ </div>
324
+ );
325
+ }
326
+
327
+ const { container } = render(<ControlledHost />);
328
+
329
+ await userEvent.type(screen.getByLabelText("sibling state"), "keep me");
330
+
331
+ expect(screen.getByText("1 of 3")).toBeInTheDocument();
332
+
333
+ await userEvent.click(screen.getByRole("button", { name: "jump to branch 3" }));
334
+
335
+ expect(screen.getByText("3 of 3")).toBeInTheDocument();
336
+ const contentDivs = container.querySelectorAll('[data-slot="message-branch-content"]');
337
+ expect(contentDivs[2]?.className).toContain("block");
338
+ expect(contentDivs[0]?.className).toContain("hidden");
339
+
340
+ // The sibling's local state survived the branch change — no remount occurred.
341
+ expect(screen.getByLabelText("sibling state")).toHaveValue("keep me");
342
+
343
+ // Navigating with Previous still calls the host's updater (controlled mode
344
+ // never falls back to internal state).
345
+ await userEvent.click(screen.getByRole("button", { name: "Previous branch" }));
346
+ expect(onBranchChange).toHaveBeenLastCalledWith(1);
347
+ expect(screen.getByText("2 of 3")).toBeInTheDocument();
348
+ });
349
+ });
350
+
351
+ describe("MessageActions hover-reveal pill", () => {
352
+ it("defaults to the pre-existing inline row (no pill, no fade)", () => {
353
+ const { container } = render(
354
+ <Message from="assistant">
355
+ <MessageActions>
356
+ <MessageAction tooltip="Copy">
357
+ <span aria-hidden="true">C</span>
358
+ </MessageAction>
359
+ </MessageActions>
360
+ </Message>,
361
+ );
362
+ const row = container.querySelector('[data-slot="message-actions"]')!;
363
+ // Adopting the variants must not restyle any consumer that never sets them.
364
+ expect(row.className).not.toContain("opacity-0");
365
+ expect(row.className).not.toContain("rounded-full");
366
+ expect(row.className).not.toContain("shadow-ring-sm");
367
+ });
368
+
369
+ // The row acts on the bubble ABOVE it, so it has to travel with that bubble.
370
+ // A user turn is `ms-auto`'d right by `Message`; a row left at the column's
371
+ // cross-start renders under the PREVIOUS (assistant) message, where "Edit"
372
+ // reads as editing the agent's answer. Asserting the selector, not just that
373
+ // two class strings differ — the alignment is the whole point.
374
+ it("follows the user bubble to the right, in both appearances", () => {
375
+ for (const appearance of ["plain", "bar"] as const) {
376
+ const { container, unmount } = render(
377
+ <Message from="user">
378
+ <MessageContent>Hi</MessageContent>
379
+ <MessageActions appearance={appearance}>
380
+ <MessageAction tooltip="Copy">
381
+ <span aria-hidden="true">C</span>
382
+ </MessageAction>
383
+ </MessageActions>
384
+ </Message>,
385
+ );
386
+ const row = container.querySelector('[data-slot="message-actions"]')!;
387
+ // Same selector MessageContent uses for the --chat-user fill.
388
+ expect(row.className).toContain("group-[.is-user]:ms-auto");
389
+ unmount();
390
+ }
391
+ });
392
+
393
+ it("gives the bar appearance a ring-edged pill and NO border (ADR 0020)", () => {
394
+ const { container } = render(
395
+ <Message from="assistant">
396
+ <MessageActions appearance="bar">
397
+ <MessageAction tooltip="Copy">
398
+ <span aria-hidden="true">C</span>
399
+ </MessageAction>
400
+ </MessageActions>
401
+ </Message>,
402
+ );
403
+ const row = container.querySelector('[data-slot="message-actions"]')!;
404
+ expect(row.className).toContain("shadow-ring-sm");
405
+ expect(row.className).toContain("rounded-full");
406
+ // A floating surface takes the ring OR a border, never both — the double
407
+ // edge is exactly what `pnpm elevation:check` exists to stop.
408
+ expect(row.className).not.toMatch(/(^|\s)border(\s|$)/);
409
+ });
410
+
411
+ it("hides on hover-capable pointers only, and reveals on hover/focus/open menu", () => {
412
+ const { container } = render(
413
+ <Message from="assistant">
414
+ <MessageActions reveal="hover">
415
+ <MessageAction tooltip="Copy">
416
+ <span aria-hidden="true">C</span>
417
+ </MessageAction>
418
+ </MessageActions>
419
+ </Message>,
420
+ );
421
+ const row = container.querySelector('[data-slot="message-actions"]')!;
422
+ // The resting fade-out is gated on `pointer-fine` — an unprefixed
423
+ // `opacity-0` would strand touch users, who have no gesture that reveals it.
424
+ expect(row.className).toContain("pointer-fine:opacity-0");
425
+ expect(row.className).not.toMatch(/(^|\s)opacity-0(\s|$)/);
426
+ expect(row.className).toContain("pointer-fine:group-hover:opacity-100");
427
+ expect(row.className).toContain("pointer-fine:focus-within:opacity-100");
428
+ expect(row.className).toContain("pointer-fine:has-[[data-state=open]]:opacity-100");
429
+ expect(row.className).toContain("motion-reduce:transition-none");
430
+ });
431
+
432
+ it("keeps the controls focusable and named while faded out", async () => {
433
+ render(
434
+ <Message from="assistant">
435
+ <MessageActions appearance="bar" reveal="hover">
436
+ <MessageAction tooltip="Copy">
437
+ <span aria-hidden="true">C</span>
438
+ </MessageAction>
439
+ <MessageAction tooltip="Pin">
440
+ <span aria-hidden="true">P</span>
441
+ </MessageAction>
442
+ </MessageActions>
443
+ </Message>,
444
+ );
445
+ // The row fades; it is never `hidden`/unmounted. Tabbing must still reach
446
+ // both controls — that is what makes `focus-within` able to bring it back.
447
+ await userEvent.tab();
448
+ expect(screen.getByRole("button", { name: "Copy" })).toHaveFocus();
449
+ await userEvent.tab();
450
+ expect(screen.getByRole("button", { name: "Pin" })).toHaveFocus();
451
+ });
452
+
453
+ it("names the group, and lets the host override the name", () => {
454
+ const { rerender } = render(
455
+ <Message from="assistant">
456
+ <MessageActions>
457
+ <MessageAction tooltip="Copy">
458
+ <span aria-hidden="true">C</span>
459
+ </MessageAction>
460
+ </MessageActions>
461
+ </Message>,
462
+ );
463
+ expect(screen.getByRole("group", { name: "Message actions" })).toBeInTheDocument();
464
+
465
+ rerender(
466
+ <Message from="assistant">
467
+ <MessageActions label="Actions for Atlas's reply">
468
+ <MessageAction tooltip="Copy">
469
+ <span aria-hidden="true">C</span>
470
+ </MessageAction>
471
+ </MessageActions>
472
+ </Message>,
473
+ );
474
+ expect(screen.getByRole("group", { name: "Actions for Atlas's reply" })).toBeInTheDocument();
475
+ });
476
+
477
+ it("ships no behaviour — the host owns the click and any toggle state", async () => {
478
+ const onCopy = vi.fn();
479
+ function Host() {
480
+ const [pinned, setPinned] = useState(false);
481
+ return (
482
+ <Message from="assistant">
483
+ <MessageActions appearance="bar" reveal="hover">
484
+ <MessageAction onClick={onCopy} tooltip="Copy">
485
+ <span aria-hidden="true">C</span>
486
+ </MessageAction>
487
+ <MessageAction
488
+ aria-pressed={pinned}
489
+ onClick={() => setPinned((v) => !v)}
490
+ tooltip={pinned ? "Unpin" : "Pin"}
491
+ >
492
+ <span aria-hidden="true">P</span>
493
+ </MessageAction>
494
+ </MessageActions>
495
+ </Message>
496
+ );
497
+ }
498
+ render(<Host />);
499
+
500
+ await userEvent.click(screen.getByRole("button", { name: "Copy" }));
501
+ expect(onCopy).toHaveBeenCalledTimes(1);
502
+
503
+ const pin = screen.getByRole("button", { name: "Pin" });
504
+ expect(pin).toHaveAttribute("aria-pressed", "false");
505
+ await userEvent.click(pin);
506
+ expect(screen.getByRole("button", { name: "Unpin" })).toHaveAttribute("aria-pressed", "true");
507
+ });
508
+ });