@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
|
@@ -1,15 +1,28 @@
|
|
|
1
|
-
import { render } from '@testing-library/react'
|
|
1
|
+
import { act, render } from '@testing-library/react'
|
|
2
2
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
|
3
3
|
import type { Message } from '../types/message.types'
|
|
4
4
|
|
|
5
5
|
const scrollToBottom = vi.fn()
|
|
6
6
|
const stopScroll = vi.fn()
|
|
7
7
|
|
|
8
|
+
/**
|
|
9
|
+
* `detachCount` records `ref(null)` invocations. React calls a ref callback with
|
|
10
|
+
* null and then with the element whenever the callback IDENTITY changes, and the
|
|
11
|
+
* real `use-stick-to-bottom` responds to the null by disconnecting its
|
|
12
|
+
* ResizeObserver and dropping its scroll/wheel listeners — so counting detaches
|
|
13
|
+
* is how a test can see the adapter-identity churn that a plain recording ref
|
|
14
|
+
* would silently absorb.
|
|
15
|
+
*/
|
|
8
16
|
function makeRef() {
|
|
9
17
|
const fn = ((el: HTMLElement | null) => {
|
|
18
|
+
if (el === null) (fn as { detachCount: number }).detachCount += 1
|
|
10
19
|
;(fn as { current: HTMLElement | null }).current = el
|
|
11
|
-
}) as ((el: HTMLElement | null) => void) & {
|
|
20
|
+
}) as ((el: HTMLElement | null) => void) & {
|
|
21
|
+
current: HTMLElement | null
|
|
22
|
+
detachCount: number
|
|
23
|
+
}
|
|
12
24
|
fn.current = null
|
|
25
|
+
fn.detachCount = 0
|
|
13
26
|
return fn
|
|
14
27
|
}
|
|
15
28
|
const scrollRefMock = makeRef()
|
|
@@ -26,21 +39,67 @@ vi.mock('use-stick-to-bottom', () => ({
|
|
|
26
39
|
|
|
27
40
|
// Keep the row renderer trivial — these tests exercise ChatMessageList's
|
|
28
41
|
// scroll orchestration, not markdown rendering.
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
42
|
+
// Forwards the ref because the list registers each row element for the
|
|
43
|
+
// per-message `scrollAnchor: 'top'` path.
|
|
44
|
+
vi.mock('../chat-message-enhanced', async () => {
|
|
45
|
+
const { forwardRef } = await import('react')
|
|
46
|
+
return {
|
|
47
|
+
ChatMessageEnhanced: forwardRef<HTMLDivElement, { content: string }>(({ content }, ref) => (
|
|
48
|
+
<div ref={ref}>{content}</div>
|
|
49
|
+
)),
|
|
50
|
+
}
|
|
51
|
+
})
|
|
32
52
|
|
|
33
53
|
import { ChatMessageList } from '../chat-message-list'
|
|
34
54
|
|
|
35
|
-
// jsdom ships neither observer; the component instantiates both
|
|
36
|
-
//
|
|
55
|
+
// jsdom ships neither observer; the component instantiates both (load-more
|
|
56
|
+
// sentinel via IO, bottom-follow + top-anchor settle watcher via RO).
|
|
57
|
+
/** Recording IO so the load-more tests can see whether the sentinel is
|
|
58
|
+
* actually being watched — the difference between "pagination is wired" and
|
|
59
|
+
* "the effect ran once against a null ref and never came back". */
|
|
60
|
+
const intersectionObservers: Array<ObserverStub> = []
|
|
37
61
|
class ObserverStub {
|
|
38
|
-
|
|
62
|
+
observed: Element[] = []
|
|
63
|
+
constructor() {
|
|
64
|
+
intersectionObservers.push(this)
|
|
65
|
+
}
|
|
66
|
+
observe(el: Element) {
|
|
67
|
+
this.observed.push(el)
|
|
68
|
+
}
|
|
39
69
|
unobserve() {}
|
|
40
70
|
disconnect() {}
|
|
41
71
|
}
|
|
42
72
|
vi.stubGlobal('IntersectionObserver', ObserverStub)
|
|
43
|
-
|
|
73
|
+
|
|
74
|
+
// Recording RO so the bottom-follow tests can drive a resize by hand.
|
|
75
|
+
const resizeCallbacks: Array<() => void> = []
|
|
76
|
+
class RecordingResizeObserver {
|
|
77
|
+
constructor(cb: () => void) {
|
|
78
|
+
resizeCallbacks.push(cb)
|
|
79
|
+
}
|
|
80
|
+
observe() {}
|
|
81
|
+
unobserve() {}
|
|
82
|
+
disconnect() {}
|
|
83
|
+
}
|
|
84
|
+
vi.stubGlobal('ResizeObserver', RecordingResizeObserver)
|
|
85
|
+
/** Fire every live ResizeObserver callback — same effect as any content
|
|
86
|
+
* or scroller-box size change in the real DOM. */
|
|
87
|
+
const fireResize = () => {
|
|
88
|
+
for (const cb of resizeCallbacks) cb()
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** jsdom has no layout, so every box metric reads 0. The jump-to-bottom
|
|
92
|
+
* button is driven off real geometry, so the tests supply it. */
|
|
93
|
+
const setGeometry = (geo: { scrollHeight: number; clientHeight: number; scrollTop: number }) => {
|
|
94
|
+
const el = scrollRefMock.current
|
|
95
|
+
if (!el) throw new Error('scroller not mounted')
|
|
96
|
+
for (const [key, value] of Object.entries(geo)) {
|
|
97
|
+
Object.defineProperty(el, key, { value, configurable: true, writable: true })
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// jsdom has no layout, so `scrollIntoView` is undefined.
|
|
102
|
+
Element.prototype.scrollIntoView = vi.fn()
|
|
44
103
|
|
|
45
104
|
const msg = (id: string, role: Message['role']): Message => ({
|
|
46
105
|
id,
|
|
@@ -90,3 +149,385 @@ describe('ChatMessageList force-scroll decisions', () => {
|
|
|
90
149
|
expect(scrollToBottom).not.toHaveBeenCalled()
|
|
91
150
|
})
|
|
92
151
|
})
|
|
152
|
+
|
|
153
|
+
// The library's own `isAtBottom` lock is lost silently when the scroller's
|
|
154
|
+
// box changes (source chips mounting below it) or when a card settling out
|
|
155
|
+
// of its skeleton produces a resize-driven scroll the library reads as a
|
|
156
|
+
// user gesture. The list therefore owns the INTENT and re-asserts on every
|
|
157
|
+
// geometry change until a real gesture releases it.
|
|
158
|
+
describe('ChatMessageList bottom-follow intent', () => {
|
|
159
|
+
beforeEach(() => {
|
|
160
|
+
scrollToBottom.mockClear()
|
|
161
|
+
resizeCallbacks.length = 0
|
|
162
|
+
})
|
|
163
|
+
|
|
164
|
+
const sendTurn = () => {
|
|
165
|
+
const initial = [msg('m1', 'user'), msg('m2', 'assistant')]
|
|
166
|
+
const view = render(<ChatMessageList dialogId="d1" messages={initial} />)
|
|
167
|
+
// A user send arms the follow intent for the whole turn.
|
|
168
|
+
view.rerender(<ChatMessageList dialogId="d1" messages={[...initial, msg('m3', 'user')]} />)
|
|
169
|
+
scrollToBottom.mockClear()
|
|
170
|
+
return view
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
it('re-asserts the bottom on geometry changes after the send', () => {
|
|
174
|
+
sendTurn()
|
|
175
|
+
fireResize()
|
|
176
|
+
expect(scrollToBottom).toHaveBeenCalled()
|
|
177
|
+
})
|
|
178
|
+
|
|
179
|
+
it('re-asserts through the library COALESCING contract, not a fresh chain', () => {
|
|
180
|
+
// `wait: true` is what lets `scrollToBottom` find the animation already in
|
|
181
|
+
// flight and return its promise. Drop it and the library clears
|
|
182
|
+
// `state.animation` on entry, so each of these per-frame calls starts an
|
|
183
|
+
// independent spring — and they all step the same shared velocity, which is
|
|
184
|
+
// what turned a smooth follow into a fight. `duration` opens the window in
|
|
185
|
+
// which that one chain keeps re-reading the (still growing) target.
|
|
186
|
+
sendTurn()
|
|
187
|
+
fireResize()
|
|
188
|
+
expect(scrollToBottom).toHaveBeenCalledWith(
|
|
189
|
+
expect.objectContaining({ wait: true, ignoreEscapes: true, duration: expect.any(Number) }),
|
|
190
|
+
)
|
|
191
|
+
})
|
|
192
|
+
|
|
193
|
+
it('keeps re-asserting for late async growth (cards + covers resolving)', () => {
|
|
194
|
+
sendTurn()
|
|
195
|
+
fireResize()
|
|
196
|
+
fireResize()
|
|
197
|
+
fireResize()
|
|
198
|
+
expect(scrollToBottom).toHaveBeenCalledTimes(3)
|
|
199
|
+
})
|
|
200
|
+
|
|
201
|
+
it('releases the intent once the user scrolls up', () => {
|
|
202
|
+
sendTurn()
|
|
203
|
+
scrollRefMock.current?.dispatchEvent(
|
|
204
|
+
new WheelEvent('wheel', { deltaY: -120, bubbles: true }),
|
|
205
|
+
)
|
|
206
|
+
fireResize()
|
|
207
|
+
expect(scrollToBottom).not.toHaveBeenCalled()
|
|
208
|
+
})
|
|
209
|
+
|
|
210
|
+
it('ignores downward wheel (never an escape from the bottom)', () => {
|
|
211
|
+
sendTurn()
|
|
212
|
+
scrollRefMock.current?.dispatchEvent(
|
|
213
|
+
new WheelEvent('wheel', { deltaY: 120, bubbles: true }),
|
|
214
|
+
)
|
|
215
|
+
fireResize()
|
|
216
|
+
expect(scrollToBottom).toHaveBeenCalled()
|
|
217
|
+
})
|
|
218
|
+
|
|
219
|
+
it('does not follow when autoScroll is off (passive demo hosts)', () => {
|
|
220
|
+
const initial = [msg('m1', 'user'), msg('m2', 'assistant')]
|
|
221
|
+
const { rerender } = render(
|
|
222
|
+
<ChatMessageList dialogId="d1" messages={initial} autoScroll={false} />,
|
|
223
|
+
)
|
|
224
|
+
rerender(
|
|
225
|
+
<ChatMessageList dialogId="d1" messages={[...initial, msg('m3', 'user')]} autoScroll={false} />,
|
|
226
|
+
)
|
|
227
|
+
scrollToBottom.mockClear()
|
|
228
|
+
fireResize()
|
|
229
|
+
expect(scrollToBottom).not.toHaveBeenCalled()
|
|
230
|
+
})
|
|
231
|
+
|
|
232
|
+
it('hides jump-to-bottom while the thread is at the bottom', () => {
|
|
233
|
+
const view = sendTurn()
|
|
234
|
+
setGeometry({ scrollHeight: 1000, clientHeight: 500, scrollTop: 500 })
|
|
235
|
+
act(() => fireResize())
|
|
236
|
+
expect(view.queryByLabelText('Scroll to latest message')).toBeNull()
|
|
237
|
+
})
|
|
238
|
+
|
|
239
|
+
it('shows jump-to-bottom once the thread is away from the bottom', () => {
|
|
240
|
+
const view = sendTurn()
|
|
241
|
+
setGeometry({ scrollHeight: 20000, clientHeight: 500, scrollTop: 500 })
|
|
242
|
+
act(() => fireResize())
|
|
243
|
+
expect(view.queryByLabelText('Scroll to latest message')).not.toBeNull()
|
|
244
|
+
})
|
|
245
|
+
|
|
246
|
+
it('re-arms the intent when the user clicks jump-to-bottom', () => {
|
|
247
|
+
const view = sendTurn()
|
|
248
|
+
// User scrolls away: intent released, button appears.
|
|
249
|
+
scrollRefMock.current?.dispatchEvent(
|
|
250
|
+
new WheelEvent('wheel', { deltaY: -120, bubbles: true }),
|
|
251
|
+
)
|
|
252
|
+
setGeometry({ scrollHeight: 20000, clientHeight: 500, scrollTop: 500 })
|
|
253
|
+
act(() => fireResize())
|
|
254
|
+
expect(scrollToBottom).not.toHaveBeenCalled()
|
|
255
|
+
|
|
256
|
+
const button = view.getByLabelText('Scroll to latest message')
|
|
257
|
+
act(() => {
|
|
258
|
+
button.click()
|
|
259
|
+
})
|
|
260
|
+
expect(scrollToBottom).toHaveBeenCalled()
|
|
261
|
+
|
|
262
|
+
// …and the click re-armed the lock, so growth follows again.
|
|
263
|
+
scrollToBottom.mockClear()
|
|
264
|
+
act(() => fireResize())
|
|
265
|
+
expect(scrollToBottom).toHaveBeenCalled()
|
|
266
|
+
})
|
|
267
|
+
|
|
268
|
+
// A drag that ends OUTSIDE the window (button released off-screen, or the
|
|
269
|
+
// tab losing focus mid-drag) never delivers `pointerup`, so a naive
|
|
270
|
+
// `pointerDown` flag stays true forever. Afterwards the very thing this
|
|
271
|
+
// component exists for — a fetch-mode card settling from skeleton to a
|
|
272
|
+
// SHORTER height, which clamps scrollTop down — is misread as a scroll-up
|
|
273
|
+
// gesture and silently drops the lock.
|
|
274
|
+
const dragThenScrollDown = (endEvent: { target: 'window'; type: string }) => {
|
|
275
|
+
sendTurn()
|
|
276
|
+
// Park away from the bottom so the at-bottom re-arm below can't mask the
|
|
277
|
+
// result, and seed `lastScrollTop`.
|
|
278
|
+
setGeometry({ scrollHeight: 20000, clientHeight: 500, scrollTop: 4000 })
|
|
279
|
+
window.dispatchEvent(new Event('pointerdown'))
|
|
280
|
+
act(() => {
|
|
281
|
+
scrollRefMock.current?.dispatchEvent(new Event('scroll'))
|
|
282
|
+
})
|
|
283
|
+
// Drag ends where we can't see it.
|
|
284
|
+
if (endEvent.target === 'window') window.dispatchEvent(new Event(endEvent.type))
|
|
285
|
+
// Resize clamp: content shrank, scrollTop drops. NOT a gesture.
|
|
286
|
+
setGeometry({ scrollHeight: 20000, clientHeight: 500, scrollTop: 3800 })
|
|
287
|
+
act(() => {
|
|
288
|
+
scrollRefMock.current?.dispatchEvent(new Event('scroll'))
|
|
289
|
+
})
|
|
290
|
+
scrollToBottom.mockClear()
|
|
291
|
+
act(() => fireResize())
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
it('keeps the intent when a pointercancel ended the drag (resize clamp is not a gesture)', () => {
|
|
295
|
+
dragThenScrollDown({ target: 'window', type: 'pointercancel' })
|
|
296
|
+
expect(scrollToBottom).toHaveBeenCalled()
|
|
297
|
+
})
|
|
298
|
+
|
|
299
|
+
it('keeps the intent when the window lost focus mid-drag', () => {
|
|
300
|
+
dragThenScrollDown({ target: 'window', type: 'blur' })
|
|
301
|
+
expect(scrollToBottom).toHaveBeenCalled()
|
|
302
|
+
})
|
|
303
|
+
|
|
304
|
+
// Focus events do not bubble but they DO capture, so a capture-phase `blur`
|
|
305
|
+
// listener on `window` fires for EVERY element blur in the document — the
|
|
306
|
+
// classic focus-delegation idiom. Registering the drag-end `blur` that way
|
|
307
|
+
// made any focus change during a pointer hold (most commonly the chat input
|
|
308
|
+
// blurring as the user presses down on the scrollbar) clear `pointerDown`,
|
|
309
|
+
// which is exactly what re-opens the yank-back-mid-drag bug. Only a TRUE
|
|
310
|
+
// window blur (which targets `window` itself) may end the drag.
|
|
311
|
+
it('a descendant element losing focus does not end the drag', () => {
|
|
312
|
+
sendTurn()
|
|
313
|
+
setGeometry({ scrollHeight: 20000, clientHeight: 500, scrollTop: 4000 })
|
|
314
|
+
window.dispatchEvent(new Event('pointerdown'))
|
|
315
|
+
act(() => {
|
|
316
|
+
scrollRefMock.current?.dispatchEvent(new Event('scroll'))
|
|
317
|
+
})
|
|
318
|
+
// The chat input loses focus as the pointer goes down elsewhere.
|
|
319
|
+
const input = document.createElement('input')
|
|
320
|
+
document.body.appendChild(input)
|
|
321
|
+
input.focus()
|
|
322
|
+
input.blur()
|
|
323
|
+
input.remove()
|
|
324
|
+
// The drag is still live, so this held scroll-up IS a gesture.
|
|
325
|
+
setGeometry({ scrollHeight: 20000, clientHeight: 500, scrollTop: 3800 })
|
|
326
|
+
act(() => {
|
|
327
|
+
scrollRefMock.current?.dispatchEvent(new Event('scroll'))
|
|
328
|
+
})
|
|
329
|
+
scrollToBottom.mockClear()
|
|
330
|
+
act(() => fireResize())
|
|
331
|
+
expect(scrollToBottom).not.toHaveBeenCalled()
|
|
332
|
+
window.dispatchEvent(new Event('pointerup'))
|
|
333
|
+
})
|
|
334
|
+
|
|
335
|
+
it('still disarms on a genuine scrollbar drag upward', () => {
|
|
336
|
+
sendTurn()
|
|
337
|
+
setGeometry({ scrollHeight: 20000, clientHeight: 500, scrollTop: 4000 })
|
|
338
|
+
window.dispatchEvent(new Event('pointerdown'))
|
|
339
|
+
act(() => {
|
|
340
|
+
scrollRefMock.current?.dispatchEvent(new Event('scroll'))
|
|
341
|
+
})
|
|
342
|
+
setGeometry({ scrollHeight: 20000, clientHeight: 500, scrollTop: 3800 })
|
|
343
|
+
act(() => {
|
|
344
|
+
scrollRefMock.current?.dispatchEvent(new Event('scroll'))
|
|
345
|
+
})
|
|
346
|
+
scrollToBottom.mockClear()
|
|
347
|
+
act(() => fireResize())
|
|
348
|
+
expect(scrollToBottom).not.toHaveBeenCalled()
|
|
349
|
+
window.dispatchEvent(new Event('pointerup'))
|
|
350
|
+
})
|
|
351
|
+
|
|
352
|
+
// Scrolling back down to the live end by hand expresses exactly the intent
|
|
353
|
+
// the jump-to-bottom button does — the lock has to re-arm, or the thread
|
|
354
|
+
// renders at-bottom (button hidden) and then silently drifts away.
|
|
355
|
+
it('re-arms the intent when the user scrolls back to the bottom by hand', () => {
|
|
356
|
+
sendTurn()
|
|
357
|
+
scrollRefMock.current?.dispatchEvent(new WheelEvent('wheel', { deltaY: -120, bubbles: true }))
|
|
358
|
+
setGeometry({ scrollHeight: 20000, clientHeight: 500, scrollTop: 500 })
|
|
359
|
+
act(() => fireResize())
|
|
360
|
+
expect(scrollToBottom).not.toHaveBeenCalled()
|
|
361
|
+
|
|
362
|
+
// …and back down to the end.
|
|
363
|
+
setGeometry({ scrollHeight: 20000, clientHeight: 500, scrollTop: 19500 })
|
|
364
|
+
act(() => {
|
|
365
|
+
scrollRefMock.current?.dispatchEvent(new Event('scroll'))
|
|
366
|
+
})
|
|
367
|
+
scrollToBottom.mockClear()
|
|
368
|
+
act(() => fireResize())
|
|
369
|
+
expect(scrollToBottom).toHaveBeenCalled()
|
|
370
|
+
})
|
|
371
|
+
|
|
372
|
+
it('does not re-arm while the user is still parked above the bottom', () => {
|
|
373
|
+
sendTurn()
|
|
374
|
+
scrollRefMock.current?.dispatchEvent(new WheelEvent('wheel', { deltaY: -120, bubbles: true }))
|
|
375
|
+
setGeometry({ scrollHeight: 20000, clientHeight: 500, scrollTop: 8000 })
|
|
376
|
+
act(() => {
|
|
377
|
+
scrollRefMock.current?.dispatchEvent(new Event('scroll'))
|
|
378
|
+
})
|
|
379
|
+
scrollToBottom.mockClear()
|
|
380
|
+
act(() => fireResize())
|
|
381
|
+
expect(scrollToBottom).not.toHaveBeenCalled()
|
|
382
|
+
})
|
|
383
|
+
|
|
384
|
+
// A short scrollbar drag that starts at the live end and stays inside the
|
|
385
|
+
// 70px band disarms on the gesture and would immediately re-arm on the very
|
|
386
|
+
// same scroll event if the re-arm didn't wait for the drag to END — yanking
|
|
387
|
+
// the reader back down mid-drag, a miniature of the bug this block exists
|
|
388
|
+
// to prevent.
|
|
389
|
+
it('does not re-arm mid-drag when the drag stays inside the bottom band', () => {
|
|
390
|
+
sendTurn()
|
|
391
|
+
setGeometry({ scrollHeight: 20000, clientHeight: 500, scrollTop: 19500 })
|
|
392
|
+
act(() => {
|
|
393
|
+
scrollRefMock.current?.dispatchEvent(new Event('scroll'))
|
|
394
|
+
})
|
|
395
|
+
window.dispatchEvent(new Event('pointerdown'))
|
|
396
|
+
setGeometry({ scrollHeight: 20000, clientHeight: 500, scrollTop: 19460 })
|
|
397
|
+
act(() => {
|
|
398
|
+
scrollRefMock.current?.dispatchEvent(new Event('scroll'))
|
|
399
|
+
})
|
|
400
|
+
scrollToBottom.mockClear()
|
|
401
|
+
act(() => fireResize())
|
|
402
|
+
expect(scrollToBottom).not.toHaveBeenCalled()
|
|
403
|
+
window.dispatchEvent(new Event('pointerup'))
|
|
404
|
+
})
|
|
405
|
+
|
|
406
|
+
it('releases the intent for a top-anchored turn (it parks at the message top)', () => {
|
|
407
|
+
const initial = [msg('m1', 'user'), msg('m2', 'assistant')]
|
|
408
|
+
const { rerender } = render(<ChatMessageList dialogId="d1" messages={initial} />)
|
|
409
|
+
const sent = [...initial, msg('m3', 'user')]
|
|
410
|
+
rerender(<ChatMessageList dialogId="d1" messages={sent} />)
|
|
411
|
+
const anchored: Message = { ...msg('m4', 'assistant'), scrollAnchor: 'top' }
|
|
412
|
+
rerender(<ChatMessageList dialogId="d1" messages={[...sent, anchored]} />)
|
|
413
|
+
scrollToBottom.mockClear()
|
|
414
|
+
fireResize()
|
|
415
|
+
expect(scrollToBottom).not.toHaveBeenCalled()
|
|
416
|
+
})
|
|
417
|
+
})
|
|
418
|
+
|
|
419
|
+
/**
|
|
420
|
+
* The whole bottom-follow subsystem — ResizeObserver, wheel/touch/keyboard
|
|
421
|
+
* escape hatches, the jump-to-bottom button — is installed by ONE effect whose
|
|
422
|
+
* deps were all identity-stable (`autoScroll` plus three `[]`-memoized values
|
|
423
|
+
* from `useStickToBottom`). `isLoading` was missing, and on the loading branch
|
|
424
|
+
* the component returns the skeleton, so `scrollRef.current` is null and the
|
|
425
|
+
* effect bails.
|
|
426
|
+
*
|
|
427
|
+
* Net result for any consumer following this repo's own prescribed
|
|
428
|
+
* "skeleton during fetch" pattern: the effect ran once against null refs and
|
|
429
|
+
* NEVER re-ran, so nothing was ever observed, `atBottom` stayed at its initial
|
|
430
|
+
* `true`, the WCAG escape hatch could not render, and the follow lock was never
|
|
431
|
+
* re-asserted — while the observers/listeners bound on a later round trip kept
|
|
432
|
+
* detached nodes alive.
|
|
433
|
+
*/
|
|
434
|
+
describe('ChatMessageList bottom-follow survives the loading skeleton', () => {
|
|
435
|
+
beforeEach(() => {
|
|
436
|
+
scrollToBottom.mockClear()
|
|
437
|
+
resizeCallbacks.length = 0
|
|
438
|
+
})
|
|
439
|
+
|
|
440
|
+
it('installs the follow observers when the skeleton is replaced by the list', () => {
|
|
441
|
+
const messages = [msg('m1', 'user'), msg('m2', 'assistant')]
|
|
442
|
+
const { rerender } = render(
|
|
443
|
+
<ChatMessageList dialogId="d1" messages={messages} isLoading />,
|
|
444
|
+
)
|
|
445
|
+
// Nothing to observe while the skeleton is up.
|
|
446
|
+
expect(resizeCallbacks).toHaveLength(0)
|
|
447
|
+
|
|
448
|
+
rerender(<ChatMessageList dialogId="d1" messages={messages} />)
|
|
449
|
+
expect(resizeCallbacks.length).toBeGreaterThan(0)
|
|
450
|
+
})
|
|
451
|
+
|
|
452
|
+
it('shows the jump-to-bottom escape hatch after a loading round trip', () => {
|
|
453
|
+
const messages = [msg('m1', 'user'), msg('m2', 'assistant')]
|
|
454
|
+
const view = render(<ChatMessageList dialogId="d1" messages={messages} />)
|
|
455
|
+
// A host loading another page / another dialog swaps the scroller out…
|
|
456
|
+
view.rerender(<ChatMessageList dialogId="d1" messages={messages} isLoading />)
|
|
457
|
+
view.rerender(<ChatMessageList dialogId="d1" messages={messages} />)
|
|
458
|
+
|
|
459
|
+
// …and the list must still notice it is scrolled away from the bottom.
|
|
460
|
+
setGeometry({ scrollHeight: 20000, clientHeight: 500, scrollTop: 0 })
|
|
461
|
+
act(() => {
|
|
462
|
+
scrollRefMock.current?.dispatchEvent(new Event('scroll'))
|
|
463
|
+
})
|
|
464
|
+
expect(view.queryByLabelText('Scroll to latest message')).not.toBeNull()
|
|
465
|
+
})
|
|
466
|
+
|
|
467
|
+
it('keeps the ref adapters identity-stable across re-renders', () => {
|
|
468
|
+
// The adapters are handed to `useStickToBottom`'s ref callbacks, and the
|
|
469
|
+
// library disconnects its ResizeObserver on `ref(null)` and builds a NEW
|
|
470
|
+
// one on re-attach. A fresh arrow per render therefore churned an observer
|
|
471
|
+
// (and two scroll listeners) on every committed frame — ~60/s while
|
|
472
|
+
// streaming — and reset the library's `previousHeight`, which is what it
|
|
473
|
+
// uses to choose between its `initial` and `resize` animations.
|
|
474
|
+
const messages = [msg('m1', 'user'), msg('m2', 'assistant')]
|
|
475
|
+
const view = render(<ChatMessageList dialogId="d1" messages={messages} />)
|
|
476
|
+
const detachesAfterMount = {
|
|
477
|
+
scroll: scrollRefMock.detachCount,
|
|
478
|
+
content: contentRefMock.detachCount,
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
for (let i = 0; i < 5; i++) {
|
|
482
|
+
view.rerender(<ChatMessageList dialogId="d1" messages={[...messages]} />)
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
// A memoized adapter is never re-invoked with null, so the library keeps its
|
|
486
|
+
// observer and listeners for the whole mount.
|
|
487
|
+
expect(scrollRefMock.detachCount).toBe(detachesAfterMount.scroll)
|
|
488
|
+
expect(contentRefMock.detachCount).toBe(detachesAfterMount.content)
|
|
489
|
+
expect(scrollRefMock.current).not.toBeNull()
|
|
490
|
+
})
|
|
491
|
+
})
|
|
492
|
+
|
|
493
|
+
describe('load-more sentinel (infinite scroll UP)', () => {
|
|
494
|
+
beforeEach(() => {
|
|
495
|
+
intersectionObservers.length = 0
|
|
496
|
+
})
|
|
497
|
+
|
|
498
|
+
it('watches the sentinel once the skeleton is replaced by the thread', () => {
|
|
499
|
+
// The shape a COMPOSITE `isLoading` produces: the messages query already
|
|
500
|
+
// knows an older page exists (`hasNextPage`), while an unrelated query
|
|
501
|
+
// (Mingo's dialog resolution, the ticket view's dialog fetch) keeps the
|
|
502
|
+
// list on its skeleton branch — where neither the scroller nor the
|
|
503
|
+
// sentinel is mounted, so the effect can only bail.
|
|
504
|
+
const messages = [msg('m1', 'user'), msg('m2', 'assistant')]
|
|
505
|
+
const { rerender } = render(
|
|
506
|
+
<ChatMessageList dialogId="d1" messages={messages} hasNextPage onLoadMore={() => {}} isLoading />,
|
|
507
|
+
)
|
|
508
|
+
expect(intersectionObservers).toHaveLength(0)
|
|
509
|
+
|
|
510
|
+
// Loading clears. With `isLoading` missing from the effect's deps NOTHING
|
|
511
|
+
// re-ran it here — no observer was ever created and paging up was dead for
|
|
512
|
+
// the life of the component.
|
|
513
|
+
rerender(<ChatMessageList dialogId="d1" messages={messages} hasNextPage onLoadMore={() => {}} />)
|
|
514
|
+
|
|
515
|
+
expect(intersectionObservers).toHaveLength(1)
|
|
516
|
+
expect(intersectionObservers[0]?.observed).toHaveLength(1)
|
|
517
|
+
})
|
|
518
|
+
|
|
519
|
+
it('re-observes after a page lands so a still-visible sentinel fires again', () => {
|
|
520
|
+
// IntersectionObserver emits nothing while an element stays continuously
|
|
521
|
+
// intersecting, so a small prepend that leaves the sentinel in view has to
|
|
522
|
+
// be re-observed or the load chain stops one page in.
|
|
523
|
+
const messages = [msg('m1', 'user'), msg('m2', 'assistant')]
|
|
524
|
+
const { rerender } = render(
|
|
525
|
+
<ChatMessageList dialogId="d1" messages={messages} hasNextPage onLoadMore={() => {}} isFetchingNextPage />,
|
|
526
|
+
)
|
|
527
|
+
const duringFetch = intersectionObservers.length
|
|
528
|
+
|
|
529
|
+
rerender(<ChatMessageList dialogId="d1" messages={messages} hasNextPage onLoadMore={() => {}} />)
|
|
530
|
+
|
|
531
|
+
expect(intersectionObservers.length).toBeGreaterThan(duringFetch)
|
|
532
|
+
})
|
|
533
|
+
})
|