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