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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (311) hide show
  1. package/dist/chat-protocol/decode.d.ts +58 -0
  2. package/dist/chat-protocol/decode.d.ts.map +1 -0
  3. package/dist/chat-protocol/encode.d.ts +44 -0
  4. package/dist/chat-protocol/encode.d.ts.map +1 -0
  5. package/dist/chat-protocol/env-flag.d.ts +35 -0
  6. package/dist/chat-protocol/env-flag.d.ts.map +1 -0
  7. package/dist/chat-protocol/events.d.ts +196 -0
  8. package/dist/chat-protocol/events.d.ts.map +1 -0
  9. package/dist/chat-protocol/frames.d.ts +213 -0
  10. package/dist/chat-protocol/frames.d.ts.map +1 -0
  11. package/dist/chat-protocol/index.cjs +494 -0
  12. package/dist/chat-protocol/index.cjs.map +1 -0
  13. package/dist/chat-protocol/index.d.ts +18 -0
  14. package/dist/chat-protocol/index.d.ts.map +1 -0
  15. package/dist/chat-protocol/index.js +478 -0
  16. package/dist/chat-protocol/index.js.map +1 -0
  17. package/dist/chat-protocol/ip-normalize.d.ts +44 -0
  18. package/dist/chat-protocol/ip-normalize.d.ts.map +1 -0
  19. package/dist/chat-protocol/nats-decoder.d.ts +26 -0
  20. package/dist/chat-protocol/nats-decoder.d.ts.map +1 -0
  21. package/dist/{chunk-AJNDDXKC.js → chunk-4NWARV5I.js} +3 -3
  22. package/dist/{chunk-GQX6LFFA.cjs → chunk-5CJR2GZ4.cjs} +42 -42
  23. package/dist/{chunk-GQX6LFFA.cjs.map → chunk-5CJR2GZ4.cjs.map} +1 -1
  24. package/dist/{chunk-EUYIFALO.cjs → chunk-5EYRXSY5.cjs} +14 -14
  25. package/dist/{chunk-EUYIFALO.cjs.map → chunk-5EYRXSY5.cjs.map} +1 -1
  26. package/dist/{chunk-5RXKOGH6.cjs → chunk-5LMGFI4J.cjs} +7011 -4777
  27. package/dist/chunk-5LMGFI4J.cjs.map +1 -0
  28. package/dist/{chunk-KFQXO7QB.js → chunk-6GT557KN.js} +1 -1
  29. package/dist/chunk-6GT557KN.js.map +1 -0
  30. package/dist/{chunk-77DFPD35.js → chunk-BE6DK7QY.js} +12267 -10033
  31. package/dist/chunk-BE6DK7QY.js.map +1 -0
  32. package/dist/{chunk-A7IPON2V.cjs → chunk-BIKOJ4FH.cjs} +5 -5
  33. package/dist/{chunk-A7IPON2V.cjs.map → chunk-BIKOJ4FH.cjs.map} +1 -1
  34. package/dist/{chunk-HQYJINFV.js → chunk-CORIBKHG.js} +4 -4
  35. package/dist/{chunk-YEEHB4T6.cjs → chunk-CSYSYJCF.cjs} +40 -40
  36. package/dist/{chunk-YEEHB4T6.cjs.map → chunk-CSYSYJCF.cjs.map} +1 -1
  37. package/dist/{chunk-SIKCXKGB.cjs → chunk-DSDZ5T4P.cjs} +7 -7
  38. package/dist/{chunk-SIKCXKGB.cjs.map → chunk-DSDZ5T4P.cjs.map} +1 -1
  39. package/dist/{chunk-PNXNWGBY.js → chunk-GTWO5NAY.js} +2 -2
  40. package/dist/{chunk-SCCTJ2UI.js → chunk-HSACATCD.js} +4 -4
  41. package/dist/{chunk-PECGZWQR.cjs → chunk-KP5KRRD6.cjs} +3 -3
  42. package/dist/{chunk-PECGZWQR.cjs.map → chunk-KP5KRRD6.cjs.map} +1 -1
  43. package/dist/{chunk-2B6773QM.cjs → chunk-LILQJ3UZ.cjs} +1 -1
  44. package/dist/chunk-LILQJ3UZ.cjs.map +1 -0
  45. package/dist/{chunk-RZSA6MZL.cjs → chunk-NWX7MMF5.cjs} +15 -15
  46. package/dist/{chunk-RZSA6MZL.cjs.map → chunk-NWX7MMF5.cjs.map} +1 -1
  47. package/dist/{chunk-V3CNNMY3.cjs → chunk-P4WTZU6L.cjs} +18 -18
  48. package/dist/{chunk-V3CNNMY3.cjs.map → chunk-P4WTZU6L.cjs.map} +1 -1
  49. package/dist/{chunk-KLYHZRAD.js → chunk-PB3BTZDU.js} +2 -2
  50. package/dist/{chunk-FZOBQG6M.cjs → chunk-Q6E7VJ4W.cjs} +37 -36
  51. package/dist/chunk-Q6E7VJ4W.cjs.map +1 -0
  52. package/dist/{chunk-EYEIVYYF.cjs → chunk-QSCHCZL3.cjs} +7 -7
  53. package/dist/{chunk-EYEIVYYF.cjs.map → chunk-QSCHCZL3.cjs.map} +1 -1
  54. package/dist/{chunk-YJSKQ2ZV.js → chunk-RQLBHKXN.js} +3 -3
  55. package/dist/{chunk-CWQACI4Z.js → chunk-RW2LPL72.js} +2 -2
  56. package/dist/{chunk-DX6AOHNU.cjs → chunk-S2NHJDKQ.cjs} +77 -77
  57. package/dist/{chunk-DX6AOHNU.cjs.map → chunk-S2NHJDKQ.cjs.map} +1 -1
  58. package/dist/{chunk-AHDJMEEN.js → chunk-T2EGSZFN.js} +9 -8
  59. package/dist/chunk-T2EGSZFN.js.map +1 -0
  60. package/dist/{chunk-S52LJNMU.js → chunk-TKN5667N.js} +13 -13
  61. package/dist/chunk-TKN5667N.js.map +1 -0
  62. package/dist/{chunk-3BFX6HJM.js → chunk-UUYRJPGJ.js} +6 -6
  63. package/dist/{chunk-F6BYUO5U.js → chunk-VHG6AX5V.js} +3 -3
  64. package/dist/{chunk-FOQV6RCO.cjs → chunk-VQ523ORK.cjs} +29 -29
  65. package/dist/{chunk-FOQV6RCO.cjs.map → chunk-VQ523ORK.cjs.map} +1 -1
  66. package/dist/{chunk-PXF5J24T.js → chunk-XQ2B74SI.js} +2 -2
  67. package/dist/{chunk-H7JX2BG7.cjs → chunk-YHOIAA2E.cjs} +76 -76
  68. package/dist/chunk-YHOIAA2E.cjs.map +1 -0
  69. package/dist/{chunk-LLWBPPZ4.js → chunk-YZ2OIYQ3.js} +6 -6
  70. package/dist/components/case-studies/index.cjs +10 -10
  71. package/dist/components/case-studies/index.js +4 -4
  72. package/dist/components/chat/chat-message-enhanced.d.ts.map +1 -1
  73. package/dist/components/chat/chat-message-list.d.ts.map +1 -1
  74. package/dist/components/chat/hooks/index.d.ts +0 -1
  75. package/dist/components/chat/hooks/index.d.ts.map +1 -1
  76. package/dist/components/chat/hooks/use-chat-history-hydration.d.ts +47 -0
  77. package/dist/components/chat/hooks/use-chat-history-hydration.d.ts.map +1 -0
  78. package/dist/components/chat/hooks/use-chat.d.ts +1 -0
  79. package/dist/components/chat/hooks/use-chat.d.ts.map +1 -1
  80. package/dist/components/chat/hooks/use-nats-chat-adapter.d.ts +15 -48
  81. package/dist/components/chat/hooks/use-nats-chat-adapter.d.ts.map +1 -1
  82. package/dist/components/chat/hooks/use-sse-chat-adapter.d.ts +10 -53
  83. package/dist/components/chat/hooks/use-sse-chat-adapter.d.ts.map +1 -1
  84. package/dist/components/chat/index.cjs +12 -4
  85. package/dist/components/chat/index.cjs.map +1 -1
  86. package/dist/components/chat/index.d.ts +1 -0
  87. package/dist/components/chat/index.d.ts.map +1 -1
  88. package/dist/components/chat/index.js +23 -15
  89. package/dist/components/chat/stream/chat-dialog-store.d.ts +184 -0
  90. package/dist/components/chat/stream/chat-dialog-store.d.ts.map +1 -0
  91. package/dist/components/chat/stream/chat-stream-reducer.d.ts +367 -0
  92. package/dist/components/chat/stream/chat-stream-reducer.d.ts.map +1 -0
  93. package/dist/components/chat/stream/delta-batcher.d.ts +51 -0
  94. package/dist/components/chat/stream/delta-batcher.d.ts.map +1 -0
  95. package/dist/components/chat/stream/index.d.ts +17 -0
  96. package/dist/components/chat/stream/index.d.ts.map +1 -0
  97. package/dist/components/chat/stream/message-mutations.d.ts +77 -0
  98. package/dist/components/chat/stream/message-mutations.d.ts.map +1 -0
  99. package/dist/components/chat/stream/use-chat-stream-reducer.d.ts +22 -0
  100. package/dist/components/chat/stream/use-chat-stream-reducer.d.ts.map +1 -0
  101. package/dist/components/chat/types/api.types.d.ts +1 -81
  102. package/dist/components/chat/types/api.types.d.ts.map +1 -1
  103. package/dist/components/chat/types/processing.types.d.ts +2 -90
  104. package/dist/components/chat/types/processing.types.d.ts.map +1 -1
  105. package/dist/components/chat/types/unified-chat-state.types.d.ts +5 -0
  106. package/dist/components/chat/types/unified-chat-state.types.d.ts.map +1 -1
  107. package/dist/components/chat/utils/chat-conversation-storage.d.ts +34 -0
  108. package/dist/components/chat/utils/chat-conversation-storage.d.ts.map +1 -0
  109. package/dist/components/chat/utils/extract-incomplete-message-state.d.ts +34 -6
  110. package/dist/components/chat/utils/extract-incomplete-message-state.d.ts.map +1 -1
  111. package/dist/components/chat/utils/index.d.ts +2 -3
  112. package/dist/components/chat/utils/index.d.ts.map +1 -1
  113. package/dist/components/chat/utils/process-historical-messages.d.ts +59 -11
  114. package/dist/components/chat/utils/process-historical-messages.d.ts.map +1 -1
  115. package/dist/components/contact/index.cjs +5 -5
  116. package/dist/components/contact/index.js +4 -4
  117. package/dist/components/docs/index.cjs +7 -7
  118. package/dist/components/docs/index.js +6 -6
  119. package/dist/components/embeds/index.cjs +5 -5
  120. package/dist/components/embeds/index.js +4 -4
  121. package/dist/components/faq/index.cjs +6 -6
  122. package/dist/components/faq/index.js +5 -5
  123. package/dist/components/features/index.cjs +4 -4
  124. package/dist/components/features/index.js +3 -3
  125. package/dist/components/help-center-pages/index.cjs +23 -23
  126. package/dist/components/help-center-pages/index.js +14 -14
  127. package/dist/components/index.cjs +182 -140
  128. package/dist/components/index.cjs.map +1 -1
  129. package/dist/components/index.js +66 -24
  130. package/dist/components/index.js.map +1 -1
  131. package/dist/components/navigation/index.cjs +4 -4
  132. package/dist/components/navigation/index.js +3 -3
  133. package/dist/components/onboarding-guides/index.cjs +8 -8
  134. package/dist/components/onboarding-guides/index.js +7 -7
  135. package/dist/components/onboarding-guides/onboarding-guide-detail-view.d.ts.map +1 -1
  136. package/dist/components/related-content/index.cjs +6 -6
  137. package/dist/components/related-content/index.js +5 -5
  138. package/dist/components/shared/product-release/release-detail-page.d.ts.map +1 -1
  139. package/dist/components/tickets/index.cjs +7 -7
  140. package/dist/components/tickets/index.js +6 -6
  141. package/dist/components/ui/index.cjs +46 -4
  142. package/dist/components/ui/index.cjs.map +1 -1
  143. package/dist/components/ui/index.d.ts +1 -2
  144. package/dist/components/ui/index.d.ts.map +1 -1
  145. package/dist/components/ui/index.js +57 -15
  146. package/dist/components/ui/markdown/base-components.d.ts +51 -0
  147. package/dist/components/ui/markdown/base-components.d.ts.map +1 -0
  148. package/dist/components/ui/markdown/engine.d.ts +99 -0
  149. package/dist/components/ui/markdown/engine.d.ts.map +1 -0
  150. package/dist/components/ui/markdown/heading-ids.d.ts +65 -0
  151. package/dist/components/ui/markdown/heading-ids.d.ts.map +1 -0
  152. package/dist/components/ui/markdown/index.d.ts +18 -0
  153. package/dist/components/ui/markdown/index.d.ts.map +1 -0
  154. package/dist/components/ui/markdown/mermaid-diagram.d.ts +79 -0
  155. package/dist/components/ui/markdown/mermaid-diagram.d.ts.map +1 -0
  156. package/dist/components/ui/markdown/rich/embed-overrides.d.ts +14 -0
  157. package/dist/components/ui/markdown/rich/embed-overrides.d.ts.map +1 -0
  158. package/dist/components/ui/markdown/rich/rich-markdown-renderer.d.ts +40 -0
  159. package/dist/components/ui/markdown/rich/rich-markdown-renderer.d.ts.map +1 -0
  160. package/dist/components/ui/markdown/rich/shortcodes.d.ts +8 -0
  161. package/dist/components/ui/markdown/rich/shortcodes.d.ts.map +1 -0
  162. package/dist/components/ui/markdown/sanitize.d.ts +153 -0
  163. package/dist/components/ui/markdown/sanitize.d.ts.map +1 -0
  164. package/dist/components/ui/markdown/simple-markdown-renderer.d.ts +23 -0
  165. package/dist/components/ui/markdown/simple-markdown-renderer.d.ts.map +1 -0
  166. package/dist/components/ui/markdown/streaming.d.ts +78 -0
  167. package/dist/components/ui/markdown/streaming.d.ts.map +1 -0
  168. package/dist/components/ui/markdown/text-size.d.ts +34 -0
  169. package/dist/components/ui/markdown/text-size.d.ts.map +1 -0
  170. package/dist/contexts/chat-runtime-context.d.ts +8 -0
  171. package/dist/contexts/chat-runtime-context.d.ts.map +1 -1
  172. package/dist/contexts/index.cjs +2 -2
  173. package/dist/contexts/index.js +1 -1
  174. package/dist/hooks/index.cjs +3 -3
  175. package/dist/hooks/index.js +2 -2
  176. package/dist/index.cjs +62 -4
  177. package/dist/index.cjs.map +1 -1
  178. package/dist/index.js +73 -15
  179. package/dist/utils/index.cjs +169 -40
  180. package/dist/utils/index.cjs.map +1 -1
  181. package/dist/utils/index.d.ts +1 -0
  182. package/dist/utils/index.d.ts.map +1 -1
  183. package/dist/utils/index.js +162 -41
  184. package/dist/utils/index.js.map +1 -1
  185. package/dist/utils/markdown-fences.d.ts +42 -0
  186. package/dist/utils/markdown-fences.d.ts.map +1 -0
  187. package/dist/utils/markdown-heading-id.d.ts +85 -0
  188. package/dist/utils/markdown-heading-id.d.ts.map +1 -0
  189. package/dist/utils/markdown-section-extractor.d.ts.map +1 -1
  190. package/package.json +7 -1
  191. package/src/chat-protocol/__tests__/__snapshots__/nats-decoder-golden.test.ts.snap +307 -0
  192. package/src/chat-protocol/__tests__/chat-protocol.test.ts +516 -0
  193. package/src/chat-protocol/__tests__/env-flag.test.ts +52 -0
  194. package/src/chat-protocol/__tests__/ip-normalize.test.ts +136 -0
  195. package/src/chat-protocol/__tests__/nats-decoder-golden.test.ts +228 -0
  196. package/src/chat-protocol/decode.ts +278 -0
  197. package/src/chat-protocol/encode.ts +71 -0
  198. package/src/chat-protocol/env-flag.ts +39 -0
  199. package/src/chat-protocol/events.ts +232 -0
  200. package/src/chat-protocol/frames.ts +245 -0
  201. package/src/chat-protocol/index.ts +21 -0
  202. package/src/chat-protocol/ip-normalize.ts +146 -0
  203. package/src/chat-protocol/nats-decoder.ts +252 -0
  204. package/src/components/chat/__tests__/chat-message-list.test.tsx +293 -5
  205. package/src/components/chat/__tests__/chat-message-streaming-memo.test.tsx +207 -0
  206. package/src/components/chat/__tests__/chat-pending-turn.test.tsx +209 -0
  207. package/src/components/chat/chat-message-enhanced.tsx +111 -17
  208. package/src/components/chat/chat-message-list.tsx +358 -109
  209. package/src/components/chat/embeddable-chat.tsx +6 -0
  210. package/src/components/chat/hooks/.index.md +30 -33
  211. package/src/components/chat/hooks/.use-nats-chat-adapter.md +36 -55
  212. package/src/components/chat/hooks/__tests__/__snapshots__/conversation-id-persistence-golden.test.ts.snap +37 -0
  213. package/src/components/chat/hooks/__tests__/__snapshots__/sse-stream-golden.test.ts.snap +0 -0
  214. package/src/components/chat/hooks/__tests__/conversation-id-persistence-golden.test.ts +438 -0
  215. package/src/components/chat/hooks/__tests__/sse-stream-golden.test.ts +318 -0
  216. package/src/components/chat/hooks/index.ts +0 -1
  217. package/src/components/chat/hooks/use-chat-history-hydration.ts +142 -0
  218. package/src/components/chat/hooks/use-chat.ts +20 -0
  219. package/src/components/chat/hooks/use-nats-chat-adapter.ts +243 -823
  220. package/src/components/chat/hooks/use-sse-chat-adapter.ts +417 -774
  221. package/src/components/chat/index.ts +5 -0
  222. package/src/components/chat/stream/__tests__/__snapshots__/chat-stream-reducer-golden.test.ts.snap +940 -0
  223. package/src/components/chat/stream/__tests__/chat-dialog-store.test.ts +756 -0
  224. package/src/components/chat/stream/__tests__/chat-stream-reducer-golden.test.ts +460 -0
  225. package/src/components/chat/stream/__tests__/chat-stream-reducer.test.ts +894 -0
  226. package/src/components/chat/stream/__tests__/delta-batcher.test.ts +255 -0
  227. package/src/components/chat/stream/__tests__/use-chat-stream-reducer.test.ts +98 -0
  228. package/src/components/chat/stream/chat-dialog-store.ts +536 -0
  229. package/src/components/chat/stream/chat-stream-reducer.ts +1796 -0
  230. package/src/components/chat/stream/delta-batcher.ts +159 -0
  231. package/src/components/chat/stream/index.ts +55 -0
  232. package/src/components/chat/stream/message-mutations.ts +338 -0
  233. package/src/components/chat/stream/use-chat-stream-reducer.ts +126 -0
  234. package/src/components/chat/thinking-display.tsx +1 -1
  235. package/src/components/chat/types/.api.types.md +39 -49
  236. package/src/components/chat/types/.processing.types.md +29 -58
  237. package/src/components/chat/types/api.types.ts +6 -73
  238. package/src/components/chat/types/processing.types.ts +11 -52
  239. package/src/components/chat/types/unified-chat-state.types.ts +6 -0
  240. package/src/components/chat/utils/.index.md +31 -40
  241. package/src/components/chat/utils/__tests__/__snapshots__/process-historical-messages-golden.test.ts.snap +420 -0
  242. package/src/components/chat/utils/__tests__/__snapshots__/segment-accumulator-golden.test.ts.snap +605 -0
  243. package/src/components/chat/utils/__tests__/process-historical-messages-golden.test.ts +317 -0
  244. package/src/components/chat/utils/__tests__/segment-accumulator-golden.test.ts +270 -0
  245. package/src/components/chat/utils/chat-conversation-storage.ts +83 -0
  246. package/src/components/chat/utils/extract-incomplete-message-state.ts +63 -5
  247. package/src/components/chat/utils/index.ts +5 -8
  248. package/src/components/chat/utils/process-historical-messages.ts +352 -384
  249. package/src/components/onboarding-guides/onboarding-guide-detail-view.tsx +22 -4
  250. package/src/components/shared/legal-document/legal-document-page.tsx +1 -1
  251. package/src/components/shared/product-release/release-detail-page.tsx +19 -4
  252. package/src/components/ui/__tests__/__snapshots__/markdown-parity.test.tsx.snap +2420 -0
  253. package/src/components/ui/__tests__/markdown-parity.test.tsx +2528 -0
  254. package/src/components/ui/index.ts +1 -2
  255. package/src/components/ui/markdown/__tests__/mermaid-security.test.ts +115 -0
  256. package/src/components/ui/markdown/__tests__/mermaid-stale-render.test.tsx +95 -0
  257. package/src/components/ui/markdown/__tests__/sanitize-invariant.test.ts +309 -0
  258. package/src/components/ui/markdown/__tests__/streaming.test.tsx +388 -0
  259. package/src/components/ui/markdown/base-components.tsx +360 -0
  260. package/src/components/ui/markdown/engine.tsx +315 -0
  261. package/src/components/ui/markdown/heading-ids.ts +239 -0
  262. package/src/components/ui/markdown/index.ts +49 -0
  263. package/src/components/ui/markdown/mermaid-diagram.tsx +291 -0
  264. package/src/components/ui/markdown/rich/embed-overrides.tsx +199 -0
  265. package/src/components/ui/markdown/rich/rich-markdown-renderer.tsx +184 -0
  266. package/src/components/ui/markdown/rich/shortcodes.ts +170 -0
  267. package/src/components/ui/markdown/sanitize.ts +3499 -0
  268. package/src/components/ui/markdown/simple-markdown-renderer.tsx +28 -0
  269. package/src/components/ui/markdown/streaming.ts +362 -0
  270. package/src/components/ui/markdown/text-size.ts +106 -0
  271. package/src/components/ui/release-changelog-section.tsx +1 -1
  272. package/src/components/ui/ticket-info-section.tsx +1 -1
  273. package/src/contexts/chat-runtime-context.tsx +8 -0
  274. package/src/utils/index.ts +1 -0
  275. package/src/utils/markdown-fences.ts +127 -0
  276. package/src/utils/markdown-heading-id.ts +348 -0
  277. package/src/utils/markdown-section-extractor.ts +41 -56
  278. package/dist/chunk-2B6773QM.cjs.map +0 -1
  279. package/dist/chunk-5RXKOGH6.cjs.map +0 -1
  280. package/dist/chunk-77DFPD35.js.map +0 -1
  281. package/dist/chunk-AHDJMEEN.js.map +0 -1
  282. package/dist/chunk-FZOBQG6M.cjs.map +0 -1
  283. package/dist/chunk-H7JX2BG7.cjs.map +0 -1
  284. package/dist/chunk-KFQXO7QB.js.map +0 -1
  285. package/dist/chunk-S52LJNMU.js.map +0 -1
  286. package/dist/components/chat/hooks/use-realtime-chunk-processor.d.ts +0 -6
  287. package/dist/components/chat/hooks/use-realtime-chunk-processor.d.ts.map +0 -1
  288. package/dist/components/chat/utils/chunk-parser.d.ts +0 -25
  289. package/dist/components/chat/utils/chunk-parser.d.ts.map +0 -1
  290. package/dist/components/ui/rich-markdown-renderer.d.ts +0 -34
  291. package/dist/components/ui/rich-markdown-renderer.d.ts.map +0 -1
  292. package/dist/components/ui/simple-markdown-renderer.d.ts +0 -73
  293. package/dist/components/ui/simple-markdown-renderer.d.ts.map +0 -1
  294. package/src/components/chat/hooks/.use-realtime-chunk-processor.md +0 -77
  295. package/src/components/chat/hooks/use-realtime-chunk-processor.ts +0 -479
  296. package/src/components/chat/utils/.chunk-parser.md +0 -62
  297. package/src/components/chat/utils/chunk-parser.ts +0 -256
  298. package/src/components/ui/.simple-markdown-renderer.md +0 -52
  299. package/src/components/ui/rich-markdown-renderer.tsx +0 -1223
  300. package/src/components/ui/simple-markdown-renderer.tsx +0 -964
  301. /package/dist/{chunk-AJNDDXKC.js.map → chunk-4NWARV5I.js.map} +0 -0
  302. /package/dist/{chunk-HQYJINFV.js.map → chunk-CORIBKHG.js.map} +0 -0
  303. /package/dist/{chunk-PNXNWGBY.js.map → chunk-GTWO5NAY.js.map} +0 -0
  304. /package/dist/{chunk-SCCTJ2UI.js.map → chunk-HSACATCD.js.map} +0 -0
  305. /package/dist/{chunk-KLYHZRAD.js.map → chunk-PB3BTZDU.js.map} +0 -0
  306. /package/dist/{chunk-YJSKQ2ZV.js.map → chunk-RQLBHKXN.js.map} +0 -0
  307. /package/dist/{chunk-CWQACI4Z.js.map → chunk-RW2LPL72.js.map} +0 -0
  308. /package/dist/{chunk-3BFX6HJM.js.map → chunk-UUYRJPGJ.js.map} +0 -0
  309. /package/dist/{chunk-F6BYUO5U.js.map → chunk-VHG6AX5V.js.map} +0 -0
  310. /package/dist/{chunk-PXF5J24T.js.map → chunk-XQ2B74SI.js.map} +0 -0
  311. /package/dist/{chunk-LLWBPPZ4.js.map → chunk-YZ2OIYQ3.js.map} +0 -0
@@ -1,4 +1,4 @@
1
- import { render } from '@testing-library/react'
1
+ import { act, render } from '@testing-library/react'
2
2
  import { beforeEach, describe, expect, it, vi } from 'vitest'
3
3
  import type { Message } from '../types/message.types'
4
4
 
@@ -26,9 +26,16 @@ vi.mock('use-stick-to-bottom', () => ({
26
26
 
27
27
  // Keep the row renderer trivial — these tests exercise ChatMessageList's
28
28
  // scroll orchestration, not markdown rendering.
29
- vi.mock('../chat-message-enhanced', () => ({
30
- ChatMessageEnhanced: ({ content }: { content: string }) => <div>{content}</div>,
31
- }))
29
+ // Forwards the ref because the list registers each row element for the
30
+ // per-message `scrollAnchor: 'top'` path.
31
+ vi.mock('../chat-message-enhanced', async () => {
32
+ const { forwardRef } = await import('react')
33
+ return {
34
+ ChatMessageEnhanced: forwardRef<HTMLDivElement, { content: string }>(({ content }, ref) => (
35
+ <div ref={ref}>{content}</div>
36
+ )),
37
+ }
38
+ })
32
39
 
33
40
  import { ChatMessageList } from '../chat-message-list'
34
41
 
@@ -40,7 +47,36 @@ class ObserverStub {
40
47
  disconnect() {}
41
48
  }
42
49
  vi.stubGlobal('IntersectionObserver', ObserverStub)
43
- vi.stubGlobal('ResizeObserver', ObserverStub)
50
+
51
+ // Recording RO so the bottom-follow tests can drive a resize by hand.
52
+ const resizeCallbacks: Array<() => void> = []
53
+ class RecordingResizeObserver {
54
+ constructor(cb: () => void) {
55
+ resizeCallbacks.push(cb)
56
+ }
57
+ observe() {}
58
+ unobserve() {}
59
+ disconnect() {}
60
+ }
61
+ vi.stubGlobal('ResizeObserver', RecordingResizeObserver)
62
+ /** Fire every live ResizeObserver callback — same effect as any content
63
+ * or scroller-box size change in the real DOM. */
64
+ const fireResize = () => {
65
+ for (const cb of resizeCallbacks) cb()
66
+ }
67
+
68
+ /** jsdom has no layout, so every box metric reads 0. The jump-to-bottom
69
+ * button is driven off real geometry, so the tests supply it. */
70
+ const setGeometry = (geo: { scrollHeight: number; clientHeight: number; scrollTop: number }) => {
71
+ const el = scrollRefMock.current
72
+ if (!el) throw new Error('scroller not mounted')
73
+ for (const [key, value] of Object.entries(geo)) {
74
+ Object.defineProperty(el, key, { value, configurable: true, writable: true })
75
+ }
76
+ }
77
+
78
+ // jsdom has no layout, so `scrollIntoView` is undefined.
79
+ Element.prototype.scrollIntoView = vi.fn()
44
80
 
45
81
  const msg = (id: string, role: Message['role']): Message => ({
46
82
  id,
@@ -90,3 +126,255 @@ describe('ChatMessageList force-scroll decisions', () => {
90
126
  expect(scrollToBottom).not.toHaveBeenCalled()
91
127
  })
92
128
  })
129
+
130
+ // The library's own `isAtBottom` lock is lost silently when the scroller's
131
+ // box changes (source chips mounting below it) or when a card settling out
132
+ // of its skeleton produces a resize-driven scroll the library reads as a
133
+ // user gesture. The list therefore owns the INTENT and re-asserts on every
134
+ // geometry change until a real gesture releases it.
135
+ describe('ChatMessageList bottom-follow intent', () => {
136
+ beforeEach(() => {
137
+ scrollToBottom.mockClear()
138
+ resizeCallbacks.length = 0
139
+ })
140
+
141
+ const sendTurn = () => {
142
+ const initial = [msg('m1', 'user'), msg('m2', 'assistant')]
143
+ const view = render(<ChatMessageList dialogId="d1" messages={initial} />)
144
+ // A user send arms the follow intent for the whole turn.
145
+ view.rerender(<ChatMessageList dialogId="d1" messages={[...initial, msg('m3', 'user')]} />)
146
+ scrollToBottom.mockClear()
147
+ return view
148
+ }
149
+
150
+ it('re-asserts the bottom on geometry changes after the send', () => {
151
+ sendTurn()
152
+ fireResize()
153
+ expect(scrollToBottom).toHaveBeenCalled()
154
+ })
155
+
156
+ it('keeps re-asserting for late async growth (cards + covers resolving)', () => {
157
+ sendTurn()
158
+ fireResize()
159
+ fireResize()
160
+ fireResize()
161
+ expect(scrollToBottom).toHaveBeenCalledTimes(3)
162
+ })
163
+
164
+ it('releases the intent once the user scrolls up', () => {
165
+ sendTurn()
166
+ scrollRefMock.current?.dispatchEvent(
167
+ new WheelEvent('wheel', { deltaY: -120, bubbles: true }),
168
+ )
169
+ fireResize()
170
+ expect(scrollToBottom).not.toHaveBeenCalled()
171
+ })
172
+
173
+ it('ignores downward wheel (never an escape from the bottom)', () => {
174
+ sendTurn()
175
+ scrollRefMock.current?.dispatchEvent(
176
+ new WheelEvent('wheel', { deltaY: 120, bubbles: true }),
177
+ )
178
+ fireResize()
179
+ expect(scrollToBottom).toHaveBeenCalled()
180
+ })
181
+
182
+ it('does not follow when autoScroll is off (passive demo hosts)', () => {
183
+ const initial = [msg('m1', 'user'), msg('m2', 'assistant')]
184
+ const { rerender } = render(
185
+ <ChatMessageList dialogId="d1" messages={initial} autoScroll={false} />,
186
+ )
187
+ rerender(
188
+ <ChatMessageList dialogId="d1" messages={[...initial, msg('m3', 'user')]} autoScroll={false} />,
189
+ )
190
+ scrollToBottom.mockClear()
191
+ fireResize()
192
+ expect(scrollToBottom).not.toHaveBeenCalled()
193
+ })
194
+
195
+ it('hides jump-to-bottom while the thread is at the bottom', () => {
196
+ const view = sendTurn()
197
+ setGeometry({ scrollHeight: 1000, clientHeight: 500, scrollTop: 500 })
198
+ act(() => fireResize())
199
+ expect(view.queryByLabelText('Scroll to latest message')).toBeNull()
200
+ })
201
+
202
+ it('shows jump-to-bottom once the thread is away from the bottom', () => {
203
+ const view = sendTurn()
204
+ setGeometry({ scrollHeight: 20000, clientHeight: 500, scrollTop: 500 })
205
+ act(() => fireResize())
206
+ expect(view.queryByLabelText('Scroll to latest message')).not.toBeNull()
207
+ })
208
+
209
+ it('re-arms the intent when the user clicks jump-to-bottom', () => {
210
+ const view = sendTurn()
211
+ // User scrolls away: intent released, button appears.
212
+ scrollRefMock.current?.dispatchEvent(
213
+ new WheelEvent('wheel', { deltaY: -120, bubbles: true }),
214
+ )
215
+ setGeometry({ scrollHeight: 20000, clientHeight: 500, scrollTop: 500 })
216
+ act(() => fireResize())
217
+ expect(scrollToBottom).not.toHaveBeenCalled()
218
+
219
+ const button = view.getByLabelText('Scroll to latest message')
220
+ act(() => {
221
+ button.click()
222
+ })
223
+ expect(scrollToBottom).toHaveBeenCalled()
224
+
225
+ // …and the click re-armed the lock, so growth follows again.
226
+ scrollToBottom.mockClear()
227
+ act(() => fireResize())
228
+ expect(scrollToBottom).toHaveBeenCalled()
229
+ })
230
+
231
+ // A drag that ends OUTSIDE the window (button released off-screen, or the
232
+ // tab losing focus mid-drag) never delivers `pointerup`, so a naive
233
+ // `pointerDown` flag stays true forever. Afterwards the very thing this
234
+ // component exists for — a fetch-mode card settling from skeleton to a
235
+ // SHORTER height, which clamps scrollTop down — is misread as a scroll-up
236
+ // gesture and silently drops the lock.
237
+ const dragThenScrollDown = (endEvent: { target: 'window'; type: string }) => {
238
+ sendTurn()
239
+ // Park away from the bottom so the at-bottom re-arm below can't mask the
240
+ // result, and seed `lastScrollTop`.
241
+ setGeometry({ scrollHeight: 20000, clientHeight: 500, scrollTop: 4000 })
242
+ window.dispatchEvent(new Event('pointerdown'))
243
+ act(() => {
244
+ scrollRefMock.current?.dispatchEvent(new Event('scroll'))
245
+ })
246
+ // Drag ends where we can't see it.
247
+ if (endEvent.target === 'window') window.dispatchEvent(new Event(endEvent.type))
248
+ // Resize clamp: content shrank, scrollTop drops. NOT a gesture.
249
+ setGeometry({ scrollHeight: 20000, clientHeight: 500, scrollTop: 3800 })
250
+ act(() => {
251
+ scrollRefMock.current?.dispatchEvent(new Event('scroll'))
252
+ })
253
+ scrollToBottom.mockClear()
254
+ act(() => fireResize())
255
+ }
256
+
257
+ it('keeps the intent when a pointercancel ended the drag (resize clamp is not a gesture)', () => {
258
+ dragThenScrollDown({ target: 'window', type: 'pointercancel' })
259
+ expect(scrollToBottom).toHaveBeenCalled()
260
+ })
261
+
262
+ it('keeps the intent when the window lost focus mid-drag', () => {
263
+ dragThenScrollDown({ target: 'window', type: 'blur' })
264
+ expect(scrollToBottom).toHaveBeenCalled()
265
+ })
266
+
267
+ // Focus events do not bubble but they DO capture, so a capture-phase `blur`
268
+ // listener on `window` fires for EVERY element blur in the document — the
269
+ // classic focus-delegation idiom. Registering the drag-end `blur` that way
270
+ // made any focus change during a pointer hold (most commonly the chat input
271
+ // blurring as the user presses down on the scrollbar) clear `pointerDown`,
272
+ // which is exactly what re-opens the yank-back-mid-drag bug. Only a TRUE
273
+ // window blur (which targets `window` itself) may end the drag.
274
+ it('a descendant element losing focus does not end the drag', () => {
275
+ sendTurn()
276
+ setGeometry({ scrollHeight: 20000, clientHeight: 500, scrollTop: 4000 })
277
+ window.dispatchEvent(new Event('pointerdown'))
278
+ act(() => {
279
+ scrollRefMock.current?.dispatchEvent(new Event('scroll'))
280
+ })
281
+ // The chat input loses focus as the pointer goes down elsewhere.
282
+ const input = document.createElement('input')
283
+ document.body.appendChild(input)
284
+ input.focus()
285
+ input.blur()
286
+ input.remove()
287
+ // The drag is still live, so this held scroll-up IS a gesture.
288
+ setGeometry({ scrollHeight: 20000, clientHeight: 500, scrollTop: 3800 })
289
+ act(() => {
290
+ scrollRefMock.current?.dispatchEvent(new Event('scroll'))
291
+ })
292
+ scrollToBottom.mockClear()
293
+ act(() => fireResize())
294
+ expect(scrollToBottom).not.toHaveBeenCalled()
295
+ window.dispatchEvent(new Event('pointerup'))
296
+ })
297
+
298
+ it('still disarms on a genuine scrollbar drag upward', () => {
299
+ sendTurn()
300
+ setGeometry({ scrollHeight: 20000, clientHeight: 500, scrollTop: 4000 })
301
+ window.dispatchEvent(new Event('pointerdown'))
302
+ act(() => {
303
+ scrollRefMock.current?.dispatchEvent(new Event('scroll'))
304
+ })
305
+ setGeometry({ scrollHeight: 20000, clientHeight: 500, scrollTop: 3800 })
306
+ act(() => {
307
+ scrollRefMock.current?.dispatchEvent(new Event('scroll'))
308
+ })
309
+ scrollToBottom.mockClear()
310
+ act(() => fireResize())
311
+ expect(scrollToBottom).not.toHaveBeenCalled()
312
+ window.dispatchEvent(new Event('pointerup'))
313
+ })
314
+
315
+ // Scrolling back down to the live end by hand expresses exactly the intent
316
+ // the jump-to-bottom button does — the lock has to re-arm, or the thread
317
+ // renders at-bottom (button hidden) and then silently drifts away.
318
+ it('re-arms the intent when the user scrolls back to the bottom by hand', () => {
319
+ sendTurn()
320
+ scrollRefMock.current?.dispatchEvent(new WheelEvent('wheel', { deltaY: -120, bubbles: true }))
321
+ setGeometry({ scrollHeight: 20000, clientHeight: 500, scrollTop: 500 })
322
+ act(() => fireResize())
323
+ expect(scrollToBottom).not.toHaveBeenCalled()
324
+
325
+ // …and back down to the end.
326
+ setGeometry({ scrollHeight: 20000, clientHeight: 500, scrollTop: 19500 })
327
+ act(() => {
328
+ scrollRefMock.current?.dispatchEvent(new Event('scroll'))
329
+ })
330
+ scrollToBottom.mockClear()
331
+ act(() => fireResize())
332
+ expect(scrollToBottom).toHaveBeenCalled()
333
+ })
334
+
335
+ it('does not re-arm while the user is still parked above the bottom', () => {
336
+ sendTurn()
337
+ scrollRefMock.current?.dispatchEvent(new WheelEvent('wheel', { deltaY: -120, bubbles: true }))
338
+ setGeometry({ scrollHeight: 20000, clientHeight: 500, scrollTop: 8000 })
339
+ act(() => {
340
+ scrollRefMock.current?.dispatchEvent(new Event('scroll'))
341
+ })
342
+ scrollToBottom.mockClear()
343
+ act(() => fireResize())
344
+ expect(scrollToBottom).not.toHaveBeenCalled()
345
+ })
346
+
347
+ // A short scrollbar drag that starts at the live end and stays inside the
348
+ // 70px band disarms on the gesture and would immediately re-arm on the very
349
+ // same scroll event if the re-arm didn't wait for the drag to END — yanking
350
+ // the reader back down mid-drag, a miniature of the bug this block exists
351
+ // to prevent.
352
+ it('does not re-arm mid-drag when the drag stays inside the bottom band', () => {
353
+ sendTurn()
354
+ setGeometry({ scrollHeight: 20000, clientHeight: 500, scrollTop: 19500 })
355
+ act(() => {
356
+ scrollRefMock.current?.dispatchEvent(new Event('scroll'))
357
+ })
358
+ window.dispatchEvent(new Event('pointerdown'))
359
+ setGeometry({ scrollHeight: 20000, clientHeight: 500, scrollTop: 19460 })
360
+ act(() => {
361
+ scrollRefMock.current?.dispatchEvent(new Event('scroll'))
362
+ })
363
+ scrollToBottom.mockClear()
364
+ act(() => fireResize())
365
+ expect(scrollToBottom).not.toHaveBeenCalled()
366
+ window.dispatchEvent(new Event('pointerup'))
367
+ })
368
+
369
+ it('releases the intent for a top-anchored turn (it parks at the message top)', () => {
370
+ const initial = [msg('m1', 'user'), msg('m2', 'assistant')]
371
+ const { rerender } = render(<ChatMessageList dialogId="d1" messages={initial} />)
372
+ const sent = [...initial, msg('m3', 'user')]
373
+ rerender(<ChatMessageList dialogId="d1" messages={sent} />)
374
+ const anchored: Message = { ...msg('m4', 'assistant'), scrollAnchor: 'top' }
375
+ rerender(<ChatMessageList dialogId="d1" messages={[...sent, anchored]} />)
376
+ scrollToBottom.mockClear()
377
+ fireResize()
378
+ expect(scrollToBottom).not.toHaveBeenCalled()
379
+ })
380
+ })
@@ -0,0 +1,207 @@
1
+ /**
2
+ * REGRESSION: `ChatMessageEnhanced` must hand the markdown engine a
3
+ * REFERENTIALLY STABLE `componentOverrides` map across a streamed turn.
4
+ *
5
+ * The engine memoizes each COMPLETED block (`StreamingBlockRenderer`) on its
6
+ * `components` prop identity. `ChatMessageEnhanced` is the sole production
7
+ * caller, and it used to defeat that memo one level up: `cardComponentOverrides`
8
+ * listed `renderingPlan` in its deps, `renderingPlan` is memoized on `segments`,
9
+ * and the stream reducer replaces `segments` on EVERY text delta. So the
10
+ * override map got a new identity per token → the engine's `components` memo
11
+ * re-created → every completed block re-parsed on every token.
12
+ *
13
+ * The engine's own streaming test cannot catch this: it passes a module-scope
14
+ * stable override map. This test drives the real component with the real
15
+ * per-delta `content` churn a reducer produces, and asserts at the SEAM (the
16
+ * props the component hands the renderer) rather than through the engine's
17
+ * internals — the two are separately owned.
18
+ */
19
+
20
+ import React from 'react'
21
+ import { describe, it, expect, vi } from 'vitest'
22
+ import { render } from '@testing-library/react'
23
+ import type { MessageSegment } from '../types'
24
+ import type { ChatRef } from '../chat-ref.types'
25
+
26
+ /** Every `componentOverrides` / `additionalRemarkPlugins` identity the
27
+ * component handed the markdown renderer, in render order. */
28
+ const seenOverrides: unknown[] = []
29
+ const seenPlugins: unknown[] = []
30
+
31
+ vi.mock('../../ui/markdown/simple-markdown-renderer', () => ({
32
+ SimpleMarkdownRenderer: ({
33
+ content,
34
+ componentOverrides,
35
+ additionalRemarkPlugins,
36
+ }: {
37
+ content: string
38
+ componentOverrides?: unknown
39
+ additionalRemarkPlugins?: unknown
40
+ }) => {
41
+ seenOverrides.push(componentOverrides)
42
+ seenPlugins.push(additionalRemarkPlugins)
43
+ return <div data-testid="md">{content}</div>
44
+ },
45
+ }))
46
+
47
+ import { ChatMessageEnhanced } from '../chat-message-enhanced'
48
+
49
+ function NavLinkAnchor({ href, children }: React.AnchorHTMLAttributes<HTMLAnchorElement>) {
50
+ return <a href={href}>{children}</a>
51
+ }
52
+ const CHAT_REFS: Record<string, ChatRef> = {}
53
+ const renderMention = () => null
54
+
55
+ /** Fresh array per call — the reducer replaces `segments` on every delta. */
56
+ const segmentsFor = (tail: string): MessageSegment[] => [
57
+ { type: 'text', text: `First paragraph, already complete.\n\n${tail}` },
58
+ ]
59
+
60
+ describe('ChatMessageEnhanced — override identity across a streamed turn', () => {
61
+ it('keeps componentOverrides + remark plugins referentially stable per token', () => {
62
+ seenOverrides.length = 0
63
+ seenPlugins.length = 0
64
+
65
+ const props = {
66
+ role: 'assistant' as const,
67
+ isTyping: true,
68
+ chatRefs: CHAT_REFS,
69
+ renderMention,
70
+ NavLinkAnchor,
71
+ }
72
+
73
+ const view = render(<ChatMessageEnhanced {...props} content={segmentsFor('Tail')} />)
74
+ expect(seenOverrides.length).toBeGreaterThan(0)
75
+ const firstOverrides = seenOverrides[0]
76
+ const firstPlugins = seenPlugins[0]
77
+ expect(firstOverrides).toBeTruthy()
78
+
79
+ let tail = 'Tail'
80
+ for (let i = 0; i < 10; i += 1) {
81
+ tail += ` token${i}`
82
+ view.rerender(<ChatMessageEnhanced {...props} content={segmentsFor(tail)} />)
83
+ }
84
+
85
+ // ONE identity for the whole turn. Before the fix this produced a fresh
86
+ // object on each of the 11 renders.
87
+ expect(new Set(seenOverrides).size).toBe(1)
88
+ expect(seenOverrides.every((o) => o === firstOverrides)).toBe(true)
89
+ expect(new Set(seenPlugins).size).toBe(1)
90
+ expect(seenPlugins.every((p) => p === firstPlugins)).toBe(true)
91
+
92
+ // Sanity: the tail really did stream through.
93
+ expect(view.container.textContent).toContain('token9')
94
+ })
95
+
96
+ it('still resolves card markers through the CURRENT plan (ref-read, not closure)', () => {
97
+ seenOverrides.length = 0
98
+ const refs: Record<string, ChatRef> = {
99
+ 'blog:abc': { type: 'blog', id: 'abc', title: 'Resolved Title', url: null },
100
+ }
101
+ render(
102
+ <ChatMessageEnhanced
103
+ role="assistant"
104
+ content={[{ type: 'text', text: 'See [card://blog:abc] for more.' }]}
105
+ chatRefs={refs}
106
+ NavLinkAnchor={NavLinkAnchor}
107
+ />,
108
+ )
109
+ const overrides = seenOverrides[0] as { a: React.FC<{ href: string; children?: React.ReactNode }> }
110
+ const out = render(<>{overrides.a({ href: 'card://blog:abc', children: 'x' })}</>)
111
+ expect(out.container.textContent).toBe('Resolved Title')
112
+ })
113
+
114
+ /**
115
+ * REGRESSION (round 3): a ref that resolves AFTER the message finished
116
+ * streaming must still reach the pill. The override reads `chatRefs`
117
+ * through a ref, so its own closure is fine — but the markdown engine
118
+ * memoizes completed blocks on the override map's IDENTITY. With
119
+ * `chatRefs` excluded from the override memo's deps entirely, the map kept
120
+ * its old identity, the engine bailed, and the marker rendered its raw-id
121
+ * fallback permanently.
122
+ */
123
+ it('gives componentOverrides a NEW identity when the ref set grows (completed message)', () => {
124
+ seenOverrides.length = 0
125
+ const content: MessageSegment[] = [{ type: 'text', text: 'See [card://blog:late] for more.' }]
126
+ const emptyRefs: Record<string, ChatRef> = {}
127
+ const props = {
128
+ role: 'assistant' as const,
129
+ isTyping: false,
130
+ content,
131
+ NavLinkAnchor,
132
+ }
133
+
134
+ const view = render(<ChatMessageEnhanced {...props} chatRefs={emptyRefs} />)
135
+ const before = seenOverrides[seenOverrides.length - 1] as {
136
+ a: React.FC<{ href: string; children?: React.ReactNode }>
137
+ }
138
+ // Unresolved: the raw card id is surfaced so the breakage is visible.
139
+ expect(render(<>{before.a({ href: 'card://blog:late', children: 'x' })}</>).container.textContent)
140
+ .toBe('late')
141
+
142
+ // Same content, same everything — only the refs map gains the entry.
143
+ const resolvedRefs: Record<string, ChatRef> = {
144
+ 'blog:late': { type: 'blog', id: 'late', title: 'Late Title', url: null },
145
+ }
146
+ view.rerender(<ChatMessageEnhanced {...props} chatRefs={resolvedRefs} />)
147
+
148
+ const after = seenOverrides[seenOverrides.length - 1] as {
149
+ a: React.FC<{ href: string; children?: React.ReactNode }>
150
+ }
151
+ expect(after).not.toBe(before)
152
+ expect(render(<>{after.a({ href: 'card://blog:late', children: 'x' })}</>).container.textContent)
153
+ .toBe('Late Title')
154
+ })
155
+
156
+ /**
157
+ * REGRESSION (round 5): a later refs frame REPLACES the map for a send index
158
+ * with the SAME key set but richer values — the `title` going from the raw
159
+ * cardId to the resolved document title is the exact production case. A
160
+ * key-set-only token produced an identical string, so the override map kept
161
+ * its identity, the engine's completed-block memo bailed, and the pill stayed
162
+ * on its fallback forever. The token fingerprints VALUES now.
163
+ */
164
+ it('gives componentOverrides a NEW identity when a ref VALUE is enriched (same key set)', () => {
165
+ seenOverrides.length = 0
166
+ const content: MessageSegment[] = [{ type: 'text', text: 'See [card://blog:abc].' }]
167
+ const props = { role: 'assistant' as const, isTyping: false, content, NavLinkAnchor }
168
+
169
+ // First frame: the ref exists but its title is still the raw card id.
170
+ const stub: Record<string, ChatRef> = {
171
+ 'blog:abc': { type: 'blog', id: 'abc', title: 'abc', url: null },
172
+ }
173
+ const view = render(<ChatMessageEnhanced {...props} chatRefs={stub} />)
174
+ const before = seenOverrides[seenOverrides.length - 1] as {
175
+ a: React.FC<{ href: string; children?: React.ReactNode }>
176
+ }
177
+ expect(render(<>{before.a({ href: 'card://blog:abc', children: 'x' })}</>).container.textContent)
178
+ .toBe('abc')
179
+
180
+ // Second frame: SAME key set, enriched value.
181
+ const enriched: Record<string, ChatRef> = {
182
+ 'blog:abc': { type: 'blog', id: 'abc', title: 'Resolved Document Title', url: null },
183
+ }
184
+ view.rerender(<ChatMessageEnhanced {...props} chatRefs={enriched} />)
185
+
186
+ const after = seenOverrides[seenOverrides.length - 1] as {
187
+ a: React.FC<{ href: string; children?: React.ReactNode }>
188
+ }
189
+ expect(after).not.toBe(before)
190
+ expect(render(<>{after.a({ href: 'card://blog:abc', children: 'x' })}</>).container.textContent)
191
+ .toBe('Resolved Document Title')
192
+ })
193
+
194
+ /** …but a same-KEY refs object (new identity, same entries) must NOT churn
195
+ * the override map — that is the streaming-stability guarantee above. */
196
+ it('keeps the override identity when only the refs object identity changes', () => {
197
+ seenOverrides.length = 0
198
+ const content: MessageSegment[] = [{ type: 'text', text: 'See [card://blog:abc].' }]
199
+ const ref: ChatRef = { type: 'blog', id: 'abc', title: 'T', url: null }
200
+ const props = { role: 'assistant' as const, content, NavLinkAnchor }
201
+
202
+ const view = render(<ChatMessageEnhanced {...props} chatRefs={{ 'blog:abc': ref }} />)
203
+ view.rerender(<ChatMessageEnhanced {...props} chatRefs={{ 'blog:abc': ref }} />)
204
+
205
+ expect(new Set(seenOverrides).size).toBe(1)
206
+ })
207
+ })