@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.
Files changed (84) hide show
  1. package/README.md +2 -2
  2. package/dist/index.d.ts +43 -16
  3. package/dist/index.js +1297 -926
  4. package/dist/index.js.map +1 -1
  5. package/package.json +9 -9
  6. package/src/__contract__/audio-visualizer.contract.test.tsx +49 -0
  7. package/src/__contract__/chat-shell.contract.test.tsx +49 -0
  8. package/src/__contract__/grouped-parts.contract.test.tsx +49 -0
  9. package/src/__contract__/image.contract.test.tsx +49 -0
  10. package/src/__contract__/markdown-view.contract.test.tsx +49 -0
  11. package/src/__contract__/message-feedback.contract.test.tsx +49 -0
  12. package/src/__contract__/message-form.contract.test.tsx +49 -0
  13. package/src/__contract__/message-table.contract.test.tsx +49 -0
  14. package/src/__contract__/model-provider-logo.contract.test.tsx +49 -0
  15. package/src/__contract__/persona.contract.test.tsx +49 -0
  16. package/src/__contract__/prompt-input-effort.contract.test.tsx +49 -0
  17. package/src/__contract__/prompt-input-mode.contract.test.tsx +49 -0
  18. package/src/_chat-shell-rail.tsx +2 -2
  19. package/src/_lazy-cjk.test.ts +43 -0
  20. package/src/_lazy-cjk.ts +73 -0
  21. package/src/_lazy-math.test.ts +63 -0
  22. package/src/_lazy-math.ts +90 -0
  23. package/src/_streamdown-i18n.ts +73 -21
  24. package/src/_streamdown-safety.ts +2 -2
  25. package/src/_theme-scope-store.test.ts +83 -0
  26. package/src/_theme-scope-store.ts +103 -0
  27. package/src/agent-event.tsx +1 -1
  28. package/src/agent.tsx +18 -13
  29. package/src/artifact.tsx +2 -2
  30. package/src/asset-preview.test.tsx +40 -0
  31. package/src/asset-preview.tsx +83 -12
  32. package/src/attachments.tsx +7 -4
  33. package/src/code-block.test.tsx +100 -1
  34. package/src/code-block.tsx +166 -103
  35. package/src/commit.tsx +30 -41
  36. package/src/confirmation.tsx +1 -1
  37. package/src/context-panel.tsx +1 -1
  38. package/src/conversation.stories.tsx +23 -0
  39. package/src/conversation.test.tsx +53 -0
  40. package/src/conversation.tsx +32 -7
  41. package/src/diff-view.test.tsx +52 -2
  42. package/src/diff-view.tsx +89 -34
  43. package/src/environment-variables.tsx +20 -37
  44. package/src/file-tree.test.tsx +21 -0
  45. package/src/file-tree.tsx +12 -2
  46. package/src/inline-citation.tsx +5 -5
  47. package/src/jsx-preview.tsx +151 -43
  48. package/src/markdown-view.test.tsx +7 -3
  49. package/src/markdown-view.tsx +8 -1
  50. package/src/message-form.stories.tsx +36 -3
  51. package/src/message-form.test.tsx +8 -2
  52. package/src/message-form.tsx +15 -7
  53. package/src/message-table.stories.tsx +2 -2
  54. package/src/message-table.test.tsx +7 -0
  55. package/src/message-table.tsx +8 -4
  56. package/src/message.test.tsx +73 -3
  57. package/src/message.tsx +36 -12
  58. package/src/model-provider-logo.test.tsx +57 -3
  59. package/src/model-provider-logo.tsx +45 -11
  60. package/src/open-in-chat.tsx +50 -29
  61. package/src/package-info.tsx +12 -12
  62. package/src/prompt-input-slash.stories.tsx +1 -1
  63. package/src/prompt-input.test.tsx +67 -1
  64. package/src/prompt-input.tsx +42 -4
  65. package/src/queue.tsx +4 -4
  66. package/src/reasoning.tsx +17 -6
  67. package/src/sandbox.tsx +3 -3
  68. package/src/schema-display.test.tsx +56 -0
  69. package/src/schema-display.tsx +68 -37
  70. package/src/session-header.tsx +1 -1
  71. package/src/snippet.test.tsx +6 -2
  72. package/src/snippet.tsx +14 -34
  73. package/src/speech-input.test.tsx +109 -0
  74. package/src/speech-input.tsx +31 -3
  75. package/src/stack-trace.tsx +20 -36
  76. package/src/streamdown-i18n.test.tsx +23 -0
  77. package/src/test-results.tsx +47 -27
  78. package/src/token-usage.tsx +6 -6
  79. package/src/tool.test.tsx +65 -0
  80. package/src/tool.tsx +59 -23
  81. package/src/transcription.tsx +1 -1
  82. package/src/voice-selector.tsx +5 -5
  83. package/src/web-preview.test.tsx +51 -1
  84. package/src/web-preview.tsx +39 -8
@@ -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: "bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400",
46
- GET: "bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400",
47
- PATCH: "bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400",
48
- POST: "bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400",
49
- PUT: "bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-400",
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-xs", methodStyles[method], className)}
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
- const highlightedPath = path.replaceAll(
91
- /\{([^}]+)\}/g,
92
- '<span class="text-blue-600 dark:text-blue-400">{$1}</span>',
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
- className={cn("font-mono text-sm", className)}
98
- // oxlint-disable-next-line eslint-plugin-react(no-danger)
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-sm", className)} {...props}>
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-sm">{name}</span>
147
- <Badge className="text-xs" variant="outline">
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-xs" variant="secondary">
181
+ <Badge className="text-meta" variant="secondary">
152
182
  {location}
153
183
  </Badge>
154
184
  )}
155
185
  {required && (
156
186
  <Badge
157
- className="bg-red-100 text-red-700 text-xs dark:bg-red-900/30 dark:text-red-400"
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-sm">{description}</p>}
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-sm">{t("ai.schemaDisplay.parameters")}</span>
183
- <Badge className="ms-auto text-xs" variant="secondary">
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-sm">{name}</span>
228
- <Badge className="text-xs" variant="outline">
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-red-100 text-red-700 text-xs dark:bg-red-900/30 dark:text-red-400"
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-sm"
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-sm">{name}</span>
266
- <Badge className="text-xs" variant="outline">
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-red-100 text-red-700 text-xs dark:bg-red-900/30 dark:text-red-400"
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-sm">{description}</p>}
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-sm">Request Body</span>
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-sm">{t("ai.schemaDisplay.response")}</span>
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-sm", className)}
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}
@@ -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-primary-text 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"
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>
@@ -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
- expect(onError).toHaveBeenCalled();
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 = 2000,
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
- if (typeof window === "undefined" || !navigator?.clipboard?.writeText) {
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, timeout, isCopied]);
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
+ });
@@ -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-red-400/30"
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-white hover:bg-destructive/80 hover:text-white"
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
  )}
@@ -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-sm",
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 = 2000,
287
+ timeout,
297
288
  className,
298
289
  children,
290
+ "aria-label": ariaLabel,
299
291
  ...props
300
292
  }: StackTraceCopyButtonProps) => {
301
- const [isCopied, setIsCopied] = useState(false);
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
- if (typeof window === "undefined" || !navigator?.clipboard?.writeText) {
307
- onError?.(new Error("Clipboard API not available"));
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
- timeoutRef.current = window.setTimeout(() => setIsCopied(false), timeout);
316
- } catch (error) {
317
- onError?.(error as Error);
306
+ } else {
307
+ onError?.(new Error("Clipboard API not available"));
318
308
  }
319
- }, [raw, onCopy, onError, timeout]);
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-xs",
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-xs">{t("ai.stackTrace.empty")}</div>
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`/