@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,170 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The runtime half of the #36 fix (the type-level `Omit` is the other half — see
|
|
3
|
+
* `MarkdownViewProps`/`MessageResponseProps`).
|
|
4
|
+
*
|
|
5
|
+
* Streamdown installs its sanitiser chain (`rehype-raw` → `rehype-sanitize` →
|
|
6
|
+
* `rehype-harden`) as the DEFAULT VALUE of its `rehypePlugins` prop — a plain
|
|
7
|
+
* JS default parameter. Supplying the prop REPLACES the whole chain; there is
|
|
8
|
+
* no merge (verified against `streamdown@2.5.0`'s `dist/chunk-BO2N2NFS.js`:
|
|
9
|
+
* `xt = { raw, sanitize, harden }; gn = Object.values(xt)` is the default, and
|
|
10
|
+
* the component signature reads `rehypePlugins: a = gn`).
|
|
11
|
+
*
|
|
12
|
+
* `MarkdownView`/`MessageResponse` render UNTRUSTED, model-authored markdown,
|
|
13
|
+
* so that prop may never reach `<Streamdown>` — not even through a plain
|
|
14
|
+
* JavaScript consumer, an `as any`/`as never` cast, or a wider
|
|
15
|
+
* `{...spreadProps}` object a type-level `Omit` cannot see at runtime. `Omit`
|
|
16
|
+
* closes the TypeScript surface; this function closes the runtime one. Both
|
|
17
|
+
* are required — see issue #36.
|
|
18
|
+
*
|
|
19
|
+
* `remarkPlugins` is deliberately NOT stripped (PR #74 review, round 1). It is
|
|
20
|
+
* the same *shape* of prop — a consumer array replaces Streamdown's own remark
|
|
21
|
+
* defaults (`gfm`, `codeMeta`) rather than merging with them — but it is NOT a
|
|
22
|
+
* sanitiser override: the remark stage runs strictly UPSTREAM of the rehype
|
|
23
|
+
* chain, and Streamdown derives its rehype list without reading `remarkPlugins`
|
|
24
|
+
* at all. Anything a remark plugin injects — a raw `html` mdast node, a
|
|
25
|
+
* `data.hName`/`hChildren` hast element, an `onerror` via `hProperties`, a
|
|
26
|
+
* `javascript:` link URL, a smuggled `raw` hast child — still passes through
|
|
27
|
+
* `rehype-raw` → `rehype-sanitize` → `rehype-harden` before it can reach the
|
|
28
|
+
* DOM. Measured with this strip removed: all five channels executed and none
|
|
29
|
+
* produced a `<script>`, an `[onerror]` attribute or a `javascript:` href,
|
|
30
|
+
* while the identical payload injected AFTER the sanitiser did. Stripping it
|
|
31
|
+
* therefore removed real capability (remark-directive, footnotes, custom
|
|
32
|
+
* syntax) and closed nothing.
|
|
33
|
+
*
|
|
34
|
+
* If a consumer needs to widen what survives SANITISATION, `allowedTags` /
|
|
35
|
+
* `literalTagContent` are the supported seam: they MERGE into the sanitize
|
|
36
|
+
* schema instead of replacing the pipeline outright.
|
|
37
|
+
*
|
|
38
|
+
* THIS LIST HAS A PARITY COUNTERPART, AND EDITING ONE SIDE FAILS CI (#75).
|
|
39
|
+
* `scripts/check/rules/sanitizer-passthrough.mjs` declares the same key set as
|
|
40
|
+
* `SAFE_RENDERERS[streamdown].dangerousProps` and reads THIS array literal back
|
|
41
|
+
* out of this file, asserting set-equality in BOTH directions — so removing a
|
|
42
|
+
* key here (the runtime stops stripping it) and removing a key there (the gate
|
|
43
|
+
* stops looking for it) are each a red build, and so is renaming the constant
|
|
44
|
+
* or rewriting it into a shape the gate cannot parse. Change the key set in
|
|
45
|
+
* both places, in the same commit, or don't change it.
|
|
46
|
+
*/
|
|
47
|
+
const SANITIZER_OVERRIDE_KEYS = ["rehypePlugins"] as const;
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Deletes `rehypePlugins` off `props` in place (if present) and warns in dev.
|
|
51
|
+
* Call this BEFORE spreading `props` onto `<Streamdown>`.
|
|
52
|
+
*
|
|
53
|
+
* Takes a plain object rather than a typed `MarkdownViewProps`/
|
|
54
|
+
* `MessageResponseProps` on purpose: the whole point is to catch a value that
|
|
55
|
+
* reached this call with a wider runtime shape than its type says (a `.tsx`
|
|
56
|
+
* consumer with no type checking at all, a force-cast, a spread of a bigger
|
|
57
|
+
* object) — a parameter typed to the post-`Omit` props would only accept
|
|
58
|
+
* values TypeScript already believes are safe.
|
|
59
|
+
*/
|
|
60
|
+
export function stripSanitizerOverrides(props: object): void {
|
|
61
|
+
const record = props as Record<string, unknown>;
|
|
62
|
+
for (const key of SANITIZER_OVERRIDE_KEYS) {
|
|
63
|
+
if (key in record) {
|
|
64
|
+
if (process.env.NODE_ENV !== "production") {
|
|
65
|
+
console.warn(
|
|
66
|
+
`[@elabs-ai/components-ai] \`${key}\` is not a supported prop on this component: ` +
|
|
67
|
+
"it would replace Streamdown's default sanitiser chain (rehype-raw → " +
|
|
68
|
+
"rehype-sanitize → rehype-harden) and let untrusted markdown execute " +
|
|
69
|
+
"script in the host page. The value was ignored. Use `allowedTags` / " +
|
|
70
|
+
"`literalTagContent` to widen sanitisation instead — see issue #36.",
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
delete record[key];
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* The CONVERSE of `SANITIZER_OVERRIDE_KEYS`: the plugin slots that deliberately
|
|
80
|
+
* stay reachable even though they land in the DOM *after* — or entirely outside
|
|
81
|
+
* — Streamdown's sanitiser chain (#76).
|
|
82
|
+
*
|
|
83
|
+
* - **`math.rehypePlugin` is APPENDED to the END of the rehype pipeline**, i.e.
|
|
84
|
+
* after `rehype-raw` → `rehype-sanitize` → `rehype-harden` (verified against
|
|
85
|
+
* `streamdown@2.5.0`'s `dist/chunk-BO2N2NFS.js`). Its output is never
|
|
86
|
+
* re-sanitised.
|
|
87
|
+
* - **`mermaid` never enters the rehype/remark pipeline at all.** Its
|
|
88
|
+
* `getMermaid().render()` result is written via `dangerouslySetInnerHTML`
|
|
89
|
+
* (streamdown's only such sink). brand-ui's own default pins mermaid's
|
|
90
|
+
* `securityLevel: "strict"` (`_lazy-mermaid.ts`).
|
|
91
|
+
*
|
|
92
|
+
* `cjk` (remark-stage, re-sanitised downstream), `code` (feeds `shikiTheme`
|
|
93
|
+
* only) and `renderers` (ordinary React components) are NOT trust-bearing and
|
|
94
|
+
* are deliberately absent — a warning that fired on them would be noise
|
|
95
|
+
* consumers learn to ignore.
|
|
96
|
+
*
|
|
97
|
+
* **This is not a live vulnerability and must not be described as one.**
|
|
98
|
+
* Supplying an executable `Pluggable` takes code in the consuming app's own
|
|
99
|
+
* bundle, and an app author who can do that can already run code. The real,
|
|
100
|
+
* non-hypothetical hazard is MISCONFIGURATION — most sharply KaTeX's `trust`
|
|
101
|
+
* option (needed for anything beyond `singleDollarTextMath`/`errorColor`),
|
|
102
|
+
* which silently disables KaTeX's own sanitisation for model-authored content
|
|
103
|
+
* the consumer never intended to trust. Hence: WARN, never strip. Stripping
|
|
104
|
+
* would break the legitimate trusted use and is the breaking change #76
|
|
105
|
+
* explicitly rejects — `markdown-view.test.tsx`/`message.test.tsx` pin the slot
|
|
106
|
+
* OPEN so a future "hardening" fails loudly.
|
|
107
|
+
*/
|
|
108
|
+
const TRUSTED_PLUGIN_SLOT_ADVICE =
|
|
109
|
+
"This is a supported, TRUSTED-CODE seam — the plugin still runs and nothing was " +
|
|
110
|
+
"dropped — so treat it as code you ship: it can put arbitrary markup in the page. " +
|
|
111
|
+
"If you only needed KaTeX options, prefer `createMathPlugin({ singleDollarTextMath, " +
|
|
112
|
+
"errorColor })` and leave KaTeX's `trust` option off for model-authored content. " +
|
|
113
|
+
"See docs/CSP-AND-NETWORK.md and issue #76.";
|
|
114
|
+
|
|
115
|
+
const isPluginObject = (value: unknown): value is Record<string, unknown> =>
|
|
116
|
+
typeof value === "object" && value !== null;
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Dev-only `console.warn` when a consumer's `plugins` override replaces one of
|
|
120
|
+
* the two slots that are NOT upstream of the sanitiser. Call it where the
|
|
121
|
+
* consumer's overrides are merged over the internal defaults, passing both.
|
|
122
|
+
*
|
|
123
|
+
* **Reference-equality comparator, deliberately — not truthiness, not a deep
|
|
124
|
+
* compare.** `useStreamdownPlugins()` returns a FRESH `code` object on every
|
|
125
|
+
* theme change but a STABLE `math`/`mermaid`, so a truthiness check would fire
|
|
126
|
+
* on every theme flip and a deep-equality check would stay silent for a consumer
|
|
127
|
+
* who rebuilds an equivalent-looking object each render. For `math` the
|
|
128
|
+
* comparison narrows further to the `rehypePlugin` member itself, so changing
|
|
129
|
+
* only `remarkPlugin`/`getStyles` (upstream of, or outside, the DOM sink) stays
|
|
130
|
+
* silent.
|
|
131
|
+
*
|
|
132
|
+
* Warns; never mutates. `stripSanitizerOverrides` is the other half of the
|
|
133
|
+
* boundary — the props that must never be reachable at all.
|
|
134
|
+
*/
|
|
135
|
+
export function warnOnTrustedPluginSlots(overrides: object | undefined, internal: object): void {
|
|
136
|
+
if (process.env.NODE_ENV === "production") return;
|
|
137
|
+
if (!overrides) return;
|
|
138
|
+
const consumer = overrides as Record<string, unknown>;
|
|
139
|
+
const defaults = internal as Record<string, unknown>;
|
|
140
|
+
|
|
141
|
+
if (isPluginObject(consumer.math)) {
|
|
142
|
+
const theirs = consumer.math.rehypePlugin;
|
|
143
|
+
const ours = isPluginObject(defaults.math) ? defaults.math.rehypePlugin : undefined;
|
|
144
|
+
if (theirs !== ours) {
|
|
145
|
+
console.warn(
|
|
146
|
+
"[@elabs-ai/components-ai] `plugins.math.rehypePlugin` is appended to the END of " +
|
|
147
|
+
"Streamdown's rehype pipeline, so it runs AFTER the sanitiser chain " +
|
|
148
|
+
"(rehype-raw → rehype-sanitize → rehype-harden) and whatever it emits is never " +
|
|
149
|
+
`re-sanitised. ${TRUSTED_PLUGIN_SLOT_ADVICE}`,
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
if (isPluginObject(consumer.mermaid) && consumer.mermaid !== defaults.mermaid) {
|
|
155
|
+
// The message describes React's `dangerouslySetInnerHTML` in prose rather than
|
|
156
|
+
// naming the identifier: the `trusted-types-sinks` check preserves string literals on
|
|
157
|
+
// purpose (a sink hidden in a string is still a sink), so spelling the prop out
|
|
158
|
+
// inside this warning makes the gate read THIS file as a new HTML-assigning
|
|
159
|
+
// module. Comments like this one are stripped by that gate, so the name is safe
|
|
160
|
+
// here and in the TSDoc above — just not inside a runtime string.
|
|
161
|
+
console.warn(
|
|
162
|
+
"[@elabs-ai/components-ai] `plugins.mermaid` never enters the rehype pipeline at " +
|
|
163
|
+
"all: its rendered SVG is injected as raw HTML through React's dangerous " +
|
|
164
|
+
"inner-HTML prop, i.e. after and outside the sanitiser chain " +
|
|
165
|
+
"(rehype-raw → rehype-sanitize → rehype-harden). A " +
|
|
166
|
+
"replacement plugin must sanitise its own output — brand-ui's default pins " +
|
|
167
|
+
`mermaid's strict security level. ${TRUSTED_PLUGIN_SLOT_ADVICE}`,
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from "@storybook/react-vite";
|
|
2
|
+
import { AgentEvent } from "./agent-event";
|
|
3
|
+
import { AgentStep, AgentTimeline } from "./agent-timeline";
|
|
4
|
+
|
|
5
|
+
const meta = {
|
|
6
|
+
title: "AI/AgentEvent",
|
|
7
|
+
component: AgentEvent,
|
|
8
|
+
parameters: {
|
|
9
|
+
layout: "padded",
|
|
10
|
+
docs: {
|
|
11
|
+
description: {
|
|
12
|
+
component:
|
|
13
|
+
"The CHAT lifecycle/guard event line; the console skin is `Terminal/TerminalEventLine`, and both read the same `AgentEventPhase` / `AgentEventOutcome` / `CheckSummary` model from `@elabs-ai/components-ui` — see [Choosing between similar components](?path=/docs/docs-choosing-between-similar-components--docs). It is an `AgentStep` VARIANT on the existing `AI/AgentTimeline` rail, not a second spine: the same ordered list, distinguished by glyph and label.",
|
|
14
|
+
},
|
|
15
|
+
},
|
|
16
|
+
},
|
|
17
|
+
tags: ["autodocs"],
|
|
18
|
+
} satisfies Meta<typeof AgentEvent>;
|
|
19
|
+
export default meta;
|
|
20
|
+
type Story = StoryObj<typeof meta>;
|
|
21
|
+
|
|
22
|
+
// A lifecycle event with no gated action — an AgentStep variant on the SAME
|
|
23
|
+
// rail, distinguished from an ordinary step by glyph + label only (#109).
|
|
24
|
+
export const Default: Story = {
|
|
25
|
+
render: () => (
|
|
26
|
+
<AgentTimeline className="max-w-prose">
|
|
27
|
+
<AgentEvent label="user_prompt_submit" phase="lifecycle" outcome="ok" />
|
|
28
|
+
</AgentTimeline>
|
|
29
|
+
),
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
// One hook firing before a tool call and one firing after — the event-level
|
|
33
|
+
// `phase` ("before" | "after" | "lifecycle") is a separate, wider prop than
|
|
34
|
+
// any individual check's own `phase`.
|
|
35
|
+
export const BeforeAndAfterHooks: Story = {
|
|
36
|
+
render: () => (
|
|
37
|
+
<AgentTimeline className="max-w-prose">
|
|
38
|
+
<AgentEvent label="pre_tool_use" phase="before" outcome="ok" durationMs={42} />
|
|
39
|
+
<AgentEvent label="post_tool_use" phase="after" outcome="ok" durationMs={128} />
|
|
40
|
+
</AgentTimeline>
|
|
41
|
+
),
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
// checks as a count summary — "passed/ran" rendered as plain text.
|
|
45
|
+
export const CheckSummaryCount: Story = {
|
|
46
|
+
render: () => (
|
|
47
|
+
<AgentTimeline className="max-w-prose">
|
|
48
|
+
<AgentEvent
|
|
49
|
+
label="pre_tool_use"
|
|
50
|
+
phase="before"
|
|
51
|
+
outcome="ok"
|
|
52
|
+
durationMs={210}
|
|
53
|
+
checks={{ ran: 4, passed: 4 }}
|
|
54
|
+
/>
|
|
55
|
+
</AgentTimeline>
|
|
56
|
+
),
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
// checks as individual verdicts — a blocked event whose gate had a failing
|
|
60
|
+
// check. Pass/fail never rides colour alone: each row pairs a distinct icon
|
|
61
|
+
// with visible "Passed"/"Failed" text (see accessibility.md's greyscale test).
|
|
62
|
+
export const BlockedWithFailingCheck: Story = {
|
|
63
|
+
render: () => (
|
|
64
|
+
<AgentTimeline className="max-w-prose">
|
|
65
|
+
<AgentEvent
|
|
66
|
+
label="pre_tool_use"
|
|
67
|
+
phase="before"
|
|
68
|
+
outcome="blocked"
|
|
69
|
+
durationMs={340}
|
|
70
|
+
checks={[
|
|
71
|
+
{ label: "policy: no secrets", ok: true, durationMs: 12 },
|
|
72
|
+
{ label: "policy: write scope", ok: false, detail: "path escapes workspace root" },
|
|
73
|
+
]}
|
|
74
|
+
/>
|
|
75
|
+
</AgentTimeline>
|
|
76
|
+
),
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
// A failed hook alongside ordinary AgentStep entries, proving AgentEvent
|
|
80
|
+
// rides the same <ol> rail as any other step — no second spine (#109 AC1).
|
|
81
|
+
export const WithinAgentTimeline: Story = {
|
|
82
|
+
name: "Alongside AgentStep on one rail",
|
|
83
|
+
render: () => (
|
|
84
|
+
<AgentTimeline className="max-w-prose">
|
|
85
|
+
<AgentEvent label="user_prompt_submit" phase="lifecycle" outcome="ok" />
|
|
86
|
+
<AgentStep status="complete" name="Searched financial filings" summary="3 documents" />
|
|
87
|
+
<AgentEvent
|
|
88
|
+
label="post_tool_use"
|
|
89
|
+
phase="after"
|
|
90
|
+
outcome="failed"
|
|
91
|
+
durationMs={95}
|
|
92
|
+
checks={[{ label: "output schema", ok: false, detail: "missing required field: total" }]}
|
|
93
|
+
/>
|
|
94
|
+
<AgentEvent label="stop" phase="lifecycle" outcome="ok" durationMs={64200} />
|
|
95
|
+
</AgentTimeline>
|
|
96
|
+
),
|
|
97
|
+
};
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { render, screen } from "@testing-library/react";
|
|
3
|
+
import { AgentEvent } from "./agent-event";
|
|
4
|
+
import { AgentStep, AgentTimeline } from "./agent-timeline";
|
|
5
|
+
|
|
6
|
+
describe("AgentEvent (#109)", () => {
|
|
7
|
+
it("renders on the same <ol> rail as an AgentStep — no second spine", () => {
|
|
8
|
+
render(
|
|
9
|
+
<AgentTimeline data-testid="timeline">
|
|
10
|
+
<AgentStep name="Queried finance.revenue" status="complete" />
|
|
11
|
+
<AgentEvent label="pre_tool_use" outcome="ok" />
|
|
12
|
+
</AgentTimeline>,
|
|
13
|
+
);
|
|
14
|
+
expect(screen.getByTestId("timeline").tagName).toBe("OL");
|
|
15
|
+
const items = screen.getAllByRole("listitem");
|
|
16
|
+
expect(items).toHaveLength(2);
|
|
17
|
+
// The AgentEvent item carries the same data-slot as any other li in this
|
|
18
|
+
// rail's list structure, and is reachable via getAllByRole("listitem")
|
|
19
|
+
// exactly like AgentStep — i.e. one list, not a nested second rail.
|
|
20
|
+
expect(items[1]).toHaveAttribute("data-slot", "agent-event");
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("maps outcome onto the existing closed Status — no new status value", () => {
|
|
24
|
+
const { rerender } = render(
|
|
25
|
+
<AgentTimeline>
|
|
26
|
+
<AgentEvent label="stop" outcome="ok" />
|
|
27
|
+
</AgentTimeline>,
|
|
28
|
+
);
|
|
29
|
+
expect(screen.getByRole("listitem")).toHaveAttribute("data-status", "complete");
|
|
30
|
+
|
|
31
|
+
rerender(
|
|
32
|
+
<AgentTimeline>
|
|
33
|
+
<AgentEvent label="pre_tool_use" outcome="blocked" />
|
|
34
|
+
</AgentTimeline>,
|
|
35
|
+
);
|
|
36
|
+
expect(screen.getByRole("listitem")).toHaveAttribute("data-status", "denied");
|
|
37
|
+
|
|
38
|
+
rerender(
|
|
39
|
+
<AgentTimeline>
|
|
40
|
+
<AgentEvent label="post_tool_use" outcome="failed" />
|
|
41
|
+
</AgentTimeline>,
|
|
42
|
+
);
|
|
43
|
+
expect(screen.getByRole("listitem")).toHaveAttribute("data-status", "failed");
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("renders the label and, when given, the event-level phase as visible text", () => {
|
|
47
|
+
render(
|
|
48
|
+
<AgentTimeline>
|
|
49
|
+
<AgentEvent label="user_prompt_submit" phase="lifecycle" outcome="ok" />
|
|
50
|
+
</AgentTimeline>,
|
|
51
|
+
);
|
|
52
|
+
expect(screen.getByText("user_prompt_submit")).toBeInTheDocument();
|
|
53
|
+
// The phase renders as its own data-slot part, distinct from the
|
|
54
|
+
// per-check `phase` field on CheckResult (never the same prop/slot), and
|
|
55
|
+
// carries real, non-empty visible text.
|
|
56
|
+
const phaseNode = document.querySelector('[data-slot="agent-event-phase"]');
|
|
57
|
+
expect(phaseNode).not.toBeNull();
|
|
58
|
+
expect(phaseNode?.textContent?.trim()).toBeTruthy();
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("renders no phase part when phase is omitted", () => {
|
|
62
|
+
render(
|
|
63
|
+
<AgentTimeline>
|
|
64
|
+
<AgentEvent label="post_tool_use" outcome="ok" />
|
|
65
|
+
</AgentTimeline>,
|
|
66
|
+
);
|
|
67
|
+
expect(document.querySelector('[data-slot="agent-event-phase"]')).toBeNull();
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("renders a failed check distinguishably from a passed one in ACCESSIBLE TEXT, not only a colour class", () => {
|
|
71
|
+
render(
|
|
72
|
+
<AgentTimeline>
|
|
73
|
+
<AgentEvent
|
|
74
|
+
label="post_tool_use"
|
|
75
|
+
outcome="failed"
|
|
76
|
+
checks={[
|
|
77
|
+
{ label: "eslint", ok: true },
|
|
78
|
+
{ label: "tsc", ok: false, detail: "2 errors" },
|
|
79
|
+
]}
|
|
80
|
+
/>
|
|
81
|
+
</AgentTimeline>,
|
|
82
|
+
);
|
|
83
|
+
|
|
84
|
+
const passRow = screen.getByText("eslint").closest('[data-slot="agent-event-check"]');
|
|
85
|
+
const failRow = screen.getByText("tsc").closest('[data-slot="agent-event-check"]');
|
|
86
|
+
expect(passRow).not.toBeNull();
|
|
87
|
+
expect(failRow).not.toBeNull();
|
|
88
|
+
|
|
89
|
+
// data-ok is a machine attribute — not itself sufficient (invisible to AT).
|
|
90
|
+
expect(passRow).toHaveAttribute("data-ok", "true");
|
|
91
|
+
expect(failRow).toHaveAttribute("data-ok", "false");
|
|
92
|
+
|
|
93
|
+
// The ACCESSIBLE TEXT differs between the two rows: the status-word node's
|
|
94
|
+
// text content is not the same string for a pass vs a fail. This holds
|
|
95
|
+
// whether `t()` resolves a registered translation or falls back to the
|
|
96
|
+
// raw key, so the assertion does not hardcode "Passed"/"Failed" copy that
|
|
97
|
+
// this package does not own.
|
|
98
|
+
const passStatus = passRow?.querySelector('[data-slot="agent-event-check-status"]');
|
|
99
|
+
const failStatus = failRow?.querySelector('[data-slot="agent-event-check-status"]');
|
|
100
|
+
expect(passStatus?.textContent).toBeTruthy();
|
|
101
|
+
expect(failStatus?.textContent).toBeTruthy();
|
|
102
|
+
expect(passStatus?.textContent).not.toBe(failStatus?.textContent);
|
|
103
|
+
|
|
104
|
+
// The failing check's detail line is also visible, real text (not only color).
|
|
105
|
+
expect(screen.getByText("2 errors")).toBeInTheDocument();
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it("renders a CheckSummary as passed/ran text instead of a row list", () => {
|
|
109
|
+
// Numeric interpolation into the localized "passed/ran" copy is exercised
|
|
110
|
+
// by the shared `t()`/messages machinery elsewhere (this package does not
|
|
111
|
+
// own `messages.ts` — see the message keys reported alongside this
|
|
112
|
+
// component). Here we assert the STRUCTURAL contract: a count summary
|
|
113
|
+
// renders one text node in the summary slot, never the per-row list.
|
|
114
|
+
render(
|
|
115
|
+
<AgentTimeline>
|
|
116
|
+
<AgentEvent label="pre_tool_use" outcome="ok" checks={{ ran: 4, passed: 3 }} />
|
|
117
|
+
</AgentTimeline>,
|
|
118
|
+
);
|
|
119
|
+
const summary = document.querySelector('[data-slot="agent-event-checks-summary"]');
|
|
120
|
+
expect(summary).not.toBeNull();
|
|
121
|
+
expect(summary?.textContent?.trim()).toBeTruthy();
|
|
122
|
+
expect(document.querySelector('[data-slot="agent-event-checks"]')).toBeNull();
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it("renders duration via the shared formatElapsed, not a second formatter", () => {
|
|
126
|
+
render(
|
|
127
|
+
<AgentTimeline>
|
|
128
|
+
<AgentEvent label="stop" outcome="ok" durationMs={8000} />
|
|
129
|
+
</AgentTimeline>,
|
|
130
|
+
);
|
|
131
|
+
// formatElapsed(8000) === "8.0s" (packages/ui/src/lib/format-duration.ts)
|
|
132
|
+
expect(screen.getByText("8.0s")).toBeInTheDocument();
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it("merges className and spreads props on the underlying li, like AgentStep", () => {
|
|
136
|
+
render(
|
|
137
|
+
<AgentTimeline>
|
|
138
|
+
<AgentEvent label="stop" className="custom-event" aria-label="event" />
|
|
139
|
+
</AgentTimeline>,
|
|
140
|
+
);
|
|
141
|
+
const li = screen.getByRole("listitem");
|
|
142
|
+
expect(li).toHaveClass("custom-event");
|
|
143
|
+
expect(li).toHaveAttribute("aria-label", "event");
|
|
144
|
+
});
|
|
145
|
+
});
|