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

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 (290) 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 +192 -0
  8. package/dist/chat-protocol/events.d.ts.map +1 -0
  9. package/dist/chat-protocol/frames.d.ts +206 -0
  10. package/dist/chat-protocol/frames.d.ts.map +1 -0
  11. package/dist/chat-protocol/index.cjs +493 -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 +477 -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-SQ3CP2IV.cjs → chunk-3N3TMZ62.cjs} +7 -7
  22. package/dist/{chunk-SQ3CP2IV.cjs.map → chunk-3N3TMZ62.cjs.map} +1 -1
  23. package/dist/{chunk-I7M6KCCS.js → chunk-4R6H2U77.js} +4 -4
  24. package/dist/{chunk-CCT5VCRP.cjs → chunk-4XDWNAZW.cjs} +9 -9
  25. package/dist/{chunk-CCT5VCRP.cjs.map → chunk-4XDWNAZW.cjs.map} +1 -1
  26. package/dist/{chunk-KQ36EHE4.cjs → chunk-7KSEINAE.cjs} +15 -15
  27. package/dist/{chunk-KQ36EHE4.cjs.map → chunk-7KSEINAE.cjs.map} +1 -1
  28. package/dist/{chunk-FM5DTJPA.cjs → chunk-ADD3FPDG.cjs} +6851 -4713
  29. package/dist/chunk-ADD3FPDG.cjs.map +1 -0
  30. package/dist/{chunk-RSW42GTZ.js → chunk-APF5QBAU.js} +2 -2
  31. package/dist/{chunk-YCIVMKRS.cjs → chunk-BLZZXZNM.cjs} +26 -26
  32. package/dist/{chunk-YCIVMKRS.cjs.map → chunk-BLZZXZNM.cjs.map} +1 -1
  33. package/dist/{chunk-YHB4MNUX.cjs → chunk-BQ7PMWLM.cjs} +28 -27
  34. package/dist/chunk-BQ7PMWLM.cjs.map +1 -0
  35. package/dist/{chunk-GJ7BN4MI.js → chunk-CGNDAJ7V.js} +4 -4
  36. package/dist/{chunk-R5IQJXA3.js → chunk-F2NMWV44.js} +2 -2
  37. package/dist/{chunk-EOVQVVDJ.cjs → chunk-F7F43U7Q.cjs} +11 -11
  38. package/dist/{chunk-EOVQVVDJ.cjs.map → chunk-F7F43U7Q.cjs.map} +1 -1
  39. package/dist/{chunk-2ZW2ZPDW.js → chunk-LAOO7XHO.js} +2 -2
  40. package/dist/{chunk-O7F7RZFN.js → chunk-LJKZIIK4.js} +6 -5
  41. package/dist/chunk-LJKZIIK4.js.map +1 -0
  42. package/dist/{chunk-AJGZ3YSR.js → chunk-OR5AMXYN.js} +2 -2
  43. package/dist/{chunk-5XFJG3TC.cjs → chunk-P5JQDFRM.cjs} +60 -60
  44. package/dist/chunk-P5JQDFRM.cjs.map +1 -0
  45. package/dist/{chunk-U4APSAAR.cjs → chunk-PXZ7YQ5V.cjs} +5 -5
  46. package/dist/{chunk-U4APSAAR.cjs.map → chunk-PXZ7YQ5V.cjs.map} +1 -1
  47. package/dist/{chunk-WXDEMTLD.js → chunk-QO2XD3FR.js} +2 -2
  48. package/dist/{chunk-C2TDHJBO.cjs → chunk-QSGZE6AZ.cjs} +37 -37
  49. package/dist/{chunk-C2TDHJBO.cjs.map → chunk-QSGZE6AZ.cjs.map} +1 -1
  50. package/dist/{chunk-NSL5MNS4.js → chunk-REWRUV3K.js} +11644 -9506
  51. package/dist/chunk-REWRUV3K.js.map +1 -0
  52. package/dist/{chunk-BDTQIIJR.cjs → chunk-TA7MFVUS.cjs} +31 -31
  53. package/dist/{chunk-BDTQIIJR.cjs.map → chunk-TA7MFVUS.cjs.map} +1 -1
  54. package/dist/{chunk-4423DHRX.js → chunk-UI7CIUAR.js} +2 -2
  55. package/dist/{chunk-5BKBG653.js → chunk-VXJH5TDN.js} +10 -10
  56. package/dist/chunk-VXJH5TDN.js.map +1 -0
  57. package/dist/{chunk-AB65PWU2.cjs → chunk-WCE66C2Z.cjs} +62 -62
  58. package/dist/{chunk-AB65PWU2.cjs.map → chunk-WCE66C2Z.cjs.map} +1 -1
  59. package/dist/{chunk-KXIIUE75.js → chunk-XF5ROTMQ.js} +2 -2
  60. package/dist/components/case-studies/index.cjs +8 -8
  61. package/dist/components/case-studies/index.js +2 -2
  62. package/dist/components/chart.d.ts +1 -1
  63. package/dist/components/chat/chat-message-enhanced.d.ts.map +1 -1
  64. package/dist/components/chat/chat-message-list.d.ts.map +1 -1
  65. package/dist/components/chat/hooks/index.d.ts +0 -1
  66. package/dist/components/chat/hooks/index.d.ts.map +1 -1
  67. package/dist/components/chat/hooks/use-chat.d.ts.map +1 -1
  68. package/dist/components/chat/hooks/use-nats-chat-adapter.d.ts +15 -48
  69. package/dist/components/chat/hooks/use-nats-chat-adapter.d.ts.map +1 -1
  70. package/dist/components/chat/hooks/use-sse-chat-adapter.d.ts +3 -47
  71. package/dist/components/chat/hooks/use-sse-chat-adapter.d.ts.map +1 -1
  72. package/dist/components/chat/index.cjs +10 -2
  73. package/dist/components/chat/index.cjs.map +1 -1
  74. package/dist/components/chat/index.d.ts +1 -0
  75. package/dist/components/chat/index.d.ts.map +1 -1
  76. package/dist/components/chat/index.js +21 -13
  77. package/dist/components/chat/stream/chat-dialog-store.d.ts +184 -0
  78. package/dist/components/chat/stream/chat-dialog-store.d.ts.map +1 -0
  79. package/dist/components/chat/stream/chat-stream-reducer.d.ts +367 -0
  80. package/dist/components/chat/stream/chat-stream-reducer.d.ts.map +1 -0
  81. package/dist/components/chat/stream/delta-batcher.d.ts +51 -0
  82. package/dist/components/chat/stream/delta-batcher.d.ts.map +1 -0
  83. package/dist/components/chat/stream/index.d.ts +17 -0
  84. package/dist/components/chat/stream/index.d.ts.map +1 -0
  85. package/dist/components/chat/stream/message-mutations.d.ts +77 -0
  86. package/dist/components/chat/stream/message-mutations.d.ts.map +1 -0
  87. package/dist/components/chat/stream/use-chat-stream-reducer.d.ts +22 -0
  88. package/dist/components/chat/stream/use-chat-stream-reducer.d.ts.map +1 -0
  89. package/dist/components/chat/types/api.types.d.ts +1 -81
  90. package/dist/components/chat/types/api.types.d.ts.map +1 -1
  91. package/dist/components/chat/types/processing.types.d.ts +2 -90
  92. package/dist/components/chat/types/processing.types.d.ts.map +1 -1
  93. package/dist/components/chat/utils/extract-incomplete-message-state.d.ts +34 -6
  94. package/dist/components/chat/utils/extract-incomplete-message-state.d.ts.map +1 -1
  95. package/dist/components/chat/utils/index.d.ts +2 -3
  96. package/dist/components/chat/utils/index.d.ts.map +1 -1
  97. package/dist/components/chat/utils/process-historical-messages.d.ts +59 -11
  98. package/dist/components/chat/utils/process-historical-messages.d.ts.map +1 -1
  99. package/dist/components/contact/index.cjs +3 -3
  100. package/dist/components/contact/index.js +2 -2
  101. package/dist/components/docs/index.cjs +5 -5
  102. package/dist/components/docs/index.js +4 -4
  103. package/dist/components/embeds/index.cjs +3 -3
  104. package/dist/components/embeds/index.js +2 -2
  105. package/dist/components/faq/index.cjs +3 -3
  106. package/dist/components/faq/index.js +2 -2
  107. package/dist/components/features/index.cjs +2 -2
  108. package/dist/components/features/index.js +1 -1
  109. package/dist/components/features/time-tracker/time-tracker-panel.d.ts.map +1 -1
  110. package/dist/components/help-center-pages/index.cjs +20 -20
  111. package/dist/components/help-center-pages/index.js +11 -11
  112. package/dist/components/index.cjs +171 -129
  113. package/dist/components/index.cjs.map +1 -1
  114. package/dist/components/index.js +63 -21
  115. package/dist/components/index.js.map +1 -1
  116. package/dist/components/layout/page-container.d.ts.map +1 -1
  117. package/dist/components/navigation/index.cjs +2 -2
  118. package/dist/components/navigation/index.js +1 -1
  119. package/dist/components/onboarding-guides/index.cjs +5 -5
  120. package/dist/components/onboarding-guides/index.js +4 -4
  121. package/dist/components/onboarding-guides/onboarding-guide-detail-view.d.ts.map +1 -1
  122. package/dist/components/related-content/index.cjs +3 -3
  123. package/dist/components/related-content/index.js +2 -2
  124. package/dist/components/shared/product-release/release-detail-page.d.ts.map +1 -1
  125. package/dist/components/tickets/index.cjs +5 -5
  126. package/dist/components/tickets/index.js +4 -4
  127. package/dist/components/ui/index.cjs +44 -2
  128. package/dist/components/ui/index.cjs.map +1 -1
  129. package/dist/components/ui/index.d.ts +1 -2
  130. package/dist/components/ui/index.d.ts.map +1 -1
  131. package/dist/components/ui/index.js +55 -13
  132. package/dist/components/ui/markdown/base-components.d.ts +56 -0
  133. package/dist/components/ui/markdown/base-components.d.ts.map +1 -0
  134. package/dist/components/ui/markdown/engine.d.ts +99 -0
  135. package/dist/components/ui/markdown/engine.d.ts.map +1 -0
  136. package/dist/components/ui/markdown/heading-ids.d.ts +65 -0
  137. package/dist/components/ui/markdown/heading-ids.d.ts.map +1 -0
  138. package/dist/components/ui/markdown/index.d.ts +18 -0
  139. package/dist/components/ui/markdown/index.d.ts.map +1 -0
  140. package/dist/components/ui/markdown/mermaid-diagram.d.ts +79 -0
  141. package/dist/components/ui/markdown/mermaid-diagram.d.ts.map +1 -0
  142. package/dist/components/ui/markdown/rich/embed-overrides.d.ts +14 -0
  143. package/dist/components/ui/markdown/rich/embed-overrides.d.ts.map +1 -0
  144. package/dist/components/ui/markdown/rich/rich-markdown-renderer.d.ts +40 -0
  145. package/dist/components/ui/markdown/rich/rich-markdown-renderer.d.ts.map +1 -0
  146. package/dist/components/ui/markdown/rich/shortcodes.d.ts +8 -0
  147. package/dist/components/ui/markdown/rich/shortcodes.d.ts.map +1 -0
  148. package/dist/components/ui/markdown/sanitize.d.ts +153 -0
  149. package/dist/components/ui/markdown/sanitize.d.ts.map +1 -0
  150. package/dist/components/ui/markdown/simple-markdown-renderer.d.ts +23 -0
  151. package/dist/components/ui/markdown/simple-markdown-renderer.d.ts.map +1 -0
  152. package/dist/components/ui/markdown/streaming.d.ts +78 -0
  153. package/dist/components/ui/markdown/streaming.d.ts.map +1 -0
  154. package/dist/components/ui/markdown/text-size.d.ts +34 -0
  155. package/dist/components/ui/markdown/text-size.d.ts.map +1 -0
  156. package/dist/components/ui/modal-v2.d.ts.map +1 -1
  157. package/dist/components/ui/modal.d.ts.map +1 -1
  158. package/dist/index.cjs +60 -2
  159. package/dist/index.cjs.map +1 -1
  160. package/dist/index.js +71 -13
  161. package/dist/utils/index.cjs +169 -40
  162. package/dist/utils/index.cjs.map +1 -1
  163. package/dist/utils/index.d.ts +1 -0
  164. package/dist/utils/index.d.ts.map +1 -1
  165. package/dist/utils/index.js +162 -41
  166. package/dist/utils/index.js.map +1 -1
  167. package/dist/utils/markdown-fences.d.ts +42 -0
  168. package/dist/utils/markdown-fences.d.ts.map +1 -0
  169. package/dist/utils/markdown-heading-id.d.ts +85 -0
  170. package/dist/utils/markdown-heading-id.d.ts.map +1 -0
  171. package/dist/utils/markdown-section-extractor.d.ts.map +1 -1
  172. package/package.json +7 -1
  173. package/src/chat-protocol/__tests__/__snapshots__/nats-decoder-golden.test.ts.snap +307 -0
  174. package/src/chat-protocol/__tests__/chat-protocol.test.ts +515 -0
  175. package/src/chat-protocol/__tests__/env-flag.test.ts +52 -0
  176. package/src/chat-protocol/__tests__/ip-normalize.test.ts +136 -0
  177. package/src/chat-protocol/__tests__/nats-decoder-golden.test.ts +228 -0
  178. package/src/chat-protocol/decode.ts +277 -0
  179. package/src/chat-protocol/encode.ts +71 -0
  180. package/src/chat-protocol/env-flag.ts +39 -0
  181. package/src/chat-protocol/events.ts +228 -0
  182. package/src/chat-protocol/frames.ts +238 -0
  183. package/src/chat-protocol/index.ts +21 -0
  184. package/src/chat-protocol/ip-normalize.ts +146 -0
  185. package/src/chat-protocol/nats-decoder.ts +252 -0
  186. package/src/components/chat/__tests__/chat-message-list.test.tsx +293 -5
  187. package/src/components/chat/__tests__/chat-message-streaming-memo.test.tsx +207 -0
  188. package/src/components/chat/__tests__/chat-pending-turn.test.tsx +209 -0
  189. package/src/components/chat/chat-message-enhanced.tsx +111 -17
  190. package/src/components/chat/chat-message-list.tsx +367 -113
  191. package/src/components/chat/embeddable-chat.tsx +6 -0
  192. package/src/components/chat/hooks/.index.md +1 -1
  193. package/src/components/chat/hooks/.use-nats-chat-adapter.md +1 -1
  194. package/src/components/chat/hooks/__tests__/__snapshots__/persisted-state-v1-golden.test.ts.snap +297 -0
  195. package/src/components/chat/hooks/__tests__/__snapshots__/sse-stream-golden.test.ts.snap +0 -0
  196. package/src/components/chat/hooks/__tests__/persisted-state-v1-golden.test.ts +272 -0
  197. package/src/components/chat/hooks/__tests__/sse-stream-golden.test.ts +318 -0
  198. package/src/components/chat/hooks/index.ts +0 -1
  199. package/src/components/chat/hooks/use-chat.ts +8 -0
  200. package/src/components/chat/hooks/use-nats-chat-adapter.ts +243 -822
  201. package/src/components/chat/hooks/use-sse-chat-adapter.ts +318 -666
  202. package/src/components/chat/index.ts +5 -0
  203. package/src/components/chat/stream/__tests__/__snapshots__/chat-stream-reducer-golden.test.ts.snap +937 -0
  204. package/src/components/chat/stream/__tests__/chat-dialog-store.test.ts +756 -0
  205. package/src/components/chat/stream/__tests__/chat-stream-reducer-golden.test.ts +460 -0
  206. package/src/components/chat/stream/__tests__/chat-stream-reducer.test.ts +894 -0
  207. package/src/components/chat/stream/__tests__/delta-batcher.test.ts +255 -0
  208. package/src/components/chat/stream/__tests__/use-chat-stream-reducer.test.ts +98 -0
  209. package/src/components/chat/stream/chat-dialog-store.ts +536 -0
  210. package/src/components/chat/stream/chat-stream-reducer.ts +1796 -0
  211. package/src/components/chat/stream/delta-batcher.ts +159 -0
  212. package/src/components/chat/stream/index.ts +55 -0
  213. package/src/components/chat/stream/message-mutations.ts +338 -0
  214. package/src/components/chat/stream/use-chat-stream-reducer.ts +126 -0
  215. package/src/components/chat/thinking-display.tsx +1 -1
  216. package/src/components/chat/types/.api.types.md +1 -3
  217. package/src/components/chat/types/.processing.types.md +6 -23
  218. package/src/components/chat/types/api.types.ts +6 -73
  219. package/src/components/chat/types/processing.types.ts +11 -52
  220. package/src/components/chat/utils/.index.md +0 -5
  221. package/src/components/chat/utils/__tests__/__snapshots__/process-historical-messages-golden.test.ts.snap +418 -0
  222. package/src/components/chat/utils/__tests__/__snapshots__/segment-accumulator-golden.test.ts.snap +599 -0
  223. package/src/components/chat/utils/__tests__/process-historical-messages-golden.test.ts +317 -0
  224. package/src/components/chat/utils/__tests__/segment-accumulator-golden.test.ts +270 -0
  225. package/src/components/chat/utils/extract-incomplete-message-state.ts +63 -5
  226. package/src/components/chat/utils/index.ts +5 -8
  227. package/src/components/chat/utils/process-historical-messages.ts +352 -383
  228. package/src/components/features/notifications/notification-drawer.tsx +4 -2
  229. package/src/components/features/time-tracker/time-tracker-panel.tsx +8 -2
  230. package/src/components/layout/page-container.tsx +5 -1
  231. package/src/components/onboarding-guides/onboarding-guide-detail-view.tsx +22 -4
  232. package/src/components/shared/legal-document/legal-document-page.tsx +1 -1
  233. package/src/components/shared/product-release/release-detail-page.tsx +19 -4
  234. package/src/components/ui/__tests__/__snapshots__/markdown-parity.test.tsx.snap +2420 -0
  235. package/src/components/ui/__tests__/markdown-parity.test.tsx +2528 -0
  236. package/src/components/ui/index.ts +1 -2
  237. package/src/components/ui/markdown/__tests__/mermaid-security.test.ts +115 -0
  238. package/src/components/ui/markdown/__tests__/mermaid-stale-render.test.tsx +95 -0
  239. package/src/components/ui/markdown/__tests__/sanitize-invariant.test.ts +309 -0
  240. package/src/components/ui/markdown/__tests__/streaming.test.tsx +388 -0
  241. package/src/components/ui/markdown/base-components.tsx +341 -0
  242. package/src/components/ui/markdown/engine.tsx +315 -0
  243. package/src/components/ui/markdown/heading-ids.ts +239 -0
  244. package/src/components/ui/markdown/index.ts +49 -0
  245. package/src/components/ui/markdown/mermaid-diagram.tsx +291 -0
  246. package/src/components/ui/markdown/rich/embed-overrides.tsx +199 -0
  247. package/src/components/ui/markdown/rich/rich-markdown-renderer.tsx +184 -0
  248. package/src/components/ui/markdown/rich/shortcodes.ts +170 -0
  249. package/src/components/ui/markdown/sanitize.ts +3499 -0
  250. package/src/components/ui/markdown/simple-markdown-renderer.tsx +28 -0
  251. package/src/components/ui/markdown/streaming.ts +362 -0
  252. package/src/components/ui/markdown/text-size.ts +106 -0
  253. package/src/components/ui/modal-v2.tsx +6 -0
  254. package/src/components/ui/modal.tsx +3 -0
  255. package/src/components/ui/release-changelog-section.tsx +1 -1
  256. package/src/components/ui/ticket-info-section.tsx +1 -1
  257. package/src/utils/index.ts +1 -0
  258. package/src/utils/markdown-fences.ts +127 -0
  259. package/src/utils/markdown-heading-id.ts +348 -0
  260. package/src/utils/markdown-section-extractor.ts +41 -56
  261. package/dist/chunk-5BKBG653.js.map +0 -1
  262. package/dist/chunk-5XFJG3TC.cjs.map +0 -1
  263. package/dist/chunk-FM5DTJPA.cjs.map +0 -1
  264. package/dist/chunk-NSL5MNS4.js.map +0 -1
  265. package/dist/chunk-O7F7RZFN.js.map +0 -1
  266. package/dist/chunk-YHB4MNUX.cjs.map +0 -1
  267. package/dist/components/chat/hooks/use-realtime-chunk-processor.d.ts +0 -6
  268. package/dist/components/chat/hooks/use-realtime-chunk-processor.d.ts.map +0 -1
  269. package/dist/components/chat/utils/chunk-parser.d.ts +0 -25
  270. package/dist/components/chat/utils/chunk-parser.d.ts.map +0 -1
  271. package/dist/components/ui/rich-markdown-renderer.d.ts +0 -34
  272. package/dist/components/ui/rich-markdown-renderer.d.ts.map +0 -1
  273. package/dist/components/ui/simple-markdown-renderer.d.ts +0 -73
  274. package/dist/components/ui/simple-markdown-renderer.d.ts.map +0 -1
  275. package/src/components/chat/hooks/.use-realtime-chunk-processor.md +0 -50
  276. package/src/components/chat/hooks/use-realtime-chunk-processor.ts +0 -479
  277. package/src/components/chat/utils/.chunk-parser.md +0 -51
  278. package/src/components/chat/utils/chunk-parser.ts +0 -255
  279. package/src/components/ui/.simple-markdown-renderer.md +0 -40
  280. package/src/components/ui/rich-markdown-renderer.tsx +0 -1223
  281. package/src/components/ui/simple-markdown-renderer.tsx +0 -965
  282. /package/dist/{chunk-I7M6KCCS.js.map → chunk-4R6H2U77.js.map} +0 -0
  283. /package/dist/{chunk-RSW42GTZ.js.map → chunk-APF5QBAU.js.map} +0 -0
  284. /package/dist/{chunk-GJ7BN4MI.js.map → chunk-CGNDAJ7V.js.map} +0 -0
  285. /package/dist/{chunk-R5IQJXA3.js.map → chunk-F2NMWV44.js.map} +0 -0
  286. /package/dist/{chunk-2ZW2ZPDW.js.map → chunk-LAOO7XHO.js.map} +0 -0
  287. /package/dist/{chunk-AJGZ3YSR.js.map → chunk-OR5AMXYN.js.map} +0 -0
  288. /package/dist/{chunk-WXDEMTLD.js.map → chunk-QO2XD3FR.js.map} +0 -0
  289. /package/dist/{chunk-4423DHRX.js.map → chunk-UI7CIUAR.js.map} +0 -0
  290. /package/dist/{chunk-KXIIUE75.js.map → chunk-XF5ROTMQ.js.map} +0 -0
@@ -6,10 +6,18 @@
6
6
  * counterpart is `useNatsChatAdapter` (Mingo mode). The public
7
7
  * `useChat({ mode })` dispatches between them based on `mode.transport`.
8
8
  *
9
- * Renamed from `useEmbeddedChat` during the unified-chat refactor. The
10
- * legacy name is re-exported as an alias from `./use-embedded-chat` for
11
- * backward compatibility — internal lib code should import this canonical
12
- * name directly.
9
+ * Phase 3 of the chat unification: the adapter no longer owns a parser or a
10
+ * message-merge layer. Raw response bytes flow
11
+ *
12
+ * fetch body → createSseFrameDecoder → ChatStreamEvent →
13
+ * createChatStreamReducer (transport: 'sse') — THE master reader
14
+ *
15
+ * The reducer absorbed `useChat`'s trailing-assistant merge, the
16
+ * `decision_resolved` receipt path, and the sendIdx-keyed
17
+ * sources/refs/meta maps; this file keeps only the transport wiring
18
+ * (fetch/abort, request-body building), localStorage persistence
19
+ * (PersistedChatState v1 — the sendIdx fan-out lookup below is the
20
+ * rehydration contract), and the public `UnifiedChatState` mapping.
13
21
  *
14
22
  * Two key contracts vs hub-side chat hooks:
15
23
  *
@@ -19,46 +27,44 @@
19
27
  * namespace. It is NEVER sent on the wire (the hub resolves source
20
28
  * server-side via `currentPlatform()`).
21
29
  *
22
- * 2. Navigation is runtime-provided. The hub-side `useDocSearch` keeps
23
- * its own `decideNewTab` + `useDocNavigation.navigate` plumbing for
24
- * the search autocomplete (it calls `router.push`). The chat body
25
- * doesn't navigate via this hook — click handlers on rendered
26
- * cards/chips go through `handleChatNavClick` instead.
27
- *
28
- * 3. `tableIdForDocumentType` is INJECTED via the optional
30
+ * 2. `tableIdForDocumentType` is INJECTED via the optional
29
31
  * `tableIdForDocumentType` parameter. Hub callers pass the
30
32
  * registry-backed lookup from `lib/config/rag-table-config`;
31
33
  * embedders that don't supply one fall back to
32
- * `defaultTableIdForDocumentType` (the lib-baked map covering
33
- * every documentType currently registered in the hub) so the
34
- * `displayRef` / `discussRef` Ask + Display buttons WORK out of
35
- * the box. Override only when you have polymorphic / per-tenant
36
- * types the default doesn't cover.
37
- *
38
- * Wire format and SSE-parser logic mirror the hub original byte-for-byte
39
- * so server-side and client-side stay in lockstep across the migration.
34
+ * `defaultTableIdForDocumentType` so the `displayRef` / `discussRef`
35
+ * Ask + Display buttons WORK out of the box.
40
36
  */
41
37
 
42
- import { useState, useEffect, useCallback, useMemo, useRef, type MutableRefObject } from 'react'
43
- import { useChat, type Message, type StreamFnExtraOptions } from './use-chat'
38
+ import { useCallback, useEffect, useMemo, useRef } from 'react'
44
39
  import { useRequiredChatRuntime } from '../../../contexts/chat-runtime-context'
45
40
  import type { ChatRef } from '../chat-ref.types'
46
- import { buildChatRefKey } from '../types/chat.types'
41
+ import type { Message } from '../types/message.types'
47
42
  import type { MessageSegment } from '../types/message.types'
48
43
  import { useSlashCommandRegistry, type SlashCommandSummary } from './use-slash-commands'
49
44
  import { getChatProxyAuth } from '../utils/chat-proxy-auth-storage'
50
45
  import { chatAuthedFetch } from '../utils/chat-authed-fetch'
51
- import { parseScrollAnchor, type ScrollAnchor } from '../utils/scroll-anchor'
46
+ import type { ScrollAnchor } from '../utils/scroll-anchor'
47
+ import { createSseFrameDecoder } from '../../../chat-protocol/decode'
52
48
  import { AUTO_CONTINUATION_DIRECTIVE_PREFIX } from '../utils/auto-continuation-directive'
53
49
  import { flattenAssistantContent } from '../utils/flatten-assistant-content'
54
50
  import { sanitizeTitleForChat } from '../utils/slash-dispatch-utils'
55
51
  import { defaultTableIdForDocumentType } from '../utils/source-icons'
52
+ import type { WireCommandOverride } from '../utils/slash-dispatch-utils'
53
+ import type { ChatAttachment } from '../utils/chat-attachment-markdown'
56
54
  import type {
57
55
  UnifiedChatState,
56
+ UnifiedChatMessage,
58
57
  UnifiedSendMessageOptions,
59
- StreamingPhase,
60
58
  } from '../types/unified-chat-state.types'
61
59
  import type { DialogItem } from '../types/component.types'
60
+ import { createChatDialogStore, DEFAULT_DIALOG_SIDE } from '../stream/chat-dialog-store'
61
+ import { useChatStreamReducer } from '../stream/use-chat-stream-reducer'
62
+ import type { ChatDialogStore } from '../stream/chat-dialog-store'
63
+ import type { ChatStreamReducerOptions } from '../stream/chat-stream-reducer'
64
+
65
+ // Canonical home of the per-turn meta row moved to the stream module in
66
+ // Phase 3; re-exported here to keep the legacy import path stable.
67
+ export type { ChatTurnMeta } from '../stream/chat-stream-reducer'
62
68
 
63
69
  // =============================================================================
64
70
  // Public types
@@ -121,33 +127,6 @@ export interface DocChatMessage {
121
127
  // `types/unified-chat-state.types.ts`.
122
128
  export type { StreamingPhase } from '../types/unified-chat-state.types'
123
129
 
124
- /** Per-turn metadata extracted from the streamed metadata frame. */
125
- export interface ChatTurnMeta {
126
- /** Provider key recognized by `<ModelDisplay>` for icon
127
- * selection: 'anthropic' | 'openai' | 'google' (case-insensitive). */
128
- provider: string | null
129
- modelLabel: string | null
130
- contextWindowMaxTokens: number | null
131
- /** Input tokens (from message_start). Includes cached tokens. */
132
- inputTokens: number | null
133
- /** Output tokens (from message_delta). Only known after stream end. */
134
- outputTokens: number | null
135
- /** Cache hit % (read / total-input × 100). Only known after stream end. */
136
- cacheHitRatePct: number | null
137
- /** Cross-call usage breakdown extracted from the trailing usage frame. */
138
- breakdown: {
139
- haikuRewriter?: { input: number; output: number }
140
- haikuClassifier?: { input: number; output: number }
141
- haikuSummarizer?: { input: number; output: number }
142
- routedAnswer?: { model: string; complexity: string; thinkingBudget: number }
143
- } | null
144
- /** Per-message viewport-positioning hint. */
145
- scrollAnchor: ScrollAnchor | null
146
- /** Routing decision from the server's `decideRoute`. */
147
- routedComplexity: string | null
148
- routedThinkingBudget: number | null
149
- }
150
-
151
130
  /**
152
131
  * Optional dependency-injection options for `useSseChatAdapter`.
153
132
  *
@@ -158,13 +137,7 @@ export interface ChatTurnMeta {
158
137
  * **Defaults to `defaultTableIdForDocumentType` from
159
138
  * `src/utils/source-icons.ts`** — a lib-baked map covering every
160
139
  * documentType currently registered in the hub's RAG_TABLE_CONFIGS.
161
- * This makes Ask / Display work out of the box in embedders without
162
- * any callback wiring.
163
- *
164
- * Override only when you have polymorphic / per-tenant document
165
- * types that map to a different tableId than the default
166
- * (hub-canonical) registry — pass your own `(docType) => tableId`
167
- * callback and it wins over the default.
140
+ * Override only for polymorphic / per-tenant document types.
168
141
  */
169
142
  export interface UseSseChatAdapterOptions {
170
143
  tableIdForDocumentType?: (documentType: string) => string | null
@@ -183,411 +156,7 @@ export interface UseSseChatAdapterRuntimeOptions {
183
156
  }
184
157
 
185
158
  // =============================================================================
186
- // Internal helpers
187
- // =============================================================================
188
-
189
- /** Single source of truth for a fresh `ChatTurnMeta` row. */
190
- function createEmptyTurnMeta(): ChatTurnMeta {
191
- return {
192
- provider: null,
193
- modelLabel: null,
194
- contextWindowMaxTokens: null,
195
- inputTokens: null,
196
- outputTokens: null,
197
- cacheHitRatePct: null,
198
- breakdown: null,
199
- scrollAnchor: null,
200
- routedComplexity: null,
201
- routedThinkingBudget: null,
202
- }
203
- }
204
-
205
- /**
206
- * Escape `<` so the lib's `SimpleMarkdownRenderer` (which uses `rehypeRaw`
207
- * to pass HTML through) doesn't treat XML-like tokens in Claude's
208
- * thinking output as React components. Escaping `<` → `&lt;` preserves
209
- * the visible character without breaking blockquote `>` markers.
210
- */
211
- function escapeThinkingTags(text: string): string {
212
- return text.replace(/</g, '&lt;')
213
- }
214
-
215
- function createDocStreamFn(
216
- source: DocSource,
217
- endpoints: { chatStreamUrl: string; approvalToolUrl: string },
218
- messagesRef: MutableRefObject<Message[]>,
219
- sourcesMapRef: MutableRefObject<Map<number, ChatSource[]>>,
220
- refsMapRef: MutableRefObject<Map<number, Record<string, ChatRef>>>,
221
- metaMapRef: MutableRefObject<Map<number, ChatTurnMeta>>,
222
- setStreamingPhase: (phase: StreamingPhase) => void,
223
- bumpMetaTick: () => void,
224
- sendCountRef: MutableRefObject<number>,
225
- ) {
226
- // CRITICAL: the decoder + buffer MUST live INSIDE the returned async-
227
- // generator function (per-call closure), NOT at the factory level. A
228
- // rapid send-stop-send sequence with hook-level state would feed the
229
- // second stream's first chunk into the first stream's tail buffer,
230
- // corrupting metadata-frame parsing.
231
- return async function* (
232
- message: string,
233
- signal?: AbortSignal,
234
- extra?: StreamFnExtraOptions,
235
- ): AsyncGenerator<MessageSegment> {
236
- const currentMessages = messagesRef.current || []
237
- // Filter `hidden:true` messages out of the API history. The approval-
238
- // action turn injects a hidden user message with `content=''`.
239
- // `flattenAssistantContent` joins text-segment arrays into a single
240
- // string so the server sees the receipt + auto-continuation Qs.
241
- const apiMessages = [
242
- ...currentMessages
243
- .filter((m) => (m.role === 'user' || m.role === 'assistant') && !m.hidden)
244
- .map((m) => ({
245
- role: m.role,
246
- content:
247
- typeof m.content === 'string' ? m.content : flattenAssistantContent(m.content),
248
- })),
249
- { role: 'user', content: message },
250
- ]
251
-
252
- // URL + body branch — approvalAction routes to the approval-tool
253
- // endpoint, the standard chat path routes to the chat-stream endpoint.
254
- const targetPath = extra?.approvalAction
255
- ? endpoints.approvalToolUrl
256
- : endpoints.chatStreamUrl
257
- // `source` is INTENTIONALLY NOT in the body. The chat route resolves
258
- // it server-side via its own platform-detection — tamper-proof binding
259
- // so a client on one platform can't POST a different platform's
260
- // conversation.
261
- const requestBody = extra?.approvalAction
262
- ? {
263
- proposal_id: extra.approvalAction.proposalId,
264
- action: extra.approvalAction.action,
265
- messages: currentMessages
266
- .filter((m) => (m.role === 'user' || m.role === 'assistant') && !m.hidden)
267
- .map((m) => ({
268
- role: m.role,
269
- content:
270
- typeof m.content === 'string'
271
- ? m.content
272
- : flattenAssistantContent(m.content),
273
- })),
274
- }
275
- : {
276
- messages: apiMessages,
277
- ...(extra?.commandOverride ? { commandOverride: extra.commandOverride } : {}),
278
- ...(extra?.pendingAttachments && extra.pendingAttachments.length > 0
279
- ? { pendingAttachments: extra.pendingAttachments }
280
- : {}),
281
- }
282
- // `chatAuthedFetch` carries the bearer-act-as headers (+ Supabase
283
- // session cookies) — same wrapper `use-chat-attachments` and
284
- // `use-chat-identity` use, so all three chat-side fetch sites share
285
- // one identity-propagation primitive.
286
- const response = await chatAuthedFetch(targetPath, {
287
- method: 'POST',
288
- body: JSON.stringify(requestBody),
289
- signal,
290
- })
291
-
292
- if (!response.ok) {
293
- throw new Error(`Chat request failed: ${response.status}`)
294
- }
295
-
296
- const reader = response.body?.getReader()
297
- if (!reader) throw new Error('No response body')
298
-
299
- const decoder = new TextDecoder()
300
- let buffer = ''
301
- let inText = false
302
- // Live thinking accumulator — coalesced to ~20fps to avoid a re-render
303
- // storm (Anthropic emits 30-60 deltas/sec).
304
- let thinkingAccum = ''
305
- let lastThinkingYieldTime = 0
306
- let pendingThinkingYield = false
307
- const THINKING_YIELD_INTERVAL_MS = 50
308
- let trailerBuffer = ''
309
- let inTrailer = false
310
-
311
- const sendIdx = sendCountRef.current - 1
312
- while (true) {
313
- const { done, value } = await reader.read()
314
- if (done) break
315
-
316
- const chunk = decoder.decode(value, { stream: true })
317
-
318
- if (!inText) {
319
- buffer += chunk
320
- while (!inText) {
321
- const recIdx = buffer.indexOf('\x1E')
322
- const nullIdx = buffer.indexOf('\0')
323
- if (recIdx !== -1 && (nullIdx === -1 || recIdx < nullIdx)) {
324
- if (pendingThinkingYield) {
325
- pendingThinkingYield = false
326
- yield { type: 'thinking', text: escapeThinkingTags(thinkingAccum) }
327
- }
328
- inText = true
329
- setStreamingPhase('streaming')
330
- const after = buffer.slice(recIdx + 1)
331
- buffer = ''
332
- if (after) {
333
- // The `after` slice may ALSO contain the `\x1F` trailing-
334
- // usage sentinel — common for fixed-answer responses where
335
- // the whole frame sequence arrives in ONE TCP chunk.
336
- const unitIdx = after.indexOf('\x1F')
337
- if (unitIdx === -1) {
338
- yield { type: 'text', text: after }
339
- } else {
340
- const textBefore = after.slice(0, unitIdx)
341
- const trailerAfter = after.slice(unitIdx + 1)
342
- if (textBefore) {
343
- yield { type: 'text', text: textBefore }
344
- }
345
- inTrailer = true
346
- trailerBuffer = trailerAfter
347
- }
348
- }
349
- break
350
- }
351
- if (nullIdx === -1) break // need more bytes
352
- const metaStr = buffer.slice(0, nullIdx)
353
- const remaining = buffer.slice(nullIdx + 1)
354
- let meta: any
355
- try {
356
- meta = JSON.parse(metaStr)
357
- } catch {
358
- // Not JSON — start of answer body.
359
- inText = true
360
- setStreamingPhase('streaming')
361
- if (buffer.length > 0) {
362
- yield { type: 'text', text: buffer }
363
- buffer = ''
364
- }
365
- break
366
- }
367
- if (meta.status === 'thinking') {
368
- setStreamingPhase('thinking')
369
- } else if (meta.kind === 'thinking-delta' && typeof meta.text === 'string') {
370
- thinkingAccum += meta.text
371
- const now = Date.now()
372
- if (now - lastThinkingYieldTime >= THINKING_YIELD_INTERVAL_MS) {
373
- lastThinkingYieldTime = now
374
- pendingThinkingYield = false
375
- yield { type: 'thinking', text: escapeThinkingTags(thinkingAccum) }
376
- } else {
377
- pendingThinkingYield = true
378
- }
379
- } else if (meta.kind === 'usage' && meta.stage === 'start') {
380
- mergeTurnMeta(metaMapRef, sendIdx, {
381
- inputTokens: meta.input_tokens ?? null,
382
- })
383
- bumpMetaTick()
384
- } else if (
385
- meta.kind === 'decision_resolved' &&
386
- typeof meta.action === 'string'
387
- ) {
388
- // Server-driven post-approve / post-reject frame.
389
- const action = meta.action === 'rejected' ? 'rejected' : 'approved'
390
- const toolName =
391
- typeof meta.tool_name === 'string' ? meta.tool_name : undefined
392
- const result = (meta.result ?? null) as
393
- | { ticket_id?: string; status?: string | null; mirror_synced?: boolean }
394
- | null
395
- const card = (meta.card ?? null) as
396
- | { type?: string; marker?: string; ref?: ChatRef }
397
- | null
398
- if (card?.ref?.id && card?.type) {
399
- const existing = refsMapRef.current.get(sendIdx) ?? {}
400
- const key = buildChatRefKey(card.type, card.ref.id)
401
- refsMapRef.current.set(sendIdx, { ...existing, [key]: card.ref })
402
- bumpMetaTick()
403
- }
404
- yield {
405
- type: 'decision_resolved',
406
- action,
407
- ok: meta.ok === true,
408
- willAutoContinue: meta.willAutoContinue === true,
409
- ...(toolName ? { toolName } : {}),
410
- ...(result ? { result } : {}),
411
- ...(card?.marker ? { marker: card.marker } : {}),
412
- ...(card?.ref ? { cardRef: card.ref } : {}),
413
- ...(typeof meta.receiptText === 'string'
414
- ? { receiptText: meta.receiptText }
415
- : {}),
416
- proposalId:
417
- typeof meta.proposalId === 'string' ? meta.proposalId : undefined,
418
- } as any
419
- } else if (meta.kind === 'approval_request' && meta.proposalId) {
420
- // The model called a write tool. Server persisted the
421
- // proposal and sent us a CARD-READY payload.
422
- const proposalId = String(meta.proposalId)
423
- const toolName = String(meta.toolName ?? 'tool')
424
- const headline =
425
- typeof meta.title === 'string' && meta.title.length > 0
426
- ? meta.title
427
- : toolName
428
- const rawFields = Array.isArray(meta.fields)
429
- ? (meta.fields as Array<{ label?: string; value?: string }>)
430
- : []
431
- const fields: Array<{ label: string; value: string }> = []
432
- for (const f of rawFields) {
433
- if (!f || !f.label || !f.value) continue
434
- fields.push({ label: f.label, value: f.value })
435
- }
436
- yield {
437
- type: 'approval_request',
438
- data: {
439
- command: headline,
440
- fields,
441
- requestId: proposalId,
442
- approvalType: toolName,
443
- },
444
- status: 'pending',
445
- } as any
446
- } else if (meta.kind === 'text-leading' && typeof meta.text === 'string') {
447
- // Model's preamble text (the prose written BEFORE the tool_use
448
- // block) — emitted as a standalone text segment.
449
- yield { type: 'text', text: meta.text }
450
- } else if (meta.kind === 'tool_error') {
451
- const msg =
452
- typeof meta.message === 'string' && meta.message.length > 0
453
- ? meta.message
454
- : 'Could not complete the requested action right now.'
455
- yield {
456
- type: 'text',
457
- text: `⚠️ ${msg}`,
458
- } as any
459
- } else if (meta.kind === 'routing') {
460
- if (typeof meta.routedComplexity === 'string') {
461
- mergeTurnMeta(metaMapRef, sendIdx, {
462
- routedComplexity: meta.routedComplexity,
463
- routedThinkingBudget:
464
- typeof meta.routedThinkingBudget === 'number'
465
- ? meta.routedThinkingBudget
466
- : null,
467
- })
468
- bumpMetaTick()
469
- }
470
- } else {
471
- if (meta.sources) {
472
- sourcesMapRef.current.set(sendIdx, meta.sources)
473
- }
474
- // Per-row refs for inline object cards.
475
- if (meta.refs && typeof meta.refs === 'object') {
476
- refsMapRef.current.set(sendIdx, meta.refs as Record<string, ChatRef>)
477
- bumpMetaTick()
478
- }
479
- if (
480
- meta.modelLabel ||
481
- meta.contextWindowMaxTokens ||
482
- meta.provider ||
483
- meta.model
484
- ) {
485
- mergeTurnMeta(metaMapRef, sendIdx, {
486
- provider: meta.provider ?? null,
487
- modelLabel: meta.modelLabel ?? null,
488
- contextWindowMaxTokens: meta.contextWindowMaxTokens ?? null,
489
- })
490
- bumpMetaTick()
491
- }
492
- const parsedAnchor = parseScrollAnchor(meta.scrollAnchor)
493
- if (parsedAnchor !== null) {
494
- mergeTurnMeta(metaMapRef, sendIdx, { scrollAnchor: parsedAnchor })
495
- bumpMetaTick()
496
- }
497
- }
498
- buffer = remaining
499
- }
500
- } else if (inTrailer) {
501
- trailerBuffer += chunk
502
- } else {
503
- setStreamingPhase('streaming')
504
- // Trailing usage frame uses \x1F (Unit Separator) as a sentinel.
505
- const sepIdx = chunk.indexOf('\x1F')
506
- if (sepIdx === -1) {
507
- yield { type: 'text', text: chunk }
508
- } else {
509
- const before = chunk.slice(0, sepIdx)
510
- const after = chunk.slice(sepIdx + 1)
511
- if (before) yield { type: 'text', text: before }
512
- inTrailer = true
513
- trailerBuffer = after
514
- }
515
- }
516
- }
517
- // Stream ended. Flush any pending thinking text that didn't make it
518
- // out before the throttle window expired.
519
- if (pendingThinkingYield) {
520
- pendingThinkingYield = false
521
- yield { type: 'thinking', text: escapeThinkingTags(thinkingAccum) }
522
- }
523
- // Parse trailer if present.
524
- if (trailerBuffer.length > 0) {
525
- try {
526
- const meta = JSON.parse(trailerBuffer)
527
- if (
528
- meta.kind === 'usage' &&
529
- (meta.stage === 'end' || meta.stage === 'display')
530
- ) {
531
- const breakdown =
532
- meta.breakdown && typeof meta.breakdown === 'object'
533
- ? {
534
- haikuRewriter:
535
- meta.breakdown.haikuRewriter &&
536
- typeof meta.breakdown.haikuRewriter.input === 'number'
537
- ? meta.breakdown.haikuRewriter
538
- : undefined,
539
- haikuClassifier:
540
- meta.breakdown.haikuClassifier &&
541
- typeof meta.breakdown.haikuClassifier.input === 'number'
542
- ? meta.breakdown.haikuClassifier
543
- : undefined,
544
- haikuSummarizer:
545
- meta.breakdown.haikuSummarizer &&
546
- typeof meta.breakdown.haikuSummarizer.input === 'number'
547
- ? meta.breakdown.haikuSummarizer
548
- : undefined,
549
- routedAnswer:
550
- meta.breakdown.routedAnswer &&
551
- typeof meta.breakdown.routedAnswer.model === 'string'
552
- ? meta.breakdown.routedAnswer
553
- : undefined,
554
- }
555
- : null
556
- mergeTurnMeta(metaMapRef, sendIdx, {
557
- inputTokens: meta.input_tokens ?? null,
558
- outputTokens: meta.output_tokens ?? null,
559
- cacheHitRatePct:
560
- typeof meta.hit_rate_pct === 'number' ? meta.hit_rate_pct : null,
561
- ...(breakdown ? { breakdown } : {}),
562
- })
563
- bumpMetaTick()
564
- }
565
- } catch {
566
- // Malformed trailer — silently ignore.
567
- }
568
- }
569
- }
570
- }
571
-
572
- /**
573
- * Merge partial fields into the per-turn meta map. Preserves existing
574
- * non-null values so a leading frame's data isn't wiped by a later frame
575
- * that didn't include it.
576
- */
577
- function mergeTurnMeta(
578
- ref: MutableRefObject<Map<number, ChatTurnMeta>>,
579
- sendIdx: number,
580
- partial: Partial<ChatTurnMeta>,
581
- ): void {
582
- const prev = ref.current.get(sendIdx) ?? createEmptyTurnMeta()
583
- const filtered = Object.fromEntries(
584
- Object.entries(partial).filter(([, v]) => v !== undefined && v !== null),
585
- ) as Partial<ChatTurnMeta>
586
- ref.current.set(sendIdx, { ...prev, ...filtered })
587
- }
588
-
589
- // =============================================================================
590
- // localStorage persistence
159
+ // localStorage persistence — PersistedChatState v1
591
160
  // =============================================================================
592
161
 
593
162
  const CHAT_STORAGE_VERSION = 1
@@ -676,6 +245,39 @@ function savePersistedChat(source: DocSource, state: PersistedChatState) {
676
245
  }
677
246
  }
678
247
 
248
+ /** v1 wire `Message` → reducer message. The v1 blob stores assistant
249
+ * segments inside `content`; the reducer keeps `content` a string and
250
+ * segments separate. */
251
+ function persistedToReducerMessage(m: Message): UnifiedChatMessage {
252
+ const segments = Array.isArray(m.content) ? (m.content as MessageSegment[]) : undefined
253
+ return {
254
+ id: m.id,
255
+ role: m.role,
256
+ content: typeof m.content === 'string' ? m.content : '',
257
+ ...(segments ? { segments } : {}),
258
+ ...(m.name !== undefined ? { name: m.name } : {}),
259
+ ...(m.avatar != null ? { avatar: m.avatar } : {}),
260
+ ...(m.timestamp !== undefined ? { timestamp: m.timestamp } : {}),
261
+ ...(m.hidden ? { hidden: true } : {}),
262
+ ...(m.chatRefs ? { chatRefs: m.chatRefs } : {}),
263
+ } as UnifiedChatMessage
264
+ }
265
+
266
+ /** Reducer message → v1 wire `Message`. Exact inverse of the loader so the
267
+ * persist round-trip re-saves an equivalent v1 blob (the golden contract). */
268
+ function reducerToPersistedMessage(m: UnifiedChatMessage): Message {
269
+ return {
270
+ id: m.id,
271
+ role: m.role,
272
+ ...(m.name !== undefined ? { name: m.name } : {}),
273
+ content: (m.segments ?? m.content) as Message['content'],
274
+ ...(m.timestamp !== undefined ? { timestamp: m.timestamp } : {}),
275
+ ...(m.avatar != null ? { avatar: m.avatar } : {}),
276
+ ...(m.hidden ? { hidden: true } : {}),
277
+ ...(m.chatRefs ? { chatRefs: m.chatRefs } : {}),
278
+ } as Message
279
+ }
280
+
679
281
  // =============================================================================
680
282
  // useSseChatAdapter — public hook
681
283
  // =============================================================================
@@ -697,70 +299,71 @@ export function useSseChatAdapter(
697
299
  // Chat-specific code REQUIRES a runtime — the lib's `<HubRuntimeProvider>`
698
300
  // (hub) / embedder's provider must wrap the tree.
699
301
  const runtime = useRequiredChatRuntime()
700
- // `source` is OPTIONAL — embedders are platform-agnostic (see ChatRuntime.source).
701
- // Here it's used ONLY for the localStorage history namespace + client-side meta
702
- // labels; it is NEVER sent on the wire (the hub resolves source server-side via
703
- // currentPlatform()). Fall back to a stable constant so the persistence key stays
704
- // well-formed when the embedder leaves source unset.
705
302
  const source = runtime.source || DEFAULT_CHAT_SOURCE
706
- // Fall back to the lib-baked hub-canonical map when the embedder
707
- // didn't supply an override. Keeps Ask + Display working in any
708
- // mount that doesn't have a custom documentType registry. Embedders
709
- // with polymorphic / per-tenant types pass their own callback and it
710
- // wins.
711
303
  const tableIdForDocumentType =
712
304
  options?.tableIdForDocumentType ?? defaultTableIdForDocumentType
713
305
 
714
- // Restore persisted state once on mount.
306
+ // ─── Reducer wiring ────────────────────────────────────────────────────────
307
+ // One store per hook instance; one reducer keyed by the source. Approval
308
+ // card buttons fire a server-driven confirm-tool turn via `sendMessage`
309
+ // (hidden approval-action send) — routed through a ref so the reducer's
310
+ // creation-time callbacks stay stable.
311
+
312
+ const sendMessageRef = useRef<
313
+ (text: string, options?: InternalSendMessageOptions) => Promise<void>
314
+ >(async () => {})
315
+
316
+ const cardApprove = useCallback((reqId?: string): void | Promise<void> => {
317
+ if (!reqId) return
318
+ return sendMessageRef.current('', {
319
+ hidden: true,
320
+ approvalAction: { proposalId: reqId, action: 'approve' },
321
+ })
322
+ }, [])
323
+ const cardReject = useCallback((reqId?: string): void | Promise<void> => {
324
+ if (!reqId) return
325
+ return sendMessageRef.current('', {
326
+ hidden: true,
327
+ approvalAction: { proposalId: reqId, action: 'reject' },
328
+ })
329
+ }, [])
330
+
331
+ const createReducerOptions = useCallback(
332
+ (): ChatStreamReducerOptions => ({
333
+ transport: 'sse',
334
+ callbacks: { onApprove: cardApprove, onReject: cardReject },
335
+ }),
336
+ [cardApprove, cardReject],
337
+ )
338
+
339
+ const storeRef = useRef<ChatDialogStore | null>(null)
340
+ if (storeRef.current === null) storeRef.current = createChatDialogStore()
341
+
342
+ // Restore persisted state once on mount — the v1 blob seeds the reducer's
343
+ // thread + its sendIdx-keyed sources/refs maps + the send counter.
715
344
  const persistedRef = useRef<PersistedChatState | null>(null)
716
345
  if (persistedRef.current === null) {
717
346
  pruneStaleChatStorage(source)
718
347
  persistedRef.current =
719
348
  loadPersistedChat(source) || { messages: [], sources: [], sendCount: 0 }
349
+ const reducer = storeRef.current.getReducer(source, DEFAULT_DIALOG_SIDE, createReducerOptions)
350
+ reducer.initializeWithState(persistedRef.current.messages.map(persistedToReducerMessage))
351
+ reducer.seedSseMaps({
352
+ sources: persistedRef.current.sources as Array<[number, unknown[]]>,
353
+ refs: persistedRef.current.refs,
354
+ sendCount: persistedRef.current.sendCount,
355
+ })
720
356
  }
721
357
 
722
- const sourcesMapRef = useRef<Map<number, ChatSource[]>>(
723
- new Map(persistedRef.current.sources),
724
- )
725
- const refsMapRef = useRef<Map<number, Record<string, ChatRef>>>(
726
- new Map(persistedRef.current.refs ?? []),
727
- )
728
- const metaMapRef = useRef<Map<number, ChatTurnMeta>>(new Map())
729
- const messagesRef = useRef<Message[]>(persistedRef.current.messages)
730
- const sendCountRef = useRef(persistedRef.current.sendCount)
731
- const [streamingPhase, setStreamingPhase] = useState<StreamingPhase>('idle')
732
- const [metaTick, setMetaTick] = useState(0)
733
- const bumpMetaTick = useCallback(() => setMetaTick((t) => t + 1), [])
358
+ const { state, applyEvent, flushDeltas, mutate, reducer } = useChatStreamReducer({
359
+ store: storeRef.current,
360
+ dialogId: source,
361
+ createReducerOptions,
362
+ })
734
363
 
735
- const streamFn = useMemo(
736
- () =>
737
- createDocStreamFn(
738
- source,
739
- {
740
- chatStreamUrl: runtime.endpoints.chatStreamUrl,
741
- approvalToolUrl: runtime.endpoints.approvalToolUrl,
742
- },
743
- messagesRef,
744
- sourcesMapRef,
745
- refsMapRef,
746
- metaMapRef,
747
- setStreamingPhase,
748
- bumpMetaTick,
749
- sendCountRef,
750
- ),
751
- [
752
- source,
753
- runtime.endpoints.chatStreamUrl,
754
- runtime.endpoints.approvalToolUrl,
755
- bumpMetaTick,
756
- ],
757
- )
364
+ const abortControllerRef = useRef<AbortController | null>(null)
758
365
 
759
- // Per-source tableId → slash-command-id lookup, derived from the shared
760
- // slash-command registry. Used by `displayRef` to translate an
761
- // inline-card's Display click into a `/<cmd> display "<value>"`
762
- // invocation, picking the canonical command for the row's RAG table.
763
- //
366
+ // ─── Slash-command registry (displayRef lookup) ───────────────────────────
764
367
  // Reads from the SAME react-query cache entry as `<EmbeddableChat>`'s
765
368
  // onboarding-card list (keyed on `commandsUrl`), so Guide mode fetches
766
369
  // `commands` ONCE. Gated on `active` so a Mingo-only panel — where this
@@ -792,138 +395,221 @@ export function useSseChatAdapter(
792
395
  return map
793
396
  }, [slashCommands])
794
397
 
795
- // Persist on every messages change. Sources + sendCount live in refs,
796
- // so we read their current values at write time.
398
+ // ─── Persistence — save on every messages change ──────────────────────────
399
+ // Sources/refs/sendCount live in the reducer's maps; read at write time.
797
400
  const persist = useCallback(
798
- (nextMessages: Message[]) => {
401
+ (nextMessages: UnifiedChatMessage[]) => {
402
+ const turnMeta = reducer.state.turnMeta
799
403
  savePersistedChat(source, {
800
- messages: nextMessages,
801
- sources: Array.from(sourcesMapRef.current.entries()),
802
- refs: Array.from(refsMapRef.current.entries()),
803
- sendCount: sendCountRef.current,
404
+ messages: nextMessages.map(reducerToPersistedMessage),
405
+ sources: Array.from(turnMeta.sources.entries()) as Array<[number, ChatSource[]]>,
406
+ refs: Array.from(turnMeta.refs.entries()),
407
+ sendCount: turnMeta.sendCount,
804
408
  })
805
409
  },
806
- [source],
410
+ [source, reducer],
807
411
  )
412
+ useEffect(() => {
413
+ persist(state.messages)
414
+ }, [state.messages, persist])
808
415
 
809
- const {
810
- messages,
811
- isTyping,
812
- isStreaming,
813
- sendMessage: chatSendMessage,
814
- stopMessage: chatStopMessage,
815
- clearMessages: chatClearMessages,
816
- hasMessages,
817
- } = useChat({
818
- useMock: false,
819
- assistantName: 'Mingo AI',
820
- streamFn,
821
- initialMessages: persistedRef.current.messages,
822
- onMessagesChange: persist,
823
- })
824
-
825
- messagesRef.current = messages
826
-
827
- // Index sources/refs/scrollAnchor by USER-SEND count (`sendIdx`), not
828
- // by assistant-message count. Each user send produces exactly ONE
829
- // refsMapRef entry server-side, but it can produce MULTIPLE assistant
830
- // messages on the client (main RAG reply + post-approve card +
831
- // auto-continuation prose). Counting USER sends and mapping every
832
- // following assistant message to that index keeps the lookup stable.
833
- let sendIdx = -1
834
- const docMessages: DocChatMessage[] = messages.map((m) => {
835
- const segments = Array.isArray(m.content) ? (m.content as MessageSegment[]) : undefined
836
- const content =
837
- typeof m.content === 'string'
838
- ? m.content
839
- : Array.isArray(m.content)
840
- ? m.content
841
- .filter((s) => s.type === 'text')
842
- .map((s) => (s as { type: 'text'; text: string }).text)
843
- .join('')
844
- : ''
845
-
846
- let sources: ChatSource[] | undefined
847
- let chatRefs: Record<string, ChatRef> | undefined
848
- let scrollAnchor: ScrollAnchor | undefined
849
- if (m.role === 'user' && !m.hidden) {
850
- sendIdx++
851
- }
852
- if (m.role === 'assistant') {
853
- const lookupIdx = sendIdx >= 0 ? sendIdx : 0
854
- sources = sourcesMapRef.current.get(lookupIdx)
855
- // The receipt path stamps `chatRefs` directly onto the assistant
856
- // message in useChat. Prefer that message-bound copy when present;
857
- // falls back to per-turn refs map.
858
- chatRefs = m.chatRefs ?? refsMapRef.current.get(lookupIdx)
859
- scrollAnchor =
860
- (metaMapRef.current.get(lookupIdx)?.scrollAnchor as ScrollAnchor | null) ??
861
- undefined
862
- }
863
-
864
- return {
865
- id: m.id,
866
- role: m.role as 'user' | 'assistant',
867
- content,
868
- ...(segments ? { segments } : {}),
869
- ...(sources ? { sources } : {}),
870
- ...(chatRefs ? { chatRefs } : {}),
871
- ...(scrollAnchor ? { scrollAnchor } : {}),
872
- ...(m.hidden ? { hidden: true } : {}),
873
- }
874
- })
416
+ // ─── Send / stream loop ────────────────────────────────────────────────────
875
417
 
876
418
  /**
877
419
  * Internal sendMessage options — union of the public
878
420
  * `UnifiedSendMessageOptions` (semantic fields: `hidden`, `attachments`)
879
421
  * and SSE-only internal extras (`commandOverride`, `approvalAction`)
880
- * set by `discussRef` / `displayRef` / post-approval continuation.
881
- *
882
- * The public `UnifiedChatState.sendMessage` accepts only the narrow
883
- * unified shape; this hook accepts the wider one because internal
884
- * callers need it. Function-param contravariance makes this assignable.
422
+ * set by `discussRef` / `displayRef` / the approval-card callbacks.
885
423
  */
886
- type InternalSendMessageOptions = UnifiedSendMessageOptions &
887
- Pick<StreamFnExtraOptions, 'commandOverride' | 'approvalAction'>
424
+ type InternalSendMessageOptions = UnifiedSendMessageOptions & {
425
+ commandOverride?: WireCommandOverride
426
+ approvalAction?: { proposalId: string; action: 'approve' | 'reject' }
427
+ }
428
+
429
+ const endpointsRef = useRef(runtime.endpoints)
430
+ endpointsRef.current = runtime.endpoints
888
431
 
889
432
  const sendMessage = useCallback(
890
- async (
891
- text: string,
892
- options?: InternalSendMessageOptions,
893
- ): Promise<void> => {
894
- const {
895
- hidden,
896
- attachments,
897
- commandOverride,
898
- approvalAction,
899
- } = options ?? {}
900
- const sseExtras: StreamFnExtraOptions = {
901
- ...(commandOverride ? { commandOverride } : {}),
902
- ...(approvalAction ? { approvalAction } : {}),
903
- ...(attachments && attachments.length > 0
904
- ? { pendingAttachments: attachments }
905
- : {}),
433
+ async (text: string, sendOptions?: InternalSendMessageOptions): Promise<void> => {
434
+ const { hidden, attachments, commandOverride, approvalAction } = sendOptions ?? {}
435
+
436
+ // Conversation history for the server: everything BEFORE this send,
437
+ // `hidden:true` rows included (LLM context) but their flag filtered
438
+ // shape-wise like legacy — hidden messages are excluded entirely.
439
+ const history = reducer.state.messages
440
+ .filter((m) => (m.role === 'user' || m.role === 'assistant') && !m.hidden)
441
+ .map((m) => ({
442
+ role: m.role,
443
+ content: m.segments ? flattenAssistantContent(m.segments) : (m.content as string),
444
+ }))
445
+
446
+ // URL + body branch — approvalAction routes to the approval-tool
447
+ // endpoint, the standard chat path routes to the chat-stream endpoint.
448
+ // `source` is INTENTIONALLY NOT in the body: the chat route resolves
449
+ // it server-side via its own platform-detection — tamper-proof binding.
450
+ const targetPath = approvalAction
451
+ ? endpointsRef.current.approvalToolUrl
452
+ : endpointsRef.current.chatStreamUrl
453
+ const requestBody = approvalAction
454
+ ? {
455
+ proposal_id: approvalAction.proposalId,
456
+ action: approvalAction.action,
457
+ messages: history,
458
+ }
459
+ : {
460
+ messages: [...history, { role: 'user', content: text }],
461
+ ...(commandOverride ? { commandOverride } : {}),
462
+ ...(attachments && attachments.length > 0
463
+ ? { pendingAttachments: attachments as ChatAttachment[] }
464
+ : {}),
465
+ }
466
+
467
+ // Optimistic user bubble + assistant placeholder + phase 'thinking'
468
+ // + sendCount++ — one reducer command.
469
+ mutate((r) =>
470
+ r.beginSseSend({ text, hidden, userName: 'You', assistantName: 'Mingo AI' }),
471
+ )
472
+
473
+ const ctrl = new AbortController()
474
+ abortControllerRef.current = ctrl
475
+
476
+ try {
477
+ // `chatAuthedFetch` carries the bearer-act-as headers (+ Supabase
478
+ // session cookies) — same wrapper `use-chat-attachments` and
479
+ // `use-chat-identity` use.
480
+ const response = await chatAuthedFetch(targetPath, {
481
+ method: 'POST',
482
+ body: JSON.stringify(requestBody),
483
+ signal: ctrl.signal,
484
+ })
485
+ if (!response.ok) {
486
+ throw new Error(`Chat request failed: ${response.status}`)
487
+ }
488
+ const reader = response.body?.getReader()
489
+ if (!reader) throw new Error('No response body')
490
+
491
+ // Decoder is per-send (a rapid send-stop-send sequence must never
492
+ // feed the second stream's first chunk into the first stream's
493
+ // tail buffer).
494
+ const frameDecoder = createSseFrameDecoder()
495
+ let finished = false
496
+ try {
497
+ while (true) {
498
+ const { done, value } = await reader.read()
499
+ if (done) break
500
+ if (ctrl.signal.aborted) break
501
+ for (const event of frameDecoder.push(value)) {
502
+ applyEvent(event)
503
+ }
504
+ }
505
+ finished = true
506
+ } finally {
507
+ // `end()` always runs so the decoder settles; its events (the
508
+ // trailing usage frame) apply only on a CLEAN end — aborts
509
+ // propagate without extra state (legacy parity).
510
+ const endEvents = frameDecoder.end()
511
+ if (finished) {
512
+ for (const event of endEvents) applyEvent(event)
513
+ }
514
+ }
515
+ } catch (err) {
516
+ // AbortError on user-initiated stop is expected — keep the partial
517
+ // message, no error row.
518
+ if ((err as { name?: string })?.name !== 'AbortError' && !ctrl.signal.aborted) {
519
+ flushDeltas()
520
+ mutate((r) =>
521
+ r.failSseTurn(
522
+ err instanceof Error
523
+ ? err.message
524
+ : 'An error occurred while processing your request.',
525
+ ),
526
+ )
527
+ }
528
+ } finally {
529
+ if (abortControllerRef.current === ctrl) {
530
+ abortControllerRef.current = null
531
+ }
532
+ // Force-flush pending deltas BEFORE the completion state lands, then
533
+ // settle the turn (drops an empty trailing placeholder — the reject
534
+ // path streams no text — and returns the phase to idle).
535
+ flushDeltas()
536
+ mutate((r) => r.endSseTurn())
906
537
  }
907
- sendCountRef.current++
908
- setStreamingPhase('thinking')
909
- await chatSendMessage(text, sseExtras, hidden ? { hidden } : undefined)
910
538
  },
911
- [chatSendMessage],
539
+ [reducer, mutate, applyEvent, flushDeltas],
912
540
  )
541
+ sendMessageRef.current = sendMessage
542
+
543
+ const stopMessage = useCallback(() => {
544
+ abortControllerRef.current?.abort()
545
+ flushDeltas()
546
+ mutate((r) => r.setPhase('idle'))
547
+ }, [mutate, flushDeltas])
548
+
549
+ const clearMessages = useCallback(() => {
550
+ mutate((r) => r.reset())
551
+ // Clear persisted state too so the next mount starts fresh.
552
+ if (typeof window !== 'undefined') {
553
+ try {
554
+ window.localStorage.removeItem(chatStorageKey(source))
555
+ } catch {}
556
+ }
557
+ }, [mutate, source])
558
+
559
+ // ─── Public message mapping (sendIdx fan-out lookup) ──────────────────────
560
+ // Index sources/refs/scrollAnchor by USER-SEND count (`sendIdx`), not by
561
+ // assistant-message count. Each user send produces exactly ONE refs entry
562
+ // server-side, but it can produce MULTIPLE assistant messages on the
563
+ // client (main RAG reply + post-approve card + auto-continuation prose).
564
+ // Counting VISIBLE user sends and mapping every following assistant
565
+ // message to that index keeps the lookup stable — this is the
566
+ // PersistedChatState v1 rehydration contract.
567
+ const docMessages: DocChatMessage[] = useMemo(() => {
568
+ const { meta, sources: sourcesMap, refs: refsMap } = state.turnMeta
569
+ let sendIdx = -1
570
+ return state.messages.map((m) => {
571
+ const segments = m.segments
572
+ const content =
573
+ typeof m.content === 'string' && !segments
574
+ ? m.content
575
+ : segments
576
+ ?.filter((s) => s.type === 'text')
577
+ .map((s) => (s as { type: 'text'; text: string }).text)
578
+ .join('') ?? ''
579
+
580
+ let sources: ChatSource[] | undefined
581
+ let chatRefs: Record<string, ChatRef> | undefined
582
+ let scrollAnchor: ScrollAnchor | undefined
583
+ if (m.role === 'user' && !m.hidden) {
584
+ sendIdx++
585
+ }
586
+ if (m.role === 'assistant') {
587
+ const lookupIdx = sendIdx >= 0 ? sendIdx : 0
588
+ sources = sourcesMap.get(lookupIdx) as ChatSource[] | undefined
589
+ // The receipt path stamps `chatRefs` directly onto the assistant
590
+ // message; prefer that message-bound copy when present, fall back
591
+ // to the per-turn refs map.
592
+ chatRefs = m.chatRefs ?? refsMap.get(lookupIdx)
593
+ scrollAnchor = (meta.get(lookupIdx)?.scrollAnchor as ScrollAnchor | null) ?? undefined
594
+ }
595
+
596
+ return {
597
+ id: m.id,
598
+ role: m.role as 'user' | 'assistant',
599
+ content,
600
+ ...(segments ? { segments } : {}),
601
+ ...(sources ? { sources } : {}),
602
+ ...(chatRefs ? { chatRefs } : {}),
603
+ ...(scrollAnchor ? { scrollAnchor } : {}),
604
+ ...(m.hidden ? { hidden: true } : {}),
605
+ }
606
+ })
607
+ }, [state.messages, state.turnMeta])
913
608
 
914
609
  /**
915
610
  * "Display" callback for inline cards whose registry entry sets
916
611
  * `displayAction: true`. Parallel to `discussRef` but emits a
917
- * `/<cmd> display "<title>"` slash command instead of the Discuss
918
- * prose. Resolution of the slash command id for the ref's documentType
919
- * happens via the `cmdIdByTableId` map hydrated from the commands
920
- * endpoint.
921
- *
922
- * `tableIdForDocumentType` is always defined here — either the
923
- * embedder-supplied override OR the lib-baked
924
- * `defaultTableIdForDocumentType` fallback. Unknown documentTypes
925
- * still short-circuit gracefully via the `tableId === null` check
926
- * below.
612
+ * `/<cmd> display "<title>"` slash command instead of the Discuss prose.
927
613
  */
928
614
  const displayRef = useCallback(
929
615
  (reference: ChatRef) => {
@@ -965,10 +651,6 @@ export function useSseChatAdapter(
965
651
  * prompt ("Tell me more about <title>"); the structured
966
652
  * `commandOverride.entityIdFilter` is sent server-side via the request
967
653
  * body so retrieval narrows to the named row.
968
- *
969
- * `tableIdForDocumentType` is always defined here — either the
970
- * embedder-supplied override OR the lib-baked default. Unknown
971
- * documentTypes still short-circuit via the `tableId === null` check.
972
654
  */
973
655
  const discussRef = useCallback(
974
656
  (reference: ChatRef) => {
@@ -998,47 +680,17 @@ export function useSseChatAdapter(
998
680
  [sendMessage, tableIdForDocumentType],
999
681
  )
1000
682
 
1001
- const stopMessage = useCallback(() => {
1002
- chatStopMessage()
1003
- setStreamingPhase('idle')
1004
- }, [chatStopMessage])
683
+ // ─── Per-turn metadata resolution ─────────────────────────────────────────
684
+ const latestMeta = useMemo(() => {
685
+ const { meta, sendCount } = state.turnMeta
686
+ return meta.get(sendCount - 1) ?? meta.get(sendCount - 2) ?? null
687
+ }, [state])
1005
688
 
1006
- const clearMessages = useCallback(() => {
1007
- sourcesMapRef.current.clear()
1008
- refsMapRef.current.clear()
1009
- metaMapRef.current.clear()
1010
- sendCountRef.current = 0
1011
- setStreamingPhase('idle')
1012
- // Force the latestMeta useMemo to recompute with the cleared map.
1013
- bumpMetaTick()
1014
- chatClearMessages()
1015
- // Clear persisted state too so the next mount starts fresh.
1016
- if (typeof window !== 'undefined') {
1017
- try {
1018
- window.localStorage.removeItem(chatStorageKey(source))
1019
- } catch {}
1020
- }
1021
- }, [chatClearMessages, source, bumpMetaTick])
1022
-
1023
- // Reset to idle whenever both flags drop off.
1024
- useEffect(() => {
1025
- if (!isTyping && !isStreaming && streamingPhase !== 'idle') {
1026
- setStreamingPhase('idle')
1027
- }
1028
- }, [isTyping, isStreaming, streamingPhase])
1029
-
1030
- // Resolve the active turn's metadata.
1031
- const latestMeta = useMemo(
1032
- () =>
1033
- metaMapRef.current.get(sendCountRef.current - 1) ??
1034
- metaMapRef.current.get(sendCountRef.current - 2) ??
1035
- null,
1036
- [metaTick, streamingPhase],
1037
- )
689
+ const streamingPhase = state.streamingPhase
1038
690
 
1039
691
  return {
1040
692
  messages: docMessages,
1041
- isLoading: isTyping || isStreaming,
693
+ isLoading: streamingPhase !== 'idle',
1042
694
  sendMessage,
1043
695
  /** "Discuss" affordance for ObjectCard. */
1044
696
  discussRef,