@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.
- package/README.md +62 -12
- package/dist/{_audio-player-media-chrome-KA5DY54G.js → _audio-player-media-chrome-T3XVXWRZ.js} +8 -4
- package/dist/_audio-player-media-chrome-T3XVXWRZ.js.map +1 -0
- package/dist/{_flow-boundary-D63PJ65S.js → _flow-boundary-SHNWLQG5.js} +32 -43
- package/dist/_flow-boundary-SHNWLQG5.js.map +1 -0
- package/dist/{_persona-rive-RFR2EUWP.js → _persona-rive-JEG44YHX.js} +9 -5
- package/dist/_persona-rive-JEG44YHX.js.map +1 -0
- package/dist/index.d.ts +1152 -218
- package/dist/index.js +5601 -3241
- package/dist/index.js.map +1 -1
- package/package.json +30 -15
- 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/_audio-player-media-chrome.tsx +102 -15
- package/src/_chat-shell-rail.tsx +2 -2
- package/src/_flow-boundary.tsx +68 -49
- package/src/_lazy-boundary-conformance.ts +38 -0
- package/src/_lazy-cjk.test.ts +43 -0
- package/src/_lazy-cjk.ts +73 -0
- package/src/_lazy-engine-boundary.tsx +61 -0
- package/src/_lazy-math.test.ts +63 -0
- package/src/_lazy-math.ts +90 -0
- package/src/_lazy-mermaid-absent.test.ts +53 -0
- package/src/_lazy-mermaid.test.ts +15 -0
- package/src/_lazy-mermaid.ts +24 -1
- package/src/_mermaid-error-panel.test.tsx +50 -0
- package/src/_mermaid-error-panel.tsx +66 -0
- package/src/_persona-rive.tsx +62 -10
- package/src/_streamdown-i18n.ts +94 -22
- package/src/_streamdown-safety.ts +170 -0
- package/src/_theme-scope-store.test.ts +83 -0
- package/src/_theme-scope-store.ts +103 -0
- package/src/agent-event.stories.tsx +97 -0
- package/src/agent-event.test.tsx +145 -0
- package/src/agent-event.tsx +187 -0
- package/src/agent-timeline.stories.tsx +9 -1
- package/src/agent.test.tsx +47 -0
- package/src/agent.tsx +43 -24
- package/src/agentic-workspace.stories.tsx +27 -20
- package/src/artifact.tsx +17 -14
- package/src/asset-preview.test.tsx +40 -0
- package/src/asset-preview.tsx +83 -12
- package/src/attachments.tsx +7 -4
- package/src/audio-player.test.tsx +78 -0
- package/src/audio-player.tsx +243 -56
- package/src/audio-visualizer.stories.tsx +126 -0
- package/src/audio-visualizer.test.tsx +438 -0
- package/src/audio-visualizer.tsx +367 -0
- package/src/canvas.stories.tsx +150 -1
- package/src/chat-shell.stories.tsx +18 -3
- package/src/chat.stories.tsx +16 -2
- package/src/code-block.stories.tsx +9 -1
- package/src/code-block.test.tsx +100 -1
- package/src/code-block.tsx +166 -103
- package/src/commit.tsx +30 -41
- package/src/composer.stories.tsx +372 -9
- package/src/composer.test.tsx +357 -6
- package/src/composer.tsx +217 -35
- package/src/confirmation.stories.tsx +72 -1
- package/src/confirmation.test.tsx +216 -2
- package/src/confirmation.tsx +264 -4
- package/src/context-panel.stories.tsx +9 -1
- package/src/context-panel.tsx +3 -2
- package/src/conversation.stories.tsx +86 -2
- package/src/conversation.test.tsx +66 -0
- package/src/conversation.tsx +52 -9
- package/src/diff-view.stories.tsx +196 -0
- package/src/diff-view.test.tsx +238 -0
- package/src/diff-view.tsx +697 -0
- package/src/environment-variables.tsx +20 -37
- package/src/file-tree.test.tsx +21 -0
- package/src/file-tree.tsx +12 -2
- package/src/gallery.tsx +4 -2
- package/src/index.ts +14 -4
- package/src/inline-citation.tsx +5 -5
- package/src/jsx-preview.stories.tsx +2 -2
- package/src/jsx-preview.tsx +151 -43
- package/src/markdown-view.stories.tsx +92 -1
- package/src/markdown-view.test.tsx +237 -2
- package/src/markdown-view.tsx +157 -6
- package/src/message-compare.stories.tsx +175 -0
- package/src/message-compare.test.tsx +207 -0
- package/src/message-compare.tsx +453 -0
- package/src/message-form.stories.tsx +65 -4
- 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.stories.tsx +9 -1
- package/src/message.test.tsx +247 -1
- package/src/message.tsx +125 -15
- package/src/microcopy.test.tsx +40 -0
- package/src/{model-selector.stories.tsx → model-provider-logo.stories.tsx} +17 -8
- package/src/model-provider-logo.test.tsx +109 -0
- package/src/model-provider-logo.tsx +183 -0
- package/src/open-in-chat.tsx +50 -29
- package/src/package-info.tsx +12 -12
- package/src/permission-mode-select.stories.tsx +82 -0
- package/src/permission-mode-select.test.tsx +100 -0
- package/src/permission-mode-select.tsx +137 -0
- package/src/persona-missing-peer.test.tsx +54 -0
- package/src/persona.tsx +68 -22
- package/src/plan.stories.tsx +166 -0
- package/src/plan.test.tsx +267 -0
- package/src/plan.tsx +182 -20
- package/src/prompt-input-effort.stories.tsx +123 -0
- package/src/prompt-input-effort.test.tsx +83 -0
- package/src/prompt-input-effort.tsx +136 -0
- package/src/prompt-input-mode.stories.tsx +108 -0
- package/src/prompt-input-mode.test.tsx +99 -0
- package/src/prompt-input-mode.tsx +169 -0
- package/src/prompt-input-slash.stories.tsx +211 -0
- package/src/prompt-input-slash.test.tsx +262 -0
- package/src/prompt-input-slash.tsx +541 -0
- package/src/prompt-input.stories.tsx +2 -2
- 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 +42 -17
- package/src/sandbox.stories.tsx +9 -1
- package/src/sandbox.tsx +3 -3
- package/src/schema-display.test.tsx +56 -0
- package/src/schema-display.tsx +71 -37
- package/src/selection-toolbar.stories.tsx +9 -1
- package/src/session-header.stories.tsx +128 -0
- package/src/session-header.test.tsx +138 -0
- package/src/session-header.tsx +243 -0
- package/src/session-status-bar.stories.tsx +73 -0
- package/src/session-status-bar.test.tsx +94 -0
- package/src/session-status-bar.tsx +165 -0
- package/src/snippet.stories.tsx +9 -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 +24 -36
- package/src/streamdown-i18n.test.tsx +24 -1
- package/src/task.stories.tsx +13 -3
- package/src/templates-ai-assistant.stories.tsx +21 -1
- package/src/test-results.tsx +47 -27
- package/src/token-usage.stories.tsx +24 -0
- package/src/token-usage.test.tsx +92 -0
- package/src/{context.tsx → token-usage.tsx} +73 -59
- package/src/tool-result-card.stories.tsx +9 -1
- package/src/tool.stories.tsx +13 -3
- package/src/tool.test.tsx +65 -0
- package/src/tool.tsx +73 -30
- package/src/transcription.tsx +1 -1
- package/src/turn-status.stories.tsx +124 -0
- package/src/turn-status.test.tsx +74 -0
- package/src/turn-status.tsx +174 -0
- package/src/use-audio-level.ts +104 -0
- package/src/voice-selector.tsx +5 -5
- package/src/web-preview.test.tsx +51 -1
- package/src/web-preview.tsx +46 -10
- package/dist/_audio-player-media-chrome-KA5DY54G.js.map +0 -1
- package/dist/_flow-boundary-D63PJ65S.js.map +0 -1
- package/dist/_interactive-terminal-xterm-ATJ5EW3G.js +0 -11
- package/dist/_interactive-terminal-xterm-ATJ5EW3G.js.map +0 -1
- package/dist/_persona-rive-RFR2EUWP.js.map +0 -1
- package/src/_interactive-terminal-xterm.ts +0 -32
- package/src/blocks-ai-composer.stories.tsx +0 -83
- package/src/context.stories.tsx +0 -16
- package/src/interactive-terminal.stories.tsx +0 -165
- package/src/interactive-terminal.test.tsx +0 -448
- package/src/interactive-terminal.tsx +0 -444
- package/src/model-selector.test.tsx +0 -55
- package/src/model-selector.tsx +0 -225
- package/src/terminal.tsx +0 -244
package/src/code-block.test.tsx
CHANGED
|
@@ -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
|
+
});
|
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
|
/>
|