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

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 (293) hide show
  1. package/dist/{chunk-5CJR2GZ4.cjs → chunk-2MKUHJ3O.cjs} +31 -31
  2. package/dist/{chunk-5CJR2GZ4.cjs.map → chunk-2MKUHJ3O.cjs.map} +1 -1
  3. package/dist/{chunk-S2NHJDKQ.cjs → chunk-3IEVA5AO.cjs} +62 -62
  4. package/dist/{chunk-S2NHJDKQ.cjs.map → chunk-3IEVA5AO.cjs.map} +1 -1
  5. package/dist/{chunk-GTWO5NAY.js → chunk-6CFFISUC.js} +2 -2
  6. package/dist/{chunk-VHG6AX5V.js → chunk-6CJ2K7BD.js} +2 -2
  7. package/dist/{chunk-4NWARV5I.js → chunk-6QK3ONDD.js} +2 -2
  8. package/dist/{chunk-YHOIAA2E.cjs → chunk-7I42V2OD.cjs} +60 -60
  9. package/dist/chunk-7I42V2OD.cjs.map +1 -0
  10. package/dist/{chunk-T2EGSZFN.js → chunk-AIRNG27G.js} +5 -6
  11. package/dist/chunk-AIRNG27G.js.map +1 -0
  12. package/dist/{chunk-NWX7MMF5.cjs → chunk-AW63ET2A.cjs} +15 -15
  13. package/dist/{chunk-NWX7MMF5.cjs.map → chunk-AW63ET2A.cjs.map} +1 -1
  14. package/dist/{chunk-TKN5667N.js → chunk-BZMOEWWS.js} +10 -10
  15. package/dist/chunk-BZMOEWWS.js.map +1 -0
  16. package/dist/{chunk-DSDZ5T4P.cjs → chunk-D5LQMRPU.cjs} +5 -5
  17. package/dist/{chunk-DSDZ5T4P.cjs.map → chunk-D5LQMRPU.cjs.map} +1 -1
  18. package/dist/{chunk-CORIBKHG.js → chunk-EGXCWZYP.js} +2 -2
  19. package/dist/{chunk-5EYRXSY5.cjs → chunk-EJQUYZXF.cjs} +9 -9
  20. package/dist/{chunk-5EYRXSY5.cjs.map → chunk-EJQUYZXF.cjs.map} +1 -1
  21. package/dist/{chunk-P4WTZU6L.cjs → chunk-FY5XAW3H.cjs} +11 -11
  22. package/dist/{chunk-P4WTZU6L.cjs.map → chunk-FY5XAW3H.cjs.map} +1 -1
  23. package/dist/{chunk-VQ523ORK.cjs → chunk-GMPYR3MX.cjs} +26 -26
  24. package/dist/{chunk-VQ523ORK.cjs.map → chunk-GMPYR3MX.cjs.map} +1 -1
  25. package/dist/{chunk-QSCHCZL3.cjs → chunk-GUTYUMGV.cjs} +7 -7
  26. package/dist/{chunk-QSCHCZL3.cjs.map → chunk-GUTYUMGV.cjs.map} +1 -1
  27. package/dist/{chunk-Q6E7VJ4W.cjs → chunk-IEZ3YBAP.cjs} +27 -28
  28. package/dist/chunk-IEZ3YBAP.cjs.map +1 -0
  29. package/dist/{chunk-HSACATCD.js → chunk-MPYDX5WX.js} +2 -2
  30. package/dist/{chunk-RQLBHKXN.js → chunk-NGEFYPY7.js} +2 -2
  31. package/dist/{chunk-BE6DK7QY.js → chunk-OQIMH2WQ.js} +8985 -11081
  32. package/dist/chunk-OQIMH2WQ.js.map +1 -0
  33. package/dist/{chunk-CSYSYJCF.cjs → chunk-UZZT65PQ.cjs} +37 -37
  34. package/dist/{chunk-CSYSYJCF.cjs.map → chunk-UZZT65PQ.cjs.map} +1 -1
  35. package/dist/{chunk-YZ2OIYQ3.js → chunk-VDZO6IDR.js} +4 -4
  36. package/dist/{chunk-UUYRJPGJ.js → chunk-VL2YMDPH.js} +4 -4
  37. package/dist/{chunk-5LMGFI4J.cjs → chunk-VUXNXWOQ.cjs} +4946 -7042
  38. package/dist/chunk-VUXNXWOQ.cjs.map +1 -0
  39. package/dist/{chunk-XQ2B74SI.js → chunk-WLM3F3WU.js} +2 -2
  40. package/dist/components/case-studies/index.cjs +8 -8
  41. package/dist/components/case-studies/index.js +2 -2
  42. package/dist/components/chat/chat-message-enhanced.d.ts.map +1 -1
  43. package/dist/components/chat/chat-message-list.d.ts.map +1 -1
  44. package/dist/components/chat/guide-display.d.ts +14 -0
  45. package/dist/components/chat/guide-display.d.ts.map +1 -0
  46. package/dist/components/chat/hooks/index.d.ts +1 -0
  47. package/dist/components/chat/hooks/index.d.ts.map +1 -1
  48. package/dist/components/chat/hooks/use-chat.d.ts.map +1 -1
  49. package/dist/components/chat/hooks/use-nats-chat-adapter.d.ts +48 -15
  50. package/dist/components/chat/hooks/use-nats-chat-adapter.d.ts.map +1 -1
  51. package/dist/components/chat/hooks/use-realtime-chunk-processor.d.ts +6 -0
  52. package/dist/components/chat/hooks/use-realtime-chunk-processor.d.ts.map +1 -0
  53. package/dist/components/chat/hooks/use-sse-chat-adapter.d.ts +57 -10
  54. package/dist/components/chat/hooks/use-sse-chat-adapter.d.ts.map +1 -1
  55. package/dist/components/chat/index.cjs +2 -8
  56. package/dist/components/chat/index.cjs.map +1 -1
  57. package/dist/components/chat/index.d.ts +1 -1
  58. package/dist/components/chat/index.d.ts.map +1 -1
  59. package/dist/components/chat/index.js +15 -21
  60. package/dist/components/chat/types/api.types.d.ts +81 -1
  61. package/dist/components/chat/types/api.types.d.ts.map +1 -1
  62. package/dist/components/chat/types/component.types.d.ts +6 -0
  63. package/dist/components/chat/types/component.types.d.ts.map +1 -1
  64. package/dist/components/chat/types/message.types.d.ts +15 -2
  65. package/dist/components/chat/types/message.types.d.ts.map +1 -1
  66. package/dist/components/chat/types/processing.types.d.ts +93 -2
  67. package/dist/components/chat/types/processing.types.d.ts.map +1 -1
  68. package/dist/components/chat/utils/chunk-parser.d.ts +25 -0
  69. package/dist/components/chat/utils/chunk-parser.d.ts.map +1 -0
  70. package/dist/components/chat/utils/extract-incomplete-message-state.d.ts +6 -34
  71. package/dist/components/chat/utils/extract-incomplete-message-state.d.ts.map +1 -1
  72. package/dist/components/chat/utils/index.d.ts +3 -2
  73. package/dist/components/chat/utils/index.d.ts.map +1 -1
  74. package/dist/components/chat/utils/message-segment-accumulator.d.ts +5 -0
  75. package/dist/components/chat/utils/message-segment-accumulator.d.ts.map +1 -1
  76. package/dist/components/chat/utils/process-historical-messages.d.ts +11 -59
  77. package/dist/components/chat/utils/process-historical-messages.d.ts.map +1 -1
  78. package/dist/components/contact/index.cjs +3 -3
  79. package/dist/components/contact/index.js +2 -2
  80. package/dist/components/docs/index.cjs +5 -5
  81. package/dist/components/docs/index.js +4 -4
  82. package/dist/components/embeds/index.cjs +3 -3
  83. package/dist/components/embeds/index.js +2 -2
  84. package/dist/components/faq/index.cjs +3 -3
  85. package/dist/components/faq/index.js +2 -2
  86. package/dist/components/features/index.cjs +2 -2
  87. package/dist/components/features/index.js +1 -1
  88. package/dist/components/help-center-pages/index.cjs +20 -20
  89. package/dist/components/help-center-pages/index.js +11 -11
  90. package/dist/components/index.cjs +130 -170
  91. package/dist/components/index.cjs.map +1 -1
  92. package/dist/components/index.js +23 -63
  93. package/dist/components/index.js.map +1 -1
  94. package/dist/components/navigation/index.cjs +2 -2
  95. package/dist/components/navigation/index.js +1 -1
  96. package/dist/components/onboarding-guides/index.cjs +5 -5
  97. package/dist/components/onboarding-guides/index.js +4 -4
  98. package/dist/components/onboarding-guides/onboarding-guide-detail-view.d.ts.map +1 -1
  99. package/dist/components/related-content/index.cjs +3 -3
  100. package/dist/components/related-content/index.js +2 -2
  101. package/dist/components/shared/product-release/release-detail-page.d.ts.map +1 -1
  102. package/dist/components/tickets/index.cjs +5 -5
  103. package/dist/components/tickets/index.js +4 -4
  104. package/dist/components/ui/index.cjs +2 -42
  105. package/dist/components/ui/index.cjs.map +1 -1
  106. package/dist/components/ui/index.d.ts +2 -1
  107. package/dist/components/ui/index.d.ts.map +1 -1
  108. package/dist/components/ui/index.js +15 -55
  109. package/dist/components/ui/rich-markdown-renderer.d.ts +34 -0
  110. package/dist/components/ui/rich-markdown-renderer.d.ts.map +1 -0
  111. package/dist/components/ui/simple-markdown-renderer.d.ts +73 -0
  112. package/dist/components/ui/simple-markdown-renderer.d.ts.map +1 -0
  113. package/dist/index.cjs +2 -58
  114. package/dist/index.cjs.map +1 -1
  115. package/dist/index.js +15 -71
  116. package/dist/utils/index.cjs +40 -169
  117. package/dist/utils/index.cjs.map +1 -1
  118. package/dist/utils/index.d.ts +0 -1
  119. package/dist/utils/index.d.ts.map +1 -1
  120. package/dist/utils/index.js +41 -162
  121. package/dist/utils/index.js.map +1 -1
  122. package/dist/utils/markdown-section-extractor.d.ts.map +1 -1
  123. package/package.json +1 -7
  124. package/src/components/chat/__tests__/chat-message-list.test.tsx +5 -293
  125. package/src/components/chat/chat-message-enhanced.tsx +80 -163
  126. package/src/components/chat/chat-message-list.tsx +109 -358
  127. package/src/components/chat/embeddable-chat.tsx +0 -6
  128. package/src/components/chat/guide-display.tsx +44 -0
  129. package/src/components/chat/hooks/.index.md +33 -30
  130. package/src/components/chat/hooks/.use-nats-chat-adapter.md +55 -36
  131. package/src/components/chat/hooks/.use-realtime-chunk-processor.md +77 -0
  132. package/src/components/chat/hooks/index.ts +1 -0
  133. package/src/components/chat/hooks/use-chat.ts +0 -8
  134. package/src/components/chat/hooks/use-nats-chat-adapter.ts +825 -243
  135. package/src/components/chat/hooks/use-realtime-chunk-processor.ts +489 -0
  136. package/src/components/chat/hooks/use-sse-chat-adapter.ts +684 -380
  137. package/src/components/chat/index.ts +1 -5
  138. package/src/components/chat/thinking-display.tsx +1 -1
  139. package/src/components/chat/types/.api.types.md +49 -39
  140. package/src/components/chat/types/.processing.types.md +58 -29
  141. package/src/components/chat/types/api.types.ts +73 -6
  142. package/src/components/chat/types/component.types.ts +9 -0
  143. package/src/components/chat/types/message.types.ts +17 -1
  144. package/src/components/chat/types/processing.types.ts +53 -11
  145. package/src/components/chat/utils/.chunk-parser.md +62 -0
  146. package/src/components/chat/utils/.index.md +40 -31
  147. package/src/components/chat/utils/chunk-parser.ts +262 -0
  148. package/src/components/chat/utils/extract-incomplete-message-state.ts +5 -63
  149. package/src/components/chat/utils/index.ts +8 -5
  150. package/src/components/chat/utils/message-segment-accumulator.ts +19 -0
  151. package/src/components/chat/utils/process-historical-messages.ts +390 -352
  152. package/src/components/onboarding-guides/onboarding-guide-detail-view.tsx +4 -22
  153. package/src/components/shared/legal-document/legal-document-page.tsx +1 -1
  154. package/src/components/shared/product-release/release-detail-page.tsx +4 -19
  155. package/src/components/ui/.simple-markdown-renderer.md +52 -0
  156. package/src/components/ui/index.ts +2 -1
  157. package/src/components/ui/release-changelog-section.tsx +1 -1
  158. package/src/components/ui/rich-markdown-renderer.tsx +1223 -0
  159. package/src/components/ui/simple-markdown-renderer.tsx +964 -0
  160. package/src/components/ui/ticket-info-section.tsx +1 -1
  161. package/src/stories/ChatMessageEnhanced.stories.tsx +48 -1
  162. package/src/utils/index.ts +0 -1
  163. package/src/utils/markdown-section-extractor.ts +56 -41
  164. package/dist/chat-protocol/decode.d.ts +0 -58
  165. package/dist/chat-protocol/decode.d.ts.map +0 -1
  166. package/dist/chat-protocol/encode.d.ts +0 -44
  167. package/dist/chat-protocol/encode.d.ts.map +0 -1
  168. package/dist/chat-protocol/env-flag.d.ts +0 -35
  169. package/dist/chat-protocol/env-flag.d.ts.map +0 -1
  170. package/dist/chat-protocol/events.d.ts +0 -196
  171. package/dist/chat-protocol/events.d.ts.map +0 -1
  172. package/dist/chat-protocol/frames.d.ts +0 -213
  173. package/dist/chat-protocol/frames.d.ts.map +0 -1
  174. package/dist/chat-protocol/index.cjs +0 -494
  175. package/dist/chat-protocol/index.cjs.map +0 -1
  176. package/dist/chat-protocol/index.d.ts +0 -18
  177. package/dist/chat-protocol/index.d.ts.map +0 -1
  178. package/dist/chat-protocol/index.js +0 -478
  179. package/dist/chat-protocol/index.js.map +0 -1
  180. package/dist/chat-protocol/ip-normalize.d.ts +0 -44
  181. package/dist/chat-protocol/ip-normalize.d.ts.map +0 -1
  182. package/dist/chat-protocol/nats-decoder.d.ts +0 -26
  183. package/dist/chat-protocol/nats-decoder.d.ts.map +0 -1
  184. package/dist/chunk-5LMGFI4J.cjs.map +0 -1
  185. package/dist/chunk-BE6DK7QY.js.map +0 -1
  186. package/dist/chunk-Q6E7VJ4W.cjs.map +0 -1
  187. package/dist/chunk-T2EGSZFN.js.map +0 -1
  188. package/dist/chunk-TKN5667N.js.map +0 -1
  189. package/dist/chunk-YHOIAA2E.cjs.map +0 -1
  190. package/dist/components/chat/stream/chat-dialog-store.d.ts +0 -184
  191. package/dist/components/chat/stream/chat-dialog-store.d.ts.map +0 -1
  192. package/dist/components/chat/stream/chat-stream-reducer.d.ts +0 -367
  193. package/dist/components/chat/stream/chat-stream-reducer.d.ts.map +0 -1
  194. package/dist/components/chat/stream/delta-batcher.d.ts +0 -51
  195. package/dist/components/chat/stream/delta-batcher.d.ts.map +0 -1
  196. package/dist/components/chat/stream/index.d.ts +0 -17
  197. package/dist/components/chat/stream/index.d.ts.map +0 -1
  198. package/dist/components/chat/stream/message-mutations.d.ts +0 -77
  199. package/dist/components/chat/stream/message-mutations.d.ts.map +0 -1
  200. package/dist/components/chat/stream/use-chat-stream-reducer.d.ts +0 -22
  201. package/dist/components/chat/stream/use-chat-stream-reducer.d.ts.map +0 -1
  202. package/dist/components/ui/markdown/base-components.d.ts +0 -51
  203. package/dist/components/ui/markdown/base-components.d.ts.map +0 -1
  204. package/dist/components/ui/markdown/engine.d.ts +0 -99
  205. package/dist/components/ui/markdown/engine.d.ts.map +0 -1
  206. package/dist/components/ui/markdown/heading-ids.d.ts +0 -65
  207. package/dist/components/ui/markdown/heading-ids.d.ts.map +0 -1
  208. package/dist/components/ui/markdown/index.d.ts +0 -18
  209. package/dist/components/ui/markdown/index.d.ts.map +0 -1
  210. package/dist/components/ui/markdown/mermaid-diagram.d.ts +0 -79
  211. package/dist/components/ui/markdown/mermaid-diagram.d.ts.map +0 -1
  212. package/dist/components/ui/markdown/rich/embed-overrides.d.ts +0 -14
  213. package/dist/components/ui/markdown/rich/embed-overrides.d.ts.map +0 -1
  214. package/dist/components/ui/markdown/rich/rich-markdown-renderer.d.ts +0 -40
  215. package/dist/components/ui/markdown/rich/rich-markdown-renderer.d.ts.map +0 -1
  216. package/dist/components/ui/markdown/rich/shortcodes.d.ts +0 -8
  217. package/dist/components/ui/markdown/rich/shortcodes.d.ts.map +0 -1
  218. package/dist/components/ui/markdown/sanitize.d.ts +0 -153
  219. package/dist/components/ui/markdown/sanitize.d.ts.map +0 -1
  220. package/dist/components/ui/markdown/simple-markdown-renderer.d.ts +0 -23
  221. package/dist/components/ui/markdown/simple-markdown-renderer.d.ts.map +0 -1
  222. package/dist/components/ui/markdown/streaming.d.ts +0 -78
  223. package/dist/components/ui/markdown/streaming.d.ts.map +0 -1
  224. package/dist/components/ui/markdown/text-size.d.ts +0 -34
  225. package/dist/components/ui/markdown/text-size.d.ts.map +0 -1
  226. package/dist/utils/markdown-fences.d.ts +0 -42
  227. package/dist/utils/markdown-fences.d.ts.map +0 -1
  228. package/dist/utils/markdown-heading-id.d.ts +0 -85
  229. package/dist/utils/markdown-heading-id.d.ts.map +0 -1
  230. package/src/chat-protocol/__tests__/__snapshots__/nats-decoder-golden.test.ts.snap +0 -307
  231. package/src/chat-protocol/__tests__/chat-protocol.test.ts +0 -516
  232. package/src/chat-protocol/__tests__/env-flag.test.ts +0 -52
  233. package/src/chat-protocol/__tests__/ip-normalize.test.ts +0 -136
  234. package/src/chat-protocol/__tests__/nats-decoder-golden.test.ts +0 -228
  235. package/src/chat-protocol/decode.ts +0 -278
  236. package/src/chat-protocol/encode.ts +0 -71
  237. package/src/chat-protocol/env-flag.ts +0 -39
  238. package/src/chat-protocol/events.ts +0 -232
  239. package/src/chat-protocol/frames.ts +0 -245
  240. package/src/chat-protocol/index.ts +0 -21
  241. package/src/chat-protocol/ip-normalize.ts +0 -146
  242. package/src/chat-protocol/nats-decoder.ts +0 -252
  243. package/src/components/chat/__tests__/chat-message-streaming-memo.test.tsx +0 -207
  244. package/src/components/chat/__tests__/chat-pending-turn.test.tsx +0 -209
  245. package/src/components/chat/hooks/__tests__/__snapshots__/conversation-id-persistence-golden.test.ts.snap +0 -37
  246. package/src/components/chat/hooks/__tests__/__snapshots__/sse-stream-golden.test.ts.snap +0 -0
  247. package/src/components/chat/hooks/__tests__/conversation-id-persistence-golden.test.ts +0 -438
  248. package/src/components/chat/hooks/__tests__/sse-stream-golden.test.ts +0 -318
  249. package/src/components/chat/stream/__tests__/__snapshots__/chat-stream-reducer-golden.test.ts.snap +0 -940
  250. package/src/components/chat/stream/__tests__/chat-dialog-store.test.ts +0 -756
  251. package/src/components/chat/stream/__tests__/chat-stream-reducer-golden.test.ts +0 -460
  252. package/src/components/chat/stream/__tests__/chat-stream-reducer.test.ts +0 -894
  253. package/src/components/chat/stream/__tests__/delta-batcher.test.ts +0 -255
  254. package/src/components/chat/stream/__tests__/use-chat-stream-reducer.test.ts +0 -98
  255. package/src/components/chat/stream/chat-dialog-store.ts +0 -536
  256. package/src/components/chat/stream/chat-stream-reducer.ts +0 -1796
  257. package/src/components/chat/stream/delta-batcher.ts +0 -159
  258. package/src/components/chat/stream/index.ts +0 -55
  259. package/src/components/chat/stream/message-mutations.ts +0 -338
  260. package/src/components/chat/stream/use-chat-stream-reducer.ts +0 -126
  261. package/src/components/chat/utils/__tests__/__snapshots__/process-historical-messages-golden.test.ts.snap +0 -420
  262. package/src/components/chat/utils/__tests__/__snapshots__/segment-accumulator-golden.test.ts.snap +0 -605
  263. package/src/components/chat/utils/__tests__/process-historical-messages-golden.test.ts +0 -317
  264. package/src/components/chat/utils/__tests__/segment-accumulator-golden.test.ts +0 -270
  265. package/src/components/ui/__tests__/__snapshots__/markdown-parity.test.tsx.snap +0 -2420
  266. package/src/components/ui/__tests__/markdown-parity.test.tsx +0 -2528
  267. package/src/components/ui/markdown/__tests__/mermaid-security.test.ts +0 -115
  268. package/src/components/ui/markdown/__tests__/mermaid-stale-render.test.tsx +0 -95
  269. package/src/components/ui/markdown/__tests__/sanitize-invariant.test.ts +0 -309
  270. package/src/components/ui/markdown/__tests__/streaming.test.tsx +0 -388
  271. package/src/components/ui/markdown/base-components.tsx +0 -360
  272. package/src/components/ui/markdown/engine.tsx +0 -315
  273. package/src/components/ui/markdown/heading-ids.ts +0 -239
  274. package/src/components/ui/markdown/index.ts +0 -49
  275. package/src/components/ui/markdown/mermaid-diagram.tsx +0 -291
  276. package/src/components/ui/markdown/rich/embed-overrides.tsx +0 -199
  277. package/src/components/ui/markdown/rich/rich-markdown-renderer.tsx +0 -184
  278. package/src/components/ui/markdown/rich/shortcodes.ts +0 -170
  279. package/src/components/ui/markdown/sanitize.ts +0 -3499
  280. package/src/components/ui/markdown/simple-markdown-renderer.tsx +0 -28
  281. package/src/components/ui/markdown/streaming.ts +0 -362
  282. package/src/components/ui/markdown/text-size.ts +0 -106
  283. package/src/utils/markdown-fences.ts +0 -127
  284. package/src/utils/markdown-heading-id.ts +0 -348
  285. /package/dist/{chunk-GTWO5NAY.js.map → chunk-6CFFISUC.js.map} +0 -0
  286. /package/dist/{chunk-VHG6AX5V.js.map → chunk-6CJ2K7BD.js.map} +0 -0
  287. /package/dist/{chunk-4NWARV5I.js.map → chunk-6QK3ONDD.js.map} +0 -0
  288. /package/dist/{chunk-CORIBKHG.js.map → chunk-EGXCWZYP.js.map} +0 -0
  289. /package/dist/{chunk-HSACATCD.js.map → chunk-MPYDX5WX.js.map} +0 -0
  290. /package/dist/{chunk-RQLBHKXN.js.map → chunk-NGEFYPY7.js.map} +0 -0
  291. /package/dist/{chunk-YZ2OIYQ3.js.map → chunk-VDZO6IDR.js.map} +0 -0
  292. /package/dist/{chunk-UUYRJPGJ.js.map → chunk-VL2YMDPH.js.map} +0 -0
  293. /package/dist/{chunk-XQ2B74SI.js.map → chunk-WLM3F3WU.js.map} +0 -0
@@ -6,84 +6,63 @@
6
6
  * counterpart is `useNatsChatAdapter` (Mingo mode). The public
7
7
  * `useChat({ mode })` dispatches between them based on `mode.transport`.
8
8
  *
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).
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.
37
13
  *
38
14
  * Two key contracts vs hub-side chat hooks:
39
15
  *
40
16
  * 1. `source` is READ FROM THE RUNTIME, not a parameter. It is OPTIONAL —
41
17
  * platform-agnostic embedders leave it unset; an empty `source` falls back
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()`).
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.
45
27
  *
46
- * 2. `tableIdForDocumentType` is INJECTED via the optional
28
+ * 3. `tableIdForDocumentType` is INJECTED via the optional
47
29
  * `tableIdForDocumentType` parameter. Hub callers pass the
48
30
  * registry-backed lookup from `lib/config/rag-table-config`;
49
31
  * embedders that don't supply one fall back to
50
- * `defaultTableIdForDocumentType` so the `displayRef` / `discussRef`
51
- * Ask + Display buttons WORK out of the box.
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.
52
40
  */
53
41
 
54
- import { useCallback, useMemo, useRef } from 'react'
42
+ import { useState, useEffect, useCallback, useMemo, useRef, type MutableRefObject } from 'react'
43
+ import { useChat, type StreamFnExtraOptions } from './use-chat'
55
44
  import { useRequiredChatRuntime } from '../../../contexts/chat-runtime-context'
56
45
  import type { ChatRef } from '../chat-ref.types'
57
- import type { Message } from '../types/message.types'
46
+ import { buildChatRefKey } from '../types/chat.types'
58
47
  import type { MessageSegment } from '../types/message.types'
59
48
  import { useSlashCommandRegistry, type SlashCommandSummary } from './use-slash-commands'
60
49
  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'
64
50
  import {
65
51
  createChatConversationStorage,
66
52
  pruneStaleChatConversationStorage,
53
+ type PersistedChatConversation,
67
54
  } from '../utils/chat-conversation-storage'
55
+ import type { LocalStorageAdapter } from '../../../utils/local-storage-adapter'
68
56
  import { useChatHistoryHydration } from './use-chat-history-hydration'
57
+ import { parseScrollAnchor, type ScrollAnchor } from '../utils/scroll-anchor'
69
58
  import { sanitizeTitleForChat } from '../utils/slash-dispatch-utils'
70
59
  import { defaultTableIdForDocumentType } from '../utils/source-icons'
71
- import type { WireCommandOverride } from '../utils/slash-dispatch-utils'
72
- import type { ChatAttachment } from '../utils/chat-attachment-markdown'
73
60
  import type {
74
61
  UnifiedChatState,
75
- UnifiedChatMessage,
76
62
  UnifiedSendMessageOptions,
63
+ StreamingPhase,
77
64
  } from '../types/unified-chat-state.types'
78
65
  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'
87
66
 
88
67
  // =============================================================================
89
68
  // Public types
@@ -146,6 +125,37 @@ export interface DocChatMessage {
146
125
  // `types/unified-chat-state.types.ts`.
147
126
  export type { StreamingPhase } from '../types/unified-chat-state.types'
148
127
 
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
+
149
159
  /**
150
160
  * Optional dependency-injection options for `useSseChatAdapter`.
151
161
  *
@@ -156,7 +166,13 @@ export type { StreamingPhase } from '../types/unified-chat-state.types'
156
166
  * **Defaults to `defaultTableIdForDocumentType` from
157
167
  * `src/utils/source-icons.ts`** — a lib-baked map covering every
158
168
  * documentType currently registered in the hub's RAG_TABLE_CONFIGS.
159
- * Override only for polymorphic / per-tenant document types.
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.
160
176
  */
161
177
  export interface UseSseChatAdapterOptions {
162
178
  tableIdForDocumentType?: (documentType: string) => string | null
@@ -165,16 +181,421 @@ export interface UseSseChatAdapterOptions {
165
181
  export interface UseSseChatAdapterRuntimeOptions {
166
182
  /**
167
183
  * When `false` the adapter skips its background slash-command registry
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
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
173
188
  * standalone callers keep the eager prefetch.
174
189
  */
175
190
  active?: boolean
176
191
  }
177
192
 
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
+
178
599
  // =============================================================================
179
600
  // Local persistence — the server-issued conversation id ONLY
180
601
  // =============================================================================
@@ -186,8 +607,6 @@ export interface UseSseChatAdapterRuntimeOptions {
186
607
  // route) and is rehydrated on mount via `GET <chatStreamUrl>/history`.
187
608
  // localStorage never stores messages, sources, refs, or send counts —
188
609
  // 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
610
 
192
611
  /** localStorage namespace used when no `source` is configured on the
193
612
  * runtime. Embedders are platform-agnostic (see `ChatRuntime.source`), so any
@@ -198,24 +617,6 @@ const DEFAULT_CHAT_SOURCE = 'embed'
198
617
  // on the lib-standard `createLocalStorageAdapter`); the mount-time history
199
618
  // rebuild lives in `./use-chat-history-hydration.ts`.
200
619
 
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
-
219
620
  // =============================================================================
220
621
  // useSseChatAdapter — public hook
221
622
  // =============================================================================
@@ -226,8 +627,8 @@ function historyMessageToReducerMessage(m: Message): UnifiedChatMessage {
226
627
  * decision) the lib's `<ChatContainer>` consumes.
227
628
  *
228
629
  * Source identity comes from `useRequiredChatRuntime().source` — no
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).
630
+ * parameter. An empty `source` falls back to `DEFAULT_CHAT_SOURCE` (used only for
631
+ * the localStorage history namespace; never sent on the wire).
231
632
  */
232
633
  export function useSseChatAdapter(
233
634
  options?: UseSseChatAdapterOptions,
@@ -237,70 +638,73 @@ export function useSseChatAdapter(
237
638
  // Chat-specific code REQUIRES a runtime — the lib's `<HubRuntimeProvider>`
238
639
  // (hub) / embedder's provider must wrap the tree.
239
640
  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.
240
646
  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.
241
652
  const tableIdForDocumentType =
242
653
  options?.tableIdForDocumentType ?? defaultTableIdForDocumentType
243
654
 
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.
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.
293
660
  const conversationStorage = useMemo(() => createChatConversationStorage(source), [source])
294
661
  const conversationIdRef = useRef<string | null>(null)
295
662
  const restoredConversationRef = useRef(false)
296
663
  if (!restoredConversationRef.current) {
297
664
  restoredConversationRef.current = true
298
- // Sweeps other-identity keys AND the retired v1 full-history blobs.
299
665
  pruneStaleChatConversationStorage(source)
300
666
  conversationIdRef.current = conversationStorage.load()?.conversationId ?? null
301
667
  }
302
668
 
303
- // ─── Slash-command registry (displayRef lookup) ───────────────────────────
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
+ //
304
708
  // Reads from the SAME react-query cache entry as `<EmbeddableChat>`'s
305
709
  // onboarding-card list (keyed on `commandsUrl`), so Guide mode fetches
306
710
  // `commands` ONCE. Gated on `active` so a Mingo-only panel — where this
@@ -332,271 +736,140 @@ export function useSseChatAdapter(
332
736
  return map
333
737
  }, [slashCommands])
334
738
 
335
- // ─── Server hydration (history SSOT) ──────────────────────────────────────
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) ───
336
755
  // 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.
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).
348
759
  const historyUrl =
349
760
  runtime.endpoints.chatHistoryUrl ??
350
761
  `${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
- )
371
762
  const { isHydratingHistory, hydratedKeyRef } = useChatHistoryHydration({
372
763
  active,
373
764
  source,
374
765
  historyUrl,
375
766
  conversationIdRef,
376
- refsMapRef: hydrationRefsMapRef,
377
- sendCountRef: hydrationSendCountRef,
767
+ refsMapRef,
768
+ sendCountRef,
378
769
  hydrateMessages,
379
- // Meta invalidation is a reducer concern here: `seedSseMaps` (inside
380
- // `hydrateMessages` above) already invalidates the snapshot.
381
- bumpMetaTick: noopBumpMetaTick,
770
+ bumpMetaTick,
382
771
  })
383
772
 
384
- // ─── Send / stream loop ────────────────────────────────────────────────────
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
+ })
385
821
 
386
822
  /**
387
823
  * Internal sendMessage options — union of the public
388
824
  * `UnifiedSendMessageOptions` (semantic fields: `hidden`, `attachments`)
389
825
  * and SSE-only internal extras (`commandOverride`, `approvalAction`)
390
- * set by `discussRef` / `displayRef` / the approval-card callbacks.
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.
391
831
  */
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
- )
832
+ type InternalSendMessageOptions = UnifiedSendMessageOptions &
833
+ Pick<StreamFnExtraOptions, 'commandOverride' | 'approvalAction'>
416
834
 
417
835
  const sendMessage = useCallback(
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())
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
+ : {}),
521
852
  }
853
+ sendCountRef.current++
854
+ setStreamingPhase('thinking')
855
+ await chatSendMessage(text, sseExtras, hidden ? { hidden } : undefined)
522
856
  },
523
- [mutate, applyEvent, flushDeltas, captureConversationId],
857
+ [chatSendMessage],
524
858
  )
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])
595
859
 
596
860
  /**
597
861
  * "Display" callback for inline cards whose registry entry sets
598
862
  * `displayAction: true`. Parallel to `discussRef` but emits a
599
- * `/<cmd> display "<title>"` slash command instead of the Discuss prose.
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.
600
873
  */
601
874
  const displayRef = useCallback(
602
875
  (reference: ChatRef) => {
@@ -638,6 +911,10 @@ export function useSseChatAdapter(
638
911
  * prompt ("Tell me more about <title>"); the structured
639
912
  * `commandOverride.entityIdFilter` is sent server-side via the request
640
913
  * 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.
641
918
  */
642
919
  const discussRef = useCallback(
643
920
  (reference: ChatRef) => {
@@ -667,17 +944,47 @@ export function useSseChatAdapter(
667
944
  [sendMessage, tableIdForDocumentType],
668
945
  )
669
946
 
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])
947
+ const stopMessage = useCallback(() => {
948
+ chatStopMessage()
949
+ setStreamingPhase('idle')
950
+ }, [chatStopMessage])
675
951
 
676
- const streamingPhase = state.streamingPhase
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
+ )
677
984
 
678
985
  return {
679
986
  messages: docMessages,
680
- isLoading: streamingPhase !== 'idle',
987
+ isLoading: isTyping || isStreaming,
681
988
  sendMessage,
682
989
  /** "Discuss" affordance for ObjectCard. */
683
990
  discussRef,
@@ -740,13 +1047,13 @@ const noopSelectDialog = (_id: string | null): void => {
740
1047
  }
741
1048
  const noopStartNewDialog = async (): Promise<string | null> => null
742
1049
  const noopDeleteDialog = async (_id: string): Promise<void> => {
743
- /* no-op until Guide server-side history is exposed as dialogs */
1050
+ /* no-op until Guide localStorage history is exposed */
744
1051
  }
745
1052
  const noopRenameDialog = async (_id: string, _title: string): Promise<void> => {
746
- /* no-op until Guide server-side history is exposed as dialogs */
1053
+ /* no-op until Guide localStorage history is exposed */
747
1054
  }
748
1055
  const noopArchiveDialog = async (_id: string): Promise<void> => {
749
- /* no-op until Guide server-side history is exposed as dialogs */
1056
+ /* no-op until Guide localStorage history is exposed */
750
1057
  }
751
1058
  const noopAsync = async (): Promise<void> => {
752
1059
  /* no-op pagination stub */
@@ -757,6 +1064,3 @@ const noopApproveRequest = async (_id: string): Promise<void> => {
757
1064
  const noopRejectRequest = async (_id: string, _reason?: string): Promise<void> => {
758
1065
  /* Guide mode has no tool-call approval workflow */
759
1066
  }
760
- /** Stable no-op for the hydration hook's `bumpMetaTick` seam — reducer
761
- * invalidation (inside `hydrateMessages`) already re-renders subscribers. */
762
- const noopBumpMetaTick = (): void => {}