@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
@@ -4,7 +4,7 @@ import { fileURLToPath } from "node:url";
4
4
  import type { CSSProperties } from "react";
5
5
  import { afterEach, describe, expect, it } from "vitest";
6
6
  import { render, waitFor } from "@testing-library/react";
7
- import { CodeBlock } from "./code-block";
7
+ import { CodeBlock, CodeBlockCopyButton, highlightCode } from "./code-block";
8
8
 
9
9
  const __dirname = dirname(fileURLToPath(import.meta.url));
10
10
 
@@ -189,3 +189,102 @@ describe("CodeBlock isStreaming (#269, loading-states.md)", () => {
189
189
  expect(container.querySelector('[role="status"]')).toBeNull();
190
190
  });
191
191
  });
192
+
193
+ describe("CodeBlockCopyButton accessible name (a11y review)", () => {
194
+ it("has an aria-label so the icon-only button has an accessible name", () => {
195
+ const { container } = render(
196
+ <CodeBlock code="const a = 1;" language="tsx">
197
+ <CodeBlockCopyButton />
198
+ </CodeBlock>,
199
+ );
200
+ expect(container.querySelector("button")).toHaveAccessibleName("Copy");
201
+ });
202
+
203
+ it("lets a consumer override the label", () => {
204
+ const { container } = render(
205
+ <CodeBlock code="const a = 1;" language="tsx">
206
+ <CodeBlockCopyButton aria-label="Copy snippet" />
207
+ </CodeBlock>,
208
+ );
209
+ expect(container.querySelector("button")).toHaveAccessibleName("Copy snippet");
210
+ });
211
+ });
212
+
213
+ // ─── `highlightCode` awaited helper for the perf-fix regression locks below ──
214
+
215
+ function highlightAsync(
216
+ code: string,
217
+ language: Parameters<typeof highlightCode>[1],
218
+ el?: Element | null,
219
+ skipCache = false,
220
+ ): Promise<NonNullable<ReturnType<typeof highlightCode>>> {
221
+ return new Promise((resolve) => {
222
+ const cached = highlightCode(code, language, (result) => resolve(result), el, skipCache);
223
+ if (cached) resolve(cached);
224
+ });
225
+ }
226
+
227
+ const flattenTokenContent = (result: NonNullable<ReturnType<typeof highlightCode>>): string =>
228
+ result.tokens.map((line) => line.map((token) => token.content).join("")).join("\n");
229
+
230
+ describe("highlightCode cache key — no hash collision (perf review 1.4b/1.4c)", () => {
231
+ it("does not collide two >200-char strings sharing a length, prefix and suffix but differing in the middle", async () => {
232
+ const prefix = "a".repeat(150);
233
+ const suffix = "b".repeat(150);
234
+ // Same length, same first/last 100 chars — the OLD `length:first100:last100`
235
+ // cache key collided on exactly this shape (a mid-string edit), showing
236
+ // whichever of the two resolved second on top of BOTH code blocks.
237
+ const codeA = `${prefix}1${suffix}`;
238
+ const codeB = `${prefix}2${suffix}`;
239
+
240
+ await highlightAsync(codeA, "tsx");
241
+ await highlightAsync(codeB, "tsx");
242
+
243
+ // Re-requesting codeA must still return a SYNCHRONOUS cache hit for its
244
+ // OWN content — never codeB's.
245
+ const againA = highlightCode(codeA, "tsx");
246
+ expect(againA).not.toBeNull();
247
+ expect(flattenTokenContent(againA!)).toBe(codeA);
248
+ expect(flattenTokenContent(againA!)).not.toBe(codeB);
249
+ });
250
+ });
251
+
252
+ describe("highlightCode tokensCache is bounded (perf review 1.4a — LRU ~200 entries)", () => {
253
+ it("evicts the least-recently-used entry once the cache exceeds its cap", async () => {
254
+ const codes = Array.from({ length: 205 }, (_, i) => `const lruEvictionProbe_${i} = ${i};`);
255
+
256
+ for (const code of codes) {
257
+ // Sequential on purpose: each must actually populate the cache (and
258
+ // become the most-recently-used entry) before the next one runs.
259
+
260
+ await highlightAsync(code, "tsx");
261
+ }
262
+
263
+ // The very first entry inserted is the least-recently-used once 205 > the
264
+ // 200-entry cap — it must have been evicted, so re-requesting it is a
265
+ // cache MISS (highlightCode returns null synchronously and kicks off a
266
+ // fresh async highlight instead).
267
+ expect(highlightCode(codes[0] as string, "tsx")).toBeNull();
268
+
269
+ // The most recently inserted entry is still a synchronous cache hit.
270
+ expect(highlightCode(codes[204] as string, "tsx")).not.toBeNull();
271
+ });
272
+ });
273
+
274
+ describe("highlightCode skipCache (perf review 1.4a — never permanently cache mid-stream)", () => {
275
+ it("does not persist a highlight into the shared cache when skipCache is set", async () => {
276
+ const code = "const streamingSkipCacheProbe = 'a';";
277
+ await highlightAsync(code, "tsx", undefined, true);
278
+
279
+ // A later, non-streaming request for the EXACT same code is a cache
280
+ // MISS — the streaming pass never wrote it in.
281
+ expect(highlightCode(code, "tsx")).toBeNull();
282
+ });
283
+
284
+ it("still persists an ordinary (non-streaming) highlight", async () => {
285
+ const code = "const nonStreamingCacheProbe = 'b';";
286
+ await highlightAsync(code, "tsx");
287
+
288
+ expect(highlightCode(code, "tsx")).not.toBeNull();
289
+ });
290
+ });
@@ -1,6 +1,6 @@
1
1
  "use client";
2
2
 
3
- import { Button, useLocale } from "@elabs-ai/components-ui";
3
+ import { Button, useCopyToClipboard, useLocale } from "@elabs-ai/components-ui";
4
4
  import {
5
5
  Select,
6
6
  SelectContent,
@@ -20,6 +20,7 @@ import {
20
20
  useEffect,
21
21
  useLayoutEffect,
22
22
  useMemo,
23
+ useReducer,
23
24
  useRef,
24
25
  useState,
25
26
  } from "react";
@@ -27,17 +28,7 @@ import type { BundledLanguage, BundledTheme, HighlighterGeneric, ThemedToken } f
27
28
  import { createHighlighter } from "shiki";
28
29
 
29
30
  import { buildCodeBlockTheme, codeBlockThemeId, getThemeScopeKey } from "./_code-block-theme";
30
-
31
- /**
32
- * Nearest ancestor (inclusive) carrying `data-theme`, defaulting to `<html>`.
33
- * Lets a CodeBlock rendered inside a region-scoped `<div data-theme="…">`
34
- * (a supported `ThemeProvider`/decorator pattern — see
35
- * @.claude/rules/theming.md) resolve THAT region's `--code-*` tokens instead
36
- * of always the document root's (#315 follow-up).
37
- */
38
- const getThemeScope = (el: Element | null): Element | null =>
39
- el?.closest("[data-theme]") ??
40
- (typeof document !== "undefined" ? document.documentElement : null);
31
+ import { getThemeScope, useThemeScopeRevision } from "./_theme-scope-store";
41
32
 
42
33
  // Shiki uses bitflags for font styles: 1=italic, 2=bold, 4=underline
43
34
  // oxlint-disable-next-line eslint(no-bitwise)
@@ -158,12 +149,54 @@ const highlighterCache = new Map<
158
149
  Promise<HighlighterGeneric<BundledLanguage, BundledTheme>>
159
150
  >();
160
151
 
161
- // Token cache
152
+ // Token cache — bounded to the `MAX_TOKENS_CACHE_ENTRIES` most recently used
153
+ // entries (perf review 1.4a). Unbounded growth showed up while streaming: a
154
+ // growing code string re-tokenizes on every token, and every intermediate
155
+ // string used to get its own permanent cache entry — O(n²) memory that was
156
+ // never released for the lifetime of the tab.
157
+ const MAX_TOKENS_CACHE_ENTRIES = 200;
162
158
  const tokensCache = new Map<string, TokenizedCode>();
163
159
 
160
+ /** Reads `key`, marking it most-recently-used (moves it to the end). */
161
+ const tokensCacheGet = (key: string): TokenizedCode | undefined => {
162
+ const value = tokensCache.get(key);
163
+ if (value === undefined) return undefined;
164
+ tokensCache.delete(key);
165
+ tokensCache.set(key, value);
166
+ return value;
167
+ };
168
+
169
+ /** Writes `key`, evicting the least-recently-used entries over the cap. */
170
+ const tokensCacheSet = (key: string, value: TokenizedCode): void => {
171
+ tokensCache.delete(key);
172
+ tokensCache.set(key, value);
173
+ while (tokensCache.size > MAX_TOKENS_CACHE_ENTRIES) {
174
+ const oldestKey = tokensCache.keys().next().value;
175
+ if (oldestKey === undefined) break;
176
+ tokensCache.delete(oldestKey);
177
+ }
178
+ };
179
+
164
180
  // Subscribers for async token updates
165
181
  const subscribers = new Map<string, Set<(result: TokenizedCode) => void>>();
166
182
 
183
+ // FNV-1a 32-bit — cheap, and (unlike the previous length + first/last 100
184
+ // chars key) hashes the FULL string, so two different strings of the same
185
+ // length sharing a prefix and suffix longer than 100 chars (an edit to the
186
+ // MIDDLE of a >200-char block — the common case for a diff or a streamed
187
+ // correction) no longer collide onto the same cache entry and show stale,
188
+ // wrong-content highlighting (#perf 1.4b).
189
+ const hashCode = (value: string): string => {
190
+ let hash = 0x811c9dc5;
191
+ for (let i = 0; i < value.length; i++) {
192
+ // oxlint-disable-next-line eslint(no-bitwise)
193
+ hash ^= value.charCodeAt(i);
194
+ hash = Math.imul(hash, 0x01000193);
195
+ }
196
+ // oxlint-disable-next-line eslint(no-bitwise)
197
+ return (hash >>> 0).toString(36);
198
+ };
199
+
167
200
  // The theme id is part of the cache key (#315) — the SAME code+language must
168
201
  // re-tokenize (and re-cache) when the active brand theme changes, or a code
169
202
  // block would keep showing stale colors from the theme active when it was
@@ -176,11 +209,8 @@ const subscribers = new Map<string, Set<(result: TokenizedCode) => void>>();
176
209
  // the validated name would let the pre-mount render's `:root` colors poison
177
210
  // the cache under the key `ThemeProvider` later writes explicitly, and they'd
178
211
  // never be replaced.
179
- const getTokensCacheKey = (code: string, language: BundledLanguage, themeId: string) => {
180
- const start = code.slice(0, 100);
181
- const end = code.length > 100 ? code.slice(-100) : "";
182
- return `${themeId}:${language}:${code.length}:${start}:${end}`;
183
- };
212
+ const getTokensCacheKey = (code: string, language: BundledLanguage, themeId: string) =>
213
+ `${themeId}:${language}:${code.length}:${hashCode(code)}`;
184
214
 
185
215
  const getHighlighter = (
186
216
  language: BundledLanguage,
@@ -225,18 +255,27 @@ const createRawTokens = (code: string): TokenizedCode => ({
225
255
  // whose active brand theme's `--code-*` tokens the highlighter derives its
226
256
  // colors from. Passing a descendant of a region-scoped `<div data-theme="…">`
227
257
  // resolves THAT region's theme instead of the document root's.
258
+ //
259
+ // `skipCache` (fifth, purely-additive parameter, default `false`) is set by a
260
+ // caller mid-stream (perf review 1.4a): a streaming code block re-tokenizes a
261
+ // GROWING string on every token, so every intermediate value is, by
262
+ // definition, never seen again — permanently caching it only pays rent
263
+ // (bounded now by the LRU cap, but still pure waste) without ever paying off
264
+ // with a hit. A cache HIT (the final, settled string matches an
265
+ // already-cached entry) is still honored either way.
228
266
  export const highlightCode = (
229
267
  code: string,
230
268
  language: BundledLanguage,
231
269
  // oxlint-disable-next-line eslint-plugin-promise(prefer-await-to-callbacks)
232
270
  callback?: (result: TokenizedCode) => void,
233
271
  el?: Element | null,
272
+ skipCache = false,
234
273
  ): TokenizedCode | null => {
235
274
  const themeId = codeBlockThemeId(getThemeScopeKey(el));
236
275
  const tokensCacheKey = getTokensCacheKey(code, language, themeId);
237
276
 
238
277
  // Return cached result if available
239
- const cached = tokensCache.get(tokensCacheKey);
278
+ const cached = tokensCacheGet(tokensCacheKey);
240
279
  if (cached) {
241
280
  return cached;
242
281
  }
@@ -269,8 +308,11 @@ export const highlightCode = (
269
308
  tokens: result.tokens,
270
309
  };
271
310
 
272
- // Cache the result
273
- tokensCache.set(tokensCacheKey, tokenized);
311
+ // Cache the result — unless the caller told us it is mid-stream, in
312
+ // which case this exact string is very unlikely to recur.
313
+ if (!skipCache) {
314
+ tokensCacheSet(tokensCacheKey, tokenized);
315
+ }
274
316
 
275
317
  // Notify all subscribers
276
318
  const subs = subscribers.get(tokensCacheKey);
@@ -314,12 +356,12 @@ const CodeBlockBody = memo(
314
356
 
315
357
  return (
316
358
  <pre
317
- className={cn("m-0 p-4 text-sm", wrap && "whitespace-pre-wrap break-words", className)}
359
+ className={cn("m-0 p-4 text-body", wrap && "whitespace-pre-wrap break-words", className)}
318
360
  style={preStyle}
319
361
  >
320
362
  <code
321
363
  className={cn(
322
- "font-mono text-sm",
364
+ "font-mono text-body",
323
365
  showLineNumbers && "[counter-increment:line_0] [counter-reset:line]",
324
366
  )}
325
367
  >
@@ -367,7 +409,7 @@ export const CodeBlockHeader = ({
367
409
  }: HTMLAttributes<HTMLDivElement>) => (
368
410
  <div
369
411
  className={cn(
370
- "flex items-center justify-between border-b bg-muted/80 px-3 py-2 text-muted-foreground text-xs",
412
+ "flex items-center justify-between border-b bg-muted/80 px-3 py-2 text-muted-foreground text-meta",
371
413
  className,
372
414
  )}
373
415
  {...props}
@@ -406,45 +448,46 @@ export const CodeBlockActions = ({
406
448
  </div>
407
449
  );
408
450
 
451
+ // Streaming re-highlight throttle (perf review 1.4e / §3.2): re-tokenizing
452
+ // on every single streamed token is O(n²) work over the life of a response.
453
+ // At most one highlight pass per window, ALWAYS with a trailing call so the
454
+ // final, settled string still gets highlighted the moment streaming stops.
455
+ const STREAM_HIGHLIGHT_THROTTLE_MS = 200;
456
+
409
457
  export const CodeBlockContent = ({
410
458
  code,
411
459
  language,
412
460
  showLineNumbers = false,
413
461
  wrap = false,
462
+ isStreaming = false,
414
463
  }: {
415
464
  code: string;
416
465
  language: BundledLanguage;
417
466
  showLineNumbers?: boolean;
418
467
  wrap?: boolean;
468
+ isStreaming?: boolean;
419
469
  }) => {
420
470
  // Track the active brand theme (#315), SCOPED to this code block's own
421
471
  // subtree (`getThemeScope`) rather than always `<html>` — so a CodeBlock
422
472
  // nested inside a region-scoped `<div data-theme="dark">` (a supported
423
473
  // ThemeProvider/decorator pattern) picks up THAT region's `--code-*` tokens,
424
- // not the document root's. A MutationObserver on the resolved scope element
425
- // re-derives the Shiki theme and any already-highlighted code — whenever
426
- // that region's `data-theme` changes.
474
+ // not the document root's. `useThemeScopeRevision` (`_theme-scope-store.ts`)
475
+ // shares ONE MutationObserver per scope element across every subscriber
476
+ // watching it, instead of one per `CodeBlock` instance (perf review §3.3).
427
477
  const scopeRef = useRef<HTMLDivElement>(null);
428
- const [themeRevision, setThemeRevision] = useState(0);
429
-
478
+ // The ref only attaches after the first commit, so the scope resolved
479
+ // during the initial render (before mount) may have fallen back to
480
+ // `<html>`. Force one more render right after mount — synchronously, before
481
+ // paint, via `useLayoutEffect` — so a scoped code block never flashes the
482
+ // document root's colors first; `useThemeScopeRevision` re-subscribes to
483
+ // the now-correct scope on that render.
484
+ const [, forceMountRerender] = useReducer((tick: number) => tick + 1, 0);
430
485
  useLayoutEffect(() => {
431
- if (typeof document === "undefined") return;
432
- // The ref only attaches after this first commit, so the scope resolved
433
- // during the initial render (before mount) may have fallen back to
434
- // `<html>`. Bump the revision now — synchronously, before paint, via
435
- // `useLayoutEffect` — so a scoped code block never flashes the document
436
- // root's colors first.
437
- const scope = getThemeScope(scopeRef.current);
438
- setThemeRevision((r) => r + 1);
439
- const observer = new MutationObserver(() => setThemeRevision((r) => r + 1));
440
- observer.observe(scope ?? document.documentElement, {
441
- attributes: true,
442
- attributeFilter: ["data-theme"],
443
- });
444
- return () => observer.disconnect();
486
+ forceMountRerender();
445
487
  }, []);
446
488
 
447
489
  const scopeEl = getThemeScope(scopeRef.current);
490
+ const themeRevision = useThemeScopeRevision(scopeRef.current);
448
491
  // Keyed on the RAW `data-theme` scope, not the validated theme name (#315
449
492
  // follow-up) — see `_code-block-theme.ts`'s module doc comment. An unset
450
493
  // attribute (the pre-mount render) and an explicit `data-theme="light"`
@@ -452,25 +495,27 @@ export const CodeBlockContent = ({
452
495
  // mean this memo's dependency doesn't CHANGE across that mutation and the
453
496
  // stale `:root`-tokenized colors would never be recomputed once
454
497
  // `ThemeProvider` mounts and writes the attribute explicitly.
455
- // eslint-disable-next-line react-hooks/exhaustive-deps -- themeRevision is the trigger; the scope key itself is re-read live off scopeEl.
456
- const themeScopeKey = useMemo(() => getThemeScopeKey(scopeEl), [themeRevision]);
498
+ const themeScopeKey = useMemo(
499
+ () => getThemeScopeKey(scopeEl),
500
+ // eslint-disable-next-line react-hooks/exhaustive-deps -- themeRevision is the trigger; scopeEl's own identity already changes across the post-mount re-render above.
501
+ [themeRevision, scopeEl],
502
+ );
457
503
 
458
504
  // Memoized raw tokens for immediate display
459
505
  const rawTokens = useMemo(() => createRawTokens(code), [code]);
460
506
 
461
- // Synchronous cache lookupavoids setState in effect for cached results.
462
- // `themeScopeKey` isn't read directly below (highlightCode re-derives it from
463
- // `scopeEl` itself), but it MUST stay a dependency: a theme switch mutates
464
- // the SAME scope element's `data-theme` attribute, so `scopeEl`'s object
465
- // identity never changes `themeScopeKey` (keyed on `themeRevision`) is the
466
- // only signal that tells this memo to re-tokenize.
467
- const syncTokens = useMemo(
468
- () => highlightCode(code, language, undefined, scopeEl) ?? rawTokens,
469
- // eslint-disable-next-line react-hooks/exhaustive-deps -- themeScopeKey is required to trigger recompute; see comment above.
470
- [code, language, themeScopeKey, scopeEl, rawTokens],
471
- );
507
+ // Synchronous cache PEEKa plain, side-effect-free `Map.get`, never a
508
+ // call into `highlightCode` (the OLD code called it here with no callback,
509
+ // AND again in the effect below with one; on a cache miss both calls
510
+ // independently kicked off `getHighlighter(language).then(...)` and
511
+ // computed `codeToTokens` for the SAME code twice perf review 1.4d).
512
+ // Highlighting is now triggered from exactly ONE place: the effect.
513
+ const themeId = codeBlockThemeId(themeScopeKey);
514
+ const tokensCacheKey = getTokensCacheKey(code, language, themeId);
515
+ const syncTokens = tokensCache.get(tokensCacheKey) ?? rawTokens;
472
516
 
473
- // Async highlighting result (populated after shiki loads)
517
+ // Async highlighting result (populated after shiki loads, or synchronously
518
+ // inside the effect on a cache hit).
474
519
  const [asyncTokens, setAsyncTokens] = useState<TokenizedCode | null>(null);
475
520
  const asyncKeyRef = useRef({ code, language, themeScopeKey });
476
521
 
@@ -484,35 +529,62 @@ export const CodeBlockContent = ({
484
529
  setAsyncTokens(null);
485
530
  }
486
531
 
532
+ // Last time THIS instance actually ran a highlight pass — the throttle
533
+ // clock while `isStreaming` (perf review 1.4e).
534
+ const lastHighlightAtRef = useRef(0);
535
+
487
536
  useEffect(() => {
488
537
  let cancelled = false;
538
+ let timeoutId: ReturnType<typeof setTimeout> | undefined;
539
+
540
+ const runHighlight = () => {
541
+ lastHighlightAtRef.current = Date.now();
542
+ // `highlightCode` returns the tokenized result SYNCHRONOUSLY on a cache
543
+ // hit (and never invokes the callback in that case — see its early
544
+ // `if (cached) return cached` branch). That cache hit is the COMMON case
545
+ // right after a theme switch (#315): the highlighter for this language is
546
+ // already loaded, so re-tokenizing for the new theme resolves within a
547
+ // microtask — often before this effect even runs — and without this
548
+ // direct check `asyncTokens` would stay null forever, stranding the
549
+ // code block on its raw/unhighlighted fallback after every theme change.
550
+ const cached = highlightCode(
551
+ code,
552
+ language,
553
+ (result) => {
554
+ if (!cancelled) {
555
+ setAsyncTokens(result);
556
+ }
557
+ },
558
+ scopeEl,
559
+ isStreaming,
560
+ );
561
+ if (cached && !cancelled) {
562
+ setAsyncTokens(cached);
563
+ }
564
+ };
489
565
 
490
- // `highlightCode` returns the tokenized result SYNCHRONOUSLY on a cache
491
- // hit (and never invokes the callback in that case — see its early
492
- // `if (cached) return cached` branch). That cache hit is the COMMON case
493
- // right after a theme switch (#315): the highlighter for this language is
494
- // already loaded, so re-tokenizing for the new theme resolves within a
495
- // microtask — often before this effect even runs — and without this
496
- // direct check `asyncTokens` would stay null forever, stranding the
497
- // code block on its raw/unhighlighted fallback after every theme change.
498
- const cached = highlightCode(
499
- code,
500
- language,
501
- (result) => {
502
- if (!cancelled) {
503
- setAsyncTokens(result);
504
- }
505
- },
506
- scopeEl,
507
- );
508
- if (cached && !cancelled) {
509
- setAsyncTokens(cached);
566
+ if (!isStreaming) {
567
+ // Not streaming: always highlight immediately (also the path a
568
+ // just-finished stream's LAST token takes, guaranteeing the final,
569
+ // settled code always gets a full, cacheable highlight even if the
570
+ // throttle skipped some of the tokens before it).
571
+ runHighlight();
572
+ } else {
573
+ const elapsed = Date.now() - lastHighlightAtRef.current;
574
+ if (elapsed >= STREAM_HIGHLIGHT_THROTTLE_MS) {
575
+ runHighlight();
576
+ } else {
577
+ timeoutId = setTimeout(runHighlight, STREAM_HIGHLIGHT_THROTTLE_MS - elapsed);
578
+ }
510
579
  }
511
580
 
512
581
  return () => {
513
582
  cancelled = true;
583
+ if (timeoutId !== undefined) {
584
+ clearTimeout(timeoutId);
585
+ }
514
586
  };
515
- }, [code, language, themeScopeKey, scopeEl]);
587
+ }, [code, language, themeScopeKey, scopeEl, isStreaming]);
516
588
 
517
589
  const tokenized = asyncTokens ?? syncTokens;
518
590
 
@@ -554,6 +626,7 @@ export const CodeBlock = ({
554
626
  language={language}
555
627
  showLineNumbers={showLineNumbers}
556
628
  wrap={wrap}
629
+ isStreaming={isStreaming}
557
630
  />
558
631
  {isStreaming ? (
559
632
  <div
@@ -578,44 +651,34 @@ export type CodeBlockCopyButtonProps = ComponentProps<typeof Button> & {
578
651
  export const CodeBlockCopyButton = ({
579
652
  onCopy,
580
653
  onError,
581
- timeout = 2000,
654
+ timeout,
582
655
  children,
583
656
  className,
584
657
  ...props
585
658
  }: CodeBlockCopyButtonProps) => {
586
- const [isCopied, setIsCopied] = useState(false);
587
- const timeoutRef = useRef<number>(0);
659
+ const { t } = useLocale();
588
660
  const { code } = useContext(CodeBlockContext);
661
+ // Shared implementation (`@elabs-ai/components-ui`) instead of a private
662
+ // copy of the same copy-to-clipboard state machine — this one used to
663
+ // duplicate `SnippetCopyButton`'s (issue-workflow.md dedupe finding).
664
+ const { copied: isCopied, copy } = useCopyToClipboard(
665
+ timeout === undefined ? undefined : { resetAfterMs: timeout },
666
+ );
589
667
 
590
668
  const copyToClipboard = useCallback(async () => {
591
- if (typeof window === "undefined" || !navigator?.clipboard?.writeText) {
669
+ const ok = await copy(code);
670
+ if (ok) {
671
+ onCopy?.();
672
+ } else {
592
673
  onError?.(new Error("Clipboard API not available"));
593
- return;
594
- }
595
-
596
- try {
597
- if (!isCopied) {
598
- await navigator.clipboard.writeText(code);
599
- setIsCopied(true);
600
- onCopy?.();
601
- timeoutRef.current = window.setTimeout(() => setIsCopied(false), timeout);
602
- }
603
- } catch (error) {
604
- onError?.(error as Error);
605
674
  }
606
- }, [code, onCopy, onError, timeout, isCopied]);
607
-
608
- useEffect(
609
- () => () => {
610
- window.clearTimeout(timeoutRef.current);
611
- },
612
- [],
613
- );
675
+ }, [copy, code, onCopy, onError]);
614
676
 
615
677
  const Icon = isCopied ? CheckIcon : CopyIcon;
616
678
 
617
679
  return (
618
680
  <Button
681
+ aria-label={t("copy")}
619
682
  className={cn("shrink-0", className)}
620
683
  onClick={copyToClipboard}
621
684
  size="icon"
@@ -647,7 +710,7 @@ export const CodeBlockLanguageSelectorTrigger = ({
647
710
  ...props
648
711
  }: CodeBlockLanguageSelectorTriggerProps) => (
649
712
  <SelectTrigger
650
- className={cn("h-7 border-none bg-transparent px-2 text-xs shadow-none", className)}
713
+ className={cn("h-7 border-none bg-transparent px-2 text-meta shadow-none", className)}
651
714
  size="sm"
652
715
  {...props}
653
716
  />