@elabs-ai/components-ai 4.0.0 → 4.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (180) hide show
  1. package/README.md +62 -12
  2. package/dist/{_audio-player-media-chrome-KA5DY54G.js → _audio-player-media-chrome-T3XVXWRZ.js} +8 -4
  3. package/dist/_audio-player-media-chrome-T3XVXWRZ.js.map +1 -0
  4. package/dist/{_flow-boundary-D63PJ65S.js → _flow-boundary-SHNWLQG5.js} +32 -43
  5. package/dist/_flow-boundary-SHNWLQG5.js.map +1 -0
  6. package/dist/{_persona-rive-RFR2EUWP.js → _persona-rive-JEG44YHX.js} +9 -5
  7. package/dist/_persona-rive-JEG44YHX.js.map +1 -0
  8. package/dist/index.d.ts +1152 -218
  9. package/dist/index.js +5601 -3241
  10. package/dist/index.js.map +1 -1
  11. package/package.json +30 -15
  12. package/src/__contract__/audio-visualizer.contract.test.tsx +49 -0
  13. package/src/__contract__/chat-shell.contract.test.tsx +49 -0
  14. package/src/__contract__/grouped-parts.contract.test.tsx +49 -0
  15. package/src/__contract__/image.contract.test.tsx +49 -0
  16. package/src/__contract__/markdown-view.contract.test.tsx +49 -0
  17. package/src/__contract__/message-feedback.contract.test.tsx +49 -0
  18. package/src/__contract__/message-form.contract.test.tsx +49 -0
  19. package/src/__contract__/message-table.contract.test.tsx +49 -0
  20. package/src/__contract__/model-provider-logo.contract.test.tsx +49 -0
  21. package/src/__contract__/persona.contract.test.tsx +49 -0
  22. package/src/__contract__/prompt-input-effort.contract.test.tsx +49 -0
  23. package/src/__contract__/prompt-input-mode.contract.test.tsx +49 -0
  24. package/src/_audio-player-media-chrome.tsx +102 -15
  25. package/src/_chat-shell-rail.tsx +2 -2
  26. package/src/_flow-boundary.tsx +68 -49
  27. package/src/_lazy-boundary-conformance.ts +38 -0
  28. package/src/_lazy-cjk.test.ts +43 -0
  29. package/src/_lazy-cjk.ts +73 -0
  30. package/src/_lazy-engine-boundary.tsx +61 -0
  31. package/src/_lazy-math.test.ts +63 -0
  32. package/src/_lazy-math.ts +90 -0
  33. package/src/_lazy-mermaid-absent.test.ts +53 -0
  34. package/src/_lazy-mermaid.test.ts +15 -0
  35. package/src/_lazy-mermaid.ts +24 -1
  36. package/src/_mermaid-error-panel.test.tsx +50 -0
  37. package/src/_mermaid-error-panel.tsx +66 -0
  38. package/src/_persona-rive.tsx +62 -10
  39. package/src/_streamdown-i18n.ts +94 -22
  40. package/src/_streamdown-safety.ts +170 -0
  41. package/src/_theme-scope-store.test.ts +83 -0
  42. package/src/_theme-scope-store.ts +103 -0
  43. package/src/agent-event.stories.tsx +97 -0
  44. package/src/agent-event.test.tsx +145 -0
  45. package/src/agent-event.tsx +187 -0
  46. package/src/agent-timeline.stories.tsx +9 -1
  47. package/src/agent.test.tsx +47 -0
  48. package/src/agent.tsx +43 -24
  49. package/src/agentic-workspace.stories.tsx +27 -20
  50. package/src/artifact.tsx +17 -14
  51. package/src/asset-preview.test.tsx +40 -0
  52. package/src/asset-preview.tsx +83 -12
  53. package/src/attachments.tsx +7 -4
  54. package/src/audio-player.test.tsx +78 -0
  55. package/src/audio-player.tsx +243 -56
  56. package/src/audio-visualizer.stories.tsx +126 -0
  57. package/src/audio-visualizer.test.tsx +438 -0
  58. package/src/audio-visualizer.tsx +367 -0
  59. package/src/canvas.stories.tsx +150 -1
  60. package/src/chat-shell.stories.tsx +18 -3
  61. package/src/chat.stories.tsx +16 -2
  62. package/src/code-block.stories.tsx +9 -1
  63. package/src/code-block.test.tsx +100 -1
  64. package/src/code-block.tsx +166 -103
  65. package/src/commit.tsx +30 -41
  66. package/src/composer.stories.tsx +372 -9
  67. package/src/composer.test.tsx +357 -6
  68. package/src/composer.tsx +217 -35
  69. package/src/confirmation.stories.tsx +72 -1
  70. package/src/confirmation.test.tsx +216 -2
  71. package/src/confirmation.tsx +264 -4
  72. package/src/context-panel.stories.tsx +9 -1
  73. package/src/context-panel.tsx +3 -2
  74. package/src/conversation.stories.tsx +86 -2
  75. package/src/conversation.test.tsx +66 -0
  76. package/src/conversation.tsx +52 -9
  77. package/src/diff-view.stories.tsx +196 -0
  78. package/src/diff-view.test.tsx +238 -0
  79. package/src/diff-view.tsx +697 -0
  80. package/src/environment-variables.tsx +20 -37
  81. package/src/file-tree.test.tsx +21 -0
  82. package/src/file-tree.tsx +12 -2
  83. package/src/gallery.tsx +4 -2
  84. package/src/index.ts +14 -4
  85. package/src/inline-citation.tsx +5 -5
  86. package/src/jsx-preview.stories.tsx +2 -2
  87. package/src/jsx-preview.tsx +151 -43
  88. package/src/markdown-view.stories.tsx +92 -1
  89. package/src/markdown-view.test.tsx +237 -2
  90. package/src/markdown-view.tsx +157 -6
  91. package/src/message-compare.stories.tsx +175 -0
  92. package/src/message-compare.test.tsx +207 -0
  93. package/src/message-compare.tsx +453 -0
  94. package/src/message-form.stories.tsx +65 -4
  95. package/src/message-form.test.tsx +8 -2
  96. package/src/message-form.tsx +15 -7
  97. package/src/message-table.stories.tsx +2 -2
  98. package/src/message-table.test.tsx +7 -0
  99. package/src/message-table.tsx +8 -4
  100. package/src/message.stories.tsx +9 -1
  101. package/src/message.test.tsx +247 -1
  102. package/src/message.tsx +125 -15
  103. package/src/microcopy.test.tsx +40 -0
  104. package/src/{model-selector.stories.tsx → model-provider-logo.stories.tsx} +17 -8
  105. package/src/model-provider-logo.test.tsx +109 -0
  106. package/src/model-provider-logo.tsx +183 -0
  107. package/src/open-in-chat.tsx +50 -29
  108. package/src/package-info.tsx +12 -12
  109. package/src/permission-mode-select.stories.tsx +82 -0
  110. package/src/permission-mode-select.test.tsx +100 -0
  111. package/src/permission-mode-select.tsx +137 -0
  112. package/src/persona-missing-peer.test.tsx +54 -0
  113. package/src/persona.tsx +68 -22
  114. package/src/plan.stories.tsx +166 -0
  115. package/src/plan.test.tsx +267 -0
  116. package/src/plan.tsx +182 -20
  117. package/src/prompt-input-effort.stories.tsx +123 -0
  118. package/src/prompt-input-effort.test.tsx +83 -0
  119. package/src/prompt-input-effort.tsx +136 -0
  120. package/src/prompt-input-mode.stories.tsx +108 -0
  121. package/src/prompt-input-mode.test.tsx +99 -0
  122. package/src/prompt-input-mode.tsx +169 -0
  123. package/src/prompt-input-slash.stories.tsx +211 -0
  124. package/src/prompt-input-slash.test.tsx +262 -0
  125. package/src/prompt-input-slash.tsx +541 -0
  126. package/src/prompt-input.stories.tsx +2 -2
  127. package/src/prompt-input.test.tsx +67 -1
  128. package/src/prompt-input.tsx +42 -4
  129. package/src/queue.tsx +4 -4
  130. package/src/reasoning.tsx +42 -17
  131. package/src/sandbox.stories.tsx +9 -1
  132. package/src/sandbox.tsx +3 -3
  133. package/src/schema-display.test.tsx +56 -0
  134. package/src/schema-display.tsx +71 -37
  135. package/src/selection-toolbar.stories.tsx +9 -1
  136. package/src/session-header.stories.tsx +128 -0
  137. package/src/session-header.test.tsx +138 -0
  138. package/src/session-header.tsx +243 -0
  139. package/src/session-status-bar.stories.tsx +73 -0
  140. package/src/session-status-bar.test.tsx +94 -0
  141. package/src/session-status-bar.tsx +165 -0
  142. package/src/snippet.stories.tsx +9 -1
  143. package/src/snippet.test.tsx +6 -2
  144. package/src/snippet.tsx +14 -34
  145. package/src/speech-input.test.tsx +109 -0
  146. package/src/speech-input.tsx +31 -3
  147. package/src/stack-trace.tsx +24 -36
  148. package/src/streamdown-i18n.test.tsx +24 -1
  149. package/src/task.stories.tsx +13 -3
  150. package/src/templates-ai-assistant.stories.tsx +21 -1
  151. package/src/test-results.tsx +47 -27
  152. package/src/token-usage.stories.tsx +24 -0
  153. package/src/token-usage.test.tsx +92 -0
  154. package/src/{context.tsx → token-usage.tsx} +73 -59
  155. package/src/tool-result-card.stories.tsx +9 -1
  156. package/src/tool.stories.tsx +13 -3
  157. package/src/tool.test.tsx +65 -0
  158. package/src/tool.tsx +73 -30
  159. package/src/transcription.tsx +1 -1
  160. package/src/turn-status.stories.tsx +124 -0
  161. package/src/turn-status.test.tsx +74 -0
  162. package/src/turn-status.tsx +174 -0
  163. package/src/use-audio-level.ts +104 -0
  164. package/src/voice-selector.tsx +5 -5
  165. package/src/web-preview.test.tsx +51 -1
  166. package/src/web-preview.tsx +46 -10
  167. package/dist/_audio-player-media-chrome-KA5DY54G.js.map +0 -1
  168. package/dist/_flow-boundary-D63PJ65S.js.map +0 -1
  169. package/dist/_interactive-terminal-xterm-ATJ5EW3G.js +0 -11
  170. package/dist/_interactive-terminal-xterm-ATJ5EW3G.js.map +0 -1
  171. package/dist/_persona-rive-RFR2EUWP.js.map +0 -1
  172. package/src/_interactive-terminal-xterm.ts +0 -32
  173. package/src/blocks-ai-composer.stories.tsx +0 -83
  174. package/src/context.stories.tsx +0 -16
  175. package/src/interactive-terminal.stories.tsx +0 -165
  176. package/src/interactive-terminal.test.tsx +0 -448
  177. package/src/interactive-terminal.tsx +0 -444
  178. package/src/model-selector.test.tsx +0 -55
  179. package/src/model-selector.tsx +0 -225
  180. package/src/terminal.tsx +0 -244
@@ -0,0 +1,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
+ });