@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
@@ -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 lookup — avoids 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 PEEK — a 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
  />
package/src/commit.tsx CHANGED
@@ -3,11 +3,11 @@
3
3
  import { Avatar, AvatarFallback } from "@elabs-ai/components-ui";
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 { CheckIcon, CopyIcon, FileIcon, GitCommitIcon, MinusIcon, PlusIcon } from "lucide-react";
9
9
  import type { ComponentProps, HTMLAttributes } from "react";
10
- import { useCallback, useEffect, useRef, useState } from "react";
10
+ import { useCallback, useEffect, useState } from "react";
11
11
 
12
12
  export type CommitProps = ComponentProps<typeof Collapsible>;
13
13
 
@@ -35,7 +35,7 @@ export const CommitHeader = ({ className, children, ...props }: CommitHeaderProp
35
35
  export type CommitHashProps = HTMLAttributes<HTMLSpanElement>;
36
36
 
37
37
  export const CommitHash = ({ className, children, ...props }: CommitHashProps) => (
38
- <span className={cn("font-mono text-xs", className)} {...props}>
38
+ <span className={cn("font-mono text-meta", className)} {...props}>
39
39
  <GitCommitIcon className="me-1 inline-block size-3" />
40
40
  {children}
41
41
  </span>
@@ -44,7 +44,7 @@ export const CommitHash = ({ className, children, ...props }: CommitHashProps) =
44
44
  export type CommitMessageProps = HTMLAttributes<HTMLSpanElement>;
45
45
 
46
46
  export const CommitMessage = ({ className, children, ...props }: CommitMessageProps) => (
47
- <span className={cn("font-medium text-sm", className)} {...props}>
47
+ <span className={cn("font-medium text-body", className)} {...props}>
48
48
  {children}
49
49
  </span>
50
50
  );
@@ -53,7 +53,7 @@ export type CommitMetadataProps = HTMLAttributes<HTMLDivElement>;
53
53
 
54
54
  export const CommitMetadata = ({ className, children, ...props }: CommitMetadataProps) => (
55
55
  <div
56
- className={cn("flex items-center gap-2 text-muted-foreground text-xs", className)}
56
+ className={cn("flex items-center gap-2 text-muted-foreground text-meta", className)}
57
57
  {...props}
58
58
  >
59
59
  {children}
@@ -90,7 +90,7 @@ export type CommitAuthorAvatarProps = ComponentProps<typeof Avatar> & {
90
90
 
91
91
  export const CommitAuthorAvatar = ({ initials, className, ...props }: CommitAuthorAvatarProps) => (
92
92
  <Avatar className={cn("size-8", className)} {...props}>
93
- <AvatarFallback className="text-xs">{initials}</AvatarFallback>
93
+ <AvatarFallback className="text-meta">{initials}</AvatarFallback>
94
94
  </Avatar>
95
95
  );
96
96
 
@@ -116,7 +116,7 @@ export const CommitTimestamp = ({ date, className, children, ...props }: CommitT
116
116
  }, [updateFormatted]);
117
117
 
118
118
  return (
119
- <time className={cn("text-xs", className)} dateTime={date.toISOString()} {...props}>
119
+ <time className={cn("text-meta", className)} dateTime={date.toISOString()} {...props}>
120
120
  {children ?? formatted}
121
121
  </time>
122
122
  );
@@ -150,43 +150,32 @@ export const CommitCopyButton = ({
150
150
  hash,
151
151
  onCopy,
152
152
  onError,
153
- timeout = 2000,
153
+ timeout,
154
154
  children,
155
155
  className,
156
+ "aria-label": ariaLabel,
156
157
  ...props
157
158
  }: CommitCopyButtonProps) => {
158
- const [isCopied, setIsCopied] = useState(false);
159
- const timeoutRef = useRef<number>(0);
159
+ const { t } = useLocale();
160
+ // Shared implementation (`@elabs-ai/components-ui`) instead of a private
161
+ // copy of the same copy-to-clipboard state machine (issue-workflow.md
162
+ // dedupe finding) — see `CodeBlockCopyButton` for the reference usage.
163
+ const { copied: isCopied, copy } = useCopyToClipboard({ resetAfterMs: timeout ?? 2000 });
160
164
 
161
165
  const copyToClipboard = useCallback(async () => {
162
- if (typeof window === "undefined" || !navigator?.clipboard?.writeText) {
166
+ const ok = await copy(hash);
167
+ if (ok) {
168
+ onCopy?.();
169
+ } else {
163
170
  onError?.(new Error("Clipboard API not available"));
164
- return;
165
171
  }
166
-
167
- try {
168
- if (!isCopied) {
169
- await navigator.clipboard.writeText(hash);
170
- setIsCopied(true);
171
- onCopy?.();
172
- timeoutRef.current = window.setTimeout(() => setIsCopied(false), timeout);
173
- }
174
- } catch (error) {
175
- onError?.(error as Error);
176
- }
177
- }, [hash, onCopy, onError, timeout, isCopied]);
178
-
179
- useEffect(
180
- () => () => {
181
- window.clearTimeout(timeoutRef.current);
182
- },
183
- [],
184
- );
172
+ }, [copy, hash, onCopy, onError]);
185
173
 
186
174
  const Icon = isCopied ? CheckIcon : CopyIcon;
187
175
 
188
176
  return (
189
177
  <Button
178
+ aria-label={ariaLabel ?? t("copy")}
190
179
  className={cn("size-7 shrink-0", className)}
191
180
  onClick={copyToClipboard}
192
181
  size="icon"
@@ -219,7 +208,7 @@ export type CommitFileProps = HTMLAttributes<HTMLDivElement>;
219
208
  export const CommitFile = ({ className, children, ...props }: CommitFileProps) => (
220
209
  <div
221
210
  className={cn(
222
- "flex items-center justify-between gap-2 rounded px-2 py-1 text-sm hover:bg-muted/50",
211
+ "flex items-center justify-between gap-2 rounded px-2 py-1 text-body hover:bg-muted/50",
223
212
  className,
224
213
  )}
225
214
  {...props}
@@ -237,10 +226,10 @@ export const CommitFileInfo = ({ className, children, ...props }: CommitFileInfo
237
226
  );
238
227
 
239
228
  const fileStatusStyles = {
240
- added: "text-green-600 dark:text-green-400",
241
- deleted: "text-red-600 dark:text-red-400",
242
- modified: "text-yellow-600 dark:text-yellow-400",
243
- renamed: "text-blue-600 dark:text-blue-400",
229
+ added: "text-success-text",
230
+ deleted: "text-destructive-text",
231
+ modified: "text-warning-text",
232
+ renamed: "text-info-text",
244
233
  };
245
234
 
246
235
  const fileStatusLabels = {
@@ -261,7 +250,7 @@ export const CommitFileStatus = ({
261
250
  ...props
262
251
  }: CommitFileStatusProps) => (
263
252
  <span
264
- className={cn("font-medium font-mono text-xs", fileStatusStyles[status], className)}
253
+ className={cn("font-medium font-mono text-meta", fileStatusStyles[status], className)}
265
254
  {...props}
266
255
  >
267
256
  {children ?? fileStatusLabels[status]}
@@ -277,7 +266,7 @@ export const CommitFileIcon = ({ className, ...props }: CommitFileIconProps) =>
277
266
  export type CommitFilePathProps = HTMLAttributes<HTMLSpanElement>;
278
267
 
279
268
  export const CommitFilePath = ({ className, children, ...props }: CommitFilePathProps) => (
280
- <span className={cn("truncate font-mono text-xs", className)} {...props}>
269
+ <span className={cn("truncate font-mono text-meta", className)} {...props}>
281
270
  {children}
282
271
  </span>
283
272
  );
@@ -285,7 +274,7 @@ export const CommitFilePath = ({ className, children, ...props }: CommitFilePath
285
274
  export type CommitFileChangesProps = HTMLAttributes<HTMLDivElement>;
286
275
 
287
276
  export const CommitFileChanges = ({ className, children, ...props }: CommitFileChangesProps) => (
288
- <div className={cn("flex shrink-0 items-center gap-1 font-mono text-xs", className)} {...props}>
277
+ <div className={cn("flex shrink-0 items-center gap-1 font-mono text-meta", className)} {...props}>
289
278
  {children}
290
279
  </div>
291
280
  );
@@ -305,7 +294,7 @@ export const CommitFileAdditions = ({
305
294
  }
306
295
 
307
296
  return (
308
- <span className={cn("text-green-600 dark:text-green-400", className)} {...props}>
297
+ <span className={cn("text-success-text", className)} {...props}>
309
298
  {children ?? (
310
299
  <>
311
300
  <PlusIcon className="inline-block size-3" />
@@ -331,7 +320,7 @@ export const CommitFileDeletions = ({
331
320
  }
332
321
 
333
322
  return (
334
- <span className={cn("text-red-600 dark:text-red-400", className)} {...props}>
323
+ <span className={cn("text-destructive-text", className)} {...props}>
335
324
  {children ?? (
336
325
  <>
337
326
  <MinusIcon className="inline-block size-3" />
@@ -251,7 +251,7 @@ export const ConfirmationActions = ({ className, ...props }: ConfirmationActions
251
251
  export type ConfirmationActionProps = ComponentProps<typeof Button>;
252
252
 
253
253
  export const ConfirmationAction = ({ className, ...props }: ConfirmationActionProps) => (
254
- <Button className={cn("h-8 px-3 text-sm", className)} type="button" {...props} />
254
+ <Button className={cn("h-8 px-3 text-body", className)} type="button" {...props} />
255
255
  );
256
256
 
257
257
  export type ConfirmationApproveProps = ConfirmationActionProps;
@@ -382,7 +382,7 @@ export const ContextPanel = forwardRef<HTMLDivElement, ContextPanelProps>(functi
382
382
  >
383
383
  <SheetHeader className="sr-only">
384
384
  <SheetTitle>{t("ai.contextPanel.title")}</SheetTitle>
385
- <SheetDescription>Displays the chat context panel.</SheetDescription>
385
+ <SheetDescription>{t("ai.contextPanel.description")}</SheetDescription>
386
386
  </SheetHeader>
387
387
  <div className="flex h-full w-full flex-col">{children}</div>
388
388
  </SheetContent>
@@ -34,6 +34,29 @@ export const Default: Story = {
34
34
  ),
35
35
  };
36
36
 
37
+ /**
38
+ * `isStreaming` suppresses the live-region chatter while tokens are still
39
+ * arriving (`aria-live="off"` + `aria-busy`) — see the prop's doc on
40
+ * `ConversationProps` for why a `"polite"` region would otherwise announce
41
+ * every incoming token.
42
+ */
43
+ export const Streaming: Story = {
44
+ render: () => (
45
+ <div className="flex h-72 flex-col rounded-lg border">
46
+ <Conversation isStreaming>
47
+ <ConversationContent>
48
+ <Message from="user">
49
+ <MessageContent>Summarize the deploy log.</MessageContent>
50
+ </Message>
51
+ <Message from="assistant">
52
+ <MessageContent>Deploying to production</MessageContent>
53
+ </Message>
54
+ </ConversationContent>
55
+ </Conversation>
56
+ </div>
57
+ ),
58
+ };
59
+
37
60
  /**
38
61
  * **Layout lock (#72).** With zero messages, `ConversationEmptyState` must render
39
62
  * vertically centred in the conversation canvas — not pinned to the top with a