@elabs-ai/components-ai 4.0.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/LICENSE +21 -0
  2. package/README.md +133 -0
  3. package/dist/_audio-player-media-chrome-KA5DY54G.js +81 -0
  4. package/dist/_audio-player-media-chrome-KA5DY54G.js.map +1 -0
  5. package/dist/_flow-boundary-D63PJ65S.js +186 -0
  6. package/dist/_flow-boundary-D63PJ65S.js.map +1 -0
  7. package/dist/_interactive-terminal-xterm-ATJ5EW3G.js +11 -0
  8. package/dist/_interactive-terminal-xterm-ATJ5EW3G.js.map +1 -0
  9. package/dist/_persona-rive-RFR2EUWP.js +164 -0
  10. package/dist/_persona-rive-RFR2EUWP.js.map +1 -0
  11. package/dist/index.d.ts +4079 -0
  12. package/dist/index.js +11770 -0
  13. package/dist/index.js.map +1 -0
  14. package/package.json +87 -0
  15. package/src/_audio-player-media-chrome.tsx +146 -0
  16. package/src/_chat-shell-rail.tsx +18 -0
  17. package/src/_code-block-theme.test.ts +134 -0
  18. package/src/_code-block-theme.ts +184 -0
  19. package/src/_flow-boundary.tsx +237 -0
  20. package/src/_flow-lazy.ts +34 -0
  21. package/src/_interactive-terminal-xterm.ts +32 -0
  22. package/src/_lazy-mermaid.test.ts +101 -0
  23. package/src/_lazy-mermaid.ts +117 -0
  24. package/src/_persona-rive.tsx +252 -0
  25. package/src/_streamdown-i18n.ts +119 -0
  26. package/src/agent-timeline.stories.tsx +66 -0
  27. package/src/agent-timeline.test.tsx +74 -0
  28. package/src/agent-timeline.tsx +106 -0
  29. package/src/agent.tsx +113 -0
  30. package/src/agentic-workspace.stories.tsx +1099 -0
  31. package/src/artifact.stories.tsx +62 -0
  32. package/src/artifact.test.tsx +112 -0
  33. package/src/artifact.tsx +167 -0
  34. package/src/asset-preview.stories.tsx +100 -0
  35. package/src/asset-preview.test.tsx +126 -0
  36. package/src/asset-preview.tsx +218 -0
  37. package/src/attachments.tsx +393 -0
  38. package/src/audio-player.stories.tsx +99 -0
  39. package/src/audio-player.tsx +170 -0
  40. package/src/blocks-ai-composer.stories.tsx +83 -0
  41. package/src/canvas.stories.tsx +142 -0
  42. package/src/canvas.tsx +29 -0
  43. package/src/chain-of-thought.tsx +193 -0
  44. package/src/chat-greeting.stories.tsx +96 -0
  45. package/src/chat-greeting.test.tsx +91 -0
  46. package/src/chat-greeting.tsx +91 -0
  47. package/src/chat-shell.stories.tsx +123 -0
  48. package/src/chat-shell.test.tsx +113 -0
  49. package/src/chat-shell.tsx +97 -0
  50. package/src/chat.stories.tsx +99 -0
  51. package/src/checkpoint.tsx +54 -0
  52. package/src/code-block.stories.tsx +81 -0
  53. package/src/code-block.test.tsx +191 -0
  54. package/src/code-block.tsx +673 -0
  55. package/src/commit.tsx +343 -0
  56. package/src/composer.stories.tsx +188 -0
  57. package/src/composer.test.tsx +182 -0
  58. package/src/composer.tsx +202 -0
  59. package/src/confirmation.stories.tsx +61 -0
  60. package/src/confirmation.test.tsx +136 -0
  61. package/src/confirmation.tsx +273 -0
  62. package/src/connection.tsx +23 -0
  63. package/src/context-panel.stories.tsx +137 -0
  64. package/src/context-panel.test.tsx +198 -0
  65. package/src/context-panel.tsx +609 -0
  66. package/src/context.stories.tsx +16 -0
  67. package/src/context.tsx +363 -0
  68. package/src/controls.tsx +20 -0
  69. package/src/conversation.stories.tsx +26 -0
  70. package/src/conversation.test.tsx +123 -0
  71. package/src/conversation.tsx +143 -0
  72. package/src/dark-theme-variant.test.ts +47 -0
  73. package/src/edge.tsx +33 -0
  74. package/src/environment-variables.tsx +304 -0
  75. package/src/file-tree.stories.tsx +55 -0
  76. package/src/file-tree.test.tsx +101 -0
  77. package/src/file-tree.tsx +390 -0
  78. package/src/gallery.stories.tsx +182 -0
  79. package/src/gallery.test.tsx +150 -0
  80. package/src/gallery.tsx +737 -0
  81. package/src/grouped-parts.stories.tsx +104 -0
  82. package/src/grouped-parts.test.tsx +167 -0
  83. package/src/grouped-parts.tsx +247 -0
  84. package/src/image.stories.tsx +67 -0
  85. package/src/image.test.tsx +105 -0
  86. package/src/image.tsx +115 -0
  87. package/src/index.ts +93 -0
  88. package/src/inline-citation.stories.tsx +91 -0
  89. package/src/inline-citation.test.tsx +96 -0
  90. package/src/inline-citation.tsx +314 -0
  91. package/src/interactive-terminal.stories.tsx +165 -0
  92. package/src/interactive-terminal.test.tsx +448 -0
  93. package/src/interactive-terminal.tsx +444 -0
  94. package/src/jsx-preview.stories.tsx +265 -0
  95. package/src/jsx-preview.test.tsx +75 -0
  96. package/src/jsx-preview.tsx +409 -0
  97. package/src/markdown-view.stories.tsx +57 -0
  98. package/src/markdown-view.test.tsx +36 -0
  99. package/src/markdown-view.tsx +113 -0
  100. package/src/message-edit.stories.tsx +150 -0
  101. package/src/message-edit.test.tsx +115 -0
  102. package/src/message-edit.tsx +336 -0
  103. package/src/message-feedback.stories.tsx +53 -0
  104. package/src/message-feedback.test.tsx +62 -0
  105. package/src/message-feedback.tsx +117 -0
  106. package/src/message-form-spec.ts +349 -0
  107. package/src/message-form.stories.tsx +182 -0
  108. package/src/message-form.test.tsx +227 -0
  109. package/src/message-form.tsx +845 -0
  110. package/src/message-table-spec.ts +281 -0
  111. package/src/message-table.stories.tsx +176 -0
  112. package/src/message-table.test.tsx +144 -0
  113. package/src/message-table.tsx +366 -0
  114. package/src/message.stories.tsx +262 -0
  115. package/src/message.test.tsx +508 -0
  116. package/src/message.tsx +619 -0
  117. package/src/mic-selector.tsx +339 -0
  118. package/src/microcopy.test.tsx +123 -0
  119. package/src/model-selector.stories.tsx +73 -0
  120. package/src/model-selector.test.tsx +55 -0
  121. package/src/model-selector.tsx +225 -0
  122. package/src/motion-config.tsx +49 -0
  123. package/src/node.tsx +71 -0
  124. package/src/open-in-chat.tsx +340 -0
  125. package/src/package-info.tsx +205 -0
  126. package/src/panel.tsx +20 -0
  127. package/src/part-groups.ts +258 -0
  128. package/src/persona-sources.ts +59 -0
  129. package/src/persona.stories.tsx +111 -0
  130. package/src/persona.test.tsx +67 -0
  131. package/src/persona.tsx +148 -0
  132. package/src/plan.tsx +135 -0
  133. package/src/prompt-input.stories.tsx +261 -0
  134. package/src/prompt-input.test.tsx +392 -0
  135. package/src/prompt-input.tsx +1668 -0
  136. package/src/queue.tsx +237 -0
  137. package/src/reasoning.stories.tsx +81 -0
  138. package/src/reasoning.test.tsx +50 -0
  139. package/src/reasoning.tsx +229 -0
  140. package/src/sandbox.stories.tsx +62 -0
  141. package/src/sandbox.test.tsx +42 -0
  142. package/src/sandbox.tsx +149 -0
  143. package/src/schema-display.tsx +412 -0
  144. package/src/selection-toolbar.stories.tsx +86 -0
  145. package/src/selection-toolbar.test.tsx +89 -0
  146. package/src/selection-toolbar.tsx +192 -0
  147. package/src/shimmer.stories.tsx +10 -0
  148. package/src/shimmer.tsx +81 -0
  149. package/src/snippet.stories.tsx +19 -0
  150. package/src/snippet.test.tsx +116 -0
  151. package/src/snippet.tsx +138 -0
  152. package/src/sources.stories.tsx +34 -0
  153. package/src/sources.test.tsx +129 -0
  154. package/src/sources.tsx +93 -0
  155. package/src/speech-input.tsx +312 -0
  156. package/src/stack-trace.tsx +479 -0
  157. package/src/streamdown-i18n.test.tsx +163 -0
  158. package/src/suggestion.stories.tsx +75 -0
  159. package/src/suggestion.test.tsx +72 -0
  160. package/src/suggestion.tsx +120 -0
  161. package/src/task.stories.tsx +22 -0
  162. package/src/task.test.tsx +36 -0
  163. package/src/task.tsx +89 -0
  164. package/src/templates-ai-assistant.stories.tsx +142 -0
  165. package/src/terminal.tsx +244 -0
  166. package/src/test-results.stories.tsx +177 -0
  167. package/src/test-results.test.tsx +241 -0
  168. package/src/test-results.tsx +408 -0
  169. package/src/tool-result-card.stories.tsx +143 -0
  170. package/src/tool-result-card.test.tsx +72 -0
  171. package/src/tool-result-card.tsx +86 -0
  172. package/src/tool.stories.tsx +60 -0
  173. package/src/tool.test.tsx +63 -0
  174. package/src/tool.tsx +235 -0
  175. package/src/toolbar.tsx +44 -0
  176. package/src/transcription.tsx +118 -0
  177. package/src/voice-selector.tsx +469 -0
  178. package/src/web-preview.stories.tsx +67 -0
  179. package/src/web-preview.test.tsx +117 -0
  180. package/src/web-preview.tsx +283 -0
@@ -0,0 +1,252 @@
1
+ "use client";
2
+
3
+ /**
4
+ * The Rive half of `Persona`, split out so it can be `lazy()`-loaded.
5
+ *
6
+ * `@rive-app/react-webgl2` declares no `sideEffects`, so a static import from
7
+ * `persona.tsx` put the whole WebGL2 runtime (and its `rive.wasm`) into the entry
8
+ * chunk of every `@elabs-ai/components-ai` consumer — including the vast majority that never
9
+ * render a `Persona`. Keeping every Rive *value* import in this module, reached
10
+ * only through `lazy(() => import("./_persona-rive"))`, confines it to its own
11
+ * chunk. `persona.tsx` may still `import type` from Rive (types erase).
12
+ *
13
+ * See ADR 0019 and `pnpm heavy-deps:check`.
14
+ *
15
+ * @lazy-boundary This module must only ever be reached via `import()`. The gate
16
+ * fails if anything imports it statically, which would put Rive back in the
17
+ * entry chunk and make the `lazy()` pointless.
18
+ */
19
+ import { cn } from "@elabs-ai/components-ui/lib/cn";
20
+ import { useReducedMotion } from "@elabs-ai/components-tokens";
21
+ import type { RiveParameters } from "@rive-app/react-webgl2";
22
+ import {
23
+ useRive,
24
+ useStateMachineInput,
25
+ useViewModel,
26
+ useViewModelInstance,
27
+ useViewModelInstanceColor,
28
+ } from "@rive-app/react-webgl2";
29
+ import type { ReactNode } from "react";
30
+ import { memo, useEffect, useMemo, useRef, useState } from "react";
31
+
32
+ import type { PersonaSource, PersonaState } from "./persona-sources";
33
+
34
+ // Delays Rive initialization by one frame so that React Strict Mode's
35
+ // immediate unmount cycle never creates a WebGL2 context. Only the
36
+ // second (real) mount will initialise, avoiding context exhaustion.
37
+ const useStrictModeSafeInit = () => {
38
+ const [ready, setReady] = useState(false);
39
+
40
+ useEffect(() => {
41
+ const id = requestAnimationFrame(() => setReady(true));
42
+ return () => {
43
+ cancelAnimationFrame(id);
44
+ setReady(false);
45
+ };
46
+ }, []);
47
+
48
+ return ready;
49
+ };
50
+
51
+ // The state machine name is always 'default' for Elements AI visuals
52
+ const stateMachine = "default";
53
+
54
+ const getCurrentTheme = (): "light" | "dark" => {
55
+ if (typeof window !== "undefined") {
56
+ if (document.documentElement.classList.contains("dark")) {
57
+ return "dark";
58
+ }
59
+ if (window.matchMedia?.("(prefers-color-scheme: dark)").matches) {
60
+ return "dark";
61
+ }
62
+ }
63
+ return "light";
64
+ };
65
+
66
+ const useTheme = (enabled: boolean) => {
67
+ const [theme, setTheme] = useState<"light" | "dark">(getCurrentTheme);
68
+
69
+ useEffect(() => {
70
+ // Skip if not enabled (avoids unnecessary observers for non-dynamic-color variants)
71
+ if (!enabled) {
72
+ return;
73
+ }
74
+
75
+ // Watch for classList changes
76
+ const observer = new MutationObserver(() => {
77
+ setTheme(getCurrentTheme());
78
+ });
79
+
80
+ observer.observe(document.documentElement, {
81
+ attributeFilter: ["class"],
82
+ attributes: true,
83
+ });
84
+
85
+ // Watch for OS-level theme changes
86
+ let mql: MediaQueryList | null = null;
87
+ const handleMediaChange = () => {
88
+ setTheme(getCurrentTheme());
89
+ };
90
+
91
+ if (window.matchMedia) {
92
+ mql = window.matchMedia("(prefers-color-scheme: dark)");
93
+ mql.addEventListener("change", handleMediaChange);
94
+ }
95
+
96
+ return () => {
97
+ observer.disconnect();
98
+ if (mql) {
99
+ mql.removeEventListener("change", handleMediaChange);
100
+ }
101
+ };
102
+ }, [enabled]);
103
+
104
+ return theme;
105
+ };
106
+
107
+ interface PersonaWithModelProps {
108
+ rive: ReturnType<typeof useRive>["rive"];
109
+ source: PersonaSource;
110
+ children: ReactNode;
111
+ }
112
+
113
+ const PersonaWithModel = memo(({ rive, source, children }: PersonaWithModelProps) => {
114
+ const theme = useTheme(source.dynamicColor);
115
+ const viewModel = useViewModel(rive, { useDefault: true });
116
+ const viewModelInstance = useViewModelInstance(viewModel, {
117
+ rive,
118
+ useDefault: true,
119
+ });
120
+ const viewModelInstanceColor = useViewModelInstanceColor("color", viewModelInstance);
121
+
122
+ useEffect(() => {
123
+ if (!(viewModelInstanceColor && source.dynamicColor)) {
124
+ return;
125
+ }
126
+
127
+ const [r, g, b] = theme === "dark" ? [255, 255, 255] : [0, 0, 0];
128
+ viewModelInstanceColor.setRgb(r, g, b);
129
+ }, [viewModelInstanceColor, theme, source.dynamicColor]);
130
+
131
+ return children;
132
+ });
133
+
134
+ PersonaWithModel.displayName = "PersonaWithModel";
135
+
136
+ const PersonaWithoutModel = memo(({ children }: { children: ReactNode }) => children);
137
+
138
+ PersonaWithoutModel.displayName = "PersonaWithoutModel";
139
+
140
+ export interface PersonaRiveProps {
141
+ className?: string;
142
+ onLoad?: RiveParameters["onLoad"];
143
+ onLoadError?: RiveParameters["onLoadError"];
144
+ onPause?: RiveParameters["onPause"];
145
+ onPlay?: RiveParameters["onPlay"];
146
+ onReady?: () => void;
147
+ onStop?: RiveParameters["onStop"];
148
+ source: PersonaSource;
149
+ /** Resolved `.riv` URL — `Persona`'s `src` override, or the variant default. */
150
+ src: string;
151
+ state: PersonaState;
152
+ }
153
+
154
+ const PersonaRive = ({
155
+ className,
156
+ onLoad,
157
+ onLoadError,
158
+ onPause,
159
+ onPlay,
160
+ onReady,
161
+ onStop,
162
+ source,
163
+ src,
164
+ state,
165
+ }: PersonaRiveProps) => {
166
+ // Stabilize callbacks to prevent useRive from reinitializing
167
+ const callbacksRef = useRef({ onLoad, onLoadError, onPause, onPlay, onReady, onStop });
168
+
169
+ useEffect(() => {
170
+ callbacksRef.current = { onLoad, onLoadError, onPause, onPlay, onReady, onStop };
171
+ }, [onLoad, onLoadError, onPause, onPlay, onReady, onStop]);
172
+
173
+ const stableCallbacks = useMemo(
174
+ () => ({
175
+ onLoad: ((loadedRive) =>
176
+ callbacksRef.current.onLoad?.(loadedRive)) as RiveParameters["onLoad"],
177
+ onLoadError: ((err) =>
178
+ callbacksRef.current.onLoadError?.(err)) as RiveParameters["onLoadError"],
179
+ onPause: ((event) => callbacksRef.current.onPause?.(event)) as RiveParameters["onPause"],
180
+ onPlay: ((event) => callbacksRef.current.onPlay?.(event)) as RiveParameters["onPlay"],
181
+ onReady: () => callbacksRef.current.onReady?.(),
182
+ onStop: ((event) => callbacksRef.current.onStop?.(event)) as RiveParameters["onStop"],
183
+ }),
184
+ [],
185
+ );
186
+
187
+ // Delay initialisation by one frame to avoid creating (and leaking)
188
+ // a WebGL2 context during React Strict Mode's first throw-away mount.
189
+ const ready = useStrictModeSafeInit();
190
+ const prefersReducedMotion = useReducedMotion();
191
+
192
+ const { rive, RiveComponent } = useRive(
193
+ ready
194
+ ? {
195
+ autoplay: !prefersReducedMotion,
196
+ onLoad: stableCallbacks.onLoad,
197
+ onLoadError: stableCallbacks.onLoadError,
198
+ onPause: stableCallbacks.onPause,
199
+ onPlay: stableCallbacks.onPlay,
200
+ onRiveReady: stableCallbacks.onReady,
201
+ onStop: stableCallbacks.onStop,
202
+ src,
203
+ stateMachines: stateMachine,
204
+ }
205
+ : null,
206
+ );
207
+
208
+ // `autoplay` only governs the INITIAL mount — honor a mid-session
209
+ // reduced-motion preference change by pausing/resuming the running
210
+ // instance. The state-machine inputs below keep updating either way, so
211
+ // the artwork still shows the correct resting pose while paused.
212
+ useEffect(() => {
213
+ if (!rive) return;
214
+ if (prefersReducedMotion) {
215
+ rive.pause();
216
+ } else {
217
+ rive.play();
218
+ }
219
+ }, [rive, prefersReducedMotion]);
220
+
221
+ const listeningInput = useStateMachineInput(rive, stateMachine, "listening");
222
+ const thinkingInput = useStateMachineInput(rive, stateMachine, "thinking");
223
+ const speakingInput = useStateMachineInput(rive, stateMachine, "speaking");
224
+ const asleepInput = useStateMachineInput(rive, stateMachine, "asleep");
225
+
226
+ // Rive state machine inputs are mutable objects that must be set via direct
227
+ // property assignment — this is the intended Rive API, not a React anti-pattern.
228
+ useEffect(() => {
229
+ if (listeningInput) {
230
+ listeningInput.value = state === "listening";
231
+ }
232
+ if (thinkingInput) {
233
+ thinkingInput.value = state === "thinking";
234
+ }
235
+ if (speakingInput) {
236
+ speakingInput.value = state === "speaking";
237
+ }
238
+ if (asleepInput) {
239
+ asleepInput.value = state === "asleep";
240
+ }
241
+ }, [state, listeningInput, thinkingInput, speakingInput, asleepInput]);
242
+
243
+ const Wrapper = source.hasModel ? PersonaWithModel : PersonaWithoutModel;
244
+
245
+ return (
246
+ <Wrapper rive={rive} source={source}>
247
+ <RiveComponent className={cn("size-16 shrink-0", className)} />
248
+ </Wrapper>
249
+ );
250
+ };
251
+
252
+ export default PersonaRive;
@@ -0,0 +1,119 @@
1
+ "use client";
2
+
3
+ /**
4
+ * The shared Streamdown wiring for every streamed-markdown surface in
5
+ * `@elabs-ai/components-ai` — the plugin set and the locale bridge.
6
+ *
7
+ * Why the locale bridge exists (#310)
8
+ * -----------------------------------
9
+ * Streamdown renders its own chrome inside the markdown it draws: a code-block
10
+ * header with a copy control, table copy/download menus, a Mermaid toolbar, an
11
+ * external-link interstitial. Those labels live in the dependency, not in our
12
+ * source, so `pnpm microcopy:check` structurally cannot see them and a
13
+ * `<LocaleProvider>` used to stop at the Streamdown boundary — every localized
14
+ * app leaked "Copy Code" / "Download diagram" / "You're about to visit an
15
+ * external website." in English.
16
+ *
17
+ * Streamdown exposes the whole set through one `translations` prop, so the fix
18
+ * is to map its keys onto our `ai.streamdown.*` namespace and resolve them with
19
+ * `t()`. The English defaults in
20
+ * `packages/ui/src/components/locale-provider/messages.ts` are byte-identical to
21
+ * streamdown's own, so this is a no-op for anyone who overrides nothing.
22
+ *
23
+ * Memoization matters: Streamdown puts `translations` into a React context, so a
24
+ * fresh object per render would re-render every code block and table in the
25
+ * document. The map is memoized on `t`, which is itself stable per locale.
26
+ */
27
+ import {
28
+ STREAMDOWN_TRANSLATION_KEYS,
29
+ useStreamdownTranslations,
30
+ type StreamdownTranslationKey,
31
+ } from "@elabs-ai/components-ui";
32
+ import { cjk } from "@streamdown/cjk";
33
+ import { createCodePlugin } from "@streamdown/code";
34
+ import { math } from "@streamdown/math";
35
+ import { useEffect, useMemo, useState } from "react";
36
+ import type { StreamdownTranslations } from "streamdown";
37
+ import { buildCodeBlockTheme } from "./_code-block-theme";
38
+ import { lazyMermaid } from "./_lazy-mermaid";
39
+
40
+ /**
41
+ * Reactive replacement for `@streamdown/code`'s pre-configured `code` export
42
+ * (issue #315 follow-up, the "major" carve-out: only `<CodeBlock>` had been
43
+ * de-GitHub-ed — every fenced code block rendered through `Message`/
44
+ * `MarkdownView`/`Reasoning`'s Streamdown instance still went through this
45
+ * plugin, whose `getThemes()` is FROZEN at import time to
46
+ * `["github-light", "github-dark"]`).
47
+ *
48
+ * Streamdown's dual-theme mechanism (`createCodePlugin({ themes: [light, dark] })`)
49
+ * expects exactly TWO themes and picks between them purely via the `.dark` CSS
50
+ * selector — but brand-ui ships THREE themes (light/dark),
51
+ * and any dark-declaring theme matches `.dark` too (see `_code-block-theme.ts`). Passing a real
52
+ * light/dark PAIR would still force such a theme into the dark slot. Instead
53
+ * this pins BOTH slots to `buildCodeBlockTheme()` — the SAME brand-token-derived
54
+ * theme the active `data-theme` resolves to — so whichever slot the `.dark`
55
+ * selector picks, it's the CORRECT theme for whatever is actually active.
56
+ *
57
+ * Re-derives on every `data-theme` mutation (a `MutationObserver` on
58
+ * `<html>`, mirroring `code-block.tsx`'s own) and returns a NEW plugin object
59
+ * each time, because Streamdown only re-reads `plugins.code.getThemes()` when
60
+ * the `plugins.code` object's REFERENCE changes (see streamdown's internal
61
+ * `shikiTheme` memo) — a mutated-in-place plugin would never be picked up.
62
+ */
63
+ function useReactiveCodePlugin() {
64
+ const [revision, setRevision] = useState(0);
65
+
66
+ useEffect(() => {
67
+ if (typeof document === "undefined") return;
68
+ const observer = new MutationObserver(() => setRevision((r) => r + 1));
69
+ observer.observe(document.documentElement, {
70
+ attributes: true,
71
+ attributeFilter: ["data-theme"],
72
+ });
73
+ return () => observer.disconnect();
74
+ }, []);
75
+
76
+ return useMemo(() => {
77
+ const theme = buildCodeBlockTheme();
78
+ return createCodePlugin({ themes: [theme, theme] });
79
+ // eslint-disable-next-line react-hooks/exhaustive-deps -- revision is the trigger; the theme itself is re-read live off document.documentElement.
80
+ }, [revision]);
81
+ }
82
+
83
+ /**
84
+ * The plugin set every `@elabs-ai/components-ai` markdown surface renders with.
85
+ *
86
+ * `mermaid` is the LAZY plugin (`./_lazy-mermaid`): the engine + d3 + DOMPurify
87
+ * load on first diagram render, not in the entry chunk of every consumer. `code`
88
+ * is the reactive, brand-token-derived plugin above (#315) — never the
89
+ * `@streamdown/code` package's static `github-light`/`github-dark` default.
90
+ * Memoized so Streamdown sees a referentially stable `plugins` prop except when
91
+ * the active theme actually changes.
92
+ */
93
+ export function useStreamdownPlugins() {
94
+ const code = useReactiveCodePlugin();
95
+ return useMemo(() => ({ cjk, code, math, mermaid: lazyMermaid }), [code]);
96
+ }
97
+
98
+ /*
99
+ * The translation MAP moved down to `@elabs-ai/components-ui`
100
+ * (`lib/streamdown-translations.ts`) when `@elabs-ai/components-viewer`
101
+ * became a second Streamdown renderer — the two packages may not import each
102
+ * other. What stays here is the half that needs the dependency itself: the
103
+ * proof that the shared key list is still COMPLETE.
104
+ */
105
+
106
+ // `satisfies` proves every key in the shared list is real…
107
+ const _keysAreReal =
108
+ STREAMDOWN_TRANSLATION_KEYS satisfies readonly (keyof StreamdownTranslations)[];
109
+ void _keysAreReal;
110
+
111
+ // …and this proves the converse. If a streamdown upgrade ADDS a translation
112
+ // key, the assignment below stops building until the shared list (and the
113
+ // `ai.streamdown.*` English defaults) catch up, instead of silently leaking
114
+ // that key in English. The tripwire lives in the package that pins the version.
115
+ type MissingTranslationKeys = Exclude<keyof StreamdownTranslations, StreamdownTranslationKey>;
116
+ const _noMissingTranslationKeys: [MissingTranslationKeys] extends [never] ? true : false = true;
117
+ void _noMissingTranslationKeys;
118
+
119
+ export { useStreamdownTranslations };
@@ -0,0 +1,66 @@
1
+ import type { Meta, StoryObj } from "@storybook/react-vite";
2
+ import { Calculator, Database, PencilLine, Search } from "lucide-react";
3
+ import { AgentStep, AgentTimeline } from "./agent-timeline";
4
+ import { ToolDetails, ToolInput, ToolOutput } from "./tool";
5
+
6
+ const meta = {
7
+ title: "AI/AgentTimeline",
8
+ component: AgentTimeline,
9
+ parameters: { layout: "padded" },
10
+ tags: ["autodocs"],
11
+ } satisfies Meta<typeof AgentTimeline>;
12
+ export default meta;
13
+ type Story = StoryObj<typeof meta>;
14
+
15
+ // The execution-trace grammar (#192, research 10 §B.3): one rail, one status
16
+ // vocabulary, business summaries — not JSON — as the readable line.
17
+ export const Default: Story = {
18
+ render: () => (
19
+ <AgentTimeline className="max-w-prose">
20
+ <AgentStep
21
+ icon={Search}
22
+ status="complete"
23
+ name="Searched financial filings"
24
+ summary="3 documents · Q3 10-Q, earnings deck, board pack"
25
+ />
26
+ <AgentStep
27
+ icon={Database}
28
+ status="complete"
29
+ name="Queried finance.revenue"
30
+ summary="8 rows reconciled · 0 variances"
31
+ />
32
+ <AgentStep icon={Calculator} status="running" name="Computing QoQ deltas" />
33
+ <AgentStep icon={PencilLine} status="pending" name="Draft the board note" />
34
+ </AgentTimeline>
35
+ ),
36
+ };
37
+
38
+ // An inspect-only tool call rides the rail as a step; its JSON stays one
39
+ // expand away behind the default-collapsed ToolDetails (research 10 §B.5).
40
+ export const InspectOnlyToolStep: Story = {
41
+ name: "Inspect-only Tool as a step",
42
+ render: () => (
43
+ <AgentTimeline className="max-w-prose">
44
+ <AgentStep
45
+ icon={Database}
46
+ status="complete"
47
+ name="Queried finance.revenue"
48
+ summary="8 rows reconciled · 0 variances"
49
+ >
50
+ <ToolDetails>
51
+ <ToolInput
52
+ input={{
53
+ sql: "select quarter, region, sum(amount) from finance.revenue group by 1,2",
54
+ }}
55
+ />
56
+ <ToolOutput
57
+ output={{ rows: 8, q3_total: 48200000, qoq_growth: 0.124 }}
58
+ errorText={undefined}
59
+ />
60
+ </ToolDetails>
61
+ </AgentStep>
62
+ <AgentStep icon={Calculator} status="failed" name="Computing QoQ deltas" />
63
+ <AgentStep icon={PencilLine} status="skipped" name="Draft the board note" />
64
+ </AgentTimeline>
65
+ ),
66
+ };
@@ -0,0 +1,74 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { render, screen } from "@testing-library/react";
3
+ import { Search } from "lucide-react";
4
+ import { AgentStep, AgentTimeline } from "./agent-timeline";
5
+ import {
6
+ ChainOfThought,
7
+ ChainOfThoughtContent,
8
+ ChainOfThoughtHeader,
9
+ ChainOfThoughtStep,
10
+ } from "./chain-of-thought";
11
+
12
+ describe("AgentTimeline / AgentStep (#192, research 10 §B.3)", () => {
13
+ it("renders steps as list items on the canonical rail with status, badge and summary", () => {
14
+ render(
15
+ <AgentTimeline data-testid="timeline">
16
+ <AgentStep
17
+ icon={Search}
18
+ status="complete"
19
+ name="Searched financial filings"
20
+ summary="3 documents"
21
+ />
22
+ <AgentStep status="running" name="Computing QoQ deltas" />
23
+ </AgentTimeline>,
24
+ );
25
+ expect(screen.getByTestId("timeline").tagName).toBe("OL");
26
+ const items = screen.getAllByRole("listitem");
27
+ expect(items).toHaveLength(2);
28
+ expect(items[0]).toHaveAttribute("data-status", "complete");
29
+ expect(items[1]).toHaveAttribute("data-status", "running");
30
+ expect(screen.getByText("Complete")).toBeInTheDocument(); // StatusBadge label
31
+ expect(screen.getByText("3 documents")).toBeInTheDocument();
32
+ expect(screen.getByText("Searched financial filings")).toBeInTheDocument();
33
+ });
34
+
35
+ it("renders rich detail children under the node; hideBadge drops the badge", () => {
36
+ render(
37
+ <AgentTimeline>
38
+ <AgentStep name="Queried finance.revenue" status="complete" hideBadge>
39
+ <div data-testid="detail">8 rows · 0 variances</div>
40
+ </AgentStep>
41
+ </AgentTimeline>,
42
+ );
43
+ expect(screen.getByTestId("detail")).toBeInTheDocument();
44
+ expect(screen.queryByText("Complete")).not.toBeInTheDocument();
45
+ });
46
+
47
+ it("merges className and spreads props on the li", () => {
48
+ render(
49
+ <AgentTimeline>
50
+ <AgentStep name="Step" className="custom-step" aria-label="step" />
51
+ </AgentTimeline>,
52
+ );
53
+ const li = screen.getByRole("listitem");
54
+ expect(li).toHaveClass("custom-step");
55
+ expect(li).toHaveAttribute("aria-label", "step");
56
+ });
57
+ });
58
+
59
+ describe("ChainOfThoughtStep → AgentStep alias (#192 convergence)", () => {
60
+ it("renders on the canonical rail with the 3-state vocabulary mapped in", () => {
61
+ render(
62
+ <ChainOfThought defaultOpen>
63
+ <ChainOfThoughtHeader>Working</ChainOfThoughtHeader>
64
+ <ChainOfThoughtContent>
65
+ <ChainOfThoughtStep label="Searched filings" description="Q3 10-Q" status="active" />
66
+ </ChainOfThoughtContent>
67
+ </ChainOfThought>,
68
+ );
69
+ const li = screen.getByRole("listitem");
70
+ expect(li).toHaveAttribute("data-status", "running");
71
+ expect(screen.getByText("Searched filings")).toBeInTheDocument();
72
+ expect(screen.getByText("Q3 10-Q")).toBeInTheDocument();
73
+ });
74
+ });
@@ -0,0 +1,106 @@
1
+ "use client";
2
+
3
+ /**
4
+ * AgentTimeline / AgentStep — the agent execution-trace rail (#192,
5
+ * research 10 §B.3).
6
+ *
7
+ * The convergence compound of the execution-trace grammar: sequenced,
8
+ * status-bearing agent steps render on ONE visual spine — the canonical
9
+ * `@elabs-ai/components-ui` Timeline rail (`TimelineRoot`/`TimelineItem`, #190) — speaking
10
+ * ONE status vocabulary — the closed 7-state `Status` enum (`StatusBadge`,
11
+ * #189). `ChainOfThought`'s steps and `Task`'s body compose this instead of
12
+ * hand-rolling rails; an inspect-only `Tool` call renders as an `AgentStep`
13
+ * with its JSON behind a `ToolDetails` disclosure as children.
14
+ *
15
+ * Deliberately NOT converged (different speech-acts, research 10 §B.3):
16
+ * `Plan` (a proposal, not an executed step), `Checkpoint` (a divider/restore
17
+ * point), `Reasoning` (inline thinking prose).
18
+ *
19
+ * The shared rail is motion-free; the gated entrance animation lives here,
20
+ * in the composing consumer (research 10 §B.2).
21
+ */
22
+ import { StatusBadge, TimelineItem, TimelineRoot, type Status } from "@elabs-ai/components-ui";
23
+ import type { TimelineItemProps, TimelineRootProps } from "@elabs-ai/components-ui";
24
+ import { cn } from "@elabs-ai/components-ui/lib/cn";
25
+ import type { LucideIcon } from "lucide-react";
26
+ import { forwardRef, memo, type ReactNode } from "react";
27
+
28
+ export type AgentTimelineProps = TimelineRootProps;
29
+
30
+ /** The `<ol>` rail an agent run's steps sit on (canonical @elabs-ai/components-ui spine). */
31
+ export const AgentTimeline = forwardRef<HTMLOListElement, AgentTimelineProps>(
32
+ function AgentTimeline({ className, ...props }, ref) {
33
+ return <TimelineRoot ref={ref} className={cn("not-prose w-full", className)} {...props} />;
34
+ },
35
+ );
36
+
37
+ export interface AgentStepProps extends Omit<TimelineItemProps, "children" | "description"> {
38
+ /** Optional leading concept glyph on the title row (Lucide). */
39
+ icon?: LucideIcon;
40
+ /** What the agent did — the step's title (`text-body`). */
41
+ name: ReactNode;
42
+ /** The business summary line under the name (`text-meta`), not JSON. */
43
+ summary?: ReactNode;
44
+ /**
45
+ * The canonical execution status (closed 7-state enum). Drives the rail
46
+ * node AND the `StatusBadge`. `skipped` is the agent-step-only state with
47
+ * no SDK source (research 10 §B.1).
48
+ */
49
+ status?: Status;
50
+ /**
51
+ * Drop the inline `StatusBadge` (the rail node still carries the status
52
+ * color). For quiet prose rails — e.g. the `ChainOfThought` alias — where a
53
+ * badge per step would shout.
54
+ */
55
+ hideBadge?: boolean;
56
+ /** Rich detail under the node (e.g. a `ToolDetails` disclosure, results). */
57
+ children?: ReactNode;
58
+ }
59
+
60
+ /**
61
+ * One executed/executing step: icon + name + `StatusBadge` + business
62
+ * summary, with composed children for detail. Memoized for long transcripts
63
+ * (research 10 §G.6).
64
+ */
65
+ export const AgentStep = memo(
66
+ forwardRef<HTMLLIElement, AgentStepProps>(function AgentStep(
67
+ {
68
+ icon: Icon,
69
+ name,
70
+ summary,
71
+ status = "pending",
72
+ hideBadge = false,
73
+ className,
74
+ children,
75
+ ...props
76
+ },
77
+ ref,
78
+ ) {
79
+ return (
80
+ <TimelineItem
81
+ ref={ref}
82
+ status={status}
83
+ description={
84
+ summary ? <span className="text-meta text-muted-foreground">{summary}</span> : undefined
85
+ }
86
+ detail={children}
87
+ className={cn(
88
+ "fade-in-0 slide-in-from-top-2 animate-in [--tw-ease:var(--ease-entrance)] motion-reduce:animate-none",
89
+ className,
90
+ )}
91
+ {...props}
92
+ >
93
+ <span className="inline-flex items-center gap-2">
94
+ {Icon ? (
95
+ <Icon aria-hidden="true" className="size-4 shrink-0 text-muted-foreground" />
96
+ ) : null}
97
+ {name}
98
+ {hideBadge ? null : <StatusBadge size="sm" status={status} />}
99
+ </span>
100
+ </TimelineItem>
101
+ );
102
+ }),
103
+ );
104
+
105
+ AgentTimeline.displayName = "AgentTimeline";
106
+ AgentStep.displayName = "AgentStep";