@elabs-ai/components-ai 4.1.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 +2 -2
- package/dist/index.d.ts +43 -16
- package/dist/index.js +1297 -926
- package/dist/index.js.map +1 -1
- package/package.json +9 -9
- 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/_chat-shell-rail.tsx +2 -2
- package/src/_lazy-cjk.test.ts +43 -0
- package/src/_lazy-cjk.ts +73 -0
- package/src/_lazy-math.test.ts +63 -0
- package/src/_lazy-math.ts +90 -0
- package/src/_streamdown-i18n.ts +73 -21
- package/src/_streamdown-safety.ts +2 -2
- package/src/_theme-scope-store.test.ts +83 -0
- package/src/_theme-scope-store.ts +103 -0
- package/src/agent-event.tsx +1 -1
- package/src/agent.tsx +18 -13
- package/src/artifact.tsx +2 -2
- package/src/asset-preview.test.tsx +40 -0
- package/src/asset-preview.tsx +83 -12
- package/src/attachments.tsx +7 -4
- package/src/code-block.test.tsx +100 -1
- package/src/code-block.tsx +166 -103
- package/src/commit.tsx +30 -41
- package/src/confirmation.tsx +1 -1
- package/src/context-panel.tsx +1 -1
- package/src/conversation.stories.tsx +23 -0
- package/src/conversation.test.tsx +53 -0
- package/src/conversation.tsx +32 -7
- package/src/diff-view.test.tsx +52 -2
- package/src/diff-view.tsx +89 -34
- package/src/environment-variables.tsx +20 -37
- package/src/file-tree.test.tsx +21 -0
- package/src/file-tree.tsx +12 -2
- package/src/inline-citation.tsx +5 -5
- package/src/jsx-preview.tsx +151 -43
- package/src/markdown-view.test.tsx +7 -3
- package/src/markdown-view.tsx +8 -1
- package/src/message-form.stories.tsx +36 -3
- 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.test.tsx +73 -3
- package/src/message.tsx +36 -12
- package/src/model-provider-logo.test.tsx +57 -3
- package/src/model-provider-logo.tsx +45 -11
- package/src/open-in-chat.tsx +50 -29
- package/src/package-info.tsx +12 -12
- package/src/prompt-input-slash.stories.tsx +1 -1
- 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 +17 -6
- package/src/sandbox.tsx +3 -3
- package/src/schema-display.test.tsx +56 -0
- package/src/schema-display.tsx +68 -37
- package/src/session-header.tsx +1 -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 +20 -36
- package/src/streamdown-i18n.test.tsx +23 -0
- package/src/test-results.tsx +47 -27
- package/src/token-usage.tsx +6 -6
- package/src/tool.test.tsx +65 -0
- package/src/tool.tsx +59 -23
- package/src/transcription.tsx +1 -1
- package/src/voice-selector.tsx +5 -5
- package/src/web-preview.test.tsx +51 -1
- package/src/web-preview.tsx +39 -8
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { renderHook, waitFor } from "@testing-library/react";
|
|
2
|
+
import { describe, expect, it, vi } from "vitest";
|
|
3
|
+
|
|
4
|
+
import { needsCjkPlugin, preloadCjk, useLazyCjkPlugin } from "./_lazy-cjk";
|
|
5
|
+
|
|
6
|
+
const fakeCjkPlugin = {
|
|
7
|
+
name: "cjk",
|
|
8
|
+
remarkPlugins: [],
|
|
9
|
+
remarkPluginsAfter: [],
|
|
10
|
+
remarkPluginsBefore: [],
|
|
11
|
+
type: "cjk",
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
vi.mock("@streamdown/cjk", () => ({ cjk: fakeCjkPlugin }));
|
|
15
|
+
|
|
16
|
+
describe("needsCjkPlugin", () => {
|
|
17
|
+
it("returns false for plain ASCII text", () => {
|
|
18
|
+
expect(needsCjkPlugin("plain text")).toBe(false);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it("matches CJK Unified Ideographs, Hiragana, Katakana and Hangul", () => {
|
|
22
|
+
expect(needsCjkPlugin("你好")).toBe(true);
|
|
23
|
+
expect(needsCjkPlugin("こんにちは")).toBe(true);
|
|
24
|
+
expect(needsCjkPlugin("コンニチハ")).toBe(true);
|
|
25
|
+
expect(needsCjkPlugin("안녕하세요")).toBe(true);
|
|
26
|
+
});
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
describe("useLazyCjkPlugin", () => {
|
|
30
|
+
it("returns undefined when the text has no CJK codepoints", () => {
|
|
31
|
+
const { result } = renderHook(() => useLazyCjkPlugin("plain text"));
|
|
32
|
+
expect(result.current).toBeUndefined();
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("loads and returns the plugin once the text needs CJK handling", async () => {
|
|
36
|
+
const { result } = renderHook(() => useLazyCjkPlugin("你好,世界"));
|
|
37
|
+
await waitFor(() => expect(result.current).toBe(fakeCjkPlugin));
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("preloadCjk resolves without throwing", () => {
|
|
41
|
+
expect(() => preloadCjk()).not.toThrow();
|
|
42
|
+
});
|
|
43
|
+
});
|
package/src/_lazy-cjk.ts
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Lazy-loaded replacement for `@streamdown/cjk`'s eager `cjk` export.
|
|
5
|
+
*
|
|
6
|
+
* Same rationale and mechanism as `_lazy-math.ts` — `remark-cjk-friendly` +
|
|
7
|
+
* `remark-cjk-friendly-gfm-strikethrough` load only once `needsCjkPlugin(text)`
|
|
8
|
+
* sees a CJK codepoint, instead of shipping in every consumer's entry chunk.
|
|
9
|
+
* See `_lazy-math.ts` for why a remark `Pluggable` cannot be deferred the way
|
|
10
|
+
* `DiagramPlugin` defers Mermaid's render, and for the one-paint literal-text
|
|
11
|
+
* cost this trades for it.
|
|
12
|
+
*/
|
|
13
|
+
import type { CjkPlugin } from "@streamdown/cjk";
|
|
14
|
+
import { useEffect, useState } from "react";
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Hiragana, Katakana, CJK Unified Ideographs (+ Extension A), Hangul Syllables,
|
|
18
|
+
* CJK punctuation and halfwidth Katakana — the ranges `remark-cjk-friendly`
|
|
19
|
+
* exists to handle (emphasis/strikethrough boundary rules around CJK text).
|
|
20
|
+
*/
|
|
21
|
+
// The range's first codepoint is the ideographic full-width space, not
|
|
22
|
+
// accidental whitespace — a deliberate boundary character, not noise.
|
|
23
|
+
// eslint-disable-next-line no-irregular-whitespace -- see comment above
|
|
24
|
+
const CJK_RANGE_RE = /[ -〿-ヿ㐀-䶿一-鿿가-힣・-゚]/u;
|
|
25
|
+
|
|
26
|
+
/** Does `text` contain a CJK codepoint? */
|
|
27
|
+
export function needsCjkPlugin(text: string): boolean {
|
|
28
|
+
return CJK_RANGE_RE.test(text);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Module-level singleton: the plugin loads at most once. */
|
|
32
|
+
let cachedPlugin: CjkPlugin | undefined;
|
|
33
|
+
let loadPromise: Promise<CjkPlugin> | undefined;
|
|
34
|
+
|
|
35
|
+
function loadCjkPlugin(): Promise<CjkPlugin> {
|
|
36
|
+
loadPromise ??= import("@streamdown/cjk").then((mod) => {
|
|
37
|
+
cachedPlugin = mod.cjk;
|
|
38
|
+
return cachedPlugin;
|
|
39
|
+
});
|
|
40
|
+
return loadPromise;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Start fetching the CJK plugin ahead of time (e.g. once a conversation's
|
|
45
|
+
* locale is known to be CJK). Optional; rendering CJK text loads it either way.
|
|
46
|
+
*/
|
|
47
|
+
export const preloadCjk = (): void => {
|
|
48
|
+
void loadCjkPlugin();
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Returns the loaded `CjkPlugin` once `text` needs one, else `undefined`
|
|
53
|
+
* (Streamdown treats an absent `plugins.cjk` slot as "no CJK handling" — plain
|
|
54
|
+
* remark-gfm boundary rules apply until the plugin arrives).
|
|
55
|
+
*/
|
|
56
|
+
export function useLazyCjkPlugin(text: string): CjkPlugin | undefined {
|
|
57
|
+
const needed = needsCjkPlugin(text);
|
|
58
|
+
const [plugin, setPlugin] = useState<CjkPlugin | undefined>(cachedPlugin);
|
|
59
|
+
|
|
60
|
+
useEffect(() => {
|
|
61
|
+
if (!needed || cachedPlugin) return;
|
|
62
|
+
let cancelled = false;
|
|
63
|
+
loadCjkPlugin().then((loaded) => {
|
|
64
|
+
if (!cancelled) setPlugin(loaded);
|
|
65
|
+
});
|
|
66
|
+
return () => {
|
|
67
|
+
cancelled = true;
|
|
68
|
+
};
|
|
69
|
+
}, [needed]);
|
|
70
|
+
|
|
71
|
+
if (!needed) return undefined;
|
|
72
|
+
return plugin ?? cachedPlugin;
|
|
73
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { renderHook, waitFor } from "@testing-library/react";
|
|
2
|
+
import { describe, expect, it, vi } from "vitest";
|
|
3
|
+
|
|
4
|
+
import { needsMathPlugin, preloadMath, useLazyMathPlugin } from "./_lazy-math";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The KaTeX-carrying `@streamdown/math` module is mocked so this file tests
|
|
8
|
+
* the lazy wrapper's *behaviour*. That the real dependency is genuinely
|
|
9
|
+
* absent from the entry chunk is a property of the BUILD, not of jsdom — see
|
|
10
|
+
* `pnpm check --rule eager-heavy-deps`, which
|
|
11
|
+
* asserts no `packages/ai/src` module statically imports `@streamdown/math`
|
|
12
|
+
* or `katex`.
|
|
13
|
+
*/
|
|
14
|
+
const fakeMathPlugin = {
|
|
15
|
+
name: "katex",
|
|
16
|
+
rehypePlugin: vi.fn(),
|
|
17
|
+
remarkPlugin: vi.fn(),
|
|
18
|
+
type: "math",
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
vi.mock("@streamdown/math", () => ({ math: fakeMathPlugin }));
|
|
22
|
+
|
|
23
|
+
describe("needsMathPlugin", () => {
|
|
24
|
+
it("matches $$block$$ and $inline$ delimiters", () => {
|
|
25
|
+
expect(needsMathPlugin("plain text")).toBe(false);
|
|
26
|
+
expect(needsMathPlugin("$$x^2$$")).toBe(true);
|
|
27
|
+
expect(needsMathPlugin("the answer is $x + 1$ today")).toBe(true);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("matches \\( \\) and \\[ \\] delimiters", () => {
|
|
31
|
+
expect(needsMathPlugin("\\(x^2\\)")).toBe(true);
|
|
32
|
+
expect(needsMathPlugin("\\[x^2\\]")).toBe(true);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("does not false-positive on a bare dollar amount", () => {
|
|
36
|
+
// A single, unpaired `$` (no closing delimiter on the same line) is not math.
|
|
37
|
+
expect(needsMathPlugin("that costs $5")).toBe(false);
|
|
38
|
+
});
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
describe("useLazyMathPlugin", () => {
|
|
42
|
+
it("returns undefined when the text has no math delimiters", () => {
|
|
43
|
+
const { result } = renderHook(() => useLazyMathPlugin("plain text"));
|
|
44
|
+
expect(result.current).toBeUndefined();
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("loads and returns the plugin once the text needs math", async () => {
|
|
48
|
+
const { result } = renderHook(() => useLazyMathPlugin("$$x^2$$"));
|
|
49
|
+
await waitFor(() => expect(result.current).toBe(fakeMathPlugin));
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("preloadMath resolves without throwing", () => {
|
|
53
|
+
expect(() => preloadMath()).not.toThrow();
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("serves a second, simultaneous consumer off the same cached load", async () => {
|
|
57
|
+
const first = renderHook(() => useLazyMathPlugin("$$x^2$$"));
|
|
58
|
+
const second = renderHook(() => useLazyMathPlugin("$y^2$"));
|
|
59
|
+
|
|
60
|
+
await waitFor(() => expect(first.result.current).toBe(fakeMathPlugin));
|
|
61
|
+
await waitFor(() => expect(second.result.current).toBe(fakeMathPlugin));
|
|
62
|
+
});
|
|
63
|
+
});
|
|
@@ -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
|
+
}
|
package/src/_streamdown-i18n.ts
CHANGED
|
@@ -29,14 +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
39
|
import { MermaidErrorPanel } from "./_mermaid-error-panel";
|
|
40
|
+
import { useThemeScopeRevision } from "./_theme-scope-store";
|
|
40
41
|
|
|
41
42
|
/**
|
|
42
43
|
* Reactive replacement for `@streamdown/code`'s pre-configured `code` export
|
|
@@ -55,24 +56,25 @@ import { MermaidErrorPanel } from "./_mermaid-error-panel";
|
|
|
55
56
|
* theme the active `data-theme` resolves to — so whichever slot the `.dark`
|
|
56
57
|
* selector picks, it's the CORRECT theme for whatever is actually active.
|
|
57
58
|
*
|
|
58
|
-
* Re-derives on every `data-theme` mutation
|
|
59
|
-
* `<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
|
|
60
60
|
* each time, because Streamdown only re-reads `plugins.code.getThemes()` when
|
|
61
61
|
* the `plugins.code` object's REFERENCE changes (see streamdown's internal
|
|
62
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.
|
|
63
75
|
*/
|
|
64
76
|
function useReactiveCodePlugin() {
|
|
65
|
-
const
|
|
66
|
-
|
|
67
|
-
useEffect(() => {
|
|
68
|
-
if (typeof document === "undefined") return;
|
|
69
|
-
const observer = new MutationObserver(() => setRevision((r) => r + 1));
|
|
70
|
-
observer.observe(document.documentElement, {
|
|
71
|
-
attributes: true,
|
|
72
|
-
attributeFilter: ["data-theme"],
|
|
73
|
-
});
|
|
74
|
-
return () => observer.disconnect();
|
|
75
|
-
}, []);
|
|
77
|
+
const revision = useThemeScopeRevision(null);
|
|
76
78
|
|
|
77
79
|
return useMemo(() => {
|
|
78
80
|
const theme = buildCodeBlockTheme();
|
|
@@ -85,15 +87,65 @@ function useReactiveCodePlugin() {
|
|
|
85
87
|
* The plugin set every `@elabs-ai/components-ai` markdown surface renders with.
|
|
86
88
|
*
|
|
87
89
|
* `mermaid` is the LAZY plugin (`./_lazy-mermaid`): the engine + d3 + DOMPurify
|
|
88
|
-
* load on first diagram render, not in the entry chunk of every consumer.
|
|
89
|
-
*
|
|
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
|
|
90
101
|
* `@streamdown/code` package's static `github-light`/`github-dark` default.
|
|
91
102
|
* Memoized so Streamdown sees a referentially stable `plugins` prop except when
|
|
92
|
-
* 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.
|
|
93
108
|
*/
|
|
94
|
-
export function useStreamdownPlugins() {
|
|
109
|
+
export function useStreamdownPlugins(text = "") {
|
|
95
110
|
const code = useReactiveCodePlugin();
|
|
96
|
-
|
|
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"}`;
|
|
97
149
|
}
|
|
98
150
|
|
|
99
151
|
/**
|
|
@@ -36,7 +36,7 @@
|
|
|
36
36
|
* schema instead of replacing the pipeline outright.
|
|
37
37
|
*
|
|
38
38
|
* THIS LIST HAS A PARITY COUNTERPART, AND EDITING ONE SIDE FAILS CI (#75).
|
|
39
|
-
* `scripts/check
|
|
39
|
+
* `scripts/check/rules/sanitizer-passthrough.mjs` declares the same key set as
|
|
40
40
|
* `SAFE_RENDERERS[streamdown].dangerousProps` and reads THIS array literal back
|
|
41
41
|
* out of this file, asserting set-equality in BOTH directions — so removing a
|
|
42
42
|
* key here (the runtime stops stripping it) and removing a key there (the gate
|
|
@@ -153,7 +153,7 @@ export function warnOnTrustedPluginSlots(overrides: object | undefined, internal
|
|
|
153
153
|
|
|
154
154
|
if (isPluginObject(consumer.mermaid) && consumer.mermaid !== defaults.mermaid) {
|
|
155
155
|
// The message describes React's `dangerouslySetInnerHTML` in prose rather than
|
|
156
|
-
// naming the identifier: `
|
|
156
|
+
// naming the identifier: the `trusted-types-sinks` check preserves string literals on
|
|
157
157
|
// purpose (a sink hidden in a string is still a sink), so spelling the prop out
|
|
158
158
|
// inside this warning makes the gate read THIS file as a new HTML-assigning
|
|
159
159
|
// module. Comments like this one are stripped by that gate, so the name is safe
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { renderHook, waitFor } from "@testing-library/react";
|
|
2
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
3
|
+
import {
|
|
4
|
+
_debugActiveThemeScopeCount,
|
|
5
|
+
getThemeScope,
|
|
6
|
+
useThemeScopeRevision,
|
|
7
|
+
} from "./_theme-scope-store";
|
|
8
|
+
|
|
9
|
+
afterEach(() => {
|
|
10
|
+
document.documentElement.removeAttribute("data-theme");
|
|
11
|
+
document.body.innerHTML = "";
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
describe("getThemeScope", () => {
|
|
15
|
+
it("resolves the nearest data-theme ancestor, falling back to <html>", () => {
|
|
16
|
+
expect(getThemeScope(document.body)).toBe(document.documentElement);
|
|
17
|
+
|
|
18
|
+
const scoped = document.createElement("div");
|
|
19
|
+
scoped.setAttribute("data-theme", "dark");
|
|
20
|
+
document.body.append(scoped);
|
|
21
|
+
expect(getThemeScope(scoped)).toBe(scoped);
|
|
22
|
+
});
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
describe("useThemeScopeRevision (perf review §3.3)", () => {
|
|
26
|
+
it("bumps the revision when the scoped element's data-theme mutates", async () => {
|
|
27
|
+
const { result } = renderHook(() => useThemeScopeRevision(document.documentElement));
|
|
28
|
+
const before = result.current;
|
|
29
|
+
|
|
30
|
+
document.documentElement.setAttribute("data-theme", "dark");
|
|
31
|
+
|
|
32
|
+
await waitFor(() => expect(result.current).toBe(before + 1));
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("shares ONE MutationObserver across every subscriber of the same scope", async () => {
|
|
36
|
+
const before = _debugActiveThemeScopeCount();
|
|
37
|
+
const hookA = renderHook(() => useThemeScopeRevision(document.documentElement));
|
|
38
|
+
const hookB = renderHook(() => useThemeScopeRevision(document.documentElement));
|
|
39
|
+
const hookC = renderHook(() => useThemeScopeRevision(document.documentElement));
|
|
40
|
+
|
|
41
|
+
// Three subscribers to the SAME scope element register as exactly one
|
|
42
|
+
// tracked scope, not three — this is the fix: previously each caller
|
|
43
|
+
// (CodeBlockContent, useReactiveCodePlugin) instantiated its own
|
|
44
|
+
// MutationObserver on the same ancestor.
|
|
45
|
+
expect(_debugActiveThemeScopeCount()).toBe(before + 1);
|
|
46
|
+
|
|
47
|
+
document.documentElement.setAttribute("data-theme", "dark");
|
|
48
|
+
|
|
49
|
+
// Every subscriber still gets notified through the single observer.
|
|
50
|
+
await waitFor(() => expect(hookA.result.current).toBe(1));
|
|
51
|
+
expect(hookB.result.current).toBe(1);
|
|
52
|
+
expect(hookC.result.current).toBe(1);
|
|
53
|
+
|
|
54
|
+
hookA.unmount();
|
|
55
|
+
hookB.unmount();
|
|
56
|
+
expect(_debugActiveThemeScopeCount()).toBe(before + 1); // still one listener left (hookC)
|
|
57
|
+
hookC.unmount();
|
|
58
|
+
expect(_debugActiveThemeScopeCount()).toBe(before); // the observer is disconnected once unused
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("scopes to the closest [data-theme] ancestor, not always <html>", async () => {
|
|
62
|
+
const outer = document.createElement("div");
|
|
63
|
+
outer.setAttribute("data-theme", "light");
|
|
64
|
+
const inner = document.createElement("div");
|
|
65
|
+
inner.setAttribute("data-theme", "dark");
|
|
66
|
+
outer.append(inner);
|
|
67
|
+
document.body.append(outer);
|
|
68
|
+
|
|
69
|
+
const { result } = renderHook(() => useThemeScopeRevision(inner));
|
|
70
|
+
const before = result.current;
|
|
71
|
+
|
|
72
|
+
// A mutation on the OUTER (non-closest) ancestor must not affect a
|
|
73
|
+
// consumer scoped to the inner region. There is nothing to `waitFor`
|
|
74
|
+
// here (a NEGATIVE assertion), so flush the microtask queue directly.
|
|
75
|
+
outer.setAttribute("data-theme", "light-updated");
|
|
76
|
+
await Promise.resolve();
|
|
77
|
+
expect(result.current).toBe(before);
|
|
78
|
+
|
|
79
|
+
// A mutation on the actually-closest scope element does.
|
|
80
|
+
inner.setAttribute("data-theme", "light");
|
|
81
|
+
await waitFor(() => expect(result.current).toBe(before + 1));
|
|
82
|
+
});
|
|
83
|
+
});
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Shared, ref-counted `data-theme` observation (perf review §3.3).
|
|
5
|
+
*
|
|
6
|
+
* `code-block.tsx`'s `CodeBlockContent` and `_streamdown-i18n.ts`'s
|
|
7
|
+
* `useReactiveCodePlugin` each used to instantiate their OWN
|
|
8
|
+
* `MutationObserver` to notice a `data-theme` flip and re-derive the Shiki
|
|
9
|
+
* theme — one observer PER rendered `CodeBlock`/`MessageResponse`/
|
|
10
|
+
* `MarkdownView` instance, all typically watching the exact same ancestor. In
|
|
11
|
+
* a message-dense conversation that is dozens of redundant observers doing
|
|
12
|
+
* the same job.
|
|
13
|
+
*
|
|
14
|
+
* This module keeps exactly ONE `MutationObserver` per distinct scope
|
|
15
|
+
* element (ref-counted: the observer is created on the first subscriber and
|
|
16
|
+
* disconnected when the last one unsubscribes), and exposes it through
|
|
17
|
+
* `useSyncExternalStore` so React's own re-render batching applies.
|
|
18
|
+
*/
|
|
19
|
+
import { useCallback, useSyncExternalStore } from "react";
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Nearest ancestor (inclusive) carrying `data-theme`, defaulting to `<html>`.
|
|
23
|
+
* Lets a themed surface rendered inside a region-scoped
|
|
24
|
+
* `<div data-theme="…">` (a supported `ThemeProvider`/decorator pattern —
|
|
25
|
+
* see @.claude/rules/theming.md) resolve THAT region's tokens instead of
|
|
26
|
+
* always the document root's.
|
|
27
|
+
*/
|
|
28
|
+
export function getThemeScope(el: Element | null): Element | null {
|
|
29
|
+
return (
|
|
30
|
+
el?.closest("[data-theme]") ??
|
|
31
|
+
(typeof document !== "undefined" ? document.documentElement : null)
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
interface ScopeEntry {
|
|
36
|
+
observer: MutationObserver;
|
|
37
|
+
revision: number;
|
|
38
|
+
listeners: Set<() => void>;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const scopes = new Map<Element, ScopeEntry>();
|
|
42
|
+
|
|
43
|
+
function subscribe(scope: Element | null, onStoreChange: () => void): () => void {
|
|
44
|
+
if (!scope) return () => {};
|
|
45
|
+
|
|
46
|
+
let entry = scopes.get(scope);
|
|
47
|
+
if (!entry) {
|
|
48
|
+
// `entry` is assigned before `observer` fires, but TypeScript can't see
|
|
49
|
+
// that through the closure — the callback only ever runs after this
|
|
50
|
+
// function returns and `entry` has been set, and every existing
|
|
51
|
+
// subscriber to this scope uses the SAME entry.
|
|
52
|
+
const created: ScopeEntry = {
|
|
53
|
+
listeners: new Set(),
|
|
54
|
+
observer: new MutationObserver(() => {
|
|
55
|
+
created.revision += 1;
|
|
56
|
+
for (const listener of created.listeners) listener();
|
|
57
|
+
}),
|
|
58
|
+
revision: 0,
|
|
59
|
+
};
|
|
60
|
+
created.observer.observe(scope, { attributeFilter: ["data-theme"], attributes: true });
|
|
61
|
+
scopes.set(scope, created);
|
|
62
|
+
entry = created;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
entry.listeners.add(onStoreChange);
|
|
66
|
+
return () => {
|
|
67
|
+
const current = scopes.get(scope);
|
|
68
|
+
if (!current) return;
|
|
69
|
+
current.listeners.delete(onStoreChange);
|
|
70
|
+
if (current.listeners.size === 0) {
|
|
71
|
+
current.observer.disconnect();
|
|
72
|
+
scopes.delete(scope);
|
|
73
|
+
}
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function getSnapshot(scope: Element | null): number {
|
|
78
|
+
if (!scope) return 0;
|
|
79
|
+
return scopes.get(scope)?.revision ?? 0;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const getServerSnapshot = () => 0;
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Subscribes to `data-theme` mutations on `el`'s closest `[data-theme]`
|
|
86
|
+
* ancestor (default `<html>`) and returns a revision number that increments
|
|
87
|
+
* on each mutation. Any number of callers scoped to the SAME ancestor share
|
|
88
|
+
* one `MutationObserver`.
|
|
89
|
+
*/
|
|
90
|
+
export function useThemeScopeRevision(el: Element | null): number {
|
|
91
|
+
const scope = getThemeScope(el);
|
|
92
|
+
const subscribeToScope = useCallback(
|
|
93
|
+
(onStoreChange: () => void) => subscribe(scope, onStoreChange),
|
|
94
|
+
[scope],
|
|
95
|
+
);
|
|
96
|
+
const getScopeSnapshot = useCallback(() => getSnapshot(scope), [scope]);
|
|
97
|
+
return useSyncExternalStore(subscribeToScope, getScopeSnapshot, getServerSnapshot);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Test-only: the number of scope elements currently under observation. */
|
|
101
|
+
export function _debugActiveThemeScopeCount(): number {
|
|
102
|
+
return scopes.size;
|
|
103
|
+
}
|
package/src/agent-event.tsx
CHANGED
|
@@ -102,7 +102,7 @@ function AgentEventCheckRow({ check }: { check: CheckResult }) {
|
|
|
102
102
|
{detail !== undefined && detail !== "" && (
|
|
103
103
|
<p
|
|
104
104
|
data-slot="agent-event-check-detail"
|
|
105
|
-
className="
|
|
105
|
+
className="ps-5 text-caption text-muted-foreground break-words"
|
|
106
106
|
>
|
|
107
107
|
{detail}
|
|
108
108
|
</p>
|