@opengeni/react 0.48.0 → 0.57.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (325) hide show
  1. package/README.md +138 -2
  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 +199 -0
  13. package/dist/artifacts.js.map +1 -0
  14. package/dist/{chunk-NJ7M2B6Z.js → chunk-5UQPNLIO.js} +16 -8
  15. package/dist/{chunk-NJ7M2B6Z.js.map → chunk-5UQPNLIO.js.map} +1 -1
  16. package/dist/{chunk-LHLYWG5R.js → chunk-6KLX2RH5.js} +461 -38
  17. package/dist/chunk-6KLX2RH5.js.map +1 -0
  18. package/dist/chunk-7WCLAEFV.js +2828 -0
  19. package/dist/chunk-7WCLAEFV.js.map +1 -0
  20. package/dist/{chunk-I4MPFDRP.js → chunk-B6WPZSTT.js} +104 -104
  21. package/dist/{chunk-I4MPFDRP.js.map → chunk-B6WPZSTT.js.map} +1 -1
  22. package/dist/{chunk-EKZH6IDG.js → chunk-C5ZPSSS5.js} +111 -1
  23. package/dist/chunk-C5ZPSSS5.js.map +1 -0
  24. package/dist/{chunk-OL6QVXGF.js → chunk-HA46CYQK.js} +152 -8
  25. package/dist/chunk-HA46CYQK.js.map +1 -0
  26. package/dist/chunk-HUIKIDM5.js +2148 -0
  27. package/dist/chunk-HUIKIDM5.js.map +1 -0
  28. package/dist/{chunk-FWXGSNXT.js → chunk-IGC6A2ZI.js} +585 -34
  29. package/dist/chunk-IGC6A2ZI.js.map +1 -0
  30. package/dist/chunk-JVYQRRC4.js +53 -0
  31. package/dist/chunk-JVYQRRC4.js.map +1 -0
  32. package/dist/chunk-KCGXPP5V.js +157 -0
  33. package/dist/chunk-KCGXPP5V.js.map +1 -0
  34. package/dist/chunk-LLOLUHVG.js +5937 -0
  35. package/dist/chunk-LLOLUHVG.js.map +1 -0
  36. package/dist/{chunk-WYA65Y3F.js → chunk-MKPIAGVL.js} +13 -3
  37. package/dist/chunk-MKPIAGVL.js.map +1 -0
  38. package/dist/chunk-PEI6RANI.js +3234 -0
  39. package/dist/chunk-PEI6RANI.js.map +1 -0
  40. package/dist/chunk-R3AZOAB5.js +933 -0
  41. package/dist/chunk-R3AZOAB5.js.map +1 -0
  42. package/dist/{chunk-O4VXOBGF.js → chunk-WSK7G5LE.js} +70 -2
  43. package/dist/chunk-WSK7G5LE.js.map +1 -0
  44. package/dist/{chunk-23EJ676W.js → chunk-XBZWOM2K.js} +9 -4
  45. package/dist/chunk-XBZWOM2K.js.map +1 -0
  46. package/dist/{chunk-XCNTXWBB.js → chunk-Y7OEDUJ5.js} +856 -338
  47. package/dist/chunk-Y7OEDUJ5.js.map +1 -0
  48. package/dist/client.d.ts +10 -2
  49. package/dist/clipboard.d.ts +1 -0
  50. package/dist/clipboard.js +7 -0
  51. package/dist/clipboard.js.map +1 -0
  52. package/dist/commands/index.d.ts +3 -3
  53. package/dist/commands/registry.d.ts +1 -1
  54. package/dist/commands/types.d.ts +1 -1
  55. package/dist/components/approval-surface.d.ts +2 -2
  56. package/dist/components/artifacts/artifact-surface.d.ts +18 -0
  57. package/dist/components/artifacts/document-editor.d.ts +172 -0
  58. package/dist/components/artifacts/editable-artifact-ui.d.ts +13 -0
  59. package/dist/components/artifacts/editable-artifact-workbench.d.ts +49 -0
  60. package/dist/components/artifacts/editable-document.d.ts +24 -0
  61. package/dist/components/artifacts/editable-presentation.d.ts +24 -0
  62. package/dist/components/artifacts/editable-spreadsheet.d.ts +32 -0
  63. package/dist/components/artifacts/index.d.ts +9 -0
  64. package/dist/components/artifacts/presentation-editor.d.ts +297 -0
  65. package/dist/components/artifacts/published-html-artifact-frame.d.ts +14 -0
  66. package/dist/components/artifacts/spreadsheet-canvas.d.ts +120 -0
  67. package/dist/components/artifacts/spreadsheet-grid.d.ts +113 -0
  68. package/dist/components/browser-input.d.ts +7 -0
  69. package/dist/components/browser-viewer.d.ts +30 -0
  70. package/dist/components/chat-composer.d.ts +8 -8
  71. package/dist/components/code-editor.d.ts +25 -3
  72. package/dist/components/command-palette.d.ts +2 -2
  73. package/dist/components/composer-transcription-control.d.ts +2 -2
  74. package/dist/components/composer.d.ts +17 -17
  75. package/dist/components/computer-viewer.d.ts +24 -0
  76. package/dist/components/copy-button.d.ts +2 -2
  77. package/dist/components/desktop-viewer.d.ts +50 -22
  78. package/dist/components/diff-view.d.ts +1 -1
  79. package/dist/components/enrollment-consent.d.ts +1 -1
  80. package/dist/components/enrollment-device-flow.d.ts +1 -1
  81. package/dist/components/file-browser.d.ts +2 -2
  82. package/dist/components/fleet-tile.d.ts +1 -1
  83. package/dist/components/generated-video-player.d.ts +10 -0
  84. package/dist/components/human-input-form.d.ts +1 -1
  85. package/dist/components/human-input-surface.d.ts +2 -2
  86. package/dist/components/interaction-intervention-banner.d.ts +11 -0
  87. package/dist/components/interactive-workbench-viewers.d.ts +2 -0
  88. package/dist/components/machine-card.d.ts +2 -2
  89. package/dist/components/machine-dock-bar.d.ts +3 -3
  90. package/dist/components/machine-health-pill.d.ts +2 -2
  91. package/dist/components/machine-input-display.d.ts +1 -1
  92. package/dist/components/machine-metrics.d.ts +2 -2
  93. package/dist/components/machine-status-pill.d.ts +4 -4
  94. package/dist/components/machines/health.d.ts +1 -1
  95. package/dist/components/machines/machine-detail.d.ts +3 -3
  96. package/dist/components/machines/metric-history-chart.d.ts +1 -1
  97. package/dist/components/machines/metric-sparkline.d.ts +2 -2
  98. package/dist/components/machines/series.d.ts +2 -2
  99. package/dist/components/machines-dashboard.d.ts +2 -2
  100. package/dist/components/markdown.d.ts +1 -1
  101. package/dist/components/message-timeline.d.ts +12 -5
  102. package/dist/components/model-picker.d.ts +2 -2
  103. package/dist/components/model-policy-picker.d.ts +7 -7
  104. package/dist/components/pierre-diff.d.ts +1 -1
  105. package/dist/components/pierre-file.d.ts +1 -1
  106. package/dist/components/queue-draft-policy.d.ts +1 -1
  107. package/dist/components/queue-surface-implementation.d.ts +3 -3
  108. package/dist/components/queue-surface-state.d.ts +4 -4
  109. package/dist/components/queue-surface.d.ts +5 -5
  110. package/dist/components/sandbox-files.d.ts +3 -3
  111. package/dist/components/sandbox-terminal.d.ts +24 -4
  112. package/dist/components/sandbox-workspace.d.ts +28 -9
  113. package/dist/components/session-chrome.d.ts +4 -4
  114. package/dist/components/session-status.d.ts +2 -2
  115. package/dist/components/timeline-annotation-selection.d.ts +9 -0
  116. package/dist/components/timeline-annotations-dialog.d.ts +16 -0
  117. package/dist/components/timeline-annotations.d.ts +13 -0
  118. package/dist/components/tooltip.d.ts +4 -4
  119. package/dist/components/user-message-body.d.ts +2 -2
  120. package/dist/components/workbench-changes.d.ts +2 -2
  121. package/dist/components/workspace-dock.d.ts +1 -1
  122. package/dist/composer.d.ts +11 -11
  123. package/dist/composer.js +7 -7
  124. package/dist/hooks/internal.d.ts +1 -1
  125. package/dist/hooks/use-attached-browsers.d.ts +21 -0
  126. package/dist/hooks/use-auth-runs.d.ts +32 -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-downloads.d.ts +25 -0
  130. package/dist/hooks/use-browser-frame-stream.d.ts +28 -0
  131. package/dist/hooks/use-browser-identities.d.ts +30 -0
  132. package/dist/hooks/use-browser-session.d.ts +29 -0
  133. package/dist/hooks/use-browser-sessions.d.ts +32 -0
  134. package/dist/hooks/use-channels.d.ts +26 -0
  135. package/dist/hooks/use-codex-accounts.d.ts +2 -2
  136. package/dist/hooks/use-composer.d.ts +36 -9
  137. package/dist/hooks/use-computer-frame-stream.d.ts +24 -0
  138. package/dist/hooks/use-computer-session.d.ts +29 -0
  139. package/dist/hooks/use-computer-sessions.d.ts +28 -0
  140. package/dist/hooks/use-desktop-stream.d.ts +5 -1
  141. package/dist/hooks/use-environments.d.ts +1 -1
  142. package/dist/hooks/use-file-attachments.d.ts +1 -1
  143. package/dist/hooks/use-goal.d.ts +2 -2
  144. package/dist/hooks/use-human-input.d.ts +2 -2
  145. package/dist/hooks/use-interaction-interventions.d.ts +27 -0
  146. package/dist/hooks/use-interaction-invalidation.d.ts +19 -0
  147. package/dist/hooks/use-last-started-turn-policy.d.ts +2 -2
  148. package/dist/hooks/use-machine-chip.d.ts +2 -2
  149. package/dist/hooks/use-machines.d.ts +4 -4
  150. package/dist/hooks/use-network-routes.d.ts +25 -0
  151. package/dist/hooks/use-packs.d.ts +7 -2
  152. package/dist/hooks/use-rigs.d.ts +1 -1
  153. package/dist/hooks/use-sandbox-files.d.ts +6 -4
  154. package/dist/hooks/use-sandbox-git.d.ts +6 -5
  155. package/dist/hooks/use-sandbox-terminal.d.ts +1 -1
  156. package/dist/hooks/use-scheduled-tasks.d.ts +1 -1
  157. package/dist/hooks/use-session-capabilities.d.ts +5 -4
  158. package/dist/hooks/use-session-control.d.ts +1 -1
  159. package/dist/hooks/use-session-events.d.ts +2 -2
  160. package/dist/hooks/use-session-lineage.d.ts +1 -1
  161. package/dist/hooks/use-session-mcp-approval-policy.d.ts +2 -2
  162. package/dist/hooks/use-session.d.ts +2 -2
  163. package/dist/hooks/use-site-auth-connections.d.ts +25 -0
  164. package/dist/hooks/use-slash-commands.d.ts +1 -1
  165. package/dist/hooks/use-supergrok-accounts.d.ts +26 -0
  166. package/dist/hooks/use-terminal-stream.d.ts +11 -2
  167. package/dist/hooks/use-turn-queue.d.ts +2 -2
  168. package/dist/hooks/use-video-artifact-playback.d.ts +9 -0
  169. package/dist/hooks/use-voice-input.d.ts +1 -1
  170. package/dist/hooks/use-workspace-capture.d.ts +1 -1
  171. package/dist/hooks/use-workspace-edit.d.ts +1 -1
  172. package/dist/hooks/use-workspace-sessions.d.ts +1 -1
  173. package/dist/hooks/use-workspaces.d.ts +1 -1
  174. package/dist/index.d.ts +162 -155
  175. package/dist/index.js +1822 -1703
  176. package/dist/index.js.map +1 -1
  177. package/dist/interaction.d.ts +33 -0
  178. package/dist/interaction.js +550 -0
  179. package/dist/interaction.js.map +1 -0
  180. package/dist/interactive-workbench-viewers-TOHBXJEE.js +15 -0
  181. package/dist/interactive-workbench-viewers-TOHBXJEE.js.map +1 -0
  182. package/dist/lib/host-platform.d.ts +5 -0
  183. package/dist/lib/relay-wire.d.ts +24 -1
  184. package/dist/lib/sandbox-liveness.d.ts +9 -0
  185. package/dist/lib/terminal-capability.d.ts +5 -0
  186. package/dist/lib/xterm-theme.d.ts +1 -1
  187. package/dist/machines.d.ts +30 -30
  188. package/dist/machines.js +2 -2
  189. package/dist/model-policy.d.ts +1 -1
  190. package/dist/model-policy.js +1 -1
  191. package/dist/provider.d.ts +6 -5
  192. package/dist/{queue-surface-implementation-U5JFG7HT.js → queue-surface-implementation-2KVJTYXH.js} +100 -48
  193. package/dist/queue-surface-implementation-2KVJTYXH.js.map +1 -0
  194. package/dist/realtime/dropdown-menu.d.ts +8 -8
  195. package/dist/realtime/realtime-control.d.ts +8 -8
  196. package/dist/realtime.d.ts +4 -4
  197. package/dist/realtime.js +38 -11
  198. package/dist/realtime.js.map +1 -1
  199. package/dist/session-context.d.ts +28 -2
  200. package/dist/session-ui.d.ts +14 -14
  201. package/dist/session-ui.js +5 -4
  202. package/dist/session.d.ts +28 -28
  203. package/dist/session.js +10 -10
  204. package/dist/timeline/activity-rail.d.ts +5 -3
  205. package/dist/timeline/disclosure-context.d.ts +1 -1
  206. package/dist/timeline/fleet-decision-projection.d.ts +1 -1
  207. package/dist/timeline/fleet-decision-row.d.ts +3 -3
  208. package/dist/timeline/index.d.ts +19 -19
  209. package/dist/timeline/parsers.d.ts +4 -1
  210. package/dist/timeline/projection.d.ts +3 -3
  211. package/dist/timeline/registry.d.ts +8 -2
  212. package/dist/timeline/screenshot-lightbox.d.ts +2 -2
  213. package/dist/timeline/shared.d.ts +21 -10
  214. package/dist/timeline/tool-diff.d.ts +2 -2
  215. package/dist/timeline/tool-renderers.d.ts +1 -1
  216. package/dist/timeline/turn-summary.d.ts +3 -3
  217. package/dist/timeline/types.d.ts +44 -3
  218. package/dist/timeline-annotation-selection-3L7LHGG2.js +187 -0
  219. package/dist/timeline-annotation-selection-3L7LHGG2.js.map +1 -0
  220. package/dist/timeline-annotations-dialog-U7EVLR75.js +196 -0
  221. package/dist/timeline-annotations-dialog-U7EVLR75.js.map +1 -0
  222. package/package.json +36 -7
  223. package/src/artifacts-document.ts +31 -0
  224. package/src/artifacts-presentation.ts +36 -0
  225. package/src/artifacts-spreadsheet.ts +21 -0
  226. package/src/artifacts.ts +89 -0
  227. package/src/client.ts +157 -1
  228. package/src/clipboard.ts +2 -0
  229. package/src/components/artifacts/artifact-surface.tsx +93 -0
  230. package/src/components/artifacts/document-editor.tsx +2255 -0
  231. package/src/components/artifacts/editable-artifact-ui.tsx +89 -0
  232. package/src/components/artifacts/editable-artifact-workbench.tsx +203 -0
  233. package/src/components/artifacts/editable-document.tsx +657 -0
  234. package/src/components/artifacts/editable-presentation.tsx +1020 -0
  235. package/src/components/artifacts/editable-spreadsheet.tsx +733 -0
  236. package/src/components/artifacts/index.ts +104 -0
  237. package/src/components/artifacts/presentation-editor.tsx +3148 -0
  238. package/src/components/artifacts/published-html-artifact-frame.tsx +38 -0
  239. package/src/components/artifacts/spreadsheet-canvas.ts +799 -0
  240. package/src/components/artifacts/spreadsheet-grid.tsx +2129 -0
  241. package/src/components/browser-input.ts +65 -0
  242. package/src/components/browser-viewer.tsx +2702 -0
  243. package/src/components/chat-composer.tsx +13 -0
  244. package/src/components/code-editor.tsx +89 -30
  245. package/src/components/composer-transcription-control.tsx +10 -6
  246. package/src/components/computer-viewer.tsx +1636 -0
  247. package/src/components/desktop-viewer.tsx +215 -74
  248. package/src/components/generated-video-player.tsx +89 -0
  249. package/src/components/interaction-intervention-banner.tsx +99 -0
  250. package/src/components/interactive-workbench-viewers.ts +2 -0
  251. package/src/components/machine-input-display.ts +3 -0
  252. package/src/components/machines/machine-detail.tsx +1 -1
  253. package/src/components/message-timeline.tsx +289 -32
  254. package/src/components/model-policy-picker.tsx +15 -0
  255. package/src/components/queue-surface-implementation.tsx +116 -60
  256. package/src/components/sandbox-files.tsx +2 -2
  257. package/src/components/sandbox-terminal.tsx +128 -46
  258. package/src/components/sandbox-workspace.tsx +256 -90
  259. package/src/components/session-chrome.tsx +53 -13
  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/workbench-changes.tsx +188 -31
  264. package/src/components/workspace-dock.tsx +59 -5
  265. package/src/hooks/use-attached-browsers.ts +154 -0
  266. package/src/hooks/use-auth-runs.ts +278 -0
  267. package/src/hooks/use-browser-downloads.ts +218 -0
  268. package/src/hooks/use-browser-frame-stream.ts +425 -0
  269. package/src/hooks/use-browser-identities.ts +266 -0
  270. package/src/hooks/use-browser-session.ts +543 -0
  271. package/src/hooks/use-browser-sessions.ts +286 -0
  272. package/src/hooks/use-channels.ts +99 -0
  273. package/src/hooks/use-composer.ts +769 -24
  274. package/src/hooks/use-computer-frame-stream.ts +434 -0
  275. package/src/hooks/use-computer-session.ts +462 -0
  276. package/src/hooks/use-computer-sessions.ts +251 -0
  277. package/src/hooks/use-desktop-stream.ts +39 -7
  278. package/src/hooks/use-interaction-interventions.ts +242 -0
  279. package/src/hooks/use-interaction-invalidation.ts +66 -0
  280. package/src/hooks/use-machine-chip.ts +15 -3
  281. package/src/hooks/use-machines.ts +33 -3
  282. package/src/hooks/use-network-routes.ts +214 -0
  283. package/src/hooks/use-packs.ts +51 -0
  284. package/src/hooks/use-sandbox-files.ts +98 -55
  285. package/src/hooks/use-sandbox-git.ts +93 -38
  286. package/src/hooks/use-sandbox-terminal.ts +3 -2
  287. package/src/hooks/use-session-capabilities.ts +59 -46
  288. package/src/hooks/use-site-auth-connections.ts +226 -0
  289. package/src/hooks/use-supergrok-accounts.ts +81 -0
  290. package/src/hooks/use-terminal-stream.ts +359 -47
  291. package/src/hooks/use-video-artifact-playback.ts +28 -0
  292. package/src/hooks/use-workspace-edit.ts +2 -1
  293. package/src/index.ts +18 -0
  294. package/src/interaction.ts +101 -0
  295. package/src/lib/host-platform.ts +10 -0
  296. package/src/lib/relay-wire.ts +47 -4
  297. package/src/lib/sandbox-liveness.ts +11 -0
  298. package/src/lib/terminal-capability.ts +10 -0
  299. package/src/model-policy.ts +19 -4
  300. package/src/provider.tsx +133 -1
  301. package/src/realtime/realtime-control.tsx +34 -6
  302. package/src/session-context.ts +156 -1
  303. package/src/timeline/activity-rail.tsx +25 -3
  304. package/src/timeline/index.ts +3 -0
  305. package/src/timeline/parsers.ts +14 -1
  306. package/src/timeline/projection.ts +269 -14
  307. package/src/timeline/registry.ts +13 -1
  308. package/src/timeline/screenshot-lightbox.tsx +19 -9
  309. package/src/timeline/shared.tsx +56 -8
  310. package/src/timeline/tool-renderers.tsx +303 -50
  311. package/src/timeline/types.ts +57 -3
  312. package/styles/compiled.css +312 -21
  313. package/styles/index.css +15 -0
  314. package/styles/responsive.css +13 -1
  315. package/dist/chunk-23EJ676W.js.map +0 -1
  316. package/dist/chunk-EKZH6IDG.js.map +0 -1
  317. package/dist/chunk-FWXGSNXT.js.map +0 -1
  318. package/dist/chunk-LHLYWG5R.js.map +0 -1
  319. package/dist/chunk-O4VXOBGF.js.map +0 -1
  320. package/dist/chunk-OL6QVXGF.js.map +0 -1
  321. package/dist/chunk-WYA65Y3F.js.map +0 -1
  322. package/dist/chunk-XCNTXWBB.js.map +0 -1
  323. package/dist/fleet-decision-projection-SLNZMS42.js +0 -241
  324. package/dist/fleet-decision-projection-SLNZMS42.js.map +0 -1
  325. package/dist/queue-surface-implementation-U5JFG7HT.js.map +0 -1
@@ -0,0 +1,2702 @@
1
+ import type {
2
+ AttachedBrowserDevice,
3
+ BrowserAction,
4
+ BrowserActionReceipt,
5
+ BrowserDiagnosticBatch,
6
+ BrowserDownload,
7
+ BrowserDownloadSaveResponse,
8
+ BrowserFrame,
9
+ BrowserIdentity,
10
+ BrowserObservation,
11
+ BrowserRevision,
12
+ BrowserSession,
13
+ BrowserTarget,
14
+ ComputerSession,
15
+ InteractionPlacement,
16
+ InteractionIntervention,
17
+ InteractionSemanticNode,
18
+ } from "@opengeni/sdk/interaction";
19
+ import {
20
+ BugIcon,
21
+ ArchiveIcon,
22
+ CheckIcon,
23
+ ChevronDownIcon,
24
+ CircleAlertIcon,
25
+ DownloadIcon,
26
+ FileCheck2Icon,
27
+ Globe2Icon,
28
+ LoaderCircleIcon,
29
+ MonitorIcon,
30
+ PlusIcon,
31
+ RefreshCwIcon,
32
+ RotateCcwIcon,
33
+ SaveIcon,
34
+ UserRoundIcon,
35
+ XIcon,
36
+ ZapIcon,
37
+ } from "lucide-react";
38
+ import {
39
+ type FormEvent,
40
+ type ClipboardEvent,
41
+ type CompositionEvent,
42
+ type KeyboardEvent,
43
+ type MouseEvent,
44
+ type PointerEvent,
45
+ type ReactNode,
46
+ type WheelEvent,
47
+ useCallback,
48
+ useEffect,
49
+ useId,
50
+ useMemo,
51
+ useRef,
52
+ useState,
53
+ } from "react";
54
+ import {
55
+ useBrowserFrameStream,
56
+ type BrowserFrameWebSocketFactory,
57
+ } from "../hooks/use-browser-frame-stream";
58
+ import { useAttachedBrowsers } from "../hooks/use-attached-browsers";
59
+ import { useBrowserIdentities } from "../hooks/use-browser-identities";
60
+ import { useBrowserDownloads } from "../hooks/use-browser-downloads";
61
+ import { useBrowserSession } from "../hooks/use-browser-session";
62
+ import { useBrowserSessions } from "../hooks/use-browser-sessions";
63
+ import { useInteractionInterventions } from "../hooks/use-interaction-interventions";
64
+ import { cn } from "../lib/cn";
65
+ import { copyTextToClipboard } from "../lib/clipboard";
66
+ import { formatBytes } from "../lib/format";
67
+ import type { EmbeddedBrowserInteractionClientOverride } from "../session-context";
68
+ import { browserKey, HUMAN_BROWSER_HOME_URL, normalizeBrowserAddress } from "./browser-input";
69
+ import { InteractionInterventionBanner } from "./interaction-intervention-banner";
70
+
71
+ export type BrowserViewerNotification = {
72
+ kind: "error" | "info";
73
+ message: string;
74
+ };
75
+
76
+ export type BrowserViewerProps = EmbeddedBrowserInteractionClientOverride & {
77
+ /** The selected OpenGeni agent/session. Peer BrowserSessions stay discoverable. */
78
+ sessionId: string;
79
+ enabled?: boolean | undefined;
80
+ className?: string | undefined;
81
+ onNotify?: ((notification: BrowserViewerNotification) => void) | undefined;
82
+ /** Tests/demos only. Production uses the browser's native WebSocket. */
83
+ webSocketFactory?: BrowserFrameWebSocketFactory | undefined;
84
+ renderEmpty?: ((create: () => void, creating: boolean) => ReactNode) | undefined;
85
+ /** Optional host capability for placing a human-created headed browser inside
86
+ * an exact ComputerSession. Browser-only embedders still create a headed
87
+ * browser; they simply omit the linked desktop resource. */
88
+ createLinkedComputer?:
89
+ | ((
90
+ name: string,
91
+ placement?: InteractionPlacement,
92
+ ) => Promise<Pick<ComputerSession, "id" | "placement">>)
93
+ | undefined;
94
+ /** Navigate to the exact linked ComputerSession; never a lookalike desktop. */
95
+ onOpenComputer?: ((computerSessionId: string) => void) | undefined;
96
+ };
97
+
98
+ type BrowserSelection = { sessionId: string; pinned: boolean } | null;
99
+ type BrowserLaunchChoice =
100
+ | { kind: "clean" }
101
+ | { kind: "profile"; identityId: string }
102
+ | { kind: "attached"; device: AttachedBrowserDevice };
103
+ type PointerStart = {
104
+ x: number;
105
+ y: number;
106
+ pointerId: number;
107
+ frame: BrowserFrame;
108
+ };
109
+ type BrowserResumeAttempt = {
110
+ operationId: string;
111
+ running: boolean;
112
+ terminal: boolean;
113
+ error: Error | null;
114
+ };
115
+ type BrowserDiagnosticsView = {
116
+ browserSessionId: string;
117
+ targetId: string;
118
+ loading: boolean;
119
+ batch: BrowserDiagnosticBatch | null;
120
+ error: Error | null;
121
+ };
122
+
123
+ /**
124
+ * Complete browser-native session surface: workspace discovery, agent relevance,
125
+ * browser/tab switching, live frames, semantic fallback, direct human input,
126
+ * address navigation, diagnostics, and reconnect. Closing it never closes the browser.
127
+ */
128
+ export function BrowserViewer({
129
+ sessionId,
130
+ enabled = true,
131
+ className,
132
+ onNotify,
133
+ webSocketFactory,
134
+ renderEmpty,
135
+ createLinkedComputer,
136
+ onOpenComputer,
137
+ ...override
138
+ }: BrowserViewerProps) {
139
+ const registry = useBrowserSessions({ ...override, sessionId, enabled });
140
+ const attached = useAttachedBrowsers({ ...override, enabled });
141
+ const profiles = useBrowserIdentities({ ...override, enabled, includeArchived: true });
142
+ const activeProfiles = useMemo(
143
+ () => profiles.identities.filter((identity) => identity.status === "active"),
144
+ [profiles.identities],
145
+ );
146
+ const createRegistryBrowser = registry.create;
147
+ const loadProfileRevisions = profiles.revisions;
148
+ const liveSessions = useMemo(
149
+ () => registry.sessions.filter((session) => isLiveBrowser(session)),
150
+ [registry.sessions],
151
+ );
152
+ const relevant = useMemo(
153
+ () => registry.relevantSessions.filter((session) => isLiveBrowser(session)),
154
+ [registry.relevantSessions],
155
+ );
156
+ const [selection, setSelection] = useState<BrowserSelection>(null);
157
+ const interventions = useInteractionInterventions({
158
+ ...override,
159
+ enabled,
160
+ resourceKind: "browser_session",
161
+ });
162
+ const [creating, setCreating] = useState(false);
163
+ const [savingProfile, setSavingProfile] = useState(false);
164
+ const [baseRevisionOrdinal, setBaseRevisionOrdinal] = useState<number | null>(null);
165
+ const [profileHistory, setProfileHistory] = useState<{
166
+ identityId: string;
167
+ loading: boolean;
168
+ revisions: BrowserRevision[];
169
+ } | null>(null);
170
+ const [resumeAttempt, setResumeAttempt] = useState<{
171
+ sessionId: string;
172
+ attempt: BrowserResumeAttempt;
173
+ } | null>(null);
174
+ const resumeAttemptsRef = useRef(new Map<string, BrowserResumeAttempt>());
175
+ const previousSessionIdRef = useRef(sessionId);
176
+ const seenInterventionIdsRef = useRef(new Set<string>());
177
+ const diagnosticRequestRef = useRef(0);
178
+ const [diagnosticsView, setDiagnosticsView] = useState<BrowserDiagnosticsView | null>(null);
179
+
180
+ const notifyError = useCallback(
181
+ (cause: unknown, fallback: string) => {
182
+ const message = cause instanceof Error ? cause.message : fallback;
183
+ onNotify?.({ kind: "error", message });
184
+ },
185
+ [onNotify],
186
+ );
187
+
188
+ useEffect(() => {
189
+ if (previousSessionIdRef.current !== sessionId) {
190
+ previousSessionIdRef.current = sessionId;
191
+ setSelection(null);
192
+ }
193
+ }, [sessionId]);
194
+
195
+ useEffect(() => {
196
+ const selectedStillLive = liveSessions.some((session) => session.id === selection?.sessionId);
197
+ // A manually selected workspace browser stays selected, but an unrelated
198
+ // browser must never become the implicit browser for this agent. That made
199
+ // a Modal session appear to own a connected Mac browser.
200
+ if (selection?.pinned && selectedStillLive) return;
201
+ const preferred = relevant[0] ?? null;
202
+ if (!preferred) {
203
+ if (selection) setSelection(null);
204
+ return;
205
+ }
206
+ if (!selectedStillLive || !selection?.pinned) {
207
+ if (selection?.sessionId !== preferred.id || selection.pinned) {
208
+ setSelection({ sessionId: preferred.id, pinned: false });
209
+ }
210
+ }
211
+ }, [liveSessions, relevant, selection]);
212
+
213
+ const selectedRegistrySession = useMemo(
214
+ () => liveSessions.find((session) => session.id === selection?.sessionId) ?? null,
215
+ [liveSessions, selection?.sessionId],
216
+ );
217
+ const interventionCounts = useMemo(
218
+ () => countInterventions(interventions.interventions),
219
+ [interventions.interventions],
220
+ );
221
+ const selectedInterventions = useMemo(
222
+ () =>
223
+ interventions.interventions.filter(
224
+ (intervention) => intervention.resourceId === selection?.sessionId,
225
+ ),
226
+ [interventions.interventions, selection?.sessionId],
227
+ );
228
+ const controllerReady = selectedRegistrySession?.lifecycle === "active";
229
+ const resumeSession = registry.resume;
230
+
231
+ const wakeBrowser = useCallback(
232
+ async (session: BrowserSession, retry = false): Promise<void> => {
233
+ const current = resumeAttemptsRef.current.get(session.id);
234
+ if (current?.running || (!retry && current)) return;
235
+ const operationId = current && !current.terminal ? current.operationId : crypto.randomUUID();
236
+ const attempt: BrowserResumeAttempt = {
237
+ operationId,
238
+ running: true,
239
+ terminal: false,
240
+ error: null,
241
+ };
242
+ resumeAttemptsRef.current.set(session.id, attempt);
243
+ setResumeAttempt({ sessionId: session.id, attempt });
244
+ try {
245
+ const response = await resumeSession(session.id, operationId);
246
+ if (response.operation.state !== "completed") {
247
+ throw Object.assign(
248
+ new Error(response.operation.error?.message ?? "Browser could not reopen."),
249
+ { terminal: true },
250
+ );
251
+ }
252
+ resumeAttemptsRef.current.delete(session.id);
253
+ setResumeAttempt((visible) => (visible?.sessionId === session.id ? null : visible));
254
+ } catch (cause) {
255
+ const error = cause instanceof Error ? cause : new Error(String(cause));
256
+ const failed: BrowserResumeAttempt = {
257
+ operationId,
258
+ running: false,
259
+ terminal:
260
+ typeof cause === "object" && cause !== null && "terminal" in cause
261
+ ? cause.terminal === true
262
+ : false,
263
+ error,
264
+ };
265
+ resumeAttemptsRef.current.set(session.id, failed);
266
+ setResumeAttempt({ sessionId: session.id, attempt: failed });
267
+ notifyError(error, "Could not reopen this browser.");
268
+ }
269
+ },
270
+ [notifyError, resumeSession],
271
+ );
272
+
273
+ useEffect(() => {
274
+ if (!selectedRegistrySession) return;
275
+ if (selectedRegistrySession.lifecycle === "active") {
276
+ resumeAttemptsRef.current.delete(selectedRegistrySession.id);
277
+ setResumeAttempt((visible) =>
278
+ visible?.sessionId === selectedRegistrySession.id ? null : visible,
279
+ );
280
+ return;
281
+ }
282
+ if (selectedRegistrySession.lifecycle === "suspended") {
283
+ void wakeBrowser(selectedRegistrySession);
284
+ }
285
+ }, [selectedRegistrySession, wakeBrowser]);
286
+
287
+ const browser = useBrowserSession({
288
+ ...override,
289
+ browserSessionId: selection?.sessionId ?? null,
290
+ enabled: enabled && selection !== null && controllerReady,
291
+ });
292
+ const downloads = useBrowserDownloads({
293
+ ...override,
294
+ browserSessionId: selection?.sessionId ?? null,
295
+ enabled:
296
+ enabled &&
297
+ diagnosticsView !== null &&
298
+ controllerReady &&
299
+ browser.session?.capabilities.downloads === true,
300
+ });
301
+ const loadBrowserDiagnostics = browser.diagnostics;
302
+
303
+ useEffect(() => {
304
+ for (const intervention of interventions.interventions) {
305
+ if (seenInterventionIdsRef.current.has(intervention.id)) continue;
306
+ seenInterventionIdsRef.current.add(intervention.id);
307
+ onNotify?.({
308
+ kind: "info",
309
+ message: `${interventionTitle(intervention)}: ${intervention.reason}`,
310
+ });
311
+ }
312
+ }, [interventions.interventions, onNotify]);
313
+
314
+ const resolveIntervention = useCallback(
315
+ (intervention: InteractionIntervention, outcome: "completed" | "dismissed") => {
316
+ void interventions
317
+ .resolve(intervention.id, {
318
+ expectedVersion: intervention.version,
319
+ outcome,
320
+ })
321
+ .then(() => {
322
+ onNotify?.({
323
+ kind: "info",
324
+ message:
325
+ outcome === "completed" ? "Agent notified. Continuing work." : "Request cancelled.",
326
+ });
327
+ })
328
+ .catch((cause) => notifyError(cause, "Could not update the browser request."));
329
+ },
330
+ [interventions, notifyError, onNotify],
331
+ );
332
+ const frames = useBrowserFrameStream({
333
+ ...override,
334
+ browserSessionId: selection?.sessionId ?? null,
335
+ targetId: browser.selectedTarget?.id ?? null,
336
+ enabled:
337
+ enabled &&
338
+ selection !== null &&
339
+ controllerReady &&
340
+ browser.selectedTarget !== null &&
341
+ browser.session?.capabilities.liveFrames === true,
342
+ stream: { format: "jpeg", quality: 76, maxWidth: 1_920, maxHeight: 1_200 },
343
+ ...(webSocketFactory ? { webSocketFactory } : {}),
344
+ });
345
+ const displayedFrame = frameMatchesSelectedTarget(
346
+ frames.frame,
347
+ browser.session,
348
+ browser.selectedTarget,
349
+ )
350
+ ? frames.frame
351
+ : null;
352
+ const supportsLiveFrames = browser.session?.capabilities.liveFrames === true;
353
+ const displayConnectionState = supportsLiveFrames
354
+ ? frames.state === "live" && !displayedFrame
355
+ ? "connecting"
356
+ : frames.state
357
+ : "semantic";
358
+ const selectedProfile = useMemo(
359
+ () =>
360
+ profiles.identities.find(
361
+ (identity) =>
362
+ identity.id === (browser.session?.identityId ?? selectedRegistrySession?.identityId),
363
+ ) ?? null,
364
+ [browser.session?.identityId, profiles.identities, selectedRegistrySession?.identityId],
365
+ );
366
+
367
+ useEffect(() => {
368
+ diagnosticRequestRef.current += 1;
369
+ setDiagnosticsView(null);
370
+ }, [browser.session?.id, browser.selectedTarget?.id]);
371
+
372
+ const loadDiagnostics = useCallback(() => {
373
+ const browserSessionId = browser.session?.id;
374
+ const targetId = browser.selectedTarget?.id;
375
+ if (!browserSessionId || !targetId) return;
376
+ const requestId = diagnosticRequestRef.current + 1;
377
+ diagnosticRequestRef.current = requestId;
378
+ setDiagnosticsView((current) => ({
379
+ browserSessionId,
380
+ targetId,
381
+ loading: true,
382
+ batch:
383
+ current?.browserSessionId === browserSessionId && current.targetId === targetId
384
+ ? current.batch
385
+ : null,
386
+ error: null,
387
+ }));
388
+ void loadBrowserDiagnostics({ limit: 100 })
389
+ .then((batch) => {
390
+ if (diagnosticRequestRef.current !== requestId) return;
391
+ setDiagnosticsView({ browserSessionId, targetId, loading: false, batch, error: null });
392
+ })
393
+ .catch((cause) => {
394
+ if (diagnosticRequestRef.current !== requestId) return;
395
+ setDiagnosticsView({
396
+ browserSessionId,
397
+ targetId,
398
+ loading: false,
399
+ batch: null,
400
+ error: cause instanceof Error ? cause : new Error(String(cause)),
401
+ });
402
+ });
403
+ }, [browser.selectedTarget?.id, browser.session?.id, loadBrowserDiagnostics]);
404
+
405
+ const closeDiagnostics = useCallback(() => {
406
+ diagnosticRequestRef.current += 1;
407
+ setDiagnosticsView(null);
408
+ }, []);
409
+
410
+ const refreshDiagnostics = useCallback(() => {
411
+ loadDiagnostics();
412
+ if (browser.session?.capabilities.downloads) void downloads.refresh();
413
+ }, [browser.session?.capabilities.downloads, downloads, loadDiagnostics]);
414
+
415
+ const saveDownload = useCallback(
416
+ async (
417
+ downloadId: string,
418
+ destinationPath: string,
419
+ overwrite: boolean,
420
+ ): Promise<BrowserDownloadSaveResponse> => {
421
+ const response = await downloads.saveToWorkspace(downloadId, destinationPath, { overwrite });
422
+ onNotify?.({
423
+ kind: "info",
424
+ message: `${response.download.filename} saved to ${response.destinationPath}.`,
425
+ });
426
+ return response;
427
+ },
428
+ [downloads, onNotify],
429
+ );
430
+
431
+ useEffect(() => {
432
+ const identityId = browser.session?.identityId ?? selectedRegistrySession?.identityId;
433
+ const revisionId = browser.session?.baseRevisionId ?? selectedRegistrySession?.baseRevisionId;
434
+ if (!identityId) {
435
+ setBaseRevisionOrdinal(null);
436
+ setProfileHistory(null);
437
+ return;
438
+ }
439
+ let disposed = false;
440
+ setBaseRevisionOrdinal(null);
441
+ setProfileHistory({ identityId, loading: true, revisions: [] });
442
+ void loadProfileRevisions(identityId)
443
+ .then((response) => {
444
+ if (!disposed) {
445
+ setBaseRevisionOrdinal(
446
+ revisionId
447
+ ? (response.revisions.find((revision) => revision.id === revisionId)?.ordinal ?? null)
448
+ : null,
449
+ );
450
+ setProfileHistory({ identityId, loading: false, revisions: response.revisions });
451
+ }
452
+ })
453
+ .catch(() => {
454
+ if (!disposed) setProfileHistory({ identityId, loading: false, revisions: [] });
455
+ });
456
+ return () => {
457
+ disposed = true;
458
+ };
459
+ }, [
460
+ browser.session?.baseRevisionId,
461
+ browser.session?.identityId,
462
+ loadProfileRevisions,
463
+ selectedRegistrySession?.baseRevisionId,
464
+ selectedRegistrySession?.identityId,
465
+ ]);
466
+
467
+ const createBrowser = useCallback(
468
+ (choice: BrowserLaunchChoice = { kind: "clean" }) => {
469
+ if (creating) return;
470
+ const identity =
471
+ choice.kind === "profile"
472
+ ? activeProfiles.find((candidate) => candidate.id === choice.identityId)
473
+ : null;
474
+ const device = choice.kind === "attached" ? choice.device : null;
475
+ const browserName =
476
+ device?.profileLabel ?? device?.name ?? (identity ? `${identity.name} browser` : "Browser");
477
+ setCreating(true);
478
+ void (async () => {
479
+ let linkedComputer: Pick<ComputerSession, "id" | "placement"> | null = null;
480
+ let computerViewUnavailable = false;
481
+ if (createLinkedComputer) {
482
+ try {
483
+ linkedComputer = await createLinkedComputer(
484
+ `${browserName} computer`,
485
+ device ? { kind: "attached_device", deviceId: device.id } : undefined,
486
+ );
487
+ } catch {
488
+ // Browser control is independently useful on locked/headless placements.
489
+ // A linked ComputerSession is an optional visual enhancement and must not
490
+ // prevent the canonical BrowserSession from starting.
491
+ computerViewUnavailable = true;
492
+ }
493
+ }
494
+ const response = await createRegistryBrowser({
495
+ sessionId,
496
+ name: browserName,
497
+ // Human-created browsers are always ordinary visual Chromium/Chrome.
498
+ // Lightpanda remains an agent-only optimization requested explicitly
499
+ // through browser_open; if an agent creates one it is still visible in
500
+ // the shared BrowserSession switcher.
501
+ headless: false,
502
+ initialUrl: HUMAN_BROWSER_HOME_URL,
503
+ ...(device
504
+ ? {
505
+ placement: {
506
+ kind: "attached_device" as const,
507
+ deviceId: device.id,
508
+ },
509
+ }
510
+ : {}),
511
+ ...(linkedComputer
512
+ ? {
513
+ linkedComputerSessionId: linkedComputer.id,
514
+ placement: linkedComputer.placement,
515
+ }
516
+ : {}),
517
+ ...(identity ? { identityId: identity.id } : {}),
518
+ });
519
+ setSelection({ sessionId: response.session.id, pinned: true });
520
+ if (computerViewUnavailable) {
521
+ onNotify?.({
522
+ kind: "info",
523
+ message: "Browser opened. Computer view is unavailable on this placement.",
524
+ });
525
+ }
526
+ })()
527
+ .catch((cause) => notifyError(cause, "Could not open a browser."))
528
+ .finally(() => setCreating(false));
529
+ },
530
+ [
531
+ createLinkedComputer,
532
+ createRegistryBrowser,
533
+ creating,
534
+ notifyError,
535
+ onNotify,
536
+ activeProfiles,
537
+ sessionId,
538
+ ],
539
+ );
540
+
541
+ const saveProfileVersion = useCallback(
542
+ async (newProfileName?: string): Promise<boolean> => {
543
+ const session = browser.session;
544
+ if (!session || savingProfile || !session.capabilities.identityPublication) return false;
545
+ setSavingProfile(true);
546
+ try {
547
+ let identity = selectedProfile;
548
+ if (!identity) {
549
+ const name = newProfileName?.trim() ?? "";
550
+ if (!name) return false;
551
+ // First-save is intentionally two-phase: the named identity exists
552
+ // before its first immutable revision. If capture/upload failed after
553
+ // that create, retry into the same empty identity instead of turning
554
+ // the recoverable draft into a permanent name conflict.
555
+ identity =
556
+ profiles.identities.find(
557
+ (candidate) =>
558
+ candidate.status === "active" &&
559
+ candidate.revisionCount === 0 &&
560
+ candidate.name.localeCompare(name, undefined, { sensitivity: "base" }) === 0,
561
+ ) ?? (await profiles.create({ name })).identity;
562
+ }
563
+ const response = await profiles.publish(session.id, {
564
+ identityId: identity.id,
565
+ expectedHeadGeneration: identity.headGeneration,
566
+ advanceDefault: true,
567
+ });
568
+ setBaseRevisionOrdinal(response.revision.ordinal);
569
+ setProfileHistory((current) => ({
570
+ identityId: response.identity.id,
571
+ loading: false,
572
+ revisions:
573
+ current?.identityId === response.identity.id
574
+ ? [
575
+ ...current.revisions.filter((revision) => revision.id !== response.revision.id),
576
+ response.revision,
577
+ ]
578
+ : [response.revision],
579
+ }));
580
+ await Promise.all([browser.refresh(), registry.refresh()]);
581
+ onNotify?.({
582
+ kind: "info",
583
+ message:
584
+ response.outcome === "saved_as_default"
585
+ ? `${response.identity.name} version ${response.revision.ordinal} saved for future browsers.`
586
+ : `${response.identity.name} version ${response.revision.ordinal} saved. Another version remains the default.`,
587
+ });
588
+ return true;
589
+ } catch (cause) {
590
+ notifyError(cause, "Could not save this browser profile.");
591
+ return false;
592
+ } finally {
593
+ setSavingProfile(false);
594
+ }
595
+ },
596
+ [browser, notifyError, onNotify, profiles, registry, savingProfile, selectedProfile],
597
+ );
598
+
599
+ const updateProfile = useCallback(
600
+ async (
601
+ identity: BrowserIdentity,
602
+ update: { name?: string; status?: "active" | "archived"; defaultRevisionId?: string },
603
+ ): Promise<boolean> => {
604
+ if (savingProfile) return false;
605
+ setSavingProfile(true);
606
+ try {
607
+ const response = await profiles.update(identity.id, {
608
+ expectedVersion: identity.version,
609
+ ...update,
610
+ });
611
+ if (update.defaultRevisionId) {
612
+ const ordinal = profileHistory?.revisions.find(
613
+ (revision) => revision.id === update.defaultRevisionId,
614
+ )?.ordinal;
615
+ onNotify?.({
616
+ kind: "info",
617
+ message: `${response.identity.name}${ordinal ? ` version ${ordinal}` : ""} will open by default in future browsers.`,
618
+ });
619
+ } else if (update.status === "archived") {
620
+ onNotify?.({
621
+ kind: "info",
622
+ message: `${response.identity.name} hidden from new browsers. Existing browsers are unchanged.`,
623
+ });
624
+ } else if (update.status === "active") {
625
+ onNotify?.({ kind: "info", message: `${response.identity.name} restored.` });
626
+ }
627
+ return true;
628
+ } catch (cause) {
629
+ notifyError(cause, "Could not update this browser profile.");
630
+ await profiles.refresh().catch(() => undefined);
631
+ return false;
632
+ } finally {
633
+ setSavingProfile(false);
634
+ }
635
+ },
636
+ [notifyError, onNotify, profileHistory?.revisions, profiles, savingProfile],
637
+ );
638
+
639
+ if (!enabled) return null;
640
+ if (registry.loading && liveSessions.length === 0) {
641
+ return (
642
+ <BrowserNotice
643
+ icon={<LoaderCircleIcon className="animate-spin" />}
644
+ text="Finding browsers…"
645
+ {...(className ? { className } : {})}
646
+ />
647
+ );
648
+ }
649
+ if (liveSessions.length === 0) {
650
+ if (renderEmpty) return <>{renderEmpty(() => createBrowser(), creating)}</>;
651
+ return (
652
+ <div
653
+ className={cn("flex h-full min-h-0 items-center justify-center bg-og-bg p-6", className)}
654
+ >
655
+ <div className="max-w-sm text-center">
656
+ <span className="mx-auto grid size-10 place-items-center rounded-og-md border border-og-border bg-og-surface-1 text-og-muted">
657
+ <Globe2Icon className="size-4.5" />
658
+ </span>
659
+ <p className="mt-3 text-og-menu font-medium text-og-fg">No browser open</p>
660
+ <p className="mt-1 text-og-control leading-5 text-og-muted">
661
+ A browser appears here when this agent—or another agent in the workspace—opens one.
662
+ </p>
663
+ <BrowserLaunchMenu
664
+ attachedDevices={attached.devices}
665
+ identities={profiles.identities}
666
+ creating={creating}
667
+ mutatingProfile={savingProfile}
668
+ onCreate={createBrowser}
669
+ onRestore={(identity) => updateProfile(identity, { status: "active" })}
670
+ prominent
671
+ />
672
+ {registry.error ? (
673
+ <p className="mt-3 text-og-control text-og-status-error">{registry.error.message}</p>
674
+ ) : null}
675
+ </div>
676
+ </div>
677
+ );
678
+ }
679
+
680
+ return (
681
+ <div
682
+ className={cn("relative flex h-full min-h-0 flex-col overflow-hidden bg-og-bg", className)}
683
+ >
684
+ <BrowserToolbar
685
+ sessions={liveSessions}
686
+ relevantSessionIds={new Set(relevant.map((session) => session.id))}
687
+ selectedSessionId={selection?.sessionId ?? null}
688
+ identities={profiles.identities}
689
+ attachedDevices={attached.devices}
690
+ selectedProfile={selectedProfile}
691
+ baseRevisionOrdinal={baseRevisionOrdinal}
692
+ profileHistory={profileHistory}
693
+ creating={creating}
694
+ savingProfile={savingProfile}
695
+ refreshing={registry.refreshing || attached.refreshing}
696
+ interventionCounts={interventionCounts}
697
+ onSelect={(browserSessionId) => setSelection({ sessionId: browserSessionId, pinned: true })}
698
+ onFollow={() => {
699
+ const preferred = relevant[0];
700
+ if (preferred) setSelection({ sessionId: preferred.id, pinned: false });
701
+ }}
702
+ onCreate={createBrowser}
703
+ onSaveProfile={saveProfileVersion}
704
+ onUpdateProfile={updateProfile}
705
+ onRefresh={() => void Promise.all([registry.refresh(), attached.refresh()])}
706
+ />
707
+ <InteractionInterventionBanner
708
+ interventions={selectedInterventions}
709
+ activeTargetId={browser.selectedTarget?.id ?? null}
710
+ mutating={interventions.mutating}
711
+ onOpen={(intervention) =>
712
+ void browser
713
+ .selectTarget(intervention.targetId)
714
+ .catch((cause) => notifyError(cause, "Could not open the requested browser tab."))
715
+ }
716
+ onResolve={resolveIntervention}
717
+ />
718
+ {!selectedRegistrySession ? (
719
+ <BrowserUnselectedPanel
720
+ peerCount={liveSessions.length}
721
+ creating={creating}
722
+ onCreate={() => createBrowser()}
723
+ />
724
+ ) : !controllerReady ? (
725
+ <BrowserLifecyclePanel
726
+ session={selectedRegistrySession}
727
+ attempt={
728
+ resumeAttempt?.sessionId === selectedRegistrySession.id ? resumeAttempt.attempt : null
729
+ }
730
+ onResume={() => void wakeBrowser(selectedRegistrySession, true)}
731
+ />
732
+ ) : (
733
+ <>
734
+ <BrowserTabs
735
+ targets={browser.targets}
736
+ selectedTargetId={browser.selectedTarget?.id ?? null}
737
+ mutating={savingProfile}
738
+ tabControl={browser.session?.capabilities.tabs === true}
739
+ onSelect={(targetId) =>
740
+ void browser
741
+ .selectTarget(targetId)
742
+ .catch((cause) => notifyError(cause, "Could not switch tabs."))
743
+ }
744
+ onClose={(targetId) =>
745
+ void browser
746
+ .closeTarget(targetId)
747
+ .catch((cause) => notifyError(cause, "Could not close the tab."))
748
+ }
749
+ onOpen={() =>
750
+ void browser
751
+ .openTarget(HUMAN_BROWSER_HOME_URL)
752
+ .catch((cause) => notifyError(cause, "Could not open a tab."))
753
+ }
754
+ />
755
+ <BrowserAddressBar
756
+ target={browser.selectedTarget}
757
+ loading={browser.loading || browser.mutating || savingProfile}
758
+ onNavigate={(url) =>
759
+ void browser
760
+ .act({ type: "navigate", url })
761
+ .catch((cause) => notifyError(cause, "Could not navigate."))
762
+ }
763
+ onReload={() => {
764
+ const url = browser.selectedTarget?.url;
765
+ if (url)
766
+ void browser
767
+ .act({ type: "navigate", url })
768
+ .catch((cause) => notifyError(cause, "Could not reload."));
769
+ }}
770
+ />
771
+ <BrowserViewport
772
+ frame={displayedFrame}
773
+ connectionState={displayConnectionState}
774
+ supportsLiveFrames={supportsLiveFrames}
775
+ connectionError={frames.error}
776
+ observation={browser.observation}
777
+ mutating={browser.mutating || savingProfile}
778
+ activityLabel={savingProfile ? "Saving browser version…" : undefined}
779
+ clipboardEnabled={browser.session?.capabilities.clipboard === true}
780
+ onAction={async (action, frame) => {
781
+ const receipt = frame
782
+ ? await browser.actFromFrame(action, frame)
783
+ : await browser.act(action);
784
+ if (receipt.state !== "completed") {
785
+ throw new Error(receipt.error?.message ?? "Browser input did not complete.");
786
+ }
787
+ return receipt;
788
+ }}
789
+ onReadClipboard={browser.readClipboard}
790
+ onReconnect={frames.reconnect}
791
+ onError={(cause) => notifyError(cause, "Browser input failed.")}
792
+ />
793
+ <BrowserStatusBar
794
+ session={browser.session}
795
+ profile={selectedProfile}
796
+ target={browser.selectedTarget}
797
+ observation={browser.observation}
798
+ connectionState={displayConnectionState}
799
+ refreshing={registry.refreshing}
800
+ diagnosticsOpen={diagnosticsView !== null}
801
+ onOpenComputer={
802
+ browser.session?.linkedComputerSessionId && onOpenComputer
803
+ ? () => {
804
+ const computerSessionId = browser.session?.linkedComputerSessionId;
805
+ if (computerSessionId) onOpenComputer(computerSessionId);
806
+ }
807
+ : undefined
808
+ }
809
+ onDiagnostics={diagnosticsView ? closeDiagnostics : loadDiagnostics}
810
+ />
811
+ </>
812
+ )}
813
+ {diagnosticsView ? (
814
+ <BrowserDiagnosticsDrawer
815
+ session={browser.session}
816
+ profile={selectedProfile}
817
+ target={browser.selectedTarget}
818
+ observation={browser.observation}
819
+ baseRevisionOrdinal={baseRevisionOrdinal}
820
+ state={diagnosticsView}
821
+ downloads={downloads.downloads}
822
+ downloadsEnabled={browser.session?.capabilities.downloads === true}
823
+ downloadsLoading={downloads.loading}
824
+ downloadsRefreshing={downloads.refreshing}
825
+ downloadsError={downloads.error}
826
+ savingDownloadIds={downloads.savingDownloadIds}
827
+ onSaveDownload={saveDownload}
828
+ onRefreshDownloads={() => void downloads.refresh()}
829
+ onRefresh={refreshDiagnostics}
830
+ onClose={closeDiagnostics}
831
+ />
832
+ ) : null}
833
+ </div>
834
+ );
835
+ }
836
+
837
+ function BrowserLifecyclePanel(props: {
838
+ session: BrowserSession;
839
+ attempt: BrowserResumeAttempt | null;
840
+ onResume: () => void;
841
+ }) {
842
+ const failed = props.attempt?.error ?? null;
843
+ const label = failed
844
+ ? "Browser could not reopen"
845
+ : props.session.lifecycle === "suspending"
846
+ ? "Saving browser state…"
847
+ : props.session.lifecycle === "restoring" || props.attempt?.running
848
+ ? "Opening browser…"
849
+ : props.session.lifecycle === "starting"
850
+ ? "Starting browser…"
851
+ : "Browser is sleeping";
852
+ const detail = failed
853
+ ? failed.message
854
+ : props.session.lifecycle === "suspended"
855
+ ? "Its private state is saved. Opening it creates a fresh, isolated browser process."
856
+ : "The browser will reconnect here when it is ready.";
857
+ return (
858
+ <div className="grid min-h-0 flex-1 place-items-center bg-og-bg p-6 text-center">
859
+ <div className="max-w-sm">
860
+ <span className="mx-auto grid size-10 place-items-center rounded-og-md border border-og-border bg-og-surface-1 text-og-muted">
861
+ {failed ? (
862
+ <CircleAlertIcon className="size-4.5 text-og-status-error" />
863
+ ) : (
864
+ <LoaderCircleIcon className="size-4.5 animate-spin" />
865
+ )}
866
+ </span>
867
+ <p className="mt-3 text-og-menu font-medium text-og-fg">{label}</p>
868
+ <p className="mt-1 text-og-control leading-5 text-og-muted">{detail}</p>
869
+ {failed || (props.session.lifecycle === "suspended" && !props.attempt?.running) ? (
870
+ <button
871
+ type="button"
872
+ onClick={props.onResume}
873
+ 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"
874
+ >
875
+ Open browser
876
+ </button>
877
+ ) : null}
878
+ </div>
879
+ </div>
880
+ );
881
+ }
882
+
883
+ function BrowserToolbar(props: {
884
+ sessions: BrowserSession[];
885
+ relevantSessionIds: Set<string>;
886
+ selectedSessionId: string | null;
887
+ attachedDevices: AttachedBrowserDevice[];
888
+ identities: BrowserIdentity[];
889
+ selectedProfile: BrowserIdentity | null;
890
+ baseRevisionOrdinal: number | null;
891
+ profileHistory: { identityId: string; loading: boolean; revisions: BrowserRevision[] } | null;
892
+ creating: boolean;
893
+ savingProfile: boolean;
894
+ refreshing: boolean;
895
+ interventionCounts: Map<string, number>;
896
+ onSelect: (id: string) => void;
897
+ onFollow: () => void;
898
+ onCreate: (choice?: BrowserLaunchChoice) => void;
899
+ onSaveProfile: (newProfileName?: string) => Promise<boolean>;
900
+ onUpdateProfile: (
901
+ identity: BrowserIdentity,
902
+ update: { name?: string; status?: "active" | "archived"; defaultRevisionId?: string },
903
+ ) => Promise<boolean>;
904
+ onRefresh: () => void;
905
+ }) {
906
+ const detailsRef = useRef<HTMLDetailsElement | null>(null);
907
+ const selected = props.sessions.find((session) => session.id === props.selectedSessionId);
908
+ const current = props.sessions.filter((session) => props.relevantSessionIds.has(session.id));
909
+ const others = props.sessions.filter((session) => !props.relevantSessionIds.has(session.id));
910
+ const choose = (id: string) => {
911
+ props.onSelect(id);
912
+ detailsRef.current?.removeAttribute("open");
913
+ };
914
+ return (
915
+ <div className="flex h-10 shrink-0 items-center gap-1.5 border-b border-og-border bg-og-surface-0 px-2">
916
+ <details ref={detailsRef} className="relative min-w-0">
917
+ <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">
918
+ <Globe2Icon className="size-3.5 shrink-0 text-og-muted" />
919
+ <span className="truncate font-medium">{selected?.name ?? "Browser"}</span>
920
+ {(props.interventionCounts.get(selected?.id ?? "") ?? 0) > 0 ? (
921
+ <span className="size-1.5 shrink-0 rounded-full bg-og-status-waiting" />
922
+ ) : null}
923
+ <ChevronDownIcon className="size-3 shrink-0 text-og-subtle" />
924
+ </summary>
925
+ <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">
926
+ <BrowserSessionGroup
927
+ label="Current agent"
928
+ sessions={current}
929
+ identities={props.identities}
930
+ selectedId={props.selectedSessionId}
931
+ interventionCounts={props.interventionCounts}
932
+ onSelect={choose}
933
+ />
934
+ <BrowserSessionGroup
935
+ label="Workspace browsers"
936
+ sessions={others}
937
+ identities={props.identities}
938
+ selectedId={props.selectedSessionId}
939
+ interventionCounts={props.interventionCounts}
940
+ onSelect={choose}
941
+ />
942
+ <div className="mt-1 flex gap-1 border-t border-og-border pt-1">
943
+ {current.length > 0 ? (
944
+ <MenuButton onClick={props.onFollow}>Follow agent</MenuButton>
945
+ ) : null}
946
+ <MenuButton onClick={() => props.onCreate()} disabled={props.creating}>
947
+ <PlusIcon className="size-3.5" /> New browser
948
+ </MenuButton>
949
+ </div>
950
+ </div>
951
+ </details>
952
+ <span className="min-w-0 flex-1 truncate text-og-xs text-og-subtle">
953
+ {placementLabel(selected)}
954
+ </span>
955
+ <BrowserProfileMenu
956
+ session={selected ?? null}
957
+ identity={props.selectedProfile}
958
+ baseRevisionOrdinal={props.baseRevisionOrdinal}
959
+ history={props.profileHistory}
960
+ saving={props.savingProfile}
961
+ onSave={props.onSaveProfile}
962
+ onUpdate={props.onUpdateProfile}
963
+ />
964
+ <button
965
+ type="button"
966
+ onClick={props.onRefresh}
967
+ className="grid size-7 place-items-center rounded-og-sm text-og-muted transition hover:bg-og-surface-2 hover:text-og-fg"
968
+ aria-label="Refresh browsers"
969
+ >
970
+ <RefreshCwIcon className={cn("size-3.5", props.refreshing && "animate-spin")} />
971
+ </button>
972
+ <BrowserLaunchMenu
973
+ attachedDevices={props.attachedDevices}
974
+ identities={props.identities}
975
+ creating={props.creating}
976
+ mutatingProfile={props.savingProfile}
977
+ onCreate={props.onCreate}
978
+ onRestore={(identity) => props.onUpdateProfile(identity, { status: "active" })}
979
+ />
980
+ </div>
981
+ );
982
+ }
983
+
984
+ function BrowserUnselectedPanel(props: {
985
+ peerCount: number;
986
+ creating: boolean;
987
+ onCreate: () => void;
988
+ }) {
989
+ return (
990
+ <div className="grid min-h-0 flex-1 place-items-center bg-og-bg p-6 text-center">
991
+ <div className="max-w-sm">
992
+ <span className="mx-auto grid size-10 place-items-center rounded-og-md border border-og-border bg-og-surface-1 text-og-muted">
993
+ <Globe2Icon className="size-4.5" />
994
+ </span>
995
+ <p className="mt-3 text-og-menu font-medium text-og-fg">No browser for this agent</p>
996
+ <p className="mt-1 text-og-control leading-5 text-og-muted">
997
+ {props.peerCount === 1
998
+ ? "One workspace browser is available from the browser menu."
999
+ : `${props.peerCount} workspace browsers are available from the browser menu.`}
1000
+ </p>
1001
+ <button
1002
+ type="button"
1003
+ disabled={props.creating}
1004
+ onClick={props.onCreate}
1005
+ className="mt-4 inline-flex h-8 items-center gap-1.5 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 disabled:cursor-not-allowed disabled:opacity-50"
1006
+ >
1007
+ <PlusIcon className="size-3.5" />
1008
+ {props.creating ? "Opening…" : "New browser"}
1009
+ </button>
1010
+ </div>
1011
+ </div>
1012
+ );
1013
+ }
1014
+
1015
+ function BrowserSessionGroup(props: {
1016
+ label: string;
1017
+ sessions: BrowserSession[];
1018
+ identities: BrowserIdentity[];
1019
+ selectedId: string | null;
1020
+ interventionCounts: Map<string, number>;
1021
+ onSelect: (id: string) => void;
1022
+ }) {
1023
+ if (props.sessions.length === 0) return null;
1024
+ return (
1025
+ <div className="py-1">
1026
+ <p className="px-2 pb-1 text-[10px] font-medium uppercase tracking-[0.12em] text-og-subtle">
1027
+ {props.label}
1028
+ </p>
1029
+ {props.sessions.map((session) => {
1030
+ const identity = props.identities.find((candidate) => candidate.id === session.identityId);
1031
+ const interventionCount = props.interventionCounts.get(session.id) ?? 0;
1032
+ return (
1033
+ <button
1034
+ key={session.id}
1035
+ type="button"
1036
+ onClick={() => props.onSelect(session.id)}
1037
+ className={cn(
1038
+ "flex w-full items-center gap-2 rounded-og-sm px-2 py-1.5 text-left transition hover:bg-og-surface-2",
1039
+ session.id === props.selectedId && "bg-og-surface-2",
1040
+ )}
1041
+ >
1042
+ <span
1043
+ className={cn(
1044
+ "size-1.5 rounded-full",
1045
+ session.lifecycle === "active" ? "bg-og-status-running" : "bg-og-muted",
1046
+ )}
1047
+ />
1048
+ <span className="min-w-0 flex-1">
1049
+ <span className="block truncate text-og-control text-og-fg">{session.name}</span>
1050
+ <span className="block truncate text-og-xs text-og-subtle">
1051
+ {identity ? `${identity.name} · ` : ""}
1052
+ {placementLabel(session)}
1053
+ </span>
1054
+ </span>
1055
+ {interventionCount > 0 ? (
1056
+ <span className="rounded-full bg-og-status-waiting/12 px-1.5 py-0.5 text-[10px] font-medium text-og-status-waiting">
1057
+ {interventionCount}
1058
+ </span>
1059
+ ) : null}
1060
+ </button>
1061
+ );
1062
+ })}
1063
+ </div>
1064
+ );
1065
+ }
1066
+
1067
+ function BrowserLaunchMenu(props: {
1068
+ attachedDevices: AttachedBrowserDevice[];
1069
+ identities: BrowserIdentity[];
1070
+ creating: boolean;
1071
+ mutatingProfile: boolean;
1072
+ onCreate: (choice?: BrowserLaunchChoice) => void;
1073
+ onRestore: (identity: BrowserIdentity) => Promise<boolean>;
1074
+ prominent?: boolean;
1075
+ }) {
1076
+ const detailsRef = useRef<HTMLDetailsElement | null>(null);
1077
+ const choose = (choice?: BrowserLaunchChoice) => {
1078
+ detailsRef.current?.removeAttribute("open");
1079
+ props.onCreate(choice);
1080
+ };
1081
+ const restore = async (identity: BrowserIdentity) => {
1082
+ if (await props.onRestore(identity)) detailsRef.current?.removeAttribute("open");
1083
+ };
1084
+ const busy = props.creating || props.mutatingProfile;
1085
+ const activeIdentities = props.identities.filter((identity) => identity.status === "active");
1086
+ const archivedIdentities = props.identities.filter((identity) => identity.status === "archived");
1087
+ return (
1088
+ <details ref={detailsRef} className={cn("relative", props.prominent && "mt-4 inline-block")}>
1089
+ <summary
1090
+ onClick={(event) => {
1091
+ if (busy) event.preventDefault();
1092
+ }}
1093
+ className={cn(
1094
+ "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",
1095
+ props.prominent
1096
+ ? "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"
1097
+ : "grid size-7 place-items-center rounded-og-sm",
1098
+ busy && "pointer-events-none opacity-50",
1099
+ )}
1100
+ aria-label="New browser"
1101
+ >
1102
+ {busy ? (
1103
+ <LoaderCircleIcon className="size-3.5 animate-spin" />
1104
+ ) : (
1105
+ <PlusIcon className="size-3.5" />
1106
+ )}
1107
+ {props.prominent ? "New browser" : null}
1108
+ {props.prominent ? <ChevronDownIcon className="size-3" /> : null}
1109
+ </summary>
1110
+ <div
1111
+ className={cn(
1112
+ "absolute z-40 w-72 overflow-hidden rounded-og-md border border-og-border bg-og-surface-1 p-1 text-left shadow-xl",
1113
+ props.prominent ? "left-1/2 top-10 -translate-x-1/2" : "right-0 top-8",
1114
+ )}
1115
+ >
1116
+ {props.attachedDevices.length > 0 ? (
1117
+ <div className="pb-1">
1118
+ <p className="px-2 pb-1 pt-1 text-[10px] font-medium uppercase tracking-[0.12em] text-og-subtle">
1119
+ Connected Chrome
1120
+ </p>
1121
+ {props.attachedDevices.map((device) => (
1122
+ <button
1123
+ key={device.id}
1124
+ type="button"
1125
+ onClick={() => choose({ kind: "attached", device })}
1126
+ className="flex w-full items-center gap-2 rounded-og-sm px-2 py-2 text-left transition hover:bg-og-surface-2"
1127
+ >
1128
+ <MonitorIcon className="size-3.5 shrink-0 text-og-muted" />
1129
+ <span className="min-w-0 flex-1">
1130
+ <span className="block truncate text-og-control text-og-fg">
1131
+ {device.profileLabel ?? device.name}
1132
+ </span>
1133
+ <span className="block truncate text-og-xs text-og-subtle">
1134
+ {device.browserName} · {device.tabCount} open{" "}
1135
+ {device.tabCount === 1 ? "tab" : "tabs"}
1136
+ </span>
1137
+ </span>
1138
+ <span className="size-1.5 shrink-0 rounded-full bg-og-status-running" />
1139
+ </button>
1140
+ ))}
1141
+ </div>
1142
+ ) : null}
1143
+ <p
1144
+ className={cn(
1145
+ "px-2 pb-1 pt-1 text-[10px] font-medium uppercase tracking-[0.12em] text-og-subtle",
1146
+ props.attachedDevices.length > 0 && "border-t border-og-border",
1147
+ )}
1148
+ >
1149
+ Open isolated browser
1150
+ </p>
1151
+ <button
1152
+ type="button"
1153
+ onClick={() => choose({ kind: "clean" })}
1154
+ className="flex w-full items-center gap-2 rounded-og-sm px-2 py-2 text-left transition hover:bg-og-surface-2"
1155
+ >
1156
+ <Globe2Icon className="size-3.5 text-og-muted" />
1157
+ <span>
1158
+ <span className="block text-og-control text-og-fg">Fresh browser</span>
1159
+ <span className="block text-og-xs text-og-subtle">Visual browser · no saved state</span>
1160
+ </span>
1161
+ </button>
1162
+ {activeIdentities.length > 0 ? (
1163
+ <div className="mt-1 border-t border-og-border pt-1">
1164
+ {activeIdentities.map((identity) => (
1165
+ <button
1166
+ key={identity.id}
1167
+ type="button"
1168
+ onClick={() => choose({ kind: "profile", identityId: identity.id })}
1169
+ className="flex w-full items-center gap-2 rounded-og-sm px-2 py-2 text-left transition hover:bg-og-surface-2"
1170
+ >
1171
+ <UserRoundIcon className="size-3.5 text-og-muted" />
1172
+ <span className="min-w-0">
1173
+ <span className="block truncate text-og-control text-og-fg">{identity.name}</span>
1174
+ <span className="block text-og-xs text-og-subtle">
1175
+ {identity.defaultRevisionId
1176
+ ? `${identity.revisionCount} saved version${identity.revisionCount === 1 ? "" : "s"}`
1177
+ : "Ready for first sign-in"}
1178
+ </span>
1179
+ </span>
1180
+ </button>
1181
+ ))}
1182
+ </div>
1183
+ ) : null}
1184
+ {archivedIdentities.length > 0 ? (
1185
+ <details className="mt-1 border-t border-og-border pt-1">
1186
+ <summary className="flex cursor-pointer list-none items-center gap-1.5 rounded-og-sm px-2 py-1.5 text-og-xs text-og-subtle transition hover:bg-og-surface-2 hover:text-og-fg [&::-webkit-details-marker]:hidden">
1187
+ <ArchiveIcon className="size-3" /> Hidden profiles · {archivedIdentities.length}
1188
+ <ChevronDownIcon className="ml-auto size-3" />
1189
+ </summary>
1190
+ {archivedIdentities.map((identity) => (
1191
+ <button
1192
+ key={identity.id}
1193
+ type="button"
1194
+ disabled={busy}
1195
+ onClick={() => void restore(identity)}
1196
+ className="flex w-full items-center gap-2 rounded-og-sm px-2 py-2 text-left transition hover:bg-og-surface-2 disabled:opacity-50"
1197
+ >
1198
+ <RotateCcwIcon className="size-3.5 text-og-muted" />
1199
+ <span className="min-w-0 flex-1 truncate text-og-control text-og-fg">
1200
+ Restore {identity.name}
1201
+ </span>
1202
+ </button>
1203
+ ))}
1204
+ </details>
1205
+ ) : null}
1206
+ </div>
1207
+ </details>
1208
+ );
1209
+ }
1210
+
1211
+ function BrowserProfileMenu(props: {
1212
+ session: BrowserSession | null;
1213
+ identity: BrowserIdentity | null;
1214
+ baseRevisionOrdinal: number | null;
1215
+ history: { identityId: string; loading: boolean; revisions: BrowserRevision[] } | null;
1216
+ saving: boolean;
1217
+ onSave: (newProfileName?: string) => Promise<boolean>;
1218
+ onUpdate: (
1219
+ identity: BrowserIdentity,
1220
+ update: { name?: string; status?: "active" | "archived"; defaultRevisionId?: string },
1221
+ ) => Promise<boolean>;
1222
+ }) {
1223
+ const detailsRef = useRef<HTMLDetailsElement | null>(null);
1224
+ const nameInputId = useId();
1225
+ const [name, setName] = useState("");
1226
+ const attached = props.session?.placement.kind === "attached_device";
1227
+ const canSave =
1228
+ (props.session?.capabilities.identityPublication ?? false) &&
1229
+ props.identity?.status !== "archived";
1230
+ const revisions =
1231
+ props.identity && props.history?.identityId === props.identity.id
1232
+ ? [...props.history.revisions].sort((left, right) => right.ordinal - left.ordinal)
1233
+ : [];
1234
+ const save = async (newProfileName?: string) => {
1235
+ if (await props.onSave(newProfileName)) {
1236
+ setName("");
1237
+ detailsRef.current?.removeAttribute("open");
1238
+ }
1239
+ };
1240
+ const versionLabel = attached
1241
+ ? "live"
1242
+ : props.identity
1243
+ ? props.baseRevisionOrdinal
1244
+ ? `v${props.baseRevisionOrdinal}`
1245
+ : props.session?.baseRevisionId
1246
+ ? "saved"
1247
+ : "unsaved"
1248
+ : "temporary";
1249
+ return (
1250
+ <details ref={detailsRef} className="relative min-w-0">
1251
+ <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">
1252
+ {props.saving ? (
1253
+ <LoaderCircleIcon className="size-3 animate-spin" />
1254
+ ) : attached ? (
1255
+ <MonitorIcon className="size-3" />
1256
+ ) : (
1257
+ <UserRoundIcon className="size-3" />
1258
+ )}
1259
+ <span className="truncate">
1260
+ {attached ? props.session?.name : (props.identity?.name ?? "Temporary")}
1261
+ </span>
1262
+ {attached || props.identity ? (
1263
+ <span className="shrink-0 text-og-subtle">· {versionLabel}</span>
1264
+ ) : null}
1265
+ <ChevronDownIcon className="size-3 shrink-0 text-og-subtle" />
1266
+ </summary>
1267
+ <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">
1268
+ <div className="flex items-start gap-2">
1269
+ <span className="grid size-7 shrink-0 place-items-center rounded-og-sm bg-og-surface-2 text-og-muted">
1270
+ {attached ? (
1271
+ <MonitorIcon className="size-3.5" />
1272
+ ) : (
1273
+ <UserRoundIcon className="size-3.5" />
1274
+ )}
1275
+ </span>
1276
+ <div className="min-w-0">
1277
+ <p className="truncate text-og-control font-medium text-og-fg">
1278
+ {attached ? props.session?.name : (props.identity?.name ?? "Temporary browser")}
1279
+ </p>
1280
+ <p className="mt-0.5 text-og-xs leading-4 text-og-subtle">
1281
+ {attached
1282
+ ? "This session drives the existing Chrome profile and its current login state."
1283
+ : props.identity?.status === "archived"
1284
+ ? "Hidden from new browsers. This already-open browser is unchanged."
1285
+ : props.identity
1286
+ ? props.baseRevisionOrdinal
1287
+ ? `Started from version ${props.baseRevisionOrdinal}.`
1288
+ : "Not saved yet."
1289
+ : "This browser is not reusable yet."}
1290
+ </p>
1291
+ </div>
1292
+ </div>
1293
+ {props.identity && revisions.length > 0 ? (
1294
+ <div className="mt-3 border-t border-og-border pt-2">
1295
+ <p className="px-1 text-[10px] font-medium uppercase tracking-[0.12em] text-og-subtle">
1296
+ Saved versions
1297
+ </p>
1298
+ <div className="mt-1 max-h-40 overflow-y-auto">
1299
+ {revisions.map((revision) => {
1300
+ const isDefault = revision.id === props.identity?.defaultRevisionId;
1301
+ const isCurrent = revision.ordinal === props.baseRevisionOrdinal;
1302
+ return (
1303
+ <button
1304
+ key={revision.id}
1305
+ type="button"
1306
+ disabled={isDefault || props.saving || props.identity?.status === "archived"}
1307
+ onClick={() =>
1308
+ void props.onUpdate(props.identity!, { defaultRevisionId: revision.id })
1309
+ }
1310
+ className="flex w-full items-center gap-2 rounded-og-sm px-2 py-1.5 text-left transition hover:bg-og-surface-2 disabled:cursor-default disabled:opacity-70"
1311
+ >
1312
+ <span className="grid size-4 place-items-center text-og-muted">
1313
+ {isDefault ? <CheckIcon className="size-3.5 text-og-status-running" /> : null}
1314
+ </span>
1315
+ <span className="min-w-0 flex-1 text-og-control text-og-fg">
1316
+ Version {revision.ordinal}
1317
+ </span>
1318
+ <span className="text-[10px] text-og-subtle">
1319
+ {isCurrent && isDefault
1320
+ ? "current · default"
1321
+ : isDefault
1322
+ ? "default"
1323
+ : isCurrent
1324
+ ? "current · Use by default"
1325
+ : "Use by default"}
1326
+ </span>
1327
+ </button>
1328
+ );
1329
+ })}
1330
+ </div>
1331
+ <p className="mt-1 px-2 text-[10px] leading-4 text-og-subtle">
1332
+ Changing the default affects only browsers opened later.
1333
+ </p>
1334
+ </div>
1335
+ ) : props.identity &&
1336
+ props.history?.identityId === props.identity.id &&
1337
+ props.history.loading ? (
1338
+ <p className="mt-3 flex items-center gap-1.5 border-t border-og-border px-2 pt-2 text-og-xs text-og-subtle">
1339
+ <LoaderCircleIcon className="size-3 animate-spin" /> Loading saved versions…
1340
+ </p>
1341
+ ) : null}
1342
+ {canSave ? (
1343
+ props.identity ? (
1344
+ <button
1345
+ type="button"
1346
+ onClick={() => void save()}
1347
+ disabled={props.saving}
1348
+ 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"
1349
+ >
1350
+ {props.saving ? (
1351
+ <LoaderCircleIcon className="size-3.5 animate-spin" />
1352
+ ) : (
1353
+ <SaveIcon className="size-3.5" />
1354
+ )}
1355
+ {props.saving ? "Saving browser version…" : "Save new version"}
1356
+ </button>
1357
+ ) : (
1358
+ <form
1359
+ className="mt-3"
1360
+ onSubmit={(event) => {
1361
+ event.preventDefault();
1362
+ if (name.trim()) void save(name);
1363
+ }}
1364
+ >
1365
+ <label className="text-og-xs font-medium text-og-muted" htmlFor={nameInputId}>
1366
+ Profile name
1367
+ </label>
1368
+ <div className="mt-1 flex gap-1.5">
1369
+ <input
1370
+ id={nameInputId}
1371
+ value={name}
1372
+ onInput={(event) => setName(event.currentTarget.value)}
1373
+ maxLength={200}
1374
+ placeholder="Work"
1375
+ 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-hidden focus-visible:ring-2 focus-visible:ring-og-accent"
1376
+ />
1377
+ <button
1378
+ type="submit"
1379
+ disabled={!name.trim() || props.saving}
1380
+ 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"
1381
+ >
1382
+ {props.saving ? (
1383
+ <LoaderCircleIcon className="size-3.5 animate-spin" />
1384
+ ) : (
1385
+ <SaveIcon className="size-3.5" />
1386
+ )}
1387
+ Save
1388
+ </button>
1389
+ </div>
1390
+ </form>
1391
+ )
1392
+ ) : (
1393
+ <p className="mt-3 rounded-og-sm bg-og-surface-2 px-2.5 py-2 text-og-xs text-og-subtle">
1394
+ {attached
1395
+ ? "Chrome keeps this profile's state directly; OpenGeni does not copy it automatically."
1396
+ : props.identity?.status === "archived"
1397
+ ? "Restore this profile before saving another version."
1398
+ : "This browser cannot save reusable profile state."}
1399
+ </p>
1400
+ )}
1401
+ {!attached && props.identity ? (
1402
+ <button
1403
+ type="button"
1404
+ disabled={props.saving}
1405
+ onClick={() =>
1406
+ void props.onUpdate(props.identity!, {
1407
+ status: props.identity!.status === "active" ? "archived" : "active",
1408
+ })
1409
+ }
1410
+ className="mt-2 flex h-8 w-full items-center justify-center gap-1.5 rounded-og-sm px-3 text-og-control text-og-muted transition hover:bg-og-surface-2 hover:text-og-fg disabled:opacity-50"
1411
+ >
1412
+ {props.identity.status === "active" ? (
1413
+ <ArchiveIcon className="size-3.5" />
1414
+ ) : (
1415
+ <RotateCcwIcon className="size-3.5" />
1416
+ )}
1417
+ {props.identity.status === "active" ? "Hide from new browsers" : "Restore profile"}
1418
+ </button>
1419
+ ) : null}
1420
+ {canSave ? (
1421
+ <p className="mt-2 text-[10px] leading-4 text-og-subtle">
1422
+ Saving briefly restarts this browser. Other open browsers are unchanged.
1423
+ </p>
1424
+ ) : null}
1425
+ </div>
1426
+ </details>
1427
+ );
1428
+ }
1429
+
1430
+ function MenuButton(props: { children: ReactNode; onClick: () => void; disabled?: boolean }) {
1431
+ return (
1432
+ <button
1433
+ type="button"
1434
+ onClick={props.onClick}
1435
+ disabled={props.disabled}
1436
+ 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"
1437
+ >
1438
+ {props.children}
1439
+ </button>
1440
+ );
1441
+ }
1442
+
1443
+ function BrowserTabs(props: {
1444
+ targets: BrowserTarget[];
1445
+ selectedTargetId: string | null;
1446
+ mutating: boolean;
1447
+ tabControl: boolean;
1448
+ onSelect: (id: string) => void;
1449
+ onClose: (id: string) => void;
1450
+ onOpen: () => void;
1451
+ }) {
1452
+ const pages = props.targets.filter((target) => target.kind === "page" || target.kind === "popup");
1453
+ return (
1454
+ <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">
1455
+ {pages.map((target) => (
1456
+ <div
1457
+ key={target.id}
1458
+ className={cn(
1459
+ "group flex h-8 min-w-24 max-w-48 items-center rounded-t-og-sm border border-b-0 border-transparent px-2",
1460
+ target.id === props.selectedTargetId
1461
+ ? "border-og-border bg-og-bg text-og-fg"
1462
+ : "text-og-muted hover:bg-og-surface-1",
1463
+ )}
1464
+ >
1465
+ <button
1466
+ type="button"
1467
+ onClick={() => props.onSelect(target.id)}
1468
+ className="min-w-0 flex-1 truncate text-left text-og-control"
1469
+ >
1470
+ {target.title || shortUrl(target.url)}
1471
+ </button>
1472
+ {props.tabControl ? (
1473
+ <button
1474
+ type="button"
1475
+ onClick={() => props.onClose(target.id)}
1476
+ disabled={props.mutating}
1477
+ 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"
1478
+ aria-label={`Close ${target.title || "tab"}`}
1479
+ >
1480
+ <XIcon className="size-3" />
1481
+ </button>
1482
+ ) : null}
1483
+ </div>
1484
+ ))}
1485
+ {props.tabControl ? (
1486
+ <button
1487
+ type="button"
1488
+ onClick={props.onOpen}
1489
+ disabled={props.mutating}
1490
+ 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"
1491
+ aria-label="New tab"
1492
+ >
1493
+ <PlusIcon className="size-3.5" />
1494
+ </button>
1495
+ ) : null}
1496
+ </div>
1497
+ );
1498
+ }
1499
+
1500
+ function BrowserAddressBar(props: {
1501
+ target: BrowserTarget | null;
1502
+ loading: boolean;
1503
+ onNavigate: (url: string) => void;
1504
+ onReload: () => void;
1505
+ }) {
1506
+ const [draft, setDraft] = useState(props.target?.url ?? "");
1507
+ const focusedRef = useRef(false);
1508
+ useEffect(() => {
1509
+ if (!focusedRef.current) setDraft(props.target?.url ?? "");
1510
+ }, [props.target?.id, props.target?.url]);
1511
+ const submit = (event: FormEvent) => {
1512
+ event.preventDefault();
1513
+ const normalized = normalizeBrowserAddress(draft);
1514
+ if (normalized) props.onNavigate(normalized);
1515
+ };
1516
+ return (
1517
+ <form
1518
+ onSubmit={submit}
1519
+ className="flex h-10 shrink-0 items-center gap-1.5 border-b border-og-border bg-og-bg px-2"
1520
+ >
1521
+ <button
1522
+ type="button"
1523
+ onClick={props.onReload}
1524
+ disabled={!props.target || props.loading}
1525
+ 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"
1526
+ aria-label="Reload"
1527
+ >
1528
+ <RefreshCwIcon className={cn("size-3.5", props.loading && "animate-spin")} />
1529
+ </button>
1530
+ <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">
1531
+ <Globe2Icon className="size-3 shrink-0 text-og-subtle" />
1532
+ <input
1533
+ value={draft}
1534
+ onChange={(event) => setDraft(event.target.value)}
1535
+ onFocus={() => {
1536
+ focusedRef.current = true;
1537
+ }}
1538
+ onBlur={() => {
1539
+ focusedRef.current = false;
1540
+ }}
1541
+ onKeyDown={(event) => {
1542
+ if (event.key !== "Enter") return;
1543
+ event.preventDefault();
1544
+ const normalized = normalizeBrowserAddress(draft);
1545
+ if (normalized) props.onNavigate(normalized);
1546
+ }}
1547
+ disabled={!props.target}
1548
+ className="min-w-0 flex-1 bg-transparent text-og-control text-og-fg placeholder:text-og-subtle focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-og-accent disabled:opacity-50"
1549
+ aria-label="Address"
1550
+ spellCheck={false}
1551
+ placeholder="Search or enter address"
1552
+ />
1553
+ </div>
1554
+ </form>
1555
+ );
1556
+ }
1557
+
1558
+ function BrowserViewport(props: {
1559
+ frame: BrowserFrame | null;
1560
+ connectionState: string;
1561
+ supportsLiveFrames: boolean;
1562
+ connectionError: Error | null;
1563
+ observation: ReturnType<typeof useBrowserSession>["observation"];
1564
+ mutating: boolean;
1565
+ clipboardEnabled: boolean;
1566
+ activityLabel?: string | undefined;
1567
+ onAction: (action: BrowserAction, frame: BrowserFrame | null) => Promise<BrowserActionReceipt>;
1568
+ onReadClipboard: () => Promise<{ text: string }>;
1569
+ onReconnect: () => void;
1570
+ onError: (cause: unknown) => void;
1571
+ }) {
1572
+ const canvasRef = useRef<HTMLCanvasElement | null>(null);
1573
+ const inputRef = useRef<HTMLTextAreaElement | null>(null);
1574
+ const composingRef = useRef(false);
1575
+ const pointerStartRef = useRef<PointerStart | null>(null);
1576
+ const lastClickRef = useRef<{
1577
+ at: number;
1578
+ x: number;
1579
+ y: number;
1580
+ frame: BrowserFrame;
1581
+ } | null>(null);
1582
+ const wheelRef = useRef<{
1583
+ x: number;
1584
+ y: number;
1585
+ deltaX: number;
1586
+ deltaY: number;
1587
+ frame: BrowserFrame;
1588
+ timer: ReturnType<typeof setTimeout> | null;
1589
+ } | null>(null);
1590
+ const pendingTextRef = useRef<{
1591
+ text: string;
1592
+ frame: BrowserFrame | null;
1593
+ timer: ReturnType<typeof setTimeout>;
1594
+ } | null>(null);
1595
+ const actionRef = useRef(props.onAction);
1596
+ const readClipboardRef = useRef(props.onReadClipboard);
1597
+ const errorRef = useRef(props.onError);
1598
+ const actionTailRef = useRef<Promise<void>>(Promise.resolve());
1599
+ const queuedFrameRef = useRef<BrowserFrame | null>(null);
1600
+ const decodingFrameRef = useRef(false);
1601
+ const mountedRef = useRef(true);
1602
+ actionRef.current = props.onAction;
1603
+ readClipboardRef.current = props.onReadClipboard;
1604
+ errorRef.current = props.onError;
1605
+
1606
+ const paintQueuedFrames = useCallback(() => {
1607
+ if (decodingFrameRef.current) return;
1608
+ decodingFrameRef.current = true;
1609
+ void (async () => {
1610
+ try {
1611
+ while (mountedRef.current) {
1612
+ const frame = queuedFrameRef.current;
1613
+ queuedFrameRef.current = null;
1614
+ if (!frame) break;
1615
+
1616
+ let objectUrl: string | null = null;
1617
+ try {
1618
+ const blob = new Blob([frame.data.slice().buffer], {
1619
+ type: frame.mediaType,
1620
+ });
1621
+ if (typeof createImageBitmap === "function") {
1622
+ const bitmap = await createImageBitmap(blob);
1623
+ try {
1624
+ const canvas = canvasRef.current;
1625
+ if (mountedRef.current && canvas) {
1626
+ paintCanvas(canvas, bitmap, frame.width, frame.height);
1627
+ }
1628
+ } finally {
1629
+ bitmap.close();
1630
+ }
1631
+ continue;
1632
+ }
1633
+ objectUrl = URL.createObjectURL(blob);
1634
+ const image = await loadImage(objectUrl);
1635
+ const canvas = canvasRef.current;
1636
+ if (mountedRef.current && canvas) {
1637
+ paintCanvas(canvas, image, frame.width, frame.height);
1638
+ }
1639
+ } catch (cause) {
1640
+ if (mountedRef.current) errorRef.current(cause);
1641
+ } finally {
1642
+ if (objectUrl) URL.revokeObjectURL(objectUrl);
1643
+ }
1644
+ }
1645
+ } finally {
1646
+ decodingFrameRef.current = false;
1647
+ if (mountedRef.current && queuedFrameRef.current) paintQueuedFrames();
1648
+ }
1649
+ })();
1650
+ }, []);
1651
+
1652
+ useEffect(() => {
1653
+ mountedRef.current = true;
1654
+ return () => {
1655
+ mountedRef.current = false;
1656
+ queuedFrameRef.current = null;
1657
+ };
1658
+ }, []);
1659
+
1660
+ useEffect(() => {
1661
+ if (!props.frame) return;
1662
+ queuedFrameRef.current = props.frame;
1663
+ paintQueuedFrames();
1664
+ }, [paintQueuedFrames, props.frame]);
1665
+
1666
+ useEffect(
1667
+ () => () => {
1668
+ if (wheelRef.current?.timer) clearTimeout(wheelRef.current.timer);
1669
+ if (pendingTextRef.current?.timer) clearTimeout(pendingTextRef.current.timer);
1670
+ },
1671
+ [],
1672
+ );
1673
+
1674
+ const enqueue = useCallback(
1675
+ (
1676
+ action: BrowserAction,
1677
+ frame: BrowserFrame | null,
1678
+ after?: (receipt: BrowserActionReceipt) => Promise<void>,
1679
+ ) => {
1680
+ actionTailRef.current = actionTailRef.current
1681
+ .catch(() => undefined)
1682
+ .then(async () => {
1683
+ const receipt = await actionRef.current(action, frame);
1684
+ await after?.(receipt);
1685
+ })
1686
+ .catch((cause) => errorRef.current(cause));
1687
+ },
1688
+ [],
1689
+ );
1690
+
1691
+ const point = useCallback(
1692
+ (frame: BrowserFrame, clientX: number, clientY: number) =>
1693
+ browserPoint(canvasRef.current, frame, clientX, clientY),
1694
+ [],
1695
+ );
1696
+
1697
+ const flushPendingText = useCallback(() => {
1698
+ const pending = pendingTextRef.current;
1699
+ if (!pending) return;
1700
+ clearTimeout(pending.timer);
1701
+ pendingTextRef.current = null;
1702
+ enqueue({ type: "type", text: pending.text }, pending.frame);
1703
+ }, [enqueue]);
1704
+
1705
+ const flushPendingClick = useCallback(() => {
1706
+ lastClickRef.current = null;
1707
+ }, []);
1708
+
1709
+ const pointerDown = (event: PointerEvent<HTMLCanvasElement>) => {
1710
+ if (!props.frame || event.button !== 0) return;
1711
+ // Keep the hidden keyboard sink focused after the browser performs the
1712
+ // pointerdown default action for the canvas. Without preventing that
1713
+ // default, Chrome immediately moves focus back to the document and all
1714
+ // subsequent typing/paste is silently lost.
1715
+ event.preventDefault();
1716
+ flushPendingText();
1717
+ pointerStartRef.current = {
1718
+ x: event.clientX,
1719
+ y: event.clientY,
1720
+ pointerId: event.pointerId,
1721
+ frame: props.frame,
1722
+ };
1723
+ event.currentTarget.setPointerCapture?.(event.pointerId);
1724
+ inputRef.current?.focus({ preventScroll: true });
1725
+ };
1726
+
1727
+ const pointerUp = (event: PointerEvent<HTMLCanvasElement>) => {
1728
+ const start = pointerStartRef.current;
1729
+ pointerStartRef.current = null;
1730
+ if (!start || start.pointerId !== event.pointerId) return;
1731
+ const from = point(start.frame, start.x, start.y);
1732
+ const to = point(start.frame, event.clientX, event.clientY);
1733
+ if (!from || !to) return;
1734
+ if (Math.hypot(event.clientX - start.x, event.clientY - start.y) > 5) {
1735
+ flushPendingClick();
1736
+ enqueue(
1737
+ {
1738
+ type: "pointer",
1739
+ action: "drag",
1740
+ x: from.x,
1741
+ y: from.y,
1742
+ endX: to.x,
1743
+ endY: to.y,
1744
+ },
1745
+ start.frame,
1746
+ );
1747
+ return;
1748
+ }
1749
+ const now = Date.now();
1750
+ const previous = lastClickRef.current;
1751
+ if (
1752
+ previous &&
1753
+ now - previous.at < 280 &&
1754
+ Math.hypot(previous.x - to.x, previous.y - to.y) < 6 &&
1755
+ sameFrameFence(previous.frame, start.frame)
1756
+ ) {
1757
+ lastClickRef.current = null;
1758
+ // The first click was already dispatched immediately. Tell CDP this is
1759
+ // the continuation (clickCount=2), so the page receives a true dblclick
1760
+ // without making every ordinary click wait for the double-click window.
1761
+ enqueue({ type: "pointer", action: "click", clickCount: 2, x: to.x, y: to.y }, start.frame);
1762
+ return;
1763
+ }
1764
+ lastClickRef.current = { at: now, x: to.x, y: to.y, frame: start.frame };
1765
+ enqueue({ type: "pointer", action: "click", x: to.x, y: to.y }, start.frame);
1766
+ };
1767
+
1768
+ const contextMenu = (event: MouseEvent<HTMLCanvasElement>) => {
1769
+ event.preventDefault();
1770
+ const frame = props.frame;
1771
+ if (!frame) return;
1772
+ flushPendingText();
1773
+ flushPendingClick();
1774
+ const at = point(frame, event.clientX, event.clientY);
1775
+ if (at) enqueue({ type: "pointer", action: "click", x: at.x, y: at.y, button: "right" }, frame);
1776
+ };
1777
+
1778
+ const wheel = (event: WheelEvent<HTMLCanvasElement>) => {
1779
+ const frame = props.frame;
1780
+ if (!frame) return;
1781
+ const at = point(frame, event.clientX, event.clientY);
1782
+ if (!at) return;
1783
+ flushPendingText();
1784
+ flushPendingClick();
1785
+ event.preventDefault();
1786
+ const pending = wheelRef.current;
1787
+ if (pending?.timer) clearTimeout(pending.timer);
1788
+ wheelRef.current = {
1789
+ x: at.x,
1790
+ y: at.y,
1791
+ deltaX: (pending && sameFrameFence(pending.frame, frame) ? pending.deltaX : 0) + event.deltaX,
1792
+ deltaY: (pending && sameFrameFence(pending.frame, frame) ? pending.deltaY : 0) + event.deltaY,
1793
+ frame,
1794
+ timer: setTimeout(() => {
1795
+ const batch = wheelRef.current;
1796
+ wheelRef.current = null;
1797
+ if (batch)
1798
+ enqueue(
1799
+ {
1800
+ type: "pointer",
1801
+ action: "scroll",
1802
+ x: batch.x,
1803
+ y: batch.y,
1804
+ deltaX: batch.deltaX,
1805
+ deltaY: batch.deltaY,
1806
+ },
1807
+ batch.frame,
1808
+ );
1809
+ }, 45),
1810
+ };
1811
+ };
1812
+
1813
+ const keyDown = (event: KeyboardEvent<HTMLTextAreaElement>) => {
1814
+ const command = event.metaKey || event.ctrlKey;
1815
+ if (
1816
+ props.clipboardEnabled &&
1817
+ command &&
1818
+ !event.altKey &&
1819
+ ["c", "v"].includes(event.key.toLowerCase())
1820
+ ) {
1821
+ flushPendingText();
1822
+ return;
1823
+ }
1824
+ const key = browserKey(event);
1825
+ if (!key) return;
1826
+ event.preventDefault();
1827
+ flushPendingText();
1828
+ flushPendingClick();
1829
+ enqueue({ type: "press", key }, props.frame);
1830
+ };
1831
+
1832
+ const copy = (event: ClipboardEvent<HTMLTextAreaElement>) => {
1833
+ if (!props.clipboardEnabled) return;
1834
+ event.preventDefault();
1835
+ flushPendingText();
1836
+ flushPendingClick();
1837
+ enqueue({ type: "clipboard", operation: "copy" }, props.frame, async () => {
1838
+ const clipboard = await readClipboardRef.current();
1839
+ if (clipboard.text.length === 0) return;
1840
+ if (!(await copyTextToClipboard(clipboard.text))) {
1841
+ throw new Error("Browser text could not be copied to the local clipboard");
1842
+ }
1843
+ });
1844
+ };
1845
+
1846
+ const paste = (event: ClipboardEvent<HTMLTextAreaElement>) => {
1847
+ if (!props.clipboardEnabled) return;
1848
+ event.preventDefault();
1849
+ flushPendingText();
1850
+ flushPendingClick();
1851
+ enqueue(
1852
+ {
1853
+ type: "clipboard",
1854
+ operation: "paste",
1855
+ text: event.clipboardData.getData("text/plain"),
1856
+ },
1857
+ props.frame,
1858
+ );
1859
+ };
1860
+
1861
+ const input = (value: string, nativeComposing = false) => {
1862
+ if (composingRef.current || nativeComposing) return;
1863
+ if (!value) return;
1864
+ flushPendingClick();
1865
+ const pending = pendingTextRef.current;
1866
+ if (pending && sameOptionalBrowserFrame(pending.frame, props.frame)) {
1867
+ clearTimeout(pending.timer);
1868
+ pending.text += value;
1869
+ pending.timer = setTimeout(flushPendingText, 16);
1870
+ } else {
1871
+ flushPendingText();
1872
+ pendingTextRef.current = {
1873
+ text: value,
1874
+ frame: props.frame,
1875
+ timer: setTimeout(flushPendingText, 16),
1876
+ };
1877
+ }
1878
+ if (inputRef.current) inputRef.current.value = "";
1879
+ };
1880
+
1881
+ const compositionStart = () => {
1882
+ composingRef.current = true;
1883
+ };
1884
+
1885
+ const compositionEnd = (event: CompositionEvent<HTMLTextAreaElement>) => {
1886
+ composingRef.current = false;
1887
+ input(event.currentTarget.value || event.data);
1888
+ };
1889
+
1890
+ const showCanvas = props.frame !== null;
1891
+ return (
1892
+ <div className="relative min-h-0 flex-1 overflow-hidden bg-black">
1893
+ <canvas
1894
+ ref={canvasRef}
1895
+ className={cn(
1896
+ "absolute inset-0 m-auto max-h-full max-w-full touch-none focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-og-accent",
1897
+ !showCanvas && "invisible",
1898
+ )}
1899
+ onPointerDown={pointerDown}
1900
+ onPointerUp={pointerUp}
1901
+ onPointerCancel={() => {
1902
+ pointerStartRef.current = null;
1903
+ }}
1904
+ onContextMenu={contextMenu}
1905
+ onWheel={wheel}
1906
+ aria-label="Interactive browser page"
1907
+ />
1908
+ <textarea
1909
+ ref={inputRef}
1910
+ defaultValue=""
1911
+ onInput={(event) =>
1912
+ input(event.currentTarget.value, (event.nativeEvent as InputEvent).isComposing)
1913
+ }
1914
+ onCompositionStart={compositionStart}
1915
+ onCompositionEnd={compositionEnd}
1916
+ onKeyDown={keyDown}
1917
+ onCopy={copy}
1918
+ onPaste={paste}
1919
+ className="pointer-events-none absolute left-1/2 top-1/2 size-px resize-none overflow-hidden opacity-0"
1920
+ aria-label="Browser keyboard input"
1921
+ autoCapitalize="off"
1922
+ autoCorrect="off"
1923
+ spellCheck={false}
1924
+ />
1925
+ {!showCanvas ? (
1926
+ <SemanticBrowserFallback
1927
+ observation={props.observation}
1928
+ connectionState={props.connectionState}
1929
+ supportsLiveFrames={props.supportsLiveFrames}
1930
+ error={props.connectionError}
1931
+ onAction={(action) => enqueue(action, null)}
1932
+ onReconnect={props.onReconnect}
1933
+ />
1934
+ ) : null}
1935
+ {props.mutating ? (
1936
+ <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">
1937
+ <LoaderCircleIcon className="size-3 animate-spin" /> {props.activityLabel ?? "Acting"}
1938
+ </div>
1939
+ ) : null}
1940
+ </div>
1941
+ );
1942
+ }
1943
+
1944
+ function SemanticBrowserFallback(props: {
1945
+ observation: ReturnType<typeof useBrowserSession>["observation"];
1946
+ connectionState: string;
1947
+ supportsLiveFrames: boolean;
1948
+ error: Error | null;
1949
+ onAction: (action: BrowserAction) => void;
1950
+ onReconnect: () => void;
1951
+ }) {
1952
+ const nodes = semanticNodes(
1953
+ props.observation?.semantic?.kind === "snapshot" ? props.observation.semantic.roots : [],
1954
+ );
1955
+ const interactive = nodes.filter((node) => node.actions.includes("click")).slice(0, 8);
1956
+ return (
1957
+ <div className="absolute inset-0 grid place-items-center bg-og-bg p-6">
1958
+ <div className="w-full max-w-md rounded-og-lg border border-og-border bg-og-surface-0 p-4 shadow-lg">
1959
+ <div className="flex items-center gap-2">
1960
+ {props.error ? (
1961
+ <CircleAlertIcon className="size-4 text-og-status-error" />
1962
+ ) : !props.supportsLiveFrames ? (
1963
+ <ZapIcon className="size-4 text-og-muted" />
1964
+ ) : (
1965
+ <LoaderCircleIcon className="size-4 animate-spin text-og-muted" />
1966
+ )}
1967
+ <p className="text-og-menu font-medium text-og-fg">
1968
+ {props.error
1969
+ ? "Live view disconnected"
1970
+ : props.supportsLiveFrames
1971
+ ? browserConnectionLabel(props.connectionState)
1972
+ : "Semantic browser"}
1973
+ </p>
1974
+ </div>
1975
+ {interactive.length > 0 ? (
1976
+ <div className="mt-3 border-t border-og-border pt-3">
1977
+ <p className="mb-2 text-og-xs text-og-subtle">
1978
+ {props.supportsLiveFrames
1979
+ ? "Page controls remain available"
1980
+ : "Available page controls"}
1981
+ </p>
1982
+ <div className="flex flex-wrap gap-1.5">
1983
+ {interactive.map((node) => (
1984
+ <button
1985
+ key={node.ref}
1986
+ type="button"
1987
+ onClick={() =>
1988
+ props.onAction({
1989
+ type: "click",
1990
+ locator: { kind: "ref", ref: node.ref },
1991
+ })
1992
+ }
1993
+ 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"
1994
+ >
1995
+ {node.name || node.role}
1996
+ </button>
1997
+ ))}
1998
+ </div>
1999
+ </div>
2000
+ ) : null}
2001
+ {props.error && props.supportsLiveFrames ? (
2002
+ <button
2003
+ type="button"
2004
+ onClick={props.onReconnect}
2005
+ className="mt-3 text-og-control font-medium text-og-accent hover:underline"
2006
+ >
2007
+ Reconnect
2008
+ </button>
2009
+ ) : null}
2010
+ </div>
2011
+ </div>
2012
+ );
2013
+ }
2014
+
2015
+ function BrowserStatusBar(props: {
2016
+ session: BrowserSession | null;
2017
+ profile: BrowserIdentity | null;
2018
+ target: BrowserTarget | null;
2019
+ observation: ReturnType<typeof useBrowserSession>["observation"];
2020
+ connectionState: string;
2021
+ refreshing: boolean;
2022
+ diagnosticsOpen: boolean;
2023
+ onOpenComputer?: (() => void) | undefined;
2024
+ onDiagnostics: () => void;
2025
+ }) {
2026
+ const errors =
2027
+ (props.observation?.diagnostics.consoleErrorCount ?? 0) +
2028
+ (props.observation?.diagnostics.pageErrorCount ?? 0);
2029
+ const failed = props.observation?.diagnostics.failedRequestCount ?? 0;
2030
+ return (
2031
+ <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">
2032
+ <span
2033
+ className={cn(
2034
+ "size-1.5 rounded-full",
2035
+ props.connectionState === "live" || props.connectionState === "semantic"
2036
+ ? "bg-og-status-running"
2037
+ : "bg-og-muted",
2038
+ )}
2039
+ />
2040
+ <span>
2041
+ {props.connectionState === "live"
2042
+ ? "Live"
2043
+ : props.connectionState === "semantic"
2044
+ ? "Semantic"
2045
+ : browserConnectionLabel(props.connectionState)}
2046
+ </span>
2047
+ <span>{props.profile?.name ?? "Temporary browser"}</span>
2048
+ <span className="min-w-0 flex-1 truncate">{props.target?.title}</span>
2049
+ {props.refreshing ? <LoaderCircleIcon className="size-3 animate-spin" /> : null}
2050
+ {props.onOpenComputer ? (
2051
+ <button
2052
+ type="button"
2053
+ onClick={props.onOpenComputer}
2054
+ className="flex items-center gap-1 rounded px-1.5 py-0.5 transition hover:bg-og-surface-2 hover:text-og-fg"
2055
+ aria-label="Open this browser window in Computer"
2056
+ >
2057
+ <MonitorIcon className="size-3" />
2058
+ Computer
2059
+ </button>
2060
+ ) : null}
2061
+ <button
2062
+ type="button"
2063
+ onClick={props.onDiagnostics}
2064
+ aria-expanded={props.diagnosticsOpen}
2065
+ aria-controls="browser-diagnostics-drawer"
2066
+ className={cn(
2067
+ "flex items-center gap-1 rounded px-1.5 py-0.5 transition hover:bg-og-surface-2 hover:text-og-fg",
2068
+ props.diagnosticsOpen && "bg-og-surface-2 text-og-fg",
2069
+ )}
2070
+ >
2071
+ <BugIcon className="size-3" />
2072
+ {errors + failed > 0 ? errors + failed : "Debug"}
2073
+ </button>
2074
+ </div>
2075
+ );
2076
+ }
2077
+
2078
+ function BrowserDiagnosticsDrawer(props: {
2079
+ session: BrowserSession | null;
2080
+ profile: BrowserIdentity | null;
2081
+ target: BrowserTarget | null;
2082
+ observation: BrowserObservation | null;
2083
+ baseRevisionOrdinal: number | null;
2084
+ state: BrowserDiagnosticsView;
2085
+ downloads: BrowserDownload[];
2086
+ downloadsEnabled: boolean;
2087
+ downloadsLoading: boolean;
2088
+ downloadsRefreshing: boolean;
2089
+ downloadsError: Error | null;
2090
+ savingDownloadIds: string[];
2091
+ onSaveDownload: (
2092
+ downloadId: string,
2093
+ destinationPath: string,
2094
+ overwrite: boolean,
2095
+ ) => Promise<BrowserDownloadSaveResponse>;
2096
+ onRefreshDownloads: () => void;
2097
+ onRefresh: () => void;
2098
+ onClose: () => void;
2099
+ }) {
2100
+ const session = props.session;
2101
+ const diagnostics = props.observation?.diagnostics;
2102
+ const semanticMode = props.observation?.semantic
2103
+ ? "Semantic page structure available"
2104
+ : props.observation?.screenshot
2105
+ ? "Visual fallback in use"
2106
+ : "Waiting for page observation";
2107
+ return (
2108
+ <aside
2109
+ id="browser-diagnostics-drawer"
2110
+ role="region"
2111
+ aria-label="Browser diagnostics"
2112
+ className="absolute bottom-7 right-0 top-10 z-40 flex w-full max-w-md flex-col border-l border-og-border bg-og-surface-0 shadow-2xl"
2113
+ >
2114
+ <div className="flex h-11 shrink-0 items-center gap-2 border-b border-og-border px-3">
2115
+ <BugIcon className="size-4 text-og-muted" />
2116
+ <div className="min-w-0 flex-1">
2117
+ <p className="text-og-control font-semibold text-og-fg">Browser diagnostics</p>
2118
+ <p className="truncate text-og-xs text-og-subtle">
2119
+ {props.target?.title || shortUrl(props.target?.url ?? "") || "Current tab"}
2120
+ </p>
2121
+ </div>
2122
+ <button
2123
+ type="button"
2124
+ onClick={props.onRefresh}
2125
+ disabled={props.state.loading}
2126
+ 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-40"
2127
+ aria-label="Refresh browser diagnostics"
2128
+ >
2129
+ <RefreshCwIcon className={cn("size-3.5", props.state.loading && "animate-spin")} />
2130
+ </button>
2131
+ <button
2132
+ type="button"
2133
+ onClick={props.onClose}
2134
+ className="grid size-7 place-items-center rounded-og-sm text-og-muted transition hover:bg-og-surface-2 hover:text-og-fg"
2135
+ aria-label="Close browser diagnostics"
2136
+ >
2137
+ <XIcon className="size-3.5" />
2138
+ </button>
2139
+ </div>
2140
+
2141
+ <div className="min-h-0 flex-1 overflow-y-auto">
2142
+ <section className="border-b border-og-border p-3" aria-labelledby="browser-runtime-title">
2143
+ <h3
2144
+ id="browser-runtime-title"
2145
+ className="text-[10px] font-semibold uppercase tracking-[0.14em] text-og-subtle"
2146
+ >
2147
+ Runtime
2148
+ </h3>
2149
+ <dl className="mt-2 grid grid-cols-[7rem_minmax(0,1fr)] gap-x-3 gap-y-1.5 text-og-xs">
2150
+ <DiagnosticFact label="Engine" value={browserEngineLabel(session)} />
2151
+ <DiagnosticFact label="Driver" value={session?.driverId ?? "Unavailable"} />
2152
+ <DiagnosticFact label="Placement" value={placementLabel(session ?? undefined)} />
2153
+ <DiagnosticFact
2154
+ label="Profile"
2155
+ value={
2156
+ props.profile
2157
+ ? `${props.profile.name}${props.baseRevisionOrdinal ? ` · v${props.baseRevisionOrdinal}` : ""}`
2158
+ : "Temporary"
2159
+ }
2160
+ />
2161
+ <DiagnosticFact
2162
+ label="Controller"
2163
+ value={shortGeneration(session?.controller?.controllerGeneration)}
2164
+ />
2165
+ <DiagnosticFact
2166
+ label="Network"
2167
+ value={session?.networkRouteId ? "Custom route" : "Placement default"}
2168
+ />
2169
+ <DiagnosticFact label="Observation" value={semanticMode} />
2170
+ </dl>
2171
+ </section>
2172
+
2173
+ <section className="border-b border-og-border p-3" aria-labelledby="browser-page-title">
2174
+ <h3
2175
+ id="browser-page-title"
2176
+ className="text-[10px] font-semibold uppercase tracking-[0.14em] text-og-subtle"
2177
+ >
2178
+ Current page
2179
+ </h3>
2180
+ <dl className="mt-2 grid grid-cols-2 gap-2">
2181
+ <DiagnosticCount label="Console errors" value={diagnostics?.consoleErrorCount ?? 0} />
2182
+ <DiagnosticCount label="Page errors" value={diagnostics?.pageErrorCount ?? 0} />
2183
+ <DiagnosticCount label="Failed requests" value={diagnostics?.failedRequestCount ?? 0} />
2184
+ <DiagnosticCount label="Downloads" value={diagnostics?.downloadCount ?? 0} />
2185
+ </dl>
2186
+ {session?.failureCode ? (
2187
+ <p className="mt-2 rounded-og-sm border border-og-status-error/30 bg-og-status-error/5 px-2.5 py-2 text-og-xs text-og-status-error">
2188
+ Browser state: {session.failureCode}
2189
+ </p>
2190
+ ) : null}
2191
+ </section>
2192
+
2193
+ {props.downloadsEnabled ? (
2194
+ <BrowserDownloadsPanel
2195
+ downloads={props.downloads}
2196
+ loading={props.downloadsLoading}
2197
+ refreshing={props.downloadsRefreshing}
2198
+ error={props.downloadsError}
2199
+ savingDownloadIds={props.savingDownloadIds}
2200
+ onSave={props.onSaveDownload}
2201
+ onRefresh={props.onRefreshDownloads}
2202
+ />
2203
+ ) : null}
2204
+
2205
+ <section className="p-3" aria-labelledby="browser-events-title">
2206
+ <div className="flex items-center justify-between gap-2">
2207
+ <h3
2208
+ id="browser-events-title"
2209
+ className="text-[10px] font-semibold uppercase tracking-[0.14em] text-og-subtle"
2210
+ >
2211
+ Recent events
2212
+ </h3>
2213
+ {props.state.batch?.truncated ? (
2214
+ <span className="text-[10px] text-og-subtle">Newest 100</span>
2215
+ ) : null}
2216
+ </div>
2217
+ {props.state.loading && !props.state.batch ? (
2218
+ <div className="flex items-center gap-2 py-6 text-og-control text-og-muted">
2219
+ <LoaderCircleIcon className="size-3.5 animate-spin" /> Loading diagnostics…
2220
+ </div>
2221
+ ) : props.state.error ? (
2222
+ <div className="py-5">
2223
+ <p className="text-og-control text-og-status-error">{props.state.error.message}</p>
2224
+ <button
2225
+ type="button"
2226
+ onClick={props.onRefresh}
2227
+ className="mt-2 text-og-control font-medium text-og-accent hover:underline"
2228
+ >
2229
+ Try again
2230
+ </button>
2231
+ </div>
2232
+ ) : props.state.batch?.entries.length ? (
2233
+ <ol className="mt-2 space-y-2">
2234
+ {props.state.batch.entries.map((entry) => (
2235
+ <li
2236
+ key={entry.sequence}
2237
+ className="rounded-og-sm border border-og-border bg-og-bg p-2.5"
2238
+ >
2239
+ <div className="flex items-center gap-2 text-[10px] text-og-subtle">
2240
+ <span
2241
+ className={cn(
2242
+ "font-semibold uppercase tracking-wide",
2243
+ diagnosticTone(entry.level),
2244
+ )}
2245
+ >
2246
+ {diagnosticKindLabel(entry.kind)}
2247
+ </span>
2248
+ <span className="ml-auto">{formatDiagnosticTime(entry.occurredAt)}</span>
2249
+ </div>
2250
+ <p className="mt-1 whitespace-pre-wrap break-words text-og-xs leading-5 text-og-fg">
2251
+ {entry.message}
2252
+ </p>
2253
+ {entry.url || entry.filename || entry.method || entry.status ? (
2254
+ <p className="mt-1 truncate font-og-mono text-[10px] text-og-subtle">
2255
+ {[entry.method, entry.status, entry.filename, entry.url]
2256
+ .filter((value) => value !== null)
2257
+ .join(" · ")}
2258
+ </p>
2259
+ ) : null}
2260
+ </li>
2261
+ ))}
2262
+ </ol>
2263
+ ) : (
2264
+ <p className="py-6 text-og-control text-og-muted">No diagnostics for this tab.</p>
2265
+ )}
2266
+ </section>
2267
+ </div>
2268
+ </aside>
2269
+ );
2270
+ }
2271
+
2272
+ function BrowserDownloadsPanel(props: {
2273
+ downloads: BrowserDownload[];
2274
+ loading: boolean;
2275
+ refreshing: boolean;
2276
+ error: Error | null;
2277
+ savingDownloadIds: string[];
2278
+ onSave: (
2279
+ downloadId: string,
2280
+ destinationPath: string,
2281
+ overwrite: boolean,
2282
+ ) => Promise<BrowserDownloadSaveResponse>;
2283
+ onRefresh: () => void;
2284
+ }) {
2285
+ const [paths, setPaths] = useState<Record<string, string>>({});
2286
+ const [overwrite, setOverwrite] = useState<Record<string, boolean>>({});
2287
+ const [savedPaths, setSavedPaths] = useState<Record<string, string>>({});
2288
+ const [errors, setErrors] = useState<Record<string, string>>({});
2289
+ const saving = useMemo(() => new Set(props.savingDownloadIds), [props.savingDownloadIds]);
2290
+
2291
+ const save = (download: BrowserDownload) => {
2292
+ const destinationPath = (paths[download.id] ?? defaultDownloadDestination(download)).trim();
2293
+ if (!destinationPath || saving.has(download.id)) return;
2294
+ setErrors((current) => ({ ...current, [download.id]: "" }));
2295
+ void props
2296
+ .onSave(download.id, destinationPath, overwrite[download.id] ?? false)
2297
+ .then((response) => {
2298
+ setSavedPaths((current) => ({ ...current, [download.id]: response.destinationPath }));
2299
+ })
2300
+ .catch((cause) => {
2301
+ setErrors((current) => ({
2302
+ ...current,
2303
+ [download.id]: cause instanceof Error ? cause.message : "Could not save this download.",
2304
+ }));
2305
+ });
2306
+ };
2307
+
2308
+ return (
2309
+ <section className="border-b border-og-border p-3" aria-labelledby="browser-downloads-title">
2310
+ <div className="flex items-center gap-2">
2311
+ <DownloadIcon className="size-3.5 text-og-muted" />
2312
+ <h3
2313
+ id="browser-downloads-title"
2314
+ className="text-[10px] font-semibold uppercase tracking-[0.14em] text-og-subtle"
2315
+ >
2316
+ Downloads
2317
+ </h3>
2318
+ {props.refreshing ? (
2319
+ <LoaderCircleIcon className="ml-auto size-3 animate-spin text-og-muted" />
2320
+ ) : null}
2321
+ </div>
2322
+
2323
+ {props.loading ? (
2324
+ <div className="flex items-center gap-2 py-5 text-og-control text-og-muted">
2325
+ <LoaderCircleIcon className="size-3.5 animate-spin" /> Loading downloads…
2326
+ </div>
2327
+ ) : props.error ? (
2328
+ <div className="py-4">
2329
+ <p className="text-og-control text-og-status-error">{props.error.message}</p>
2330
+ <button
2331
+ type="button"
2332
+ onClick={props.onRefresh}
2333
+ className="mt-2 text-og-control font-medium text-og-accent hover:underline"
2334
+ >
2335
+ Try again
2336
+ </button>
2337
+ </div>
2338
+ ) : props.downloads.length === 0 ? (
2339
+ <p className="py-4 text-og-control text-og-muted">No files downloaded yet.</p>
2340
+ ) : (
2341
+ <ol className="mt-2 space-y-2">
2342
+ {props.downloads.map((download) => {
2343
+ const destinationPath = paths[download.id] ?? defaultDownloadDestination(download);
2344
+ const isSaving = saving.has(download.id);
2345
+ const isSaved = savedPaths[download.id] === destinationPath;
2346
+ return (
2347
+ <li
2348
+ key={download.id}
2349
+ className="rounded-og-sm border border-og-border bg-og-bg p-2.5"
2350
+ >
2351
+ <div className="flex min-w-0 items-start gap-2">
2352
+ <span className="mt-0.5 grid size-6 shrink-0 place-items-center rounded bg-og-surface-2 text-og-muted">
2353
+ {isSaved ? (
2354
+ <FileCheck2Icon className="size-3.5 text-og-status-success" />
2355
+ ) : (
2356
+ <DownloadIcon className="size-3.5" />
2357
+ )}
2358
+ </span>
2359
+ <div className="min-w-0 flex-1">
2360
+ <p
2361
+ className="truncate text-og-xs font-medium text-og-fg"
2362
+ title={download.filename}
2363
+ >
2364
+ {download.filename}
2365
+ </p>
2366
+ <p className="mt-0.5 text-[10px] text-og-subtle">
2367
+ {downloadStatusLabel(download)}
2368
+ </p>
2369
+ </div>
2370
+ </div>
2371
+
2372
+ {download.status === "completed" ? (
2373
+ <div className="mt-2">
2374
+ <div className="flex gap-1.5">
2375
+ <input
2376
+ type="text"
2377
+ value={destinationPath}
2378
+ onInput={(event) => {
2379
+ const value = event.currentTarget.value;
2380
+ setPaths((current) => ({ ...current, [download.id]: value }));
2381
+ setErrors((current) => ({ ...current, [download.id]: "" }));
2382
+ }}
2383
+ onKeyDown={(event) => {
2384
+ if (event.key === "Enter") save(download);
2385
+ }}
2386
+ disabled={isSaving}
2387
+ aria-label={`Workspace path for ${download.filename}`}
2388
+ className="h-7 min-w-0 flex-1 rounded-og-sm border border-og-border bg-og-surface-0 px-2 font-og-mono text-[10px] text-og-fg transition focus:border-og-accent focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-og-accent disabled:opacity-50"
2389
+ />
2390
+ <button
2391
+ type="button"
2392
+ onClick={() => save(download)}
2393
+ disabled={
2394
+ isSaving ||
2395
+ !destinationPath.trim() ||
2396
+ (isSaved && !overwrite[download.id])
2397
+ }
2398
+ className="inline-flex h-7 shrink-0 items-center gap-1 rounded-og-sm border border-og-border bg-og-surface-1 px-2 text-[10px] font-medium text-og-fg transition hover:bg-og-surface-2 disabled:opacity-40"
2399
+ >
2400
+ {isSaving ? (
2401
+ <LoaderCircleIcon className="size-3 animate-spin" />
2402
+ ) : (
2403
+ <SaveIcon className="size-3" />
2404
+ )}
2405
+ {isSaving
2406
+ ? "Saving"
2407
+ : isSaved
2408
+ ? "Saved"
2409
+ : overwrite[download.id]
2410
+ ? "Replace"
2411
+ : "Save"}
2412
+ </button>
2413
+ </div>
2414
+ <label className="mt-1.5 flex w-fit items-center gap-1.5 text-[10px] text-og-subtle">
2415
+ <input
2416
+ type="checkbox"
2417
+ checked={overwrite[download.id] ?? false}
2418
+ onChange={(event) => {
2419
+ setOverwrite((current) => ({
2420
+ ...current,
2421
+ [download.id]: event.currentTarget.checked,
2422
+ }));
2423
+ setErrors((current) => ({ ...current, [download.id]: "" }));
2424
+ }}
2425
+ disabled={isSaving}
2426
+ className="size-3 accent-og-accent"
2427
+ />
2428
+ Replace an existing workspace file
2429
+ </label>
2430
+ {errors[download.id] ? (
2431
+ <p className="mt-1.5 break-words text-[10px] text-og-status-error">
2432
+ {errors[download.id]}
2433
+ </p>
2434
+ ) : null}
2435
+ </div>
2436
+ ) : null}
2437
+ </li>
2438
+ );
2439
+ })}
2440
+ </ol>
2441
+ )}
2442
+ </section>
2443
+ );
2444
+ }
2445
+
2446
+ function downloadStatusLabel(download: BrowserDownload): string {
2447
+ if (download.status === "completed") return formatBytes(download.receivedBytes);
2448
+ if (download.status === "in_progress") {
2449
+ const received = formatBytes(download.receivedBytes);
2450
+ return download.totalBytes === null
2451
+ ? `Downloading · ${received}`
2452
+ : `Downloading · ${received} of ${formatBytes(download.totalBytes)}`;
2453
+ }
2454
+ if (download.status === "cancelled") return "Cancelled";
2455
+ if (download.status === "unavailable") return "No longer available";
2456
+ return "Download failed";
2457
+ }
2458
+
2459
+ function defaultDownloadDestination(download: BrowserDownload): string {
2460
+ const leaf = download.filename.split(/[\\/]/u).at(-1)?.trim() ?? "";
2461
+ const portable = leaf
2462
+ .normalize("NFC")
2463
+ .replace(/[<>:"|?*\u0000-\u001f\u007f]/gu, "-")
2464
+ .replace(/[ .]+$/u, "");
2465
+ if (portable.length === 0 || /^(?:con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\.|$)/iu.test(portable)) {
2466
+ return `download-${download.id.slice(0, 8)}`;
2467
+ }
2468
+ return portable;
2469
+ }
2470
+
2471
+ function DiagnosticFact(props: { label: string; value: string }) {
2472
+ return (
2473
+ <>
2474
+ <dt className="text-og-subtle">{props.label}</dt>
2475
+ <dd className="min-w-0 truncate text-og-fg" title={props.value}>
2476
+ {props.value}
2477
+ </dd>
2478
+ </>
2479
+ );
2480
+ }
2481
+
2482
+ function DiagnosticCount(props: { label: string; value: number }) {
2483
+ return (
2484
+ <div className="rounded-og-sm border border-og-border bg-og-bg px-2.5 py-2">
2485
+ <dt className="text-[10px] text-og-subtle">{props.label}</dt>
2486
+ <dd
2487
+ className={cn(
2488
+ "mt-0.5 text-og-sm font-semibold",
2489
+ props.value ? "text-og-fg" : "text-og-muted",
2490
+ )}
2491
+ >
2492
+ {props.value}
2493
+ </dd>
2494
+ </div>
2495
+ );
2496
+ }
2497
+
2498
+ function browserEngineLabel(session: BrowserSession | null): string {
2499
+ if (!session) return "Unavailable";
2500
+ const version = session.engineVersion ? ` ${session.engineVersion}` : "";
2501
+ return `${session.engine}${version} · ${session.headless ? "headless" : "headed"}`;
2502
+ }
2503
+
2504
+ function shortGeneration(value: string | null | undefined): string {
2505
+ if (!value) return "Unavailable";
2506
+ return value.length > 16 ? `${value.slice(0, 8)}…${value.slice(-6)}` : value;
2507
+ }
2508
+
2509
+ function diagnosticKindLabel(kind: BrowserDiagnosticBatch["entries"][number]["kind"]): string {
2510
+ switch (kind) {
2511
+ case "console":
2512
+ return "Console";
2513
+ case "page_error":
2514
+ return "Page error";
2515
+ case "failed_request":
2516
+ return "Request";
2517
+ case "download":
2518
+ return "Download";
2519
+ }
2520
+ }
2521
+
2522
+ function diagnosticTone(level: BrowserDiagnosticBatch["entries"][number]["level"]): string {
2523
+ if (level === "error") return "text-og-status-error";
2524
+ if (level === "warning") return "text-og-status-waiting";
2525
+ return "text-og-muted";
2526
+ }
2527
+
2528
+ function formatDiagnosticTime(value: string): string {
2529
+ const date = new Date(value);
2530
+ return Number.isNaN(date.getTime())
2531
+ ? ""
2532
+ : date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
2533
+ }
2534
+
2535
+ function BrowserNotice(props: { icon: ReactNode; text: string; className?: string }) {
2536
+ return (
2537
+ <div
2538
+ className={cn(
2539
+ "grid h-full place-items-center bg-og-bg text-og-control text-og-muted",
2540
+ props.className,
2541
+ )}
2542
+ >
2543
+ <div className="flex items-center gap-2">
2544
+ {props.icon}
2545
+ {props.text}
2546
+ </div>
2547
+ </div>
2548
+ );
2549
+ }
2550
+
2551
+ function paintCanvas(
2552
+ canvas: HTMLCanvasElement,
2553
+ image: CanvasImageSource,
2554
+ width: number,
2555
+ height: number,
2556
+ ): void {
2557
+ canvas.width = width;
2558
+ canvas.height = height;
2559
+ const context = canvas.getContext("2d", { alpha: false });
2560
+ if (!context) throw new Error("Browser canvas is unavailable.");
2561
+ context.drawImage(image, 0, 0, width, height);
2562
+ }
2563
+
2564
+ function loadImage(url: string): Promise<HTMLImageElement> {
2565
+ return new Promise((resolve, reject) => {
2566
+ const image = new Image();
2567
+ image.onload = () => resolve(image);
2568
+ image.onerror = () => reject(new Error("Browser frame image could not be decoded."));
2569
+ image.src = url;
2570
+ });
2571
+ }
2572
+
2573
+ function browserPoint(
2574
+ canvas: HTMLCanvasElement | null,
2575
+ frame: BrowserFrame | null,
2576
+ clientX: number,
2577
+ clientY: number,
2578
+ ): { x: number; y: number } | null {
2579
+ if (!canvas || !frame) return null;
2580
+ const rect = canvas.getBoundingClientRect();
2581
+ if (rect.width <= 0 || rect.height <= 0) return null;
2582
+ const pixelX = ((clientX - rect.left) / rect.width) * frame.width;
2583
+ const pixelY = ((clientY - rect.top) / rect.height) * frame.height;
2584
+ return {
2585
+ x: Math.max(0, pixelX / frame.deviceScaleFactor),
2586
+ y: Math.max(0, pixelY / frame.deviceScaleFactor),
2587
+ };
2588
+ }
2589
+
2590
+ function semanticNodes(roots: readonly InteractionSemanticNode[]): InteractionSemanticNode[] {
2591
+ const result: InteractionSemanticNode[] = [];
2592
+ const stack = [...roots].reverse();
2593
+ while (stack.length > 0) {
2594
+ const node = stack.pop()!;
2595
+ result.push(node);
2596
+ if (node.children) stack.push(...[...node.children].reverse());
2597
+ }
2598
+ return result;
2599
+ }
2600
+
2601
+ function frameMatchesSelectedTarget(
2602
+ frame: BrowserFrame | null,
2603
+ session: BrowserSession | null,
2604
+ target: BrowserTarget | null,
2605
+ ): frame is BrowserFrame {
2606
+ // The stream hook already validates the frame against the authenticated
2607
+ // attachment's controller generation. The separately-polled BrowserSession
2608
+ // can briefly carry the previous generation while an attachment is issued;
2609
+ // comparing against it here discarded valid frames and left the viewer on
2610
+ // "Connecting" indefinitely.
2611
+ return Boolean(
2612
+ frame &&
2613
+ session &&
2614
+ target &&
2615
+ frame.browserSessionId === session.id &&
2616
+ frame.targetId === target.id &&
2617
+ frame.targetGeneration === target.targetGeneration &&
2618
+ frame.documentGeneration === target.documentGeneration,
2619
+ );
2620
+ }
2621
+
2622
+ function sameFrameFence(left: BrowserFrame, right: BrowserFrame): boolean {
2623
+ return (
2624
+ left.browserSessionId === right.browserSessionId &&
2625
+ left.controllerGeneration === right.controllerGeneration &&
2626
+ left.targetId === right.targetId &&
2627
+ left.targetGeneration === right.targetGeneration &&
2628
+ left.documentGeneration === right.documentGeneration &&
2629
+ left.frameId === right.frameId
2630
+ );
2631
+ }
2632
+
2633
+ function sameOptionalBrowserFrame(left: BrowserFrame | null, right: BrowserFrame | null): boolean {
2634
+ return left === null || right === null ? left === right : sameFrameFence(left, right);
2635
+ }
2636
+
2637
+ function isLiveBrowser(session: BrowserSession): boolean {
2638
+ return !["ending", "ended", "failed", "lost"].includes(session.lifecycle);
2639
+ }
2640
+
2641
+ function countInterventions(
2642
+ interventions: readonly InteractionIntervention[],
2643
+ ): Map<string, number> {
2644
+ const counts = new Map<string, number>();
2645
+ for (const intervention of interventions) {
2646
+ counts.set(intervention.resourceId, (counts.get(intervention.resourceId) ?? 0) + 1);
2647
+ }
2648
+ return counts;
2649
+ }
2650
+
2651
+ function interventionTitle(intervention: InteractionIntervention): string {
2652
+ switch (intervention.kind) {
2653
+ case "manual_login":
2654
+ return "Sign in needed";
2655
+ case "mfa":
2656
+ return "Verification needed";
2657
+ case "external_action":
2658
+ case "confirmation":
2659
+ return "Action needed";
2660
+ case "other":
2661
+ return "Browser needs your help";
2662
+ }
2663
+ }
2664
+
2665
+ function placementLabel(session: BrowserSession | undefined): string {
2666
+ if (!session) return "";
2667
+ switch (session.placement.kind) {
2668
+ case "sandbox_group":
2669
+ return "Agent sandbox";
2670
+ case "connected_machine":
2671
+ return "Connected machine";
2672
+ case "attached_device":
2673
+ return "Your browser";
2674
+ case "external_provider":
2675
+ return "Cloud browser";
2676
+ }
2677
+ }
2678
+
2679
+ function browserConnectionLabel(state: string): string {
2680
+ switch (state) {
2681
+ case "attaching":
2682
+ return "Opening browser…";
2683
+ case "connecting":
2684
+ return "Connecting…";
2685
+ case "reconnecting":
2686
+ return "Reconnecting…";
2687
+ case "error":
2688
+ return "Disconnected";
2689
+ case "semantic":
2690
+ return "Semantic";
2691
+ default:
2692
+ return "Waiting for browser…";
2693
+ }
2694
+ }
2695
+
2696
+ function shortUrl(value: string): string {
2697
+ try {
2698
+ return new URL(value).hostname || value;
2699
+ } catch {
2700
+ return value;
2701
+ }
2702
+ }