@opengeni/react 0.15.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 (101) hide show
  1. package/README.md +110 -10
  2. package/dist/{chunk-TOJR776I.js → chunk-6BXBGJ3B.js} +45 -369
  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 +1288 -1785
  21. package/dist/index.js +7015 -8806
  22. package/dist/index.js.map +1 -1
  23. package/dist/machines.d.ts +412 -3
  24. package/dist/machines.js +3 -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 +20 -4
  38. package/src/approvals.ts +5 -3
  39. package/src/client.ts +40 -0
  40. package/src/components/approval-surface.tsx +198 -0
  41. package/src/components/chat-composer.tsx +94 -993
  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 +1 -1
  51. package/src/components/machines-dashboard.tsx +3 -3
  52. package/src/components/message-timeline.tsx +15 -7
  53. package/src/components/model-picker.tsx +12 -3
  54. package/src/components/pierre-diff.tsx +32 -6
  55. package/src/components/queue-surface-implementation.tsx +1041 -0
  56. package/src/components/queue-surface-state.tsx +94 -0
  57. package/src/components/queue-surface.tsx +41 -559
  58. package/src/components/sandbox-files.tsx +138 -14
  59. package/src/components/sandbox-terminal.tsx +8 -2
  60. package/src/components/sandbox-workspace.tsx +339 -119
  61. package/src/components/workbench-changes.tsx +84 -33
  62. package/src/components/workspace-dock.tsx +188 -18
  63. package/src/composer.ts +60 -0
  64. package/src/hooks/internal.ts +115 -34
  65. package/src/hooks/use-composer.ts +335 -104
  66. package/src/hooks/use-human-input.ts +131 -0
  67. package/src/hooks/use-machine-chip.ts +2 -2
  68. package/src/hooks/use-machines.ts +27 -11
  69. package/src/hooks/use-sandbox-files.ts +310 -62
  70. package/src/hooks/use-sandbox-git.ts +154 -40
  71. package/src/hooks/use-sandbox-terminal.ts +28 -6
  72. package/src/hooks/use-session-capabilities.ts +38 -9
  73. package/src/hooks/use-session-control.ts +37 -8
  74. package/src/hooks/use-session-events.ts +522 -48
  75. package/src/hooks/use-slash-commands.ts +38 -38
  76. package/src/hooks/use-transcription.ts +757 -0
  77. package/src/hooks/use-turn-queue.ts +134 -66
  78. package/src/hooks/use-windowed-sections.ts +2 -2
  79. package/src/hooks/use-workspace-capture.ts +146 -40
  80. package/src/hooks/use-workspace-edit.ts +50 -14
  81. package/src/hooks/use-workspace-sessions.ts +3 -0
  82. package/src/human-input.ts +72 -0
  83. package/src/index.ts +76 -6
  84. package/src/lib/noto-sans-arabic-loader.ts +21 -0
  85. package/src/lib/noto-sans-jp-loader.generated.ts +295 -0
  86. package/src/lib/use-portal-token-style.ts +51 -0
  87. package/src/lib/use-unicode-fonts.ts +57 -0
  88. package/src/provider.tsx +4 -69
  89. package/src/session-context.ts +135 -0
  90. package/src/session.ts +79 -0
  91. package/src/timeline/parsers.ts +35 -0
  92. package/src/timeline/projection.ts +12 -4
  93. package/src/timeline/tool-renderers.tsx +41 -0
  94. package/src/timeline/turn-summary.tsx +2 -2
  95. package/src/timeline/types.ts +16 -8
  96. package/styles/index.css +56 -0
  97. package/styles/index.d.ts +2 -0
  98. package/styles/tokens.css +11 -6
  99. package/styles/tokens.d.ts +2 -0
  100. package/dist/chunk-TOJR776I.js.map +0 -1
  101. package/dist/machines-BpdwuQcD.d.ts +0 -449
@@ -12,7 +12,7 @@
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";
@@ -28,6 +28,7 @@ import {
28
28
  import { type ClientOverride, useOpenGeni } from "../provider";
29
29
  import { cn } from "../lib/cn";
30
30
  import { xtermThemeFromTokens } from "../lib/xterm-theme";
31
+ import { usePortalTokenStyle } from "../lib/use-portal-token-style";
31
32
  import { useSessionCapabilities } from "../hooks/use-session-capabilities";
32
33
  import { useSandboxFiles } from "../hooks/use-sandbox-files";
33
34
  import { useSandboxGit, type UseSandboxGitResult } from "../hooks/use-sandbox-git";
@@ -51,6 +52,37 @@ export type WorkspaceNotification = { kind: "error" | "info"; message: string };
51
52
  /** The workbench's canonical tab ids (a host injects extras around these). */
52
53
  export const WORKBENCH_TAB_CHANGES = "changes";
53
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
+ }
54
86
 
55
87
  function captureDegradedMessage(reason: string): string {
56
88
  switch (reason) {
@@ -122,7 +154,9 @@ function sandboxProvisionInFlight(events: SessionEvent[]): boolean {
122
154
  }
123
155
 
124
156
  export type WorkspaceMachine = {
125
- /** 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. */
126
160
  chip: MachineChip;
127
161
  /** The machine these surfaces are bound to (the Modal group box or a
128
162
  * self-hosted machine), or null while the fleet is still resolving. */
@@ -136,6 +170,12 @@ export type UseSandboxWorkspaceTabsOptions = ClientOverride & {
136
170
  sessionId: string;
137
171
  /** Live event log (usually `useSessionEvents().events`). */
138
172
  events: SessionEvent[];
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;
139
179
  /** Override the source-driven default tab (e.g. a host landing tab id). When
140
180
  * omitted the workbench picks Changes-vs-Files from capture or live Git. */
141
181
  initialTab?: string | null | undefined;
@@ -157,7 +197,8 @@ export type UseSandboxWorkspaceTabsResult = {
157
197
  tabs: WorkspaceTab[];
158
198
  /** The source-driven default tab: Changes when the first authoritative capture
159
199
  * or live Git result has changes, else Files (a host `initialTab` overrides).
160
- * null while that source resolves; the choice latches once. */
200
+ * null while that source resolves, or permanently when neither Changes nor
201
+ * Files is enabled; the choice latches once. */
161
202
  defaultTab: string | null;
162
203
  /** The machine-state model for the dock-header chip. */
163
204
  machine: WorkspaceMachine;
@@ -191,6 +232,15 @@ export function useSandboxWorkspaceTabs(
191
232
  const { sessionId, events, onNotify, requestedFilePath, requestedFileRequestId, onOpenFile } =
192
233
  options;
193
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(",");
194
244
 
195
245
  // The three box-warming INTENTS, each off by default and each
196
246
  // flipped true by a genuine user action (never on mount, never on a passive
@@ -219,6 +269,7 @@ export function useSandboxWorkspaceTabs(
219
269
  workspaceId,
220
270
  sessionId,
221
271
  pollIntervalMs: 8000,
272
+ enabled: anySurfaceEnabled,
222
273
  });
223
274
  const activeMachine: MachineView | null =
224
275
  machines.machines.find((m) => m.sandboxId === machines.activeSandboxId) ??
@@ -227,11 +278,12 @@ export function useSandboxWorkspaceTabs(
227
278
 
228
279
  const caps = useSessionCapabilities(sessionId, {
229
280
  events,
230
- attachDesktop: watchDesktop,
231
- attachTerminal: warmTerminal,
281
+ enabled: anySurfaceEnabled,
282
+ attachDesktop: desktopEnabled && watchDesktop,
283
+ attachTerminal: terminalEnabled && warmTerminal,
232
284
  // Explicit live-file intent only — NOT "the Files tab is open". A cold edit
233
285
  // or guarded-file open wakes the box; a glance at the tree/diff does not.
234
- attachFiles: warmFiles,
286
+ attachFiles: workspaceDataEnabled && warmFiles,
235
287
  });
236
288
  const capabilities = caps.capabilities;
237
289
  const liveness = capabilities?.liveness;
@@ -239,13 +291,17 @@ export function useSandboxWorkspaceTabs(
239
291
  // The FS is writable only when it's live AND not read-only. A self-hosted box
240
292
  // that's offline (or any read-only advertisement) or a capture-served cold tree
241
293
  // must not offer create/rename/delete/edit affordances — you cannot mutate a
242
- // 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
243
295
  // writable box; content editing on a cold CLOUD box is the wake-on-edit path in
244
296
  // the editor, not tree mutation.
245
297
  const fsReadOnly = capabilities?.FileSystem.readOnly ?? false;
246
- 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");
247
303
  const gitOn = capabilities?.Git.available ?? false;
248
- const terminalOn = (capabilities?.Terminal.transport ?? null) !== null;
304
+ const terminalOn = terminalEnabled && (capabilities?.Terminal.transport ?? null) !== null;
249
305
  // The REAL interactive terminal is the ttyd pty-ws stream. When it's live the
250
306
  // legacy HTTP PTY must NOT run; `useSandboxTerminal` opens its HTTP PTY only as
251
307
  // the firehose-mode fallback (a backend without ttyd).
@@ -253,13 +309,14 @@ export function useSandboxWorkspaceTabs(
253
309
  capabilities?.Terminal.transport === "pty-ws" && Boolean(capabilities?.Terminal.url);
254
310
  const ptyCapable = (capabilities?.Terminal.ptyCapable ?? false) && !ptyWsLive;
255
311
  const terminal = useSandboxTerminal(sessionId, {
256
- events,
257
- interactive: ptyCapable,
312
+ events: terminalEnabled ? events : [],
313
+ interactive: terminalEnabled && ptyCapable,
258
314
  liveness,
259
315
  });
260
316
  const desktopAdvertised =
261
- (capabilities?.DesktopStream.transport ?? null) !== null ||
262
- capabilities?.DesktopStream.reason === "lease_cold";
317
+ desktopEnabled &&
318
+ ((capabilities?.DesktopStream.transport ?? null) !== null ||
319
+ capabilities?.DesktopStream.reason === "lease_cold");
263
320
 
264
321
  // Lazy provisioning (#315) creates the box mid-turn on the first sandbox tool
265
322
  // call, emitting sandbox.provision started→completed/failed on the live stream.
@@ -280,9 +337,27 @@ export function useSandboxWorkspaceTabs(
280
337
 
281
338
  // The cold-paint data source: the latest turn-end capture, fetched with a single
282
339
  // api round-trip on mount (no machine). Feeds the Files tree + the Changes/Git
283
- // diff when the box is not warm; a warm box always wins (live path unchanged).
284
- 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
+ });
285
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]);
286
361
  const notifiedCaptureDegradedReason = useRef<{
287
362
  sessionId: string;
288
363
  reason: string | null;
@@ -302,7 +377,9 @@ export function useSandboxWorkspaceTabs(
302
377
 
303
378
  const files = useSandboxFiles(sessionId, {
304
379
  events,
305
- 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)),
306
383
  liveness,
307
384
  capture: captureState.capture,
308
385
  // A reverted optimistic mutation (e.g. a 409 rename collision) surfaces as a
@@ -315,14 +392,18 @@ export function useSandboxWorkspaceTabs(
315
392
  });
316
393
  const git = useSandboxGit(sessionId, {
317
394
  events,
318
- enabled: gitOn || captureAvailable,
395
+ enabled: workspaceDataEnabled && (captureAvailable || (gitOn && liveWorkspaceExpected)),
396
+ repoPaths,
319
397
  liveness,
320
398
  capture: captureState.capture,
321
399
  });
322
400
  const stagedGit = useSandboxGit(sessionId, {
323
401
  events,
324
- 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,
325
405
  staged: true,
406
+ repoPaths,
326
407
  liveness,
327
408
  capture: captureState.capture,
328
409
  });
@@ -331,7 +412,7 @@ export function useSandboxWorkspaceTabs(
331
412
  // belongs in the package (an embedder's theme toggle drives it too).
332
413
  const [xtermTheme, setXtermTheme] = useState<XtermTheme | undefined>(undefined);
333
414
  useEffect(() => {
334
- if (typeof document === "undefined") return;
415
+ if (!terminalEnabled || typeof document === "undefined") return;
335
416
  const derive = () => setXtermTheme(xtermThemeFromTokens());
336
417
  derive();
337
418
  const observer = new MutationObserver(derive);
@@ -340,7 +421,7 @@ export function useSandboxWorkspaceTabs(
340
421
  attributeFilter: ["data-og-theme", "class"],
341
422
  });
342
423
  return () => observer.disconnect();
343
- }, []);
424
+ }, [terminalEnabled]);
344
425
 
345
426
  async function acknowledgeAndWatch() {
346
427
  try {
@@ -375,9 +456,13 @@ export function useSandboxWorkspaceTabs(
375
456
  capabilitiesState: caps.state,
376
457
  activeMachineState: activeMachine?.state ?? null,
377
458
  activeIsSelfhosted: activeMachine?.kind === "selfhosted",
378
- wantsWarm: warmTerminal || watchDesktop || warmFiles,
459
+ wantsWarm:
460
+ (terminalEnabled && warmTerminal) ||
461
+ (desktopEnabled && watchDesktop) ||
462
+ (workspaceDataEnabled && warmFiles),
379
463
  capturedAt: captureState.capturedAt,
380
464
  });
465
+ const workspaceWaking = chip.state === "waking";
381
466
 
382
467
  // The pre-paint default tab is decided from the first AUTHORITATIVE workspace
383
468
  // source. A capture wins immediately on the cold/offline fast path; a warm box
@@ -387,49 +472,63 @@ export function useSandboxWorkspaceTabs(
387
472
  // resolve Changes earlier, but a pure embedder with no event preload still gets
388
473
  // the correct live default. The choice latches once so later edits never steal
389
474
  // the user's current tab.
390
- const defaultTabRef = useRef<{ sessionId: string; value: string | null }>({
391
- sessionId,
475
+ const defaultIdentity = `${sessionId}\u0000${surfaceIdentity}`;
476
+ const defaultTabRef = useRef<{ identity: string; value: string | null }>({
477
+ identity: defaultIdentity,
392
478
  value: null,
393
479
  });
394
- if (defaultTabRef.current.sessionId !== sessionId) {
395
- defaultTabRef.current = { sessionId, value: null };
480
+ if (defaultTabRef.current.identity !== defaultIdentity) {
481
+ defaultTabRef.current = { identity: defaultIdentity, value: null };
396
482
  }
397
- const liveWorkspaceExpected = liveness === "warm" || liveness === "draining";
398
483
  const captureIsAuthoritative =
399
484
  !liveWorkspaceExpected && (liveness !== undefined || caps.error !== null);
400
485
  const captureUnavailable = captureState.fileCount === 0 || captureState.error !== null;
401
486
  if (defaultTabRef.current.value === null) {
402
- if (initialTab) {
487
+ if (initialTab && (!isWorkbenchSurface(initialTab) || surfaceSet.has(initialTab))) {
403
488
  defaultTabRef.current.value = initialTab;
404
489
  } else if (git.source === "live") {
405
- defaultTabRef.current.value =
406
- git.diff.length > 0 ? WORKBENCH_TAB_CHANGES : WORKBENCH_TAB_FILES;
490
+ defaultTabRef.current.value = sourceDrivenDefaultTab(
491
+ git.diff.length > 0,
492
+ changesEnabled,
493
+ filesEnabled,
494
+ );
407
495
  } else if (
408
496
  captureIsAuthoritative &&
409
497
  captureState.fileCount !== null &&
410
498
  captureState.available
411
499
  ) {
412
- defaultTabRef.current.value =
413
- captureState.fileCount > 0 ? WORKBENCH_TAB_CHANGES : WORKBENCH_TAB_FILES;
500
+ defaultTabRef.current.value = sourceDrivenDefaultTab(
501
+ captureState.fileCount > 0,
502
+ changesEnabled,
503
+ filesEnabled,
504
+ );
414
505
  } else if (
415
506
  captureIsAuthoritative &&
416
507
  captureUnavailable &&
417
508
  initialWorkspaceTab(events) === WORKBENCH_TAB_CHANGES
418
509
  ) {
419
- defaultTabRef.current.value = WORKBENCH_TAB_CHANGES;
420
- } else if (captureState.available && git.error && captureState.fileCount !== null) {
421
- // A warm live read failed but the durable capture is intact: retain an
422
- // immediate, deterministic review surface instead of hanging unresolved.
423
- defaultTabRef.current.value =
424
- captureState.fileCount > 0 ? WORKBENCH_TAB_CHANGES : WORKBENCH_TAB_FILES;
510
+ defaultTabRef.current.value = sourceDrivenDefaultTab(true, changesEnabled, filesEnabled);
425
511
  } else if (captureUnavailable && caps.error) {
426
512
  // The Changes surface owns the truthful sandbox-unavailable state + retry.
427
513
  // Files would misleadingly describe this as a missing filesystem.
428
- defaultTabRef.current.value = WORKBENCH_TAB_CHANGES;
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
+ );
429
528
  } else if (captureUnavailable && git.error) {
430
529
  // No capture and live Git failed: Files is the least surprising fallback
431
530
  // and preserves the established no-capture degraded behavior.
432
- defaultTabRef.current.value = WORKBENCH_TAB_FILES;
531
+ defaultTabRef.current.value = sourceDrivenDefaultTab(false, changesEnabled, filesEnabled);
433
532
  }
434
533
  }
435
534
  // null only during the brief pre-first-resolve window (no host override yet).
@@ -438,66 +537,82 @@ export function useSandboxWorkspaceTabs(
438
537
  const tabs = useMemo(() => {
439
538
  const list: WorkspaceTab[] = [];
440
539
 
441
- // Changes — always present (capture-backed, works cold/offline). The default
442
- // primary surface. M5 replaces this body with the windowed diff renderer; the
443
- // seam is the `<ChangesTabBody>` element (this is the minimal placeholder).
444
- list.push({
445
- id: WORKBENCH_TAB_CHANGES,
446
- label: "Changes",
447
- badge: dirtyCount > 0 ? <DirtyBadge count={dirtyCount} /> : undefined,
448
- content: (
449
- <ChangesTabBody
450
- git={git}
451
- captureAvailable={captureAvailable}
452
- captureRevision={captureState.revision}
453
- capabilitiesState={caps.state}
454
- capabilitiesError={caps.error}
455
- onRetry={caps.renegotiate}
456
- {...(onOpenFile
457
- ? {
458
- onOpenFile: (path: string) => {
459
- requestWarmIntent("warmFiles");
460
- onOpenFile(path);
461
- },
462
- }
463
- : {})}
464
- />
465
- ),
466
- });
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
+ });
467
569
 
468
- // Files — always present (capture-backed cold tree; live warm). M5 virtualizes.
469
- list.push({
470
- id: WORKBENCH_TAB_FILES,
471
- label: "Files",
472
- content: (
473
- <SandboxFiles
474
- key={sessionId}
475
- files={files}
476
- git={git}
477
- stagedGit={stagedGit}
478
- fileSystemAvailable={fileSystemOn || captureAvailable}
479
- editable={filesEditable}
480
- {...(requestedFilePath
481
- ? {
482
- requestedPath: requestedFilePath,
483
- ...(requestedFileRequestId !== null && requestedFileRequestId !== undefined
484
- ? { requestedPathRequestId: requestedFileRequestId }
485
- : {}),
486
- requestedPathReady: liveness === "warm" || liveness === "draining",
487
- }
488
- : {})}
489
- // Ordinary capture browsing does not warm a box. The first edit — or an
490
- // explicit guarded-file open from Changes — is deliberate live-file intent.
491
- onEditIntent={() => requestWarmIntent("warmFiles")}
492
- className="h-full"
493
- />
494
- ),
495
- });
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
+ });
496
611
 
497
612
  // Terminal — capability-gated (appears after negotiation; never the default).
498
613
  if (terminalOn) {
499
614
  list.push({
500
- id: "terminal",
615
+ id: WORKBENCH_TAB_TERMINAL,
501
616
  label: "Terminal",
502
617
  content: (
503
618
  <div className="h-full bg-og-bg p-1">
@@ -518,10 +633,10 @@ export function useSandboxWorkspaceTabs(
518
633
  // Desktop — capability-gated + consent-gated.
519
634
  if (desktopAdvertised) {
520
635
  list.push({
521
- id: "desktop",
636
+ id: WORKBENCH_TAB_DESKTOP,
522
637
  label: "Desktop",
523
638
  badge: watchDesktop ? (
524
- <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">
525
640
  Live
526
641
  </span>
527
642
  ) : undefined,
@@ -542,6 +657,8 @@ export function useSandboxWorkspaceTabs(
542
657
  // eslint-disable-next-line react-hooks/exhaustive-deps
543
658
  }, [
544
659
  fileSystemOn,
660
+ changesEnabled,
661
+ filesEnabled,
545
662
  terminalOn,
546
663
  desktopAdvertised,
547
664
  captureAvailable,
@@ -559,6 +676,7 @@ export function useSandboxWorkspaceTabs(
559
676
  terminal,
560
677
  xtermTheme,
561
678
  capabilities,
679
+ workspaceWaking,
562
680
  caps.state,
563
681
  caps.error,
564
682
  caps.viewerCapReached,
@@ -569,6 +687,7 @@ export function useSandboxWorkspaceTabs(
569
687
  tabs,
570
688
  defaultTab,
571
689
  machine: {
690
+ enabled: anySurfaceEnabled,
572
691
  chip,
573
692
  activeMachine,
574
693
  loading: machines.loading,
@@ -584,6 +703,11 @@ export type SandboxWorkspaceProps = ClientOverride & {
584
703
  events: SessionEvent[];
585
704
  /** The chat / primary pane shown beside the dock. */
586
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;
587
711
  /** Host tabs injected BEFORE the workbench tabs (e.g. a "Run" landing tab). */
588
712
  leadingTabs?: WorkspaceTab[] | undefined;
589
713
  /** Host tabs injected AFTER the workbench tabs (e.g. a "Debug" tab). */
@@ -617,6 +741,7 @@ export function SandboxWorkspace(props: SandboxWorkspaceProps): ReactNode {
617
741
  sessionId,
618
742
  events,
619
743
  primary,
744
+ surfaces,
620
745
  leadingTabs,
621
746
  trailingTabs,
622
747
  initialTab,
@@ -659,6 +784,7 @@ export function SandboxWorkspace(props: SandboxWorkspaceProps): ReactNode {
659
784
  ...(props.workspaceId ? { workspaceId: props.workspaceId } : {}),
660
785
  sessionId,
661
786
  events,
787
+ ...(surfaces ? { surfaces } : {}),
662
788
  ...(initialTab ? { initialTab } : {}),
663
789
  ...(onNotify ? { onNotify } : {}),
664
790
  requestedFilePath: requestedFile?.sessionId === sessionId ? requestedFile.path : null,
@@ -673,7 +799,9 @@ export function SandboxWorkspace(props: SandboxWorkspaceProps): ReactNode {
673
799
  // so committing the real default at first-resolve produces no CONTENT switch.
674
800
  const tabs: WorkspaceTab[] = [...(leadingTabs ?? []), ...workbenchTabs, ...(trailingTabs ?? [])];
675
801
  const selectedTab = storedSelection?.sessionId === sessionId ? storedSelection.tab : null;
676
- 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;
677
805
  const selectTab = useCallback(
678
806
  (tab: string) => setStoredSelection({ sessionId, tab }),
679
807
  [sessionId],
@@ -685,14 +813,18 @@ export function SandboxWorkspace(props: SandboxWorkspaceProps): ReactNode {
685
813
  tabs={tabs}
686
814
  {...(activeTab !== undefined ? { activeTab } : {})}
687
815
  onActiveTabChange={selectTab}
688
- headerAccessory={
689
- <MachineStateChip
690
- chip={machine.chip}
691
- activeMachine={machine.activeMachine}
692
- error={machine.error}
693
- onRetry={() => void machine.refresh()}
694
- />
695
- }
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
+ : {})}
696
828
  {...(mobileLeadingControl !== undefined ? { mobileLeadingControl } : {})}
697
829
  {...(collapsed !== undefined ? { collapsed } : {})}
698
830
  {...(onCollapsedChange ? { onCollapsedChange } : {})}
@@ -710,7 +842,9 @@ export type { WorkspaceDockProps };
710
842
 
711
843
  function DirtyBadge({ count }: { count: number }) {
712
844
  return (
713
- <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>
714
848
  );
715
849
  }
716
850
 
@@ -737,7 +871,7 @@ function chipDotClass(state: MachineChip["state"]): string {
737
871
  * with a popover carrying the machine identity, connection state, the "shown as
738
872
  * of <time>" staleness note, the shared-session disclosure, and a retry when the
739
873
  * fleet failed to resolve (the old per-surface machine bar + Sandbox info tab,
740
- * folded into one header affordance — dossier §10.6 recommendation).
874
+ * folded into one header affordance — recommendation).
741
875
  */
742
876
  function MachineStateChip({
743
877
  chip,
@@ -750,10 +884,13 @@ function MachineStateChip({
750
884
  error: Error | null;
751
885
  onRetry: () => void;
752
886
  }) {
887
+ const triggerRef = useRef<HTMLButtonElement | null>(null);
888
+ const portalStyle = usePortalTokenStyle(triggerRef);
753
889
  return (
754
890
  <Popover.Root>
755
891
  <Popover.Trigger asChild>
756
892
  <button
893
+ ref={triggerRef}
757
894
  type="button"
758
895
  aria-label={`Machine: ${chip.label}`}
759
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"
@@ -768,9 +905,12 @@ function MachineStateChip({
768
905
  <Popover.Portal forceMount>
769
906
  <Popover.Content
770
907
  forceMount
908
+ data-machine-state-popover
771
909
  align="end"
772
910
  sideOffset={6}
773
- 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}
774
914
  >
775
915
  <div className="flex min-w-0 items-center gap-1.5">
776
916
  <MachineKindIcon kind={activeMachine?.kind} />
@@ -802,7 +942,7 @@ function MachineStateChip({
802
942
  ) : null}
803
943
  {error ? (
804
944
  <div className="mt-2.5 space-y-1.5 border-t border-og-border pt-2.5">
805
- <p className="text-og-xs leading-4 text-og-status-danger">
945
+ <p className="text-og-xs leading-4 text-og-danger">
806
946
  Couldn't reach the sandbox for this session.
807
947
  </p>
808
948
  <DockActionButton onClick={onRetry}>
@@ -840,30 +980,69 @@ function ChangesTabBody({
840
980
  git,
841
981
  captureAvailable,
842
982
  captureRevision,
983
+ capturePending,
984
+ liveWorkspaceExpected,
985
+ workspaceWaking,
843
986
  capabilitiesState,
844
987
  capabilitiesError,
845
988
  onRetry,
989
+ onWake,
846
990
  onOpenFile,
847
991
  }: {
848
992
  git: UseSandboxGitResult;
849
993
  captureAvailable: boolean;
850
994
  captureRevision: number | null;
995
+ capturePending: boolean;
996
+ liveWorkspaceExpected: boolean;
997
+ workspaceWaking: boolean;
851
998
  capabilitiesState: string;
852
999
  capabilitiesError: Error | null;
853
1000
  onRetry: () => void;
1001
+ onWake: () => void;
854
1002
  onOpenFile?: ((path: string) => void) | undefined;
855
1003
  }) {
856
1004
  const diff = git.diff;
857
1005
 
858
1006
  if (diff.length > 0) {
859
1007
  return (
860
- <WorkbenchChanges
861
- diff={diff}
862
- source={git.source}
863
- capturedAt={git.capturedAt}
864
- captureRevision={captureRevision}
865
- {...(onOpenFile ? { onOpenFile } : {})}
866
- />
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>
867
1046
  );
868
1047
  }
869
1048
 
@@ -871,7 +1050,7 @@ function ChangesTabBody({
871
1050
  return (
872
1051
  <CenteredState icon={<TriangleAlertIcon className="size-5" aria-hidden />} tone="danger">
873
1052
  <p className="text-og-sm font-medium text-og-fg">Sandbox unavailable</p>
874
- <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">
875
1054
  {capabilitiesError.message || "Couldn't reach the sandbox for this session."}
876
1055
  </p>
877
1056
  <DockActionButton onClick={onRetry}>
@@ -882,7 +1061,11 @@ function ChangesTabBody({
882
1061
  );
883
1062
  }
884
1063
 
885
- if ((capabilitiesState === "negotiating" || capabilitiesState === "cold") && !captureAvailable) {
1064
+ if (
1065
+ (capturePending || capabilitiesState === "negotiating") &&
1066
+ !captureAvailable &&
1067
+ !workspaceWaking
1068
+ ) {
886
1069
  return (
887
1070
  <CenteredState
888
1071
  icon={
@@ -900,6 +1083,38 @@ function ChangesTabBody({
900
1083
  );
901
1084
  }
902
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
+
903
1118
  if (git.loading && git.source === null) {
904
1119
  return (
905
1120
  <CenteredState
@@ -936,7 +1151,12 @@ function CenteredState({
936
1151
  tone?: "neutral" | "success" | "danger";
937
1152
  }) {
938
1153
  return (
939
- <div className="grid h-full place-items-center p-6 text-center">
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
+ >
940
1160
  <div className="flex max-w-sm flex-col items-center gap-2.5">
941
1161
  {icon ? (
942
1162
  <span