@elabs-ai/components-ai 4.0.0 → 4.1.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 (128) hide show
  1. package/README.md +61 -11
  2. package/dist/{_audio-player-media-chrome-KA5DY54G.js → _audio-player-media-chrome-T3XVXWRZ.js} +8 -4
  3. package/dist/_audio-player-media-chrome-T3XVXWRZ.js.map +1 -0
  4. package/dist/{_flow-boundary-D63PJ65S.js → _flow-boundary-SHNWLQG5.js} +32 -43
  5. package/dist/_flow-boundary-SHNWLQG5.js.map +1 -0
  6. package/dist/{_persona-rive-RFR2EUWP.js → _persona-rive-JEG44YHX.js} +9 -5
  7. package/dist/_persona-rive-JEG44YHX.js.map +1 -0
  8. package/dist/index.d.ts +1109 -202
  9. package/dist/index.js +4867 -2878
  10. package/dist/index.js.map +1 -1
  11. package/package.json +31 -16
  12. package/src/_audio-player-media-chrome.tsx +102 -15
  13. package/src/_flow-boundary.tsx +68 -49
  14. package/src/_lazy-boundary-conformance.ts +38 -0
  15. package/src/_lazy-engine-boundary.tsx +61 -0
  16. package/src/_lazy-mermaid-absent.test.ts +53 -0
  17. package/src/_lazy-mermaid.test.ts +15 -0
  18. package/src/_lazy-mermaid.ts +24 -1
  19. package/src/_mermaid-error-panel.test.tsx +50 -0
  20. package/src/_mermaid-error-panel.tsx +66 -0
  21. package/src/_persona-rive.tsx +62 -10
  22. package/src/_streamdown-i18n.ts +21 -1
  23. package/src/_streamdown-safety.ts +170 -0
  24. package/src/agent-event.stories.tsx +97 -0
  25. package/src/agent-event.test.tsx +145 -0
  26. package/src/agent-event.tsx +187 -0
  27. package/src/agent-timeline.stories.tsx +9 -1
  28. package/src/agent.test.tsx +47 -0
  29. package/src/agent.tsx +28 -14
  30. package/src/agentic-workspace.stories.tsx +27 -20
  31. package/src/artifact.tsx +15 -12
  32. package/src/audio-player.test.tsx +78 -0
  33. package/src/audio-player.tsx +243 -56
  34. package/src/audio-visualizer.stories.tsx +126 -0
  35. package/src/audio-visualizer.test.tsx +438 -0
  36. package/src/audio-visualizer.tsx +367 -0
  37. package/src/canvas.stories.tsx +150 -1
  38. package/src/chat-shell.stories.tsx +18 -3
  39. package/src/chat.stories.tsx +16 -2
  40. package/src/code-block.stories.tsx +9 -1
  41. package/src/composer.stories.tsx +372 -9
  42. package/src/composer.test.tsx +357 -6
  43. package/src/composer.tsx +217 -35
  44. package/src/confirmation.stories.tsx +72 -1
  45. package/src/confirmation.test.tsx +216 -2
  46. package/src/confirmation.tsx +263 -3
  47. package/src/context-panel.stories.tsx +9 -1
  48. package/src/context-panel.tsx +2 -1
  49. package/src/conversation.stories.tsx +63 -2
  50. package/src/conversation.test.tsx +13 -0
  51. package/src/conversation.tsx +20 -2
  52. package/src/diff-view.stories.tsx +196 -0
  53. package/src/diff-view.test.tsx +188 -0
  54. package/src/diff-view.tsx +642 -0
  55. package/src/gallery.tsx +4 -2
  56. package/src/index.ts +14 -4
  57. package/src/jsx-preview.stories.tsx +2 -2
  58. package/src/markdown-view.stories.tsx +92 -1
  59. package/src/markdown-view.test.tsx +232 -1
  60. package/src/markdown-view.tsx +150 -6
  61. package/src/message-compare.stories.tsx +175 -0
  62. package/src/message-compare.test.tsx +207 -0
  63. package/src/message-compare.tsx +453 -0
  64. package/src/message-form.stories.tsx +29 -1
  65. package/src/message.stories.tsx +9 -1
  66. package/src/message.test.tsx +176 -0
  67. package/src/message.tsx +90 -4
  68. package/src/microcopy.test.tsx +40 -0
  69. package/src/{model-selector.stories.tsx → model-provider-logo.stories.tsx} +17 -8
  70. package/src/{model-selector.test.tsx → model-provider-logo.test.tsx} +10 -10
  71. package/src/model-provider-logo.tsx +149 -0
  72. package/src/permission-mode-select.stories.tsx +82 -0
  73. package/src/permission-mode-select.test.tsx +100 -0
  74. package/src/permission-mode-select.tsx +137 -0
  75. package/src/persona-missing-peer.test.tsx +54 -0
  76. package/src/persona.tsx +68 -22
  77. package/src/plan.stories.tsx +166 -0
  78. package/src/plan.test.tsx +267 -0
  79. package/src/plan.tsx +182 -20
  80. package/src/prompt-input-effort.stories.tsx +123 -0
  81. package/src/prompt-input-effort.test.tsx +83 -0
  82. package/src/prompt-input-effort.tsx +136 -0
  83. package/src/prompt-input-mode.stories.tsx +108 -0
  84. package/src/prompt-input-mode.test.tsx +99 -0
  85. package/src/prompt-input-mode.tsx +169 -0
  86. package/src/prompt-input-slash.stories.tsx +211 -0
  87. package/src/prompt-input-slash.test.tsx +262 -0
  88. package/src/prompt-input-slash.tsx +541 -0
  89. package/src/prompt-input.stories.tsx +2 -2
  90. package/src/reasoning.tsx +27 -13
  91. package/src/sandbox.stories.tsx +9 -1
  92. package/src/schema-display.tsx +5 -2
  93. package/src/selection-toolbar.stories.tsx +9 -1
  94. package/src/session-header.stories.tsx +128 -0
  95. package/src/session-header.test.tsx +138 -0
  96. package/src/session-header.tsx +243 -0
  97. package/src/session-status-bar.stories.tsx +73 -0
  98. package/src/session-status-bar.test.tsx +94 -0
  99. package/src/session-status-bar.tsx +165 -0
  100. package/src/snippet.stories.tsx +9 -1
  101. package/src/stack-trace.tsx +6 -2
  102. package/src/streamdown-i18n.test.tsx +1 -1
  103. package/src/task.stories.tsx +13 -3
  104. package/src/templates-ai-assistant.stories.tsx +21 -1
  105. package/src/token-usage.stories.tsx +24 -0
  106. package/src/token-usage.test.tsx +92 -0
  107. package/src/{context.tsx → token-usage.tsx} +67 -53
  108. package/src/tool-result-card.stories.tsx +9 -1
  109. package/src/tool.stories.tsx +13 -3
  110. package/src/tool.tsx +15 -8
  111. package/src/turn-status.stories.tsx +124 -0
  112. package/src/turn-status.test.tsx +74 -0
  113. package/src/turn-status.tsx +174 -0
  114. package/src/use-audio-level.ts +104 -0
  115. package/src/web-preview.tsx +8 -3
  116. package/dist/_audio-player-media-chrome-KA5DY54G.js.map +0 -1
  117. package/dist/_flow-boundary-D63PJ65S.js.map +0 -1
  118. package/dist/_interactive-terminal-xterm-ATJ5EW3G.js +0 -11
  119. package/dist/_interactive-terminal-xterm-ATJ5EW3G.js.map +0 -1
  120. package/dist/_persona-rive-RFR2EUWP.js.map +0 -1
  121. package/src/_interactive-terminal-xterm.ts +0 -32
  122. package/src/blocks-ai-composer.stories.tsx +0 -83
  123. package/src/context.stories.tsx +0 -16
  124. package/src/interactive-terminal.stories.tsx +0 -165
  125. package/src/interactive-terminal.test.tsx +0 -448
  126. package/src/interactive-terminal.tsx +0 -444
  127. package/src/model-selector.tsx +0 -225
  128. package/src/terminal.tsx +0 -244
@@ -0,0 +1,642 @@
1
+ "use client";
2
+
3
+ /**
4
+ * DiffView — line-level unified diff renderer for an agent transcript (#102).
5
+ *
6
+ * An agent's primary output is a code change; the package had no way to show
7
+ * one line-by-line. DiffView is the presentational surface for a `DiffLine[]`
8
+ * someone else computed (parsed a unified diff, a git patch, an LLM tool
9
+ * result) — it never diffs, fetches or parses a patch itself (D5).
10
+ *
11
+ * Package placement + the `ChangeReview` seam are BINDING decisions, not
12
+ * choices this file makes on its own — see
13
+ * `docs/decisions/2026-09-01-brainless-adoption-architecture.md` § 3:
14
+ * - DiffView ships here (`@elabs-ai/components-ai`), never in
15
+ * `@elabs-ai/components-ui`, because it reuses `highlightCode` (Shiki) and
16
+ * `ui` does not depend on Shiki.
17
+ * - `ChangeHunk` (`ui`) gains NO `lines` field and `DiffLine` never moves to
18
+ * `ui`. A consumer composes the two via `ChangeReview`'s existing
19
+ * injection seams — `renderHunk` or `ChangeHunk.after` (both `ReactNode`)
20
+ * — never a cross-package import in either direction. See the
21
+ * `ChangeReviewComposition` story.
22
+ *
23
+ * Reuses `highlightCode` from `./code-block` (Shiki) for intra-line syntax
24
+ * colour, so a diff and a code block agree on tokenisation and theme — real
25
+ * `add`/`del`/`context` lines are re-joined into one document before
26
+ * highlighting so Shiki sees genuine surrounding context, then the result is
27
+ * mapped back onto the original line indices. `hunk`/`meta` lines (diff
28
+ * headers, not source) are excluded from that document.
29
+ *
30
+ * Colour is never the only channel (accessibility.md, WCAG 1.4.1): every
31
+ * `add`/`del` row carries a screen-reader polarity word AND a `+`/`−` marker
32
+ * glyph in its own column — the row tint (`bg-success/10` / `bg-destructive/10`,
33
+ * the fill rung) is the third, redundant cue, never the only one.
34
+ *
35
+ * The `DiffLine` model and the polarity-prefix technique are adapted from
36
+ * `theswerd/brainless` (MIT) — see `ATTRIBUTION.md`.
37
+ */
38
+
39
+ import {
40
+ forwardRef,
41
+ useCallback,
42
+ useEffect,
43
+ useMemo,
44
+ useRef,
45
+ useState,
46
+ type ComponentProps,
47
+ type KeyboardEvent,
48
+ } from "react";
49
+ import {
50
+ Skeleton,
51
+ diffLineAccessibleLabel,
52
+ diffLineMarker,
53
+ useDiffRows,
54
+ useLocale,
55
+ type DiffLine,
56
+ } from "@elabs-ai/components-ui";
57
+ import { cn } from "@elabs-ai/components-ui/lib/cn";
58
+ import { cva } from "class-variance-authority";
59
+ import type { BundledLanguage, ThemedToken } from "shiki";
60
+ import { highlightCode } from "./code-block";
61
+ import { Shimmer } from "./shimmer";
62
+
63
+ // ─── Public types ───────────────────────────────────────────────────────────
64
+ //
65
+ // `DiffLineType` and `DiffLine` moved to `@elabs-ai/components-ui`
66
+ // (`lib/diff-line.ts`) — the terminal CLI look-alike family's own diff hunk
67
+ // (issue #117) reuses the same model, and `@elabs-ai/components-ai`/
68
+ // `@elabs-ai/components-terminal` are layer-2 DAG siblings that may not
69
+ // import each other (T0; see
70
+ // docs/decisions/2026-09-01-brainless-adoption-architecture.md § 4). Imported
71
+ // above; NOT re-exported from here — `@elabs-ai/components-ai` never
72
+ // re-exports from `@elabs-ai/components-ui` (its barrel stays own-module
73
+ // only). A consumer imports `DiffLine`/`DiffLineType` from
74
+ // `@elabs-ai/components-ui`.
75
+
76
+ export interface DiffViewProps extends Omit<ComponentProps<"div">, "children"> {
77
+ /** The diff, as a flat, ordered line list. */
78
+ lines: DiffLine[];
79
+ /** File path shown in the header. */
80
+ file?: string;
81
+ /** One-line summary shown beside the file name. */
82
+ summary?: string;
83
+ /** Added / removed line counts shown in the header. */
84
+ stats?: { additions: number; deletions: number };
85
+ /** `inline` = unified single column; `split` = side-by-side. @default "inline" */
86
+ variant?: "inline" | "split";
87
+ /**
88
+ * Full-screen reading surface: a scrollable region with a scroll-position
89
+ * indicator, a key legend (↑/↓, Page Up/Down, Home/End), and a named
90
+ * `role="region"`. Absorbs the upstream `CodexDiff` pager shape — this
91
+ * prop, not a second component.
92
+ */
93
+ pager?: boolean;
94
+ /** Shiki language id for intra-line syntax colour. Omit to render plain text. */
95
+ language?: BundledLanguage;
96
+ /** Collapse a run of consecutive `context` lines longer than this behind a "show more" control. */
97
+ contextLines?: number;
98
+ /** Hard cap on rendered lines. Extra lines are simply not rendered. */
99
+ maxLines?: number;
100
+ /** No renderable diff yet (loading-states.md) — layout-shaped skeleton rows at the real row height. */
101
+ loading?: boolean;
102
+ /** Lines are still arriving (loading-states.md) — render what exists; never an error surface. */
103
+ isStreaming?: boolean;
104
+ }
105
+
106
+ // ─── Row styling (one visual axis: line type → tint) ───────────────────────
107
+
108
+ /**
109
+ * The row's fill-rung tint — the THIRD, redundant cue (accessibility.md /
110
+ * styling-and-tokens.md). Never the only signal: every `add`/`del` row also
111
+ * carries a marker glyph (shape) and an `sr-only` polarity word (name).
112
+ */
113
+ export const diffRowVariants = cva(
114
+ "grid grid-cols-[2.75rem_2.75rem_1.25rem_1fr] items-start gap-x-2 px-2 py-0.5",
115
+ {
116
+ variants: {
117
+ type: {
118
+ add: "bg-success/10",
119
+ del: "bg-destructive/10",
120
+ context: "",
121
+ hunk: "bg-muted/40",
122
+ meta: "",
123
+ },
124
+ },
125
+ defaultVariants: { type: "context" },
126
+ },
127
+ );
128
+
129
+ const GUTTER_CLASS = "select-none text-end text-meta text-muted-foreground tabular-nums";
130
+
131
+ // Marker GLYPHS (`diffLineMarker`) and screen-reader polarity LABELS
132
+ // (`diffLineAccessibleLabel`) are promoted to `@elabs-ai/components-ui`
133
+ // (`lib/diff-line.ts`) — imported above. The tint below stays here: it is
134
+ // presentational (Tailwind token classes), not part of the shared model.
135
+
136
+ const MARKER_TONE: Record<"add" | "del" | "context", string> = {
137
+ add: "text-success-text",
138
+ del: "text-destructive-text",
139
+ context: "text-muted-foreground",
140
+ };
141
+
142
+ // ─── Theme scope (mirrors code-block.tsx's private helper — not a fork of it) ──
143
+
144
+ const getThemeScope = (el: Element | null): Element | null =>
145
+ el?.closest("[data-theme]") ??
146
+ (typeof document !== "undefined" ? document.documentElement : null);
147
+
148
+ // ─── Intra-line syntax highlighting ────────────────────────────────────────
149
+
150
+ type TokenizedResult = NonNullable<ReturnType<typeof highlightCode>>;
151
+
152
+ /**
153
+ * Highlights only the lines that are real source (`add` / `del` / `context`)
154
+ * as one combined document, so Shiki sees genuine surrounding context instead
155
+ * of tokenizing each line in isolation, then maps the result back onto the
156
+ * original line indices. `hunk` / `meta` lines are diff headers, not code, and
157
+ * are excluded from the document entirely.
158
+ */
159
+ function useDiffTokens(
160
+ lines: DiffLine[],
161
+ language: BundledLanguage | undefined,
162
+ scopeEl: Element | null,
163
+ ): Map<number, ThemedToken[]> | null {
164
+ const codeIndices = useMemo(
165
+ () =>
166
+ lines
167
+ .map((line, index) => ({ line, index }))
168
+ .filter(({ line }) => line.type !== "hunk" && line.type !== "meta")
169
+ .map(({ index }) => index),
170
+ [lines],
171
+ );
172
+ const combinedCode = useMemo(
173
+ () => codeIndices.map((index) => lines[index]?.text ?? "").join("\n"),
174
+ [codeIndices, lines],
175
+ );
176
+
177
+ const [result, setResult] = useState<TokenizedResult | null>(null);
178
+
179
+ useEffect(() => {
180
+ if (!language || combinedCode.length === 0) {
181
+ setResult(null);
182
+ return;
183
+ }
184
+ let cancelled = false;
185
+ const cached = highlightCode(
186
+ combinedCode,
187
+ language,
188
+ (r) => {
189
+ if (!cancelled) setResult(r);
190
+ },
191
+ scopeEl,
192
+ );
193
+ if (cached) setResult(cached);
194
+ return () => {
195
+ cancelled = true;
196
+ };
197
+ }, [combinedCode, language, scopeEl]);
198
+
199
+ return useMemo(() => {
200
+ if (!language || !result) return null;
201
+ const map = new Map<number, ThemedToken[]>();
202
+ codeIndices.forEach((originalIndex, i) => {
203
+ const tokenLine = result.tokens[i];
204
+ if (tokenLine) map.set(originalIndex, tokenLine);
205
+ });
206
+ return map;
207
+ }, [codeIndices, result, language]);
208
+ }
209
+
210
+ /** Renders pre-highlighted tokens; colour only (a diff row doesn't need bold/italic/underline). */
211
+ function TokenText({ tokens, fallback }: { tokens?: ThemedToken[]; fallback: string }) {
212
+ if (!tokens) return <>{fallback}</>;
213
+ return (
214
+ <>
215
+ {tokens.map((token, i) => (
216
+ // oxlint-disable-next-line eslint(react/no-array-index-key) -- tokens for one line never reorder
217
+ <span key={i} style={{ color: token.color }}>
218
+ {token.content}
219
+ </span>
220
+ ))}
221
+ </>
222
+ );
223
+ }
224
+
225
+ // ─── Context-run collapsing ─────────────────────────────────────────────────
226
+ //
227
+ // The row-collapsing model (`DiffRow`, `collapseDiffRows`, `useDiffRows`,
228
+ // formerly this file's private `Row`/`useVisibleRows`) is promoted to
229
+ // `@elabs-ai/components-ui` (`lib/diff-rows.ts`) — imported above. It is pure
230
+ // (no Shiki, no DOM) and the terminal CLI look-alike family's own diff hunk
231
+ // (issue #117) reuses it unchanged; see
232
+ // docs/decisions/2026-09-01-brainless-adoption-architecture.md § 4.
233
+
234
+ function CollapsedRow({ hiddenCount, onExpand }: { hiddenCount: number; onExpand: () => void }) {
235
+ const { t } = useLocale();
236
+ return (
237
+ <div
238
+ data-slot="diff-view-collapsed"
239
+ className="col-span-full grid grid-cols-[2.75rem_2.75rem_1.25rem_1fr]"
240
+ >
241
+ <button
242
+ type="button"
243
+ onClick={onExpand}
244
+ className="col-start-4 w-fit rounded-sm px-1 text-info-text text-meta hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
245
+ >
246
+ {t("ai.diffView.showMore", { count: hiddenCount })}
247
+ </button>
248
+ </div>
249
+ );
250
+ }
251
+
252
+ // ─── Full-width rows (hunk / meta) ──────────────────────────────────────────
253
+
254
+ function FullWidthRow({ line }: { line: DiffLine }) {
255
+ return (
256
+ <div
257
+ data-slot="diff-view-row"
258
+ data-diff-type={line.type}
259
+ className={cn(
260
+ diffRowVariants({ type: line.type as "hunk" | "meta" }),
261
+ "col-span-full block font-mono text-meta text-muted-foreground",
262
+ line.type === "hunk" && "italic",
263
+ )}
264
+ >
265
+ {line.text || " "}
266
+ </div>
267
+ );
268
+ }
269
+
270
+ // ─── Inline rows ────────────────────────────────────────────────────────────
271
+
272
+ function InlineRow({
273
+ line,
274
+ index,
275
+ tokens,
276
+ }: {
277
+ line: DiffLine;
278
+ index: number;
279
+ tokens?: ThemedToken[];
280
+ }) {
281
+ const { t } = useLocale();
282
+ if (line.type === "hunk" || line.type === "meta") {
283
+ return <FullWidthRow line={line} />;
284
+ }
285
+ const type = line.type;
286
+ const polarityKey = diffLineAccessibleLabel(type);
287
+ return (
288
+ <div
289
+ data-slot="diff-view-row"
290
+ data-diff-type={type}
291
+ data-diff-index={index}
292
+ className={diffRowVariants({ type })}
293
+ >
294
+ <span data-slot="diff-view-gutter-old" className={GUTTER_CLASS} aria-hidden="true">
295
+ {type !== "add" ? (line.oldNumber ?? "") : ""}
296
+ </span>
297
+ <span data-slot="diff-view-gutter-new" className={GUTTER_CLASS} aria-hidden="true">
298
+ {type !== "del" ? (line.newNumber ?? "") : ""}
299
+ </span>
300
+ <span
301
+ data-slot="diff-view-marker"
302
+ aria-hidden="true"
303
+ className={cn("select-none font-bold font-mono text-code", MARKER_TONE[type])}
304
+ >
305
+ {diffLineMarker(type)}
306
+ </span>
307
+ <span
308
+ data-slot="diff-view-line-text"
309
+ className="whitespace-pre-wrap break-all font-mono text-code"
310
+ >
311
+ {polarityKey && <span className="sr-only">{t(polarityKey)}</span>}
312
+ <TokenText tokens={tokens} fallback={line.text || " "} />
313
+ </span>
314
+ </div>
315
+ );
316
+ }
317
+
318
+ // ─── Split rows (two aligned columns: old-file view | new-file view) ───────
319
+
320
+ function SplitSide({
321
+ show,
322
+ number,
323
+ markerType,
324
+ polarityKey,
325
+ text,
326
+ tokens,
327
+ }: {
328
+ show: boolean;
329
+ number?: number;
330
+ markerType: "add" | "del" | "context";
331
+ polarityKey?: ReturnType<typeof diffLineAccessibleLabel>;
332
+ text: string;
333
+ tokens?: ThemedToken[];
334
+ }) {
335
+ const { t } = useLocale();
336
+ if (!show) {
337
+ return (
338
+ <span aria-hidden="true" className="block">
339
+ {" "}
340
+ </span>
341
+ );
342
+ }
343
+ return (
344
+ <div className="grid grid-cols-[2.75rem_1.25rem_1fr] items-start gap-x-2">
345
+ <span className={GUTTER_CLASS} aria-hidden="true">
346
+ {number ?? ""}
347
+ </span>
348
+ <span
349
+ aria-hidden="true"
350
+ className={cn("select-none font-bold font-mono text-code", MARKER_TONE[markerType])}
351
+ >
352
+ {diffLineMarker(markerType)}
353
+ </span>
354
+ <span className="whitespace-pre-wrap break-all font-mono text-code">
355
+ {polarityKey && <span className="sr-only">{t(polarityKey)}</span>}
356
+ <TokenText tokens={tokens} fallback={text || " "} />
357
+ </span>
358
+ </div>
359
+ );
360
+ }
361
+
362
+ function SplitRow({
363
+ line,
364
+ index,
365
+ tokens,
366
+ }: {
367
+ line: DiffLine;
368
+ index: number;
369
+ tokens?: ThemedToken[];
370
+ }) {
371
+ if (line.type === "hunk" || line.type === "meta") {
372
+ return <FullWidthRow line={line} />;
373
+ }
374
+ const type = line.type;
375
+ const showOld = type !== "add";
376
+ const showNew = type !== "del";
377
+ return (
378
+ <div data-slot="diff-view-row" data-diff-type={type} data-diff-index={index} className="flex">
379
+ <div className={cn("flex-1 px-2 py-0.5", showOld && diffRowVariants({ type }))}>
380
+ <SplitSide
381
+ show={showOld}
382
+ number={line.oldNumber}
383
+ markerType={type === "del" ? "del" : "context"}
384
+ polarityKey={diffLineAccessibleLabel(type)}
385
+ text={line.text}
386
+ tokens={tokens}
387
+ />
388
+ </div>
389
+ <div
390
+ className={cn(
391
+ "flex-1 border-s border-s-border-strong px-2 py-0.5",
392
+ showNew && diffRowVariants({ type }),
393
+ )}
394
+ >
395
+ <SplitSide
396
+ show={showNew}
397
+ number={line.newNumber}
398
+ markerType={type === "add" ? "add" : "context"}
399
+ polarityKey={diffLineAccessibleLabel(type)}
400
+ text={line.text}
401
+ tokens={tokens}
402
+ />
403
+ </div>
404
+ </div>
405
+ );
406
+ }
407
+
408
+ // ─── Skeleton (loading-states.md) ───────────────────────────────────────────
409
+
410
+ function SkeletonRow() {
411
+ return (
412
+ <div data-slot="diff-view-row" className={diffRowVariants({ type: "context" })}>
413
+ <Skeleton className="ms-auto h-3 w-6" />
414
+ <Skeleton className="ms-auto h-3 w-6" />
415
+ <span />
416
+ <Skeleton className="h-3 w-2/3" />
417
+ </div>
418
+ );
419
+ }
420
+
421
+ // ─── Header ─────────────────────────────────────────────────────────────────
422
+
423
+ function DiffViewHeader({
424
+ file,
425
+ summary,
426
+ stats,
427
+ }: {
428
+ file?: string;
429
+ summary?: string;
430
+ stats?: { additions: number; deletions: number };
431
+ }) {
432
+ const { t } = useLocale();
433
+ return (
434
+ <div
435
+ data-slot="diff-view-header"
436
+ className="flex items-center justify-between gap-3 border-b bg-muted/80 px-3 py-2 text-meta text-muted-foreground"
437
+ >
438
+ <div className="flex min-w-0 items-center gap-2">
439
+ {file && (
440
+ <span className="truncate font-mono text-code" data-slot="diff-view-file">
441
+ {file}
442
+ </span>
443
+ )}
444
+ {summary && <span className="truncate">{summary}</span>}
445
+ </div>
446
+ {stats && (
447
+ <div data-slot="diff-view-stats" className="flex shrink-0 items-center gap-2 tabular-nums">
448
+ <span className="sr-only">
449
+ {t("ai.diffView.statsSummary", {
450
+ additions: stats.additions,
451
+ deletions: stats.deletions,
452
+ })}
453
+ </span>
454
+ <span aria-hidden="true" className="font-mono text-success-text">
455
+ +{stats.additions}
456
+ </span>
457
+ <span aria-hidden="true" className="font-mono text-destructive-text">
458
+ {"−"}
459
+ {stats.deletions}
460
+ </span>
461
+ </div>
462
+ )}
463
+ </div>
464
+ );
465
+ }
466
+
467
+ // ─── Pager (absorbs the upstream CodexDiff shape) ──────────────────────────
468
+
469
+ function DiffViewPager({
470
+ children,
471
+ regionLabel,
472
+ }: {
473
+ children: React.ReactNode;
474
+ regionLabel: string;
475
+ }) {
476
+ const { t } = useLocale();
477
+ const scrollRef = useRef<HTMLDivElement>(null);
478
+ const [percent, setPercent] = useState(0);
479
+
480
+ const updatePercent = useCallback(() => {
481
+ const el = scrollRef.current;
482
+ if (!el) return;
483
+ const max = el.scrollHeight - el.clientHeight;
484
+ setPercent(max <= 0 ? 100 : Math.round((el.scrollTop / max) * 100));
485
+ }, []);
486
+
487
+ const onKeyDown = useCallback((event: KeyboardEvent<HTMLDivElement>) => {
488
+ const el = scrollRef.current;
489
+ if (!el) return;
490
+ switch (event.key) {
491
+ case "ArrowDown":
492
+ el.scrollBy({ top: 24 });
493
+ break;
494
+ case "ArrowUp":
495
+ el.scrollBy({ top: -24 });
496
+ break;
497
+ case "PageDown":
498
+ el.scrollBy({ top: el.clientHeight });
499
+ break;
500
+ case "PageUp":
501
+ el.scrollBy({ top: -el.clientHeight });
502
+ break;
503
+ case "Home":
504
+ el.scrollTo({ top: 0 });
505
+ break;
506
+ case "End":
507
+ el.scrollTo({ top: el.scrollHeight });
508
+ break;
509
+ default:
510
+ return;
511
+ }
512
+ event.preventDefault();
513
+ }, []);
514
+
515
+ return (
516
+ <div
517
+ data-slot="diff-view-pager"
518
+ role="region"
519
+ aria-label={regionLabel}
520
+ className="flex flex-col"
521
+ >
522
+ <div
523
+ ref={scrollRef}
524
+ tabIndex={0}
525
+ onScroll={updatePercent}
526
+ onKeyDown={onKeyDown}
527
+ className="max-h-[32rem] overflow-y-auto focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset"
528
+ >
529
+ {children}
530
+ </div>
531
+ <div
532
+ data-slot="diff-view-pager-footer"
533
+ className="flex items-center justify-between border-t bg-muted/50 px-3 py-1.5 text-meta text-muted-foreground"
534
+ >
535
+ <span>{t("ai.diffView.pagerLegend")}</span>
536
+ <span aria-hidden="true" className="tabular-nums">
537
+ {percent}%
538
+ </span>
539
+ </div>
540
+ </div>
541
+ );
542
+ }
543
+
544
+ // ─── Root ───────────────────────────────────────────────────────────────────
545
+
546
+ export const DiffView = forwardRef<HTMLDivElement, DiffViewProps>(function DiffView(
547
+ {
548
+ lines,
549
+ file,
550
+ summary,
551
+ stats,
552
+ variant = "inline",
553
+ pager = false,
554
+ language,
555
+ contextLines,
556
+ maxLines,
557
+ loading = false,
558
+ isStreaming = false,
559
+ className,
560
+ ...props
561
+ },
562
+ ref,
563
+ ) {
564
+ const { t } = useLocale();
565
+ const containerRef = useRef<HTMLDivElement>(null);
566
+ const [scopeEl, setScopeEl] = useState<Element | null>(null);
567
+
568
+ useEffect(() => {
569
+ setScopeEl(getThemeScope(containerRef.current));
570
+ }, []);
571
+
572
+ const clippedLines = useMemo(
573
+ () => (maxLines ? lines.slice(0, maxLines) : lines),
574
+ [lines, maxLines],
575
+ );
576
+ const tokens = useDiffTokens(clippedLines, language, scopeEl);
577
+ const { rows, expand } = useDiffRows(clippedLines, contextLines);
578
+
579
+ const RowComponent = variant === "split" ? SplitRow : InlineRow;
580
+
581
+ const regionLabel = file
582
+ ? `${t("ai.diffView.regionLabel")}: ${file}`
583
+ : t("ai.diffView.regionLabel");
584
+
585
+ const body = loading ? (
586
+ <div data-slot="diff-view-body">
587
+ <span role="status" aria-live="polite" className="sr-only">
588
+ {t("ai.diffView.loading")}
589
+ </span>
590
+ {Array.from({
591
+ length: clippedLines.length > 0 ? Math.min(clippedLines.length, 12) : 6,
592
+ }).map((_, i) => (
593
+ // oxlint-disable-next-line eslint(react/no-array-index-key) -- fixed-count placeholder rows
594
+ <SkeletonRow key={i} />
595
+ ))}
596
+ </div>
597
+ ) : (
598
+ <div data-slot="diff-view-body">
599
+ {rows.map((row) =>
600
+ row.kind === "collapsed" ? (
601
+ <CollapsedRow
602
+ key={`collapsed-${row.runStart}`}
603
+ hiddenCount={row.hiddenCount}
604
+ onExpand={() => expand(row.runStart)}
605
+ />
606
+ ) : (
607
+ <RowComponent
608
+ key={row.index}
609
+ line={row.line}
610
+ index={row.index}
611
+ tokens={tokens?.get(row.index)}
612
+ />
613
+ ),
614
+ )}
615
+ </div>
616
+ );
617
+
618
+ return (
619
+ <div
620
+ ref={(node) => {
621
+ containerRef.current = node;
622
+ if (typeof ref === "function") ref(node);
623
+ else if (ref) ref.current = node;
624
+ }}
625
+ data-slot="diff-view"
626
+ className={cn("overflow-hidden rounded-md border bg-background text-foreground", className)}
627
+ {...props}
628
+ >
629
+ {(file || summary || stats) && <DiffViewHeader file={file} summary={summary} stats={stats} />}
630
+ {pager ? <DiffViewPager regionLabel={regionLabel}>{body}</DiffViewPager> : body}
631
+ {isStreaming && (
632
+ <div
633
+ className="flex items-center gap-2 border-t bg-muted/80 px-3 py-1.5 text-caption text-muted-foreground"
634
+ role="status"
635
+ aria-live="polite"
636
+ >
637
+ <Shimmer>{t("ai.codeBlock.generating")}</Shimmer>
638
+ </div>
639
+ )}
640
+ </div>
641
+ );
642
+ });
package/src/gallery.tsx CHANGED
@@ -317,10 +317,11 @@ function GalleryTilt({ className, children }: { className?: string; children: Re
317
317
  }
318
318
 
319
319
  function GalleryEmpty() {
320
+ const { t } = useLocale();
320
321
  return (
321
322
  <div className="flex min-h-32 flex-col items-center justify-center gap-2 p-6 text-center text-muted-foreground">
322
323
  <ImageOff className="size-6" aria-hidden="true" />
323
- <p className="text-body">No images</p>
324
+ <p className="text-body">{t("ai.gallery.noImages")}</p>
324
325
  </div>
325
326
  );
326
327
  }
@@ -485,6 +486,7 @@ function GalleryDetailPanel({
485
486
  index: number;
486
487
  renderMeta?: GalleryProps["renderMeta"];
487
488
  }) {
489
+ const { t } = useLocale();
488
490
  const custom = renderMeta?.(image, index);
489
491
  if (custom != null) {
490
492
  return <div className="flex flex-col gap-4">{custom}</div>;
@@ -494,7 +496,7 @@ function GalleryDetailPanel({
494
496
  const hasBody = Boolean(image.description) || hasMeta;
495
497
 
496
498
  if (!hasBody) {
497
- return <p className="text-body text-muted-foreground">No details</p>;
499
+ return <p className="text-body text-muted-foreground">{t("ai.gallery.noDetails")}</p>;
498
500
  }
499
501
 
500
502
  return (