@opengeni/react 0.13.0 → 0.20.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 (113) hide show
  1. package/README.md +129 -23
  2. package/dist/chunk-6BXBGJ3B.js +1956 -0
  3. package/dist/chunk-6BXBGJ3B.js.map +1 -0
  4. package/dist/chunk-6UT5OC3P.js +2133 -0
  5. package/dist/chunk-6UT5OC3P.js.map +1 -0
  6. package/dist/chunk-EBTT3MYN.js +92 -0
  7. package/dist/chunk-EBTT3MYN.js.map +1 -0
  8. package/dist/chunk-HHFA4AFX.js +425 -0
  9. package/dist/chunk-HHFA4AFX.js.map +1 -0
  10. package/dist/chunk-LAZ2A743.js +2393 -0
  11. package/dist/chunk-LAZ2A743.js.map +1 -0
  12. package/dist/chunk-MRSECXRP.js +563 -0
  13. package/dist/chunk-MRSECXRP.js.map +1 -0
  14. package/dist/chunk-TK7G6XLT.js +18 -0
  15. package/dist/chunk-TK7G6XLT.js.map +1 -0
  16. package/dist/composer-DvUaa5ki.d.ts +585 -0
  17. package/dist/composer.d.ts +6 -0
  18. package/dist/composer.js +56 -0
  19. package/dist/composer.js.map +1 -0
  20. package/dist/index.d.ts +642 -1080
  21. package/dist/index.js +7672 -8005
  22. package/dist/index.js.map +1 -1
  23. package/dist/machines.d.ts +412 -3
  24. package/dist/machines.js +25 -1
  25. package/dist/noto-sans-arabic-loader-IA2T4X2A.js +21 -0
  26. package/dist/noto-sans-arabic-loader-IA2T4X2A.js.map +1 -0
  27. package/dist/noto-sans-jp-loader.generated-HFGLYBR3.js +292 -0
  28. package/dist/noto-sans-jp-loader.generated-HFGLYBR3.js.map +1 -0
  29. package/dist/queue-surface-implementation-NQMV2ML3.js +890 -0
  30. package/dist/queue-surface-implementation-NQMV2ML3.js.map +1 -0
  31. package/dist/session-Gd4iVPYw.d.ts +425 -0
  32. package/dist/session-context-DIFFlXKc.d.ts +52 -0
  33. package/dist/session.d.ts +4 -0
  34. package/dist/session.js +50 -0
  35. package/dist/session.js.map +1 -0
  36. package/dist/use-composer-BCfm4mzX.d.ts +112 -0
  37. package/package.json +23 -4
  38. package/src/approvals.ts +5 -3
  39. package/src/client.ts +50 -1
  40. package/src/components/approval-surface.tsx +198 -0
  41. package/src/components/chat-composer.tsx +104 -751
  42. package/src/components/code-editor.tsx +104 -26
  43. package/src/components/command-palette.tsx +8 -2
  44. package/src/components/composer-transcription-control.tsx +211 -0
  45. package/src/components/composer.tsx +1536 -0
  46. package/src/components/desktop-viewer.tsx +3 -3
  47. package/src/components/enrollment-consent.tsx +2 -2
  48. package/src/components/file-browser.tsx +367 -29
  49. package/src/components/human-input-form.tsx +417 -0
  50. package/src/components/machine-card.tsx +82 -16
  51. package/src/components/machine-health-pill.tsx +68 -0
  52. package/src/components/machine-metrics.tsx +10 -24
  53. package/src/components/machines/health.ts +146 -0
  54. package/src/components/machines/machine-detail.tsx +220 -0
  55. package/src/components/machines/metric-history-chart.tsx +298 -0
  56. package/src/components/machines/metric-sparkline.tsx +76 -0
  57. package/src/components/machines/series.ts +113 -0
  58. package/src/components/machines-dashboard.tsx +16 -4
  59. package/src/components/message-timeline.tsx +15 -7
  60. package/src/components/model-picker.tsx +12 -3
  61. package/src/components/pierre-diff.tsx +32 -6
  62. package/src/components/queue-surface-implementation.tsx +1041 -0
  63. package/src/components/queue-surface-state.tsx +94 -0
  64. package/src/components/queue-surface.tsx +60 -0
  65. package/src/components/sandbox-files.tsx +230 -21
  66. package/src/components/sandbox-terminal.tsx +8 -2
  67. package/src/components/sandbox-workspace.tsx +489 -135
  68. package/src/components/session-status.tsx +0 -6
  69. package/src/components/workbench-changes.tsx +145 -50
  70. package/src/components/workspace-dock.tsx +329 -68
  71. package/src/composer.ts +60 -0
  72. package/src/hooks/internal.ts +115 -34
  73. package/src/hooks/use-composer.ts +635 -74
  74. package/src/hooks/use-human-input.ts +131 -0
  75. package/src/hooks/use-machine-chip.ts +2 -2
  76. package/src/hooks/use-machines.ts +27 -11
  77. package/src/hooks/use-sandbox-files.ts +310 -62
  78. package/src/hooks/use-sandbox-git.ts +154 -40
  79. package/src/hooks/use-sandbox-terminal.ts +28 -6
  80. package/src/hooks/use-session-capabilities.ts +38 -9
  81. package/src/hooks/use-session-control.ts +43 -15
  82. package/src/hooks/use-session-events.ts +523 -48
  83. package/src/hooks/use-session-lineage.ts +15 -6
  84. package/src/hooks/use-session.ts +10 -2
  85. package/src/hooks/use-slash-commands.ts +38 -38
  86. package/src/hooks/use-transcription.ts +757 -0
  87. package/src/hooks/use-turn-queue.ts +273 -77
  88. package/src/hooks/use-windowed-sections.ts +2 -2
  89. package/src/hooks/use-workspace-capture.ts +146 -40
  90. package/src/hooks/use-workspace-edit.ts +50 -14
  91. package/src/hooks/use-workspace-sessions.ts +3 -0
  92. package/src/human-input.ts +72 -0
  93. package/src/index.ts +88 -12
  94. package/src/lib/noto-sans-arabic-loader.ts +21 -0
  95. package/src/lib/noto-sans-jp-loader.generated.ts +295 -0
  96. package/src/lib/use-portal-token-style.ts +51 -0
  97. package/src/lib/use-unicode-fonts.ts +57 -0
  98. package/src/machines.ts +16 -0
  99. package/src/provider.tsx +163 -41
  100. package/src/session-context.ts +135 -0
  101. package/src/session.ts +79 -0
  102. package/src/timeline/parsers.ts +78 -6
  103. package/src/timeline/projection.ts +36 -6
  104. package/src/timeline/tool-renderers.tsx +41 -0
  105. package/src/timeline/turn-summary.tsx +2 -2
  106. package/src/timeline/types.ts +16 -8
  107. package/styles/index.css +78 -0
  108. package/styles/index.d.ts +2 -0
  109. package/styles/tokens.css +11 -6
  110. package/styles/tokens.d.ts +2 -0
  111. package/dist/chunk-NFYVQWIB.js +0 -1377
  112. package/dist/chunk-NFYVQWIB.js.map +0 -1
  113. package/dist/machines-CnlMb7E-.d.ts +0 -329
@@ -35,12 +35,6 @@ export const SESSION_STATUS_META: Record<SessionStatusValue, SessionStatusMeta>
35
35
  badgeClassName: "text-og-status-waiting border-og-status-waiting/35 bg-og-status-waiting/10",
36
36
  pulse: true,
37
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
- },
44
38
  idle: {
45
39
  label: "Idle",
46
40
  dotClassName: "bg-og-status-idle",
@@ -1,5 +1,5 @@
1
1
  import type { GitFileDiff } from "@opengeni/sdk";
2
- import { FileWarningIcon, HistoryIcon } from "lucide-react";
2
+ import { ArrowUpRightIcon, FileWarningIcon, HistoryIcon } from "lucide-react";
3
3
  import {
4
4
  type ReactNode,
5
5
  useCallback,
@@ -12,25 +12,28 @@ import {
12
12
  } from "react";
13
13
  import { VList } from "virtua";
14
14
  import { cn } from "../lib/cn";
15
+ import { useUnicodeFallbackFonts } from "../lib/use-unicode-fonts";
15
16
  import { useThemeType } from "../lib/use-theme-type";
16
17
  import { formatAsOf } from "../hooks/use-machine-chip";
18
+ import type { SandboxGitFileDiff } from "../hooks/use-sandbox-git";
17
19
  import { useWindowedSections } from "../hooks/use-windowed-sections";
18
20
  import { PierreDiff } from "./pierre-diff";
19
21
 
20
22
  /* ----------------------------------------------------------------------------
21
23
  Changes tab — a PR-review surface.
22
24
 
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
+ A file rail (status glyph, ±counts, grouped by repository when metadata is
26
+ available, otherwise by top-level directory for a large legacy input) on the
27
+ left; a stacked, WINDOWED diff pane on the right. Only
25
28
  the file sections inside the visible window ± overscan mount a Pierre/Shiki
26
29
  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
30
+ highlighters at once (D2). A per-file size guard (binary / diff too
28
31
  large) degrades to an "open live" affordance instead of a diff body.
29
32
 
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.
33
+ Workspace-wide Git results carry an explicit `repoRoot`, so grouping never
34
+ guesses repository ownership from a directory name. Multi-repo results group
35
+ at every size; legacy unscoped inputs group by top-level directory only past
36
+ the threshold.
34
37
  -------------------------------------------------------------------------- */
35
38
 
36
39
  /** Group the rail (and reorder the pane) once the change set is larger than this. */
@@ -67,9 +70,16 @@ const STATUS_LETTER: Record<GitFileDiff["status"], string> = {
67
70
  typechange: "T",
68
71
  };
69
72
 
73
+ function compactFileLabel(file: SandboxGitFileDiff): string {
74
+ const slash = file.path.lastIndexOf("/");
75
+ const basename = slash >= 0 ? file.path.slice(slash + 1) : file.path;
76
+ const parent = slash >= 0 ? file.path.slice(0, slash + 1) : "";
77
+ return `${STATUS_LETTER[file.status]} · ${basename}${parent ? ` — ${parent}` : ""}`;
78
+ }
79
+
70
80
  export type WorkbenchChangesProps = {
71
81
  /** The changed files (`useSandboxGit().diff`). Assumed non-empty by the caller. */
72
- diff: GitFileDiff[];
82
+ diff: SandboxGitFileDiff[];
73
83
  /** Which source the diff came from — drives the "live" vs "as of turn" badge. */
74
84
  source: "live" | "capture" | null;
75
85
  /** When the served capture was taken (ISO), when `source === "capture"`. */
@@ -77,33 +87,48 @@ export type WorkbenchChangesProps = {
77
87
  /** The capture's turn revision, for the "as of turn N" badge. */
78
88
  captureRevision?: number | null | undefined;
79
89
  themeType?: "dark" | "light" | undefined;
90
+ /** Route a guarded binary/oversize file into the live Files surface. */
91
+ onOpenFile?: ((path: string) => void) | undefined;
80
92
  className?: string | undefined;
81
93
  };
82
94
 
83
95
  type RailRow =
84
96
  | { kind: "group"; label: string; count: number }
85
- | { kind: "file"; file: GitFileDiff; index: number };
97
+ | { kind: "file"; file: SandboxGitFileDiff; index: number };
86
98
 
87
99
  /** Order the files and build the rail rows (grouped past the threshold). The
88
100
  * returned `orderedFiles` drives BOTH the rail and the diff pane so a rail row's
89
101
  * `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) {
102
+ export function buildRail(files: SandboxGitFileDiff[]): {
103
+ orderedFiles: SandboxGitFileDiff[];
104
+ rows: RailRow[];
105
+ grouped: boolean;
106
+ } {
107
+ const roots = new Set(
108
+ files.flatMap((file) => (file.repoRoot === undefined ? [] : [file.repoRoot])),
109
+ );
110
+ const groupByRepo = roots.size > 1;
111
+ if (!groupByRepo && files.length <= GROUP_THRESHOLD) {
92
112
  return {
93
113
  orderedFiles: files,
94
114
  rows: files.map((file, index) => ({ kind: "file", file, index })),
115
+ grouped: false,
95
116
  };
96
117
  }
97
- const groups = new Map<string, GitFileDiff[]>();
118
+ const groups = new Map<string, SandboxGitFileDiff[]>();
98
119
  for (const file of files) {
99
120
  const slash = file.path.indexOf("/");
100
- const key = slash > 0 ? file.path.slice(0, slash) : "(root)";
121
+ const key = groupByRepo
122
+ ? file.repoRoot || "(workspace)"
123
+ : slash > 0
124
+ ? file.path.slice(0, slash)
125
+ : "(root)";
101
126
  const bucket = groups.get(key);
102
127
  if (bucket) bucket.push(file);
103
128
  else groups.set(key, [file]);
104
129
  }
105
130
  const labels = [...groups.keys()].sort((a, b) => a.localeCompare(b));
106
- const orderedFiles: GitFileDiff[] = [];
131
+ const orderedFiles: SandboxGitFileDiff[] = [];
107
132
  const rows: RailRow[] = [];
108
133
  for (const label of labels) {
109
134
  const bucket = (groups.get(label) ?? []).slice().sort((a, b) => a.path.localeCompare(b.path));
@@ -113,7 +138,7 @@ export function buildRail(files: GitFileDiff[]): { orderedFiles: GitFileDiff[];
113
138
  orderedFiles.push(file);
114
139
  }
115
140
  }
116
- return { orderedFiles, rows };
141
+ return { orderedFiles, rows, grouped: true };
117
142
  }
118
143
 
119
144
  /** A file's diff body is "too large to inline" when it's binary or the diff guard
@@ -137,6 +162,7 @@ export function WorkbenchChanges({
137
162
  capturedAt,
138
163
  captureRevision,
139
164
  themeType,
165
+ onOpenFile,
140
166
  className,
141
167
  }: WorkbenchChangesProps) {
142
168
  const rootRef = useRef<HTMLDivElement | null>(null);
@@ -147,8 +173,13 @@ export function WorkbenchChanges({
147
173
  const [activePath, setActivePath] = useState<string | null>(null);
148
174
  const pickerId = useId();
149
175
 
150
- const { orderedFiles, rows } = useMemo(() => buildRail(diff), [diff]);
151
- const grouped = diff.length > GROUP_THRESHOLD;
176
+ const unicodeFontPaths = useMemo(
177
+ () => diff.flatMap((file) => (file.oldPath ? [file.path, file.oldPath] : [file.path])),
178
+ [diff],
179
+ );
180
+ useUnicodeFallbackFonts(unicodeFontPaths);
181
+
182
+ const { orderedFiles, rows, grouped } = useMemo(() => buildRail(diff), [diff]);
152
183
  const selectedIndex = orderedFiles.findIndex((file) => file.path === activePath);
153
184
  const activeIndex = selectedIndex >= 0 ? selectedIndex : 0;
154
185
 
@@ -211,7 +242,9 @@ export function WorkbenchChanges({
211
242
  // Track which section is at the top of the pane so the rail highlights it.
212
243
  const onPaneScroll = useCallback(() => {
213
244
  const el = windowed.scrollRef.current;
214
- if (!el) return;
245
+ if (!el) {
246
+ return;
247
+ }
215
248
  const top = el.scrollTop;
216
249
  const offsets = windowed.offsets;
217
250
  let idx = 0;
@@ -240,13 +273,33 @@ export function WorkbenchChanges({
240
273
  data-workbench-changes-layout={compact ? "compact" : "rail"}
241
274
  >
242
275
  {/* 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">
276
+ <div
277
+ className={cn(
278
+ "flex shrink-0 items-center justify-between gap-2 border-b border-og-border px-3 py-1.5",
279
+ compact && source === "capture" && "flex-col items-stretch gap-1.5 py-2",
280
+ )}
281
+ >
282
+ <span
283
+ role="status"
284
+ aria-atomic="true"
285
+ aria-live="polite"
286
+ data-contrast-audited
287
+ className="min-w-0 text-og-xs text-og-fg-muted"
288
+ >
245
289
  {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>
290
+ <span data-contrast-audited className="ml-2 text-og-status-idle">
291
+ +{additions}
292
+ </span>
293
+ <span data-contrast-audited className="ml-1 text-og-status-failed">
294
+ −{deletions}
295
+ </span>
248
296
  </span>
249
- <div className="flex shrink-0 items-center gap-2">
297
+ <div
298
+ className={cn(
299
+ "flex shrink-0 items-center gap-2",
300
+ compact && source === "capture" && "self-start",
301
+ )}
302
+ >
250
303
  {compact ? null : <LayoutToggle layout={layout} onChange={setLayout} />}
251
304
  <SourceBadge source={source} capturedAt={capturedAt} label={sourceBadge} />
252
305
  </div>
@@ -266,26 +319,32 @@ export function WorkbenchChanges({
266
319
  const index = Number(event.currentTarget.value);
267
320
  setActivePath(orderedFiles[index]?.path ?? null);
268
321
  }}
322
+ title={activeFile?.path}
269
323
  data-compact-file-picker
270
324
  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
325
  >
272
326
  {orderedFiles.map((file, index) => (
273
327
  <option key={file.path} value={index}>
274
- {STATUS_LETTER[file.status]} · {file.path} · +{file.additions} −{file.deletions}
328
+ {compactFileLabel(file)}
275
329
  </option>
276
330
  ))}
277
331
  </select>
278
332
  </div>
279
333
  <div className="min-h-0 min-w-0 flex-1 overflow-auto" data-opengeni-changes-pane>
280
334
  {activeFile ? (
281
- <DiffSection file={activeFile} layout="unified" themeType={resolvedTheme} />
335
+ <DiffSection
336
+ file={activeFile}
337
+ layout="unified"
338
+ themeType={resolvedTheme}
339
+ {...(onOpenFile ? { onOpenFile } : {})}
340
+ />
282
341
  ) : null}
283
342
  </div>
284
343
  </div>
285
344
  ) : (
286
345
  <div className="flex min-h-0 flex-1">
287
346
  {/* File rail — virtualized (virtua): a dense change set is fine. */}
288
- <div className="w-[240px] shrink-0 border-r border-og-border">
347
+ <div className="w-[clamp(12rem,28%,15rem)] shrink-0 border-r border-og-border bg-og-surface-1/35">
289
348
  <VList
290
349
  className="h-full"
291
350
  itemSize={coarsePointer ? 44 : 28}
@@ -296,7 +355,7 @@ export function WorkbenchChanges({
296
355
  <div
297
356
  key={`g:${row.label}`}
298
357
  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"
358
+ className="flex items-center gap-1.5 px-2 pb-0.5 pt-2 text-og-xs font-medium uppercase tracking-wide text-og-fg-subtle"
300
359
  >
301
360
  <span className="min-w-0 truncate">{row.label}</span>
302
361
  <span className="shrink-0">{row.count}</span>
@@ -332,7 +391,12 @@ export function WorkbenchChanges({
332
391
  top={windowed.offsets[index] ?? 0}
333
392
  onMeasure={windowed.measure}
334
393
  >
335
- <DiffSection file={file} layout={layout} themeType={resolvedTheme} />
394
+ <DiffSection
395
+ file={file}
396
+ layout={layout}
397
+ themeType={resolvedTheme}
398
+ {...(onOpenFile ? { onOpenFile } : {})}
399
+ />
336
400
  </MeasuredSection>
337
401
  );
338
402
  })}
@@ -362,7 +426,7 @@ function SourceBadge({
362
426
  if (source === "capture" && capturedAt) {
363
427
  return (
364
428
  <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"
429
+ className="inline-flex items-center gap-1 rounded-og-xs border border-og-border px-1.5 py-px text-og-xs text-og-fg-muted"
366
430
  title={new Date(capturedAt).toLocaleString()}
367
431
  >
368
432
  <HistoryIcon className="size-3 shrink-0 text-og-status-running" aria-hidden />
@@ -371,19 +435,19 @@ function SourceBadge({
371
435
  );
372
436
  }
373
437
  return (
374
- <span className="rounded-og-xs bg-og-surface-2 px-1.5 py-px text-2xs text-og-fg-subtle">
438
+ <span className="rounded-og-xs bg-og-surface-2 px-1.5 py-px text-og-xs text-og-fg-subtle">
375
439
  {label}
376
440
  </span>
377
441
  );
378
442
  }
379
443
 
380
- /** "live" · "as of turn 7 · 14:32" · "as of 14:32" (no revision). */
444
+ /** "Live diff" · "as of turn 7 · 14:32" · "as of 14:32" (no revision). */
381
445
  function describeSource(
382
446
  source: "live" | "capture" | null,
383
447
  capturedAt: string | null,
384
448
  revision: number | null,
385
449
  ): string {
386
- if (source !== "capture" || !capturedAt) return "live";
450
+ if (source !== "capture" || !capturedAt) return "Live diff";
387
451
  const time = formatAsOf(capturedAt, Date.now());
388
452
  return revision !== null ? `as of turn ${revision} · ${time}` : `as of ${time}`;
389
453
  }
@@ -394,14 +458,21 @@ function RailFileRow({
394
458
  active,
395
459
  onClick,
396
460
  }: {
397
- file: GitFileDiff;
461
+ file: SandboxGitFileDiff;
398
462
  grouped: boolean;
399
463
  active: boolean;
400
464
  onClick: () => void;
401
465
  }) {
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;
466
+ // A real repository group strips the exact repo root. Legacy top-directory
467
+ // grouping strips only that first segment.
468
+ const repoPrefix = file.repoRoot ? `${file.repoRoot}/` : null;
469
+ const shown = grouped
470
+ ? file.repoRoot !== undefined
471
+ ? repoPrefix && file.path.startsWith(repoPrefix)
472
+ ? file.path.slice(repoPrefix.length)
473
+ : file.path
474
+ : file.path.slice(file.path.indexOf("/") + 1) || file.path
475
+ : file.path;
405
476
  return (
406
477
  <button
407
478
  type="button"
@@ -409,20 +480,26 @@ function RailFileRow({
409
480
  title={file.path}
410
481
  data-rail-file
411
482
  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",
483
+ "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",
413
484
  grouped && "pl-3",
414
- active && "bg-og-surface-2",
485
+ active &&
486
+ "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",
415
487
  )}
416
488
  >
417
489
  <span
490
+ data-contrast-audited
418
491
  className={cn("w-3 shrink-0 text-center font-og-mono text-og-xs", STATUS_TINT[file.status])}
419
492
  >
420
493
  {STATUS_LETTER[file.status]}
421
494
  </span>
422
495
  <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>
496
+ <span className="ml-auto flex shrink-0 items-center gap-1 pl-1 font-og-mono text-og-xs">
497
+ <span data-contrast-audited className="text-og-status-idle">
498
+ +{file.additions}
499
+ </span>
500
+ <span data-contrast-audited className="text-og-status-failed">
501
+ −{file.deletions}
502
+ </span>
426
503
  </span>
427
504
  </button>
428
505
  );
@@ -468,10 +545,12 @@ function DiffSection({
468
545
  file,
469
546
  layout,
470
547
  themeType,
548
+ onOpenFile,
471
549
  }: {
472
550
  file: GitFileDiff;
473
551
  layout: "unified" | "split";
474
552
  themeType: "dark" | "light";
553
+ onOpenFile?: ((path: string) => void) | undefined;
475
554
  }) {
476
555
  // The guard files (binary / over-cap) get a minimal header + "open live" body
477
556
  // since Pierre has nothing to render; real diffs use Pierre's own sticky file
@@ -484,17 +563,17 @@ function DiffSection({
484
563
  <span className="min-w-0 truncate font-og-mono text-og-xs">
485
564
  {renamed ? `${file.oldPath} → ${file.path}` : file.path}
486
565
  </span>
487
- <span className="flex shrink-0 items-center gap-2 font-og-mono text-2xs">
566
+ <span className="flex shrink-0 items-center gap-2 font-og-mono text-og-xs">
488
567
  <span className="text-og-status-idle">+{file.additions}</span>
489
568
  <span className="text-og-status-failed">−{file.deletions}</span>
490
569
  </span>
491
570
  </header>
492
- <GuardBody file={file} />
571
+ <GuardBody file={file} {...(onOpenFile ? { onOpenFile } : {})} />
493
572
  </section>
494
573
  );
495
574
  }
496
575
  return (
497
- <section className="border-b border-og-border pb-2">
576
+ <section data-pierre-section className="border-b border-og-border pb-2">
498
577
  <PierreDiff diff={[file]} layout={layout} themeType={themeType} className="px-1" />
499
578
  </section>
500
579
  );
@@ -502,12 +581,28 @@ function DiffSection({
502
581
 
503
582
  /** The per-file guard: a binary file or an over-cap diff opens live rather than
504
583
  * inlining a body we don't (fully) have. */
505
- function GuardBody({ file }: { file: GitFileDiff }) {
584
+ function GuardBody({
585
+ file,
586
+ onOpenFile,
587
+ }: {
588
+ file: GitFileDiff;
589
+ onOpenFile?: ((path: string) => void) | undefined;
590
+ }) {
506
591
  const reason = file.isBinary ? "Binary file" : "Diff too large to show here";
507
592
  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>
593
+ <div className="flex flex-wrap items-center gap-2 px-3 py-3 text-og-sm text-og-fg-subtle">
594
+ <FileWarningIcon className="size-3.5 shrink-0" aria-hidden />
595
+ <span className="min-w-0 flex-1">{reason}.</span>
596
+ {onOpenFile ? (
597
+ <button
598
+ type="button"
599
+ onClick={() => onOpenFile(file.path)}
600
+ 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"
601
+ >
602
+ Open in Files
603
+ <ArrowUpRightIcon className="size-3.5" aria-hidden />
604
+ </button>
605
+ ) : null}
511
606
  </div>
512
607
  );
513
608
  }
@@ -527,7 +622,7 @@ function LayoutToggle({
527
622
  type="button"
528
623
  onClick={() => onChange(value)}
529
624
  className={cn(
530
- "min-h-7 rounded-og-xs px-1.5 py-0.5 text-2xs capitalize pointer-coarse:min-h-11",
625
+ "min-h-7 rounded-og-xs px-1.5 py-0.5 text-og-xs capitalize focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-og-accent max-[1023px]:min-w-11 pointer-coarse:min-h-11 pointer-coarse:min-w-11",
531
626
  layout === value
532
627
  ? "bg-og-accent-soft text-og-fg"
533
628
  : "text-og-fg-subtle hover:text-og-fg",