@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.
Files changed (180) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +133 -0
  3. package/dist/_audio-player-media-chrome-KA5DY54G.js +81 -0
  4. package/dist/_audio-player-media-chrome-KA5DY54G.js.map +1 -0
  5. package/dist/_flow-boundary-D63PJ65S.js +186 -0
  6. package/dist/_flow-boundary-D63PJ65S.js.map +1 -0
  7. package/dist/_interactive-terminal-xterm-ATJ5EW3G.js +11 -0
  8. package/dist/_interactive-terminal-xterm-ATJ5EW3G.js.map +1 -0
  9. package/dist/_persona-rive-RFR2EUWP.js +164 -0
  10. package/dist/_persona-rive-RFR2EUWP.js.map +1 -0
  11. package/dist/index.d.ts +4079 -0
  12. package/dist/index.js +11770 -0
  13. package/dist/index.js.map +1 -0
  14. package/package.json +87 -0
  15. package/src/_audio-player-media-chrome.tsx +146 -0
  16. package/src/_chat-shell-rail.tsx +18 -0
  17. package/src/_code-block-theme.test.ts +134 -0
  18. package/src/_code-block-theme.ts +184 -0
  19. package/src/_flow-boundary.tsx +237 -0
  20. package/src/_flow-lazy.ts +34 -0
  21. package/src/_interactive-terminal-xterm.ts +32 -0
  22. package/src/_lazy-mermaid.test.ts +101 -0
  23. package/src/_lazy-mermaid.ts +117 -0
  24. package/src/_persona-rive.tsx +252 -0
  25. package/src/_streamdown-i18n.ts +119 -0
  26. package/src/agent-timeline.stories.tsx +66 -0
  27. package/src/agent-timeline.test.tsx +74 -0
  28. package/src/agent-timeline.tsx +106 -0
  29. package/src/agent.tsx +113 -0
  30. package/src/agentic-workspace.stories.tsx +1099 -0
  31. package/src/artifact.stories.tsx +62 -0
  32. package/src/artifact.test.tsx +112 -0
  33. package/src/artifact.tsx +167 -0
  34. package/src/asset-preview.stories.tsx +100 -0
  35. package/src/asset-preview.test.tsx +126 -0
  36. package/src/asset-preview.tsx +218 -0
  37. package/src/attachments.tsx +393 -0
  38. package/src/audio-player.stories.tsx +99 -0
  39. package/src/audio-player.tsx +170 -0
  40. package/src/blocks-ai-composer.stories.tsx +83 -0
  41. package/src/canvas.stories.tsx +142 -0
  42. package/src/canvas.tsx +29 -0
  43. package/src/chain-of-thought.tsx +193 -0
  44. package/src/chat-greeting.stories.tsx +96 -0
  45. package/src/chat-greeting.test.tsx +91 -0
  46. package/src/chat-greeting.tsx +91 -0
  47. package/src/chat-shell.stories.tsx +123 -0
  48. package/src/chat-shell.test.tsx +113 -0
  49. package/src/chat-shell.tsx +97 -0
  50. package/src/chat.stories.tsx +99 -0
  51. package/src/checkpoint.tsx +54 -0
  52. package/src/code-block.stories.tsx +81 -0
  53. package/src/code-block.test.tsx +191 -0
  54. package/src/code-block.tsx +673 -0
  55. package/src/commit.tsx +343 -0
  56. package/src/composer.stories.tsx +188 -0
  57. package/src/composer.test.tsx +182 -0
  58. package/src/composer.tsx +202 -0
  59. package/src/confirmation.stories.tsx +61 -0
  60. package/src/confirmation.test.tsx +136 -0
  61. package/src/confirmation.tsx +273 -0
  62. package/src/connection.tsx +23 -0
  63. package/src/context-panel.stories.tsx +137 -0
  64. package/src/context-panel.test.tsx +198 -0
  65. package/src/context-panel.tsx +609 -0
  66. package/src/context.stories.tsx +16 -0
  67. package/src/context.tsx +363 -0
  68. package/src/controls.tsx +20 -0
  69. package/src/conversation.stories.tsx +26 -0
  70. package/src/conversation.test.tsx +123 -0
  71. package/src/conversation.tsx +143 -0
  72. package/src/dark-theme-variant.test.ts +47 -0
  73. package/src/edge.tsx +33 -0
  74. package/src/environment-variables.tsx +304 -0
  75. package/src/file-tree.stories.tsx +55 -0
  76. package/src/file-tree.test.tsx +101 -0
  77. package/src/file-tree.tsx +390 -0
  78. package/src/gallery.stories.tsx +182 -0
  79. package/src/gallery.test.tsx +150 -0
  80. package/src/gallery.tsx +737 -0
  81. package/src/grouped-parts.stories.tsx +104 -0
  82. package/src/grouped-parts.test.tsx +167 -0
  83. package/src/grouped-parts.tsx +247 -0
  84. package/src/image.stories.tsx +67 -0
  85. package/src/image.test.tsx +105 -0
  86. package/src/image.tsx +115 -0
  87. package/src/index.ts +93 -0
  88. package/src/inline-citation.stories.tsx +91 -0
  89. package/src/inline-citation.test.tsx +96 -0
  90. package/src/inline-citation.tsx +314 -0
  91. package/src/interactive-terminal.stories.tsx +165 -0
  92. package/src/interactive-terminal.test.tsx +448 -0
  93. package/src/interactive-terminal.tsx +444 -0
  94. package/src/jsx-preview.stories.tsx +265 -0
  95. package/src/jsx-preview.test.tsx +75 -0
  96. package/src/jsx-preview.tsx +409 -0
  97. package/src/markdown-view.stories.tsx +57 -0
  98. package/src/markdown-view.test.tsx +36 -0
  99. package/src/markdown-view.tsx +113 -0
  100. package/src/message-edit.stories.tsx +150 -0
  101. package/src/message-edit.test.tsx +115 -0
  102. package/src/message-edit.tsx +336 -0
  103. package/src/message-feedback.stories.tsx +53 -0
  104. package/src/message-feedback.test.tsx +62 -0
  105. package/src/message-feedback.tsx +117 -0
  106. package/src/message-form-spec.ts +349 -0
  107. package/src/message-form.stories.tsx +182 -0
  108. package/src/message-form.test.tsx +227 -0
  109. package/src/message-form.tsx +845 -0
  110. package/src/message-table-spec.ts +281 -0
  111. package/src/message-table.stories.tsx +176 -0
  112. package/src/message-table.test.tsx +144 -0
  113. package/src/message-table.tsx +366 -0
  114. package/src/message.stories.tsx +262 -0
  115. package/src/message.test.tsx +508 -0
  116. package/src/message.tsx +619 -0
  117. package/src/mic-selector.tsx +339 -0
  118. package/src/microcopy.test.tsx +123 -0
  119. package/src/model-selector.stories.tsx +73 -0
  120. package/src/model-selector.test.tsx +55 -0
  121. package/src/model-selector.tsx +225 -0
  122. package/src/motion-config.tsx +49 -0
  123. package/src/node.tsx +71 -0
  124. package/src/open-in-chat.tsx +340 -0
  125. package/src/package-info.tsx +205 -0
  126. package/src/panel.tsx +20 -0
  127. package/src/part-groups.ts +258 -0
  128. package/src/persona-sources.ts +59 -0
  129. package/src/persona.stories.tsx +111 -0
  130. package/src/persona.test.tsx +67 -0
  131. package/src/persona.tsx +148 -0
  132. package/src/plan.tsx +135 -0
  133. package/src/prompt-input.stories.tsx +261 -0
  134. package/src/prompt-input.test.tsx +392 -0
  135. package/src/prompt-input.tsx +1668 -0
  136. package/src/queue.tsx +237 -0
  137. package/src/reasoning.stories.tsx +81 -0
  138. package/src/reasoning.test.tsx +50 -0
  139. package/src/reasoning.tsx +229 -0
  140. package/src/sandbox.stories.tsx +62 -0
  141. package/src/sandbox.test.tsx +42 -0
  142. package/src/sandbox.tsx +149 -0
  143. package/src/schema-display.tsx +412 -0
  144. package/src/selection-toolbar.stories.tsx +86 -0
  145. package/src/selection-toolbar.test.tsx +89 -0
  146. package/src/selection-toolbar.tsx +192 -0
  147. package/src/shimmer.stories.tsx +10 -0
  148. package/src/shimmer.tsx +81 -0
  149. package/src/snippet.stories.tsx +19 -0
  150. package/src/snippet.test.tsx +116 -0
  151. package/src/snippet.tsx +138 -0
  152. package/src/sources.stories.tsx +34 -0
  153. package/src/sources.test.tsx +129 -0
  154. package/src/sources.tsx +93 -0
  155. package/src/speech-input.tsx +312 -0
  156. package/src/stack-trace.tsx +479 -0
  157. package/src/streamdown-i18n.test.tsx +163 -0
  158. package/src/suggestion.stories.tsx +75 -0
  159. package/src/suggestion.test.tsx +72 -0
  160. package/src/suggestion.tsx +120 -0
  161. package/src/task.stories.tsx +22 -0
  162. package/src/task.test.tsx +36 -0
  163. package/src/task.tsx +89 -0
  164. package/src/templates-ai-assistant.stories.tsx +142 -0
  165. package/src/terminal.tsx +244 -0
  166. package/src/test-results.stories.tsx +177 -0
  167. package/src/test-results.test.tsx +241 -0
  168. package/src/test-results.tsx +408 -0
  169. package/src/tool-result-card.stories.tsx +143 -0
  170. package/src/tool-result-card.test.tsx +72 -0
  171. package/src/tool-result-card.tsx +86 -0
  172. package/src/tool.stories.tsx +60 -0
  173. package/src/tool.test.tsx +63 -0
  174. package/src/tool.tsx +235 -0
  175. package/src/toolbar.tsx +44 -0
  176. package/src/transcription.tsx +118 -0
  177. package/src/voice-selector.tsx +469 -0
  178. package/src/web-preview.stories.tsx +67 -0
  179. package/src/web-preview.test.tsx +117 -0
  180. 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
+ );