@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,409 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { Skeleton } from "@elabs-ai/components-ui";
|
|
4
|
+
import { cn } from "@elabs-ai/components-ui/lib/cn";
|
|
5
|
+
import { AlertCircle } from "lucide-react";
|
|
6
|
+
import type { ComponentProps, ReactNode } from "react";
|
|
7
|
+
import {
|
|
8
|
+
createContext,
|
|
9
|
+
memo,
|
|
10
|
+
useCallback,
|
|
11
|
+
useContext,
|
|
12
|
+
useEffect,
|
|
13
|
+
useMemo,
|
|
14
|
+
useRef,
|
|
15
|
+
useState,
|
|
16
|
+
} from "react";
|
|
17
|
+
import type { TProps as JsxParserProps } from "react-jsx-parser";
|
|
18
|
+
import JsxParser from "react-jsx-parser";
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* The readiness of the preview, derived from the input (see loading-states.md):
|
|
22
|
+
* - `idle` — nothing requested yet (empty input, never rendered). Renders nothing.
|
|
23
|
+
* - `pending` — not-ready: `loading`, `isStreaming`, OR syntactically incomplete
|
|
24
|
+
* input (a half-arrived tag). Builds up best-effort; the error slot
|
|
25
|
+
* is suppressed; the skeleton shows until the first successful paint.
|
|
26
|
+
* - `ready` — settled, parseable input is rendering.
|
|
27
|
+
* - `error` — settled AND genuinely invalid input (the only state that surfaces
|
|
28
|
+
* `JSXPreviewError`).
|
|
29
|
+
*/
|
|
30
|
+
export type JSXPreviewStatus = "idle" | "pending" | "ready" | "error";
|
|
31
|
+
|
|
32
|
+
interface JSXPreviewContextValue {
|
|
33
|
+
jsx: string;
|
|
34
|
+
processedJsx: string;
|
|
35
|
+
isStreaming: boolean;
|
|
36
|
+
loading: boolean;
|
|
37
|
+
status: JSXPreviewStatus;
|
|
38
|
+
hasRendered: boolean;
|
|
39
|
+
error: Error | null;
|
|
40
|
+
setError: (error: Error | null) => void;
|
|
41
|
+
setHasRendered: (value: boolean) => void;
|
|
42
|
+
components: JsxParserProps["components"];
|
|
43
|
+
bindings: JsxParserProps["bindings"];
|
|
44
|
+
onErrorProp?: (error: Error) => void;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const JSXPreviewContext = createContext<JSXPreviewContextValue | null>(null);
|
|
48
|
+
|
|
49
|
+
const TAG_REGEX = /<\/?([a-zA-Z][a-zA-Z0-9]*)\s*([^>]*?)(\/)?>/;
|
|
50
|
+
|
|
51
|
+
export const useJSXPreview = () => {
|
|
52
|
+
const context = useContext(JSXPreviewContext);
|
|
53
|
+
if (!context) {
|
|
54
|
+
throw new Error("JSXPreview components must be used within JSXPreview");
|
|
55
|
+
}
|
|
56
|
+
return context;
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
const matchJsxTag = (code: string) => {
|
|
60
|
+
if (code.trim() === "") {
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const match = code.match(TAG_REGEX);
|
|
65
|
+
|
|
66
|
+
if (!match || match.index === undefined) {
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const [fullMatch = "", tagName = "", attributes = "", selfClosing] = match;
|
|
71
|
+
|
|
72
|
+
let type: "self-closing" | "closing" | "opening";
|
|
73
|
+
if (selfClosing) {
|
|
74
|
+
type = "self-closing";
|
|
75
|
+
} else if (fullMatch.startsWith("</")) {
|
|
76
|
+
type = "closing";
|
|
77
|
+
} else {
|
|
78
|
+
type = "opening";
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return {
|
|
82
|
+
attributes: attributes.trim(),
|
|
83
|
+
endIndex: match.index + fullMatch.length,
|
|
84
|
+
startIndex: match.index,
|
|
85
|
+
tag: fullMatch,
|
|
86
|
+
tagName,
|
|
87
|
+
type,
|
|
88
|
+
};
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
const stripIncompleteTag = (text: string) => {
|
|
92
|
+
// Find the last '<' that isn't part of a complete tag
|
|
93
|
+
const lastOpen = text.lastIndexOf("<");
|
|
94
|
+
if (lastOpen === -1) {
|
|
95
|
+
return text;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const afterOpen = text.slice(lastOpen);
|
|
99
|
+
// If there's no closing '>' after the last '<', it's an incomplete tag
|
|
100
|
+
if (!afterOpen.includes(">")) {
|
|
101
|
+
return text.slice(0, lastOpen);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return text;
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
const completeJsxTag = (code: string) => {
|
|
108
|
+
const stack: string[] = [];
|
|
109
|
+
let result = "";
|
|
110
|
+
let currentPosition = 0;
|
|
111
|
+
|
|
112
|
+
while (currentPosition < code.length) {
|
|
113
|
+
const match = matchJsxTag(code.slice(currentPosition));
|
|
114
|
+
if (!match) {
|
|
115
|
+
// No more tags found, strip any trailing incomplete tag
|
|
116
|
+
result += stripIncompleteTag(code.slice(currentPosition));
|
|
117
|
+
break;
|
|
118
|
+
}
|
|
119
|
+
const { tagName, type, endIndex } = match;
|
|
120
|
+
|
|
121
|
+
// Include any text content before this tag
|
|
122
|
+
result += code.slice(currentPosition, currentPosition + endIndex);
|
|
123
|
+
|
|
124
|
+
if (type === "opening") {
|
|
125
|
+
stack.push(tagName);
|
|
126
|
+
} else if (type === "closing") {
|
|
127
|
+
stack.pop();
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
currentPosition += endIndex;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
return (
|
|
134
|
+
result +
|
|
135
|
+
[...stack]
|
|
136
|
+
.reverse()
|
|
137
|
+
.map((tag) => `</${tag}>`)
|
|
138
|
+
.join("")
|
|
139
|
+
);
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
export type JSXPreviewProps = ComponentProps<"div"> & {
|
|
143
|
+
jsx: string;
|
|
144
|
+
/** Content is arriving incrementally — build up, suppress transient errors. */
|
|
145
|
+
isStreaming?: boolean;
|
|
146
|
+
/**
|
|
147
|
+
* No renderable content yet (fetch-then-show). Forces the `pending` state so
|
|
148
|
+
* `JSXPreviewSkeleton` shows until `jsx` arrives. Orthogonal to `isStreaming`.
|
|
149
|
+
*/
|
|
150
|
+
loading?: boolean;
|
|
151
|
+
components?: JsxParserProps["components"];
|
|
152
|
+
bindings?: JsxParserProps["bindings"];
|
|
153
|
+
onError?: (error: Error) => void;
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
export const JSXPreview = memo(
|
|
157
|
+
({
|
|
158
|
+
jsx,
|
|
159
|
+
isStreaming = false,
|
|
160
|
+
loading = false,
|
|
161
|
+
components,
|
|
162
|
+
bindings,
|
|
163
|
+
onError,
|
|
164
|
+
className,
|
|
165
|
+
children,
|
|
166
|
+
...props
|
|
167
|
+
}: JSXPreviewProps) => {
|
|
168
|
+
const [prevJsx, setPrevJsx] = useState(jsx);
|
|
169
|
+
const [error, setError] = useState<Error | null>(null);
|
|
170
|
+
const [hasRendered, setHasRendered] = useState(false);
|
|
171
|
+
|
|
172
|
+
// Clear the terminal error when jsx changes (derived state pattern)
|
|
173
|
+
if (jsx !== prevJsx) {
|
|
174
|
+
setPrevJsx(jsx);
|
|
175
|
+
setError(null);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// The tag-balanced best-effort of the current input. When it differs from the
|
|
179
|
+
// raw jsx, the input ends mid-tag → it is INCOMPLETE (still streaming / being
|
|
180
|
+
// typed), not invalid. This is the timer-free "not-ready" signal.
|
|
181
|
+
const completed = useMemo(() => completeJsxTag(jsx), [jsx]);
|
|
182
|
+
const isIncomplete = jsx.trim() !== "" && completed !== jsx;
|
|
183
|
+
|
|
184
|
+
const processedJsx = useMemo(() => {
|
|
185
|
+
if (loading) return "";
|
|
186
|
+
return isStreaming || isIncomplete ? completed : jsx;
|
|
187
|
+
}, [jsx, isStreaming, loading, isIncomplete, completed]);
|
|
188
|
+
|
|
189
|
+
const status = useMemo<JSXPreviewStatus>(() => {
|
|
190
|
+
if (loading) return "pending";
|
|
191
|
+
if (jsx.trim() === "") return hasRendered ? "ready" : "idle";
|
|
192
|
+
if (isStreaming || isIncomplete) return "pending";
|
|
193
|
+
return error != null ? "error" : "ready";
|
|
194
|
+
}, [jsx, isStreaming, loading, isIncomplete, error, hasRendered]);
|
|
195
|
+
|
|
196
|
+
const contextValue = useMemo(
|
|
197
|
+
() => ({
|
|
198
|
+
bindings,
|
|
199
|
+
components,
|
|
200
|
+
error,
|
|
201
|
+
hasRendered,
|
|
202
|
+
isStreaming,
|
|
203
|
+
jsx,
|
|
204
|
+
loading,
|
|
205
|
+
onErrorProp: onError,
|
|
206
|
+
processedJsx,
|
|
207
|
+
setError,
|
|
208
|
+
setHasRendered,
|
|
209
|
+
status,
|
|
210
|
+
}),
|
|
211
|
+
[
|
|
212
|
+
bindings,
|
|
213
|
+
components,
|
|
214
|
+
error,
|
|
215
|
+
hasRendered,
|
|
216
|
+
isStreaming,
|
|
217
|
+
jsx,
|
|
218
|
+
loading,
|
|
219
|
+
onError,
|
|
220
|
+
processedJsx,
|
|
221
|
+
status,
|
|
222
|
+
],
|
|
223
|
+
);
|
|
224
|
+
|
|
225
|
+
return (
|
|
226
|
+
<JSXPreviewContext.Provider value={contextValue}>
|
|
227
|
+
<div className={cn("relative", className)} {...props}>
|
|
228
|
+
{children}
|
|
229
|
+
</div>
|
|
230
|
+
</JSXPreviewContext.Provider>
|
|
231
|
+
);
|
|
232
|
+
},
|
|
233
|
+
);
|
|
234
|
+
|
|
235
|
+
JSXPreview.displayName = "JSXPreview";
|
|
236
|
+
|
|
237
|
+
export type JSXPreviewContentProps = Omit<ComponentProps<"div">, "children">;
|
|
238
|
+
|
|
239
|
+
export const JSXPreviewContent = memo(({ className, ...props }: JSXPreviewContentProps) => {
|
|
240
|
+
const { processedJsx, status, components, bindings, setError, setHasRendered, onErrorProp } =
|
|
241
|
+
useJSXPreview();
|
|
242
|
+
const lastGoodJsxRef = useRef("");
|
|
243
|
+
// `erroredJsx` is committed in an effect (never during render). The parser
|
|
244
|
+
// reports errors synchronously DURING its own render, so `handleError` only
|
|
245
|
+
// stashes a ref — committing state there would be a setState-in-render.
|
|
246
|
+
const [erroredJsx, setErroredJsx] = useState<string | null>(null);
|
|
247
|
+
const pendingErrorRef = useRef<{ jsx: string; error: Error } | null>(null);
|
|
248
|
+
|
|
249
|
+
// Reset the per-input error flag synchronously when the content changes.
|
|
250
|
+
const prevProcessedRef = useRef(processedJsx);
|
|
251
|
+
if (prevProcessedRef.current !== processedJsx) {
|
|
252
|
+
prevProcessedRef.current = processedJsx;
|
|
253
|
+
if (erroredJsx !== null) setErroredJsx(null);
|
|
254
|
+
}
|
|
255
|
+
const hadError = erroredJsx === processedJsx;
|
|
256
|
+
|
|
257
|
+
const handleError = useCallback(
|
|
258
|
+
(error: Error) => {
|
|
259
|
+
pendingErrorRef.current = { error, jsx: processedJsx };
|
|
260
|
+
},
|
|
261
|
+
[processedJsx],
|
|
262
|
+
);
|
|
263
|
+
|
|
264
|
+
// After commit, reconcile the parse outcome. A fresh error becomes terminal
|
|
265
|
+
// (surfaces `JSXPreviewError`) ONLY when settled — for a `pending` input it is
|
|
266
|
+
// held silently (build-up keeps going behind the last good render). A clean
|
|
267
|
+
// render records the last-good JSX and marks the first paint.
|
|
268
|
+
useEffect(() => {
|
|
269
|
+
const failed = pendingErrorRef.current?.jsx === processedJsx;
|
|
270
|
+
if (failed) {
|
|
271
|
+
const { error } = pendingErrorRef.current!;
|
|
272
|
+
pendingErrorRef.current = null;
|
|
273
|
+
if (erroredJsx !== processedJsx) setErroredJsx(processedJsx);
|
|
274
|
+
if (status !== "pending") {
|
|
275
|
+
setError(error);
|
|
276
|
+
onErrorProp?.(error);
|
|
277
|
+
}
|
|
278
|
+
} else if (erroredJsx !== processedJsx && processedJsx.trim() !== "") {
|
|
279
|
+
lastGoodJsxRef.current = processedJsx;
|
|
280
|
+
setHasRendered(true);
|
|
281
|
+
}
|
|
282
|
+
}, [processedJsx, status, erroredJsx, setError, setHasRendered, onErrorProp]);
|
|
283
|
+
|
|
284
|
+
// The error slot and the skeleton own the "error" / nothing-yet states.
|
|
285
|
+
if (status !== "ready" && status !== "pending") {
|
|
286
|
+
return null;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// While pending, a failed parse falls back to the last good render (which may
|
|
290
|
+
// be empty on first paint — the skeleton covers that).
|
|
291
|
+
const displayJsx = hadError ? lastGoodJsxRef.current : processedJsx;
|
|
292
|
+
|
|
293
|
+
return (
|
|
294
|
+
<div className={cn("jsx-preview-content", className)} {...props}>
|
|
295
|
+
<JsxParser
|
|
296
|
+
bindings={bindings}
|
|
297
|
+
components={components}
|
|
298
|
+
jsx={displayJsx}
|
|
299
|
+
onError={handleError}
|
|
300
|
+
renderInWrapper={false}
|
|
301
|
+
/>
|
|
302
|
+
</div>
|
|
303
|
+
);
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
JSXPreviewContent.displayName = "JSXPreviewContent";
|
|
307
|
+
|
|
308
|
+
export type JSXPreviewSkeletonProps = ComponentProps<"div">;
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* The not-ready placeholder (loading-states.md §slot anatomy). Renders a
|
|
312
|
+
* layout-shaped `Skeleton` while the preview is `pending` and nothing has
|
|
313
|
+
* painted yet — so a streaming/incomplete surface BUILDS UP behind a skeleton
|
|
314
|
+
* instead of flashing the error box. Pass `children` to override the shape.
|
|
315
|
+
*/
|
|
316
|
+
export const JSXPreviewSkeleton = memo(
|
|
317
|
+
({ className, children, ...props }: JSXPreviewSkeletonProps) => {
|
|
318
|
+
const { status, hasRendered } = useJSXPreview();
|
|
319
|
+
|
|
320
|
+
// Show only before the first successful paint; once content builds up, the
|
|
321
|
+
// partial render is its own affordance (no skeleton-over-content).
|
|
322
|
+
const show = !hasRendered && status === "pending";
|
|
323
|
+
|
|
324
|
+
// The live region must exist in the DOM BEFORE its text appears, or NVDA
|
|
325
|
+
// skips the announcement (ARIA22). So the wrapper is ALWAYS rendered (it
|
|
326
|
+
// collapses to `sr-only` when idle) and the label is set one tick later via
|
|
327
|
+
// an effect — guaranteeing an empty region precedes the "Loading…" text.
|
|
328
|
+
const [announce, setAnnounce] = useState(false);
|
|
329
|
+
useEffect(() => setAnnounce(show), [show]);
|
|
330
|
+
|
|
331
|
+
return (
|
|
332
|
+
<div
|
|
333
|
+
className={cn(show ? "space-y-2" : "sr-only", className)}
|
|
334
|
+
role="status"
|
|
335
|
+
aria-live="polite"
|
|
336
|
+
{...props}
|
|
337
|
+
>
|
|
338
|
+
<span className="sr-only">{announce ? "Loading preview…" : ""}</span>
|
|
339
|
+
{show
|
|
340
|
+
? (children ?? (
|
|
341
|
+
<>
|
|
342
|
+
<Skeleton className="h-20 w-full" />
|
|
343
|
+
<div className="grid grid-cols-3 gap-2">
|
|
344
|
+
<Skeleton className="h-16" />
|
|
345
|
+
<Skeleton className="h-16" />
|
|
346
|
+
<Skeleton className="h-16" />
|
|
347
|
+
</div>
|
|
348
|
+
</>
|
|
349
|
+
))
|
|
350
|
+
: null}
|
|
351
|
+
</div>
|
|
352
|
+
);
|
|
353
|
+
},
|
|
354
|
+
);
|
|
355
|
+
|
|
356
|
+
JSXPreviewSkeleton.displayName = "JSXPreviewSkeleton";
|
|
357
|
+
|
|
358
|
+
export type JSXPreviewErrorProps = ComponentProps<"div"> & {
|
|
359
|
+
children?: ReactNode | ((error: Error) => ReactNode);
|
|
360
|
+
};
|
|
361
|
+
|
|
362
|
+
const renderChildren = (
|
|
363
|
+
children: ReactNode | ((error: Error) => ReactNode),
|
|
364
|
+
error: Error,
|
|
365
|
+
): ReactNode => {
|
|
366
|
+
if (typeof children === "function") {
|
|
367
|
+
return children(error);
|
|
368
|
+
}
|
|
369
|
+
return children;
|
|
370
|
+
};
|
|
371
|
+
|
|
372
|
+
export const JSXPreviewError = memo(({ className, children, ...props }: JSXPreviewErrorProps) => {
|
|
373
|
+
const { status, error } = useJSXPreview();
|
|
374
|
+
|
|
375
|
+
// Terminal failures only — never while loading / streaming / incomplete.
|
|
376
|
+
if (status !== "error" || !error) {
|
|
377
|
+
return null;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
return (
|
|
381
|
+
<div
|
|
382
|
+
// The status wash is the separation gesture (#194, research 08 §B.1):
|
|
383
|
+
// `bg-destructive/10` + the destructive ink/icon already mark the region,
|
|
384
|
+
// and the alpha-wash survives high decoration (token-inequality escape from the
|
|
385
|
+
// drawn-not-filled rule), so the old `border border-destructive/50` was
|
|
386
|
+
// redundant on every theme — dropped. Text + icon use `text-destructive-text`
|
|
387
|
+
// (the on-surface destructive ink ramp) — raw `text-destructive` fails AA on
|
|
388
|
+
// the wash (3.86:1) and goes near-white in dark; the *-text token is
|
|
389
|
+
// AA-guaranteed and keeps the destructive cue in every theme.
|
|
390
|
+
className={cn(
|
|
391
|
+
"flex items-center gap-2 rounded-md bg-destructive/10 p-3 text-body text-destructive-text",
|
|
392
|
+
className,
|
|
393
|
+
)}
|
|
394
|
+
role="alert"
|
|
395
|
+
{...props}
|
|
396
|
+
>
|
|
397
|
+
{children ? (
|
|
398
|
+
renderChildren(children, error)
|
|
399
|
+
) : (
|
|
400
|
+
<>
|
|
401
|
+
<AlertCircle className="size-4 shrink-0" />
|
|
402
|
+
<span>{error.message}</span>
|
|
403
|
+
</>
|
|
404
|
+
)}
|
|
405
|
+
</div>
|
|
406
|
+
);
|
|
407
|
+
});
|
|
408
|
+
|
|
409
|
+
JSXPreviewError.displayName = "JSXPreviewError";
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from "@storybook/react-vite";
|
|
2
|
+
import { MarkdownView } from "./markdown-view";
|
|
3
|
+
|
|
4
|
+
const meta = {
|
|
5
|
+
title: "AI/MarkdownView",
|
|
6
|
+
component: MarkdownView,
|
|
7
|
+
tags: ["autodocs"],
|
|
8
|
+
parameters: { layout: "padded" },
|
|
9
|
+
} satisfies Meta<typeof MarkdownView>;
|
|
10
|
+
export default meta;
|
|
11
|
+
type Story = StoryObj<typeof meta>;
|
|
12
|
+
|
|
13
|
+
const DOC = `# Quarterly summary
|
|
14
|
+
|
|
15
|
+
Revenue grew **12.4% QoQ**. See the [variance check](https://example.com) for details.
|
|
16
|
+
|
|
17
|
+
## Regional highlights
|
|
18
|
+
|
|
19
|
+
- EMEA $18.4M (+14%)
|
|
20
|
+
- Americas $21.2M (+8%)
|
|
21
|
+
- APAC $8.6M — watch churn
|
|
22
|
+
|
|
23
|
+
> Flagged by the automated variance check.
|
|
24
|
+
|
|
25
|
+
Inline \`code\` stays inline; fenced blocks go through Shiki:
|
|
26
|
+
|
|
27
|
+
\`\`\`sql
|
|
28
|
+
SELECT region, total FROM finance.revenue;
|
|
29
|
+
\`\`\`
|
|
30
|
+
`;
|
|
31
|
+
|
|
32
|
+
/** The full reading scale — markdown mapped onto the Prose* primitives. */
|
|
33
|
+
export const Default: Story = {
|
|
34
|
+
args: { children: DOC },
|
|
35
|
+
decorators: [
|
|
36
|
+
(Story) => (
|
|
37
|
+
<div className="max-w-prose">
|
|
38
|
+
<Story />
|
|
39
|
+
</div>
|
|
40
|
+
),
|
|
41
|
+
],
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* The constrained rung for narrow embeds (research 09 §G.2): `baseHeadingLevel={2}`
|
|
46
|
+
* maps a document `#` to the title rung — never "biggest text on screen" in a rail.
|
|
47
|
+
*/
|
|
48
|
+
export const ConstrainedHeadings: Story = {
|
|
49
|
+
args: { children: DOC, baseHeadingLevel: 2 },
|
|
50
|
+
decorators: [
|
|
51
|
+
(Story) => (
|
|
52
|
+
<div className="max-w-80">
|
|
53
|
+
<Story />
|
|
54
|
+
</div>
|
|
55
|
+
),
|
|
56
|
+
],
|
|
57
|
+
};
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { cleanup, render, screen } from "@testing-library/react";
|
|
2
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
3
|
+
import { MarkdownView } from "./markdown-view";
|
|
4
|
+
|
|
5
|
+
afterEach(cleanup);
|
|
6
|
+
|
|
7
|
+
const DOC = `# Board note
|
|
8
|
+
|
|
9
|
+
A paragraph with [a link](https://example.com) and \`inline\` code.
|
|
10
|
+
|
|
11
|
+
- first
|
|
12
|
+
- second
|
|
13
|
+
`;
|
|
14
|
+
|
|
15
|
+
describe("MarkdownView (#193, research 04 §5 — document, not Shiki source)", () => {
|
|
16
|
+
it("renders markdown onto the Prose primitives (document semantics)", () => {
|
|
17
|
+
render(<MarkdownView>{DOC}</MarkdownView>);
|
|
18
|
+
|
|
19
|
+
// Headings are real headings (not mono source text).
|
|
20
|
+
expect(screen.getByRole("heading", { level: 1, name: "Board note" })).toBeInTheDocument();
|
|
21
|
+
// Links map onto ProseLink (external → safe rel + new tab).
|
|
22
|
+
const link = screen.getByRole("link", { name: "a link" });
|
|
23
|
+
expect(link).toHaveAttribute("target", "_blank");
|
|
24
|
+
expect(link).toHaveAttribute("rel", expect.stringContaining("noopener"));
|
|
25
|
+
// Lists are real lists.
|
|
26
|
+
expect(screen.getAllByRole("listitem")).toHaveLength(2);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("caps headings at the constrained rung via baseHeadingLevel (research 09 §G.2)", () => {
|
|
30
|
+
render(<MarkdownView baseHeadingLevel={2}>{DOC}</MarkdownView>);
|
|
31
|
+
// The document `#` renders as an h2 (the `title` rung), never the
|
|
32
|
+
// reading-scale h1 — no "biggest text on screen" inside a 20rem rail.
|
|
33
|
+
expect(screen.queryByRole("heading", { level: 1 })).not.toBeInTheDocument();
|
|
34
|
+
expect(screen.getByRole("heading", { level: 2, name: "Board note" })).toBeInTheDocument();
|
|
35
|
+
});
|
|
36
|
+
});
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* MarkdownView — the branded READ-ONLY markdown renderer (#193, research 04 §5).
|
|
5
|
+
*
|
|
6
|
+
* Renders a markdown *document* as a document — never as Shiki source (that
|
|
7
|
+
* was the ASSET-2 defect: `CodeBlock code={…} language="markdown"`). Built on
|
|
8
|
+
* the EXISTING `streamdown` dependency (no new heavy dep), but unlike the
|
|
9
|
+
* streaming `MessageResponse` it maps the element tree onto the promoted
|
|
10
|
+
* `Prose*` primitives from `@elabs-ai/components-ui` via a `components` map — one
|
|
11
|
+
* source-owned prose set for chat answers, the editor preview and this view.
|
|
12
|
+
*
|
|
13
|
+
* `baseHeadingLevel` is the constrained-rung seam (research 04 §5 / 09 §G.2):
|
|
14
|
+
* inside a narrow rail pass `baseHeadingLevel={2}` so a document `#` renders
|
|
15
|
+
* at the `title` rung, not the reading-scale h1 — never "biggest text on
|
|
16
|
+
* screen" inside a 20rem panel.
|
|
17
|
+
*/
|
|
18
|
+
import {
|
|
19
|
+
ProseBlockquote,
|
|
20
|
+
ProseHeading,
|
|
21
|
+
ProseInlineCode,
|
|
22
|
+
ProseLink,
|
|
23
|
+
ProseList,
|
|
24
|
+
ProseListItem,
|
|
25
|
+
ProseText,
|
|
26
|
+
type ProseHeadingLevel,
|
|
27
|
+
} from "@elabs-ai/components-ui";
|
|
28
|
+
import { cn } from "@elabs-ai/components-ui/lib/cn";
|
|
29
|
+
import { useStreamdownPlugins, useStreamdownTranslations } from "./_streamdown-i18n";
|
|
30
|
+
import type { ComponentProps } from "react";
|
|
31
|
+
import { useMemo } from "react";
|
|
32
|
+
import { Streamdown } from "streamdown";
|
|
33
|
+
|
|
34
|
+
type StreamdownComponents = NonNullable<ComponentProps<typeof Streamdown>["components"]>;
|
|
35
|
+
|
|
36
|
+
const MAX_HEADING_LEVEL = 6;
|
|
37
|
+
|
|
38
|
+
const clampHeadingLevel = (level: number): ProseHeadingLevel =>
|
|
39
|
+
Math.min(MAX_HEADING_LEVEL, Math.max(1, level)) as ProseHeadingLevel;
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Map the markdown element tree onto the Prose* primitives. Fenced code blocks
|
|
43
|
+
* (className `language-*`) stay with the streamdown `code` plugin (Shiki);
|
|
44
|
+
* only INLINE code re-maps to `ProseInlineCode`.
|
|
45
|
+
*/
|
|
46
|
+
function buildProseComponents(baseHeadingLevel: ProseHeadingLevel): StreamdownComponents {
|
|
47
|
+
const heading =
|
|
48
|
+
(markdownLevel: number): StreamdownComponents["h1"] =>
|
|
49
|
+
({ node: _node, ...props }) => (
|
|
50
|
+
<ProseHeading level={clampHeadingLevel(markdownLevel + baseHeadingLevel - 1)} {...props} />
|
|
51
|
+
);
|
|
52
|
+
return {
|
|
53
|
+
h1: heading(1),
|
|
54
|
+
h2: heading(2),
|
|
55
|
+
h3: heading(3),
|
|
56
|
+
h4: heading(4),
|
|
57
|
+
h5: heading(5),
|
|
58
|
+
h6: heading(6),
|
|
59
|
+
p: ({ node: _node, ...props }) => <ProseText {...props} />,
|
|
60
|
+
a: ({ node: _node, ...props }) => <ProseLink {...props} />,
|
|
61
|
+
// `ref` is stripped: ProseList's intersection ref type (ul & ol) is
|
|
62
|
+
// narrower than the per-element ref react-markdown passes.
|
|
63
|
+
ul: ({ node: _node, ref: _ref, ...props }) => <ProseList {...props} />,
|
|
64
|
+
ol: ({ node: _node, ref: _ref, ...props }) => <ProseList ordered {...props} />,
|
|
65
|
+
li: ({ node: _node, ...props }) => <ProseListItem {...props} />,
|
|
66
|
+
blockquote: ({ node: _node, ...props }) => <ProseBlockquote {...props} />,
|
|
67
|
+
code: ({ node: _node, className, ...props }) =>
|
|
68
|
+
/\blanguage-/.test(className ?? "") ? (
|
|
69
|
+
<code className={className} {...props} />
|
|
70
|
+
) : (
|
|
71
|
+
<ProseInlineCode className={className} {...props} />
|
|
72
|
+
),
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export interface MarkdownViewProps extends Omit<
|
|
77
|
+
ComponentProps<typeof Streamdown>,
|
|
78
|
+
"components" | "plugins"
|
|
79
|
+
> {
|
|
80
|
+
/**
|
|
81
|
+
* The prose level a markdown `#` maps to; deeper headings shift with it
|
|
82
|
+
* (capped at 6). Default `1` (the full reading scale). Pass `2` in narrow
|
|
83
|
+
* embeds (a context rail) so headings stay on the constrained rung.
|
|
84
|
+
*/
|
|
85
|
+
baseHeadingLevel?: ProseHeadingLevel;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export const MarkdownView = ({
|
|
89
|
+
baseHeadingLevel = 1,
|
|
90
|
+
className,
|
|
91
|
+
children,
|
|
92
|
+
...props
|
|
93
|
+
}: MarkdownViewProps) => {
|
|
94
|
+
const components = useMemo(() => buildProseComponents(baseHeadingLevel), [baseHeadingLevel]);
|
|
95
|
+
// Streamdown's own chrome (code copy, table menus) reads the locale seam (#310).
|
|
96
|
+
// Spread AFTER so an explicit `translations` prop still wins (ADR 0017).
|
|
97
|
+
const translations = useStreamdownTranslations();
|
|
98
|
+
// Brand-token-derived `code` plugin, not the package's static github-*
|
|
99
|
+
// default (#315 follow-up) — re-derives when the active theme changes.
|
|
100
|
+
const plugins = useStreamdownPlugins();
|
|
101
|
+
return (
|
|
102
|
+
<Streamdown
|
|
103
|
+
data-slot="markdown-view"
|
|
104
|
+
className={cn("space-y-3 [&>*:first-child]:mt-0 [&>*:last-child]:mb-0", className)}
|
|
105
|
+
components={components}
|
|
106
|
+
plugins={plugins}
|
|
107
|
+
translations={translations}
|
|
108
|
+
{...props}
|
|
109
|
+
>
|
|
110
|
+
{children}
|
|
111
|
+
</Streamdown>
|
|
112
|
+
);
|
|
113
|
+
};
|