@flamingo-stack/openframe-frontend-core 0.0.508 → 0.0.509
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.
- package/dist/chat-protocol/decode.d.ts +58 -0
- package/dist/chat-protocol/decode.d.ts.map +1 -0
- package/dist/chat-protocol/encode.d.ts +44 -0
- package/dist/chat-protocol/encode.d.ts.map +1 -0
- package/dist/chat-protocol/env-flag.d.ts +35 -0
- package/dist/chat-protocol/env-flag.d.ts.map +1 -0
- package/dist/chat-protocol/events.d.ts +216 -0
- package/dist/chat-protocol/events.d.ts.map +1 -0
- package/dist/chat-protocol/frames.d.ts +213 -0
- package/dist/chat-protocol/frames.d.ts.map +1 -0
- package/dist/chat-protocol/index.cjs +506 -0
- package/dist/chat-protocol/index.cjs.map +1 -0
- package/dist/chat-protocol/index.d.ts +18 -0
- package/dist/chat-protocol/index.d.ts.map +1 -0
- package/dist/chat-protocol/index.js +490 -0
- package/dist/chat-protocol/index.js.map +1 -0
- package/dist/chat-protocol/ip-normalize.d.ts +44 -0
- package/dist/chat-protocol/ip-normalize.d.ts.map +1 -0
- package/dist/chat-protocol/nats-decoder.d.ts +26 -0
- package/dist/chat-protocol/nats-decoder.d.ts.map +1 -0
- package/dist/{chunk-3SQ5KXHQ.cjs → chunk-2T4GTV25.cjs} +9 -9
- package/dist/{chunk-3SQ5KXHQ.cjs.map → chunk-2T4GTV25.cjs.map} +1 -1
- package/dist/{chunk-CWAOV2FK.cjs → chunk-3CGZPAGR.cjs} +3 -3
- package/dist/{chunk-CWAOV2FK.cjs.map → chunk-3CGZPAGR.cjs.map} +1 -1
- package/dist/{chunk-TLMJHMXJ.js → chunk-3QSR22IC.js} +12 -12
- package/dist/chunk-3QSR22IC.js.map +1 -0
- package/dist/{chunk-TWYNR4TZ.cjs → chunk-4G5TLHJD.cjs} +7141 -4799
- package/dist/chunk-4G5TLHJD.cjs.map +1 -0
- package/dist/{chunk-ADPMHWOE.js → chunk-72XAID7Y.js} +4 -4
- package/dist/{chunk-PAGKRNWK.js → chunk-7WZHBQ4J.js} +2 -2
- package/dist/{chunk-OCEKO5CW.js → chunk-AEELJC4N.js} +12106 -9764
- package/dist/chunk-AEELJC4N.js.map +1 -0
- package/dist/{chunk-Z42BGM6Q.cjs → chunk-BT2WWDFP.cjs} +5 -5
- package/dist/{chunk-Z42BGM6Q.cjs.map → chunk-BT2WWDFP.cjs.map} +1 -1
- package/dist/{chunk-7LEL3RIX.cjs → chunk-C7OY3PQP.cjs} +11 -11
- package/dist/{chunk-7LEL3RIX.cjs.map → chunk-C7OY3PQP.cjs.map} +1 -1
- package/dist/{chunk-RHGGEKPQ.cjs → chunk-CIOLOQKQ.cjs} +4 -4
- package/dist/{chunk-RHGGEKPQ.cjs.map → chunk-CIOLOQKQ.cjs.map} +1 -1
- package/dist/{chunk-TSZHM74B.js → chunk-DC2TKS7C.js} +2 -2
- package/dist/{chunk-M2GOR3XQ.cjs → chunk-DYPYR7FR.cjs} +87 -87
- package/dist/{chunk-M2GOR3XQ.cjs.map → chunk-DYPYR7FR.cjs.map} +1 -1
- package/dist/{chunk-I64ABCDX.js → chunk-I2B6X77L.js} +2 -2
- package/dist/{chunk-NIZDKTGL.cjs → chunk-K5MSCMDK.cjs} +37 -37
- package/dist/{chunk-NIZDKTGL.cjs.map → chunk-K5MSCMDK.cjs.map} +1 -1
- package/dist/{chunk-DTYRYB2N.js → chunk-K6QTZ7CZ.js} +2 -2
- package/dist/{chunk-4LTMXDUS.js → chunk-KBN7PMFT.js} +2 -2
- package/dist/{chunk-TBMV7I5N.js → chunk-KXZO2WZX.js} +2 -2
- package/dist/{chunk-QTYZMP6D.cjs → chunk-LBAKLT6T.cjs} +62 -62
- package/dist/chunk-LBAKLT6T.cjs.map +1 -0
- package/dist/{chunk-KEBYLU3U.js → chunk-LFUPI7VX.js} +2 -2
- package/dist/{chunk-LDTR4IVZ.cjs → chunk-M37PBG4N.cjs} +31 -31
- package/dist/{chunk-LDTR4IVZ.cjs.map → chunk-M37PBG4N.cjs.map} +1 -1
- package/dist/{chunk-WA7RR64F.js → chunk-PO5O654H.js} +6 -5
- package/dist/chunk-PO5O654H.js.map +1 -0
- package/dist/{chunk-WK4N5VBX.cjs → chunk-QJJISLPA.cjs} +28 -27
- package/dist/chunk-QJJISLPA.cjs.map +1 -0
- package/dist/{chunk-2F44PLTV.cjs → chunk-T7MCSA55.cjs} +26 -26
- package/dist/{chunk-2F44PLTV.cjs.map → chunk-T7MCSA55.cjs.map} +1 -1
- package/dist/{chunk-WJQHJD7J.js → chunk-TWSKTJHW.js} +2 -2
- package/dist/{chunk-ZSQHZYCO.cjs → chunk-UQDMJ6DH.cjs} +13 -13
- package/dist/{chunk-ZSQHZYCO.cjs.map → chunk-UQDMJ6DH.cjs.map} +1 -1
- package/dist/{chunk-TZRUCD56.js → chunk-V3ZAAFE3.js} +5 -5
- package/dist/{chunk-LRGHJPET.js → chunk-XGIFOHKE.js} +2 -2
- package/dist/{chunk-POOMO3PA.cjs → chunk-YQEPRYT5.cjs} +7 -7
- package/dist/{chunk-POOMO3PA.cjs.map → chunk-YQEPRYT5.cjs.map} +1 -1
- package/dist/components/case-studies/index.cjs +8 -8
- package/dist/components/case-studies/index.js +2 -2
- package/dist/components/chat/chat-message-enhanced.d.ts.map +1 -1
- package/dist/components/chat/chat-message-list.d.ts.map +1 -1
- package/dist/components/chat/hooks/index.d.ts +0 -1
- package/dist/components/chat/hooks/index.d.ts.map +1 -1
- package/dist/components/chat/hooks/use-chat.d.ts.map +1 -1
- package/dist/components/chat/hooks/use-chunk-catchup.d.ts.map +1 -1
- package/dist/components/chat/hooks/use-nats-chat-adapter.d.ts +15 -48
- package/dist/components/chat/hooks/use-nats-chat-adapter.d.ts.map +1 -1
- package/dist/components/chat/hooks/use-sse-chat-adapter.d.ts +10 -57
- package/dist/components/chat/hooks/use-sse-chat-adapter.d.ts.map +1 -1
- package/dist/components/chat/index.cjs +10 -2
- package/dist/components/chat/index.cjs.map +1 -1
- package/dist/components/chat/index.d.ts +1 -0
- package/dist/components/chat/index.d.ts.map +1 -1
- package/dist/components/chat/index.js +21 -13
- package/dist/components/chat/stream/chat-dialog-store.d.ts +184 -0
- package/dist/components/chat/stream/chat-dialog-store.d.ts.map +1 -0
- package/dist/components/chat/stream/chat-stream-reducer.d.ts +380 -0
- package/dist/components/chat/stream/chat-stream-reducer.d.ts.map +1 -0
- package/dist/components/chat/stream/delta-batcher.d.ts +51 -0
- package/dist/components/chat/stream/delta-batcher.d.ts.map +1 -0
- package/dist/components/chat/stream/index.d.ts +17 -0
- package/dist/components/chat/stream/index.d.ts.map +1 -0
- package/dist/components/chat/stream/message-mutations.d.ts +77 -0
- package/dist/components/chat/stream/message-mutations.d.ts.map +1 -0
- package/dist/components/chat/stream/use-chat-stream-reducer.d.ts +22 -0
- package/dist/components/chat/stream/use-chat-stream-reducer.d.ts.map +1 -0
- package/dist/components/chat/types/api.types.d.ts +1 -81
- package/dist/components/chat/types/api.types.d.ts.map +1 -1
- package/dist/components/chat/types/processing.types.d.ts +2 -93
- package/dist/components/chat/types/processing.types.d.ts.map +1 -1
- package/dist/components/chat/types/unified-chat-state.types.d.ts +15 -0
- package/dist/components/chat/types/unified-chat-state.types.d.ts.map +1 -1
- package/dist/components/chat/utils/extract-incomplete-message-state.d.ts +34 -6
- package/dist/components/chat/utils/extract-incomplete-message-state.d.ts.map +1 -1
- package/dist/components/chat/utils/history-merge.d.ts.map +1 -1
- package/dist/components/chat/utils/index.d.ts +2 -3
- package/dist/components/chat/utils/index.d.ts.map +1 -1
- package/dist/components/chat/utils/process-historical-messages.d.ts +59 -11
- package/dist/components/chat/utils/process-historical-messages.d.ts.map +1 -1
- package/dist/components/contact/index.cjs +3 -3
- package/dist/components/contact/index.js +2 -2
- package/dist/components/docs/index.cjs +5 -5
- package/dist/components/docs/index.js +4 -4
- package/dist/components/embeds/index.cjs +3 -3
- package/dist/components/embeds/index.js +2 -2
- package/dist/components/faq/index.cjs +3 -3
- package/dist/components/faq/index.js +2 -2
- package/dist/components/features/index.cjs +2 -2
- package/dist/components/features/index.js +1 -1
- package/dist/components/help-center-pages/index.cjs +22 -22
- package/dist/components/help-center-pages/index.js +13 -13
- package/dist/components/index.cjs +174 -132
- package/dist/components/index.cjs.map +1 -1
- package/dist/components/index.js +65 -23
- package/dist/components/index.js.map +1 -1
- package/dist/components/meeting-scheduler/index.cjs +34 -34
- package/dist/components/meeting-scheduler/index.js +3 -3
- package/dist/components/navigation/index.cjs +2 -2
- package/dist/components/navigation/index.js +1 -1
- package/dist/components/onboarding-guides/index.cjs +5 -5
- package/dist/components/onboarding-guides/index.js +4 -4
- package/dist/components/onboarding-guides/onboarding-guide-detail-view.d.ts.map +1 -1
- package/dist/components/related-content/index.cjs +3 -3
- package/dist/components/related-content/index.js +2 -2
- package/dist/components/shared/product-release/release-detail-page.d.ts.map +1 -1
- package/dist/components/tickets/index.cjs +6 -6
- package/dist/components/tickets/index.js +5 -5
- package/dist/components/ui/index.cjs +44 -2
- package/dist/components/ui/index.cjs.map +1 -1
- package/dist/components/ui/index.d.ts +1 -2
- package/dist/components/ui/index.d.ts.map +1 -1
- package/dist/components/ui/index.js +55 -13
- package/dist/components/ui/markdown/base-components.d.ts +51 -0
- package/dist/components/ui/markdown/base-components.d.ts.map +1 -0
- package/dist/components/ui/markdown/engine.d.ts +99 -0
- package/dist/components/ui/markdown/engine.d.ts.map +1 -0
- package/dist/components/ui/markdown/heading-ids.d.ts +65 -0
- package/dist/components/ui/markdown/heading-ids.d.ts.map +1 -0
- package/dist/components/ui/markdown/index.d.ts +18 -0
- package/dist/components/ui/markdown/index.d.ts.map +1 -0
- package/dist/components/ui/markdown/mermaid-diagram.d.ts +79 -0
- package/dist/components/ui/markdown/mermaid-diagram.d.ts.map +1 -0
- package/dist/components/ui/markdown/rich/embed-overrides.d.ts +14 -0
- package/dist/components/ui/markdown/rich/embed-overrides.d.ts.map +1 -0
- package/dist/components/ui/markdown/rich/rich-markdown-renderer.d.ts +40 -0
- package/dist/components/ui/markdown/rich/rich-markdown-renderer.d.ts.map +1 -0
- package/dist/components/ui/markdown/rich/shortcodes.d.ts +8 -0
- package/dist/components/ui/markdown/rich/shortcodes.d.ts.map +1 -0
- package/dist/components/ui/markdown/sanitize.d.ts +153 -0
- package/dist/components/ui/markdown/sanitize.d.ts.map +1 -0
- package/dist/components/ui/markdown/simple-markdown-renderer.d.ts +23 -0
- package/dist/components/ui/markdown/simple-markdown-renderer.d.ts.map +1 -0
- package/dist/components/ui/markdown/streaming.d.ts +78 -0
- package/dist/components/ui/markdown/streaming.d.ts.map +1 -0
- package/dist/components/ui/markdown/text-size.d.ts +34 -0
- package/dist/components/ui/markdown/text-size.d.ts.map +1 -0
- package/dist/index.cjs +60 -2
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +71 -13
- package/dist/utils/index.cjs +169 -40
- package/dist/utils/index.cjs.map +1 -1
- package/dist/utils/index.d.ts +1 -0
- package/dist/utils/index.d.ts.map +1 -1
- package/dist/utils/index.js +162 -41
- package/dist/utils/index.js.map +1 -1
- package/dist/utils/markdown-fences.d.ts +42 -0
- package/dist/utils/markdown-fences.d.ts.map +1 -0
- package/dist/utils/markdown-heading-id.d.ts +85 -0
- package/dist/utils/markdown-heading-id.d.ts.map +1 -0
- package/dist/utils/markdown-section-extractor.d.ts.map +1 -1
- package/package.json +7 -1
- package/src/chat-protocol/__tests__/__snapshots__/nats-decoder-golden.test.ts.snap +319 -0
- package/src/chat-protocol/__tests__/chat-protocol.test.ts +516 -0
- package/src/chat-protocol/__tests__/env-flag.test.ts +52 -0
- package/src/chat-protocol/__tests__/ip-normalize.test.ts +136 -0
- package/src/chat-protocol/__tests__/nats-decoder-golden.test.ts +237 -0
- package/src/chat-protocol/decode.ts +278 -0
- package/src/chat-protocol/encode.ts +71 -0
- package/src/chat-protocol/env-flag.ts +39 -0
- package/src/chat-protocol/events.ts +254 -0
- package/src/chat-protocol/frames.ts +245 -0
- package/src/chat-protocol/index.ts +21 -0
- package/src/chat-protocol/ip-normalize.ts +146 -0
- package/src/chat-protocol/nats-decoder.ts +264 -0
- package/src/components/chat/.chat-message-list.md +10 -6
- package/src/components/chat/__tests__/chat-message-list.test.tsx +450 -9
- package/src/components/chat/__tests__/chat-message-streaming-memo.test.tsx +207 -0
- package/src/components/chat/__tests__/chat-pending-turn.test.tsx +250 -0
- package/src/components/chat/chat-message-enhanced.tsx +122 -20
- package/src/components/chat/chat-message-list.tsx +515 -133
- package/src/components/chat/embeddable-chat.tsx +6 -0
- package/src/components/chat/hooks/.index.md +30 -33
- package/src/components/chat/hooks/.use-nats-chat-adapter.md +36 -55
- package/src/components/chat/hooks/__tests__/__snapshots__/conversation-id-persistence-golden.test.ts.snap +37 -0
- package/src/components/chat/hooks/__tests__/__snapshots__/sse-stream-golden.test.ts.snap +0 -0
- package/src/components/chat/hooks/__tests__/chunk-catchup-dialog-staleness.test.ts +92 -0
- package/src/components/chat/hooks/__tests__/conversation-id-persistence-golden.test.ts +438 -0
- package/src/components/chat/hooks/__tests__/sse-stream-golden.test.ts +318 -0
- package/src/components/chat/hooks/index.ts +0 -1
- package/src/components/chat/hooks/use-chat.ts +8 -0
- package/src/components/chat/hooks/use-chunk-catchup.ts +26 -1
- package/src/components/chat/hooks/use-nats-chat-adapter.ts +254 -825
- package/src/components/chat/hooks/use-sse-chat-adapter.ts +380 -684
- package/src/components/chat/index.ts +5 -0
- package/src/components/chat/stream/__tests__/__snapshots__/chat-stream-reducer-golden.test.ts.snap +944 -0
- package/src/components/chat/stream/__tests__/chat-dialog-store.test.ts +806 -0
- package/src/components/chat/stream/__tests__/chat-stream-reducer-golden.test.ts +467 -0
- package/src/components/chat/stream/__tests__/chat-stream-reducer.test.ts +1330 -0
- package/src/components/chat/stream/__tests__/delta-batcher.test.ts +255 -0
- package/src/components/chat/stream/__tests__/use-chat-stream-reducer.test.ts +105 -0
- package/src/components/chat/stream/chat-dialog-store.ts +536 -0
- package/src/components/chat/stream/chat-stream-reducer.ts +1928 -0
- package/src/components/chat/stream/delta-batcher.ts +168 -0
- package/src/components/chat/stream/index.ts +55 -0
- package/src/components/chat/stream/message-mutations.ts +340 -0
- package/src/components/chat/stream/use-chat-stream-reducer.ts +126 -0
- package/src/components/chat/thinking-display.tsx +1 -1
- package/src/components/chat/types/.api.types.md +39 -49
- package/src/components/chat/types/.processing.types.md +29 -58
- package/src/components/chat/types/.unified-chat-state.types.md +2 -2
- package/src/components/chat/types/api.types.ts +6 -73
- package/src/components/chat/types/processing.types.ts +11 -53
- package/src/components/chat/types/unified-chat-state.types.ts +16 -0
- package/src/components/chat/utils/.extract-incomplete-message-state.md +21 -3
- package/src/components/chat/utils/.history-merge.md +44 -3
- package/src/components/chat/utils/.index.md +31 -40
- package/src/components/chat/utils/__tests__/__snapshots__/process-historical-messages-golden.test.ts.snap +420 -0
- package/src/components/chat/utils/__tests__/__snapshots__/segment-accumulator-golden.test.ts.snap +605 -0
- package/src/components/chat/utils/__tests__/extract-incomplete-message-state.test.ts +106 -0
- package/src/components/chat/utils/__tests__/history-merge.test.ts +160 -0
- package/src/components/chat/utils/__tests__/process-historical-messages-approvals.test.ts +61 -0
- package/src/components/chat/utils/__tests__/process-historical-messages-golden.test.ts +394 -0
- package/src/components/chat/utils/__tests__/segment-accumulator-golden.test.ts +270 -0
- package/src/components/chat/utils/extract-incomplete-message-state.ts +93 -6
- package/src/components/chat/utils/history-merge.ts +85 -5
- package/src/components/chat/utils/index.ts +5 -8
- package/src/components/chat/utils/process-historical-messages.ts +361 -385
- package/src/components/onboarding-guides/onboarding-guide-detail-view.tsx +22 -4
- package/src/components/shared/legal-document/legal-document-page.tsx +1 -1
- package/src/components/shared/product-release/release-detail-page.tsx +19 -4
- package/src/components/ui/__tests__/__snapshots__/markdown-parity.test.tsx.snap +2420 -0
- package/src/components/ui/__tests__/markdown-parity.test.tsx +2540 -0
- package/src/components/ui/index.ts +1 -2
- package/src/components/ui/markdown/__tests__/mermaid-security.test.ts +176 -0
- package/src/components/ui/markdown/__tests__/mermaid-stale-render.test.tsx +151 -0
- package/src/components/ui/markdown/__tests__/sanitize-invariant.test.ts +354 -0
- package/src/components/ui/markdown/__tests__/sanitize-render.test.tsx +118 -0
- package/src/components/ui/markdown/__tests__/streaming.test.tsx +436 -0
- package/src/components/ui/markdown/base-components.tsx +360 -0
- package/src/components/ui/markdown/engine.tsx +315 -0
- package/src/components/ui/markdown/heading-ids.ts +239 -0
- package/src/components/ui/markdown/index.ts +49 -0
- package/src/components/ui/markdown/mermaid-diagram.tsx +323 -0
- package/src/components/ui/markdown/rich/embed-overrides.tsx +199 -0
- package/src/components/ui/markdown/rich/rich-markdown-renderer.tsx +184 -0
- package/src/components/ui/markdown/rich/shortcodes.ts +170 -0
- package/src/components/ui/markdown/sanitize.ts +3584 -0
- package/src/components/ui/markdown/simple-markdown-renderer.tsx +28 -0
- package/src/components/ui/markdown/streaming.ts +390 -0
- package/src/components/ui/markdown/text-size.ts +106 -0
- package/src/components/ui/release-changelog-section.tsx +1 -1
- package/src/components/ui/ticket-info-section.tsx +1 -1
- package/src/utils/index.ts +1 -0
- package/src/utils/markdown-fences.ts +127 -0
- package/src/utils/markdown-heading-id.ts +348 -0
- package/src/utils/markdown-section-extractor.ts +41 -56
- package/dist/chunk-OCEKO5CW.js.map +0 -1
- package/dist/chunk-QTYZMP6D.cjs.map +0 -1
- package/dist/chunk-TLMJHMXJ.js.map +0 -1
- package/dist/chunk-TWYNR4TZ.cjs.map +0 -1
- package/dist/chunk-WA7RR64F.js.map +0 -1
- package/dist/chunk-WK4N5VBX.cjs.map +0 -1
- package/dist/components/chat/hooks/use-realtime-chunk-processor.d.ts +0 -6
- package/dist/components/chat/hooks/use-realtime-chunk-processor.d.ts.map +0 -1
- package/dist/components/chat/utils/chunk-parser.d.ts +0 -25
- package/dist/components/chat/utils/chunk-parser.d.ts.map +0 -1
- package/dist/components/ui/rich-markdown-renderer.d.ts +0 -34
- package/dist/components/ui/rich-markdown-renderer.d.ts.map +0 -1
- package/dist/components/ui/simple-markdown-renderer.d.ts +0 -73
- package/dist/components/ui/simple-markdown-renderer.d.ts.map +0 -1
- package/src/components/chat/hooks/.use-realtime-chunk-processor.md +0 -77
- package/src/components/chat/hooks/use-realtime-chunk-processor.ts +0 -489
- package/src/components/chat/utils/.chunk-parser.md +0 -62
- package/src/components/chat/utils/chunk-parser.ts +0 -262
- package/src/components/ui/.simple-markdown-renderer.md +0 -52
- package/src/components/ui/rich-markdown-renderer.tsx +0 -1223
- package/src/components/ui/simple-markdown-renderer.tsx +0 -964
- /package/dist/{chunk-ADPMHWOE.js.map → chunk-72XAID7Y.js.map} +0 -0
- /package/dist/{chunk-PAGKRNWK.js.map → chunk-7WZHBQ4J.js.map} +0 -0
- /package/dist/{chunk-TSZHM74B.js.map → chunk-DC2TKS7C.js.map} +0 -0
- /package/dist/{chunk-I64ABCDX.js.map → chunk-I2B6X77L.js.map} +0 -0
- /package/dist/{chunk-DTYRYB2N.js.map → chunk-K6QTZ7CZ.js.map} +0 -0
- /package/dist/{chunk-4LTMXDUS.js.map → chunk-KBN7PMFT.js.map} +0 -0
- /package/dist/{chunk-TBMV7I5N.js.map → chunk-KXZO2WZX.js.map} +0 -0
- /package/dist/{chunk-KEBYLU3U.js.map → chunk-LFUPI7VX.js.map} +0 -0
- /package/dist/{chunk-WJQHJD7J.js.map → chunk-TWSKTJHW.js.map} +0 -0
- /package/dist/{chunk-TZRUCD56.js.map → chunk-V3ZAAFE3.js.map} +0 -0
- /package/dist/{chunk-LRGHJPET.js.map → chunk-XGIFOHKE.js.map} +0 -0
|
@@ -0,0 +1,146 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,264 @@
|
|
|
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.GUIDE:
|
|
99
|
+
if (typeof data.text === 'string') {
|
|
100
|
+
return { type: 'guide-delta', text: data.text, ...seq }
|
|
101
|
+
}
|
|
102
|
+
return null
|
|
103
|
+
|
|
104
|
+
case MESSAGE_TYPE.EXECUTING_TOOL:
|
|
105
|
+
return {
|
|
106
|
+
type: 'tool-execution',
|
|
107
|
+
data: {
|
|
108
|
+
type: 'EXECUTING_TOOL',
|
|
109
|
+
integratedToolType: data.integratedToolType || '',
|
|
110
|
+
toolFunction: data.toolFunction || '',
|
|
111
|
+
toolTitle: typeof data.title === 'string' ? data.title : undefined,
|
|
112
|
+
// The human-readable "why this tool is running" line rendered inside
|
|
113
|
+
// the tool card. It rides ONLY the EXECUTING chunk — the EXECUTED one
|
|
114
|
+
// never carries it, and the accumulator's merge preserves whatever
|
|
115
|
+
// the EXECUTING segment held. Dropping it here therefore blanks the
|
|
116
|
+
// card for the whole lifetime of the run.
|
|
117
|
+
toolExplanation: typeof data.toolExplanation === 'string' ? data.toolExplanation : undefined,
|
|
118
|
+
parameters: data.parameters,
|
|
119
|
+
toolExecutionRequestId:
|
|
120
|
+
typeof data.toolExecutionRequestId === 'string'
|
|
121
|
+
? data.toolExecutionRequestId
|
|
122
|
+
: undefined,
|
|
123
|
+
},
|
|
124
|
+
...seq,
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
case MESSAGE_TYPE.EXECUTED_TOOL:
|
|
128
|
+
return {
|
|
129
|
+
type: 'tool-execution',
|
|
130
|
+
data: {
|
|
131
|
+
type: 'EXECUTED_TOOL',
|
|
132
|
+
integratedToolType: data.integratedToolType || '',
|
|
133
|
+
toolFunction: data.toolFunction || '',
|
|
134
|
+
toolTitle: typeof data.title === 'string' ? data.title : undefined,
|
|
135
|
+
parameters: data.parameters,
|
|
136
|
+
result: data.result,
|
|
137
|
+
success: data.success,
|
|
138
|
+
toolExecutionRequestId:
|
|
139
|
+
typeof data.toolExecutionRequestId === 'string'
|
|
140
|
+
? data.toolExecutionRequestId
|
|
141
|
+
: undefined,
|
|
142
|
+
},
|
|
143
|
+
...seq,
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
case MESSAGE_TYPE.APPROVAL_REQUEST: {
|
|
147
|
+
const requestId = data.approvalRequestId || data.approval_request_id || ''
|
|
148
|
+
const approvalType = data.approvalType || 'USER'
|
|
149
|
+
const toolCalls = normalizeToolCalls(data.toolCalls)
|
|
150
|
+
|
|
151
|
+
if (toolCalls.length > 0) {
|
|
152
|
+
return { type: 'approval-request', requestId, approvalType, toolCalls, ...seq }
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
return {
|
|
156
|
+
type: 'approval-request',
|
|
157
|
+
requestId,
|
|
158
|
+
approvalType,
|
|
159
|
+
command: data.command || '',
|
|
160
|
+
explanation: data.explanation,
|
|
161
|
+
...seq,
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
case MESSAGE_TYPE.APPROVAL_RESULT: {
|
|
166
|
+
// Realtime chunks carry the resolver's name as `displayName`; the
|
|
167
|
+
// persisted GraphQL message exposes the same value as
|
|
168
|
+
// `resolvedByName`. Accept either so realtime and history-replay
|
|
169
|
+
// render "by {name}" identically.
|
|
170
|
+
const resolvedByName =
|
|
171
|
+
typeof data.resolvedByName === 'string'
|
|
172
|
+
? data.resolvedByName
|
|
173
|
+
: typeof data.displayName === 'string'
|
|
174
|
+
? data.displayName
|
|
175
|
+
: undefined
|
|
176
|
+
return {
|
|
177
|
+
type: 'approval-resolved',
|
|
178
|
+
requestId: data.approvalRequestId || data.approval_request_id || '',
|
|
179
|
+
status: data.approved === true ? 'approved' : 'rejected',
|
|
180
|
+
approvalType: data.approvalType || 'CLIENT',
|
|
181
|
+
resolvedByName,
|
|
182
|
+
...seq,
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
case MESSAGE_TYPE.ERROR:
|
|
187
|
+
return {
|
|
188
|
+
type: 'error',
|
|
189
|
+
title: data.error || 'An error occurred',
|
|
190
|
+
details: data.details,
|
|
191
|
+
...seq,
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
case MESSAGE_TYPE.MESSAGE_REQUEST:
|
|
195
|
+
return {
|
|
196
|
+
type: 'participant',
|
|
197
|
+
kind: 'message-request',
|
|
198
|
+
text: String(data.text || ''),
|
|
199
|
+
ownerType: typeof data.ownerType === 'string' ? data.ownerType : undefined,
|
|
200
|
+
displayName: typeof data.displayName === 'string' ? data.displayName : undefined,
|
|
201
|
+
userId: typeof data.userId === 'string' ? data.userId : undefined,
|
|
202
|
+
// Wire shape carries no label; fall back to the id (parity with
|
|
203
|
+
// the history path in `process-historical-messages.ts`).
|
|
204
|
+
contextItems: Array.isArray(data.contextItems)
|
|
205
|
+
? (data.contextItems as Array<{ type?: unknown; id?: unknown }>)
|
|
206
|
+
.filter((it) => typeof it?.type === 'string' && typeof it?.id === 'string')
|
|
207
|
+
.map((it) => ({
|
|
208
|
+
type: it.type as string,
|
|
209
|
+
id: it.id as string,
|
|
210
|
+
label: it.id as string,
|
|
211
|
+
}))
|
|
212
|
+
: undefined,
|
|
213
|
+
...seq,
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
case MESSAGE_TYPE.TOKEN_USAGE:
|
|
217
|
+
return {
|
|
218
|
+
type: 'token-usage',
|
|
219
|
+
inputTokensSize: data.inputTokensSize ?? 0,
|
|
220
|
+
outputTokensSize: data.outputTokensSize ?? 0,
|
|
221
|
+
totalTokensSize: data.totalTokensSize ?? 0,
|
|
222
|
+
contextSize: data.contextSize ?? 0,
|
|
223
|
+
...seq,
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
case MESSAGE_TYPE.CONTEXT_COMPACTION_START:
|
|
227
|
+
return { type: 'compaction', phase: 'start', ...seq }
|
|
228
|
+
|
|
229
|
+
case MESSAGE_TYPE.CONTEXT_COMPACTION_END:
|
|
230
|
+
return {
|
|
231
|
+
type: 'compaction',
|
|
232
|
+
phase: 'end',
|
|
233
|
+
summary: typeof data.text === 'string' ? data.text : undefined,
|
|
234
|
+
...seq,
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
case MESSAGE_TYPE.SYSTEM:
|
|
238
|
+
if (typeof data.text === 'string') {
|
|
239
|
+
return { type: 'participant', kind: 'system', text: data.text, ...seq }
|
|
240
|
+
}
|
|
241
|
+
return null
|
|
242
|
+
|
|
243
|
+
case MESSAGE_TYPE.DIRECT_MESSAGE:
|
|
244
|
+
if (typeof data.text === 'string') {
|
|
245
|
+
return {
|
|
246
|
+
type: 'participant',
|
|
247
|
+
kind: 'direct-message',
|
|
248
|
+
text: data.text,
|
|
249
|
+
ownerType: typeof data.ownerType === 'string' ? data.ownerType : undefined,
|
|
250
|
+
displayName:
|
|
251
|
+
typeof data.displayName === 'string' ? data.displayName : undefined,
|
|
252
|
+
userId: typeof data.userId === 'string' ? data.userId : undefined,
|
|
253
|
+
...seq,
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
return null
|
|
257
|
+
|
|
258
|
+
case MESSAGE_TYPE.DIALOG_CLOSED:
|
|
259
|
+
return { type: 'dialog-closed', ...seq }
|
|
260
|
+
|
|
261
|
+
default:
|
|
262
|
+
return null
|
|
263
|
+
}
|
|
264
|
+
}
|
|
@@ -1,25 +1,27 @@
|
|
|
1
|
-
<!-- source-hash:
|
|
1
|
+
<!-- source-hash: d9436810c5dd4b6f6250d2c8f91f5212 -->
|
|
2
2
|
Renders a scrollable, auto-anchoring chat message list with streaming indicators, infinite scroll upward pagination, and stick-to-bottom physics for AI chat interfaces.
|
|
3
3
|
|
|
4
4
|
## Key Components
|
|
5
5
|
|
|
6
6
|
- **`ChatMessageList`** — Primary `forwardRef` component wrapping `useStickToBottom` for spring-physics scroll anchoring during token streaming
|
|
7
7
|
- **`STREAMING_WORDS`** — Flamingo-themed cycling phrases displayed alongside the typing/streaming indicator
|
|
8
|
+
- **`BOTTOM_THRESHOLD_PX`** / **`FOLLOW_RETAIN_MS`** — The at-bottom band (mirrors the library's `STICK_TO_BOTTOM_OFFSET_PX`) and the chase window each follow re-assert opens (mirrors its `RETAIN_ANIMATION_DURATION_MS`)
|
|
8
9
|
- **`hasNonEmptyContent`** — Utility predicate that checks whether a `MessageContent` value (string or `MessageSegment[]`) contains visible text
|
|
9
|
-
- **`AnchorWatcher`** / **`disposeAnchorWatcher`** — Interface and idempotent teardown helper for the
|
|
10
|
+
- **`AnchorWatcher`** / **`disposeAnchorWatcher`** — Interface and idempotent teardown helper for the top-anchor `ResizeObserver` settle window
|
|
10
11
|
- **`ChatMessageEnhanced`** — Per-message renderer (avatars, approval variants, entity cards, context icons, mentions)
|
|
11
12
|
- **`ChatMessageListSkeleton`** — Placeholder skeleton shown during initial load
|
|
12
|
-
- **`CyclingPhrase`** —
|
|
13
|
-
- **`DotsLoaderIcon`** — Animated dots icon displayed while `isTyping` is active
|
|
13
|
+
- **`CyclingPhrase`** / **`DotsLoaderIcon`** — The footer-pinned streaming indicator, rendered OUTSIDE the scroller so it can't jitter the thread
|
|
14
14
|
|
|
15
15
|
## Scroll Architecture
|
|
16
16
|
|
|
17
17
|
| Concern | Owner |
|
|
18
18
|
|---|---|
|
|
19
19
|
| Streaming follow (spring) | `useStickToBottom` |
|
|
20
|
+
| Follow INTENT (arm / disarm / re-assert) | `followBottomRef` + growth watch |
|
|
20
21
|
| Dialog change / first load / new user message | Force-scroll `useEffect` |
|
|
21
22
|
| Load-older prepend anchoring | `loadOlderAnchorRef` + geometry delta |
|
|
22
23
|
| Top-sentinel infinite scroll | `sentinelRef` + `IntersectionObserver` |
|
|
24
|
+
| Jump-to-bottom affordance | `atBottom` (measured geometry, NOT the library flag) |
|
|
23
25
|
| Passive demo hard-pin | `pinBottom` prop path |
|
|
24
26
|
|
|
25
27
|
## Usage Example
|
|
@@ -53,5 +55,7 @@ export function MyChatView() {
|
|
|
53
55
|
|
|
54
56
|
- Requires `use-stick-to-bottom` (StackBlitz Labs) — the same library used by Vercel AI SDK's `<Conversation>` and bolt.new
|
|
55
57
|
- `initial: false` disables mount-time auto-scroll; the dialog-change `useEffect` owns first-paint positioning
|
|
56
|
-
-
|
|
57
|
-
- `
|
|
58
|
+
- The follow lock exists because this list's layout silently loses the library's own `isAtBottom` (siblings below the scroller change its box; a card settling out of its skeleton produces a resize-driven scroll the library reads as a gesture). Re-asserts pass `ignoreEscapes` and the library's COALESCING options (`wait: true` + `duration`) so per-frame growth folds into ONE spring instead of spawning competing chains
|
|
59
|
+
- Growth is detected by an rAF poll on the scroller's `scrollHeight`, not only by `ResizeObserver`: the observed content node is a snapshot that React can swap out, after which the observer watches a detached element and streamed tokens grow the thread silently
|
|
60
|
+
- `isLoading` is in the deps of every effect that touches the scroller (follow, load-more sentinel, imperative handle): the loading branch returns the skeleton, so those effects bail on null refs and would otherwise never re-run for hosts whose `isLoading` outlives `hasNextPage` turning true
|
|
61
|
+
- `pinBottom` bypasses smart-follow for scripted demos (Fae/Mingo) where no human controls scroll
|