@flamingo-stack/openframe-frontend-core 0.0.458-snapshot.20260720215024 → 0.0.458

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 (281) hide show
  1. package/dist/{chunk-BLZZXZNM.cjs → chunk-5USSSRAL.cjs} +26 -26
  2. package/dist/{chunk-BLZZXZNM.cjs.map → chunk-5USSSRAL.cjs.map} +1 -1
  3. package/dist/{chunk-CGNDAJ7V.js → chunk-6B27O3ZB.js} +4 -4
  4. package/dist/{chunk-P5JQDFRM.cjs → chunk-7IRPMNRL.cjs} +60 -60
  5. package/dist/chunk-7IRPMNRL.cjs.map +1 -0
  6. package/dist/{chunk-4XDWNAZW.cjs → chunk-DQ3EHM6G.cjs} +9 -9
  7. package/dist/{chunk-4XDWNAZW.cjs.map → chunk-DQ3EHM6G.cjs.map} +1 -1
  8. package/dist/{chunk-7KSEINAE.cjs → chunk-FTXDIDNX.cjs} +15 -15
  9. package/dist/{chunk-7KSEINAE.cjs.map → chunk-FTXDIDNX.cjs.map} +1 -1
  10. package/dist/{chunk-4R6H2U77.js → chunk-H7ETLG65.js} +4 -4
  11. package/dist/{chunk-LAOO7XHO.js → chunk-HWTUVLYB.js} +2 -2
  12. package/dist/{chunk-F2NMWV44.js → chunk-IDKDOQZL.js} +2 -2
  13. package/dist/{chunk-BQ7PMWLM.cjs → chunk-KIIOBK7G.cjs} +27 -28
  14. package/dist/chunk-KIIOBK7G.cjs.map +1 -0
  15. package/dist/{chunk-QO2XD3FR.js → chunk-KQGFKG6Y.js} +2 -2
  16. package/dist/{chunk-3N3TMZ62.cjs → chunk-L676XVP4.cjs} +7 -7
  17. package/dist/{chunk-3N3TMZ62.cjs.map → chunk-L676XVP4.cjs.map} +1 -1
  18. package/dist/{chunk-F7F43U7Q.cjs → chunk-LDAOPWXZ.cjs} +11 -11
  19. package/dist/{chunk-F7F43U7Q.cjs.map → chunk-LDAOPWXZ.cjs.map} +1 -1
  20. package/dist/{chunk-ADD3FPDG.cjs → chunk-MKLP2SOZ.cjs} +4886 -7004
  21. package/dist/chunk-MKLP2SOZ.cjs.map +1 -0
  22. package/dist/{chunk-VXJH5TDN.js → chunk-QL7ODLJA.js} +10 -10
  23. package/dist/chunk-QL7ODLJA.js.map +1 -0
  24. package/dist/{chunk-OR5AMXYN.js → chunk-R5QYMUKN.js} +2 -2
  25. package/dist/{chunk-REWRUV3K.js → chunk-TNLFAJPA.js} +9673 -11791
  26. package/dist/chunk-TNLFAJPA.js.map +1 -0
  27. package/dist/{chunk-XF5ROTMQ.js → chunk-U2YDR5DT.js} +2 -2
  28. package/dist/{chunk-PXZ7YQ5V.cjs → chunk-VTQSB6QB.cjs} +5 -5
  29. package/dist/{chunk-PXZ7YQ5V.cjs.map → chunk-VTQSB6QB.cjs.map} +1 -1
  30. package/dist/{chunk-UI7CIUAR.js → chunk-WGJPTFRH.js} +2 -2
  31. package/dist/{chunk-WCE66C2Z.cjs → chunk-XUNJILJT.cjs} +62 -62
  32. package/dist/{chunk-WCE66C2Z.cjs.map → chunk-XUNJILJT.cjs.map} +1 -1
  33. package/dist/{chunk-APF5QBAU.js → chunk-YQBPIUZU.js} +2 -2
  34. package/dist/{chunk-TA7MFVUS.cjs → chunk-YV2YA3QH.cjs} +31 -31
  35. package/dist/{chunk-TA7MFVUS.cjs.map → chunk-YV2YA3QH.cjs.map} +1 -1
  36. package/dist/{chunk-QSGZE6AZ.cjs → chunk-ZDJ4ZUQ4.cjs} +37 -37
  37. package/dist/{chunk-QSGZE6AZ.cjs.map → chunk-ZDJ4ZUQ4.cjs.map} +1 -1
  38. package/dist/{chunk-LJKZIIK4.js → chunk-ZHQQRYMB.js} +5 -6
  39. package/dist/chunk-ZHQQRYMB.js.map +1 -0
  40. package/dist/components/case-studies/index.cjs +8 -8
  41. package/dist/components/case-studies/index.js +2 -2
  42. package/dist/components/chart.d.ts +1 -1
  43. package/dist/components/chat/chat-message-enhanced.d.ts.map +1 -1
  44. package/dist/components/chat/chat-message-list.d.ts.map +1 -1
  45. package/dist/components/chat/hooks/index.d.ts +1 -0
  46. package/dist/components/chat/hooks/index.d.ts.map +1 -1
  47. package/dist/components/chat/hooks/use-chat.d.ts.map +1 -1
  48. package/dist/components/chat/hooks/use-nats-chat-adapter.d.ts +48 -15
  49. package/dist/components/chat/hooks/use-nats-chat-adapter.d.ts.map +1 -1
  50. package/dist/components/chat/hooks/use-realtime-chunk-processor.d.ts +6 -0
  51. package/dist/components/chat/hooks/use-realtime-chunk-processor.d.ts.map +1 -0
  52. package/dist/components/chat/hooks/use-sse-chat-adapter.d.ts +47 -3
  53. package/dist/components/chat/hooks/use-sse-chat-adapter.d.ts.map +1 -1
  54. package/dist/components/chat/index.cjs +2 -10
  55. package/dist/components/chat/index.cjs.map +1 -1
  56. package/dist/components/chat/index.d.ts +0 -1
  57. package/dist/components/chat/index.d.ts.map +1 -1
  58. package/dist/components/chat/index.js +13 -21
  59. package/dist/components/chat/types/api.types.d.ts +81 -1
  60. package/dist/components/chat/types/api.types.d.ts.map +1 -1
  61. package/dist/components/chat/types/processing.types.d.ts +90 -2
  62. package/dist/components/chat/types/processing.types.d.ts.map +1 -1
  63. package/dist/components/chat/utils/chunk-parser.d.ts +25 -0
  64. package/dist/components/chat/utils/chunk-parser.d.ts.map +1 -0
  65. package/dist/components/chat/utils/extract-incomplete-message-state.d.ts +6 -34
  66. package/dist/components/chat/utils/extract-incomplete-message-state.d.ts.map +1 -1
  67. package/dist/components/chat/utils/index.d.ts +3 -2
  68. package/dist/components/chat/utils/index.d.ts.map +1 -1
  69. package/dist/components/chat/utils/process-historical-messages.d.ts +11 -59
  70. package/dist/components/chat/utils/process-historical-messages.d.ts.map +1 -1
  71. package/dist/components/contact/index.cjs +3 -3
  72. package/dist/components/contact/index.js +2 -2
  73. package/dist/components/docs/index.cjs +5 -5
  74. package/dist/components/docs/index.js +4 -4
  75. package/dist/components/embeds/index.cjs +3 -3
  76. package/dist/components/embeds/index.js +2 -2
  77. package/dist/components/faq/index.cjs +3 -3
  78. package/dist/components/faq/index.js +2 -2
  79. package/dist/components/features/index.cjs +2 -2
  80. package/dist/components/features/index.js +1 -1
  81. package/dist/components/help-center-pages/index.cjs +20 -20
  82. package/dist/components/help-center-pages/index.js +11 -11
  83. package/dist/components/index.cjs +129 -171
  84. package/dist/components/index.cjs.map +1 -1
  85. package/dist/components/index.js +21 -63
  86. package/dist/components/index.js.map +1 -1
  87. package/dist/components/navigation/index.cjs +2 -2
  88. package/dist/components/navigation/index.js +1 -1
  89. package/dist/components/onboarding-guides/index.cjs +5 -5
  90. package/dist/components/onboarding-guides/index.js +4 -4
  91. package/dist/components/onboarding-guides/onboarding-guide-detail-view.d.ts.map +1 -1
  92. package/dist/components/related-content/index.cjs +3 -3
  93. package/dist/components/related-content/index.js +2 -2
  94. package/dist/components/shared/product-release/release-detail-page.d.ts.map +1 -1
  95. package/dist/components/tickets/index.cjs +5 -5
  96. package/dist/components/tickets/index.js +4 -4
  97. package/dist/components/ui/index.cjs +2 -44
  98. package/dist/components/ui/index.cjs.map +1 -1
  99. package/dist/components/ui/index.d.ts +2 -1
  100. package/dist/components/ui/index.d.ts.map +1 -1
  101. package/dist/components/ui/index.js +13 -55
  102. package/dist/components/ui/rich-markdown-renderer.d.ts +34 -0
  103. package/dist/components/ui/rich-markdown-renderer.d.ts.map +1 -0
  104. package/dist/components/ui/simple-markdown-renderer.d.ts +73 -0
  105. package/dist/components/ui/simple-markdown-renderer.d.ts.map +1 -0
  106. package/dist/index.cjs +2 -60
  107. package/dist/index.cjs.map +1 -1
  108. package/dist/index.js +13 -71
  109. package/dist/utils/index.cjs +40 -169
  110. package/dist/utils/index.cjs.map +1 -1
  111. package/dist/utils/index.d.ts +0 -1
  112. package/dist/utils/index.d.ts.map +1 -1
  113. package/dist/utils/index.js +41 -162
  114. package/dist/utils/index.js.map +1 -1
  115. package/dist/utils/markdown-section-extractor.d.ts.map +1 -1
  116. package/package.json +1 -7
  117. package/src/components/chat/__tests__/chat-message-list.test.tsx +5 -293
  118. package/src/components/chat/chat-message-enhanced.tsx +17 -111
  119. package/src/components/chat/chat-message-list.tsx +113 -367
  120. package/src/components/chat/embeddable-chat.tsx +0 -6
  121. package/src/components/chat/hooks/.index.md +1 -1
  122. package/src/components/chat/hooks/.use-nats-chat-adapter.md +1 -1
  123. package/src/components/chat/hooks/.use-realtime-chunk-processor.md +50 -0
  124. package/src/components/chat/hooks/index.ts +1 -0
  125. package/src/components/chat/hooks/use-chat.ts +0 -8
  126. package/src/components/chat/hooks/use-nats-chat-adapter.ts +822 -243
  127. package/src/components/chat/hooks/use-realtime-chunk-processor.ts +479 -0
  128. package/src/components/chat/hooks/use-sse-chat-adapter.ts +666 -318
  129. package/src/components/chat/index.ts +0 -5
  130. package/src/components/chat/thinking-display.tsx +1 -1
  131. package/src/components/chat/types/.api.types.md +3 -1
  132. package/src/components/chat/types/.processing.types.md +23 -6
  133. package/src/components/chat/types/api.types.ts +73 -6
  134. package/src/components/chat/types/processing.types.ts +52 -11
  135. package/src/components/chat/utils/.chunk-parser.md +51 -0
  136. package/src/components/chat/utils/.index.md +5 -0
  137. package/src/components/chat/utils/chunk-parser.ts +255 -0
  138. package/src/components/chat/utils/extract-incomplete-message-state.ts +5 -63
  139. package/src/components/chat/utils/index.ts +8 -5
  140. package/src/components/chat/utils/process-historical-messages.ts +383 -352
  141. package/src/components/onboarding-guides/onboarding-guide-detail-view.tsx +4 -22
  142. package/src/components/shared/legal-document/legal-document-page.tsx +1 -1
  143. package/src/components/shared/product-release/release-detail-page.tsx +4 -19
  144. package/src/components/ui/.simple-markdown-renderer.md +40 -0
  145. package/src/components/ui/index.ts +2 -1
  146. package/src/components/ui/release-changelog-section.tsx +1 -1
  147. package/src/components/ui/rich-markdown-renderer.tsx +1223 -0
  148. package/src/components/ui/simple-markdown-renderer.tsx +965 -0
  149. package/src/components/ui/ticket-info-section.tsx +1 -1
  150. package/src/utils/index.ts +0 -1
  151. package/src/utils/markdown-section-extractor.ts +56 -41
  152. package/dist/chat-protocol/decode.d.ts +0 -58
  153. package/dist/chat-protocol/decode.d.ts.map +0 -1
  154. package/dist/chat-protocol/encode.d.ts +0 -44
  155. package/dist/chat-protocol/encode.d.ts.map +0 -1
  156. package/dist/chat-protocol/env-flag.d.ts +0 -35
  157. package/dist/chat-protocol/env-flag.d.ts.map +0 -1
  158. package/dist/chat-protocol/events.d.ts +0 -192
  159. package/dist/chat-protocol/events.d.ts.map +0 -1
  160. package/dist/chat-protocol/frames.d.ts +0 -206
  161. package/dist/chat-protocol/frames.d.ts.map +0 -1
  162. package/dist/chat-protocol/index.cjs +0 -493
  163. package/dist/chat-protocol/index.cjs.map +0 -1
  164. package/dist/chat-protocol/index.d.ts +0 -18
  165. package/dist/chat-protocol/index.d.ts.map +0 -1
  166. package/dist/chat-protocol/index.js +0 -477
  167. package/dist/chat-protocol/index.js.map +0 -1
  168. package/dist/chat-protocol/ip-normalize.d.ts +0 -44
  169. package/dist/chat-protocol/ip-normalize.d.ts.map +0 -1
  170. package/dist/chat-protocol/nats-decoder.d.ts +0 -26
  171. package/dist/chat-protocol/nats-decoder.d.ts.map +0 -1
  172. package/dist/chunk-ADD3FPDG.cjs.map +0 -1
  173. package/dist/chunk-BQ7PMWLM.cjs.map +0 -1
  174. package/dist/chunk-LJKZIIK4.js.map +0 -1
  175. package/dist/chunk-P5JQDFRM.cjs.map +0 -1
  176. package/dist/chunk-REWRUV3K.js.map +0 -1
  177. package/dist/chunk-VXJH5TDN.js.map +0 -1
  178. package/dist/components/chat/stream/chat-dialog-store.d.ts +0 -184
  179. package/dist/components/chat/stream/chat-dialog-store.d.ts.map +0 -1
  180. package/dist/components/chat/stream/chat-stream-reducer.d.ts +0 -367
  181. package/dist/components/chat/stream/chat-stream-reducer.d.ts.map +0 -1
  182. package/dist/components/chat/stream/delta-batcher.d.ts +0 -51
  183. package/dist/components/chat/stream/delta-batcher.d.ts.map +0 -1
  184. package/dist/components/chat/stream/index.d.ts +0 -17
  185. package/dist/components/chat/stream/index.d.ts.map +0 -1
  186. package/dist/components/chat/stream/message-mutations.d.ts +0 -77
  187. package/dist/components/chat/stream/message-mutations.d.ts.map +0 -1
  188. package/dist/components/chat/stream/use-chat-stream-reducer.d.ts +0 -22
  189. package/dist/components/chat/stream/use-chat-stream-reducer.d.ts.map +0 -1
  190. package/dist/components/ui/markdown/base-components.d.ts +0 -56
  191. package/dist/components/ui/markdown/base-components.d.ts.map +0 -1
  192. package/dist/components/ui/markdown/engine.d.ts +0 -99
  193. package/dist/components/ui/markdown/engine.d.ts.map +0 -1
  194. package/dist/components/ui/markdown/heading-ids.d.ts +0 -65
  195. package/dist/components/ui/markdown/heading-ids.d.ts.map +0 -1
  196. package/dist/components/ui/markdown/index.d.ts +0 -18
  197. package/dist/components/ui/markdown/index.d.ts.map +0 -1
  198. package/dist/components/ui/markdown/mermaid-diagram.d.ts +0 -79
  199. package/dist/components/ui/markdown/mermaid-diagram.d.ts.map +0 -1
  200. package/dist/components/ui/markdown/rich/embed-overrides.d.ts +0 -14
  201. package/dist/components/ui/markdown/rich/embed-overrides.d.ts.map +0 -1
  202. package/dist/components/ui/markdown/rich/rich-markdown-renderer.d.ts +0 -40
  203. package/dist/components/ui/markdown/rich/rich-markdown-renderer.d.ts.map +0 -1
  204. package/dist/components/ui/markdown/rich/shortcodes.d.ts +0 -8
  205. package/dist/components/ui/markdown/rich/shortcodes.d.ts.map +0 -1
  206. package/dist/components/ui/markdown/sanitize.d.ts +0 -153
  207. package/dist/components/ui/markdown/sanitize.d.ts.map +0 -1
  208. package/dist/components/ui/markdown/simple-markdown-renderer.d.ts +0 -23
  209. package/dist/components/ui/markdown/simple-markdown-renderer.d.ts.map +0 -1
  210. package/dist/components/ui/markdown/streaming.d.ts +0 -78
  211. package/dist/components/ui/markdown/streaming.d.ts.map +0 -1
  212. package/dist/components/ui/markdown/text-size.d.ts +0 -34
  213. package/dist/components/ui/markdown/text-size.d.ts.map +0 -1
  214. package/dist/utils/markdown-fences.d.ts +0 -42
  215. package/dist/utils/markdown-fences.d.ts.map +0 -1
  216. package/dist/utils/markdown-heading-id.d.ts +0 -85
  217. package/dist/utils/markdown-heading-id.d.ts.map +0 -1
  218. package/src/chat-protocol/__tests__/__snapshots__/nats-decoder-golden.test.ts.snap +0 -307
  219. package/src/chat-protocol/__tests__/chat-protocol.test.ts +0 -515
  220. package/src/chat-protocol/__tests__/env-flag.test.ts +0 -52
  221. package/src/chat-protocol/__tests__/ip-normalize.test.ts +0 -136
  222. package/src/chat-protocol/__tests__/nats-decoder-golden.test.ts +0 -228
  223. package/src/chat-protocol/decode.ts +0 -277
  224. package/src/chat-protocol/encode.ts +0 -71
  225. package/src/chat-protocol/env-flag.ts +0 -39
  226. package/src/chat-protocol/events.ts +0 -228
  227. package/src/chat-protocol/frames.ts +0 -238
  228. package/src/chat-protocol/index.ts +0 -21
  229. package/src/chat-protocol/ip-normalize.ts +0 -146
  230. package/src/chat-protocol/nats-decoder.ts +0 -252
  231. package/src/components/chat/__tests__/chat-message-streaming-memo.test.tsx +0 -207
  232. package/src/components/chat/__tests__/chat-pending-turn.test.tsx +0 -209
  233. package/src/components/chat/hooks/__tests__/__snapshots__/persisted-state-v1-golden.test.ts.snap +0 -297
  234. package/src/components/chat/hooks/__tests__/__snapshots__/sse-stream-golden.test.ts.snap +0 -0
  235. package/src/components/chat/hooks/__tests__/persisted-state-v1-golden.test.ts +0 -272
  236. package/src/components/chat/hooks/__tests__/sse-stream-golden.test.ts +0 -318
  237. package/src/components/chat/stream/__tests__/__snapshots__/chat-stream-reducer-golden.test.ts.snap +0 -937
  238. package/src/components/chat/stream/__tests__/chat-dialog-store.test.ts +0 -756
  239. package/src/components/chat/stream/__tests__/chat-stream-reducer-golden.test.ts +0 -460
  240. package/src/components/chat/stream/__tests__/chat-stream-reducer.test.ts +0 -894
  241. package/src/components/chat/stream/__tests__/delta-batcher.test.ts +0 -255
  242. package/src/components/chat/stream/__tests__/use-chat-stream-reducer.test.ts +0 -98
  243. package/src/components/chat/stream/chat-dialog-store.ts +0 -536
  244. package/src/components/chat/stream/chat-stream-reducer.ts +0 -1796
  245. package/src/components/chat/stream/delta-batcher.ts +0 -159
  246. package/src/components/chat/stream/index.ts +0 -55
  247. package/src/components/chat/stream/message-mutations.ts +0 -338
  248. package/src/components/chat/stream/use-chat-stream-reducer.ts +0 -126
  249. package/src/components/chat/utils/__tests__/__snapshots__/process-historical-messages-golden.test.ts.snap +0 -418
  250. package/src/components/chat/utils/__tests__/__snapshots__/segment-accumulator-golden.test.ts.snap +0 -599
  251. package/src/components/chat/utils/__tests__/process-historical-messages-golden.test.ts +0 -317
  252. package/src/components/chat/utils/__tests__/segment-accumulator-golden.test.ts +0 -270
  253. package/src/components/ui/__tests__/__snapshots__/markdown-parity.test.tsx.snap +0 -2420
  254. package/src/components/ui/__tests__/markdown-parity.test.tsx +0 -2528
  255. package/src/components/ui/markdown/__tests__/mermaid-security.test.ts +0 -115
  256. package/src/components/ui/markdown/__tests__/mermaid-stale-render.test.tsx +0 -95
  257. package/src/components/ui/markdown/__tests__/sanitize-invariant.test.ts +0 -309
  258. package/src/components/ui/markdown/__tests__/streaming.test.tsx +0 -388
  259. package/src/components/ui/markdown/base-components.tsx +0 -341
  260. package/src/components/ui/markdown/engine.tsx +0 -315
  261. package/src/components/ui/markdown/heading-ids.ts +0 -239
  262. package/src/components/ui/markdown/index.ts +0 -49
  263. package/src/components/ui/markdown/mermaid-diagram.tsx +0 -291
  264. package/src/components/ui/markdown/rich/embed-overrides.tsx +0 -199
  265. package/src/components/ui/markdown/rich/rich-markdown-renderer.tsx +0 -184
  266. package/src/components/ui/markdown/rich/shortcodes.ts +0 -170
  267. package/src/components/ui/markdown/sanitize.ts +0 -3499
  268. package/src/components/ui/markdown/simple-markdown-renderer.tsx +0 -28
  269. package/src/components/ui/markdown/streaming.ts +0 -362
  270. package/src/components/ui/markdown/text-size.ts +0 -106
  271. package/src/utils/markdown-fences.ts +0 -127
  272. package/src/utils/markdown-heading-id.ts +0 -348
  273. /package/dist/{chunk-CGNDAJ7V.js.map → chunk-6B27O3ZB.js.map} +0 -0
  274. /package/dist/{chunk-4R6H2U77.js.map → chunk-H7ETLG65.js.map} +0 -0
  275. /package/dist/{chunk-LAOO7XHO.js.map → chunk-HWTUVLYB.js.map} +0 -0
  276. /package/dist/{chunk-F2NMWV44.js.map → chunk-IDKDOQZL.js.map} +0 -0
  277. /package/dist/{chunk-QO2XD3FR.js.map → chunk-KQGFKG6Y.js.map} +0 -0
  278. /package/dist/{chunk-OR5AMXYN.js.map → chunk-R5QYMUKN.js.map} +0 -0
  279. /package/dist/{chunk-XF5ROTMQ.js.map → chunk-U2YDR5DT.js.map} +0 -0
  280. /package/dist/{chunk-UI7CIUAR.js.map → chunk-WGJPTFRH.js.map} +0 -0
  281. /package/dist/{chunk-APF5QBAU.js.map → chunk-YQBPIUZU.js.map} +0 -0
@@ -1,12 +1,12 @@
1
1
  "use client"
2
2
 
3
- import { useRef, useState, useEffect, useLayoutEffect, useImperativeHandle, forwardRef } from "react"
3
+ import { useRef, 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 { OverlayScrollArea } from '../ui/overlay-scroll-area'
7
7
  import { ChatMessageEnhanced } from "./chat-message-enhanced"
8
8
  import { ChatMessageListSkeleton } from "./chat-message-skeleton"
9
- import { DotsLoaderIcon, Arrow02DownIcon } from "../icons-v2-generated"
9
+ import { DotsLoaderIcon } from "../icons-v2-generated"
10
10
  import { CyclingPhrase } from "./cycling-phrase"
11
11
  import type { ChatMessageListProps } from "./types"
12
12
  import { SCROLL_ANCHOR } from "./types/message.types"
@@ -28,11 +28,6 @@ const STREAMING_WORDS = [
28
28
  'Riffing',
29
29
  ] as const
30
30
 
31
- /** Distance from the bottom (px) still counted as "at the bottom".
32
- * Mirrors `use-stick-to-bottom`'s internal `STICK_TO_BOTTOM_OFFSET_PX`
33
- * so the jump-to-bottom button and the library agree on the boundary. */
34
- const BOTTOM_THRESHOLD_PX = 70
35
-
36
31
  /*
37
32
  * Stick-to-bottom: `use-stick-to-bottom` (stackblitz-labs)
38
33
  *
@@ -59,9 +54,6 @@ const BOTTOM_THRESHOLD_PX = 70
59
54
  * chat / sent a message; show me the bottom immediately").
60
55
  * - **Load-more sentinel** (top-of-list IntersectionObserver) for
61
56
  * infinite-scroll-UP. Distinct concern from stick-to-bottom.
62
- * - **Bottom-follow intent** (see `followBottomRef` below). The
63
- * library's follow is gated on its own `isAtBottom` flag, which this
64
- * list's layout can lose silently; we own the INTENT and re-assert.
65
57
  *
66
58
  * `useStickToBottom`'s returned refs are MUTABLE refs + ref callbacks
67
59
  * — pass them directly as `ref={scrollRef}` / `ref={contentRef}`.
@@ -151,45 +143,6 @@ const ChatMessageList = forwardRef<HTMLDivElement, ChatMessageListProps>(
151
143
  const dialogIdRef = useRef(dialogId)
152
144
  dialogIdRef.current = dialogId
153
145
 
154
- // ---- Bottom-follow intent (list-owned lock) ----------------------
155
- // The library follows the bottom only while ITS OWN `isAtBottom` flag
156
- // is true, and this list's layout loses that flag silently in two
157
- // ways:
158
- //
159
- // 1. **The scroller's own box changes.** The library's
160
- // ResizeObserver watches `contentRef` (INSIDE the scroller) only.
161
- // The streaming loader unmounting and the host's source-chip row
162
- // mounting — both siblings BELOW the scroller (see render) —
163
- // change the scroller's `clientHeight`, which moves the bottom
164
- // without emitting anything the library can observe.
165
- // 2. **Resize-induced scroll events read as a user gesture.**
166
- // Fetch-mode entity cards mount as a skeleton and settle to a
167
- // different height; a shrink clamps `scrollTop` down, and the
168
- // library's scroll handler can see that as a scroll-UP and
169
- // permanently escape the lock (its `resizeDifference` guard is a
170
- // `setTimeout(…, 1)` race, so this is intermittent).
171
- //
172
- // Either way the thread stops following mid-turn. It bites hardest on
173
- // card-heavy answers — a slash-command listing (`/getting-started`)
174
- // renders a dozen fetch-mode cards with cover images that keep
175
- // resolving for SECONDS after the stream ends, so everything after
176
- // the lock was lost lands below the fold.
177
- //
178
- // Fix: this list owns the INTENT. Sending a message (or opening a
179
- // dialog) arms it; only a real user scroll-up gesture — or a
180
- // top-anchored turn, which deliberately parks elsewhere — disarms it.
181
- // While armed, ANY geometry change re-asserts `scrollToBottom`, and
182
- // because that call also re-sets the library's `isAtBottom`, a lost
183
- // lock self-heals instead of being permanent.
184
- const followBottomRef = useRef(false)
185
-
186
- // Drives the jump-to-bottom affordance. Derived from GEOMETRY, not
187
- // from the library's `isAtBottom`: that flag is the one this list
188
- // loses silently (see above), and a stale `true` would hide the
189
- // button in exactly the situation it exists for. Threshold mirrors
190
- // the library's own `STICK_TO_BOTTOM_OFFSET_PX`.
191
- const [atBottom, setAtBottom] = useState(true)
192
-
193
146
  // Tracks previous render state for explicit "force-stick" decisions
194
147
  // (dialog change, first message, new user message). The library
195
148
  // covers ongoing streaming; these are about INTENT transitions.
@@ -226,13 +179,11 @@ const ChatMessageList = forwardRef<HTMLDivElement, ChatMessageListProps>(
226
179
 
227
180
  if (dialogChanged) {
228
181
  // Opening a different chat — instant snap to bottom.
229
- followBottomRef.current = true
230
182
  void scrollToBottom({ animation: 'instant', ignoreEscapes: true })
231
183
  return
232
184
  }
233
185
  if (newCount > 0 && prevCount === 0) {
234
186
  // First message arrived in an empty chat — instant snap.
235
- followBottomRef.current = true
236
187
  void scrollToBottom({ animation: 'instant', ignoreEscapes: true })
237
188
  return
238
189
  }
@@ -257,193 +208,54 @@ const ChatMessageList = forwardRef<HTMLDivElement, ChatMessageListProps>(
257
208
  const newSlice = prevLastIdx >= 0 ? messages.slice(prevLastIdx + 1) : messages.slice(prevCount)
258
209
  const hasNewUser = newSlice.some((m) => m.role === 'user')
259
210
  if (hasNewUser) {
260
- // Arms the follow intent for the WHOLE turn: the answer's
261
- // async growth (attachment images, streamed tokens, entity
262
- // cards resolving out of their skeletons long after the
263
- // stream ends) is re-asserted by the follow effect below.
264
- // This replaces the old per-`<img>` `load`-handler hack —
265
- // same defect class, but the ResizeObserver sees every
266
- // source of growth, not just images present at send time.
267
- followBottomRef.current = true
268
211
  void scrollToBottom({ animation: 'instant', ignoreEscapes: true })
212
+
213
+ // Image-attachment growth follow-up: if the new user message
214
+ // carries inline `<img>` elements (chat-attachment markdown
215
+ // emits `![alt](/api/storage/view/...)` which the markdown
216
+ // renderer turns into `<img>` / Next.js `<Image>`), those
217
+ // images load AFTER the initial paint. Each image-load grows
218
+ // the bubble height, but `useStickToBottom`'s `resize:
219
+ // 'smooth'` spring sometimes loses anchor under rapid
220
+ // resize events from Next.js Image's progressive
221
+ // dimensioning (observed: scroll lands 50-100px short of
222
+ // bottom — exactly one image-load short).
223
+ //
224
+ // Fix: after the initial scrollToBottom snaps, scan the
225
+ // content area for `<img>` elements that haven't loaded
226
+ // yet, and attach one-time `load` handlers that re-trigger
227
+ // `scrollToBottom`. Idempotent — runs for any image,
228
+ // affects only the new-user-message diff (effect dep is
229
+ // `messages`).
230
+ //
231
+ // `requestAnimationFrame` so the DOM has the new bubble
232
+ // before we query. Without rAF the new-message `<img>`s
233
+ // haven't been committed yet — querySelectorAll returns
234
+ // an empty set.
235
+ requestAnimationFrame(() => {
236
+ const el = contentRef.current
237
+ if (!el) return
238
+ const imgs = el.querySelectorAll('img')
239
+ imgs.forEach((img) => {
240
+ if (img.complete) return
241
+ const onLoad = () => {
242
+ img.removeEventListener('load', onLoad)
243
+ img.removeEventListener('error', onLoad)
244
+ // `ignoreEscapes: true` because the user may have
245
+ // accidentally moved scroll during the image-load
246
+ // window; we want their fresh send to be visible.
247
+ void scrollToBottom({ animation: 'instant', ignoreEscapes: true })
248
+ }
249
+ img.addEventListener('load', onLoad, { once: true })
250
+ img.addEventListener('error', onLoad, { once: true })
251
+ })
252
+ })
269
253
  }
270
254
  // Assistant-only new messages → the library's resize-watch
271
255
  // already keeps the bottom locked when the user hasn't
272
256
  // escaped. No explicit call needed; spring animation runs.
273
257
  }
274
- }, [autoScroll, messages, dialogId, scrollToBottom])
275
-
276
- // ---- Follow the bottom while the intent is armed -----------------
277
- // Installs once per mount (all deps are stable). See the
278
- // `followBottomRef` comment above for WHY this exists.
279
- useEffect(() => {
280
- if (!autoScroll) return
281
- const scroller = scrollRef.current
282
- const content = contentRef.current
283
- if (!scroller || !content) return
284
-
285
- // Re-assert on ANY geometry change. Deliberately WITHOUT
286
- // `ignoreEscapes`: the library must stay free to hand control back
287
- // on a real gesture mid-animation. Re-asserting is precisely what
288
- // re-sets its `isAtBottom`, so a silently-lost lock recovers on the
289
- // next resize instead of staying broken for the rest of the turn.
290
- // Cheap to call repeatedly — the library returns the in-flight
291
- // promise when an animation with the same behaviour is running.
292
- const measure = () => {
293
- const distance = scroller.scrollHeight - scroller.scrollTop - scroller.clientHeight
294
- setAtBottom(distance <= BOTTOM_THRESHOLD_PX)
295
- }
296
-
297
- const reassert = () => {
298
- if (followBottomRef.current) void scrollToBottom({ animation: 'smooth' })
299
- // Measured on every geometry change, follow armed or not: a
300
- // sibling growing below the scroller moves the bottom without
301
- // any scroll event, and the button has to notice.
302
- measure()
303
- }
304
-
305
- const ro = new ResizeObserver(reassert)
306
- ro.observe(content) // tokens, entity cards, images
307
- ro.observe(scroller) // streaming loader / source chips below it
308
-
309
- // --- user-intent disarm ---------------------------------------
310
- // Only a deliberate move AWAY from the bottom releases the lock.
311
- // Resize-derived scroll events are NOT gestures, which is exactly
312
- // the distinction the library's heuristic gets wrong.
313
- const disarm = () => {
314
- followBottomRef.current = false
315
- }
316
- const onWheel = (e: WheelEvent) => {
317
- if (e.deltaY < 0) disarm()
318
- }
319
- const onKeyDown = (e: KeyboardEvent) => {
320
- if (e.key === 'ArrowUp' || e.key === 'PageUp' || e.key === 'Home') disarm()
321
- }
322
- let touchY: number | null = null
323
- const onTouchStart = (e: TouchEvent) => {
324
- touchY = e.touches[0]?.clientY ?? null
325
- }
326
- const onTouchMove = (e: TouchEvent) => {
327
- const y = e.touches[0]?.clientY
328
- if (y == null || touchY == null) return
329
- if (y > touchY) disarm() // finger travels DOWN → content moves up
330
- touchY = y
331
- }
332
- // Scrollbar drag emits neither wheel nor touch — a scrollTop
333
- // DECREASE while the pointer is held down is the same intent.
334
- //
335
- // ALL pointer listeners are on `window`, deliberately. The viewport is
336
- // wrapped in `OverlayScrollArea`, and OverlayScrollbars appends its
337
- // scrollbar handles to the HOST element, NOT to the viewport we hold in
338
- // `scroller`. A `pointerdown` on the overlay thumb therefore never
339
- // reaches `scroller`, so listening there would leave `pointerDown`
340
- // false and the user would be yanked back down mid-drag — the exact
341
- // gesture this block exists to honor. `window` covers the native
342
- // scrollbar and the overlay thumb alike.
343
- //
344
- // The POINTER listeners use CAPTURE phase. The currently-installed
345
- // OverlayScrollbars was checked and calls no `stopPropagation` on its
346
- // pointer path, so bubble would work today — but capture makes the
347
- // guarantee structural instead of contingent on a third party's
348
- // internals (a library bump, or any host component between us and
349
- // `window`, could otherwise strand `pointerDown` at `false` and
350
- // reintroduce the yank-back bug).
351
- //
352
- // `blur` deliberately does NOT use capture. Focus events don't bubble
353
- // but they DO capture, so a capture listener on `window` fires for
354
- // EVERY element blur in the document (the focus-delegation idiom) —
355
- // any focus change during a hold, e.g. the chat input blurring as the
356
- // user presses down on the scrollbar, would clear `pointerDown` and
357
- // re-open the very yank-back bug this block prevents. A TRUE window
358
- // blur targets `window` itself, so an at-target (bubble-phase)
359
- // listener still receives it: capture buys nothing and costs
360
- // correctness. Verified in jsdom: descendant blur reaches the capture
361
- // listener only; window blur reaches both.
362
- //
363
- // `pointerDown` must be cleared by every way a drag can END, not just
364
- // `pointerup`: a button released OUTSIDE the window, or the tab losing
365
- // focus mid-drag, delivers no `pointerup` at all and would leave the
366
- // flag stuck true. From then on ANY resize-induced scrollTop clamp — a
367
- // fetch-mode card settling from skeleton to a shorter height, i.e. the
368
- // exact case this whole block exists for — reads as a scroll-up gesture
369
- // and drops the lock. Same for merely HOLDING the button down (text
370
- // selection) while a card shrinks. Hence `pointercancel` + window
371
- // `blur`.
372
- let pointerDown = false
373
- let lastScrollTop = scroller.scrollTop
374
- const onPointerDown = () => {
375
- pointerDown = true
376
- }
377
- // A drag that RELEASES at the live end expresses the same intent as
378
- // clicking jump-to-bottom. The scroll events during that drag can't
379
- // re-arm (see the gate in `onScroll`), and releasing the button emits
380
- // no further `scroll`, so the re-arm is evaluated once here, when the
381
- // gesture is actually over.
382
- const rearmIfAtBottom = () => {
383
- if (followBottomRef.current) return
384
- const distance = scroller.scrollHeight - scroller.scrollTop - scroller.clientHeight
385
- if (distance <= BOTTOM_THRESHOLD_PX) followBottomRef.current = true
386
- }
387
- const endPointer = () => {
388
- pointerDown = false
389
- rearmIfAtBottom()
390
- }
391
- const onScroll = () => {
392
- const st = scroller.scrollTop
393
- if (pointerDown && st < lastScrollTop) disarm()
394
- lastScrollTop = st
395
- // Landing at the bottom by hand is the SAME expressed intent as
396
- // clicking jump-to-bottom, so it re-arms a lock an earlier scroll-up
397
- // released. Without it the thread sits at-bottom (button hidden,
398
- // because `atBottom` is true) and then silently drifts away as the
399
- // answer grows.
400
- //
401
- // Gated on the drag being OVER (`!pointerDown`). A mid-drag `scroll`
402
- // is not a gesture that has ended, and a short scrollbar drag that
403
- // starts at the live end stays inside the 70px band — so re-arming
404
- // here would undo the `disarm()` two lines above on the very same
405
- // event and yank the reader back down mid-drag. Releasing the button
406
- // at the bottom still re-arms on the next scroll (or, if none
407
- // follows, on the next deliberate return to the end).
408
- if (
409
- !pointerDown &&
410
- !followBottomRef.current &&
411
- scroller.scrollHeight - st - scroller.clientHeight <= BOTTOM_THRESHOLD_PX
412
- ) {
413
- followBottomRef.current = true
414
- }
415
- measure()
416
- }
417
- measure()
418
-
419
- const winCapture = { capture: true, passive: true } as const
420
-
421
- scroller.addEventListener('wheel', onWheel, { passive: true })
422
- scroller.addEventListener('keydown', onKeyDown)
423
- scroller.addEventListener('touchstart', onTouchStart, { passive: true })
424
- scroller.addEventListener('touchmove', onTouchMove, { passive: true })
425
- window.addEventListener('pointerdown', onPointerDown, winCapture)
426
- scroller.addEventListener('scroll', onScroll, { passive: true })
427
- window.addEventListener('pointerup', endPointer, winCapture)
428
- window.addEventListener('pointercancel', endPointer, winCapture)
429
- // Bubble phase, NOT capture — see the note above.
430
- window.addEventListener('blur', endPointer, { passive: true })
431
-
432
- return () => {
433
- ro.disconnect()
434
- scroller.removeEventListener('wheel', onWheel)
435
- scroller.removeEventListener('keydown', onKeyDown)
436
- scroller.removeEventListener('touchstart', onTouchStart)
437
- scroller.removeEventListener('touchmove', onTouchMove)
438
- // `capture` must match the add call or the listener is not removed.
439
- window.removeEventListener('pointerdown', onPointerDown, { capture: true })
440
- scroller.removeEventListener('scroll', onScroll)
441
- window.removeEventListener('pointerup', endPointer, { capture: true })
442
- window.removeEventListener('pointercancel', endPointer, { capture: true })
443
- // Added WITHOUT capture — the remove must match.
444
- window.removeEventListener('blur', endPointer)
445
- }
446
- }, [autoScroll, scrollRef, contentRef, scrollToBottom])
258
+ }, [autoScroll, messages, dialogId, scrollToBottom, contentRef])
447
259
 
448
260
  // ---- Passive-demo hard pin (pinBottom) ---------------------------
449
261
  // Scripted in-page replays (the Fae/Mingo demos) have no human at the
@@ -573,10 +385,7 @@ const ChatMessageList = forwardRef<HTMLDivElement, ChatMessageListProps>(
573
385
  // `stopScroll` MUST precede scrollIntoView — the imperative scrollTop
574
386
  // write is what the library's geometry tracker uses to flip
575
387
  // `escapedFromLock`, so any in-flight scrollToBottom spring is
576
- // first cancelled. The list-owned follow intent is released for the
577
- // same reason: this turn deliberately parks at the message TOP, so
578
- // its own async growth must NOT drag the reader to the bottom.
579
- followBottomRef.current = false
388
+ // first cancelled.
580
389
  stopScroll()
581
390
  // Instant (not smooth): the markdown body contains async <img>
582
391
  // children whose loads race a 300ms smooth animation and leave
@@ -720,139 +529,76 @@ const ChatMessageList = forwardRef<HTMLDivElement, ChatMessageListProps>(
720
529
  !(pendingApprovals && pendingApprovals.length > 0)
721
530
 
722
531
  return (
723
- <div className="flex-1 min-h-0 flex flex-col">
724
- {/* Positioning box for the jump-to-bottom button: it wraps ONLY the
725
- scroller, so `absolute bottom-…` really is the scroller's lower
726
- edge. The outer flex column also holds the streaming loader and the
727
- approvals bar, and anchoring against THAT box put the button over
728
- the loader for the whole of every stream — precisely when it is
729
- most likely to be visible. */}
730
- <div className="relative flex-1 min-h-0 flex flex-col">
731
- <OverlayScrollArea
732
- viewportRef={setScrollRef}
733
- className="flex-1 min-h-0"
734
- contentClassName={cn(
735
- "flex flex-col overflow-x-hidden",
736
- // `overscroll-contain` (default ON, opt-out via `overscrollContain={false}`):
737
- // reaching the top/bottom of the thread must NOT chain the wheel/
738
- // touch scroll to the page behind the chat — e.g. the company-hub
739
- // deck (native body scroll + sticky slide panels) would otherwise
740
- // advance slides while you scroll the chat (fixed in #1501; kept as
741
- // the prop default here). Passive in-page DEMO chats disable it so
742
- // the surrounding page keeps scrolling normally when the pointer is
743
- // over the (non-interactive) thread.
744
- overscrollContain && "overscroll-contain",
745
- className,
532
+ <div className="relative flex-1 min-h-0 flex flex-col">
533
+ <OverlayScrollArea
534
+ viewportRef={setScrollRef}
535
+ className="flex-1 min-h-0"
536
+ contentClassName={cn(
537
+ "flex flex-col overflow-x-hidden",
538
+ // `overscroll-contain` (default ON, opt-out via `overscrollContain={false}`):
539
+ // reaching the top/bottom of the thread must NOT chain the wheel/
540
+ // touch scroll to the page behind the chat — e.g. the company-hub
541
+ // deck (native body scroll + sticky slide panels) would otherwise
542
+ // advance slides while you scroll the chat (fixed in #1501; kept as
543
+ // the prop default here). Passive in-page DEMO chats disable it so
544
+ // the surrounding page keeps scrolling normally when the pointer is
545
+ // over the (non-interactive) thread.
546
+ overscrollContain && "overscroll-contain",
547
+ className,
548
+ )}
549
+ {...props}
550
+ >
551
+ <div
552
+ ref={setContentRef}
553
+ className={cn(
554
+ // `fullWidth=true` drops the centered-narrow column for
555
+ // side-panel hosts (e.g. multi-platform-hub Mingo). Same
556
+ // semantics as ChatHeader / ChatInput / ChatFooter.
557
+ fullWidth
558
+ ? "flex w-full flex-col pb-[var(--spacing-system-xs)] min-w-0"
559
+ : "mx-auto flex w-full max-w-ods-content-narrow flex-col pb-[var(--spacing-system-xs)] min-w-0",
560
+ contentClassName ?? "px-[var(--spacing-system-m)]",
746
561
  )}
747
- {...props}
562
+ style={{ minHeight: '100%' }}
748
563
  >
749
- <div
750
- ref={setContentRef}
751
- className={cn(
752
- // `fullWidth=true` drops the centered-narrow column for
753
- // side-panel hosts (e.g. multi-platform-hub Mingo). Same
754
- // semantics as ChatHeader / ChatInput / ChatFooter.
755
- fullWidth
756
- ? "flex w-full flex-col pb-[var(--spacing-system-xs)] min-w-0"
757
- : "mx-auto flex w-full max-w-ods-content-narrow flex-col pb-[var(--spacing-system-xs)] min-w-0",
758
- contentClassName ?? "px-[var(--spacing-system-m)]",
759
- )}
760
- style={{ minHeight: '100%' }}
761
- >
762
- {hasNextPage && (
763
- <div ref={sentinelRef} className="h-px" />
764
- )}
765
- <div className="flex-1" />
766
- {messages.map((message, index) => {
767
- // Hidden messages (synthetic continuation prompts the host
768
- // injects after an approval card) are part of the API
769
- // conversation history but never render. Skipping here
770
- // keeps the visible thread coherent — see
771
- // `Message.hidden` doc-comment in message.types.ts.
772
- if (message.hidden) return null
773
- // ONE OWNER for the pending turn. Both send paths mint an empty
774
- // assistant placeholder (the accumulation target — it MUST stay
775
- // in state) and flip the phase to 'thinking'. Rendering it here
776
- // too would put an author label with no body in the transcript
777
- // WHILE the footer loader below already represents the same
778
- // pending turn — two owners, and an `aria-live` log that
779
- // announces a bare "Mingo". The footer loader wins (it is
780
- // already `role="status" aria-live="polite"`, sits outside the
781
- // scroller so it can't jitter the thread, and cycles the
782
- // progress phrase), so the placeholder is not rendered AT ALL
783
- // until it carries something visible — removed from the live
784
- // region, not merely hidden. Mirrors `endSseTurn`, which prunes
785
- // an empty trailing assistant AFTER a turn; this closes the same
786
- // window BEFORE the first token. Any non-text segment (tool
787
- // execution, approval card, thinking) counts as visible.
788
- const isEmptyPendingTurn =
789
- index === messages.length - 1 &&
790
- isTyping &&
791
- message.role === 'assistant' &&
792
- !hasNonEmptyContent(message.content) &&
793
- (!Array.isArray(message.content) ||
794
- message.content.every((s) => s.type === 'text'))
795
- if (isEmptyPendingTurn) return null
796
- return (
797
- <ChatMessageEnhanced
798
- key={message.id}
799
- ref={getRegisterMessageEl(message.id)}
800
- role={message.role}
801
- name={message.name}
802
- content={message.content}
803
- timestamp={message.timestamp}
804
- isTyping={index === messages.length - 1 && isTyping && message.role === 'assistant'}
805
- avatar={showAvatars ? message.avatar : null}
806
- showAvatar={showAvatars}
807
- assistantType={message.assistantType || assistantType}
808
- approvalVariant={approvalVariant}
809
- authorType={message.authorType}
810
- assistantIcon={message.role !== 'user' ? assistantIcon : undefined}
811
- chatRefs={message.chatRefs}
812
- contextItems={message.contextItems}
813
- resolveContextIcon={resolveContextIcon}
814
- renderContextItem={renderContextItem}
815
- renderMention={renderMention}
816
- renderEntityCard={renderEntityCard}
817
- NavLinkAnchor={NavLinkAnchor}
818
- />
819
- )
820
- })}
821
- </div>
822
- </OverlayScrollArea>
823
-
824
- {/* Jump to bottom. The manual escape hatch from auto-scroll that
825
- every 2026 chat surface ships (AI Elements calls it
826
- `ConversationScrollButton`) and the WCAG 2.2.2 counterpart to
827
- moving content: the reader can always get back to the live
828
- end without hunting for it. Positioned inside the scroller's
829
- OWN `relative` wrapper above, so `bottom` really is the
830
- scroller's lower edge and not the outer column's (which also
831
- carries the streaming loader). It costs the thread no layout
832
- height either way — a sibling would shrink the scroller and
833
- move the very bottom it points at. Hidden while at the bottom
834
- and in passive demo hosts. */}
835
- {autoScroll && !atBottom && messages.length > 0 && (
836
- <button
837
- type="button"
838
- onClick={() => {
839
- // Clicking IS the intent to return to the live end, so it
840
- // re-arms the follow lock a scroll-up had released.
841
- followBottomRef.current = true
842
- void scrollToBottom({ animation: 'smooth' })
843
- }}
844
- aria-label="Scroll to latest message"
845
- className={cn(
846
- "absolute bottom-[var(--spacing-system-s)] left-1/2 -translate-x-1/2 z-10",
847
- "flex items-center justify-center size-9 rounded-full",
848
- "bg-ods-card text-ods-text-primary border border-ods-border shadow-lg",
849
- "hover:bg-ods-bg-hover transition-colors",
850
- )}
851
- >
852
- <Arrow02DownIcon size={16} />
853
- </button>
854
- )}
855
- </div>
564
+ {hasNextPage && (
565
+ <div ref={sentinelRef} className="h-px" />
566
+ )}
567
+ <div className="flex-1" />
568
+ {messages.map((message, index) => {
569
+ // Hidden messages (synthetic continuation prompts the host
570
+ // injects after an approval card) are part of the API
571
+ // conversation history but never render. Skipping here
572
+ // keeps the visible thread coherent — see
573
+ // `Message.hidden` doc-comment in message.types.ts.
574
+ if (message.hidden) return null
575
+ return (
576
+ <ChatMessageEnhanced
577
+ key={message.id}
578
+ ref={getRegisterMessageEl(message.id)}
579
+ role={message.role}
580
+ name={message.name}
581
+ content={message.content}
582
+ timestamp={message.timestamp}
583
+ isTyping={index === messages.length - 1 && isTyping && message.role === 'assistant'}
584
+ avatar={showAvatars ? message.avatar : null}
585
+ showAvatar={showAvatars}
586
+ assistantType={message.assistantType || assistantType}
587
+ approvalVariant={approvalVariant}
588
+ authorType={message.authorType}
589
+ assistantIcon={message.role !== 'user' ? assistantIcon : undefined}
590
+ chatRefs={message.chatRefs}
591
+ contextItems={message.contextItems}
592
+ resolveContextIcon={resolveContextIcon}
593
+ renderContextItem={renderContextItem}
594
+ renderMention={renderMention}
595
+ renderEntityCard={renderEntityCard}
596
+ NavLinkAnchor={NavLinkAnchor}
597
+ />
598
+ )
599
+ })}
600
+ </div>
601
+ </OverlayScrollArea>
856
602
 
857
603
  {/* Footer-pinned streaming loader — outside the scroller so it
858
604
  doesn't jitter as the streaming message grows. Color is set
@@ -1510,12 +1510,6 @@ function EmbeddableChatInner({
1510
1510
  content: m.segments && m.segments.length > 0 ? m.segments : m.content,
1511
1511
  timestamp,
1512
1512
  assistantType: m.role === 'assistant' ? ('mingo' as const) : undefined,
1513
- // `hidden` is load-bearing, NOT cosmetic: it carries synthetic rows
1514
- // (e.g. an auto-continuation directive) that the LLM must see but the
1515
- // reader must not. Dropping it here made the raw directive text render
1516
- // as an ordinary bubble. This field-by-field rebuild has to forward it
1517
- // explicitly — see `Message.hidden` and `chat-message-list`'s skip.
1518
- ...(m.hidden ? { hidden: true } : {}),
1519
1513
  ...(m.chatRefs ? { chatRefs: m.chatRefs } : {}),
1520
1514
  ...(m.scrollAnchor ? { scrollAnchor: m.scrollAnchor } : {}),
1521
1515
  // Forward attached context items so the user bubble renders its chips.
@@ -5,7 +5,7 @@ Barrel export file for the chat hooks module, re-exporting all public hooks from
5
5
 
6
6
  | Export Group | Hooks |
7
7
  |---|---|
8
- | **Realtime / Streaming** | `useChunkCatchup`, `useJetstreamDialogSubscription`, `useNatsDialogSubscription` |
8
+ | **Realtime / Streaming** | `useChunkCatchup`, `useRealtimeChunkProcessor`, `useJetstreamDialogSubscription`, `useNatsDialogSubscription` |
9
9
  | **UI Utilities** | `useCollapsible`, `useCloseOnNavigation`, `useChatCardItem` |
10
10
  | **Chat Core** | `useChat`, `useSSE`, `useChatIdentity`, `useChatAttachments`, `useChatAttachmentImageGallery`, `useSlashCommands` |
11
11
  | **Transport Adapters** | `useSSEChatAdapter`, `useNatsChatAdapter` |
@@ -21,7 +21,7 @@ Main hook. Composes three internal hooks:
21
21
  ```text
22
22
  useNatsDialogSubscription → live NATS event tail
23
23
  ↓
24
- decodeNatsChunk + reducer → chunk → ChatStreamEvent → state
24
+ useRealtimeChunkProcessor → chunk → MessageSegment[]
25
25
  ↓
26
26
  useChunkCatchup → back-fills missed chunks on reconnect/activation
27
27
  ```
@@ -0,0 +1,50 @@
1
+ <!-- source-hash: e37a3772ece6c09122bfe5d89ce800e0 -->
2
+ Real-time NATS streaming chunk processor hook that converts raw streaming messages into typed message segments, managing stream state, tool execution, and approval workflows.
3
+
4
+ ## Key Components
5
+
6
+ | Export | Type | Description |
7
+ |--------|------|-------------|
8
+ | `useRealtimeChunkProcessor` | Hook | Primary hook for processing NATS stream chunks into UI-ready message segments |
9
+ | `accumulatorRef` | Internal Ref | Holds a `MessageSegmentAccumulator` instance that tracks and builds segments across chunk events |
10
+ | `isInStreamRef` | Internal Ref | Tracks whether a `MESSAGE_START`/`MESSAGE_END` boundary is active |
11
+ | `hasEverStreamedRef` | Internal Ref | Distinguishes cold-start vs. resumed-dialog continuation mode |
12
+ | `pendingEscalatedRef` | Internal Ref | Tracks escalated (non-display) approval requests awaiting results |
13
+
14
+ ### Handled Chunk Actions
15
+
16
+ | Action | Behavior |
17
+ |--------|----------|
18
+ | `message_start` / `message_end` | Opens/closes stream window, resets segment accumulator |
19
+ | `text` / `thinking` | Appends to accumulator; uses append mode post-stream |
20
+ | `tool_execution` | Routes to accumulator in-stream, or `onToolExecuted` post-stream |
21
+ | `approval_request` | Renders inline or escalates based on `displayApprovalTypes` |
22
+ | `approval_batch` | Renders batch card or unfolds into legacy cards if `batchApprovalsEnabled = false` |
23
+ | `approval_result` | Resolves escalated or inline approvals, updates accumulator status |
24
+ | `error` | Parses error detail and emits error segment |
25
+
26
+ ## Usage Example
27
+
28
+ ```typescript
29
+ const { processChunk } = useRealtimeChunkProcessor({
30
+ callbacks: {
31
+ onSegmentsUpdate: (segments, opts) => updateMessage(segments, opts),
32
+ onStreamStart: () => setStreaming(true),
33
+ onStreamEnd: () => setStreaming(false),
34
+ onApprove: (requestId) => approveToolCall(requestId),
35
+ onReject: (requestId) => rejectToolCall(requestId),
36
+ onEscalatedApproval: (requestId, data) => notifyTechnician(requestId, data),
37
+ onApprovalResolved: (requestId, status) => syncStatus(requestId, status),
38
+ onError: (error) => console.error(error),
39
+ },
40
+ displayApprovalTypes: ['CLIENT'],
41
+ approvalStatuses: { 'req-123': 'pending' },
42
+ enableThinking: false,
43
+ batchApprovalsEnabled: true,
44
+ })
45
+
46
+ // Feed raw NATS chunks directly
47
+ natsSubscription.on('message', (msg) => processChunk(msg.data))
48
+ ```
49
+
50
+ > **Note:** `batchApprovalsEnabled` defaults to `true`. Pass `false` to fall back to legacy per-call approval cards. Consumers using `onApprovalResolved` should omit `onSegmentsUpdate` handling for approval results to avoid bubble overwrites during status flips.
@@ -5,6 +5,7 @@ export * from './use-collapsible'
5
5
  export * from './use-scripted-stream'
6
6
  export * from './use-jetstream-dialog-subscription'
7
7
  export * from './use-nats-dialog-subscription'
8
+ export * from './use-realtime-chunk-processor'
8
9
 
9
10
  // Chat hooks migrated from hub `hooks/*` (Task #7).
10
11
  export * from './use-slash-commands'