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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (306) hide show
  1. package/dist/chat-protocol/decode.d.ts +58 -0
  2. package/dist/chat-protocol/decode.d.ts.map +1 -0
  3. package/dist/chat-protocol/encode.d.ts +44 -0
  4. package/dist/chat-protocol/encode.d.ts.map +1 -0
  5. package/dist/chat-protocol/env-flag.d.ts +35 -0
  6. package/dist/chat-protocol/env-flag.d.ts.map +1 -0
  7. package/dist/chat-protocol/events.d.ts +216 -0
  8. package/dist/chat-protocol/events.d.ts.map +1 -0
  9. package/dist/chat-protocol/frames.d.ts +213 -0
  10. package/dist/chat-protocol/frames.d.ts.map +1 -0
  11. package/dist/chat-protocol/index.cjs +506 -0
  12. package/dist/chat-protocol/index.cjs.map +1 -0
  13. package/dist/chat-protocol/index.d.ts +18 -0
  14. package/dist/chat-protocol/index.d.ts.map +1 -0
  15. package/dist/chat-protocol/index.js +490 -0
  16. package/dist/chat-protocol/index.js.map +1 -0
  17. package/dist/chat-protocol/ip-normalize.d.ts +44 -0
  18. package/dist/chat-protocol/ip-normalize.d.ts.map +1 -0
  19. package/dist/chat-protocol/nats-decoder.d.ts +26 -0
  20. package/dist/chat-protocol/nats-decoder.d.ts.map +1 -0
  21. package/dist/{chunk-3SQ5KXHQ.cjs → chunk-2T4GTV25.cjs} +9 -9
  22. package/dist/{chunk-3SQ5KXHQ.cjs.map → chunk-2T4GTV25.cjs.map} +1 -1
  23. package/dist/{chunk-CWAOV2FK.cjs → chunk-3CGZPAGR.cjs} +3 -3
  24. package/dist/{chunk-CWAOV2FK.cjs.map → chunk-3CGZPAGR.cjs.map} +1 -1
  25. package/dist/{chunk-TLMJHMXJ.js → chunk-3QSR22IC.js} +12 -12
  26. package/dist/chunk-3QSR22IC.js.map +1 -0
  27. package/dist/{chunk-TWYNR4TZ.cjs → chunk-4G5TLHJD.cjs} +7141 -4799
  28. package/dist/chunk-4G5TLHJD.cjs.map +1 -0
  29. package/dist/{chunk-ADPMHWOE.js → chunk-72XAID7Y.js} +4 -4
  30. package/dist/{chunk-PAGKRNWK.js → chunk-7WZHBQ4J.js} +2 -2
  31. package/dist/{chunk-OCEKO5CW.js → chunk-AEELJC4N.js} +12106 -9764
  32. package/dist/chunk-AEELJC4N.js.map +1 -0
  33. package/dist/{chunk-Z42BGM6Q.cjs → chunk-BT2WWDFP.cjs} +5 -5
  34. package/dist/{chunk-Z42BGM6Q.cjs.map → chunk-BT2WWDFP.cjs.map} +1 -1
  35. package/dist/{chunk-7LEL3RIX.cjs → chunk-C7OY3PQP.cjs} +11 -11
  36. package/dist/{chunk-7LEL3RIX.cjs.map → chunk-C7OY3PQP.cjs.map} +1 -1
  37. package/dist/{chunk-RHGGEKPQ.cjs → chunk-CIOLOQKQ.cjs} +4 -4
  38. package/dist/{chunk-RHGGEKPQ.cjs.map → chunk-CIOLOQKQ.cjs.map} +1 -1
  39. package/dist/{chunk-TSZHM74B.js → chunk-DC2TKS7C.js} +2 -2
  40. package/dist/{chunk-M2GOR3XQ.cjs → chunk-DYPYR7FR.cjs} +87 -87
  41. package/dist/{chunk-M2GOR3XQ.cjs.map → chunk-DYPYR7FR.cjs.map} +1 -1
  42. package/dist/{chunk-I64ABCDX.js → chunk-I2B6X77L.js} +2 -2
  43. package/dist/{chunk-NIZDKTGL.cjs → chunk-K5MSCMDK.cjs} +37 -37
  44. package/dist/{chunk-NIZDKTGL.cjs.map → chunk-K5MSCMDK.cjs.map} +1 -1
  45. package/dist/{chunk-DTYRYB2N.js → chunk-K6QTZ7CZ.js} +2 -2
  46. package/dist/{chunk-4LTMXDUS.js → chunk-KBN7PMFT.js} +2 -2
  47. package/dist/{chunk-TBMV7I5N.js → chunk-KXZO2WZX.js} +2 -2
  48. package/dist/{chunk-QTYZMP6D.cjs → chunk-LBAKLT6T.cjs} +62 -62
  49. package/dist/chunk-LBAKLT6T.cjs.map +1 -0
  50. package/dist/{chunk-KEBYLU3U.js → chunk-LFUPI7VX.js} +2 -2
  51. package/dist/{chunk-LDTR4IVZ.cjs → chunk-M37PBG4N.cjs} +31 -31
  52. package/dist/{chunk-LDTR4IVZ.cjs.map → chunk-M37PBG4N.cjs.map} +1 -1
  53. package/dist/{chunk-WA7RR64F.js → chunk-PO5O654H.js} +6 -5
  54. package/dist/chunk-PO5O654H.js.map +1 -0
  55. package/dist/{chunk-WK4N5VBX.cjs → chunk-QJJISLPA.cjs} +28 -27
  56. package/dist/chunk-QJJISLPA.cjs.map +1 -0
  57. package/dist/{chunk-2F44PLTV.cjs → chunk-T7MCSA55.cjs} +26 -26
  58. package/dist/{chunk-2F44PLTV.cjs.map → chunk-T7MCSA55.cjs.map} +1 -1
  59. package/dist/{chunk-WJQHJD7J.js → chunk-TWSKTJHW.js} +2 -2
  60. package/dist/{chunk-ZSQHZYCO.cjs → chunk-UQDMJ6DH.cjs} +13 -13
  61. package/dist/{chunk-ZSQHZYCO.cjs.map → chunk-UQDMJ6DH.cjs.map} +1 -1
  62. package/dist/{chunk-TZRUCD56.js → chunk-V3ZAAFE3.js} +5 -5
  63. package/dist/{chunk-LRGHJPET.js → chunk-XGIFOHKE.js} +2 -2
  64. package/dist/{chunk-POOMO3PA.cjs → chunk-YQEPRYT5.cjs} +7 -7
  65. package/dist/{chunk-POOMO3PA.cjs.map → chunk-YQEPRYT5.cjs.map} +1 -1
  66. package/dist/components/case-studies/index.cjs +8 -8
  67. package/dist/components/case-studies/index.js +2 -2
  68. package/dist/components/chat/chat-message-enhanced.d.ts.map +1 -1
  69. package/dist/components/chat/chat-message-list.d.ts.map +1 -1
  70. package/dist/components/chat/hooks/index.d.ts +0 -1
  71. package/dist/components/chat/hooks/index.d.ts.map +1 -1
  72. package/dist/components/chat/hooks/use-chat.d.ts.map +1 -1
  73. package/dist/components/chat/hooks/use-chunk-catchup.d.ts.map +1 -1
  74. package/dist/components/chat/hooks/use-nats-chat-adapter.d.ts +15 -48
  75. package/dist/components/chat/hooks/use-nats-chat-adapter.d.ts.map +1 -1
  76. package/dist/components/chat/hooks/use-sse-chat-adapter.d.ts +10 -57
  77. package/dist/components/chat/hooks/use-sse-chat-adapter.d.ts.map +1 -1
  78. package/dist/components/chat/index.cjs +10 -2
  79. package/dist/components/chat/index.cjs.map +1 -1
  80. package/dist/components/chat/index.d.ts +1 -0
  81. package/dist/components/chat/index.d.ts.map +1 -1
  82. package/dist/components/chat/index.js +21 -13
  83. package/dist/components/chat/stream/chat-dialog-store.d.ts +184 -0
  84. package/dist/components/chat/stream/chat-dialog-store.d.ts.map +1 -0
  85. package/dist/components/chat/stream/chat-stream-reducer.d.ts +380 -0
  86. package/dist/components/chat/stream/chat-stream-reducer.d.ts.map +1 -0
  87. package/dist/components/chat/stream/delta-batcher.d.ts +51 -0
  88. package/dist/components/chat/stream/delta-batcher.d.ts.map +1 -0
  89. package/dist/components/chat/stream/index.d.ts +17 -0
  90. package/dist/components/chat/stream/index.d.ts.map +1 -0
  91. package/dist/components/chat/stream/message-mutations.d.ts +77 -0
  92. package/dist/components/chat/stream/message-mutations.d.ts.map +1 -0
  93. package/dist/components/chat/stream/use-chat-stream-reducer.d.ts +22 -0
  94. package/dist/components/chat/stream/use-chat-stream-reducer.d.ts.map +1 -0
  95. package/dist/components/chat/types/api.types.d.ts +1 -81
  96. package/dist/components/chat/types/api.types.d.ts.map +1 -1
  97. package/dist/components/chat/types/processing.types.d.ts +2 -93
  98. package/dist/components/chat/types/processing.types.d.ts.map +1 -1
  99. package/dist/components/chat/types/unified-chat-state.types.d.ts +15 -0
  100. package/dist/components/chat/types/unified-chat-state.types.d.ts.map +1 -1
  101. package/dist/components/chat/utils/extract-incomplete-message-state.d.ts +34 -6
  102. package/dist/components/chat/utils/extract-incomplete-message-state.d.ts.map +1 -1
  103. package/dist/components/chat/utils/history-merge.d.ts.map +1 -1
  104. package/dist/components/chat/utils/index.d.ts +2 -3
  105. package/dist/components/chat/utils/index.d.ts.map +1 -1
  106. package/dist/components/chat/utils/process-historical-messages.d.ts +59 -11
  107. package/dist/components/chat/utils/process-historical-messages.d.ts.map +1 -1
  108. package/dist/components/contact/index.cjs +3 -3
  109. package/dist/components/contact/index.js +2 -2
  110. package/dist/components/docs/index.cjs +5 -5
  111. package/dist/components/docs/index.js +4 -4
  112. package/dist/components/embeds/index.cjs +3 -3
  113. package/dist/components/embeds/index.js +2 -2
  114. package/dist/components/faq/index.cjs +3 -3
  115. package/dist/components/faq/index.js +2 -2
  116. package/dist/components/features/index.cjs +2 -2
  117. package/dist/components/features/index.js +1 -1
  118. package/dist/components/help-center-pages/index.cjs +22 -22
  119. package/dist/components/help-center-pages/index.js +13 -13
  120. package/dist/components/index.cjs +174 -132
  121. package/dist/components/index.cjs.map +1 -1
  122. package/dist/components/index.js +65 -23
  123. package/dist/components/index.js.map +1 -1
  124. package/dist/components/meeting-scheduler/index.cjs +34 -34
  125. package/dist/components/meeting-scheduler/index.js +3 -3
  126. package/dist/components/navigation/index.cjs +2 -2
  127. package/dist/components/navigation/index.js +1 -1
  128. package/dist/components/onboarding-guides/index.cjs +5 -5
  129. package/dist/components/onboarding-guides/index.js +4 -4
  130. package/dist/components/onboarding-guides/onboarding-guide-detail-view.d.ts.map +1 -1
  131. package/dist/components/related-content/index.cjs +3 -3
  132. package/dist/components/related-content/index.js +2 -2
  133. package/dist/components/shared/product-release/release-detail-page.d.ts.map +1 -1
  134. package/dist/components/tickets/index.cjs +6 -6
  135. package/dist/components/tickets/index.js +5 -5
  136. package/dist/components/ui/index.cjs +44 -2
  137. package/dist/components/ui/index.cjs.map +1 -1
  138. package/dist/components/ui/index.d.ts +1 -2
  139. package/dist/components/ui/index.d.ts.map +1 -1
  140. package/dist/components/ui/index.js +55 -13
  141. package/dist/components/ui/markdown/base-components.d.ts +51 -0
  142. package/dist/components/ui/markdown/base-components.d.ts.map +1 -0
  143. package/dist/components/ui/markdown/engine.d.ts +99 -0
  144. package/dist/components/ui/markdown/engine.d.ts.map +1 -0
  145. package/dist/components/ui/markdown/heading-ids.d.ts +65 -0
  146. package/dist/components/ui/markdown/heading-ids.d.ts.map +1 -0
  147. package/dist/components/ui/markdown/index.d.ts +18 -0
  148. package/dist/components/ui/markdown/index.d.ts.map +1 -0
  149. package/dist/components/ui/markdown/mermaid-diagram.d.ts +79 -0
  150. package/dist/components/ui/markdown/mermaid-diagram.d.ts.map +1 -0
  151. package/dist/components/ui/markdown/rich/embed-overrides.d.ts +14 -0
  152. package/dist/components/ui/markdown/rich/embed-overrides.d.ts.map +1 -0
  153. package/dist/components/ui/markdown/rich/rich-markdown-renderer.d.ts +40 -0
  154. package/dist/components/ui/markdown/rich/rich-markdown-renderer.d.ts.map +1 -0
  155. package/dist/components/ui/markdown/rich/shortcodes.d.ts +8 -0
  156. package/dist/components/ui/markdown/rich/shortcodes.d.ts.map +1 -0
  157. package/dist/components/ui/markdown/sanitize.d.ts +153 -0
  158. package/dist/components/ui/markdown/sanitize.d.ts.map +1 -0
  159. package/dist/components/ui/markdown/simple-markdown-renderer.d.ts +23 -0
  160. package/dist/components/ui/markdown/simple-markdown-renderer.d.ts.map +1 -0
  161. package/dist/components/ui/markdown/streaming.d.ts +78 -0
  162. package/dist/components/ui/markdown/streaming.d.ts.map +1 -0
  163. package/dist/components/ui/markdown/text-size.d.ts +34 -0
  164. package/dist/components/ui/markdown/text-size.d.ts.map +1 -0
  165. package/dist/index.cjs +60 -2
  166. package/dist/index.cjs.map +1 -1
  167. package/dist/index.js +71 -13
  168. package/dist/utils/index.cjs +169 -40
  169. package/dist/utils/index.cjs.map +1 -1
  170. package/dist/utils/index.d.ts +1 -0
  171. package/dist/utils/index.d.ts.map +1 -1
  172. package/dist/utils/index.js +162 -41
  173. package/dist/utils/index.js.map +1 -1
  174. package/dist/utils/markdown-fences.d.ts +42 -0
  175. package/dist/utils/markdown-fences.d.ts.map +1 -0
  176. package/dist/utils/markdown-heading-id.d.ts +85 -0
  177. package/dist/utils/markdown-heading-id.d.ts.map +1 -0
  178. package/dist/utils/markdown-section-extractor.d.ts.map +1 -1
  179. package/package.json +7 -1
  180. package/src/chat-protocol/__tests__/__snapshots__/nats-decoder-golden.test.ts.snap +319 -0
  181. package/src/chat-protocol/__tests__/chat-protocol.test.ts +516 -0
  182. package/src/chat-protocol/__tests__/env-flag.test.ts +52 -0
  183. package/src/chat-protocol/__tests__/ip-normalize.test.ts +136 -0
  184. package/src/chat-protocol/__tests__/nats-decoder-golden.test.ts +237 -0
  185. package/src/chat-protocol/decode.ts +278 -0
  186. package/src/chat-protocol/encode.ts +71 -0
  187. package/src/chat-protocol/env-flag.ts +39 -0
  188. package/src/chat-protocol/events.ts +254 -0
  189. package/src/chat-protocol/frames.ts +245 -0
  190. package/src/chat-protocol/index.ts +21 -0
  191. package/src/chat-protocol/ip-normalize.ts +146 -0
  192. package/src/chat-protocol/nats-decoder.ts +264 -0
  193. package/src/components/chat/.chat-message-list.md +10 -6
  194. package/src/components/chat/__tests__/chat-message-list.test.tsx +450 -9
  195. package/src/components/chat/__tests__/chat-message-streaming-memo.test.tsx +207 -0
  196. package/src/components/chat/__tests__/chat-pending-turn.test.tsx +250 -0
  197. package/src/components/chat/chat-message-enhanced.tsx +122 -20
  198. package/src/components/chat/chat-message-list.tsx +515 -133
  199. package/src/components/chat/embeddable-chat.tsx +6 -0
  200. package/src/components/chat/hooks/.index.md +30 -33
  201. package/src/components/chat/hooks/.use-nats-chat-adapter.md +36 -55
  202. package/src/components/chat/hooks/__tests__/__snapshots__/conversation-id-persistence-golden.test.ts.snap +37 -0
  203. package/src/components/chat/hooks/__tests__/__snapshots__/sse-stream-golden.test.ts.snap +0 -0
  204. package/src/components/chat/hooks/__tests__/chunk-catchup-dialog-staleness.test.ts +92 -0
  205. package/src/components/chat/hooks/__tests__/conversation-id-persistence-golden.test.ts +438 -0
  206. package/src/components/chat/hooks/__tests__/sse-stream-golden.test.ts +318 -0
  207. package/src/components/chat/hooks/index.ts +0 -1
  208. package/src/components/chat/hooks/use-chat.ts +8 -0
  209. package/src/components/chat/hooks/use-chunk-catchup.ts +26 -1
  210. package/src/components/chat/hooks/use-nats-chat-adapter.ts +254 -825
  211. package/src/components/chat/hooks/use-sse-chat-adapter.ts +380 -684
  212. package/src/components/chat/index.ts +5 -0
  213. package/src/components/chat/stream/__tests__/__snapshots__/chat-stream-reducer-golden.test.ts.snap +944 -0
  214. package/src/components/chat/stream/__tests__/chat-dialog-store.test.ts +806 -0
  215. package/src/components/chat/stream/__tests__/chat-stream-reducer-golden.test.ts +467 -0
  216. package/src/components/chat/stream/__tests__/chat-stream-reducer.test.ts +1330 -0
  217. package/src/components/chat/stream/__tests__/delta-batcher.test.ts +255 -0
  218. package/src/components/chat/stream/__tests__/use-chat-stream-reducer.test.ts +105 -0
  219. package/src/components/chat/stream/chat-dialog-store.ts +536 -0
  220. package/src/components/chat/stream/chat-stream-reducer.ts +1928 -0
  221. package/src/components/chat/stream/delta-batcher.ts +168 -0
  222. package/src/components/chat/stream/index.ts +55 -0
  223. package/src/components/chat/stream/message-mutations.ts +340 -0
  224. package/src/components/chat/stream/use-chat-stream-reducer.ts +126 -0
  225. package/src/components/chat/thinking-display.tsx +1 -1
  226. package/src/components/chat/types/.api.types.md +39 -49
  227. package/src/components/chat/types/.processing.types.md +29 -58
  228. package/src/components/chat/types/.unified-chat-state.types.md +2 -2
  229. package/src/components/chat/types/api.types.ts +6 -73
  230. package/src/components/chat/types/processing.types.ts +11 -53
  231. package/src/components/chat/types/unified-chat-state.types.ts +16 -0
  232. package/src/components/chat/utils/.extract-incomplete-message-state.md +21 -3
  233. package/src/components/chat/utils/.history-merge.md +44 -3
  234. package/src/components/chat/utils/.index.md +31 -40
  235. package/src/components/chat/utils/__tests__/__snapshots__/process-historical-messages-golden.test.ts.snap +420 -0
  236. package/src/components/chat/utils/__tests__/__snapshots__/segment-accumulator-golden.test.ts.snap +605 -0
  237. package/src/components/chat/utils/__tests__/extract-incomplete-message-state.test.ts +106 -0
  238. package/src/components/chat/utils/__tests__/history-merge.test.ts +160 -0
  239. package/src/components/chat/utils/__tests__/process-historical-messages-approvals.test.ts +61 -0
  240. package/src/components/chat/utils/__tests__/process-historical-messages-golden.test.ts +394 -0
  241. package/src/components/chat/utils/__tests__/segment-accumulator-golden.test.ts +270 -0
  242. package/src/components/chat/utils/extract-incomplete-message-state.ts +93 -6
  243. package/src/components/chat/utils/history-merge.ts +85 -5
  244. package/src/components/chat/utils/index.ts +5 -8
  245. package/src/components/chat/utils/process-historical-messages.ts +361 -385
  246. package/src/components/onboarding-guides/onboarding-guide-detail-view.tsx +22 -4
  247. package/src/components/shared/legal-document/legal-document-page.tsx +1 -1
  248. package/src/components/shared/product-release/release-detail-page.tsx +19 -4
  249. package/src/components/ui/__tests__/__snapshots__/markdown-parity.test.tsx.snap +2420 -0
  250. package/src/components/ui/__tests__/markdown-parity.test.tsx +2540 -0
  251. package/src/components/ui/index.ts +1 -2
  252. package/src/components/ui/markdown/__tests__/mermaid-security.test.ts +176 -0
  253. package/src/components/ui/markdown/__tests__/mermaid-stale-render.test.tsx +151 -0
  254. package/src/components/ui/markdown/__tests__/sanitize-invariant.test.ts +354 -0
  255. package/src/components/ui/markdown/__tests__/sanitize-render.test.tsx +118 -0
  256. package/src/components/ui/markdown/__tests__/streaming.test.tsx +436 -0
  257. package/src/components/ui/markdown/base-components.tsx +360 -0
  258. package/src/components/ui/markdown/engine.tsx +315 -0
  259. package/src/components/ui/markdown/heading-ids.ts +239 -0
  260. package/src/components/ui/markdown/index.ts +49 -0
  261. package/src/components/ui/markdown/mermaid-diagram.tsx +323 -0
  262. package/src/components/ui/markdown/rich/embed-overrides.tsx +199 -0
  263. package/src/components/ui/markdown/rich/rich-markdown-renderer.tsx +184 -0
  264. package/src/components/ui/markdown/rich/shortcodes.ts +170 -0
  265. package/src/components/ui/markdown/sanitize.ts +3584 -0
  266. package/src/components/ui/markdown/simple-markdown-renderer.tsx +28 -0
  267. package/src/components/ui/markdown/streaming.ts +390 -0
  268. package/src/components/ui/markdown/text-size.ts +106 -0
  269. package/src/components/ui/release-changelog-section.tsx +1 -1
  270. package/src/components/ui/ticket-info-section.tsx +1 -1
  271. package/src/utils/index.ts +1 -0
  272. package/src/utils/markdown-fences.ts +127 -0
  273. package/src/utils/markdown-heading-id.ts +348 -0
  274. package/src/utils/markdown-section-extractor.ts +41 -56
  275. package/dist/chunk-OCEKO5CW.js.map +0 -1
  276. package/dist/chunk-QTYZMP6D.cjs.map +0 -1
  277. package/dist/chunk-TLMJHMXJ.js.map +0 -1
  278. package/dist/chunk-TWYNR4TZ.cjs.map +0 -1
  279. package/dist/chunk-WA7RR64F.js.map +0 -1
  280. package/dist/chunk-WK4N5VBX.cjs.map +0 -1
  281. package/dist/components/chat/hooks/use-realtime-chunk-processor.d.ts +0 -6
  282. package/dist/components/chat/hooks/use-realtime-chunk-processor.d.ts.map +0 -1
  283. package/dist/components/chat/utils/chunk-parser.d.ts +0 -25
  284. package/dist/components/chat/utils/chunk-parser.d.ts.map +0 -1
  285. package/dist/components/ui/rich-markdown-renderer.d.ts +0 -34
  286. package/dist/components/ui/rich-markdown-renderer.d.ts.map +0 -1
  287. package/dist/components/ui/simple-markdown-renderer.d.ts +0 -73
  288. package/dist/components/ui/simple-markdown-renderer.d.ts.map +0 -1
  289. package/src/components/chat/hooks/.use-realtime-chunk-processor.md +0 -77
  290. package/src/components/chat/hooks/use-realtime-chunk-processor.ts +0 -489
  291. package/src/components/chat/utils/.chunk-parser.md +0 -62
  292. package/src/components/chat/utils/chunk-parser.ts +0 -262
  293. package/src/components/ui/.simple-markdown-renderer.md +0 -52
  294. package/src/components/ui/rich-markdown-renderer.tsx +0 -1223
  295. package/src/components/ui/simple-markdown-renderer.tsx +0 -964
  296. /package/dist/{chunk-ADPMHWOE.js.map → chunk-72XAID7Y.js.map} +0 -0
  297. /package/dist/{chunk-PAGKRNWK.js.map → chunk-7WZHBQ4J.js.map} +0 -0
  298. /package/dist/{chunk-TSZHM74B.js.map → chunk-DC2TKS7C.js.map} +0 -0
  299. /package/dist/{chunk-I64ABCDX.js.map → chunk-I2B6X77L.js.map} +0 -0
  300. /package/dist/{chunk-DTYRYB2N.js.map → chunk-K6QTZ7CZ.js.map} +0 -0
  301. /package/dist/{chunk-4LTMXDUS.js.map → chunk-KBN7PMFT.js.map} +0 -0
  302. /package/dist/{chunk-TBMV7I5N.js.map → chunk-KXZO2WZX.js.map} +0 -0
  303. /package/dist/{chunk-KEBYLU3U.js.map → chunk-LFUPI7VX.js.map} +0 -0
  304. /package/dist/{chunk-WJQHJD7J.js.map → chunk-TWSKTJHW.js.map} +0 -0
  305. /package/dist/{chunk-TZRUCD56.js.map → chunk-V3ZAAFE3.js.map} +0 -0
  306. /package/dist/{chunk-LRGHJPET.js.map → chunk-XGIFOHKE.js.map} +0 -0
@@ -6,63 +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 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
60
  import { chatAuthedFetch } from '../utils/chat-authed-fetch'
61
+ import type { ScrollAnchor } from '../utils/scroll-anchor'
62
+ import { createSseFrameDecoder } from '../../../chat-protocol/decode'
63
+ import type { ChatStreamEvent } from '../../../chat-protocol/events'
50
64
  import {
51
65
  createChatConversationStorage,
52
66
  pruneStaleChatConversationStorage,
53
- type PersistedChatConversation,
54
67
  } from '../utils/chat-conversation-storage'
55
- import type { LocalStorageAdapter } from '../../../utils/local-storage-adapter'
56
68
  import { useChatHistoryHydration } from './use-chat-history-hydration'
57
- import { parseScrollAnchor, type ScrollAnchor } from '../utils/scroll-anchor'
58
69
  import { sanitizeTitleForChat } from '../utils/slash-dispatch-utils'
59
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'
60
73
  import type {
61
74
  UnifiedChatState,
75
+ UnifiedChatMessage,
62
76
  UnifiedSendMessageOptions,
63
- StreamingPhase,
64
77
  } from '../types/unified-chat-state.types'
65
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'
66
87
 
67
88
  // =============================================================================
68
89
  // Public types
@@ -125,37 +146,6 @@ export interface DocChatMessage {
125
146
  // `types/unified-chat-state.types.ts`.
126
147
  export type { StreamingPhase } from '../types/unified-chat-state.types'
127
148
 
128
- /** Per-turn metadata extracted from the streamed metadata frame. */
129
- export interface ChatTurnMeta {
130
- /** Provider key recognized by `<ModelDisplay>` for icon
131
- * selection: 'anthropic' | 'openai' | 'google' (case-insensitive). */
132
- provider: string | null
133
- modelLabel: string | null
134
- contextWindowMaxTokens: number | null
135
- /** Input tokens (from message_start). Includes cached tokens. */
136
- inputTokens: number | null
137
- /** Output tokens (from message_delta). Only known after stream end. */
138
- outputTokens: number | null
139
- /** Cache hit % (read / total-input × 100). Only known after stream end. */
140
- cacheHitRatePct: number | null
141
- /** Cross-call usage breakdown extracted from the trailing usage frame. */
142
- breakdown: {
143
- haikuRewriter?: { input: number; output: number }
144
- haikuClassifier?: { input: number; output: number }
145
- haikuSummarizer?: { input: number; output: number }
146
- routedAnswer?: { model: string; complexity: string; thinkingBudget: number }
147
- } | null
148
- /** Per-message viewport-positioning hint. */
149
- scrollAnchor: ScrollAnchor | null
150
- /** Routing decision from the server's `decideRoute`. */
151
- routedComplexity: string | null
152
- routedThinkingBudget: number | null
153
- /** Server-computed conversation trace id (`chat_conversations.id` for this
154
- * thread), echoed in the leading metadata frame. Stable across turns of
155
- * one thread — the correlation handle for transcripts/analytics. */
156
- conversationId: string | null
157
- }
158
-
159
149
  /**
160
150
  * Optional dependency-injection options for `useSseChatAdapter`.
161
151
  *
@@ -166,13 +156,7 @@ export interface ChatTurnMeta {
166
156
  * **Defaults to `defaultTableIdForDocumentType` from
167
157
  * `src/utils/source-icons.ts`** — a lib-baked map covering every
168
158
  * documentType currently registered in the hub's RAG_TABLE_CONFIGS.
169
- * This makes Ask / Display work out of the box in embedders without
170
- * any callback wiring.
171
- *
172
- * Override only when you have polymorphic / per-tenant document
173
- * types that map to a different tableId than the default
174
- * (hub-canonical) registry — pass your own `(docType) => tableId`
175
- * callback and it wins over the default.
159
+ * Override only for polymorphic / per-tenant document types.
176
160
  */
177
161
  export interface UseSseChatAdapterOptions {
178
162
  tableIdForDocumentType?: (documentType: string) => string | null
@@ -181,421 +165,16 @@ export interface UseSseChatAdapterOptions {
181
165
  export interface UseSseChatAdapterRuntimeOptions {
182
166
  /**
183
167
  * When `false` the adapter skips its background slash-command registry
184
- * fetch (used to hydrate the `displayRef` table lookup). Mirrors the
185
- * NATS adapter's `active` gate: `useUnifiedChat` passes `false` while
186
- * Guide mode is configured-but-not-active, so opening the panel in
187
- * 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
188
173
  * standalone callers keep the eager prefetch.
189
174
  */
190
175
  active?: boolean
191
176
  }
192
177
 
193
- // =============================================================================
194
- // Internal helpers
195
- // =============================================================================
196
-
197
- /** Single source of truth for a fresh `ChatTurnMeta` row. */
198
- function createEmptyTurnMeta(): ChatTurnMeta {
199
- return {
200
- provider: null,
201
- modelLabel: null,
202
- contextWindowMaxTokens: null,
203
- inputTokens: null,
204
- outputTokens: null,
205
- cacheHitRatePct: null,
206
- breakdown: null,
207
- scrollAnchor: null,
208
- routedComplexity: null,
209
- routedThinkingBudget: null,
210
- conversationId: null,
211
- }
212
- }
213
-
214
- /**
215
- * Escape `<` so the lib's `SimpleMarkdownRenderer` (which uses `rehypeRaw`
216
- * to pass HTML through) doesn't treat XML-like tokens in Claude's
217
- * thinking output as React components. Escaping `<` → `&lt;` preserves
218
- * the visible character without breaking blockquote `>` markers.
219
- */
220
- function escapeThinkingTags(text: string): string {
221
- return text.replace(/</g, '&lt;')
222
- }
223
-
224
- function createDocStreamFn(
225
- source: DocSource,
226
- endpoints: { chatStreamUrl: string; approvalToolUrl: string },
227
- sourcesMapRef: MutableRefObject<Map<number, ChatSource[]>>,
228
- refsMapRef: MutableRefObject<Map<number, Record<string, ChatRef>>>,
229
- metaMapRef: MutableRefObject<Map<number, ChatTurnMeta>>,
230
- setStreamingPhase: (phase: StreamingPhase) => void,
231
- bumpMetaTick: () => void,
232
- sendCountRef: MutableRefObject<number>,
233
- conversationIdRef: MutableRefObject<string | null>,
234
- conversationStorage: LocalStorageAdapter<PersistedChatConversation>,
235
- ) {
236
- // CRITICAL: the decoder + buffer MUST live INSIDE the returned async-
237
- // generator function (per-call closure), NOT at the factory level. A
238
- // rapid send-stop-send sequence with hook-level state would feed the
239
- // second stream's first chunk into the first stream's tail buffer,
240
- // corrupting metadata-frame parsing.
241
- return async function* (
242
- message: string,
243
- signal?: AbortSignal,
244
- extra?: StreamFnExtraOptions,
245
- ): AsyncGenerator<MessageSegment> {
246
- // URL + body branch — approvalAction routes to the approval-tool
247
- // endpoint, the standard chat path routes to the chat-stream endpoint.
248
- const targetPath = extra?.approvalAction
249
- ? endpoints.approvalToolUrl
250
- : endpoints.chatStreamUrl
251
- // `source` is INTENTIONALLY NOT in the body. The chat route resolves
252
- // it server-side via its own platform-detection — tamper-proof binding
253
- // so a client on one platform can't POST a different platform's
254
- // conversation.
255
- //
256
- // The server is the single source of conversation history: it re-reads
257
- // `chat_messages` by conversation id on every turn. The wire therefore
258
- // carries ONLY the new user message — never the prior conversation.
259
- // The conversation id is SERVER-minted: the first message of a session
260
- // sends none, the server mints one and echoes it in the leading metadata
261
- // frame (captured below into `conversationIdRef`), and every later turn
262
- // echoes it back.
263
- const conversationId = conversationIdRef.current
264
- const requestBody = extra?.approvalAction
265
- ? {
266
- proposal_id: extra.approvalAction.proposalId,
267
- action: extra.approvalAction.action,
268
- // Always present here — an approval can only happen inside an
269
- // established conversation (the proposal turn captured the id).
270
- conversationId,
271
- }
272
- : {
273
- messages: [{ role: 'user', content: message }],
274
- ...(extra?.commandOverride ? { commandOverride: extra.commandOverride } : {}),
275
- ...(extra?.pendingAttachments && extra.pendingAttachments.length > 0
276
- ? { pendingAttachments: extra.pendingAttachments }
277
- : {}),
278
- ...(conversationId ? { conversationId } : {}),
279
- }
280
- // `chatAuthedFetch` carries the bearer-act-as headers (+ Supabase
281
- // session cookies) — same wrapper `use-chat-attachments` and
282
- // `use-chat-identity` use, so all three chat-side fetch sites share
283
- // one identity-propagation primitive.
284
- const response = await chatAuthedFetch(targetPath, {
285
- method: 'POST',
286
- body: JSON.stringify(requestBody),
287
- signal,
288
- })
289
-
290
- if (!response.ok) {
291
- throw new Error(`Chat request failed: ${response.status}`)
292
- }
293
-
294
- const reader = response.body?.getReader()
295
- if (!reader) throw new Error('No response body')
296
-
297
- const decoder = new TextDecoder()
298
- let buffer = ''
299
- let inText = false
300
- // Live thinking accumulator — coalesced to ~20fps to avoid a re-render
301
- // storm (Anthropic emits 30-60 deltas/sec).
302
- let thinkingAccum = ''
303
- let lastThinkingYieldTime = 0
304
- let pendingThinkingYield = false
305
- const THINKING_YIELD_INTERVAL_MS = 50
306
- let trailerBuffer = ''
307
- let inTrailer = false
308
-
309
- const sendIdx = sendCountRef.current - 1
310
- while (true) {
311
- const { done, value } = await reader.read()
312
- if (done) break
313
-
314
- const chunk = decoder.decode(value, { stream: true })
315
-
316
- if (!inText) {
317
- buffer += chunk
318
- while (!inText) {
319
- const recIdx = buffer.indexOf('\x1E')
320
- const nullIdx = buffer.indexOf('\0')
321
- if (recIdx !== -1 && (nullIdx === -1 || recIdx < nullIdx)) {
322
- if (pendingThinkingYield) {
323
- pendingThinkingYield = false
324
- yield { type: 'thinking', text: escapeThinkingTags(thinkingAccum) }
325
- }
326
- inText = true
327
- setStreamingPhase('streaming')
328
- const after = buffer.slice(recIdx + 1)
329
- buffer = ''
330
- if (after) {
331
- // The `after` slice may ALSO contain the `\x1F` trailing-
332
- // usage sentinel — common for fixed-answer responses where
333
- // the whole frame sequence arrives in ONE TCP chunk.
334
- const unitIdx = after.indexOf('\x1F')
335
- if (unitIdx === -1) {
336
- yield { type: 'text', text: after }
337
- } else {
338
- const textBefore = after.slice(0, unitIdx)
339
- const trailerAfter = after.slice(unitIdx + 1)
340
- if (textBefore) {
341
- yield { type: 'text', text: textBefore }
342
- }
343
- inTrailer = true
344
- trailerBuffer = trailerAfter
345
- }
346
- }
347
- break
348
- }
349
- if (nullIdx === -1) break // need more bytes
350
- const metaStr = buffer.slice(0, nullIdx)
351
- const remaining = buffer.slice(nullIdx + 1)
352
- let meta: any
353
- try {
354
- meta = JSON.parse(metaStr)
355
- } catch {
356
- // Not JSON — start of answer body.
357
- inText = true
358
- setStreamingPhase('streaming')
359
- if (buffer.length > 0) {
360
- yield { type: 'text', text: buffer }
361
- buffer = ''
362
- }
363
- break
364
- }
365
- if (meta.status === 'thinking') {
366
- setStreamingPhase('thinking')
367
- } else if (meta.kind === 'thinking-delta' && typeof meta.text === 'string') {
368
- thinkingAccum += meta.text
369
- const now = Date.now()
370
- if (now - lastThinkingYieldTime >= THINKING_YIELD_INTERVAL_MS) {
371
- lastThinkingYieldTime = now
372
- pendingThinkingYield = false
373
- yield { type: 'thinking', text: escapeThinkingTags(thinkingAccum) }
374
- } else {
375
- pendingThinkingYield = true
376
- }
377
- } else if (meta.kind === 'usage' && meta.stage === 'start') {
378
- mergeTurnMeta(metaMapRef, sendIdx, {
379
- inputTokens: meta.input_tokens ?? null,
380
- })
381
- bumpMetaTick()
382
- } else if (
383
- meta.kind === 'decision_resolved' &&
384
- typeof meta.action === 'string'
385
- ) {
386
- // Server-driven post-approve / post-reject frame.
387
- const action = meta.action === 'rejected' ? 'rejected' : 'approved'
388
- const toolName =
389
- typeof meta.tool_name === 'string' ? meta.tool_name : undefined
390
- const result = (meta.result ?? null) as
391
- | { ticket_id?: string; status?: string | null; mirror_synced?: boolean }
392
- | null
393
- const card = (meta.card ?? null) as
394
- | { type?: string; marker?: string; ref?: ChatRef }
395
- | null
396
- if (card?.ref?.id && card?.type) {
397
- const existing = refsMapRef.current.get(sendIdx) ?? {}
398
- const key = buildChatRefKey(card.type, card.ref.id)
399
- refsMapRef.current.set(sendIdx, { ...existing, [key]: card.ref })
400
- bumpMetaTick()
401
- }
402
- yield {
403
- type: 'decision_resolved',
404
- action,
405
- ok: meta.ok === true,
406
- willAutoContinue: meta.willAutoContinue === true,
407
- ...(toolName ? { toolName } : {}),
408
- ...(result ? { result } : {}),
409
- ...(card?.marker ? { marker: card.marker } : {}),
410
- ...(card?.ref ? { cardRef: card.ref } : {}),
411
- ...(typeof meta.receiptText === 'string'
412
- ? { receiptText: meta.receiptText }
413
- : {}),
414
- proposalId:
415
- typeof meta.proposalId === 'string' ? meta.proposalId : undefined,
416
- } as any
417
- } else if (meta.kind === 'approval_request' && meta.proposalId) {
418
- // The model called a write tool. Server persisted the
419
- // proposal and sent us a CARD-READY payload.
420
- const proposalId = String(meta.proposalId)
421
- const toolName = String(meta.toolName ?? 'tool')
422
- const headline =
423
- typeof meta.title === 'string' && meta.title.length > 0
424
- ? meta.title
425
- : toolName
426
- const rawFields = Array.isArray(meta.fields)
427
- ? (meta.fields as Array<{ label?: string; value?: string }>)
428
- : []
429
- const fields: Array<{ label: string; value: string }> = []
430
- for (const f of rawFields) {
431
- if (!f || !f.label || !f.value) continue
432
- fields.push({ label: f.label, value: f.value })
433
- }
434
- yield {
435
- type: 'approval_request',
436
- data: {
437
- command: headline,
438
- fields,
439
- requestId: proposalId,
440
- approvalType: toolName,
441
- },
442
- status: 'pending',
443
- } as any
444
- } else if (meta.kind === 'text-leading' && typeof meta.text === 'string') {
445
- // Model's preamble text (the prose written BEFORE the tool_use
446
- // block) — emitted as a standalone text segment.
447
- yield { type: 'text', text: meta.text }
448
- } else if (meta.kind === 'tool_error') {
449
- const msg =
450
- typeof meta.message === 'string' && meta.message.length > 0
451
- ? meta.message
452
- : 'Could not complete the requested action right now.'
453
- yield {
454
- type: 'text',
455
- text: `⚠️ ${msg}`,
456
- } as any
457
- } else if (meta.kind === 'routing') {
458
- if (typeof meta.routedComplexity === 'string') {
459
- mergeTurnMeta(metaMapRef, sendIdx, {
460
- routedComplexity: meta.routedComplexity,
461
- routedThinkingBudget:
462
- typeof meta.routedThinkingBudget === 'number'
463
- ? meta.routedThinkingBudget
464
- : null,
465
- })
466
- bumpMetaTick()
467
- }
468
- } else {
469
- if (meta.sources) {
470
- sourcesMapRef.current.set(sendIdx, meta.sources)
471
- }
472
- // Per-row refs for inline object cards.
473
- if (meta.refs && typeof meta.refs === 'object') {
474
- refsMapRef.current.set(sendIdx, meta.refs as Record<string, ChatRef>)
475
- bumpMetaTick()
476
- }
477
- if (
478
- meta.modelLabel ||
479
- meta.contextWindowMaxTokens ||
480
- meta.provider ||
481
- meta.model
482
- ) {
483
- mergeTurnMeta(metaMapRef, sendIdx, {
484
- provider: meta.provider ?? null,
485
- modelLabel: meta.modelLabel ?? null,
486
- contextWindowMaxTokens: meta.contextWindowMaxTokens ?? null,
487
- })
488
- bumpMetaTick()
489
- }
490
- // Server-minted conversation id, echoed on every turn. On the
491
- // session's FIRST turn this is where the client learns its id —
492
- // capture it and persist it so later turns and future visits
493
- // continue the same conversation.
494
- if (typeof meta.conversationId === 'string' && meta.conversationId) {
495
- if (conversationIdRef.current !== meta.conversationId) {
496
- conversationIdRef.current = meta.conversationId
497
- conversationStorage.save({ conversationId: meta.conversationId })
498
- }
499
- mergeTurnMeta(metaMapRef, sendIdx, { conversationId: meta.conversationId })
500
- bumpMetaTick()
501
- }
502
- const parsedAnchor = parseScrollAnchor(meta.scrollAnchor)
503
- if (parsedAnchor !== null) {
504
- mergeTurnMeta(metaMapRef, sendIdx, { scrollAnchor: parsedAnchor })
505
- bumpMetaTick()
506
- }
507
- }
508
- buffer = remaining
509
- }
510
- } else if (inTrailer) {
511
- trailerBuffer += chunk
512
- } else {
513
- setStreamingPhase('streaming')
514
- // Trailing usage frame uses \x1F (Unit Separator) as a sentinel.
515
- const sepIdx = chunk.indexOf('\x1F')
516
- if (sepIdx === -1) {
517
- yield { type: 'text', text: chunk }
518
- } else {
519
- const before = chunk.slice(0, sepIdx)
520
- const after = chunk.slice(sepIdx + 1)
521
- if (before) yield { type: 'text', text: before }
522
- inTrailer = true
523
- trailerBuffer = after
524
- }
525
- }
526
- }
527
- // Stream ended. Flush any pending thinking text that didn't make it
528
- // out before the throttle window expired.
529
- if (pendingThinkingYield) {
530
- pendingThinkingYield = false
531
- yield { type: 'thinking', text: escapeThinkingTags(thinkingAccum) }
532
- }
533
- // Parse trailer if present.
534
- if (trailerBuffer.length > 0) {
535
- try {
536
- const meta = JSON.parse(trailerBuffer)
537
- if (
538
- meta.kind === 'usage' &&
539
- (meta.stage === 'end' || meta.stage === 'display')
540
- ) {
541
- const breakdown =
542
- meta.breakdown && typeof meta.breakdown === 'object'
543
- ? {
544
- haikuRewriter:
545
- meta.breakdown.haikuRewriter &&
546
- typeof meta.breakdown.haikuRewriter.input === 'number'
547
- ? meta.breakdown.haikuRewriter
548
- : undefined,
549
- haikuClassifier:
550
- meta.breakdown.haikuClassifier &&
551
- typeof meta.breakdown.haikuClassifier.input === 'number'
552
- ? meta.breakdown.haikuClassifier
553
- : undefined,
554
- haikuSummarizer:
555
- meta.breakdown.haikuSummarizer &&
556
- typeof meta.breakdown.haikuSummarizer.input === 'number'
557
- ? meta.breakdown.haikuSummarizer
558
- : undefined,
559
- routedAnswer:
560
- meta.breakdown.routedAnswer &&
561
- typeof meta.breakdown.routedAnswer.model === 'string'
562
- ? meta.breakdown.routedAnswer
563
- : undefined,
564
- }
565
- : null
566
- mergeTurnMeta(metaMapRef, sendIdx, {
567
- inputTokens: meta.input_tokens ?? null,
568
- outputTokens: meta.output_tokens ?? null,
569
- cacheHitRatePct:
570
- typeof meta.hit_rate_pct === 'number' ? meta.hit_rate_pct : null,
571
- ...(breakdown ? { breakdown } : {}),
572
- })
573
- bumpMetaTick()
574
- }
575
- } catch {
576
- // Malformed trailer — silently ignore.
577
- }
578
- }
579
- }
580
- }
581
-
582
- /**
583
- * Merge partial fields into the per-turn meta map. Preserves existing
584
- * non-null values so a leading frame's data isn't wiped by a later frame
585
- * that didn't include it.
586
- */
587
- function mergeTurnMeta(
588
- ref: MutableRefObject<Map<number, ChatTurnMeta>>,
589
- sendIdx: number,
590
- partial: Partial<ChatTurnMeta>,
591
- ): void {
592
- const prev = ref.current.get(sendIdx) ?? createEmptyTurnMeta()
593
- const filtered = Object.fromEntries(
594
- Object.entries(partial).filter(([, v]) => v !== undefined && v !== null),
595
- ) as Partial<ChatTurnMeta>
596
- ref.current.set(sendIdx, { ...prev, ...filtered })
597
- }
598
-
599
178
  // =============================================================================
600
179
  // Local persistence — the server-issued conversation id ONLY
601
180
  // =============================================================================
@@ -607,6 +186,8 @@ function mergeTurnMeta(
607
186
  // route) and is rehydrated on mount via `GET <chatStreamUrl>/history`.
608
187
  // localStorage never stores messages, sources, refs, or send counts —
609
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.)
610
191
 
611
192
  /** localStorage namespace used when no `source` is configured on the
612
193
  * runtime. Embedders are platform-agnostic (see `ChatRuntime.source`), so any
@@ -617,6 +198,24 @@ const DEFAULT_CHAT_SOURCE = 'embed'
617
198
  // on the lib-standard `createLocalStorageAdapter`); the mount-time history
618
199
  // rebuild lives in `./use-chat-history-hydration.ts`.
619
200
 
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
217
+ }
218
+
620
219
  // =============================================================================
621
220
  // useSseChatAdapter — public hook
622
221
  // =============================================================================
@@ -627,8 +226,8 @@ const DEFAULT_CHAT_SOURCE = 'embed'
627
226
  * decision) the lib's `<ChatContainer>` consumes.
628
227
  *
629
228
  * Source identity comes from `useRequiredChatRuntime().source` — no
630
- * parameter. An empty `source` falls back to `DEFAULT_CHAT_SOURCE` (used only for
631
- * 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).
632
231
  */
633
232
  export function useSseChatAdapter(
634
233
  options?: UseSseChatAdapterOptions,
@@ -638,73 +237,70 @@ export function useSseChatAdapter(
638
237
  // Chat-specific code REQUIRES a runtime — the lib's `<HubRuntimeProvider>`
639
238
  // (hub) / embedder's provider must wrap the tree.
640
239
  const runtime = useRequiredChatRuntime()
641
- // `source` is OPTIONAL — embedders are platform-agnostic (see ChatRuntime.source).
642
- // Here it's used ONLY for the conversation-id localStorage namespace + client-side
643
- // meta labels; it is NEVER sent on the wire (the hub resolves source server-side
644
- // via currentPlatform()). Fall back to a stable constant so the persistence key
645
- // stays well-formed when the embedder leaves source unset.
646
240
  const source = runtime.source || DEFAULT_CHAT_SOURCE
647
- // Fall back to the lib-baked hub-canonical map when the embedder
648
- // didn't supply an override. Keeps Ask + Display working in any
649
- // mount that doesn't have a custom documentType registry. Embedders
650
- // with polymorphic / per-tenant types pass their own callback and it
651
- // wins.
652
241
  const tableIdForDocumentType =
653
242
  options?.tableIdForDocumentType ?? defaultTableIdForDocumentType
654
243
 
655
- // Restore the server-issued conversation id once on mount. Null = no
656
- // conversation yet the FIRST send goes out without an id, the server
657
- // mints one, and the metadata-frame capture (in the stream fn) stores it.
658
- // The client NEVER generates ids. This is the ONLY local persistence —
659
- // message history hydrates from the server transcript below.
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],
275
+ )
276
+
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
+ })
285
+
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.
660
293
  const conversationStorage = useMemo(() => createChatConversationStorage(source), [source])
661
294
  const conversationIdRef = useRef<string | null>(null)
662
295
  const restoredConversationRef = useRef(false)
663
296
  if (!restoredConversationRef.current) {
664
297
  restoredConversationRef.current = true
298
+ // Sweeps other-identity keys AND the retired v1 full-history blobs.
665
299
  pruneStaleChatConversationStorage(source)
666
300
  conversationIdRef.current = conversationStorage.load()?.conversationId ?? null
667
301
  }
668
302
 
669
- const sourcesMapRef = useRef<Map<number, ChatSource[]>>(new Map())
670
- const refsMapRef = useRef<Map<number, Record<string, ChatRef>>>(new Map())
671
- const metaMapRef = useRef<Map<number, ChatTurnMeta>>(new Map())
672
- const sendCountRef = useRef(0)
673
- const [streamingPhase, setStreamingPhase] = useState<StreamingPhase>('idle')
674
- const [metaTick, setMetaTick] = useState(0)
675
- const bumpMetaTick = useCallback(() => setMetaTick((t) => t + 1), [])
676
-
677
- const streamFn = useMemo(
678
- () =>
679
- createDocStreamFn(
680
- source,
681
- {
682
- chatStreamUrl: runtime.endpoints.chatStreamUrl,
683
- approvalToolUrl: runtime.endpoints.approvalToolUrl,
684
- },
685
- sourcesMapRef,
686
- refsMapRef,
687
- metaMapRef,
688
- setStreamingPhase,
689
- bumpMetaTick,
690
- sendCountRef,
691
- conversationIdRef,
692
- conversationStorage,
693
- ),
694
- [
695
- source,
696
- runtime.endpoints.chatStreamUrl,
697
- runtime.endpoints.approvalToolUrl,
698
- bumpMetaTick,
699
- conversationStorage,
700
- ],
701
- )
702
-
703
- // Per-source tableId → slash-command-id lookup, derived from the shared
704
- // slash-command registry. Used by `displayRef` to translate an
705
- // inline-card's Display click into a `/<cmd> display "<value>"`
706
- // invocation, picking the canonical command for the row's RAG table.
707
- //
303
+ // ─── Slash-command registry (displayRef lookup) ───────────────────────────
708
304
  // Reads from the SAME react-query cache entry as `<EmbeddableChat>`'s
709
305
  // onboarding-card list (keyed on `commandsUrl`), so Guide mode fetches
710
306
  // `commands` ONCE. Gated on `active` so a Mingo-only panel — where this
@@ -736,140 +332,271 @@ export function useSseChatAdapter(
736
332
  return map
737
333
  }, [slashCommands])
738
334
 
739
- const {
740
- messages,
741
- isTyping,
742
- isStreaming,
743
- sendMessage: chatSendMessage,
744
- stopMessage: chatStopMessage,
745
- clearMessages: chatClearMessages,
746
- hydrateMessages,
747
- hasMessages,
748
- } = useChat({
749
- useMock: false,
750
- assistantName: 'Mingo AI',
751
- streamFn,
752
- })
753
-
754
- // ─── Server hydration (history SSOT) ───
335
+ // ─── Server hydration (history SSOT) ──────────────────────────────────────
755
336
  // Mount-time rebuild of the message list from the server transcript — the
756
- // single store of conversation history. Extracted to its own hook; see
757
- // `use-chat-history-hydration.ts` for the full contract + failure
758
- // semantics (a miss starts the UI empty, never loses server context).
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.
759
348
  const historyUrl =
760
349
  runtime.endpoints.chatHistoryUrl ??
761
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
+ })
367
+ })
368
+ },
369
+ [mutate],
370
+ )
762
371
  const { isHydratingHistory, hydratedKeyRef } = useChatHistoryHydration({
763
372
  active,
764
373
  source,
765
374
  historyUrl,
766
375
  conversationIdRef,
767
- refsMapRef,
768
- sendCountRef,
376
+ refsMapRef: hydrationRefsMapRef,
377
+ sendCountRef: hydrationSendCountRef,
769
378
  hydrateMessages,
770
- bumpMetaTick,
379
+ // Meta invalidation is a reducer concern here: `seedSseMaps` (inside
380
+ // `hydrateMessages` above) already invalidates the snapshot.
381
+ bumpMetaTick: noopBumpMetaTick,
771
382
  })
772
383
 
773
- // Index sources/refs/scrollAnchor by USER-SEND count (`sendIdx`), not
774
- // by assistant-message count. Each user send produces exactly ONE
775
- // refsMapRef entry server-side, but it can produce MULTIPLE assistant
776
- // messages on the client (main RAG reply + post-approve card +
777
- // auto-continuation prose). Counting USER sends and mapping every
778
- // following assistant message to that index keeps the lookup stable.
779
- let sendIdx = -1
780
- const docMessages: DocChatMessage[] = messages.map((m) => {
781
- const segments = Array.isArray(m.content) ? (m.content as MessageSegment[]) : undefined
782
- const content =
783
- typeof m.content === 'string'
784
- ? m.content
785
- : Array.isArray(m.content)
786
- ? m.content
787
- .filter((s) => s.type === 'text')
788
- .map((s) => (s as { type: 'text'; text: string }).text)
789
- .join('')
790
- : ''
791
-
792
- let sources: ChatSource[] | undefined
793
- let chatRefs: Record<string, ChatRef> | undefined
794
- let scrollAnchor: ScrollAnchor | undefined
795
- if (m.role === 'user' && !m.hidden) {
796
- sendIdx++
797
- }
798
- if (m.role === 'assistant') {
799
- const lookupIdx = sendIdx >= 0 ? sendIdx : 0
800
- sources = sourcesMapRef.current.get(lookupIdx)
801
- // The receipt path stamps `chatRefs` directly onto the assistant
802
- // message in useChat. Prefer that message-bound copy when present;
803
- // falls back to per-turn refs map.
804
- chatRefs = m.chatRefs ?? refsMapRef.current.get(lookupIdx)
805
- scrollAnchor =
806
- (metaMapRef.current.get(lookupIdx)?.scrollAnchor as ScrollAnchor | null) ??
807
- undefined
808
- }
809
-
810
- return {
811
- id: m.id,
812
- role: m.role as 'user' | 'assistant',
813
- content,
814
- ...(segments ? { segments } : {}),
815
- ...(sources ? { sources } : {}),
816
- ...(chatRefs ? { chatRefs } : {}),
817
- ...(scrollAnchor ? { scrollAnchor } : {}),
818
- ...(m.hidden ? { hidden: true } : {}),
819
- }
820
- })
384
+ // ─── Send / stream loop ────────────────────────────────────────────────────
821
385
 
822
386
  /**
823
387
  * Internal sendMessage options — union of the public
824
388
  * `UnifiedSendMessageOptions` (semantic fields: `hidden`, `attachments`)
825
389
  * and SSE-only internal extras (`commandOverride`, `approvalAction`)
826
- * set by `discussRef` / `displayRef` / post-approval continuation.
827
- *
828
- * The public `UnifiedChatState.sendMessage` accepts only the narrow
829
- * unified shape; this hook accepts the wider one because internal
830
- * callers need it. Function-param contravariance makes this assignable.
390
+ * set by `discussRef` / `displayRef` / the approval-card callbacks.
831
391
  */
832
- type InternalSendMessageOptions = UnifiedSendMessageOptions &
833
- 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
+ )
834
416
 
835
417
  const sendMessage = useCallback(
836
- async (
837
- text: string,
838
- options?: InternalSendMessageOptions,
839
- ): Promise<void> => {
840
- const {
841
- hidden,
842
- attachments,
843
- commandOverride,
844
- approvalAction,
845
- } = options ?? {}
846
- const sseExtras: StreamFnExtraOptions = {
847
- ...(commandOverride ? { commandOverride } : {}),
848
- ...(approvalAction ? { approvalAction } : {}),
849
- ...(attachments && attachments.length > 0
850
- ? { pendingAttachments: attachments }
851
- : {}),
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())
852
521
  }
853
- sendCountRef.current++
854
- setStreamingPhase('thinking')
855
- await chatSendMessage(text, sseExtras, hidden ? { hidden } : undefined)
856
522
  },
857
- [chatSendMessage],
523
+ [mutate, applyEvent, flushDeltas, captureConversationId],
858
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])
859
595
 
860
596
  /**
861
597
  * "Display" callback for inline cards whose registry entry sets
862
598
  * `displayAction: true`. Parallel to `discussRef` but emits a
863
- * `/<cmd> display "<title>"` slash command instead of the Discuss
864
- * prose. Resolution of the slash command id for the ref's documentType
865
- * happens via the `cmdIdByTableId` map hydrated from the commands
866
- * endpoint.
867
- *
868
- * `tableIdForDocumentType` is always defined here — either the
869
- * embedder-supplied override OR the lib-baked
870
- * `defaultTableIdForDocumentType` fallback. Unknown documentTypes
871
- * still short-circuit gracefully via the `tableId === null` check
872
- * below.
599
+ * `/<cmd> display "<title>"` slash command instead of the Discuss prose.
873
600
  */
874
601
  const displayRef = useCallback(
875
602
  (reference: ChatRef) => {
@@ -911,10 +638,6 @@ export function useSseChatAdapter(
911
638
  * prompt ("Tell me more about <title>"); the structured
912
639
  * `commandOverride.entityIdFilter` is sent server-side via the request
913
640
  * body so retrieval narrows to the named row.
914
- *
915
- * `tableIdForDocumentType` is always defined here — either the
916
- * embedder-supplied override OR the lib-baked default. Unknown
917
- * documentTypes still short-circuit via the `tableId === null` check.
918
641
  */
919
642
  const discussRef = useCallback(
920
643
  (reference: ChatRef) => {
@@ -944,47 +667,17 @@ export function useSseChatAdapter(
944
667
  [sendMessage, tableIdForDocumentType],
945
668
  )
946
669
 
947
- const stopMessage = useCallback(() => {
948
- chatStopMessage()
949
- setStreamingPhase('idle')
950
- }, [chatStopMessage])
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])
951
675
 
952
- const clearMessages = useCallback(() => {
953
- sourcesMapRef.current.clear()
954
- refsMapRef.current.clear()
955
- metaMapRef.current.clear()
956
- sendCountRef.current = 0
957
- // New chat = drop the stored conversation id. The old row stays frozen
958
- // server-side; the NEXT send goes out id-less and the server mints a
959
- // fresh conversation (echoed back and re-captured then).
960
- conversationIdRef.current = null
961
- conversationStorage.clear()
962
- hydratedKeyRef.current = null
963
- setStreamingPhase('idle')
964
- // Force the latestMeta useMemo to recompute with the cleared map.
965
- bumpMetaTick()
966
- chatClearMessages()
967
- }, [chatClearMessages, conversationStorage, hydratedKeyRef, bumpMetaTick])
968
-
969
- // Reset to idle whenever both flags drop off.
970
- useEffect(() => {
971
- if (!isTyping && !isStreaming && streamingPhase !== 'idle') {
972
- setStreamingPhase('idle')
973
- }
974
- }, [isTyping, isStreaming, streamingPhase])
975
-
976
- // Resolve the active turn's metadata.
977
- const latestMeta = useMemo(
978
- () =>
979
- metaMapRef.current.get(sendCountRef.current - 1) ??
980
- metaMapRef.current.get(sendCountRef.current - 2) ??
981
- null,
982
- [metaTick, streamingPhase],
983
- )
676
+ const streamingPhase = state.streamingPhase
984
677
 
985
678
  return {
986
679
  messages: docMessages,
987
- isLoading: isTyping || isStreaming,
680
+ isLoading: streamingPhase !== 'idle',
988
681
  sendMessage,
989
682
  /** "Discuss" affordance for ObjectCard. */
990
683
  discussRef,
@@ -1047,13 +740,13 @@ const noopSelectDialog = (_id: string | null): void => {
1047
740
  }
1048
741
  const noopStartNewDialog = async (): Promise<string | null> => null
1049
742
  const noopDeleteDialog = async (_id: string): Promise<void> => {
1050
- /* no-op until Guide localStorage history is exposed */
743
+ /* no-op until Guide server-side history is exposed as dialogs */
1051
744
  }
1052
745
  const noopRenameDialog = async (_id: string, _title: string): Promise<void> => {
1053
- /* no-op until Guide localStorage history is exposed */
746
+ /* no-op until Guide server-side history is exposed as dialogs */
1054
747
  }
1055
748
  const noopArchiveDialog = async (_id: string): Promise<void> => {
1056
- /* no-op until Guide localStorage history is exposed */
749
+ /* no-op until Guide server-side history is exposed as dialogs */
1057
750
  }
1058
751
  const noopAsync = async (): Promise<void> => {
1059
752
  /* no-op pagination stub */
@@ -1064,3 +757,6 @@ const noopApproveRequest = async (_id: string): Promise<void> => {
1064
757
  const noopRejectRequest = async (_id: string, _reason?: string): Promise<void> => {
1065
758
  /* Guide mode has no tool-call approval workflow */
1066
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 => {}