@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
@@ -1,85 +1,94 @@
1
- import type { FsReadResponse, GitFileDiff } from "@opengeni/sdk";
2
- import { FileIcon } from "lucide-react";
3
- import { type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from "react";
1
+ import type { FsReadResponse } from "@opengeni/sdk";
2
+ import { FileCode2Icon, FileWarningIcon, LoaderCircleIcon } from "lucide-react";
3
+ import { type ReactNode, useCallback, useEffect, useRef, useState } from "react";
4
4
  import { cn } from "../lib/cn";
5
5
  import { useThemeType } from "../lib/use-theme-type";
6
6
  import type { UseSandboxFilesResult } from "../hooks/use-sandbox-files";
7
7
  import type { UseSandboxGitResult } from "../hooks/use-sandbox-git";
8
8
  import { CodeEditor } from "./code-editor";
9
9
  import { FileBrowser } from "./file-browser";
10
- import { DiffView } from "./diff-view";
11
- import { PierreDiff } from "./pierre-diff";
12
10
  import { PierreFile } from "./pierre-file";
13
11
 
14
12
  export type SandboxFilesProps = {
15
13
  /** From `useSandboxFiles(...)`. */
16
14
  files: UseSandboxFilesResult;
17
- /** From `useSandboxGit(...)` — the working-tree diff. */
15
+ /** From `useSandboxGit(...)` — drives the branch/dirty header only. */
18
16
  git: UseSandboxGitResult;
19
- /** From a second `useSandboxGit(..., { staged: true })` the staged diff. */
17
+ /** @deprecated Diffs live in the dedicated Changes tab now; accepted for
18
+ * source-compat but unused (the Files surface is a browser + viewer). */
20
19
  stagedGit?: UseSandboxGitResult | undefined;
21
20
  /** Whether a FileSystem surface is advertised (drives the unavailable notice). */
22
21
  fileSystemAvailable?: boolean | undefined;
23
- /** Use Pierre's Shiki-highlighted diff (default true; falls back to built-in). */
22
+ /** Use Pierre's Shiki highlighter for the viewer (default true; plain fallback). */
24
23
  usePierre?: boolean | undefined;
25
24
  /** Allow in-place editing of tree files (CodeMirror). Default true. When false
26
25
  * the surface is review-only: every text file opens in the read-only viewer. */
27
26
  editable?: boolean | undefined;
27
+ /** Fired once when the user first edits an open file (wake-on-edit intent). The
28
+ * dock warms the box on this so the save lands fast; opening/reading never fires
29
+ * it. Browsing the tree/diff must not warm a box. */
30
+ onEditIntent?: (() => void) | undefined;
31
+ /** A guarded diff path routed here by the parent workspace. */
32
+ requestedPath?: string | undefined;
33
+ /** Identity for one guarded-file request. Increment this when the same path is
34
+ * deliberately requested again; it also lets a pending request be consumed
35
+ * without overriding later manual tree navigation. Defaults to the path. */
36
+ requestedPathRequestId?: string | number | undefined;
37
+ /** False while the parent is waking a cold sandbox for `requestedPath`. */
38
+ requestedPathReady?: boolean | undefined;
28
39
  themeType?: "dark" | "light" | undefined;
29
40
  className?: string | undefined;
30
41
  };
31
42
 
32
- const STATUS_TINT: Record<GitFileDiff["status"], string> = {
33
- added: "text-og-status-idle",
34
- modified: "text-og-status-running",
35
- deleted: "text-og-status-failed",
36
- renamed: "text-og-accent",
37
- copied: "text-og-accent",
38
- untracked: "text-og-fg-subtle",
39
- ignored: "text-og-fg-subtle",
40
- conflicted: "text-og-status-failed",
41
- typechange: "text-og-status-running",
42
- };
43
-
44
- const STATUS_LETTER: Record<GitFileDiff["status"], string> = {
45
- added: "A",
46
- modified: "M",
47
- deleted: "D",
48
- renamed: "R",
49
- copied: "C",
50
- untracked: "U",
51
- ignored: "I",
52
- conflicted: "!",
53
- typechange: "T",
54
- };
55
-
56
43
  /**
57
- * The review-first Files surface: a sticky branch/dirty header, a "Changes"
58
- * group (changed files vs HEAD, badged), the full lazy file tree for context,
59
- * and an inline diff pane below for the selected changed file. The agent
60
- * commits; the human reviews there is no stage/commit/push UI here (power-git
61
- * lives in the terminal).
44
+ * The Files surface: a branch/dirty header, the full lazy file tree, and a
45
+ * viewer/editor pane for the selected file. This is the workspace BROWSER pick
46
+ * a file to read or edit it. Diff review lives in the dedicated Changes tab (this
47
+ * surface deliberately does NOT replicate the changed-files list, and does not
48
+ * diff here one job per tab). The agent commits; the human reviews.
62
49
  */
63
50
  export function SandboxFiles({
64
51
  files,
65
52
  git,
66
- stagedGit,
67
53
  fileSystemAvailable = true,
68
54
  usePierre = true,
69
55
  editable = true,
56
+ onEditIntent,
57
+ requestedPath,
58
+ requestedPathRequestId,
59
+ requestedPathReady = true,
70
60
  themeType,
71
61
  className,
72
62
  }: SandboxFilesProps) {
73
63
  const [selected, setSelected] = useState<string | null>(null);
74
- const [staged, setStaged] = useState(false);
75
- const [layout, setLayout] = useState<"unified" | "split">("unified");
76
- // View vs Edit for the read-only-viewer branch (a tree file that is NOT a
77
- // diff). Resets to View on every new selection so opening a file never lands
78
- // you in a stale dirty editor for a different path.
64
+ // View vs Edit for the selected file. Resets to View on every new selection so
65
+ // opening a file never lands you in a stale dirty editor for a different path.
79
66
  const [editMode, setEditMode] = useState(false);
67
+ const pendingRequestRef = useRef<string | number | null>(null);
68
+ const handledRequestRef = useRef<string | number | null>(null);
69
+ const requestKey = requestedPath ? (requestedPathRequestId ?? requestedPath) : null;
80
70
 
81
- // Side-by-side (tree left, diff right) once the surface is wide enough;
82
- // stacked (tree over diff) on a narrow dock. Tracked off the container so it
71
+ useEffect(() => {
72
+ if (!requestedPath || requestKey === null) {
73
+ pendingRequestRef.current = null;
74
+ handledRequestRef.current = null;
75
+ return;
76
+ }
77
+ if (handledRequestRef.current === requestKey) {
78
+ return;
79
+ }
80
+ if (!requestedPathReady) {
81
+ pendingRequestRef.current = requestKey;
82
+ return;
83
+ }
84
+ handledRequestRef.current = requestKey;
85
+ pendingRequestRef.current = null;
86
+ setSelected(requestedPath);
87
+ setEditMode(false);
88
+ }, [requestKey, requestedPath, requestedPathReady]);
89
+
90
+ // Side-by-side (tree left, viewer right) once the surface is wide enough;
91
+ // stacked (tree over viewer) on a narrow dock. Tracked off the container so it
83
92
  // reacts to the dock resize, not just the viewport.
84
93
  const rootRef = useRef<HTMLDivElement | null>(null);
85
94
  const [wide, setWide] = useState(false);
@@ -94,42 +103,33 @@ export function SandboxFiles({
94
103
  return () => observer.disconnect();
95
104
  }, []);
96
105
 
97
- // Resolve the effective diff theme from the host palette (the `data-og-theme`
98
- // attribute the demo/app sets), defaulting to dark, unless the caller forced
99
- // one. Keeps the Pierre diff dark-on-dark instead of a white slab.
106
+ // Resolve the effective viewer theme from the host palette (the `data-og-theme`
107
+ // attribute the demo/app sets), defaulting to dark, unless the caller forced one.
100
108
  const resolvedTheme = useThemeType(themeType);
101
109
 
102
- const activeGit = staged && stagedGit ? stagedGit : git;
103
- const changed = activeGit.diff;
104
- const changedPaths = useMemo(() => new Set(changed.map((f) => f.path)), [changed]);
105
-
106
- // The selection is EITHER a changed file (-> diff pane) or any tree file
107
- // (-> read-only viewer pane). An explicit pick wins; otherwise default to the
108
- // first changed file so a review-first dock opens on a diff. Crucially this is
109
- // NOT gated on a repo: with no changes (or no repo) the pane simply waits for a
110
- // tree click, and a clicked tree file always resolves to the viewer.
111
- const effectiveSelected = selected ?? changed[0]?.path ?? null;
112
- const selectedDiff = changed.find((f) => f.path === effectiveSelected) ?? null;
113
- // A selected path that is not a changed file is viewed (fs.read), not diffed.
114
- const viewPath = effectiveSelected && !selectedDiff ? effectiveSelected : null;
115
-
116
- // Read the selected file's contents for the viewer pane (text/base64). The
117
- // hook caps size + flags binary; we surface a notice for binary rather than
118
- // dumping base64 into the highlighter.
110
+ // The selected tree file opens in the viewer (read-only) or the editor (when
111
+ // writable). Nothing is auto-selected — the pane waits for a tree click, so the
112
+ // Files tab opens as a calm browser, not a diff.
113
+ const viewPath = selected;
119
114
  const fileView = useFileView(viewPath, files.readFile);
120
115
 
121
- // Selecting a (different) file always returns to View — never drop the user
122
- // into an editor whose buffer belongs to the previously-selected path.
116
+ // Selecting a (different) file always returns to View — never drop the user into
117
+ // an editor whose buffer belongs to the previously-selected path. Manual
118
+ // navigation also consumes a pending guarded-file request: a late cold→warm
119
+ // transition must never pull the user away from the file they chose meanwhile.
123
120
  const selectFile = useCallback((path: string) => {
121
+ if (pendingRequestRef.current !== null) {
122
+ handledRequestRef.current = pendingRequestRef.current;
123
+ pendingRequestRef.current = null;
124
+ }
124
125
  setSelected(path);
125
126
  setEditMode(false);
126
127
  }, []);
127
128
 
128
129
  // A tree file is editable only when it is a real, fully-loaded text file: not
129
- // binary (would corrupt on save) and not truncated (we only hold a PREFIX, so
130
- // a save would write the prefix back and lose the tail). The editor is then
131
- // additionally gated on the `editable` prop. Anything failing this opens
132
- // read-only in the viewer.
130
+ // binary (would corrupt on save) and not truncated (we only hold a PREFIX). The
131
+ // editor is then additionally gated on the `editable` prop; anything failing this
132
+ // opens read-only in the viewer.
133
133
  const canEdit =
134
134
  editable &&
135
135
  viewPath !== null &&
@@ -142,151 +142,72 @@ export function SandboxFiles({
142
142
 
143
143
  if (!fileSystemAvailable) {
144
144
  return (
145
- <Notice className={className}>This sandbox does not expose a file system.</Notice>
145
+ <Notice
146
+ className={className}
147
+ icon={<FileWarningIcon className="size-5" aria-hidden />}
148
+ title="Files unavailable"
149
+ >
150
+ This sandbox does not expose a file system.
151
+ </Notice>
146
152
  );
147
153
  }
148
154
 
149
155
  return (
150
156
  <div ref={rootRef} className={cn("flex h-full min-h-0 min-w-0 flex-col", className)}>
151
- {/* Branch + dirty header */}
152
- <GitHeader git={git} dirtyCount={changedPaths.size} />
157
+ {/* Branch + dirty header (context; NOT the changed-files list). */}
158
+ <GitHeader git={git} dirtyCount={git.diff.length} />
153
159
 
154
160
  <div className={cn("flex min-h-0 flex-1", wide ? "flex-row" : "flex-col")}>
155
- {/* Tree pane: Changes group + full tree. A fixed left column when wide,
156
- a top band when narrow. */}
161
+ {/* Tree pane: the full lazy file tree. A fixed left column when wide, a top
162
+ band when narrow. */}
157
163
  <div
158
164
  className={cn(
159
- "min-h-0 overflow-auto",
160
- wide
161
- ? "w-[280px] shrink-0 border-r border-og-border"
162
- : "flex-1",
165
+ "flex min-h-0 flex-col",
166
+ wide ? "w-[280px] shrink-0 border-r border-og-border" : "flex-1",
163
167
  )}
164
168
  >
165
- {changed.length > 0 && (
166
- <div className="border-b border-og-border">
167
- <div className="px-2 py-1 text-og-xs font-medium uppercase tracking-wide text-og-fg-subtle">
168
- Changes · {changed.length}
169
- </div>
170
- <ul className="pb-1">
171
- {changed.map((file) => (
172
- <li key={file.path}>
173
- <button
174
- type="button"
175
- onClick={() => selectFile(file.path)}
176
- className={cn(
177
- "flex 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-10",
178
- file.path === effectiveSelected && "bg-og-surface-2",
179
- )}
180
- >
181
- <span
182
- className={cn(
183
- "w-3 shrink-0 text-center font-og-mono text-og-xs",
184
- STATUS_TINT[file.status],
185
- )}
186
- >
187
- {STATUS_LETTER[file.status]}
188
- </span>
189
- <FileIcon className="size-3.5 shrink-0 opacity-70" />
190
- <span className="truncate">{file.path}</span>
191
- <span className="ml-auto flex shrink-0 items-center gap-1.5 pl-2 text-og-xs">
192
- <span className="text-og-status-idle">
193
- +{file.additions}
194
- </span>
195
- <span className="text-og-status-failed">
196
- −{file.deletions}
197
- </span>
198
- </span>
199
- </button>
200
- </li>
201
- ))}
202
- </ul>
203
- </div>
204
- )}
205
-
206
- <div className="px-2 py-1 text-og-xs font-medium uppercase tracking-wide text-og-fg-subtle">
207
- Files
208
- </div>
209
169
  <FileBrowser
210
170
  result={files}
211
- selectedPath={effectiveSelected ?? undefined}
171
+ selectedPath={selected ?? undefined}
212
172
  onSelectFile={selectFile}
173
+ editable={editable}
213
174
  emptyState="This directory is empty"
214
- className="min-w-0"
175
+ className="min-w-0 flex-1"
215
176
  />
216
177
  </div>
217
178
 
218
- {/* Diff pane: the selected file's diff. Fills the remaining width when
219
- side-by-side, sits below the tree when stacked. */}
179
+ {/* Viewer pane: the selected file's contents (read-only) or the editor.
180
+ Fills the remaining width when side-by-side, sits below the tree when
181
+ stacked. */}
220
182
  <div
221
183
  className={cn(
222
184
  "flex min-h-0 min-w-0 flex-col",
223
- wide
224
- ? "flex-1"
225
- : "flex-[1.4] border-t border-og-border",
185
+ wide ? "flex-1" : "flex-[1.4] border-t border-og-border",
226
186
  )}
227
187
  >
228
188
  <div className="flex shrink-0 items-center justify-between gap-2 border-b border-og-border bg-og-surface-1 px-2 py-1">
229
- <span className="min-w-0 truncate font-og-mono text-og-xs text-og-fg-muted">
230
- {effectiveSelected ?? "No file selected"}
189
+ <span
190
+ data-opengeni-selected-file
191
+ className="min-w-0 truncate font-og-mono text-og-xs text-og-fg-muted"
192
+ >
193
+ {selected ?? "No file selected"}
231
194
  </span>
232
- <div className="flex min-w-0 shrink-0 flex-wrap items-center justify-end gap-1">
233
- {/* The Working/Staged + Unified/Split toggles only apply to a diff.
234
- Hide them in the read-only viewer (a non-changed tree file). */}
235
- {selectedDiff && stagedGit && (
236
- <Segmented
237
- options={[
238
- { value: "working", label: "Working tree" },
239
- { value: "staged", label: "Staged" },
240
- ]}
241
- value={staged ? "staged" : "working"}
242
- onChange={(v) => setStaged(v === "staged")}
243
- />
244
- )}
245
- {selectedDiff && (
246
- <Segmented
247
- options={[
248
- { value: "unified", label: "Unified" },
249
- { value: "split", label: "Split" },
250
- ]}
251
- value={layout}
252
- onChange={(v) => setLayout(v as "unified" | "split")}
253
- />
254
- )}
255
- {/* View/Edit toggle — only for a real, fully-loaded text file the
256
- editor can safely round-trip. Binary/truncated files never get
257
- an Edit affordance (they'd corrupt on save). */}
258
- {!selectedDiff && canEdit && (
259
- <Segmented
260
- options={[
261
- { value: "view", label: "View" },
262
- { value: "edit", label: "Edit" },
263
- ]}
264
- value={editMode ? "edit" : "view"}
265
- onChange={(v) => setEditMode(v === "edit")}
266
- />
267
- )}
268
- </div>
195
+ {/* View/Edit toggle only for a real, fully-loaded text file the editor
196
+ can safely round-trip. Binary/truncated/read-only files never get an
197
+ Edit affordance (they'd corrupt on save or can't be written). */}
198
+ {canEdit && (
199
+ <Segmented
200
+ options={[
201
+ { value: "view", label: "View" },
202
+ { value: "edit", label: "Edit" },
203
+ ]}
204
+ value={editMode ? "edit" : "view"}
205
+ onChange={(v) => setEditMode(v === "edit")}
206
+ />
207
+ )}
269
208
  </div>
270
209
  <div className="min-h-0 flex-1 overflow-auto">
271
- {selectedDiff ? (
272
- // A changed file -> diff pane (HEAD vs working/staged).
273
- usePierre ? (
274
- <PierreDiff
275
- diff={[selectedDiff]}
276
- layout={layout}
277
- themeType={resolvedTheme}
278
- fallback={
279
- <DiffView diff={[selectedDiff]} layout={layout} isRepo={git.isRepo} className="p-1" />
280
- }
281
- className="p-1"
282
- />
283
- ) : (
284
- <DiffView diff={[selectedDiff]} layout={layout} isRepo={git.isRepo} className="p-1" />
285
- )
286
- ) : viewPath ? (
287
- // Any other tree file -> editable editor (Edit mode) or read-only
288
- // single-file viewer (fs.read). This works with NO repo — viewing
289
- // and editing files never requires git.
210
+ {viewPath ? (
290
211
  showEditor && fileView.content !== null ? (
291
212
  <CodeEditor
292
213
  key={viewPath}
@@ -294,49 +215,64 @@ export function SandboxFiles({
294
215
  initialContents={fileView.content}
295
216
  themeType={resolvedTheme}
296
217
  onSave={(contents) => files.writeFile(viewPath, contents)}
218
+ {...(onEditIntent ? { onEditIntent } : {})}
297
219
  className="h-full"
298
220
  />
299
221
  ) : fileView.error ? (
300
- <Notice>Could not open {viewPath}: {fileView.error.message}</Notice>
222
+ <Notice>
223
+ Could not open {viewPath}: {fileView.error.message}
224
+ </Notice>
301
225
  ) : fileView.loading ? (
302
226
  <Notice>Loading {viewPath}…</Notice>
303
227
  ) : fileView.isBinary ? (
304
- <Notice>{viewPath} is a binary file ({fileView.sizeBytes ?? 0} bytes).</Notice>
228
+ <Notice>
229
+ {viewPath} is a binary file ({fileView.sizeBytes ?? 0} bytes).
230
+ </Notice>
305
231
  ) : fileView.content !== null ? (
306
232
  <>
307
- {fileView.truncated && (
308
- <div className="border-b border-og-border bg-og-surface-1 px-2 py-1 text-og-xs text-og-status-running">
309
- Large file — showing a truncated preview ({fileView.sizeBytes ?? 0} bytes loaded). Editing is disabled to avoid corrupting the file.
310
- </div>
311
- )}
312
- {usePierre ? (
313
- <PierreFile
314
- path={viewPath}
315
- contents={fileView.content}
316
- themeType={resolvedTheme}
317
- fallback={
318
- <pre className="overflow-auto whitespace-pre p-2 font-og-mono text-og-sm text-og-fg">
319
- {fileView.content}
320
- </pre>
321
- }
322
- className="p-1"
323
- />
324
- ) : (
325
- <pre className="overflow-auto whitespace-pre p-2 font-og-mono text-og-sm text-og-fg">
326
- {fileView.content}
327
- </pre>
328
- )}
233
+ {fileView.truncated && (
234
+ <div className="border-b border-og-border bg-og-surface-1 px-2 py-1 text-og-xs text-og-status-running">
235
+ Large file — showing a truncated preview ({fileView.sizeBytes ?? 0} bytes
236
+ loaded). Editing is disabled to avoid corrupting the file.
237
+ </div>
238
+ )}
239
+ {usePierre ? (
240
+ <PierreFile
241
+ path={viewPath}
242
+ contents={fileView.content}
243
+ themeType={resolvedTheme}
244
+ fallback={
245
+ <pre className="overflow-auto whitespace-pre p-2 font-og-mono text-og-sm text-og-fg">
246
+ {fileView.content}
247
+ </pre>
248
+ }
249
+ className="p-1"
250
+ />
251
+ ) : (
252
+ <pre className="overflow-auto whitespace-pre p-2 font-og-mono text-og-sm text-og-fg">
253
+ {fileView.content}
254
+ </pre>
255
+ )}
329
256
  </>
330
257
  ) : (
331
258
  <Notice>Loading {viewPath}…</Notice>
332
259
  )
260
+ ) : // Nothing selected — the tree shows the whole workspace; pick a file.
261
+ requestedPath && !requestedPathReady ? (
262
+ <Notice
263
+ icon={
264
+ <LoaderCircleIcon
265
+ className="size-5 animate-spin motion-reduce:animate-none"
266
+ aria-hidden
267
+ />
268
+ }
269
+ title="Waking sandbox"
270
+ >
271
+ Opening {requestedPath} when the live workspace is ready…
272
+ </Notice>
333
273
  ) : (
334
- // Nothing selected never claim a repo is required; the tree shows
335
- // the whole workspace regardless of git.
336
- <Notice>
337
- {changed.length > 0
338
- ? "Select a changed file to review its diff, or a file in the tree to view it."
339
- : "Select a file in the tree to view it."}
274
+ <Notice icon={<FileCode2Icon className="size-5" aria-hidden />} title="Choose a file">
275
+ Select a file in the tree to preview it.
340
276
  </Notice>
341
277
  )}
342
278
  </div>
@@ -367,7 +303,7 @@ function GitHeader({ git, dirtyCount }: { git: UseSandboxGitResult; dirtyCount:
367
303
  </span>
368
304
  )}
369
305
  {dirty && (
370
- <span className="ml-auto shrink-0 text-og-xs text-og-fg-subtle">
306
+ <span data-contrast-audited className="ml-auto shrink-0 text-og-xs text-og-fg-subtle">
371
307
  {dirtyCount} changed
372
308
  </span>
373
309
  )}
@@ -392,7 +328,7 @@ function Segmented({
392
328
  type="button"
393
329
  onClick={() => onChange(opt.value)}
394
330
  className={cn(
395
- "rounded-og-xs px-1.5 py-0.5 text-og-xs pointer-coarse:min-h-10",
331
+ "min-h-7 rounded-og-xs px-1.5 py-0.5 text-og-xs focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-og-accent max-[1023px]:min-h-11 pointer-coarse:min-h-11",
396
332
  opt.value === value
397
333
  ? "bg-og-accent-soft text-og-fg"
398
334
  : "text-og-fg-subtle hover:text-og-fg",
@@ -437,11 +373,25 @@ function useFileView(
437
373
  });
438
374
  useEffect(() => {
439
375
  if (!path) {
440
- setState({ content: null, isBinary: false, truncated: false, sizeBytes: null, loading: false, error: null });
376
+ setState({
377
+ content: null,
378
+ isBinary: false,
379
+ truncated: false,
380
+ sizeBytes: null,
381
+ loading: false,
382
+ error: null,
383
+ });
441
384
  return;
442
385
  }
443
386
  let cancelled = false;
444
- setState({ content: null, isBinary: false, truncated: false, sizeBytes: null, loading: true, error: null });
387
+ setState({
388
+ content: null,
389
+ isBinary: false,
390
+ truncated: false,
391
+ sizeBytes: null,
392
+ loading: true,
393
+ error: null,
394
+ });
445
395
  void readFile(path)
446
396
  .then((res) => {
447
397
  if (cancelled) return;
@@ -492,7 +442,17 @@ function decodeBase64Utf8(b64: string): string {
492
442
  return b64;
493
443
  }
494
444
 
495
- function Notice({ children, className }: { children: ReactNode; className?: string | undefined }) {
445
+ function Notice({
446
+ children,
447
+ className,
448
+ icon,
449
+ title,
450
+ }: {
451
+ children: ReactNode;
452
+ className?: string | undefined;
453
+ icon?: ReactNode | undefined;
454
+ title?: string | undefined;
455
+ }) {
496
456
  return (
497
457
  <div
498
458
  className={cn(
@@ -500,7 +460,15 @@ function Notice({ children, className }: { children: ReactNode; className?: stri
500
460
  className,
501
461
  )}
502
462
  >
503
- {children}
463
+ <div className="flex max-w-sm flex-col items-center gap-2.5">
464
+ {icon ? (
465
+ <span className="grid size-10 place-items-center rounded-og-lg border border-og-border bg-og-surface-1 text-og-fg-muted shadow-sm">
466
+ {icon}
467
+ </span>
468
+ ) : null}
469
+ {title ? <p className="font-medium text-og-fg">{title}</p> : null}
470
+ <div className="leading-5">{children}</div>
471
+ </div>
504
472
  </div>
505
473
  );
506
474
  }