@elabs-ai/components-ai 4.0.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/LICENSE +21 -0
- package/README.md +133 -0
- package/dist/_audio-player-media-chrome-KA5DY54G.js +81 -0
- package/dist/_audio-player-media-chrome-KA5DY54G.js.map +1 -0
- package/dist/_flow-boundary-D63PJ65S.js +186 -0
- package/dist/_flow-boundary-D63PJ65S.js.map +1 -0
- package/dist/_interactive-terminal-xterm-ATJ5EW3G.js +11 -0
- package/dist/_interactive-terminal-xterm-ATJ5EW3G.js.map +1 -0
- package/dist/_persona-rive-RFR2EUWP.js +164 -0
- package/dist/_persona-rive-RFR2EUWP.js.map +1 -0
- package/dist/index.d.ts +4079 -0
- package/dist/index.js +11770 -0
- package/dist/index.js.map +1 -0
- package/package.json +87 -0
- package/src/_audio-player-media-chrome.tsx +146 -0
- package/src/_chat-shell-rail.tsx +18 -0
- package/src/_code-block-theme.test.ts +134 -0
- package/src/_code-block-theme.ts +184 -0
- package/src/_flow-boundary.tsx +237 -0
- package/src/_flow-lazy.ts +34 -0
- package/src/_interactive-terminal-xterm.ts +32 -0
- package/src/_lazy-mermaid.test.ts +101 -0
- package/src/_lazy-mermaid.ts +117 -0
- package/src/_persona-rive.tsx +252 -0
- package/src/_streamdown-i18n.ts +119 -0
- package/src/agent-timeline.stories.tsx +66 -0
- package/src/agent-timeline.test.tsx +74 -0
- package/src/agent-timeline.tsx +106 -0
- package/src/agent.tsx +113 -0
- package/src/agentic-workspace.stories.tsx +1099 -0
- package/src/artifact.stories.tsx +62 -0
- package/src/artifact.test.tsx +112 -0
- package/src/artifact.tsx +167 -0
- package/src/asset-preview.stories.tsx +100 -0
- package/src/asset-preview.test.tsx +126 -0
- package/src/asset-preview.tsx +218 -0
- package/src/attachments.tsx +393 -0
- package/src/audio-player.stories.tsx +99 -0
- package/src/audio-player.tsx +170 -0
- package/src/blocks-ai-composer.stories.tsx +83 -0
- package/src/canvas.stories.tsx +142 -0
- package/src/canvas.tsx +29 -0
- package/src/chain-of-thought.tsx +193 -0
- package/src/chat-greeting.stories.tsx +96 -0
- package/src/chat-greeting.test.tsx +91 -0
- package/src/chat-greeting.tsx +91 -0
- package/src/chat-shell.stories.tsx +123 -0
- package/src/chat-shell.test.tsx +113 -0
- package/src/chat-shell.tsx +97 -0
- package/src/chat.stories.tsx +99 -0
- package/src/checkpoint.tsx +54 -0
- package/src/code-block.stories.tsx +81 -0
- package/src/code-block.test.tsx +191 -0
- package/src/code-block.tsx +673 -0
- package/src/commit.tsx +343 -0
- package/src/composer.stories.tsx +188 -0
- package/src/composer.test.tsx +182 -0
- package/src/composer.tsx +202 -0
- package/src/confirmation.stories.tsx +61 -0
- package/src/confirmation.test.tsx +136 -0
- package/src/confirmation.tsx +273 -0
- package/src/connection.tsx +23 -0
- package/src/context-panel.stories.tsx +137 -0
- package/src/context-panel.test.tsx +198 -0
- package/src/context-panel.tsx +609 -0
- package/src/context.stories.tsx +16 -0
- package/src/context.tsx +363 -0
- package/src/controls.tsx +20 -0
- package/src/conversation.stories.tsx +26 -0
- package/src/conversation.test.tsx +123 -0
- package/src/conversation.tsx +143 -0
- package/src/dark-theme-variant.test.ts +47 -0
- package/src/edge.tsx +33 -0
- package/src/environment-variables.tsx +304 -0
- package/src/file-tree.stories.tsx +55 -0
- package/src/file-tree.test.tsx +101 -0
- package/src/file-tree.tsx +390 -0
- package/src/gallery.stories.tsx +182 -0
- package/src/gallery.test.tsx +150 -0
- package/src/gallery.tsx +737 -0
- package/src/grouped-parts.stories.tsx +104 -0
- package/src/grouped-parts.test.tsx +167 -0
- package/src/grouped-parts.tsx +247 -0
- package/src/image.stories.tsx +67 -0
- package/src/image.test.tsx +105 -0
- package/src/image.tsx +115 -0
- package/src/index.ts +93 -0
- package/src/inline-citation.stories.tsx +91 -0
- package/src/inline-citation.test.tsx +96 -0
- package/src/inline-citation.tsx +314 -0
- package/src/interactive-terminal.stories.tsx +165 -0
- package/src/interactive-terminal.test.tsx +448 -0
- package/src/interactive-terminal.tsx +444 -0
- package/src/jsx-preview.stories.tsx +265 -0
- package/src/jsx-preview.test.tsx +75 -0
- package/src/jsx-preview.tsx +409 -0
- package/src/markdown-view.stories.tsx +57 -0
- package/src/markdown-view.test.tsx +36 -0
- package/src/markdown-view.tsx +113 -0
- package/src/message-edit.stories.tsx +150 -0
- package/src/message-edit.test.tsx +115 -0
- package/src/message-edit.tsx +336 -0
- package/src/message-feedback.stories.tsx +53 -0
- package/src/message-feedback.test.tsx +62 -0
- package/src/message-feedback.tsx +117 -0
- package/src/message-form-spec.ts +349 -0
- package/src/message-form.stories.tsx +182 -0
- package/src/message-form.test.tsx +227 -0
- package/src/message-form.tsx +845 -0
- package/src/message-table-spec.ts +281 -0
- package/src/message-table.stories.tsx +176 -0
- package/src/message-table.test.tsx +144 -0
- package/src/message-table.tsx +366 -0
- package/src/message.stories.tsx +262 -0
- package/src/message.test.tsx +508 -0
- package/src/message.tsx +619 -0
- package/src/mic-selector.tsx +339 -0
- package/src/microcopy.test.tsx +123 -0
- package/src/model-selector.stories.tsx +73 -0
- package/src/model-selector.test.tsx +55 -0
- package/src/model-selector.tsx +225 -0
- package/src/motion-config.tsx +49 -0
- package/src/node.tsx +71 -0
- package/src/open-in-chat.tsx +340 -0
- package/src/package-info.tsx +205 -0
- package/src/panel.tsx +20 -0
- package/src/part-groups.ts +258 -0
- package/src/persona-sources.ts +59 -0
- package/src/persona.stories.tsx +111 -0
- package/src/persona.test.tsx +67 -0
- package/src/persona.tsx +148 -0
- package/src/plan.tsx +135 -0
- package/src/prompt-input.stories.tsx +261 -0
- package/src/prompt-input.test.tsx +392 -0
- package/src/prompt-input.tsx +1668 -0
- package/src/queue.tsx +237 -0
- package/src/reasoning.stories.tsx +81 -0
- package/src/reasoning.test.tsx +50 -0
- package/src/reasoning.tsx +229 -0
- package/src/sandbox.stories.tsx +62 -0
- package/src/sandbox.test.tsx +42 -0
- package/src/sandbox.tsx +149 -0
- package/src/schema-display.tsx +412 -0
- package/src/selection-toolbar.stories.tsx +86 -0
- package/src/selection-toolbar.test.tsx +89 -0
- package/src/selection-toolbar.tsx +192 -0
- package/src/shimmer.stories.tsx +10 -0
- package/src/shimmer.tsx +81 -0
- package/src/snippet.stories.tsx +19 -0
- package/src/snippet.test.tsx +116 -0
- package/src/snippet.tsx +138 -0
- package/src/sources.stories.tsx +34 -0
- package/src/sources.test.tsx +129 -0
- package/src/sources.tsx +93 -0
- package/src/speech-input.tsx +312 -0
- package/src/stack-trace.tsx +479 -0
- package/src/streamdown-i18n.test.tsx +163 -0
- package/src/suggestion.stories.tsx +75 -0
- package/src/suggestion.test.tsx +72 -0
- package/src/suggestion.tsx +120 -0
- package/src/task.stories.tsx +22 -0
- package/src/task.test.tsx +36 -0
- package/src/task.tsx +89 -0
- package/src/templates-ai-assistant.stories.tsx +142 -0
- package/src/terminal.tsx +244 -0
- package/src/test-results.stories.tsx +177 -0
- package/src/test-results.test.tsx +241 -0
- package/src/test-results.tsx +408 -0
- package/src/tool-result-card.stories.tsx +143 -0
- package/src/tool-result-card.test.tsx +72 -0
- package/src/tool-result-card.tsx +86 -0
- package/src/tool.stories.tsx +60 -0
- package/src/tool.test.tsx +63 -0
- package/src/tool.tsx +235 -0
- package/src/toolbar.tsx +44 -0
- package/src/transcription.tsx +118 -0
- package/src/voice-selector.tsx +469 -0
- package/src/web-preview.stories.tsx +67 -0
- package/src/web-preview.test.tsx +117 -0
- package/src/web-preview.tsx +283 -0
|
@@ -0,0 +1,673 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { Button, useLocale } from "@elabs-ai/components-ui";
|
|
4
|
+
import {
|
|
5
|
+
Select,
|
|
6
|
+
SelectContent,
|
|
7
|
+
SelectItem,
|
|
8
|
+
SelectTrigger,
|
|
9
|
+
SelectValue,
|
|
10
|
+
} from "@elabs-ai/components-ui";
|
|
11
|
+
import { cn } from "@elabs-ai/components-ui/lib/cn";
|
|
12
|
+
import { CheckIcon, CopyIcon } from "lucide-react";
|
|
13
|
+
import type { ComponentProps, CSSProperties, HTMLAttributes } from "react";
|
|
14
|
+
import { Shimmer } from "./shimmer";
|
|
15
|
+
import {
|
|
16
|
+
createContext,
|
|
17
|
+
memo,
|
|
18
|
+
useCallback,
|
|
19
|
+
useContext,
|
|
20
|
+
useEffect,
|
|
21
|
+
useLayoutEffect,
|
|
22
|
+
useMemo,
|
|
23
|
+
useRef,
|
|
24
|
+
useState,
|
|
25
|
+
} from "react";
|
|
26
|
+
import type { BundledLanguage, BundledTheme, HighlighterGeneric, ThemedToken } from "shiki";
|
|
27
|
+
import { createHighlighter } from "shiki";
|
|
28
|
+
|
|
29
|
+
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);
|
|
41
|
+
|
|
42
|
+
// Shiki uses bitflags for font styles: 1=italic, 2=bold, 4=underline
|
|
43
|
+
// oxlint-disable-next-line eslint(no-bitwise)
|
|
44
|
+
const isItalic = (fontStyle: number | undefined) => fontStyle && fontStyle & 1;
|
|
45
|
+
// oxlint-disable-next-line eslint(no-bitwise)
|
|
46
|
+
const isBold = (fontStyle: number | undefined) => fontStyle && fontStyle & 2;
|
|
47
|
+
const isUnderline = (fontStyle: number | undefined) =>
|
|
48
|
+
// oxlint-disable-next-line eslint(no-bitwise)
|
|
49
|
+
fontStyle && fontStyle & 4;
|
|
50
|
+
|
|
51
|
+
// Transform tokens to include pre-computed keys to avoid noArrayIndexKey lint
|
|
52
|
+
interface KeyedToken {
|
|
53
|
+
token: ThemedToken;
|
|
54
|
+
key: string;
|
|
55
|
+
}
|
|
56
|
+
interface KeyedLine {
|
|
57
|
+
tokens: KeyedToken[];
|
|
58
|
+
key: string;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const addKeysToTokens = (lines: ThemedToken[][]): KeyedLine[] =>
|
|
62
|
+
lines.map((line, lineIdx) => ({
|
|
63
|
+
key: `line-${lineIdx}`,
|
|
64
|
+
tokens: line.map((token, tokenIdx) => ({
|
|
65
|
+
key: `line-${lineIdx}-${tokenIdx}`,
|
|
66
|
+
token,
|
|
67
|
+
})),
|
|
68
|
+
}));
|
|
69
|
+
|
|
70
|
+
// Token rendering component. Colors come entirely from the derived brand
|
|
71
|
+
// theme's inline styles (`_code-block-theme.ts`, #315) — no `dark:` escape
|
|
72
|
+
// hatch needed, since the theme is already resolved for whichever brand
|
|
73
|
+
// theme is active (including a monochrome ink palette).
|
|
74
|
+
const TokenSpan = ({ token }: { token: ThemedToken }) => (
|
|
75
|
+
<span
|
|
76
|
+
style={
|
|
77
|
+
{
|
|
78
|
+
backgroundColor: token.bgColor,
|
|
79
|
+
color: token.color,
|
|
80
|
+
fontStyle: isItalic(token.fontStyle) ? "italic" : undefined,
|
|
81
|
+
fontWeight: isBold(token.fontStyle) ? "bold" : undefined,
|
|
82
|
+
textDecoration: isUnderline(token.fontStyle) ? "underline" : undefined,
|
|
83
|
+
...token.htmlStyle,
|
|
84
|
+
} as CSSProperties
|
|
85
|
+
}
|
|
86
|
+
>
|
|
87
|
+
{token.content}
|
|
88
|
+
</span>
|
|
89
|
+
);
|
|
90
|
+
|
|
91
|
+
// Line number styles using CSS counters
|
|
92
|
+
const LINE_NUMBER_CLASSES = cn(
|
|
93
|
+
"block",
|
|
94
|
+
"before:content-[counter(line)]",
|
|
95
|
+
"before:inline-block",
|
|
96
|
+
"before:[counter-increment:line]",
|
|
97
|
+
"before:w-8",
|
|
98
|
+
"before:me-4",
|
|
99
|
+
"before:text-end",
|
|
100
|
+
"before:text-muted-foreground/50",
|
|
101
|
+
"before:font-mono",
|
|
102
|
+
"before:select-none",
|
|
103
|
+
);
|
|
104
|
+
|
|
105
|
+
// Line rendering component
|
|
106
|
+
const LineSpan = ({
|
|
107
|
+
keyedLine,
|
|
108
|
+
showLineNumbers,
|
|
109
|
+
}: {
|
|
110
|
+
keyedLine: KeyedLine;
|
|
111
|
+
showLineNumbers: boolean;
|
|
112
|
+
}) => (
|
|
113
|
+
<span className={showLineNumbers ? LINE_NUMBER_CLASSES : "block"}>
|
|
114
|
+
{keyedLine.tokens.length === 0
|
|
115
|
+
? "\n"
|
|
116
|
+
: keyedLine.tokens.map(({ token, key }) => <TokenSpan key={key} token={token} />)}
|
|
117
|
+
</span>
|
|
118
|
+
);
|
|
119
|
+
|
|
120
|
+
// Types
|
|
121
|
+
export type CodeBlockProps = HTMLAttributes<HTMLDivElement> & {
|
|
122
|
+
code: string;
|
|
123
|
+
language: BundledLanguage;
|
|
124
|
+
showLineNumbers?: boolean;
|
|
125
|
+
/**
|
|
126
|
+
* Soft-wrap long lines instead of scrolling horizontally. Use in narrow embeds
|
|
127
|
+
* (e.g. a side rail) so content is not silently clipped. Default: false.
|
|
128
|
+
*/
|
|
129
|
+
wrap?: boolean;
|
|
130
|
+
/**
|
|
131
|
+
* code is arriving incrementally (loading-states.md isStreaming) — code
|
|
132
|
+
* keeps rendering progressively (build-up), NOT a skeleton, since a
|
|
133
|
+
* streaming code block is not fetch-then-show. Shows a Shimmer generating
|
|
134
|
+
* cue below the content as the in-progress affordance.
|
|
135
|
+
* @default false
|
|
136
|
+
*/
|
|
137
|
+
isStreaming?: boolean;
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
interface TokenizedCode {
|
|
141
|
+
tokens: ThemedToken[][];
|
|
142
|
+
fg: string;
|
|
143
|
+
bg: string;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
interface CodeBlockContextType {
|
|
147
|
+
code: string;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Context
|
|
151
|
+
const CodeBlockContext = createContext<CodeBlockContextType>({
|
|
152
|
+
code: "",
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
// Highlighter cache (singleton per language)
|
|
156
|
+
const highlighterCache = new Map<
|
|
157
|
+
string,
|
|
158
|
+
Promise<HighlighterGeneric<BundledLanguage, BundledTheme>>
|
|
159
|
+
>();
|
|
160
|
+
|
|
161
|
+
// Token cache
|
|
162
|
+
const tokensCache = new Map<string, TokenizedCode>();
|
|
163
|
+
|
|
164
|
+
// Subscribers for async token updates
|
|
165
|
+
const subscribers = new Map<string, Set<(result: TokenizedCode) => void>>();
|
|
166
|
+
|
|
167
|
+
// The theme id is part of the cache key (#315) — the SAME code+language must
|
|
168
|
+
// re-tokenize (and re-cache) when the active brand theme changes, or a code
|
|
169
|
+
// block would keep showing stale colors from the theme active when it was
|
|
170
|
+
// first highlighted. The id is derived from `getThemeScopeKey` (the RAW
|
|
171
|
+
// `data-theme` attribute, or a sentinel when unset) rather than the validated
|
|
172
|
+
// `ThemeName` — see `_code-block-theme.ts`'s module doc comment for why: an
|
|
173
|
+
// unset attribute and an explicit `data-theme="light"` both narrow to
|
|
174
|
+
// the SAME `ThemeName`, but `:root`'s `--code-*` fallback values are their own
|
|
175
|
+
// distinct placeholder palette, not an alias of `light`'s — so keying on
|
|
176
|
+
// the validated name would let the pre-mount render's `:root` colors poison
|
|
177
|
+
// the cache under the key `ThemeProvider` later writes explicitly, and they'd
|
|
178
|
+
// 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
|
+
};
|
|
184
|
+
|
|
185
|
+
const getHighlighter = (
|
|
186
|
+
language: BundledLanguage,
|
|
187
|
+
): Promise<HighlighterGeneric<BundledLanguage, BundledTheme>> => {
|
|
188
|
+
const cached = highlighterCache.get(language);
|
|
189
|
+
if (cached) {
|
|
190
|
+
return cached;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// No themes preloaded here (#315) — the theme is derived from brand tokens
|
|
194
|
+
// per call (`buildCodeBlockTheme`) and passed directly to `codeToTokens`,
|
|
195
|
+
// never a bundled `github-*` literal.
|
|
196
|
+
const highlighterPromise = createHighlighter({
|
|
197
|
+
langs: [language],
|
|
198
|
+
themes: [],
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
highlighterCache.set(language, highlighterPromise);
|
|
202
|
+
return highlighterPromise;
|
|
203
|
+
};
|
|
204
|
+
|
|
205
|
+
// Create raw tokens for immediate display while highlighting loads
|
|
206
|
+
const createRawTokens = (code: string): TokenizedCode => ({
|
|
207
|
+
bg: "transparent",
|
|
208
|
+
fg: "inherit",
|
|
209
|
+
tokens: code.split("\n").map((line) =>
|
|
210
|
+
line === ""
|
|
211
|
+
? []
|
|
212
|
+
: [
|
|
213
|
+
{
|
|
214
|
+
color: "inherit",
|
|
215
|
+
content: line,
|
|
216
|
+
} as ThemedToken,
|
|
217
|
+
],
|
|
218
|
+
),
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
// Synchronous highlight with callback for async results. `callback` stays in
|
|
222
|
+
// its ORIGINAL third position (pre-#315 public API — a positional break here
|
|
223
|
+
// would silently mis-wire any existing 3-arg caller, see #315 follow-up); `el`
|
|
224
|
+
// is a new, purely-additive fourth parameter: the element (default `<html>`)
|
|
225
|
+
// whose active brand theme's `--code-*` tokens the highlighter derives its
|
|
226
|
+
// colors from. Passing a descendant of a region-scoped `<div data-theme="…">`
|
|
227
|
+
// resolves THAT region's theme instead of the document root's.
|
|
228
|
+
export const highlightCode = (
|
|
229
|
+
code: string,
|
|
230
|
+
language: BundledLanguage,
|
|
231
|
+
// oxlint-disable-next-line eslint-plugin-promise(prefer-await-to-callbacks)
|
|
232
|
+
callback?: (result: TokenizedCode) => void,
|
|
233
|
+
el?: Element | null,
|
|
234
|
+
): TokenizedCode | null => {
|
|
235
|
+
const themeId = codeBlockThemeId(getThemeScopeKey(el));
|
|
236
|
+
const tokensCacheKey = getTokensCacheKey(code, language, themeId);
|
|
237
|
+
|
|
238
|
+
// Return cached result if available
|
|
239
|
+
const cached = tokensCache.get(tokensCacheKey);
|
|
240
|
+
if (cached) {
|
|
241
|
+
return cached;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// Subscribe callback if provided
|
|
245
|
+
if (callback) {
|
|
246
|
+
if (!subscribers.has(tokensCacheKey)) {
|
|
247
|
+
subscribers.set(tokensCacheKey, new Set());
|
|
248
|
+
}
|
|
249
|
+
subscribers.get(tokensCacheKey)?.add(callback);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// Start highlighting in background - fire-and-forget async pattern
|
|
253
|
+
getHighlighter(language)
|
|
254
|
+
// oxlint-disable-next-line eslint-plugin-promise(prefer-await-to-then)
|
|
255
|
+
.then((highlighter) => {
|
|
256
|
+
const availableLangs = highlighter.getLoadedLanguages();
|
|
257
|
+
const langToUse = availableLangs.includes(language) ? language : "text";
|
|
258
|
+
|
|
259
|
+
// Derived from brand `--code-*` tokens (#315), never a `github-*` literal.
|
|
260
|
+
// Same `el` the theme NAME above came from, so id/type/colors agree.
|
|
261
|
+
const result = highlighter.codeToTokens(code, {
|
|
262
|
+
lang: langToUse,
|
|
263
|
+
theme: buildCodeBlockTheme(el),
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
const tokenized: TokenizedCode = {
|
|
267
|
+
bg: result.bg ?? "transparent",
|
|
268
|
+
fg: result.fg ?? "inherit",
|
|
269
|
+
tokens: result.tokens,
|
|
270
|
+
};
|
|
271
|
+
|
|
272
|
+
// Cache the result
|
|
273
|
+
tokensCache.set(tokensCacheKey, tokenized);
|
|
274
|
+
|
|
275
|
+
// Notify all subscribers
|
|
276
|
+
const subs = subscribers.get(tokensCacheKey);
|
|
277
|
+
if (subs) {
|
|
278
|
+
for (const sub of subs) {
|
|
279
|
+
sub(tokenized);
|
|
280
|
+
}
|
|
281
|
+
subscribers.delete(tokensCacheKey);
|
|
282
|
+
}
|
|
283
|
+
})
|
|
284
|
+
// oxlint-disable-next-line eslint-plugin-promise(prefer-await-to-then), eslint-plugin-promise(prefer-await-to-callbacks)
|
|
285
|
+
.catch((error) => {
|
|
286
|
+
console.error("Failed to highlight code:", error);
|
|
287
|
+
subscribers.delete(tokensCacheKey);
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
return null;
|
|
291
|
+
};
|
|
292
|
+
|
|
293
|
+
const CodeBlockBody = memo(
|
|
294
|
+
({
|
|
295
|
+
tokenized,
|
|
296
|
+
showLineNumbers,
|
|
297
|
+
wrap,
|
|
298
|
+
className,
|
|
299
|
+
}: {
|
|
300
|
+
tokenized: TokenizedCode;
|
|
301
|
+
showLineNumbers: boolean;
|
|
302
|
+
wrap?: boolean;
|
|
303
|
+
className?: string;
|
|
304
|
+
}) => {
|
|
305
|
+
const preStyle = useMemo(
|
|
306
|
+
() => ({
|
|
307
|
+
backgroundColor: tokenized.bg,
|
|
308
|
+
color: tokenized.fg,
|
|
309
|
+
}),
|
|
310
|
+
[tokenized.bg, tokenized.fg],
|
|
311
|
+
);
|
|
312
|
+
|
|
313
|
+
const keyedLines = useMemo(() => addKeysToTokens(tokenized.tokens), [tokenized.tokens]);
|
|
314
|
+
|
|
315
|
+
return (
|
|
316
|
+
<pre
|
|
317
|
+
className={cn("m-0 p-4 text-sm", wrap && "whitespace-pre-wrap break-words", className)}
|
|
318
|
+
style={preStyle}
|
|
319
|
+
>
|
|
320
|
+
<code
|
|
321
|
+
className={cn(
|
|
322
|
+
"font-mono text-sm",
|
|
323
|
+
showLineNumbers && "[counter-increment:line_0] [counter-reset:line]",
|
|
324
|
+
)}
|
|
325
|
+
>
|
|
326
|
+
{keyedLines.map((keyedLine) => (
|
|
327
|
+
<LineSpan key={keyedLine.key} keyedLine={keyedLine} showLineNumbers={showLineNumbers} />
|
|
328
|
+
))}
|
|
329
|
+
</code>
|
|
330
|
+
</pre>
|
|
331
|
+
);
|
|
332
|
+
},
|
|
333
|
+
(prevProps, nextProps) =>
|
|
334
|
+
prevProps.tokenized === nextProps.tokenized &&
|
|
335
|
+
prevProps.showLineNumbers === nextProps.showLineNumbers &&
|
|
336
|
+
prevProps.wrap === nextProps.wrap &&
|
|
337
|
+
prevProps.className === nextProps.className,
|
|
338
|
+
);
|
|
339
|
+
|
|
340
|
+
CodeBlockBody.displayName = "CodeBlockBody";
|
|
341
|
+
|
|
342
|
+
export const CodeBlockContainer = ({
|
|
343
|
+
className,
|
|
344
|
+
language,
|
|
345
|
+
style,
|
|
346
|
+
...props
|
|
347
|
+
}: HTMLAttributes<HTMLDivElement> & { language: string }) => (
|
|
348
|
+
<div
|
|
349
|
+
className={cn(
|
|
350
|
+
"group relative w-full overflow-hidden rounded-md border bg-background text-foreground",
|
|
351
|
+
className,
|
|
352
|
+
)}
|
|
353
|
+
data-language={language}
|
|
354
|
+
style={{
|
|
355
|
+
containIntrinsicSize: "auto 200px",
|
|
356
|
+
contentVisibility: "auto",
|
|
357
|
+
...style,
|
|
358
|
+
}}
|
|
359
|
+
{...props}
|
|
360
|
+
/>
|
|
361
|
+
);
|
|
362
|
+
|
|
363
|
+
export const CodeBlockHeader = ({
|
|
364
|
+
children,
|
|
365
|
+
className,
|
|
366
|
+
...props
|
|
367
|
+
}: HTMLAttributes<HTMLDivElement>) => (
|
|
368
|
+
<div
|
|
369
|
+
className={cn(
|
|
370
|
+
"flex items-center justify-between border-b bg-muted/80 px-3 py-2 text-muted-foreground text-xs",
|
|
371
|
+
className,
|
|
372
|
+
)}
|
|
373
|
+
{...props}
|
|
374
|
+
>
|
|
375
|
+
{children}
|
|
376
|
+
</div>
|
|
377
|
+
);
|
|
378
|
+
|
|
379
|
+
export const CodeBlockTitle = ({
|
|
380
|
+
children,
|
|
381
|
+
className,
|
|
382
|
+
...props
|
|
383
|
+
}: HTMLAttributes<HTMLDivElement>) => (
|
|
384
|
+
<div className={cn("flex items-center gap-2", className)} {...props}>
|
|
385
|
+
{children}
|
|
386
|
+
</div>
|
|
387
|
+
);
|
|
388
|
+
|
|
389
|
+
export const CodeBlockFilename = ({
|
|
390
|
+
children,
|
|
391
|
+
className,
|
|
392
|
+
...props
|
|
393
|
+
}: HTMLAttributes<HTMLSpanElement>) => (
|
|
394
|
+
<span className={cn("font-mono", className)} {...props}>
|
|
395
|
+
{children}
|
|
396
|
+
</span>
|
|
397
|
+
);
|
|
398
|
+
|
|
399
|
+
export const CodeBlockActions = ({
|
|
400
|
+
children,
|
|
401
|
+
className,
|
|
402
|
+
...props
|
|
403
|
+
}: HTMLAttributes<HTMLDivElement>) => (
|
|
404
|
+
<div className={cn("-my-1 -me-1 flex items-center gap-2", className)} {...props}>
|
|
405
|
+
{children}
|
|
406
|
+
</div>
|
|
407
|
+
);
|
|
408
|
+
|
|
409
|
+
export const CodeBlockContent = ({
|
|
410
|
+
code,
|
|
411
|
+
language,
|
|
412
|
+
showLineNumbers = false,
|
|
413
|
+
wrap = false,
|
|
414
|
+
}: {
|
|
415
|
+
code: string;
|
|
416
|
+
language: BundledLanguage;
|
|
417
|
+
showLineNumbers?: boolean;
|
|
418
|
+
wrap?: boolean;
|
|
419
|
+
}) => {
|
|
420
|
+
// Track the active brand theme (#315), SCOPED to this code block's own
|
|
421
|
+
// subtree (`getThemeScope`) rather than always `<html>` — so a CodeBlock
|
|
422
|
+
// nested inside a region-scoped `<div data-theme="dark">` (a supported
|
|
423
|
+
// 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.
|
|
427
|
+
const scopeRef = useRef<HTMLDivElement>(null);
|
|
428
|
+
const [themeRevision, setThemeRevision] = useState(0);
|
|
429
|
+
|
|
430
|
+
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();
|
|
445
|
+
}, []);
|
|
446
|
+
|
|
447
|
+
const scopeEl = getThemeScope(scopeRef.current);
|
|
448
|
+
// Keyed on the RAW `data-theme` scope, not the validated theme name (#315
|
|
449
|
+
// follow-up) — see `_code-block-theme.ts`'s module doc comment. An unset
|
|
450
|
+
// attribute (the pre-mount render) and an explicit `data-theme="light"`
|
|
451
|
+
// both narrow to the SAME `ThemeName`, so using the validated name here would
|
|
452
|
+
// mean this memo's dependency doesn't CHANGE across that mutation and the
|
|
453
|
+
// stale `:root`-tokenized colors would never be recomputed once
|
|
454
|
+
// `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]);
|
|
457
|
+
|
|
458
|
+
// Memoized raw tokens for immediate display
|
|
459
|
+
const rawTokens = useMemo(() => createRawTokens(code), [code]);
|
|
460
|
+
|
|
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
|
+
);
|
|
472
|
+
|
|
473
|
+
// Async highlighting result (populated after shiki loads)
|
|
474
|
+
const [asyncTokens, setAsyncTokens] = useState<TokenizedCode | null>(null);
|
|
475
|
+
const asyncKeyRef = useRef({ code, language, themeScopeKey });
|
|
476
|
+
|
|
477
|
+
// Invalidate stale async tokens synchronously during render
|
|
478
|
+
if (
|
|
479
|
+
asyncKeyRef.current.code !== code ||
|
|
480
|
+
asyncKeyRef.current.language !== language ||
|
|
481
|
+
asyncKeyRef.current.themeScopeKey !== themeScopeKey
|
|
482
|
+
) {
|
|
483
|
+
asyncKeyRef.current = { code, language, themeScopeKey };
|
|
484
|
+
setAsyncTokens(null);
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
useEffect(() => {
|
|
488
|
+
let cancelled = false;
|
|
489
|
+
|
|
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);
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
return () => {
|
|
513
|
+
cancelled = true;
|
|
514
|
+
};
|
|
515
|
+
}, [code, language, themeScopeKey, scopeEl]);
|
|
516
|
+
|
|
517
|
+
const tokenized = asyncTokens ?? syncTokens;
|
|
518
|
+
|
|
519
|
+
return (
|
|
520
|
+
<div
|
|
521
|
+
ref={scopeRef}
|
|
522
|
+
className={cn(
|
|
523
|
+
"relative overflow-auto",
|
|
524
|
+
// Discoverable horizontal-scroll affordance in non-wrap mode, so a narrow
|
|
525
|
+
// embed doesn't silently clip long lines. Hidden when wrapping (no overflow).
|
|
526
|
+
!wrap &&
|
|
527
|
+
"[scrollbar-width:thin] [&::-webkit-scrollbar]:h-1.5 [&::-webkit-scrollbar-thumb]:rounded-full [&::-webkit-scrollbar-thumb]:bg-border",
|
|
528
|
+
)}
|
|
529
|
+
>
|
|
530
|
+
<CodeBlockBody showLineNumbers={showLineNumbers} tokenized={tokenized} wrap={wrap} />
|
|
531
|
+
</div>
|
|
532
|
+
);
|
|
533
|
+
};
|
|
534
|
+
|
|
535
|
+
export const CodeBlock = ({
|
|
536
|
+
code,
|
|
537
|
+
language,
|
|
538
|
+
showLineNumbers = false,
|
|
539
|
+
wrap = false,
|
|
540
|
+
isStreaming = false,
|
|
541
|
+
className,
|
|
542
|
+
children,
|
|
543
|
+
...props
|
|
544
|
+
}: CodeBlockProps) => {
|
|
545
|
+
const contextValue = useMemo(() => ({ code }), [code]);
|
|
546
|
+
const { t } = useLocale();
|
|
547
|
+
|
|
548
|
+
return (
|
|
549
|
+
<CodeBlockContext.Provider value={contextValue}>
|
|
550
|
+
<CodeBlockContainer className={className} language={language} {...props}>
|
|
551
|
+
{children}
|
|
552
|
+
<CodeBlockContent
|
|
553
|
+
code={code}
|
|
554
|
+
language={language}
|
|
555
|
+
showLineNumbers={showLineNumbers}
|
|
556
|
+
wrap={wrap}
|
|
557
|
+
/>
|
|
558
|
+
{isStreaming ? (
|
|
559
|
+
<div
|
|
560
|
+
className="flex items-center gap-2 border-t bg-muted/80 px-3 py-1.5 text-caption text-muted-foreground"
|
|
561
|
+
role="status"
|
|
562
|
+
aria-live="polite"
|
|
563
|
+
>
|
|
564
|
+
<Shimmer>{t("ai.codeBlock.generating")}</Shimmer>
|
|
565
|
+
</div>
|
|
566
|
+
) : null}
|
|
567
|
+
</CodeBlockContainer>
|
|
568
|
+
</CodeBlockContext.Provider>
|
|
569
|
+
);
|
|
570
|
+
};
|
|
571
|
+
|
|
572
|
+
export type CodeBlockCopyButtonProps = ComponentProps<typeof Button> & {
|
|
573
|
+
onCopy?: () => void;
|
|
574
|
+
onError?: (error: Error) => void;
|
|
575
|
+
timeout?: number;
|
|
576
|
+
};
|
|
577
|
+
|
|
578
|
+
export const CodeBlockCopyButton = ({
|
|
579
|
+
onCopy,
|
|
580
|
+
onError,
|
|
581
|
+
timeout = 2000,
|
|
582
|
+
children,
|
|
583
|
+
className,
|
|
584
|
+
...props
|
|
585
|
+
}: CodeBlockCopyButtonProps) => {
|
|
586
|
+
const [isCopied, setIsCopied] = useState(false);
|
|
587
|
+
const timeoutRef = useRef<number>(0);
|
|
588
|
+
const { code } = useContext(CodeBlockContext);
|
|
589
|
+
|
|
590
|
+
const copyToClipboard = useCallback(async () => {
|
|
591
|
+
if (typeof window === "undefined" || !navigator?.clipboard?.writeText) {
|
|
592
|
+
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
|
+
}
|
|
606
|
+
}, [code, onCopy, onError, timeout, isCopied]);
|
|
607
|
+
|
|
608
|
+
useEffect(
|
|
609
|
+
() => () => {
|
|
610
|
+
window.clearTimeout(timeoutRef.current);
|
|
611
|
+
},
|
|
612
|
+
[],
|
|
613
|
+
);
|
|
614
|
+
|
|
615
|
+
const Icon = isCopied ? CheckIcon : CopyIcon;
|
|
616
|
+
|
|
617
|
+
return (
|
|
618
|
+
<Button
|
|
619
|
+
className={cn("shrink-0", className)}
|
|
620
|
+
onClick={copyToClipboard}
|
|
621
|
+
size="icon"
|
|
622
|
+
variant="ghost"
|
|
623
|
+
{...props}
|
|
624
|
+
>
|
|
625
|
+
{children ?? (
|
|
626
|
+
<span
|
|
627
|
+
key={isCopied ? "check" : "copy"}
|
|
628
|
+
className="flex animate-in fade-in-0 zoom-in-95 duration-fast ease-entrance"
|
|
629
|
+
>
|
|
630
|
+
<Icon size={14} />
|
|
631
|
+
</span>
|
|
632
|
+
)}
|
|
633
|
+
</Button>
|
|
634
|
+
);
|
|
635
|
+
};
|
|
636
|
+
|
|
637
|
+
export type CodeBlockLanguageSelectorProps = ComponentProps<typeof Select>;
|
|
638
|
+
|
|
639
|
+
export const CodeBlockLanguageSelector = (props: CodeBlockLanguageSelectorProps) => (
|
|
640
|
+
<Select {...props} />
|
|
641
|
+
);
|
|
642
|
+
|
|
643
|
+
export type CodeBlockLanguageSelectorTriggerProps = ComponentProps<typeof SelectTrigger>;
|
|
644
|
+
|
|
645
|
+
export const CodeBlockLanguageSelectorTrigger = ({
|
|
646
|
+
className,
|
|
647
|
+
...props
|
|
648
|
+
}: CodeBlockLanguageSelectorTriggerProps) => (
|
|
649
|
+
<SelectTrigger
|
|
650
|
+
className={cn("h-7 border-none bg-transparent px-2 text-xs shadow-none", className)}
|
|
651
|
+
size="sm"
|
|
652
|
+
{...props}
|
|
653
|
+
/>
|
|
654
|
+
);
|
|
655
|
+
|
|
656
|
+
export type CodeBlockLanguageSelectorValueProps = ComponentProps<typeof SelectValue>;
|
|
657
|
+
|
|
658
|
+
export const CodeBlockLanguageSelectorValue = (props: CodeBlockLanguageSelectorValueProps) => (
|
|
659
|
+
<SelectValue {...props} />
|
|
660
|
+
);
|
|
661
|
+
|
|
662
|
+
export type CodeBlockLanguageSelectorContentProps = ComponentProps<typeof SelectContent>;
|
|
663
|
+
|
|
664
|
+
export const CodeBlockLanguageSelectorContent = ({
|
|
665
|
+
align = "end",
|
|
666
|
+
...props
|
|
667
|
+
}: CodeBlockLanguageSelectorContentProps) => <SelectContent align={align} {...props} />;
|
|
668
|
+
|
|
669
|
+
export type CodeBlockLanguageSelectorItemProps = ComponentProps<typeof SelectItem>;
|
|
670
|
+
|
|
671
|
+
export const CodeBlockLanguageSelectorItem = (props: CodeBlockLanguageSelectorItemProps) => (
|
|
672
|
+
<SelectItem {...props} />
|
|
673
|
+
);
|