@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.
- package/README.md +61 -11
- package/dist/{_audio-player-media-chrome-KA5DY54G.js → _audio-player-media-chrome-T3XVXWRZ.js} +8 -4
- package/dist/_audio-player-media-chrome-T3XVXWRZ.js.map +1 -0
- package/dist/{_flow-boundary-D63PJ65S.js → _flow-boundary-SHNWLQG5.js} +32 -43
- package/dist/_flow-boundary-SHNWLQG5.js.map +1 -0
- package/dist/{_persona-rive-RFR2EUWP.js → _persona-rive-JEG44YHX.js} +9 -5
- package/dist/_persona-rive-JEG44YHX.js.map +1 -0
- package/dist/index.d.ts +1109 -202
- package/dist/index.js +4867 -2878
- package/dist/index.js.map +1 -1
- package/package.json +31 -16
- package/src/_audio-player-media-chrome.tsx +102 -15
- package/src/_flow-boundary.tsx +68 -49
- package/src/_lazy-boundary-conformance.ts +38 -0
- package/src/_lazy-engine-boundary.tsx +61 -0
- package/src/_lazy-mermaid-absent.test.ts +53 -0
- package/src/_lazy-mermaid.test.ts +15 -0
- package/src/_lazy-mermaid.ts +24 -1
- package/src/_mermaid-error-panel.test.tsx +50 -0
- package/src/_mermaid-error-panel.tsx +66 -0
- package/src/_persona-rive.tsx +62 -10
- package/src/_streamdown-i18n.ts +21 -1
- package/src/_streamdown-safety.ts +170 -0
- package/src/agent-event.stories.tsx +97 -0
- package/src/agent-event.test.tsx +145 -0
- package/src/agent-event.tsx +187 -0
- package/src/agent-timeline.stories.tsx +9 -1
- package/src/agent.test.tsx +47 -0
- package/src/agent.tsx +28 -14
- package/src/agentic-workspace.stories.tsx +27 -20
- package/src/artifact.tsx +15 -12
- package/src/audio-player.test.tsx +78 -0
- package/src/audio-player.tsx +243 -56
- package/src/audio-visualizer.stories.tsx +126 -0
- package/src/audio-visualizer.test.tsx +438 -0
- package/src/audio-visualizer.tsx +367 -0
- package/src/canvas.stories.tsx +150 -1
- package/src/chat-shell.stories.tsx +18 -3
- package/src/chat.stories.tsx +16 -2
- package/src/code-block.stories.tsx +9 -1
- package/src/composer.stories.tsx +372 -9
- package/src/composer.test.tsx +357 -6
- package/src/composer.tsx +217 -35
- package/src/confirmation.stories.tsx +72 -1
- package/src/confirmation.test.tsx +216 -2
- package/src/confirmation.tsx +263 -3
- package/src/context-panel.stories.tsx +9 -1
- package/src/context-panel.tsx +2 -1
- package/src/conversation.stories.tsx +63 -2
- package/src/conversation.test.tsx +13 -0
- package/src/conversation.tsx +20 -2
- package/src/diff-view.stories.tsx +196 -0
- package/src/diff-view.test.tsx +188 -0
- package/src/diff-view.tsx +642 -0
- package/src/gallery.tsx +4 -2
- package/src/index.ts +14 -4
- package/src/jsx-preview.stories.tsx +2 -2
- package/src/markdown-view.stories.tsx +92 -1
- package/src/markdown-view.test.tsx +232 -1
- package/src/markdown-view.tsx +150 -6
- package/src/message-compare.stories.tsx +175 -0
- package/src/message-compare.test.tsx +207 -0
- package/src/message-compare.tsx +453 -0
- package/src/message-form.stories.tsx +29 -1
- package/src/message.stories.tsx +9 -1
- package/src/message.test.tsx +176 -0
- package/src/message.tsx +90 -4
- package/src/microcopy.test.tsx +40 -0
- package/src/{model-selector.stories.tsx → model-provider-logo.stories.tsx} +17 -8
- package/src/{model-selector.test.tsx → model-provider-logo.test.tsx} +10 -10
- package/src/model-provider-logo.tsx +149 -0
- package/src/permission-mode-select.stories.tsx +82 -0
- package/src/permission-mode-select.test.tsx +100 -0
- package/src/permission-mode-select.tsx +137 -0
- package/src/persona-missing-peer.test.tsx +54 -0
- package/src/persona.tsx +68 -22
- package/src/plan.stories.tsx +166 -0
- package/src/plan.test.tsx +267 -0
- package/src/plan.tsx +182 -20
- package/src/prompt-input-effort.stories.tsx +123 -0
- package/src/prompt-input-effort.test.tsx +83 -0
- package/src/prompt-input-effort.tsx +136 -0
- package/src/prompt-input-mode.stories.tsx +108 -0
- package/src/prompt-input-mode.test.tsx +99 -0
- package/src/prompt-input-mode.tsx +169 -0
- package/src/prompt-input-slash.stories.tsx +211 -0
- package/src/prompt-input-slash.test.tsx +262 -0
- package/src/prompt-input-slash.tsx +541 -0
- package/src/prompt-input.stories.tsx +2 -2
- package/src/reasoning.tsx +27 -13
- package/src/sandbox.stories.tsx +9 -1
- package/src/schema-display.tsx +5 -2
- package/src/selection-toolbar.stories.tsx +9 -1
- package/src/session-header.stories.tsx +128 -0
- package/src/session-header.test.tsx +138 -0
- package/src/session-header.tsx +243 -0
- package/src/session-status-bar.stories.tsx +73 -0
- package/src/session-status-bar.test.tsx +94 -0
- package/src/session-status-bar.tsx +165 -0
- package/src/snippet.stories.tsx +9 -1
- package/src/stack-trace.tsx +6 -2
- package/src/streamdown-i18n.test.tsx +1 -1
- package/src/task.stories.tsx +13 -3
- package/src/templates-ai-assistant.stories.tsx +21 -1
- package/src/token-usage.stories.tsx +24 -0
- package/src/token-usage.test.tsx +92 -0
- package/src/{context.tsx → token-usage.tsx} +67 -53
- package/src/tool-result-card.stories.tsx +9 -1
- package/src/tool.stories.tsx +13 -3
- package/src/tool.tsx +15 -8
- package/src/turn-status.stories.tsx +124 -0
- package/src/turn-status.test.tsx +74 -0
- package/src/turn-status.tsx +174 -0
- package/src/use-audio-level.ts +104 -0
- package/src/web-preview.tsx +8 -3
- package/dist/_audio-player-media-chrome-KA5DY54G.js.map +0 -1
- package/dist/_flow-boundary-D63PJ65S.js.map +0 -1
- package/dist/_interactive-terminal-xterm-ATJ5EW3G.js +0 -11
- package/dist/_interactive-terminal-xterm-ATJ5EW3G.js.map +0 -1
- package/dist/_persona-rive-RFR2EUWP.js.map +0 -1
- package/src/_interactive-terminal-xterm.ts +0 -32
- package/src/blocks-ai-composer.stories.tsx +0 -83
- package/src/context.stories.tsx +0 -16
- package/src/interactive-terminal.stories.tsx +0 -165
- package/src/interactive-terminal.test.tsx +0 -448
- package/src/interactive-terminal.tsx +0 -444
- package/src/model-selector.tsx +0 -225
- 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">
|
|
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">
|
|
499
|
+
return <p className="text-body text-muted-foreground">{t("ai.gallery.noDetails")}</p>;
|
|
498
500
|
}
|
|
499
501
|
|
|
500
502
|
return (
|