@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.
- package/README.md +62 -12
- package/dist/{_audio-player-media-chrome-KA5DY54G.js → _audio-player-media-chrome-T3XVXWRZ.js} +8 -4
- package/dist/_audio-player-media-chrome-T3XVXWRZ.js.map +1 -0
- package/dist/{_flow-boundary-D63PJ65S.js → _flow-boundary-SHNWLQG5.js} +32 -43
- package/dist/_flow-boundary-SHNWLQG5.js.map +1 -0
- package/dist/{_persona-rive-RFR2EUWP.js → _persona-rive-JEG44YHX.js} +9 -5
- package/dist/_persona-rive-JEG44YHX.js.map +1 -0
- package/dist/index.d.ts +1152 -218
- package/dist/index.js +5601 -3241
- package/dist/index.js.map +1 -1
- package/package.json +30 -15
- package/src/__contract__/audio-visualizer.contract.test.tsx +49 -0
- package/src/__contract__/chat-shell.contract.test.tsx +49 -0
- package/src/__contract__/grouped-parts.contract.test.tsx +49 -0
- package/src/__contract__/image.contract.test.tsx +49 -0
- package/src/__contract__/markdown-view.contract.test.tsx +49 -0
- package/src/__contract__/message-feedback.contract.test.tsx +49 -0
- package/src/__contract__/message-form.contract.test.tsx +49 -0
- package/src/__contract__/message-table.contract.test.tsx +49 -0
- package/src/__contract__/model-provider-logo.contract.test.tsx +49 -0
- package/src/__contract__/persona.contract.test.tsx +49 -0
- package/src/__contract__/prompt-input-effort.contract.test.tsx +49 -0
- package/src/__contract__/prompt-input-mode.contract.test.tsx +49 -0
- package/src/_audio-player-media-chrome.tsx +102 -15
- package/src/_chat-shell-rail.tsx +2 -2
- package/src/_flow-boundary.tsx +68 -49
- package/src/_lazy-boundary-conformance.ts +38 -0
- package/src/_lazy-cjk.test.ts +43 -0
- package/src/_lazy-cjk.ts +73 -0
- package/src/_lazy-engine-boundary.tsx +61 -0
- package/src/_lazy-math.test.ts +63 -0
- package/src/_lazy-math.ts +90 -0
- package/src/_lazy-mermaid-absent.test.ts +53 -0
- package/src/_lazy-mermaid.test.ts +15 -0
- package/src/_lazy-mermaid.ts +24 -1
- package/src/_mermaid-error-panel.test.tsx +50 -0
- package/src/_mermaid-error-panel.tsx +66 -0
- package/src/_persona-rive.tsx +62 -10
- package/src/_streamdown-i18n.ts +94 -22
- package/src/_streamdown-safety.ts +170 -0
- package/src/_theme-scope-store.test.ts +83 -0
- package/src/_theme-scope-store.ts +103 -0
- package/src/agent-event.stories.tsx +97 -0
- package/src/agent-event.test.tsx +145 -0
- package/src/agent-event.tsx +187 -0
- package/src/agent-timeline.stories.tsx +9 -1
- package/src/agent.test.tsx +47 -0
- package/src/agent.tsx +43 -24
- package/src/agentic-workspace.stories.tsx +27 -20
- package/src/artifact.tsx +17 -14
- package/src/asset-preview.test.tsx +40 -0
- package/src/asset-preview.tsx +83 -12
- package/src/attachments.tsx +7 -4
- package/src/audio-player.test.tsx +78 -0
- package/src/audio-player.tsx +243 -56
- package/src/audio-visualizer.stories.tsx +126 -0
- package/src/audio-visualizer.test.tsx +438 -0
- package/src/audio-visualizer.tsx +367 -0
- package/src/canvas.stories.tsx +150 -1
- package/src/chat-shell.stories.tsx +18 -3
- package/src/chat.stories.tsx +16 -2
- package/src/code-block.stories.tsx +9 -1
- package/src/code-block.test.tsx +100 -1
- package/src/code-block.tsx +166 -103
- package/src/commit.tsx +30 -41
- package/src/composer.stories.tsx +372 -9
- package/src/composer.test.tsx +357 -6
- package/src/composer.tsx +217 -35
- package/src/confirmation.stories.tsx +72 -1
- package/src/confirmation.test.tsx +216 -2
- package/src/confirmation.tsx +264 -4
- package/src/context-panel.stories.tsx +9 -1
- package/src/context-panel.tsx +3 -2
- package/src/conversation.stories.tsx +86 -2
- package/src/conversation.test.tsx +66 -0
- package/src/conversation.tsx +52 -9
- package/src/diff-view.stories.tsx +196 -0
- package/src/diff-view.test.tsx +238 -0
- package/src/diff-view.tsx +697 -0
- package/src/environment-variables.tsx +20 -37
- package/src/file-tree.test.tsx +21 -0
- package/src/file-tree.tsx +12 -2
- package/src/gallery.tsx +4 -2
- package/src/index.ts +14 -4
- package/src/inline-citation.tsx +5 -5
- package/src/jsx-preview.stories.tsx +2 -2
- package/src/jsx-preview.tsx +151 -43
- package/src/markdown-view.stories.tsx +92 -1
- package/src/markdown-view.test.tsx +237 -2
- package/src/markdown-view.tsx +157 -6
- package/src/message-compare.stories.tsx +175 -0
- package/src/message-compare.test.tsx +207 -0
- package/src/message-compare.tsx +453 -0
- package/src/message-form.stories.tsx +65 -4
- package/src/message-form.test.tsx +8 -2
- package/src/message-form.tsx +15 -7
- package/src/message-table.stories.tsx +2 -2
- package/src/message-table.test.tsx +7 -0
- package/src/message-table.tsx +8 -4
- package/src/message.stories.tsx +9 -1
- package/src/message.test.tsx +247 -1
- package/src/message.tsx +125 -15
- package/src/microcopy.test.tsx +40 -0
- package/src/{model-selector.stories.tsx → model-provider-logo.stories.tsx} +17 -8
- package/src/model-provider-logo.test.tsx +109 -0
- package/src/model-provider-logo.tsx +183 -0
- package/src/open-in-chat.tsx +50 -29
- package/src/package-info.tsx +12 -12
- package/src/permission-mode-select.stories.tsx +82 -0
- package/src/permission-mode-select.test.tsx +100 -0
- package/src/permission-mode-select.tsx +137 -0
- package/src/persona-missing-peer.test.tsx +54 -0
- package/src/persona.tsx +68 -22
- package/src/plan.stories.tsx +166 -0
- package/src/plan.test.tsx +267 -0
- package/src/plan.tsx +182 -20
- package/src/prompt-input-effort.stories.tsx +123 -0
- package/src/prompt-input-effort.test.tsx +83 -0
- package/src/prompt-input-effort.tsx +136 -0
- package/src/prompt-input-mode.stories.tsx +108 -0
- package/src/prompt-input-mode.test.tsx +99 -0
- package/src/prompt-input-mode.tsx +169 -0
- package/src/prompt-input-slash.stories.tsx +211 -0
- package/src/prompt-input-slash.test.tsx +262 -0
- package/src/prompt-input-slash.tsx +541 -0
- package/src/prompt-input.stories.tsx +2 -2
- package/src/prompt-input.test.tsx +67 -1
- package/src/prompt-input.tsx +42 -4
- package/src/queue.tsx +4 -4
- package/src/reasoning.tsx +42 -17
- package/src/sandbox.stories.tsx +9 -1
- package/src/sandbox.tsx +3 -3
- package/src/schema-display.test.tsx +56 -0
- package/src/schema-display.tsx +71 -37
- package/src/selection-toolbar.stories.tsx +9 -1
- package/src/session-header.stories.tsx +128 -0
- package/src/session-header.test.tsx +138 -0
- package/src/session-header.tsx +243 -0
- package/src/session-status-bar.stories.tsx +73 -0
- package/src/session-status-bar.test.tsx +94 -0
- package/src/session-status-bar.tsx +165 -0
- package/src/snippet.stories.tsx +9 -1
- package/src/snippet.test.tsx +6 -2
- package/src/snippet.tsx +14 -34
- package/src/speech-input.test.tsx +109 -0
- package/src/speech-input.tsx +31 -3
- package/src/stack-trace.tsx +24 -36
- package/src/streamdown-i18n.test.tsx +24 -1
- package/src/task.stories.tsx +13 -3
- package/src/templates-ai-assistant.stories.tsx +21 -1
- package/src/test-results.tsx +47 -27
- package/src/token-usage.stories.tsx +24 -0
- package/src/token-usage.test.tsx +92 -0
- package/src/{context.tsx → token-usage.tsx} +73 -59
- package/src/tool-result-card.stories.tsx +9 -1
- package/src/tool.stories.tsx +13 -3
- package/src/tool.test.tsx +65 -0
- package/src/tool.tsx +73 -30
- package/src/transcription.tsx +1 -1
- package/src/turn-status.stories.tsx +124 -0
- package/src/turn-status.test.tsx +74 -0
- package/src/turn-status.tsx +174 -0
- package/src/use-audio-level.ts +104 -0
- package/src/voice-selector.tsx +5 -5
- package/src/web-preview.test.tsx +51 -1
- package/src/web-preview.tsx +46 -10
- package/dist/_audio-player-media-chrome-KA5DY54G.js.map +0 -1
- package/dist/_flow-boundary-D63PJ65S.js.map +0 -1
- package/dist/_interactive-terminal-xterm-ATJ5EW3G.js +0 -11
- package/dist/_interactive-terminal-xterm-ATJ5EW3G.js.map +0 -1
- package/dist/_persona-rive-RFR2EUWP.js.map +0 -1
- package/src/_interactive-terminal-xterm.ts +0 -32
- package/src/blocks-ai-composer.stories.tsx +0 -83
- package/src/context.stories.tsx +0 -16
- package/src/interactive-terminal.stories.tsx +0 -165
- package/src/interactive-terminal.test.tsx +0 -448
- package/src/interactive-terminal.tsx +0 -444
- package/src/model-selector.test.tsx +0 -55
- package/src/model-selector.tsx +0 -225
- 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
|
});
|
package/src/_lazy-mermaid.ts
CHANGED
|
@@ -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) =>
|
|
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
|
+
}
|
package/src/_persona-rive.tsx
CHANGED
|
@@ -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
|
-
}
|
|
29
|
-
import type { ReactNode } from "react";
|
|
30
|
-
import { memo, useEffect, useMemo, useRef, useState } from "react";
|
|
56
|
+
} = RiveModule;
|
|
31
57
|
|
|
32
|
-
|
|
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?:
|
|
143
|
-
onLoadError?:
|
|
144
|
-
onPause?:
|
|
145
|
-
onPlay?:
|
|
170
|
+
onLoad?: PersonaRiveEventCallback;
|
|
171
|
+
onLoadError?: PersonaRiveEventCallback;
|
|
172
|
+
onPause?: PersonaRiveEventCallback;
|
|
173
|
+
onPlay?: PersonaRiveEventCallback;
|
|
146
174
|
onReady?: () => void;
|
|
147
|
-
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
|
+
>;
|
package/src/_streamdown-i18n.ts
CHANGED
|
@@ -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 {
|
|
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
|
|
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
|
|
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
|
|
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.
|
|
88
|
-
*
|
|
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
|
-
|
|
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
|
/*
|