@elabs-ai/components-ai 4.0.0 → 4.2.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/README.md +62 -12
  2. package/dist/{_audio-player-media-chrome-KA5DY54G.js → _audio-player-media-chrome-T3XVXWRZ.js} +8 -4
  3. package/dist/_audio-player-media-chrome-T3XVXWRZ.js.map +1 -0
  4. package/dist/{_flow-boundary-D63PJ65S.js → _flow-boundary-SHNWLQG5.js} +32 -43
  5. package/dist/_flow-boundary-SHNWLQG5.js.map +1 -0
  6. package/dist/{_persona-rive-RFR2EUWP.js → _persona-rive-JEG44YHX.js} +9 -5
  7. package/dist/_persona-rive-JEG44YHX.js.map +1 -0
  8. package/dist/index.d.ts +1152 -218
  9. package/dist/index.js +5601 -3241
  10. package/dist/index.js.map +1 -1
  11. package/package.json +30 -15
  12. package/src/__contract__/audio-visualizer.contract.test.tsx +49 -0
  13. package/src/__contract__/chat-shell.contract.test.tsx +49 -0
  14. package/src/__contract__/grouped-parts.contract.test.tsx +49 -0
  15. package/src/__contract__/image.contract.test.tsx +49 -0
  16. package/src/__contract__/markdown-view.contract.test.tsx +49 -0
  17. package/src/__contract__/message-feedback.contract.test.tsx +49 -0
  18. package/src/__contract__/message-form.contract.test.tsx +49 -0
  19. package/src/__contract__/message-table.contract.test.tsx +49 -0
  20. package/src/__contract__/model-provider-logo.contract.test.tsx +49 -0
  21. package/src/__contract__/persona.contract.test.tsx +49 -0
  22. package/src/__contract__/prompt-input-effort.contract.test.tsx +49 -0
  23. package/src/__contract__/prompt-input-mode.contract.test.tsx +49 -0
  24. package/src/_audio-player-media-chrome.tsx +102 -15
  25. package/src/_chat-shell-rail.tsx +2 -2
  26. package/src/_flow-boundary.tsx +68 -49
  27. package/src/_lazy-boundary-conformance.ts +38 -0
  28. package/src/_lazy-cjk.test.ts +43 -0
  29. package/src/_lazy-cjk.ts +73 -0
  30. package/src/_lazy-engine-boundary.tsx +61 -0
  31. package/src/_lazy-math.test.ts +63 -0
  32. package/src/_lazy-math.ts +90 -0
  33. package/src/_lazy-mermaid-absent.test.ts +53 -0
  34. package/src/_lazy-mermaid.test.ts +15 -0
  35. package/src/_lazy-mermaid.ts +24 -1
  36. package/src/_mermaid-error-panel.test.tsx +50 -0
  37. package/src/_mermaid-error-panel.tsx +66 -0
  38. package/src/_persona-rive.tsx +62 -10
  39. package/src/_streamdown-i18n.ts +94 -22
  40. package/src/_streamdown-safety.ts +170 -0
  41. package/src/_theme-scope-store.test.ts +83 -0
  42. package/src/_theme-scope-store.ts +103 -0
  43. package/src/agent-event.stories.tsx +97 -0
  44. package/src/agent-event.test.tsx +145 -0
  45. package/src/agent-event.tsx +187 -0
  46. package/src/agent-timeline.stories.tsx +9 -1
  47. package/src/agent.test.tsx +47 -0
  48. package/src/agent.tsx +43 -24
  49. package/src/agentic-workspace.stories.tsx +27 -20
  50. package/src/artifact.tsx +17 -14
  51. package/src/asset-preview.test.tsx +40 -0
  52. package/src/asset-preview.tsx +83 -12
  53. package/src/attachments.tsx +7 -4
  54. package/src/audio-player.test.tsx +78 -0
  55. package/src/audio-player.tsx +243 -56
  56. package/src/audio-visualizer.stories.tsx +126 -0
  57. package/src/audio-visualizer.test.tsx +438 -0
  58. package/src/audio-visualizer.tsx +367 -0
  59. package/src/canvas.stories.tsx +150 -1
  60. package/src/chat-shell.stories.tsx +18 -3
  61. package/src/chat.stories.tsx +16 -2
  62. package/src/code-block.stories.tsx +9 -1
  63. package/src/code-block.test.tsx +100 -1
  64. package/src/code-block.tsx +166 -103
  65. package/src/commit.tsx +30 -41
  66. package/src/composer.stories.tsx +372 -9
  67. package/src/composer.test.tsx +357 -6
  68. package/src/composer.tsx +217 -35
  69. package/src/confirmation.stories.tsx +72 -1
  70. package/src/confirmation.test.tsx +216 -2
  71. package/src/confirmation.tsx +264 -4
  72. package/src/context-panel.stories.tsx +9 -1
  73. package/src/context-panel.tsx +3 -2
  74. package/src/conversation.stories.tsx +86 -2
  75. package/src/conversation.test.tsx +66 -0
  76. package/src/conversation.tsx +52 -9
  77. package/src/diff-view.stories.tsx +196 -0
  78. package/src/diff-view.test.tsx +238 -0
  79. package/src/diff-view.tsx +697 -0
  80. package/src/environment-variables.tsx +20 -37
  81. package/src/file-tree.test.tsx +21 -0
  82. package/src/file-tree.tsx +12 -2
  83. package/src/gallery.tsx +4 -2
  84. package/src/index.ts +14 -4
  85. package/src/inline-citation.tsx +5 -5
  86. package/src/jsx-preview.stories.tsx +2 -2
  87. package/src/jsx-preview.tsx +151 -43
  88. package/src/markdown-view.stories.tsx +92 -1
  89. package/src/markdown-view.test.tsx +237 -2
  90. package/src/markdown-view.tsx +157 -6
  91. package/src/message-compare.stories.tsx +175 -0
  92. package/src/message-compare.test.tsx +207 -0
  93. package/src/message-compare.tsx +453 -0
  94. package/src/message-form.stories.tsx +65 -4
  95. package/src/message-form.test.tsx +8 -2
  96. package/src/message-form.tsx +15 -7
  97. package/src/message-table.stories.tsx +2 -2
  98. package/src/message-table.test.tsx +7 -0
  99. package/src/message-table.tsx +8 -4
  100. package/src/message.stories.tsx +9 -1
  101. package/src/message.test.tsx +247 -1
  102. package/src/message.tsx +125 -15
  103. package/src/microcopy.test.tsx +40 -0
  104. package/src/{model-selector.stories.tsx → model-provider-logo.stories.tsx} +17 -8
  105. package/src/model-provider-logo.test.tsx +109 -0
  106. package/src/model-provider-logo.tsx +183 -0
  107. package/src/open-in-chat.tsx +50 -29
  108. package/src/package-info.tsx +12 -12
  109. package/src/permission-mode-select.stories.tsx +82 -0
  110. package/src/permission-mode-select.test.tsx +100 -0
  111. package/src/permission-mode-select.tsx +137 -0
  112. package/src/persona-missing-peer.test.tsx +54 -0
  113. package/src/persona.tsx +68 -22
  114. package/src/plan.stories.tsx +166 -0
  115. package/src/plan.test.tsx +267 -0
  116. package/src/plan.tsx +182 -20
  117. package/src/prompt-input-effort.stories.tsx +123 -0
  118. package/src/prompt-input-effort.test.tsx +83 -0
  119. package/src/prompt-input-effort.tsx +136 -0
  120. package/src/prompt-input-mode.stories.tsx +108 -0
  121. package/src/prompt-input-mode.test.tsx +99 -0
  122. package/src/prompt-input-mode.tsx +169 -0
  123. package/src/prompt-input-slash.stories.tsx +211 -0
  124. package/src/prompt-input-slash.test.tsx +262 -0
  125. package/src/prompt-input-slash.tsx +541 -0
  126. package/src/prompt-input.stories.tsx +2 -2
  127. package/src/prompt-input.test.tsx +67 -1
  128. package/src/prompt-input.tsx +42 -4
  129. package/src/queue.tsx +4 -4
  130. package/src/reasoning.tsx +42 -17
  131. package/src/sandbox.stories.tsx +9 -1
  132. package/src/sandbox.tsx +3 -3
  133. package/src/schema-display.test.tsx +56 -0
  134. package/src/schema-display.tsx +71 -37
  135. package/src/selection-toolbar.stories.tsx +9 -1
  136. package/src/session-header.stories.tsx +128 -0
  137. package/src/session-header.test.tsx +138 -0
  138. package/src/session-header.tsx +243 -0
  139. package/src/session-status-bar.stories.tsx +73 -0
  140. package/src/session-status-bar.test.tsx +94 -0
  141. package/src/session-status-bar.tsx +165 -0
  142. package/src/snippet.stories.tsx +9 -1
  143. package/src/snippet.test.tsx +6 -2
  144. package/src/snippet.tsx +14 -34
  145. package/src/speech-input.test.tsx +109 -0
  146. package/src/speech-input.tsx +31 -3
  147. package/src/stack-trace.tsx +24 -36
  148. package/src/streamdown-i18n.test.tsx +24 -1
  149. package/src/task.stories.tsx +13 -3
  150. package/src/templates-ai-assistant.stories.tsx +21 -1
  151. package/src/test-results.tsx +47 -27
  152. package/src/token-usage.stories.tsx +24 -0
  153. package/src/token-usage.test.tsx +92 -0
  154. package/src/{context.tsx → token-usage.tsx} +73 -59
  155. package/src/tool-result-card.stories.tsx +9 -1
  156. package/src/tool.stories.tsx +13 -3
  157. package/src/tool.test.tsx +65 -0
  158. package/src/tool.tsx +73 -30
  159. package/src/transcription.tsx +1 -1
  160. package/src/turn-status.stories.tsx +124 -0
  161. package/src/turn-status.test.tsx +74 -0
  162. package/src/turn-status.tsx +174 -0
  163. package/src/use-audio-level.ts +104 -0
  164. package/src/voice-selector.tsx +5 -5
  165. package/src/web-preview.test.tsx +51 -1
  166. package/src/web-preview.tsx +46 -10
  167. package/dist/_audio-player-media-chrome-KA5DY54G.js.map +0 -1
  168. package/dist/_flow-boundary-D63PJ65S.js.map +0 -1
  169. package/dist/_interactive-terminal-xterm-ATJ5EW3G.js +0 -11
  170. package/dist/_interactive-terminal-xterm-ATJ5EW3G.js.map +0 -1
  171. package/dist/_persona-rive-RFR2EUWP.js.map +0 -1
  172. package/src/_interactive-terminal-xterm.ts +0 -32
  173. package/src/blocks-ai-composer.stories.tsx +0 -83
  174. package/src/context.stories.tsx +0 -16
  175. package/src/interactive-terminal.stories.tsx +0 -165
  176. package/src/interactive-terminal.test.tsx +0 -448
  177. package/src/interactive-terminal.tsx +0 -444
  178. package/src/model-selector.test.tsx +0 -55
  179. package/src/model-selector.tsx +0 -225
  180. package/src/terminal.tsx +0 -244
@@ -65,6 +65,13 @@ describe("MessageTable — never throws / fallback", () => {
65
65
  });
66
66
 
67
67
  it("renders skeleton rows while streaming with no rows yet", () => {
68
+ const { container } = render(
69
+ <MessageTable spec={{ columns: spec.columns, rows: [] }} isStreaming />,
70
+ );
71
+ expect(container.querySelector('[aria-hidden="true"] .animate-pulse')).not.toBeNull();
72
+ });
73
+
74
+ it("still honors the deprecated `streaming` alias", () => {
68
75
  const { container } = render(
69
76
  <MessageTable spec={{ columns: spec.columns, rows: [] }} streaming />,
70
77
  );
@@ -116,6 +116,8 @@ export interface MessageTableProps extends Omit<HTMLAttributes<HTMLDivElement>,
116
116
  /** Per-cell render override for custom composition. */
117
117
  renderCell?: RenderCell;
118
118
  /** The spec is still streaming (columns-but-no-rows shows skeleton rows). */
119
+ isStreaming?: boolean;
120
+ /** @deprecated Use `isStreaming`. */
119
121
  streaming?: boolean;
120
122
  }
121
123
 
@@ -145,12 +147,14 @@ export const MessageTable = forwardRef<HTMLDivElement, MessageTableProps>(functi
145
147
  onSortChange,
146
148
  maxRows,
147
149
  renderCell,
150
+ isStreaming,
148
151
  streaming = false,
149
152
  className,
150
153
  ...props
151
154
  },
152
155
  ref,
153
156
  ) {
157
+ const resolvedStreaming = isStreaming ?? streaming;
154
158
  const { t } = useLocale();
155
159
  const reactId = useId();
156
160
  const isControlled = sortProp !== undefined;
@@ -181,7 +185,7 @@ export const MessageTable = forwardRef<HTMLDivElement, MessageTableProps>(functi
181
185
 
182
186
  // No columns: skeleton while streaming, otherwise a fallback.
183
187
  if (columns.length === 0) {
184
- if (streaming) {
188
+ if (resolvedStreaming) {
185
189
  return (
186
190
  <div ref={ref} className={cn("w-full", className)} {...props}>
187
191
  <span className="sr-only" role="status" aria-live="polite">
@@ -256,7 +260,7 @@ export const MessageTable = forwardRef<HTMLDivElement, MessageTableProps>(functi
256
260
  </p>
257
261
  )}
258
262
  <div className="overflow-hidden rounded-md border border-border">
259
- {streaming && cappedRows.length === 0 && (
263
+ {resolvedStreaming && cappedRows.length === 0 && (
260
264
  <span className="sr-only" role="status" aria-live="polite">
261
265
  Loading table…
262
266
  </span>
@@ -264,7 +268,7 @@ export const MessageTable = forwardRef<HTMLDivElement, MessageTableProps>(functi
264
268
  <Table
265
269
  aria-labelledby={titleId}
266
270
  aria-label={title ? undefined : t("ai.messageTable.label")}
267
- aria-busy={(streaming && cappedRows.length === 0) || undefined}
271
+ aria-busy={(resolvedStreaming && cappedRows.length === 0) || undefined}
268
272
  >
269
273
  {truncatedCount > 0 && (
270
274
  <TableCaption className="mb-2 mt-3 px-3 text-caption">
@@ -322,7 +326,7 @@ export const MessageTable = forwardRef<HTMLDivElement, MessageTableProps>(functi
322
326
  </TableRow>
323
327
  </TableHeader>
324
328
  <TableBody>
325
- {cappedRows.length === 0 && streaming ? (
329
+ {cappedRows.length === 0 && resolvedStreaming ? (
326
330
  [0, 1, 2].map((i) => (
327
331
  <TableRow key={`skeleton-${i}`} aria-hidden="true">
328
332
  {columns.map((column) => (
@@ -28,7 +28,15 @@ import { SourceList } from "./sources";
28
28
  const meta = {
29
29
  title: "AI/Message",
30
30
  component: Message,
31
- parameters: { layout: "padded" },
31
+ parameters: {
32
+ layout: "padded",
33
+ docs: {
34
+ description: {
35
+ component:
36
+ "One turn of a CHAT transcript; one line of a CONSOLE transcript is `Terminal/TerminalTranscriptRow` — see [Choosing between similar components](?path=/docs/docs-choosing-between-similar-components--docs). `UserMessage` and `AgentMessage` are named presets over this same component and keep its `message` slot, so one consumer selector matches every entry point.",
37
+ },
38
+ },
39
+ },
32
40
  } satisfies Meta<typeof Message>;
33
41
  export default meta;
34
42
  type Story = StoryObj<typeof meta>;
@@ -1,6 +1,8 @@
1
+ import { math } from "@streamdown/math";
1
2
  import { useState } from "react";
3
+ import type { ComponentProps } from "react";
2
4
  import { describe, expect, it, vi } from "vitest";
3
- import { render, screen } from "@testing-library/react";
5
+ import { render, screen, waitFor } from "@testing-library/react";
4
6
  import userEvent from "@testing-library/user-event";
5
7
  import {
6
8
  AgentMessage,
@@ -183,6 +185,153 @@ describe("MessageResponse loading (#269, loading-states.md)", () => {
183
185
  });
184
186
  });
185
187
 
188
+ describe("MessageResponse memo comparator covers every prop, not a hand-picked field list (perf review §2)", () => {
189
+ it("re-renders when only `className` changes (children/isAnimating/loading held constant)", () => {
190
+ const { container, rerender } = render(
191
+ <MessageResponse className="response-a">{"same content"}</MessageResponse>,
192
+ );
193
+ // Streamdown's own root `<div>` does not forward `data-slot` (it spreads
194
+ // rest props onto the inner markdown renderer, not its wrapper div), so
195
+ // key off the fixed `size-full` class `MessageResponse` always applies.
196
+ const responseEl = () => container.querySelector(".size-full");
197
+ expect(responseEl()).toHaveClass("response-a");
198
+
199
+ rerender(<MessageResponse className="response-b">{"same content"}</MessageResponse>);
200
+
201
+ // Under the old comparator (children/isAnimating/loading only), this
202
+ // rerender was skipped entirely — the DOM kept `response-a` even though
203
+ // the caller supplied a new `className`.
204
+ expect(responseEl()).toHaveClass("response-b");
205
+ expect(responseEl()).not.toHaveClass("response-a");
206
+ });
207
+
208
+ it("re-renders when only `mode` changes (children/isAnimating/loading held constant)", () => {
209
+ // `mode="streaming"` (the default) leniently auto-closes incomplete
210
+ // markdown syntax; `mode="static"` renders it literally. A real,
211
+ // discriminating, upstream-independent signal for this prop.
212
+ const { container, rerender } = render(
213
+ <MessageResponse mode="streaming">{"**bold"}</MessageResponse>,
214
+ );
215
+ expect(container.querySelector('[data-streamdown="strong"]')).toBeInTheDocument();
216
+
217
+ rerender(<MessageResponse mode="static">{"**bold"}</MessageResponse>);
218
+
219
+ // Under the old comparator (children/isAnimating/loading only), this
220
+ // rerender was skipped, so the switch to `mode="static"` never took
221
+ // effect and the incomplete markdown stayed auto-completed.
222
+ expect(container.querySelector('[data-streamdown="strong"]')).not.toBeInTheDocument();
223
+ expect(screen.getByText("**bold")).toBeInTheDocument();
224
+ });
225
+ });
226
+
227
+ describe("MessageResponse plugins/components overrides (#10 — merge-not-replace semantics)", () => {
228
+ it("merges a real `plugins.cjk` override in (append), keeps sanitisation on, and keeps the untouched `plugins.math` default alive (#10)", async () => {
229
+ // A real, discriminating lock — NOT `plugins={{}}` (that exercises zero
230
+ // slots and passes identically under merge, replace, or a no-op; #10
231
+ // review I3). This test supplies a genuine `cjk` plugin (one of the two
232
+ // slots MessageResponse actually reaches — `code`/`mermaid`/`renderers` are
233
+ // consulted only inside Streamdown's OWN default `code` renderer, which
234
+ // `MessageResponse` always shadows) and proves BOTH halves of the
235
+ // merge property:
236
+ // 1. Streamdown's default rehypePlugins (raw → sanitize → harden)
237
+ // still hold — a <script> is stripped.
238
+ // 2. The supplied `cjk` plugin APPENDS (its remark transformer runs)
239
+ // *and* the internal `math` default the consumer did NOT set
240
+ // SURVIVES alongside it (`$$x^2$$` renders as real KaTeX, not
241
+ // literal text). A REPLACE implementation
242
+ // (`plugins = pluginOverrides`) would drop `math` — along with
243
+ // `code`/`mermaid` — the moment a consumer sets `cjk`, and this
244
+ // assertion would fail.
245
+ const cjkRemarkSpy = vi.fn(() => (tree: unknown) => tree);
246
+ const customCjkPlugin: NonNullable<
247
+ NonNullable<ComponentProps<typeof MessageResponse>["plugins"]>["cjk"]
248
+ > = {
249
+ name: "cjk",
250
+ remarkPlugins: [],
251
+ remarkPluginsAfter: [cjkRemarkSpy],
252
+ remarkPluginsBefore: [],
253
+ type: "cjk",
254
+ };
255
+ const UNSAFE_DOC = `# Note
256
+
257
+ <script>window.__pwned = true;</script>
258
+
259
+ $$x^2$$
260
+ `;
261
+ render(<MessageResponse plugins={{ cjk: customCjkPlugin }}>{UNSAFE_DOC}</MessageResponse>);
262
+
263
+ // (1) sanitisation still holds.
264
+ expect(document.querySelector("script")).not.toBeInTheDocument();
265
+ expect(document.body.textContent).not.toMatch(/pwned/);
266
+ // (2a) the supplied `cjk` plugin appended (its transformer ran)…
267
+ expect(cjkRemarkSpy).toHaveBeenCalled();
268
+ // (2b) …and the internal `math` default the consumer did not set is
269
+ // still active — real KaTeX markup, not the literal `$$x^2$$` text.
270
+ // `math` is now lazy-loaded off the source text (#perf-5) — it starts
271
+ // `undefined` and arrives after a dynamic import, hence `waitFor`.
272
+ await waitFor(
273
+ () => {
274
+ expect(document.querySelector(".katex")).toBeInTheDocument();
275
+ },
276
+ { timeout: 5000 },
277
+ );
278
+ });
279
+ });
280
+
281
+ describe("MessageResponse sanitiser is not overridable (#36)", () => {
282
+ it("ignores a caller-supplied rehypePlugins array (the sanitiser is not overridable)", () => {
283
+ // A rehype plugin that appends a <script> AFTER the pipeline. Under the bug the
284
+ // consumer array REPLACES [rehypeRaw, rehypeSanitize, harden], so nothing strips it.
285
+ const injectScript = () => (tree: { children: unknown[] }) => {
286
+ tree.children.push({
287
+ type: "element",
288
+ tagName: "script",
289
+ properties: {},
290
+ children: [{ type: "text", value: "globalThis.__pwned = true" }],
291
+ });
292
+ };
293
+ const { container } = render(
294
+ // `as any`: a JS consumer, an `any`, or a wider spread object still reaches
295
+ // this path even after the type-level `Omit`, so the assertion must exercise
296
+ // the RUNTIME strip, not the type. (`as never` doesn't typecheck as a JSX
297
+ // spread — TS2698, "Spread types may only be created from object types" —
298
+ // `any` is the cast the issue itself names as the bypass vector.)
299
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- deliberate: proves the runtime strip, not the type
300
+ <MessageResponse {...({ rehypePlugins: [injectScript] } as any)}>{"# hi"}</MessageResponse>,
301
+ );
302
+ expect(container.querySelector("script")).toBeNull();
303
+ });
304
+
305
+ it("SUPPORTS a caller-supplied remarkPlugins array, and still sanitises what it injects", () => {
306
+ // PR #74 review, round 1: `remarkPlugins` is NOT a sanitiser override. It runs
307
+ // upstream of [rehypeRaw, rehypeSanitize, harden], which Streamdown derives
308
+ // without reading it — so the prop stays supported (no `as any` below: the TYPE
309
+ // must accept it) while its output is still sanitised. Both halves are asserted,
310
+ // so re-adding it to the runtime strip fails, and dropping the rehype chain fails.
311
+ const ran = vi.fn();
312
+ const injectHostile = () => (tree: { children: unknown[] }) => {
313
+ ran();
314
+ tree.children.push({
315
+ type: "html",
316
+ value: '<script>globalThis.__pwned = true</script><img src="x" onerror="void 0">',
317
+ });
318
+ tree.children.push({
319
+ type: "paragraph",
320
+ data: { hName: "script", hChildren: [{ type: "text", value: "globalThis.__x = 1" }] },
321
+ children: [],
322
+ });
323
+ };
324
+ const { container } = render(
325
+ <MessageResponse remarkPlugins={[injectHostile]}>{"# hi"}</MessageResponse>,
326
+ );
327
+
328
+ expect(ran).toHaveBeenCalled(); // the prop really reached Streamdown
329
+ expect(container.querySelector("script")).toBeNull();
330
+ expect(container.querySelector("[onerror]")).toBeNull();
331
+ expect(document.body.textContent).not.toMatch(/pwned/);
332
+ });
333
+ });
334
+
186
335
  describe("Message data-slot / data-role contract", () => {
187
336
  it("marks the root and its parts with kebab-case data-slots", () => {
188
337
  const { container } = render(
@@ -278,6 +427,30 @@ function BranchHarness(props: { branch?: number; onBranchChange?: (n: number) =>
278
427
  );
279
428
  }
280
429
 
430
+ describe("MessageBranchContent — id uniqueness and content sync (perf review §2)", () => {
431
+ it("does not spread a caller-supplied id onto every branch (each branch keeps a unique id)", () => {
432
+ const { container } = render(
433
+ <MessageBranch>
434
+ <MessageBranchContent id="branch-content">
435
+ <div key="a">Branch A</div>
436
+ <div key="b">Branch B</div>
437
+ <div key="c">Branch C</div>
438
+ </MessageBranchContent>
439
+ </MessageBranch>,
440
+ );
441
+
442
+ const contentDivs = container.querySelectorAll('[data-slot="message-branch-content"]');
443
+ expect(contentDivs).toHaveLength(3);
444
+ const ids = Array.from(contentDivs).map((el) => el.getAttribute("id"));
445
+ // Under the bug, `{...props}` spread the literal `id="branch-content"`
446
+ // onto every mapped div — three duplicate DOM ids for one supplied id.
447
+ expect(new Set(ids).size).toBe(ids.length);
448
+ for (const id of ids) {
449
+ expect(id).toMatch(/^branch-content-\d+$/);
450
+ }
451
+ });
452
+ });
453
+
281
454
  describe("MessageBranch — controlled mode (#361)", () => {
282
455
  it("is uncontrolled by default: defaultBranch + internal Next/Previous navigation are unaffected", async () => {
283
456
  const onBranchChange = vi.fn();
@@ -506,3 +679,76 @@ describe("MessageActions hover-reveal pill", () => {
506
679
  expect(screen.getByRole("button", { name: "Unpin" })).toHaveAttribute("aria-pressed", "true");
507
680
  });
508
681
  });
682
+
683
+ describe("MessageResponse trusted plugin slots run after the sanitiser (#76)", () => {
684
+ // `plugins.math.rehypePlugin` is appended to the END of Streamdown's rehype
685
+ // pipeline, i.e. AFTER `rehype-raw` → `rehype-sanitize` → `rehype-harden`, so
686
+ // whatever it emits is never re-sanitised. That is a deliberate, documented
687
+ // trusted-code seam (a consumer who can supply an executable `Pluggable` can
688
+ // already run code in their own bundle) — but until #76 it had no runtime
689
+ // half at all, unlike `rehypePlugins`. These three tests are the runtime half:
690
+ // it WARNS (1), it does not become noise (2), and it stays OPEN (3).
691
+ const injectScript = () => (tree: { children: unknown[] }) => {
692
+ tree.children.push({
693
+ type: "element",
694
+ tagName: "script",
695
+ properties: {},
696
+ children: [{ type: "text", value: "globalThis.__pwned76 = true" }],
697
+ });
698
+ };
699
+ const evilMath = { ...math, rehypePlugin: injectScript };
700
+
701
+ it("warns when a consumer replaces plugins.math.rehypePlugin", () => {
702
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
703
+ try {
704
+ render(<MessageResponse plugins={{ math: evilMath }}>{"# hi"}</MessageResponse>);
705
+ const messages = warn.mock.calls.flat().join("\n");
706
+ expect(messages).toMatch(/math\.rehypePlugin/);
707
+ expect(messages).toMatch(/after/i);
708
+ } finally {
709
+ warn.mockRestore();
710
+ }
711
+ });
712
+
713
+ it("does not warn for the default plugin set or a safe slot override", () => {
714
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
715
+ try {
716
+ const { unmount } = render(<MessageResponse>{"# hi"}</MessageResponse>);
717
+ unmount();
718
+ // A `cjk` override is remark-stage only — its output is re-sanitised
719
+ // downstream, so it is NOT a trust boundary and must stay silent. This is
720
+ // the arm that stops the warning degrading into noise consumers learn to
721
+ // ignore.
722
+ const customCjkPlugin: NonNullable<
723
+ NonNullable<ComponentProps<typeof MessageResponse>["plugins"]>["cjk"]
724
+ > = {
725
+ name: "cjk",
726
+ remarkPlugins: [],
727
+ remarkPluginsAfter: [],
728
+ remarkPluginsBefore: [],
729
+ type: "cjk",
730
+ };
731
+ render(<MessageResponse plugins={{ cjk: customCjkPlugin }}>{"# hi"}</MessageResponse>);
732
+ expect(warn).not.toHaveBeenCalled();
733
+ } finally {
734
+ warn.mockRestore();
735
+ }
736
+ });
737
+
738
+ it("documents the boundary rather than closing it (the slot stays open)", () => {
739
+ // Counter-intuitive but load-bearing: this pins the deliberate trusted-code
740
+ // escape hatch OPEN. A future "hardening" that strips the slot would break a
741
+ // legitimate consumer (a real KaTeX/math plugin) silently — this fails loudly
742
+ // instead. The defence is the warning above plus the documented boundary,
743
+ // NOT removing the capability.
744
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
745
+ try {
746
+ const { container } = render(
747
+ <MessageResponse plugins={{ math: evilMath }}>{"# hi"}</MessageResponse>,
748
+ );
749
+ expect(container.querySelector("script")).not.toBeNull();
750
+ } finally {
751
+ warn.mockRestore();
752
+ }
753
+ });
754
+ });
package/src/message.tsx CHANGED
@@ -6,7 +6,13 @@ import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@elabs
6
6
  import { cn } from "@elabs-ai/components-ui/lib/cn";
7
7
  import { useLocale } from "@elabs-ai/components-ui";
8
8
  import { cva, type VariantProps } from "class-variance-authority";
9
- import { useStreamdownPlugins, useStreamdownTranslations } from "./_streamdown-i18n";
9
+ import { stripSanitizerOverrides, warnOnTrustedPluginSlots } from "./_streamdown-safety";
10
+ import {
11
+ getStreamdownPluginsKey,
12
+ useStreamdownMermaidOptions,
13
+ useStreamdownPlugins,
14
+ useStreamdownTranslations,
15
+ } from "./_streamdown-i18n";
10
16
  import type { UIMessage } from "ai";
11
17
  import { BotIcon, ChevronLeftIcon, ChevronRightIcon } from "lucide-react";
12
18
  import type { ComponentProps, HTMLAttributes, ReactElement } from "react";
@@ -428,19 +434,20 @@ export const MessageBranch = ({
428
434
 
429
435
  export type MessageBranchContentProps = HTMLAttributes<HTMLDivElement>;
430
436
 
431
- export const MessageBranchContent = ({ children, ...props }: MessageBranchContentProps) => {
432
- const { currentBranch, setBranches, branches } = useMessageBranch();
437
+ export const MessageBranchContent = ({ children, id, ...props }: MessageBranchContentProps) => {
438
+ const { currentBranch, setBranches } = useMessageBranch();
433
439
  const childrenArray = useMemo(
434
440
  () => (Array.isArray(children) ? children : [children]),
435
441
  [children],
436
442
  );
437
443
 
438
- // Use useEffect to update branches when they change
444
+ // Always resync an earlier length-gate (`branches.length !==
445
+ // childrenArray.length`) skipped this when a branch's CONTENT changed
446
+ // (e.g. an edited message) without the branch COUNT changing, leaving
447
+ // `context.branches` stale relative to what is actually rendered.
439
448
  useEffect(() => {
440
- if (branches.length !== childrenArray.length) {
441
- setBranches(childrenArray);
442
- }
443
- }, [childrenArray, branches, setBranches]);
449
+ setBranches(childrenArray);
450
+ }, [childrenArray, setBranches]);
444
451
 
445
452
  return childrenArray.map((branch, index) => (
446
453
  <div
@@ -449,6 +456,10 @@ export const MessageBranchContent = ({ children, ...props }: MessageBranchConten
449
456
  index === currentBranch ? "block" : "hidden",
450
457
  )}
451
458
  data-slot="message-branch-content"
459
+ // Spreading a caller-supplied `id` onto every mapped branch produced
460
+ // duplicate DOM ids (one per branch); suffix it per index instead so
461
+ // each branch keeps a unique, stable id.
462
+ id={id ? `${id}-${index}` : undefined}
452
463
  key={branch.key}
453
464
  {...props}
454
465
  >
@@ -540,7 +551,19 @@ export const MessageBranchPage = ({ className, ...props }: MessageBranchPageProp
540
551
  );
541
552
  };
542
553
 
543
- export type MessageResponseProps = ComponentProps<typeof Streamdown> & {
554
+ /**
555
+ * `rehypePlugins` is NOT exposed on `MessageResponse` (#36, fixed). This
556
+ * renders untrusted, streamed model output — the type-level `Omit` below plus
557
+ * a runtime `stripSanitizerOverrides()` call before the `{...props}` spread
558
+ * onto `<Streamdown>` keep Streamdown's default sanitiser chain (rehype-raw →
559
+ * rehype-sanitize → rehype-harden) non-overridable, even through a JS consumer
560
+ * or a force-cast.
561
+ *
562
+ * `remarkPlugins` IS supported: the remark stage runs upstream of that chain
563
+ * and cannot bypass it (PR #74 review, round 1). See `MarkdownView`'s TSDoc and
564
+ * `packages/ai/src/_streamdown-safety.ts` for the full security model.
565
+ */
566
+ export type MessageResponseProps = Omit<ComponentProps<typeof Streamdown>, "rehypePlugins"> & {
544
567
  /**
545
568
  * No content has arrived yet (loading-states.md `loading`) — renders
546
569
  * skeleton lines at the body line-height instead of `<Streamdown>`, so the
@@ -548,10 +571,58 @@ export type MessageResponseProps = ComponentProps<typeof Streamdown> & {
548
571
  * @default false
549
572
  */
550
573
  loading?: boolean;
574
+ /**
575
+ * Custom renderers for markdown elements. **Semantics differ from
576
+ * `MarkdownView`:** `components` here REPLACES (not merges) per key against
577
+ * Streamdown's own defaults, since `MessageResponse` has no internal
578
+ * component map to merge with. See `MarkdownView` for merge-over-defaults
579
+ * semantics and `#10` for details.
580
+ */
581
+ components?: ComponentProps<typeof Streamdown>["components"];
582
+ /**
583
+ * Streamdown plugin-slot overrides (`cjk`/`code`/`math`/`mermaid`/
584
+ * `renderers`), **merged** per key over the internal defaults, so a
585
+ * consumer's entry wins for its key; every key the consumer does not set
586
+ * keeps the reactive, i18n-aware internal default. Same semantics as
587
+ * `MarkdownView` (#10 fix round).
588
+ *
589
+ * **This narrow `plugins` prop can only APPEND — it can never displace
590
+ * Streamdown's default `rehypePlugins` chain** (`rehype-raw` →
591
+ * `rehype-sanitize` → `rehype-harden`). `MessageResponse` never sets
592
+ * `rehypePlugins` itself, and nothing in `PluginConfig` removes a member of
593
+ * that pipeline. But two of the five slots are **not** upstream of it and
594
+ * must be treated as TRUSTED CODE (#76):
595
+ * - **`math.rehypePlugin` runs AFTER `rehype-sanitize`/`rehype-harden`** —
596
+ * it is appended to the end of the rehype pipeline (verified against
597
+ * `streamdown@2.5.0`'s `dist/chunk-BO2N2NFS.js`), so its output is never
598
+ * re-sanitised. Replacing it emits a dev-only `console.warn`; the plugin
599
+ * still runs (the seam is deliberate, not a hole to close). The sharp
600
+ * edge in practice is KaTeX's `trust` option — it disables KaTeX's own
601
+ * sanitisation, so leave it off for model-authored content.
602
+ * - **`mermaid` never enters the rehype/remark pipeline at all.** Its
603
+ * render output is written via `dangerouslySetInnerHTML` (streamdown's
604
+ * only such sink); brand-ui's default pins mermaid's strict security
605
+ * level, and a replacement must sanitise its own SVG. Also dev-warned.
606
+ * - `cjk` (remark-stage, re-sanitised downstream), `code` (feeds
607
+ * `shikiTheme` only) and `renderers` (ordinary React components) do not
608
+ * bypass sanitisation and are never warned about.
609
+ *
610
+ * See `docs/CSP-AND-NETWORK.md` §1 and
611
+ * `packages/ai/src/_streamdown-safety.ts`; `MarkdownView`'s TSDoc carries
612
+ * the same model for the sibling surface.
613
+ */
614
+ plugins?: ComponentProps<typeof Streamdown>["plugins"];
551
615
  };
552
616
 
553
617
  export const MessageResponse = memo(
554
- ({ className, loading = false, ...props }: MessageResponseProps) => {
618
+ ({ className, loading = false, plugins: pluginOverrides, ...props }: MessageResponseProps) => {
619
+ // Streamdown installs [rehypeRaw, rehypeSanitize, harden] as the DEFAULT VALUE of
620
+ // `rehypePlugins`; a supplied array REPLACES it. This component renders untrusted,
621
+ // streamed model output, so the chain is not overridable. Widen with `allowedTags` /
622
+ // `literalTagContent`, which merge into the sanitize schema. See issue #36.
623
+ // `remarkPlugins` is deliberately left alone — it runs upstream of the rehype
624
+ // chain and cannot bypass it (PR #74 review; see `_streamdown-safety.ts`).
625
+ stripSanitizerOverrides(props);
555
626
  // Streamdown renders its own chrome (code copy, table menus, Mermaid
556
627
  // toolbar); route its labels through the locale seam (#310). Spread AFTER
557
628
  // so an explicit `translations` prop still wins (ADR 0017 override chain).
@@ -559,7 +630,32 @@ export const MessageResponse = memo(
559
630
  const { t } = useLocale();
560
631
  // Brand-token-derived `code` plugin, not the package's static github-*
561
632
  // default (#315 follow-up) — re-derives when the active theme changes.
562
- const plugins = useStreamdownPlugins();
633
+ // `math`/`cjk` are lazy-loaded off the raw markdown source (#perf-5, see
634
+ // `_streamdown-i18n.ts`) — pass `props.children` so they load only when
635
+ // this message actually needs them.
636
+ const internalPlugins = useStreamdownPlugins(
637
+ typeof props.children === "string" ? props.children : "",
638
+ );
639
+ // MERGED per key over the internal defaults — the same semantics as
640
+ // `MarkdownView`'s `plugins` prop (#10 fix round, M4): a consumer entry
641
+ // wins for its key; every key the consumer does not set keeps the
642
+ // reactive, i18n-aware internal default. Previously this component
643
+ // REPLACED `plugins` wholesale (`{...props}` spread after `plugins=`),
644
+ // the opposite of `MarkdownView` for the same prop on a sibling
645
+ // Streamdown surface — aligned so both components mean the same thing by
646
+ // "override a plugin slot".
647
+ // `math.rehypePlugin`/`mermaid` are the two slots that land in the DOM
648
+ // after (or outside) the sanitiser chain, so replacing one is a
649
+ // TRUSTED-CODE decision. The runtime half of that documented boundary is a
650
+ // dev warning — the plugin still runs; stripping it would break the
651
+ // legitimate use (#76).
652
+ const plugins = useMemo(() => {
653
+ warnOnTrustedPluginSlots(pluginOverrides, internalPlugins);
654
+ return { ...internalPlugins, ...pluginOverrides };
655
+ }, [internalPlugins, pluginOverrides]);
656
+ // The TOP-LEVEL Streamdown `mermaid` prop — catches a failed diagram
657
+ // render, including a missing `mermaid` optional peer (issue #33).
658
+ const mermaidOptions = useStreamdownMermaidOptions();
563
659
 
564
660
  if (loading) {
565
661
  return (
@@ -591,17 +687,31 @@ export const MessageResponse = memo(
591
687
  "[&_p]:text-body [&_li]:text-body [&_code]:text-code",
592
688
  className,
593
689
  )}
690
+ // Forces a remount the first time the lazy math/cjk slot resolves —
691
+ // see `getStreamdownPluginsKey`'s doc in `_streamdown-i18n.ts` for why.
692
+ key={getStreamdownPluginsKey(internalPlugins)}
594
693
  data-slot="message-response"
595
694
  plugins={plugins}
596
695
  translations={translations}
696
+ mermaid={mermaidOptions}
597
697
  {...props}
598
698
  />
599
699
  );
600
700
  },
601
- (prevProps, nextProps) =>
602
- prevProps.children === nextProps.children &&
603
- nextProps.isAnimating === prevProps.isAnimating &&
604
- nextProps.loading === prevProps.loading,
701
+ // A hand-picked field list (`children`/`isAnimating`/`loading`) silently
702
+ // went stale as `MessageResponseProps` grew: `className`, `components`,
703
+ // `plugins`, `mode` and every other `Streamdown` prop passed through
704
+ // `...props` were never compared, so a caller-driven change to any of them
705
+ // (e.g. toggling `mode="static"`/`"streaming"`, swapping `components`, or
706
+ // overriding a `plugins` slot) rendered stale content until an unrelated
707
+ // prop also changed. Compare every own key on BOTH sides with `Object.is`
708
+ // instead, so a newly added prop is covered for free.
709
+ (prevProps, nextProps) => {
710
+ const prevKeys = Object.keys(prevProps) as (keyof MessageResponseProps)[];
711
+ const nextKeys = Object.keys(nextProps) as (keyof MessageResponseProps)[];
712
+ if (prevKeys.length !== nextKeys.length) return false;
713
+ return prevKeys.every((key) => Object.is(prevProps[key], nextProps[key]));
714
+ },
605
715
  );
606
716
 
607
717
  MessageResponse.displayName = "MessageResponse";
@@ -2,9 +2,11 @@ import { describe, expect, it } from "vitest";
2
2
  import { render, screen } from "@testing-library/react";
3
3
  import { LocaleProvider } from "@elabs-ai/components-ui";
4
4
 
5
+ import { Artifact, ArtifactClose } from "./artifact";
5
6
  import { Composer } from "./composer";
6
7
  import { InlineCitationCard, InlineCitationCarouselNext } from "./inline-citation";
7
8
  import { Message, MessageBranch, MessageBranchNext } from "./message";
9
+ import { Plan, PlanTrigger } from "./plan";
8
10
  import { PromptInput, PromptInputBody, PromptInputTextarea } from "./prompt-input";
9
11
 
10
12
  /**
@@ -19,7 +21,9 @@ const de = {
19
21
  "ai.message.nextBranch": "Nächster Zweig",
20
22
  "ai.promptInput.placeholder": "Was möchtest du wissen?",
21
23
  "ai.promptInput.uploadFiles": "Dateien hochladen",
24
+ "ai.plan.togglePlan": "Plan umschalten",
22
25
  next: "Weiter",
26
+ close: "Schließen",
23
27
  };
24
28
 
25
29
  describe("microcopy — English defaults are unchanged", () => {
@@ -48,6 +52,20 @@ describe("microcopy — English defaults are unchanged", () => {
48
52
  );
49
53
  expect(screen.getByRole("button", { name: "Next branch" })).toBeInTheDocument();
50
54
  });
55
+
56
+ it("ArtifactClose keeps its sr-only 'Close' name (#18 — reuses the generic `close` key)", () => {
57
+ render(<ArtifactClose />);
58
+ expect(screen.getByRole("button", { name: "Close" })).toBeInTheDocument();
59
+ });
60
+
61
+ it("PlanTrigger keeps its sr-only 'Toggle plan' name (#18)", () => {
62
+ render(
63
+ <Plan>
64
+ <PlanTrigger />
65
+ </Plan>,
66
+ );
67
+ expect(screen.getByRole("button", { name: "Toggle plan" })).toBeInTheDocument();
68
+ });
51
69
  });
52
70
 
53
71
  describe("microcopy — a LocaleProvider overrides it", () => {
@@ -106,6 +124,28 @@ describe("microcopy — a LocaleProvider overrides it", () => {
106
124
  );
107
125
  expect(screen.getByPlaceholderText("Explicit wins")).toBeInTheDocument();
108
126
  });
127
+
128
+ it("translates ArtifactClose's sr-only name via the reused generic `close` key (#18)", () => {
129
+ render(
130
+ <LocaleProvider locale="de-DE" messages={de}>
131
+ <Artifact>
132
+ <ArtifactClose />
133
+ </Artifact>
134
+ </LocaleProvider>,
135
+ );
136
+ expect(screen.getByRole("button", { name: "Schließen" })).toBeInTheDocument();
137
+ });
138
+
139
+ it("translates PlanTrigger's sr-only name via the new `ai.plan.togglePlan` key (#18)", () => {
140
+ render(
141
+ <LocaleProvider locale="de-DE" messages={de}>
142
+ <Plan>
143
+ <PlanTrigger />
144
+ </Plan>
145
+ </LocaleProvider>,
146
+ );
147
+ expect(screen.getByRole("button", { name: "Plan umschalten" })).toBeInTheDocument();
148
+ });
109
149
  });
110
150
 
111
151
  describe("microcopy — no provider is required", () => {