@flamingo-stack/openframe-frontend-core 0.0.458-snapshot.20260720215024 → 0.0.458

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 (281) hide show
  1. package/dist/{chunk-BLZZXZNM.cjs → chunk-5USSSRAL.cjs} +26 -26
  2. package/dist/{chunk-BLZZXZNM.cjs.map → chunk-5USSSRAL.cjs.map} +1 -1
  3. package/dist/{chunk-CGNDAJ7V.js → chunk-6B27O3ZB.js} +4 -4
  4. package/dist/{chunk-P5JQDFRM.cjs → chunk-7IRPMNRL.cjs} +60 -60
  5. package/dist/chunk-7IRPMNRL.cjs.map +1 -0
  6. package/dist/{chunk-4XDWNAZW.cjs → chunk-DQ3EHM6G.cjs} +9 -9
  7. package/dist/{chunk-4XDWNAZW.cjs.map → chunk-DQ3EHM6G.cjs.map} +1 -1
  8. package/dist/{chunk-7KSEINAE.cjs → chunk-FTXDIDNX.cjs} +15 -15
  9. package/dist/{chunk-7KSEINAE.cjs.map → chunk-FTXDIDNX.cjs.map} +1 -1
  10. package/dist/{chunk-4R6H2U77.js → chunk-H7ETLG65.js} +4 -4
  11. package/dist/{chunk-LAOO7XHO.js → chunk-HWTUVLYB.js} +2 -2
  12. package/dist/{chunk-F2NMWV44.js → chunk-IDKDOQZL.js} +2 -2
  13. package/dist/{chunk-BQ7PMWLM.cjs → chunk-KIIOBK7G.cjs} +27 -28
  14. package/dist/chunk-KIIOBK7G.cjs.map +1 -0
  15. package/dist/{chunk-QO2XD3FR.js → chunk-KQGFKG6Y.js} +2 -2
  16. package/dist/{chunk-3N3TMZ62.cjs → chunk-L676XVP4.cjs} +7 -7
  17. package/dist/{chunk-3N3TMZ62.cjs.map → chunk-L676XVP4.cjs.map} +1 -1
  18. package/dist/{chunk-F7F43U7Q.cjs → chunk-LDAOPWXZ.cjs} +11 -11
  19. package/dist/{chunk-F7F43U7Q.cjs.map → chunk-LDAOPWXZ.cjs.map} +1 -1
  20. package/dist/{chunk-ADD3FPDG.cjs → chunk-MKLP2SOZ.cjs} +4886 -7004
  21. package/dist/chunk-MKLP2SOZ.cjs.map +1 -0
  22. package/dist/{chunk-VXJH5TDN.js → chunk-QL7ODLJA.js} +10 -10
  23. package/dist/chunk-QL7ODLJA.js.map +1 -0
  24. package/dist/{chunk-OR5AMXYN.js → chunk-R5QYMUKN.js} +2 -2
  25. package/dist/{chunk-REWRUV3K.js → chunk-TNLFAJPA.js} +9673 -11791
  26. package/dist/chunk-TNLFAJPA.js.map +1 -0
  27. package/dist/{chunk-XF5ROTMQ.js → chunk-U2YDR5DT.js} +2 -2
  28. package/dist/{chunk-PXZ7YQ5V.cjs → chunk-VTQSB6QB.cjs} +5 -5
  29. package/dist/{chunk-PXZ7YQ5V.cjs.map → chunk-VTQSB6QB.cjs.map} +1 -1
  30. package/dist/{chunk-UI7CIUAR.js → chunk-WGJPTFRH.js} +2 -2
  31. package/dist/{chunk-WCE66C2Z.cjs → chunk-XUNJILJT.cjs} +62 -62
  32. package/dist/{chunk-WCE66C2Z.cjs.map → chunk-XUNJILJT.cjs.map} +1 -1
  33. package/dist/{chunk-APF5QBAU.js → chunk-YQBPIUZU.js} +2 -2
  34. package/dist/{chunk-TA7MFVUS.cjs → chunk-YV2YA3QH.cjs} +31 -31
  35. package/dist/{chunk-TA7MFVUS.cjs.map → chunk-YV2YA3QH.cjs.map} +1 -1
  36. package/dist/{chunk-QSGZE6AZ.cjs → chunk-ZDJ4ZUQ4.cjs} +37 -37
  37. package/dist/{chunk-QSGZE6AZ.cjs.map → chunk-ZDJ4ZUQ4.cjs.map} +1 -1
  38. package/dist/{chunk-LJKZIIK4.js → chunk-ZHQQRYMB.js} +5 -6
  39. package/dist/chunk-ZHQQRYMB.js.map +1 -0
  40. package/dist/components/case-studies/index.cjs +8 -8
  41. package/dist/components/case-studies/index.js +2 -2
  42. package/dist/components/chart.d.ts +1 -1
  43. package/dist/components/chat/chat-message-enhanced.d.ts.map +1 -1
  44. package/dist/components/chat/chat-message-list.d.ts.map +1 -1
  45. package/dist/components/chat/hooks/index.d.ts +1 -0
  46. package/dist/components/chat/hooks/index.d.ts.map +1 -1
  47. package/dist/components/chat/hooks/use-chat.d.ts.map +1 -1
  48. package/dist/components/chat/hooks/use-nats-chat-adapter.d.ts +48 -15
  49. package/dist/components/chat/hooks/use-nats-chat-adapter.d.ts.map +1 -1
  50. package/dist/components/chat/hooks/use-realtime-chunk-processor.d.ts +6 -0
  51. package/dist/components/chat/hooks/use-realtime-chunk-processor.d.ts.map +1 -0
  52. package/dist/components/chat/hooks/use-sse-chat-adapter.d.ts +47 -3
  53. package/dist/components/chat/hooks/use-sse-chat-adapter.d.ts.map +1 -1
  54. package/dist/components/chat/index.cjs +2 -10
  55. package/dist/components/chat/index.cjs.map +1 -1
  56. package/dist/components/chat/index.d.ts +0 -1
  57. package/dist/components/chat/index.d.ts.map +1 -1
  58. package/dist/components/chat/index.js +13 -21
  59. package/dist/components/chat/types/api.types.d.ts +81 -1
  60. package/dist/components/chat/types/api.types.d.ts.map +1 -1
  61. package/dist/components/chat/types/processing.types.d.ts +90 -2
  62. package/dist/components/chat/types/processing.types.d.ts.map +1 -1
  63. package/dist/components/chat/utils/chunk-parser.d.ts +25 -0
  64. package/dist/components/chat/utils/chunk-parser.d.ts.map +1 -0
  65. package/dist/components/chat/utils/extract-incomplete-message-state.d.ts +6 -34
  66. package/dist/components/chat/utils/extract-incomplete-message-state.d.ts.map +1 -1
  67. package/dist/components/chat/utils/index.d.ts +3 -2
  68. package/dist/components/chat/utils/index.d.ts.map +1 -1
  69. package/dist/components/chat/utils/process-historical-messages.d.ts +11 -59
  70. package/dist/components/chat/utils/process-historical-messages.d.ts.map +1 -1
  71. package/dist/components/contact/index.cjs +3 -3
  72. package/dist/components/contact/index.js +2 -2
  73. package/dist/components/docs/index.cjs +5 -5
  74. package/dist/components/docs/index.js +4 -4
  75. package/dist/components/embeds/index.cjs +3 -3
  76. package/dist/components/embeds/index.js +2 -2
  77. package/dist/components/faq/index.cjs +3 -3
  78. package/dist/components/faq/index.js +2 -2
  79. package/dist/components/features/index.cjs +2 -2
  80. package/dist/components/features/index.js +1 -1
  81. package/dist/components/help-center-pages/index.cjs +20 -20
  82. package/dist/components/help-center-pages/index.js +11 -11
  83. package/dist/components/index.cjs +129 -171
  84. package/dist/components/index.cjs.map +1 -1
  85. package/dist/components/index.js +21 -63
  86. package/dist/components/index.js.map +1 -1
  87. package/dist/components/navigation/index.cjs +2 -2
  88. package/dist/components/navigation/index.js +1 -1
  89. package/dist/components/onboarding-guides/index.cjs +5 -5
  90. package/dist/components/onboarding-guides/index.js +4 -4
  91. package/dist/components/onboarding-guides/onboarding-guide-detail-view.d.ts.map +1 -1
  92. package/dist/components/related-content/index.cjs +3 -3
  93. package/dist/components/related-content/index.js +2 -2
  94. package/dist/components/shared/product-release/release-detail-page.d.ts.map +1 -1
  95. package/dist/components/tickets/index.cjs +5 -5
  96. package/dist/components/tickets/index.js +4 -4
  97. package/dist/components/ui/index.cjs +2 -44
  98. package/dist/components/ui/index.cjs.map +1 -1
  99. package/dist/components/ui/index.d.ts +2 -1
  100. package/dist/components/ui/index.d.ts.map +1 -1
  101. package/dist/components/ui/index.js +13 -55
  102. package/dist/components/ui/rich-markdown-renderer.d.ts +34 -0
  103. package/dist/components/ui/rich-markdown-renderer.d.ts.map +1 -0
  104. package/dist/components/ui/simple-markdown-renderer.d.ts +73 -0
  105. package/dist/components/ui/simple-markdown-renderer.d.ts.map +1 -0
  106. package/dist/index.cjs +2 -60
  107. package/dist/index.cjs.map +1 -1
  108. package/dist/index.js +13 -71
  109. package/dist/utils/index.cjs +40 -169
  110. package/dist/utils/index.cjs.map +1 -1
  111. package/dist/utils/index.d.ts +0 -1
  112. package/dist/utils/index.d.ts.map +1 -1
  113. package/dist/utils/index.js +41 -162
  114. package/dist/utils/index.js.map +1 -1
  115. package/dist/utils/markdown-section-extractor.d.ts.map +1 -1
  116. package/package.json +1 -7
  117. package/src/components/chat/__tests__/chat-message-list.test.tsx +5 -293
  118. package/src/components/chat/chat-message-enhanced.tsx +17 -111
  119. package/src/components/chat/chat-message-list.tsx +113 -367
  120. package/src/components/chat/embeddable-chat.tsx +0 -6
  121. package/src/components/chat/hooks/.index.md +1 -1
  122. package/src/components/chat/hooks/.use-nats-chat-adapter.md +1 -1
  123. package/src/components/chat/hooks/.use-realtime-chunk-processor.md +50 -0
  124. package/src/components/chat/hooks/index.ts +1 -0
  125. package/src/components/chat/hooks/use-chat.ts +0 -8
  126. package/src/components/chat/hooks/use-nats-chat-adapter.ts +822 -243
  127. package/src/components/chat/hooks/use-realtime-chunk-processor.ts +479 -0
  128. package/src/components/chat/hooks/use-sse-chat-adapter.ts +666 -318
  129. package/src/components/chat/index.ts +0 -5
  130. package/src/components/chat/thinking-display.tsx +1 -1
  131. package/src/components/chat/types/.api.types.md +3 -1
  132. package/src/components/chat/types/.processing.types.md +23 -6
  133. package/src/components/chat/types/api.types.ts +73 -6
  134. package/src/components/chat/types/processing.types.ts +52 -11
  135. package/src/components/chat/utils/.chunk-parser.md +51 -0
  136. package/src/components/chat/utils/.index.md +5 -0
  137. package/src/components/chat/utils/chunk-parser.ts +255 -0
  138. package/src/components/chat/utils/extract-incomplete-message-state.ts +5 -63
  139. package/src/components/chat/utils/index.ts +8 -5
  140. package/src/components/chat/utils/process-historical-messages.ts +383 -352
  141. package/src/components/onboarding-guides/onboarding-guide-detail-view.tsx +4 -22
  142. package/src/components/shared/legal-document/legal-document-page.tsx +1 -1
  143. package/src/components/shared/product-release/release-detail-page.tsx +4 -19
  144. package/src/components/ui/.simple-markdown-renderer.md +40 -0
  145. package/src/components/ui/index.ts +2 -1
  146. package/src/components/ui/release-changelog-section.tsx +1 -1
  147. package/src/components/ui/rich-markdown-renderer.tsx +1223 -0
  148. package/src/components/ui/simple-markdown-renderer.tsx +965 -0
  149. package/src/components/ui/ticket-info-section.tsx +1 -1
  150. package/src/utils/index.ts +0 -1
  151. package/src/utils/markdown-section-extractor.ts +56 -41
  152. package/dist/chat-protocol/decode.d.ts +0 -58
  153. package/dist/chat-protocol/decode.d.ts.map +0 -1
  154. package/dist/chat-protocol/encode.d.ts +0 -44
  155. package/dist/chat-protocol/encode.d.ts.map +0 -1
  156. package/dist/chat-protocol/env-flag.d.ts +0 -35
  157. package/dist/chat-protocol/env-flag.d.ts.map +0 -1
  158. package/dist/chat-protocol/events.d.ts +0 -192
  159. package/dist/chat-protocol/events.d.ts.map +0 -1
  160. package/dist/chat-protocol/frames.d.ts +0 -206
  161. package/dist/chat-protocol/frames.d.ts.map +0 -1
  162. package/dist/chat-protocol/index.cjs +0 -493
  163. package/dist/chat-protocol/index.cjs.map +0 -1
  164. package/dist/chat-protocol/index.d.ts +0 -18
  165. package/dist/chat-protocol/index.d.ts.map +0 -1
  166. package/dist/chat-protocol/index.js +0 -477
  167. package/dist/chat-protocol/index.js.map +0 -1
  168. package/dist/chat-protocol/ip-normalize.d.ts +0 -44
  169. package/dist/chat-protocol/ip-normalize.d.ts.map +0 -1
  170. package/dist/chat-protocol/nats-decoder.d.ts +0 -26
  171. package/dist/chat-protocol/nats-decoder.d.ts.map +0 -1
  172. package/dist/chunk-ADD3FPDG.cjs.map +0 -1
  173. package/dist/chunk-BQ7PMWLM.cjs.map +0 -1
  174. package/dist/chunk-LJKZIIK4.js.map +0 -1
  175. package/dist/chunk-P5JQDFRM.cjs.map +0 -1
  176. package/dist/chunk-REWRUV3K.js.map +0 -1
  177. package/dist/chunk-VXJH5TDN.js.map +0 -1
  178. package/dist/components/chat/stream/chat-dialog-store.d.ts +0 -184
  179. package/dist/components/chat/stream/chat-dialog-store.d.ts.map +0 -1
  180. package/dist/components/chat/stream/chat-stream-reducer.d.ts +0 -367
  181. package/dist/components/chat/stream/chat-stream-reducer.d.ts.map +0 -1
  182. package/dist/components/chat/stream/delta-batcher.d.ts +0 -51
  183. package/dist/components/chat/stream/delta-batcher.d.ts.map +0 -1
  184. package/dist/components/chat/stream/index.d.ts +0 -17
  185. package/dist/components/chat/stream/index.d.ts.map +0 -1
  186. package/dist/components/chat/stream/message-mutations.d.ts +0 -77
  187. package/dist/components/chat/stream/message-mutations.d.ts.map +0 -1
  188. package/dist/components/chat/stream/use-chat-stream-reducer.d.ts +0 -22
  189. package/dist/components/chat/stream/use-chat-stream-reducer.d.ts.map +0 -1
  190. package/dist/components/ui/markdown/base-components.d.ts +0 -56
  191. package/dist/components/ui/markdown/base-components.d.ts.map +0 -1
  192. package/dist/components/ui/markdown/engine.d.ts +0 -99
  193. package/dist/components/ui/markdown/engine.d.ts.map +0 -1
  194. package/dist/components/ui/markdown/heading-ids.d.ts +0 -65
  195. package/dist/components/ui/markdown/heading-ids.d.ts.map +0 -1
  196. package/dist/components/ui/markdown/index.d.ts +0 -18
  197. package/dist/components/ui/markdown/index.d.ts.map +0 -1
  198. package/dist/components/ui/markdown/mermaid-diagram.d.ts +0 -79
  199. package/dist/components/ui/markdown/mermaid-diagram.d.ts.map +0 -1
  200. package/dist/components/ui/markdown/rich/embed-overrides.d.ts +0 -14
  201. package/dist/components/ui/markdown/rich/embed-overrides.d.ts.map +0 -1
  202. package/dist/components/ui/markdown/rich/rich-markdown-renderer.d.ts +0 -40
  203. package/dist/components/ui/markdown/rich/rich-markdown-renderer.d.ts.map +0 -1
  204. package/dist/components/ui/markdown/rich/shortcodes.d.ts +0 -8
  205. package/dist/components/ui/markdown/rich/shortcodes.d.ts.map +0 -1
  206. package/dist/components/ui/markdown/sanitize.d.ts +0 -153
  207. package/dist/components/ui/markdown/sanitize.d.ts.map +0 -1
  208. package/dist/components/ui/markdown/simple-markdown-renderer.d.ts +0 -23
  209. package/dist/components/ui/markdown/simple-markdown-renderer.d.ts.map +0 -1
  210. package/dist/components/ui/markdown/streaming.d.ts +0 -78
  211. package/dist/components/ui/markdown/streaming.d.ts.map +0 -1
  212. package/dist/components/ui/markdown/text-size.d.ts +0 -34
  213. package/dist/components/ui/markdown/text-size.d.ts.map +0 -1
  214. package/dist/utils/markdown-fences.d.ts +0 -42
  215. package/dist/utils/markdown-fences.d.ts.map +0 -1
  216. package/dist/utils/markdown-heading-id.d.ts +0 -85
  217. package/dist/utils/markdown-heading-id.d.ts.map +0 -1
  218. package/src/chat-protocol/__tests__/__snapshots__/nats-decoder-golden.test.ts.snap +0 -307
  219. package/src/chat-protocol/__tests__/chat-protocol.test.ts +0 -515
  220. package/src/chat-protocol/__tests__/env-flag.test.ts +0 -52
  221. package/src/chat-protocol/__tests__/ip-normalize.test.ts +0 -136
  222. package/src/chat-protocol/__tests__/nats-decoder-golden.test.ts +0 -228
  223. package/src/chat-protocol/decode.ts +0 -277
  224. package/src/chat-protocol/encode.ts +0 -71
  225. package/src/chat-protocol/env-flag.ts +0 -39
  226. package/src/chat-protocol/events.ts +0 -228
  227. package/src/chat-protocol/frames.ts +0 -238
  228. package/src/chat-protocol/index.ts +0 -21
  229. package/src/chat-protocol/ip-normalize.ts +0 -146
  230. package/src/chat-protocol/nats-decoder.ts +0 -252
  231. package/src/components/chat/__tests__/chat-message-streaming-memo.test.tsx +0 -207
  232. package/src/components/chat/__tests__/chat-pending-turn.test.tsx +0 -209
  233. package/src/components/chat/hooks/__tests__/__snapshots__/persisted-state-v1-golden.test.ts.snap +0 -297
  234. package/src/components/chat/hooks/__tests__/__snapshots__/sse-stream-golden.test.ts.snap +0 -0
  235. package/src/components/chat/hooks/__tests__/persisted-state-v1-golden.test.ts +0 -272
  236. package/src/components/chat/hooks/__tests__/sse-stream-golden.test.ts +0 -318
  237. package/src/components/chat/stream/__tests__/__snapshots__/chat-stream-reducer-golden.test.ts.snap +0 -937
  238. package/src/components/chat/stream/__tests__/chat-dialog-store.test.ts +0 -756
  239. package/src/components/chat/stream/__tests__/chat-stream-reducer-golden.test.ts +0 -460
  240. package/src/components/chat/stream/__tests__/chat-stream-reducer.test.ts +0 -894
  241. package/src/components/chat/stream/__tests__/delta-batcher.test.ts +0 -255
  242. package/src/components/chat/stream/__tests__/use-chat-stream-reducer.test.ts +0 -98
  243. package/src/components/chat/stream/chat-dialog-store.ts +0 -536
  244. package/src/components/chat/stream/chat-stream-reducer.ts +0 -1796
  245. package/src/components/chat/stream/delta-batcher.ts +0 -159
  246. package/src/components/chat/stream/index.ts +0 -55
  247. package/src/components/chat/stream/message-mutations.ts +0 -338
  248. package/src/components/chat/stream/use-chat-stream-reducer.ts +0 -126
  249. package/src/components/chat/utils/__tests__/__snapshots__/process-historical-messages-golden.test.ts.snap +0 -418
  250. package/src/components/chat/utils/__tests__/__snapshots__/segment-accumulator-golden.test.ts.snap +0 -599
  251. package/src/components/chat/utils/__tests__/process-historical-messages-golden.test.ts +0 -317
  252. package/src/components/chat/utils/__tests__/segment-accumulator-golden.test.ts +0 -270
  253. package/src/components/ui/__tests__/__snapshots__/markdown-parity.test.tsx.snap +0 -2420
  254. package/src/components/ui/__tests__/markdown-parity.test.tsx +0 -2528
  255. package/src/components/ui/markdown/__tests__/mermaid-security.test.ts +0 -115
  256. package/src/components/ui/markdown/__tests__/mermaid-stale-render.test.tsx +0 -95
  257. package/src/components/ui/markdown/__tests__/sanitize-invariant.test.ts +0 -309
  258. package/src/components/ui/markdown/__tests__/streaming.test.tsx +0 -388
  259. package/src/components/ui/markdown/base-components.tsx +0 -341
  260. package/src/components/ui/markdown/engine.tsx +0 -315
  261. package/src/components/ui/markdown/heading-ids.ts +0 -239
  262. package/src/components/ui/markdown/index.ts +0 -49
  263. package/src/components/ui/markdown/mermaid-diagram.tsx +0 -291
  264. package/src/components/ui/markdown/rich/embed-overrides.tsx +0 -199
  265. package/src/components/ui/markdown/rich/rich-markdown-renderer.tsx +0 -184
  266. package/src/components/ui/markdown/rich/shortcodes.ts +0 -170
  267. package/src/components/ui/markdown/sanitize.ts +0 -3499
  268. package/src/components/ui/markdown/simple-markdown-renderer.tsx +0 -28
  269. package/src/components/ui/markdown/streaming.ts +0 -362
  270. package/src/components/ui/markdown/text-size.ts +0 -106
  271. package/src/utils/markdown-fences.ts +0 -127
  272. package/src/utils/markdown-heading-id.ts +0 -348
  273. /package/dist/{chunk-CGNDAJ7V.js.map → chunk-6B27O3ZB.js.map} +0 -0
  274. /package/dist/{chunk-4R6H2U77.js.map → chunk-H7ETLG65.js.map} +0 -0
  275. /package/dist/{chunk-LAOO7XHO.js.map → chunk-HWTUVLYB.js.map} +0 -0
  276. /package/dist/{chunk-F2NMWV44.js.map → chunk-IDKDOQZL.js.map} +0 -0
  277. /package/dist/{chunk-QO2XD3FR.js.map → chunk-KQGFKG6Y.js.map} +0 -0
  278. /package/dist/{chunk-OR5AMXYN.js.map → chunk-R5QYMUKN.js.map} +0 -0
  279. /package/dist/{chunk-XF5ROTMQ.js.map → chunk-U2YDR5DT.js.map} +0 -0
  280. /package/dist/{chunk-UI7CIUAR.js.map → chunk-WGJPTFRH.js.map} +0 -0
  281. /package/dist/{chunk-APF5QBAU.js.map → chunk-YQBPIUZU.js.map} +0 -0
@@ -6,18 +6,10 @@
6
6
  * counterpart is `useNatsChatAdapter` (Mingo mode). The public
7
7
  * `useChat({ mode })` dispatches between them based on `mode.transport`.
8
8
  *
9
- * Phase 3 of the chat unification: the adapter no longer owns a parser or a
10
- * message-merge layer. Raw response bytes flow
11
- *
12
- * fetch body → createSseFrameDecoder → ChatStreamEvent →
13
- * createChatStreamReducer (transport: 'sse') — THE master reader
14
- *
15
- * The reducer absorbed `useChat`'s trailing-assistant merge, the
16
- * `decision_resolved` receipt path, and the sendIdx-keyed
17
- * sources/refs/meta maps; this file keeps only the transport wiring
18
- * (fetch/abort, request-body building), localStorage persistence
19
- * (PersistedChatState v1 — the sendIdx fan-out lookup below is the
20
- * rehydration contract), and the public `UnifiedChatState` mapping.
9
+ * Renamed from `useEmbeddedChat` during the unified-chat refactor. The
10
+ * legacy name is re-exported as an alias from `./use-embedded-chat` for
11
+ * backward compatibility — internal lib code should import this canonical
12
+ * name directly.
21
13
  *
22
14
  * Two key contracts vs hub-side chat hooks:
23
15
  *
@@ -27,44 +19,46 @@
27
19
  * namespace. It is NEVER sent on the wire (the hub resolves source
28
20
  * server-side via `currentPlatform()`).
29
21
  *
30
- * 2. `tableIdForDocumentType` is INJECTED via the optional
22
+ * 2. Navigation is runtime-provided. The hub-side `useDocSearch` keeps
23
+ * its own `decideNewTab` + `useDocNavigation.navigate` plumbing for
24
+ * the search autocomplete (it calls `router.push`). The chat body
25
+ * doesn't navigate via this hook — click handlers on rendered
26
+ * cards/chips go through `handleChatNavClick` instead.
27
+ *
28
+ * 3. `tableIdForDocumentType` is INJECTED via the optional
31
29
  * `tableIdForDocumentType` parameter. Hub callers pass the
32
30
  * registry-backed lookup from `lib/config/rag-table-config`;
33
31
  * embedders that don't supply one fall back to
34
- * `defaultTableIdForDocumentType` so the `displayRef` / `discussRef`
35
- * Ask + Display buttons WORK out of the box.
32
+ * `defaultTableIdForDocumentType` (the lib-baked map covering
33
+ * every documentType currently registered in the hub) so the
34
+ * `displayRef` / `discussRef` Ask + Display buttons WORK out of
35
+ * the box. Override only when you have polymorphic / per-tenant
36
+ * types the default doesn't cover.
37
+ *
38
+ * Wire format and SSE-parser logic mirror the hub original byte-for-byte
39
+ * so server-side and client-side stay in lockstep across the migration.
36
40
  */
37
41
 
38
- import { useCallback, useEffect, useMemo, useRef } from 'react'
42
+ import { useState, useEffect, useCallback, useMemo, useRef, type MutableRefObject } from 'react'
43
+ import { useChat, type Message, type StreamFnExtraOptions } from './use-chat'
39
44
  import { useRequiredChatRuntime } from '../../../contexts/chat-runtime-context'
40
45
  import type { ChatRef } from '../chat-ref.types'
41
- import type { Message } from '../types/message.types'
46
+ import { buildChatRefKey } from '../types/chat.types'
42
47
  import type { MessageSegment } from '../types/message.types'
43
48
  import { useSlashCommandRegistry, type SlashCommandSummary } from './use-slash-commands'
44
49
  import { getChatProxyAuth } from '../utils/chat-proxy-auth-storage'
45
50
  import { chatAuthedFetch } from '../utils/chat-authed-fetch'
46
- import type { ScrollAnchor } from '../utils/scroll-anchor'
47
- import { createSseFrameDecoder } from '../../../chat-protocol/decode'
51
+ import { parseScrollAnchor, type ScrollAnchor } from '../utils/scroll-anchor'
48
52
  import { AUTO_CONTINUATION_DIRECTIVE_PREFIX } from '../utils/auto-continuation-directive'
49
53
  import { flattenAssistantContent } from '../utils/flatten-assistant-content'
50
54
  import { sanitizeTitleForChat } from '../utils/slash-dispatch-utils'
51
55
  import { defaultTableIdForDocumentType } from '../utils/source-icons'
52
- import type { WireCommandOverride } from '../utils/slash-dispatch-utils'
53
- import type { ChatAttachment } from '../utils/chat-attachment-markdown'
54
56
  import type {
55
57
  UnifiedChatState,
56
- UnifiedChatMessage,
57
58
  UnifiedSendMessageOptions,
59
+ StreamingPhase,
58
60
  } from '../types/unified-chat-state.types'
59
61
  import type { DialogItem } from '../types/component.types'
60
- import { createChatDialogStore, DEFAULT_DIALOG_SIDE } from '../stream/chat-dialog-store'
61
- import { useChatStreamReducer } from '../stream/use-chat-stream-reducer'
62
- import type { ChatDialogStore } from '../stream/chat-dialog-store'
63
- import type { ChatStreamReducerOptions } from '../stream/chat-stream-reducer'
64
-
65
- // Canonical home of the per-turn meta row moved to the stream module in
66
- // Phase 3; re-exported here to keep the legacy import path stable.
67
- export type { ChatTurnMeta } from '../stream/chat-stream-reducer'
68
62
 
69
63
  // =============================================================================
70
64
  // Public types
@@ -127,6 +121,33 @@ export interface DocChatMessage {
127
121
  // `types/unified-chat-state.types.ts`.
128
122
  export type { StreamingPhase } from '../types/unified-chat-state.types'
129
123
 
124
+ /** Per-turn metadata extracted from the streamed metadata frame. */
125
+ export interface ChatTurnMeta {
126
+ /** Provider key recognized by `<ModelDisplay>` for icon
127
+ * selection: 'anthropic' | 'openai' | 'google' (case-insensitive). */
128
+ provider: string | null
129
+ modelLabel: string | null
130
+ contextWindowMaxTokens: number | null
131
+ /** Input tokens (from message_start). Includes cached tokens. */
132
+ inputTokens: number | null
133
+ /** Output tokens (from message_delta). Only known after stream end. */
134
+ outputTokens: number | null
135
+ /** Cache hit % (read / total-input × 100). Only known after stream end. */
136
+ cacheHitRatePct: number | null
137
+ /** Cross-call usage breakdown extracted from the trailing usage frame. */
138
+ breakdown: {
139
+ haikuRewriter?: { input: number; output: number }
140
+ haikuClassifier?: { input: number; output: number }
141
+ haikuSummarizer?: { input: number; output: number }
142
+ routedAnswer?: { model: string; complexity: string; thinkingBudget: number }
143
+ } | null
144
+ /** Per-message viewport-positioning hint. */
145
+ scrollAnchor: ScrollAnchor | null
146
+ /** Routing decision from the server's `decideRoute`. */
147
+ routedComplexity: string | null
148
+ routedThinkingBudget: number | null
149
+ }
150
+
130
151
  /**
131
152
  * Optional dependency-injection options for `useSseChatAdapter`.
132
153
  *
@@ -137,7 +158,13 @@ export type { StreamingPhase } from '../types/unified-chat-state.types'
137
158
  * **Defaults to `defaultTableIdForDocumentType` from
138
159
  * `src/utils/source-icons.ts`** — a lib-baked map covering every
139
160
  * documentType currently registered in the hub's RAG_TABLE_CONFIGS.
140
- * Override only for polymorphic / per-tenant document types.
161
+ * This makes Ask / Display work out of the box in embedders without
162
+ * any callback wiring.
163
+ *
164
+ * Override only when you have polymorphic / per-tenant document
165
+ * types that map to a different tableId than the default
166
+ * (hub-canonical) registry — pass your own `(docType) => tableId`
167
+ * callback and it wins over the default.
141
168
  */
142
169
  export interface UseSseChatAdapterOptions {
143
170
  tableIdForDocumentType?: (documentType: string) => string | null
@@ -156,7 +183,411 @@ export interface UseSseChatAdapterRuntimeOptions {
156
183
  }
157
184
 
158
185
  // =============================================================================
159
- // localStorage persistence — PersistedChatState v1
186
+ // Internal helpers
187
+ // =============================================================================
188
+
189
+ /** Single source of truth for a fresh `ChatTurnMeta` row. */
190
+ function createEmptyTurnMeta(): ChatTurnMeta {
191
+ return {
192
+ provider: null,
193
+ modelLabel: null,
194
+ contextWindowMaxTokens: null,
195
+ inputTokens: null,
196
+ outputTokens: null,
197
+ cacheHitRatePct: null,
198
+ breakdown: null,
199
+ scrollAnchor: null,
200
+ routedComplexity: null,
201
+ routedThinkingBudget: null,
202
+ }
203
+ }
204
+
205
+ /**
206
+ * Escape `<` so the lib's `SimpleMarkdownRenderer` (which uses `rehypeRaw`
207
+ * to pass HTML through) doesn't treat XML-like tokens in Claude's
208
+ * thinking output as React components. Escaping `<` → `&lt;` preserves
209
+ * the visible character without breaking blockquote `>` markers.
210
+ */
211
+ function escapeThinkingTags(text: string): string {
212
+ return text.replace(/</g, '&lt;')
213
+ }
214
+
215
+ function createDocStreamFn(
216
+ source: DocSource,
217
+ endpoints: { chatStreamUrl: string; approvalToolUrl: string },
218
+ messagesRef: MutableRefObject<Message[]>,
219
+ sourcesMapRef: MutableRefObject<Map<number, ChatSource[]>>,
220
+ refsMapRef: MutableRefObject<Map<number, Record<string, ChatRef>>>,
221
+ metaMapRef: MutableRefObject<Map<number, ChatTurnMeta>>,
222
+ setStreamingPhase: (phase: StreamingPhase) => void,
223
+ bumpMetaTick: () => void,
224
+ sendCountRef: MutableRefObject<number>,
225
+ ) {
226
+ // CRITICAL: the decoder + buffer MUST live INSIDE the returned async-
227
+ // generator function (per-call closure), NOT at the factory level. A
228
+ // rapid send-stop-send sequence with hook-level state would feed the
229
+ // second stream's first chunk into the first stream's tail buffer,
230
+ // corrupting metadata-frame parsing.
231
+ return async function* (
232
+ message: string,
233
+ signal?: AbortSignal,
234
+ extra?: StreamFnExtraOptions,
235
+ ): AsyncGenerator<MessageSegment> {
236
+ const currentMessages = messagesRef.current || []
237
+ // Filter `hidden:true` messages out of the API history. The approval-
238
+ // action turn injects a hidden user message with `content=''`.
239
+ // `flattenAssistantContent` joins text-segment arrays into a single
240
+ // string so the server sees the receipt + auto-continuation Qs.
241
+ const apiMessages = [
242
+ ...currentMessages
243
+ .filter((m) => (m.role === 'user' || m.role === 'assistant') && !m.hidden)
244
+ .map((m) => ({
245
+ role: m.role,
246
+ content:
247
+ typeof m.content === 'string' ? m.content : flattenAssistantContent(m.content),
248
+ })),
249
+ { role: 'user', content: message },
250
+ ]
251
+
252
+ // URL + body branch — approvalAction routes to the approval-tool
253
+ // endpoint, the standard chat path routes to the chat-stream endpoint.
254
+ const targetPath = extra?.approvalAction
255
+ ? endpoints.approvalToolUrl
256
+ : endpoints.chatStreamUrl
257
+ // `source` is INTENTIONALLY NOT in the body. The chat route resolves
258
+ // it server-side via its own platform-detection — tamper-proof binding
259
+ // so a client on one platform can't POST a different platform's
260
+ // conversation.
261
+ const requestBody = extra?.approvalAction
262
+ ? {
263
+ proposal_id: extra.approvalAction.proposalId,
264
+ action: extra.approvalAction.action,
265
+ messages: currentMessages
266
+ .filter((m) => (m.role === 'user' || m.role === 'assistant') && !m.hidden)
267
+ .map((m) => ({
268
+ role: m.role,
269
+ content:
270
+ typeof m.content === 'string'
271
+ ? m.content
272
+ : flattenAssistantContent(m.content),
273
+ })),
274
+ }
275
+ : {
276
+ messages: apiMessages,
277
+ ...(extra?.commandOverride ? { commandOverride: extra.commandOverride } : {}),
278
+ ...(extra?.pendingAttachments && extra.pendingAttachments.length > 0
279
+ ? { pendingAttachments: extra.pendingAttachments }
280
+ : {}),
281
+ }
282
+ // `chatAuthedFetch` carries the bearer-act-as headers (+ Supabase
283
+ // session cookies) — same wrapper `use-chat-attachments` and
284
+ // `use-chat-identity` use, so all three chat-side fetch sites share
285
+ // one identity-propagation primitive.
286
+ const response = await chatAuthedFetch(targetPath, {
287
+ method: 'POST',
288
+ body: JSON.stringify(requestBody),
289
+ signal,
290
+ })
291
+
292
+ if (!response.ok) {
293
+ throw new Error(`Chat request failed: ${response.status}`)
294
+ }
295
+
296
+ const reader = response.body?.getReader()
297
+ if (!reader) throw new Error('No response body')
298
+
299
+ const decoder = new TextDecoder()
300
+ let buffer = ''
301
+ let inText = false
302
+ // Live thinking accumulator — coalesced to ~20fps to avoid a re-render
303
+ // storm (Anthropic emits 30-60 deltas/sec).
304
+ let thinkingAccum = ''
305
+ let lastThinkingYieldTime = 0
306
+ let pendingThinkingYield = false
307
+ const THINKING_YIELD_INTERVAL_MS = 50
308
+ let trailerBuffer = ''
309
+ let inTrailer = false
310
+
311
+ const sendIdx = sendCountRef.current - 1
312
+ while (true) {
313
+ const { done, value } = await reader.read()
314
+ if (done) break
315
+
316
+ const chunk = decoder.decode(value, { stream: true })
317
+
318
+ if (!inText) {
319
+ buffer += chunk
320
+ while (!inText) {
321
+ const recIdx = buffer.indexOf('\x1E')
322
+ const nullIdx = buffer.indexOf('\0')
323
+ if (recIdx !== -1 && (nullIdx === -1 || recIdx < nullIdx)) {
324
+ if (pendingThinkingYield) {
325
+ pendingThinkingYield = false
326
+ yield { type: 'thinking', text: escapeThinkingTags(thinkingAccum) }
327
+ }
328
+ inText = true
329
+ setStreamingPhase('streaming')
330
+ const after = buffer.slice(recIdx + 1)
331
+ buffer = ''
332
+ if (after) {
333
+ // The `after` slice may ALSO contain the `\x1F` trailing-
334
+ // usage sentinel — common for fixed-answer responses where
335
+ // the whole frame sequence arrives in ONE TCP chunk.
336
+ const unitIdx = after.indexOf('\x1F')
337
+ if (unitIdx === -1) {
338
+ yield { type: 'text', text: after }
339
+ } else {
340
+ const textBefore = after.slice(0, unitIdx)
341
+ const trailerAfter = after.slice(unitIdx + 1)
342
+ if (textBefore) {
343
+ yield { type: 'text', text: textBefore }
344
+ }
345
+ inTrailer = true
346
+ trailerBuffer = trailerAfter
347
+ }
348
+ }
349
+ break
350
+ }
351
+ if (nullIdx === -1) break // need more bytes
352
+ const metaStr = buffer.slice(0, nullIdx)
353
+ const remaining = buffer.slice(nullIdx + 1)
354
+ let meta: any
355
+ try {
356
+ meta = JSON.parse(metaStr)
357
+ } catch {
358
+ // Not JSON — start of answer body.
359
+ inText = true
360
+ setStreamingPhase('streaming')
361
+ if (buffer.length > 0) {
362
+ yield { type: 'text', text: buffer }
363
+ buffer = ''
364
+ }
365
+ break
366
+ }
367
+ if (meta.status === 'thinking') {
368
+ setStreamingPhase('thinking')
369
+ } else if (meta.kind === 'thinking-delta' && typeof meta.text === 'string') {
370
+ thinkingAccum += meta.text
371
+ const now = Date.now()
372
+ if (now - lastThinkingYieldTime >= THINKING_YIELD_INTERVAL_MS) {
373
+ lastThinkingYieldTime = now
374
+ pendingThinkingYield = false
375
+ yield { type: 'thinking', text: escapeThinkingTags(thinkingAccum) }
376
+ } else {
377
+ pendingThinkingYield = true
378
+ }
379
+ } else if (meta.kind === 'usage' && meta.stage === 'start') {
380
+ mergeTurnMeta(metaMapRef, sendIdx, {
381
+ inputTokens: meta.input_tokens ?? null,
382
+ })
383
+ bumpMetaTick()
384
+ } else if (
385
+ meta.kind === 'decision_resolved' &&
386
+ typeof meta.action === 'string'
387
+ ) {
388
+ // Server-driven post-approve / post-reject frame.
389
+ const action = meta.action === 'rejected' ? 'rejected' : 'approved'
390
+ const toolName =
391
+ typeof meta.tool_name === 'string' ? meta.tool_name : undefined
392
+ const result = (meta.result ?? null) as
393
+ | { ticket_id?: string; status?: string | null; mirror_synced?: boolean }
394
+ | null
395
+ const card = (meta.card ?? null) as
396
+ | { type?: string; marker?: string; ref?: ChatRef }
397
+ | null
398
+ if (card?.ref?.id && card?.type) {
399
+ const existing = refsMapRef.current.get(sendIdx) ?? {}
400
+ const key = buildChatRefKey(card.type, card.ref.id)
401
+ refsMapRef.current.set(sendIdx, { ...existing, [key]: card.ref })
402
+ bumpMetaTick()
403
+ }
404
+ yield {
405
+ type: 'decision_resolved',
406
+ action,
407
+ ok: meta.ok === true,
408
+ willAutoContinue: meta.willAutoContinue === true,
409
+ ...(toolName ? { toolName } : {}),
410
+ ...(result ? { result } : {}),
411
+ ...(card?.marker ? { marker: card.marker } : {}),
412
+ ...(card?.ref ? { cardRef: card.ref } : {}),
413
+ ...(typeof meta.receiptText === 'string'
414
+ ? { receiptText: meta.receiptText }
415
+ : {}),
416
+ proposalId:
417
+ typeof meta.proposalId === 'string' ? meta.proposalId : undefined,
418
+ } as any
419
+ } else if (meta.kind === 'approval_request' && meta.proposalId) {
420
+ // The model called a write tool. Server persisted the
421
+ // proposal and sent us a CARD-READY payload.
422
+ const proposalId = String(meta.proposalId)
423
+ const toolName = String(meta.toolName ?? 'tool')
424
+ const headline =
425
+ typeof meta.title === 'string' && meta.title.length > 0
426
+ ? meta.title
427
+ : toolName
428
+ const rawFields = Array.isArray(meta.fields)
429
+ ? (meta.fields as Array<{ label?: string; value?: string }>)
430
+ : []
431
+ const fields: Array<{ label: string; value: string }> = []
432
+ for (const f of rawFields) {
433
+ if (!f || !f.label || !f.value) continue
434
+ fields.push({ label: f.label, value: f.value })
435
+ }
436
+ yield {
437
+ type: 'approval_request',
438
+ data: {
439
+ command: headline,
440
+ fields,
441
+ requestId: proposalId,
442
+ approvalType: toolName,
443
+ },
444
+ status: 'pending',
445
+ } as any
446
+ } else if (meta.kind === 'text-leading' && typeof meta.text === 'string') {
447
+ // Model's preamble text (the prose written BEFORE the tool_use
448
+ // block) — emitted as a standalone text segment.
449
+ yield { type: 'text', text: meta.text }
450
+ } else if (meta.kind === 'tool_error') {
451
+ const msg =
452
+ typeof meta.message === 'string' && meta.message.length > 0
453
+ ? meta.message
454
+ : 'Could not complete the requested action right now.'
455
+ yield {
456
+ type: 'text',
457
+ text: `⚠️ ${msg}`,
458
+ } as any
459
+ } else if (meta.kind === 'routing') {
460
+ if (typeof meta.routedComplexity === 'string') {
461
+ mergeTurnMeta(metaMapRef, sendIdx, {
462
+ routedComplexity: meta.routedComplexity,
463
+ routedThinkingBudget:
464
+ typeof meta.routedThinkingBudget === 'number'
465
+ ? meta.routedThinkingBudget
466
+ : null,
467
+ })
468
+ bumpMetaTick()
469
+ }
470
+ } else {
471
+ if (meta.sources) {
472
+ sourcesMapRef.current.set(sendIdx, meta.sources)
473
+ }
474
+ // Per-row refs for inline object cards.
475
+ if (meta.refs && typeof meta.refs === 'object') {
476
+ refsMapRef.current.set(sendIdx, meta.refs as Record<string, ChatRef>)
477
+ bumpMetaTick()
478
+ }
479
+ if (
480
+ meta.modelLabel ||
481
+ meta.contextWindowMaxTokens ||
482
+ meta.provider ||
483
+ meta.model
484
+ ) {
485
+ mergeTurnMeta(metaMapRef, sendIdx, {
486
+ provider: meta.provider ?? null,
487
+ modelLabel: meta.modelLabel ?? null,
488
+ contextWindowMaxTokens: meta.contextWindowMaxTokens ?? null,
489
+ })
490
+ bumpMetaTick()
491
+ }
492
+ const parsedAnchor = parseScrollAnchor(meta.scrollAnchor)
493
+ if (parsedAnchor !== null) {
494
+ mergeTurnMeta(metaMapRef, sendIdx, { scrollAnchor: parsedAnchor })
495
+ bumpMetaTick()
496
+ }
497
+ }
498
+ buffer = remaining
499
+ }
500
+ } else if (inTrailer) {
501
+ trailerBuffer += chunk
502
+ } else {
503
+ setStreamingPhase('streaming')
504
+ // Trailing usage frame uses \x1F (Unit Separator) as a sentinel.
505
+ const sepIdx = chunk.indexOf('\x1F')
506
+ if (sepIdx === -1) {
507
+ yield { type: 'text', text: chunk }
508
+ } else {
509
+ const before = chunk.slice(0, sepIdx)
510
+ const after = chunk.slice(sepIdx + 1)
511
+ if (before) yield { type: 'text', text: before }
512
+ inTrailer = true
513
+ trailerBuffer = after
514
+ }
515
+ }
516
+ }
517
+ // Stream ended. Flush any pending thinking text that didn't make it
518
+ // out before the throttle window expired.
519
+ if (pendingThinkingYield) {
520
+ pendingThinkingYield = false
521
+ yield { type: 'thinking', text: escapeThinkingTags(thinkingAccum) }
522
+ }
523
+ // Parse trailer if present.
524
+ if (trailerBuffer.length > 0) {
525
+ try {
526
+ const meta = JSON.parse(trailerBuffer)
527
+ if (
528
+ meta.kind === 'usage' &&
529
+ (meta.stage === 'end' || meta.stage === 'display')
530
+ ) {
531
+ const breakdown =
532
+ meta.breakdown && typeof meta.breakdown === 'object'
533
+ ? {
534
+ haikuRewriter:
535
+ meta.breakdown.haikuRewriter &&
536
+ typeof meta.breakdown.haikuRewriter.input === 'number'
537
+ ? meta.breakdown.haikuRewriter
538
+ : undefined,
539
+ haikuClassifier:
540
+ meta.breakdown.haikuClassifier &&
541
+ typeof meta.breakdown.haikuClassifier.input === 'number'
542
+ ? meta.breakdown.haikuClassifier
543
+ : undefined,
544
+ haikuSummarizer:
545
+ meta.breakdown.haikuSummarizer &&
546
+ typeof meta.breakdown.haikuSummarizer.input === 'number'
547
+ ? meta.breakdown.haikuSummarizer
548
+ : undefined,
549
+ routedAnswer:
550
+ meta.breakdown.routedAnswer &&
551
+ typeof meta.breakdown.routedAnswer.model === 'string'
552
+ ? meta.breakdown.routedAnswer
553
+ : undefined,
554
+ }
555
+ : null
556
+ mergeTurnMeta(metaMapRef, sendIdx, {
557
+ inputTokens: meta.input_tokens ?? null,
558
+ outputTokens: meta.output_tokens ?? null,
559
+ cacheHitRatePct:
560
+ typeof meta.hit_rate_pct === 'number' ? meta.hit_rate_pct : null,
561
+ ...(breakdown ? { breakdown } : {}),
562
+ })
563
+ bumpMetaTick()
564
+ }
565
+ } catch {
566
+ // Malformed trailer — silently ignore.
567
+ }
568
+ }
569
+ }
570
+ }
571
+
572
+ /**
573
+ * Merge partial fields into the per-turn meta map. Preserves existing
574
+ * non-null values so a leading frame's data isn't wiped by a later frame
575
+ * that didn't include it.
576
+ */
577
+ function mergeTurnMeta(
578
+ ref: MutableRefObject<Map<number, ChatTurnMeta>>,
579
+ sendIdx: number,
580
+ partial: Partial<ChatTurnMeta>,
581
+ ): void {
582
+ const prev = ref.current.get(sendIdx) ?? createEmptyTurnMeta()
583
+ const filtered = Object.fromEntries(
584
+ Object.entries(partial).filter(([, v]) => v !== undefined && v !== null),
585
+ ) as Partial<ChatTurnMeta>
586
+ ref.current.set(sendIdx, { ...prev, ...filtered })
587
+ }
588
+
589
+ // =============================================================================
590
+ // localStorage persistence
160
591
  // =============================================================================
161
592
 
162
593
  const CHAT_STORAGE_VERSION = 1
@@ -245,39 +676,6 @@ function savePersistedChat(source: DocSource, state: PersistedChatState) {
245
676
  }
246
677
  }
247
678
 
248
- /** v1 wire `Message` → reducer message. The v1 blob stores assistant
249
- * segments inside `content`; the reducer keeps `content` a string and
250
- * segments separate. */
251
- function persistedToReducerMessage(m: Message): UnifiedChatMessage {
252
- const segments = Array.isArray(m.content) ? (m.content as MessageSegment[]) : undefined
253
- return {
254
- id: m.id,
255
- role: m.role,
256
- content: typeof m.content === 'string' ? m.content : '',
257
- ...(segments ? { segments } : {}),
258
- ...(m.name !== undefined ? { name: m.name } : {}),
259
- ...(m.avatar != null ? { avatar: m.avatar } : {}),
260
- ...(m.timestamp !== undefined ? { timestamp: m.timestamp } : {}),
261
- ...(m.hidden ? { hidden: true } : {}),
262
- ...(m.chatRefs ? { chatRefs: m.chatRefs } : {}),
263
- } as UnifiedChatMessage
264
- }
265
-
266
- /** Reducer message → v1 wire `Message`. Exact inverse of the loader so the
267
- * persist round-trip re-saves an equivalent v1 blob (the golden contract). */
268
- function reducerToPersistedMessage(m: UnifiedChatMessage): Message {
269
- return {
270
- id: m.id,
271
- role: m.role,
272
- ...(m.name !== undefined ? { name: m.name } : {}),
273
- content: (m.segments ?? m.content) as Message['content'],
274
- ...(m.timestamp !== undefined ? { timestamp: m.timestamp } : {}),
275
- ...(m.avatar != null ? { avatar: m.avatar } : {}),
276
- ...(m.hidden ? { hidden: true } : {}),
277
- ...(m.chatRefs ? { chatRefs: m.chatRefs } : {}),
278
- } as Message
279
- }
280
-
281
679
  // =============================================================================
282
680
  // useSseChatAdapter — public hook
283
681
  // =============================================================================
@@ -299,71 +697,70 @@ export function useSseChatAdapter(
299
697
  // Chat-specific code REQUIRES a runtime — the lib's `<HubRuntimeProvider>`
300
698
  // (hub) / embedder's provider must wrap the tree.
301
699
  const runtime = useRequiredChatRuntime()
700
+ // `source` is OPTIONAL — embedders are platform-agnostic (see ChatRuntime.source).
701
+ // Here it's used ONLY for the localStorage history namespace + client-side meta
702
+ // labels; it is NEVER sent on the wire (the hub resolves source server-side via
703
+ // currentPlatform()). Fall back to a stable constant so the persistence key stays
704
+ // well-formed when the embedder leaves source unset.
302
705
  const source = runtime.source || DEFAULT_CHAT_SOURCE
706
+ // Fall back to the lib-baked hub-canonical map when the embedder
707
+ // didn't supply an override. Keeps Ask + Display working in any
708
+ // mount that doesn't have a custom documentType registry. Embedders
709
+ // with polymorphic / per-tenant types pass their own callback and it
710
+ // wins.
303
711
  const tableIdForDocumentType =
304
712
  options?.tableIdForDocumentType ?? defaultTableIdForDocumentType
305
713
 
306
- // ─── Reducer wiring ────────────────────────────────────────────────────────
307
- // One store per hook instance; one reducer keyed by the source. Approval
308
- // card buttons fire a server-driven confirm-tool turn via `sendMessage`
309
- // (hidden approval-action send) — routed through a ref so the reducer's
310
- // creation-time callbacks stay stable.
311
-
312
- const sendMessageRef = useRef<
313
- (text: string, options?: InternalSendMessageOptions) => Promise<void>
314
- >(async () => {})
315
-
316
- const cardApprove = useCallback((reqId?: string): void | Promise<void> => {
317
- if (!reqId) return
318
- return sendMessageRef.current('', {
319
- hidden: true,
320
- approvalAction: { proposalId: reqId, action: 'approve' },
321
- })
322
- }, [])
323
- const cardReject = useCallback((reqId?: string): void | Promise<void> => {
324
- if (!reqId) return
325
- return sendMessageRef.current('', {
326
- hidden: true,
327
- approvalAction: { proposalId: reqId, action: 'reject' },
328
- })
329
- }, [])
330
-
331
- const createReducerOptions = useCallback(
332
- (): ChatStreamReducerOptions => ({
333
- transport: 'sse',
334
- callbacks: { onApprove: cardApprove, onReject: cardReject },
335
- }),
336
- [cardApprove, cardReject],
337
- )
338
-
339
- const storeRef = useRef<ChatDialogStore | null>(null)
340
- if (storeRef.current === null) storeRef.current = createChatDialogStore()
341
-
342
- // Restore persisted state once on mount — the v1 blob seeds the reducer's
343
- // thread + its sendIdx-keyed sources/refs maps + the send counter.
714
+ // Restore persisted state once on mount.
344
715
  const persistedRef = useRef<PersistedChatState | null>(null)
345
716
  if (persistedRef.current === null) {
346
717
  pruneStaleChatStorage(source)
347
718
  persistedRef.current =
348
719
  loadPersistedChat(source) || { messages: [], sources: [], sendCount: 0 }
349
- const reducer = storeRef.current.getReducer(source, DEFAULT_DIALOG_SIDE, createReducerOptions)
350
- reducer.initializeWithState(persistedRef.current.messages.map(persistedToReducerMessage))
351
- reducer.seedSseMaps({
352
- sources: persistedRef.current.sources as Array<[number, unknown[]]>,
353
- refs: persistedRef.current.refs,
354
- sendCount: persistedRef.current.sendCount,
355
- })
356
720
  }
357
721
 
358
- const { state, applyEvent, flushDeltas, mutate, reducer } = useChatStreamReducer({
359
- store: storeRef.current,
360
- dialogId: source,
361
- createReducerOptions,
362
- })
722
+ const sourcesMapRef = useRef<Map<number, ChatSource[]>>(
723
+ new Map(persistedRef.current.sources),
724
+ )
725
+ const refsMapRef = useRef<Map<number, Record<string, ChatRef>>>(
726
+ new Map(persistedRef.current.refs ?? []),
727
+ )
728
+ const metaMapRef = useRef<Map<number, ChatTurnMeta>>(new Map())
729
+ const messagesRef = useRef<Message[]>(persistedRef.current.messages)
730
+ const sendCountRef = useRef(persistedRef.current.sendCount)
731
+ const [streamingPhase, setStreamingPhase] = useState<StreamingPhase>('idle')
732
+ const [metaTick, setMetaTick] = useState(0)
733
+ const bumpMetaTick = useCallback(() => setMetaTick((t) => t + 1), [])
363
734
 
364
- const abortControllerRef = useRef<AbortController | null>(null)
735
+ const streamFn = useMemo(
736
+ () =>
737
+ createDocStreamFn(
738
+ source,
739
+ {
740
+ chatStreamUrl: runtime.endpoints.chatStreamUrl,
741
+ approvalToolUrl: runtime.endpoints.approvalToolUrl,
742
+ },
743
+ messagesRef,
744
+ sourcesMapRef,
745
+ refsMapRef,
746
+ metaMapRef,
747
+ setStreamingPhase,
748
+ bumpMetaTick,
749
+ sendCountRef,
750
+ ),
751
+ [
752
+ source,
753
+ runtime.endpoints.chatStreamUrl,
754
+ runtime.endpoints.approvalToolUrl,
755
+ bumpMetaTick,
756
+ ],
757
+ )
365
758
 
366
- // ─── Slash-command registry (displayRef lookup) ───────────────────────────
759
+ // Per-source tableId → slash-command-id lookup, derived from the shared
760
+ // slash-command registry. Used by `displayRef` to translate an
761
+ // inline-card's Display click into a `/<cmd> display "<value>"`
762
+ // invocation, picking the canonical command for the row's RAG table.
763
+ //
367
764
  // Reads from the SAME react-query cache entry as `<EmbeddableChat>`'s
368
765
  // onboarding-card list (keyed on `commandsUrl`), so Guide mode fetches
369
766
  // `commands` ONCE. Gated on `active` so a Mingo-only panel — where this
@@ -395,221 +792,138 @@ export function useSseChatAdapter(
395
792
  return map
396
793
  }, [slashCommands])
397
794
 
398
- // ─── Persistence — save on every messages change ──────────────────────────
399
- // Sources/refs/sendCount live in the reducer's maps; read at write time.
795
+ // Persist on every messages change. Sources + sendCount live in refs,
796
+ // so we read their current values at write time.
400
797
  const persist = useCallback(
401
- (nextMessages: UnifiedChatMessage[]) => {
402
- const turnMeta = reducer.state.turnMeta
798
+ (nextMessages: Message[]) => {
403
799
  savePersistedChat(source, {
404
- messages: nextMessages.map(reducerToPersistedMessage),
405
- sources: Array.from(turnMeta.sources.entries()) as Array<[number, ChatSource[]]>,
406
- refs: Array.from(turnMeta.refs.entries()),
407
- sendCount: turnMeta.sendCount,
800
+ messages: nextMessages,
801
+ sources: Array.from(sourcesMapRef.current.entries()),
802
+ refs: Array.from(refsMapRef.current.entries()),
803
+ sendCount: sendCountRef.current,
408
804
  })
409
805
  },
410
- [source, reducer],
806
+ [source],
411
807
  )
412
- useEffect(() => {
413
- persist(state.messages)
414
- }, [state.messages, persist])
415
808
 
416
- // ─── Send / stream loop ────────────────────────────────────────────────────
809
+ const {
810
+ messages,
811
+ isTyping,
812
+ isStreaming,
813
+ sendMessage: chatSendMessage,
814
+ stopMessage: chatStopMessage,
815
+ clearMessages: chatClearMessages,
816
+ hasMessages,
817
+ } = useChat({
818
+ useMock: false,
819
+ assistantName: 'Mingo AI',
820
+ streamFn,
821
+ initialMessages: persistedRef.current.messages,
822
+ onMessagesChange: persist,
823
+ })
824
+
825
+ messagesRef.current = messages
826
+
827
+ // Index sources/refs/scrollAnchor by USER-SEND count (`sendIdx`), not
828
+ // by assistant-message count. Each user send produces exactly ONE
829
+ // refsMapRef entry server-side, but it can produce MULTIPLE assistant
830
+ // messages on the client (main RAG reply + post-approve card +
831
+ // auto-continuation prose). Counting USER sends and mapping every
832
+ // following assistant message to that index keeps the lookup stable.
833
+ let sendIdx = -1
834
+ const docMessages: DocChatMessage[] = messages.map((m) => {
835
+ const segments = Array.isArray(m.content) ? (m.content as MessageSegment[]) : undefined
836
+ const content =
837
+ typeof m.content === 'string'
838
+ ? m.content
839
+ : Array.isArray(m.content)
840
+ ? m.content
841
+ .filter((s) => s.type === 'text')
842
+ .map((s) => (s as { type: 'text'; text: string }).text)
843
+ .join('')
844
+ : ''
845
+
846
+ let sources: ChatSource[] | undefined
847
+ let chatRefs: Record<string, ChatRef> | undefined
848
+ let scrollAnchor: ScrollAnchor | undefined
849
+ if (m.role === 'user' && !m.hidden) {
850
+ sendIdx++
851
+ }
852
+ if (m.role === 'assistant') {
853
+ const lookupIdx = sendIdx >= 0 ? sendIdx : 0
854
+ sources = sourcesMapRef.current.get(lookupIdx)
855
+ // The receipt path stamps `chatRefs` directly onto the assistant
856
+ // message in useChat. Prefer that message-bound copy when present;
857
+ // falls back to per-turn refs map.
858
+ chatRefs = m.chatRefs ?? refsMapRef.current.get(lookupIdx)
859
+ scrollAnchor =
860
+ (metaMapRef.current.get(lookupIdx)?.scrollAnchor as ScrollAnchor | null) ??
861
+ undefined
862
+ }
863
+
864
+ return {
865
+ id: m.id,
866
+ role: m.role as 'user' | 'assistant',
867
+ content,
868
+ ...(segments ? { segments } : {}),
869
+ ...(sources ? { sources } : {}),
870
+ ...(chatRefs ? { chatRefs } : {}),
871
+ ...(scrollAnchor ? { scrollAnchor } : {}),
872
+ ...(m.hidden ? { hidden: true } : {}),
873
+ }
874
+ })
417
875
 
418
876
  /**
419
877
  * Internal sendMessage options — union of the public
420
878
  * `UnifiedSendMessageOptions` (semantic fields: `hidden`, `attachments`)
421
879
  * and SSE-only internal extras (`commandOverride`, `approvalAction`)
422
- * set by `discussRef` / `displayRef` / the approval-card callbacks.
880
+ * set by `discussRef` / `displayRef` / post-approval continuation.
881
+ *
882
+ * The public `UnifiedChatState.sendMessage` accepts only the narrow
883
+ * unified shape; this hook accepts the wider one because internal
884
+ * callers need it. Function-param contravariance makes this assignable.
423
885
  */
424
- type InternalSendMessageOptions = UnifiedSendMessageOptions & {
425
- commandOverride?: WireCommandOverride
426
- approvalAction?: { proposalId: string; action: 'approve' | 'reject' }
427
- }
428
-
429
- const endpointsRef = useRef(runtime.endpoints)
430
- endpointsRef.current = runtime.endpoints
886
+ type InternalSendMessageOptions = UnifiedSendMessageOptions &
887
+ Pick<StreamFnExtraOptions, 'commandOverride' | 'approvalAction'>
431
888
 
432
889
  const sendMessage = useCallback(
433
- async (text: string, sendOptions?: InternalSendMessageOptions): Promise<void> => {
434
- const { hidden, attachments, commandOverride, approvalAction } = sendOptions ?? {}
435
-
436
- // Conversation history for the server: everything BEFORE this send,
437
- // `hidden:true` rows included (LLM context) but their flag filtered
438
- // shape-wise like legacy — hidden messages are excluded entirely.
439
- const history = reducer.state.messages
440
- .filter((m) => (m.role === 'user' || m.role === 'assistant') && !m.hidden)
441
- .map((m) => ({
442
- role: m.role,
443
- content: m.segments ? flattenAssistantContent(m.segments) : (m.content as string),
444
- }))
445
-
446
- // URL + body branch — approvalAction routes to the approval-tool
447
- // endpoint, the standard chat path routes to the chat-stream endpoint.
448
- // `source` is INTENTIONALLY NOT in the body: the chat route resolves
449
- // it server-side via its own platform-detection — tamper-proof binding.
450
- const targetPath = approvalAction
451
- ? endpointsRef.current.approvalToolUrl
452
- : endpointsRef.current.chatStreamUrl
453
- const requestBody = approvalAction
454
- ? {
455
- proposal_id: approvalAction.proposalId,
456
- action: approvalAction.action,
457
- messages: history,
458
- }
459
- : {
460
- messages: [...history, { role: 'user', content: text }],
461
- ...(commandOverride ? { commandOverride } : {}),
462
- ...(attachments && attachments.length > 0
463
- ? { pendingAttachments: attachments as ChatAttachment[] }
464
- : {}),
465
- }
466
-
467
- // Optimistic user bubble + assistant placeholder + phase 'thinking'
468
- // + sendCount++ — one reducer command.
469
- mutate((r) =>
470
- r.beginSseSend({ text, hidden, userName: 'You', assistantName: 'Mingo AI' }),
471
- )
472
-
473
- const ctrl = new AbortController()
474
- abortControllerRef.current = ctrl
475
-
476
- try {
477
- // `chatAuthedFetch` carries the bearer-act-as headers (+ Supabase
478
- // session cookies) — same wrapper `use-chat-attachments` and
479
- // `use-chat-identity` use.
480
- const response = await chatAuthedFetch(targetPath, {
481
- method: 'POST',
482
- body: JSON.stringify(requestBody),
483
- signal: ctrl.signal,
484
- })
485
- if (!response.ok) {
486
- throw new Error(`Chat request failed: ${response.status}`)
487
- }
488
- const reader = response.body?.getReader()
489
- if (!reader) throw new Error('No response body')
490
-
491
- // Decoder is per-send (a rapid send-stop-send sequence must never
492
- // feed the second stream's first chunk into the first stream's
493
- // tail buffer).
494
- const frameDecoder = createSseFrameDecoder()
495
- let finished = false
496
- try {
497
- while (true) {
498
- const { done, value } = await reader.read()
499
- if (done) break
500
- if (ctrl.signal.aborted) break
501
- for (const event of frameDecoder.push(value)) {
502
- applyEvent(event)
503
- }
504
- }
505
- finished = true
506
- } finally {
507
- // `end()` always runs so the decoder settles; its events (the
508
- // trailing usage frame) apply only on a CLEAN end — aborts
509
- // propagate without extra state (legacy parity).
510
- const endEvents = frameDecoder.end()
511
- if (finished) {
512
- for (const event of endEvents) applyEvent(event)
513
- }
514
- }
515
- } catch (err) {
516
- // AbortError on user-initiated stop is expected — keep the partial
517
- // message, no error row.
518
- if ((err as { name?: string })?.name !== 'AbortError' && !ctrl.signal.aborted) {
519
- flushDeltas()
520
- mutate((r) =>
521
- r.failSseTurn(
522
- err instanceof Error
523
- ? err.message
524
- : 'An error occurred while processing your request.',
525
- ),
526
- )
527
- }
528
- } finally {
529
- if (abortControllerRef.current === ctrl) {
530
- abortControllerRef.current = null
531
- }
532
- // Force-flush pending deltas BEFORE the completion state lands, then
533
- // settle the turn (drops an empty trailing placeholder — the reject
534
- // path streams no text — and returns the phase to idle).
535
- flushDeltas()
536
- mutate((r) => r.endSseTurn())
890
+ async (
891
+ text: string,
892
+ options?: InternalSendMessageOptions,
893
+ ): Promise<void> => {
894
+ const {
895
+ hidden,
896
+ attachments,
897
+ commandOverride,
898
+ approvalAction,
899
+ } = options ?? {}
900
+ const sseExtras: StreamFnExtraOptions = {
901
+ ...(commandOverride ? { commandOverride } : {}),
902
+ ...(approvalAction ? { approvalAction } : {}),
903
+ ...(attachments && attachments.length > 0
904
+ ? { pendingAttachments: attachments }
905
+ : {}),
537
906
  }
907
+ sendCountRef.current++
908
+ setStreamingPhase('thinking')
909
+ await chatSendMessage(text, sseExtras, hidden ? { hidden } : undefined)
538
910
  },
539
- [reducer, mutate, applyEvent, flushDeltas],
911
+ [chatSendMessage],
540
912
  )
541
- sendMessageRef.current = sendMessage
542
-
543
- const stopMessage = useCallback(() => {
544
- abortControllerRef.current?.abort()
545
- flushDeltas()
546
- mutate((r) => r.setPhase('idle'))
547
- }, [mutate, flushDeltas])
548
-
549
- const clearMessages = useCallback(() => {
550
- mutate((r) => r.reset())
551
- // Clear persisted state too so the next mount starts fresh.
552
- if (typeof window !== 'undefined') {
553
- try {
554
- window.localStorage.removeItem(chatStorageKey(source))
555
- } catch {}
556
- }
557
- }, [mutate, source])
558
-
559
- // ─── Public message mapping (sendIdx fan-out lookup) ──────────────────────
560
- // Index sources/refs/scrollAnchor by USER-SEND count (`sendIdx`), not by
561
- // assistant-message count. Each user send produces exactly ONE refs entry
562
- // server-side, but it can produce MULTIPLE assistant messages on the
563
- // client (main RAG reply + post-approve card + auto-continuation prose).
564
- // Counting VISIBLE user sends and mapping every following assistant
565
- // message to that index keeps the lookup stable — this is the
566
- // PersistedChatState v1 rehydration contract.
567
- const docMessages: DocChatMessage[] = useMemo(() => {
568
- const { meta, sources: sourcesMap, refs: refsMap } = state.turnMeta
569
- let sendIdx = -1
570
- return state.messages.map((m) => {
571
- const segments = m.segments
572
- const content =
573
- typeof m.content === 'string' && !segments
574
- ? m.content
575
- : segments
576
- ?.filter((s) => s.type === 'text')
577
- .map((s) => (s as { type: 'text'; text: string }).text)
578
- .join('') ?? ''
579
-
580
- let sources: ChatSource[] | undefined
581
- let chatRefs: Record<string, ChatRef> | undefined
582
- let scrollAnchor: ScrollAnchor | undefined
583
- if (m.role === 'user' && !m.hidden) {
584
- sendIdx++
585
- }
586
- if (m.role === 'assistant') {
587
- const lookupIdx = sendIdx >= 0 ? sendIdx : 0
588
- sources = sourcesMap.get(lookupIdx) as ChatSource[] | undefined
589
- // The receipt path stamps `chatRefs` directly onto the assistant
590
- // message; prefer that message-bound copy when present, fall back
591
- // to the per-turn refs map.
592
- chatRefs = m.chatRefs ?? refsMap.get(lookupIdx)
593
- scrollAnchor = (meta.get(lookupIdx)?.scrollAnchor as ScrollAnchor | null) ?? undefined
594
- }
595
-
596
- return {
597
- id: m.id,
598
- role: m.role as 'user' | 'assistant',
599
- content,
600
- ...(segments ? { segments } : {}),
601
- ...(sources ? { sources } : {}),
602
- ...(chatRefs ? { chatRefs } : {}),
603
- ...(scrollAnchor ? { scrollAnchor } : {}),
604
- ...(m.hidden ? { hidden: true } : {}),
605
- }
606
- })
607
- }, [state.messages, state.turnMeta])
608
913
 
609
914
  /**
610
915
  * "Display" callback for inline cards whose registry entry sets
611
916
  * `displayAction: true`. Parallel to `discussRef` but emits a
612
- * `/<cmd> display "<title>"` slash command instead of the Discuss prose.
917
+ * `/<cmd> display "<title>"` slash command instead of the Discuss
918
+ * prose. Resolution of the slash command id for the ref's documentType
919
+ * happens via the `cmdIdByTableId` map hydrated from the commands
920
+ * endpoint.
921
+ *
922
+ * `tableIdForDocumentType` is always defined here — either the
923
+ * embedder-supplied override OR the lib-baked
924
+ * `defaultTableIdForDocumentType` fallback. Unknown documentTypes
925
+ * still short-circuit gracefully via the `tableId === null` check
926
+ * below.
613
927
  */
614
928
  const displayRef = useCallback(
615
929
  (reference: ChatRef) => {
@@ -651,6 +965,10 @@ export function useSseChatAdapter(
651
965
  * prompt ("Tell me more about <title>"); the structured
652
966
  * `commandOverride.entityIdFilter` is sent server-side via the request
653
967
  * body so retrieval narrows to the named row.
968
+ *
969
+ * `tableIdForDocumentType` is always defined here — either the
970
+ * embedder-supplied override OR the lib-baked default. Unknown
971
+ * documentTypes still short-circuit via the `tableId === null` check.
654
972
  */
655
973
  const discussRef = useCallback(
656
974
  (reference: ChatRef) => {
@@ -680,17 +998,47 @@ export function useSseChatAdapter(
680
998
  [sendMessage, tableIdForDocumentType],
681
999
  )
682
1000
 
683
- // ─── Per-turn metadata resolution ─────────────────────────────────────────
684
- const latestMeta = useMemo(() => {
685
- const { meta, sendCount } = state.turnMeta
686
- return meta.get(sendCount - 1) ?? meta.get(sendCount - 2) ?? null
687
- }, [state])
1001
+ const stopMessage = useCallback(() => {
1002
+ chatStopMessage()
1003
+ setStreamingPhase('idle')
1004
+ }, [chatStopMessage])
688
1005
 
689
- const streamingPhase = state.streamingPhase
1006
+ const clearMessages = useCallback(() => {
1007
+ sourcesMapRef.current.clear()
1008
+ refsMapRef.current.clear()
1009
+ metaMapRef.current.clear()
1010
+ sendCountRef.current = 0
1011
+ setStreamingPhase('idle')
1012
+ // Force the latestMeta useMemo to recompute with the cleared map.
1013
+ bumpMetaTick()
1014
+ chatClearMessages()
1015
+ // Clear persisted state too so the next mount starts fresh.
1016
+ if (typeof window !== 'undefined') {
1017
+ try {
1018
+ window.localStorage.removeItem(chatStorageKey(source))
1019
+ } catch {}
1020
+ }
1021
+ }, [chatClearMessages, source, bumpMetaTick])
1022
+
1023
+ // Reset to idle whenever both flags drop off.
1024
+ useEffect(() => {
1025
+ if (!isTyping && !isStreaming && streamingPhase !== 'idle') {
1026
+ setStreamingPhase('idle')
1027
+ }
1028
+ }, [isTyping, isStreaming, streamingPhase])
1029
+
1030
+ // Resolve the active turn's metadata.
1031
+ const latestMeta = useMemo(
1032
+ () =>
1033
+ metaMapRef.current.get(sendCountRef.current - 1) ??
1034
+ metaMapRef.current.get(sendCountRef.current - 2) ??
1035
+ null,
1036
+ [metaTick, streamingPhase],
1037
+ )
690
1038
 
691
1039
  return {
692
1040
  messages: docMessages,
693
- isLoading: streamingPhase !== 'idle',
1041
+ isLoading: isTyping || isStreaming,
694
1042
  sendMessage,
695
1043
  /** "Discuss" affordance for ObjectCard. */
696
1044
  discussRef,