@elabs-ai/components-ai 4.0.0 → 4.2.0

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