@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/schema-display.tsx
CHANGED
|
@@ -5,8 +5,8 @@ import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@elabs-ai/c
|
|
|
5
5
|
import { useLocale } from "@elabs-ai/components-ui";
|
|
6
6
|
import { cn } from "@elabs-ai/components-ui/lib/cn";
|
|
7
7
|
import { ChevronRightIcon } from "lucide-react";
|
|
8
|
-
import type { ComponentProps, HTMLAttributes } from "react";
|
|
9
|
-
import { createContext, useContext, useMemo } from "react";
|
|
8
|
+
import type { ComponentProps, HTMLAttributes, ReactNode } from "react";
|
|
9
|
+
import { createContext, Fragment, useContext, useMemo } from "react";
|
|
10
10
|
|
|
11
11
|
type HttpMethod = "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
|
|
12
12
|
|
|
@@ -42,13 +42,36 @@ const SchemaDisplayContext = createContext<SchemaDisplayContextType>({
|
|
|
42
42
|
});
|
|
43
43
|
|
|
44
44
|
const methodStyles: Record<HttpMethod, string> = {
|
|
45
|
-
DELETE: "
|
|
46
|
-
GET: "
|
|
47
|
-
PATCH: "
|
|
48
|
-
POST: "
|
|
49
|
-
PUT: "
|
|
45
|
+
DELETE: "border-destructive/40 bg-destructive/10 text-destructive-text",
|
|
46
|
+
GET: "border-success/40 bg-success/10 text-success-text",
|
|
47
|
+
PATCH: "border-warning/40 bg-warning/10 text-warning-text",
|
|
48
|
+
POST: "border-info/40 bg-info/10 text-info-text",
|
|
49
|
+
PUT: "border-warning/40 bg-warning/10 text-warning-text",
|
|
50
50
|
};
|
|
51
51
|
|
|
52
|
+
/** Splits a `{param}` path into plain-text and parameter segments for safe rendering. */
|
|
53
|
+
function splitSchemaPath(path: string): Array<{ text: string; isParam: boolean }> {
|
|
54
|
+
const parts: Array<{ text: string; isParam: boolean }> = [];
|
|
55
|
+
const regex = /\{([^}]+)\}/g;
|
|
56
|
+
let lastIndex = 0;
|
|
57
|
+
let match: RegExpExecArray | null = regex.exec(path);
|
|
58
|
+
|
|
59
|
+
while (match !== null) {
|
|
60
|
+
if (match.index > lastIndex) {
|
|
61
|
+
parts.push({ isParam: false, text: path.slice(lastIndex, match.index) });
|
|
62
|
+
}
|
|
63
|
+
parts.push({ isParam: true, text: match[0] });
|
|
64
|
+
lastIndex = match.index + match[0].length;
|
|
65
|
+
match = regex.exec(path);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (lastIndex < path.length) {
|
|
69
|
+
parts.push({ isParam: false, text: path.slice(lastIndex) });
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return parts;
|
|
73
|
+
}
|
|
74
|
+
|
|
52
75
|
export type SchemaDisplayHeaderProps = HTMLAttributes<HTMLDivElement>;
|
|
53
76
|
|
|
54
77
|
export const SchemaDisplayHeader = ({
|
|
@@ -72,7 +95,7 @@ export const SchemaDisplayMethod = ({
|
|
|
72
95
|
|
|
73
96
|
return (
|
|
74
97
|
<Badge
|
|
75
|
-
className={cn("font-mono text-
|
|
98
|
+
className={cn("font-mono text-meta", methodStyles[method], className)}
|
|
76
99
|
variant="secondary"
|
|
77
100
|
{...props}
|
|
78
101
|
>
|
|
@@ -86,19 +109,26 @@ export type SchemaDisplayPathProps = HTMLAttributes<HTMLSpanElement>;
|
|
|
86
109
|
export const SchemaDisplayPath = ({ className, children, ...props }: SchemaDisplayPathProps) => {
|
|
87
110
|
const { path } = useContext(SchemaDisplayContext);
|
|
88
111
|
|
|
89
|
-
// Highlight path parameters
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
112
|
+
// Highlight path parameters as real elements — the path is untrusted
|
|
113
|
+
// (tool/model output) and must never reach dangerouslySetInnerHTML.
|
|
114
|
+
const pathNodes = useMemo<ReactNode>(
|
|
115
|
+
() =>
|
|
116
|
+
splitSchemaPath(path).map((part, index) =>
|
|
117
|
+
part.isParam ? (
|
|
118
|
+
<span className="text-info-text" key={`${part.text}-${index}`}>
|
|
119
|
+
{part.text}
|
|
120
|
+
</span>
|
|
121
|
+
) : (
|
|
122
|
+
<Fragment key={`${part.text}-${index}`}>{part.text}</Fragment>
|
|
123
|
+
),
|
|
124
|
+
),
|
|
125
|
+
[path],
|
|
93
126
|
);
|
|
94
127
|
|
|
95
128
|
return (
|
|
96
|
-
<span
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
dangerouslySetInnerHTML={{ __html: children ?? highlightedPath }}
|
|
100
|
-
{...props}
|
|
101
|
-
/>
|
|
129
|
+
<span className={cn("font-mono text-body", className)} {...props}>
|
|
130
|
+
{children ?? pathNodes}
|
|
131
|
+
</span>
|
|
102
132
|
);
|
|
103
133
|
};
|
|
104
134
|
|
|
@@ -112,7 +142,7 @@ export const SchemaDisplayDescription = ({
|
|
|
112
142
|
const { description } = useContext(SchemaDisplayContext);
|
|
113
143
|
|
|
114
144
|
return (
|
|
115
|
-
<p className={cn("border-b px-4 py-3 text-muted-foreground text-
|
|
145
|
+
<p className={cn("border-b px-4 py-3 text-muted-foreground text-body", className)} {...props}>
|
|
116
146
|
{children ?? description}
|
|
117
147
|
</p>
|
|
118
148
|
);
|
|
@@ -143,25 +173,25 @@ export const SchemaDisplayParameter = ({
|
|
|
143
173
|
}: SchemaDisplayParameterProps) => (
|
|
144
174
|
<div className={cn("px-4 py-3 ps-10", className)} {...props}>
|
|
145
175
|
<div className="flex items-center gap-2">
|
|
146
|
-
<span className="font-mono text-
|
|
147
|
-
<Badge className="text-
|
|
176
|
+
<span className="font-mono text-body">{name}</span>
|
|
177
|
+
<Badge className="text-meta" variant="outline">
|
|
148
178
|
{type}
|
|
149
179
|
</Badge>
|
|
150
180
|
{location && (
|
|
151
|
-
<Badge className="text-
|
|
181
|
+
<Badge className="text-meta" variant="secondary">
|
|
152
182
|
{location}
|
|
153
183
|
</Badge>
|
|
154
184
|
)}
|
|
155
185
|
{required && (
|
|
156
186
|
<Badge
|
|
157
|
-
className="bg-
|
|
187
|
+
className="border-destructive/40 bg-destructive/10 text-destructive-text text-meta"
|
|
158
188
|
variant="secondary"
|
|
159
189
|
>
|
|
160
190
|
required
|
|
161
191
|
</Badge>
|
|
162
192
|
)}
|
|
163
193
|
</div>
|
|
164
|
-
{description && <p className="mt-1 text-muted-foreground text-
|
|
194
|
+
{description && <p className="mt-1 text-muted-foreground text-body">{description}</p>}
|
|
165
195
|
</div>
|
|
166
196
|
);
|
|
167
197
|
|
|
@@ -179,8 +209,8 @@ export const SchemaDisplayParameters = ({
|
|
|
179
209
|
<Collapsible className={cn(className)} defaultOpen {...props}>
|
|
180
210
|
<CollapsibleTrigger className="group flex w-full items-center gap-2 px-4 py-3 text-start transition-colors hover:bg-muted/50">
|
|
181
211
|
<ChevronRightIcon className="size-4 shrink-0 text-muted-foreground transition-transform group-data-[state=open]:rotate-90" />
|
|
182
|
-
<span className="font-medium text-
|
|
183
|
-
<Badge className="ms-auto text-
|
|
212
|
+
<span className="font-medium text-body">{t("ai.schemaDisplay.parameters")}</span>
|
|
213
|
+
<Badge className="ms-auto text-meta" variant="secondary">
|
|
184
214
|
{parameters?.length}
|
|
185
215
|
</Badge>
|
|
186
216
|
</CollapsibleTrigger>
|
|
@@ -224,13 +254,13 @@ export const SchemaDisplayProperty = ({
|
|
|
224
254
|
style={{ paddingLeft }}
|
|
225
255
|
>
|
|
226
256
|
<ChevronRightIcon className="size-4 shrink-0 text-muted-foreground transition-transform group-data-[state=open]:rotate-90" />
|
|
227
|
-
<span className="font-mono text-
|
|
228
|
-
<Badge className="text-
|
|
257
|
+
<span className="font-mono text-body">{name}</span>
|
|
258
|
+
<Badge className="text-meta" variant="outline">
|
|
229
259
|
{type}
|
|
230
260
|
</Badge>
|
|
231
261
|
{required && (
|
|
232
262
|
<Badge
|
|
233
|
-
className="bg-
|
|
263
|
+
className="border-destructive/40 bg-destructive/10 text-destructive-text text-meta"
|
|
234
264
|
variant="secondary"
|
|
235
265
|
>
|
|
236
266
|
required
|
|
@@ -239,7 +269,7 @@ export const SchemaDisplayProperty = ({
|
|
|
239
269
|
</CollapsibleTrigger>
|
|
240
270
|
{description && (
|
|
241
271
|
<p
|
|
242
|
-
className="pb-2 text-muted-foreground text-
|
|
272
|
+
className="pb-2 text-muted-foreground text-body"
|
|
243
273
|
style={{ paddingLeft: paddingLeft + 24 }}
|
|
244
274
|
>
|
|
245
275
|
{description}
|
|
@@ -262,20 +292,20 @@ export const SchemaDisplayProperty = ({
|
|
|
262
292
|
<div className="flex items-center gap-2">
|
|
263
293
|
{/* Spacer for alignment */}
|
|
264
294
|
<span className="size-4" />
|
|
265
|
-
<span className="font-mono text-
|
|
266
|
-
<Badge className="text-
|
|
295
|
+
<span className="font-mono text-body">{name}</span>
|
|
296
|
+
<Badge className="text-meta" variant="outline">
|
|
267
297
|
{type}
|
|
268
298
|
</Badge>
|
|
269
299
|
{required && (
|
|
270
300
|
<Badge
|
|
271
|
-
className="bg-
|
|
301
|
+
className="border-destructive/40 bg-destructive/10 text-destructive-text text-meta"
|
|
272
302
|
variant="secondary"
|
|
273
303
|
>
|
|
274
304
|
required
|
|
275
305
|
</Badge>
|
|
276
306
|
)}
|
|
277
307
|
</div>
|
|
278
|
-
{description && <p className="mt-1 ps-6 text-muted-foreground text-
|
|
308
|
+
{description && <p className="mt-1 ps-6 text-muted-foreground text-body">{description}</p>}
|
|
279
309
|
</div>
|
|
280
310
|
);
|
|
281
311
|
};
|
|
@@ -288,12 +318,13 @@ export const SchemaDisplayRequest = ({
|
|
|
288
318
|
...props
|
|
289
319
|
}: SchemaDisplayRequestProps) => {
|
|
290
320
|
const { requestBody } = useContext(SchemaDisplayContext);
|
|
321
|
+
const { t } = useLocale();
|
|
291
322
|
|
|
292
323
|
return (
|
|
293
324
|
<Collapsible className={cn(className)} defaultOpen {...props}>
|
|
294
325
|
<CollapsibleTrigger className="group flex w-full items-center gap-2 px-4 py-3 text-start transition-colors hover:bg-muted/50">
|
|
295
326
|
<ChevronRightIcon className="size-4 shrink-0 text-muted-foreground transition-transform group-data-[state=open]:rotate-90" />
|
|
296
|
-
<span className="font-medium text-
|
|
327
|
+
<span className="font-medium text-body">{t("ai.schemaDisplay.requestBody")}</span>
|
|
297
328
|
</CollapsibleTrigger>
|
|
298
329
|
<CollapsibleContent>
|
|
299
330
|
<div className="border-t">
|
|
@@ -321,7 +352,7 @@ export const SchemaDisplayResponse = ({
|
|
|
321
352
|
<Collapsible className={cn(className)} defaultOpen {...props}>
|
|
322
353
|
<CollapsibleTrigger className="group flex w-full items-center gap-2 px-4 py-3 text-start transition-colors hover:bg-muted/50">
|
|
323
354
|
<ChevronRightIcon className="size-4 shrink-0 text-muted-foreground transition-transform group-data-[state=open]:rotate-90" />
|
|
324
|
-
<span className="font-medium text-
|
|
355
|
+
<span className="font-medium text-body">{t("ai.schemaDisplay.response")}</span>
|
|
325
356
|
</CollapsibleTrigger>
|
|
326
357
|
<CollapsibleContent>
|
|
327
358
|
<div className="border-t">
|
|
@@ -407,7 +438,7 @@ export const SchemaDisplayExample = ({
|
|
|
407
438
|
...props
|
|
408
439
|
}: SchemaDisplayExampleProps) => (
|
|
409
440
|
<pre
|
|
410
|
-
className={cn("mx-4 mb-4 overflow-auto rounded-md bg-muted p-4 font-mono text-
|
|
441
|
+
className={cn("mx-4 mb-4 overflow-auto rounded-md bg-muted p-4 font-mono text-body", className)}
|
|
411
442
|
{...props}
|
|
412
443
|
>
|
|
413
444
|
{children}
|
package/src/session-header.tsx
CHANGED
|
@@ -194,7 +194,7 @@ export const SessionHeader = forwardRef<HTMLDivElement, SessionHeaderProps>(func
|
|
|
194
194
|
{item.href != null ? (
|
|
195
195
|
<a
|
|
196
196
|
href={item.href}
|
|
197
|
-
className="text-
|
|
197
|
+
className="text-link underline-offset-4 hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
|
|
198
198
|
>
|
|
199
199
|
{item.label}
|
|
200
200
|
</a>
|
package/src/snippet.test.tsx
CHANGED
|
@@ -88,7 +88,7 @@ describe("SnippetCopyButton", () => {
|
|
|
88
88
|
await waitFor(() => expect(onError).toHaveBeenCalled());
|
|
89
89
|
});
|
|
90
90
|
|
|
91
|
-
it("reports an UNAVAILABLE clipboard through onError (insecure context / old browser)", () => {
|
|
91
|
+
it("reports an UNAVAILABLE clipboard through onError (insecure context / old browser)", async () => {
|
|
92
92
|
Object.defineProperty(navigator, "clipboard", { value: undefined, configurable: true });
|
|
93
93
|
const onError = vi.fn();
|
|
94
94
|
render(
|
|
@@ -98,7 +98,11 @@ describe("SnippetCopyButton", () => {
|
|
|
98
98
|
</Snippet>,
|
|
99
99
|
);
|
|
100
100
|
fireEvent.click(screen.getByRole("button", { name: "Copy" }));
|
|
101
|
-
|
|
101
|
+
// The shared `useCopyToClipboard` hook (`@elabs-ai/components-ui`) always
|
|
102
|
+
// resolves `copy()` on a microtask, even for the synchronous
|
|
103
|
+
// "clipboard missing" case — so this needs a `waitFor`, unlike a
|
|
104
|
+
// hand-rolled synchronous check would have.
|
|
105
|
+
await waitFor(() => expect(onError).toHaveBeenCalled());
|
|
102
106
|
});
|
|
103
107
|
|
|
104
108
|
it("fires onCopy once the write resolves", async () => {
|
package/src/snippet.tsx
CHANGED
|
@@ -8,18 +8,10 @@ import {
|
|
|
8
8
|
InputGroupText,
|
|
9
9
|
} from "@elabs-ai/components-ui";
|
|
10
10
|
import { cn } from "@elabs-ai/components-ui/lib/cn";
|
|
11
|
-
import { useLocale } from "@elabs-ai/components-ui";
|
|
11
|
+
import { useCopyToClipboard, useLocale } from "@elabs-ai/components-ui";
|
|
12
12
|
import { CheckIcon, CopyIcon } from "lucide-react";
|
|
13
13
|
import type { ComponentProps } from "react";
|
|
14
|
-
import {
|
|
15
|
-
createContext,
|
|
16
|
-
useCallback,
|
|
17
|
-
useContext,
|
|
18
|
-
useEffect,
|
|
19
|
-
useMemo,
|
|
20
|
-
useRef,
|
|
21
|
-
useState,
|
|
22
|
-
} from "react";
|
|
14
|
+
import { createContext, useCallback, useContext, useMemo } from "react";
|
|
23
15
|
|
|
24
16
|
interface SnippetContextType {
|
|
25
17
|
code: string;
|
|
@@ -79,40 +71,28 @@ export type SnippetCopyButtonProps = ComponentProps<typeof InputGroupButton> & {
|
|
|
79
71
|
export const SnippetCopyButton = ({
|
|
80
72
|
onCopy,
|
|
81
73
|
onError,
|
|
82
|
-
timeout
|
|
74
|
+
timeout,
|
|
83
75
|
children,
|
|
84
76
|
className,
|
|
85
77
|
...props
|
|
86
78
|
}: SnippetCopyButtonProps) => {
|
|
87
79
|
const { t } = useLocale();
|
|
88
|
-
const [isCopied, setIsCopied] = useState(false);
|
|
89
|
-
const timeoutRef = useRef<number>(0);
|
|
90
80
|
const { code } = useContext(SnippetContext);
|
|
81
|
+
// Shared implementation (`@elabs-ai/components-ui`) instead of a private
|
|
82
|
+
// copy of the same copy-to-clipboard state machine (issue-workflow.md
|
|
83
|
+
// dedupe finding) — see `CodeBlockCopyButton` for the reference usage.
|
|
84
|
+
const { copied: isCopied, copy } = useCopyToClipboard(
|
|
85
|
+
timeout === undefined ? undefined : { resetAfterMs: timeout },
|
|
86
|
+
);
|
|
91
87
|
|
|
92
88
|
const copyToClipboard = useCallback(async () => {
|
|
93
|
-
|
|
89
|
+
const ok = await copy(code);
|
|
90
|
+
if (ok) {
|
|
91
|
+
onCopy?.();
|
|
92
|
+
} else {
|
|
94
93
|
onError?.(new Error("Clipboard API not available"));
|
|
95
|
-
return;
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
try {
|
|
99
|
-
if (!isCopied) {
|
|
100
|
-
await navigator.clipboard.writeText(code);
|
|
101
|
-
setIsCopied(true);
|
|
102
|
-
onCopy?.();
|
|
103
|
-
timeoutRef.current = window.setTimeout(() => setIsCopied(false), timeout);
|
|
104
|
-
}
|
|
105
|
-
} catch (error) {
|
|
106
|
-
onError?.(error as Error);
|
|
107
94
|
}
|
|
108
|
-
}, [code, onCopy, onError
|
|
109
|
-
|
|
110
|
-
useEffect(
|
|
111
|
-
() => () => {
|
|
112
|
-
window.clearTimeout(timeoutRef.current);
|
|
113
|
-
},
|
|
114
|
-
[],
|
|
115
|
-
);
|
|
95
|
+
}, [copy, code, onCopy, onError]);
|
|
116
96
|
|
|
117
97
|
const Icon = isCopied ? CheckIcon : CopyIcon;
|
|
118
98
|
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* speech-input.test.tsx — the MediaRecorder fallback path's two race bugs:
|
|
3
|
+
* a double click starting two recorders before the first `getUserMedia()`
|
|
4
|
+
* resolves, and an unmount during a pending `getUserMedia()` leaving the
|
|
5
|
+
* microphone on with nothing left to stop it.
|
|
6
|
+
*/
|
|
7
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
8
|
+
import { cleanup, fireEvent, render, waitFor } from "@testing-library/react";
|
|
9
|
+
import { SpeechInput } from "./speech-input";
|
|
10
|
+
|
|
11
|
+
class FakeMediaRecorder {
|
|
12
|
+
state: "inactive" | "recording" = "inactive";
|
|
13
|
+
private listeners = new Map<string, Set<(event: Event) => void>>();
|
|
14
|
+
|
|
15
|
+
constructor(public stream: MediaStream) {}
|
|
16
|
+
|
|
17
|
+
addEventListener(type: string, cb: (event: Event) => void) {
|
|
18
|
+
if (!this.listeners.has(type)) this.listeners.set(type, new Set());
|
|
19
|
+
this.listeners.get(type)?.add(cb);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
removeEventListener(type: string, cb: (event: Event) => void) {
|
|
23
|
+
this.listeners.get(type)?.delete(cb);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
start() {
|
|
27
|
+
this.state = "recording";
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
stop() {
|
|
31
|
+
this.state = "inactive";
|
|
32
|
+
for (const cb of this.listeners.get("stop") ?? []) cb(new Event("stop"));
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function makeFakeStream() {
|
|
37
|
+
const tracks = [{ stop: vi.fn() }, { stop: vi.fn() }];
|
|
38
|
+
return {
|
|
39
|
+
getTracks: () => tracks,
|
|
40
|
+
tracks,
|
|
41
|
+
} as unknown as MediaStream & { tracks: { stop: ReturnType<typeof vi.fn> }[] };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function stubMediaRecorderEnvironment(getUserMedia: (...args: unknown[]) => unknown) {
|
|
45
|
+
vi.stubGlobal("MediaRecorder", FakeMediaRecorder);
|
|
46
|
+
Object.defineProperty(navigator, "mediaDevices", {
|
|
47
|
+
configurable: true,
|
|
48
|
+
value: { getUserMedia },
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
afterEach(() => {
|
|
53
|
+
cleanup();
|
|
54
|
+
vi.unstubAllGlobals();
|
|
55
|
+
// @ts-expect-error -- test-only cleanup of a property we defined above
|
|
56
|
+
delete navigator.mediaDevices;
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
describe("SpeechInput — media-recorder fallback getUserMedia race guards", () => {
|
|
60
|
+
it("does not call getUserMedia twice for a double click before the first call resolves", async () => {
|
|
61
|
+
let resolveGetUserMedia: ((stream: MediaStream) => void) | undefined;
|
|
62
|
+
const getUserMedia = vi.fn(
|
|
63
|
+
() =>
|
|
64
|
+
new Promise((resolve: (stream: MediaStream) => void) => {
|
|
65
|
+
resolveGetUserMedia = resolve;
|
|
66
|
+
}),
|
|
67
|
+
);
|
|
68
|
+
stubMediaRecorderEnvironment(getUserMedia);
|
|
69
|
+
|
|
70
|
+
const startSpy = vi.spyOn(FakeMediaRecorder.prototype, "start");
|
|
71
|
+
const { getByRole } = render(<SpeechInput onAudioRecorded={() => Promise.resolve("hi")} />);
|
|
72
|
+
const button = getByRole("button");
|
|
73
|
+
|
|
74
|
+
fireEvent.click(button);
|
|
75
|
+
fireEvent.click(button);
|
|
76
|
+
|
|
77
|
+
expect(getUserMedia).toHaveBeenCalledTimes(1);
|
|
78
|
+
|
|
79
|
+
resolveGetUserMedia?.(makeFakeStream());
|
|
80
|
+
// Only ONE recorder is ever created/started — the second click's
|
|
81
|
+
// `getUserMedia()` call was suppressed by the in-flight guard, not merely
|
|
82
|
+
// delayed.
|
|
83
|
+
await waitFor(() => expect(startSpy).toHaveBeenCalledTimes(1));
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it("stops the granted tracks when the component unmounts before getUserMedia resolves", async () => {
|
|
87
|
+
let resolveGetUserMedia: ((stream: MediaStream) => void) | undefined;
|
|
88
|
+
const getUserMedia = vi.fn(
|
|
89
|
+
() =>
|
|
90
|
+
new Promise((resolve: (stream: MediaStream) => void) => {
|
|
91
|
+
resolveGetUserMedia = resolve;
|
|
92
|
+
}),
|
|
93
|
+
);
|
|
94
|
+
stubMediaRecorderEnvironment(getUserMedia);
|
|
95
|
+
|
|
96
|
+
const { getByRole, unmount } = render(
|
|
97
|
+
<SpeechInput onAudioRecorded={() => Promise.resolve("hi")} />,
|
|
98
|
+
);
|
|
99
|
+
fireEvent.click(getByRole("button"));
|
|
100
|
+
|
|
101
|
+
unmount();
|
|
102
|
+
|
|
103
|
+
const stream = makeFakeStream();
|
|
104
|
+
resolveGetUserMedia?.(stream);
|
|
105
|
+
|
|
106
|
+
await waitFor(() => expect(stream.tracks[0]?.stop).toHaveBeenCalledTimes(1));
|
|
107
|
+
expect(stream.tracks[1]?.stop).toHaveBeenCalledTimes(1);
|
|
108
|
+
});
|
|
109
|
+
});
|
package/src/speech-input.tsx
CHANGED
|
@@ -101,6 +101,21 @@ export const SpeechInput = ({
|
|
|
101
101
|
const onTranscriptionChangeRef =
|
|
102
102
|
useRef<SpeechInputProps["onTranscriptionChange"]>(onTranscriptionChange);
|
|
103
103
|
const onAudioRecordedRef = useRef<SpeechInputProps["onAudioRecorded"]>(onAudioRecorded);
|
|
104
|
+
// Guards a pending `getUserMedia()` call against a second click/toggle
|
|
105
|
+
// firing before the first resolves (both see `isListening === false`, since
|
|
106
|
+
// that flips only once the stream is actually granted).
|
|
107
|
+
const isStartingMediaRecorderRef = useRef(false);
|
|
108
|
+
// Flips false on unmount so an in-flight `getUserMedia()` that resolves
|
|
109
|
+
// AFTER teardown stops its just-granted tracks immediately instead of
|
|
110
|
+
// leaving the microphone on with nothing listening.
|
|
111
|
+
const isMountedRef = useRef(true);
|
|
112
|
+
|
|
113
|
+
useEffect(
|
|
114
|
+
() => () => {
|
|
115
|
+
isMountedRef.current = false;
|
|
116
|
+
},
|
|
117
|
+
[],
|
|
118
|
+
);
|
|
104
119
|
|
|
105
120
|
// Keep refs in sync
|
|
106
121
|
onTranscriptionChangeRef.current = onTranscriptionChange;
|
|
@@ -183,12 +198,23 @@ export const SpeechInput = ({
|
|
|
183
198
|
|
|
184
199
|
// Start MediaRecorder recording
|
|
185
200
|
const startMediaRecorder = useCallback(async () => {
|
|
186
|
-
if (!onAudioRecordedRef.current) {
|
|
201
|
+
if (!onAudioRecordedRef.current || isStartingMediaRecorderRef.current) {
|
|
187
202
|
return;
|
|
188
203
|
}
|
|
189
204
|
|
|
205
|
+
isStartingMediaRecorderRef.current = true;
|
|
190
206
|
try {
|
|
191
207
|
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
|
208
|
+
|
|
209
|
+
// Unmounted (or stopped) while getUserMedia was pending — the granted
|
|
210
|
+
// tracks have no listener left to stop them later, so stop them now.
|
|
211
|
+
if (!isMountedRef.current) {
|
|
212
|
+
for (const track of stream.getTracks()) {
|
|
213
|
+
track.stop();
|
|
214
|
+
}
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
|
|
192
218
|
streamRef.current = stream;
|
|
193
219
|
const mediaRecorder = new MediaRecorder(stream);
|
|
194
220
|
audioChunksRef.current = [];
|
|
@@ -241,6 +267,8 @@ export const SpeechInput = ({
|
|
|
241
267
|
setIsListening(true);
|
|
242
268
|
} catch {
|
|
243
269
|
setIsListening(false);
|
|
270
|
+
} finally {
|
|
271
|
+
isStartingMediaRecorderRef.current = false;
|
|
244
272
|
}
|
|
245
273
|
}, []);
|
|
246
274
|
|
|
@@ -281,7 +309,7 @@ export const SpeechInput = ({
|
|
|
281
309
|
{isListening &&
|
|
282
310
|
[0, 1, 2].map((index) => (
|
|
283
311
|
<div
|
|
284
|
-
className="absolute inset-0 animate-ping rounded-full border-2 border-
|
|
312
|
+
className="absolute inset-0 animate-ping rounded-full border-2 border-destructive/30"
|
|
285
313
|
key={index}
|
|
286
314
|
style={{
|
|
287
315
|
animationDelay: `${index * 0.3}s`,
|
|
@@ -295,7 +323,7 @@ export const SpeechInput = ({
|
|
|
295
323
|
className={cn(
|
|
296
324
|
"relative z-10 rounded-full transition-colors duration-slow ease-standard motion-reduce:transition-none",
|
|
297
325
|
isListening
|
|
298
|
-
? "bg-destructive text-
|
|
326
|
+
? "bg-destructive text-destructive-foreground hover:bg-destructive/80 hover:text-destructive-foreground"
|
|
299
327
|
: "bg-primary text-primary-foreground hover:bg-primary/80 hover:text-primary-foreground",
|
|
300
328
|
className,
|
|
301
329
|
)}
|
package/src/stack-trace.tsx
CHANGED
|
@@ -3,20 +3,11 @@
|
|
|
3
3
|
import { useControllableState } from "@radix-ui/react-use-controllable-state";
|
|
4
4
|
import { Button } from "@elabs-ai/components-ui";
|
|
5
5
|
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@elabs-ai/components-ui";
|
|
6
|
-
import { useLocale } from "@elabs-ai/components-ui";
|
|
6
|
+
import { useCopyToClipboard, useLocale } from "@elabs-ai/components-ui";
|
|
7
7
|
import { cn } from "@elabs-ai/components-ui/lib/cn";
|
|
8
8
|
import { AlertTriangleIcon, CheckIcon, ChevronDownIcon, CopyIcon } from "lucide-react";
|
|
9
9
|
import type { ComponentProps } from "react";
|
|
10
|
-
import {
|
|
11
|
-
createContext,
|
|
12
|
-
memo,
|
|
13
|
-
useCallback,
|
|
14
|
-
useContext,
|
|
15
|
-
useEffect,
|
|
16
|
-
useMemo,
|
|
17
|
-
useRef,
|
|
18
|
-
useState,
|
|
19
|
-
} from "react";
|
|
10
|
+
import { createContext, memo, useCallback, useContext, useMemo } from "react";
|
|
20
11
|
|
|
21
12
|
// Regex patterns for parsing stack traces
|
|
22
13
|
const STACK_FRAME_WITH_PARENS_REGEX = /^at\s+(.+?)\s+\((.+):(\d+):(\d+)\)$/;
|
|
@@ -188,7 +179,7 @@ export const StackTrace = memo(
|
|
|
188
179
|
<StackTraceContext.Provider value={contextValue}>
|
|
189
180
|
<div
|
|
190
181
|
className={cn(
|
|
191
|
-
"not-prose w-full overflow-hidden rounded-lg border bg-background font-mono text-
|
|
182
|
+
"not-prose w-full overflow-hidden rounded-lg border bg-background font-mono text-body",
|
|
192
183
|
className,
|
|
193
184
|
)}
|
|
194
185
|
{...props}
|
|
@@ -293,42 +284,35 @@ export const StackTraceCopyButton = memo(
|
|
|
293
284
|
({
|
|
294
285
|
onCopy,
|
|
295
286
|
onError,
|
|
296
|
-
timeout
|
|
287
|
+
timeout,
|
|
297
288
|
className,
|
|
298
289
|
children,
|
|
290
|
+
"aria-label": ariaLabel,
|
|
299
291
|
...props
|
|
300
292
|
}: StackTraceCopyButtonProps) => {
|
|
301
|
-
const
|
|
302
|
-
const timeoutRef = useRef<number>(0);
|
|
293
|
+
const { t } = useLocale();
|
|
303
294
|
const { raw } = useStackTrace();
|
|
295
|
+
// Shared implementation (`@elabs-ai/components-ui`) instead of a private
|
|
296
|
+
// copy of the same copy-to-clipboard state machine (issue-workflow.md
|
|
297
|
+
// dedupe finding) — see `CodeBlockCopyButton` for the reference usage.
|
|
298
|
+
const { copied: isCopied, copy } = useCopyToClipboard(
|
|
299
|
+
timeout === undefined ? undefined : { resetAfterMs: timeout },
|
|
300
|
+
);
|
|
304
301
|
|
|
305
302
|
const copyToClipboard = useCallback(async () => {
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
return;
|
|
309
|
-
}
|
|
310
|
-
|
|
311
|
-
try {
|
|
312
|
-
await navigator.clipboard.writeText(raw);
|
|
313
|
-
setIsCopied(true);
|
|
303
|
+
const ok = await copy(raw);
|
|
304
|
+
if (ok) {
|
|
314
305
|
onCopy?.();
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
onError?.(error as Error);
|
|
306
|
+
} else {
|
|
307
|
+
onError?.(new Error("Clipboard API not available"));
|
|
318
308
|
}
|
|
319
|
-
}, [raw, onCopy, onError
|
|
320
|
-
|
|
321
|
-
useEffect(
|
|
322
|
-
() => () => {
|
|
323
|
-
window.clearTimeout(timeoutRef.current);
|
|
324
|
-
},
|
|
325
|
-
[],
|
|
326
|
-
);
|
|
309
|
+
}, [copy, raw, onCopy, onError]);
|
|
327
310
|
|
|
328
311
|
const Icon = isCopied ? CheckIcon : CopyIcon;
|
|
329
312
|
|
|
330
313
|
return (
|
|
331
314
|
<Button
|
|
315
|
+
aria-label={ariaLabel ?? t("copy")}
|
|
332
316
|
className={cn("size-7", className)}
|
|
333
317
|
onClick={copyToClipboard}
|
|
334
318
|
size="icon"
|
|
@@ -440,7 +424,7 @@ export const StackTraceFrames = memo(
|
|
|
440
424
|
{framesToShow.map((frame) => (
|
|
441
425
|
<div
|
|
442
426
|
className={cn(
|
|
443
|
-
"text-
|
|
427
|
+
"text-meta",
|
|
444
428
|
frame.isInternal ? "text-muted-foreground/50" : "text-foreground/90",
|
|
445
429
|
)}
|
|
446
430
|
key={frame.raw}
|
|
@@ -464,7 +448,7 @@ export const StackTraceFrames = memo(
|
|
|
464
448
|
</div>
|
|
465
449
|
))}
|
|
466
450
|
{framesToShow.length === 0 && (
|
|
467
|
-
<div className="text-muted-foreground text-
|
|
451
|
+
<div className="text-muted-foreground text-meta">{t("ai.stackTrace.empty")}</div>
|
|
468
452
|
)}
|
|
469
453
|
</div>
|
|
470
454
|
);
|
|
@@ -13,6 +13,7 @@ import { LocaleProvider } from "@elabs-ai/components-ui";
|
|
|
13
13
|
import { MessageResponse } from "./message";
|
|
14
14
|
import { MarkdownView } from "./markdown-view";
|
|
15
15
|
import { Reasoning, ReasoningContent } from "./reasoning";
|
|
16
|
+
import { _debugActiveThemeScopeCount } from "./_theme-scope-store";
|
|
16
17
|
|
|
17
18
|
const CODE_MARKDOWN = "```js\nconst a = 1;\n```";
|
|
18
19
|
// `MarkdownView` re-maps `code` onto `ProseInlineCode`, so its Streamdown chrome
|
|
@@ -107,6 +108,28 @@ describe("Streamdown chrome microcopy (#310)", () => {
|
|
|
107
108
|
});
|
|
108
109
|
});
|
|
109
110
|
|
|
111
|
+
describe("useReactiveCodePlugin shares ONE theme-scope observer (perf review §3.3)", () => {
|
|
112
|
+
it("registers a single tracked scope for many simultaneously-mounted MessageResponse instances", () => {
|
|
113
|
+
const before = _debugActiveThemeScopeCount();
|
|
114
|
+
|
|
115
|
+
const { unmount } = render(
|
|
116
|
+
<>
|
|
117
|
+
<MessageResponse>{CODE_MARKDOWN}</MessageResponse>
|
|
118
|
+
<MessageResponse>{CODE_MARKDOWN}</MessageResponse>
|
|
119
|
+
<MessageResponse>{CODE_MARKDOWN}</MessageResponse>
|
|
120
|
+
</>,
|
|
121
|
+
);
|
|
122
|
+
|
|
123
|
+
// Under the old per-hook `MutationObserver`, a message-dense conversation
|
|
124
|
+
// registered one observer PER instance; the shared store collapses every
|
|
125
|
+
// instance watching the same `<html>` scope into exactly one.
|
|
126
|
+
expect(_debugActiveThemeScopeCount()).toBe(before + 1);
|
|
127
|
+
|
|
128
|
+
unmount();
|
|
129
|
+
expect(_debugActiveThemeScopeCount()).toBe(before);
|
|
130
|
+
});
|
|
131
|
+
});
|
|
132
|
+
|
|
110
133
|
/**
|
|
111
134
|
* #315 follow-up — the "major" carve-out: only `<CodeBlock>` had been
|
|
112
135
|
* de-GitHub-ed; every fenced code block rendered through `MessageResponse`/
|