@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
@@ -0,0 +1,1592 @@
1
+ import type {
2
+ AttachedBrowserDevice,
3
+ BrowserAction,
4
+ BrowserFrame,
5
+ BrowserIdentity,
6
+ BrowserObservation,
7
+ BrowserSession,
8
+ BrowserTarget,
9
+ ComputerSession,
10
+ InteractionSemanticNode,
11
+ } from "@opengeni/sdk/interaction";
12
+ import {
13
+ BugIcon,
14
+ ChevronDownIcon,
15
+ CircleAlertIcon,
16
+ Globe2Icon,
17
+ LoaderCircleIcon,
18
+ MonitorIcon,
19
+ PlusIcon,
20
+ RefreshCwIcon,
21
+ SaveIcon,
22
+ UserRoundIcon,
23
+ XIcon,
24
+ } from "lucide-react";
25
+ import {
26
+ type FormEvent,
27
+ type KeyboardEvent,
28
+ type MouseEvent,
29
+ type PointerEvent,
30
+ type ReactNode,
31
+ type WheelEvent,
32
+ useCallback,
33
+ useEffect,
34
+ useId,
35
+ useMemo,
36
+ useRef,
37
+ useState,
38
+ } from "react";
39
+ import {
40
+ useBrowserFrameStream,
41
+ type BrowserFrameWebSocketFactory,
42
+ } from "../hooks/use-browser-frame-stream";
43
+ import { useAttachedBrowsers } from "../hooks/use-attached-browsers";
44
+ import { useBrowserIdentities } from "../hooks/use-browser-identities";
45
+ import { useBrowserSession } from "../hooks/use-browser-session";
46
+ import { useBrowserSessions } from "../hooks/use-browser-sessions";
47
+ import { cn } from "../lib/cn";
48
+ import type { EmbeddedBrowserInteractionClientOverride } from "../session-context";
49
+
50
+ export type BrowserViewerNotification = {
51
+ kind: "error" | "info";
52
+ message: string;
53
+ };
54
+
55
+ export type BrowserViewerProps = EmbeddedBrowserInteractionClientOverride & {
56
+ /** The selected OpenGeni agent/session. Peer BrowserSessions stay discoverable. */
57
+ sessionId: string;
58
+ enabled?: boolean | undefined;
59
+ className?: string | undefined;
60
+ onNotify?: ((notification: BrowserViewerNotification) => void) | undefined;
61
+ /** Tests/demos only. Production uses the browser's native WebSocket. */
62
+ webSocketFactory?: BrowserFrameWebSocketFactory | undefined;
63
+ renderEmpty?: ((create: () => void, creating: boolean) => ReactNode) | undefined;
64
+ /** Optional host capability for a headed managed browser. Browser-only
65
+ * embedders remain valid and create headless sessions instead. */
66
+ createLinkedComputer?:
67
+ | ((name: string) => Promise<Pick<ComputerSession, "id" | "placement">>)
68
+ | undefined;
69
+ /** Navigate to the exact linked ComputerSession; never a lookalike desktop. */
70
+ onOpenComputer?: ((computerSessionId: string) => void) | undefined;
71
+ };
72
+
73
+ type BrowserSelection = { sessionId: string; pinned: boolean } | null;
74
+ type BrowserLaunchChoice =
75
+ | { kind: "clean" }
76
+ | { kind: "profile"; identityId: string }
77
+ | { kind: "attached"; device: AttachedBrowserDevice };
78
+ type PointerStart = {
79
+ x: number;
80
+ y: number;
81
+ pointerId: number;
82
+ frame: BrowserFrame;
83
+ };
84
+ type BrowserResumeAttempt = {
85
+ operationId: string;
86
+ running: boolean;
87
+ terminal: boolean;
88
+ error: Error | null;
89
+ };
90
+
91
+ /**
92
+ * Complete browser-native session surface: workspace discovery, agent relevance,
93
+ * browser/tab switching, live frames, semantic fallback, direct human input,
94
+ * address navigation, diagnostics, and reconnect. Closing it never closes the browser.
95
+ */
96
+ export function BrowserViewer({
97
+ sessionId,
98
+ enabled = true,
99
+ className,
100
+ onNotify,
101
+ webSocketFactory,
102
+ renderEmpty,
103
+ createLinkedComputer,
104
+ onOpenComputer,
105
+ ...override
106
+ }: BrowserViewerProps) {
107
+ const registry = useBrowserSessions({ ...override, sessionId, enabled });
108
+ const attached = useAttachedBrowsers({ ...override, enabled });
109
+ const profiles = useBrowserIdentities({ ...override, enabled });
110
+ const createRegistryBrowser = registry.create;
111
+ const loadProfileRevisions = profiles.revisions;
112
+ const liveSessions = useMemo(
113
+ () => registry.sessions.filter((session) => isLiveBrowser(session)),
114
+ [registry.sessions],
115
+ );
116
+ const relevant = useMemo(
117
+ () => registry.relevantSessions.filter((session) => isLiveBrowser(session)),
118
+ [registry.relevantSessions],
119
+ );
120
+ const [selection, setSelection] = useState<BrowserSelection>(null);
121
+ const [creating, setCreating] = useState(false);
122
+ const [savingProfile, setSavingProfile] = useState(false);
123
+ const [baseRevisionOrdinal, setBaseRevisionOrdinal] = useState<number | null>(null);
124
+ const [resumeAttempt, setResumeAttempt] = useState<{
125
+ sessionId: string;
126
+ attempt: BrowserResumeAttempt;
127
+ } | null>(null);
128
+ const resumeAttemptsRef = useRef(new Map<string, BrowserResumeAttempt>());
129
+ const previousSessionIdRef = useRef(sessionId);
130
+
131
+ const notifyError = useCallback(
132
+ (cause: unknown, fallback: string) => {
133
+ const message = cause instanceof Error ? cause.message : fallback;
134
+ onNotify?.({ kind: "error", message });
135
+ },
136
+ [onNotify],
137
+ );
138
+
139
+ useEffect(() => {
140
+ if (previousSessionIdRef.current !== sessionId) {
141
+ previousSessionIdRef.current = sessionId;
142
+ setSelection(null);
143
+ }
144
+ }, [sessionId]);
145
+
146
+ useEffect(() => {
147
+ const selectedStillLive = liveSessions.some((session) => session.id === selection?.sessionId);
148
+ const preferred = relevant[0] ?? liveSessions[0] ?? null;
149
+ if (!preferred) {
150
+ if (selection) setSelection(null);
151
+ return;
152
+ }
153
+ if (!selectedStillLive || !selection?.pinned) {
154
+ if (selection?.sessionId !== preferred.id || selection.pinned) {
155
+ setSelection({ sessionId: preferred.id, pinned: false });
156
+ }
157
+ }
158
+ }, [liveSessions, relevant, selection]);
159
+
160
+ const selectedRegistrySession = useMemo(
161
+ () => liveSessions.find((session) => session.id === selection?.sessionId) ?? null,
162
+ [liveSessions, selection?.sessionId],
163
+ );
164
+ const controllerReady = selectedRegistrySession?.lifecycle === "active";
165
+ const resumeSession = registry.resume;
166
+
167
+ const wakeBrowser = useCallback(
168
+ async (session: BrowserSession, retry = false): Promise<void> => {
169
+ const current = resumeAttemptsRef.current.get(session.id);
170
+ if (current?.running || (!retry && current)) return;
171
+ const operationId = current && !current.terminal ? current.operationId : crypto.randomUUID();
172
+ const attempt: BrowserResumeAttempt = {
173
+ operationId,
174
+ running: true,
175
+ terminal: false,
176
+ error: null,
177
+ };
178
+ resumeAttemptsRef.current.set(session.id, attempt);
179
+ setResumeAttempt({ sessionId: session.id, attempt });
180
+ try {
181
+ const response = await resumeSession(session.id, operationId);
182
+ if (response.operation.state !== "completed") {
183
+ throw Object.assign(
184
+ new Error(response.operation.error?.message ?? "Browser could not reopen."),
185
+ { terminal: true },
186
+ );
187
+ }
188
+ resumeAttemptsRef.current.delete(session.id);
189
+ setResumeAttempt((visible) => (visible?.sessionId === session.id ? null : visible));
190
+ } catch (cause) {
191
+ const error = cause instanceof Error ? cause : new Error(String(cause));
192
+ const failed: BrowserResumeAttempt = {
193
+ operationId,
194
+ running: false,
195
+ terminal:
196
+ typeof cause === "object" && cause !== null && "terminal" in cause
197
+ ? cause.terminal === true
198
+ : false,
199
+ error,
200
+ };
201
+ resumeAttemptsRef.current.set(session.id, failed);
202
+ setResumeAttempt({ sessionId: session.id, attempt: failed });
203
+ notifyError(error, "Could not reopen this browser.");
204
+ }
205
+ },
206
+ [notifyError, resumeSession],
207
+ );
208
+
209
+ useEffect(() => {
210
+ if (!selectedRegistrySession) return;
211
+ if (selectedRegistrySession.lifecycle === "active") {
212
+ resumeAttemptsRef.current.delete(selectedRegistrySession.id);
213
+ setResumeAttempt((visible) =>
214
+ visible?.sessionId === selectedRegistrySession.id ? null : visible,
215
+ );
216
+ return;
217
+ }
218
+ if (selectedRegistrySession.lifecycle === "suspended") {
219
+ void wakeBrowser(selectedRegistrySession);
220
+ }
221
+ }, [selectedRegistrySession, wakeBrowser]);
222
+
223
+ const browser = useBrowserSession({
224
+ ...override,
225
+ browserSessionId: selection?.sessionId ?? null,
226
+ enabled: enabled && selection !== null && controllerReady,
227
+ });
228
+ const frames = useBrowserFrameStream({
229
+ ...override,
230
+ browserSessionId: selection?.sessionId ?? null,
231
+ targetId: browser.selectedTarget?.id ?? null,
232
+ enabled: enabled && selection !== null && controllerReady && browser.selectedTarget !== null,
233
+ stream: { format: "jpeg", quality: 76, maxWidth: 1_920, maxHeight: 1_200 },
234
+ ...(webSocketFactory ? { webSocketFactory } : {}),
235
+ });
236
+ const displayedFrame = frameMatchesObservation(frames.frame, browser.observation)
237
+ ? frames.frame
238
+ : null;
239
+ const displayConnectionState =
240
+ frames.state === "live" && !displayedFrame ? "connecting" : frames.state;
241
+ const selectedProfile = useMemo(
242
+ () =>
243
+ profiles.identities.find(
244
+ (identity) =>
245
+ identity.id === (browser.session?.identityId ?? selectedRegistrySession?.identityId),
246
+ ) ?? null,
247
+ [browser.session?.identityId, profiles.identities, selectedRegistrySession?.identityId],
248
+ );
249
+
250
+ useEffect(() => {
251
+ const identityId = browser.session?.identityId ?? selectedRegistrySession?.identityId;
252
+ const revisionId = browser.session?.baseRevisionId ?? selectedRegistrySession?.baseRevisionId;
253
+ if (!identityId || !revisionId) {
254
+ setBaseRevisionOrdinal(null);
255
+ return;
256
+ }
257
+ let disposed = false;
258
+ setBaseRevisionOrdinal(null);
259
+ void loadProfileRevisions(identityId)
260
+ .then((response) => {
261
+ if (!disposed) {
262
+ setBaseRevisionOrdinal(
263
+ response.revisions.find((revision) => revision.id === revisionId)?.ordinal ?? null,
264
+ );
265
+ }
266
+ })
267
+ .catch(() => undefined);
268
+ return () => {
269
+ disposed = true;
270
+ };
271
+ }, [
272
+ browser.session?.baseRevisionId,
273
+ browser.session?.identityId,
274
+ loadProfileRevisions,
275
+ selectedRegistrySession?.baseRevisionId,
276
+ selectedRegistrySession?.identityId,
277
+ ]);
278
+
279
+ const createBrowser = useCallback(
280
+ (choice: BrowserLaunchChoice = { kind: "clean" }) => {
281
+ if (creating) return;
282
+ const identity =
283
+ choice.kind === "profile"
284
+ ? profiles.identities.find((candidate) => candidate.id === choice.identityId)
285
+ : null;
286
+ const device = choice.kind === "attached" ? choice.device : null;
287
+ const browserName =
288
+ device?.profileLabel ?? device?.name ?? (identity ? `${identity.name} browser` : "Browser");
289
+ setCreating(true);
290
+ void (async () => {
291
+ const linkedComputer =
292
+ !device && createLinkedComputer
293
+ ? await createLinkedComputer(`${browserName} computer`)
294
+ : null;
295
+ const response = await createRegistryBrowser({
296
+ sessionId,
297
+ name: browserName,
298
+ ...(device
299
+ ? {
300
+ headless: false,
301
+ placement: {
302
+ kind: "attached_device" as const,
303
+ deviceId: device.id,
304
+ },
305
+ }
306
+ : {}),
307
+ ...(linkedComputer
308
+ ? {
309
+ headless: false,
310
+ linkedComputerSessionId: linkedComputer.id,
311
+ placement: linkedComputer.placement,
312
+ }
313
+ : {}),
314
+ ...(identity ? { identityId: identity.id } : {}),
315
+ });
316
+ setSelection({ sessionId: response.session.id, pinned: true });
317
+ })()
318
+ .catch((cause) => notifyError(cause, "Could not open a browser."))
319
+ .finally(() => setCreating(false));
320
+ },
321
+ [
322
+ createLinkedComputer,
323
+ createRegistryBrowser,
324
+ creating,
325
+ notifyError,
326
+ profiles.identities,
327
+ sessionId,
328
+ ],
329
+ );
330
+
331
+ const saveProfileVersion = useCallback(
332
+ async (newProfileName?: string): Promise<boolean> => {
333
+ const session = browser.session;
334
+ if (!session || savingProfile || !session.capabilities.identityPublication) return false;
335
+ setSavingProfile(true);
336
+ try {
337
+ let identity = selectedProfile;
338
+ if (!identity) {
339
+ const name = newProfileName?.trim() ?? "";
340
+ if (!name) return false;
341
+ identity = (await profiles.create({ name })).identity;
342
+ }
343
+ const response = await profiles.publish(session.id, {
344
+ identityId: identity.id,
345
+ expectedHeadGeneration: identity.headGeneration,
346
+ advanceDefault: true,
347
+ });
348
+ setBaseRevisionOrdinal(response.revision.ordinal);
349
+ await Promise.all([browser.refresh(), registry.refresh()]);
350
+ onNotify?.({
351
+ kind: "info",
352
+ message:
353
+ response.outcome === "saved_as_default"
354
+ ? `${response.identity.name} version ${response.revision.ordinal} saved for future browsers.`
355
+ : `${response.identity.name} version ${response.revision.ordinal} saved. Another version remains the default.`,
356
+ });
357
+ return true;
358
+ } catch (cause) {
359
+ notifyError(cause, "Could not save this browser profile.");
360
+ return false;
361
+ } finally {
362
+ setSavingProfile(false);
363
+ }
364
+ },
365
+ [browser, notifyError, onNotify, profiles, registry, savingProfile, selectedProfile],
366
+ );
367
+
368
+ if (!enabled) return null;
369
+ if (registry.loading && liveSessions.length === 0) {
370
+ return (
371
+ <BrowserNotice
372
+ icon={<LoaderCircleIcon className="animate-spin" />}
373
+ text="Finding browsers…"
374
+ {...(className ? { className } : {})}
375
+ />
376
+ );
377
+ }
378
+ if (liveSessions.length === 0) {
379
+ if (renderEmpty) return <>{renderEmpty(() => createBrowser(), creating)}</>;
380
+ return (
381
+ <div
382
+ className={cn("flex h-full min-h-0 items-center justify-center bg-og-bg p-6", className)}
383
+ >
384
+ <div className="max-w-sm text-center">
385
+ <span className="mx-auto grid size-10 place-items-center rounded-og-md border border-og-border bg-og-surface-1 text-og-muted">
386
+ <Globe2Icon className="size-4.5" />
387
+ </span>
388
+ <p className="mt-3 text-og-menu font-medium text-og-fg">No browser open</p>
389
+ <p className="mt-1 text-og-control leading-5 text-og-muted">
390
+ A browser appears here when this agent—or another agent in the workspace—opens one.
391
+ </p>
392
+ <BrowserLaunchMenu
393
+ attachedDevices={attached.devices}
394
+ identities={profiles.identities}
395
+ creating={creating}
396
+ onCreate={createBrowser}
397
+ prominent
398
+ />
399
+ {registry.error ? (
400
+ <p className="mt-3 text-og-control text-og-status-error">{registry.error.message}</p>
401
+ ) : null}
402
+ </div>
403
+ </div>
404
+ );
405
+ }
406
+
407
+ return (
408
+ <div className={cn("flex h-full min-h-0 flex-col overflow-hidden bg-og-bg", className)}>
409
+ <BrowserToolbar
410
+ sessions={liveSessions}
411
+ relevantSessionIds={new Set(relevant.map((session) => session.id))}
412
+ selectedSessionId={selection?.sessionId ?? null}
413
+ identities={profiles.identities}
414
+ attachedDevices={attached.devices}
415
+ selectedProfile={selectedProfile}
416
+ baseRevisionOrdinal={baseRevisionOrdinal}
417
+ creating={creating}
418
+ savingProfile={savingProfile}
419
+ refreshing={registry.refreshing || attached.refreshing}
420
+ onSelect={(browserSessionId) => setSelection({ sessionId: browserSessionId, pinned: true })}
421
+ onFollow={() => {
422
+ const preferred = relevant[0];
423
+ if (preferred) setSelection({ sessionId: preferred.id, pinned: false });
424
+ }}
425
+ onCreate={createBrowser}
426
+ onSaveProfile={saveProfileVersion}
427
+ onRefresh={() => void Promise.all([registry.refresh(), attached.refresh()])}
428
+ />
429
+ {selectedRegistrySession && !controllerReady ? (
430
+ <BrowserLifecyclePanel
431
+ session={selectedRegistrySession}
432
+ attempt={
433
+ resumeAttempt?.sessionId === selectedRegistrySession.id ? resumeAttempt.attempt : null
434
+ }
435
+ onResume={() => void wakeBrowser(selectedRegistrySession, true)}
436
+ />
437
+ ) : (
438
+ <>
439
+ <BrowserTabs
440
+ targets={browser.targets}
441
+ selectedTargetId={browser.selectedTarget?.id ?? null}
442
+ mutating={browser.mutating || savingProfile}
443
+ onSelect={(targetId) =>
444
+ void browser
445
+ .selectTarget(targetId)
446
+ .catch((cause) => notifyError(cause, "Could not switch tabs."))
447
+ }
448
+ onClose={(targetId) =>
449
+ void browser
450
+ .closeTarget(targetId)
451
+ .catch((cause) => notifyError(cause, "Could not close the tab."))
452
+ }
453
+ onOpen={() =>
454
+ void browser
455
+ .openTarget()
456
+ .catch((cause) => notifyError(cause, "Could not open a tab."))
457
+ }
458
+ />
459
+ <BrowserAddressBar
460
+ target={browser.selectedTarget}
461
+ loading={browser.loading || browser.mutating || savingProfile}
462
+ onNavigate={(url) =>
463
+ void browser
464
+ .act({ type: "navigate", url })
465
+ .catch((cause) => notifyError(cause, "Could not navigate."))
466
+ }
467
+ onReload={() => {
468
+ const url = browser.selectedTarget?.url;
469
+ if (url)
470
+ void browser
471
+ .act({ type: "navigate", url })
472
+ .catch((cause) => notifyError(cause, "Could not reload."));
473
+ }}
474
+ />
475
+ <BrowserViewport
476
+ frame={displayedFrame}
477
+ connectionState={displayConnectionState}
478
+ connectionError={frames.error}
479
+ observation={browser.observation}
480
+ mutating={browser.mutating || savingProfile}
481
+ activityLabel={savingProfile ? "Saving browser version…" : undefined}
482
+ onAction={(action, frame) =>
483
+ (frame ? browser.actFromFrame(action, frame) : browser.act(action)).then(
484
+ () => undefined,
485
+ )
486
+ }
487
+ onReconnect={frames.reconnect}
488
+ onError={(cause) => notifyError(cause, "Browser input failed.")}
489
+ />
490
+ <BrowserStatusBar
491
+ session={browser.session}
492
+ profile={selectedProfile}
493
+ target={browser.selectedTarget}
494
+ observation={browser.observation}
495
+ connectionState={displayConnectionState}
496
+ refreshing={registry.refreshing}
497
+ onOpenComputer={
498
+ browser.session?.linkedComputerSessionId && onOpenComputer
499
+ ? () => {
500
+ const computerSessionId = browser.session?.linkedComputerSessionId;
501
+ if (computerSessionId) onOpenComputer(computerSessionId);
502
+ }
503
+ : undefined
504
+ }
505
+ onDiagnostics={() =>
506
+ void browser
507
+ .diagnostics({ limit: 100 })
508
+ .then((batch) => {
509
+ const errors = batch.entries.filter((entry) => entry.level === "error").length;
510
+ onNotify?.({
511
+ kind: "info",
512
+ message: batch.entries.length
513
+ ? `${batch.entries.length} browser diagnostic${batch.entries.length === 1 ? "" : "s"} (${errors} errors).`
514
+ : "No browser diagnostics.",
515
+ });
516
+ })
517
+ .catch((cause) => notifyError(cause, "Could not load browser diagnostics."))
518
+ }
519
+ />
520
+ </>
521
+ )}
522
+ </div>
523
+ );
524
+ }
525
+
526
+ function BrowserLifecyclePanel(props: {
527
+ session: BrowserSession;
528
+ attempt: BrowserResumeAttempt | null;
529
+ onResume: () => void;
530
+ }) {
531
+ const failed = props.attempt?.error ?? null;
532
+ const label = failed
533
+ ? "Browser could not reopen"
534
+ : props.session.lifecycle === "suspending"
535
+ ? "Saving browser state…"
536
+ : props.session.lifecycle === "restoring" || props.attempt?.running
537
+ ? "Opening browser…"
538
+ : props.session.lifecycle === "starting"
539
+ ? "Starting browser…"
540
+ : "Browser is sleeping";
541
+ const detail = failed
542
+ ? failed.message
543
+ : props.session.lifecycle === "suspended"
544
+ ? "Its private state is saved. Opening it creates a fresh, isolated browser process."
545
+ : "The browser will reconnect here when it is ready.";
546
+ return (
547
+ <div className="grid min-h-0 flex-1 place-items-center bg-og-bg p-6 text-center">
548
+ <div className="max-w-sm">
549
+ <span className="mx-auto grid size-10 place-items-center rounded-og-md border border-og-border bg-og-surface-1 text-og-muted">
550
+ {failed ? (
551
+ <CircleAlertIcon className="size-4.5 text-og-status-error" />
552
+ ) : (
553
+ <LoaderCircleIcon className="size-4.5 animate-spin" />
554
+ )}
555
+ </span>
556
+ <p className="mt-3 text-og-menu font-medium text-og-fg">{label}</p>
557
+ <p className="mt-1 text-og-control leading-5 text-og-muted">{detail}</p>
558
+ {failed || (props.session.lifecycle === "suspended" && !props.attempt?.running) ? (
559
+ <button
560
+ type="button"
561
+ onClick={props.onResume}
562
+ className="mt-4 inline-flex h-8 items-center rounded-og-sm border border-og-border bg-og-surface-1 px-3 text-og-control font-medium text-og-fg transition hover:bg-og-surface-2"
563
+ >
564
+ Open browser
565
+ </button>
566
+ ) : null}
567
+ </div>
568
+ </div>
569
+ );
570
+ }
571
+
572
+ function BrowserToolbar(props: {
573
+ sessions: BrowserSession[];
574
+ relevantSessionIds: Set<string>;
575
+ selectedSessionId: string | null;
576
+ attachedDevices: AttachedBrowserDevice[];
577
+ identities: BrowserIdentity[];
578
+ selectedProfile: BrowserIdentity | null;
579
+ baseRevisionOrdinal: number | null;
580
+ creating: boolean;
581
+ savingProfile: boolean;
582
+ refreshing: boolean;
583
+ onSelect: (id: string) => void;
584
+ onFollow: () => void;
585
+ onCreate: (choice?: BrowserLaunchChoice) => void;
586
+ onSaveProfile: (newProfileName?: string) => Promise<boolean>;
587
+ onRefresh: () => void;
588
+ }) {
589
+ const detailsRef = useRef<HTMLDetailsElement | null>(null);
590
+ const selected = props.sessions.find((session) => session.id === props.selectedSessionId);
591
+ const current = props.sessions.filter((session) => props.relevantSessionIds.has(session.id));
592
+ const others = props.sessions.filter((session) => !props.relevantSessionIds.has(session.id));
593
+ const choose = (id: string) => {
594
+ props.onSelect(id);
595
+ detailsRef.current?.removeAttribute("open");
596
+ };
597
+ return (
598
+ <div className="flex h-10 shrink-0 items-center gap-1.5 border-b border-og-border bg-og-surface-0 px-2">
599
+ <details ref={detailsRef} className="relative min-w-0">
600
+ <summary className="flex h-7 max-w-52 cursor-pointer list-none items-center gap-2 rounded-og-sm px-2 text-og-control text-og-fg transition hover:bg-og-surface-2 [&::-webkit-details-marker]:hidden">
601
+ <Globe2Icon className="size-3.5 shrink-0 text-og-muted" />
602
+ <span className="truncate font-medium">{selected?.name ?? "Browser"}</span>
603
+ <ChevronDownIcon className="size-3 shrink-0 text-og-subtle" />
604
+ </summary>
605
+ <div className="absolute left-0 top-8 z-30 w-72 overflow-hidden rounded-og-md border border-og-border bg-og-surface-1 p-1 shadow-xl">
606
+ <BrowserSessionGroup
607
+ label="Current agent"
608
+ sessions={current}
609
+ identities={props.identities}
610
+ selectedId={props.selectedSessionId}
611
+ onSelect={choose}
612
+ />
613
+ <BrowserSessionGroup
614
+ label="Other agents"
615
+ sessions={others}
616
+ identities={props.identities}
617
+ selectedId={props.selectedSessionId}
618
+ onSelect={choose}
619
+ />
620
+ <div className="mt-1 flex gap-1 border-t border-og-border pt-1">
621
+ {current.length > 0 ? (
622
+ <MenuButton onClick={props.onFollow}>Follow agent</MenuButton>
623
+ ) : null}
624
+ <MenuButton onClick={() => props.onCreate()} disabled={props.creating}>
625
+ <PlusIcon className="size-3.5" /> New browser
626
+ </MenuButton>
627
+ </div>
628
+ </div>
629
+ </details>
630
+ <span className="min-w-0 flex-1 truncate text-og-xs text-og-subtle">
631
+ {placementLabel(selected)}
632
+ </span>
633
+ <BrowserProfileMenu
634
+ session={selected ?? null}
635
+ identity={props.selectedProfile}
636
+ baseRevisionOrdinal={props.baseRevisionOrdinal}
637
+ saving={props.savingProfile}
638
+ onSave={props.onSaveProfile}
639
+ />
640
+ <button
641
+ type="button"
642
+ onClick={props.onRefresh}
643
+ className="grid size-7 place-items-center rounded-og-sm text-og-muted transition hover:bg-og-surface-2 hover:text-og-fg"
644
+ aria-label="Refresh browsers"
645
+ >
646
+ <RefreshCwIcon className={cn("size-3.5", props.refreshing && "animate-spin")} />
647
+ </button>
648
+ <BrowserLaunchMenu
649
+ attachedDevices={props.attachedDevices}
650
+ identities={props.identities}
651
+ creating={props.creating}
652
+ onCreate={props.onCreate}
653
+ />
654
+ </div>
655
+ );
656
+ }
657
+
658
+ function BrowserSessionGroup(props: {
659
+ label: string;
660
+ sessions: BrowserSession[];
661
+ identities: BrowserIdentity[];
662
+ selectedId: string | null;
663
+ onSelect: (id: string) => void;
664
+ }) {
665
+ if (props.sessions.length === 0) return null;
666
+ return (
667
+ <div className="py-1">
668
+ <p className="px-2 pb-1 text-[10px] font-medium uppercase tracking-[0.12em] text-og-subtle">
669
+ {props.label}
670
+ </p>
671
+ {props.sessions.map((session) => {
672
+ const identity = props.identities.find((candidate) => candidate.id === session.identityId);
673
+ return (
674
+ <button
675
+ key={session.id}
676
+ type="button"
677
+ onClick={() => props.onSelect(session.id)}
678
+ className={cn(
679
+ "flex w-full items-center gap-2 rounded-og-sm px-2 py-1.5 text-left transition hover:bg-og-surface-2",
680
+ session.id === props.selectedId && "bg-og-surface-2",
681
+ )}
682
+ >
683
+ <span
684
+ className={cn(
685
+ "size-1.5 rounded-full",
686
+ session.lifecycle === "active" ? "bg-og-status-running" : "bg-og-muted",
687
+ )}
688
+ />
689
+ <span className="min-w-0 flex-1">
690
+ <span className="block truncate text-og-control text-og-fg">{session.name}</span>
691
+ <span className="block truncate text-og-xs text-og-subtle">
692
+ {identity ? `${identity.name} · ` : ""}
693
+ {placementLabel(session)}
694
+ </span>
695
+ </span>
696
+ </button>
697
+ );
698
+ })}
699
+ </div>
700
+ );
701
+ }
702
+
703
+ function BrowserLaunchMenu(props: {
704
+ attachedDevices: AttachedBrowserDevice[];
705
+ identities: BrowserIdentity[];
706
+ creating: boolean;
707
+ onCreate: (choice?: BrowserLaunchChoice) => void;
708
+ prominent?: boolean;
709
+ }) {
710
+ const detailsRef = useRef<HTMLDetailsElement | null>(null);
711
+ const choose = (choice?: BrowserLaunchChoice) => {
712
+ detailsRef.current?.removeAttribute("open");
713
+ props.onCreate(choice);
714
+ };
715
+ return (
716
+ <details ref={detailsRef} className={cn("relative", props.prominent && "mt-4 inline-block")}>
717
+ <summary
718
+ onClick={(event) => {
719
+ if (props.creating) event.preventDefault();
720
+ }}
721
+ className={cn(
722
+ "cursor-pointer list-none items-center justify-center gap-1.5 text-og-muted transition hover:bg-og-surface-2 hover:text-og-fg [&::-webkit-details-marker]:hidden",
723
+ props.prominent
724
+ ? "inline-flex h-8 rounded-og-sm border border-og-border bg-og-surface-1 px-3 text-og-control font-medium text-og-fg"
725
+ : "grid size-7 place-items-center rounded-og-sm",
726
+ props.creating && "pointer-events-none opacity-50",
727
+ )}
728
+ aria-label="New browser"
729
+ >
730
+ {props.creating ? (
731
+ <LoaderCircleIcon className="size-3.5 animate-spin" />
732
+ ) : (
733
+ <PlusIcon className="size-3.5" />
734
+ )}
735
+ {props.prominent ? "New browser" : null}
736
+ {props.prominent ? <ChevronDownIcon className="size-3" /> : null}
737
+ </summary>
738
+ <div
739
+ className={cn(
740
+ "absolute z-40 w-72 overflow-hidden rounded-og-md border border-og-border bg-og-surface-1 p-1 text-left shadow-xl",
741
+ props.prominent ? "left-1/2 top-10 -translate-x-1/2" : "right-0 top-8",
742
+ )}
743
+ >
744
+ {props.attachedDevices.length > 0 ? (
745
+ <div className="pb-1">
746
+ <p className="px-2 pb-1 pt-1 text-[10px] font-medium uppercase tracking-[0.12em] text-og-subtle">
747
+ Connected Chrome
748
+ </p>
749
+ {props.attachedDevices.map((device) => (
750
+ <button
751
+ key={device.id}
752
+ type="button"
753
+ onClick={() => choose({ kind: "attached", device })}
754
+ className="flex w-full items-center gap-2 rounded-og-sm px-2 py-2 text-left transition hover:bg-og-surface-2"
755
+ >
756
+ <MonitorIcon className="size-3.5 shrink-0 text-og-muted" />
757
+ <span className="min-w-0 flex-1">
758
+ <span className="block truncate text-og-control text-og-fg">
759
+ {device.profileLabel ?? device.name}
760
+ </span>
761
+ <span className="block truncate text-og-xs text-og-subtle">
762
+ {device.browserName} · {device.tabCount} open{" "}
763
+ {device.tabCount === 1 ? "tab" : "tabs"}
764
+ </span>
765
+ </span>
766
+ <span className="size-1.5 shrink-0 rounded-full bg-og-status-running" />
767
+ </button>
768
+ ))}
769
+ </div>
770
+ ) : null}
771
+ <p
772
+ className={cn(
773
+ "px-2 pb-1 pt-1 text-[10px] font-medium uppercase tracking-[0.12em] text-og-subtle",
774
+ props.attachedDevices.length > 0 && "border-t border-og-border",
775
+ )}
776
+ >
777
+ Open isolated browser
778
+ </p>
779
+ <button
780
+ type="button"
781
+ onClick={() => choose({ kind: "clean" })}
782
+ className="flex w-full items-center gap-2 rounded-og-sm px-2 py-2 text-left transition hover:bg-og-surface-2"
783
+ >
784
+ <Globe2Icon className="size-3.5 text-og-muted" />
785
+ <span>
786
+ <span className="block text-og-control text-og-fg">Clean browser</span>
787
+ <span className="block text-og-xs text-og-subtle">No saved profile</span>
788
+ </span>
789
+ </button>
790
+ {props.identities.length > 0 ? (
791
+ <div className="mt-1 border-t border-og-border pt-1">
792
+ {props.identities.map((identity) => (
793
+ <button
794
+ key={identity.id}
795
+ type="button"
796
+ onClick={() => choose({ kind: "profile", identityId: identity.id })}
797
+ className="flex w-full items-center gap-2 rounded-og-sm px-2 py-2 text-left transition hover:bg-og-surface-2"
798
+ >
799
+ <UserRoundIcon className="size-3.5 text-og-muted" />
800
+ <span className="min-w-0">
801
+ <span className="block truncate text-og-control text-og-fg">{identity.name}</span>
802
+ <span className="block text-og-xs text-og-subtle">
803
+ {identity.defaultRevisionId
804
+ ? `${identity.revisionCount} saved version${identity.revisionCount === 1 ? "" : "s"}`
805
+ : "Ready for first sign-in"}
806
+ </span>
807
+ </span>
808
+ </button>
809
+ ))}
810
+ </div>
811
+ ) : null}
812
+ </div>
813
+ </details>
814
+ );
815
+ }
816
+
817
+ function BrowserProfileMenu(props: {
818
+ session: BrowserSession | null;
819
+ identity: BrowserIdentity | null;
820
+ baseRevisionOrdinal: number | null;
821
+ saving: boolean;
822
+ onSave: (newProfileName?: string) => Promise<boolean>;
823
+ }) {
824
+ const detailsRef = useRef<HTMLDetailsElement | null>(null);
825
+ const nameInputId = useId();
826
+ const [name, setName] = useState("");
827
+ const attached = props.session?.placement.kind === "attached_device";
828
+ const canSave = props.session?.capabilities.identityPublication ?? false;
829
+ const save = async (newProfileName?: string) => {
830
+ if (await props.onSave(newProfileName)) {
831
+ setName("");
832
+ detailsRef.current?.removeAttribute("open");
833
+ }
834
+ };
835
+ const versionLabel = attached
836
+ ? "live"
837
+ : props.identity
838
+ ? props.baseRevisionOrdinal
839
+ ? `v${props.baseRevisionOrdinal}`
840
+ : props.session?.baseRevisionId
841
+ ? "saved"
842
+ : "unsaved"
843
+ : "temporary";
844
+ return (
845
+ <details ref={detailsRef} className="relative min-w-0">
846
+ <summary className="flex h-7 max-w-40 cursor-pointer list-none items-center gap-1.5 rounded-og-sm px-2 text-og-xs text-og-muted transition hover:bg-og-surface-2 hover:text-og-fg [&::-webkit-details-marker]:hidden">
847
+ {props.saving ? (
848
+ <LoaderCircleIcon className="size-3 animate-spin" />
849
+ ) : attached ? (
850
+ <MonitorIcon className="size-3" />
851
+ ) : (
852
+ <UserRoundIcon className="size-3" />
853
+ )}
854
+ <span className="truncate">
855
+ {attached ? props.session?.name : (props.identity?.name ?? "Temporary")}
856
+ </span>
857
+ {attached || props.identity ? (
858
+ <span className="shrink-0 text-og-subtle">· {versionLabel}</span>
859
+ ) : null}
860
+ <ChevronDownIcon className="size-3 shrink-0 text-og-subtle" />
861
+ </summary>
862
+ <div className="absolute right-0 top-8 z-40 w-72 rounded-og-md border border-og-border bg-og-surface-1 p-3 shadow-xl">
863
+ <div className="flex items-start gap-2">
864
+ <span className="grid size-7 shrink-0 place-items-center rounded-og-sm bg-og-surface-2 text-og-muted">
865
+ {attached ? (
866
+ <MonitorIcon className="size-3.5" />
867
+ ) : (
868
+ <UserRoundIcon className="size-3.5" />
869
+ )}
870
+ </span>
871
+ <div className="min-w-0">
872
+ <p className="truncate text-og-control font-medium text-og-fg">
873
+ {attached ? props.session?.name : (props.identity?.name ?? "Temporary browser")}
874
+ </p>
875
+ <p className="mt-0.5 text-og-xs leading-4 text-og-subtle">
876
+ {attached
877
+ ? "This session drives the existing Chrome profile and its current login state."
878
+ : props.identity
879
+ ? props.baseRevisionOrdinal
880
+ ? `Started from version ${props.baseRevisionOrdinal}.`
881
+ : "Not saved yet."
882
+ : "This browser is not reusable yet."}
883
+ </p>
884
+ </div>
885
+ </div>
886
+ {canSave ? (
887
+ props.identity ? (
888
+ <button
889
+ type="button"
890
+ onClick={() => void save()}
891
+ disabled={props.saving}
892
+ className="mt-3 flex h-8 w-full items-center justify-center gap-1.5 rounded-og-sm border border-og-border bg-og-surface-2 px-3 text-og-control font-medium text-og-fg transition hover:bg-og-surface-3 disabled:opacity-50"
893
+ >
894
+ {props.saving ? (
895
+ <LoaderCircleIcon className="size-3.5 animate-spin" />
896
+ ) : (
897
+ <SaveIcon className="size-3.5" />
898
+ )}
899
+ {props.saving ? "Saving browser version…" : "Save new version"}
900
+ </button>
901
+ ) : (
902
+ <form
903
+ className="mt-3"
904
+ onSubmit={(event) => {
905
+ event.preventDefault();
906
+ if (name.trim()) void save(name);
907
+ }}
908
+ >
909
+ <label className="text-og-xs font-medium text-og-muted" htmlFor={nameInputId}>
910
+ Profile name
911
+ </label>
912
+ <div className="mt-1 flex gap-1.5">
913
+ <input
914
+ id={nameInputId}
915
+ value={name}
916
+ onInput={(event) => setName(event.currentTarget.value)}
917
+ maxLength={200}
918
+ placeholder="Work"
919
+ className="h-8 min-w-0 flex-1 rounded-og-sm border border-og-border bg-og-bg px-2 text-og-control text-og-fg placeholder:text-og-subtle focus:border-og-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-og-accent"
920
+ />
921
+ <button
922
+ type="submit"
923
+ disabled={!name.trim() || props.saving}
924
+ className="inline-flex h-8 items-center gap-1 rounded-og-sm bg-og-accent px-2.5 text-og-control font-medium text-white transition hover:opacity-90 disabled:opacity-40"
925
+ >
926
+ {props.saving ? (
927
+ <LoaderCircleIcon className="size-3.5 animate-spin" />
928
+ ) : (
929
+ <SaveIcon className="size-3.5" />
930
+ )}
931
+ Save
932
+ </button>
933
+ </div>
934
+ </form>
935
+ )
936
+ ) : (
937
+ <p className="mt-3 rounded-og-sm bg-og-surface-2 px-2.5 py-2 text-og-xs text-og-subtle">
938
+ {attached
939
+ ? "Chrome keeps this profile's state directly; OpenGeni does not copy it automatically."
940
+ : "This browser cannot save reusable profile state."}
941
+ </p>
942
+ )}
943
+ {canSave ? (
944
+ <p className="mt-2 text-[10px] leading-4 text-og-subtle">
945
+ Saving briefly restarts this browser. Other open browsers are unchanged.
946
+ </p>
947
+ ) : null}
948
+ </div>
949
+ </details>
950
+ );
951
+ }
952
+
953
+ function MenuButton(props: { children: ReactNode; onClick: () => void; disabled?: boolean }) {
954
+ return (
955
+ <button
956
+ type="button"
957
+ onClick={props.onClick}
958
+ disabled={props.disabled}
959
+ className="flex h-7 flex-1 items-center justify-center gap-1 rounded-og-sm px-2 text-og-control text-og-muted transition hover:bg-og-surface-2 hover:text-og-fg disabled:opacity-50"
960
+ >
961
+ {props.children}
962
+ </button>
963
+ );
964
+ }
965
+
966
+ function BrowserTabs(props: {
967
+ targets: BrowserTarget[];
968
+ selectedTargetId: string | null;
969
+ mutating: boolean;
970
+ onSelect: (id: string) => void;
971
+ onClose: (id: string) => void;
972
+ onOpen: () => void;
973
+ }) {
974
+ const pages = props.targets.filter((target) => target.kind === "page" || target.kind === "popup");
975
+ return (
976
+ <div className="flex h-9 shrink-0 items-end gap-px overflow-x-auto border-b border-og-border bg-og-surface-0 px-1 pt-1">
977
+ {pages.map((target) => (
978
+ <div
979
+ key={target.id}
980
+ className={cn(
981
+ "group flex h-8 min-w-24 max-w-48 items-center rounded-t-og-sm border border-b-0 border-transparent px-2",
982
+ target.id === props.selectedTargetId
983
+ ? "border-og-border bg-og-bg text-og-fg"
984
+ : "text-og-muted hover:bg-og-surface-1",
985
+ )}
986
+ >
987
+ <button
988
+ type="button"
989
+ onClick={() => props.onSelect(target.id)}
990
+ className="min-w-0 flex-1 truncate text-left text-og-control"
991
+ >
992
+ {target.title || shortUrl(target.url)}
993
+ </button>
994
+ <button
995
+ type="button"
996
+ onClick={() => props.onClose(target.id)}
997
+ disabled={props.mutating}
998
+ className="ml-1 grid size-4 shrink-0 place-items-center rounded opacity-0 transition hover:bg-og-surface-3 group-hover:opacity-100 focus:opacity-100 disabled:opacity-30"
999
+ aria-label={`Close ${target.title || "tab"}`}
1000
+ >
1001
+ <XIcon className="size-3" />
1002
+ </button>
1003
+ </div>
1004
+ ))}
1005
+ <button
1006
+ type="button"
1007
+ onClick={props.onOpen}
1008
+ disabled={props.mutating}
1009
+ className="mb-1 grid size-6 shrink-0 place-items-center rounded-og-sm text-og-muted transition hover:bg-og-surface-2 hover:text-og-fg disabled:opacity-40"
1010
+ aria-label="New tab"
1011
+ >
1012
+ <PlusIcon className="size-3.5" />
1013
+ </button>
1014
+ </div>
1015
+ );
1016
+ }
1017
+
1018
+ function BrowserAddressBar(props: {
1019
+ target: BrowserTarget | null;
1020
+ loading: boolean;
1021
+ onNavigate: (url: string) => void;
1022
+ onReload: () => void;
1023
+ }) {
1024
+ const [draft, setDraft] = useState(props.target?.url ?? "");
1025
+ const focusedRef = useRef(false);
1026
+ useEffect(() => {
1027
+ if (!focusedRef.current) setDraft(props.target?.url ?? "");
1028
+ }, [props.target?.id, props.target?.url]);
1029
+ const submit = (event: FormEvent) => {
1030
+ event.preventDefault();
1031
+ const normalized = normalizeBrowserAddress(draft);
1032
+ if (normalized) props.onNavigate(normalized);
1033
+ };
1034
+ return (
1035
+ <form
1036
+ onSubmit={submit}
1037
+ className="flex h-10 shrink-0 items-center gap-1.5 border-b border-og-border bg-og-bg px-2"
1038
+ >
1039
+ <button
1040
+ type="button"
1041
+ onClick={props.onReload}
1042
+ disabled={!props.target || props.loading}
1043
+ className="grid size-7 place-items-center rounded-og-sm text-og-muted transition hover:bg-og-surface-2 hover:text-og-fg disabled:opacity-35"
1044
+ aria-label="Reload"
1045
+ >
1046
+ <RefreshCwIcon className={cn("size-3.5", props.loading && "animate-spin")} />
1047
+ </button>
1048
+ <div className="flex h-7 min-w-0 flex-1 items-center gap-2 rounded-og-sm border border-og-border bg-og-surface-0 px-2 focus-within:border-og-accent/60">
1049
+ <Globe2Icon className="size-3 shrink-0 text-og-subtle" />
1050
+ <input
1051
+ value={draft}
1052
+ onChange={(event) => setDraft(event.target.value)}
1053
+ onFocus={() => {
1054
+ focusedRef.current = true;
1055
+ }}
1056
+ onBlur={() => {
1057
+ focusedRef.current = false;
1058
+ }}
1059
+ disabled={!props.target}
1060
+ className="min-w-0 flex-1 bg-transparent text-og-control text-og-fg placeholder:text-og-subtle focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-og-accent disabled:opacity-50"
1061
+ aria-label="Address"
1062
+ spellCheck={false}
1063
+ placeholder="Search or enter address"
1064
+ />
1065
+ </div>
1066
+ </form>
1067
+ );
1068
+ }
1069
+
1070
+ function BrowserViewport(props: {
1071
+ frame: BrowserFrame | null;
1072
+ connectionState: string;
1073
+ connectionError: Error | null;
1074
+ observation: ReturnType<typeof useBrowserSession>["observation"];
1075
+ mutating: boolean;
1076
+ activityLabel?: string | undefined;
1077
+ onAction: (action: BrowserAction, frame: BrowserFrame | null) => Promise<void>;
1078
+ onReconnect: () => void;
1079
+ onError: (cause: unknown) => void;
1080
+ }) {
1081
+ const canvasRef = useRef<HTMLCanvasElement | null>(null);
1082
+ const inputRef = useRef<HTMLTextAreaElement | null>(null);
1083
+ const pointerStartRef = useRef<PointerStart | null>(null);
1084
+ const clickTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
1085
+ const lastClickRef = useRef<{
1086
+ at: number;
1087
+ x: number;
1088
+ y: number;
1089
+ frame: BrowserFrame;
1090
+ } | null>(null);
1091
+ const wheelRef = useRef<{
1092
+ x: number;
1093
+ y: number;
1094
+ deltaX: number;
1095
+ deltaY: number;
1096
+ frame: BrowserFrame;
1097
+ timer: ReturnType<typeof setTimeout> | null;
1098
+ } | null>(null);
1099
+ const actionRef = useRef(props.onAction);
1100
+ const errorRef = useRef(props.onError);
1101
+ const actionTailRef = useRef<Promise<void>>(Promise.resolve());
1102
+ actionRef.current = props.onAction;
1103
+ errorRef.current = props.onError;
1104
+
1105
+ useEffect(() => {
1106
+ const frame = props.frame;
1107
+ const canvas = canvasRef.current;
1108
+ if (!frame || !canvas) return;
1109
+ let cancelled = false;
1110
+ let objectUrl: string | null = null;
1111
+ void (async () => {
1112
+ try {
1113
+ const blob = new Blob([frame.data.slice().buffer], {
1114
+ type: frame.mediaType,
1115
+ });
1116
+ if (typeof createImageBitmap === "function") {
1117
+ const bitmap = await createImageBitmap(blob);
1118
+ if (cancelled) {
1119
+ bitmap.close();
1120
+ return;
1121
+ }
1122
+ paintCanvas(canvas, bitmap, frame.width, frame.height);
1123
+ bitmap.close();
1124
+ return;
1125
+ }
1126
+ objectUrl = URL.createObjectURL(blob);
1127
+ const image = await loadImage(objectUrl);
1128
+ if (!cancelled) paintCanvas(canvas, image, frame.width, frame.height);
1129
+ } catch (cause) {
1130
+ if (!cancelled) errorRef.current(cause);
1131
+ }
1132
+ })();
1133
+ return () => {
1134
+ cancelled = true;
1135
+ if (objectUrl) URL.revokeObjectURL(objectUrl);
1136
+ };
1137
+ }, [props.frame]);
1138
+
1139
+ useEffect(
1140
+ () => () => {
1141
+ if (clickTimerRef.current) clearTimeout(clickTimerRef.current);
1142
+ if (wheelRef.current?.timer) clearTimeout(wheelRef.current.timer);
1143
+ },
1144
+ [],
1145
+ );
1146
+
1147
+ const enqueue = useCallback((action: BrowserAction, frame: BrowserFrame | null) => {
1148
+ actionTailRef.current = actionTailRef.current
1149
+ .catch(() => undefined)
1150
+ .then(async () => await actionRef.current(action, frame))
1151
+ .catch((cause) => errorRef.current(cause));
1152
+ }, []);
1153
+
1154
+ const point = useCallback(
1155
+ (frame: BrowserFrame, clientX: number, clientY: number) =>
1156
+ browserPoint(canvasRef.current, frame, clientX, clientY),
1157
+ [],
1158
+ );
1159
+
1160
+ const pointerDown = (event: PointerEvent<HTMLCanvasElement>) => {
1161
+ if (!props.frame || props.mutating) return;
1162
+ pointerStartRef.current = {
1163
+ x: event.clientX,
1164
+ y: event.clientY,
1165
+ pointerId: event.pointerId,
1166
+ frame: props.frame,
1167
+ };
1168
+ event.currentTarget.setPointerCapture?.(event.pointerId);
1169
+ inputRef.current?.focus({ preventScroll: true });
1170
+ };
1171
+
1172
+ const pointerUp = (event: PointerEvent<HTMLCanvasElement>) => {
1173
+ const start = pointerStartRef.current;
1174
+ pointerStartRef.current = null;
1175
+ if (!start || start.pointerId !== event.pointerId) return;
1176
+ const from = point(start.frame, start.x, start.y);
1177
+ const to = point(start.frame, event.clientX, event.clientY);
1178
+ if (!from || !to) return;
1179
+ if (Math.hypot(event.clientX - start.x, event.clientY - start.y) > 5) {
1180
+ enqueue(
1181
+ {
1182
+ type: "pointer",
1183
+ action: "drag",
1184
+ x: from.x,
1185
+ y: from.y,
1186
+ endX: to.x,
1187
+ endY: to.y,
1188
+ },
1189
+ start.frame,
1190
+ );
1191
+ return;
1192
+ }
1193
+ const now = Date.now();
1194
+ const previous = lastClickRef.current;
1195
+ if (
1196
+ previous &&
1197
+ now - previous.at < 280 &&
1198
+ Math.hypot(previous.x - to.x, previous.y - to.y) < 6 &&
1199
+ sameFrameFence(previous.frame, start.frame)
1200
+ ) {
1201
+ if (clickTimerRef.current) clearTimeout(clickTimerRef.current);
1202
+ clickTimerRef.current = null;
1203
+ lastClickRef.current = null;
1204
+ enqueue({ type: "pointer", action: "double_click", x: to.x, y: to.y }, start.frame);
1205
+ return;
1206
+ }
1207
+ lastClickRef.current = { at: now, x: to.x, y: to.y, frame: start.frame };
1208
+ clickTimerRef.current = setTimeout(() => {
1209
+ clickTimerRef.current = null;
1210
+ lastClickRef.current = null;
1211
+ enqueue({ type: "pointer", action: "click", x: to.x, y: to.y }, start.frame);
1212
+ }, 280);
1213
+ };
1214
+
1215
+ const contextMenu = (event: MouseEvent<HTMLCanvasElement>) => {
1216
+ event.preventDefault();
1217
+ const frame = props.frame;
1218
+ if (!frame) return;
1219
+ const at = point(frame, event.clientX, event.clientY);
1220
+ if (at) enqueue({ type: "pointer", action: "click", x: at.x, y: at.y, button: "right" }, frame);
1221
+ };
1222
+
1223
+ const wheel = (event: WheelEvent<HTMLCanvasElement>) => {
1224
+ const frame = props.frame;
1225
+ if (!frame) return;
1226
+ const at = point(frame, event.clientX, event.clientY);
1227
+ if (!at) return;
1228
+ event.preventDefault();
1229
+ const pending = wheelRef.current;
1230
+ if (pending?.timer) clearTimeout(pending.timer);
1231
+ wheelRef.current = {
1232
+ x: at.x,
1233
+ y: at.y,
1234
+ deltaX: (pending && sameFrameFence(pending.frame, frame) ? pending.deltaX : 0) + event.deltaX,
1235
+ deltaY: (pending && sameFrameFence(pending.frame, frame) ? pending.deltaY : 0) + event.deltaY,
1236
+ frame,
1237
+ timer: setTimeout(() => {
1238
+ const batch = wheelRef.current;
1239
+ wheelRef.current = null;
1240
+ if (batch)
1241
+ enqueue(
1242
+ {
1243
+ type: "pointer",
1244
+ action: "scroll",
1245
+ x: batch.x,
1246
+ y: batch.y,
1247
+ deltaX: batch.deltaX,
1248
+ deltaY: batch.deltaY,
1249
+ },
1250
+ batch.frame,
1251
+ );
1252
+ }, 45),
1253
+ };
1254
+ };
1255
+
1256
+ const keyDown = (event: KeyboardEvent<HTMLTextAreaElement>) => {
1257
+ const key = browserKey(event);
1258
+ if (!key) return;
1259
+ event.preventDefault();
1260
+ enqueue({ type: "press", key }, props.frame);
1261
+ };
1262
+
1263
+ const input = (value: string) => {
1264
+ if (!value) return;
1265
+ enqueue({ type: "type", text: value }, props.frame);
1266
+ if (inputRef.current) inputRef.current.value = "";
1267
+ };
1268
+
1269
+ const showCanvas = props.frame !== null;
1270
+ return (
1271
+ <div className="relative min-h-0 flex-1 overflow-hidden bg-black">
1272
+ <canvas
1273
+ ref={canvasRef}
1274
+ className={cn(
1275
+ "absolute inset-0 m-auto max-h-full max-w-full touch-none focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-og-accent",
1276
+ !showCanvas && "invisible",
1277
+ )}
1278
+ onPointerDown={pointerDown}
1279
+ onPointerUp={pointerUp}
1280
+ onPointerCancel={() => {
1281
+ pointerStartRef.current = null;
1282
+ }}
1283
+ onContextMenu={contextMenu}
1284
+ onWheel={wheel}
1285
+ aria-label="Interactive browser page"
1286
+ />
1287
+ <textarea
1288
+ ref={inputRef}
1289
+ defaultValue=""
1290
+ onInput={(event) => input(event.currentTarget.value)}
1291
+ onKeyDown={keyDown}
1292
+ className="pointer-events-none absolute left-1/2 top-1/2 size-px resize-none overflow-hidden opacity-0"
1293
+ aria-label="Browser keyboard input"
1294
+ autoCapitalize="off"
1295
+ autoCorrect="off"
1296
+ spellCheck={false}
1297
+ />
1298
+ {!showCanvas ? (
1299
+ <SemanticBrowserFallback
1300
+ observation={props.observation}
1301
+ connectionState={props.connectionState}
1302
+ error={props.connectionError}
1303
+ onAction={(action) => enqueue(action, null)}
1304
+ onReconnect={props.onReconnect}
1305
+ />
1306
+ ) : null}
1307
+ {props.mutating ? (
1308
+ <div className="pointer-events-none absolute right-3 top-3 flex items-center gap-1.5 rounded-full border border-white/10 bg-black/65 px-2.5 py-1 text-[11px] text-white/80 backdrop-blur">
1309
+ <LoaderCircleIcon className="size-3 animate-spin" /> {props.activityLabel ?? "Acting"}
1310
+ </div>
1311
+ ) : null}
1312
+ </div>
1313
+ );
1314
+ }
1315
+
1316
+ function SemanticBrowserFallback(props: {
1317
+ observation: ReturnType<typeof useBrowserSession>["observation"];
1318
+ connectionState: string;
1319
+ error: Error | null;
1320
+ onAction: (action: BrowserAction) => void;
1321
+ onReconnect: () => void;
1322
+ }) {
1323
+ const nodes = semanticNodes(
1324
+ props.observation?.semantic?.kind === "snapshot" ? props.observation.semantic.roots : [],
1325
+ );
1326
+ const interactive = nodes.filter((node) => node.actions.includes("click")).slice(0, 8);
1327
+ return (
1328
+ <div className="absolute inset-0 grid place-items-center bg-og-bg p-6">
1329
+ <div className="w-full max-w-md rounded-og-lg border border-og-border bg-og-surface-0 p-4 shadow-lg">
1330
+ <div className="flex items-center gap-2">
1331
+ {props.error ? (
1332
+ <CircleAlertIcon className="size-4 text-og-status-error" />
1333
+ ) : (
1334
+ <LoaderCircleIcon className="size-4 animate-spin text-og-muted" />
1335
+ )}
1336
+ <p className="text-og-menu font-medium text-og-fg">
1337
+ {props.error ? "Live view disconnected" : browserConnectionLabel(props.connectionState)}
1338
+ </p>
1339
+ </div>
1340
+ {interactive.length > 0 ? (
1341
+ <div className="mt-3 border-t border-og-border pt-3">
1342
+ <p className="mb-2 text-og-xs text-og-subtle">Page controls remain available</p>
1343
+ <div className="flex flex-wrap gap-1.5">
1344
+ {interactive.map((node) => (
1345
+ <button
1346
+ key={node.ref}
1347
+ type="button"
1348
+ onClick={() =>
1349
+ props.onAction({
1350
+ type: "click",
1351
+ locator: { kind: "ref", ref: node.ref },
1352
+ })
1353
+ }
1354
+ className="rounded-og-sm border border-og-border bg-og-surface-1 px-2 py-1 text-og-control text-og-fg transition hover:bg-og-surface-2"
1355
+ >
1356
+ {node.name || node.role}
1357
+ </button>
1358
+ ))}
1359
+ </div>
1360
+ </div>
1361
+ ) : null}
1362
+ {props.error ? (
1363
+ <button
1364
+ type="button"
1365
+ onClick={props.onReconnect}
1366
+ className="mt-3 text-og-control font-medium text-og-accent hover:underline"
1367
+ >
1368
+ Reconnect
1369
+ </button>
1370
+ ) : null}
1371
+ </div>
1372
+ </div>
1373
+ );
1374
+ }
1375
+
1376
+ function BrowserStatusBar(props: {
1377
+ session: BrowserSession | null;
1378
+ profile: BrowserIdentity | null;
1379
+ target: BrowserTarget | null;
1380
+ observation: ReturnType<typeof useBrowserSession>["observation"];
1381
+ connectionState: string;
1382
+ refreshing: boolean;
1383
+ onOpenComputer?: (() => void) | undefined;
1384
+ onDiagnostics: () => void;
1385
+ }) {
1386
+ const errors = props.observation?.diagnostics.consoleErrorCount ?? 0;
1387
+ const failed = props.observation?.diagnostics.failedRequestCount ?? 0;
1388
+ return (
1389
+ <div className="flex h-7 shrink-0 items-center gap-2 border-t border-og-border bg-og-surface-0 px-2 text-og-xs text-og-subtle">
1390
+ <span
1391
+ className={cn(
1392
+ "size-1.5 rounded-full",
1393
+ props.connectionState === "live" ? "bg-og-status-running" : "bg-og-muted",
1394
+ )}
1395
+ />
1396
+ <span>
1397
+ {props.connectionState === "live" ? "Live" : browserConnectionLabel(props.connectionState)}
1398
+ </span>
1399
+ <span>{props.profile?.name ?? "Temporary browser"}</span>
1400
+ <span className="min-w-0 flex-1 truncate">{props.target?.title}</span>
1401
+ {props.refreshing ? <LoaderCircleIcon className="size-3 animate-spin" /> : null}
1402
+ {props.onOpenComputer ? (
1403
+ <button
1404
+ type="button"
1405
+ onClick={props.onOpenComputer}
1406
+ className="flex items-center gap-1 rounded px-1.5 py-0.5 transition hover:bg-og-surface-2 hover:text-og-fg"
1407
+ aria-label="Open this browser window in Computer"
1408
+ >
1409
+ <MonitorIcon className="size-3" />
1410
+ Computer
1411
+ </button>
1412
+ ) : null}
1413
+ <button
1414
+ type="button"
1415
+ onClick={props.onDiagnostics}
1416
+ className="flex items-center gap-1 rounded px-1.5 py-0.5 transition hover:bg-og-surface-2 hover:text-og-fg"
1417
+ >
1418
+ <BugIcon className="size-3" />
1419
+ {errors + failed > 0 ? errors + failed : "Debug"}
1420
+ </button>
1421
+ </div>
1422
+ );
1423
+ }
1424
+
1425
+ function BrowserNotice(props: { icon: ReactNode; text: string; className?: string }) {
1426
+ return (
1427
+ <div
1428
+ className={cn(
1429
+ "grid h-full place-items-center bg-og-bg text-og-control text-og-muted",
1430
+ props.className,
1431
+ )}
1432
+ >
1433
+ <div className="flex items-center gap-2">
1434
+ {props.icon}
1435
+ {props.text}
1436
+ </div>
1437
+ </div>
1438
+ );
1439
+ }
1440
+
1441
+ function paintCanvas(
1442
+ canvas: HTMLCanvasElement,
1443
+ image: CanvasImageSource,
1444
+ width: number,
1445
+ height: number,
1446
+ ): void {
1447
+ canvas.width = width;
1448
+ canvas.height = height;
1449
+ const context = canvas.getContext("2d", { alpha: false });
1450
+ if (!context) throw new Error("Browser canvas is unavailable.");
1451
+ context.drawImage(image, 0, 0, width, height);
1452
+ }
1453
+
1454
+ function loadImage(url: string): Promise<HTMLImageElement> {
1455
+ return new Promise((resolve, reject) => {
1456
+ const image = new Image();
1457
+ image.onload = () => resolve(image);
1458
+ image.onerror = () => reject(new Error("Browser frame image could not be decoded."));
1459
+ image.src = url;
1460
+ });
1461
+ }
1462
+
1463
+ function browserPoint(
1464
+ canvas: HTMLCanvasElement | null,
1465
+ frame: BrowserFrame | null,
1466
+ clientX: number,
1467
+ clientY: number,
1468
+ ): { x: number; y: number } | null {
1469
+ if (!canvas || !frame) return null;
1470
+ const rect = canvas.getBoundingClientRect();
1471
+ if (rect.width <= 0 || rect.height <= 0) return null;
1472
+ const pixelX = ((clientX - rect.left) / rect.width) * frame.width;
1473
+ const pixelY = ((clientY - rect.top) / rect.height) * frame.height;
1474
+ return {
1475
+ x: Math.max(0, pixelX / frame.deviceScaleFactor),
1476
+ y: Math.max(0, pixelY / frame.deviceScaleFactor),
1477
+ };
1478
+ }
1479
+
1480
+ function browserKey(event: KeyboardEvent<HTMLTextAreaElement>): string | null {
1481
+ const special = new Set([
1482
+ "Enter",
1483
+ "Tab",
1484
+ "Escape",
1485
+ "Backspace",
1486
+ "Delete",
1487
+ "ArrowUp",
1488
+ "ArrowDown",
1489
+ "ArrowLeft",
1490
+ "ArrowRight",
1491
+ ]);
1492
+ const modified = event.altKey || event.ctrlKey || event.metaKey;
1493
+ if (!modified && !special.has(event.key)) return null;
1494
+ const parts: string[] = [];
1495
+ if (event.ctrlKey) parts.push("Control");
1496
+ if (event.altKey) parts.push("Alt");
1497
+ if (event.metaKey) parts.push("Meta");
1498
+ if (event.shiftKey) parts.push("Shift");
1499
+ parts.push(event.key === " " ? "Space" : event.key);
1500
+ return parts.join("+");
1501
+ }
1502
+
1503
+ function normalizeBrowserAddress(value: string): string | null {
1504
+ const trimmed = value.trim();
1505
+ if (!trimmed) return null;
1506
+ const candidate = /^[a-z][a-z0-9+.-]*:/iu.test(trimmed) ? trimmed : `https://${trimmed}`;
1507
+ try {
1508
+ const url = new URL(candidate);
1509
+ return ["http:", "https:", "about:"].includes(url.protocol) ? url.href : null;
1510
+ } catch {
1511
+ return null;
1512
+ }
1513
+ }
1514
+
1515
+ function semanticNodes(roots: readonly InteractionSemanticNode[]): InteractionSemanticNode[] {
1516
+ const result: InteractionSemanticNode[] = [];
1517
+ const stack = [...roots].reverse();
1518
+ while (stack.length > 0) {
1519
+ const node = stack.pop()!;
1520
+ result.push(node);
1521
+ if (node.children) stack.push(...[...node.children].reverse());
1522
+ }
1523
+ return result;
1524
+ }
1525
+
1526
+ function frameMatchesObservation(
1527
+ frame: BrowserFrame | null,
1528
+ observation: BrowserObservation | null,
1529
+ ): frame is BrowserFrame {
1530
+ return Boolean(
1531
+ frame &&
1532
+ observation &&
1533
+ frame.browserSessionId === observation.browserSessionId &&
1534
+ frame.controllerGeneration === observation.target.controllerGeneration &&
1535
+ frame.targetId === observation.target.id &&
1536
+ frame.targetGeneration === observation.target.targetGeneration &&
1537
+ frame.documentGeneration === observation.target.documentGeneration &&
1538
+ frame.frameId === observation.frameId,
1539
+ );
1540
+ }
1541
+
1542
+ function sameFrameFence(left: BrowserFrame, right: BrowserFrame): boolean {
1543
+ return (
1544
+ left.browserSessionId === right.browserSessionId &&
1545
+ left.controllerGeneration === right.controllerGeneration &&
1546
+ left.targetId === right.targetId &&
1547
+ left.targetGeneration === right.targetGeneration &&
1548
+ left.documentGeneration === right.documentGeneration &&
1549
+ left.frameId === right.frameId
1550
+ );
1551
+ }
1552
+
1553
+ function isLiveBrowser(session: BrowserSession): boolean {
1554
+ return !["ending", "ended", "failed", "lost"].includes(session.lifecycle);
1555
+ }
1556
+
1557
+ function placementLabel(session: BrowserSession | undefined): string {
1558
+ if (!session) return "";
1559
+ switch (session.placement.kind) {
1560
+ case "sandbox_group":
1561
+ return "Agent sandbox";
1562
+ case "connected_machine":
1563
+ return "Connected machine";
1564
+ case "attached_device":
1565
+ return "Your browser";
1566
+ case "external_provider":
1567
+ return "Cloud browser";
1568
+ }
1569
+ }
1570
+
1571
+ function browserConnectionLabel(state: string): string {
1572
+ switch (state) {
1573
+ case "attaching":
1574
+ return "Opening browser…";
1575
+ case "connecting":
1576
+ return "Connecting…";
1577
+ case "reconnecting":
1578
+ return "Reconnecting…";
1579
+ case "error":
1580
+ return "Disconnected";
1581
+ default:
1582
+ return "Waiting for browser…";
1583
+ }
1584
+ }
1585
+
1586
+ function shortUrl(value: string): string {
1587
+ try {
1588
+ return new URL(value).hostname || value;
1589
+ } catch {
1590
+ return value;
1591
+ }
1592
+ }