@opengeni/react 0.11.0 → 0.13.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 (85) hide show
  1. package/README.md +1 -1
  2. package/dist/{chunk-5C7RGAWA.js → chunk-NFYVQWIB.js} +170 -64
  3. package/dist/chunk-NFYVQWIB.js.map +1 -0
  4. package/dist/index.d.ts +791 -145
  5. package/dist/index.js +5447 -2213
  6. package/dist/index.js.map +1 -1
  7. package/dist/{machines-BeZ3bD3t.d.ts → machines-CnlMb7E-.d.ts} +5 -5
  8. package/dist/machines.d.ts +1 -1
  9. package/dist/machines.js +1 -1
  10. package/package.json +17 -12
  11. package/src/approvals.ts +16 -4
  12. package/src/client.ts +32 -5
  13. package/src/commands/index.ts +1 -7
  14. package/src/commands/registry.ts +46 -12
  15. package/src/components/chat-composer.tsx +135 -39
  16. package/src/components/code-editor.tsx +28 -36
  17. package/src/components/command-palette.tsx +26 -5
  18. package/src/components/desktop-viewer.tsx +29 -16
  19. package/src/components/diff-view.tsx +27 -189
  20. package/src/components/enrollment-consent.tsx +28 -10
  21. package/src/components/enrollment-device-flow.tsx +8 -5
  22. package/src/components/file-browser.tsx +190 -56
  23. package/src/components/fleet-tile.tsx +13 -4
  24. package/src/components/machine-card.tsx +16 -4
  25. package/src/components/machine-dock-bar.tsx +20 -8
  26. package/src/components/machine-metrics.tsx +31 -8
  27. package/src/components/machine-status-pill.tsx +26 -5
  28. package/src/components/machines-dashboard.tsx +8 -2
  29. package/src/components/markdown.tsx +39 -9
  30. package/src/components/message-timeline.tsx +633 -109
  31. package/src/components/pierre-diff.tsx +83 -15
  32. package/src/components/pierre-file.tsx +10 -9
  33. package/src/components/sandbox-files.tsx +110 -227
  34. package/src/components/sandbox-terminal.tsx +256 -88
  35. package/src/components/sandbox-workspace.tsx +823 -0
  36. package/src/components/session-status.tsx +28 -2
  37. package/src/components/workbench-changes.tsx +541 -0
  38. package/src/components/workspace-dock.tsx +85 -22
  39. package/src/hooks/internal.ts +5 -2
  40. package/src/hooks/use-available-models.ts +7 -2
  41. package/src/hooks/use-billing-usage.ts +4 -1
  42. package/src/hooks/use-codex-accounts.ts +74 -44
  43. package/src/hooks/use-composer.ts +57 -36
  44. package/src/hooks/use-environments.ts +92 -47
  45. package/src/hooks/use-file-attachments.ts +101 -55
  46. package/src/hooks/use-goal.ts +52 -16
  47. package/src/hooks/use-machine-chip.ts +134 -0
  48. package/src/hooks/use-machines.ts +34 -16
  49. package/src/hooks/use-packs.ts +24 -19
  50. package/src/hooks/use-relay-frame-stream.ts +5 -2
  51. package/src/hooks/use-rigs.ts +335 -0
  52. package/src/hooks/use-sandbox-files.ts +213 -39
  53. package/src/hooks/use-sandbox-git.ts +188 -11
  54. package/src/hooks/use-sandbox-terminal.ts +18 -14
  55. package/src/hooks/use-scheduled-tasks.ts +10 -2
  56. package/src/hooks/use-session-capabilities.ts +77 -20
  57. package/src/hooks/use-session-control.ts +49 -21
  58. package/src/hooks/use-session-events.ts +48 -20
  59. package/src/hooks/use-session-lineage.ts +119 -0
  60. package/src/hooks/use-session.ts +43 -28
  61. package/src/hooks/use-slash-commands.ts +6 -4
  62. package/src/hooks/use-turn-queue.ts +74 -147
  63. package/src/hooks/use-windowed-sections.ts +204 -0
  64. package/src/hooks/use-workspace-capture.ts +233 -0
  65. package/src/hooks/use-workspace-edit.ts +231 -0
  66. package/src/hooks/use-workspace-sessions.ts +48 -5
  67. package/src/hooks/use-workspaces.ts +18 -15
  68. package/src/index.ts +128 -23
  69. package/src/lib/format.ts +44 -6
  70. package/src/lib/xterm-renderer.ts +65 -0
  71. package/src/lib/xterm-theme.ts +224 -18
  72. package/src/machines.ts +13 -3
  73. package/src/provider.tsx +3 -1
  74. package/src/timeline/activity-rail.tsx +213 -54
  75. package/src/timeline/disclosure-context.tsx +12 -2
  76. package/src/timeline/index.ts +21 -2
  77. package/src/timeline/parsers.ts +44 -10
  78. package/src/timeline/projection.ts +435 -84
  79. package/src/timeline/shared.tsx +111 -24
  80. package/src/timeline/tool-diff.tsx +24 -20
  81. package/src/timeline/tool-renderers.tsx +98 -21
  82. package/src/timeline/turn-summary.tsx +76 -23
  83. package/src/timeline/types.ts +96 -12
  84. package/styles/tokens.css +2 -2
  85. package/dist/chunk-5C7RGAWA.js.map +0 -1
@@ -23,6 +23,24 @@ export const SESSION_STATUS_META: Record<SessionStatusValue, SessionStatusMeta>
23
23
  badgeClassName: "text-og-status-running border-og-status-running/30 bg-og-status-running/10",
24
24
  pulse: true,
25
25
  },
26
+ recovering: {
27
+ label: "Recovering",
28
+ dotClassName: "bg-og-status-running",
29
+ badgeClassName: "text-og-status-running border-og-status-running/30 bg-og-status-running/10",
30
+ pulse: true,
31
+ },
32
+ waiting_capacity: {
33
+ label: "Waiting for capacity",
34
+ dotClassName: "bg-og-status-waiting",
35
+ badgeClassName: "text-og-status-waiting border-og-status-waiting/35 bg-og-status-waiting/10",
36
+ pulse: true,
37
+ },
38
+ paused: {
39
+ label: "Paused",
40
+ dotClassName: "bg-og-status-cancelled",
41
+ badgeClassName: "text-og-fg-subtle border-og-border bg-og-status-cancelled/10",
42
+ pulse: false,
43
+ },
26
44
  idle: {
27
45
  label: "Idle",
28
46
  dotClassName: "bg-og-status-idle",
@@ -85,8 +103,16 @@ export type StatusDotProps = {
85
103
  export function StatusDot({ status, className }: StatusDotProps) {
86
104
  const meta = SESSION_STATUS_META[status];
87
105
  return (
88
- <span className={cn("relative inline-flex size-1.5 shrink-0 rounded-full", meta.dotClassName, className)}>
89
- {meta.pulse ? <span className={cn("absolute inset-0 animate-og-pulse rounded-full", meta.dotClassName)} /> : null}
106
+ <span
107
+ className={cn(
108
+ "relative inline-flex size-1.5 shrink-0 rounded-full",
109
+ meta.dotClassName,
110
+ className,
111
+ )}
112
+ >
113
+ {meta.pulse ? (
114
+ <span className={cn("absolute inset-0 animate-og-pulse rounded-full", meta.dotClassName)} />
115
+ ) : null}
90
116
  </span>
91
117
  );
92
118
  }
@@ -0,0 +1,541 @@
1
+ import type { GitFileDiff } from "@opengeni/sdk";
2
+ import { FileWarningIcon, HistoryIcon } from "lucide-react";
3
+ import {
4
+ type ReactNode,
5
+ useCallback,
6
+ useEffect,
7
+ useId,
8
+ useLayoutEffect,
9
+ useMemo,
10
+ useRef,
11
+ useState,
12
+ } from "react";
13
+ import { VList } from "virtua";
14
+ import { cn } from "../lib/cn";
15
+ import { useThemeType } from "../lib/use-theme-type";
16
+ import { formatAsOf } from "../hooks/use-machine-chip";
17
+ import { useWindowedSections } from "../hooks/use-windowed-sections";
18
+ import { PierreDiff } from "./pierre-diff";
19
+
20
+ /* ----------------------------------------------------------------------------
21
+ Changes tab — a PR-review surface.
22
+
23
+ A file rail (status glyph, ±counts, grouped by top-level dir when the change
24
+ set is large) on the left; a stacked, WINDOWED diff pane on the right. Only
25
+ the file sections inside the visible window ± overscan mount a Pierre/Shiki
26
+ highlighter (the one renderer), so a 40- or 400-file change set never mounts N
27
+ highlighters at once (dossier §10.7, D2). A per-file >guard (binary / diff too
28
+ large) degrades to an "open live" affordance instead of a diff body.
29
+
30
+ `git.diff` is a single flat scope, so "group by repo then directory" here means
31
+ "group by top-level directory" — which is exactly the repo dir in a multi-repo
32
+ workspace and the top folder otherwise. Grouping only kicks in past the
33
+ threshold; a small change set stays a flat list.
34
+ -------------------------------------------------------------------------- */
35
+
36
+ /** Group the rail (and reorder the pane) once the change set is larger than this. */
37
+ const GROUP_THRESHOLD = 20;
38
+ /** Mount this many sections beyond the visible window on each side. */
39
+ const OVERSCAN = 2;
40
+ const HEADER_PX = 30;
41
+ const LINE_PX = 18;
42
+ const GUARD_BODY_PX = 52;
43
+ const COMPACT_SURFACE_PX = 560;
44
+ const useChangesLayoutEffect = typeof window === "undefined" ? useEffect : useLayoutEffect;
45
+
46
+ const STATUS_TINT: Record<GitFileDiff["status"], string> = {
47
+ added: "text-og-status-idle",
48
+ modified: "text-og-status-running",
49
+ deleted: "text-og-status-failed",
50
+ renamed: "text-og-accent",
51
+ copied: "text-og-accent",
52
+ untracked: "text-og-fg-subtle",
53
+ ignored: "text-og-fg-subtle",
54
+ conflicted: "text-og-status-failed",
55
+ typechange: "text-og-status-running",
56
+ };
57
+
58
+ const STATUS_LETTER: Record<GitFileDiff["status"], string> = {
59
+ added: "A",
60
+ modified: "M",
61
+ deleted: "D",
62
+ renamed: "R",
63
+ copied: "C",
64
+ untracked: "U",
65
+ ignored: "I",
66
+ conflicted: "!",
67
+ typechange: "T",
68
+ };
69
+
70
+ export type WorkbenchChangesProps = {
71
+ /** The changed files (`useSandboxGit().diff`). Assumed non-empty by the caller. */
72
+ diff: GitFileDiff[];
73
+ /** Which source the diff came from — drives the "live" vs "as of turn" badge. */
74
+ source: "live" | "capture" | null;
75
+ /** When the served capture was taken (ISO), when `source === "capture"`. */
76
+ capturedAt: string | null;
77
+ /** The capture's turn revision, for the "as of turn N" badge. */
78
+ captureRevision?: number | null | undefined;
79
+ themeType?: "dark" | "light" | undefined;
80
+ className?: string | undefined;
81
+ };
82
+
83
+ type RailRow =
84
+ | { kind: "group"; label: string; count: number }
85
+ | { kind: "file"; file: GitFileDiff; index: number };
86
+
87
+ /** Order the files and build the rail rows (grouped past the threshold). The
88
+ * returned `orderedFiles` drives BOTH the rail and the diff pane so a rail row's
89
+ * `index` addresses the matching pane section. Exported for tests. */
90
+ export function buildRail(files: GitFileDiff[]): { orderedFiles: GitFileDiff[]; rows: RailRow[] } {
91
+ if (files.length <= GROUP_THRESHOLD) {
92
+ return {
93
+ orderedFiles: files,
94
+ rows: files.map((file, index) => ({ kind: "file", file, index })),
95
+ };
96
+ }
97
+ const groups = new Map<string, GitFileDiff[]>();
98
+ for (const file of files) {
99
+ const slash = file.path.indexOf("/");
100
+ const key = slash > 0 ? file.path.slice(0, slash) : "(root)";
101
+ const bucket = groups.get(key);
102
+ if (bucket) bucket.push(file);
103
+ else groups.set(key, [file]);
104
+ }
105
+ const labels = [...groups.keys()].sort((a, b) => a.localeCompare(b));
106
+ const orderedFiles: GitFileDiff[] = [];
107
+ const rows: RailRow[] = [];
108
+ for (const label of labels) {
109
+ const bucket = (groups.get(label) ?? []).slice().sort((a, b) => a.path.localeCompare(b.path));
110
+ rows.push({ kind: "group", label, count: bucket.length });
111
+ for (const file of bucket) {
112
+ rows.push({ kind: "file", file, index: orderedFiles.length });
113
+ orderedFiles.push(file);
114
+ }
115
+ }
116
+ return { orderedFiles, rows };
117
+ }
118
+
119
+ /** A file's diff body is "too large to inline" when it's binary or the diff guard
120
+ * tripped (the backend omits hunks past its size cap) — open it live instead. */
121
+ function isGuarded(file: GitFileDiff): boolean {
122
+ return file.isBinary || file.truncated;
123
+ }
124
+
125
+ /** Estimate a section's rendered height for the windowing math. Line-count based,
126
+ * so it is close enough that the first paint barely shifts as real heights land. */
127
+ function estimateSectionHeight(file: GitFileDiff): number {
128
+ if (isGuarded(file)) return HEADER_PX + GUARD_BODY_PX;
129
+ let lines = 0;
130
+ for (const hunk of file.hunks) lines += hunk.lines.length + 1;
131
+ return HEADER_PX + Math.max(lines, 1) * LINE_PX + 8;
132
+ }
133
+
134
+ export function WorkbenchChanges({
135
+ diff,
136
+ source,
137
+ capturedAt,
138
+ captureRevision,
139
+ themeType,
140
+ className,
141
+ }: WorkbenchChangesProps) {
142
+ const rootRef = useRef<HTMLDivElement | null>(null);
143
+ const [compact, setCompact] = useState(false);
144
+ const [coarsePointer, setCoarsePointer] = useState(false);
145
+ const resolvedTheme = useThemeType(themeType);
146
+ const [layout, setLayout] = useState<"unified" | "split">("unified");
147
+ const [activePath, setActivePath] = useState<string | null>(null);
148
+ const pickerId = useId();
149
+
150
+ const { orderedFiles, rows } = useMemo(() => buildRail(diff), [diff]);
151
+ const grouped = diff.length > GROUP_THRESHOLD;
152
+ const selectedIndex = orderedFiles.findIndex((file) => file.path === activePath);
153
+ const activeIndex = selectedIndex >= 0 ? selectedIndex : 0;
154
+
155
+ useChangesLayoutEffect(() => {
156
+ const root = rootRef.current;
157
+ if (!root || typeof ResizeObserver === "undefined") return;
158
+ const update = (width: number) => {
159
+ // DOM test environments report a zero geometry. Keep the deterministic
160
+ // desktop/SSR branch until the observer has a real measurement.
161
+ if (width > 0) setCompact(width < COMPACT_SURFACE_PX);
162
+ };
163
+ update(root.getBoundingClientRect().width);
164
+ const observer = new ResizeObserver((entries) => {
165
+ update(entries[0]?.contentRect.width ?? root.getBoundingClientRect().width);
166
+ });
167
+ observer.observe(root);
168
+ return () => observer.disconnect();
169
+ }, []);
170
+
171
+ useChangesLayoutEffect(() => {
172
+ if (typeof window === "undefined" || typeof window.matchMedia !== "function") return;
173
+ const query = window.matchMedia("(pointer: coarse)");
174
+ const update = () => setCoarsePointer(query.matches);
175
+ update();
176
+ if (typeof query.addEventListener === "function") {
177
+ query.addEventListener("change", update);
178
+ return () => query.removeEventListener("change", update);
179
+ }
180
+ if (typeof query.addListener === "function") {
181
+ query.addListener(update);
182
+ return () => query.removeListener(update);
183
+ }
184
+ }, []);
185
+
186
+ const additions = useMemo(() => diff.reduce((sum, f) => sum + f.additions, 0), [diff]);
187
+ const deletions = useMemo(() => diff.reduce((sum, f) => sum + f.deletions, 0), [diff]);
188
+
189
+ const estimateHeight = useCallback(
190
+ (index: number) => {
191
+ const file = orderedFiles[index];
192
+ return file ? estimateSectionHeight(file) : HEADER_PX + LINE_PX;
193
+ },
194
+ [orderedFiles],
195
+ );
196
+
197
+ const windowed = useWindowedSections({
198
+ count: orderedFiles.length,
199
+ estimateHeight,
200
+ overscan: OVERSCAN,
201
+ });
202
+
203
+ const jumpTo = useCallback(
204
+ (index: number) => {
205
+ setActivePath(orderedFiles[index]?.path ?? null);
206
+ windowed.scrollToIndex(index);
207
+ },
208
+ [orderedFiles, windowed],
209
+ );
210
+
211
+ // Track which section is at the top of the pane so the rail highlights it.
212
+ const onPaneScroll = useCallback(() => {
213
+ const el = windowed.scrollRef.current;
214
+ if (!el) return;
215
+ const top = el.scrollTop;
216
+ const offsets = windowed.offsets;
217
+ let idx = 0;
218
+ for (let i = 0; i < orderedFiles.length; i++) {
219
+ if ((offsets[i] ?? 0) <= top + 4) idx = i;
220
+ else break;
221
+ }
222
+ const nextPath = orderedFiles[idx]?.path ?? null;
223
+ setActivePath((previous) => (previous === nextPath ? previous : nextPath));
224
+ }, [windowed, orderedFiles]);
225
+
226
+ useEffect(() => {
227
+ const el = windowed.scrollRef.current;
228
+ if (!el) return;
229
+ el.addEventListener("scroll", onPaneScroll, { passive: true });
230
+ return () => el.removeEventListener("scroll", onPaneScroll);
231
+ }, [windowed.scrollRef, onPaneScroll]);
232
+
233
+ const sourceBadge = describeSource(source, capturedAt, captureRevision ?? null);
234
+ const activeFile = orderedFiles[activeIndex] ?? orderedFiles[0];
235
+
236
+ return (
237
+ <div
238
+ ref={rootRef}
239
+ className={cn("flex h-full min-h-0 min-w-0 flex-col", className)}
240
+ data-workbench-changes-layout={compact ? "compact" : "rail"}
241
+ >
242
+ {/* Summary + source badge + layout toggle. */}
243
+ <div className="flex shrink-0 items-center justify-between gap-2 border-b border-og-border px-3 py-1.5">
244
+ <span className="min-w-0 truncate text-og-xs text-og-fg-muted">
245
+ {diff.length} {diff.length === 1 ? "file" : "files"} changed
246
+ <span className="ml-2 text-og-status-idle">+{additions}</span>
247
+ <span className="ml-1 text-og-status-failed">−{deletions}</span>
248
+ </span>
249
+ <div className="flex shrink-0 items-center gap-2">
250
+ {compact ? null : <LayoutToggle layout={layout} onChange={setLayout} />}
251
+ <SourceBadge source={source} capturedAt={capturedAt} label={sourceBadge} />
252
+ </div>
253
+ </div>
254
+
255
+ {compact ? (
256
+ <div className="flex min-h-0 flex-1 flex-col">
257
+ <div className="shrink-0 border-b border-og-border bg-og-surface-1 p-2">
258
+ <label className="sr-only" htmlFor={pickerId}>
259
+ Changed file
260
+ </label>
261
+ <select
262
+ id={pickerId}
263
+ aria-label="Changed file"
264
+ value={activeIndex}
265
+ onChange={(event) => {
266
+ const index = Number(event.currentTarget.value);
267
+ setActivePath(orderedFiles[index]?.path ?? null);
268
+ }}
269
+ data-compact-file-picker
270
+ className="h-11 w-full rounded-og-md border border-og-border bg-og-bg px-3 font-og-mono text-og-sm text-og-fg outline-none transition-colors focus:border-og-accent focus:ring-2 focus:ring-og-accent-soft"
271
+ >
272
+ {orderedFiles.map((file, index) => (
273
+ <option key={file.path} value={index}>
274
+ {STATUS_LETTER[file.status]} · {file.path} · +{file.additions} −{file.deletions}
275
+ </option>
276
+ ))}
277
+ </select>
278
+ </div>
279
+ <div className="min-h-0 min-w-0 flex-1 overflow-auto" data-opengeni-changes-pane>
280
+ {activeFile ? (
281
+ <DiffSection file={activeFile} layout="unified" themeType={resolvedTheme} />
282
+ ) : null}
283
+ </div>
284
+ </div>
285
+ ) : (
286
+ <div className="flex min-h-0 flex-1">
287
+ {/* File rail — virtualized (virtua): a dense change set is fine. */}
288
+ <div className="w-[240px] shrink-0 border-r border-og-border">
289
+ <VList
290
+ className="h-full"
291
+ itemSize={coarsePointer ? 44 : 28}
292
+ ssrCount={Math.min(30, rows.length)}
293
+ >
294
+ {rows.map((row) =>
295
+ row.kind === "group" ? (
296
+ <div
297
+ key={`g:${row.label}`}
298
+ data-rail-group
299
+ className="flex items-center gap-1.5 px-2 pb-0.5 pt-2 text-2xs font-medium uppercase tracking-wide text-og-fg-subtle"
300
+ >
301
+ <span className="min-w-0 truncate">{row.label}</span>
302
+ <span className="shrink-0">{row.count}</span>
303
+ </div>
304
+ ) : (
305
+ <RailFileRow
306
+ key={row.file.path}
307
+ file={row.file}
308
+ grouped={grouped}
309
+ active={row.index === activeIndex}
310
+ onClick={() => jumpTo(row.index)}
311
+ />
312
+ ),
313
+ )}
314
+ </VList>
315
+ </div>
316
+
317
+ {/* Diff pane — windowed file sections. Only the sections inside the
318
+ visible window ± overscan are in the DOM; the container reserves the
319
+ full scroll height so scrolling + the rail-jump stay accurate. */}
320
+ <div
321
+ ref={windowed.scrollRef}
322
+ className="min-h-0 min-w-0 flex-1 overflow-auto"
323
+ data-opengeni-changes-pane
324
+ >
325
+ <div style={{ position: "relative", height: windowed.totalHeight }}>
326
+ {orderedFiles.map((file, index) => {
327
+ if (index < windowed.range.start || index >= windowed.range.end) return null;
328
+ return (
329
+ <MeasuredSection
330
+ key={file.path}
331
+ index={index}
332
+ top={windowed.offsets[index] ?? 0}
333
+ onMeasure={windowed.measure}
334
+ >
335
+ <DiffSection file={file} layout={layout} themeType={resolvedTheme} />
336
+ </MeasuredSection>
337
+ );
338
+ })}
339
+ </div>
340
+ </div>
341
+ </div>
342
+ )}
343
+ </div>
344
+ );
345
+ }
346
+
347
+ /**
348
+ * The data-source badge. "live" stays understated (the header chip already
349
+ * carries machine liveness) — but a capture-served diff is honestly labelled
350
+ * historical: a muted clock + the "as of turn N · <time>" so the reviewer always
351
+ * knows they are looking at a turn-end snapshot, not the live tree.
352
+ */
353
+ function SourceBadge({
354
+ source,
355
+ capturedAt,
356
+ label,
357
+ }: {
358
+ source: "live" | "capture" | null;
359
+ capturedAt: string | null;
360
+ label: string;
361
+ }) {
362
+ if (source === "capture" && capturedAt) {
363
+ return (
364
+ <span
365
+ className="inline-flex items-center gap-1 rounded-og-xs border border-og-border px-1.5 py-px text-2xs text-og-fg-muted"
366
+ title={new Date(capturedAt).toLocaleString()}
367
+ >
368
+ <HistoryIcon className="size-3 shrink-0 text-og-status-running" aria-hidden />
369
+ {label}
370
+ </span>
371
+ );
372
+ }
373
+ return (
374
+ <span className="rounded-og-xs bg-og-surface-2 px-1.5 py-px text-2xs text-og-fg-subtle">
375
+ {label}
376
+ </span>
377
+ );
378
+ }
379
+
380
+ /** "live" · "as of turn 7 · 14:32" · "as of 14:32" (no revision). */
381
+ function describeSource(
382
+ source: "live" | "capture" | null,
383
+ capturedAt: string | null,
384
+ revision: number | null,
385
+ ): string {
386
+ if (source !== "capture" || !capturedAt) return "live";
387
+ const time = formatAsOf(capturedAt, Date.now());
388
+ return revision !== null ? `as of turn ${revision} · ${time}` : `as of ${time}`;
389
+ }
390
+
391
+ function RailFileRow({
392
+ file,
393
+ grouped,
394
+ active,
395
+ onClick,
396
+ }: {
397
+ file: GitFileDiff;
398
+ grouped: boolean;
399
+ active: boolean;
400
+ onClick: () => void;
401
+ }) {
402
+ // In a grouped rail the top-level dir is the group header, so show the path
403
+ // beneath it; otherwise show the whole path.
404
+ const shown = grouped ? file.path.slice(file.path.indexOf("/") + 1) || file.path : file.path;
405
+ return (
406
+ <button
407
+ type="button"
408
+ onClick={onClick}
409
+ title={file.path}
410
+ data-rail-file
411
+ className={cn(
412
+ "flex min-h-7 w-full items-center gap-1.5 truncate px-2 py-0.5 text-left text-og-sm hover:bg-og-surface-2 pointer-coarse:min-h-11",
413
+ grouped && "pl-3",
414
+ active && "bg-og-surface-2",
415
+ )}
416
+ >
417
+ <span
418
+ className={cn("w-3 shrink-0 text-center font-og-mono text-og-xs", STATUS_TINT[file.status])}
419
+ >
420
+ {STATUS_LETTER[file.status]}
421
+ </span>
422
+ <span className="min-w-0 flex-1 truncate">{shown}</span>
423
+ <span className="ml-auto flex shrink-0 items-center gap-1 pl-1 font-og-mono text-2xs">
424
+ <span className="text-og-status-idle">+{file.additions}</span>
425
+ <span className="text-og-status-failed">−{file.deletions}</span>
426
+ </span>
427
+ </button>
428
+ );
429
+ }
430
+
431
+ /** A windowed section wrapper. Absolutely positioned at `top`; a ResizeObserver
432
+ * reports its real height back so the layout refines as Pierre's async Shiki
433
+ * render grows (else short estimates would overlap sections). */
434
+ function MeasuredSection({
435
+ index,
436
+ top,
437
+ onMeasure,
438
+ children,
439
+ }: {
440
+ index: number;
441
+ top: number;
442
+ onMeasure: (index: number, height: number) => void;
443
+ children: ReactNode;
444
+ }) {
445
+ const ref = useRef<HTMLDivElement>(null);
446
+ useEffect(() => {
447
+ const el = ref.current;
448
+ if (!el) return;
449
+ const report = () => onMeasure(index, el.offsetHeight);
450
+ report();
451
+ const ro = typeof ResizeObserver !== "undefined" ? new ResizeObserver(report) : null;
452
+ ro?.observe(el);
453
+ return () => ro?.disconnect();
454
+ }, [index, onMeasure]);
455
+ return (
456
+ <div
457
+ ref={ref}
458
+ data-diff-section
459
+ data-diff-index={index}
460
+ style={{ position: "absolute", top, left: 0, right: 0 }}
461
+ >
462
+ {children}
463
+ </div>
464
+ );
465
+ }
466
+
467
+ function DiffSection({
468
+ file,
469
+ layout,
470
+ themeType,
471
+ }: {
472
+ file: GitFileDiff;
473
+ layout: "unified" | "split";
474
+ themeType: "dark" | "light";
475
+ }) {
476
+ // The guard files (binary / over-cap) get a minimal header + "open live" body
477
+ // since Pierre has nothing to render; real diffs use Pierre's own sticky file
478
+ // header (one header — no redundant chrome).
479
+ if (isGuarded(file)) {
480
+ const renamed = file.oldPath && file.oldPath !== file.path;
481
+ return (
482
+ <section className="border-b border-og-border pb-1">
483
+ <header className="flex items-center justify-between gap-2 bg-og-surface-1 px-3 py-1.5 text-og-sm">
484
+ <span className="min-w-0 truncate font-og-mono text-og-xs">
485
+ {renamed ? `${file.oldPath} → ${file.path}` : file.path}
486
+ </span>
487
+ <span className="flex shrink-0 items-center gap-2 font-og-mono text-2xs">
488
+ <span className="text-og-status-idle">+{file.additions}</span>
489
+ <span className="text-og-status-failed">−{file.deletions}</span>
490
+ </span>
491
+ </header>
492
+ <GuardBody file={file} />
493
+ </section>
494
+ );
495
+ }
496
+ return (
497
+ <section className="border-b border-og-border pb-2">
498
+ <PierreDiff diff={[file]} layout={layout} themeType={themeType} className="px-1" />
499
+ </section>
500
+ );
501
+ }
502
+
503
+ /** The per-file guard: a binary file or an over-cap diff opens live rather than
504
+ * inlining a body we don't (fully) have. */
505
+ function GuardBody({ file }: { file: GitFileDiff }) {
506
+ const reason = file.isBinary ? "Binary file" : "Diff too large to show here";
507
+ return (
508
+ <div className="flex items-center gap-2 px-3 py-3 text-og-sm text-og-fg-subtle">
509
+ <FileWarningIcon className="size-3.5 shrink-0" />
510
+ <span className="min-w-0">{reason} — open it on the machine to view.</span>
511
+ </div>
512
+ );
513
+ }
514
+
515
+ function LayoutToggle({
516
+ layout,
517
+ onChange,
518
+ }: {
519
+ layout: "unified" | "split";
520
+ onChange: (next: "unified" | "split") => void;
521
+ }) {
522
+ return (
523
+ <div className="inline-flex items-center rounded-og-sm border border-og-border p-0.5">
524
+ {(["unified", "split"] as const).map((value) => (
525
+ <button
526
+ key={value}
527
+ type="button"
528
+ onClick={() => onChange(value)}
529
+ className={cn(
530
+ "min-h-7 rounded-og-xs px-1.5 py-0.5 text-2xs capitalize pointer-coarse:min-h-11",
531
+ layout === value
532
+ ? "bg-og-accent-soft text-og-fg"
533
+ : "text-og-fg-subtle hover:text-og-fg",
534
+ )}
535
+ >
536
+ {value}
537
+ </button>
538
+ ))}
539
+ </div>
540
+ );
541
+ }