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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (311) hide show
  1. package/dist/chat-protocol/decode.d.ts +58 -0
  2. package/dist/chat-protocol/decode.d.ts.map +1 -0
  3. package/dist/chat-protocol/encode.d.ts +44 -0
  4. package/dist/chat-protocol/encode.d.ts.map +1 -0
  5. package/dist/chat-protocol/env-flag.d.ts +35 -0
  6. package/dist/chat-protocol/env-flag.d.ts.map +1 -0
  7. package/dist/chat-protocol/events.d.ts +196 -0
  8. package/dist/chat-protocol/events.d.ts.map +1 -0
  9. package/dist/chat-protocol/frames.d.ts +213 -0
  10. package/dist/chat-protocol/frames.d.ts.map +1 -0
  11. package/dist/chat-protocol/index.cjs +494 -0
  12. package/dist/chat-protocol/index.cjs.map +1 -0
  13. package/dist/chat-protocol/index.d.ts +18 -0
  14. package/dist/chat-protocol/index.d.ts.map +1 -0
  15. package/dist/chat-protocol/index.js +478 -0
  16. package/dist/chat-protocol/index.js.map +1 -0
  17. package/dist/chat-protocol/ip-normalize.d.ts +44 -0
  18. package/dist/chat-protocol/ip-normalize.d.ts.map +1 -0
  19. package/dist/chat-protocol/nats-decoder.d.ts +26 -0
  20. package/dist/chat-protocol/nats-decoder.d.ts.map +1 -0
  21. package/dist/{chunk-AJNDDXKC.js → chunk-4NWARV5I.js} +3 -3
  22. package/dist/{chunk-GQX6LFFA.cjs → chunk-5CJR2GZ4.cjs} +42 -42
  23. package/dist/{chunk-GQX6LFFA.cjs.map → chunk-5CJR2GZ4.cjs.map} +1 -1
  24. package/dist/{chunk-EUYIFALO.cjs → chunk-5EYRXSY5.cjs} +14 -14
  25. package/dist/{chunk-EUYIFALO.cjs.map → chunk-5EYRXSY5.cjs.map} +1 -1
  26. package/dist/{chunk-5RXKOGH6.cjs → chunk-5LMGFI4J.cjs} +7011 -4777
  27. package/dist/chunk-5LMGFI4J.cjs.map +1 -0
  28. package/dist/{chunk-KFQXO7QB.js → chunk-6GT557KN.js} +1 -1
  29. package/dist/chunk-6GT557KN.js.map +1 -0
  30. package/dist/{chunk-77DFPD35.js → chunk-BE6DK7QY.js} +12267 -10033
  31. package/dist/chunk-BE6DK7QY.js.map +1 -0
  32. package/dist/{chunk-A7IPON2V.cjs → chunk-BIKOJ4FH.cjs} +5 -5
  33. package/dist/{chunk-A7IPON2V.cjs.map → chunk-BIKOJ4FH.cjs.map} +1 -1
  34. package/dist/{chunk-HQYJINFV.js → chunk-CORIBKHG.js} +4 -4
  35. package/dist/{chunk-YEEHB4T6.cjs → chunk-CSYSYJCF.cjs} +40 -40
  36. package/dist/{chunk-YEEHB4T6.cjs.map → chunk-CSYSYJCF.cjs.map} +1 -1
  37. package/dist/{chunk-SIKCXKGB.cjs → chunk-DSDZ5T4P.cjs} +7 -7
  38. package/dist/{chunk-SIKCXKGB.cjs.map → chunk-DSDZ5T4P.cjs.map} +1 -1
  39. package/dist/{chunk-PNXNWGBY.js → chunk-GTWO5NAY.js} +2 -2
  40. package/dist/{chunk-SCCTJ2UI.js → chunk-HSACATCD.js} +4 -4
  41. package/dist/{chunk-PECGZWQR.cjs → chunk-KP5KRRD6.cjs} +3 -3
  42. package/dist/{chunk-PECGZWQR.cjs.map → chunk-KP5KRRD6.cjs.map} +1 -1
  43. package/dist/{chunk-2B6773QM.cjs → chunk-LILQJ3UZ.cjs} +1 -1
  44. package/dist/chunk-LILQJ3UZ.cjs.map +1 -0
  45. package/dist/{chunk-RZSA6MZL.cjs → chunk-NWX7MMF5.cjs} +15 -15
  46. package/dist/{chunk-RZSA6MZL.cjs.map → chunk-NWX7MMF5.cjs.map} +1 -1
  47. package/dist/{chunk-V3CNNMY3.cjs → chunk-P4WTZU6L.cjs} +18 -18
  48. package/dist/{chunk-V3CNNMY3.cjs.map → chunk-P4WTZU6L.cjs.map} +1 -1
  49. package/dist/{chunk-KLYHZRAD.js → chunk-PB3BTZDU.js} +2 -2
  50. package/dist/{chunk-FZOBQG6M.cjs → chunk-Q6E7VJ4W.cjs} +37 -36
  51. package/dist/chunk-Q6E7VJ4W.cjs.map +1 -0
  52. package/dist/{chunk-EYEIVYYF.cjs → chunk-QSCHCZL3.cjs} +7 -7
  53. package/dist/{chunk-EYEIVYYF.cjs.map → chunk-QSCHCZL3.cjs.map} +1 -1
  54. package/dist/{chunk-YJSKQ2ZV.js → chunk-RQLBHKXN.js} +3 -3
  55. package/dist/{chunk-CWQACI4Z.js → chunk-RW2LPL72.js} +2 -2
  56. package/dist/{chunk-DX6AOHNU.cjs → chunk-S2NHJDKQ.cjs} +77 -77
  57. package/dist/{chunk-DX6AOHNU.cjs.map → chunk-S2NHJDKQ.cjs.map} +1 -1
  58. package/dist/{chunk-AHDJMEEN.js → chunk-T2EGSZFN.js} +9 -8
  59. package/dist/chunk-T2EGSZFN.js.map +1 -0
  60. package/dist/{chunk-S52LJNMU.js → chunk-TKN5667N.js} +13 -13
  61. package/dist/chunk-TKN5667N.js.map +1 -0
  62. package/dist/{chunk-3BFX6HJM.js → chunk-UUYRJPGJ.js} +6 -6
  63. package/dist/{chunk-F6BYUO5U.js → chunk-VHG6AX5V.js} +3 -3
  64. package/dist/{chunk-FOQV6RCO.cjs → chunk-VQ523ORK.cjs} +29 -29
  65. package/dist/{chunk-FOQV6RCO.cjs.map → chunk-VQ523ORK.cjs.map} +1 -1
  66. package/dist/{chunk-PXF5J24T.js → chunk-XQ2B74SI.js} +2 -2
  67. package/dist/{chunk-H7JX2BG7.cjs → chunk-YHOIAA2E.cjs} +76 -76
  68. package/dist/chunk-YHOIAA2E.cjs.map +1 -0
  69. package/dist/{chunk-LLWBPPZ4.js → chunk-YZ2OIYQ3.js} +6 -6
  70. package/dist/components/case-studies/index.cjs +10 -10
  71. package/dist/components/case-studies/index.js +4 -4
  72. package/dist/components/chat/chat-message-enhanced.d.ts.map +1 -1
  73. package/dist/components/chat/chat-message-list.d.ts.map +1 -1
  74. package/dist/components/chat/hooks/index.d.ts +0 -1
  75. package/dist/components/chat/hooks/index.d.ts.map +1 -1
  76. package/dist/components/chat/hooks/use-chat-history-hydration.d.ts +47 -0
  77. package/dist/components/chat/hooks/use-chat-history-hydration.d.ts.map +1 -0
  78. package/dist/components/chat/hooks/use-chat.d.ts +1 -0
  79. package/dist/components/chat/hooks/use-chat.d.ts.map +1 -1
  80. package/dist/components/chat/hooks/use-nats-chat-adapter.d.ts +15 -48
  81. package/dist/components/chat/hooks/use-nats-chat-adapter.d.ts.map +1 -1
  82. package/dist/components/chat/hooks/use-sse-chat-adapter.d.ts +10 -53
  83. package/dist/components/chat/hooks/use-sse-chat-adapter.d.ts.map +1 -1
  84. package/dist/components/chat/index.cjs +12 -4
  85. package/dist/components/chat/index.cjs.map +1 -1
  86. package/dist/components/chat/index.d.ts +1 -0
  87. package/dist/components/chat/index.d.ts.map +1 -1
  88. package/dist/components/chat/index.js +23 -15
  89. package/dist/components/chat/stream/chat-dialog-store.d.ts +184 -0
  90. package/dist/components/chat/stream/chat-dialog-store.d.ts.map +1 -0
  91. package/dist/components/chat/stream/chat-stream-reducer.d.ts +367 -0
  92. package/dist/components/chat/stream/chat-stream-reducer.d.ts.map +1 -0
  93. package/dist/components/chat/stream/delta-batcher.d.ts +51 -0
  94. package/dist/components/chat/stream/delta-batcher.d.ts.map +1 -0
  95. package/dist/components/chat/stream/index.d.ts +17 -0
  96. package/dist/components/chat/stream/index.d.ts.map +1 -0
  97. package/dist/components/chat/stream/message-mutations.d.ts +77 -0
  98. package/dist/components/chat/stream/message-mutations.d.ts.map +1 -0
  99. package/dist/components/chat/stream/use-chat-stream-reducer.d.ts +22 -0
  100. package/dist/components/chat/stream/use-chat-stream-reducer.d.ts.map +1 -0
  101. package/dist/components/chat/types/api.types.d.ts +1 -81
  102. package/dist/components/chat/types/api.types.d.ts.map +1 -1
  103. package/dist/components/chat/types/processing.types.d.ts +2 -90
  104. package/dist/components/chat/types/processing.types.d.ts.map +1 -1
  105. package/dist/components/chat/types/unified-chat-state.types.d.ts +5 -0
  106. package/dist/components/chat/types/unified-chat-state.types.d.ts.map +1 -1
  107. package/dist/components/chat/utils/chat-conversation-storage.d.ts +34 -0
  108. package/dist/components/chat/utils/chat-conversation-storage.d.ts.map +1 -0
  109. package/dist/components/chat/utils/extract-incomplete-message-state.d.ts +34 -6
  110. package/dist/components/chat/utils/extract-incomplete-message-state.d.ts.map +1 -1
  111. package/dist/components/chat/utils/index.d.ts +2 -3
  112. package/dist/components/chat/utils/index.d.ts.map +1 -1
  113. package/dist/components/chat/utils/process-historical-messages.d.ts +59 -11
  114. package/dist/components/chat/utils/process-historical-messages.d.ts.map +1 -1
  115. package/dist/components/contact/index.cjs +5 -5
  116. package/dist/components/contact/index.js +4 -4
  117. package/dist/components/docs/index.cjs +7 -7
  118. package/dist/components/docs/index.js +6 -6
  119. package/dist/components/embeds/index.cjs +5 -5
  120. package/dist/components/embeds/index.js +4 -4
  121. package/dist/components/faq/index.cjs +6 -6
  122. package/dist/components/faq/index.js +5 -5
  123. package/dist/components/features/index.cjs +4 -4
  124. package/dist/components/features/index.js +3 -3
  125. package/dist/components/help-center-pages/index.cjs +23 -23
  126. package/dist/components/help-center-pages/index.js +14 -14
  127. package/dist/components/index.cjs +182 -140
  128. package/dist/components/index.cjs.map +1 -1
  129. package/dist/components/index.js +66 -24
  130. package/dist/components/index.js.map +1 -1
  131. package/dist/components/navigation/index.cjs +4 -4
  132. package/dist/components/navigation/index.js +3 -3
  133. package/dist/components/onboarding-guides/index.cjs +8 -8
  134. package/dist/components/onboarding-guides/index.js +7 -7
  135. package/dist/components/onboarding-guides/onboarding-guide-detail-view.d.ts.map +1 -1
  136. package/dist/components/related-content/index.cjs +6 -6
  137. package/dist/components/related-content/index.js +5 -5
  138. package/dist/components/shared/product-release/release-detail-page.d.ts.map +1 -1
  139. package/dist/components/tickets/index.cjs +7 -7
  140. package/dist/components/tickets/index.js +6 -6
  141. package/dist/components/ui/index.cjs +46 -4
  142. package/dist/components/ui/index.cjs.map +1 -1
  143. package/dist/components/ui/index.d.ts +1 -2
  144. package/dist/components/ui/index.d.ts.map +1 -1
  145. package/dist/components/ui/index.js +57 -15
  146. package/dist/components/ui/markdown/base-components.d.ts +51 -0
  147. package/dist/components/ui/markdown/base-components.d.ts.map +1 -0
  148. package/dist/components/ui/markdown/engine.d.ts +99 -0
  149. package/dist/components/ui/markdown/engine.d.ts.map +1 -0
  150. package/dist/components/ui/markdown/heading-ids.d.ts +65 -0
  151. package/dist/components/ui/markdown/heading-ids.d.ts.map +1 -0
  152. package/dist/components/ui/markdown/index.d.ts +18 -0
  153. package/dist/components/ui/markdown/index.d.ts.map +1 -0
  154. package/dist/components/ui/markdown/mermaid-diagram.d.ts +79 -0
  155. package/dist/components/ui/markdown/mermaid-diagram.d.ts.map +1 -0
  156. package/dist/components/ui/markdown/rich/embed-overrides.d.ts +14 -0
  157. package/dist/components/ui/markdown/rich/embed-overrides.d.ts.map +1 -0
  158. package/dist/components/ui/markdown/rich/rich-markdown-renderer.d.ts +40 -0
  159. package/dist/components/ui/markdown/rich/rich-markdown-renderer.d.ts.map +1 -0
  160. package/dist/components/ui/markdown/rich/shortcodes.d.ts +8 -0
  161. package/dist/components/ui/markdown/rich/shortcodes.d.ts.map +1 -0
  162. package/dist/components/ui/markdown/sanitize.d.ts +153 -0
  163. package/dist/components/ui/markdown/sanitize.d.ts.map +1 -0
  164. package/dist/components/ui/markdown/simple-markdown-renderer.d.ts +23 -0
  165. package/dist/components/ui/markdown/simple-markdown-renderer.d.ts.map +1 -0
  166. package/dist/components/ui/markdown/streaming.d.ts +78 -0
  167. package/dist/components/ui/markdown/streaming.d.ts.map +1 -0
  168. package/dist/components/ui/markdown/text-size.d.ts +34 -0
  169. package/dist/components/ui/markdown/text-size.d.ts.map +1 -0
  170. package/dist/contexts/chat-runtime-context.d.ts +8 -0
  171. package/dist/contexts/chat-runtime-context.d.ts.map +1 -1
  172. package/dist/contexts/index.cjs +2 -2
  173. package/dist/contexts/index.js +1 -1
  174. package/dist/hooks/index.cjs +3 -3
  175. package/dist/hooks/index.js +2 -2
  176. package/dist/index.cjs +62 -4
  177. package/dist/index.cjs.map +1 -1
  178. package/dist/index.js +73 -15
  179. package/dist/utils/index.cjs +169 -40
  180. package/dist/utils/index.cjs.map +1 -1
  181. package/dist/utils/index.d.ts +1 -0
  182. package/dist/utils/index.d.ts.map +1 -1
  183. package/dist/utils/index.js +162 -41
  184. package/dist/utils/index.js.map +1 -1
  185. package/dist/utils/markdown-fences.d.ts +42 -0
  186. package/dist/utils/markdown-fences.d.ts.map +1 -0
  187. package/dist/utils/markdown-heading-id.d.ts +85 -0
  188. package/dist/utils/markdown-heading-id.d.ts.map +1 -0
  189. package/dist/utils/markdown-section-extractor.d.ts.map +1 -1
  190. package/package.json +7 -1
  191. package/src/chat-protocol/__tests__/__snapshots__/nats-decoder-golden.test.ts.snap +307 -0
  192. package/src/chat-protocol/__tests__/chat-protocol.test.ts +516 -0
  193. package/src/chat-protocol/__tests__/env-flag.test.ts +52 -0
  194. package/src/chat-protocol/__tests__/ip-normalize.test.ts +136 -0
  195. package/src/chat-protocol/__tests__/nats-decoder-golden.test.ts +228 -0
  196. package/src/chat-protocol/decode.ts +278 -0
  197. package/src/chat-protocol/encode.ts +71 -0
  198. package/src/chat-protocol/env-flag.ts +39 -0
  199. package/src/chat-protocol/events.ts +232 -0
  200. package/src/chat-protocol/frames.ts +245 -0
  201. package/src/chat-protocol/index.ts +21 -0
  202. package/src/chat-protocol/ip-normalize.ts +146 -0
  203. package/src/chat-protocol/nats-decoder.ts +252 -0
  204. package/src/components/chat/__tests__/chat-message-list.test.tsx +293 -5
  205. package/src/components/chat/__tests__/chat-message-streaming-memo.test.tsx +207 -0
  206. package/src/components/chat/__tests__/chat-pending-turn.test.tsx +209 -0
  207. package/src/components/chat/chat-message-enhanced.tsx +111 -17
  208. package/src/components/chat/chat-message-list.tsx +358 -109
  209. package/src/components/chat/embeddable-chat.tsx +6 -0
  210. package/src/components/chat/hooks/.index.md +30 -33
  211. package/src/components/chat/hooks/.use-nats-chat-adapter.md +36 -55
  212. package/src/components/chat/hooks/__tests__/__snapshots__/conversation-id-persistence-golden.test.ts.snap +37 -0
  213. package/src/components/chat/hooks/__tests__/__snapshots__/sse-stream-golden.test.ts.snap +0 -0
  214. package/src/components/chat/hooks/__tests__/conversation-id-persistence-golden.test.ts +438 -0
  215. package/src/components/chat/hooks/__tests__/sse-stream-golden.test.ts +318 -0
  216. package/src/components/chat/hooks/index.ts +0 -1
  217. package/src/components/chat/hooks/use-chat-history-hydration.ts +142 -0
  218. package/src/components/chat/hooks/use-chat.ts +20 -0
  219. package/src/components/chat/hooks/use-nats-chat-adapter.ts +243 -823
  220. package/src/components/chat/hooks/use-sse-chat-adapter.ts +417 -774
  221. package/src/components/chat/index.ts +5 -0
  222. package/src/components/chat/stream/__tests__/__snapshots__/chat-stream-reducer-golden.test.ts.snap +940 -0
  223. package/src/components/chat/stream/__tests__/chat-dialog-store.test.ts +756 -0
  224. package/src/components/chat/stream/__tests__/chat-stream-reducer-golden.test.ts +460 -0
  225. package/src/components/chat/stream/__tests__/chat-stream-reducer.test.ts +894 -0
  226. package/src/components/chat/stream/__tests__/delta-batcher.test.ts +255 -0
  227. package/src/components/chat/stream/__tests__/use-chat-stream-reducer.test.ts +98 -0
  228. package/src/components/chat/stream/chat-dialog-store.ts +536 -0
  229. package/src/components/chat/stream/chat-stream-reducer.ts +1796 -0
  230. package/src/components/chat/stream/delta-batcher.ts +159 -0
  231. package/src/components/chat/stream/index.ts +55 -0
  232. package/src/components/chat/stream/message-mutations.ts +338 -0
  233. package/src/components/chat/stream/use-chat-stream-reducer.ts +126 -0
  234. package/src/components/chat/thinking-display.tsx +1 -1
  235. package/src/components/chat/types/.api.types.md +39 -49
  236. package/src/components/chat/types/.processing.types.md +29 -58
  237. package/src/components/chat/types/api.types.ts +6 -73
  238. package/src/components/chat/types/processing.types.ts +11 -52
  239. package/src/components/chat/types/unified-chat-state.types.ts +6 -0
  240. package/src/components/chat/utils/.index.md +31 -40
  241. package/src/components/chat/utils/__tests__/__snapshots__/process-historical-messages-golden.test.ts.snap +420 -0
  242. package/src/components/chat/utils/__tests__/__snapshots__/segment-accumulator-golden.test.ts.snap +605 -0
  243. package/src/components/chat/utils/__tests__/process-historical-messages-golden.test.ts +317 -0
  244. package/src/components/chat/utils/__tests__/segment-accumulator-golden.test.ts +270 -0
  245. package/src/components/chat/utils/chat-conversation-storage.ts +83 -0
  246. package/src/components/chat/utils/extract-incomplete-message-state.ts +63 -5
  247. package/src/components/chat/utils/index.ts +5 -8
  248. package/src/components/chat/utils/process-historical-messages.ts +352 -384
  249. package/src/components/onboarding-guides/onboarding-guide-detail-view.tsx +22 -4
  250. package/src/components/shared/legal-document/legal-document-page.tsx +1 -1
  251. package/src/components/shared/product-release/release-detail-page.tsx +19 -4
  252. package/src/components/ui/__tests__/__snapshots__/markdown-parity.test.tsx.snap +2420 -0
  253. package/src/components/ui/__tests__/markdown-parity.test.tsx +2528 -0
  254. package/src/components/ui/index.ts +1 -2
  255. package/src/components/ui/markdown/__tests__/mermaid-security.test.ts +115 -0
  256. package/src/components/ui/markdown/__tests__/mermaid-stale-render.test.tsx +95 -0
  257. package/src/components/ui/markdown/__tests__/sanitize-invariant.test.ts +309 -0
  258. package/src/components/ui/markdown/__tests__/streaming.test.tsx +388 -0
  259. package/src/components/ui/markdown/base-components.tsx +360 -0
  260. package/src/components/ui/markdown/engine.tsx +315 -0
  261. package/src/components/ui/markdown/heading-ids.ts +239 -0
  262. package/src/components/ui/markdown/index.ts +49 -0
  263. package/src/components/ui/markdown/mermaid-diagram.tsx +291 -0
  264. package/src/components/ui/markdown/rich/embed-overrides.tsx +199 -0
  265. package/src/components/ui/markdown/rich/rich-markdown-renderer.tsx +184 -0
  266. package/src/components/ui/markdown/rich/shortcodes.ts +170 -0
  267. package/src/components/ui/markdown/sanitize.ts +3499 -0
  268. package/src/components/ui/markdown/simple-markdown-renderer.tsx +28 -0
  269. package/src/components/ui/markdown/streaming.ts +362 -0
  270. package/src/components/ui/markdown/text-size.ts +106 -0
  271. package/src/components/ui/release-changelog-section.tsx +1 -1
  272. package/src/components/ui/ticket-info-section.tsx +1 -1
  273. package/src/contexts/chat-runtime-context.tsx +8 -0
  274. package/src/utils/index.ts +1 -0
  275. package/src/utils/markdown-fences.ts +127 -0
  276. package/src/utils/markdown-heading-id.ts +348 -0
  277. package/src/utils/markdown-section-extractor.ts +41 -56
  278. package/dist/chunk-2B6773QM.cjs.map +0 -1
  279. package/dist/chunk-5RXKOGH6.cjs.map +0 -1
  280. package/dist/chunk-77DFPD35.js.map +0 -1
  281. package/dist/chunk-AHDJMEEN.js.map +0 -1
  282. package/dist/chunk-FZOBQG6M.cjs.map +0 -1
  283. package/dist/chunk-H7JX2BG7.cjs.map +0 -1
  284. package/dist/chunk-KFQXO7QB.js.map +0 -1
  285. package/dist/chunk-S52LJNMU.js.map +0 -1
  286. package/dist/components/chat/hooks/use-realtime-chunk-processor.d.ts +0 -6
  287. package/dist/components/chat/hooks/use-realtime-chunk-processor.d.ts.map +0 -1
  288. package/dist/components/chat/utils/chunk-parser.d.ts +0 -25
  289. package/dist/components/chat/utils/chunk-parser.d.ts.map +0 -1
  290. package/dist/components/ui/rich-markdown-renderer.d.ts +0 -34
  291. package/dist/components/ui/rich-markdown-renderer.d.ts.map +0 -1
  292. package/dist/components/ui/simple-markdown-renderer.d.ts +0 -73
  293. package/dist/components/ui/simple-markdown-renderer.d.ts.map +0 -1
  294. package/src/components/chat/hooks/.use-realtime-chunk-processor.md +0 -77
  295. package/src/components/chat/hooks/use-realtime-chunk-processor.ts +0 -479
  296. package/src/components/chat/utils/.chunk-parser.md +0 -62
  297. package/src/components/chat/utils/chunk-parser.ts +0 -256
  298. package/src/components/ui/.simple-markdown-renderer.md +0 -52
  299. package/src/components/ui/rich-markdown-renderer.tsx +0 -1223
  300. package/src/components/ui/simple-markdown-renderer.tsx +0 -964
  301. /package/dist/{chunk-AJNDDXKC.js.map → chunk-4NWARV5I.js.map} +0 -0
  302. /package/dist/{chunk-HQYJINFV.js.map → chunk-CORIBKHG.js.map} +0 -0
  303. /package/dist/{chunk-PNXNWGBY.js.map → chunk-GTWO5NAY.js.map} +0 -0
  304. /package/dist/{chunk-SCCTJ2UI.js.map → chunk-HSACATCD.js.map} +0 -0
  305. /package/dist/{chunk-KLYHZRAD.js.map → chunk-PB3BTZDU.js.map} +0 -0
  306. /package/dist/{chunk-YJSKQ2ZV.js.map → chunk-RQLBHKXN.js.map} +0 -0
  307. /package/dist/{chunk-CWQACI4Z.js.map → chunk-RW2LPL72.js.map} +0 -0
  308. /package/dist/{chunk-3BFX6HJM.js.map → chunk-UUYRJPGJ.js.map} +0 -0
  309. /package/dist/{chunk-F6BYUO5U.js.map → chunk-VHG6AX5V.js.map} +0 -0
  310. /package/dist/{chunk-PXF5J24T.js.map → chunk-XQ2B74SI.js.map} +0 -0
  311. /package/dist/{chunk-LLWBPPZ4.js.map → chunk-YZ2OIYQ3.js.map} +0 -0
@@ -12,11 +12,11 @@
12
12
  * ┌──────────────────────────────────────────────────────────────┐
13
13
  * │ useNatsDialogSubscription live tail of agent events │
14
14
  * │ ↓ │
15
- * │ onEvent → processChunk
15
+ * │ onEvent → decodeNatsChunk → ChatStreamEvent
16
16
  * │ ↓ │
17
- * │ useRealtimeChunkProcessor chunk MessageSegment[]
18
- * │
19
- * │ onSegmentsUpdate updates assistant message in state
17
+ * │ createChatStreamReducer THE master accumulation path
18
+ * │ (via ChatDialogStore + useChatStreamReducer wrapper —
19
+ * │ messages, phase, token usage, approvals all live there)
20
20
  * │ │
21
21
  * │ useChunkCatchup back-fill missed chunks after │
22
22
  * │ activation / reconnect │
@@ -24,53 +24,38 @@
24
24
  * │ config.publishUserMessage consumer-owned send (HTTP/NATS) │
25
25
  * └──────────────────────────────────────────────────────────────┘
26
26
  *
27
+ * Phase 3 of the chat unification: the adapter's local message-mutation
28
+ * layer (`updateTrailingAssistant` / `appendToTrailingAssistant` /
29
+ * `upsertTrailingCompaction` / `applyToolExecutionToMessages` and the
30
+ * chunk-callback body that drove them) is DELETED — those semantics live in
31
+ * `../stream/chat-stream-reducer` + `../stream/message-mutations` now. The
32
+ * adapter owns only what is genuinely transport/host wiring: dialog-list
33
+ * management, history fetch, catchup orchestration, the NATS subscription,
34
+ * and the publish path.
35
+ *
27
36
  * Two operating modes, distinguished by the presence of `fetchDialogs`:
28
37
  *
29
38
  * 1. **Bare-transport mode** (current Tauri Fae Chat usage):
30
39
  * consumer supplies `config.dialogId` explicitly and owns dialog
31
- * management above the adapter. The adapter does no list/select
32
- * bookkeeping — every dialog field on the return value falls back
33
- * to an empty default. This is the original v0 API; preserved
34
- * byte-compatible so existing pinned consumers keep working.
40
+ * management above the adapter.
35
41
  *
36
42
  * 2. **Managed-dialog mode** (openframe-frontend, EmbeddableChat
37
- * sidebar): consumer supplies `fetchDialogs`,
38
- * `fetchDialogMessages`, `createDialog`, etc. and the adapter
39
- * owns the dialog state machine. The active dialog id, the list,
40
- * pagination cursors, token usage hydration, and history merge
41
- * all live inside this hook. The host renders the sidebar via
42
- * `EmbeddableChat`'s built-in `<ChatSidebar>` slot and calls
43
- * `selectDialog` / `startNewDialog` from there.
44
- *
45
- * The two modes are NOT mutually exclusive — passing `config.dialogId`
46
- * alongside `fetchDialogs` forces the adapter into controlled mode (the
47
- * external id wins) while still using the host's list fetchers for the
48
- * sidebar.
49
- *
50
- * Why publish is consumer-owned: the NATS module exposes a low-level
51
- * `publishBytes/String/Json` but the actual user-message endpoint varies
52
- * by deployment (REST POST in some, NATS subject in others). The adapter
53
- * stays agnostic — caller wires up "user typed something, do X" via
54
- * the `publishUserMessage` callback.
43
+ * sidebar): consumer supplies `fetchDialogs`, `fetchDialogMessages`,
44
+ * `createDialog`, etc. and the adapter owns the dialog state machine.
55
45
  *
56
46
  * The `active` option gates the live subscription so the unified chat
57
47
  * shell can keep both adapters mounted while only paying network cost
58
48
  * for the currently-displayed mode.
59
49
  */
60
50
 
61
- import {
62
- useCallback,
63
- useEffect,
64
- useMemo,
65
- useRef,
66
- useState,
67
- type MutableRefObject,
68
- } from 'react'
51
+ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
69
52
  import { useNatsDialogSubscription } from './use-nats-dialog-subscription'
70
- import { useRealtimeChunkProcessor } from './use-realtime-chunk-processor'
71
53
  import { useChunkCatchup } from './use-chunk-catchup'
54
+ import { decodeNatsChunk } from '../../../chat-protocol/nats-decoder'
55
+ import { createChatDialogStore, DEFAULT_DIALOG_SIDE } from '../stream/chat-dialog-store'
56
+ import { useChatStreamReducer } from '../stream/use-chat-stream-reducer'
72
57
  import { processHistoricalMessagesWithErrors } from '../utils/process-historical-messages'
73
- import { extractIncompleteMessageState } from '../utils/extract-incomplete-message-state'
58
+ import { extractIncompleteTailState } from '../utils/extract-incomplete-message-state'
74
59
  import type {
75
60
  ChunkData,
76
61
  FetchChunksFunction,
@@ -78,11 +63,7 @@ import type {
78
63
  MessageProcessingOptions,
79
64
  MessageSegment,
80
65
  NatsMessageType,
81
- StreamingPhase,
82
66
  ChatApprovalStatus,
83
- SegmentsUpdateMetadata,
84
- ToolExecutionSegment,
85
- ApprovalBatchExecutionState,
86
67
  } from '../types'
87
68
  import type {
88
69
  ChatConnectionState,
@@ -94,6 +75,16 @@ import type {
94
75
  import type { DialogItem } from '../types/component.types'
95
76
  import type { ChatRef } from '../chat-ref.types'
96
77
 
78
+ // Legacy public home of the pure message-mutation helpers ("exported for
79
+ // host reuse"). Canonical implementation moved to the stream module in
80
+ // Phase 3 — these re-exports keep the import path stable until Phase 4
81
+ // migrates consumers.
82
+ export {
83
+ appendToTrailingAssistant,
84
+ applyToolExecutionToMessages,
85
+ upsertTrailingCompaction,
86
+ } from '../stream/message-mutations'
87
+
97
88
  // =============================================================================
98
89
  // Config + options
99
90
  // =============================================================================
@@ -210,19 +201,19 @@ export interface UseNatsChatAdapterConfig {
210
201
  topics?: NatsMessageType[]
211
202
 
212
203
  /**
213
- * Mirrors `UseRealtimeChunkProcessorOptions.batchApprovalsEnabled`.
214
- * Default `true` — single batch card per APPROVAL_REQUEST with
215
- * `toolCalls[]`. Set `false` to fall back to legacy per-tool cards.
204
+ * Mirrors the reducer's `batchApprovalsEnabled`. Default `true` —
205
+ * single batch card per APPROVAL_REQUEST with `toolCalls[]`. Set
206
+ * `false` to fall back to legacy per-tool cards.
216
207
  */
217
208
  batchApprovalsEnabled?: boolean
218
209
 
219
210
  /**
220
- * Approval types rendered as actionable cards inline. Mirrors
221
- * `UseRealtimeChunkProcessorOptions.displayApprovalTypes` (default
222
- * `['CLIENT']`) and is forwarded to the history processor so both paths
223
- * agree. Hosts whose backend emits other types (e.g. `USER`) MUST set this
224
- * — otherwise those approvals are escalated to a callback this adapter
225
- * doesn't surface and the card never renders.
211
+ * Approval types rendered as actionable cards inline. Mirrors the
212
+ * reducer's `displayApprovalTypes` (default `['CLIENT']`) and is
213
+ * forwarded to the history processor so both paths agree. Hosts whose
214
+ * backend emits other types (e.g. `USER`) MUST set this — otherwise
215
+ * those approvals are escalated to a callback this adapter doesn't
216
+ * surface and the card never renders.
226
217
  */
227
218
  displayApprovalTypes?: string[]
228
219
 
@@ -245,7 +236,7 @@ export interface UseNatsChatAdapterConfig {
245
236
  * Messages must arrive in the same wire shape the openframe backend
246
237
  * emits (HistoricalMessage with messageData[]); the adapter feeds
247
238
  * them through `processHistoricalMessagesWithErrors` to produce
248
- * accumulator-compatible segments.
239
+ * reducer-compatible messages.
249
240
  */
250
241
  fetchDialogMessages?: (
251
242
  params: FetchDialogMessagesParams,
@@ -283,9 +274,9 @@ export interface UseNatsChatAdapterConfig {
283
274
  unarchiveDialog?: (dialogId: string) => Promise<void>
284
275
 
285
276
  /**
286
- * Approve a pending tool-call request. Wired into the segment
287
- * accumulator's `onApprove` so approval-card buttons fire this
288
- * directly. When omitted, approval cards render disabled buttons.
277
+ * Approve a pending tool-call request. Wired into the reducer's
278
+ * approval-card callbacks so card buttons fire this directly. When
279
+ * omitted, approval cards render disabled buttons.
289
280
  */
290
281
  approveRequest?: (requestId: string) => Promise<void>
291
282
 
@@ -344,262 +335,9 @@ export interface UseNatsChatAdapterOptions {
344
335
  }
345
336
 
346
337
  // =============================================================================
347
- // Internal helpers
338
+ // Historical-message mapping helpers (host-facing)
348
339
  // =============================================================================
349
340
 
350
- function nextId(role: 'user' | 'assistant'): string {
351
- // Date.now() + counter sliver keeps ids monotonic even when two
352
- // messages are produced inside the same ms tick (user + assistant
353
- // placeholder fire back-to-back from a single sendMessage call).
354
- return `${role}-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`
355
- }
356
-
357
- /**
358
- * Replace (or append) the trailing assistant message with the latest
359
- * accumulated segments. Mirrors the use-chat.ts pattern so render
360
- * behaviour matches the SSE adapter exactly.
361
- */
362
- function updateTrailingAssistant(
363
- prev: UnifiedChatMessage[],
364
- segments: MessageSegment[],
365
- ): UnifiedChatMessage[] {
366
- const last = prev[prev.length - 1]
367
- if (!last || last.role !== 'assistant') {
368
- // No placeholder exists — append a fresh assistant message.
369
- return [
370
- ...prev,
371
- {
372
- id: nextId('assistant'),
373
- role: 'assistant',
374
- content: '',
375
- segments,
376
- },
377
- ]
378
- }
379
- return [
380
- ...prev.slice(0, -1),
381
- { ...last, segments },
382
- ]
383
- }
384
-
385
- /**
386
- * Realtime user/direct/system chunks can be replays of rows already on
387
- * screen (our own optimistic send echoed back, catchup replaying the tail
388
- * over just-loaded history, reconnect back-fill).
389
- *
390
- * Dedup layers, in priority order:
391
- * 1. `streamSeq` identity (JetStream) — exact, never confuses a genuinely
392
- * repeated text with a replay; tracked in a per-dialog seen-set.
393
- * 2. One-shot optimistic-echo consumption — `sendMessage` records the text
394
- * it optimistically rendered; the backend's MESSAGE_REQUEST echo removes
395
- * exactly one entry.
396
- * 3. Content match over a SHORT same-author tail window — only for
397
- * seq-less transports (plain NATS replay over just-loaded history, where
398
- * the persisted twin sits at most a couple of rows up). Same-author so
399
- * an admin message can never suppress a user's identical text.
400
- */
401
- const CONTENT_DEDUP_WINDOW = 4
402
- const SYSTEM_DEDUP_WINDOW = 10
403
-
404
- function hasRecentMessage(
405
- prev: UnifiedChatMessage[],
406
- predicate: (message: UnifiedChatMessage) => boolean,
407
- window: number,
408
- ): boolean {
409
- const start = Math.max(0, prev.length - window)
410
- for (let i = prev.length - 1; i >= start; i--) {
411
- if (predicate(prev[i])) return true
412
- }
413
- return false
414
- }
415
-
416
- /**
417
- * Append-mode counterpart of `updateTrailingAssistant` for post-MESSAGE_END
418
- * continuation chunks (`SegmentsUpdateMetadata.append`). The processor emits
419
- * single text/thinking fragments there — replacing the bubble with them (the
420
- * old behaviour, which dropped the metadata) wiped everything the bubble
421
- * already showed. Coalesces trailing fragments of the same type, mirroring
422
- * the accumulator.
423
- *
424
- * Exported for host reuse (custom stores wiring the same processor) + tests.
425
- */
426
- export function appendToTrailingAssistant(
427
- prev: UnifiedChatMessage[],
428
- segments: MessageSegment[],
429
- ): UnifiedChatMessage[] {
430
- if (segments.length === 0) return prev
431
- const last = prev[prev.length - 1]
432
- if (!last || last.role !== 'assistant') {
433
- return [
434
- ...prev,
435
- { id: nextId('assistant'), role: 'assistant', content: '', segments },
436
- ]
437
- }
438
- const merged = [...(last.segments ?? [])]
439
- for (const seg of segments) {
440
- const tail = merged[merged.length - 1]
441
- if (seg.type === 'text' && tail?.type === 'text') {
442
- merged[merged.length - 1] = { type: 'text', text: tail.text + seg.text }
443
- } else if (seg.type === 'thinking' && tail?.type === 'thinking') {
444
- merged[merged.length - 1] = { type: 'thinking', text: tail.text + seg.text }
445
- } else if (seg.type === 'approval_batch') {
446
- // Approval deltas must be IDEMPOTENT: the escalated-result emit can be
447
- // seen twice (live + catch-up replay over hydrated history), so upsert
448
- // by request id instead of raw-appending a duplicate card.
449
- const idx = merged.findIndex(
450
- (m) => m.type === 'approval_batch' && m.data.approvalRequestId === seg.data.approvalRequestId,
451
- )
452
- if (idx !== -1) merged[idx] = seg
453
- else merged.push(seg)
454
- } else if (seg.type === 'approval_request') {
455
- // Legacy cards share one requestId across an unfolded batch — key on
456
- // (requestId, command) so each tool's card upserts its own twin.
457
- const idx = merged.findIndex(
458
- (m) =>
459
- m.type === 'approval_request' &&
460
- m.data.requestId === seg.data.requestId &&
461
- m.data.command === seg.data.command,
462
- )
463
- if (idx !== -1) merged[idx] = seg
464
- else merged.push(seg)
465
- } else {
466
- // Other non-text segments are pushed RAW on purpose: EXECUTING↔EXECUTED
467
- // pairing and batch merging are `applyToolExecutionToMessages`'s job
468
- // (post-END tool chunks never reach this helper — the processor routes
469
- // them to onToolExecuted). Running the accumulator here would
470
- // double-apply those rules.
471
- merged.push(seg)
472
- }
473
- }
474
- return [...prev.slice(0, -1), { ...last, segments: merged }]
475
- }
476
-
477
- /**
478
- * Upsert a standalone context-compaction segment into the trailing assistant
479
- * bubble. Compaction emissions arrive as the accumulator's CUMULATIVE array —
480
- * only the compaction segment itself may be applied, or interleaved
481
- * continuation text would duplicate. A `completed` segment replaces the last
482
- * `started` one in place.
483
- *
484
- * Exported for host reuse + tests.
485
- */
486
- export function upsertTrailingCompaction(
487
- prev: UnifiedChatMessage[],
488
- segments: MessageSegment[],
489
- ): UnifiedChatMessage[] {
490
- const compaction = [...segments].reverse().find((s) => s.type === 'context_compaction')
491
- if (!compaction) return prev
492
- const last = prev[prev.length - 1]
493
- if (!last || last.role !== 'assistant') {
494
- return [
495
- ...prev,
496
- { id: nextId('assistant'), role: 'assistant', content: '', segments: [compaction] },
497
- ]
498
- }
499
- const existing = last.segments ?? []
500
- // LAST 'started' segment (not first): with repeated compactions in one
501
- // bubble the earlier ones are already completed-in-place, so the newest
502
- // 'started' is the only one this completion can belong to.
503
- const startedIdx = existing.map((s) => s.type === 'context_compaction' && s.status === 'started').lastIndexOf(true)
504
- const merged =
505
- startedIdx !== -1
506
- ? existing.map((s, i) => (i === startedIdx ? compaction : s))
507
- : [...existing, compaction]
508
- return [...prev.slice(0, -1), { ...last, segments: merged }]
509
- }
510
-
511
- /**
512
- * Cross-message tool-execution updater for post-MESSAGE_END tool chunks
513
- * (approved commands executing after the approval bubble, async batch
514
- * results). Scans messages from the end:
515
- * 1) an `approval_batch` whose `toolCalls` contains the execution id →
516
- * merge into its `executions` map;
517
- * 2) a matching `tool_execution` segment (same id, or EXECUTING with the
518
- * same tool for legacy id-less backends) → update in place;
519
- * 3) no match → append the segment to the trailing assistant bubble.
520
- *
521
- * Exported for host reuse + tests.
522
- */
523
- export function applyToolExecutionToMessages(
524
- prev: UnifiedChatMessage[],
525
- segment: ToolExecutionSegment,
526
- ): UnifiedChatMessage[] {
527
- const toolData = segment.data
528
- const execId = toolData.toolExecutionRequestId
529
-
530
- for (let i = prev.length - 1; i >= 0; i--) {
531
- const message = prev[i]
532
- if (message.role !== 'assistant' || !message.segments) continue
533
-
534
- for (let j = message.segments.length - 1; j >= 0; j--) {
535
- const seg = message.segments[j]
536
-
537
- if (
538
- execId &&
539
- seg.type === 'approval_batch' &&
540
- seg.data.toolCalls.some((c) => c.toolExecutionRequestId === execId)
541
- ) {
542
- const prevExec: ApprovalBatchExecutionState | undefined = seg.data.executions?.[execId]
543
- // Never downgrade a done batch slot back to executing (JetStream
544
- // redelivery of the EXECUTING chunk after EXECUTED landed). Returning
545
- // prev = matched-with-no-change, so the caller doesn't append either.
546
- if (toolData.type === 'EXECUTING_TOOL' && prevExec?.status === 'done') {
547
- return prev
548
- }
549
- const nextExec: ApprovalBatchExecutionState =
550
- toolData.type === 'EXECUTED_TOOL'
551
- ? { status: 'done', result: toolData.result, success: toolData.success }
552
- : { status: 'executing', result: prevExec?.result, success: prevExec?.success }
553
- const nextSegments = [...message.segments]
554
- nextSegments[j] = {
555
- ...seg,
556
- data: { ...seg.data, executions: { ...(seg.data.executions ?? {}), [execId]: nextExec } },
557
- }
558
- const next = [...prev]
559
- next[i] = { ...message, segments: nextSegments }
560
- return next
561
- }
562
-
563
- if (seg.type === 'tool_execution') {
564
- // KNOWN LIMIT (id-less chunks only — current backends always send
565
- // toolExecutionRequestId): the fuzzy fallback pairs only with a
566
- // segment still EXECUTING, so a replayed id-less EXECUTING/EXECUTED
567
- // arriving after the pair completed matches nothing and falls to the
568
- // append below (duplicate card). Widening the predicate to EXECUTED
569
- // twins would instead swallow a LEGITIMATE second run of the same
570
- // tool — without ids the two are indistinguishable, and losing a
571
- // real run is worse than a duplicate card on a legacy transport.
572
- const matches = execId
573
- ? seg.data.toolExecutionRequestId === execId
574
- : seg.data.type === 'EXECUTING_TOOL' &&
575
- seg.data.integratedToolType === toolData.integratedToolType &&
576
- seg.data.toolFunction === toolData.toolFunction
577
- if (!matches) continue
578
- // Never downgrade a completed segment back to EXECUTING (replayed
579
- // EXECUTING chunk after its EXECUTED already landed).
580
- if (toolData.type === 'EXECUTING_TOOL' && seg.data.type === 'EXECUTED_TOOL') {
581
- return prev
582
- }
583
- const nextSegments = [...message.segments]
584
- nextSegments[j] = {
585
- type: 'tool_execution',
586
- data: {
587
- ...toolData,
588
- toolTitle: toolData.toolTitle ?? seg.data.toolTitle,
589
- toolExplanation: toolData.toolExplanation ?? seg.data.toolExplanation,
590
- parameters: toolData.parameters || seg.data.parameters,
591
- },
592
- }
593
- const next = [...prev]
594
- next[i] = { ...message, segments: nextSegments }
595
- return next
596
- }
597
- }
598
- }
599
-
600
- return appendToTrailingAssistant(prev, [segment])
601
- }
602
-
603
341
  /**
604
342
  * Map `ProcessedMessage` (lib's historical-message format) into
605
343
  * `UnifiedChatMessage` (the unified-chat-state contract). Only `user`
@@ -669,6 +407,9 @@ export function historicalToUnified(
669
407
  // Hook
670
408
  // =============================================================================
671
409
 
410
+ /** Store key for the draft ("no dialog selected") state. */
411
+ const DRAFT_DIALOG_KEY = '__draft__'
412
+
672
413
  export function useNatsChatAdapter(
673
414
  config: UseNatsChatAdapterConfig,
674
415
  options: UseNatsChatAdapterOptions = {},
@@ -724,56 +465,109 @@ export function useNatsChatAdapter(
724
465
  const currentDialogIdRef = useRef<string | null>(dialogId)
725
466
  currentDialogIdRef.current = dialogId
726
467
 
727
- // ─── Message thread + streaming phase ─────────────────────────────────────
728
-
729
- const [messages, setMessages] = useState<UnifiedChatMessage[]>([])
730
- const [streamingPhase, setStreamingPhase] = useState<StreamingPhase>('idle')
731
- // Live mirror for click handlers that must read the CURRENT phase without
732
- // re-creating on every phase change (handleApprove's scoped revert).
733
- const streamingPhaseRef = useRef(streamingPhase)
734
- streamingPhaseRef.current = streamingPhase
735
- // Gate: suppress onAgentBusy while the initial catchup replays historical
736
- // chunks. A dead tail (approval approved / tool started, then Stop or a
737
- // crash — no continuation ever persisted) replays its APPROVAL_RESULT /
738
- // EXECUTING_TOOL chunks on every dialog (re)open, and the releasing
739
- // MESSAGE_END never replays — locking the composer forever. Live chunks
740
- // after catchup lock normally; a reopened mid-execution dialog re-locks on
741
- // the next live chunk instead.
742
- // A COUNTER, not a boolean: the initial-catchup window and a reconnect
743
- // back-fill window can overlap (reconnect during the initial fetch), and
744
- // with a boolean whichever finished FIRST dropped the other's suppression
745
- // mid-replay. Each window increments on entry / decrements in its finally.
746
- const suppressAgentBusyRef = useRef(0)
747
-
748
- // True when the trailing assistant loaded from history is an INCOMPLETE
749
- // turn (mid-stream / mid-approval tail). The catchup replay re-streams that
750
- // turn from its MESSAGE_START, and the replayed stream must ADOPT (replace)
751
- // the partial history bubble. Any other MESSAGE_START over a non-empty
752
- // trailing assistant is a NEW turn (observer / second device / post-approval
753
- // continuation) and must open a fresh bubble instead of overwriting the
754
- // completed one. Consumed (reset) by the first MESSAGE_START.
755
- const adoptTrailingAssistantRef = useRef(false)
468
+ // ─── Approval handlers ────────────────────────────────────────────────────
469
+ // Defined before the reducer so the reducer's create-options can wire the
470
+ // approval-card buttons. Optimistically flip status before the network
471
+ // round-trip so the card reflects the user's choice immediately.
756
472
 
757
- // Set after the first successful NATS connect. Later 'connected' events are
758
- // RECONNECTS: plain NATS replays nothing, so the adapter must back-fill the
759
- // disconnect gap via `resetAndCatchUp` or those chunks are lost forever.
760
- const hasConnectedOnceRef = useRef(false)
473
+ const handleApproveRef = useRef<(requestId: string) => Promise<void>>(async () => {})
474
+ const handleRejectRef = useRef<(requestId: string, reason?: string) => Promise<void>>(
475
+ async () => {},
476
+ )
761
477
 
762
- // Exact-identity dedup for participant chunks (user/direct/system): the
763
- // JetStream streamSeq of every rendered participant row. Cleared per
764
- // dialog. See the dedup-layers note above `hasRecentMessage`.
765
- const seenParticipantSeqsRef = useRef<Set<number>>(new Set())
766
- // Texts `sendMessage` rendered optimistically and whose MESSAGE_REQUEST
767
- // echo hasn't come back yet — the echo consumes exactly ONE entry, so a
768
- // genuinely repeated send ("yes", "yes") still renders twice.
769
- const pendingEchoTextsRef = useRef<string[]>([])
770
-
771
- // Approval status map. Used both to dedupe pending segments at render
772
- // time and to feed `processHistoricalMessagesWithErrors` so previously
773
- // resolved approvals don't re-render as actionable on dialog switch.
774
- const [approvalStatuses, setApprovalStatuses] = useState<
775
- Record<string, ChatApprovalStatus>
776
- >({})
478
+ // Reducer-card adapters. The card contract is `(requestId?) => void |
479
+ // Promise<void>` single optional arg; narrow at the boundary.
480
+ const accumApprove = useCallback((requestId?: string): void | Promise<void> => {
481
+ if (!requestId) return
482
+ return handleApproveRef.current(requestId)
483
+ }, [])
484
+ const accumReject = useCallback((requestId?: string): void | Promise<void> => {
485
+ if (!requestId) return
486
+ return handleRejectRef.current(requestId)
487
+ }, [])
488
+
489
+ // ─── THE reducer (master stream reader) ───────────────────────────────────
490
+ // One `ChatDialogStore` per adapter instance; one reducer per dialog.
491
+ // Approval statuses are request-id-keyed and GLOBAL across dialogs (a
492
+ // resolved approval must not resurrect as actionable after a dialog
493
+ // switch), so the adapter accumulates them into a ref that seeds every
494
+ // newly-created per-dialog reducer.
495
+
496
+ const storeRef = useRef(createChatDialogStore())
497
+ const globalApprovalStatusesRef = useRef<Record<string, ChatApprovalStatus>>({})
498
+ const dialogKey = dialogId ?? DRAFT_DIALOG_KEY
499
+
500
+ const { state, applyEvent, mutate } = useChatStreamReducer({
501
+ store: storeRef.current,
502
+ dialogId: dialogKey,
503
+ createReducerOptions: () => ({
504
+ transport: 'nats',
505
+ batchApprovalsEnabled,
506
+ ...(displayApprovalTypes ? { displayApprovalTypes } : {}),
507
+ approvalStatuses: globalApprovalStatusesRef.current,
508
+ callbacks: { onApprove: accumApprove, onReject: accumReject },
509
+ }),
510
+ })
511
+
512
+ // Accumulate resolved statuses globally (render-time merge is cheap and
513
+ // keeps the ref current for the next dialog's reducer seed).
514
+ globalApprovalStatusesRef.current = {
515
+ ...globalApprovalStatusesRef.current,
516
+ ...state.approvalStatuses,
517
+ }
518
+
519
+ // Live handleApprove/handleReject bodies (need `mutate` + state access).
520
+ handleApproveRef.current = async (requestId: string) => {
521
+ if (!approveRequestCallback) return
522
+ // Approval hands the turn back to the agent — lock the composer now
523
+ // rather than waiting for the APPROVAL_RESULT/EXECUTING_TOOL chunks.
524
+ // Remember whether THIS click took the lock: if the phase was already
525
+ // busy (another approval's command executing), a failure of this
526
+ // request must not release a lock it doesn't own.
527
+ const store = storeRef.current
528
+ const key = dialogKey
529
+ const tookLock = store.mutate(key, DEFAULT_DIALOG_SIDE, (r) => {
530
+ const took = r.state.streamingPhase === 'idle'
531
+ r.setApprovalStatus(requestId, 'approved')
532
+ if (took) r.setPhase('thinking')
533
+ return took
534
+ })
535
+ try {
536
+ await approveRequestCallback(requestId)
537
+ } catch (err) {
538
+ // Revert the optimistic flip on failure so the user can retry.
539
+ store.mutate(key, DEFAULT_DIALOG_SIDE, (r) => {
540
+ r.setApprovalStatus(requestId, null)
541
+ if (tookLock && r.state.streamingPhase === 'thinking') r.setPhase('idle')
542
+ })
543
+ console.error('[useNatsChatAdapter] approveRequest failed:', err)
544
+ }
545
+ }
546
+ handleRejectRef.current = async (requestId: string, reason?: string) => {
547
+ if (!rejectRequestCallback) return
548
+ // Deliberately NO phase lock here (asymmetric with approve): rejection
549
+ // keeps the composer free so the user can type a correction right away —
550
+ // the reducer's `approved`-only agent-busy gate is the same asymmetry.
551
+ const store = storeRef.current
552
+ const key = dialogKey
553
+ store.mutate(key, DEFAULT_DIALOG_SIDE, (r) => r.setApprovalStatus(requestId, 'rejected'))
554
+ try {
555
+ await rejectRequestCallback(requestId, reason)
556
+ } catch (err) {
557
+ store.mutate(key, DEFAULT_DIALOG_SIDE, (r) => r.setApprovalStatus(requestId, null))
558
+ console.error('[useNatsChatAdapter] rejectRequest failed:', err)
559
+ }
560
+ }
561
+
562
+ // Decode + apply — the ONLY chunk entry point (catchup routes the live
563
+ // tail through here too, so buffering/dedupe stay consistent).
564
+ const processChunk = useCallback(
565
+ (chunk: unknown) => {
566
+ const event = decodeNatsChunk(chunk)
567
+ if (event) applyEvent(event)
568
+ },
569
+ [applyEvent],
570
+ )
777
571
 
778
572
  // ─── Dialog list state (managed-dialog mode only) ─────────────────────────
779
573
 
@@ -788,383 +582,15 @@ export function useNatsChatAdapter(
788
582
  const [messagesNextCursor, setMessagesNextCursor] = useState<string | null>(null)
789
583
  const [isMessagesLoading, setIsMessagesLoading] = useState<boolean>(false)
790
584
 
791
- // ─── Token usage (Mingo per-dialog cumulative) ────────────────────────────
792
-
793
- const [dialogTokenUsage, setDialogTokenUsage] = useState<DialogTokenUsage | null>(
794
- null,
795
- )
796
-
797
- // ─── Live model metadata (from streaming `metadata` frames) ───────────────
798
- // Refines the composer's model badge per-turn. Falls back to the config
799
- // baseline (`modelProvider`/`modelLabel`) until the first frame lands.
800
- const [liveModel, setLiveModel] = useState<{
801
- provider: string | null
802
- modelLabel: string | null
803
- contextWindowMaxTokens: number | null
804
- } | null>(null)
805
-
806
585
  // ─── Connection state ─────────────────────────────────────────────────────
807
586
 
808
587
  const [connectionState, setConnectionState] =
809
588
  useState<ChatConnectionState>('connecting')
810
589
 
811
- // ─── Approval handlers ────────────────────────────────────────────────────
812
- // Optimistically flip status before the network round-trip so the
813
- // accumulator's render reflects the user's choice immediately.
814
-
815
- const handleApprove = useCallback(
816
- async (requestId: string) => {
817
- if (!approveRequestCallback) return
818
- setApprovalStatuses((prev) => ({ ...prev, [requestId]: 'approved' }))
819
- // Approval hands the turn back to the agent — lock the composer now
820
- // rather than waiting for the APPROVAL_RESULT/EXECUTING_TOOL chunks.
821
- // Remember whether THIS click took the lock: if the phase was already
822
- // busy (another approval's command executing), a failure of this
823
- // request must not release a lock it doesn't own.
824
- const tookLock = streamingPhaseRef.current === 'idle'
825
- setStreamingPhase((p) => (p === 'idle' ? 'thinking' : p))
826
- try {
827
- await approveRequestCallback(requestId)
828
- } catch (err) {
829
- // Revert the optimistic flip on failure so the user can retry.
830
- setApprovalStatuses((prev) => {
831
- const next = { ...prev }
832
- delete next[requestId]
833
- return next
834
- })
835
- if (tookLock) {
836
- setStreamingPhase((p) => (p === 'thinking' ? 'idle' : p))
837
- }
838
- console.error('[useNatsChatAdapter] approveRequest failed:', err)
839
- }
840
- },
841
- [approveRequestCallback],
842
- )
843
-
844
- const handleReject = useCallback(
845
- async (requestId: string, reason?: string) => {
846
- if (!rejectRequestCallback) return
847
- // Deliberately NO phase lock here (asymmetric with handleApprove):
848
- // rejection keeps the composer free so the user can type a correction
849
- // right away — see the matching `approved`-only onAgentBusy guard in
850
- // use-realtime-chunk-processor's approval_result case. If the agent
851
- // does stream an acknowledgment, MESSAGE_START locks the composer then.
852
- setApprovalStatuses((prev) => ({ ...prev, [requestId]: 'rejected' }))
853
- try {
854
- await rejectRequestCallback(requestId, reason)
855
- } catch (err) {
856
- setApprovalStatuses((prev) => {
857
- const next = { ...prev }
858
- delete next[requestId]
859
- return next
860
- })
861
- console.error('[useNatsChatAdapter] rejectRequest failed:', err)
862
- }
863
- },
864
- [rejectRequestCallback],
865
- )
866
-
867
- // Stable refs so the accumulator's callbacks don't churn on re-render.
868
- const handleApproveRef = useRef(handleApprove)
869
- handleApproveRef.current = handleApprove
870
- const handleRejectRef = useRef(handleReject)
871
- handleRejectRef.current = handleReject
872
-
873
- // Accumulator-compatible adapters. The lib's accumulator contract is
874
- // `(requestId?: string) => void | Promise<void>` — single optional arg,
875
- // no rejection reason. Our public API surface widens that to require a
876
- // string + accept a reason, so we narrow here at the boundary. Reason
877
- // is unavailable from button-click callers (the accumulator doesn't
878
- // pass it) so we only forward the requestId; consumers that need
879
- // structured reject reasons can call `rejectRequest` directly.
880
- const accumApprove = useCallback((requestId?: string): void | Promise<void> => {
881
- if (!requestId) return
882
- return handleApproveRef.current(requestId)
883
- }, [])
884
- const accumReject = useCallback((requestId?: string): void | Promise<void> => {
885
- if (!requestId) return
886
- return handleRejectRef.current(requestId)
887
- }, [])
888
-
889
- // Stable callback ref so `useRealtimeChunkProcessor`'s options object
890
- // doesn't churn every render and tear down the accumulator state.
891
- const callbacksRef: MutableRefObject<{
892
- onSegmentsUpdate: (segments: MessageSegment[], meta?: SegmentsUpdateMetadata) => void
893
- onStreamStart: () => void
894
- onStreamEnd: () => void
895
- onAgentBusy: () => void
896
- onError: () => void
897
- onTokenUsage: (data: DialogTokenUsage) => void
898
- onMetadata: (meta: {
899
- modelDisplayName?: string
900
- modelName: string
901
- providerName: string
902
- contextWindow: number
903
- }) => void
904
- onToolExecuted: (segment: ToolExecutionSegment) => void
905
- onApprovalResolved: (
906
- requestId: string,
907
- status: ChatApprovalStatus,
908
- approvalType: string,
909
- resolvedByName?: string | null,
910
- ) => void
911
- onUserMessage: (
912
- text: string,
913
- meta?: { ownerType?: string; displayName?: string; userId?: string; streamSeq?: number; contextItems?: Array<{ type: string; id: string }> },
914
- ) => void
915
- onDirectMessage: (
916
- text: string,
917
- meta?: { ownerType?: string; displayName?: string; userId?: string; streamSeq?: number },
918
- ) => void
919
- onSystemMessage: (text: string, meta?: { streamSeq?: number }) => void
920
- }> = useRef({
921
- onSegmentsUpdate: (segments: MessageSegment[], meta?: SegmentsUpdateMetadata) => {
922
- // Standalone compaction updates carry the accumulator's cumulative
923
- // array — apply only the compaction segment (upsert) or interleaved
924
- // continuation text would duplicate.
925
- if (meta?.append && meta.isCompacting) {
926
- setMessages((prev) => upsertTrailingCompaction(prev, segments))
927
- return
928
- }
929
- // Post-MESSAGE_END continuation fragments append into the existing
930
- // bubble; replacing (the pre-fix behaviour, which dropped `meta`)
931
- // wiped the completed reply and left only the newest fragment.
932
- if (meta?.append) {
933
- setMessages((prev) => appendToTrailingAssistant(prev, segments))
934
- return
935
- }
936
- setMessages((prev) => updateTrailingAssistant(prev, segments))
937
- },
938
- onStreamStart: () => {
939
- setStreamingPhase('streaming')
940
- // A new stream must never overwrite a COMPLETED trailing assistant
941
- // bubble (observer tab / second device / post-approval continuation
942
- // turn). Open a fresh bubble unless the trailing assistant is empty
943
- // (our own optimistic placeholder) or is the incomplete history tail
944
- // the catchup replay is legitimately re-streaming (adopt-once flag).
945
- const adoptTail = adoptTrailingAssistantRef.current
946
- adoptTrailingAssistantRef.current = false
947
- setMessages((prev) => {
948
- const last = prev[prev.length - 1]
949
- if (!last || last.role !== 'assistant' || adoptTail) return prev
950
- const hasContent = (last.segments?.length ?? 0) > 0 || last.content !== ''
951
- if (!hasContent) return prev
952
- return [
953
- ...prev,
954
- { id: nextId('assistant'), role: 'assistant', content: '', segments: [] },
955
- ]
956
- })
957
- },
958
- onStreamEnd: () => setStreamingPhase('idle'),
959
- // Agent is executing (approved) commands outside an open stream — keep
960
- // the composer locked exactly like the in-stream phases. An open stream
961
- // keeps ownership: only 'idle' upgrades to 'thinking'. Suppressed during
962
- // initial catchup so a replayed dead tail can't lock a finished dialog.
963
- onAgentBusy: () => {
964
- if (suppressAgentBusyRef.current > 0) return
965
- setStreamingPhase((p) => (p === 'idle' ? 'thinking' : p))
966
- },
967
- // Terminal turn failures can arrive without a MESSAGE_END; unlock the
968
- // composer unless an open stream still owns the phase (in-stream errors
969
- // are followed by the stream's own MESSAGE_END).
970
- onError: () => setStreamingPhase((p) => (p === 'streaming' ? p : 'idle')),
971
- // Live cumulative token counter for the active dialog — drives the
972
- // composer's "X / Y tokens used" tail as the assistant streams.
973
- onTokenUsage: (data: DialogTokenUsage) => setDialogTokenUsage(data),
974
- // Per-turn model badge (provider/label/context window).
975
- onMetadata: (meta) =>
976
- setLiveModel({
977
- provider: meta.providerName || null,
978
- modelLabel: meta.modelDisplayName || meta.modelName || null,
979
- contextWindowMaxTokens: meta.contextWindow || null,
980
- }),
981
- // Post-MESSAGE_END tool chunks (approved commands executing after the
982
- // approval bubble, async batch results). Routed through the cross-message
983
- // updater — the accumulator was reset at MESSAGE_END, so the old
984
- // fallthrough replaced the whole trailing bubble with one tool segment.
985
- onToolExecuted: (segment: ToolExecutionSegment) => {
986
- setMessages((prev) => applyToolExecutionToMessages(prev, segment))
987
- },
988
- // Cross-message approval status flip. Wiring this also makes the
989
- // processor skip its cumulative re-emit for resolved approvals — which,
990
- // post-MESSAGE_END, was an empty array that blanked the trailing bubble.
991
- onApprovalResolved: (requestId, status, _approvalType, resolvedByName) => {
992
- setMessages((prev) => {
993
- let changed = false
994
- const next = prev.map((m) => {
995
- if (m.role !== 'assistant' || !m.segments) return m
996
- let msgChanged = false
997
- const segs = m.segments.map((s) => {
998
- if (s.type === 'approval_request' && s.data.requestId === requestId && s.status !== status) {
999
- msgChanged = true
1000
- return { ...s, status }
1001
- }
1002
- if (s.type === 'approval_batch' && s.data.approvalRequestId === requestId) {
1003
- const nextResolvedBy = resolvedByName ?? s.resolvedByName
1004
- if (s.status === status && nextResolvedBy === s.resolvedByName) return s
1005
- msgChanged = true
1006
- return { ...s, status, resolvedByName: nextResolvedBy }
1007
- }
1008
- return s
1009
- })
1010
- if (!msgChanged) return m
1011
- changed = true
1012
- return { ...m, segments: segs }
1013
- })
1014
- return changed ? next : prev
1015
- })
1016
- // Mirror into the status map so history re-processing (dialog reopen,
1017
- // pagination) renders the card resolved instead of actionable.
1018
- setApprovalStatuses((prev) =>
1019
- prev[requestId] === status ? prev : { ...prev, [requestId]: status },
1020
- )
1021
- },
1022
- // MESSAGE_REQUEST echo — a user message from THIS or another session.
1023
- onUserMessage: (text, meta) => {
1024
- if (!text) return
1025
- // Layer 1 FIRST: exact event identity. A seq we've handled is a
1026
- // replay; a fresh seq is appended with NO content matching, so
1027
- // genuinely repeated texts from other participants always render.
1028
- // Must run (and RECORD) before the echo consume below — the echo
1029
- // branch early-returns, and skipping the record there let an
1030
- // at-least-once redelivery of the same event pass as fresh (and eat
1031
- // a second pending echo entry).
1032
- const seq = meta?.streamSeq
1033
- if (typeof seq === 'number') {
1034
- if (seenParticipantSeqsRef.current.has(seq)) return
1035
- seenParticipantSeqsRef.current.add(seq)
1036
- }
1037
- // Layer 2: our own optimistic echo — consume exactly one recorded send.
1038
- if (meta?.ownerType !== 'ADMIN') {
1039
- const echoIdx = pendingEchoTextsRef.current.indexOf(text)
1040
- if (echoIdx !== -1) {
1041
- pendingEchoTextsRef.current.splice(echoIdx, 1)
1042
- return
1043
- }
1044
- }
1045
- const authorType = meta?.ownerType === 'ADMIN' ? 'admin' : 'user'
1046
- setMessages((prev) => {
1047
- // Layer 3: seq-less transports only — same-author content twin in the
1048
- // immediate tail (replay over just-loaded history).
1049
- if (
1050
- typeof seq !== 'number' &&
1051
- hasRecentMessage(
1052
- prev,
1053
- (m) => m.role === 'user' && (m.authorType ?? 'user') === authorType && m.content === text,
1054
- CONTENT_DEDUP_WINDOW,
1055
- )
1056
- ) {
1057
- return prev
1058
- }
1059
- return [
1060
- ...prev,
1061
- {
1062
- id: nextId('user'),
1063
- role: 'user',
1064
- content: text,
1065
- ...(meta?.displayName ? { name: meta.displayName } : {}),
1066
- authorType,
1067
- ...(meta?.contextItems && meta.contextItems.length > 0
1068
- ? {
1069
- contextItems: meta.contextItems.map((ci) => ({
1070
- type: ci.type,
1071
- id: ci.id,
1072
- label: (ci as { label?: string }).label ?? ci.id,
1073
- })),
1074
- }
1075
- : {}),
1076
- },
1077
- ]
1078
- })
1079
- },
1080
- // Technician / admin direct message into the dialog.
1081
- onDirectMessage: (text, meta) => {
1082
- if (!text) return
1083
- const seq = meta?.streamSeq
1084
- if (typeof seq === 'number') {
1085
- if (seenParticipantSeqsRef.current.has(seq)) return
1086
- seenParticipantSeqsRef.current.add(seq)
1087
- }
1088
- setMessages((prev) => {
1089
- // Seq-less fallback matches only prior ADMIN-authored twins — a
1090
- // client's identical text must never suppress the technician's.
1091
- if (
1092
- typeof seq !== 'number' &&
1093
- hasRecentMessage(
1094
- prev,
1095
- (m) => m.role === 'user' && m.authorType === 'admin' && m.content === text,
1096
- CONTENT_DEDUP_WINDOW,
1097
- )
1098
- ) {
1099
- return prev
1100
- }
1101
- return [
1102
- ...prev,
1103
- {
1104
- id: `direct-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`,
1105
- role: 'user',
1106
- content: text,
1107
- name: meta?.displayName ?? 'Admin',
1108
- authorType: 'admin',
1109
- },
1110
- ]
1111
- })
1112
- },
1113
- // System notice — rendered as a name-only row (same shape the history
1114
- // processor produces via `pushStandaloneMessages`).
1115
- onSystemMessage: (text, meta) => {
1116
- if (!text) return
1117
- const seq = meta?.streamSeq
1118
- if (typeof seq === 'number') {
1119
- if (seenParticipantSeqsRef.current.has(seq)) return
1120
- seenParticipantSeqsRef.current.add(seq)
1121
- }
1122
- setMessages((prev) => {
1123
- if (
1124
- typeof seq !== 'number' &&
1125
- hasRecentMessage(prev, (m) => m.authorType === 'system' && m.name === text, SYSTEM_DEDUP_WINDOW)
1126
- ) {
1127
- return prev
1128
- }
1129
- return [
1130
- ...prev,
1131
- {
1132
- id: `system-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`,
1133
- role: 'user',
1134
- content: '',
1135
- name: text,
1136
- authorType: 'system',
1137
- },
1138
- ]
1139
- })
1140
- },
1141
- })
1142
-
1143
- // Real-time chunk → segment processor. Approval handlers route through
1144
- // the refs so a config change (e.g. callback identity churn from the
1145
- // host) doesn't tear down the accumulator.
1146
- const { processChunk, reset: resetAccumulator } = useRealtimeChunkProcessor({
1147
- callbacks: {
1148
- onSegmentsUpdate: (segments, meta) => callbacksRef.current.onSegmentsUpdate(segments, meta),
1149
- onStreamStart: () => callbacksRef.current.onStreamStart(),
1150
- onStreamEnd: () => callbacksRef.current.onStreamEnd(),
1151
- onAgentBusy: () => callbacksRef.current.onAgentBusy(),
1152
- onError: () => callbacksRef.current.onError(),
1153
- onTokenUsage: (data) => callbacksRef.current.onTokenUsage(data),
1154
- onMetadata: (meta) => callbacksRef.current.onMetadata(meta),
1155
- onToolExecuted: (segment) => callbacksRef.current.onToolExecuted(segment),
1156
- onApprovalResolved: (requestId, status, approvalType, resolvedByName) =>
1157
- callbacksRef.current.onApprovalResolved(requestId, status, approvalType, resolvedByName),
1158
- onUserMessage: (text, meta) => callbacksRef.current.onUserMessage(text, meta),
1159
- onDirectMessage: (text, meta) => callbacksRef.current.onDirectMessage(text, meta),
1160
- onSystemMessage: (text, meta) => callbacksRef.current.onSystemMessage(text, meta),
1161
- onApprove: accumApprove,
1162
- onReject: accumReject,
1163
- },
1164
- batchApprovalsEnabled,
1165
- approvalStatuses,
1166
- ...(displayApprovalTypes ? { displayApprovalTypes } : {}),
1167
- })
590
+ // Set after the first successful NATS connect. Later 'connected' events are
591
+ // RECONNECTS: plain NATS replays nothing, so the adapter must back-fill the
592
+ // disconnect gap via `resetAndCatchUp` or those chunks are lost forever.
593
+ const hasConnectedOnceRef = useRef(false)
1168
594
 
1169
595
  // History catchup — back-fills chunks emitted while the adapter was
1170
596
  // inactive or before the WS came online.
@@ -1184,8 +610,8 @@ export function useNatsChatAdapter(
1184
610
  // ─── Historical message hydration ─────────────────────────────────────────
1185
611
  // When the active dialog changes AND a `fetchDialogMessages` callback
1186
612
  // is wired, load the first page of history, process it through the
1187
- // accumulator, and seed `messages`. Streaming chunks for the same
1188
- // dialog then append on top.
613
+ // history decoder + envelope, and seed the reducer. Streaming chunks for
614
+ // the same dialog then append on top.
1189
615
 
1190
616
  // Monotonic guard: each history load captures a sequence number; when a
1191
617
  // response resolves, it only applies if it's still the latest request. This
@@ -1198,6 +624,7 @@ export function useNatsChatAdapter(
1198
624
  if (!fetchDialogMessages) return
1199
625
  historyLoadSeqRef.current += 1
1200
626
  const seq = historyLoadSeqRef.current
627
+ const store = storeRef.current
1201
628
  setIsMessagesLoading(true)
1202
629
  try {
1203
630
  const result = await fetchDialogMessages({
@@ -1215,34 +642,39 @@ export function useNatsChatAdapter(
1215
642
  chatTypeFilter,
1216
643
  onApprove: accumApprove,
1217
644
  onReject: accumReject,
1218
- approvalStatuses,
645
+ approvalStatuses: store.getSnapshot(id).approvalStatuses,
1219
646
  batchApprovalsEnabled,
1220
647
  ...(displayApprovalTypes ? { displayApprovalTypes } : {}),
1221
648
  },
1222
649
  )
1223
650
  const unified = mapProcessedToUnified(rawProcessed)
1224
- if (cursor === undefined) {
1225
- // First page replace. When the trailing assistant is an
1226
- // INCOMPLETE turn, the catchup replay will re-stream it from its
1227
- // MESSAGE_START let that stream adopt (replace) the partial
1228
- // bubble instead of opening a duplicate one.
1229
- // Gated on an ACTIVE catchup buffer: when the catchup already
1230
- // finalized before this history page resolved, no replay is coming
1231
- // to consume the flag arming it anyway would hand the adoption
1232
- // to the NEXT genuine turn, which would overwrite the partial
1233
- // bubble instead of opening a fresh one.
1234
- adoptTrailingAssistantRef.current =
1235
- isBufferingActive() &&
1236
- extractIncompleteMessageState(rawProcessed[rawProcessed.length - 1]) !== undefined
1237
- setMessages(unified)
1238
- } else {
1239
- // Older page prepend.
1240
- setMessages((prev) => [...unified, ...prev])
1241
- }
651
+ store.mutate(id, DEFAULT_DIALOG_SIDE, (r) => {
652
+ if (cursor === undefined) {
653
+ // First page replace. When the trailing assistant is an
654
+ // INCOMPLETE turn, the catchup replay will re-stream it from its
655
+ // MESSAGE_START let that stream adopt (replace) the partial
656
+ // bubble instead of opening a duplicate one.
657
+ // Gated on an ACTIVE catchup buffer: when the catchup already
658
+ // finalized before this history page resolved, no replay is
659
+ // coming to consume the flag arming it anyway would hand the
660
+ // adoption to the NEXT genuine turn, which would overwrite the
661
+ // partial bubble instead of opening a fresh one.
662
+ // `extractIncompleteTailState` (not the single-row extractor):
663
+ // one logical turn can span several trailing assistant bubbles,
664
+ // so the unfinished artifact may sit above the last row.
665
+ r.armAdoptTrailingAssistant(
666
+ isBufferingActive() && extractIncompleteTailState(rawProcessed) !== undefined,
667
+ )
668
+ r.setMessages(unified)
669
+ } else {
670
+ // Older page — prepend.
671
+ r.prependMessages(unified)
672
+ }
673
+ if (result.tokenUsage !== undefined) {
674
+ r.setDialogTokenUsage(result.tokenUsage ?? null)
675
+ }
676
+ })
1242
677
  setMessagesNextCursor(result.nextCursor)
1243
- if (result.tokenUsage !== undefined) {
1244
- setDialogTokenUsage(result.tokenUsage)
1245
- }
1246
678
  } catch (err) {
1247
679
  console.error('[useNatsChatAdapter] fetchDialogMessages failed:', err)
1248
680
  } finally {
@@ -1258,7 +690,6 @@ export function useNatsChatAdapter(
1258
690
  messagesPageSize,
1259
691
  assistantName,
1260
692
  chatTypeFilter,
1261
- approvalStatuses,
1262
693
  batchApprovalsEnabled,
1263
694
  displayApprovalTypes,
1264
695
  accumApprove,
@@ -1279,20 +710,19 @@ export function useNatsChatAdapter(
1279
710
  // this an in-flight response could repopulate the just-cleared state.
1280
711
  historyLoadSeqRef.current += 1
1281
712
 
1282
- // Drop accumulator + message state for the previous dialog.
1283
- resetAccumulator()
1284
- adoptTrailingAssistantRef.current = false
1285
- seenParticipantSeqsRef.current.clear()
1286
- pendingEchoTextsRef.current = []
1287
- setMessages([])
1288
- setMessagesNextCursor(null)
1289
- setDialogTokenUsage(null)
1290
- // Clear per-dialog live model metadata too — otherwise the composer briefly
1291
- // shows the previous dialog's provider/model until the next chunk streams.
1292
- setLiveModel(null)
1293
- setStreamingPhase('idle')
713
+ // Clean slate for the (re)entered dialog: thread, per-turn kernel,
714
+ // participant dedup sets, seq gate, phase. Approval statuses survive
715
+ // per-reducer AND get topped up with the adapter's global map (request
716
+ // ids are dialog-independent).
717
+ storeRef.current.mutate(dialogKey, DEFAULT_DIALOG_SIDE, (r) => {
718
+ r.resetForDialogSwitch()
719
+ // Canonical precedence lives in the reducer: persisted (the adapter's
720
+ // global map) fills gaps, stream-learned statuses always win.
721
+ r.mergeApprovalStatuses(globalApprovalStatusesRef.current)
722
+ })
1294
723
  // No load runs when there's no active dialog, so clear the flag here;
1295
724
  // otherwise the superseded load's guarded `finally` leaves it stuck on.
725
+ setMessagesNextCursor(null)
1296
726
  setIsMessagesLoading(false)
1297
727
 
1298
728
  if (!active || !dialogId) return
@@ -1300,7 +730,7 @@ export function useNatsChatAdapter(
1300
730
  // Hydrate history first; then catchup will fold in any chunks that
1301
731
  // landed between the history snapshot and the live tail.
1302
732
  void loadDialogHistory(dialogId)
1303
- }, [active, dialogId, loadDialogHistory, resetAccumulator])
733
+ }, [active, dialogId, dialogKey, loadDialogHistory])
1304
734
 
1305
735
  // Trigger initial chunk backfill whenever a fresh dialog activates.
1306
736
  // Runs alongside history hydration — history seeds processed messages,
@@ -1308,8 +738,14 @@ export function useNatsChatAdapter(
1308
738
  // was already snapshotted.
1309
739
  useEffect(() => {
1310
740
  if (!active || !dialogId) return
1311
- // Gate onAgentBusy for the replay window (see suppressAgentBusyRef).
1312
- suppressAgentBusyRef.current += 1
741
+ const store = storeRef.current
742
+ // Gate agent-busy locks for the replay window: a dead tail (approval
743
+ // approved / tool started, then Stop or a crash) replays its
744
+ // APPROVAL_RESULT / EXECUTING_TOOL chunks on every dialog (re)open, and
745
+ // the releasing MESSAGE_END never replays — locking the composer
746
+ // forever. The suppression is a COUNTER inside the reducer: the
747
+ // initial-catchup window and a reconnect back-fill window can overlap.
748
+ store.mutate(dialogId, DEFAULT_DIALOG_SIDE, (r) => r.adjustAgentBusySuppression(1))
1313
749
  resetChunkTracking()
1314
750
  startInitialBuffering()
1315
751
  catchUpChunks()
@@ -1317,16 +753,15 @@ export function useNatsChatAdapter(
1317
753
  console.error('[useNatsChatAdapter] initial catchup failed:', err)
1318
754
  })
1319
755
  .finally(() => {
1320
- suppressAgentBusyRef.current = Math.max(0, suppressAgentBusyRef.current - 1)
1321
- // The adopt-once flag targets the replayed MESSAGE_START of the
1322
- // incomplete history tail. When the replay produced none (chunk
1323
- // store expired past its ~10-min retention / empty), the armed flag
1324
- // would survive and make the NEXT genuine turn (post-approval
1325
- // continuation, second device) adopt-and-overwrite the partial
1326
- // bubble. Scoped: only the still-active dialog clears its own flag.
1327
- if (dialogId === currentDialogIdRef.current) {
1328
- adoptTrailingAssistantRef.current = false
1329
- }
756
+ store.mutate(dialogId, DEFAULT_DIALOG_SIDE, (r) => {
757
+ r.adjustAgentBusySuppression(-1)
758
+ // The adopt-once flag targets the replayed MESSAGE_START of the
759
+ // incomplete history tail. When the replay produced none (chunk
760
+ // store expired past its ~10-min retention / empty), the armed
761
+ // flag would survive and make the NEXT genuine turn adopt-and-
762
+ // overwrite the partial bubble.
763
+ r.armAdoptTrailingAssistant(false)
764
+ })
1330
765
  })
1331
766
  }, [active, dialogId, resetChunkTracking, startInitialBuffering, catchUpChunks])
1332
767
 
@@ -1359,7 +794,13 @@ export function useNatsChatAdapter(
1359
794
  hasConnectedOnceRef.current = true
1360
795
  return
1361
796
  }
1362
- suppressAgentBusyRef.current += 1
797
+ const store = storeRef.current
798
+ const reconnectDialogId = dialogId
799
+ if (reconnectDialogId) {
800
+ store.mutate(reconnectDialogId, DEFAULT_DIALOG_SIDE, (r) =>
801
+ r.adjustAgentBusySuppression(1),
802
+ )
803
+ }
1363
804
  // Buffer live deliveries for the WHOLE back-fill, including the history
1364
805
  // await — otherwise chunks landing mid-fetch are processed unbuffered
1365
806
  // (advancing lastSequenceId + dedup keys) and the history replace then
@@ -1373,28 +814,27 @@ export function useNatsChatAdapter(
1373
814
  // so the chunk back-fill can't recover them — the persisted history
1374
815
  // page is the only source. Then replay the unsaved chunk tail on
1375
816
  // top of the fresh snapshot.
1376
- if (dialogId && fetchDialogMessages) {
1377
- await loadDialogHistory(dialogId)
817
+ if (reconnectDialogId && fetchDialogMessages) {
818
+ await loadDialogHistory(reconnectDialogId)
1378
819
  }
1379
820
  // The user may have switched dialogs during the await — the new
1380
821
  // dialog's own effects run their catchup cycle; re-running it here
1381
- // (via refs it would target the NEW dialog) would reset that cycle
1382
- // mid-flight.
1383
- if (dialogId !== currentDialogIdRef.current) return
822
+ // would reset that cycle mid-flight.
823
+ if (reconnectDialogId !== currentDialogIdRef.current) return
1384
824
  await resetAndCatchUp()
1385
825
  } catch (err) {
1386
826
  console.error('[useNatsChatAdapter] reconnect catchup failed:', err)
1387
827
  } finally {
1388
- suppressAgentBusyRef.current = Math.max(0, suppressAgentBusyRef.current - 1)
1389
- // The adopt-once flag targets the replayed MESSAGE_START of the
1390
- // incomplete tail — but on reconnect that START was already
1391
- // consumed live and is dedup-skipped by the replay, so it never
1392
- // fires onStreamStart. Clear the flag or the NEXT genuine turn
1393
- // adopts (and overwrites) the completed trailing bubble. Scoped:
1394
- // a stale back-fill must not clear the NEW dialog's freshly armed
1395
- // flag.
1396
- if (dialogId === currentDialogIdRef.current) {
1397
- adoptTrailingAssistantRef.current = false
828
+ if (reconnectDialogId) {
829
+ store.mutate(reconnectDialogId, DEFAULT_DIALOG_SIDE, (r) => {
830
+ r.adjustAgentBusySuppression(-1)
831
+ // The adopt-once flag targets the replayed MESSAGE_START of the
832
+ // incomplete tail but on reconnect that START was already
833
+ // consumed live and is dedup-skipped by the replay, so it never
834
+ // fires. Clear the flag or the NEXT genuine turn adopts (and
835
+ // overwrites) the completed trailing bubble.
836
+ r.armAdoptTrailingAssistant(false)
837
+ })
1398
838
  }
1399
839
  }
1400
840
  })()
@@ -1474,52 +914,30 @@ export function useNatsChatAdapter(
1474
914
  ): Promise<void> => {
1475
915
  const hidden = sendOptions?.hidden ?? false
1476
916
 
1477
- // Record the text so the backend's MESSAGE_REQUEST echo consumes this
1478
- // send instead of rendering a duplicate row (cap keeps the list from
1479
- // growing if a backend never echoes).
1480
- pendingEchoTextsRef.current.push(text)
1481
- if (pendingEchoTextsRef.current.length > 5) pendingEchoTextsRef.current.shift()
1482
-
1483
917
  // Optimistically append the user bubble + an empty assistant
1484
- // placeholder. The assistant body fills in as NATS chunks land.
1485
- setMessages((prev) => [
1486
- ...prev,
1487
- {
1488
- id: nextId('user'),
1489
- role: 'user',
1490
- content: text,
1491
- ...(hidden ? { hidden: true } : {}),
1492
- },
1493
- {
1494
- id: nextId('assistant'),
1495
- role: 'assistant',
1496
- content: '',
1497
- segments: [],
1498
- },
1499
- ])
1500
- setStreamingPhase('thinking')
918
+ // placeholder, record the echo text, and flip the phase — all inside
919
+ // the reducer. The assistant body fills in as NATS chunks land.
920
+ mutate((r) => r.pushOptimisticSend(text, hidden))
1501
921
 
1502
922
  await publishUserMessage(text, { hidden, dialogId })
1503
923
  },
1504
- [publishUserMessage, dialogId],
924
+ [mutate, publishUserMessage, dialogId],
1505
925
  )
1506
926
 
1507
927
  const stopMessage = useCallback(() => {
1508
928
  // Best-effort UI flip. The actual backend cancellation is gated on
1509
929
  // the host-supplied callback.
1510
- setStreamingPhase('idle')
930
+ mutate((r) => r.setPhase('idle'))
1511
931
  if (stopGenerationCallback && dialogId) {
1512
932
  void stopGenerationCallback(dialogId).catch((err) => {
1513
933
  console.error('[useNatsChatAdapter] stopGeneration failed:', err)
1514
934
  })
1515
935
  }
1516
- }, [stopGenerationCallback, dialogId])
936
+ }, [mutate, stopGenerationCallback, dialogId])
1517
937
 
1518
938
  const clearMessages = useCallback(() => {
1519
- setMessages([])
1520
- resetAccumulator()
1521
- setStreamingPhase('idle')
1522
- }, [resetAccumulator])
939
+ mutate((r) => r.clearThread())
940
+ }, [mutate])
1523
941
 
1524
942
  const selectDialog = useCallback(
1525
943
  (id: string | null) => {
@@ -1570,6 +988,7 @@ export function useNatsChatAdapter(
1570
988
  try {
1571
989
  await deleteDialogCallback(id)
1572
990
  setDialogs((prev) => prev.filter((d) => d.id !== id))
991
+ storeRef.current.remove(id)
1573
992
  if (isManagedMode && internalDialogId === id) {
1574
993
  setInternalDialogId(null)
1575
994
  }
@@ -1656,6 +1075,7 @@ export function useNatsChatAdapter(
1656
1075
 
1657
1076
  // ─── Return shape ─────────────────────────────────────────────────────────
1658
1077
 
1078
+ const { messages, streamingPhase, dialogTokenUsage = null, liveModel = null } = state
1659
1079
  const isLoading = streamingPhase !== 'idle'
1660
1080
  const hasMoreDialogs = dialogsNextCursor != null
1661
1081
  const hasMoreMessages = messagesNextCursor != null