@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,536 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* createChatDialogStore — framework-free registry of `ChatStreamReducer`
|
|
3
|
+
* instances keyed by `(dialogId, side)`. Reducer state PERSISTS across
|
|
4
|
+
* component unmounts (the store outlives React), and the store is the
|
|
5
|
+
* `useSyncExternalStore` source for the `useChatStreamReducer` wrapper.
|
|
6
|
+
*
|
|
7
|
+
* CROSS-SIDE PROJECTION: exactly two operations fan out across sides of the
|
|
8
|
+
* same dialogId —
|
|
9
|
+
* 1. approval RESOLUTION by requestId (`approval-resolved` events), and
|
|
10
|
+
* 2. tool-execution MERGE by execId (`tool-execution` events)
|
|
11
|
+
* — implemented as idempotent pure projections over the other side's state
|
|
12
|
+
* (recomputed from state, never re-injected into the other side's seq
|
|
13
|
+
* stream; per-side seq gates stay untouched). Everything else — text,
|
|
14
|
+
* thinking, participant rows — is strictly per-side.
|
|
15
|
+
*
|
|
16
|
+
* LIFECYCLE / MEMORY: the reducer map is LRU-capped (`maxReducers`, default
|
|
17
|
+
* `DEFAULT_MAX_REDUCERS`) and evicted through the same path as `remove()`.
|
|
18
|
+
* Without a cap an agent cycling hundreds of dialogs would retain a reducer
|
|
19
|
+
* (thread + accumulator) per dialog for the process's lifetime; under SSR,
|
|
20
|
+
* where the store is module-scoped, that growth is shared across every
|
|
21
|
+
* request. `getSnapshot` is PURE — it never creates an entry — and
|
|
22
|
+
* `getServerSnapshot` always returns the shared frozen `EMPTY_STATE`.
|
|
23
|
+
*
|
|
24
|
+
* EVICTION SAFETY (LRU recency alone is NOT liveness):
|
|
25
|
+
* 1. RETAINED keys are never evicted. `retain(dialogId, side)` returns a
|
|
26
|
+
* release fn; the `useChatStreamReducer` hook calls it for as long as it
|
|
27
|
+
* is mounted, so a quiet-but-visible panel can't be dropped just because
|
|
28
|
+
* ten other keys were touched. NON-React hosts must call it too.
|
|
29
|
+
* Because the hook retains in an EFFECT but takes its reducer during
|
|
30
|
+
* RENDER, a key created by a public `getReducer` is protected from
|
|
31
|
+
* creation until its FIRST `retain()` (bounded by
|
|
32
|
+
* `MAX_PENDING_RETAIN_KEYS`) — see `pendingRetain`.
|
|
33
|
+
* 2. A reducer whose `streamingPhase !== 'idle'` is never evicted — dropping
|
|
34
|
+
* mid-turn would recreate an EMPTY reducer on the next `getReducer`
|
|
35
|
+
* while the host's seq cursor still says "caught up", i.e. the thread
|
|
36
|
+
* visually vanishes with no path back.
|
|
37
|
+
* 3. Eviction hands the host the dropped instance's PARKED state
|
|
38
|
+
* (`onEvict(..., { messages, approvalStatuses, lastAppliedSeq,
|
|
39
|
+
* pendingEchoes })`). A recreated reducer is pristine, so re-seeding
|
|
40
|
+
* messages alone would
|
|
41
|
+
* resurrect resolved approvals as actionable and reset the seq gate to
|
|
42
|
+
* `-Infinity`; the parked payload is what makes the round-trip lossless.
|
|
43
|
+
* 4. Eviction NEVER notifies synchronously. `getReducer` is called during
|
|
44
|
+
* render (by the hook), and notifying there means updating other
|
|
45
|
+
* components mid-render. The notify is deferred to a microtask.
|
|
46
|
+
*/
|
|
47
|
+
|
|
48
|
+
import {
|
|
49
|
+
createChatStreamReducer,
|
|
50
|
+
type ChatReducerState,
|
|
51
|
+
type ChatStreamReducer,
|
|
52
|
+
type ChatStreamReducerOptions,
|
|
53
|
+
type PendingEcho,
|
|
54
|
+
} from './chat-stream-reducer'
|
|
55
|
+
import type { ChatStreamEvent } from '../../../chat-protocol/events'
|
|
56
|
+
|
|
57
|
+
export type ChatDialogSide = string
|
|
58
|
+
|
|
59
|
+
/** Factory for a key's reducer options, consulted ONCE at creation. A plain
|
|
60
|
+
* `() => options` thunk remains assignable. */
|
|
61
|
+
export type CreateReducerOptionsFn = (
|
|
62
|
+
dialogId: string,
|
|
63
|
+
side: ChatDialogSide,
|
|
64
|
+
) => ChatStreamReducerOptions
|
|
65
|
+
|
|
66
|
+
export interface ChatDialogStore {
|
|
67
|
+
/** Create-or-get the reducer for `(dialogId, side)`. `createOptions` is
|
|
68
|
+
* consulted ONLY when the instance is first created. */
|
|
69
|
+
getReducer(
|
|
70
|
+
dialogId: string,
|
|
71
|
+
side?: ChatDialogSide,
|
|
72
|
+
createOptions?: CreateReducerOptionsFn,
|
|
73
|
+
): ChatStreamReducer
|
|
74
|
+
/** Apply one event to a side, then fan out the cross-side projections to
|
|
75
|
+
* the other sides of the same dialog. */
|
|
76
|
+
apply(dialogId: string, side: ChatDialogSide, event: ChatStreamEvent): void
|
|
77
|
+
/** Run adapter commands against a reducer; subscribers are notified. */
|
|
78
|
+
mutate<T>(dialogId: string, side: ChatDialogSide, fn: (reducer: ChatStreamReducer) => T): T
|
|
79
|
+
subscribe(listener: () => void): () => void
|
|
80
|
+
/**
|
|
81
|
+
* Referentially-stable snapshot for `useSyncExternalStore`. PURE — an
|
|
82
|
+
* unknown key returns the shared frozen `EMPTY_STATE` rather than
|
|
83
|
+
* creating a reducer (a create-on-read would let SSR renders accumulate
|
|
84
|
+
* reducers in a process-global map, and would make React's snapshot read
|
|
85
|
+
* a mutation).
|
|
86
|
+
*/
|
|
87
|
+
getSnapshot(dialogId: string, side?: ChatDialogSide): ChatReducerState
|
|
88
|
+
/** `useSyncExternalStore`'s server getter — always `EMPTY_STATE`. There is
|
|
89
|
+
* no per-request store on the server, so a live thread cannot be rendered
|
|
90
|
+
* server-side; the client hydrates from the real reducer. */
|
|
91
|
+
getServerSnapshot(): ChatReducerState
|
|
92
|
+
/** Drop a side's reducer (or every side of the dialog when omitted). */
|
|
93
|
+
remove(dialogId: string, side?: ChatDialogSide): void
|
|
94
|
+
/**
|
|
95
|
+
* PIN `(dialogId, side)` against LRU eviction while a consumer is live.
|
|
96
|
+
* Returns the release fn (idempotent; refcounted, so nested retains are
|
|
97
|
+
* safe). Creation is NOT implied — retaining an absent key simply means the
|
|
98
|
+
* key is protected once it exists.
|
|
99
|
+
*/
|
|
100
|
+
retain(dialogId: string, side?: ChatDialogSide): () => void
|
|
101
|
+
/**
|
|
102
|
+
* POLICY retain: keep exactly `keys` pinned by this store-level set and
|
|
103
|
+
* release every key the set previously pinned. Independent of the refcounted
|
|
104
|
+
* `retain()` handles a host's components hold — the two compose.
|
|
105
|
+
*
|
|
106
|
+
* Exists because "retain this set, release the rest" is generic store
|
|
107
|
+
* policy that every multi-panel host otherwise re-derives (diffing its own
|
|
108
|
+
* key list against a map of release fns). Retains BEFORE releasing, so a key
|
|
109
|
+
* present in both the old and the new set never momentarily drops to zero
|
|
110
|
+
* retains and becomes evictable mid-swap.
|
|
111
|
+
*/
|
|
112
|
+
setRetained(keys: Array<{ dialogId: string; side?: ChatDialogSide }>): void
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export const DEFAULT_DIALOG_SIDE: ChatDialogSide = 'main'
|
|
116
|
+
|
|
117
|
+
/** Most-recently-used reducer keys retained before eviction. Ten covers
|
|
118
|
+
* every realistic multi-panel / dialog-switching session while bounding an
|
|
119
|
+
* agent that cycles hundreds of dialogs. */
|
|
120
|
+
export const DEFAULT_MAX_REDUCERS = 10
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Hard cap on keys held in the "created by `getReducer`, `retain()` still
|
|
124
|
+
* pending" protection set — the ONLY thing bounding the map while retains are
|
|
125
|
+
* outstanding, since that protection ends at the retain rather than on a timer.
|
|
126
|
+
*
|
|
127
|
+
* Deliberately far above `maxReducers`: a host may legitimately mount MORE
|
|
128
|
+
* panels than the LRU cap (once their effects run, retained keys already let
|
|
129
|
+
* the map exceed it), so capping the pending set at `maxReducers` would blank
|
|
130
|
+
* exactly the panels round 10 fixed. It is a backstop against a caller that
|
|
131
|
+
* reads a reducer once and drops it — or a concurrent render React discards
|
|
132
|
+
* before any effect runs — not a per-commit budget. Past it, the oldest pending
|
|
133
|
+
* key falls through to ordinary LRU.
|
|
134
|
+
*/
|
|
135
|
+
export const MAX_PENDING_RETAIN_KEYS = 256
|
|
136
|
+
|
|
137
|
+
export interface CreateChatDialogStoreOptions {
|
|
138
|
+
/** LRU cap on retained reducers (keys are `(dialogId, side)` pairs).
|
|
139
|
+
* Values < 1 are clamped to 1. Default `DEFAULT_MAX_REDUCERS`. */
|
|
140
|
+
maxReducers?: number
|
|
141
|
+
/**
|
|
142
|
+
* Options applied to any reducer this store creates WITHOUT an explicit
|
|
143
|
+
* per-call `createOptions` — i.e. every reducer first materialized by
|
|
144
|
+
* `apply()` or `mutate()`. Hosts with non-default reducer semantics
|
|
145
|
+
* (`ownEchoIncludesAdmin`, `selfUserId`, approve/reject `callbacks`, …)
|
|
146
|
+
* MUST set this: creation options are consulted once, so a bare
|
|
147
|
+
* apply-created instance would keep those semantics missing for its whole
|
|
148
|
+
* lifetime, including for a later `getReducer(..., options)` caller.
|
|
149
|
+
*/
|
|
150
|
+
defaultCreateOptions?: CreateReducerOptionsFn
|
|
151
|
+
/**
|
|
152
|
+
* Called when LRU eviction actually DROPS a key. The store knows this
|
|
153
|
+
* exactly; without publishing it, hosts are left inferring eviction by
|
|
154
|
+
* comparing reducer OBJECT IDENTITY across `getReducer` calls and then
|
|
155
|
+
* suppressing the "pristine empty" snapshot that a silently-recreated
|
|
156
|
+
* reducer returns — archaeology about a fact this callback states outright.
|
|
157
|
+
*
|
|
158
|
+
* Fires only for LRU eviction. Host-initiated `remove()` needs no
|
|
159
|
+
* notification: the caller already knows which key it dropped.
|
|
160
|
+
*
|
|
161
|
+
* Called AFTER the key is gone, from `getReducer` (i.e. potentially during a
|
|
162
|
+
* React render) — treat it as a "schedule a refetch/rehydrate" signal, not a
|
|
163
|
+
* place to set state synchronously.
|
|
164
|
+
*
|
|
165
|
+
* `parked` carries the dropped reducer's non-refetchable state so the host
|
|
166
|
+
* can restore it on the recreated instance (`initializeWithState(messages,
|
|
167
|
+
* { approvalStatuses, lastAppliedSeq, pendingEchoes })`). Refetching
|
|
168
|
+
* messages alone is NOT
|
|
169
|
+
* equivalent: a recreated reducer starts with `approvalStatuses = {}` and
|
|
170
|
+
* `lastAppliedSeq = -Infinity`, so a resolved approval whose APPROVAL_RESULT
|
|
171
|
+
* row is not in the refetched history page re-renders as ACTIONABLE (the
|
|
172
|
+
* exact hazard `resetForDialogSwitch` preserves that map to avoid), and a
|
|
173
|
+
* replay from the host's own cursor re-applies events the dropped instance
|
|
174
|
+
* had already consumed.
|
|
175
|
+
*/
|
|
176
|
+
onEvict?: (
|
|
177
|
+
dialogId: string,
|
|
178
|
+
side: ChatDialogSide,
|
|
179
|
+
parked: EvictedReducerState,
|
|
180
|
+
) => void
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Snapshot of an LRU-evicted reducer's state, captured just before the
|
|
185
|
+
* instance is dropped. Everything here either cannot be refetched
|
|
186
|
+
* (`lastAppliedSeq`) or is not reliably present in a refetched history page
|
|
187
|
+
* (`approvalStatuses`); `messages` rides along so a host that keeps its own
|
|
188
|
+
* copy does not have to.
|
|
189
|
+
*/
|
|
190
|
+
export interface EvictedReducerState {
|
|
191
|
+
messages: ChatReducerState['messages']
|
|
192
|
+
approvalStatuses: ChatReducerState['approvalStatuses']
|
|
193
|
+
/** `-Infinity` when the instance never applied a seq-carrying event. */
|
|
194
|
+
lastAppliedSeq: number
|
|
195
|
+
/**
|
|
196
|
+
* Optimistic-echo entries still armed at eviction time. A key dropped
|
|
197
|
+
* between `pushOptimisticSend` and its `MESSAGE_REQUEST` echo would
|
|
198
|
+
* otherwise leave the recreated reducer with nothing armed, and the echo
|
|
199
|
+
* renders a DUPLICATE user bubble. Replay via
|
|
200
|
+
* `initializeWithState(messages, { pendingEchoes })`; the reducer drops
|
|
201
|
+
* entries already past `OWN_ECHO_AUTHOR_TTL_MS`. Empty for a key with no
|
|
202
|
+
* send in flight (the common case).
|
|
203
|
+
*/
|
|
204
|
+
pendingEchoes: readonly PendingEcho[]
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/** Shared, frozen "no reducer for this key" snapshot. ONE instance, so
|
|
208
|
+
* repeated `getSnapshot` reads of an absent key are referentially stable
|
|
209
|
+
* for `useSyncExternalStore`. */
|
|
210
|
+
const EMPTY_STATE: ChatReducerState = Object.freeze({
|
|
211
|
+
messages: Object.freeze([]) as unknown as ChatReducerState['messages'],
|
|
212
|
+
streamingPhase: 'idle',
|
|
213
|
+
turnMeta: Object.freeze({
|
|
214
|
+
meta: new Map(),
|
|
215
|
+
sources: new Map(),
|
|
216
|
+
refs: new Map(),
|
|
217
|
+
sendCount: 0,
|
|
218
|
+
}) as ChatReducerState['turnMeta'],
|
|
219
|
+
dialogTokenUsage: null,
|
|
220
|
+
liveModel: null,
|
|
221
|
+
approvalStatuses: Object.freeze({}) as ChatReducerState['approvalStatuses'],
|
|
222
|
+
}) as ChatReducerState
|
|
223
|
+
|
|
224
|
+
/** Shared frozen empty list for a key with no send in flight. */
|
|
225
|
+
const EMPTY_PENDING_ECHOES: readonly PendingEcho[] = Object.freeze([])
|
|
226
|
+
|
|
227
|
+
const KEY_SEP = '\u0000'
|
|
228
|
+
const keyOf = (dialogId: string, side: ChatDialogSide) => `${dialogId}${KEY_SEP}${side}`
|
|
229
|
+
|
|
230
|
+
export function createChatDialogStore(
|
|
231
|
+
options: CreateChatDialogStoreOptions = {},
|
|
232
|
+
): ChatDialogStore {
|
|
233
|
+
const maxReducers = Math.max(1, options.maxReducers ?? DEFAULT_MAX_REDUCERS)
|
|
234
|
+
const defaultCreateOptions = options.defaultCreateOptions
|
|
235
|
+
const onEvict = options.onEvict
|
|
236
|
+
// Insertion order IS the LRU order: `touch` re-inserts at the tail, so the
|
|
237
|
+
// FIRST key is always the least-recently-used one.
|
|
238
|
+
const reducers = new Map<string, ChatStreamReducer>()
|
|
239
|
+
const listeners = new Set<() => void>()
|
|
240
|
+
/** key → live retain count (see EVICTION SAFETY #1). */
|
|
241
|
+
const retained = new Map<string, number>()
|
|
242
|
+
|
|
243
|
+
function notify(): void {
|
|
244
|
+
for (const listener of listeners) listener()
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/** Notify OUTSIDE the current task — `getReducer` (and therefore eviction)
|
|
248
|
+
* runs during React's render phase. See EVICTION SAFETY #4. */
|
|
249
|
+
function notifyDeferred(): void {
|
|
250
|
+
if (typeof queueMicrotask === 'function') queueMicrotask(notify)
|
|
251
|
+
else Promise.resolve().then(notify)
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* Keys handed out by a PUBLIC `getReducer` whose `retain()` has not landed
|
|
256
|
+
* yet. ALL of them are protected from eviction, not just the one being
|
|
257
|
+
* inserted right now.
|
|
258
|
+
*
|
|
259
|
+
* `useChatStreamReducer` calls `getReducer` during RENDER (to hand the
|
|
260
|
+
* component its reducer) and `retain()` in an EFFECT, so React can render N
|
|
261
|
+
* panels with N distinct keys before a single retain lands. Protecting only
|
|
262
|
+
* the key being inserted let render #(cap+1) evict the key render #1 had
|
|
263
|
+
* just created: panel #1 already holds that reducer object, but
|
|
264
|
+
* `getSnapshot` is a pure map read, so its thread renders as `EMPTY_STATE`
|
|
265
|
+
* forever and its later `retain()` pins a key that no longer exists — a
|
|
266
|
+
* blank thread unless the host re-seeds from `onEvict`.
|
|
267
|
+
*
|
|
268
|
+
* PROTECTION ENDS AT THE FIRST `retain()` (`retainKey` deletes the entry),
|
|
269
|
+
* NOT on a timer. This set exists solely to stand in for the retain that has
|
|
270
|
+
* not happened yet, so the retain itself is the only non-guessed end of the
|
|
271
|
+
* window — and from that moment the retain COUNT protects the key instead.
|
|
272
|
+
* The previous microtask window assumed a render/commit pass never yields to
|
|
273
|
+
* the microtask queue; under CONCURRENT rendering React time-slices and
|
|
274
|
+
* resumes in a later macrotask (microtasks drain in between), so keys created
|
|
275
|
+
* before the yield silently lost protection mid-render and the exact
|
|
276
|
+
* blank-thread defect above could recur.
|
|
277
|
+
*
|
|
278
|
+
* BOUND: a caller that reads a reducer once and drops it never retains, so
|
|
279
|
+
* the set is FIFO-capped at `MAX_PENDING_RETAIN_KEYS`; the oldest pending key
|
|
280
|
+
* falls through to ordinary LRU when a newer one arrives. Hence
|
|
281
|
+
* `reducers.size <= max(maxReducers, R + MAX_PENDING_RETAIN_KEYS)` where `R`
|
|
282
|
+
* counts retained / mid-stream keys — the same "protected keys may exceed the
|
|
283
|
+
* cap" rule that has always applied to retained ones. Eviction itself stays
|
|
284
|
+
* SYNCHRONOUS, and the loop always catches back up because `overBy` is
|
|
285
|
+
* recomputed from `reducers.size`.
|
|
286
|
+
*
|
|
287
|
+
* Event-phase creations (`apply()` / `mutate()` first-touching a key) are
|
|
288
|
+
* deliberately NOT grouped: nobody holds those instances past the call, so a
|
|
289
|
+
* burst of events across many dialogs must still evict down to the cap
|
|
290
|
+
* within the same task. They protect only the key currently being written,
|
|
291
|
+
* via `evictExcess`'s `protectKey`.
|
|
292
|
+
*/
|
|
293
|
+
const pendingRetain = new Set<string>()
|
|
294
|
+
|
|
295
|
+
function markPendingRetain(key: string): void {
|
|
296
|
+
pendingRetain.add(key)
|
|
297
|
+
if (pendingRetain.size <= MAX_PENDING_RETAIN_KEYS) return
|
|
298
|
+
// Set iteration is insertion-ordered: the oldest still-unretained key is
|
|
299
|
+
// the one least likely to belong to a live consumer.
|
|
300
|
+
const oldest = pendingRetain.values().next().value
|
|
301
|
+
if (oldest !== undefined) pendingRetain.delete(oldest)
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/** Mark `key` most-recently-used. */
|
|
305
|
+
function touch(key: string): void {
|
|
306
|
+
const reducer = reducers.get(key)
|
|
307
|
+
if (reducer === undefined) return
|
|
308
|
+
reducers.delete(key)
|
|
309
|
+
reducers.set(key, reducer)
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/** A key is evictable only when nothing pins it and its turn is finished. */
|
|
313
|
+
function isEvictable(key: string): boolean {
|
|
314
|
+
if ((retained.get(key) ?? 0) > 0) return false
|
|
315
|
+
const reducer = reducers.get(key)
|
|
316
|
+
return reducer === undefined || reducer.state.streamingPhase === 'idle'
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/** Evict least-recently-used EVICTABLE keys down to the cap, through the
|
|
320
|
+
* same drop path as `remove()`. Protected keys (retained / mid-stream) are
|
|
321
|
+
* skipped, so the map can legitimately sit above `maxReducers` for as long
|
|
322
|
+
* as that many live threads exist. Returns true when anything was
|
|
323
|
+
* evicted.
|
|
324
|
+
*
|
|
325
|
+
* Keys awaiting their first `retain()` (`pendingRetain`) are protected as a
|
|
326
|
+
* GROUP: they are unretained (the hook retains in an effect, i.e. after
|
|
327
|
+
* render) and idle, so without that guard a full map of protected older keys
|
|
328
|
+
* would walk the loop all the way to the tail and drop a brand-new entry —
|
|
329
|
+
* `getReducer` would return a DETACHED reducer whose mutations no snapshot
|
|
330
|
+
* can ever see (`getSnapshot` is a pure map read, so that key returns
|
|
331
|
+
* `EMPTY_STATE` forever). See `pendingRetain` for why every key still
|
|
332
|
+
* awaiting a retain is protected, not just the one being inserted. */
|
|
333
|
+
function evictExcess(protectKey: string): boolean {
|
|
334
|
+
let evicted = false
|
|
335
|
+
let overBy = reducers.size - maxReducers
|
|
336
|
+
if (overBy <= 0) return false
|
|
337
|
+
for (const key of [...reducers.keys()]) {
|
|
338
|
+
if (overBy <= 0) break
|
|
339
|
+
if (key === protectKey || pendingRetain.has(key)) continue
|
|
340
|
+
if (!isEvictable(key)) continue
|
|
341
|
+
// Capture BEFORE the drop — after `delete` the instance is unreachable
|
|
342
|
+
// and its approval statuses / seq cursor would be lost with it.
|
|
343
|
+
const dropped = onEvict ? reducers.get(key) : undefined
|
|
344
|
+
reducers.delete(key)
|
|
345
|
+
evicted = true
|
|
346
|
+
overBy -= 1
|
|
347
|
+
if (onEvict) {
|
|
348
|
+
const sep = key.indexOf(KEY_SEP)
|
|
349
|
+
onEvict(key.slice(0, sep), key.slice(sep + 1), {
|
|
350
|
+
messages: dropped?.state.messages ?? EMPTY_STATE.messages,
|
|
351
|
+
approvalStatuses: dropped?.state.approvalStatuses ?? EMPTY_STATE.approvalStatuses,
|
|
352
|
+
lastAppliedSeq: dropped?.getLastAppliedSeq() ?? Number.NEGATIVE_INFINITY,
|
|
353
|
+
pendingEchoes: dropped?.getPendingEchoes() ?? EMPTY_PENDING_ECHOES,
|
|
354
|
+
})
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
return evicted
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/**
|
|
361
|
+
* @param groupProtect true for PUBLIC (render-phase) calls, whose caller
|
|
362
|
+
* keeps the returned instance until its `retain()` lands — see
|
|
363
|
+
* `pendingRetain`. Event-phase callers (`apply` / `mutate`) pass false: they
|
|
364
|
+
* use the reducer within the call and must not defer eviction of a burst.
|
|
365
|
+
*/
|
|
366
|
+
function getReducerInternal(
|
|
367
|
+
dialogId: string,
|
|
368
|
+
side: ChatDialogSide = DEFAULT_DIALOG_SIDE,
|
|
369
|
+
createOptions?: CreateReducerOptionsFn,
|
|
370
|
+
groupProtect = true,
|
|
371
|
+
): ChatStreamReducer {
|
|
372
|
+
const key = keyOf(dialogId, side)
|
|
373
|
+
let reducer = reducers.get(key)
|
|
374
|
+
if (!reducer) {
|
|
375
|
+
// `apply()` / `mutate()` can be the FIRST toucher of a key (an event for
|
|
376
|
+
// a dialog no component has rendered yet). Without the store-level
|
|
377
|
+
// default they would create a reducer missing the host's
|
|
378
|
+
// `ownEchoIncludesAdmin` / `callbacks` / `batchApprovalsEnabled`, and a
|
|
379
|
+
// later `getReducer(..., options)` would hand back that mis-configured
|
|
380
|
+
// instance (create-options are consulted at creation only).
|
|
381
|
+
const base = (createOptions ?? defaultCreateOptions)?.(dialogId, side) ?? {}
|
|
382
|
+
const userOnChange = base.onChange
|
|
383
|
+
reducer = createChatStreamReducer({
|
|
384
|
+
...base,
|
|
385
|
+
onChange: () => {
|
|
386
|
+
userOnChange?.()
|
|
387
|
+
notify()
|
|
388
|
+
},
|
|
389
|
+
})
|
|
390
|
+
reducers.set(key, reducer)
|
|
391
|
+
// An already-retained key needs no stand-in — it is protected by its
|
|
392
|
+
// retain count, and marking it would burn a slot of the bounded set.
|
|
393
|
+
if (groupProtect && (retained.get(key) ?? 0) === 0) markPendingRetain(key)
|
|
394
|
+
if (evictExcess(key)) notifyDeferred()
|
|
395
|
+
} else {
|
|
396
|
+
touch(key)
|
|
397
|
+
}
|
|
398
|
+
return reducer
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
const getReducer: ChatDialogStore['getReducer'] = (dialogId, side, createOptions) =>
|
|
402
|
+
getReducerInternal(dialogId, side, createOptions, true)
|
|
403
|
+
|
|
404
|
+
function otherSides(dialogId: string, side: ChatDialogSide): ChatStreamReducer[] {
|
|
405
|
+
const prefix = `${dialogId}${KEY_SEP}`
|
|
406
|
+
const selfKey = keyOf(dialogId, side)
|
|
407
|
+
const out: ChatStreamReducer[] = []
|
|
408
|
+
for (const [key, reducer] of reducers) {
|
|
409
|
+
if (key !== selfKey && key.startsWith(prefix)) out.push(reducer)
|
|
410
|
+
}
|
|
411
|
+
return out
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
function apply(dialogId: string, side: ChatDialogSide, event: ChatStreamEvent): void {
|
|
415
|
+
const reducer = getReducerInternal(dialogId, side, undefined, false)
|
|
416
|
+
reducer.apply(event)
|
|
417
|
+
|
|
418
|
+
// Cross-side fan-out — the ONLY two operations that cross sides.
|
|
419
|
+
if (event.type === 'approval-resolved' && event.requestId) {
|
|
420
|
+
for (const other of otherSides(dialogId, side)) {
|
|
421
|
+
other.projectApprovalResolution(event.requestId, event.status, event.resolvedByName)
|
|
422
|
+
}
|
|
423
|
+
} else if (event.type === 'tool-execution') {
|
|
424
|
+
for (const other of otherSides(dialogId, side)) {
|
|
425
|
+
other.projectToolExecution({ type: 'tool_execution', data: event.data })
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
function mutate<T>(
|
|
431
|
+
dialogId: string,
|
|
432
|
+
side: ChatDialogSide,
|
|
433
|
+
fn: (reducer: ChatStreamReducer) => T,
|
|
434
|
+
): T {
|
|
435
|
+
return fn(getReducerInternal(dialogId, side, undefined, false))
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
function subscribe(listener: () => void): () => void {
|
|
439
|
+
listeners.add(listener)
|
|
440
|
+
return () => {
|
|
441
|
+
listeners.delete(listener)
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
/** PURE read — deliberately does NOT go through `getReducer`, which would
|
|
446
|
+
* insert into the module-lived map on every miss (unbounded growth under
|
|
447
|
+
* SSR) and make a React snapshot read mutate the store. Callers that need
|
|
448
|
+
* a reducer to exist call `getReducer` explicitly first (the
|
|
449
|
+
* `useChatStreamReducer` hook does exactly that, during render). */
|
|
450
|
+
function getSnapshot(
|
|
451
|
+
dialogId: string,
|
|
452
|
+
side: ChatDialogSide = DEFAULT_DIALOG_SIDE,
|
|
453
|
+
): ChatReducerState {
|
|
454
|
+
return reducers.get(keyOf(dialogId, side))?.state ?? EMPTY_STATE
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
function getServerSnapshot(): ChatReducerState {
|
|
458
|
+
return EMPTY_STATE
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
/** Notifies only when something was ACTUALLY dropped — an unconditional
|
|
462
|
+
* notify wakes every subscriber for a no-op removal, and the snapshot each
|
|
463
|
+
* would read is byte-identical.
|
|
464
|
+
*
|
|
465
|
+
* Deliberately does NOT touch `retained` / `policyRetains`. A retain is a
|
|
466
|
+
* claim on the KEY, not on the instance (`retain()` never implies creation),
|
|
467
|
+
* and `remove()` is routinely followed by a re-render that recreates the key
|
|
468
|
+
* — a still-mounted consumer must keep protecting it across that round trip.
|
|
469
|
+
* Dropping the counter here would also desync the outstanding release
|
|
470
|
+
* closures: with two live retainers, the first release would decrement from
|
|
471
|
+
* the `?? 1` fallback straight to 0 and unpin a key the second consumer is
|
|
472
|
+
* still holding. The entries are bounded by live retainers (never by removed
|
|
473
|
+
* dialogs) and are deleted when the last release runs. */
|
|
474
|
+
function remove(dialogId: string, side?: ChatDialogSide): void {
|
|
475
|
+
let removed = false
|
|
476
|
+
if (side !== undefined) {
|
|
477
|
+
removed = reducers.delete(keyOf(dialogId, side))
|
|
478
|
+
} else {
|
|
479
|
+
const prefix = `${dialogId}${KEY_SEP}`
|
|
480
|
+
for (const key of [...reducers.keys()]) {
|
|
481
|
+
if (key.startsWith(prefix) && reducers.delete(key)) removed = true
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
if (removed) notify()
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
function retainKey(key: string): () => void {
|
|
488
|
+
// The retain this key was waiting for has landed: the refcount below now
|
|
489
|
+
// protects it, so the stand-in must go (leaving it would keep an unmounted
|
|
490
|
+
// key pinned and hold a slot of the bounded `pendingRetain` set).
|
|
491
|
+
pendingRetain.delete(key)
|
|
492
|
+
retained.set(key, (retained.get(key) ?? 0) + 1)
|
|
493
|
+
let released = false
|
|
494
|
+
return () => {
|
|
495
|
+
if (released) return
|
|
496
|
+
released = true
|
|
497
|
+
const next = (retained.get(key) ?? 1) - 1
|
|
498
|
+
if (next <= 0) retained.delete(key)
|
|
499
|
+
else retained.set(key, next)
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
function retain(dialogId: string, side: ChatDialogSide = DEFAULT_DIALOG_SIDE): () => void {
|
|
504
|
+
return retainKey(keyOf(dialogId, side))
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
/** Store-level policy retains, keyed the same way. Held separately from the
|
|
508
|
+
* per-consumer `retain()` handles so the two never clobber each other. */
|
|
509
|
+
const policyRetains = new Map<string, () => void>()
|
|
510
|
+
|
|
511
|
+
function setRetained(keys: Array<{ dialogId: string; side?: ChatDialogSide }>): void {
|
|
512
|
+
const next = new Set(keys.map((k) => keyOf(k.dialogId, k.side ?? DEFAULT_DIALOG_SIDE)))
|
|
513
|
+
// Retain the additions BEFORE releasing the removals: a key in both sets is
|
|
514
|
+
// simply left alone, and nothing in the new set is ever unpinned in between.
|
|
515
|
+
for (const key of next) {
|
|
516
|
+
if (!policyRetains.has(key)) policyRetains.set(key, retainKey(key))
|
|
517
|
+
}
|
|
518
|
+
for (const [key, release] of [...policyRetains]) {
|
|
519
|
+
if (next.has(key)) continue
|
|
520
|
+
release()
|
|
521
|
+
policyRetains.delete(key)
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
return {
|
|
526
|
+
getReducer,
|
|
527
|
+
apply,
|
|
528
|
+
mutate,
|
|
529
|
+
subscribe,
|
|
530
|
+
getSnapshot,
|
|
531
|
+
getServerSnapshot,
|
|
532
|
+
remove,
|
|
533
|
+
retain,
|
|
534
|
+
setRetained,
|
|
535
|
+
}
|
|
536
|
+
}
|