@elabs-ai/components-ai 4.1.0 → 4.2.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/README.md +2 -2
- package/dist/index.d.ts +43 -16
- package/dist/index.js +1297 -926
- package/dist/index.js.map +1 -1
- package/package.json +9 -9
- package/src/__contract__/audio-visualizer.contract.test.tsx +49 -0
- package/src/__contract__/chat-shell.contract.test.tsx +49 -0
- package/src/__contract__/grouped-parts.contract.test.tsx +49 -0
- package/src/__contract__/image.contract.test.tsx +49 -0
- package/src/__contract__/markdown-view.contract.test.tsx +49 -0
- package/src/__contract__/message-feedback.contract.test.tsx +49 -0
- package/src/__contract__/message-form.contract.test.tsx +49 -0
- package/src/__contract__/message-table.contract.test.tsx +49 -0
- package/src/__contract__/model-provider-logo.contract.test.tsx +49 -0
- package/src/__contract__/persona.contract.test.tsx +49 -0
- package/src/__contract__/prompt-input-effort.contract.test.tsx +49 -0
- package/src/__contract__/prompt-input-mode.contract.test.tsx +49 -0
- package/src/_chat-shell-rail.tsx +2 -2
- package/src/_lazy-cjk.test.ts +43 -0
- package/src/_lazy-cjk.ts +73 -0
- package/src/_lazy-math.test.ts +63 -0
- package/src/_lazy-math.ts +90 -0
- package/src/_streamdown-i18n.ts +73 -21
- package/src/_streamdown-safety.ts +2 -2
- package/src/_theme-scope-store.test.ts +83 -0
- package/src/_theme-scope-store.ts +103 -0
- package/src/agent-event.tsx +1 -1
- package/src/agent.tsx +18 -13
- package/src/artifact.tsx +2 -2
- package/src/asset-preview.test.tsx +40 -0
- package/src/asset-preview.tsx +83 -12
- package/src/attachments.tsx +7 -4
- package/src/code-block.test.tsx +100 -1
- package/src/code-block.tsx +166 -103
- package/src/commit.tsx +30 -41
- package/src/confirmation.tsx +1 -1
- package/src/context-panel.tsx +1 -1
- package/src/conversation.stories.tsx +23 -0
- package/src/conversation.test.tsx +53 -0
- package/src/conversation.tsx +32 -7
- package/src/diff-view.test.tsx +52 -2
- package/src/diff-view.tsx +89 -34
- package/src/environment-variables.tsx +20 -37
- package/src/file-tree.test.tsx +21 -0
- package/src/file-tree.tsx +12 -2
- package/src/inline-citation.tsx +5 -5
- package/src/jsx-preview.tsx +151 -43
- package/src/markdown-view.test.tsx +7 -3
- package/src/markdown-view.tsx +8 -1
- package/src/message-form.stories.tsx +36 -3
- package/src/message-form.test.tsx +8 -2
- package/src/message-form.tsx +15 -7
- package/src/message-table.stories.tsx +2 -2
- package/src/message-table.test.tsx +7 -0
- package/src/message-table.tsx +8 -4
- package/src/message.test.tsx +73 -3
- package/src/message.tsx +36 -12
- package/src/model-provider-logo.test.tsx +57 -3
- package/src/model-provider-logo.tsx +45 -11
- package/src/open-in-chat.tsx +50 -29
- package/src/package-info.tsx +12 -12
- package/src/prompt-input-slash.stories.tsx +1 -1
- package/src/prompt-input.test.tsx +67 -1
- package/src/prompt-input.tsx +42 -4
- package/src/queue.tsx +4 -4
- package/src/reasoning.tsx +17 -6
- package/src/sandbox.tsx +3 -3
- package/src/schema-display.test.tsx +56 -0
- package/src/schema-display.tsx +68 -37
- package/src/session-header.tsx +1 -1
- package/src/snippet.test.tsx +6 -2
- package/src/snippet.tsx +14 -34
- package/src/speech-input.test.tsx +109 -0
- package/src/speech-input.tsx +31 -3
- package/src/stack-trace.tsx +20 -36
- package/src/streamdown-i18n.test.tsx +23 -0
- package/src/test-results.tsx +47 -27
- package/src/token-usage.tsx +6 -6
- package/src/tool.test.tsx +65 -0
- package/src/tool.tsx +59 -23
- package/src/transcription.tsx +1 -1
- package/src/voice-selector.tsx +5 -5
- package/src/web-preview.test.tsx +51 -1
- package/src/web-preview.tsx +39 -8
package/src/test-results.tsx
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
-
import { StatusBadge, StatusIcon, type Status } from "@elabs-ai/components-ui";
|
|
3
|
+
import { StatusBadge, StatusIcon, useLocale, type Status } from "@elabs-ai/components-ui";
|
|
4
4
|
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@elabs-ai/components-ui";
|
|
5
5
|
import { cn } from "@elabs-ai/components-ui/lib/cn";
|
|
6
6
|
import { ChevronRightIcon } from "lucide-react";
|
|
@@ -64,7 +64,7 @@ export const TestResultsDuration = ({
|
|
|
64
64
|
|
|
65
65
|
return (
|
|
66
66
|
<span
|
|
67
|
-
className={cn("shrink-0 text-muted-foreground text-
|
|
67
|
+
className={cn("shrink-0 text-muted-foreground text-body tabular-nums", className)}
|
|
68
68
|
{...props}
|
|
69
69
|
>
|
|
70
70
|
{children ?? formatDuration(summary.duration)}
|
|
@@ -76,6 +76,7 @@ export type TestResultsSummaryProps = HTMLAttributes<HTMLDivElement>;
|
|
|
76
76
|
|
|
77
77
|
export const TestResultsSummary = ({ className, children, ...props }: TestResultsSummaryProps) => {
|
|
78
78
|
const { summary } = useContext(TestResultsContext);
|
|
79
|
+
const { t } = useLocale();
|
|
79
80
|
|
|
80
81
|
if (!summary) {
|
|
81
82
|
return null;
|
|
@@ -86,16 +87,16 @@ export const TestResultsSummary = ({ className, children, ...props }: TestResult
|
|
|
86
87
|
{children ?? (
|
|
87
88
|
<>
|
|
88
89
|
<StatusBadge size="sm" status="complete">
|
|
89
|
-
{summary.passed}
|
|
90
|
+
{t("ai.testResults.passedCount", { count: summary.passed })}
|
|
90
91
|
</StatusBadge>
|
|
91
92
|
{summary.failed > 0 && (
|
|
92
93
|
<StatusBadge size="sm" status="failed">
|
|
93
|
-
{summary.failed}
|
|
94
|
+
{t("ai.testResults.failedCount", { count: summary.failed })}
|
|
94
95
|
</StatusBadge>
|
|
95
96
|
)}
|
|
96
97
|
{summary.skipped > 0 && (
|
|
97
98
|
<StatusBadge size="sm" status="skipped">
|
|
98
|
-
{summary.skipped}
|
|
99
|
+
{t("ai.testResults.skippedCount", { count: summary.skipped })}
|
|
99
100
|
</StatusBadge>
|
|
100
101
|
)}
|
|
101
102
|
</>
|
|
@@ -134,6 +135,7 @@ export const TestResultsProgress = ({
|
|
|
134
135
|
...props
|
|
135
136
|
}: TestResultsProgressProps) => {
|
|
136
137
|
const { summary } = useContext(TestResultsContext);
|
|
138
|
+
const { t, formatNumber } = useLocale();
|
|
137
139
|
|
|
138
140
|
if (!summary) {
|
|
139
141
|
return null;
|
|
@@ -156,11 +158,14 @@ export const TestResultsProgress = ({
|
|
|
156
158
|
style={{ width: `${failedPercent}%` }}
|
|
157
159
|
/>
|
|
158
160
|
</div>
|
|
159
|
-
<div className="flex justify-between text-muted-foreground text-
|
|
161
|
+
<div className="flex justify-between text-muted-foreground text-meta tabular-nums">
|
|
160
162
|
<span>
|
|
161
|
-
{summary.passed
|
|
163
|
+
{t("ai.testResults.testsPassed", { passed: summary.passed, total: summary.total })}
|
|
164
|
+
</span>
|
|
165
|
+
<span>
|
|
166
|
+
{formatNumber(passedPercent, { maximumFractionDigits: 0 })}
|
|
167
|
+
{"%"}
|
|
162
168
|
</span>
|
|
163
|
-
<span>{passedPercent.toFixed(0)}%</span>
|
|
164
169
|
</div>
|
|
165
170
|
</>
|
|
166
171
|
)}
|
|
@@ -247,7 +252,7 @@ export const TestSuiteName = ({ className, children, ...props }: TestSuiteNamePr
|
|
|
247
252
|
>
|
|
248
253
|
<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
254
|
<TestStatusIcon status={status} />
|
|
250
|
-
<span className="min-w-0 flex-1 truncate font-medium text-
|
|
255
|
+
<span className="min-w-0 flex-1 truncate font-medium text-body">{children ?? name}</span>
|
|
251
256
|
</CollapsibleTrigger>
|
|
252
257
|
);
|
|
253
258
|
};
|
|
@@ -265,20 +270,35 @@ export const TestSuiteStats = ({
|
|
|
265
270
|
className,
|
|
266
271
|
children,
|
|
267
272
|
...props
|
|
268
|
-
}: TestSuiteStatsProps) =>
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
)
|
|
273
|
+
}: TestSuiteStatsProps) => {
|
|
274
|
+
const { t } = useLocale();
|
|
275
|
+
return (
|
|
276
|
+
<div
|
|
277
|
+
className={cn("ms-auto flex shrink-0 items-center gap-3 text-meta tabular-nums", className)}
|
|
278
|
+
{...props}
|
|
279
|
+
>
|
|
280
|
+
{children ?? (
|
|
281
|
+
<>
|
|
282
|
+
{passed > 0 && (
|
|
283
|
+
<span className="text-success-text">
|
|
284
|
+
{t("ai.testResults.passedCount", { count: passed })}
|
|
285
|
+
</span>
|
|
286
|
+
)}
|
|
287
|
+
{failed > 0 && (
|
|
288
|
+
<span className="text-destructive-text">
|
|
289
|
+
{t("ai.testResults.failedCount", { count: failed })}
|
|
290
|
+
</span>
|
|
291
|
+
)}
|
|
292
|
+
{skipped > 0 && (
|
|
293
|
+
<span className="text-muted-foreground">
|
|
294
|
+
{t("ai.testResults.skippedCount", { count: skipped })}
|
|
295
|
+
</span>
|
|
296
|
+
)}
|
|
297
|
+
</>
|
|
298
|
+
)}
|
|
299
|
+
</div>
|
|
300
|
+
);
|
|
301
|
+
};
|
|
282
302
|
|
|
283
303
|
export type TestSuiteContentProps = ComponentProps<typeof CollapsibleContent>;
|
|
284
304
|
|
|
@@ -328,7 +348,7 @@ export const TestDuration = ({ className, children, ...props }: TestDurationProp
|
|
|
328
348
|
|
|
329
349
|
return (
|
|
330
350
|
<span
|
|
331
|
-
className={cn("shrink-0 text-muted-foreground text-
|
|
351
|
+
className={cn("shrink-0 text-muted-foreground text-meta tabular-nums", className)}
|
|
332
352
|
{...props}
|
|
333
353
|
>
|
|
334
354
|
{children ?? `${duration}ms`}
|
|
@@ -365,7 +385,7 @@ export const Test = ({ name, status, duration, className, children, ...props }:
|
|
|
365
385
|
return (
|
|
366
386
|
<TestContext.Provider value={contextValue}>
|
|
367
387
|
<div
|
|
368
|
-
className={cn("flex min-w-0 items-center gap-2 ps-10 pe-4 py-2 text-
|
|
388
|
+
className={cn("flex min-w-0 items-center gap-2 ps-10 pe-4 py-2 text-body", className)}
|
|
369
389
|
{...props}
|
|
370
390
|
>
|
|
371
391
|
{children ?? (
|
|
@@ -391,7 +411,7 @@ export const TestError = ({ className, children, ...props }: TestErrorProps) =>
|
|
|
391
411
|
export type TestErrorMessageProps = HTMLAttributes<HTMLParagraphElement>;
|
|
392
412
|
|
|
393
413
|
export const TestErrorMessage = ({ className, children, ...props }: TestErrorMessageProps) => (
|
|
394
|
-
<p className={cn("font-medium text-destructive-text text-
|
|
414
|
+
<p className={cn("font-medium text-destructive-text text-body", className)} {...props}>
|
|
395
415
|
{children}
|
|
396
416
|
</p>
|
|
397
417
|
);
|
|
@@ -400,7 +420,7 @@ export type TestErrorStackProps = HTMLAttributes<HTMLPreElement>;
|
|
|
400
420
|
|
|
401
421
|
export const TestErrorStack = ({ className, children, ...props }: TestErrorStackProps) => (
|
|
402
422
|
<pre
|
|
403
|
-
className={cn("mt-2 overflow-auto font-mono text-destructive-text text-
|
|
423
|
+
className={cn("mt-2 overflow-auto font-mono text-destructive-text text-meta", className)}
|
|
404
424
|
{...props}
|
|
405
425
|
>
|
|
406
426
|
{children}
|
package/src/token-usage.tsx
CHANGED
|
@@ -154,7 +154,7 @@ export const TokenUsageContentHeader = ({
|
|
|
154
154
|
<div className={cn("w-full space-y-2 p-3", className)} {...props}>
|
|
155
155
|
{children ?? (
|
|
156
156
|
<>
|
|
157
|
-
<div className="flex items-center justify-between gap-3 text-
|
|
157
|
+
<div className="flex items-center justify-between gap-3 text-meta">
|
|
158
158
|
<p>{displayPct}</p>
|
|
159
159
|
<p className="font-mono text-muted-foreground">
|
|
160
160
|
{used} / {total}
|
|
@@ -207,7 +207,7 @@ export const TokenUsageContentFooter = ({
|
|
|
207
207
|
return (
|
|
208
208
|
<div
|
|
209
209
|
className={cn(
|
|
210
|
-
"flex w-full items-center justify-between gap-3 bg-secondary p-3 text-
|
|
210
|
+
"flex w-full items-center justify-between gap-3 bg-secondary p-3 text-meta",
|
|
211
211
|
className,
|
|
212
212
|
)}
|
|
213
213
|
{...props}
|
|
@@ -259,7 +259,7 @@ export const TokenUsageInput = ({ className, children, ...props }: TokenUsageInp
|
|
|
259
259
|
});
|
|
260
260
|
|
|
261
261
|
return (
|
|
262
|
-
<div className={cn("flex items-center justify-between text-
|
|
262
|
+
<div className={cn("flex items-center justify-between text-meta", className)} {...props}>
|
|
263
263
|
<span className="text-muted-foreground">{t("ai.tokenUsage.input")}</span>
|
|
264
264
|
<TokensWithCost costText={inputCostText} tokens={inputTokens} />
|
|
265
265
|
</div>
|
|
@@ -293,7 +293,7 @@ export const TokenUsageOutput = ({ className, children, ...props }: TokenUsageOu
|
|
|
293
293
|
});
|
|
294
294
|
|
|
295
295
|
return (
|
|
296
|
-
<div className={cn("flex items-center justify-between text-
|
|
296
|
+
<div className={cn("flex items-center justify-between text-meta", className)} {...props}>
|
|
297
297
|
<span className="text-muted-foreground">{t("ai.tokenUsage.output")}</span>
|
|
298
298
|
<TokensWithCost costText={outputCostText} tokens={outputTokens} />
|
|
299
299
|
</div>
|
|
@@ -333,7 +333,7 @@ export const TokenUsageReasoning = ({
|
|
|
333
333
|
});
|
|
334
334
|
|
|
335
335
|
return (
|
|
336
|
-
<div className={cn("flex items-center justify-between text-
|
|
336
|
+
<div className={cn("flex items-center justify-between text-meta", className)} {...props}>
|
|
337
337
|
<span className="text-muted-foreground">{t("ai.tokenUsage.reasoning")}</span>
|
|
338
338
|
<TokensWithCost costText={reasoningCostText} tokens={reasoningTokens} />
|
|
339
339
|
</div>
|
|
@@ -369,7 +369,7 @@ export const TokenUsageCache = ({ className, children, ...props }: TokenUsageCac
|
|
|
369
369
|
});
|
|
370
370
|
|
|
371
371
|
return (
|
|
372
|
-
<div className={cn("flex items-center justify-between text-
|
|
372
|
+
<div className={cn("flex items-center justify-between text-meta", className)} {...props}>
|
|
373
373
|
<span className="text-muted-foreground">{t("ai.tokenUsage.cache")}</span>
|
|
374
374
|
<TokensWithCost costText={cacheCostText} tokens={cacheTokens} />
|
|
375
375
|
</div>
|
package/src/tool.test.tsx
CHANGED
|
@@ -61,3 +61,68 @@ describe("ToolOutput isStreaming (#269, loading-states.md)", () => {
|
|
|
61
61
|
expect(container).toBeEmptyDOMElement();
|
|
62
62
|
});
|
|
63
63
|
});
|
|
64
|
+
|
|
65
|
+
// A tool that returns a count of 0, a boolean check of false, or an empty
|
|
66
|
+
// string is a REAL, defined result — only `undefined` means "no output yet".
|
|
67
|
+
// The old `!(output || errorText || isStreaming)` guard treated all three as
|
|
68
|
+
// "nothing to show" and rendered null outright.
|
|
69
|
+
describe("ToolOutput — defined-but-falsy output renders (review #1)", () => {
|
|
70
|
+
it("renders a numeric 0 result instead of returning null", () => {
|
|
71
|
+
render(<ToolOutput output={0} errorText={undefined} />);
|
|
72
|
+
expect(screen.getByText("Result")).toBeInTheDocument();
|
|
73
|
+
expect(screen.getByText("0")).toBeInTheDocument();
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("renders a boolean false result instead of returning null", () => {
|
|
77
|
+
const { container } = render(<ToolOutput output={false} errorText={undefined} />);
|
|
78
|
+
expect(screen.getByText("Result")).toBeInTheDocument();
|
|
79
|
+
expect(container.querySelector('[data-language="json"]')).not.toBeNull();
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("renders an empty-string result (not the pending skeleton, not null)", () => {
|
|
83
|
+
const { container } = render(<ToolOutput output="" errorText={undefined} />);
|
|
84
|
+
expect(screen.getByText("Result")).toBeInTheDocument();
|
|
85
|
+
expect(container.querySelector('[role="status"]')).toBeNull();
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
describe("ToolOutput — string output renders as plain text, not forced JSON (review #1)", () => {
|
|
90
|
+
it("renders a plain-text (non-JSON) string result without the JSON highlighter", () => {
|
|
91
|
+
const { container } = render(
|
|
92
|
+
<ToolOutput output="just a plain sentence, not json" errorText={undefined} />,
|
|
93
|
+
);
|
|
94
|
+
expect(screen.getByText("just a plain sentence, not json")).toBeInTheDocument();
|
|
95
|
+
expect(container.querySelector('[data-language="text"]')).not.toBeNull();
|
|
96
|
+
expect(container.querySelector('[data-language="json"]')).toBeNull();
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it("still renders an object result through the JSON highlighter", () => {
|
|
100
|
+
const { container } = render(<ToolOutput output={{ ok: true }} errorText={undefined} />);
|
|
101
|
+
expect(container.querySelector('[data-language="json"]')).not.toBeNull();
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
describe("ToolOutput/ToolInput — JSON.stringify never crashes the render (review #1)", () => {
|
|
106
|
+
it("falls back gracefully for a circular output object instead of throwing", () => {
|
|
107
|
+
const circular: Record<string, unknown> = { name: "circular" };
|
|
108
|
+
circular.self = circular;
|
|
109
|
+
|
|
110
|
+
expect(() => render(<ToolOutput output={circular} errorText={undefined} />)).not.toThrow();
|
|
111
|
+
expect(screen.getByText("Result")).toBeInTheDocument();
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it("renders a BigInt field in the output instead of throwing", () => {
|
|
115
|
+
expect(() =>
|
|
116
|
+
render(<ToolOutput output={{ total: 10n }} errorText={undefined} />),
|
|
117
|
+
).not.toThrow();
|
|
118
|
+
expect(screen.getByText("Result")).toBeInTheDocument();
|
|
119
|
+
expect(screen.getByText(/10n/)).toBeInTheDocument();
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it("falls back gracefully for circular tool input instead of throwing", () => {
|
|
123
|
+
const circular: Record<string, unknown> = { query: "x" };
|
|
124
|
+
circular.self = circular;
|
|
125
|
+
|
|
126
|
+
expect(() => render(<ToolInput input={circular} />)).not.toThrow();
|
|
127
|
+
});
|
|
128
|
+
});
|
package/src/tool.tsx
CHANGED
|
@@ -7,6 +7,7 @@ import type { DynamicToolUIPart, ToolUIPart } from "ai";
|
|
|
7
7
|
import { ChevronDownIcon, WrenchIcon } from "lucide-react";
|
|
8
8
|
import type { ComponentProps, ReactNode } from "react";
|
|
9
9
|
import { isValidElement } from "react";
|
|
10
|
+
import type { BundledLanguage } from "shiki";
|
|
10
11
|
|
|
11
12
|
import { CodeBlock } from "./code-block";
|
|
12
13
|
|
|
@@ -21,6 +22,19 @@ export const Tool = ({ className, ...props }: ToolProps) => (
|
|
|
21
22
|
|
|
22
23
|
export type ToolPart = ToolUIPart | DynamicToolUIPart;
|
|
23
24
|
|
|
25
|
+
/**
|
|
26
|
+
* `JSON.stringify` throws on a circular reference or a `BigInt` — both
|
|
27
|
+
* realistic shapes for tool input/output payloads a model produced. Falls
|
|
28
|
+
* back to a readable placeholder instead of crashing the message render.
|
|
29
|
+
*/
|
|
30
|
+
function safeJsonStringify(value: unknown): string {
|
|
31
|
+
try {
|
|
32
|
+
return JSON.stringify(value, (_key, v) => (typeof v === "bigint" ? `${v.toString()}n` : v), 2);
|
|
33
|
+
} catch {
|
|
34
|
+
return String(value);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
24
38
|
export type ToolHeaderProps = {
|
|
25
39
|
title?: string;
|
|
26
40
|
/**
|
|
@@ -123,23 +137,26 @@ export type ToolDetailsProps = ComponentProps<typeof Collapsible> & {
|
|
|
123
137
|
*/
|
|
124
138
|
export const ToolDetails = ({
|
|
125
139
|
className,
|
|
126
|
-
label
|
|
140
|
+
label,
|
|
127
141
|
defaultOpen = false,
|
|
128
142
|
children,
|
|
129
143
|
...props
|
|
130
|
-
}: ToolDetailsProps) =>
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
144
|
+
}: ToolDetailsProps) => {
|
|
145
|
+
const { t } = useLocale();
|
|
146
|
+
return (
|
|
147
|
+
<Collapsible
|
|
148
|
+
className={cn("group/tool-details not-prose", className)}
|
|
149
|
+
defaultOpen={defaultOpen}
|
|
150
|
+
{...props}
|
|
151
|
+
>
|
|
152
|
+
<CollapsibleTrigger className="flex items-center gap-1 rounded-sm text-meta text-muted-foreground transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring">
|
|
153
|
+
<ChevronDownIcon className="size-3.5 transition-transform group-data-[state=open]/tool-details:rotate-180" />
|
|
154
|
+
{label ?? t("ai.tool.showTechnicalDetails")}
|
|
155
|
+
</CollapsibleTrigger>
|
|
156
|
+
<CollapsibleContent className="mt-3 space-y-4">{children}</CollapsibleContent>
|
|
157
|
+
</Collapsible>
|
|
158
|
+
);
|
|
159
|
+
};
|
|
143
160
|
|
|
144
161
|
export type ToolInputProps = ComponentProps<"div"> & {
|
|
145
162
|
input: ToolPart["input"];
|
|
@@ -153,7 +170,7 @@ export const ToolInput = ({ className, input, ...props }: ToolInputProps) => {
|
|
|
153
170
|
{t("ai.schemaDisplay.parameters")}
|
|
154
171
|
</h4>
|
|
155
172
|
<div className="rounded-md bg-muted/50">
|
|
156
|
-
<CodeBlock code={
|
|
173
|
+
<CodeBlock code={safeJsonStringify(input)} language="json" />
|
|
157
174
|
</div>
|
|
158
175
|
</div>
|
|
159
176
|
);
|
|
@@ -197,25 +214,44 @@ export const ToolOutput = ({
|
|
|
197
214
|
}: ToolOutputProps) => {
|
|
198
215
|
const { t } = useLocale();
|
|
199
216
|
|
|
200
|
-
|
|
217
|
+
// `output` is a defined-but-falsy result (`0`, `false`, `""`) for plenty of
|
|
218
|
+
// real tools (a count, a boolean check, an empty-string field) — only
|
|
219
|
+
// `undefined` means "no output (yet)".
|
|
220
|
+
const hasOutput = output !== undefined;
|
|
221
|
+
|
|
222
|
+
if (!(hasOutput || errorText || isStreaming)) {
|
|
201
223
|
return null;
|
|
202
224
|
}
|
|
203
225
|
|
|
204
226
|
const showError = !isStreaming && Boolean(errorText);
|
|
205
|
-
const pending = isStreaming && !
|
|
227
|
+
const pending = isStreaming && !hasOutput && !errorText;
|
|
206
228
|
|
|
207
|
-
let Output =
|
|
229
|
+
let Output: ReactNode = null;
|
|
208
230
|
|
|
209
|
-
if (
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
231
|
+
if (hasOutput) {
|
|
232
|
+
if (isValidElement(output)) {
|
|
233
|
+
Output = output;
|
|
234
|
+
} else if (typeof output === "string") {
|
|
235
|
+
// A tool result is arbitrary text, not guaranteed JSON — forcing the
|
|
236
|
+
// JSON highlighter on it mis-colours ordinary strings. Shiki's own
|
|
237
|
+
// `BundledLanguage` union (grammar-backed languages) omits its
|
|
238
|
+
// hard-coded plain-text pseudo-languages (`isPlainLang`:
|
|
239
|
+
// "plaintext" | "txt" | "text" | "plain") — `createHighlighter`/
|
|
240
|
+
// `codeToTokens` accept and special-case them with no grammar load
|
|
241
|
+
// (verified: `getLoadedLanguages()` stays empty, no throw), so this
|
|
242
|
+
// is a type-only gap, not a runtime one.
|
|
243
|
+
Output = <CodeBlock code={output} language={"text" as BundledLanguage} />;
|
|
244
|
+
} else {
|
|
245
|
+
// Objects, arrays, numbers, booleans, null, bigint — all safe to
|
|
246
|
+
// stringify for display.
|
|
247
|
+
Output = <CodeBlock code={safeJsonStringify(output)} language="json" />;
|
|
248
|
+
}
|
|
213
249
|
}
|
|
214
250
|
|
|
215
251
|
return (
|
|
216
252
|
<div className={cn("space-y-2", className)} {...props}>
|
|
217
253
|
<h4 className="text-meta uppercase text-muted-foreground">
|
|
218
|
-
{showError ? "
|
|
254
|
+
{showError ? t("ai.tool.error") : t("ai.tool.result")}
|
|
219
255
|
</h4>
|
|
220
256
|
{pending ? (
|
|
221
257
|
<div className="space-y-2 rounded-md bg-muted/50 p-3" role="status" aria-live="polite">
|
package/src/transcription.tsx
CHANGED
|
@@ -54,7 +54,7 @@ export const Transcription = ({
|
|
|
54
54
|
return (
|
|
55
55
|
<TranscriptionContext.Provider value={contextValue}>
|
|
56
56
|
<div
|
|
57
|
-
className={cn("flex flex-wrap gap-1 text-
|
|
57
|
+
className={cn("flex flex-wrap gap-1 text-body leading-relaxed", className)}
|
|
58
58
|
data-slot="transcription"
|
|
59
59
|
{...props}
|
|
60
60
|
>
|
package/src/voice-selector.tsx
CHANGED
|
@@ -197,7 +197,7 @@ export const VoiceSelectorGender = ({
|
|
|
197
197
|
}
|
|
198
198
|
|
|
199
199
|
return (
|
|
200
|
-
<span className={cn("text-muted-foreground text-
|
|
200
|
+
<span className={cn("text-muted-foreground text-meta", className)} {...props}>
|
|
201
201
|
{children ?? icon}
|
|
202
202
|
</span>
|
|
203
203
|
);
|
|
@@ -373,7 +373,7 @@ export const VoiceSelectorAccent = ({
|
|
|
373
373
|
}
|
|
374
374
|
|
|
375
375
|
return (
|
|
376
|
-
<span className={cn("text-muted-foreground text-
|
|
376
|
+
<span className={cn("text-muted-foreground text-meta", className)} {...props}>
|
|
377
377
|
{children ?? emoji}
|
|
378
378
|
</span>
|
|
379
379
|
);
|
|
@@ -382,7 +382,7 @@ export const VoiceSelectorAccent = ({
|
|
|
382
382
|
export type VoiceSelectorAgeProps = ComponentProps<"span">;
|
|
383
383
|
|
|
384
384
|
export const VoiceSelectorAge = ({ className, ...props }: VoiceSelectorAgeProps) => (
|
|
385
|
-
<span className={cn("text-muted-foreground text-
|
|
385
|
+
<span className={cn("text-muted-foreground text-meta tabular-nums", className)} {...props} />
|
|
386
386
|
);
|
|
387
387
|
|
|
388
388
|
export type VoiceSelectorNameProps = ComponentProps<"span">;
|
|
@@ -397,7 +397,7 @@ export const VoiceSelectorDescription = ({
|
|
|
397
397
|
className,
|
|
398
398
|
...props
|
|
399
399
|
}: VoiceSelectorDescriptionProps) => (
|
|
400
|
-
<span className={cn("text-muted-foreground text-
|
|
400
|
+
<span className={cn("text-muted-foreground text-meta", className)} {...props} />
|
|
401
401
|
);
|
|
402
402
|
|
|
403
403
|
export type VoiceSelectorAttributesProps = ComponentProps<"div">;
|
|
@@ -407,7 +407,7 @@ export const VoiceSelectorAttributes = ({
|
|
|
407
407
|
children,
|
|
408
408
|
...props
|
|
409
409
|
}: VoiceSelectorAttributesProps) => (
|
|
410
|
-
<div className={cn("flex items-center text-
|
|
410
|
+
<div className={cn("flex items-center text-meta", className)} {...props}>
|
|
411
411
|
{children}
|
|
412
412
|
</div>
|
|
413
413
|
);
|
package/src/web-preview.test.tsx
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import { describe, expect, it } from "vitest";
|
|
2
|
-
import { render, screen } from "@testing-library/react";
|
|
2
|
+
import { render, screen, fireEvent } from "@testing-library/react";
|
|
3
|
+
import { LocaleProvider } from "@elabs-ai/components-ui";
|
|
3
4
|
import {
|
|
4
5
|
WebPreview,
|
|
5
6
|
WebPreviewBody,
|
|
7
|
+
WebPreviewConsole,
|
|
6
8
|
WebPreviewNavigation,
|
|
7
9
|
WebPreviewNavigationButton,
|
|
8
10
|
WebPreviewUrl,
|
|
@@ -115,3 +117,51 @@ describe("WebPreviewNavigationButton accessible name (#386)", () => {
|
|
|
115
117
|
expect(screen.getByRole("button", { name: "Back" })).toBeInTheDocument();
|
|
116
118
|
});
|
|
117
119
|
});
|
|
120
|
+
|
|
121
|
+
// 1.3 — `allow-same-origin` combined with `allow-scripts` lets a same-origin
|
|
122
|
+
// previewed page break out of the sandbox entirely. It must be opt-in.
|
|
123
|
+
describe("WebPreviewBody sandbox (#1.3)", () => {
|
|
124
|
+
it("never includes allow-same-origin by default", () => {
|
|
125
|
+
const { container } = render(
|
|
126
|
+
<WebPreview>
|
|
127
|
+
<WebPreviewBody />
|
|
128
|
+
</WebPreview>,
|
|
129
|
+
);
|
|
130
|
+
const iframe = container.querySelector("iframe");
|
|
131
|
+
expect(iframe).not.toBeNull();
|
|
132
|
+
expect(iframe?.getAttribute("sandbox")).not.toContain("allow-same-origin");
|
|
133
|
+
// The other, non-origin-escaping permissions still apply.
|
|
134
|
+
expect(iframe?.getAttribute("sandbox")).toContain("allow-scripts");
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it("adds allow-same-origin only when explicitly opted in", () => {
|
|
138
|
+
const { container } = render(
|
|
139
|
+
<WebPreview>
|
|
140
|
+
<WebPreviewBody allowSameOrigin />
|
|
141
|
+
</WebPreview>,
|
|
142
|
+
);
|
|
143
|
+
const iframe = container.querySelector("iframe");
|
|
144
|
+
expect(iframe?.getAttribute("sandbox")).toContain("allow-same-origin");
|
|
145
|
+
});
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
describe("WebPreviewConsole timestamp formatting (#1.3)", () => {
|
|
149
|
+
it("formats the log timestamp through the active locale, not the JS-engine default", () => {
|
|
150
|
+
const timestamp = new Date("2024-03-04T15:30:00Z");
|
|
151
|
+
render(
|
|
152
|
+
<LocaleProvider locale="de-DE">
|
|
153
|
+
<WebPreview>
|
|
154
|
+
<WebPreviewConsole logs={[{ level: "log", message: "started", timestamp }]} />
|
|
155
|
+
</WebPreview>
|
|
156
|
+
</LocaleProvider>,
|
|
157
|
+
);
|
|
158
|
+
// The console body is collapsed by default — open it before asserting.
|
|
159
|
+
fireEvent.click(screen.getByRole("button", { name: "Console" }));
|
|
160
|
+
const expected = new Intl.DateTimeFormat("de-DE", {
|
|
161
|
+
hour: "2-digit",
|
|
162
|
+
minute: "2-digit",
|
|
163
|
+
second: "2-digit",
|
|
164
|
+
}).format(timestamp);
|
|
165
|
+
expect(screen.getByText(expected)).toBeInTheDocument();
|
|
166
|
+
});
|
|
167
|
+
});
|
package/src/web-preview.tsx
CHANGED
|
@@ -158,7 +158,7 @@ export const WebPreviewUrl = ({ value, onChange, onKeyDown, ...props }: WebPrevi
|
|
|
158
158
|
|
|
159
159
|
return (
|
|
160
160
|
<Input
|
|
161
|
-
className="h-8 flex-1 text-
|
|
161
|
+
className="h-8 flex-1 text-body"
|
|
162
162
|
onChange={onChange ?? handleChange}
|
|
163
163
|
onKeyDown={handleKeyDown}
|
|
164
164
|
placeholder={t("ai.webPreview.urlPlaceholder")}
|
|
@@ -180,20 +180,45 @@ export type WebPreviewBodyProps = Omit<ComponentProps<"iframe">, "loading"> & {
|
|
|
180
180
|
* @default false
|
|
181
181
|
*/
|
|
182
182
|
loading?: boolean | ReactNode;
|
|
183
|
+
/**
|
|
184
|
+
* Adds `allow-same-origin` to the iframe sandbox. **Opt-in, off by
|
|
185
|
+
* default**: combined with `allow-scripts`, a same-origin previewed page
|
|
186
|
+
* can script its way out of the sandbox restrictions entirely (its own
|
|
187
|
+
* `<iframe>` reads as unsandboxed once it shares the origin). Only enable
|
|
188
|
+
* this for content you trust to be same-origin-safe.
|
|
189
|
+
* @default false
|
|
190
|
+
*/
|
|
191
|
+
allowSameOrigin?: boolean;
|
|
183
192
|
};
|
|
184
193
|
|
|
185
|
-
export const WebPreviewBody = ({
|
|
194
|
+
export const WebPreviewBody = ({
|
|
195
|
+
className,
|
|
196
|
+
loading,
|
|
197
|
+
src,
|
|
198
|
+
allowSameOrigin = false,
|
|
199
|
+
...props
|
|
200
|
+
}: WebPreviewBodyProps) => {
|
|
186
201
|
const { url } = useWebPreview();
|
|
187
202
|
const { t } = useLocale();
|
|
188
203
|
|
|
204
|
+
const sandbox = [
|
|
205
|
+
"allow-scripts",
|
|
206
|
+
"allow-forms",
|
|
207
|
+
"allow-popups",
|
|
208
|
+
"allow-presentation",
|
|
209
|
+
allowSameOrigin && "allow-same-origin",
|
|
210
|
+
]
|
|
211
|
+
.filter(Boolean)
|
|
212
|
+
.join(" ");
|
|
213
|
+
|
|
189
214
|
return (
|
|
190
215
|
<div className="relative flex-1">
|
|
191
216
|
<iframe
|
|
192
217
|
className={cn("size-full", className)}
|
|
193
218
|
// oxlint-disable-next-line eslint-plugin-react(iframe-missing-sandbox)
|
|
194
|
-
sandbox=
|
|
219
|
+
sandbox={sandbox}
|
|
195
220
|
src={(src ?? url) || undefined}
|
|
196
|
-
title="
|
|
221
|
+
title={t("ai.webPreview.previewTitle")}
|
|
197
222
|
{...props}
|
|
198
223
|
/>
|
|
199
224
|
{loading === true ? (
|
|
@@ -228,11 +253,11 @@ export const WebPreviewConsole = ({
|
|
|
228
253
|
...props
|
|
229
254
|
}: WebPreviewConsoleProps) => {
|
|
230
255
|
const { consoleOpen, setConsoleOpen } = useWebPreview();
|
|
231
|
-
const { t } = useLocale();
|
|
256
|
+
const { t, formatDate } = useLocale();
|
|
232
257
|
|
|
233
258
|
return (
|
|
234
259
|
<Collapsible
|
|
235
|
-
className={cn("border-t bg-muted/50 font-mono text-
|
|
260
|
+
className={cn("border-t bg-muted/50 font-mono text-body", className)}
|
|
236
261
|
onOpenChange={setConsoleOpen}
|
|
237
262
|
open={consoleOpen}
|
|
238
263
|
{...props}
|
|
@@ -264,7 +289,7 @@ export const WebPreviewConsole = ({
|
|
|
264
289
|
logs.map((log) => (
|
|
265
290
|
<div
|
|
266
291
|
className={cn(
|
|
267
|
-
"text-
|
|
292
|
+
"text-meta",
|
|
268
293
|
// #124: console log lines are running text — ink rung, not
|
|
269
294
|
// the mark rung. `text-yellow-600` was also a raw, untokened
|
|
270
295
|
// Tailwind palette colour (styling-and-tokens.md); replaced
|
|
@@ -275,7 +300,13 @@ export const WebPreviewConsole = ({
|
|
|
275
300
|
)}
|
|
276
301
|
key={`${log.timestamp.getTime()}-${log.level}-${log.message}`}
|
|
277
302
|
>
|
|
278
|
-
<span className="text-muted-foreground">
|
|
303
|
+
<span className="text-muted-foreground">
|
|
304
|
+
{formatDate(log.timestamp, {
|
|
305
|
+
hour: "2-digit",
|
|
306
|
+
minute: "2-digit",
|
|
307
|
+
second: "2-digit",
|
|
308
|
+
})}
|
|
309
|
+
</span>{" "}
|
|
279
310
|
{log.message}
|
|
280
311
|
</div>
|
|
281
312
|
))
|