@flamingo-stack/openframe-frontend-core 0.0.488 → 0.0.490-1506.3944.1

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 (311) 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 +196 -0
  8. package/dist/chat-protocol/events.d.ts.map +1 -0
  9. package/dist/chat-protocol/frames.d.ts +213 -0
  10. package/dist/chat-protocol/frames.d.ts.map +1 -0
  11. package/dist/chat-protocol/index.cjs +494 -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 +478 -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-AJNDDXKC.js → chunk-4NWARV5I.js} +3 -3
  22. package/dist/{chunk-GQX6LFFA.cjs → chunk-5CJR2GZ4.cjs} +42 -42
  23. package/dist/{chunk-GQX6LFFA.cjs.map → chunk-5CJR2GZ4.cjs.map} +1 -1
  24. package/dist/{chunk-EUYIFALO.cjs → chunk-5EYRXSY5.cjs} +14 -14
  25. package/dist/{chunk-EUYIFALO.cjs.map → chunk-5EYRXSY5.cjs.map} +1 -1
  26. package/dist/{chunk-5RXKOGH6.cjs → chunk-5LMGFI4J.cjs} +7011 -4777
  27. package/dist/chunk-5LMGFI4J.cjs.map +1 -0
  28. package/dist/{chunk-KFQXO7QB.js → chunk-6GT557KN.js} +1 -1
  29. package/dist/chunk-6GT557KN.js.map +1 -0
  30. package/dist/{chunk-77DFPD35.js → chunk-BE6DK7QY.js} +12267 -10033
  31. package/dist/chunk-BE6DK7QY.js.map +1 -0
  32. package/dist/{chunk-A7IPON2V.cjs → chunk-BIKOJ4FH.cjs} +5 -5
  33. package/dist/{chunk-A7IPON2V.cjs.map → chunk-BIKOJ4FH.cjs.map} +1 -1
  34. package/dist/{chunk-HQYJINFV.js → chunk-CORIBKHG.js} +4 -4
  35. package/dist/{chunk-YEEHB4T6.cjs → chunk-CSYSYJCF.cjs} +40 -40
  36. package/dist/{chunk-YEEHB4T6.cjs.map → chunk-CSYSYJCF.cjs.map} +1 -1
  37. package/dist/{chunk-SIKCXKGB.cjs → chunk-DSDZ5T4P.cjs} +7 -7
  38. package/dist/{chunk-SIKCXKGB.cjs.map → chunk-DSDZ5T4P.cjs.map} +1 -1
  39. package/dist/{chunk-PNXNWGBY.js → chunk-GTWO5NAY.js} +2 -2
  40. package/dist/{chunk-SCCTJ2UI.js → chunk-HSACATCD.js} +4 -4
  41. package/dist/{chunk-PECGZWQR.cjs → chunk-KP5KRRD6.cjs} +3 -3
  42. package/dist/{chunk-PECGZWQR.cjs.map → chunk-KP5KRRD6.cjs.map} +1 -1
  43. package/dist/{chunk-2B6773QM.cjs → chunk-LILQJ3UZ.cjs} +1 -1
  44. package/dist/chunk-LILQJ3UZ.cjs.map +1 -0
  45. package/dist/{chunk-RZSA6MZL.cjs → chunk-NWX7MMF5.cjs} +15 -15
  46. package/dist/{chunk-RZSA6MZL.cjs.map → chunk-NWX7MMF5.cjs.map} +1 -1
  47. package/dist/{chunk-V3CNNMY3.cjs → chunk-P4WTZU6L.cjs} +18 -18
  48. package/dist/{chunk-V3CNNMY3.cjs.map → chunk-P4WTZU6L.cjs.map} +1 -1
  49. package/dist/{chunk-KLYHZRAD.js → chunk-PB3BTZDU.js} +2 -2
  50. package/dist/{chunk-FZOBQG6M.cjs → chunk-Q6E7VJ4W.cjs} +37 -36
  51. package/dist/chunk-Q6E7VJ4W.cjs.map +1 -0
  52. package/dist/{chunk-EYEIVYYF.cjs → chunk-QSCHCZL3.cjs} +7 -7
  53. package/dist/{chunk-EYEIVYYF.cjs.map → chunk-QSCHCZL3.cjs.map} +1 -1
  54. package/dist/{chunk-YJSKQ2ZV.js → chunk-RQLBHKXN.js} +3 -3
  55. package/dist/{chunk-CWQACI4Z.js → chunk-RW2LPL72.js} +2 -2
  56. package/dist/{chunk-DX6AOHNU.cjs → chunk-S2NHJDKQ.cjs} +77 -77
  57. package/dist/{chunk-DX6AOHNU.cjs.map → chunk-S2NHJDKQ.cjs.map} +1 -1
  58. package/dist/{chunk-AHDJMEEN.js → chunk-T2EGSZFN.js} +9 -8
  59. package/dist/chunk-T2EGSZFN.js.map +1 -0
  60. package/dist/{chunk-S52LJNMU.js → chunk-TKN5667N.js} +13 -13
  61. package/dist/chunk-TKN5667N.js.map +1 -0
  62. package/dist/{chunk-3BFX6HJM.js → chunk-UUYRJPGJ.js} +6 -6
  63. package/dist/{chunk-F6BYUO5U.js → chunk-VHG6AX5V.js} +3 -3
  64. package/dist/{chunk-FOQV6RCO.cjs → chunk-VQ523ORK.cjs} +29 -29
  65. package/dist/{chunk-FOQV6RCO.cjs.map → chunk-VQ523ORK.cjs.map} +1 -1
  66. package/dist/{chunk-PXF5J24T.js → chunk-XQ2B74SI.js} +2 -2
  67. package/dist/{chunk-H7JX2BG7.cjs → chunk-YHOIAA2E.cjs} +76 -76
  68. package/dist/chunk-YHOIAA2E.cjs.map +1 -0
  69. package/dist/{chunk-LLWBPPZ4.js → chunk-YZ2OIYQ3.js} +6 -6
  70. package/dist/components/case-studies/index.cjs +10 -10
  71. package/dist/components/case-studies/index.js +4 -4
  72. package/dist/components/chat/chat-message-enhanced.d.ts.map +1 -1
  73. package/dist/components/chat/chat-message-list.d.ts.map +1 -1
  74. package/dist/components/chat/hooks/index.d.ts +0 -1
  75. package/dist/components/chat/hooks/index.d.ts.map +1 -1
  76. package/dist/components/chat/hooks/use-chat-history-hydration.d.ts +47 -0
  77. package/dist/components/chat/hooks/use-chat-history-hydration.d.ts.map +1 -0
  78. package/dist/components/chat/hooks/use-chat.d.ts +1 -0
  79. package/dist/components/chat/hooks/use-chat.d.ts.map +1 -1
  80. package/dist/components/chat/hooks/use-nats-chat-adapter.d.ts +15 -48
  81. package/dist/components/chat/hooks/use-nats-chat-adapter.d.ts.map +1 -1
  82. package/dist/components/chat/hooks/use-sse-chat-adapter.d.ts +10 -53
  83. package/dist/components/chat/hooks/use-sse-chat-adapter.d.ts.map +1 -1
  84. package/dist/components/chat/index.cjs +12 -4
  85. package/dist/components/chat/index.cjs.map +1 -1
  86. package/dist/components/chat/index.d.ts +1 -0
  87. package/dist/components/chat/index.d.ts.map +1 -1
  88. package/dist/components/chat/index.js +23 -15
  89. package/dist/components/chat/stream/chat-dialog-store.d.ts +184 -0
  90. package/dist/components/chat/stream/chat-dialog-store.d.ts.map +1 -0
  91. package/dist/components/chat/stream/chat-stream-reducer.d.ts +367 -0
  92. package/dist/components/chat/stream/chat-stream-reducer.d.ts.map +1 -0
  93. package/dist/components/chat/stream/delta-batcher.d.ts +51 -0
  94. package/dist/components/chat/stream/delta-batcher.d.ts.map +1 -0
  95. package/dist/components/chat/stream/index.d.ts +17 -0
  96. package/dist/components/chat/stream/index.d.ts.map +1 -0
  97. package/dist/components/chat/stream/message-mutations.d.ts +77 -0
  98. package/dist/components/chat/stream/message-mutations.d.ts.map +1 -0
  99. package/dist/components/chat/stream/use-chat-stream-reducer.d.ts +22 -0
  100. package/dist/components/chat/stream/use-chat-stream-reducer.d.ts.map +1 -0
  101. package/dist/components/chat/types/api.types.d.ts +1 -81
  102. package/dist/components/chat/types/api.types.d.ts.map +1 -1
  103. package/dist/components/chat/types/processing.types.d.ts +2 -90
  104. package/dist/components/chat/types/processing.types.d.ts.map +1 -1
  105. package/dist/components/chat/types/unified-chat-state.types.d.ts +5 -0
  106. package/dist/components/chat/types/unified-chat-state.types.d.ts.map +1 -1
  107. package/dist/components/chat/utils/chat-conversation-storage.d.ts +34 -0
  108. package/dist/components/chat/utils/chat-conversation-storage.d.ts.map +1 -0
  109. package/dist/components/chat/utils/extract-incomplete-message-state.d.ts +34 -6
  110. package/dist/components/chat/utils/extract-incomplete-message-state.d.ts.map +1 -1
  111. package/dist/components/chat/utils/index.d.ts +2 -3
  112. package/dist/components/chat/utils/index.d.ts.map +1 -1
  113. package/dist/components/chat/utils/process-historical-messages.d.ts +59 -11
  114. package/dist/components/chat/utils/process-historical-messages.d.ts.map +1 -1
  115. package/dist/components/contact/index.cjs +5 -5
  116. package/dist/components/contact/index.js +4 -4
  117. package/dist/components/docs/index.cjs +7 -7
  118. package/dist/components/docs/index.js +6 -6
  119. package/dist/components/embeds/index.cjs +5 -5
  120. package/dist/components/embeds/index.js +4 -4
  121. package/dist/components/faq/index.cjs +6 -6
  122. package/dist/components/faq/index.js +5 -5
  123. package/dist/components/features/index.cjs +4 -4
  124. package/dist/components/features/index.js +3 -3
  125. package/dist/components/help-center-pages/index.cjs +23 -23
  126. package/dist/components/help-center-pages/index.js +14 -14
  127. package/dist/components/index.cjs +182 -140
  128. package/dist/components/index.cjs.map +1 -1
  129. package/dist/components/index.js +66 -24
  130. package/dist/components/index.js.map +1 -1
  131. package/dist/components/navigation/index.cjs +4 -4
  132. package/dist/components/navigation/index.js +3 -3
  133. package/dist/components/onboarding-guides/index.cjs +8 -8
  134. package/dist/components/onboarding-guides/index.js +7 -7
  135. package/dist/components/onboarding-guides/onboarding-guide-detail-view.d.ts.map +1 -1
  136. package/dist/components/related-content/index.cjs +6 -6
  137. package/dist/components/related-content/index.js +5 -5
  138. package/dist/components/shared/product-release/release-detail-page.d.ts.map +1 -1
  139. package/dist/components/tickets/index.cjs +7 -7
  140. package/dist/components/tickets/index.js +6 -6
  141. package/dist/components/ui/index.cjs +46 -4
  142. package/dist/components/ui/index.cjs.map +1 -1
  143. package/dist/components/ui/index.d.ts +1 -2
  144. package/dist/components/ui/index.d.ts.map +1 -1
  145. package/dist/components/ui/index.js +57 -15
  146. package/dist/components/ui/markdown/base-components.d.ts +51 -0
  147. package/dist/components/ui/markdown/base-components.d.ts.map +1 -0
  148. package/dist/components/ui/markdown/engine.d.ts +99 -0
  149. package/dist/components/ui/markdown/engine.d.ts.map +1 -0
  150. package/dist/components/ui/markdown/heading-ids.d.ts +65 -0
  151. package/dist/components/ui/markdown/heading-ids.d.ts.map +1 -0
  152. package/dist/components/ui/markdown/index.d.ts +18 -0
  153. package/dist/components/ui/markdown/index.d.ts.map +1 -0
  154. package/dist/components/ui/markdown/mermaid-diagram.d.ts +79 -0
  155. package/dist/components/ui/markdown/mermaid-diagram.d.ts.map +1 -0
  156. package/dist/components/ui/markdown/rich/embed-overrides.d.ts +14 -0
  157. package/dist/components/ui/markdown/rich/embed-overrides.d.ts.map +1 -0
  158. package/dist/components/ui/markdown/rich/rich-markdown-renderer.d.ts +40 -0
  159. package/dist/components/ui/markdown/rich/rich-markdown-renderer.d.ts.map +1 -0
  160. package/dist/components/ui/markdown/rich/shortcodes.d.ts +8 -0
  161. package/dist/components/ui/markdown/rich/shortcodes.d.ts.map +1 -0
  162. package/dist/components/ui/markdown/sanitize.d.ts +153 -0
  163. package/dist/components/ui/markdown/sanitize.d.ts.map +1 -0
  164. package/dist/components/ui/markdown/simple-markdown-renderer.d.ts +23 -0
  165. package/dist/components/ui/markdown/simple-markdown-renderer.d.ts.map +1 -0
  166. package/dist/components/ui/markdown/streaming.d.ts +78 -0
  167. package/dist/components/ui/markdown/streaming.d.ts.map +1 -0
  168. package/dist/components/ui/markdown/text-size.d.ts +34 -0
  169. package/dist/components/ui/markdown/text-size.d.ts.map +1 -0
  170. package/dist/contexts/chat-runtime-context.d.ts +8 -0
  171. package/dist/contexts/chat-runtime-context.d.ts.map +1 -1
  172. package/dist/contexts/index.cjs +2 -2
  173. package/dist/contexts/index.js +1 -1
  174. package/dist/hooks/index.cjs +3 -3
  175. package/dist/hooks/index.js +2 -2
  176. package/dist/index.cjs +62 -4
  177. package/dist/index.cjs.map +1 -1
  178. package/dist/index.js +73 -15
  179. package/dist/utils/index.cjs +169 -40
  180. package/dist/utils/index.cjs.map +1 -1
  181. package/dist/utils/index.d.ts +1 -0
  182. package/dist/utils/index.d.ts.map +1 -1
  183. package/dist/utils/index.js +162 -41
  184. package/dist/utils/index.js.map +1 -1
  185. package/dist/utils/markdown-fences.d.ts +42 -0
  186. package/dist/utils/markdown-fences.d.ts.map +1 -0
  187. package/dist/utils/markdown-heading-id.d.ts +85 -0
  188. package/dist/utils/markdown-heading-id.d.ts.map +1 -0
  189. package/dist/utils/markdown-section-extractor.d.ts.map +1 -1
  190. package/package.json +7 -1
  191. package/src/chat-protocol/__tests__/__snapshots__/nats-decoder-golden.test.ts.snap +307 -0
  192. package/src/chat-protocol/__tests__/chat-protocol.test.ts +516 -0
  193. package/src/chat-protocol/__tests__/env-flag.test.ts +52 -0
  194. package/src/chat-protocol/__tests__/ip-normalize.test.ts +136 -0
  195. package/src/chat-protocol/__tests__/nats-decoder-golden.test.ts +228 -0
  196. package/src/chat-protocol/decode.ts +278 -0
  197. package/src/chat-protocol/encode.ts +71 -0
  198. package/src/chat-protocol/env-flag.ts +39 -0
  199. package/src/chat-protocol/events.ts +232 -0
  200. package/src/chat-protocol/frames.ts +245 -0
  201. package/src/chat-protocol/index.ts +21 -0
  202. package/src/chat-protocol/ip-normalize.ts +146 -0
  203. package/src/chat-protocol/nats-decoder.ts +252 -0
  204. package/src/components/chat/__tests__/chat-message-list.test.tsx +293 -5
  205. package/src/components/chat/__tests__/chat-message-streaming-memo.test.tsx +207 -0
  206. package/src/components/chat/__tests__/chat-pending-turn.test.tsx +209 -0
  207. package/src/components/chat/chat-message-enhanced.tsx +111 -17
  208. package/src/components/chat/chat-message-list.tsx +358 -109
  209. package/src/components/chat/embeddable-chat.tsx +6 -0
  210. package/src/components/chat/hooks/.index.md +30 -33
  211. package/src/components/chat/hooks/.use-nats-chat-adapter.md +36 -55
  212. package/src/components/chat/hooks/__tests__/__snapshots__/conversation-id-persistence-golden.test.ts.snap +37 -0
  213. package/src/components/chat/hooks/__tests__/__snapshots__/sse-stream-golden.test.ts.snap +0 -0
  214. package/src/components/chat/hooks/__tests__/conversation-id-persistence-golden.test.ts +438 -0
  215. package/src/components/chat/hooks/__tests__/sse-stream-golden.test.ts +318 -0
  216. package/src/components/chat/hooks/index.ts +0 -1
  217. package/src/components/chat/hooks/use-chat-history-hydration.ts +142 -0
  218. package/src/components/chat/hooks/use-chat.ts +20 -0
  219. package/src/components/chat/hooks/use-nats-chat-adapter.ts +243 -823
  220. package/src/components/chat/hooks/use-sse-chat-adapter.ts +417 -774
  221. package/src/components/chat/index.ts +5 -0
  222. package/src/components/chat/stream/__tests__/__snapshots__/chat-stream-reducer-golden.test.ts.snap +940 -0
  223. package/src/components/chat/stream/__tests__/chat-dialog-store.test.ts +756 -0
  224. package/src/components/chat/stream/__tests__/chat-stream-reducer-golden.test.ts +460 -0
  225. package/src/components/chat/stream/__tests__/chat-stream-reducer.test.ts +894 -0
  226. package/src/components/chat/stream/__tests__/delta-batcher.test.ts +255 -0
  227. package/src/components/chat/stream/__tests__/use-chat-stream-reducer.test.ts +98 -0
  228. package/src/components/chat/stream/chat-dialog-store.ts +536 -0
  229. package/src/components/chat/stream/chat-stream-reducer.ts +1796 -0
  230. package/src/components/chat/stream/delta-batcher.ts +159 -0
  231. package/src/components/chat/stream/index.ts +55 -0
  232. package/src/components/chat/stream/message-mutations.ts +338 -0
  233. package/src/components/chat/stream/use-chat-stream-reducer.ts +126 -0
  234. package/src/components/chat/thinking-display.tsx +1 -1
  235. package/src/components/chat/types/.api.types.md +39 -49
  236. package/src/components/chat/types/.processing.types.md +29 -58
  237. package/src/components/chat/types/api.types.ts +6 -73
  238. package/src/components/chat/types/processing.types.ts +11 -52
  239. package/src/components/chat/types/unified-chat-state.types.ts +6 -0
  240. package/src/components/chat/utils/.index.md +31 -40
  241. package/src/components/chat/utils/__tests__/__snapshots__/process-historical-messages-golden.test.ts.snap +420 -0
  242. package/src/components/chat/utils/__tests__/__snapshots__/segment-accumulator-golden.test.ts.snap +605 -0
  243. package/src/components/chat/utils/__tests__/process-historical-messages-golden.test.ts +317 -0
  244. package/src/components/chat/utils/__tests__/segment-accumulator-golden.test.ts +270 -0
  245. package/src/components/chat/utils/chat-conversation-storage.ts +83 -0
  246. package/src/components/chat/utils/extract-incomplete-message-state.ts +63 -5
  247. package/src/components/chat/utils/index.ts +5 -8
  248. package/src/components/chat/utils/process-historical-messages.ts +352 -384
  249. package/src/components/onboarding-guides/onboarding-guide-detail-view.tsx +22 -4
  250. package/src/components/shared/legal-document/legal-document-page.tsx +1 -1
  251. package/src/components/shared/product-release/release-detail-page.tsx +19 -4
  252. package/src/components/ui/__tests__/__snapshots__/markdown-parity.test.tsx.snap +2420 -0
  253. package/src/components/ui/__tests__/markdown-parity.test.tsx +2528 -0
  254. package/src/components/ui/index.ts +1 -2
  255. package/src/components/ui/markdown/__tests__/mermaid-security.test.ts +115 -0
  256. package/src/components/ui/markdown/__tests__/mermaid-stale-render.test.tsx +95 -0
  257. package/src/components/ui/markdown/__tests__/sanitize-invariant.test.ts +309 -0
  258. package/src/components/ui/markdown/__tests__/streaming.test.tsx +388 -0
  259. package/src/components/ui/markdown/base-components.tsx +360 -0
  260. package/src/components/ui/markdown/engine.tsx +315 -0
  261. package/src/components/ui/markdown/heading-ids.ts +239 -0
  262. package/src/components/ui/markdown/index.ts +49 -0
  263. package/src/components/ui/markdown/mermaid-diagram.tsx +291 -0
  264. package/src/components/ui/markdown/rich/embed-overrides.tsx +199 -0
  265. package/src/components/ui/markdown/rich/rich-markdown-renderer.tsx +184 -0
  266. package/src/components/ui/markdown/rich/shortcodes.ts +170 -0
  267. package/src/components/ui/markdown/sanitize.ts +3499 -0
  268. package/src/components/ui/markdown/simple-markdown-renderer.tsx +28 -0
  269. package/src/components/ui/markdown/streaming.ts +362 -0
  270. package/src/components/ui/markdown/text-size.ts +106 -0
  271. package/src/components/ui/release-changelog-section.tsx +1 -1
  272. package/src/components/ui/ticket-info-section.tsx +1 -1
  273. package/src/contexts/chat-runtime-context.tsx +8 -0
  274. package/src/utils/index.ts +1 -0
  275. package/src/utils/markdown-fences.ts +127 -0
  276. package/src/utils/markdown-heading-id.ts +348 -0
  277. package/src/utils/markdown-section-extractor.ts +41 -56
  278. package/dist/chunk-2B6773QM.cjs.map +0 -1
  279. package/dist/chunk-5RXKOGH6.cjs.map +0 -1
  280. package/dist/chunk-77DFPD35.js.map +0 -1
  281. package/dist/chunk-AHDJMEEN.js.map +0 -1
  282. package/dist/chunk-FZOBQG6M.cjs.map +0 -1
  283. package/dist/chunk-H7JX2BG7.cjs.map +0 -1
  284. package/dist/chunk-KFQXO7QB.js.map +0 -1
  285. package/dist/chunk-S52LJNMU.js.map +0 -1
  286. package/dist/components/chat/hooks/use-realtime-chunk-processor.d.ts +0 -6
  287. package/dist/components/chat/hooks/use-realtime-chunk-processor.d.ts.map +0 -1
  288. package/dist/components/chat/utils/chunk-parser.d.ts +0 -25
  289. package/dist/components/chat/utils/chunk-parser.d.ts.map +0 -1
  290. package/dist/components/ui/rich-markdown-renderer.d.ts +0 -34
  291. package/dist/components/ui/rich-markdown-renderer.d.ts.map +0 -1
  292. package/dist/components/ui/simple-markdown-renderer.d.ts +0 -73
  293. package/dist/components/ui/simple-markdown-renderer.d.ts.map +0 -1
  294. package/src/components/chat/hooks/.use-realtime-chunk-processor.md +0 -77
  295. package/src/components/chat/hooks/use-realtime-chunk-processor.ts +0 -479
  296. package/src/components/chat/utils/.chunk-parser.md +0 -62
  297. package/src/components/chat/utils/chunk-parser.ts +0 -256
  298. package/src/components/ui/.simple-markdown-renderer.md +0 -52
  299. package/src/components/ui/rich-markdown-renderer.tsx +0 -1223
  300. package/src/components/ui/simple-markdown-renderer.tsx +0 -964
  301. /package/dist/{chunk-AJNDDXKC.js.map → chunk-4NWARV5I.js.map} +0 -0
  302. /package/dist/{chunk-HQYJINFV.js.map → chunk-CORIBKHG.js.map} +0 -0
  303. /package/dist/{chunk-PNXNWGBY.js.map → chunk-GTWO5NAY.js.map} +0 -0
  304. /package/dist/{chunk-SCCTJ2UI.js.map → chunk-HSACATCD.js.map} +0 -0
  305. /package/dist/{chunk-KLYHZRAD.js.map → chunk-PB3BTZDU.js.map} +0 -0
  306. /package/dist/{chunk-YJSKQ2ZV.js.map → chunk-RQLBHKXN.js.map} +0 -0
  307. /package/dist/{chunk-CWQACI4Z.js.map → chunk-RW2LPL72.js.map} +0 -0
  308. /package/dist/{chunk-3BFX6HJM.js.map → chunk-UUYRJPGJ.js.map} +0 -0
  309. /package/dist/{chunk-F6BYUO5U.js.map → chunk-VHG6AX5V.js.map} +0 -0
  310. /package/dist/{chunk-PXF5J24T.js.map → chunk-XQ2B74SI.js.map} +0 -0
  311. /package/dist/{chunk-LLWBPPZ4.js.map → chunk-YZ2OIYQ3.js.map} +0 -0
@@ -6,59 +6,84 @@
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), the server-issued conversation-id
19
+ * persistence (id ONLY — see below), the mount-time server-history
20
+ * hydration seam, and the public `UnifiedChatState` mapping.
21
+ *
22
+ * Conversation identity + history (server SSOT):
23
+ *
24
+ * - Conversation ids are SERVER-minted. The first send of a session goes
25
+ * out id-less; the server mints an id and echoes it in the leading
26
+ * metadata frame (surfaced by the decoder as
27
+ * `ChatMetadataEvent.conversationId`), and every later turn echoes it
28
+ * back in the request body.
29
+ * - localStorage stores ONLY that echoed id (`chat-conversation-storage`).
30
+ * Message history lives server-side in `chat_conversations` /
31
+ * `chat_messages` and is rebuilt on mount via `useChatHistoryHydration`,
32
+ * which materializes through the reducer (`initializeWithState` /
33
+ * `prependMessages` + `seedSseMaps`).
34
+ * - The wire carries ONLY the newest user message — never a replay of the
35
+ * client-side thread (the server re-reads `chat_messages` by
36
+ * conversation id on every turn).
13
37
  *
14
38
  * Two key contracts vs hub-side chat hooks:
15
39
  *
16
40
  * 1. `source` is READ FROM THE RUNTIME, not a parameter. It is OPTIONAL —
17
41
  * platform-agnostic embedders leave it unset; an empty `source` falls back
18
- * to a stable constant (`DEFAULT_CHAT_SOURCE`) for the localStorage history
19
- * namespace. It is NEVER sent on the wire (the hub resolves source
20
- * server-side via `currentPlatform()`).
21
- *
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.
42
+ * to a stable constant (`DEFAULT_CHAT_SOURCE`) for the localStorage
43
+ * conversation-id namespace. It is NEVER sent on the wire (the hub
44
+ * resolves source server-side via `currentPlatform()`).
27
45
  *
28
- * 3. `tableIdForDocumentType` is INJECTED via the optional
46
+ * 2. `tableIdForDocumentType` is INJECTED via the optional
29
47
  * `tableIdForDocumentType` parameter. Hub callers pass the
30
48
  * registry-backed lookup from `lib/config/rag-table-config`;
31
49
  * 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.
50
+ * `defaultTableIdForDocumentType` so the `displayRef` / `discussRef`
51
+ * Ask + Display buttons WORK out of the box.
40
52
  */
41
53
 
42
- import { useState, useEffect, useCallback, useMemo, useRef, type MutableRefObject } from 'react'
43
- import { useChat, type Message, type StreamFnExtraOptions } from './use-chat'
54
+ import { useCallback, useMemo, useRef } from 'react'
44
55
  import { useRequiredChatRuntime } from '../../../contexts/chat-runtime-context'
45
56
  import type { ChatRef } from '../chat-ref.types'
46
- import { buildChatRefKey } from '../types/chat.types'
57
+ import type { Message } from '../types/message.types'
47
58
  import type { MessageSegment } from '../types/message.types'
48
59
  import { useSlashCommandRegistry, type SlashCommandSummary } from './use-slash-commands'
49
- import { getChatProxyAuth } from '../utils/chat-proxy-auth-storage'
50
60
  import { chatAuthedFetch } from '../utils/chat-authed-fetch'
51
- import { parseScrollAnchor, type ScrollAnchor } from '../utils/scroll-anchor'
52
- import { AUTO_CONTINUATION_DIRECTIVE_PREFIX } from '../utils/auto-continuation-directive'
53
- import { flattenAssistantContent } from '../utils/flatten-assistant-content'
61
+ import type { ScrollAnchor } from '../utils/scroll-anchor'
62
+ import { createSseFrameDecoder } from '../../../chat-protocol/decode'
63
+ import type { ChatStreamEvent } from '../../../chat-protocol/events'
64
+ import {
65
+ createChatConversationStorage,
66
+ pruneStaleChatConversationStorage,
67
+ } from '../utils/chat-conversation-storage'
68
+ import { useChatHistoryHydration } from './use-chat-history-hydration'
54
69
  import { sanitizeTitleForChat } from '../utils/slash-dispatch-utils'
55
70
  import { defaultTableIdForDocumentType } from '../utils/source-icons'
71
+ import type { WireCommandOverride } from '../utils/slash-dispatch-utils'
72
+ import type { ChatAttachment } from '../utils/chat-attachment-markdown'
56
73
  import type {
57
74
  UnifiedChatState,
75
+ UnifiedChatMessage,
58
76
  UnifiedSendMessageOptions,
59
- StreamingPhase,
60
77
  } from '../types/unified-chat-state.types'
61
78
  import type { DialogItem } from '../types/component.types'
79
+ import { createChatDialogStore, DEFAULT_DIALOG_SIDE } from '../stream/chat-dialog-store'
80
+ import { useChatStreamReducer } from '../stream/use-chat-stream-reducer'
81
+ import type { ChatDialogStore } from '../stream/chat-dialog-store'
82
+ import type { ChatStreamReducerOptions } from '../stream/chat-stream-reducer'
83
+
84
+ // Canonical home of the per-turn meta row moved to the stream module in
85
+ // Phase 3; re-exported here to keep the legacy import path stable.
86
+ export type { ChatTurnMeta } from '../stream/chat-stream-reducer'
62
87
 
63
88
  // =============================================================================
64
89
  // Public types
@@ -121,33 +146,6 @@ export interface DocChatMessage {
121
146
  // `types/unified-chat-state.types.ts`.
122
147
  export type { StreamingPhase } from '../types/unified-chat-state.types'
123
148
 
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
149
  /**
152
150
  * Optional dependency-injection options for `useSseChatAdapter`.
153
151
  *
@@ -158,13 +156,7 @@ export interface ChatTurnMeta {
158
156
  * **Defaults to `defaultTableIdForDocumentType` from
159
157
  * `src/utils/source-icons.ts`** — a lib-baked map covering every
160
158
  * 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.
159
+ * Override only for polymorphic / per-tenant document types.
168
160
  */
169
161
  export interface UseSseChatAdapterOptions {
170
162
  tableIdForDocumentType?: (documentType: string) => string | null
@@ -173,507 +165,55 @@ export interface UseSseChatAdapterOptions {
173
165
  export interface UseSseChatAdapterRuntimeOptions {
174
166
  /**
175
167
  * When `false` the adapter skips its background slash-command registry
176
- * fetch (used to hydrate the `displayRef` table lookup). Mirrors the
177
- * NATS adapter's `active` gate: `useUnifiedChat` passes `false` while
178
- * Guide mode is configured-but-not-active, so opening the panel in
179
- * Mingo mode does NOT hit the commands endpoint. Default `true` so
168
+ * fetch (used to hydrate the `displayRef` table lookup) AND the
169
+ * mount-time server-history hydration. Mirrors the NATS adapter's
170
+ * `active` gate: `useUnifiedChat` passes `false` while Guide mode is
171
+ * configured-but-not-active, so opening the panel in Mingo mode does
172
+ * NOT hit the commands or history endpoints. Default `true` so
180
173
  * standalone callers keep the eager prefetch.
181
174
  */
182
175
  active?: boolean
183
176
  }
184
177
 
185
178
  // =============================================================================
186
- // Internal helpers
179
+ // Local persistence — the server-issued conversation id ONLY
187
180
  // =============================================================================
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
591
- // =============================================================================
592
-
593
- const CHAT_STORAGE_VERSION = 1
594
-
595
- /** localStorage history namespace used when no `source` is configured on the
181
+ //
182
+ // The ONLY thing persisted locally is the conversation id the SERVER minted
183
+ // and echoed in the leading metadata frame of the session's first turn.
184
+ // The client never generates ids. Message history lives server-side in
185
+ // `chat_conversations` / `chat_messages` (recorded turn-by-turn by the chat
186
+ // route) and is rehydrated on mount via `GET <chatStreamUrl>/history`.
187
+ // localStorage never stores messages, sources, refs, or send counts —
188
+ // the server transcript is the single source of truth for history.
189
+ // (The retired PersistedChatState v1 full-history blobs are swept on mount
190
+ // by `pruneStaleChatConversationStorage` — never read, never migrated.)
191
+
192
+ /** localStorage namespace used when no `source` is configured on the
596
193
  * runtime. Embedders are platform-agnostic (see `ChatRuntime.source`), so any
597
194
  * stable string works here — the hub passes its real platform instead. */
598
195
  const DEFAULT_CHAT_SOURCE = 'embed'
599
196
 
600
- /** Storage key includes the proxy-auth impersonation email when
601
- * present so each impersonated customer keeps a SEPARATE chat history. */
602
- const chatStorageKey = (source: DocSource): string => {
603
- const base = `mingo-chat-${source}-v${CHAT_STORAGE_VERSION}`
604
- const auth = getChatProxyAuth()
605
- if (auth?.email) {
606
- return `${base}-u-${encodeURIComponent(auth.email.toLowerCase())}`
607
- }
608
- return base
609
- }
197
+ // Persistence itself lives in `../utils/chat-conversation-storage.ts` (built
198
+ // on the lib-standard `createLocalStorageAdapter`); the mount-time history
199
+ // rebuild lives in `./use-chat-history-hydration.ts`.
610
200
 
611
- /** Sweep stale per-user chat-history keys. Drops any key whose email
612
- * differs from the CURRENT proxy-auth identity. */
613
- function pruneStaleChatStorage(source: DocSource): void {
614
- if (typeof window === 'undefined') return
615
- try {
616
- const currentKey = chatStorageKey(source)
617
- const prefix = `mingo-chat-${source}-v${CHAT_STORAGE_VERSION}-u-`
618
- const legacy = `mingo-chat-${source}-v${CHAT_STORAGE_VERSION}`
619
- const toRemove: string[] = []
620
- for (let i = 0; i < window.localStorage.length; i++) {
621
- const k = window.localStorage.key(i)
622
- if (!k) continue
623
- if (!k.startsWith(prefix)) continue
624
- if (k === currentKey) continue
625
- if (k === legacy) continue
626
- toRemove.push(k)
627
- }
628
- for (const k of toRemove) {
629
- window.localStorage.removeItem(k)
630
- }
631
- } catch {
632
- // localStorage access blocked (Safari private mode etc.) — non-fatal.
633
- }
634
- }
635
-
636
- interface PersistedChatState {
637
- messages: Message[]
638
- sources: Array<[number, ChatSource[]]>
639
- /** Per-turn refs for inline object cards. */
640
- refs?: Array<[number, Record<string, ChatRef>]>
641
- sendCount: number
642
- }
643
-
644
- function loadPersistedChat(source: DocSource): PersistedChatState | null {
645
- if (typeof window === 'undefined') return null
646
- try {
647
- const raw = window.localStorage.getItem(chatStorageKey(source))
648
- if (!raw) return null
649
- const parsed = JSON.parse(raw) as PersistedChatState
650
- if (!parsed || !Array.isArray(parsed.messages)) return null
651
- // Rehydrate Date objects + run forward-compat migrations.
652
- for (const m of parsed.messages) {
653
- if (typeof m.timestamp === 'string') m.timestamp = new Date(m.timestamp)
654
- // Forward-migration for auto-continuation directive bubbles.
655
- if (
656
- m.role === 'user' &&
657
- !m.hidden &&
658
- typeof m.content === 'string' &&
659
- m.content.startsWith(AUTO_CONTINUATION_DIRECTIVE_PREFIX)
660
- ) {
661
- m.hidden = true
662
- }
663
- }
664
- return parsed
665
- } catch {
666
- return null
667
- }
668
- }
669
-
670
- function savePersistedChat(source: DocSource, state: PersistedChatState) {
671
- if (typeof window === 'undefined') return
672
- try {
673
- window.localStorage.setItem(chatStorageKey(source), JSON.stringify(state))
674
- } catch {
675
- // Quota exceeded or private mode — silently drop.
676
- }
201
+ /** Server-hydrated wire `Message` reducer message. Hydrated rows carry
202
+ * plain-string content; the defensive segments branch keeps the mapper
203
+ * total over the wider `Message` union the hydration seam is typed with. */
204
+ function historyMessageToReducerMessage(m: Message): UnifiedChatMessage {
205
+ const segments = Array.isArray(m.content) ? (m.content as MessageSegment[]) : undefined
206
+ return {
207
+ id: m.id,
208
+ role: m.role,
209
+ content: typeof m.content === 'string' ? m.content : '',
210
+ ...(segments ? { segments } : {}),
211
+ ...(m.name !== undefined ? { name: m.name } : {}),
212
+ ...(m.avatar != null ? { avatar: m.avatar } : {}),
213
+ ...(m.timestamp !== undefined ? { timestamp: m.timestamp } : {}),
214
+ ...(m.hidden ? { hidden: true } : {}),
215
+ ...(m.chatRefs ? { chatRefs: m.chatRefs } : {}),
216
+ } as UnifiedChatMessage
677
217
  }
678
218
 
679
219
  // =============================================================================
@@ -686,8 +226,8 @@ function savePersistedChat(source: DocSource, state: PersistedChatState) {
686
226
  * decision) the lib's `<ChatContainer>` consumes.
687
227
  *
688
228
  * Source identity comes from `useRequiredChatRuntime().source` — no
689
- * parameter. An empty `source` falls back to `DEFAULT_CHAT_SOURCE` (used only for
690
- * the localStorage history namespace; never sent on the wire).
229
+ * parameter. An empty `source` falls back to `DEFAULT_CHAT_SOURCE` (used only
230
+ * for the conversation-id localStorage namespace; never sent on the wire).
691
231
  */
692
232
  export function useSseChatAdapter(
693
233
  options?: UseSseChatAdapterOptions,
@@ -697,70 +237,70 @@ export function useSseChatAdapter(
697
237
  // Chat-specific code REQUIRES a runtime — the lib's `<HubRuntimeProvider>`
698
238
  // (hub) / embedder's provider must wrap the tree.
699
239
  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
240
  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
241
  const tableIdForDocumentType =
712
242
  options?.tableIdForDocumentType ?? defaultTableIdForDocumentType
713
243
 
714
- // Restore persisted state once on mount.
715
- const persistedRef = useRef<PersistedChatState | null>(null)
716
- if (persistedRef.current === null) {
717
- pruneStaleChatStorage(source)
718
- persistedRef.current =
719
- loadPersistedChat(source) || { messages: [], sources: [], sendCount: 0 }
720
- }
721
-
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 ?? []),
244
+ // ─── Reducer wiring ────────────────────────────────────────────────────────
245
+ // One store per hook instance; one reducer keyed by the source. Approval
246
+ // card buttons fire a server-driven confirm-tool turn via `sendMessage`
247
+ // (hidden approval-action send) — routed through a ref so the reducer's
248
+ // creation-time callbacks stay stable.
249
+
250
+ const sendMessageRef = useRef<
251
+ (text: string, options?: InternalSendMessageOptions) => Promise<void>
252
+ >(async () => {})
253
+
254
+ const cardApprove = useCallback((reqId?: string): void | Promise<void> => {
255
+ if (!reqId) return
256
+ return sendMessageRef.current('', {
257
+ hidden: true,
258
+ approvalAction: { proposalId: reqId, action: 'approve' },
259
+ })
260
+ }, [])
261
+ const cardReject = useCallback((reqId?: string): void | Promise<void> => {
262
+ if (!reqId) return
263
+ return sendMessageRef.current('', {
264
+ hidden: true,
265
+ approvalAction: { proposalId: reqId, action: 'reject' },
266
+ })
267
+ }, [])
268
+
269
+ const createReducerOptions = useCallback(
270
+ (): ChatStreamReducerOptions => ({
271
+ transport: 'sse',
272
+ callbacks: { onApprove: cardApprove, onReject: cardReject },
273
+ }),
274
+ [cardApprove, cardReject],
727
275
  )
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), [])
734
276
 
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
- )
277
+ const storeRef = useRef<ChatDialogStore | null>(null)
278
+ if (storeRef.current === null) storeRef.current = createChatDialogStore()
279
+
280
+ const { state, applyEvent, flushDeltas, mutate, reducer } = useChatStreamReducer({
281
+ store: storeRef.current,
282
+ dialogId: source,
283
+ createReducerOptions,
284
+ })
758
285
 
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
- //
286
+ const abortControllerRef = useRef<AbortController | null>(null)
287
+
288
+ // ─── Conversation identity — server-minted id, restored once on mount ─────
289
+ // Null = no conversation yet: the FIRST send goes out without an id, the
290
+ // server mints one, and the metadata-frame capture (in the send loop below)
291
+ // stores it. The client NEVER generates ids. This is the ONLY local
292
+ // persistence — message history hydrates from the server transcript below.
293
+ const conversationStorage = useMemo(() => createChatConversationStorage(source), [source])
294
+ const conversationIdRef = useRef<string | null>(null)
295
+ const restoredConversationRef = useRef(false)
296
+ if (!restoredConversationRef.current) {
297
+ restoredConversationRef.current = true
298
+ // Sweeps other-identity keys AND the retired v1 full-history blobs.
299
+ pruneStaleChatConversationStorage(source)
300
+ conversationIdRef.current = conversationStorage.load()?.conversationId ?? null
301
+ }
302
+
303
+ // ─── Slash-command registry (displayRef lookup) ───────────────────────────
764
304
  // Reads from the SAME react-query cache entry as `<EmbeddableChat>`'s
765
305
  // onboarding-card list (keyed on `commandsUrl`), so Guide mode fetches
766
306
  // `commands` ONCE. Gated on `active` so a Mingo-only panel — where this
@@ -792,138 +332,271 @@ export function useSseChatAdapter(
792
332
  return map
793
333
  }, [slashCommands])
794
334
 
795
- // Persist on every messages change. Sources + sendCount live in refs,
796
- // so we read their current values at write time.
797
- const persist = useCallback(
798
- (nextMessages: Message[]) => {
799
- savePersistedChat(source, {
800
- messages: nextMessages,
801
- sources: Array.from(sourcesMapRef.current.entries()),
802
- refs: Array.from(refsMapRef.current.entries()),
803
- sendCount: sendCountRef.current,
335
+ // ─── Server hydration (history SSOT) ──────────────────────────────────────
336
+ // Mount-time rebuild of the message list from the server transcript — the
337
+ // single store of conversation history. The fetch/guard/decode lives in
338
+ // `use-chat-history-hydration.ts` (see there for the full contract +
339
+ // failure semantics: a miss starts the UI empty, never loses server
340
+ // context). THIS adapter owns the materialization seam: the hook hands
341
+ // back plain `Message[]` rows plus per-send refs + the user-turn count
342
+ // (via the two shim refs below), and `hydrateMessages` feeds ALL of it
343
+ // through the REDUCER — `initializeWithState` on an empty thread,
344
+ // `prependMessages` when the user already sent before hydration landed
345
+ // (nothing typed is lost; the server resolves LLM history from its own
346
+ // store either way), and `seedSseMaps` for the sendIdx-keyed refs + the
347
+ // send counter. No parallel merge path.
348
+ const historyUrl =
349
+ runtime.endpoints.chatHistoryUrl ??
350
+ `${runtime.endpoints.chatStreamUrl.replace(/\/+$/, '')}/history`
351
+ const hydrationRefsMapRef = useRef<Map<number, Record<string, ChatRef>>>(new Map())
352
+ const hydrationSendCountRef = useRef(0)
353
+ const hydrateMessages = useCallback(
354
+ (history: Message[]) => {
355
+ mutate((r) => {
356
+ const mapped = history.map(historyMessageToReducerMessage)
357
+ const liveSendCount = r.state.turnMeta.sendCount
358
+ if (r.state.messages.length === 0) {
359
+ r.initializeWithState(mapped)
360
+ } else {
361
+ r.prependMessages(mapped)
362
+ }
363
+ r.seedSseMaps({
364
+ refs: Array.from(hydrationRefsMapRef.current.entries()),
365
+ sendCount: hydrationSendCountRef.current + liveSendCount,
366
+ })
804
367
  })
805
368
  },
806
- [source],
369
+ [mutate],
807
370
  )
808
-
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,
371
+ const { isHydratingHistory, hydratedKeyRef } = useChatHistoryHydration({
372
+ active,
373
+ source,
374
+ historyUrl,
375
+ conversationIdRef,
376
+ refsMapRef: hydrationRefsMapRef,
377
+ sendCountRef: hydrationSendCountRef,
378
+ hydrateMessages,
379
+ // Meta invalidation is a reducer concern here: `seedSseMaps` (inside
380
+ // `hydrateMessages` above) already invalidates the snapshot.
381
+ bumpMetaTick: noopBumpMetaTick,
823
382
  })
824
383
 
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
- })
384
+ // ─── Send / stream loop ────────────────────────────────────────────────────
875
385
 
876
386
  /**
877
387
  * Internal sendMessage options — union of the public
878
388
  * `UnifiedSendMessageOptions` (semantic fields: `hidden`, `attachments`)
879
389
  * 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.
390
+ * set by `discussRef` / `displayRef` / the approval-card callbacks.
885
391
  */
886
- type InternalSendMessageOptions = UnifiedSendMessageOptions &
887
- Pick<StreamFnExtraOptions, 'commandOverride' | 'approvalAction'>
392
+ type InternalSendMessageOptions = UnifiedSendMessageOptions & {
393
+ commandOverride?: WireCommandOverride
394
+ approvalAction?: { proposalId: string; action: 'approve' | 'reject' }
395
+ }
396
+
397
+ const endpointsRef = useRef(runtime.endpoints)
398
+ endpointsRef.current = runtime.endpoints
399
+
400
+ /** Capture the SERVER-minted conversation id from a decoded metadata
401
+ * event. On the session's FIRST turn this is where the client learns
402
+ * its id — persist it so later turns and future visits continue the
403
+ * same conversation. The reducer ignores the field (identity is a
404
+ * transport/persistence concern, not render state). */
405
+ const captureConversationId = useCallback(
406
+ (event: ChatStreamEvent): void => {
407
+ if (event.type !== 'metadata') return
408
+ const id = event.conversationId
409
+ if (typeof id !== 'string' || !id) return
410
+ if (conversationIdRef.current === id) return
411
+ conversationIdRef.current = id
412
+ conversationStorage.save({ conversationId: id })
413
+ },
414
+ [conversationStorage],
415
+ )
888
416
 
889
417
  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
- : {}),
418
+ async (text: string, sendOptions?: InternalSendMessageOptions): Promise<void> => {
419
+ const { hidden, attachments, commandOverride, approvalAction } = sendOptions ?? {}
420
+
421
+ // URL + body branch — approvalAction routes to the approval-tool
422
+ // endpoint, the standard chat path routes to the chat-stream endpoint.
423
+ // `source` is INTENTIONALLY NOT in the body: the chat route resolves
424
+ // it server-side via its own platform-detection — tamper-proof binding.
425
+ //
426
+ // The server is the single source of conversation history: it re-reads
427
+ // `chat_messages` by conversation id on every turn. The wire therefore
428
+ // carries ONLY the new user message — never the prior conversation.
429
+ const conversationId = conversationIdRef.current
430
+ const targetPath = approvalAction
431
+ ? endpointsRef.current.approvalToolUrl
432
+ : endpointsRef.current.chatStreamUrl
433
+ const requestBody = approvalAction
434
+ ? {
435
+ proposal_id: approvalAction.proposalId,
436
+ action: approvalAction.action,
437
+ // Always present here — an approval can only happen inside an
438
+ // established conversation (the proposal turn captured the id).
439
+ conversationId,
440
+ }
441
+ : {
442
+ messages: [{ role: 'user', content: text }],
443
+ ...(commandOverride ? { commandOverride } : {}),
444
+ ...(attachments && attachments.length > 0
445
+ ? { pendingAttachments: attachments as ChatAttachment[] }
446
+ : {}),
447
+ ...(conversationId ? { conversationId } : {}),
448
+ }
449
+
450
+ // Optimistic user bubble + assistant placeholder + phase 'thinking'
451
+ // + sendCount++ — one reducer command.
452
+ mutate((r) =>
453
+ r.beginSseSend({ text, hidden, userName: 'You', assistantName: 'Mingo AI' }),
454
+ )
455
+
456
+ const ctrl = new AbortController()
457
+ abortControllerRef.current = ctrl
458
+
459
+ try {
460
+ // `chatAuthedFetch` carries the bearer-act-as headers (+ Supabase
461
+ // session cookies) — same wrapper `use-chat-attachments` and
462
+ // `use-chat-identity` use.
463
+ const response = await chatAuthedFetch(targetPath, {
464
+ method: 'POST',
465
+ body: JSON.stringify(requestBody),
466
+ signal: ctrl.signal,
467
+ })
468
+ if (!response.ok) {
469
+ throw new Error(`Chat request failed: ${response.status}`)
470
+ }
471
+ const reader = response.body?.getReader()
472
+ if (!reader) throw new Error('No response body')
473
+
474
+ // Decoder is per-send (a rapid send-stop-send sequence must never
475
+ // feed the second stream's first chunk into the first stream's
476
+ // tail buffer).
477
+ const frameDecoder = createSseFrameDecoder()
478
+ let finished = false
479
+ try {
480
+ while (true) {
481
+ const { done, value } = await reader.read()
482
+ if (done) break
483
+ if (ctrl.signal.aborted) break
484
+ for (const event of frameDecoder.push(value)) {
485
+ captureConversationId(event)
486
+ applyEvent(event)
487
+ }
488
+ }
489
+ finished = true
490
+ } finally {
491
+ // `end()` always runs so the decoder settles; its events (the
492
+ // trailing usage frame) apply only on a CLEAN end — aborts
493
+ // propagate without extra state (legacy parity).
494
+ const endEvents = frameDecoder.end()
495
+ if (finished) {
496
+ for (const event of endEvents) applyEvent(event)
497
+ }
498
+ }
499
+ } catch (err) {
500
+ // AbortError on user-initiated stop is expected — keep the partial
501
+ // message, no error row.
502
+ if ((err as { name?: string })?.name !== 'AbortError' && !ctrl.signal.aborted) {
503
+ flushDeltas()
504
+ mutate((r) =>
505
+ r.failSseTurn(
506
+ err instanceof Error
507
+ ? err.message
508
+ : 'An error occurred while processing your request.',
509
+ ),
510
+ )
511
+ }
512
+ } finally {
513
+ if (abortControllerRef.current === ctrl) {
514
+ abortControllerRef.current = null
515
+ }
516
+ // Force-flush pending deltas BEFORE the completion state lands, then
517
+ // settle the turn (drops an empty trailing placeholder — the reject
518
+ // path streams no text — and returns the phase to idle).
519
+ flushDeltas()
520
+ mutate((r) => r.endSseTurn())
906
521
  }
907
- sendCountRef.current++
908
- setStreamingPhase('thinking')
909
- await chatSendMessage(text, sseExtras, hidden ? { hidden } : undefined)
910
522
  },
911
- [chatSendMessage],
523
+ [mutate, applyEvent, flushDeltas, captureConversationId],
912
524
  )
525
+ sendMessageRef.current = sendMessage
526
+
527
+ const stopMessage = useCallback(() => {
528
+ abortControllerRef.current?.abort()
529
+ flushDeltas()
530
+ mutate((r) => r.setPhase('idle'))
531
+ }, [mutate, flushDeltas])
532
+
533
+ const clearMessages = useCallback(() => {
534
+ mutate((r) => r.reset())
535
+ // New chat = drop the stored conversation id. The old row stays frozen
536
+ // server-side; the NEXT send goes out id-less and the server mints a
537
+ // fresh conversation (echoed back and re-captured then). Resetting the
538
+ // hydration guard means a re-captured id can hydrate again if needed.
539
+ conversationIdRef.current = null
540
+ conversationStorage.clear()
541
+ hydrationRefsMapRef.current = new Map()
542
+ hydrationSendCountRef.current = 0
543
+ hydratedKeyRef.current = null
544
+ }, [mutate, conversationStorage, hydratedKeyRef])
545
+
546
+ // ─── Public message mapping (sendIdx fan-out lookup) ──────────────────────
547
+ // Index sources/refs/scrollAnchor by USER-SEND count (`sendIdx`), not by
548
+ // assistant-message count. Each user send produces exactly ONE refs entry
549
+ // server-side, but it can produce MULTIPLE assistant messages on the
550
+ // client (main RAG reply + post-approve card + auto-continuation prose).
551
+ // Counting VISIBLE user sends and mapping every following assistant
552
+ // message to that index keeps the lookup stable — server hydration seeds
553
+ // the same maps (via `seedSseMaps`) so hydrated turns resolve identically.
554
+ const docMessages: DocChatMessage[] = useMemo(() => {
555
+ const { meta, sources: sourcesMap, refs: refsMap } = state.turnMeta
556
+ let sendIdx = -1
557
+ return state.messages.map((m) => {
558
+ const segments = m.segments
559
+ const content =
560
+ typeof m.content === 'string' && !segments
561
+ ? m.content
562
+ : segments
563
+ ?.filter((s) => s.type === 'text')
564
+ .map((s) => (s as { type: 'text'; text: string }).text)
565
+ .join('') ?? ''
566
+
567
+ let sources: ChatSource[] | undefined
568
+ let chatRefs: Record<string, ChatRef> | undefined
569
+ let scrollAnchor: ScrollAnchor | undefined
570
+ if (m.role === 'user' && !m.hidden) {
571
+ sendIdx++
572
+ }
573
+ if (m.role === 'assistant') {
574
+ const lookupIdx = sendIdx >= 0 ? sendIdx : 0
575
+ sources = sourcesMap.get(lookupIdx) as ChatSource[] | undefined
576
+ // The receipt path stamps `chatRefs` directly onto the assistant
577
+ // message; prefer that message-bound copy when present, fall back
578
+ // to the per-turn refs map.
579
+ chatRefs = m.chatRefs ?? refsMap.get(lookupIdx)
580
+ scrollAnchor = (meta.get(lookupIdx)?.scrollAnchor as ScrollAnchor | null) ?? undefined
581
+ }
582
+
583
+ return {
584
+ id: m.id,
585
+ role: m.role as 'user' | 'assistant',
586
+ content,
587
+ ...(segments ? { segments } : {}),
588
+ ...(sources ? { sources } : {}),
589
+ ...(chatRefs ? { chatRefs } : {}),
590
+ ...(scrollAnchor ? { scrollAnchor } : {}),
591
+ ...(m.hidden ? { hidden: true } : {}),
592
+ }
593
+ })
594
+ }, [state.messages, state.turnMeta])
913
595
 
914
596
  /**
915
597
  * "Display" callback for inline cards whose registry entry sets
916
598
  * `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.
599
+ * `/<cmd> display "<title>"` slash command instead of the Discuss prose.
927
600
  */
928
601
  const displayRef = useCallback(
929
602
  (reference: ChatRef) => {
@@ -965,10 +638,6 @@ export function useSseChatAdapter(
965
638
  * prompt ("Tell me more about <title>"); the structured
966
639
  * `commandOverride.entityIdFilter` is sent server-side via the request
967
640
  * 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
641
  */
973
642
  const discussRef = useCallback(
974
643
  (reference: ChatRef) => {
@@ -998,47 +667,17 @@ export function useSseChatAdapter(
998
667
  [sendMessage, tableIdForDocumentType],
999
668
  )
1000
669
 
1001
- const stopMessage = useCallback(() => {
1002
- chatStopMessage()
1003
- setStreamingPhase('idle')
1004
- }, [chatStopMessage])
1005
-
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])
670
+ // ─── Per-turn metadata resolution ─────────────────────────────────────────
671
+ const latestMeta = useMemo(() => {
672
+ const { meta, sendCount } = state.turnMeta
673
+ return meta.get(sendCount - 1) ?? meta.get(sendCount - 2) ?? null
674
+ }, [state])
1029
675
 
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
- )
676
+ const streamingPhase = state.streamingPhase
1038
677
 
1039
678
  return {
1040
679
  messages: docMessages,
1041
- isLoading: isTyping || isStreaming,
680
+ isLoading: streamingPhase !== 'idle',
1042
681
  sendMessage,
1043
682
  /** "Discuss" affordance for ObjectCard. */
1044
683
  discussRef,
@@ -1047,6 +686,8 @@ export function useSseChatAdapter(
1047
686
  stopMessage,
1048
687
  clearMessages,
1049
688
  streamingPhase,
689
+ /** True while the mount-time rebuild from the server transcript runs. */
690
+ isHydratingHistory,
1050
691
  /** Provider key for the lib's `<ModelDisplay>` icon. */
1051
692
  currentProvider: latestMeta?.provider ?? null,
1052
693
  currentModelLabel: latestMeta?.modelLabel ?? null,
@@ -1061,12 +702,11 @@ export function useSseChatAdapter(
1061
702
  * token counts). null until the trailing usage frame lands. */
1062
703
  currentUsageBreakdown: latestMeta?.breakdown ?? null,
1063
704
  // ─── Dialog management — stubs for v1 ────────────────────────────────
1064
- // Guide mode currently keeps its history in `localStorage` opaquely
1065
- // under the hood (`runtime.source` namespaced key). Surfacing that
1066
- // history as a structured dialog list is a follow-up; for now the
1067
- // shape is satisfied with empty defaults so the unified contract
1068
- // type-checks and EmbeddableChat hides sidebar affordances when
1069
- // `dialogs.length === 0`.
705
+ // Guide mode keeps ONE server-side conversation per stored id
706
+ // (`chat_conversations`, hydrated on mount). Surfacing multiple threads
707
+ // as a structured dialog list is a follow-up; for now the shape is
708
+ // satisfied with empty defaults so the unified contract type-checks and
709
+ // EmbeddableChat hides sidebar affordances when `dialogs.length === 0`.
1070
710
  dialogs: SSE_EMPTY_DIALOGS,
1071
711
  activeDialogId: null,
1072
712
  selectDialog: noopSelectDialog,
@@ -1100,13 +740,13 @@ const noopSelectDialog = (_id: string | null): void => {
1100
740
  }
1101
741
  const noopStartNewDialog = async (): Promise<string | null> => null
1102
742
  const noopDeleteDialog = async (_id: string): Promise<void> => {
1103
- /* no-op until Guide localStorage history is exposed */
743
+ /* no-op until Guide server-side history is exposed as dialogs */
1104
744
  }
1105
745
  const noopRenameDialog = async (_id: string, _title: string): Promise<void> => {
1106
- /* no-op until Guide localStorage history is exposed */
746
+ /* no-op until Guide server-side history is exposed as dialogs */
1107
747
  }
1108
748
  const noopArchiveDialog = async (_id: string): Promise<void> => {
1109
- /* no-op until Guide localStorage history is exposed */
749
+ /* no-op until Guide server-side history is exposed as dialogs */
1110
750
  }
1111
751
  const noopAsync = async (): Promise<void> => {
1112
752
  /* no-op pagination stub */
@@ -1117,3 +757,6 @@ const noopApproveRequest = async (_id: string): Promise<void> => {
1117
757
  const noopRejectRequest = async (_id: string, _reason?: string): Promise<void> => {
1118
758
  /* Guide mode has no tool-call approval workflow */
1119
759
  }
760
+ /** Stable no-op for the hydration hook's `bumpMetaTick` seam — reducer
761
+ * invalidation (inside `hydrateMessages`) already re-renders subscribers. */
762
+ const noopBumpMetaTick = (): void => {}