@opengeni/react 3.1.0 → 3.1.2

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 (863) hide show
  1. package/README.md +769 -22
  2. package/dist/accounts.js +89 -11
  3. package/dist/accounts.js.map +1 -1
  4. package/dist/approvals.d.ts +2 -1
  5. package/dist/artifacts-document.js +5 -3
  6. package/dist/artifacts-presentation.js +5 -3
  7. package/dist/artifacts-spreadsheet.d.ts +1 -1
  8. package/dist/artifacts-spreadsheet.js +5 -3
  9. package/dist/artifacts.d.ts +5 -1
  10. package/dist/artifacts.js +536 -42
  11. package/dist/artifacts.js.map +1 -1
  12. package/dist/browser-viewer-ZWR6MWJ7.js +19 -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-BO2IWXVK.js +14 -0
  16. package/dist/chunk-2DDJ6YH6.js +41 -0
  17. package/dist/chunk-2DDJ6YH6.js.map +1 -0
  18. package/dist/chunk-2HVLQ26H.js +183 -0
  19. package/dist/chunk-2HVLQ26H.js.map +1 -0
  20. package/dist/{chunk-RGALCTWO.js → chunk-2I6R3GCZ.js} +4285 -3980
  21. package/dist/chunk-2I6R3GCZ.js.map +1 -0
  22. package/dist/{chunk-D3UIAJKY.js → chunk-2SFFYIH3.js} +1179 -166
  23. package/dist/chunk-2SFFYIH3.js.map +1 -0
  24. package/dist/chunk-3PVDYTTB.js +24 -0
  25. package/dist/chunk-3PVDYTTB.js.map +1 -0
  26. package/dist/chunk-3UZCWK46.js +298 -0
  27. package/dist/chunk-3UZCWK46.js.map +1 -0
  28. package/dist/chunk-47CKUXTW.js +293 -0
  29. package/dist/chunk-47CKUXTW.js.map +1 -0
  30. package/dist/chunk-4QYP6QUI.js +109 -0
  31. package/dist/chunk-4QYP6QUI.js.map +1 -0
  32. package/dist/chunk-5EY26VZ7.js +1053 -0
  33. package/dist/chunk-5EY26VZ7.js.map +1 -0
  34. package/dist/chunk-5I4VRWBB.js +56 -0
  35. package/dist/chunk-5I4VRWBB.js.map +1 -0
  36. package/dist/chunk-5WOR7UCL.js +191 -0
  37. package/dist/chunk-5WOR7UCL.js.map +1 -0
  38. package/dist/chunk-5YJLGOM5.js +51 -0
  39. package/dist/chunk-5YJLGOM5.js.map +1 -0
  40. package/dist/chunk-6JFN3OWG.js +386 -0
  41. package/dist/chunk-6JFN3OWG.js.map +1 -0
  42. package/dist/chunk-7QJT5O4S.js +35 -0
  43. package/dist/chunk-7QJT5O4S.js.map +1 -0
  44. package/dist/chunk-A55KV72M.js +402 -0
  45. package/dist/chunk-A55KV72M.js.map +1 -0
  46. package/dist/chunk-ADWCLI4G.js +2028 -0
  47. package/dist/chunk-ADWCLI4G.js.map +1 -0
  48. package/dist/chunk-B27E3V37.js +29 -0
  49. package/dist/chunk-B27E3V37.js.map +1 -0
  50. package/dist/chunk-B7TC2A33.js +83 -0
  51. package/dist/chunk-B7TC2A33.js.map +1 -0
  52. package/dist/chunk-BLL56F7R.js +385 -0
  53. package/dist/chunk-BLL56F7R.js.map +1 -0
  54. package/dist/chunk-BOFEQC6K.js +162 -0
  55. package/dist/chunk-BOFEQC6K.js.map +1 -0
  56. package/dist/chunk-CM7LVUKC.js +35 -0
  57. package/dist/chunk-CM7LVUKC.js.map +1 -0
  58. package/dist/chunk-CORUP6JB.js +122 -0
  59. package/dist/chunk-CORUP6JB.js.map +1 -0
  60. package/dist/chunk-CSX5FFKZ.js +35 -0
  61. package/dist/chunk-CSX5FFKZ.js.map +1 -0
  62. package/dist/chunk-EGVHJKGJ.js +3125 -0
  63. package/dist/chunk-EGVHJKGJ.js.map +1 -0
  64. package/dist/{chunk-NEXLLTP4.js → chunk-G76LSA76.js} +6 -5
  65. package/dist/chunk-G76LSA76.js.map +1 -0
  66. package/dist/{chunk-PEI6RANI.js → chunk-GQMRL52F.js} +311 -75
  67. package/dist/chunk-GQMRL52F.js.map +1 -0
  68. package/dist/{chunk-ELIYWBZR.js → chunk-GUUXOISA.js} +1113 -457
  69. package/dist/chunk-GUUXOISA.js.map +1 -0
  70. package/dist/{chunk-HUIKIDM5.js → chunk-I7X2P4FU.js} +225 -116
  71. package/dist/chunk-I7X2P4FU.js.map +1 -0
  72. package/dist/chunk-IGFZFLI2.js +1390 -0
  73. package/dist/chunk-IGFZFLI2.js.map +1 -0
  74. package/dist/{chunk-4MDLEHXL.js → chunk-IMSUVYQB.js} +26 -13
  75. package/dist/chunk-IMSUVYQB.js.map +1 -0
  76. package/dist/chunk-IOYHDEP3.js +38 -0
  77. package/dist/chunk-IOYHDEP3.js.map +1 -0
  78. package/dist/chunk-IZV5FTJC.js +55 -0
  79. package/dist/chunk-IZV5FTJC.js.map +1 -0
  80. package/dist/chunk-KF33GTWN.js +343 -0
  81. package/dist/chunk-KF33GTWN.js.map +1 -0
  82. package/dist/chunk-KSGQ5ODA.js +457 -0
  83. package/dist/chunk-KSGQ5ODA.js.map +1 -0
  84. package/dist/{chunk-OAM4WAAM.js → chunk-KZC6QRNR.js} +1513 -697
  85. package/dist/chunk-KZC6QRNR.js.map +1 -0
  86. package/dist/chunk-LJUMYUBO.js +218 -0
  87. package/dist/chunk-LJUMYUBO.js.map +1 -0
  88. package/dist/chunk-LSW2OJVV.js +380 -0
  89. package/dist/chunk-LSW2OJVV.js.map +1 -0
  90. package/dist/chunk-M357MHH3.js +2 -0
  91. package/dist/chunk-M357MHH3.js.map +1 -0
  92. package/dist/chunk-MK2TQO4Y.js +24 -0
  93. package/dist/chunk-MK2TQO4Y.js.map +1 -0
  94. package/dist/chunk-N7S72EXK.js +25 -0
  95. package/dist/chunk-N7S72EXK.js.map +1 -0
  96. package/dist/{chunk-XXWH2JLC.js → chunk-NKMYZFHA.js} +616 -621
  97. package/dist/chunk-NKMYZFHA.js.map +1 -0
  98. package/dist/chunk-NX4ADH73.js +14 -0
  99. package/dist/chunk-NX4ADH73.js.map +1 -0
  100. package/dist/chunk-O7F6UJO4.js +71 -0
  101. package/dist/chunk-O7F6UJO4.js.map +1 -0
  102. package/dist/chunk-OVB5WIG5.js +14064 -0
  103. package/dist/chunk-OVB5WIG5.js.map +1 -0
  104. package/dist/chunk-OYQQJL3J.js +16 -0
  105. package/dist/chunk-OYQQJL3J.js.map +1 -0
  106. package/dist/{chunk-H5YGYNXS.js → chunk-P4BNUO57.js} +60 -17
  107. package/dist/chunk-P4BNUO57.js.map +1 -0
  108. package/dist/{chunk-JVYQRRC4.js → chunk-PEHTX3V7.js} +21 -1
  109. package/dist/chunk-PEHTX3V7.js.map +1 -0
  110. package/dist/chunk-PMV53IMM.js +26 -0
  111. package/dist/chunk-PMV53IMM.js.map +1 -0
  112. package/dist/{chunk-KCGXPP5V.js → chunk-PYEOPA4M.js} +13 -7
  113. package/dist/chunk-PYEOPA4M.js.map +1 -0
  114. package/dist/{chunk-KZ73RL76.js → chunk-QHUE7E4N.js} +8 -3
  115. package/dist/chunk-QHUE7E4N.js.map +1 -0
  116. package/dist/chunk-QV3DJVRZ.js +11 -0
  117. package/dist/chunk-QV3DJVRZ.js.map +1 -0
  118. package/dist/chunk-R5SBNG7S.js +810 -0
  119. package/dist/chunk-R5SBNG7S.js.map +1 -0
  120. package/dist/chunk-RI42H5IU.js +326 -0
  121. package/dist/chunk-RI42H5IU.js.map +1 -0
  122. package/dist/chunk-S2QH6EZ5.js +211 -0
  123. package/dist/chunk-S2QH6EZ5.js.map +1 -0
  124. package/dist/chunk-SHLZMAJN.js +304 -0
  125. package/dist/chunk-SHLZMAJN.js.map +1 -0
  126. package/dist/chunk-TGXK4BAZ.js +293 -0
  127. package/dist/chunk-TGXK4BAZ.js.map +1 -0
  128. package/dist/{chunk-5YK2ZDIG.js → chunk-TNEZNMSN.js} +4 -10
  129. package/dist/chunk-TNEZNMSN.js.map +1 -0
  130. package/dist/{chunk-22KP6LR5.js → chunk-TXS65BB2.js} +3 -1
  131. package/dist/{chunk-22KP6LR5.js.map → chunk-TXS65BB2.js.map} +1 -1
  132. package/dist/chunk-UNJPZGNK.js +63 -0
  133. package/dist/chunk-UNJPZGNK.js.map +1 -0
  134. package/dist/{chunk-MZ2G2XBA.js → chunk-V5ZUJT2T.js} +205 -45
  135. package/dist/chunk-V5ZUJT2T.js.map +1 -0
  136. package/dist/chunk-VIUEOHXY.js +394 -0
  137. package/dist/chunk-VIUEOHXY.js.map +1 -0
  138. package/dist/chunk-WIUKB3LD.js +1911 -0
  139. package/dist/chunk-WIUKB3LD.js.map +1 -0
  140. package/dist/{chunk-ICA6R6TW.js → chunk-WNN7NO2N.js} +1451 -355
  141. package/dist/chunk-WNN7NO2N.js.map +1 -0
  142. package/dist/{chunk-YJFGAFBD.js → chunk-X5UVUIMF.js} +1892 -1223
  143. package/dist/chunk-X5UVUIMF.js.map +1 -0
  144. package/dist/chunk-XDA5QJE7.js +402 -0
  145. package/dist/chunk-XDA5QJE7.js.map +1 -0
  146. package/dist/{chunk-VZTQ73XT.js → chunk-XEA5BCMA.js} +3 -1
  147. package/dist/{chunk-VZTQ73XT.js.map → chunk-XEA5BCMA.js.map} +1 -1
  148. package/dist/chunk-XHF7OYAN.js +1150 -0
  149. package/dist/chunk-XHF7OYAN.js.map +1 -0
  150. package/dist/chunk-YSRBFJMQ.js +43 -0
  151. package/dist/chunk-YSRBFJMQ.js.map +1 -0
  152. package/dist/chunk-ZOZAPAUO.js +174 -0
  153. package/dist/chunk-ZOZAPAUO.js.map +1 -0
  154. package/dist/chunk-ZQV5W7YC.js +809 -0
  155. package/dist/chunk-ZQV5W7YC.js.map +1 -0
  156. package/dist/chunk-ZUWF4NF7.js +13 -0
  157. package/dist/chunk-ZUWF4NF7.js.map +1 -0
  158. package/dist/client.d.ts +16 -7
  159. package/dist/clipboard.js +3 -1
  160. package/dist/commands/types.d.ts +2 -0
  161. package/dist/components/anthropic-mark.d.ts +3 -0
  162. package/dist/components/approval-surface.d.ts +19 -6
  163. package/dist/components/artifacts/artifact-chrome.d.ts +120 -0
  164. package/dist/components/artifacts/artifact-sandbox.d.ts +30 -0
  165. package/dist/components/artifacts/artifact-surface.d.ts +4 -2
  166. package/dist/components/artifacts/chat-interactive-block.d.ts +29 -0
  167. package/dist/components/artifacts/deferred-chat-media.d.ts +15 -0
  168. package/dist/components/artifacts/document-editor.d.ts +4 -2
  169. package/dist/components/artifacts/editable-artifact-ui.d.ts +2 -1
  170. package/dist/components/artifacts/editable-artifact-view.d.ts +48 -0
  171. package/dist/components/artifacts/editable-document.d.ts +1 -1
  172. package/dist/components/artifacts/editable-presentation.d.ts +2 -2
  173. package/dist/components/artifacts/editable-spreadsheet.d.ts +8 -3
  174. package/dist/components/artifacts/index.d.ts +9 -2
  175. package/dist/components/artifacts/inline-html-document.d.ts +2 -0
  176. package/dist/components/artifacts/latest-task-runner.d.ts +15 -0
  177. package/dist/components/artifacts/presentation-editor.d.ts +6 -2
  178. package/dist/components/artifacts/published-html-artifact-frame.d.ts +23 -3
  179. package/dist/components/artifacts/session-artifact-viewer.d.ts +39 -0
  180. package/dist/components/artifacts/site-view.d.ts +26 -0
  181. package/dist/components/artifacts/spreadsheet-command-queue.d.ts +20 -0
  182. package/dist/components/artifacts/spreadsheet-download.d.ts +7 -0
  183. package/dist/components/artifacts/spreadsheet-general-display.d.ts +24 -0
  184. package/dist/components/artifacts/spreadsheet-grid.d.ts +10 -3
  185. package/dist/components/artifacts/spreadsheet-resize.d.ts +50 -0
  186. package/dist/components/artifacts/spreadsheet-submissions.d.ts +9 -0
  187. package/dist/components/artifacts/visualization-assets.gen.d.ts +2 -0
  188. package/dist/components/browser-select-control.d.ts +16 -0
  189. package/dist/components/browser-viewer.d.ts +11 -2
  190. package/dist/components/chat-composer.d.ts +21 -3
  191. package/dist/components/child-session-link.d.ts +9 -0
  192. package/dist/components/claude-mark.d.ts +3 -0
  193. package/dist/components/code-editor.d.ts +1 -4
  194. package/dist/components/composer-transcription-control.d.ts +7 -0
  195. package/dist/components/composer.d.ts +34 -67
  196. package/dist/components/computer-viewer.d.ts +1 -1
  197. package/dist/components/copy-button.d.ts +3 -1
  198. package/dist/components/desktop-viewer.d.ts +1 -1
  199. package/dist/components/fleet-tile.d.ts +2 -2
  200. package/dist/components/grok-mark.d.ts +26 -0
  201. package/dist/components/human-input-form.d.ts +28 -32
  202. package/dist/components/human-input-surface.d.ts +7 -2
  203. package/dist/components/linkified-text.d.ts +18 -0
  204. package/dist/components/machine-input-display.d.ts +22 -1
  205. package/dist/components/machines/agent-status-icon.d.ts +6 -0
  206. package/dist/components/machines/machine-detail.d.ts +7 -1
  207. package/dist/components/machines/machine-status.d.ts +48 -0
  208. package/dist/components/markdown-table-layout.d.ts +18 -0
  209. package/dist/components/markdown.d.ts +37 -4
  210. package/dist/components/message-timeline.d.ts +86 -7
  211. package/dist/components/model-mark.d.ts +29 -0
  212. package/dist/components/model-policy-picker-menu.d.ts +9 -0
  213. package/dist/components/model-policy-picker.d.ts +41 -25
  214. package/dist/components/new-conversation.d.ts +32 -0
  215. package/dist/components/open-geni-chat.d.ts +73 -0
  216. package/dist/components/open-geni-links.d.ts +68 -0
  217. package/dist/components/openrouter-mark.d.ts +6 -0
  218. package/dist/components/opper-mark.d.ts +7 -0
  219. package/dist/components/preview-loading.d.ts +2 -0
  220. package/dist/components/provider-recovery-notice.d.ts +16 -0
  221. package/dist/components/queue-item-content.d.ts +9 -0
  222. package/dist/components/rehype-secret-refs.d.ts +23 -0
  223. package/dist/components/remark-soft-line-breaks.d.ts +38 -0
  224. package/dist/components/sandbox-files.d.ts +3 -1
  225. package/dist/components/sandbox-workspace.d.ts +18 -0
  226. package/dist/components/secret-question-field.d.ts +31 -0
  227. package/dist/components/secret-ref-chip.d.ts +35 -0
  228. package/dist/components/session-capability-frame.d.ts +37 -0
  229. package/dist/components/session-chrome.d.ts +57 -8
  230. package/dist/components/session-commands-panel.d.ts +6 -0
  231. package/dist/components/session-commands.d.ts +6 -0
  232. package/dist/components/session-connection-request.d.ts +8 -0
  233. package/dist/components/session-conversation.d.ts +157 -0
  234. package/dist/components/session-list.d.ts +39 -0
  235. package/dist/components/session-mcp-capability-card.d.ts +41 -0
  236. package/dist/components/session-proxy-scope.d.ts +35 -0
  237. package/dist/components/session-status.d.ts +4 -1
  238. package/dist/components/session-usage-limit-notice.d.ts +39 -0
  239. package/dist/components/timeline-anchor.d.ts +24 -0
  240. package/dist/components/timeline-annotation-chrome.d.ts +25 -0
  241. package/dist/components/timeline-annotation-layout.d.ts +42 -0
  242. package/dist/components/timeline-annotation-markers.d.ts +8 -0
  243. package/dist/components/timeline-annotation-reveal-context.d.ts +4 -0
  244. package/dist/components/timeline-annotation-shared.d.ts +31 -0
  245. package/dist/components/timeline-annotations-dialog.d.ts +3 -2
  246. package/dist/components/timeline-annotations.d.ts +13 -3
  247. package/dist/components/timeline-reading-progress.d.ts +11 -0
  248. package/dist/components/timeline-search.d.ts +34 -0
  249. package/dist/components/timeline-settlement.d.ts +16 -0
  250. package/dist/components/tip-follow.d.ts +16 -0
  251. package/dist/components/tool-action-review.d.ts +47 -0
  252. package/dist/components/tool-review-history.d.ts +25 -0
  253. package/dist/components/usage-limit-notice.d.ts +49 -0
  254. package/dist/components/usage-member-list.d.ts +91 -0
  255. package/dist/components/usage-meter.d.ts +73 -0
  256. package/dist/components/use-viewer-menu-dismiss.d.ts +2 -0
  257. package/dist/components/user-message-body.d.ts +10 -1
  258. package/dist/composer-messages.d.ts +74 -0
  259. package/dist/composer-messages.js +9 -0
  260. package/dist/composer-messages.js.map +1 -0
  261. package/dist/composer-voice-events.d.ts +8 -0
  262. package/dist/composer.d.ts +5 -4
  263. package/dist/composer.js +26 -13
  264. package/dist/computer-viewer-GMLDXPON.js +20 -0
  265. package/dist/computer-viewer-GMLDXPON.js.map +1 -0
  266. package/dist/connect-account-identity.d.ts +8 -0
  267. package/dist/connect-accounts.d.ts +15 -0
  268. package/dist/connect-chooser.d.ts +18 -0
  269. package/dist/connect-panel.d.ts +18 -0
  270. package/dist/connect-setup.d.ts +18 -0
  271. package/dist/connect.d.ts +21 -0
  272. package/dist/connect.js +2149 -0
  273. package/dist/connect.js.map +1 -0
  274. package/dist/connection-catalog.d.ts +38 -0
  275. package/dist/connection-discovery.d.ts +12 -0
  276. package/dist/connection-installed.d.ts +17 -0
  277. package/dist/connection-logo.d.ts +9 -0
  278. package/dist/connection-service-logo.d.ts +8 -0
  279. package/dist/connection-service-presentation.d.ts +13 -0
  280. package/dist/connection-skeleton.d.ts +5 -0
  281. package/dist/connection-type-picker.d.ts +6 -0
  282. package/dist/conversation-timeline.d.ts +5 -0
  283. package/dist/desktop.d.ts +7 -0
  284. package/dist/desktop.js +27 -0
  285. package/dist/desktop.js.map +1 -0
  286. package/dist/device-authorization.d.ts +23 -0
  287. package/dist/diffs.d.ts +8 -0
  288. package/dist/diffs.js +15 -0
  289. package/dist/diffs.js.map +1 -0
  290. package/dist/discovery-cache.d.ts +5 -0
  291. package/dist/editor.d.ts +7 -0
  292. package/dist/editor.js +22 -0
  293. package/dist/editor.js.map +1 -0
  294. package/dist/embedded-new-voice-OCRNT6U5.js +18 -0
  295. package/dist/embedded-new-voice-OCRNT6U5.js.map +1 -0
  296. package/dist/embedded-voice-RPHPYVBT.js +18 -0
  297. package/dist/embedded-voice-RPHPYVBT.js.map +1 -0
  298. package/dist/{fleet-decision-row-GGCAT4E4.js → fleet-decision-row-SI63O5V4.js} +4 -1
  299. package/dist/fleet-decision-row-SI63O5V4.js.map +1 -0
  300. package/dist/hooks/latest-question.d.ts +25 -0
  301. package/dist/hooks/shared-machines-list.d.ts +4 -0
  302. package/dist/hooks/shared-query.d.ts +113 -0
  303. package/dist/hooks/use-attached-browsers.d.ts +1 -1
  304. package/dist/hooks/use-auth-runs.d.ts +1 -4
  305. package/dist/hooks/use-available-models.d.ts +1 -1
  306. package/dist/hooks/use-browser-frame-stream.d.ts +1 -1
  307. package/dist/hooks/use-browser-session.d.ts +12 -1
  308. package/dist/hooks/use-client-config-flags.d.ts +49 -0
  309. package/dist/hooks/use-codex-accounts.d.ts +59 -10
  310. package/dist/hooks/use-composer.d.ts +24 -2
  311. package/dist/hooks/use-computer-frame-stream.d.ts +1 -1
  312. package/dist/hooks/use-computer-input-posture.d.ts +13 -0
  313. package/dist/hooks/use-computer-session.d.ts +4 -0
  314. package/dist/hooks/use-connect.d.ts +23 -0
  315. package/dist/hooks/use-desktop-stream.d.ts +1 -1
  316. package/dist/hooks/use-file-attachments.d.ts +12 -0
  317. package/dist/hooks/use-goal.d.ts +2 -1
  318. package/dist/hooks/use-last-started-turn-policy.d.ts +5 -1
  319. package/dist/hooks/use-new-conversation.d.ts +56 -0
  320. package/dist/hooks/use-new-session-draft.d.ts +74 -0
  321. package/dist/hooks/use-realtime-voice-models.d.ts +7 -0
  322. package/dist/hooks/use-sandbox-files.d.ts +4 -1
  323. package/dist/hooks/use-session-conversation.d.ts +49 -0
  324. package/dist/hooks/use-session-events.d.ts +28 -7
  325. package/dist/hooks/use-session-mcp-approval-policy.d.ts +2 -2
  326. package/dist/hooks/use-session-model-account.d.ts +48 -0
  327. package/dist/hooks/use-supergrok-accounts.d.ts +7 -1
  328. package/dist/hooks/use-terminal-stream.d.ts +11 -5
  329. package/dist/hooks/use-usage.d.ts +40 -0
  330. package/dist/hooks/use-vaults.d.ts +53 -0
  331. package/dist/hooks/use-voice-input.d.ts +8 -1
  332. package/dist/hooks/use-workspace-edit.d.ts +4 -0
  333. package/dist/hooks/use-workspace-sessions.d.ts +22 -5
  334. package/dist/human-input-model.d.ts +122 -0
  335. package/dist/identity-link-accounts.d.ts +11 -0
  336. package/dist/identity-link-consent.d.ts +18 -0
  337. package/dist/index.d.ts +82 -20
  338. package/dist/index.js +2350 -6605
  339. package/dist/index.js.map +1 -1
  340. package/dist/interaction.d.ts +1 -1
  341. package/dist/interaction.js +14 -24
  342. package/dist/interaction.js.map +1 -1
  343. package/dist/latest-question-R77KWSUP.js +118 -0
  344. package/dist/latest-question-R77KWSUP.js.map +1 -0
  345. package/dist/lib/clipboard.d.ts +7 -0
  346. package/dist/lib/error-message.d.ts +7 -0
  347. package/dist/lib/format.d.ts +58 -4
  348. package/dist/lib/host-theme.d.ts +77 -0
  349. package/dist/lib/interaction-errors.d.ts +3 -0
  350. package/dist/lib/menu-styles.d.ts +22 -0
  351. package/dist/lib/notify-observer.d.ts +2 -0
  352. package/dist/lib/pierre-diffs-loader.d.ts +20 -0
  353. package/dist/lib/provider-recovery.d.ts +55 -0
  354. package/dist/lib/scrollbars.d.ts +11 -0
  355. package/dist/lib/terminal-input-readiness.d.ts +11 -0
  356. package/dist/lib/transient-retry.d.ts +25 -0
  357. package/dist/lib/workbench-peers.d.ts +36 -0
  358. package/dist/lib/xterm-theme.d.ts +7 -5
  359. package/dist/machines.js +9 -5
  360. package/dist/markdown-table-layout-3PV2UAAH.js +113 -0
  361. package/dist/markdown-table-layout-3PV2UAAH.js.map +1 -0
  362. package/dist/mcp-connection-status.d.ts +14 -0
  363. package/dist/model-mark-paths.d.ts +34 -0
  364. package/dist/model-mark-paths.js +16 -0
  365. package/dist/model-mark-paths.js.map +1 -0
  366. package/dist/model-picker-model.d.ts +111 -0
  367. package/dist/model-picker-model.js +25 -0
  368. package/dist/model-picker-model.js.map +1 -0
  369. package/dist/model-policy-picker-menu-7GJ5URPR.js +17 -0
  370. package/dist/model-policy-picker-menu-7GJ5URPR.js.map +1 -0
  371. package/dist/model-policy.d.ts +30 -3
  372. package/dist/model-policy.js +14 -1
  373. package/dist/native-previews.d.ts +28 -0
  374. package/dist/native-previews.js +82 -0
  375. package/dist/native-previews.js.map +1 -0
  376. package/dist/new-session-draft.d.ts +7 -0
  377. package/dist/new-session-draft.js +639 -0
  378. package/dist/new-session-draft.js.map +1 -0
  379. package/dist/notification-text.d.ts +27 -0
  380. package/dist/{noto-sans-arabic-loader-IA2T4X2A.js → noto-sans-arabic-loader-XSH2KYWJ.js} +3 -1
  381. package/dist/{noto-sans-arabic-loader-IA2T4X2A.js.map → noto-sans-arabic-loader-XSH2KYWJ.js.map} +1 -1
  382. package/dist/{noto-sans-jp-loader.generated-HFGLYBR3.js → noto-sans-jp-loader.generated-MR5L6V7K.js} +3 -1
  383. package/dist/{noto-sans-jp-loader.generated-HFGLYBR3.js.map → noto-sans-jp-loader.generated-MR5L6V7K.js.map} +1 -1
  384. package/dist/older-history.d.ts +3 -2
  385. package/dist/organization-model.d.ts +32 -0
  386. package/dist/organization-model.js +65 -0
  387. package/dist/organization-model.js.map +1 -0
  388. package/dist/{platform-activity-row-BJHUHXAT.js → platform-activity-row-5OCKOQWZ.js} +16 -78
  389. package/dist/platform-activity-row-5OCKOQWZ.js.map +1 -0
  390. package/dist/plugin-details.d.ts +14 -0
  391. package/dist/plugin-discovery.d.ts +25 -0
  392. package/dist/preview-loading-paint.d.ts +20 -0
  393. package/dist/provider.d.ts +25 -2
  394. package/dist/queue-presentation-model.d.ts +72 -0
  395. package/dist/queue-presentation-model.js +38 -0
  396. package/dist/queue-presentation-model.js.map +1 -0
  397. package/dist/{queue-surface-implementation-BSKUWHRI.js → queue-surface-implementation-GJBH7NKO.js} +65 -265
  398. package/dist/queue-surface-implementation-GJBH7NKO.js.map +1 -0
  399. package/dist/realtime/embedded-new-voice.d.ts +1 -0
  400. package/dist/realtime/embedded-voice.d.ts +1 -0
  401. package/dist/realtime/realtime-control.d.ts +4 -65
  402. package/dist/realtime/session-realtime.d.ts +78 -0
  403. package/dist/realtime.js +25 -1294
  404. package/dist/realtime.js.map +1 -1
  405. package/dist/sandbox-files-O27IPOZW.js +21 -0
  406. package/dist/sandbox-files-O27IPOZW.js.map +1 -0
  407. package/dist/sandbox-label-model.d.ts +30 -0
  408. package/dist/sandbox-label-model.js +20 -0
  409. package/dist/sandbox-label-model.js.map +1 -0
  410. package/dist/scrollbars.d.ts +1 -0
  411. package/dist/scrollbars.js +10 -0
  412. package/dist/scrollbars.js.map +1 -0
  413. package/dist/secret-reference-label.d.ts +14 -0
  414. package/dist/secrets-ui.d.ts +57 -0
  415. package/dist/session-agents-model.d.ts +10 -0
  416. package/dist/session-agents-model.js +25 -0
  417. package/dist/session-agents-model.js.map +1 -0
  418. package/dist/session-attention-model.d.ts +8 -0
  419. package/dist/session-attention-model.js +47 -0
  420. package/dist/session-attention-model.js.map +1 -0
  421. package/dist/session-auth-recommendation.d.ts +5 -0
  422. package/dist/session-capability-policy.d.ts +31 -0
  423. package/dist/session-feedback-model.d.ts +20 -0
  424. package/dist/session-feedback-model.js +56 -0
  425. package/dist/session-feedback-model.js.map +1 -0
  426. package/dist/session-list-model.d.ts +138 -0
  427. package/dist/session-list-model.js +274 -0
  428. package/dist/session-list-model.js.map +1 -0
  429. package/dist/session-realtime-7DJOIHQW.js +21 -0
  430. package/dist/session-realtime-7DJOIHQW.js.map +1 -0
  431. package/dist/session-realtime.d.ts +7 -0
  432. package/dist/session-realtime.js +19 -0
  433. package/dist/session-realtime.js.map +1 -0
  434. package/dist/session-status-model.d.ts +36 -0
  435. package/dist/session-ui.d.ts +39 -1
  436. package/dist/session-ui.js +126 -12
  437. package/dist/session-ui.js.map +1 -1
  438. package/dist/session.d.ts +8 -2
  439. package/dist/session.js +35 -20
  440. package/dist/site-snapshot.d.ts +14 -0
  441. package/dist/sites-ui.d.ts +24 -0
  442. package/dist/sites.d.ts +3 -0
  443. package/dist/sites.js +14 -0
  444. package/dist/sites.js.map +1 -0
  445. package/dist/skill-discovery.d.ts +47 -0
  446. package/dist/terminal.d.ts +8 -0
  447. package/dist/terminal.js +32 -0
  448. package/dist/terminal.js.map +1 -0
  449. package/dist/testing/session-scenarios.d.ts +10 -0
  450. package/dist/testing/session-scenarios.js +367 -0
  451. package/dist/testing/session-scenarios.js.map +1 -0
  452. package/dist/timeline/activity-rail.d.ts +11 -2
  453. package/dist/timeline/agent-identity.d.ts +30 -0
  454. package/dist/timeline/agent-row.d.ts +31 -0
  455. package/dist/timeline/allowance-exhausted-row.d.ts +24 -0
  456. package/dist/timeline/compaction-copy.d.ts +46 -0
  457. package/dist/timeline/entrance.d.ts +13 -1
  458. package/dist/timeline/focus-sequence.d.ts +9 -0
  459. package/dist/timeline/genie-copy.d.ts +3 -0
  460. package/dist/timeline/genie-loading.d.ts +47 -0
  461. package/dist/timeline/index.d.ts +10 -5
  462. package/dist/timeline/knowledge-receipt.d.ts +17 -0
  463. package/dist/timeline/message-sender.d.ts +19 -0
  464. package/dist/timeline/notice-presentation.d.ts +13 -0
  465. package/dist/timeline/parsers.d.ts +11 -0
  466. package/dist/timeline/patch-apply-command.d.ts +18 -0
  467. package/dist/timeline/platform-activity-presentation.d.ts +21 -0
  468. package/dist/timeline/platform-activity-row.d.ts +1 -2
  469. package/dist/timeline/presented-image.d.ts +11 -0
  470. package/dist/timeline/projection.d.ts +31 -2
  471. package/dist/timeline/provider-logo.d.ts +15 -0
  472. package/dist/timeline/question-nav-model.d.ts +23 -0
  473. package/dist/timeline/registry.d.ts +12 -1
  474. package/dist/timeline/retained-image.d.ts +18 -0
  475. package/dist/timeline/retained-loaders.d.ts +27 -0
  476. package/dist/timeline/rolling-activity.d.ts +11 -0
  477. package/dist/timeline/screenshot-lightbox.d.ts +23 -1
  478. package/dist/timeline/session-usage-limit-row.d.ts +11 -0
  479. package/dist/timeline/shared.d.ts +4 -1
  480. package/dist/timeline/startup-dispatch-details.d.ts +5 -0
  481. package/dist/timeline/startup-preference.d.ts +3 -0
  482. package/dist/timeline/startup-timings.d.ts +4 -0
  483. package/dist/timeline/tool-display-name.d.ts +5 -2
  484. package/dist/timeline/tool-presentation.d.ts +172 -0
  485. package/dist/timeline/turn-summary-model.d.ts +95 -0
  486. package/dist/timeline/turn-summary.d.ts +20 -54
  487. package/dist/timeline/types.d.ts +179 -7
  488. package/dist/timeline/work-presentation.d.ts +60 -0
  489. package/dist/timeline-annotation-markers-U3G66KUD.js +166 -0
  490. package/dist/timeline-annotation-markers-U3G66KUD.js.map +1 -0
  491. package/dist/timeline-annotation-selection-BI7QTPTZ.js +242 -0
  492. package/dist/timeline-annotation-selection-BI7QTPTZ.js.map +1 -0
  493. package/dist/timeline-annotations-dialog-FOMHSDTE.js +274 -0
  494. package/dist/timeline-annotations-dialog-FOMHSDTE.js.map +1 -0
  495. package/dist/timeline-model.d.ts +29 -0
  496. package/dist/timeline-model.js +254 -0
  497. package/dist/timeline-model.js.map +1 -0
  498. package/dist/usage/allowance-copy.d.ts +25 -0
  499. package/dist/usage/session-usage-limit.d.ts +88 -0
  500. package/dist/usage/summary.d.ts +35 -0
  501. package/dist/usage.d.ts +17 -0
  502. package/dist/usage.js +1187 -0
  503. package/dist/usage.js.map +1 -0
  504. package/dist/voice-recording-owner.d.ts +7 -0
  505. package/package.json +107 -7
  506. package/src/accounts.tsx +116 -14
  507. package/src/approvals.ts +8 -1
  508. package/src/artifacts-spreadsheet.ts +1 -0
  509. package/src/artifacts.ts +44 -0
  510. package/src/assets/README.md +8 -0
  511. package/src/assets/gmail.ico +0 -0
  512. package/src/assets/outlook.ico +0 -0
  513. package/src/capability-catalog-row.tsx +94 -0
  514. package/src/capability-logo-fallback.ts +22 -0
  515. package/src/client.ts +20 -31
  516. package/src/commands/registry.ts +18 -15
  517. package/src/commands/types.ts +2 -0
  518. package/src/components/anthropic-mark.tsx +10 -0
  519. package/src/components/approval-surface.tsx +401 -138
  520. package/src/components/artifacts/artifact-chrome.tsx +413 -0
  521. package/src/components/artifacts/artifact-sandbox.tsx +189 -0
  522. package/src/components/artifacts/artifact-surface.tsx +40 -20
  523. package/src/components/artifacts/chat-interactive-block.tsx +260 -0
  524. package/src/components/artifacts/deferred-chat-media.tsx +67 -0
  525. package/src/components/artifacts/document-editor.tsx +141 -16
  526. package/src/components/artifacts/editable-artifact-ui.tsx +4 -2
  527. package/src/components/artifacts/editable-artifact-view.tsx +197 -0
  528. package/src/components/artifacts/editable-artifact-workbench.tsx +5 -2
  529. package/src/components/artifacts/editable-document.tsx +37 -36
  530. package/src/components/artifacts/editable-presentation.tsx +36 -18
  531. package/src/components/artifacts/editable-spreadsheet.tsx +543 -103
  532. package/src/components/artifacts/index.ts +45 -0
  533. package/src/components/artifacts/inline-html-document.ts +24 -0
  534. package/src/components/artifacts/latest-task-runner.ts +49 -0
  535. package/src/components/artifacts/presentation-editor.tsx +323 -67
  536. package/src/components/artifacts/published-html-artifact-frame.tsx +122 -5
  537. package/src/components/artifacts/session-artifact-viewer.tsx +378 -0
  538. package/src/components/artifacts/site-view.tsx +121 -0
  539. package/src/components/artifacts/spreadsheet-canvas.ts +5 -10
  540. package/src/components/artifacts/spreadsheet-command-queue.ts +184 -0
  541. package/src/components/artifacts/spreadsheet-download.tsx +109 -0
  542. package/src/components/artifacts/spreadsheet-general-display.ts +43 -0
  543. package/src/components/artifacts/spreadsheet-grid.tsx +344 -144
  544. package/src/components/artifacts/spreadsheet-resize.tsx +421 -0
  545. package/src/components/artifacts/spreadsheet-submissions.tsx +46 -0
  546. package/src/components/artifacts/visualization-assets/base.css +1015 -0
  547. package/src/components/artifacts/visualization-assets/helpers.html +349 -0
  548. package/src/components/artifacts/visualization-assets.gen.ts +3 -0
  549. package/src/components/browser-select-control.tsx +312 -0
  550. package/src/components/browser-viewer.tsx +778 -318
  551. package/src/components/chat-composer.tsx +47 -5
  552. package/src/components/child-session-link.tsx +36 -0
  553. package/src/components/claude-mark.tsx +11 -0
  554. package/src/components/code-editor.tsx +13 -16
  555. package/src/components/composer-transcription-control.tsx +128 -30
  556. package/src/components/composer.tsx +404 -195
  557. package/src/components/computer-viewer.tsx +880 -253
  558. package/src/components/copy-button.tsx +13 -5
  559. package/src/components/desktop-viewer.tsx +1 -1
  560. package/src/components/enrollment-consent.tsx +1 -1
  561. package/src/components/enrollment-device-flow.tsx +1 -1
  562. package/src/components/file-browser.tsx +10 -14
  563. package/src/components/fleet-tile.tsx +9 -18
  564. package/src/components/generated-video-player.tsx +4 -2
  565. package/src/components/grok-mark.tsx +34 -0
  566. package/src/components/human-input-form.tsx +590 -283
  567. package/src/components/human-input-surface.tsx +11 -0
  568. package/src/components/interaction-intervention-banner.tsx +2 -2
  569. package/src/components/linkified-text.tsx +79 -0
  570. package/src/components/machine-card.tsx +35 -78
  571. package/src/components/machine-dock-bar.tsx +1 -1
  572. package/src/components/machine-input-display.ts +197 -3
  573. package/src/components/machines/agent-status-icon.tsx +31 -0
  574. package/src/components/machines/machine-detail.tsx +200 -31
  575. package/src/components/machines/machine-operation-policy.tsx +1 -1
  576. package/src/components/machines/machine-status.ts +241 -0
  577. package/src/components/machines-dashboard.tsx +2 -17
  578. package/src/components/markdown-table-layout.ts +143 -0
  579. package/src/components/markdown.tsx +466 -129
  580. package/src/components/message-timeline.tsx +2567 -728
  581. package/src/components/model-mark.tsx +102 -0
  582. package/src/components/model-picker.tsx +15 -4
  583. package/src/components/model-policy-picker-menu.tsx +371 -0
  584. package/src/components/model-policy-picker.tsx +256 -412
  585. package/src/components/new-conversation.tsx +221 -0
  586. package/src/components/open-geni-chat.tsx +309 -0
  587. package/src/components/open-geni-links.tsx +179 -0
  588. package/src/components/openrouter-mark.tsx +13 -0
  589. package/src/components/opper-mark.tsx +18 -0
  590. package/src/components/pierre-diff.tsx +41 -16
  591. package/src/components/pierre-file.tsx +35 -10
  592. package/src/components/preview-loading.tsx +109 -0
  593. package/src/components/provider-recovery-notice.tsx +49 -0
  594. package/src/components/queue-item-content.ts +14 -0
  595. package/src/components/queue-surface-implementation.tsx +51 -356
  596. package/src/components/queue-surface-state.tsx +3 -1
  597. package/src/components/rehype-secret-refs.ts +98 -0
  598. package/src/components/remark-soft-line-breaks.ts +166 -0
  599. package/src/components/sandbox-files.tsx +91 -15
  600. package/src/components/sandbox-terminal.tsx +84 -13
  601. package/src/components/sandbox-workspace.tsx +147 -51
  602. package/src/components/secret-question-field.tsx +324 -0
  603. package/src/components/secret-ref-chip.tsx +185 -0
  604. package/src/components/session-capability-frame.tsx +197 -0
  605. package/src/components/session-chrome.tsx +842 -251
  606. package/src/components/session-commands-panel.tsx +126 -0
  607. package/src/components/session-commands.tsx +18 -0
  608. package/src/components/session-connection-request.tsx +97 -0
  609. package/src/components/session-conversation.tsx +876 -0
  610. package/src/components/session-list.tsx +257 -0
  611. package/src/components/session-mcp-capability-card.tsx +680 -0
  612. package/src/components/session-proxy-scope.tsx +153 -0
  613. package/src/components/session-status.tsx +26 -17
  614. package/src/components/session-usage-limit-notice.tsx +174 -0
  615. package/src/components/timeline-anchor.tsx +119 -0
  616. package/src/components/timeline-annotation-chrome.tsx +196 -0
  617. package/src/components/timeline-annotation-layout.ts +268 -0
  618. package/src/components/timeline-annotation-markers.tsx +185 -0
  619. package/src/components/timeline-annotation-reveal-context.ts +9 -0
  620. package/src/components/timeline-annotation-selection.tsx +106 -54
  621. package/src/components/timeline-annotation-shared.ts +245 -0
  622. package/src/components/timeline-annotations-dialog.tsx +185 -128
  623. package/src/components/timeline-annotations.tsx +189 -45
  624. package/src/components/timeline-reading-progress.ts +80 -0
  625. package/src/components/timeline-search.tsx +168 -0
  626. package/src/components/timeline-settlement.ts +60 -0
  627. package/src/components/tip-follow.ts +52 -0
  628. package/src/components/tool-action-review.tsx +650 -0
  629. package/src/components/tool-review-history.tsx +88 -0
  630. package/src/components/tooltip.tsx +38 -5
  631. package/src/components/usage-limit-notice.tsx +179 -0
  632. package/src/components/usage-member-list.tsx +792 -0
  633. package/src/components/usage-meter.tsx +382 -0
  634. package/src/components/use-viewer-menu-dismiss.ts +33 -0
  635. package/src/components/user-message-body.tsx +27 -2
  636. package/src/components/workspace-dock.tsx +18 -1
  637. package/src/composer-messages.ts +150 -0
  638. package/src/composer-voice-events.ts +12 -0
  639. package/src/composer.ts +6 -1
  640. package/src/connect-account-identity.tsx +34 -0
  641. package/src/connect-accounts.tsx +233 -0
  642. package/src/connect-chooser.tsx +336 -0
  643. package/src/connect-panel.tsx +99 -0
  644. package/src/connect-setup.tsx +355 -0
  645. package/src/connect.ts +60 -0
  646. package/src/connection-assets.d.ts +4 -0
  647. package/src/connection-catalog.tsx +162 -0
  648. package/src/connection-discovery.tsx +238 -0
  649. package/src/connection-installed.tsx +51 -0
  650. package/src/connection-logo.tsx +37 -0
  651. package/src/connection-service-logo.tsx +55 -0
  652. package/src/connection-service-presentation.ts +46 -0
  653. package/src/connection-skeleton.tsx +16 -0
  654. package/src/connection-type-picker.tsx +36 -0
  655. package/src/conversation-timeline.ts +92 -0
  656. package/src/desktop.ts +24 -0
  657. package/src/device-authorization.tsx +98 -0
  658. package/src/diffs.ts +16 -0
  659. package/src/discovery-cache.ts +42 -0
  660. package/src/editor.ts +13 -0
  661. package/src/hooks/internal.ts +3 -2
  662. package/src/hooks/latest-question.ts +164 -0
  663. package/src/hooks/shared-machines-list.ts +34 -1
  664. package/src/hooks/shared-query.ts +721 -0
  665. package/src/hooks/use-attached-browsers.ts +1 -1
  666. package/src/hooks/use-auth-runs.ts +0 -27
  667. package/src/hooks/use-available-models.ts +21 -15
  668. package/src/hooks/use-browser-frame-stream.ts +19 -2
  669. package/src/hooks/use-browser-session.ts +680 -205
  670. package/src/hooks/use-browser-sessions.ts +32 -1
  671. package/src/hooks/use-client-config-flags.ts +118 -0
  672. package/src/hooks/use-codex-accounts.ts +247 -47
  673. package/src/hooks/use-composer.ts +276 -47
  674. package/src/hooks/use-computer-frame-stream.ts +25 -7
  675. package/src/hooks/use-computer-input-posture.ts +94 -0
  676. package/src/hooks/use-computer-session.ts +409 -95
  677. package/src/hooks/use-connect.ts +23 -0
  678. package/src/hooks/use-desktop-stream.ts +19 -28
  679. package/src/hooks/use-file-attachments.ts +80 -4
  680. package/src/hooks/use-goal.ts +107 -13
  681. package/src/hooks/use-last-started-turn-policy.ts +46 -14
  682. package/src/hooks/use-machines.ts +4 -0
  683. package/src/hooks/use-new-conversation.ts +374 -0
  684. package/src/hooks/use-new-session-draft.ts +813 -0
  685. package/src/hooks/use-realtime-voice-models.ts +46 -0
  686. package/src/hooks/use-sandbox-files.ts +68 -20
  687. package/src/hooks/use-session-background-commands.ts +22 -11
  688. package/src/hooks/use-session-capabilities.ts +3 -2
  689. package/src/hooks/use-session-conversation.ts +150 -0
  690. package/src/hooks/use-session-events.ts +703 -382
  691. package/src/hooks/use-session-lineage.ts +20 -16
  692. package/src/hooks/use-session-mcp-approval-policy.ts +13 -5
  693. package/src/hooks/use-session-model-account.ts +174 -0
  694. package/src/hooks/use-session.ts +53 -21
  695. package/src/hooks/use-slash-commands.ts +6 -11
  696. package/src/hooks/use-supergrok-accounts.ts +22 -8
  697. package/src/hooks/use-terminal-stream.ts +153 -27
  698. package/src/hooks/use-transcription.ts +6 -0
  699. package/src/hooks/use-turn-queue.ts +2 -1
  700. package/src/hooks/use-usage.ts +100 -0
  701. package/src/hooks/use-vaults.ts +153 -0
  702. package/src/hooks/use-video-artifact-playback.ts +1 -1
  703. package/src/hooks/use-voice-input.ts +127 -19
  704. package/src/hooks/use-workspace-capture.ts +3 -2
  705. package/src/hooks/use-workspace-edit.ts +23 -5
  706. package/src/hooks/use-workspace-sessions.ts +97 -32
  707. package/src/human-input-model.ts +359 -0
  708. package/src/identity-link-accounts.tsx +148 -0
  709. package/src/identity-link-consent.tsx +192 -0
  710. package/src/index.ts +262 -16
  711. package/src/interaction.ts +1 -0
  712. package/src/lib/clipboard.ts +29 -0
  713. package/src/lib/error-message.ts +37 -0
  714. package/src/lib/format.ts +213 -16
  715. package/src/lib/host-theme.ts +529 -0
  716. package/src/lib/interaction-errors.ts +15 -0
  717. package/src/lib/menu-styles.ts +44 -0
  718. package/src/lib/notify-observer.ts +15 -0
  719. package/src/lib/pierre-diffs-loader.ts +53 -0
  720. package/src/lib/provider-recovery.ts +243 -0
  721. package/src/lib/scrollbars.ts +30 -0
  722. package/src/lib/terminal-input-readiness.ts +51 -0
  723. package/src/lib/transient-retry.ts +99 -0
  724. package/src/lib/workbench-peers.ts +62 -0
  725. package/src/lib/xterm-theme.ts +23 -21
  726. package/src/mcp-connection-status.ts +35 -0
  727. package/src/model-mark-paths.ts +53 -0
  728. package/src/model-picker-model.ts +330 -0
  729. package/src/model-policy.ts +299 -74
  730. package/src/native-previews.ts +100 -0
  731. package/src/new-session-draft.ts +22 -0
  732. package/src/notification-text.ts +81 -0
  733. package/src/older-history.ts +22 -4
  734. package/src/organization-model.ts +118 -0
  735. package/src/plugin-details.tsx +185 -0
  736. package/src/plugin-discovery.tsx +196 -0
  737. package/src/preview-loading-paint.ts +68 -0
  738. package/src/provider.tsx +74 -15
  739. package/src/queue-presentation-model.ts +464 -0
  740. package/src/realtime/dropdown-menu.tsx +19 -12
  741. package/src/realtime/embedded-new-voice.tsx +1 -0
  742. package/src/realtime/embedded-voice.tsx +3 -0
  743. package/src/realtime/realtime-control.tsx +138 -501
  744. package/src/realtime/session-realtime.ts +566 -0
  745. package/src/sandbox-label-model.ts +53 -0
  746. package/src/scrollbars.ts +3 -0
  747. package/src/secret-reference-label.ts +43 -0
  748. package/src/secrets-ui.tsx +59 -0
  749. package/src/session-agents-model.ts +33 -0
  750. package/src/session-attention-model.ts +62 -0
  751. package/src/session-auth-recommendation.ts +48 -0
  752. package/src/session-capability-policy.ts +196 -0
  753. package/src/session-context.ts +3 -4
  754. package/src/session-feedback-model.ts +95 -0
  755. package/src/session-list-model.ts +434 -0
  756. package/src/session-realtime.ts +18 -0
  757. package/src/session-status-model.ts +99 -0
  758. package/src/session-ui.ts +98 -1
  759. package/src/session.ts +17 -1
  760. package/src/site-snapshot.ts +74 -0
  761. package/src/sites-ui.tsx +348 -0
  762. package/src/sites.ts +3 -0
  763. package/src/skill-discovery.tsx +208 -0
  764. package/src/terminal.ts +26 -0
  765. package/src/testing/session-scenarios.ts +404 -0
  766. package/src/timeline/activity-rail.tsx +242 -101
  767. package/src/timeline/agent-identity.tsx +84 -0
  768. package/src/timeline/agent-row.tsx +231 -0
  769. package/src/timeline/allowance-exhausted-row.tsx +86 -0
  770. package/src/timeline/compaction-copy.ts +115 -0
  771. package/src/timeline/entrance.tsx +28 -3
  772. package/src/timeline/fleet-decision-projection.ts +2 -1
  773. package/src/timeline/fleet-decision-row.tsx +1 -0
  774. package/src/timeline/focus-sequence.ts +44 -0
  775. package/src/timeline/genie-copy.ts +19 -0
  776. package/src/timeline/genie-loading.tsx +143 -0
  777. package/src/timeline/index.ts +30 -2
  778. package/src/timeline/knowledge-receipt.tsx +100 -0
  779. package/src/timeline/message-sender.tsx +71 -0
  780. package/src/timeline/notice-presentation.ts +48 -0
  781. package/src/timeline/parsers.ts +42 -2
  782. package/src/timeline/patch-apply-command.tsx +135 -0
  783. package/src/timeline/platform-activity-presentation.ts +166 -0
  784. package/src/timeline/platform-activity-row.tsx +15 -90
  785. package/src/timeline/presented-image.ts +63 -0
  786. package/src/timeline/projection.ts +1746 -76
  787. package/src/timeline/provider-logo.tsx +53 -0
  788. package/src/timeline/question-nav-model.ts +28 -0
  789. package/src/timeline/registry.ts +26 -4
  790. package/src/timeline/retained-image.ts +97 -0
  791. package/src/timeline/retained-loaders.ts +91 -0
  792. package/src/timeline/rolling-activity.tsx +80 -0
  793. package/src/timeline/screenshot-lightbox.tsx +228 -5
  794. package/src/timeline/session-usage-limit-row.tsx +42 -0
  795. package/src/timeline/shared.tsx +42 -15
  796. package/src/timeline/startup-dispatch-details.tsx +36 -0
  797. package/src/timeline/startup-preference.ts +33 -0
  798. package/src/timeline/startup-timings.tsx +145 -0
  799. package/src/timeline/tool-display-name.ts +19 -7
  800. package/src/timeline/tool-presentation.ts +1316 -0
  801. package/src/timeline/tool-renderers.tsx +835 -1138
  802. package/src/timeline/turn-summary-model.ts +343 -0
  803. package/src/timeline/turn-summary.tsx +272 -365
  804. package/src/timeline/types.ts +189 -6
  805. package/src/timeline/work-presentation.ts +189 -0
  806. package/src/timeline-model.ts +143 -0
  807. package/src/usage/allowance-copy.ts +64 -0
  808. package/src/usage/session-usage-limit.ts +266 -0
  809. package/src/usage/summary.ts +93 -0
  810. package/src/usage.ts +70 -0
  811. package/src/voice-recording-owner.ts +22 -0
  812. package/styles/compiled.css +2319 -748
  813. package/styles/connect.css +290 -0
  814. package/styles/connect.d.ts +1 -0
  815. package/styles/effective-tokens.css +1 -0
  816. package/styles/index.css +309 -9
  817. package/styles/responsive.css +99 -7
  818. package/styles/session-capability.css +44 -0
  819. package/styles/tokens.css +134 -66
  820. package/dist/browser-viewer-AEFHUYZK.js +0 -14
  821. package/dist/chunk-4MDLEHXL.js.map +0 -1
  822. package/dist/chunk-5YK2ZDIG.js.map +0 -1
  823. package/dist/chunk-D3UIAJKY.js.map +0 -1
  824. package/dist/chunk-ELIYWBZR.js.map +0 -1
  825. package/dist/chunk-H5YGYNXS.js.map +0 -1
  826. package/dist/chunk-HQVCKCLG.js +0 -9458
  827. package/dist/chunk-HQVCKCLG.js.map +0 -1
  828. package/dist/chunk-HUIKIDM5.js.map +0 -1
  829. package/dist/chunk-ICA6R6TW.js.map +0 -1
  830. package/dist/chunk-JVYQRRC4.js.map +0 -1
  831. package/dist/chunk-KCGXPP5V.js.map +0 -1
  832. package/dist/chunk-KZ73RL76.js.map +0 -1
  833. package/dist/chunk-LAKLF4PA.js +0 -141
  834. package/dist/chunk-LAKLF4PA.js.map +0 -1
  835. package/dist/chunk-MZ2G2XBA.js.map +0 -1
  836. package/dist/chunk-NEXLLTP4.js.map +0 -1
  837. package/dist/chunk-OAM4WAAM.js.map +0 -1
  838. package/dist/chunk-PEI6RANI.js.map +0 -1
  839. package/dist/chunk-RGALCTWO.js.map +0 -1
  840. package/dist/chunk-W6NFJXSA.js +0 -454
  841. package/dist/chunk-W6NFJXSA.js.map +0 -1
  842. package/dist/chunk-WSK7G5LE.js +0 -170
  843. package/dist/chunk-WSK7G5LE.js.map +0 -1
  844. package/dist/chunk-X4A66EWZ.js +0 -223
  845. package/dist/chunk-X4A66EWZ.js.map +0 -1
  846. package/dist/chunk-XXWH2JLC.js.map +0 -1
  847. package/dist/chunk-YFKJ7VZH.js +0 -268
  848. package/dist/chunk-YFKJ7VZH.js.map +0 -1
  849. package/dist/chunk-YJFGAFBD.js.map +0 -1
  850. package/dist/computer-viewer-FWEZISW4.js +0 -16
  851. package/dist/fleet-decision-row-GGCAT4E4.js.map +0 -1
  852. package/dist/hooks/use-environments.d.ts +0 -41
  853. package/dist/hooks/use-packs.d.ts +0 -32
  854. package/dist/platform-activity-row-BJHUHXAT.js.map +0 -1
  855. package/dist/queue-surface-implementation-BSKUWHRI.js.map +0 -1
  856. package/dist/timeline-annotation-selection-3L7LHGG2.js +0 -187
  857. package/dist/timeline-annotation-selection-3L7LHGG2.js.map +0 -1
  858. package/dist/timeline-annotations-dialog-U7EVLR75.js +0 -196
  859. package/dist/timeline-annotations-dialog-U7EVLR75.js.map +0 -1
  860. package/src/hooks/use-environments.ts +0 -182
  861. package/src/hooks/use-packs.ts +0 -157
  862. /package/dist/{browser-viewer-AEFHUYZK.js.map → browser-viewer-ZWR6MWJ7.js.map} +0 -0
  863. /package/dist/{computer-viewer-FWEZISW4.js.map → chat-interactive-block-BO2IWXVK.js.map} +0 -0
@@ -1,16 +1,52 @@
1
+ import { QUESTION_NAV_MARGIN_PX, questionNavTarget } from "../timeline/question-nav-model";
2
+ import { timelineGroupIndexAtSequence } from "../timeline/focus-sequence";
3
+ import { RollingActivity } from "../timeline/rolling-activity";
4
+ import {
5
+ clusterIsSettled,
6
+ compactedLandmarkCount,
7
+ durationBetween,
8
+ flattenActivityItems,
9
+ isPreparingWork,
10
+ readableWorkDefaultOpen,
11
+ readableWorkHasDetails,
12
+ readableWorkShowsPreview,
13
+ readableWorkStatus,
14
+ } from "../timeline/work-presentation";
15
+ import { useErrorMessage } from "../lib/error-message";
16
+ import { formatElapsed } from "../timeline/turn-summary";
17
+ import { ActivityNoteTextContext } from "../timeline/activity-rail";
18
+ import { timelineGroupContainsPresentedImage } from "../timeline/presented-image";
19
+ import {
20
+ isTimelineSearchTarget,
21
+ TimelineSearchRevealContext,
22
+ useTimelineSearchNavigation,
23
+ type TimelineSearchTarget,
24
+ } from "./timeline-search";
25
+ import { GenieLoadingOptionsContext, type GenieLoadingOptions } from "../timeline/genie-loading";
26
+ import { ChildSessionLink } from "./child-session-link";
27
+ import { useStartupDetails } from "../timeline/startup-preference";
28
+ import {
29
+ compactionFailureDetails,
30
+ compactionSkipSubtitle,
31
+ compactionSkipTitle,
32
+ } from "../timeline/compaction-copy";
1
33
  import type {
2
34
  DraftTimelineAnnotation,
3
35
  MediaGenerationResult,
4
36
  SessionEvent,
5
37
  SessionStatus,
6
38
  } from "@opengeni/sdk";
39
+ import { dequal } from "dequal/lite";
7
40
  import {
8
41
  ArrowDownIcon,
9
42
  ArrowRightIcon,
43
+ ArrowUpIcon,
10
44
  BotIcon,
11
45
  CheckCircle2Icon,
12
46
  CheckIcon,
47
+ ChevronDownIcon,
13
48
  ChevronRightIcon,
49
+ CornerDownRightIcon,
14
50
  PauseCircleIcon,
15
51
  PauseIcon,
16
52
  MessageCircleQuestionIcon,
@@ -23,16 +59,19 @@ import {
23
59
  TriangleAlertIcon,
24
60
  XCircleIcon,
25
61
  } from "lucide-react";
26
- import type { ComponentType } from "react";
27
- import { AnimatePresence, motion } from "motion/react";
28
- import { Collapsible } from "radix-ui";
62
+ import type { ComponentType, CSSProperties } from "react";
63
+ import { AnimatePresence, motion, useReducedMotion } from "motion/react";
29
64
  import {
30
65
  Component,
66
+ Fragment,
31
67
  Suspense,
68
+ createContext,
32
69
  lazy,
33
70
  memo,
34
71
  useCallback,
72
+ useContext,
35
73
  useEffect,
74
+ useId,
36
75
  useLayoutEffect,
37
76
  useMemo,
38
77
  useRef,
@@ -40,6 +79,11 @@ import {
40
79
  type ReactNode,
41
80
  } from "react";
42
81
  import { cn } from "../lib/cn";
82
+ import {
83
+ MessageSenderContext,
84
+ MessageSenderSlot,
85
+ type RenderMessageSender,
86
+ } from "../timeline/message-sender";
43
87
  import { formatClockTime, formatRelativeTime, truncate } from "../lib/format";
44
88
  import { prefersReducedMotion } from "../lib/motion";
45
89
  import {
@@ -47,22 +91,39 @@ import {
47
91
  type OlderHistoryLoadReceipt,
48
92
  type OlderHistoryLoader,
49
93
  } from "../older-history";
50
- import { Markdown } from "./markdown";
94
+ import { Markdown, type MarkdownProps } from "./markdown";
95
+ import { linkifyText } from "./linkified-text";
96
+ import { OpenGeniLinkProvider, type OpenGeniLinkResolver } from "./open-geni-links";
97
+ import {
98
+ TimelineBeforeLayout,
99
+ captureTimelineAnchor,
100
+ timelineAnchorCorrection,
101
+ timelineHasReader,
102
+ type TimelineAnchor,
103
+ } from "./timeline-anchor";
104
+ import { useReadingProgress } from "./timeline-reading-progress";
105
+ import {
106
+ animateTimelineSettlement,
107
+ captureTimelineSettlement,
108
+ type TimelineSettlement,
109
+ } from "./timeline-settlement";
51
110
  import {
52
111
  UserMessageBody,
53
112
  UserMessageDisclosureProvider,
54
113
  type UserMessageDisclosureContextValue,
114
+ type UserMessageDisclosureLabels,
55
115
  } from "./user-message-body";
56
116
  import {
57
117
  createTipFollowState,
58
118
  readerScrollUpPx,
59
119
  tipFollowCancel,
60
- tipFollowCompensateShrink,
61
120
  tipFollowCompensateViewportShrink,
121
+ tipFollowObserveContentShrink,
62
122
  tipFollowStep,
63
123
  supportsScrollEndEvent,
64
124
  TIP_FOLLOW_READER_UP_EPS_PX,
65
125
  TIP_FOLLOW_SHRINK_EPS_PX,
126
+ type TipFollowContentShrinkBaseline,
66
127
  type TipFollowState,
67
128
  } from "./tip-follow";
68
129
  import {
@@ -87,6 +148,7 @@ import {
87
148
  type VideoArtifactPlaybackLoader,
88
149
  type ToolRegistry,
89
150
  type TurnSummaryOptions,
151
+ type TurnSummaryStatus,
90
152
  type UserMessageItem,
91
153
  type FoldRestingState,
92
154
  type WorkerCompletionItem,
@@ -97,42 +159,104 @@ import {
97
159
  useTurnSettleOpen,
98
160
  } from "../timeline";
99
161
  import { CopyHoverFrame } from "./copy-button";
162
+ import { SecretAnswerChip } from "./secret-ref-chip";
163
+ import { secretAnswerLabel } from "../human-input-model";
164
+ import { TimelineAnnotationSourceRootContext } from "./timeline-annotation-reveal-context";
100
165
  import { GeneratedVideoPlayer } from "./generated-video-player";
101
166
  import {
102
167
  MACHINE_INPUT_META,
168
+ agentMemberSessionId,
169
+ agentMemberText,
170
+ agentMemberTitle,
103
171
  cleanMachineInputSummary,
172
+ isAgentMember,
104
173
  machineInputBatchLabel,
105
174
  machineInputSummaryIsUseful,
106
175
  readableMachineInputSource,
107
176
  } from "./machine-input-display";
177
+ import { AgentRow, AgentRowSection, AgentRowText } from "../timeline/agent-row";
178
+ import {
179
+ AgentIdentityProvider,
180
+ useAgentIdentity,
181
+ type ResolveSessionTitle,
182
+ } from "../timeline/agent-identity";
183
+ import { namedAgentTitle, type AgentTitleParts } from "../timeline/platform-activity-presentation";
108
184
  import { SESSION_STATUS_META, StatusDot } from "./session-status";
185
+ import {
186
+ noticeDisplayText,
187
+ noticeIsResolvedApproval,
188
+ noticeTone,
189
+ } from "../timeline/notice-presentation";
109
190
  import { TimelineComputeLabelProvider } from "../timeline/compute-label";
191
+ import { ProviderLogoProvider } from "../timeline/provider-logo";
110
192
  import { EntranceAnimationProvider, useEntranceAnimation } from "../timeline/entrance";
193
+ import {
194
+ AllowanceExhaustedRow,
195
+ AllowancePresentationProvider,
196
+ type RenderAllowanceExhausted,
197
+ } from "../timeline/allowance-exhausted-row";
198
+ import { SessionUsageLimitRow } from "../timeline/session-usage-limit-row";
199
+ import type { AllowanceLabels } from "../usage/allowance-copy";
111
200
  import { SeenActivityIdsProvider } from "../timeline/seen-activity-ids";
112
- import { TimelineAnnotationsChip } from "./timeline-annotations";
201
+ import { TimelineAnnotationCards } from "./timeline-annotations";
113
202
  import { TooltipProvider } from "./tooltip";
114
203
 
115
204
  const TimelineAnnotationSelection = lazy(() => import("./timeline-annotation-selection"));
205
+ const TimelineAnnotationMarkers = lazy(() => import("./timeline-annotation-markers"));
116
206
 
117
207
  export type MessageTimelineProps = {
208
+ /** Exact durable search hit; clearing it removes highlighting without moving the reader. */
209
+ searchTarget?: TimelineSearchTarget | null | undefined;
210
+ /**
211
+ * A durable event sequence to open on once (an inbox item's or notification's
212
+ * moment): the timeline lands on the row that holds it, or the nearest row
213
+ * before it, instead of the latest message. Load the window around it first.
214
+ */
215
+ focusSequence?: number | null | undefined;
216
+ /** Localized user-message disclosure actions, including custom UserMessageBody renderers. */
217
+ userMessageDisclosureLabels?: UserMessageDisclosureLabels | undefined;
118
218
  /** Raw session events (projected internally) … */
119
219
  events?: SessionEvent[] | undefined;
120
220
  /** … or pre-projected items (e.g. from `useSessionEvents().timeline`). */
121
221
  items?: TimelineItem[] | undefined;
122
222
  /** Current session status (reserved; tip "Working…" chrome removed for now). */
123
223
  status?: SessionStatus | null | undefined;
224
+ /** Host-owned controls beside Copy and the timestamp on settled message rows. */
225
+ renderMessageActions?: ((item: AgentMessageItem | UserMessageItem) => ReactNode) | undefined;
226
+ /**
227
+ * Who sent a person's message, drawn above its bubble. Called only for
228
+ * messages that carry a sender; return null for the viewer's own messages
229
+ * or when the host has no identity to show. Omit to show no senders.
230
+ */
231
+ renderMessageSender?: RenderMessageSender | undefined;
124
232
  /** Plug a markdown renderer for message bodies (e.g. streamdown). */
125
233
  renderMessageText?:
126
- | ((text: string, item: AgentMessageItem | UserMessageItem) => ReactNode)
234
+ | ((
235
+ text: string,
236
+ item: AgentMessageItem | UserMessageItem,
237
+ context: { searchTarget: TimelineSearchTarget | null },
238
+ ) => ReactNode)
127
239
  | undefined;
240
+ /**
241
+ * Render assistant `opengeni-html` / `opengeni-site` fences in the default
242
+ * message renderer (`SessionConversation` supplies the inline Site preview).
243
+ */
244
+ renderInteractiveBlock?: MarkdownProps["renderInteractiveBlock"];
128
245
  /** Drill into a spawned worker session. */
129
246
  onOpenSession?: ((sessionId: string) => void) | undefined;
247
+ /**
248
+ * Name the agents this session spawns, messages, and hears from. Return the
249
+ * current display title for a session id, or null when unknown; the
250
+ * timeline falls back to the title given at spawn, then to generic copy.
251
+ * Keep the function identity stable across renders.
252
+ */
253
+ resolveSessionTitle?: ResolveSessionTitle | undefined;
130
254
  /**
131
255
  * Deep-link a memory row (a `memory.saved` / `memory.corrected` step) to its
132
256
  * record in the host's memory pane. Opt-in, exactly like `onReconnect`: the
133
257
  * library draws no "View in memory" affordance without a handler — the memory
134
258
  * row is then non-interactive rich content. This is the switch that makes the
135
- * deep-link a first-party OpenGeni capability without other SDK consumers
259
+ * deep-link a first-party Opengeni capability without other SDK consumers
136
260
  * opting into it. The app supplies it (it owns routing to the memory pane).
137
261
  */
138
262
  onMemoryClick?: ((memoryId: string) => void) | undefined;
@@ -144,6 +268,17 @@ export type MessageTimelineProps = {
144
268
  * a Reconnect button without a handler to run it.
145
269
  */
146
270
  onReconnect?: ((item: AuthNeededItem) => void | Promise<void>) | undefined;
271
+ /** Host-owned inline connection setup. Return undefined to use the default recovery card. */
272
+ renderAuthNeeded?: ((item: AuthNeededItem) => ReactNode | undefined) | undefined;
273
+ /**
274
+ * Replace the "usage limit reached" row shown when a workspace or member
275
+ * usage allowance stops work (an `allowance_exhausted` refusal). Receives the
276
+ * typed refusal (scope, reset time) and the default row; return undefined to
277
+ * keep the default. Use it to point people at your own plan or admin page.
278
+ */
279
+ renderAllowanceExhausted?: RenderAllowanceExhausted | undefined;
280
+ /** Replace the words of the default "usage limit reached" row. */
281
+ allowanceExhaustedLabels?: Partial<AllowanceLabels> | undefined;
147
282
  /**
148
283
  * Decide which durable authentication notices this timeline presents.
149
284
  * Defaults to showing every notice. Embedded hosts can suppress notices for
@@ -164,6 +299,16 @@ export type MessageTimelineProps = {
164
299
  * `createDefaultToolRegistry({ entries })` to add custom tool renderers.
165
300
  */
166
301
  toolRegistry?: ToolRegistry | undefined;
302
+ /**
303
+ * Open Opengeni object links the agent writes in replies and progress notes:
304
+ * `artifact:<file>`, `sandbox:<path>`, editable artifacts, and Sites. Return
305
+ * a host URL (`{ href }`) or action (`{ open }`); unhandled targets render as
306
+ * unavailable text instead of a console link that 404s inside the host.
307
+ * Applies to the default message renderer and to any `Markdown` a custom
308
+ * `renderMessageText` renders. `SessionConversation` supplies file and
309
+ * sandbox downloads by default.
310
+ */
311
+ resolveLink?: OpenGeniLinkResolver | undefined;
167
312
  /** Resolve opaque retained screenshot receipts through the authenticated host SDK. */
168
313
  loadRetainedScreenshot?: RetainedScreenshotLoader | undefined;
169
314
  /** Resolve permanent workspace image/file receipts through the authenticated host SDK. */
@@ -176,11 +321,22 @@ export type MessageTimelineProps = {
176
321
  */
177
322
  computeLabel?: string | null | undefined;
178
323
  /** Customize collapsed turn facets for this timeline instance. */
324
+ genieLoading?: GenieLoadingOptions | undefined;
179
325
  turnSummary?: TurnSummaryOptions | undefined;
180
326
  /** Follow new events when pinned to the bottom. Defaults to true. */
181
327
  autoFollow?: boolean | undefined;
182
328
  /** Capture a same-row text selection into the host's canonical composer draft. */
183
329
  onAnnotate?: ((annotation: DraftTimelineAnnotation) => void) | undefined;
330
+ /**
331
+ * Hide the floating "Back to your message" pill while the timeline viewport
332
+ * is shorter than this (px), e.g. a narrow embed above a decision card.
333
+ * Defaults to 0 (always available).
334
+ */
335
+ questionNavMinViewportHeight?: number | undefined;
336
+ /** Composer draft quotes currently attached to the next send. */
337
+ draftAnnotations?: readonly DraftTimelineAnnotation[] | undefined;
338
+ /** Open the composer review list for one numbered draft badge. */
339
+ onDraftAnnotationSelect?: ((id: string) => void) | undefined;
184
340
  /** Older durable history exists above the current window (see useSessionEvents). */
185
341
  hasOlder?: boolean | undefined;
186
342
  /** An older window is being fetched; shows the quiet top shimmer. */
@@ -199,13 +355,16 @@ export type MessageTimelineProps = {
199
355
  hasNewer?: boolean | undefined;
200
356
  /** A newer history page is being fetched. */
201
357
  loadingNewer?: boolean | undefined;
202
- /** Page forward through history without loading the whole gap to the tip. */
203
- onLoadNewer?: (() => void) | undefined;
358
+ /** Page forward through history. Return the request promise to enable inline error/retry. */
359
+ onLoadNewer?: (() => unknown) | undefined;
204
360
  /**
205
361
  * Reload the live tip window. When omitted, Jump to latest only re-pins and
206
362
  * scrolls the in-memory window.
207
363
  */
208
364
  onJumpToLatest?: (() => void | Promise<void>) | undefined;
365
+ /** @deprecated Retained for source compatibility; contextual prompt navigation
366
+ * uses only mounted prompts and never invokes this global resolver. */
367
+ onJumpToLatestQuestion?: (() => Promise<number | null>) | undefined;
209
368
  /** Host-owned content appended after timeline groups, such as startup progress. */
210
369
  trailingState?: ReactNode | undefined;
211
370
  emptyState?: ReactNode | undefined;
@@ -244,6 +403,24 @@ const PIN_THRESHOLD_PX = 48;
244
403
  * line-sized streaming movement.
245
404
  */
246
405
  const JUMP_TO_LATEST_CATCHUP_DEBT_PX = 240;
406
+ /**
407
+ * Floating timeline navigation: two identical small round arrow buttons at fixed
408
+ * spots, centered on the conversation. Back to your message floats 12px below
409
+ * the top edge, or just below an expanded work-header strip while that strip is
410
+ * pinned (32px, 44px on coarse pointers) so the strip stays a full-width
411
+ * target. Jump to latest floats 12px above the bottom edge. They never dodge
412
+ * the content beneath them (floating over a sliver of text is fine), so they
413
+ * cannot wander as rows stream in or the host resizes. Coarse pointers get a
414
+ * larger invisible hit area instead of a larger button.
415
+ */
416
+ const NAV_BUTTON_CLASS =
417
+ "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-['']";
418
+ const NAV_FADE = {
419
+ initial: { opacity: 0 },
420
+ animate: { opacity: 1 },
421
+ exit: { opacity: 0 },
422
+ transition: { duration: 0.15, ease: "easeOut" },
423
+ } as const;
247
424
  /**
248
425
  * Prefetch older history when the top sentinel is this far from the viewport.
249
426
  * After a page loads we stay cool until the reader leaves this band (scrolls
@@ -252,13 +429,21 @@ const JUMP_TO_LATEST_CATCHUP_DEBT_PX = 240;
252
429
  const OLDER_PREFETCH_MARGIN_PX = 400;
253
430
  const OLDER_PREFETCH_ROOT_MARGIN = `${OLDER_PREFETCH_MARGIN_PX}px 0px 0px 0px`;
254
431
  const PRIMARY_ACTION_CLASS =
255
- "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";
432
+ "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";
256
433
  const MESSAGE_BUBBLE_CLASS =
257
434
  "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";
258
435
  const WAITING_PILL_CLASS =
259
436
  "border-og-status-waiting/35 bg-og-status-waiting/10 text-og-status-waiting";
260
437
  const LOADING_CHIP_CLASS =
261
438
  "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";
439
+ /** How long live progress notes take to fold into (or out of) their work row. */
440
+ const LIVE_NOTE_FOLD_MS = 220;
441
+ /** The timeline list's `gap-5`; a folded note gives it back with `-mt-5`. */
442
+ const LIVE_NOTE_GAP_PX = 20;
443
+ /** Longest a bottom-space loan waits for an opening work list to grow into it. */
444
+ const LIVE_NOTE_RESERVE_MS = 700;
445
+ /** Lets a live work disclosure report whether it is open (see `reportLiveFold`). */
446
+ const LiveNoteFoldContext = createContext<((workId: string, open: boolean) => void) | null>(null);
262
447
 
263
448
  // State: 0 underfill retry, 1 prefetch pending, 2 compatibility-settled,
264
449
  // 3 explicit no-progress waiting to become an underfill retry.
@@ -276,13 +461,18 @@ function invokeOlderLoad(
276
461
  load: OlderHistoryLoader,
277
462
  noProgress: () => void,
278
463
  attempt: OlderLoadAttempt,
464
+ preserveTail = false,
279
465
  ): 1 | undefined {
280
466
  try {
281
467
  // Receipt creation is captured synchronously through legacy wrappers such
282
468
  // as `() => void loadOlder()`, even when the wrapper discards the return.
283
- const result = invokeOlderHistoryLoaderWithReceiptCapture(load, (receipt) => {
284
- attempt[2] = receipt;
285
- }) as OlderHistoryLoadReceipt | PromiseLike<unknown> | unknown;
469
+ const result = invokeOlderHistoryLoaderWithReceiptCapture(
470
+ load,
471
+ (receipt) => {
472
+ attempt[2] = receipt;
473
+ },
474
+ preserveTail,
475
+ ) as OlderHistoryLoadReceipt | PromiseLike<unknown> | unknown;
286
476
  const receipt =
287
477
  attempt[2] ??
288
478
  (typeof (result as { committed?: unknown } | undefined)?.committed === "boolean"
@@ -362,6 +552,49 @@ function isNearBottom(node: HTMLElement): boolean {
362
552
  return gap < Math.min(PIN_THRESHOLD_PX, maxScroll);
363
553
  }
364
554
 
555
+ /** Content-space top of a timeline group, or null when it is not mounted. */
556
+ function contentTopOf(node: HTMLElement, groupKey: string): number | null {
557
+ const element = node.querySelector(`[data-og-group-key="${cssEscapeAttribute(groupKey)}"]`);
558
+ if (!(element instanceof HTMLElement)) {
559
+ return null;
560
+ }
561
+ return element.getBoundingClientRect().top - node.getBoundingClientRect().top + node.scrollTop;
562
+ }
563
+
564
+ /** The question the reader is inside of, once its start has scrolled out of view. */
565
+ /**
566
+ * `belowHeader`: an expanded work header is pinned to the top of the scrollport,
567
+ * so the up arrow sits just below that strip instead of covering it.
568
+ */
569
+ type QuestionNav = { key: string; belowHeader: boolean };
570
+
571
+ function readQuestionNav(node: HTMLElement): QuestionNav | null {
572
+ const view = node.getBoundingClientRect();
573
+ const prompts = [...node.querySelectorAll<HTMLElement>("[data-og-prompt]")].flatMap((prompt) => {
574
+ const key = prompt.dataset.ogGroupKey;
575
+ if (!key) return [];
576
+ const bounds = prompt.getBoundingClientRect();
577
+ return [{ key, top: bounds.top, bottom: bounds.bottom }];
578
+ });
579
+ const key = questionNavTarget(prompts, { top: view.top, height: view.height });
580
+ return key ? { key, belowHeader: workHeaderPinned(node, view) } : null;
581
+ }
582
+
583
+ /** True while an expanded outer work header is stuck to the scrollport's top edge. */
584
+ function workHeaderPinned(node: HTMLElement, view: DOMRect): boolean {
585
+ for (const header of node.querySelectorAll<HTMLElement>(
586
+ '[data-og-work-header="outer"][data-state="open"]',
587
+ )) {
588
+ const box = header.getBoundingClientRect();
589
+ if (Math.abs(box.top - view.top) <= 1 && box.bottom > view.top) return true;
590
+ }
591
+ return false;
592
+ }
593
+
594
+ function sameQuestionNav(a: QuestionNav | null, b: QuestionNav | null): boolean {
595
+ return a === b || (!!a && !!b && a.key === b.key && a.belowHeader === b.belowHeader);
596
+ }
597
+
365
598
  /** Escape a value for use inside a CSS attribute selector. */
366
599
  function cssEscapeAttribute(value: string): string {
367
600
  if (typeof CSS !== "undefined" && typeof CSS.escape === "function") {
@@ -377,13 +610,24 @@ function cssEscapeAttribute(value: string): string {
377
610
  * with a "jump to latest" affordance when the reader scrolls back.
378
611
  */
379
612
  export function MessageTimeline({
613
+ resolveLink,
614
+ renderInteractiveBlock,
615
+ userMessageDisclosureLabels,
616
+ searchTarget,
617
+ focusSequence,
380
618
  events,
381
619
  items,
382
620
  status: _status,
621
+ renderMessageActions,
622
+ renderMessageSender,
383
623
  renderMessageText,
384
624
  onOpenSession,
625
+ resolveSessionTitle,
385
626
  onMemoryClick,
386
627
  onReconnect,
628
+ renderAuthNeeded,
629
+ renderAllowanceExhausted,
630
+ allowanceExhaustedLabels,
387
631
  shouldRenderAuthNeeded,
388
632
  resolveProviderLogo,
389
633
  toolRegistry = defaultToolRegistry,
@@ -391,9 +635,13 @@ export function MessageTimeline({
391
635
  loadRetainedArtifact,
392
636
  loadVideoArtifactPlayback,
393
637
  computeLabel = null,
638
+ genieLoading,
394
639
  turnSummary,
395
640
  autoFollow = true,
396
641
  onAnnotate,
642
+ questionNavMinViewportHeight = 0,
643
+ draftAnnotations,
644
+ onDraftAnnotationSelect,
397
645
  hasOlder = false,
398
646
  loadingOlder = false,
399
647
  onLoadOlder,
@@ -407,6 +655,7 @@ export function MessageTimeline({
407
655
  emptyState,
408
656
  className,
409
657
  }: MessageTimelineProps) {
658
+ const formatError = useErrorMessage();
410
659
  const resolvedItems = useMemo(() => {
411
660
  const projectedItems = items ?? buildTimeline(events ?? []);
412
661
  if (!shouldRenderAuthNeeded) {
@@ -416,9 +665,134 @@ export function MessageTimeline({
416
665
  (item) => item.kind !== "auth-needed" || shouldRenderAuthNeeded(item),
417
666
  );
418
667
  }, [items, events, shouldRenderAuthNeeded]);
419
- const sourceItems = items || events;
668
+ const searchItem = searchTarget
669
+ ? resolvedItems.find((item) => isTimelineSearchTarget(item, searchTarget))
670
+ : undefined;
671
+ const searchRevealKey =
672
+ searchItem && searchTarget
673
+ ? JSON.stringify([
674
+ searchItem.id,
675
+ searchTarget.query,
676
+ searchTarget.occurrence ?? 0,
677
+ searchTarget.offset ?? null,
678
+ ])
679
+ : null;
680
+ const searchTargetRef = useRef(searchTarget);
681
+ searchTargetRef.current = searchTarget;
682
+ const renderSearchableMessageText = useMemo(
683
+ () =>
684
+ renderMessageText
685
+ ? (text: string, item: AgentMessageItem | UserMessageItem) =>
686
+ renderMessageText(text, item, {
687
+ searchTarget: searchItem?.id === item.id ? (searchTarget ?? null) : null,
688
+ })
689
+ : (text: string, item: AgentMessageItem | UserMessageItem) => {
690
+ const body = (
691
+ <Markdown
692
+ searchTarget={searchItem?.id === item.id ? searchTarget : null}
693
+ softLineBreaks
694
+ streaming={item.kind === "agent-message" && item.streaming}
695
+ renderInteractiveBlock={
696
+ item.kind === "agent-message" ? renderInteractiveBlock : undefined
697
+ }
698
+ >
699
+ {text}
700
+ </Markdown>
701
+ );
702
+ return item.kind === "user-message" ? (
703
+ <UserMessageBody messageId={item.id} text={text}>
704
+ {body}
705
+ </UserMessageBody>
706
+ ) : (
707
+ body
708
+ );
709
+ },
710
+ [renderInteractiveBlock, renderMessageText, searchItem?.id, searchTarget],
711
+ );
712
+ // Event-window identity is independent of projected rows (partial messages
713
+ // can acquire a different first-delta id when older text arrives).
714
+ const sourceItems = events ?? items;
420
715
  const olderBoundaryKey = sourceItems?.[0]?.id;
421
- const allGroups = useMemo(() => groupTimeline(resolvedItems), [resolvedItems]);
716
+ const newerBoundaryKey = `${events?.[0]?.sessionId ?? ""}:${olderBoundaryKey ?? ""}`;
717
+ const newerScopeRef = useRef(newerBoundaryKey);
718
+ newerScopeRef.current = newerBoundaryKey;
719
+ const newerAttemptRef = useRef<{ pending: boolean } | null>(null);
720
+ const newerRetryButtonRef = useRef<HTMLButtonElement | null>(null);
721
+ const [newerRetryPending, setNewerRetryPending] = useState(false);
722
+ const [newerFailure, setNewerFailure] = useState<{ key: string; message: string } | null>(null);
723
+ useEffect(() => {
724
+ newerScopeRef.current = newerBoundaryKey;
725
+ newerAttemptRef.current = null;
726
+ setNewerFailure(null);
727
+ setNewerRetryPending(false);
728
+ return () => {
729
+ newerScopeRef.current = "";
730
+ newerAttemptRef.current = null;
731
+ };
732
+ }, [newerBoundaryKey]);
733
+ const requestNewer = useCallback(
734
+ (explicitRetry = false) => {
735
+ if (
736
+ newerScopeRef.current !== newerBoundaryKey ||
737
+ !onLoadNewer ||
738
+ loadingNewer ||
739
+ (newerAttemptRef.current && (!explicitRetry || newerAttemptRef.current.pending))
740
+ ) {
741
+ return;
742
+ }
743
+ const attempt = { pending: true };
744
+ newerAttemptRef.current = attempt;
745
+ if (explicitRetry) setNewerRetryPending(true);
746
+ const isCurrent = () =>
747
+ newerAttemptRef.current === attempt && newerScopeRef.current === newerBoundaryKey;
748
+ // Both synchronous host errors and rejected promises belong to this
749
+ // boundary. Retain the failed attempt so observers cannot hot-retry it.
750
+ void Promise.resolve()
751
+ .then(() => (isCurrent() ? onLoadNewer() : undefined))
752
+ .then(
753
+ () => {
754
+ if (!isCurrent()) return;
755
+ newerAttemptRef.current = null;
756
+ // The successful page removes the recovery control. Return focus
757
+ // to the reading surface without moving the reader's viewport.
758
+ if (document.activeElement === newerRetryButtonRef.current) {
759
+ scrollRef.current?.focus({ preventScroll: true });
760
+ }
761
+ setNewerFailure(null);
762
+ setNewerRetryPending(false);
763
+ },
764
+ (reason: unknown) => {
765
+ if (!isCurrent()) return;
766
+ attempt.pending = false;
767
+ setNewerRetryPending(false);
768
+ setNewerFailure({
769
+ key: newerBoundaryKey,
770
+ message: formatError(reason),
771
+ });
772
+ },
773
+ );
774
+ },
775
+ [onLoadNewer, loadingNewer, newerBoundaryKey, formatError],
776
+ );
777
+ const previousSourceIdsRef = useRef(new Set<string>());
778
+ const previousSourceBoundaryRef = useRef<string | undefined>(undefined);
779
+ const readingAnchorRef = useRef<TimelineAnchor | null>(null);
780
+ const settlementRef = useRef<TimelineSettlement | null>(null);
781
+ const settlementAnimationRef = useRef<Animation | null>(null);
782
+ const stopSettlement = useCallback(() => {
783
+ settlementAnimationRef.current?.cancel();
784
+ settlementAnimationRef.current = null;
785
+ }, []);
786
+ const olderPageBudgetRef = useRef(0);
787
+ const [olderDemand, setOlderDemand] = useState(0);
788
+ const readableTurns = turnSummary?.rolling === true;
789
+ const projectedGroups = useMemo(
790
+ () => groupTimeline(resolvedItems, { readableTurns }),
791
+ [resolvedItems, readableTurns],
792
+ );
793
+ // A new timeline revision (including session/turn replacement) owns layout.
794
+ // Never leave an old decorative transform running on its retained rows.
795
+ useLayoutEffect(() => stopSettlement, [projectedGroups, stopSettlement]);
422
796
  const annotationSources = useMemo(() => {
423
797
  const sources = new Map<string, TimelineAnnotationSourceDescriptor>();
424
798
  for (const item of resolvedItems) {
@@ -433,12 +807,16 @@ export function MessageTimeline({
433
807
  }
434
808
  return sources;
435
809
  }, [resolvedItems]);
436
- const groups = useStableTimelineGroupKeys(allGroups);
437
810
  const scrollRef = useRef<HTMLDivElement | null>(null);
438
811
  const topSentinelRef = useRef<HTMLDivElement | null>(null);
439
812
  const bottomSentinelRef = useRef<HTMLDivElement | null>(null);
440
813
  const previousBulkFirstKeyRef = useRef<string | null | undefined>(undefined);
441
814
  const [pinned, setPinned] = useState(true);
815
+ const { groups: allGroups, release: releaseProgress } = useReadingProgress(
816
+ projectedGroups,
817
+ autoFollow && pinned && !hasNewer,
818
+ scrollRef.current,
819
+ );
442
820
  const [canSkipTipCatchup, setCanSkipTipCatchup] = useState(false);
443
821
  const canSkipTipCatchupRef = useRef(false);
444
822
  const [bulkActive, setBulkActive] = useState(true);
@@ -460,6 +838,17 @@ export function MessageTimeline({
460
838
  );
461
839
  const underfillRetryReadyRef = useRef(false);
462
840
  const resizeFollowRafRef = useRef<number | null>(null);
841
+ const questionNavFrameRef = useRef<number | null>(null);
842
+ const [questionNav, setQuestionNav] = useState<QuestionNav | null>(null);
843
+ const questionNavMinViewportRef = useRef(questionNavMinViewportHeight);
844
+ questionNavMinViewportRef.current = questionNavMinViewportHeight;
845
+ const scheduleQuestionNavRef = useRef<() => void>(() => {});
846
+ // Unpinned is not the same as away from the tip: focusing a control in the
847
+ // conversation (Copy, a connection card) hands the view to the reader while
848
+ // they are still at the bottom. Offer the jump only once there is something
849
+ // below them, or a newer window / catch-up to skip.
850
+ const [awayFromTip, setAwayFromTip] = useState(false);
851
+ const jumpPillShown = autoFollow && ((!pinned && awayFromTip) || hasNewer || canSkipTipCatchup);
463
852
  const firstGroupKey = allGroups[0] ? timelineGroupKey(allGroups[0]) : null;
464
853
  // Content stays invisible until the tip is hard-parked across a short
465
854
  // post-commit settle (two rAFs). That absorbs sync late layout while hidden
@@ -506,6 +895,8 @@ export function MessageTimeline({
506
895
  * jumps (Vimium) settle via scrollend while the camera is idle.
507
896
  */
508
897
  const readerIntentArmRef = useRef(false);
898
+ /** Gesture-start geometry survives commits before the native scroll event. */
899
+ const readerIntentStartRef = useRef<{ scrollTop: number; maxScroll: number } | null>(null);
509
900
  /**
510
901
  * Count of camera/snap scrollTop writes whose scroll echoes are not yet
511
902
  * consumed. A boolean was wrong when the browser coalesced two writes into
@@ -530,10 +921,195 @@ export function MessageTimeline({
530
921
  // deliberate chip remounts (activity→turn wrap, nested key flips) so a fold
531
922
  // that already settled closed — or that the reader closed — never reopens.
532
923
  const foldMemoryRef = useRef<Map<string, FoldRestingState>>(new Map());
924
+ // Live work disclosures that are open. While open, that turn's progress
925
+ // notes read inside the disclosure, so their copies above it fold away.
926
+ const [openLiveFolds, setOpenLiveFolds] = useState<ReadonlySet<string>>(() => new Set());
927
+ const openLiveFoldsRef = useRef<ReadonlySet<string>>(openLiveFolds);
928
+ const liveNoteIdsByWorkRef = useRef<ReadonlyMap<string, readonly string[]>>(new Map());
929
+ const liveFoldMotionRef = useRef<{ frame: number | null; finish: () => void } | null>(null);
930
+ // The bottom-space loan currently on the scroller, by owner.
931
+ const liveFoldReserveRef = useRef<object | null>(null);
932
+ // writeScrollTop is declared further down; the fold reaches it through this.
933
+ const writeScrollTopRef = useRef<(node: HTMLElement, top: number) => void>(() => {});
934
+ const reportLiveFold = useCallback((workId: string, open: boolean) => {
935
+ const current = openLiveFoldsRef.current;
936
+ if (current.has(workId) === open) return;
937
+ const next = new Set(current);
938
+ if (open) next.add(workId);
939
+ else next.delete(workId);
940
+ openLiveFoldsRef.current = next;
941
+ liveFoldMotionRef.current?.finish();
942
+ const scroller = scrollRef.current;
943
+ const rows = (liveNoteIdsByWorkRef.current.get(workId) ?? []).flatMap((id) => {
944
+ const row = scroller?.querySelector<HTMLElement>(
945
+ `[data-og-live-note-id="${cssEscapeAttribute(id)}"]`,
946
+ );
947
+ return row ? [row] : [];
948
+ });
949
+ if (!scroller || rows.length === 0) {
950
+ setOpenLiveFolds(next);
951
+ return;
952
+ }
953
+ const header = scroller.querySelector<HTMLElement>(
954
+ `[data-og-work-id="${cssEscapeAttribute(workId)}"] [data-og-work-header="outer"]`,
955
+ );
956
+ const viewTop = scroller.getBoundingClientRect().top;
957
+ const headerTop = header?.getBoundingClientRect().top;
958
+ if (
959
+ !open &&
960
+ header &&
961
+ headerTop !== undefined &&
962
+ (headerTop < viewTop || headerTop > viewTop + scroller.clientHeight)
963
+ ) {
964
+ // Closed from the row's sticky header after reading down the list: the
965
+ // row is no longer where the reader is. Like any big collapse, land on
966
+ // it in one step — the notes return above and the closed row rests at
967
+ // the bottom edge — rather than animating content the reader can't see.
968
+ setOpenLiveFolds(next);
969
+ requestFrame(() =>
970
+ requestFrame(() => {
971
+ const row = header.closest<HTMLElement>("[data-og-timeline-group-anchor]");
972
+ if (!row?.isConnected) return;
973
+ const box = row.getBoundingClientRect();
974
+ const top = scroller.getBoundingClientRect().top;
975
+ const bottom = top + scroller.clientHeight;
976
+ // + the scroller's pb-6, as at the true end.
977
+ if (box.bottom > bottom || box.top < top)
978
+ writeScrollTopRef.current(scroller, scroller.scrollTop + box.bottom - bottom + 24);
979
+ scheduleQuestionNavRef.current();
980
+ }),
981
+ );
982
+ return;
983
+ }
984
+ // Opening a live row near the bottom drops its one-line preview while its
985
+ // list grows from nothing; closing shrinks the list before the notes have
986
+ // grown back. For that moment the page is shorter than where the reader
987
+ // is, so the browser would clamp the scroll and everything would drop.
988
+ // This runs before that frame paints: keep the reader's position and lend
989
+ // one screen of space at the bottom (the shortfall keeps changing through
990
+ // the commit) until the content has grown back into it.
991
+ const readerTop = lastScrollTopRef.current;
992
+ const reserve = readerTop > 0.5 ? scroller.clientHeight : 0;
993
+ const loan = {};
994
+ if (reserve > 0) {
995
+ liveFoldReserveRef.current = loan;
996
+ scroller.style.paddingBottom = `calc(1.5rem + ${reserve}px)`;
997
+ if (Math.abs(scroller.scrollTop - readerTop) > 0.5)
998
+ writeScrollTopRef.current(scroller, readerTop);
999
+ }
1000
+ const releaseReserve = (deadline: number) => {
1001
+ // Only this motion's loan; a newer toggle may have replaced it.
1002
+ if (reserve === 0 || liveFoldReserveRef.current !== loan) return;
1003
+ const needed = scroller.scrollTop + scroller.clientHeight - (scroller.scrollHeight - reserve);
1004
+ if (needed > 0.5 && performance.now() < deadline && scroller.isConnected) {
1005
+ requestFrame(() => releaseReserve(deadline));
1006
+ return;
1007
+ }
1008
+ liveFoldReserveRef.current = null;
1009
+ scroller.style.removeProperty("padding-bottom");
1010
+ };
1011
+ // The notes fold into (or out of) the work row in one short motion. The
1012
+ // row glides to where it should rest: opening, into the first note's place
1013
+ // (or the top edge, when that note was above view) so its list starts just
1014
+ // beneath; closing, down below its returning notes but never past the
1015
+ // bottom edge. When everything is in view that is exactly what happens on
1016
+ // its own; otherwise the scroll is written in the same frame as the
1017
+ // heights, so it cannot lag. Native scroll anchoring is suspended for the
1018
+ // motion; inline styles pin the current look across the state commit, end
1019
+ // exactly at the classes' resting geometry, and are then removed.
1020
+ const workRow = header?.closest<HTMLElement>("[data-og-timeline-group-anchor]") ?? null;
1021
+ const rowStart = workRow?.getBoundingClientRect().top;
1022
+ const viewBottom = viewTop + scroller.clientHeight;
1023
+ let landing =
1024
+ open && rowStart !== undefined
1025
+ ? Math.max(rows[0]!.getBoundingClientRect().top, viewTop)
1026
+ : null;
1027
+ const heights = rows.map((row) => (open ? row.offsetHeight : 0));
1028
+ const apply = (progress: number) => {
1029
+ const shown = open ? 1 - progress : progress;
1030
+ rows.forEach((note, index) => {
1031
+ // The list gap travels inside the row as padding (margin cancels it),
1032
+ // so a shrinking row never overlaps the one above it mid-motion.
1033
+ note.style.marginTop = `${-LIVE_NOTE_GAP_PX}px`;
1034
+ note.style.paddingTop = `${LIVE_NOTE_GAP_PX * shown}px`;
1035
+ note.style.height = `${(heights[index]! + LIVE_NOTE_GAP_PX) * shown}px`;
1036
+ note.style.opacity = String(shown);
1037
+ });
1038
+ if (workRow && rowStart !== undefined && landing !== null) {
1039
+ const drift =
1040
+ workRow.getBoundingClientRect().top - (rowStart + (landing - rowStart) * progress);
1041
+ if (Math.abs(drift) > 0.5) writeScrollTopRef.current(scroller, scroller.scrollTop + drift);
1042
+ }
1043
+ };
1044
+ for (const row of rows) {
1045
+ row.style.boxSizing = "border-box";
1046
+ row.style.overflow = "hidden";
1047
+ row.style.overflowAnchor = "none";
1048
+ }
1049
+ scroller.style.overflowAnchor = "none";
1050
+ apply(0);
1051
+ const fold: { frame: number | null; finish: () => void } = {
1052
+ frame: null,
1053
+ finish: () => {
1054
+ if (fold.frame != null) cancelFrame(fold.frame);
1055
+ for (const row of rows) {
1056
+ for (const property of [
1057
+ "height",
1058
+ "margin-top",
1059
+ "padding-top",
1060
+ "opacity",
1061
+ "overflow",
1062
+ "overflow-anchor",
1063
+ "box-sizing",
1064
+ ])
1065
+ row.style.removeProperty(property);
1066
+ }
1067
+ scroller.style.removeProperty("overflow-anchor");
1068
+ releaseReserve(performance.now() + LIVE_NOTE_RESERVE_MS);
1069
+ // The row may now be pinned to the top without any scroll: re-place
1070
+ // the up arrow (it sits below a pinned work header).
1071
+ scheduleQuestionNavRef.current();
1072
+ if (liveFoldMotionRef.current === fold) liveFoldMotionRef.current = null;
1073
+ },
1074
+ };
1075
+ liveFoldMotionRef.current = fold;
1076
+ const duration = prefersReducedMotion() ? 0 : LIVE_NOTE_FOLD_MS;
1077
+ let began: number | null = null;
1078
+ const step = (now: number) => {
1079
+ fold.frame = null;
1080
+ if (!rows.every((row) => row.isConnected)) return fold.finish();
1081
+ if (!open && began === null) {
1082
+ // Unfolding: the natural heights are known only once the folded
1083
+ // class is gone; the inline styles still hold them at zero.
1084
+ rows.forEach((note, index) => (heights[index] = note.scrollHeight));
1085
+ if (workRow && rowStart !== undefined) {
1086
+ const returning = heights.reduce((sum, height) => sum + height + LIVE_NOTE_GAP_PX, 0);
1087
+ // The closed row is its header plus one preview line (its list is
1088
+ // still collapsing now, so the row's own height is not final yet).
1089
+ const closedRow = 2 * (header?.getBoundingClientRect().height ?? 0);
1090
+ const lowest = viewBottom - closedRow - 24;
1091
+ landing = Math.min(rowStart + returning, Math.max(lowest, rowStart));
1092
+ }
1093
+ }
1094
+ began ??= now;
1095
+ const linear = duration === 0 ? 1 : Math.min(1, (now - began) / duration);
1096
+ apply(1 - (1 - linear) ** 3);
1097
+ if (linear < 1) fold.frame = requestFrame(step);
1098
+ else fold.finish();
1099
+ };
1100
+ fold.frame = requestFrame(step);
1101
+ setOpenLiveFolds(next);
1102
+ }, []);
1103
+ useEffect(
1104
+ () => () => {
1105
+ liveFoldMotionRef.current?.finish();
1106
+ },
1107
+ [],
1108
+ );
533
1109
  const userMessageDisclosureMemoryRef = useRef<Map<string, boolean>>(new Map());
534
1110
  const seenActivityIdsRef = useRef<Set<string>>(new Set());
535
1111
  const firstItemGroupKeyRef = useRef<string | null>(null);
536
- const groupOffsetByKeyRef = useRef<Map<string, number>>(new Map());
1112
+ const firstItemGroupOffsetTopRef = useRef<number | null>(null);
537
1113
  const firstItemContentTopRef = useRef<number | null>(null);
538
1114
  const firstItemId = resolvedItems[0]?.id ?? null;
539
1115
  // Pagination ownership follows the oldest committed input, before host
@@ -553,6 +1129,35 @@ export function MessageTimeline({
553
1129
  previousBulkFirstKeyRef.current !== undefined &&
554
1130
  previousBulkFirstKeyRef.current !== firstGroupKey;
555
1131
  const bulkRender = allGroups.length > 0 && (bulkActive || firstKeyChangedForBulk);
1132
+ const groups = useStableTimelineGroupKeys(allGroups, !bulkRender);
1133
+ // Each live progress note → whether its turn's work disclosure is open.
1134
+ const [liveNoteFolds, liveNoteIdsByWork] = useMemo(() => {
1135
+ const folds = new Map<string, boolean>();
1136
+ const byWork = new Map<string, readonly string[]>();
1137
+ for (const { group } of groups) {
1138
+ if (group.kind !== "activity" || !group.work?.liveNoteIds) continue;
1139
+ byWork.set(group.id, group.work.liveNoteIds);
1140
+ for (const id of group.work.liveNoteIds) folds.set(id, openLiveFolds.has(group.id));
1141
+ }
1142
+ return [folds, byWork] as const;
1143
+ }, [groups, openLiveFolds]);
1144
+ useLayoutEffect(() => {
1145
+ liveNoteIdsByWorkRef.current = liveNoteIdsByWork;
1146
+ }, [liveNoteIdsByWork]);
1147
+ const turnsWithOutput = useMemo(
1148
+ () =>
1149
+ new Set(
1150
+ resolvedItems.flatMap((item) =>
1151
+ "turnId" in item &&
1152
+ item.turnId &&
1153
+ (item.kind === "tool-call" ||
1154
+ ((item.kind === "agent-message" || item.kind === "reasoning") && item.text.trim()))
1155
+ ? [item.turnId]
1156
+ : [],
1157
+ ),
1158
+ ),
1159
+ [resolvedItems],
1160
+ );
556
1161
 
557
1162
  const applyCanSkipTipCatchup = useCallback((value: boolean) => {
558
1163
  if (canSkipTipCatchupRef.current !== value) {
@@ -592,6 +1197,7 @@ export function MessageTimeline({
592
1197
  // Pure tip-follow camera. Pin intent uses clamp conservation, not timers.
593
1198
  const followRef = useRef<TipFollowState>(createTipFollowState());
594
1199
  const followFrameRef = useRef<number | null>(null);
1200
+ const contentShrinkBaselineRef = useRef<TipFollowContentShrinkBaseline | null>(null);
595
1201
 
596
1202
  const syncScrollBaseline = useCallback((node: HTMLElement) => {
597
1203
  lastScrollTopRef.current = node.scrollTop;
@@ -606,6 +1212,9 @@ export function MessageTimeline({
606
1212
  if (before === next) {
607
1213
  return;
608
1214
  }
1215
+ // Unpinned camera/anchor writes invalidate return intent. A pinned
1216
+ // pointer gesture still needs its cumulative leave budget across writes.
1217
+ if (!pinnedRef.current || !readerIntentArmRef.current) readerIntentStartRef.current = null;
609
1218
  programmaticScrollRef.current += 1;
610
1219
  node.scrollTop = next;
611
1220
  if (node.scrollTop === before) {
@@ -615,6 +1224,7 @@ export function MessageTimeline({
615
1224
  programmaticScrollRef.current -= 1;
616
1225
  }
617
1226
  }, []);
1227
+ writeScrollTopRef.current = writeScrollTop;
618
1228
 
619
1229
  const cancelLeaveFallback = useCallback(() => {
620
1230
  if (leaveFallbackRafRef.current != null) {
@@ -628,7 +1238,13 @@ export function MessageTimeline({
628
1238
  cancelLeaveFallback();
629
1239
  }, [cancelLeaveFallback]);
630
1240
 
1241
+ const clearReaderIntent = useCallback(() => {
1242
+ readerIntentArmRef.current = false;
1243
+ readerIntentStartRef.current = null;
1244
+ }, []);
1245
+
631
1246
  const stopFollow = useCallback(() => {
1247
+ contentShrinkBaselineRef.current = null;
632
1248
  followRef.current = tipFollowCancel(followRef.current);
633
1249
  if (followFrameRef.current != null) {
634
1250
  cancelFrame(followFrameRef.current);
@@ -637,29 +1253,43 @@ export function MessageTimeline({
637
1253
  }, []);
638
1254
 
639
1255
  /** Reader left the tip — wheel, keyboard, pointer-armed scroll-up, or scrollend. */
640
- const releasePinFromReader = useCallback(
641
- (node?: HTMLElement | null) => {
642
- if (!autoFollow || !pinnedRef.current || hasNewerRef.current) {
643
- return;
644
- }
645
- // Unscrollable window: unpin strands Jump-to-latest with no way back.
646
- if (node && maxScrollOf(node) <= 1) {
647
- return;
648
- }
649
- readerIntentArmRef.current = false;
650
- clearPendingReaderLeave();
651
- stopFollow();
652
- applyPinned(false);
653
- if (wantPinRef.current) {
654
- wantPinRef.current = false;
655
- }
656
- if (!olderPrefetchArmedRef.current) {
657
- olderPrefetchArmedRef.current = true;
658
- setOlderPrefetchArmed(true);
1256
+ const releasePinFromReader = useCallback(() => {
1257
+ if (!autoFollow || !pinnedRef.current || hasNewerRef.current) {
1258
+ return;
1259
+ }
1260
+ clearReaderIntent();
1261
+ clearPendingReaderLeave();
1262
+ stopFollow();
1263
+ applyPinned(false);
1264
+ if (wantPinRef.current) {
1265
+ wantPinRef.current = false;
1266
+ }
1267
+ if (!olderPrefetchArmedRef.current) {
1268
+ olderPrefetchArmedRef.current = true;
1269
+ setOlderPrefetchArmed(true);
1270
+ }
1271
+ }, [autoFollow, applyPinned, clearPendingReaderLeave, clearReaderIntent, stopFollow]);
1272
+
1273
+ useEffect(() => {
1274
+ if (!readableTurns) return;
1275
+ const node = scrollRef.current;
1276
+ if (!node) return;
1277
+ const onSelection = () => {
1278
+ const selection = node.ownerDocument.getSelection();
1279
+ if (
1280
+ selection &&
1281
+ !selection.isCollapsed &&
1282
+ node.contains(selection.anchorNode) &&
1283
+ node.contains(selection.focusNode)
1284
+ ) {
1285
+ stopSettlement();
1286
+ releasePinFromReader();
1287
+ disclosureKeepsUnpinnedRef.current = true;
659
1288
  }
660
- },
661
- [autoFollow, applyPinned, clearPendingReaderLeave, stopFollow],
662
- );
1289
+ };
1290
+ node.ownerDocument.addEventListener("selectionchange", onSelection);
1291
+ return () => node.ownerDocument.removeEventListener("selectionchange", onSelection);
1292
+ }, [readableTurns, releasePinFromReader, stopSettlement]);
663
1293
 
664
1294
  /**
665
1295
  * Settled away from the tip while the camera is idle — Vimium / unfocused
@@ -680,7 +1310,7 @@ export function MessageTimeline({
680
1310
  clearPendingReaderLeave();
681
1311
  return;
682
1312
  }
683
- releasePinFromReader(node);
1313
+ releasePinFromReader();
684
1314
  rearmOlderPrefetchAfterLeavingTop(node);
685
1315
  },
686
1316
  [autoFollow, clearPendingReaderLeave, rearmOlderPrefetchAfterLeavingTop, releasePinFromReader],
@@ -701,6 +1331,22 @@ export function MessageTimeline({
701
1331
  });
702
1332
  }, [cancelLeaveFallback, releasePinAfterScrollSettled]);
703
1333
 
1334
+ const requestEarlierFromReader = () => {
1335
+ // Upward navigation replaces any earlier downward gesture, even when
1336
+ // already unpinned and the engine never delivered scrollend.
1337
+ clearReaderIntent();
1338
+ releasePinFromReader();
1339
+ wantPinRef.current = false;
1340
+ // A stationary upward gesture is still demand. Successful short/folded
1341
+ // pages may never create enough range to leave the prefetch band.
1342
+ olderPageBudgetRef.current = 8;
1343
+ if (olderLoadAttemptRef.current?.[1] === 2) {
1344
+ olderLoadAttemptRef.current = null;
1345
+ setOlderDemand((value) => value + 1);
1346
+ }
1347
+ };
1348
+ const touchPositionRef = useRef<{ x: number; y: number } | null>(null);
1349
+
704
1350
  const onWheel = (event: {
705
1351
  deltaY: number;
706
1352
  deltaX: number;
@@ -717,13 +1363,17 @@ export function MessageTimeline({
717
1363
  if (wheelConsumedByNestedScrollable(event)) {
718
1364
  return;
719
1365
  }
1366
+ stopSettlement();
720
1367
  disclosureKeepsUnpinnedRef.current = false;
1368
+ programmaticScrollRef.current = 0;
721
1369
  if (event.deltaY >= 0) {
1370
+ const node = scrollRef.current;
1371
+ readerIntentStartRef.current = node
1372
+ ? { scrollTop: node.scrollTop, maxScroll: maxScrollOf(node) }
1373
+ : null;
722
1374
  return;
723
1375
  }
724
- const node =
725
- event.currentTarget instanceof HTMLElement ? event.currentTarget : scrollRef.current;
726
- releasePinFromReader(node);
1376
+ requestEarlierFromReader();
727
1377
  };
728
1378
 
729
1379
  /** Touch / stylus / mouse drag on the scroller — explicit leave (not layout). */
@@ -731,11 +1381,13 @@ export function MessageTimeline({
731
1381
  button: number;
732
1382
  pointerType: string;
733
1383
  target: EventTarget | null;
1384
+ currentTarget: EventTarget | null;
734
1385
  }) => {
735
1386
  // Primary button / touch / pen only. Ignore right-click etc.
736
1387
  if (event.button && event.pointerType === "mouse") {
737
1388
  return;
738
1389
  }
1390
+ stopSettlement();
739
1391
  // Clicks on chips/buttons/links must not arm — their settle collapse
740
1392
  // also drops scrollTop and would false-unpin. Drag on prose/scroller may.
741
1393
  if (
@@ -745,7 +1397,13 @@ export function MessageTimeline({
745
1397
  return;
746
1398
  }
747
1399
  disclosureKeepsUnpinnedRef.current = false;
1400
+ programmaticScrollRef.current = 0;
1401
+ const node =
1402
+ event.currentTarget instanceof HTMLElement ? event.currentTarget : scrollRef.current;
748
1403
  readerIntentArmRef.current = true;
1404
+ readerIntentStartRef.current = node
1405
+ ? { scrollTop: node.scrollTop, maxScroll: maxScrollOf(node) }
1406
+ : null;
749
1407
  };
750
1408
 
751
1409
  const onKeyDown = (event: { key: string; currentTarget: EventTarget | null }) => {
@@ -757,18 +1415,25 @@ export function MessageTimeline({
757
1415
  event.key === "Home" ||
758
1416
  event.key === "End"
759
1417
  ) {
1418
+ stopSettlement();
760
1419
  disclosureKeepsUnpinnedRef.current = false;
1420
+ // Reader navigation supersedes a pending camera/anchor scroll echo,
1421
+ // including downward keys that do not explicitly release the pin.
1422
+ programmaticScrollRef.current = 0;
1423
+ const node = scrollRef.current;
1424
+ readerIntentStartRef.current = node
1425
+ ? { scrollTop: node.scrollTop, maxScroll: maxScrollOf(node) }
1426
+ : null;
761
1427
  }
762
1428
  if (event.key !== "ArrowUp" && event.key !== "PageUp" && event.key !== "Home") {
763
1429
  return;
764
1430
  }
765
- const node =
766
- event.currentTarget instanceof HTMLElement ? event.currentTarget : scrollRef.current;
767
- releasePinFromReader(node);
1431
+ requestEarlierFromReader();
768
1432
  };
769
1433
 
770
1434
  const snapToBottom = useCallback(
771
1435
  (node: HTMLElement) => {
1436
+ clearReaderIntent();
772
1437
  stopFollow();
773
1438
  cancelLeaveFallback();
774
1439
  writeScrollTop(node, Math.max(0, node.scrollHeight - node.clientHeight));
@@ -781,7 +1446,14 @@ export function MessageTimeline({
781
1446
  };
782
1447
  applyCanSkipTipCatchup(false);
783
1448
  },
784
- [applyCanSkipTipCatchup, cancelLeaveFallback, stopFollow, syncScrollBaseline, writeScrollTop],
1449
+ [
1450
+ applyCanSkipTipCatchup,
1451
+ cancelLeaveFallback,
1452
+ clearReaderIntent,
1453
+ stopFollow,
1454
+ syncScrollBaseline,
1455
+ writeScrollTop,
1456
+ ],
785
1457
  );
786
1458
 
787
1459
  const beginUserMessageDisclosureChange = useCallback(
@@ -839,8 +1511,65 @@ export function MessageTimeline({
839
1511
  () => ({
840
1512
  expandedByMessageId: userMessageDisclosureMemoryRef.current,
841
1513
  beginChange: beginUserMessageDisclosureChange,
1514
+ labels: {
1515
+ showMore: userMessageDisclosureLabels?.showMore,
1516
+ showLess: userMessageDisclosureLabels?.showLess,
1517
+ },
1518
+ }),
1519
+ [
1520
+ beginUserMessageDisclosureChange,
1521
+ userMessageDisclosureLabels?.showMore,
1522
+ userMessageDisclosureLabels?.showLess,
1523
+ ],
1524
+ );
1525
+ const timelineGroupEntryContext = useMemo<TimelineGroupEntryContext>(
1526
+ () => ({
1527
+ userMessageDisclosureContext,
1528
+ behavior: {
1529
+ renderMessageActions,
1530
+ renderMessageText: renderSearchableMessageText,
1531
+ onOpenSession,
1532
+ onMemoryClick,
1533
+ onReconnect,
1534
+ renderAuthNeeded,
1535
+ resolveProviderLogo,
1536
+ toolRegistry,
1537
+ loadRetainedScreenshot,
1538
+ loadRetainedArtifact,
1539
+ loadVideoArtifactPlayback,
1540
+ genieLoading,
1541
+ turnSummary,
1542
+ },
842
1543
  }),
843
- [beginUserMessageDisclosureChange],
1544
+ [
1545
+ loadRetainedArtifact,
1546
+ loadRetainedScreenshot,
1547
+ loadVideoArtifactPlayback,
1548
+ onMemoryClick,
1549
+ onOpenSession,
1550
+ onReconnect,
1551
+ renderAuthNeeded,
1552
+ renderMessageActions,
1553
+ renderSearchableMessageText,
1554
+ resolveProviderLogo,
1555
+ toolRegistry,
1556
+ genieLoading,
1557
+ turnSummary,
1558
+ userMessageDisclosureContext,
1559
+ ],
1560
+ );
1561
+
1562
+ const releaseSearchPin = useCallback(() => {
1563
+ applyPinned(false);
1564
+ stopFollow();
1565
+ setRevealed(true);
1566
+ }, [applyPinned, stopFollow]);
1567
+ useTimelineSearchNavigation(
1568
+ scrollRef,
1569
+ searchItem?.id,
1570
+ searchTarget,
1571
+ releaseSearchPin,
1572
+ resolvedItems,
844
1573
  );
845
1574
 
846
1575
  const requestOlderIfUnderfilled = useCallback(
@@ -892,10 +1621,78 @@ export function MessageTimeline({
892
1621
  // exact `false` is the first-party request-not-accepted receipt.
893
1622
  // All other fulfillment retains this exact owner until its prepend
894
1623
  // boundary commits; promise settlement alone cannot prove progress.
895
- invokeOlderLoad(onLoadOlder, noProgress, attempt);
1624
+ invokeOlderLoad(onLoadOlder, noProgress, attempt, !retry);
896
1625
  },
897
1626
  [hasOlder, loadingOlder, olderBoundaryKey, onLoadOlder],
898
1627
  );
1628
+ const scheduleQuestionNav = useCallback(() => {
1629
+ if (!readableTurns || questionNavFrameRef.current != null) {
1630
+ return;
1631
+ }
1632
+ questionNavFrameRef.current = requestFrame(() => {
1633
+ questionNavFrameRef.current = null;
1634
+ const node = scrollRef.current;
1635
+ // A short viewport (a narrow embed above a decision card) has no room
1636
+ // for a floating pill that would cover the very rows being read.
1637
+ // clientHeight <= 1 is pre-layout/headless, not a short viewport.
1638
+ const roomy =
1639
+ node !== null &&
1640
+ (node.clientHeight <= 1 || node.clientHeight >= questionNavMinViewportRef.current);
1641
+ const next = node && roomy ? readQuestionNav(node) : null;
1642
+ setQuestionNav((current) => (sameQuestionNav(current, next) ? current : next));
1643
+ });
1644
+ }, [readableTurns]);
1645
+ scheduleQuestionNavRef.current = scheduleQuestionNav;
1646
+ useEffect(
1647
+ () => () => {
1648
+ if (questionNavFrameRef.current != null) {
1649
+ cancelFrame(questionNavFrameRef.current);
1650
+ questionNavFrameRef.current = null;
1651
+ }
1652
+ },
1653
+ [],
1654
+ );
1655
+ const jumpToQuestion = (key: string) => {
1656
+ const node = scrollRef.current;
1657
+ const top = node ? contentTopOf(node, key) : null;
1658
+ if (!node || top === null) {
1659
+ return;
1660
+ }
1661
+ // Explicit reader navigation: leave the tip and park the question on top.
1662
+ releasePinFromReader();
1663
+ wantPinRef.current = false;
1664
+ disclosureKeepsUnpinnedRef.current = false;
1665
+ writeScrollTop(node, Math.max(0, top - QUESTION_NAV_MARGIN_PX));
1666
+ // Hand keyboard/accessibility focus to the actual destination. This also
1667
+ // gives native scroll anchoring the new question rather than an old work
1668
+ // row when the bounded context subsequently gains a later history page.
1669
+ node
1670
+ .querySelector<HTMLElement>(`[data-og-group-key="${cssEscapeAttribute(key)}"]`)
1671
+ ?.focus({ preventScroll: true });
1672
+ syncScrollBaseline(node);
1673
+ scheduleQuestionNav();
1674
+ };
1675
+
1676
+ // Land once on the moment an inbox item or a notification points at.
1677
+ // Deliberately dependency-free: it retries after every commit until the
1678
+ // target row is laid out, and the ref guard makes the state update run once
1679
+ // per focus sequence, so it cannot chain renders.
1680
+ const focusedSequenceRef = useRef<number | null>(null);
1681
+ // oxlint-disable-next-line react-hooks/exhaustive-deps -- retries each commit; ref-guarded to run once
1682
+ useLayoutEffect(() => {
1683
+ if (!focusSequence || focusedSequenceRef.current === focusSequence) return;
1684
+ const index = timelineGroupIndexAtSequence(
1685
+ groups.map((entry) => entry.group),
1686
+ focusSequence,
1687
+ );
1688
+ const key = index >= 0 ? groups[index]?.key : undefined;
1689
+ const node = scrollRef.current;
1690
+ if (!key || !node || contentTopOf(node, key) === null) return;
1691
+ focusedSequenceRef.current = focusSequence;
1692
+ setRevealed(true);
1693
+ jumpToQuestion(key);
1694
+ });
1695
+
899
1696
  const driveFollowRef = useRef<(node: HTMLElement, now?: number) => void>(
900
1697
  requestOlderIfUnderfilled as (node: HTMLElement, now?: number) => void,
901
1698
  );
@@ -919,19 +1716,41 @@ export function MessageTimeline({
919
1716
  : typeof performance !== "undefined"
920
1717
  ? performance.now()
921
1718
  : Date.now();
922
- const previousHeight = followRef.current.lastHeight;
1719
+ let previousHeight = followRef.current.lastHeight;
1720
+ const previousObservedHeight = lastScrollHeightRef.current;
1721
+ if (
1722
+ contentShrinkBaselineRef.current &&
1723
+ previousObservedHeight > 0 &&
1724
+ node.scrollHeight > previousObservedHeight
1725
+ ) {
1726
+ // A reversal ends the collapse sequence. If it remains below the held
1727
+ // baseline, adopt the recovered height; if it grew beyond the baseline,
1728
+ // leave that baseline for tipFollowStep to observe as real growth.
1729
+ contentShrinkBaselineRef.current = null;
1730
+ if (node.scrollHeight < previousHeight) {
1731
+ followRef.current = {
1732
+ ...followRef.current,
1733
+ lastHeight: node.scrollHeight,
1734
+ cameraTop: null,
1735
+ };
1736
+ previousHeight = node.scrollHeight;
1737
+ }
1738
+ }
1739
+ const shrinkObservation = tipFollowObserveContentShrink(
1740
+ contentShrinkBaselineRef.current,
1741
+ previousHeight,
1742
+ lastScrollTopRef.current,
1743
+ node.scrollHeight,
1744
+ node.clientHeight,
1745
+ );
1746
+ contentShrinkBaselineRef.current = shrinkObservation.baseline;
923
1747
  // Settle-collapse: compensate Δh from the pre-shrink baseline (browser
924
1748
  // may already have clamped — don't double-subtract). Keep the follow rAF
925
1749
  // alive so when collapse ends (or stream resumes) we ease instead of a
926
1750
  // hard stop → flick. Do NOT tip-ease on the same frame as a real shrink
927
1751
  // (that fight was the top-of-viewport flicker).
928
- if (previousHeight > 0 && node.scrollHeight < previousHeight - TIP_FOLLOW_SHRINK_EPS_PX) {
929
- let nextTop = tipFollowCompensateShrink(
930
- lastScrollTopRef.current,
931
- previousHeight,
932
- node.scrollHeight,
933
- node.clientHeight,
934
- );
1752
+ if (shrinkObservation.compensatedScrollTop !== null) {
1753
+ let nextTop = shrinkObservation.compensatedScrollTop;
935
1754
  // A chrome/composer dock can land on the same frame as a settle-fold
936
1755
  // (the "turn blocked" moment). Compensate BOTH in one write — adopting
937
1756
  // the shrunk clientHeight below without gluing left the chrome height
@@ -967,15 +1786,6 @@ export function MessageTimeline({
967
1786
  }
968
1787
  return;
969
1788
  }
970
- // Sub-eps height noise: adopt height without moving the camera. Do NOT
971
- // adopt clientHeight here — tipFollowStep (next line) owns that baseline
972
- // and must still see a same-frame viewport shrink to glue it.
973
- if (previousHeight > 0 && node.scrollHeight < previousHeight) {
974
- followRef.current = {
975
- ...followRef.current,
976
- lastHeight: node.scrollHeight,
977
- };
978
- }
979
1789
  const result = tipFollowStep(followRef.current, {
980
1790
  scrollTop: node.scrollTop,
981
1791
  scrollHeight: node.scrollHeight,
@@ -1027,7 +1837,7 @@ export function MessageTimeline({
1027
1837
  }
1028
1838
  const previousFirstItemId = previousFirstItemIdRef.current;
1029
1839
  const previousFirstItemGroupKey = firstItemGroupKeyRef.current;
1030
- const previousGroupOffsetByKey = groupOffsetByKeyRef.current;
1840
+ const previousFirstItemGroupOffsetTop = firstItemGroupOffsetTopRef.current;
1031
1841
  const previousItemContentTop = firstItemContentTopRef.current;
1032
1842
  const previousScrollTop = lastScrollTopRef.current;
1033
1843
  const previousMaxScroll = lastMaxScrollRef.current;
@@ -1035,15 +1845,28 @@ export function MessageTimeline({
1035
1845
  previousMaxScroll <= 1 ||
1036
1846
  previousMaxScroll - previousScrollTop < Math.min(PIN_THRESHOLD_PX, previousMaxScroll);
1037
1847
  const firstItemChanged = !!previousFirstItemId && firstItemId !== previousFirstItemId;
1038
- const prepended =
1039
- firstItemChanged && resolvedItems.some((item) => item.id === previousFirstItemId);
1848
+ const sourceChanged = olderBoundaryKey !== previousSourceBoundaryRef.current;
1849
+ const retainedSource =
1850
+ sourceItems?.some((item) => previousSourceIdsRef.current.has(item.id)) ?? false;
1851
+ const prepended = sourceChanged && retainedSource;
1852
+ previousSourceBoundaryRef.current = olderBoundaryKey;
1853
+ previousSourceIdsRef.current = new Set(sourceItems?.map((item) => item.id));
1854
+ const readingAnchor = readingAnchorRef.current;
1855
+ readingAnchorRef.current = null;
1856
+ const settlement = settlementRef.current;
1857
+ settlementRef.current = null;
1040
1858
  const attempt = olderLoadAttemptRef.current;
1041
1859
  const committedZeroOverlapOlderReplacement = !!(
1042
1860
  attempt?.[2]?.committed &&
1043
- firstItemChanged &&
1044
- !prepended
1861
+ sourceChanged &&
1862
+ !retainedSource
1045
1863
  );
1046
1864
  const restorePrependAnchor = () => {
1865
+ const correction = readingAnchor && timelineAnchorCorrection(node, readingAnchor);
1866
+ if (correction != null) {
1867
+ if (Math.abs(correction) > 1) writeScrollTop(node, node.scrollTop + correction);
1868
+ return;
1869
+ }
1047
1870
  // Keep the reader on the same retained rows. Prefer the exact first-item
1048
1871
  // content coordinate (needed when a prepend merges inside one group),
1049
1872
  // then the retained group offset, then scrollHeight as a final fallback.
@@ -1060,14 +1883,16 @@ export function MessageTimeline({
1060
1883
  }
1061
1884
  }
1062
1885
  const anchorKey = previousFirstItemGroupKey;
1063
- const previousAnchorTop =
1064
- anchorKey != null ? previousGroupOffsetByKey.get(anchorKey) : undefined;
1065
1886
  const anchorEl =
1066
1887
  anchorKey != null
1067
1888
  ? node.querySelector(`[data-og-group-key="${cssEscapeAttribute(anchorKey)}"]`)
1068
1889
  : null;
1069
- if (delta == null && anchorEl instanceof HTMLElement && previousAnchorTop !== undefined) {
1070
- const moved = Math.round(anchorEl.offsetTop - previousAnchorTop);
1890
+ if (
1891
+ delta == null &&
1892
+ anchorEl instanceof HTMLElement &&
1893
+ previousFirstItemGroupOffsetTop != null
1894
+ ) {
1895
+ const moved = Math.round(anchorEl.offsetTop - previousFirstItemGroupOffsetTop);
1071
1896
  if (moved) {
1072
1897
  delta = moved;
1073
1898
  }
@@ -1113,17 +1938,23 @@ export function MessageTimeline({
1113
1938
  applyPinned(false);
1114
1939
  }
1115
1940
  } else if (prepended) {
1941
+ const olderAttemptState = olderLoadAttemptRef.current?.[1];
1942
+ const underfillOwned =
1943
+ !!olderLoadAttemptRef.current &&
1944
+ olderAttemptState !== 1 &&
1945
+ olderAttemptState !== 2 &&
1946
+ olderAttemptState !== 3;
1116
1947
  if (
1117
1948
  autoFollow &&
1118
1949
  pinnedRef.current &&
1119
1950
  !hasNewer &&
1120
1951
  !pendingReaderLeaveRef.current &&
1121
- (wasAtLiveTailBeforeCommit || olderLoadAttemptRef.current)
1952
+ (wasAtLiveTailBeforeCommit || underfillOwned)
1122
1953
  ) {
1123
- // A pending short-window load owns the prepend even when live growth
1124
- // has left its still-pinned camera with raw geometry debt. Unrelated
1125
- // prepends retain the prior-tip fallback so a stale pin after an
1126
- // extension/programmatic history jump still restores its row anchor.
1954
+ // Still following the live tip: underfill, or a prefetch the reader
1955
+ // started and then returned from. Park at the new tip. A stale pin
1956
+ // while they are actually up in a short window (gap is not inside
1957
+ // PIN_THRESHOLD of maxScroll, so wasAtLiveTail is false) restores.
1127
1958
  clearPendingReaderLeave();
1128
1959
  snapToBottom(node);
1129
1960
  } else {
@@ -1136,6 +1967,22 @@ export function MessageTimeline({
1136
1967
  applyPinned(false);
1137
1968
  }
1138
1969
  }
1970
+ } else if (!pinnedRef.current && readingAnchor && readableTurns) {
1971
+ // A live work row moves behind newly streamed prose. Correct only the
1972
+ // residual movement native anchoring did not absorb, using the reader's
1973
+ // retained paragraph/control rather than the moving row's outer box.
1974
+ const correction = timelineAnchorCorrection(node, readingAnchor);
1975
+ if (correction !== null && Math.abs(correction) > 1) {
1976
+ writeScrollTop(node, node.scrollTop + correction);
1977
+ }
1978
+ const focused = readingAnchor.find((anchor) => anchor.focused)?.element;
1979
+ if (
1980
+ focused &&
1981
+ node.contains(focused) &&
1982
+ node.ownerDocument.activeElement === node.ownerDocument.body
1983
+ ) {
1984
+ focused.focus({ preventScroll: true });
1985
+ }
1139
1986
  } else if (autoFollow && pinnedRef.current && !hasNewer) {
1140
1987
  // Load/remount (still hidden): hard-park. Live tip after reveal: ease.
1141
1988
  // Pending unarmed leave: tip *growth* must not yank (Vimium during stream).
@@ -1157,6 +2004,7 @@ export function MessageTimeline({
1157
2004
  if (prepended && !pinnedRef.current && node.scrollTop > OLDER_PREFETCH_MARGIN_PX) {
1158
2005
  rearmOlderPrefetchAfterLeavingTop(node);
1159
2006
  }
2007
+ scheduleQuestionNav();
1160
2008
  previousFirstItemIdRef.current = firstItemId;
1161
2009
  previousScrollHeightRef.current = node.scrollHeight;
1162
2010
  syncScrollBaseline(node);
@@ -1170,14 +2018,12 @@ export function MessageTimeline({
1170
2018
  hasNewer ||
1171
2019
  firstItemContentTopRef.current == null;
1172
2020
  if (needOffsets) {
1173
- const nextOffsetByKey = new Map<string, number>();
1174
- for (const { key } of groups) {
1175
- const el = node.querySelector(`[data-og-group-key="${cssEscapeAttribute(key)}"]`);
1176
- if (el instanceof HTMLElement) {
1177
- nextOffsetByKey.set(key, el.offsetTop);
1178
- }
1179
- }
1180
- groupOffsetByKeyRef.current = nextOffsetByKey;
2021
+ const committedFirstGroupKey = firstItemGroupKeyRef.current;
2022
+ const firstGroupEl = committedFirstGroupKey
2023
+ ? node.querySelector(`[data-og-group-key="${cssEscapeAttribute(committedFirstGroupKey)}"]`)
2024
+ : null;
2025
+ firstItemGroupOffsetTopRef.current =
2026
+ firstGroupEl instanceof HTMLElement ? firstGroupEl.offsetTop : null;
1181
2027
  const firstItemEl = firstItemId
1182
2028
  ? node.querySelector(`[data-og-item="${cssEscapeAttribute(firstItemId)}"]`)
1183
2029
  : null;
@@ -1189,6 +2035,8 @@ export function MessageTimeline({
1189
2035
  : null;
1190
2036
  }
1191
2037
 
2038
+ if (settlement) settlementAnimationRef.current = animateTimelineSettlement(settlement);
2039
+
1192
2040
  // Promise settlement is not itself permission to retry. A receipt-marked
1193
2041
  // accepted page retires its exact owner on this commit even when projection
1194
2042
  // is empty or merges into the same first item. Without that mark, retain
@@ -1215,6 +2063,19 @@ export function MessageTimeline({
1215
2063
  // Boundary progress retires the request owner. A late settlement from
1216
2064
  // that completed prefetch cannot mutate the new window's cooldown owner.
1217
2065
  olderLoadAttemptRef.current = [olderBoundaryKey, 2];
2066
+ // Bound automatic work, but let continued upward input replenish demand.
2067
+ // Only a committed first-party receipt proves that another page is safe.
2068
+ if (
2069
+ attempt[2]?.committed &&
2070
+ !pinnedRef.current &&
2071
+ hasOlder &&
2072
+ node.scrollTop <= OLDER_PREFETCH_MARGIN_PX &&
2073
+ olderPageBudgetRef.current > 0
2074
+ ) {
2075
+ olderPageBudgetRef.current -= 1;
2076
+ olderLoadAttemptRef.current = null;
2077
+ setOlderDemand((value) => value + 1);
2078
+ }
1218
2079
  rearmOlderPrefetchAfterLeavingTop(node);
1219
2080
  requestOlderIfUnderfilled(node);
1220
2081
  });
@@ -1279,14 +2140,16 @@ export function MessageTimeline({
1279
2140
  lastScrollHeightRef.current = 0;
1280
2141
  lastClientHeightRef.current = 0;
1281
2142
  firstItemGroupKeyRef.current = null;
1282
- groupOffsetByKeyRef.current = new Map();
2143
+ firstItemGroupOffsetTopRef.current = null;
1283
2144
  firstItemContentTopRef.current = null;
1284
2145
  foldMemoryRef.current.clear();
1285
2146
  userMessageDisclosureMemoryRef.current.clear();
1286
2147
  disclosureKeepsUnpinnedRef.current = false;
2148
+ clearReaderIntent();
2149
+ contentShrinkBaselineRef.current = null;
1287
2150
  seenActivityIdsRef.current.clear();
1288
2151
  applyPinned(true);
1289
- }, [allGroups.length, revealed, applyPinned]);
2152
+ }, [allGroups.length, revealed, applyPinned, clearReaderIntent]);
1290
2153
 
1291
2154
  // Parent commits cover the initial/history-loading cases. Disclosure state
1292
2155
  // changes are child-local, so the ResizeObserver below owns dynamic collapse
@@ -1383,6 +2246,7 @@ export function MessageTimeline({
1383
2246
  loadingOlder,
1384
2247
  olderBoundaryKey,
1385
2248
  olderPrefetchArmed,
2249
+ olderDemand,
1386
2250
  onLoadOlder,
1387
2251
  requestOlderIfUnderfilled,
1388
2252
  ]);
@@ -1404,15 +2268,17 @@ export function MessageTimeline({
1404
2268
  }
1405
2269
  const observer = new IntersectionObserver(
1406
2270
  (entries) => {
1407
- if (entries.some((entry) => entry.isIntersecting)) {
1408
- onLoadNewer();
2271
+ // An underfilled history window has both sentinels visible. Advancing
2272
+ // it automatically would undo an explicit older-page navigation.
2273
+ if (maxScrollOf(root) > 1 && entries.some((entry) => entry.isIntersecting)) {
2274
+ requestNewer();
1409
2275
  }
1410
2276
  },
1411
2277
  { root, rootMargin: "0px 0px 1200px 0px" },
1412
2278
  );
1413
2279
  observer.observe(target);
1414
2280
  return () => observer.disconnect();
1415
- }, [hasNewer, loadingNewer, onLoadNewer, firstGroupKey]);
2281
+ }, [hasNewer, loadingNewer, onLoadNewer, requestNewer, firstGroupKey]);
1416
2282
 
1417
2283
  // Late layout that React commits cannot see (images decoding, fonts, code
1418
2284
  // blocks) grows content without a commit. While pinned, soft-follow the tip;
@@ -1434,6 +2300,9 @@ export function MessageTimeline({
1434
2300
  if (!current) {
1435
2301
  return;
1436
2302
  }
2303
+ // A viewport that shrank below the pill's minimum hides it now, not
2304
+ // on the next scroll.
2305
+ if (questionNavMinViewportRef.current > 0) scheduleQuestionNavRef.current();
1437
2306
  requestOlderIfUnderfilled(current);
1438
2307
  if (!autoFollow || !pinnedRef.current || hasNewerRef.current) {
1439
2308
  return;
@@ -1465,6 +2334,8 @@ export function MessageTimeline({
1465
2334
  // at what just became the live bottom — paging forward to the tip must not
1466
2335
  // strand them unpinned watching new content grow below.
1467
2336
  useEffect(() => {
2337
+ // Closing find is paint-only; it must not trigger this history-to-tip rule.
2338
+ if (searchTargetRef.current) return;
1468
2339
  if (hasNewer) {
1469
2340
  stopFollow();
1470
2341
  applyPinned(false);
@@ -1487,6 +2358,15 @@ export function MessageTimeline({
1487
2358
  }
1488
2359
  }, [hasNewer, autoFollow, applyPinned, snapToBottom, stopFollow]);
1489
2360
 
2361
+ // After the scroll authority above has settled each commit, record whether
2362
+ // the reader is away from the tip (content can land below an unpinned
2363
+ // reader without any scroll event).
2364
+ // oxlint-disable-next-line react-hooks/exhaustive-deps -- Deliberately runs after every commit.
2365
+ useLayoutEffect(() => {
2366
+ const node = scrollRef.current;
2367
+ if (node && !pinnedRef.current) setAwayFromTip(!isNearBottom(node));
2368
+ });
2369
+
1490
2370
  // Pinned: layout/camera recover tip debt; wheel/keys/pointer-arm unpin
1491
2371
  // immediately; extension jumps settle via scrollend (or one-rAF fallback).
1492
2372
  // Do not tip-follow-yank an in-flight unarmed scroll-away — that ate Vimium.
@@ -1495,6 +2375,9 @@ export function MessageTimeline({
1495
2375
  if (!node) {
1496
2376
  return;
1497
2377
  }
2378
+ // Only an unpinned reader's distance matters for the jump.
2379
+ if (!pinnedRef.current) setAwayFromTip(!isNearBottom(node));
2380
+ scheduleQuestionNav();
1498
2381
  const previousTop = lastScrollTopRef.current;
1499
2382
  const previousMaxScroll = lastMaxScrollRef.current;
1500
2383
  const nextTop = node.scrollTop;
@@ -1503,6 +2386,16 @@ export function MessageTimeline({
1503
2386
  const readerUp = readerScrollUpPx(previousTop, nextTop, previousMaxScroll, nextMaxScroll);
1504
2387
  const maxFell = nextMaxScroll < previousMaxScroll - 1;
1505
2388
  const readerArmed = readerIntentArmRef.current;
2389
+ const readerIntentStart = readerIntentStartRef.current;
2390
+ const cumulativeReaderUp =
2391
+ readerArmed && readerIntentStart
2392
+ ? readerScrollUpPx(
2393
+ readerIntentStart.scrollTop,
2394
+ nextTop,
2395
+ readerIntentStart.maxScroll,
2396
+ nextMaxScroll,
2397
+ )
2398
+ : readerUp;
1506
2399
  const heightShrunk =
1507
2400
  followRef.current.lastHeight > 0 &&
1508
2401
  nextHeight < followRef.current.lastHeight - TIP_FOLLOW_SHRINK_EPS_PX;
@@ -1518,6 +2411,19 @@ export function MessageTimeline({
1518
2411
  return;
1519
2412
  }
1520
2413
 
2414
+ if (programmatic && !pinnedRef.current) {
2415
+ // Restore/camera writes while reading history are not a return to the tip.
2416
+ // Still expire a pending Jump-to-latest latch: the in-window jump itself
2417
+ // is a programmatic snap, and a later reader scroll-away can arrive
2418
+ // before that echo is consumed. Skipping this left the latch armed and
2419
+ // snapped the reader when hasNewer later flipped false.
2420
+ syncScrollBaseline(node);
2421
+ if (wantPinRef.current && !isNearBottom(node)) {
2422
+ wantPinRef.current = false;
2423
+ }
2424
+ return;
2425
+ }
2426
+
1521
2427
  if (autoFollow && pinnedRef.current && !hasNewer) {
1522
2428
  // Fold / composer / SessionChrome: viewport shrink raises maxScroll without
1523
2429
  // growing content. Must hit tipFollow before we adopt the new clientHeight
@@ -1528,7 +2434,7 @@ export function MessageTimeline({
1528
2434
  // Fold / composer content shrink: compensate before baseline sync so
1529
2435
  // driveFollow still sees the pre-shrink scrollTop (avoid double-subtract).
1530
2436
  if (heightShrunk || maxFell || viewportShrunk) {
1531
- readerIntentArmRef.current = false;
2437
+ clearReaderIntent();
1532
2438
  clearPendingReaderLeave();
1533
2439
  driveFollow(node);
1534
2440
  return;
@@ -1550,9 +2456,9 @@ export function MessageTimeline({
1550
2456
  clearPendingReaderLeave();
1551
2457
  }
1552
2458
  // Pointer-dragged scroll-up away from tip. Layout churn never arms this.
1553
- if (readerArmed && readerUp > TIP_FOLLOW_READER_UP_EPS_PX && !nearBottomPinned) {
1554
- readerIntentArmRef.current = false;
1555
- releasePinFromReader(node);
2459
+ if (readerArmed && cumulativeReaderUp > TIP_FOLLOW_READER_UP_EPS_PX && !nearBottomPinned) {
2460
+ clearReaderIntent();
2461
+ requestEarlierFromReader();
1556
2462
  rearmOlderPrefetchAfterLeavingTop(node);
1557
2463
  return;
1558
2464
  }
@@ -1577,15 +2483,46 @@ export function MessageTimeline({
1577
2483
  }
1578
2484
 
1579
2485
  syncScrollBaseline(node);
2486
+ if (programmatic) {
2487
+ // Anchor restoration is never permission to resume tip-follow.
2488
+ return;
2489
+ }
1580
2490
  const nearBottom = isNearBottom(node);
1581
2491
 
1582
- // Re-pin only when the reader moved toward/at the tip — not when a fold
1583
- // clamp dragged scrollTop down onto nearBottom.
2492
+ // Re-pin only when the reader moved toward/at the tip. Prepend restore
2493
+ // and overflow-anchor raise scrollTop by roughly the same amount as
2494
+ // maxScroll; treating that as a scroll-down
2495
+ // re-pinned a compact-tail history reader (their preserved gap falls
2496
+ // inside PIN_THRESHOLD once the window is tall) and snapped them back.
2497
+ // A commit may already have adopted this scroll position into the layout
2498
+ // baseline. Explicit wheel/key/pointer intent retains its own start so
2499
+ // returning to the tip still re-pins after that interleaving.
2500
+ // Passive wheel handlers may run after compositor scrolling; retain the
2501
+ // earlier layout baseline when it still contains that reader movement.
2502
+ const returnStart =
2503
+ readerIntentStart && readerIntentStart.scrollTop <= previousTop ? readerIntentStart : null;
2504
+ const inserted = Math.max(0, nextMaxScroll - (returnStart?.maxScroll ?? previousMaxScroll));
2505
+ const towardTip = nextTop - (returnStart?.scrollTop ?? previousTop) - inserted;
2506
+ // Explicit intent may reach the exact live bottom despite intervening
2507
+ // streaming growth. Keep subtracting that growth: native anchoring alone
2508
+ // conserves the gap and must never qualify as reader movement.
2509
+ const explicitReturnToBottom = returnStart !== null && nextMaxScroll - nextTop <= 1;
1584
2510
  const nextPinned =
1585
- !hasNewer && nearBottom && nextTop >= previousTop - TIP_FOLLOW_READER_UP_EPS_PX;
2511
+ !hasNewer && nearBottom && towardTip > 0.5 && (inserted <= 1 || explicitReturnToBottom);
1586
2512
  if (!nextPinned) {
1587
2513
  stopFollow();
1588
2514
  }
2515
+ const focused = node.ownerDocument.activeElement;
2516
+ if (
2517
+ nextPinned &&
2518
+ focused instanceof HTMLElement &&
2519
+ focused.hasAttribute("data-og-prompt") &&
2520
+ node.contains(focused)
2521
+ ) {
2522
+ // Question navigation focuses its destination. Returning to the live
2523
+ // tip releases that stale reader ownership before the commit snapshot.
2524
+ node.focus({ preventScroll: true });
2525
+ }
1589
2526
  applyPinned(nextPinned);
1590
2527
  // A far-from-bottom scroll while a Jump-to-latest is pending is the reader
1591
2528
  // changing their mind: drop the latch, or a stale one (host rejected or
@@ -1596,8 +2533,15 @@ export function MessageTimeline({
1596
2533
  wantPinRef.current = false;
1597
2534
  }
1598
2535
  if (nextPinned) {
2536
+ clearReaderIntent();
1599
2537
  return;
1600
2538
  }
2539
+ if (readerIntentStart) {
2540
+ // This scroll already accounted for the gesture's movement. Retain a
2541
+ // fresh start for continuous input across the next commit, not stale
2542
+ // movement that could turn a later native clamp into a return to tip.
2543
+ readerIntentStartRef.current = { scrollTop: nextTop, maxScroll: nextMaxScroll };
2544
+ }
1601
2545
  if (!olderPrefetchArmedRef.current) {
1602
2546
  olderPrefetchArmedRef.current = true;
1603
2547
  setOlderPrefetchArmed(true);
@@ -1613,6 +2557,9 @@ export function MessageTimeline({
1613
2557
  return;
1614
2558
  }
1615
2559
  cancelLeaveFallback();
2560
+ // Engines can emit scrollend between tiny pointer steps. Keep the pinned
2561
+ // gesture's cumulative leave budget until it actually leaves the tip.
2562
+ if (!pinnedRef.current || !readerIntentArmRef.current) clearReaderIntent();
1616
2563
  if (disclosureKeepsUnpinnedRef.current) {
1617
2564
  programmaticScrollRef.current = 0;
1618
2565
  stopFollow();
@@ -1627,308 +2574,506 @@ export function MessageTimeline({
1627
2574
  }
1628
2575
  releasePinAfterScrollSettled(node);
1629
2576
  };
2577
+ // React 18 has no `onScrollEnd` prop: it warns and never calls it, while
2578
+ // `supportsScrollEndEvent()` still makes the camera wait for scrollend. A
2579
+ // native listener (re-bound each commit, so it always sees this render's
2580
+ // handler and scroller) works on every supported React version.
2581
+ useEffect(() => {
2582
+ const node = scrollRef.current;
2583
+ if (!node) return;
2584
+ node.addEventListener("scrollend", onScrollEnd);
2585
+ return () => node.removeEventListener("scrollend", onScrollEnd);
2586
+ });
1630
2587
 
1631
- return (
1632
- <LightboxProvider>
1633
- <FoldMemoryProvider value={foldMemoryRef.current}>
1634
- <SeenActivityIdsProvider value={seenActivityIdsRef.current}>
1635
- <TimelineComputeLabelProvider value={computeLabel ?? null}>
1636
- <EntranceAnimationProvider value={!bulkRender}>
1637
- <TooltipProvider delayDuration={400}>
1638
- <div className={cn("og-root relative flex min-h-0 flex-col", className)}>
1639
- {onAnnotate ? (
1640
- <Suspense fallback={null}>
1641
- <TimelineAnnotationSelection
1642
- rootRef={scrollRef}
1643
- sources={annotationSources}
1644
- onAnnotate={onAnnotate}
1645
- />
1646
- </Suspense>
1647
- ) : null}
1648
- {/* Pinned: anchoring off so the tip-follow camera owns the motion.
1649
- Unpinned: native scroll anchoring holds the reader's place. */}
1650
- <div
1651
- ref={scrollRef}
1652
- data-og-timeline-scroller=""
1653
- data-og-bottom-follow={autoFollow && pinned && !hasNewer ? "true" : "false"}
1654
- tabIndex={-1}
1655
- onScroll={onScroll}
1656
- onScrollEnd={onScrollEnd}
1657
- onWheel={onWheel}
1658
- onPointerDown={onPointerDown}
1659
- onKeyDown={onKeyDown}
1660
- style={groups.length > 0 && !revealed ? { visibility: "hidden" } : undefined}
1661
- className={cn(
1662
- // tabIndex=-1 is programmatic only — never paint a focus ring on
1663
- // the whole scroller (click + Shift used to flash a blue outline).
1664
- "min-h-0 flex-1 overflow-y-auto overscroll-contain px-4 py-6 sm:px-6 outline-hidden",
1665
- autoFollow && pinned && !hasNewer
1666
- ? "[overflow-anchor:none]"
1667
- : "[overflow-anchor:auto]",
1668
- )}
1669
- >
1670
- <div className="relative mx-auto flex w-full max-w-3xl flex-col gap-5">
1671
- {!groups.length
1672
- ? (emptyState ?? (
1673
- <p className="py-10 text-center text-og-menu text-og-fg-subtle">
1674
- No activity yet.
1675
- </p>
1676
- ))
1677
- : null}
1678
- {hasOlder && olderPrefetchArmed ? (
1679
- // Overlaid, not a layout row: mounting/unmounting the sentinel
1680
- // must never shift content (that shift was itself a wobble).
1681
- <div
1682
- ref={topSentinelRef}
1683
- data-og-top-sentinel=""
1684
- data-og-timeline-chrome=""
1685
- aria-hidden="true"
1686
- className="pointer-events-none absolute inset-x-0 top-0 h-px"
1687
- />
1688
- ) : null}
1689
- {groups.map(({ group, key }, index) => {
1690
- const next = groups[index + 1]?.group;
1691
- const contextCompactionCount =
1692
- group.kind === "turn"
1693
- ? (group.contextCompactionCount ?? 0)
1694
- : group.kind === "activity" &&
1695
- next?.kind === "item" &&
1696
- next.item.kind === "context-compaction" &&
1697
- next.item.phase === "compacted"
1698
- ? 1
1699
- : 0;
1700
- return (
1701
- <div key={key} data-og-timeline-group-anchor="" data-og-group-key={key}>
1702
- <TimelineGroupRenderBoundary
1703
- resetKeys={[
1704
- group,
1705
- renderMessageText,
1706
- onOpenSession,
1707
- onMemoryClick,
1708
- onReconnect,
1709
- resolveProviderLogo,
1710
- toolRegistry,
1711
- loadRetainedScreenshot,
1712
- loadRetainedArtifact,
1713
- loadVideoArtifactPlayback,
1714
- turnSummary,
1715
- ]}
1716
- >
1717
- <UserMessageDisclosureProvider value={userMessageDisclosureContext}>
1718
- <TimelineGroupView
1719
- group={group}
1720
- renderMessageText={renderMessageText}
1721
- onOpenSession={onOpenSession}
1722
- onMemoryClick={onMemoryClick}
1723
- onReconnect={onReconnect}
1724
- resolveProviderLogo={resolveProviderLogo}
1725
- toolRegistry={toolRegistry}
1726
- loadRetainedScreenshot={loadRetainedScreenshot}
1727
- loadRetainedArtifact={loadRetainedArtifact}
1728
- loadVideoArtifactPlayback={loadVideoArtifactPlayback}
1729
- turnSummary={turnSummary}
1730
- foldLiveCluster={isAgentProgress(next)}
1731
- trailingAgentText={trailingAgentTextAfterTurn(group, next)}
1732
- contextCompactionCount={
1733
- contextCompactionCount > 0 ? contextCompactionCount : undefined
1734
- }
1735
- />
1736
- </UserMessageDisclosureProvider>
1737
- </TimelineGroupRenderBoundary>
1738
- </div>
1739
- );
1740
- })}
1741
- {groups.length > 0 && trailingState ? (
1742
- <div data-og-timeline-trailing-state="">{trailingState}</div>
1743
- ) : null}
1744
- {hasNewer ? (
1745
- <div
1746
- ref={bottomSentinelRef}
1747
- data-og-bottom-sentinel=""
1748
- data-og-timeline-chrome=""
1749
- aria-hidden="true"
1750
- className="h-px w-full"
1751
- />
1752
- ) : null}
1753
- </div>
1754
- </div>
1755
- <AnimatePresence>
1756
- {loadingOlder ||
1757
- loadingOldest ||
1758
- (hasOlder && onJumpToStart && olderPrefetchArmed) ||
1759
- underfillRetryReady ? (
1760
- // Floating over the scroller (not a timeline row) so showing and
1761
- // hiding it never reflows history under the reader.
1762
- <motion.div
1763
- initial={{ opacity: 0, y: -6 }}
1764
- animate={{ opacity: 1, y: 0 }}
1765
- exit={{ opacity: 0, y: -6 }}
1766
- transition={{ duration: 0.15, ease: "easeOut" }}
1767
- data-og-loading-older=""
1768
- aria-live="polite"
1769
- className="absolute inset-x-0 top-3 z-10 flex justify-center"
2588
+ const timeline = (
2589
+ <AllowancePresentationProvider
2590
+ render={renderAllowanceExhausted}
2591
+ labels={allowanceExhaustedLabels}
2592
+ >
2593
+ <LightboxProvider>
2594
+ <FoldMemoryProvider value={foldMemoryRef.current}>
2595
+ <LiveNoteFoldContext.Provider value={reportLiveFold}>
2596
+ <SeenActivityIdsProvider value={seenActivityIdsRef.current}>
2597
+ <TimelineComputeLabelProvider value={computeLabel ?? null}>
2598
+ <EntranceAnimationProvider value={false}>
2599
+ <TooltipProvider delayDuration={400}>
2600
+ <TimelineAnnotationSourceRootContext.Provider value={scrollRef}>
2601
+ <div
2602
+ className={cn("og-root relative flex min-h-0 flex-col", className)}
2603
+ // Sticky insets start at the scroller's padded content edge
2604
+ // (pt-16). Subtract exactly that padding so an expanded work
2605
+ // header pins flush to the scrollport. Pinning it lower left a
2606
+ // band of scrolling rows visible above the header, which then
2607
+ // looked like it floated over the middle of the timeline.
2608
+ style={{ "--og-work-header-top": "-4rem" } as CSSProperties}
1770
2609
  >
1771
- {loadingOlder || loadingOldest ? (
1772
- <span className={LOADING_CHIP_CLASS}>
1773
- <span className="og-shimmer-text">
1774
- {loadingOldest ? "Jumping to start…" : "Loading earlier activity…"}
1775
- </span>
1776
- </span>
2610
+ {onAnnotate ? (
2611
+ <Suspense fallback={null}>
2612
+ <TimelineAnnotationSelection
2613
+ rootRef={scrollRef}
2614
+ sources={annotationSources}
2615
+ onAnnotate={onAnnotate}
2616
+ />
2617
+ </Suspense>
1777
2618
  ) : null}
1778
- {hasOlder &&
1779
- !loadingOlder &&
1780
- !loadingOldest &&
1781
- (underfillRetryReady || onJumpToStart) ? (
1782
- <button
1783
- type="button"
1784
- data-og-retry={underfillRetryReady || undefined}
1785
- data-og-jump-to-start={!underfillRetryReady || undefined}
1786
- onClick={() => {
1787
- const node = scrollRef.current;
1788
- if (underfillRetryReady) {
1789
- if (node && underfillRetryReadyRef.current) {
1790
- // AnimatePresence retains this handler during
1791
- // exit. Current authorization plus the exact
1792
- // attempt check prevent stale dispatch.
1793
- requestOlderIfUnderfilled(node, underfillSettledAttempt!);
1794
- }
1795
- return;
2619
+ {/* Pinned: anchoring off so the tip-follow camera owns the motion.
2620
+ Unpinned: native scroll anchoring holds the reader's place. */}
2621
+ <div
2622
+ ref={scrollRef}
2623
+ data-og-timeline-scroller=""
2624
+ data-og-bottom-follow={
2625
+ autoFollow && pinned && !hasNewer ? "true" : "false"
2626
+ }
2627
+ tabIndex={-1}
2628
+ onScroll={onScroll}
2629
+ onWheel={onWheel}
2630
+ onTouchStart={(event) => {
2631
+ stopSettlement();
2632
+ const touch = event.touches.length === 1 ? event.touches[0] : undefined;
2633
+ touchPositionRef.current = touch
2634
+ ? { x: touch.clientX, y: touch.clientY }
2635
+ : null;
2636
+ }}
2637
+ onTouchMove={(event) => {
2638
+ const touch = event.touches[0];
2639
+ const previous = touchPositionRef.current;
2640
+ if (!touch || !previous || event.touches.length !== 1) {
2641
+ touchPositionRef.current = null;
2642
+ return;
2643
+ }
2644
+ const deltaX = previous.x - touch.clientX;
2645
+ const deltaY = previous.y - touch.clientY;
2646
+ if (Math.max(Math.abs(deltaX), Math.abs(deltaY)) < 4) return;
2647
+ touchPositionRef.current = { x: touch.clientX, y: touch.clientY };
2648
+ onWheel({
2649
+ deltaX,
2650
+ deltaY,
2651
+ target: event.target,
2652
+ currentTarget: event.currentTarget,
2653
+ });
2654
+ }}
2655
+ onTouchEnd={() => {
2656
+ touchPositionRef.current = null;
2657
+ }}
2658
+ onTouchCancel={() => {
2659
+ touchPositionRef.current = null;
2660
+ }}
2661
+ onClickCapture={(event) => {
2662
+ const target =
2663
+ event.target instanceof Element
2664
+ ? event.target.closest("button[aria-expanded]")
2665
+ : null;
2666
+ if (target) {
2667
+ if (target.hasAttribute("data-og-work-header")) {
2668
+ releaseProgress(
2669
+ target
2670
+ .closest("[data-og-group-key]")
2671
+ ?.getAttribute("data-og-group-key") ?? null,
2672
+ );
1796
2673
  }
1797
- applyPinned(false);
1798
- pendingJumpToStartRef.current = true;
1799
- const seq = ++jumpToStartSeqRef.current;
1800
- void Promise.resolve(onJumpToStart!()).then(
1801
- () => {
1802
- // The commit that swaps in the oldest window consumes
1803
- // the flag against the new DOM; this write covers the
1804
- // already-committed order and the no-window-change
1805
- // case (jumping within the current window).
1806
- const scroller = scrollRef.current ?? node;
1807
- if (scroller) {
1808
- scroller.scrollTop = 0;
1809
- }
1810
- // A host may resolve without ever changing the
1811
- // window (already on the oldest page). Any swap
1812
- // commit runs its layout effect before the next
1813
- // frame, so a flag still armed by then is the
1814
- // no-change case — clear it, or a LATER prepend
1815
- // would spuriously jump the reader to the top.
1816
- requestFrame(() => {
1817
- if (jumpToStartSeqRef.current === seq) {
1818
- pendingJumpToStartRef.current = false;
1819
- }
1820
- });
1821
- },
1822
- () => {
1823
- if (jumpToStartSeqRef.current === seq) {
1824
- pendingJumpToStartRef.current = false;
1825
- }
1826
- },
1827
- );
2674
+ releasePinFromReader();
2675
+ disclosureKeepsUnpinnedRef.current = true;
2676
+ }
2677
+ }}
2678
+ onFocusCapture={(event) => {
2679
+ if (readableTurns && event.target !== event.currentTarget) {
2680
+ stopSettlement();
2681
+ releasePinFromReader();
2682
+ disclosureKeepsUnpinnedRef.current = true;
2683
+ }
2684
+ }}
2685
+ onPointerDown={onPointerDown}
2686
+ onKeyDown={onKeyDown}
2687
+ style={
2688
+ groups.length > 0 && !revealed ? { visibility: "hidden" } : undefined
2689
+ }
2690
+ className={cn(
2691
+ // tabIndex=-1 is programmatic only — never paint a focus ring on
2692
+ // the whole scroller (click + Shift used to flash a blue outline).
2693
+ "min-h-0 flex-1 overflow-y-auto overscroll-contain px-4 pt-16 pb-6 sm:px-6 outline-hidden",
2694
+ autoFollow && pinned && !hasNewer
2695
+ ? "[overflow-anchor:none]"
2696
+ : "[overflow-anchor:auto]",
2697
+ )}
2698
+ >
2699
+ <TimelineBeforeLayout
2700
+ capture={() => {
2701
+ if (readableTurns && timelineHasReader(scrollRef.current)) {
2702
+ releasePinFromReader();
2703
+ }
2704
+ readingAnchorRef.current =
2705
+ !pinnedRef.current && scrollRef.current
2706
+ ? captureTimelineAnchor(scrollRef.current)
2707
+ : null;
2708
+ settlementRef.current =
2709
+ readableTurns &&
2710
+ autoFollow &&
2711
+ pinnedRef.current &&
2712
+ revealedRef.current &&
2713
+ !hasNewer &&
2714
+ !pendingReaderLeaveRef.current &&
2715
+ !disclosureKeepsUnpinnedRef.current &&
2716
+ !prefersReducedMotion() &&
2717
+ scrollRef.current
2718
+ ? captureTimelineSettlement(scrollRef.current, groups)
2719
+ : null;
1828
2720
  }}
1829
- className="rounded-full border border-og-border px-3 py-1.5 text-og-control"
1830
2721
  >
1831
- {underfillRetryReady ? "Retry earlier activity" : "Jump to start"}
1832
- </button>
2722
+ <div className="relative mx-auto flex w-full max-w-3xl flex-col gap-5">
2723
+ <AnimatePresence>
2724
+ {loadingOlder ||
2725
+ loadingOldest ||
2726
+ (hasOlder && onJumpToStart && olderPrefetchArmed) ||
2727
+ underfillRetryReady ? (
2728
+ // Reserved top gutter: controls scroll with history and cannot
2729
+ // cover a disclosure. Visibility never changes content height.
2730
+ <motion.div
2731
+ initial={{ opacity: 0, y: -6 }}
2732
+ animate={{ opacity: 1, y: 0 }}
2733
+ exit={{ opacity: 0, y: -6 }}
2734
+ transition={{ duration: 0.15, ease: "easeOut" }}
2735
+ data-og-loading-older=""
2736
+ aria-live="polite"
2737
+ className="pointer-events-none absolute inset-x-0 -top-11 z-10 flex justify-center"
2738
+ >
2739
+ {loadingOlder || loadingOldest ? (
2740
+ <span className={LOADING_CHIP_CLASS}>
2741
+ <span className="og-shimmer-text">
2742
+ {loadingOldest
2743
+ ? "Jumping to start…"
2744
+ : "Loading earlier activity…"}
2745
+ </span>
2746
+ </span>
2747
+ ) : null}
2748
+ {hasOlder &&
2749
+ !loadingOlder &&
2750
+ !loadingOldest &&
2751
+ (underfillRetryReady || onJumpToStart) ? (
2752
+ <button
2753
+ type="button"
2754
+ data-og-retry={underfillRetryReady || undefined}
2755
+ data-og-jump-to-start={!underfillRetryReady || undefined}
2756
+ onClick={() => {
2757
+ const node = scrollRef.current;
2758
+ if (underfillRetryReady) {
2759
+ if (node && underfillRetryReadyRef.current) {
2760
+ // AnimatePresence retains this handler during
2761
+ // exit. Current authorization plus the exact
2762
+ // attempt check prevent stale dispatch.
2763
+ requestOlderIfUnderfilled(
2764
+ node,
2765
+ underfillSettledAttempt!,
2766
+ );
2767
+ }
2768
+ return;
2769
+ }
2770
+ applyPinned(false);
2771
+ pendingJumpToStartRef.current = true;
2772
+ const seq = ++jumpToStartSeqRef.current;
2773
+ void Promise.resolve(onJumpToStart!()).then(
2774
+ () => {
2775
+ // The commit that swaps in the oldest window consumes
2776
+ // the flag against the new DOM; this write covers the
2777
+ // already-committed order and the no-window-change
2778
+ // case (jumping within the current window).
2779
+ const scroller = scrollRef.current ?? node;
2780
+ if (scroller) {
2781
+ scroller.scrollTop = 0;
2782
+ }
2783
+ // A host may resolve without ever changing the
2784
+ // window (already on the oldest page). Any swap
2785
+ // commit runs its layout effect before the next
2786
+ // frame, so a flag still armed by then is the
2787
+ // no-change case — clear it, or a LATER prepend
2788
+ // would spuriously jump the reader to the top.
2789
+ requestFrame(() => {
2790
+ if (jumpToStartSeqRef.current === seq) {
2791
+ pendingJumpToStartRef.current = false;
2792
+ }
2793
+ });
2794
+ },
2795
+ () => {
2796
+ if (jumpToStartSeqRef.current === seq) {
2797
+ pendingJumpToStartRef.current = false;
2798
+ }
2799
+ },
2800
+ );
2801
+ }}
2802
+ className="pointer-events-auto rounded-full border border-og-border px-3 py-1.5 text-og-control"
2803
+ >
2804
+ {underfillRetryReady
2805
+ ? underfillSettledAttempt?.[2]?.tailPreserved
2806
+ ? "Load earlier activity"
2807
+ : "Retry earlier activity"
2808
+ : "Jump to start"}
2809
+ </button>
2810
+ ) : null}
2811
+ </motion.div>
2812
+ ) : null}
2813
+ </AnimatePresence>
2814
+ {!groups.length
2815
+ ? (emptyState ?? (
2816
+ <p className="py-10 text-center text-og-menu text-og-fg-subtle">
2817
+ No activity yet.
2818
+ </p>
2819
+ ))
2820
+ : null}
2821
+ {hasOlder && olderPrefetchArmed ? (
2822
+ // Overlaid, not a layout row: mounting/unmounting the sentinel
2823
+ // must never shift content (that shift was itself a wobble).
2824
+ // End at the scroll origin above the pt-16 gutter so the
2825
+ // observer and scrollTop cooldown share the same 400px band.
2826
+ <div
2827
+ ref={topSentinelRef}
2828
+ data-og-top-sentinel=""
2829
+ data-og-timeline-chrome=""
2830
+ aria-hidden="true"
2831
+ className="pointer-events-none absolute inset-x-0 -top-16 h-px -translate-y-full"
2832
+ />
2833
+ ) : null}
2834
+ {groups.map(({ group, key, entranceEnabled }, index) => {
2835
+ return (
2836
+ <TimelineSearchRevealContext.Provider
2837
+ key={key}
2838
+ value={
2839
+ searchItem &&
2840
+ timelineGroupItemIds(group).includes(searchItem.id)
2841
+ ? searchRevealKey
2842
+ : null
2843
+ }
2844
+ >
2845
+ <TimelineGroupEntry
2846
+ groupKey={key}
2847
+ group={group}
2848
+ nextGroup={groups[index + 1]?.group}
2849
+ startupDismissed={
2850
+ group.kind === "activity" &&
2851
+ group.items.some(
2852
+ (item) => item.turnId && turnsWithOutput.has(item.turnId),
2853
+ )
2854
+ }
2855
+ entranceEnabled={entranceEnabled}
2856
+ liveEntranceEnabled={
2857
+ group.kind === "activity" ? !bulkRender : undefined
2858
+ }
2859
+ noteFolded={
2860
+ group.kind === "item"
2861
+ ? liveNoteFolds.get(group.item.id)
2862
+ : undefined
2863
+ }
2864
+ context={timelineGroupEntryContext}
2865
+ />
2866
+ </TimelineSearchRevealContext.Provider>
2867
+ );
2868
+ })}
2869
+ {groups.length > 0 && trailingState ? (
2870
+ <div data-og-timeline-trailing-state="">{trailingState}</div>
2871
+ ) : null}
2872
+ {hasNewer && newerFailure?.key === newerBoundaryKey ? (
2873
+ <div
2874
+ data-og-newer-error=""
2875
+ className="flex flex-col items-center gap-2 px-4 py-3 text-center text-og-menu text-og-fg-muted"
2876
+ >
2877
+ <p role="status" className="max-w-prose [overflow-wrap:anywhere]">
2878
+ Couldn’t load later activity. {newerFailure.message}
2879
+ </p>
2880
+ <button
2881
+ ref={newerRetryButtonRef}
2882
+ type="button"
2883
+ data-og-retry-newer=""
2884
+ 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"
2885
+ aria-disabled={loadingNewer || newerRetryPending}
2886
+ aria-busy={newerRetryPending}
2887
+ onClick={() => requestNewer(true)}
2888
+ >
2889
+ {newerRetryPending
2890
+ ? "Retrying later activity…"
2891
+ : "Retry later activity"}
2892
+ </button>
2893
+ </div>
2894
+ ) : null}
2895
+ {hasNewer ? (
2896
+ <div
2897
+ ref={bottomSentinelRef}
2898
+ data-og-bottom-sentinel=""
2899
+ data-og-timeline-chrome=""
2900
+ aria-hidden="true"
2901
+ className="h-px w-full"
2902
+ />
2903
+ ) : null}
2904
+ </div>
2905
+ </TimelineBeforeLayout>
2906
+ </div>
2907
+ {draftAnnotations && draftAnnotations.length > 0 ? (
2908
+ <Suspense fallback={null}>
2909
+ <TimelineAnnotationMarkers
2910
+ rootRef={scrollRef}
2911
+ annotations={draftAnnotations}
2912
+ onSelect={onDraftAnnotationSelect}
2913
+ />
2914
+ </Suspense>
1833
2915
  ) : null}
1834
- </motion.div>
1835
- ) : null}
1836
- </AnimatePresence>
1837
- <AnimatePresence>
1838
- {loadingNewer ? (
1839
- <motion.div
1840
- initial={{ opacity: 0, y: 6 }}
1841
- animate={{ opacity: 1, y: 0 }}
1842
- exit={{ opacity: 0, y: 6 }}
1843
- transition={{ duration: 0.15, ease: "easeOut" }}
1844
- data-og-loading-newer=""
1845
- aria-live="polite"
1846
- className="pointer-events-none absolute inset-x-0 bottom-14 z-10 flex justify-center"
1847
- >
1848
- <span className={LOADING_CHIP_CLASS}>
1849
- <span className="og-shimmer-text">Loading later activity…</span>
1850
- </span>
1851
- </motion.div>
1852
- ) : null}
1853
- </AnimatePresence>
1854
- <AnimatePresence>
1855
- {((!pinned && autoFollow) || hasNewer || canSkipTipCatchup) && autoFollow ? (
1856
- <motion.button
1857
- type="button"
1858
- data-og-jump-to-latest=""
1859
- initial={{ opacity: 0, y: 8 }}
1860
- animate={{ opacity: 1, y: 0 }}
1861
- exit={{ opacity: 0, y: 8 }}
1862
- transition={{ duration: 0.15, ease: "easeOut" }}
1863
- onClick={() => {
1864
- disclosureKeepsUnpinnedRef.current = false;
1865
- if (hasNewer) {
1866
- // Do not pin against the current history page — its bottom
1867
- // is not the tip. The pin + snap run when the tip window
1868
- // actually lands (`hasNewer` flips false).
1869
- wantPinRef.current = true;
1870
- const node = scrollRef.current;
1871
- if (onJumpToLatest) {
1872
- void Promise.resolve(onJumpToLatest()).then(
1873
- () => {
1874
- // Covers a host that flipped hasNewer before
1875
- // resolving; otherwise the tip-window commit
1876
- // consumes the flag.
1877
- const current = scrollRef.current;
1878
- if (current && wantPinRef.current && !hasNewerRef.current) {
1879
- wantPinRef.current = false;
2916
+
2917
+ <div
2918
+ data-og-timeline-nav="top"
2919
+ className={cn(
2920
+ "pointer-events-none absolute inset-x-0 z-10 flex justify-center",
2921
+ questionNav?.belowHeader ? "top-10 pointer-coarse:top-13" : "top-3",
2922
+ )}
2923
+ >
2924
+ <AnimatePresence>
2925
+ {questionNav ? (
2926
+ <motion.button
2927
+ key="question-nav"
2928
+ {...NAV_FADE}
2929
+ type="button"
2930
+ data-og-question-nav=""
2931
+ data-og-jump-to-question=""
2932
+ title="Back to your message"
2933
+ onClick={() => jumpToQuestion(questionNav.key)}
2934
+ className={NAV_BUTTON_CLASS}
2935
+ >
2936
+ <ArrowUpIcon aria-hidden className="size-4" />
2937
+ <span className="sr-only">Back to your message</span>
2938
+ </motion.button>
2939
+ ) : null}
2940
+ </AnimatePresence>
2941
+ </div>
2942
+ <div
2943
+ data-og-timeline-nav="bottom"
2944
+ className="pointer-events-none absolute inset-x-0 bottom-3 z-10 flex flex-col items-center gap-2"
2945
+ >
2946
+ <AnimatePresence>
2947
+ {loadingNewer ? (
2948
+ <motion.div
2949
+ key="loading-newer"
2950
+ {...NAV_FADE}
2951
+ data-og-loading-newer=""
2952
+ aria-live="polite"
2953
+ className="flex max-w-full justify-center"
2954
+ >
2955
+ <span className={LOADING_CHIP_CLASS}>
2956
+ <span className="og-shimmer-text">Loading later activity…</span>
2957
+ </span>
2958
+ </motion.div>
2959
+ ) : null}
2960
+ {jumpPillShown ? (
2961
+ <motion.button
2962
+ key="jump-to-latest"
2963
+ {...NAV_FADE}
2964
+ type="button"
2965
+ data-og-jump-to-latest=""
2966
+ title="Jump to latest"
2967
+ onClick={() => {
2968
+ // Returning to the tip explicitly releases reader-owned
2969
+ // prose. Clear only this timeline's selection before the
2970
+ // synchronous ownership check on the next commit.
2971
+ const viewport = scrollRef.current;
2972
+ const selection = viewport?.ownerDocument.getSelection();
2973
+ if (
2974
+ selection &&
2975
+ !selection.isCollapsed &&
2976
+ (viewport?.contains(selection.anchorNode) ||
2977
+ viewport?.contains(selection.focusNode))
2978
+ )
2979
+ selection.removeAllRanges();
2980
+ disclosureKeepsUnpinnedRef.current = false;
2981
+ if (hasNewer) {
2982
+ // Do not pin against the current history page — its bottom
2983
+ // is not the tip. The pin + snap run when the tip window
2984
+ // actually lands (`hasNewer` flips false).
2985
+ wantPinRef.current = true;
2986
+ const node = scrollRef.current;
2987
+ if (onJumpToLatest) {
2988
+ void Promise.resolve(onJumpToLatest()).then(
2989
+ () => {
2990
+ // Covers a host that flipped hasNewer before
2991
+ // resolving; otherwise the tip-window commit
2992
+ // consumes the flag.
2993
+ const current = scrollRef.current;
2994
+ if (
2995
+ current &&
2996
+ wantPinRef.current &&
2997
+ !hasNewerRef.current
2998
+ ) {
2999
+ wantPinRef.current = false;
3000
+ applyPinned(true);
3001
+ snapToBottom(current);
3002
+ }
3003
+ },
3004
+ () => {
3005
+ // The tip reload failed (ordinary network error):
3006
+ // an armed latch would fire a surprise snap when
3007
+ // the reader later pages to the tip themselves.
3008
+ wantPinRef.current = false;
3009
+ },
3010
+ );
3011
+ } else if (node) {
3012
+ // No tip reload available: jump within the in-memory
3013
+ // window so the newer sentinel can page forward; the
3014
+ // latch pins if the tip window eventually lands.
3015
+ snapToBottom(node);
3016
+ }
3017
+ return;
3018
+ }
3019
+ const node = scrollRef.current;
3020
+ if (node) {
1880
3021
  applyPinned(true);
1881
- snapToBottom(current);
3022
+ snapToBottom(node);
1882
3023
  }
1883
- },
1884
- () => {
1885
- // The tip reload failed (ordinary network error):
1886
- // an armed latch would fire a surprise snap when
1887
- // the reader later pages to the tip themselves.
1888
- wantPinRef.current = false;
1889
- },
1890
- );
1891
- } else if (node) {
1892
- // No tip reload available: jump within the in-memory
1893
- // window so the newer sentinel can page forward; the
1894
- // latch pins if the tip window eventually lands.
1895
- snapToBottom(node);
1896
- }
1897
- return;
1898
- }
1899
- const node = scrollRef.current;
1900
- if (node) {
1901
- applyPinned(true);
1902
- snapToBottom(node);
1903
- }
1904
- }}
1905
- className={cn(
1906
- "absolute inset-x-0 bottom-4 mx-auto w-fit",
1907
- "inline-flex items-center gap-1.5 rounded-full border border-og-border bg-og-surface-3/90 px-3 py-1.5",
1908
- "text-og-control font-medium text-og-fg shadow-og-md backdrop-blur",
1909
- "hover:border-og-border-strong",
1910
- )}
1911
- >
1912
- <ArrowDownIcon className="size-3.5" />
1913
- Jump to latest
1914
- </motion.button>
1915
- ) : null}
1916
- </AnimatePresence>
1917
- </div>
1918
- </TooltipProvider>
1919
- </EntranceAnimationProvider>
1920
- </TimelineComputeLabelProvider>
1921
- </SeenActivityIdsProvider>
1922
- </FoldMemoryProvider>
1923
- </LightboxProvider>
3024
+ }}
3025
+ className={NAV_BUTTON_CLASS}
3026
+ >
3027
+ <ArrowDownIcon aria-hidden className="size-4" />
3028
+ <span className="sr-only">Jump to latest</span>
3029
+ </motion.button>
3030
+ ) : null}
3031
+ </AnimatePresence>
3032
+ </div>
3033
+ </div>
3034
+ </TimelineAnnotationSourceRootContext.Provider>
3035
+ </TooltipProvider>
3036
+ </EntranceAnimationProvider>
3037
+ </TimelineComputeLabelProvider>
3038
+ </SeenActivityIdsProvider>
3039
+ </LiveNoteFoldContext.Provider>
3040
+ </FoldMemoryProvider>
3041
+ </LightboxProvider>
3042
+ </AllowancePresentationProvider>
3043
+ );
3044
+ // Agent-authored object links resolve through the host, never the console.
3045
+ return (
3046
+ <OpenGeniLinkProvider resolveLink={resolveLink}>
3047
+ <MessageSenderContext.Provider value={renderMessageSender}>
3048
+ <AgentIdentityProvider
3049
+ items={resolvedItems}
3050
+ resolveSessionTitle={resolveSessionTitle}
3051
+ onOpenSession={onOpenSession}
3052
+ >
3053
+ <ProviderLogoProvider value={resolveProviderLogo}>{timeline}</ProviderLogoProvider>
3054
+ </AgentIdentityProvider>
3055
+ </MessageSenderContext.Provider>
3056
+ </OpenGeniLinkProvider>
1924
3057
  );
1925
3058
  }
1926
3059
 
1927
3060
  type KeyedTimelineGroup = {
1928
3061
  group: TimelineGroup;
1929
3062
  key: string;
3063
+ entranceEnabled: boolean;
1930
3064
  };
1931
3065
 
3066
+ function timelineGroupsRenderEqual(previous: TimelineGroup, next: TimelineGroup): boolean {
3067
+ try {
3068
+ return dequal(previous, next);
3069
+ } catch {
3070
+ // Consumer-owned `unknown` payloads may be proxies/getters. Equality is an
3071
+ // optimization only; a hostile comparator surface must fall back to the
3072
+ // authoritative new group rather than taking down the timeline render.
3073
+ return false;
3074
+ }
3075
+ }
3076
+
1932
3077
  /**
1933
3078
  * Projection can legitimately change a group's content-derived key while
1934
3079
  * retaining its existing rows. The common pagination case is an older activity
@@ -1937,42 +3082,73 @@ type KeyedTimelineGroup = {
1937
3082
  * committed groups by their durable item IDs so both the React key and the
1938
3083
  * progressive-window anchor survive either change.
1939
3084
  */
1940
- function useStableTimelineGroupKeys(allGroups: TimelineGroup[]): KeyedTimelineGroup[] {
3085
+ function useStableTimelineGroupKeys(
3086
+ allGroups: TimelineGroup[],
3087
+ entranceEnabled: boolean,
3088
+ ): KeyedTimelineGroup[] {
1941
3089
  const previousRef = useRef<KeyedTimelineGroup[]>([]);
1942
3090
  const keyedGroups = useMemo(() => {
1943
3091
  const previousByItemId = new Map<string, KeyedTimelineGroup>();
1944
3092
  for (const previous of previousRef.current) {
1945
3093
  for (const itemId of timelineGroupItemIds(previous.group)) {
1946
- previousByItemId.set(itemId, previous);
3094
+ // A live progress note is both its own row and listed in its work
3095
+ // history. Its own row owns the id, so the note keeps its identity.
3096
+ if (previous.group.kind === "item" || !previousByItemId.has(itemId)) {
3097
+ previousByItemId.set(itemId, previous);
3098
+ }
1947
3099
  }
1948
3100
  }
1949
3101
 
1950
3102
  const usedKeys = new Set<string>();
1951
3103
  return allGroups.map((group, index) => {
1952
3104
  const itemIds = timelineGroupItemIds(group);
1953
- let retainedKey: string | undefined;
3105
+ let retainedGroup: KeyedTimelineGroup | undefined;
1954
3106
  for (const itemId of itemIds) {
1955
3107
  const previous = previousByItemId.get(itemId);
1956
3108
  // Retain only same-kind matches. Activity → turn wrap must NOT keep the
1957
3109
  // activity chip's React key: that reused a collapsed TurnSummary and
1958
3110
  // skipped the settle beat (insta-collapse / content flash).
1959
- if (previous && previous.group.kind === group.kind && !usedKeys.has(previous.key)) {
1960
- retainedKey = previous.key;
3111
+ const startupCompletion =
3112
+ previous?.group.kind === "activity" &&
3113
+ previous.group.items.every(
3114
+ (item) =>
3115
+ item.kind === "startup-phase" || (item.kind === "reasoning" && !item.text.trim()),
3116
+ ) &&
3117
+ group.kind === "turn" &&
3118
+ group.outcome === "complete" &&
3119
+ group.groups.every(
3120
+ (child) =>
3121
+ child.kind === "activity" &&
3122
+ child.items.every((item) => item.kind === "startup-phase"),
3123
+ );
3124
+ if (
3125
+ previous &&
3126
+ (previous.group.kind === group.kind || startupCompletion) &&
3127
+ !usedKeys.has(previous.key)
3128
+ ) {
3129
+ retainedGroup = previous;
1961
3130
  break;
1962
3131
  }
1963
3132
  }
1964
3133
 
1965
3134
  const canonicalKey = timelineGroupKey(group);
1966
- let key = retainedKey ?? canonicalKey;
3135
+ let key = retainedGroup?.key ?? canonicalKey;
1967
3136
  let collision = 0;
1968
3137
  while (usedKeys.has(key)) {
1969
3138
  key = `${canonicalKey}:${index}:${collision}`;
1970
3139
  collision += 1;
1971
3140
  }
1972
3141
  usedKeys.add(key);
1973
- return { group, key };
3142
+ return {
3143
+ group:
3144
+ retainedGroup && timelineGroupsRenderEqual(retainedGroup.group, group)
3145
+ ? retainedGroup.group
3146
+ : group,
3147
+ key,
3148
+ entranceEnabled: retainedGroup?.entranceEnabled ?? entranceEnabled,
3149
+ };
1974
3150
  });
1975
- }, [allGroups]);
3151
+ }, [allGroups, entranceEnabled]);
1976
3152
 
1977
3153
  useLayoutEffect(() => {
1978
3154
  previousRef.current = keyedGroups;
@@ -2065,17 +3241,155 @@ class TimelineGroupRenderBoundary extends Component<
2065
3241
  }
2066
3242
  }
2067
3243
 
3244
+ type TimelineGroupEntryProps = {
3245
+ groupKey: string;
3246
+ group: TimelineGroup;
3247
+ nextGroup?: TimelineGroup | undefined;
3248
+ startupDismissed: boolean;
3249
+ entranceEnabled: boolean;
3250
+ liveEntranceEnabled?: boolean | undefined;
3251
+ /**
3252
+ * Present for a live progress note: true while its turn's work disclosure is
3253
+ * open (the note then reads inside it), false while it reads here.
3254
+ */
3255
+ noteFolded?: boolean | undefined;
3256
+ context: TimelineGroupEntryContext;
3257
+ };
3258
+
3259
+ type TimelineGroupEntryContext = {
3260
+ userMessageDisclosureContext: UserMessageDisclosureContextValue;
3261
+ behavior: TimelineGroupBehaviorProps;
3262
+ };
3263
+
3264
+ type TimelineGroupBehaviorProps = {
3265
+ renderMessageActions: MessageTimelineProps["renderMessageActions"];
3266
+ renderMessageText:
3267
+ | ((text: string, item: AgentMessageItem | UserMessageItem) => ReactNode)
3268
+ | undefined;
3269
+ onOpenSession: MessageTimelineProps["onOpenSession"];
3270
+ onMemoryClick: MessageTimelineProps["onMemoryClick"];
3271
+ onReconnect: MessageTimelineProps["onReconnect"];
3272
+ renderAuthNeeded: MessageTimelineProps["renderAuthNeeded"];
3273
+ resolveProviderLogo: MessageTimelineProps["resolveProviderLogo"];
3274
+ toolRegistry: ToolRegistry;
3275
+ loadRetainedScreenshot: MessageTimelineProps["loadRetainedScreenshot"];
3276
+ loadRetainedArtifact: MessageTimelineProps["loadRetainedArtifact"];
3277
+ loadVideoArtifactPlayback: MessageTimelineProps["loadVideoArtifactPlayback"];
3278
+ genieLoading: MessageTimelineProps["genieLoading"];
3279
+ turnSummary: MessageTimelineProps["turnSummary"];
3280
+ };
3281
+
3282
+ /**
3283
+ * Keep the complete settled-row shell behind one shallow memo boundary. A
3284
+ * prepend still reconciles the keyed list, but render-equivalent suffix groups
3285
+ * skip their providers, error boundaries, disclosure wrappers, and row trees.
3286
+ */
3287
+ const TimelineGroupEntry = memo(function TimelineGroupEntry({
3288
+ groupKey,
3289
+ group,
3290
+ nextGroup,
3291
+ startupDismissed,
3292
+ entranceEnabled,
3293
+ liveEntranceEnabled,
3294
+ noteFolded,
3295
+ context,
3296
+ }: TimelineGroupEntryProps) {
3297
+ const reducedMotion = useReducedMotion();
3298
+ const { behavior, userMessageDisclosureContext } = context;
3299
+ const contextCompactionCount =
3300
+ group.kind === "turn"
3301
+ ? (group.contextCompactionCount ?? 0)
3302
+ : group.kind === "activity" &&
3303
+ nextGroup?.kind === "item" &&
3304
+ nextGroup.item.kind === "context-compaction" &&
3305
+ nextGroup.item.phase === "compacted"
3306
+ ? 1
3307
+ : 0;
3308
+ const content = (
3309
+ <ActivityNoteTextContext.Provider value={behavior.renderMessageText ?? null}>
3310
+ <TimelineGroupView
3311
+ {...behavior}
3312
+ group={group}
3313
+ foldLiveCluster={isAgentProgress(nextGroup)}
3314
+ startupDismissed={startupDismissed}
3315
+ trailingAgentText={trailingAgentTextAfterTurn(group, nextGroup)}
3316
+ contextCompactionCount={contextCompactionCount > 0 ? contextCompactionCount : undefined}
3317
+ />
3318
+ </ActivityNoteTextContext.Provider>
3319
+ );
3320
+ return (
3321
+ <GenieLoadingOptionsContext.Provider value={behavior.genieLoading}>
3322
+ <div
3323
+ data-og-timeline-group-anchor=""
3324
+ data-og-group-key={groupKey}
3325
+ data-og-prompt={
3326
+ group.kind === "item" && group.item.kind === "user-message" ? "" : undefined
3327
+ }
3328
+ tabIndex={group.kind === "item" && group.item.kind === "user-message" ? -1 : undefined}
3329
+ // A live progress note reads inside its open work row instead; folded,
3330
+ // it takes no space and also gives back the list's gap-5 above it.
3331
+ data-og-live-note={noteFolded === undefined ? undefined : noteFolded ? "folded" : "shown"}
3332
+ data-og-live-note-id={
3333
+ noteFolded !== undefined && group.kind === "item" ? group.item.id : undefined
3334
+ }
3335
+ aria-hidden={noteFolded || undefined}
3336
+ inert={noteFolded || undefined}
3337
+ className={
3338
+ noteFolded ? "-mt-5 h-0 overflow-hidden opacity-0 [overflow-anchor:none]" : undefined
3339
+ }
3340
+ >
3341
+ <EntranceAnimationProvider value={entranceEnabled} liveValue={liveEntranceEnabled}>
3342
+ <TimelineGroupRenderBoundary resetKeys={[group, behavior]}>
3343
+ <UserMessageDisclosureProvider value={userMessageDisclosureContext}>
3344
+ {/* Item groups never switch the preparation/content key. Keep their
3345
+ DOM shell without mounting inert presence and motion lifecycles
3346
+ for every historical message in a prepend. */}
3347
+ {group.kind === "item" ? (
3348
+ <div>{content}</div>
3349
+ ) : (
3350
+ <AnimatePresence initial={false}>
3351
+ <motion.div
3352
+ key={
3353
+ !startupDismissed &&
3354
+ group.kind === "activity" &&
3355
+ !group.work &&
3356
+ group.items.every(
3357
+ (item) =>
3358
+ item.kind === "startup-phase" ||
3359
+ (item.kind === "reasoning" && !item.text.trim()),
3360
+ )
3361
+ ? "preparation"
3362
+ : "content"
3363
+ }
3364
+ initial={false}
3365
+ exit={{ opacity: 0, height: 0 }}
3366
+ transition={{ duration: reducedMotion ? 0 : 0.2 }}
3367
+ >
3368
+ {content}
3369
+ </motion.div>
3370
+ </AnimatePresence>
3371
+ )}
3372
+ </UserMessageDisclosureProvider>
3373
+ </TimelineGroupRenderBoundary>
3374
+ </EntranceAnimationProvider>
3375
+ </div>
3376
+ </GenieLoadingOptionsContext.Provider>
3377
+ );
3378
+ });
3379
+
2068
3380
  // The full loaded window stays mounted, so settled history rows must be cheap
2069
- // on every commit: projection reuses group objects for unchanged groups, so
2070
- // memo skips them. Live projection creates a new group object, and
2071
- // behavior/callback changes are separate props, so ordinary streaming and host
2072
- // updates still invalidate immediately.
3381
+ // on every commit: the stable-key projection reuses render-equivalent group
3382
+ // objects, so memo skips them. Changed projection content receives a new group
3383
+ // object, and behavior/callback changes are separate props, so ordinary
3384
+ // streaming and host updates still invalidate immediately.
2073
3385
  const TimelineGroupView = memo(function TimelineGroupView({
2074
3386
  group,
3387
+ renderMessageActions,
2075
3388
  renderMessageText,
2076
3389
  onOpenSession,
2077
3390
  onMemoryClick,
2078
3391
  onReconnect,
3392
+ renderAuthNeeded,
2079
3393
  resolveProviderLogo,
2080
3394
  toolRegistry,
2081
3395
  loadRetainedScreenshot,
@@ -2085,16 +3399,20 @@ const TimelineGroupView = memo(function TimelineGroupView({
2085
3399
  insideTurn = false,
2086
3400
  nestClusterChips = false,
2087
3401
  foldLiveCluster = false,
3402
+ startupDismissed = false,
2088
3403
  trailingAgentText,
2089
3404
  contextCompactionCount,
2090
3405
  }: {
2091
3406
  group: TimelineGroup;
3407
+ renderMessageActions?: ((item: AgentMessageItem | UserMessageItem) => ReactNode) | undefined;
2092
3408
  renderMessageText?:
2093
3409
  | ((text: string, item: AgentMessageItem | UserMessageItem) => ReactNode)
2094
3410
  | undefined;
2095
3411
  onOpenSession?: ((sessionId: string) => void) | undefined;
2096
3412
  onMemoryClick?: ((memoryId: string) => void) | undefined;
2097
3413
  onReconnect?: ((item: AuthNeededItem) => void | Promise<void>) | undefined;
3414
+ /** Host-owned inline connection setup. Return undefined to use the default recovery card. */
3415
+ renderAuthNeeded?: ((item: AuthNeededItem) => ReactNode | undefined) | undefined;
2098
3416
  resolveProviderLogo?: ((providerDomain: string) => string | null | undefined) | undefined;
2099
3417
  toolRegistry: ToolRegistry;
2100
3418
  loadRetainedScreenshot?: RetainedScreenshotLoader | undefined;
@@ -2105,6 +3423,7 @@ const TimelineGroupView = memo(function TimelineGroupView({
2105
3423
  behind a neutral chip — the one place activity without an outcome still
2106
3424
  folds, bounding the DOM of days-long autonomous turns. */
2107
3425
  foldLiveCluster?: boolean;
3426
+ startupDismissed?: boolean;
2108
3427
  /** Rendering inside an expanded turn group: the outer chip already owns the
2109
3428
  failure surface, so nested chips stay tinted but quiet (no repeated
2110
3429
  failure text, no auto-open) — one loud error, N calm sub-expands. */
@@ -2125,15 +3444,29 @@ const TimelineGroupView = memo(function TimelineGroupView({
2125
3444
  /** Secondary chip facet when this fold sits next to a compaction landmark. */
2126
3445
  contextCompactionCount?: number | undefined;
2127
3446
  }) {
3447
+ const startupDetails = useStartupDetails();
2128
3448
  const enter = useEntranceAnimation();
2129
3449
  const settleChrome = useTurnSettleOpen();
3450
+ const previousSingleActivity = useRef<ActivityItem | undefined>(undefined);
3451
+ useLayoutEffect(() => {
3452
+ const work =
3453
+ group.kind === "activity" ? group.items.filter((item) => item.kind !== "startup-phase") : [];
3454
+ previousSingleActivity.current = work.length === 1 ? work[0] : undefined;
3455
+ }, [group]);
2130
3456
  const foldMemory = useFoldMemory();
3457
+ const reportLiveFold = useContext(LiveNoteFoldContext);
2131
3458
  // Settled (or live-fold) activity clusters get a chip. Inside an expanded
2132
3459
  // turn that is the second layer — quiet nested chips under the outer turn
2133
3460
  // summary when contiguous activity naturally clusters (≥2 only).
3461
+ const containsPresentedImage = timelineGroupContainsPresentedImage(group);
3462
+ const hasRememberedImageFold =
3463
+ group.kind === "activity" && containsPresentedImage && foldMemory?.has(group.id);
3464
+ // Primary images stay visible through live narration and the turn wrap.
3465
+ // Manual collapse still belongs to TurnSummary's existing fold memory.
2134
3466
  const activityShouldFold =
2135
- group.kind === "activity" && !!(group.outcome || (foldLiveCluster && clusterIsSettled(group)));
2136
- const containsGeneratedImage = timelineGroupContainsGeneratedImage(group);
3467
+ group.kind === "activity" &&
3468
+ !containsPresentedImage &&
3469
+ !!(group.outcome || (foldLiveCluster && clusterIsSettled(group)));
2137
3470
  // Latch live→folded so a top-level shell that was already mounted open can
2138
3471
  // start the settle beat without remounting bare rail → wrapper.
2139
3472
  const liveActivitySettle = useLiveSettleFold(activityShouldFold && !insideTurn);
@@ -2142,7 +3475,7 @@ const TimelineGroupView = memo(function TimelineGroupView({
2142
3475
  group.kind === "turn" &&
2143
3476
  (group.outcome === "failed" ||
2144
3477
  timelineGroupContainsAuthNeeded(group) ||
2145
- containsGeneratedImage);
3478
+ containsPresentedImage);
2146
3479
  // activity-* → turn-* remount: carry resting state so settleFold does not
2147
3480
  // re-open a chip the reader already watched collapse.
2148
3481
  if (group.kind === "turn" && foldMemory && !insideTurn) {
@@ -2153,9 +3486,167 @@ const TimelineGroupView = memo(function TimelineGroupView({
2153
3486
  );
2154
3487
  }
2155
3488
  const settleFold =
2156
- group.kind === "turn" ? !!(enter && !insideTurn && !turnDefaultOpen) : liveActivitySettle;
3489
+ !turnSummary?.rolling &&
3490
+ (group.kind === "turn" ? !!(enter && !insideTurn && !turnDefaultOpen) : liveActivitySettle);
2157
3491
  switch (group.kind) {
2158
3492
  case "activity":
3493
+ if (group.work) {
3494
+ const preparing = isPreparingWork(group, { startupDetails, startupDismissed });
3495
+ if (preparing) {
3496
+ // Preparation is primary, never hidden in an activity disclosure.
3497
+ // Keep work.startedAt unchanged for the handoff and settled duration.
3498
+ return (
3499
+ <ActivityRail
3500
+ items={group.items}
3501
+ startupActive
3502
+ bare
3503
+ toolRegistry={toolRegistry}
3504
+ onOpenSession={onOpenSession}
3505
+ onMemoryClick={onMemoryClick}
3506
+ loadRetainedScreenshot={loadRetainedScreenshot}
3507
+ loadRetainedArtifact={loadRetainedArtifact}
3508
+ />
3509
+ );
3510
+ }
3511
+ const workStatus = readableWorkStatus({ ...group, work: group.work });
3512
+ const status: TurnSummaryStatus =
3513
+ workStatus.kind === "working"
3514
+ ? {
3515
+ ...workStatus,
3516
+ preview: readableWorkShowsPreview(group) ? (
3517
+ <RollingActivity
3518
+ items={group.items}
3519
+ toolRegistry={toolRegistry}
3520
+ showCount={false}
3521
+ />
3522
+ ) : undefined,
3523
+ }
3524
+ : workStatus.kind === "waiting"
3525
+ ? {
3526
+ kind: "waiting",
3527
+ label: workStatus.label,
3528
+ since: workStatus.since,
3529
+ // One quiet line under the row saying what the wait needs,
3530
+ // kept visible when the work is expanded; never a second
3531
+ // warning banner.
3532
+ guidance: workStatus.detail ? (
3533
+ <span
3534
+ data-og-exchange-wait-detail=""
3535
+ className="block text-og-sm text-og-fg-subtle"
3536
+ >
3537
+ {workStatus.detail}
3538
+ </span>
3539
+ ) : undefined,
3540
+ }
3541
+ : group.work.cycles?.summary
3542
+ ? {
3543
+ ...workStatus,
3544
+ // Folded quiet cycles say what the agent is waiting on.
3545
+ preview: (
3546
+ <p
3547
+ data-og-cycles-summary=""
3548
+ className="truncate text-og-sm text-og-fg-subtle"
3549
+ title={group.work.cycles.summary}
3550
+ >
3551
+ {group.work.cycles.summary}
3552
+ </p>
3553
+ ),
3554
+ }
3555
+ : workStatus;
3556
+ return (
3557
+ // Marks this work row for the live-note fold (list keys can be carried
3558
+ // over from an earlier row, so they are not the work id).
3559
+ <div className="contents" data-og-work-id={group.id}>
3560
+ <TurnSummary
3561
+ key="work"
3562
+ items={group.items}
3563
+ bare={insideTurn || undefined}
3564
+ status={status}
3565
+ // A wait before any visible work has nothing to reveal.
3566
+ expandable={readableWorkHasDetails(
3567
+ { ...group, work: group.work },
3568
+ { startupDetails },
3569
+ )}
3570
+ outcome={group.outcome}
3571
+ failureText={group.failureText}
3572
+ foldKey={group.id}
3573
+ defaultOpen={readableWorkDefaultOpen(group)}
3574
+ facets={turnSummary?.facets}
3575
+ contextCompactionCount={compactedLandmarkCount(group.work.details)}
3576
+ onOpenStateChange={
3577
+ group.work.liveNoteIds?.length && reportLiveFold
3578
+ ? (open) => reportLiveFold(group.id, open)
3579
+ : undefined
3580
+ }
3581
+ >
3582
+ <TurnRailFrame compact>
3583
+ {renderFoldedGroups(
3584
+ group.work.details,
3585
+ {
3586
+ renderMessageActions,
3587
+ renderMessageText,
3588
+ onOpenSession,
3589
+ onMemoryClick,
3590
+ onReconnect,
3591
+ renderAuthNeeded,
3592
+ resolveProviderLogo,
3593
+ toolRegistry,
3594
+ loadRetainedScreenshot,
3595
+ loadRetainedArtifact,
3596
+ loadVideoArtifactPlayback,
3597
+ turnSummary,
3598
+ },
3599
+ true,
3600
+ )}
3601
+ </TurnRailFrame>
3602
+ </TurnSummary>
3603
+ </div>
3604
+ );
3605
+ }
3606
+ // Preparation is one quiet surface, not a fold with eight technical steps.
3607
+ // A later turn of the same exchange keeps its status row instead.
3608
+ if (
3609
+ !startupDetails &&
3610
+ !insideTurn &&
3611
+ !group.outcome &&
3612
+ group.items.every(
3613
+ (item) =>
3614
+ item.kind === "startup-phase" || (item.kind === "reasoning" && !item.text.trim()),
3615
+ )
3616
+ ) {
3617
+ return (
3618
+ <ActivityRail
3619
+ items={group.items}
3620
+ startupActive={!startupDismissed && !foldLiveCluster}
3621
+ bare
3622
+ toolRegistry={toolRegistry}
3623
+ onOpenSession={onOpenSession}
3624
+ onMemoryClick={onMemoryClick}
3625
+ loadRetainedScreenshot={loadRetainedScreenshot}
3626
+ loadRetainedArtifact={loadRetainedArtifact}
3627
+ />
3628
+ );
3629
+ }
3630
+ if (
3631
+ turnSummary?.rolling &&
3632
+ insideTurn &&
3633
+ !startupDetails &&
3634
+ !hasRememberedImageFold &&
3635
+ group.items.filter((item) => item.kind !== "startup-phase").length === 1
3636
+ ) {
3637
+ return (
3638
+ <ActivityRail
3639
+ items={group.items}
3640
+ startupActive={false}
3641
+ bare
3642
+ toolRegistry={toolRegistry}
3643
+ onOpenSession={onOpenSession}
3644
+ onMemoryClick={onMemoryClick}
3645
+ loadRetainedScreenshot={loadRetainedScreenshot}
3646
+ loadRetainedArtifact={loadRetainedArtifact}
3647
+ />
3648
+ );
3649
+ }
2159
3650
  if (insideTurn) {
2160
3651
  // Nested chips whenever the parent has ≥2 clusters. During outer settle
2161
3652
  // chrome they stay force-open so structure is visible and height stays
@@ -2167,11 +3658,22 @@ const TimelineGroupView = memo(function TimelineGroupView({
2167
3658
  // settled closed pre-wrap was showing as a CHIP, so mounting it closed
2168
3659
  // is both the stable height and the honest state — force-opening it
2169
3660
  // was the "already-collapsed cluster auto-expands at the end" reopen.
2170
- const useNestedChip = nestClusterChips && activityShouldFold && !containsGeneratedImage;
3661
+ const visibleActivity = group.items.filter((item) => item.kind !== "startup-phase");
3662
+ const singleThought =
3663
+ !startupDetails &&
3664
+ visibleActivity.length === 1 &&
3665
+ visibleActivity[0]?.kind === "reasoning";
3666
+ // Untouched images stay primary output, but a reader-owned image fold
3667
+ // must keep its shell across a multi-cluster wrap. A bare rail would
3668
+ // bypass the remembered choice; retaining either state also lets the
3669
+ // reader reopen and close the same chip after settlement.
3670
+ const useNestedChip =
3671
+ nestClusterChips && (activityShouldFold || hasRememberedImageFold) && !singleThought;
2171
3672
  if (!useNestedChip) {
2172
3673
  return (
2173
3674
  <ActivityRail
2174
3675
  items={group.items}
3676
+ {...(startupDismissed ? { startupActive: false } : {})}
2175
3677
  onOpenSession={onOpenSession}
2176
3678
  onMemoryClick={onMemoryClick}
2177
3679
  toolRegistry={toolRegistry}
@@ -2209,10 +3711,31 @@ const TimelineGroupView = memo(function TimelineGroupView({
2209
3711
  // flips settleFold (collapse) instead of remounting bare rail → wrapper.
2210
3712
  return (
2211
3713
  <TurnSummary
3714
+ // Apply the primary-output default when a rolling activity first
3715
+ // produces an image; the stable foldKey still preserves user choices.
3716
+ key={containsPresentedImage ? "primary-image" : "activity"}
2212
3717
  items={group.items}
2213
3718
  outcome={group.outcome}
2214
3719
  failureText={group.failureText}
2215
- defaultOpen={!activityShouldFold || group.outcome === "failed" ? true : undefined}
3720
+ defaultOpen={
3721
+ group.outcome === "failed" ||
3722
+ containsPresentedImage ||
3723
+ (!turnSummary?.rolling && !activityShouldFold)
3724
+ ? true
3725
+ : undefined
3726
+ }
3727
+ liveHeader={
3728
+ turnSummary?.rolling &&
3729
+ !containsPresentedImage &&
3730
+ !group.outcome &&
3731
+ !foldLiveCluster ? (
3732
+ <RollingActivity
3733
+ items={group.items}
3734
+ toolRegistry={toolRegistry}
3735
+ previousItem={previousSingleActivity.current}
3736
+ />
3737
+ ) : undefined
3738
+ }
2216
3739
  foldKey={group.id}
2217
3740
  facets={turnSummary?.facets}
2218
3741
  settleFold={settleFold}
@@ -2235,54 +3758,40 @@ const TimelineGroupView = memo(function TimelineGroupView({
2235
3758
  );
2236
3759
  case "turn": {
2237
3760
  const activityItems = flattenActivityItems(group.groups);
2238
- // Second-layer chips only when there are natural multi-cluster seams —
2239
- // otherwise the outer turn chip alone is enough ("N steps" wrapping one
2240
- // more "N steps" was the redundant double fold).
2241
- const nestClusters = foldableActivityClusterCount(group.groups) >= 2;
3761
+ if (
3762
+ !startupDetails &&
3763
+ group.outcome === "complete" &&
3764
+ group.groups.every(
3765
+ (child) =>
3766
+ child.kind === "activity" &&
3767
+ child.items.every(
3768
+ (item) => item.kind === "startup-phase" && item.status === "complete",
3769
+ ),
3770
+ )
3771
+ )
3772
+ return <ActivityRail items={activityItems} startupActive={false} bare />;
2242
3773
  const turnCopyText = collectTurnCopyText(group.groups, trailingAgentText);
2243
- const body = group.groups.map((child) => {
2244
- const key = timelineGroupKey(child);
2245
- return (
2246
- <TimelineGroupRenderBoundary
2247
- key={key}
2248
- resetKeys={[
2249
- child,
2250
- renderMessageText,
2251
- onOpenSession,
2252
- onMemoryClick,
2253
- onReconnect,
2254
- resolveProviderLogo,
2255
- toolRegistry,
2256
- loadRetainedScreenshot,
2257
- loadRetainedArtifact,
2258
- loadVideoArtifactPlayback,
2259
- turnSummary,
2260
- ]}
2261
- >
2262
- <TimelineGroupView
2263
- group={child}
2264
- renderMessageText={renderMessageText}
2265
- onOpenSession={onOpenSession}
2266
- onMemoryClick={onMemoryClick}
2267
- onReconnect={onReconnect}
2268
- resolveProviderLogo={resolveProviderLogo}
2269
- toolRegistry={toolRegistry}
2270
- loadRetainedScreenshot={loadRetainedScreenshot}
2271
- loadRetainedArtifact={loadRetainedArtifact}
2272
- loadVideoArtifactPlayback={loadVideoArtifactPlayback}
2273
- turnSummary={turnSummary}
2274
- insideTurn
2275
- nestClusterChips={nestClusters}
2276
- />
2277
- </TimelineGroupRenderBoundary>
2278
- );
3774
+ const body = renderFoldedGroups(group.groups, {
3775
+ renderMessageActions,
3776
+ renderMessageText,
3777
+ onOpenSession,
3778
+ onMemoryClick,
3779
+ onReconnect,
3780
+ renderAuthNeeded,
3781
+ resolveProviderLogo,
3782
+ toolRegistry,
3783
+ loadRetainedScreenshot,
3784
+ loadRetainedArtifact,
3785
+ loadVideoArtifactPlayback,
3786
+ turnSummary,
2279
3787
  });
3788
+ const durationMs = durationBetween(group.startedAt, group.endedAt);
2280
3789
  return (
2281
3790
  <TurnSummary
2282
3791
  items={activityItems}
2283
3792
  outcome={group.outcome}
2284
3793
  failureText={insideTurn ? undefined : group.failureText}
2285
- durationMs={durationBetween(group.startedAt, group.endedAt)}
3794
+ durationMs={durationMs}
2286
3795
  defaultOpen={turnDefaultOpen ? true : undefined}
2287
3796
  bare={insideTurn}
2288
3797
  foldKey={group.id}
@@ -2307,8 +3816,10 @@ const TimelineGroupView = memo(function TimelineGroupView({
2307
3816
  return (
2308
3817
  <TimelineRow
2309
3818
  item={group.item}
3819
+ renderMessageActions={renderMessageActions}
2310
3820
  renderMessageText={renderMessageText}
2311
3821
  onReconnect={onReconnect}
3822
+ renderAuthNeeded={renderAuthNeeded}
2312
3823
  resolveProviderLogo={resolveProviderLogo}
2313
3824
  onOpenSession={onOpenSession}
2314
3825
  loadVideoArtifactPlayback={loadVideoArtifactPlayback}
@@ -2317,6 +3828,94 @@ const TimelineGroupView = memo(function TimelineGroupView({
2317
3828
  }
2318
3829
  });
2319
3830
 
3831
+ type FoldedGroupBehavior = {
3832
+ renderMessageActions?: ((item: AgentMessageItem | UserMessageItem) => ReactNode) | undefined;
3833
+ renderMessageText?:
3834
+ | ((text: string, item: AgentMessageItem | UserMessageItem) => ReactNode)
3835
+ | undefined;
3836
+ onOpenSession?: ((sessionId: string) => void) | undefined;
3837
+ onMemoryClick?: ((memoryId: string) => void) | undefined;
3838
+ onReconnect?: ((item: AuthNeededItem) => void | Promise<void>) | undefined;
3839
+ renderAuthNeeded?: ((item: AuthNeededItem) => ReactNode | undefined) | undefined;
3840
+ resolveProviderLogo?: ((providerDomain: string) => string | null | undefined) | undefined;
3841
+ toolRegistry: ToolRegistry;
3842
+ loadRetainedScreenshot?: RetainedScreenshotLoader | undefined;
3843
+ loadRetainedArtifact?: RetainedArtifactLoader | undefined;
3844
+ loadVideoArtifactPlayback?: VideoArtifactPlaybackLoader | undefined;
3845
+ turnSummary?: TurnSummaryOptions | undefined;
3846
+ };
3847
+
3848
+ /**
3849
+ * A search hit inside a nested work row opens that row only. The reveal key
3850
+ * is provided per top-level group, so without narrowing every nested row of
3851
+ * a folded quiet-cycle run would open at once.
3852
+ */
3853
+ function NestedSearchReveal({ group, children }: { group: TimelineGroup; children: ReactNode }) {
3854
+ const revealKey = useContext(TimelineSearchRevealContext);
3855
+ const itemId = useMemo(() => {
3856
+ if (!revealKey) return null;
3857
+ try {
3858
+ const [id] = JSON.parse(revealKey) as unknown[];
3859
+ return typeof id === "string" ? id : null;
3860
+ } catch {
3861
+ return null;
3862
+ }
3863
+ }, [revealKey]);
3864
+ const value = itemId && timelineGroupItemIds(group).includes(itemId) ? revealKey : null;
3865
+ return (
3866
+ <TimelineSearchRevealContext.Provider value={value}>
3867
+ {children}
3868
+ </TimelineSearchRevealContext.Provider>
3869
+ );
3870
+ }
3871
+
3872
+ function renderFoldedGroups(
3873
+ groups: readonly TimelineGroup[],
3874
+ behavior: FoldedGroupBehavior,
3875
+ readableWork = false,
3876
+ ) {
3877
+ // Second-layer chips only when there are natural multi-cluster seams:
3878
+ // otherwise the outer turn chip alone is enough ("N steps" wrapping one
3879
+ // more "N steps" was the redundant double fold).
3880
+ const nestClusters = foldableActivityClusterCount(groups) >= 2;
3881
+ return groups.map((child, index) => (
3882
+ <TimelineGroupRenderBoundary
3883
+ key={timelineGroupKey(child)}
3884
+ resetKeys={[
3885
+ child,
3886
+ behavior.renderMessageActions,
3887
+ behavior.renderMessageText,
3888
+ behavior.onOpenSession,
3889
+ behavior.onMemoryClick,
3890
+ behavior.onReconnect,
3891
+ behavior.renderAuthNeeded,
3892
+ behavior.resolveProviderLogo,
3893
+ behavior.toolRegistry,
3894
+ behavior.loadRetainedScreenshot,
3895
+ behavior.loadRetainedArtifact,
3896
+ behavior.loadVideoArtifactPlayback,
3897
+ behavior.turnSummary,
3898
+ ]}
3899
+ >
3900
+ {readableWork ? (
3901
+ <div
3902
+ className={
3903
+ index > 0 && (child.kind !== "activity" || groups[index - 1]?.kind !== "activity")
3904
+ ? "mt-3"
3905
+ : undefined
3906
+ }
3907
+ >
3908
+ <NestedSearchReveal group={child}>
3909
+ <TimelineGroupView {...behavior} group={child} insideTurn startupDismissed />
3910
+ </NestedSearchReveal>
3911
+ </div>
3912
+ ) : (
3913
+ <TimelineGroupView {...behavior} group={child} insideTurn nestClusterChips={nestClusters} />
3914
+ )}
3915
+ </TimelineGroupRenderBoundary>
3916
+ ));
3917
+ }
3918
+
2320
3919
  /**
2321
3920
  * Body under a turn/activity chip. Remount flashes are gated by the timeline
2322
3921
  * seen-activity-id map (not by killing entrance): FoldBody used to force
@@ -2328,9 +3927,16 @@ function FoldBody({ children }: { children: ReactNode }) {
2328
3927
 
2329
3928
  /** Stable left rule for turn/activity bodies — always present so settle wrap
2330
3929
  never inserts or removes the rail chrome. */
2331
- function TurnRailFrame({ children }: { children: ReactNode }) {
3930
+ function TurnRailFrame({ children, compact = false }: { children: ReactNode; compact?: boolean }) {
2332
3931
  return (
2333
- <div className="flex flex-col gap-4 border-l-2 border-og-border pl-3 sm:pl-4">{children}</div>
3932
+ <div
3933
+ className={cn(
3934
+ "flex flex-col border-l-2 border-og-border pl-3 sm:pl-4",
3935
+ compact ? "gap-0.5" : "gap-4",
3936
+ )}
3937
+ >
3938
+ {children}
3939
+ </div>
2334
3940
  );
2335
3941
  }
2336
3942
 
@@ -2377,26 +3983,15 @@ function timelineGroupContainsAuthNeeded(group: TimelineGroup): boolean {
2377
3983
  }
2378
3984
  }
2379
3985
 
2380
- /** Generated images are primary user-visible output, not incidental activity. */
2381
- function timelineGroupContainsGeneratedImage(group: TimelineGroup): boolean {
2382
- switch (group.kind) {
2383
- case "item":
2384
- return false;
2385
- case "activity":
2386
- return group.items.some(
2387
- (item) => item.kind === "tool-call" && item.name === "generate_image",
2388
- );
2389
- case "turn":
2390
- return group.groups.some(timelineGroupContainsGeneratedImage);
2391
- }
2392
- }
2393
-
2394
3986
  function timelineGroupItemIds(group: TimelineGroup): string[] {
2395
3987
  switch (group.kind) {
2396
3988
  case "item":
2397
3989
  return [group.item.id];
2398
3990
  case "activity":
2399
- return group.items.map((item) => item.id);
3991
+ return [
3992
+ ...(group.work?.details ?? []).flatMap(timelineGroupItemIds),
3993
+ ...group.items.map((item) => item.id),
3994
+ ];
2400
3995
  case "turn":
2401
3996
  return group.groups.flatMap(timelineGroupItemIds);
2402
3997
  }
@@ -2414,25 +4009,13 @@ function isAgentProgress(next: TimelineGroup | undefined): boolean {
2414
4009
  return (
2415
4010
  next.kind === "activity" ||
2416
4011
  next.kind === "turn" ||
2417
- (next.kind === "item" && next.item.kind === "agent-message")
4012
+ (next.kind === "item" && next.item.kind === "agent-message" && next.item.text.trim().length > 0)
2418
4013
  );
2419
4014
  }
2420
4015
 
2421
4016
  /** No item still running or streaming — the only state safe to fold live.
2422
4017
  Position alone is a broken proxy: a pending queued message (or any trailing
2423
4018
  item) can sit after the ACTIVE cluster, which must never fold mid-work. */
2424
- function clusterIsSettled(group: Extract<TimelineGroup, { kind: "activity" }>): boolean {
2425
- return group.items.every((item) => {
2426
- if (item.kind === "reasoning") {
2427
- return !item.streaming;
2428
- }
2429
- // Memory writes and fleet observations are discrete, already-settled events.
2430
- if (item.kind === "memory" || item.kind === "fleet-decision") {
2431
- return true;
2432
- }
2433
- return item.status !== "running";
2434
- });
2435
- }
2436
4019
 
2437
4020
  /** Settled activity clusters that could become nested chips under a turn. */
2438
4021
  function foldableActivityClusterCount(groups: readonly TimelineGroup[]): number {
@@ -2445,18 +4028,6 @@ function foldableActivityClusterCount(groups: readonly TimelineGroup[]): number
2445
4028
  return count;
2446
4029
  }
2447
4030
 
2448
- function flattenActivityItems(groups: TimelineGroup[]): ActivityItem[] {
2449
- const items: ActivityItem[] = [];
2450
- for (const group of groups) {
2451
- if (group.kind === "activity") {
2452
- items.push(...group.items);
2453
- } else if (group.kind === "turn") {
2454
- items.push(...flattenActivityItems(group.groups));
2455
- }
2456
- }
2457
- return items;
2458
- }
2459
-
2460
4031
  /** Assistant prose inside a turn fold (mid-turn narration), joined for copy. */
2461
4032
  function collectAgentMessageText(groups: readonly TimelineGroup[]): string {
2462
4033
  const parts: string[] = [];
@@ -2466,6 +4037,18 @@ function collectAgentMessageText(groups: readonly TimelineGroup[]): string {
2466
4037
  if (text.length > 0) {
2467
4038
  parts.push(text);
2468
4039
  }
4040
+ } else if (group.kind === "activity") {
4041
+ // Commentary folded into clusters is still the assistant's own prose.
4042
+ const nested = collectAgentMessageText(group.work?.details ?? []);
4043
+ if (nested.length > 0) {
4044
+ parts.push(nested);
4045
+ }
4046
+ for (const item of group.items) {
4047
+ const text = item.kind === "agent-message" ? item.text.trim() : "";
4048
+ if (text.length > 0) {
4049
+ parts.push(text);
4050
+ }
4051
+ }
2469
4052
  } else if (group.kind === "turn") {
2470
4053
  const nested = collectAgentMessageText(group.groups);
2471
4054
  if (nested.length > 0) {
@@ -2486,6 +4069,9 @@ function collectTurnIdsFromGroups(groups: readonly TimelineGroup[]): Set<string>
2486
4069
  ids.add(turnId);
2487
4070
  }
2488
4071
  } else if (group.kind === "activity") {
4072
+ for (const nested of collectTurnIdsFromGroups(group.work?.details ?? [])) {
4073
+ ids.add(nested);
4074
+ }
2489
4075
  for (const item of group.items) {
2490
4076
  if (item.turnId) {
2491
4077
  ids.add(item.turnId);
@@ -2540,15 +4126,6 @@ function collectTurnCopyText(
2540
4126
  return parts.join("\n\n");
2541
4127
  }
2542
4128
 
2543
- function durationBetween(startedAt: string, endedAt: string): number | undefined {
2544
- const started = Date.parse(startedAt);
2545
- const ended = Date.parse(endedAt);
2546
- if (!Number.isFinite(started) || !Number.isFinite(ended) || ended < started) {
2547
- return undefined;
2548
- }
2549
- return ended - started;
2550
- }
2551
-
2552
4129
  /* --- single rows ------------------------------------------------------------ */
2553
4130
 
2554
4131
  /**
@@ -2558,28 +4135,45 @@ function durationBetween(startedAt: string, endedAt: string): number | undefined
2558
4135
  */
2559
4136
  export function TimelineRow({
2560
4137
  item,
4138
+ renderMessageActions,
2561
4139
  renderMessageText,
2562
4140
  onReconnect,
4141
+ renderAuthNeeded,
2563
4142
  resolveProviderLogo,
2564
4143
  onOpenSession,
2565
4144
  loadVideoArtifactPlayback,
2566
4145
  }: {
2567
4146
  item: TimelineItem;
4147
+ renderMessageActions?: ((item: AgentMessageItem | UserMessageItem) => ReactNode) | undefined;
2568
4148
  renderMessageText?:
2569
4149
  | ((text: string, item: AgentMessageItem | UserMessageItem) => ReactNode)
2570
4150
  | undefined;
2571
4151
  onReconnect?: ((item: AuthNeededItem) => void | Promise<void>) | undefined;
4152
+ /** Host-owned inline connection setup. Return undefined to use the default recovery card. */
4153
+ renderAuthNeeded?: ((item: AuthNeededItem) => ReactNode | undefined) | undefined;
2572
4154
  resolveProviderLogo?: ((providerDomain: string) => string | null | undefined) | undefined;
2573
4155
  onOpenSession?: ((sessionId: string) => void) | undefined;
2574
4156
  loadVideoArtifactPlayback?: VideoArtifactPlaybackLoader | undefined;
2575
4157
  }) {
2576
4158
  switch (item.kind) {
2577
4159
  case "user-message":
2578
- return <UserMessageRow item={item} renderMessageText={renderMessageText} />;
4160
+ return (
4161
+ <UserMessageRow
4162
+ item={item}
4163
+ renderMessageActions={renderMessageActions}
4164
+ renderMessageText={renderMessageText}
4165
+ />
4166
+ );
2579
4167
  case "human-input":
2580
4168
  return <HumanInputConversationRow item={item} />;
2581
4169
  case "agent-message":
2582
- return <AgentMessageRow item={item} renderMessageText={renderMessageText} />;
4170
+ return (
4171
+ <AgentMessageRow
4172
+ item={item}
4173
+ renderMessageActions={renderMessageActions}
4174
+ renderMessageText={renderMessageText}
4175
+ />
4176
+ );
2583
4177
  case "worker-completion":
2584
4178
  return <WorkerCompletionRow item={item} onOpenSession={onOpenSession} />;
2585
4179
  case "session-status":
@@ -2588,7 +4182,11 @@ export function TimelineRow({
2588
4182
  return <GoalRow item={item} />;
2589
4183
  case "machine-input-batch":
2590
4184
  return (
2591
- <MachineInputBatchRow item={item} loadVideoArtifactPlayback={loadVideoArtifactPlayback} />
4185
+ <MachineInputBatchRow
4186
+ item={item}
4187
+ onOpenSession={onOpenSession}
4188
+ loadVideoArtifactPlayback={loadVideoArtifactPlayback}
4189
+ />
2592
4190
  );
2593
4191
  case "notice":
2594
4192
  return <NoticeRow item={item} />;
@@ -2596,11 +4194,13 @@ export function TimelineRow({
2596
4194
  return <CompactionRow item={item} />;
2597
4195
  case "auth-needed":
2598
4196
  return (
2599
- <AuthNeededRow
2600
- item={item}
2601
- onReconnect={onReconnect}
2602
- resolveProviderLogo={resolveProviderLogo}
2603
- />
4197
+ renderAuthNeeded?.(item) ?? (
4198
+ <AuthNeededRow
4199
+ item={item}
4200
+ onReconnect={onReconnect}
4201
+ resolveProviderLogo={resolveProviderLogo}
4202
+ />
4203
+ )
2604
4204
  );
2605
4205
  default:
2606
4206
  return null;
@@ -2616,6 +4216,8 @@ const COMPACTION_TRIGGER_LABEL: Record<NonNullable<ContextCompactionItem["trigge
2616
4216
 
2617
4217
  function CompactionRow({ item }: { item: ContextCompactionItem }) {
2618
4218
  const enter = useEntranceAnimation();
4219
+ const detailsId = useId();
4220
+ const [detailsOpen, setDetailsOpen] = useState(false);
2619
4221
  const trigger =
2620
4222
  item.trigger && item.phase !== "started" ? COMPACTION_TRIGGER_LABEL[item.trigger] : null;
2621
4223
  const before =
@@ -2633,56 +4235,97 @@ function CompactionRow({ item }: { item: ContextCompactionItem }) {
2633
4235
  ? before && after
2634
4236
  ? `Conversation history compacted · ~${before} → ~${after} estimated history tokens`
2635
4237
  : "Conversation history compacted"
2636
- : "Couldn’t compact conversation history";
4238
+ : compactionSkipTitle(item.skipReason);
2637
4239
  const subtitle =
2638
4240
  item.phase === "compacted"
2639
4241
  ? "Chat history above is unchanged"
2640
4242
  : item.phase === "skipped"
2641
- ? compactionSkipSubtitle(item.skipReason)
4243
+ ? compactionSkipSubtitle(item.skipReason, item.providerRejection)
2642
4244
  : null;
2643
- const pill =
2644
- item.phase === "skipped" && item.skipReason === "summarization_failed"
2645
- ? "border-og-status-failed/35 bg-og-status-failed/10 text-og-status-failed"
2646
- : item.phase === "started"
2647
- ? WAITING_PILL_CLASS
2648
- : "border-og-border bg-og-surface-1 text-og-fg-muted";
4245
+ // Provider identifiers are diagnostics: one short line up front, the rest on demand.
4246
+ const details = compactionFailureDetails(item);
4247
+ const failed = item.phase === "skipped" && item.skipReason === "summarization_failed";
4248
+ const pill = failed
4249
+ ? "border-og-status-failed/35 bg-og-status-failed/10 text-og-status-failed"
4250
+ : item.phase === "started"
4251
+ ? WAITING_PILL_CLASS
4252
+ : NEUTRAL_PILL;
2649
4253
  return (
2650
- <div className={cn(enter && "animate-og-enter", "flex justify-center")}>
4254
+ <div className={cn(enter && "animate-og-enter", "flex min-w-0 flex-col items-center gap-1.5")}>
2651
4255
  <div
2652
4256
  className={cn(
2653
- "inline-flex max-w-full flex-col items-center gap-0.5 rounded-full border px-3 py-1.5 text-og-sm",
4257
+ "inline-flex min-w-0 max-w-full flex-col items-center gap-0.5 border px-3 py-1.5 text-og-sm",
4258
+ // A failure's recovery step may wrap on narrow screens; a full pill
4259
+ // radius would pinch its second line.
4260
+ failed ? "rounded-og-lg" : "rounded-full",
2654
4261
  pill,
2655
4262
  )}
2656
4263
  role="status"
2657
4264
  >
2658
- <span className="inline-flex max-w-full items-center gap-1.5">
4265
+ <span className="inline-flex min-w-0 max-w-full items-center gap-1.5">
2659
4266
  <ShrinkIcon className="size-3.5 shrink-0" />
2660
- <span className="truncate">
4267
+ <span className="min-w-0 truncate">
2661
4268
  {title}
2662
4269
  {trigger ? ` · ${trigger}` : ""}
2663
4270
  </span>
2664
4271
  </span>
2665
- {subtitle ? <span className="truncate text-og-xs opacity-80">{subtitle}</span> : null}
4272
+ {subtitle || details ? (
4273
+ <span
4274
+ className={cn(
4275
+ "min-w-0 max-w-full text-og-xs",
4276
+ failed ? "text-balance text-center" : "inline-flex items-center gap-1",
4277
+ )}
4278
+ >
4279
+ {subtitle ? (
4280
+ <span className={cn("opacity-80", !failed && "min-w-0 truncate")}>{subtitle}</span>
4281
+ ) : null}
4282
+ {subtitle && details && failed ? " " : null}
4283
+ {details ? (
4284
+ <button
4285
+ type="button"
4286
+ aria-expanded={detailsOpen}
4287
+ aria-controls={detailsId}
4288
+ onClick={() => setDetailsOpen((open) => !open)}
4289
+ className={cn(
4290
+ "inline-flex shrink-0 items-center gap-0.5 whitespace-nowrap rounded-og-sm align-baseline font-medium underline-offset-2",
4291
+ "hover:underline focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-current/40",
4292
+ "pointer-coarse:-my-2 pointer-coarse:px-1 pointer-coarse:py-2",
4293
+ )}
4294
+ >
4295
+ Details
4296
+ <ChevronDownIcon
4297
+ aria-hidden
4298
+ className={cn("size-3 transition-transform", detailsOpen && "rotate-180")}
4299
+ />
4300
+ </button>
4301
+ ) : null}
4302
+ </span>
4303
+ ) : null}
2666
4304
  </div>
4305
+ {details ? (
4306
+ <div
4307
+ id={detailsId}
4308
+ hidden={!detailsOpen}
4309
+ data-og-compaction-details=""
4310
+ className="w-full max-w-md rounded-og-md border border-og-border bg-og-surface-1 px-3 py-2 text-left text-og-xs text-og-fg-muted"
4311
+ >
4312
+ {details.note ? <p className="mb-2">{details.note}</p> : null}
4313
+ <dl className="grid grid-cols-[auto_minmax(0,1fr)] gap-x-3 gap-y-1">
4314
+ {details.facts.map((fact) => (
4315
+ <Fragment key={fact.label}>
4316
+ <dt className="text-og-fg-subtle">{fact.label}</dt>
4317
+ <dd className="max-h-40 min-w-0 overflow-auto whitespace-pre-wrap break-words font-mono">
4318
+ {fact.value}
4319
+ </dd>
4320
+ </Fragment>
4321
+ ))}
4322
+ </dl>
4323
+ </div>
4324
+ ) : null}
2667
4325
  </div>
2668
4326
  );
2669
4327
  }
2670
4328
 
2671
- function compactionSkipSubtitle(reason: string | null): string {
2672
- switch (reason) {
2673
- case "no_history":
2674
- return "No active history to compact";
2675
- case "replacement_not_smaller":
2676
- return "Checkpoint would not reduce memory size";
2677
- case "replacement_unchanged":
2678
- return "Checkpoint made no progress";
2679
- case "summarization_failed":
2680
- return "Request it again to retry. Chat history is unchanged.";
2681
- default:
2682
- return "Compaction was not needed. Chat history is unchanged.";
2683
- }
2684
- }
2685
-
2686
4329
  /** Hover-reveal clock beside the copy control (sent / finished). */
2687
4330
  function MessageFooterTime({ occurredAt }: { occurredAt: string }) {
2688
4331
  return (
@@ -2701,9 +4344,11 @@ function MessageFooterTime({ occurredAt }: { occurredAt: string }) {
2701
4344
 
2702
4345
  function UserMessageRow({
2703
4346
  item,
4347
+ renderMessageActions,
2704
4348
  renderMessageText,
2705
4349
  }: {
2706
4350
  item: UserMessageItem;
4351
+ renderMessageActions?: ((item: AgentMessageItem | UserMessageItem) => ReactNode) | undefined;
2707
4352
  renderMessageText?:
2708
4353
  | ((text: string, item: AgentMessageItem | UserMessageItem) => ReactNode)
2709
4354
  | undefined;
@@ -2713,6 +4358,7 @@ function UserMessageRow({
2713
4358
  return (
2714
4359
  <div className={cn(enter && "animate-og-enter", "flex justify-end")}>
2715
4360
  <div className="flex max-w-[85%] min-w-0 flex-col items-end gap-1">
4361
+ <MessageSenderSlot item={item} />
2716
4362
  <CopyHoverFrame
2717
4363
  copyText={
2718
4364
  item.text ||
@@ -2722,22 +4368,30 @@ function UserMessageRow({
2722
4368
  }
2723
4369
  label="Copy message"
2724
4370
  className="w-fit max-w-full min-w-0"
2725
- trailing={<MessageFooterTime occurredAt={item.occurredAt} />}
4371
+ trailing={
4372
+ <>
4373
+ {renderMessageActions?.(item)}
4374
+ <MessageFooterTime occurredAt={item.occurredAt} />
4375
+ </>
4376
+ }
2726
4377
  >
2727
4378
  <div className={MESSAGE_BUBBLE_CLASS}>
2728
4379
  {item.text ? (
2729
- <div data-og-annotation-source-key={item.annotationSource?.eventId}>
4380
+ <div
4381
+ data-og-search-item={item.id}
4382
+ data-og-annotation-source-key={item.annotationSource?.eventId}
4383
+ >
2730
4384
  {renderMessageText ? (
2731
4385
  renderMessageText(item.text, item)
2732
4386
  ) : (
2733
4387
  <UserMessageBody messageId={item.id} text={item.text}>
2734
- <Markdown>{item.text}</Markdown>
4388
+ <Markdown softLineBreaks>{item.text}</Markdown>
2735
4389
  </UserMessageBody>
2736
4390
  )}
2737
4391
  </div>
2738
4392
  ) : null}
2739
4393
  {(item.annotations?.length ?? 0) > 0 ? (
2740
- <TimelineAnnotationsChip
4394
+ <TimelineAnnotationCards
2741
4395
  annotations={item.annotations ?? []}
2742
4396
  className={item.text ? "mt-2" : undefined}
2743
4397
  />
@@ -2747,30 +4401,43 @@ function UserMessageRow({
2747
4401
  {deliveryFailed ? (
2748
4402
  <div
2749
4403
  role="status"
2750
- className="flex max-w-full items-center gap-2 px-1 text-og-xs text-og-status-failed"
4404
+ className="flex max-w-full flex-col items-start gap-1 px-1 text-og-xs text-og-status-failed"
2751
4405
  >
2752
- <span className="inline-flex items-center gap-1" title={item.delivery?.error}>
4406
+ <span className="flex min-w-0 items-start gap-1">
2753
4407
  <TriangleAlertIcon className="size-3.5 shrink-0" aria-hidden="true" />
2754
- <span>Message not sent</span>
4408
+ <span className="min-w-0 break-words">
4409
+ {item.delivery?.error || "Message not sent"}
4410
+ </span>
2755
4411
  </span>
2756
- {item.delivery?.onRetry ? (
2757
- <button
2758
- type="button"
2759
- className="font-medium text-og-status-failed underline decoration-og-status-failed/50 underline-offset-2 hover:text-og-fg"
2760
- onClick={item.delivery.onRetry}
2761
- >
2762
- Retry
2763
- </button>
2764
- ) : null}
2765
- {item.delivery?.onRemove ? (
2766
- <button
2767
- type="button"
2768
- className="font-medium text-og-fg-subtle underline decoration-og-border underline-offset-2 hover:text-og-fg"
2769
- onClick={item.delivery.onRemove}
2770
- >
2771
- Remove
2772
- </button>
2773
- ) : null}
4412
+ <div className="flex flex-wrap items-center gap-2">
4413
+ {item.delivery?.onEdit ? (
4414
+ <button
4415
+ type="button"
4416
+ className="font-medium text-og-status-failed underline decoration-og-status-failed/50 underline-offset-2 hover:text-og-fg"
4417
+ onClick={item.delivery.onEdit}
4418
+ >
4419
+ Edit message
4420
+ </button>
4421
+ ) : null}
4422
+ {item.delivery?.onRetry ? (
4423
+ <button
4424
+ type="button"
4425
+ className="font-medium text-og-status-failed underline decoration-og-status-failed/50 underline-offset-2 hover:text-og-fg"
4426
+ onClick={item.delivery.onRetry}
4427
+ >
4428
+ Retry
4429
+ </button>
4430
+ ) : null}
4431
+ {item.delivery?.onRemove ? (
4432
+ <button
4433
+ type="button"
4434
+ className="font-medium text-og-fg-subtle underline decoration-og-border underline-offset-2 hover:text-og-fg"
4435
+ onClick={item.delivery.onRemove}
4436
+ >
4437
+ Remove
4438
+ </button>
4439
+ ) : null}
4440
+ </div>
2774
4441
  </div>
2775
4442
  ) : null}
2776
4443
  </div>
@@ -2810,7 +4477,7 @@ function HumanInputConversationRow({ item }: { item: HumanInputItem }) {
2810
4477
  item.questions.map((question, index) => (
2811
4478
  <div key={question.id}>
2812
4479
  {question.label ? (
2813
- <p className="text-og-sm font-semibold text-og-fg">
4480
+ <p className="text-og-sm font-semibold text-og-fg [overflow-wrap:anywhere]">
2814
4481
  {multipleQuestions ? (
2815
4482
  <span className="mr-1.5 tabular-nums text-og-fg-muted">{index + 1}.</span>
2816
4483
  ) : null}
@@ -2820,7 +4487,7 @@ function HumanInputConversationRow({ item }: { item: HumanInputItem }) {
2820
4487
  ) : null}
2821
4488
  <p
2822
4489
  className={cn(
2823
- "text-og-md leading-6 text-og-fg",
4490
+ "text-og-md leading-6 text-og-fg [overflow-wrap:anywhere]",
2824
4491
  question.label && "mt-0.5 text-og-sm text-og-fg-muted",
2825
4492
  )}
2826
4493
  >
@@ -2828,7 +4495,7 @@ function HumanInputConversationRow({ item }: { item: HumanInputItem }) {
2828
4495
  <span className="mr-1.5 tabular-nums text-og-fg-muted">{index + 1}.</span>
2829
4496
  ) : null}
2830
4497
  {!question.label && multipleQuestions ? " " : null}
2831
- {question.prompt}
4498
+ {linkifyText(question.prompt)}
2832
4499
  </p>
2833
4500
  </div>
2834
4501
  ))
@@ -2862,7 +4529,12 @@ function HumanInputConversationRow({ item }: { item: HumanInputItem }) {
2862
4529
  </p>
2863
4530
  ) : null}
2864
4531
  <div className={cn("text-og-md text-og-fg", multipleQuestions && "mt-0.5")}>
2865
- {answer.values.length > 1 ? (
4532
+ {answer.secret ? (
4533
+ <SecretAnswerChip
4534
+ result={answer.secret}
4535
+ label={secretAnswerLabel(answer.secret)}
4536
+ />
4537
+ ) : answer.values.length > 1 ? (
2866
4538
  <ul className="list-disc space-y-0.5 pl-5">
2867
4539
  {answer.values.map((value) => (
2868
4540
  <li key={`${answer.questionId}-${value}`}>{value}</li>
@@ -2903,7 +4575,11 @@ function humanInputConversationCopyText(item: HumanInputItem, settledLabel: stri
2903
4575
  item.questions.length > 1
2904
4576
  ? `${questionNumberById.get(answer.questionId) ?? index + 1}. `
2905
4577
  : ""
2906
- }${answer.label}: ${answer.values.join(", ") || "Answered"}`,
4578
+ }${answer.label}: ${
4579
+ answer.secret
4580
+ ? `${secretAnswerLabel(answer.secret)} (${answer.secret.reference})`
4581
+ : answer.values.join(", ") || "Answered"
4582
+ }`,
2907
4583
  )
2908
4584
  .join("\n\n");
2909
4585
  return [questions, `${settledLabel}${answers ? `\n\n${answers}` : ""}`]
@@ -2913,9 +4589,11 @@ function humanInputConversationCopyText(item: HumanInputItem, settledLabel: stri
2913
4589
 
2914
4590
  function AgentMessageRow({
2915
4591
  item,
4592
+ renderMessageActions,
2916
4593
  renderMessageText,
2917
4594
  }: {
2918
4595
  item: AgentMessageItem;
4596
+ renderMessageActions?: ((item: AgentMessageItem | UserMessageItem) => ReactNode) | undefined;
2919
4597
  renderMessageText?:
2920
4598
  | ((text: string, item: AgentMessageItem | UserMessageItem) => ReactNode)
2921
4599
  | undefined;
@@ -2926,76 +4604,80 @@ function AgentMessageRow({
2926
4604
  const body = renderMessageText ? (
2927
4605
  renderMessageText(item.text, item)
2928
4606
  ) : (
2929
- <Markdown streaming={item.streaming}>{item.text}</Markdown>
4607
+ <Markdown softLineBreaks streaming={item.streaming}>
4608
+ {item.text}
4609
+ </Markdown>
2930
4610
  );
2931
4611
  // While streaming, copy is still useful (current text) but keep chrome calm —
2932
4612
  // stamp only after the message finishes (occurredAt tracks completion).
2933
4613
  return (
2934
- <div data-og-annotation-source-key={item.annotationSource?.eventId}>
2935
- <CopyHoverFrame
2936
- copyText={item.text}
2937
- label="Copy message"
2938
- align="start"
2939
- className={cn(enter && "animate-og-enter", "min-w-0 text-og-md leading-7 text-og-fg")}
2940
- trailing={item.streaming ? null : <MessageFooterTime occurredAt={item.occurredAt} />}
4614
+ <CopyHoverFrame
4615
+ copyText={item.text}
4616
+ label="Copy message"
4617
+ align="start"
4618
+ className={cn(enter && "animate-og-enter", "min-w-0 text-og-md leading-7 text-og-fg")}
4619
+ trailing={
4620
+ item.streaming ? null : (
4621
+ <>
4622
+ {renderMessageActions?.(item)}
4623
+ <MessageFooterTime occurredAt={item.occurredAt} />
4624
+ </>
4625
+ )
4626
+ }
4627
+ >
4628
+ <div
4629
+ data-og-wide-table-message=""
4630
+ data-og-search-item={item.id}
4631
+ data-og-annotation-source-key={item.annotationSource?.eventId}
2941
4632
  >
2942
4633
  {body}
2943
- </CopyHoverFrame>
2944
- </div>
4634
+ </div>
4635
+ </CopyHoverFrame>
2945
4636
  );
2946
4637
  }
2947
4638
 
2948
4639
  /**
2949
- * A worker session reporting back to its manager. The child's completion arrives
2950
- * as a `user.message` carrying a `childCompletion` payload (the raw message text
2951
- * used to render as an "ugly" user bubble); it projects to a `worker-completion`
2952
- * item and draws here as a quietly-confident card — an inbound result, not
2953
- * something the human said. One glyph + one line carry the outcome; the worker's
2954
- * full report, evidence, and any paused reason live behind a collapsed
2955
- * disclosure, and a "View session" affordance deep-links into the child.
2956
- *
2957
- * Color follows the timeline's restraint: green only for a completed goal, the
2958
- * waiting hue only for a paused one, red only for a failed child — everything
2959
- * else is a neutral inbound card.
4640
+ * A worker session reporting back to its manager in older history: the child's
4641
+ * completion arrived as a `user.message` carrying a `childCompletion` payload.
4642
+ * It draws as the same named agent row as every other inbound agent update:
4643
+ * the outcome and goal on the row, the report, evidence, and any paused
4644
+ * reason behind the disclosure, and a deep link into the child.
2960
4645
  */
2961
4646
  type WorkerCompletionMeta = {
2962
- label: string;
4647
+ title: AgentTitleParts;
2963
4648
  icon: ComponentType<{ className?: string }>;
2964
- iconClass: string;
2965
- /** The 2px left-accent hue — color spent only on the exceptional outcomes. */
2966
- accentClass: string;
4649
+ failed: boolean;
2967
4650
  };
2968
4651
 
2969
- function workerCompletionMeta(item: WorkerCompletionItem): WorkerCompletionMeta {
4652
+ function workerCompletionMeta(
4653
+ item: WorkerCompletionItem,
4654
+ name: string | null,
4655
+ ): WorkerCompletionMeta {
2970
4656
  if (item.childStatus === "failed") {
2971
4657
  return {
2972
- label: "Worker failed",
4658
+ title: namedAgentTitle(name, "", " failed", "Worker failed"),
2973
4659
  icon: XCircleIcon,
2974
- iconClass: "text-og-status-failed",
2975
- accentClass: "border-og-status-failed/60",
4660
+ failed: true,
2976
4661
  };
2977
4662
  }
2978
4663
  if (item.goalStatus === "paused") {
2979
4664
  return {
2980
- label: "Worker paused",
4665
+ title: namedAgentTitle(name, "", " paused", "Worker paused"),
2981
4666
  icon: PauseCircleIcon,
2982
- iconClass: "text-og-status-waiting",
2983
- accentClass: "border-og-status-waiting/50",
4667
+ failed: false,
2984
4668
  };
2985
4669
  }
2986
4670
  if (item.goalStatus === "completed") {
2987
4671
  return {
2988
- label: "Worker completed",
4672
+ title: namedAgentTitle(name, "", " completed its goal", "Worker completed"),
2989
4673
  icon: CheckCircle2Icon,
2990
- iconClass: "text-og-status-idle",
2991
- accentClass: "border-og-status-idle/45",
4674
+ failed: false,
2992
4675
  };
2993
4676
  }
2994
4677
  return {
2995
- label: "Worker reported back",
4678
+ title: namedAgentTitle(name, "Result from ", "", "Worker reported back"),
2996
4679
  icon: BotIcon,
2997
- iconClass: "text-og-accent",
2998
- accentClass: "border-og-border-strong",
4680
+ failed: false,
2999
4681
  };
3000
4682
  }
3001
4683
 
@@ -3007,14 +4689,12 @@ function WorkerCompletionRow({
3007
4689
  onOpenSession?: ((sessionId: string) => void) | undefined;
3008
4690
  }) {
3009
4691
  const enter = useEntranceAnimation();
3010
- const [open, setOpen] = useState(false);
3011
- const meta = workerCompletionMeta(item);
4692
+ const identity = useAgentIdentity();
4693
+ const meta = workerCompletionMeta(item, identity.titleFor(item.childSessionId));
3012
4694
  const Icon = meta.icon;
3013
- // The worker's own report is the substance behind the fold; evidence and any
3014
- // paused reason sit alongside it as quieter, labelled context.
3015
4695
  // "Paused because" only when the outcome actually IS a pause — completion
3016
4696
  // payloads can carry a leftover pausedReason/rationale from earlier in the
3017
- // worker's life, and a "Worker completed" card must not show a pause section.
4697
+ // worker's life, and a completed row must not show a pause section.
3018
4698
  const showPausedReason =
3019
4699
  item.childStatus !== "failed" && item.goalStatus === "paused" && !!item.pausedReason?.trim();
3020
4700
  const details: { label: string; value: string; muted?: boolean }[] = [
@@ -3026,82 +4706,44 @@ function WorkerCompletionRow({
3026
4706
  ? [{ label: "Paused because", value: item.pausedReason!.trim(), muted: true }]
3027
4707
  : []),
3028
4708
  ];
3029
- const hasDetails = details.length > 0;
3030
4709
  return (
3031
4710
  <div className={cn(enter && "animate-og-enter", "min-w-0")}>
3032
- {/* An inbound result, not a bubble: a 2px left accent carries the outcome —
3033
- no full frame, no surface fill. The report unfolds flush beneath. */}
3034
- <div className={cn("flex flex-col gap-2 border-l-2 pl-3", meta.accentClass)}>
3035
- <div className="flex items-start gap-2.5">
3036
- <span className={cn("mt-0.5 shrink-0", meta.iconClass)}>
3037
- <Icon className="size-4" />
3038
- </span>
3039
- <div className="min-w-0 flex-1">
3040
- <p className="text-og-base leading-5 text-og-fg">
3041
- <span className="font-medium">{meta.label}</span>
3042
- {item.goalText ? (
3043
- <span className="text-og-fg-muted"> · {truncate(item.goalText, 90)}</span>
3044
- ) : null}
3045
- </p>
3046
- </div>
3047
- {item.childSessionId && onOpenSession ? (
3048
- <button
3049
- type="button"
3050
- onClick={() => onOpenSession(item.childSessionId)}
3051
- className={cn(
3052
- "-my-0.5 -mr-1 inline-flex shrink-0 items-center gap-1 rounded-og-sm px-2 py-1 text-og-sm font-medium text-og-fg-muted pointer-coarse:py-2",
3053
- "outline-hidden transition-colors duration-150 hover:bg-og-surface-2 hover:text-og-fg",
3054
- "focus-visible:ring-2 focus-visible:ring-og-accent",
3055
- )}
3056
- >
3057
- View session
3058
- <ArrowRightIcon className="size-3.5" />
3059
- </button>
3060
- ) : null}
3061
- </div>
3062
- {hasDetails ? (
3063
- <Collapsible.Root open={open} onOpenChange={setOpen}>
3064
- <Collapsible.Trigger asChild>
3065
- <button
3066
- type="button"
3067
- className={cn(
3068
- "group/wc -mx-1 inline-flex w-fit items-center gap-1 rounded-og-sm px-1 py-0.5 text-og-xs font-medium text-og-fg-subtle",
3069
- "outline-hidden transition-colors duration-150 hover:text-og-fg-muted focus-visible:ring-2 focus-visible:ring-og-accent",
3070
- )}
3071
- >
3072
- <ChevronRightIcon className="size-3 transition-transform duration-150 ease-og-in-out group-data-[state=open]/wc:rotate-90" />
3073
- {open ? "Hide details" : "Show details"}
3074
- </button>
3075
- </Collapsible.Trigger>
3076
- <Collapsible.Content className="overflow-hidden data-[state=closed]:animate-og-collapse data-[state=open]:animate-og-expand">
3077
- <div className="ml-1 mt-1.5 flex flex-col gap-2.5">
3078
- {details.map((detail) => (
3079
- <div key={detail.label} className="min-w-0">
3080
- <p className="mb-1 text-og-xs font-medium uppercase tracking-[0.08em] text-og-fg-subtle">
3081
- {detail.label}
3082
- </p>
3083
- <p
3084
- className={cn(
3085
- "whitespace-pre-wrap break-words text-og-sm leading-6",
3086
- detail.muted ? "text-og-fg-subtle" : "text-og-fg-muted",
3087
- )}
3088
- >
3089
- {detail.value}
3090
- </p>
3091
- </div>
3092
- ))}
3093
- </div>
3094
- </Collapsible.Content>
3095
- </Collapsible.Root>
3096
- ) : null}
3097
- </div>
4711
+ <AgentRow
4712
+ icon={<Icon />}
4713
+ title={meta.title}
4714
+ preview={item.goalText ? truncate(item.goalText, 240) : null}
4715
+ previewLines={2}
4716
+ sessionId={item.childSessionId}
4717
+ onOpenSession={onOpenSession ?? identity.onOpenSession}
4718
+ failed={meta.failed}
4719
+ >
4720
+ {details.length > 0 ? (
4721
+ <>
4722
+ {details.map((detail) => (
4723
+ <AgentRowSection key={detail.label} label={detail.label} muted={detail.muted}>
4724
+ {detail.value}
4725
+ </AgentRowSection>
4726
+ ))}
4727
+ </>
4728
+ ) : undefined}
4729
+ </AgentRow>
3098
4730
  </div>
3099
4731
  );
3100
4732
  }
3101
4733
 
3102
- function SessionStatusRow({ item }: { item: { status: SessionStatus; occurredAt: string } }) {
4734
+ function SessionStatusRow({
4735
+ item,
4736
+ }: {
4737
+ item: {
4738
+ status: SessionStatus;
4739
+ blocked?: true;
4740
+ occurredAt: string;
4741
+ resolvedAt?: string;
4742
+ };
4743
+ }) {
3103
4744
  const enter = useEntranceAnimation();
3104
- const meta = SESSION_STATUS_META[item.status];
4745
+ const status = item.blocked ? "blocked" : item.status;
4746
+ const meta = SESSION_STATUS_META[status];
3105
4747
  return (
3106
4748
  <div
3107
4749
  className={cn(
@@ -3112,8 +4754,9 @@ function SessionStatusRow({ item }: { item: { status: SessionStatus; occurredAt:
3112
4754
  >
3113
4755
  <span className="h-px flex-1 bg-og-border" />
3114
4756
  <span className="inline-flex items-center gap-1.5">
3115
- <StatusDot status={item.status} className="size-1" />
3116
- {meta.label.toLowerCase()} · {formatRelativeTime(item.occurredAt)}
4757
+ {item.resolvedAt ? null : <StatusDot status={status} className="size-1" />}
4758
+ {item.resolvedAt ? "work resumed" : meta.label.toLowerCase()} ·{" "}
4759
+ {formatRelativeTime(item.resolvedAt ?? item.occurredAt)}
3117
4760
  </span>
3118
4761
  <span className="h-px flex-1 bg-og-border" />
3119
4762
  </div>
@@ -3141,41 +4784,46 @@ type GoalMeta = { label: string; pill: string; icon: ComponentType<{ className?:
3141
4784
 
3142
4785
  const NEUTRAL_PILL = "border-og-border bg-og-surface-1 text-og-fg-muted";
3143
4786
 
3144
- const GOAL_META: Record<GoalItem["action"], GoalMeta> = {
3145
- set: {
3146
- label: "Goal set",
3147
- pill: "border-og-accent/30 bg-og-accent/10 text-og-accent",
3148
- icon: TargetIcon,
3149
- },
3150
- updated: { label: "Goal updated", pill: NEUTRAL_PILL, icon: PencilLineIcon },
3151
- completed: {
3152
- label: "Goal completed",
3153
- pill: "border-og-status-idle/30 bg-og-status-idle/10 text-og-status-idle",
3154
- icon: CheckIcon,
3155
- },
3156
- paused: {
3157
- label: "Goal paused",
3158
- pill: WAITING_PILL_CLASS,
3159
- icon: PauseIcon,
3160
- },
3161
- resumed: { label: "Goal resumed", pill: NEUTRAL_PILL, icon: PlayIcon },
3162
- cleared: { label: "Goal cleared", pill: NEUTRAL_PILL, icon: Trash2Icon },
3163
- held: {
3164
- label: "Goal held",
3165
- pill: WAITING_PILL_CLASS,
3166
- icon: PauseCircleIcon,
3167
- },
3168
- continuation: { label: "Continuing toward the goal", pill: NEUTRAL_PILL, icon: ArrowRightIcon },
3169
- };
4787
+ // Shared production chunks can be cyclic. Resolve icon bindings during render,
4788
+ // after their modules initialize, rather than permanently capturing undefined.
4789
+ function goalMeta(action: GoalItem["action"]): GoalMeta {
4790
+ const metadata: Record<GoalItem["action"], GoalMeta> = {
4791
+ set: {
4792
+ label: "Goal set",
4793
+ pill: "border-og-accent/30 bg-og-accent/10 text-og-accent",
4794
+ icon: TargetIcon,
4795
+ },
4796
+ updated: { label: "Goal updated", pill: NEUTRAL_PILL, icon: PencilLineIcon },
4797
+ completed: {
4798
+ label: "Goal completed",
4799
+ pill: "border-og-status-idle/30 bg-og-status-idle/10 text-og-status-idle",
4800
+ icon: CheckIcon,
4801
+ },
4802
+ paused: {
4803
+ label: "Goal paused",
4804
+ pill: WAITING_PILL_CLASS,
4805
+ icon: PauseIcon,
4806
+ },
4807
+ resumed: { label: "Goal resumed", pill: NEUTRAL_PILL, icon: PlayIcon },
4808
+ cleared: { label: "Goal cleared", pill: NEUTRAL_PILL, icon: Trash2Icon },
4809
+ held: {
4810
+ label: "Goal held",
4811
+ pill: WAITING_PILL_CLASS,
4812
+ icon: PauseCircleIcon,
4813
+ },
4814
+ continuation: { label: "Continuing toward the goal", pill: NEUTRAL_PILL, icon: ArrowRightIcon },
4815
+ };
4816
+ return metadata[action];
4817
+ }
3170
4818
 
3171
4819
  /**
3172
4820
  * A goal landmark pill. Resolves its label, accent/tone, and glyph from
3173
- * {@link GOAL_META} so all six actions are visually distinguishable while the
4821
+ * {@link goalMeta} so all six actions are visually distinguishable while the
3174
4822
  * palette stays restrained — see that table for the per-action rationale.
3175
4823
  */
3176
4824
  function GoalRow({ item }: { item: GoalItem }) {
3177
4825
  const enter = useEntranceAnimation();
3178
- const { label, pill, icon: Icon } = GOAL_META[item.action];
4826
+ const { label, pill, icon: Icon } = goalMeta(item.action);
3179
4827
  return (
3180
4828
  <div className={cn(enter && "animate-og-enter", "flex justify-center")}>
3181
4829
  <span
@@ -3196,13 +4844,16 @@ function GoalRow({ item }: { item: GoalItem }) {
3196
4844
 
3197
4845
  function MachineInputBatchRow({
3198
4846
  item,
4847
+ onOpenSession,
3199
4848
  loadVideoArtifactPlayback,
3200
4849
  }: {
3201
4850
  item: MachineInputBatchItem;
4851
+ onOpenSession?: ((sessionId: string) => void) | undefined;
3202
4852
  loadVideoArtifactPlayback?: VideoArtifactPlaybackLoader | undefined;
3203
4853
  }) {
3204
4854
  const enter = useEntranceAnimation();
3205
- const label = machineInputBatchLabel(item.members);
4855
+ const identity = useAgentIdentity();
4856
+ const label = machineInputBatchLabel(item.members, identity.titleFor);
3206
4857
  const single = item.members.length === 1 ? item.members[0]! : null;
3207
4858
  if (single?.kind === "media_generation_result" && single.result) {
3208
4859
  return (
@@ -3213,12 +4864,21 @@ function MachineInputBatchRow({
3213
4864
  );
3214
4865
  }
3215
4866
  const singleSummary = single ? cleanMachineInputSummary(single.summary) : "";
4867
+ // An agent's words live in its named row behind the pill; the pill already
4868
+ // says who it is from, so nothing repeats beneath it.
3216
4869
  const showCollapsedSummary =
3217
- single != null && machineInputSummaryIsUseful(single.kind, singleSummary);
4870
+ !item.compact &&
4871
+ single != null &&
4872
+ !isAgentMember(single) &&
4873
+ machineInputSummaryIsUseful(single.kind, singleSummary);
3218
4874
 
3219
4875
  return (
3220
4876
  <div className={cn(enter && "animate-og-enter", "flex flex-col items-center gap-1.5")}>
3221
- <details className="group w-full max-w-full" data-og-machine-input-batch="">
4877
+ <details
4878
+ className="group w-full max-w-full"
4879
+ data-og-machine-input-batch=""
4880
+ title={`Received ${formatClockTime(item.occurredAt)}`}
4881
+ >
3222
4882
  <summary className="flex cursor-pointer list-none justify-center [&::-webkit-details-marker]:hidden">
3223
4883
  <span
3224
4884
  className={cn(
@@ -3233,14 +4893,26 @@ function MachineInputBatchRow({
3233
4893
  <span className="truncate">{label}</span>
3234
4894
  </span>
3235
4895
  </summary>
3236
- <div className="mx-auto mt-2 w-full max-w-lg space-y-2 border-t border-og-border/50 pt-2">
3237
- {item.members.map((member) => (
3238
- <MachineInputRow
3239
- key={member.id}
3240
- member={member}
3241
- loadVideoArtifactPlayback={loadVideoArtifactPlayback}
3242
- />
3243
- ))}
4896
+ <div className="mx-auto mt-2 w-full max-w-lg space-y-1.5 border-t border-og-border/50 pt-2">
4897
+ <p className="text-og-xs text-og-fg-subtle">
4898
+ Received <time dateTime={item.occurredAt}>{formatClockTime(item.occurredAt)}</time>
4899
+ </p>
4900
+ {groupMachineInputMembers(item.members).map((group) =>
4901
+ group.kind === "agent" ? (
4902
+ <AgentMemberRow
4903
+ key={group.members[0]!.id}
4904
+ members={group.members}
4905
+ onOpenSession={onOpenSession}
4906
+ />
4907
+ ) : (
4908
+ <MachineInputRow
4909
+ key={group.member.id}
4910
+ member={group.member}
4911
+ onOpenSession={onOpenSession}
4912
+ loadVideoArtifactPlayback={loadVideoArtifactPlayback}
4913
+ />
4914
+ ),
4915
+ )}
3244
4916
  </div>
3245
4917
  </details>
3246
4918
  {showCollapsedSummary ? (
@@ -3252,11 +4924,94 @@ function MachineInputBatchRow({
3252
4924
  );
3253
4925
  }
3254
4926
 
4927
+ type MachineInputMemberGroup =
4928
+ | { kind: "agent"; members: MachineInputBatchItem["members"] }
4929
+ | { kind: "other"; member: MachineInputBatchItem["members"][number] };
4930
+
4931
+ /**
4932
+ * Agent members get named rows; repeated progress notes from one agent fold
4933
+ * into its single row (latest note shown, earlier ones behind the disclosure).
4934
+ */
4935
+ function groupMachineInputMembers(
4936
+ members: MachineInputBatchItem["members"],
4937
+ ): MachineInputMemberGroup[] {
4938
+ const groups: MachineInputMemberGroup[] = [];
4939
+ const progressBySource = new Map<string, MachineInputBatchItem["members"]>();
4940
+ for (const member of members) {
4941
+ if (!isAgentMember(member)) {
4942
+ groups.push({ kind: "other", member });
4943
+ continue;
4944
+ }
4945
+ if (member.kind === "child_progress") {
4946
+ const existing = progressBySource.get(member.sourceId);
4947
+ if (existing) {
4948
+ existing.push(member);
4949
+ continue;
4950
+ }
4951
+ const bucket = [member];
4952
+ progressBySource.set(member.sourceId, bucket);
4953
+ groups.push({ kind: "agent", members: bucket });
4954
+ continue;
4955
+ }
4956
+ groups.push({ kind: "agent", members: [member] });
4957
+ }
4958
+ return groups;
4959
+ }
4960
+
4961
+ function agentMemberIcon(member: MachineInputBatchItem["members"][number]): ReactNode {
4962
+ if (member.kind === "child_terminal_result" && member.classification === "failure") {
4963
+ return <XCircleIcon />;
4964
+ }
4965
+ if (member.kind === "child_paused") return <PauseCircleIcon />;
4966
+ return <CornerDownRightIcon />;
4967
+ }
4968
+
4969
+ /** One named row for an update sent by (or about) another agent. */
4970
+ function AgentMemberRow({
4971
+ members,
4972
+ onOpenSession,
4973
+ }: {
4974
+ members: MachineInputBatchItem["members"];
4975
+ onOpenSession?: ((sessionId: string) => void) | undefined;
4976
+ }) {
4977
+ const identity = useAgentIdentity();
4978
+ const latest = members[members.length - 1]!;
4979
+ const sessionId = agentMemberSessionId(latest);
4980
+ const title = agentMemberTitle(latest, identity.titleFor(sessionId));
4981
+ const texts = members.map((member) => agentMemberText(member));
4982
+ const latestText = texts[texts.length - 1]!;
4983
+ const bodies = texts
4984
+ .map((text, index) => ({ id: members[index]!.id, body: text.body }))
4985
+ .filter((entry) => entry.body.length > 0);
4986
+ return (
4987
+ <AgentRow
4988
+ icon={agentMemberIcon(latest)}
4989
+ title={title}
4990
+ preview={latestText.preview ? truncate(latestText.preview, 320) : null}
4991
+ previewLines={2}
4992
+ meta={members.length > 1 ? `+${members.length - 1} earlier` : null}
4993
+ sessionId={sessionId}
4994
+ onOpenSession={onOpenSession ?? identity.onOpenSession}
4995
+ failed={latest.kind === "child_terminal_result" && latest.classification === "failure"}
4996
+ >
4997
+ {bodies.length > 0 ? (
4998
+ <>
4999
+ {bodies.map((entry) => (
5000
+ <AgentRowText key={entry.id}>{entry.body}</AgentRowText>
5001
+ ))}
5002
+ </>
5003
+ ) : undefined}
5004
+ </AgentRow>
5005
+ );
5006
+ }
5007
+
3255
5008
  function MachineInputRow({
3256
5009
  member,
5010
+ onOpenSession,
3257
5011
  loadVideoArtifactPlayback,
3258
5012
  }: {
3259
5013
  member: MachineInputBatchItem["members"][number];
5014
+ onOpenSession?: ((sessionId: string) => void) | undefined;
3260
5015
  loadVideoArtifactPlayback?: VideoArtifactPlaybackLoader | undefined;
3261
5016
  }) {
3262
5017
  if (member.kind === "media_generation_result" && member.result) {
@@ -3283,6 +5038,11 @@ function MachineInputRow({
3283
5038
  {truncate(summary, 320)}
3284
5039
  </p>
3285
5040
  ) : null}
5041
+ <ChildSessionLink
5042
+ kind={member.kind}
5043
+ sourceId={member.sourceId}
5044
+ onOpenSession={onOpenSession}
5045
+ />
3286
5046
  </div>
3287
5047
  </div>
3288
5048
  );
@@ -3356,12 +5116,36 @@ function formatVideoDuration(seconds: number): string {
3356
5116
 
3357
5117
  function NoticeRow({ item }: { item: NoticeItem }) {
3358
5118
  const enter = useEntranceAnimation();
5119
+ if (item.allowance) return <AllowanceExhaustedRow refusal={item.allowance} />;
5120
+ if (item.sessionUsageLimit) return <SessionUsageLimitRow stop={item.sessionUsageLimit} />;
5121
+ if (item.recordedOutcome) {
5122
+ return (
5123
+ <details
5124
+ className="group text-og-sm text-og-fg-muted"
5125
+ role="note"
5126
+ data-og-recorded-outcome="wait"
5127
+ >
5128
+ <summary className="flex cursor-pointer list-none items-center gap-2 py-1 [&::-webkit-details-marker]:hidden">
5129
+ <ChevronRightIcon
5130
+ aria-hidden
5131
+ className="size-3.5 transition-transform group-open:rotate-90"
5132
+ />
5133
+ <span title={new Date(item.occurredAt).toLocaleString()}>
5134
+ {recordedWaitSummary(item)}
5135
+ </span>
5136
+ </summary>
5137
+ <p className="mt-1 whitespace-pre-wrap break-words pl-5 text-og-fg-muted">{item.text}</p>
5138
+ </details>
5139
+ );
5140
+ }
5141
+ const presentedTone = noticeTone(item);
5142
+ const resolvedApproval = noticeIsResolvedApproval(item);
3359
5143
  const tone =
3360
- item.tone === "failed"
5144
+ presentedTone === "failed"
3361
5145
  ? "border-og-status-failed/35 bg-og-status-failed/10 text-og-status-failed"
3362
- : item.tone === "waiting"
5146
+ : presentedTone === "waiting"
3363
5147
  ? WAITING_PILL_CLASS
3364
- : "border-og-border bg-og-surface-1 text-og-fg-muted";
5148
+ : NEUTRAL_PILL;
3365
5149
  return (
3366
5150
  <div
3367
5151
  className={cn(
@@ -3371,16 +5155,22 @@ function NoticeRow({ item }: { item: NoticeItem }) {
3371
5155
  )}
3372
5156
  role="status"
3373
5157
  >
3374
- <TriangleAlertIcon
3375
- className={cn("mt-0.5 size-4 shrink-0", item.tone === "cancelled" && "opacity-60")}
3376
- />
5158
+ {resolvedApproval ? (
5159
+ <CheckIcon className="mt-0.5 size-4 shrink-0 opacity-70" aria-hidden="true" />
5160
+ ) : (
5161
+ <TriangleAlertIcon
5162
+ className={cn("mt-0.5 size-4 shrink-0", item.tone === "cancelled" && "opacity-60")}
5163
+ />
5164
+ )}
3377
5165
  <div className="min-w-0 flex-1">
3378
- <span className="whitespace-pre-wrap break-words">{item.text}</span>
5166
+ <span className="whitespace-pre-wrap break-words">{noticeDisplayText(item)}</span>
3379
5167
  {item.details ? (
3380
5168
  <details className="mt-2 text-og-control">
3381
5169
  <summary className="cursor-pointer font-medium">{item.details.label}</summary>
3382
- <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">
3383
- {JSON.stringify(item.details.value, null, 2)}
5170
+ <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">
5171
+ {typeof item.details.value === "string"
5172
+ ? item.details.value
5173
+ : JSON.stringify(item.details.value, null, 2)}
3384
5174
  </pre>
3385
5175
  </details>
3386
5176
  ) : null}
@@ -3399,6 +5189,41 @@ function NoticeRow({ item }: { item: NoticeItem }) {
3399
5189
  );
3400
5190
  }
3401
5191
 
5192
+ /**
5193
+ * "Waited for 2 agents · 3m 5s" once later input ended the wait, otherwise
5194
+ * "Waiting for 2 agents · since 10:32" (with the date on a later day). The
5195
+ * reason stays behind the disclosure.
5196
+ */
5197
+ function recordedWaitSummary(item: NoticeItem): ReactNode {
5198
+ const agents = item.waitingAgents ?? 0;
5199
+ const target = agents > 0 ? ` for ${agents} ${agents === 1 ? "agent" : "agents"}` : "";
5200
+ const waitedMs = item.waitEndedAt
5201
+ ? durationBetween(item.occurredAt, item.waitEndedAt)
5202
+ : undefined;
5203
+ if (waitedMs !== undefined) {
5204
+ return (
5205
+ <>
5206
+ Waited{target} ·{" "}
5207
+ <time dateTime={`PT${Math.floor(waitedMs / 1000)}S`}>{formatElapsed(waitedMs)}</time>
5208
+ </>
5209
+ );
5210
+ }
5211
+ // An open wait is historical once its day has passed; keep its date visible.
5212
+ const started = new Date(item.occurredAt);
5213
+ const sameDay = started.toDateString() === new Date().toDateString();
5214
+ return (
5215
+ <>
5216
+ {item.waitEndedAt ? "Waited" : "Waiting"}
5217
+ {target} · since{" "}
5218
+ <time dateTime={item.occurredAt}>
5219
+ {sameDay
5220
+ ? formatClockTime(item.occurredAt)
5221
+ : started.toLocaleString(undefined, { dateStyle: "medium", timeStyle: "short" })}
5222
+ </time>
5223
+ </>
5224
+ );
5225
+ }
5226
+
3402
5227
  /**
3403
5228
  * The inline connection-recovery card: missing or lapsed access surfaces as a
3404
5229
  * calm, tappable affordance instead of a raw provider-domain error. The `reason`
@@ -3445,6 +5270,7 @@ function AuthNeededRow({
3445
5270
  ? `${provider} tools unavailable`
3446
5271
  : `${actionLabel} ${provider}`;
3447
5272
  const reasonLine = recommendation?.rationale ?? authReasonLine(item.reason);
5273
+ const hostAuthorizationUrl = item.authoritySource === "host" ? item.authorizationUrl : null;
3448
5274
 
3449
5275
  const start = async () => {
3450
5276
  if (!onReconnect || busy) {
@@ -3487,27 +5313,40 @@ function AuthNeededRow({
3487
5313
  ) : null}
3488
5314
  </div>
3489
5315
  </div>
3490
- {!unavailable && onReconnect ? (
5316
+ {hostAuthorizationUrl ? (
5317
+ <a
5318
+ href={hostAuthorizationUrl}
5319
+ rel="noreferrer"
5320
+ target="_blank"
5321
+ className={cn(
5322
+ PRIMARY_ACTION_CLASS,
5323
+ "transition-colors hover:bg-og-primary-hover pointer-coarse:min-h-9",
5324
+ )}
5325
+ >
5326
+ <RefreshCwIcon className="size-3.5" aria-hidden />
5327
+ {actionLabel}
5328
+ </a>
5329
+ ) : item.authoritySource !== "host" && !unavailable && onReconnect ? (
3491
5330
  <button
3492
5331
  type="button"
3493
5332
  onClick={() => void start()}
3494
5333
  disabled={busy}
3495
5334
  className={cn(
3496
5335
  PRIMARY_ACTION_CLASS,
3497
- "transition-colors hover:bg-og-accent-strong disabled:opacity-70 pointer-coarse:min-h-9",
5336
+ "transition-colors hover:bg-og-primary-hover disabled:opacity-50 pointer-coarse:min-h-9",
3498
5337
  )}
3499
5338
  >
3500
5339
  <RefreshCwIcon className={cn("size-3.5", busy && "animate-og-spin")} aria-hidden />
3501
5340
  {busy ? "Opening…" : actionLabel}
3502
5341
  </button>
3503
- ) : !unavailable && item.authorizationUrl ? (
5342
+ ) : item.authoritySource !== "host" && !unavailable && item.authorizationUrl ? (
3504
5343
  <a
3505
5344
  href={item.authorizationUrl}
3506
5345
  rel="noreferrer"
3507
5346
  target="_blank"
3508
5347
  className={cn(
3509
5348
  PRIMARY_ACTION_CLASS,
3510
- "transition-colors hover:bg-og-accent-strong pointer-coarse:min-h-9",
5349
+ "transition-colors hover:bg-og-primary-hover pointer-coarse:min-h-9",
3511
5350
  )}
3512
5351
  >
3513
5352
  <RefreshCwIcon className="size-3.5" aria-hidden />