@opengeni/react 0.57.0 → 1.0.2-canary.37191796234001

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 (629) hide show
  1. package/README.md +654 -22
  2. package/dist/accounts.d.ts +64 -0
  3. package/dist/accounts.js +822 -0
  4. package/dist/accounts.js.map +1 -0
  5. package/dist/approvals.d.ts +2 -1
  6. package/dist/artifacts-document.js +3 -2
  7. package/dist/artifacts-presentation.js +3 -2
  8. package/dist/artifacts-spreadsheet.js +2 -2
  9. package/dist/artifacts.d.ts +4 -1
  10. package/dist/artifacts.js +518 -42
  11. package/dist/artifacts.js.map +1 -1
  12. package/dist/browser-viewer-GTR5XQ7X.js +16 -0
  13. package/dist/capability-catalog-row.d.ts +20 -0
  14. package/dist/capability-logo-fallback.d.ts +3 -0
  15. package/dist/chat-interactive-block-UGBTNB3L.js +11 -0
  16. package/dist/chat-interactive-block-UGBTNB3L.js.map +1 -0
  17. package/dist/{chunk-5UQPNLIO.js → chunk-23J2ZNXT.js} +4117 -3896
  18. package/dist/chunk-23J2ZNXT.js.map +1 -0
  19. package/dist/chunk-447JLCBU.js +209 -0
  20. package/dist/chunk-447JLCBU.js.map +1 -0
  21. package/dist/chunk-4RVYOY4O.js +379 -0
  22. package/dist/chunk-4RVYOY4O.js.map +1 -0
  23. package/dist/chunk-5CVXZA3Y.js +292 -0
  24. package/dist/chunk-5CVXZA3Y.js.map +1 -0
  25. package/dist/chunk-5J4FOW6Q.js +108 -0
  26. package/dist/chunk-5J4FOW6Q.js.map +1 -0
  27. package/dist/chunk-A2PI6UMY.js +41 -0
  28. package/dist/chunk-A2PI6UMY.js.map +1 -0
  29. package/dist/{chunk-7WCLAEFV.js → chunk-A4UJB2MM.js} +37 -27
  30. package/dist/chunk-A4UJB2MM.js.map +1 -0
  31. package/dist/chunk-B3V6TG77.js +41 -0
  32. package/dist/chunk-B3V6TG77.js.map +1 -0
  33. package/dist/chunk-BRM6FVZA.js +302 -0
  34. package/dist/chunk-BRM6FVZA.js.map +1 -0
  35. package/dist/chunk-C5X5AJXK.js +182 -0
  36. package/dist/chunk-C5X5AJXK.js.map +1 -0
  37. package/dist/chunk-CR7U7HAR.js +453 -0
  38. package/dist/chunk-CR7U7HAR.js.map +1 -0
  39. package/dist/{chunk-R3AZOAB5.js → chunk-DYWBUX5Q.js} +29 -123
  40. package/dist/chunk-DYWBUX5Q.js.map +1 -0
  41. package/dist/chunk-E3G7SAYZ.js +3124 -0
  42. package/dist/chunk-E3G7SAYZ.js.map +1 -0
  43. package/dist/chunk-ELCE5SXV.js +359 -0
  44. package/dist/chunk-ELCE5SXV.js.map +1 -0
  45. package/dist/chunk-ERGYJIBB.js +21 -0
  46. package/dist/chunk-ERGYJIBB.js.map +1 -0
  47. package/dist/chunk-FFA2OVOV.js +319 -0
  48. package/dist/chunk-FFA2OVOV.js.map +1 -0
  49. package/dist/chunk-FZCP527Y.js +116 -0
  50. package/dist/chunk-FZCP527Y.js.map +1 -0
  51. package/dist/chunk-GIMRELBW.js +14 -0
  52. package/dist/chunk-GIMRELBW.js.map +1 -0
  53. package/dist/{chunk-6KLX2RH5.js → chunk-H2NJQORO.js} +1069 -79
  54. package/dist/chunk-H2NJQORO.js.map +1 -0
  55. package/dist/chunk-I7IWRQLN.js +1608 -0
  56. package/dist/chunk-I7IWRQLN.js.map +1 -0
  57. package/dist/{chunk-HA46CYQK.js → chunk-IQUCRAT7.js} +792 -537
  58. package/dist/chunk-IQUCRAT7.js.map +1 -0
  59. package/dist/chunk-IWIDV7SB.js +1 -0
  60. package/dist/chunk-IWIDV7SB.js.map +1 -0
  61. package/dist/chunk-J2QWVSQY.js +312 -0
  62. package/dist/chunk-J2QWVSQY.js.map +1 -0
  63. package/dist/chunk-JFNEV4LU.js +2733 -0
  64. package/dist/chunk-JFNEV4LU.js.map +1 -0
  65. package/dist/chunk-JT4JW7BC.js +54 -0
  66. package/dist/chunk-JT4JW7BC.js.map +1 -0
  67. package/dist/chunk-K3HQAKQX.js +24 -0
  68. package/dist/chunk-K3HQAKQX.js.map +1 -0
  69. package/dist/{chunk-C5ZPSSS5.js → chunk-NEXLLTP4.js} +2 -1
  70. package/dist/{chunk-C5ZPSSS5.js.map → chunk-NEXLLTP4.js.map} +1 -1
  71. package/dist/chunk-NZUI76EM.js +371 -0
  72. package/dist/chunk-NZUI76EM.js.map +1 -0
  73. package/dist/{chunk-PEI6RANI.js → chunk-PNY3JKBJ.js} +309 -74
  74. package/dist/chunk-PNY3JKBJ.js.map +1 -0
  75. package/dist/{chunk-IGC6A2ZI.js → chunk-Q5QBEZIQ.js} +777 -246
  76. package/dist/chunk-Q5QBEZIQ.js.map +1 -0
  77. package/dist/chunk-RHREZ7BO.js +11 -0
  78. package/dist/chunk-RHREZ7BO.js.map +1 -0
  79. package/dist/chunk-SCOTPE2I.js +589 -0
  80. package/dist/chunk-SCOTPE2I.js.map +1 -0
  81. package/dist/{chunk-B6WPZSTT.js → chunk-SJZDKVW2.js} +1331 -1163
  82. package/dist/chunk-SJZDKVW2.js.map +1 -0
  83. package/dist/chunk-TMJRG4YC.js +12803 -0
  84. package/dist/chunk-TMJRG4YC.js.map +1 -0
  85. package/dist/chunk-TMYGL6X5.js +742 -0
  86. package/dist/chunk-TMYGL6X5.js.map +1 -0
  87. package/dist/chunk-UGLEYEMP.js +33 -0
  88. package/dist/chunk-UGLEYEMP.js.map +1 -0
  89. package/dist/chunk-UK4TINKT.js +27 -0
  90. package/dist/chunk-UK4TINKT.js.map +1 -0
  91. package/dist/chunk-UTRTG6LH.js +220 -0
  92. package/dist/chunk-UTRTG6LH.js.map +1 -0
  93. package/dist/chunk-WEHMPOXK.js +39 -0
  94. package/dist/chunk-WEHMPOXK.js.map +1 -0
  95. package/dist/chunk-WRHIZYZF.js +988 -0
  96. package/dist/chunk-WRHIZYZF.js.map +1 -0
  97. package/dist/{chunk-HUIKIDM5.js → chunk-WUISZ3WU.js} +223 -115
  98. package/dist/chunk-WUISZ3WU.js.map +1 -0
  99. package/dist/chunk-XCHWDJ22.js +107 -0
  100. package/dist/chunk-XCHWDJ22.js.map +1 -0
  101. package/dist/chunk-XOAMRDDK.js +481 -0
  102. package/dist/chunk-XOAMRDDK.js.map +1 -0
  103. package/dist/chunk-XUW2UWQM.js +299 -0
  104. package/dist/chunk-XUW2UWQM.js.map +1 -0
  105. package/dist/{chunk-KCGXPP5V.js → chunk-Y2PRMYLC.js} +11 -6
  106. package/dist/chunk-Y2PRMYLC.js.map +1 -0
  107. package/dist/chunk-YBXQDQFX.js +1772 -0
  108. package/dist/chunk-YBXQDQFX.js.map +1 -0
  109. package/dist/{chunk-KZ73RL76.js → chunk-YQEAA6LT.js} +7 -3
  110. package/dist/chunk-YQEAA6LT.js.map +1 -0
  111. package/dist/chunk-YV4L3ILF.js +9 -0
  112. package/dist/chunk-YV4L3ILF.js.map +1 -0
  113. package/dist/{chunk-LLOLUHVG.js → chunk-YWFG7ZX7.js} +1358 -2746
  114. package/dist/chunk-YWFG7ZX7.js.map +1 -0
  115. package/dist/chunk-ZYRGEKWS.js +216 -0
  116. package/dist/chunk-ZYRGEKWS.js.map +1 -0
  117. package/dist/client.d.ts +7 -4
  118. package/dist/clipboard.js +1 -0
  119. package/dist/commands/types.d.ts +2 -0
  120. package/dist/components/anthropic-mark.d.ts +3 -0
  121. package/dist/components/approval-surface.d.ts +15 -0
  122. package/dist/components/artifacts/artifact-chrome.d.ts +120 -0
  123. package/dist/components/artifacts/artifact-sandbox.d.ts +27 -0
  124. package/dist/components/artifacts/artifact-surface.d.ts +3 -1
  125. package/dist/components/artifacts/chat-interactive-block.d.ts +29 -0
  126. package/dist/components/artifacts/deferred-chat-media.d.ts +15 -0
  127. package/dist/components/artifacts/document-editor.d.ts +4 -2
  128. package/dist/components/artifacts/editable-artifact-ui.d.ts +2 -1
  129. package/dist/components/artifacts/editable-artifact-view.d.ts +46 -0
  130. package/dist/components/artifacts/editable-document.d.ts +1 -1
  131. package/dist/components/artifacts/editable-presentation.d.ts +2 -2
  132. package/dist/components/artifacts/editable-spreadsheet.d.ts +2 -1
  133. package/dist/components/artifacts/index.d.ts +8 -1
  134. package/dist/components/artifacts/inline-html-document.d.ts +2 -0
  135. package/dist/components/artifacts/latest-task-runner.d.ts +15 -0
  136. package/dist/components/artifacts/presentation-editor.d.ts +6 -2
  137. package/dist/components/artifacts/published-html-artifact-frame.d.ts +48 -3
  138. package/dist/components/artifacts/session-artifact-viewer.d.ts +39 -0
  139. package/dist/components/artifacts/site-view.d.ts +23 -0
  140. package/dist/components/artifacts/spreadsheet-general-display.d.ts +24 -0
  141. package/dist/components/artifacts/visualization-assets.gen.d.ts +2 -0
  142. package/dist/components/browser-select-control.d.ts +16 -0
  143. package/dist/components/browser-viewer.d.ts +10 -1
  144. package/dist/components/chat-composer.d.ts +21 -3
  145. package/dist/components/child-session-link.d.ts +5 -0
  146. package/dist/components/claude-mark.d.ts +3 -0
  147. package/dist/components/code-editor.d.ts +1 -4
  148. package/dist/components/composer.d.ts +45 -4
  149. package/dist/components/computer-viewer.d.ts +4 -1
  150. package/dist/components/copy-button.d.ts +3 -1
  151. package/dist/components/desktop-viewer.d.ts +1 -1
  152. package/dist/components/enrollment-consent.d.ts +6 -3
  153. package/dist/components/file-browser.d.ts +10 -1
  154. package/dist/components/fleet-tile.d.ts +2 -2
  155. package/dist/components/grok-mark.d.ts +26 -0
  156. package/dist/components/human-input-form.d.ts +21 -2
  157. package/dist/components/human-input-surface.d.ts +6 -3
  158. package/dist/components/machine-card.d.ts +4 -1
  159. package/dist/components/machines/machine-detail.d.ts +4 -1
  160. package/dist/components/machines/machine-operation-policy.d.ts +11 -0
  161. package/dist/components/machines-dashboard.d.ts +3 -1
  162. package/dist/components/markdown-table-layout.d.ts +12 -0
  163. package/dist/components/markdown.d.ts +47 -3
  164. package/dist/components/message-timeline.d.ts +72 -9
  165. package/dist/components/model-mark.d.ts +29 -0
  166. package/dist/components/model-policy-picker-menu.d.ts +9 -0
  167. package/dist/components/model-policy-picker.d.ts +62 -13
  168. package/dist/components/open-geni-chat.d.ts +55 -0
  169. package/dist/components/open-geni-links.d.ts +68 -0
  170. package/dist/components/openrouter-mark.d.ts +6 -0
  171. package/dist/components/pierre-file.d.ts +1 -1
  172. package/dist/components/preview-loading.d.ts +2 -0
  173. package/dist/components/queue-item-content.d.ts +9 -0
  174. package/dist/components/sandbox-files.d.ts +13 -2
  175. package/dist/components/sandbox-workspace.d.ts +43 -3
  176. package/dist/components/session-capability-frame.d.ts +35 -0
  177. package/dist/components/session-chrome.d.ts +66 -8
  178. package/dist/components/session-commands-panel.d.ts +6 -0
  179. package/dist/components/session-commands.d.ts +6 -0
  180. package/dist/components/session-connection-request.d.ts +8 -0
  181. package/dist/components/session-conversation.d.ts +86 -0
  182. package/dist/components/session-list.d.ts +36 -0
  183. package/dist/components/session-mcp-capability-card.d.ts +41 -0
  184. package/dist/components/timeline-anchor.d.ts +24 -0
  185. package/dist/components/timeline-annotation-chrome.d.ts +25 -0
  186. package/dist/components/timeline-annotation-layout.d.ts +42 -0
  187. package/dist/components/timeline-annotation-markers.d.ts +8 -0
  188. package/dist/components/timeline-annotation-reveal-context.d.ts +4 -0
  189. package/dist/components/timeline-annotation-shared.d.ts +31 -0
  190. package/dist/components/timeline-annotations-dialog.d.ts +3 -2
  191. package/dist/components/timeline-annotations.d.ts +13 -3
  192. package/dist/components/timeline-reading-progress.d.ts +11 -0
  193. package/dist/components/timeline-search.d.ts +34 -0
  194. package/dist/components/timeline-settlement.d.ts +16 -0
  195. package/dist/components/tip-follow.d.ts +16 -0
  196. package/dist/components/usage-limit-notice.d.ts +49 -0
  197. package/dist/components/usage-member-list.d.ts +91 -0
  198. package/dist/components/usage-meter.d.ts +73 -0
  199. package/dist/components/use-viewer-menu-dismiss.d.ts +2 -0
  200. package/dist/components/user-message-body.d.ts +10 -1
  201. package/dist/components/workspace-dock.d.ts +6 -2
  202. package/dist/composer.d.ts +5 -4
  203. package/dist/composer.js +15 -9
  204. package/dist/computer-viewer-ENHVAZ2Q.js +19 -0
  205. package/dist/computer-viewer-ENHVAZ2Q.js.map +1 -0
  206. package/dist/connect-account-identity.d.ts +8 -0
  207. package/dist/connect-accounts.d.ts +15 -0
  208. package/dist/connect-chooser.d.ts +18 -0
  209. package/dist/connect-panel.d.ts +18 -0
  210. package/dist/connect-setup.d.ts +16 -0
  211. package/dist/connect.d.ts +21 -0
  212. package/dist/connect.js +2129 -0
  213. package/dist/connect.js.map +1 -0
  214. package/dist/connection-catalog.d.ts +38 -0
  215. package/dist/connection-discovery.d.ts +12 -0
  216. package/dist/connection-installed.d.ts +17 -0
  217. package/dist/connection-logo.d.ts +9 -0
  218. package/dist/connection-service-logo.d.ts +8 -0
  219. package/dist/connection-service-presentation.d.ts +13 -0
  220. package/dist/connection-skeleton.d.ts +5 -0
  221. package/dist/connection-type-picker.d.ts +6 -0
  222. package/dist/conversation-timeline.d.ts +5 -0
  223. package/dist/desktop.d.ts +7 -0
  224. package/dist/desktop.js +26 -0
  225. package/dist/desktop.js.map +1 -0
  226. package/dist/device-authorization.d.ts +23 -0
  227. package/dist/diffs.d.ts +8 -0
  228. package/dist/diffs.js +14 -0
  229. package/dist/diffs.js.map +1 -0
  230. package/dist/discovery-cache.d.ts +5 -0
  231. package/dist/editor.d.ts +7 -0
  232. package/dist/editor.js +21 -0
  233. package/dist/editor.js.map +1 -0
  234. package/dist/embedded-session-client.d.ts +23 -0
  235. package/dist/file-node-visibility.d.ts +15 -0
  236. package/dist/{fleet-decision-row-GGCAT4E4.js → fleet-decision-row-YZ3ZKWMM.js} +2 -1
  237. package/dist/fleet-decision-row-YZ3ZKWMM.js.map +1 -0
  238. package/dist/hooks/channel-cache.d.ts +23 -0
  239. package/dist/hooks/latest-question.d.ts +25 -0
  240. package/dist/hooks/shared-machines-list.d.ts +14 -0
  241. package/dist/hooks/use-attached-browsers.d.ts +2 -1
  242. package/dist/hooks/use-browser-frame-stream.d.ts +1 -1
  243. package/dist/hooks/use-browser-session.d.ts +12 -1
  244. package/dist/hooks/use-channels.d.ts +1 -0
  245. package/dist/hooks/use-codex-accounts.d.ts +25 -7
  246. package/dist/hooks/use-composer.d.ts +45 -9
  247. package/dist/hooks/use-computer-frame-stream.d.ts +1 -1
  248. package/dist/hooks/use-computer-input-posture.d.ts +13 -0
  249. package/dist/hooks/use-computer-session.d.ts +2 -0
  250. package/dist/hooks/use-connect.d.ts +22 -0
  251. package/dist/hooks/use-desktop-stream.d.ts +1 -1
  252. package/dist/hooks/use-file-attachments.d.ts +21 -2
  253. package/dist/hooks/use-machine-chip.d.ts +3 -3
  254. package/dist/hooks/use-machines.d.ts +19 -1
  255. package/dist/hooks/use-sandbox-files.d.ts +5 -1
  256. package/dist/hooks/use-session-background-commands.d.ts +14 -0
  257. package/dist/hooks/use-session-events.d.ts +30 -8
  258. package/dist/hooks/use-session-lineage.d.ts +4 -0
  259. package/dist/hooks/use-session-mcp-approval-policy.d.ts +2 -2
  260. package/dist/hooks/use-session.d.ts +6 -0
  261. package/dist/hooks/use-turn-queue.d.ts +12 -0
  262. package/dist/hooks/use-usage.d.ts +40 -0
  263. package/dist/hooks/use-workspace-edit.d.ts +4 -0
  264. package/dist/hooks/use-workspace-sessions.d.ts +27 -5
  265. package/dist/human-input.d.ts +3 -1
  266. package/dist/identity-link-accounts.d.ts +11 -0
  267. package/dist/identity-link-consent.d.ts +18 -0
  268. package/dist/index.d.ts +60 -17
  269. package/dist/index.js +2237 -6200
  270. package/dist/index.js.map +1 -1
  271. package/dist/interaction.d.ts +1 -1
  272. package/dist/interaction.js +35 -184
  273. package/dist/interaction.js.map +1 -1
  274. package/dist/latest-question-755OW5DS.js +117 -0
  275. package/dist/latest-question-755OW5DS.js.map +1 -0
  276. package/dist/lib/error-message.d.ts +7 -0
  277. package/dist/lib/format.d.ts +36 -2
  278. package/dist/lib/host-theme.d.ts +70 -0
  279. package/dist/lib/interaction-errors.d.ts +6 -0
  280. package/dist/lib/menu-styles.d.ts +22 -0
  281. package/dist/lib/pierre-diffs-loader.d.ts +20 -0
  282. package/dist/lib/workbench-peers.d.ts +36 -0
  283. package/dist/lib/xterm-theme.d.ts +7 -5
  284. package/dist/machines.d.ts +4 -2
  285. package/dist/machines.js +14 -4
  286. package/dist/markdown-table-layout-QXSLK4BY.js +106 -0
  287. package/dist/markdown-table-layout-QXSLK4BY.js.map +1 -0
  288. package/dist/mcp-connection-status.d.ts +14 -0
  289. package/dist/model-policy-picker-menu-PWRI7R2X.js +14 -0
  290. package/dist/model-policy-picker-menu-PWRI7R2X.js.map +1 -0
  291. package/dist/model-policy.d.ts +19 -3
  292. package/dist/model-policy.js +9 -1
  293. package/dist/older-history.d.ts +30 -0
  294. package/dist/platform-activity-row-627BFZ46.js +182 -0
  295. package/dist/platform-activity-row-627BFZ46.js.map +1 -0
  296. package/dist/plugin-details.d.ts +14 -0
  297. package/dist/plugin-discovery.d.ts +25 -0
  298. package/dist/provider.d.ts +13 -1
  299. package/dist/{queue-surface-implementation-2KVJTYXH.js → queue-surface-implementation-ZHFI5JBP.js} +131 -44
  300. package/dist/queue-surface-implementation-ZHFI5JBP.js.map +1 -0
  301. package/dist/realtime/realtime-control.d.ts +10 -3
  302. package/dist/realtime.js +229 -83
  303. package/dist/realtime.js.map +1 -1
  304. package/dist/sandbox-files-BBC47SLD.js +20 -0
  305. package/dist/sandbox-files-BBC47SLD.js.map +1 -0
  306. package/dist/session-auth-recommendation.d.ts +5 -0
  307. package/dist/session-capability-policy.d.ts +12 -0
  308. package/dist/session-ui.d.ts +30 -2
  309. package/dist/session-ui.js +85 -7
  310. package/dist/session-ui.js.map +1 -1
  311. package/dist/session.d.ts +8 -2
  312. package/dist/session.js +30 -19
  313. package/dist/sites-ui.d.ts +34 -0
  314. package/dist/sites.d.ts +3 -0
  315. package/dist/sites.js +11 -0
  316. package/dist/sites.js.map +1 -0
  317. package/dist/skill-discovery.d.ts +47 -0
  318. package/dist/terminal.d.ts +8 -0
  319. package/dist/terminal.js +31 -0
  320. package/dist/terminal.js.map +1 -0
  321. package/dist/timeline/activity-rail.d.ts +11 -2
  322. package/dist/timeline/allowance-exhausted-row.d.ts +24 -0
  323. package/dist/timeline/compaction-copy.d.ts +10 -0
  324. package/dist/timeline/entrance.d.ts +13 -1
  325. package/dist/timeline/genie-loading.d.ts +34 -0
  326. package/dist/timeline/index.d.ts +6 -2
  327. package/dist/timeline/knowledge-receipt.d.ts +17 -0
  328. package/dist/timeline/platform-activity-row.d.ts +14 -0
  329. package/dist/timeline/presented-image.d.ts +6 -0
  330. package/dist/timeline/projection.d.ts +31 -2
  331. package/dist/timeline/registry.d.ts +2 -0
  332. package/dist/timeline/retained-image.d.ts +18 -0
  333. package/dist/timeline/rolling-activity.d.ts +11 -0
  334. package/dist/timeline/screenshot-lightbox.d.ts +16 -1
  335. package/dist/timeline/shared.d.ts +8 -3
  336. package/dist/timeline/startup-preference.d.ts +3 -0
  337. package/dist/timeline/startup-timings.d.ts +4 -0
  338. package/dist/timeline/tool-display-name.d.ts +2 -1
  339. package/dist/timeline/turn-summary.d.ts +32 -3
  340. package/dist/timeline/types.d.ts +141 -7
  341. package/dist/timeline-annotation-markers-GNDHTGBD.js +165 -0
  342. package/dist/timeline-annotation-markers-GNDHTGBD.js.map +1 -0
  343. package/dist/timeline-annotation-selection-DMLMM2TX.js +241 -0
  344. package/dist/timeline-annotation-selection-DMLMM2TX.js.map +1 -0
  345. package/dist/timeline-annotations-dialog-ELRDBHTS.js +273 -0
  346. package/dist/timeline-annotations-dialog-ELRDBHTS.js.map +1 -0
  347. package/dist/types/machines.d.ts +2 -2
  348. package/dist/usage/allowance-copy.d.ts +25 -0
  349. package/dist/usage/summary.d.ts +35 -0
  350. package/dist/usage.d.ts +13 -0
  351. package/dist/usage.js +1074 -0
  352. package/dist/usage.js.map +1 -0
  353. package/package.json +44 -4
  354. package/src/accounts.tsx +1072 -0
  355. package/src/approvals.ts +8 -1
  356. package/src/artifacts.ts +42 -0
  357. package/src/assets/README.md +8 -0
  358. package/src/assets/gmail.ico +0 -0
  359. package/src/assets/outlook.ico +0 -0
  360. package/src/capability-catalog-row.tsx +94 -0
  361. package/src/capability-logo-fallback.ts +22 -0
  362. package/src/client.ts +22 -13
  363. package/src/commands/registry.ts +18 -15
  364. package/src/commands/types.ts +2 -0
  365. package/src/components/anthropic-mark.tsx +10 -0
  366. package/src/components/approval-surface.tsx +171 -37
  367. package/src/components/artifacts/artifact-chrome.tsx +413 -0
  368. package/src/components/artifacts/artifact-sandbox.tsx +185 -0
  369. package/src/components/artifacts/artifact-surface.tsx +39 -19
  370. package/src/components/artifacts/chat-interactive-block.tsx +260 -0
  371. package/src/components/artifacts/deferred-chat-media.tsx +67 -0
  372. package/src/components/artifacts/document-editor.tsx +141 -16
  373. package/src/components/artifacts/editable-artifact-ui.tsx +4 -2
  374. package/src/components/artifacts/editable-artifact-view.tsx +187 -0
  375. package/src/components/artifacts/editable-artifact-workbench.tsx +5 -2
  376. package/src/components/artifacts/editable-document.tsx +37 -36
  377. package/src/components/artifacts/editable-presentation.tsx +36 -18
  378. package/src/components/artifacts/editable-spreadsheet.tsx +5 -2
  379. package/src/components/artifacts/index.ts +44 -0
  380. package/src/components/artifacts/inline-html-document.ts +24 -0
  381. package/src/components/artifacts/latest-task-runner.ts +49 -0
  382. package/src/components/artifacts/presentation-editor.tsx +323 -67
  383. package/src/components/artifacts/published-html-artifact-frame.tsx +338 -5
  384. package/src/components/artifacts/session-artifact-viewer.tsx +376 -0
  385. package/src/components/artifacts/site-view.tsx +116 -0
  386. package/src/components/artifacts/spreadsheet-canvas.ts +5 -10
  387. package/src/components/artifacts/spreadsheet-general-display.ts +43 -0
  388. package/src/components/artifacts/spreadsheet-grid.tsx +12 -13
  389. package/src/components/artifacts/visualization-assets/base.css +1015 -0
  390. package/src/components/artifacts/visualization-assets/helpers.html +349 -0
  391. package/src/components/artifacts/visualization-assets.gen.ts +3 -0
  392. package/src/components/browser-select-control.tsx +312 -0
  393. package/src/components/browser-viewer.tsx +1054 -314
  394. package/src/components/chat-composer.tsx +131 -85
  395. package/src/components/child-session-link.tsx +27 -0
  396. package/src/components/claude-mark.tsx +10 -0
  397. package/src/components/code-editor.tsx +13 -16
  398. package/src/components/composer-transcription-control.tsx +7 -5
  399. package/src/components/composer.tsx +376 -49
  400. package/src/components/computer-viewer.tsx +1041 -329
  401. package/src/components/copy-button.tsx +13 -5
  402. package/src/components/desktop-viewer.tsx +1 -1
  403. package/src/components/enrollment-consent.tsx +65 -7
  404. package/src/components/enrollment-device-flow.tsx +1 -1
  405. package/src/components/file-browser.tsx +158 -34
  406. package/src/components/fleet-tile.tsx +8 -16
  407. package/src/components/generated-video-player.tsx +4 -2
  408. package/src/components/grok-mark.tsx +33 -0
  409. package/src/components/human-input-form.tsx +455 -114
  410. package/src/components/human-input-surface.tsx +18 -7
  411. package/src/components/interaction-intervention-banner.tsx +2 -2
  412. package/src/components/machine-card.tsx +119 -4
  413. package/src/components/machine-dock-bar.tsx +1 -1
  414. package/src/components/machine-input-display.ts +23 -2
  415. package/src/components/machines/machine-detail.tsx +52 -2
  416. package/src/components/machines/machine-operation-policy.tsx +179 -0
  417. package/src/components/machines-dashboard.tsx +8 -17
  418. package/src/components/markdown-table-layout.ts +132 -0
  419. package/src/components/markdown.tsx +473 -44
  420. package/src/components/message-timeline.tsx +2498 -663
  421. package/src/components/model-mark.tsx +90 -0
  422. package/src/components/model-picker.tsx +15 -4
  423. package/src/components/model-policy-picker-menu.tsx +424 -0
  424. package/src/components/model-policy-picker.tsx +337 -381
  425. package/src/components/open-geni-chat.tsx +319 -0
  426. package/src/components/open-geni-links.tsx +179 -0
  427. package/src/components/openrouter-mark.tsx +13 -0
  428. package/src/components/pierre-diff.tsx +41 -16
  429. package/src/components/pierre-file.tsx +36 -11
  430. package/src/components/preview-loading.tsx +154 -0
  431. package/src/components/queue-item-content.ts +14 -0
  432. package/src/components/queue-surface-implementation.tsx +132 -42
  433. package/src/components/queue-surface-state.tsx +3 -1
  434. package/src/components/sandbox-files.tsx +267 -35
  435. package/src/components/sandbox-terminal.tsx +48 -11
  436. package/src/components/sandbox-workspace.tsx +337 -135
  437. package/src/components/session-capability-frame.tsx +192 -0
  438. package/src/components/session-chrome.tsx +1027 -328
  439. package/src/components/session-commands-panel.tsx +126 -0
  440. package/src/components/session-commands.tsx +18 -0
  441. package/src/components/session-connection-request.tsx +97 -0
  442. package/src/components/session-conversation.tsx +512 -0
  443. package/src/components/session-list.tsx +250 -0
  444. package/src/components/session-mcp-capability-card.tsx +680 -0
  445. package/src/components/session-status.tsx +1 -1
  446. package/src/components/timeline-anchor.tsx +119 -0
  447. package/src/components/timeline-annotation-chrome.tsx +196 -0
  448. package/src/components/timeline-annotation-layout.ts +268 -0
  449. package/src/components/timeline-annotation-markers.tsx +185 -0
  450. package/src/components/timeline-annotation-reveal-context.ts +9 -0
  451. package/src/components/timeline-annotation-selection.tsx +106 -54
  452. package/src/components/timeline-annotation-shared.ts +245 -0
  453. package/src/components/timeline-annotations-dialog.tsx +185 -128
  454. package/src/components/timeline-annotations.tsx +189 -45
  455. package/src/components/timeline-reading-progress.ts +80 -0
  456. package/src/components/timeline-search.tsx +168 -0
  457. package/src/components/timeline-settlement.ts +60 -0
  458. package/src/components/tip-follow.ts +70 -7
  459. package/src/components/tooltip.tsx +38 -5
  460. package/src/components/usage-limit-notice.tsx +179 -0
  461. package/src/components/usage-member-list.tsx +792 -0
  462. package/src/components/usage-meter.tsx +382 -0
  463. package/src/components/use-viewer-menu-dismiss.ts +33 -0
  464. package/src/components/user-message-body.tsx +194 -49
  465. package/src/components/workspace-dock.tsx +141 -175
  466. package/src/composer.ts +6 -1
  467. package/src/connect-account-identity.tsx +34 -0
  468. package/src/connect-accounts.tsx +233 -0
  469. package/src/connect-chooser.tsx +336 -0
  470. package/src/connect-panel.tsx +99 -0
  471. package/src/connect-setup.tsx +307 -0
  472. package/src/connect.ts +60 -0
  473. package/src/connection-assets.d.ts +4 -0
  474. package/src/connection-catalog.tsx +162 -0
  475. package/src/connection-discovery.tsx +235 -0
  476. package/src/connection-installed.tsx +51 -0
  477. package/src/connection-logo.tsx +37 -0
  478. package/src/connection-service-logo.tsx +55 -0
  479. package/src/connection-service-presentation.ts +46 -0
  480. package/src/connection-skeleton.tsx +16 -0
  481. package/src/connection-type-picker.tsx +36 -0
  482. package/src/conversation-timeline.ts +92 -0
  483. package/src/desktop.ts +24 -0
  484. package/src/device-authorization.tsx +98 -0
  485. package/src/diffs.ts +16 -0
  486. package/src/discovery-cache.ts +42 -0
  487. package/src/editor.ts +13 -0
  488. package/src/embedded-session-client.ts +104 -0
  489. package/src/file-node-visibility.ts +66 -0
  490. package/src/hooks/channel-cache.ts +104 -0
  491. package/src/hooks/internal.ts +3 -2
  492. package/src/hooks/latest-question.ts +164 -0
  493. package/src/hooks/shared-machines-list.ts +239 -0
  494. package/src/hooks/use-attached-browsers.ts +5 -1
  495. package/src/hooks/use-available-models.ts +20 -14
  496. package/src/hooks/use-browser-frame-stream.ts +25 -3
  497. package/src/hooks/use-browser-session.ts +271 -71
  498. package/src/hooks/use-browser-sessions.ts +7 -1
  499. package/src/hooks/use-channels.ts +32 -10
  500. package/src/hooks/use-codex-accounts.ts +78 -21
  501. package/src/hooks/use-composer.ts +1034 -320
  502. package/src/hooks/use-computer-frame-stream.ts +39 -16
  503. package/src/hooks/use-computer-input-posture.ts +94 -0
  504. package/src/hooks/use-computer-session.ts +110 -32
  505. package/src/hooks/use-computer-sessions.ts +8 -1
  506. package/src/hooks/use-connect.ts +23 -0
  507. package/src/hooks/use-desktop-stream.ts +19 -28
  508. package/src/hooks/use-file-attachments.ts +118 -10
  509. package/src/hooks/use-goal.ts +36 -9
  510. package/src/hooks/use-human-input.ts +31 -4
  511. package/src/hooks/use-last-started-turn-policy.ts +15 -13
  512. package/src/hooks/use-machine-chip.ts +23 -6
  513. package/src/hooks/use-machines.ts +104 -6
  514. package/src/hooks/use-sandbox-files.ts +253 -150
  515. package/src/hooks/use-sandbox-git.ts +58 -29
  516. package/src/hooks/use-session-background-commands.ts +67 -0
  517. package/src/hooks/use-session-capabilities.ts +21 -2
  518. package/src/hooks/use-session-events.ts +759 -433
  519. package/src/hooks/use-session-lineage.ts +28 -6
  520. package/src/hooks/use-session-mcp-approval-policy.ts +13 -5
  521. package/src/hooks/use-session.ts +75 -16
  522. package/src/hooks/use-slash-commands.ts +6 -11
  523. package/src/hooks/use-turn-queue.ts +113 -13
  524. package/src/hooks/use-usage.ts +100 -0
  525. package/src/hooks/use-voice-input.ts +12 -1
  526. package/src/hooks/use-workspace-capture.ts +3 -2
  527. package/src/hooks/use-workspace-edit.ts +23 -5
  528. package/src/hooks/use-workspace-sessions.ts +99 -11
  529. package/src/human-input.ts +12 -1
  530. package/src/identity-link-accounts.tsx +148 -0
  531. package/src/identity-link-consent.tsx +192 -0
  532. package/src/index.ts +157 -12
  533. package/src/interaction.ts +1 -0
  534. package/src/lib/error-message.ts +37 -0
  535. package/src/lib/format.ts +150 -14
  536. package/src/lib/host-theme.ts +511 -0
  537. package/src/lib/interaction-errors.ts +33 -0
  538. package/src/lib/menu-styles.ts +44 -0
  539. package/src/lib/pierre-diffs-loader.ts +53 -0
  540. package/src/lib/workbench-peers.ts +62 -0
  541. package/src/lib/xterm-theme.ts +23 -21
  542. package/src/machines.ts +9 -1
  543. package/src/mcp-connection-status.ts +35 -0
  544. package/src/model-policy.ts +223 -69
  545. package/src/older-history.ts +87 -0
  546. package/src/plugin-details.tsx +185 -0
  547. package/src/plugin-discovery.tsx +196 -0
  548. package/src/provider.tsx +55 -14
  549. package/src/realtime/dropdown-menu.tsx +19 -12
  550. package/src/realtime/realtime-control.tsx +274 -60
  551. package/src/session-auth-recommendation.ts +48 -0
  552. package/src/session-capability-policy.ts +83 -0
  553. package/src/session-context.ts +1 -0
  554. package/src/session-ui.ts +66 -2
  555. package/src/session.ts +18 -1
  556. package/src/sites-ui.tsx +402 -0
  557. package/src/sites.ts +3 -0
  558. package/src/skill-discovery.tsx +208 -0
  559. package/src/terminal.ts +26 -0
  560. package/src/timeline/activity-rail.tsx +171 -80
  561. package/src/timeline/allowance-exhausted-row.tsx +86 -0
  562. package/src/timeline/compaction-copy.ts +42 -0
  563. package/src/timeline/entrance.tsx +28 -3
  564. package/src/timeline/fleet-decision-projection.ts +2 -1
  565. package/src/timeline/fleet-decision-row.tsx +1 -0
  566. package/src/timeline/genie-loading.tsx +136 -0
  567. package/src/timeline/index.ts +11 -0
  568. package/src/timeline/knowledge-receipt.tsx +100 -0
  569. package/src/timeline/parsers.ts +2 -2
  570. package/src/timeline/platform-activity-row.tsx +216 -0
  571. package/src/timeline/presented-image.ts +49 -0
  572. package/src/timeline/projection.ts +1600 -84
  573. package/src/timeline/registry.ts +15 -3
  574. package/src/timeline/retained-image.ts +97 -0
  575. package/src/timeline/rolling-activity.tsx +92 -0
  576. package/src/timeline/screenshot-lightbox.tsx +115 -17
  577. package/src/timeline/shared.tsx +47 -15
  578. package/src/timeline/startup-preference.ts +33 -0
  579. package/src/timeline/startup-timings.tsx +145 -0
  580. package/src/timeline/tool-display-name.ts +8 -6
  581. package/src/timeline/tool-renderers.tsx +497 -128
  582. package/src/timeline/turn-summary.tsx +208 -44
  583. package/src/timeline/types.ts +186 -7
  584. package/src/types/machines.ts +10 -0
  585. package/src/usage/allowance-copy.ts +64 -0
  586. package/src/usage/summary.ts +93 -0
  587. package/src/usage.ts +50 -0
  588. package/styles/compiled.css +2098 -757
  589. package/styles/connect.css +284 -0
  590. package/styles/connect.d.ts +1 -0
  591. package/styles/effective-tokens.css +1 -0
  592. package/styles/index.css +201 -9
  593. package/styles/responsive.css +27 -1
  594. package/styles/session-capability.css +44 -0
  595. package/styles/tokens.css +135 -65
  596. package/dist/chunk-5UQPNLIO.js.map +0 -1
  597. package/dist/chunk-6KLX2RH5.js.map +0 -1
  598. package/dist/chunk-7WCLAEFV.js.map +0 -1
  599. package/dist/chunk-B6WPZSTT.js.map +0 -1
  600. package/dist/chunk-FK6VG4LL.js +0 -90
  601. package/dist/chunk-FK6VG4LL.js.map +0 -1
  602. package/dist/chunk-HA46CYQK.js.map +0 -1
  603. package/dist/chunk-HUIKIDM5.js.map +0 -1
  604. package/dist/chunk-IGC6A2ZI.js.map +0 -1
  605. package/dist/chunk-KCGXPP5V.js.map +0 -1
  606. package/dist/chunk-KZ73RL76.js.map +0 -1
  607. package/dist/chunk-LAKLF4PA.js +0 -141
  608. package/dist/chunk-LAKLF4PA.js.map +0 -1
  609. package/dist/chunk-LLOLUHVG.js.map +0 -1
  610. package/dist/chunk-MKPIAGVL.js +0 -465
  611. package/dist/chunk-MKPIAGVL.js.map +0 -1
  612. package/dist/chunk-PEI6RANI.js.map +0 -1
  613. package/dist/chunk-R3AZOAB5.js.map +0 -1
  614. package/dist/chunk-WSK7G5LE.js +0 -170
  615. package/dist/chunk-WSK7G5LE.js.map +0 -1
  616. package/dist/chunk-XBZWOM2K.js +0 -218
  617. package/dist/chunk-XBZWOM2K.js.map +0 -1
  618. package/dist/chunk-Y7OEDUJ5.js +0 -8810
  619. package/dist/chunk-Y7OEDUJ5.js.map +0 -1
  620. package/dist/fleet-decision-row-GGCAT4E4.js.map +0 -1
  621. package/dist/hooks/use-packs.d.ts +0 -32
  622. package/dist/interactive-workbench-viewers-TOHBXJEE.js +0 -15
  623. package/dist/queue-surface-implementation-2KVJTYXH.js.map +0 -1
  624. package/dist/timeline-annotation-selection-3L7LHGG2.js +0 -187
  625. package/dist/timeline-annotation-selection-3L7LHGG2.js.map +0 -1
  626. package/dist/timeline-annotations-dialog-U7EVLR75.js +0 -196
  627. package/dist/timeline-annotations-dialog-U7EVLR75.js.map +0 -1
  628. package/src/hooks/use-packs.ts +0 -157
  629. /package/dist/{interactive-workbench-viewers-TOHBXJEE.js.map → browser-viewer-GTR5XQ7X.js.map} +0 -0
@@ -1,10 +1,18 @@
1
- import type { SessionEvent, SessionStatus, StreamConnectionState } from "@opengeni/sdk";
2
- import { useCallback, useEffect, useMemo, useRef, useState } from "react";
1
+ import {
2
+ sessionEventStreamCoveredThrough,
3
+ type SessionEvent,
4
+ type SessionStatus,
5
+ type StreamConnectionState,
6
+ } from "@opengeni/sdk";
7
+ import { startTransition, useCallback, useEffect, useMemo, useRef, useState } from "react";
3
8
  import { useEmbeddedSession, type EmbeddedSessionClientOverride } from "../session-context";
9
+ import { createOlderHistoryLoadReceipt, type OlderHistoryLoadReceipt } from "../older-history";
4
10
  import { buildTimeline, groupTimeline, sessionStatusFromEvents } from "../timeline/projection";
5
11
  import type { TimelineItem } from "../timeline/types";
6
12
  import type { EmbeddedSessionClientLike } from "../client";
13
+ import { normalizeError } from "../lib/error-message";
7
14
  import { usePageLiveActivity } from "./internal";
15
+ import type { LatestQuestionOptions } from "./latest-question";
8
16
 
9
17
  export type SessionEventsConnectionState = StreamConnectionState | "idle" | "ended" | "error";
10
18
 
@@ -20,10 +28,12 @@ export type UseSessionEventsOptions = EmbeddedSessionClientOverride & {
20
28
  export type UseSessionEventsResult = {
21
29
  /** Replayed + live events, ordered by sequence, no gaps, no duplicates. */
22
30
  events: SessionEvent[];
23
- /** Projected, renderable timeline (memoized over `events`). */
31
+ /** Renderable timeline, including the bounded navigation witness for a distant queued prompt. */
24
32
  timeline: TimelineItem[];
25
33
  /** Latest session status observed in the event log, if any. */
26
34
  sessionStatus: SessionStatus | null;
35
+ /** Sequence of the retained status projection, including events evicted from the window. */
36
+ sessionStatusSequence?: number;
27
37
  connectionState: SessionEventsConnectionState;
28
38
  /** Highest sequence seen so far (0 before the first event). */
29
39
  lastSequence: number;
@@ -33,12 +43,16 @@ export type UseSessionEventsResult = {
33
43
  windowTruncated: boolean;
34
44
  /** True until the initial tail window has been applied (windowed mode). */
35
45
  initialLoading: boolean;
46
+ /** A history window (including an empty tail) succeeded for this session/replay
47
+ * identity. Stays true through later stream errors and navigation/reloads.
48
+ * Full replay has no snapshot-completion watermark and does not set this by itself. */
49
+ initialHistoryReady: boolean;
36
50
  /** Whether older durable events are available before the current window. */
37
51
  hasOlder: boolean;
38
52
  /** True while an older window is being fetched. */
39
53
  loadingOlder: boolean;
40
54
  /** Prepend an older density-bounded window; resolves true when more remain. */
41
- loadOlder: () => Promise<boolean>;
55
+ loadOlder: () => OlderHistoryLoadReceipt;
42
56
  /**
43
57
  * Durable events exist after the current window (history view jumped away
44
58
  * from the live tip, or a forward page is incomplete).
@@ -65,13 +79,20 @@ export type UseSessionEventsResult = {
65
79
  * "Jump to latest" when the tip is not in memory (history view).
66
80
  */
67
81
  jumpToLatest: () => Promise<void>;
82
+ /** Resolve the newest eligible durable human question. Pending prompts use the
83
+ * optional queue destination and return null; started prompts load their actual
84
+ * turn context (pass `timeline` to MessageTimeline as `items`). */
85
+ jumpToLatestQuestion: (options?: LatestQuestionOptions) => Promise<number | null>;
86
+ /** Replace history with a bounded window containing this exact durable event. */
87
+ jumpToSequence: (sequence: number, options?: { signal?: AbortSignal }) => Promise<boolean>;
88
+ loadingTarget: boolean;
68
89
  error: Error | null;
69
90
  };
70
91
 
71
- const INITIAL_TAIL_PAGE_SIZE = 1000;
72
- const OLDER_PAGE_SIZE = 5000;
73
- const NEWER_PAGE_SIZE = 5000;
74
- const OLDEST_PAGE_SIZE = 1000;
92
+ // Keep every browser history read inside one database batch, including the
93
+ // server's one-row continuation lookahead. A large total session must never
94
+ // turn one lazy page into dozens of sequential database round trips.
95
+ const SESSION_HISTORY_PAGE_SIZE = 1000;
75
96
  const INITIAL_FETCH_CAP = 1;
76
97
  const OLDER_GROUP_TARGET = 32;
77
98
  const OLDER_FETCH_CAP = 2;
@@ -79,37 +100,26 @@ const NEWER_GROUP_TARGET = 32;
79
100
  const NEWER_FETCH_CAP = 2;
80
101
  const OLDEST_GROUP_TARGET = 32;
81
102
  const OLDEST_FETCH_CAP = 2;
82
- const BOUNDARY_PAGE_CAP = 4;
103
+ // A tail page may land inside one unusually dense turn. Permit exactly one
104
+ // additional bounded page to find its user/session boundary without turning a
105
+ // fresh open into an unbounded history walk.
106
+ const BOUNDARY_PAGE_CAP = 1;
107
+ // Foreground reconciliation is intentionally semantic, not merely time-based:
108
+ // tiny raw gaps can stay on SSE; medium raw gaps get one compact probe so a
109
+ // token-heavy single answer is not mistaken for hundreds of visible messages;
110
+ // only a large/complex missed window reloads the latest tail.
111
+ const FOREGROUND_DIRECT_REPLAY_MAX_SEQUENCES = 16;
112
+ const FOREGROUND_COMPACT_PROBE_MAX_SEQUENCES = SESSION_HISTORY_PAGE_SIZE;
113
+ const FOREGROUND_COMPACT_CATCHUP_MAX_EVENTS = 128;
114
+ const FOREGROUND_COMPACT_CATCHUP_MAX_GROUPS = 16;
115
+ const FOREGROUND_COMPACT_CATCHUP_MAX_BYTES = 512 * 1024;
83
116
  const EMPTY_EVENTS: SessionEvent[] = [];
84
117
  const encoder = new TextEncoder();
85
- const decoder = new TextDecoder();
86
-
87
- const BROWSER_EVENT_TYPE_MAX_BYTES = 256;
88
- const BROWSER_EVENT_ID_MAX_BYTES = 256;
89
- const BROWSER_EVENT_CLIENT_ID_MAX_BYTES = 4 * 1024;
90
- const BROWSER_EVENT_DUPLICATE_REASON_MAX_BYTES = 4 * 1024;
91
- const BROWSER_EVENT_PAYLOAD_PREVIEW_MAX_BYTES = 48 * 1024;
92
- const BROWSER_EVENT_PAYLOAD_IDENTITY_FIELDS = [
93
- "id",
94
- "callId",
95
- "call_id",
96
- "name",
97
- "toolName",
98
- "status",
99
- "code",
100
- "isError",
101
- "stream",
102
- "commandId",
103
- "sequence",
104
- "coalescedUntil",
105
- "coalescedCount",
106
- "firstSequence",
107
- "lastSequence",
108
- ] as const;
109
-
110
- export const SESSION_EVENT_BROWSER_MAX_BYTES = 8 * 1024 * 1024;
111
- export const SESSION_EVENT_BROWSER_MAX_COUNT = 10_000;
112
- export const SESSION_EVENT_BROWSER_SINGLE_EVENT_MAX_BYTES = 96 * 1024;
118
+ // Durable history remains on the server and is available through bounded
119
+ // navigation. Keep the live browser working set small across multiple tabs;
120
+ // serialized payloads also expand into objects and timeline projections.
121
+ export const SESSION_EVENT_BROWSER_MAX_BYTES = 16 * 1024 * 1024;
122
+ export const SESSION_EVENT_BROWSER_MAX_COUNT = 20_000;
113
123
  export const SESSION_EVENT_BROWSER_PENDING_MAX_BYTES = 1024 * 1024;
114
124
  export const SESSION_EVENT_BROWSER_PENDING_MAX_COUNT = 256;
115
125
 
@@ -142,20 +152,47 @@ export function useSessionEvents(
142
152
  const replay = options.replay ?? "windowed";
143
153
  const fullReplay = replay === "full" || after !== 0;
144
154
  const streamKey = `${workspaceId}\u0000${sessionId ?? ""}\u0000${after}\u0000${fullReplay ? "full" : "windowed"}`;
155
+ // Bind callbacks to this read lifetime, including a return to a previous
156
+ // identity. Publish during render so old callbacks are fenced before passive
157
+ // cleanup can advance the navigation generation.
158
+ const navigationIdentityRef = useRef({ client, streamKey, enabled });
159
+ if (
160
+ navigationIdentityRef.current.client !== client ||
161
+ navigationIdentityRef.current.streamKey !== streamKey ||
162
+ navigationIdentityRef.current.enabled !== enabled
163
+ ) {
164
+ navigationIdentityRef.current = { client, streamKey, enabled };
165
+ }
166
+ const navigationIdentity = navigationIdentityRef.current;
167
+ const ownsNavigation = useCallback(
168
+ () => navigationIdentityRef.current === navigationIdentity,
169
+ [navigationIdentity],
170
+ );
145
171
 
146
172
  const [eventWindow, setEventWindow] = useState<BrowserSessionEventWindow>(EMPTY_EVENT_WINDOW);
173
+ // One navigation witness, not a second history cache. Raw events stay contiguous.
174
+ const [questionEvidence, setQuestionEvidence] = useState<{
175
+ client: EmbeddedSessionClientLike;
176
+ streamKey: string;
177
+ anchor: number;
178
+ events: SessionEvent[];
179
+ } | null>(null);
147
180
  const [connectionState, setConnectionState] = useState<SessionEventsConnectionState>("idle");
148
181
  const [error, setError] = useState<Error | null>(null);
182
+ const [newerError, setNewerError] = useState<Error | null>(null);
149
183
  const [hasOlder, setHasOlder] = useState(false);
150
184
  const [hasNewer, setHasNewer] = useState(false);
151
185
  const [sessionStatusProjection, setSessionStatusProjection] = useState<SessionStatus | null>(
152
186
  null,
153
187
  );
154
188
  const [initialLoading, setInitialLoading] = useState(true);
189
+ const [initialHistoryReady, setInitialHistoryReady] = useState(false);
155
190
  const [loadingOlder, setLoadingOlder] = useState(false);
156
191
  const [loadingNewer, setLoadingNewer] = useState(false);
157
192
  const [loadingOldest, setLoadingOldest] = useState(false);
158
193
  const [loadingLatest, setLoadingLatest] = useState(false);
194
+ const [loadingTarget, setLoadingTarget] = useState(false);
195
+ const loadingTargetRef = useRef(false);
159
196
  const [streamEpoch, setStreamEpoch] = useState(0);
160
197
  // "history" = reading a non-tip window; live SSE must not fill the gap.
161
198
  const [viewMode, setViewMode] = useState<"live" | "history">("live");
@@ -171,9 +208,11 @@ export function useSessionEvents(
171
208
  const loadingLatestRef = useRef(false);
172
209
  const viewModeRef = useRef<"live" | "history">("live");
173
210
  const initialWindowLoadedRef = useRef(false);
211
+ const reconcileAfterPageResumeRef = useRef(false);
174
212
  const streamAbortRef = useRef<AbortController | null>(null);
175
213
  const streamKeyRef = useRef<string | null>(null);
176
214
  const generationRef = useRef(0);
215
+ const navigationGenerationRef = useRef(0);
177
216
  const eventWindowRef = useRef<BrowserSessionEventWindow>(EMPTY_EVENT_WINDOW);
178
217
  const sessionStatusRef = useRef<{
179
218
  sequence: number;
@@ -186,6 +225,25 @@ export function useSessionEvents(
186
225
  // new stream identity cannot expose the previous session's event log.
187
226
  const [stateStreamKey, setStateStreamKey] = useState(streamKey);
188
227
 
228
+ // Reopening SSE after a prepend must not cancel the next history page.
229
+ // Navigation belongs to the session/client lifetime, not the transport.
230
+ useEffect(() => {
231
+ navigationGenerationRef.current += 1;
232
+ setNewerError(null);
233
+ loadingOlderRef.current = false;
234
+ loadingNewerRef.current = false;
235
+ loadingOldestRef.current = false;
236
+ loadingTargetRef.current = false;
237
+ setQuestionEvidence(null);
238
+ setLoadingTarget(false);
239
+ setLoadingOlder(false);
240
+ setLoadingNewer(false);
241
+ setLoadingOldest(false);
242
+ return () => {
243
+ navigationGenerationRef.current += 1;
244
+ };
245
+ }, [client, workspaceId, sessionId, after, enabled, fullReplay]);
246
+
189
247
  useEffect(() => {
190
248
  // Reset the accumulated log only when the stream identity changes —
191
249
  // pausing via `enabled: false` keeps the timeline visible.
@@ -204,6 +262,7 @@ export function useSessionEvents(
204
262
  setLoadingOldest(false);
205
263
  setLoadingLatest(false);
206
264
  setInitialLoading(true);
265
+ setInitialHistoryReady(false);
207
266
  lastSequenceRef.current = after;
208
267
  streamResumeSequenceRef.current = after;
209
268
  oldestSequenceRef.current = null;
@@ -217,29 +276,50 @@ export function useSessionEvents(
217
276
  viewModeRef.current = "live";
218
277
  setViewMode("live");
219
278
  initialWindowLoadedRef.current = false;
279
+ reconcileAfterPageResumeRef.current = false;
220
280
  }
221
281
  // AbortController is advisory: custom SDK clients and async iterators may
222
282
  // ignore it and resolve/yield after cleanup. Fence every effect instance so
223
283
  // only the newest dependency generation can mutate refs or React state.
224
284
  const generation = generationRef.current + 1;
225
285
  generationRef.current = generation;
226
- if (loadingOlderRef.current) {
227
- loadingOlderRef.current = false;
228
- setLoadingOlder(false);
229
- }
230
286
  if (!sessionId || !streamEnabled) {
287
+ // A page that stayed hidden beyond the live-activity grace deliberately
288
+ // closed its SSE connection. Replaying from the old cursor on return can
289
+ // drip a large background backlog through React in many fast batches and
290
+ // leave the pinned camera catching up long after the user is foregrounded.
291
+ // Remember that suspension so the next live effect replaces the browser
292
+ // window with one compact durable tail instead.
293
+ if (
294
+ sessionId &&
295
+ enabled &&
296
+ !pageLive &&
297
+ !fullReplay &&
298
+ viewModeRef.current === "live" &&
299
+ initialWindowLoadedRef.current
300
+ ) {
301
+ reconcileAfterPageResumeRef.current = true;
302
+ }
231
303
  setConnectionState("idle");
232
304
  return;
233
305
  }
234
306
  // History view owns a non-tip window; do not open SSE (it would replay the
235
307
  // entire gap into the browser). jumpToLatest / catching loadNewer resume live.
236
308
  if (viewMode === "history") {
309
+ reconcileAfterPageResumeRef.current = false;
237
310
  setConnectionState("idle");
238
311
  setInitialLoading(false);
239
312
  return;
240
313
  }
314
+ const reconcileForegroundResume = reconcileAfterPageResumeRef.current && !fullReplay;
315
+ reconcileAfterPageResumeRef.current = false;
241
316
  const controller = new AbortController();
242
- const isCurrent = () => generationRef.current === generation && !controller.signal.aborted;
317
+ const isCurrent = () =>
318
+ generationRef.current === generation &&
319
+ !controller.signal.aborted &&
320
+ navigationIdentityRef.current.client === client &&
321
+ navigationIdentityRef.current.streamKey === streamKey &&
322
+ navigationIdentityRef.current.enabled === enabled;
243
323
  streamAbortRef.current = controller;
244
324
  // Batch yielded events into one React update per flush window so a long
245
325
  // replay (thousands of events) does not render per event. Project every
@@ -282,9 +362,12 @@ export function useSessionEvents(
282
362
  batchResumeSequence,
283
363
  );
284
364
  }
285
- observeSessionStatus(batch, sessionStatusRef, setSessionStatusProjection);
365
+ const status = observeSessionStatus(batch, sessionStatusRef);
366
+ if (status !== undefined) {
367
+ setSessionStatusProjection(status);
368
+ }
286
369
  const current = eventWindowRef.current;
287
- const next = boundBrowserSessionEventWindow([...current.events, ...batch]);
370
+ const next = appendBrowserSessionEventWindow(current, batch);
288
371
  const retained = {
289
372
  ...next,
290
373
  truncated: current.truncated || next.truncated,
@@ -292,7 +375,7 @@ export function useSessionEvents(
292
375
  eventWindowRef.current = retained;
293
376
  setEventWindow(retained);
294
377
  oldestSequenceRef.current = retained.events[0]?.sequence ?? null;
295
- newestSequenceRef.current = retained.events.at(-1)?.sequence ?? null;
378
+ newestSequenceRef.current = maxResumeSequenceOrNull(retained.events);
296
379
  if (retained.truncated) {
297
380
  hasOlderRef.current = true;
298
381
  setHasOlder(true);
@@ -305,6 +388,56 @@ export function useSessionEvents(
305
388
 
306
389
  void (async () => {
307
390
  try {
391
+ if (reconcileForegroundResume) {
392
+ setConnectionState("connecting");
393
+ const cursor = streamResumeSequenceRef.current;
394
+ let plan: ForegroundCatchupPlan = { kind: "resume" };
395
+ try {
396
+ plan = await planForegroundCatchup(client, workspaceId, sessionId, cursor, {
397
+ signal: controller.signal,
398
+ });
399
+ } catch {
400
+ if (controller.signal.aborted) return;
401
+ // This read is an optimization gate, not a new availability
402
+ // dependency. If it fails, preserve the SDK's exact cursor replay
403
+ // rather than blanking a usable timeline or failing the stream.
404
+ plan = { kind: "resume" };
405
+ }
406
+ if (!isCurrent()) return;
407
+ if (plan.kind === "append") {
408
+ const current = eventWindowRef.current;
409
+ assertAppendOrder(current.events, plan.events);
410
+ const status = observeSessionStatus(plan.events, sessionStatusRef);
411
+ const next = appendBrowserSessionEventWindow(current, plan.events);
412
+ const retained = {
413
+ ...next,
414
+ truncated: current.truncated || next.truncated,
415
+ };
416
+ eventWindowRef.current = retained;
417
+ oldestSequenceRef.current = retained.events[0]?.sequence ?? null;
418
+ newestSequenceRef.current = maxResumeSequenceOrNull(retained.events);
419
+ lastSequenceRef.current = Math.max(lastSequenceRef.current, plan.resumeSequence);
420
+ streamResumeSequenceRef.current = Math.max(
421
+ streamResumeSequenceRef.current,
422
+ plan.resumeSequence,
423
+ );
424
+ if (status !== undefined) {
425
+ setSessionStatusProjection(status);
426
+ }
427
+ setEventWindow(retained);
428
+ if (retained.truncated) {
429
+ hasOlderRef.current = true;
430
+ setHasOlder(true);
431
+ }
432
+ } else if (plan.kind === "reload") {
433
+ // A large/complex missed window would make the foreground timeline
434
+ // and pinned camera chase many rapid commits. Keep the last known
435
+ // complete window visible until the bounded latest replacement is
436
+ // ready, then install that replacement atomically below.
437
+ initialWindowLoadedRef.current = false;
438
+ setError(null);
439
+ }
440
+ }
308
441
  if (!fullReplay && !initialWindowLoadedRef.current) {
309
442
  setConnectionState("connecting");
310
443
  // First paint is ONE compact fetch — the newest window, revealed at
@@ -312,25 +445,42 @@ export function useSessionEvents(
312
445
  // reader actually scrolls up (the sentinel drives loadOlder).
313
446
  const window = await loadEventWindow(client, workspaceId, sessionId, {
314
447
  before: Number.MAX_SAFE_INTEGER,
315
- pageSize: INITIAL_TAIL_PAGE_SIZE,
448
+ pageSize: SESSION_HISTORY_PAGE_SIZE,
316
449
  targetGroups: Number.POSITIVE_INFINITY,
317
450
  maxFetches: INITIAL_FETCH_CAP,
451
+ boundaryPageCap: BOUNDARY_PAGE_CAP,
318
452
  signal: controller.signal,
319
453
  });
320
454
  if (!isCurrent()) {
321
455
  return;
322
456
  }
323
- observeSessionStatus(window.events, sessionStatusRef, setSessionStatusProjection);
457
+ const status = observeSessionStatus(window.events, sessionStatusRef);
324
458
  const retained = boundBrowserSessionEventWindow(window.events);
459
+ // A replacement tail retires requests against the discarded window.
460
+ // Ordinary SSE reconnects preserve navigation, but splicing an old
461
+ // page into this new tail could leave an inaccessible history gap.
462
+ navigationGenerationRef.current += 1;
463
+ loadingOlderRef.current = false;
464
+ loadingNewerRef.current = false;
465
+ loadingOldestRef.current = false;
466
+ setLoadingOlder(false);
467
+ setLoadingNewer(false);
468
+ setLoadingOldest(false);
325
469
  eventWindowRef.current = retained;
326
- setEventWindow(retained);
327
470
  oldestSequenceRef.current = retained.events[0]?.sequence ?? window.oldestSequence;
328
- newestSequenceRef.current = retained.events.at(-1)?.sequence ?? window.newestSequence;
471
+ newestSequenceRef.current =
472
+ maxResumeSequenceOrNull(retained.events) ?? window.newestSequence;
329
473
  hasOlderRef.current = window.hasOlder || retained.truncated;
330
474
  hasNewerRef.current = false;
331
475
  lastSequenceRef.current = window.newestSequence;
332
476
  streamResumeSequenceRef.current = window.newestSequence;
333
477
  initialWindowLoadedRef.current = true;
478
+ setInitialHistoryReady(true);
479
+ setError(null);
480
+ if (status !== undefined) {
481
+ setSessionStatusProjection(status);
482
+ }
483
+ setEventWindow(retained);
334
484
  setHasOlder(window.hasOlder || retained.truncated);
335
485
  setHasNewer(false);
336
486
  setInitialLoading(false);
@@ -349,7 +499,7 @@ export function useSessionEvents(
349
499
  .then(() => reconcileSession(sessionId))
350
500
  .catch((cause) => {
351
501
  if (isCurrent()) {
352
- setError(cause instanceof Error ? cause : new Error(String(cause)));
502
+ setError(normalizeError(cause));
353
503
  }
354
504
  });
355
505
  },
@@ -361,7 +511,7 @@ export function useSessionEvents(
361
511
  });
362
512
  for await (const event of stream) {
363
513
  if (!isCurrent()) break;
364
- const boundedEvent = boundBrowserLegacyEvent(event);
514
+ const boundedEvent = event;
365
515
  const boundedEventBytes = browserJsonBytes(boundedEvent);
366
516
  const separatorBytes = pending.length === 0 ? 0 : 1;
367
517
  if (
@@ -390,8 +540,12 @@ export function useSessionEvents(
390
540
  } catch (cause) {
391
541
  if (isCurrent()) {
392
542
  flush();
393
- setError(cause instanceof Error ? cause : new Error(String(cause)));
543
+ setError(normalizeError(cause));
394
544
  setConnectionState("error");
545
+ // A failed first compact-tail request has no later success path in
546
+ // this effect instance. End the loading gate so hosts can render the
547
+ // retryable error instead of an infinite initial spinner.
548
+ setInitialLoading(false);
395
549
  }
396
550
  }
397
551
  })();
@@ -413,6 +567,8 @@ export function useSessionEvents(
413
567
  workspaceId,
414
568
  sessionId,
415
569
  after,
570
+ enabled,
571
+ pageLive,
416
572
  streamEnabled,
417
573
  fullReplay,
418
574
  streamKey,
@@ -422,102 +578,157 @@ export function useSessionEvents(
422
578
  ]);
423
579
 
424
580
  const navigationBusy = (): boolean =>
581
+ loadingTargetRef.current ||
425
582
  loadingOlderRef.current ||
426
583
  loadingNewerRef.current ||
427
584
  loadingOldestRef.current ||
428
585
  loadingLatestRef.current;
429
586
 
430
- const loadOlder = useCallback(async (): Promise<boolean> => {
431
- if (!sessionId || navigationBusy() || !hasOlderRef.current) {
432
- return false;
433
- }
434
- const before = oldestSequenceRef.current;
435
- if (before === null) {
436
- hasOlderRef.current = false;
437
- setHasOlder(false);
438
- return false;
439
- }
440
- const generation = generationRef.current;
441
- loadingOlderRef.current = true;
442
- setLoadingOlder(true);
443
- try {
444
- const window = await loadEventWindow(client, workspaceId, sessionId, {
445
- before,
446
- pageSize: OLDER_PAGE_SIZE,
447
- targetGroups: OLDER_GROUP_TARGET,
448
- maxFetches: OLDER_FETCH_CAP,
449
- });
450
- if (generationRef.current !== generation) {
451
- return false;
452
- }
453
- if (window.events.length === 0) {
454
- oldestSequenceRef.current = null;
455
- hasOlderRef.current = false;
456
- setHasOlder(false);
457
- return false;
458
- }
459
- const current = eventWindowRef.current;
460
- assertPrependOrder(current.events, window.events);
461
- // Freeze the live iterator before replacing its in-memory window. Rows
462
- // pending in the aborted iterator were never cursor-committed and will
463
- // be replayed from the retained high-water mark below.
464
- streamAbortRef.current?.abort();
465
- observeSessionStatus(window.events, sessionStatusRef, setSessionStatusProjection);
466
- const next = boundBrowserSessionEventWindow([...window.events, ...current.events], {
467
- direction: "oldest",
468
- });
469
- const retained = {
470
- ...next,
471
- truncated: current.truncated || next.truncated,
472
- };
473
- const retainedOldest = retained.events[0]?.sequence ?? null;
474
- if (retainedOldest === null || retainedOldest >= before) {
475
- throw new Error("@opengeni/react: loadOlder made no durable sequence progress");
476
- }
477
- const retainedNewest = retained.events.at(-1)?.sequence ?? null;
478
- eventWindowRef.current = retained;
479
- setEventWindow(retained);
480
- oldestSequenceRef.current = retainedOldest;
481
- newestSequenceRef.current = retainedNewest;
482
- streamResumeSequenceRef.current = maxResumeSequence(retained.events);
483
- // Oldest-directed eviction can discard newer in-memory rows. That fact
484
- // keeps windowTruncated true, but it does not imply older durable rows
485
- // exist; only the backward DB page can answer hasOlder truthfully.
486
- const olderStillAvailable = window.hasOlder;
487
- hasOlderRef.current = olderStillAvailable;
488
- setHasOlder(olderStillAvailable);
489
- if (viewModeRef.current === "history") {
490
- const highWater = lastSequenceRef.current;
491
- const newer = retainedNewest !== null && (retainedNewest < highWater || retained.truncated);
492
- hasNewerRef.current = newer;
493
- setHasNewer(newer);
494
- } else {
495
- // Live mode keeps the historical contract: reconnect and replay the
496
- // evicted tip through SSE so the window stays one contiguous suffix.
497
- setStreamEpoch((epoch) => epoch + 1);
498
- }
499
- return olderStillAvailable;
500
- } finally {
501
- loadingOlderRef.current = false;
502
- setLoadingOlder(false);
503
- }
504
- }, [client, workspaceId, sessionId]);
587
+ const loadOlder = useCallback(
588
+ (): OlderHistoryLoadReceipt =>
589
+ createOlderHistoryLoadReceipt(async (markCommitted, preserveTail, markTailPreserved) => {
590
+ if (!ownsNavigation() || !sessionId || navigationBusy() || !hasOlderRef.current) {
591
+ return false;
592
+ }
593
+ const before = oldestSequenceRef.current;
594
+ if (before === null) {
595
+ oldestSequenceRef.current = null;
596
+ hasOlderRef.current = false;
597
+ setHasOlder(false);
598
+ return false;
599
+ }
600
+ const generation = navigationGenerationRef.current;
601
+ const isCurrent = () => ownsNavigation() && navigationGenerationRef.current === generation;
602
+ loadingOlderRef.current = true;
603
+ setLoadingOlder(true);
604
+ let published = false;
605
+ try {
606
+ const window = await loadEventWindow(client, workspaceId, sessionId, {
607
+ before,
608
+ pageSize: SESSION_HISTORY_PAGE_SIZE,
609
+ targetGroups: OLDER_GROUP_TARGET,
610
+ maxFetches: OLDER_FETCH_CAP,
611
+ isCurrent,
612
+ });
613
+ if (!isCurrent()) {
614
+ return false;
615
+ }
616
+ if (window.events.length === 0) {
617
+ oldestSequenceRef.current = null;
618
+ hasOlderRef.current = false;
619
+ setHasOlder(false);
620
+ return false;
621
+ }
622
+ const current = eventWindowRef.current;
623
+ assertPrependOrder(current.events, window.events);
624
+ // Freeze the live iterator before replacing its in-memory window. Rows
625
+ // pending in the aborted iterator were never cursor-committed and will
626
+ // be replayed from the retained high-water mark below.
627
+ const next = boundBrowserSessionEventWindow([...window.events, ...current.events], {
628
+ direction: "oldest",
629
+ });
630
+ if (preserveTail && maxResumeSequence(next.events) < maxResumeSequence(current.events)) {
631
+ // Automatic viewport filling must not navigate away from the
632
+ // reader's retained tail. Explicit history navigation may do so.
633
+ markTailPreserved();
634
+ return false;
635
+ }
636
+ streamAbortRef.current?.abort();
637
+ const status = observeSessionStatus(window.events, sessionStatusRef);
638
+ const retained = {
639
+ ...next,
640
+ truncated: current.truncated || next.truncated,
641
+ };
642
+ const retainedOldest = retained.events[0]?.sequence ?? null;
643
+ if (retainedOldest === null || retainedOldest >= before) {
644
+ throw new Error("@opengeni/react: loadOlder made no durable sequence progress");
645
+ }
646
+ const retainedNewest = maxResumeSequenceOrNull(retained.events);
647
+ const previousNewest = maxResumeSequenceOrNull(current.events);
648
+ eventWindowRef.current = retained;
649
+ markCommitted();
650
+ oldestSequenceRef.current = retainedOldest;
651
+ newestSequenceRef.current = retainedNewest;
652
+ streamResumeSequenceRef.current = maxResumeSequence(retained.events);
653
+ // Oldest-directed eviction can discard newer in-memory rows. That fact
654
+ // keeps windowTruncated true, but it does not imply older durable rows
655
+ // exist; only the backward DB page can answer hasOlder truthfully.
656
+ const olderStillAvailable = window.hasOlder;
657
+ hasOlderRef.current = olderStillAvailable;
658
+ const evictedLiveTail =
659
+ previousNewest !== null && retainedNewest !== null && retainedNewest < previousNewest;
660
+ let newer: boolean | null = null;
661
+ let enterHistory = false;
662
+ let reconnectLive = false;
663
+ if (viewModeRef.current === "history" || evictedLiveTail) {
664
+ const highWater = lastSequenceRef.current;
665
+ newer = retainedNewest !== null && (retainedNewest < highWater || retained.truncated);
666
+ hasNewerRef.current = newer;
667
+ if (evictedLiveTail) {
668
+ // A full oldest-directed browser window cannot also retain the
669
+ // live suffix. Keep the newly loaded history and page forward;
670
+ // reconnecting SSE here immediately newest-bounds the window and
671
+ // evicts every row the reader just requested.
672
+ viewModeRef.current = "history";
673
+ enterHistory = true;
674
+ }
675
+ } else {
676
+ // The retained merge is still one contiguous live suffix. Restart
677
+ // SSE from its cursor so events queued during the fetch are replayed.
678
+ reconnectLive = true;
679
+ }
680
+ loadingOlderRef.current = false;
681
+ startTransition(() => {
682
+ if (status !== undefined) {
683
+ setSessionStatusProjection(status);
684
+ }
685
+ setEventWindow(retained);
686
+ setHasOlder(olderStillAvailable);
687
+ if (newer !== null) {
688
+ setHasNewer(newer);
689
+ }
690
+ if (enterHistory) {
691
+ setViewMode("history");
692
+ } else if (reconnectLive) {
693
+ setStreamEpoch((epoch) => epoch + 1);
694
+ }
695
+ setLoadingOlder(false);
696
+ });
697
+ published = true;
698
+ return olderStillAvailable;
699
+ } catch (reason) {
700
+ if (!isCurrent()) return false;
701
+ throw reason;
702
+ } finally {
703
+ if (!published && isCurrent()) {
704
+ loadingOlderRef.current = false;
705
+ setLoadingOlder(false);
706
+ }
707
+ }
708
+ }),
709
+ [client, workspaceId, sessionId, ownsNavigation],
710
+ );
505
711
 
506
712
  const loadOldest = useCallback(async (): Promise<boolean> => {
507
- if (!sessionId || navigationBusy() || !hasOlderRef.current) {
713
+ if (!ownsNavigation() || !sessionId || navigationBusy() || !hasOlderRef.current) {
508
714
  return false;
509
715
  }
510
- const generation = generationRef.current;
716
+ // Explicit window replacement supersedes unresolved question destinations.
717
+ const generation = ++navigationGenerationRef.current;
718
+ const isCurrent = () => ownsNavigation() && navigationGenerationRef.current === generation;
719
+ setQuestionEvidence(null);
511
720
  loadingOldestRef.current = true;
512
721
  setLoadingOldest(true);
722
+ let published = false;
513
723
  try {
514
724
  const window = await loadForwardEventWindow(client, workspaceId, sessionId, {
515
725
  after: 0,
516
- pageSize: OLDEST_PAGE_SIZE,
726
+ pageSize: SESSION_HISTORY_PAGE_SIZE,
517
727
  targetGroups: OLDEST_GROUP_TARGET,
518
728
  maxFetches: OLDEST_FETCH_CAP,
729
+ isCurrent,
519
730
  });
520
- if (generationRef.current !== generation) {
731
+ if (!isCurrent()) {
521
732
  return false;
522
733
  }
523
734
  if (window.events.length === 0) {
@@ -526,17 +737,15 @@ export function useSessionEvents(
526
737
  return false;
527
738
  }
528
739
  streamAbortRef.current?.abort();
529
- observeSessionStatus(window.events, sessionStatusRef, setSessionStatusProjection);
740
+ const status = observeSessionStatus(window.events, sessionStatusRef);
530
741
  // Keep the oldest prefix — never the middle or tip.
531
742
  const retained = boundBrowserSessionEventWindow(window.events, { direction: "oldest" });
532
743
  const retainedOldest = retained.events[0]?.sequence ?? null;
533
- const retainedNewest = retained.events.at(-1)?.sequence ?? null;
744
+ const retainedNewest = maxResumeSequenceOrNull(retained.events);
534
745
  eventWindowRef.current = retained;
535
- setEventWindow(retained);
536
746
  oldestSequenceRef.current = retainedOldest;
537
747
  newestSequenceRef.current = retainedNewest;
538
748
  hasOlderRef.current = false;
539
- setHasOlder(false);
540
749
  const highWater = Math.max(lastSequenceRef.current, retainedNewest ?? 0);
541
750
  lastSequenceRef.current = highWater;
542
751
  const newer =
@@ -544,19 +753,37 @@ export function useSessionEvents(
544
753
  retained.truncated ||
545
754
  (retainedNewest !== null && retainedNewest < highWater);
546
755
  hasNewerRef.current = newer;
547
- setHasNewer(newer);
548
756
  initialWindowLoadedRef.current = true;
757
+ setInitialHistoryReady(true);
758
+ setError(null);
549
759
  viewModeRef.current = "history";
550
- setViewMode("history");
760
+ loadingOldestRef.current = false;
761
+ setNewerError(null);
762
+ startTransition(() => {
763
+ if (status !== undefined) {
764
+ setSessionStatusProjection(status);
765
+ }
766
+ setEventWindow(retained);
767
+ setHasOlder(false);
768
+ setHasNewer(newer);
769
+ setViewMode("history");
770
+ setLoadingOldest(false);
771
+ });
772
+ published = true;
551
773
  return newer;
774
+ } catch (reason) {
775
+ if (!isCurrent()) return false;
776
+ throw reason;
552
777
  } finally {
553
- loadingOldestRef.current = false;
554
- setLoadingOldest(false);
778
+ if (!published && isCurrent()) {
779
+ loadingOldestRef.current = false;
780
+ setLoadingOldest(false);
781
+ }
555
782
  }
556
- }, [client, workspaceId, sessionId]);
783
+ }, [client, workspaceId, sessionId, ownsNavigation]);
557
784
 
558
785
  const loadNewer = useCallback(async (): Promise<boolean> => {
559
- if (!sessionId || navigationBusy() || !hasNewerRef.current) {
786
+ if (!ownsNavigation() || !sessionId || navigationBusy() || !hasNewerRef.current) {
560
787
  return false;
561
788
  }
562
789
  const afterSequence = newestSequenceRef.current;
@@ -565,20 +792,24 @@ export function useSessionEvents(
565
792
  setHasNewer(false);
566
793
  return false;
567
794
  }
568
- const generation = generationRef.current;
795
+ const generation = navigationGenerationRef.current;
796
+ const isCurrent = () => ownsNavigation() && navigationGenerationRef.current === generation;
569
797
  loadingNewerRef.current = true;
570
798
  setLoadingNewer(true);
799
+ let published = false;
571
800
  try {
572
801
  const window = await loadForwardEventWindow(client, workspaceId, sessionId, {
573
802
  after: afterSequence,
574
- pageSize: NEWER_PAGE_SIZE,
803
+ pageSize: SESSION_HISTORY_PAGE_SIZE,
575
804
  targetGroups: NEWER_GROUP_TARGET,
576
805
  maxFetches: NEWER_FETCH_CAP,
806
+ isCurrent,
577
807
  });
578
- if (generationRef.current !== generation) {
808
+ if (!isCurrent()) {
579
809
  return false;
580
810
  }
581
811
  if (window.events.length === 0) {
812
+ setNewerError(null);
582
813
  hasNewerRef.current = false;
583
814
  setHasNewer(false);
584
815
  // Caught up with durable history — resume the live tip stream.
@@ -590,23 +821,20 @@ export function useSessionEvents(
590
821
  }
591
822
  const current = eventWindowRef.current;
592
823
  assertAppendOrder(current.events, window.events);
593
- observeSessionStatus(window.events, sessionStatusRef, setSessionStatusProjection);
824
+ const status = observeSessionStatus(window.events, sessionStatusRef);
594
825
  const previousOldest = current.events[0]?.sequence ?? null;
595
826
  // Moving forward through history: keep the newest suffix of the merge.
596
- const next = boundBrowserSessionEventWindow([...current.events, ...window.events], {
597
- direction: "newest",
598
- });
827
+ const next = appendBrowserSessionEventWindow(current, window.events);
599
828
  const retained = {
600
829
  ...next,
601
830
  truncated: current.truncated || next.truncated || window.hasNewer,
602
831
  };
603
832
  const retainedOldest = retained.events[0]?.sequence ?? null;
604
- const retainedNewest = retained.events.at(-1)?.sequence ?? null;
833
+ const retainedNewest = maxResumeSequenceOrNull(retained.events);
605
834
  if (retainedNewest === null || retainedNewest <= afterSequence) {
606
835
  throw new Error("@opengeni/react: loadNewer made no durable sequence progress");
607
836
  }
608
837
  eventWindowRef.current = retained;
609
- setEventWindow(retained);
610
838
  oldestSequenceRef.current = retainedOldest;
611
839
  newestSequenceRef.current = retainedNewest;
612
840
  // Evicting the start while paging forward re-arms hasOlder.
@@ -619,69 +847,261 @@ export function useSessionEvents(
619
847
  retainedOldest > 1))
620
848
  ) {
621
849
  hasOlderRef.current = true;
622
- setHasOlder(true);
623
850
  }
851
+ const rearmedOlder = hasOlderRef.current;
624
852
  const highWater = Math.max(lastSequenceRef.current, retainedNewest);
625
853
  lastSequenceRef.current = highWater;
626
854
  const newer = window.hasNewer || retainedNewest < highWater;
627
855
  hasNewerRef.current = newer;
628
- setHasNewer(newer);
856
+ let resumeLive = false;
629
857
  if (!newer) {
630
858
  streamResumeSequenceRef.current = retainedNewest;
631
859
  viewModeRef.current = "live";
632
- setViewMode("live");
633
- setStreamEpoch((epoch) => epoch + 1);
860
+ resumeLive = true;
634
861
  }
862
+ loadingNewerRef.current = false;
863
+ setNewerError(null);
864
+ startTransition(() => {
865
+ if (status !== undefined) {
866
+ setSessionStatusProjection(status);
867
+ }
868
+ setEventWindow(retained);
869
+ setHasOlder(rearmedOlder);
870
+ setHasNewer(newer);
871
+ if (resumeLive) {
872
+ setViewMode("live");
873
+ setStreamEpoch((epoch) => epoch + 1);
874
+ }
875
+ setLoadingNewer(false);
876
+ });
877
+ published = true;
635
878
  return newer;
879
+ } catch (reason) {
880
+ // A settled request from an old navigation lifetime is not a failure of
881
+ // the current session. Preserve current loading/error state as well.
882
+ if (!isCurrent()) {
883
+ return false;
884
+ }
885
+ setNewerError(normalizeError(reason));
886
+ // Keep authorization and integrity failures actionable for callers;
887
+ // timeline-owned invocations attach their own explicit recovery UI.
888
+ throw reason;
636
889
  } finally {
637
- loadingNewerRef.current = false;
638
- setLoadingNewer(false);
890
+ if (!published && isCurrent()) {
891
+ loadingNewerRef.current = false;
892
+ setLoadingNewer(false);
893
+ }
639
894
  }
640
- }, [client, workspaceId, sessionId]);
895
+ }, [client, workspaceId, sessionId, ownsNavigation]);
896
+
897
+ const jumpToSequence = useCallback(
898
+ async (sequence: number, navigationOptions?: { signal?: AbortSignal }): Promise<boolean> => {
899
+ const signal = navigationOptions?.signal;
900
+ if (!ownsNavigation() || !sessionId || !Number.isSafeInteger(sequence) || sequence < 1)
901
+ return false;
902
+ // A navigation that was already cancelled (for example Find closed before
903
+ // the caller could react) must not disturb any current view state.
904
+ if (signal?.aborted) return false;
905
+ // Explicit targets supersede both other targets and adjacent-page requests.
906
+ const generation = ++navigationGenerationRef.current;
907
+ setQuestionEvidence(null);
908
+ const current = () => generation === navigationGenerationRef.current && ownsNavigation();
909
+ const previousMode = viewModeRef.current;
910
+ streamAbortRef.current?.abort();
911
+ generationRef.current += 1;
912
+ viewModeRef.current = "history";
913
+ setViewMode("history");
914
+ loadingOlderRef.current = loadingNewerRef.current = loadingOldestRef.current = false;
915
+ setLoadingOlder(false);
916
+ setLoadingNewer(false);
917
+ setLoadingOldest(false);
918
+ loadingTargetRef.current = true;
919
+ setLoadingTarget(true);
920
+ let published = false;
921
+ try {
922
+ const [before, following] = await Promise.all([
923
+ loadPreviousPage(client, workspaceId, sessionId, sequence + 1, { pageSize: 128 }),
924
+ loadNextPage(client, workspaceId, sessionId, sequence, { pageSize: 128 }),
925
+ ]);
926
+ // Close-during-fetch fence: a pending jump must not replace the window
927
+ // after its owner (for example ConversationFind) has gone away. The
928
+ // finally block still settles loadingTarget for this generation.
929
+ if (signal?.aborted) return false;
930
+ if (!current()) return false;
931
+ if (!before.some((event) => event.sequence === sequence)) return false;
932
+ // Bound each side, keeping the target even if its message alone exceeds
933
+ // the ordinary byte budget. Never evict the very event being navigated to.
934
+ const prefix = boundBrowserSessionEventWindow(before, {
935
+ direction: "newest",
936
+ maxBytes: SESSION_EVENT_BROWSER_MAX_BYTES / 2,
937
+ });
938
+ const retained = boundBrowserSessionEventWindow([...prefix.events, ...following], {
939
+ direction: "oldest",
940
+ });
941
+ if (!retained.events.some((event) => event.sequence === sequence)) return false;
942
+ streamAbortRef.current?.abort();
943
+ generationRef.current += 1;
944
+ const status = observeSessionStatus(retained.events, sessionStatusRef);
945
+ eventWindowRef.current = retained;
946
+ oldestSequenceRef.current = retained.events[0]!.sequence;
947
+ newestSequenceRef.current = maxResumeSequenceOrNull(retained.events);
948
+ lastSequenceRef.current = Math.max(lastSequenceRef.current, newestSequenceRef.current ?? 0);
949
+ hasOlderRef.current = !isLogStart(retained.events[0]!);
950
+ // Compact/byte-bounded pages may be short despite more durable history.
951
+ // Only an empty forward page proves the end (same rule as loadNewer).
952
+ hasNewerRef.current =
953
+ following.length > 0 ||
954
+ retained.truncated ||
955
+ (newestSequenceRef.current ?? 0) < lastSequenceRef.current;
956
+ initialWindowLoadedRef.current = true;
957
+ setInitialHistoryReady(true);
958
+ setError(null);
959
+ viewModeRef.current = "history";
960
+ setEventWindow(retained);
961
+ setHasOlder(hasOlderRef.current);
962
+ setHasNewer(hasNewerRef.current);
963
+ setInitialLoading(false);
964
+ setViewMode("history");
965
+ setNewerError(null);
966
+ if (status !== undefined) setSessionStatusProjection(status);
967
+ published = true;
968
+ return true;
969
+ } catch (reason) {
970
+ if (!current() || signal?.aborted) return false;
971
+ throw reason;
972
+ } finally {
973
+ if (current()) {
974
+ loadingTargetRef.current = false;
975
+ setLoadingTarget(false);
976
+ if (!published && previousMode === "live") {
977
+ viewModeRef.current = "live";
978
+ setViewMode("live");
979
+ setStreamEpoch((epoch) => epoch + 1);
980
+ }
981
+ }
982
+ }
983
+ },
984
+ [client, workspaceId, sessionId, ownsNavigation],
985
+ );
986
+
987
+ const jumpToLatestQuestion = useCallback(
988
+ async (questionOptions?: LatestQuestionOptions): Promise<number | null> => {
989
+ if (!sessionId || !ownsNavigation()) return null;
990
+ let generation = navigationGenerationRef.current;
991
+ const current = () => ownsNavigation() && generation === navigationGenerationRef.current;
992
+ try {
993
+ // Optional action code stays off the session-open path. Capture identity
994
+ // before the import and fence its completion just like network reads.
995
+ const { resolveLatestQuestion } = await import("./latest-question");
996
+ if (!current()) return null;
997
+ const destination = await resolveLatestQuestion({
998
+ client,
999
+ workspaceId,
1000
+ sessionId,
1001
+ isCurrent: current,
1002
+ resumeSequence: maxResumeSequence,
1003
+ options: questionOptions,
1004
+ });
1005
+ if (!destination || !current()) return null;
1006
+ if (!eventWindowRef.current.events.some((event) => event.sequence === destination.anchor)) {
1007
+ const navigation = jumpToSequence(destination.anchor);
1008
+ generation = navigationGenerationRef.current;
1009
+ if (!(await navigation) || !current()) return null;
1010
+ }
1011
+ setQuestionEvidence({
1012
+ client,
1013
+ streamKey,
1014
+ anchor: destination.anchor,
1015
+ events: destination.events,
1016
+ });
1017
+ return destination.questionSequence;
1018
+ } catch (reason) {
1019
+ if (!current()) return null;
1020
+ throw reason;
1021
+ }
1022
+ },
1023
+ [client, workspaceId, sessionId, streamKey, ownsNavigation, jumpToSequence],
1024
+ );
641
1025
 
642
1026
  const jumpToLatest = useCallback(async (): Promise<void> => {
1027
+ // An old host retry closure must not clear a replacement session's error
1028
+ // or abort its live feed before the passive effect cleanup has run.
1029
+ if (!ownsNavigation()) return;
1030
+ if (loadingTargetRef.current) {
1031
+ navigationGenerationRef.current += 1;
1032
+ loadingTargetRef.current = false;
1033
+ setLoadingTarget(false);
1034
+ }
643
1035
  if (!sessionId || navigationBusy()) {
644
1036
  return;
645
1037
  }
1038
+ navigationGenerationRef.current += 1;
1039
+ setQuestionEvidence(null);
646
1040
  loadingLatestRef.current = true;
647
1041
  setLoadingLatest(true);
1042
+ setNewerError(null);
1043
+ setError(null);
1044
+ let published = false;
648
1045
  try {
649
1046
  streamAbortRef.current?.abort();
650
1047
  hasNewerRef.current = false;
651
- setHasNewer(false);
652
1048
  hasOlderRef.current = false;
653
- setHasOlder(false);
654
1049
  newestSequenceRef.current = null;
655
1050
  oldestSequenceRef.current = null;
656
1051
  eventWindowRef.current = EMPTY_EVENT_WINDOW;
657
- setEventWindow(EMPTY_EVENT_WINDOW);
658
1052
  initialWindowLoadedRef.current = false;
659
1053
  streamResumeSequenceRef.current = after;
660
- setInitialLoading(true);
661
1054
  viewModeRef.current = "live";
662
- setViewMode("live");
663
- setStreamEpoch((epoch) => epoch + 1);
1055
+ loadingLatestRef.current = false;
1056
+ startTransition(() => {
1057
+ setHasNewer(false);
1058
+ setHasOlder(false);
1059
+ setEventWindow(EMPTY_EVENT_WINDOW);
1060
+ setInitialLoading(true);
1061
+ setViewMode("live");
1062
+ setStreamEpoch((epoch) => epoch + 1);
1063
+ setLoadingLatest(false);
1064
+ });
1065
+ published = true;
664
1066
  // Effect reloads the tip. The tip fetch itself is owned by the effect.
665
1067
  await Promise.resolve();
666
1068
  } finally {
667
- loadingLatestRef.current = false;
668
- setLoadingLatest(false);
1069
+ if (!published) {
1070
+ loadingLatestRef.current = false;
1071
+ setLoadingLatest(false);
1072
+ }
669
1073
  }
670
- }, [after, sessionId]);
1074
+ }, [after, sessionId, ownsNavigation]);
671
1075
 
672
1076
  const identityMatches = stateStreamKey === streamKey;
673
1077
  const visibleEvents = identityMatches ? eventWindow.events : EMPTY_EVENTS;
674
- const timeline = useMemo(() => buildTimeline(visibleEvents), [visibleEvents]);
1078
+ const timeline = useMemo(() => {
1079
+ const witness =
1080
+ questionEvidence?.client === client &&
1081
+ questionEvidence.streamKey === streamKey &&
1082
+ visibleEvents.some((event) => event.sequence === questionEvidence.anchor)
1083
+ ? questionEvidence.events
1084
+ : EMPTY_EVENTS;
1085
+ const timelineOptions = { partialStart: hasOlder || eventWindow.truncated || after > 0 };
1086
+ if (witness.length === 0) return buildTimeline(visibleEvents, timelineOptions);
1087
+ const ids = new Set(visibleEvents.map((event) => event.id));
1088
+ return buildTimeline(
1089
+ [...visibleEvents, ...witness.filter((event) => !ids.has(event.id))],
1090
+ timelineOptions,
1091
+ );
1092
+ }, [visibleEvents, hasOlder, eventWindow.truncated, after, questionEvidence, client, streamKey]);
675
1093
 
676
1094
  return {
677
1095
  events: visibleEvents,
678
1096
  timeline,
679
1097
  sessionStatus: identityMatches ? sessionStatusProjection : null,
1098
+ sessionStatusSequence: identityMatches ? sessionStatusRef.current.sequence : 0,
680
1099
  connectionState: identityMatches ? connectionState : "idle",
681
1100
  lastSequence: identityMatches ? lastSequenceRef.current : after,
682
1101
  windowBytes: identityMatches ? eventWindow.bytes : 2,
683
1102
  windowTruncated: identityMatches ? eventWindow.truncated : false,
684
1103
  initialLoading: fullReplay ? false : identityMatches ? initialLoading : true,
1104
+ initialHistoryReady: identityMatches && initialHistoryReady,
685
1105
  hasOlder: !identityMatches ? false : hasOlder,
686
1106
  loadingOlder: !identityMatches ? false : loadingOlder,
687
1107
  loadOlder,
@@ -692,7 +1112,10 @@ export function useSessionEvents(
692
1112
  loadOldest,
693
1113
  loadingLatest: !identityMatches ? false : loadingLatest,
694
1114
  jumpToLatest,
695
- error: identityMatches ? error : null,
1115
+ jumpToLatestQuestion,
1116
+ jumpToSequence,
1117
+ loadingTarget: identityMatches && loadingTarget,
1118
+ error: identityMatches ? (newerError ?? error) : null,
696
1119
  };
697
1120
  }
698
1121
 
@@ -701,9 +1124,10 @@ export function useSessionEvents(
701
1124
  * accumulation retains the newest suffix; backward paging retains the oldest
702
1125
  * prefix so newly fetched history cannot be immediately evicted. This is
703
1126
  * deliberately separate from durable history and transport paging: when a
704
- * backward page evicts the live tail, the hook reconnects from the retained
705
- * high-water mark while preserving the highest-ever-observed sequence
706
- * separately. The source event remains durable in PostgreSQL throughout.
1127
+ * backward page evicts the live tail, the hook enters bounded history mode and
1128
+ * preserves the highest-ever-observed sequence separately for forward paging.
1129
+ * The source event remains durable in PostgreSQL throughout. An event larger
1130
+ * than the byte target is retained alone, never replaced by a lossy preview.
707
1131
  */
708
1132
  export function boundBrowserSessionEventWindow(
709
1133
  events: readonly SessionEvent[],
@@ -715,7 +1139,7 @@ export function boundBrowserSessionEventWindow(
715
1139
  ): BrowserSessionEventWindow {
716
1140
  const maxBytes = Math.max(1024, options.maxBytes ?? SESSION_EVENT_BROWSER_MAX_BYTES);
717
1141
  const maxCount = Math.max(1, Math.floor(options.maxCount ?? SESSION_EVENT_BROWSER_MAX_COUNT));
718
- const safe = events.map(boundBrowserLegacyEvent);
1142
+ const safe = events;
719
1143
  const selected: SessionEvent[] = [];
720
1144
  let bytes = 2; // []
721
1145
  const direction = options.direction ?? "newest";
@@ -726,7 +1150,7 @@ export function boundBrowserSessionEventWindow(
726
1150
  const event = safe[index]!;
727
1151
  const eventBytes = browserJsonBytes(event);
728
1152
  const separator = selected.length === 0 ? 0 : 1;
729
- if (bytes + separator + eventBytes > maxBytes) break;
1153
+ if (selected.length > 0 && bytes + separator + eventBytes > maxBytes) break;
730
1154
  selected.push(event);
731
1155
  bytes += separator + eventBytes;
732
1156
  }
@@ -738,255 +1162,41 @@ export function boundBrowserSessionEventWindow(
738
1162
  };
739
1163
  }
740
1164
 
741
- function boundBrowserLegacyEvent(event: SessionEvent): SessionEvent {
742
- // The server-side canonical projection lives in @opengeni/contracts. The
743
- // publishable React package may depend only on the zero-runtime-dependency
744
- // SDK, so this is intentionally a last-resort client guard rather than a
745
- // second durable representation. Reconstructing the SDK wire shape prevents
746
- // legacy/malformed extra properties from bypassing the browser byte cap.
747
- const serialized = browserSerialize(event);
748
- const originalBytes = serialized.serializable
749
- ? encoder.encode(serialized.value).byteLength
750
- : null;
751
- const typeIsSafe =
752
- browserUtf8Bytes(event.type) <= BROWSER_EVENT_TYPE_MAX_BYTES &&
753
- !event.type.includes("\n") &&
754
- !event.type.includes("\r");
755
- const clientEventId = boundBrowserOptionalText(
756
- event.clientEventId,
757
- BROWSER_EVENT_CLIENT_ID_MAX_BYTES,
758
- );
759
- const duplicateReason = boundBrowserOptionalText(
760
- event.duplicateReason,
761
- BROWSER_EVENT_DUPLICATE_REASON_MAX_BYTES,
762
- );
763
-
764
- if (
765
- serialized.serializable &&
766
- originalBytes !== null &&
767
- originalBytes <= SESSION_EVENT_BROWSER_SINGLE_EVENT_MAX_BYTES &&
768
- typeIsSafe &&
769
- clientEventId === event.clientEventId &&
770
- duplicateReason === event.duplicateReason
771
- ) {
772
- return event;
773
- }
774
-
775
- const envelopeProjection = [
776
- !typeIsSafe
777
- ? browserEnvelopeFieldProjection("type", event.type, "session.event.envelope_omitted")
778
- : null,
779
- clientEventId !== event.clientEventId
780
- ? browserEnvelopeFieldProjection("clientEventId", event.clientEventId, clientEventId)
781
- : null,
782
- duplicateReason !== event.duplicateReason
783
- ? browserEnvelopeFieldProjection("duplicateReason", event.duplicateReason, duplicateReason)
784
- : null,
785
- ].filter((field) => field !== null);
786
- const payloadSerialization = browserSerialize(event.payload);
787
- const payloadBytes = payloadSerialization.serializable
788
- ? encoder.encode(payloadSerialization.value).byteLength
789
- : null;
790
- const preview = truncateBrowserUtf8Middle(
791
- payloadSerialization.value,
792
- BROWSER_EVENT_PAYLOAD_PREVIEW_MAX_BYTES,
793
- );
794
- const truncation = {
795
- truncated: true as const,
796
- surface: "browser_legacy_guard" as const,
797
- reason: serialized.serializable ? "event_envelope_bytes_exceeded" : "event_not_serializable",
798
- originalBytes,
799
- deliveredBytes: 0,
800
- omittedBytes: originalBytes,
801
- estimatedOriginalTokens: originalBytes === null ? null : Math.ceil(originalBytes / 4),
802
- estimatedDeliveredTokens: 0,
803
- fullEvidence: { available: false as const, reason: "not_retained" as const },
804
- details: [
805
- {
806
- path: "$.payload",
807
- kind: "payload_preview",
808
- originalBytes: payloadBytes,
809
- deliveredBytes: browserUtf8Bytes(preview),
810
- },
811
- ],
812
- };
813
- const payload: Record<string, unknown> = {
814
- ...browserPayloadIdentity(event.payload),
815
- preview,
816
- ...(envelopeProjection.length > 0
817
- ? {
818
- originalType: boundBrowserText(event.type, BROWSER_EVENT_TYPE_MAX_BYTES),
819
- envelopeProjection: {
820
- truncated: true,
821
- surface: "browser_legacy_guard",
822
- fields: envelopeProjection,
823
- },
824
- }
825
- : {}),
826
- truncation,
827
- };
828
- const bounded: SessionEvent = {
829
- id: boundBrowserText(event.id, BROWSER_EVENT_ID_MAX_BYTES),
830
- workspaceId: boundBrowserText(event.workspaceId, BROWSER_EVENT_ID_MAX_BYTES),
831
- sessionId: boundBrowserText(event.sessionId, BROWSER_EVENT_ID_MAX_BYTES),
832
- sequence: event.sequence,
833
- type: typeIsSafe ? event.type : "session.event.envelope_omitted",
834
- payload,
835
- occurredAt: boundBrowserText(event.occurredAt, BROWSER_EVENT_ID_MAX_BYTES),
836
- clientEventId,
837
- turnId: boundBrowserOptionalText(event.turnId, BROWSER_EVENT_ID_MAX_BYTES),
838
- turnGeneration: event.turnGeneration,
839
- turnAttemptId: boundBrowserOptionalText(event.turnAttemptId, BROWSER_EVENT_ID_MAX_BYTES),
840
- turnAssociation: event.turnAssociation,
841
- duplicateOfEventId: boundBrowserOptionalText(
842
- event.duplicateOfEventId,
843
- BROWSER_EVENT_ID_MAX_BYTES,
844
- ),
845
- duplicateReason,
846
- };
847
-
848
- settleBrowserEventTruncation(bounded, truncation);
849
- if (browserJsonBytes(bounded) > SESSION_EVENT_BROWSER_SINGLE_EVENT_MAX_BYTES) {
850
- payload.preview = truncateBrowserUtf8Middle(String(payload.preview), 4 * 1024);
851
- truncation.details = truncation.details.slice(0, 1);
852
- settleBrowserEventTruncation(bounded, truncation);
853
- }
854
- if (browserJsonBytes(bounded) > SESSION_EVENT_BROWSER_SINGLE_EVENT_MAX_BYTES) {
855
- bounded.clientEventId = null;
856
- bounded.duplicateReason = null;
857
- payload.preview = "[legacy event omitted at the browser byte boundary]";
858
- settleBrowserEventTruncation(bounded, truncation);
859
- }
860
- return bounded;
861
- }
862
-
863
- function browserJsonBytes(value: unknown): number {
864
- return encoder.encode(browserSerialize(value).value).byteLength;
865
- }
866
-
867
- function browserSerialize(value: unknown): { value: string; serializable: boolean } {
868
- try {
869
- const serialized = JSON.stringify(value);
870
- return { value: serialized === undefined ? "null" : serialized, serializable: true };
871
- } catch {
872
- return {
873
- value: '"[unserializable event payload omitted at browser boundary]"',
874
- serializable: false,
875
- };
1165
+ /** @internal Append immutable events without serializing the retained history again. */
1166
+ export function appendBrowserSessionEventWindow(
1167
+ current: BrowserSessionEventWindow,
1168
+ batch: readonly SessionEvent[],
1169
+ options: { maxBytes?: number; maxCount?: number } = {},
1170
+ ): BrowserSessionEventWindow {
1171
+ const maxBytes = Math.max(1024, options.maxBytes ?? SESSION_EVENT_BROWSER_MAX_BYTES);
1172
+ const maxCount = Math.max(1, Math.floor(options.maxCount ?? SESSION_EVENT_BROWSER_MAX_COUNT));
1173
+ const events = [...current.events, ...batch];
1174
+ let bytes = current.bytes;
1175
+ for (let index = 0; index < batch.length; index += 1) {
1176
+ bytes += browserJsonBytes(batch[index]) + (current.events.length + index > 0 ? 1 : 0);
876
1177
  }
877
- }
878
-
879
- function browserPayloadIdentity(payload: unknown): Record<string, unknown> {
880
- if (!payload || typeof payload !== "object" || Array.isArray(payload)) return {};
881
- const record = payload as Record<string, unknown>;
882
- const identity: Record<string, unknown> = {};
883
- for (const field of BROWSER_EVENT_PAYLOAD_IDENTITY_FIELDS) {
884
- const value = record[field];
885
- if (typeof value === "string") {
886
- identity[field] = boundBrowserText(value, BROWSER_EVENT_ID_MAX_BYTES);
887
- } else if (typeof value === "number" || typeof value === "boolean" || value === null) {
888
- identity[field] = value;
889
- }
1178
+ let start = 0;
1179
+ // Preserve the newest event even when it alone exceeds the byte target,
1180
+ // matching the full-window reducer and retaining exact cursor progress.
1181
+ while (events.length - start > 1 && (bytes > maxBytes || events.length - start > maxCount)) {
1182
+ bytes -= browserJsonBytes(events[start]) + 1;
1183
+ start += 1;
890
1184
  }
891
- return identity;
892
- }
893
-
894
- function browserEnvelopeFieldProjection(
895
- field: string,
896
- original: string | null | undefined,
897
- delivered: string | null | undefined,
898
- ): { field: string; originalBytes: number; deliveredBytes: number } {
899
1185
  return {
900
- field,
901
- originalBytes: typeof original === "string" ? browserUtf8Bytes(original) : 0,
902
- deliveredBytes: typeof delivered === "string" ? browserUtf8Bytes(delivered) : 0,
1186
+ events: start === 0 ? events : events.slice(start),
1187
+ bytes,
1188
+ truncated: current.truncated || start > 0,
903
1189
  };
904
1190
  }
905
1191
 
906
- function boundBrowserOptionalText<T extends string | null | undefined>(
907
- value: T,
908
- maxBytes: number,
909
- ): T {
910
- return (typeof value === "string" ? boundBrowserText(value, maxBytes) : value) as T;
911
- }
912
-
913
- function boundBrowserText(value: string, maxBytes: number): string {
914
- const bytes = encoder.encode(value);
915
- if (bytes.byteLength <= maxBytes) return value;
916
- const marker = "…[truncated]";
917
- const prefixBudget = Math.max(0, maxBytes - browserUtf8Bytes(marker));
918
- let prefixEnd = Math.min(prefixBudget, bytes.byteLength);
919
- while (
920
- prefixEnd > 0 &&
921
- prefixEnd < bytes.byteLength &&
922
- isBrowserUtf8Continuation(bytes[prefixEnd]!)
923
- ) {
924
- prefixEnd -= 1;
925
- }
926
- return `${decoder.decode(bytes.subarray(0, prefixEnd))}${marker}`;
927
- }
928
-
929
- function truncateBrowserUtf8Middle(value: string, maxBytes: number): string {
930
- const bytes = encoder.encode(value);
931
- if (bytes.byteLength <= maxBytes) return value;
932
- const marker = `…[${bytes.byteLength - maxBytes} bytes omitted]…`;
933
- const contentBudget = Math.max(0, maxBytes - browserUtf8Bytes(marker));
934
- const leftBudget = Math.floor(contentBudget / 2);
935
- const rightBudget = contentBudget - leftBudget;
936
- let leftEnd = Math.min(leftBudget, bytes.byteLength);
937
- while (leftEnd > 0 && leftEnd < bytes.byteLength && isBrowserUtf8Continuation(bytes[leftEnd]!)) {
938
- leftEnd -= 1;
939
- }
940
- let rightStart = Math.max(0, bytes.byteLength - rightBudget);
941
- while (rightStart < bytes.byteLength && isBrowserUtf8Continuation(bytes[rightStart]!)) {
942
- rightStart += 1;
943
- }
944
- return `${decoder.decode(bytes.subarray(0, leftEnd))}${marker}${decoder.decode(bytes.subarray(rightStart))}`;
945
- }
946
-
947
- function settleBrowserEventTruncation(
948
- event: SessionEvent,
949
- truncation: {
950
- originalBytes: number | null;
951
- deliveredBytes: number;
952
- omittedBytes: number | null;
953
- estimatedDeliveredTokens: number;
954
- },
955
- ): void {
956
- for (let attempt = 0; attempt < 16; attempt += 1) {
957
- const deliveredBytes = browserJsonBytes(event);
958
- const omittedBytes =
959
- truncation.originalBytes === null
960
- ? null
961
- : Math.max(0, truncation.originalBytes - deliveredBytes);
962
- const estimatedDeliveredTokens = Math.ceil(deliveredBytes / 4);
963
- if (
964
- truncation.deliveredBytes === deliveredBytes &&
965
- truncation.omittedBytes === omittedBytes &&
966
- truncation.estimatedDeliveredTokens === estimatedDeliveredTokens
967
- ) {
968
- return;
969
- }
970
- truncation.deliveredBytes = deliveredBytes;
971
- truncation.omittedBytes = omittedBytes;
972
- truncation.estimatedDeliveredTokens = estimatedDeliveredTokens;
973
- }
974
- throw new RangeError("Browser event byte accounting did not converge");
975
- }
976
-
977
- function browserUtf8Bytes(value: string): number {
978
- return encoder.encode(value).byteLength;
979
- }
980
-
981
- function isBrowserUtf8Continuation(value: number): boolean {
982
- return (value & 0xc0) === 0x80;
1192
+ function browserJsonBytes(value: unknown): number {
1193
+ return encoder.encode(JSON.stringify(value)).byteLength;
983
1194
  }
984
1195
 
985
1196
  function observeSessionStatus(
986
1197
  events: readonly SessionEvent[],
987
1198
  ref: { current: { sequence: number; status: SessionStatus | null } },
988
- setStatus: (status: SessionStatus | null) => void,
989
- ): void {
1199
+ ): SessionStatus | undefined {
990
1200
  let latest: { sequence: number; status: SessionStatus } | null = null;
991
1201
  for (const event of events) {
992
1202
  const status = sessionStatusFromEvents([event]);
@@ -994,9 +1204,71 @@ function observeSessionStatus(
994
1204
  latest = { sequence: event.sequence, status };
995
1205
  }
996
1206
  }
997
- if (!latest || latest.sequence < ref.current.sequence) return;
1207
+ if (!latest || latest.sequence < ref.current.sequence) return undefined;
998
1208
  ref.current = latest;
999
- setStatus(latest.status);
1209
+ return latest.status;
1210
+ }
1211
+
1212
+ type ForegroundCatchupPlan =
1213
+ | { kind: "resume" }
1214
+ | { kind: "append"; events: SessionEvent[]; resumeSequence: number }
1215
+ | { kind: "reload" };
1216
+
1217
+ /**
1218
+ * Decide how a sustained hidden-tab suspension rejoins the durable event log.
1219
+ *
1220
+ * `lastSequence - cursor` is the exact raw durable work missed, but it is not
1221
+ * the number of visible messages: one streaming answer can own thousands of
1222
+ * adjacent delta rows. Small raw gaps therefore resume directly; medium gaps
1223
+ * get one forward compact page and are judged by the browser-visible result;
1224
+ * a page that cannot prove complete bounded coverage, or that would add too
1225
+ * many rows/groups/bytes, reloads the latest tail instead.
1226
+ */
1227
+ async function planForegroundCatchup(
1228
+ client: EmbeddedSessionClientLike,
1229
+ workspaceId: string,
1230
+ sessionId: string,
1231
+ cursor: number,
1232
+ options: { signal?: AbortSignal } = {},
1233
+ ): Promise<ForegroundCatchupPlan> {
1234
+ if (options.signal?.aborted) throw abortError();
1235
+ const session = await client.getSession(workspaceId, sessionId, {
1236
+ fresh: true,
1237
+ ...(options.signal ? { signal: options.signal } : {}),
1238
+ });
1239
+ if (options.signal?.aborted) throw abortError();
1240
+ const durableHead = Math.max(cursor, session.lastSequence);
1241
+ const rawGap = durableHead - cursor;
1242
+ if (rawGap <= FOREGROUND_DIRECT_REPLAY_MAX_SEQUENCES) {
1243
+ return { kind: "resume" };
1244
+ }
1245
+ if (rawGap > FOREGROUND_COMPACT_PROBE_MAX_SEQUENCES) {
1246
+ return { kind: "reload" };
1247
+ }
1248
+
1249
+ const page = await loadNextPage(client, workspaceId, sessionId, cursor, {
1250
+ pageSize: rawGap,
1251
+ ...(options.signal ? { signal: options.signal } : {}),
1252
+ });
1253
+ const events = page.filter((event) => eventResumeSequence(event) > cursor);
1254
+ if (events.length === 0) {
1255
+ return { kind: "reload" };
1256
+ }
1257
+ assertAscending(events);
1258
+ const resumeSequence = maxResumeSequence(events);
1259
+ if (resumeSequence < durableHead) {
1260
+ // Byte/count truncation or an incomplete projection means this page cannot
1261
+ // prove that one append reaches the head observed above.
1262
+ return { kind: "reload" };
1263
+ }
1264
+ if (
1265
+ events.length > FOREGROUND_COMPACT_CATCHUP_MAX_EVENTS ||
1266
+ groupCount(events) > FOREGROUND_COMPACT_CATCHUP_MAX_GROUPS ||
1267
+ browserJsonBytes(events) > FOREGROUND_COMPACT_CATCHUP_MAX_BYTES
1268
+ ) {
1269
+ return { kind: "reload" };
1270
+ }
1271
+ return { kind: "append", events, resumeSequence };
1000
1272
  }
1001
1273
 
1002
1274
  type LoadedEventWindow = {
@@ -1022,7 +1294,9 @@ async function loadEventWindow(
1022
1294
  pageSize: number;
1023
1295
  targetGroups: number;
1024
1296
  maxFetches: number;
1297
+ boundaryPageCap?: number;
1025
1298
  signal?: AbortSignal;
1299
+ isCurrent?: () => boolean;
1026
1300
  },
1027
1301
  ): Promise<LoadedEventWindow> {
1028
1302
  let cursor = options.before;
@@ -1038,6 +1312,7 @@ async function loadEventWindow(
1038
1312
  pageSize: options.pageSize,
1039
1313
  ...(options.signal ? { signal: options.signal } : {}),
1040
1314
  });
1315
+ if (options.isCurrent?.() === false) throw abortError();
1041
1316
  fetches += 1;
1042
1317
  if (page.length === 0) {
1043
1318
  reachedStart = true;
@@ -1056,19 +1331,19 @@ async function loadEventWindow(
1056
1331
  // turn boundary already in the buffer — the dropped fragment is refetched by
1057
1332
  // the next loadOlder (everything below the new oldest sequence), whose own
1058
1333
  // window snaps the same way, so every seam lands on a turn start. Extra
1059
- // pages are fetched only when the buffer holds no boundary at all (one
1060
- // monster turn); past the cap a mid-turn top is accepted.
1334
+ // page is fetched only when the buffer holds no boundary at all (one dense
1335
+ // turn); past the cap the existing truncation/hasOlder signal remains true.
1061
1336
  let snapPages = 0;
1062
1337
  while (
1063
1338
  !reachedStart &&
1064
1339
  findBoundaryIndex(buffer) === -1 &&
1065
- snapPages < BOUNDARY_PAGE_CAP &&
1066
- fetches < options.maxFetches
1340
+ snapPages < (options.boundaryPageCap ?? 0)
1067
1341
  ) {
1068
1342
  const page = await loadPreviousPage(client, workspaceId, sessionId, cursor, {
1069
1343
  pageSize: options.pageSize,
1070
1344
  ...(options.signal ? { signal: options.signal } : {}),
1071
1345
  });
1346
+ if (options.isCurrent?.() === false) throw abortError();
1072
1347
  fetches += 1;
1073
1348
  snapPages += 1;
1074
1349
  if (page.length === 0) {
@@ -1089,6 +1364,8 @@ async function loadEventWindow(
1089
1364
  buffer = buffer.slice(boundary);
1090
1365
  }
1091
1366
  }
1367
+ const densityWindow = trimBackwardToGroupTarget(buffer, options.targetGroups);
1368
+ buffer = densityWindow.events;
1092
1369
 
1093
1370
  const oldest = buffer[0] ?? null;
1094
1371
  const newest = buffer[buffer.length - 1] ?? null;
@@ -1096,21 +1373,26 @@ async function loadEventWindow(
1096
1373
  events: buffer,
1097
1374
  oldestSequence: oldest?.sequence ?? null,
1098
1375
  newestSequence: newest ? maxResumeSequence(buffer) : 0,
1099
- hasOlder: buffer.length > 0 && !reachedStart && oldest?.type !== "session.created",
1376
+ hasOlder:
1377
+ buffer.length > 0 &&
1378
+ (densityWindow.trimmed || (!reachedStart && oldest?.type !== "session.created")),
1100
1379
  };
1101
1380
  }
1102
1381
 
1103
1382
  /** Index of the oldest clean turn start in the buffer, or -1. */
1104
1383
  function findBoundaryIndex(events: SessionEvent[]): number {
1105
1384
  for (let index = 0; index < events.length; index += 1) {
1106
- const type = events[index]!.type;
1107
- if (type === "session.created" || type === "user.message") {
1385
+ if (isTurnBoundary(events[index]!)) {
1108
1386
  return index;
1109
1387
  }
1110
1388
  }
1111
1389
  return -1;
1112
1390
  }
1113
1391
 
1392
+ function isTurnBoundary(event: SessionEvent): boolean {
1393
+ return event.type === "session.created" || event.type === "user.message";
1394
+ }
1395
+
1114
1396
  /**
1115
1397
  * Forward (oldest→newer) density-bounded window. Used for jump-to-start and
1116
1398
  * loadNewer so the browser never walks the whole middle gap.
@@ -1125,6 +1407,7 @@ async function loadForwardEventWindow(
1125
1407
  targetGroups: number;
1126
1408
  maxFetches: number;
1127
1409
  signal?: AbortSignal;
1410
+ isCurrent?: () => boolean;
1128
1411
  },
1129
1412
  ): Promise<LoadedForwardEventWindow> {
1130
1413
  let cursor = options.after;
@@ -1140,6 +1423,7 @@ async function loadForwardEventWindow(
1140
1423
  pageSize: options.pageSize,
1141
1424
  ...(options.signal ? { signal: options.signal } : {}),
1142
1425
  });
1426
+ if (options.isCurrent?.() === false) throw abortError();
1143
1427
  fetches += 1;
1144
1428
  if (page.length === 0) {
1145
1429
  reachedEnd = true;
@@ -1147,12 +1431,15 @@ async function loadForwardEventWindow(
1147
1431
  }
1148
1432
  assertAscending(page);
1149
1433
  buffer = [...buffer, ...page];
1150
- cursor = page[page.length - 1]!.sequence;
1151
- if (page.length < options.pageSize) {
1152
- reachedEnd = true;
1153
- break;
1154
- }
1434
+ // Compact events retain their first raw sequence in `sequence` and expose
1435
+ // the covered high-water mark through `coalescedUntil`. Advancing by the
1436
+ // first sequence duplicates compacted text on the next page. A compact
1437
+ // response length is not an end-of-history signal either: thousands of raw
1438
+ // events may legitimately collapse into one returned event.
1439
+ cursor = maxResumeSequence(page);
1155
1440
  }
1441
+ const densityWindow = trimForwardToGroupTarget(buffer, options.targetGroups);
1442
+ buffer = densityWindow.events;
1156
1443
 
1157
1444
  const oldest = buffer[0] ?? null;
1158
1445
  const newest = buffer[buffer.length - 1] ?? null;
@@ -1160,10 +1447,45 @@ async function loadForwardEventWindow(
1160
1447
  events: buffer,
1161
1448
  oldestSequence: oldest?.sequence ?? null,
1162
1449
  newestSequence: newest ? maxResumeSequence(buffer) : 0,
1163
- hasNewer: buffer.length > 0 && !reachedEnd,
1450
+ hasNewer: buffer.length > 0 && (densityWindow.trimmed || !reachedEnd),
1164
1451
  };
1165
1452
  }
1166
1453
 
1454
+ type DensityWindow = {
1455
+ events: SessionEvent[];
1456
+ trimmed: boolean;
1457
+ };
1458
+
1459
+ /** Keep the newest complete turn groups near an older-history seam. */
1460
+ function trimBackwardToGroupTarget(events: SessionEvent[], targetGroups: number): DensityWindow {
1461
+ if (!Number.isFinite(targetGroups) || targetGroups <= 0 || events.length === 0) {
1462
+ return { events, trimmed: false };
1463
+ }
1464
+ for (let index = events.length - 1; index > 0; index -= 1) {
1465
+ if (!isTurnBoundary(events[index]!)) continue;
1466
+ const candidate = events.slice(index);
1467
+ if (groupCount(candidate) >= targetGroups) {
1468
+ return { events: candidate, trimmed: true };
1469
+ }
1470
+ }
1471
+ return { events, trimmed: false };
1472
+ }
1473
+
1474
+ /** Keep the oldest complete turn groups near a forward-history seam. */
1475
+ function trimForwardToGroupTarget(events: SessionEvent[], targetGroups: number): DensityWindow {
1476
+ if (!Number.isFinite(targetGroups) || targetGroups <= 0 || events.length === 0) {
1477
+ return { events, trimmed: false };
1478
+ }
1479
+ for (let index = 1; index < events.length; index += 1) {
1480
+ if (!isTurnBoundary(events[index]!)) continue;
1481
+ const candidate = events.slice(0, index);
1482
+ if (groupCount(candidate) >= targetGroups) {
1483
+ return { events: candidate, trimmed: true };
1484
+ }
1485
+ }
1486
+ return { events, trimmed: false };
1487
+ }
1488
+
1167
1489
  type PreviousPage = SessionEvent[] & { requested: number };
1168
1490
 
1169
1491
  async function loadPreviousPage(
@@ -1240,18 +1562,22 @@ function isLogStart(event: SessionEvent): boolean {
1240
1562
  return event.type === "session.created" || event.sequence <= 1;
1241
1563
  }
1242
1564
 
1243
- function maxResumeSequence(events: SessionEvent[]): number {
1565
+ function maxResumeSequence(events: readonly SessionEvent[]): number {
1244
1566
  return events.reduce((max, event) => Math.max(max, eventResumeSequence(event)), 0);
1245
1567
  }
1246
1568
 
1247
- function eventResumeSequence(event: SessionEvent): number {
1248
- const payload = asRecord(event.payload);
1249
- const coalescedUntil = Number(payload.coalescedUntil);
1250
- return Math.max(event.sequence, Number.isFinite(coalescedUntil) ? Math.floor(coalescedUntil) : 0);
1569
+ function maxResumeSequenceOrNull(events: readonly SessionEvent[]): number | null {
1570
+ return events.length > 0 ? maxResumeSequence(events) : null;
1251
1571
  }
1252
1572
 
1253
- function asRecord(value: unknown): Record<string, unknown> {
1254
- return value && typeof value === "object" ? (value as Record<string, unknown>) : {};
1573
+ function eventResumeSequence(event: SessionEvent): number {
1574
+ const streamedCoverage = sessionEventStreamCoveredThrough(event);
1575
+ if (streamedCoverage !== null) return streamedCoverage;
1576
+ return typeof event.coveredThrough === "number" &&
1577
+ Number.isSafeInteger(event.coveredThrough) &&
1578
+ event.coveredThrough >= event.sequence
1579
+ ? event.coveredThrough
1580
+ : event.sequence;
1255
1581
  }
1256
1582
 
1257
1583
  function assertAscending(events: SessionEvent[]): void {