@elabs-ai/components-ai 4.0.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 (180) hide show
  1. package/README.md +62 -12
  2. package/dist/{_audio-player-media-chrome-KA5DY54G.js → _audio-player-media-chrome-T3XVXWRZ.js} +8 -4
  3. package/dist/_audio-player-media-chrome-T3XVXWRZ.js.map +1 -0
  4. package/dist/{_flow-boundary-D63PJ65S.js → _flow-boundary-SHNWLQG5.js} +32 -43
  5. package/dist/_flow-boundary-SHNWLQG5.js.map +1 -0
  6. package/dist/{_persona-rive-RFR2EUWP.js → _persona-rive-JEG44YHX.js} +9 -5
  7. package/dist/_persona-rive-JEG44YHX.js.map +1 -0
  8. package/dist/index.d.ts +1152 -218
  9. package/dist/index.js +5601 -3241
  10. package/dist/index.js.map +1 -1
  11. package/package.json +30 -15
  12. package/src/__contract__/audio-visualizer.contract.test.tsx +49 -0
  13. package/src/__contract__/chat-shell.contract.test.tsx +49 -0
  14. package/src/__contract__/grouped-parts.contract.test.tsx +49 -0
  15. package/src/__contract__/image.contract.test.tsx +49 -0
  16. package/src/__contract__/markdown-view.contract.test.tsx +49 -0
  17. package/src/__contract__/message-feedback.contract.test.tsx +49 -0
  18. package/src/__contract__/message-form.contract.test.tsx +49 -0
  19. package/src/__contract__/message-table.contract.test.tsx +49 -0
  20. package/src/__contract__/model-provider-logo.contract.test.tsx +49 -0
  21. package/src/__contract__/persona.contract.test.tsx +49 -0
  22. package/src/__contract__/prompt-input-effort.contract.test.tsx +49 -0
  23. package/src/__contract__/prompt-input-mode.contract.test.tsx +49 -0
  24. package/src/_audio-player-media-chrome.tsx +102 -15
  25. package/src/_chat-shell-rail.tsx +2 -2
  26. package/src/_flow-boundary.tsx +68 -49
  27. package/src/_lazy-boundary-conformance.ts +38 -0
  28. package/src/_lazy-cjk.test.ts +43 -0
  29. package/src/_lazy-cjk.ts +73 -0
  30. package/src/_lazy-engine-boundary.tsx +61 -0
  31. package/src/_lazy-math.test.ts +63 -0
  32. package/src/_lazy-math.ts +90 -0
  33. package/src/_lazy-mermaid-absent.test.ts +53 -0
  34. package/src/_lazy-mermaid.test.ts +15 -0
  35. package/src/_lazy-mermaid.ts +24 -1
  36. package/src/_mermaid-error-panel.test.tsx +50 -0
  37. package/src/_mermaid-error-panel.tsx +66 -0
  38. package/src/_persona-rive.tsx +62 -10
  39. package/src/_streamdown-i18n.ts +94 -22
  40. package/src/_streamdown-safety.ts +170 -0
  41. package/src/_theme-scope-store.test.ts +83 -0
  42. package/src/_theme-scope-store.ts +103 -0
  43. package/src/agent-event.stories.tsx +97 -0
  44. package/src/agent-event.test.tsx +145 -0
  45. package/src/agent-event.tsx +187 -0
  46. package/src/agent-timeline.stories.tsx +9 -1
  47. package/src/agent.test.tsx +47 -0
  48. package/src/agent.tsx +43 -24
  49. package/src/agentic-workspace.stories.tsx +27 -20
  50. package/src/artifact.tsx +17 -14
  51. package/src/asset-preview.test.tsx +40 -0
  52. package/src/asset-preview.tsx +83 -12
  53. package/src/attachments.tsx +7 -4
  54. package/src/audio-player.test.tsx +78 -0
  55. package/src/audio-player.tsx +243 -56
  56. package/src/audio-visualizer.stories.tsx +126 -0
  57. package/src/audio-visualizer.test.tsx +438 -0
  58. package/src/audio-visualizer.tsx +367 -0
  59. package/src/canvas.stories.tsx +150 -1
  60. package/src/chat-shell.stories.tsx +18 -3
  61. package/src/chat.stories.tsx +16 -2
  62. package/src/code-block.stories.tsx +9 -1
  63. package/src/code-block.test.tsx +100 -1
  64. package/src/code-block.tsx +166 -103
  65. package/src/commit.tsx +30 -41
  66. package/src/composer.stories.tsx +372 -9
  67. package/src/composer.test.tsx +357 -6
  68. package/src/composer.tsx +217 -35
  69. package/src/confirmation.stories.tsx +72 -1
  70. package/src/confirmation.test.tsx +216 -2
  71. package/src/confirmation.tsx +264 -4
  72. package/src/context-panel.stories.tsx +9 -1
  73. package/src/context-panel.tsx +3 -2
  74. package/src/conversation.stories.tsx +86 -2
  75. package/src/conversation.test.tsx +66 -0
  76. package/src/conversation.tsx +52 -9
  77. package/src/diff-view.stories.tsx +196 -0
  78. package/src/diff-view.test.tsx +238 -0
  79. package/src/diff-view.tsx +697 -0
  80. package/src/environment-variables.tsx +20 -37
  81. package/src/file-tree.test.tsx +21 -0
  82. package/src/file-tree.tsx +12 -2
  83. package/src/gallery.tsx +4 -2
  84. package/src/index.ts +14 -4
  85. package/src/inline-citation.tsx +5 -5
  86. package/src/jsx-preview.stories.tsx +2 -2
  87. package/src/jsx-preview.tsx +151 -43
  88. package/src/markdown-view.stories.tsx +92 -1
  89. package/src/markdown-view.test.tsx +237 -2
  90. package/src/markdown-view.tsx +157 -6
  91. package/src/message-compare.stories.tsx +175 -0
  92. package/src/message-compare.test.tsx +207 -0
  93. package/src/message-compare.tsx +453 -0
  94. package/src/message-form.stories.tsx +65 -4
  95. package/src/message-form.test.tsx +8 -2
  96. package/src/message-form.tsx +15 -7
  97. package/src/message-table.stories.tsx +2 -2
  98. package/src/message-table.test.tsx +7 -0
  99. package/src/message-table.tsx +8 -4
  100. package/src/message.stories.tsx +9 -1
  101. package/src/message.test.tsx +247 -1
  102. package/src/message.tsx +125 -15
  103. package/src/microcopy.test.tsx +40 -0
  104. package/src/{model-selector.stories.tsx → model-provider-logo.stories.tsx} +17 -8
  105. package/src/model-provider-logo.test.tsx +109 -0
  106. package/src/model-provider-logo.tsx +183 -0
  107. package/src/open-in-chat.tsx +50 -29
  108. package/src/package-info.tsx +12 -12
  109. package/src/permission-mode-select.stories.tsx +82 -0
  110. package/src/permission-mode-select.test.tsx +100 -0
  111. package/src/permission-mode-select.tsx +137 -0
  112. package/src/persona-missing-peer.test.tsx +54 -0
  113. package/src/persona.tsx +68 -22
  114. package/src/plan.stories.tsx +166 -0
  115. package/src/plan.test.tsx +267 -0
  116. package/src/plan.tsx +182 -20
  117. package/src/prompt-input-effort.stories.tsx +123 -0
  118. package/src/prompt-input-effort.test.tsx +83 -0
  119. package/src/prompt-input-effort.tsx +136 -0
  120. package/src/prompt-input-mode.stories.tsx +108 -0
  121. package/src/prompt-input-mode.test.tsx +99 -0
  122. package/src/prompt-input-mode.tsx +169 -0
  123. package/src/prompt-input-slash.stories.tsx +211 -0
  124. package/src/prompt-input-slash.test.tsx +262 -0
  125. package/src/prompt-input-slash.tsx +541 -0
  126. package/src/prompt-input.stories.tsx +2 -2
  127. package/src/prompt-input.test.tsx +67 -1
  128. package/src/prompt-input.tsx +42 -4
  129. package/src/queue.tsx +4 -4
  130. package/src/reasoning.tsx +42 -17
  131. package/src/sandbox.stories.tsx +9 -1
  132. package/src/sandbox.tsx +3 -3
  133. package/src/schema-display.test.tsx +56 -0
  134. package/src/schema-display.tsx +71 -37
  135. package/src/selection-toolbar.stories.tsx +9 -1
  136. package/src/session-header.stories.tsx +128 -0
  137. package/src/session-header.test.tsx +138 -0
  138. package/src/session-header.tsx +243 -0
  139. package/src/session-status-bar.stories.tsx +73 -0
  140. package/src/session-status-bar.test.tsx +94 -0
  141. package/src/session-status-bar.tsx +165 -0
  142. package/src/snippet.stories.tsx +9 -1
  143. package/src/snippet.test.tsx +6 -2
  144. package/src/snippet.tsx +14 -34
  145. package/src/speech-input.test.tsx +109 -0
  146. package/src/speech-input.tsx +31 -3
  147. package/src/stack-trace.tsx +24 -36
  148. package/src/streamdown-i18n.test.tsx +24 -1
  149. package/src/task.stories.tsx +13 -3
  150. package/src/templates-ai-assistant.stories.tsx +21 -1
  151. package/src/test-results.tsx +47 -27
  152. package/src/token-usage.stories.tsx +24 -0
  153. package/src/token-usage.test.tsx +92 -0
  154. package/src/{context.tsx → token-usage.tsx} +73 -59
  155. package/src/tool-result-card.stories.tsx +9 -1
  156. package/src/tool.stories.tsx +13 -3
  157. package/src/tool.test.tsx +65 -0
  158. package/src/tool.tsx +73 -30
  159. package/src/transcription.tsx +1 -1
  160. package/src/turn-status.stories.tsx +124 -0
  161. package/src/turn-status.test.tsx +74 -0
  162. package/src/turn-status.tsx +174 -0
  163. package/src/use-audio-level.ts +104 -0
  164. package/src/voice-selector.tsx +5 -5
  165. package/src/web-preview.test.tsx +51 -1
  166. package/src/web-preview.tsx +46 -10
  167. package/dist/_audio-player-media-chrome-KA5DY54G.js.map +0 -1
  168. package/dist/_flow-boundary-D63PJ65S.js.map +0 -1
  169. package/dist/_interactive-terminal-xterm-ATJ5EW3G.js +0 -11
  170. package/dist/_interactive-terminal-xterm-ATJ5EW3G.js.map +0 -1
  171. package/dist/_persona-rive-RFR2EUWP.js.map +0 -1
  172. package/src/_interactive-terminal-xterm.ts +0 -32
  173. package/src/blocks-ai-composer.stories.tsx +0 -83
  174. package/src/context.stories.tsx +0 -16
  175. package/src/interactive-terminal.stories.tsx +0 -165
  176. package/src/interactive-terminal.test.tsx +0 -448
  177. package/src/interactive-terminal.tsx +0 -444
  178. package/src/model-selector.test.tsx +0 -55
  179. package/src/model-selector.tsx +0 -225
  180. package/src/terminal.tsx +0 -244
@@ -0,0 +1,165 @@
1
+ "use client";
2
+
3
+ import { cn, useLocale } from "@elabs-ai/components-ui";
4
+ import { Cpu, Folder, GitBranch, Loader2Icon, PlugZapIcon } from "lucide-react";
5
+ import type { ComponentProps, ComponentType, ReactNode, SVGProps } from "react";
6
+ import { forwardRef } from "react";
7
+
8
+ export interface SessionStatusBarConnections {
9
+ /** Number of integrations/MCP servers currently connected. */
10
+ connected: number;
11
+ /** Total number of configured integrations/MCP servers. */
12
+ total: number;
13
+ /** Renders a spinner in place of the connection icon while establishing. */
14
+ connecting?: boolean;
15
+ }
16
+
17
+ export interface SessionStatusBarProps extends ComponentProps<"div"> {
18
+ /** Current workspace/project name. */
19
+ workspace?: string;
20
+ /** Current git branch. */
21
+ branch?: string;
22
+ /** Active model id/label. */
23
+ model?: string;
24
+ /** Integration/MCP connection progress; renders a spinner while connecting. */
25
+ connections?: SessionStatusBarConnections;
26
+ /**
27
+ * Docks another component alongside the ambient segments — most often
28
+ * `<TokenUsage />` (`@elabs-ai/components-ai`), so token usage/cost keeps
29
+ * its one owner (`token-usage.tsx`, tokenlens) instead of being re-implemented
30
+ * here. Rendered flush to the end of the bar.
31
+ */
32
+ children?: ReactNode;
33
+ }
34
+
35
+ type SegmentIcon = ComponentType<SVGProps<SVGSVGElement>>;
36
+
37
+ const SessionStatusBarSegment = ({
38
+ icon: Icon,
39
+ slot,
40
+ value,
41
+ }: {
42
+ icon: SegmentIcon;
43
+ slot: string;
44
+ value: string;
45
+ }) => (
46
+ <span className="inline-flex min-w-0 items-center gap-1.5" data-slot={slot}>
47
+ <Icon aria-hidden="true" className="size-3.5 shrink-0" />
48
+ <span className="truncate">{value}</span>
49
+ </span>
50
+ );
51
+
52
+ /**
53
+ * SessionStatusBar — the ambient session row: workspace, branch, model, and
54
+ * integration-connection progress (#105). Every segment is independently
55
+ * optional and renders only when its prop is supplied; with nothing supplied
56
+ * the bar renders nothing at all, not an empty shell.
57
+ *
58
+ * Docks `Context` (or any other node) via `children` rather than
59
+ * re-implementing token-usage/cost maths — `context.tsx` stays the one owner
60
+ * of that calculation (tokenlens).
61
+ */
62
+ export const SessionStatusBar = forwardRef<HTMLDivElement, SessionStatusBarProps>(
63
+ function SessionStatusBar(
64
+ { workspace, branch, model, connections, children, className, ...props },
65
+ ref,
66
+ ) {
67
+ const { formatNumber, t } = useLocale();
68
+ const hasContent = Boolean(workspace ?? branch ?? model ?? connections ?? children);
69
+
70
+ if (!hasContent) {
71
+ return null;
72
+ }
73
+
74
+ // Computed once and reused for BOTH the connections region's name (for a
75
+ // user who navigates to it deliberately) and its real, non-hidden text
76
+ // content — what a polite announcement actually reads on change. An
77
+ // aria-label alone is NOT that (#155): a live region announces its
78
+ // content, not its own accessible name.
79
+ const connectionsAnnouncement = connections
80
+ ? connections.connecting
81
+ ? t("ai.sessionStatusBar.connecting")
82
+ : t("ai.sessionStatusBar.connections", {
83
+ connected: connections.connected,
84
+ total: connections.total,
85
+ })
86
+ : undefined;
87
+
88
+ return (
89
+ <div
90
+ ref={ref}
91
+ className={cn(
92
+ "flex w-full items-center gap-4 border-t bg-surface-muted px-3 py-1.5 text-meta text-muted-foreground",
93
+ className,
94
+ )}
95
+ data-slot="session-status-bar"
96
+ {...props}
97
+ >
98
+ {workspace ? (
99
+ <SessionStatusBarSegment
100
+ icon={Folder}
101
+ slot="session-status-bar-workspace"
102
+ value={workspace}
103
+ />
104
+ ) : null}
105
+ {branch ? (
106
+ <SessionStatusBarSegment
107
+ icon={GitBranch}
108
+ slot="session-status-bar-branch"
109
+ value={branch}
110
+ />
111
+ ) : null}
112
+ {model ? (
113
+ <SessionStatusBarSegment icon={Cpu} slot="session-status-bar-model" value={model} />
114
+ ) : null}
115
+ {connections ? (
116
+ <span
117
+ aria-live="polite"
118
+ className="inline-flex shrink-0 items-center gap-1.5"
119
+ data-slot="session-status-bar-connections"
120
+ role="status"
121
+ >
122
+ {connections.connecting ? (
123
+ <Loader2Icon
124
+ aria-hidden="true"
125
+ className="size-3.5 shrink-0 animate-spin motion-reduce:animate-none"
126
+ />
127
+ ) : (
128
+ <PlugZapIcon aria-hidden="true" className="size-3.5 shrink-0" />
129
+ )}
130
+ <span aria-hidden="true" className="tabular-nums">
131
+ {formatNumber(connections.connected)}/{formatNumber(connections.total)}
132
+ </span>
133
+ {/* The live region's only NON-hidden text — mirrors
134
+ terminal-status-bar.tsx's aria-hidden-visual +
135
+ sr-only-sentence pair (#155). Without this, every polite
136
+ update announced an empty string: the counter above is
137
+ aria-hidden, so it was the region's ONLY text content.
138
+ Deliberately no `aria-label` here (unlike
139
+ terminal-status-bar.tsx, where the label lives on the OUTER
140
+ bar as a generic region name distinct from any segment's
141
+ content): an explicit `aria-label` here would give this
142
+ span an ACCESSIBLE NAME byte-identical to its announced
143
+ CONTENT, which risks a double announcement ("Connecting…
144
+ Connecting…") on ATs that speak a live region's name
145
+ alongside its changed content. Leaving `aria-label` off
146
+ does NOT relocate the name to this text — `role="status"`
147
+ is `nameFrom: author`, so it never falls back to content
148
+ the way e.g. a `button` does. The span simply has no
149
+ accessible name, which is fine: no acceptance criterion
150
+ needs one here, only the announced content does. */}
151
+ <span className="sr-only">{connectionsAnnouncement}</span>
152
+ </span>
153
+ ) : null}
154
+ {children ? (
155
+ <div
156
+ className="ms-auto flex shrink-0 items-center"
157
+ data-slot="session-status-bar-context"
158
+ >
159
+ {children}
160
+ </div>
161
+ ) : null}
162
+ </div>
163
+ );
164
+ },
165
+ );
@@ -3,7 +3,15 @@ import { Snippet, SnippetAddon, SnippetCopyButton, SnippetInput } from "./snippe
3
3
  const meta = {
4
4
  title: "AI/Snippet",
5
5
  component: Snippet,
6
- parameters: { layout: "padded" },
6
+ parameters: {
7
+ layout: "padded",
8
+ docs: {
9
+ description: {
10
+ component:
11
+ "A single-line copyable COMMAND — an `InputGroup` in a mono face wrapping a read-only value and a copy button (`npm i …`, a curl line, an id). Multi-line highlighted source with a language picker is `AI/CodeBlock`; see [Choosing between similar components](?path=/docs/docs-choosing-between-similar-components--docs). Nothing here highlights: `Snippet` never loads Shiki.",
12
+ },
13
+ },
14
+ },
7
15
  } satisfies Meta<typeof Snippet>;
8
16
  export default meta;
9
17
  type Story = StoryObj<typeof meta>;
@@ -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,19 +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 { useCopyToClipboard, useLocale } from "@elabs-ai/components-ui";
6
7
  import { cn } from "@elabs-ai/components-ui/lib/cn";
7
8
  import { AlertTriangleIcon, CheckIcon, ChevronDownIcon, CopyIcon } from "lucide-react";
8
9
  import type { ComponentProps } from "react";
9
- import {
10
- createContext,
11
- memo,
12
- useCallback,
13
- useContext,
14
- useEffect,
15
- useMemo,
16
- useRef,
17
- useState,
18
- } from "react";
10
+ import { createContext, memo, useCallback, useContext, useMemo } from "react";
19
11
 
20
12
  // Regex patterns for parsing stack traces
21
13
  const STACK_FRAME_WITH_PARENS_REGEX = /^at\s+(.+?)\s+\((.+):(\d+):(\d+)\)$/;
@@ -187,7 +179,7 @@ export const StackTrace = memo(
187
179
  <StackTraceContext.Provider value={contextValue}>
188
180
  <div
189
181
  className={cn(
190
- "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",
191
183
  className,
192
184
  )}
193
185
  {...props}
@@ -236,7 +228,9 @@ export const StackTraceErrorType = memo(
236
228
  const { trace } = useStackTrace();
237
229
 
238
230
  return (
239
- <span className={cn("shrink-0 font-semibold text-destructive", className)} {...props}>
231
+ // #124: the error type NAME is running text (e.g. "TypeError"), not a
232
+ // mark — ink rung, unlike the AlertTriangleIcon mark above.
233
+ <span className={cn("shrink-0 font-semibold text-destructive-text", className)} {...props}>
240
234
  {children ?? trace.errorType}
241
235
  </span>
242
236
  );
@@ -290,42 +284,35 @@ export const StackTraceCopyButton = memo(
290
284
  ({
291
285
  onCopy,
292
286
  onError,
293
- timeout = 2000,
287
+ timeout,
294
288
  className,
295
289
  children,
290
+ "aria-label": ariaLabel,
296
291
  ...props
297
292
  }: StackTraceCopyButtonProps) => {
298
- const [isCopied, setIsCopied] = useState(false);
299
- const timeoutRef = useRef<number>(0);
293
+ const { t } = useLocale();
300
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
+ );
301
301
 
302
302
  const copyToClipboard = useCallback(async () => {
303
- if (typeof window === "undefined" || !navigator?.clipboard?.writeText) {
304
- onError?.(new Error("Clipboard API not available"));
305
- return;
306
- }
307
-
308
- try {
309
- await navigator.clipboard.writeText(raw);
310
- setIsCopied(true);
303
+ const ok = await copy(raw);
304
+ if (ok) {
311
305
  onCopy?.();
312
- timeoutRef.current = window.setTimeout(() => setIsCopied(false), timeout);
313
- } catch (error) {
314
- onError?.(error as Error);
306
+ } else {
307
+ onError?.(new Error("Clipboard API not available"));
315
308
  }
316
- }, [raw, onCopy, onError, timeout]);
317
-
318
- useEffect(
319
- () => () => {
320
- window.clearTimeout(timeoutRef.current);
321
- },
322
- [],
323
- );
309
+ }, [copy, raw, onCopy, onError]);
324
310
 
325
311
  const Icon = isCopied ? CheckIcon : CopyIcon;
326
312
 
327
313
  return (
328
314
  <Button
315
+ aria-label={ariaLabel ?? t("copy")}
329
316
  className={cn("size-7", className)}
330
317
  onClick={copyToClipboard}
331
318
  size="icon"
@@ -426,6 +413,7 @@ FilePathButton.displayName = "FilePathButton";
426
413
  export const StackTraceFrames = memo(
427
414
  ({ className, showInternalFrames = true, ...props }: StackTraceFramesProps) => {
428
415
  const { trace, onFilePathClick } = useStackTrace();
416
+ const { t } = useLocale();
429
417
 
430
418
  const framesToShow = showInternalFrames
431
419
  ? trace.frames
@@ -436,7 +424,7 @@ export const StackTraceFrames = memo(
436
424
  {framesToShow.map((frame) => (
437
425
  <div
438
426
  className={cn(
439
- "text-xs",
427
+ "text-meta",
440
428
  frame.isInternal ? "text-muted-foreground/50" : "text-foreground/90",
441
429
  )}
442
430
  key={frame.raw}
@@ -460,7 +448,7 @@ export const StackTraceFrames = memo(
460
448
  </div>
461
449
  ))}
462
450
  {framesToShow.length === 0 && (
463
- <div className="text-muted-foreground text-xs">No stack frames</div>
451
+ <div className="text-muted-foreground text-meta">{t("ai.stackTrace.empty")}</div>
464
452
  )}
465
453
  </div>
466
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`/
@@ -129,7 +152,7 @@ describe("Streamdown code-block theme (#315 follow-up)", () => {
129
152
  // (github-light's keyword is #d73a49, github-dark's is #ff7b72).
130
153
  expect(span.getAttribute("style")).toContain("--sdm-c: #133796");
131
154
  // Both dual-theme slots are pinned to the SAME active-theme color (#315
132
- // follow-up design: brand-ui has three themes, not the two Shiki's
155
+ // follow-up design: brand-ui has more themes than the two Shiki's
133
156
  // light/dark mechanism expects), so `.dark` never resolves a different palette.
134
157
  expect(span.getAttribute("style")).toContain("--shiki-dark: #133796");
135
158
  });
@@ -1,8 +1,18 @@
1
1
  import type { Meta, StoryObj } from "@storybook/react-vite";
2
2
  import { Task, TaskContent, TaskItem, TaskItemFile, TaskTrigger } from "./task";
3
- const meta = { title: "AI/Task", component: Task, parameters: { layout: "padded" } } satisfies Meta<
4
- typeof Task
5
- >;
3
+ const meta = {
4
+ title: "AI/Task",
5
+ component: Task,
6
+ parameters: {
7
+ layout: "padded",
8
+ docs: {
9
+ description: {
10
+ component:
11
+ "The CHAT run summary — what got done, collapsed by default so a settled transcript keeps only its focal surfaces open. The console counterpart of an agent checklist is `Terminal/TerminalTodoList`; see [Choosing between similar components](?path=/docs/docs-choosing-between-similar-components--docs). The body rides the canonical `AI/AgentTimeline` rail rather than a hand-rolled one, and what the agent intends to do BEFORE it runs is `AI/Plan`.",
12
+ },
13
+ },
14
+ },
15
+ } satisfies Meta<typeof Task>;
6
16
  export default meta;
7
17
  type Story = StoryObj<typeof meta>;
8
18
  // Collapsed by default since #192; `defaultOpen` here only so the docs render