@elabs-ai/components-ai 4.0.0 → 4.1.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 (128) hide show
  1. package/README.md +61 -11
  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 +1109 -202
  9. package/dist/index.js +4867 -2878
  10. package/dist/index.js.map +1 -1
  11. package/package.json +31 -16
  12. package/src/_audio-player-media-chrome.tsx +102 -15
  13. package/src/_flow-boundary.tsx +68 -49
  14. package/src/_lazy-boundary-conformance.ts +38 -0
  15. package/src/_lazy-engine-boundary.tsx +61 -0
  16. package/src/_lazy-mermaid-absent.test.ts +53 -0
  17. package/src/_lazy-mermaid.test.ts +15 -0
  18. package/src/_lazy-mermaid.ts +24 -1
  19. package/src/_mermaid-error-panel.test.tsx +50 -0
  20. package/src/_mermaid-error-panel.tsx +66 -0
  21. package/src/_persona-rive.tsx +62 -10
  22. package/src/_streamdown-i18n.ts +21 -1
  23. package/src/_streamdown-safety.ts +170 -0
  24. package/src/agent-event.stories.tsx +97 -0
  25. package/src/agent-event.test.tsx +145 -0
  26. package/src/agent-event.tsx +187 -0
  27. package/src/agent-timeline.stories.tsx +9 -1
  28. package/src/agent.test.tsx +47 -0
  29. package/src/agent.tsx +28 -14
  30. package/src/agentic-workspace.stories.tsx +27 -20
  31. package/src/artifact.tsx +15 -12
  32. package/src/audio-player.test.tsx +78 -0
  33. package/src/audio-player.tsx +243 -56
  34. package/src/audio-visualizer.stories.tsx +126 -0
  35. package/src/audio-visualizer.test.tsx +438 -0
  36. package/src/audio-visualizer.tsx +367 -0
  37. package/src/canvas.stories.tsx +150 -1
  38. package/src/chat-shell.stories.tsx +18 -3
  39. package/src/chat.stories.tsx +16 -2
  40. package/src/code-block.stories.tsx +9 -1
  41. package/src/composer.stories.tsx +372 -9
  42. package/src/composer.test.tsx +357 -6
  43. package/src/composer.tsx +217 -35
  44. package/src/confirmation.stories.tsx +72 -1
  45. package/src/confirmation.test.tsx +216 -2
  46. package/src/confirmation.tsx +263 -3
  47. package/src/context-panel.stories.tsx +9 -1
  48. package/src/context-panel.tsx +2 -1
  49. package/src/conversation.stories.tsx +63 -2
  50. package/src/conversation.test.tsx +13 -0
  51. package/src/conversation.tsx +20 -2
  52. package/src/diff-view.stories.tsx +196 -0
  53. package/src/diff-view.test.tsx +188 -0
  54. package/src/diff-view.tsx +642 -0
  55. package/src/gallery.tsx +4 -2
  56. package/src/index.ts +14 -4
  57. package/src/jsx-preview.stories.tsx +2 -2
  58. package/src/markdown-view.stories.tsx +92 -1
  59. package/src/markdown-view.test.tsx +232 -1
  60. package/src/markdown-view.tsx +150 -6
  61. package/src/message-compare.stories.tsx +175 -0
  62. package/src/message-compare.test.tsx +207 -0
  63. package/src/message-compare.tsx +453 -0
  64. package/src/message-form.stories.tsx +29 -1
  65. package/src/message.stories.tsx +9 -1
  66. package/src/message.test.tsx +176 -0
  67. package/src/message.tsx +90 -4
  68. package/src/microcopy.test.tsx +40 -0
  69. package/src/{model-selector.stories.tsx → model-provider-logo.stories.tsx} +17 -8
  70. package/src/{model-selector.test.tsx → model-provider-logo.test.tsx} +10 -10
  71. package/src/model-provider-logo.tsx +149 -0
  72. package/src/permission-mode-select.stories.tsx +82 -0
  73. package/src/permission-mode-select.test.tsx +100 -0
  74. package/src/permission-mode-select.tsx +137 -0
  75. package/src/persona-missing-peer.test.tsx +54 -0
  76. package/src/persona.tsx +68 -22
  77. package/src/plan.stories.tsx +166 -0
  78. package/src/plan.test.tsx +267 -0
  79. package/src/plan.tsx +182 -20
  80. package/src/prompt-input-effort.stories.tsx +123 -0
  81. package/src/prompt-input-effort.test.tsx +83 -0
  82. package/src/prompt-input-effort.tsx +136 -0
  83. package/src/prompt-input-mode.stories.tsx +108 -0
  84. package/src/prompt-input-mode.test.tsx +99 -0
  85. package/src/prompt-input-mode.tsx +169 -0
  86. package/src/prompt-input-slash.stories.tsx +211 -0
  87. package/src/prompt-input-slash.test.tsx +262 -0
  88. package/src/prompt-input-slash.tsx +541 -0
  89. package/src/prompt-input.stories.tsx +2 -2
  90. package/src/reasoning.tsx +27 -13
  91. package/src/sandbox.stories.tsx +9 -1
  92. package/src/schema-display.tsx +5 -2
  93. package/src/selection-toolbar.stories.tsx +9 -1
  94. package/src/session-header.stories.tsx +128 -0
  95. package/src/session-header.test.tsx +138 -0
  96. package/src/session-header.tsx +243 -0
  97. package/src/session-status-bar.stories.tsx +73 -0
  98. package/src/session-status-bar.test.tsx +94 -0
  99. package/src/session-status-bar.tsx +165 -0
  100. package/src/snippet.stories.tsx +9 -1
  101. package/src/stack-trace.tsx +6 -2
  102. package/src/streamdown-i18n.test.tsx +1 -1
  103. package/src/task.stories.tsx +13 -3
  104. package/src/templates-ai-assistant.stories.tsx +21 -1
  105. package/src/token-usage.stories.tsx +24 -0
  106. package/src/token-usage.test.tsx +92 -0
  107. package/src/{context.tsx → token-usage.tsx} +67 -53
  108. package/src/tool-result-card.stories.tsx +9 -1
  109. package/src/tool.stories.tsx +13 -3
  110. package/src/tool.tsx +15 -8
  111. package/src/turn-status.stories.tsx +124 -0
  112. package/src/turn-status.test.tsx +74 -0
  113. package/src/turn-status.tsx +174 -0
  114. package/src/use-audio-level.ts +104 -0
  115. package/src/web-preview.tsx +8 -3
  116. package/dist/_audio-player-media-chrome-KA5DY54G.js.map +0 -1
  117. package/dist/_flow-boundary-D63PJ65S.js.map +0 -1
  118. package/dist/_interactive-terminal-xterm-ATJ5EW3G.js +0 -11
  119. package/dist/_interactive-terminal-xterm-ATJ5EW3G.js.map +0 -1
  120. package/dist/_persona-rive-RFR2EUWP.js.map +0 -1
  121. package/src/_interactive-terminal-xterm.ts +0 -32
  122. package/src/blocks-ai-composer.stories.tsx +0 -83
  123. package/src/context.stories.tsx +0 -16
  124. package/src/interactive-terminal.stories.tsx +0 -165
  125. package/src/interactive-terminal.test.tsx +0 -448
  126. package/src/interactive-terminal.tsx +0 -444
  127. package/src/model-selector.tsx +0 -225
  128. package/src/terminal.tsx +0 -244
@@ -1,4 +1,6 @@
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
5
  import { render, screen } from "@testing-library/react";
4
6
  import userEvent from "@testing-library/user-event";
@@ -183,6 +185,107 @@ describe("MessageResponse loading (#269, loading-states.md)", () => {
183
185
  });
184
186
  });
185
187
 
188
+ describe("MessageResponse plugins/components overrides (#10 — merge-not-replace semantics)", () => {
189
+ it("merges a real `plugins.cjk` override in (append), keeps sanitisation on, and keeps the untouched `plugins.math` default alive (#10)", () => {
190
+ // A real, discriminating lock — NOT `plugins={{}}` (that exercises zero
191
+ // slots and passes identically under merge, replace, or a no-op; #10
192
+ // review I3). This test supplies a genuine `cjk` plugin (one of the two
193
+ // slots MessageResponse actually reaches — `code`/`mermaid`/`renderers` are
194
+ // consulted only inside Streamdown's OWN default `code` renderer, which
195
+ // `MessageResponse` always shadows) and proves BOTH halves of the
196
+ // merge property:
197
+ // 1. Streamdown's default rehypePlugins (raw → sanitize → harden)
198
+ // still hold — a <script> is stripped.
199
+ // 2. The supplied `cjk` plugin APPENDS (its remark transformer runs)
200
+ // *and* the internal `math` default the consumer did NOT set
201
+ // SURVIVES alongside it (`$$x^2$$` renders as real KaTeX, not
202
+ // literal text). A REPLACE implementation
203
+ // (`plugins = pluginOverrides`) would drop `math` — along with
204
+ // `code`/`mermaid` — the moment a consumer sets `cjk`, and this
205
+ // assertion would fail.
206
+ const cjkRemarkSpy = vi.fn(() => (tree: unknown) => tree);
207
+ const customCjkPlugin: NonNullable<
208
+ NonNullable<ComponentProps<typeof MessageResponse>["plugins"]>["cjk"]
209
+ > = {
210
+ name: "cjk",
211
+ remarkPlugins: [],
212
+ remarkPluginsAfter: [cjkRemarkSpy],
213
+ remarkPluginsBefore: [],
214
+ type: "cjk",
215
+ };
216
+ const UNSAFE_DOC = `# Note
217
+
218
+ <script>window.__pwned = true;</script>
219
+
220
+ $$x^2$$
221
+ `;
222
+ render(<MessageResponse plugins={{ cjk: customCjkPlugin }}>{UNSAFE_DOC}</MessageResponse>);
223
+
224
+ // (1) sanitisation still holds.
225
+ expect(document.querySelector("script")).not.toBeInTheDocument();
226
+ expect(document.body.textContent).not.toMatch(/pwned/);
227
+ // (2a) the supplied `cjk` plugin appended (its transformer ran)…
228
+ expect(cjkRemarkSpy).toHaveBeenCalled();
229
+ // (2b) …and the internal `math` default the consumer did not set is
230
+ // still active — real KaTeX markup, not the literal `$$x^2$$` text.
231
+ expect(document.querySelector(".katex")).toBeInTheDocument();
232
+ });
233
+ });
234
+
235
+ describe("MessageResponse sanitiser is not overridable (#36)", () => {
236
+ it("ignores a caller-supplied rehypePlugins array (the sanitiser is not overridable)", () => {
237
+ // A rehype plugin that appends a <script> AFTER the pipeline. Under the bug the
238
+ // consumer array REPLACES [rehypeRaw, rehypeSanitize, harden], so nothing strips it.
239
+ const injectScript = () => (tree: { children: unknown[] }) => {
240
+ tree.children.push({
241
+ type: "element",
242
+ tagName: "script",
243
+ properties: {},
244
+ children: [{ type: "text", value: "globalThis.__pwned = true" }],
245
+ });
246
+ };
247
+ const { container } = render(
248
+ // `as any`: a JS consumer, an `any`, or a wider spread object still reaches
249
+ // this path even after the type-level `Omit`, so the assertion must exercise
250
+ // the RUNTIME strip, not the type. (`as never` doesn't typecheck as a JSX
251
+ // spread — TS2698, "Spread types may only be created from object types" —
252
+ // `any` is the cast the issue itself names as the bypass vector.)
253
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- deliberate: proves the runtime strip, not the type
254
+ <MessageResponse {...({ rehypePlugins: [injectScript] } as any)}>{"# hi"}</MessageResponse>,
255
+ );
256
+ expect(container.querySelector("script")).toBeNull();
257
+ });
258
+
259
+ it("SUPPORTS a caller-supplied remarkPlugins array, and still sanitises what it injects", () => {
260
+ // PR #74 review, round 1: `remarkPlugins` is NOT a sanitiser override. It runs
261
+ // upstream of [rehypeRaw, rehypeSanitize, harden], which Streamdown derives
262
+ // without reading it — so the prop stays supported (no `as any` below: the TYPE
263
+ // must accept it) while its output is still sanitised. Both halves are asserted,
264
+ // so re-adding it to the runtime strip fails, and dropping the rehype chain fails.
265
+ const ran = vi.fn();
266
+ const injectHostile = () => (tree: { children: unknown[] }) => {
267
+ ran();
268
+ tree.children.push({
269
+ type: "html",
270
+ value: '<script>globalThis.__pwned = true</script><img src="x" onerror="void 0">',
271
+ });
272
+ tree.children.push({
273
+ type: "paragraph",
274
+ data: { hName: "script", hChildren: [{ type: "text", value: "globalThis.__x = 1" }] },
275
+ children: [],
276
+ });
277
+ };
278
+ const { container } = render(
279
+ <MessageResponse remarkPlugins={[injectHostile]}>{"# hi"}</MessageResponse>,
280
+ );
281
+
282
+ expect(ran).toHaveBeenCalled(); // the prop really reached Streamdown
283
+ expect(container.querySelector("script")).toBeNull();
284
+ expect(container.querySelector("[onerror]")).toBeNull();
285
+ expect(document.body.textContent).not.toMatch(/pwned/);
286
+ });
287
+ });
288
+
186
289
  describe("Message data-slot / data-role contract", () => {
187
290
  it("marks the root and its parts with kebab-case data-slots", () => {
188
291
  const { container } = render(
@@ -506,3 +609,76 @@ describe("MessageActions hover-reveal pill", () => {
506
609
  expect(screen.getByRole("button", { name: "Unpin" })).toHaveAttribute("aria-pressed", "true");
507
610
  });
508
611
  });
612
+
613
+ describe("MessageResponse trusted plugin slots run after the sanitiser (#76)", () => {
614
+ // `plugins.math.rehypePlugin` is appended to the END of Streamdown's rehype
615
+ // pipeline, i.e. AFTER `rehype-raw` → `rehype-sanitize` → `rehype-harden`, so
616
+ // whatever it emits is never re-sanitised. That is a deliberate, documented
617
+ // trusted-code seam (a consumer who can supply an executable `Pluggable` can
618
+ // already run code in their own bundle) — but until #76 it had no runtime
619
+ // half at all, unlike `rehypePlugins`. These three tests are the runtime half:
620
+ // it WARNS (1), it does not become noise (2), and it stays OPEN (3).
621
+ const injectScript = () => (tree: { children: unknown[] }) => {
622
+ tree.children.push({
623
+ type: "element",
624
+ tagName: "script",
625
+ properties: {},
626
+ children: [{ type: "text", value: "globalThis.__pwned76 = true" }],
627
+ });
628
+ };
629
+ const evilMath = { ...math, rehypePlugin: injectScript };
630
+
631
+ it("warns when a consumer replaces plugins.math.rehypePlugin", () => {
632
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
633
+ try {
634
+ render(<MessageResponse plugins={{ math: evilMath }}>{"# hi"}</MessageResponse>);
635
+ const messages = warn.mock.calls.flat().join("\n");
636
+ expect(messages).toMatch(/math\.rehypePlugin/);
637
+ expect(messages).toMatch(/after/i);
638
+ } finally {
639
+ warn.mockRestore();
640
+ }
641
+ });
642
+
643
+ it("does not warn for the default plugin set or a safe slot override", () => {
644
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
645
+ try {
646
+ const { unmount } = render(<MessageResponse>{"# hi"}</MessageResponse>);
647
+ unmount();
648
+ // A `cjk` override is remark-stage only — its output is re-sanitised
649
+ // downstream, so it is NOT a trust boundary and must stay silent. This is
650
+ // the arm that stops the warning degrading into noise consumers learn to
651
+ // ignore.
652
+ const customCjkPlugin: NonNullable<
653
+ NonNullable<ComponentProps<typeof MessageResponse>["plugins"]>["cjk"]
654
+ > = {
655
+ name: "cjk",
656
+ remarkPlugins: [],
657
+ remarkPluginsAfter: [],
658
+ remarkPluginsBefore: [],
659
+ type: "cjk",
660
+ };
661
+ render(<MessageResponse plugins={{ cjk: customCjkPlugin }}>{"# hi"}</MessageResponse>);
662
+ expect(warn).not.toHaveBeenCalled();
663
+ } finally {
664
+ warn.mockRestore();
665
+ }
666
+ });
667
+
668
+ it("documents the boundary rather than closing it (the slot stays open)", () => {
669
+ // Counter-intuitive but load-bearing: this pins the deliberate trusted-code
670
+ // escape hatch OPEN. A future "hardening" that strips the slot would break a
671
+ // legitimate consumer (a real KaTeX/math plugin) silently — this fails loudly
672
+ // instead. The defence is the warning above plus the documented boundary,
673
+ // NOT removing the capability.
674
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
675
+ try {
676
+ const { container } = render(
677
+ <MessageResponse plugins={{ math: evilMath }}>{"# hi"}</MessageResponse>,
678
+ );
679
+ expect(container.querySelector("script")).not.toBeNull();
680
+ } finally {
681
+ warn.mockRestore();
682
+ }
683
+ });
684
+ });
package/src/message.tsx CHANGED
@@ -6,7 +6,12 @@ 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
+ useStreamdownMermaidOptions,
12
+ useStreamdownPlugins,
13
+ useStreamdownTranslations,
14
+ } from "./_streamdown-i18n";
10
15
  import type { UIMessage } from "ai";
11
16
  import { BotIcon, ChevronLeftIcon, ChevronRightIcon } from "lucide-react";
12
17
  import type { ComponentProps, HTMLAttributes, ReactElement } from "react";
@@ -540,7 +545,19 @@ export const MessageBranchPage = ({ className, ...props }: MessageBranchPageProp
540
545
  );
541
546
  };
542
547
 
543
- export type MessageResponseProps = ComponentProps<typeof Streamdown> & {
548
+ /**
549
+ * `rehypePlugins` is NOT exposed on `MessageResponse` (#36, fixed). This
550
+ * renders untrusted, streamed model output — the type-level `Omit` below plus
551
+ * a runtime `stripSanitizerOverrides()` call before the `{...props}` spread
552
+ * onto `<Streamdown>` keep Streamdown's default sanitiser chain (rehype-raw →
553
+ * rehype-sanitize → rehype-harden) non-overridable, even through a JS consumer
554
+ * or a force-cast.
555
+ *
556
+ * `remarkPlugins` IS supported: the remark stage runs upstream of that chain
557
+ * and cannot bypass it (PR #74 review, round 1). See `MarkdownView`'s TSDoc and
558
+ * `packages/ai/src/_streamdown-safety.ts` for the full security model.
559
+ */
560
+ export type MessageResponseProps = Omit<ComponentProps<typeof Streamdown>, "rehypePlugins"> & {
544
561
  /**
545
562
  * No content has arrived yet (loading-states.md `loading`) — renders
546
563
  * skeleton lines at the body line-height instead of `<Streamdown>`, so the
@@ -548,10 +565,58 @@ export type MessageResponseProps = ComponentProps<typeof Streamdown> & {
548
565
  * @default false
549
566
  */
550
567
  loading?: boolean;
568
+ /**
569
+ * Custom renderers for markdown elements. **Semantics differ from
570
+ * `MarkdownView`:** `components` here REPLACES (not merges) per key against
571
+ * Streamdown's own defaults, since `MessageResponse` has no internal
572
+ * component map to merge with. See `MarkdownView` for merge-over-defaults
573
+ * semantics and `#10` for details.
574
+ */
575
+ components?: ComponentProps<typeof Streamdown>["components"];
576
+ /**
577
+ * Streamdown plugin-slot overrides (`cjk`/`code`/`math`/`mermaid`/
578
+ * `renderers`), **merged** per key over the internal defaults, so a
579
+ * consumer's entry wins for its key; every key the consumer does not set
580
+ * keeps the reactive, i18n-aware internal default. Same semantics as
581
+ * `MarkdownView` (#10 fix round).
582
+ *
583
+ * **This narrow `plugins` prop can only APPEND — it can never displace
584
+ * Streamdown's default `rehypePlugins` chain** (`rehype-raw` →
585
+ * `rehype-sanitize` → `rehype-harden`). `MessageResponse` never sets
586
+ * `rehypePlugins` itself, and nothing in `PluginConfig` removes a member of
587
+ * that pipeline. But two of the five slots are **not** upstream of it and
588
+ * must be treated as TRUSTED CODE (#76):
589
+ * - **`math.rehypePlugin` runs AFTER `rehype-sanitize`/`rehype-harden`** —
590
+ * it is appended to the end of the rehype pipeline (verified against
591
+ * `streamdown@2.5.0`'s `dist/chunk-BO2N2NFS.js`), so its output is never
592
+ * re-sanitised. Replacing it emits a dev-only `console.warn`; the plugin
593
+ * still runs (the seam is deliberate, not a hole to close). The sharp
594
+ * edge in practice is KaTeX's `trust` option — it disables KaTeX's own
595
+ * sanitisation, so leave it off for model-authored content.
596
+ * - **`mermaid` never enters the rehype/remark pipeline at all.** Its
597
+ * render output is written via `dangerouslySetInnerHTML` (streamdown's
598
+ * only such sink); brand-ui's default pins mermaid's strict security
599
+ * level, and a replacement must sanitise its own SVG. Also dev-warned.
600
+ * - `cjk` (remark-stage, re-sanitised downstream), `code` (feeds
601
+ * `shikiTheme` only) and `renderers` (ordinary React components) do not
602
+ * bypass sanitisation and are never warned about.
603
+ *
604
+ * See `docs/CSP-AND-NETWORK.md` §1 and
605
+ * `packages/ai/src/_streamdown-safety.ts`; `MarkdownView`'s TSDoc carries
606
+ * the same model for the sibling surface.
607
+ */
608
+ plugins?: ComponentProps<typeof Streamdown>["plugins"];
551
609
  };
552
610
 
553
611
  export const MessageResponse = memo(
554
- ({ className, loading = false, ...props }: MessageResponseProps) => {
612
+ ({ className, loading = false, plugins: pluginOverrides, ...props }: MessageResponseProps) => {
613
+ // Streamdown installs [rehypeRaw, rehypeSanitize, harden] as the DEFAULT VALUE of
614
+ // `rehypePlugins`; a supplied array REPLACES it. This component renders untrusted,
615
+ // streamed model output, so the chain is not overridable. Widen with `allowedTags` /
616
+ // `literalTagContent`, which merge into the sanitize schema. See issue #36.
617
+ // `remarkPlugins` is deliberately left alone — it runs upstream of the rehype
618
+ // chain and cannot bypass it (PR #74 review; see `_streamdown-safety.ts`).
619
+ stripSanitizerOverrides(props);
555
620
  // Streamdown renders its own chrome (code copy, table menus, Mermaid
556
621
  // toolbar); route its labels through the locale seam (#310). Spread AFTER
557
622
  // so an explicit `translations` prop still wins (ADR 0017 override chain).
@@ -559,7 +624,27 @@ export const MessageResponse = memo(
559
624
  const { t } = useLocale();
560
625
  // Brand-token-derived `code` plugin, not the package's static github-*
561
626
  // default (#315 follow-up) — re-derives when the active theme changes.
562
- const plugins = useStreamdownPlugins();
627
+ const internalPlugins = useStreamdownPlugins();
628
+ // MERGED per key over the internal defaults — the same semantics as
629
+ // `MarkdownView`'s `plugins` prop (#10 fix round, M4): a consumer entry
630
+ // wins for its key; every key the consumer does not set keeps the
631
+ // reactive, i18n-aware internal default. Previously this component
632
+ // REPLACED `plugins` wholesale (`{...props}` spread after `plugins=`),
633
+ // the opposite of `MarkdownView` for the same prop on a sibling
634
+ // Streamdown surface — aligned so both components mean the same thing by
635
+ // "override a plugin slot".
636
+ // `math.rehypePlugin`/`mermaid` are the two slots that land in the DOM
637
+ // after (or outside) the sanitiser chain, so replacing one is a
638
+ // TRUSTED-CODE decision. The runtime half of that documented boundary is a
639
+ // dev warning — the plugin still runs; stripping it would break the
640
+ // legitimate use (#76).
641
+ const plugins = useMemo(() => {
642
+ warnOnTrustedPluginSlots(pluginOverrides, internalPlugins);
643
+ return { ...internalPlugins, ...pluginOverrides };
644
+ }, [internalPlugins, pluginOverrides]);
645
+ // The TOP-LEVEL Streamdown `mermaid` prop — catches a failed diagram
646
+ // render, including a missing `mermaid` optional peer (issue #33).
647
+ const mermaidOptions = useStreamdownMermaidOptions();
563
648
 
564
649
  if (loading) {
565
650
  return (
@@ -594,6 +679,7 @@ export const MessageResponse = memo(
594
679
  data-slot="message-response"
595
680
  plugins={plugins}
596
681
  translations={translations}
682
+ mermaid={mermaidOptions}
597
683
  {...props}
598
684
  />
599
685
  );
@@ -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", () => {
@@ -1,30 +1,39 @@
1
1
  /**
2
- * ModelSelectora command-palette model picker.
2
+ * ModelProviderLogothe mark of an AI model provider, sized for a list row.
3
3
  *
4
- * `ModelSelectorLogo` fetches provider marks from a **remote origin**
4
+ * It fetches provider marks from a **remote origin**
5
5
  * (`models.dev`) by default, so a restrictive `img-src` blocks them. Pass `src`
6
6
  * to self-host, or `fallback` to control what a blocked load renders — it is
7
7
  * never a broken-image glyph. See `docs/CSP-AND-NETWORK.md`.
8
8
  */
9
9
  import type { Meta, StoryObj } from "@storybook/react-vite";
10
10
 
11
- import { ModelSelectorLogo } from "./model-selector";
11
+ import { ModelProviderLogo } from "./model-provider-logo";
12
12
 
13
13
  const meta = {
14
- title: "AI/ModelSelectorLogo",
15
- component: ModelSelectorLogo,
14
+ title: "AI/ModelProviderLogo",
15
+ component: ModelProviderLogo,
16
16
  parameters: {
17
17
  layout: "centered",
18
18
  docs: {
19
19
  description: {
20
20
  component:
21
- "Provider logo for the model selector. Defaults to fetching from models.dev; pass `src` for a self-hosted asset and `fallback` for the blocked/offline case. A failed load renders a neutral glyph, never a broken image.",
21
+ "The mark of an AI model provider, sized for a row in a model list. Defaults to " +
22
+ "fetching from models.dev; pass `src` for a self-hosted asset and `fallback` for the " +
23
+ "blocked/offline case. A failed load renders a neutral glyph, never a broken image.\n\n" +
24
+ "**`ModelProviderLogo` vs `ServiceLogo` (`@elabs-ai/components-icons`).** An AI " +
25
+ "provider mark with a zero-config remote default → this component. Any other " +
26
+ "third-party service mark, resolved from a registry the app supplies and never " +
27
+ "fetched → `ServiceLogo`.\n\n" +
28
+ "For the list this logo sits in: an inline pill in a composer footer → `ModelPicker` " +
29
+ "(`@elabs-ai/components-ui`); a full ⌘K palette → compose `CommandDialog` + " +
30
+ "`Command*` from `@elabs-ai/components-ui` directly.",
22
31
  },
23
32
  },
24
33
  },
25
34
  args: { provider: "anthropic" },
26
35
  tags: ["autodocs"],
27
- } satisfies Meta<typeof ModelSelectorLogo>;
36
+ } satisfies Meta<typeof ModelProviderLogo>;
28
37
 
29
38
  export default meta;
30
39
  type Story = StoryObj<typeof meta>;
@@ -64,7 +73,7 @@ export const LiveRemoteLogos: Story = {
64
73
  <div className="flex items-center gap-4">
65
74
  {(["anthropic", "openai", "google", "mistral"] as const).map((provider) => (
66
75
  <figure className="flex flex-col items-center gap-2" key={provider}>
67
- <ModelSelectorLogo provider={provider} />
76
+ <ModelProviderLogo provider={provider} />
68
77
  <figcaption className="text-meta text-muted-foreground">{provider}</figcaption>
69
78
  </figure>
70
79
  ))}
@@ -1,21 +1,21 @@
1
1
  import { describe, expect, it, vi } from "vitest";
2
2
  import { fireEvent, render, screen } from "@testing-library/react";
3
3
 
4
- import { MODEL_SELECTOR_LOGO_BASE_URL, ModelSelectorLogo } from "./model-selector";
4
+ import { MODEL_PROVIDER_LOGO_BASE_URL, ModelProviderLogo } from "./model-provider-logo";
5
5
 
6
- describe("ModelSelectorLogo — remote origin escape hatches", () => {
6
+ describe("ModelProviderLogo — remote origin escape hatches", () => {
7
7
  it("defaults to the models.dev logo for the provider", () => {
8
- render(<ModelSelectorLogo provider="anthropic" />);
8
+ render(<ModelProviderLogo provider="anthropic" />);
9
9
  expect(screen.getByRole("img", { name: "anthropic logo" })).toHaveAttribute(
10
10
  "src",
11
- `${MODEL_SELECTOR_LOGO_BASE_URL}/anthropic.svg`,
11
+ `${MODEL_PROVIDER_LOGO_BASE_URL}/anthropic.svg`,
12
12
  );
13
13
  });
14
14
 
15
15
  it("lets a caller override src for a self-hosted asset", () => {
16
16
  // The old props type did `Omit<…, "src">` AND spread props before `src=`, so
17
17
  // a self-hosting consumer had no way to win. Both are fixed.
18
- render(<ModelSelectorLogo provider="anthropic" src="/local/anthropic.svg" />);
18
+ render(<ModelProviderLogo provider="anthropic" src="/local/anthropic.svg" />);
19
19
  expect(screen.getByRole("img", { name: "anthropic logo" })).toHaveAttribute(
20
20
  "src",
21
21
  "/local/anthropic.svg",
@@ -23,7 +23,7 @@ describe("ModelSelectorLogo — remote origin escape hatches", () => {
23
23
  });
24
24
 
25
25
  it("renders a neutral glyph instead of a broken image when the load fails", () => {
26
- render(<ModelSelectorLogo provider="anthropic" />);
26
+ render(<ModelProviderLogo provider="anthropic" />);
27
27
  fireEvent.error(screen.getByRole("img", { name: "anthropic logo" }));
28
28
 
29
29
  const fallback = screen.getByRole("img", { name: "anthropic logo" });
@@ -32,24 +32,24 @@ describe("ModelSelectorLogo — remote origin escape hatches", () => {
32
32
  });
33
33
 
34
34
  it("renders a caller-supplied fallback when provided", () => {
35
- render(<ModelSelectorLogo provider="anthropic" fallback={<span data-testid="fb">AI</span>} />);
35
+ render(<ModelProviderLogo provider="anthropic" fallback={<span data-testid="fb">AI</span>} />);
36
36
  fireEvent.error(screen.getByRole("img", { name: "anthropic logo" }));
37
37
  expect(screen.getByTestId("fb")).toBeInTheDocument();
38
38
  });
39
39
 
40
40
  it("still calls a caller's onError", () => {
41
41
  const onError = vi.fn();
42
- render(<ModelSelectorLogo provider="anthropic" onError={onError} />);
42
+ render(<ModelProviderLogo provider="anthropic" onError={onError} />);
43
43
  fireEvent.error(screen.getByRole("img", { name: "anthropic logo" }));
44
44
  expect(onError).toHaveBeenCalledTimes(1);
45
45
  });
46
46
 
47
47
  it("keeps an accessible name in both states", () => {
48
- const { rerender } = render(<ModelSelectorLogo provider="openai" />);
48
+ const { rerender } = render(<ModelProviderLogo provider="openai" />);
49
49
  expect(screen.getByRole("img", { name: "openai logo" })).toBeInTheDocument();
50
50
 
51
51
  fireEvent.error(screen.getByRole("img", { name: "openai logo" }));
52
- rerender(<ModelSelectorLogo provider="openai" />);
52
+ rerender(<ModelProviderLogo provider="openai" />);
53
53
  expect(screen.getByRole("img", { name: "openai logo" })).toBeInTheDocument();
54
54
  });
55
55
  });
@@ -0,0 +1,149 @@
1
+ import { cn } from "@elabs-ai/components-ui/lib/cn";
2
+ import { BotIcon } from "lucide-react";
3
+ import type { ComponentProps, ReactNode } from "react";
4
+ import { useState } from "react";
5
+
6
+ /**
7
+ * Where provider logos are fetched from by default.
8
+ *
9
+ * This is a REMOTE origin: a deployment with a restrictive `img-src` blocks it.
10
+ * Self-host the SVGs and pass `src` (or set a `fallback`) — see
11
+ * `docs/CSP-AND-NETWORK.md`.
12
+ */
13
+ export const MODEL_PROVIDER_LOGO_BASE_URL = "https://models.dev/logos";
14
+
15
+ export type ModelProviderLogoProps = Omit<ComponentProps<"img">, "alt"> & {
16
+ /**
17
+ * Override the logo URL — point at a self-hosted or bundled asset when a CSP
18
+ * blocks `models.dev`. Defaults to `${MODEL_PROVIDER_LOGO_BASE_URL}/<provider>.svg`.
19
+ */
20
+ src?: string;
21
+ /**
22
+ * Rendered when the logo fails to load (blocked, offline, or unknown
23
+ * provider). Defaults to a neutral Lucide glyph, so the row never collapses
24
+ * to a broken image.
25
+ */
26
+ fallback?: ReactNode;
27
+ provider:
28
+ | "moonshotai-cn"
29
+ | "lucidquery"
30
+ | "moonshotai"
31
+ | "zai-coding-plan"
32
+ | "alibaba"
33
+ | "xai"
34
+ | "vultr"
35
+ | "nvidia"
36
+ | "upstage"
37
+ | "groq"
38
+ | "github-copilot"
39
+ | "mistral"
40
+ | "vercel"
41
+ | "nebius"
42
+ | "deepseek"
43
+ | "alibaba-cn"
44
+ | "google-vertex-anthropic"
45
+ | "venice"
46
+ | "chutes"
47
+ | "cortecs"
48
+ | "github-models"
49
+ | "togetherai"
50
+ | "azure"
51
+ | "baseten"
52
+ | "huggingface"
53
+ | "opencode"
54
+ | "fastrouter"
55
+ | "google"
56
+ | "google-vertex"
57
+ | "cloudflare-workers-ai"
58
+ | "inception"
59
+ | "wandb"
60
+ | "openai"
61
+ | "zhipuai-coding-plan"
62
+ | "perplexity"
63
+ | "openrouter"
64
+ | "zenmux"
65
+ | "v0"
66
+ | "iflowcn"
67
+ | "synthetic"
68
+ | "deepinfra"
69
+ | "zhipuai"
70
+ | "submodel"
71
+ | "zai"
72
+ | "inference"
73
+ | "requesty"
74
+ | "morph"
75
+ | "lmstudio"
76
+ | "anthropic"
77
+ | "aihubmix"
78
+ | "fireworks-ai"
79
+ | "modelscope"
80
+ | "llama"
81
+ | "scaleway"
82
+ | "amazon-bedrock"
83
+ | "cerebras"
84
+ // oxlint-disable-next-line typescript-eslint(ban-types) -- intentional pattern for autocomplete-friendly string union
85
+ | (string & {});
86
+ };
87
+
88
+ /**
89
+ * The mark of an AI model provider (`anthropic`, `openai`, `google`, …), sized
90
+ * for a row in a model list.
91
+ *
92
+ * **`ModelProviderLogo` vs `ServiceLogo` (`@elabs-ai/components-icons`).** An AI
93
+ * provider mark with a zero-config remote default — name the provider and a logo
94
+ * appears, no registration step — is this component. Any other third-party
95
+ * service mark, resolved from a registry the app supplies and never fetched, is
96
+ * `ServiceLogo`. The two are not interchangeable: `ServiceLogo` has no
97
+ * per-instance `src` and no `onError`, and this one reaches a remote origin by
98
+ * default (see `docs/CSP-AND-NETWORK.md`).
99
+ */
100
+ export const ModelProviderLogo = ({
101
+ provider,
102
+ className,
103
+ src,
104
+ fallback,
105
+ onError,
106
+ ...props
107
+ }: ModelProviderLogoProps) => {
108
+ const [failed, setFailed] = useState(false);
109
+
110
+ // A blocked/missing logo must not leave a broken-image glyph in the row.
111
+ if (failed) {
112
+ return (
113
+ <>
114
+ {fallback ?? (
115
+ <BotIcon aria-label={`${provider} logo`} className={cn("size-3", className)} role="img" />
116
+ )}
117
+ </>
118
+ );
119
+ }
120
+
121
+ return (
122
+ <img
123
+ {...props}
124
+ alt={`${provider} logo`}
125
+ className={cn("size-3 dark:invert", className)}
126
+ height={12}
127
+ onError={(event) => {
128
+ setFailed(true);
129
+ onError?.(event);
130
+ }}
131
+ // AFTER the spread, so the caller's `src` wins via the destructured
132
+ // default rather than being silently overwritten.
133
+ src={src ?? `${MODEL_PROVIDER_LOGO_BASE_URL}/${provider}.svg`}
134
+ width={12}
135
+ />
136
+ );
137
+ };
138
+
139
+ export type ModelProviderLogoGroupProps = ComponentProps<"div">;
140
+
141
+ export const ModelProviderLogoGroup = ({ className, ...props }: ModelProviderLogoGroupProps) => (
142
+ <div
143
+ className={cn(
144
+ "flex shrink-0 items-center -space-x-1 [&>img]:rounded-full [&>img]:bg-background [&>img]:p-px [&>img]:ring-1 dark:[&>img]:bg-foreground",
145
+ className,
146
+ )}
147
+ {...props}
148
+ />
149
+ );