@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
@@ -0,0 +1,94 @@
1
+ import { Loader2Icon, RotateCwIcon } from "lucide-react";
2
+
3
+ import type { UseTurnQueueResult } from "../hooks/use-turn-queue";
4
+
5
+ type QueueStateProps = {
6
+ queue: UseTurnQueueResult;
7
+ };
8
+
9
+ export function EmptyQueueStateSurface({ queue }: QueueStateProps) {
10
+ const hasError = Boolean(queue.error || queue.mutationError);
11
+ return (
12
+ <div
13
+ className="mx-auto mb-2 w-full max-w-3xl shrink-0 px-4 sm:px-6"
14
+ data-testid="queue-surface"
15
+ >
16
+ <div className="overflow-hidden rounded-lg border border-border bg-surface/80 shadow-sm">
17
+ {queue.stoppingPreviousAttempt ? (
18
+ <QueueStoppingStatus queue={queue} dividerAfter={hasError} />
19
+ ) : null}
20
+ {hasError ? <QueueErrorAlert queue={queue} /> : null}
21
+ </div>
22
+ </div>
23
+ );
24
+ }
25
+
26
+ export function QueueStoppingStatus({
27
+ queue,
28
+ dividerAfter = false,
29
+ }: QueueStateProps & { dividerAfter?: boolean }) {
30
+ return (
31
+ <div
32
+ role="status"
33
+ aria-live="polite"
34
+ className={`flex min-h-11 items-center gap-2.5 bg-status-waiting/[0.07] px-3 py-2 text-xs text-fg ${
35
+ dividerAfter ? "border-b border-status-waiting/20" : ""
36
+ }`}
37
+ data-testid="stopping-previous-attempt"
38
+ >
39
+ <Loader2Icon
40
+ aria-hidden="true"
41
+ className="size-3.5 shrink-0 animate-spin text-status-waiting motion-reduce:animate-none"
42
+ />
43
+ <span className="min-w-0">
44
+ <span className="font-medium">
45
+ {queue.effectiveControl?.state === "paused"
46
+ ? "Stopping current attempt…"
47
+ : "Stopping previous attempt…"}
48
+ </span>{" "}
49
+ <span className="text-fg-muted">
50
+ {queue.effectiveControl?.state === "paused"
51
+ ? "Queued work stays saved until you resume."
52
+ : "Queued work is saved and starts automatically."}
53
+ </span>
54
+ </span>
55
+ </div>
56
+ );
57
+ }
58
+
59
+ export function QueueErrorAlert({
60
+ queue,
61
+ dividerBefore = false,
62
+ }: QueueStateProps & { dividerBefore?: boolean }) {
63
+ return (
64
+ <div className={`${dividerBefore ? "border-t border-border" : ""} p-2`}>
65
+ <div
66
+ role="alert"
67
+ className="flex min-w-0 max-w-full flex-wrap items-start gap-2 rounded-md bg-status-failed/10 px-2 py-1.5 text-xs text-status-failed"
68
+ >
69
+ <span
70
+ role="region"
71
+ aria-label="Queue error details"
72
+ tabIndex={0}
73
+ className="max-h-[min(5rem,20dvh)] min-w-0 max-w-full flex-[1_1_5rem] overflow-auto overscroll-contain whitespace-pre-wrap rounded-sm outline-none [overflow-wrap:anywhere] [unicode-bidi:plaintext] focus-visible:ring-2 focus-visible:ring-ring/40"
74
+ data-testid="queue-error-message"
75
+ dir="auto"
76
+ >
77
+ {(queue.mutationError ?? queue.error)?.message}
78
+ </span>
79
+ <button
80
+ type="button"
81
+ onClick={() => {
82
+ queue.clearMutationError();
83
+ void queue.refresh();
84
+ }}
85
+ aria-label="Dismiss queue error and retry"
86
+ title="Retry loading the queue"
87
+ className="ms-auto inline-flex size-7 shrink-0 items-center justify-center self-start rounded-md outline-none transition-colors hover:bg-surface-3 focus-visible:ring-2 focus-visible:ring-ring/40 motion-reduce:transition-none pointer-coarse:size-[44px]"
88
+ >
89
+ <RotateCwIcon className="size-3.5" />
90
+ </button>
91
+ </div>
92
+ </div>
93
+ );
94
+ }
@@ -0,0 +1,60 @@
1
+ import { Loader2Icon } from "lucide-react";
2
+ import { lazy, Suspense, type ComponentType } from "react";
3
+
4
+ import type { QueueSurfaceProps } from "./queue-surface-implementation";
5
+ import { EmptyQueueStateSurface } from "./queue-surface-state";
6
+
7
+ export type { QueueSurfaceProps } from "./queue-surface-implementation";
8
+
9
+ const loadQueueSurface = () => import("./queue-surface-implementation");
10
+ type QueueSurfaceModule = { QueueSurface: ComponentType<QueueSurfaceProps> };
11
+ type QueueSurfaceLoader = () => Promise<QueueSurfaceModule>;
12
+
13
+ /** The sole human prompt queue: compact above Goal, Agents, and composer. */
14
+ export const QueueSurface = createQueueSurface(loadQueueSurface);
15
+
16
+ /** @internal Deterministic Suspense seam for the QueueSurface regression suite. */
17
+ export function createQueueSurfaceForTest(loadImplementation: QueueSurfaceLoader) {
18
+ return createQueueSurface(loadImplementation);
19
+ }
20
+
21
+ function createQueueSurface(loadImplementation: QueueSurfaceLoader) {
22
+ const LazyQueueSurface = lazy(async () => ({
23
+ default: (await loadImplementation()).QueueSurface,
24
+ }));
25
+
26
+ return function QueueSurfaceBoundary(props: QueueSurfaceProps) {
27
+ const { queue } = props;
28
+ if (queue.queue.length === 0) {
29
+ if (!queue.stoppingPreviousAttempt && !queue.error && !queue.mutationError) return null;
30
+ return <EmptyQueueStateSurface queue={queue} />;
31
+ }
32
+
33
+ return (
34
+ <Suspense fallback={<QueueSurfaceFallback count={queue.queue.length} />}>
35
+ <LazyQueueSurface {...props} />
36
+ </Suspense>
37
+ );
38
+ };
39
+ }
40
+
41
+ function QueueSurfaceFallback({ count }: { count: number }) {
42
+ return (
43
+ <div
44
+ aria-live="polite"
45
+ className="mx-auto mb-2 w-full max-w-3xl shrink-0 px-4 sm:px-6"
46
+ data-testid="queue-surface-loading"
47
+ role="status"
48
+ >
49
+ <div className="overflow-hidden rounded-lg border border-border bg-surface/80 shadow-sm">
50
+ <div className="flex items-center gap-2 px-3 py-2 text-xs text-fg-muted pointer-coarse:min-h-[44px]">
51
+ <Loader2Icon
52
+ aria-hidden="true"
53
+ className="size-3.5 shrink-0 animate-spin motion-reduce:animate-none"
54
+ />
55
+ Loading {count} queued prompt{count === 1 ? "" : "s"}…
56
+ </div>
57
+ </div>
58
+ </div>
59
+ );
60
+ }
@@ -1,8 +1,11 @@
1
- import type { FsReadResponse } from "@opengeni/sdk";
1
+ import { FileCode2Icon, FileWarningIcon, LoaderCircleIcon } from "lucide-react";
2
2
  import { type ReactNode, useCallback, useEffect, useRef, useState } from "react";
3
3
  import { cn } from "../lib/cn";
4
4
  import { useThemeType } from "../lib/use-theme-type";
5
- import type { UseSandboxFilesResult } from "../hooks/use-sandbox-files";
5
+ import {
6
+ CapturedFileUnavailableError,
7
+ type UseSandboxFilesResult,
8
+ } from "../hooks/use-sandbox-files";
6
9
  import type { UseSandboxGitResult } from "../hooks/use-sandbox-git";
7
10
  import { CodeEditor } from "./code-editor";
8
11
  import { FileBrowser } from "./file-browser";
@@ -27,6 +30,23 @@ export type SandboxFilesProps = {
27
30
  * dock warms the box on this so the save lands fast; opening/reading never fires
28
31
  * it. Browsing the tree/diff must not warm a box. */
29
32
  onEditIntent?: (() => void) | undefined;
33
+ /** A guarded diff path routed here by the parent workspace. */
34
+ requestedPath?: string | undefined;
35
+ /** Identity for one guarded-file request. Increment this when the same path is
36
+ * deliberately requested again; it also lets a pending request be consumed
37
+ * without overriding later manual tree navigation. Defaults to the path. */
38
+ requestedPathRequestId?: string | number | undefined;
39
+ /** False while the parent is waking a cold sandbox for `requestedPath`. */
40
+ requestedPathReady?: boolean | undefined;
41
+ /** The machine is cold and no durable capture exists yet. Render an explicit
42
+ * wake gate instead of an empty-tree lie or an implicit Channel-A read. */
43
+ workspaceResting?: boolean | undefined;
44
+ /** A deliberate wake has started but the live file surface is not ready yet. */
45
+ workspaceWaking?: boolean | undefined;
46
+ /** Whether live reads are currently authoritative. */
47
+ liveWorkspaceReady?: boolean | undefined;
48
+ /** Deliberately wake the machine to read content absent from the capture. */
49
+ onWakeWorkspace?: (() => void) | undefined;
30
50
  themeType?: "dark" | "light" | undefined;
31
51
  className?: string | undefined;
32
52
  };
@@ -45,6 +65,13 @@ export function SandboxFiles({
45
65
  usePierre = true,
46
66
  editable = true,
47
67
  onEditIntent,
68
+ requestedPath,
69
+ requestedPathRequestId,
70
+ requestedPathReady = true,
71
+ workspaceResting = false,
72
+ workspaceWaking = false,
73
+ liveWorkspaceReady = true,
74
+ onWakeWorkspace,
48
75
  themeType,
49
76
  className,
50
77
  }: SandboxFilesProps) {
@@ -52,6 +79,30 @@ export function SandboxFiles({
52
79
  // View vs Edit for the selected file. Resets to View on every new selection so
53
80
  // opening a file never lands you in a stale dirty editor for a different path.
54
81
  const [editMode, setEditMode] = useState(false);
82
+ const [liveRequestedPath, setLiveRequestedPath] = useState<string | null>(null);
83
+ const [viewReloadRevision, setViewReloadRevision] = useState(0);
84
+ const pendingRequestRef = useRef<string | number | null>(null);
85
+ const handledRequestRef = useRef<string | number | null>(null);
86
+ const requestKey = requestedPath ? (requestedPathRequestId ?? requestedPath) : null;
87
+
88
+ useEffect(() => {
89
+ if (!requestedPath || requestKey === null) {
90
+ pendingRequestRef.current = null;
91
+ handledRequestRef.current = null;
92
+ return;
93
+ }
94
+ if (handledRequestRef.current === requestKey) {
95
+ return;
96
+ }
97
+ if (!requestedPathReady) {
98
+ pendingRequestRef.current = requestKey;
99
+ return;
100
+ }
101
+ handledRequestRef.current = requestKey;
102
+ pendingRequestRef.current = null;
103
+ setSelected(requestedPath);
104
+ setEditMode(false);
105
+ }, [requestKey, requestedPath, requestedPathReady]);
55
106
 
56
107
  // Side-by-side (tree left, viewer right) once the surface is wide enough;
57
108
  // stacked (tree over viewer) on a narrow dock. Tracked off the container so it
@@ -77,13 +128,21 @@ export function SandboxFiles({
77
128
  // writable). Nothing is auto-selected — the pane waits for a tree click, so the
78
129
  // Files tab opens as a calm browser, not a diff.
79
130
  const viewPath = selected;
80
- const fileView = useFileView(viewPath, files.readFile);
131
+ const fileView = useFileView(viewPath, files.readFile, viewReloadRevision);
81
132
 
82
133
  // Selecting a (different) file always returns to View — never drop the user into
83
- // an editor whose buffer belongs to the previously-selected path.
134
+ // an editor whose buffer belongs to the previously-selected path. Manual
135
+ // navigation also consumes a pending guarded-file request: a late cold→warm
136
+ // transition must never pull the user away from the file they chose meanwhile.
84
137
  const selectFile = useCallback((path: string) => {
138
+ if (pendingRequestRef.current !== null) {
139
+ handledRequestRef.current = pendingRequestRef.current;
140
+ pendingRequestRef.current = null;
141
+ }
85
142
  setSelected(path);
86
143
  setEditMode(false);
144
+ setLiveRequestedPath(null);
145
+ setViewReloadRevision(0);
87
146
  }, []);
88
147
 
89
148
  // A tree file is editable only when it is a real, fully-loaded text file: not
@@ -99,9 +158,54 @@ export function SandboxFiles({
99
158
  !fileView.truncated &&
100
159
  fileView.content !== null;
101
160
  const showEditor = canEdit && editMode;
161
+ const captureFileUnavailable =
162
+ fileView.error instanceof CapturedFileUnavailableError ? fileView.error : null;
163
+ const waitingForSelectedFile =
164
+ liveRequestedPath === viewPath &&
165
+ (!liveWorkspaceReady || files.loading) &&
166
+ captureFileUnavailable !== null;
102
167
 
103
168
  if (!fileSystemAvailable) {
104
- return <Notice className={className}>This sandbox does not expose a file system.</Notice>;
169
+ return (
170
+ <Notice
171
+ className={className}
172
+ icon={<FileWarningIcon className="size-5" aria-hidden />}
173
+ title="Files unavailable"
174
+ announce="alert"
175
+ >
176
+ This sandbox does not expose a file system.
177
+ </Notice>
178
+ );
179
+ }
180
+
181
+ if (workspaceResting || workspaceWaking) {
182
+ return (
183
+ <div className={cn("h-full", className)} data-opengeni-workspace-resting>
184
+ <Notice
185
+ icon={
186
+ workspaceWaking ? (
187
+ <LoaderCircleIcon
188
+ className="size-5 animate-spin motion-reduce:animate-none"
189
+ aria-hidden
190
+ />
191
+ ) : (
192
+ <FileCode2Icon className="size-5" aria-hidden />
193
+ )
194
+ }
195
+ title={workspaceWaking ? "Waking workspace" : "Workspace is resting"}
196
+ announce="status"
197
+ >
198
+ <p>
199
+ {workspaceWaking
200
+ ? "Connecting to the live file system…"
201
+ : "No captured revision is available yet. Wake the sandbox to browse its current files."}
202
+ </p>
203
+ {!workspaceWaking && onWakeWorkspace ? (
204
+ <WakeButton onClick={onWakeWorkspace}>Open live workspace</WakeButton>
205
+ ) : null}
206
+ </Notice>
207
+ </div>
208
+ );
105
209
  }
106
210
 
107
211
  return (
@@ -138,7 +242,10 @@ export function SandboxFiles({
138
242
  )}
139
243
  >
140
244
  <div className="flex shrink-0 items-center justify-between gap-2 border-b border-og-border bg-og-surface-1 px-2 py-1">
141
- <span className="min-w-0 truncate font-og-mono text-og-xs text-og-fg-muted">
245
+ <span
246
+ data-opengeni-selected-file
247
+ className="min-w-0 truncate font-og-mono text-og-xs text-og-fg-muted"
248
+ >
142
249
  {selected ?? "No file selected"}
143
250
  </span>
144
251
  {/* View/Edit toggle — only for a real, fully-loaded text file the editor
@@ -163,16 +270,54 @@ export function SandboxFiles({
163
270
  path={viewPath}
164
271
  initialContents={fileView.content}
165
272
  themeType={resolvedTheme}
166
- onSave={(contents) => files.writeFile(viewPath, contents)}
273
+ onSave={(contents) =>
274
+ files.writeFile(viewPath, contents, { expectedContent: fileView.content! })
275
+ }
276
+ onOverwrite={(contents) => files.writeFile(viewPath, contents, { force: true })}
277
+ onReload={() => setViewReloadRevision((revision) => revision + 1)}
167
278
  {...(onEditIntent ? { onEditIntent } : {})}
168
279
  className="h-full"
169
280
  />
281
+ ) : waitingForSelectedFile ? (
282
+ <Notice
283
+ icon={
284
+ <LoaderCircleIcon
285
+ className="size-5 animate-spin motion-reduce:animate-none"
286
+ aria-hidden
287
+ />
288
+ }
289
+ title="Waking workspace"
290
+ announce="status"
291
+ >
292
+ Opening {viewPath} when the live file system is ready…
293
+ </Notice>
294
+ ) : captureFileUnavailable ? (
295
+ <Notice icon={<FileCode2Icon className="size-5" aria-hidden />} title="On machine">
296
+ <p>
297
+ {captureFileUnavailable.reason === "too-large"
298
+ ? "This file is larger than the captured preview limit."
299
+ : captureFileUnavailable.reason === "content-missing"
300
+ ? "The captured copy is no longer available."
301
+ : "This file was indexed, but it was not changed in the captured turn."}
302
+ </p>
303
+ {onWakeWorkspace ? (
304
+ <WakeButton
305
+ onClick={() => {
306
+ setLiveRequestedPath(viewPath);
307
+ if (liveWorkspaceReady) void files.refresh();
308
+ else onWakeWorkspace();
309
+ }}
310
+ >
311
+ {liveWorkspaceReady ? "Retry live file" : "Open live file"}
312
+ </WakeButton>
313
+ ) : null}
314
+ </Notice>
170
315
  ) : fileView.error ? (
171
- <Notice>
316
+ <Notice announce="alert">
172
317
  Could not open {viewPath}: {fileView.error.message}
173
318
  </Notice>
174
319
  ) : fileView.loading ? (
175
- <Notice>Loading {viewPath}…</Notice>
320
+ <Notice announce="status">Loading {viewPath}…</Notice>
176
321
  ) : fileView.isBinary ? (
177
322
  <Notice>
178
323
  {viewPath} is a binary file ({fileView.sizeBytes ?? 0} bytes).
@@ -204,11 +349,26 @@ export function SandboxFiles({
204
349
  )}
205
350
  </>
206
351
  ) : (
207
- <Notice>Loading {viewPath}…</Notice>
352
+ <Notice announce="status">Loading {viewPath}…</Notice>
208
353
  )
354
+ ) : // Nothing selected — the tree shows the whole workspace; pick a file.
355
+ requestedPath && !requestedPathReady ? (
356
+ <Notice
357
+ icon={
358
+ <LoaderCircleIcon
359
+ className="size-5 animate-spin motion-reduce:animate-none"
360
+ aria-hidden
361
+ />
362
+ }
363
+ title="Waking sandbox"
364
+ announce="status"
365
+ >
366
+ Opening {requestedPath} when the live workspace is ready…
367
+ </Notice>
209
368
  ) : (
210
- // Nothing selected the tree shows the whole workspace; pick a file.
211
- <Notice>Select a file in the tree to view it.</Notice>
369
+ <Notice icon={<FileCode2Icon className="size-5" aria-hidden />} title="Choose a file">
370
+ Select a file in the tree to preview it.
371
+ </Notice>
212
372
  )}
213
373
  </div>
214
374
  </div>
@@ -217,19 +377,36 @@ export function SandboxFiles({
217
377
  );
218
378
  }
219
379
 
380
+ function WakeButton({ children, onClick }: { children: ReactNode; onClick: () => void }) {
381
+ return (
382
+ <button
383
+ type="button"
384
+ onClick={onClick}
385
+ className="mt-1 inline-flex min-h-11 items-center justify-center rounded-og-md bg-og-accent px-3 py-2 text-og-sm font-medium text-og-on-accent shadow-sm transition-colors hover:bg-og-accent-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-og-accent focus-visible:ring-offset-2 focus-visible:ring-offset-og-bg"
386
+ >
387
+ {children}
388
+ </button>
389
+ );
390
+ }
391
+
220
392
  function GitHeader({ git, dirtyCount }: { git: UseSandboxGitResult; dirtyCount: number }) {
221
393
  const dirty = dirtyCount > 0;
222
394
  return (
223
395
  <div className="flex shrink-0 items-center gap-2 border-b border-og-border bg-og-surface-1 px-2 py-1 text-og-sm">
224
396
  <span
397
+ aria-hidden="true"
225
398
  className={cn(
226
399
  "size-2 shrink-0 rounded-full",
227
400
  dirty ? "bg-og-status-running" : "bg-og-status-idle",
228
401
  )}
229
- title={dirty ? `${dirtyCount} changed` : "clean"}
230
402
  />
403
+ <span className="sr-only">
404
+ {dirty ? `Working tree has ${dirtyCount} changed files` : "Working tree clean"}
405
+ </span>
231
406
  <span className="truncate font-og-mono text-og-fg">
232
- {git.branch ?? (git.isRepo ? "(detached)" : "no repo")}
407
+ {git.repoCount > 1
408
+ ? `${git.repoCount} repositories`
409
+ : (git.branch ?? (git.isRepo ? "(detached)" : "no repo"))}
233
410
  </span>
234
411
  {(git.ahead > 0 || git.behind > 0) && (
235
412
  <span className="flex shrink-0 items-center gap-1.5 text-og-xs text-og-fg-subtle">
@@ -238,7 +415,13 @@ function GitHeader({ git, dirtyCount }: { git: UseSandboxGitResult; dirtyCount:
238
415
  </span>
239
416
  )}
240
417
  {dirty && (
241
- <span className="ml-auto shrink-0 text-og-xs text-og-fg-subtle">{dirtyCount} changed</span>
418
+ <span
419
+ aria-hidden="true"
420
+ data-contrast-audited
421
+ className="ml-auto shrink-0 text-og-xs text-og-fg-subtle"
422
+ >
423
+ {dirtyCount} changed
424
+ </span>
242
425
  )}
243
426
  </div>
244
427
  );
@@ -261,7 +444,7 @@ function Segmented({
261
444
  type="button"
262
445
  onClick={() => onChange(opt.value)}
263
446
  className={cn(
264
- "min-h-7 rounded-og-xs px-1.5 py-0.5 text-og-xs max-[1023px]:min-h-11 pointer-coarse:min-h-11",
447
+ "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 max-[1023px]:min-w-11 pointer-coarse:min-h-11 pointer-coarse:min-w-11",
265
448
  opt.value === value
266
449
  ? "bg-og-accent-soft text-og-fg"
267
450
  : "text-og-fg-subtle hover:text-og-fg",
@@ -294,7 +477,8 @@ type FileViewState = {
294
477
  */
295
478
  function useFileView(
296
479
  path: string | null,
297
- readFile: (path: string) => Promise<FsReadResponse>,
480
+ readFile: UseSandboxFilesResult["readFile"],
481
+ reloadRevision = 0,
298
482
  ): FileViewState {
299
483
  const [state, setState] = useState<FileViewState>({
300
484
  content: null,
@@ -317,6 +501,7 @@ function useFileView(
317
501
  return;
318
502
  }
319
503
  let cancelled = false;
504
+ const abort = new AbortController();
320
505
  setState({
321
506
  content: null,
322
507
  isBinary: false,
@@ -325,7 +510,7 @@ function useFileView(
325
510
  loading: true,
326
511
  error: null,
327
512
  });
328
- void readFile(path)
513
+ void readFile(path, { signal: abort.signal })
329
514
  .then((res) => {
330
515
  if (cancelled) return;
331
516
  const content = res.isBinary
@@ -355,8 +540,9 @@ function useFileView(
355
540
  });
356
541
  return () => {
357
542
  cancelled = true;
543
+ abort.abort();
358
544
  };
359
- }, [path, readFile]);
545
+ }, [path, readFile, reloadRevision]);
360
546
  return state;
361
547
  }
362
548
 
@@ -375,15 +561,38 @@ function decodeBase64Utf8(b64: string): string {
375
561
  return b64;
376
562
  }
377
563
 
378
- function Notice({ children, className }: { children: ReactNode; className?: string | undefined }) {
564
+ function Notice({
565
+ children,
566
+ className,
567
+ icon,
568
+ title,
569
+ announce,
570
+ }: {
571
+ children: ReactNode;
572
+ className?: string | undefined;
573
+ icon?: ReactNode | undefined;
574
+ title?: string | undefined;
575
+ announce?: "status" | "alert" | undefined;
576
+ }) {
379
577
  return (
380
578
  <div
579
+ role={announce}
580
+ aria-atomic={announce ? "true" : undefined}
581
+ aria-live={announce === "alert" ? "assertive" : announce === "status" ? "polite" : undefined}
381
582
  className={cn(
382
583
  "flex h-full items-center justify-center p-4 text-center text-og-sm text-og-fg-subtle",
383
584
  className,
384
585
  )}
385
586
  >
386
- {children}
587
+ <div className="flex max-w-sm flex-col items-center gap-2.5">
588
+ {icon ? (
589
+ <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">
590
+ {icon}
591
+ </span>
592
+ ) : null}
593
+ {title ? <p className="font-medium text-og-fg">{title}</p> : null}
594
+ <div className="leading-5">{children}</div>
595
+ </div>
387
596
  </div>
388
597
  );
389
598
  }
@@ -221,7 +221,13 @@ export function SandboxTerminal({
221
221
  // status lines INSIDE xterm instead of an overlay — but only when there is no
222
222
  // firehose transcript to show yet (otherwise the projected output is shown).
223
223
  const warm = liveness === "warm" || liveness === "draining";
224
- const booting = activated && !ptyMode && !warm && liveness != null && result.chunks.length === 0;
224
+ const booting =
225
+ activated &&
226
+ !ptyMode &&
227
+ !warm &&
228
+ liveness !== null &&
229
+ liveness !== undefined &&
230
+ result.chunks.length === 0;
225
231
 
226
232
  function handleActivate() {
227
233
  if (!activated) setActivated(true);
@@ -502,7 +508,7 @@ export function SandboxTerminal({
502
508
  writtenRef.current = new Set();
503
509
  wroteFirehoseRef.current = false;
504
510
  }}
505
- className="rounded-og-sm px-1.5 py-0.5 text-og-xs hover:text-og-fg pointer-coarse:min-h-10"
511
+ className="rounded-og-sm px-1.5 py-0.5 text-og-xs hover:text-og-fg pointer-coarse:min-h-11"
506
512
  >
507
513
  Clear
508
514
  </button>