@elabs-ai/components-ai 4.0.0 → 4.1.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 (128) hide show
  1. package/README.md +61 -11
  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 +1109 -202
  9. package/dist/index.js +4867 -2878
  10. package/dist/index.js.map +1 -1
  11. package/package.json +31 -16
  12. package/src/_audio-player-media-chrome.tsx +102 -15
  13. package/src/_flow-boundary.tsx +68 -49
  14. package/src/_lazy-boundary-conformance.ts +38 -0
  15. package/src/_lazy-engine-boundary.tsx +61 -0
  16. package/src/_lazy-mermaid-absent.test.ts +53 -0
  17. package/src/_lazy-mermaid.test.ts +15 -0
  18. package/src/_lazy-mermaid.ts +24 -1
  19. package/src/_mermaid-error-panel.test.tsx +50 -0
  20. package/src/_mermaid-error-panel.tsx +66 -0
  21. package/src/_persona-rive.tsx +62 -10
  22. package/src/_streamdown-i18n.ts +21 -1
  23. package/src/_streamdown-safety.ts +170 -0
  24. package/src/agent-event.stories.tsx +97 -0
  25. package/src/agent-event.test.tsx +145 -0
  26. package/src/agent-event.tsx +187 -0
  27. package/src/agent-timeline.stories.tsx +9 -1
  28. package/src/agent.test.tsx +47 -0
  29. package/src/agent.tsx +28 -14
  30. package/src/agentic-workspace.stories.tsx +27 -20
  31. package/src/artifact.tsx +15 -12
  32. package/src/audio-player.test.tsx +78 -0
  33. package/src/audio-player.tsx +243 -56
  34. package/src/audio-visualizer.stories.tsx +126 -0
  35. package/src/audio-visualizer.test.tsx +438 -0
  36. package/src/audio-visualizer.tsx +367 -0
  37. package/src/canvas.stories.tsx +150 -1
  38. package/src/chat-shell.stories.tsx +18 -3
  39. package/src/chat.stories.tsx +16 -2
  40. package/src/code-block.stories.tsx +9 -1
  41. package/src/composer.stories.tsx +372 -9
  42. package/src/composer.test.tsx +357 -6
  43. package/src/composer.tsx +217 -35
  44. package/src/confirmation.stories.tsx +72 -1
  45. package/src/confirmation.test.tsx +216 -2
  46. package/src/confirmation.tsx +263 -3
  47. package/src/context-panel.stories.tsx +9 -1
  48. package/src/context-panel.tsx +2 -1
  49. package/src/conversation.stories.tsx +63 -2
  50. package/src/conversation.test.tsx +13 -0
  51. package/src/conversation.tsx +20 -2
  52. package/src/diff-view.stories.tsx +196 -0
  53. package/src/diff-view.test.tsx +188 -0
  54. package/src/diff-view.tsx +642 -0
  55. package/src/gallery.tsx +4 -2
  56. package/src/index.ts +14 -4
  57. package/src/jsx-preview.stories.tsx +2 -2
  58. package/src/markdown-view.stories.tsx +92 -1
  59. package/src/markdown-view.test.tsx +232 -1
  60. package/src/markdown-view.tsx +150 -6
  61. package/src/message-compare.stories.tsx +175 -0
  62. package/src/message-compare.test.tsx +207 -0
  63. package/src/message-compare.tsx +453 -0
  64. package/src/message-form.stories.tsx +29 -1
  65. package/src/message.stories.tsx +9 -1
  66. package/src/message.test.tsx +176 -0
  67. package/src/message.tsx +90 -4
  68. package/src/microcopy.test.tsx +40 -0
  69. package/src/{model-selector.stories.tsx → model-provider-logo.stories.tsx} +17 -8
  70. package/src/{model-selector.test.tsx → model-provider-logo.test.tsx} +10 -10
  71. package/src/model-provider-logo.tsx +149 -0
  72. package/src/permission-mode-select.stories.tsx +82 -0
  73. package/src/permission-mode-select.test.tsx +100 -0
  74. package/src/permission-mode-select.tsx +137 -0
  75. package/src/persona-missing-peer.test.tsx +54 -0
  76. package/src/persona.tsx +68 -22
  77. package/src/plan.stories.tsx +166 -0
  78. package/src/plan.test.tsx +267 -0
  79. package/src/plan.tsx +182 -20
  80. package/src/prompt-input-effort.stories.tsx +123 -0
  81. package/src/prompt-input-effort.test.tsx +83 -0
  82. package/src/prompt-input-effort.tsx +136 -0
  83. package/src/prompt-input-mode.stories.tsx +108 -0
  84. package/src/prompt-input-mode.test.tsx +99 -0
  85. package/src/prompt-input-mode.tsx +169 -0
  86. package/src/prompt-input-slash.stories.tsx +211 -0
  87. package/src/prompt-input-slash.test.tsx +262 -0
  88. package/src/prompt-input-slash.tsx +541 -0
  89. package/src/prompt-input.stories.tsx +2 -2
  90. package/src/reasoning.tsx +27 -13
  91. package/src/sandbox.stories.tsx +9 -1
  92. package/src/schema-display.tsx +5 -2
  93. package/src/selection-toolbar.stories.tsx +9 -1
  94. package/src/session-header.stories.tsx +128 -0
  95. package/src/session-header.test.tsx +138 -0
  96. package/src/session-header.tsx +243 -0
  97. package/src/session-status-bar.stories.tsx +73 -0
  98. package/src/session-status-bar.test.tsx +94 -0
  99. package/src/session-status-bar.tsx +165 -0
  100. package/src/snippet.stories.tsx +9 -1
  101. package/src/stack-trace.tsx +6 -2
  102. package/src/streamdown-i18n.test.tsx +1 -1
  103. package/src/task.stories.tsx +13 -3
  104. package/src/templates-ai-assistant.stories.tsx +21 -1
  105. package/src/token-usage.stories.tsx +24 -0
  106. package/src/token-usage.test.tsx +92 -0
  107. package/src/{context.tsx → token-usage.tsx} +67 -53
  108. package/src/tool-result-card.stories.tsx +9 -1
  109. package/src/tool.stories.tsx +13 -3
  110. package/src/tool.tsx +15 -8
  111. package/src/turn-status.stories.tsx +124 -0
  112. package/src/turn-status.test.tsx +74 -0
  113. package/src/turn-status.tsx +174 -0
  114. package/src/use-audio-level.ts +104 -0
  115. package/src/web-preview.tsx +8 -3
  116. package/dist/_audio-player-media-chrome-KA5DY54G.js.map +0 -1
  117. package/dist/_flow-boundary-D63PJ65S.js.map +0 -1
  118. package/dist/_interactive-terminal-xterm-ATJ5EW3G.js +0 -11
  119. package/dist/_interactive-terminal-xterm-ATJ5EW3G.js.map +0 -1
  120. package/dist/_persona-rive-RFR2EUWP.js.map +0 -1
  121. package/src/_interactive-terminal-xterm.ts +0 -32
  122. package/src/blocks-ai-composer.stories.tsx +0 -83
  123. package/src/context.stories.tsx +0 -16
  124. package/src/interactive-terminal.stories.tsx +0 -165
  125. package/src/interactive-terminal.test.tsx +0 -448
  126. package/src/interactive-terminal.tsx +0 -444
  127. package/src/model-selector.tsx +0 -225
  128. package/src/terminal.tsx +0 -244
@@ -0,0 +1,66 @@
1
+ "use client";
2
+
3
+ /**
4
+ * The `errorComponent` Streamdown renders in place of a Mermaid diagram that
5
+ * failed (issue #33). Streamdown hands back the render failure as a plain
6
+ * STRING (`MermaidErrorComponentProps.error`, already reduced from whatever
7
+ * was thrown), so this distinguishes two different failures by matching that
8
+ * string rather than the original error object:
9
+ *
10
+ * - **The optional `mermaid` peer is not installed.** This is a CAPABILITY
11
+ * GAP, not something wrong with the diagram or the app — the same class as
12
+ * `@elabs-ai/components-viewer`'s `parser-missing`
13
+ * (@.claude/rules/viewer-components.md "A capability gap is not a
14
+ * failure"). Renders the neutral `StatePanel kind="empty"`, naming the
15
+ * package to install, announced `role="status"`, with no retry action
16
+ * (re-installing a dependency does not happen by clicking a button in the
17
+ * page).
18
+ * - **Anything else is a genuine render/syntax failure** — a real error, so
19
+ * it gets `StatePanel kind="error"` (`role="alert"`, set internally by
20
+ * `StatePanel`) wired to Streamdown's own `retry()`, which increments the
21
+ * render attempt and re-runs the diagram.
22
+ */
23
+ import { Button, StatePanel, isModuleNotFoundMessage, useLocale } from "@elabs-ai/components-ui";
24
+ import { EyeOffIcon } from "lucide-react";
25
+
26
+ export interface MermaidErrorPanelProps {
27
+ chart: string;
28
+ error: string;
29
+ retry: () => void;
30
+ }
31
+
32
+ export function MermaidErrorPanel({ error, retry }: MermaidErrorPanelProps) {
33
+ const { t } = useLocale();
34
+
35
+ if (isModuleNotFoundMessage(error)) {
36
+ const feature = t("ai.mermaid.feature");
37
+ return (
38
+ <div className="my-4" role="status" aria-live="polite">
39
+ <StatePanel
40
+ kind="empty"
41
+ // A dashed edge invites a drop; this panel accepts nothing. Solid —
42
+ // mirrors `@elabs-ai/components-viewer`'s `FileViewerError`.
43
+ className="border-solid"
44
+ icon={<EyeOffIcon aria-hidden="true" />}
45
+ title={t("ai.error.engineMissing", { feature })}
46
+ description={t("ai.error.engineMissingBody", { feature, packages: "mermaid" })}
47
+ />
48
+ </div>
49
+ );
50
+ }
51
+
52
+ return (
53
+ <div className="my-4">
54
+ <StatePanel
55
+ kind="error"
56
+ title={t("ai.mermaid.renderError")}
57
+ description={error}
58
+ actions={
59
+ <Button size="sm" variant="outline" onClick={retry}>
60
+ {t("ai.error.retry")}
61
+ </Button>
62
+ }
63
+ />
64
+ </div>
65
+ );
66
+ }
@@ -19,17 +19,45 @@
19
19
  import { cn } from "@elabs-ai/components-ui/lib/cn";
20
20
  import { useReducedMotion } from "@elabs-ai/components-tokens";
21
21
  import type { RiveParameters } from "@rive-app/react-webgl2";
22
- import {
22
+ // `import type` from an optional peer is safe HERE ONLY because this module is
23
+ // reached exclusively through `lazy(() => import("./_persona-rive"))` — it is
24
+ // never statically imported, so nothing in this file reaches the barrel's
25
+ // `.d.ts` declaration graph. `persona.tsx`'s PUBLIC types must never do this
26
+ // (issue #101) — see its module doc comment and `PersonaRiveEvent`/
27
+ // `PersonaRiveEventCallback`, which this module imports below and proves
28
+ // conformant against the real peer type at the bottom of this file.
29
+ // A NAMED import (`import { useRive } from "@rive-app/react-webgl2"`) is a
30
+ // static ESM binding a bundler must resolve at build time. Now that
31
+ // `@rive-app/react-webgl2` is a genuinely optional peer (issue #33), a
32
+ // consumer who has not installed it hits that resolution at the worst
33
+ // possible time: Vite's own optional-peer-dependency handling swaps in a
34
+ // build-time stub with no exports, and Rollup's static named-export check
35
+ // then fails the WHOLE APP BUILD — not a runtime error `LazyEngineBoundary`
36
+ // could ever see (confirmed against `fixtures/consumer-smoke`'s real Vite
37
+ // build). A namespace import defers every one of these to a plain property
38
+ // lookup, which Rollup does not statically validate, so the build always
39
+ // succeeds; a genuinely missing peer instead resolves the destructured hooks
40
+ // below to `undefined`, and the guard turns that into the render-phase throw
41
+ // `LazyEngineBoundary` (see `persona.tsx`) already catches.
42
+ import * as RiveModule from "@rive-app/react-webgl2";
43
+ import type { ReactNode } from "react";
44
+ import { memo, useEffect, useMemo, useRef, useState } from "react";
45
+
46
+ import type { AssertAssignable } from "./_lazy-boundary-conformance";
47
+ import type { PersonaRiveEventCallback } from "./persona";
48
+ import type { PersonaSource, PersonaState } from "./persona-sources";
49
+
50
+ const {
23
51
  useRive,
24
52
  useStateMachineInput,
25
53
  useViewModel,
26
54
  useViewModelInstance,
27
55
  useViewModelInstanceColor,
28
- } from "@rive-app/react-webgl2";
29
- import type { ReactNode } from "react";
30
- import { memo, useEffect, useMemo, useRef, useState } from "react";
56
+ } = RiveModule;
31
57
 
32
- import type { PersonaSource, PersonaState } from "./persona-sources";
58
+ if (!useRive) {
59
+ throw new Error("Cannot find module '@rive-app/react-webgl2'");
60
+ }
33
61
 
34
62
  // Delays Rive initialization by one frame so that React Strict Mode's
35
63
  // immediate unmount cycle never creates a WebGL2 context. Only the
@@ -139,12 +167,12 @@ PersonaWithoutModel.displayName = "PersonaWithoutModel";
139
167
 
140
168
  export interface PersonaRiveProps {
141
169
  className?: string;
142
- onLoad?: RiveParameters["onLoad"];
143
- onLoadError?: RiveParameters["onLoadError"];
144
- onPause?: RiveParameters["onPause"];
145
- onPlay?: RiveParameters["onPlay"];
170
+ onLoad?: PersonaRiveEventCallback;
171
+ onLoadError?: PersonaRiveEventCallback;
172
+ onPause?: PersonaRiveEventCallback;
173
+ onPlay?: PersonaRiveEventCallback;
146
174
  onReady?: () => void;
147
- onStop?: RiveParameters["onStop"];
175
+ onStop?: PersonaRiveEventCallback;
148
176
  source: PersonaSource;
149
177
  /** Resolved `.riv` URL — `Persona`'s `src` override, or the variant default. */
150
178
  src: string;
@@ -250,3 +278,27 @@ const PersonaRive = ({
250
278
  };
251
279
 
252
280
  export default PersonaRive;
281
+
282
+ /**
283
+ * Conformance assertion (issue #101): compile-time proof that the owned
284
+ * `PersonaRiveEventCallback` (declared in `persona.tsx`, structurally free of
285
+ * `@rive-app/react-webgl2`/`@rive-app/webgl2`) is still assignable to the REAL
286
+ * `RiveParameters["onLoad"]` — and, since `onLoad`/`onLoadError`/`onPause`/
287
+ * `onPlay`/`onStop` all share Rive's one `EventCallback` type, this single
288
+ * check covers all five. `AssertAssignable`'s type parameter is constrained
289
+ * (`TOwned extends TReal`), so if a future Rive release changes `Event`'s
290
+ * shape in a way `PersonaRiveEvent` no longer satisfies, this line fails to
291
+ * typecheck — caught by `pnpm --filter @elabs-ai/components-ai typecheck`
292
+ * locally, never shipped as a silent mismatch to a consumer.
293
+ *
294
+ * `AssertAssignable` itself is shared with `_audio-player-media-chrome.tsx`
295
+ * via `_lazy-boundary-conformance.ts` — see that module's doc comment for
296
+ * what this check can and cannot prove (it is one-directional: it cannot
297
+ * catch the owned type being NARROWER than the real one, which is exactly
298
+ * what `PersonaRiveEvent["data"]` is — see the CHANGELOG's "Breaking (types)"
299
+ * entry).
300
+ */
301
+ export type _PersonaRiveEventCallbackConformance = AssertAssignable<
302
+ PersonaRiveEventCallback,
303
+ NonNullable<RiveParameters["onLoad"]>
304
+ >;
@@ -36,6 +36,7 @@ import { useEffect, useMemo, useState } from "react";
36
36
  import type { StreamdownTranslations } from "streamdown";
37
37
  import { buildCodeBlockTheme } from "./_code-block-theme";
38
38
  import { lazyMermaid } from "./_lazy-mermaid";
39
+ import { MermaidErrorPanel } from "./_mermaid-error-panel";
39
40
 
40
41
  /**
41
42
  * Reactive replacement for `@streamdown/code`'s pre-configured `code` export
@@ -47,7 +48,7 @@ import { lazyMermaid } from "./_lazy-mermaid";
47
48
  *
48
49
  * Streamdown's dual-theme mechanism (`createCodePlugin({ themes: [light, dark] })`)
49
50
  * expects exactly TWO themes and picks between them purely via the `.dark` CSS
50
- * selector — but brand-ui ships THREE themes (light/dark),
51
+ * selector — but brand-ui ships EVERY theme, not just Shiki's light/dark,
51
52
  * and any dark-declaring theme matches `.dark` too (see `_code-block-theme.ts`). Passing a real
52
53
  * light/dark PAIR would still force such a theme into the dark slot. Instead
53
54
  * this pins BOTH slots to `buildCodeBlockTheme()` — the SAME brand-token-derived
@@ -95,6 +96,25 @@ export function useStreamdownPlugins() {
95
96
  return useMemo(() => ({ cjk, code, math, mermaid: lazyMermaid }), [code]);
96
97
  }
97
98
 
99
+ /**
100
+ * The top-level Streamdown `mermaid` prop (distinct from `plugins.mermaid`,
101
+ * the LAZY `DiagramPlugin` `useStreamdownPlugins()` wires above) — this is
102
+ * where a FAILED render is caught (issue #33). Streamdown hands its
103
+ * `errorComponent` a plain STRING describing what went wrong;
104
+ * `MermaidErrorPanel` decides whether that string names a missing optional
105
+ * peer (`mermaid` not installed — a capability gap, `StatePanel kind="empty"`)
106
+ * or a genuine diagram render failure (`StatePanel kind="error"`, with retry).
107
+ *
108
+ * A stable module-level object, not a fresh one per render: Streamdown only
109
+ * re-reads `mermaid.errorComponent` when this reference changes, and
110
+ * `MermaidErrorPanel` never varies at runtime. Exported as a hook to match
111
+ * `useStreamdownPlugins()`'s call-site shape.
112
+ */
113
+ const MERMAID_OPTIONS = { errorComponent: MermaidErrorPanel };
114
+ export function useStreamdownMermaidOptions() {
115
+ return MERMAID_OPTIONS;
116
+ }
117
+
98
118
  /*
99
119
  * The translation MAP moved down to `@elabs-ai/components-ui`
100
120
  * (`lib/streamdown-translations.ts`) when `@elabs-ai/components-viewer`
@@ -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-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: `pnpm csp-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,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
+ });