@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
@@ -0,0 +1,90 @@
1
+ "use client";
2
+
3
+ /**
4
+ * Lazy-loaded replacement for `@streamdown/math`'s eager `math` export.
5
+ *
6
+ * Why this exists
7
+ * ----------------
8
+ * `@streamdown/math` bundles KaTeX (~600 KB unminified) plus `remark-math` and
9
+ * `rehype-katex`. `useStreamdownPlugins()` (`_streamdown-i18n.ts`) used to
10
+ * import it statically, so every consumer of `MessageResponse`/`MarkdownView`/
11
+ * `ReasoningContent` paid for KaTeX in their entry chunk whether or not a
12
+ * single conversation ever contains `$…$`.
13
+ *
14
+ * How it stays correct
15
+ * ---------------------
16
+ * Unlike Mermaid (`_lazy-mermaid.ts`), a remark/rehype `Pluggable` cannot be
17
+ * wrapped in a lazy accessor: `remark-math`'s micromark extension has to be
18
+ * registered on the unified processor BEFORE it parses, so there is no way to
19
+ * hand Streamdown a present-but-deferred math plugin the way `DiagramPlugin`
20
+ * defers its render. Instead:
21
+ *
22
+ * 1. `needsMathPlugin(text)` cheaply sniffs the RAW markdown source for `$…$`
23
+ * / `\(…\)` / `\[…\]` delimiters — no parsing, just a regex.
24
+ * 2. `useLazyMathPlugin(text)` returns `undefined` until that sniff is true,
25
+ * at which point it kicks off `import("@streamdown/math")` and returns the
26
+ * loaded plugin once it resolves, triggering a re-render.
27
+ *
28
+ * The one user-visible cost: a message whose FIRST rendered chunk already
29
+ * contains math delimiters shows literal `$x^2$` for one paint before the
30
+ * import resolves (typically well under a frame from a warm cache, since the
31
+ * chunk is tiny next to KaTeX's own parse/layout work). Every subsequent
32
+ * message reuses the module-level cache and never re-pays it.
33
+ */
34
+ import type { MathPlugin } from "@streamdown/math";
35
+ import { useEffect, useState } from "react";
36
+
37
+ /** Cheap, over-inclusive sniff — a false positive just means an unneeded fetch. */
38
+ const MATH_DELIMITER_RE = /\$\$[\s\S]*?\$\$|\$[^\s$][^$\n]*\$|\\\(|\\\)|\\\[|\\\]/;
39
+
40
+ /** Does `text` look like it contains LaTeX math delimiters? */
41
+ export function needsMathPlugin(text: string): boolean {
42
+ return MATH_DELIMITER_RE.test(text);
43
+ }
44
+
45
+ /** Module-level singleton: the plugin (and its KaTeX bytes) load at most once. */
46
+ let cachedPlugin: MathPlugin | undefined;
47
+ let loadPromise: Promise<MathPlugin> | undefined;
48
+
49
+ function loadMathPlugin(): Promise<MathPlugin> {
50
+ loadPromise ??= import("@streamdown/math").then((mod) => {
51
+ cachedPlugin = mod.math;
52
+ return cachedPlugin;
53
+ });
54
+ return loadPromise;
55
+ }
56
+
57
+ /**
58
+ * Start fetching the math plugin ahead of time (e.g. once a conversation is
59
+ * known to contain math, or on route entry). Optional; rendering math loads
60
+ * it either way.
61
+ */
62
+ export const preloadMath = (): void => {
63
+ void loadMathPlugin();
64
+ };
65
+
66
+ /**
67
+ * Returns the loaded `MathPlugin` once `text` needs one, else `undefined`
68
+ * (Streamdown treats an absent `plugins.math` slot as "no math support" — the
69
+ * delimiters render as literal text until the plugin arrives).
70
+ */
71
+ export function useLazyMathPlugin(text: string): MathPlugin | undefined {
72
+ const needed = needsMathPlugin(text);
73
+ const [plugin, setPlugin] = useState<MathPlugin | undefined>(cachedPlugin);
74
+
75
+ useEffect(() => {
76
+ if (!needed || cachedPlugin) return;
77
+ let cancelled = false;
78
+ loadMathPlugin().then((loaded) => {
79
+ if (!cancelled) setPlugin(loaded);
80
+ });
81
+ return () => {
82
+ cancelled = true;
83
+ };
84
+ }, [needed]);
85
+
86
+ if (!needed) return undefined;
87
+ // `cachedPlugin` covers the case where another instance finished loading
88
+ // between this component's mount and this render (state update pending).
89
+ return plugin ?? cachedPlugin;
90
+ }
@@ -0,0 +1,53 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+
3
+ /**
4
+ * `mermaid` is genuinely absent from this file's module registry (deliberately
5
+ * mocked to the shape a bundler hands back for an uninstalled optional peer —
6
+ * see below), so this lives in its OWN file rather than a `describe` inside
7
+ * `_lazy-mermaid.test.ts`. `vi.mock` is hoisted to the top of the file and
8
+ * applies for the file's entire run; a second, conflicting registration for
9
+ * the same specifier inside one file (this shape, vs. `_lazy-mermaid.test.ts`'s
10
+ * `{ initialize, render }` shape) does not reliably scope to one `describe`
11
+ * block — it can leak into sibling tests in the same file that expect the
12
+ * OTHER shape, corrupting them instead of the isolated case this is meant to
13
+ * add. See `.claude/rules/component-api.md` "Regression locks with observable
14
+ * side effects must be verified co-resident" for the general version of this
15
+ * lesson; a global module-mock registration is exactly such a side effect.
16
+ *
17
+ * Vite's production build substitutes an EMPTY module for a genuinely-absent
18
+ * optional peer (`export default {}`) rather than rejecting the import — so
19
+ * `loadEngine()`'s own shape guard in `_lazy-mermaid.ts`, not a `.catch()`, is
20
+ * what has to catch this. `_lazy-mermaid.test.ts`'s own "engine call itself
21
+ * fails" test is circular for this exact case (it rejects with the very
22
+ * string it then asserts on); this one instead makes the RESOLVED module
23
+ * shape wrong, the way an empty stub actually is, and checks `loadEngine`'s
24
+ * guard converts that into the module-not-found-shaped message
25
+ * `isModuleNotFoundMessage` recognizes.
26
+ *
27
+ * **Explicit scope statement (issue #94):** this test proves the LOAD-TIME
28
+ * ERROR PATH — what happens when `import("mermaid")` resolves to an empty
29
+ * stub — and nothing more. It does NOT prove mermaid is absent from an
30
+ * installed consumer tree, and must never be read as though it did. For the
31
+ * other four lazy-loaded optional peers (Rive, xterm, `@xterm/addon-fit`,
32
+ * media-chrome), `fixtures/consumer-smoke` + `pnpm consumer:check` give an
33
+ * end-to-end proof of genuine absence from the installed tarball. That proof
34
+ * is currently UNWRITABLE for mermaid: two of `@elabs-ai/components-ai`'s own
35
+ * plain dependencies (`streamdown`, `@streamdown/mermaid`) each depend on
36
+ * mermaid directly, so mermaid is never actually absent from the installed
37
+ * tree for this fixture to observe (see `scripts/check-optional-peer-transitives.mjs`
38
+ * / `pnpm optional-peers:check`, which proves and tracks that residual
39
+ * instead). Do not "fix" this gap by trying to make this file assert
40
+ * absence — it can't, until upstream `streamdown` declares mermaid an
41
+ * optional peer of its own.
42
+ */
43
+ vi.mock("mermaid", () => ({ default: {} }));
44
+
45
+ describe("lazy mermaid plugin — genuinely absent optional peer (#33)", () => {
46
+ it("throws a module-not-found-shaped message when the resolved module has no initialize/render", async () => {
47
+ const { createLazyMermaidPlugin } = await import("./_lazy-mermaid");
48
+
49
+ await expect(
50
+ createLazyMermaidPlugin().getMermaid().render("d1", "graph TD; A-->B;"),
51
+ ).rejects.toThrow(/cannot find module 'mermaid'/i);
52
+ });
53
+ });
@@ -98,4 +98,19 @@ describe("lazy mermaid plugin", () => {
98
98
  expect(() => preloadMermaid()).not.toThrow();
99
99
  expect(render).not.toHaveBeenCalled();
100
100
  });
101
+
102
+ it("surfaces a module-not-found-shaped message when the engine call itself fails — the missing optional `mermaid` peer, #33", async () => {
103
+ // The engine module resolved fine (it is mocked at the top of this file);
104
+ // this simulates the shape a missing peer actually takes downstream — the
105
+ // dynamic `import("mermaid")` rejecting — by rejecting the mocked call the
106
+ // plugin awaits. Streamdown reduces whatever this rejects with to a plain
107
+ // string and hands it to `MermaidErrorPanel`
108
+ // (`_mermaid-error-panel.tsx`), which classifies a message in this exact
109
+ // shape as a capability gap, not a render failure.
110
+ render.mockRejectedValueOnce(new Error("Cannot find module 'mermaid'"));
111
+
112
+ await expect(
113
+ createLazyMermaidPlugin().getMermaid().render("d1", "graph TD; A-->B;"),
114
+ ).rejects.toThrow(/cannot find module/i);
115
+ });
101
116
  });
@@ -50,7 +50,30 @@ type MermaidModule = {
50
50
  let enginePromise: Promise<MermaidModule> | undefined;
51
51
 
52
52
  const loadEngine = (): Promise<MermaidModule> => {
53
- enginePromise ??= import("mermaid").then((m) => m.default as unknown as MermaidModule);
53
+ enginePromise ??= import("mermaid").then((m) => {
54
+ const engine = m.default as unknown as MermaidModule;
55
+ // `mermaid` is an optional peer (issue #33), but its bytes are always
56
+ // installed anyway: TWO of this package's own plain dependencies —
57
+ // `streamdown` and `@streamdown/mermaid` — each declare `mermaid` as
58
+ // their own plain, non-optional dependency (issue #94,
59
+ // `pnpm optional-peers:check` proves and tracks this). So a hoisting
60
+ // package manager (pnpm's default `node-linker=isolated` included, via
61
+ // phantom resolution through the virtual store) may still make the real
62
+ // bytes resolve even when a consumer never installed `mermaid`
63
+ // themselves — this guard does not cover that case, only a bundler
64
+ // whose optional-peer handling substitutes an EMPTY module (Vite's
65
+ // production stub is `export default {}`) once the peer is genuinely
66
+ // absent from the resolved tree. Without this guard that empty object
67
+ // resolves successfully and the crash lands one call later, on
68
+ // `engine.initialize is not a function` — a message
69
+ // `isModuleNotFoundMessage` does not recognize, so it reaches the user
70
+ // as "Diagram couldn't be drawn" with a Retry button that can never
71
+ // succeed, instead of the actionable capability-gap panel.
72
+ if (typeof engine?.initialize !== "function" || typeof engine?.render !== "function") {
73
+ throw new Error("Cannot find module 'mermaid'");
74
+ }
75
+ return engine;
76
+ });
54
77
  return enginePromise;
55
78
  };
56
79
 
@@ -0,0 +1,50 @@
1
+ /**
2
+ * Issue #33 — `mermaid` is an OPTIONAL peer of `@elabs-ai/components-ai`,
3
+ * reached only through the lazy plugin in `_lazy-mermaid.ts`. Streamdown hands
4
+ * a failed diagram render to `MermaidErrorPanel` as a plain STRING (never the
5
+ * original `Error`), so this test locks the message-based branch directly:
6
+ * a missing-peer-shaped message renders the neutral "capability gap" panel
7
+ * (`kind="empty"`, `role="status"`, no retry — re-installing a dependency does
8
+ * not happen by clicking a button); any other message keeps the destructive
9
+ * `kind="error"` panel wired to Streamdown's own `retry()`.
10
+ */
11
+ import { cleanup, render, screen } from "@testing-library/react";
12
+ import { afterEach, describe, expect, it, vi } from "vitest";
13
+ import { MermaidErrorPanel } from "./_mermaid-error-panel";
14
+
15
+ afterEach(cleanup);
16
+
17
+ describe("MermaidErrorPanel (#33)", () => {
18
+ it("renders the neutral capability-gap panel for a missing-peer-shaped message, naming mermaid", () => {
19
+ const retry = vi.fn();
20
+ render(
21
+ <MermaidErrorPanel
22
+ chart="graph TD; A-->B;"
23
+ error="Cannot find module 'mermaid'"
24
+ retry={retry}
25
+ />,
26
+ );
27
+
28
+ expect(screen.getByRole("status")).toBeInTheDocument();
29
+ expect(screen.getByText(/mermaid/)).toBeInTheDocument();
30
+ // Re-installing a dependency does not happen by clicking a button.
31
+ expect(screen.queryByRole("button")).not.toBeInTheDocument();
32
+ });
33
+
34
+ it("keeps the destructive, retryable panel for a genuine render failure", () => {
35
+ const retry = vi.fn();
36
+ render(
37
+ <MermaidErrorPanel
38
+ chart="graph TD; A--"
39
+ error="Parse error on line 1: Unexpected end of input"
40
+ retry={retry}
41
+ />,
42
+ );
43
+
44
+ expect(screen.getByRole("alert")).toBeInTheDocument();
45
+ expect(screen.getByText(/Unexpected end of input/)).toBeInTheDocument();
46
+ const retryButton = screen.getByRole("button", { name: /try again/i });
47
+ retryButton.click();
48
+ expect(retry).toHaveBeenCalledTimes(1);
49
+ });
50
+ });
@@ -0,0 +1,66 @@
1
+ "use client";
2
+
3
+ /**
4
+ * The `errorComponent` Streamdown renders in place of a Mermaid diagram that
5
+ * failed (issue #33). Streamdown hands back the render failure as a plain
6
+ * STRING (`MermaidErrorComponentProps.error`, already reduced from whatever
7
+ * was thrown), so this distinguishes two different failures by matching that
8
+ * string rather than the original error object:
9
+ *
10
+ * - **The optional `mermaid` peer is not installed.** This is a CAPABILITY
11
+ * GAP, not something wrong with the diagram or the app — the same class as
12
+ * `@elabs-ai/components-viewer`'s `parser-missing`
13
+ * (@.claude/rules/viewer-components.md "A capability gap is not a
14
+ * failure"). Renders the neutral `StatePanel kind="empty"`, naming the
15
+ * package to install, announced `role="status"`, with no retry action
16
+ * (re-installing a dependency does not happen by clicking a button in the
17
+ * page).
18
+ * - **Anything else is a genuine render/syntax failure** — a real error, so
19
+ * it gets `StatePanel kind="error"` (`role="alert"`, set internally by
20
+ * `StatePanel`) wired to Streamdown's own `retry()`, which increments the
21
+ * render attempt and re-runs the diagram.
22
+ */
23
+ import { Button, StatePanel, isModuleNotFoundMessage, useLocale } from "@elabs-ai/components-ui";
24
+ import { EyeOffIcon } from "lucide-react";
25
+
26
+ export interface MermaidErrorPanelProps {
27
+ chart: string;
28
+ error: string;
29
+ retry: () => void;
30
+ }
31
+
32
+ export function MermaidErrorPanel({ error, retry }: MermaidErrorPanelProps) {
33
+ const { t } = useLocale();
34
+
35
+ if (isModuleNotFoundMessage(error)) {
36
+ const feature = t("ai.mermaid.feature");
37
+ return (
38
+ <div className="my-4" role="status" aria-live="polite">
39
+ <StatePanel
40
+ kind="empty"
41
+ // A dashed edge invites a drop; this panel accepts nothing. Solid —
42
+ // mirrors `@elabs-ai/components-viewer`'s `FileViewerError`.
43
+ className="border-solid"
44
+ icon={<EyeOffIcon aria-hidden="true" />}
45
+ title={t("ai.error.engineMissing", { feature })}
46
+ description={t("ai.error.engineMissingBody", { feature, packages: "mermaid" })}
47
+ />
48
+ </div>
49
+ );
50
+ }
51
+
52
+ return (
53
+ <div className="my-4">
54
+ <StatePanel
55
+ kind="error"
56
+ title={t("ai.mermaid.renderError")}
57
+ description={error}
58
+ actions={
59
+ <Button size="sm" variant="outline" onClick={retry}>
60
+ {t("ai.error.retry")}
61
+ </Button>
62
+ }
63
+ />
64
+ </div>
65
+ );
66
+ }
@@ -19,17 +19,45 @@
19
19
  import { cn } from "@elabs-ai/components-ui/lib/cn";
20
20
  import { useReducedMotion } from "@elabs-ai/components-tokens";
21
21
  import type { RiveParameters } from "@rive-app/react-webgl2";
22
- import {
22
+ // `import type` from an optional peer is safe HERE ONLY because this module is
23
+ // reached exclusively through `lazy(() => import("./_persona-rive"))` — it is
24
+ // never statically imported, so nothing in this file reaches the barrel's
25
+ // `.d.ts` declaration graph. `persona.tsx`'s PUBLIC types must never do this
26
+ // (issue #101) — see its module doc comment and `PersonaRiveEvent`/
27
+ // `PersonaRiveEventCallback`, which this module imports below and proves
28
+ // conformant against the real peer type at the bottom of this file.
29
+ // A NAMED import (`import { useRive } from "@rive-app/react-webgl2"`) is a
30
+ // static ESM binding a bundler must resolve at build time. Now that
31
+ // `@rive-app/react-webgl2` is a genuinely optional peer (issue #33), a
32
+ // consumer who has not installed it hits that resolution at the worst
33
+ // possible time: Vite's own optional-peer-dependency handling swaps in a
34
+ // build-time stub with no exports, and Rollup's static named-export check
35
+ // then fails the WHOLE APP BUILD — not a runtime error `LazyEngineBoundary`
36
+ // could ever see (confirmed against `fixtures/consumer-smoke`'s real Vite
37
+ // build). A namespace import defers every one of these to a plain property
38
+ // lookup, which Rollup does not statically validate, so the build always
39
+ // succeeds; a genuinely missing peer instead resolves the destructured hooks
40
+ // below to `undefined`, and the guard turns that into the render-phase throw
41
+ // `LazyEngineBoundary` (see `persona.tsx`) already catches.
42
+ import * as RiveModule from "@rive-app/react-webgl2";
43
+ import type { ReactNode } from "react";
44
+ import { memo, useEffect, useMemo, useRef, useState } from "react";
45
+
46
+ import type { AssertAssignable } from "./_lazy-boundary-conformance";
47
+ import type { PersonaRiveEventCallback } from "./persona";
48
+ import type { PersonaSource, PersonaState } from "./persona-sources";
49
+
50
+ const {
23
51
  useRive,
24
52
  useStateMachineInput,
25
53
  useViewModel,
26
54
  useViewModelInstance,
27
55
  useViewModelInstanceColor,
28
- } from "@rive-app/react-webgl2";
29
- import type { ReactNode } from "react";
30
- import { memo, useEffect, useMemo, useRef, useState } from "react";
56
+ } = RiveModule;
31
57
 
32
- import type { PersonaSource, PersonaState } from "./persona-sources";
58
+ if (!useRive) {
59
+ throw new Error("Cannot find module '@rive-app/react-webgl2'");
60
+ }
33
61
 
34
62
  // Delays Rive initialization by one frame so that React Strict Mode's
35
63
  // immediate unmount cycle never creates a WebGL2 context. Only the
@@ -139,12 +167,12 @@ PersonaWithoutModel.displayName = "PersonaWithoutModel";
139
167
 
140
168
  export interface PersonaRiveProps {
141
169
  className?: string;
142
- onLoad?: RiveParameters["onLoad"];
143
- onLoadError?: RiveParameters["onLoadError"];
144
- onPause?: RiveParameters["onPause"];
145
- onPlay?: RiveParameters["onPlay"];
170
+ onLoad?: PersonaRiveEventCallback;
171
+ onLoadError?: PersonaRiveEventCallback;
172
+ onPause?: PersonaRiveEventCallback;
173
+ onPlay?: PersonaRiveEventCallback;
146
174
  onReady?: () => void;
147
- onStop?: RiveParameters["onStop"];
175
+ onStop?: PersonaRiveEventCallback;
148
176
  source: PersonaSource;
149
177
  /** Resolved `.riv` URL — `Persona`'s `src` override, or the variant default. */
150
178
  src: string;
@@ -250,3 +278,27 @@ const PersonaRive = ({
250
278
  };
251
279
 
252
280
  export default PersonaRive;
281
+
282
+ /**
283
+ * Conformance assertion (issue #101): compile-time proof that the owned
284
+ * `PersonaRiveEventCallback` (declared in `persona.tsx`, structurally free of
285
+ * `@rive-app/react-webgl2`/`@rive-app/webgl2`) is still assignable to the REAL
286
+ * `RiveParameters["onLoad"]` — and, since `onLoad`/`onLoadError`/`onPause`/
287
+ * `onPlay`/`onStop` all share Rive's one `EventCallback` type, this single
288
+ * check covers all five. `AssertAssignable`'s type parameter is constrained
289
+ * (`TOwned extends TReal`), so if a future Rive release changes `Event`'s
290
+ * shape in a way `PersonaRiveEvent` no longer satisfies, this line fails to
291
+ * typecheck — caught by `pnpm --filter @elabs-ai/components-ai typecheck`
292
+ * locally, never shipped as a silent mismatch to a consumer.
293
+ *
294
+ * `AssertAssignable` itself is shared with `_audio-player-media-chrome.tsx`
295
+ * via `_lazy-boundary-conformance.ts` — see that module's doc comment for
296
+ * what this check can and cannot prove (it is one-directional: it cannot
297
+ * catch the owned type being NARROWER than the real one, which is exactly
298
+ * what `PersonaRiveEvent["data"]` is — see the CHANGELOG's "Breaking (types)"
299
+ * entry).
300
+ */
301
+ export type _PersonaRiveEventCallbackConformance = AssertAssignable<
302
+ PersonaRiveEventCallback,
303
+ NonNullable<RiveParameters["onLoad"]>
304
+ >;
@@ -29,13 +29,15 @@ import {
29
29
  useStreamdownTranslations,
30
30
  type StreamdownTranslationKey,
31
31
  } from "@elabs-ai/components-ui";
32
- import { cjk } from "@streamdown/cjk";
33
32
  import { createCodePlugin } from "@streamdown/code";
34
- import { math } from "@streamdown/math";
35
- import { useEffect, useMemo, useState } from "react";
33
+ import { useMemo } from "react";
36
34
  import type { StreamdownTranslations } from "streamdown";
37
35
  import { buildCodeBlockTheme } from "./_code-block-theme";
36
+ import { useLazyCjkPlugin } from "./_lazy-cjk";
37
+ import { useLazyMathPlugin } from "./_lazy-math";
38
38
  import { lazyMermaid } from "./_lazy-mermaid";
39
+ import { MermaidErrorPanel } from "./_mermaid-error-panel";
40
+ import { useThemeScopeRevision } from "./_theme-scope-store";
39
41
 
40
42
  /**
41
43
  * Reactive replacement for `@streamdown/code`'s pre-configured `code` export
@@ -47,31 +49,32 @@ import { lazyMermaid } from "./_lazy-mermaid";
47
49
  *
48
50
  * Streamdown's dual-theme mechanism (`createCodePlugin({ themes: [light, dark] })`)
49
51
  * expects exactly TWO themes and picks between them purely via the `.dark` CSS
50
- * selector — but brand-ui ships THREE themes (light/dark),
52
+ * selector — but brand-ui ships EVERY theme, not just Shiki's light/dark,
51
53
  * and any dark-declaring theme matches `.dark` too (see `_code-block-theme.ts`). Passing a real
52
54
  * light/dark PAIR would still force such a theme into the dark slot. Instead
53
55
  * this pins BOTH slots to `buildCodeBlockTheme()` — the SAME brand-token-derived
54
56
  * theme the active `data-theme` resolves to — so whichever slot the `.dark`
55
57
  * selector picks, it's the CORRECT theme for whatever is actually active.
56
58
  *
57
- * Re-derives on every `data-theme` mutation (a `MutationObserver` on
58
- * `<html>`, mirroring `code-block.tsx`'s own) and returns a NEW plugin object
59
+ * Re-derives on every `data-theme` mutation and returns a NEW plugin object
59
60
  * each time, because Streamdown only re-reads `plugins.code.getThemes()` when
60
61
  * the `plugins.code` object's REFERENCE changes (see streamdown's internal
61
62
  * `shikiTheme` memo) — a mutated-in-place plugin would never be picked up.
63
+ *
64
+ * The mutation watch itself comes from the SHARED, ref-counted store in
65
+ * `./_theme-scope-store` (perf review §3.3) rather than a `MutationObserver`
66
+ * instantiated per hook call — every simultaneously-mounted
67
+ * `MessageResponse`/`MarkdownView` (and every `CodeBlock`) in a message-dense
68
+ * conversation now shares exactly one observer per distinct scope element,
69
+ * instead of one each. This hook has no element of its own to scope to (it
70
+ * runs before Streamdown renders anything), so it resolves to the document's
71
+ * theme scope (`getThemeScope(null)` → closest `[data-theme]` on `<html>`,
72
+ * same as its prior always-`<html>` behaviour) — a genuinely region-scoped
73
+ * derivation would need a ref to the rendered surface, which no current call
74
+ * site (`message.tsx`, `markdown-view.tsx`, `reasoning.tsx`) has available.
62
75
  */
63
76
  function useReactiveCodePlugin() {
64
- const [revision, setRevision] = useState(0);
65
-
66
- useEffect(() => {
67
- if (typeof document === "undefined") return;
68
- const observer = new MutationObserver(() => setRevision((r) => r + 1));
69
- observer.observe(document.documentElement, {
70
- attributes: true,
71
- attributeFilter: ["data-theme"],
72
- });
73
- return () => observer.disconnect();
74
- }, []);
77
+ const revision = useThemeScopeRevision(null);
75
78
 
76
79
  return useMemo(() => {
77
80
  const theme = buildCodeBlockTheme();
@@ -84,15 +87,84 @@ function useReactiveCodePlugin() {
84
87
  * The plugin set every `@elabs-ai/components-ai` markdown surface renders with.
85
88
  *
86
89
  * `mermaid` is the LAZY plugin (`./_lazy-mermaid`): the engine + d3 + DOMPurify
87
- * load on first diagram render, not in the entry chunk of every consumer. `code`
88
- * is the reactive, brand-token-derived plugin above (#315) — never the
90
+ * load on first diagram render, not in the entry chunk of every consumer.
91
+ * `math`/`cjk` are ALSO lazy (`./_lazy-math`, `./_lazy-cjk`, #perf-5): KaTeX
92
+ * (~600 KB) and the CJK remark plugins load only once `text` — the raw
93
+ * markdown source about to render — actually needs them, sniffed by a cheap
94
+ * regex rather than shipped unconditionally in every consumer's entry chunk.
95
+ * Both resolve to `undefined` (the slot is simply absent) until their content
96
+ * cue fires and the dynamic import resolves; see the two modules' docs for the
97
+ * one-paint tradeoff this makes and why it can't be avoided the way Mermaid's
98
+ * render-time laziness is.
99
+ *
100
+ * `code` is the reactive, brand-token-derived plugin above (#315) — never the
89
101
  * `@streamdown/code` package's static `github-light`/`github-dark` default.
90
102
  * Memoized so Streamdown sees a referentially stable `plugins` prop except when
91
- * the active theme actually changes.
103
+ * the active theme (or a lazy slot) actually changes.
104
+ *
105
+ * @param text The raw markdown source about to render — used only to decide
106
+ * whether the math/CJK plugins are needed. Pass `""` to never load them
107
+ * speculatively.
92
108
  */
93
- export function useStreamdownPlugins() {
109
+ export function useStreamdownPlugins(text = "") {
94
110
  const code = useReactiveCodePlugin();
95
- return useMemo(() => ({ cjk, code, math, mermaid: lazyMermaid }), [code]);
111
+ const math = useLazyMathPlugin(text);
112
+ const cjk = useLazyCjkPlugin(text);
113
+ return useMemo(() => ({ cjk, code, math, mermaid: lazyMermaid }), [cjk, code, math]);
114
+ }
115
+
116
+ /**
117
+ * A React `key` for the `<Streamdown>` element that renders with
118
+ * {@link useStreamdownPlugins}'s result — pass it as `key={getStreamdownPluginsKey(plugins)}`
119
+ * on the `<Streamdown>` JSX, never inside the `plugins` object itself.
120
+ *
121
+ * Why this exists (#perf-5 follow-up): Streamdown v2.5.0's `rehypePlugins`/
122
+ * `remarkPlugins` are `useMemo`'d with `plugins.math`/`plugins.cjk` in their
123
+ * dependency arrays, and its `Block`/`Streamdown` components' custom `memo`
124
+ * comparators do check plugin reference equality — on paper, a fresh
125
+ * `plugins.math` object arriving after the lazy `import()` resolves should be
126
+ * enough to make Streamdown rebuild its processing pipeline with the math
127
+ * plugin included. Empirically (verified directly against the real
128
+ * `streamdown` package, not a mock) it does not: once a block has rendered
129
+ * once without `plugins.math`/`plugins.cjk`, Streamdown's internal processor
130
+ * cache keeps serving the plugin-less result even after the reference
131
+ * changes, and the literal `$$…$$`/CJK-remark-less markdown source stays
132
+ * un-rendered indefinitely.
133
+ *
134
+ * Forcing React to unmount and remount the `<Streamdown>` element — by
135
+ * changing its `key` — sidesteps that internal cache entirely (a fresh
136
+ * instance has no stale cache to serve) and reliably picks up the lazily
137
+ * loaded plugin. The cost is a one-time full remount of that Streamdown
138
+ * instance the first time its lazy math/CJK slot resolves — at most once per
139
+ * mounted instance, since after that the key is stable again. Filed for
140
+ * upstream investigation; if a future `streamdown` release fixes the
141
+ * underlying cache, this key can be dropped without changing any call site's
142
+ * behavior (an unchanging key is a no-op).
143
+ */
144
+ export function getStreamdownPluginsKey(plugins: {
145
+ readonly cjk?: unknown;
146
+ readonly math?: unknown;
147
+ }): string {
148
+ return `${plugins.math ? "math" : "no-math"}:${plugins.cjk ? "cjk" : "no-cjk"}`;
149
+ }
150
+
151
+ /**
152
+ * The top-level Streamdown `mermaid` prop (distinct from `plugins.mermaid`,
153
+ * the LAZY `DiagramPlugin` `useStreamdownPlugins()` wires above) — this is
154
+ * where a FAILED render is caught (issue #33). Streamdown hands its
155
+ * `errorComponent` a plain STRING describing what went wrong;
156
+ * `MermaidErrorPanel` decides whether that string names a missing optional
157
+ * peer (`mermaid` not installed — a capability gap, `StatePanel kind="empty"`)
158
+ * or a genuine diagram render failure (`StatePanel kind="error"`, with retry).
159
+ *
160
+ * A stable module-level object, not a fresh one per render: Streamdown only
161
+ * re-reads `mermaid.errorComponent` when this reference changes, and
162
+ * `MermaidErrorPanel` never varies at runtime. Exported as a hook to match
163
+ * `useStreamdownPlugins()`'s call-site shape.
164
+ */
165
+ const MERMAID_OPTIONS = { errorComponent: MermaidErrorPanel };
166
+ export function useStreamdownMermaidOptions() {
167
+ return MERMAID_OPTIONS;
96
168
  }
97
169
 
98
170
  /*