@opengeni/react 0.45.0 → 0.48.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 (141) hide show
  1. package/README.md +68 -12
  2. package/dist/{chunk-U3CSCMWA.js → chunk-FK6VG4LL.js} +9 -13
  3. package/dist/chunk-FK6VG4LL.js.map +1 -0
  4. package/dist/{chunk-HFO4ERGQ.js → chunk-FWXGSNXT.js} +2 -2
  5. package/dist/{chunk-SRFUT2ZU.js → chunk-I4MPFDRP.js} +119 -63
  6. package/dist/chunk-I4MPFDRP.js.map +1 -0
  7. package/dist/{chunk-EB67J347.js → chunk-KZ73RL76.js} +111 -31
  8. package/dist/chunk-KZ73RL76.js.map +1 -0
  9. package/dist/{chunk-LWR4MXSS.js → chunk-LHLYWG5R.js} +10 -1
  10. package/dist/chunk-LHLYWG5R.js.map +1 -0
  11. package/dist/{chunk-L3EWO3UK.js → chunk-NJ7M2B6Z.js} +445 -84
  12. package/dist/chunk-NJ7M2B6Z.js.map +1 -0
  13. package/dist/{chunk-VCBFUSUZ.js → chunk-O4VXOBGF.js} +3 -3
  14. package/dist/{chunk-VCBFUSUZ.js.map → chunk-O4VXOBGF.js.map} +1 -1
  15. package/dist/{chunk-6EDV6YFE.js → chunk-OL6QVXGF.js} +121 -29
  16. package/dist/chunk-OL6QVXGF.js.map +1 -0
  17. package/dist/chunk-VZTQ73XT.js +86 -0
  18. package/dist/chunk-VZTQ73XT.js.map +1 -0
  19. package/dist/{chunk-EHSYZ4KP.js → chunk-WYA65Y3F.js} +16 -16
  20. package/dist/chunk-WYA65Y3F.js.map +1 -0
  21. package/dist/{chunk-FD72GJMK.js → chunk-XCNTXWBB.js} +274 -125
  22. package/dist/chunk-XCNTXWBB.js.map +1 -0
  23. package/dist/client.d.ts +1 -1
  24. package/dist/components/chat-composer.d.ts +7 -2
  25. package/dist/components/composer-transcription-control.d.ts +2 -0
  26. package/dist/components/composer.d.ts +14 -0
  27. package/dist/components/machines/machine-detail.d.ts +3 -1
  28. package/dist/components/machines-dashboard.d.ts +3 -3
  29. package/dist/components/message-timeline.d.ts +12 -2
  30. package/dist/components/sandbox-files.d.ts +2 -2
  31. package/dist/components/sandbox-terminal.d.ts +29 -0
  32. package/dist/components/sandbox-workspace.d.ts +5 -1
  33. package/dist/components/workspace-dock.d.ts +3 -1
  34. package/dist/composer.d.ts +2 -2
  35. package/dist/composer.js +6 -6
  36. package/dist/hooks/internal.d.ts +6 -0
  37. package/dist/hooks/use-codex-accounts.d.ts +1 -1
  38. package/dist/hooks/use-environments.d.ts +10 -4
  39. package/dist/hooks/use-machines.d.ts +7 -0
  40. package/dist/hooks/use-sandbox-files.d.ts +19 -0
  41. package/dist/hooks/use-sandbox-git.d.ts +7 -0
  42. package/dist/hooks/use-transcription.d.ts +2 -2
  43. package/dist/hooks/use-voice-input.d.ts +13 -4
  44. package/dist/index.d.ts +6 -5
  45. package/dist/index.js +904 -538
  46. package/dist/index.js.map +1 -1
  47. package/dist/lib/composer-responsive-context.d.ts +9 -0
  48. package/dist/lib/use-portal-token-style.d.ts +14 -3
  49. package/dist/machines.js +2 -2
  50. package/dist/{queue-surface-implementation-J4XJFKRW.js → queue-surface-implementation-U5JFG7HT.js} +19 -18
  51. package/dist/queue-surface-implementation-U5JFG7HT.js.map +1 -0
  52. package/dist/realtime/realtime-control.d.ts +9 -0
  53. package/dist/realtime.js +29 -32
  54. package/dist/realtime.js.map +1 -1
  55. package/dist/session-ui.js +5 -5
  56. package/dist/session.js +4 -4
  57. package/dist/timeline/activity-rail.d.ts +3 -2
  58. package/dist/timeline/index.d.ts +2 -2
  59. package/dist/timeline/parsers.d.ts +3 -3
  60. package/dist/timeline/registry.d.ts +3 -0
  61. package/dist/timeline/screenshot-lightbox.d.ts +1 -1
  62. package/dist/timeline/types.d.ts +4 -0
  63. package/dist/voice-recording-store.d.ts +8 -2
  64. package/package.json +20 -4
  65. package/src/client.ts +3 -0
  66. package/src/components/chat-composer.tsx +12 -2
  67. package/src/components/code-editor.tsx +1 -1
  68. package/src/components/command-palette.tsx +1 -1
  69. package/src/components/composer-transcription-control.tsx +41 -16
  70. package/src/components/composer.tsx +52 -26
  71. package/src/components/copy-button.tsx +2 -2
  72. package/src/components/desktop-viewer.tsx +2 -2
  73. package/src/components/file-browser.tsx +39 -17
  74. package/src/components/human-input-form.tsx +2 -2
  75. package/src/components/machine-card.tsx +1 -1
  76. package/src/components/machines/machine-detail.tsx +39 -15
  77. package/src/components/machines-dashboard.tsx +7 -6
  78. package/src/components/message-timeline.tsx +44 -11
  79. package/src/components/model-picker.tsx +2 -2
  80. package/src/components/model-policy-picker.tsx +16 -15
  81. package/src/components/queue-surface-implementation.tsx +20 -16
  82. package/src/components/queue-surface-state.tsx +2 -2
  83. package/src/components/sandbox-files.tsx +140 -60
  84. package/src/components/sandbox-terminal.tsx +24 -5
  85. package/src/components/sandbox-workspace.tsx +180 -142
  86. package/src/components/session-chrome.tsx +14 -14
  87. package/src/components/tooltip.tsx +13 -16
  88. package/src/components/user-message-body.tsx +1 -1
  89. package/src/components/workbench-changes.tsx +4 -4
  90. package/src/components/workspace-dock.tsx +13 -5
  91. package/src/composer.ts +3 -0
  92. package/src/hooks/internal.ts +132 -38
  93. package/src/hooks/use-codex-accounts.ts +4 -4
  94. package/src/hooks/use-environments.ts +24 -5
  95. package/src/hooks/use-file-attachments.ts +70 -32
  96. package/src/hooks/use-goal.ts +17 -5
  97. package/src/hooks/use-machines.ts +47 -1
  98. package/src/hooks/use-sandbox-files.ts +310 -59
  99. package/src/hooks/use-sandbox-git.ts +148 -41
  100. package/src/hooks/use-session-capabilities.ts +35 -24
  101. package/src/hooks/use-session-events.ts +18 -4
  102. package/src/hooks/use-transcription.ts +4 -14
  103. package/src/hooks/use-turn-queue.ts +17 -5
  104. package/src/hooks/use-voice-input.ts +481 -36
  105. package/src/hooks/use-windowed-sections.ts +9 -7
  106. package/src/index.ts +11 -6
  107. package/src/lib/composer-responsive-context.ts +15 -0
  108. package/src/lib/use-portal-token-style.ts +60 -15
  109. package/src/provider.tsx +52 -7
  110. package/src/realtime/dropdown-menu.tsx +15 -19
  111. package/src/realtime/realtime-control.tsx +18 -7
  112. package/src/timeline/activity-rail.tsx +15 -6
  113. package/src/timeline/index.ts +1 -1
  114. package/src/timeline/parsers.ts +33 -20
  115. package/src/timeline/projection.ts +23 -0
  116. package/src/timeline/registry.ts +7 -0
  117. package/src/timeline/screenshot-lightbox.tsx +15 -4
  118. package/src/timeline/shared.tsx +8 -8
  119. package/src/timeline/tool-renderers.tsx +166 -22
  120. package/src/timeline/turn-summary.tsx +6 -6
  121. package/src/timeline/types.ts +4 -0
  122. package/src/voice-recording-store.ts +15 -0
  123. package/styles/compiled.css +5065 -0
  124. package/styles/compiled.d.ts +2 -0
  125. package/styles/effective-tokens.css +27 -0
  126. package/styles/index.css +3 -0
  127. package/styles/responsive.css +196 -0
  128. package/styles/responsive.d.ts +2 -0
  129. package/styles/tokens.css +50 -17
  130. package/dist/chunk-6EDV6YFE.js.map +0 -1
  131. package/dist/chunk-EB67J347.js.map +0 -1
  132. package/dist/chunk-EHSYZ4KP.js.map +0 -1
  133. package/dist/chunk-FD72GJMK.js.map +0 -1
  134. package/dist/chunk-JB5725BI.js +0 -48
  135. package/dist/chunk-JB5725BI.js.map +0 -1
  136. package/dist/chunk-L3EWO3UK.js.map +0 -1
  137. package/dist/chunk-LWR4MXSS.js.map +0 -1
  138. package/dist/chunk-SRFUT2ZU.js.map +0 -1
  139. package/dist/chunk-U3CSCMWA.js.map +0 -1
  140. package/dist/queue-surface-implementation-J4XJFKRW.js.map +0 -1
  141. /package/dist/{chunk-HFO4ERGQ.js.map → chunk-FWXGSNXT.js.map} +0 -0
@@ -14,32 +14,34 @@
14
14
  // tokens only. `apps/web` consumes this through the exact public surface an
15
15
  // external embedder uses — that is criterion F1.
16
16
  import { type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from "react";
17
- import { Popover } from "radix-ui";
18
17
  import type { SessionEvent } from "@opengeni/sdk";
19
18
  import {
20
19
  CircleCheckIcon,
21
20
  CpuIcon,
22
- LaptopIcon,
21
+ FileCode2Icon,
22
+ GitCompareArrowsIcon,
23
23
  LoaderCircleIcon,
24
+ MonitorIcon,
24
25
  RefreshCwIcon,
26
+ SquareTerminalIcon,
25
27
  TriangleAlertIcon,
26
28
  } from "lucide-react";
27
29
 
28
30
  import { type ClientOverride, useOpenGeni } from "../provider";
29
31
  import { cn } from "../lib/cn";
30
32
  import { xtermThemeFromTokens } from "../lib/xterm-theme";
31
- import { usePortalTokenStyle } from "../lib/use-portal-token-style";
32
33
  import { useSessionCapabilities } from "../hooks/use-session-capabilities";
33
34
  import { useSandboxFiles } from "../hooks/use-sandbox-files";
34
- import { useSandboxGit, type UseSandboxGitResult } from "../hooks/use-sandbox-git";
35
+ import {
36
+ useSandboxGit,
37
+ type SandboxGitComparison,
38
+ type UseSandboxGitResult,
39
+ } from "../hooks/use-sandbox-git";
35
40
  import { useSandboxTerminal } from "../hooks/use-sandbox-terminal";
36
41
  import { useWorkspaceCapture } from "../hooks/use-workspace-capture";
37
- import { formatAsOf, useMachineChip, type MachineChip } from "../hooks/use-machine-chip";
42
+ import { useMachineChip, type MachineChip } from "../hooks/use-machine-chip";
38
43
  import { useMachines } from "../hooks/use-machines";
39
44
  import type { MachineView } from "../types/machines";
40
- import { connectionStatusForState } from "../types/machines";
41
- import { ConnectionStatusPill } from "./machine-status-pill";
42
- import { SharedMachineDisclosure } from "./machine-dock-bar";
43
45
  import { SandboxFiles } from "./sandbox-files";
44
46
  import { WorkbenchChanges } from "./workbench-changes";
45
47
  import { SandboxTerminal, type XtermTheme } from "./sandbox-terminal";
@@ -97,6 +99,17 @@ function captureDegradedMessage(reason: string): string {
97
99
  }
98
100
  }
99
101
 
102
+ function WorkbenchTabLabel({ icon, children }: { icon: ReactNode; children: ReactNode }) {
103
+ return (
104
+ <span className="inline-flex items-center gap-1.5">
105
+ <span className="[&>svg]:size-3.5" aria-hidden>
106
+ {icon}
107
+ </span>
108
+ <span>{children}</span>
109
+ </span>
110
+ );
111
+ }
112
+
100
113
  /**
101
114
  * Decide a workspace tab from an already-local event log: the newest
102
115
  * `workspace.revision.captured` announce carries the change surface stats, so
@@ -181,6 +194,8 @@ export type UseSandboxWorkspaceTabsOptions = ClientOverride & {
181
194
  /** Override the source-driven default tab (e.g. a host landing tab id). When
182
195
  * omitted the workbench picks Changes-vs-Files from capture or live Git. */
183
196
  initialTab?: string | null | undefined;
197
+ /** Actually selected dock tab. Inactive data surfaces retain cache but issue no live reads. */
198
+ activeTab?: string | null | undefined;
184
199
  /** Host-routed notifications (mutation errors, desktop-consent failures). The
185
200
  * package never imports a toast library — the host decides how to surface. */
186
201
  onNotify?: ((notification: WorkspaceNotification) => void) | undefined;
@@ -234,6 +249,7 @@ export function useSandboxWorkspaceTabs(
234
249
  const { sessionId, events, onNotify, requestedFilePath, requestedFileRequestId, onOpenFile } =
235
250
  options;
236
251
  const initialTab = options.initialTab ?? null;
252
+ const activeTab = options.activeTab ?? initialTab;
237
253
  const requestedSurfaces = options.surfaces ?? WORKBENCH_SURFACES;
238
254
  const surfaceSet = new Set<SandboxWorkspaceSurface>(requestedSurfaces);
239
255
  const changesEnabled = surfaceSet.has(WORKBENCH_TAB_CHANGES);
@@ -242,6 +258,13 @@ export function useSandboxWorkspaceTabs(
242
258
  const desktopEnabled = surfaceSet.has(WORKBENCH_TAB_DESKTOP);
243
259
  const workspaceDataEnabled = changesEnabled || filesEnabled;
244
260
  const anySurfaceEnabled = workspaceDataEnabled || terminalEnabled || desktopEnabled;
261
+ // Do not speculatively activate both remote workspace surfaces while the dock
262
+ // is still resolving its selected tab. Modal calls cannot be cancelled after
263
+ // dispatch, so that transient state otherwise creates an aborted duplicate
264
+ // batch immediately before the real selected-tab request.
265
+ const resolvedActiveTab = activeTab ?? initialTab;
266
+ const changesActive = resolvedActiveTab === WORKBENCH_TAB_CHANGES;
267
+ const filesActive = resolvedActiveTab === WORKBENCH_TAB_FILES;
245
268
  const surfaceIdentity = WORKBENCH_SURFACES.filter((surface) => surfaceSet.has(surface)).join(",");
246
269
 
247
270
  // The three box-warming INTENTS, each off by default and each
@@ -382,6 +405,8 @@ export function useSandboxWorkspaceTabs(
382
405
  // No passive Channel-A reads while cold, even after a conclusive capture
383
406
  // miss. A missing capture gets an explicit "Open live workspace" gate.
384
407
  enabled: filesEnabled && (captureAvailable || (fileSystemOn && liveWorkspaceExpected)),
408
+ active: filesActive,
409
+ repoPaths,
385
410
  liveness,
386
411
  capture: captureState.capture,
387
412
  // A reverted optimistic mutation (e.g. a 409 rename collision) surfaces as a
@@ -392,24 +417,58 @@ export function useSandboxWorkspaceTabs(
392
417
  message: `Could not ${op}: ${error.message}`,
393
418
  }),
394
419
  });
420
+ const [storedChangesComparison, setStoredChangesComparison] = useState<{
421
+ sessionId: string;
422
+ value: SandboxGitComparison;
423
+ } | null>(null);
424
+ const captureSupportsBranch =
425
+ captureState.capture !== null &&
426
+ captureState.capture.repos.length > 0 &&
427
+ captureState.capture.repos.every((repo) => repo.branchDiff !== undefined);
428
+ // A sleeping workspace can truthfully serve only the captured working-tree
429
+ // comparison. A live workspace defaults to the branch comparison so committed
430
+ // agent work remains visible. Explicit user selection is scoped per session.
431
+ const changesComparison =
432
+ storedChangesComparison?.sessionId === sessionId
433
+ ? storedChangesComparison.value
434
+ : liveWorkspaceExpected || captureSupportsBranch
435
+ ? "branch"
436
+ : "working";
437
+ const setChangesComparison = useCallback(
438
+ (value: SandboxGitComparison) => setStoredChangesComparison({ sessionId, value }),
439
+ [sessionId],
440
+ );
395
441
  const git = useSandboxGit(sessionId, {
396
442
  events,
397
- enabled: workspaceDataEnabled && (captureAvailable || (gitOn && liveWorkspaceExpected)),
398
- repoPaths,
399
- liveness,
400
- capture: captureState.capture,
401
- });
402
- const stagedGit = useSandboxGit(sessionId, {
403
- events,
404
- // Captures intentionally expose one combined review diff, not a staged
405
- // index. Never probe the live index while the lease is cold.
406
- enabled: filesEnabled && gitOn && liveWorkspaceExpected,
407
- staged: true,
443
+ enabled:
444
+ changesComparison === "working"
445
+ ? workspaceDataEnabled && (captureAvailable || (gitOn && liveWorkspaceExpected))
446
+ : changesComparison === "branch"
447
+ ? workspaceDataEnabled && (captureSupportsBranch || (gitOn && liveWorkspaceExpected))
448
+ : workspaceDataEnabled && gitOn && liveWorkspaceExpected,
449
+ // Before the dock chooses its source-driven default, Git is the sole probe
450
+ // that may need to resolve that choice on a live workspace. Files remains
451
+ // dormant, avoiding the old duplicate full-tree read.
452
+ active: changesActive || resolvedActiveTab === null,
408
453
  repoPaths,
409
454
  liveness,
410
- capture: captureState.capture,
455
+ comparison: changesComparison,
456
+ capture: changesComparison === "staged" ? null : captureState.capture,
411
457
  });
412
-
458
+ useEffect(() => {
459
+ if (
460
+ changesComparison === "branch" &&
461
+ git.error &&
462
+ storedChangesComparison?.sessionId !== sessionId
463
+ ) {
464
+ // `origin/HEAD` is not guaranteed on a newly initialized repository, a
465
+ // connected machine, or a clone whose remote was renamed. The implicit
466
+ // live default must not strand the whole Changes surface when its ordinary
467
+ // working-tree comparison is still valid. Fall back once; an explicit
468
+ // user-selected Branch view remains selected and surfaces its real error.
469
+ setStoredChangesComparison({ sessionId, value: "working" });
470
+ }
471
+ }, [changesComparison, git.error, sessionId, storedChangesComparison?.sessionId]);
413
472
  // Token-derived xterm theme; re-derive on a `data-og-theme` flip. Generic — it
414
473
  // belongs in the package (an embedder's theme toggle drives it too).
415
474
  const [xtermTheme, setXtermTheme] = useState<XtermTheme | undefined>(undefined);
@@ -485,6 +544,10 @@ export function useSandboxWorkspaceTabs(
485
544
  const captureIsAuthoritative =
486
545
  !liveWorkspaceExpected && (liveness !== undefined || caps.error !== null);
487
546
  const captureUnavailable = captureState.fileCount === 0 || captureState.error !== null;
547
+ const implicitBranchFallbackPending =
548
+ changesComparison === "branch" &&
549
+ git.error !== null &&
550
+ storedChangesComparison?.sessionId !== sessionId;
488
551
  if (defaultTabRef.current.value === null) {
489
552
  if (initialTab && (!isWorkbenchSurface(initialTab) || surfaceSet.has(initialTab))) {
490
553
  defaultTabRef.current.value = initialTab;
@@ -527,9 +590,9 @@ export function useSandboxWorkspaceTabs(
527
590
  changesEnabled,
528
591
  filesEnabled,
529
592
  );
530
- } else if (captureUnavailable && git.error) {
593
+ } else if (captureUnavailable && git.error && !implicitBranchFallbackPending) {
531
594
  // No capture and live Git failed: Files is the least surprising fallback
532
- // and preserves the established no-capture degraded behavior.
595
+ // after the implicit branch-to-working fallback has also had its chance.
533
596
  defaultTabRef.current.value = sourceDrivenDefaultTab(false, changesEnabled, filesEnabled);
534
597
  }
535
598
  }
@@ -543,11 +606,13 @@ export function useSandboxWorkspaceTabs(
543
606
  if (changesEnabled)
544
607
  list.push({
545
608
  id: WORKBENCH_TAB_CHANGES,
546
- label: "Changes",
609
+ label: <WorkbenchTabLabel icon={<GitCompareArrowsIcon />}>Changes</WorkbenchTabLabel>,
547
610
  badge: dirtyCount > 0 ? <DirtyBadge count={dirtyCount} /> : undefined,
548
611
  content: (
549
612
  <ChangesTabBody
550
613
  git={git}
614
+ comparison={changesComparison}
615
+ onComparisonChange={setChangesComparison}
551
616
  captureAvailable={captureAvailable}
552
617
  captureRevision={captureState.revision}
553
618
  capturePending={capturePending}
@@ -573,13 +638,12 @@ export function useSandboxWorkspaceTabs(
573
638
  if (filesEnabled)
574
639
  list.push({
575
640
  id: WORKBENCH_TAB_FILES,
576
- label: "Files",
641
+ label: <WorkbenchTabLabel icon={<FileCode2Icon />}>Files</WorkbenchTabLabel>,
577
642
  content: (
578
643
  <SandboxFiles
579
644
  key={sessionId}
580
645
  files={files}
581
646
  git={git}
582
- stagedGit={stagedGit}
583
647
  fileSystemAvailable={fileSystemOn || captureAvailable}
584
648
  editable={
585
649
  filesEditable && files.source === "live" && files.error === null && !files.loading
@@ -615,7 +679,7 @@ export function useSandboxWorkspaceTabs(
615
679
  if (terminalOn) {
616
680
  list.push({
617
681
  id: WORKBENCH_TAB_TERMINAL,
618
- label: "Terminal",
682
+ label: <WorkbenchTabLabel icon={<SquareTerminalIcon />}>Terminal</WorkbenchTabLabel>,
619
683
  content: (
620
684
  <div className="h-full bg-og-bg p-1">
621
685
  <SandboxTerminal
@@ -636,7 +700,7 @@ export function useSandboxWorkspaceTabs(
636
700
  if (desktopAdvertised) {
637
701
  list.push({
638
702
  id: WORKBENCH_TAB_DESKTOP,
639
- label: "Desktop",
703
+ label: <WorkbenchTabLabel icon={<MonitorIcon />}>Desktop</WorkbenchTabLabel>,
640
704
  badge: watchDesktop ? (
641
705
  <span className="rounded-og-xs bg-og-status-running/20 px-1 text-og-xs text-og-status-running">
642
706
  Live
@@ -674,7 +738,7 @@ export function useSandboxWorkspaceTabs(
674
738
  sessionId,
675
739
  files,
676
740
  git,
677
- stagedGit,
741
+ changesComparison,
678
742
  terminal,
679
743
  xtermTheme,
680
744
  capabilities,
@@ -722,6 +786,8 @@ export type SandboxWorkspaceProps = ClientOverride & {
722
786
  /** Controlled collapsed state for hosts with their own dock toggle. */
723
787
  collapsed?: boolean | undefined;
724
788
  onCollapsedChange?: ((collapsed: boolean) => void) | undefined;
789
+ /** Keep the dock's built-in collapse control with controlled collapsed state. */
790
+ showCollapseControl?: boolean | undefined;
725
791
  /** Host navigation shown only in the phone workspace overlay header. */
726
792
  mobileLeadingControl?: ReactNode | undefined;
727
793
  autoSaveId?: string | undefined;
@@ -735,7 +801,7 @@ export type SandboxWorkspaceProps = ClientOverride & {
735
801
  * The whole session workspace, ready to mount: `<SandboxWorkspace>` assembles the
736
802
  * capability-gated tabs, pins the machine-state chip in the dock header, and
737
803
  * renders the resizable `<WorkspaceDock>`. Wrap the tree in `<OpenGeniProvider>`
738
- * (client + workspaceId) and import `@opengeni/react/styles.css`; that is the
804
+ * (client + workspaceId) and import `@opengeni/react/compiled.css`; that is the
739
805
  * entire integration (see `docs/embedding-workbench.md`).
740
806
  */
741
807
  export function SandboxWorkspace(props: SandboxWorkspaceProps): ReactNode {
@@ -750,6 +816,7 @@ export function SandboxWorkspace(props: SandboxWorkspaceProps): ReactNode {
750
816
  onNotify,
751
817
  collapsed,
752
818
  onCollapsedChange,
819
+ showCollapseControl,
753
820
  mobileLeadingControl,
754
821
  autoSaveId,
755
822
  defaultSize,
@@ -768,6 +835,8 @@ export function SandboxWorkspace(props: SandboxWorkspaceProps): ReactNode {
768
835
  requestId: number;
769
836
  } | null>(null);
770
837
  const nextFileRequestId = useRef(0);
838
+ const selectedTab = storedSelection?.sessionId === sessionId ? storedSelection.tab : null;
839
+ const activeTabHint = selectedTab ?? initialTab ?? leadingTabs?.[0]?.id ?? null;
771
840
  const openFile = useCallback(
772
841
  (path: string) => {
773
842
  nextFileRequestId.current += 1;
@@ -788,6 +857,7 @@ export function SandboxWorkspace(props: SandboxWorkspaceProps): ReactNode {
788
857
  events,
789
858
  ...(surfaces ? { surfaces } : {}),
790
859
  ...(initialTab ? { initialTab } : {}),
860
+ activeTab: activeTabHint,
791
861
  ...(onNotify ? { onNotify } : {}),
792
862
  requestedFilePath: requestedFile?.sessionId === sessionId ? requestedFile.path : null,
793
863
  requestedFileRequestId: requestedFile?.sessionId === sessionId ? requestedFile.requestId : null,
@@ -800,7 +870,6 @@ export function SandboxWorkspace(props: SandboxWorkspaceProps): ReactNode {
800
870
  // (Changes) — whose body is a connecting/loading state until the capture lands,
801
871
  // so committing the real default at first-resolve produces no CONTENT switch.
802
872
  const tabs: WorkspaceTab[] = [...(leadingTabs ?? []), ...workbenchTabs, ...(trailingTabs ?? [])];
803
- const selectedTab = storedSelection?.sessionId === sessionId ? storedSelection.tab : null;
804
873
  const preferredTab = selectedTab ?? defaultTab;
805
874
  const activeTab =
806
875
  preferredTab && tabs.some((tab) => tab.id === preferredTab) ? preferredTab : tabs[0]?.id;
@@ -817,19 +886,13 @@ export function SandboxWorkspace(props: SandboxWorkspaceProps): ReactNode {
817
886
  onActiveTabChange={selectTab}
818
887
  {...(machine.enabled
819
888
  ? {
820
- headerAccessory: (
821
- <MachineStateChip
822
- chip={machine.chip}
823
- activeMachine={machine.activeMachine}
824
- error={machine.error}
825
- onRetry={() => void machine.refresh()}
826
- />
827
- ),
889
+ headerAccessory: <MachineStateChip chip={machine.chip} />,
828
890
  }
829
891
  : {})}
830
892
  {...(mobileLeadingControl !== undefined ? { mobileLeadingControl } : {})}
831
893
  {...(collapsed !== undefined ? { collapsed } : {})}
832
894
  {...(onCollapsedChange ? { onCollapsedChange } : {})}
895
+ {...(showCollapseControl !== undefined ? { showCollapseControl } : {})}
833
896
  {...(autoSaveId !== undefined ? { autoSaveId } : {})}
834
897
  {...(defaultSize !== undefined ? { defaultSize } : {})}
835
898
  {...(minSize !== undefined ? { minSize } : {})}
@@ -850,18 +913,6 @@ function DirtyBadge({ count }: { count: number }) {
850
913
  );
851
914
  }
852
915
 
853
- /** A machine-kind glyph for the chip popover. */
854
- function MachineKindIcon({
855
- kind,
856
- className,
857
- }: {
858
- kind: MachineView["kind"] | undefined;
859
- className?: string;
860
- }) {
861
- const Icon = kind === "selfhosted" ? LaptopIcon : CpuIcon;
862
- return <Icon className={cn("size-3.5 shrink-0 text-og-fg-subtle", className)} aria-hidden />;
863
- }
864
-
865
916
  function chipDotClass(state: MachineChip["state"]): string {
866
917
  if (state === "live") return "bg-og-status-running";
867
918
  if (state === "waking") return "bg-og-status-idle animate-pulse motion-reduce:animate-none";
@@ -869,93 +920,23 @@ function chipDotClass(state: MachineChip["state"]): string {
869
920
  }
870
921
 
871
922
  /**
872
- * The dock-header machine chip: the one truthful live/waking/offline indicator,
873
- * with a popover carrying the machine identity, connection state, the "shown as
874
- * of <time>" staleness note, the shared-session disclosure, and a retry when the
875
- * fleet failed to resolve (the old per-surface machine bar + Sandbox info tab,
876
- * folded into one header affordance — recommendation).
923
+ * The dock-header machine status: one quiet, truthful live/waking/offline
924
+ * indicator. It is intentionally not interactive; detailed machine controls do
925
+ * not belong in a transient popover above the workspace tabs.
877
926
  */
878
- function MachineStateChip({
879
- chip,
880
- activeMachine,
881
- error,
882
- onRetry,
883
- }: {
884
- chip: MachineChip;
885
- activeMachine: MachineView | null;
886
- error: Error | null;
887
- onRetry: () => void;
888
- }) {
889
- const triggerRef = useRef<HTMLButtonElement | null>(null);
890
- const portalStyle = usePortalTokenStyle(triggerRef);
927
+ function MachineStateChip({ chip }: { chip: MachineChip }) {
891
928
  return (
892
- <Popover.Root>
893
- <Popover.Trigger asChild>
894
- <button
895
- ref={triggerRef}
896
- type="button"
897
- aria-label={`Machine: ${chip.label}`}
898
- 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"
899
- >
900
- <span
901
- className={cn("size-1.5 shrink-0 rounded-full", chipDotClass(chip.state))}
902
- aria-hidden
903
- />
904
- <span className="max-w-[11rem] truncate">{chip.label}</span>
905
- </button>
906
- </Popover.Trigger>
907
- <Popover.Portal forceMount>
908
- <Popover.Content
909
- forceMount
910
- data-machine-state-popover
911
- align="end"
912
- sideOffset={6}
913
- collisionPadding={8}
914
- 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"
915
- style={portalStyle}
916
- >
917
- <div className="flex min-w-0 items-center gap-1.5">
918
- <MachineKindIcon kind={activeMachine?.kind} />
919
- <span className="min-w-0 truncate font-medium">{activeMachine?.name ?? "Sandbox"}</span>
920
- </div>
921
- <div className="mt-2.5 flex items-center justify-between gap-2">
922
- <span className="text-og-xs text-og-fg-subtle">Connection</span>
923
- {activeMachine ? (
924
- <ConnectionStatusPill
925
- status={connectionStatusForState(activeMachine.state)}
926
- size="sm"
927
- />
928
- ) : (
929
- <span className="text-og-xs text-og-fg-muted">{chip.label}</span>
930
- )}
931
- </div>
932
- {chip.asOf ? (
933
- <p className="mt-2.5 text-og-xs leading-4 text-og-fg-subtle">
934
- Workspace shown as of {formatAsOf(chip.asOf, Date.now())} — the machine is not live.
935
- </p>
936
- ) : null}
937
- {activeMachine && activeMachine.sharedSessionCount > 1 ? (
938
- <div className="mt-2.5">
939
- <SharedMachineDisclosure
940
- sharedSessionCount={activeMachine.sharedSessionCount}
941
- density="full"
942
- />
943
- </div>
944
- ) : null}
945
- {error ? (
946
- <div className="mt-2.5 space-y-1.5 border-t border-og-border pt-2.5">
947
- <p className="text-og-xs leading-4 text-og-danger">
948
- Couldn't reach the sandbox for this session.
949
- </p>
950
- <DockActionButton onClick={onRetry}>
951
- <RefreshCwIcon className="size-3" />
952
- Retry
953
- </DockActionButton>
954
- </div>
955
- ) : null}
956
- </Popover.Content>
957
- </Popover.Portal>
958
- </Popover.Root>
929
+ <div
930
+ role="status"
931
+ aria-label={`Machine: ${chip.label}`}
932
+ className="inline-flex min-h-7 items-center gap-1.5 px-2 py-1 text-og-xs font-medium text-og-fg-muted max-[1023px]:min-h-11 pointer-coarse:min-h-11"
933
+ >
934
+ <span
935
+ className={cn("size-1.5 shrink-0 rounded-full", chipDotClass(chip.state))}
936
+ aria-hidden
937
+ />
938
+ <span className="max-w-[11rem] truncate">{chip.label}</span>
939
+ </div>
959
940
  );
960
941
  }
961
942
 
@@ -965,7 +946,7 @@ function DockActionButton({ onClick, children }: { onClick: () => void; children
965
946
  <button
966
947
  type="button"
967
948
  onClick={onClick}
968
- 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"
949
+ 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-hidden focus-visible:ring-2 focus-visible:ring-og-accent max-[1023px]:min-h-11 pointer-coarse:min-h-11"
969
950
  >
970
951
  {children}
971
952
  </button>
@@ -979,7 +960,49 @@ function DockActionButton({ onClick, children }: { onClick: () => void; children
979
960
  * The dock frame is untouched; this is the M5 seam.
980
961
  */
981
962
  function ChangesTabBody({
963
+ comparison,
964
+ onComparisonChange,
965
+ ...props
966
+ }: {
967
+ comparison: SandboxGitComparison;
968
+ onComparisonChange: (comparison: SandboxGitComparison) => void;
969
+ } & Parameters<typeof ChangesTabContent>[0]) {
970
+ return (
971
+ <div className="flex h-full min-h-0 flex-col">
972
+ <div className="flex shrink-0 items-center gap-1 border-b border-og-border bg-og-surface-1 px-2 py-1.5">
973
+ {(
974
+ [
975
+ ["branch", "Branch"],
976
+ ["working", "Uncommitted"],
977
+ ["staged", "Staged"],
978
+ ] as const
979
+ ).map(([value, label]) => (
980
+ <button
981
+ key={value}
982
+ type="button"
983
+ aria-pressed={comparison === value}
984
+ onClick={() => onComparisonChange(value)}
985
+ className={cn(
986
+ "rounded-og-sm px-2 py-1 text-og-xs font-medium transition-colors focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-og-accent max-[1023px]:min-h-11 pointer-coarse:min-h-11",
987
+ comparison === value
988
+ ? "bg-og-surface-2 text-og-fg shadow-sm"
989
+ : "text-og-fg-muted hover:bg-og-surface-2 hover:text-og-fg",
990
+ )}
991
+ >
992
+ {label}
993
+ </button>
994
+ ))}
995
+ </div>
996
+ <div className="min-h-0 flex-1">
997
+ <ChangesTabContent comparison={comparison} {...props} />
998
+ </div>
999
+ </div>
1000
+ );
1001
+ }
1002
+
1003
+ function ChangesTabContent({
982
1004
  git,
1005
+ comparison,
983
1006
  captureAvailable,
984
1007
  captureRevision,
985
1008
  capturePending,
@@ -992,6 +1015,7 @@ function ChangesTabBody({
992
1015
  onOpenFile,
993
1016
  }: {
994
1017
  git: UseSandboxGitResult;
1018
+ comparison: SandboxGitComparison;
995
1019
  captureAvailable: boolean;
996
1020
  captureRevision: number | null;
997
1021
  capturePending: boolean;
@@ -1025,7 +1049,7 @@ function ChangesTabBody({
1025
1049
  type="button"
1026
1050
  onClick={() => void git.refresh()}
1027
1051
  disabled={git.loading}
1028
- 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"
1052
+ 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-hidden focus-visible:ring-2 focus-visible:ring-og-accent disabled:cursor-not-allowed disabled:opacity-50 pointer-coarse:min-h-11"
1029
1053
  >
1030
1054
  <RefreshCwIcon
1031
1055
  className={cn("size-3", git.loading && "animate-spin motion-reduce:animate-none")}
@@ -1085,7 +1109,7 @@ function ChangesTabBody({
1085
1109
  );
1086
1110
  }
1087
1111
 
1088
- if (!liveWorkspaceExpected && !captureAvailable) {
1112
+ if (!liveWorkspaceExpected && (comparison !== "working" || !captureAvailable)) {
1089
1113
  return (
1090
1114
  <CenteredState
1091
1115
  icon={
@@ -1105,7 +1129,11 @@ function ChangesTabBody({
1105
1129
  <p className="text-og-sm leading-5 text-og-fg-subtle">
1106
1130
  {workspaceWaking
1107
1131
  ? "Connecting to the live working tree…"
1108
- : "No captured revision is available yet. Wake the sandbox to inspect current changes."}
1132
+ : comparison === "branch"
1133
+ ? "Wake the sandbox to compare this branch with the remote default branch."
1134
+ : comparison === "staged"
1135
+ ? "Wake the sandbox to inspect staged changes."
1136
+ : "No captured revision is available yet. Wake the sandbox to inspect uncommitted changes."}
1109
1137
  </p>
1110
1138
  {!workspaceWaking ? (
1111
1139
  <DockActionButton onClick={onWake}>
@@ -1135,9 +1163,19 @@ function ChangesTabBody({
1135
1163
 
1136
1164
  return (
1137
1165
  <CenteredState icon={<CircleCheckIcon className="size-5" aria-hidden />} tone="success">
1138
- <p className="text-og-sm font-medium text-og-fg">Working tree is clean</p>
1166
+ <p className="text-og-sm font-medium text-og-fg">
1167
+ {comparison === "branch"
1168
+ ? "No branch changes"
1169
+ : comparison === "staged"
1170
+ ? "No staged changes"
1171
+ : "No uncommitted changes"}
1172
+ </p>
1139
1173
  <p className="text-og-sm leading-5 text-og-fg-subtle">
1140
- File edits from future turns will appear here.
1174
+ {comparison === "branch"
1175
+ ? "This branch matches the remote default branch."
1176
+ : comparison === "staged"
1177
+ ? "Stage files to review the next commit here."
1178
+ : "Local edits not yet committed will appear here."}
1141
1179
  </p>
1142
1180
  </CenteredState>
1143
1181
  );
@@ -560,18 +560,18 @@ export function SessionChrome({
560
560
  "transition-[background-color,border-color,box-shadow] motion-reduce:transition-none",
561
561
  )}
562
562
  style={{
563
- borderRadius: "var(--og-session-chrome-radius)",
563
+ borderRadius: "var(--_og-session-chrome-radius)",
564
564
  background: open
565
- ? "var(--og-session-chrome-surface-open)"
566
- : "var(--og-session-chrome-surface)",
565
+ ? "var(--_og-session-chrome-surface-open)"
566
+ : "var(--_og-session-chrome-surface)",
567
567
  borderColor: open
568
- ? "var(--og-session-chrome-border-open)"
569
- : "var(--og-session-chrome-border)",
568
+ ? "var(--_og-session-chrome-border-open)"
569
+ : "var(--_og-session-chrome-border)",
570
570
  boxShadow: open
571
571
  ? "var(--og-session-chrome-shadow-open)"
572
572
  : "var(--og-session-chrome-shadow)",
573
573
  transitionDuration: "var(--og-session-chrome-duration)",
574
- transitionTimingFunction: "var(--og-session-chrome-ease)",
574
+ transitionTimingFunction: "var(--_og-session-chrome-ease)",
575
575
  }}
576
576
  >
577
577
  <div
@@ -594,8 +594,8 @@ export function SessionChrome({
594
594
  aria-hidden
595
595
  className="pointer-events-none absolute left-0 top-0 rounded-og-md"
596
596
  style={{
597
- background: "var(--og-session-chrome-highlight)",
598
- boxShadow: "inset 0 0 0 1px var(--og-session-chrome-highlight-ring)",
597
+ background: "var(--_og-session-chrome-highlight)",
598
+ boxShadow: "inset 0 0 0 1px var(--_og-session-chrome-highlight-ring)",
599
599
  }}
600
600
  initial={false}
601
601
  animate={{
@@ -623,7 +623,7 @@ export function SessionChrome({
623
623
  data-og-session-chrome-signal={signal.id}
624
624
  onClick={() => setActive(selected ? null : signal.id)}
625
625
  className={cn(
626
- "group relative z-[1] inline-flex min-h-[var(--og-session-chrome-chip-min-height)] max-w-full items-center gap-1 rounded-og-md py-1 text-left text-og-xs outline-none",
626
+ "group relative z-[1] inline-flex min-h-[var(--og-session-chrome-chip-min-height)] max-w-full items-center gap-1 rounded-og-md py-1 text-left text-og-xs outline-hidden",
627
627
  // Coarse pointers keep a 44px target (session-pins acceptance).
628
628
  "pointer-coarse:min-h-11",
629
629
  "transition-colors duration-150 motion-reduce:transition-none",
@@ -731,7 +731,7 @@ function IncomingPanel({
731
731
  {inputs.map((input) => (
732
732
  <li
733
733
  key={input.id}
734
- className="group flex items-start gap-1.5 rounded-og-sm px-1.5 py-1 transition-colors hover:bg-[var(--og-session-chrome-row-hover)]"
734
+ className="group flex items-start gap-1.5 rounded-og-sm px-1.5 py-1 transition-colors hover:bg-[var(--_og-session-chrome-row-hover)]"
735
735
  >
736
736
  <span
737
737
  className={cn(
@@ -830,7 +830,7 @@ function QueuePanel({
830
830
  <li
831
831
  key={turn.id}
832
832
  data-queue-turn-id={turn.id}
833
- className="group flex flex-col gap-1 rounded-og-sm px-1.5 py-1 transition-colors hover:bg-[var(--og-session-chrome-row-hover)]"
833
+ className="group flex flex-col gap-1 rounded-og-sm px-1.5 py-1 transition-colors hover:bg-[var(--_og-session-chrome-row-hover)]"
834
834
  >
835
835
  <div className="flex items-start gap-1.5">
836
836
  {voice ? (
@@ -1003,7 +1003,7 @@ function GoalPanel({
1003
1003
  ? goal.resume()
1004
1004
  : goal.pause("Paused from session chrome"))
1005
1005
  }
1006
- className="inline-flex h-6 items-center gap-1 rounded-og-sm px-1.5 text-og-xs font-medium text-og-fg-muted outline-none transition-colors hover:bg-og-surface-3/70 hover:text-og-fg focus-visible:ring-2 focus-visible:ring-og-accent/40 disabled:opacity-50"
1006
+ className="inline-flex h-6 items-center gap-1 rounded-og-sm px-1.5 text-og-xs font-medium text-og-fg-muted outline-hidden transition-colors hover:bg-og-surface-3/70 hover:text-og-fg focus-visible:ring-2 focus-visible:ring-og-accent/40 disabled:opacity-50"
1007
1007
  >
1008
1008
  {goal.updating ? (
1009
1009
  <Loader2Icon className="size-3 animate-og-spin" />
@@ -1019,7 +1019,7 @@ function GoalPanel({
1019
1019
  type="button"
1020
1020
  disabled={goal.updating}
1021
1021
  onClick={() => void goal.deleteGoal()}
1022
- className="inline-flex h-6 items-center gap-1 rounded-og-sm px-1.5 text-og-xs font-medium text-og-fg-subtle outline-none transition-colors hover:bg-og-surface-3/70 hover:text-og-danger focus-visible:ring-2 focus-visible:ring-og-accent/40 disabled:opacity-50"
1022
+ className="inline-flex h-6 items-center gap-1 rounded-og-sm px-1.5 text-og-xs font-medium text-og-fg-subtle outline-hidden transition-colors hover:bg-og-surface-3/70 hover:text-og-danger focus-visible:ring-2 focus-visible:ring-og-accent/40 disabled:opacity-50"
1023
1023
  >
1024
1024
  <Trash2Icon className="size-3" />
1025
1025
  Clear
@@ -1064,7 +1064,7 @@ function IconAction({
1064
1064
  disabled={disabled}
1065
1065
  onClick={onClick}
1066
1066
  className={cn(
1067
- "inline-flex size-6 items-center justify-center rounded-og-sm text-og-fg-subtle outline-none transition-colors",
1067
+ "inline-flex size-6 items-center justify-center rounded-og-sm text-og-fg-subtle outline-hidden transition-colors",
1068
1068
  "hover:bg-og-surface-2 hover:text-og-fg focus-visible:ring-2 focus-visible:ring-og-accent/40",
1069
1069
  "disabled:pointer-events-none disabled:opacity-40 pointer-coarse:size-9",
1070
1070
  danger && "hover:text-og-danger",