@elabs-ai/components-ai 4.1.0 → 4.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -2
- package/dist/index.d.ts +43 -16
- package/dist/index.js +1297 -926
- package/dist/index.js.map +1 -1
- package/package.json +9 -9
- package/src/__contract__/audio-visualizer.contract.test.tsx +49 -0
- package/src/__contract__/chat-shell.contract.test.tsx +49 -0
- package/src/__contract__/grouped-parts.contract.test.tsx +49 -0
- package/src/__contract__/image.contract.test.tsx +49 -0
- package/src/__contract__/markdown-view.contract.test.tsx +49 -0
- package/src/__contract__/message-feedback.contract.test.tsx +49 -0
- package/src/__contract__/message-form.contract.test.tsx +49 -0
- package/src/__contract__/message-table.contract.test.tsx +49 -0
- package/src/__contract__/model-provider-logo.contract.test.tsx +49 -0
- package/src/__contract__/persona.contract.test.tsx +49 -0
- package/src/__contract__/prompt-input-effort.contract.test.tsx +49 -0
- package/src/__contract__/prompt-input-mode.contract.test.tsx +49 -0
- package/src/_chat-shell-rail.tsx +2 -2
- package/src/_lazy-cjk.test.ts +43 -0
- package/src/_lazy-cjk.ts +73 -0
- package/src/_lazy-math.test.ts +63 -0
- package/src/_lazy-math.ts +90 -0
- package/src/_streamdown-i18n.ts +73 -21
- package/src/_streamdown-safety.ts +2 -2
- package/src/_theme-scope-store.test.ts +83 -0
- package/src/_theme-scope-store.ts +103 -0
- package/src/agent-event.tsx +1 -1
- package/src/agent.tsx +18 -13
- package/src/artifact.tsx +2 -2
- package/src/asset-preview.test.tsx +40 -0
- package/src/asset-preview.tsx +83 -12
- package/src/attachments.tsx +7 -4
- package/src/code-block.test.tsx +100 -1
- package/src/code-block.tsx +166 -103
- package/src/commit.tsx +30 -41
- package/src/confirmation.tsx +1 -1
- package/src/context-panel.tsx +1 -1
- package/src/conversation.stories.tsx +23 -0
- package/src/conversation.test.tsx +53 -0
- package/src/conversation.tsx +32 -7
- package/src/diff-view.test.tsx +52 -2
- package/src/diff-view.tsx +89 -34
- package/src/environment-variables.tsx +20 -37
- package/src/file-tree.test.tsx +21 -0
- package/src/file-tree.tsx +12 -2
- package/src/inline-citation.tsx +5 -5
- package/src/jsx-preview.tsx +151 -43
- package/src/markdown-view.test.tsx +7 -3
- package/src/markdown-view.tsx +8 -1
- package/src/message-form.stories.tsx +36 -3
- package/src/message-form.test.tsx +8 -2
- package/src/message-form.tsx +15 -7
- package/src/message-table.stories.tsx +2 -2
- package/src/message-table.test.tsx +7 -0
- package/src/message-table.tsx +8 -4
- package/src/message.test.tsx +73 -3
- package/src/message.tsx +36 -12
- package/src/model-provider-logo.test.tsx +57 -3
- package/src/model-provider-logo.tsx +45 -11
- package/src/open-in-chat.tsx +50 -29
- package/src/package-info.tsx +12 -12
- package/src/prompt-input-slash.stories.tsx +1 -1
- package/src/prompt-input.test.tsx +67 -1
- package/src/prompt-input.tsx +42 -4
- package/src/queue.tsx +4 -4
- package/src/reasoning.tsx +17 -6
- package/src/sandbox.tsx +3 -3
- package/src/schema-display.test.tsx +56 -0
- package/src/schema-display.tsx +68 -37
- package/src/session-header.tsx +1 -1
- package/src/snippet.test.tsx +6 -2
- package/src/snippet.tsx +14 -34
- package/src/speech-input.test.tsx +109 -0
- package/src/speech-input.tsx +31 -3
- package/src/stack-trace.tsx +20 -36
- package/src/streamdown-i18n.test.tsx +23 -0
- package/src/test-results.tsx +47 -27
- package/src/token-usage.tsx +6 -6
- package/src/tool.test.tsx +65 -0
- package/src/tool.tsx +59 -23
- package/src/transcription.tsx +1 -1
- package/src/voice-selector.tsx +5 -5
- package/src/web-preview.test.tsx +51 -1
- package/src/web-preview.tsx +39 -8
package/src/code-block.tsx
CHANGED
|
@@ -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
|
-
|
|
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 =
|
|
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
|
-
|
|
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-
|
|
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-
|
|
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-
|
|
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.
|
|
425
|
-
//
|
|
426
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
456
|
-
|
|
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
|
|
462
|
-
// `
|
|
463
|
-
//
|
|
464
|
-
//
|
|
465
|
-
//
|
|
466
|
-
//
|
|
467
|
-
const
|
|
468
|
-
|
|
469
|
-
|
|
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
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
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
|
|
654
|
+
timeout,
|
|
582
655
|
children,
|
|
583
656
|
className,
|
|
584
657
|
...props
|
|
585
658
|
}: CodeBlockCopyButtonProps) => {
|
|
586
|
-
const
|
|
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
|
-
|
|
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
|
|
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-
|
|
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,
|
|
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-
|
|
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-
|
|
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-
|
|
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-
|
|
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-
|
|
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
|
|
153
|
+
timeout,
|
|
154
154
|
children,
|
|
155
155
|
className,
|
|
156
|
+
"aria-label": ariaLabel,
|
|
156
157
|
...props
|
|
157
158
|
}: CommitCopyButtonProps) => {
|
|
158
|
-
const
|
|
159
|
-
|
|
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
|
-
|
|
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-
|
|
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-
|
|
241
|
-
deleted: "text-
|
|
242
|
-
modified: "text-
|
|
243
|
-
renamed: "text-
|
|
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-
|
|
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-
|
|
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-
|
|
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-
|
|
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-
|
|
323
|
+
<span className={cn("text-destructive-text", className)} {...props}>
|
|
335
324
|
{children ?? (
|
|
336
325
|
<>
|
|
337
326
|
<MinusIcon className="inline-block size-3" />
|
package/src/confirmation.tsx
CHANGED
|
@@ -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-
|
|
254
|
+
<Button className={cn("h-8 px-3 text-body", className)} type="button" {...props} />
|
|
255
255
|
);
|
|
256
256
|
|
|
257
257
|
export type ConfirmationApproveProps = ConfirmationActionProps;
|
package/src/context-panel.tsx
CHANGED
|
@@ -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>
|
|
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
|