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

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 (293) hide show
  1. package/dist/{chunk-5CJR2GZ4.cjs → chunk-2MKUHJ3O.cjs} +31 -31
  2. package/dist/{chunk-5CJR2GZ4.cjs.map → chunk-2MKUHJ3O.cjs.map} +1 -1
  3. package/dist/{chunk-S2NHJDKQ.cjs → chunk-3IEVA5AO.cjs} +62 -62
  4. package/dist/{chunk-S2NHJDKQ.cjs.map → chunk-3IEVA5AO.cjs.map} +1 -1
  5. package/dist/{chunk-GTWO5NAY.js → chunk-6CFFISUC.js} +2 -2
  6. package/dist/{chunk-VHG6AX5V.js → chunk-6CJ2K7BD.js} +2 -2
  7. package/dist/{chunk-4NWARV5I.js → chunk-6QK3ONDD.js} +2 -2
  8. package/dist/{chunk-YHOIAA2E.cjs → chunk-7I42V2OD.cjs} +60 -60
  9. package/dist/chunk-7I42V2OD.cjs.map +1 -0
  10. package/dist/{chunk-T2EGSZFN.js → chunk-AIRNG27G.js} +5 -6
  11. package/dist/chunk-AIRNG27G.js.map +1 -0
  12. package/dist/{chunk-NWX7MMF5.cjs → chunk-AW63ET2A.cjs} +15 -15
  13. package/dist/{chunk-NWX7MMF5.cjs.map → chunk-AW63ET2A.cjs.map} +1 -1
  14. package/dist/{chunk-TKN5667N.js → chunk-BZMOEWWS.js} +10 -10
  15. package/dist/chunk-BZMOEWWS.js.map +1 -0
  16. package/dist/{chunk-DSDZ5T4P.cjs → chunk-D5LQMRPU.cjs} +5 -5
  17. package/dist/{chunk-DSDZ5T4P.cjs.map → chunk-D5LQMRPU.cjs.map} +1 -1
  18. package/dist/{chunk-CORIBKHG.js → chunk-EGXCWZYP.js} +2 -2
  19. package/dist/{chunk-5EYRXSY5.cjs → chunk-EJQUYZXF.cjs} +9 -9
  20. package/dist/{chunk-5EYRXSY5.cjs.map → chunk-EJQUYZXF.cjs.map} +1 -1
  21. package/dist/{chunk-P4WTZU6L.cjs → chunk-FY5XAW3H.cjs} +11 -11
  22. package/dist/{chunk-P4WTZU6L.cjs.map → chunk-FY5XAW3H.cjs.map} +1 -1
  23. package/dist/{chunk-VQ523ORK.cjs → chunk-GMPYR3MX.cjs} +26 -26
  24. package/dist/{chunk-VQ523ORK.cjs.map → chunk-GMPYR3MX.cjs.map} +1 -1
  25. package/dist/{chunk-QSCHCZL3.cjs → chunk-GUTYUMGV.cjs} +7 -7
  26. package/dist/{chunk-QSCHCZL3.cjs.map → chunk-GUTYUMGV.cjs.map} +1 -1
  27. package/dist/{chunk-Q6E7VJ4W.cjs → chunk-IEZ3YBAP.cjs} +27 -28
  28. package/dist/chunk-IEZ3YBAP.cjs.map +1 -0
  29. package/dist/{chunk-HSACATCD.js → chunk-MPYDX5WX.js} +2 -2
  30. package/dist/{chunk-RQLBHKXN.js → chunk-NGEFYPY7.js} +2 -2
  31. package/dist/{chunk-BE6DK7QY.js → chunk-OQIMH2WQ.js} +8985 -11081
  32. package/dist/chunk-OQIMH2WQ.js.map +1 -0
  33. package/dist/{chunk-CSYSYJCF.cjs → chunk-UZZT65PQ.cjs} +37 -37
  34. package/dist/{chunk-CSYSYJCF.cjs.map → chunk-UZZT65PQ.cjs.map} +1 -1
  35. package/dist/{chunk-YZ2OIYQ3.js → chunk-VDZO6IDR.js} +4 -4
  36. package/dist/{chunk-UUYRJPGJ.js → chunk-VL2YMDPH.js} +4 -4
  37. package/dist/{chunk-5LMGFI4J.cjs → chunk-VUXNXWOQ.cjs} +4946 -7042
  38. package/dist/chunk-VUXNXWOQ.cjs.map +1 -0
  39. package/dist/{chunk-XQ2B74SI.js → chunk-WLM3F3WU.js} +2 -2
  40. package/dist/components/case-studies/index.cjs +8 -8
  41. package/dist/components/case-studies/index.js +2 -2
  42. package/dist/components/chat/chat-message-enhanced.d.ts.map +1 -1
  43. package/dist/components/chat/chat-message-list.d.ts.map +1 -1
  44. package/dist/components/chat/guide-display.d.ts +14 -0
  45. package/dist/components/chat/guide-display.d.ts.map +1 -0
  46. package/dist/components/chat/hooks/index.d.ts +1 -0
  47. package/dist/components/chat/hooks/index.d.ts.map +1 -1
  48. package/dist/components/chat/hooks/use-chat.d.ts.map +1 -1
  49. package/dist/components/chat/hooks/use-nats-chat-adapter.d.ts +48 -15
  50. package/dist/components/chat/hooks/use-nats-chat-adapter.d.ts.map +1 -1
  51. package/dist/components/chat/hooks/use-realtime-chunk-processor.d.ts +6 -0
  52. package/dist/components/chat/hooks/use-realtime-chunk-processor.d.ts.map +1 -0
  53. package/dist/components/chat/hooks/use-sse-chat-adapter.d.ts +57 -10
  54. package/dist/components/chat/hooks/use-sse-chat-adapter.d.ts.map +1 -1
  55. package/dist/components/chat/index.cjs +2 -8
  56. package/dist/components/chat/index.cjs.map +1 -1
  57. package/dist/components/chat/index.d.ts +1 -1
  58. package/dist/components/chat/index.d.ts.map +1 -1
  59. package/dist/components/chat/index.js +15 -21
  60. package/dist/components/chat/types/api.types.d.ts +81 -1
  61. package/dist/components/chat/types/api.types.d.ts.map +1 -1
  62. package/dist/components/chat/types/component.types.d.ts +6 -0
  63. package/dist/components/chat/types/component.types.d.ts.map +1 -1
  64. package/dist/components/chat/types/message.types.d.ts +15 -2
  65. package/dist/components/chat/types/message.types.d.ts.map +1 -1
  66. package/dist/components/chat/types/processing.types.d.ts +93 -2
  67. package/dist/components/chat/types/processing.types.d.ts.map +1 -1
  68. package/dist/components/chat/utils/chunk-parser.d.ts +25 -0
  69. package/dist/components/chat/utils/chunk-parser.d.ts.map +1 -0
  70. package/dist/components/chat/utils/extract-incomplete-message-state.d.ts +6 -34
  71. package/dist/components/chat/utils/extract-incomplete-message-state.d.ts.map +1 -1
  72. package/dist/components/chat/utils/index.d.ts +3 -2
  73. package/dist/components/chat/utils/index.d.ts.map +1 -1
  74. package/dist/components/chat/utils/message-segment-accumulator.d.ts +5 -0
  75. package/dist/components/chat/utils/message-segment-accumulator.d.ts.map +1 -1
  76. package/dist/components/chat/utils/process-historical-messages.d.ts +11 -59
  77. package/dist/components/chat/utils/process-historical-messages.d.ts.map +1 -1
  78. package/dist/components/contact/index.cjs +3 -3
  79. package/dist/components/contact/index.js +2 -2
  80. package/dist/components/docs/index.cjs +5 -5
  81. package/dist/components/docs/index.js +4 -4
  82. package/dist/components/embeds/index.cjs +3 -3
  83. package/dist/components/embeds/index.js +2 -2
  84. package/dist/components/faq/index.cjs +3 -3
  85. package/dist/components/faq/index.js +2 -2
  86. package/dist/components/features/index.cjs +2 -2
  87. package/dist/components/features/index.js +1 -1
  88. package/dist/components/help-center-pages/index.cjs +20 -20
  89. package/dist/components/help-center-pages/index.js +11 -11
  90. package/dist/components/index.cjs +130 -170
  91. package/dist/components/index.cjs.map +1 -1
  92. package/dist/components/index.js +23 -63
  93. package/dist/components/index.js.map +1 -1
  94. package/dist/components/navigation/index.cjs +2 -2
  95. package/dist/components/navigation/index.js +1 -1
  96. package/dist/components/onboarding-guides/index.cjs +5 -5
  97. package/dist/components/onboarding-guides/index.js +4 -4
  98. package/dist/components/onboarding-guides/onboarding-guide-detail-view.d.ts.map +1 -1
  99. package/dist/components/related-content/index.cjs +3 -3
  100. package/dist/components/related-content/index.js +2 -2
  101. package/dist/components/shared/product-release/release-detail-page.d.ts.map +1 -1
  102. package/dist/components/tickets/index.cjs +5 -5
  103. package/dist/components/tickets/index.js +4 -4
  104. package/dist/components/ui/index.cjs +2 -42
  105. package/dist/components/ui/index.cjs.map +1 -1
  106. package/dist/components/ui/index.d.ts +2 -1
  107. package/dist/components/ui/index.d.ts.map +1 -1
  108. package/dist/components/ui/index.js +15 -55
  109. package/dist/components/ui/rich-markdown-renderer.d.ts +34 -0
  110. package/dist/components/ui/rich-markdown-renderer.d.ts.map +1 -0
  111. package/dist/components/ui/simple-markdown-renderer.d.ts +73 -0
  112. package/dist/components/ui/simple-markdown-renderer.d.ts.map +1 -0
  113. package/dist/index.cjs +2 -58
  114. package/dist/index.cjs.map +1 -1
  115. package/dist/index.js +15 -71
  116. package/dist/utils/index.cjs +40 -169
  117. package/dist/utils/index.cjs.map +1 -1
  118. package/dist/utils/index.d.ts +0 -1
  119. package/dist/utils/index.d.ts.map +1 -1
  120. package/dist/utils/index.js +41 -162
  121. package/dist/utils/index.js.map +1 -1
  122. package/dist/utils/markdown-section-extractor.d.ts.map +1 -1
  123. package/package.json +1 -7
  124. package/src/components/chat/__tests__/chat-message-list.test.tsx +5 -293
  125. package/src/components/chat/chat-message-enhanced.tsx +80 -163
  126. package/src/components/chat/chat-message-list.tsx +109 -358
  127. package/src/components/chat/embeddable-chat.tsx +0 -6
  128. package/src/components/chat/guide-display.tsx +44 -0
  129. package/src/components/chat/hooks/.index.md +33 -30
  130. package/src/components/chat/hooks/.use-nats-chat-adapter.md +55 -36
  131. package/src/components/chat/hooks/.use-realtime-chunk-processor.md +77 -0
  132. package/src/components/chat/hooks/index.ts +1 -0
  133. package/src/components/chat/hooks/use-chat.ts +0 -8
  134. package/src/components/chat/hooks/use-nats-chat-adapter.ts +825 -243
  135. package/src/components/chat/hooks/use-realtime-chunk-processor.ts +489 -0
  136. package/src/components/chat/hooks/use-sse-chat-adapter.ts +684 -380
  137. package/src/components/chat/index.ts +1 -5
  138. package/src/components/chat/thinking-display.tsx +1 -1
  139. package/src/components/chat/types/.api.types.md +49 -39
  140. package/src/components/chat/types/.processing.types.md +58 -29
  141. package/src/components/chat/types/api.types.ts +73 -6
  142. package/src/components/chat/types/component.types.ts +9 -0
  143. package/src/components/chat/types/message.types.ts +17 -1
  144. package/src/components/chat/types/processing.types.ts +53 -11
  145. package/src/components/chat/utils/.chunk-parser.md +62 -0
  146. package/src/components/chat/utils/.index.md +40 -31
  147. package/src/components/chat/utils/chunk-parser.ts +262 -0
  148. package/src/components/chat/utils/extract-incomplete-message-state.ts +5 -63
  149. package/src/components/chat/utils/index.ts +8 -5
  150. package/src/components/chat/utils/message-segment-accumulator.ts +19 -0
  151. package/src/components/chat/utils/process-historical-messages.ts +390 -352
  152. package/src/components/onboarding-guides/onboarding-guide-detail-view.tsx +4 -22
  153. package/src/components/shared/legal-document/legal-document-page.tsx +1 -1
  154. package/src/components/shared/product-release/release-detail-page.tsx +4 -19
  155. package/src/components/ui/.simple-markdown-renderer.md +52 -0
  156. package/src/components/ui/index.ts +2 -1
  157. package/src/components/ui/release-changelog-section.tsx +1 -1
  158. package/src/components/ui/rich-markdown-renderer.tsx +1223 -0
  159. package/src/components/ui/simple-markdown-renderer.tsx +964 -0
  160. package/src/components/ui/ticket-info-section.tsx +1 -1
  161. package/src/stories/ChatMessageEnhanced.stories.tsx +48 -1
  162. package/src/utils/index.ts +0 -1
  163. package/src/utils/markdown-section-extractor.ts +56 -41
  164. package/dist/chat-protocol/decode.d.ts +0 -58
  165. package/dist/chat-protocol/decode.d.ts.map +0 -1
  166. package/dist/chat-protocol/encode.d.ts +0 -44
  167. package/dist/chat-protocol/encode.d.ts.map +0 -1
  168. package/dist/chat-protocol/env-flag.d.ts +0 -35
  169. package/dist/chat-protocol/env-flag.d.ts.map +0 -1
  170. package/dist/chat-protocol/events.d.ts +0 -196
  171. package/dist/chat-protocol/events.d.ts.map +0 -1
  172. package/dist/chat-protocol/frames.d.ts +0 -213
  173. package/dist/chat-protocol/frames.d.ts.map +0 -1
  174. package/dist/chat-protocol/index.cjs +0 -494
  175. package/dist/chat-protocol/index.cjs.map +0 -1
  176. package/dist/chat-protocol/index.d.ts +0 -18
  177. package/dist/chat-protocol/index.d.ts.map +0 -1
  178. package/dist/chat-protocol/index.js +0 -478
  179. package/dist/chat-protocol/index.js.map +0 -1
  180. package/dist/chat-protocol/ip-normalize.d.ts +0 -44
  181. package/dist/chat-protocol/ip-normalize.d.ts.map +0 -1
  182. package/dist/chat-protocol/nats-decoder.d.ts +0 -26
  183. package/dist/chat-protocol/nats-decoder.d.ts.map +0 -1
  184. package/dist/chunk-5LMGFI4J.cjs.map +0 -1
  185. package/dist/chunk-BE6DK7QY.js.map +0 -1
  186. package/dist/chunk-Q6E7VJ4W.cjs.map +0 -1
  187. package/dist/chunk-T2EGSZFN.js.map +0 -1
  188. package/dist/chunk-TKN5667N.js.map +0 -1
  189. package/dist/chunk-YHOIAA2E.cjs.map +0 -1
  190. package/dist/components/chat/stream/chat-dialog-store.d.ts +0 -184
  191. package/dist/components/chat/stream/chat-dialog-store.d.ts.map +0 -1
  192. package/dist/components/chat/stream/chat-stream-reducer.d.ts +0 -367
  193. package/dist/components/chat/stream/chat-stream-reducer.d.ts.map +0 -1
  194. package/dist/components/chat/stream/delta-batcher.d.ts +0 -51
  195. package/dist/components/chat/stream/delta-batcher.d.ts.map +0 -1
  196. package/dist/components/chat/stream/index.d.ts +0 -17
  197. package/dist/components/chat/stream/index.d.ts.map +0 -1
  198. package/dist/components/chat/stream/message-mutations.d.ts +0 -77
  199. package/dist/components/chat/stream/message-mutations.d.ts.map +0 -1
  200. package/dist/components/chat/stream/use-chat-stream-reducer.d.ts +0 -22
  201. package/dist/components/chat/stream/use-chat-stream-reducer.d.ts.map +0 -1
  202. package/dist/components/ui/markdown/base-components.d.ts +0 -51
  203. package/dist/components/ui/markdown/base-components.d.ts.map +0 -1
  204. package/dist/components/ui/markdown/engine.d.ts +0 -99
  205. package/dist/components/ui/markdown/engine.d.ts.map +0 -1
  206. package/dist/components/ui/markdown/heading-ids.d.ts +0 -65
  207. package/dist/components/ui/markdown/heading-ids.d.ts.map +0 -1
  208. package/dist/components/ui/markdown/index.d.ts +0 -18
  209. package/dist/components/ui/markdown/index.d.ts.map +0 -1
  210. package/dist/components/ui/markdown/mermaid-diagram.d.ts +0 -79
  211. package/dist/components/ui/markdown/mermaid-diagram.d.ts.map +0 -1
  212. package/dist/components/ui/markdown/rich/embed-overrides.d.ts +0 -14
  213. package/dist/components/ui/markdown/rich/embed-overrides.d.ts.map +0 -1
  214. package/dist/components/ui/markdown/rich/rich-markdown-renderer.d.ts +0 -40
  215. package/dist/components/ui/markdown/rich/rich-markdown-renderer.d.ts.map +0 -1
  216. package/dist/components/ui/markdown/rich/shortcodes.d.ts +0 -8
  217. package/dist/components/ui/markdown/rich/shortcodes.d.ts.map +0 -1
  218. package/dist/components/ui/markdown/sanitize.d.ts +0 -153
  219. package/dist/components/ui/markdown/sanitize.d.ts.map +0 -1
  220. package/dist/components/ui/markdown/simple-markdown-renderer.d.ts +0 -23
  221. package/dist/components/ui/markdown/simple-markdown-renderer.d.ts.map +0 -1
  222. package/dist/components/ui/markdown/streaming.d.ts +0 -78
  223. package/dist/components/ui/markdown/streaming.d.ts.map +0 -1
  224. package/dist/components/ui/markdown/text-size.d.ts +0 -34
  225. package/dist/components/ui/markdown/text-size.d.ts.map +0 -1
  226. package/dist/utils/markdown-fences.d.ts +0 -42
  227. package/dist/utils/markdown-fences.d.ts.map +0 -1
  228. package/dist/utils/markdown-heading-id.d.ts +0 -85
  229. package/dist/utils/markdown-heading-id.d.ts.map +0 -1
  230. package/src/chat-protocol/__tests__/__snapshots__/nats-decoder-golden.test.ts.snap +0 -307
  231. package/src/chat-protocol/__tests__/chat-protocol.test.ts +0 -516
  232. package/src/chat-protocol/__tests__/env-flag.test.ts +0 -52
  233. package/src/chat-protocol/__tests__/ip-normalize.test.ts +0 -136
  234. package/src/chat-protocol/__tests__/nats-decoder-golden.test.ts +0 -228
  235. package/src/chat-protocol/decode.ts +0 -278
  236. package/src/chat-protocol/encode.ts +0 -71
  237. package/src/chat-protocol/env-flag.ts +0 -39
  238. package/src/chat-protocol/events.ts +0 -232
  239. package/src/chat-protocol/frames.ts +0 -245
  240. package/src/chat-protocol/index.ts +0 -21
  241. package/src/chat-protocol/ip-normalize.ts +0 -146
  242. package/src/chat-protocol/nats-decoder.ts +0 -252
  243. package/src/components/chat/__tests__/chat-message-streaming-memo.test.tsx +0 -207
  244. package/src/components/chat/__tests__/chat-pending-turn.test.tsx +0 -209
  245. package/src/components/chat/hooks/__tests__/__snapshots__/conversation-id-persistence-golden.test.ts.snap +0 -37
  246. package/src/components/chat/hooks/__tests__/__snapshots__/sse-stream-golden.test.ts.snap +0 -0
  247. package/src/components/chat/hooks/__tests__/conversation-id-persistence-golden.test.ts +0 -438
  248. package/src/components/chat/hooks/__tests__/sse-stream-golden.test.ts +0 -318
  249. package/src/components/chat/stream/__tests__/__snapshots__/chat-stream-reducer-golden.test.ts.snap +0 -940
  250. package/src/components/chat/stream/__tests__/chat-dialog-store.test.ts +0 -756
  251. package/src/components/chat/stream/__tests__/chat-stream-reducer-golden.test.ts +0 -460
  252. package/src/components/chat/stream/__tests__/chat-stream-reducer.test.ts +0 -894
  253. package/src/components/chat/stream/__tests__/delta-batcher.test.ts +0 -255
  254. package/src/components/chat/stream/__tests__/use-chat-stream-reducer.test.ts +0 -98
  255. package/src/components/chat/stream/chat-dialog-store.ts +0 -536
  256. package/src/components/chat/stream/chat-stream-reducer.ts +0 -1796
  257. package/src/components/chat/stream/delta-batcher.ts +0 -159
  258. package/src/components/chat/stream/index.ts +0 -55
  259. package/src/components/chat/stream/message-mutations.ts +0 -338
  260. package/src/components/chat/stream/use-chat-stream-reducer.ts +0 -126
  261. package/src/components/chat/utils/__tests__/__snapshots__/process-historical-messages-golden.test.ts.snap +0 -420
  262. package/src/components/chat/utils/__tests__/__snapshots__/segment-accumulator-golden.test.ts.snap +0 -605
  263. package/src/components/chat/utils/__tests__/process-historical-messages-golden.test.ts +0 -317
  264. package/src/components/chat/utils/__tests__/segment-accumulator-golden.test.ts +0 -270
  265. package/src/components/ui/__tests__/__snapshots__/markdown-parity.test.tsx.snap +0 -2420
  266. package/src/components/ui/__tests__/markdown-parity.test.tsx +0 -2528
  267. package/src/components/ui/markdown/__tests__/mermaid-security.test.ts +0 -115
  268. package/src/components/ui/markdown/__tests__/mermaid-stale-render.test.tsx +0 -95
  269. package/src/components/ui/markdown/__tests__/sanitize-invariant.test.ts +0 -309
  270. package/src/components/ui/markdown/__tests__/streaming.test.tsx +0 -388
  271. package/src/components/ui/markdown/base-components.tsx +0 -360
  272. package/src/components/ui/markdown/engine.tsx +0 -315
  273. package/src/components/ui/markdown/heading-ids.ts +0 -239
  274. package/src/components/ui/markdown/index.ts +0 -49
  275. package/src/components/ui/markdown/mermaid-diagram.tsx +0 -291
  276. package/src/components/ui/markdown/rich/embed-overrides.tsx +0 -199
  277. package/src/components/ui/markdown/rich/rich-markdown-renderer.tsx +0 -184
  278. package/src/components/ui/markdown/rich/shortcodes.ts +0 -170
  279. package/src/components/ui/markdown/sanitize.ts +0 -3499
  280. package/src/components/ui/markdown/simple-markdown-renderer.tsx +0 -28
  281. package/src/components/ui/markdown/streaming.ts +0 -362
  282. package/src/components/ui/markdown/text-size.ts +0 -106
  283. package/src/utils/markdown-fences.ts +0 -127
  284. package/src/utils/markdown-heading-id.ts +0 -348
  285. /package/dist/{chunk-GTWO5NAY.js.map → chunk-6CFFISUC.js.map} +0 -0
  286. /package/dist/{chunk-VHG6AX5V.js.map → chunk-6CJ2K7BD.js.map} +0 -0
  287. /package/dist/{chunk-4NWARV5I.js.map → chunk-6QK3ONDD.js.map} +0 -0
  288. /package/dist/{chunk-CORIBKHG.js.map → chunk-EGXCWZYP.js.map} +0 -0
  289. /package/dist/{chunk-HSACATCD.js.map → chunk-MPYDX5WX.js.map} +0 -0
  290. /package/dist/{chunk-RQLBHKXN.js.map → chunk-NGEFYPY7.js.map} +0 -0
  291. /package/dist/{chunk-YZ2OIYQ3.js.map → chunk-VDZO6IDR.js.map} +0 -0
  292. /package/dist/{chunk-UUYRJPGJ.js.map → chunk-VL2YMDPH.js.map} +0 -0
  293. /package/dist/{chunk-XQ2B74SI.js.map → chunk-WLM3F3WU.js.map} +0 -0
@@ -1,146 +0,0 @@
1
- /**
2
- * `normalizeIpForBucketKey` — THE cross-repo normalizer for turning a raw
3
- * client-IP candidate (`x-forwarded-for` hop, socket remote address, proxy
4
- * header) into a stable rate-limit / dedup bucket key.
5
- *
6
- * WHY IT LIVES HERE: producer and consumer sit in DIFFERENT repos (the app
7
- * emits the candidate, the hub buckets it) and each had grown its own
8
- * normalizer. They disagreed — one unwrapped `::ffff:1.2.3.4` to the bare
9
- * IPv4 and stripped `%zone`, the other kept the zone and only lower-cased the
10
- * mapped form — so ONE visitor could land in TWO buckets depending on which
11
- * side normalized. Both repos already depend on this package, and
12
- * `./chat-protocol` is its server-safe subpath (no React, no browser APIs),
13
- * so this is the one place both sides can share.
14
- *
15
- * Contract:
16
- * - input longer than `IP_BUCKET_KEY_MAX_LENGTH` → `null` (an unbounded
17
- * header must never become an unbounded map key);
18
- * - `[…]` brackets and a trailing `]:port` are stripped, as is a bare
19
- * `:port` on an IPv4 (`203.0.113.4:8080`) — an unbracketed IPv6 with a
20
- * bare port stays ambiguous and is not stripped;
21
- * - a `%zone` suffix is stripped (link-local scope is per-host, not part of
22
- * the peer's identity);
23
- * - IPv4-mapped IPv6 (`::ffff:203.0.113.4`) collapses to the bare IPv4, so
24
- * the same peer buckets identically over either stack;
25
- * - IPv6 is lower-cased;
26
- * - IPv4 octets are range-checked and REJECTED when zero-padded (`.04`
27
- * would otherwise bucket separately from `.4`), and IPv6 is
28
- * charset/shape-checked (one `::` at most, ≤8 groups, an embedded IPv4
29
- * only in the last group, no dangling separator); anything else returns
30
- * `null` rather than a junk bucket key.
31
- *
32
- * It does NOT canonicalize IPv6 zero-compression (`2001:db8::1` vs
33
- * `2001:0db8:0:0:0:0:0:1`) — peers do not spell their own address two ways
34
- * within one deployment, and a full expander is more surface than the bucket
35
- * key warrants.
36
- */
37
-
38
- /** Longest accepted candidate. A full IPv6 + zone fits well inside this. */
39
- export const IP_BUCKET_KEY_MAX_LENGTH = 64
40
-
41
- function isIpv4(value: string): boolean {
42
- const parts = value.split('.')
43
- if (parts.length !== 4) return false
44
- for (const part of parts) {
45
- if (!/^\d{1,3}$/.test(part)) return false
46
- // Leading zeros are a SECOND spelling of the same address (`203.0.113.04`),
47
- // so accepting them verbatim splits one peer across two buckets. Reject
48
- // rather than canonicalize: no legitimate emitter zero-pads, and some
49
- // resolvers read a `0`-prefixed octet as octal, i.e. it is not even
50
- // reliably the same address.
51
- if (part.length > 1 && part.startsWith('0')) return false
52
- if (Number(part) > 255) return false
53
- }
54
- return true
55
- }
56
-
57
- function isIpv6(value: string): boolean {
58
- if (!value.includes(':')) return false
59
- // Charset gate first: hex digits, separators, and the dots of a trailing
60
- // embedded IPv4. Anything else (a hostname, a header injection) is out.
61
- if (!/^[0-9a-f:.]+$/.test(value)) return false
62
- if (value.includes(':::')) return false
63
- // A dangling separator (`2001:db8::1:`) leaves an EMPTY trailing group that
64
- // is not part of a `::` compression — malformed, not an address.
65
- if (value.endsWith(':') && !value.endsWith('::')) return false
66
- if (value.startsWith(':') && !value.startsWith('::')) return false
67
- const compressions = value.split('::').length - 1
68
- if (compressions > 1) return false
69
- const groups = value.split(':')
70
- if (groups.length > 8) return false
71
- for (let i = 0; i < groups.length; i += 1) {
72
- const group = groups[i]
73
- if (group === '') continue
74
- if (group.includes('.')) {
75
- // An embedded IPv4 occupies the LAST two groups; anywhere else
76
- // (`1.2.3.4::1`) it is malformed.
77
- if (i !== groups.length - 1) return false
78
- if (!isIpv4(group)) return false
79
- continue
80
- }
81
- if (!/^[0-9a-f]{1,4}$/.test(group)) return false
82
- }
83
- // Uncompressed form must be exactly 8 groups (a trailing embedded IPv4
84
- // occupies two, so 7 written groups is also complete).
85
- if (compressions === 0) {
86
- const embedsIpv4 = groups[groups.length - 1]?.includes('.') ?? false
87
- if (groups.length !== (embedsIpv4 ? 7 : 8)) return false
88
- }
89
- return true
90
- }
91
-
92
- /**
93
- * Normalize one IP candidate to its canonical bucket key, or `null` when the
94
- * value is not a usable address.
95
- */
96
- export function normalizeIpForBucketKey(value: string): string | null {
97
- if (typeof value !== 'string') return null
98
- let candidate = value.trim()
99
- if (candidate === '' || candidate.length > IP_BUCKET_KEY_MAX_LENGTH) return null
100
-
101
- // `[2001:db8::1]` / `[::1]:443` — the bracketed forms a proxy emits so the
102
- // port is unambiguous.
103
- if (candidate.startsWith('[')) {
104
- const close = candidate.indexOf(']')
105
- if (close === -1) return null
106
- const trailer = candidate.slice(close + 1)
107
- if (trailer !== '' && !/^:\d{1,5}$/.test(trailer)) return null
108
- candidate = candidate.slice(1, close)
109
- }
110
-
111
- // `%eth0` / `%25eth0` scope id — host-local, never part of peer identity.
112
- const zone = candidate.indexOf('%')
113
- if (zone !== -1) candidate = candidate.slice(0, zone)
114
- if (candidate === '') return null
115
-
116
- // UNBRACKETED `203.0.113.4:8080` — Azure App Service / Front Door and several
117
- // CDNs write the client hop that way. Rejecting it left the caller with NO
118
- // candidate at all, collapsing every visitor into the single egress bucket:
119
- // exactly the silent regression this module exists to prevent. Stripped ONLY
120
- // when the remainder is a valid IPv4; an unbracketed IPv6 with a bare port
121
- // (`::1:443`) is genuinely ambiguous — `:443` is equally a final group — so
122
- // that shape is left to the IPv6 path.
123
- const portSplit = candidate.lastIndexOf(':')
124
- if (portSplit > 0 && /^\d{1,5}$/.test(candidate.slice(portSplit + 1))) {
125
- const head = candidate.slice(0, portSplit)
126
- if (isIpv4(head)) candidate = head
127
- }
128
-
129
- if (isIpv4(candidate)) return candidate
130
-
131
- const lowered = candidate.toLowerCase()
132
-
133
- // IPv4-mapped IPv6 → the bare IPv4 (same peer, one bucket). EVERY spelling
134
- // is handled: the compressed `::ffff:1.2.3.4` that dual-stack listeners
135
- // actually emit, the written-out `0:0:0:0:0:ffff:1.2.3.4` that a hop doing
136
- // its own (non-compressing) formatting can produce, and the ZERO-PADDED
137
- // `0000:0000:0000:0000:0000:ffff:1.2.3.4` that a fixed-width formatter
138
- // produces — leading zeros in an IPv6 group are purely cosmetic, so all
139
- // three name the same peer. Matching only some of them would split one peer
140
- // across two buckets — the exact failure this module exists to prevent.
141
- const mapped = /^(?:(?:0{1,4}:){5}|::)ffff:(\d{1,3}(?:\.\d{1,3}){3})$/.exec(lowered)
142
- if (mapped) return isIpv4(mapped[1]) ? mapped[1] : null
143
-
144
- if (isIpv6(lowered)) return lowered
145
- return null
146
- }
@@ -1,252 +0,0 @@
1
- /**
2
- * NATS chunk → normalized `ChatStreamEvent` decoder.
3
- *
4
- * THE live NATS reading path — the ONLY chunk parser in the codebase. Every
5
- * consumer (this lib's `useNatsChatAdapter`, the hub, the product app) feeds
6
- * raw chunks through `decodeNatsChunk` into `createChatStreamReducer`. The
7
- * legacy `components/chat/utils/chunk-parser.ts` (`parseChunkToAction`) that
8
- * this module superseded was DELETED — do not reintroduce a second decoder.
9
- *
10
- * Chunks map onto the transport-agnostic event union, with the JetStream
11
- * `streamSeq` lifted into the `seq` envelope (the reducer's idempotency gate
12
- * keys off it).
13
- *
14
- * Behavior is pinned by `__tests__/nats-decoder-golden.test.ts`.
15
- *
16
- * Server-safe: no React, no browser APIs.
17
- */
18
-
19
- import { MESSAGE_TYPE } from '../components/chat/types/message.types'
20
- import type { ApprovalToolCall, ChatStreamEvent } from './events'
21
-
22
- /** Minimal structural view of a NATS chunk (see `ChunkData` in
23
- * `src/components/chat/types/network.types.ts`). */
24
- type NatsChunk = Record<string, any>
25
-
26
- /** Coerce the wire's `toolCalls[]` into the batch-approval shape, dropping
27
- * non-object entries and defaulting every field. */
28
- function normalizeToolCalls(raw: unknown): ApprovalToolCall[] {
29
- if (!Array.isArray(raw)) return []
30
- return raw
31
- .filter((item): item is Record<string, any> => !!item && typeof item === 'object')
32
- .map((item) => ({
33
- toolExecutionRequestId: String(item.toolExecutionRequestId ?? ''),
34
- toolName: String(item.toolName ?? ''),
35
- toolTitle: typeof item.toolTitle === 'string' ? item.toolTitle : undefined,
36
- toolExplanation:
37
- typeof item.toolExplanation === 'string' ? item.toolExplanation : undefined,
38
- toolType: typeof item.toolType === 'string' ? item.toolType : undefined,
39
- requiresApproval: item.requiresApproval === true,
40
- approvalType: typeof item.approvalType === 'string' ? item.approvalType : null,
41
- toolCallArguments:
42
- item.toolCallArguments && typeof item.toolCallArguments === 'object'
43
- ? (item.toolCallArguments as Record<string, any>)
44
- : null,
45
- }))
46
- }
47
-
48
- /**
49
- * Parse one raw NATS chunk into a normalized event. Returns `null` for
50
- * unknown/malformed chunks — an unrecognized `type`, a missing required
51
- * field, or a non-object payload are all tolerated as no-ops rather than
52
- * throwing, so a backend that adds a chunk type can't break the stream.
53
- */
54
- export function decodeNatsChunk(chunk: unknown): ChatStreamEvent | null {
55
- if (!chunk || typeof chunk !== 'object') return null
56
-
57
- const data = chunk as NatsChunk
58
- const type = String(data.type || '')
59
- // JetStream stream sequence → the generic `seq` envelope.
60
- const seq: { seq?: number } =
61
- typeof data.streamSeq === 'number' ? { seq: data.streamSeq } : {}
62
-
63
- switch (type) {
64
- case MESSAGE_TYPE.MESSAGE_START:
65
- return { type: 'turn-start', ...seq }
66
-
67
- case MESSAGE_TYPE.MESSAGE_END:
68
- return { type: 'turn-end', ...seq }
69
-
70
- case MESSAGE_TYPE.AI_METADATA: {
71
- const providerName = data.providerName || data.provider
72
- if (typeof data.modelName === 'string' && typeof providerName === 'string') {
73
- return {
74
- type: 'metadata',
75
- modelLabel: data.modelDisplayName,
76
- modelName: data.modelName,
77
- provider: providerName,
78
- contextWindowMaxTokens:
79
- typeof data.contextWindow === 'number' ? data.contextWindow : 0,
80
- ...seq,
81
- }
82
- }
83
- return null
84
- }
85
-
86
- case MESSAGE_TYPE.TEXT:
87
- if (typeof data.text === 'string') {
88
- return { type: 'text-delta', text: data.text, ...seq }
89
- }
90
- return null
91
-
92
- case MESSAGE_TYPE.THINKING:
93
- if (typeof data.text === 'string') {
94
- return { type: 'thinking-delta', text: data.text, ...seq }
95
- }
96
- return null
97
-
98
- case MESSAGE_TYPE.EXECUTING_TOOL:
99
- return {
100
- type: 'tool-execution',
101
- data: {
102
- type: 'EXECUTING_TOOL',
103
- integratedToolType: data.integratedToolType || '',
104
- toolFunction: data.toolFunction || '',
105
- toolTitle: typeof data.title === 'string' ? data.title : undefined,
106
- parameters: data.parameters,
107
- toolExecutionRequestId:
108
- typeof data.toolExecutionRequestId === 'string'
109
- ? data.toolExecutionRequestId
110
- : undefined,
111
- },
112
- ...seq,
113
- }
114
-
115
- case MESSAGE_TYPE.EXECUTED_TOOL:
116
- return {
117
- type: 'tool-execution',
118
- data: {
119
- type: 'EXECUTED_TOOL',
120
- integratedToolType: data.integratedToolType || '',
121
- toolFunction: data.toolFunction || '',
122
- toolTitle: typeof data.title === 'string' ? data.title : undefined,
123
- parameters: data.parameters,
124
- result: data.result,
125
- success: data.success,
126
- toolExecutionRequestId:
127
- typeof data.toolExecutionRequestId === 'string'
128
- ? data.toolExecutionRequestId
129
- : undefined,
130
- },
131
- ...seq,
132
- }
133
-
134
- case MESSAGE_TYPE.APPROVAL_REQUEST: {
135
- const requestId = data.approvalRequestId || data.approval_request_id || ''
136
- const approvalType = data.approvalType || 'USER'
137
- const toolCalls = normalizeToolCalls(data.toolCalls)
138
-
139
- if (toolCalls.length > 0) {
140
- return { type: 'approval-request', requestId, approvalType, toolCalls, ...seq }
141
- }
142
-
143
- return {
144
- type: 'approval-request',
145
- requestId,
146
- approvalType,
147
- command: data.command || '',
148
- explanation: data.explanation,
149
- ...seq,
150
- }
151
- }
152
-
153
- case MESSAGE_TYPE.APPROVAL_RESULT: {
154
- // Realtime chunks carry the resolver's name as `displayName`; the
155
- // persisted GraphQL message exposes the same value as
156
- // `resolvedByName`. Accept either so realtime and history-replay
157
- // render "by {name}" identically.
158
- const resolvedByName =
159
- typeof data.resolvedByName === 'string'
160
- ? data.resolvedByName
161
- : typeof data.displayName === 'string'
162
- ? data.displayName
163
- : undefined
164
- return {
165
- type: 'approval-resolved',
166
- requestId: data.approvalRequestId || data.approval_request_id || '',
167
- status: data.approved === true ? 'approved' : 'rejected',
168
- approvalType: data.approvalType || 'CLIENT',
169
- resolvedByName,
170
- ...seq,
171
- }
172
- }
173
-
174
- case MESSAGE_TYPE.ERROR:
175
- return {
176
- type: 'error',
177
- title: data.error || 'An error occurred',
178
- details: data.details,
179
- ...seq,
180
- }
181
-
182
- case MESSAGE_TYPE.MESSAGE_REQUEST:
183
- return {
184
- type: 'participant',
185
- kind: 'message-request',
186
- text: String(data.text || ''),
187
- ownerType: typeof data.ownerType === 'string' ? data.ownerType : undefined,
188
- displayName: typeof data.displayName === 'string' ? data.displayName : undefined,
189
- userId: typeof data.userId === 'string' ? data.userId : undefined,
190
- // Wire shape carries no label; fall back to the id (parity with
191
- // the history path in `process-historical-messages.ts`).
192
- contextItems: Array.isArray(data.contextItems)
193
- ? (data.contextItems as Array<{ type?: unknown; id?: unknown }>)
194
- .filter((it) => typeof it?.type === 'string' && typeof it?.id === 'string')
195
- .map((it) => ({
196
- type: it.type as string,
197
- id: it.id as string,
198
- label: it.id as string,
199
- }))
200
- : undefined,
201
- ...seq,
202
- }
203
-
204
- case MESSAGE_TYPE.TOKEN_USAGE:
205
- return {
206
- type: 'token-usage',
207
- inputTokensSize: data.inputTokensSize ?? 0,
208
- outputTokensSize: data.outputTokensSize ?? 0,
209
- totalTokensSize: data.totalTokensSize ?? 0,
210
- contextSize: data.contextSize ?? 0,
211
- ...seq,
212
- }
213
-
214
- case MESSAGE_TYPE.CONTEXT_COMPACTION_START:
215
- return { type: 'compaction', phase: 'start', ...seq }
216
-
217
- case MESSAGE_TYPE.CONTEXT_COMPACTION_END:
218
- return {
219
- type: 'compaction',
220
- phase: 'end',
221
- summary: typeof data.text === 'string' ? data.text : undefined,
222
- ...seq,
223
- }
224
-
225
- case MESSAGE_TYPE.SYSTEM:
226
- if (typeof data.text === 'string') {
227
- return { type: 'participant', kind: 'system', text: data.text, ...seq }
228
- }
229
- return null
230
-
231
- case MESSAGE_TYPE.DIRECT_MESSAGE:
232
- if (typeof data.text === 'string') {
233
- return {
234
- type: 'participant',
235
- kind: 'direct-message',
236
- text: data.text,
237
- ownerType: typeof data.ownerType === 'string' ? data.ownerType : undefined,
238
- displayName:
239
- typeof data.displayName === 'string' ? data.displayName : undefined,
240
- userId: typeof data.userId === 'string' ? data.userId : undefined,
241
- ...seq,
242
- }
243
- }
244
- return null
245
-
246
- case MESSAGE_TYPE.DIALOG_CLOSED:
247
- return { type: 'dialog-closed', ...seq }
248
-
249
- default:
250
- return null
251
- }
252
- }
@@ -1,207 +0,0 @@
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
- })