@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,408 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { StatusBadge, StatusIcon, type Status } from "@elabs-ai/components-ui";
|
|
4
|
+
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@elabs-ai/components-ui";
|
|
5
|
+
import { cn } from "@elabs-ai/components-ui/lib/cn";
|
|
6
|
+
import { ChevronRightIcon } from "lucide-react";
|
|
7
|
+
import type { ComponentProps, HTMLAttributes } from "react";
|
|
8
|
+
import { createContext, useContext, useMemo } from "react";
|
|
9
|
+
|
|
10
|
+
type TestStatus = "passed" | "failed" | "skipped" | "running";
|
|
11
|
+
|
|
12
|
+
/** Test status → the canonical 7-state vocabulary (#189, research 10 §B.1). */
|
|
13
|
+
const testStatusToCanonical: Record<TestStatus, Status> = {
|
|
14
|
+
passed: "complete",
|
|
15
|
+
failed: "failed",
|
|
16
|
+
running: "running",
|
|
17
|
+
skipped: "skipped",
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
interface TestResultsSummary {
|
|
21
|
+
passed: number;
|
|
22
|
+
failed: number;
|
|
23
|
+
skipped: number;
|
|
24
|
+
total: number;
|
|
25
|
+
duration?: number;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
interface TestResultsContextType {
|
|
29
|
+
summary?: TestResultsSummary;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const TestResultsContext = createContext<TestResultsContextType>({});
|
|
33
|
+
|
|
34
|
+
const formatDuration = (ms: number) => {
|
|
35
|
+
if (ms < 1000) {
|
|
36
|
+
return `${ms}ms`;
|
|
37
|
+
}
|
|
38
|
+
return `${(ms / 1000).toFixed(2)}s`;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
export type TestResultsHeaderProps = HTMLAttributes<HTMLDivElement>;
|
|
42
|
+
|
|
43
|
+
export const TestResultsHeader = ({ className, children, ...props }: TestResultsHeaderProps) => (
|
|
44
|
+
<div
|
|
45
|
+
className={cn("flex min-w-0 items-center justify-between border-b px-4 py-3", className)}
|
|
46
|
+
{...props}
|
|
47
|
+
>
|
|
48
|
+
{children}
|
|
49
|
+
</div>
|
|
50
|
+
);
|
|
51
|
+
|
|
52
|
+
export type TestResultsDurationProps = HTMLAttributes<HTMLSpanElement>;
|
|
53
|
+
|
|
54
|
+
export const TestResultsDuration = ({
|
|
55
|
+
className,
|
|
56
|
+
children,
|
|
57
|
+
...props
|
|
58
|
+
}: TestResultsDurationProps) => {
|
|
59
|
+
const { summary } = useContext(TestResultsContext);
|
|
60
|
+
|
|
61
|
+
if (!summary?.duration) {
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return (
|
|
66
|
+
<span
|
|
67
|
+
className={cn("shrink-0 text-muted-foreground text-sm tabular-nums", className)}
|
|
68
|
+
{...props}
|
|
69
|
+
>
|
|
70
|
+
{children ?? formatDuration(summary.duration)}
|
|
71
|
+
</span>
|
|
72
|
+
);
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
export type TestResultsSummaryProps = HTMLAttributes<HTMLDivElement>;
|
|
76
|
+
|
|
77
|
+
export const TestResultsSummary = ({ className, children, ...props }: TestResultsSummaryProps) => {
|
|
78
|
+
const { summary } = useContext(TestResultsContext);
|
|
79
|
+
|
|
80
|
+
if (!summary) {
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return (
|
|
85
|
+
<div className={cn("flex items-center gap-2", className)} {...props}>
|
|
86
|
+
{children ?? (
|
|
87
|
+
<>
|
|
88
|
+
<StatusBadge size="sm" status="complete">
|
|
89
|
+
{summary.passed} passed
|
|
90
|
+
</StatusBadge>
|
|
91
|
+
{summary.failed > 0 && (
|
|
92
|
+
<StatusBadge size="sm" status="failed">
|
|
93
|
+
{summary.failed} failed
|
|
94
|
+
</StatusBadge>
|
|
95
|
+
)}
|
|
96
|
+
{summary.skipped > 0 && (
|
|
97
|
+
<StatusBadge size="sm" status="skipped">
|
|
98
|
+
{summary.skipped} skipped
|
|
99
|
+
</StatusBadge>
|
|
100
|
+
)}
|
|
101
|
+
</>
|
|
102
|
+
)}
|
|
103
|
+
</div>
|
|
104
|
+
);
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
export type TestResultsProps = HTMLAttributes<HTMLDivElement> & {
|
|
108
|
+
summary?: TestResultsSummary;
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
export const TestResults = ({ summary, className, children, ...props }: TestResultsProps) => {
|
|
112
|
+
const contextValue = useMemo(() => ({ summary }), [summary]);
|
|
113
|
+
|
|
114
|
+
return (
|
|
115
|
+
<TestResultsContext.Provider value={contextValue}>
|
|
116
|
+
<div className={cn("overflow-hidden rounded-lg border bg-background", className)} {...props}>
|
|
117
|
+
{children ??
|
|
118
|
+
(summary && (
|
|
119
|
+
<TestResultsHeader>
|
|
120
|
+
<TestResultsSummary />
|
|
121
|
+
<TestResultsDuration />
|
|
122
|
+
</TestResultsHeader>
|
|
123
|
+
))}
|
|
124
|
+
</div>
|
|
125
|
+
</TestResultsContext.Provider>
|
|
126
|
+
);
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
export type TestResultsProgressProps = HTMLAttributes<HTMLDivElement>;
|
|
130
|
+
|
|
131
|
+
export const TestResultsProgress = ({
|
|
132
|
+
className,
|
|
133
|
+
children,
|
|
134
|
+
...props
|
|
135
|
+
}: TestResultsProgressProps) => {
|
|
136
|
+
const { summary } = useContext(TestResultsContext);
|
|
137
|
+
|
|
138
|
+
if (!summary) {
|
|
139
|
+
return null;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const passedPercent = (summary.passed / summary.total) * 100;
|
|
143
|
+
const failedPercent = (summary.failed / summary.total) * 100;
|
|
144
|
+
|
|
145
|
+
return (
|
|
146
|
+
<div className={cn("space-y-1.5", className)} {...props}>
|
|
147
|
+
{children ?? (
|
|
148
|
+
<>
|
|
149
|
+
<div className="flex h-1.5 overflow-hidden rounded-full bg-muted">
|
|
150
|
+
<div
|
|
151
|
+
className="bg-success transition-[width] duration-base ease-standard motion-reduce:transition-none"
|
|
152
|
+
style={{ width: `${passedPercent}%` }}
|
|
153
|
+
/>
|
|
154
|
+
<div
|
|
155
|
+
className="bg-destructive transition-[width] duration-base ease-standard motion-reduce:transition-none"
|
|
156
|
+
style={{ width: `${failedPercent}%` }}
|
|
157
|
+
/>
|
|
158
|
+
</div>
|
|
159
|
+
<div className="flex justify-between text-muted-foreground text-xs tabular-nums">
|
|
160
|
+
<span>
|
|
161
|
+
{summary.passed}/{summary.total} tests passed
|
|
162
|
+
</span>
|
|
163
|
+
<span>{passedPercent.toFixed(0)}%</span>
|
|
164
|
+
</div>
|
|
165
|
+
</>
|
|
166
|
+
)}
|
|
167
|
+
</div>
|
|
168
|
+
);
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* TestResultsMeta — the summary badges + progress bar block that sits between
|
|
173
|
+
* the header and the suite list. Provides consistent `px-4 pt-3 pb-4` padding
|
|
174
|
+
* so callers do not need ad-hoc wrapper divs.
|
|
175
|
+
*/
|
|
176
|
+
export type TestResultsMetaProps = HTMLAttributes<HTMLDivElement>;
|
|
177
|
+
|
|
178
|
+
export const TestResultsMeta = ({ className, children, ...props }: TestResultsMetaProps) => (
|
|
179
|
+
<div className={cn("space-y-3 px-4 pb-4 pt-3", className)} {...props}>
|
|
180
|
+
{children}
|
|
181
|
+
</div>
|
|
182
|
+
);
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* TestResultsContent — the suite list. Uses `divide-y` instead of `space-y`
|
|
186
|
+
* so suites are separated by a single structural divider, not nested boxes.
|
|
187
|
+
*/
|
|
188
|
+
export type TestResultsContentProps = HTMLAttributes<HTMLDivElement>;
|
|
189
|
+
|
|
190
|
+
export const TestResultsContent = ({ className, children, ...props }: TestResultsContentProps) => (
|
|
191
|
+
<div className={cn("divide-y", className)} {...props}>
|
|
192
|
+
{children}
|
|
193
|
+
</div>
|
|
194
|
+
);
|
|
195
|
+
|
|
196
|
+
interface TestSuiteContextType {
|
|
197
|
+
name: string;
|
|
198
|
+
status: TestStatus;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const TestSuiteContext = createContext<TestSuiteContextType>({
|
|
202
|
+
name: "",
|
|
203
|
+
status: "passed",
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
// The local status icon/color records were the 5th status idiom (#189);
|
|
207
|
+
// re-pointed to the canonical `StatusIcon` vocabulary from @elabs-ai/components-ui.
|
|
208
|
+
const TestStatusIcon = ({ status }: { status: TestStatus }) => (
|
|
209
|
+
<StatusIcon className="shrink-0" status={testStatusToCanonical[status]} />
|
|
210
|
+
);
|
|
211
|
+
|
|
212
|
+
export type TestSuiteProps = ComponentProps<typeof Collapsible> & {
|
|
213
|
+
name: string;
|
|
214
|
+
status: TestStatus;
|
|
215
|
+
};
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* TestSuite — one expandable file/suite row. Uses a quiet left rail
|
|
219
|
+
* (`border-s-2`) as the sole focal separation gesture inside the divider-
|
|
220
|
+
* separated list. No nested rounded box: that was redundant border-on-region
|
|
221
|
+
* with no fill change (separation grammar).
|
|
222
|
+
*/
|
|
223
|
+
export const TestSuite = ({ name, status, className, children, ...props }: TestSuiteProps) => {
|
|
224
|
+
const contextValue = useMemo(() => ({ name, status }), [name, status]);
|
|
225
|
+
|
|
226
|
+
return (
|
|
227
|
+
<TestSuiteContext.Provider value={contextValue}>
|
|
228
|
+
<Collapsible className={cn("group/suite", className)} {...props}>
|
|
229
|
+
{children}
|
|
230
|
+
</Collapsible>
|
|
231
|
+
</TestSuiteContext.Provider>
|
|
232
|
+
);
|
|
233
|
+
};
|
|
234
|
+
|
|
235
|
+
export type TestSuiteNameProps = ComponentProps<typeof CollapsibleTrigger>;
|
|
236
|
+
|
|
237
|
+
export const TestSuiteName = ({ className, children, ...props }: TestSuiteNameProps) => {
|
|
238
|
+
const { name, status } = useContext(TestSuiteContext);
|
|
239
|
+
|
|
240
|
+
return (
|
|
241
|
+
<CollapsibleTrigger
|
|
242
|
+
className={cn(
|
|
243
|
+
"group flex w-full items-center gap-2 px-4 py-2.5 text-start transition-colors duration-fast ease-standard motion-reduce:transition-none hover:bg-muted/50",
|
|
244
|
+
className,
|
|
245
|
+
)}
|
|
246
|
+
{...props}
|
|
247
|
+
>
|
|
248
|
+
<ChevronRightIcon className="size-4 shrink-0 text-muted-foreground transition-transform duration-fast ease-standard motion-reduce:transition-none group-data-[state=open]:rotate-90" />
|
|
249
|
+
<TestStatusIcon status={status} />
|
|
250
|
+
<span className="min-w-0 flex-1 truncate font-medium text-sm">{children ?? name}</span>
|
|
251
|
+
</CollapsibleTrigger>
|
|
252
|
+
);
|
|
253
|
+
};
|
|
254
|
+
|
|
255
|
+
export type TestSuiteStatsProps = HTMLAttributes<HTMLDivElement> & {
|
|
256
|
+
passed?: number;
|
|
257
|
+
failed?: number;
|
|
258
|
+
skipped?: number;
|
|
259
|
+
};
|
|
260
|
+
|
|
261
|
+
export const TestSuiteStats = ({
|
|
262
|
+
passed = 0,
|
|
263
|
+
failed = 0,
|
|
264
|
+
skipped = 0,
|
|
265
|
+
className,
|
|
266
|
+
children,
|
|
267
|
+
...props
|
|
268
|
+
}: TestSuiteStatsProps) => (
|
|
269
|
+
<div
|
|
270
|
+
className={cn("ms-auto flex shrink-0 items-center gap-3 text-xs tabular-nums", className)}
|
|
271
|
+
{...props}
|
|
272
|
+
>
|
|
273
|
+
{children ?? (
|
|
274
|
+
<>
|
|
275
|
+
{passed > 0 && <span className="text-success-text">{passed} passed</span>}
|
|
276
|
+
{failed > 0 && <span className="text-destructive-text">{failed} failed</span>}
|
|
277
|
+
{skipped > 0 && <span className="text-muted-foreground">{skipped} skipped</span>}
|
|
278
|
+
</>
|
|
279
|
+
)}
|
|
280
|
+
</div>
|
|
281
|
+
);
|
|
282
|
+
|
|
283
|
+
export type TestSuiteContentProps = ComponentProps<typeof CollapsibleContent>;
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* TestSuiteContent — the rows inside an expanded suite. A left rail
|
|
287
|
+
* (`border-s-2 border-s-border ms-6`) indents the block under the suite
|
|
288
|
+
* trigger and separates it structurally without a box. Rows are divided by
|
|
289
|
+
* `divide-y`.
|
|
290
|
+
*/
|
|
291
|
+
export const TestSuiteContent = ({ className, children, ...props }: TestSuiteContentProps) => (
|
|
292
|
+
<CollapsibleContent className={cn("border-t", className)} {...props}>
|
|
293
|
+
<div className="divide-y">{children}</div>
|
|
294
|
+
</CollapsibleContent>
|
|
295
|
+
);
|
|
296
|
+
|
|
297
|
+
interface TestContextType {
|
|
298
|
+
name: string;
|
|
299
|
+
status: TestStatus;
|
|
300
|
+
duration?: number;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
const TestContext = createContext<TestContextType>({
|
|
304
|
+
name: "",
|
|
305
|
+
status: "passed",
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
export type TestNameProps = HTMLAttributes<HTMLSpanElement>;
|
|
309
|
+
|
|
310
|
+
export const TestName = ({ className, children, ...props }: TestNameProps) => {
|
|
311
|
+
const { name } = useContext(TestContext);
|
|
312
|
+
|
|
313
|
+
return (
|
|
314
|
+
<span className={cn("min-w-0 flex-1 truncate", className)} {...props}>
|
|
315
|
+
{children ?? name}
|
|
316
|
+
</span>
|
|
317
|
+
);
|
|
318
|
+
};
|
|
319
|
+
|
|
320
|
+
export type TestDurationProps = HTMLAttributes<HTMLSpanElement>;
|
|
321
|
+
|
|
322
|
+
export const TestDuration = ({ className, children, ...props }: TestDurationProps) => {
|
|
323
|
+
const { duration } = useContext(TestContext);
|
|
324
|
+
|
|
325
|
+
if (duration === undefined) {
|
|
326
|
+
return null;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
return (
|
|
330
|
+
<span
|
|
331
|
+
className={cn("shrink-0 text-muted-foreground text-xs tabular-nums", className)}
|
|
332
|
+
{...props}
|
|
333
|
+
>
|
|
334
|
+
{children ?? `${duration}ms`}
|
|
335
|
+
</span>
|
|
336
|
+
);
|
|
337
|
+
};
|
|
338
|
+
|
|
339
|
+
export type TestStatusProps = HTMLAttributes<HTMLSpanElement>;
|
|
340
|
+
|
|
341
|
+
export const TestStatus = ({ className, children, ...props }: TestStatusProps) => {
|
|
342
|
+
const { status } = useContext(TestContext);
|
|
343
|
+
|
|
344
|
+
return (
|
|
345
|
+
<span className={cn("shrink-0", className)} {...props}>
|
|
346
|
+
{children ?? <StatusIcon status={testStatusToCanonical[status]} />}
|
|
347
|
+
</span>
|
|
348
|
+
);
|
|
349
|
+
};
|
|
350
|
+
|
|
351
|
+
export type TestProps = HTMLAttributes<HTMLDivElement> & {
|
|
352
|
+
name: string;
|
|
353
|
+
status: TestStatus;
|
|
354
|
+
duration?: number;
|
|
355
|
+
};
|
|
356
|
+
|
|
357
|
+
/**
|
|
358
|
+
* Test — a single test-case row. `ps-10` aligns the status icon with the
|
|
359
|
+
* suite's status icon (after the trigger's chevron + gap), giving a clean
|
|
360
|
+
* left-column across the expansion.
|
|
361
|
+
*/
|
|
362
|
+
export const Test = ({ name, status, duration, className, children, ...props }: TestProps) => {
|
|
363
|
+
const contextValue = useMemo(() => ({ duration, name, status }), [duration, name, status]);
|
|
364
|
+
|
|
365
|
+
return (
|
|
366
|
+
<TestContext.Provider value={contextValue}>
|
|
367
|
+
<div
|
|
368
|
+
className={cn("flex min-w-0 items-center gap-2 ps-10 pe-4 py-2 text-sm", className)}
|
|
369
|
+
{...props}
|
|
370
|
+
>
|
|
371
|
+
{children ?? (
|
|
372
|
+
<>
|
|
373
|
+
<TestStatus />
|
|
374
|
+
<TestName />
|
|
375
|
+
{duration !== undefined && <TestDuration />}
|
|
376
|
+
</>
|
|
377
|
+
)}
|
|
378
|
+
</div>
|
|
379
|
+
</TestContext.Provider>
|
|
380
|
+
);
|
|
381
|
+
};
|
|
382
|
+
|
|
383
|
+
export type TestErrorProps = HTMLAttributes<HTMLDivElement>;
|
|
384
|
+
|
|
385
|
+
export const TestError = ({ className, children, ...props }: TestErrorProps) => (
|
|
386
|
+
<div className={cn("rounded-md bg-destructive/10 p-3", className)} {...props}>
|
|
387
|
+
{children}
|
|
388
|
+
</div>
|
|
389
|
+
);
|
|
390
|
+
|
|
391
|
+
export type TestErrorMessageProps = HTMLAttributes<HTMLParagraphElement>;
|
|
392
|
+
|
|
393
|
+
export const TestErrorMessage = ({ className, children, ...props }: TestErrorMessageProps) => (
|
|
394
|
+
<p className={cn("font-medium text-destructive-text text-sm", className)} {...props}>
|
|
395
|
+
{children}
|
|
396
|
+
</p>
|
|
397
|
+
);
|
|
398
|
+
|
|
399
|
+
export type TestErrorStackProps = HTMLAttributes<HTMLPreElement>;
|
|
400
|
+
|
|
401
|
+
export const TestErrorStack = ({ className, children, ...props }: TestErrorStackProps) => (
|
|
402
|
+
<pre
|
|
403
|
+
className={cn("mt-2 overflow-auto font-mono text-destructive-text text-xs", className)}
|
|
404
|
+
{...props}
|
|
405
|
+
>
|
|
406
|
+
{children}
|
|
407
|
+
</pre>
|
|
408
|
+
);
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from "@storybook/react-vite";
|
|
2
|
+
import { Maximize2 } from "lucide-react";
|
|
3
|
+
import { Button, Dialog, DialogTrigger, ExpandDialog } from "@elabs-ai/components-ui";
|
|
4
|
+
import { MessageTable } from "./message-table";
|
|
5
|
+
import type { TableSpec } from "./message-table-spec";
|
|
6
|
+
import { ToolDetails, ToolInput } from "./tool";
|
|
7
|
+
import { ToolResultCard } from "./tool-result-card";
|
|
8
|
+
|
|
9
|
+
const meta = {
|
|
10
|
+
title: "AI/ToolResultCard",
|
|
11
|
+
component: ToolResultCard,
|
|
12
|
+
parameters: { layout: "padded" },
|
|
13
|
+
tags: ["autodocs"],
|
|
14
|
+
} satisfies Meta<typeof ToolResultCard>;
|
|
15
|
+
export default meta;
|
|
16
|
+
type Story = StoryObj<typeof meta>;
|
|
17
|
+
|
|
18
|
+
// The elevation channel (#192, research 10 §B.4): the produced artifact IS
|
|
19
|
+
// the headline. The host is presentational — pass any node (an AutoChart,
|
|
20
|
+
// DataTable, file preview, …) as children; here a plain token-styled table.
|
|
21
|
+
export const Default: Story = {
|
|
22
|
+
render: () => (
|
|
23
|
+
<ToolResultCard
|
|
24
|
+
className="max-w-prose"
|
|
25
|
+
title="Revenue by region — Q3 vs Q2"
|
|
26
|
+
summary="Total $48.2M · +12.4% QoQ"
|
|
27
|
+
status="complete"
|
|
28
|
+
details={
|
|
29
|
+
<ToolDetails>
|
|
30
|
+
<ToolInput input={{ chart: "bar", x: "region", series: ["q2", "q3"] }} />
|
|
31
|
+
</ToolDetails>
|
|
32
|
+
}
|
|
33
|
+
>
|
|
34
|
+
<table className="w-full text-body tabular-nums">
|
|
35
|
+
<thead>
|
|
36
|
+
<tr className="border-b border-border-strong text-start text-meta text-muted-foreground">
|
|
37
|
+
<th className="py-1.5 text-start">Region</th>
|
|
38
|
+
<th className="py-1.5 text-end">Q2</th>
|
|
39
|
+
<th className="py-1.5 text-end">Q3</th>
|
|
40
|
+
</tr>
|
|
41
|
+
</thead>
|
|
42
|
+
<tbody>
|
|
43
|
+
<tr>
|
|
44
|
+
<td className="py-1.5">EMEA</td>
|
|
45
|
+
<td className="py-1.5 text-end">$16.1M</td>
|
|
46
|
+
<td className="py-1.5 text-end">$18.4M</td>
|
|
47
|
+
</tr>
|
|
48
|
+
<tr>
|
|
49
|
+
<td className="py-1.5">Americas</td>
|
|
50
|
+
<td className="py-1.5 text-end">$19.6M</td>
|
|
51
|
+
<td className="py-1.5 text-end">$21.2M</td>
|
|
52
|
+
</tr>
|
|
53
|
+
<tr>
|
|
54
|
+
<td className="py-1.5">APAC</td>
|
|
55
|
+
<td className="py-1.5 text-end">$7.2M</td>
|
|
56
|
+
<td className="py-1.5 text-end">$8.6M</td>
|
|
57
|
+
</tr>
|
|
58
|
+
</tbody>
|
|
59
|
+
</table>
|
|
60
|
+
</ToolResultCard>
|
|
61
|
+
),
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
const invoicesSpec: TableSpec = {
|
|
65
|
+
columns: [
|
|
66
|
+
{ key: "id", label: "Invoice" },
|
|
67
|
+
{ key: "customer", label: "Customer" },
|
|
68
|
+
{ key: "amount", label: "Amount", format: "currency" },
|
|
69
|
+
{ key: "margin", label: "Margin", format: "percent" },
|
|
70
|
+
{ key: "status", label: "Status", format: "badge" },
|
|
71
|
+
],
|
|
72
|
+
rows: [
|
|
73
|
+
{ id: "INV-1042", customer: "Acme", amount: 12400, margin: 0.32, status: "Paid" },
|
|
74
|
+
{ id: "INV-1043", customer: "Globex", amount: 8800, margin: 0.21, status: "Pending" },
|
|
75
|
+
{ id: "INV-1044", customer: "Initech", amount: 21500, margin: 0.44, status: "Overdue" },
|
|
76
|
+
{ id: "INV-1045", customer: "Umbrella", amount: 3600, margin: 0.12, status: "Draft" },
|
|
77
|
+
],
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* The whole point of the `actions` slot, and of `ExpandDialog` living in
|
|
82
|
+
* `@elabs-ai/components-ui`: a produced table expands through the
|
|
83
|
+
* SAME surface a produced chart does, so "make this bigger" means one thing
|
|
84
|
+
* across the whole chat. Never hand-roll a lightbox or an in-place breakout
|
|
85
|
+
* here — that is how a chat ends up teaching four different meanings for one
|
|
86
|
+
* button.
|
|
87
|
+
*
|
|
88
|
+
* Note what the context pane holds: the table's SHAPE (row count, columns and
|
|
89
|
+
* their declared formats), not min/max/avg. A model-emitted table's numerics
|
|
90
|
+
* may already be formatted strings, so statistics over them are wrong more
|
|
91
|
+
* often than right — a chart's `DefaultDetail` can compute them, this cannot.
|
|
92
|
+
*/
|
|
93
|
+
export const ExpandableTable: Story = {
|
|
94
|
+
render: () => (
|
|
95
|
+
<Dialog>
|
|
96
|
+
<ToolResultCard
|
|
97
|
+
className="max-w-prose"
|
|
98
|
+
title="Recent invoices"
|
|
99
|
+
summary="4 rows · billed in USD"
|
|
100
|
+
status="complete"
|
|
101
|
+
actions={
|
|
102
|
+
<DialogTrigger asChild>
|
|
103
|
+
<Button aria-label="Expand table" size="icon-sm" variant="ghost">
|
|
104
|
+
<Maximize2 aria-hidden="true" className="size-4" />
|
|
105
|
+
</Button>
|
|
106
|
+
</DialogTrigger>
|
|
107
|
+
}
|
|
108
|
+
>
|
|
109
|
+
<MessageTable spec={invoicesSpec} />
|
|
110
|
+
</ToolResultCard>
|
|
111
|
+
<ExpandDialog
|
|
112
|
+
title="Recent invoices"
|
|
113
|
+
detail={<TableShape spec={invoicesSpec} />}
|
|
114
|
+
detailLabel="Table shape"
|
|
115
|
+
>
|
|
116
|
+
<MessageTable spec={invoicesSpec} sortable />
|
|
117
|
+
</ExpandDialog>
|
|
118
|
+
</Dialog>
|
|
119
|
+
),
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
/** The context pane for a table: what it IS, not what it averages. */
|
|
123
|
+
function TableShape({ spec }: { spec: TableSpec }) {
|
|
124
|
+
return (
|
|
125
|
+
<div className="flex h-full flex-col gap-4 p-4">
|
|
126
|
+
<h3 className="text-subtitle text-card-foreground">Shape</h3>
|
|
127
|
+
<dl className="grid grid-cols-[auto_1fr] gap-x-4 gap-y-1 text-meta">
|
|
128
|
+
<dt className="text-muted-foreground">Rows</dt>
|
|
129
|
+
<dd className="tabular-nums">{spec.rows.length}</dd>
|
|
130
|
+
<dt className="text-muted-foreground">Columns</dt>
|
|
131
|
+
<dd className="tabular-nums">{spec.columns.length}</dd>
|
|
132
|
+
</dl>
|
|
133
|
+
<div className="flex flex-col gap-1">
|
|
134
|
+
{spec.columns.map((column) => (
|
|
135
|
+
<div key={column.key} className="flex items-baseline justify-between gap-3 text-meta">
|
|
136
|
+
<span className="truncate">{column.label ?? column.key}</span>
|
|
137
|
+
<span className="shrink-0 text-muted-foreground">{column.format ?? "text"}</span>
|
|
138
|
+
</div>
|
|
139
|
+
))}
|
|
140
|
+
</div>
|
|
141
|
+
</div>
|
|
142
|
+
);
|
|
143
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { render, screen } from "@testing-library/react";
|
|
3
|
+
import { ToolResultCard } from "./tool-result-card";
|
|
4
|
+
|
|
5
|
+
describe("ToolResultCard (#192, research 10 §B.4)", () => {
|
|
6
|
+
it("hosts the artifact as children on the elevation channel (fill + shadow, no border)", () => {
|
|
7
|
+
render(
|
|
8
|
+
<ToolResultCard
|
|
9
|
+
data-testid="card"
|
|
10
|
+
title="Revenue by region — Q3 vs Q2"
|
|
11
|
+
summary="Total $48.2M · +12.4% QoQ"
|
|
12
|
+
status="complete"
|
|
13
|
+
>
|
|
14
|
+
<div data-testid="artifact">chart</div>
|
|
15
|
+
</ToolResultCard>,
|
|
16
|
+
);
|
|
17
|
+
const card = screen.getByTestId("card");
|
|
18
|
+
expect(card.className).toContain("bg-surface-elevated");
|
|
19
|
+
expect(card.className).toContain("shadow-sm");
|
|
20
|
+
expect(card.className.split(" ")).not.toContain("border");
|
|
21
|
+
expect(screen.getByTestId("artifact")).toBeInTheDocument();
|
|
22
|
+
expect(screen.getByText("Revenue by region — Q3 vs Q2")).toBeInTheDocument();
|
|
23
|
+
expect(screen.getByText("Total $48.2M · +12.4% QoQ")).toBeInTheDocument();
|
|
24
|
+
expect(screen.getByText("Complete")).toBeInTheDocument(); // StatusBadge
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("renders the details slot, merges className and spreads props", () => {
|
|
28
|
+
render(
|
|
29
|
+
<ToolResultCard
|
|
30
|
+
data-testid="card"
|
|
31
|
+
className="custom-card"
|
|
32
|
+
aria-label="result"
|
|
33
|
+
details={<div data-testid="details">technical details</div>}
|
|
34
|
+
>
|
|
35
|
+
body
|
|
36
|
+
</ToolResultCard>,
|
|
37
|
+
);
|
|
38
|
+
const card = screen.getByTestId("card");
|
|
39
|
+
expect(card).toHaveClass("custom-card");
|
|
40
|
+
expect(card).toHaveAttribute("aria-label", "result");
|
|
41
|
+
expect(screen.getByTestId("details")).toBeInTheDocument();
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
/*
|
|
45
|
+
* The artifact's own chrome shares the title row — it never gets a second
|
|
46
|
+
* strip above the heading. A hover-revealed toolbar row costs a whole line of
|
|
47
|
+
* vertical space in a chat transcript and hides the controls until you find
|
|
48
|
+
* them.
|
|
49
|
+
*/
|
|
50
|
+
it("puts artifact actions on the title row, between the title and the status", () => {
|
|
51
|
+
render(
|
|
52
|
+
<ToolResultCard
|
|
53
|
+
title="Recent invoices"
|
|
54
|
+
status="complete"
|
|
55
|
+
actions={<button type="button">Expand</button>}
|
|
56
|
+
>
|
|
57
|
+
body
|
|
58
|
+
</ToolResultCard>,
|
|
59
|
+
);
|
|
60
|
+
const row = screen.getByRole("heading", { name: "Recent invoices" }).parentElement!
|
|
61
|
+
.parentElement!;
|
|
62
|
+
const text = row.textContent ?? "";
|
|
63
|
+
expect(text.indexOf("Recent invoices")).toBeLessThan(text.indexOf("Expand"));
|
|
64
|
+
expect(text.indexOf("Expand")).toBeLessThan(text.indexOf("Complete"));
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
/* An actions-only card still gets its header — `hasHeader` must see the slot. */
|
|
68
|
+
it("renders the header for an actions-only card", () => {
|
|
69
|
+
render(<ToolResultCard actions={<button type="button">Download</button>}>body</ToolResultCard>);
|
|
70
|
+
expect(screen.getByRole("button", { name: "Download" })).toBeInTheDocument();
|
|
71
|
+
});
|
|
72
|
+
});
|