@flamingo-stack/openframe-frontend-core 0.0.498-1506.4000.1 → 0.0.498

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