@opengeni/react 1.0.1 → 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 +651 -21
  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 +6 -3
  302. package/dist/realtime.js +133 -74
  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 +133 -46
  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,19 +1,36 @@
1
+ import { RollingActivity } from "../timeline/rolling-activity";
2
+ import { useErrorMessage } from "../lib/error-message";
3
+ import { formatElapsed } from "../timeline/turn-summary";
4
+ import { ActivityNoteTextContext } from "../timeline/activity-rail";
5
+ import { timelineGroupContainsPresentedImage } from "../timeline/presented-image";
6
+ import {
7
+ isTimelineSearchTarget,
8
+ TimelineSearchRevealContext,
9
+ useTimelineSearchNavigation,
10
+ type TimelineSearchTarget,
11
+ } from "./timeline-search";
12
+ import { GenieLoadingOptionsContext, type GenieLoadingOptions } from "../timeline/genie-loading";
13
+ import { ChildSessionLink } from "./child-session-link";
14
+ import { useStartupDetails } from "../timeline/startup-preference";
15
+ import { compactionSkipSubtitle } from "../timeline/compaction-copy";
1
16
  import type {
2
17
  DraftTimelineAnnotation,
3
18
  MediaGenerationResult,
4
19
  SessionEvent,
5
20
  SessionStatus,
6
21
  } from "@opengeni/sdk";
22
+ import { dequal } from "dequal/lite";
7
23
  import {
8
24
  ArrowDownIcon,
9
25
  ArrowRightIcon,
10
- ArrowUpToLineIcon,
26
+ ArrowUpIcon,
11
27
  BotIcon,
12
28
  CheckCircle2Icon,
13
29
  CheckIcon,
14
30
  ChevronRightIcon,
15
31
  PauseCircleIcon,
16
32
  PauseIcon,
33
+ MessageCircleQuestionIcon,
17
34
  PencilLineIcon,
18
35
  PlayIcon,
19
36
  RefreshCwIcon,
@@ -23,8 +40,8 @@ import {
23
40
  TriangleAlertIcon,
24
41
  XCircleIcon,
25
42
  } from "lucide-react";
26
- import type { ComponentType } from "react";
27
- import { AnimatePresence, motion } from "motion/react";
43
+ import type { ComponentType, CSSProperties } from "react";
44
+ import { AnimatePresence, motion, useReducedMotion } from "motion/react";
28
45
  import { Collapsible } from "radix-ui";
29
46
  import {
30
47
  Component,
@@ -42,22 +59,43 @@ import {
42
59
  import { cn } from "../lib/cn";
43
60
  import { formatClockTime, formatRelativeTime, truncate } from "../lib/format";
44
61
  import { prefersReducedMotion } from "../lib/motion";
45
- import { Markdown } from "./markdown";
62
+ import {
63
+ invokeOlderHistoryLoaderWithReceiptCapture,
64
+ type OlderHistoryLoadReceipt,
65
+ type OlderHistoryLoader,
66
+ } from "../older-history";
67
+ import { Markdown, type MarkdownProps } from "./markdown";
68
+ import { OpenGeniLinkProvider, type OpenGeniLinkResolver } from "./open-geni-links";
69
+ import {
70
+ TimelineBeforeLayout,
71
+ captureTimelineAnchor,
72
+ timelineAnchorCorrection,
73
+ timelineHasReader,
74
+ type TimelineAnchor,
75
+ } from "./timeline-anchor";
76
+ import { useReadingProgress } from "./timeline-reading-progress";
77
+ import {
78
+ animateTimelineSettlement,
79
+ captureTimelineSettlement,
80
+ type TimelineSettlement,
81
+ } from "./timeline-settlement";
46
82
  import {
47
83
  UserMessageBody,
48
84
  UserMessageDisclosureProvider,
49
85
  type UserMessageDisclosureContextValue,
86
+ type UserMessageDisclosureLabels,
50
87
  } from "./user-message-body";
51
88
  import {
52
89
  createTipFollowState,
53
90
  readerScrollUpPx,
54
91
  tipFollowCancel,
55
- tipFollowCompensateShrink,
56
92
  tipFollowCompensateViewportShrink,
93
+ tipFollowObserveContentShrink,
57
94
  tipFollowStep,
58
95
  supportsScrollEndEvent,
59
96
  TIP_FOLLOW_READER_UP_EPS_PX,
60
97
  TIP_FOLLOW_SHRINK_EPS_PX,
98
+ type TipFollowContentShrinkBaseline,
61
99
  type TipFollowState,
62
100
  } from "./tip-follow";
63
101
  import {
@@ -71,6 +109,7 @@ import {
71
109
  type AuthNeededItem,
72
110
  type ContextCompactionItem,
73
111
  type GoalItem,
112
+ type HumanInputItem,
74
113
  type MachineInputBatchItem,
75
114
  type NoticeItem,
76
115
  type TimelineGroup,
@@ -81,6 +120,7 @@ import {
81
120
  type VideoArtifactPlaybackLoader,
82
121
  type ToolRegistry,
83
122
  type TurnSummaryOptions,
123
+ type TurnSummaryStatus,
84
124
  type UserMessageItem,
85
125
  type FoldRestingState,
86
126
  type WorkerCompletionItem,
@@ -91,6 +131,7 @@ import {
91
131
  useTurnSettleOpen,
92
132
  } from "../timeline";
93
133
  import { CopyHoverFrame } from "./copy-button";
134
+ import { TimelineAnnotationSourceRootContext } from "./timeline-annotation-reveal-context";
94
135
  import { GeneratedVideoPlayer } from "./generated-video-player";
95
136
  import {
96
137
  MACHINE_INPUT_META,
@@ -102,23 +143,45 @@ import {
102
143
  import { SESSION_STATUS_META, StatusDot } from "./session-status";
103
144
  import { TimelineComputeLabelProvider } from "../timeline/compute-label";
104
145
  import { EntranceAnimationProvider, useEntranceAnimation } from "../timeline/entrance";
146
+ import {
147
+ AllowanceExhaustedRow,
148
+ AllowancePresentationProvider,
149
+ type RenderAllowanceExhausted,
150
+ } from "../timeline/allowance-exhausted-row";
151
+ import type { AllowanceLabels } from "../usage/allowance-copy";
105
152
  import { SeenActivityIdsProvider } from "../timeline/seen-activity-ids";
106
- import { TimelineAnnotationsChip } from "./timeline-annotations";
153
+ import { TimelineAnnotationCards } from "./timeline-annotations";
107
154
  import { TooltipProvider } from "./tooltip";
108
155
 
109
156
  const TimelineAnnotationSelection = lazy(() => import("./timeline-annotation-selection"));
157
+ const TimelineAnnotationMarkers = lazy(() => import("./timeline-annotation-markers"));
110
158
 
111
159
  export type MessageTimelineProps = {
160
+ /** Exact durable search hit; clearing it removes highlighting without moving the reader. */
161
+ searchTarget?: TimelineSearchTarget | null | undefined;
162
+ /** Localized user-message disclosure actions, including custom UserMessageBody renderers. */
163
+ userMessageDisclosureLabels?: UserMessageDisclosureLabels | undefined;
112
164
  /** Raw session events (projected internally) … */
113
165
  events?: SessionEvent[] | undefined;
114
166
  /** … or pre-projected items (e.g. from `useSessionEvents().timeline`). */
115
167
  items?: TimelineItem[] | undefined;
116
168
  /** Current session status (reserved; tip "Working…" chrome removed for now). */
117
169
  status?: SessionStatus | null | undefined;
170
+ /** Host-owned controls beside Copy and the timestamp on settled message rows. */
171
+ renderMessageActions?: ((item: AgentMessageItem | UserMessageItem) => ReactNode) | undefined;
118
172
  /** Plug a markdown renderer for message bodies (e.g. streamdown). */
119
173
  renderMessageText?:
120
- | ((text: string, item: AgentMessageItem | UserMessageItem) => ReactNode)
174
+ | ((
175
+ text: string,
176
+ item: AgentMessageItem | UserMessageItem,
177
+ context: { searchTarget: TimelineSearchTarget | null },
178
+ ) => ReactNode)
121
179
  | undefined;
180
+ /**
181
+ * Render assistant `opengeni-html` / `opengeni-site` fences in the default
182
+ * message renderer (`SessionConversation` supplies the inline Site preview).
183
+ */
184
+ renderInteractiveBlock?: MarkdownProps["renderInteractiveBlock"];
122
185
  /** Drill into a spawned worker session. */
123
186
  onOpenSession?: ((sessionId: string) => void) | undefined;
124
187
  /**
@@ -138,6 +201,17 @@ export type MessageTimelineProps = {
138
201
  * a Reconnect button without a handler to run it.
139
202
  */
140
203
  onReconnect?: ((item: AuthNeededItem) => void | Promise<void>) | undefined;
204
+ /** Host-owned inline connection setup. Return undefined to use the default recovery card. */
205
+ renderAuthNeeded?: ((item: AuthNeededItem) => ReactNode | undefined) | undefined;
206
+ /**
207
+ * Replace the "usage limit reached" row shown when a workspace or member
208
+ * usage allowance stops work (an `allowance_exhausted` refusal). Receives the
209
+ * typed refusal (scope, reset time) and the default row; return undefined to
210
+ * keep the default. Use it to point people at your own plan or admin page.
211
+ */
212
+ renderAllowanceExhausted?: RenderAllowanceExhausted | undefined;
213
+ /** Replace the words of the default "usage limit reached" row. */
214
+ allowanceExhaustedLabels?: Partial<AllowanceLabels> | undefined;
141
215
  /**
142
216
  * Decide which durable authentication notices this timeline presents.
143
217
  * Defaults to showing every notice. Embedded hosts can suppress notices for
@@ -158,9 +232,19 @@ export type MessageTimelineProps = {
158
232
  * `createDefaultToolRegistry({ entries })` to add custom tool renderers.
159
233
  */
160
234
  toolRegistry?: ToolRegistry | undefined;
235
+ /**
236
+ * Open OpenGeni object links the agent writes in replies and progress notes:
237
+ * `artifact:<file>`, `sandbox:<path>`, editable artifacts, and Sites. Return
238
+ * a host URL (`{ href }`) or action (`{ open }`); unhandled targets render as
239
+ * unavailable text instead of a console link that 404s inside the host.
240
+ * Applies to the default message renderer and to any `Markdown` a custom
241
+ * `renderMessageText` renders. `SessionConversation` supplies file and
242
+ * sandbox downloads by default.
243
+ */
244
+ resolveLink?: OpenGeniLinkResolver | undefined;
161
245
  /** Resolve opaque retained screenshot receipts through the authenticated host SDK. */
162
246
  loadRetainedScreenshot?: RetainedScreenshotLoader | undefined;
163
- /** Resolve permanent generated-image receipts through the authenticated host SDK. */
247
+ /** Resolve permanent workspace image/file receipts through the authenticated host SDK. */
164
248
  loadRetainedArtifact?: RetainedArtifactLoader | undefined;
165
249
  /** Mint short-lived native playback sources for retained generated videos. */
166
250
  loadVideoArtifactPlayback?: VideoArtifactPlaybackLoader | undefined;
@@ -170,17 +254,32 @@ export type MessageTimelineProps = {
170
254
  */
171
255
  computeLabel?: string | null | undefined;
172
256
  /** Customize collapsed turn facets for this timeline instance. */
257
+ genieLoading?: GenieLoadingOptions | undefined;
173
258
  turnSummary?: TurnSummaryOptions | undefined;
174
259
  /** Follow new events when pinned to the bottom. Defaults to true. */
175
260
  autoFollow?: boolean | undefined;
176
261
  /** Capture a same-row text selection into the host's canonical composer draft. */
177
262
  onAnnotate?: ((annotation: DraftTimelineAnnotation) => void) | undefined;
263
+ /**
264
+ * Hide the floating "Back to your message" pill while the timeline viewport
265
+ * is shorter than this (px), e.g. a narrow embed above a decision card.
266
+ * Defaults to 0 (always available).
267
+ */
268
+ questionNavMinViewportHeight?: number | undefined;
269
+ /** Composer draft quotes currently attached to the next send. */
270
+ draftAnnotations?: readonly DraftTimelineAnnotation[] | undefined;
271
+ /** Open the composer review list for one numbered draft badge. */
272
+ onDraftAnnotationSelect?: ((id: string) => void) | undefined;
178
273
  /** Older durable history exists above the current window (see useSessionEvents). */
179
274
  hasOlder?: boolean | undefined;
180
275
  /** An older window is being fetched; shows the quiet top shimmer. */
181
276
  loadingOlder?: boolean | undefined;
182
- /** Called when the reader nears the top and older history should backfill. */
183
- onLoadOlder?: (() => void) | undefined;
277
+ /**
278
+ * Called when older history should backfill. Existing void, synchronous-value,
279
+ * and arbitrary-promise callbacks remain supported. Receipt-aware loaders
280
+ * preserve committed-page direction through wrappers and bounded windows.
281
+ */
282
+ onLoadOlder?: OlderHistoryLoader | undefined;
184
283
  /** Jump to the durable session start (bounded oldest window, no middle). */
185
284
  onJumpToStart?: (() => void | Promise<void>) | undefined;
186
285
  /** True while the oldest window is loading. */
@@ -189,13 +288,16 @@ export type MessageTimelineProps = {
189
288
  hasNewer?: boolean | undefined;
190
289
  /** A newer history page is being fetched. */
191
290
  loadingNewer?: boolean | undefined;
192
- /** Page forward through history without loading the whole gap to the tip. */
193
- onLoadNewer?: (() => void) | undefined;
291
+ /** Page forward through history. Return the request promise to enable inline error/retry. */
292
+ onLoadNewer?: (() => unknown) | undefined;
194
293
  /**
195
294
  * Reload the live tip window. When omitted, Jump to latest only re-pins and
196
295
  * scrolls the in-memory window.
197
296
  */
198
297
  onJumpToLatest?: (() => void | Promise<void>) | undefined;
298
+ /** @deprecated Retained for source compatibility; contextual prompt navigation
299
+ * uses only mounted prompts and never invokes this global resolver. */
300
+ onJumpToLatestQuestion?: (() => Promise<number | null>) | undefined;
199
301
  /** Host-owned content appended after timeline groups, such as startup progress. */
200
302
  trailingState?: ReactNode | undefined;
201
303
  emptyState?: ReactNode | undefined;
@@ -211,8 +313,9 @@ export type MessageTimelineProps = {
211
313
  *
212
314
  * Scroll invariant (tip-follow camera — see `./tip-follow.ts`):
213
315
  * - Load/remount: hidden until tip is hard-snapped across a short settle; then
214
- * reveal. Live tip: DOM grows immediately; the camera eases down (not
215
- * tip-glued feed-forward — that is the one-line yank).
316
+ * reveal. Live tip: DOM growth advances the pinned viewport by the same
317
+ * amount, so rendered content is visible immediately. Only debt that already
318
+ * existed before the growth goes through the camera ease.
216
319
  * - One continuous follow while hot (faster τ when behind); sleeps when cold.
217
320
  * - While pinned, tip-debt from growth/collapse must NEVER unpin — only
218
321
  * wheel/keys/pointer-armed scroll-up, or a settled scrollend away from the
@@ -226,6 +329,33 @@ export type MessageTimelineProps = {
226
329
  * alone is wrong. Late layout while unpinned stays browser-owned.
227
330
  */
228
331
  const PIN_THRESHOLD_PX = 48;
332
+ /**
333
+ * A pinned timeline can still have meaningful pre-existing debt while the
334
+ * tip-follow camera catches up. Surface the existing explicit jump once that
335
+ * debt is large enough to be useful, without flashing the control for ordinary
336
+ * line-sized streaming movement.
337
+ */
338
+ const JUMP_TO_LATEST_CATCHUP_DEBT_PX = 240;
339
+ /** Breathing room above the question when following stops at an answer. */
340
+ const QUESTION_NAV_MARGIN_PX = 12;
341
+ /**
342
+ * Floating timeline navigation: two identical small round arrow buttons at fixed
343
+ * spots, centered on the conversation. Back to your message floats 12px below
344
+ * the top edge, or just below an expanded work-header strip while that strip is
345
+ * pinned (32px, 44px on coarse pointers) so the strip stays a full-width
346
+ * target. Jump to latest floats 12px above the bottom edge. They never dodge
347
+ * the content beneath them (floating over a sliver of text is fine), so they
348
+ * cannot wander as rows stream in or the host resizes. Coarse pointers get a
349
+ * larger invisible hit area instead of a larger button.
350
+ */
351
+ const NAV_BUTTON_CLASS =
352
+ "pointer-events-auto relative inline-flex size-8 items-center justify-center rounded-full border border-og-border bg-og-surface-3/90 text-og-fg-muted shadow-og-md backdrop-blur hover:border-og-border-strong hover:text-og-fg focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-og-accent pointer-coarse:before:absolute pointer-coarse:before:-inset-1.5 pointer-coarse:before:content-['']";
353
+ const NAV_FADE = {
354
+ initial: { opacity: 0 },
355
+ animate: { opacity: 1 },
356
+ exit: { opacity: 0 },
357
+ transition: { duration: 0.15, ease: "easeOut" },
358
+ } as const;
229
359
  /**
230
360
  * Prefetch older history when the top sentinel is this far from the viewport.
231
361
  * After a page loads we stay cool until the reader leaves this band (scrolls
@@ -233,6 +363,68 @@ const PIN_THRESHOLD_PX = 48;
233
363
  */
234
364
  const OLDER_PREFETCH_MARGIN_PX = 400;
235
365
  const OLDER_PREFETCH_ROOT_MARGIN = `${OLDER_PREFETCH_MARGIN_PX}px 0px 0px 0px`;
366
+ const PRIMARY_ACTION_CLASS =
367
+ "inline-flex w-full shrink-0 items-center justify-center gap-1.5 rounded-og-md border border-og-primary-border bg-og-primary text-og-primary-fg px-3 py-1.5 text-og-menu font-medium sm:w-auto";
368
+ const MESSAGE_BUBBLE_CLASS =
369
+ "w-fit max-w-full min-w-0 rounded-og-lg rounded-br-og-xs border border-og-border bg-og-surface-2 px-4 py-2.5 text-og-md leading-6 text-og-fg";
370
+ const WAITING_PILL_CLASS =
371
+ "border-og-status-waiting/35 bg-og-status-waiting/10 text-og-status-waiting";
372
+ const LOADING_CHIP_CLASS =
373
+ "pointer-events-none inline-flex items-center rounded-full border border-og-border bg-og-surface-3/90 px-3 py-1 text-og-control font-medium shadow-og-md backdrop-blur";
374
+
375
+ // State: 0 underfill retry, 1 prefetch pending, 2 compatibility-settled,
376
+ // 3 explicit no-progress waiting to become an underfill retry.
377
+ // The tuple identity is the exact request ownership fence. The boundary may rebase
378
+ // forward while that request is pending when a bounded live-tail append evicts
379
+ // its former oldest row. First-party loaders mark the exact owner when their
380
+ // older page commits; retained direction is the fallback for other hosts.
381
+ type OlderLoadAttempt = [
382
+ boundary: string | undefined,
383
+ state?: number,
384
+ receipt?: OlderHistoryLoadReceipt,
385
+ ];
386
+
387
+ function invokeOlderLoad(
388
+ load: OlderHistoryLoader,
389
+ noProgress: () => void,
390
+ attempt: OlderLoadAttempt,
391
+ preserveTail = false,
392
+ ): 1 | undefined {
393
+ try {
394
+ // Receipt creation is captured synchronously through legacy wrappers such
395
+ // as `() => void loadOlder()`, even when the wrapper discards the return.
396
+ const result = invokeOlderHistoryLoaderWithReceiptCapture(
397
+ load,
398
+ (receipt) => {
399
+ attempt[2] = receipt;
400
+ },
401
+ preserveTail,
402
+ ) as OlderHistoryLoadReceipt | PromiseLike<unknown> | unknown;
403
+ const receipt =
404
+ attempt[2] ??
405
+ (typeof (result as { committed?: unknown } | undefined)?.committed === "boolean"
406
+ ? (result as OlderHistoryLoadReceipt)
407
+ : undefined);
408
+ if (receipt) {
409
+ attempt[2] = receipt;
410
+ void receipt.then(
411
+ (value) => value === false && !receipt.committed && noProgress(),
412
+ noProgress,
413
+ );
414
+ return 1;
415
+ }
416
+ if (typeof (result as PromiseLike<unknown> | undefined)?.then != "function") {
417
+ return;
418
+ }
419
+ void (result as PromiseLike<unknown>).then(
420
+ (value) => value === false && noProgress(),
421
+ noProgress,
422
+ );
423
+ } catch {
424
+ noProgress();
425
+ }
426
+ return 1;
427
+ }
236
428
 
237
429
  /**
238
430
  * Pinned = the viewport bottom is within PIN_THRESHOLD_PX of the content
@@ -243,7 +435,8 @@ const OLDER_PREFETCH_ROOT_MARGIN = `${OLDER_PREFETCH_MARGIN_PX}px 0px 0px 0px`;
243
435
  * cannot scroll at all is always pinned.
244
436
  */
245
437
  function maxScrollOf(node: HTMLElement): number {
246
- return Math.max(0, node.scrollHeight - node.clientHeight);
438
+ // Browser layout guarantees scrollHeight is at least clientHeight.
439
+ return node.scrollHeight - node.clientHeight;
247
440
  }
248
441
 
249
442
  /**
@@ -286,6 +479,58 @@ function isNearBottom(node: HTMLElement): boolean {
286
479
  return gap < Math.min(PIN_THRESHOLD_PX, maxScroll);
287
480
  }
288
481
 
482
+ /** Content-space top of a timeline group, or null when it is not mounted. */
483
+ function contentTopOf(node: HTMLElement, groupKey: string): number | null {
484
+ const element = node.querySelector(`[data-og-group-key="${cssEscapeAttribute(groupKey)}"]`);
485
+ if (!(element instanceof HTMLElement)) {
486
+ return null;
487
+ }
488
+ return element.getBoundingClientRect().top - node.getBoundingClientRect().top + node.scrollTop;
489
+ }
490
+
491
+ /** The question the reader is inside of, once its start has scrolled out of view. */
492
+ /**
493
+ * `belowHeader`: an expanded work header is pinned to the top of the scrollport,
494
+ * so the up arrow sits just below that strip instead of covering it.
495
+ */
496
+ type QuestionNav = { key: string; belowHeader: boolean };
497
+
498
+ /** How far a question's start must be above the viewport before navigation shows. */
499
+ const QUESTION_NAV_HIDDEN_PX = 24;
500
+
501
+ function readQuestionNav(node: HTMLElement): QuestionNav | null {
502
+ const view = node.getBoundingClientRect();
503
+ const prompts = [...node.querySelectorAll<HTMLElement>("[data-og-prompt]")];
504
+ // The last prompt before the reading position owns the response/work in view.
505
+ // A newer prompt farther down the conversation is not a navigation target.
506
+ const middle = view.top + view.height / 2;
507
+ const prompt = prompts
508
+ .reverse()
509
+ .find((candidate) => candidate.getBoundingClientRect().top <= middle);
510
+ const key = prompt?.dataset.ogGroupKey;
511
+ if (!prompt || !key) return null;
512
+ const bounds = prompt.getBoundingClientRect();
513
+ if (bounds.top >= view.top - QUESTION_NAV_HIDDEN_PX || bounds.bottom > view.top) {
514
+ return null;
515
+ }
516
+ return { key, belowHeader: workHeaderPinned(node, view) };
517
+ }
518
+
519
+ /** True while an expanded outer work header is stuck to the scrollport's top edge. */
520
+ function workHeaderPinned(node: HTMLElement, view: DOMRect): boolean {
521
+ for (const header of node.querySelectorAll<HTMLElement>(
522
+ '[data-og-work-header="outer"][data-state="open"]',
523
+ )) {
524
+ const box = header.getBoundingClientRect();
525
+ if (Math.abs(box.top - view.top) <= 1 && box.bottom > view.top) return true;
526
+ }
527
+ return false;
528
+ }
529
+
530
+ function sameQuestionNav(a: QuestionNav | null, b: QuestionNav | null): boolean {
531
+ return a === b || (!!a && !!b && a.key === b.key && a.belowHeader === b.belowHeader);
532
+ }
533
+
289
534
  /** Escape a value for use inside a CSS attribute selector. */
290
535
  function cssEscapeAttribute(value: string): string {
291
536
  if (typeof CSS !== "undefined" && typeof CSS.escape === "function") {
@@ -301,13 +546,21 @@ function cssEscapeAttribute(value: string): string {
301
546
  * with a "jump to latest" affordance when the reader scrolls back.
302
547
  */
303
548
  export function MessageTimeline({
549
+ resolveLink,
550
+ renderInteractiveBlock,
551
+ userMessageDisclosureLabels,
552
+ searchTarget,
304
553
  events,
305
554
  items,
306
555
  status: _status,
556
+ renderMessageActions,
307
557
  renderMessageText,
308
558
  onOpenSession,
309
559
  onMemoryClick,
310
560
  onReconnect,
561
+ renderAuthNeeded,
562
+ renderAllowanceExhausted,
563
+ allowanceExhaustedLabels,
311
564
  shouldRenderAuthNeeded,
312
565
  resolveProviderLogo,
313
566
  toolRegistry = defaultToolRegistry,
@@ -315,9 +568,13 @@ export function MessageTimeline({
315
568
  loadRetainedArtifact,
316
569
  loadVideoArtifactPlayback,
317
570
  computeLabel = null,
571
+ genieLoading,
318
572
  turnSummary,
319
573
  autoFollow = true,
320
574
  onAnnotate,
575
+ questionNavMinViewportHeight = 0,
576
+ draftAnnotations,
577
+ onDraftAnnotationSelect,
321
578
  hasOlder = false,
322
579
  loadingOlder = false,
323
580
  onLoadOlder,
@@ -331,14 +588,143 @@ export function MessageTimeline({
331
588
  emptyState,
332
589
  className,
333
590
  }: MessageTimelineProps) {
591
+ const formatError = useErrorMessage();
334
592
  const resolvedItems = useMemo(() => {
335
- const projected = items ?? buildTimeline(events ?? []);
593
+ const projectedItems = items ?? buildTimeline(events ?? []);
336
594
  if (!shouldRenderAuthNeeded) {
337
- return projected;
595
+ return projectedItems;
338
596
  }
339
- return projected.filter((item) => item.kind !== "auth-needed" || shouldRenderAuthNeeded(item));
597
+ return projectedItems.filter(
598
+ (item) => item.kind !== "auth-needed" || shouldRenderAuthNeeded(item),
599
+ );
340
600
  }, [items, events, shouldRenderAuthNeeded]);
341
- const allGroups = useMemo(() => groupTimeline(resolvedItems), [resolvedItems]);
601
+ const searchItem = searchTarget
602
+ ? resolvedItems.find((item) => isTimelineSearchTarget(item, searchTarget))
603
+ : undefined;
604
+ const searchRevealKey =
605
+ searchItem && searchTarget
606
+ ? JSON.stringify([
607
+ searchItem.id,
608
+ searchTarget.query,
609
+ searchTarget.occurrence ?? 0,
610
+ searchTarget.offset ?? null,
611
+ ])
612
+ : null;
613
+ const searchTargetRef = useRef(searchTarget);
614
+ searchTargetRef.current = searchTarget;
615
+ const renderSearchableMessageText = useMemo(
616
+ () =>
617
+ renderMessageText
618
+ ? (text: string, item: AgentMessageItem | UserMessageItem) =>
619
+ renderMessageText(text, item, {
620
+ searchTarget: searchItem?.id === item.id ? (searchTarget ?? null) : null,
621
+ })
622
+ : (text: string, item: AgentMessageItem | UserMessageItem) => {
623
+ const body = (
624
+ <Markdown
625
+ searchTarget={searchItem?.id === item.id ? searchTarget : null}
626
+ streaming={item.kind === "agent-message" && item.streaming}
627
+ renderInteractiveBlock={
628
+ item.kind === "agent-message" ? renderInteractiveBlock : undefined
629
+ }
630
+ >
631
+ {text}
632
+ </Markdown>
633
+ );
634
+ return item.kind === "user-message" ? (
635
+ <UserMessageBody messageId={item.id} text={text}>
636
+ {body}
637
+ </UserMessageBody>
638
+ ) : (
639
+ body
640
+ );
641
+ },
642
+ [renderInteractiveBlock, renderMessageText, searchItem?.id, searchTarget],
643
+ );
644
+ // Event-window identity is independent of projected rows (partial messages
645
+ // can acquire a different first-delta id when older text arrives).
646
+ const sourceItems = events ?? items;
647
+ const olderBoundaryKey = sourceItems?.[0]?.id;
648
+ const newerBoundaryKey = `${events?.[0]?.sessionId ?? ""}:${olderBoundaryKey ?? ""}`;
649
+ const newerScopeRef = useRef(newerBoundaryKey);
650
+ newerScopeRef.current = newerBoundaryKey;
651
+ const newerAttemptRef = useRef<{ pending: boolean } | null>(null);
652
+ const newerRetryButtonRef = useRef<HTMLButtonElement | null>(null);
653
+ const [newerRetryPending, setNewerRetryPending] = useState(false);
654
+ const [newerFailure, setNewerFailure] = useState<{ key: string; message: string } | null>(null);
655
+ useEffect(() => {
656
+ newerScopeRef.current = newerBoundaryKey;
657
+ newerAttemptRef.current = null;
658
+ setNewerFailure(null);
659
+ setNewerRetryPending(false);
660
+ return () => {
661
+ newerScopeRef.current = "";
662
+ newerAttemptRef.current = null;
663
+ };
664
+ }, [newerBoundaryKey]);
665
+ const requestNewer = useCallback(
666
+ (explicitRetry = false) => {
667
+ if (
668
+ newerScopeRef.current !== newerBoundaryKey ||
669
+ !onLoadNewer ||
670
+ loadingNewer ||
671
+ (newerAttemptRef.current && (!explicitRetry || newerAttemptRef.current.pending))
672
+ ) {
673
+ return;
674
+ }
675
+ const attempt = { pending: true };
676
+ newerAttemptRef.current = attempt;
677
+ if (explicitRetry) setNewerRetryPending(true);
678
+ const isCurrent = () =>
679
+ newerAttemptRef.current === attempt && newerScopeRef.current === newerBoundaryKey;
680
+ // Both synchronous host errors and rejected promises belong to this
681
+ // boundary. Retain the failed attempt so observers cannot hot-retry it.
682
+ void Promise.resolve()
683
+ .then(() => (isCurrent() ? onLoadNewer() : undefined))
684
+ .then(
685
+ () => {
686
+ if (!isCurrent()) return;
687
+ newerAttemptRef.current = null;
688
+ // The successful page removes the recovery control. Return focus
689
+ // to the reading surface without moving the reader's viewport.
690
+ if (document.activeElement === newerRetryButtonRef.current) {
691
+ scrollRef.current?.focus({ preventScroll: true });
692
+ }
693
+ setNewerFailure(null);
694
+ setNewerRetryPending(false);
695
+ },
696
+ (reason: unknown) => {
697
+ if (!isCurrent()) return;
698
+ attempt.pending = false;
699
+ setNewerRetryPending(false);
700
+ setNewerFailure({
701
+ key: newerBoundaryKey,
702
+ message: formatError(reason),
703
+ });
704
+ },
705
+ );
706
+ },
707
+ [onLoadNewer, loadingNewer, newerBoundaryKey, formatError],
708
+ );
709
+ const previousSourceIdsRef = useRef(new Set<string>());
710
+ const previousSourceBoundaryRef = useRef<string | undefined>(undefined);
711
+ const readingAnchorRef = useRef<TimelineAnchor | null>(null);
712
+ const settlementRef = useRef<TimelineSettlement | null>(null);
713
+ const settlementAnimationRef = useRef<Animation | null>(null);
714
+ const stopSettlement = useCallback(() => {
715
+ settlementAnimationRef.current?.cancel();
716
+ settlementAnimationRef.current = null;
717
+ }, []);
718
+ const olderPageBudgetRef = useRef(0);
719
+ const [olderDemand, setOlderDemand] = useState(0);
720
+ const readableTurns = turnSummary?.rolling === true;
721
+ const projectedGroups = useMemo(
722
+ () => groupTimeline(resolvedItems, { readableTurns }),
723
+ [resolvedItems, readableTurns],
724
+ );
725
+ // A new timeline revision (including session/turn replacement) owns layout.
726
+ // Never leave an old decorative transform running on its retained rows.
727
+ useLayoutEffect(() => stopSettlement, [projectedGroups, stopSettlement]);
342
728
  const annotationSources = useMemo(() => {
343
729
  const sources = new Map<string, TimelineAnnotationSourceDescriptor>();
344
730
  for (const item of resolvedItems) {
@@ -353,29 +739,56 @@ export function MessageTimeline({
353
739
  }
354
740
  return sources;
355
741
  }, [resolvedItems]);
356
- const groups = useStableTimelineGroupKeys(allGroups);
357
742
  const scrollRef = useRef<HTMLDivElement | null>(null);
358
743
  const topSentinelRef = useRef<HTMLDivElement | null>(null);
359
744
  const bottomSentinelRef = useRef<HTMLDivElement | null>(null);
360
745
  const previousBulkFirstKeyRef = useRef<string | null | undefined>(undefined);
361
746
  const [pinned, setPinned] = useState(true);
747
+ const { groups: allGroups, release: releaseProgress } = useReadingProgress(
748
+ projectedGroups,
749
+ autoFollow && pinned && !hasNewer,
750
+ scrollRef.current,
751
+ );
752
+ const [canSkipTipCatchup, setCanSkipTipCatchup] = useState(false);
753
+ const canSkipTipCatchupRef = useRef(false);
362
754
  const [bulkActive, setBulkActive] = useState(true);
363
755
  // Older history prefetch is user-driven: a window shorter than the viewport
364
756
  // + rootMargin would otherwise keep the top sentinel intersecting and fetch
365
757
  // history forever while the reader sits at the tip. Arm on first scroll-up.
366
758
  const olderPrefetchArmedRef = useRef(false);
367
759
  const [olderPrefetchArmed, setOlderPrefetchArmed] = useState(false);
368
- // One loadOlder per visit to the top band. Re-arm only after the reader
369
- // leaves that band (scrolls down / sentinel exits) — never from scrolling
370
- // further toward y=0 (that was the batch-top load loop).
371
- const olderLoadGateRef = useRef<"armed" | "cooling">("armed");
760
+ // A collapsed history tail can be shorter than the viewport. In that state
761
+ // there is no upward scroll range, so reader intent can never arm the top
762
+ // sentinel. Request one older page per loaded window until history either
763
+ // fills the viewport or the host reports that no older rows remain. Each
764
+ // attempt owns the oldest loaded item boundary across BOTH the automatic
765
+ // underfill and reader-driven sentinel paths. Live-tail appends do not
766
+ // advance older pagination or release that exact owner.
767
+ const olderLoadAttemptRef = useRef<OlderLoadAttempt | null>(null);
768
+ const [underfillSettledAttempt, setUnderfillSettledAttempt] = useState<OlderLoadAttempt | null>(
769
+ null,
770
+ );
771
+ const underfillRetryReadyRef = useRef(false);
372
772
  const resizeFollowRafRef = useRef<number | null>(null);
773
+ const questionNavFrameRef = useRef<number | null>(null);
774
+ const [questionNav, setQuestionNav] = useState<QuestionNav | null>(null);
775
+ const questionNavMinViewportRef = useRef(questionNavMinViewportHeight);
776
+ questionNavMinViewportRef.current = questionNavMinViewportHeight;
777
+ const scheduleQuestionNavRef = useRef<() => void>(() => {});
778
+ // Unpinned is not the same as away from the tip: focusing a control in the
779
+ // conversation (Copy, a connection card) hands the view to the reader while
780
+ // they are still at the bottom. Offer the jump only once there is something
781
+ // below them, or a newer window / catch-up to skip.
782
+ const [awayFromTip, setAwayFromTip] = useState(false);
783
+ const jumpPillShown = autoFollow && ((!pinned && awayFromTip) || hasNewer || canSkipTipCatchup);
373
784
  const firstGroupKey = allGroups[0] ? timelineGroupKey(allGroups[0]) : null;
374
785
  // Content stays invisible until the tip is hard-parked across a short
375
786
  // post-commit settle (two rAFs). That absorbs sync late layout while hidden
376
787
  // so load/remount does not ease into the tip — live tip-follow is unchanged
377
788
  // once revealed. A flash of the window's TOP is still structurally impossible.
378
- const [revealed, setRevealed] = useState(false);
789
+ // The accepted-create handoff uses the reserved local id "c" so its known
790
+ // first message is visible immediately; loaded histories still park first.
791
+ const [revealed, setRevealed] = useState(resolvedItems[0]?.id === "c");
379
792
  // Mirror `pinned` into a ref, written ONLY by applyPinned, so the
380
793
  // ResizeObserver rAF (a stable closure) reads the live value and a snap can
381
794
  // never race a just-unpinned reader across a pending React commit.
@@ -414,6 +827,8 @@ export function MessageTimeline({
414
827
  * jumps (Vimium) settle via scrollend while the camera is idle.
415
828
  */
416
829
  const readerIntentArmRef = useRef(false);
830
+ /** Gesture-start geometry survives commits before the native scroll event. */
831
+ const readerIntentStartRef = useRef<{ scrollTop: number; maxScroll: number } | null>(null);
417
832
  /**
418
833
  * Count of camera/snap scrollTop writes whose scroll echoes are not yet
419
834
  * consumed. A boolean was wrong when the browser coalesced two writes into
@@ -440,23 +855,72 @@ export function MessageTimeline({
440
855
  const foldMemoryRef = useRef<Map<string, FoldRestingState>>(new Map());
441
856
  const userMessageDisclosureMemoryRef = useRef<Map<string, boolean>>(new Map());
442
857
  const seenActivityIdsRef = useRef<Set<string>>(new Set());
443
- const groupKeyByItemIdRef = useRef<Map<string, string>>(new Map());
444
- const groupOffsetByKeyRef = useRef<Map<string, number>>(new Map());
858
+ const firstItemGroupKeyRef = useRef<string | null>(null);
859
+ const firstItemGroupOffsetTopRef = useRef<number | null>(null);
860
+ const firstItemContentTopRef = useRef<number | null>(null);
445
861
  const firstItemId = resolvedItems[0]?.id ?? null;
862
+ // Pagination ownership follows the oldest committed input, before host
863
+ // filtering and grouping. A page containing only suppressed auth notices
864
+ // still advances this receipt, while live-tail appends leave it unchanged.
865
+ // Promise fulfillment alone does neither, so delayed prepends stay fenced.
866
+ const underfillRetryReady = (underfillRetryReadyRef.current = !!(
867
+ underfillSettledAttempt &&
868
+ underfillSettledAttempt === olderLoadAttemptRef.current &&
869
+ hasOlder &&
870
+ onLoadOlder &&
871
+ !loadingOlder
872
+ ));
446
873
  // Bulk paints (the initial tail window, a prepended older window — detected
447
874
  // by the first group key changing) must not run per-row entrance animations.
448
875
  const firstKeyChangedForBulk =
449
876
  previousBulkFirstKeyRef.current !== undefined &&
450
877
  previousBulkFirstKeyRef.current !== firstGroupKey;
451
878
  const bulkRender = allGroups.length > 0 && (bulkActive || firstKeyChangedForBulk);
879
+ const groups = useStableTimelineGroupKeys(allGroups, !bulkRender);
880
+ const turnsWithOutput = useMemo(
881
+ () =>
882
+ new Set(
883
+ resolvedItems.flatMap((item) =>
884
+ "turnId" in item &&
885
+ item.turnId &&
886
+ (item.kind === "tool-call" ||
887
+ ((item.kind === "agent-message" || item.kind === "reasoning") && item.text.trim()))
888
+ ? [item.turnId]
889
+ : [],
890
+ ),
891
+ ),
892
+ [resolvedItems],
893
+ );
894
+
895
+ const applyCanSkipTipCatchup = useCallback((value: boolean) => {
896
+ if (canSkipTipCatchupRef.current !== value) {
897
+ canSkipTipCatchupRef.current = value;
898
+ setCanSkipTipCatchup(value);
899
+ }
900
+ }, []);
452
901
 
453
902
  // The ONLY writer of the pinned flag. Ref and state move together, so
454
903
  // behavior (refs read by rAF callbacks) and rendering (the anchor class,
455
904
  // the Jump-to-latest button) can never desync.
456
- const applyPinned = useCallback((value: boolean) => {
457
- if (pinnedRef.current !== value) {
458
- pinnedRef.current = value;
459
- setPinned(value);
905
+ const applyPinned = useCallback(
906
+ (value: boolean) => {
907
+ if (pinnedRef.current !== value) {
908
+ pinnedRef.current = value;
909
+ setPinned(value);
910
+ }
911
+ if (!value) {
912
+ applyCanSkipTipCatchup(false);
913
+ }
914
+ },
915
+ [applyCanSkipTipCatchup],
916
+ );
917
+
918
+ const rearmOlderPrefetchAfterLeavingTop = useCallback((node: HTMLElement) => {
919
+ if (node.scrollTop > OLDER_PREFETCH_MARGIN_PX) {
920
+ const state = olderLoadAttemptRef.current?.[1];
921
+ if (state === 2 || state === 3) {
922
+ olderLoadAttemptRef.current = null;
923
+ }
460
924
  }
461
925
  }, []);
462
926
 
@@ -466,6 +930,7 @@ export function MessageTimeline({
466
930
  // Pure tip-follow camera. Pin intent uses clamp conservation, not timers.
467
931
  const followRef = useRef<TipFollowState>(createTipFollowState());
468
932
  const followFrameRef = useRef<number | null>(null);
933
+ const contentShrinkBaselineRef = useRef<TipFollowContentShrinkBaseline | null>(null);
469
934
 
470
935
  const syncScrollBaseline = useCallback((node: HTMLElement) => {
471
936
  lastScrollTopRef.current = node.scrollTop;
@@ -480,6 +945,9 @@ export function MessageTimeline({
480
945
  if (before === next) {
481
946
  return;
482
947
  }
948
+ // Unpinned camera/anchor writes invalidate return intent. A pinned
949
+ // pointer gesture still needs its cumulative leave budget across writes.
950
+ if (!pinnedRef.current || !readerIntentArmRef.current) readerIntentStartRef.current = null;
483
951
  programmaticScrollRef.current += 1;
484
952
  node.scrollTop = next;
485
953
  if (node.scrollTop === before) {
@@ -491,7 +959,7 @@ export function MessageTimeline({
491
959
  }, []);
492
960
 
493
961
  const cancelLeaveFallback = useCallback(() => {
494
- if (leaveFallbackRafRef.current !== null) {
962
+ if (leaveFallbackRafRef.current != null) {
495
963
  cancelFrame(leaveFallbackRafRef.current);
496
964
  leaveFallbackRafRef.current = null;
497
965
  }
@@ -502,38 +970,58 @@ export function MessageTimeline({
502
970
  cancelLeaveFallback();
503
971
  }, [cancelLeaveFallback]);
504
972
 
973
+ const clearReaderIntent = useCallback(() => {
974
+ readerIntentArmRef.current = false;
975
+ readerIntentStartRef.current = null;
976
+ }, []);
977
+
505
978
  const stopFollow = useCallback(() => {
979
+ contentShrinkBaselineRef.current = null;
506
980
  followRef.current = tipFollowCancel(followRef.current);
507
- if (followFrameRef.current !== null) {
981
+ if (followFrameRef.current != null) {
508
982
  cancelFrame(followFrameRef.current);
509
983
  followFrameRef.current = null;
510
984
  }
511
985
  }, []);
512
986
 
513
987
  /** Reader left the tip — wheel, keyboard, pointer-armed scroll-up, or scrollend. */
514
- const releasePinFromReader = useCallback(
515
- (node?: HTMLElement | null) => {
516
- if (!autoFollow || !pinnedRef.current || hasNewerRef.current) {
517
- return;
518
- }
519
- // Unscrollable window: unpin strands Jump-to-latest with no way back.
520
- if (node && maxScrollOf(node) <= 1) {
521
- return;
522
- }
523
- readerIntentArmRef.current = false;
524
- clearPendingReaderLeave();
525
- stopFollow();
526
- applyPinned(false);
527
- if (wantPinRef.current) {
528
- wantPinRef.current = false;
529
- }
530
- if (!olderPrefetchArmedRef.current) {
531
- olderPrefetchArmedRef.current = true;
532
- setOlderPrefetchArmed(true);
988
+ const releasePinFromReader = useCallback(() => {
989
+ if (!autoFollow || !pinnedRef.current || hasNewerRef.current) {
990
+ return;
991
+ }
992
+ clearReaderIntent();
993
+ clearPendingReaderLeave();
994
+ stopFollow();
995
+ applyPinned(false);
996
+ if (wantPinRef.current) {
997
+ wantPinRef.current = false;
998
+ }
999
+ if (!olderPrefetchArmedRef.current) {
1000
+ olderPrefetchArmedRef.current = true;
1001
+ setOlderPrefetchArmed(true);
1002
+ }
1003
+ }, [autoFollow, applyPinned, clearPendingReaderLeave, clearReaderIntent, stopFollow]);
1004
+
1005
+ useEffect(() => {
1006
+ if (!readableTurns) return;
1007
+ const node = scrollRef.current;
1008
+ if (!node) return;
1009
+ const onSelection = () => {
1010
+ const selection = node.ownerDocument.getSelection();
1011
+ if (
1012
+ selection &&
1013
+ !selection.isCollapsed &&
1014
+ node.contains(selection.anchorNode) &&
1015
+ node.contains(selection.focusNode)
1016
+ ) {
1017
+ stopSettlement();
1018
+ releasePinFromReader();
1019
+ disclosureKeepsUnpinnedRef.current = true;
533
1020
  }
534
- },
535
- [autoFollow, applyPinned, clearPendingReaderLeave, stopFollow],
536
- );
1021
+ };
1022
+ node.ownerDocument.addEventListener("selectionchange", onSelection);
1023
+ return () => node.ownerDocument.removeEventListener("selectionchange", onSelection);
1024
+ }, [readableTurns, releasePinFromReader, stopSettlement]);
537
1025
 
538
1026
  /**
539
1027
  * Settled away from the tip while the camera is idle — Vimium / unfocused
@@ -547,19 +1035,17 @@ export function MessageTimeline({
547
1035
  if (programmaticScrollRef.current > 0) {
548
1036
  return;
549
1037
  }
550
- if (followRef.current.running || followFrameRef.current !== null) {
1038
+ if (followRef.current.running || followFrameRef.current != null) {
551
1039
  return;
552
1040
  }
553
1041
  if (isNearBottom(node) || maxScrollOf(node) <= 1) {
554
1042
  clearPendingReaderLeave();
555
1043
  return;
556
1044
  }
557
- releasePinFromReader(node);
558
- if (olderLoadGateRef.current === "cooling" && node.scrollTop > OLDER_PREFETCH_MARGIN_PX) {
559
- olderLoadGateRef.current = "armed";
560
- }
1045
+ releasePinFromReader();
1046
+ rearmOlderPrefetchAfterLeavingTop(node);
561
1047
  },
562
- [autoFollow, clearPendingReaderLeave, releasePinFromReader],
1048
+ [autoFollow, clearPendingReaderLeave, rearmOlderPrefetchAfterLeavingTop, releasePinFromReader],
563
1049
  );
564
1050
 
565
1051
  const scheduleLeaveFallback = useCallback(() => {
@@ -577,6 +1063,22 @@ export function MessageTimeline({
577
1063
  });
578
1064
  }, [cancelLeaveFallback, releasePinAfterScrollSettled]);
579
1065
 
1066
+ const requestEarlierFromReader = () => {
1067
+ // Upward navigation replaces any earlier downward gesture, even when
1068
+ // already unpinned and the engine never delivered scrollend.
1069
+ clearReaderIntent();
1070
+ releasePinFromReader();
1071
+ wantPinRef.current = false;
1072
+ // A stationary upward gesture is still demand. Successful short/folded
1073
+ // pages may never create enough range to leave the prefetch band.
1074
+ olderPageBudgetRef.current = 8;
1075
+ if (olderLoadAttemptRef.current?.[1] === 2) {
1076
+ olderLoadAttemptRef.current = null;
1077
+ setOlderDemand((value) => value + 1);
1078
+ }
1079
+ };
1080
+ const touchPositionRef = useRef<{ x: number; y: number } | null>(null);
1081
+
580
1082
  const onWheel = (event: {
581
1083
  deltaY: number;
582
1084
  deltaX: number;
@@ -593,13 +1095,17 @@ export function MessageTimeline({
593
1095
  if (wheelConsumedByNestedScrollable(event)) {
594
1096
  return;
595
1097
  }
1098
+ stopSettlement();
596
1099
  disclosureKeepsUnpinnedRef.current = false;
1100
+ programmaticScrollRef.current = 0;
597
1101
  if (event.deltaY >= 0) {
1102
+ const node = scrollRef.current;
1103
+ readerIntentStartRef.current = node
1104
+ ? { scrollTop: node.scrollTop, maxScroll: maxScrollOf(node) }
1105
+ : null;
598
1106
  return;
599
1107
  }
600
- const node =
601
- event.currentTarget instanceof HTMLElement ? event.currentTarget : scrollRef.current;
602
- releasePinFromReader(node);
1108
+ requestEarlierFromReader();
603
1109
  };
604
1110
 
605
1111
  /** Touch / stylus / mouse drag on the scroller — explicit leave (not layout). */
@@ -607,11 +1113,13 @@ export function MessageTimeline({
607
1113
  button: number;
608
1114
  pointerType: string;
609
1115
  target: EventTarget | null;
1116
+ currentTarget: EventTarget | null;
610
1117
  }) => {
611
1118
  // Primary button / touch / pen only. Ignore right-click etc.
612
- if (event.button !== 0 && event.pointerType === "mouse") {
1119
+ if (event.button && event.pointerType === "mouse") {
613
1120
  return;
614
1121
  }
1122
+ stopSettlement();
615
1123
  // Clicks on chips/buttons/links must not arm — their settle collapse
616
1124
  // also drops scrollTop and would false-unpin. Drag on prose/scroller may.
617
1125
  if (
@@ -621,7 +1129,13 @@ export function MessageTimeline({
621
1129
  return;
622
1130
  }
623
1131
  disclosureKeepsUnpinnedRef.current = false;
1132
+ programmaticScrollRef.current = 0;
1133
+ const node =
1134
+ event.currentTarget instanceof HTMLElement ? event.currentTarget : scrollRef.current;
624
1135
  readerIntentArmRef.current = true;
1136
+ readerIntentStartRef.current = node
1137
+ ? { scrollTop: node.scrollTop, maxScroll: maxScrollOf(node) }
1138
+ : null;
625
1139
  };
626
1140
 
627
1141
  const onKeyDown = (event: { key: string; currentTarget: EventTarget | null }) => {
@@ -633,18 +1147,25 @@ export function MessageTimeline({
633
1147
  event.key === "Home" ||
634
1148
  event.key === "End"
635
1149
  ) {
1150
+ stopSettlement();
636
1151
  disclosureKeepsUnpinnedRef.current = false;
1152
+ // Reader navigation supersedes a pending camera/anchor scroll echo,
1153
+ // including downward keys that do not explicitly release the pin.
1154
+ programmaticScrollRef.current = 0;
1155
+ const node = scrollRef.current;
1156
+ readerIntentStartRef.current = node
1157
+ ? { scrollTop: node.scrollTop, maxScroll: maxScrollOf(node) }
1158
+ : null;
637
1159
  }
638
1160
  if (event.key !== "ArrowUp" && event.key !== "PageUp" && event.key !== "Home") {
639
1161
  return;
640
1162
  }
641
- const node =
642
- event.currentTarget instanceof HTMLElement ? event.currentTarget : scrollRef.current;
643
- releasePinFromReader(node);
1163
+ requestEarlierFromReader();
644
1164
  };
645
1165
 
646
1166
  const snapToBottom = useCallback(
647
1167
  (node: HTMLElement) => {
1168
+ clearReaderIntent();
648
1169
  stopFollow();
649
1170
  cancelLeaveFallback();
650
1171
  writeScrollTop(node, Math.max(0, node.scrollHeight - node.clientHeight));
@@ -655,8 +1176,16 @@ export function MessageTimeline({
655
1176
  lastClientHeight: node.clientHeight,
656
1177
  cameraTop: null,
657
1178
  };
1179
+ applyCanSkipTipCatchup(false);
658
1180
  },
659
- [cancelLeaveFallback, stopFollow, syncScrollBaseline, writeScrollTop],
1181
+ [
1182
+ applyCanSkipTipCatchup,
1183
+ cancelLeaveFallback,
1184
+ clearReaderIntent,
1185
+ stopFollow,
1186
+ syncScrollBaseline,
1187
+ writeScrollTop,
1188
+ ],
660
1189
  );
661
1190
 
662
1191
  const beginUserMessageDisclosureChange = useCallback(
@@ -714,11 +1243,171 @@ export function MessageTimeline({
714
1243
  () => ({
715
1244
  expandedByMessageId: userMessageDisclosureMemoryRef.current,
716
1245
  beginChange: beginUserMessageDisclosureChange,
1246
+ labels: {
1247
+ showMore: userMessageDisclosureLabels?.showMore,
1248
+ showLess: userMessageDisclosureLabels?.showLess,
1249
+ },
717
1250
  }),
718
- [beginUserMessageDisclosureChange],
1251
+ [
1252
+ beginUserMessageDisclosureChange,
1253
+ userMessageDisclosureLabels?.showMore,
1254
+ userMessageDisclosureLabels?.showLess,
1255
+ ],
1256
+ );
1257
+ const timelineGroupEntryContext = useMemo<TimelineGroupEntryContext>(
1258
+ () => ({
1259
+ userMessageDisclosureContext,
1260
+ behavior: {
1261
+ renderMessageActions,
1262
+ renderMessageText: renderSearchableMessageText,
1263
+ onOpenSession,
1264
+ onMemoryClick,
1265
+ onReconnect,
1266
+ renderAuthNeeded,
1267
+ resolveProviderLogo,
1268
+ toolRegistry,
1269
+ loadRetainedScreenshot,
1270
+ loadRetainedArtifact,
1271
+ loadVideoArtifactPlayback,
1272
+ genieLoading,
1273
+ turnSummary,
1274
+ },
1275
+ }),
1276
+ [
1277
+ loadRetainedArtifact,
1278
+ loadRetainedScreenshot,
1279
+ loadVideoArtifactPlayback,
1280
+ onMemoryClick,
1281
+ onOpenSession,
1282
+ onReconnect,
1283
+ renderAuthNeeded,
1284
+ renderMessageActions,
1285
+ renderSearchableMessageText,
1286
+ resolveProviderLogo,
1287
+ toolRegistry,
1288
+ genieLoading,
1289
+ turnSummary,
1290
+ userMessageDisclosureContext,
1291
+ ],
1292
+ );
1293
+
1294
+ const releaseSearchPin = useCallback(() => {
1295
+ applyPinned(false);
1296
+ stopFollow();
1297
+ setRevealed(true);
1298
+ }, [applyPinned, stopFollow]);
1299
+ useTimelineSearchNavigation(
1300
+ scrollRef,
1301
+ searchItem?.id,
1302
+ searchTarget,
1303
+ releaseSearchPin,
1304
+ resolvedItems,
1305
+ );
1306
+
1307
+ const requestOlderIfUnderfilled = useCallback(
1308
+ (node: HTMLElement, retry?: OlderLoadAttempt) => {
1309
+ let currentAttempt = olderLoadAttemptRef.current;
1310
+ if (retry && (currentAttempt !== retry || retry[0] !== olderBoundaryKey)) {
1311
+ // AnimatePresence may retain the exiting button briefly. Its stale
1312
+ // handler cannot replace a newer exact owner.
1313
+ return;
1314
+ }
1315
+ const underfilled = maxScrollOf(node) <= 1;
1316
+ if (!retry && underfilled && currentAttempt?.[1] === 3) {
1317
+ // A receipted prefetch already declined or failed while the viewport
1318
+ // was still scrollable. Collapse may later remove the scroll range;
1319
+ // promote that exact owner to Retry without issuing another request.
1320
+ currentAttempt[1] = 0;
1321
+ setUnderfillSettledAttempt(currentAttempt);
1322
+ return;
1323
+ }
1324
+ if (!retry && underfilled && currentAttempt?.[1] === 2) {
1325
+ // A settled ordinary prefetch owns only this visit to the top band.
1326
+ // If its resulting window cannot scroll, yield to automatic underfill
1327
+ // so the reader is not stranded behind a cooldown they cannot exit.
1328
+ currentAttempt = olderLoadAttemptRef.current = null;
1329
+ }
1330
+ // clientHeight=0 is pre-layout/headless, not evidence that the rendered
1331
+ // history underfills a real viewport.
1332
+ if (
1333
+ node.clientHeight <= 1 ||
1334
+ (!retry && !underfilled) ||
1335
+ !hasOlder ||
1336
+ loadingOlder ||
1337
+ !onLoadOlder ||
1338
+ (currentAttempt && !retry)
1339
+ ) {
1340
+ return;
1341
+ }
1342
+ const attempt: OlderLoadAttempt = (olderLoadAttemptRef.current = [olderBoundaryKey]);
1343
+ setUnderfillSettledAttempt(null);
1344
+ // Every underfill request owns the ordinary sentinel too, even if reader
1345
+ // intent had not armed it yet when this short-window request began.
1346
+ const noProgress = () => {
1347
+ if (scrollRef.current && olderLoadAttemptRef.current === attempt) {
1348
+ setUnderfillSettledAttempt(attempt);
1349
+ }
1350
+ };
1351
+ // Legacy fire-and-forget callbacks keep the one-shot behavior. Hosts
1352
+ // that return the real promise opt into safe rejection/no-progress retry;
1353
+ // exact `false` is the first-party request-not-accepted receipt.
1354
+ // All other fulfillment retains this exact owner until its prepend
1355
+ // boundary commits; promise settlement alone cannot prove progress.
1356
+ invokeOlderLoad(onLoadOlder, noProgress, attempt, !retry);
1357
+ },
1358
+ [hasOlder, loadingOlder, olderBoundaryKey, onLoadOlder],
719
1359
  );
1360
+ const scheduleQuestionNav = useCallback(() => {
1361
+ if (!readableTurns || questionNavFrameRef.current != null) {
1362
+ return;
1363
+ }
1364
+ questionNavFrameRef.current = requestFrame(() => {
1365
+ questionNavFrameRef.current = null;
1366
+ const node = scrollRef.current;
1367
+ // A short viewport (a narrow embed above a decision card) has no room
1368
+ // for a floating pill that would cover the very rows being read.
1369
+ // clientHeight <= 1 is pre-layout/headless, not a short viewport.
1370
+ const roomy =
1371
+ node !== null &&
1372
+ (node.clientHeight <= 1 || node.clientHeight >= questionNavMinViewportRef.current);
1373
+ const next = node && roomy ? readQuestionNav(node) : null;
1374
+ setQuestionNav((current) => (sameQuestionNav(current, next) ? current : next));
1375
+ });
1376
+ }, [readableTurns]);
1377
+ scheduleQuestionNavRef.current = scheduleQuestionNav;
1378
+ useEffect(
1379
+ () => () => {
1380
+ if (questionNavFrameRef.current != null) {
1381
+ cancelFrame(questionNavFrameRef.current);
1382
+ questionNavFrameRef.current = null;
1383
+ }
1384
+ },
1385
+ [],
1386
+ );
1387
+ const jumpToQuestion = (key: string) => {
1388
+ const node = scrollRef.current;
1389
+ const top = node ? contentTopOf(node, key) : null;
1390
+ if (!node || top === null) {
1391
+ return;
1392
+ }
1393
+ // Explicit reader navigation: leave the tip and park the question on top.
1394
+ releasePinFromReader();
1395
+ wantPinRef.current = false;
1396
+ disclosureKeepsUnpinnedRef.current = false;
1397
+ writeScrollTop(node, Math.max(0, top - QUESTION_NAV_MARGIN_PX));
1398
+ // Hand keyboard/accessibility focus to the actual destination. This also
1399
+ // gives native scroll anchoring the new question rather than an old work
1400
+ // row when the bounded context subsequently gains a later history page.
1401
+ node
1402
+ .querySelector<HTMLElement>(`[data-og-group-key="${cssEscapeAttribute(key)}"]`)
1403
+ ?.focus({ preventScroll: true });
1404
+ syncScrollBaseline(node);
1405
+ scheduleQuestionNav();
1406
+ };
720
1407
 
721
- const driveFollowRef = useRef<(node: HTMLElement, now?: number) => void>(() => undefined);
1408
+ const driveFollowRef = useRef<(node: HTMLElement, now?: number) => void>(
1409
+ requestOlderIfUnderfilled as (node: HTMLElement, now?: number) => void,
1410
+ );
722
1411
  const driveFollow = useCallback(
723
1412
  (node: HTMLElement, nowMs?: number) => {
724
1413
  if (!pinnedRef.current || hasNewerRef.current) {
@@ -739,19 +1428,41 @@ export function MessageTimeline({
739
1428
  : typeof performance !== "undefined"
740
1429
  ? performance.now()
741
1430
  : Date.now();
742
- const previousHeight = followRef.current.lastHeight;
1431
+ let previousHeight = followRef.current.lastHeight;
1432
+ const previousObservedHeight = lastScrollHeightRef.current;
1433
+ if (
1434
+ contentShrinkBaselineRef.current &&
1435
+ previousObservedHeight > 0 &&
1436
+ node.scrollHeight > previousObservedHeight
1437
+ ) {
1438
+ // A reversal ends the collapse sequence. If it remains below the held
1439
+ // baseline, adopt the recovered height; if it grew beyond the baseline,
1440
+ // leave that baseline for tipFollowStep to observe as real growth.
1441
+ contentShrinkBaselineRef.current = null;
1442
+ if (node.scrollHeight < previousHeight) {
1443
+ followRef.current = {
1444
+ ...followRef.current,
1445
+ lastHeight: node.scrollHeight,
1446
+ cameraTop: null,
1447
+ };
1448
+ previousHeight = node.scrollHeight;
1449
+ }
1450
+ }
1451
+ const shrinkObservation = tipFollowObserveContentShrink(
1452
+ contentShrinkBaselineRef.current,
1453
+ previousHeight,
1454
+ lastScrollTopRef.current,
1455
+ node.scrollHeight,
1456
+ node.clientHeight,
1457
+ );
1458
+ contentShrinkBaselineRef.current = shrinkObservation.baseline;
743
1459
  // Settle-collapse: compensate Δh from the pre-shrink baseline (browser
744
1460
  // may already have clamped — don't double-subtract). Keep the follow rAF
745
1461
  // alive so when collapse ends (or stream resumes) we ease instead of a
746
1462
  // hard stop → flick. Do NOT tip-ease on the same frame as a real shrink
747
1463
  // (that fight was the top-of-viewport flicker).
748
- if (previousHeight > 0 && node.scrollHeight < previousHeight - TIP_FOLLOW_SHRINK_EPS_PX) {
749
- let nextTop = tipFollowCompensateShrink(
750
- lastScrollTopRef.current,
751
- previousHeight,
752
- node.scrollHeight,
753
- node.clientHeight,
754
- );
1464
+ if (shrinkObservation.compensatedScrollTop !== null) {
1465
+ let nextTop = shrinkObservation.compensatedScrollTop;
755
1466
  // A chrome/composer dock can land on the same frame as a settle-fold
756
1467
  // (the "turn blocked" moment). Compensate BOTH in one write — adopting
757
1468
  // the shrunk clientHeight below without gluing left the chrome height
@@ -776,7 +1487,7 @@ export function MessageTimeline({
776
1487
  // A direct glue write re-based the camera — drop any stale fraction.
777
1488
  cameraTop: null,
778
1489
  };
779
- if (followFrameRef.current === null) {
1490
+ if (followFrameRef.current == null) {
780
1491
  followFrameRef.current = requestFrame((frameNow) => {
781
1492
  followFrameRef.current = null;
782
1493
  const current = scrollRef.current;
@@ -787,15 +1498,6 @@ export function MessageTimeline({
787
1498
  }
788
1499
  return;
789
1500
  }
790
- // Sub-eps height noise: adopt height without moving the camera. Do NOT
791
- // adopt clientHeight here — tipFollowStep (next line) owns that baseline
792
- // and must still see a same-frame viewport shrink to glue it.
793
- if (previousHeight > 0 && node.scrollHeight < previousHeight) {
794
- followRef.current = {
795
- ...followRef.current,
796
- lastHeight: node.scrollHeight,
797
- };
798
- }
799
1501
  const result = tipFollowStep(followRef.current, {
800
1502
  scrollTop: node.scrollTop,
801
1503
  scrollHeight: node.scrollHeight,
@@ -808,9 +1510,13 @@ export function MessageTimeline({
808
1510
  followRef.current = result.state;
809
1511
  writeScrollTop(node, result.scrollTop);
810
1512
  syncScrollBaseline(node);
1513
+ applyCanSkipTipCatchup(
1514
+ result.state.running &&
1515
+ maxScrollOf(node) - node.scrollTop >= JUMP_TO_LATEST_CATCHUP_DEBT_PX,
1516
+ );
811
1517
  if (result.state.running) {
812
1518
  cancelLeaveFallback();
813
- if (followFrameRef.current === null) {
1519
+ if (followFrameRef.current == null) {
814
1520
  followFrameRef.current = requestFrame((frameNow) => {
815
1521
  followFrameRef.current = null;
816
1522
  const current = scrollRef.current;
@@ -819,12 +1525,12 @@ export function MessageTimeline({
819
1525
  }
820
1526
  });
821
1527
  }
822
- } else if (followFrameRef.current !== null) {
1528
+ } else if (followFrameRef.current != null) {
823
1529
  cancelFrame(followFrameRef.current);
824
1530
  followFrameRef.current = null;
825
1531
  }
826
1532
  },
827
- [cancelLeaveFallback, stopFollow, syncScrollBaseline, writeScrollTop],
1533
+ [applyCanSkipTipCatchup, cancelLeaveFallback, stopFollow, syncScrollBaseline, writeScrollTop],
828
1534
  );
829
1535
  driveFollowRef.current = driveFollow;
830
1536
 
@@ -842,12 +1548,80 @@ export function MessageTimeline({
842
1548
  return;
843
1549
  }
844
1550
  const previousFirstItemId = previousFirstItemIdRef.current;
845
- const previousGroupKeyByItemId = groupKeyByItemIdRef.current;
846
- const previousGroupOffsetByKey = groupOffsetByKeyRef.current;
1551
+ const previousFirstItemGroupKey = firstItemGroupKeyRef.current;
1552
+ const previousFirstItemGroupOffsetTop = firstItemGroupOffsetTopRef.current;
1553
+ const previousItemContentTop = firstItemContentTopRef.current;
847
1554
  const previousScrollTop = lastScrollTopRef.current;
848
- const firstItemChanged = previousFirstItemId !== null && firstItemId !== previousFirstItemId;
849
- const prepended =
850
- firstItemChanged && resolvedItems.some((item) => item.id === previousFirstItemId);
1555
+ const previousMaxScroll = lastMaxScrollRef.current;
1556
+ const wasAtLiveTailBeforeCommit =
1557
+ previousMaxScroll <= 1 ||
1558
+ previousMaxScroll - previousScrollTop < Math.min(PIN_THRESHOLD_PX, previousMaxScroll);
1559
+ const firstItemChanged = !!previousFirstItemId && firstItemId !== previousFirstItemId;
1560
+ const sourceChanged = olderBoundaryKey !== previousSourceBoundaryRef.current;
1561
+ const retainedSource =
1562
+ sourceItems?.some((item) => previousSourceIdsRef.current.has(item.id)) ?? false;
1563
+ const prepended = sourceChanged && retainedSource;
1564
+ previousSourceBoundaryRef.current = olderBoundaryKey;
1565
+ previousSourceIdsRef.current = new Set(sourceItems?.map((item) => item.id));
1566
+ const readingAnchor = readingAnchorRef.current;
1567
+ readingAnchorRef.current = null;
1568
+ const settlement = settlementRef.current;
1569
+ settlementRef.current = null;
1570
+ const attempt = olderLoadAttemptRef.current;
1571
+ const committedZeroOverlapOlderReplacement = !!(
1572
+ attempt?.[2]?.committed &&
1573
+ sourceChanged &&
1574
+ !retainedSource
1575
+ );
1576
+ const restorePrependAnchor = () => {
1577
+ const correction = readingAnchor && timelineAnchorCorrection(node, readingAnchor);
1578
+ if (correction != null) {
1579
+ if (Math.abs(correction) > 1) writeScrollTop(node, node.scrollTop + correction);
1580
+ return;
1581
+ }
1582
+ // Keep the reader on the same retained rows. Prefer the exact first-item
1583
+ // content coordinate (needed when a prepend merges inside one group),
1584
+ // then the retained group offset, then scrollHeight as a final fallback.
1585
+ // If native anchoring already applied the same shift, leave scrollTop.
1586
+ let delta: number | null = null;
1587
+ if (previousFirstItemId && previousItemContentTop != null) {
1588
+ const itemEl = node.querySelector(
1589
+ `[data-og-item="${cssEscapeAttribute(previousFirstItemId)}"]`,
1590
+ );
1591
+ if (itemEl instanceof HTMLElement) {
1592
+ const scrollerTop = node.getBoundingClientRect().top;
1593
+ const currentItemTop = itemEl.getBoundingClientRect().top - scrollerTop + node.scrollTop;
1594
+ delta = Math.round(currentItemTop - previousItemContentTop);
1595
+ }
1596
+ }
1597
+ const anchorKey = previousFirstItemGroupKey;
1598
+ const anchorEl =
1599
+ anchorKey != null
1600
+ ? node.querySelector(`[data-og-group-key="${cssEscapeAttribute(anchorKey)}"]`)
1601
+ : null;
1602
+ if (
1603
+ delta == null &&
1604
+ anchorEl instanceof HTMLElement &&
1605
+ previousFirstItemGroupOffsetTop != null
1606
+ ) {
1607
+ const moved = Math.round(anchorEl.offsetTop - previousFirstItemGroupOffsetTop);
1608
+ if (moved) {
1609
+ delta = moved;
1610
+ }
1611
+ }
1612
+ if (delta == null) {
1613
+ const heightDelta = Math.round(node.scrollHeight - previousScrollHeightRef.current);
1614
+ if (heightDelta > 0) {
1615
+ delta = heightDelta;
1616
+ }
1617
+ }
1618
+ if (delta != null) {
1619
+ const expected = previousScrollTop + delta;
1620
+ if (Math.abs(node.scrollTop - expected) > 2) {
1621
+ writeScrollTop(node, expected);
1622
+ }
1623
+ }
1624
+ };
851
1625
  if (pendingJumpToStartRef.current && firstItemChanged) {
852
1626
  // The oldest window landed — jump against the NEW DOM, and skip the
853
1627
  // prepend correction (it would shift the reader away from the top).
@@ -863,6 +1637,64 @@ export function MessageTimeline({
863
1637
  applyPinned(true);
864
1638
  snapToBottom(node);
865
1639
  }
1640
+ } else if (committedZeroOverlapOlderReplacement) {
1641
+ // An oldest-directed bounded page can replace every previously mounted
1642
+ // row. Its receipt is the only causal proof that this is backward
1643
+ // progress rather than live-tail forward eviction. Anchor at the bottom
1644
+ // seam before recording this window's scroll baselines so an unpinned
1645
+ // reader stays adjacent to the history they were reading.
1646
+ clearPendingReaderLeave();
1647
+ stopFollow();
1648
+ writeScrollTop(node, maxScrollOf(node));
1649
+ if (attempt?.[1] === 1 && pinnedRef.current) {
1650
+ applyPinned(false);
1651
+ }
1652
+ } else if (prepended) {
1653
+ const olderAttemptState = olderLoadAttemptRef.current?.[1];
1654
+ const underfillOwned =
1655
+ !!olderLoadAttemptRef.current &&
1656
+ olderAttemptState !== 1 &&
1657
+ olderAttemptState !== 2 &&
1658
+ olderAttemptState !== 3;
1659
+ if (
1660
+ autoFollow &&
1661
+ pinnedRef.current &&
1662
+ !hasNewer &&
1663
+ !pendingReaderLeaveRef.current &&
1664
+ (wasAtLiveTailBeforeCommit || underfillOwned)
1665
+ ) {
1666
+ // Still following the live tip: underfill, or a prefetch the reader
1667
+ // started and then returned from. Park at the new tip. A stale pin
1668
+ // while they are actually up in a short window (gap is not inside
1669
+ // PIN_THRESHOLD of maxScroll, so wasAtLiveTail is false) restores.
1670
+ clearPendingReaderLeave();
1671
+ snapToBottom(node);
1672
+ } else {
1673
+ restorePrependAnchor();
1674
+ if (autoFollow && pinnedRef.current && !hasNewer) {
1675
+ // Geometry or a pending extension/programmatic leave proves the
1676
+ // reader was browsing history even if the pin ref has not settled.
1677
+ stopFollow();
1678
+ clearPendingReaderLeave();
1679
+ applyPinned(false);
1680
+ }
1681
+ }
1682
+ } else if (!pinnedRef.current && readingAnchor && readableTurns) {
1683
+ // A live work row moves behind newly streamed prose. Correct only the
1684
+ // residual movement native anchoring did not absorb, using the reader's
1685
+ // retained paragraph/control rather than the moving row's outer box.
1686
+ const correction = timelineAnchorCorrection(node, readingAnchor);
1687
+ if (correction !== null && Math.abs(correction) > 1) {
1688
+ writeScrollTop(node, node.scrollTop + correction);
1689
+ }
1690
+ const focused = readingAnchor.find((anchor) => anchor.focused)?.element;
1691
+ if (
1692
+ focused &&
1693
+ node.contains(focused) &&
1694
+ node.ownerDocument.activeElement === node.ownerDocument.body
1695
+ ) {
1696
+ focused.focus({ preventScroll: true });
1697
+ }
866
1698
  } else if (autoFollow && pinnedRef.current && !hasNewer) {
867
1699
  // Load/remount (still hidden): hard-park. Live tip after reveal: ease.
868
1700
  // Pending unarmed leave: tip *growth* must not yank (Vimium during stream).
@@ -877,83 +1709,94 @@ export function MessageTimeline({
877
1709
  } else {
878
1710
  driveFollow(node);
879
1711
  }
880
- } else if (prepended) {
881
- // Keep the reader on the same retained rows. Prefer the offsetTop delta
882
- // of the group that still holds the previous first item — that stays
883
- // correct when loadOlder also truncates the tip (height delta then lies).
884
- // If native anchoring already applied the same shift, leave scrollTop.
885
- const anchorKey =
886
- previousFirstItemId !== null
887
- ? previousGroupKeyByItemId.get(previousFirstItemId)
888
- : undefined;
889
- const previousAnchorTop =
890
- anchorKey !== undefined ? previousGroupOffsetByKey.get(anchorKey) : undefined;
891
- const anchorEl =
892
- anchorKey !== undefined
893
- ? node.querySelector(`[data-og-group-key="${cssEscapeAttribute(anchorKey)}"]`)
894
- : null;
895
- let delta: number | null = null;
896
- if (anchorEl instanceof HTMLElement && previousAnchorTop !== undefined) {
897
- const moved = Math.round(anchorEl.offsetTop - previousAnchorTop);
898
- if (moved !== 0) {
899
- delta = moved;
900
- }
901
- }
902
- if (delta === null) {
903
- const heightDelta = Math.round(node.scrollHeight - previousScrollHeightRef.current);
904
- if (heightDelta > 0) {
905
- delta = heightDelta;
906
- }
907
- }
908
- if (delta !== null) {
909
- const expected = previousScrollTop + delta;
910
- if (Math.abs(node.scrollTop - expected) > 2) {
911
- writeScrollTop(node, expected);
912
- }
913
- }
914
1712
  }
915
1713
  // After a prepend, if restore left us below the top prefetch band,
916
1714
  // re-arm so a later approach can load again. Still cooling while parked
917
1715
  // inside the band (short pages) — that stops the y=0 load loop.
918
- if (
919
- prepended &&
920
- !pinnedRef.current &&
921
- olderLoadGateRef.current === "cooling" &&
922
- node.scrollTop > OLDER_PREFETCH_MARGIN_PX
923
- ) {
924
- olderLoadGateRef.current = "armed";
1716
+ if (prepended && !pinnedRef.current && node.scrollTop > OLDER_PREFETCH_MARGIN_PX) {
1717
+ rearmOlderPrefetchAfterLeavingTop(node);
925
1718
  }
1719
+ scheduleQuestionNav();
926
1720
  previousFirstItemIdRef.current = firstItemId;
927
1721
  previousScrollHeightRef.current = node.scrollHeight;
928
1722
  syncScrollBaseline(node);
929
- // Item→group keys are cheap (data only). offsetTop queries are O(groups)
930
- // layout reads — skip while pinned at the live tip (every stream token
931
- // used to remeasure the whole timeline; that was the long-run lag).
932
- const nextKeyByItemId = new Map<string, string>();
933
- for (const { group, key } of groups) {
934
- for (const itemId of timelineGroupItemIds(group)) {
935
- nextKeyByItemId.set(itemId, key);
936
- }
937
- }
938
- groupKeyByItemIdRef.current = nextKeyByItemId;
939
- const needOffsets = prepended || firstItemChanged || !pinnedRef.current || Boolean(hasNewer);
1723
+ // offsetTop queries are O(groups); skip while pinned at the live tip
1724
+ // (every stream token used to remeasure the whole timeline).
1725
+ firstItemGroupKeyRef.current = groups[0]?.key ?? null;
1726
+ const needOffsets =
1727
+ prepended ||
1728
+ firstItemChanged ||
1729
+ !pinnedRef.current ||
1730
+ hasNewer ||
1731
+ firstItemContentTopRef.current == null;
940
1732
  if (needOffsets) {
941
- const nextOffsetByKey = new Map<string, number>();
942
- for (const { key } of groups) {
943
- const el = node.querySelector(`[data-og-group-key="${cssEscapeAttribute(key)}"]`);
944
- if (el instanceof HTMLElement) {
945
- nextOffsetByKey.set(key, el.offsetTop);
946
- }
947
- }
948
- groupOffsetByKeyRef.current = nextOffsetByKey;
1733
+ const committedFirstGroupKey = firstItemGroupKeyRef.current;
1734
+ const firstGroupEl = committedFirstGroupKey
1735
+ ? node.querySelector(`[data-og-group-key="${cssEscapeAttribute(committedFirstGroupKey)}"]`)
1736
+ : null;
1737
+ firstItemGroupOffsetTopRef.current =
1738
+ firstGroupEl instanceof HTMLElement ? firstGroupEl.offsetTop : null;
1739
+ const firstItemEl = firstItemId
1740
+ ? node.querySelector(`[data-og-item="${cssEscapeAttribute(firstItemId)}"]`)
1741
+ : null;
1742
+ firstItemContentTopRef.current =
1743
+ firstItemId && firstItemEl instanceof HTMLElement
1744
+ ? firstItemEl.getBoundingClientRect().top -
1745
+ node.getBoundingClientRect().top +
1746
+ node.scrollTop
1747
+ : null;
1748
+ }
1749
+
1750
+ if (settlement) settlementAnimationRef.current = animateTimelineSettlement(settlement);
1751
+
1752
+ // Promise settlement is not itself permission to retry. A receipt-marked
1753
+ // accepted page retires its exact owner on this commit even when projection
1754
+ // is empty or merges into the same first item. Without that mark, retain
1755
+ // the compatibility fallback: a retained prior boundary proves prepend,
1756
+ // while a missing prior boundary is forward eviction and merely rebases.
1757
+ if (!attempt) {
1758
+ return;
1759
+ }
1760
+ if (!attempt[2]?.committed && attempt[0] === olderBoundaryKey) {
1761
+ return;
1762
+ }
1763
+ if (
1764
+ !attempt[2]?.committed &&
1765
+ attempt[0] &&
1766
+ !sourceItems?.find((entry) => entry.id === attempt[0])
1767
+ ) {
1768
+ attempt[0] = olderBoundaryKey;
1769
+ return;
1770
+ }
1771
+ if (!attempt[1] || !hasOlder) {
1772
+ setUnderfillSettledAttempt((olderLoadAttemptRef.current = null));
1773
+ return;
1774
+ }
1775
+ // Boundary progress retires the request owner. A late settlement from
1776
+ // that completed prefetch cannot mutate the new window's cooldown owner.
1777
+ olderLoadAttemptRef.current = [olderBoundaryKey, 2];
1778
+ // Bound automatic work, but let continued upward input replenish demand.
1779
+ // Only a committed first-party receipt proves that another page is safe.
1780
+ if (
1781
+ attempt[2]?.committed &&
1782
+ !pinnedRef.current &&
1783
+ hasOlder &&
1784
+ node.scrollTop <= OLDER_PREFETCH_MARGIN_PX &&
1785
+ olderPageBudgetRef.current > 0
1786
+ ) {
1787
+ olderPageBudgetRef.current -= 1;
1788
+ olderLoadAttemptRef.current = null;
1789
+ setOlderDemand((value) => value + 1);
949
1790
  }
1791
+ rearmOlderPrefetchAfterLeavingTop(node);
1792
+ requestOlderIfUnderfilled(node);
950
1793
  });
951
1794
 
952
1795
  // First paint / session remount: keep the scroller hidden, snap to tip for
953
1796
  // two animation frames (late sync layout), then reveal. Does not change the
954
- // tip-follow ease law used once `revealed` is true.
1797
+ // live tip-follow law used once `revealed` is true.
955
1798
  useLayoutEffect(() => {
956
- if (revealed || allGroups.length === 0) {
1799
+ if (revealed || !allGroups.length) {
957
1800
  return;
958
1801
  }
959
1802
  let cancelled = false;
@@ -981,7 +1824,7 @@ export function MessageTimeline({
981
1824
  return () => {
982
1825
  cancelled = true;
983
1826
  cancelFrame(frame1);
984
- if (frame2 !== 0) {
1827
+ if (frame2) {
985
1828
  cancelFrame(frame2);
986
1829
  }
987
1830
  };
@@ -1000,7 +1843,6 @@ export function MessageTimeline({
1000
1843
  olderPrefetchArmedRef.current = false;
1001
1844
  setOlderPrefetchArmed(false);
1002
1845
  }
1003
- olderLoadGateRef.current = "armed";
1004
1846
  wantPinRef.current = false;
1005
1847
  pendingJumpToStartRef.current = false;
1006
1848
  previousFirstItemIdRef.current = null;
@@ -1009,14 +1851,27 @@ export function MessageTimeline({
1009
1851
  lastMaxScrollRef.current = 0;
1010
1852
  lastScrollHeightRef.current = 0;
1011
1853
  lastClientHeightRef.current = 0;
1012
- groupKeyByItemIdRef.current = new Map();
1013
- groupOffsetByKeyRef.current = new Map();
1854
+ firstItemGroupKeyRef.current = null;
1855
+ firstItemGroupOffsetTopRef.current = null;
1856
+ firstItemContentTopRef.current = null;
1014
1857
  foldMemoryRef.current.clear();
1015
1858
  userMessageDisclosureMemoryRef.current.clear();
1016
1859
  disclosureKeepsUnpinnedRef.current = false;
1860
+ clearReaderIntent();
1861
+ contentShrinkBaselineRef.current = null;
1017
1862
  seenActivityIdsRef.current.clear();
1018
1863
  applyPinned(true);
1019
- }, [allGroups.length, revealed, applyPinned]);
1864
+ }, [allGroups.length, revealed, applyPinned, clearReaderIntent]);
1865
+
1866
+ // Parent commits cover the initial/history-loading cases. Disclosure state
1867
+ // changes are child-local, so the ResizeObserver below owns dynamic collapse
1868
+ // and expansion after mount.
1869
+ useEffect(() => {
1870
+ const node = scrollRef.current;
1871
+ if (node) {
1872
+ requestOlderIfUnderfilled(node);
1873
+ }
1874
+ });
1020
1875
 
1021
1876
  // Clear the bulk-paint marker a frame after it renders, so rows appended
1022
1877
  // live (streams, new turns) animate exactly as before.
@@ -1052,21 +1907,61 @@ export function MessageTimeline({
1052
1907
  (entries) => {
1053
1908
  const intersecting = entries.some((entry) => entry.isIntersecting);
1054
1909
  if (!intersecting) {
1055
- // Left the top band — next approach may load once.
1056
- olderLoadGateRef.current = "armed";
1910
+ // Left the top band. A settled ordinary prefetch releases its exact
1911
+ // owner here; a still-pending request remains cooling so a quick
1912
+ // leave/re-enter cannot overlap it.
1913
+ const attempt = olderLoadAttemptRef.current;
1914
+ if (attempt?.[1] === 2 || attempt?.[1] === 3) {
1915
+ olderLoadAttemptRef.current = null;
1916
+ }
1057
1917
  return;
1058
1918
  }
1059
- if (olderLoadGateRef.current !== "armed") {
1919
+ // Both automatic underfill and ordinary prefetch use one boundary
1920
+ // owner. In particular, live growth cannot arm a second sentinel load
1921
+ // while the short-window request that preceded it is still pending.
1922
+ if (olderLoadAttemptRef.current) {
1060
1923
  return;
1061
1924
  }
1062
- olderLoadGateRef.current = "cooling";
1063
- onLoadOlder();
1925
+ const attempt: OlderLoadAttempt = (olderLoadAttemptRef.current = [olderBoundaryKey, 1]);
1926
+ const noProgress = () => {
1927
+ if (!scrollRef.current || olderLoadAttemptRef.current !== attempt) {
1928
+ return;
1929
+ }
1930
+ attempt[1] = 3;
1931
+ if (root.scrollTop > OLDER_PREFETCH_MARGIN_PX) {
1932
+ olderLoadAttemptRef.current = null;
1933
+ } else if (maxScrollOf(root) <= 1) {
1934
+ attempt[1] = 0;
1935
+ setUnderfillSettledAttempt(attempt);
1936
+ }
1937
+ };
1938
+ // Preserve legacy fire-and-forget top-band retries: the callback has
1939
+ // synchronously returned, but this visit remains cooling until exit.
1940
+ // Only rejection/throw/exact `false` is a no-progress receipt. Other
1941
+ // fulfillment retains the pending owner until boundary commit;
1942
+ // otherwise a delayed prepend could overlap a same-boundary request.
1943
+ if (
1944
+ !invokeOlderLoad(onLoadOlder, noProgress, attempt) &&
1945
+ olderLoadAttemptRef.current === attempt
1946
+ ) {
1947
+ olderLoadAttemptRef.current[1] = 2;
1948
+ requestOlderIfUnderfilled(root);
1949
+ }
1064
1950
  },
1065
1951
  { root, rootMargin: OLDER_PREFETCH_ROOT_MARGIN },
1066
1952
  );
1067
1953
  observer.observe(target);
1068
1954
  return () => observer.disconnect();
1069
- }, [olderPrefetchArmed, hasOlder, loadingOlder, onLoadOlder, firstGroupKey]);
1955
+ }, [
1956
+ firstGroupKey,
1957
+ hasOlder,
1958
+ loadingOlder,
1959
+ olderBoundaryKey,
1960
+ olderPrefetchArmed,
1961
+ olderDemand,
1962
+ onLoadOlder,
1963
+ requestOlderIfUnderfilled,
1964
+ ]);
1070
1965
 
1071
1966
  // History view: page forward when the reader nears the bottom of the current
1072
1967
  // non-tip window. Does not pull the whole gap — one density-bounded page.
@@ -1085,15 +1980,17 @@ export function MessageTimeline({
1085
1980
  }
1086
1981
  const observer = new IntersectionObserver(
1087
1982
  (entries) => {
1088
- if (entries.some((entry) => entry.isIntersecting)) {
1089
- onLoadNewer();
1983
+ // An underfilled history window has both sentinels visible. Advancing
1984
+ // it automatically would undo an explicit older-page navigation.
1985
+ if (maxScrollOf(root) > 1 && entries.some((entry) => entry.isIntersecting)) {
1986
+ requestNewer();
1090
1987
  }
1091
1988
  },
1092
1989
  { root, rootMargin: "0px 0px 1200px 0px" },
1093
1990
  );
1094
1991
  observer.observe(target);
1095
1992
  return () => observer.disconnect();
1096
- }, [hasNewer, loadingNewer, onLoadNewer, firstGroupKey]);
1993
+ }, [hasNewer, loadingNewer, onLoadNewer, requestNewer, firstGroupKey]);
1097
1994
 
1098
1995
  // Late layout that React commits cannot see (images decoding, fonts, code
1099
1996
  // blocks) grows content without a commit. While pinned, soft-follow the tip;
@@ -1106,7 +2003,7 @@ export function MessageTimeline({
1106
2003
  return;
1107
2004
  }
1108
2005
  const observer = new ResizeObserver(() => {
1109
- if (resizeFollowRafRef.current !== null) {
2006
+ if (resizeFollowRafRef.current != null) {
1110
2007
  return;
1111
2008
  }
1112
2009
  resizeFollowRafRef.current = requestFrame(() => {
@@ -1115,6 +2012,10 @@ export function MessageTimeline({
1115
2012
  if (!current) {
1116
2013
  return;
1117
2014
  }
2015
+ // A viewport that shrank below the pill's minimum hides it now, not
2016
+ // on the next scroll.
2017
+ if (questionNavMinViewportRef.current > 0) scheduleQuestionNavRef.current();
2018
+ requestOlderIfUnderfilled(current);
1118
2019
  if (!autoFollow || !pinnedRef.current || hasNewerRef.current) {
1119
2020
  return;
1120
2021
  }
@@ -1132,12 +2033,12 @@ export function MessageTimeline({
1132
2033
  observer.observe(node);
1133
2034
  return () => {
1134
2035
  observer.disconnect();
1135
- if (resizeFollowRafRef.current !== null) {
2036
+ if (resizeFollowRafRef.current != null) {
1136
2037
  cancelFrame(resizeFollowRafRef.current);
1137
2038
  resizeFollowRafRef.current = null;
1138
2039
  }
1139
2040
  };
1140
- }, [autoFollow, driveFollow, snapToBottom]);
2041
+ }, [autoFollow, driveFollow, requestOlderIfUnderfilled, snapToBottom]);
1141
2042
 
1142
2043
  // Entering a non-tip history window: drop any live pin so the page bottom
1143
2044
  // cannot re-stick follow across loadNewer. Leaving it (the tip window
@@ -1145,6 +2046,8 @@ export function MessageTimeline({
1145
2046
  // at what just became the live bottom — paging forward to the tip must not
1146
2047
  // strand them unpinned watching new content grow below.
1147
2048
  useEffect(() => {
2049
+ // Closing find is paint-only; it must not trigger this history-to-tip rule.
2050
+ if (searchTargetRef.current) return;
1148
2051
  if (hasNewer) {
1149
2052
  stopFollow();
1150
2053
  applyPinned(false);
@@ -1167,6 +2070,15 @@ export function MessageTimeline({
1167
2070
  }
1168
2071
  }, [hasNewer, autoFollow, applyPinned, snapToBottom, stopFollow]);
1169
2072
 
2073
+ // After the scroll authority above has settled each commit, record whether
2074
+ // the reader is away from the tip (content can land below an unpinned
2075
+ // reader without any scroll event).
2076
+ // oxlint-disable-next-line react-hooks/exhaustive-deps -- Deliberately runs after every commit.
2077
+ useLayoutEffect(() => {
2078
+ const node = scrollRef.current;
2079
+ if (node && !pinnedRef.current) setAwayFromTip(!isNearBottom(node));
2080
+ });
2081
+
1170
2082
  // Pinned: layout/camera recover tip debt; wheel/keys/pointer-arm unpin
1171
2083
  // immediately; extension jumps settle via scrollend (or one-rAF fallback).
1172
2084
  // Do not tip-follow-yank an in-flight unarmed scroll-away — that ate Vimium.
@@ -1175,6 +2087,9 @@ export function MessageTimeline({
1175
2087
  if (!node) {
1176
2088
  return;
1177
2089
  }
2090
+ // Only an unpinned reader's distance matters for the jump.
2091
+ if (!pinnedRef.current) setAwayFromTip(!isNearBottom(node));
2092
+ scheduleQuestionNav();
1178
2093
  const previousTop = lastScrollTopRef.current;
1179
2094
  const previousMaxScroll = lastMaxScrollRef.current;
1180
2095
  const nextTop = node.scrollTop;
@@ -1183,6 +2098,16 @@ export function MessageTimeline({
1183
2098
  const readerUp = readerScrollUpPx(previousTop, nextTop, previousMaxScroll, nextMaxScroll);
1184
2099
  const maxFell = nextMaxScroll < previousMaxScroll - 1;
1185
2100
  const readerArmed = readerIntentArmRef.current;
2101
+ const readerIntentStart = readerIntentStartRef.current;
2102
+ const cumulativeReaderUp =
2103
+ readerArmed && readerIntentStart
2104
+ ? readerScrollUpPx(
2105
+ readerIntentStart.scrollTop,
2106
+ nextTop,
2107
+ readerIntentStart.maxScroll,
2108
+ nextMaxScroll,
2109
+ )
2110
+ : readerUp;
1186
2111
  const heightShrunk =
1187
2112
  followRef.current.lastHeight > 0 &&
1188
2113
  nextHeight < followRef.current.lastHeight - TIP_FOLLOW_SHRINK_EPS_PX;
@@ -1198,6 +2123,19 @@ export function MessageTimeline({
1198
2123
  return;
1199
2124
  }
1200
2125
 
2126
+ if (programmatic && !pinnedRef.current) {
2127
+ // Restore/camera writes while reading history are not a return to the tip.
2128
+ // Still expire a pending Jump-to-latest latch: the in-window jump itself
2129
+ // is a programmatic snap, and a later reader scroll-away can arrive
2130
+ // before that echo is consumed. Skipping this left the latch armed and
2131
+ // snapped the reader when hasNewer later flipped false.
2132
+ syncScrollBaseline(node);
2133
+ if (wantPinRef.current && !isNearBottom(node)) {
2134
+ wantPinRef.current = false;
2135
+ }
2136
+ return;
2137
+ }
2138
+
1201
2139
  if (autoFollow && pinnedRef.current && !hasNewer) {
1202
2140
  // Fold / composer / SessionChrome: viewport shrink raises maxScroll without
1203
2141
  // growing content. Must hit tipFollow before we adopt the new clientHeight
@@ -1208,7 +2146,7 @@ export function MessageTimeline({
1208
2146
  // Fold / composer content shrink: compensate before baseline sync so
1209
2147
  // driveFollow still sees the pre-shrink scrollTop (avoid double-subtract).
1210
2148
  if (heightShrunk || maxFell || viewportShrunk) {
1211
- readerIntentArmRef.current = false;
2149
+ clearReaderIntent();
1212
2150
  clearPendingReaderLeave();
1213
2151
  driveFollow(node);
1214
2152
  return;
@@ -1230,15 +2168,14 @@ export function MessageTimeline({
1230
2168
  clearPendingReaderLeave();
1231
2169
  }
1232
2170
  // Pointer-dragged scroll-up away from tip. Layout churn never arms this.
1233
- if (readerArmed && readerUp > TIP_FOLLOW_READER_UP_EPS_PX && !nearBottomPinned) {
1234
- readerIntentArmRef.current = false;
1235
- releasePinFromReader(node);
1236
- if (olderLoadGateRef.current === "cooling" && node.scrollTop > OLDER_PREFETCH_MARGIN_PX) {
1237
- olderLoadGateRef.current = "armed";
1238
- }
2171
+ if (readerArmed && cumulativeReaderUp > TIP_FOLLOW_READER_UP_EPS_PX && !nearBottomPinned) {
2172
+ clearReaderIntent();
2173
+ requestEarlierFromReader();
2174
+ rearmOlderPrefetchAfterLeavingTop(node);
1239
2175
  return;
1240
2176
  }
1241
- // Tip grew under a still viewport (no reader-up): ease back to the tip.
2177
+ // Tip grew under a still viewport (no reader-up): track the new growth
2178
+ // immediately and ease only any debt that already existed.
1242
2179
  // Reader/extension scroll-up in progress: do not yank — scrollend decides.
1243
2180
  // Near-bottom with tipDebt≈0 stays the quiet path (do not broaden follow
1244
2181
  // inside PIN_THRESHOLD — that fights small intentional scroll-ups).
@@ -1258,15 +2195,46 @@ export function MessageTimeline({
1258
2195
  }
1259
2196
 
1260
2197
  syncScrollBaseline(node);
2198
+ if (programmatic) {
2199
+ // Anchor restoration is never permission to resume tip-follow.
2200
+ return;
2201
+ }
1261
2202
  const nearBottom = isNearBottom(node);
1262
2203
 
1263
- // Re-pin only when the reader moved toward/at the tip — not when a fold
1264
- // clamp dragged scrollTop down onto nearBottom.
2204
+ // Re-pin only when the reader moved toward/at the tip. Prepend restore
2205
+ // and overflow-anchor raise scrollTop by roughly the same amount as
2206
+ // maxScroll; treating that as a scroll-down
2207
+ // re-pinned a compact-tail history reader (their preserved gap falls
2208
+ // inside PIN_THRESHOLD once the window is tall) and snapped them back.
2209
+ // A commit may already have adopted this scroll position into the layout
2210
+ // baseline. Explicit wheel/key/pointer intent retains its own start so
2211
+ // returning to the tip still re-pins after that interleaving.
2212
+ // Passive wheel handlers may run after compositor scrolling; retain the
2213
+ // earlier layout baseline when it still contains that reader movement.
2214
+ const returnStart =
2215
+ readerIntentStart && readerIntentStart.scrollTop <= previousTop ? readerIntentStart : null;
2216
+ const inserted = Math.max(0, nextMaxScroll - (returnStart?.maxScroll ?? previousMaxScroll));
2217
+ const towardTip = nextTop - (returnStart?.scrollTop ?? previousTop) - inserted;
2218
+ // Explicit intent may reach the exact live bottom despite intervening
2219
+ // streaming growth. Keep subtracting that growth: native anchoring alone
2220
+ // conserves the gap and must never qualify as reader movement.
2221
+ const explicitReturnToBottom = returnStart !== null && nextMaxScroll - nextTop <= 1;
1265
2222
  const nextPinned =
1266
- !hasNewer && nearBottom && nextTop >= previousTop - TIP_FOLLOW_READER_UP_EPS_PX;
2223
+ !hasNewer && nearBottom && towardTip > 0.5 && (inserted <= 1 || explicitReturnToBottom);
1267
2224
  if (!nextPinned) {
1268
2225
  stopFollow();
1269
2226
  }
2227
+ const focused = node.ownerDocument.activeElement;
2228
+ if (
2229
+ nextPinned &&
2230
+ focused instanceof HTMLElement &&
2231
+ focused.hasAttribute("data-og-prompt") &&
2232
+ node.contains(focused)
2233
+ ) {
2234
+ // Question navigation focuses its destination. Returning to the live
2235
+ // tip releases that stale reader ownership before the commit snapshot.
2236
+ node.focus({ preventScroll: true });
2237
+ }
1270
2238
  applyPinned(nextPinned);
1271
2239
  // A far-from-bottom scroll while a Jump-to-latest is pending is the reader
1272
2240
  // changing their mind: drop the latch, or a stale one (host rejected or
@@ -1277,17 +2245,22 @@ export function MessageTimeline({
1277
2245
  wantPinRef.current = false;
1278
2246
  }
1279
2247
  if (nextPinned) {
2248
+ clearReaderIntent();
1280
2249
  return;
1281
2250
  }
2251
+ if (readerIntentStart) {
2252
+ // This scroll already accounted for the gesture's movement. Retain a
2253
+ // fresh start for continuous input across the next commit, not stale
2254
+ // movement that could turn a later native clamp into a return to tip.
2255
+ readerIntentStartRef.current = { scrollTop: nextTop, maxScroll: nextMaxScroll };
2256
+ }
1282
2257
  if (!olderPrefetchArmedRef.current) {
1283
2258
  olderPrefetchArmedRef.current = true;
1284
2259
  setOlderPrefetchArmed(true);
1285
2260
  }
1286
2261
  // Re-arm older prefetch only after leaving the top band (scroll down into
1287
2262
  // content). Never re-arm/load from continued scroll toward y=0.
1288
- if (olderLoadGateRef.current === "cooling" && node.scrollTop > OLDER_PREFETCH_MARGIN_PX) {
1289
- olderLoadGateRef.current = "armed";
1290
- }
2263
+ rearmOlderPrefetchAfterLeavingTop(node);
1291
2264
  };
1292
2265
 
1293
2266
  const onScrollEnd = () => {
@@ -1296,6 +2269,9 @@ export function MessageTimeline({
1296
2269
  return;
1297
2270
  }
1298
2271
  cancelLeaveFallback();
2272
+ // Engines can emit scrollend between tiny pointer steps. Keep the pinned
2273
+ // gesture's cumulative leave budget until it actually leaves the tip.
2274
+ if (!pinnedRef.current || !readerIntentArmRef.current) clearReaderIntent();
1299
2275
  if (disclosureKeepsUnpinnedRef.current) {
1300
2276
  programmaticScrollRef.current = 0;
1301
2277
  stopFollow();
@@ -1311,298 +2287,471 @@ export function MessageTimeline({
1311
2287
  releasePinAfterScrollSettled(node);
1312
2288
  };
1313
2289
 
1314
- return (
1315
- <LightboxProvider>
1316
- <FoldMemoryProvider value={foldMemoryRef.current}>
1317
- <SeenActivityIdsProvider value={seenActivityIdsRef.current}>
1318
- <TimelineComputeLabelProvider value={computeLabel ?? null}>
1319
- <EntranceAnimationProvider value={!bulkRender}>
1320
- <TooltipProvider delayDuration={400}>
1321
- <div className={cn("og-root relative flex min-h-0 flex-col", className)}>
1322
- {onAnnotate ? (
1323
- <Suspense fallback={null}>
1324
- <TimelineAnnotationSelection
1325
- rootRef={scrollRef}
1326
- sources={annotationSources}
1327
- onAnnotate={onAnnotate}
1328
- />
1329
- </Suspense>
1330
- ) : null}
1331
- {/* Pinned: anchoring off so the tip-follow camera owns the motion.
1332
- Unpinned: native scroll anchoring holds the reader's place. */}
1333
- <div
1334
- ref={scrollRef}
1335
- data-og-timeline-scroller=""
1336
- data-og-bottom-follow={autoFollow && pinned && !hasNewer ? "true" : "false"}
1337
- tabIndex={-1}
1338
- onScroll={onScroll}
1339
- onScrollEnd={onScrollEnd}
1340
- onWheel={onWheel}
1341
- onPointerDown={onPointerDown}
1342
- onKeyDown={onKeyDown}
1343
- style={groups.length > 0 && !revealed ? { visibility: "hidden" } : undefined}
1344
- className={cn(
1345
- // tabIndex=-1 is programmatic only — never paint a focus ring on
1346
- // the whole scroller (click + Shift used to flash a blue outline).
1347
- "min-h-0 flex-1 overflow-y-auto overscroll-contain px-4 py-6 sm:px-6 outline-hidden",
1348
- autoFollow && pinned && !hasNewer
1349
- ? "[overflow-anchor:none]"
1350
- : "[overflow-anchor:auto]",
1351
- )}
1352
- >
1353
- <div className="relative mx-auto flex w-full max-w-3xl flex-col gap-5">
1354
- {groups.length === 0
1355
- ? (emptyState ?? (
1356
- <p className="py-10 text-center text-og-menu text-og-fg-subtle">
1357
- No activity yet.
1358
- </p>
1359
- ))
1360
- : null}
1361
- {hasOlder && olderPrefetchArmed ? (
1362
- // Overlaid, not a layout row: mounting/unmounting the sentinel
1363
- // must never shift content (that shift was itself a wobble).
1364
- <div
1365
- ref={topSentinelRef}
1366
- data-og-top-sentinel=""
1367
- data-og-timeline-chrome=""
1368
- aria-hidden="true"
1369
- className="pointer-events-none absolute inset-x-0 top-0 h-px"
1370
- />
1371
- ) : null}
1372
- {groups.map(({ group, key }, index) => {
1373
- const next = groups[index + 1]?.group;
1374
- const contextCompactionCount =
1375
- group.kind === "turn"
1376
- ? (group.contextCompactionCount ?? 0)
1377
- : group.kind === "activity" &&
1378
- next?.kind === "item" &&
1379
- next.item.kind === "context-compaction" &&
1380
- next.item.phase === "compacted"
1381
- ? 1
1382
- : 0;
1383
- return (
1384
- <div key={key} data-og-timeline-group-anchor="" data-og-group-key={key}>
1385
- <TimelineGroupRenderBoundary
1386
- resetKeys={[
1387
- group,
1388
- renderMessageText,
1389
- onOpenSession,
1390
- onMemoryClick,
1391
- onReconnect,
1392
- resolveProviderLogo,
1393
- toolRegistry,
1394
- loadRetainedScreenshot,
1395
- loadRetainedArtifact,
1396
- loadVideoArtifactPlayback,
1397
- turnSummary,
1398
- ]}
1399
- >
1400
- <UserMessageDisclosureProvider value={userMessageDisclosureContext}>
1401
- <TimelineGroupView
1402
- group={group}
1403
- renderMessageText={renderMessageText}
1404
- onOpenSession={onOpenSession}
1405
- onMemoryClick={onMemoryClick}
1406
- onReconnect={onReconnect}
1407
- resolveProviderLogo={resolveProviderLogo}
1408
- toolRegistry={toolRegistry}
1409
- loadRetainedScreenshot={loadRetainedScreenshot}
1410
- loadRetainedArtifact={loadRetainedArtifact}
1411
- loadVideoArtifactPlayback={loadVideoArtifactPlayback}
1412
- turnSummary={turnSummary}
1413
- foldLiveCluster={isAgentProgress(next)}
1414
- trailingAgentText={trailingAgentTextAfterTurn(group, next)}
1415
- contextCompactionCount={
1416
- contextCompactionCount > 0 ? contextCompactionCount : undefined
1417
- }
1418
- />
1419
- </UserMessageDisclosureProvider>
1420
- </TimelineGroupRenderBoundary>
1421
- </div>
1422
- );
1423
- })}
1424
- {groups.length > 0 && trailingState ? (
1425
- <div data-og-timeline-trailing-state="">{trailingState}</div>
1426
- ) : null}
1427
- {hasNewer ? (
1428
- <div
1429
- ref={bottomSentinelRef}
1430
- data-og-bottom-sentinel=""
1431
- data-og-timeline-chrome=""
1432
- aria-hidden="true"
1433
- className="h-px w-full"
1434
- />
2290
+ const timeline = (
2291
+ <AllowancePresentationProvider
2292
+ render={renderAllowanceExhausted}
2293
+ labels={allowanceExhaustedLabels}
2294
+ >
2295
+ <LightboxProvider>
2296
+ <FoldMemoryProvider value={foldMemoryRef.current}>
2297
+ <SeenActivityIdsProvider value={seenActivityIdsRef.current}>
2298
+ <TimelineComputeLabelProvider value={computeLabel ?? null}>
2299
+ <EntranceAnimationProvider value={false}>
2300
+ <TooltipProvider delayDuration={400}>
2301
+ <TimelineAnnotationSourceRootContext.Provider value={scrollRef}>
2302
+ <div
2303
+ className={cn("og-root relative flex min-h-0 flex-col", className)}
2304
+ // Sticky insets start at the scroller's padded content edge
2305
+ // (pt-16). Subtract exactly that padding so an expanded work
2306
+ // header pins flush to the scrollport. Pinning it lower left a
2307
+ // band of scrolling rows visible above the header, which then
2308
+ // looked like it floated over the middle of the timeline.
2309
+ style={{ "--og-work-header-top": "-4rem" } as CSSProperties}
2310
+ >
2311
+ {onAnnotate ? (
2312
+ <Suspense fallback={null}>
2313
+ <TimelineAnnotationSelection
2314
+ rootRef={scrollRef}
2315
+ sources={annotationSources}
2316
+ onAnnotate={onAnnotate}
2317
+ />
2318
+ </Suspense>
1435
2319
  ) : null}
1436
- </div>
1437
- </div>
1438
- <AnimatePresence>
1439
- {loadingOlder ||
1440
- loadingOldest ||
1441
- (hasOlder && onJumpToStart && olderPrefetchArmed) ? (
1442
- // Floating over the scroller (not a timeline row) so showing and
1443
- // hiding it never reflows history under the reader.
1444
- <motion.div
1445
- initial={{ opacity: 0, y: -6 }}
1446
- animate={{ opacity: 1, y: 0 }}
1447
- exit={{ opacity: 0, y: -6 }}
1448
- transition={{ duration: 0.15, ease: "easeOut" }}
1449
- data-og-loading-older=""
1450
- aria-live="polite"
1451
- className="absolute inset-x-0 top-3 z-10 flex justify-center gap-2"
1452
- >
1453
- {loadingOlder || loadingOldest ? (
1454
- <span className="pointer-events-none inline-flex items-center rounded-full border border-og-border bg-og-surface-3/90 px-3 py-1 text-og-control font-medium shadow-og-md backdrop-blur">
1455
- <span className="og-shimmer-text">
1456
- {loadingOldest ? "Jumping to start…" : "Loading earlier activity…"}
1457
- </span>
1458
- </span>
1459
- ) : null}
1460
- {hasOlder && onJumpToStart && !loadingOldest ? (
1461
- <button
1462
- type="button"
1463
- data-og-jump-to-start=""
1464
- disabled={loadingOlder}
1465
- onClick={() => {
1466
- applyPinned(false);
1467
- pendingJumpToStartRef.current = true;
1468
- const seq = ++jumpToStartSeqRef.current;
1469
- const node = scrollRef.current;
1470
- void Promise.resolve(onJumpToStart()).then(
1471
- () => {
1472
- // The commit that swaps in the oldest window consumes
1473
- // the flag against the new DOM; this write covers the
1474
- // already-committed order and the no-window-change
1475
- // case (jumping within the current window).
1476
- const scroller = scrollRef.current ?? node;
1477
- if (scroller) {
1478
- scroller.scrollTop = 0;
1479
- }
1480
- // A host may resolve without ever changing the
1481
- // window (already on the oldest page). Any swap
1482
- // commit runs its layout effect before the next
1483
- // frame, so a flag still armed by then is the
1484
- // no-change case — clear it, or a LATER prepend
1485
- // would spuriously jump the reader to the top.
1486
- requestFrame(() => {
1487
- if (jumpToStartSeqRef.current === seq) {
1488
- pendingJumpToStartRef.current = false;
1489
- }
1490
- });
1491
- },
1492
- () => {
1493
- if (jumpToStartSeqRef.current === seq) {
1494
- pendingJumpToStartRef.current = false;
1495
- }
1496
- },
1497
- );
1498
- }}
1499
- className={cn(
1500
- "inline-flex items-center gap-1.5 rounded-full border border-og-border bg-og-surface-3/90 px-3 py-1.5",
1501
- "text-og-control font-medium text-og-fg shadow-og-md backdrop-blur",
1502
- "hover:border-og-border-strong disabled:opacity-60",
1503
- )}
1504
- >
1505
- <ArrowUpToLineIcon className="size-3.5" />
1506
- Jump to start
1507
- </button>
1508
- ) : null}
1509
- </motion.div>
1510
- ) : null}
1511
- </AnimatePresence>
1512
- <AnimatePresence>
1513
- {loadingNewer ? (
1514
- <motion.div
1515
- initial={{ opacity: 0, y: 6 }}
1516
- animate={{ opacity: 1, y: 0 }}
1517
- exit={{ opacity: 0, y: 6 }}
1518
- transition={{ duration: 0.15, ease: "easeOut" }}
1519
- data-og-loading-newer=""
1520
- aria-live="polite"
1521
- className="pointer-events-none absolute inset-x-0 bottom-14 z-10 flex justify-center"
1522
- >
1523
- <span className="inline-flex items-center rounded-full border border-og-border bg-og-surface-3/90 px-3 py-1 text-og-control font-medium shadow-og-md backdrop-blur">
1524
- <span className="og-shimmer-text">Loading later activity…</span>
1525
- </span>
1526
- </motion.div>
1527
- ) : null}
1528
- </AnimatePresence>
1529
- <AnimatePresence>
1530
- {((!pinned && autoFollow) || hasNewer) && autoFollow ? (
1531
- <motion.button
1532
- type="button"
1533
- initial={{ opacity: 0, y: 8 }}
1534
- animate={{ opacity: 1, y: 0 }}
1535
- exit={{ opacity: 0, y: 8 }}
1536
- transition={{ duration: 0.15, ease: "easeOut" }}
1537
- onClick={() => {
1538
- disclosureKeepsUnpinnedRef.current = false;
1539
- if (hasNewer) {
1540
- // Do not pin against the current history page — its bottom
1541
- // is not the tip. The pin + snap run when the tip window
1542
- // actually lands (`hasNewer` flips false).
1543
- wantPinRef.current = true;
1544
- const node = scrollRef.current;
1545
- if (onJumpToLatest) {
1546
- void Promise.resolve(onJumpToLatest()).then(
1547
- () => {
1548
- // Covers a host that flipped hasNewer before
1549
- // resolving; otherwise the tip-window commit
1550
- // consumes the flag.
1551
- const current = scrollRef.current;
1552
- if (current && wantPinRef.current && !hasNewerRef.current) {
1553
- wantPinRef.current = false;
1554
- applyPinned(true);
1555
- snapToBottom(current);
1556
- }
1557
- },
1558
- () => {
1559
- // The tip reload failed (ordinary network error):
1560
- // an armed latch would fire a surprise snap when
1561
- // the reader later pages to the tip themselves.
1562
- wantPinRef.current = false;
1563
- },
2320
+ {/* Pinned: anchoring off so the tip-follow camera owns the motion.
2321
+ Unpinned: native scroll anchoring holds the reader's place. */}
2322
+ <div
2323
+ ref={scrollRef}
2324
+ data-og-timeline-scroller=""
2325
+ data-og-bottom-follow={autoFollow && pinned && !hasNewer ? "true" : "false"}
2326
+ tabIndex={-1}
2327
+ onScroll={onScroll}
2328
+ onScrollEnd={onScrollEnd}
2329
+ onWheel={onWheel}
2330
+ onTouchStart={(event) => {
2331
+ stopSettlement();
2332
+ const touch = event.touches.length === 1 ? event.touches[0] : undefined;
2333
+ touchPositionRef.current = touch
2334
+ ? { x: touch.clientX, y: touch.clientY }
2335
+ : null;
2336
+ }}
2337
+ onTouchMove={(event) => {
2338
+ const touch = event.touches[0];
2339
+ const previous = touchPositionRef.current;
2340
+ if (!touch || !previous || event.touches.length !== 1) {
2341
+ touchPositionRef.current = null;
2342
+ return;
2343
+ }
2344
+ const deltaX = previous.x - touch.clientX;
2345
+ const deltaY = previous.y - touch.clientY;
2346
+ if (Math.max(Math.abs(deltaX), Math.abs(deltaY)) < 4) return;
2347
+ touchPositionRef.current = { x: touch.clientX, y: touch.clientY };
2348
+ onWheel({
2349
+ deltaX,
2350
+ deltaY,
2351
+ target: event.target,
2352
+ currentTarget: event.currentTarget,
2353
+ });
2354
+ }}
2355
+ onTouchEnd={() => {
2356
+ touchPositionRef.current = null;
2357
+ }}
2358
+ onTouchCancel={() => {
2359
+ touchPositionRef.current = null;
2360
+ }}
2361
+ onClickCapture={(event) => {
2362
+ const target =
2363
+ event.target instanceof Element
2364
+ ? event.target.closest("button[aria-expanded]")
2365
+ : null;
2366
+ if (target) {
2367
+ if (target.hasAttribute("data-og-work-header")) {
2368
+ releaseProgress(
2369
+ target
2370
+ .closest("[data-og-group-key]")
2371
+ ?.getAttribute("data-og-group-key") ?? null,
1564
2372
  );
1565
- } else if (node) {
1566
- // No tip reload available: jump within the in-memory
1567
- // window so the newer sentinel can page forward; the
1568
- // latch pins if the tip window eventually lands.
1569
- snapToBottom(node);
1570
2373
  }
1571
- return;
2374
+ releasePinFromReader();
2375
+ disclosureKeepsUnpinnedRef.current = true;
1572
2376
  }
1573
- const node = scrollRef.current;
1574
- if (node) {
1575
- applyPinned(true);
1576
- snapToBottom(node);
2377
+ }}
2378
+ onFocusCapture={(event) => {
2379
+ if (readableTurns && event.target !== event.currentTarget) {
2380
+ stopSettlement();
2381
+ releasePinFromReader();
2382
+ disclosureKeepsUnpinnedRef.current = true;
1577
2383
  }
1578
2384
  }}
2385
+ onPointerDown={onPointerDown}
2386
+ onKeyDown={onKeyDown}
2387
+ style={
2388
+ groups.length > 0 && !revealed ? { visibility: "hidden" } : undefined
2389
+ }
1579
2390
  className={cn(
1580
- "absolute inset-x-0 bottom-4 mx-auto w-fit",
1581
- "inline-flex items-center gap-1.5 rounded-full border border-og-border bg-og-surface-3/90 px-3 py-1.5",
1582
- "text-og-control font-medium text-og-fg shadow-og-md backdrop-blur",
1583
- "hover:border-og-border-strong",
2391
+ // tabIndex=-1 is programmatic only — never paint a focus ring on
2392
+ // the whole scroller (click + Shift used to flash a blue outline).
2393
+ "min-h-0 flex-1 overflow-y-auto overscroll-contain px-4 pt-16 pb-6 sm:px-6 outline-hidden",
2394
+ autoFollow && pinned && !hasNewer
2395
+ ? "[overflow-anchor:none]"
2396
+ : "[overflow-anchor:auto]",
1584
2397
  )}
1585
2398
  >
1586
- <ArrowDownIcon className="size-3.5" />
1587
- Jump to latest
1588
- </motion.button>
1589
- ) : null}
1590
- </AnimatePresence>
1591
- </div>
1592
- </TooltipProvider>
1593
- </EntranceAnimationProvider>
1594
- </TimelineComputeLabelProvider>
1595
- </SeenActivityIdsProvider>
1596
- </FoldMemoryProvider>
1597
- </LightboxProvider>
2399
+ <TimelineBeforeLayout
2400
+ capture={() => {
2401
+ if (readableTurns && timelineHasReader(scrollRef.current)) {
2402
+ releasePinFromReader();
2403
+ }
2404
+ readingAnchorRef.current =
2405
+ !pinnedRef.current && scrollRef.current
2406
+ ? captureTimelineAnchor(scrollRef.current)
2407
+ : null;
2408
+ settlementRef.current =
2409
+ readableTurns &&
2410
+ autoFollow &&
2411
+ pinnedRef.current &&
2412
+ revealedRef.current &&
2413
+ !hasNewer &&
2414
+ !pendingReaderLeaveRef.current &&
2415
+ !disclosureKeepsUnpinnedRef.current &&
2416
+ !prefersReducedMotion() &&
2417
+ scrollRef.current
2418
+ ? captureTimelineSettlement(scrollRef.current, groups)
2419
+ : null;
2420
+ }}
2421
+ >
2422
+ <div className="relative mx-auto flex w-full max-w-3xl flex-col gap-5">
2423
+ <AnimatePresence>
2424
+ {loadingOlder ||
2425
+ loadingOldest ||
2426
+ (hasOlder && onJumpToStart && olderPrefetchArmed) ||
2427
+ underfillRetryReady ? (
2428
+ // Reserved top gutter: controls scroll with history and cannot
2429
+ // cover a disclosure. Visibility never changes content height.
2430
+ <motion.div
2431
+ initial={{ opacity: 0, y: -6 }}
2432
+ animate={{ opacity: 1, y: 0 }}
2433
+ exit={{ opacity: 0, y: -6 }}
2434
+ transition={{ duration: 0.15, ease: "easeOut" }}
2435
+ data-og-loading-older=""
2436
+ aria-live="polite"
2437
+ className="pointer-events-none absolute inset-x-0 -top-11 z-10 flex justify-center"
2438
+ >
2439
+ {loadingOlder || loadingOldest ? (
2440
+ <span className={LOADING_CHIP_CLASS}>
2441
+ <span className="og-shimmer-text">
2442
+ {loadingOldest
2443
+ ? "Jumping to start…"
2444
+ : "Loading earlier activity…"}
2445
+ </span>
2446
+ </span>
2447
+ ) : null}
2448
+ {hasOlder &&
2449
+ !loadingOlder &&
2450
+ !loadingOldest &&
2451
+ (underfillRetryReady || onJumpToStart) ? (
2452
+ <button
2453
+ type="button"
2454
+ data-og-retry={underfillRetryReady || undefined}
2455
+ data-og-jump-to-start={!underfillRetryReady || undefined}
2456
+ onClick={() => {
2457
+ const node = scrollRef.current;
2458
+ if (underfillRetryReady) {
2459
+ if (node && underfillRetryReadyRef.current) {
2460
+ // AnimatePresence retains this handler during
2461
+ // exit. Current authorization plus the exact
2462
+ // attempt check prevent stale dispatch.
2463
+ requestOlderIfUnderfilled(
2464
+ node,
2465
+ underfillSettledAttempt!,
2466
+ );
2467
+ }
2468
+ return;
2469
+ }
2470
+ applyPinned(false);
2471
+ pendingJumpToStartRef.current = true;
2472
+ const seq = ++jumpToStartSeqRef.current;
2473
+ void Promise.resolve(onJumpToStart!()).then(
2474
+ () => {
2475
+ // The commit that swaps in the oldest window consumes
2476
+ // the flag against the new DOM; this write covers the
2477
+ // already-committed order and the no-window-change
2478
+ // case (jumping within the current window).
2479
+ const scroller = scrollRef.current ?? node;
2480
+ if (scroller) {
2481
+ scroller.scrollTop = 0;
2482
+ }
2483
+ // A host may resolve without ever changing the
2484
+ // window (already on the oldest page). Any swap
2485
+ // commit runs its layout effect before the next
2486
+ // frame, so a flag still armed by then is the
2487
+ // no-change case — clear it, or a LATER prepend
2488
+ // would spuriously jump the reader to the top.
2489
+ requestFrame(() => {
2490
+ if (jumpToStartSeqRef.current === seq) {
2491
+ pendingJumpToStartRef.current = false;
2492
+ }
2493
+ });
2494
+ },
2495
+ () => {
2496
+ if (jumpToStartSeqRef.current === seq) {
2497
+ pendingJumpToStartRef.current = false;
2498
+ }
2499
+ },
2500
+ );
2501
+ }}
2502
+ className="pointer-events-auto rounded-full border border-og-border px-3 py-1.5 text-og-control"
2503
+ >
2504
+ {underfillRetryReady
2505
+ ? underfillSettledAttempt?.[2]?.tailPreserved
2506
+ ? "Load earlier activity"
2507
+ : "Retry earlier activity"
2508
+ : "Jump to start"}
2509
+ </button>
2510
+ ) : null}
2511
+ </motion.div>
2512
+ ) : null}
2513
+ </AnimatePresence>
2514
+ {!groups.length
2515
+ ? (emptyState ?? (
2516
+ <p className="py-10 text-center text-og-menu text-og-fg-subtle">
2517
+ No activity yet.
2518
+ </p>
2519
+ ))
2520
+ : null}
2521
+ {hasOlder && olderPrefetchArmed ? (
2522
+ // Overlaid, not a layout row: mounting/unmounting the sentinel
2523
+ // must never shift content (that shift was itself a wobble).
2524
+ // End at the scroll origin above the pt-16 gutter so the
2525
+ // observer and scrollTop cooldown share the same 400px band.
2526
+ <div
2527
+ ref={topSentinelRef}
2528
+ data-og-top-sentinel=""
2529
+ data-og-timeline-chrome=""
2530
+ aria-hidden="true"
2531
+ className="pointer-events-none absolute inset-x-0 -top-16 h-px -translate-y-full"
2532
+ />
2533
+ ) : null}
2534
+ {groups.map(({ group, key, entranceEnabled }, index) => {
2535
+ return (
2536
+ <TimelineSearchRevealContext.Provider
2537
+ key={key}
2538
+ value={
2539
+ searchItem &&
2540
+ timelineGroupItemIds(group).includes(searchItem.id)
2541
+ ? searchRevealKey
2542
+ : null
2543
+ }
2544
+ >
2545
+ <TimelineGroupEntry
2546
+ groupKey={key}
2547
+ group={group}
2548
+ nextGroup={groups[index + 1]?.group}
2549
+ startupDismissed={
2550
+ group.kind === "activity" &&
2551
+ group.items.some(
2552
+ (item) => item.turnId && turnsWithOutput.has(item.turnId),
2553
+ )
2554
+ }
2555
+ entranceEnabled={entranceEnabled}
2556
+ liveEntranceEnabled={
2557
+ group.kind === "activity" ? !bulkRender : undefined
2558
+ }
2559
+ context={timelineGroupEntryContext}
2560
+ />
2561
+ </TimelineSearchRevealContext.Provider>
2562
+ );
2563
+ })}
2564
+ {groups.length > 0 && trailingState ? (
2565
+ <div data-og-timeline-trailing-state="">{trailingState}</div>
2566
+ ) : null}
2567
+ {hasNewer && newerFailure?.key === newerBoundaryKey ? (
2568
+ <div
2569
+ data-og-newer-error=""
2570
+ className="flex flex-col items-center gap-2 px-4 py-3 text-center text-og-menu text-og-fg-muted"
2571
+ >
2572
+ <p role="status" className="max-w-prose [overflow-wrap:anywhere]">
2573
+ Couldn’t load later activity. {newerFailure.message}
2574
+ </p>
2575
+ <button
2576
+ ref={newerRetryButtonRef}
2577
+ type="button"
2578
+ data-og-retry-newer=""
2579
+ className="min-h-11 rounded-og-md border border-og-border px-3 py-2 text-og-fg hover:bg-og-surface-2 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-og-accent"
2580
+ aria-disabled={loadingNewer || newerRetryPending}
2581
+ aria-busy={newerRetryPending}
2582
+ onClick={() => requestNewer(true)}
2583
+ >
2584
+ {newerRetryPending
2585
+ ? "Retrying later activity…"
2586
+ : "Retry later activity"}
2587
+ </button>
2588
+ </div>
2589
+ ) : null}
2590
+ {hasNewer ? (
2591
+ <div
2592
+ ref={bottomSentinelRef}
2593
+ data-og-bottom-sentinel=""
2594
+ data-og-timeline-chrome=""
2595
+ aria-hidden="true"
2596
+ className="h-px w-full"
2597
+ />
2598
+ ) : null}
2599
+ </div>
2600
+ </TimelineBeforeLayout>
2601
+ </div>
2602
+ {draftAnnotations && draftAnnotations.length > 0 ? (
2603
+ <Suspense fallback={null}>
2604
+ <TimelineAnnotationMarkers
2605
+ rootRef={scrollRef}
2606
+ annotations={draftAnnotations}
2607
+ onSelect={onDraftAnnotationSelect}
2608
+ />
2609
+ </Suspense>
2610
+ ) : null}
2611
+
2612
+ <div
2613
+ data-og-timeline-nav="top"
2614
+ className={cn(
2615
+ "pointer-events-none absolute inset-x-0 z-10 flex justify-center",
2616
+ questionNav?.belowHeader ? "top-10 pointer-coarse:top-13" : "top-3",
2617
+ )}
2618
+ >
2619
+ <AnimatePresence>
2620
+ {questionNav ? (
2621
+ <motion.button
2622
+ key="question-nav"
2623
+ {...NAV_FADE}
2624
+ type="button"
2625
+ data-og-question-nav=""
2626
+ data-og-jump-to-question=""
2627
+ title="Back to your message"
2628
+ onClick={() => jumpToQuestion(questionNav.key)}
2629
+ className={NAV_BUTTON_CLASS}
2630
+ >
2631
+ <ArrowUpIcon aria-hidden className="size-4" />
2632
+ <span className="sr-only">Back to your message</span>
2633
+ </motion.button>
2634
+ ) : null}
2635
+ </AnimatePresence>
2636
+ </div>
2637
+ <div
2638
+ data-og-timeline-nav="bottom"
2639
+ className="pointer-events-none absolute inset-x-0 bottom-3 z-10 flex flex-col items-center gap-2"
2640
+ >
2641
+ <AnimatePresence>
2642
+ {loadingNewer ? (
2643
+ <motion.div
2644
+ key="loading-newer"
2645
+ {...NAV_FADE}
2646
+ data-og-loading-newer=""
2647
+ aria-live="polite"
2648
+ className="flex max-w-full justify-center"
2649
+ >
2650
+ <span className={LOADING_CHIP_CLASS}>
2651
+ <span className="og-shimmer-text">Loading later activity…</span>
2652
+ </span>
2653
+ </motion.div>
2654
+ ) : null}
2655
+ {jumpPillShown ? (
2656
+ <motion.button
2657
+ key="jump-to-latest"
2658
+ {...NAV_FADE}
2659
+ type="button"
2660
+ data-og-jump-to-latest=""
2661
+ title="Jump to latest"
2662
+ onClick={() => {
2663
+ // Returning to the tip explicitly releases reader-owned
2664
+ // prose. Clear only this timeline's selection before the
2665
+ // synchronous ownership check on the next commit.
2666
+ const viewport = scrollRef.current;
2667
+ const selection = viewport?.ownerDocument.getSelection();
2668
+ if (
2669
+ selection &&
2670
+ !selection.isCollapsed &&
2671
+ (viewport?.contains(selection.anchorNode) ||
2672
+ viewport?.contains(selection.focusNode))
2673
+ )
2674
+ selection.removeAllRanges();
2675
+ disclosureKeepsUnpinnedRef.current = false;
2676
+ if (hasNewer) {
2677
+ // Do not pin against the current history page — its bottom
2678
+ // is not the tip. The pin + snap run when the tip window
2679
+ // actually lands (`hasNewer` flips false).
2680
+ wantPinRef.current = true;
2681
+ const node = scrollRef.current;
2682
+ if (onJumpToLatest) {
2683
+ void Promise.resolve(onJumpToLatest()).then(
2684
+ () => {
2685
+ // Covers a host that flipped hasNewer before
2686
+ // resolving; otherwise the tip-window commit
2687
+ // consumes the flag.
2688
+ const current = scrollRef.current;
2689
+ if (current && wantPinRef.current && !hasNewerRef.current) {
2690
+ wantPinRef.current = false;
2691
+ applyPinned(true);
2692
+ snapToBottom(current);
2693
+ }
2694
+ },
2695
+ () => {
2696
+ // The tip reload failed (ordinary network error):
2697
+ // an armed latch would fire a surprise snap when
2698
+ // the reader later pages to the tip themselves.
2699
+ wantPinRef.current = false;
2700
+ },
2701
+ );
2702
+ } else if (node) {
2703
+ // No tip reload available: jump within the in-memory
2704
+ // window so the newer sentinel can page forward; the
2705
+ // latch pins if the tip window eventually lands.
2706
+ snapToBottom(node);
2707
+ }
2708
+ return;
2709
+ }
2710
+ const node = scrollRef.current;
2711
+ if (node) {
2712
+ applyPinned(true);
2713
+ snapToBottom(node);
2714
+ }
2715
+ }}
2716
+ className={NAV_BUTTON_CLASS}
2717
+ >
2718
+ <ArrowDownIcon aria-hidden className="size-4" />
2719
+ <span className="sr-only">Jump to latest</span>
2720
+ </motion.button>
2721
+ ) : null}
2722
+ </AnimatePresence>
2723
+ </div>
2724
+ </div>
2725
+ </TimelineAnnotationSourceRootContext.Provider>
2726
+ </TooltipProvider>
2727
+ </EntranceAnimationProvider>
2728
+ </TimelineComputeLabelProvider>
2729
+ </SeenActivityIdsProvider>
2730
+ </FoldMemoryProvider>
2731
+ </LightboxProvider>
2732
+ </AllowancePresentationProvider>
1598
2733
  );
2734
+ // Agent-authored object links resolve through the host, never the console.
2735
+ return <OpenGeniLinkProvider resolveLink={resolveLink}>{timeline}</OpenGeniLinkProvider>;
1599
2736
  }
1600
2737
 
1601
2738
  type KeyedTimelineGroup = {
1602
2739
  group: TimelineGroup;
1603
2740
  key: string;
2741
+ entranceEnabled: boolean;
1604
2742
  };
1605
2743
 
2744
+ function timelineGroupsRenderEqual(previous: TimelineGroup, next: TimelineGroup): boolean {
2745
+ try {
2746
+ return dequal(previous, next);
2747
+ } catch {
2748
+ // Consumer-owned `unknown` payloads may be proxies/getters. Equality is an
2749
+ // optimization only; a hostile comparator surface must fall back to the
2750
+ // authoritative new group rather than taking down the timeline render.
2751
+ return false;
2752
+ }
2753
+ }
2754
+
1606
2755
  /**
1607
2756
  * Projection can legitimately change a group's content-derived key while
1608
2757
  * retaining its existing rows. The common pagination case is an older activity
@@ -1611,7 +2760,10 @@ type KeyedTimelineGroup = {
1611
2760
  * committed groups by their durable item IDs so both the React key and the
1612
2761
  * progressive-window anchor survive either change.
1613
2762
  */
1614
- function useStableTimelineGroupKeys(allGroups: TimelineGroup[]): KeyedTimelineGroup[] {
2763
+ function useStableTimelineGroupKeys(
2764
+ allGroups: TimelineGroup[],
2765
+ entranceEnabled: boolean,
2766
+ ): KeyedTimelineGroup[] {
1615
2767
  const previousRef = useRef<KeyedTimelineGroup[]>([]);
1616
2768
  const keyedGroups = useMemo(() => {
1617
2769
  const previousByItemId = new Map<string, KeyedTimelineGroup>();
@@ -1624,29 +2776,53 @@ function useStableTimelineGroupKeys(allGroups: TimelineGroup[]): KeyedTimelineGr
1624
2776
  const usedKeys = new Set<string>();
1625
2777
  return allGroups.map((group, index) => {
1626
2778
  const itemIds = timelineGroupItemIds(group);
1627
- let retainedKey: string | undefined;
2779
+ let retainedGroup: KeyedTimelineGroup | undefined;
1628
2780
  for (const itemId of itemIds) {
1629
2781
  const previous = previousByItemId.get(itemId);
1630
2782
  // Retain only same-kind matches. Activity → turn wrap must NOT keep the
1631
2783
  // activity chip's React key: that reused a collapsed TurnSummary and
1632
2784
  // skipped the settle beat (insta-collapse / content flash).
1633
- if (previous && previous.group.kind === group.kind && !usedKeys.has(previous.key)) {
1634
- retainedKey = previous.key;
2785
+ const startupCompletion =
2786
+ previous?.group.kind === "activity" &&
2787
+ previous.group.items.every(
2788
+ (item) =>
2789
+ item.kind === "startup-phase" || (item.kind === "reasoning" && !item.text.trim()),
2790
+ ) &&
2791
+ group.kind === "turn" &&
2792
+ group.outcome === "complete" &&
2793
+ group.groups.every(
2794
+ (child) =>
2795
+ child.kind === "activity" &&
2796
+ child.items.every((item) => item.kind === "startup-phase"),
2797
+ );
2798
+ if (
2799
+ previous &&
2800
+ (previous.group.kind === group.kind || startupCompletion) &&
2801
+ !usedKeys.has(previous.key)
2802
+ ) {
2803
+ retainedGroup = previous;
1635
2804
  break;
1636
2805
  }
1637
2806
  }
1638
2807
 
1639
2808
  const canonicalKey = timelineGroupKey(group);
1640
- let key = retainedKey ?? canonicalKey;
2809
+ let key = retainedGroup?.key ?? canonicalKey;
1641
2810
  let collision = 0;
1642
2811
  while (usedKeys.has(key)) {
1643
2812
  key = `${canonicalKey}:${index}:${collision}`;
1644
2813
  collision += 1;
1645
2814
  }
1646
2815
  usedKeys.add(key);
1647
- return { group, key };
2816
+ return {
2817
+ group:
2818
+ retainedGroup && timelineGroupsRenderEqual(retainedGroup.group, group)
2819
+ ? retainedGroup.group
2820
+ : group,
2821
+ key,
2822
+ entranceEnabled: retainedGroup?.entranceEnabled ?? entranceEnabled,
2823
+ };
1648
2824
  });
1649
- }, [allGroups]);
2825
+ }, [allGroups, entranceEnabled]);
1650
2826
 
1651
2827
  useLayoutEffect(() => {
1652
2828
  previousRef.current = keyedGroups;
@@ -1739,17 +2915,138 @@ class TimelineGroupRenderBoundary extends Component<
1739
2915
  }
1740
2916
  }
1741
2917
 
2918
+ type TimelineGroupEntryProps = {
2919
+ groupKey: string;
2920
+ group: TimelineGroup;
2921
+ nextGroup?: TimelineGroup | undefined;
2922
+ startupDismissed: boolean;
2923
+ entranceEnabled: boolean;
2924
+ liveEntranceEnabled?: boolean | undefined;
2925
+ context: TimelineGroupEntryContext;
2926
+ };
2927
+
2928
+ type TimelineGroupEntryContext = {
2929
+ userMessageDisclosureContext: UserMessageDisclosureContextValue;
2930
+ behavior: TimelineGroupBehaviorProps;
2931
+ };
2932
+
2933
+ type TimelineGroupBehaviorProps = {
2934
+ renderMessageActions: MessageTimelineProps["renderMessageActions"];
2935
+ renderMessageText:
2936
+ | ((text: string, item: AgentMessageItem | UserMessageItem) => ReactNode)
2937
+ | undefined;
2938
+ onOpenSession: MessageTimelineProps["onOpenSession"];
2939
+ onMemoryClick: MessageTimelineProps["onMemoryClick"];
2940
+ onReconnect: MessageTimelineProps["onReconnect"];
2941
+ renderAuthNeeded: MessageTimelineProps["renderAuthNeeded"];
2942
+ resolveProviderLogo: MessageTimelineProps["resolveProviderLogo"];
2943
+ toolRegistry: ToolRegistry;
2944
+ loadRetainedScreenshot: MessageTimelineProps["loadRetainedScreenshot"];
2945
+ loadRetainedArtifact: MessageTimelineProps["loadRetainedArtifact"];
2946
+ loadVideoArtifactPlayback: MessageTimelineProps["loadVideoArtifactPlayback"];
2947
+ genieLoading: MessageTimelineProps["genieLoading"];
2948
+ turnSummary: MessageTimelineProps["turnSummary"];
2949
+ };
2950
+
2951
+ /**
2952
+ * Keep the complete settled-row shell behind one shallow memo boundary. A
2953
+ * prepend still reconciles the keyed list, but render-equivalent suffix groups
2954
+ * skip their providers, error boundaries, disclosure wrappers, and row trees.
2955
+ */
2956
+ const TimelineGroupEntry = memo(function TimelineGroupEntry({
2957
+ groupKey,
2958
+ group,
2959
+ nextGroup,
2960
+ startupDismissed,
2961
+ entranceEnabled,
2962
+ liveEntranceEnabled,
2963
+ context,
2964
+ }: TimelineGroupEntryProps) {
2965
+ const reducedMotion = useReducedMotion();
2966
+ const { behavior, userMessageDisclosureContext } = context;
2967
+ const contextCompactionCount =
2968
+ group.kind === "turn"
2969
+ ? (group.contextCompactionCount ?? 0)
2970
+ : group.kind === "activity" &&
2971
+ nextGroup?.kind === "item" &&
2972
+ nextGroup.item.kind === "context-compaction" &&
2973
+ nextGroup.item.phase === "compacted"
2974
+ ? 1
2975
+ : 0;
2976
+ const content = (
2977
+ <ActivityNoteTextContext.Provider value={behavior.renderMessageText ?? null}>
2978
+ <TimelineGroupView
2979
+ {...behavior}
2980
+ group={group}
2981
+ foldLiveCluster={isAgentProgress(nextGroup)}
2982
+ startupDismissed={startupDismissed}
2983
+ trailingAgentText={trailingAgentTextAfterTurn(group, nextGroup)}
2984
+ contextCompactionCount={contextCompactionCount > 0 ? contextCompactionCount : undefined}
2985
+ />
2986
+ </ActivityNoteTextContext.Provider>
2987
+ );
2988
+ return (
2989
+ <GenieLoadingOptionsContext.Provider value={behavior.genieLoading}>
2990
+ <div
2991
+ data-og-timeline-group-anchor=""
2992
+ data-og-group-key={groupKey}
2993
+ data-og-prompt={
2994
+ group.kind === "item" && group.item.kind === "user-message" ? "" : undefined
2995
+ }
2996
+ tabIndex={group.kind === "item" && group.item.kind === "user-message" ? -1 : undefined}
2997
+ >
2998
+ <EntranceAnimationProvider value={entranceEnabled} liveValue={liveEntranceEnabled}>
2999
+ <TimelineGroupRenderBoundary resetKeys={[group, behavior]}>
3000
+ <UserMessageDisclosureProvider value={userMessageDisclosureContext}>
3001
+ {/* Item groups never switch the preparation/content key. Keep their
3002
+ DOM shell without mounting inert presence and motion lifecycles
3003
+ for every historical message in a prepend. */}
3004
+ {group.kind === "item" ? (
3005
+ <div>{content}</div>
3006
+ ) : (
3007
+ <AnimatePresence initial={false}>
3008
+ <motion.div
3009
+ key={
3010
+ !startupDismissed &&
3011
+ group.kind === "activity" &&
3012
+ !group.work &&
3013
+ group.items.every(
3014
+ (item) =>
3015
+ item.kind === "startup-phase" ||
3016
+ (item.kind === "reasoning" && !item.text.trim()),
3017
+ )
3018
+ ? "preparation"
3019
+ : "content"
3020
+ }
3021
+ initial={false}
3022
+ exit={{ opacity: 0, height: 0 }}
3023
+ transition={{ duration: reducedMotion ? 0 : 0.2 }}
3024
+ >
3025
+ {content}
3026
+ </motion.div>
3027
+ </AnimatePresence>
3028
+ )}
3029
+ </UserMessageDisclosureProvider>
3030
+ </TimelineGroupRenderBoundary>
3031
+ </EntranceAnimationProvider>
3032
+ </div>
3033
+ </GenieLoadingOptionsContext.Provider>
3034
+ );
3035
+ });
3036
+
1742
3037
  // The full loaded window stays mounted, so settled history rows must be cheap
1743
- // on every commit: projection reuses group objects for unchanged groups, so
1744
- // memo skips them. Live projection creates a new group object, and
1745
- // behavior/callback changes are separate props, so ordinary streaming and host
1746
- // updates still invalidate immediately.
3038
+ // on every commit: the stable-key projection reuses render-equivalent group
3039
+ // objects, so memo skips them. Changed projection content receives a new group
3040
+ // object, and behavior/callback changes are separate props, so ordinary
3041
+ // streaming and host updates still invalidate immediately.
1747
3042
  const TimelineGroupView = memo(function TimelineGroupView({
1748
3043
  group,
3044
+ renderMessageActions,
1749
3045
  renderMessageText,
1750
3046
  onOpenSession,
1751
3047
  onMemoryClick,
1752
3048
  onReconnect,
3049
+ renderAuthNeeded,
1753
3050
  resolveProviderLogo,
1754
3051
  toolRegistry,
1755
3052
  loadRetainedScreenshot,
@@ -1759,16 +3056,20 @@ const TimelineGroupView = memo(function TimelineGroupView({
1759
3056
  insideTurn = false,
1760
3057
  nestClusterChips = false,
1761
3058
  foldLiveCluster = false,
3059
+ startupDismissed = false,
1762
3060
  trailingAgentText,
1763
3061
  contextCompactionCount,
1764
3062
  }: {
1765
3063
  group: TimelineGroup;
3064
+ renderMessageActions?: ((item: AgentMessageItem | UserMessageItem) => ReactNode) | undefined;
1766
3065
  renderMessageText?:
1767
3066
  | ((text: string, item: AgentMessageItem | UserMessageItem) => ReactNode)
1768
3067
  | undefined;
1769
3068
  onOpenSession?: ((sessionId: string) => void) | undefined;
1770
3069
  onMemoryClick?: ((memoryId: string) => void) | undefined;
1771
3070
  onReconnect?: ((item: AuthNeededItem) => void | Promise<void>) | undefined;
3071
+ /** Host-owned inline connection setup. Return undefined to use the default recovery card. */
3072
+ renderAuthNeeded?: ((item: AuthNeededItem) => ReactNode | undefined) | undefined;
1772
3073
  resolveProviderLogo?: ((providerDomain: string) => string | null | undefined) | undefined;
1773
3074
  toolRegistry: ToolRegistry;
1774
3075
  loadRetainedScreenshot?: RetainedScreenshotLoader | undefined;
@@ -1779,6 +3080,7 @@ const TimelineGroupView = memo(function TimelineGroupView({
1779
3080
  behind a neutral chip — the one place activity without an outcome still
1780
3081
  folds, bounding the DOM of days-long autonomous turns. */
1781
3082
  foldLiveCluster?: boolean;
3083
+ startupDismissed?: boolean;
1782
3084
  /** Rendering inside an expanded turn group: the outer chip already owns the
1783
3085
  failure surface, so nested chips stay tinted but quiet (no repeated
1784
3086
  failure text, no auto-open) — one loud error, N calm sub-expands. */
@@ -1799,16 +3101,28 @@ const TimelineGroupView = memo(function TimelineGroupView({
1799
3101
  /** Secondary chip facet when this fold sits next to a compaction landmark. */
1800
3102
  contextCompactionCount?: number | undefined;
1801
3103
  }) {
3104
+ const startupDetails = useStartupDetails();
1802
3105
  const enter = useEntranceAnimation();
1803
3106
  const settleChrome = useTurnSettleOpen();
3107
+ const previousSingleActivity = useRef<ActivityItem | undefined>(undefined);
3108
+ useLayoutEffect(() => {
3109
+ const work =
3110
+ group.kind === "activity" ? group.items.filter((item) => item.kind !== "startup-phase") : [];
3111
+ previousSingleActivity.current = work.length === 1 ? work[0] : undefined;
3112
+ }, [group]);
1804
3113
  const foldMemory = useFoldMemory();
1805
3114
  // Settled (or live-fold) activity clusters get a chip. Inside an expanded
1806
3115
  // turn that is the second layer — quiet nested chips under the outer turn
1807
3116
  // summary when contiguous activity naturally clusters (≥2 only).
3117
+ const containsPresentedImage = timelineGroupContainsPresentedImage(group);
3118
+ const hasRememberedImageFold =
3119
+ group.kind === "activity" && containsPresentedImage && foldMemory?.has(group.id);
3120
+ // Primary images stay visible through live narration and the turn wrap.
3121
+ // Manual collapse still belongs to TurnSummary's existing fold memory.
1808
3122
  const activityShouldFold =
1809
3123
  group.kind === "activity" &&
1810
- Boolean(group.outcome || (foldLiveCluster && clusterIsSettled(group)));
1811
- const containsGeneratedImage = timelineGroupContainsGeneratedImage(group);
3124
+ !containsPresentedImage &&
3125
+ !!(group.outcome || (foldLiveCluster && clusterIsSettled(group)));
1812
3126
  // Latch live→folded so a top-level shell that was already mounted open can
1813
3127
  // start the settle beat without remounting bare rail → wrapper.
1814
3128
  const liveActivitySettle = useLiveSettleFold(activityShouldFold && !insideTurn);
@@ -1817,7 +3131,7 @@ const TimelineGroupView = memo(function TimelineGroupView({
1817
3131
  group.kind === "turn" &&
1818
3132
  (group.outcome === "failed" ||
1819
3133
  timelineGroupContainsAuthNeeded(group) ||
1820
- containsGeneratedImage);
3134
+ containsPresentedImage);
1821
3135
  // activity-* → turn-* remount: carry resting state so settleFold does not
1822
3136
  // re-open a chip the reader already watched collapse.
1823
3137
  if (group.kind === "turn" && foldMemory && !insideTurn) {
@@ -1828,9 +3142,140 @@ const TimelineGroupView = memo(function TimelineGroupView({
1828
3142
  );
1829
3143
  }
1830
3144
  const settleFold =
1831
- group.kind === "turn" ? Boolean(enter && !insideTurn && !turnDefaultOpen) : liveActivitySettle;
3145
+ !turnSummary?.rolling &&
3146
+ (group.kind === "turn" ? !!(enter && !insideTurn && !turnDefaultOpen) : liveActivitySettle);
1832
3147
  switch (group.kind) {
1833
3148
  case "activity":
3149
+ if (group.work) {
3150
+ const phases = group.items.filter((item) => item.kind === "startup-phase");
3151
+ const preparing =
3152
+ !startupDetails &&
3153
+ !startupDismissed &&
3154
+ !group.work.endedAt &&
3155
+ !group.work.waiting &&
3156
+ phases.length > 0 &&
3157
+ group.items.every(
3158
+ (item) =>
3159
+ item.kind === "startup-phase" || (item.kind === "reasoning" && !item.text.trim()),
3160
+ ) &&
3161
+ !phases.some((item) => item.status === "failed" || item.status === "cancelled");
3162
+ if (preparing) {
3163
+ // Preparation is primary, never hidden in an activity disclosure.
3164
+ // Keep work.startedAt unchanged for the handoff and settled duration.
3165
+ return (
3166
+ <ActivityRail
3167
+ items={group.items}
3168
+ startupActive
3169
+ bare
3170
+ toolRegistry={toolRegistry}
3171
+ onOpenSession={onOpenSession}
3172
+ onMemoryClick={onMemoryClick}
3173
+ loadRetainedScreenshot={loadRetainedScreenshot}
3174
+ loadRetainedArtifact={loadRetainedArtifact}
3175
+ />
3176
+ );
3177
+ }
3178
+ const end = group.work.endedAt;
3179
+ const status: TurnSummaryStatus = end
3180
+ ? { kind: "worked", durationMs: durationBetween(group.work.startedAt, end) }
3181
+ : group.work.waiting
3182
+ ? { kind: "waiting", ...group.work.waiting }
3183
+ : {
3184
+ kind: "working",
3185
+ since: group.work.startedAt,
3186
+ preview: containsPresentedImage ? undefined : (
3187
+ <RollingActivity
3188
+ items={group.items}
3189
+ toolRegistry={toolRegistry}
3190
+ showCount={false}
3191
+ />
3192
+ ),
3193
+ };
3194
+ return (
3195
+ <TurnSummary
3196
+ key="work"
3197
+ items={group.items}
3198
+ status={status}
3199
+ outcome={group.outcome}
3200
+ failureText={group.failureText}
3201
+ foldKey={group.id}
3202
+ defaultOpen={
3203
+ containsPresentedImage ||
3204
+ group.outcome === "failed" ||
3205
+ phases.some((item) => item.status === "failed" || item.status === "cancelled")
3206
+ ? true
3207
+ : undefined
3208
+ }
3209
+ facets={turnSummary?.facets}
3210
+ contextCompactionCount={compactedLandmarkCount(group.work.details)}
3211
+ >
3212
+ <TurnRailFrame compact>
3213
+ {renderFoldedGroups(
3214
+ group.work.details,
3215
+ {
3216
+ renderMessageActions,
3217
+ renderMessageText,
3218
+ onOpenSession,
3219
+ onMemoryClick,
3220
+ onReconnect,
3221
+ renderAuthNeeded,
3222
+ resolveProviderLogo,
3223
+ toolRegistry,
3224
+ loadRetainedScreenshot,
3225
+ loadRetainedArtifact,
3226
+ loadVideoArtifactPlayback,
3227
+ turnSummary,
3228
+ },
3229
+ true,
3230
+ )}
3231
+ </TurnRailFrame>
3232
+ </TurnSummary>
3233
+ );
3234
+ }
3235
+ // Preparation is one quiet surface, not a fold with eight technical steps.
3236
+ // A later turn of the same exchange keeps its status row instead.
3237
+ if (
3238
+ !startupDetails &&
3239
+ !insideTurn &&
3240
+ !group.outcome &&
3241
+ group.items.every(
3242
+ (item) =>
3243
+ item.kind === "startup-phase" || (item.kind === "reasoning" && !item.text.trim()),
3244
+ )
3245
+ ) {
3246
+ return (
3247
+ <ActivityRail
3248
+ items={group.items}
3249
+ startupActive={!startupDismissed && !foldLiveCluster}
3250
+ bare
3251
+ toolRegistry={toolRegistry}
3252
+ onOpenSession={onOpenSession}
3253
+ onMemoryClick={onMemoryClick}
3254
+ loadRetainedScreenshot={loadRetainedScreenshot}
3255
+ loadRetainedArtifact={loadRetainedArtifact}
3256
+ />
3257
+ );
3258
+ }
3259
+ if (
3260
+ turnSummary?.rolling &&
3261
+ insideTurn &&
3262
+ !startupDetails &&
3263
+ !hasRememberedImageFold &&
3264
+ group.items.filter((item) => item.kind !== "startup-phase").length === 1
3265
+ ) {
3266
+ return (
3267
+ <ActivityRail
3268
+ items={group.items}
3269
+ startupActive={false}
3270
+ bare
3271
+ toolRegistry={toolRegistry}
3272
+ onOpenSession={onOpenSession}
3273
+ onMemoryClick={onMemoryClick}
3274
+ loadRetainedScreenshot={loadRetainedScreenshot}
3275
+ loadRetainedArtifact={loadRetainedArtifact}
3276
+ />
3277
+ );
3278
+ }
1834
3279
  if (insideTurn) {
1835
3280
  // Nested chips whenever the parent has ≥2 clusters. During outer settle
1836
3281
  // chrome they stay force-open so structure is visible and height stays
@@ -1842,11 +3287,22 @@ const TimelineGroupView = memo(function TimelineGroupView({
1842
3287
  // settled closed pre-wrap was showing as a CHIP, so mounting it closed
1843
3288
  // is both the stable height and the honest state — force-opening it
1844
3289
  // was the "already-collapsed cluster auto-expands at the end" reopen.
1845
- const useNestedChip = nestClusterChips && activityShouldFold && !containsGeneratedImage;
3290
+ const visibleActivity = group.items.filter((item) => item.kind !== "startup-phase");
3291
+ const singleThought =
3292
+ !startupDetails &&
3293
+ visibleActivity.length === 1 &&
3294
+ visibleActivity[0]?.kind === "reasoning";
3295
+ // Untouched images stay primary output, but a reader-owned image fold
3296
+ // must keep its shell across a multi-cluster wrap. A bare rail would
3297
+ // bypass the remembered choice; retaining either state also lets the
3298
+ // reader reopen and close the same chip after settlement.
3299
+ const useNestedChip =
3300
+ nestClusterChips && (activityShouldFold || hasRememberedImageFold) && !singleThought;
1846
3301
  if (!useNestedChip) {
1847
3302
  return (
1848
3303
  <ActivityRail
1849
3304
  items={group.items}
3305
+ {...(startupDismissed ? { startupActive: false } : {})}
1850
3306
  onOpenSession={onOpenSession}
1851
3307
  onMemoryClick={onMemoryClick}
1852
3308
  toolRegistry={toolRegistry}
@@ -1884,10 +3340,31 @@ const TimelineGroupView = memo(function TimelineGroupView({
1884
3340
  // flips settleFold (collapse) instead of remounting bare rail → wrapper.
1885
3341
  return (
1886
3342
  <TurnSummary
3343
+ // Apply the primary-output default when a rolling activity first
3344
+ // produces an image; the stable foldKey still preserves user choices.
3345
+ key={containsPresentedImage ? "primary-image" : "activity"}
1887
3346
  items={group.items}
1888
3347
  outcome={group.outcome}
1889
3348
  failureText={group.failureText}
1890
- defaultOpen={!activityShouldFold || group.outcome === "failed" ? true : undefined}
3349
+ defaultOpen={
3350
+ group.outcome === "failed" ||
3351
+ containsPresentedImage ||
3352
+ (!turnSummary?.rolling && !activityShouldFold)
3353
+ ? true
3354
+ : undefined
3355
+ }
3356
+ liveHeader={
3357
+ turnSummary?.rolling &&
3358
+ !containsPresentedImage &&
3359
+ !group.outcome &&
3360
+ !foldLiveCluster ? (
3361
+ <RollingActivity
3362
+ items={group.items}
3363
+ toolRegistry={toolRegistry}
3364
+ previousItem={previousSingleActivity.current}
3365
+ />
3366
+ ) : undefined
3367
+ }
1891
3368
  foldKey={group.id}
1892
3369
  facets={turnSummary?.facets}
1893
3370
  settleFold={settleFold}
@@ -1910,54 +3387,40 @@ const TimelineGroupView = memo(function TimelineGroupView({
1910
3387
  );
1911
3388
  case "turn": {
1912
3389
  const activityItems = flattenActivityItems(group.groups);
1913
- // Second-layer chips only when there are natural multi-cluster seams —
1914
- // otherwise the outer turn chip alone is enough ("N steps" wrapping one
1915
- // more "N steps" was the redundant double fold).
1916
- const nestClusters = foldableActivityClusterCount(group.groups) >= 2;
3390
+ if (
3391
+ !startupDetails &&
3392
+ group.outcome === "complete" &&
3393
+ group.groups.every(
3394
+ (child) =>
3395
+ child.kind === "activity" &&
3396
+ child.items.every(
3397
+ (item) => item.kind === "startup-phase" && item.status === "complete",
3398
+ ),
3399
+ )
3400
+ )
3401
+ return <ActivityRail items={activityItems} startupActive={false} bare />;
1917
3402
  const turnCopyText = collectTurnCopyText(group.groups, trailingAgentText);
1918
- const body = group.groups.map((child) => {
1919
- const key = timelineGroupKey(child);
1920
- return (
1921
- <TimelineGroupRenderBoundary
1922
- key={key}
1923
- resetKeys={[
1924
- child,
1925
- renderMessageText,
1926
- onOpenSession,
1927
- onMemoryClick,
1928
- onReconnect,
1929
- resolveProviderLogo,
1930
- toolRegistry,
1931
- loadRetainedScreenshot,
1932
- loadRetainedArtifact,
1933
- loadVideoArtifactPlayback,
1934
- turnSummary,
1935
- ]}
1936
- >
1937
- <TimelineGroupView
1938
- group={child}
1939
- renderMessageText={renderMessageText}
1940
- onOpenSession={onOpenSession}
1941
- onMemoryClick={onMemoryClick}
1942
- onReconnect={onReconnect}
1943
- resolveProviderLogo={resolveProviderLogo}
1944
- toolRegistry={toolRegistry}
1945
- loadRetainedScreenshot={loadRetainedScreenshot}
1946
- loadRetainedArtifact={loadRetainedArtifact}
1947
- loadVideoArtifactPlayback={loadVideoArtifactPlayback}
1948
- turnSummary={turnSummary}
1949
- insideTurn
1950
- nestClusterChips={nestClusters}
1951
- />
1952
- </TimelineGroupRenderBoundary>
1953
- );
3403
+ const body = renderFoldedGroups(group.groups, {
3404
+ renderMessageActions,
3405
+ renderMessageText,
3406
+ onOpenSession,
3407
+ onMemoryClick,
3408
+ onReconnect,
3409
+ renderAuthNeeded,
3410
+ resolveProviderLogo,
3411
+ toolRegistry,
3412
+ loadRetainedScreenshot,
3413
+ loadRetainedArtifact,
3414
+ loadVideoArtifactPlayback,
3415
+ turnSummary,
1954
3416
  });
3417
+ const durationMs = durationBetween(group.startedAt, group.endedAt);
1955
3418
  return (
1956
3419
  <TurnSummary
1957
3420
  items={activityItems}
1958
3421
  outcome={group.outcome}
1959
3422
  failureText={insideTurn ? undefined : group.failureText}
1960
- durationMs={durationBetween(group.startedAt, group.endedAt)}
3423
+ durationMs={durationMs}
1961
3424
  defaultOpen={turnDefaultOpen ? true : undefined}
1962
3425
  bare={insideTurn}
1963
3426
  foldKey={group.id}
@@ -1982,8 +3445,10 @@ const TimelineGroupView = memo(function TimelineGroupView({
1982
3445
  return (
1983
3446
  <TimelineRow
1984
3447
  item={group.item}
3448
+ renderMessageActions={renderMessageActions}
1985
3449
  renderMessageText={renderMessageText}
1986
3450
  onReconnect={onReconnect}
3451
+ renderAuthNeeded={renderAuthNeeded}
1987
3452
  resolveProviderLogo={resolveProviderLogo}
1988
3453
  onOpenSession={onOpenSession}
1989
3454
  loadVideoArtifactPlayback={loadVideoArtifactPlayback}
@@ -1992,6 +3457,78 @@ const TimelineGroupView = memo(function TimelineGroupView({
1992
3457
  }
1993
3458
  });
1994
3459
 
3460
+ type FoldedGroupBehavior = {
3461
+ renderMessageActions?: ((item: AgentMessageItem | UserMessageItem) => ReactNode) | undefined;
3462
+ renderMessageText?:
3463
+ | ((text: string, item: AgentMessageItem | UserMessageItem) => ReactNode)
3464
+ | undefined;
3465
+ onOpenSession?: ((sessionId: string) => void) | undefined;
3466
+ onMemoryClick?: ((memoryId: string) => void) | undefined;
3467
+ onReconnect?: ((item: AuthNeededItem) => void | Promise<void>) | undefined;
3468
+ renderAuthNeeded?: ((item: AuthNeededItem) => ReactNode | undefined) | undefined;
3469
+ resolveProviderLogo?: ((providerDomain: string) => string | null | undefined) | undefined;
3470
+ toolRegistry: ToolRegistry;
3471
+ loadRetainedScreenshot?: RetainedScreenshotLoader | undefined;
3472
+ loadRetainedArtifact?: RetainedArtifactLoader | undefined;
3473
+ loadVideoArtifactPlayback?: VideoArtifactPlaybackLoader | undefined;
3474
+ turnSummary?: TurnSummaryOptions | undefined;
3475
+ };
3476
+
3477
+ /** Children of a folded turn or exchange, each on the shared rail. */
3478
+ function renderFoldedGroups(
3479
+ groups: readonly TimelineGroup[],
3480
+ behavior: FoldedGroupBehavior,
3481
+ readableWork = false,
3482
+ ) {
3483
+ // Second-layer chips only when there are natural multi-cluster seams:
3484
+ // otherwise the outer turn chip alone is enough ("N steps" wrapping one
3485
+ // more "N steps" was the redundant double fold).
3486
+ const nestClusters = foldableActivityClusterCount(groups) >= 2;
3487
+ return groups.map((child, index) => (
3488
+ <TimelineGroupRenderBoundary
3489
+ key={timelineGroupKey(child)}
3490
+ resetKeys={[
3491
+ child,
3492
+ behavior.renderMessageActions,
3493
+ behavior.renderMessageText,
3494
+ behavior.onOpenSession,
3495
+ behavior.onMemoryClick,
3496
+ behavior.onReconnect,
3497
+ behavior.renderAuthNeeded,
3498
+ behavior.resolveProviderLogo,
3499
+ behavior.toolRegistry,
3500
+ behavior.loadRetainedScreenshot,
3501
+ behavior.loadRetainedArtifact,
3502
+ behavior.loadVideoArtifactPlayback,
3503
+ behavior.turnSummary,
3504
+ ]}
3505
+ >
3506
+ {readableWork ? (
3507
+ <div
3508
+ className={
3509
+ index > 0 && (child.kind !== "activity" || groups[index - 1]?.kind !== "activity")
3510
+ ? "mt-3"
3511
+ : undefined
3512
+ }
3513
+ >
3514
+ <TimelineGroupView {...behavior} group={child} insideTurn startupDismissed />
3515
+ </div>
3516
+ ) : (
3517
+ <TimelineGroupView {...behavior} group={child} insideTurn nestClusterChips={nestClusters} />
3518
+ )}
3519
+ </TimelineGroupRenderBoundary>
3520
+ ));
3521
+ }
3522
+
3523
+ function compactedLandmarkCount(groups: readonly TimelineGroup[]): number {
3524
+ return groups.filter(
3525
+ (group) =>
3526
+ group.kind === "item" &&
3527
+ group.item.kind === "context-compaction" &&
3528
+ group.item.phase === "compacted",
3529
+ ).length;
3530
+ }
3531
+
1995
3532
  /**
1996
3533
  * Body under a turn/activity chip. Remount flashes are gated by the timeline
1997
3534
  * seen-activity-id map (not by killing entrance): FoldBody used to force
@@ -2003,9 +3540,16 @@ function FoldBody({ children }: { children: ReactNode }) {
2003
3540
 
2004
3541
  /** Stable left rule for turn/activity bodies — always present so settle wrap
2005
3542
  never inserts or removes the rail chrome. */
2006
- function TurnRailFrame({ children }: { children: ReactNode }) {
3543
+ function TurnRailFrame({ children, compact = false }: { children: ReactNode; compact?: boolean }) {
2007
3544
  return (
2008
- <div className="flex flex-col gap-4 border-l-2 border-og-border pl-3 sm:pl-4">{children}</div>
3545
+ <div
3546
+ className={cn(
3547
+ "flex flex-col border-l-2 border-og-border pl-3 sm:pl-4",
3548
+ compact ? "gap-0.5" : "gap-4",
3549
+ )}
3550
+ >
3551
+ {children}
3552
+ </div>
2009
3553
  );
2010
3554
  }
2011
3555
 
@@ -2031,7 +3575,9 @@ function useLiveSettleFold(folded: boolean): boolean {
2031
3575
  function timelineGroupKey(group: TimelineGroup): string {
2032
3576
  switch (group.kind) {
2033
3577
  case "item":
2034
- return group.item.id;
3578
+ return group.item.kind === "user-message" && group.item.reconciliationKey
3579
+ ? group.item.reconciliationKey
3580
+ : group.item.id;
2035
3581
  case "activity":
2036
3582
  return group.id;
2037
3583
  case "turn":
@@ -2050,26 +3596,15 @@ function timelineGroupContainsAuthNeeded(group: TimelineGroup): boolean {
2050
3596
  }
2051
3597
  }
2052
3598
 
2053
- /** Generated images are primary user-visible output, not incidental activity. */
2054
- function timelineGroupContainsGeneratedImage(group: TimelineGroup): boolean {
2055
- switch (group.kind) {
2056
- case "item":
2057
- return false;
2058
- case "activity":
2059
- return group.items.some(
2060
- (item) => item.kind === "tool-call" && item.name === "generate_image",
2061
- );
2062
- case "turn":
2063
- return group.groups.some(timelineGroupContainsGeneratedImage);
2064
- }
2065
- }
2066
-
2067
3599
  function timelineGroupItemIds(group: TimelineGroup): string[] {
2068
3600
  switch (group.kind) {
2069
3601
  case "item":
2070
3602
  return [group.item.id];
2071
3603
  case "activity":
2072
- return group.items.map((item) => item.id);
3604
+ return [
3605
+ ...(group.work?.details ?? []).flatMap(timelineGroupItemIds),
3606
+ ...group.items.map((item) => item.id),
3607
+ ];
2073
3608
  case "turn":
2074
3609
  return group.groups.flatMap(timelineGroupItemIds);
2075
3610
  }
@@ -2087,7 +3622,7 @@ function isAgentProgress(next: TimelineGroup | undefined): boolean {
2087
3622
  return (
2088
3623
  next.kind === "activity" ||
2089
3624
  next.kind === "turn" ||
2090
- (next.kind === "item" && next.item.kind === "agent-message")
3625
+ (next.kind === "item" && next.item.kind === "agent-message" && next.item.text.trim().length > 0)
2091
3626
  );
2092
3627
  }
2093
3628
 
@@ -2096,7 +3631,7 @@ function isAgentProgress(next: TimelineGroup | undefined): boolean {
2096
3631
  item) can sit after the ACTIVE cluster, which must never fold mid-work. */
2097
3632
  function clusterIsSettled(group: Extract<TimelineGroup, { kind: "activity" }>): boolean {
2098
3633
  return group.items.every((item) => {
2099
- if (item.kind === "reasoning") {
3634
+ if (item.kind === "reasoning" || item.kind === "agent-message") {
2100
3635
  return !item.streaming;
2101
3636
  }
2102
3637
  // Memory writes and fleet observations are discrete, already-settled events.
@@ -2118,11 +3653,11 @@ function foldableActivityClusterCount(groups: readonly TimelineGroup[]): number
2118
3653
  return count;
2119
3654
  }
2120
3655
 
2121
- function flattenActivityItems(groups: TimelineGroup[]): ActivityItem[] {
3656
+ function flattenActivityItems(groups: readonly TimelineGroup[]): ActivityItem[] {
2122
3657
  const items: ActivityItem[] = [];
2123
3658
  for (const group of groups) {
2124
3659
  if (group.kind === "activity") {
2125
- items.push(...group.items);
3660
+ items.push(...flattenActivityItems(group.work?.details ?? []), ...group.items);
2126
3661
  } else if (group.kind === "turn") {
2127
3662
  items.push(...flattenActivityItems(group.groups));
2128
3663
  }
@@ -2139,6 +3674,18 @@ function collectAgentMessageText(groups: readonly TimelineGroup[]): string {
2139
3674
  if (text.length > 0) {
2140
3675
  parts.push(text);
2141
3676
  }
3677
+ } else if (group.kind === "activity") {
3678
+ // Commentary folded into clusters is still the assistant's own prose.
3679
+ const nested = collectAgentMessageText(group.work?.details ?? []);
3680
+ if (nested.length > 0) {
3681
+ parts.push(nested);
3682
+ }
3683
+ for (const item of group.items) {
3684
+ const text = item.kind === "agent-message" ? item.text.trim() : "";
3685
+ if (text.length > 0) {
3686
+ parts.push(text);
3687
+ }
3688
+ }
2142
3689
  } else if (group.kind === "turn") {
2143
3690
  const nested = collectAgentMessageText(group.groups);
2144
3691
  if (nested.length > 0) {
@@ -2159,6 +3706,9 @@ function collectTurnIdsFromGroups(groups: readonly TimelineGroup[]): Set<string>
2159
3706
  ids.add(turnId);
2160
3707
  }
2161
3708
  } else if (group.kind === "activity") {
3709
+ for (const nested of collectTurnIdsFromGroups(group.work?.details ?? [])) {
3710
+ ids.add(nested);
3711
+ }
2162
3712
  for (const item of group.items) {
2163
3713
  if (item.turnId) {
2164
3714
  ids.add(item.turnId);
@@ -2188,7 +3738,7 @@ export function trailingAgentTextAfterTurn(
2188
3738
  if (next?.kind === "item" && next.item.kind === "agent-message") {
2189
3739
  const turnIds = collectTurnIdsFromGroups(group.groups);
2190
3740
  // Degenerate body with no turnIds: do not guess — safer than lifting wrong.
2191
- if (turnIds.size === 0) {
3741
+ if (!turnIds.size) {
2192
3742
  return undefined;
2193
3743
  }
2194
3744
  if (!next.item.turnId || !turnIds.has(next.item.turnId)) {
@@ -2207,7 +3757,7 @@ function collectTurnCopyText(
2207
3757
  const parts = [collectAgentMessageText(groups), trailingAgentText?.trim() ?? ""].filter(
2208
3758
  (part) => part.length > 0,
2209
3759
  );
2210
- if (parts.length === 0) {
3760
+ if (!parts.length) {
2211
3761
  return undefined;
2212
3762
  }
2213
3763
  return parts.join("\n\n");
@@ -2231,26 +3781,45 @@ function durationBetween(startedAt: string, endedAt: string): number | undefined
2231
3781
  */
2232
3782
  export function TimelineRow({
2233
3783
  item,
3784
+ renderMessageActions,
2234
3785
  renderMessageText,
2235
3786
  onReconnect,
3787
+ renderAuthNeeded,
2236
3788
  resolveProviderLogo,
2237
3789
  onOpenSession,
2238
3790
  loadVideoArtifactPlayback,
2239
3791
  }: {
2240
3792
  item: TimelineItem;
3793
+ renderMessageActions?: ((item: AgentMessageItem | UserMessageItem) => ReactNode) | undefined;
2241
3794
  renderMessageText?:
2242
3795
  | ((text: string, item: AgentMessageItem | UserMessageItem) => ReactNode)
2243
3796
  | undefined;
2244
3797
  onReconnect?: ((item: AuthNeededItem) => void | Promise<void>) | undefined;
3798
+ /** Host-owned inline connection setup. Return undefined to use the default recovery card. */
3799
+ renderAuthNeeded?: ((item: AuthNeededItem) => ReactNode | undefined) | undefined;
2245
3800
  resolveProviderLogo?: ((providerDomain: string) => string | null | undefined) | undefined;
2246
3801
  onOpenSession?: ((sessionId: string) => void) | undefined;
2247
3802
  loadVideoArtifactPlayback?: VideoArtifactPlaybackLoader | undefined;
2248
3803
  }) {
2249
3804
  switch (item.kind) {
2250
3805
  case "user-message":
2251
- return <UserMessageRow item={item} renderMessageText={renderMessageText} />;
3806
+ return (
3807
+ <UserMessageRow
3808
+ item={item}
3809
+ renderMessageActions={renderMessageActions}
3810
+ renderMessageText={renderMessageText}
3811
+ />
3812
+ );
3813
+ case "human-input":
3814
+ return <HumanInputConversationRow item={item} />;
2252
3815
  case "agent-message":
2253
- return <AgentMessageRow item={item} renderMessageText={renderMessageText} />;
3816
+ return (
3817
+ <AgentMessageRow
3818
+ item={item}
3819
+ renderMessageActions={renderMessageActions}
3820
+ renderMessageText={renderMessageText}
3821
+ />
3822
+ );
2254
3823
  case "worker-completion":
2255
3824
  return <WorkerCompletionRow item={item} onOpenSession={onOpenSession} />;
2256
3825
  case "session-status":
@@ -2259,7 +3828,11 @@ export function TimelineRow({
2259
3828
  return <GoalRow item={item} />;
2260
3829
  case "machine-input-batch":
2261
3830
  return (
2262
- <MachineInputBatchRow item={item} loadVideoArtifactPlayback={loadVideoArtifactPlayback} />
3831
+ <MachineInputBatchRow
3832
+ item={item}
3833
+ onOpenSession={onOpenSession}
3834
+ loadVideoArtifactPlayback={loadVideoArtifactPlayback}
3835
+ />
2263
3836
  );
2264
3837
  case "notice":
2265
3838
  return <NoticeRow item={item} />;
@@ -2267,11 +3840,13 @@ export function TimelineRow({
2267
3840
  return <CompactionRow item={item} />;
2268
3841
  case "auth-needed":
2269
3842
  return (
2270
- <AuthNeededRow
2271
- item={item}
2272
- onReconnect={onReconnect}
2273
- resolveProviderLogo={resolveProviderLogo}
2274
- />
3843
+ renderAuthNeeded?.(item) ?? (
3844
+ <AuthNeededRow
3845
+ item={item}
3846
+ onReconnect={onReconnect}
3847
+ resolveProviderLogo={resolveProviderLogo}
3848
+ />
3849
+ )
2275
3850
  );
2276
3851
  default:
2277
3852
  return null;
@@ -2290,11 +3865,11 @@ function CompactionRow({ item }: { item: ContextCompactionItem }) {
2290
3865
  const trigger =
2291
3866
  item.trigger && item.phase !== "started" ? COMPACTION_TRIGGER_LABEL[item.trigger] : null;
2292
3867
  const before =
2293
- item.estimatedTokensBefore !== null
3868
+ item.estimatedTokensBefore != null
2294
3869
  ? Math.round(item.estimatedTokensBefore).toLocaleString("en-US")
2295
3870
  : null;
2296
3871
  const after =
2297
- item.estimatedTokensAfter !== null
3872
+ item.estimatedTokensAfter != null
2298
3873
  ? Math.round(item.estimatedTokensAfter).toLocaleString("en-US")
2299
3874
  : null;
2300
3875
  const title =
@@ -2309,14 +3884,14 @@ function CompactionRow({ item }: { item: ContextCompactionItem }) {
2309
3884
  item.phase === "compacted"
2310
3885
  ? "Chat history above is unchanged"
2311
3886
  : item.phase === "skipped"
2312
- ? compactionSkipSubtitle(item.skipReason)
3887
+ ? compactionSkipSubtitle(item.skipReason, item.providerRejection)
2313
3888
  : null;
2314
3889
  const pill =
2315
3890
  item.phase === "skipped" && item.skipReason === "summarization_failed"
2316
3891
  ? "border-og-status-failed/35 bg-og-status-failed/10 text-og-status-failed"
2317
3892
  : item.phase === "started"
2318
- ? "border-og-status-waiting/35 bg-og-status-waiting/10 text-og-status-waiting"
2319
- : "border-og-border bg-og-surface-1 text-og-fg-muted";
3893
+ ? WAITING_PILL_CLASS
3894
+ : NEUTRAL_PILL;
2320
3895
  return (
2321
3896
  <div className={cn(enter && "animate-og-enter", "flex justify-center")}>
2322
3897
  <div
@@ -2339,21 +3914,6 @@ function CompactionRow({ item }: { item: ContextCompactionItem }) {
2339
3914
  );
2340
3915
  }
2341
3916
 
2342
- function compactionSkipSubtitle(reason: string | null): string {
2343
- switch (reason) {
2344
- case "no_history":
2345
- return "No active history to compact";
2346
- case "replacement_not_smaller":
2347
- return "Checkpoint would not reduce memory size";
2348
- case "replacement_unchanged":
2349
- return "Checkpoint made no progress";
2350
- case "summarization_failed":
2351
- return "Request it again to retry. Chat history is unchanged.";
2352
- default:
2353
- return "Compaction was not needed. Chat history is unchanged.";
2354
- }
2355
- }
2356
-
2357
3917
  /** Hover-reveal clock beside the copy control (sent / finished). */
2358
3918
  function MessageFooterTime({ occurredAt }: { occurredAt: string }) {
2359
3919
  return (
@@ -2372,22 +3932,17 @@ function MessageFooterTime({ occurredAt }: { occurredAt: string }) {
2372
3932
 
2373
3933
  function UserMessageRow({
2374
3934
  item,
3935
+ renderMessageActions,
2375
3936
  renderMessageText,
2376
3937
  }: {
2377
3938
  item: UserMessageItem;
3939
+ renderMessageActions?: ((item: AgentMessageItem | UserMessageItem) => ReactNode) | undefined;
2378
3940
  renderMessageText?:
2379
3941
  | ((text: string, item: AgentMessageItem | UserMessageItem) => ReactNode)
2380
3942
  | undefined;
2381
3943
  }) {
2382
3944
  const enter = useEntranceAnimation();
2383
- const deliveryLabel =
2384
- item.delivery?.state === "sending"
2385
- ? "Sending…"
2386
- : item.delivery?.state === "queued"
2387
- ? "Queued"
2388
- : item.delivery?.state === "failed"
2389
- ? "Not sent"
2390
- : null;
3945
+ const deliveryFailed = item.delivery?.state === "failed";
2391
3946
  return (
2392
3947
  <div className={cn(enter && "animate-og-enter", "flex justify-end")}>
2393
3948
  <div className="flex max-w-[85%] min-w-0 flex-col items-end gap-1">
@@ -2400,11 +3955,19 @@ function UserMessageRow({
2400
3955
  }
2401
3956
  label="Copy message"
2402
3957
  className="w-fit max-w-full min-w-0"
2403
- trailing={<MessageFooterTime occurredAt={item.occurredAt} />}
3958
+ trailing={
3959
+ <>
3960
+ {renderMessageActions?.(item)}
3961
+ <MessageFooterTime occurredAt={item.occurredAt} />
3962
+ </>
3963
+ }
2404
3964
  >
2405
- <div className="w-fit max-w-full min-w-0 rounded-og-lg rounded-br-og-xs border border-og-border bg-og-surface-2 px-4 py-2.5 text-og-md leading-6 text-og-fg">
3965
+ <div className={MESSAGE_BUBBLE_CLASS}>
2406
3966
  {item.text ? (
2407
- <div data-og-annotation-source-key={item.annotationSource?.eventId}>
3967
+ <div
3968
+ data-og-search-item={item.id}
3969
+ data-og-annotation-source-key={item.annotationSource?.eventId}
3970
+ >
2408
3971
  {renderMessageText ? (
2409
3972
  renderMessageText(item.text, item)
2410
3973
  ) : (
@@ -2415,34 +3978,53 @@ function UserMessageRow({
2415
3978
  </div>
2416
3979
  ) : null}
2417
3980
  {(item.annotations?.length ?? 0) > 0 ? (
2418
- <TimelineAnnotationsChip
3981
+ <TimelineAnnotationCards
2419
3982
  annotations={item.annotations ?? []}
2420
3983
  className={item.text ? "mt-2" : undefined}
2421
3984
  />
2422
3985
  ) : null}
2423
3986
  </div>
2424
3987
  </CopyHoverFrame>
2425
- {deliveryLabel ? (
2426
- <div className="flex max-w-full items-center gap-2 px-1 text-og-xs text-og-fg-subtle">
2427
- <span title={item.delivery?.error}>{deliveryLabel}</span>
2428
- {item.delivery?.state === "failed" && item.delivery.onRetry ? (
2429
- <button
2430
- type="button"
2431
- className="font-medium text-og-fg underline decoration-og-border underline-offset-2 hover:text-og-fg-strong"
2432
- onClick={item.delivery.onRetry}
2433
- >
2434
- Retry
2435
- </button>
2436
- ) : null}
2437
- {item.delivery?.state === "failed" && item.delivery.onRemove ? (
2438
- <button
2439
- type="button"
2440
- className="font-medium text-og-fg-subtle underline decoration-og-border underline-offset-2 hover:text-og-fg"
2441
- onClick={item.delivery.onRemove}
2442
- >
2443
- Remove
2444
- </button>
2445
- ) : null}
3988
+ {deliveryFailed ? (
3989
+ <div
3990
+ role="status"
3991
+ className="flex max-w-full flex-col items-start gap-1 px-1 text-og-xs text-og-status-failed"
3992
+ >
3993
+ <span className="flex min-w-0 items-start gap-1">
3994
+ <TriangleAlertIcon className="size-3.5 shrink-0" aria-hidden="true" />
3995
+ <span className="min-w-0 break-words">
3996
+ {item.delivery?.error || "Message not sent"}
3997
+ </span>
3998
+ </span>
3999
+ <div className="flex flex-wrap items-center gap-2">
4000
+ {item.delivery?.onEdit ? (
4001
+ <button
4002
+ type="button"
4003
+ className="font-medium text-og-status-failed underline decoration-og-status-failed/50 underline-offset-2 hover:text-og-fg"
4004
+ onClick={item.delivery.onEdit}
4005
+ >
4006
+ Edit message
4007
+ </button>
4008
+ ) : null}
4009
+ {item.delivery?.onRetry ? (
4010
+ <button
4011
+ type="button"
4012
+ className="font-medium text-og-status-failed underline decoration-og-status-failed/50 underline-offset-2 hover:text-og-fg"
4013
+ onClick={item.delivery.onRetry}
4014
+ >
4015
+ Retry
4016
+ </button>
4017
+ ) : null}
4018
+ {item.delivery?.onRemove ? (
4019
+ <button
4020
+ type="button"
4021
+ className="font-medium text-og-fg-subtle underline decoration-og-border underline-offset-2 hover:text-og-fg"
4022
+ onClick={item.delivery.onRemove}
4023
+ >
4024
+ Remove
4025
+ </button>
4026
+ ) : null}
4027
+ </div>
2446
4028
  </div>
2447
4029
  ) : null}
2448
4030
  </div>
@@ -2450,11 +4032,146 @@ function UserMessageRow({
2450
4032
  );
2451
4033
  }
2452
4034
 
4035
+ function HumanInputConversationRow({ item }: { item: HumanInputItem }) {
4036
+ const enter = useEntranceAnimation();
4037
+ const multipleQuestions = Math.max(item.questions.length, item.answers.length) > 1;
4038
+ const questionNumberById = new Map(
4039
+ item.questions.map((question, index) => [question.id, index + 1]),
4040
+ );
4041
+ const settledLabel =
4042
+ item.response.outcome === "answered"
4043
+ ? "You answered"
4044
+ : item.response.outcome === "skipped"
4045
+ ? "Skipped"
4046
+ : item.response.outcome === "expired"
4047
+ ? "Expired"
4048
+ : "Cancelled";
4049
+ const copyText = humanInputConversationCopyText(item, settledLabel);
4050
+
4051
+ return (
4052
+ <div
4053
+ className={cn(enter && "animate-og-enter", "flex w-full flex-col gap-2.5")}
4054
+ data-human-input-history={item.requestId}
4055
+ >
4056
+ <div className="flex max-w-[90%] items-start gap-3 rounded-og-lg rounded-bl-og-xs border border-og-border bg-og-surface-1 px-3.5 py-3 sm:max-w-[82%]">
4057
+ <span className="mt-0.5 inline-flex size-8 shrink-0 items-center justify-center rounded-og-md bg-og-status-waiting/10 text-og-status-waiting">
4058
+ <MessageCircleQuestionIcon aria-hidden="true" className="size-4" />
4059
+ </span>
4060
+ <div className="min-w-0 flex-1">
4061
+ <p className="text-og-xs font-medium text-og-fg-subtle">Agent asked</p>
4062
+ <div className="mt-1.5 space-y-3">
4063
+ {item.questions.length > 0 ? (
4064
+ item.questions.map((question, index) => (
4065
+ <div key={question.id}>
4066
+ {question.label ? (
4067
+ <p className="text-og-sm font-semibold text-og-fg">
4068
+ {multipleQuestions ? (
4069
+ <span className="mr-1.5 tabular-nums text-og-fg-muted">{index + 1}.</span>
4070
+ ) : null}
4071
+ {multipleQuestions ? " " : null}
4072
+ {question.label}
4073
+ </p>
4074
+ ) : null}
4075
+ <p
4076
+ className={cn(
4077
+ "text-og-md leading-6 text-og-fg",
4078
+ question.label && "mt-0.5 text-og-sm text-og-fg-muted",
4079
+ )}
4080
+ >
4081
+ {!question.label && multipleQuestions ? (
4082
+ <span className="mr-1.5 tabular-nums text-og-fg-muted">{index + 1}.</span>
4083
+ ) : null}
4084
+ {!question.label && multipleQuestions ? " " : null}
4085
+ {question.prompt}
4086
+ </p>
4087
+ </div>
4088
+ ))
4089
+ ) : (
4090
+ <p className="text-og-sm text-og-fg-muted">The agent requested structured input.</p>
4091
+ )}
4092
+ </div>
4093
+ </div>
4094
+ </div>
4095
+
4096
+ <div className="flex justify-end">
4097
+ <CopyHoverFrame
4098
+ copyText={copyText}
4099
+ label="Copy answer"
4100
+ className="w-fit max-w-[90%] min-w-0 sm:max-w-[82%]"
4101
+ trailing={<MessageFooterTime occurredAt={item.occurredAt} />}
4102
+ >
4103
+ <div className={MESSAGE_BUBBLE_CLASS}>
4104
+ <p className="text-og-xs font-medium text-og-fg-subtle">{settledLabel}</p>
4105
+ {item.response.outcome === "answered" ? (
4106
+ <div className="mt-1.5 space-y-3">
4107
+ {item.answers.length > 0 ? (
4108
+ item.answers.map((answer, answerIndex) => (
4109
+ <div key={answer.questionId}>
4110
+ {multipleQuestions ? (
4111
+ <p className="text-og-sm font-semibold text-og-fg">
4112
+ <span className="mr-1.5 tabular-nums text-og-fg-muted">
4113
+ {questionNumberById.get(answer.questionId) ?? answerIndex + 1}.
4114
+ </span>{" "}
4115
+ {answer.label}
4116
+ </p>
4117
+ ) : null}
4118
+ <div className={cn("text-og-md text-og-fg", multipleQuestions && "mt-0.5")}>
4119
+ {answer.values.length > 1 ? (
4120
+ <ul className="list-disc space-y-0.5 pl-5">
4121
+ {answer.values.map((value) => (
4122
+ <li key={`${answer.questionId}-${value}`}>{value}</li>
4123
+ ))}
4124
+ </ul>
4125
+ ) : (
4126
+ <p>{answer.values[0] || "Answered"}</p>
4127
+ )}
4128
+ </div>
4129
+ </div>
4130
+ ))
4131
+ ) : (
4132
+ <p>Answered</p>
4133
+ )}
4134
+ </div>
4135
+ ) : null}
4136
+ </div>
4137
+ </CopyHoverFrame>
4138
+ </div>
4139
+ </div>
4140
+ );
4141
+ }
4142
+
4143
+ function humanInputConversationCopyText(item: HumanInputItem, settledLabel: string): string {
4144
+ const questions = item.questions
4145
+ .map(
4146
+ (question, index) =>
4147
+ `${item.questions.length > 1 ? `${index + 1}. ` : ""}${question.label || "Question"}: ${question.prompt}`,
4148
+ )
4149
+ .join("\n\n");
4150
+ const questionNumberById = new Map(
4151
+ item.questions.map((question, index) => [question.id, index + 1]),
4152
+ );
4153
+ const answers = item.answers
4154
+ .map(
4155
+ (answer, index) =>
4156
+ `${
4157
+ item.questions.length > 1
4158
+ ? `${questionNumberById.get(answer.questionId) ?? index + 1}. `
4159
+ : ""
4160
+ }${answer.label}: ${answer.values.join(", ") || "Answered"}`,
4161
+ )
4162
+ .join("\n\n");
4163
+ return [questions, `${settledLabel}${answers ? `\n\n${answers}` : ""}`]
4164
+ .filter(Boolean)
4165
+ .join("\n\n");
4166
+ }
4167
+
2453
4168
  function AgentMessageRow({
2454
4169
  item,
4170
+ renderMessageActions,
2455
4171
  renderMessageText,
2456
4172
  }: {
2457
4173
  item: AgentMessageItem;
4174
+ renderMessageActions?: ((item: AgentMessageItem | UserMessageItem) => ReactNode) | undefined;
2458
4175
  renderMessageText?:
2459
4176
  | ((text: string, item: AgentMessageItem | UserMessageItem) => ReactNode)
2460
4177
  | undefined;
@@ -2470,17 +4187,28 @@ function AgentMessageRow({
2470
4187
  // While streaming, copy is still useful (current text) but keep chrome calm —
2471
4188
  // stamp only after the message finishes (occurredAt tracks completion).
2472
4189
  return (
2473
- <div data-og-annotation-source-key={item.annotationSource?.eventId}>
2474
- <CopyHoverFrame
2475
- copyText={item.text}
2476
- label="Copy message"
2477
- align="start"
2478
- className={cn(enter && "animate-og-enter", "min-w-0 text-og-md leading-7 text-og-fg")}
2479
- trailing={item.streaming ? null : <MessageFooterTime occurredAt={item.occurredAt} />}
4190
+ <CopyHoverFrame
4191
+ copyText={item.text}
4192
+ label="Copy message"
4193
+ align="start"
4194
+ className={cn(enter && "animate-og-enter", "min-w-0 text-og-md leading-7 text-og-fg")}
4195
+ trailing={
4196
+ item.streaming ? null : (
4197
+ <>
4198
+ {renderMessageActions?.(item)}
4199
+ <MessageFooterTime occurredAt={item.occurredAt} />
4200
+ </>
4201
+ )
4202
+ }
4203
+ >
4204
+ <div
4205
+ data-og-wide-table-message=""
4206
+ data-og-search-item={item.id}
4207
+ data-og-annotation-source-key={item.annotationSource?.eventId}
2480
4208
  >
2481
4209
  {body}
2482
- </CopyHoverFrame>
2483
- </div>
4210
+ </div>
4211
+ </CopyHoverFrame>
2484
4212
  );
2485
4213
  }
2486
4214
 
@@ -2555,9 +4283,7 @@ function WorkerCompletionRow({
2555
4283
  // payloads can carry a leftover pausedReason/rationale from earlier in the
2556
4284
  // worker's life, and a "Worker completed" card must not show a pause section.
2557
4285
  const showPausedReason =
2558
- item.childStatus !== "failed" &&
2559
- item.goalStatus === "paused" &&
2560
- Boolean(item.pausedReason?.trim());
4286
+ item.childStatus !== "failed" && item.goalStatus === "paused" && !!item.pausedReason?.trim();
2561
4287
  const details: { label: string; value: string; muted?: boolean }[] = [
2562
4288
  ...(item.text.trim() ? [{ label: "Report", value: item.text.trim() }] : []),
2563
4289
  ...(item.evidence?.trim()
@@ -2640,7 +4366,11 @@ function WorkerCompletionRow({
2640
4366
  );
2641
4367
  }
2642
4368
 
2643
- function SessionStatusRow({ item }: { item: { status: SessionStatus; occurredAt: string } }) {
4369
+ function SessionStatusRow({
4370
+ item,
4371
+ }: {
4372
+ item: { status: SessionStatus; occurredAt: string; resolvedAt?: string };
4373
+ }) {
2644
4374
  const enter = useEntranceAnimation();
2645
4375
  const meta = SESSION_STATUS_META[item.status];
2646
4376
  return (
@@ -2653,8 +4383,9 @@ function SessionStatusRow({ item }: { item: { status: SessionStatus; occurredAt:
2653
4383
  >
2654
4384
  <span className="h-px flex-1 bg-og-border" />
2655
4385
  <span className="inline-flex items-center gap-1.5">
2656
- <StatusDot status={item.status} className="size-1" />
2657
- {meta.label.toLowerCase()} · {formatRelativeTime(item.occurredAt)}
4386
+ {item.resolvedAt ? null : <StatusDot status={item.status} className="size-1" />}
4387
+ {item.resolvedAt ? "work resumed" : meta.label.toLowerCase()} ·{" "}
4388
+ {formatRelativeTime(item.resolvedAt ?? item.occurredAt)}
2658
4389
  </span>
2659
4390
  <span className="h-px flex-1 bg-og-border" />
2660
4391
  </div>
@@ -2682,36 +4413,46 @@ type GoalMeta = { label: string; pill: string; icon: ComponentType<{ className?:
2682
4413
 
2683
4414
  const NEUTRAL_PILL = "border-og-border bg-og-surface-1 text-og-fg-muted";
2684
4415
 
2685
- const GOAL_META: Record<GoalItem["action"], GoalMeta> = {
2686
- set: {
2687
- label: "Goal set",
2688
- pill: "border-og-accent/30 bg-og-accent/10 text-og-accent",
2689
- icon: TargetIcon,
2690
- },
2691
- updated: { label: "Goal updated", pill: NEUTRAL_PILL, icon: PencilLineIcon },
2692
- completed: {
2693
- label: "Goal completed",
2694
- pill: "border-og-status-idle/30 bg-og-status-idle/10 text-og-status-idle",
2695
- icon: CheckIcon,
2696
- },
2697
- paused: {
2698
- label: "Goal paused",
2699
- pill: "border-og-status-waiting/35 bg-og-status-waiting/10 text-og-status-waiting",
2700
- icon: PauseIcon,
2701
- },
2702
- resumed: { label: "Goal resumed", pill: NEUTRAL_PILL, icon: PlayIcon },
2703
- cleared: { label: "Goal cleared", pill: NEUTRAL_PILL, icon: Trash2Icon },
2704
- continuation: { label: "Continuing toward the goal", pill: NEUTRAL_PILL, icon: ArrowRightIcon },
2705
- };
4416
+ // Shared production chunks can be cyclic. Resolve icon bindings during render,
4417
+ // after their modules initialize, rather than permanently capturing undefined.
4418
+ function goalMeta(action: GoalItem["action"]): GoalMeta {
4419
+ const metadata: Record<GoalItem["action"], GoalMeta> = {
4420
+ set: {
4421
+ label: "Goal set",
4422
+ pill: "border-og-accent/30 bg-og-accent/10 text-og-accent",
4423
+ icon: TargetIcon,
4424
+ },
4425
+ updated: { label: "Goal updated", pill: NEUTRAL_PILL, icon: PencilLineIcon },
4426
+ completed: {
4427
+ label: "Goal completed",
4428
+ pill: "border-og-status-idle/30 bg-og-status-idle/10 text-og-status-idle",
4429
+ icon: CheckIcon,
4430
+ },
4431
+ paused: {
4432
+ label: "Goal paused",
4433
+ pill: WAITING_PILL_CLASS,
4434
+ icon: PauseIcon,
4435
+ },
4436
+ resumed: { label: "Goal resumed", pill: NEUTRAL_PILL, icon: PlayIcon },
4437
+ cleared: { label: "Goal cleared", pill: NEUTRAL_PILL, icon: Trash2Icon },
4438
+ held: {
4439
+ label: "Goal held",
4440
+ pill: WAITING_PILL_CLASS,
4441
+ icon: PauseCircleIcon,
4442
+ },
4443
+ continuation: { label: "Continuing toward the goal", pill: NEUTRAL_PILL, icon: ArrowRightIcon },
4444
+ };
4445
+ return metadata[action];
4446
+ }
2706
4447
 
2707
4448
  /**
2708
4449
  * A goal landmark pill. Resolves its label, accent/tone, and glyph from
2709
- * {@link GOAL_META} so all six actions are visually distinguishable while the
4450
+ * {@link goalMeta} so all six actions are visually distinguishable while the
2710
4451
  * palette stays restrained — see that table for the per-action rationale.
2711
4452
  */
2712
4453
  function GoalRow({ item }: { item: GoalItem }) {
2713
4454
  const enter = useEntranceAnimation();
2714
- const { label, pill, icon: Icon } = GOAL_META[item.action];
4455
+ const { label, pill, icon: Icon } = goalMeta(item.action);
2715
4456
  return (
2716
4457
  <div className={cn(enter && "animate-og-enter", "flex justify-center")}>
2717
4458
  <span
@@ -2732,9 +4473,11 @@ function GoalRow({ item }: { item: GoalItem }) {
2732
4473
 
2733
4474
  function MachineInputBatchRow({
2734
4475
  item,
4476
+ onOpenSession,
2735
4477
  loadVideoArtifactPlayback,
2736
4478
  }: {
2737
4479
  item: MachineInputBatchItem;
4480
+ onOpenSession?: ((sessionId: string) => void) | undefined;
2738
4481
  loadVideoArtifactPlayback?: VideoArtifactPlaybackLoader | undefined;
2739
4482
  }) {
2740
4483
  const enter = useEntranceAnimation();
@@ -2750,11 +4493,15 @@ function MachineInputBatchRow({
2750
4493
  }
2751
4494
  const singleSummary = single ? cleanMachineInputSummary(single.summary) : "";
2752
4495
  const showCollapsedSummary =
2753
- single != null && machineInputSummaryIsUseful(single.kind, singleSummary);
4496
+ !item.compact && single != null && machineInputSummaryIsUseful(single.kind, singleSummary);
2754
4497
 
2755
4498
  return (
2756
4499
  <div className={cn(enter && "animate-og-enter", "flex flex-col items-center gap-1.5")}>
2757
- <details className="group w-full max-w-full" data-og-machine-input-batch="">
4500
+ <details
4501
+ className="group w-full max-w-full"
4502
+ data-og-machine-input-batch=""
4503
+ title={`Received ${formatClockTime(item.occurredAt)}`}
4504
+ >
2758
4505
  <summary className="flex cursor-pointer list-none justify-center [&::-webkit-details-marker]:hidden">
2759
4506
  <span
2760
4507
  className={cn(
@@ -2770,10 +4517,14 @@ function MachineInputBatchRow({
2770
4517
  </span>
2771
4518
  </summary>
2772
4519
  <div className="mx-auto mt-2 w-full max-w-lg space-y-2 border-t border-og-border/50 pt-2">
4520
+ <p className="text-og-xs text-og-fg-subtle">
4521
+ Received <time dateTime={item.occurredAt}>{formatClockTime(item.occurredAt)}</time>
4522
+ </p>
2773
4523
  {item.members.map((member) => (
2774
4524
  <MachineInputRow
2775
4525
  key={member.id}
2776
4526
  member={member}
4527
+ onOpenSession={onOpenSession}
2777
4528
  loadVideoArtifactPlayback={loadVideoArtifactPlayback}
2778
4529
  />
2779
4530
  ))}
@@ -2790,9 +4541,11 @@ function MachineInputBatchRow({
2790
4541
 
2791
4542
  function MachineInputRow({
2792
4543
  member,
4544
+ onOpenSession,
2793
4545
  loadVideoArtifactPlayback,
2794
4546
  }: {
2795
4547
  member: MachineInputBatchItem["members"][number];
4548
+ onOpenSession?: ((sessionId: string) => void) | undefined;
2796
4549
  loadVideoArtifactPlayback?: VideoArtifactPlaybackLoader | undefined;
2797
4550
  }) {
2798
4551
  if (member.kind === "media_generation_result" && member.result) {
@@ -2819,6 +4572,11 @@ function MachineInputRow({
2819
4572
  {truncate(summary, 320)}
2820
4573
  </p>
2821
4574
  ) : null}
4575
+ <ChildSessionLink
4576
+ kind={member.kind}
4577
+ sourceId={member.sourceId}
4578
+ onOpenSession={onOpenSession}
4579
+ />
2822
4580
  </div>
2823
4581
  </div>
2824
4582
  );
@@ -2892,12 +4650,34 @@ function formatVideoDuration(seconds: number): string {
2892
4650
 
2893
4651
  function NoticeRow({ item }: { item: NoticeItem }) {
2894
4652
  const enter = useEntranceAnimation();
4653
+ if (item.allowance) return <AllowanceExhaustedRow refusal={item.allowance} />;
4654
+ if (item.recordedOutcome) {
4655
+ return (
4656
+ <details
4657
+ className="group text-og-sm text-og-fg-muted"
4658
+ role="note"
4659
+ data-og-recorded-outcome="wait"
4660
+ >
4661
+ <summary className="flex cursor-pointer list-none items-center gap-2 py-1 [&::-webkit-details-marker]:hidden">
4662
+ <ChevronRightIcon
4663
+ aria-hidden
4664
+ className="size-3.5 transition-transform group-open:rotate-90"
4665
+ />
4666
+ <span title={new Date(item.occurredAt).toLocaleString()}>
4667
+ {recordedWaitSummary(item)}
4668
+ </span>
4669
+ </summary>
4670
+ <p className="mt-1 whitespace-pre-wrap break-words pl-5 text-og-fg-muted">{item.text}</p>
4671
+ </details>
4672
+ );
4673
+ }
4674
+ const resolvedApproval = Boolean(item.resolvedAt && item.text.startsWith("Approval needed"));
2895
4675
  const tone =
2896
4676
  item.tone === "failed"
2897
4677
  ? "border-og-status-failed/35 bg-og-status-failed/10 text-og-status-failed"
2898
- : item.tone === "waiting"
2899
- ? "border-og-status-waiting/35 bg-og-status-waiting/10 text-og-status-waiting"
2900
- : "border-og-border bg-og-surface-1 text-og-fg-muted";
4678
+ : item.tone === "waiting" && !item.resolvedAt
4679
+ ? WAITING_PILL_CLASS
4680
+ : NEUTRAL_PILL;
2901
4681
  return (
2902
4682
  <div
2903
4683
  className={cn(
@@ -2907,15 +4687,21 @@ function NoticeRow({ item }: { item: NoticeItem }) {
2907
4687
  )}
2908
4688
  role="status"
2909
4689
  >
2910
- <TriangleAlertIcon
2911
- className={cn("mt-0.5 size-4 shrink-0", item.tone === "cancelled" && "opacity-60")}
2912
- />
4690
+ {resolvedApproval ? (
4691
+ <CheckIcon className="mt-0.5 size-4 shrink-0 opacity-70" aria-hidden="true" />
4692
+ ) : (
4693
+ <TriangleAlertIcon
4694
+ className={cn("mt-0.5 size-4 shrink-0", item.tone === "cancelled" && "opacity-60")}
4695
+ />
4696
+ )}
2913
4697
  <div className="min-w-0 flex-1">
2914
- <span className="whitespace-pre-wrap break-words">{item.text}</span>
4698
+ <span className="whitespace-pre-wrap break-words">
4699
+ {resolvedApproval ? "You responded to this approval." : item.text}
4700
+ </span>
2915
4701
  {item.details ? (
2916
4702
  <details className="mt-2 text-og-control">
2917
4703
  <summary className="cursor-pointer font-medium">{item.details.label}</summary>
2918
- <pre className="mt-2 max-h-64 overflow-auto whitespace-pre-wrap break-all rounded-og-sm bg-black/5 p-2 font-mono dark:bg-white/5">
4704
+ <pre className="mt-2 max-h-64 overflow-auto whitespace-pre-wrap break-all rounded-og-sm bg-og-fg/5 p-2 font-mono">
2919
4705
  {JSON.stringify(item.details.value, null, 2)}
2920
4706
  </pre>
2921
4707
  </details>
@@ -2935,6 +4721,41 @@ function NoticeRow({ item }: { item: NoticeItem }) {
2935
4721
  );
2936
4722
  }
2937
4723
 
4724
+ /**
4725
+ * "Waited for 2 agents · 3m 5s" once later input ended the wait, otherwise
4726
+ * "Waiting for 2 agents · since 10:32" (with the date on a later day). The
4727
+ * reason stays behind the disclosure.
4728
+ */
4729
+ function recordedWaitSummary(item: NoticeItem): ReactNode {
4730
+ const agents = item.waitingAgents ?? 0;
4731
+ const target = agents > 0 ? ` for ${agents} ${agents === 1 ? "agent" : "agents"}` : "";
4732
+ const waitedMs = item.waitEndedAt
4733
+ ? durationBetween(item.occurredAt, item.waitEndedAt)
4734
+ : undefined;
4735
+ if (waitedMs !== undefined) {
4736
+ return (
4737
+ <>
4738
+ Waited{target} ·{" "}
4739
+ <time dateTime={`PT${Math.floor(waitedMs / 1000)}S`}>{formatElapsed(waitedMs)}</time>
4740
+ </>
4741
+ );
4742
+ }
4743
+ // An open wait is historical once its day has passed; keep its date visible.
4744
+ const started = new Date(item.occurredAt);
4745
+ const sameDay = started.toDateString() === new Date().toDateString();
4746
+ return (
4747
+ <>
4748
+ {item.waitEndedAt ? "Waited" : "Waiting"}
4749
+ {target} · since{" "}
4750
+ <time dateTime={item.occurredAt}>
4751
+ {sameDay
4752
+ ? formatClockTime(item.occurredAt)
4753
+ : started.toLocaleString(undefined, { dateStyle: "medium", timeStyle: "short" })}
4754
+ </time>
4755
+ </>
4756
+ );
4757
+ }
4758
+
2938
4759
  /**
2939
4760
  * The inline connection-recovery card: missing or lapsed access surfaces as a
2940
4761
  * calm, tappable affordance instead of a raw provider-domain error. The `reason`
@@ -2981,6 +4802,7 @@ function AuthNeededRow({
2981
4802
  ? `${provider} tools unavailable`
2982
4803
  : `${actionLabel} ${provider}`;
2983
4804
  const reasonLine = recommendation?.rationale ?? authReasonLine(item.reason);
4805
+ const hostAuthorizationUrl = item.authoritySource === "host" ? item.authorizationUrl : null;
2984
4806
 
2985
4807
  const start = async () => {
2986
4808
  if (!onReconnect || busy) {
@@ -3023,27 +4845,40 @@ function AuthNeededRow({
3023
4845
  ) : null}
3024
4846
  </div>
3025
4847
  </div>
3026
- {!unavailable && onReconnect ? (
4848
+ {hostAuthorizationUrl ? (
4849
+ <a
4850
+ href={hostAuthorizationUrl}
4851
+ rel="noreferrer"
4852
+ target="_blank"
4853
+ className={cn(
4854
+ PRIMARY_ACTION_CLASS,
4855
+ "transition-colors hover:bg-og-primary-hover pointer-coarse:min-h-9",
4856
+ )}
4857
+ >
4858
+ <RefreshCwIcon className="size-3.5" aria-hidden />
4859
+ {actionLabel}
4860
+ </a>
4861
+ ) : item.authoritySource !== "host" && !unavailable && onReconnect ? (
3027
4862
  <button
3028
4863
  type="button"
3029
4864
  onClick={() => void start()}
3030
4865
  disabled={busy}
3031
4866
  className={cn(
3032
- "inline-flex w-full shrink-0 items-center justify-center gap-1.5 rounded-og-md bg-og-accent px-3 py-1.5 text-og-menu font-medium text-og-accent-fg sm:w-auto",
3033
- "transition-colors hover:bg-og-accent-strong disabled:opacity-70 pointer-coarse:min-h-9",
4867
+ PRIMARY_ACTION_CLASS,
4868
+ "transition-colors hover:bg-og-primary-hover disabled:opacity-50 pointer-coarse:min-h-9",
3034
4869
  )}
3035
4870
  >
3036
4871
  <RefreshCwIcon className={cn("size-3.5", busy && "animate-og-spin")} aria-hidden />
3037
4872
  {busy ? "Opening…" : actionLabel}
3038
4873
  </button>
3039
- ) : !unavailable && item.authorizationUrl ? (
4874
+ ) : item.authoritySource !== "host" && !unavailable && item.authorizationUrl ? (
3040
4875
  <a
3041
4876
  href={item.authorizationUrl}
3042
4877
  rel="noreferrer"
3043
4878
  target="_blank"
3044
4879
  className={cn(
3045
- "inline-flex w-full shrink-0 items-center justify-center gap-1.5 rounded-og-md bg-og-accent px-3 py-1.5 text-og-menu font-medium text-og-accent-fg sm:w-auto",
3046
- "transition-colors hover:bg-og-accent-strong pointer-coarse:min-h-9",
4880
+ PRIMARY_ACTION_CLASS,
4881
+ "transition-colors hover:bg-og-primary-hover pointer-coarse:min-h-9",
3047
4882
  )}
3048
4883
  >
3049
4884
  <RefreshCwIcon className="size-3.5" aria-hidden />
@@ -3105,7 +4940,7 @@ function AuthProviderLogo({ src, label }: { src: string | null; label: string })
3105
4940
  `capabilityMonogram` so the reconnect tile reads like every other logo tile. */
3106
4941
  function monogram(label: string): string {
3107
4942
  const words = label.trim().split(/\s+/).filter(Boolean);
3108
- if (words.length === 0) {
4943
+ if (!words.length) {
3109
4944
  return "?";
3110
4945
  }
3111
4946
  if (words.length === 1) {