@opengeni/react 0.46.1 → 0.52.1

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 (341) hide show
  1. package/README.md +158 -14
  2. package/dist/artifacts-document.d.ts +3 -0
  3. package/dist/artifacts-document.js +17 -0
  4. package/dist/artifacts-document.js.map +1 -0
  5. package/dist/artifacts-presentation.d.ts +3 -0
  6. package/dist/artifacts-presentation.js +17 -0
  7. package/dist/artifacts-presentation.js.map +1 -0
  8. package/dist/artifacts-spreadsheet.d.ts +3 -0
  9. package/dist/artifacts-spreadsheet.js +17 -0
  10. package/dist/artifacts-spreadsheet.js.map +1 -0
  11. package/dist/artifacts.d.ts +2 -0
  12. package/dist/artifacts.js +170 -0
  13. package/dist/artifacts.js.map +1 -0
  14. package/dist/chunk-6BBWWOOU.js +82 -0
  15. package/dist/chunk-6BBWWOOU.js.map +1 -0
  16. package/dist/{chunk-HFO4ERGQ.js → chunk-724BW4TW.js} +586 -35
  17. package/dist/chunk-724BW4TW.js.map +1 -0
  18. package/dist/chunk-7I2KB3OK.js +3895 -0
  19. package/dist/chunk-7I2KB3OK.js.map +1 -0
  20. package/dist/chunk-7WCLAEFV.js +2828 -0
  21. package/dist/chunk-7WCLAEFV.js.map +1 -0
  22. package/dist/{chunk-U3CSCMWA.js → chunk-FK6VG4LL.js} +9 -13
  23. package/dist/chunk-FK6VG4LL.js.map +1 -0
  24. package/dist/{chunk-SRFUT2ZU.js → chunk-FWS3KC3Z.js} +120 -64
  25. package/dist/chunk-FWS3KC3Z.js.map +1 -0
  26. package/dist/{chunk-EKZH6IDG.js → chunk-HDBG7XGO.js} +62 -1
  27. package/dist/chunk-HDBG7XGO.js.map +1 -0
  28. package/dist/chunk-HUIKIDM5.js +2148 -0
  29. package/dist/chunk-HUIKIDM5.js.map +1 -0
  30. package/dist/{chunk-LGVMUIRV.js → chunk-JAGDUCZQ.js} +1053 -390
  31. package/dist/chunk-JAGDUCZQ.js.map +1 -0
  32. package/dist/{chunk-LWR4MXSS.js → chunk-JR3QFY6B.js} +420 -35
  33. package/dist/chunk-JR3QFY6B.js.map +1 -0
  34. package/dist/chunk-JVYQRRC4.js +53 -0
  35. package/dist/chunk-JVYQRRC4.js.map +1 -0
  36. package/dist/chunk-KCGXPP5V.js +157 -0
  37. package/dist/chunk-KCGXPP5V.js.map +1 -0
  38. package/dist/{chunk-EB67J347.js → chunk-KZ73RL76.js} +111 -31
  39. package/dist/chunk-KZ73RL76.js.map +1 -0
  40. package/dist/chunk-PEI6RANI.js +3234 -0
  41. package/dist/chunk-PEI6RANI.js.map +1 -0
  42. package/dist/{chunk-3PB7MIT6.js → chunk-ROIRTM37.js} +220 -72
  43. package/dist/chunk-ROIRTM37.js.map +1 -0
  44. package/dist/chunk-VZTQ73XT.js +86 -0
  45. package/dist/chunk-VZTQ73XT.js.map +1 -0
  46. package/dist/{chunk-VCBFUSUZ.js → chunk-WSK7G5LE.js} +72 -4
  47. package/dist/chunk-WSK7G5LE.js.map +1 -0
  48. package/dist/{chunk-FGZUCXZF.js → chunk-WYA65Y3F.js} +16 -16
  49. package/dist/chunk-WYA65Y3F.js.map +1 -0
  50. package/dist/{chunk-6EDV6YFE.js → chunk-X4S56Y2H.js} +147 -35
  51. package/dist/chunk-X4S56Y2H.js.map +1 -0
  52. package/dist/client.d.ts +7 -1
  53. package/dist/clipboard.d.ts +1 -0
  54. package/dist/clipboard.js +7 -0
  55. package/dist/clipboard.js.map +1 -0
  56. package/dist/commands/index.d.ts +3 -3
  57. package/dist/commands/registry.d.ts +1 -1
  58. package/dist/commands/types.d.ts +1 -1
  59. package/dist/components/approval-surface.d.ts +2 -2
  60. package/dist/components/artifacts/artifact-surface.d.ts +18 -0
  61. package/dist/components/artifacts/document-editor.d.ts +172 -0
  62. package/dist/components/artifacts/editable-artifact-ui.d.ts +13 -0
  63. package/dist/components/artifacts/editable-artifact-workbench.d.ts +49 -0
  64. package/dist/components/artifacts/editable-document.d.ts +24 -0
  65. package/dist/components/artifacts/editable-presentation.d.ts +24 -0
  66. package/dist/components/artifacts/editable-spreadsheet.d.ts +32 -0
  67. package/dist/components/artifacts/index.d.ts +8 -0
  68. package/dist/components/artifacts/presentation-editor.d.ts +297 -0
  69. package/dist/components/artifacts/spreadsheet-canvas.d.ts +120 -0
  70. package/dist/components/artifacts/spreadsheet-grid.d.ts +113 -0
  71. package/dist/components/browser-viewer.d.ts +29 -0
  72. package/dist/components/chat-composer.d.ts +13 -8
  73. package/dist/components/code-editor.d.ts +25 -3
  74. package/dist/components/command-palette.d.ts +2 -2
  75. package/dist/components/composer-transcription-control.d.ts +4 -2
  76. package/dist/components/composer.d.ts +29 -15
  77. package/dist/components/computer-viewer.d.ts +23 -0
  78. package/dist/components/copy-button.d.ts +2 -2
  79. package/dist/components/desktop-viewer.d.ts +25 -21
  80. package/dist/components/diff-view.d.ts +1 -1
  81. package/dist/components/enrollment-consent.d.ts +1 -1
  82. package/dist/components/enrollment-device-flow.d.ts +1 -1
  83. package/dist/components/file-browser.d.ts +2 -2
  84. package/dist/components/fleet-tile.d.ts +1 -1
  85. package/dist/components/generated-video-player.d.ts +10 -0
  86. package/dist/components/human-input-form.d.ts +1 -1
  87. package/dist/components/human-input-surface.d.ts +2 -2
  88. package/dist/components/interactive-workbench-viewers.d.ts +2 -0
  89. package/dist/components/machine-card.d.ts +2 -2
  90. package/dist/components/machine-dock-bar.d.ts +3 -3
  91. package/dist/components/machine-health-pill.d.ts +2 -2
  92. package/dist/components/machine-input-display.d.ts +1 -1
  93. package/dist/components/machine-metrics.d.ts +2 -2
  94. package/dist/components/machine-status-pill.d.ts +4 -4
  95. package/dist/components/machines/health.d.ts +1 -1
  96. package/dist/components/machines/machine-detail.d.ts +5 -3
  97. package/dist/components/machines/metric-history-chart.d.ts +1 -1
  98. package/dist/components/machines/metric-sparkline.d.ts +2 -2
  99. package/dist/components/machines/series.d.ts +2 -2
  100. package/dist/components/machines-dashboard.d.ts +5 -5
  101. package/dist/components/markdown.d.ts +1 -1
  102. package/dist/components/message-timeline.d.ts +22 -5
  103. package/dist/components/model-picker.d.ts +2 -2
  104. package/dist/components/model-policy-picker.d.ts +7 -7
  105. package/dist/components/pierre-diff.d.ts +1 -1
  106. package/dist/components/pierre-file.d.ts +1 -1
  107. package/dist/components/queue-draft-policy.d.ts +1 -1
  108. package/dist/components/queue-surface-implementation.d.ts +3 -3
  109. package/dist/components/queue-surface-state.d.ts +4 -4
  110. package/dist/components/queue-surface.d.ts +5 -5
  111. package/dist/components/sandbox-files.d.ts +5 -5
  112. package/dist/components/sandbox-terminal.d.ts +50 -4
  113. package/dist/components/sandbox-workspace.d.ts +30 -10
  114. package/dist/components/session-chrome.d.ts +4 -4
  115. package/dist/components/session-status.d.ts +2 -2
  116. package/dist/components/timeline-annotation-selection.d.ts +9 -0
  117. package/dist/components/timeline-annotations-dialog.d.ts +16 -0
  118. package/dist/components/timeline-annotations.d.ts +13 -0
  119. package/dist/components/tooltip.d.ts +4 -4
  120. package/dist/components/user-message-body.d.ts +2 -2
  121. package/dist/components/workbench-changes.d.ts +2 -2
  122. package/dist/components/workspace-dock.d.ts +3 -1
  123. package/dist/composer.d.ts +11 -11
  124. package/dist/composer.js +7 -7
  125. package/dist/hooks/internal.d.ts +7 -1
  126. package/dist/hooks/use-attached-browsers.d.ts +21 -0
  127. package/dist/hooks/use-available-models.d.ts +2 -2
  128. package/dist/hooks/use-billing-usage.d.ts +1 -1
  129. package/dist/hooks/use-browser-frame-stream.d.ts +28 -0
  130. package/dist/hooks/use-browser-identities.d.ts +26 -0
  131. package/dist/hooks/use-browser-session.d.ts +28 -0
  132. package/dist/hooks/use-browser-sessions.d.ts +32 -0
  133. package/dist/hooks/use-codex-accounts.d.ts +3 -3
  134. package/dist/hooks/use-composer.d.ts +36 -9
  135. package/dist/hooks/use-computer-frame-stream.d.ts +24 -0
  136. package/dist/hooks/use-computer-session.d.ts +28 -0
  137. package/dist/hooks/use-computer-sessions.d.ts +28 -0
  138. package/dist/hooks/use-desktop-stream.d.ts +1 -1
  139. package/dist/hooks/use-environments.d.ts +11 -5
  140. package/dist/hooks/use-file-attachments.d.ts +1 -1
  141. package/dist/hooks/use-goal.d.ts +2 -2
  142. package/dist/hooks/use-human-input.d.ts +2 -2
  143. package/dist/hooks/use-last-started-turn-policy.d.ts +2 -2
  144. package/dist/hooks/use-machine-chip.d.ts +2 -2
  145. package/dist/hooks/use-machines.d.ts +10 -3
  146. package/dist/hooks/use-packs.d.ts +7 -2
  147. package/dist/hooks/use-rigs.d.ts +1 -1
  148. package/dist/hooks/use-sandbox-files.d.ts +25 -4
  149. package/dist/hooks/use-sandbox-git.d.ts +13 -5
  150. package/dist/hooks/use-sandbox-terminal.d.ts +1 -1
  151. package/dist/hooks/use-scheduled-tasks.d.ts +1 -1
  152. package/dist/hooks/use-session-capabilities.d.ts +5 -4
  153. package/dist/hooks/use-session-control.d.ts +1 -1
  154. package/dist/hooks/use-session-events.d.ts +2 -2
  155. package/dist/hooks/use-session-lineage.d.ts +1 -1
  156. package/dist/hooks/use-session-mcp-approval-policy.d.ts +2 -2
  157. package/dist/hooks/use-session.d.ts +2 -2
  158. package/dist/hooks/use-slash-commands.d.ts +1 -1
  159. package/dist/hooks/use-terminal-stream.d.ts +8 -2
  160. package/dist/hooks/use-transcription.d.ts +2 -2
  161. package/dist/hooks/use-turn-queue.d.ts +2 -2
  162. package/dist/hooks/use-video-artifact-playback.d.ts +9 -0
  163. package/dist/hooks/use-voice-input.d.ts +6 -4
  164. package/dist/hooks/use-workspace-capture.d.ts +1 -1
  165. package/dist/hooks/use-workspace-edit.d.ts +1 -1
  166. package/dist/hooks/use-workspace-sessions.d.ts +1 -1
  167. package/dist/hooks/use-workspaces.d.ts +1 -1
  168. package/dist/index.d.ts +158 -154
  169. package/dist/index.js +1718 -908
  170. package/dist/index.js.map +1 -1
  171. package/dist/interaction.d.ts +23 -0
  172. package/dist/interaction.js +36 -0
  173. package/dist/interaction.js.map +1 -0
  174. package/dist/interactive-workbench-viewers-KHNOHWQE.js +13 -0
  175. package/dist/interactive-workbench-viewers-KHNOHWQE.js.map +1 -0
  176. package/dist/lib/composer-responsive-context.d.ts +9 -0
  177. package/dist/lib/relay-wire.d.ts +4 -0
  178. package/dist/lib/sandbox-liveness.d.ts +9 -0
  179. package/dist/lib/terminal-capability.d.ts +5 -0
  180. package/dist/lib/use-portal-token-style.d.ts +14 -3
  181. package/dist/lib/xterm-theme.d.ts +1 -1
  182. package/dist/machines.d.ts +30 -30
  183. package/dist/machines.js +3 -3
  184. package/dist/provider.d.ts +4 -4
  185. package/dist/{queue-surface-implementation-J4XJFKRW.js → queue-surface-implementation-2KVJTYXH.js} +117 -64
  186. package/dist/queue-surface-implementation-2KVJTYXH.js.map +1 -0
  187. package/dist/realtime/dropdown-menu.d.ts +8 -8
  188. package/dist/realtime/realtime-control.d.ts +15 -6
  189. package/dist/realtime.d.ts +4 -4
  190. package/dist/realtime.js +30 -33
  191. package/dist/realtime.js.map +1 -1
  192. package/dist/session-context.d.ts +19 -1
  193. package/dist/session-ui.d.ts +14 -14
  194. package/dist/session-ui.js +6 -5
  195. package/dist/session.d.ts +28 -28
  196. package/dist/session.js +5 -5
  197. package/dist/timeline/activity-rail.d.ts +6 -3
  198. package/dist/timeline/disclosure-context.d.ts +1 -1
  199. package/dist/timeline/fleet-decision-projection.d.ts +1 -1
  200. package/dist/timeline/fleet-decision-row.d.ts +3 -3
  201. package/dist/timeline/index.d.ts +19 -19
  202. package/dist/timeline/parsers.d.ts +6 -3
  203. package/dist/timeline/projection.d.ts +3 -3
  204. package/dist/timeline/registry.d.ts +10 -1
  205. package/dist/timeline/screenshot-lightbox.d.ts +2 -2
  206. package/dist/timeline/shared.d.ts +21 -10
  207. package/dist/timeline/tool-diff.d.ts +2 -2
  208. package/dist/timeline/tool-renderers.d.ts +1 -1
  209. package/dist/timeline/turn-summary.d.ts +3 -3
  210. package/dist/timeline/types.d.ts +42 -2
  211. package/dist/timeline-annotation-selection-3L7LHGG2.js +187 -0
  212. package/dist/timeline-annotation-selection-3L7LHGG2.js.map +1 -0
  213. package/dist/timeline-annotations-dialog-U7EVLR75.js +196 -0
  214. package/dist/timeline-annotations-dialog-U7EVLR75.js.map +1 -0
  215. package/dist/voice-recording-store.d.ts +8 -2
  216. package/package.json +52 -7
  217. package/src/artifacts-document.ts +31 -0
  218. package/src/artifacts-presentation.ts +36 -0
  219. package/src/artifacts-spreadsheet.ts +21 -0
  220. package/src/artifacts.ts +86 -0
  221. package/src/client.ts +90 -1
  222. package/src/clipboard.ts +2 -0
  223. package/src/components/artifacts/artifact-surface.tsx +93 -0
  224. package/src/components/artifacts/document-editor.tsx +2255 -0
  225. package/src/components/artifacts/editable-artifact-ui.tsx +89 -0
  226. package/src/components/artifacts/editable-artifact-workbench.tsx +203 -0
  227. package/src/components/artifacts/editable-document.tsx +657 -0
  228. package/src/components/artifacts/editable-presentation.tsx +1020 -0
  229. package/src/components/artifacts/editable-spreadsheet.tsx +733 -0
  230. package/src/components/artifacts/index.ts +99 -0
  231. package/src/components/artifacts/presentation-editor.tsx +3148 -0
  232. package/src/components/artifacts/spreadsheet-canvas.ts +799 -0
  233. package/src/components/artifacts/spreadsheet-grid.tsx +2129 -0
  234. package/src/components/browser-viewer.tsx +1592 -0
  235. package/src/components/chat-composer.tsx +25 -2
  236. package/src/components/code-editor.tsx +90 -31
  237. package/src/components/command-palette.tsx +1 -1
  238. package/src/components/composer-transcription-control.tsx +51 -22
  239. package/src/components/composer.tsx +49 -23
  240. package/src/components/computer-viewer.tsx +1139 -0
  241. package/src/components/copy-button.tsx +1 -1
  242. package/src/components/desktop-viewer.tsx +103 -75
  243. package/src/components/file-browser.tsx +39 -17
  244. package/src/components/generated-video-player.tsx +89 -0
  245. package/src/components/human-input-form.tsx +2 -2
  246. package/src/components/interactive-workbench-viewers.ts +2 -0
  247. package/src/components/machine-card.tsx +1 -1
  248. package/src/components/machine-input-display.ts +3 -0
  249. package/src/components/machines/machine-detail.tsx +39 -15
  250. package/src/components/machines-dashboard.tsx +7 -6
  251. package/src/components/message-timeline.tsx +327 -37
  252. package/src/components/model-picker.tsx +2 -2
  253. package/src/components/model-policy-picker.tsx +16 -15
  254. package/src/components/queue-surface-implementation.tsx +135 -75
  255. package/src/components/queue-surface-state.tsx +2 -2
  256. package/src/components/sandbox-files.tsx +142 -62
  257. package/src/components/sandbox-terminal.tsx +119 -35
  258. package/src/components/sandbox-workspace.tsx +394 -228
  259. package/src/components/session-chrome.tsx +67 -27
  260. package/src/components/timeline-annotation-selection.tsx +221 -0
  261. package/src/components/timeline-annotations-dialog.tsx +213 -0
  262. package/src/components/timeline-annotations.tsx +84 -0
  263. package/src/components/tooltip.tsx +13 -16
  264. package/src/components/user-message-body.tsx +1 -1
  265. package/src/components/workbench-changes.tsx +192 -35
  266. package/src/components/workspace-dock.tsx +13 -5
  267. package/src/composer.ts +3 -0
  268. package/src/hooks/internal.ts +132 -38
  269. package/src/hooks/use-attached-browsers.ts +152 -0
  270. package/src/hooks/use-browser-frame-stream.ts +316 -0
  271. package/src/hooks/use-browser-identities.ts +225 -0
  272. package/src/hooks/use-browser-session.ts +476 -0
  273. package/src/hooks/use-browser-sessions.ts +281 -0
  274. package/src/hooks/use-codex-accounts.ts +4 -4
  275. package/src/hooks/use-composer.ts +769 -24
  276. package/src/hooks/use-computer-frame-stream.ts +311 -0
  277. package/src/hooks/use-computer-session.ts +442 -0
  278. package/src/hooks/use-computer-sessions.ts +245 -0
  279. package/src/hooks/use-environments.ts +24 -5
  280. package/src/hooks/use-file-attachments.ts +70 -32
  281. package/src/hooks/use-goal.ts +17 -5
  282. package/src/hooks/use-machine-chip.ts +5 -3
  283. package/src/hooks/use-machines.ts +79 -3
  284. package/src/hooks/use-packs.ts +51 -0
  285. package/src/hooks/use-sandbox-files.ts +382 -88
  286. package/src/hooks/use-sandbox-git.ts +208 -46
  287. package/src/hooks/use-sandbox-terminal.ts +3 -2
  288. package/src/hooks/use-session-capabilities.ts +94 -70
  289. package/src/hooks/use-session-events.ts +18 -4
  290. package/src/hooks/use-terminal-stream.ts +102 -23
  291. package/src/hooks/use-transcription.ts +4 -14
  292. package/src/hooks/use-turn-queue.ts +17 -5
  293. package/src/hooks/use-video-artifact-playback.ts +28 -0
  294. package/src/hooks/use-voice-input.ts +163 -19
  295. package/src/hooks/use-windowed-sections.ts +9 -7
  296. package/src/hooks/use-workspace-edit.ts +2 -1
  297. package/src/index.ts +21 -6
  298. package/src/interaction.ts +71 -0
  299. package/src/lib/composer-responsive-context.ts +15 -0
  300. package/src/lib/relay-wire.ts +4 -0
  301. package/src/lib/sandbox-liveness.ts +11 -0
  302. package/src/lib/terminal-capability.ts +10 -0
  303. package/src/lib/use-portal-token-style.ts +60 -15
  304. package/src/provider.tsx +52 -7
  305. package/src/realtime/dropdown-menu.tsx +15 -19
  306. package/src/realtime/realtime-control.tsx +16 -5
  307. package/src/session-context.ts +90 -0
  308. package/src/timeline/activity-rail.tsx +31 -6
  309. package/src/timeline/index.ts +4 -1
  310. package/src/timeline/parsers.ts +46 -20
  311. package/src/timeline/projection.ts +233 -13
  312. package/src/timeline/registry.ts +19 -0
  313. package/src/timeline/screenshot-lightbox.tsx +30 -9
  314. package/src/timeline/shared.tsx +59 -11
  315. package/src/timeline/tool-renderers.tsx +421 -24
  316. package/src/timeline/turn-summary.tsx +2 -2
  317. package/src/timeline/types.ts +58 -2
  318. package/src/voice-recording-store.ts +15 -0
  319. package/styles/compiled.css +5298 -0
  320. package/styles/compiled.d.ts +2 -0
  321. package/styles/effective-tokens.css +27 -0
  322. package/styles/index.css +18 -0
  323. package/styles/responsive.css +208 -0
  324. package/styles/responsive.d.ts +2 -0
  325. package/styles/tokens.css +50 -17
  326. package/dist/chunk-3PB7MIT6.js.map +0 -1
  327. package/dist/chunk-6EDV6YFE.js.map +0 -1
  328. package/dist/chunk-EB67J347.js.map +0 -1
  329. package/dist/chunk-EKZH6IDG.js.map +0 -1
  330. package/dist/chunk-FGZUCXZF.js.map +0 -1
  331. package/dist/chunk-HFO4ERGQ.js.map +0 -1
  332. package/dist/chunk-JB5725BI.js +0 -48
  333. package/dist/chunk-JB5725BI.js.map +0 -1
  334. package/dist/chunk-LGVMUIRV.js.map +0 -1
  335. package/dist/chunk-LWR4MXSS.js.map +0 -1
  336. package/dist/chunk-SRFUT2ZU.js.map +0 -1
  337. package/dist/chunk-U3CSCMWA.js.map +0 -1
  338. package/dist/chunk-VCBFUSUZ.js.map +0 -1
  339. package/dist/fleet-decision-projection-SLNZMS42.js +0 -241
  340. package/dist/fleet-decision-projection-SLNZMS42.js.map +0 -1
  341. package/dist/queue-surface-implementation-J4XJFKRW.js.map +0 -1
@@ -4,7 +4,8 @@
4
4
  // Changes — review-first git: turn-end capture (cold) or live diff (warm)
5
5
  // Files — tree + inline editor (capture-backed cold, live warm)
6
6
  // Terminal — interactive xterm wired to the box PTY (Channel-A projection)
7
- // DesktopnoVNC (Channel-B), watch by default + a server-gated take-control
7
+ // Browserworkspace-wide BrowserSessions, tabs, live frames, and input
8
+ // Computer — workspace ComputerSessions: native targets, frames, and input
8
9
  // plus a machine-state chip in the dock header (the one truthful live/waking/
9
10
  // offline indicator) and any host-injected extra tabs (Run/Debug in apps/web).
10
11
  //
@@ -13,39 +14,63 @@
13
14
  // (no `sonner` import), and every surface renders with package primitives + og
14
15
  // tokens only. `apps/web` consumes this through the exact public surface an
15
16
  // external embedder uses — that is criterion F1.
16
- import { type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from "react";
17
- import { Popover } from "radix-ui";
17
+ import {
18
+ lazy,
19
+ type ReactNode,
20
+ Suspense,
21
+ useCallback,
22
+ useEffect,
23
+ useMemo,
24
+ useRef,
25
+ useState,
26
+ } from "react";
18
27
  import type { SessionEvent } from "@opengeni/sdk";
19
28
  import {
20
29
  CircleCheckIcon,
21
30
  CpuIcon,
22
- LaptopIcon,
31
+ FileCode2Icon,
32
+ GitCompareArrowsIcon,
33
+ Globe2Icon,
23
34
  LoaderCircleIcon,
35
+ MonitorIcon,
24
36
  RefreshCwIcon,
37
+ SquareTerminalIcon,
25
38
  TriangleAlertIcon,
26
39
  } from "lucide-react";
27
40
 
28
41
  import { type ClientOverride, useOpenGeni } from "../provider";
29
42
  import { cn } from "../lib/cn";
30
43
  import { xtermThemeFromTokens } from "../lib/xterm-theme";
31
- import { usePortalTokenStyle } from "../lib/use-portal-token-style";
44
+ import { sandboxAcceptsLiveIo } from "../lib/sandbox-liveness";
45
+ import type { BrowserFrameWebSocketFactory } from "../hooks/use-browser-frame-stream";
46
+ import type { ComputerFrameWebSocketFactory } from "../hooks/use-computer-frame-stream";
32
47
  import { useSessionCapabilities } from "../hooks/use-session-capabilities";
33
48
  import { useSandboxFiles } from "../hooks/use-sandbox-files";
34
- import { useSandboxGit, type UseSandboxGitResult } from "../hooks/use-sandbox-git";
49
+ import {
50
+ useSandboxGit,
51
+ type SandboxGitComparison,
52
+ type UseSandboxGitResult,
53
+ } from "../hooks/use-sandbox-git";
35
54
  import { useSandboxTerminal } from "../hooks/use-sandbox-terminal";
36
55
  import { useWorkspaceCapture } from "../hooks/use-workspace-capture";
37
- import { formatAsOf, useMachineChip, type MachineChip } from "../hooks/use-machine-chip";
56
+ import { useMachineChip, type MachineChip } from "../hooks/use-machine-chip";
38
57
  import { useMachines } from "../hooks/use-machines";
39
58
  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
59
  import { SandboxFiles } from "./sandbox-files";
44
60
  import { WorkbenchChanges } from "./workbench-changes";
45
61
  import { SandboxTerminal, type XtermTheme } from "./sandbox-terminal";
46
- import { DesktopViewer } from "./desktop-viewer";
47
62
  import { WorkspaceDock, type WorkspaceDockProps, type WorkspaceTab } from "./workspace-dock";
48
63
 
64
+ const LazyBrowserViewer = lazy(async () => {
65
+ const viewers = await import("./interactive-workbench-viewers");
66
+ return { default: viewers.BrowserViewer };
67
+ });
68
+
69
+ const LazyComputerViewer = lazy(async () => {
70
+ const viewers = await import("./interactive-workbench-viewers");
71
+ return { default: viewers.ComputerViewer };
72
+ });
73
+
49
74
  /** A host-routed notification (replaces the app-only `sonner` toast coupling). */
50
75
  export type WorkspaceNotification = { kind: "error" | "info"; message: string };
51
76
 
@@ -53,6 +78,7 @@ export type WorkspaceNotification = { kind: "error" | "info"; message: string };
53
78
  export const WORKBENCH_TAB_CHANGES = "changes";
54
79
  export const WORKBENCH_TAB_FILES = "files";
55
80
  export const WORKBENCH_TAB_TERMINAL = "terminal";
81
+ export const WORKBENCH_TAB_BROWSER = "browser";
56
82
  export const WORKBENCH_TAB_DESKTOP = "desktop";
57
83
 
58
84
  /**
@@ -64,6 +90,7 @@ export const WORKBENCH_SURFACES = [
64
90
  WORKBENCH_TAB_CHANGES,
65
91
  WORKBENCH_TAB_FILES,
66
92
  WORKBENCH_TAB_TERMINAL,
93
+ WORKBENCH_TAB_BROWSER,
67
94
  WORKBENCH_TAB_DESKTOP,
68
95
  ] as const;
69
96
 
@@ -97,6 +124,29 @@ function captureDegradedMessage(reason: string): string {
97
124
  }
98
125
  }
99
126
 
127
+ function WorkbenchTabLabel({ icon, children }: { icon: ReactNode; children: ReactNode }) {
128
+ return (
129
+ <span className="inline-flex items-center gap-1.5">
130
+ <span className="[&>svg]:size-3.5" aria-hidden>
131
+ {icon}
132
+ </span>
133
+ <span>{children}</span>
134
+ </span>
135
+ );
136
+ }
137
+
138
+ function WorkbenchSurfaceLoading({ name }: { name: "Browser" | "Computer" }) {
139
+ return (
140
+ <CenteredState
141
+ icon={
142
+ <LoaderCircleIcon className="size-5 animate-spin motion-reduce:animate-none" aria-hidden />
143
+ }
144
+ >
145
+ <p className="text-og-sm font-medium text-og-fg">Opening {name}</p>
146
+ </CenteredState>
147
+ );
148
+ }
149
+
100
150
  /**
101
151
  * Decide a workspace tab from an already-local event log: the newest
102
152
  * `workspace.revision.captured` announce carries the change surface stats, so
@@ -121,7 +171,19 @@ export function initialWorkspaceTab(
121
171
  bestSeq = event.sequence;
122
172
  bestFileCount = typeof stats?.fileCount === "number" ? stats.fileCount : 0;
123
173
  }
124
- return bestFileCount > 0 ? WORKBENCH_TAB_CHANGES : WORKBENCH_TAB_FILES;
174
+ if (bestFileCount > 0) return WORKBENCH_TAB_CHANGES;
175
+
176
+ let latestGitSeq = -1;
177
+ let committedOnly = false;
178
+ for (const event of events ?? []) {
179
+ if (event.type !== "git.changed" || event.sequence > bestSeq) continue;
180
+ if (event.sequence <= latestGitSeq) continue;
181
+ const payload = event.payload as { ahead?: number; reason?: string } | null;
182
+ latestGitSeq = event.sequence;
183
+ committedOnly =
184
+ (typeof payload?.ahead === "number" && payload.ahead > 0) || payload?.reason === "commit";
185
+ }
186
+ return committedOnly ? WORKBENCH_TAB_CHANGES : WORKBENCH_TAB_FILES;
125
187
  }
126
188
 
127
189
  /**
@@ -173,7 +235,7 @@ export type UseSandboxWorkspaceTabsOptions = ClientOverride & {
173
235
  /** Live event log (usually `useSessionEvents().events`). */
174
236
  events: SessionEvent[];
175
237
  /**
176
- * Built-in surfaces this host wants to expose. Omit for all four. A disabled
238
+ * Built-in surfaces this host wants to expose. Omit for all five. A disabled
177
239
  * surface is behaviorally dormant: its data hooks, warm intents, stream
178
240
  * attachment, and navigation callbacks are not activated.
179
241
  */
@@ -181,9 +243,15 @@ export type UseSandboxWorkspaceTabsOptions = ClientOverride & {
181
243
  /** Override the source-driven default tab (e.g. a host landing tab id). When
182
244
  * omitted the workbench picks Changes-vs-Files from capture or live Git. */
183
245
  initialTab?: string | null | undefined;
246
+ /** Actually selected dock tab. Inactive data surfaces retain cache but issue no live reads. */
247
+ activeTab?: string | null | undefined;
184
248
  /** Host-routed notifications (mutation errors, desktop-consent failures). The
185
249
  * package never imports a toast library — the host decides how to surface. */
186
250
  onNotify?: ((notification: WorkspaceNotification) => void) | undefined;
251
+ /** Alternate frame transport for non-browser runtimes and deterministic tests. */
252
+ browserWebSocketFactory?: BrowserFrameWebSocketFactory | undefined;
253
+ /** Alternate Computer frame transport for non-browser runtimes and deterministic tests. */
254
+ computerWebSocketFactory?: ComputerFrameWebSocketFactory | undefined;
187
255
  /** File requested by a Changes guard. The Files surface defers the read until
188
256
  * the sandbox is live, then reveals this path. */
189
257
  requestedFilePath?: string | null | undefined;
@@ -192,10 +260,14 @@ export type UseSandboxWorkspaceTabsOptions = ClientOverride & {
192
260
  requestedFileRequestId?: string | number | null | undefined;
193
261
  /** Route a guarded diff into the host's Files tab. */
194
262
  onOpenFile?: ((path: string) => void) | undefined;
263
+ /** Navigate the host dock to one exact linked ComputerSession. */
264
+ onOpenComputerSession?: ((computerSessionId: string) => void) | undefined;
265
+ requestedComputerSessionId?: string | null | undefined;
266
+ requestedComputerRequestId?: string | number | null | undefined;
195
267
  };
196
268
 
197
269
  export type UseSandboxWorkspaceTabsResult = {
198
- /** Changes | Files | Terminal | Desktop (capability-gated where noted). */
270
+ /** Changes | Files | Terminal | Browser | Desktop (capability-gated where noted). */
199
271
  tabs: WorkspaceTab[];
200
272
  /** The source-driven default tab: Changes when the first authoritative capture
201
273
  * or live Git result has changes, else Files (a host `initialTab` overrides).
@@ -208,7 +280,6 @@ export type UseSandboxWorkspaceTabsResult = {
208
280
 
209
281
  type SessionWarmIntents = {
210
282
  sessionId: string;
211
- watchDesktop: boolean;
212
283
  warmTerminal: boolean;
213
284
  warmFiles: boolean;
214
285
  };
@@ -216,7 +287,6 @@ type SessionWarmIntents = {
216
287
  function emptyWarmIntents(sessionId: string): SessionWarmIntents {
217
288
  return {
218
289
  sessionId,
219
- watchDesktop: false,
220
290
  warmTerminal: false,
221
291
  warmFiles: false,
222
292
  };
@@ -225,36 +295,71 @@ function emptyWarmIntents(sessionId: string): SessionWarmIntents {
225
295
  /**
226
296
  * Build the workbench tabs + the machine-chip model for one session. This is the
227
297
  * dock "brain": capability negotiation, capture-backed cold reads, prewarm
228
- * flags, desktop consent, and the xterm theme observer — all package-local.
298
+ * flags, automatic desktop acknowledgment, and the xterm theme observer — all
299
+ * package-local.
229
300
  */
230
301
  export function useSandboxWorkspaceTabs(
231
302
  options: UseSandboxWorkspaceTabsOptions,
232
303
  ): UseSandboxWorkspaceTabsResult {
233
304
  const { client, workspaceId } = useOpenGeni(options);
234
- const { sessionId, events, onNotify, requestedFilePath, requestedFileRequestId, onOpenFile } =
235
- options;
305
+ const {
306
+ sessionId,
307
+ events,
308
+ onNotify,
309
+ browserWebSocketFactory,
310
+ computerWebSocketFactory,
311
+ requestedFilePath,
312
+ requestedFileRequestId,
313
+ onOpenFile,
314
+ onOpenComputerSession,
315
+ requestedComputerSessionId,
316
+ requestedComputerRequestId,
317
+ } = options;
236
318
  const initialTab = options.initialTab ?? null;
319
+ const activeTab = options.activeTab ?? initialTab;
237
320
  const requestedSurfaces = options.surfaces ?? WORKBENCH_SURFACES;
238
321
  const surfaceSet = new Set<SandboxWorkspaceSurface>(requestedSurfaces);
239
322
  const changesEnabled = surfaceSet.has(WORKBENCH_TAB_CHANGES);
240
323
  const filesEnabled = surfaceSet.has(WORKBENCH_TAB_FILES);
241
324
  const terminalEnabled = surfaceSet.has(WORKBENCH_TAB_TERMINAL);
325
+ const browserEnabled = surfaceSet.has(WORKBENCH_TAB_BROWSER);
242
326
  const desktopEnabled = surfaceSet.has(WORKBENCH_TAB_DESKTOP);
327
+ const createLinkedComputer = useCallback(
328
+ async (name: string) => {
329
+ const response = await client.createComputerSession(workspaceId, {
330
+ operationId: crypto.randomUUID(),
331
+ sessionId,
332
+ name,
333
+ });
334
+ return {
335
+ id: response.session.id,
336
+ placement: response.session.placement,
337
+ };
338
+ },
339
+ [client, sessionId, workspaceId],
340
+ );
243
341
  const workspaceDataEnabled = changesEnabled || filesEnabled;
244
- const anySurfaceEnabled = workspaceDataEnabled || terminalEnabled || desktopEnabled;
342
+ const machineSurfaceEnabled = workspaceDataEnabled || terminalEnabled || desktopEnabled;
343
+ // Do not speculatively activate both remote workspace surfaces while the dock
344
+ // is still resolving its selected tab. Modal calls cannot be cancelled after
345
+ // dispatch, so that transient state otherwise creates an aborted duplicate
346
+ // batch immediately before the real selected-tab request.
347
+ const resolvedActiveTab = activeTab ?? initialTab;
348
+ const changesActive = resolvedActiveTab === WORKBENCH_TAB_CHANGES;
349
+ const filesActive = resolvedActiveTab === WORKBENCH_TAB_FILES;
245
350
  const surfaceIdentity = WORKBENCH_SURFACES.filter((surface) => surfaceSet.has(surface)).join(",");
246
351
 
247
- // The three box-warming INTENTS, each off by default and each
352
+ // The two box-warming INTENTS, each off by default and each
248
353
  // flipped true by a genuine user action (never on mount, never on a passive
249
- // capture glance): desktop watch consent, terminal engagement (`onActivate`), and
250
- // a deliberate live-file open/edit in Files. Browsing capture-served
354
+ // capture glance): terminal engagement (`onActivate`) and a deliberate
355
+ // live-file open/edit in Files. Browsing capture-served
251
356
  // Changes/Files warms nothing — that is the whole point of Refinement 1.
252
357
  const [storedWarmIntents, setStoredWarmIntents] = useState<SessionWarmIntents>(() =>
253
358
  emptyWarmIntents(sessionId),
254
359
  );
255
360
  const warmIntents =
256
361
  storedWarmIntents.sessionId === sessionId ? storedWarmIntents : emptyWarmIntents(sessionId);
257
- const { watchDesktop, warmTerminal, warmFiles } = warmIntents;
362
+ const { warmTerminal, warmFiles } = warmIntents;
258
363
  const requestWarmIntent = useCallback(
259
364
  (intent: Exclude<keyof SessionWarmIntents, "sessionId">) => {
260
365
  setStoredWarmIntents((previous) => {
@@ -271,7 +376,7 @@ export function useSandboxWorkspaceTabs(
271
376
  workspaceId,
272
377
  sessionId,
273
378
  pollIntervalMs: 8000,
274
- enabled: anySurfaceEnabled,
379
+ enabled: machineSurfaceEnabled,
275
380
  });
276
381
  const activeMachine: MachineView | null =
277
382
  machines.machines.find((m) => m.sandboxId === machines.activeSandboxId) ??
@@ -280,8 +385,8 @@ export function useSandboxWorkspaceTabs(
280
385
 
281
386
  const caps = useSessionCapabilities(sessionId, {
282
387
  events,
283
- enabled: anySurfaceEnabled,
284
- attachDesktop: desktopEnabled && watchDesktop,
388
+ enabled: machineSurfaceEnabled,
389
+ attachDesktop: false,
285
390
  attachTerminal: terminalEnabled && warmTerminal,
286
391
  // Explicit live-file intent only — NOT "the Files tab is open". A cold edit
287
392
  // or guarded-file open wakes the box; a glance at the tree/diff does not.
@@ -289,6 +394,11 @@ export function useSandboxWorkspaceTabs(
289
394
  });
290
395
  const capabilities = caps.capabilities;
291
396
  const liveness = capabilities?.liveness;
397
+ // A missing capability document is not evidence that the sandbox is live.
398
+ // Pass explicit null to live-I/O hooks so fast capture/event hydration cannot
399
+ // exploit their legacy "liveness omitted" compatibility path while negotiation
400
+ // is still pending.
401
+ const liveIoLiveness = liveness ?? null;
292
402
  const fileSystemOn = capabilities?.FileSystem.available ?? false;
293
403
  // The FS is writable only when it's live AND not read-only. A self-hosted box
294
404
  // that's offline (or any read-only advertisement) or a capture-served cold tree
@@ -300,26 +410,23 @@ export function useSandboxWorkspaceTabs(
300
410
  // Mutations are live-only. A capture is a review artifact, never a writable
301
411
  // surrogate for a sleeping machine; the user must deliberately open the live
302
412
  // workspace before create/rename/delete/edit controls appear.
303
- const filesEditable =
304
- fileSystemOn && !fsReadOnly && (liveness === "warm" || liveness === "draining");
413
+ const filesEditable = fileSystemOn && !fsReadOnly && sandboxAcceptsLiveIo(liveness);
305
414
  const gitOn = capabilities?.Git.available ?? false;
306
415
  const terminalOn = terminalEnabled && (capabilities?.Terminal.transport ?? null) !== null;
307
- // The REAL interactive terminal is the ttyd pty-ws stream. When it's live the
308
- // legacy HTTP PTY must NOT run; `useSandboxTerminal` opens its HTTP PTY only as
309
- // the firehose-mode fallback (a backend without ttyd).
310
- const ptyWsLive =
311
- capabilities?.Terminal.transport === "pty-ws" && Boolean(capabilities?.Terminal.url);
312
- const ptyCapable = (capabilities?.Terminal.ptyCapable ?? false) && !ptyWsLive;
416
+ // A descriptor-only pty-ws cell intentionally has no short-lived URL until
417
+ // terminal intent acquires one. Do not mistake that grant boundary for a
418
+ // legacy HTTP PTY and eagerly open a second terminal. The fallback exists only
419
+ // for a genuinely firehose-transport backend with no typed degradation.
420
+ const ptyCapable =
421
+ warmTerminal &&
422
+ (capabilities?.Terminal.ptyCapable ?? false) &&
423
+ capabilities?.Terminal.transport === "sse-events" &&
424
+ capabilities?.Terminal.reason === null;
313
425
  const terminal = useSandboxTerminal(sessionId, {
314
426
  events: terminalEnabled ? events : [],
315
427
  interactive: terminalEnabled && ptyCapable,
316
428
  liveness,
317
429
  });
318
- const desktopAdvertised =
319
- desktopEnabled &&
320
- ((capabilities?.DesktopStream.transport ?? null) !== null ||
321
- capabilities?.DesktopStream.reason === "lease_cold");
322
-
323
430
  // Lazy provisioning (#315) creates the box mid-turn on the first sandbox tool
324
431
  // call, emitting sandbox.provision started→completed/failed on the live stream.
325
432
  // The on-demand resting hook rests without polling, so when the box warms the
@@ -346,7 +453,7 @@ export function useSandboxWorkspaceTabs(
346
453
  enabled: workspaceDataEnabled,
347
454
  });
348
455
  const captureAvailable = captureState.available;
349
- const liveWorkspaceExpected = liveness === "warm" || liveness === "draining";
456
+ const liveWorkspaceExpected = sandboxAcceptsLiveIo(liveness);
350
457
  // Capability negotiation and capture resolution are independent requests. A
351
458
  // cold capability document can win that race by a few milliseconds; enabling
352
459
  // the leaf hooks in that window would make them interpret `capture: null` as a
@@ -382,7 +489,9 @@ export function useSandboxWorkspaceTabs(
382
489
  // No passive Channel-A reads while cold, even after a conclusive capture
383
490
  // miss. A missing capture gets an explicit "Open live workspace" gate.
384
491
  enabled: filesEnabled && (captureAvailable || (fileSystemOn && liveWorkspaceExpected)),
385
- liveness,
492
+ active: filesActive,
493
+ repoPaths,
494
+ liveness: liveIoLiveness,
386
495
  capture: captureState.capture,
387
496
  // A reverted optimistic mutation (e.g. a 409 rename collision) surfaces as a
388
497
  // host notification — the tree silently rolls back, the user sees why.
@@ -392,24 +501,58 @@ export function useSandboxWorkspaceTabs(
392
501
  message: `Could not ${op}: ${error.message}`,
393
502
  }),
394
503
  });
504
+ const [storedChangesComparison, setStoredChangesComparison] = useState<{
505
+ sessionId: string;
506
+ value: SandboxGitComparison;
507
+ } | null>(null);
508
+ const captureSupportsBranch =
509
+ captureState.capture !== null &&
510
+ captureState.capture.repos.length > 0 &&
511
+ captureState.capture.repos.every((repo) => repo.branchDiff !== undefined);
512
+ // A sleeping workspace can truthfully serve only the captured working-tree
513
+ // comparison. A live workspace defaults to the branch comparison so committed
514
+ // agent work remains visible. Explicit user selection is scoped per session.
515
+ const changesComparison =
516
+ storedChangesComparison?.sessionId === sessionId
517
+ ? storedChangesComparison.value
518
+ : liveWorkspaceExpected || captureSupportsBranch
519
+ ? "branch"
520
+ : "working";
521
+ const setChangesComparison = useCallback(
522
+ (value: SandboxGitComparison) => setStoredChangesComparison({ sessionId, value }),
523
+ [sessionId],
524
+ );
395
525
  const git = useSandboxGit(sessionId, {
396
526
  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,
527
+ enabled:
528
+ changesComparison === "working"
529
+ ? workspaceDataEnabled && (captureAvailable || (gitOn && liveWorkspaceExpected))
530
+ : changesComparison === "branch"
531
+ ? workspaceDataEnabled && (captureSupportsBranch || (gitOn && liveWorkspaceExpected))
532
+ : workspaceDataEnabled && gitOn && liveWorkspaceExpected,
533
+ // Before the dock chooses its source-driven default, Git is the sole probe
534
+ // that may need to resolve that choice on a live workspace. Files remains
535
+ // dormant, avoiding the old duplicate full-tree read.
536
+ active: changesActive || resolvedActiveTab === null,
408
537
  repoPaths,
409
- liveness,
410
- capture: captureState.capture,
538
+ liveness: liveIoLiveness,
539
+ comparison: changesComparison,
540
+ capture: changesComparison === "staged" ? null : captureState.capture,
411
541
  });
412
-
542
+ useEffect(() => {
543
+ if (
544
+ changesComparison === "branch" &&
545
+ git.error &&
546
+ storedChangesComparison?.sessionId !== sessionId
547
+ ) {
548
+ // `origin/HEAD` is not guaranteed on a newly initialized repository, a
549
+ // connected machine, or a clone whose remote was renamed. The implicit
550
+ // live default must not strand the whole Changes surface when its ordinary
551
+ // working-tree comparison is still valid. Fall back once; an explicit
552
+ // user-selected Branch view remains selected and surfaces its real error.
553
+ setStoredChangesComparison({ sessionId, value: "working" });
554
+ }
555
+ }, [changesComparison, git.error, sessionId, storedChangesComparison?.sessionId]);
413
556
  // Token-derived xterm theme; re-derive on a `data-og-theme` flip. Generic — it
414
557
  // belongs in the package (an embedder's theme toggle drives it too).
415
558
  const [xtermTheme, setXtermTheme] = useState<XtermTheme | undefined>(undefined);
@@ -425,30 +568,6 @@ export function useSandboxWorkspaceTabs(
425
568
  return () => observer.disconnect();
426
569
  }, [terminalEnabled]);
427
570
 
428
- async function acknowledgeAndWatch() {
429
- try {
430
- const shared = capabilities?.DesktopStream.shared ?? false;
431
- await client.acknowledgeStream(workspaceId, sessionId, {
432
- acknowledgeUnredacted: true,
433
- acknowledgeShared: shared,
434
- });
435
- requestWarmIntent("watchDesktop");
436
- caps.renegotiate();
437
- } catch (error) {
438
- onNotify?.({
439
- kind: "error",
440
- message: `Could not start the desktop stream: ${error instanceof Error ? error.message : String(error)}`,
441
- });
442
- }
443
- }
444
-
445
- // Re-warm WITHOUT re-acknowledging: the consent was already recorded, only the
446
- // box drained back to cold. Idempotent — the viewer auto-warm de-dupes.
447
- function rewarmDesktop() {
448
- if (!watchDesktop) requestWarmIntent("watchDesktop");
449
- caps.renegotiate();
450
- }
451
-
452
571
  const dirtyCount = git.diff.length;
453
572
 
454
573
  // The one truthful machine indicator, derived from the live capability/liveness
@@ -458,10 +577,7 @@ export function useSandboxWorkspaceTabs(
458
577
  capabilitiesState: caps.state,
459
578
  activeMachineState: activeMachine?.state ?? null,
460
579
  activeIsSelfhosted: activeMachine?.kind === "selfhosted",
461
- wantsWarm:
462
- (terminalEnabled && warmTerminal) ||
463
- (desktopEnabled && watchDesktop) ||
464
- (workspaceDataEnabled && warmFiles),
580
+ wantsWarm: (terminalEnabled && warmTerminal) || (workspaceDataEnabled && warmFiles),
465
581
  capturedAt: captureState.capturedAt,
466
582
  });
467
583
  const workspaceWaking = chip.state === "waking";
@@ -484,7 +600,15 @@ export function useSandboxWorkspaceTabs(
484
600
  }
485
601
  const captureIsAuthoritative =
486
602
  !liveWorkspaceExpected && (liveness !== undefined || caps.error !== null);
487
- const captureUnavailable = captureState.fileCount === 0 || captureState.error !== null;
603
+ const captureHasChanges =
604
+ (captureState.fileCount ?? 0) > 0 ||
605
+ (captureState.capture?.repos.some((repo) => (repo.branchDiff?.length ?? 0) > 0) ?? false);
606
+ const captureUnavailable =
607
+ (captureState.fileCount === 0 && !captureHasChanges) || captureState.error !== null;
608
+ const implicitBranchFallbackPending =
609
+ changesComparison === "branch" &&
610
+ git.error !== null &&
611
+ storedChangesComparison?.sessionId !== sessionId;
488
612
  if (defaultTabRef.current.value === null) {
489
613
  if (initialTab && (!isWorkbenchSurface(initialTab) || surfaceSet.has(initialTab))) {
490
614
  defaultTabRef.current.value = initialTab;
@@ -500,7 +624,7 @@ export function useSandboxWorkspaceTabs(
500
624
  captureState.available
501
625
  ) {
502
626
  defaultTabRef.current.value = sourceDrivenDefaultTab(
503
- captureState.fileCount > 0,
627
+ captureHasChanges,
504
628
  changesEnabled,
505
629
  filesEnabled,
506
630
  );
@@ -523,13 +647,13 @@ export function useSandboxWorkspaceTabs(
523
647
  // A warm live read failed but the durable capture is intact: retain an
524
648
  // immediate, deterministic review surface instead of hanging unresolved.
525
649
  defaultTabRef.current.value = sourceDrivenDefaultTab(
526
- captureState.fileCount > 0,
650
+ captureHasChanges,
527
651
  changesEnabled,
528
652
  filesEnabled,
529
653
  );
530
- } else if (captureUnavailable && git.error) {
654
+ } else if (captureUnavailable && git.error && !implicitBranchFallbackPending) {
531
655
  // No capture and live Git failed: Files is the least surprising fallback
532
- // and preserves the established no-capture degraded behavior.
656
+ // after the implicit branch-to-working fallback has also had its chance.
533
657
  defaultTabRef.current.value = sourceDrivenDefaultTab(false, changesEnabled, filesEnabled);
534
658
  }
535
659
  }
@@ -543,11 +667,13 @@ export function useSandboxWorkspaceTabs(
543
667
  if (changesEnabled)
544
668
  list.push({
545
669
  id: WORKBENCH_TAB_CHANGES,
546
- label: "Changes",
670
+ label: <WorkbenchTabLabel icon={<GitCompareArrowsIcon />}>Changes</WorkbenchTabLabel>,
547
671
  badge: dirtyCount > 0 ? <DirtyBadge count={dirtyCount} /> : undefined,
548
672
  content: (
549
673
  <ChangesTabBody
550
674
  git={git}
675
+ comparison={changesComparison}
676
+ onComparisonChange={setChangesComparison}
551
677
  captureAvailable={captureAvailable}
552
678
  captureRevision={captureState.revision}
553
679
  capturePending={capturePending}
@@ -573,13 +699,12 @@ export function useSandboxWorkspaceTabs(
573
699
  if (filesEnabled)
574
700
  list.push({
575
701
  id: WORKBENCH_TAB_FILES,
576
- label: "Files",
702
+ label: <WorkbenchTabLabel icon={<FileCode2Icon />}>Files</WorkbenchTabLabel>,
577
703
  content: (
578
704
  <SandboxFiles
579
705
  key={sessionId}
580
706
  files={files}
581
707
  git={git}
582
- stagedGit={stagedGit}
583
708
  fileSystemAvailable={fileSystemOn || captureAvailable}
584
709
  editable={
585
710
  filesEditable && files.source === "live" && files.error === null && !files.loading
@@ -600,7 +725,7 @@ export function useSandboxWorkspaceTabs(
600
725
  ...(requestedFileRequestId !== null && requestedFileRequestId !== undefined
601
726
  ? { requestedPathRequestId: requestedFileRequestId }
602
727
  : {}),
603
- requestedPathReady: liveness === "warm" || liveness === "draining",
728
+ requestedPathReady: sandboxAcceptsLiveIo(liveness),
604
729
  }
605
730
  : {})}
606
731
  // Ordinary capture browsing does not warm a box. The first edit — or an
@@ -615,10 +740,11 @@ export function useSandboxWorkspaceTabs(
615
740
  if (terminalOn) {
616
741
  list.push({
617
742
  id: WORKBENCH_TAB_TERMINAL,
618
- label: "Terminal",
743
+ label: <WorkbenchTabLabel icon={<SquareTerminalIcon />}>Terminal</WorkbenchTabLabel>,
619
744
  content: (
620
745
  <div className="h-full bg-og-bg p-1">
621
746
  <SandboxTerminal
747
+ key={sessionId}
622
748
  result={terminal}
623
749
  terminalCapability={capabilities?.Terminal ?? null}
624
750
  onActivate={() => requestWarmIntent("warmTerminal")}
@@ -632,25 +758,51 @@ export function useSandboxWorkspaceTabs(
632
758
  });
633
759
  }
634
760
 
635
- // Desktop — capability-gated + consent-gated.
636
- if (desktopAdvertised) {
761
+ // Browserindependent from sandbox capability negotiation. BrowserSessions
762
+ // are workspace resources and may live in this agent's sandbox, a peer's
763
+ // sandbox, a connected machine, or an external browser placement.
764
+ if (browserEnabled) {
765
+ list.push({
766
+ id: WORKBENCH_TAB_BROWSER,
767
+ label: <WorkbenchTabLabel icon={<Globe2Icon />}>Browser</WorkbenchTabLabel>,
768
+ content: (
769
+ <Suspense fallback={<WorkbenchSurfaceLoading name="Browser" />}>
770
+ <LazyBrowserViewer
771
+ key={sessionId}
772
+ client={client}
773
+ workspaceId={workspaceId}
774
+ sessionId={sessionId}
775
+ onNotify={onNotify}
776
+ {...(desktopEnabled ? { createLinkedComputer } : {})}
777
+ {...(onOpenComputerSession ? { onOpenComputer: onOpenComputerSession } : {})}
778
+ {...(browserWebSocketFactory ? { webSocketFactory: browserWebSocketFactory } : {})}
779
+ className="h-full"
780
+ />
781
+ </Suspense>
782
+ ),
783
+ });
784
+ }
785
+
786
+ // Computer — stable legacy tab id, new workspace resource body. Like Browser,
787
+ // it is independent from the selected agent's sandbox capability document.
788
+ if (desktopEnabled) {
637
789
  list.push({
638
790
  id: WORKBENCH_TAB_DESKTOP,
639
- label: "Desktop",
640
- badge: watchDesktop ? (
641
- <span className="rounded-og-xs bg-og-status-running/20 px-1 text-og-xs text-og-status-running">
642
- Live
643
- </span>
644
- ) : undefined,
791
+ label: <WorkbenchTabLabel icon={<MonitorIcon />}>Computer</WorkbenchTabLabel>,
645
792
  content: (
646
- <DesktopViewer
647
- capability={capabilities?.DesktopStream ?? null}
648
- viewerCapReached={caps.viewerCapReached}
649
- watching={watchDesktop}
650
- onAcknowledge={() => void acknowledgeAndWatch()}
651
- onWarm={rewarmDesktop}
652
- className="h-full"
653
- />
793
+ <Suspense fallback={<WorkbenchSurfaceLoading name="Computer" />}>
794
+ <LazyComputerViewer
795
+ key={sessionId}
796
+ client={client}
797
+ workspaceId={workspaceId}
798
+ sessionId={sessionId}
799
+ onNotify={onNotify}
800
+ requestedComputerSessionId={requestedComputerSessionId}
801
+ requestedComputerRequestId={requestedComputerRequestId}
802
+ {...(computerWebSocketFactory ? { webSocketFactory: computerWebSocketFactory } : {})}
803
+ className="h-full"
804
+ />
805
+ </Suspense>
654
806
  ),
655
807
  });
656
808
  }
@@ -662,19 +814,27 @@ export function useSandboxWorkspaceTabs(
662
814
  changesEnabled,
663
815
  filesEnabled,
664
816
  terminalOn,
665
- desktopAdvertised,
817
+ browserEnabled,
818
+ desktopEnabled,
666
819
  captureAvailable,
667
820
  dirtyCount,
668
- watchDesktop,
669
821
  warmTerminal,
670
822
  requestedFilePath,
671
823
  requestedFileRequestId,
672
824
  onOpenFile,
825
+ onOpenComputerSession,
826
+ requestedComputerSessionId,
827
+ requestedComputerRequestId,
828
+ onNotify,
829
+ browserWebSocketFactory,
830
+ computerWebSocketFactory,
831
+ client,
832
+ workspaceId,
673
833
  liveness,
674
834
  sessionId,
675
835
  files,
676
836
  git,
677
- stagedGit,
837
+ changesComparison,
678
838
  terminal,
679
839
  xtermTheme,
680
840
  capabilities,
@@ -683,13 +843,14 @@ export function useSandboxWorkspaceTabs(
683
843
  caps.error,
684
844
  caps.viewerCapReached,
685
845
  requestWarmIntent,
846
+ createLinkedComputer,
686
847
  ]);
687
848
 
688
849
  return {
689
850
  tabs,
690
851
  defaultTab,
691
852
  machine: {
692
- enabled: anySurfaceEnabled,
853
+ enabled: machineSurfaceEnabled,
693
854
  chip,
694
855
  activeMachine,
695
856
  loading: machines.loading,
@@ -707,7 +868,7 @@ export type SandboxWorkspaceProps = ClientOverride & {
707
868
  primary: ReactNode;
708
869
  /**
709
870
  * Built-in surfaces this host wants to expose. Omit for Changes, Files,
710
- * Terminal, and Desktop. Disabled surfaces remain behaviorally dormant.
871
+ * Terminal, Browser, and Desktop. Disabled surfaces remain behaviorally dormant.
711
872
  */
712
873
  surfaces?: readonly SandboxWorkspaceSurface[] | undefined;
713
874
  /** Host tabs injected BEFORE the workbench tabs (e.g. a "Run" landing tab). */
@@ -719,9 +880,15 @@ export type SandboxWorkspaceProps = ClientOverride & {
719
880
  initialTab?: string | undefined;
720
881
  /** Host-routed notifications (no toast dependency in the package). */
721
882
  onNotify?: ((notification: WorkspaceNotification) => void) | undefined;
883
+ /** Alternate Browser frame transport for non-browser runtimes and deterministic tests. */
884
+ browserWebSocketFactory?: BrowserFrameWebSocketFactory | undefined;
885
+ /** Alternate Computer frame transport for non-browser runtimes and deterministic tests. */
886
+ computerWebSocketFactory?: ComputerFrameWebSocketFactory | undefined;
722
887
  /** Controlled collapsed state for hosts with their own dock toggle. */
723
888
  collapsed?: boolean | undefined;
724
889
  onCollapsedChange?: ((collapsed: boolean) => void) | undefined;
890
+ /** Keep the dock's built-in collapse control with controlled collapsed state. */
891
+ showCollapseControl?: boolean | undefined;
725
892
  /** Host navigation shown only in the phone workspace overlay header. */
726
893
  mobileLeadingControl?: ReactNode | undefined;
727
894
  autoSaveId?: string | undefined;
@@ -735,7 +902,7 @@ export type SandboxWorkspaceProps = ClientOverride & {
735
902
  * The whole session workspace, ready to mount: `<SandboxWorkspace>` assembles the
736
903
  * capability-gated tabs, pins the machine-state chip in the dock header, and
737
904
  * renders the resizable `<WorkspaceDock>`. Wrap the tree in `<OpenGeniProvider>`
738
- * (client + workspaceId) and import `@opengeni/react/styles.css`; that is the
905
+ * (client + workspaceId) and import `@opengeni/react/compiled.css`; that is the
739
906
  * entire integration (see `docs/embedding-workbench.md`).
740
907
  */
741
908
  export function SandboxWorkspace(props: SandboxWorkspaceProps): ReactNode {
@@ -748,8 +915,11 @@ export function SandboxWorkspace(props: SandboxWorkspaceProps): ReactNode {
748
915
  trailingTabs,
749
916
  initialTab,
750
917
  onNotify,
918
+ browserWebSocketFactory,
919
+ computerWebSocketFactory,
751
920
  collapsed,
752
921
  onCollapsedChange,
922
+ showCollapseControl,
753
923
  mobileLeadingControl,
754
924
  autoSaveId,
755
925
  defaultSize,
@@ -767,7 +937,15 @@ export function SandboxWorkspace(props: SandboxWorkspaceProps): ReactNode {
767
937
  path: string;
768
938
  requestId: number;
769
939
  } | null>(null);
940
+ const [requestedComputer, setRequestedComputer] = useState<{
941
+ sessionId: string;
942
+ computerSessionId: string;
943
+ requestId: number;
944
+ } | null>(null);
770
945
  const nextFileRequestId = useRef(0);
946
+ const nextComputerRequestId = useRef(0);
947
+ const selectedTab = storedSelection?.sessionId === sessionId ? storedSelection.tab : null;
948
+ const activeTabHint = selectedTab ?? initialTab ?? leadingTabs?.[0]?.id ?? null;
771
949
  const openFile = useCallback(
772
950
  (path: string) => {
773
951
  nextFileRequestId.current += 1;
@@ -776,6 +954,18 @@ export function SandboxWorkspace(props: SandboxWorkspaceProps): ReactNode {
776
954
  },
777
955
  [sessionId],
778
956
  );
957
+ const openComputer = useCallback(
958
+ (computerSessionId: string) => {
959
+ nextComputerRequestId.current += 1;
960
+ setRequestedComputer({
961
+ sessionId,
962
+ computerSessionId,
963
+ requestId: nextComputerRequestId.current,
964
+ });
965
+ setStoredSelection({ sessionId, tab: WORKBENCH_TAB_DESKTOP });
966
+ },
967
+ [sessionId],
968
+ );
779
969
 
780
970
  const {
781
971
  tabs: workbenchTabs,
@@ -788,10 +978,18 @@ export function SandboxWorkspace(props: SandboxWorkspaceProps): ReactNode {
788
978
  events,
789
979
  ...(surfaces ? { surfaces } : {}),
790
980
  ...(initialTab ? { initialTab } : {}),
981
+ activeTab: activeTabHint,
791
982
  ...(onNotify ? { onNotify } : {}),
983
+ ...(browserWebSocketFactory ? { browserWebSocketFactory } : {}),
984
+ ...(computerWebSocketFactory ? { computerWebSocketFactory } : {}),
792
985
  requestedFilePath: requestedFile?.sessionId === sessionId ? requestedFile.path : null,
793
986
  requestedFileRequestId: requestedFile?.sessionId === sessionId ? requestedFile.requestId : null,
794
987
  onOpenFile: openFile,
988
+ onOpenComputerSession: openComputer,
989
+ requestedComputerSessionId:
990
+ requestedComputer?.sessionId === sessionId ? requestedComputer.computerSessionId : null,
991
+ requestedComputerRequestId:
992
+ requestedComputer?.sessionId === sessionId ? requestedComputer.requestId : null,
795
993
  });
796
994
 
797
995
  // A user's tab click wins forever; before that we follow the source-driven
@@ -800,7 +998,6 @@ export function SandboxWorkspace(props: SandboxWorkspaceProps): ReactNode {
800
998
  // (Changes) — whose body is a connecting/loading state until the capture lands,
801
999
  // so committing the real default at first-resolve produces no CONTENT switch.
802
1000
  const tabs: WorkspaceTab[] = [...(leadingTabs ?? []), ...workbenchTabs, ...(trailingTabs ?? [])];
803
- const selectedTab = storedSelection?.sessionId === sessionId ? storedSelection.tab : null;
804
1001
  const preferredTab = selectedTab ?? defaultTab;
805
1002
  const activeTab =
806
1003
  preferredTab && tabs.some((tab) => tab.id === preferredTab) ? preferredTab : tabs[0]?.id;
@@ -817,19 +1014,13 @@ export function SandboxWorkspace(props: SandboxWorkspaceProps): ReactNode {
817
1014
  onActiveTabChange={selectTab}
818
1015
  {...(machine.enabled
819
1016
  ? {
820
- headerAccessory: (
821
- <MachineStateChip
822
- chip={machine.chip}
823
- activeMachine={machine.activeMachine}
824
- error={machine.error}
825
- onRetry={() => void machine.refresh()}
826
- />
827
- ),
1017
+ headerAccessory: <MachineStateChip chip={machine.chip} />,
828
1018
  }
829
1019
  : {})}
830
1020
  {...(mobileLeadingControl !== undefined ? { mobileLeadingControl } : {})}
831
1021
  {...(collapsed !== undefined ? { collapsed } : {})}
832
1022
  {...(onCollapsedChange ? { onCollapsedChange } : {})}
1023
+ {...(showCollapseControl !== undefined ? { showCollapseControl } : {})}
833
1024
  {...(autoSaveId !== undefined ? { autoSaveId } : {})}
834
1025
  {...(defaultSize !== undefined ? { defaultSize } : {})}
835
1026
  {...(minSize !== undefined ? { minSize } : {})}
@@ -850,18 +1041,6 @@ function DirtyBadge({ count }: { count: number }) {
850
1041
  );
851
1042
  }
852
1043
 
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
1044
  function chipDotClass(state: MachineChip["state"]): string {
866
1045
  if (state === "live") return "bg-og-status-running";
867
1046
  if (state === "waking") return "bg-og-status-idle animate-pulse motion-reduce:animate-none";
@@ -869,93 +1048,23 @@ function chipDotClass(state: MachineChip["state"]): string {
869
1048
  }
870
1049
 
871
1050
  /**
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).
1051
+ * The dock-header machine status: one quiet, truthful live/waking/offline
1052
+ * indicator. It is intentionally not interactive; detailed machine controls do
1053
+ * not belong in a transient popover above the workspace tabs.
877
1054
  */
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);
1055
+ function MachineStateChip({ chip }: { chip: MachineChip }) {
891
1056
  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>
1057
+ <div
1058
+ role="status"
1059
+ aria-label={`Machine: ${chip.label}`}
1060
+ 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"
1061
+ >
1062
+ <span
1063
+ className={cn("size-1.5 shrink-0 rounded-full", chipDotClass(chip.state))}
1064
+ aria-hidden
1065
+ />
1066
+ <span className="max-w-[11rem] truncate">{chip.label}</span>
1067
+ </div>
959
1068
  );
960
1069
  }
961
1070
 
@@ -965,7 +1074,7 @@ function DockActionButton({ onClick, children }: { onClick: () => void; children
965
1074
  <button
966
1075
  type="button"
967
1076
  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"
1077
+ 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
1078
  >
970
1079
  {children}
971
1080
  </button>
@@ -979,7 +1088,49 @@ function DockActionButton({ onClick, children }: { onClick: () => void; children
979
1088
  * The dock frame is untouched; this is the M5 seam.
980
1089
  */
981
1090
  function ChangesTabBody({
1091
+ comparison,
1092
+ onComparisonChange,
1093
+ ...props
1094
+ }: {
1095
+ comparison: SandboxGitComparison;
1096
+ onComparisonChange: (comparison: SandboxGitComparison) => void;
1097
+ } & Parameters<typeof ChangesTabContent>[0]) {
1098
+ return (
1099
+ <div className="flex h-full min-h-0 flex-col">
1100
+ <div className="flex shrink-0 items-center gap-1 border-b border-og-border bg-og-surface-1 px-2 py-1.5">
1101
+ {(
1102
+ [
1103
+ ["branch", "Branch"],
1104
+ ["working", "Uncommitted"],
1105
+ ["staged", "Staged"],
1106
+ ] as const
1107
+ ).map(([value, label]) => (
1108
+ <button
1109
+ key={value}
1110
+ type="button"
1111
+ aria-pressed={comparison === value}
1112
+ onClick={() => onComparisonChange(value)}
1113
+ className={cn(
1114
+ "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",
1115
+ comparison === value
1116
+ ? "bg-og-surface-2 text-og-fg shadow-sm"
1117
+ : "text-og-fg-muted hover:bg-og-surface-2 hover:text-og-fg",
1118
+ )}
1119
+ >
1120
+ {label}
1121
+ </button>
1122
+ ))}
1123
+ </div>
1124
+ <div className="min-h-0 flex-1">
1125
+ <ChangesTabContent comparison={comparison} {...props} />
1126
+ </div>
1127
+ </div>
1128
+ );
1129
+ }
1130
+
1131
+ function ChangesTabContent({
982
1132
  git,
1133
+ comparison,
983
1134
  captureAvailable,
984
1135
  captureRevision,
985
1136
  capturePending,
@@ -992,6 +1143,7 @@ function ChangesTabBody({
992
1143
  onOpenFile,
993
1144
  }: {
994
1145
  git: UseSandboxGitResult;
1146
+ comparison: SandboxGitComparison;
995
1147
  captureAvailable: boolean;
996
1148
  captureRevision: number | null;
997
1149
  capturePending: boolean;
@@ -1025,7 +1177,7 @@ function ChangesTabBody({
1025
1177
  type="button"
1026
1178
  onClick={() => void git.refresh()}
1027
1179
  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"
1180
+ 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
1181
  >
1030
1182
  <RefreshCwIcon
1031
1183
  className={cn("size-3", git.loading && "animate-spin motion-reduce:animate-none")}
@@ -1085,7 +1237,7 @@ function ChangesTabBody({
1085
1237
  );
1086
1238
  }
1087
1239
 
1088
- if (!liveWorkspaceExpected && !captureAvailable) {
1240
+ if (!liveWorkspaceExpected && (comparison !== "working" || !captureAvailable)) {
1089
1241
  return (
1090
1242
  <CenteredState
1091
1243
  icon={
@@ -1105,7 +1257,11 @@ function ChangesTabBody({
1105
1257
  <p className="text-og-sm leading-5 text-og-fg-subtle">
1106
1258
  {workspaceWaking
1107
1259
  ? "Connecting to the live working tree…"
1108
- : "No captured revision is available yet. Wake the sandbox to inspect current changes."}
1260
+ : comparison === "branch"
1261
+ ? "Wake the sandbox to compare this branch with the remote default branch."
1262
+ : comparison === "staged"
1263
+ ? "Wake the sandbox to inspect staged changes."
1264
+ : "No captured revision is available yet. Wake the sandbox to inspect uncommitted changes."}
1109
1265
  </p>
1110
1266
  {!workspaceWaking ? (
1111
1267
  <DockActionButton onClick={onWake}>
@@ -1135,9 +1291,19 @@ function ChangesTabBody({
1135
1291
 
1136
1292
  return (
1137
1293
  <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>
1294
+ <p className="text-og-sm font-medium text-og-fg">
1295
+ {comparison === "branch"
1296
+ ? "No branch changes"
1297
+ : comparison === "staged"
1298
+ ? "No staged changes"
1299
+ : "No uncommitted changes"}
1300
+ </p>
1139
1301
  <p className="text-og-sm leading-5 text-og-fg-subtle">
1140
- File edits from future turns will appear here.
1302
+ {comparison === "branch"
1303
+ ? "This branch matches the remote default branch."
1304
+ : comparison === "staged"
1305
+ ? "Stage files to review the next commit here."
1306
+ : "Local edits not yet committed will appear here."}
1141
1307
  </p>
1142
1308
  </CenteredState>
1143
1309
  );