@opengeni/react 1.0.1 → 1.0.2-canary.37202341988001

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 (632) hide show
  1. package/README.md +660 -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-SHYKQRNX.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-26ZNDDLZ.js +453 -0
  18. package/dist/chunk-26ZNDDLZ.js.map +1 -0
  19. package/dist/chunk-3CTRFMAK.js +359 -0
  20. package/dist/chunk-3CTRFMAK.js.map +1 -0
  21. package/dist/chunk-447JLCBU.js +209 -0
  22. package/dist/chunk-447JLCBU.js.map +1 -0
  23. package/dist/chunk-4RVYOY4O.js +379 -0
  24. package/dist/chunk-4RVYOY4O.js.map +1 -0
  25. package/dist/chunk-5CVXZA3Y.js +292 -0
  26. package/dist/chunk-5CVXZA3Y.js.map +1 -0
  27. package/dist/chunk-5J4FOW6Q.js +108 -0
  28. package/dist/chunk-5J4FOW6Q.js.map +1 -0
  29. package/dist/chunk-742CZ3L3.js +481 -0
  30. package/dist/chunk-742CZ3L3.js.map +1 -0
  31. package/dist/chunk-A2PI6UMY.js +41 -0
  32. package/dist/chunk-A2PI6UMY.js.map +1 -0
  33. package/dist/{chunk-7WCLAEFV.js → chunk-A4UJB2MM.js} +37 -27
  34. package/dist/chunk-A4UJB2MM.js.map +1 -0
  35. package/dist/chunk-B3V6TG77.js +41 -0
  36. package/dist/chunk-B3V6TG77.js.map +1 -0
  37. package/dist/chunk-BRM6FVZA.js +302 -0
  38. package/dist/chunk-BRM6FVZA.js.map +1 -0
  39. package/dist/chunk-C5X5AJXK.js +182 -0
  40. package/dist/chunk-C5X5AJXK.js.map +1 -0
  41. package/dist/{chunk-6KLX2RH5.js → chunk-DO544OSV.js} +1075 -79
  42. package/dist/chunk-DO544OSV.js.map +1 -0
  43. package/dist/{chunk-R3AZOAB5.js → chunk-DYWBUX5Q.js} +29 -123
  44. package/dist/chunk-DYWBUX5Q.js.map +1 -0
  45. package/dist/{chunk-HA46CYQK.js → chunk-EESD3PY3.js} +792 -537
  46. package/dist/chunk-EESD3PY3.js.map +1 -0
  47. package/dist/{chunk-5UQPNLIO.js → chunk-EN3YTHKR.js} +4117 -3896
  48. package/dist/chunk-EN3YTHKR.js.map +1 -0
  49. package/dist/chunk-ERGYJIBB.js +21 -0
  50. package/dist/chunk-ERGYJIBB.js.map +1 -0
  51. package/dist/chunk-FZCP527Y.js +116 -0
  52. package/dist/chunk-FZCP527Y.js.map +1 -0
  53. package/dist/chunk-GIMRELBW.js +14 -0
  54. package/dist/chunk-GIMRELBW.js.map +1 -0
  55. package/dist/chunk-GYVRFZDY.js +2733 -0
  56. package/dist/chunk-GYVRFZDY.js.map +1 -0
  57. package/dist/{chunk-LLOLUHVG.js → chunk-HDJWDK7W.js} +1358 -2746
  58. package/dist/chunk-HDJWDK7W.js.map +1 -0
  59. package/dist/chunk-I7IWRQLN.js +1608 -0
  60. package/dist/chunk-I7IWRQLN.js.map +1 -0
  61. package/dist/chunk-IT4BJUGF.js +13055 -0
  62. package/dist/chunk-IT4BJUGF.js.map +1 -0
  63. package/dist/chunk-IWIDV7SB.js +1 -0
  64. package/dist/chunk-IWIDV7SB.js.map +1 -0
  65. package/dist/chunk-J2QWVSQY.js +312 -0
  66. package/dist/chunk-J2QWVSQY.js.map +1 -0
  67. package/dist/chunk-J6DBS4T3.js +371 -0
  68. package/dist/chunk-J6DBS4T3.js.map +1 -0
  69. package/dist/{chunk-B6WPZSTT.js → chunk-JIQYUGVL.js} +1331 -1163
  70. package/dist/chunk-JIQYUGVL.js.map +1 -0
  71. package/dist/chunk-JT4JW7BC.js +54 -0
  72. package/dist/chunk-JT4JW7BC.js.map +1 -0
  73. package/dist/chunk-K3HQAKQX.js +24 -0
  74. package/dist/chunk-K3HQAKQX.js.map +1 -0
  75. package/dist/{chunk-PEI6RANI.js → chunk-PNY3JKBJ.js} +309 -74
  76. package/dist/chunk-PNY3JKBJ.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-RLUSC2WA.js +299 -0
  80. package/dist/chunk-RLUSC2WA.js.map +1 -0
  81. package/dist/{chunk-IGC6A2ZI.js → chunk-RSI7XYXL.js} +777 -246
  82. package/dist/chunk-RSI7XYXL.js.map +1 -0
  83. package/dist/chunk-SCOTPE2I.js +589 -0
  84. package/dist/chunk-SCOTPE2I.js.map +1 -0
  85. package/dist/{chunk-C5ZPSSS5.js → chunk-TKOTKRWY.js} +5 -4
  86. package/dist/{chunk-C5ZPSSS5.js.map → chunk-TKOTKRWY.js.map} +1 -1
  87. package/dist/chunk-TMYGL6X5.js +742 -0
  88. package/dist/chunk-TMYGL6X5.js.map +1 -0
  89. package/dist/chunk-UGLEYEMP.js +33 -0
  90. package/dist/chunk-UGLEYEMP.js.map +1 -0
  91. package/dist/chunk-UK4TINKT.js +27 -0
  92. package/dist/chunk-UK4TINKT.js.map +1 -0
  93. package/dist/chunk-UTRTG6LH.js +220 -0
  94. package/dist/chunk-UTRTG6LH.js.map +1 -0
  95. package/dist/chunk-WEHMPOXK.js +39 -0
  96. package/dist/chunk-WEHMPOXK.js.map +1 -0
  97. package/dist/chunk-WGXVEJO5.js +319 -0
  98. package/dist/chunk-WGXVEJO5.js.map +1 -0
  99. package/dist/chunk-WRHIZYZF.js +988 -0
  100. package/dist/chunk-WRHIZYZF.js.map +1 -0
  101. package/dist/{chunk-HUIKIDM5.js → chunk-WUISZ3WU.js} +223 -115
  102. package/dist/chunk-WUISZ3WU.js.map +1 -0
  103. package/dist/chunk-XCHWDJ22.js +107 -0
  104. package/dist/chunk-XCHWDJ22.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-YM2DE7BQ.js +3124 -0
  110. package/dist/chunk-YM2DE7BQ.js.map +1 -0
  111. package/dist/{chunk-KZ73RL76.js → chunk-YQEAA6LT.js} +7 -3
  112. package/dist/chunk-YQEAA6LT.js.map +1 -0
  113. package/dist/chunk-YV4L3ILF.js +9 -0
  114. package/dist/chunk-YV4L3ILF.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 +58 -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 +89 -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/session-proxy-scope.d.ts +21 -0
  185. package/dist/components/timeline-anchor.d.ts +24 -0
  186. package/dist/components/timeline-annotation-chrome.d.ts +25 -0
  187. package/dist/components/timeline-annotation-layout.d.ts +42 -0
  188. package/dist/components/timeline-annotation-markers.d.ts +8 -0
  189. package/dist/components/timeline-annotation-reveal-context.d.ts +4 -0
  190. package/dist/components/timeline-annotation-shared.d.ts +31 -0
  191. package/dist/components/timeline-annotations-dialog.d.ts +3 -2
  192. package/dist/components/timeline-annotations.d.ts +13 -3
  193. package/dist/components/timeline-reading-progress.d.ts +11 -0
  194. package/dist/components/timeline-search.d.ts +34 -0
  195. package/dist/components/timeline-settlement.d.ts +16 -0
  196. package/dist/components/tip-follow.d.ts +16 -0
  197. package/dist/components/usage-limit-notice.d.ts +49 -0
  198. package/dist/components/usage-member-list.d.ts +91 -0
  199. package/dist/components/usage-meter.d.ts +73 -0
  200. package/dist/components/use-viewer-menu-dismiss.d.ts +2 -0
  201. package/dist/components/user-message-body.d.ts +10 -1
  202. package/dist/components/workspace-dock.d.ts +6 -2
  203. package/dist/composer.d.ts +5 -4
  204. package/dist/composer.js +15 -9
  205. package/dist/computer-viewer-6SRGWRUX.js +19 -0
  206. package/dist/computer-viewer-6SRGWRUX.js.map +1 -0
  207. package/dist/connect-account-identity.d.ts +8 -0
  208. package/dist/connect-accounts.d.ts +15 -0
  209. package/dist/connect-chooser.d.ts +18 -0
  210. package/dist/connect-panel.d.ts +18 -0
  211. package/dist/connect-setup.d.ts +16 -0
  212. package/dist/connect.d.ts +21 -0
  213. package/dist/connect.js +2129 -0
  214. package/dist/connect.js.map +1 -0
  215. package/dist/connection-catalog.d.ts +38 -0
  216. package/dist/connection-discovery.d.ts +12 -0
  217. package/dist/connection-installed.d.ts +17 -0
  218. package/dist/connection-logo.d.ts +9 -0
  219. package/dist/connection-service-logo.d.ts +8 -0
  220. package/dist/connection-service-presentation.d.ts +13 -0
  221. package/dist/connection-skeleton.d.ts +5 -0
  222. package/dist/connection-type-picker.d.ts +6 -0
  223. package/dist/conversation-timeline.d.ts +5 -0
  224. package/dist/desktop.d.ts +7 -0
  225. package/dist/desktop.js +26 -0
  226. package/dist/desktop.js.map +1 -0
  227. package/dist/device-authorization.d.ts +23 -0
  228. package/dist/diffs.d.ts +8 -0
  229. package/dist/diffs.js +14 -0
  230. package/dist/diffs.js.map +1 -0
  231. package/dist/discovery-cache.d.ts +5 -0
  232. package/dist/editor.d.ts +7 -0
  233. package/dist/editor.js +21 -0
  234. package/dist/editor.js.map +1 -0
  235. package/dist/embedded-session-client.d.ts +23 -0
  236. package/dist/file-node-visibility.d.ts +15 -0
  237. package/dist/{fleet-decision-row-GGCAT4E4.js → fleet-decision-row-YZ3ZKWMM.js} +2 -1
  238. package/dist/fleet-decision-row-YZ3ZKWMM.js.map +1 -0
  239. package/dist/hooks/channel-cache.d.ts +23 -0
  240. package/dist/hooks/latest-question.d.ts +25 -0
  241. package/dist/hooks/shared-machines-list.d.ts +14 -0
  242. package/dist/hooks/use-attached-browsers.d.ts +2 -1
  243. package/dist/hooks/use-browser-frame-stream.d.ts +1 -1
  244. package/dist/hooks/use-browser-session.d.ts +12 -1
  245. package/dist/hooks/use-channels.d.ts +1 -0
  246. package/dist/hooks/use-codex-accounts.d.ts +25 -7
  247. package/dist/hooks/use-composer.d.ts +45 -9
  248. package/dist/hooks/use-computer-frame-stream.d.ts +1 -1
  249. package/dist/hooks/use-computer-input-posture.d.ts +13 -0
  250. package/dist/hooks/use-computer-session.d.ts +2 -0
  251. package/dist/hooks/use-connect.d.ts +22 -0
  252. package/dist/hooks/use-desktop-stream.d.ts +1 -1
  253. package/dist/hooks/use-file-attachments.d.ts +21 -2
  254. package/dist/hooks/use-machine-chip.d.ts +3 -3
  255. package/dist/hooks/use-machines.d.ts +19 -1
  256. package/dist/hooks/use-sandbox-files.d.ts +5 -1
  257. package/dist/hooks/use-session-background-commands.d.ts +14 -0
  258. package/dist/hooks/use-session-events.d.ts +30 -8
  259. package/dist/hooks/use-session-lineage.d.ts +4 -0
  260. package/dist/hooks/use-session-mcp-approval-policy.d.ts +2 -2
  261. package/dist/hooks/use-session.d.ts +6 -0
  262. package/dist/hooks/use-turn-queue.d.ts +12 -0
  263. package/dist/hooks/use-usage.d.ts +40 -0
  264. package/dist/hooks/use-workspace-edit.d.ts +4 -0
  265. package/dist/hooks/use-workspace-sessions.d.ts +27 -5
  266. package/dist/human-input.d.ts +3 -1
  267. package/dist/identity-link-accounts.d.ts +11 -0
  268. package/dist/identity-link-consent.d.ts +18 -0
  269. package/dist/index.d.ts +60 -17
  270. package/dist/index.js +2238 -6201
  271. package/dist/index.js.map +1 -1
  272. package/dist/interaction.d.ts +1 -1
  273. package/dist/interaction.js +35 -184
  274. package/dist/interaction.js.map +1 -1
  275. package/dist/latest-question-HTRIS4KJ.js +117 -0
  276. package/dist/latest-question-HTRIS4KJ.js.map +1 -0
  277. package/dist/lib/error-message.d.ts +7 -0
  278. package/dist/lib/format.d.ts +36 -2
  279. package/dist/lib/host-theme.d.ts +70 -0
  280. package/dist/lib/interaction-errors.d.ts +6 -0
  281. package/dist/lib/menu-styles.d.ts +22 -0
  282. package/dist/lib/pierre-diffs-loader.d.ts +20 -0
  283. package/dist/lib/workbench-peers.d.ts +36 -0
  284. package/dist/lib/xterm-theme.d.ts +7 -5
  285. package/dist/machines.d.ts +4 -2
  286. package/dist/machines.js +14 -4
  287. package/dist/markdown-table-layout-QXSLK4BY.js +106 -0
  288. package/dist/markdown-table-layout-QXSLK4BY.js.map +1 -0
  289. package/dist/mcp-connection-status.d.ts +14 -0
  290. package/dist/model-policy-picker-menu-M54ISBMN.js +14 -0
  291. package/dist/model-policy-picker-menu-M54ISBMN.js.map +1 -0
  292. package/dist/model-policy.d.ts +19 -3
  293. package/dist/model-policy.js +9 -1
  294. package/dist/older-history.d.ts +30 -0
  295. package/dist/platform-activity-row-627BFZ46.js +182 -0
  296. package/dist/platform-activity-row-627BFZ46.js.map +1 -0
  297. package/dist/plugin-details.d.ts +14 -0
  298. package/dist/plugin-discovery.d.ts +25 -0
  299. package/dist/provider.d.ts +13 -1
  300. package/dist/{queue-surface-implementation-2KVJTYXH.js → queue-surface-implementation-ZHFI5JBP.js} +131 -44
  301. package/dist/queue-surface-implementation-ZHFI5JBP.js.map +1 -0
  302. package/dist/realtime/realtime-control.d.ts +6 -3
  303. package/dist/realtime.js +133 -74
  304. package/dist/realtime.js.map +1 -1
  305. package/dist/sandbox-files-GORZOUUJ.js +20 -0
  306. package/dist/sandbox-files-GORZOUUJ.js.map +1 -0
  307. package/dist/session-auth-recommendation.d.ts +5 -0
  308. package/dist/session-capability-policy.d.ts +12 -0
  309. package/dist/session-ui.d.ts +30 -2
  310. package/dist/session-ui.js +85 -7
  311. package/dist/session-ui.js.map +1 -1
  312. package/dist/session.d.ts +8 -2
  313. package/dist/session.js +30 -19
  314. package/dist/sites-ui.d.ts +34 -0
  315. package/dist/sites.d.ts +3 -0
  316. package/dist/sites.js +11 -0
  317. package/dist/sites.js.map +1 -0
  318. package/dist/skill-discovery.d.ts +47 -0
  319. package/dist/terminal.d.ts +8 -0
  320. package/dist/terminal.js +31 -0
  321. package/dist/terminal.js.map +1 -0
  322. package/dist/timeline/activity-rail.d.ts +11 -2
  323. package/dist/timeline/allowance-exhausted-row.d.ts +24 -0
  324. package/dist/timeline/compaction-copy.d.ts +10 -0
  325. package/dist/timeline/entrance.d.ts +13 -1
  326. package/dist/timeline/genie-loading.d.ts +34 -0
  327. package/dist/timeline/index.d.ts +6 -2
  328. package/dist/timeline/knowledge-receipt.d.ts +17 -0
  329. package/dist/timeline/platform-activity-row.d.ts +14 -0
  330. package/dist/timeline/presented-image.d.ts +6 -0
  331. package/dist/timeline/projection.d.ts +31 -2
  332. package/dist/timeline/registry.d.ts +2 -0
  333. package/dist/timeline/retained-image.d.ts +18 -0
  334. package/dist/timeline/rolling-activity.d.ts +11 -0
  335. package/dist/timeline/screenshot-lightbox.d.ts +16 -1
  336. package/dist/timeline/shared.d.ts +8 -3
  337. package/dist/timeline/startup-preference.d.ts +3 -0
  338. package/dist/timeline/startup-timings.d.ts +4 -0
  339. package/dist/timeline/tool-display-name.d.ts +2 -1
  340. package/dist/timeline/turn-summary.d.ts +34 -3
  341. package/dist/timeline/types.d.ts +147 -7
  342. package/dist/timeline-annotation-markers-GNDHTGBD.js +165 -0
  343. package/dist/timeline-annotation-markers-GNDHTGBD.js.map +1 -0
  344. package/dist/timeline-annotation-selection-DMLMM2TX.js +241 -0
  345. package/dist/timeline-annotation-selection-DMLMM2TX.js.map +1 -0
  346. package/dist/timeline-annotations-dialog-ELRDBHTS.js +273 -0
  347. package/dist/timeline-annotations-dialog-ELRDBHTS.js.map +1 -0
  348. package/dist/types/machines.d.ts +2 -2
  349. package/dist/usage/allowance-copy.d.ts +25 -0
  350. package/dist/usage/summary.d.ts +35 -0
  351. package/dist/usage.d.ts +13 -0
  352. package/dist/usage.js +1074 -0
  353. package/dist/usage.js.map +1 -0
  354. package/package.json +44 -4
  355. package/src/accounts.tsx +1072 -0
  356. package/src/approvals.ts +8 -1
  357. package/src/artifacts.ts +42 -0
  358. package/src/assets/README.md +8 -0
  359. package/src/assets/gmail.ico +0 -0
  360. package/src/assets/outlook.ico +0 -0
  361. package/src/capability-catalog-row.tsx +94 -0
  362. package/src/capability-logo-fallback.ts +22 -0
  363. package/src/client.ts +22 -13
  364. package/src/commands/registry.ts +18 -15
  365. package/src/commands/types.ts +2 -0
  366. package/src/components/anthropic-mark.tsx +10 -0
  367. package/src/components/approval-surface.tsx +171 -37
  368. package/src/components/artifacts/artifact-chrome.tsx +413 -0
  369. package/src/components/artifacts/artifact-sandbox.tsx +185 -0
  370. package/src/components/artifacts/artifact-surface.tsx +39 -19
  371. package/src/components/artifacts/chat-interactive-block.tsx +260 -0
  372. package/src/components/artifacts/deferred-chat-media.tsx +67 -0
  373. package/src/components/artifacts/document-editor.tsx +141 -16
  374. package/src/components/artifacts/editable-artifact-ui.tsx +4 -2
  375. package/src/components/artifacts/editable-artifact-view.tsx +187 -0
  376. package/src/components/artifacts/editable-artifact-workbench.tsx +5 -2
  377. package/src/components/artifacts/editable-document.tsx +37 -36
  378. package/src/components/artifacts/editable-presentation.tsx +36 -18
  379. package/src/components/artifacts/editable-spreadsheet.tsx +5 -2
  380. package/src/components/artifacts/index.ts +44 -0
  381. package/src/components/artifacts/inline-html-document.ts +24 -0
  382. package/src/components/artifacts/latest-task-runner.ts +49 -0
  383. package/src/components/artifacts/presentation-editor.tsx +323 -67
  384. package/src/components/artifacts/published-html-artifact-frame.tsx +338 -5
  385. package/src/components/artifacts/session-artifact-viewer.tsx +376 -0
  386. package/src/components/artifacts/site-view.tsx +116 -0
  387. package/src/components/artifacts/spreadsheet-canvas.ts +5 -10
  388. package/src/components/artifacts/spreadsheet-general-display.ts +43 -0
  389. package/src/components/artifacts/spreadsheet-grid.tsx +12 -13
  390. package/src/components/artifacts/visualization-assets/base.css +1015 -0
  391. package/src/components/artifacts/visualization-assets/helpers.html +349 -0
  392. package/src/components/artifacts/visualization-assets.gen.ts +3 -0
  393. package/src/components/browser-select-control.tsx +312 -0
  394. package/src/components/browser-viewer.tsx +1054 -314
  395. package/src/components/chat-composer.tsx +131 -85
  396. package/src/components/child-session-link.tsx +27 -0
  397. package/src/components/claude-mark.tsx +10 -0
  398. package/src/components/code-editor.tsx +13 -16
  399. package/src/components/composer-transcription-control.tsx +7 -5
  400. package/src/components/composer.tsx +376 -49
  401. package/src/components/computer-viewer.tsx +1041 -329
  402. package/src/components/copy-button.tsx +13 -5
  403. package/src/components/desktop-viewer.tsx +1 -1
  404. package/src/components/enrollment-consent.tsx +65 -7
  405. package/src/components/enrollment-device-flow.tsx +1 -1
  406. package/src/components/file-browser.tsx +158 -34
  407. package/src/components/fleet-tile.tsx +8 -16
  408. package/src/components/generated-video-player.tsx +4 -2
  409. package/src/components/grok-mark.tsx +33 -0
  410. package/src/components/human-input-form.tsx +455 -114
  411. package/src/components/human-input-surface.tsx +18 -7
  412. package/src/components/interaction-intervention-banner.tsx +2 -2
  413. package/src/components/machine-card.tsx +119 -4
  414. package/src/components/machine-dock-bar.tsx +1 -1
  415. package/src/components/machine-input-display.ts +23 -2
  416. package/src/components/machines/machine-detail.tsx +52 -2
  417. package/src/components/machines/machine-operation-policy.tsx +179 -0
  418. package/src/components/machines-dashboard.tsx +8 -17
  419. package/src/components/markdown-table-layout.ts +132 -0
  420. package/src/components/markdown.tsx +473 -44
  421. package/src/components/message-timeline.tsx +2754 -665
  422. package/src/components/model-mark.tsx +90 -0
  423. package/src/components/model-picker.tsx +15 -4
  424. package/src/components/model-policy-picker-menu.tsx +424 -0
  425. package/src/components/model-policy-picker.tsx +337 -381
  426. package/src/components/open-geni-chat.tsx +338 -0
  427. package/src/components/open-geni-links.tsx +179 -0
  428. package/src/components/openrouter-mark.tsx +13 -0
  429. package/src/components/pierre-diff.tsx +41 -16
  430. package/src/components/pierre-file.tsx +36 -11
  431. package/src/components/preview-loading.tsx +154 -0
  432. package/src/components/queue-item-content.ts +14 -0
  433. package/src/components/queue-surface-implementation.tsx +132 -42
  434. package/src/components/queue-surface-state.tsx +3 -1
  435. package/src/components/sandbox-files.tsx +267 -35
  436. package/src/components/sandbox-terminal.tsx +48 -11
  437. package/src/components/sandbox-workspace.tsx +337 -135
  438. package/src/components/session-capability-frame.tsx +192 -0
  439. package/src/components/session-chrome.tsx +1027 -328
  440. package/src/components/session-commands-panel.tsx +126 -0
  441. package/src/components/session-commands.tsx +18 -0
  442. package/src/components/session-connection-request.tsx +97 -0
  443. package/src/components/session-conversation.tsx +526 -0
  444. package/src/components/session-list.tsx +250 -0
  445. package/src/components/session-mcp-capability-card.tsx +680 -0
  446. package/src/components/session-proxy-scope.tsx +92 -0
  447. package/src/components/session-status.tsx +1 -1
  448. package/src/components/timeline-anchor.tsx +119 -0
  449. package/src/components/timeline-annotation-chrome.tsx +196 -0
  450. package/src/components/timeline-annotation-layout.ts +268 -0
  451. package/src/components/timeline-annotation-markers.tsx +185 -0
  452. package/src/components/timeline-annotation-reveal-context.ts +9 -0
  453. package/src/components/timeline-annotation-selection.tsx +106 -54
  454. package/src/components/timeline-annotation-shared.ts +245 -0
  455. package/src/components/timeline-annotations-dialog.tsx +185 -128
  456. package/src/components/timeline-annotations.tsx +189 -45
  457. package/src/components/timeline-reading-progress.ts +80 -0
  458. package/src/components/timeline-search.tsx +168 -0
  459. package/src/components/timeline-settlement.ts +60 -0
  460. package/src/components/tip-follow.ts +70 -7
  461. package/src/components/tooltip.tsx +38 -5
  462. package/src/components/usage-limit-notice.tsx +179 -0
  463. package/src/components/usage-member-list.tsx +792 -0
  464. package/src/components/usage-meter.tsx +382 -0
  465. package/src/components/use-viewer-menu-dismiss.ts +33 -0
  466. package/src/components/user-message-body.tsx +194 -49
  467. package/src/components/workspace-dock.tsx +141 -175
  468. package/src/composer.ts +6 -1
  469. package/src/connect-account-identity.tsx +34 -0
  470. package/src/connect-accounts.tsx +233 -0
  471. package/src/connect-chooser.tsx +336 -0
  472. package/src/connect-panel.tsx +99 -0
  473. package/src/connect-setup.tsx +307 -0
  474. package/src/connect.ts +60 -0
  475. package/src/connection-assets.d.ts +4 -0
  476. package/src/connection-catalog.tsx +162 -0
  477. package/src/connection-discovery.tsx +235 -0
  478. package/src/connection-installed.tsx +51 -0
  479. package/src/connection-logo.tsx +37 -0
  480. package/src/connection-service-logo.tsx +55 -0
  481. package/src/connection-service-presentation.ts +46 -0
  482. package/src/connection-skeleton.tsx +16 -0
  483. package/src/connection-type-picker.tsx +36 -0
  484. package/src/conversation-timeline.ts +92 -0
  485. package/src/desktop.ts +24 -0
  486. package/src/device-authorization.tsx +98 -0
  487. package/src/diffs.ts +16 -0
  488. package/src/discovery-cache.ts +42 -0
  489. package/src/editor.ts +13 -0
  490. package/src/embedded-session-client.ts +104 -0
  491. package/src/file-node-visibility.ts +66 -0
  492. package/src/hooks/channel-cache.ts +104 -0
  493. package/src/hooks/internal.ts +3 -2
  494. package/src/hooks/latest-question.ts +164 -0
  495. package/src/hooks/shared-machines-list.ts +239 -0
  496. package/src/hooks/use-attached-browsers.ts +5 -1
  497. package/src/hooks/use-available-models.ts +20 -14
  498. package/src/hooks/use-browser-frame-stream.ts +25 -3
  499. package/src/hooks/use-browser-session.ts +271 -71
  500. package/src/hooks/use-browser-sessions.ts +7 -1
  501. package/src/hooks/use-channels.ts +32 -10
  502. package/src/hooks/use-codex-accounts.ts +78 -21
  503. package/src/hooks/use-composer.ts +1034 -320
  504. package/src/hooks/use-computer-frame-stream.ts +39 -16
  505. package/src/hooks/use-computer-input-posture.ts +94 -0
  506. package/src/hooks/use-computer-session.ts +110 -32
  507. package/src/hooks/use-computer-sessions.ts +8 -1
  508. package/src/hooks/use-connect.ts +23 -0
  509. package/src/hooks/use-desktop-stream.ts +19 -28
  510. package/src/hooks/use-file-attachments.ts +118 -10
  511. package/src/hooks/use-goal.ts +36 -9
  512. package/src/hooks/use-human-input.ts +31 -4
  513. package/src/hooks/use-last-started-turn-policy.ts +15 -13
  514. package/src/hooks/use-machine-chip.ts +23 -6
  515. package/src/hooks/use-machines.ts +104 -6
  516. package/src/hooks/use-sandbox-files.ts +253 -150
  517. package/src/hooks/use-sandbox-git.ts +58 -29
  518. package/src/hooks/use-session-background-commands.ts +67 -0
  519. package/src/hooks/use-session-capabilities.ts +21 -2
  520. package/src/hooks/use-session-events.ts +759 -433
  521. package/src/hooks/use-session-lineage.ts +28 -6
  522. package/src/hooks/use-session-mcp-approval-policy.ts +13 -5
  523. package/src/hooks/use-session.ts +75 -16
  524. package/src/hooks/use-slash-commands.ts +6 -11
  525. package/src/hooks/use-turn-queue.ts +113 -13
  526. package/src/hooks/use-usage.ts +100 -0
  527. package/src/hooks/use-video-artifact-playback.ts +1 -1
  528. package/src/hooks/use-voice-input.ts +12 -1
  529. package/src/hooks/use-workspace-capture.ts +3 -2
  530. package/src/hooks/use-workspace-edit.ts +23 -5
  531. package/src/hooks/use-workspace-sessions.ts +99 -11
  532. package/src/human-input.ts +12 -1
  533. package/src/identity-link-accounts.tsx +148 -0
  534. package/src/identity-link-consent.tsx +192 -0
  535. package/src/index.ts +157 -12
  536. package/src/interaction.ts +1 -0
  537. package/src/lib/error-message.ts +37 -0
  538. package/src/lib/format.ts +150 -14
  539. package/src/lib/host-theme.ts +511 -0
  540. package/src/lib/interaction-errors.ts +33 -0
  541. package/src/lib/menu-styles.ts +44 -0
  542. package/src/lib/pierre-diffs-loader.ts +53 -0
  543. package/src/lib/workbench-peers.ts +62 -0
  544. package/src/lib/xterm-theme.ts +23 -21
  545. package/src/machines.ts +9 -1
  546. package/src/mcp-connection-status.ts +35 -0
  547. package/src/model-policy.ts +223 -69
  548. package/src/older-history.ts +87 -0
  549. package/src/plugin-details.tsx +185 -0
  550. package/src/plugin-discovery.tsx +196 -0
  551. package/src/provider.tsx +55 -14
  552. package/src/realtime/dropdown-menu.tsx +19 -12
  553. package/src/realtime/realtime-control.tsx +133 -46
  554. package/src/session-auth-recommendation.ts +48 -0
  555. package/src/session-capability-policy.ts +83 -0
  556. package/src/session-context.ts +4 -3
  557. package/src/session-ui.ts +66 -2
  558. package/src/session.ts +18 -1
  559. package/src/sites-ui.tsx +402 -0
  560. package/src/sites.ts +3 -0
  561. package/src/skill-discovery.tsx +208 -0
  562. package/src/terminal.ts +26 -0
  563. package/src/timeline/activity-rail.tsx +171 -80
  564. package/src/timeline/allowance-exhausted-row.tsx +86 -0
  565. package/src/timeline/compaction-copy.ts +42 -0
  566. package/src/timeline/entrance.tsx +28 -3
  567. package/src/timeline/fleet-decision-projection.ts +2 -1
  568. package/src/timeline/fleet-decision-row.tsx +1 -0
  569. package/src/timeline/genie-loading.tsx +136 -0
  570. package/src/timeline/index.ts +11 -0
  571. package/src/timeline/knowledge-receipt.tsx +100 -0
  572. package/src/timeline/parsers.ts +2 -2
  573. package/src/timeline/platform-activity-row.tsx +216 -0
  574. package/src/timeline/presented-image.ts +49 -0
  575. package/src/timeline/projection.ts +1609 -84
  576. package/src/timeline/registry.ts +15 -3
  577. package/src/timeline/retained-image.ts +97 -0
  578. package/src/timeline/rolling-activity.tsx +92 -0
  579. package/src/timeline/screenshot-lightbox.tsx +115 -17
  580. package/src/timeline/shared.tsx +47 -15
  581. package/src/timeline/startup-preference.ts +33 -0
  582. package/src/timeline/startup-timings.tsx +145 -0
  583. package/src/timeline/tool-display-name.ts +8 -6
  584. package/src/timeline/tool-renderers.tsx +497 -128
  585. package/src/timeline/turn-summary.tsx +218 -44
  586. package/src/timeline/types.ts +192 -7
  587. package/src/types/machines.ts +10 -0
  588. package/src/usage/allowance-copy.ts +64 -0
  589. package/src/usage/summary.ts +93 -0
  590. package/src/usage.ts +50 -0
  591. package/styles/compiled.css +2098 -757
  592. package/styles/connect.css +284 -0
  593. package/styles/connect.d.ts +1 -0
  594. package/styles/effective-tokens.css +1 -0
  595. package/styles/index.css +201 -9
  596. package/styles/responsive.css +27 -1
  597. package/styles/session-capability.css +44 -0
  598. package/styles/tokens.css +135 -65
  599. package/dist/chunk-5UQPNLIO.js.map +0 -1
  600. package/dist/chunk-6KLX2RH5.js.map +0 -1
  601. package/dist/chunk-7WCLAEFV.js.map +0 -1
  602. package/dist/chunk-B6WPZSTT.js.map +0 -1
  603. package/dist/chunk-FK6VG4LL.js +0 -90
  604. package/dist/chunk-FK6VG4LL.js.map +0 -1
  605. package/dist/chunk-HA46CYQK.js.map +0 -1
  606. package/dist/chunk-HUIKIDM5.js.map +0 -1
  607. package/dist/chunk-IGC6A2ZI.js.map +0 -1
  608. package/dist/chunk-KCGXPP5V.js.map +0 -1
  609. package/dist/chunk-KZ73RL76.js.map +0 -1
  610. package/dist/chunk-LAKLF4PA.js +0 -141
  611. package/dist/chunk-LAKLF4PA.js.map +0 -1
  612. package/dist/chunk-LLOLUHVG.js.map +0 -1
  613. package/dist/chunk-MKPIAGVL.js +0 -465
  614. package/dist/chunk-MKPIAGVL.js.map +0 -1
  615. package/dist/chunk-PEI6RANI.js.map +0 -1
  616. package/dist/chunk-R3AZOAB5.js.map +0 -1
  617. package/dist/chunk-WSK7G5LE.js +0 -170
  618. package/dist/chunk-WSK7G5LE.js.map +0 -1
  619. package/dist/chunk-XBZWOM2K.js +0 -218
  620. package/dist/chunk-XBZWOM2K.js.map +0 -1
  621. package/dist/chunk-Y7OEDUJ5.js +0 -8810
  622. package/dist/chunk-Y7OEDUJ5.js.map +0 -1
  623. package/dist/fleet-decision-row-GGCAT4E4.js.map +0 -1
  624. package/dist/hooks/use-packs.d.ts +0 -32
  625. package/dist/interactive-workbench-viewers-TOHBXJEE.js +0 -15
  626. package/dist/queue-surface-implementation-2KVJTYXH.js.map +0 -1
  627. package/dist/timeline-annotation-selection-3L7LHGG2.js +0 -187
  628. package/dist/timeline-annotation-selection-3L7LHGG2.js.map +0 -1
  629. package/dist/timeline-annotations-dialog-U7EVLR75.js +0 -196
  630. package/dist/timeline-annotations-dialog-U7EVLR75.js.map +0 -1
  631. package/src/hooks/use-packs.ts +0 -157
  632. /package/dist/{interactive-workbench-viewers-TOHBXJEE.js.map → browser-viewer-SHYKQRNX.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,15 +40,17 @@ 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,
31
48
  Suspense,
49
+ createContext,
32
50
  lazy,
33
51
  memo,
34
52
  useCallback,
53
+ useContext,
35
54
  useEffect,
36
55
  useLayoutEffect,
37
56
  useMemo,
@@ -42,22 +61,43 @@ import {
42
61
  import { cn } from "../lib/cn";
43
62
  import { formatClockTime, formatRelativeTime, truncate } from "../lib/format";
44
63
  import { prefersReducedMotion } from "../lib/motion";
45
- import { Markdown } from "./markdown";
64
+ import {
65
+ invokeOlderHistoryLoaderWithReceiptCapture,
66
+ type OlderHistoryLoadReceipt,
67
+ type OlderHistoryLoader,
68
+ } from "../older-history";
69
+ import { Markdown, type MarkdownProps } from "./markdown";
70
+ import { OpenGeniLinkProvider, type OpenGeniLinkResolver } from "./open-geni-links";
71
+ import {
72
+ TimelineBeforeLayout,
73
+ captureTimelineAnchor,
74
+ timelineAnchorCorrection,
75
+ timelineHasReader,
76
+ type TimelineAnchor,
77
+ } from "./timeline-anchor";
78
+ import { useReadingProgress } from "./timeline-reading-progress";
79
+ import {
80
+ animateTimelineSettlement,
81
+ captureTimelineSettlement,
82
+ type TimelineSettlement,
83
+ } from "./timeline-settlement";
46
84
  import {
47
85
  UserMessageBody,
48
86
  UserMessageDisclosureProvider,
49
87
  type UserMessageDisclosureContextValue,
88
+ type UserMessageDisclosureLabels,
50
89
  } from "./user-message-body";
51
90
  import {
52
91
  createTipFollowState,
53
92
  readerScrollUpPx,
54
93
  tipFollowCancel,
55
- tipFollowCompensateShrink,
56
94
  tipFollowCompensateViewportShrink,
95
+ tipFollowObserveContentShrink,
57
96
  tipFollowStep,
58
97
  supportsScrollEndEvent,
59
98
  TIP_FOLLOW_READER_UP_EPS_PX,
60
99
  TIP_FOLLOW_SHRINK_EPS_PX,
100
+ type TipFollowContentShrinkBaseline,
61
101
  type TipFollowState,
62
102
  } from "./tip-follow";
63
103
  import {
@@ -71,6 +111,7 @@ import {
71
111
  type AuthNeededItem,
72
112
  type ContextCompactionItem,
73
113
  type GoalItem,
114
+ type HumanInputItem,
74
115
  type MachineInputBatchItem,
75
116
  type NoticeItem,
76
117
  type TimelineGroup,
@@ -81,6 +122,7 @@ import {
81
122
  type VideoArtifactPlaybackLoader,
82
123
  type ToolRegistry,
83
124
  type TurnSummaryOptions,
125
+ type TurnSummaryStatus,
84
126
  type UserMessageItem,
85
127
  type FoldRestingState,
86
128
  type WorkerCompletionItem,
@@ -91,6 +133,7 @@ import {
91
133
  useTurnSettleOpen,
92
134
  } from "../timeline";
93
135
  import { CopyHoverFrame } from "./copy-button";
136
+ import { TimelineAnnotationSourceRootContext } from "./timeline-annotation-reveal-context";
94
137
  import { GeneratedVideoPlayer } from "./generated-video-player";
95
138
  import {
96
139
  MACHINE_INPUT_META,
@@ -102,23 +145,45 @@ import {
102
145
  import { SESSION_STATUS_META, StatusDot } from "./session-status";
103
146
  import { TimelineComputeLabelProvider } from "../timeline/compute-label";
104
147
  import { EntranceAnimationProvider, useEntranceAnimation } from "../timeline/entrance";
148
+ import {
149
+ AllowanceExhaustedRow,
150
+ AllowancePresentationProvider,
151
+ type RenderAllowanceExhausted,
152
+ } from "../timeline/allowance-exhausted-row";
153
+ import type { AllowanceLabels } from "../usage/allowance-copy";
105
154
  import { SeenActivityIdsProvider } from "../timeline/seen-activity-ids";
106
- import { TimelineAnnotationsChip } from "./timeline-annotations";
155
+ import { TimelineAnnotationCards } from "./timeline-annotations";
107
156
  import { TooltipProvider } from "./tooltip";
108
157
 
109
158
  const TimelineAnnotationSelection = lazy(() => import("./timeline-annotation-selection"));
159
+ const TimelineAnnotationMarkers = lazy(() => import("./timeline-annotation-markers"));
110
160
 
111
161
  export type MessageTimelineProps = {
162
+ /** Exact durable search hit; clearing it removes highlighting without moving the reader. */
163
+ searchTarget?: TimelineSearchTarget | null | undefined;
164
+ /** Localized user-message disclosure actions, including custom UserMessageBody renderers. */
165
+ userMessageDisclosureLabels?: UserMessageDisclosureLabels | undefined;
112
166
  /** Raw session events (projected internally) … */
113
167
  events?: SessionEvent[] | undefined;
114
168
  /** … or pre-projected items (e.g. from `useSessionEvents().timeline`). */
115
169
  items?: TimelineItem[] | undefined;
116
170
  /** Current session status (reserved; tip "Working…" chrome removed for now). */
117
171
  status?: SessionStatus | null | undefined;
172
+ /** Host-owned controls beside Copy and the timestamp on settled message rows. */
173
+ renderMessageActions?: ((item: AgentMessageItem | UserMessageItem) => ReactNode) | undefined;
118
174
  /** Plug a markdown renderer for message bodies (e.g. streamdown). */
119
175
  renderMessageText?:
120
- | ((text: string, item: AgentMessageItem | UserMessageItem) => ReactNode)
176
+ | ((
177
+ text: string,
178
+ item: AgentMessageItem | UserMessageItem,
179
+ context: { searchTarget: TimelineSearchTarget | null },
180
+ ) => ReactNode)
121
181
  | undefined;
182
+ /**
183
+ * Render assistant `opengeni-html` / `opengeni-site` fences in the default
184
+ * message renderer (`SessionConversation` supplies the inline Site preview).
185
+ */
186
+ renderInteractiveBlock?: MarkdownProps["renderInteractiveBlock"];
122
187
  /** Drill into a spawned worker session. */
123
188
  onOpenSession?: ((sessionId: string) => void) | undefined;
124
189
  /**
@@ -138,6 +203,17 @@ export type MessageTimelineProps = {
138
203
  * a Reconnect button without a handler to run it.
139
204
  */
140
205
  onReconnect?: ((item: AuthNeededItem) => void | Promise<void>) | undefined;
206
+ /** Host-owned inline connection setup. Return undefined to use the default recovery card. */
207
+ renderAuthNeeded?: ((item: AuthNeededItem) => ReactNode | undefined) | undefined;
208
+ /**
209
+ * Replace the "usage limit reached" row shown when a workspace or member
210
+ * usage allowance stops work (an `allowance_exhausted` refusal). Receives the
211
+ * typed refusal (scope, reset time) and the default row; return undefined to
212
+ * keep the default. Use it to point people at your own plan or admin page.
213
+ */
214
+ renderAllowanceExhausted?: RenderAllowanceExhausted | undefined;
215
+ /** Replace the words of the default "usage limit reached" row. */
216
+ allowanceExhaustedLabels?: Partial<AllowanceLabels> | undefined;
141
217
  /**
142
218
  * Decide which durable authentication notices this timeline presents.
143
219
  * Defaults to showing every notice. Embedded hosts can suppress notices for
@@ -158,9 +234,19 @@ export type MessageTimelineProps = {
158
234
  * `createDefaultToolRegistry({ entries })` to add custom tool renderers.
159
235
  */
160
236
  toolRegistry?: ToolRegistry | undefined;
237
+ /**
238
+ * Open OpenGeni object links the agent writes in replies and progress notes:
239
+ * `artifact:<file>`, `sandbox:<path>`, editable artifacts, and Sites. Return
240
+ * a host URL (`{ href }`) or action (`{ open }`); unhandled targets render as
241
+ * unavailable text instead of a console link that 404s inside the host.
242
+ * Applies to the default message renderer and to any `Markdown` a custom
243
+ * `renderMessageText` renders. `SessionConversation` supplies file and
244
+ * sandbox downloads by default.
245
+ */
246
+ resolveLink?: OpenGeniLinkResolver | undefined;
161
247
  /** Resolve opaque retained screenshot receipts through the authenticated host SDK. */
162
248
  loadRetainedScreenshot?: RetainedScreenshotLoader | undefined;
163
- /** Resolve permanent generated-image receipts through the authenticated host SDK. */
249
+ /** Resolve permanent workspace image/file receipts through the authenticated host SDK. */
164
250
  loadRetainedArtifact?: RetainedArtifactLoader | undefined;
165
251
  /** Mint short-lived native playback sources for retained generated videos. */
166
252
  loadVideoArtifactPlayback?: VideoArtifactPlaybackLoader | undefined;
@@ -170,17 +256,32 @@ export type MessageTimelineProps = {
170
256
  */
171
257
  computeLabel?: string | null | undefined;
172
258
  /** Customize collapsed turn facets for this timeline instance. */
259
+ genieLoading?: GenieLoadingOptions | undefined;
173
260
  turnSummary?: TurnSummaryOptions | undefined;
174
261
  /** Follow new events when pinned to the bottom. Defaults to true. */
175
262
  autoFollow?: boolean | undefined;
176
263
  /** Capture a same-row text selection into the host's canonical composer draft. */
177
264
  onAnnotate?: ((annotation: DraftTimelineAnnotation) => void) | undefined;
265
+ /**
266
+ * Hide the floating "Back to your message" pill while the timeline viewport
267
+ * is shorter than this (px), e.g. a narrow embed above a decision card.
268
+ * Defaults to 0 (always available).
269
+ */
270
+ questionNavMinViewportHeight?: number | undefined;
271
+ /** Composer draft quotes currently attached to the next send. */
272
+ draftAnnotations?: readonly DraftTimelineAnnotation[] | undefined;
273
+ /** Open the composer review list for one numbered draft badge. */
274
+ onDraftAnnotationSelect?: ((id: string) => void) | undefined;
178
275
  /** Older durable history exists above the current window (see useSessionEvents). */
179
276
  hasOlder?: boolean | undefined;
180
277
  /** An older window is being fetched; shows the quiet top shimmer. */
181
278
  loadingOlder?: boolean | undefined;
182
- /** Called when the reader nears the top and older history should backfill. */
183
- onLoadOlder?: (() => void) | undefined;
279
+ /**
280
+ * Called when older history should backfill. Existing void, synchronous-value,
281
+ * and arbitrary-promise callbacks remain supported. Receipt-aware loaders
282
+ * preserve committed-page direction through wrappers and bounded windows.
283
+ */
284
+ onLoadOlder?: OlderHistoryLoader | undefined;
184
285
  /** Jump to the durable session start (bounded oldest window, no middle). */
185
286
  onJumpToStart?: (() => void | Promise<void>) | undefined;
186
287
  /** True while the oldest window is loading. */
@@ -189,13 +290,16 @@ export type MessageTimelineProps = {
189
290
  hasNewer?: boolean | undefined;
190
291
  /** A newer history page is being fetched. */
191
292
  loadingNewer?: boolean | undefined;
192
- /** Page forward through history without loading the whole gap to the tip. */
193
- onLoadNewer?: (() => void) | undefined;
293
+ /** Page forward through history. Return the request promise to enable inline error/retry. */
294
+ onLoadNewer?: (() => unknown) | undefined;
194
295
  /**
195
296
  * Reload the live tip window. When omitted, Jump to latest only re-pins and
196
297
  * scrolls the in-memory window.
197
298
  */
198
299
  onJumpToLatest?: (() => void | Promise<void>) | undefined;
300
+ /** @deprecated Retained for source compatibility; contextual prompt navigation
301
+ * uses only mounted prompts and never invokes this global resolver. */
302
+ onJumpToLatestQuestion?: (() => Promise<number | null>) | undefined;
199
303
  /** Host-owned content appended after timeline groups, such as startup progress. */
200
304
  trailingState?: ReactNode | undefined;
201
305
  emptyState?: ReactNode | undefined;
@@ -211,8 +315,9 @@ export type MessageTimelineProps = {
211
315
  *
212
316
  * Scroll invariant (tip-follow camera — see `./tip-follow.ts`):
213
317
  * - 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).
318
+ * reveal. Live tip: DOM growth advances the pinned viewport by the same
319
+ * amount, so rendered content is visible immediately. Only debt that already
320
+ * existed before the growth goes through the camera ease.
216
321
  * - One continuous follow while hot (faster τ when behind); sleeps when cold.
217
322
  * - While pinned, tip-debt from growth/collapse must NEVER unpin — only
218
323
  * wheel/keys/pointer-armed scroll-up, or a settled scrollend away from the
@@ -226,6 +331,33 @@ export type MessageTimelineProps = {
226
331
  * alone is wrong. Late layout while unpinned stays browser-owned.
227
332
  */
228
333
  const PIN_THRESHOLD_PX = 48;
334
+ /**
335
+ * A pinned timeline can still have meaningful pre-existing debt while the
336
+ * tip-follow camera catches up. Surface the existing explicit jump once that
337
+ * debt is large enough to be useful, without flashing the control for ordinary
338
+ * line-sized streaming movement.
339
+ */
340
+ const JUMP_TO_LATEST_CATCHUP_DEBT_PX = 240;
341
+ /** Breathing room above the question when following stops at an answer. */
342
+ const QUESTION_NAV_MARGIN_PX = 12;
343
+ /**
344
+ * Floating timeline navigation: two identical small round arrow buttons at fixed
345
+ * spots, centered on the conversation. Back to your message floats 12px below
346
+ * the top edge, or just below an expanded work-header strip while that strip is
347
+ * pinned (32px, 44px on coarse pointers) so the strip stays a full-width
348
+ * target. Jump to latest floats 12px above the bottom edge. They never dodge
349
+ * the content beneath them (floating over a sliver of text is fine), so they
350
+ * cannot wander as rows stream in or the host resizes. Coarse pointers get a
351
+ * larger invisible hit area instead of a larger button.
352
+ */
353
+ const NAV_BUTTON_CLASS =
354
+ "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-['']";
355
+ const NAV_FADE = {
356
+ initial: { opacity: 0 },
357
+ animate: { opacity: 1 },
358
+ exit: { opacity: 0 },
359
+ transition: { duration: 0.15, ease: "easeOut" },
360
+ } as const;
229
361
  /**
230
362
  * Prefetch older history when the top sentinel is this far from the viewport.
231
363
  * After a page loads we stay cool until the reader leaves this band (scrolls
@@ -233,6 +365,76 @@ const PIN_THRESHOLD_PX = 48;
233
365
  */
234
366
  const OLDER_PREFETCH_MARGIN_PX = 400;
235
367
  const OLDER_PREFETCH_ROOT_MARGIN = `${OLDER_PREFETCH_MARGIN_PX}px 0px 0px 0px`;
368
+ const PRIMARY_ACTION_CLASS =
369
+ "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";
370
+ const MESSAGE_BUBBLE_CLASS =
371
+ "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";
372
+ const WAITING_PILL_CLASS =
373
+ "border-og-status-waiting/35 bg-og-status-waiting/10 text-og-status-waiting";
374
+ const LOADING_CHIP_CLASS =
375
+ "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";
376
+ /** How long live progress notes take to fold into (or out of) their work row. */
377
+ const LIVE_NOTE_FOLD_MS = 220;
378
+ /** The timeline list's `gap-5`; a folded note gives it back with `-mt-5`. */
379
+ const LIVE_NOTE_GAP_PX = 20;
380
+ /** Longest a bottom-space loan waits for an opening work list to grow into it. */
381
+ const LIVE_NOTE_RESERVE_MS = 700;
382
+ /** Lets a live work disclosure report whether it is open (see `reportLiveFold`). */
383
+ const LiveNoteFoldContext = createContext<((workId: string, open: boolean) => void) | null>(null);
384
+
385
+ // State: 0 underfill retry, 1 prefetch pending, 2 compatibility-settled,
386
+ // 3 explicit no-progress waiting to become an underfill retry.
387
+ // The tuple identity is the exact request ownership fence. The boundary may rebase
388
+ // forward while that request is pending when a bounded live-tail append evicts
389
+ // its former oldest row. First-party loaders mark the exact owner when their
390
+ // older page commits; retained direction is the fallback for other hosts.
391
+ type OlderLoadAttempt = [
392
+ boundary: string | undefined,
393
+ state?: number,
394
+ receipt?: OlderHistoryLoadReceipt,
395
+ ];
396
+
397
+ function invokeOlderLoad(
398
+ load: OlderHistoryLoader,
399
+ noProgress: () => void,
400
+ attempt: OlderLoadAttempt,
401
+ preserveTail = false,
402
+ ): 1 | undefined {
403
+ try {
404
+ // Receipt creation is captured synchronously through legacy wrappers such
405
+ // as `() => void loadOlder()`, even when the wrapper discards the return.
406
+ const result = invokeOlderHistoryLoaderWithReceiptCapture(
407
+ load,
408
+ (receipt) => {
409
+ attempt[2] = receipt;
410
+ },
411
+ preserveTail,
412
+ ) as OlderHistoryLoadReceipt | PromiseLike<unknown> | unknown;
413
+ const receipt =
414
+ attempt[2] ??
415
+ (typeof (result as { committed?: unknown } | undefined)?.committed === "boolean"
416
+ ? (result as OlderHistoryLoadReceipt)
417
+ : undefined);
418
+ if (receipt) {
419
+ attempt[2] = receipt;
420
+ void receipt.then(
421
+ (value) => value === false && !receipt.committed && noProgress(),
422
+ noProgress,
423
+ );
424
+ return 1;
425
+ }
426
+ if (typeof (result as PromiseLike<unknown> | undefined)?.then != "function") {
427
+ return;
428
+ }
429
+ void (result as PromiseLike<unknown>).then(
430
+ (value) => value === false && noProgress(),
431
+ noProgress,
432
+ );
433
+ } catch {
434
+ noProgress();
435
+ }
436
+ return 1;
437
+ }
236
438
 
237
439
  /**
238
440
  * Pinned = the viewport bottom is within PIN_THRESHOLD_PX of the content
@@ -243,7 +445,8 @@ const OLDER_PREFETCH_ROOT_MARGIN = `${OLDER_PREFETCH_MARGIN_PX}px 0px 0px 0px`;
243
445
  * cannot scroll at all is always pinned.
244
446
  */
245
447
  function maxScrollOf(node: HTMLElement): number {
246
- return Math.max(0, node.scrollHeight - node.clientHeight);
448
+ // Browser layout guarantees scrollHeight is at least clientHeight.
449
+ return node.scrollHeight - node.clientHeight;
247
450
  }
248
451
 
249
452
  /**
@@ -286,6 +489,58 @@ function isNearBottom(node: HTMLElement): boolean {
286
489
  return gap < Math.min(PIN_THRESHOLD_PX, maxScroll);
287
490
  }
288
491
 
492
+ /** Content-space top of a timeline group, or null when it is not mounted. */
493
+ function contentTopOf(node: HTMLElement, groupKey: string): number | null {
494
+ const element = node.querySelector(`[data-og-group-key="${cssEscapeAttribute(groupKey)}"]`);
495
+ if (!(element instanceof HTMLElement)) {
496
+ return null;
497
+ }
498
+ return element.getBoundingClientRect().top - node.getBoundingClientRect().top + node.scrollTop;
499
+ }
500
+
501
+ /** The question the reader is inside of, once its start has scrolled out of view. */
502
+ /**
503
+ * `belowHeader`: an expanded work header is pinned to the top of the scrollport,
504
+ * so the up arrow sits just below that strip instead of covering it.
505
+ */
506
+ type QuestionNav = { key: string; belowHeader: boolean };
507
+
508
+ /** How far a question's start must be above the viewport before navigation shows. */
509
+ const QUESTION_NAV_HIDDEN_PX = 24;
510
+
511
+ function readQuestionNav(node: HTMLElement): QuestionNav | null {
512
+ const view = node.getBoundingClientRect();
513
+ const prompts = [...node.querySelectorAll<HTMLElement>("[data-og-prompt]")];
514
+ // The last prompt before the reading position owns the response/work in view.
515
+ // A newer prompt farther down the conversation is not a navigation target.
516
+ const middle = view.top + view.height / 2;
517
+ const prompt = prompts
518
+ .reverse()
519
+ .find((candidate) => candidate.getBoundingClientRect().top <= middle);
520
+ const key = prompt?.dataset.ogGroupKey;
521
+ if (!prompt || !key) return null;
522
+ const bounds = prompt.getBoundingClientRect();
523
+ if (bounds.top >= view.top - QUESTION_NAV_HIDDEN_PX || bounds.bottom > view.top) {
524
+ return null;
525
+ }
526
+ return { key, belowHeader: workHeaderPinned(node, view) };
527
+ }
528
+
529
+ /** True while an expanded outer work header is stuck to the scrollport's top edge. */
530
+ function workHeaderPinned(node: HTMLElement, view: DOMRect): boolean {
531
+ for (const header of node.querySelectorAll<HTMLElement>(
532
+ '[data-og-work-header="outer"][data-state="open"]',
533
+ )) {
534
+ const box = header.getBoundingClientRect();
535
+ if (Math.abs(box.top - view.top) <= 1 && box.bottom > view.top) return true;
536
+ }
537
+ return false;
538
+ }
539
+
540
+ function sameQuestionNav(a: QuestionNav | null, b: QuestionNav | null): boolean {
541
+ return a === b || (!!a && !!b && a.key === b.key && a.belowHeader === b.belowHeader);
542
+ }
543
+
289
544
  /** Escape a value for use inside a CSS attribute selector. */
290
545
  function cssEscapeAttribute(value: string): string {
291
546
  if (typeof CSS !== "undefined" && typeof CSS.escape === "function") {
@@ -301,13 +556,21 @@ function cssEscapeAttribute(value: string): string {
301
556
  * with a "jump to latest" affordance when the reader scrolls back.
302
557
  */
303
558
  export function MessageTimeline({
559
+ resolveLink,
560
+ renderInteractiveBlock,
561
+ userMessageDisclosureLabels,
562
+ searchTarget,
304
563
  events,
305
564
  items,
306
565
  status: _status,
566
+ renderMessageActions,
307
567
  renderMessageText,
308
568
  onOpenSession,
309
569
  onMemoryClick,
310
570
  onReconnect,
571
+ renderAuthNeeded,
572
+ renderAllowanceExhausted,
573
+ allowanceExhaustedLabels,
311
574
  shouldRenderAuthNeeded,
312
575
  resolveProviderLogo,
313
576
  toolRegistry = defaultToolRegistry,
@@ -315,9 +578,13 @@ export function MessageTimeline({
315
578
  loadRetainedArtifact,
316
579
  loadVideoArtifactPlayback,
317
580
  computeLabel = null,
581
+ genieLoading,
318
582
  turnSummary,
319
583
  autoFollow = true,
320
584
  onAnnotate,
585
+ questionNavMinViewportHeight = 0,
586
+ draftAnnotations,
587
+ onDraftAnnotationSelect,
321
588
  hasOlder = false,
322
589
  loadingOlder = false,
323
590
  onLoadOlder,
@@ -331,14 +598,143 @@ export function MessageTimeline({
331
598
  emptyState,
332
599
  className,
333
600
  }: MessageTimelineProps) {
601
+ const formatError = useErrorMessage();
334
602
  const resolvedItems = useMemo(() => {
335
- const projected = items ?? buildTimeline(events ?? []);
603
+ const projectedItems = items ?? buildTimeline(events ?? []);
336
604
  if (!shouldRenderAuthNeeded) {
337
- return projected;
605
+ return projectedItems;
338
606
  }
339
- return projected.filter((item) => item.kind !== "auth-needed" || shouldRenderAuthNeeded(item));
607
+ return projectedItems.filter(
608
+ (item) => item.kind !== "auth-needed" || shouldRenderAuthNeeded(item),
609
+ );
340
610
  }, [items, events, shouldRenderAuthNeeded]);
341
- const allGroups = useMemo(() => groupTimeline(resolvedItems), [resolvedItems]);
611
+ const searchItem = searchTarget
612
+ ? resolvedItems.find((item) => isTimelineSearchTarget(item, searchTarget))
613
+ : undefined;
614
+ const searchRevealKey =
615
+ searchItem && searchTarget
616
+ ? JSON.stringify([
617
+ searchItem.id,
618
+ searchTarget.query,
619
+ searchTarget.occurrence ?? 0,
620
+ searchTarget.offset ?? null,
621
+ ])
622
+ : null;
623
+ const searchTargetRef = useRef(searchTarget);
624
+ searchTargetRef.current = searchTarget;
625
+ const renderSearchableMessageText = useMemo(
626
+ () =>
627
+ renderMessageText
628
+ ? (text: string, item: AgentMessageItem | UserMessageItem) =>
629
+ renderMessageText(text, item, {
630
+ searchTarget: searchItem?.id === item.id ? (searchTarget ?? null) : null,
631
+ })
632
+ : (text: string, item: AgentMessageItem | UserMessageItem) => {
633
+ const body = (
634
+ <Markdown
635
+ searchTarget={searchItem?.id === item.id ? searchTarget : null}
636
+ streaming={item.kind === "agent-message" && item.streaming}
637
+ renderInteractiveBlock={
638
+ item.kind === "agent-message" ? renderInteractiveBlock : undefined
639
+ }
640
+ >
641
+ {text}
642
+ </Markdown>
643
+ );
644
+ return item.kind === "user-message" ? (
645
+ <UserMessageBody messageId={item.id} text={text}>
646
+ {body}
647
+ </UserMessageBody>
648
+ ) : (
649
+ body
650
+ );
651
+ },
652
+ [renderInteractiveBlock, renderMessageText, searchItem?.id, searchTarget],
653
+ );
654
+ // Event-window identity is independent of projected rows (partial messages
655
+ // can acquire a different first-delta id when older text arrives).
656
+ const sourceItems = events ?? items;
657
+ const olderBoundaryKey = sourceItems?.[0]?.id;
658
+ const newerBoundaryKey = `${events?.[0]?.sessionId ?? ""}:${olderBoundaryKey ?? ""}`;
659
+ const newerScopeRef = useRef(newerBoundaryKey);
660
+ newerScopeRef.current = newerBoundaryKey;
661
+ const newerAttemptRef = useRef<{ pending: boolean } | null>(null);
662
+ const newerRetryButtonRef = useRef<HTMLButtonElement | null>(null);
663
+ const [newerRetryPending, setNewerRetryPending] = useState(false);
664
+ const [newerFailure, setNewerFailure] = useState<{ key: string; message: string } | null>(null);
665
+ useEffect(() => {
666
+ newerScopeRef.current = newerBoundaryKey;
667
+ newerAttemptRef.current = null;
668
+ setNewerFailure(null);
669
+ setNewerRetryPending(false);
670
+ return () => {
671
+ newerScopeRef.current = "";
672
+ newerAttemptRef.current = null;
673
+ };
674
+ }, [newerBoundaryKey]);
675
+ const requestNewer = useCallback(
676
+ (explicitRetry = false) => {
677
+ if (
678
+ newerScopeRef.current !== newerBoundaryKey ||
679
+ !onLoadNewer ||
680
+ loadingNewer ||
681
+ (newerAttemptRef.current && (!explicitRetry || newerAttemptRef.current.pending))
682
+ ) {
683
+ return;
684
+ }
685
+ const attempt = { pending: true };
686
+ newerAttemptRef.current = attempt;
687
+ if (explicitRetry) setNewerRetryPending(true);
688
+ const isCurrent = () =>
689
+ newerAttemptRef.current === attempt && newerScopeRef.current === newerBoundaryKey;
690
+ // Both synchronous host errors and rejected promises belong to this
691
+ // boundary. Retain the failed attempt so observers cannot hot-retry it.
692
+ void Promise.resolve()
693
+ .then(() => (isCurrent() ? onLoadNewer() : undefined))
694
+ .then(
695
+ () => {
696
+ if (!isCurrent()) return;
697
+ newerAttemptRef.current = null;
698
+ // The successful page removes the recovery control. Return focus
699
+ // to the reading surface without moving the reader's viewport.
700
+ if (document.activeElement === newerRetryButtonRef.current) {
701
+ scrollRef.current?.focus({ preventScroll: true });
702
+ }
703
+ setNewerFailure(null);
704
+ setNewerRetryPending(false);
705
+ },
706
+ (reason: unknown) => {
707
+ if (!isCurrent()) return;
708
+ attempt.pending = false;
709
+ setNewerRetryPending(false);
710
+ setNewerFailure({
711
+ key: newerBoundaryKey,
712
+ message: formatError(reason),
713
+ });
714
+ },
715
+ );
716
+ },
717
+ [onLoadNewer, loadingNewer, newerBoundaryKey, formatError],
718
+ );
719
+ const previousSourceIdsRef = useRef(new Set<string>());
720
+ const previousSourceBoundaryRef = useRef<string | undefined>(undefined);
721
+ const readingAnchorRef = useRef<TimelineAnchor | null>(null);
722
+ const settlementRef = useRef<TimelineSettlement | null>(null);
723
+ const settlementAnimationRef = useRef<Animation | null>(null);
724
+ const stopSettlement = useCallback(() => {
725
+ settlementAnimationRef.current?.cancel();
726
+ settlementAnimationRef.current = null;
727
+ }, []);
728
+ const olderPageBudgetRef = useRef(0);
729
+ const [olderDemand, setOlderDemand] = useState(0);
730
+ const readableTurns = turnSummary?.rolling === true;
731
+ const projectedGroups = useMemo(
732
+ () => groupTimeline(resolvedItems, { readableTurns }),
733
+ [resolvedItems, readableTurns],
734
+ );
735
+ // A new timeline revision (including session/turn replacement) owns layout.
736
+ // Never leave an old decorative transform running on its retained rows.
737
+ useLayoutEffect(() => stopSettlement, [projectedGroups, stopSettlement]);
342
738
  const annotationSources = useMemo(() => {
343
739
  const sources = new Map<string, TimelineAnnotationSourceDescriptor>();
344
740
  for (const item of resolvedItems) {
@@ -353,29 +749,56 @@ export function MessageTimeline({
353
749
  }
354
750
  return sources;
355
751
  }, [resolvedItems]);
356
- const groups = useStableTimelineGroupKeys(allGroups);
357
752
  const scrollRef = useRef<HTMLDivElement | null>(null);
358
753
  const topSentinelRef = useRef<HTMLDivElement | null>(null);
359
754
  const bottomSentinelRef = useRef<HTMLDivElement | null>(null);
360
755
  const previousBulkFirstKeyRef = useRef<string | null | undefined>(undefined);
361
756
  const [pinned, setPinned] = useState(true);
757
+ const { groups: allGroups, release: releaseProgress } = useReadingProgress(
758
+ projectedGroups,
759
+ autoFollow && pinned && !hasNewer,
760
+ scrollRef.current,
761
+ );
762
+ const [canSkipTipCatchup, setCanSkipTipCatchup] = useState(false);
763
+ const canSkipTipCatchupRef = useRef(false);
362
764
  const [bulkActive, setBulkActive] = useState(true);
363
765
  // Older history prefetch is user-driven: a window shorter than the viewport
364
766
  // + rootMargin would otherwise keep the top sentinel intersecting and fetch
365
767
  // history forever while the reader sits at the tip. Arm on first scroll-up.
366
768
  const olderPrefetchArmedRef = useRef(false);
367
769
  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");
770
+ // A collapsed history tail can be shorter than the viewport. In that state
771
+ // there is no upward scroll range, so reader intent can never arm the top
772
+ // sentinel. Request one older page per loaded window until history either
773
+ // fills the viewport or the host reports that no older rows remain. Each
774
+ // attempt owns the oldest loaded item boundary across BOTH the automatic
775
+ // underfill and reader-driven sentinel paths. Live-tail appends do not
776
+ // advance older pagination or release that exact owner.
777
+ const olderLoadAttemptRef = useRef<OlderLoadAttempt | null>(null);
778
+ const [underfillSettledAttempt, setUnderfillSettledAttempt] = useState<OlderLoadAttempt | null>(
779
+ null,
780
+ );
781
+ const underfillRetryReadyRef = useRef(false);
372
782
  const resizeFollowRafRef = useRef<number | null>(null);
783
+ const questionNavFrameRef = useRef<number | null>(null);
784
+ const [questionNav, setQuestionNav] = useState<QuestionNav | null>(null);
785
+ const questionNavMinViewportRef = useRef(questionNavMinViewportHeight);
786
+ questionNavMinViewportRef.current = questionNavMinViewportHeight;
787
+ const scheduleQuestionNavRef = useRef<() => void>(() => {});
788
+ // Unpinned is not the same as away from the tip: focusing a control in the
789
+ // conversation (Copy, a connection card) hands the view to the reader while
790
+ // they are still at the bottom. Offer the jump only once there is something
791
+ // below them, or a newer window / catch-up to skip.
792
+ const [awayFromTip, setAwayFromTip] = useState(false);
793
+ const jumpPillShown = autoFollow && ((!pinned && awayFromTip) || hasNewer || canSkipTipCatchup);
373
794
  const firstGroupKey = allGroups[0] ? timelineGroupKey(allGroups[0]) : null;
374
795
  // Content stays invisible until the tip is hard-parked across a short
375
796
  // post-commit settle (two rAFs). That absorbs sync late layout while hidden
376
797
  // so load/remount does not ease into the tip — live tip-follow is unchanged
377
798
  // once revealed. A flash of the window's TOP is still structurally impossible.
378
- const [revealed, setRevealed] = useState(false);
799
+ // The accepted-create handoff uses the reserved local id "c" so its known
800
+ // first message is visible immediately; loaded histories still park first.
801
+ const [revealed, setRevealed] = useState(resolvedItems[0]?.id === "c");
379
802
  // Mirror `pinned` into a ref, written ONLY by applyPinned, so the
380
803
  // ResizeObserver rAF (a stable closure) reads the live value and a snap can
381
804
  // never race a just-unpinned reader across a pending React commit.
@@ -414,6 +837,8 @@ export function MessageTimeline({
414
837
  * jumps (Vimium) settle via scrollend while the camera is idle.
415
838
  */
416
839
  const readerIntentArmRef = useRef(false);
840
+ /** Gesture-start geometry survives commits before the native scroll event. */
841
+ const readerIntentStartRef = useRef<{ scrollTop: number; maxScroll: number } | null>(null);
417
842
  /**
418
843
  * Count of camera/snap scrollTop writes whose scroll echoes are not yet
419
844
  * consumed. A boolean was wrong when the browser coalesced two writes into
@@ -438,25 +863,273 @@ export function MessageTimeline({
438
863
  // deliberate chip remounts (activity→turn wrap, nested key flips) so a fold
439
864
  // that already settled closed — or that the reader closed — never reopens.
440
865
  const foldMemoryRef = useRef<Map<string, FoldRestingState>>(new Map());
866
+ // Live work disclosures that are open. While open, that turn's progress
867
+ // notes read inside the disclosure, so their copies above it fold away.
868
+ const [openLiveFolds, setOpenLiveFolds] = useState<ReadonlySet<string>>(() => new Set());
869
+ const openLiveFoldsRef = useRef<ReadonlySet<string>>(openLiveFolds);
870
+ const liveNoteIdsByWorkRef = useRef<ReadonlyMap<string, readonly string[]>>(new Map());
871
+ const liveFoldMotionRef = useRef<{ frame: number | null; finish: () => void } | null>(null);
872
+ // The bottom-space loan currently on the scroller, by owner.
873
+ const liveFoldReserveRef = useRef<object | null>(null);
874
+ // writeScrollTop is declared further down; the fold reaches it through this.
875
+ const writeScrollTopRef = useRef<(node: HTMLElement, top: number) => void>(() => {});
876
+ const reportLiveFold = useCallback((workId: string, open: boolean) => {
877
+ const current = openLiveFoldsRef.current;
878
+ if (current.has(workId) === open) return;
879
+ const next = new Set(current);
880
+ if (open) next.add(workId);
881
+ else next.delete(workId);
882
+ openLiveFoldsRef.current = next;
883
+ liveFoldMotionRef.current?.finish();
884
+ const scroller = scrollRef.current;
885
+ const rows = (liveNoteIdsByWorkRef.current.get(workId) ?? []).flatMap((id) => {
886
+ const row = scroller?.querySelector<HTMLElement>(
887
+ `[data-og-live-note-id="${cssEscapeAttribute(id)}"]`,
888
+ );
889
+ return row ? [row] : [];
890
+ });
891
+ if (!scroller || rows.length === 0) {
892
+ setOpenLiveFolds(next);
893
+ return;
894
+ }
895
+ const header = scroller.querySelector<HTMLElement>(
896
+ `[data-og-work-id="${cssEscapeAttribute(workId)}"] [data-og-work-header="outer"]`,
897
+ );
898
+ const viewTop = scroller.getBoundingClientRect().top;
899
+ const headerTop = header?.getBoundingClientRect().top;
900
+ if (
901
+ !open &&
902
+ header &&
903
+ headerTop !== undefined &&
904
+ (headerTop < viewTop || headerTop > viewTop + scroller.clientHeight)
905
+ ) {
906
+ // Closed from the row's sticky header after reading down the list: the
907
+ // row is no longer where the reader is. Like any big collapse, land on
908
+ // it in one step — the notes return above and the closed row rests at
909
+ // the bottom edge — rather than animating content the reader can't see.
910
+ setOpenLiveFolds(next);
911
+ requestFrame(() =>
912
+ requestFrame(() => {
913
+ const row = header.closest<HTMLElement>("[data-og-timeline-group-anchor]");
914
+ if (!row?.isConnected) return;
915
+ const box = row.getBoundingClientRect();
916
+ const top = scroller.getBoundingClientRect().top;
917
+ const bottom = top + scroller.clientHeight;
918
+ // + the scroller's pb-6, as at the true end.
919
+ if (box.bottom > bottom || box.top < top)
920
+ writeScrollTopRef.current(scroller, scroller.scrollTop + box.bottom - bottom + 24);
921
+ scheduleQuestionNavRef.current();
922
+ }),
923
+ );
924
+ return;
925
+ }
926
+ // Opening a live row near the bottom drops its one-line preview while its
927
+ // list grows from nothing; closing shrinks the list before the notes have
928
+ // grown back. For that moment the page is shorter than where the reader
929
+ // is, so the browser would clamp the scroll and everything would drop.
930
+ // This runs before that frame paints: keep the reader's position and lend
931
+ // one screen of space at the bottom (the shortfall keeps changing through
932
+ // the commit) until the content has grown back into it.
933
+ const readerTop = lastScrollTopRef.current;
934
+ const reserve = readerTop > 0.5 ? scroller.clientHeight : 0;
935
+ const loan = {};
936
+ if (reserve > 0) {
937
+ liveFoldReserveRef.current = loan;
938
+ scroller.style.paddingBottom = `calc(1.5rem + ${reserve}px)`;
939
+ if (Math.abs(scroller.scrollTop - readerTop) > 0.5)
940
+ writeScrollTopRef.current(scroller, readerTop);
941
+ }
942
+ const releaseReserve = (deadline: number) => {
943
+ // Only this motion's loan; a newer toggle may have replaced it.
944
+ if (reserve === 0 || liveFoldReserveRef.current !== loan) return;
945
+ const needed = scroller.scrollTop + scroller.clientHeight - (scroller.scrollHeight - reserve);
946
+ if (needed > 0.5 && performance.now() < deadline && scroller.isConnected) {
947
+ requestFrame(() => releaseReserve(deadline));
948
+ return;
949
+ }
950
+ liveFoldReserveRef.current = null;
951
+ scroller.style.removeProperty("padding-bottom");
952
+ };
953
+ // The notes fold into (or out of) the work row in one short motion. The
954
+ // row glides to where it should rest: opening, into the first note's place
955
+ // (or the top edge, when that note was above view) so its list starts just
956
+ // beneath; closing, down below its returning notes but never past the
957
+ // bottom edge. When everything is in view that is exactly what happens on
958
+ // its own; otherwise the scroll is written in the same frame as the
959
+ // heights, so it cannot lag. Native scroll anchoring is suspended for the
960
+ // motion; inline styles pin the current look across the state commit, end
961
+ // exactly at the classes' resting geometry, and are then removed.
962
+ const workRow = header?.closest<HTMLElement>("[data-og-timeline-group-anchor]") ?? null;
963
+ const rowStart = workRow?.getBoundingClientRect().top;
964
+ const viewBottom = viewTop + scroller.clientHeight;
965
+ let landing =
966
+ open && rowStart !== undefined
967
+ ? Math.max(rows[0]!.getBoundingClientRect().top, viewTop)
968
+ : null;
969
+ const heights = rows.map((row) => (open ? row.offsetHeight : 0));
970
+ const apply = (progress: number) => {
971
+ const shown = open ? 1 - progress : progress;
972
+ rows.forEach((note, index) => {
973
+ // The list gap travels inside the row as padding (margin cancels it),
974
+ // so a shrinking row never overlaps the one above it mid-motion.
975
+ note.style.marginTop = `${-LIVE_NOTE_GAP_PX}px`;
976
+ note.style.paddingTop = `${LIVE_NOTE_GAP_PX * shown}px`;
977
+ note.style.height = `${(heights[index]! + LIVE_NOTE_GAP_PX) * shown}px`;
978
+ note.style.opacity = String(shown);
979
+ });
980
+ if (workRow && rowStart !== undefined && landing !== null) {
981
+ const drift =
982
+ workRow.getBoundingClientRect().top - (rowStart + (landing - rowStart) * progress);
983
+ if (Math.abs(drift) > 0.5) writeScrollTopRef.current(scroller, scroller.scrollTop + drift);
984
+ }
985
+ };
986
+ for (const row of rows) {
987
+ row.style.boxSizing = "border-box";
988
+ row.style.overflow = "hidden";
989
+ row.style.overflowAnchor = "none";
990
+ }
991
+ scroller.style.overflowAnchor = "none";
992
+ apply(0);
993
+ const fold: { frame: number | null; finish: () => void } = {
994
+ frame: null,
995
+ finish: () => {
996
+ if (fold.frame != null) cancelFrame(fold.frame);
997
+ for (const row of rows) {
998
+ for (const property of [
999
+ "height",
1000
+ "margin-top",
1001
+ "padding-top",
1002
+ "opacity",
1003
+ "overflow",
1004
+ "overflow-anchor",
1005
+ "box-sizing",
1006
+ ])
1007
+ row.style.removeProperty(property);
1008
+ }
1009
+ scroller.style.removeProperty("overflow-anchor");
1010
+ releaseReserve(performance.now() + LIVE_NOTE_RESERVE_MS);
1011
+ // The row may now be pinned to the top without any scroll: re-place
1012
+ // the up arrow (it sits below a pinned work header).
1013
+ scheduleQuestionNavRef.current();
1014
+ if (liveFoldMotionRef.current === fold) liveFoldMotionRef.current = null;
1015
+ },
1016
+ };
1017
+ liveFoldMotionRef.current = fold;
1018
+ const duration = prefersReducedMotion() ? 0 : LIVE_NOTE_FOLD_MS;
1019
+ let began: number | null = null;
1020
+ const step = (now: number) => {
1021
+ fold.frame = null;
1022
+ if (!rows.every((row) => row.isConnected)) return fold.finish();
1023
+ if (!open && began === null) {
1024
+ // Unfolding: the natural heights are known only once the folded
1025
+ // class is gone; the inline styles still hold them at zero.
1026
+ rows.forEach((note, index) => (heights[index] = note.scrollHeight));
1027
+ if (workRow && rowStart !== undefined) {
1028
+ const returning = heights.reduce((sum, height) => sum + height + LIVE_NOTE_GAP_PX, 0);
1029
+ // The closed row is its header plus one preview line (its list is
1030
+ // still collapsing now, so the row's own height is not final yet).
1031
+ const closedRow = 2 * (header?.getBoundingClientRect().height ?? 0);
1032
+ const lowest = viewBottom - closedRow - 24;
1033
+ landing = Math.min(rowStart + returning, Math.max(lowest, rowStart));
1034
+ }
1035
+ }
1036
+ began ??= now;
1037
+ const linear = duration === 0 ? 1 : Math.min(1, (now - began) / duration);
1038
+ apply(1 - (1 - linear) ** 3);
1039
+ if (linear < 1) fold.frame = requestFrame(step);
1040
+ else fold.finish();
1041
+ };
1042
+ fold.frame = requestFrame(step);
1043
+ setOpenLiveFolds(next);
1044
+ }, []);
1045
+ useEffect(
1046
+ () => () => {
1047
+ liveFoldMotionRef.current?.finish();
1048
+ },
1049
+ [],
1050
+ );
441
1051
  const userMessageDisclosureMemoryRef = useRef<Map<string, boolean>>(new Map());
442
1052
  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());
1053
+ const firstItemGroupKeyRef = useRef<string | null>(null);
1054
+ const firstItemGroupOffsetTopRef = useRef<number | null>(null);
1055
+ const firstItemContentTopRef = useRef<number | null>(null);
445
1056
  const firstItemId = resolvedItems[0]?.id ?? null;
1057
+ // Pagination ownership follows the oldest committed input, before host
1058
+ // filtering and grouping. A page containing only suppressed auth notices
1059
+ // still advances this receipt, while live-tail appends leave it unchanged.
1060
+ // Promise fulfillment alone does neither, so delayed prepends stay fenced.
1061
+ const underfillRetryReady = (underfillRetryReadyRef.current = !!(
1062
+ underfillSettledAttempt &&
1063
+ underfillSettledAttempt === olderLoadAttemptRef.current &&
1064
+ hasOlder &&
1065
+ onLoadOlder &&
1066
+ !loadingOlder
1067
+ ));
446
1068
  // Bulk paints (the initial tail window, a prepended older window — detected
447
1069
  // by the first group key changing) must not run per-row entrance animations.
448
1070
  const firstKeyChangedForBulk =
449
1071
  previousBulkFirstKeyRef.current !== undefined &&
450
1072
  previousBulkFirstKeyRef.current !== firstGroupKey;
451
1073
  const bulkRender = allGroups.length > 0 && (bulkActive || firstKeyChangedForBulk);
1074
+ const groups = useStableTimelineGroupKeys(allGroups, !bulkRender);
1075
+ // Each live progress note → whether its turn's work disclosure is open.
1076
+ const [liveNoteFolds, liveNoteIdsByWork] = useMemo(() => {
1077
+ const folds = new Map<string, boolean>();
1078
+ const byWork = new Map<string, readonly string[]>();
1079
+ for (const { group } of groups) {
1080
+ if (group.kind !== "activity" || !group.work?.liveNoteIds) continue;
1081
+ byWork.set(group.id, group.work.liveNoteIds);
1082
+ for (const id of group.work.liveNoteIds) folds.set(id, openLiveFolds.has(group.id));
1083
+ }
1084
+ return [folds, byWork] as const;
1085
+ }, [groups, openLiveFolds]);
1086
+ useLayoutEffect(() => {
1087
+ liveNoteIdsByWorkRef.current = liveNoteIdsByWork;
1088
+ }, [liveNoteIdsByWork]);
1089
+ const turnsWithOutput = useMemo(
1090
+ () =>
1091
+ new Set(
1092
+ resolvedItems.flatMap((item) =>
1093
+ "turnId" in item &&
1094
+ item.turnId &&
1095
+ (item.kind === "tool-call" ||
1096
+ ((item.kind === "agent-message" || item.kind === "reasoning") && item.text.trim()))
1097
+ ? [item.turnId]
1098
+ : [],
1099
+ ),
1100
+ ),
1101
+ [resolvedItems],
1102
+ );
1103
+
1104
+ const applyCanSkipTipCatchup = useCallback((value: boolean) => {
1105
+ if (canSkipTipCatchupRef.current !== value) {
1106
+ canSkipTipCatchupRef.current = value;
1107
+ setCanSkipTipCatchup(value);
1108
+ }
1109
+ }, []);
452
1110
 
453
1111
  // The ONLY writer of the pinned flag. Ref and state move together, so
454
1112
  // behavior (refs read by rAF callbacks) and rendering (the anchor class,
455
1113
  // 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);
1114
+ const applyPinned = useCallback(
1115
+ (value: boolean) => {
1116
+ if (pinnedRef.current !== value) {
1117
+ pinnedRef.current = value;
1118
+ setPinned(value);
1119
+ }
1120
+ if (!value) {
1121
+ applyCanSkipTipCatchup(false);
1122
+ }
1123
+ },
1124
+ [applyCanSkipTipCatchup],
1125
+ );
1126
+
1127
+ const rearmOlderPrefetchAfterLeavingTop = useCallback((node: HTMLElement) => {
1128
+ if (node.scrollTop > OLDER_PREFETCH_MARGIN_PX) {
1129
+ const state = olderLoadAttemptRef.current?.[1];
1130
+ if (state === 2 || state === 3) {
1131
+ olderLoadAttemptRef.current = null;
1132
+ }
460
1133
  }
461
1134
  }, []);
462
1135
 
@@ -466,6 +1139,7 @@ export function MessageTimeline({
466
1139
  // Pure tip-follow camera. Pin intent uses clamp conservation, not timers.
467
1140
  const followRef = useRef<TipFollowState>(createTipFollowState());
468
1141
  const followFrameRef = useRef<number | null>(null);
1142
+ const contentShrinkBaselineRef = useRef<TipFollowContentShrinkBaseline | null>(null);
469
1143
 
470
1144
  const syncScrollBaseline = useCallback((node: HTMLElement) => {
471
1145
  lastScrollTopRef.current = node.scrollTop;
@@ -480,6 +1154,9 @@ export function MessageTimeline({
480
1154
  if (before === next) {
481
1155
  return;
482
1156
  }
1157
+ // Unpinned camera/anchor writes invalidate return intent. A pinned
1158
+ // pointer gesture still needs its cumulative leave budget across writes.
1159
+ if (!pinnedRef.current || !readerIntentArmRef.current) readerIntentStartRef.current = null;
483
1160
  programmaticScrollRef.current += 1;
484
1161
  node.scrollTop = next;
485
1162
  if (node.scrollTop === before) {
@@ -489,9 +1166,10 @@ export function MessageTimeline({
489
1166
  programmaticScrollRef.current -= 1;
490
1167
  }
491
1168
  }, []);
1169
+ writeScrollTopRef.current = writeScrollTop;
492
1170
 
493
1171
  const cancelLeaveFallback = useCallback(() => {
494
- if (leaveFallbackRafRef.current !== null) {
1172
+ if (leaveFallbackRafRef.current != null) {
495
1173
  cancelFrame(leaveFallbackRafRef.current);
496
1174
  leaveFallbackRafRef.current = null;
497
1175
  }
@@ -502,38 +1180,58 @@ export function MessageTimeline({
502
1180
  cancelLeaveFallback();
503
1181
  }, [cancelLeaveFallback]);
504
1182
 
1183
+ const clearReaderIntent = useCallback(() => {
1184
+ readerIntentArmRef.current = false;
1185
+ readerIntentStartRef.current = null;
1186
+ }, []);
1187
+
505
1188
  const stopFollow = useCallback(() => {
1189
+ contentShrinkBaselineRef.current = null;
506
1190
  followRef.current = tipFollowCancel(followRef.current);
507
- if (followFrameRef.current !== null) {
1191
+ if (followFrameRef.current != null) {
508
1192
  cancelFrame(followFrameRef.current);
509
1193
  followFrameRef.current = null;
510
1194
  }
511
1195
  }, []);
512
1196
 
513
1197
  /** 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);
1198
+ const releasePinFromReader = useCallback(() => {
1199
+ if (!autoFollow || !pinnedRef.current || hasNewerRef.current) {
1200
+ return;
1201
+ }
1202
+ clearReaderIntent();
1203
+ clearPendingReaderLeave();
1204
+ stopFollow();
1205
+ applyPinned(false);
1206
+ if (wantPinRef.current) {
1207
+ wantPinRef.current = false;
1208
+ }
1209
+ if (!olderPrefetchArmedRef.current) {
1210
+ olderPrefetchArmedRef.current = true;
1211
+ setOlderPrefetchArmed(true);
1212
+ }
1213
+ }, [autoFollow, applyPinned, clearPendingReaderLeave, clearReaderIntent, stopFollow]);
1214
+
1215
+ useEffect(() => {
1216
+ if (!readableTurns) return;
1217
+ const node = scrollRef.current;
1218
+ if (!node) return;
1219
+ const onSelection = () => {
1220
+ const selection = node.ownerDocument.getSelection();
1221
+ if (
1222
+ selection &&
1223
+ !selection.isCollapsed &&
1224
+ node.contains(selection.anchorNode) &&
1225
+ node.contains(selection.focusNode)
1226
+ ) {
1227
+ stopSettlement();
1228
+ releasePinFromReader();
1229
+ disclosureKeepsUnpinnedRef.current = true;
533
1230
  }
534
- },
535
- [autoFollow, applyPinned, clearPendingReaderLeave, stopFollow],
536
- );
1231
+ };
1232
+ node.ownerDocument.addEventListener("selectionchange", onSelection);
1233
+ return () => node.ownerDocument.removeEventListener("selectionchange", onSelection);
1234
+ }, [readableTurns, releasePinFromReader, stopSettlement]);
537
1235
 
538
1236
  /**
539
1237
  * Settled away from the tip while the camera is idle — Vimium / unfocused
@@ -547,19 +1245,17 @@ export function MessageTimeline({
547
1245
  if (programmaticScrollRef.current > 0) {
548
1246
  return;
549
1247
  }
550
- if (followRef.current.running || followFrameRef.current !== null) {
1248
+ if (followRef.current.running || followFrameRef.current != null) {
551
1249
  return;
552
1250
  }
553
1251
  if (isNearBottom(node) || maxScrollOf(node) <= 1) {
554
1252
  clearPendingReaderLeave();
555
1253
  return;
556
1254
  }
557
- releasePinFromReader(node);
558
- if (olderLoadGateRef.current === "cooling" && node.scrollTop > OLDER_PREFETCH_MARGIN_PX) {
559
- olderLoadGateRef.current = "armed";
560
- }
1255
+ releasePinFromReader();
1256
+ rearmOlderPrefetchAfterLeavingTop(node);
561
1257
  },
562
- [autoFollow, clearPendingReaderLeave, releasePinFromReader],
1258
+ [autoFollow, clearPendingReaderLeave, rearmOlderPrefetchAfterLeavingTop, releasePinFromReader],
563
1259
  );
564
1260
 
565
1261
  const scheduleLeaveFallback = useCallback(() => {
@@ -577,6 +1273,22 @@ export function MessageTimeline({
577
1273
  });
578
1274
  }, [cancelLeaveFallback, releasePinAfterScrollSettled]);
579
1275
 
1276
+ const requestEarlierFromReader = () => {
1277
+ // Upward navigation replaces any earlier downward gesture, even when
1278
+ // already unpinned and the engine never delivered scrollend.
1279
+ clearReaderIntent();
1280
+ releasePinFromReader();
1281
+ wantPinRef.current = false;
1282
+ // A stationary upward gesture is still demand. Successful short/folded
1283
+ // pages may never create enough range to leave the prefetch band.
1284
+ olderPageBudgetRef.current = 8;
1285
+ if (olderLoadAttemptRef.current?.[1] === 2) {
1286
+ olderLoadAttemptRef.current = null;
1287
+ setOlderDemand((value) => value + 1);
1288
+ }
1289
+ };
1290
+ const touchPositionRef = useRef<{ x: number; y: number } | null>(null);
1291
+
580
1292
  const onWheel = (event: {
581
1293
  deltaY: number;
582
1294
  deltaX: number;
@@ -593,13 +1305,17 @@ export function MessageTimeline({
593
1305
  if (wheelConsumedByNestedScrollable(event)) {
594
1306
  return;
595
1307
  }
1308
+ stopSettlement();
596
1309
  disclosureKeepsUnpinnedRef.current = false;
1310
+ programmaticScrollRef.current = 0;
597
1311
  if (event.deltaY >= 0) {
1312
+ const node = scrollRef.current;
1313
+ readerIntentStartRef.current = node
1314
+ ? { scrollTop: node.scrollTop, maxScroll: maxScrollOf(node) }
1315
+ : null;
598
1316
  return;
599
1317
  }
600
- const node =
601
- event.currentTarget instanceof HTMLElement ? event.currentTarget : scrollRef.current;
602
- releasePinFromReader(node);
1318
+ requestEarlierFromReader();
603
1319
  };
604
1320
 
605
1321
  /** Touch / stylus / mouse drag on the scroller — explicit leave (not layout). */
@@ -607,11 +1323,13 @@ export function MessageTimeline({
607
1323
  button: number;
608
1324
  pointerType: string;
609
1325
  target: EventTarget | null;
1326
+ currentTarget: EventTarget | null;
610
1327
  }) => {
611
1328
  // Primary button / touch / pen only. Ignore right-click etc.
612
- if (event.button !== 0 && event.pointerType === "mouse") {
1329
+ if (event.button && event.pointerType === "mouse") {
613
1330
  return;
614
1331
  }
1332
+ stopSettlement();
615
1333
  // Clicks on chips/buttons/links must not arm — their settle collapse
616
1334
  // also drops scrollTop and would false-unpin. Drag on prose/scroller may.
617
1335
  if (
@@ -621,7 +1339,13 @@ export function MessageTimeline({
621
1339
  return;
622
1340
  }
623
1341
  disclosureKeepsUnpinnedRef.current = false;
1342
+ programmaticScrollRef.current = 0;
1343
+ const node =
1344
+ event.currentTarget instanceof HTMLElement ? event.currentTarget : scrollRef.current;
624
1345
  readerIntentArmRef.current = true;
1346
+ readerIntentStartRef.current = node
1347
+ ? { scrollTop: node.scrollTop, maxScroll: maxScrollOf(node) }
1348
+ : null;
625
1349
  };
626
1350
 
627
1351
  const onKeyDown = (event: { key: string; currentTarget: EventTarget | null }) => {
@@ -633,18 +1357,25 @@ export function MessageTimeline({
633
1357
  event.key === "Home" ||
634
1358
  event.key === "End"
635
1359
  ) {
1360
+ stopSettlement();
636
1361
  disclosureKeepsUnpinnedRef.current = false;
1362
+ // Reader navigation supersedes a pending camera/anchor scroll echo,
1363
+ // including downward keys that do not explicitly release the pin.
1364
+ programmaticScrollRef.current = 0;
1365
+ const node = scrollRef.current;
1366
+ readerIntentStartRef.current = node
1367
+ ? { scrollTop: node.scrollTop, maxScroll: maxScrollOf(node) }
1368
+ : null;
637
1369
  }
638
1370
  if (event.key !== "ArrowUp" && event.key !== "PageUp" && event.key !== "Home") {
639
1371
  return;
640
1372
  }
641
- const node =
642
- event.currentTarget instanceof HTMLElement ? event.currentTarget : scrollRef.current;
643
- releasePinFromReader(node);
1373
+ requestEarlierFromReader();
644
1374
  };
645
1375
 
646
1376
  const snapToBottom = useCallback(
647
1377
  (node: HTMLElement) => {
1378
+ clearReaderIntent();
648
1379
  stopFollow();
649
1380
  cancelLeaveFallback();
650
1381
  writeScrollTop(node, Math.max(0, node.scrollHeight - node.clientHeight));
@@ -655,8 +1386,16 @@ export function MessageTimeline({
655
1386
  lastClientHeight: node.clientHeight,
656
1387
  cameraTop: null,
657
1388
  };
1389
+ applyCanSkipTipCatchup(false);
658
1390
  },
659
- [cancelLeaveFallback, stopFollow, syncScrollBaseline, writeScrollTop],
1391
+ [
1392
+ applyCanSkipTipCatchup,
1393
+ cancelLeaveFallback,
1394
+ clearReaderIntent,
1395
+ stopFollow,
1396
+ syncScrollBaseline,
1397
+ writeScrollTop,
1398
+ ],
660
1399
  );
661
1400
 
662
1401
  const beginUserMessageDisclosureChange = useCallback(
@@ -714,11 +1453,171 @@ export function MessageTimeline({
714
1453
  () => ({
715
1454
  expandedByMessageId: userMessageDisclosureMemoryRef.current,
716
1455
  beginChange: beginUserMessageDisclosureChange,
1456
+ labels: {
1457
+ showMore: userMessageDisclosureLabels?.showMore,
1458
+ showLess: userMessageDisclosureLabels?.showLess,
1459
+ },
1460
+ }),
1461
+ [
1462
+ beginUserMessageDisclosureChange,
1463
+ userMessageDisclosureLabels?.showMore,
1464
+ userMessageDisclosureLabels?.showLess,
1465
+ ],
1466
+ );
1467
+ const timelineGroupEntryContext = useMemo<TimelineGroupEntryContext>(
1468
+ () => ({
1469
+ userMessageDisclosureContext,
1470
+ behavior: {
1471
+ renderMessageActions,
1472
+ renderMessageText: renderSearchableMessageText,
1473
+ onOpenSession,
1474
+ onMemoryClick,
1475
+ onReconnect,
1476
+ renderAuthNeeded,
1477
+ resolveProviderLogo,
1478
+ toolRegistry,
1479
+ loadRetainedScreenshot,
1480
+ loadRetainedArtifact,
1481
+ loadVideoArtifactPlayback,
1482
+ genieLoading,
1483
+ turnSummary,
1484
+ },
717
1485
  }),
718
- [beginUserMessageDisclosureChange],
1486
+ [
1487
+ loadRetainedArtifact,
1488
+ loadRetainedScreenshot,
1489
+ loadVideoArtifactPlayback,
1490
+ onMemoryClick,
1491
+ onOpenSession,
1492
+ onReconnect,
1493
+ renderAuthNeeded,
1494
+ renderMessageActions,
1495
+ renderSearchableMessageText,
1496
+ resolveProviderLogo,
1497
+ toolRegistry,
1498
+ genieLoading,
1499
+ turnSummary,
1500
+ userMessageDisclosureContext,
1501
+ ],
1502
+ );
1503
+
1504
+ const releaseSearchPin = useCallback(() => {
1505
+ applyPinned(false);
1506
+ stopFollow();
1507
+ setRevealed(true);
1508
+ }, [applyPinned, stopFollow]);
1509
+ useTimelineSearchNavigation(
1510
+ scrollRef,
1511
+ searchItem?.id,
1512
+ searchTarget,
1513
+ releaseSearchPin,
1514
+ resolvedItems,
1515
+ );
1516
+
1517
+ const requestOlderIfUnderfilled = useCallback(
1518
+ (node: HTMLElement, retry?: OlderLoadAttempt) => {
1519
+ let currentAttempt = olderLoadAttemptRef.current;
1520
+ if (retry && (currentAttempt !== retry || retry[0] !== olderBoundaryKey)) {
1521
+ // AnimatePresence may retain the exiting button briefly. Its stale
1522
+ // handler cannot replace a newer exact owner.
1523
+ return;
1524
+ }
1525
+ const underfilled = maxScrollOf(node) <= 1;
1526
+ if (!retry && underfilled && currentAttempt?.[1] === 3) {
1527
+ // A receipted prefetch already declined or failed while the viewport
1528
+ // was still scrollable. Collapse may later remove the scroll range;
1529
+ // promote that exact owner to Retry without issuing another request.
1530
+ currentAttempt[1] = 0;
1531
+ setUnderfillSettledAttempt(currentAttempt);
1532
+ return;
1533
+ }
1534
+ if (!retry && underfilled && currentAttempt?.[1] === 2) {
1535
+ // A settled ordinary prefetch owns only this visit to the top band.
1536
+ // If its resulting window cannot scroll, yield to automatic underfill
1537
+ // so the reader is not stranded behind a cooldown they cannot exit.
1538
+ currentAttempt = olderLoadAttemptRef.current = null;
1539
+ }
1540
+ // clientHeight=0 is pre-layout/headless, not evidence that the rendered
1541
+ // history underfills a real viewport.
1542
+ if (
1543
+ node.clientHeight <= 1 ||
1544
+ (!retry && !underfilled) ||
1545
+ !hasOlder ||
1546
+ loadingOlder ||
1547
+ !onLoadOlder ||
1548
+ (currentAttempt && !retry)
1549
+ ) {
1550
+ return;
1551
+ }
1552
+ const attempt: OlderLoadAttempt = (olderLoadAttemptRef.current = [olderBoundaryKey]);
1553
+ setUnderfillSettledAttempt(null);
1554
+ // Every underfill request owns the ordinary sentinel too, even if reader
1555
+ // intent had not armed it yet when this short-window request began.
1556
+ const noProgress = () => {
1557
+ if (scrollRef.current && olderLoadAttemptRef.current === attempt) {
1558
+ setUnderfillSettledAttempt(attempt);
1559
+ }
1560
+ };
1561
+ // Legacy fire-and-forget callbacks keep the one-shot behavior. Hosts
1562
+ // that return the real promise opt into safe rejection/no-progress retry;
1563
+ // exact `false` is the first-party request-not-accepted receipt.
1564
+ // All other fulfillment retains this exact owner until its prepend
1565
+ // boundary commits; promise settlement alone cannot prove progress.
1566
+ invokeOlderLoad(onLoadOlder, noProgress, attempt, !retry);
1567
+ },
1568
+ [hasOlder, loadingOlder, olderBoundaryKey, onLoadOlder],
1569
+ );
1570
+ const scheduleQuestionNav = useCallback(() => {
1571
+ if (!readableTurns || questionNavFrameRef.current != null) {
1572
+ return;
1573
+ }
1574
+ questionNavFrameRef.current = requestFrame(() => {
1575
+ questionNavFrameRef.current = null;
1576
+ const node = scrollRef.current;
1577
+ // A short viewport (a narrow embed above a decision card) has no room
1578
+ // for a floating pill that would cover the very rows being read.
1579
+ // clientHeight <= 1 is pre-layout/headless, not a short viewport.
1580
+ const roomy =
1581
+ node !== null &&
1582
+ (node.clientHeight <= 1 || node.clientHeight >= questionNavMinViewportRef.current);
1583
+ const next = node && roomy ? readQuestionNav(node) : null;
1584
+ setQuestionNav((current) => (sameQuestionNav(current, next) ? current : next));
1585
+ });
1586
+ }, [readableTurns]);
1587
+ scheduleQuestionNavRef.current = scheduleQuestionNav;
1588
+ useEffect(
1589
+ () => () => {
1590
+ if (questionNavFrameRef.current != null) {
1591
+ cancelFrame(questionNavFrameRef.current);
1592
+ questionNavFrameRef.current = null;
1593
+ }
1594
+ },
1595
+ [],
719
1596
  );
1597
+ const jumpToQuestion = (key: string) => {
1598
+ const node = scrollRef.current;
1599
+ const top = node ? contentTopOf(node, key) : null;
1600
+ if (!node || top === null) {
1601
+ return;
1602
+ }
1603
+ // Explicit reader navigation: leave the tip and park the question on top.
1604
+ releasePinFromReader();
1605
+ wantPinRef.current = false;
1606
+ disclosureKeepsUnpinnedRef.current = false;
1607
+ writeScrollTop(node, Math.max(0, top - QUESTION_NAV_MARGIN_PX));
1608
+ // Hand keyboard/accessibility focus to the actual destination. This also
1609
+ // gives native scroll anchoring the new question rather than an old work
1610
+ // row when the bounded context subsequently gains a later history page.
1611
+ node
1612
+ .querySelector<HTMLElement>(`[data-og-group-key="${cssEscapeAttribute(key)}"]`)
1613
+ ?.focus({ preventScroll: true });
1614
+ syncScrollBaseline(node);
1615
+ scheduleQuestionNav();
1616
+ };
720
1617
 
721
- const driveFollowRef = useRef<(node: HTMLElement, now?: number) => void>(() => undefined);
1618
+ const driveFollowRef = useRef<(node: HTMLElement, now?: number) => void>(
1619
+ requestOlderIfUnderfilled as (node: HTMLElement, now?: number) => void,
1620
+ );
722
1621
  const driveFollow = useCallback(
723
1622
  (node: HTMLElement, nowMs?: number) => {
724
1623
  if (!pinnedRef.current || hasNewerRef.current) {
@@ -739,19 +1638,41 @@ export function MessageTimeline({
739
1638
  : typeof performance !== "undefined"
740
1639
  ? performance.now()
741
1640
  : Date.now();
742
- const previousHeight = followRef.current.lastHeight;
1641
+ let previousHeight = followRef.current.lastHeight;
1642
+ const previousObservedHeight = lastScrollHeightRef.current;
1643
+ if (
1644
+ contentShrinkBaselineRef.current &&
1645
+ previousObservedHeight > 0 &&
1646
+ node.scrollHeight > previousObservedHeight
1647
+ ) {
1648
+ // A reversal ends the collapse sequence. If it remains below the held
1649
+ // baseline, adopt the recovered height; if it grew beyond the baseline,
1650
+ // leave that baseline for tipFollowStep to observe as real growth.
1651
+ contentShrinkBaselineRef.current = null;
1652
+ if (node.scrollHeight < previousHeight) {
1653
+ followRef.current = {
1654
+ ...followRef.current,
1655
+ lastHeight: node.scrollHeight,
1656
+ cameraTop: null,
1657
+ };
1658
+ previousHeight = node.scrollHeight;
1659
+ }
1660
+ }
1661
+ const shrinkObservation = tipFollowObserveContentShrink(
1662
+ contentShrinkBaselineRef.current,
1663
+ previousHeight,
1664
+ lastScrollTopRef.current,
1665
+ node.scrollHeight,
1666
+ node.clientHeight,
1667
+ );
1668
+ contentShrinkBaselineRef.current = shrinkObservation.baseline;
743
1669
  // Settle-collapse: compensate Δh from the pre-shrink baseline (browser
744
1670
  // may already have clamped — don't double-subtract). Keep the follow rAF
745
1671
  // alive so when collapse ends (or stream resumes) we ease instead of a
746
1672
  // hard stop → flick. Do NOT tip-ease on the same frame as a real shrink
747
1673
  // (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
- );
1674
+ if (shrinkObservation.compensatedScrollTop !== null) {
1675
+ let nextTop = shrinkObservation.compensatedScrollTop;
755
1676
  // A chrome/composer dock can land on the same frame as a settle-fold
756
1677
  // (the "turn blocked" moment). Compensate BOTH in one write — adopting
757
1678
  // the shrunk clientHeight below without gluing left the chrome height
@@ -776,7 +1697,7 @@ export function MessageTimeline({
776
1697
  // A direct glue write re-based the camera — drop any stale fraction.
777
1698
  cameraTop: null,
778
1699
  };
779
- if (followFrameRef.current === null) {
1700
+ if (followFrameRef.current == null) {
780
1701
  followFrameRef.current = requestFrame((frameNow) => {
781
1702
  followFrameRef.current = null;
782
1703
  const current = scrollRef.current;
@@ -787,15 +1708,6 @@ export function MessageTimeline({
787
1708
  }
788
1709
  return;
789
1710
  }
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
1711
  const result = tipFollowStep(followRef.current, {
800
1712
  scrollTop: node.scrollTop,
801
1713
  scrollHeight: node.scrollHeight,
@@ -808,9 +1720,13 @@ export function MessageTimeline({
808
1720
  followRef.current = result.state;
809
1721
  writeScrollTop(node, result.scrollTop);
810
1722
  syncScrollBaseline(node);
1723
+ applyCanSkipTipCatchup(
1724
+ result.state.running &&
1725
+ maxScrollOf(node) - node.scrollTop >= JUMP_TO_LATEST_CATCHUP_DEBT_PX,
1726
+ );
811
1727
  if (result.state.running) {
812
1728
  cancelLeaveFallback();
813
- if (followFrameRef.current === null) {
1729
+ if (followFrameRef.current == null) {
814
1730
  followFrameRef.current = requestFrame((frameNow) => {
815
1731
  followFrameRef.current = null;
816
1732
  const current = scrollRef.current;
@@ -819,12 +1735,12 @@ export function MessageTimeline({
819
1735
  }
820
1736
  });
821
1737
  }
822
- } else if (followFrameRef.current !== null) {
1738
+ } else if (followFrameRef.current != null) {
823
1739
  cancelFrame(followFrameRef.current);
824
1740
  followFrameRef.current = null;
825
1741
  }
826
1742
  },
827
- [cancelLeaveFallback, stopFollow, syncScrollBaseline, writeScrollTop],
1743
+ [applyCanSkipTipCatchup, cancelLeaveFallback, stopFollow, syncScrollBaseline, writeScrollTop],
828
1744
  );
829
1745
  driveFollowRef.current = driveFollow;
830
1746
 
@@ -842,12 +1758,80 @@ export function MessageTimeline({
842
1758
  return;
843
1759
  }
844
1760
  const previousFirstItemId = previousFirstItemIdRef.current;
845
- const previousGroupKeyByItemId = groupKeyByItemIdRef.current;
846
- const previousGroupOffsetByKey = groupOffsetByKeyRef.current;
1761
+ const previousFirstItemGroupKey = firstItemGroupKeyRef.current;
1762
+ const previousFirstItemGroupOffsetTop = firstItemGroupOffsetTopRef.current;
1763
+ const previousItemContentTop = firstItemContentTopRef.current;
847
1764
  const previousScrollTop = lastScrollTopRef.current;
848
- const firstItemChanged = previousFirstItemId !== null && firstItemId !== previousFirstItemId;
849
- const prepended =
850
- firstItemChanged && resolvedItems.some((item) => item.id === previousFirstItemId);
1765
+ const previousMaxScroll = lastMaxScrollRef.current;
1766
+ const wasAtLiveTailBeforeCommit =
1767
+ previousMaxScroll <= 1 ||
1768
+ previousMaxScroll - previousScrollTop < Math.min(PIN_THRESHOLD_PX, previousMaxScroll);
1769
+ const firstItemChanged = !!previousFirstItemId && firstItemId !== previousFirstItemId;
1770
+ const sourceChanged = olderBoundaryKey !== previousSourceBoundaryRef.current;
1771
+ const retainedSource =
1772
+ sourceItems?.some((item) => previousSourceIdsRef.current.has(item.id)) ?? false;
1773
+ const prepended = sourceChanged && retainedSource;
1774
+ previousSourceBoundaryRef.current = olderBoundaryKey;
1775
+ previousSourceIdsRef.current = new Set(sourceItems?.map((item) => item.id));
1776
+ const readingAnchor = readingAnchorRef.current;
1777
+ readingAnchorRef.current = null;
1778
+ const settlement = settlementRef.current;
1779
+ settlementRef.current = null;
1780
+ const attempt = olderLoadAttemptRef.current;
1781
+ const committedZeroOverlapOlderReplacement = !!(
1782
+ attempt?.[2]?.committed &&
1783
+ sourceChanged &&
1784
+ !retainedSource
1785
+ );
1786
+ const restorePrependAnchor = () => {
1787
+ const correction = readingAnchor && timelineAnchorCorrection(node, readingAnchor);
1788
+ if (correction != null) {
1789
+ if (Math.abs(correction) > 1) writeScrollTop(node, node.scrollTop + correction);
1790
+ return;
1791
+ }
1792
+ // Keep the reader on the same retained rows. Prefer the exact first-item
1793
+ // content coordinate (needed when a prepend merges inside one group),
1794
+ // then the retained group offset, then scrollHeight as a final fallback.
1795
+ // If native anchoring already applied the same shift, leave scrollTop.
1796
+ let delta: number | null = null;
1797
+ if (previousFirstItemId && previousItemContentTop != null) {
1798
+ const itemEl = node.querySelector(
1799
+ `[data-og-item="${cssEscapeAttribute(previousFirstItemId)}"]`,
1800
+ );
1801
+ if (itemEl instanceof HTMLElement) {
1802
+ const scrollerTop = node.getBoundingClientRect().top;
1803
+ const currentItemTop = itemEl.getBoundingClientRect().top - scrollerTop + node.scrollTop;
1804
+ delta = Math.round(currentItemTop - previousItemContentTop);
1805
+ }
1806
+ }
1807
+ const anchorKey = previousFirstItemGroupKey;
1808
+ const anchorEl =
1809
+ anchorKey != null
1810
+ ? node.querySelector(`[data-og-group-key="${cssEscapeAttribute(anchorKey)}"]`)
1811
+ : null;
1812
+ if (
1813
+ delta == null &&
1814
+ anchorEl instanceof HTMLElement &&
1815
+ previousFirstItemGroupOffsetTop != null
1816
+ ) {
1817
+ const moved = Math.round(anchorEl.offsetTop - previousFirstItemGroupOffsetTop);
1818
+ if (moved) {
1819
+ delta = moved;
1820
+ }
1821
+ }
1822
+ if (delta == null) {
1823
+ const heightDelta = Math.round(node.scrollHeight - previousScrollHeightRef.current);
1824
+ if (heightDelta > 0) {
1825
+ delta = heightDelta;
1826
+ }
1827
+ }
1828
+ if (delta != null) {
1829
+ const expected = previousScrollTop + delta;
1830
+ if (Math.abs(node.scrollTop - expected) > 2) {
1831
+ writeScrollTop(node, expected);
1832
+ }
1833
+ }
1834
+ };
851
1835
  if (pendingJumpToStartRef.current && firstItemChanged) {
852
1836
  // The oldest window landed — jump against the NEW DOM, and skip the
853
1837
  // prepend correction (it would shift the reader away from the top).
@@ -863,6 +1847,64 @@ export function MessageTimeline({
863
1847
  applyPinned(true);
864
1848
  snapToBottom(node);
865
1849
  }
1850
+ } else if (committedZeroOverlapOlderReplacement) {
1851
+ // An oldest-directed bounded page can replace every previously mounted
1852
+ // row. Its receipt is the only causal proof that this is backward
1853
+ // progress rather than live-tail forward eviction. Anchor at the bottom
1854
+ // seam before recording this window's scroll baselines so an unpinned
1855
+ // reader stays adjacent to the history they were reading.
1856
+ clearPendingReaderLeave();
1857
+ stopFollow();
1858
+ writeScrollTop(node, maxScrollOf(node));
1859
+ if (attempt?.[1] === 1 && pinnedRef.current) {
1860
+ applyPinned(false);
1861
+ }
1862
+ } else if (prepended) {
1863
+ const olderAttemptState = olderLoadAttemptRef.current?.[1];
1864
+ const underfillOwned =
1865
+ !!olderLoadAttemptRef.current &&
1866
+ olderAttemptState !== 1 &&
1867
+ olderAttemptState !== 2 &&
1868
+ olderAttemptState !== 3;
1869
+ if (
1870
+ autoFollow &&
1871
+ pinnedRef.current &&
1872
+ !hasNewer &&
1873
+ !pendingReaderLeaveRef.current &&
1874
+ (wasAtLiveTailBeforeCommit || underfillOwned)
1875
+ ) {
1876
+ // Still following the live tip: underfill, or a prefetch the reader
1877
+ // started and then returned from. Park at the new tip. A stale pin
1878
+ // while they are actually up in a short window (gap is not inside
1879
+ // PIN_THRESHOLD of maxScroll, so wasAtLiveTail is false) restores.
1880
+ clearPendingReaderLeave();
1881
+ snapToBottom(node);
1882
+ } else {
1883
+ restorePrependAnchor();
1884
+ if (autoFollow && pinnedRef.current && !hasNewer) {
1885
+ // Geometry or a pending extension/programmatic leave proves the
1886
+ // reader was browsing history even if the pin ref has not settled.
1887
+ stopFollow();
1888
+ clearPendingReaderLeave();
1889
+ applyPinned(false);
1890
+ }
1891
+ }
1892
+ } else if (!pinnedRef.current && readingAnchor && readableTurns) {
1893
+ // A live work row moves behind newly streamed prose. Correct only the
1894
+ // residual movement native anchoring did not absorb, using the reader's
1895
+ // retained paragraph/control rather than the moving row's outer box.
1896
+ const correction = timelineAnchorCorrection(node, readingAnchor);
1897
+ if (correction !== null && Math.abs(correction) > 1) {
1898
+ writeScrollTop(node, node.scrollTop + correction);
1899
+ }
1900
+ const focused = readingAnchor.find((anchor) => anchor.focused)?.element;
1901
+ if (
1902
+ focused &&
1903
+ node.contains(focused) &&
1904
+ node.ownerDocument.activeElement === node.ownerDocument.body
1905
+ ) {
1906
+ focused.focus({ preventScroll: true });
1907
+ }
866
1908
  } else if (autoFollow && pinnedRef.current && !hasNewer) {
867
1909
  // Load/remount (still hidden): hard-park. Live tip after reveal: ease.
868
1910
  // Pending unarmed leave: tip *growth* must not yank (Vimium during stream).
@@ -877,83 +1919,94 @@ export function MessageTimeline({
877
1919
  } else {
878
1920
  driveFollow(node);
879
1921
  }
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
1922
  }
915
1923
  // After a prepend, if restore left us below the top prefetch band,
916
1924
  // re-arm so a later approach can load again. Still cooling while parked
917
1925
  // 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";
1926
+ if (prepended && !pinnedRef.current && node.scrollTop > OLDER_PREFETCH_MARGIN_PX) {
1927
+ rearmOlderPrefetchAfterLeavingTop(node);
925
1928
  }
1929
+ scheduleQuestionNav();
926
1930
  previousFirstItemIdRef.current = firstItemId;
927
1931
  previousScrollHeightRef.current = node.scrollHeight;
928
1932
  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);
1933
+ // offsetTop queries are O(groups); skip while pinned at the live tip
1934
+ // (every stream token used to remeasure the whole timeline).
1935
+ firstItemGroupKeyRef.current = groups[0]?.key ?? null;
1936
+ const needOffsets =
1937
+ prepended ||
1938
+ firstItemChanged ||
1939
+ !pinnedRef.current ||
1940
+ hasNewer ||
1941
+ firstItemContentTopRef.current == null;
940
1942
  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;
1943
+ const committedFirstGroupKey = firstItemGroupKeyRef.current;
1944
+ const firstGroupEl = committedFirstGroupKey
1945
+ ? node.querySelector(`[data-og-group-key="${cssEscapeAttribute(committedFirstGroupKey)}"]`)
1946
+ : null;
1947
+ firstItemGroupOffsetTopRef.current =
1948
+ firstGroupEl instanceof HTMLElement ? firstGroupEl.offsetTop : null;
1949
+ const firstItemEl = firstItemId
1950
+ ? node.querySelector(`[data-og-item="${cssEscapeAttribute(firstItemId)}"]`)
1951
+ : null;
1952
+ firstItemContentTopRef.current =
1953
+ firstItemId && firstItemEl instanceof HTMLElement
1954
+ ? firstItemEl.getBoundingClientRect().top -
1955
+ node.getBoundingClientRect().top +
1956
+ node.scrollTop
1957
+ : null;
1958
+ }
1959
+
1960
+ if (settlement) settlementAnimationRef.current = animateTimelineSettlement(settlement);
1961
+
1962
+ // Promise settlement is not itself permission to retry. A receipt-marked
1963
+ // accepted page retires its exact owner on this commit even when projection
1964
+ // is empty or merges into the same first item. Without that mark, retain
1965
+ // the compatibility fallback: a retained prior boundary proves prepend,
1966
+ // while a missing prior boundary is forward eviction and merely rebases.
1967
+ if (!attempt) {
1968
+ return;
1969
+ }
1970
+ if (!attempt[2]?.committed && attempt[0] === olderBoundaryKey) {
1971
+ return;
1972
+ }
1973
+ if (
1974
+ !attempt[2]?.committed &&
1975
+ attempt[0] &&
1976
+ !sourceItems?.find((entry) => entry.id === attempt[0])
1977
+ ) {
1978
+ attempt[0] = olderBoundaryKey;
1979
+ return;
1980
+ }
1981
+ if (!attempt[1] || !hasOlder) {
1982
+ setUnderfillSettledAttempt((olderLoadAttemptRef.current = null));
1983
+ return;
1984
+ }
1985
+ // Boundary progress retires the request owner. A late settlement from
1986
+ // that completed prefetch cannot mutate the new window's cooldown owner.
1987
+ olderLoadAttemptRef.current = [olderBoundaryKey, 2];
1988
+ // Bound automatic work, but let continued upward input replenish demand.
1989
+ // Only a committed first-party receipt proves that another page is safe.
1990
+ if (
1991
+ attempt[2]?.committed &&
1992
+ !pinnedRef.current &&
1993
+ hasOlder &&
1994
+ node.scrollTop <= OLDER_PREFETCH_MARGIN_PX &&
1995
+ olderPageBudgetRef.current > 0
1996
+ ) {
1997
+ olderPageBudgetRef.current -= 1;
1998
+ olderLoadAttemptRef.current = null;
1999
+ setOlderDemand((value) => value + 1);
949
2000
  }
2001
+ rearmOlderPrefetchAfterLeavingTop(node);
2002
+ requestOlderIfUnderfilled(node);
950
2003
  });
951
2004
 
952
2005
  // First paint / session remount: keep the scroller hidden, snap to tip for
953
2006
  // two animation frames (late sync layout), then reveal. Does not change the
954
- // tip-follow ease law used once `revealed` is true.
2007
+ // live tip-follow law used once `revealed` is true.
955
2008
  useLayoutEffect(() => {
956
- if (revealed || allGroups.length === 0) {
2009
+ if (revealed || !allGroups.length) {
957
2010
  return;
958
2011
  }
959
2012
  let cancelled = false;
@@ -981,7 +2034,7 @@ export function MessageTimeline({
981
2034
  return () => {
982
2035
  cancelled = true;
983
2036
  cancelFrame(frame1);
984
- if (frame2 !== 0) {
2037
+ if (frame2) {
985
2038
  cancelFrame(frame2);
986
2039
  }
987
2040
  };
@@ -1000,7 +2053,6 @@ export function MessageTimeline({
1000
2053
  olderPrefetchArmedRef.current = false;
1001
2054
  setOlderPrefetchArmed(false);
1002
2055
  }
1003
- olderLoadGateRef.current = "armed";
1004
2056
  wantPinRef.current = false;
1005
2057
  pendingJumpToStartRef.current = false;
1006
2058
  previousFirstItemIdRef.current = null;
@@ -1009,14 +2061,27 @@ export function MessageTimeline({
1009
2061
  lastMaxScrollRef.current = 0;
1010
2062
  lastScrollHeightRef.current = 0;
1011
2063
  lastClientHeightRef.current = 0;
1012
- groupKeyByItemIdRef.current = new Map();
1013
- groupOffsetByKeyRef.current = new Map();
2064
+ firstItemGroupKeyRef.current = null;
2065
+ firstItemGroupOffsetTopRef.current = null;
2066
+ firstItemContentTopRef.current = null;
1014
2067
  foldMemoryRef.current.clear();
1015
2068
  userMessageDisclosureMemoryRef.current.clear();
1016
2069
  disclosureKeepsUnpinnedRef.current = false;
2070
+ clearReaderIntent();
2071
+ contentShrinkBaselineRef.current = null;
1017
2072
  seenActivityIdsRef.current.clear();
1018
2073
  applyPinned(true);
1019
- }, [allGroups.length, revealed, applyPinned]);
2074
+ }, [allGroups.length, revealed, applyPinned, clearReaderIntent]);
2075
+
2076
+ // Parent commits cover the initial/history-loading cases. Disclosure state
2077
+ // changes are child-local, so the ResizeObserver below owns dynamic collapse
2078
+ // and expansion after mount.
2079
+ useEffect(() => {
2080
+ const node = scrollRef.current;
2081
+ if (node) {
2082
+ requestOlderIfUnderfilled(node);
2083
+ }
2084
+ });
1020
2085
 
1021
2086
  // Clear the bulk-paint marker a frame after it renders, so rows appended
1022
2087
  // live (streams, new turns) animate exactly as before.
@@ -1052,21 +2117,61 @@ export function MessageTimeline({
1052
2117
  (entries) => {
1053
2118
  const intersecting = entries.some((entry) => entry.isIntersecting);
1054
2119
  if (!intersecting) {
1055
- // Left the top band — next approach may load once.
1056
- olderLoadGateRef.current = "armed";
2120
+ // Left the top band. A settled ordinary prefetch releases its exact
2121
+ // owner here; a still-pending request remains cooling so a quick
2122
+ // leave/re-enter cannot overlap it.
2123
+ const attempt = olderLoadAttemptRef.current;
2124
+ if (attempt?.[1] === 2 || attempt?.[1] === 3) {
2125
+ olderLoadAttemptRef.current = null;
2126
+ }
1057
2127
  return;
1058
2128
  }
1059
- if (olderLoadGateRef.current !== "armed") {
2129
+ // Both automatic underfill and ordinary prefetch use one boundary
2130
+ // owner. In particular, live growth cannot arm a second sentinel load
2131
+ // while the short-window request that preceded it is still pending.
2132
+ if (olderLoadAttemptRef.current) {
1060
2133
  return;
1061
2134
  }
1062
- olderLoadGateRef.current = "cooling";
1063
- onLoadOlder();
2135
+ const attempt: OlderLoadAttempt = (olderLoadAttemptRef.current = [olderBoundaryKey, 1]);
2136
+ const noProgress = () => {
2137
+ if (!scrollRef.current || olderLoadAttemptRef.current !== attempt) {
2138
+ return;
2139
+ }
2140
+ attempt[1] = 3;
2141
+ if (root.scrollTop > OLDER_PREFETCH_MARGIN_PX) {
2142
+ olderLoadAttemptRef.current = null;
2143
+ } else if (maxScrollOf(root) <= 1) {
2144
+ attempt[1] = 0;
2145
+ setUnderfillSettledAttempt(attempt);
2146
+ }
2147
+ };
2148
+ // Preserve legacy fire-and-forget top-band retries: the callback has
2149
+ // synchronously returned, but this visit remains cooling until exit.
2150
+ // Only rejection/throw/exact `false` is a no-progress receipt. Other
2151
+ // fulfillment retains the pending owner until boundary commit;
2152
+ // otherwise a delayed prepend could overlap a same-boundary request.
2153
+ if (
2154
+ !invokeOlderLoad(onLoadOlder, noProgress, attempt) &&
2155
+ olderLoadAttemptRef.current === attempt
2156
+ ) {
2157
+ olderLoadAttemptRef.current[1] = 2;
2158
+ requestOlderIfUnderfilled(root);
2159
+ }
1064
2160
  },
1065
2161
  { root, rootMargin: OLDER_PREFETCH_ROOT_MARGIN },
1066
2162
  );
1067
2163
  observer.observe(target);
1068
2164
  return () => observer.disconnect();
1069
- }, [olderPrefetchArmed, hasOlder, loadingOlder, onLoadOlder, firstGroupKey]);
2165
+ }, [
2166
+ firstGroupKey,
2167
+ hasOlder,
2168
+ loadingOlder,
2169
+ olderBoundaryKey,
2170
+ olderPrefetchArmed,
2171
+ olderDemand,
2172
+ onLoadOlder,
2173
+ requestOlderIfUnderfilled,
2174
+ ]);
1070
2175
 
1071
2176
  // History view: page forward when the reader nears the bottom of the current
1072
2177
  // non-tip window. Does not pull the whole gap — one density-bounded page.
@@ -1085,15 +2190,17 @@ export function MessageTimeline({
1085
2190
  }
1086
2191
  const observer = new IntersectionObserver(
1087
2192
  (entries) => {
1088
- if (entries.some((entry) => entry.isIntersecting)) {
1089
- onLoadNewer();
2193
+ // An underfilled history window has both sentinels visible. Advancing
2194
+ // it automatically would undo an explicit older-page navigation.
2195
+ if (maxScrollOf(root) > 1 && entries.some((entry) => entry.isIntersecting)) {
2196
+ requestNewer();
1090
2197
  }
1091
2198
  },
1092
2199
  { root, rootMargin: "0px 0px 1200px 0px" },
1093
2200
  );
1094
2201
  observer.observe(target);
1095
2202
  return () => observer.disconnect();
1096
- }, [hasNewer, loadingNewer, onLoadNewer, firstGroupKey]);
2203
+ }, [hasNewer, loadingNewer, onLoadNewer, requestNewer, firstGroupKey]);
1097
2204
 
1098
2205
  // Late layout that React commits cannot see (images decoding, fonts, code
1099
2206
  // blocks) grows content without a commit. While pinned, soft-follow the tip;
@@ -1106,7 +2213,7 @@ export function MessageTimeline({
1106
2213
  return;
1107
2214
  }
1108
2215
  const observer = new ResizeObserver(() => {
1109
- if (resizeFollowRafRef.current !== null) {
2216
+ if (resizeFollowRafRef.current != null) {
1110
2217
  return;
1111
2218
  }
1112
2219
  resizeFollowRafRef.current = requestFrame(() => {
@@ -1115,6 +2222,10 @@ export function MessageTimeline({
1115
2222
  if (!current) {
1116
2223
  return;
1117
2224
  }
2225
+ // A viewport that shrank below the pill's minimum hides it now, not
2226
+ // on the next scroll.
2227
+ if (questionNavMinViewportRef.current > 0) scheduleQuestionNavRef.current();
2228
+ requestOlderIfUnderfilled(current);
1118
2229
  if (!autoFollow || !pinnedRef.current || hasNewerRef.current) {
1119
2230
  return;
1120
2231
  }
@@ -1132,12 +2243,12 @@ export function MessageTimeline({
1132
2243
  observer.observe(node);
1133
2244
  return () => {
1134
2245
  observer.disconnect();
1135
- if (resizeFollowRafRef.current !== null) {
2246
+ if (resizeFollowRafRef.current != null) {
1136
2247
  cancelFrame(resizeFollowRafRef.current);
1137
2248
  resizeFollowRafRef.current = null;
1138
2249
  }
1139
2250
  };
1140
- }, [autoFollow, driveFollow, snapToBottom]);
2251
+ }, [autoFollow, driveFollow, requestOlderIfUnderfilled, snapToBottom]);
1141
2252
 
1142
2253
  // Entering a non-tip history window: drop any live pin so the page bottom
1143
2254
  // cannot re-stick follow across loadNewer. Leaving it (the tip window
@@ -1145,6 +2256,8 @@ export function MessageTimeline({
1145
2256
  // at what just became the live bottom — paging forward to the tip must not
1146
2257
  // strand them unpinned watching new content grow below.
1147
2258
  useEffect(() => {
2259
+ // Closing find is paint-only; it must not trigger this history-to-tip rule.
2260
+ if (searchTargetRef.current) return;
1148
2261
  if (hasNewer) {
1149
2262
  stopFollow();
1150
2263
  applyPinned(false);
@@ -1167,6 +2280,15 @@ export function MessageTimeline({
1167
2280
  }
1168
2281
  }, [hasNewer, autoFollow, applyPinned, snapToBottom, stopFollow]);
1169
2282
 
2283
+ // After the scroll authority above has settled each commit, record whether
2284
+ // the reader is away from the tip (content can land below an unpinned
2285
+ // reader without any scroll event).
2286
+ // oxlint-disable-next-line react-hooks/exhaustive-deps -- Deliberately runs after every commit.
2287
+ useLayoutEffect(() => {
2288
+ const node = scrollRef.current;
2289
+ if (node && !pinnedRef.current) setAwayFromTip(!isNearBottom(node));
2290
+ });
2291
+
1170
2292
  // Pinned: layout/camera recover tip debt; wheel/keys/pointer-arm unpin
1171
2293
  // immediately; extension jumps settle via scrollend (or one-rAF fallback).
1172
2294
  // Do not tip-follow-yank an in-flight unarmed scroll-away — that ate Vimium.
@@ -1175,6 +2297,9 @@ export function MessageTimeline({
1175
2297
  if (!node) {
1176
2298
  return;
1177
2299
  }
2300
+ // Only an unpinned reader's distance matters for the jump.
2301
+ if (!pinnedRef.current) setAwayFromTip(!isNearBottom(node));
2302
+ scheduleQuestionNav();
1178
2303
  const previousTop = lastScrollTopRef.current;
1179
2304
  const previousMaxScroll = lastMaxScrollRef.current;
1180
2305
  const nextTop = node.scrollTop;
@@ -1183,6 +2308,16 @@ export function MessageTimeline({
1183
2308
  const readerUp = readerScrollUpPx(previousTop, nextTop, previousMaxScroll, nextMaxScroll);
1184
2309
  const maxFell = nextMaxScroll < previousMaxScroll - 1;
1185
2310
  const readerArmed = readerIntentArmRef.current;
2311
+ const readerIntentStart = readerIntentStartRef.current;
2312
+ const cumulativeReaderUp =
2313
+ readerArmed && readerIntentStart
2314
+ ? readerScrollUpPx(
2315
+ readerIntentStart.scrollTop,
2316
+ nextTop,
2317
+ readerIntentStart.maxScroll,
2318
+ nextMaxScroll,
2319
+ )
2320
+ : readerUp;
1186
2321
  const heightShrunk =
1187
2322
  followRef.current.lastHeight > 0 &&
1188
2323
  nextHeight < followRef.current.lastHeight - TIP_FOLLOW_SHRINK_EPS_PX;
@@ -1198,6 +2333,19 @@ export function MessageTimeline({
1198
2333
  return;
1199
2334
  }
1200
2335
 
2336
+ if (programmatic && !pinnedRef.current) {
2337
+ // Restore/camera writes while reading history are not a return to the tip.
2338
+ // Still expire a pending Jump-to-latest latch: the in-window jump itself
2339
+ // is a programmatic snap, and a later reader scroll-away can arrive
2340
+ // before that echo is consumed. Skipping this left the latch armed and
2341
+ // snapped the reader when hasNewer later flipped false.
2342
+ syncScrollBaseline(node);
2343
+ if (wantPinRef.current && !isNearBottom(node)) {
2344
+ wantPinRef.current = false;
2345
+ }
2346
+ return;
2347
+ }
2348
+
1201
2349
  if (autoFollow && pinnedRef.current && !hasNewer) {
1202
2350
  // Fold / composer / SessionChrome: viewport shrink raises maxScroll without
1203
2351
  // growing content. Must hit tipFollow before we adopt the new clientHeight
@@ -1208,7 +2356,7 @@ export function MessageTimeline({
1208
2356
  // Fold / composer content shrink: compensate before baseline sync so
1209
2357
  // driveFollow still sees the pre-shrink scrollTop (avoid double-subtract).
1210
2358
  if (heightShrunk || maxFell || viewportShrunk) {
1211
- readerIntentArmRef.current = false;
2359
+ clearReaderIntent();
1212
2360
  clearPendingReaderLeave();
1213
2361
  driveFollow(node);
1214
2362
  return;
@@ -1230,15 +2378,14 @@ export function MessageTimeline({
1230
2378
  clearPendingReaderLeave();
1231
2379
  }
1232
2380
  // 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
- }
2381
+ if (readerArmed && cumulativeReaderUp > TIP_FOLLOW_READER_UP_EPS_PX && !nearBottomPinned) {
2382
+ clearReaderIntent();
2383
+ requestEarlierFromReader();
2384
+ rearmOlderPrefetchAfterLeavingTop(node);
1239
2385
  return;
1240
2386
  }
1241
- // Tip grew under a still viewport (no reader-up): ease back to the tip.
2387
+ // Tip grew under a still viewport (no reader-up): track the new growth
2388
+ // immediately and ease only any debt that already existed.
1242
2389
  // Reader/extension scroll-up in progress: do not yank — scrollend decides.
1243
2390
  // Near-bottom with tipDebt≈0 stays the quiet path (do not broaden follow
1244
2391
  // inside PIN_THRESHOLD — that fights small intentional scroll-ups).
@@ -1258,15 +2405,46 @@ export function MessageTimeline({
1258
2405
  }
1259
2406
 
1260
2407
  syncScrollBaseline(node);
2408
+ if (programmatic) {
2409
+ // Anchor restoration is never permission to resume tip-follow.
2410
+ return;
2411
+ }
1261
2412
  const nearBottom = isNearBottom(node);
1262
2413
 
1263
- // Re-pin only when the reader moved toward/at the tip — not when a fold
1264
- // clamp dragged scrollTop down onto nearBottom.
2414
+ // Re-pin only when the reader moved toward/at the tip. Prepend restore
2415
+ // and overflow-anchor raise scrollTop by roughly the same amount as
2416
+ // maxScroll; treating that as a scroll-down
2417
+ // re-pinned a compact-tail history reader (their preserved gap falls
2418
+ // inside PIN_THRESHOLD once the window is tall) and snapped them back.
2419
+ // A commit may already have adopted this scroll position into the layout
2420
+ // baseline. Explicit wheel/key/pointer intent retains its own start so
2421
+ // returning to the tip still re-pins after that interleaving.
2422
+ // Passive wheel handlers may run after compositor scrolling; retain the
2423
+ // earlier layout baseline when it still contains that reader movement.
2424
+ const returnStart =
2425
+ readerIntentStart && readerIntentStart.scrollTop <= previousTop ? readerIntentStart : null;
2426
+ const inserted = Math.max(0, nextMaxScroll - (returnStart?.maxScroll ?? previousMaxScroll));
2427
+ const towardTip = nextTop - (returnStart?.scrollTop ?? previousTop) - inserted;
2428
+ // Explicit intent may reach the exact live bottom despite intervening
2429
+ // streaming growth. Keep subtracting that growth: native anchoring alone
2430
+ // conserves the gap and must never qualify as reader movement.
2431
+ const explicitReturnToBottom = returnStart !== null && nextMaxScroll - nextTop <= 1;
1265
2432
  const nextPinned =
1266
- !hasNewer && nearBottom && nextTop >= previousTop - TIP_FOLLOW_READER_UP_EPS_PX;
2433
+ !hasNewer && nearBottom && towardTip > 0.5 && (inserted <= 1 || explicitReturnToBottom);
1267
2434
  if (!nextPinned) {
1268
2435
  stopFollow();
1269
2436
  }
2437
+ const focused = node.ownerDocument.activeElement;
2438
+ if (
2439
+ nextPinned &&
2440
+ focused instanceof HTMLElement &&
2441
+ focused.hasAttribute("data-og-prompt") &&
2442
+ node.contains(focused)
2443
+ ) {
2444
+ // Question navigation focuses its destination. Returning to the live
2445
+ // tip releases that stale reader ownership before the commit snapshot.
2446
+ node.focus({ preventScroll: true });
2447
+ }
1270
2448
  applyPinned(nextPinned);
1271
2449
  // A far-from-bottom scroll while a Jump-to-latest is pending is the reader
1272
2450
  // changing their mind: drop the latch, or a stale one (host rejected or
@@ -1277,17 +2455,22 @@ export function MessageTimeline({
1277
2455
  wantPinRef.current = false;
1278
2456
  }
1279
2457
  if (nextPinned) {
2458
+ clearReaderIntent();
1280
2459
  return;
1281
2460
  }
2461
+ if (readerIntentStart) {
2462
+ // This scroll already accounted for the gesture's movement. Retain a
2463
+ // fresh start for continuous input across the next commit, not stale
2464
+ // movement that could turn a later native clamp into a return to tip.
2465
+ readerIntentStartRef.current = { scrollTop: nextTop, maxScroll: nextMaxScroll };
2466
+ }
1282
2467
  if (!olderPrefetchArmedRef.current) {
1283
2468
  olderPrefetchArmedRef.current = true;
1284
2469
  setOlderPrefetchArmed(true);
1285
2470
  }
1286
2471
  // Re-arm older prefetch only after leaving the top band (scroll down into
1287
2472
  // 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
- }
2473
+ rearmOlderPrefetchAfterLeavingTop(node);
1291
2474
  };
1292
2475
 
1293
2476
  const onScrollEnd = () => {
@@ -1296,6 +2479,9 @@ export function MessageTimeline({
1296
2479
  return;
1297
2480
  }
1298
2481
  cancelLeaveFallback();
2482
+ // Engines can emit scrollend between tiny pointer steps. Keep the pinned
2483
+ // gesture's cumulative leave budget until it actually leaves the tip.
2484
+ if (!pinnedRef.current || !readerIntentArmRef.current) clearReaderIntent();
1299
2485
  if (disclosureKeepsUnpinnedRef.current) {
1300
2486
  programmaticScrollRef.current = 0;
1301
2487
  stopFollow();
@@ -1311,298 +2497,484 @@ export function MessageTimeline({
1311
2497
  releasePinAfterScrollSettled(node);
1312
2498
  };
1313
2499
 
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.
2500
+ const timeline = (
2501
+ <AllowancePresentationProvider
2502
+ render={renderAllowanceExhausted}
2503
+ labels={allowanceExhaustedLabels}
2504
+ >
2505
+ <LightboxProvider>
2506
+ <FoldMemoryProvider value={foldMemoryRef.current}>
2507
+ <LiveNoteFoldContext.Provider value={reportLiveFold}>
2508
+ <SeenActivityIdsProvider value={seenActivityIdsRef.current}>
2509
+ <TimelineComputeLabelProvider value={computeLabel ?? null}>
2510
+ <EntranceAnimationProvider value={false}>
2511
+ <TooltipProvider delayDuration={400}>
2512
+ <TimelineAnnotationSourceRootContext.Provider value={scrollRef}>
2513
+ <div
2514
+ className={cn("og-root relative flex min-h-0 flex-col", className)}
2515
+ // Sticky insets start at the scroller's padded content edge
2516
+ // (pt-16). Subtract exactly that padding so an expanded work
2517
+ // header pins flush to the scrollport. Pinning it lower left a
2518
+ // band of scrolling rows visible above the header, which then
2519
+ // looked like it floated over the middle of the timeline.
2520
+ style={{ "--og-work-header-top": "-4rem" } as CSSProperties}
2521
+ >
2522
+ {onAnnotate ? (
2523
+ <Suspense fallback={null}>
2524
+ <TimelineAnnotationSelection
2525
+ rootRef={scrollRef}
2526
+ sources={annotationSources}
2527
+ onAnnotate={onAnnotate}
2528
+ />
2529
+ </Suspense>
2530
+ ) : null}
2531
+ {/* Pinned: anchoring off so the tip-follow camera owns the motion.
1332
2532
  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
2533
  <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
- }
2534
+ ref={scrollRef}
2535
+ data-og-timeline-scroller=""
2536
+ data-og-bottom-follow={
2537
+ autoFollow && pinned && !hasNewer ? "true" : "false"
2538
+ }
2539
+ tabIndex={-1}
2540
+ onScroll={onScroll}
2541
+ onScrollEnd={onScrollEnd}
2542
+ onWheel={onWheel}
2543
+ onTouchStart={(event) => {
2544
+ stopSettlement();
2545
+ const touch = event.touches.length === 1 ? event.touches[0] : undefined;
2546
+ touchPositionRef.current = touch
2547
+ ? { x: touch.clientX, y: touch.clientY }
2548
+ : null;
2549
+ }}
2550
+ onTouchMove={(event) => {
2551
+ const touch = event.touches[0];
2552
+ const previous = touchPositionRef.current;
2553
+ if (!touch || !previous || event.touches.length !== 1) {
2554
+ touchPositionRef.current = null;
2555
+ return;
2556
+ }
2557
+ const deltaX = previous.x - touch.clientX;
2558
+ const deltaY = previous.y - touch.clientY;
2559
+ if (Math.max(Math.abs(deltaX), Math.abs(deltaY)) < 4) return;
2560
+ touchPositionRef.current = { x: touch.clientX, y: touch.clientY };
2561
+ onWheel({
2562
+ deltaX,
2563
+ deltaY,
2564
+ target: event.target,
2565
+ currentTarget: event.currentTarget,
2566
+ });
2567
+ }}
2568
+ onTouchEnd={() => {
2569
+ touchPositionRef.current = null;
2570
+ }}
2571
+ onTouchCancel={() => {
2572
+ touchPositionRef.current = null;
2573
+ }}
2574
+ onClickCapture={(event) => {
2575
+ const target =
2576
+ event.target instanceof Element
2577
+ ? event.target.closest("button[aria-expanded]")
2578
+ : null;
2579
+ if (target) {
2580
+ if (target.hasAttribute("data-og-work-header")) {
2581
+ releaseProgress(
2582
+ target
2583
+ .closest("[data-og-group-key]")
2584
+ ?.getAttribute("data-og-group-key") ?? null,
2585
+ );
2586
+ }
2587
+ releasePinFromReader();
2588
+ disclosureKeepsUnpinnedRef.current = true;
2589
+ }
2590
+ }}
2591
+ onFocusCapture={(event) => {
2592
+ if (readableTurns && event.target !== event.currentTarget) {
2593
+ stopSettlement();
2594
+ releasePinFromReader();
2595
+ disclosureKeepsUnpinnedRef.current = true;
2596
+ }
2597
+ }}
2598
+ onPointerDown={onPointerDown}
2599
+ onKeyDown={onKeyDown}
2600
+ style={
2601
+ groups.length > 0 && !revealed ? { visibility: "hidden" } : undefined
2602
+ }
2603
+ className={cn(
2604
+ // tabIndex=-1 is programmatic only — never paint a focus ring on
2605
+ // the whole scroller (click + Shift used to flash a blue outline).
2606
+ "min-h-0 flex-1 overflow-y-auto overscroll-contain px-4 pt-16 pb-6 sm:px-6 outline-hidden",
2607
+ autoFollow && pinned && !hasNewer
2608
+ ? "[overflow-anchor:none]"
2609
+ : "[overflow-anchor:auto]",
2610
+ )}
2611
+ >
2612
+ <TimelineBeforeLayout
2613
+ capture={() => {
2614
+ if (readableTurns && timelineHasReader(scrollRef.current)) {
2615
+ releasePinFromReader();
2616
+ }
2617
+ readingAnchorRef.current =
2618
+ !pinnedRef.current && scrollRef.current
2619
+ ? captureTimelineAnchor(scrollRef.current)
2620
+ : null;
2621
+ settlementRef.current =
2622
+ readableTurns &&
2623
+ autoFollow &&
2624
+ pinnedRef.current &&
2625
+ revealedRef.current &&
2626
+ !hasNewer &&
2627
+ !pendingReaderLeaveRef.current &&
2628
+ !disclosureKeepsUnpinnedRef.current &&
2629
+ !prefersReducedMotion() &&
2630
+ scrollRef.current
2631
+ ? captureTimelineSettlement(scrollRef.current, groups)
2632
+ : null;
2633
+ }}
2634
+ >
2635
+ <div className="relative mx-auto flex w-full max-w-3xl flex-col gap-5">
2636
+ <AnimatePresence>
2637
+ {loadingOlder ||
2638
+ loadingOldest ||
2639
+ (hasOlder && onJumpToStart && olderPrefetchArmed) ||
2640
+ underfillRetryReady ? (
2641
+ // Reserved top gutter: controls scroll with history and cannot
2642
+ // cover a disclosure. Visibility never changes content height.
2643
+ <motion.div
2644
+ initial={{ opacity: 0, y: -6 }}
2645
+ animate={{ opacity: 1, y: 0 }}
2646
+ exit={{ opacity: 0, y: -6 }}
2647
+ transition={{ duration: 0.15, ease: "easeOut" }}
2648
+ data-og-loading-older=""
2649
+ aria-live="polite"
2650
+ className="pointer-events-none absolute inset-x-0 -top-11 z-10 flex justify-center"
2651
+ >
2652
+ {loadingOlder || loadingOldest ? (
2653
+ <span className={LOADING_CHIP_CLASS}>
2654
+ <span className="og-shimmer-text">
2655
+ {loadingOldest
2656
+ ? "Jumping to start…"
2657
+ : "Loading earlier activity…"}
2658
+ </span>
2659
+ </span>
2660
+ ) : null}
2661
+ {hasOlder &&
2662
+ !loadingOlder &&
2663
+ !loadingOldest &&
2664
+ (underfillRetryReady || onJumpToStart) ? (
2665
+ <button
2666
+ type="button"
2667
+ data-og-retry={underfillRetryReady || undefined}
2668
+ data-og-jump-to-start={!underfillRetryReady || undefined}
2669
+ onClick={() => {
2670
+ const node = scrollRef.current;
2671
+ if (underfillRetryReady) {
2672
+ if (node && underfillRetryReadyRef.current) {
2673
+ // AnimatePresence retains this handler during
2674
+ // exit. Current authorization plus the exact
2675
+ // attempt check prevent stale dispatch.
2676
+ requestOlderIfUnderfilled(
2677
+ node,
2678
+ underfillSettledAttempt!,
2679
+ );
2680
+ }
2681
+ return;
2682
+ }
2683
+ applyPinned(false);
2684
+ pendingJumpToStartRef.current = true;
2685
+ const seq = ++jumpToStartSeqRef.current;
2686
+ void Promise.resolve(onJumpToStart!()).then(
2687
+ () => {
2688
+ // The commit that swaps in the oldest window consumes
2689
+ // the flag against the new DOM; this write covers the
2690
+ // already-committed order and the no-window-change
2691
+ // case (jumping within the current window).
2692
+ const scroller = scrollRef.current ?? node;
2693
+ if (scroller) {
2694
+ scroller.scrollTop = 0;
2695
+ }
2696
+ // A host may resolve without ever changing the
2697
+ // window (already on the oldest page). Any swap
2698
+ // commit runs its layout effect before the next
2699
+ // frame, so a flag still armed by then is the
2700
+ // no-change case — clear it, or a LATER prepend
2701
+ // would spuriously jump the reader to the top.
2702
+ requestFrame(() => {
2703
+ if (jumpToStartSeqRef.current === seq) {
2704
+ pendingJumpToStartRef.current = false;
2705
+ }
2706
+ });
2707
+ },
2708
+ () => {
2709
+ if (jumpToStartSeqRef.current === seq) {
2710
+ pendingJumpToStartRef.current = false;
2711
+ }
2712
+ },
2713
+ );
2714
+ }}
2715
+ className="pointer-events-auto rounded-full border border-og-border px-3 py-1.5 text-og-control"
2716
+ >
2717
+ {underfillRetryReady
2718
+ ? underfillSettledAttempt?.[2]?.tailPreserved
2719
+ ? "Load earlier activity"
2720
+ : "Retry earlier activity"
2721
+ : "Jump to start"}
2722
+ </button>
2723
+ ) : null}
2724
+ </motion.div>
2725
+ ) : null}
2726
+ </AnimatePresence>
2727
+ {!groups.length
2728
+ ? (emptyState ?? (
2729
+ <p className="py-10 text-center text-og-menu text-og-fg-subtle">
2730
+ No activity yet.
2731
+ </p>
2732
+ ))
2733
+ : null}
2734
+ {hasOlder && olderPrefetchArmed ? (
2735
+ // Overlaid, not a layout row: mounting/unmounting the sentinel
2736
+ // must never shift content (that shift was itself a wobble).
2737
+ // End at the scroll origin above the pt-16 gutter so the
2738
+ // observer and scrollTop cooldown share the same 400px band.
2739
+ <div
2740
+ ref={topSentinelRef}
2741
+ data-og-top-sentinel=""
2742
+ data-og-timeline-chrome=""
2743
+ aria-hidden="true"
2744
+ className="pointer-events-none absolute inset-x-0 -top-16 h-px -translate-y-full"
1418
2745
  />
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
- />
1435
- ) : 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>
2746
+ ) : null}
2747
+ {groups.map(({ group, key, entranceEnabled }, index) => {
2748
+ return (
2749
+ <TimelineSearchRevealContext.Provider
2750
+ key={key}
2751
+ value={
2752
+ searchItem &&
2753
+ timelineGroupItemIds(group).includes(searchItem.id)
2754
+ ? searchRevealKey
2755
+ : null
2756
+ }
2757
+ >
2758
+ <TimelineGroupEntry
2759
+ groupKey={key}
2760
+ group={group}
2761
+ nextGroup={groups[index + 1]?.group}
2762
+ startupDismissed={
2763
+ group.kind === "activity" &&
2764
+ group.items.some(
2765
+ (item) => item.turnId && turnsWithOutput.has(item.turnId),
2766
+ )
2767
+ }
2768
+ entranceEnabled={entranceEnabled}
2769
+ liveEntranceEnabled={
2770
+ group.kind === "activity" ? !bulkRender : undefined
2771
+ }
2772
+ noteFolded={
2773
+ group.kind === "item"
2774
+ ? liveNoteFolds.get(group.item.id)
2775
+ : undefined
2776
+ }
2777
+ context={timelineGroupEntryContext}
2778
+ />
2779
+ </TimelineSearchRevealContext.Provider>
2780
+ );
2781
+ })}
2782
+ {groups.length > 0 && trailingState ? (
2783
+ <div data-og-timeline-trailing-state="">{trailingState}</div>
2784
+ ) : null}
2785
+ {hasNewer && newerFailure?.key === newerBoundaryKey ? (
2786
+ <div
2787
+ data-og-newer-error=""
2788
+ className="flex flex-col items-center gap-2 px-4 py-3 text-center text-og-menu text-og-fg-muted"
2789
+ >
2790
+ <p role="status" className="max-w-prose [overflow-wrap:anywhere]">
2791
+ Couldn’t load later activity. {newerFailure.message}
2792
+ </p>
2793
+ <button
2794
+ ref={newerRetryButtonRef}
2795
+ type="button"
2796
+ data-og-retry-newer=""
2797
+ 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"
2798
+ aria-disabled={loadingNewer || newerRetryPending}
2799
+ aria-busy={newerRetryPending}
2800
+ onClick={() => requestNewer(true)}
2801
+ >
2802
+ {newerRetryPending
2803
+ ? "Retrying later activity…"
2804
+ : "Retry later activity"}
2805
+ </button>
2806
+ </div>
2807
+ ) : null}
2808
+ {hasNewer ? (
2809
+ <div
2810
+ ref={bottomSentinelRef}
2811
+ data-og-bottom-sentinel=""
2812
+ data-og-timeline-chrome=""
2813
+ aria-hidden="true"
2814
+ className="h-px w-full"
2815
+ />
2816
+ ) : null}
2817
+ </div>
2818
+ </TimelineBeforeLayout>
2819
+ </div>
2820
+ {draftAnnotations && draftAnnotations.length > 0 ? (
2821
+ <Suspense fallback={null}>
2822
+ <TimelineAnnotationMarkers
2823
+ rootRef={scrollRef}
2824
+ annotations={draftAnnotations}
2825
+ onSelect={onDraftAnnotationSelect}
2826
+ />
2827
+ </Suspense>
1459
2828
  ) : 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;
2829
+
2830
+ <div
2831
+ data-og-timeline-nav="top"
2832
+ className={cn(
2833
+ "pointer-events-none absolute inset-x-0 z-10 flex justify-center",
2834
+ questionNav?.belowHeader ? "top-10 pointer-coarse:top-13" : "top-3",
2835
+ )}
2836
+ >
2837
+ <AnimatePresence>
2838
+ {questionNav ? (
2839
+ <motion.button
2840
+ key="question-nav"
2841
+ {...NAV_FADE}
2842
+ type="button"
2843
+ data-og-question-nav=""
2844
+ data-og-jump-to-question=""
2845
+ title="Back to your message"
2846
+ onClick={() => jumpToQuestion(questionNav.key)}
2847
+ className={NAV_BUTTON_CLASS}
2848
+ >
2849
+ <ArrowUpIcon aria-hidden className="size-4" />
2850
+ <span className="sr-only">Back to your message</span>
2851
+ </motion.button>
2852
+ ) : null}
2853
+ </AnimatePresence>
2854
+ </div>
2855
+ <div
2856
+ data-og-timeline-nav="bottom"
2857
+ className="pointer-events-none absolute inset-x-0 bottom-3 z-10 flex flex-col items-center gap-2"
2858
+ >
2859
+ <AnimatePresence>
2860
+ {loadingNewer ? (
2861
+ <motion.div
2862
+ key="loading-newer"
2863
+ {...NAV_FADE}
2864
+ data-og-loading-newer=""
2865
+ aria-live="polite"
2866
+ className="flex max-w-full justify-center"
2867
+ >
2868
+ <span className={LOADING_CHIP_CLASS}>
2869
+ <span className="og-shimmer-text">Loading later activity…</span>
2870
+ </span>
2871
+ </motion.div>
2872
+ ) : null}
2873
+ {jumpPillShown ? (
2874
+ <motion.button
2875
+ key="jump-to-latest"
2876
+ {...NAV_FADE}
2877
+ type="button"
2878
+ data-og-jump-to-latest=""
2879
+ title="Jump to latest"
2880
+ onClick={() => {
2881
+ // Returning to the tip explicitly releases reader-owned
2882
+ // prose. Clear only this timeline's selection before the
2883
+ // synchronous ownership check on the next commit.
2884
+ const viewport = scrollRef.current;
2885
+ const selection = viewport?.ownerDocument.getSelection();
2886
+ if (
2887
+ selection &&
2888
+ !selection.isCollapsed &&
2889
+ (viewport?.contains(selection.anchorNode) ||
2890
+ viewport?.contains(selection.focusNode))
2891
+ )
2892
+ selection.removeAllRanges();
2893
+ disclosureKeepsUnpinnedRef.current = false;
2894
+ if (hasNewer) {
2895
+ // Do not pin against the current history page — its bottom
2896
+ // is not the tip. The pin + snap run when the tip window
2897
+ // actually lands (`hasNewer` flips false).
2898
+ wantPinRef.current = true;
2899
+ const node = scrollRef.current;
2900
+ if (onJumpToLatest) {
2901
+ void Promise.resolve(onJumpToLatest()).then(
2902
+ () => {
2903
+ // Covers a host that flipped hasNewer before
2904
+ // resolving; otherwise the tip-window commit
2905
+ // consumes the flag.
2906
+ const current = scrollRef.current;
2907
+ if (
2908
+ current &&
2909
+ wantPinRef.current &&
2910
+ !hasNewerRef.current
2911
+ ) {
2912
+ wantPinRef.current = false;
2913
+ applyPinned(true);
2914
+ snapToBottom(current);
2915
+ }
2916
+ },
2917
+ () => {
2918
+ // The tip reload failed (ordinary network error):
2919
+ // an armed latch would fire a surprise snap when
2920
+ // the reader later pages to the tip themselves.
2921
+ wantPinRef.current = false;
2922
+ },
2923
+ );
2924
+ } else if (node) {
2925
+ // No tip reload available: jump within the in-memory
2926
+ // window so the newer sentinel can page forward; the
2927
+ // latch pins if the tip window eventually lands.
2928
+ snapToBottom(node);
1489
2929
  }
1490
- });
1491
- },
1492
- () => {
1493
- if (jumpToStartSeqRef.current === seq) {
1494
- pendingJumpToStartRef.current = false;
2930
+ return;
1495
2931
  }
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;
2932
+ const node = scrollRef.current;
2933
+ if (node) {
1554
2934
  applyPinned(true);
1555
- snapToBottom(current);
2935
+ snapToBottom(node);
1556
2936
  }
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
- },
1564
- );
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
- }
1571
- return;
1572
- }
1573
- const node = scrollRef.current;
1574
- if (node) {
1575
- applyPinned(true);
1576
- snapToBottom(node);
1577
- }
1578
- }}
1579
- 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",
1584
- )}
1585
- >
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>
2937
+ }}
2938
+ className={NAV_BUTTON_CLASS}
2939
+ >
2940
+ <ArrowDownIcon aria-hidden className="size-4" />
2941
+ <span className="sr-only">Jump to latest</span>
2942
+ </motion.button>
2943
+ ) : null}
2944
+ </AnimatePresence>
2945
+ </div>
2946
+ </div>
2947
+ </TimelineAnnotationSourceRootContext.Provider>
2948
+ </TooltipProvider>
2949
+ </EntranceAnimationProvider>
2950
+ </TimelineComputeLabelProvider>
2951
+ </SeenActivityIdsProvider>
2952
+ </LiveNoteFoldContext.Provider>
2953
+ </FoldMemoryProvider>
2954
+ </LightboxProvider>
2955
+ </AllowancePresentationProvider>
1598
2956
  );
2957
+ // Agent-authored object links resolve through the host, never the console.
2958
+ return <OpenGeniLinkProvider resolveLink={resolveLink}>{timeline}</OpenGeniLinkProvider>;
1599
2959
  }
1600
2960
 
1601
2961
  type KeyedTimelineGroup = {
1602
2962
  group: TimelineGroup;
1603
2963
  key: string;
2964
+ entranceEnabled: boolean;
1604
2965
  };
1605
2966
 
2967
+ function timelineGroupsRenderEqual(previous: TimelineGroup, next: TimelineGroup): boolean {
2968
+ try {
2969
+ return dequal(previous, next);
2970
+ } catch {
2971
+ // Consumer-owned `unknown` payloads may be proxies/getters. Equality is an
2972
+ // optimization only; a hostile comparator surface must fall back to the
2973
+ // authoritative new group rather than taking down the timeline render.
2974
+ return false;
2975
+ }
2976
+ }
2977
+
1606
2978
  /**
1607
2979
  * Projection can legitimately change a group's content-derived key while
1608
2980
  * retaining its existing rows. The common pagination case is an older activity
@@ -1611,42 +2983,73 @@ type KeyedTimelineGroup = {
1611
2983
  * committed groups by their durable item IDs so both the React key and the
1612
2984
  * progressive-window anchor survive either change.
1613
2985
  */
1614
- function useStableTimelineGroupKeys(allGroups: TimelineGroup[]): KeyedTimelineGroup[] {
2986
+ function useStableTimelineGroupKeys(
2987
+ allGroups: TimelineGroup[],
2988
+ entranceEnabled: boolean,
2989
+ ): KeyedTimelineGroup[] {
1615
2990
  const previousRef = useRef<KeyedTimelineGroup[]>([]);
1616
2991
  const keyedGroups = useMemo(() => {
1617
2992
  const previousByItemId = new Map<string, KeyedTimelineGroup>();
1618
2993
  for (const previous of previousRef.current) {
1619
2994
  for (const itemId of timelineGroupItemIds(previous.group)) {
1620
- previousByItemId.set(itemId, previous);
2995
+ // A live progress note is both its own row and listed in its work
2996
+ // history. Its own row owns the id, so the note keeps its identity.
2997
+ if (previous.group.kind === "item" || !previousByItemId.has(itemId)) {
2998
+ previousByItemId.set(itemId, previous);
2999
+ }
1621
3000
  }
1622
3001
  }
1623
3002
 
1624
3003
  const usedKeys = new Set<string>();
1625
3004
  return allGroups.map((group, index) => {
1626
3005
  const itemIds = timelineGroupItemIds(group);
1627
- let retainedKey: string | undefined;
3006
+ let retainedGroup: KeyedTimelineGroup | undefined;
1628
3007
  for (const itemId of itemIds) {
1629
3008
  const previous = previousByItemId.get(itemId);
1630
3009
  // Retain only same-kind matches. Activity → turn wrap must NOT keep the
1631
3010
  // activity chip's React key: that reused a collapsed TurnSummary and
1632
3011
  // skipped the settle beat (insta-collapse / content flash).
1633
- if (previous && previous.group.kind === group.kind && !usedKeys.has(previous.key)) {
1634
- retainedKey = previous.key;
3012
+ const startupCompletion =
3013
+ previous?.group.kind === "activity" &&
3014
+ previous.group.items.every(
3015
+ (item) =>
3016
+ item.kind === "startup-phase" || (item.kind === "reasoning" && !item.text.trim()),
3017
+ ) &&
3018
+ group.kind === "turn" &&
3019
+ group.outcome === "complete" &&
3020
+ group.groups.every(
3021
+ (child) =>
3022
+ child.kind === "activity" &&
3023
+ child.items.every((item) => item.kind === "startup-phase"),
3024
+ );
3025
+ if (
3026
+ previous &&
3027
+ (previous.group.kind === group.kind || startupCompletion) &&
3028
+ !usedKeys.has(previous.key)
3029
+ ) {
3030
+ retainedGroup = previous;
1635
3031
  break;
1636
3032
  }
1637
3033
  }
1638
3034
 
1639
3035
  const canonicalKey = timelineGroupKey(group);
1640
- let key = retainedKey ?? canonicalKey;
3036
+ let key = retainedGroup?.key ?? canonicalKey;
1641
3037
  let collision = 0;
1642
3038
  while (usedKeys.has(key)) {
1643
3039
  key = `${canonicalKey}:${index}:${collision}`;
1644
3040
  collision += 1;
1645
3041
  }
1646
3042
  usedKeys.add(key);
1647
- return { group, key };
3043
+ return {
3044
+ group:
3045
+ retainedGroup && timelineGroupsRenderEqual(retainedGroup.group, group)
3046
+ ? retainedGroup.group
3047
+ : group,
3048
+ key,
3049
+ entranceEnabled: retainedGroup?.entranceEnabled ?? entranceEnabled,
3050
+ };
1648
3051
  });
1649
- }, [allGroups]);
3052
+ }, [allGroups, entranceEnabled]);
1650
3053
 
1651
3054
  useLayoutEffect(() => {
1652
3055
  previousRef.current = keyedGroups;
@@ -1739,17 +3142,155 @@ class TimelineGroupRenderBoundary extends Component<
1739
3142
  }
1740
3143
  }
1741
3144
 
3145
+ type TimelineGroupEntryProps = {
3146
+ groupKey: string;
3147
+ group: TimelineGroup;
3148
+ nextGroup?: TimelineGroup | undefined;
3149
+ startupDismissed: boolean;
3150
+ entranceEnabled: boolean;
3151
+ liveEntranceEnabled?: boolean | undefined;
3152
+ /**
3153
+ * Present for a live progress note: true while its turn's work disclosure is
3154
+ * open (the note then reads inside it), false while it reads here.
3155
+ */
3156
+ noteFolded?: boolean | undefined;
3157
+ context: TimelineGroupEntryContext;
3158
+ };
3159
+
3160
+ type TimelineGroupEntryContext = {
3161
+ userMessageDisclosureContext: UserMessageDisclosureContextValue;
3162
+ behavior: TimelineGroupBehaviorProps;
3163
+ };
3164
+
3165
+ type TimelineGroupBehaviorProps = {
3166
+ renderMessageActions: MessageTimelineProps["renderMessageActions"];
3167
+ renderMessageText:
3168
+ | ((text: string, item: AgentMessageItem | UserMessageItem) => ReactNode)
3169
+ | undefined;
3170
+ onOpenSession: MessageTimelineProps["onOpenSession"];
3171
+ onMemoryClick: MessageTimelineProps["onMemoryClick"];
3172
+ onReconnect: MessageTimelineProps["onReconnect"];
3173
+ renderAuthNeeded: MessageTimelineProps["renderAuthNeeded"];
3174
+ resolveProviderLogo: MessageTimelineProps["resolveProviderLogo"];
3175
+ toolRegistry: ToolRegistry;
3176
+ loadRetainedScreenshot: MessageTimelineProps["loadRetainedScreenshot"];
3177
+ loadRetainedArtifact: MessageTimelineProps["loadRetainedArtifact"];
3178
+ loadVideoArtifactPlayback: MessageTimelineProps["loadVideoArtifactPlayback"];
3179
+ genieLoading: MessageTimelineProps["genieLoading"];
3180
+ turnSummary: MessageTimelineProps["turnSummary"];
3181
+ };
3182
+
3183
+ /**
3184
+ * Keep the complete settled-row shell behind one shallow memo boundary. A
3185
+ * prepend still reconciles the keyed list, but render-equivalent suffix groups
3186
+ * skip their providers, error boundaries, disclosure wrappers, and row trees.
3187
+ */
3188
+ const TimelineGroupEntry = memo(function TimelineGroupEntry({
3189
+ groupKey,
3190
+ group,
3191
+ nextGroup,
3192
+ startupDismissed,
3193
+ entranceEnabled,
3194
+ liveEntranceEnabled,
3195
+ noteFolded,
3196
+ context,
3197
+ }: TimelineGroupEntryProps) {
3198
+ const reducedMotion = useReducedMotion();
3199
+ const { behavior, userMessageDisclosureContext } = context;
3200
+ const contextCompactionCount =
3201
+ group.kind === "turn"
3202
+ ? (group.contextCompactionCount ?? 0)
3203
+ : group.kind === "activity" &&
3204
+ nextGroup?.kind === "item" &&
3205
+ nextGroup.item.kind === "context-compaction" &&
3206
+ nextGroup.item.phase === "compacted"
3207
+ ? 1
3208
+ : 0;
3209
+ const content = (
3210
+ <ActivityNoteTextContext.Provider value={behavior.renderMessageText ?? null}>
3211
+ <TimelineGroupView
3212
+ {...behavior}
3213
+ group={group}
3214
+ foldLiveCluster={isAgentProgress(nextGroup)}
3215
+ startupDismissed={startupDismissed}
3216
+ trailingAgentText={trailingAgentTextAfterTurn(group, nextGroup)}
3217
+ contextCompactionCount={contextCompactionCount > 0 ? contextCompactionCount : undefined}
3218
+ />
3219
+ </ActivityNoteTextContext.Provider>
3220
+ );
3221
+ return (
3222
+ <GenieLoadingOptionsContext.Provider value={behavior.genieLoading}>
3223
+ <div
3224
+ data-og-timeline-group-anchor=""
3225
+ data-og-group-key={groupKey}
3226
+ data-og-prompt={
3227
+ group.kind === "item" && group.item.kind === "user-message" ? "" : undefined
3228
+ }
3229
+ tabIndex={group.kind === "item" && group.item.kind === "user-message" ? -1 : undefined}
3230
+ // A live progress note reads inside its open work row instead; folded,
3231
+ // it takes no space and also gives back the list's gap-5 above it.
3232
+ data-og-live-note={noteFolded === undefined ? undefined : noteFolded ? "folded" : "shown"}
3233
+ data-og-live-note-id={
3234
+ noteFolded !== undefined && group.kind === "item" ? group.item.id : undefined
3235
+ }
3236
+ aria-hidden={noteFolded || undefined}
3237
+ inert={noteFolded || undefined}
3238
+ className={
3239
+ noteFolded ? "-mt-5 h-0 overflow-hidden opacity-0 [overflow-anchor:none]" : undefined
3240
+ }
3241
+ >
3242
+ <EntranceAnimationProvider value={entranceEnabled} liveValue={liveEntranceEnabled}>
3243
+ <TimelineGroupRenderBoundary resetKeys={[group, behavior]}>
3244
+ <UserMessageDisclosureProvider value={userMessageDisclosureContext}>
3245
+ {/* Item groups never switch the preparation/content key. Keep their
3246
+ DOM shell without mounting inert presence and motion lifecycles
3247
+ for every historical message in a prepend. */}
3248
+ {group.kind === "item" ? (
3249
+ <div>{content}</div>
3250
+ ) : (
3251
+ <AnimatePresence initial={false}>
3252
+ <motion.div
3253
+ key={
3254
+ !startupDismissed &&
3255
+ group.kind === "activity" &&
3256
+ !group.work &&
3257
+ group.items.every(
3258
+ (item) =>
3259
+ item.kind === "startup-phase" ||
3260
+ (item.kind === "reasoning" && !item.text.trim()),
3261
+ )
3262
+ ? "preparation"
3263
+ : "content"
3264
+ }
3265
+ initial={false}
3266
+ exit={{ opacity: 0, height: 0 }}
3267
+ transition={{ duration: reducedMotion ? 0 : 0.2 }}
3268
+ >
3269
+ {content}
3270
+ </motion.div>
3271
+ </AnimatePresence>
3272
+ )}
3273
+ </UserMessageDisclosureProvider>
3274
+ </TimelineGroupRenderBoundary>
3275
+ </EntranceAnimationProvider>
3276
+ </div>
3277
+ </GenieLoadingOptionsContext.Provider>
3278
+ );
3279
+ });
3280
+
1742
3281
  // 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.
3282
+ // on every commit: the stable-key projection reuses render-equivalent group
3283
+ // objects, so memo skips them. Changed projection content receives a new group
3284
+ // object, and behavior/callback changes are separate props, so ordinary
3285
+ // streaming and host updates still invalidate immediately.
1747
3286
  const TimelineGroupView = memo(function TimelineGroupView({
1748
3287
  group,
3288
+ renderMessageActions,
1749
3289
  renderMessageText,
1750
3290
  onOpenSession,
1751
3291
  onMemoryClick,
1752
3292
  onReconnect,
3293
+ renderAuthNeeded,
1753
3294
  resolveProviderLogo,
1754
3295
  toolRegistry,
1755
3296
  loadRetainedScreenshot,
@@ -1759,16 +3300,20 @@ const TimelineGroupView = memo(function TimelineGroupView({
1759
3300
  insideTurn = false,
1760
3301
  nestClusterChips = false,
1761
3302
  foldLiveCluster = false,
3303
+ startupDismissed = false,
1762
3304
  trailingAgentText,
1763
3305
  contextCompactionCount,
1764
3306
  }: {
1765
3307
  group: TimelineGroup;
3308
+ renderMessageActions?: ((item: AgentMessageItem | UserMessageItem) => ReactNode) | undefined;
1766
3309
  renderMessageText?:
1767
3310
  | ((text: string, item: AgentMessageItem | UserMessageItem) => ReactNode)
1768
3311
  | undefined;
1769
3312
  onOpenSession?: ((sessionId: string) => void) | undefined;
1770
3313
  onMemoryClick?: ((memoryId: string) => void) | undefined;
1771
3314
  onReconnect?: ((item: AuthNeededItem) => void | Promise<void>) | undefined;
3315
+ /** Host-owned inline connection setup. Return undefined to use the default recovery card. */
3316
+ renderAuthNeeded?: ((item: AuthNeededItem) => ReactNode | undefined) | undefined;
1772
3317
  resolveProviderLogo?: ((providerDomain: string) => string | null | undefined) | undefined;
1773
3318
  toolRegistry: ToolRegistry;
1774
3319
  loadRetainedScreenshot?: RetainedScreenshotLoader | undefined;
@@ -1779,6 +3324,7 @@ const TimelineGroupView = memo(function TimelineGroupView({
1779
3324
  behind a neutral chip — the one place activity without an outcome still
1780
3325
  folds, bounding the DOM of days-long autonomous turns. */
1781
3326
  foldLiveCluster?: boolean;
3327
+ startupDismissed?: boolean;
1782
3328
  /** Rendering inside an expanded turn group: the outer chip already owns the
1783
3329
  failure surface, so nested chips stay tinted but quiet (no repeated
1784
3330
  failure text, no auto-open) — one loud error, N calm sub-expands. */
@@ -1799,16 +3345,29 @@ const TimelineGroupView = memo(function TimelineGroupView({
1799
3345
  /** Secondary chip facet when this fold sits next to a compaction landmark. */
1800
3346
  contextCompactionCount?: number | undefined;
1801
3347
  }) {
3348
+ const startupDetails = useStartupDetails();
1802
3349
  const enter = useEntranceAnimation();
1803
3350
  const settleChrome = useTurnSettleOpen();
3351
+ const previousSingleActivity = useRef<ActivityItem | undefined>(undefined);
3352
+ useLayoutEffect(() => {
3353
+ const work =
3354
+ group.kind === "activity" ? group.items.filter((item) => item.kind !== "startup-phase") : [];
3355
+ previousSingleActivity.current = work.length === 1 ? work[0] : undefined;
3356
+ }, [group]);
1804
3357
  const foldMemory = useFoldMemory();
3358
+ const reportLiveFold = useContext(LiveNoteFoldContext);
1805
3359
  // Settled (or live-fold) activity clusters get a chip. Inside an expanded
1806
3360
  // turn that is the second layer — quiet nested chips under the outer turn
1807
3361
  // summary when contiguous activity naturally clusters (≥2 only).
3362
+ const containsPresentedImage = timelineGroupContainsPresentedImage(group);
3363
+ const hasRememberedImageFold =
3364
+ group.kind === "activity" && containsPresentedImage && foldMemory?.has(group.id);
3365
+ // Primary images stay visible through live narration and the turn wrap.
3366
+ // Manual collapse still belongs to TurnSummary's existing fold memory.
1808
3367
  const activityShouldFold =
1809
3368
  group.kind === "activity" &&
1810
- Boolean(group.outcome || (foldLiveCluster && clusterIsSettled(group)));
1811
- const containsGeneratedImage = timelineGroupContainsGeneratedImage(group);
3369
+ !containsPresentedImage &&
3370
+ !!(group.outcome || (foldLiveCluster && clusterIsSettled(group)));
1812
3371
  // Latch live→folded so a top-level shell that was already mounted open can
1813
3372
  // start the settle beat without remounting bare rail → wrapper.
1814
3373
  const liveActivitySettle = useLiveSettleFold(activityShouldFold && !insideTurn);
@@ -1817,7 +3376,7 @@ const TimelineGroupView = memo(function TimelineGroupView({
1817
3376
  group.kind === "turn" &&
1818
3377
  (group.outcome === "failed" ||
1819
3378
  timelineGroupContainsAuthNeeded(group) ||
1820
- containsGeneratedImage);
3379
+ containsPresentedImage);
1821
3380
  // activity-* → turn-* remount: carry resting state so settleFold does not
1822
3381
  // re-open a chip the reader already watched collapse.
1823
3382
  if (group.kind === "turn" && foldMemory && !insideTurn) {
@@ -1828,9 +3387,149 @@ const TimelineGroupView = memo(function TimelineGroupView({
1828
3387
  );
1829
3388
  }
1830
3389
  const settleFold =
1831
- group.kind === "turn" ? Boolean(enter && !insideTurn && !turnDefaultOpen) : liveActivitySettle;
3390
+ !turnSummary?.rolling &&
3391
+ (group.kind === "turn" ? !!(enter && !insideTurn && !turnDefaultOpen) : liveActivitySettle);
1832
3392
  switch (group.kind) {
1833
3393
  case "activity":
3394
+ if (group.work) {
3395
+ const phases = group.items.filter((item) => item.kind === "startup-phase");
3396
+ const preparing =
3397
+ !startupDetails &&
3398
+ !startupDismissed &&
3399
+ !group.work.endedAt &&
3400
+ !group.work.waiting &&
3401
+ phases.length > 0 &&
3402
+ group.items.every(
3403
+ (item) =>
3404
+ item.kind === "startup-phase" || (item.kind === "reasoning" && !item.text.trim()),
3405
+ ) &&
3406
+ !phases.some((item) => item.status === "failed" || item.status === "cancelled");
3407
+ if (preparing) {
3408
+ // Preparation is primary, never hidden in an activity disclosure.
3409
+ // Keep work.startedAt unchanged for the handoff and settled duration.
3410
+ return (
3411
+ <ActivityRail
3412
+ items={group.items}
3413
+ startupActive
3414
+ bare
3415
+ toolRegistry={toolRegistry}
3416
+ onOpenSession={onOpenSession}
3417
+ onMemoryClick={onMemoryClick}
3418
+ loadRetainedScreenshot={loadRetainedScreenshot}
3419
+ loadRetainedArtifact={loadRetainedArtifact}
3420
+ />
3421
+ );
3422
+ }
3423
+ const end = group.work.endedAt;
3424
+ const status: TurnSummaryStatus = end
3425
+ ? { kind: "worked", durationMs: durationBetween(group.work.startedAt, end) }
3426
+ : group.work.waiting
3427
+ ? { kind: "waiting", ...group.work.waiting }
3428
+ : {
3429
+ kind: "working",
3430
+ since: group.work.startedAt,
3431
+ preview: containsPresentedImage ? undefined : (
3432
+ <RollingActivity
3433
+ items={group.items}
3434
+ toolRegistry={toolRegistry}
3435
+ showCount={false}
3436
+ />
3437
+ ),
3438
+ };
3439
+ return (
3440
+ // Marks this work row for the live-note fold (list keys can be carried
3441
+ // over from an earlier row, so they are not the work id).
3442
+ <div className="contents" data-og-work-id={group.id}>
3443
+ <TurnSummary
3444
+ key="work"
3445
+ items={group.items}
3446
+ status={status}
3447
+ outcome={group.outcome}
3448
+ failureText={group.failureText}
3449
+ foldKey={group.id}
3450
+ defaultOpen={
3451
+ containsPresentedImage ||
3452
+ group.outcome === "failed" ||
3453
+ phases.some((item) => item.status === "failed" || item.status === "cancelled")
3454
+ ? true
3455
+ : undefined
3456
+ }
3457
+ facets={turnSummary?.facets}
3458
+ contextCompactionCount={compactedLandmarkCount(group.work.details)}
3459
+ onOpenStateChange={
3460
+ group.work.liveNoteIds?.length && reportLiveFold
3461
+ ? (open) => reportLiveFold(group.id, open)
3462
+ : undefined
3463
+ }
3464
+ >
3465
+ <TurnRailFrame compact>
3466
+ {renderFoldedGroups(
3467
+ group.work.details,
3468
+ {
3469
+ renderMessageActions,
3470
+ renderMessageText,
3471
+ onOpenSession,
3472
+ onMemoryClick,
3473
+ onReconnect,
3474
+ renderAuthNeeded,
3475
+ resolveProviderLogo,
3476
+ toolRegistry,
3477
+ loadRetainedScreenshot,
3478
+ loadRetainedArtifact,
3479
+ loadVideoArtifactPlayback,
3480
+ turnSummary,
3481
+ },
3482
+ true,
3483
+ )}
3484
+ </TurnRailFrame>
3485
+ </TurnSummary>
3486
+ </div>
3487
+ );
3488
+ }
3489
+ // Preparation is one quiet surface, not a fold with eight technical steps.
3490
+ // A later turn of the same exchange keeps its status row instead.
3491
+ if (
3492
+ !startupDetails &&
3493
+ !insideTurn &&
3494
+ !group.outcome &&
3495
+ group.items.every(
3496
+ (item) =>
3497
+ item.kind === "startup-phase" || (item.kind === "reasoning" && !item.text.trim()),
3498
+ )
3499
+ ) {
3500
+ return (
3501
+ <ActivityRail
3502
+ items={group.items}
3503
+ startupActive={!startupDismissed && !foldLiveCluster}
3504
+ bare
3505
+ toolRegistry={toolRegistry}
3506
+ onOpenSession={onOpenSession}
3507
+ onMemoryClick={onMemoryClick}
3508
+ loadRetainedScreenshot={loadRetainedScreenshot}
3509
+ loadRetainedArtifact={loadRetainedArtifact}
3510
+ />
3511
+ );
3512
+ }
3513
+ if (
3514
+ turnSummary?.rolling &&
3515
+ insideTurn &&
3516
+ !startupDetails &&
3517
+ !hasRememberedImageFold &&
3518
+ group.items.filter((item) => item.kind !== "startup-phase").length === 1
3519
+ ) {
3520
+ return (
3521
+ <ActivityRail
3522
+ items={group.items}
3523
+ startupActive={false}
3524
+ bare
3525
+ toolRegistry={toolRegistry}
3526
+ onOpenSession={onOpenSession}
3527
+ onMemoryClick={onMemoryClick}
3528
+ loadRetainedScreenshot={loadRetainedScreenshot}
3529
+ loadRetainedArtifact={loadRetainedArtifact}
3530
+ />
3531
+ );
3532
+ }
1834
3533
  if (insideTurn) {
1835
3534
  // Nested chips whenever the parent has ≥2 clusters. During outer settle
1836
3535
  // chrome they stay force-open so structure is visible and height stays
@@ -1842,11 +3541,22 @@ const TimelineGroupView = memo(function TimelineGroupView({
1842
3541
  // settled closed pre-wrap was showing as a CHIP, so mounting it closed
1843
3542
  // is both the stable height and the honest state — force-opening it
1844
3543
  // was the "already-collapsed cluster auto-expands at the end" reopen.
1845
- const useNestedChip = nestClusterChips && activityShouldFold && !containsGeneratedImage;
3544
+ const visibleActivity = group.items.filter((item) => item.kind !== "startup-phase");
3545
+ const singleThought =
3546
+ !startupDetails &&
3547
+ visibleActivity.length === 1 &&
3548
+ visibleActivity[0]?.kind === "reasoning";
3549
+ // Untouched images stay primary output, but a reader-owned image fold
3550
+ // must keep its shell across a multi-cluster wrap. A bare rail would
3551
+ // bypass the remembered choice; retaining either state also lets the
3552
+ // reader reopen and close the same chip after settlement.
3553
+ const useNestedChip =
3554
+ nestClusterChips && (activityShouldFold || hasRememberedImageFold) && !singleThought;
1846
3555
  if (!useNestedChip) {
1847
3556
  return (
1848
3557
  <ActivityRail
1849
3558
  items={group.items}
3559
+ {...(startupDismissed ? { startupActive: false } : {})}
1850
3560
  onOpenSession={onOpenSession}
1851
3561
  onMemoryClick={onMemoryClick}
1852
3562
  toolRegistry={toolRegistry}
@@ -1884,10 +3594,31 @@ const TimelineGroupView = memo(function TimelineGroupView({
1884
3594
  // flips settleFold (collapse) instead of remounting bare rail → wrapper.
1885
3595
  return (
1886
3596
  <TurnSummary
3597
+ // Apply the primary-output default when a rolling activity first
3598
+ // produces an image; the stable foldKey still preserves user choices.
3599
+ key={containsPresentedImage ? "primary-image" : "activity"}
1887
3600
  items={group.items}
1888
3601
  outcome={group.outcome}
1889
3602
  failureText={group.failureText}
1890
- defaultOpen={!activityShouldFold || group.outcome === "failed" ? true : undefined}
3603
+ defaultOpen={
3604
+ group.outcome === "failed" ||
3605
+ containsPresentedImage ||
3606
+ (!turnSummary?.rolling && !activityShouldFold)
3607
+ ? true
3608
+ : undefined
3609
+ }
3610
+ liveHeader={
3611
+ turnSummary?.rolling &&
3612
+ !containsPresentedImage &&
3613
+ !group.outcome &&
3614
+ !foldLiveCluster ? (
3615
+ <RollingActivity
3616
+ items={group.items}
3617
+ toolRegistry={toolRegistry}
3618
+ previousItem={previousSingleActivity.current}
3619
+ />
3620
+ ) : undefined
3621
+ }
1891
3622
  foldKey={group.id}
1892
3623
  facets={turnSummary?.facets}
1893
3624
  settleFold={settleFold}
@@ -1910,54 +3641,40 @@ const TimelineGroupView = memo(function TimelineGroupView({
1910
3641
  );
1911
3642
  case "turn": {
1912
3643
  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;
3644
+ if (
3645
+ !startupDetails &&
3646
+ group.outcome === "complete" &&
3647
+ group.groups.every(
3648
+ (child) =>
3649
+ child.kind === "activity" &&
3650
+ child.items.every(
3651
+ (item) => item.kind === "startup-phase" && item.status === "complete",
3652
+ ),
3653
+ )
3654
+ )
3655
+ return <ActivityRail items={activityItems} startupActive={false} bare />;
1917
3656
  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
- );
3657
+ const body = renderFoldedGroups(group.groups, {
3658
+ renderMessageActions,
3659
+ renderMessageText,
3660
+ onOpenSession,
3661
+ onMemoryClick,
3662
+ onReconnect,
3663
+ renderAuthNeeded,
3664
+ resolveProviderLogo,
3665
+ toolRegistry,
3666
+ loadRetainedScreenshot,
3667
+ loadRetainedArtifact,
3668
+ loadVideoArtifactPlayback,
3669
+ turnSummary,
1954
3670
  });
3671
+ const durationMs = durationBetween(group.startedAt, group.endedAt);
1955
3672
  return (
1956
3673
  <TurnSummary
1957
3674
  items={activityItems}
1958
3675
  outcome={group.outcome}
1959
3676
  failureText={insideTurn ? undefined : group.failureText}
1960
- durationMs={durationBetween(group.startedAt, group.endedAt)}
3677
+ durationMs={durationMs}
1961
3678
  defaultOpen={turnDefaultOpen ? true : undefined}
1962
3679
  bare={insideTurn}
1963
3680
  foldKey={group.id}
@@ -1982,8 +3699,10 @@ const TimelineGroupView = memo(function TimelineGroupView({
1982
3699
  return (
1983
3700
  <TimelineRow
1984
3701
  item={group.item}
3702
+ renderMessageActions={renderMessageActions}
1985
3703
  renderMessageText={renderMessageText}
1986
3704
  onReconnect={onReconnect}
3705
+ renderAuthNeeded={renderAuthNeeded}
1987
3706
  resolveProviderLogo={resolveProviderLogo}
1988
3707
  onOpenSession={onOpenSession}
1989
3708
  loadVideoArtifactPlayback={loadVideoArtifactPlayback}
@@ -1992,6 +3711,78 @@ const TimelineGroupView = memo(function TimelineGroupView({
1992
3711
  }
1993
3712
  });
1994
3713
 
3714
+ type FoldedGroupBehavior = {
3715
+ renderMessageActions?: ((item: AgentMessageItem | UserMessageItem) => ReactNode) | undefined;
3716
+ renderMessageText?:
3717
+ | ((text: string, item: AgentMessageItem | UserMessageItem) => ReactNode)
3718
+ | undefined;
3719
+ onOpenSession?: ((sessionId: string) => void) | undefined;
3720
+ onMemoryClick?: ((memoryId: string) => void) | undefined;
3721
+ onReconnect?: ((item: AuthNeededItem) => void | Promise<void>) | undefined;
3722
+ renderAuthNeeded?: ((item: AuthNeededItem) => ReactNode | undefined) | undefined;
3723
+ resolveProviderLogo?: ((providerDomain: string) => string | null | undefined) | undefined;
3724
+ toolRegistry: ToolRegistry;
3725
+ loadRetainedScreenshot?: RetainedScreenshotLoader | undefined;
3726
+ loadRetainedArtifact?: RetainedArtifactLoader | undefined;
3727
+ loadVideoArtifactPlayback?: VideoArtifactPlaybackLoader | undefined;
3728
+ turnSummary?: TurnSummaryOptions | undefined;
3729
+ };
3730
+
3731
+ /** Children of a folded turn or exchange, each on the shared rail. */
3732
+ function renderFoldedGroups(
3733
+ groups: readonly TimelineGroup[],
3734
+ behavior: FoldedGroupBehavior,
3735
+ readableWork = false,
3736
+ ) {
3737
+ // Second-layer chips only when there are natural multi-cluster seams:
3738
+ // otherwise the outer turn chip alone is enough ("N steps" wrapping one
3739
+ // more "N steps" was the redundant double fold).
3740
+ const nestClusters = foldableActivityClusterCount(groups) >= 2;
3741
+ return groups.map((child, index) => (
3742
+ <TimelineGroupRenderBoundary
3743
+ key={timelineGroupKey(child)}
3744
+ resetKeys={[
3745
+ child,
3746
+ behavior.renderMessageActions,
3747
+ behavior.renderMessageText,
3748
+ behavior.onOpenSession,
3749
+ behavior.onMemoryClick,
3750
+ behavior.onReconnect,
3751
+ behavior.renderAuthNeeded,
3752
+ behavior.resolveProviderLogo,
3753
+ behavior.toolRegistry,
3754
+ behavior.loadRetainedScreenshot,
3755
+ behavior.loadRetainedArtifact,
3756
+ behavior.loadVideoArtifactPlayback,
3757
+ behavior.turnSummary,
3758
+ ]}
3759
+ >
3760
+ {readableWork ? (
3761
+ <div
3762
+ className={
3763
+ index > 0 && (child.kind !== "activity" || groups[index - 1]?.kind !== "activity")
3764
+ ? "mt-3"
3765
+ : undefined
3766
+ }
3767
+ >
3768
+ <TimelineGroupView {...behavior} group={child} insideTurn startupDismissed />
3769
+ </div>
3770
+ ) : (
3771
+ <TimelineGroupView {...behavior} group={child} insideTurn nestClusterChips={nestClusters} />
3772
+ )}
3773
+ </TimelineGroupRenderBoundary>
3774
+ ));
3775
+ }
3776
+
3777
+ function compactedLandmarkCount(groups: readonly TimelineGroup[]): number {
3778
+ return groups.filter(
3779
+ (group) =>
3780
+ group.kind === "item" &&
3781
+ group.item.kind === "context-compaction" &&
3782
+ group.item.phase === "compacted",
3783
+ ).length;
3784
+ }
3785
+
1995
3786
  /**
1996
3787
  * Body under a turn/activity chip. Remount flashes are gated by the timeline
1997
3788
  * seen-activity-id map (not by killing entrance): FoldBody used to force
@@ -2003,9 +3794,16 @@ function FoldBody({ children }: { children: ReactNode }) {
2003
3794
 
2004
3795
  /** Stable left rule for turn/activity bodies — always present so settle wrap
2005
3796
  never inserts or removes the rail chrome. */
2006
- function TurnRailFrame({ children }: { children: ReactNode }) {
3797
+ function TurnRailFrame({ children, compact = false }: { children: ReactNode; compact?: boolean }) {
2007
3798
  return (
2008
- <div className="flex flex-col gap-4 border-l-2 border-og-border pl-3 sm:pl-4">{children}</div>
3799
+ <div
3800
+ className={cn(
3801
+ "flex flex-col border-l-2 border-og-border pl-3 sm:pl-4",
3802
+ compact ? "gap-0.5" : "gap-4",
3803
+ )}
3804
+ >
3805
+ {children}
3806
+ </div>
2009
3807
  );
2010
3808
  }
2011
3809
 
@@ -2031,7 +3829,9 @@ function useLiveSettleFold(folded: boolean): boolean {
2031
3829
  function timelineGroupKey(group: TimelineGroup): string {
2032
3830
  switch (group.kind) {
2033
3831
  case "item":
2034
- return group.item.id;
3832
+ return group.item.kind === "user-message" && group.item.reconciliationKey
3833
+ ? group.item.reconciliationKey
3834
+ : group.item.id;
2035
3835
  case "activity":
2036
3836
  return group.id;
2037
3837
  case "turn":
@@ -2050,26 +3850,15 @@ function timelineGroupContainsAuthNeeded(group: TimelineGroup): boolean {
2050
3850
  }
2051
3851
  }
2052
3852
 
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
3853
  function timelineGroupItemIds(group: TimelineGroup): string[] {
2068
3854
  switch (group.kind) {
2069
3855
  case "item":
2070
3856
  return [group.item.id];
2071
3857
  case "activity":
2072
- return group.items.map((item) => item.id);
3858
+ return [
3859
+ ...(group.work?.details ?? []).flatMap(timelineGroupItemIds),
3860
+ ...group.items.map((item) => item.id),
3861
+ ];
2073
3862
  case "turn":
2074
3863
  return group.groups.flatMap(timelineGroupItemIds);
2075
3864
  }
@@ -2087,7 +3876,7 @@ function isAgentProgress(next: TimelineGroup | undefined): boolean {
2087
3876
  return (
2088
3877
  next.kind === "activity" ||
2089
3878
  next.kind === "turn" ||
2090
- (next.kind === "item" && next.item.kind === "agent-message")
3879
+ (next.kind === "item" && next.item.kind === "agent-message" && next.item.text.trim().length > 0)
2091
3880
  );
2092
3881
  }
2093
3882
 
@@ -2096,7 +3885,7 @@ function isAgentProgress(next: TimelineGroup | undefined): boolean {
2096
3885
  item) can sit after the ACTIVE cluster, which must never fold mid-work. */
2097
3886
  function clusterIsSettled(group: Extract<TimelineGroup, { kind: "activity" }>): boolean {
2098
3887
  return group.items.every((item) => {
2099
- if (item.kind === "reasoning") {
3888
+ if (item.kind === "reasoning" || item.kind === "agent-message") {
2100
3889
  return !item.streaming;
2101
3890
  }
2102
3891
  // Memory writes and fleet observations are discrete, already-settled events.
@@ -2118,11 +3907,11 @@ function foldableActivityClusterCount(groups: readonly TimelineGroup[]): number
2118
3907
  return count;
2119
3908
  }
2120
3909
 
2121
- function flattenActivityItems(groups: TimelineGroup[]): ActivityItem[] {
3910
+ function flattenActivityItems(groups: readonly TimelineGroup[]): ActivityItem[] {
2122
3911
  const items: ActivityItem[] = [];
2123
3912
  for (const group of groups) {
2124
3913
  if (group.kind === "activity") {
2125
- items.push(...group.items);
3914
+ items.push(...flattenActivityItems(group.work?.details ?? []), ...group.items);
2126
3915
  } else if (group.kind === "turn") {
2127
3916
  items.push(...flattenActivityItems(group.groups));
2128
3917
  }
@@ -2139,6 +3928,18 @@ function collectAgentMessageText(groups: readonly TimelineGroup[]): string {
2139
3928
  if (text.length > 0) {
2140
3929
  parts.push(text);
2141
3930
  }
3931
+ } else if (group.kind === "activity") {
3932
+ // Commentary folded into clusters is still the assistant's own prose.
3933
+ const nested = collectAgentMessageText(group.work?.details ?? []);
3934
+ if (nested.length > 0) {
3935
+ parts.push(nested);
3936
+ }
3937
+ for (const item of group.items) {
3938
+ const text = item.kind === "agent-message" ? item.text.trim() : "";
3939
+ if (text.length > 0) {
3940
+ parts.push(text);
3941
+ }
3942
+ }
2142
3943
  } else if (group.kind === "turn") {
2143
3944
  const nested = collectAgentMessageText(group.groups);
2144
3945
  if (nested.length > 0) {
@@ -2159,6 +3960,9 @@ function collectTurnIdsFromGroups(groups: readonly TimelineGroup[]): Set<string>
2159
3960
  ids.add(turnId);
2160
3961
  }
2161
3962
  } else if (group.kind === "activity") {
3963
+ for (const nested of collectTurnIdsFromGroups(group.work?.details ?? [])) {
3964
+ ids.add(nested);
3965
+ }
2162
3966
  for (const item of group.items) {
2163
3967
  if (item.turnId) {
2164
3968
  ids.add(item.turnId);
@@ -2188,7 +3992,7 @@ export function trailingAgentTextAfterTurn(
2188
3992
  if (next?.kind === "item" && next.item.kind === "agent-message") {
2189
3993
  const turnIds = collectTurnIdsFromGroups(group.groups);
2190
3994
  // Degenerate body with no turnIds: do not guess — safer than lifting wrong.
2191
- if (turnIds.size === 0) {
3995
+ if (!turnIds.size) {
2192
3996
  return undefined;
2193
3997
  }
2194
3998
  if (!next.item.turnId || !turnIds.has(next.item.turnId)) {
@@ -2207,7 +4011,7 @@ function collectTurnCopyText(
2207
4011
  const parts = [collectAgentMessageText(groups), trailingAgentText?.trim() ?? ""].filter(
2208
4012
  (part) => part.length > 0,
2209
4013
  );
2210
- if (parts.length === 0) {
4014
+ if (!parts.length) {
2211
4015
  return undefined;
2212
4016
  }
2213
4017
  return parts.join("\n\n");
@@ -2231,26 +4035,45 @@ function durationBetween(startedAt: string, endedAt: string): number | undefined
2231
4035
  */
2232
4036
  export function TimelineRow({
2233
4037
  item,
4038
+ renderMessageActions,
2234
4039
  renderMessageText,
2235
4040
  onReconnect,
4041
+ renderAuthNeeded,
2236
4042
  resolveProviderLogo,
2237
4043
  onOpenSession,
2238
4044
  loadVideoArtifactPlayback,
2239
4045
  }: {
2240
4046
  item: TimelineItem;
4047
+ renderMessageActions?: ((item: AgentMessageItem | UserMessageItem) => ReactNode) | undefined;
2241
4048
  renderMessageText?:
2242
4049
  | ((text: string, item: AgentMessageItem | UserMessageItem) => ReactNode)
2243
4050
  | undefined;
2244
4051
  onReconnect?: ((item: AuthNeededItem) => void | Promise<void>) | undefined;
4052
+ /** Host-owned inline connection setup. Return undefined to use the default recovery card. */
4053
+ renderAuthNeeded?: ((item: AuthNeededItem) => ReactNode | undefined) | undefined;
2245
4054
  resolveProviderLogo?: ((providerDomain: string) => string | null | undefined) | undefined;
2246
4055
  onOpenSession?: ((sessionId: string) => void) | undefined;
2247
4056
  loadVideoArtifactPlayback?: VideoArtifactPlaybackLoader | undefined;
2248
4057
  }) {
2249
4058
  switch (item.kind) {
2250
4059
  case "user-message":
2251
- return <UserMessageRow item={item} renderMessageText={renderMessageText} />;
4060
+ return (
4061
+ <UserMessageRow
4062
+ item={item}
4063
+ renderMessageActions={renderMessageActions}
4064
+ renderMessageText={renderMessageText}
4065
+ />
4066
+ );
4067
+ case "human-input":
4068
+ return <HumanInputConversationRow item={item} />;
2252
4069
  case "agent-message":
2253
- return <AgentMessageRow item={item} renderMessageText={renderMessageText} />;
4070
+ return (
4071
+ <AgentMessageRow
4072
+ item={item}
4073
+ renderMessageActions={renderMessageActions}
4074
+ renderMessageText={renderMessageText}
4075
+ />
4076
+ );
2254
4077
  case "worker-completion":
2255
4078
  return <WorkerCompletionRow item={item} onOpenSession={onOpenSession} />;
2256
4079
  case "session-status":
@@ -2259,7 +4082,11 @@ export function TimelineRow({
2259
4082
  return <GoalRow item={item} />;
2260
4083
  case "machine-input-batch":
2261
4084
  return (
2262
- <MachineInputBatchRow item={item} loadVideoArtifactPlayback={loadVideoArtifactPlayback} />
4085
+ <MachineInputBatchRow
4086
+ item={item}
4087
+ onOpenSession={onOpenSession}
4088
+ loadVideoArtifactPlayback={loadVideoArtifactPlayback}
4089
+ />
2263
4090
  );
2264
4091
  case "notice":
2265
4092
  return <NoticeRow item={item} />;
@@ -2267,11 +4094,13 @@ export function TimelineRow({
2267
4094
  return <CompactionRow item={item} />;
2268
4095
  case "auth-needed":
2269
4096
  return (
2270
- <AuthNeededRow
2271
- item={item}
2272
- onReconnect={onReconnect}
2273
- resolveProviderLogo={resolveProviderLogo}
2274
- />
4097
+ renderAuthNeeded?.(item) ?? (
4098
+ <AuthNeededRow
4099
+ item={item}
4100
+ onReconnect={onReconnect}
4101
+ resolveProviderLogo={resolveProviderLogo}
4102
+ />
4103
+ )
2275
4104
  );
2276
4105
  default:
2277
4106
  return null;
@@ -2290,11 +4119,11 @@ function CompactionRow({ item }: { item: ContextCompactionItem }) {
2290
4119
  const trigger =
2291
4120
  item.trigger && item.phase !== "started" ? COMPACTION_TRIGGER_LABEL[item.trigger] : null;
2292
4121
  const before =
2293
- item.estimatedTokensBefore !== null
4122
+ item.estimatedTokensBefore != null
2294
4123
  ? Math.round(item.estimatedTokensBefore).toLocaleString("en-US")
2295
4124
  : null;
2296
4125
  const after =
2297
- item.estimatedTokensAfter !== null
4126
+ item.estimatedTokensAfter != null
2298
4127
  ? Math.round(item.estimatedTokensAfter).toLocaleString("en-US")
2299
4128
  : null;
2300
4129
  const title =
@@ -2309,14 +4138,14 @@ function CompactionRow({ item }: { item: ContextCompactionItem }) {
2309
4138
  item.phase === "compacted"
2310
4139
  ? "Chat history above is unchanged"
2311
4140
  : item.phase === "skipped"
2312
- ? compactionSkipSubtitle(item.skipReason)
4141
+ ? compactionSkipSubtitle(item.skipReason, item.providerRejection)
2313
4142
  : null;
2314
4143
  const pill =
2315
4144
  item.phase === "skipped" && item.skipReason === "summarization_failed"
2316
4145
  ? "border-og-status-failed/35 bg-og-status-failed/10 text-og-status-failed"
2317
4146
  : 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";
4147
+ ? WAITING_PILL_CLASS
4148
+ : NEUTRAL_PILL;
2320
4149
  return (
2321
4150
  <div className={cn(enter && "animate-og-enter", "flex justify-center")}>
2322
4151
  <div
@@ -2339,21 +4168,6 @@ function CompactionRow({ item }: { item: ContextCompactionItem }) {
2339
4168
  );
2340
4169
  }
2341
4170
 
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
4171
  /** Hover-reveal clock beside the copy control (sent / finished). */
2358
4172
  function MessageFooterTime({ occurredAt }: { occurredAt: string }) {
2359
4173
  return (
@@ -2372,22 +4186,17 @@ function MessageFooterTime({ occurredAt }: { occurredAt: string }) {
2372
4186
 
2373
4187
  function UserMessageRow({
2374
4188
  item,
4189
+ renderMessageActions,
2375
4190
  renderMessageText,
2376
4191
  }: {
2377
4192
  item: UserMessageItem;
4193
+ renderMessageActions?: ((item: AgentMessageItem | UserMessageItem) => ReactNode) | undefined;
2378
4194
  renderMessageText?:
2379
4195
  | ((text: string, item: AgentMessageItem | UserMessageItem) => ReactNode)
2380
4196
  | undefined;
2381
4197
  }) {
2382
4198
  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;
4199
+ const deliveryFailed = item.delivery?.state === "failed";
2391
4200
  return (
2392
4201
  <div className={cn(enter && "animate-og-enter", "flex justify-end")}>
2393
4202
  <div className="flex max-w-[85%] min-w-0 flex-col items-end gap-1">
@@ -2400,11 +4209,19 @@ function UserMessageRow({
2400
4209
  }
2401
4210
  label="Copy message"
2402
4211
  className="w-fit max-w-full min-w-0"
2403
- trailing={<MessageFooterTime occurredAt={item.occurredAt} />}
4212
+ trailing={
4213
+ <>
4214
+ {renderMessageActions?.(item)}
4215
+ <MessageFooterTime occurredAt={item.occurredAt} />
4216
+ </>
4217
+ }
2404
4218
  >
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">
4219
+ <div className={MESSAGE_BUBBLE_CLASS}>
2406
4220
  {item.text ? (
2407
- <div data-og-annotation-source-key={item.annotationSource?.eventId}>
4221
+ <div
4222
+ data-og-search-item={item.id}
4223
+ data-og-annotation-source-key={item.annotationSource?.eventId}
4224
+ >
2408
4225
  {renderMessageText ? (
2409
4226
  renderMessageText(item.text, item)
2410
4227
  ) : (
@@ -2415,34 +4232,53 @@ function UserMessageRow({
2415
4232
  </div>
2416
4233
  ) : null}
2417
4234
  {(item.annotations?.length ?? 0) > 0 ? (
2418
- <TimelineAnnotationsChip
4235
+ <TimelineAnnotationCards
2419
4236
  annotations={item.annotations ?? []}
2420
4237
  className={item.text ? "mt-2" : undefined}
2421
4238
  />
2422
4239
  ) : null}
2423
4240
  </div>
2424
4241
  </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}
4242
+ {deliveryFailed ? (
4243
+ <div
4244
+ role="status"
4245
+ className="flex max-w-full flex-col items-start gap-1 px-1 text-og-xs text-og-status-failed"
4246
+ >
4247
+ <span className="flex min-w-0 items-start gap-1">
4248
+ <TriangleAlertIcon className="size-3.5 shrink-0" aria-hidden="true" />
4249
+ <span className="min-w-0 break-words">
4250
+ {item.delivery?.error || "Message not sent"}
4251
+ </span>
4252
+ </span>
4253
+ <div className="flex flex-wrap items-center gap-2">
4254
+ {item.delivery?.onEdit ? (
4255
+ <button
4256
+ type="button"
4257
+ className="font-medium text-og-status-failed underline decoration-og-status-failed/50 underline-offset-2 hover:text-og-fg"
4258
+ onClick={item.delivery.onEdit}
4259
+ >
4260
+ Edit message
4261
+ </button>
4262
+ ) : null}
4263
+ {item.delivery?.onRetry ? (
4264
+ <button
4265
+ type="button"
4266
+ className="font-medium text-og-status-failed underline decoration-og-status-failed/50 underline-offset-2 hover:text-og-fg"
4267
+ onClick={item.delivery.onRetry}
4268
+ >
4269
+ Retry
4270
+ </button>
4271
+ ) : null}
4272
+ {item.delivery?.onRemove ? (
4273
+ <button
4274
+ type="button"
4275
+ className="font-medium text-og-fg-subtle underline decoration-og-border underline-offset-2 hover:text-og-fg"
4276
+ onClick={item.delivery.onRemove}
4277
+ >
4278
+ Remove
4279
+ </button>
4280
+ ) : null}
4281
+ </div>
2446
4282
  </div>
2447
4283
  ) : null}
2448
4284
  </div>
@@ -2450,11 +4286,146 @@ function UserMessageRow({
2450
4286
  );
2451
4287
  }
2452
4288
 
4289
+ function HumanInputConversationRow({ item }: { item: HumanInputItem }) {
4290
+ const enter = useEntranceAnimation();
4291
+ const multipleQuestions = Math.max(item.questions.length, item.answers.length) > 1;
4292
+ const questionNumberById = new Map(
4293
+ item.questions.map((question, index) => [question.id, index + 1]),
4294
+ );
4295
+ const settledLabel =
4296
+ item.response.outcome === "answered"
4297
+ ? "You answered"
4298
+ : item.response.outcome === "skipped"
4299
+ ? "Skipped"
4300
+ : item.response.outcome === "expired"
4301
+ ? "Expired"
4302
+ : "Cancelled";
4303
+ const copyText = humanInputConversationCopyText(item, settledLabel);
4304
+
4305
+ return (
4306
+ <div
4307
+ className={cn(enter && "animate-og-enter", "flex w-full flex-col gap-2.5")}
4308
+ data-human-input-history={item.requestId}
4309
+ >
4310
+ <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%]">
4311
+ <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">
4312
+ <MessageCircleQuestionIcon aria-hidden="true" className="size-4" />
4313
+ </span>
4314
+ <div className="min-w-0 flex-1">
4315
+ <p className="text-og-xs font-medium text-og-fg-subtle">Agent asked</p>
4316
+ <div className="mt-1.5 space-y-3">
4317
+ {item.questions.length > 0 ? (
4318
+ item.questions.map((question, index) => (
4319
+ <div key={question.id}>
4320
+ {question.label ? (
4321
+ <p className="text-og-sm font-semibold text-og-fg">
4322
+ {multipleQuestions ? (
4323
+ <span className="mr-1.5 tabular-nums text-og-fg-muted">{index + 1}.</span>
4324
+ ) : null}
4325
+ {multipleQuestions ? " " : null}
4326
+ {question.label}
4327
+ </p>
4328
+ ) : null}
4329
+ <p
4330
+ className={cn(
4331
+ "text-og-md leading-6 text-og-fg",
4332
+ question.label && "mt-0.5 text-og-sm text-og-fg-muted",
4333
+ )}
4334
+ >
4335
+ {!question.label && multipleQuestions ? (
4336
+ <span className="mr-1.5 tabular-nums text-og-fg-muted">{index + 1}.</span>
4337
+ ) : null}
4338
+ {!question.label && multipleQuestions ? " " : null}
4339
+ {question.prompt}
4340
+ </p>
4341
+ </div>
4342
+ ))
4343
+ ) : (
4344
+ <p className="text-og-sm text-og-fg-muted">The agent requested structured input.</p>
4345
+ )}
4346
+ </div>
4347
+ </div>
4348
+ </div>
4349
+
4350
+ <div className="flex justify-end">
4351
+ <CopyHoverFrame
4352
+ copyText={copyText}
4353
+ label="Copy answer"
4354
+ className="w-fit max-w-[90%] min-w-0 sm:max-w-[82%]"
4355
+ trailing={<MessageFooterTime occurredAt={item.occurredAt} />}
4356
+ >
4357
+ <div className={MESSAGE_BUBBLE_CLASS}>
4358
+ <p className="text-og-xs font-medium text-og-fg-subtle">{settledLabel}</p>
4359
+ {item.response.outcome === "answered" ? (
4360
+ <div className="mt-1.5 space-y-3">
4361
+ {item.answers.length > 0 ? (
4362
+ item.answers.map((answer, answerIndex) => (
4363
+ <div key={answer.questionId}>
4364
+ {multipleQuestions ? (
4365
+ <p className="text-og-sm font-semibold text-og-fg">
4366
+ <span className="mr-1.5 tabular-nums text-og-fg-muted">
4367
+ {questionNumberById.get(answer.questionId) ?? answerIndex + 1}.
4368
+ </span>{" "}
4369
+ {answer.label}
4370
+ </p>
4371
+ ) : null}
4372
+ <div className={cn("text-og-md text-og-fg", multipleQuestions && "mt-0.5")}>
4373
+ {answer.values.length > 1 ? (
4374
+ <ul className="list-disc space-y-0.5 pl-5">
4375
+ {answer.values.map((value) => (
4376
+ <li key={`${answer.questionId}-${value}`}>{value}</li>
4377
+ ))}
4378
+ </ul>
4379
+ ) : (
4380
+ <p>{answer.values[0] || "Answered"}</p>
4381
+ )}
4382
+ </div>
4383
+ </div>
4384
+ ))
4385
+ ) : (
4386
+ <p>Answered</p>
4387
+ )}
4388
+ </div>
4389
+ ) : null}
4390
+ </div>
4391
+ </CopyHoverFrame>
4392
+ </div>
4393
+ </div>
4394
+ );
4395
+ }
4396
+
4397
+ function humanInputConversationCopyText(item: HumanInputItem, settledLabel: string): string {
4398
+ const questions = item.questions
4399
+ .map(
4400
+ (question, index) =>
4401
+ `${item.questions.length > 1 ? `${index + 1}. ` : ""}${question.label || "Question"}: ${question.prompt}`,
4402
+ )
4403
+ .join("\n\n");
4404
+ const questionNumberById = new Map(
4405
+ item.questions.map((question, index) => [question.id, index + 1]),
4406
+ );
4407
+ const answers = item.answers
4408
+ .map(
4409
+ (answer, index) =>
4410
+ `${
4411
+ item.questions.length > 1
4412
+ ? `${questionNumberById.get(answer.questionId) ?? index + 1}. `
4413
+ : ""
4414
+ }${answer.label}: ${answer.values.join(", ") || "Answered"}`,
4415
+ )
4416
+ .join("\n\n");
4417
+ return [questions, `${settledLabel}${answers ? `\n\n${answers}` : ""}`]
4418
+ .filter(Boolean)
4419
+ .join("\n\n");
4420
+ }
4421
+
2453
4422
  function AgentMessageRow({
2454
4423
  item,
4424
+ renderMessageActions,
2455
4425
  renderMessageText,
2456
4426
  }: {
2457
4427
  item: AgentMessageItem;
4428
+ renderMessageActions?: ((item: AgentMessageItem | UserMessageItem) => ReactNode) | undefined;
2458
4429
  renderMessageText?:
2459
4430
  | ((text: string, item: AgentMessageItem | UserMessageItem) => ReactNode)
2460
4431
  | undefined;
@@ -2470,17 +4441,28 @@ function AgentMessageRow({
2470
4441
  // While streaming, copy is still useful (current text) but keep chrome calm —
2471
4442
  // stamp only after the message finishes (occurredAt tracks completion).
2472
4443
  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} />}
4444
+ <CopyHoverFrame
4445
+ copyText={item.text}
4446
+ label="Copy message"
4447
+ align="start"
4448
+ className={cn(enter && "animate-og-enter", "min-w-0 text-og-md leading-7 text-og-fg")}
4449
+ trailing={
4450
+ item.streaming ? null : (
4451
+ <>
4452
+ {renderMessageActions?.(item)}
4453
+ <MessageFooterTime occurredAt={item.occurredAt} />
4454
+ </>
4455
+ )
4456
+ }
4457
+ >
4458
+ <div
4459
+ data-og-wide-table-message=""
4460
+ data-og-search-item={item.id}
4461
+ data-og-annotation-source-key={item.annotationSource?.eventId}
2480
4462
  >
2481
4463
  {body}
2482
- </CopyHoverFrame>
2483
- </div>
4464
+ </div>
4465
+ </CopyHoverFrame>
2484
4466
  );
2485
4467
  }
2486
4468
 
@@ -2555,9 +4537,7 @@ function WorkerCompletionRow({
2555
4537
  // payloads can carry a leftover pausedReason/rationale from earlier in the
2556
4538
  // worker's life, and a "Worker completed" card must not show a pause section.
2557
4539
  const showPausedReason =
2558
- item.childStatus !== "failed" &&
2559
- item.goalStatus === "paused" &&
2560
- Boolean(item.pausedReason?.trim());
4540
+ item.childStatus !== "failed" && item.goalStatus === "paused" && !!item.pausedReason?.trim();
2561
4541
  const details: { label: string; value: string; muted?: boolean }[] = [
2562
4542
  ...(item.text.trim() ? [{ label: "Report", value: item.text.trim() }] : []),
2563
4543
  ...(item.evidence?.trim()
@@ -2640,7 +4620,11 @@ function WorkerCompletionRow({
2640
4620
  );
2641
4621
  }
2642
4622
 
2643
- function SessionStatusRow({ item }: { item: { status: SessionStatus; occurredAt: string } }) {
4623
+ function SessionStatusRow({
4624
+ item,
4625
+ }: {
4626
+ item: { status: SessionStatus; occurredAt: string; resolvedAt?: string };
4627
+ }) {
2644
4628
  const enter = useEntranceAnimation();
2645
4629
  const meta = SESSION_STATUS_META[item.status];
2646
4630
  return (
@@ -2653,8 +4637,9 @@ function SessionStatusRow({ item }: { item: { status: SessionStatus; occurredAt:
2653
4637
  >
2654
4638
  <span className="h-px flex-1 bg-og-border" />
2655
4639
  <span className="inline-flex items-center gap-1.5">
2656
- <StatusDot status={item.status} className="size-1" />
2657
- {meta.label.toLowerCase()} · {formatRelativeTime(item.occurredAt)}
4640
+ {item.resolvedAt ? null : <StatusDot status={item.status} className="size-1" />}
4641
+ {item.resolvedAt ? "work resumed" : meta.label.toLowerCase()} ·{" "}
4642
+ {formatRelativeTime(item.resolvedAt ?? item.occurredAt)}
2658
4643
  </span>
2659
4644
  <span className="h-px flex-1 bg-og-border" />
2660
4645
  </div>
@@ -2682,36 +4667,46 @@ type GoalMeta = { label: string; pill: string; icon: ComponentType<{ className?:
2682
4667
 
2683
4668
  const NEUTRAL_PILL = "border-og-border bg-og-surface-1 text-og-fg-muted";
2684
4669
 
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
- };
4670
+ // Shared production chunks can be cyclic. Resolve icon bindings during render,
4671
+ // after their modules initialize, rather than permanently capturing undefined.
4672
+ function goalMeta(action: GoalItem["action"]): GoalMeta {
4673
+ const metadata: Record<GoalItem["action"], GoalMeta> = {
4674
+ set: {
4675
+ label: "Goal set",
4676
+ pill: "border-og-accent/30 bg-og-accent/10 text-og-accent",
4677
+ icon: TargetIcon,
4678
+ },
4679
+ updated: { label: "Goal updated", pill: NEUTRAL_PILL, icon: PencilLineIcon },
4680
+ completed: {
4681
+ label: "Goal completed",
4682
+ pill: "border-og-status-idle/30 bg-og-status-idle/10 text-og-status-idle",
4683
+ icon: CheckIcon,
4684
+ },
4685
+ paused: {
4686
+ label: "Goal paused",
4687
+ pill: WAITING_PILL_CLASS,
4688
+ icon: PauseIcon,
4689
+ },
4690
+ resumed: { label: "Goal resumed", pill: NEUTRAL_PILL, icon: PlayIcon },
4691
+ cleared: { label: "Goal cleared", pill: NEUTRAL_PILL, icon: Trash2Icon },
4692
+ held: {
4693
+ label: "Goal held",
4694
+ pill: WAITING_PILL_CLASS,
4695
+ icon: PauseCircleIcon,
4696
+ },
4697
+ continuation: { label: "Continuing toward the goal", pill: NEUTRAL_PILL, icon: ArrowRightIcon },
4698
+ };
4699
+ return metadata[action];
4700
+ }
2706
4701
 
2707
4702
  /**
2708
4703
  * 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
4704
+ * {@link goalMeta} so all six actions are visually distinguishable while the
2710
4705
  * palette stays restrained — see that table for the per-action rationale.
2711
4706
  */
2712
4707
  function GoalRow({ item }: { item: GoalItem }) {
2713
4708
  const enter = useEntranceAnimation();
2714
- const { label, pill, icon: Icon } = GOAL_META[item.action];
4709
+ const { label, pill, icon: Icon } = goalMeta(item.action);
2715
4710
  return (
2716
4711
  <div className={cn(enter && "animate-og-enter", "flex justify-center")}>
2717
4712
  <span
@@ -2732,9 +4727,11 @@ function GoalRow({ item }: { item: GoalItem }) {
2732
4727
 
2733
4728
  function MachineInputBatchRow({
2734
4729
  item,
4730
+ onOpenSession,
2735
4731
  loadVideoArtifactPlayback,
2736
4732
  }: {
2737
4733
  item: MachineInputBatchItem;
4734
+ onOpenSession?: ((sessionId: string) => void) | undefined;
2738
4735
  loadVideoArtifactPlayback?: VideoArtifactPlaybackLoader | undefined;
2739
4736
  }) {
2740
4737
  const enter = useEntranceAnimation();
@@ -2750,11 +4747,15 @@ function MachineInputBatchRow({
2750
4747
  }
2751
4748
  const singleSummary = single ? cleanMachineInputSummary(single.summary) : "";
2752
4749
  const showCollapsedSummary =
2753
- single != null && machineInputSummaryIsUseful(single.kind, singleSummary);
4750
+ !item.compact && single != null && machineInputSummaryIsUseful(single.kind, singleSummary);
2754
4751
 
2755
4752
  return (
2756
4753
  <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="">
4754
+ <details
4755
+ className="group w-full max-w-full"
4756
+ data-og-machine-input-batch=""
4757
+ title={`Received ${formatClockTime(item.occurredAt)}`}
4758
+ >
2758
4759
  <summary className="flex cursor-pointer list-none justify-center [&::-webkit-details-marker]:hidden">
2759
4760
  <span
2760
4761
  className={cn(
@@ -2770,10 +4771,14 @@ function MachineInputBatchRow({
2770
4771
  </span>
2771
4772
  </summary>
2772
4773
  <div className="mx-auto mt-2 w-full max-w-lg space-y-2 border-t border-og-border/50 pt-2">
4774
+ <p className="text-og-xs text-og-fg-subtle">
4775
+ Received <time dateTime={item.occurredAt}>{formatClockTime(item.occurredAt)}</time>
4776
+ </p>
2773
4777
  {item.members.map((member) => (
2774
4778
  <MachineInputRow
2775
4779
  key={member.id}
2776
4780
  member={member}
4781
+ onOpenSession={onOpenSession}
2777
4782
  loadVideoArtifactPlayback={loadVideoArtifactPlayback}
2778
4783
  />
2779
4784
  ))}
@@ -2790,9 +4795,11 @@ function MachineInputBatchRow({
2790
4795
 
2791
4796
  function MachineInputRow({
2792
4797
  member,
4798
+ onOpenSession,
2793
4799
  loadVideoArtifactPlayback,
2794
4800
  }: {
2795
4801
  member: MachineInputBatchItem["members"][number];
4802
+ onOpenSession?: ((sessionId: string) => void) | undefined;
2796
4803
  loadVideoArtifactPlayback?: VideoArtifactPlaybackLoader | undefined;
2797
4804
  }) {
2798
4805
  if (member.kind === "media_generation_result" && member.result) {
@@ -2819,6 +4826,11 @@ function MachineInputRow({
2819
4826
  {truncate(summary, 320)}
2820
4827
  </p>
2821
4828
  ) : null}
4829
+ <ChildSessionLink
4830
+ kind={member.kind}
4831
+ sourceId={member.sourceId}
4832
+ onOpenSession={onOpenSession}
4833
+ />
2822
4834
  </div>
2823
4835
  </div>
2824
4836
  );
@@ -2892,12 +4904,34 @@ function formatVideoDuration(seconds: number): string {
2892
4904
 
2893
4905
  function NoticeRow({ item }: { item: NoticeItem }) {
2894
4906
  const enter = useEntranceAnimation();
4907
+ if (item.allowance) return <AllowanceExhaustedRow refusal={item.allowance} />;
4908
+ if (item.recordedOutcome) {
4909
+ return (
4910
+ <details
4911
+ className="group text-og-sm text-og-fg-muted"
4912
+ role="note"
4913
+ data-og-recorded-outcome="wait"
4914
+ >
4915
+ <summary className="flex cursor-pointer list-none items-center gap-2 py-1 [&::-webkit-details-marker]:hidden">
4916
+ <ChevronRightIcon
4917
+ aria-hidden
4918
+ className="size-3.5 transition-transform group-open:rotate-90"
4919
+ />
4920
+ <span title={new Date(item.occurredAt).toLocaleString()}>
4921
+ {recordedWaitSummary(item)}
4922
+ </span>
4923
+ </summary>
4924
+ <p className="mt-1 whitespace-pre-wrap break-words pl-5 text-og-fg-muted">{item.text}</p>
4925
+ </details>
4926
+ );
4927
+ }
4928
+ const resolvedApproval = Boolean(item.resolvedAt && item.text.startsWith("Approval needed"));
2895
4929
  const tone =
2896
4930
  item.tone === "failed"
2897
4931
  ? "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";
4932
+ : item.tone === "waiting" && !item.resolvedAt
4933
+ ? WAITING_PILL_CLASS
4934
+ : NEUTRAL_PILL;
2901
4935
  return (
2902
4936
  <div
2903
4937
  className={cn(
@@ -2907,15 +4941,21 @@ function NoticeRow({ item }: { item: NoticeItem }) {
2907
4941
  )}
2908
4942
  role="status"
2909
4943
  >
2910
- <TriangleAlertIcon
2911
- className={cn("mt-0.5 size-4 shrink-0", item.tone === "cancelled" && "opacity-60")}
2912
- />
4944
+ {resolvedApproval ? (
4945
+ <CheckIcon className="mt-0.5 size-4 shrink-0 opacity-70" aria-hidden="true" />
4946
+ ) : (
4947
+ <TriangleAlertIcon
4948
+ className={cn("mt-0.5 size-4 shrink-0", item.tone === "cancelled" && "opacity-60")}
4949
+ />
4950
+ )}
2913
4951
  <div className="min-w-0 flex-1">
2914
- <span className="whitespace-pre-wrap break-words">{item.text}</span>
4952
+ <span className="whitespace-pre-wrap break-words">
4953
+ {resolvedApproval ? "You responded to this approval." : item.text}
4954
+ </span>
2915
4955
  {item.details ? (
2916
4956
  <details className="mt-2 text-og-control">
2917
4957
  <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">
4958
+ <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
4959
  {JSON.stringify(item.details.value, null, 2)}
2920
4960
  </pre>
2921
4961
  </details>
@@ -2935,6 +4975,41 @@ function NoticeRow({ item }: { item: NoticeItem }) {
2935
4975
  );
2936
4976
  }
2937
4977
 
4978
+ /**
4979
+ * "Waited for 2 agents · 3m 5s" once later input ended the wait, otherwise
4980
+ * "Waiting for 2 agents · since 10:32" (with the date on a later day). The
4981
+ * reason stays behind the disclosure.
4982
+ */
4983
+ function recordedWaitSummary(item: NoticeItem): ReactNode {
4984
+ const agents = item.waitingAgents ?? 0;
4985
+ const target = agents > 0 ? ` for ${agents} ${agents === 1 ? "agent" : "agents"}` : "";
4986
+ const waitedMs = item.waitEndedAt
4987
+ ? durationBetween(item.occurredAt, item.waitEndedAt)
4988
+ : undefined;
4989
+ if (waitedMs !== undefined) {
4990
+ return (
4991
+ <>
4992
+ Waited{target} ·{" "}
4993
+ <time dateTime={`PT${Math.floor(waitedMs / 1000)}S`}>{formatElapsed(waitedMs)}</time>
4994
+ </>
4995
+ );
4996
+ }
4997
+ // An open wait is historical once its day has passed; keep its date visible.
4998
+ const started = new Date(item.occurredAt);
4999
+ const sameDay = started.toDateString() === new Date().toDateString();
5000
+ return (
5001
+ <>
5002
+ {item.waitEndedAt ? "Waited" : "Waiting"}
5003
+ {target} · since{" "}
5004
+ <time dateTime={item.occurredAt}>
5005
+ {sameDay
5006
+ ? formatClockTime(item.occurredAt)
5007
+ : started.toLocaleString(undefined, { dateStyle: "medium", timeStyle: "short" })}
5008
+ </time>
5009
+ </>
5010
+ );
5011
+ }
5012
+
2938
5013
  /**
2939
5014
  * The inline connection-recovery card: missing or lapsed access surfaces as a
2940
5015
  * calm, tappable affordance instead of a raw provider-domain error. The `reason`
@@ -2981,6 +5056,7 @@ function AuthNeededRow({
2981
5056
  ? `${provider} tools unavailable`
2982
5057
  : `${actionLabel} ${provider}`;
2983
5058
  const reasonLine = recommendation?.rationale ?? authReasonLine(item.reason);
5059
+ const hostAuthorizationUrl = item.authoritySource === "host" ? item.authorizationUrl : null;
2984
5060
 
2985
5061
  const start = async () => {
2986
5062
  if (!onReconnect || busy) {
@@ -3023,27 +5099,40 @@ function AuthNeededRow({
3023
5099
  ) : null}
3024
5100
  </div>
3025
5101
  </div>
3026
- {!unavailable && onReconnect ? (
5102
+ {hostAuthorizationUrl ? (
5103
+ <a
5104
+ href={hostAuthorizationUrl}
5105
+ rel="noreferrer"
5106
+ target="_blank"
5107
+ className={cn(
5108
+ PRIMARY_ACTION_CLASS,
5109
+ "transition-colors hover:bg-og-primary-hover pointer-coarse:min-h-9",
5110
+ )}
5111
+ >
5112
+ <RefreshCwIcon className="size-3.5" aria-hidden />
5113
+ {actionLabel}
5114
+ </a>
5115
+ ) : item.authoritySource !== "host" && !unavailable && onReconnect ? (
3027
5116
  <button
3028
5117
  type="button"
3029
5118
  onClick={() => void start()}
3030
5119
  disabled={busy}
3031
5120
  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",
5121
+ PRIMARY_ACTION_CLASS,
5122
+ "transition-colors hover:bg-og-primary-hover disabled:opacity-50 pointer-coarse:min-h-9",
3034
5123
  )}
3035
5124
  >
3036
5125
  <RefreshCwIcon className={cn("size-3.5", busy && "animate-og-spin")} aria-hidden />
3037
5126
  {busy ? "Opening…" : actionLabel}
3038
5127
  </button>
3039
- ) : !unavailable && item.authorizationUrl ? (
5128
+ ) : item.authoritySource !== "host" && !unavailable && item.authorizationUrl ? (
3040
5129
  <a
3041
5130
  href={item.authorizationUrl}
3042
5131
  rel="noreferrer"
3043
5132
  target="_blank"
3044
5133
  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",
5134
+ PRIMARY_ACTION_CLASS,
5135
+ "transition-colors hover:bg-og-primary-hover pointer-coarse:min-h-9",
3047
5136
  )}
3048
5137
  >
3049
5138
  <RefreshCwIcon className="size-3.5" aria-hidden />
@@ -3105,7 +5194,7 @@ function AuthProviderLogo({ src, label }: { src: string | null; label: string })
3105
5194
  `capabilityMonogram` so the reconnect tile reads like every other logo tile. */
3106
5195
  function monogram(label: string): string {
3107
5196
  const words = label.trim().split(/\s+/).filter(Boolean);
3108
- if (words.length === 0) {
5197
+ if (!words.length) {
3109
5198
  return "?";
3110
5199
  }
3111
5200
  if (words.length === 1) {