@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.
- package/README.md +1 -1
- package/dist/{chunk-5C7RGAWA.js → chunk-NFYVQWIB.js} +170 -64
- package/dist/chunk-NFYVQWIB.js.map +1 -0
- package/dist/index.d.ts +791 -145
- package/dist/index.js +5447 -2213
- package/dist/index.js.map +1 -1
- package/dist/{machines-BeZ3bD3t.d.ts → machines-CnlMb7E-.d.ts} +5 -5
- package/dist/machines.d.ts +1 -1
- package/dist/machines.js +1 -1
- package/package.json +17 -12
- package/src/approvals.ts +16 -4
- package/src/client.ts +32 -5
- package/src/commands/index.ts +1 -7
- package/src/commands/registry.ts +46 -12
- package/src/components/chat-composer.tsx +135 -39
- package/src/components/code-editor.tsx +28 -36
- package/src/components/command-palette.tsx +26 -5
- package/src/components/desktop-viewer.tsx +29 -16
- package/src/components/diff-view.tsx +27 -189
- package/src/components/enrollment-consent.tsx +28 -10
- package/src/components/enrollment-device-flow.tsx +8 -5
- package/src/components/file-browser.tsx +190 -56
- package/src/components/fleet-tile.tsx +13 -4
- package/src/components/machine-card.tsx +16 -4
- package/src/components/machine-dock-bar.tsx +20 -8
- package/src/components/machine-metrics.tsx +31 -8
- package/src/components/machine-status-pill.tsx +26 -5
- package/src/components/machines-dashboard.tsx +8 -2
- package/src/components/markdown.tsx +39 -9
- package/src/components/message-timeline.tsx +633 -109
- package/src/components/pierre-diff.tsx +83 -15
- package/src/components/pierre-file.tsx +10 -9
- package/src/components/sandbox-files.tsx +110 -227
- package/src/components/sandbox-terminal.tsx +256 -88
- package/src/components/sandbox-workspace.tsx +823 -0
- package/src/components/session-status.tsx +28 -2
- package/src/components/workbench-changes.tsx +541 -0
- package/src/components/workspace-dock.tsx +85 -22
- package/src/hooks/internal.ts +5 -2
- package/src/hooks/use-available-models.ts +7 -2
- package/src/hooks/use-billing-usage.ts +4 -1
- package/src/hooks/use-codex-accounts.ts +74 -44
- package/src/hooks/use-composer.ts +57 -36
- package/src/hooks/use-environments.ts +92 -47
- package/src/hooks/use-file-attachments.ts +101 -55
- package/src/hooks/use-goal.ts +52 -16
- package/src/hooks/use-machine-chip.ts +134 -0
- package/src/hooks/use-machines.ts +34 -16
- package/src/hooks/use-packs.ts +24 -19
- package/src/hooks/use-relay-frame-stream.ts +5 -2
- package/src/hooks/use-rigs.ts +335 -0
- package/src/hooks/use-sandbox-files.ts +213 -39
- package/src/hooks/use-sandbox-git.ts +188 -11
- package/src/hooks/use-sandbox-terminal.ts +18 -14
- package/src/hooks/use-scheduled-tasks.ts +10 -2
- package/src/hooks/use-session-capabilities.ts +77 -20
- package/src/hooks/use-session-control.ts +49 -21
- package/src/hooks/use-session-events.ts +48 -20
- package/src/hooks/use-session-lineage.ts +119 -0
- package/src/hooks/use-session.ts +43 -28
- package/src/hooks/use-slash-commands.ts +6 -4
- package/src/hooks/use-turn-queue.ts +74 -147
- package/src/hooks/use-windowed-sections.ts +204 -0
- package/src/hooks/use-workspace-capture.ts +233 -0
- package/src/hooks/use-workspace-edit.ts +231 -0
- package/src/hooks/use-workspace-sessions.ts +48 -5
- package/src/hooks/use-workspaces.ts +18 -15
- package/src/index.ts +128 -23
- package/src/lib/format.ts +44 -6
- package/src/lib/xterm-renderer.ts +65 -0
- package/src/lib/xterm-theme.ts +224 -18
- package/src/machines.ts +13 -3
- package/src/provider.tsx +3 -1
- package/src/timeline/activity-rail.tsx +213 -54
- package/src/timeline/disclosure-context.tsx +12 -2
- package/src/timeline/index.ts +21 -2
- package/src/timeline/parsers.ts +44 -10
- package/src/timeline/projection.ts +435 -84
- package/src/timeline/shared.tsx +111 -24
- package/src/timeline/tool-diff.tsx +24 -20
- package/src/timeline/tool-renderers.tsx +98 -21
- package/src/timeline/turn-summary.tsx +76 -23
- package/src/timeline/types.ts +96 -12
- package/styles/tokens.css +2 -2
- 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
|
|
89
|
-
|
|
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
|
+
}
|