@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,479 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useControllableState } from "@radix-ui/react-use-controllable-state";
|
|
4
|
+
import { Button } from "@elabs-ai/components-ui";
|
|
5
|
+
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@elabs-ai/components-ui";
|
|
6
|
+
import { cn } from "@elabs-ai/components-ui/lib/cn";
|
|
7
|
+
import { AlertTriangleIcon, CheckIcon, ChevronDownIcon, CopyIcon } from "lucide-react";
|
|
8
|
+
import type { ComponentProps } from "react";
|
|
9
|
+
import {
|
|
10
|
+
createContext,
|
|
11
|
+
memo,
|
|
12
|
+
useCallback,
|
|
13
|
+
useContext,
|
|
14
|
+
useEffect,
|
|
15
|
+
useMemo,
|
|
16
|
+
useRef,
|
|
17
|
+
useState,
|
|
18
|
+
} from "react";
|
|
19
|
+
|
|
20
|
+
// Regex patterns for parsing stack traces
|
|
21
|
+
const STACK_FRAME_WITH_PARENS_REGEX = /^at\s+(.+?)\s+\((.+):(\d+):(\d+)\)$/;
|
|
22
|
+
const STACK_FRAME_WITHOUT_FN_REGEX = /^at\s+(.+):(\d+):(\d+)$/;
|
|
23
|
+
const ERROR_TYPE_REGEX = /^(\w+Error|Error):\s*(.*)$/;
|
|
24
|
+
const AT_PREFIX_REGEX = /^at\s+/;
|
|
25
|
+
|
|
26
|
+
interface StackFrame {
|
|
27
|
+
raw: string;
|
|
28
|
+
functionName: string | null;
|
|
29
|
+
filePath: string | null;
|
|
30
|
+
lineNumber: number | null;
|
|
31
|
+
columnNumber: number | null;
|
|
32
|
+
isInternal: boolean;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
interface ParsedStackTrace {
|
|
36
|
+
errorType: string | null;
|
|
37
|
+
errorMessage: string;
|
|
38
|
+
frames: StackFrame[];
|
|
39
|
+
raw: string;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
interface StackTraceContextValue {
|
|
43
|
+
trace: ParsedStackTrace;
|
|
44
|
+
raw: string;
|
|
45
|
+
isOpen: boolean;
|
|
46
|
+
setIsOpen: (open: boolean) => void;
|
|
47
|
+
onFilePathClick?: (filePath: string, line?: number, column?: number) => void;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const StackTraceContext = createContext<StackTraceContextValue | null>(null);
|
|
51
|
+
|
|
52
|
+
const useStackTrace = () => {
|
|
53
|
+
const context = useContext(StackTraceContext);
|
|
54
|
+
if (!context) {
|
|
55
|
+
throw new Error("StackTrace components must be used within StackTrace");
|
|
56
|
+
}
|
|
57
|
+
return context;
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
const parseStackFrame = (line: string): StackFrame => {
|
|
61
|
+
const trimmed = line.trim();
|
|
62
|
+
|
|
63
|
+
// Pattern: at functionName (filePath:line:column)
|
|
64
|
+
const withParensMatch = trimmed.match(STACK_FRAME_WITH_PARENS_REGEX);
|
|
65
|
+
if (withParensMatch) {
|
|
66
|
+
const [, functionName, filePath, lineNum, colNum] = withParensMatch;
|
|
67
|
+
const isInternal =
|
|
68
|
+
(filePath?.includes("node_modules") ?? false) ||
|
|
69
|
+
(filePath?.startsWith("node:") ?? false) ||
|
|
70
|
+
(filePath?.includes("internal/") ?? false);
|
|
71
|
+
return {
|
|
72
|
+
columnNumber: colNum ? Number.parseInt(colNum, 10) : null,
|
|
73
|
+
filePath: filePath ?? null,
|
|
74
|
+
functionName: functionName ?? null,
|
|
75
|
+
isInternal,
|
|
76
|
+
lineNumber: lineNum ? Number.parseInt(lineNum, 10) : null,
|
|
77
|
+
raw: trimmed,
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Pattern: at filePath:line:column (no function name)
|
|
82
|
+
const withoutFnMatch = trimmed.match(STACK_FRAME_WITHOUT_FN_REGEX);
|
|
83
|
+
if (withoutFnMatch) {
|
|
84
|
+
const [, filePath, lineNum, colNum] = withoutFnMatch;
|
|
85
|
+
const isInternal =
|
|
86
|
+
(filePath?.includes("node_modules") ?? false) ||
|
|
87
|
+
(filePath?.startsWith("node:") ?? false) ||
|
|
88
|
+
(filePath?.includes("internal/") ?? false);
|
|
89
|
+
return {
|
|
90
|
+
columnNumber: colNum ? Number.parseInt(colNum, 10) : null,
|
|
91
|
+
filePath: filePath ?? null,
|
|
92
|
+
functionName: null,
|
|
93
|
+
isInternal,
|
|
94
|
+
lineNumber: lineNum ? Number.parseInt(lineNum, 10) : null,
|
|
95
|
+
raw: trimmed,
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Fallback: unparseable line
|
|
100
|
+
return {
|
|
101
|
+
columnNumber: null,
|
|
102
|
+
filePath: null,
|
|
103
|
+
functionName: null,
|
|
104
|
+
isInternal: trimmed.includes("node_modules") || trimmed.includes("node:"),
|
|
105
|
+
lineNumber: null,
|
|
106
|
+
raw: trimmed,
|
|
107
|
+
};
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
const parseStackTrace = (trace: string): ParsedStackTrace => {
|
|
111
|
+
const lines = trace.split("\n").filter((line) => line.trim());
|
|
112
|
+
|
|
113
|
+
if (lines.length === 0) {
|
|
114
|
+
return {
|
|
115
|
+
errorMessage: trace,
|
|
116
|
+
errorType: null,
|
|
117
|
+
frames: [],
|
|
118
|
+
raw: trace,
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const firstLine = lines[0]?.trim() ?? "";
|
|
123
|
+
let errorType: string | null = null;
|
|
124
|
+
let errorMessage = firstLine;
|
|
125
|
+
|
|
126
|
+
// Try to extract error type from "ErrorType: message" format
|
|
127
|
+
const errorMatch = firstLine.match(ERROR_TYPE_REGEX);
|
|
128
|
+
if (errorMatch) {
|
|
129
|
+
const [, type, msg] = errorMatch;
|
|
130
|
+
errorType = type ?? null;
|
|
131
|
+
errorMessage = msg || "";
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Parse stack frames (lines starting with "at")
|
|
135
|
+
const frames = lines
|
|
136
|
+
.slice(1)
|
|
137
|
+
.filter((line) => line.trim().startsWith("at "))
|
|
138
|
+
.map(parseStackFrame);
|
|
139
|
+
|
|
140
|
+
return {
|
|
141
|
+
errorMessage,
|
|
142
|
+
errorType,
|
|
143
|
+
frames,
|
|
144
|
+
raw: trace,
|
|
145
|
+
};
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
export type StackTraceProps = ComponentProps<"div"> & {
|
|
149
|
+
trace: string;
|
|
150
|
+
open?: boolean;
|
|
151
|
+
defaultOpen?: boolean;
|
|
152
|
+
onOpenChange?: (open: boolean) => void;
|
|
153
|
+
onFilePathClick?: (filePath: string, line?: number, column?: number) => void;
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
export const StackTrace = memo(
|
|
157
|
+
({
|
|
158
|
+
trace,
|
|
159
|
+
className,
|
|
160
|
+
open,
|
|
161
|
+
defaultOpen = false,
|
|
162
|
+
onOpenChange,
|
|
163
|
+
onFilePathClick,
|
|
164
|
+
children,
|
|
165
|
+
...props
|
|
166
|
+
}: StackTraceProps) => {
|
|
167
|
+
const [isOpen, setIsOpen] = useControllableState({
|
|
168
|
+
defaultProp: defaultOpen,
|
|
169
|
+
onChange: onOpenChange,
|
|
170
|
+
prop: open,
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
const parsedTrace = useMemo(() => parseStackTrace(trace), [trace]);
|
|
174
|
+
|
|
175
|
+
const contextValue = useMemo(
|
|
176
|
+
() => ({
|
|
177
|
+
isOpen,
|
|
178
|
+
onFilePathClick,
|
|
179
|
+
raw: trace,
|
|
180
|
+
setIsOpen,
|
|
181
|
+
trace: parsedTrace,
|
|
182
|
+
}),
|
|
183
|
+
[parsedTrace, trace, isOpen, setIsOpen, onFilePathClick],
|
|
184
|
+
);
|
|
185
|
+
|
|
186
|
+
return (
|
|
187
|
+
<StackTraceContext.Provider value={contextValue}>
|
|
188
|
+
<div
|
|
189
|
+
className={cn(
|
|
190
|
+
"not-prose w-full overflow-hidden rounded-lg border bg-background font-mono text-sm",
|
|
191
|
+
className,
|
|
192
|
+
)}
|
|
193
|
+
{...props}
|
|
194
|
+
>
|
|
195
|
+
{children}
|
|
196
|
+
</div>
|
|
197
|
+
</StackTraceContext.Provider>
|
|
198
|
+
);
|
|
199
|
+
},
|
|
200
|
+
);
|
|
201
|
+
|
|
202
|
+
export type StackTraceHeaderProps = ComponentProps<typeof CollapsibleTrigger>;
|
|
203
|
+
|
|
204
|
+
export const StackTraceHeader = memo(({ className, children, ...props }: StackTraceHeaderProps) => {
|
|
205
|
+
const { isOpen, setIsOpen } = useStackTrace();
|
|
206
|
+
|
|
207
|
+
return (
|
|
208
|
+
<Collapsible onOpenChange={setIsOpen} open={isOpen}>
|
|
209
|
+
<CollapsibleTrigger asChild {...props}>
|
|
210
|
+
<div
|
|
211
|
+
className={cn(
|
|
212
|
+
"flex w-full cursor-pointer items-center gap-3 p-3 text-start transition-colors hover:bg-muted/50",
|
|
213
|
+
className,
|
|
214
|
+
)}
|
|
215
|
+
>
|
|
216
|
+
{children}
|
|
217
|
+
</div>
|
|
218
|
+
</CollapsibleTrigger>
|
|
219
|
+
</Collapsible>
|
|
220
|
+
);
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
export type StackTraceErrorProps = ComponentProps<"div">;
|
|
224
|
+
|
|
225
|
+
export const StackTraceError = memo(({ className, children, ...props }: StackTraceErrorProps) => (
|
|
226
|
+
<div className={cn("flex flex-1 items-center gap-2 overflow-hidden", className)} {...props}>
|
|
227
|
+
<AlertTriangleIcon className="size-4 shrink-0 text-destructive" />
|
|
228
|
+
{children}
|
|
229
|
+
</div>
|
|
230
|
+
));
|
|
231
|
+
|
|
232
|
+
export type StackTraceErrorTypeProps = ComponentProps<"span">;
|
|
233
|
+
|
|
234
|
+
export const StackTraceErrorType = memo(
|
|
235
|
+
({ className, children, ...props }: StackTraceErrorTypeProps) => {
|
|
236
|
+
const { trace } = useStackTrace();
|
|
237
|
+
|
|
238
|
+
return (
|
|
239
|
+
<span className={cn("shrink-0 font-semibold text-destructive", className)} {...props}>
|
|
240
|
+
{children ?? trace.errorType}
|
|
241
|
+
</span>
|
|
242
|
+
);
|
|
243
|
+
},
|
|
244
|
+
);
|
|
245
|
+
|
|
246
|
+
export type StackTraceErrorMessageProps = ComponentProps<"span">;
|
|
247
|
+
|
|
248
|
+
export const StackTraceErrorMessage = memo(
|
|
249
|
+
({ className, children, ...props }: StackTraceErrorMessageProps) => {
|
|
250
|
+
const { trace } = useStackTrace();
|
|
251
|
+
|
|
252
|
+
return (
|
|
253
|
+
<span className={cn("truncate text-foreground", className)} {...props}>
|
|
254
|
+
{children ?? trace.errorMessage}
|
|
255
|
+
</span>
|
|
256
|
+
);
|
|
257
|
+
},
|
|
258
|
+
);
|
|
259
|
+
|
|
260
|
+
export type StackTraceActionsProps = ComponentProps<"div">;
|
|
261
|
+
|
|
262
|
+
const handleActionsClick = (e: React.MouseEvent) => e.stopPropagation();
|
|
263
|
+
const handleActionsKeyDown = (e: React.KeyboardEvent) => {
|
|
264
|
+
if (e.key === "Enter" || e.key === " ") {
|
|
265
|
+
e.stopPropagation();
|
|
266
|
+
}
|
|
267
|
+
};
|
|
268
|
+
|
|
269
|
+
export const StackTraceActions = memo(
|
|
270
|
+
({ className, children, ...props }: StackTraceActionsProps) => (
|
|
271
|
+
<div
|
|
272
|
+
className={cn("flex shrink-0 items-center gap-1", className)}
|
|
273
|
+
onClick={handleActionsClick}
|
|
274
|
+
onKeyDown={handleActionsKeyDown}
|
|
275
|
+
role="group"
|
|
276
|
+
{...props}
|
|
277
|
+
>
|
|
278
|
+
{children}
|
|
279
|
+
</div>
|
|
280
|
+
),
|
|
281
|
+
);
|
|
282
|
+
|
|
283
|
+
export type StackTraceCopyButtonProps = ComponentProps<typeof Button> & {
|
|
284
|
+
onCopy?: () => void;
|
|
285
|
+
onError?: (error: Error) => void;
|
|
286
|
+
timeout?: number;
|
|
287
|
+
};
|
|
288
|
+
|
|
289
|
+
export const StackTraceCopyButton = memo(
|
|
290
|
+
({
|
|
291
|
+
onCopy,
|
|
292
|
+
onError,
|
|
293
|
+
timeout = 2000,
|
|
294
|
+
className,
|
|
295
|
+
children,
|
|
296
|
+
...props
|
|
297
|
+
}: StackTraceCopyButtonProps) => {
|
|
298
|
+
const [isCopied, setIsCopied] = useState(false);
|
|
299
|
+
const timeoutRef = useRef<number>(0);
|
|
300
|
+
const { raw } = useStackTrace();
|
|
301
|
+
|
|
302
|
+
const copyToClipboard = useCallback(async () => {
|
|
303
|
+
if (typeof window === "undefined" || !navigator?.clipboard?.writeText) {
|
|
304
|
+
onError?.(new Error("Clipboard API not available"));
|
|
305
|
+
return;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
try {
|
|
309
|
+
await navigator.clipboard.writeText(raw);
|
|
310
|
+
setIsCopied(true);
|
|
311
|
+
onCopy?.();
|
|
312
|
+
timeoutRef.current = window.setTimeout(() => setIsCopied(false), timeout);
|
|
313
|
+
} catch (error) {
|
|
314
|
+
onError?.(error as Error);
|
|
315
|
+
}
|
|
316
|
+
}, [raw, onCopy, onError, timeout]);
|
|
317
|
+
|
|
318
|
+
useEffect(
|
|
319
|
+
() => () => {
|
|
320
|
+
window.clearTimeout(timeoutRef.current);
|
|
321
|
+
},
|
|
322
|
+
[],
|
|
323
|
+
);
|
|
324
|
+
|
|
325
|
+
const Icon = isCopied ? CheckIcon : CopyIcon;
|
|
326
|
+
|
|
327
|
+
return (
|
|
328
|
+
<Button
|
|
329
|
+
className={cn("size-7", className)}
|
|
330
|
+
onClick={copyToClipboard}
|
|
331
|
+
size="icon"
|
|
332
|
+
variant="ghost"
|
|
333
|
+
{...props}
|
|
334
|
+
>
|
|
335
|
+
{children ?? <Icon size={14} />}
|
|
336
|
+
</Button>
|
|
337
|
+
);
|
|
338
|
+
},
|
|
339
|
+
);
|
|
340
|
+
|
|
341
|
+
export type StackTraceExpandButtonProps = ComponentProps<"div">;
|
|
342
|
+
|
|
343
|
+
export const StackTraceExpandButton = memo(
|
|
344
|
+
({ className, ...props }: StackTraceExpandButtonProps) => {
|
|
345
|
+
const { isOpen } = useStackTrace();
|
|
346
|
+
|
|
347
|
+
return (
|
|
348
|
+
<div className={cn("flex size-7 items-center justify-center", className)} {...props}>
|
|
349
|
+
<ChevronDownIcon
|
|
350
|
+
className={cn(
|
|
351
|
+
"size-4 text-muted-foreground transition-transform",
|
|
352
|
+
isOpen ? "rotate-180" : "rotate-0",
|
|
353
|
+
)}
|
|
354
|
+
/>
|
|
355
|
+
</div>
|
|
356
|
+
);
|
|
357
|
+
},
|
|
358
|
+
);
|
|
359
|
+
|
|
360
|
+
export type StackTraceContentProps = ComponentProps<typeof CollapsibleContent> & {
|
|
361
|
+
maxHeight?: number;
|
|
362
|
+
};
|
|
363
|
+
|
|
364
|
+
export const StackTraceContent = memo(
|
|
365
|
+
({ className, maxHeight = 400, children, ...props }: StackTraceContentProps) => {
|
|
366
|
+
const { isOpen } = useStackTrace();
|
|
367
|
+
|
|
368
|
+
return (
|
|
369
|
+
<Collapsible open={isOpen}>
|
|
370
|
+
<CollapsibleContent
|
|
371
|
+
className={cn(
|
|
372
|
+
"overflow-auto border-t bg-muted/30",
|
|
373
|
+
"data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:animate-out data-[state=open]:animate-in",
|
|
374
|
+
className,
|
|
375
|
+
)}
|
|
376
|
+
style={{ maxHeight }}
|
|
377
|
+
{...props}
|
|
378
|
+
>
|
|
379
|
+
{children}
|
|
380
|
+
</CollapsibleContent>
|
|
381
|
+
</Collapsible>
|
|
382
|
+
);
|
|
383
|
+
},
|
|
384
|
+
);
|
|
385
|
+
|
|
386
|
+
export type StackTraceFramesProps = ComponentProps<"div"> & {
|
|
387
|
+
showInternalFrames?: boolean;
|
|
388
|
+
};
|
|
389
|
+
|
|
390
|
+
interface FilePathButtonProps {
|
|
391
|
+
frame: StackFrame;
|
|
392
|
+
onFilePathClick?: (filePath: string, lineNumber?: number, columnNumber?: number) => void;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
const FilePathButton = memo(({ frame, onFilePathClick }: FilePathButtonProps) => {
|
|
396
|
+
const handleClick = useCallback(() => {
|
|
397
|
+
if (frame.filePath) {
|
|
398
|
+
onFilePathClick?.(
|
|
399
|
+
frame.filePath,
|
|
400
|
+
frame.lineNumber ?? undefined,
|
|
401
|
+
frame.columnNumber ?? undefined,
|
|
402
|
+
);
|
|
403
|
+
}
|
|
404
|
+
}, [frame, onFilePathClick]);
|
|
405
|
+
|
|
406
|
+
return (
|
|
407
|
+
<button
|
|
408
|
+
className={cn(
|
|
409
|
+
// #399 — a frame link is body text; the hover ink takes the `-text` rung.
|
|
410
|
+
"underline decoration-dotted hover:text-primary-text",
|
|
411
|
+
onFilePathClick && "cursor-pointer",
|
|
412
|
+
)}
|
|
413
|
+
disabled={!onFilePathClick}
|
|
414
|
+
onClick={handleClick}
|
|
415
|
+
type="button"
|
|
416
|
+
>
|
|
417
|
+
{frame.filePath}
|
|
418
|
+
{frame.lineNumber !== null && `:${frame.lineNumber}`}
|
|
419
|
+
{frame.columnNumber !== null && `:${frame.columnNumber}`}
|
|
420
|
+
</button>
|
|
421
|
+
);
|
|
422
|
+
});
|
|
423
|
+
|
|
424
|
+
FilePathButton.displayName = "FilePathButton";
|
|
425
|
+
|
|
426
|
+
export const StackTraceFrames = memo(
|
|
427
|
+
({ className, showInternalFrames = true, ...props }: StackTraceFramesProps) => {
|
|
428
|
+
const { trace, onFilePathClick } = useStackTrace();
|
|
429
|
+
|
|
430
|
+
const framesToShow = showInternalFrames
|
|
431
|
+
? trace.frames
|
|
432
|
+
: trace.frames.filter((f) => !f.isInternal);
|
|
433
|
+
|
|
434
|
+
return (
|
|
435
|
+
<div className={cn("space-y-1 p-3", className)} {...props}>
|
|
436
|
+
{framesToShow.map((frame) => (
|
|
437
|
+
<div
|
|
438
|
+
className={cn(
|
|
439
|
+
"text-xs",
|
|
440
|
+
frame.isInternal ? "text-muted-foreground/50" : "text-foreground/90",
|
|
441
|
+
)}
|
|
442
|
+
key={frame.raw}
|
|
443
|
+
>
|
|
444
|
+
<span className="text-muted-foreground">at </span>
|
|
445
|
+
{frame.functionName && (
|
|
446
|
+
<span className={frame.isInternal ? "" : "text-foreground"}>
|
|
447
|
+
{frame.functionName}{" "}
|
|
448
|
+
</span>
|
|
449
|
+
)}
|
|
450
|
+
{frame.filePath && (
|
|
451
|
+
<>
|
|
452
|
+
<span className="text-muted-foreground">(</span>
|
|
453
|
+
<FilePathButton frame={frame} onFilePathClick={onFilePathClick} />
|
|
454
|
+
<span className="text-muted-foreground">)</span>
|
|
455
|
+
</>
|
|
456
|
+
)}
|
|
457
|
+
{!(frame.filePath || frame.functionName) && (
|
|
458
|
+
<span>{frame.raw.replace(AT_PREFIX_REGEX, "")}</span>
|
|
459
|
+
)}
|
|
460
|
+
</div>
|
|
461
|
+
))}
|
|
462
|
+
{framesToShow.length === 0 && (
|
|
463
|
+
<div className="text-muted-foreground text-xs">No stack frames</div>
|
|
464
|
+
)}
|
|
465
|
+
</div>
|
|
466
|
+
);
|
|
467
|
+
},
|
|
468
|
+
);
|
|
469
|
+
|
|
470
|
+
StackTrace.displayName = "StackTrace";
|
|
471
|
+
StackTraceHeader.displayName = "StackTraceHeader";
|
|
472
|
+
StackTraceError.displayName = "StackTraceError";
|
|
473
|
+
StackTraceErrorType.displayName = "StackTraceErrorType";
|
|
474
|
+
StackTraceErrorMessage.displayName = "StackTraceErrorMessage";
|
|
475
|
+
StackTraceActions.displayName = "StackTraceActions";
|
|
476
|
+
StackTraceCopyButton.displayName = "StackTraceCopyButton";
|
|
477
|
+
StackTraceExpandButton.displayName = "StackTraceExpandButton";
|
|
478
|
+
StackTraceContent.displayName = "StackTraceContent";
|
|
479
|
+
StackTraceFrames.displayName = "StackTraceFrames";
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* #310 — Streamdown renders its own chrome (code-block copy/download, table
|
|
3
|
+
* menus, Mermaid toolbar, external-link interstitial) from a `translations`
|
|
4
|
+
* prop we never passed, so a `<LocaleProvider>` stopped at the markdown
|
|
5
|
+
* boundary and every streamed surface leaked English.
|
|
6
|
+
*
|
|
7
|
+
* These assertions run against the REAL Streamdown (no mock) — a stand-in would
|
|
8
|
+
* prove nothing about the shipped surface.
|
|
9
|
+
*/
|
|
10
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
11
|
+
import { render, screen, waitFor } from "@testing-library/react";
|
|
12
|
+
import { LocaleProvider } from "@elabs-ai/components-ui";
|
|
13
|
+
import { MessageResponse } from "./message";
|
|
14
|
+
import { MarkdownView } from "./markdown-view";
|
|
15
|
+
import { Reasoning, ReasoningContent } from "./reasoning";
|
|
16
|
+
|
|
17
|
+
const CODE_MARKDOWN = "```js\nconst a = 1;\n```";
|
|
18
|
+
// `MarkdownView` re-maps `code` onto `ProseInlineCode`, so its Streamdown chrome
|
|
19
|
+
// surfaces on the table toolbar rather than the code-block header.
|
|
20
|
+
const TABLE_MARKDOWN = "| a | b |\n| --- | --- |\n| 1 | 2 |\n";
|
|
21
|
+
|
|
22
|
+
afterEach(() => {
|
|
23
|
+
document.documentElement.removeAttribute("data-theme");
|
|
24
|
+
document.documentElement.style.removeProperty("--code-keyword");
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Shiki highlights ASYNCHRONOUSLY, and how long it takes is a property of the
|
|
29
|
+
* machine, not of the code under test: ~324ms on a warm dev laptop, over 1.7s
|
|
30
|
+
* on a cold CI runner loading grammars for the first time. Testing Library's
|
|
31
|
+
* default `waitFor` timeout is 1s, so the highlight assertions below passed
|
|
32
|
+
* locally and reddened a blocking CI job with `Error: not yet highlighted` —
|
|
33
|
+
* a false failure about the runner's speed, not about the colours.
|
|
34
|
+
*
|
|
35
|
+
* The condition itself is precise ("a token span carries a resolved `--sdm-c`
|
|
36
|
+
* hex"), so waiting longer cannot make a broken result pass; it only stops the
|
|
37
|
+
* wait from expiring first.
|
|
38
|
+
*/
|
|
39
|
+
const HIGHLIGHT_TIMEOUT = { timeout: 10_000, interval: 50 } as const;
|
|
40
|
+
|
|
41
|
+
/** The token span whose text is `keyword` — waits until it's actually highlighted (not the raw fallback). */
|
|
42
|
+
async function findHighlightedKeywordSpan(container: HTMLElement, keyword: string) {
|
|
43
|
+
return waitFor(() => {
|
|
44
|
+
const span = [...container.querySelectorAll("pre code span")].find(
|
|
45
|
+
(el) => el.textContent === keyword,
|
|
46
|
+
);
|
|
47
|
+
const style = span?.getAttribute("style") ?? "";
|
|
48
|
+
if (!/--sdm-c:\s*#/.test(style)) throw new Error("not yet highlighted");
|
|
49
|
+
return span as HTMLElement;
|
|
50
|
+
}, HIGHLIGHT_TIMEOUT);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
describe("Streamdown chrome microcopy (#310)", () => {
|
|
54
|
+
it("MessageResponse resolves Streamdown's copy control through the locale seam", async () => {
|
|
55
|
+
render(
|
|
56
|
+
<LocaleProvider messages={{ "ai.streamdown.copyCode": "Kopieren" }}>
|
|
57
|
+
<MessageResponse>{CODE_MARKDOWN}</MessageResponse>
|
|
58
|
+
</LocaleProvider>,
|
|
59
|
+
);
|
|
60
|
+
|
|
61
|
+
expect(await screen.findByRole("button", { name: "Kopieren" })).toBeInTheDocument();
|
|
62
|
+
expect(screen.queryByRole("button", { name: "Copy Code" })).not.toBeInTheDocument();
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("MessageResponse keeps streamdown's English default when nothing is overridden", async () => {
|
|
66
|
+
render(<MessageResponse>{CODE_MARKDOWN}</MessageResponse>);
|
|
67
|
+
|
|
68
|
+
// Byte-identical to streamdown@2.5.0's `defaultTranslations` — wiring the
|
|
69
|
+
// seam must be a no-op for consumers that override nothing (ADR 0017).
|
|
70
|
+
expect(await screen.findByRole("button", { name: "Copy Code" })).toBeInTheDocument();
|
|
71
|
+
expect(screen.getByRole("button", { name: "Download file" })).toBeInTheDocument();
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("MarkdownView resolves the chrome through the locale seam", async () => {
|
|
75
|
+
render(
|
|
76
|
+
<LocaleProvider messages={{ "ai.streamdown.copyTable": "Tabelle kopieren" }}>
|
|
77
|
+
<MarkdownView>{TABLE_MARKDOWN}</MarkdownView>
|
|
78
|
+
</LocaleProvider>,
|
|
79
|
+
);
|
|
80
|
+
|
|
81
|
+
expect(await screen.findByRole("button", { name: "Tabelle kopieren" })).toBeInTheDocument();
|
|
82
|
+
expect(screen.queryByRole("button", { name: "Copy table" })).not.toBeInTheDocument();
|
|
83
|
+
// Un-overridden keys still fall through to streamdown's English default.
|
|
84
|
+
expect(screen.getByRole("button", { name: "Download table" })).toBeInTheDocument();
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("ReasoningContent resolves the chrome through the locale seam", async () => {
|
|
88
|
+
render(
|
|
89
|
+
<LocaleProvider messages={{ "ai.streamdown.copyCode": "Kopieren" }}>
|
|
90
|
+
<Reasoning open>
|
|
91
|
+
<ReasoningContent>{CODE_MARKDOWN}</ReasoningContent>
|
|
92
|
+
</Reasoning>
|
|
93
|
+
</LocaleProvider>,
|
|
94
|
+
);
|
|
95
|
+
|
|
96
|
+
expect(await screen.findByRole("button", { name: "Kopieren" })).toBeInTheDocument();
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it("lets an explicit `translations` prop win over the locale seam", async () => {
|
|
100
|
+
render(
|
|
101
|
+
<LocaleProvider messages={{ "ai.streamdown.copyCode": "Kopieren" }}>
|
|
102
|
+
<MessageResponse translations={{ copyCode: "Copiar" }}>{CODE_MARKDOWN}</MessageResponse>
|
|
103
|
+
</LocaleProvider>,
|
|
104
|
+
);
|
|
105
|
+
|
|
106
|
+
expect(await screen.findByRole("button", { name: "Copiar" })).toBeInTheDocument();
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* #315 follow-up — the "major" carve-out: only `<CodeBlock>` had been
|
|
112
|
+
* de-GitHub-ed; every fenced code block rendered through `MessageResponse`/
|
|
113
|
+
* `MarkdownView`/`ReasoningContent`'s shared Streamdown instance still went
|
|
114
|
+
* through `@streamdown/code`'s pre-configured plugin, whose `getThemes()` is
|
|
115
|
+
* frozen to `["github-light", "github-dark"]`. These assertions render the
|
|
116
|
+
* REAL components (no mock) and read the resolved inline color custom
|
|
117
|
+
* properties Streamdown's own code-block renderer emits per token
|
|
118
|
+
* (`--sdm-c`/`--shiki-dark`, see `chunk-BO2N2NFS.js`'s `HighlightedCodeBlockBody`).
|
|
119
|
+
*/
|
|
120
|
+
describe("Streamdown code-block theme (#315 follow-up)", () => {
|
|
121
|
+
it("MessageResponse derives fenced code-block colors from brand --code-* tokens, not a static github palette", async () => {
|
|
122
|
+
document.documentElement.setAttribute("data-theme", "light");
|
|
123
|
+
document.documentElement.style.setProperty("--code-keyword", "oklch(0.38 0.16 264)");
|
|
124
|
+
|
|
125
|
+
const { container } = render(<MessageResponse>{CODE_MARKDOWN}</MessageResponse>);
|
|
126
|
+
|
|
127
|
+
const span = await findHighlightedKeywordSpan(container, "const");
|
|
128
|
+
// oklch(0.38 0.16 264) → #133796 — never a github-light/dark literal
|
|
129
|
+
// (github-light's keyword is #d73a49, github-dark's is #ff7b72).
|
|
130
|
+
expect(span.getAttribute("style")).toContain("--sdm-c: #133796");
|
|
131
|
+
// Both dual-theme slots are pinned to the SAME active-theme color (#315
|
|
132
|
+
// follow-up design: brand-ui has three themes, not the two Shiki's
|
|
133
|
+
// light/dark mechanism expects), so `.dark` never resolves a different palette.
|
|
134
|
+
expect(span.getAttribute("style")).toContain("--shiki-dark: #133796");
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it("re-derives MessageResponse's code colors when data-theme changes at runtime", async () => {
|
|
138
|
+
document.documentElement.setAttribute("data-theme", "light");
|
|
139
|
+
document.documentElement.style.setProperty("--code-keyword", "oklch(0.38 0.16 264)");
|
|
140
|
+
|
|
141
|
+
const { container } = render(
|
|
142
|
+
<MessageResponse>{"```js\nconst streamdownThemeMarker = 1;\n```"}</MessageResponse>,
|
|
143
|
+
);
|
|
144
|
+
|
|
145
|
+
await findHighlightedKeywordSpan(container, "const");
|
|
146
|
+
|
|
147
|
+
document.documentElement.setAttribute("data-theme", "dark");
|
|
148
|
+
document.documentElement.style.setProperty("--code-keyword", "oklch(0.92 0.02 240)");
|
|
149
|
+
|
|
150
|
+
await waitFor(() => {
|
|
151
|
+
const span = [...container.querySelectorAll("pre code span")].find(
|
|
152
|
+
(el) => el.textContent === "const",
|
|
153
|
+
);
|
|
154
|
+
const style = (span?.getAttribute("style") ?? "").toLowerCase();
|
|
155
|
+
// Must differ from the light color above (case-insensitive — Shiki's
|
|
156
|
+
// own re-serialization may differ in hex letter case from ours), proving
|
|
157
|
+
// the plugin re-derived rather than staying frozen on whichever theme was
|
|
158
|
+
// active at first mount.
|
|
159
|
+
expect(style).not.toContain("--sdm-c: #133796");
|
|
160
|
+
expect(style).toMatch(/--sdm-c: #[0-9a-f]{6}/);
|
|
161
|
+
}, HIGHLIGHT_TIMEOUT);
|
|
162
|
+
});
|
|
163
|
+
});
|