@flamingo-stack/openframe-frontend-core 0.0.467 → 0.0.468-snapshot.20260723140929

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 (289) hide show
  1. package/dist/chat-protocol/decode.d.ts +58 -0
  2. package/dist/chat-protocol/decode.d.ts.map +1 -0
  3. package/dist/chat-protocol/encode.d.ts +44 -0
  4. package/dist/chat-protocol/encode.d.ts.map +1 -0
  5. package/dist/chat-protocol/env-flag.d.ts +35 -0
  6. package/dist/chat-protocol/env-flag.d.ts.map +1 -0
  7. package/dist/chat-protocol/events.d.ts +192 -0
  8. package/dist/chat-protocol/events.d.ts.map +1 -0
  9. package/dist/chat-protocol/frames.d.ts +206 -0
  10. package/dist/chat-protocol/frames.d.ts.map +1 -0
  11. package/dist/chat-protocol/index.cjs +493 -0
  12. package/dist/chat-protocol/index.cjs.map +1 -0
  13. package/dist/chat-protocol/index.d.ts +18 -0
  14. package/dist/chat-protocol/index.d.ts.map +1 -0
  15. package/dist/chat-protocol/index.js +477 -0
  16. package/dist/chat-protocol/index.js.map +1 -0
  17. package/dist/chat-protocol/ip-normalize.d.ts +44 -0
  18. package/dist/chat-protocol/ip-normalize.d.ts.map +1 -0
  19. package/dist/chat-protocol/nats-decoder.d.ts +26 -0
  20. package/dist/chat-protocol/nats-decoder.d.ts.map +1 -0
  21. package/dist/{chunk-BIDOIPIT.js → chunk-2BJSRTKU.js} +2 -2
  22. package/dist/{chunk-4DFPQKKJ.js → chunk-33FDAZDB.js} +2 -2
  23. package/dist/{chunk-LOWJWKM5.js → chunk-4QDFNDVW.js} +4 -4
  24. package/dist/{chunk-72NEN4G4.cjs → chunk-4UFRQT7Z.cjs} +15 -15
  25. package/dist/{chunk-72NEN4G4.cjs.map → chunk-4UFRQT7Z.cjs.map} +1 -1
  26. package/dist/{chunk-WWC75D3J.cjs → chunk-4W54HUP7.cjs} +6853 -4609
  27. package/dist/chunk-4W54HUP7.cjs.map +1 -0
  28. package/dist/{chunk-6DZ5QOUM.cjs → chunk-5HUTT3FD.cjs} +11 -11
  29. package/dist/{chunk-6DZ5QOUM.cjs.map → chunk-5HUTT3FD.cjs.map} +1 -1
  30. package/dist/{chunk-GXXLJHQX.js → chunk-7I2QMUEZ.js} +2 -2
  31. package/dist/{chunk-QJ7NIL6A.js → chunk-AJSNF5YF.js} +6 -5
  32. package/dist/chunk-AJSNF5YF.js.map +1 -0
  33. package/dist/{chunk-NYOQNYSI.js → chunk-AW3LVWVV.js} +2 -2
  34. package/dist/{chunk-25YYEVSE.js → chunk-DG7TX763.js} +2 -2
  35. package/dist/{chunk-CZFPPNYV.js → chunk-E6O6XATC.js} +11899 -9655
  36. package/dist/chunk-E6O6XATC.js.map +1 -0
  37. package/dist/{chunk-ZYPHZ3R3.cjs → chunk-GLKULDQN.cjs} +9 -9
  38. package/dist/{chunk-ZYPHZ3R3.cjs.map → chunk-GLKULDQN.cjs.map} +1 -1
  39. package/dist/{chunk-TZSPLSDU.js → chunk-GYEZV4GK.js} +2 -2
  40. package/dist/{chunk-G5D7NRH5.cjs → chunk-LRESZO4L.cjs} +26 -26
  41. package/dist/{chunk-G5D7NRH5.cjs.map → chunk-LRESZO4L.cjs.map} +1 -1
  42. package/dist/{chunk-ABU37QSG.js → chunk-MMF6HJB7.js} +4 -4
  43. package/dist/{chunk-O7LQYYUA.cjs → chunk-MTLBBSLP.cjs} +5 -5
  44. package/dist/{chunk-O7LQYYUA.cjs.map → chunk-MTLBBSLP.cjs.map} +1 -1
  45. package/dist/{chunk-UWW4PQQS.cjs → chunk-OVIUQJ2H.cjs} +28 -27
  46. package/dist/chunk-OVIUQJ2H.cjs.map +1 -0
  47. package/dist/{chunk-3L44PPMF.js → chunk-RPAOSHBN.js} +2 -2
  48. package/dist/{chunk-VERK32MO.cjs → chunk-TOFOK7G5.cjs} +37 -37
  49. package/dist/{chunk-VERK32MO.cjs.map → chunk-TOFOK7G5.cjs.map} +1 -1
  50. package/dist/{chunk-QITN43HN.cjs → chunk-U2KPMWO6.cjs} +7 -7
  51. package/dist/{chunk-QITN43HN.cjs.map → chunk-U2KPMWO6.cjs.map} +1 -1
  52. package/dist/{chunk-YGW7T57C.cjs → chunk-V5YWOQBP.cjs} +31 -31
  53. package/dist/{chunk-YGW7T57C.cjs.map → chunk-V5YWOQBP.cjs.map} +1 -1
  54. package/dist/{chunk-ILZ66CS6.js → chunk-VUCYS36H.js} +10 -10
  55. package/dist/chunk-VUCYS36H.js.map +1 -0
  56. package/dist/{chunk-SGDXS7VT.cjs → chunk-WHWDGCQV.cjs} +60 -60
  57. package/dist/chunk-WHWDGCQV.cjs.map +1 -0
  58. package/dist/{chunk-G2H4AKL3.cjs → chunk-YXNWEVER.cjs} +62 -62
  59. package/dist/{chunk-G2H4AKL3.cjs.map → chunk-YXNWEVER.cjs.map} +1 -1
  60. package/dist/components/case-studies/index.cjs +8 -8
  61. package/dist/components/case-studies/index.js +2 -2
  62. package/dist/components/chat/chat-composer.d.ts +8 -1
  63. package/dist/components/chat/chat-composer.d.ts.map +1 -1
  64. package/dist/components/chat/chat-context-memory-bar.d.ts +37 -0
  65. package/dist/components/chat/chat-context-memory-bar.d.ts.map +1 -0
  66. package/dist/components/chat/chat-message-enhanced.d.ts.map +1 -1
  67. package/dist/components/chat/chat-message-list.d.ts.map +1 -1
  68. package/dist/components/chat/embeddable-chat.d.ts +14 -6
  69. package/dist/components/chat/embeddable-chat.d.ts.map +1 -1
  70. package/dist/components/chat/hooks/index.d.ts +0 -1
  71. package/dist/components/chat/hooks/index.d.ts.map +1 -1
  72. package/dist/components/chat/hooks/use-chat.d.ts.map +1 -1
  73. package/dist/components/chat/hooks/use-nats-chat-adapter.d.ts +15 -48
  74. package/dist/components/chat/hooks/use-nats-chat-adapter.d.ts.map +1 -1
  75. package/dist/components/chat/hooks/use-sse-chat-adapter.d.ts +3 -47
  76. package/dist/components/chat/hooks/use-sse-chat-adapter.d.ts.map +1 -1
  77. package/dist/components/chat/index.cjs +12 -2
  78. package/dist/components/chat/index.cjs.map +1 -1
  79. package/dist/components/chat/index.d.ts +2 -0
  80. package/dist/components/chat/index.d.ts.map +1 -1
  81. package/dist/components/chat/index.js +23 -13
  82. package/dist/components/chat/stream/chat-dialog-store.d.ts +184 -0
  83. package/dist/components/chat/stream/chat-dialog-store.d.ts.map +1 -0
  84. package/dist/components/chat/stream/chat-stream-reducer.d.ts +367 -0
  85. package/dist/components/chat/stream/chat-stream-reducer.d.ts.map +1 -0
  86. package/dist/components/chat/stream/delta-batcher.d.ts +51 -0
  87. package/dist/components/chat/stream/delta-batcher.d.ts.map +1 -0
  88. package/dist/components/chat/stream/index.d.ts +17 -0
  89. package/dist/components/chat/stream/index.d.ts.map +1 -0
  90. package/dist/components/chat/stream/message-mutations.d.ts +77 -0
  91. package/dist/components/chat/stream/message-mutations.d.ts.map +1 -0
  92. package/dist/components/chat/stream/use-chat-stream-reducer.d.ts +22 -0
  93. package/dist/components/chat/stream/use-chat-stream-reducer.d.ts.map +1 -0
  94. package/dist/components/chat/types/api.types.d.ts +1 -81
  95. package/dist/components/chat/types/api.types.d.ts.map +1 -1
  96. package/dist/components/chat/types/processing.types.d.ts +2 -90
  97. package/dist/components/chat/types/processing.types.d.ts.map +1 -1
  98. package/dist/components/chat/utils/extract-incomplete-message-state.d.ts +34 -6
  99. package/dist/components/chat/utils/extract-incomplete-message-state.d.ts.map +1 -1
  100. package/dist/components/chat/utils/index.d.ts +2 -3
  101. package/dist/components/chat/utils/index.d.ts.map +1 -1
  102. package/dist/components/chat/utils/process-historical-messages.d.ts +59 -11
  103. package/dist/components/chat/utils/process-historical-messages.d.ts.map +1 -1
  104. package/dist/components/contact/index.cjs +3 -3
  105. package/dist/components/contact/index.js +2 -2
  106. package/dist/components/docs/index.cjs +5 -5
  107. package/dist/components/docs/index.js +4 -4
  108. package/dist/components/embeds/index.cjs +3 -3
  109. package/dist/components/embeds/index.js +2 -2
  110. package/dist/components/faq/index.cjs +3 -3
  111. package/dist/components/faq/index.js +2 -2
  112. package/dist/components/features/index.cjs +2 -2
  113. package/dist/components/features/index.js +1 -1
  114. package/dist/components/help-center-pages/index.cjs +20 -20
  115. package/dist/components/help-center-pages/index.js +11 -11
  116. package/dist/components/index.cjs +174 -130
  117. package/dist/components/index.cjs.map +1 -1
  118. package/dist/components/index.js +65 -21
  119. package/dist/components/index.js.map +1 -1
  120. package/dist/components/navigation/index.cjs +2 -2
  121. package/dist/components/navigation/index.js +1 -1
  122. package/dist/components/onboarding-guides/index.cjs +5 -5
  123. package/dist/components/onboarding-guides/index.js +4 -4
  124. package/dist/components/onboarding-guides/onboarding-guide-detail-view.d.ts.map +1 -1
  125. package/dist/components/related-content/index.cjs +3 -3
  126. package/dist/components/related-content/index.js +2 -2
  127. package/dist/components/shared/product-release/release-detail-page.d.ts.map +1 -1
  128. package/dist/components/tickets/index.cjs +5 -5
  129. package/dist/components/tickets/index.js +4 -4
  130. package/dist/components/ui/index.cjs +46 -2
  131. package/dist/components/ui/index.cjs.map +1 -1
  132. package/dist/components/ui/index.d.ts +1 -2
  133. package/dist/components/ui/index.d.ts.map +1 -1
  134. package/dist/components/ui/index.js +57 -13
  135. package/dist/components/ui/markdown/base-components.d.ts +56 -0
  136. package/dist/components/ui/markdown/base-components.d.ts.map +1 -0
  137. package/dist/components/ui/markdown/engine.d.ts +99 -0
  138. package/dist/components/ui/markdown/engine.d.ts.map +1 -0
  139. package/dist/components/ui/markdown/heading-ids.d.ts +65 -0
  140. package/dist/components/ui/markdown/heading-ids.d.ts.map +1 -0
  141. package/dist/components/ui/markdown/index.d.ts +18 -0
  142. package/dist/components/ui/markdown/index.d.ts.map +1 -0
  143. package/dist/components/ui/markdown/mermaid-diagram.d.ts +79 -0
  144. package/dist/components/ui/markdown/mermaid-diagram.d.ts.map +1 -0
  145. package/dist/components/ui/markdown/rich/embed-overrides.d.ts +14 -0
  146. package/dist/components/ui/markdown/rich/embed-overrides.d.ts.map +1 -0
  147. package/dist/components/ui/markdown/rich/rich-markdown-renderer.d.ts +40 -0
  148. package/dist/components/ui/markdown/rich/rich-markdown-renderer.d.ts.map +1 -0
  149. package/dist/components/ui/markdown/rich/shortcodes.d.ts +8 -0
  150. package/dist/components/ui/markdown/rich/shortcodes.d.ts.map +1 -0
  151. package/dist/components/ui/markdown/sanitize.d.ts +153 -0
  152. package/dist/components/ui/markdown/sanitize.d.ts.map +1 -0
  153. package/dist/components/ui/markdown/simple-markdown-renderer.d.ts +23 -0
  154. package/dist/components/ui/markdown/simple-markdown-renderer.d.ts.map +1 -0
  155. package/dist/components/ui/markdown/streaming.d.ts +78 -0
  156. package/dist/components/ui/markdown/streaming.d.ts.map +1 -0
  157. package/dist/components/ui/markdown/text-size.d.ts +34 -0
  158. package/dist/components/ui/markdown/text-size.d.ts.map +1 -0
  159. package/dist/index.cjs +62 -2
  160. package/dist/index.cjs.map +1 -1
  161. package/dist/index.js +73 -13
  162. package/dist/utils/index.cjs +169 -40
  163. package/dist/utils/index.cjs.map +1 -1
  164. package/dist/utils/index.d.ts +1 -0
  165. package/dist/utils/index.d.ts.map +1 -1
  166. package/dist/utils/index.js +162 -41
  167. package/dist/utils/index.js.map +1 -1
  168. package/dist/utils/markdown-fences.d.ts +42 -0
  169. package/dist/utils/markdown-fences.d.ts.map +1 -0
  170. package/dist/utils/markdown-heading-id.d.ts +85 -0
  171. package/dist/utils/markdown-heading-id.d.ts.map +1 -0
  172. package/dist/utils/markdown-section-extractor.d.ts.map +1 -1
  173. package/package.json +7 -1
  174. package/src/chat-protocol/__tests__/__snapshots__/nats-decoder-golden.test.ts.snap +307 -0
  175. package/src/chat-protocol/__tests__/chat-protocol.test.ts +515 -0
  176. package/src/chat-protocol/__tests__/env-flag.test.ts +52 -0
  177. package/src/chat-protocol/__tests__/ip-normalize.test.ts +136 -0
  178. package/src/chat-protocol/__tests__/nats-decoder-golden.test.ts +228 -0
  179. package/src/chat-protocol/decode.ts +277 -0
  180. package/src/chat-protocol/encode.ts +71 -0
  181. package/src/chat-protocol/env-flag.ts +39 -0
  182. package/src/chat-protocol/events.ts +228 -0
  183. package/src/chat-protocol/frames.ts +238 -0
  184. package/src/chat-protocol/index.ts +21 -0
  185. package/src/chat-protocol/ip-normalize.ts +146 -0
  186. package/src/chat-protocol/nats-decoder.ts +252 -0
  187. package/src/components/chat/__tests__/chat-message-list.test.tsx +293 -5
  188. package/src/components/chat/__tests__/chat-message-streaming-memo.test.tsx +207 -0
  189. package/src/components/chat/__tests__/chat-pending-turn.test.tsx +209 -0
  190. package/src/components/chat/chat-composer.tsx +32 -5
  191. package/src/components/chat/chat-context-memory-bar.tsx +159 -0
  192. package/src/components/chat/chat-message-enhanced.tsx +111 -17
  193. package/src/components/chat/chat-message-list.tsx +358 -109
  194. package/src/components/chat/embeddable-chat.tsx +23 -15
  195. package/src/components/chat/hooks/.index.md +1 -1
  196. package/src/components/chat/hooks/.use-nats-chat-adapter.md +1 -1
  197. package/src/components/chat/hooks/__tests__/__snapshots__/persisted-state-v1-golden.test.ts.snap +297 -0
  198. package/src/components/chat/hooks/__tests__/__snapshots__/sse-stream-golden.test.ts.snap +0 -0
  199. package/src/components/chat/hooks/__tests__/persisted-state-v1-golden.test.ts +272 -0
  200. package/src/components/chat/hooks/__tests__/sse-stream-golden.test.ts +318 -0
  201. package/src/components/chat/hooks/index.ts +0 -1
  202. package/src/components/chat/hooks/use-chat.ts +8 -0
  203. package/src/components/chat/hooks/use-nats-chat-adapter.ts +243 -822
  204. package/src/components/chat/hooks/use-sse-chat-adapter.ts +318 -666
  205. package/src/components/chat/index.ts +6 -0
  206. package/src/components/chat/stream/__tests__/__snapshots__/chat-stream-reducer-golden.test.ts.snap +937 -0
  207. package/src/components/chat/stream/__tests__/chat-dialog-store.test.ts +756 -0
  208. package/src/components/chat/stream/__tests__/chat-stream-reducer-golden.test.ts +460 -0
  209. package/src/components/chat/stream/__tests__/chat-stream-reducer.test.ts +894 -0
  210. package/src/components/chat/stream/__tests__/delta-batcher.test.ts +255 -0
  211. package/src/components/chat/stream/__tests__/use-chat-stream-reducer.test.ts +98 -0
  212. package/src/components/chat/stream/chat-dialog-store.ts +536 -0
  213. package/src/components/chat/stream/chat-stream-reducer.ts +1796 -0
  214. package/src/components/chat/stream/delta-batcher.ts +159 -0
  215. package/src/components/chat/stream/index.ts +55 -0
  216. package/src/components/chat/stream/message-mutations.ts +338 -0
  217. package/src/components/chat/stream/use-chat-stream-reducer.ts +126 -0
  218. package/src/components/chat/thinking-display.tsx +1 -1
  219. package/src/components/chat/types/.api.types.md +1 -3
  220. package/src/components/chat/types/.processing.types.md +6 -23
  221. package/src/components/chat/types/api.types.ts +6 -73
  222. package/src/components/chat/types/processing.types.ts +11 -52
  223. package/src/components/chat/utils/.index.md +0 -5
  224. package/src/components/chat/utils/__tests__/__snapshots__/process-historical-messages-golden.test.ts.snap +418 -0
  225. package/src/components/chat/utils/__tests__/__snapshots__/segment-accumulator-golden.test.ts.snap +599 -0
  226. package/src/components/chat/utils/__tests__/process-historical-messages-golden.test.ts +317 -0
  227. package/src/components/chat/utils/__tests__/segment-accumulator-golden.test.ts +270 -0
  228. package/src/components/chat/utils/extract-incomplete-message-state.ts +63 -5
  229. package/src/components/chat/utils/index.ts +5 -8
  230. package/src/components/chat/utils/process-historical-messages.ts +352 -383
  231. package/src/components/onboarding-guides/onboarding-guide-detail-view.tsx +22 -4
  232. package/src/components/shared/legal-document/legal-document-page.tsx +1 -1
  233. package/src/components/shared/product-release/release-detail-page.tsx +19 -4
  234. package/src/components/ui/__tests__/__snapshots__/markdown-parity.test.tsx.snap +2420 -0
  235. package/src/components/ui/__tests__/markdown-parity.test.tsx +2528 -0
  236. package/src/components/ui/index.ts +1 -2
  237. package/src/components/ui/markdown/__tests__/mermaid-security.test.ts +115 -0
  238. package/src/components/ui/markdown/__tests__/mermaid-stale-render.test.tsx +95 -0
  239. package/src/components/ui/markdown/__tests__/sanitize-invariant.test.ts +309 -0
  240. package/src/components/ui/markdown/__tests__/streaming.test.tsx +388 -0
  241. package/src/components/ui/markdown/base-components.tsx +341 -0
  242. package/src/components/ui/markdown/engine.tsx +315 -0
  243. package/src/components/ui/markdown/heading-ids.ts +239 -0
  244. package/src/components/ui/markdown/index.ts +49 -0
  245. package/src/components/ui/markdown/mermaid-diagram.tsx +291 -0
  246. package/src/components/ui/markdown/rich/embed-overrides.tsx +199 -0
  247. package/src/components/ui/markdown/rich/rich-markdown-renderer.tsx +184 -0
  248. package/src/components/ui/markdown/rich/shortcodes.ts +170 -0
  249. package/src/components/ui/markdown/sanitize.ts +3499 -0
  250. package/src/components/ui/markdown/simple-markdown-renderer.tsx +28 -0
  251. package/src/components/ui/markdown/streaming.ts +362 -0
  252. package/src/components/ui/markdown/text-size.ts +106 -0
  253. package/src/components/ui/release-changelog-section.tsx +1 -1
  254. package/src/components/ui/tab-selector.tsx +8 -8
  255. package/src/components/ui/ticket-info-section.tsx +1 -1
  256. package/src/utils/index.ts +1 -0
  257. package/src/utils/markdown-fences.ts +127 -0
  258. package/src/utils/markdown-heading-id.ts +348 -0
  259. package/src/utils/markdown-section-extractor.ts +41 -56
  260. package/dist/chunk-CZFPPNYV.js.map +0 -1
  261. package/dist/chunk-ILZ66CS6.js.map +0 -1
  262. package/dist/chunk-QJ7NIL6A.js.map +0 -1
  263. package/dist/chunk-SGDXS7VT.cjs.map +0 -1
  264. package/dist/chunk-UWW4PQQS.cjs.map +0 -1
  265. package/dist/chunk-WWC75D3J.cjs.map +0 -1
  266. package/dist/components/chat/hooks/use-realtime-chunk-processor.d.ts +0 -6
  267. package/dist/components/chat/hooks/use-realtime-chunk-processor.d.ts.map +0 -1
  268. package/dist/components/chat/utils/chunk-parser.d.ts +0 -25
  269. package/dist/components/chat/utils/chunk-parser.d.ts.map +0 -1
  270. package/dist/components/ui/rich-markdown-renderer.d.ts +0 -34
  271. package/dist/components/ui/rich-markdown-renderer.d.ts.map +0 -1
  272. package/dist/components/ui/simple-markdown-renderer.d.ts +0 -73
  273. package/dist/components/ui/simple-markdown-renderer.d.ts.map +0 -1
  274. package/src/components/chat/hooks/.use-realtime-chunk-processor.md +0 -50
  275. package/src/components/chat/hooks/use-realtime-chunk-processor.ts +0 -479
  276. package/src/components/chat/utils/.chunk-parser.md +0 -51
  277. package/src/components/chat/utils/chunk-parser.ts +0 -255
  278. package/src/components/ui/.simple-markdown-renderer.md +0 -40
  279. package/src/components/ui/rich-markdown-renderer.tsx +0 -1223
  280. package/src/components/ui/simple-markdown-renderer.tsx +0 -965
  281. /package/dist/{chunk-BIDOIPIT.js.map → chunk-2BJSRTKU.js.map} +0 -0
  282. /package/dist/{chunk-4DFPQKKJ.js.map → chunk-33FDAZDB.js.map} +0 -0
  283. /package/dist/{chunk-LOWJWKM5.js.map → chunk-4QDFNDVW.js.map} +0 -0
  284. /package/dist/{chunk-GXXLJHQX.js.map → chunk-7I2QMUEZ.js.map} +0 -0
  285. /package/dist/{chunk-NYOQNYSI.js.map → chunk-AW3LVWVV.js.map} +0 -0
  286. /package/dist/{chunk-25YYEVSE.js.map → chunk-DG7TX763.js.map} +0 -0
  287. /package/dist/{chunk-TZSPLSDU.js.map → chunk-GYEZV4GK.js.map} +0 -0
  288. /package/dist/{chunk-ABU37QSG.js.map → chunk-MMF6HJB7.js.map} +0 -0
  289. /package/dist/{chunk-3L44PPMF.js.map → chunk-RPAOSHBN.js.map} +0 -0
@@ -0,0 +1,209 @@
1
+ /**
2
+ * Pending-turn + hidden-row contracts.
3
+ *
4
+ * Two reader-visible defects this pins against:
5
+ *
6
+ * 1. Approving a tool call minted an author-label row with NO body
7
+ * ("Michael Assraf:" then nothing). The approval decision is
8
+ * out-of-band metadata, so the reducer must not materialize a user
9
+ * row for it at all — fixing it at render would leave dead state.
10
+ * 2. While the model was thinking, the transcript showed a second,
11
+ * empty "Mingo:" row ABOVE the footer progress indicator — two
12
+ * owners for one pending turn, and an `aria-live` log announcing a
13
+ * bare "Mingo". Exactly one affordance must be present.
14
+ *
15
+ * Plus the two idioms that must SURVIVE both fixes: a hidden row that
16
+ * carries real text (auto-continuation directive) stays in state while
17
+ * never rendering, and the SYSTEM name-only row still renders.
18
+ */
19
+
20
+ import { render } from '@testing-library/react'
21
+ import { describe, expect, it, vi } from 'vitest'
22
+ import { createChatStreamReducer } from '../stream/chat-stream-reducer'
23
+ import {
24
+ AUTO_CONTINUATION_DIRECTIVE_PREFIX,
25
+ buildAutoContinuationDirective,
26
+ } from '../utils/auto-continuation-directive'
27
+ import type { Message } from '../types/message.types'
28
+
29
+ // ─── Render harness (mirrors chat-message-list.test.tsx) ────────────────────
30
+
31
+ const scrollToBottom = vi.fn()
32
+ const stopScroll = vi.fn()
33
+
34
+ function makeRef() {
35
+ const fn = ((el: HTMLElement | null) => {
36
+ ;(fn as { current: HTMLElement | null }).current = el
37
+ }) as ((el: HTMLElement | null) => void) & { current: HTMLElement | null }
38
+ fn.current = null
39
+ return fn
40
+ }
41
+
42
+ vi.mock('use-stick-to-bottom', () => ({
43
+ useStickToBottom: () => ({
44
+ scrollRef: makeRef(),
45
+ contentRef: makeRef(),
46
+ scrollToBottom,
47
+ stopScroll,
48
+ }),
49
+ }))
50
+
51
+ // Row stub that renders the author label the way the real component does
52
+ // (`Name:`), so "labeled row with no body" is directly assertable.
53
+ vi.mock('../chat-message-enhanced', () => ({
54
+ ChatMessageEnhanced: ({ name, content }: { name?: string; content: unknown }) => (
55
+ <div data-testid="row">
56
+ <span data-testid="row-name">{name ?? ''}</span>
57
+ <span data-testid="row-body">
58
+ {typeof content === 'string'
59
+ ? content
60
+ : Array.isArray(content)
61
+ ? content
62
+ .map((s) => (s && (s as { type?: string }).type === 'text' ? (s as { text: string }).text : '[seg]'))
63
+ .join('')
64
+ : ''}
65
+ </span>
66
+ </div>
67
+ ),
68
+ }))
69
+
70
+ import { ChatMessageList } from '../chat-message-list'
71
+
72
+ class ObserverStub {
73
+ observe() {}
74
+ unobserve() {}
75
+ disconnect() {}
76
+ }
77
+ vi.stubGlobal('IntersectionObserver', ObserverStub)
78
+ vi.stubGlobal('ResizeObserver', ObserverStub)
79
+
80
+ const names = (c: HTMLElement) =>
81
+ Array.from(c.querySelectorAll('[data-testid="row-name"]')).map((n) => n.textContent)
82
+ const bodies = (c: HTMLElement) =>
83
+ Array.from(c.querySelectorAll('[data-testid="row-body"]')).map((n) => n.textContent)
84
+
85
+ // ─── State-level contracts ─────────────────────────────────────────────────
86
+
87
+ describe('approval sends mint no visible user row', () => {
88
+ it('beginSseSend with empty text pushes only the assistant placeholder', () => {
89
+ const r = createChatStreamReducer({ transport: 'sse' })
90
+ // What `cardApprove` triggers: ('', { hidden: true, approvalAction }).
91
+ r.beginSseSend({ text: '', hidden: true, assistantName: 'Mingo AI' })
92
+
93
+ expect(r.state.messages).toHaveLength(1)
94
+ expect(r.state.messages[0].role).toBe('assistant')
95
+ expect(r.state.messages.some((m) => m.role === 'user')).toBe(false)
96
+ expect(r.state.streamingPhase).toBe('thinking')
97
+ })
98
+
99
+ it('pushOptimisticSend with empty text likewise mints no user row', () => {
100
+ const r = createChatStreamReducer({ transport: 'nats' })
101
+ r.pushOptimisticSend('', true)
102
+
103
+ expect(r.state.messages).toHaveLength(1)
104
+ expect(r.state.messages[0].role).toBe('assistant')
105
+ })
106
+
107
+ it('a normal send still mints the user row', () => {
108
+ const r = createChatStreamReducer({ transport: 'sse' })
109
+ r.beginSseSend({ text: 'hello', assistantName: 'Mingo AI' })
110
+
111
+ expect(r.state.messages).toHaveLength(2)
112
+ expect(r.state.messages[0]).toMatchObject({ role: 'user', content: 'hello' })
113
+ expect(r.state.messages[0].hidden).toBeUndefined()
114
+ })
115
+ })
116
+
117
+ describe('hidden rows that carry text', () => {
118
+ it('an auto-continuation directive IS state (the model sees it) but is flagged hidden', () => {
119
+ const directive = buildAutoContinuationDirective('create_ticket', { ticketId: '42' })
120
+ expect(directive.startsWith(AUTO_CONTINUATION_DIRECTIVE_PREFIX)).toBe(true)
121
+
122
+ const r = createChatStreamReducer({ transport: 'sse' })
123
+ r.beginSseSend({ text: directive, hidden: true, assistantName: 'Mingo AI' })
124
+
125
+ const userRow = r.state.messages.find((m) => m.role === 'user')
126
+ expect(userRow?.content).toBe(directive)
127
+ expect(userRow?.hidden).toBe(true)
128
+ })
129
+
130
+ it('never renders — the raw directive text must not reach the transcript', () => {
131
+ const directive = buildAutoContinuationDirective('create_ticket', { ticketId: '42' })
132
+ const messages: Message[] = [
133
+ { id: 'u1', role: 'user', name: 'Michael Assraf', content: 'my laptop is dead' },
134
+ { id: 'h1', role: 'user', name: 'Michael Assraf', content: directive, hidden: true },
135
+ { id: 'a1', role: 'assistant', name: 'Mingo', content: 'Opened a ticket.' },
136
+ ]
137
+ const { container } = render(<ChatMessageList dialogId="d" messages={messages} />)
138
+
139
+ expect(container.textContent).not.toContain(AUTO_CONTINUATION_DIRECTIVE_PREFIX)
140
+ expect(bodies(container)).toEqual(['my laptop is dead', 'Opened a ticket.'])
141
+ })
142
+ })
143
+
144
+ // ─── Render-level contracts ────────────────────────────────────────────────
145
+
146
+ describe('a thinking turn has exactly ONE pending affordance', () => {
147
+ const pending: Message[] = [
148
+ { id: 'u1', role: 'user', name: 'Michael Assraf', content: 'what is broken?' },
149
+ { id: 'a1', role: 'assistant', name: 'Mingo', content: '', segments: [] },
150
+ ]
151
+
152
+ it('does not render the empty labeled assistant row while typing', () => {
153
+ const { container } = render(
154
+ <ChatMessageList dialogId="d" messages={pending} isTyping />,
155
+ )
156
+ // The bare "Mingo" label must be gone from the transcript entirely —
157
+ // removed from the DOM, not visually hidden (the list is an aria-live
158
+ // log; a zero-height node would still be announced).
159
+ expect(names(container)).toEqual(['Michael Assraf'])
160
+ expect(bodies(container)).toEqual(['what is broken?'])
161
+ // …and the single remaining owner is the polite status indicator.
162
+ expect(container.querySelectorAll('[role="status"]')).toHaveLength(1)
163
+ })
164
+
165
+ it('renders the assistant row as soon as it carries text', () => {
166
+ const withText: Message[] = [
167
+ pending[0],
168
+ { id: 'a1', role: 'assistant', name: 'Mingo', content: [{ type: 'text', text: 'Chec' }] },
169
+ ]
170
+ const { container } = render(
171
+ <ChatMessageList dialogId="d" messages={withText} isTyping />,
172
+ )
173
+ expect(names(container)).toEqual(['Michael Assraf', 'Mingo'])
174
+ })
175
+
176
+ it('renders the assistant row when it carries a NON-text segment (tool call)', () => {
177
+ const withTool: Message[] = [
178
+ pending[0],
179
+ {
180
+ id: 'a1',
181
+ role: 'assistant',
182
+ name: 'Mingo',
183
+ content: [
184
+ { type: 'tool_execution', toolExecutionRequestId: 'e1', status: 'executing' },
185
+ ] as Message['content'],
186
+ },
187
+ ]
188
+ const { container } = render(
189
+ <ChatMessageList dialogId="d" messages={withTool} isTyping />,
190
+ )
191
+ expect(names(container)).toEqual(['Michael Assraf', 'Mingo'])
192
+ })
193
+
194
+ it('renders a completed empty-ish assistant row when NOT typing', () => {
195
+ const { container } = render(<ChatMessageList dialogId="d" messages={pending} />)
196
+ expect(names(container)).toEqual(['Michael Assraf', 'Mingo'])
197
+ })
198
+ })
199
+
200
+ describe('the SYSTEM name-only row is a legitimate label-without-body', () => {
201
+ it('still renders', () => {
202
+ const messages: Message[] = [
203
+ { id: 'u1', role: 'user', name: 'Michael Assraf', content: 'hi' },
204
+ { id: 's1', role: 'user', name: 'Technician joined', content: '', authorType: 'system' },
205
+ ]
206
+ const { container } = render(<ChatMessageList dialogId="d" messages={messages} isTyping />)
207
+ expect(names(container)).toContain('Technician joined')
208
+ })
209
+ })
@@ -4,6 +4,7 @@ import * as React from 'react'
4
4
  import { ChatFooter } from './chat-container'
5
5
  import { ChatInput } from './chat-input'
6
6
  import { ChatAttachmentAddButton } from './chat-attachment-bar'
7
+ import { ChatContextMemoryBar } from './chat-context-memory-bar'
7
8
  import {
8
9
  ChatComposerPlusMenu,
9
10
  ChatContextChipStrip,
@@ -11,6 +12,7 @@ import {
11
12
  } from './chat-context-picker'
12
13
  import { ModelDisplay } from './model-display'
13
14
  import { BoxArchiveIcon } from '../icons-v2-generated'
15
+ import { cn } from '../../utils/cn'
14
16
  import type { ChatInputRef, ModelDisplayProps } from './types/component.types'
15
17
  import type {
16
18
  ChatContextItem,
@@ -49,6 +51,13 @@ export interface ChatComposerProps {
49
51
  onToggleContextItem?: (item: ChatContextItem) => void
50
52
  /** Remove an item (chip ×). */
51
53
  onRemoveContextItem?: (item: ChatContextItem) => void
54
+ /** Ambient CONTEXT MEMORY (Figma 271:38656) — the entities the host collected
55
+ * from the user's navigation history and sends with every message. Rendered
56
+ * as a summary strip at the top of the composer card, above the assigned-item
57
+ * chips. Empty/omitted → no strip. */
58
+ contextMemoryItems?: ChatContextItem[]
59
+ /** Drop one entity from the context memory (dropdown row ×). */
60
+ onRemoveContextMemoryItem?: (item: ChatContextItem) => void
52
61
  /** Controlled picker open state. */
53
62
  contextPickerOpen?: boolean
54
63
  /** Open the picker (the `+` menu's "Assign Item"). */
@@ -94,6 +103,8 @@ export function ChatComposer({
94
103
  selectedContextItems,
95
104
  onToggleContextItem,
96
105
  onRemoveContextItem,
106
+ contextMemoryItems,
107
+ onRemoveContextMemoryItem,
97
108
  contextPickerOpen = false,
98
109
  onOpenContextPicker,
99
110
  onCloseContextPicker,
@@ -104,6 +115,7 @@ export function ChatComposer({
104
115
  }: ChatComposerProps) {
105
116
  const contextEnabled = !!contextPicker && !archived
106
117
  const selected = selectedContextItems ?? []
118
+ const memory = contextMemoryItems ?? []
107
119
 
108
120
  // type → icon lookup so the chips (composer + bubble) lead with their
109
121
  // entity-type glyph without the parent threading an extra resolver. Keyed on
@@ -184,23 +196,38 @@ export function ChatComposer({
184
196
  </p>
185
197
  </div>
186
198
  ) : contextEnabled ? (
187
- // Unified card (Figma 1:6073): a context-chip header strip
188
- // (`bg-ods-bg`, `border-b`) sits flush above the input; the card
189
- // owns the border / radius / focus ring. No `overflow-hidden` so the
190
- // picker popover can float above the input (chips get `rounded-t`).
199
+ // Unified card (Figma 1:6073 / 271:38656): the context-memory
200
+ // summary strip and the assigned-chip strip (both `bg-ods-bg`,
201
+ // `border-b`) stack flush above the input; the card owns the border
202
+ // / radius / focus ring. No `overflow-hidden` so the picker and the
203
+ // memory popover can float above the input (whichever strip comes
204
+ // first gets `rounded-t`).
191
205
  // `has-[[data-editor]:focus]` (not `focus-within`) so the accent
192
206
  // border reacts ONLY to the composer editor — not the picker's search
193
207
  // input or item buttons, which also live inside this card. The editor
194
208
  // is a `contentEditable` div (`data-editor`), not a `<textarea>`, so we
195
209
  // target its data attribute rather than the element name.
196
210
  <div className="rounded-md border border-ods-border bg-ods-card transition-colors has-[[data-editor]:focus]:border-ods-accent">
211
+ {/* Ambient navigation-history context — always the same items, so
212
+ it sits furthest from the textarea. Self-hides when empty. */}
213
+ <ChatContextMemoryBar
214
+ items={memory}
215
+ onRemove={onRemoveContextMemoryItem}
216
+ resolveIcon={resolveContextIcon}
217
+ disabled={sending}
218
+ className="rounded-t-md"
219
+ />
197
220
  {selected.length > 0 && (
198
221
  <ChatContextChipStrip
199
222
  items={selected}
200
223
  onRemove={onRemoveContextItem}
201
224
  resolveIcon={resolveContextIcon}
202
225
  disabled={sending}
203
- className="rounded-t-md border-b border-ods-border bg-ods-bg p-2"
226
+ className={cn(
227
+ 'border-b border-ods-border bg-ods-bg p-2',
228
+ // Only the TOP strip rounds into the card corner.
229
+ memory.length === 0 && 'rounded-t-md',
230
+ )}
204
231
  />
205
232
  )}
206
233
  {/* Picker is rendered inside the `+` button (its `dropdown` prop)
@@ -0,0 +1,159 @@
1
+ 'use client'
2
+
3
+ /**
4
+ * `<ChatContextMemoryBar>` — the composer's CONTEXT MEMORY strip
5
+ * (Figma node 271:38656).
6
+ *
7
+ * A one-line summary row pinned to the top of the composer card ("5 recently
8
+ * viewed items in context") with a `⋯` trigger that opens a dropdown listing
9
+ * every remembered entity, each removable via its `×`.
10
+ *
11
+ * This is the AMBIENT half of the composer's context UI — the entities the host
12
+ * accumulated from the user's navigation history and sends along with every
13
+ * message — as opposed to `<ChatContextChipStrip>`, which shows the items the
14
+ * user ASSIGNED to the message they're typing. Both sit above the input; memory
15
+ * on top (passive, always the same items), assigned chips closest to the
16
+ * textarea (active selection for this message).
17
+ *
18
+ * The lib owns only the chrome: items and removal are host-fed
19
+ * (`EmbeddableChat`'s `contextMemory` prop), and lead glyphs come from the
20
+ * picker's entity-type icon map via `resolveIcon`. Renders `null` when the host
21
+ * has nothing remembered, so it has no permanent footprint.
22
+ */
23
+
24
+ import type * as React from 'react'
25
+ import { useEffect, useRef, useState } from 'react'
26
+ import { Ellipsis01Icon } from '../icons-v2-generated/interface/ellipsis-01-icon'
27
+ import { XmarkCircleIcon } from '../icons-v2-generated/signs-and-symbols/xmark-circle-icon'
28
+ import { cn } from '../../utils/cn'
29
+ import { CONTEXT_ICON_CLASS, CONTEXT_LABEL_CLASS, CONTEXT_ROW_CLASS } from './context-items-list'
30
+ import type { ChatContextItem } from './types/context-item.types'
31
+
32
+ const itemKey = (item: { type: string; id: string }) => `${item.type}:${item.id}`
33
+
34
+ export interface ChatContextMemoryBarProps {
35
+ /** Remembered entities, most-recent-first. Empty → the bar renders nothing. */
36
+ items: ChatContextItem[]
37
+ /** Drop one entity from the host's memory (dropdown row `×`). When omitted the
38
+ * list is read-only. */
39
+ onRemove?: (item: ChatContextItem) => void
40
+ /** Lead-glyph resolver — the composer passes its entity-type icon map, so the
41
+ * rows match the picker and the assigned chips. */
42
+ resolveIcon?: (item: ChatContextItem) => React.ReactNode
43
+ /** External disable (e.g. while a message is streaming). */
44
+ disabled?: boolean
45
+ className?: string
46
+ }
47
+
48
+ export function ChatContextMemoryBar({
49
+ items,
50
+ onRemove,
51
+ resolveIcon,
52
+ disabled = false,
53
+ className,
54
+ }: ChatContextMemoryBarProps) {
55
+ const [open, setOpen] = useState(false)
56
+ const rootRef = useRef<HTMLDivElement>(null)
57
+
58
+ // Removing the last remembered entity unmounts the bar; close first so a
59
+ // stale popover can't outlive its trigger.
60
+ useEffect(() => {
61
+ if (items.length === 0) setOpen(false)
62
+ }, [items.length])
63
+
64
+ // Outside-press close. Registered only while open. The root wraps BOTH the
65
+ // trigger and the popover, so a press on the `⋯` (inside root) is ignored here
66
+ // and handled by the button's own toggle.
67
+ //
68
+ // CAPTURE phase (the `true` 3rd arg): the chat lives inside a resizable drawer
69
+ // whose panel captures pointer events and whose composer sits on a
70
+ // `contentEditable`; a bubble-phase `document` listener can be pre-empted by
71
+ // an ancestor's `stopPropagation`, which is why a press outside the popover
72
+ // wasn't closing it. Capture runs top-down before any of that, so the handler
73
+ // always fires. `touchstart` covers tap-to-dismiss on touch devices.
74
+ useEffect(() => {
75
+ if (!open) return
76
+ const onOutside = (e: Event) => {
77
+ if (rootRef.current?.contains(e.target as Node)) return
78
+ setOpen(false)
79
+ }
80
+ document.addEventListener('mousedown', onOutside, true)
81
+ document.addEventListener('touchstart', onOutside, true)
82
+ return () => {
83
+ document.removeEventListener('mousedown', onOutside, true)
84
+ document.removeEventListener('touchstart', onOutside, true)
85
+ }
86
+ }, [open])
87
+
88
+ if (items.length === 0) return null
89
+
90
+ return (
91
+ // `relative` anchors the popover to this strip; `bottom-full` floats it
92
+ // above, matching the context picker's placement over the input.
93
+ <div
94
+ ref={rootRef}
95
+ className={cn(
96
+ 'relative flex items-center gap-[var(--spacing-system-xs)] border-b border-ods-border bg-ods-bg p-[var(--spacing-system-xs)]',
97
+ className,
98
+ )}
99
+ onKeyDown={e => {
100
+ if (e.key === 'Escape' && open) {
101
+ e.preventDefault()
102
+ setOpen(false)
103
+ }
104
+ }}
105
+ >
106
+ <p className="min-w-0 flex-1 truncate text-h6 text-ods-text-secondary">
107
+ {items.length} recently viewed {items.length === 1 ? 'item' : 'items'} in context
108
+ </p>
109
+
110
+ <button
111
+ type="button"
112
+ aria-label="Show remembered context"
113
+ aria-expanded={open}
114
+ title="Show remembered context"
115
+ onClick={() => setOpen(o => !o)}
116
+ className={cn(
117
+ 'flex size-4 shrink-0 items-center justify-center rounded outline-none transition-colors [&_svg]:size-4',
118
+ open ? 'text-ods-text-primary' : 'text-ods-text-secondary hover:text-ods-text-primary',
119
+ 'focus-visible:text-ods-text-primary',
120
+ )}
121
+ >
122
+ <Ellipsis01Icon size={16} />
123
+ </button>
124
+
125
+ {open && (
126
+ <div
127
+ role="list"
128
+ aria-label="Remembered context"
129
+ // Mirrors `ChatContextPicker`'s popover chrome (border / card bg /
130
+ // shadow-md) and its scroll cap, so both dropdowns over the composer
131
+ // read as one component family.
132
+ className="absolute bottom-full right-0 z-50 mb-1 max-h-[min(340px,45vh)] w-[320px] max-w-[90vw] overflow-y-auto overscroll-contain rounded-md border border-ods-border bg-ods-card text-ods-text-primary shadow-md"
133
+ >
134
+ {items.map(item => (
135
+ // Not a `<button>`: the row itself is inert (only the `×` acts), so
136
+ // the remove control isn't nested inside another button.
137
+ <div key={itemKey(item)} role="listitem" className={CONTEXT_ROW_CLASS}>
138
+ {resolveIcon && <span className={CONTEXT_ICON_CLASS}>{resolveIcon(item)}</span>}
139
+ <span className={CONTEXT_LABEL_CLASS} title={item.label}>
140
+ {item.label}
141
+ </span>
142
+ {onRemove && (
143
+ <button
144
+ type="button"
145
+ onClick={() => onRemove(item)}
146
+ disabled={disabled}
147
+ aria-label={`Remove ${item.label} from context`}
148
+ className="flex size-4 shrink-0 items-center justify-center text-ods-text-secondary outline-none transition-colors hover:text-ods-text-primary focus-visible:text-ods-text-primary disabled:cursor-not-allowed disabled:opacity-40 md:size-6"
149
+ >
150
+ <XmarkCircleIcon className="size-4 shrink-0 md:size-6" />
151
+ </button>
152
+ )}
153
+ </div>
154
+ ))}
155
+ </div>
156
+ )}
157
+ </div>
158
+ )
159
+ }
@@ -1,6 +1,6 @@
1
1
  "use client"
2
2
 
3
- import React, { forwardRef, memo, useMemo, useRef } from "react"
3
+ import React, { forwardRef, memo, useEffect, useMemo, useRef } from "react"
4
4
  import { cn } from "../../utils/cn"
5
5
  import { isToday } from "../../utils/date-utils"
6
6
  import { formatDate, formatTime } from "../../utils/format-date"
@@ -11,7 +11,7 @@ import { ApprovalBatchMessage } from "./approval-batch-message"
11
11
  import { ErrorMessageDisplay } from "./error-message-display"
12
12
  import { ContextCompactionDisplay } from "./context-compaction-display"
13
13
  import { ThinkingDisplay } from "./thinking-display"
14
- import { SimpleMarkdownRenderer } from "../ui/simple-markdown-renderer"
14
+ import { SimpleMarkdownRenderer } from "../ui/markdown/simple-markdown-renderer"
15
15
  import type { ChatRef } from "./chat-ref.types"
16
16
  import { remarkCardLinks } from "./remark-card-links"
17
17
  import { remarkMentionChips } from "./remark-mention-chips"
@@ -163,9 +163,9 @@ const ChatMessageEnhanced = forwardRef<HTMLDivElement, ChatMessageEnhancedProps>
163
163
  | { kind: 'text'; text: string }
164
164
  | { kind: 'block'; key: string; node: React.ReactNode }
165
165
  const partsBySegment = new Map<number, SegmentPart[]>()
166
- if (!render) return { inlineByKey, partsBySegment }
167
- const cache = renderedCardNodeCache.current
168
166
  const usedKeys = new Set<string>()
167
+ if (!render) return { inlineByKey, partsBySegment, usedKeys }
168
+ const cache = renderedCardNodeCache.current
169
169
  // Card keys already emitted as a hoisted block (`b-<key>`). The same
170
170
  // marker can legitimately appear twice in one message (LLM references
171
171
  // the same entity twice); we hoist the FIRST occurrence and skip the
@@ -275,20 +275,99 @@ const ChatMessageEnhanced = forwardRef<HTMLDivElement, ChatMessageEnhancedProps>
275
275
  partsBySegment.set(segIdx, parts)
276
276
  }
277
277
  })
278
- // Drop cached nodes for markers no longer present so the cache can't
279
- // grow unbounded as a long message's markers change.
280
- if (cache.size > usedKeys.size) {
281
- for (const k of cache.keys()) {
282
- if (!usedKeys.has(k)) cache.delete(k)
283
- }
284
- }
285
- return { inlineByKey, partsBySegment }
278
+ return { inlineByKey, partsBySegment, usedKeys }
286
279
  }, [hasMarkerSupport, chatRefs, renderEntityCard, segments])
287
280
 
281
+ // Drop cached nodes for markers no longer present so the cache can't grow
282
+ // unbounded as a long message's markers change. Deliberately an EFFECT,
283
+ // not part of the memo above: pruning is a mutation, and doing it in the
284
+ // render phase is non-idempotent (StrictMode's double-invoke, or a render
285
+ // React throws away, would evict nodes the committed tree still uses and
286
+ // remount the cards it was built to preserve).
287
+ useEffect(() => {
288
+ const cache = renderedCardNodeCache.current
289
+ const usedKeys = renderingPlan?.usedKeys
290
+ if (!usedKeys) return
291
+ if (cache.size <= usedKeys.size) return
292
+ for (const k of [...cache.keys()]) {
293
+ if (!usedKeys.has(k)) cache.delete(k)
294
+ }
295
+ }, [renderingPlan])
296
+
297
+ // The plan is read through a REF inside the `<a>` override, not captured
298
+ // in the override's closure. `renderingPlan` is rebuilt on every text
299
+ // delta (its `segments` dep is a fresh array per delta), so depending on
300
+ // it here would give `cardComponentOverrides` a new identity every token
301
+ // — which re-creates the markdown engine's `components` memo and defeats
302
+ // its per-block `StreamingBlockRenderer` memoization for the WHOLE
303
+ // message, on every token. The ref keeps the override identity stable
304
+ // while still reading the current plan at call time.
305
+ const renderingPlanRef = useRef(renderingPlan)
306
+ renderingPlanRef.current = renderingPlan
307
+ const chatRefsRef = useRef(chatRefs)
308
+ chatRefsRef.current = chatRefs
309
+
310
+ // Ref-SET token. The override reads `chatRefsRef` at CALL time, but the
311
+ // markdown engine memoizes completed blocks on the override map's
312
+ // identity — so on a COMPLETED (no longer streaming) message, a ref that
313
+ // resolves late would never be re-read: nothing else about the renderer's
314
+ // props changes, the engine's memo bails, and the pill stays stuck on its
315
+ // fallback (`refMatch.title`, or the raw cardId) forever. Streaming is
316
+ // covered because `card://` blocks are excluded from the block cache; the
317
+ // completed path has no other escape.
318
+ //
319
+ // The token fingerprints VALUES, not just the key set: a later refs frame
320
+ // REPLACES the whole map for a send index, and the enrichment case that
321
+ // matters most — a `title` going from the raw cardId to the resolved
322
+ // document title — keeps the key set IDENTICAL. A key-only token would be
323
+ // unchanged, the override map would keep its identity, the engine's
324
+ // completed-block memo would bail, and the pill would stay on its fallback
325
+ // forever. Every `ChatRef` field is included because the host's
326
+ // `renderEntityCard` receives the whole ref and may render any of them.
327
+ //
328
+ // Still STABLE per text delta (the refs map doesn't change while tokens
329
+ // stream), and the cost is one pass over a handful of refs per CHANGED
330
+ // `chatRefs` identity — the dep list is unchanged.
331
+ //
332
+ // Fields are joined by `JSON.stringify`, NOT by a separator character.
333
+ // `title`, `preview` and `url` are free-form HOST strings, so any
334
+ // delimiter we pick is one the data may also contain: joined with a plain
335
+ // space, `title: 'a b'` + empty `url` fingerprints identically to
336
+ // `title: 'a'` + `url: 'b'`, silently reintroducing the stale-pill memo
337
+ // bail this token exists to fix. (An earlier revision used a literal NUL
338
+ // byte as the separator: collision-free in practice, but it made the
339
+ // source file itself binary to grep and diff tooling.) `JSON.stringify`
340
+ // escapes every field, so no value can forge a boundary.
341
+ //
342
+ // Caveat worth stating: `JSON.stringify` on `metadata` is key-ORDER
343
+ // dependent: two structurally equal objects built in different insertion
344
+ // orders fingerprint differently. Acceptable here because every refs map
345
+ // arrives from ONE decoder with a fixed field order, and the failure mode
346
+ // is a redundant re-render, never a stale pill.
347
+ const refsKey = useMemo(
348
+ () =>
349
+ Object.entries(chatRefs ?? {})
350
+ .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
351
+ .map(([k, v]) =>
352
+ JSON.stringify([
353
+ k,
354
+ v.type,
355
+ v.sourceRepo ?? null,
356
+ v.id,
357
+ v.title,
358
+ v.url ?? null,
359
+ v.targetPlatform ?? null,
360
+ v.date ?? null,
361
+ v.preview ?? null,
362
+ v.metadata ?? null,
363
+ ]),
364
+ )
365
+ .join('|'),
366
+ [chatRefs],
367
+ )
368
+
288
369
  const cardComponentOverrides = useMemo(() => {
289
370
  if (!hasMarkerSupport && !hasMentionSupport) return undefined
290
- const refs = chatRefs ?? {}
291
- const inlineByKey = renderingPlan?.inlineByKey
292
371
  return {
293
372
  // Override `<a>` to detect `card://` URLs emitted by `remarkCardLinks`.
294
373
  // The render result was pre-computed in `renderingPlan` so block-level
@@ -316,7 +395,7 @@ const ChatMessageEnhanced = forwardRef<HTMLDivElement, ChatMessageEnhancedProps>
316
395
  const cardType = stripped.slice(0, sepIdx)
317
396
  const cardId = stripped.slice(sepIdx + 1)
318
397
  const key = `${cardType}:${cardId}`
319
- const inline = inlineByKey?.get(key)
398
+ const inline = renderingPlanRef.current?.inlineByKey.get(key)
320
399
  if (inline != null) return inline
321
400
  // Three fallback cases — keep them DISTINCT, never blur them
322
401
  // together. Mixing them up (which the old code did by reaching
@@ -340,7 +419,7 @@ const ChatMessageEnhanced = forwardRef<HTMLDivElement, ChatMessageEnhancedProps>
340
419
  // title from an unrelated ref — that hides the bug and
341
420
  // deceives the reader into thinking they're looking at
342
421
  // a real card.
343
- const refMatch: ChatRef | undefined = refs[key]
422
+ const refMatch: ChatRef | undefined = chatRefsRef.current?.[key]
344
423
  if (refMatch) {
345
424
  return <span className="text-ods-text-primary">{refMatch.title}</span>
346
425
  }
@@ -382,7 +461,13 @@ const ChatMessageEnhanced = forwardRef<HTMLDivElement, ChatMessageEnhancedProps>
382
461
  )
383
462
  },
384
463
  }
385
- }, [hasMarkerSupport, hasMentionSupport, renderMention, chatRefs, renderingPlan, NavLinkAnchor])
464
+ // DEPS ARE DELIBERATELY MINIMAL — every one of them is stable across a
465
+ // streaming turn (booleans + host-stable fn/component identities, both
466
+ // enforced by this file's `memo` comparator). The rendering plan and the
467
+ // refs MAP are read through refs above precisely so they do NOT appear
468
+ // here; `refsKey` is the one ref-derived dep, and it only moves when the
469
+ // ref set OR any ref VALUE does (see its definition).
470
+ }, [hasMarkerSupport, hasMentionSupport, renderMention, NavLinkAnchor, refsKey])
386
471
 
387
472
  const getAvatarProps = () => {
388
473
  const displayName = name || (isUser ? "User" : assistantType === 'mingo' ? "Mingo" : "Fae")
@@ -493,6 +578,13 @@ const ChatMessageEnhanced = forwardRef<HTMLDivElement, ChatMessageEnhancedProps>
493
578
  "min-w-0 w-full break-words text-h4",
494
579
  isError ? "text-ods-error" : "text-ods-text-primary",
495
580
  )
581
+ // The engine's streaming path (atomic-block memoization +
582
+ // fence tail-completion + aria-live) applies ONLY to the
583
+ // actively streaming segment: last segment of a message
584
+ // that is still typing. On completion `isTyping` flips
585
+ // false and the engine does one authoritative
586
+ // whole-document parse.
587
+ const segmentIsStreaming = index === segments.length - 1 && !!isTyping
496
588
  // No block markers in this segment → single
497
589
  // SimpleMarkdownRenderer call (existing behaviour
498
590
  // preserved for the vast majority of messages).
@@ -504,6 +596,7 @@ const ChatMessageEnhanced = forwardRef<HTMLDivElement, ChatMessageEnhancedProps>
504
596
  textSize="compact"
505
597
  additionalRemarkPlugins={cardRemarkPlugins}
506
598
  componentOverrides={cardComponentOverrides}
599
+ streaming={segmentIsStreaming}
507
600
  />
508
601
  </div>
509
602
  )
@@ -531,6 +624,7 @@ const ChatMessageEnhanced = forwardRef<HTMLDivElement, ChatMessageEnhancedProps>
531
624
  textSize="compact"
532
625
  additionalRemarkPlugins={cardRemarkPlugins}
533
626
  componentOverrides={cardComponentOverrides}
627
+ streaming={segmentIsStreaming && pIdx === parts.length - 1}
534
628
  />
535
629
  )
536
630
  }