@flamingo-stack/openframe-frontend-core 0.0.508 → 0.0.509

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 (306) hide show
  1. package/dist/chat-protocol/decode.d.ts +58 -0
  2. package/dist/chat-protocol/decode.d.ts.map +1 -0
  3. package/dist/chat-protocol/encode.d.ts +44 -0
  4. package/dist/chat-protocol/encode.d.ts.map +1 -0
  5. package/dist/chat-protocol/env-flag.d.ts +35 -0
  6. package/dist/chat-protocol/env-flag.d.ts.map +1 -0
  7. package/dist/chat-protocol/events.d.ts +216 -0
  8. package/dist/chat-protocol/events.d.ts.map +1 -0
  9. package/dist/chat-protocol/frames.d.ts +213 -0
  10. package/dist/chat-protocol/frames.d.ts.map +1 -0
  11. package/dist/chat-protocol/index.cjs +506 -0
  12. package/dist/chat-protocol/index.cjs.map +1 -0
  13. package/dist/chat-protocol/index.d.ts +18 -0
  14. package/dist/chat-protocol/index.d.ts.map +1 -0
  15. package/dist/chat-protocol/index.js +490 -0
  16. package/dist/chat-protocol/index.js.map +1 -0
  17. package/dist/chat-protocol/ip-normalize.d.ts +44 -0
  18. package/dist/chat-protocol/ip-normalize.d.ts.map +1 -0
  19. package/dist/chat-protocol/nats-decoder.d.ts +26 -0
  20. package/dist/chat-protocol/nats-decoder.d.ts.map +1 -0
  21. package/dist/{chunk-3SQ5KXHQ.cjs → chunk-2T4GTV25.cjs} +9 -9
  22. package/dist/{chunk-3SQ5KXHQ.cjs.map → chunk-2T4GTV25.cjs.map} +1 -1
  23. package/dist/{chunk-CWAOV2FK.cjs → chunk-3CGZPAGR.cjs} +3 -3
  24. package/dist/{chunk-CWAOV2FK.cjs.map → chunk-3CGZPAGR.cjs.map} +1 -1
  25. package/dist/{chunk-TLMJHMXJ.js → chunk-3QSR22IC.js} +12 -12
  26. package/dist/chunk-3QSR22IC.js.map +1 -0
  27. package/dist/{chunk-TWYNR4TZ.cjs → chunk-4G5TLHJD.cjs} +7141 -4799
  28. package/dist/chunk-4G5TLHJD.cjs.map +1 -0
  29. package/dist/{chunk-ADPMHWOE.js → chunk-72XAID7Y.js} +4 -4
  30. package/dist/{chunk-PAGKRNWK.js → chunk-7WZHBQ4J.js} +2 -2
  31. package/dist/{chunk-OCEKO5CW.js → chunk-AEELJC4N.js} +12106 -9764
  32. package/dist/chunk-AEELJC4N.js.map +1 -0
  33. package/dist/{chunk-Z42BGM6Q.cjs → chunk-BT2WWDFP.cjs} +5 -5
  34. package/dist/{chunk-Z42BGM6Q.cjs.map → chunk-BT2WWDFP.cjs.map} +1 -1
  35. package/dist/{chunk-7LEL3RIX.cjs → chunk-C7OY3PQP.cjs} +11 -11
  36. package/dist/{chunk-7LEL3RIX.cjs.map → chunk-C7OY3PQP.cjs.map} +1 -1
  37. package/dist/{chunk-RHGGEKPQ.cjs → chunk-CIOLOQKQ.cjs} +4 -4
  38. package/dist/{chunk-RHGGEKPQ.cjs.map → chunk-CIOLOQKQ.cjs.map} +1 -1
  39. package/dist/{chunk-TSZHM74B.js → chunk-DC2TKS7C.js} +2 -2
  40. package/dist/{chunk-M2GOR3XQ.cjs → chunk-DYPYR7FR.cjs} +87 -87
  41. package/dist/{chunk-M2GOR3XQ.cjs.map → chunk-DYPYR7FR.cjs.map} +1 -1
  42. package/dist/{chunk-I64ABCDX.js → chunk-I2B6X77L.js} +2 -2
  43. package/dist/{chunk-NIZDKTGL.cjs → chunk-K5MSCMDK.cjs} +37 -37
  44. package/dist/{chunk-NIZDKTGL.cjs.map → chunk-K5MSCMDK.cjs.map} +1 -1
  45. package/dist/{chunk-DTYRYB2N.js → chunk-K6QTZ7CZ.js} +2 -2
  46. package/dist/{chunk-4LTMXDUS.js → chunk-KBN7PMFT.js} +2 -2
  47. package/dist/{chunk-TBMV7I5N.js → chunk-KXZO2WZX.js} +2 -2
  48. package/dist/{chunk-QTYZMP6D.cjs → chunk-LBAKLT6T.cjs} +62 -62
  49. package/dist/chunk-LBAKLT6T.cjs.map +1 -0
  50. package/dist/{chunk-KEBYLU3U.js → chunk-LFUPI7VX.js} +2 -2
  51. package/dist/{chunk-LDTR4IVZ.cjs → chunk-M37PBG4N.cjs} +31 -31
  52. package/dist/{chunk-LDTR4IVZ.cjs.map → chunk-M37PBG4N.cjs.map} +1 -1
  53. package/dist/{chunk-WA7RR64F.js → chunk-PO5O654H.js} +6 -5
  54. package/dist/chunk-PO5O654H.js.map +1 -0
  55. package/dist/{chunk-WK4N5VBX.cjs → chunk-QJJISLPA.cjs} +28 -27
  56. package/dist/chunk-QJJISLPA.cjs.map +1 -0
  57. package/dist/{chunk-2F44PLTV.cjs → chunk-T7MCSA55.cjs} +26 -26
  58. package/dist/{chunk-2F44PLTV.cjs.map → chunk-T7MCSA55.cjs.map} +1 -1
  59. package/dist/{chunk-WJQHJD7J.js → chunk-TWSKTJHW.js} +2 -2
  60. package/dist/{chunk-ZSQHZYCO.cjs → chunk-UQDMJ6DH.cjs} +13 -13
  61. package/dist/{chunk-ZSQHZYCO.cjs.map → chunk-UQDMJ6DH.cjs.map} +1 -1
  62. package/dist/{chunk-TZRUCD56.js → chunk-V3ZAAFE3.js} +5 -5
  63. package/dist/{chunk-LRGHJPET.js → chunk-XGIFOHKE.js} +2 -2
  64. package/dist/{chunk-POOMO3PA.cjs → chunk-YQEPRYT5.cjs} +7 -7
  65. package/dist/{chunk-POOMO3PA.cjs.map → chunk-YQEPRYT5.cjs.map} +1 -1
  66. package/dist/components/case-studies/index.cjs +8 -8
  67. package/dist/components/case-studies/index.js +2 -2
  68. package/dist/components/chat/chat-message-enhanced.d.ts.map +1 -1
  69. package/dist/components/chat/chat-message-list.d.ts.map +1 -1
  70. package/dist/components/chat/hooks/index.d.ts +0 -1
  71. package/dist/components/chat/hooks/index.d.ts.map +1 -1
  72. package/dist/components/chat/hooks/use-chat.d.ts.map +1 -1
  73. package/dist/components/chat/hooks/use-chunk-catchup.d.ts.map +1 -1
  74. package/dist/components/chat/hooks/use-nats-chat-adapter.d.ts +15 -48
  75. package/dist/components/chat/hooks/use-nats-chat-adapter.d.ts.map +1 -1
  76. package/dist/components/chat/hooks/use-sse-chat-adapter.d.ts +10 -57
  77. package/dist/components/chat/hooks/use-sse-chat-adapter.d.ts.map +1 -1
  78. package/dist/components/chat/index.cjs +10 -2
  79. package/dist/components/chat/index.cjs.map +1 -1
  80. package/dist/components/chat/index.d.ts +1 -0
  81. package/dist/components/chat/index.d.ts.map +1 -1
  82. package/dist/components/chat/index.js +21 -13
  83. package/dist/components/chat/stream/chat-dialog-store.d.ts +184 -0
  84. package/dist/components/chat/stream/chat-dialog-store.d.ts.map +1 -0
  85. package/dist/components/chat/stream/chat-stream-reducer.d.ts +380 -0
  86. package/dist/components/chat/stream/chat-stream-reducer.d.ts.map +1 -0
  87. package/dist/components/chat/stream/delta-batcher.d.ts +51 -0
  88. package/dist/components/chat/stream/delta-batcher.d.ts.map +1 -0
  89. package/dist/components/chat/stream/index.d.ts +17 -0
  90. package/dist/components/chat/stream/index.d.ts.map +1 -0
  91. package/dist/components/chat/stream/message-mutations.d.ts +77 -0
  92. package/dist/components/chat/stream/message-mutations.d.ts.map +1 -0
  93. package/dist/components/chat/stream/use-chat-stream-reducer.d.ts +22 -0
  94. package/dist/components/chat/stream/use-chat-stream-reducer.d.ts.map +1 -0
  95. package/dist/components/chat/types/api.types.d.ts +1 -81
  96. package/dist/components/chat/types/api.types.d.ts.map +1 -1
  97. package/dist/components/chat/types/processing.types.d.ts +2 -93
  98. package/dist/components/chat/types/processing.types.d.ts.map +1 -1
  99. package/dist/components/chat/types/unified-chat-state.types.d.ts +15 -0
  100. package/dist/components/chat/types/unified-chat-state.types.d.ts.map +1 -1
  101. package/dist/components/chat/utils/extract-incomplete-message-state.d.ts +34 -6
  102. package/dist/components/chat/utils/extract-incomplete-message-state.d.ts.map +1 -1
  103. package/dist/components/chat/utils/history-merge.d.ts.map +1 -1
  104. package/dist/components/chat/utils/index.d.ts +2 -3
  105. package/dist/components/chat/utils/index.d.ts.map +1 -1
  106. package/dist/components/chat/utils/process-historical-messages.d.ts +59 -11
  107. package/dist/components/chat/utils/process-historical-messages.d.ts.map +1 -1
  108. package/dist/components/contact/index.cjs +3 -3
  109. package/dist/components/contact/index.js +2 -2
  110. package/dist/components/docs/index.cjs +5 -5
  111. package/dist/components/docs/index.js +4 -4
  112. package/dist/components/embeds/index.cjs +3 -3
  113. package/dist/components/embeds/index.js +2 -2
  114. package/dist/components/faq/index.cjs +3 -3
  115. package/dist/components/faq/index.js +2 -2
  116. package/dist/components/features/index.cjs +2 -2
  117. package/dist/components/features/index.js +1 -1
  118. package/dist/components/help-center-pages/index.cjs +22 -22
  119. package/dist/components/help-center-pages/index.js +13 -13
  120. package/dist/components/index.cjs +174 -132
  121. package/dist/components/index.cjs.map +1 -1
  122. package/dist/components/index.js +65 -23
  123. package/dist/components/index.js.map +1 -1
  124. package/dist/components/meeting-scheduler/index.cjs +34 -34
  125. package/dist/components/meeting-scheduler/index.js +3 -3
  126. package/dist/components/navigation/index.cjs +2 -2
  127. package/dist/components/navigation/index.js +1 -1
  128. package/dist/components/onboarding-guides/index.cjs +5 -5
  129. package/dist/components/onboarding-guides/index.js +4 -4
  130. package/dist/components/onboarding-guides/onboarding-guide-detail-view.d.ts.map +1 -1
  131. package/dist/components/related-content/index.cjs +3 -3
  132. package/dist/components/related-content/index.js +2 -2
  133. package/dist/components/shared/product-release/release-detail-page.d.ts.map +1 -1
  134. package/dist/components/tickets/index.cjs +6 -6
  135. package/dist/components/tickets/index.js +5 -5
  136. package/dist/components/ui/index.cjs +44 -2
  137. package/dist/components/ui/index.cjs.map +1 -1
  138. package/dist/components/ui/index.d.ts +1 -2
  139. package/dist/components/ui/index.d.ts.map +1 -1
  140. package/dist/components/ui/index.js +55 -13
  141. package/dist/components/ui/markdown/base-components.d.ts +51 -0
  142. package/dist/components/ui/markdown/base-components.d.ts.map +1 -0
  143. package/dist/components/ui/markdown/engine.d.ts +99 -0
  144. package/dist/components/ui/markdown/engine.d.ts.map +1 -0
  145. package/dist/components/ui/markdown/heading-ids.d.ts +65 -0
  146. package/dist/components/ui/markdown/heading-ids.d.ts.map +1 -0
  147. package/dist/components/ui/markdown/index.d.ts +18 -0
  148. package/dist/components/ui/markdown/index.d.ts.map +1 -0
  149. package/dist/components/ui/markdown/mermaid-diagram.d.ts +79 -0
  150. package/dist/components/ui/markdown/mermaid-diagram.d.ts.map +1 -0
  151. package/dist/components/ui/markdown/rich/embed-overrides.d.ts +14 -0
  152. package/dist/components/ui/markdown/rich/embed-overrides.d.ts.map +1 -0
  153. package/dist/components/ui/markdown/rich/rich-markdown-renderer.d.ts +40 -0
  154. package/dist/components/ui/markdown/rich/rich-markdown-renderer.d.ts.map +1 -0
  155. package/dist/components/ui/markdown/rich/shortcodes.d.ts +8 -0
  156. package/dist/components/ui/markdown/rich/shortcodes.d.ts.map +1 -0
  157. package/dist/components/ui/markdown/sanitize.d.ts +153 -0
  158. package/dist/components/ui/markdown/sanitize.d.ts.map +1 -0
  159. package/dist/components/ui/markdown/simple-markdown-renderer.d.ts +23 -0
  160. package/dist/components/ui/markdown/simple-markdown-renderer.d.ts.map +1 -0
  161. package/dist/components/ui/markdown/streaming.d.ts +78 -0
  162. package/dist/components/ui/markdown/streaming.d.ts.map +1 -0
  163. package/dist/components/ui/markdown/text-size.d.ts +34 -0
  164. package/dist/components/ui/markdown/text-size.d.ts.map +1 -0
  165. package/dist/index.cjs +60 -2
  166. package/dist/index.cjs.map +1 -1
  167. package/dist/index.js +71 -13
  168. package/dist/utils/index.cjs +169 -40
  169. package/dist/utils/index.cjs.map +1 -1
  170. package/dist/utils/index.d.ts +1 -0
  171. package/dist/utils/index.d.ts.map +1 -1
  172. package/dist/utils/index.js +162 -41
  173. package/dist/utils/index.js.map +1 -1
  174. package/dist/utils/markdown-fences.d.ts +42 -0
  175. package/dist/utils/markdown-fences.d.ts.map +1 -0
  176. package/dist/utils/markdown-heading-id.d.ts +85 -0
  177. package/dist/utils/markdown-heading-id.d.ts.map +1 -0
  178. package/dist/utils/markdown-section-extractor.d.ts.map +1 -1
  179. package/package.json +7 -1
  180. package/src/chat-protocol/__tests__/__snapshots__/nats-decoder-golden.test.ts.snap +319 -0
  181. package/src/chat-protocol/__tests__/chat-protocol.test.ts +516 -0
  182. package/src/chat-protocol/__tests__/env-flag.test.ts +52 -0
  183. package/src/chat-protocol/__tests__/ip-normalize.test.ts +136 -0
  184. package/src/chat-protocol/__tests__/nats-decoder-golden.test.ts +237 -0
  185. package/src/chat-protocol/decode.ts +278 -0
  186. package/src/chat-protocol/encode.ts +71 -0
  187. package/src/chat-protocol/env-flag.ts +39 -0
  188. package/src/chat-protocol/events.ts +254 -0
  189. package/src/chat-protocol/frames.ts +245 -0
  190. package/src/chat-protocol/index.ts +21 -0
  191. package/src/chat-protocol/ip-normalize.ts +146 -0
  192. package/src/chat-protocol/nats-decoder.ts +264 -0
  193. package/src/components/chat/.chat-message-list.md +10 -6
  194. package/src/components/chat/__tests__/chat-message-list.test.tsx +450 -9
  195. package/src/components/chat/__tests__/chat-message-streaming-memo.test.tsx +207 -0
  196. package/src/components/chat/__tests__/chat-pending-turn.test.tsx +250 -0
  197. package/src/components/chat/chat-message-enhanced.tsx +122 -20
  198. package/src/components/chat/chat-message-list.tsx +515 -133
  199. package/src/components/chat/embeddable-chat.tsx +6 -0
  200. package/src/components/chat/hooks/.index.md +30 -33
  201. package/src/components/chat/hooks/.use-nats-chat-adapter.md +36 -55
  202. package/src/components/chat/hooks/__tests__/__snapshots__/conversation-id-persistence-golden.test.ts.snap +37 -0
  203. package/src/components/chat/hooks/__tests__/__snapshots__/sse-stream-golden.test.ts.snap +0 -0
  204. package/src/components/chat/hooks/__tests__/chunk-catchup-dialog-staleness.test.ts +92 -0
  205. package/src/components/chat/hooks/__tests__/conversation-id-persistence-golden.test.ts +438 -0
  206. package/src/components/chat/hooks/__tests__/sse-stream-golden.test.ts +318 -0
  207. package/src/components/chat/hooks/index.ts +0 -1
  208. package/src/components/chat/hooks/use-chat.ts +8 -0
  209. package/src/components/chat/hooks/use-chunk-catchup.ts +26 -1
  210. package/src/components/chat/hooks/use-nats-chat-adapter.ts +254 -825
  211. package/src/components/chat/hooks/use-sse-chat-adapter.ts +380 -684
  212. package/src/components/chat/index.ts +5 -0
  213. package/src/components/chat/stream/__tests__/__snapshots__/chat-stream-reducer-golden.test.ts.snap +944 -0
  214. package/src/components/chat/stream/__tests__/chat-dialog-store.test.ts +806 -0
  215. package/src/components/chat/stream/__tests__/chat-stream-reducer-golden.test.ts +467 -0
  216. package/src/components/chat/stream/__tests__/chat-stream-reducer.test.ts +1330 -0
  217. package/src/components/chat/stream/__tests__/delta-batcher.test.ts +255 -0
  218. package/src/components/chat/stream/__tests__/use-chat-stream-reducer.test.ts +105 -0
  219. package/src/components/chat/stream/chat-dialog-store.ts +536 -0
  220. package/src/components/chat/stream/chat-stream-reducer.ts +1928 -0
  221. package/src/components/chat/stream/delta-batcher.ts +168 -0
  222. package/src/components/chat/stream/index.ts +55 -0
  223. package/src/components/chat/stream/message-mutations.ts +340 -0
  224. package/src/components/chat/stream/use-chat-stream-reducer.ts +126 -0
  225. package/src/components/chat/thinking-display.tsx +1 -1
  226. package/src/components/chat/types/.api.types.md +39 -49
  227. package/src/components/chat/types/.processing.types.md +29 -58
  228. package/src/components/chat/types/.unified-chat-state.types.md +2 -2
  229. package/src/components/chat/types/api.types.ts +6 -73
  230. package/src/components/chat/types/processing.types.ts +11 -53
  231. package/src/components/chat/types/unified-chat-state.types.ts +16 -0
  232. package/src/components/chat/utils/.extract-incomplete-message-state.md +21 -3
  233. package/src/components/chat/utils/.history-merge.md +44 -3
  234. package/src/components/chat/utils/.index.md +31 -40
  235. package/src/components/chat/utils/__tests__/__snapshots__/process-historical-messages-golden.test.ts.snap +420 -0
  236. package/src/components/chat/utils/__tests__/__snapshots__/segment-accumulator-golden.test.ts.snap +605 -0
  237. package/src/components/chat/utils/__tests__/extract-incomplete-message-state.test.ts +106 -0
  238. package/src/components/chat/utils/__tests__/history-merge.test.ts +160 -0
  239. package/src/components/chat/utils/__tests__/process-historical-messages-approvals.test.ts +61 -0
  240. package/src/components/chat/utils/__tests__/process-historical-messages-golden.test.ts +394 -0
  241. package/src/components/chat/utils/__tests__/segment-accumulator-golden.test.ts +270 -0
  242. package/src/components/chat/utils/extract-incomplete-message-state.ts +93 -6
  243. package/src/components/chat/utils/history-merge.ts +85 -5
  244. package/src/components/chat/utils/index.ts +5 -8
  245. package/src/components/chat/utils/process-historical-messages.ts +361 -385
  246. package/src/components/onboarding-guides/onboarding-guide-detail-view.tsx +22 -4
  247. package/src/components/shared/legal-document/legal-document-page.tsx +1 -1
  248. package/src/components/shared/product-release/release-detail-page.tsx +19 -4
  249. package/src/components/ui/__tests__/__snapshots__/markdown-parity.test.tsx.snap +2420 -0
  250. package/src/components/ui/__tests__/markdown-parity.test.tsx +2540 -0
  251. package/src/components/ui/index.ts +1 -2
  252. package/src/components/ui/markdown/__tests__/mermaid-security.test.ts +176 -0
  253. package/src/components/ui/markdown/__tests__/mermaid-stale-render.test.tsx +151 -0
  254. package/src/components/ui/markdown/__tests__/sanitize-invariant.test.ts +354 -0
  255. package/src/components/ui/markdown/__tests__/sanitize-render.test.tsx +118 -0
  256. package/src/components/ui/markdown/__tests__/streaming.test.tsx +436 -0
  257. package/src/components/ui/markdown/base-components.tsx +360 -0
  258. package/src/components/ui/markdown/engine.tsx +315 -0
  259. package/src/components/ui/markdown/heading-ids.ts +239 -0
  260. package/src/components/ui/markdown/index.ts +49 -0
  261. package/src/components/ui/markdown/mermaid-diagram.tsx +323 -0
  262. package/src/components/ui/markdown/rich/embed-overrides.tsx +199 -0
  263. package/src/components/ui/markdown/rich/rich-markdown-renderer.tsx +184 -0
  264. package/src/components/ui/markdown/rich/shortcodes.ts +170 -0
  265. package/src/components/ui/markdown/sanitize.ts +3584 -0
  266. package/src/components/ui/markdown/simple-markdown-renderer.tsx +28 -0
  267. package/src/components/ui/markdown/streaming.ts +390 -0
  268. package/src/components/ui/markdown/text-size.ts +106 -0
  269. package/src/components/ui/release-changelog-section.tsx +1 -1
  270. package/src/components/ui/ticket-info-section.tsx +1 -1
  271. package/src/utils/index.ts +1 -0
  272. package/src/utils/markdown-fences.ts +127 -0
  273. package/src/utils/markdown-heading-id.ts +348 -0
  274. package/src/utils/markdown-section-extractor.ts +41 -56
  275. package/dist/chunk-OCEKO5CW.js.map +0 -1
  276. package/dist/chunk-QTYZMP6D.cjs.map +0 -1
  277. package/dist/chunk-TLMJHMXJ.js.map +0 -1
  278. package/dist/chunk-TWYNR4TZ.cjs.map +0 -1
  279. package/dist/chunk-WA7RR64F.js.map +0 -1
  280. package/dist/chunk-WK4N5VBX.cjs.map +0 -1
  281. package/dist/components/chat/hooks/use-realtime-chunk-processor.d.ts +0 -6
  282. package/dist/components/chat/hooks/use-realtime-chunk-processor.d.ts.map +0 -1
  283. package/dist/components/chat/utils/chunk-parser.d.ts +0 -25
  284. package/dist/components/chat/utils/chunk-parser.d.ts.map +0 -1
  285. package/dist/components/ui/rich-markdown-renderer.d.ts +0 -34
  286. package/dist/components/ui/rich-markdown-renderer.d.ts.map +0 -1
  287. package/dist/components/ui/simple-markdown-renderer.d.ts +0 -73
  288. package/dist/components/ui/simple-markdown-renderer.d.ts.map +0 -1
  289. package/src/components/chat/hooks/.use-realtime-chunk-processor.md +0 -77
  290. package/src/components/chat/hooks/use-realtime-chunk-processor.ts +0 -489
  291. package/src/components/chat/utils/.chunk-parser.md +0 -62
  292. package/src/components/chat/utils/chunk-parser.ts +0 -262
  293. package/src/components/ui/.simple-markdown-renderer.md +0 -52
  294. package/src/components/ui/rich-markdown-renderer.tsx +0 -1223
  295. package/src/components/ui/simple-markdown-renderer.tsx +0 -964
  296. /package/dist/{chunk-ADPMHWOE.js.map → chunk-72XAID7Y.js.map} +0 -0
  297. /package/dist/{chunk-PAGKRNWK.js.map → chunk-7WZHBQ4J.js.map} +0 -0
  298. /package/dist/{chunk-TSZHM74B.js.map → chunk-DC2TKS7C.js.map} +0 -0
  299. /package/dist/{chunk-I64ABCDX.js.map → chunk-I2B6X77L.js.map} +0 -0
  300. /package/dist/{chunk-DTYRYB2N.js.map → chunk-K6QTZ7CZ.js.map} +0 -0
  301. /package/dist/{chunk-4LTMXDUS.js.map → chunk-KBN7PMFT.js.map} +0 -0
  302. /package/dist/{chunk-TBMV7I5N.js.map → chunk-KXZO2WZX.js.map} +0 -0
  303. /package/dist/{chunk-KEBYLU3U.js.map → chunk-LFUPI7VX.js.map} +0 -0
  304. /package/dist/{chunk-WJQHJD7J.js.map → chunk-TWSKTJHW.js.map} +0 -0
  305. /package/dist/{chunk-TZRUCD56.js.map → chunk-V3ZAAFE3.js.map} +0 -0
  306. /package/dist/{chunk-LRGHJPET.js.map → chunk-XGIFOHKE.js.map} +0 -0
@@ -1,11 +1,11 @@
1
1
  "use client"
2
2
 
3
- import { useRef, useEffect, useLayoutEffect, useImperativeHandle, forwardRef } from "react"
3
+ import { useRef, useState, useCallback, useEffect, useLayoutEffect, useImperativeHandle, forwardRef } from "react"
4
4
  import { useStickToBottom } from "use-stick-to-bottom"
5
5
  import { cn } from "../../utils/cn"
6
6
  import { ChatMessageEnhanced } from "./chat-message-enhanced"
7
7
  import { ChatMessageListSkeleton } from "./chat-message-skeleton"
8
- import { DotsLoaderIcon } from "../icons-v2-generated"
8
+ import { DotsLoaderIcon, Arrow02DownIcon } from "../icons-v2-generated"
9
9
  import { CyclingPhrase } from "./cycling-phrase"
10
10
  import type { ChatMessageListProps } from "./types"
11
11
  import { SCROLL_ANCHOR } from "./types/message.types"
@@ -27,6 +27,19 @@ const STREAMING_WORDS = [
27
27
  'Riffing',
28
28
  ] as const
29
29
 
30
+ /** Distance from the bottom (px) still counted as "at the bottom".
31
+ * Mirrors `use-stick-to-bottom`'s internal `STICK_TO_BOTTOM_OFFSET_PX`
32
+ * so the jump-to-bottom button and the library agree on the boundary. */
33
+ const BOTTOM_THRESHOLD_PX = 70
34
+
35
+ /** How long each follow re-assert keeps CHASING a moving bottom, in ms.
36
+ * Mirrors the library's own `RETAIN_ANIMATION_DURATION_MS`, which is the
37
+ * window its resize-follow uses for exactly this: while it is open the
38
+ * spring re-reads `calculatedTargetScrollTop` every frame instead of
39
+ * finishing against the target captured when the call was made. */
40
+ const FOLLOW_RETAIN_MS = 350
41
+
42
+
30
43
  /*
31
44
  * Stick-to-bottom: `use-stick-to-bottom` (stackblitz-labs)
32
45
  *
@@ -53,6 +66,9 @@ const STREAMING_WORDS = [
53
66
  * chat / sent a message; show me the bottom immediately").
54
67
  * - **Load-more sentinel** (top-of-list IntersectionObserver) for
55
68
  * infinite-scroll-UP. Distinct concern from stick-to-bottom.
69
+ * - **Bottom-follow intent** (see `followBottomRef` below). The
70
+ * library's follow is gated on its own `isAtBottom` flag, which this
71
+ * list's layout can lose silently; we own the INTENT and re-assert.
56
72
  *
57
73
  * `useStickToBottom`'s returned refs are MUTABLE refs + ref callbacks
58
74
  * — pass them directly as `ref={scrollRef}` / `ref={contentRef}`.
@@ -142,6 +158,45 @@ const ChatMessageList = forwardRef<HTMLDivElement, ChatMessageListProps>(
142
158
  const dialogIdRef = useRef(dialogId)
143
159
  dialogIdRef.current = dialogId
144
160
 
161
+ // ---- Bottom-follow intent (list-owned lock) ----------------------
162
+ // The library follows the bottom only while ITS OWN `isAtBottom` flag
163
+ // is true, and this list's layout loses that flag silently in two
164
+ // ways:
165
+ //
166
+ // 1. **The scroller's own box changes.** The library's
167
+ // ResizeObserver watches `contentRef` (INSIDE the scroller) only.
168
+ // The streaming loader unmounting and the host's source-chip row
169
+ // mounting — both siblings BELOW the scroller (see render) —
170
+ // change the scroller's `clientHeight`, which moves the bottom
171
+ // without emitting anything the library can observe.
172
+ // 2. **Resize-induced scroll events read as a user gesture.**
173
+ // Fetch-mode entity cards mount as a skeleton and settle to a
174
+ // different height; a shrink clamps `scrollTop` down, and the
175
+ // library's scroll handler can see that as a scroll-UP and
176
+ // permanently escape the lock (its `resizeDifference` guard is a
177
+ // `setTimeout(…, 1)` race, so this is intermittent).
178
+ //
179
+ // Either way the thread stops following mid-turn. It bites hardest on
180
+ // card-heavy answers — a slash-command listing (`/getting-started`)
181
+ // renders a dozen fetch-mode cards with cover images that keep
182
+ // resolving for SECONDS after the stream ends, so everything after
183
+ // the lock was lost lands below the fold.
184
+ //
185
+ // Fix: this list owns the INTENT. Sending a message (or opening a
186
+ // dialog) arms it; only a real user scroll-up gesture — or a
187
+ // top-anchored turn, which deliberately parks elsewhere — disarms it.
188
+ // While armed, ANY geometry change re-asserts `scrollToBottom`, and
189
+ // because that call also re-sets the library's `isAtBottom`, a lost
190
+ // lock self-heals instead of being permanent.
191
+ const followBottomRef = useRef(false)
192
+
193
+ // Drives the jump-to-bottom affordance. Derived from GEOMETRY, not
194
+ // from the library's `isAtBottom`: that flag is the one this list
195
+ // loses silently (see above), and a stale `true` would hide the
196
+ // button in exactly the situation it exists for. Threshold mirrors
197
+ // the library's own `STICK_TO_BOTTOM_OFFSET_PX`.
198
+ const [atBottom, setAtBottom] = useState(true)
199
+
145
200
  // Tracks previous render state for explicit "force-stick" decisions
146
201
  // (dialog change, first message, new user message). The library
147
202
  // covers ongoing streaming; these are about INTENT transitions.
@@ -178,11 +233,13 @@ const ChatMessageList = forwardRef<HTMLDivElement, ChatMessageListProps>(
178
233
 
179
234
  if (dialogChanged) {
180
235
  // Opening a different chat — instant snap to bottom.
236
+ followBottomRef.current = true
181
237
  void scrollToBottom({ animation: 'instant', ignoreEscapes: true })
182
238
  return
183
239
  }
184
240
  if (newCount > 0 && prevCount === 0) {
185
241
  // First message arrived in an empty chat — instant snap.
242
+ followBottomRef.current = true
186
243
  void scrollToBottom({ animation: 'instant', ignoreEscapes: true })
187
244
  return
188
245
  }
@@ -207,54 +264,284 @@ const ChatMessageList = forwardRef<HTMLDivElement, ChatMessageListProps>(
207
264
  const newSlice = prevLastIdx >= 0 ? messages.slice(prevLastIdx + 1) : messages.slice(prevCount)
208
265
  const hasNewUser = newSlice.some((m) => m.role === 'user')
209
266
  if (hasNewUser) {
267
+ // Arms the follow intent for the WHOLE turn: the answer's
268
+ // async growth (attachment images, streamed tokens, entity
269
+ // cards resolving out of their skeletons long after the
270
+ // stream ends) is re-asserted by the follow effect below.
271
+ // This replaces the old per-`<img>` `load`-handler hack —
272
+ // same defect class, but the ResizeObserver sees every
273
+ // source of growth, not just images present at send time.
274
+ followBottomRef.current = true
210
275
  void scrollToBottom({ animation: 'instant', ignoreEscapes: true })
211
-
212
- // Image-attachment growth follow-up: if the new user message
213
- // carries inline `<img>` elements (chat-attachment markdown
214
- // emits `![alt](/api/storage/view/...)` which the markdown
215
- // renderer turns into `<img>` / Next.js `<Image>`), those
216
- // images load AFTER the initial paint. Each image-load grows
217
- // the bubble height, but `useStickToBottom`'s `resize:
218
- // 'smooth'` spring sometimes loses anchor under rapid
219
- // resize events from Next.js Image's progressive
220
- // dimensioning (observed: scroll lands 50-100px short of
221
- // bottom — exactly one image-load short).
222
- //
223
- // Fix: after the initial scrollToBottom snaps, scan the
224
- // content area for `<img>` elements that haven't loaded
225
- // yet, and attach one-time `load` handlers that re-trigger
226
- // `scrollToBottom`. Idempotent — runs for any image,
227
- // affects only the new-user-message diff (effect dep is
228
- // `messages`).
229
- //
230
- // `requestAnimationFrame` so the DOM has the new bubble
231
- // before we query. Without rAF the new-message `<img>`s
232
- // haven't been committed yet — querySelectorAll returns
233
- // an empty set.
234
- requestAnimationFrame(() => {
235
- const el = contentRef.current
236
- if (!el) return
237
- const imgs = el.querySelectorAll('img')
238
- imgs.forEach((img) => {
239
- if (img.complete) return
240
- const onLoad = () => {
241
- img.removeEventListener('load', onLoad)
242
- img.removeEventListener('error', onLoad)
243
- // `ignoreEscapes: true` because the user may have
244
- // accidentally moved scroll during the image-load
245
- // window; we want their fresh send to be visible.
246
- void scrollToBottom({ animation: 'instant', ignoreEscapes: true })
247
- }
248
- img.addEventListener('load', onLoad, { once: true })
249
- img.addEventListener('error', onLoad, { once: true })
250
- })
251
- })
252
276
  }
253
277
  // Assistant-only new messages → the library's resize-watch
254
278
  // already keeps the bottom locked when the user hasn't
255
279
  // escaped. No explicit call needed; spring animation runs.
256
280
  }
257
- }, [autoScroll, messages, dialogId, scrollToBottom, contentRef])
281
+ }, [autoScroll, messages, dialogId, scrollToBottom])
282
+
283
+ // ---- Follow the bottom while the intent is armed -----------------
284
+ // Installs once per mount (all deps are stable). See the
285
+ // `followBottomRef` comment above for WHY this exists.
286
+ useEffect(() => {
287
+ if (!autoScroll) return
288
+ const scroller = scrollRef.current
289
+ const content = contentRef.current
290
+ if (!scroller || !content) return
291
+
292
+ // Re-assert on ANY geometry change, WITH `ignoreEscapes` — because
293
+ // this list, not the library, owns the follow intent.
294
+ //
295
+ // Without the flag the re-assert is a no-op the moment the library's
296
+ // `escapedFromLock` is set, and it does NOT "re-set isAtBottom" the way
297
+ // this comment used to claim: `handleScroll` only clears the escape
298
+ // after a scroll it reads as DOWNWARD, and a gated animation never
299
+ // produces one. Since the escape is set by exactly the false positives
300
+ // this lock exists to survive — a resize clamping `scrollTop`, a
301
+ // wheel doing an inertial nudge — the thread followed the first
302
+ // message (those calls pass `ignoreEscapes: true`) and then silently
303
+ // stopped following for the rest of the session.
304
+ //
305
+ // Handing control back on a REAL gesture is `disarm()` below: wheel-up,
306
+ // ArrowUp/PageUp/Home, touch-drag down, and scrollbar drags. Those run
307
+ // before this and clear the intent, so an armed re-assert only ever
308
+ // fights the library's own heuristic, never the user.
309
+ //
310
+ // Cheap to call repeatedly — the library returns the in-flight
311
+ // promise when an animation with the same behaviour is running.
312
+ const measure = () => {
313
+ const distance = scroller.scrollHeight - scroller.scrollTop - scroller.clientHeight
314
+ setAtBottom(distance <= BOTTOM_THRESHOLD_PX)
315
+ }
316
+
317
+ const reassert = () => {
318
+ // `wait: true` + `duration` are NOT decoration — they are the library's
319
+ // COALESCING contract, and this call fires once per growth frame.
320
+ //
321
+ // Without `wait`, `scrollToBottom` opens with `state.animation =
322
+ // undefined` unconditionally, so the "an animation with this behaviour
323
+ // is already running, return its promise" check can never hit: every
324
+ // frame spawned ANOTHER independent spring chain, all of them stepping
325
+ // the same shared `state.velocity` / `state.accumulated`. They compound
326
+ // and fight, which is why a smooth re-assert misbehaved where an
327
+ // `instant` one (it just writes the target, so extra chains are
328
+ // idempotent) looked fine. With `wait: true` the animation survives,
329
+ // the identity check hits, and every later re-assert folds into the
330
+ // one chain already in flight.
331
+ //
332
+ // `duration` opens the retain window in which the chain refreshes
333
+ // `startTarget` from `calculatedTargetScrollTop` each frame — that is
334
+ // what makes ONE spring track a bottom that keeps moving, instead of
335
+ // finishing against the height the thread had when the call was made.
336
+ //
337
+ // `setIsAtBottom(true)` still runs on EVERY call, before the coalescing
338
+ // return, so a folded-in re-assert keeps doing the job it exists for:
339
+ // healing the library's lock after this list's layout silently loses it.
340
+ //
341
+ // Suppressed while a load-older fetch has an anchor pending: that
342
+ // prepend's growth belongs to the anchoring layout effect below, which
343
+ // is restoring the reader's position in the page they just loaded.
344
+ // Following it would drag them straight back to the live end and make
345
+ // paging through history impossible.
346
+ if (followBottomRef.current && !loadOlderAnchorRef.current) {
347
+ void scrollToBottom({
348
+ animation: 'smooth',
349
+ ignoreEscapes: true,
350
+ wait: true,
351
+ duration: FOLLOW_RETAIN_MS,
352
+ })
353
+ }
354
+ // Measured on every geometry change, follow armed or not: a
355
+ // sibling growing below the scroller moves the bottom without
356
+ // any scroll event, and the button has to notice.
357
+ measure()
358
+ }
359
+
360
+ const ro = new ResizeObserver(reassert)
361
+ ro.observe(content) // tokens, entity cards, images
362
+ ro.observe(scroller) // streaming loader / source chips below it
363
+
364
+ // GROWTH WATCH — the load-bearing one, because the ResizeObserver above
365
+ // is not reliable for this list.
366
+ //
367
+ // `content` is a SNAPSHOT taken when this effect installed, and the deps
368
+ // are all stable, so it is never refreshed. React swaps that node out
369
+ // (skeleton → thread, host re-layouts), after which the observer is
370
+ // holding a detached element and sees nothing at all. Observed symptom:
371
+ // the callback fires ONLY on a window resize — that is the scroller,
372
+ // whose own box does change — while streamed tokens grow the real
373
+ // content silently, and the thread never follows.
374
+ //
375
+ // A poll on `scrollHeight` sidesteps the whole question of WHICH node is
376
+ // live: it reads the scroller (always mounted, it owns the listeners)
377
+ // and reacts to growth from any source — tokens, images settling, cards
378
+ // resolving. Gated on the intent, so it costs one rAF-tick comparison
379
+ // per frame only while actually following, and stops the moment a
380
+ // gesture disarms.
381
+ let growthRaf = 0
382
+ let lastScrollHeight = scroller.scrollHeight
383
+ const watchGrowth = () => {
384
+ growthRaf = requestAnimationFrame(watchGrowth)
385
+
386
+ const height = scroller.scrollHeight
387
+ if (height === lastScrollHeight) return
388
+ lastScrollHeight = height
389
+ reassert()
390
+ }
391
+ growthRaf = requestAnimationFrame(watchGrowth)
392
+
393
+ // --- user-intent disarm ---------------------------------------
394
+ // Only a deliberate move AWAY from the bottom releases the lock.
395
+ // Resize-derived scroll events are NOT gestures, which is exactly
396
+ // the distinction the library's heuristic gets wrong.
397
+ const disarm = () => {
398
+ followBottomRef.current = false
399
+ // MUST cancel the in-flight spring too: an armed re-assert runs with
400
+ // `ignoreEscapes`, and the library pins `scrollTop` for the whole
401
+ // duration of such an animation — so clearing the intent alone would
402
+ // leave the user's gesture fighting a scroll they cannot win until it
403
+ // settles. `stopScroll` ends it on the spot, handing the scroller
404
+ // straight back.
405
+ stopScroll()
406
+ }
407
+ const onWheel = (e: WheelEvent) => {
408
+ if (e.deltaY < 0) disarm()
409
+ }
410
+ const onKeyDown = (e: KeyboardEvent) => {
411
+ if (e.key === 'ArrowUp' || e.key === 'PageUp' || e.key === 'Home') disarm()
412
+ }
413
+ let touchY: number | null = null
414
+ const onTouchStart = (e: TouchEvent) => {
415
+ touchY = e.touches[0]?.clientY ?? null
416
+ }
417
+ const onTouchMove = (e: TouchEvent) => {
418
+ const y = e.touches[0]?.clientY
419
+ if (y == null || touchY == null) return
420
+ if (y > touchY) disarm() // finger travels DOWN → content moves up
421
+ touchY = y
422
+ }
423
+ // Scrollbar drag emits neither wheel nor touch — a scrollTop
424
+ // DECREASE while the pointer is held down is the same intent.
425
+ //
426
+ // ALL pointer listeners are on `window`, deliberately. The scroller is a
427
+ // native `overflow-y-auto` div (OverlayScrollbars was reverted in #1512),
428
+ // so its native scrollbar IS part of `scroller` and a `pointerdown` on the
429
+ // thumb does reach it — but `window` is a safe superset that also survives
430
+ // any future host component (or a return to a custom-scrollbar library
431
+ // that renders the thumb OUTSIDE the viewport) without stranding
432
+ // `pointerDown` at `false` and reintroducing the yank-back-mid-drag bug.
433
+ //
434
+ // The POINTER listeners use CAPTURE phase, so the guarantee is structural
435
+ // rather than contingent on nothing between us and `window` ever calling
436
+ // `stopPropagation` on the pointer path.
437
+ //
438
+ // `blur` deliberately does NOT use capture. Focus events don't bubble
439
+ // but they DO capture, so a capture listener on `window` fires for
440
+ // EVERY element blur in the document (the focus-delegation idiom) —
441
+ // any focus change during a hold, e.g. the chat input blurring as the
442
+ // user presses down on the scrollbar, would clear `pointerDown` and
443
+ // re-open the very yank-back bug this block prevents. A TRUE window
444
+ // blur targets `window` itself, so an at-target (bubble-phase)
445
+ // listener still receives it: capture buys nothing and costs
446
+ // correctness. Verified in jsdom: descendant blur reaches the capture
447
+ // listener only; window blur reaches both.
448
+ //
449
+ // `pointerDown` must be cleared by every way a drag can END, not just
450
+ // `pointerup`: a button released OUTSIDE the window, or the tab losing
451
+ // focus mid-drag, delivers no `pointerup` at all and would leave the
452
+ // flag stuck true. From then on ANY resize-induced scrollTop clamp — a
453
+ // fetch-mode card settling from skeleton to a shorter height, i.e. the
454
+ // exact case this whole block exists for — reads as a scroll-up gesture
455
+ // and drops the lock. Same for merely HOLDING the button down (text
456
+ // selection) while a card shrinks. Hence `pointercancel` + window
457
+ // `blur`.
458
+ let pointerDown = false
459
+ let lastScrollTop = scroller.scrollTop
460
+ const onPointerDown = () => {
461
+ pointerDown = true
462
+ }
463
+ // A drag that RELEASES at the live end expresses the same intent as
464
+ // clicking jump-to-bottom. The scroll events during that drag can't
465
+ // re-arm (see the gate in `onScroll`), and releasing the button emits
466
+ // no further `scroll`, so the re-arm is evaluated once here, when the
467
+ // gesture is actually over.
468
+ const rearmIfAtBottom = () => {
469
+ if (followBottomRef.current) return
470
+ const distance = scroller.scrollHeight - scroller.scrollTop - scroller.clientHeight
471
+ if (distance <= BOTTOM_THRESHOLD_PX) followBottomRef.current = true
472
+ }
473
+ const endPointer = () => {
474
+ pointerDown = false
475
+ rearmIfAtBottom()
476
+ }
477
+ const onScroll = () => {
478
+ const st = scroller.scrollTop
479
+ if (pointerDown && st < lastScrollTop) disarm()
480
+ lastScrollTop = st
481
+ // Landing at the bottom by hand is the SAME expressed intent as
482
+ // clicking jump-to-bottom, so it re-arms a lock an earlier scroll-up
483
+ // released. Without it the thread sits at-bottom (button hidden,
484
+ // because `atBottom` is true) and then silently drifts away as the
485
+ // answer grows.
486
+ //
487
+ // Gated on the drag being OVER (`!pointerDown`). A mid-drag `scroll`
488
+ // is not a gesture that has ended, and a short scrollbar drag that
489
+ // starts at the live end stays inside the 70px band — so re-arming
490
+ // here would undo the `disarm()` two lines above on the very same
491
+ // event and yank the reader back down mid-drag. Releasing the button
492
+ // at the bottom still re-arms on the next scroll (or, if none
493
+ // follows, on the next deliberate return to the end).
494
+ if (
495
+ !pointerDown &&
496
+ !followBottomRef.current &&
497
+ scroller.scrollHeight - st - scroller.clientHeight <= BOTTOM_THRESHOLD_PX
498
+ ) {
499
+ followBottomRef.current = true
500
+ }
501
+ measure()
502
+ }
503
+ measure()
504
+
505
+ const winCapture = { capture: true, passive: true } as const
506
+
507
+ scroller.addEventListener('wheel', onWheel, { passive: true })
508
+ scroller.addEventListener('keydown', onKeyDown)
509
+ scroller.addEventListener('touchstart', onTouchStart, { passive: true })
510
+ scroller.addEventListener('touchmove', onTouchMove, { passive: true })
511
+ window.addEventListener('pointerdown', onPointerDown, winCapture)
512
+ scroller.addEventListener('scroll', onScroll, { passive: true })
513
+ window.addEventListener('pointerup', endPointer, winCapture)
514
+ window.addEventListener('pointercancel', endPointer, winCapture)
515
+ // Bubble phase, NOT capture — see the note above.
516
+ window.addEventListener('blur', endPointer, { passive: true })
517
+
518
+ return () => {
519
+ ro.disconnect()
520
+ cancelAnimationFrame(growthRaf)
521
+ scroller.removeEventListener('wheel', onWheel)
522
+ scroller.removeEventListener('keydown', onKeyDown)
523
+ scroller.removeEventListener('touchstart', onTouchStart)
524
+ scroller.removeEventListener('touchmove', onTouchMove)
525
+ // `capture` must match the add call or the listener is not removed.
526
+ window.removeEventListener('pointerdown', onPointerDown, { capture: true })
527
+ scroller.removeEventListener('scroll', onScroll)
528
+ window.removeEventListener('pointerup', endPointer, { capture: true })
529
+ window.removeEventListener('pointercancel', endPointer, { capture: true })
530
+ // Added WITHOUT capture — the remove must match.
531
+ window.removeEventListener('blur', endPointer)
532
+ }
533
+ // `isLoading` IS load-bearing here, even though the body never reads it.
534
+ // On the loading branch this component returns the skeleton, so
535
+ // `scrollRef.current` is null and the bail-out above fires. With all four
536
+ // other deps identity-stable (`autoScroll` plus three `[]`-memoized values
537
+ // from `useStickToBottom`), the effect then never ran again: no
538
+ // ResizeObserver, no wheel/touch/keyboard escape hatches, `atBottom` stuck
539
+ // at its initial `true` so the jump-to-bottom button could not render, and
540
+ // the follow lock never re-asserted — for the rest of the component's
541
+ // life. A `false → true → false` round trip (host loads another page or
542
+ // dialog) also swaps the scroller element, so the stale closure kept
543
+ // observing and listening on DETACHED nodes.
544
+ }, [autoScroll, scrollRef, contentRef, scrollToBottom, stopScroll, isLoading])
258
545
 
259
546
  // ---- Passive-demo hard pin (pinBottom) ---------------------------
260
547
  // Scripted in-page replays (the Fae/Mingo demos) have no human at the
@@ -384,7 +671,10 @@ const ChatMessageList = forwardRef<HTMLDivElement, ChatMessageListProps>(
384
671
  // `stopScroll` MUST precede scrollIntoView — the imperative scrollTop
385
672
  // write is what the library's geometry tracker uses to flip
386
673
  // `escapedFromLock`, so any in-flight scrollToBottom spring is
387
- // first cancelled.
674
+ // first cancelled. The list-owned follow intent is released for the
675
+ // same reason: this turn deliberately parks at the message TOP, so
676
+ // its own async growth must NOT drag the reader to the bottom.
677
+ followBottomRef.current = false
388
678
  stopScroll()
389
679
  // Instant (not smooth): the markdown body contains async <img>
390
680
  // children whose loads race a 300ms smooth animation and leave
@@ -466,12 +756,63 @@ const ChatMessageList = forwardRef<HTMLDivElement, ChatMessageListProps>(
466
756
  )
467
757
  observer.observe(sentinelElement)
468
758
  return () => observer.disconnect()
469
- }, [hasNextPage, isFetchingNextPage, scrollRef])
759
+ // `isLoading` is load-bearing for the SAME reason as the follow effect and
760
+ // the imperative handle: the loading branch returns the skeleton, so both
761
+ // the scroller and the sentinel are unmounted and this effect bails at the
762
+ // guard above. Whether it ever ran again then depended on `hasNextPage`
763
+ // happening to flip in the same commit that cleared `isLoading` — true for
764
+ // a plain `useInfiniteQuery` (both derive from the first page landing),
765
+ // FALSE wherever `isLoading` is a composite that outlives the messages
766
+ // query (the ticket view ANDs in its dialog query). In that case
767
+ // `hasNextPage` went true while the skeleton was still up, the effect ran
768
+ // once against null refs, and nothing re-ran it afterwards: no
769
+ // IntersectionObserver was ever created and infinite-scroll-up was dead
770
+ // for the life of the component.
771
+ }, [hasNextPage, isFetchingNextPage, scrollRef, isLoading])
470
772
 
471
773
  // Expose the scroll container ref to parents that need it (rare,
472
774
  // but the existing public contract). Library's `scrollRef` is a
473
775
  // MutableRefObject<HTMLElement> so we cast to the public type.
474
- useImperativeHandle(ref, () => scrollRef.current as HTMLDivElement, [scrollRef])
776
+ // `isLoading` for the same reason as the follow effect: on the loading
777
+ // branch `scrollRef.current` is null, and without it in the deps a parent
778
+ // that mounted this list while loading held `null` forever.
779
+ useImperativeHandle(ref, () => scrollRef.current as HTMLDivElement, [scrollRef, isLoading])
780
+
781
+ // Ref adapters — MEMOIZED, and declared above the early return so the hook
782
+ // order is unconditional (see CLAUDE.md rule 3).
783
+ //
784
+ // `useStickToBottom` treats these as ref callbacks: React invokes
785
+ // `ref(null)` then `ref(el)` whenever the callback IDENTITY changes, and the
786
+ // library responds by disconnecting its ResizeObserver and building a fresh
787
+ // one (plus removing/re-adding its scroll and wheel listeners). A plain arrow
788
+ // per render therefore churned all of that on EVERY committed frame — one per
789
+ // batched delta flush, so ~60/s while streaming — and, because the rebuild
790
+ // resets the library's `previousHeight` to undefined, it permanently pinned
791
+ // the library to its `initial` animation and never used `resize`, inverting
792
+ // the policy configured above.
793
+ //
794
+ // The library types its refs against `HTMLElement` (broader than
795
+ // `HTMLDivElement`) and `MutableRefObject` is INVARIANT in its type
796
+ // parameter, so a direct assignment into the JSX `Ref<HTMLDivElement>` slot
797
+ // fails type-check; a callback is contravariant in its argument, so this
798
+ // adapter type-checks AND preserves the library's mount bookkeeping (the
799
+ // intersection `MutableRefObject<T> & RefCallback<T>` means calling
800
+ // `scrollRef(el)` both writes `scrollRef.current` and runs that bookkeeping).
801
+ // The explicit `: void` return is required too: the library's signature
802
+ // returns `void | (() => void)` (React 19's optional ref-cleanup contract),
803
+ // which the hub's React types reject in a `Ref<T>` slot.
804
+ const setScrollRef = useCallback(
805
+ (el: HTMLDivElement | null): void => {
806
+ scrollRef(el)
807
+ },
808
+ [scrollRef],
809
+ )
810
+ const setContentRef = useCallback(
811
+ (el: HTMLDivElement | null): void => {
812
+ contentRef(el)
813
+ },
814
+ [contentRef],
815
+ )
475
816
 
476
817
  if (isLoading) {
477
818
  return (
@@ -485,28 +826,6 @@ const ChatMessageList = forwardRef<HTMLDivElement, ChatMessageListProps>(
485
826
  )
486
827
  }
487
828
 
488
- // Adapt the library's refs to React's `Ref<HTMLDivElement>` JSX
489
- // slot. The library types its refs against `HTMLElement` (broader
490
- // than `HTMLDivElement`); MutableRefObject is INVARIANT in its
491
- // type parameter, so a direct assignment fails type-check. The
492
- // callback form is contravariant in its arg, so a tiny adapter
493
- // that invokes the library's RefCallback portion type-checks
494
- // cleanly AND preserves the library's internal observer setup.
495
- // (The intersection type `MutableRefObject<T> & RefCallback<T>`
496
- // means calling `scrollRef(el)` writes `el` into `scrollRef.current`
497
- // AND triggers the library's mount-bookkeeping.)
498
- // Note the explicit `: void` return type. The library's RefCallback
499
- // signature returns `void | (() => void)` (React 19's optional ref-
500
- // cleanup contract). The hub's React types reject that union in a
501
- // JSX `Ref<T>` slot. Force `void` here — the library doesn't use
502
- // the cleanup return path in any meaningful way for the public hook.
503
- const setScrollRef = (el: HTMLDivElement | null): void => {
504
- scrollRef(el)
505
- }
506
- const setContentRef = (el: HTMLDivElement | null): void => {
507
- contentRef(el)
508
- }
509
-
510
829
  // Footer-pinned streaming loader. Rendered OUTSIDE the scroller (a
511
830
  // sibling of the scroll div, like the sticky approvals below) so it
512
831
  // stays put while messages stream and grow — no jitter from the
@@ -528,75 +847,138 @@ const ChatMessageList = forwardRef<HTMLDivElement, ChatMessageListProps>(
528
847
  !(pendingApprovals && pendingApprovals.length > 0)
529
848
 
530
849
  return (
531
- <div className="relative flex-1 min-h-0 flex flex-col">
532
- <div
533
- ref={setScrollRef}
534
- className={cn(
535
- "flex h-full w-full flex-col overflow-y-auto overflow-x-hidden flex-1",
536
- // `overscroll-contain` (default ON, opt-out via `overscrollContain={false}`):
537
- // reaching the top/bottom of the thread must NOT chain the wheel/
538
- // touch scroll to the page behind the chat — e.g. the company-hub
539
- // deck (native body scroll + sticky slide panels) would otherwise
540
- // advance slides while you scroll the chat (fixed in #1501; kept as
541
- // the prop default here). Passive in-page DEMO chats disable it so
542
- // the surrounding page keeps scrolling normally when the pointer is
543
- // over the (non-interactive) thread.
544
- overscrollContain && "overscroll-contain",
545
- "scrollbar-thin scrollbar-track-transparent scrollbar-thumb-ods-border/30 hover:scrollbar-thumb-ods-text-secondary/30",
546
- className,
547
- )}
548
- {...props}
549
- >
850
+ <div className="flex-1 min-h-0 flex flex-col">
851
+ {/* Positioning box for the jump-to-bottom button: it wraps ONLY the
852
+ scroller, so `absolute bottom-…` really is the scroller's lower
853
+ edge. The outer flex column also holds the streaming loader and the
854
+ approvals bar, and anchoring against THAT box put the button over
855
+ the loader for the whole of every stream — precisely when it is
856
+ most likely to be visible. */}
857
+ <div className="relative flex-1 min-h-0 flex flex-col">
550
858
  <div
551
- ref={setContentRef}
859
+ ref={setScrollRef}
552
860
  className={cn(
553
- // `fullWidth=true` drops the centered-narrow column for
554
- // side-panel hosts (e.g. multi-platform-hub Mingo). Same
555
- // semantics as ChatHeader / ChatInput / ChatFooter.
556
- fullWidth
557
- ? "flex w-full flex-col pb-[var(--spacing-system-xs)] min-w-0"
558
- : "mx-auto flex w-full max-w-ods-content-narrow flex-col pb-[var(--spacing-system-xs)] min-w-0",
559
- contentClassName ?? "px-[var(--spacing-system-m)]",
861
+ "flex h-full w-full flex-col overflow-y-auto overflow-x-hidden flex-1",
862
+ // `overscroll-contain` (default ON, opt-out via `overscrollContain={false}`):
863
+ // reaching the top/bottom of the thread must NOT chain the wheel/
864
+ // touch scroll to the page behind the chat — e.g. the company-hub
865
+ // deck (native body scroll + sticky slide panels) would otherwise
866
+ // advance slides while you scroll the chat (fixed in #1501; kept as
867
+ // the prop default here). Passive in-page DEMO chats disable it so
868
+ // the surrounding page keeps scrolling normally when the pointer is
869
+ // over the (non-interactive) thread.
870
+ overscrollContain && "overscroll-contain",
871
+ "scrollbar-thin scrollbar-track-transparent scrollbar-thumb-ods-border/30 hover:scrollbar-thumb-ods-text-secondary/30",
872
+ className,
560
873
  )}
561
- style={{ minHeight: '100%' }}
874
+ {...props}
562
875
  >
563
- {hasNextPage && (
564
- <div ref={sentinelRef} className="h-px" />
565
- )}
566
- <div className="flex-1" />
567
- {messages.map((message, index) => {
568
- // Hidden messages (synthetic continuation prompts the host
569
- // injects after an approval card) are part of the API
570
- // conversation history but never render. Skipping here
571
- // keeps the visible thread coherent see
572
- // `Message.hidden` doc-comment in message.types.ts.
573
- if (message.hidden) return null
574
- return (
575
- <ChatMessageEnhanced
576
- key={message.id}
577
- ref={getRegisterMessageEl(message.id)}
578
- role={message.role}
579
- name={message.name}
580
- content={message.content}
581
- timestamp={message.timestamp}
582
- isTyping={index === messages.length - 1 && isTyping && message.role === 'assistant'}
583
- avatar={showAvatars ? message.avatar : null}
584
- showAvatar={showAvatars}
585
- assistantType={message.assistantType || assistantType}
586
- approvalVariant={approvalVariant}
587
- authorType={message.authorType}
588
- assistantIcon={message.role !== 'user' ? assistantIcon : undefined}
589
- chatRefs={message.chatRefs}
590
- contextItems={message.contextItems}
591
- resolveContextIcon={resolveContextIcon}
592
- renderContextItem={renderContextItem}
593
- renderMention={renderMention}
594
- renderEntityCard={renderEntityCard}
595
- NavLinkAnchor={NavLinkAnchor}
596
- />
597
- )
598
- })}
876
+ <div
877
+ ref={setContentRef}
878
+ className={cn(
879
+ // `fullWidth=true` drops the centered-narrow column for
880
+ // side-panel hosts (e.g. multi-platform-hub Mingo). Same
881
+ // semantics as ChatHeader / ChatInput / ChatFooter.
882
+ fullWidth
883
+ ? "flex w-full flex-col pb-[var(--spacing-system-xs)] min-w-0"
884
+ : "mx-auto flex w-full max-w-ods-content-narrow flex-col pb-[var(--spacing-system-xs)] min-w-0",
885
+ contentClassName ?? "px-[var(--spacing-system-m)]",
886
+ )}
887
+ style={{ minHeight: '100%' }}
888
+ >
889
+ {hasNextPage && (
890
+ <div ref={sentinelRef} className="h-px" />
891
+ )}
892
+ <div className="flex-1" />
893
+ {messages.map((message, index) => {
894
+ // Hidden messages (synthetic continuation prompts the host
895
+ // injects after an approval card) are part of the API
896
+ // conversation history but never render. Skipping here
897
+ // keeps the visible thread coherent — see
898
+ // `Message.hidden` doc-comment in message.types.ts.
899
+ if (message.hidden) return null
900
+ // ONE OWNER for the pending turn. Both send paths mint an empty
901
+ // assistant placeholder (the accumulation target — it MUST stay
902
+ // in state) and flip the phase to 'thinking'. Rendering it here
903
+ // too would put an author label with no body in the transcript
904
+ // WHILE the footer loader below already represents the same
905
+ // pending turn — two owners, and an `aria-live` log that
906
+ // announces a bare "Mingo". The footer loader wins (it is
907
+ // already `role="status" aria-live="polite"`, sits outside the
908
+ // scroller so it can't jitter the thread, and cycles the
909
+ // progress phrase), so the placeholder is not rendered AT ALL
910
+ // until it carries something visible — removed from the live
911
+ // region, not merely hidden. Mirrors `endSseTurn`, which prunes
912
+ // an empty trailing assistant AFTER a turn; this closes the same
913
+ // window BEFORE the first token. Any non-text segment (tool
914
+ // execution, approval card, thinking) counts as visible.
915
+ const isEmptyPendingTurn =
916
+ index === messages.length - 1 &&
917
+ isTyping &&
918
+ message.role === 'assistant' &&
919
+ !hasNonEmptyContent(message.content) &&
920
+ (!Array.isArray(message.content) ||
921
+ message.content.every((s) => s.type === 'text'))
922
+ if (isEmptyPendingTurn) return null
923
+ return (
924
+ <ChatMessageEnhanced
925
+ key={message.id}
926
+ ref={getRegisterMessageEl(message.id)}
927
+ role={message.role}
928
+ name={message.name}
929
+ content={message.content}
930
+ timestamp={message.timestamp}
931
+ isTyping={index === messages.length - 1 && isTyping && message.role === 'assistant'}
932
+ avatar={showAvatars ? message.avatar : null}
933
+ showAvatar={showAvatars}
934
+ assistantType={message.assistantType || assistantType}
935
+ approvalVariant={approvalVariant}
936
+ authorType={message.authorType}
937
+ assistantIcon={message.role !== 'user' ? assistantIcon : undefined}
938
+ chatRefs={message.chatRefs}
939
+ contextItems={message.contextItems}
940
+ resolveContextIcon={resolveContextIcon}
941
+ renderContextItem={renderContextItem}
942
+ renderMention={renderMention}
943
+ renderEntityCard={renderEntityCard}
944
+ NavLinkAnchor={NavLinkAnchor}
945
+ />
946
+ )
947
+ })}
948
+ </div>
599
949
  </div>
950
+
951
+ {/* Jump to bottom. The manual escape hatch from auto-scroll that
952
+ every 2026 chat surface ships (AI Elements calls it
953
+ `ConversationScrollButton`) and the WCAG 2.2.2 counterpart to
954
+ moving content: the reader can always get back to the live
955
+ end without hunting for it. Positioned inside the scroller's
956
+ OWN `relative` wrapper above, so `bottom` really is the
957
+ scroller's lower edge and not the outer column's (which also
958
+ carries the streaming loader). It costs the thread no layout
959
+ height either way — a sibling would shrink the scroller and
960
+ move the very bottom it points at. Hidden while at the bottom
961
+ and in passive demo hosts. */}
962
+ {autoScroll && !atBottom && messages.length > 0 && (
963
+ <button
964
+ type="button"
965
+ onClick={() => {
966
+ // Clicking IS the intent to return to the live end, so it
967
+ // re-arms the follow lock a scroll-up had released.
968
+ followBottomRef.current = true
969
+ void scrollToBottom({ animation: 'smooth' })
970
+ }}
971
+ aria-label="Scroll to latest message"
972
+ className={cn(
973
+ "absolute bottom-[var(--spacing-system-s)] left-1/2 -translate-x-1/2 z-10",
974
+ "flex items-center justify-center size-9 rounded-full",
975
+ "bg-ods-card text-ods-text-primary border border-ods-border shadow-lg",
976
+ "hover:bg-ods-bg-hover transition-colors",
977
+ )}
978
+ >
979
+ <Arrow02DownIcon size={16} />
980
+ </button>
981
+ )}
600
982
  </div>
601
983
 
602
984
  {/* Footer-pinned streaming loader — outside the scroller so it