@flamingo-stack/openframe-frontend-core 0.0.510 → 0.0.511-snapshot.20260804140004

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 (248) hide show
  1. package/dist/chat-protocol/decode.d.ts.map +1 -1
  2. package/dist/chat-protocol/frames.d.ts +26 -1
  3. package/dist/chat-protocol/frames.d.ts.map +1 -1
  4. package/dist/chat-protocol/index.cjs +24 -0
  5. package/dist/chat-protocol/index.cjs.map +1 -1
  6. package/dist/chat-protocol/index.js +24 -0
  7. package/dist/chat-protocol/index.js.map +1 -1
  8. package/dist/{chunk-GE3JUTW2.js → chunk-27OKD7ZV.js} +44 -14
  9. package/dist/chunk-27OKD7ZV.js.map +1 -0
  10. package/dist/{chunk-RHQ7BJKK.cjs → chunk-2WWDZR4D.cjs} +73 -73
  11. package/dist/{chunk-RHQ7BJKK.cjs.map → chunk-2WWDZR4D.cjs.map} +1 -1
  12. package/dist/{chunk-J2JUAS4C.cjs → chunk-2XL7UK5Y.cjs} +11 -11
  13. package/dist/{chunk-J2JUAS4C.cjs.map → chunk-2XL7UK5Y.cjs.map} +1 -1
  14. package/dist/{chunk-Q4K3TGNP.cjs → chunk-3NECEXDF.cjs} +74 -44
  15. package/dist/chunk-3NECEXDF.cjs.map +1 -0
  16. package/dist/{chunk-JOCFPAS6.js → chunk-4NFDSMB5.js} +6 -6
  17. package/dist/{chunk-SYZFFRVD.cjs → chunk-5BAGE7AI.cjs} +13 -13
  18. package/dist/{chunk-SYZFFRVD.cjs.map → chunk-5BAGE7AI.cjs.map} +1 -1
  19. package/dist/{chunk-53JKCFFE.cjs → chunk-5JBDT2MQ.cjs} +12 -12
  20. package/dist/{chunk-53JKCFFE.cjs.map → chunk-5JBDT2MQ.cjs.map} +1 -1
  21. package/dist/{chunk-XJVRAKEX.cjs → chunk-67YDSTH7.cjs} +56 -33
  22. package/dist/chunk-67YDSTH7.cjs.map +1 -0
  23. package/dist/{chunk-WNOGR5AA.js → chunk-6LMTBNLN.js} +3 -3
  24. package/dist/{chunk-6FIDTXYC.js → chunk-7AQSXQEL.js} +3 -3
  25. package/dist/{chunk-JMBIGH4H.cjs → chunk-7VBEOGCR.cjs} +83 -83
  26. package/dist/{chunk-JMBIGH4H.cjs.map → chunk-7VBEOGCR.cjs.map} +1 -1
  27. package/dist/{chunk-5AZZQQYS.cjs → chunk-AWLFJMOJ.cjs} +40 -40
  28. package/dist/{chunk-5AZZQQYS.cjs.map → chunk-AWLFJMOJ.cjs.map} +1 -1
  29. package/dist/{chunk-RJ3D3PE2.cjs → chunk-DQDPDUI3.cjs} +9 -9
  30. package/dist/{chunk-RJ3D3PE2.cjs.map → chunk-DQDPDUI3.cjs.map} +1 -1
  31. package/dist/{chunk-2RLI5XVI.cjs → chunk-F2YI5N7E.cjs} +16 -16
  32. package/dist/{chunk-2RLI5XVI.cjs.map → chunk-F2YI5N7E.cjs.map} +1 -1
  33. package/dist/{chunk-LBOWSKGM.js → chunk-FI35LJ3L.js} +5 -5
  34. package/dist/{chunk-BTUHMCYW.js → chunk-FS3YSNSU.js} +193 -6
  35. package/dist/chunk-FS3YSNSU.js.map +1 -0
  36. package/dist/{chunk-RB2EW6IZ.cjs → chunk-GOFTC2HV.cjs} +3690 -3472
  37. package/dist/chunk-GOFTC2HV.cjs.map +1 -0
  38. package/dist/{chunk-CTMLY2GN.js → chunk-HGZPH7S5.js} +5 -5
  39. package/dist/{chunk-7VUKIWF6.cjs → chunk-HIV2GIFP.cjs} +3 -3
  40. package/dist/{chunk-7VUKIWF6.cjs.map → chunk-HIV2GIFP.cjs.map} +1 -1
  41. package/dist/{chunk-DBR5JLX3.js → chunk-IHNUMRMU.js} +46 -23
  42. package/dist/chunk-IHNUMRMU.js.map +1 -0
  43. package/dist/{chunk-R4HKB3YD.cjs → chunk-IIPBTWQR.cjs} +22 -22
  44. package/dist/{chunk-R4HKB3YD.cjs.map → chunk-IIPBTWQR.cjs.map} +1 -1
  45. package/dist/{chunk-TBGSQTJK.js → chunk-IK2WRYVA.js} +6 -6
  46. package/dist/{chunk-WSAPKKDU.cjs → chunk-JIW5JI3E.cjs} +9 -9
  47. package/dist/{chunk-WSAPKKDU.cjs.map → chunk-JIW5JI3E.cjs.map} +1 -1
  48. package/dist/{chunk-MLGGF24T.js → chunk-JKAX5K7C.js} +8 -8
  49. package/dist/{chunk-UWPT23SA.js → chunk-JO3NPEK6.js} +3 -3
  50. package/dist/{chunk-HCZJZMU7.js → chunk-JOFGJYIT.js} +3 -3
  51. package/dist/{chunk-A62C4C4V.cjs → chunk-KI4EGKNV.cjs} +3 -2
  52. package/dist/chunk-KI4EGKNV.cjs.map +1 -0
  53. package/dist/{chunk-OB7YDOTK.cjs → chunk-NJADHL2G.cjs} +8 -8
  54. package/dist/{chunk-OB7YDOTK.cjs.map → chunk-NJADHL2G.cjs.map} +1 -1
  55. package/dist/{chunk-EYTXD62B.cjs → chunk-OGVIGAMZ.cjs} +232 -45
  56. package/dist/chunk-OGVIGAMZ.cjs.map +1 -0
  57. package/dist/{chunk-HX4CJYXL.js → chunk-OT4RV6WY.js} +10 -10
  58. package/dist/{chunk-Z35QWWJH.cjs → chunk-RJXI25TJ.cjs} +34 -34
  59. package/dist/{chunk-Z35QWWJH.cjs.map → chunk-RJXI25TJ.cjs.map} +1 -1
  60. package/dist/{chunk-6TL5XIAW.cjs → chunk-SG25J6LV.cjs} +119 -119
  61. package/dist/{chunk-6TL5XIAW.cjs.map → chunk-SG25J6LV.cjs.map} +1 -1
  62. package/dist/{chunk-M4MKQKFF.js → chunk-UBTOOE55.js} +2 -2
  63. package/dist/{chunk-FEGEC6FY.js → chunk-V5WLIFKN.js} +5660 -5442
  64. package/dist/chunk-V5WLIFKN.js.map +1 -0
  65. package/dist/{chunk-EJ3EBYNV.cjs → chunk-VVXUUBML.cjs} +32 -32
  66. package/dist/{chunk-EJ3EBYNV.cjs.map → chunk-VVXUUBML.cjs.map} +1 -1
  67. package/dist/{chunk-SRYV4H3M.js → chunk-VZX3SSFL.js} +3 -3
  68. package/dist/{chunk-5CDPRCPS.cjs → chunk-WVZHCDB5.cjs} +23 -23
  69. package/dist/{chunk-5CDPRCPS.cjs.map → chunk-WVZHCDB5.cjs.map} +1 -1
  70. package/dist/{chunk-VDJF4ZAU.js → chunk-XG7FLAKQ.js} +3 -3
  71. package/dist/{chunk-SZMA65OW.js → chunk-XURRAFIB.js} +2 -2
  72. package/dist/{chunk-RG2WRBPT.js → chunk-Z3ADCYC5.js} +2 -2
  73. package/dist/{chunk-2AASLY6D.js → chunk-ZIQMVAGA.js} +3 -3
  74. package/dist/{chunk-4GPWLGKT.js → chunk-ZRILYK5U.js} +2 -1
  75. package/dist/components/calendar.cjs +4 -4
  76. package/dist/components/calendar.js +3 -3
  77. package/dist/components/case-studies/index.cjs +17 -17
  78. package/dist/components/case-studies/index.js +7 -7
  79. package/dist/components/chat/approval-batch-message.d.ts +2 -2
  80. package/dist/components/chat/approval-batch-message.d.ts.map +1 -1
  81. package/dist/components/chat/chat-message-enhanced.d.ts.map +1 -1
  82. package/dist/components/chat/chat-message-list.d.ts.map +1 -1
  83. package/dist/components/chat/entity-cards/deleted-data-card.d.ts +27 -0
  84. package/dist/components/chat/entity-cards/deleted-data-card.d.ts.map +1 -0
  85. package/dist/components/chat/entity-cards/dispatch.d.ts.map +1 -1
  86. package/dist/components/chat/hooks/use-chat-card-item.d.ts +3 -0
  87. package/dist/components/chat/hooks/use-chat-card-item.d.ts.map +1 -1
  88. package/dist/components/chat/hooks/use-nats-chat-adapter.d.ts.map +1 -1
  89. package/dist/components/chat/hooks/use-sse-chat-adapter.d.ts.map +1 -1
  90. package/dist/components/chat/index.cjs +13 -7
  91. package/dist/components/chat/index.cjs.map +1 -1
  92. package/dist/components/chat/index.js +12 -6
  93. package/dist/components/chat/remark-mention-chips.d.ts +5 -2
  94. package/dist/components/chat/remark-mention-chips.d.ts.map +1 -1
  95. package/dist/components/chat/remark-strip-citations.d.ts +22 -0
  96. package/dist/components/chat/remark-strip-citations.d.ts.map +1 -0
  97. package/dist/components/chat/stream/chat-stream-reducer.d.ts.map +1 -1
  98. package/dist/components/chat/stream/message-mutations.d.ts +49 -5
  99. package/dist/components/chat/stream/message-mutations.d.ts.map +1 -1
  100. package/dist/components/chat/types/component.types.d.ts +7 -7
  101. package/dist/components/chat/types/component.types.d.ts.map +1 -1
  102. package/dist/components/chat/types/message.types.d.ts +11 -4
  103. package/dist/components/chat/types/message.types.d.ts.map +1 -1
  104. package/dist/components/chat/types/unified-chat-state.types.d.ts +4 -1
  105. package/dist/components/chat/types/unified-chat-state.types.d.ts.map +1 -1
  106. package/dist/components/chat/utils/discuss-ref-prompt.d.ts +48 -0
  107. package/dist/components/chat/utils/discuss-ref-prompt.d.ts.map +1 -0
  108. package/dist/components/chat/utils/index.d.ts +2 -0
  109. package/dist/components/chat/utils/index.d.ts.map +1 -1
  110. package/dist/components/chat/utils/message-segment-accumulator.d.ts +5 -3
  111. package/dist/components/chat/utils/message-segment-accumulator.d.ts.map +1 -1
  112. package/dist/components/chat/utils/resolve-fetched-card-href.d.ts +63 -0
  113. package/dist/components/chat/utils/resolve-fetched-card-href.d.ts.map +1 -0
  114. package/dist/components/chat/utils/source-row-cta.d.ts.map +1 -1
  115. package/dist/components/contact/index.cjs +8 -8
  116. package/dist/components/contact/index.js +7 -7
  117. package/dist/components/docs/index.cjs +10 -10
  118. package/dist/components/docs/index.js +9 -9
  119. package/dist/components/embeds/index.cjs +8 -8
  120. package/dist/components/embeds/index.js +7 -7
  121. package/dist/components/faq/index.cjs +9 -9
  122. package/dist/components/faq/index.js +8 -8
  123. package/dist/components/features/index.cjs +7 -7
  124. package/dist/components/features/index.js +6 -6
  125. package/dist/components/help-center-pages/index.cjs +28 -28
  126. package/dist/components/help-center-pages/index.js +19 -19
  127. package/dist/components/icons/index.cjs +3 -3
  128. package/dist/components/icons/index.js +2 -2
  129. package/dist/components/index.cjs +253 -247
  130. package/dist/components/index.cjs.map +1 -1
  131. package/dist/components/index.js +23 -17
  132. package/dist/components/index.js.map +1 -1
  133. package/dist/components/meeting-scheduler/index.cjs +92 -92
  134. package/dist/components/meeting-scheduler/index.js +9 -9
  135. package/dist/components/navigation/index.cjs +7 -7
  136. package/dist/components/navigation/index.js +6 -6
  137. package/dist/components/onboarding-guides/index.cjs +11 -11
  138. package/dist/components/onboarding-guides/index.js +10 -10
  139. package/dist/components/related-content/index.cjs +9 -9
  140. package/dist/components/related-content/index.js +8 -8
  141. package/dist/components/shared/doc-search/resolve-search-result-action.d.ts +11 -3
  142. package/dist/components/shared/doc-search/resolve-search-result-action.d.ts.map +1 -1
  143. package/dist/components/shared/doc-search/use-doc-search.d.ts +7 -0
  144. package/dist/components/shared/doc-search/use-doc-search.d.ts.map +1 -1
  145. package/dist/components/tickets/index.cjs +11 -11
  146. package/dist/components/tickets/index.js +10 -10
  147. package/dist/components/ui/actions-menu.d.ts.map +1 -1
  148. package/dist/components/ui/dropdown-menu.d.ts.map +1 -1
  149. package/dist/components/ui/file-manager/index.cjs +70 -70
  150. package/dist/components/ui/file-manager/index.js +3 -3
  151. package/dist/components/ui/index.cjs +13 -7
  152. package/dist/components/ui/index.cjs.map +1 -1
  153. package/dist/components/ui/index.js +12 -6
  154. package/dist/components/ui/markdown/base-components.d.ts.map +1 -1
  155. package/dist/components/ui/overlay-open-registry.d.ts +50 -0
  156. package/dist/components/ui/overlay-open-registry.d.ts.map +1 -0
  157. package/dist/components/ui/portal-container.d.ts +24 -0
  158. package/dist/components/ui/portal-container.d.ts.map +1 -1
  159. package/dist/hooks/index.cjs +4 -4
  160. package/dist/hooks/index.js +3 -3
  161. package/dist/index.cjs +20 -10
  162. package/dist/index.cjs.map +1 -1
  163. package/dist/index.js +24 -14
  164. package/dist/utils/anchor-id.d.ts +32 -0
  165. package/dist/utils/anchor-id.d.ts.map +1 -0
  166. package/dist/utils/content-href.d.ts +27 -6
  167. package/dist/utils/content-href.d.ts.map +1 -1
  168. package/dist/utils/index.cjs +213 -152
  169. package/dist/utils/index.cjs.map +1 -1
  170. package/dist/utils/index.d.ts +1 -0
  171. package/dist/utils/index.d.ts.map +1 -1
  172. package/dist/utils/index.js +212 -153
  173. package/dist/utils/index.js.map +1 -1
  174. package/dist/utils/list-url.d.ts.map +1 -1
  175. package/dist/utils/same-page-hash-nav.d.ts +17 -6
  176. package/dist/utils/same-page-hash-nav.d.ts.map +1 -1
  177. package/package.json +1 -1
  178. package/src/chat-protocol/decode.ts +43 -0
  179. package/src/chat-protocol/frames.ts +27 -0
  180. package/src/components/chat/__tests__/remark-mention-chips.test.ts +67 -0
  181. package/src/components/chat/approval-batch-message.tsx +2 -2
  182. package/src/components/chat/chat-message-enhanced.tsx +5 -3
  183. package/src/components/chat/chat-message-list.tsx +49 -5
  184. package/src/components/chat/embeddable-chat.tsx +19 -2
  185. package/src/components/chat/entity-cards/deleted-data-card.tsx +61 -0
  186. package/src/components/chat/entity-cards/dispatch.tsx +205 -102
  187. package/src/components/chat/hooks/__tests__/sse-stream-golden.test.ts +1 -1
  188. package/src/components/chat/hooks/use-chat-card-item.ts +15 -1
  189. package/src/components/chat/hooks/use-nats-chat-adapter.ts +28 -4
  190. package/src/components/chat/hooks/use-sse-chat-adapter.ts +57 -32
  191. package/src/components/chat/remark-mention-chips.ts +6 -3
  192. package/src/components/chat/remark-strip-citations.ts +39 -0
  193. package/src/components/chat/stream/__tests__/chat-stream-reducer-approval-batch.test.ts +196 -0
  194. package/src/components/chat/stream/chat-stream-reducer.ts +67 -2
  195. package/src/components/chat/stream/message-mutations.ts +170 -40
  196. package/src/components/chat/types/component.types.ts +9 -7
  197. package/src/components/chat/types/message.types.ts +14 -4
  198. package/src/components/chat/types/unified-chat-state.types.ts +4 -1
  199. package/src/components/chat/utils/__tests__/discuss-ref-prompt.test.ts +67 -0
  200. package/src/components/chat/utils/__tests__/resolve-fetched-card-href.test.ts +156 -0
  201. package/src/components/chat/utils/discuss-ref-prompt.ts +63 -0
  202. package/src/components/chat/utils/index.ts +16 -0
  203. package/src/components/chat/utils/message-segment-accumulator.ts +24 -29
  204. package/src/components/chat/utils/resolve-fetched-card-href.ts +104 -0
  205. package/src/components/chat/utils/source-row-cta.ts +5 -1
  206. package/src/components/shared/doc-search/__tests__/resolve-search-result-action.test.ts +145 -0
  207. package/src/components/shared/doc-search/resolve-search-result-action.ts +46 -10
  208. package/src/components/shared/doc-search/use-doc-search.ts +50 -14
  209. package/src/components/ui/__tests__/overlay-open-registry.test.tsx +87 -0
  210. package/src/components/ui/actions-menu.tsx +65 -2
  211. package/src/components/ui/dropdown-menu.tsx +62 -12
  212. package/src/components/ui/markdown/base-components.tsx +26 -0
  213. package/src/components/ui/overlay-open-registry.tsx +99 -0
  214. package/src/components/ui/portal-container.tsx +31 -0
  215. package/src/utils/__tests__/anchor-id.test.ts +139 -0
  216. package/src/utils/__tests__/content-href.test.ts +74 -0
  217. package/src/utils/__tests__/list-url.test.ts +11 -1
  218. package/src/utils/anchor-id.ts +106 -0
  219. package/src/utils/content-href.ts +113 -13
  220. package/src/utils/index.ts +8 -0
  221. package/src/utils/list-url.ts +6 -1
  222. package/src/utils/same-page-hash-nav.ts +28 -9
  223. package/dist/chunk-A62C4C4V.cjs.map +0 -1
  224. package/dist/chunk-BTUHMCYW.js.map +0 -1
  225. package/dist/chunk-DBR5JLX3.js.map +0 -1
  226. package/dist/chunk-EYTXD62B.cjs.map +0 -1
  227. package/dist/chunk-FEGEC6FY.js.map +0 -1
  228. package/dist/chunk-GE3JUTW2.js.map +0 -1
  229. package/dist/chunk-Q4K3TGNP.cjs.map +0 -1
  230. package/dist/chunk-RB2EW6IZ.cjs.map +0 -1
  231. package/dist/chunk-XJVRAKEX.cjs.map +0 -1
  232. /package/dist/{chunk-JOCFPAS6.js.map → chunk-4NFDSMB5.js.map} +0 -0
  233. /package/dist/{chunk-WNOGR5AA.js.map → chunk-6LMTBNLN.js.map} +0 -0
  234. /package/dist/{chunk-6FIDTXYC.js.map → chunk-7AQSXQEL.js.map} +0 -0
  235. /package/dist/{chunk-LBOWSKGM.js.map → chunk-FI35LJ3L.js.map} +0 -0
  236. /package/dist/{chunk-CTMLY2GN.js.map → chunk-HGZPH7S5.js.map} +0 -0
  237. /package/dist/{chunk-TBGSQTJK.js.map → chunk-IK2WRYVA.js.map} +0 -0
  238. /package/dist/{chunk-MLGGF24T.js.map → chunk-JKAX5K7C.js.map} +0 -0
  239. /package/dist/{chunk-UWPT23SA.js.map → chunk-JO3NPEK6.js.map} +0 -0
  240. /package/dist/{chunk-HCZJZMU7.js.map → chunk-JOFGJYIT.js.map} +0 -0
  241. /package/dist/{chunk-HX4CJYXL.js.map → chunk-OT4RV6WY.js.map} +0 -0
  242. /package/dist/{chunk-M4MKQKFF.js.map → chunk-UBTOOE55.js.map} +0 -0
  243. /package/dist/{chunk-SRYV4H3M.js.map → chunk-VZX3SSFL.js.map} +0 -0
  244. /package/dist/{chunk-VDJF4ZAU.js.map → chunk-XG7FLAKQ.js.map} +0 -0
  245. /package/dist/{chunk-SZMA65OW.js.map → chunk-XURRAFIB.js.map} +0 -0
  246. /package/dist/{chunk-RG2WRBPT.js.map → chunk-Z3ADCYC5.js.map} +0 -0
  247. /package/dist/{chunk-2AASLY6D.js.map → chunk-ZIQMVAGA.js.map} +0 -0
  248. /package/dist/{chunk-4GPWLGKT.js.map → chunk-ZRILYK5U.js.map} +0 -0
@@ -15,9 +15,12 @@
15
15
  * intact, and the marker disappears before any rehype-raw HTML pass.
16
16
  *
17
17
  * Grammar:
18
- * - marker: `[a-z]+` — the backend `ContextItemType.marker()` short form
18
+ * - marker: `[a-zA-Z]+` — the backend `ContextItemType.marker()` short form
19
19
  * (`device`, `script`, `ticket`, `organization`, `user`, `kb`, `policy`,
20
- * `query`, …). Always lowercase.
20
+ * `query`, `scheduledScript`, …). Mostly lowercase, but NOT guaranteed to be:
21
+ * the marker is matched verbatim by the backend's `MentionParser`, and
22
+ * `SCHEDULED_SCRIPT` ships as camelCase `scheduledScript` — a lowercase-only
23
+ * grammar silently dropped that whole token (no chip, raw text in the bubble).
21
24
  * - id: charset `[A-Za-z0-9_.+/=-]` (UUIDs with hyphens, fleet numeric ids,
22
25
  * base64 global ids, etc.; matches the composer's `MENTION_TOKEN`) — but the
23
26
  * id may NOT END in `.` or `-`, so a trailing SENTENCE period/dash isn't
@@ -35,7 +38,7 @@ import type { Plugin } from 'unified'
35
38
  import type { Root, Text, Link } from 'mdast'
36
39
  import { visit, SKIP } from 'unist-util-visit'
37
40
 
38
- const MENTION_REGEX = /(^|[^\w@])@([a-z]+):([A-Za-z0-9_.+/=-]*[A-Za-z0-9_+/=])/g
41
+ const MENTION_REGEX = /(^|[^\w@])@([a-zA-Z]+):([A-Za-z0-9_.+/=-]*[A-Za-z0-9_+/=])/g
39
42
 
40
43
  export const remarkMentionChips: Plugin<[], Root> = () => {
41
44
  return (tree: Root) => {
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Remark plugin that strips bare numeric citation tokens — `[1]`,
3
+ * `[2, 3]` — from assistant chat markdown.
4
+ *
5
+ * RAG models habitually cite retrieved sources with bracketed indices
6
+ * next to entity mentions (`[card://delivery_item:x] [1] **Title**`).
7
+ * The chat surfaces sources as chips and entities as inline cards, so
8
+ * the naked indices are pure noise — and when a card fetch misses they
9
+ * were the ONLY thing left on the line (observed live: candidate lists
10
+ * rendering as "[1]" + bold title).
11
+ *
12
+ * Deliberately conservative:
13
+ * - text leaves only (code/inline-code nodes are never visited);
14
+ * - 1–2 digit indices, optionally comma-separated;
15
+ * - the token must be delimiter-bounded on BOTH sides (start/whitespace
16
+ * before; whitespace/punctuation/end after) so technical prose like
17
+ * `array[1]` or `foo[12]bar` is untouched.
18
+ */
19
+
20
+ import type { Plugin } from "unified"
21
+ import type { Root, Text } from "mdast"
22
+ import { visit } from "unist-util-visit"
23
+
24
+ const CITATION_REGEX = /(^|\s)\[\d{1,2}(?:\s*,\s*\d{1,2})*\](?=[\s.,;:!?)\]]|$)/g
25
+
26
+ export const remarkStripCitations: Plugin<[], Root> = () => {
27
+ return (tree: Root) => {
28
+ visit(tree, "text", (node: Text) => {
29
+ if (!node.value || !node.value.includes("[")) return
30
+ const withoutCitations = node.value.replace(CITATION_REGEX, "$1")
31
+ // No citation matched → leave the node untouched, INCLUDING its
32
+ // whitespace (collapsing unconditionally would rewrite deliberate
33
+ // spacing in prose like `array[1] value`).
34
+ if (withoutCitations === node.value) return
35
+ // Collapse doubled spaces the removal can leave mid-sentence.
36
+ node.value = withoutCitations.replace(/ {2,}/g, " ")
37
+ })
38
+ }
39
+ }
@@ -0,0 +1,196 @@
1
+ /**
2
+ * Wire-native approval batches on the SSE kernel.
3
+ *
4
+ * The SERVER groups a multi-proposal turn into ONE `approval_batch`
5
+ * frame; the decoder surfaces it as an `approval-request` event
6
+ * carrying `toolCalls` (one row per proposal, row id = proposal id).
7
+ * These tests pin the reducer contracts:
8
+ *
9
+ * - a batch event renders ONE `approval_batch` segment (no per-
10
+ * proposal cards, no adjacency heuristics);
11
+ * - batch onApprove flips status optimistically and resolves EVERY
12
+ * row sequentially through the per-proposal callback;
13
+ * - each `approval-resolved` (per proposal id) ticks that row's
14
+ * execution icon; the anchor row also carries the status flip;
15
+ * - a single-proposal event keeps the classic `approval_request`
16
+ * card (editable per-card flow).
17
+ */
18
+
19
+ import { describe, it, expect, vi } from 'vitest'
20
+ import { createChatStreamReducer } from '../chat-stream-reducer'
21
+ import { createSseFrameDecoder } from '../../../../chat-protocol/decode'
22
+ import type { ApprovalBatchSegment } from '../../types'
23
+
24
+ const batchEvent = {
25
+ type: 'approval-request' as const,
26
+ // Anchor is DISTINCT from every row's proposal id (server emits
27
+ // `batch:<first proposalId>`) — the optimistic click flip keys on it
28
+ // and must never pre-tick a row's execution.
29
+ requestId: 'batch:prop-1',
30
+ approvalType: 'chat',
31
+ toolCalls: [
32
+ {
33
+ toolExecutionRequestId: 'prop-1',
34
+ toolName: 'delete_clickup_task',
35
+ toolTitle: 'Delete ClickUp task — Fix Website Header',
36
+ requiresApproval: true,
37
+ toolCallArguments: { Task: '86aaa', Title: 'Fix Website Header' },
38
+ },
39
+ {
40
+ toolExecutionRequestId: 'prop-2',
41
+ toolName: 'delete_clickup_task',
42
+ toolTitle: 'Delete ClickUp task — Fix Website Header (2)',
43
+ requiresApproval: true,
44
+ toolCallArguments: { Task: '86bbb', Title: 'Fix Website Header' },
45
+ },
46
+ ],
47
+ }
48
+
49
+ function findBatch(r: ReturnType<typeof createChatStreamReducer>): ApprovalBatchSegment {
50
+ const trailing = r.state.messages[r.state.messages.length - 1]
51
+ const batch = trailing.segments?.find(
52
+ (s): s is ApprovalBatchSegment => s.type === 'approval_batch',
53
+ )
54
+ expect(batch).toBeTruthy()
55
+ return batch as ApprovalBatchSegment
56
+ }
57
+
58
+ describe('SSE kernel — wire-native approval batch', () => {
59
+ it('renders ONE approval_batch segment for a toolCalls event', () => {
60
+ const r = createChatStreamReducer({ transport: 'sse' })
61
+ r.beginSseSend({ text: 'delete both', assistantName: 'Mingo AI' })
62
+ r.apply(batchEvent)
63
+ const trailing = r.state.messages[r.state.messages.length - 1]
64
+ expect(
65
+ trailing.segments?.filter((s) => s.type === 'approval_request').length ?? 0,
66
+ ).toBe(0)
67
+ const batch = findBatch(r)
68
+ expect(batch.status).toBe('pending')
69
+ expect(batch.data.approvalRequestId).toBe('batch:prop-1')
70
+ expect(batch.data.toolCalls.map((c) => c.toolExecutionRequestId)).toEqual([
71
+ 'prop-1',
72
+ 'prop-2',
73
+ ])
74
+ })
75
+
76
+ it('onApprove flips status optimistically and resolves every row sequentially', async () => {
77
+ const onApprove = vi.fn().mockResolvedValue(undefined)
78
+ const r = createChatStreamReducer({ transport: 'sse', callbacks: { onApprove } })
79
+ r.beginSseSend({ text: 'delete both', assistantName: 'Mingo AI' })
80
+ r.apply(batchEvent)
81
+ const batch = findBatch(r)
82
+ await batch.onApprove?.(batch.data.approvalRequestId)
83
+ expect(onApprove.mock.calls.map((c) => c[0])).toEqual(['prop-1', 'prop-2'])
84
+ const after = findBatch(r)
85
+ expect(after.status).toBe('approved')
86
+ // REGRESSION PIN: the optimistic flip must NOT pre-tick any row's
87
+ // execution — a pre-ticked green check would mask a failed confirm
88
+ // (observed when the anchor doubled as row 1's proposal id).
89
+ expect(after.data.executions ?? {}).toEqual({})
90
+ })
91
+
92
+ it('a failed row confirm (false) ticks that row cross, others still attempted', async () => {
93
+ const onApprove = vi
94
+ .fn()
95
+ .mockResolvedValueOnce(false) // row 1 confirm fails (e.g. expired)
96
+ .mockResolvedValueOnce(true)
97
+ const r = createChatStreamReducer({ transport: 'sse', callbacks: { onApprove } })
98
+ r.beginSseSend({ text: 'delete both', assistantName: 'Mingo AI' })
99
+ r.apply(batchEvent)
100
+ const batch = findBatch(r)
101
+ await batch.onApprove?.(batch.data.approvalRequestId)
102
+ expect(onApprove).toHaveBeenCalledTimes(2)
103
+ const after = findBatch(r)
104
+ expect(after.data.executions?.['prop-1']).toEqual({ status: 'done', success: false })
105
+ expect(after.data.executions?.['prop-2']).toBeUndefined()
106
+ })
107
+
108
+ it('per-proposal approval-resolved events tick the matching row execution', () => {
109
+ const r = createChatStreamReducer({ transport: 'sse' })
110
+ r.beginSseSend({ text: 'delete both', assistantName: 'Mingo AI' })
111
+ r.apply(batchEvent)
112
+ r.apply({ type: 'approval-resolved', requestId: 'prop-2', status: 'approved', ok: true })
113
+ let batch = findBatch(r)
114
+ expect(batch.data.executions?.['prop-2']).toEqual({ status: 'done', success: true })
115
+ expect(batch.data.executions?.['prop-1']).toBeUndefined()
116
+ r.apply({ type: 'approval-resolved', requestId: 'prop-1', status: 'approved', ok: true })
117
+ batch = findBatch(r)
118
+ expect(batch.data.executions?.['prop-1']).toEqual({ status: 'done', success: true })
119
+ // Status flips at CLICK time (anchor never matches a row id, and no
120
+ // decision frame carries the anchor) — row resolutions alone leave
121
+ // the pending status untouched.
122
+ expect(batch.status).toBe('pending')
123
+ })
124
+
125
+ it('a single proposal keeps the classic approval_request card', () => {
126
+ const r = createChatStreamReducer({ transport: 'sse' })
127
+ r.beginSseSend({ text: 'delete it', assistantName: 'Mingo AI' })
128
+ r.apply({
129
+ type: 'approval-request',
130
+ requestId: 'prop-solo',
131
+ approvalType: 'delete_clickup_task',
132
+ command: 'Delete ClickUp task',
133
+ fields: [{ label: 'Task', value: '86ccc' }],
134
+ })
135
+ const trailing = r.state.messages[r.state.messages.length - 1]
136
+ expect(trailing.segments?.some((s) => s.type === 'approval_batch')).toBe(false)
137
+ expect(trailing.segments?.some((s) => s.type === 'approval_request')).toBe(true)
138
+ })
139
+ })
140
+
141
+ describe('decoder — approval_batch frame mapping', () => {
142
+ it('maps the wire frame to ONE batched approval-request event', () => {
143
+ const frame = {
144
+ kind: 'approval_batch',
145
+ batchId: 'batch:prop-1',
146
+ proposals: [
147
+ {
148
+ proposalId: 'prop-1',
149
+ toolName: 'delete_clickup_task',
150
+ title: 'Delete ClickUp task',
151
+ fields: [
152
+ { label: 'Task', value: '86aaa' },
153
+ { label: 'Title', value: 'Fix Website Header' },
154
+ ],
155
+ },
156
+ {
157
+ proposalId: 'prop-2',
158
+ toolName: 'delete_clickup_task',
159
+ title: 'Delete ClickUp task',
160
+ fields: [{ label: 'Task', value: '86bbb' }],
161
+ },
162
+ ],
163
+ }
164
+ const decoder = createSseFrameDecoder()
165
+ const bytes = new TextEncoder().encode(JSON.stringify(frame) + '\0')
166
+ const events = decoder.push(bytes)
167
+ const approval = events.find((e) => e.type === 'approval-request') as
168
+ | { requestId: string; toolCalls?: Array<Record<string, unknown>> }
169
+ | undefined
170
+ expect(approval).toBeTruthy()
171
+ expect(approval!.requestId).toBe('batch:prop-1')
172
+ expect(approval!.toolCalls?.length).toBe(2)
173
+ expect(approval!.toolCalls?.[0].toolExecutionRequestId).toBe('prop-1')
174
+ // Title field drives the row's disambiguating toolTitle.
175
+ expect(approval!.toolCalls?.[0].toolTitle).toContain('Fix Website Header')
176
+ expect(approval!.toolCalls?.[1].toolExecutionRequestId).toBe('prop-2')
177
+ })
178
+
179
+ it('a colliding anchor id (misbehaving server) still cannot pre-tick a row execution', async () => {
180
+ // Defense-in-depth pin: the click-time flip uses the STATUS-ONLY
181
+ // projection, so even a server that (incorrectly) reuses a row's
182
+ // proposal id as the batchId cannot mark that row done before its
183
+ // confirm actually resolves.
184
+ const onApprove = vi.fn().mockResolvedValue(undefined)
185
+ const r = createChatStreamReducer({ transport: 'sse', callbacks: { onApprove } })
186
+ r.beginSseSend({ text: 'delete both', assistantName: 'Mingo AI' })
187
+ r.apply({ ...batchEvent, requestId: 'prop-1' })
188
+ const batch = findBatch(r)
189
+ await batch.onApprove?.(batch.data.approvalRequestId)
190
+ const after = findBatch(r)
191
+ expect(after.status).toBe('approved')
192
+ // The flip itself wrote NO execution for prop-1 — its icon state
193
+ // comes only from the confirm result / approval-resolved event.
194
+ expect(after.data.executions ?? {}).toEqual({})
195
+ })
196
+ })
@@ -60,6 +60,7 @@ import { parseScrollAnchor, type ScrollAnchor } from '../utils/scroll-anchor'
60
60
  import { escapeThinkingTags } from '../../../chat-protocol/decode'
61
61
  import { buildChatRefKey } from '../types/chat.types'
62
62
  import type {
63
+ ApprovalBatchSegment,
63
64
  ApprovalRequestSegment,
64
65
  ChatApprovalStatus,
65
66
  MessageSegment,
@@ -93,6 +94,8 @@ import {
93
94
  mergeToolExecutionIfPresent,
94
95
  nextId,
95
96
  projectApprovalResolutionToMessages,
97
+ projectBatchRowFailureToMessages,
98
+ projectBatchStatusToMessages,
96
99
  updateTrailingAssistant,
97
100
  upsertTrailingCompaction,
98
101
  } from './message-mutations'
@@ -1526,12 +1529,68 @@ export function createChatStreamReducer(
1526
1529
  onReject: callbacks.onReject,
1527
1530
  }
1528
1531
  const last = messages[messages.length - 1]
1529
- if (last && last.role === 'assistant') {
1532
+ if (!last || last.role !== 'assistant') break
1533
+ // ── WIRE-NATIVE BATCH ─────────────────────────────────────────
1534
+ // The SERVER groups a multi-proposal turn into ONE
1535
+ // `approval_batch` frame (decoded to `event.toolCalls`) — the
1536
+ // client performs NO adjacency heuristics. Render the bulk-
1537
+ // approval component: one Approve/Reject resolving every row
1538
+ // sequentially through its own per-proposal confirm; per-row
1539
+ // execution icons ticked by each `approval-resolved` event
1540
+ // (see `projectApprovalResolutionToMessages`). Single-proposal
1541
+ // turns keep the classic editable per-card flow below.
1542
+ if (event.toolCalls && event.toolCalls.length > 0) {
1543
+ const rows = event.toolCalls
1544
+ const anchorId = event.requestId
1545
+ const ids = rows.map((c) => c.toolExecutionRequestId)
1546
+ const batchSegment: ApprovalBatchSegment = {
1547
+ type: 'approval_batch',
1548
+ status: 'pending',
1549
+ data: {
1550
+ approvalRequestId: anchorId,
1551
+ approvalType: event.approvalType ?? 'chat',
1552
+ toolCalls: rows,
1553
+ executions: {},
1554
+ },
1555
+ // Optimistic status flip so the per-row loaders show while
1556
+ // the sequential per-proposal confirms run (the anchor id is
1557
+ // NEVER a row id — see ApprovalBatchFrame.batchId — so the
1558
+ // flip cannot pre-tick a row's execution). A confirm that
1559
+ // reports failure (`false` — expired proposal, network
1560
+ // error) ticks THAT row's cross so no loader spins forever;
1561
+ // remaining rows still get their attempt.
1562
+ ...(() => {
1563
+ const resolveBatch =
1564
+ (status: 'approved' | 'rejected', confirm: typeof callbacks.onApprove) =>
1565
+ async () => {
1566
+ // STATUS-ONLY projection (defense-in-depth): even a
1567
+ // batchId colliding with a row id cannot pre-tick an
1568
+ // execution here — rows resolve exclusively via their
1569
+ // own confirm results / approval-resolved events.
1570
+ setMessagesInternal(projectBatchStatusToMessages(messages, anchorId, status))
1571
+ for (const id of ids) {
1572
+ const ok = await confirm?.(id)
1573
+ if (ok === false) {
1574
+ setMessagesInternal(projectBatchRowFailureToMessages(messages, id))
1575
+ }
1576
+ }
1577
+ }
1578
+ return {
1579
+ onApprove: resolveBatch('approved', callbacks.onApprove),
1580
+ onReject: resolveBatch('rejected', callbacks.onReject),
1581
+ }
1582
+ })(),
1583
+ }
1530
1584
  setMessagesInternal([
1531
1585
  ...messages.slice(0, -1),
1532
- { ...last, segments: [...(last.segments ?? []), segment] },
1586
+ { ...last, segments: [...(last.segments ?? []), batchSegment] },
1533
1587
  ])
1588
+ break
1534
1589
  }
1590
+ setMessagesInternal([
1591
+ ...messages.slice(0, -1),
1592
+ { ...last, segments: [...(last.segments ?? []), segment] },
1593
+ ])
1535
1594
  break
1536
1595
  }
1537
1596
  case 'approval-resolved':
@@ -1919,6 +1978,12 @@ export function createChatStreamReducer(
1919
1978
  setMessagesInternal(
1920
1979
  projectApprovalResolutionToMessages(messages, requestId, status, resolvedByName),
1921
1980
  )
1981
+ // Status-map mirror — kept in lockstep with the other two flip
1982
+ // paths (SSE approval-resolved at ~1240, NATS at ~1950): the map
1983
+ // restores status when a replayed approval-request event
1984
+ // re-renders the card, and a flip applied through THIS path must
1985
+ // survive that replay the same way.
1986
+ mirrorApprovalStatus(requestId, status)
1922
1987
  return segments
1923
1988
  },
1924
1989
  getPendingEscalated() {
@@ -134,6 +134,162 @@ export function appendToTrailingAssistant(
134
134
  return [...prev.slice(0, -1), { ...last, segments: merged }]
135
135
  }
136
136
 
137
+ // =============================================================================
138
+ // Segment-projection kernel — shared scaffolding + single-rule predicates
139
+ // =============================================================================
140
+
141
+ /**
142
+ * Map every assistant message's segments through `fn`, preserving the
143
+ * REFERENTIAL-STABILITY CONTRACT in ONE place: `fn` returns the SAME
144
+ * segment reference to signal "unchanged"; untouched messages (and the
145
+ * whole array on a global no-op) keep their prior references. Every
146
+ * segment projection below rides this scaffold instead of re-implementing
147
+ * the changed-flag dance.
148
+ */
149
+ function mapSegments(
150
+ prev: UnifiedChatMessage[],
151
+ fn: (s: MessageSegment) => MessageSegment,
152
+ ): UnifiedChatMessage[] {
153
+ let changed = false
154
+ const next = prev.map((m) => {
155
+ if (m.role !== 'assistant' || !m.segments) return m
156
+ let msgChanged = false
157
+ const segs = m.segments.map((s) => {
158
+ const out = fn(s)
159
+ if (out !== s) msgChanged = true
160
+ return out
161
+ })
162
+ if (!msgChanged) return m
163
+ changed = true
164
+ return { ...m, segments: segs }
165
+ })
166
+ return changed ? next : prev
167
+ }
168
+
169
+ /**
170
+ * THE approval-resolution rule for a single segment — one predicate for
171
+ * both containers (the message-array projections here AND the flat
172
+ * segment list in `MessageSegmentAccumulator.updateApprovalStatus`), so
173
+ * the two paths can never drift:
174
+ * - `approval_request` matched on `data.requestId` → status flip;
175
+ * - `approval_batch` anchor (`data.approvalRequestId`, the server's
176
+ * `batch:<first proposalId>` — never a row id) → status flip;
177
+ * - `approval_batch` ROW (`toolCalls[].toolExecutionRequestId`) →
178
+ * tick that row's execution (check on approved, cross otherwise)
179
+ * without touching the batch status.
180
+ * Returns the SAME reference when nothing matched or changed.
181
+ */
182
+ export function applyApprovalStatusToSegment(
183
+ s: MessageSegment,
184
+ requestId: string,
185
+ status: ChatApprovalStatus,
186
+ resolvedByName?: string | null,
187
+ ): MessageSegment {
188
+ if (s.type === 'approval_request' && s.data.requestId === requestId && s.status !== status) {
189
+ return { ...s, status }
190
+ }
191
+ if (s.type !== 'approval_batch') return s
192
+ const isAnchor = s.data.approvalRequestId === requestId
193
+ const hasRow = s.data.toolCalls?.some((c) => c.toolExecutionRequestId === requestId)
194
+ if (!isAnchor && !hasRow) return s
195
+ const nextResolvedBy = resolvedByName ?? s.resolvedByName
196
+ const nextExecutions = hasRow
197
+ ? {
198
+ ...(s.data.executions ?? {}),
199
+ [requestId]: { status: 'done' as const, success: status === 'approved' },
200
+ }
201
+ : s.data.executions
202
+ const nextStatus = isAnchor ? status : s.status
203
+ if (
204
+ s.status === nextStatus &&
205
+ nextResolvedBy === s.resolvedByName &&
206
+ nextExecutions === s.data.executions
207
+ ) {
208
+ return s
209
+ }
210
+ return {
211
+ ...s,
212
+ status: nextStatus,
213
+ resolvedByName: nextResolvedBy,
214
+ data: {
215
+ ...s.data,
216
+ ...(nextExecutions ? { executions: nextExecutions } : {}),
217
+ },
218
+ }
219
+ }
220
+
221
+ /**
222
+ * Next state for a batch row's execution slot from a tool-execution
223
+ * chunk — folds the never-downgrade guard (redelivered EXECUTING after
224
+ * EXECUTED landed) and the EXECUTED/EXECUTING ternary into ONE rule
225
+ * shared by the message projection and the accumulator. Returns null
226
+ * for the no-op case (caller keeps prior references).
227
+ */
228
+ export function nextBatchExecution(
229
+ prevExec: ApprovalBatchExecutionState | undefined,
230
+ toolData: ToolExecutionData,
231
+ ): ApprovalBatchExecutionState | null {
232
+ if (toolData.type === 'EXECUTING_TOOL' && prevExec?.status === 'done') return null
233
+ return toolData.type === 'EXECUTED_TOOL'
234
+ ? { status: 'done', result: toolData.result, success: toolData.success }
235
+ : { status: 'executing', result: prevExec?.result, success: prevExec?.success }
236
+ }
237
+
238
+ /** The executions-map triple-spread — one home for the write shape. */
239
+ export function withBatchExecution<S extends { data: { executions?: Record<string, ApprovalBatchExecutionState> } }>(
240
+ seg: S,
241
+ execId: string,
242
+ state: ApprovalBatchExecutionState,
243
+ ): S {
244
+ return {
245
+ ...seg,
246
+ data: { ...seg.data, executions: { ...(seg.data.executions ?? {}), [execId]: state } },
247
+ }
248
+ }
249
+
250
+ /**
251
+ * Flip ONLY an approval_batch segment's status — no execution writes.
252
+ * The click-time optimistic flip uses this instead of
253
+ * `projectApprovalResolutionToMessages` as defense-in-depth: even if a
254
+ * (misbehaving) server emitted a batchId colliding with a row's
255
+ * proposal id, the flip could not pre-tick that row's execution to a
256
+ * false success before its confirm actually ran.
257
+ */
258
+ export function projectBatchStatusToMessages(
259
+ prev: UnifiedChatMessage[],
260
+ anchorId: string,
261
+ status: ChatApprovalStatus,
262
+ ): UnifiedChatMessage[] {
263
+ return mapSegments(prev, (s) => {
264
+ if (s.type !== 'approval_batch') return s
265
+ if (s.data.approvalRequestId !== anchorId || s.status === status) return s
266
+ return { ...s, status }
267
+ })
268
+ }
269
+
270
+ /**
271
+ * Mark ONE batch row's confirm as FAILED (expired proposal, network
272
+ * error): tick its execution icon to the failure cross so the row's
273
+ * loader doesn't spin forever. Batch status is untouched — other rows
274
+ * may still be resolving.
275
+ */
276
+ export function projectBatchRowFailureToMessages(
277
+ prev: UnifiedChatMessage[],
278
+ rowRequestId: string,
279
+ ): UnifiedChatMessage[] {
280
+ return mapSegments(prev, (s) => {
281
+ if (
282
+ s.type !== 'approval_batch' ||
283
+ !s.data.toolCalls?.some((c) => c.toolExecutionRequestId === rowRequestId)
284
+ ) {
285
+ return s
286
+ }
287
+ const existing = s.data.executions?.[rowRequestId]
288
+ if (existing?.status === 'done') return s
289
+ return withBatchExecution(s, rowRequestId, { status: 'done', success: false })
290
+ })
291
+ }
292
+
137
293
  /**
138
294
  * Upsert a standalone context-compaction segment into the trailing assistant
139
295
  * bubble. Compaction emissions arrive as the accumulator's CUMULATIVE array —
@@ -242,23 +398,16 @@ export function mergeToolExecutionIfPresent(
242
398
  seg.data.toolCalls.some((c) => c.toolExecutionRequestId === execId)
243
399
  ) {
244
400
  const prevExec: ApprovalBatchExecutionState | undefined = seg.data.executions?.[execId]
245
- // Never downgrade a done batch slot back to executing (JetStream
246
- // redelivery of the EXECUTING chunk after EXECUTED landed). Returning
247
- // prev = matched-with-no-change, so the caller doesn't append either.
248
- if (toolData.type === 'EXECUTING_TOOL' && prevExec?.status === 'done') {
249
- return prev
250
- }
251
- const nextExec: ApprovalBatchExecutionState =
252
- toolData.type === 'EXECUTED_TOOL'
253
- ? { status: 'done', result: toolData.result, success: toolData.success }
254
- : { status: 'executing', result: prevExec?.result, success: prevExec?.success }
401
+ // Shared rule (`nextBatchExecution`): folds the never-downgrade
402
+ // guard (JetStream redelivery of EXECUTING after EXECUTED
403
+ // landed — null = matched-with-no-change, caller doesn't append
404
+ // either) and the EXECUTED/EXECUTING ternary.
405
+ const nextExec = nextBatchExecution(prevExec, toolData)
406
+ if (nextExec === null) return prev
255
407
  // Value-level no-op (replayed duplicate) → prior references.
256
408
  if (sameExecutionState(prevExec, nextExec)) return prev
257
409
  const nextSegments = [...message.segments]
258
- nextSegments[j] = {
259
- ...seg,
260
- data: { ...seg.data, executions: { ...(seg.data.executions ?? {}), [execId]: nextExec } },
261
- }
410
+ nextSegments[j] = withBatchExecution(seg, execId, nextExec)
262
411
  const next = [...prev]
263
412
  next[i] = { ...message, segments: nextSegments }
264
413
  return next
@@ -304,10 +453,10 @@ export function mergeToolExecutionIfPresent(
304
453
  }
305
454
 
306
455
  /**
307
- * Cross-message approval status flip (absorbed from the NATS adapter's
308
- * `onApprovalResolved` handler). Idempotent + reference-preserving: only the
309
- * messages/segments whose status (or resolvedByName) actually changes are
310
- * cloned; a no-op returns `prev`.
456
+ * Project an approval resolution onto the thread — the ONE rule lives in
457
+ * `applyApprovalStatusToSegment` (shared with the accumulator's flat
458
+ * segment list); this is just its message-array projection via
459
+ * `mapSegments`.
311
460
  */
312
461
  export function projectApprovalResolutionToMessages(
313
462
  prev: UnifiedChatMessage[],
@@ -315,26 +464,7 @@ export function projectApprovalResolutionToMessages(
315
464
  status: ChatApprovalStatus,
316
465
  resolvedByName?: string | null,
317
466
  ): UnifiedChatMessage[] {
318
- let changed = false
319
- const next = prev.map((m) => {
320
- if (m.role !== 'assistant' || !m.segments) return m
321
- let msgChanged = false
322
- const segs = m.segments.map((s) => {
323
- if (s.type === 'approval_request' && s.data.requestId === requestId && s.status !== status) {
324
- msgChanged = true
325
- return { ...s, status }
326
- }
327
- if (s.type === 'approval_batch' && s.data.approvalRequestId === requestId) {
328
- const nextResolvedBy = resolvedByName ?? s.resolvedByName
329
- if (s.status === status && nextResolvedBy === s.resolvedByName) return s
330
- msgChanged = true
331
- return { ...s, status, resolvedByName: nextResolvedBy }
332
- }
333
- return s
334
- })
335
- if (!msgChanged) return m
336
- changed = true
337
- return { ...m, segments: segs }
338
- })
339
- return changed ? next : prev
467
+ return mapSegments(prev, (s) =>
468
+ applyApprovalStatusToSegment(s, requestId, status, resolvedByName),
469
+ )
340
470
  }
@@ -4,7 +4,9 @@
4
4
 
5
5
  import type { ComponentType, HTMLAttributes, ReactNode, TextareaHTMLAttributes } from 'react'
6
6
  import type { ApprovalBlockVariant, AssistantType, AuthorType, ChatApprovalStatus, ConnectionStatus } from './chat.types'
7
- import type { ApprovalRequestData, Message, MessageSegment, ToolExecutionData } from './message.types'
7
+ import type { ApprovalRequestData, Message, MessageSegment, ToolExecutionData,
8
+ ApprovalResolutionHandler,
9
+ } from './message.types'
8
10
  import type { ChatRef } from '../chat-ref.types'
9
11
  import type { ChatContextItem } from './context-item.types'
10
12
  import type { MspOrganizationCardProps } from '../msp-organization-card'
@@ -129,8 +131,8 @@ export interface ChatMessageEnhancedProps extends Omit<HTMLAttributes<HTMLDivEle
129
131
  timestamp?: Date
130
132
  showAvatar?: boolean
131
133
  isTyping?: boolean
132
- onApprove?: (requestId?: string) => void | Promise<void>
133
- onReject?: (requestId?: string) => void | Promise<void>
134
+ onApprove?: ApprovalResolutionHandler
135
+ onReject?: ApprovalResolutionHandler
134
136
  /**
135
137
  * Per-row metadata for inline entity-card rendering (v6.1 §B.2.6+§B.2.7).
136
138
  * Keyed by `<documentType>:<primaryKey>`. When present AND
@@ -259,8 +261,8 @@ export interface ChatMessageListProps extends HTMLAttributes<HTMLDivElement> {
259
261
  approvalVariant?: ApprovalBlockVariant
260
262
  assistantIcon?: React.ReactNode
261
263
  pendingApprovals?: MessageSegment[]
262
- onApprove?: (requestId?: string) => void | Promise<void>
263
- onReject?: (requestId?: string) => void | Promise<void>
264
+ onApprove?: ApprovalResolutionHandler
265
+ onReject?: ApprovalResolutionHandler
264
266
  // Infinite scroll for loading older messages
265
267
  hasNextPage?: boolean
266
268
  isFetchingNextPage?: boolean
@@ -518,8 +520,8 @@ export interface ToolExecutionDisplayProps extends HTMLAttributes<HTMLDivElement
518
520
 
519
521
  export interface ApprovalRequestMessageProps extends HTMLAttributes<HTMLDivElement> {
520
522
  data: ApprovalRequestData
521
- onApprove?: (requestId?: string) => void | Promise<void>
522
- onReject?: (requestId?: string) => void | Promise<void>
523
+ onApprove?: ApprovalResolutionHandler
524
+ onReject?: ApprovalResolutionHandler
523
525
  status?: ChatApprovalStatus
524
526
  disabled?: boolean
525
527
  /** Chat identity. Accepted for parity with the batch card; does NOT drive