@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
@@ -12,15 +12,23 @@
12
12
  // (never an app context), notifications flow through an optional `onNotify` prop
13
13
  // (no `sonner` import), and every surface renders with package primitives + og
14
14
  // tokens only. `apps/web` consumes this through the exact public surface an
15
- // external embedder (cloudgeni #1577) uses — that is criterion F1.
15
+ // external embedder uses — that is criterion F1.
16
16
  import { type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from "react";
17
17
  import { Popover } from "radix-ui";
18
18
  import type { SessionEvent } from "@opengeni/sdk";
19
- import { CpuIcon, LaptopIcon, RefreshCwIcon } from "lucide-react";
19
+ import {
20
+ CircleCheckIcon,
21
+ CpuIcon,
22
+ LaptopIcon,
23
+ LoaderCircleIcon,
24
+ RefreshCwIcon,
25
+ TriangleAlertIcon,
26
+ } from "lucide-react";
20
27
 
21
28
  import { type ClientOverride, useOpenGeni } from "../provider";
22
29
  import { cn } from "../lib/cn";
23
30
  import { xtermThemeFromTokens } from "../lib/xterm-theme";
31
+ import { usePortalTokenStyle } from "../lib/use-portal-token-style";
24
32
  import { useSessionCapabilities } from "../hooks/use-session-capabilities";
25
33
  import { useSandboxFiles } from "../hooks/use-sandbox-files";
26
34
  import { useSandboxGit, type UseSandboxGitResult } from "../hooks/use-sandbox-git";
@@ -44,6 +52,37 @@ export type WorkspaceNotification = { kind: "error" | "info"; message: string };
44
52
  /** The workbench's canonical tab ids (a host injects extras around these). */
45
53
  export const WORKBENCH_TAB_CHANGES = "changes";
46
54
  export const WORKBENCH_TAB_FILES = "files";
55
+ export const WORKBENCH_TAB_TERMINAL = "terminal";
56
+ export const WORKBENCH_TAB_DESKTOP = "desktop";
57
+
58
+ /**
59
+ * The built-in workbench surfaces in their canonical display order. Hosts can
60
+ * pass a subset through `surfaces`; omission preserves the complete standalone
61
+ * workbench.
62
+ */
63
+ export const WORKBENCH_SURFACES = [
64
+ WORKBENCH_TAB_CHANGES,
65
+ WORKBENCH_TAB_FILES,
66
+ WORKBENCH_TAB_TERMINAL,
67
+ WORKBENCH_TAB_DESKTOP,
68
+ ] as const;
69
+
70
+ export type SandboxWorkspaceSurface = (typeof WORKBENCH_SURFACES)[number];
71
+
72
+ function isWorkbenchSurface(value: string): value is SandboxWorkspaceSurface {
73
+ return (WORKBENCH_SURFACES as readonly string[]).includes(value);
74
+ }
75
+
76
+ function sourceDrivenDefaultTab(
77
+ hasChanges: boolean,
78
+ changesEnabled: boolean,
79
+ filesEnabled: boolean,
80
+ ): string | null {
81
+ if (hasChanges && changesEnabled) return WORKBENCH_TAB_CHANGES;
82
+ if (filesEnabled) return WORKBENCH_TAB_FILES;
83
+ if (changesEnabled) return WORKBENCH_TAB_CHANGES;
84
+ return null;
85
+ }
47
86
 
48
87
  function captureDegradedMessage(reason: string): string {
49
88
  switch (reason) {
@@ -62,11 +101,9 @@ function captureDegradedMessage(reason: string): string {
62
101
  * "changes exist → Changes, else Files" needs zero machine round-trips. A host
63
102
  * `override` (e.g. a landing "run" tab) wins when supplied.
64
103
  *
65
- * NOTE: the dock no longer uses this for its default tab — `<SandboxWorkspace>`
66
- * now derives the default from its OWN capture fetch (`useWorkspaceCapture`'s
67
- * `fileCount`), so a pure embedder needs no events-at-mount contract (Refinement
68
- * 2). This remains exported as a standalone helper for hosts that already hold
69
- * the event log and want the same decision without the capture fetch.
104
+ * `<SandboxWorkspace>` uses this only as an optional early hint after its own
105
+ * capture GET reports no durable capture. A pure embedder needs no events-at-mount
106
+ * contract: it falls through to authoritative live Git instead.
70
107
  */
71
108
  export function initialWorkspaceTab(
72
109
  events: SessionEvent[] | undefined,
@@ -117,7 +154,9 @@ function sandboxProvisionInFlight(events: SessionEvent[]): boolean {
117
154
  }
118
155
 
119
156
  export type WorkspaceMachine = {
120
- /** The derived live/waking/offline chip model (dossier §3 #10). */
157
+ /** Whether at least one built-in workbench surface is enabled. */
158
+ enabled: boolean;
159
+ /** The derived live/waking/offline chip model. */
121
160
  chip: MachineChip;
122
161
  /** The machine these surfaces are bound to (the Modal group box or a
123
162
  * self-hosted machine), or null while the fleet is still resolving. */
@@ -131,22 +170,35 @@ export type UseSandboxWorkspaceTabsOptions = ClientOverride & {
131
170
  sessionId: string;
132
171
  /** Live event log (usually `useSessionEvents().events`). */
133
172
  events: SessionEvent[];
134
- /** Override the capture-driven default tab (e.g. a host landing tab id). When
135
- * omitted the workbench picks Changes-vs-Files from its own capture fetch. */
173
+ /**
174
+ * Built-in surfaces this host wants to expose. Omit for all four. A disabled
175
+ * surface is behaviorally dormant: its data hooks, warm intents, stream
176
+ * attachment, and navigation callbacks are not activated.
177
+ */
178
+ surfaces?: readonly SandboxWorkspaceSurface[] | undefined;
179
+ /** Override the source-driven default tab (e.g. a host landing tab id). When
180
+ * omitted the workbench picks Changes-vs-Files from capture or live Git. */
136
181
  initialTab?: string | null | undefined;
137
182
  /** Host-routed notifications (mutation errors, desktop-consent failures). The
138
183
  * package never imports a toast library — the host decides how to surface. */
139
184
  onNotify?: ((notification: WorkspaceNotification) => void) | undefined;
185
+ /** File requested by a Changes guard. The Files surface defers the read until
186
+ * the sandbox is live, then reveals this path. */
187
+ requestedFilePath?: string | null | undefined;
188
+ /** Unique identity for `requestedFilePath`, including repeated requests for the
189
+ * same path. */
190
+ requestedFileRequestId?: string | number | null | undefined;
191
+ /** Route a guarded diff into the host's Files tab. */
192
+ onOpenFile?: ((path: string) => void) | undefined;
140
193
  };
141
194
 
142
195
  export type UseSandboxWorkspaceTabsResult = {
143
196
  /** Changes | Files | Terminal | Desktop (capability-gated where noted). */
144
197
  tabs: WorkspaceTab[];
145
- /** The capture-driven default tab: Changes when the session has changes, else
146
- * Files (a host `initialTab` overrides). null during the brief window before the
147
- * capture GET first resolves (and no host override was given) — consumers render
148
- * their dock's own first-tab fallback until it latches, which it does ONCE, so it
149
- * never causes a post-render switch. */
198
+ /** The source-driven default tab: Changes when the first authoritative capture
199
+ * or live Git result has changes, else Files (a host `initialTab` overrides).
200
+ * null while that source resolves, or permanently when neither Changes nor
201
+ * Files is enabled; the choice latches once. */
150
202
  defaultTab: string | null;
151
203
  /** The machine-state model for the dock-header chip. */
152
204
  machine: WorkspaceMachine;
@@ -156,7 +208,7 @@ type SessionWarmIntents = {
156
208
  sessionId: string;
157
209
  watchDesktop: boolean;
158
210
  warmTerminal: boolean;
159
- warmEdit: boolean;
211
+ warmFiles: boolean;
160
212
  };
161
213
 
162
214
  function emptyWarmIntents(sessionId: string): SessionWarmIntents {
@@ -164,7 +216,7 @@ function emptyWarmIntents(sessionId: string): SessionWarmIntents {
164
216
  sessionId,
165
217
  watchDesktop: false,
166
218
  warmTerminal: false,
167
- warmEdit: false,
219
+ warmFiles: false,
168
220
  };
169
221
  }
170
222
 
@@ -177,20 +229,30 @@ export function useSandboxWorkspaceTabs(
177
229
  options: UseSandboxWorkspaceTabsOptions,
178
230
  ): UseSandboxWorkspaceTabsResult {
179
231
  const { client, workspaceId } = useOpenGeni(options);
180
- const { sessionId, events, onNotify } = options;
232
+ const { sessionId, events, onNotify, requestedFilePath, requestedFileRequestId, onOpenFile } =
233
+ options;
181
234
  const initialTab = options.initialTab ?? null;
235
+ const requestedSurfaces = options.surfaces ?? WORKBENCH_SURFACES;
236
+ const surfaceSet = new Set<SandboxWorkspaceSurface>(requestedSurfaces);
237
+ const changesEnabled = surfaceSet.has(WORKBENCH_TAB_CHANGES);
238
+ const filesEnabled = surfaceSet.has(WORKBENCH_TAB_FILES);
239
+ const terminalEnabled = surfaceSet.has(WORKBENCH_TAB_TERMINAL);
240
+ const desktopEnabled = surfaceSet.has(WORKBENCH_TAB_DESKTOP);
241
+ const workspaceDataEnabled = changesEnabled || filesEnabled;
242
+ const anySurfaceEnabled = workspaceDataEnabled || terminalEnabled || desktopEnabled;
243
+ const surfaceIdentity = WORKBENCH_SURFACES.filter((surface) => surfaceSet.has(surface)).join(",");
182
244
 
183
- // The three — and only three — box-warming INTENTS, each off by default and each
245
+ // The three box-warming INTENTS, each off by default and each
184
246
  // flipped true by a genuine user action (never on mount, never on a passive
185
247
  // capture glance): desktop watch consent, terminal engagement (`onActivate`), and
186
- // the first wake-on-edit keystroke in the Files editor. Browsing capture-served
248
+ // a deliberate live-file open/edit in Files. Browsing capture-served
187
249
  // Changes/Files warms nothing — that is the whole point of Refinement 1.
188
250
  const [storedWarmIntents, setStoredWarmIntents] = useState<SessionWarmIntents>(() =>
189
251
  emptyWarmIntents(sessionId),
190
252
  );
191
253
  const warmIntents =
192
254
  storedWarmIntents.sessionId === sessionId ? storedWarmIntents : emptyWarmIntents(sessionId);
193
- const { watchDesktop, warmTerminal, warmEdit } = warmIntents;
255
+ const { watchDesktop, warmTerminal, warmFiles } = warmIntents;
194
256
  const requestWarmIntent = useCallback(
195
257
  (intent: Exclude<keyof SessionWarmIntents, "sessionId">) => {
196
258
  setStoredWarmIntents((previous) => {
@@ -207,6 +269,7 @@ export function useSandboxWorkspaceTabs(
207
269
  workspaceId,
208
270
  sessionId,
209
271
  pollIntervalMs: 8000,
272
+ enabled: anySurfaceEnabled,
210
273
  });
211
274
  const activeMachine: MachineView | null =
212
275
  machines.machines.find((m) => m.sandboxId === machines.activeSandboxId) ??
@@ -215,11 +278,12 @@ export function useSandboxWorkspaceTabs(
215
278
 
216
279
  const caps = useSessionCapabilities(sessionId, {
217
280
  events,
218
- attachDesktop: watchDesktop,
219
- attachTerminal: warmTerminal,
220
- // Edit intent only — NOT "the Files tab is open". A cold edit warms the box
221
- // (that is the wake); a cold glance at the tree/diff does not.
222
- attachFiles: warmEdit,
281
+ enabled: anySurfaceEnabled,
282
+ attachDesktop: desktopEnabled && watchDesktop,
283
+ attachTerminal: terminalEnabled && warmTerminal,
284
+ // Explicit live-file intent only NOT "the Files tab is open". A cold edit
285
+ // or guarded-file open wakes the box; a glance at the tree/diff does not.
286
+ attachFiles: workspaceDataEnabled && warmFiles,
223
287
  });
224
288
  const capabilities = caps.capabilities;
225
289
  const liveness = capabilities?.liveness;
@@ -227,13 +291,17 @@ export function useSandboxWorkspaceTabs(
227
291
  // The FS is writable only when it's live AND not read-only. A self-hosted box
228
292
  // that's offline (or any read-only advertisement) or a capture-served cold tree
229
293
  // must not offer create/rename/delete/edit affordances — you cannot mutate a
230
- // machine you can't reach (dossier §12-A2/C3). Tree-structure ops need a warm
294
+ // machine you can't reach (C3). Tree-structure ops need a warm
231
295
  // writable box; content editing on a cold CLOUD box is the wake-on-edit path in
232
296
  // the editor, not tree mutation.
233
297
  const fsReadOnly = capabilities?.FileSystem.readOnly ?? false;
234
- const filesEditable = fileSystemOn && !fsReadOnly;
298
+ // Mutations are live-only. A capture is a review artifact, never a writable
299
+ // surrogate for a sleeping machine; the user must deliberately open the live
300
+ // workspace before create/rename/delete/edit controls appear.
301
+ const filesEditable =
302
+ fileSystemOn && !fsReadOnly && (liveness === "warm" || liveness === "draining");
235
303
  const gitOn = capabilities?.Git.available ?? false;
236
- const terminalOn = (capabilities?.Terminal.transport ?? null) !== null;
304
+ const terminalOn = terminalEnabled && (capabilities?.Terminal.transport ?? null) !== null;
237
305
  // The REAL interactive terminal is the ttyd pty-ws stream. When it's live the
238
306
  // legacy HTTP PTY must NOT run; `useSandboxTerminal` opens its HTTP PTY only as
239
307
  // the firehose-mode fallback (a backend without ttyd).
@@ -241,13 +309,14 @@ export function useSandboxWorkspaceTabs(
241
309
  capabilities?.Terminal.transport === "pty-ws" && Boolean(capabilities?.Terminal.url);
242
310
  const ptyCapable = (capabilities?.Terminal.ptyCapable ?? false) && !ptyWsLive;
243
311
  const terminal = useSandboxTerminal(sessionId, {
244
- events,
245
- interactive: ptyCapable,
312
+ events: terminalEnabled ? events : [],
313
+ interactive: terminalEnabled && ptyCapable,
246
314
  liveness,
247
315
  });
248
316
  const desktopAdvertised =
249
- (capabilities?.DesktopStream.transport ?? null) !== null ||
250
- capabilities?.DesktopStream.reason === "lease_cold";
317
+ desktopEnabled &&
318
+ ((capabilities?.DesktopStream.transport ?? null) !== null ||
319
+ capabilities?.DesktopStream.reason === "lease_cold");
251
320
 
252
321
  // Lazy provisioning (#315) creates the box mid-turn on the first sandbox tool
253
322
  // call, emitting sandbox.provision started→completed/failed on the live stream.
@@ -268,9 +337,27 @@ export function useSandboxWorkspaceTabs(
268
337
 
269
338
  // The cold-paint data source: the latest turn-end capture, fetched with a single
270
339
  // api round-trip on mount (no machine). Feeds the Files tree + the Changes/Git
271
- // diff when the box is not warm; a warm box always wins (live path unchanged).
272
- const captureState = useWorkspaceCapture(sessionId, { events });
340
+ // diff immediately; a warm box reconciles live without discarding the capture
341
+ // when that provider read is temporarily unavailable.
342
+ const captureState = useWorkspaceCapture(sessionId, {
343
+ events,
344
+ enabled: workspaceDataEnabled,
345
+ });
273
346
  const captureAvailable = captureState.available;
347
+ const liveWorkspaceExpected = liveness === "warm" || liveness === "draining";
348
+ // Capability negotiation and capture resolution are independent requests. A
349
+ // cold capability document can win that race by a few milliseconds; enabling
350
+ // the leaf hooks in that window would make them interpret `capture: null` as a
351
+ // real miss and issue Channel-A reads, waking the very box the capture exists
352
+ // to avoid. Wait until the capture request has conclusively resolved. A warm
353
+ // workspace never waits on this server-side snapshot.
354
+ const capturePending =
355
+ !liveWorkspaceExpected && (captureState.fileCount === null || captureState.loading);
356
+ const repoPaths = useMemo(() => {
357
+ const advertised = capabilities?.Git.repos ?? [];
358
+ if (advertised.length > 0) return advertised;
359
+ return captureState.capture?.repos.map((repo) => repo.root) ?? [];
360
+ }, [capabilities?.Git.repos, captureState.capture]);
274
361
  const notifiedCaptureDegradedReason = useRef<{
275
362
  sessionId: string;
276
363
  reason: string | null;
@@ -290,7 +377,9 @@ export function useSandboxWorkspaceTabs(
290
377
 
291
378
  const files = useSandboxFiles(sessionId, {
292
379
  events,
293
- enabled: fileSystemOn || captureAvailable,
380
+ // No passive Channel-A reads while cold, even after a conclusive capture
381
+ // miss. A missing capture gets an explicit "Open live workspace" gate.
382
+ enabled: filesEnabled && (captureAvailable || (fileSystemOn && liveWorkspaceExpected)),
294
383
  liveness,
295
384
  capture: captureState.capture,
296
385
  // A reverted optimistic mutation (e.g. a 409 rename collision) surfaces as a
@@ -303,14 +392,18 @@ export function useSandboxWorkspaceTabs(
303
392
  });
304
393
  const git = useSandboxGit(sessionId, {
305
394
  events,
306
- enabled: gitOn || captureAvailable,
395
+ enabled: workspaceDataEnabled && (captureAvailable || (gitOn && liveWorkspaceExpected)),
396
+ repoPaths,
307
397
  liveness,
308
398
  capture: captureState.capture,
309
399
  });
310
400
  const stagedGit = useSandboxGit(sessionId, {
311
401
  events,
312
- enabled: gitOn,
402
+ // Captures intentionally expose one combined review diff, not a staged
403
+ // index. Never probe the live index while the lease is cold.
404
+ enabled: filesEnabled && gitOn && liveWorkspaceExpected,
313
405
  staged: true,
406
+ repoPaths,
314
407
  liveness,
315
408
  capture: captureState.capture,
316
409
  });
@@ -319,7 +412,7 @@ export function useSandboxWorkspaceTabs(
319
412
  // belongs in the package (an embedder's theme toggle drives it too).
320
413
  const [xtermTheme, setXtermTheme] = useState<XtermTheme | undefined>(undefined);
321
414
  useEffect(() => {
322
- if (typeof document === "undefined") return;
415
+ if (!terminalEnabled || typeof document === "undefined") return;
323
416
  const derive = () => setXtermTheme(xtermThemeFromTokens());
324
417
  derive();
325
418
  const observer = new MutationObserver(derive);
@@ -328,7 +421,7 @@ export function useSandboxWorkspaceTabs(
328
421
  attributeFilter: ["data-og-theme", "class"],
329
422
  });
330
423
  return () => observer.disconnect();
331
- }, []);
424
+ }, [terminalEnabled]);
332
425
 
333
426
  async function acknowledgeAndWatch() {
334
427
  try {
@@ -363,31 +456,79 @@ export function useSandboxWorkspaceTabs(
363
456
  capabilitiesState: caps.state,
364
457
  activeMachineState: activeMachine?.state ?? null,
365
458
  activeIsSelfhosted: activeMachine?.kind === "selfhosted",
366
- wantsWarm: warmTerminal || watchDesktop || warmEdit,
459
+ wantsWarm:
460
+ (terminalEnabled && warmTerminal) ||
461
+ (desktopEnabled && watchDesktop) ||
462
+ (workspaceDataEnabled && warmFiles),
367
463
  capturedAt: captureState.capturedAt,
368
464
  });
465
+ const workspaceWaking = chip.state === "waking";
369
466
 
370
- // The pre-paint default tab, decided from the workbench's OWN capture fetch — no
371
- // embedder events-at-mount contract (Refinement 2). A host `initialTab` wins
372
- // immediately; otherwise the default stays null until the capture GET first
373
- // resolves, then latches Changes (changes exist) or Files, ONCE live data can
374
- // never switch it afterward. Committing at first-resolve before the tab body's
375
- // first CONTENT paint (both bodies show a connecting/loading state until the
376
- // capture lands) means the first real content is the correct tab, no switch. A
377
- // pure embedder that never preloads events now gets the right default for free.
378
- const defaultTabRef = useRef<{ sessionId: string; value: string | null }>({
379
- sessionId,
467
+ // The pre-paint default tab is decided from the first AUTHORITATIVE workspace
468
+ // source. A capture wins immediately on the cold/offline fast path; a warm box
469
+ // waits for live Git so a prior snapshot can never outrank the current tree.
470
+ // When the GET says no capture exists, `fileCount: 0` does NOT itself mean the
471
+ // working tree is clean. A captured-revision announce can
472
+ // resolve Changes earlier, but a pure embedder with no event preload still gets
473
+ // the correct live default. The choice latches once so later edits never steal
474
+ // the user's current tab.
475
+ const defaultIdentity = `${sessionId}\u0000${surfaceIdentity}`;
476
+ const defaultTabRef = useRef<{ identity: string; value: string | null }>({
477
+ identity: defaultIdentity,
380
478
  value: null,
381
479
  });
382
- if (defaultTabRef.current.sessionId !== sessionId) {
383
- defaultTabRef.current = { sessionId, value: null };
480
+ if (defaultTabRef.current.identity !== defaultIdentity) {
481
+ defaultTabRef.current = { identity: defaultIdentity, value: null };
384
482
  }
483
+ const captureIsAuthoritative =
484
+ !liveWorkspaceExpected && (liveness !== undefined || caps.error !== null);
485
+ const captureUnavailable = captureState.fileCount === 0 || captureState.error !== null;
385
486
  if (defaultTabRef.current.value === null) {
386
- if (initialTab) {
487
+ if (initialTab && (!isWorkbenchSurface(initialTab) || surfaceSet.has(initialTab))) {
387
488
  defaultTabRef.current.value = initialTab;
388
- } else if (captureState.fileCount !== null) {
389
- defaultTabRef.current.value =
390
- captureState.fileCount > 0 ? WORKBENCH_TAB_CHANGES : WORKBENCH_TAB_FILES;
489
+ } else if (git.source === "live") {
490
+ defaultTabRef.current.value = sourceDrivenDefaultTab(
491
+ git.diff.length > 0,
492
+ changesEnabled,
493
+ filesEnabled,
494
+ );
495
+ } else if (
496
+ captureIsAuthoritative &&
497
+ captureState.fileCount !== null &&
498
+ captureState.available
499
+ ) {
500
+ defaultTabRef.current.value = sourceDrivenDefaultTab(
501
+ captureState.fileCount > 0,
502
+ changesEnabled,
503
+ filesEnabled,
504
+ );
505
+ } else if (
506
+ captureIsAuthoritative &&
507
+ captureUnavailable &&
508
+ initialWorkspaceTab(events) === WORKBENCH_TAB_CHANGES
509
+ ) {
510
+ defaultTabRef.current.value = sourceDrivenDefaultTab(true, changesEnabled, filesEnabled);
511
+ } else if (captureUnavailable && caps.error) {
512
+ // The Changes surface owns the truthful sandbox-unavailable state + retry.
513
+ // Files would misleadingly describe this as a missing filesystem.
514
+ defaultTabRef.current.value = sourceDrivenDefaultTab(true, changesEnabled, filesEnabled);
515
+ } else if (captureIsAuthoritative && captureState.fileCount !== null && captureUnavailable) {
516
+ // No durable review surface exists and the machine is resting. Files owns
517
+ // the explicit wake gate; do not issue a live Git probe merely to choose a
518
+ // tab.
519
+ defaultTabRef.current.value = sourceDrivenDefaultTab(false, changesEnabled, filesEnabled);
520
+ } else if (captureState.available && git.error && captureState.fileCount !== null) {
521
+ // A warm live read failed but the durable capture is intact: retain an
522
+ // immediate, deterministic review surface instead of hanging unresolved.
523
+ defaultTabRef.current.value = sourceDrivenDefaultTab(
524
+ captureState.fileCount > 0,
525
+ changesEnabled,
526
+ filesEnabled,
527
+ );
528
+ } else if (captureUnavailable && git.error) {
529
+ // No capture and live Git failed: Files is the least surprising fallback
530
+ // and preserves the established no-capture degraded behavior.
531
+ defaultTabRef.current.value = sourceDrivenDefaultTab(false, changesEnabled, filesEnabled);
391
532
  }
392
533
  }
393
534
  // null only during the brief pre-first-resolve window (no host override yet).
@@ -396,48 +537,82 @@ export function useSandboxWorkspaceTabs(
396
537
  const tabs = useMemo(() => {
397
538
  const list: WorkspaceTab[] = [];
398
539
 
399
- // Changes — always present (capture-backed, works cold/offline). The default
400
- // primary surface. M5 replaces this body with the windowed diff renderer; the
401
- // seam is the `<ChangesTabBody>` element (this is the minimal placeholder).
402
- list.push({
403
- id: WORKBENCH_TAB_CHANGES,
404
- label: "Changes",
405
- badge: dirtyCount > 0 ? <DirtyBadge count={dirtyCount} /> : undefined,
406
- content: (
407
- <ChangesTabBody
408
- git={git}
409
- captureAvailable={captureAvailable}
410
- captureRevision={captureState.revision}
411
- capabilitiesState={caps.state}
412
- capabilitiesError={caps.error}
413
- onRetry={caps.renegotiate}
414
- />
415
- ),
416
- });
540
+ // Changes — capture-backed and usable cold/offline when the host enables it.
541
+ if (changesEnabled)
542
+ list.push({
543
+ id: WORKBENCH_TAB_CHANGES,
544
+ label: "Changes",
545
+ badge: dirtyCount > 0 ? <DirtyBadge count={dirtyCount} /> : undefined,
546
+ content: (
547
+ <ChangesTabBody
548
+ git={git}
549
+ captureAvailable={captureAvailable}
550
+ captureRevision={captureState.revision}
551
+ capturePending={capturePending}
552
+ liveWorkspaceExpected={liveWorkspaceExpected}
553
+ workspaceWaking={workspaceWaking}
554
+ capabilitiesState={caps.state}
555
+ capabilitiesError={caps.error}
556
+ onRetry={caps.renegotiate}
557
+ onWake={() => requestWarmIntent("warmFiles")}
558
+ {...(filesEnabled && onOpenFile
559
+ ? {
560
+ onOpenFile: (path: string) => {
561
+ requestWarmIntent("warmFiles");
562
+ onOpenFile(path);
563
+ },
564
+ }
565
+ : {})}
566
+ />
567
+ ),
568
+ });
417
569
 
418
- // Files — always present (capture-backed cold tree; live warm). M5 virtualizes.
419
- list.push({
420
- id: WORKBENCH_TAB_FILES,
421
- label: "Files",
422
- content: (
423
- <SandboxFiles
424
- files={files}
425
- git={git}
426
- stagedGit={stagedGit}
427
- fileSystemAvailable={fileSystemOn || captureAvailable}
428
- editable={filesEditable}
429
- // The first keystroke in the editor is the wake-on-edit INTENT: warm the
430
- // box (even cold) so the write lands fast. Opening/reading files does not.
431
- onEditIntent={() => requestWarmIntent("warmEdit")}
432
- className="h-full"
433
- />
434
- ),
435
- });
570
+ // Files — capture-backed cold tree; live warm when the host enables it.
571
+ if (filesEnabled)
572
+ list.push({
573
+ id: WORKBENCH_TAB_FILES,
574
+ label: "Files",
575
+ content: (
576
+ <SandboxFiles
577
+ key={sessionId}
578
+ files={files}
579
+ git={git}
580
+ stagedGit={stagedGit}
581
+ fileSystemAvailable={fileSystemOn || captureAvailable}
582
+ editable={
583
+ filesEditable && files.source === "live" && files.error === null && !files.loading
584
+ }
585
+ workspaceResting={
586
+ !liveWorkspaceExpected &&
587
+ liveness !== undefined &&
588
+ !capturePending &&
589
+ !captureAvailable &&
590
+ !workspaceWaking
591
+ }
592
+ workspaceWaking={!liveWorkspaceExpected && !captureAvailable && workspaceWaking}
593
+ liveWorkspaceReady={liveWorkspaceExpected}
594
+ onWakeWorkspace={() => requestWarmIntent("warmFiles")}
595
+ {...(requestedFilePath
596
+ ? {
597
+ requestedPath: requestedFilePath,
598
+ ...(requestedFileRequestId !== null && requestedFileRequestId !== undefined
599
+ ? { requestedPathRequestId: requestedFileRequestId }
600
+ : {}),
601
+ requestedPathReady: liveness === "warm" || liveness === "draining",
602
+ }
603
+ : {})}
604
+ // Ordinary capture browsing does not warm a box. The first edit — or an
605
+ // explicit guarded-file open from Changes — is deliberate live-file intent.
606
+ onEditIntent={() => requestWarmIntent("warmFiles")}
607
+ className="h-full"
608
+ />
609
+ ),
610
+ });
436
611
 
437
612
  // Terminal — capability-gated (appears after negotiation; never the default).
438
613
  if (terminalOn) {
439
614
  list.push({
440
- id: "terminal",
615
+ id: WORKBENCH_TAB_TERMINAL,
441
616
  label: "Terminal",
442
617
  content: (
443
618
  <div className="h-full bg-og-bg p-1">
@@ -458,10 +633,10 @@ export function useSandboxWorkspaceTabs(
458
633
  // Desktop — capability-gated + consent-gated.
459
634
  if (desktopAdvertised) {
460
635
  list.push({
461
- id: "desktop",
636
+ id: WORKBENCH_TAB_DESKTOP,
462
637
  label: "Desktop",
463
638
  badge: watchDesktop ? (
464
- <span className="rounded-og-xs bg-og-status-running/20 px-1 text-2xs text-og-status-running">
639
+ <span className="rounded-og-xs bg-og-status-running/20 px-1 text-og-xs text-og-status-running">
465
640
  Live
466
641
  </span>
467
642
  ) : undefined,
@@ -482,18 +657,26 @@ export function useSandboxWorkspaceTabs(
482
657
  // eslint-disable-next-line react-hooks/exhaustive-deps
483
658
  }, [
484
659
  fileSystemOn,
660
+ changesEnabled,
661
+ filesEnabled,
485
662
  terminalOn,
486
663
  desktopAdvertised,
487
664
  captureAvailable,
488
665
  dirtyCount,
489
666
  watchDesktop,
490
667
  warmTerminal,
668
+ requestedFilePath,
669
+ requestedFileRequestId,
670
+ onOpenFile,
671
+ liveness,
672
+ sessionId,
491
673
  files,
492
674
  git,
493
675
  stagedGit,
494
676
  terminal,
495
677
  xtermTheme,
496
678
  capabilities,
679
+ workspaceWaking,
497
680
  caps.state,
498
681
  caps.error,
499
682
  caps.viewerCapReached,
@@ -504,6 +687,7 @@ export function useSandboxWorkspaceTabs(
504
687
  tabs,
505
688
  defaultTab,
506
689
  machine: {
690
+ enabled: anySurfaceEnabled,
507
691
  chip,
508
692
  activeMachine,
509
693
  loading: machines.loading,
@@ -519,12 +703,17 @@ export type SandboxWorkspaceProps = ClientOverride & {
519
703
  events: SessionEvent[];
520
704
  /** The chat / primary pane shown beside the dock. */
521
705
  primary: ReactNode;
706
+ /**
707
+ * Built-in surfaces this host wants to expose. Omit for Changes, Files,
708
+ * Terminal, and Desktop. Disabled surfaces remain behaviorally dormant.
709
+ */
710
+ surfaces?: readonly SandboxWorkspaceSurface[] | undefined;
522
711
  /** Host tabs injected BEFORE the workbench tabs (e.g. a "Run" landing tab). */
523
712
  leadingTabs?: WorkspaceTab[] | undefined;
524
713
  /** Host tabs injected AFTER the workbench tabs (e.g. a "Debug" tab). */
525
714
  trailingTabs?: WorkspaceTab[] | undefined;
526
715
  /** Override the pre-paint default tab (e.g. a host landing tab id). When
527
- * omitted the workbench decides Changes-vs-Files from local capture stats. */
716
+ * omitted the workbench decides from capture, then live Git when no capture exists. */
528
717
  initialTab?: string | undefined;
529
718
  /** Host-routed notifications (no toast dependency in the package). */
530
719
  onNotify?: ((notification: WorkspaceNotification) => void) | undefined;
@@ -552,6 +741,7 @@ export function SandboxWorkspace(props: SandboxWorkspaceProps): ReactNode {
552
741
  sessionId,
553
742
  events,
554
743
  primary,
744
+ surfaces,
555
745
  leadingTabs,
556
746
  trailingTabs,
557
747
  initialTab,
@@ -566,6 +756,25 @@ export function SandboxWorkspace(props: SandboxWorkspaceProps): ReactNode {
566
756
  className,
567
757
  } = props;
568
758
 
759
+ const [storedSelection, setStoredSelection] = useState<{
760
+ sessionId: string;
761
+ tab: string;
762
+ } | null>(null);
763
+ const [requestedFile, setRequestedFile] = useState<{
764
+ sessionId: string;
765
+ path: string;
766
+ requestId: number;
767
+ } | null>(null);
768
+ const nextFileRequestId = useRef(0);
769
+ const openFile = useCallback(
770
+ (path: string) => {
771
+ nextFileRequestId.current += 1;
772
+ setRequestedFile({ sessionId, path, requestId: nextFileRequestId.current });
773
+ setStoredSelection({ sessionId, tab: WORKBENCH_TAB_FILES });
774
+ },
775
+ [sessionId],
776
+ );
777
+
569
778
  const {
570
779
  tabs: workbenchTabs,
571
780
  machine,
@@ -575,22 +784,24 @@ export function SandboxWorkspace(props: SandboxWorkspaceProps): ReactNode {
575
784
  ...(props.workspaceId ? { workspaceId: props.workspaceId } : {}),
576
785
  sessionId,
577
786
  events,
787
+ ...(surfaces ? { surfaces } : {}),
578
788
  ...(initialTab ? { initialTab } : {}),
579
789
  ...(onNotify ? { onNotify } : {}),
790
+ requestedFilePath: requestedFile?.sessionId === sessionId ? requestedFile.path : null,
791
+ requestedFileRequestId: requestedFile?.sessionId === sessionId ? requestedFile.requestId : null,
792
+ onOpenFile: openFile,
580
793
  });
581
794
 
582
- // A user's tab click wins forever; before that we follow the capture-driven
795
+ // A user's tab click wins forever; before that we follow the source-driven
583
796
  // default. While it is still resolving (null, pure-embedder pre-first-resolve)
584
797
  // we pass no controlled tab, so the dock renders its own first-tab fallback
585
798
  // (Changes) — whose body is a connecting/loading state until the capture lands,
586
799
  // so committing the real default at first-resolve produces no CONTENT switch.
587
800
  const tabs: WorkspaceTab[] = [...(leadingTabs ?? []), ...workbenchTabs, ...(trailingTabs ?? [])];
588
- const [storedSelection, setStoredSelection] = useState<{
589
- sessionId: string;
590
- tab: string;
591
- } | null>(null);
592
801
  const selectedTab = storedSelection?.sessionId === sessionId ? storedSelection.tab : null;
593
- const activeTab = selectedTab ?? defaultTab ?? tabs[0]?.id;
802
+ const preferredTab = selectedTab ?? defaultTab;
803
+ const activeTab =
804
+ preferredTab && tabs.some((tab) => tab.id === preferredTab) ? preferredTab : tabs[0]?.id;
594
805
  const selectTab = useCallback(
595
806
  (tab: string) => setStoredSelection({ sessionId, tab }),
596
807
  [sessionId],
@@ -602,14 +813,18 @@ export function SandboxWorkspace(props: SandboxWorkspaceProps): ReactNode {
602
813
  tabs={tabs}
603
814
  {...(activeTab !== undefined ? { activeTab } : {})}
604
815
  onActiveTabChange={selectTab}
605
- headerAccessory={
606
- <MachineStateChip
607
- chip={machine.chip}
608
- activeMachine={machine.activeMachine}
609
- error={machine.error}
610
- onRetry={() => void machine.refresh()}
611
- />
612
- }
816
+ {...(machine.enabled
817
+ ? {
818
+ headerAccessory: (
819
+ <MachineStateChip
820
+ chip={machine.chip}
821
+ activeMachine={machine.activeMachine}
822
+ error={machine.error}
823
+ onRetry={() => void machine.refresh()}
824
+ />
825
+ ),
826
+ }
827
+ : {})}
613
828
  {...(mobileLeadingControl !== undefined ? { mobileLeadingControl } : {})}
614
829
  {...(collapsed !== undefined ? { collapsed } : {})}
615
830
  {...(onCollapsedChange ? { onCollapsedChange } : {})}
@@ -627,7 +842,9 @@ export type { WorkspaceDockProps };
627
842
 
628
843
  function DirtyBadge({ count }: { count: number }) {
629
844
  return (
630
- <span className="rounded-og-xs bg-og-accent-soft px-1 text-2xs text-og-fg-muted">{count}</span>
845
+ <span className="rounded-og-xs bg-og-accent-soft px-1 text-og-xs text-og-fg-muted">
846
+ {count}
847
+ </span>
631
848
  );
632
849
  }
633
850
 
@@ -645,7 +862,7 @@ function MachineKindIcon({
645
862
 
646
863
  function chipDotClass(state: MachineChip["state"]): string {
647
864
  if (state === "live") return "bg-og-status-running";
648
- if (state === "waking") return "bg-og-status-idle animate-pulse";
865
+ if (state === "waking") return "bg-og-status-idle animate-pulse motion-reduce:animate-none";
649
866
  return "bg-og-fg-subtle";
650
867
  }
651
868
 
@@ -654,7 +871,7 @@ function chipDotClass(state: MachineChip["state"]): string {
654
871
  * with a popover carrying the machine identity, connection state, the "shown as
655
872
  * of <time>" staleness note, the shared-session disclosure, and a retry when the
656
873
  * fleet failed to resolve (the old per-surface machine bar + Sandbox info tab,
657
- * folded into one header affordance — dossier §10.6 recommendation).
874
+ * folded into one header affordance — recommendation).
658
875
  */
659
876
  function MachineStateChip({
660
877
  chip,
@@ -667,13 +884,16 @@ function MachineStateChip({
667
884
  error: Error | null;
668
885
  onRetry: () => void;
669
886
  }) {
887
+ const triggerRef = useRef<HTMLButtonElement | null>(null);
888
+ const portalStyle = usePortalTokenStyle(triggerRef);
670
889
  return (
671
890
  <Popover.Root>
672
891
  <Popover.Trigger asChild>
673
892
  <button
893
+ ref={triggerRef}
674
894
  type="button"
675
895
  aria-label={`Machine: ${chip.label}`}
676
- className="inline-flex min-h-7 items-center gap-1.5 rounded-og-sm px-2 py-1 text-og-xs font-medium text-og-fg-muted transition-colors hover:bg-og-surface-2 hover:text-og-fg max-[1023px]:min-h-11 pointer-coarse:min-h-11"
896
+ className="inline-flex min-h-7 items-center gap-1.5 rounded-og-sm px-2 py-1 text-og-xs font-medium text-og-fg-muted transition-colors hover:bg-og-surface-2 hover:text-og-fg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-og-accent max-[1023px]:min-h-11 pointer-coarse:min-h-11"
677
897
  >
678
898
  <span
679
899
  className={cn("size-1.5 shrink-0 rounded-full", chipDotClass(chip.state))}
@@ -685,9 +905,12 @@ function MachineStateChip({
685
905
  <Popover.Portal forceMount>
686
906
  <Popover.Content
687
907
  forceMount
908
+ data-machine-state-popover
688
909
  align="end"
689
910
  sideOffset={6}
690
- className="z-50 w-64 rounded-og-md border border-og-border bg-og-surface-1 p-3 text-og-sm text-og-fg shadow-lg outline-none data-[state=closed]:hidden"
911
+ collisionPadding={8}
912
+ className="og-root z-50 w-64 max-w-[calc(100vw-1rem)] rounded-og-md border border-og-border bg-og-surface-1 p-3 text-og-sm text-og-fg shadow-lg outline-none"
913
+ style={portalStyle}
691
914
  >
692
915
  <div className="flex min-w-0 items-center gap-1.5">
693
916
  <MachineKindIcon kind={activeMachine?.kind} />
@@ -719,7 +942,7 @@ function MachineStateChip({
719
942
  ) : null}
720
943
  {error ? (
721
944
  <div className="mt-2.5 space-y-1.5 border-t border-og-border pt-2.5">
722
- <p className="text-og-xs leading-4 text-og-status-danger">
945
+ <p className="text-og-xs leading-4 text-og-danger">
723
946
  Couldn't reach the sandbox for this session.
724
947
  </p>
725
948
  <DockActionButton onClick={onRetry}>
@@ -740,7 +963,7 @@ function DockActionButton({ onClick, children }: { onClick: () => void; children
740
963
  <button
741
964
  type="button"
742
965
  onClick={onClick}
743
- className="inline-flex 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:text-og-fg pointer-coarse:min-h-9"
966
+ className="inline-flex 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:text-og-fg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-og-accent max-[1023px]:min-h-11 pointer-coarse:min-h-11"
744
967
  >
745
968
  {children}
746
969
  </button>
@@ -757,35 +980,77 @@ function ChangesTabBody({
757
980
  git,
758
981
  captureAvailable,
759
982
  captureRevision,
983
+ capturePending,
984
+ liveWorkspaceExpected,
985
+ workspaceWaking,
760
986
  capabilitiesState,
761
987
  capabilitiesError,
762
988
  onRetry,
989
+ onWake,
990
+ onOpenFile,
763
991
  }: {
764
992
  git: UseSandboxGitResult;
765
993
  captureAvailable: boolean;
766
994
  captureRevision: number | null;
995
+ capturePending: boolean;
996
+ liveWorkspaceExpected: boolean;
997
+ workspaceWaking: boolean;
767
998
  capabilitiesState: string;
768
999
  capabilitiesError: Error | null;
769
1000
  onRetry: () => void;
1001
+ onWake: () => void;
1002
+ onOpenFile?: ((path: string) => void) | undefined;
770
1003
  }) {
771
1004
  const diff = git.diff;
772
1005
 
773
1006
  if (diff.length > 0) {
774
1007
  return (
775
- <WorkbenchChanges
776
- diff={diff}
777
- source={git.source}
778
- capturedAt={git.capturedAt}
779
- captureRevision={captureRevision}
780
- />
1008
+ <div className="flex h-full min-h-0 flex-col">
1009
+ {git.error ? (
1010
+ <div
1011
+ role="status"
1012
+ aria-live="polite"
1013
+ data-opengeni-changes-degraded
1014
+ className="flex shrink-0 items-center gap-2 border-b border-og-status-running/30 bg-og-status-running/10 px-2 py-1.5 text-og-xs text-og-fg-muted"
1015
+ >
1016
+ <TriangleAlertIcon className="size-3.5 shrink-0 text-og-status-running" aria-hidden />
1017
+ <span className="min-w-0 flex-1">
1018
+ {git.source === "capture"
1019
+ ? "Live changes are temporarily unavailable. Showing the latest captured revision."
1020
+ : "Live refresh failed. Showing the last loaded changes."}
1021
+ </span>
1022
+ <button
1023
+ type="button"
1024
+ onClick={() => void git.refresh()}
1025
+ disabled={git.loading}
1026
+ className="inline-flex min-h-7 shrink-0 items-center gap-1 rounded-og-sm border border-og-border bg-og-surface-1 px-2 font-medium text-og-fg transition-colors hover:border-og-border-strong focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-og-accent disabled:cursor-not-allowed disabled:opacity-50 pointer-coarse:min-h-11"
1027
+ >
1028
+ <RefreshCwIcon
1029
+ className={cn("size-3", git.loading && "animate-spin motion-reduce:animate-none")}
1030
+ aria-hidden
1031
+ />
1032
+ Retry
1033
+ </button>
1034
+ </div>
1035
+ ) : null}
1036
+ <div className="min-h-0 flex-1">
1037
+ <WorkbenchChanges
1038
+ diff={diff}
1039
+ source={git.source}
1040
+ capturedAt={git.capturedAt}
1041
+ captureRevision={captureRevision}
1042
+ {...(onOpenFile ? { onOpenFile } : {})}
1043
+ />
1044
+ </div>
1045
+ </div>
781
1046
  );
782
1047
  }
783
1048
 
784
1049
  if (capabilitiesError && !captureAvailable) {
785
1050
  return (
786
- <CenteredState>
1051
+ <CenteredState icon={<TriangleAlertIcon className="size-5" aria-hidden />} tone="danger">
787
1052
  <p className="text-og-sm font-medium text-og-fg">Sandbox unavailable</p>
788
- <p className="text-og-sm leading-5 text-og-fg-muted">
1053
+ <p data-contrast-audited className="text-og-sm leading-5 text-og-fg-muted">
789
1054
  {capabilitiesError.message || "Couldn't reach the sandbox for this session."}
790
1055
  </p>
791
1056
  <DockActionButton onClick={onRetry}>
@@ -796,28 +1061,117 @@ function ChangesTabBody({
796
1061
  );
797
1062
  }
798
1063
 
799
- if ((capabilitiesState === "negotiating" || capabilitiesState === "cold") && !captureAvailable) {
1064
+ if (
1065
+ (capturePending || capabilitiesState === "negotiating") &&
1066
+ !captureAvailable &&
1067
+ !workspaceWaking
1068
+ ) {
800
1069
  return (
801
- <CenteredState>
802
- <p className="text-og-sm text-og-fg-muted">Connecting sandbox…</p>
1070
+ <CenteredState
1071
+ icon={
1072
+ <LoaderCircleIcon
1073
+ className="size-5 animate-spin motion-reduce:animate-none"
1074
+ aria-hidden
1075
+ />
1076
+ }
1077
+ >
1078
+ <p className="text-og-sm font-medium text-og-fg">Connecting workspace</p>
1079
+ <p className="text-og-sm leading-5 text-og-fg-subtle">
1080
+ Looking for the latest files and changes…
1081
+ </p>
1082
+ </CenteredState>
1083
+ );
1084
+ }
1085
+
1086
+ if (!liveWorkspaceExpected && !captureAvailable) {
1087
+ return (
1088
+ <CenteredState
1089
+ icon={
1090
+ workspaceWaking ? (
1091
+ <LoaderCircleIcon
1092
+ className="size-5 animate-spin motion-reduce:animate-none"
1093
+ aria-hidden
1094
+ />
1095
+ ) : (
1096
+ <CpuIcon className="size-5" aria-hidden />
1097
+ )
1098
+ }
1099
+ >
1100
+ <p className="text-og-sm font-medium text-og-fg">
1101
+ {workspaceWaking ? "Waking workspace" : "Workspace is resting"}
1102
+ </p>
1103
+ <p className="text-og-sm leading-5 text-og-fg-subtle">
1104
+ {workspaceWaking
1105
+ ? "Connecting to the live working tree…"
1106
+ : "No captured revision is available yet. Wake the sandbox to inspect current changes."}
1107
+ </p>
1108
+ {!workspaceWaking ? (
1109
+ <DockActionButton onClick={onWake}>
1110
+ <CpuIcon className="size-3" />
1111
+ Open live workspace
1112
+ </DockActionButton>
1113
+ ) : null}
1114
+ </CenteredState>
1115
+ );
1116
+ }
1117
+
1118
+ if (git.loading && git.source === null) {
1119
+ return (
1120
+ <CenteredState
1121
+ icon={
1122
+ <LoaderCircleIcon
1123
+ className="size-5 animate-spin motion-reduce:animate-none"
1124
+ aria-hidden
1125
+ />
1126
+ }
1127
+ >
1128
+ <p className="text-og-sm font-medium text-og-fg">Loading workspace</p>
1129
+ <p className="text-og-sm leading-5 text-og-fg-subtle">Reading the current working tree…</p>
803
1130
  </CenteredState>
804
1131
  );
805
1132
  }
806
1133
 
807
1134
  return (
808
- <CenteredState>
809
- <p className="text-og-sm font-medium text-og-fg">No changes yet</p>
1135
+ <CenteredState icon={<CircleCheckIcon className="size-5" aria-hidden />} tone="success">
1136
+ <p className="text-og-sm font-medium text-og-fg">Working tree is clean</p>
810
1137
  <p className="text-og-sm leading-5 text-og-fg-subtle">
811
- File edits from this session's turns show up here.
1138
+ File edits from future turns will appear here.
812
1139
  </p>
813
1140
  </CenteredState>
814
1141
  );
815
1142
  }
816
1143
 
817
- function CenteredState({ children }: { children: ReactNode }) {
1144
+ function CenteredState({
1145
+ children,
1146
+ icon,
1147
+ tone = "neutral",
1148
+ }: {
1149
+ children: ReactNode;
1150
+ icon?: ReactNode | undefined;
1151
+ tone?: "neutral" | "success" | "danger";
1152
+ }) {
818
1153
  return (
819
- <div className="grid h-full place-items-center p-6 text-center">
820
- <div className="flex max-w-sm flex-col items-center gap-2.5">{children}</div>
1154
+ <div
1155
+ role={tone === "danger" ? "alert" : "status"}
1156
+ aria-atomic="true"
1157
+ aria-live={tone === "danger" ? "assertive" : "polite"}
1158
+ className="grid h-full place-items-center p-6 text-center"
1159
+ >
1160
+ <div className="flex max-w-sm flex-col items-center gap-2.5">
1161
+ {icon ? (
1162
+ <span
1163
+ className={cn(
1164
+ "grid size-10 place-items-center rounded-og-lg border bg-og-surface-1 shadow-sm",
1165
+ tone === "success" && "border-og-status-idle/30 text-og-status-idle",
1166
+ tone === "danger" && "border-og-status-failed/30 text-og-status-failed",
1167
+ tone === "neutral" && "border-og-border text-og-fg-muted",
1168
+ )}
1169
+ >
1170
+ {icon}
1171
+ </span>
1172
+ ) : null}
1173
+ {children}
1174
+ </div>
821
1175
  </div>
822
1176
  );
823
1177
  }