@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.
- package/LICENSE +21 -0
- package/README.md +133 -0
- package/dist/_audio-player-media-chrome-KA5DY54G.js +81 -0
- package/dist/_audio-player-media-chrome-KA5DY54G.js.map +1 -0
- package/dist/_flow-boundary-D63PJ65S.js +186 -0
- package/dist/_flow-boundary-D63PJ65S.js.map +1 -0
- package/dist/_interactive-terminal-xterm-ATJ5EW3G.js +11 -0
- package/dist/_interactive-terminal-xterm-ATJ5EW3G.js.map +1 -0
- package/dist/_persona-rive-RFR2EUWP.js +164 -0
- package/dist/_persona-rive-RFR2EUWP.js.map +1 -0
- package/dist/index.d.ts +4079 -0
- package/dist/index.js +11770 -0
- package/dist/index.js.map +1 -0
- package/package.json +87 -0
- package/src/_audio-player-media-chrome.tsx +146 -0
- package/src/_chat-shell-rail.tsx +18 -0
- package/src/_code-block-theme.test.ts +134 -0
- package/src/_code-block-theme.ts +184 -0
- package/src/_flow-boundary.tsx +237 -0
- package/src/_flow-lazy.ts +34 -0
- package/src/_interactive-terminal-xterm.ts +32 -0
- package/src/_lazy-mermaid.test.ts +101 -0
- package/src/_lazy-mermaid.ts +117 -0
- package/src/_persona-rive.tsx +252 -0
- package/src/_streamdown-i18n.ts +119 -0
- package/src/agent-timeline.stories.tsx +66 -0
- package/src/agent-timeline.test.tsx +74 -0
- package/src/agent-timeline.tsx +106 -0
- package/src/agent.tsx +113 -0
- package/src/agentic-workspace.stories.tsx +1099 -0
- package/src/artifact.stories.tsx +62 -0
- package/src/artifact.test.tsx +112 -0
- package/src/artifact.tsx +167 -0
- package/src/asset-preview.stories.tsx +100 -0
- package/src/asset-preview.test.tsx +126 -0
- package/src/asset-preview.tsx +218 -0
- package/src/attachments.tsx +393 -0
- package/src/audio-player.stories.tsx +99 -0
- package/src/audio-player.tsx +170 -0
- package/src/blocks-ai-composer.stories.tsx +83 -0
- package/src/canvas.stories.tsx +142 -0
- package/src/canvas.tsx +29 -0
- package/src/chain-of-thought.tsx +193 -0
- package/src/chat-greeting.stories.tsx +96 -0
- package/src/chat-greeting.test.tsx +91 -0
- package/src/chat-greeting.tsx +91 -0
- package/src/chat-shell.stories.tsx +123 -0
- package/src/chat-shell.test.tsx +113 -0
- package/src/chat-shell.tsx +97 -0
- package/src/chat.stories.tsx +99 -0
- package/src/checkpoint.tsx +54 -0
- package/src/code-block.stories.tsx +81 -0
- package/src/code-block.test.tsx +191 -0
- package/src/code-block.tsx +673 -0
- package/src/commit.tsx +343 -0
- package/src/composer.stories.tsx +188 -0
- package/src/composer.test.tsx +182 -0
- package/src/composer.tsx +202 -0
- package/src/confirmation.stories.tsx +61 -0
- package/src/confirmation.test.tsx +136 -0
- package/src/confirmation.tsx +273 -0
- package/src/connection.tsx +23 -0
- package/src/context-panel.stories.tsx +137 -0
- package/src/context-panel.test.tsx +198 -0
- package/src/context-panel.tsx +609 -0
- package/src/context.stories.tsx +16 -0
- package/src/context.tsx +363 -0
- package/src/controls.tsx +20 -0
- package/src/conversation.stories.tsx +26 -0
- package/src/conversation.test.tsx +123 -0
- package/src/conversation.tsx +143 -0
- package/src/dark-theme-variant.test.ts +47 -0
- package/src/edge.tsx +33 -0
- package/src/environment-variables.tsx +304 -0
- package/src/file-tree.stories.tsx +55 -0
- package/src/file-tree.test.tsx +101 -0
- package/src/file-tree.tsx +390 -0
- package/src/gallery.stories.tsx +182 -0
- package/src/gallery.test.tsx +150 -0
- package/src/gallery.tsx +737 -0
- package/src/grouped-parts.stories.tsx +104 -0
- package/src/grouped-parts.test.tsx +167 -0
- package/src/grouped-parts.tsx +247 -0
- package/src/image.stories.tsx +67 -0
- package/src/image.test.tsx +105 -0
- package/src/image.tsx +115 -0
- package/src/index.ts +93 -0
- package/src/inline-citation.stories.tsx +91 -0
- package/src/inline-citation.test.tsx +96 -0
- package/src/inline-citation.tsx +314 -0
- package/src/interactive-terminal.stories.tsx +165 -0
- package/src/interactive-terminal.test.tsx +448 -0
- package/src/interactive-terminal.tsx +444 -0
- package/src/jsx-preview.stories.tsx +265 -0
- package/src/jsx-preview.test.tsx +75 -0
- package/src/jsx-preview.tsx +409 -0
- package/src/markdown-view.stories.tsx +57 -0
- package/src/markdown-view.test.tsx +36 -0
- package/src/markdown-view.tsx +113 -0
- package/src/message-edit.stories.tsx +150 -0
- package/src/message-edit.test.tsx +115 -0
- package/src/message-edit.tsx +336 -0
- package/src/message-feedback.stories.tsx +53 -0
- package/src/message-feedback.test.tsx +62 -0
- package/src/message-feedback.tsx +117 -0
- package/src/message-form-spec.ts +349 -0
- package/src/message-form.stories.tsx +182 -0
- package/src/message-form.test.tsx +227 -0
- package/src/message-form.tsx +845 -0
- package/src/message-table-spec.ts +281 -0
- package/src/message-table.stories.tsx +176 -0
- package/src/message-table.test.tsx +144 -0
- package/src/message-table.tsx +366 -0
- package/src/message.stories.tsx +262 -0
- package/src/message.test.tsx +508 -0
- package/src/message.tsx +619 -0
- package/src/mic-selector.tsx +339 -0
- package/src/microcopy.test.tsx +123 -0
- package/src/model-selector.stories.tsx +73 -0
- package/src/model-selector.test.tsx +55 -0
- package/src/model-selector.tsx +225 -0
- package/src/motion-config.tsx +49 -0
- package/src/node.tsx +71 -0
- package/src/open-in-chat.tsx +340 -0
- package/src/package-info.tsx +205 -0
- package/src/panel.tsx +20 -0
- package/src/part-groups.ts +258 -0
- package/src/persona-sources.ts +59 -0
- package/src/persona.stories.tsx +111 -0
- package/src/persona.test.tsx +67 -0
- package/src/persona.tsx +148 -0
- package/src/plan.tsx +135 -0
- package/src/prompt-input.stories.tsx +261 -0
- package/src/prompt-input.test.tsx +392 -0
- package/src/prompt-input.tsx +1668 -0
- package/src/queue.tsx +237 -0
- package/src/reasoning.stories.tsx +81 -0
- package/src/reasoning.test.tsx +50 -0
- package/src/reasoning.tsx +229 -0
- package/src/sandbox.stories.tsx +62 -0
- package/src/sandbox.test.tsx +42 -0
- package/src/sandbox.tsx +149 -0
- package/src/schema-display.tsx +412 -0
- package/src/selection-toolbar.stories.tsx +86 -0
- package/src/selection-toolbar.test.tsx +89 -0
- package/src/selection-toolbar.tsx +192 -0
- package/src/shimmer.stories.tsx +10 -0
- package/src/shimmer.tsx +81 -0
- package/src/snippet.stories.tsx +19 -0
- package/src/snippet.test.tsx +116 -0
- package/src/snippet.tsx +138 -0
- package/src/sources.stories.tsx +34 -0
- package/src/sources.test.tsx +129 -0
- package/src/sources.tsx +93 -0
- package/src/speech-input.tsx +312 -0
- package/src/stack-trace.tsx +479 -0
- package/src/streamdown-i18n.test.tsx +163 -0
- package/src/suggestion.stories.tsx +75 -0
- package/src/suggestion.test.tsx +72 -0
- package/src/suggestion.tsx +120 -0
- package/src/task.stories.tsx +22 -0
- package/src/task.test.tsx +36 -0
- package/src/task.tsx +89 -0
- package/src/templates-ai-assistant.stories.tsx +142 -0
- package/src/terminal.tsx +244 -0
- package/src/test-results.stories.tsx +177 -0
- package/src/test-results.test.tsx +241 -0
- package/src/test-results.tsx +408 -0
- package/src/tool-result-card.stories.tsx +143 -0
- package/src/tool-result-card.test.tsx +72 -0
- package/src/tool-result-card.tsx +86 -0
- package/src/tool.stories.tsx +60 -0
- package/src/tool.test.tsx +63 -0
- package/src/tool.tsx +235 -0
- package/src/toolbar.tsx +44 -0
- package/src/transcription.tsx +118 -0
- package/src/voice-selector.tsx +469 -0
- package/src/web-preview.stories.tsx +67 -0
- package/src/web-preview.test.tsx +117 -0
- 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";
|