@flamingo-stack/openframe-frontend-core 0.0.492-1506.3956.1 → 0.0.492

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 (296) hide show
  1. package/dist/{chunk-KX3K2EH4.cjs → chunk-2XAAFAQT.cjs} +11 -11
  2. package/dist/{chunk-KX3K2EH4.cjs.map → chunk-2XAAFAQT.cjs.map} +1 -1
  3. package/dist/{chunk-JHEIOXXZ.js → chunk-4AWRPTKE.js} +5 -6
  4. package/dist/chunk-4AWRPTKE.js.map +1 -0
  5. package/dist/{chunk-PTXZB2KW.js → chunk-4J3CB4KO.js} +2 -2
  6. package/dist/{chunk-7N7YZCHR.cjs → chunk-4MLOMMDX.cjs} +5519 -7555
  7. package/dist/chunk-4MLOMMDX.cjs.map +1 -0
  8. package/dist/{chunk-MEMRQTOI.js → chunk-56J7RLMM.js} +10434 -12470
  9. package/dist/chunk-56J7RLMM.js.map +1 -0
  10. package/dist/{chunk-MEIGDK7M.cjs → chunk-5ICPQAUI.cjs} +60 -60
  11. package/dist/chunk-5ICPQAUI.cjs.map +1 -0
  12. package/dist/{chunk-BEEPU44B.js → chunk-5RHJVPL7.js} +10 -10
  13. package/dist/chunk-5RHJVPL7.js.map +1 -0
  14. package/dist/{chunk-XOFE2RT4.js → chunk-BTIRYGDT.js} +4 -4
  15. package/dist/{chunk-JSEJ5MH5.cjs → chunk-DY6AJNVK.cjs} +5 -5
  16. package/dist/{chunk-JSEJ5MH5.cjs.map → chunk-DY6AJNVK.cjs.map} +1 -1
  17. package/dist/{chunk-PLB6CQSO.js → chunk-FG5ALORQ.js} +2 -2
  18. package/dist/{chunk-QG2EBIH5.cjs → chunk-J2J36PEN.cjs} +31 -31
  19. package/dist/{chunk-QG2EBIH5.cjs.map → chunk-J2J36PEN.cjs.map} +1 -1
  20. package/dist/{chunk-A6FPYAUD.cjs → chunk-LIRXKEOE.cjs} +15 -15
  21. package/dist/{chunk-A6FPYAUD.cjs.map → chunk-LIRXKEOE.cjs.map} +1 -1
  22. package/dist/{chunk-MSL5NE3N.cjs → chunk-LVDI7R7T.cjs} +37 -37
  23. package/dist/{chunk-MSL5NE3N.cjs.map → chunk-LVDI7R7T.cjs.map} +1 -1
  24. package/dist/{chunk-MPUQFRJG.cjs → chunk-M4SCYPCR.cjs} +7 -7
  25. package/dist/{chunk-MPUQFRJG.cjs.map → chunk-M4SCYPCR.cjs.map} +1 -1
  26. package/dist/{chunk-QDGB2DKY.cjs → chunk-O6S6Q52W.cjs} +26 -26
  27. package/dist/{chunk-QDGB2DKY.cjs.map → chunk-O6S6Q52W.cjs.map} +1 -1
  28. package/dist/{chunk-W56JYDSC.cjs → chunk-OFFYASUY.cjs} +27 -28
  29. package/dist/chunk-OFFYASUY.cjs.map +1 -0
  30. package/dist/{chunk-NCAN6VUF.js → chunk-P3ITKTUE.js} +2 -2
  31. package/dist/{chunk-M6FEGPQJ.cjs → chunk-PI5BMKV3.cjs} +62 -62
  32. package/dist/{chunk-M6FEGPQJ.cjs.map → chunk-PI5BMKV3.cjs.map} +1 -1
  33. package/dist/{chunk-34TDA22K.js → chunk-QHRNQTW5.js} +2 -2
  34. package/dist/{chunk-OL3MJKUI.js → chunk-SZWMJIH3.js} +2 -2
  35. package/dist/{chunk-MZRIRQYV.js → chunk-U5LU56JE.js} +2 -2
  36. package/dist/{chunk-C65NCIMV.cjs → chunk-VML66FJQ.cjs} +9 -9
  37. package/dist/{chunk-C65NCIMV.cjs.map → chunk-VML66FJQ.cjs.map} +1 -1
  38. package/dist/{chunk-3LKB722U.js → chunk-WKWZ7LVC.js} +2 -2
  39. package/dist/{chunk-J3VBVR4W.js → chunk-WPFLV4QJ.js} +4 -4
  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-nats-chat-adapter.d.ts +48 -15
  48. package/dist/components/chat/hooks/use-nats-chat-adapter.d.ts.map +1 -1
  49. package/dist/components/chat/hooks/use-realtime-chunk-processor.d.ts +6 -0
  50. package/dist/components/chat/hooks/use-realtime-chunk-processor.d.ts.map +1 -0
  51. package/dist/components/chat/hooks/use-sse-chat-adapter.d.ts +57 -10
  52. package/dist/components/chat/hooks/use-sse-chat-adapter.d.ts.map +1 -1
  53. package/dist/components/chat/index.cjs +2 -10
  54. package/dist/components/chat/index.cjs.map +1 -1
  55. package/dist/components/chat/index.d.ts +0 -1
  56. package/dist/components/chat/index.d.ts.map +1 -1
  57. package/dist/components/chat/index.js +13 -21
  58. package/dist/components/chat/types/api.types.d.ts +81 -1
  59. package/dist/components/chat/types/api.types.d.ts.map +1 -1
  60. package/dist/components/chat/types/processing.types.d.ts +93 -2
  61. package/dist/components/chat/types/processing.types.d.ts.map +1 -1
  62. package/dist/components/chat/utils/chunk-parser.d.ts +25 -0
  63. package/dist/components/chat/utils/chunk-parser.d.ts.map +1 -0
  64. package/dist/components/chat/utils/extract-incomplete-message-state.d.ts +6 -34
  65. package/dist/components/chat/utils/extract-incomplete-message-state.d.ts.map +1 -1
  66. package/dist/components/chat/utils/index.d.ts +3 -2
  67. package/dist/components/chat/utils/index.d.ts.map +1 -1
  68. package/dist/components/chat/utils/process-historical-messages.d.ts +11 -59
  69. package/dist/components/chat/utils/process-historical-messages.d.ts.map +1 -1
  70. package/dist/components/contact/index.cjs +3 -3
  71. package/dist/components/contact/index.js +2 -2
  72. package/dist/components/docs/index.cjs +5 -5
  73. package/dist/components/docs/index.js +4 -4
  74. package/dist/components/embeds/index.cjs +3 -3
  75. package/dist/components/embeds/index.js +2 -2
  76. package/dist/components/faq/index.cjs +3 -3
  77. package/dist/components/faq/index.js +2 -2
  78. package/dist/components/features/floating-walkthrough-video.d.ts.map +1 -1
  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/index.cjs +2 -2
  88. package/dist/components/navigation/index.js +1 -1
  89. package/dist/components/navigation/navigation-sidebar-item.d.ts +23 -1
  90. package/dist/components/navigation/navigation-sidebar-item.d.ts.map +1 -1
  91. package/dist/components/navigation/navigation-sidebar.d.ts.map +1 -1
  92. package/dist/components/onboarding-guides/index.cjs +5 -5
  93. package/dist/components/onboarding-guides/index.js +4 -4
  94. package/dist/components/onboarding-guides/onboarding-guide-detail-view.d.ts.map +1 -1
  95. package/dist/components/related-content/index.cjs +3 -3
  96. package/dist/components/related-content/index.js +2 -2
  97. package/dist/components/shared/product-release/release-detail-page.d.ts.map +1 -1
  98. package/dist/components/tickets/index.cjs +5 -5
  99. package/dist/components/tickets/index.js +4 -4
  100. package/dist/components/ui/data-table/data-table-column-filter.d.ts.map +1 -1
  101. package/dist/components/ui/data-table/data-table-header.d.ts.map +1 -1
  102. package/dist/components/ui/data-table/data-table-skeleton.d.ts.map +1 -1
  103. package/dist/components/ui/index.cjs +2 -44
  104. package/dist/components/ui/index.cjs.map +1 -1
  105. package/dist/components/ui/index.d.ts +2 -1
  106. package/dist/components/ui/index.d.ts.map +1 -1
  107. package/dist/components/ui/index.js +13 -55
  108. package/dist/components/ui/rich-markdown-renderer.d.ts +34 -0
  109. package/dist/components/ui/rich-markdown-renderer.d.ts.map +1 -0
  110. package/dist/components/ui/simple-markdown-renderer.d.ts +73 -0
  111. package/dist/components/ui/simple-markdown-renderer.d.ts.map +1 -0
  112. package/dist/components/ui/tab-navigation.d.ts.map +1 -1
  113. package/dist/index.cjs +2 -60
  114. package/dist/index.cjs.map +1 -1
  115. package/dist/index.js +13 -71
  116. package/dist/utils/index.cjs +40 -169
  117. package/dist/utils/index.cjs.map +1 -1
  118. package/dist/utils/index.d.ts +0 -1
  119. package/dist/utils/index.d.ts.map +1 -1
  120. package/dist/utils/index.js +41 -162
  121. package/dist/utils/index.js.map +1 -1
  122. package/dist/utils/markdown-section-extractor.d.ts.map +1 -1
  123. package/package.json +1 -7
  124. package/src/components/chat/__tests__/chat-message-list.test.tsx +5 -293
  125. package/src/components/chat/chat-message-enhanced.tsx +20 -122
  126. package/src/components/chat/chat-message-list.tsx +109 -358
  127. package/src/components/chat/embeddable-chat.tsx +0 -6
  128. package/src/components/chat/hooks/.index.md +33 -30
  129. package/src/components/chat/hooks/.use-nats-chat-adapter.md +55 -36
  130. package/src/components/chat/hooks/.use-realtime-chunk-processor.md +77 -0
  131. package/src/components/chat/hooks/index.ts +1 -0
  132. package/src/components/chat/hooks/use-chat.ts +0 -8
  133. package/src/components/chat/hooks/use-nats-chat-adapter.ts +825 -243
  134. package/src/components/chat/hooks/use-realtime-chunk-processor.ts +489 -0
  135. package/src/components/chat/hooks/use-sse-chat-adapter.ts +684 -380
  136. package/src/components/chat/index.ts +0 -5
  137. package/src/components/chat/thinking-display.tsx +1 -1
  138. package/src/components/chat/types/.api.types.md +49 -39
  139. package/src/components/chat/types/.processing.types.md +58 -29
  140. package/src/components/chat/types/api.types.ts +73 -6
  141. package/src/components/chat/types/processing.types.ts +53 -11
  142. package/src/components/chat/utils/.chunk-parser.md +62 -0
  143. package/src/components/chat/utils/.index.md +40 -31
  144. package/src/components/chat/utils/chunk-parser.ts +262 -0
  145. package/src/components/chat/utils/extract-incomplete-message-state.ts +5 -63
  146. package/src/components/chat/utils/index.ts +8 -5
  147. package/src/components/chat/utils/process-historical-messages.ts +386 -358
  148. package/src/components/features/floating-walkthrough-video.tsx +3 -1
  149. package/src/components/navigation/navigation-sidebar-item.tsx +80 -30
  150. package/src/components/navigation/navigation-sidebar.tsx +106 -46
  151. package/src/components/onboarding-guides/onboarding-guide-detail-view.tsx +4 -22
  152. package/src/components/shared/legal-document/legal-document-page.tsx +1 -1
  153. package/src/components/shared/product-release/release-detail-page.tsx +4 -19
  154. package/src/components/ui/.simple-markdown-renderer.md +52 -0
  155. package/src/components/ui/__tests__/tab-navigation.test.tsx +159 -0
  156. package/src/components/ui/data-table/data-table-column-filter.tsx +7 -1
  157. package/src/components/ui/data-table/data-table-header.tsx +4 -1
  158. package/src/components/ui/data-table/data-table-skeleton.tsx +7 -0
  159. package/src/components/ui/index.ts +2 -1
  160. package/src/components/ui/release-changelog-section.tsx +1 -1
  161. package/src/components/ui/rich-markdown-renderer.tsx +1223 -0
  162. package/src/components/ui/simple-markdown-renderer.tsx +964 -0
  163. package/src/components/ui/tab-navigation.tsx +370 -171
  164. package/src/components/ui/ticket-info-section.tsx +1 -1
  165. package/src/utils/index.ts +0 -1
  166. package/src/utils/markdown-section-extractor.ts +56 -41
  167. package/dist/chat-protocol/decode.d.ts +0 -58
  168. package/dist/chat-protocol/decode.d.ts.map +0 -1
  169. package/dist/chat-protocol/encode.d.ts +0 -44
  170. package/dist/chat-protocol/encode.d.ts.map +0 -1
  171. package/dist/chat-protocol/env-flag.d.ts +0 -35
  172. package/dist/chat-protocol/env-flag.d.ts.map +0 -1
  173. package/dist/chat-protocol/events.d.ts +0 -207
  174. package/dist/chat-protocol/events.d.ts.map +0 -1
  175. package/dist/chat-protocol/frames.d.ts +0 -213
  176. package/dist/chat-protocol/frames.d.ts.map +0 -1
  177. package/dist/chat-protocol/index.cjs +0 -500
  178. package/dist/chat-protocol/index.cjs.map +0 -1
  179. package/dist/chat-protocol/index.d.ts +0 -18
  180. package/dist/chat-protocol/index.d.ts.map +0 -1
  181. package/dist/chat-protocol/index.js +0 -484
  182. package/dist/chat-protocol/index.js.map +0 -1
  183. package/dist/chat-protocol/ip-normalize.d.ts +0 -44
  184. package/dist/chat-protocol/ip-normalize.d.ts.map +0 -1
  185. package/dist/chat-protocol/nats-decoder.d.ts +0 -26
  186. package/dist/chat-protocol/nats-decoder.d.ts.map +0 -1
  187. package/dist/chunk-7N7YZCHR.cjs.map +0 -1
  188. package/dist/chunk-BEEPU44B.js.map +0 -1
  189. package/dist/chunk-JHEIOXXZ.js.map +0 -1
  190. package/dist/chunk-MEIGDK7M.cjs.map +0 -1
  191. package/dist/chunk-MEMRQTOI.js.map +0 -1
  192. package/dist/chunk-W56JYDSC.cjs.map +0 -1
  193. package/dist/components/chat/stream/chat-dialog-store.d.ts +0 -184
  194. package/dist/components/chat/stream/chat-dialog-store.d.ts.map +0 -1
  195. package/dist/components/chat/stream/chat-stream-reducer.d.ts +0 -367
  196. package/dist/components/chat/stream/chat-stream-reducer.d.ts.map +0 -1
  197. package/dist/components/chat/stream/delta-batcher.d.ts +0 -51
  198. package/dist/components/chat/stream/delta-batcher.d.ts.map +0 -1
  199. package/dist/components/chat/stream/index.d.ts +0 -17
  200. package/dist/components/chat/stream/index.d.ts.map +0 -1
  201. package/dist/components/chat/stream/message-mutations.d.ts +0 -77
  202. package/dist/components/chat/stream/message-mutations.d.ts.map +0 -1
  203. package/dist/components/chat/stream/use-chat-stream-reducer.d.ts +0 -22
  204. package/dist/components/chat/stream/use-chat-stream-reducer.d.ts.map +0 -1
  205. package/dist/components/ui/markdown/base-components.d.ts +0 -51
  206. package/dist/components/ui/markdown/base-components.d.ts.map +0 -1
  207. package/dist/components/ui/markdown/engine.d.ts +0 -99
  208. package/dist/components/ui/markdown/engine.d.ts.map +0 -1
  209. package/dist/components/ui/markdown/heading-ids.d.ts +0 -65
  210. package/dist/components/ui/markdown/heading-ids.d.ts.map +0 -1
  211. package/dist/components/ui/markdown/index.d.ts +0 -18
  212. package/dist/components/ui/markdown/index.d.ts.map +0 -1
  213. package/dist/components/ui/markdown/mermaid-diagram.d.ts +0 -79
  214. package/dist/components/ui/markdown/mermaid-diagram.d.ts.map +0 -1
  215. package/dist/components/ui/markdown/rich/embed-overrides.d.ts +0 -14
  216. package/dist/components/ui/markdown/rich/embed-overrides.d.ts.map +0 -1
  217. package/dist/components/ui/markdown/rich/rich-markdown-renderer.d.ts +0 -40
  218. package/dist/components/ui/markdown/rich/rich-markdown-renderer.d.ts.map +0 -1
  219. package/dist/components/ui/markdown/rich/shortcodes.d.ts +0 -8
  220. package/dist/components/ui/markdown/rich/shortcodes.d.ts.map +0 -1
  221. package/dist/components/ui/markdown/sanitize.d.ts +0 -153
  222. package/dist/components/ui/markdown/sanitize.d.ts.map +0 -1
  223. package/dist/components/ui/markdown/simple-markdown-renderer.d.ts +0 -23
  224. package/dist/components/ui/markdown/simple-markdown-renderer.d.ts.map +0 -1
  225. package/dist/components/ui/markdown/streaming.d.ts +0 -78
  226. package/dist/components/ui/markdown/streaming.d.ts.map +0 -1
  227. package/dist/components/ui/markdown/text-size.d.ts +0 -34
  228. package/dist/components/ui/markdown/text-size.d.ts.map +0 -1
  229. package/dist/utils/markdown-fences.d.ts +0 -42
  230. package/dist/utils/markdown-fences.d.ts.map +0 -1
  231. package/dist/utils/markdown-heading-id.d.ts +0 -85
  232. package/dist/utils/markdown-heading-id.d.ts.map +0 -1
  233. package/src/chat-protocol/__tests__/__snapshots__/nats-decoder-golden.test.ts.snap +0 -317
  234. package/src/chat-protocol/__tests__/chat-protocol.test.ts +0 -516
  235. package/src/chat-protocol/__tests__/env-flag.test.ts +0 -52
  236. package/src/chat-protocol/__tests__/ip-normalize.test.ts +0 -136
  237. package/src/chat-protocol/__tests__/nats-decoder-golden.test.ts +0 -232
  238. package/src/chat-protocol/decode.ts +0 -278
  239. package/src/chat-protocol/encode.ts +0 -71
  240. package/src/chat-protocol/env-flag.ts +0 -39
  241. package/src/chat-protocol/events.ts +0 -245
  242. package/src/chat-protocol/frames.ts +0 -245
  243. package/src/chat-protocol/index.ts +0 -21
  244. package/src/chat-protocol/ip-normalize.ts +0 -146
  245. package/src/chat-protocol/nats-decoder.ts +0 -258
  246. package/src/components/chat/__tests__/chat-message-streaming-memo.test.tsx +0 -207
  247. package/src/components/chat/__tests__/chat-pending-turn.test.tsx +0 -230
  248. package/src/components/chat/hooks/__tests__/__snapshots__/conversation-id-persistence-golden.test.ts.snap +0 -37
  249. package/src/components/chat/hooks/__tests__/__snapshots__/sse-stream-golden.test.ts.snap +0 -0
  250. package/src/components/chat/hooks/__tests__/conversation-id-persistence-golden.test.ts +0 -438
  251. package/src/components/chat/hooks/__tests__/sse-stream-golden.test.ts +0 -318
  252. package/src/components/chat/stream/__tests__/__snapshots__/chat-stream-reducer-golden.test.ts.snap +0 -940
  253. package/src/components/chat/stream/__tests__/chat-dialog-store.test.ts +0 -756
  254. package/src/components/chat/stream/__tests__/chat-stream-reducer-golden.test.ts +0 -460
  255. package/src/components/chat/stream/__tests__/chat-stream-reducer.test.ts +0 -973
  256. package/src/components/chat/stream/__tests__/delta-batcher.test.ts +0 -255
  257. package/src/components/chat/stream/__tests__/use-chat-stream-reducer.test.ts +0 -98
  258. package/src/components/chat/stream/chat-dialog-store.ts +0 -536
  259. package/src/components/chat/stream/chat-stream-reducer.ts +0 -1808
  260. package/src/components/chat/stream/delta-batcher.ts +0 -168
  261. package/src/components/chat/stream/index.ts +0 -55
  262. package/src/components/chat/stream/message-mutations.ts +0 -340
  263. package/src/components/chat/stream/use-chat-stream-reducer.ts +0 -126
  264. package/src/components/chat/utils/__tests__/__snapshots__/process-historical-messages-golden.test.ts.snap +0 -420
  265. package/src/components/chat/utils/__tests__/__snapshots__/segment-accumulator-golden.test.ts.snap +0 -605
  266. package/src/components/chat/utils/__tests__/process-historical-messages-golden.test.ts +0 -394
  267. package/src/components/chat/utils/__tests__/segment-accumulator-golden.test.ts +0 -270
  268. package/src/components/ui/__tests__/__snapshots__/markdown-parity.test.tsx.snap +0 -2420
  269. package/src/components/ui/__tests__/markdown-parity.test.tsx +0 -2528
  270. package/src/components/ui/markdown/__tests__/mermaid-security.test.ts +0 -115
  271. package/src/components/ui/markdown/__tests__/mermaid-stale-render.test.tsx +0 -95
  272. package/src/components/ui/markdown/__tests__/sanitize-invariant.test.ts +0 -309
  273. package/src/components/ui/markdown/__tests__/streaming.test.tsx +0 -388
  274. package/src/components/ui/markdown/base-components.tsx +0 -360
  275. package/src/components/ui/markdown/engine.tsx +0 -315
  276. package/src/components/ui/markdown/heading-ids.ts +0 -239
  277. package/src/components/ui/markdown/index.ts +0 -49
  278. package/src/components/ui/markdown/mermaid-diagram.tsx +0 -291
  279. package/src/components/ui/markdown/rich/embed-overrides.tsx +0 -199
  280. package/src/components/ui/markdown/rich/rich-markdown-renderer.tsx +0 -184
  281. package/src/components/ui/markdown/rich/shortcodes.ts +0 -170
  282. package/src/components/ui/markdown/sanitize.ts +0 -3499
  283. package/src/components/ui/markdown/simple-markdown-renderer.tsx +0 -28
  284. package/src/components/ui/markdown/streaming.ts +0 -362
  285. package/src/components/ui/markdown/text-size.ts +0 -106
  286. package/src/utils/markdown-fences.ts +0 -127
  287. package/src/utils/markdown-heading-id.ts +0 -348
  288. /package/dist/{chunk-PTXZB2KW.js.map → chunk-4J3CB4KO.js.map} +0 -0
  289. /package/dist/{chunk-XOFE2RT4.js.map → chunk-BTIRYGDT.js.map} +0 -0
  290. /package/dist/{chunk-PLB6CQSO.js.map → chunk-FG5ALORQ.js.map} +0 -0
  291. /package/dist/{chunk-NCAN6VUF.js.map → chunk-P3ITKTUE.js.map} +0 -0
  292. /package/dist/{chunk-34TDA22K.js.map → chunk-QHRNQTW5.js.map} +0 -0
  293. /package/dist/{chunk-OL3MJKUI.js.map → chunk-SZWMJIH3.js.map} +0 -0
  294. /package/dist/{chunk-MZRIRQYV.js.map → chunk-U5LU56JE.js.map} +0 -0
  295. /package/dist/{chunk-3LKB722U.js.map → chunk-WKWZ7LVC.js.map} +0 -0
  296. /package/dist/{chunk-J3VBVR4W.js.map → chunk-WPFLV4QJ.js.map} +0 -0
@@ -1,1808 +0,0 @@
1
- /**
2
- * createChatStreamReducer — THE master chat-stream reader (Phase 3 of the
3
- * chat unification). One accumulation path for every event source:
4
- *
5
- * SSE bytes → createSseFrameDecoder ─┐
6
- * NATS chunk → decodeNatsChunk ─┼→ ChatStreamEvent → reducer.apply()
7
- * history → decodeHistoricalMessageData (per-item vocabulary) →
8
- * envelope grouping → reducer.initializeWithState()
9
- *
10
- * The reducer absorbs, verbatim, the semantics that used to be spread over
11
- * three layers:
12
- * - `MessageSegmentAccumulator` stays as the internal per-turn segment
13
- * kernel (instantiated here — its goldens remain valid);
14
- * - the deleted `useRealtimeChunkProcessor`'s switch (in-stream vs post-END
15
- * routing, agent-busy outside a message window, escalated approvals,
16
- * compaction upsert, direct-mode barrier) — surfaced as
17
- * `ChatReducerEffect`s for consumers that want the callback contract;
18
- * - `useNatsChatAdapter`'s message-mutation callbacks (trailing-assistant
19
- * replace/append, compaction upsert, cross-message tool merge, approval
20
- * flip, participant dedup) — now the reducer's own state transitions via
21
- * `./message-mutations`;
22
- * - `useSseChatAdapter`/`useChat`'s SSE turn kernel (cumulative text
23
- * replace, front-inserted thinking, approval card, `decision_resolved`
24
- * receipt + cardRef stamping, sendIdx-keyed sources/refs/meta maps).
25
- *
26
- * Idempotency:
27
- * - events whose `seq` ≤ the last applied seq are dropped (per instance);
28
- * - participant rows additionally dedup via a one-shot optimistic-echo
29
- * list and a short same-author content window (seq-less transports);
30
- * - value-level no-op merges return prior references (see
31
- * `message-mutations.ts`).
32
- *
33
- * TRANSPORT ORDERING ASSUMPTION (the seq gate, see `apply()`): every
34
- * transport feeding this reducer is AT-LEAST-ONCE and IN-ORDER. JetStream
35
- * redelivers (hence the gate) but never reorders within a stream, and the
36
- * catchup back-fill replays a monotonically increasing range. So `seq <=
37
- * lastAppliedSeq` means "already applied", and dropping is correct.
38
- * A genuinely out-of-order arrival (seq 5 then 4) would be discarded
39
- * PERMANENTLY — accepted deliberately: no transport here reorders, and an
40
- * out-of-order buffer would add latency + unbounded state to guard against
41
- * a case that cannot occur. If a reordering transport is ever added, this
42
- * gate is the place that must change (do not paper over it downstream).
43
- *
44
- * `resolvePendingApprovalForExecution` (the accumulator's implicit
45
- * approve-on-execution) is reachable ONLY on `transport: 'nats'` — the SSE
46
- * kernel never routes tool executions through the accumulator.
47
- *
48
- * PURE + framework-free: no React, no timers, no network. Side effects the
49
- * UI needs (approve/reject POSTs) enter as opaque callbacks stamped onto
50
- * approval segments.
51
- */
52
-
53
- import {
54
- MessageSegmentAccumulator,
55
- createMessageSegmentAccumulator,
56
- type AccumulatorCallbacks,
57
- } from '../utils/message-segment-accumulator'
58
- import { getCommandText } from '../utils/tool-call-helpers'
59
- import { parseScrollAnchor, type ScrollAnchor } from '../utils/scroll-anchor'
60
- import { escapeThinkingTags } from '../../../chat-protocol/decode'
61
- import { buildChatRefKey } from '../types/chat.types'
62
- import type {
63
- ApprovalRequestSegment,
64
- ChatApprovalStatus,
65
- MessageSegment,
66
- PendingToolCallData,
67
- SegmentsUpdateMetadata,
68
- ToolExecutionSegment,
69
- ExecutingToolState,
70
- } from '../types'
71
- import type { PendingApproval } from '../types/processing.types'
72
- import type {
73
- DialogTokenUsage,
74
- StreamingPhase,
75
- UnifiedChatMessage,
76
- UnifiedUsageBreakdown,
77
- } from '../types/unified-chat-state.types'
78
- import {
79
- CHAT_OWNER_ADMIN,
80
- type ChatStreamEvent,
81
- type ApprovalResolvedEvent,
82
- type ChatMetadataEvent,
83
- type ParticipantEvent,
84
- type UsageEvent,
85
- } from '../../../chat-protocol/events'
86
- import type { ChatRef } from '../chat-ref.types'
87
- import {
88
- CONTENT_DEDUP_WINDOW,
89
- SYSTEM_DEDUP_WINDOW,
90
- appendToTrailingAssistant,
91
- applyToolExecutionToMessages,
92
- hasRecentMessage,
93
- mergeToolExecutionIfPresent,
94
- nextId,
95
- projectApprovalResolutionToMessages,
96
- updateTrailingAssistant,
97
- upsertTrailingCompaction,
98
- } from './message-mutations'
99
-
100
- // =============================================================================
101
- // Public types
102
- // =============================================================================
103
-
104
- /** Per-turn metadata extracted from the streamed metadata/usage frames
105
- * (SSE transport). Canonical home — `use-sse-chat-adapter` re-exports it. */
106
- export interface ChatTurnMeta {
107
- provider: string | null
108
- modelLabel: string | null
109
- contextWindowMaxTokens: number | null
110
- /** Input tokens (from usage:start). Includes cached tokens. */
111
- inputTokens: number | null
112
- /** Output tokens (from the trailing usage frame). */
113
- outputTokens: number | null
114
- /** Cache hit % (read / total-input × 100). Only known after stream end. */
115
- cacheHitRatePct: number | null
116
- /** Cross-call usage breakdown extracted from the trailing usage frame. */
117
- breakdown: UnifiedUsageBreakdown | null
118
- /** Per-message viewport-positioning hint. */
119
- scrollAnchor: ScrollAnchor | null
120
- routedComplexity: string | null
121
- routedThinkingBudget: number | null
122
- }
123
-
124
- /** Single source of truth for a fresh `ChatTurnMeta` row. */
125
- export function createEmptyTurnMeta(): ChatTurnMeta {
126
- return {
127
- provider: null,
128
- modelLabel: null,
129
- contextWindowMaxTokens: null,
130
- inputTokens: null,
131
- outputTokens: null,
132
- cacheHitRatePct: null,
133
- breakdown: null,
134
- scrollAnchor: null,
135
- routedComplexity: null,
136
- routedThinkingBudget: null,
137
- }
138
- }
139
-
140
- /** SSE per-send maps, keyed by the send counter (`sendIdx`). Each user send
141
- * produces ONE server-side refs/sources entry but can fan out to MULTIPLE
142
- * assistant messages client-side — the adapter maps every following
143
- * assistant message back to its send's entry. */
144
- export interface ChatTurnMetaState {
145
- meta: Map<number, ChatTurnMeta>
146
- sources: Map<number, unknown[]>
147
- refs: Map<number, Record<string, ChatRef>>
148
- sendCount: number
149
- }
150
-
151
- export interface ChatReducerState {
152
- messages: UnifiedChatMessage[]
153
- streamingPhase: StreamingPhase
154
- turnMeta: ChatTurnMetaState
155
- dialogTokenUsage?: DialogTokenUsage | null
156
- liveModel?: {
157
- provider: string | null
158
- modelLabel: string | null
159
- contextWindowMaxTokens: number | null
160
- } | null
161
- approvalStatuses: Record<string, ChatApprovalStatus>
162
- }
163
-
164
- /** Escalated-approval bookkeeping entry (mirrors the legacy processor). */
165
- export interface EscalatedApprovalData {
166
- command: string
167
- explanation?: string
168
- approvalType: string
169
- toolCalls?: PendingToolCallData[]
170
- }
171
-
172
- /**
173
- * Callback-visible effect produced by `apply()`. Names + args mirror the
174
- * legacy `RealtimeChunkCallbacks` contract 1:1 — the contract the deleted
175
- * `useRealtimeChunkProcessor` wrapper exposed, now pinned directly by
176
- * `__tests__/chat-stream-reducer-golden.test.ts`.
177
- *
178
- * `segments-after-approval-result` is the one conditional emission: the
179
- * legacy processor emitted a cumulative `onSegmentsUpdate` for a
180
- * non-escalated APPROVAL_RESULT only when the consumer had NOT wired
181
- * `onApprovalResolved` — the effect sink resolves that at dispatch time.
182
- */
183
- export interface ChatReducerEffect {
184
- name:
185
- | 'onStreamStart'
186
- | 'onStreamEnd'
187
- | 'onMetadata'
188
- | 'onSegmentsUpdate'
189
- | 'onError'
190
- | 'onUserMessage'
191
- | 'onTokenUsage'
192
- | 'onDirectMessage'
193
- | 'onSystemMessage'
194
- | 'onEscalatedApproval'
195
- | 'onEscalatedApprovalResult'
196
- | 'onApprovalResolved'
197
- | 'onToolExecuted'
198
- | 'onAgentBusy'
199
- | 'onDialogClosed'
200
- | 'segments-after-approval-result'
201
- args: unknown[]
202
- }
203
-
204
- export interface ChatStreamReducerOptions {
205
- /** Which transport's turn kernel drives `apply()`. Default `'nats'`. */
206
- transport?: 'sse' | 'nats'
207
- /** Seed for the approval-status map (request-id → status). */
208
- approvalStatuses?: Record<string, ChatApprovalStatus>
209
- /** Batch APPROVAL_REQUESTs render as one card (default true) or unfold. */
210
- batchApprovalsEnabled?: boolean
211
- /** Approval types displayed inline; others escalate. Default ['CLIENT']. */
212
- displayApprovalTypes?: string[]
213
- /** Opaque approve/reject handlers stamped onto approval segments. */
214
- callbacks?: AccumulatorCallbacks
215
- /** Engage the direct-mode barrier optimistically (host-known takeover). */
216
- isDirectMode?: boolean
217
- /**
218
- * Legacy-processor parity knob: post-MESSAGE_END tool chunks route
219
- * cross-message (`onToolExecuted` effect + `applyToolExecutionToMessages`)
220
- * only when the consumer wired the cross-message updater; otherwise they
221
- * fall through the accumulator like the pre-callback code path. Default
222
- * true (both first-party adapters wire it).
223
- */
224
- crossMessageToolRouting?: boolean
225
- /** Effect sink for callback-contract consumers (compat wrapper). */
226
- onEffect?: (effect: ChatReducerEffect) => void
227
- /** Notified after every state mutation (the dialog store wires this). */
228
- onChange?: () => void
229
- /**
230
- * Whether an ADMIN-authored `MESSAGE_REQUEST` may be consumed as OUR OWN
231
- * optimistic echo (see `pushOptimisticSend`). Default `false`.
232
- *
233
- * Who the local operator is decides this, and it differs per host:
234
- * - Hub website chat / ticket CLIENT side: the local user is NEVER the
235
- * admin, so an ADMIN-authored inbound row is a technician's reply and
236
- * must ALWAYS render. Consuming it as an echo would silently delete a
237
- * real message whenever its text happened to match ours. → `false`.
238
- * - OpenFrame product app (Mingo + ticket ADMIN side): the operator IS
239
- * the admin, so their own sends echo back as ADMIN and MUST be
240
- * deduped, or every message renders twice. → `true`.
241
- *
242
- * Only ever applies to text the host itself registered via
243
- * `pushOptimisticSend`, so enabling it cannot drop a message the host
244
- * did not just send.
245
- */
246
- ownEchoIncludesAdmin?: boolean
247
- /**
248
- * The LOCAL user's id, either as a value or as a GETTER resolved at event
249
- * time. When it resolves to a value, an inbound `MESSAGE_REQUEST` that
250
- * DECLARES a different author (`event.userId !== selfUserId`) may not be
251
- * consumed as our own optimistic echo.
252
- *
253
- * Without it the echo list matches on RAW TEXT alone, which on a shared
254
- * ADMIN side (two technicians in one ticket, `ownEchoIncludesAdmin: true`)
255
- * can silently delete a colleague's message: if OUR send never echoes back,
256
- * its entry stays armed and the next identical text from anyone — canned
257
- * replies like "ok" / "done" / "on it" make this routine — is swallowed.
258
- *
259
- * The guard FAILS OPEN, deliberately: a row that carries NO `userId` is not
260
- * "someone else's", it is UNATTRIBUTED, and rejecting it would disable
261
- * dedup entirely (→ every send rendered twice) on any transport whose
262
- * decoder does not surface the author id. Such a row falls back to the
263
- * text + `OWN_ECHO_TTL_MS` behaviour instead, and a one-shot `console.warn`
264
- * makes the missing id observable.
265
- *
266
- * Pass a FUNCTION whenever the id can change or arrive late (auth
267
- * rehydration, logout / login-as-another-user without a reload): reducers
268
- * are retained for the store's lifetime, so a value captured at creation
269
- * time can go stale and silently disable the guard.
270
- */
271
- selfUserId?: string | (() => string | undefined)
272
- }
273
-
274
- /**
275
- * How long an un-consumed optimistic-echo entry stays armed **on the
276
- * UNATTRIBUTED path**. Long enough to cover any realistic send → server-echo
277
- * round trip, short enough that a send whose echo never lands cannot swallow
278
- * an unrelated identical message later in the conversation.
279
- *
280
- * A row that is DECLARED ours (`selfUserId` set and `event.userId ===
281
- * selfUserId`) gets the LONGER `OWN_ECHO_AUTHOR_TTL_MS` instead — see there.
282
- */
283
- export const OWN_ECHO_TTL_MS = 30_000
284
-
285
- /**
286
- * How long an un-consumed entry stays armed on the AUTHOR-MATCHED path
287
- * (`selfUserId` set, inbound row declares the same author).
288
- *
289
- * The author check rules out CROSS-technician theft, but NOT the same user on
290
- * a second tab or device: a row THIS tab did not originate looks exactly like
291
- * an author-matched echo. So the entry still needs an upper bound — without
292
- * one, a send whose echo never lands (dropped frame, backend text
293
- * normalization, reconnect gap) leaves the entry armed for the reducer's
294
- * whole lifetime, and the next identical text from the same user on ANOTHER
295
- * tab is silently swallowed. With `MAX_PENDING_ECHOES` armed slots and canned
296
- * replies ("ok", "done", "on it") being exactly the recurring strings, that is
297
- * a routine message-LOSS bug, strictly worse than the duplicate row it trades
298
- * against.
299
- *
300
- * Ten minutes is the bound: generously longer than the slow echo the author
301
- * path exists to protect (a JetStream catch-up replay after a network gap
302
- * arrives long after the send, and its rows carry `seq`, so the seq-less
303
- * content-dedup fallback cannot rescue it), and far shorter than the
304
- * "same text again an hour later" window in which a stale entry does damage.
305
- *
306
- * It is a BACKSTOP, not the usual bound: `turn-end` disarms entries much
307
- * sooner (see `purgeEchoesAtTurnEnd`). The ten minutes only ever apply to a
308
- * dialog that never reports a turn boundary at all.
309
- */
310
- export const OWN_ECHO_AUTHOR_TTL_MS = 10 * 60_000
311
-
312
- /** Cap on simultaneously-armed echo entries (a backend that never echoes must
313
- * not grow the list without bound). */
314
- const MAX_PENDING_ECHOES = 5
315
-
316
- /** One armed optimistic-echo entry: the sent text and the wall-clock ms it was
317
- * armed at. Exported because the LRU-eviction round trip PARKS these (see
318
- * `getPendingEchoes` / `InitializeExtras.pendingEchoes`). */
319
- export interface PendingEcho {
320
- text: string
321
- at: number
322
- }
323
-
324
- export interface InitializeExtras {
325
- existingSegments?: MessageSegment[]
326
- pendingApprovals?: Map<string, PendingApproval>
327
- executingTools?: Map<string, ExecutingToolState>
328
- escalatedApprovals?: Map<string, EscalatedApprovalData>
329
- /**
330
- * Approval statuses PARKED from a previous instance of this key (LRU
331
- * eviction hands them to `onEvict`). Merged with the same state-monotonic
332
- * precedence as `mergeApprovalStatuses` — a resolved approval must not come
333
- * back as actionable, which is exactly why `resetForDialogSwitch` preserves
334
- * this map rather than clearing it.
335
- */
336
- approvalStatuses?: Record<string, ChatApprovalStatus>
337
- /**
338
- * Seq cursor PARKED from a previous instance of this key. A recreated
339
- * reducer starts at `-Infinity`, so a host that replays from its own cursor
340
- * would re-apply already-applied events; restoring the parked value keeps
341
- * `apply()`'s idempotency gate intact across an eviction. Only ever moves
342
- * the gate FORWARD (a lower value is ignored).
343
- */
344
- lastAppliedSeq?: number
345
- /**
346
- * Armed optimistic-echo entries PARKED from a previous instance of this key.
347
- * Without them, a key LRU-evicted between `pushOptimisticSend` and its
348
- * `MESSAGE_REQUEST` echo leaves the replacement reducer with nothing armed,
349
- * so the echo renders a DUPLICATE user bubble. Entries already past
350
- * `OWN_ECHO_AUTHOR_TTL_MS` are dropped on restore (an expired entry could
351
- * only swallow an unrelated identical message), and the list is capped at
352
- * `MAX_PENDING_ECHOES` exactly as the live path is.
353
- */
354
- pendingEchoes?: readonly PendingEcho[]
355
- /**
356
- * Whether the restored thread is a RESUMED dialog (a `MESSAGE_START` already
357
- * fired server-side), which makes post-stream continuation chunks append
358
- * into the existing bubble instead of taking the cold-start cumulative path.
359
- * Defaults to "the restored thread is non-empty" — an eviction restore of a
360
- * key that never streamed must NOT claim it did, or a cold text-delta with
361
- * no preceding `turn-start` appends into a bubble that was never spawned.
362
- * Pass explicitly only to override that derivation.
363
- */
364
- resumed?: boolean
365
- }
366
-
367
- export interface BeginSseSendOptions {
368
- text: string
369
- hidden?: boolean
370
- userName?: string
371
- assistantName?: string
372
- assistantAvatar?: string
373
- }
374
-
375
- // =============================================================================
376
- // Reducer
377
- // =============================================================================
378
-
379
- export interface ChatStreamReducer {
380
- /** Apply one normalized stream event. */
381
- apply(event: ChatStreamEvent): void
382
- /** Current immutable state snapshot (stable identity between mutations). */
383
- readonly state: ChatReducerState
384
- /** Full reset — thread, per-turn kernel, dedup sets, seq gate, maps. */
385
- reset(): void
386
- /**
387
- * Seed the thread (history hydration / persisted-state rehydration) and
388
- * optionally the per-turn kernel + escalated approvals (resume of an
389
- * incomplete turn). `messages: null` keeps the current thread. Escalated
390
- * entries are re-surfaced via `onEscalatedApproval` effects. Marks the
391
- * instance as having streamed so continuation chunks append.
392
- */
393
- initializeWithState(messages: UnifiedChatMessage[] | null, extras?: InitializeExtras): void
394
-
395
- // ── Thread commands (adapter-driven, not wire events) ──────────────────
396
- setMessages(messages: UnifiedChatMessage[]): void
397
- prependMessages(messages: UnifiedChatMessage[]): void
398
- /** Optimistic local send: user bubble (+ echo record) + assistant placeholder. */
399
- pushOptimisticSend(text: string, hidden?: boolean): void
400
- /** Wipe thread + per-turn kernel, keep approval statuses (legacy clear). */
401
- clearThread(): void
402
- /** Per-dialog reset (keeps approval statuses — request-ids are global). */
403
- resetForDialogSwitch(): void
404
-
405
- // ── SSE turn commands ───────────────────────────────────────────────────
406
- beginSseSend(options: BeginSseSendOptions): void
407
- endSseTurn(): void
408
- failSseTurn(errorMessage: string): void
409
- seedSseMaps(seed: {
410
- sources?: Array<[number, unknown[]]>
411
- refs?: Array<[number, Record<string, ChatRef>]>
412
- sendCount?: number
413
- }): void
414
-
415
- // ── Phase / status commands ─────────────────────────────────────────────
416
- setPhase(phase: StreamingPhase): void
417
- setApprovalStatus(requestId: string, status: ChatApprovalStatus | null): void
418
- /**
419
- * CANONICAL merge of a PERSISTED status map (host-side store, history
420
- * hydration, dialog-switch top-up) into this reducer's map.
421
- *
422
- * Precedence is STATE-MONOTONIC, not source-based, because either side can
423
- * be the stale one:
424
- * - persisted lags (a snapshot taken before the stream resolved the
425
- * request) → the stream's resolved status must win, or a just-approved
426
- * card would be downgraded back to `pending` and re-arm its buttons;
427
- * - the STREAM lags (this tab saw the request, the operator resolved it in
428
- * a SECOND TAB, and the persisted map is the one carrying the truth) →
429
- * the persisted resolution must win, or the card stays actionable and
430
- * invites a second approve on an already-resolved request.
431
- *
432
- * With statuses being exactly `pending | approved | rejected | cancelled`,
433
- * "resolved beats pending" is a total order that gets both directions
434
- * right: take the persisted value when ours is `pending` and theirs isn't;
435
- * keep ours otherwise. Persisted entries still fill in every request-id the
436
- * stream hasn't seen (other dialogs, pre-session history).
437
- */
438
- mergeApprovalStatuses(persisted: Record<string, ChatApprovalStatus>): void
439
- setDirectMode(isDirectMode: boolean): void
440
- setDialogTokenUsage(usage: DialogTokenUsage | null): void
441
- /** +1/-1 windows during catchup replay (suppresses agent-busy locks). */
442
- adjustAgentBusySuppression(delta: number): void
443
- armAdoptTrailingAssistant(value: boolean): void
444
-
445
- // ── Cross-side projections (dialog-store fan-out) ───────────────────────
446
- projectApprovalResolution(
447
- requestId: string,
448
- status: ChatApprovalStatus,
449
- resolvedByName?: string | null,
450
- ): void
451
- projectToolExecution(segment: ToolExecutionSegment): void
452
-
453
- /**
454
- * The seq gate's current value (`-Infinity` before anything seq-carrying was
455
- * applied). Not part of `state` — it is bookkeeping, not render input — but
456
- * a host that PARKS a reducer (LRU eviction, dialog swap) needs it to
457
- * restore idempotency on the recreated instance via
458
- * `initializeWithState(messages, { lastAppliedSeq })`.
459
- */
460
- getLastAppliedSeq(): number
461
-
462
- /**
463
- * Currently-armed optimistic-echo entries. Same rationale as
464
- * `getLastAppliedSeq`: not render input, but a host that PARKS a reducer
465
- * needs them to restore echo dedup on the recreated instance via
466
- * `initializeWithState(messages, { pendingEchoes })` — otherwise an echo
467
- * in flight across the eviction renders a duplicate user bubble.
468
- */
469
- getPendingEchoes(): readonly PendingEcho[]
470
-
471
- // ── Legacy processor surface (compat wrapper) ───────────────────────────
472
- getSegments(): MessageSegment[]
473
- updateApprovalStatus(
474
- requestId: string,
475
- status: ChatApprovalStatus,
476
- resolvedByName?: string | null,
477
- ): MessageSegment[]
478
- getPendingEscalated(): Map<string, EscalatedApprovalData>
479
- }
480
-
481
- // Actions allowed through once the dialog is in direct mode. Everything else
482
- // is an AI-assistant event and gets dropped — an allowlist so any future
483
- // assistant event is blocked by default.
484
- function isDirectModeAllowed(event: ChatStreamEvent): boolean {
485
- return event.type === 'participant' || event.type === 'dialog-closed'
486
- }
487
-
488
- export function createChatStreamReducer(
489
- options: ChatStreamReducerOptions = {},
490
- ): ChatStreamReducer {
491
- const {
492
- transport = 'nats',
493
- batchApprovalsEnabled = true,
494
- displayApprovalTypes = ['CLIENT'],
495
- callbacks = {},
496
- crossMessageToolRouting = true,
497
- onEffect,
498
- onChange,
499
- } = options
500
-
501
- // ── Mutable internals ──────────────────────────────────────────────────
502
- let messages: UnifiedChatMessage[] = []
503
- let streamingPhase: StreamingPhase = 'idle'
504
- let dialogTokenUsage: DialogTokenUsage | null = null
505
- let liveModel: ChatReducerState['liveModel'] = null
506
- let approvalStatuses: Record<string, ChatApprovalStatus> = {
507
- ...(options.approvalStatuses ?? {}),
508
- }
509
-
510
- const accumulator: MessageSegmentAccumulator = createMessageSegmentAccumulator(callbacks)
511
-
512
- // Stream-window flags (chunk-processor parity).
513
- let isInStream = false
514
- let hasEverStreamed = false
515
-
516
- // Direct-mode barrier.
517
- let directModeFlag = options.isDirectMode ?? false
518
- let sawDirectMessage = false
519
- let directTeardownFired = false
520
-
521
- // Escalated approvals (single or batch).
522
- const pendingEscalated = new Map<string, EscalatedApprovalData>()
523
-
524
- // Idempotency. The per-participant seen-seq set that used to live here was
525
- // REMOVED as redundant: `apply()`'s global gate already drops any event
526
- // whose seq was applied before, so a seq-carrying participant row can never
527
- // reach the handlers twice.
528
- let lastAppliedSeq = Number.NEGATIVE_INFINITY
529
- let pendingEchoTexts: PendingEcho[] = []
530
- /** `Date.now()` of the last observed `turn-start`; 0 = none seen yet.
531
- * Bounds `purgeEchoesAtTurnEnd` — see there. */
532
- let turnStartedAt = 0
533
-
534
- /** Consume a one-shot optimistic-echo entry for `text`. Returns true when
535
- * this inbound row IS our own echo and must not render.
536
- *
537
- * `authorMatched` widens the expiry window (`OWN_ECHO_AUTHOR_TTL_MS` vs
538
- * `OWN_ECHO_TTL_MS`) — it does NOT remove it. The author check rules out a
539
- * colleague's message, not the same user's second tab, so an entry whose
540
- * echo never landed must still age out or it eventually swallows a real
541
- * message. `MAX_PENDING_ECHOES` bounds growth on both paths. */
542
- function consumeOwnEcho(text: string, authorMatched = false): boolean {
543
- if (pendingEchoTexts.length === 0) return false
544
- const now = Date.now()
545
- // Purge at the WIDEST bound only: a short-TTL (unattributed) lookup must
546
- // not evict an entry that a later author-matched row is still entitled to
547
- // consume. The per-path window is applied at match time instead.
548
- pendingEchoTexts = pendingEchoTexts.filter((e) => now - e.at < OWN_ECHO_AUTHOR_TTL_MS)
549
- const ttl = authorMatched ? OWN_ECHO_AUTHOR_TTL_MS : OWN_ECHO_TTL_MS
550
- const idx = pendingEchoTexts.findIndex((e) => e.text === text && now - e.at < ttl)
551
- if (idx === -1) return false
552
- pendingEchoTexts.splice(idx, 1)
553
- return true
554
- }
555
-
556
- /** Disarm every echo entry that was armed BEFORE the turn this `turn-end`
557
- * closes (i.e. before the last observed `turn-start`).
558
- *
559
- * A `turn-end` is proof the server finished processing everything it had
560
- * accepted up to that point, so an echo for a send made BEFORE the turn
561
- * opened that has not landed by now never will (dropped frame, backend
562
- * text normalization, reconnect gap). Keeping the entry armed for the full
563
- * `OWN_ECHO_AUTHOR_TTL_MS` preserves up to ten minutes in which the same
564
- * user's identical send from a SECOND TAB is silently swallowed — the
565
- * message-LOSS failure this module ranks strictly worse than the duplicate
566
- * row it trades against. Draining at the turn boundary cuts that window
567
- * from ten minutes to one turn.
568
- *
569
- * The purge is SELECTIVE (`at > turnStartedAt`) rather than unconditional:
570
- * an entry armed DURING the turn being ended is not stale by the argument
571
- * above — its echo is still in flight — and dropping it duplicates the
572
- * user's bubble. Such entries survive to their normal author TTL; entries
573
- * from earlier turns still drain here.
574
- *
575
- * WHAT THIS COVERS, EXACTLY. `turnStartedAt` is the ARRIVAL wall-clock of
576
- * the last `turn-start` this reducer saw, NOT the event's own time: the
577
- * normalized event union carries no timestamp. `ChatStreamEventBase` has a
578
- * single optional field, `seq` (JetStream `streamSeq`, lifted by
579
- * `decodeNatsChunk`); the SSE decoder emits a bare `{ type: 'turn-start' }`
580
- * with no envelope at all. The boundary is therefore an ARRIVAL order, and
581
- * only these cases are actually handled:
582
- *
583
- * - COVERED — a `turn-end` with NO observed `turn-start` purges nothing
584
- * (`turnStartedAt` starts at 0; there is no boundary to be stale
585
- * relative to). This is the approval-interrupt shape as the adapter
586
- * drives it — sending while an approval pends cancels the in-flight
587
- * turn, so the interrupted turn's `turn-end` lands after the new send
588
- * armed its entry — and any stale `turn-end` on a fresh reducer.
589
- * - COVERED — `turn-start` observed BEFORE the send, `turn-end` after: the
590
- * entry was armed past the boundary and survives.
591
- * - NOT COVERED — a reconnect catch-up replay that delivers BOTH a
592
- * pre-disconnect `turn-start` and its `turn-end` after the user has
593
- * already sent. The replayed `turn-start` restamps `turnStartedAt` to
594
- * now, so the following `turn-end` purges a still-live entry and the
595
- * echo renders a SECOND bubble. This is not fixable at this layer: with
596
- * no event-own timestamp, that interleaving is indistinguishable from
597
- * "the send was queued mid-turn, and this reducer's own turn opened and
598
- * closed before the echo landed" — a case the module deliberately
599
- * resolves toward the duplicate row rather than risk swallowing a real
600
- * message. `seq` does not disambiguate it either: a replay whose seqs
601
- * were already applied is dropped by `apply()`'s idempotency gate before
602
- * reaching here, so any replay that DOES reach here carries
603
- * strictly-increasing unseen seqs — exactly what a live turn carries.
604
- * Pinned by the "a replayed turn boundary after a send duplicates the
605
- * bubble" test. Fix it by giving the transport an event-own time, not by
606
- * widening the purge here.
607
- *
608
- * `OWN_ECHO_AUTHOR_TTL_MS` remains the backstop for dialogs that never
609
- * report a turn boundary at all.
610
- *
611
- * Same-millisecond ties break toward PURGING (`>`, not `>=`): a send and
612
- * the turn it opened share a timestamp, and that entry IS the pre-turn
613
- * one. A send made DURING a turn is a human action a turn-start later, so
614
- * it never ties in practice. */
615
- function purgeEchoesAtTurnEnd(): void {
616
- if (pendingEchoTexts.length === 0) return
617
- if (turnStartedAt === 0) return
618
- pendingEchoTexts = pendingEchoTexts.filter((e) => e.at > turnStartedAt)
619
- }
620
-
621
- /** Resolve `selfUserId` AT EVENT TIME — a getter lets the host track auth
622
- * rehydration / user switches on a reducer that outlives both. */
623
- function resolveSelfUserId(): string | undefined {
624
- const self = options.selfUserId
625
- return typeof self === 'function' ? self() : self
626
- }
627
-
628
- /** One-shot: the host configured `selfUserId` but inbound rows carry none,
629
- * so the author guard is inert and dedup silently degrades to text+TTL. */
630
- let warnedIdLessAuthor = false
631
- function warnIdLessAuthor(): void {
632
- if (warnedIdLessAuthor) return
633
- warnedIdLessAuthor = true
634
- console.warn(
635
- '[chat] selfUserId is configured but an inbound MESSAGE_REQUEST carries no userId — ' +
636
- 'the author echo guard is inert and dedup falls back to text + TTL matching. ' +
637
- 'Check the transport decoder surfaces the author id (the ticket wire model ' +
638
- 'declares it at `owner.userId`) and that it is in the SAME id space as selfUserId.',
639
- )
640
- }
641
-
642
- // Adapter-armed flags.
643
- let suppressAgentBusy = 0
644
- let adoptTrailingAssistant = false
645
-
646
- // SSE per-send maps + per-turn kernel.
647
- const metaMap = new Map<number, ChatTurnMeta>()
648
- const sourcesMap = new Map<number, unknown[]>()
649
- const refsMap = new Map<number, Record<string, ChatRef>>()
650
- let sendCount = 0
651
- let sseCurrentText = ''
652
-
653
- // ── Snapshot cache ─────────────────────────────────────────────────────
654
- let stateCache: ChatReducerState | null = null
655
- function invalidate(): void {
656
- stateCache = null
657
- onChange?.()
658
- }
659
- function getState(): ChatReducerState {
660
- if (!stateCache) {
661
- stateCache = {
662
- messages,
663
- streamingPhase,
664
- turnMeta: { meta: metaMap, sources: sourcesMap, refs: refsMap, sendCount },
665
- dialogTokenUsage,
666
- liveModel,
667
- approvalStatuses,
668
- }
669
- }
670
- return stateCache
671
- }
672
-
673
- function emit(name: ChatReducerEffect['name'], ...args: unknown[]): void {
674
- onEffect?.({ name, args })
675
- }
676
-
677
- function setMessagesInternal(next: UnifiedChatMessage[]): void {
678
- if (next !== messages) {
679
- messages = next
680
- invalidate()
681
- }
682
- }
683
-
684
- function setPhaseInternal(phase: StreamingPhase): void {
685
- if (streamingPhase !== phase) {
686
- streamingPhase = phase
687
- invalidate()
688
- }
689
- }
690
-
691
- /** onAgentBusy state semantics: only 'idle' upgrades to 'thinking'; an
692
- * open stream keeps ownership. Suppressed during catchup replay. */
693
- function agentBusyState(): void {
694
- if (suppressAgentBusy > 0) return
695
- if (streamingPhase === 'idle') setPhaseInternal('thinking')
696
- }
697
-
698
- function mirrorApprovalStatus(requestId: string, status: ChatApprovalStatus): void {
699
- if (approvalStatuses[requestId] === status) return
700
- approvalStatuses = { ...approvalStatuses, [requestId]: status }
701
- invalidate()
702
- }
703
-
704
- // ===========================================================================
705
- // NATS kernel — the absorbed chunk-processor switch + adapter callbacks body
706
- // ===========================================================================
707
-
708
- function applySegmentsToState(segments: MessageSegment[], meta?: SegmentsUpdateMetadata): void {
709
- // Standalone compaction updates carry the accumulator's cumulative
710
- // array — apply only the compaction segment (upsert) or interleaved
711
- // continuation text would duplicate.
712
- if (meta?.append && meta.isCompacting) {
713
- setMessagesInternal(upsertTrailingCompaction(messages, segments))
714
- return
715
- }
716
- // Post-MESSAGE_END continuation fragments append into the existing
717
- // bubble; replacing would wipe the completed reply.
718
- if (meta?.append) {
719
- setMessagesInternal(appendToTrailingAssistant(messages, segments))
720
- return
721
- }
722
- setMessagesInternal(updateTrailingAssistant(messages, segments))
723
- }
724
-
725
- function applyStreamStartToState(): void {
726
- setPhaseInternal('streaming')
727
- // A new stream must never overwrite a COMPLETED trailing assistant
728
- // bubble (observer tab / second device / post-approval continuation
729
- // turn). Open a fresh bubble unless the trailing assistant is empty
730
- // (our own optimistic placeholder) or is the incomplete history tail
731
- // the catchup replay is legitimately re-streaming (adopt-once flag).
732
- const adoptTail = adoptTrailingAssistant
733
- adoptTrailingAssistant = false
734
- const last = messages[messages.length - 1]
735
- if (!last || last.role !== 'assistant' || adoptTail) return
736
- const hasContent = (last.segments?.length ?? 0) > 0 || last.content !== ''
737
- if (!hasContent) return
738
- setMessagesInternal([
739
- ...messages,
740
- { id: nextId('assistant'), role: 'assistant', content: '', segments: [] },
741
- ])
742
- }
743
-
744
- /** MESSAGE_REQUEST echo — a user message from THIS or another session.
745
- * Dedup layers in priority order: seq identity → one-shot optimistic-echo
746
- * consumption → same-author content window (seq-less transports only). */
747
- function applyUserMessageToState(ev: ParticipantEvent): void {
748
- const text = ev.text
749
- if (!text) return
750
- const seq = ev.seq
751
- // ADMIN rows are only echo-consumable when the host declares that its
752
- // operator IS the admin (see `ownEchoIncludesAdmin`). Otherwise an
753
- // ADMIN row is someone else's message and must always render.
754
- const adminEchoAllowed =
755
- ev.ownerType !== CHAT_OWNER_ADMIN || options.ownEchoIncludesAdmin === true
756
- // AUTHOR guard: with `selfUserId` configured, a row that DECLARES a
757
- // DIFFERENT author may not consume an echo entry. Two technicians on the
758
- // same ADMIN side both author ADMIN rows, so text alone cannot tell "my
759
- // echo" from "their message".
760
- //
761
- // It fails OPEN on an id-less row: the decoder may not surface the author
762
- // (the ticket wire model declares it at `owner.userId`, and the app's id
763
- // may live in another id space entirely), and treating "unattributed" as
764
- // "not mine" would disable dedup outright — every send rendered TWICE,
765
- // strictly worse than the bug the guard fixes. Unattributed rows fall back
766
- // to the text + TTL behaviour, with a one-shot warning so the
767
- // misconfiguration is observable.
768
- const selfUserId = resolveSelfUserId()
769
- const authorDeclared = typeof ev.userId === 'string' && ev.userId !== ''
770
- if (selfUserId !== undefined && !authorDeclared) warnIdLessAuthor()
771
- const authorMatched = selfUserId !== undefined && authorDeclared && ev.userId === selfUserId
772
- const authorEchoAllowed = selfUserId === undefined || !authorDeclared || authorMatched
773
- if (adminEchoAllowed && authorEchoAllowed && consumeOwnEcho(text, authorMatched)) return
774
- const authorType = ev.ownerType === CHAT_OWNER_ADMIN ? ('admin' as const) : ('user' as const)
775
- if (
776
- typeof seq !== 'number' &&
777
- hasRecentMessage(
778
- messages,
779
- (m) => m.role === 'user' && (m.authorType ?? 'user') === authorType && m.content === text,
780
- CONTENT_DEDUP_WINDOW,
781
- )
782
- ) {
783
- return
784
- }
785
- setMessagesInternal([
786
- ...messages,
787
- {
788
- id: nextId('user'),
789
- role: 'user',
790
- content: text,
791
- ...(ev.displayName ? { name: ev.displayName } : {}),
792
- authorType,
793
- ...(ev.contextItems && ev.contextItems.length > 0
794
- ? { contextItems: ev.contextItems }
795
- : {}),
796
- },
797
- ])
798
- }
799
-
800
- /** Technician / admin direct message into the dialog. */
801
- function applyDirectMessageToState(ev: ParticipantEvent): void {
802
- const text = ev.text
803
- if (!text) return
804
- const seq = ev.seq
805
- // Seq-less fallback matches only prior ADMIN-authored twins — a client's
806
- // identical text must never suppress the technician's.
807
- if (
808
- typeof seq !== 'number' &&
809
- hasRecentMessage(
810
- messages,
811
- (m) => m.role === 'user' && m.authorType === 'admin' && m.content === text,
812
- CONTENT_DEDUP_WINDOW,
813
- )
814
- ) {
815
- return
816
- }
817
- setMessagesInternal([
818
- ...messages,
819
- {
820
- id: `direct-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`,
821
- role: 'user',
822
- content: text,
823
- name: ev.displayName ?? 'Admin',
824
- authorType: 'admin',
825
- },
826
- ])
827
- }
828
-
829
- /** System notice — rendered as a name-only row (same shape the history
830
- * processor produces via its standalone-SYSTEM path). */
831
- function applySystemMessageToState(ev: ParticipantEvent): void {
832
- const text = ev.text
833
- if (!text) return
834
- const seq = ev.seq
835
- if (
836
- typeof seq !== 'number' &&
837
- hasRecentMessage(
838
- messages,
839
- (m) => m.authorType === 'system' && m.name === text,
840
- SYSTEM_DEDUP_WINDOW,
841
- )
842
- ) {
843
- return
844
- }
845
- setMessagesInternal([
846
- ...messages,
847
- {
848
- id: `system-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`,
849
- role: 'user',
850
- content: '',
851
- name: text,
852
- authorType: 'system',
853
- },
854
- ])
855
- }
856
-
857
- function applyNats(event: ChatStreamEvent): void {
858
- const seq = event.seq
859
- const withSeqMeta = (meta?: SegmentsUpdateMetadata): SegmentsUpdateMetadata | undefined =>
860
- seq != null ? { ...meta, streamSeq: seq } : meta
861
- const emitSegments = (segments: MessageSegment[], meta?: SegmentsUpdateMetadata) =>
862
- emit('onSegmentsUpdate', segments, withSeqMeta(meta))
863
-
864
- if (event.type === 'participant' && event.kind === 'direct-message') {
865
- sawDirectMessage = true
866
- }
867
-
868
- if ((directModeFlag || sawDirectMessage) && !isDirectModeAllowed(event)) {
869
- // Hard stop: tear down AI activity exactly once so the typing
870
- // indicator, composer lock, and half-rendered bubble clear, then drop
871
- // the event. Fires even when no stream is open — an agent-busy lock
872
- // (approved commands executing post-MESSAGE_END) has no other release
873
- // once the barrier starts dropping the continuation chunks.
874
- if (isInStream || !directTeardownFired) {
875
- isInStream = false
876
- directTeardownFired = true
877
- emit('onStreamEnd')
878
- setPhaseInternal('idle')
879
- accumulator.resetSegments()
880
- }
881
- return
882
- }
883
-
884
- switch (event.type) {
885
- case 'turn-start': {
886
- isInStream = true
887
- hasEverStreamed = true
888
- turnStartedAt = Date.now()
889
- emit('onStreamStart')
890
- applyStreamStartToState()
891
- accumulator.resetSegments()
892
- break
893
- }
894
-
895
- case 'turn-end': {
896
- isInStream = false
897
- purgeEchoesAtTurnEnd()
898
- emit('onStreamEnd')
899
- setPhaseInternal('idle')
900
- accumulator.resetSegments()
901
- break
902
- }
903
-
904
- case 'metadata': {
905
- // Legacy `parseChunkToAction` action shape, reconstructed for the
906
- // callback contract.
907
- emit('onMetadata', {
908
- action: 'metadata',
909
- modelDisplayName: event.modelLabel ?? undefined,
910
- modelName: event.modelName,
911
- providerName: event.provider,
912
- contextWindow: event.contextWindowMaxTokens,
913
- })
914
- liveModel = {
915
- provider: event.provider || null,
916
- modelLabel: event.modelLabel || event.modelName || null,
917
- contextWindowMaxTokens: event.contextWindowMaxTokens || null,
918
- }
919
- invalidate()
920
- break
921
- }
922
-
923
- // The three APPEND-ONLY body streams share one shape: accumulate the
924
- // verbatim slice into the trailing segment of the matching kind (the
925
- // accumulator coalesces), then emit. `guide-delta` is NATS-only today
926
- // but rides the identical path so a guide body streams, coalesces and
927
- // continues post-MESSAGE_END exactly like answer text.
928
- case 'text-delta':
929
- case 'thinking-delta':
930
- case 'guide-delta': {
931
- const kind =
932
- event.type === 'text-delta'
933
- ? 'text'
934
- : event.type === 'thinking-delta'
935
- ? 'thinking'
936
- : 'guide'
937
- const segments =
938
- kind === 'text'
939
- ? accumulator.appendText(event.text)
940
- : kind === 'thinking'
941
- ? accumulator.appendThinking(event.text)
942
- : accumulator.appendGuide(event.text)
943
- // Append-mode only for *true* post-stream continuation (after a
944
- // MESSAGE_END we actually saw). Cold-start chunks (no prior
945
- // MESSAGE_START) emit cumulative segments so the consumer can spawn
946
- // the first assistant bubble.
947
- if (isInStream || !hasEverStreamed) {
948
- emitSegments(segments)
949
- applySegmentsToState(segments, withSeqMeta(undefined))
950
- } else {
951
- const delta: MessageSegment[] = [{ type: kind, text: event.text }]
952
- emitSegments(delta, { append: true })
953
- applySegmentsToState(delta, withSeqMeta({ append: true }))
954
- }
955
- break
956
- }
957
-
958
- case 'tool-execution': {
959
- const segment: ToolExecutionSegment = { type: 'tool_execution', data: event.data }
960
- // A starting tool run means the agent's turn is in progress even
961
- // when this lands after MESSAGE_END (approved commands execute
962
- // between the approval bubble and the continuation stream).
963
- if (event.data.type === 'EXECUTING_TOOL') {
964
- emit('onAgentBusy')
965
- agentBusyState()
966
- }
967
- // Post-MESSAGE_END tool chunks (cancellations / async batch results
968
- // for a batch in a prior bubble) flow only through the cross-message
969
- // updater. Skipping the accumulator avoids pushing a standalone
970
- // segment that the next text chunk would replay into a new bubble.
971
- if (!isInStream && crossMessageToolRouting) {
972
- emit('onToolExecuted', segment)
973
- setMessagesInternal(applyToolExecutionToMessages(messages, segment))
974
- break
975
- }
976
- // In-stream: accumulator-driven update of the streaming bubble is
977
- // the source of truth. The cross-message scan is first-match-wins
978
- // and could touch a same-execId segment in a prior bubble (agent
979
- // retry case), so it is NOT run here.
980
- const segments = accumulator.addToolExecution(segment)
981
- emitSegments(segments)
982
- applySegmentsToState(segments, withSeqMeta(undefined))
983
- break
984
- }
985
-
986
- case 'approval-request': {
987
- const requestId = event.requestId
988
- const approvalType = event.approvalType ?? 'USER'
989
- const toolCalls = event.toolCalls
990
-
991
- if (toolCalls && toolCalls.length > 0) {
992
- // ── Batch form ──
993
- const status = (approvalStatuses[requestId] || 'pending') as ChatApprovalStatus
994
- if (!displayApprovalTypes.includes(approvalType)) {
995
- // Escalated: keep batch context locally for replay on result;
996
- // surface a summary command via the legacy escalation callback.
997
- const required = toolCalls.find((c) => c.requiresApproval) ?? toolCalls[0]
998
- const summary = required
999
- ? getCommandText(required)
1000
- : `Batch of ${toolCalls.length} tool calls`
1001
- pendingEscalated.set(requestId, {
1002
- command: summary,
1003
- explanation: required?.toolExplanation,
1004
- approvalType,
1005
- toolCalls,
1006
- })
1007
- emit('onEscalatedApproval', requestId, {
1008
- command: summary,
1009
- explanation: required?.toolExplanation,
1010
- approvalType,
1011
- })
1012
- break
1013
- }
1014
- if (batchApprovalsEnabled) {
1015
- const segments = accumulator.addApprovalBatch(requestId, approvalType, toolCalls, status)
1016
- emitSegments(segments)
1017
- applySegmentsToState(segments, withSeqMeta(undefined))
1018
- break
1019
- }
1020
- // Flag OFF — unfold batch into N legacy approval cards. They share
1021
- // `requestId`, so a click on any will approve the whole batch via a
1022
- // single backend call, and the resulting APPROVAL_RESULT event will
1023
- // flip status on every matching segment.
1024
- let segments = accumulator.getSegments()
1025
- for (const call of toolCalls) {
1026
- if (!call.requiresApproval) continue
1027
- segments = accumulator.addApprovalRequest(
1028
- requestId,
1029
- getCommandText(call),
1030
- call.toolExplanation,
1031
- approvalType,
1032
- status,
1033
- )
1034
- }
1035
- emitSegments(segments)
1036
- applySegmentsToState(segments, withSeqMeta(undefined))
1037
- break
1038
- }
1039
-
1040
- // ── Single form ──
1041
- const command = event.command ?? ''
1042
- const explanation = event.explanation
1043
- if (displayApprovalTypes.includes(approvalType)) {
1044
- const status = (approvalStatuses[requestId] || 'pending') as ChatApprovalStatus
1045
- const segments = accumulator.addApprovalRequest(
1046
- requestId,
1047
- command,
1048
- explanation,
1049
- approvalType,
1050
- status,
1051
- )
1052
- emitSegments(segments)
1053
- applySegmentsToState(segments, withSeqMeta(undefined))
1054
- } else {
1055
- pendingEscalated.set(requestId, { command, explanation, approvalType })
1056
- emit('onEscalatedApproval', requestId, { command, explanation, approvalType })
1057
- }
1058
- break
1059
- }
1060
-
1061
- case 'approval-resolved': {
1062
- const requestId = event.requestId ?? ''
1063
- const status = event.status
1064
- const approved = status === 'approved'
1065
- const approvalType = event.approvalType ?? 'CLIENT'
1066
- const resolvedByName = event.resolvedByName
1067
- // Approved → the agent resumes to execute the command(s); surface
1068
- // busy immediately so the composer locks before EXECUTING_TOOL
1069
- // lands. Rejection keeps the input free — the user may want to type
1070
- // a correction right away.
1071
- if (approved) {
1072
- emit('onAgentBusy')
1073
- agentBusyState()
1074
- }
1075
- const escalatedData = pendingEscalated.get(requestId)
1076
-
1077
- if (escalatedData) {
1078
- pendingEscalated.delete(requestId)
1079
- emit('onEscalatedApprovalResult', requestId, approved, {
1080
- command: escalatedData.command,
1081
- explanation: escalatedData.explanation,
1082
- approvalType: escalatedData.approvalType,
1083
- })
1084
-
1085
- // The escalated card was never displayed inline, so this emit is
1086
- // what surfaces it after resolution. In-stream the cumulative
1087
- // array is correct; post-MESSAGE_END the accumulator was RESET, so
1088
- // a cumulative (replace-mode) emit would wipe the trailing bubble
1089
- // down to the lone card — emit only the resolved card(s) as an
1090
- // append instead. Appends upsert by request id, so a replayed
1091
- // append stays idempotent.
1092
- const emitResolved = (segments: MessageSegment[]) => {
1093
- if (isInStream) {
1094
- emitSegments(segments)
1095
- applySegmentsToState(segments, withSeqMeta(undefined))
1096
- return
1097
- }
1098
- const delta = segments.filter(
1099
- (s) =>
1100
- (s.type === 'approval_request' && s.data.requestId === requestId) ||
1101
- (s.type === 'approval_batch' && s.data.approvalRequestId === requestId),
1102
- )
1103
- emitSegments(delta, { append: true })
1104
- applySegmentsToState(delta, withSeqMeta({ append: true }))
1105
- }
1106
-
1107
- if (escalatedData.toolCalls && escalatedData.toolCalls.length > 0) {
1108
- if (batchApprovalsEnabled) {
1109
- emitResolved(
1110
- accumulator.addApprovalBatch(
1111
- requestId,
1112
- escalatedData.approvalType,
1113
- escalatedData.toolCalls,
1114
- status,
1115
- undefined,
1116
- resolvedByName,
1117
- ),
1118
- )
1119
- } else {
1120
- let segments = accumulator.getSegments()
1121
- for (const call of escalatedData.toolCalls) {
1122
- if (!call.requiresApproval) continue
1123
- segments = accumulator.addApprovalRequest(
1124
- requestId,
1125
- getCommandText(call),
1126
- call.toolExplanation,
1127
- escalatedData.approvalType,
1128
- status,
1129
- )
1130
- }
1131
- emitResolved(segments)
1132
- }
1133
- } else {
1134
- emitResolved(
1135
- accumulator.addApprovalRequest(
1136
- requestId,
1137
- escalatedData.command,
1138
- escalatedData.explanation,
1139
- escalatedData.approvalType,
1140
- status,
1141
- ),
1142
- )
1143
- }
1144
- } else {
1145
- // Always keep the in-memory accumulator in sync so a following
1146
- // text/tool event replays the resolved status into the message.
1147
- accumulator.updateApprovalStatus(requestId, status, resolvedByName)
1148
- // Conditional legacy emission — forwarded as onSegmentsUpdate only
1149
- // when the consumer did NOT wire onApprovalResolved (the wrapper
1150
- // resolves the condition at dispatch time).
1151
- emit('segments-after-approval-result', accumulator.getSegments(), withSeqMeta(undefined))
1152
- }
1153
-
1154
- // Cross-message status flip + status-map mirror (absorbed adapter
1155
- // handler) — runs for both branches, matching the legacy adapter
1156
- // whose onApprovalResolved callback fired unconditionally.
1157
- setMessagesInternal(
1158
- projectApprovalResolutionToMessages(messages, requestId, status, resolvedByName),
1159
- )
1160
- mirrorApprovalStatus(requestId, status)
1161
- emit('onApprovalResolved', requestId, status, approvalType, resolvedByName)
1162
- break
1163
- }
1164
-
1165
- case 'error': {
1166
- let message: string | undefined
1167
- if (event.details) {
1168
- try {
1169
- message = JSON.parse(event.details)?.error?.message
1170
- } catch {
1171
- message = event.details
1172
- }
1173
- }
1174
- const segments = accumulator.addError(event.title, message)
1175
- emitSegments(segments)
1176
- applySegmentsToState(segments, withSeqMeta(undefined))
1177
- emit('onError', event.title, message)
1178
- // Terminal turn failures can arrive without a MESSAGE_END; unlock
1179
- // the composer unless an open stream still owns the phase.
1180
- if (streamingPhase !== 'streaming') setPhaseInternal('idle')
1181
- break
1182
- }
1183
-
1184
- case 'participant': {
1185
- if (event.kind === 'message-request') {
1186
- emit('onUserMessage', event.text, {
1187
- ownerType: event.ownerType,
1188
- displayName: event.displayName,
1189
- userId: event.userId,
1190
- streamSeq: seq,
1191
- contextItems: event.contextItems,
1192
- })
1193
- applyUserMessageToState(event)
1194
- } else if (event.kind === 'direct-message') {
1195
- emit('onDirectMessage', event.text, {
1196
- ownerType: event.ownerType,
1197
- displayName: event.displayName,
1198
- userId: event.userId,
1199
- streamSeq: seq,
1200
- })
1201
- applyDirectMessageToState(event)
1202
- } else {
1203
- emit('onSystemMessage', event.text, { streamSeq: seq })
1204
- applySystemMessageToState(event)
1205
- }
1206
- break
1207
- }
1208
-
1209
- case 'token-usage': {
1210
- const data = {
1211
- inputTokensSize: event.inputTokensSize,
1212
- outputTokensSize: event.outputTokensSize,
1213
- totalTokensSize: event.totalTokensSize,
1214
- contextSize: event.contextSize,
1215
- }
1216
- emit('onTokenUsage', data)
1217
- dialogTokenUsage = data
1218
- invalidate()
1219
- break
1220
- }
1221
-
1222
- case 'compaction': {
1223
- const standalone = !isInStream
1224
- const segments =
1225
- event.phase === 'start'
1226
- ? accumulator.addContextCompaction()
1227
- : accumulator.completeContextCompaction(event.summary)
1228
- const meta = standalone ? ({ append: true, isCompacting: true } as const) : undefined
1229
- emitSegments(segments, meta)
1230
- applySegmentsToState(segments, withSeqMeta(meta))
1231
- break
1232
- }
1233
-
1234
- case 'dialog-closed': {
1235
- emit('onDialogClosed')
1236
- break
1237
- }
1238
-
1239
- default:
1240
- // Unknown / SSE-only event on the NATS kernel — ignore.
1241
- break
1242
- }
1243
- }
1244
-
1245
- // ===========================================================================
1246
- // SSE kernel — the absorbed useChat merge + decision_resolved receipt path
1247
- // ===========================================================================
1248
-
1249
- /** Replace the trailing text segment of the LAST assistant message with
1250
- * the cumulative turn text (or push one). Mirrors `useChat`'s
1251
- * currentTextSegment handling byte-for-byte. */
1252
- function sseWriteTrailingText(text: string): void {
1253
- const last = messages[messages.length - 1]
1254
- if (!last || last.role !== 'assistant') return
1255
- const segments = [...(last.segments ?? [])]
1256
- const tail = segments[segments.length - 1]
1257
- if (tail && tail.type === 'text') {
1258
- segments[segments.length - 1] = { type: 'text', text }
1259
- } else {
1260
- segments.push({ type: 'text', text })
1261
- }
1262
- setMessagesInternal([...messages.slice(0, -1), { ...last, segments }])
1263
- }
1264
-
1265
- function sseAppendText(text: string): void {
1266
- sseCurrentText += text
1267
- sseWriteTrailingText(sseCurrentText)
1268
- }
1269
-
1270
- /** Single thinking segment per message, ALWAYS before the answer text:
1271
- * replaced in place when present, inserted at the FRONT otherwise. */
1272
- function sseAppendThinking(escapedDelta: string): void {
1273
- const last = messages[messages.length - 1]
1274
- if (!last || last.role !== 'assistant') return
1275
- const segments = [...(last.segments ?? [])]
1276
- const idx = segments.findIndex((s) => s.type === 'thinking')
1277
- if (idx !== -1) {
1278
- const existing = segments[idx] as { type: 'thinking'; text: string }
1279
- segments[idx] = { type: 'thinking', text: existing.text + escapedDelta }
1280
- } else {
1281
- segments.unshift({ type: 'thinking', text: escapedDelta })
1282
- }
1283
- setMessagesInternal([...messages.slice(0, -1), { ...last, segments }])
1284
- }
1285
-
1286
- function mergeTurnMeta(sendIdx: number, partial: Partial<ChatTurnMeta>): void {
1287
- const prev = metaMap.get(sendIdx) ?? createEmptyTurnMeta()
1288
- const filtered = Object.fromEntries(
1289
- Object.entries(partial).filter(([, v]) => v !== undefined && v !== null),
1290
- ) as Partial<ChatTurnMeta>
1291
- metaMap.set(sendIdx, { ...prev, ...filtered })
1292
- invalidate()
1293
- }
1294
-
1295
- function applySseApprovalResolved(event: ApprovalResolvedEvent, sendIdx: number): void {
1296
- // Inline post-approve card ref → per-send refs map (the marker in the
1297
- // receipt resolves through it on rehydration).
1298
- const cardRef = event.cardRef as ChatRef | undefined
1299
- if (cardRef?.id && event.cardType) {
1300
- const existing = refsMap.get(sendIdx) ?? {}
1301
- const key = buildChatRefKey(event.cardType, cardRef.id)
1302
- refsMap.set(sendIdx, { ...existing, [key]: cardRef })
1303
- invalidate()
1304
- }
1305
- const proposalId = event.requestId
1306
- if (!proposalId) return
1307
-
1308
- // Step 1 — flip the SOURCE card's status (the approval card lives in an
1309
- // EARLIER assistant message than the one being streamed into).
1310
- setMessagesInternal(
1311
- projectApprovalResolutionToMessages(messages, proposalId, event.status),
1312
- )
1313
-
1314
- // Step 2 — server-rendered receipt into the CURRENT message. No
1315
- // server-provided copy → don't fabricate a fallback.
1316
- const receipt = typeof event.receiptText === 'string' ? event.receiptText : null
1317
- if (receipt === null) return
1318
- sseCurrentText = receipt + '\n\n'
1319
- sseWriteTrailingText(sseCurrentText)
1320
-
1321
- // Step 3 — stamp the ref onto THIS assistant message so the
1322
- // `[card://<type>:<id>]` marker resolves via `message.chatRefs`
1323
- // independent of per-turn refsMap indexing.
1324
- const refForMessage =
1325
- cardRef && typeof (cardRef as { type?: unknown }).type === 'string' && typeof cardRef.id === 'string'
1326
- ? cardRef
1327
- : null
1328
- if (refForMessage) {
1329
- const last = messages[messages.length - 1]
1330
- if (last && last.role === 'assistant') {
1331
- const refKey = buildChatRefKey((refForMessage as { type: string }).type, refForMessage.id)
1332
- setMessagesInternal([
1333
- ...messages.slice(0, -1),
1334
- { ...last, chatRefs: { ...(last.chatRefs ?? {}), [refKey]: refForMessage } },
1335
- ])
1336
- }
1337
- }
1338
- }
1339
-
1340
- function applySseMetadata(event: ChatMetadataEvent, sendIdx: number): void {
1341
- if (event.routing) {
1342
- mergeTurnMeta(sendIdx, {
1343
- routedComplexity: event.routing.routedComplexity,
1344
- routedThinkingBudget: event.routing.routedThinkingBudget,
1345
- })
1346
- return
1347
- }
1348
- if (event.sources) {
1349
- sourcesMap.set(sendIdx, event.sources as unknown[])
1350
- invalidate()
1351
- }
1352
- if (event.refs && typeof event.refs === 'object') {
1353
- refsMap.set(sendIdx, event.refs as Record<string, ChatRef>)
1354
- invalidate()
1355
- }
1356
- if (event.modelLabel || event.contextWindowMaxTokens || event.provider || event.modelName) {
1357
- mergeTurnMeta(sendIdx, {
1358
- provider: event.provider ?? null,
1359
- modelLabel: event.modelLabel ?? null,
1360
- contextWindowMaxTokens: event.contextWindowMaxTokens ?? null,
1361
- })
1362
- }
1363
- const parsedAnchor = parseScrollAnchor(event.scrollAnchor)
1364
- if (parsedAnchor !== null) {
1365
- mergeTurnMeta(sendIdx, { scrollAnchor: parsedAnchor })
1366
- }
1367
- }
1368
-
1369
- function applySseUsage(event: UsageEvent, sendIdx: number): void {
1370
- if (event.stage === 'start') {
1371
- mergeTurnMeta(sendIdx, { inputTokens: event.input_tokens ?? null })
1372
- return
1373
- }
1374
- // stage === 'end' — the trailing usage frame. Raw wire values pass the
1375
- // legacy truthiness/typeof gates so a malformed frame degrades
1376
- // identically to the pre-SSOT parser.
1377
- const rawBreakdown = event.breakdown as Record<string, any> | null | undefined
1378
- const breakdown: UnifiedUsageBreakdown | null =
1379
- rawBreakdown && typeof rawBreakdown === 'object'
1380
- ? {
1381
- haikuRewriter:
1382
- rawBreakdown.haikuRewriter && typeof rawBreakdown.haikuRewriter.input === 'number'
1383
- ? rawBreakdown.haikuRewriter
1384
- : undefined,
1385
- haikuClassifier:
1386
- rawBreakdown.haikuClassifier && typeof rawBreakdown.haikuClassifier.input === 'number'
1387
- ? rawBreakdown.haikuClassifier
1388
- : undefined,
1389
- haikuSummarizer:
1390
- rawBreakdown.haikuSummarizer && typeof rawBreakdown.haikuSummarizer.input === 'number'
1391
- ? rawBreakdown.haikuSummarizer
1392
- : undefined,
1393
- routedAnswer:
1394
- rawBreakdown.routedAnswer && typeof rawBreakdown.routedAnswer.model === 'string'
1395
- ? rawBreakdown.routedAnswer
1396
- : undefined,
1397
- }
1398
- : null
1399
- mergeTurnMeta(sendIdx, {
1400
- inputTokens: event.input_tokens ?? null,
1401
- outputTokens: event.output_tokens ?? null,
1402
- cacheHitRatePct: typeof event.hit_rate_pct === 'number' ? event.hit_rate_pct : null,
1403
- ...(breakdown ? { breakdown } : {}),
1404
- })
1405
- }
1406
-
1407
- function applySse(event: ChatStreamEvent): void {
1408
- const sendIdx = sendCount - 1
1409
- switch (event.type) {
1410
- case 'status':
1411
- setPhaseInternal('thinking')
1412
- break
1413
- case 'thinking-delta':
1414
- sseAppendThinking(escapeThinkingTags(event.text))
1415
- break
1416
- case 'turn-start':
1417
- setPhaseInternal('streaming')
1418
- break
1419
- case 'text-delta':
1420
- if (!event.leading) setPhaseInternal('streaming')
1421
- sseAppendText(event.text)
1422
- break
1423
- case 'error':
1424
- // Legacy parity: SSE tool errors surface as answer text.
1425
- sseAppendText(`⚠️ ${event.title}`)
1426
- break
1427
- case 'approval-request': {
1428
- // Flush any accumulated preamble text so the card lands after it.
1429
- if (sseCurrentText) {
1430
- sseWriteTrailingText(sseCurrentText)
1431
- sseCurrentText = ''
1432
- }
1433
- const segment: ApprovalRequestSegment = {
1434
- type: 'approval_request',
1435
- data: {
1436
- command: event.command ?? '',
1437
- fields: event.fields,
1438
- requestId: event.requestId,
1439
- approvalType: event.approvalType,
1440
- },
1441
- status: 'pending',
1442
- onApprove: callbacks.onApprove,
1443
- onReject: callbacks.onReject,
1444
- }
1445
- const last = messages[messages.length - 1]
1446
- if (last && last.role === 'assistant') {
1447
- setMessagesInternal([
1448
- ...messages.slice(0, -1),
1449
- { ...last, segments: [...(last.segments ?? []), segment] },
1450
- ])
1451
- }
1452
- break
1453
- }
1454
- case 'approval-resolved':
1455
- applySseApprovalResolved(event, sendIdx)
1456
- break
1457
- case 'metadata':
1458
- applySseMetadata(event, sendIdx)
1459
- break
1460
- case 'usage':
1461
- applySseUsage(event, sendIdx)
1462
- break
1463
- // NOTE: 'tool-execution' is intentionally NOT handled on the SSE
1464
- // kernel — the SSE wire never carries it, and routing it through the
1465
- // accumulator would invoke resolvePendingApprovalForExecution, which
1466
- // is a NATS-only semantic (observer streams without APPROVAL_RESULT).
1467
- default:
1468
- break
1469
- }
1470
- }
1471
-
1472
- // ===========================================================================
1473
- // Public surface
1474
- // ===========================================================================
1475
-
1476
- function apply(event: ChatStreamEvent): void {
1477
- // seq-based idempotency: drop redelivered / out-of-order events.
1478
- if (typeof event.seq === 'number') {
1479
- if (event.seq <= lastAppliedSeq) return
1480
- lastAppliedSeq = event.seq
1481
- }
1482
- if (transport === 'sse') applySse(event)
1483
- else applyNats(event)
1484
- }
1485
-
1486
- function reset(): void {
1487
- messages = []
1488
- streamingPhase = 'idle'
1489
- dialogTokenUsage = null
1490
- liveModel = null
1491
- approvalStatuses = {}
1492
- accumulator.reset()
1493
- pendingEscalated.clear()
1494
- isInStream = false
1495
- hasEverStreamed = false
1496
- sawDirectMessage = false
1497
- directTeardownFired = false
1498
- lastAppliedSeq = Number.NEGATIVE_INFINITY
1499
- pendingEchoTexts = []
1500
- turnStartedAt = 0
1501
- adoptTrailingAssistant = false
1502
- metaMap.clear()
1503
- sourcesMap.clear()
1504
- refsMap.clear()
1505
- sendCount = 0
1506
- sseCurrentText = ''
1507
- invalidate()
1508
- }
1509
-
1510
- function resetForDialogSwitch(): void {
1511
- // Per-dialog reset. approvalStatuses deliberately SURVIVE — request ids
1512
- // are globally unique and a resolved approval must not re-render as
1513
- // actionable when its APPROVAL_RESULT row is missing from history pages.
1514
- messages = []
1515
- streamingPhase = 'idle'
1516
- dialogTokenUsage = null
1517
- liveModel = null
1518
- accumulator.reset()
1519
- pendingEscalated.clear()
1520
- isInStream = false
1521
- hasEverStreamed = false
1522
- sawDirectMessage = false
1523
- directTeardownFired = false
1524
- lastAppliedSeq = Number.NEGATIVE_INFINITY
1525
- pendingEchoTexts = []
1526
- turnStartedAt = 0
1527
- adoptTrailingAssistant = false
1528
- invalidate()
1529
- }
1530
-
1531
- /** Canonical state-monotonic merge of a persisted / parked status map.
1532
- * "resolved beats pending" in BOTH directions — see the interface doc on
1533
- * `mergeApprovalStatuses`, which is this function. */
1534
- function mergeApprovalStatusesInternal(
1535
- persisted: Record<string, ChatApprovalStatus>,
1536
- ): void {
1537
- const keys = Object.keys(persisted)
1538
- if (keys.length === 0) return
1539
- let next: Record<string, ChatApprovalStatus> | null = null
1540
- for (const key of keys) {
1541
- const mine = approvalStatuses[key]
1542
- const theirs = persisted[key]
1543
- // Unknown here → adopt. Known → adopt only to upgrade pending →
1544
- // resolved; never downgrade a resolution back to pending.
1545
- const adopt = mine === undefined || (mine === 'pending' && theirs !== 'pending')
1546
- if (!adopt || mine === theirs) continue
1547
- next ??= { ...approvalStatuses }
1548
- next[key] = theirs
1549
- }
1550
- if (next === null) return
1551
- approvalStatuses = next
1552
- invalidate()
1553
- }
1554
-
1555
- function initializeWithState(
1556
- nextMessages: UnifiedChatMessage[] | null,
1557
- extras?: InitializeExtras,
1558
- ): void {
1559
- if (nextMessages !== null) {
1560
- messages = nextMessages
1561
- }
1562
- if (extras) {
1563
- accumulator.initializeWithState({
1564
- existingSegments: extras.existingSegments,
1565
- pendingApprovals: extras.pendingApprovals,
1566
- executingTools: extras.executingTools,
1567
- })
1568
- if (extras.escalatedApprovals) {
1569
- for (const [requestId, data] of extras.escalatedApprovals) {
1570
- pendingEscalated.set(requestId, data)
1571
- emit('onEscalatedApproval', requestId, data)
1572
- }
1573
- }
1574
- // Parked state from a previous instance of this key (see the fields'
1575
- // docs). Both are restore-only: statuses merge monotonically, the seq
1576
- // gate only moves forward.
1577
- if (extras.approvalStatuses) mergeApprovalStatusesInternal(extras.approvalStatuses)
1578
- if (typeof extras.lastAppliedSeq === 'number' && extras.lastAppliedSeq > lastAppliedSeq) {
1579
- lastAppliedSeq = extras.lastAppliedSeq
1580
- }
1581
- if (extras.pendingEchoes && extras.pendingEchoes.length > 0) {
1582
- const now = Date.now()
1583
- const restored = extras.pendingEchoes.filter(
1584
- (e) => now - e.at < OWN_ECHO_AUTHOR_TTL_MS,
1585
- )
1586
- if (restored.length > 0) {
1587
- pendingEchoTexts = [...pendingEchoTexts, ...restored.map((e) => ({ ...e }))]
1588
- if (pendingEchoTexts.length > MAX_PENDING_ECHOES) {
1589
- pendingEchoTexts = pendingEchoTexts.slice(-MAX_PENDING_ECHOES)
1590
- }
1591
- }
1592
- }
1593
- }
1594
- // Resumed dialog: a MESSAGE_START already fired server-side. Treat
1595
- // subsequent continuation chunks (after the next MESSAGE_END) as
1596
- // post-stream so they append into the existing bubble instead of
1597
- // replacing its content via the cold-start cumulative path.
1598
- //
1599
- // Gated on the restored thread being non-empty (overridable via
1600
- // `extras.resumed`): the eviction round trip calls this for EVERY dropped
1601
- // key, including one that never streamed and parked an empty thread.
1602
- // Claiming "resumed" there would send a later cold text-delta with no
1603
- // preceding `turn-start` down the append branch, so the first assistant
1604
- // bubble never spawns.
1605
- // Never DOWNGRADES an instance that already streamed — this only decides
1606
- // whether the restore itself counts as evidence of a prior stream.
1607
- hasEverStreamed = hasEverStreamed || (extras?.resumed ?? messages.length > 0)
1608
- invalidate()
1609
- }
1610
-
1611
- return {
1612
- apply,
1613
- get state() {
1614
- return getState()
1615
- },
1616
- reset,
1617
- initializeWithState,
1618
-
1619
- setMessages(next) {
1620
- messages = next
1621
- invalidate()
1622
- },
1623
- prependMessages(older) {
1624
- messages = [...older, ...messages]
1625
- invalidate()
1626
- },
1627
- pushOptimisticSend(text, hidden = false) {
1628
- // Record the text so the backend's MESSAGE_REQUEST echo consumes this
1629
- // send instead of rendering a duplicate row (cap keeps the list from
1630
- // growing if a backend never echoes).
1631
- pendingEchoTexts.push({ text, at: Date.now() })
1632
- if (pendingEchoTexts.length > MAX_PENDING_ECHOES) pendingEchoTexts.shift()
1633
- messages = [
1634
- ...messages,
1635
- // An EMPTY-text send is out-of-band metadata (an approval decision),
1636
- // not something the user said — see `beginSseSend` for the full
1637
- // rationale. Never materialize a row for it.
1638
- ...(text.length > 0
1639
- ? ([
1640
- {
1641
- id: nextId('user'),
1642
- role: 'user' as const,
1643
- content: text,
1644
- ...(hidden ? { hidden: true } : {}),
1645
- },
1646
- ] satisfies UnifiedChatMessage[])
1647
- : []),
1648
- { id: nextId('assistant'), role: 'assistant', content: '', segments: [] },
1649
- ]
1650
- streamingPhase = 'thinking'
1651
- invalidate()
1652
- },
1653
- clearThread() {
1654
- messages = []
1655
- accumulator.reset()
1656
- streamingPhase = 'idle'
1657
- invalidate()
1658
- },
1659
- resetForDialogSwitch,
1660
-
1661
- beginSseSend({ text, hidden, userName = 'You', assistantName, assistantAvatar }) {
1662
- const now = Date.now()
1663
- messages = [
1664
- ...messages,
1665
- // Approve / Reject send `('', { hidden: true, approvalAction })`: the
1666
- // decision is OUT-OF-BAND metadata (it rides `proposal_id`/`action` in
1667
- // the request body), not a user utterance. The history builder already
1668
- // filters `!m.hidden`, so such a row contributes NOTHING to the wire —
1669
- // it is dead state whose only effect is a render hazard (an author
1670
- // label with no body). So we never mint it. Hidden sends that DO carry
1671
- // text (e.g. an auto-continuation directive) still become real state:
1672
- // the LLM must see them, `Message.hidden` keeps them off screen.
1673
- ...(text.length > 0
1674
- ? ([
1675
- {
1676
- id: `user-${now}`,
1677
- role: 'user' as const,
1678
- name: userName,
1679
- content: text,
1680
- timestamp: new Date(now),
1681
- ...(hidden ? { hidden: true } : {}),
1682
- },
1683
- ] satisfies UnifiedChatMessage[])
1684
- : []),
1685
- {
1686
- id: `assistant-${now}`,
1687
- role: 'assistant',
1688
- name: assistantName,
1689
- content: '',
1690
- segments: [],
1691
- timestamp: new Date(now),
1692
- ...(assistantAvatar !== undefined ? { avatar: assistantAvatar } : {}),
1693
- },
1694
- ]
1695
- sendCount += 1
1696
- sseCurrentText = ''
1697
- streamingPhase = 'thinking'
1698
- invalidate()
1699
- },
1700
- endSseTurn() {
1701
- // Reject-path on confirm-tool emits ONLY a `decision_resolved` leading
1702
- // frame and closes — no text segments stream in, so the placeholder
1703
- // assistant message would render as a blank bubble. Remove the
1704
- // trailing assistant message if it still has no content.
1705
- const last = messages[messages.length - 1]
1706
- if (
1707
- last &&
1708
- last.role === 'assistant' &&
1709
- (last.segments?.length ?? 0) === 0 &&
1710
- last.content === ''
1711
- ) {
1712
- messages = messages.slice(0, -1)
1713
- }
1714
- sseCurrentText = ''
1715
- streamingPhase = 'idle'
1716
- invalidate()
1717
- },
1718
- failSseTurn(errorMessage) {
1719
- // Legacy `useChat` catch parity: the trailing (placeholder) message is
1720
- // replaced by an error-role row.
1721
- messages = [
1722
- ...messages.slice(0, -1),
1723
- {
1724
- id: `error-${Date.now()}`,
1725
- role: 'error',
1726
- content: errorMessage,
1727
- timestamp: new Date(),
1728
- } as unknown as UnifiedChatMessage,
1729
- ]
1730
- sseCurrentText = ''
1731
- streamingPhase = 'idle'
1732
- invalidate()
1733
- },
1734
- seedSseMaps({ sources, refs, sendCount: seedSendCount }) {
1735
- if (sources) for (const [k, v] of sources) sourcesMap.set(k, v)
1736
- if (refs) for (const [k, v] of refs) refsMap.set(k, v)
1737
- if (typeof seedSendCount === 'number') sendCount = seedSendCount
1738
- invalidate()
1739
- },
1740
-
1741
- setPhase(phase) {
1742
- setPhaseInternal(phase)
1743
- },
1744
- setApprovalStatus(requestId, status) {
1745
- if (status === null) {
1746
- if (!(requestId in approvalStatuses)) return
1747
- const next = { ...approvalStatuses }
1748
- delete next[requestId]
1749
- approvalStatuses = next
1750
- } else {
1751
- if (approvalStatuses[requestId] === status) return
1752
- approvalStatuses = { ...approvalStatuses, [requestId]: status }
1753
- }
1754
- invalidate()
1755
- },
1756
- mergeApprovalStatuses: mergeApprovalStatusesInternal,
1757
- setDirectMode(isDirectMode) {
1758
- directModeFlag = isDirectMode
1759
- },
1760
- setDialogTokenUsage(usage) {
1761
- dialogTokenUsage = usage
1762
- invalidate()
1763
- },
1764
- adjustAgentBusySuppression(delta) {
1765
- suppressAgentBusy = Math.max(0, suppressAgentBusy + delta)
1766
- },
1767
- armAdoptTrailingAssistant(value) {
1768
- adoptTrailingAssistant = value
1769
- },
1770
-
1771
- projectApprovalResolution(requestId, status, resolvedByName) {
1772
- setMessagesInternal(
1773
- projectApprovalResolutionToMessages(messages, requestId, status, resolvedByName),
1774
- )
1775
- mirrorApprovalStatus(requestId, status)
1776
- },
1777
- projectToolExecution(segment) {
1778
- // Merge-only: a projection must never grow a card on a side the tool
1779
- // doesn't belong to, and must never re-enter the seq stream.
1780
- const merged = mergeToolExecutionIfPresent(messages, segment)
1781
- if (merged !== null) setMessagesInternal(merged)
1782
- },
1783
-
1784
- getLastAppliedSeq() {
1785
- return lastAppliedSeq
1786
- },
1787
- getPendingEchoes() {
1788
- // SNAPSHOT, not the live array: `pushOptimisticSend` pushes into and
1789
- // `consumeOwnEcho` splices the same object, so handing it out would make
1790
- // the `readonly` return type a lie and let a parked copy mutate under
1791
- // its holder.
1792
- return pendingEchoTexts.slice()
1793
- },
1794
- getSegments() {
1795
- return accumulator.getSegments()
1796
- },
1797
- updateApprovalStatus(requestId, status, resolvedByName) {
1798
- const segments = accumulator.updateApprovalStatus(requestId, status, resolvedByName)
1799
- setMessagesInternal(
1800
- projectApprovalResolutionToMessages(messages, requestId, status, resolvedByName),
1801
- )
1802
- return segments
1803
- },
1804
- getPendingEscalated() {
1805
- return new Map(pendingEscalated)
1806
- },
1807
- }
1808
- }