@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
|
@@ -12,11 +12,11 @@
|
|
|
12
12
|
* ┌──────────────────────────────────────────────────────────────┐
|
|
13
13
|
* │ useNatsDialogSubscription live tail of agent events │
|
|
14
14
|
* │ ↓ │
|
|
15
|
-
* │ onEvent →
|
|
15
|
+
* │ onEvent → decodeNatsChunk → ChatStreamEvent │
|
|
16
16
|
* │ ↓ │
|
|
17
|
-
* │
|
|
18
|
-
* │
|
|
19
|
-
* │
|
|
17
|
+
* │ createChatStreamReducer THE master accumulation path │
|
|
18
|
+
* │ (via ChatDialogStore + useChatStreamReducer wrapper — │
|
|
19
|
+
* │ messages, phase, token usage, approvals all live there) │
|
|
20
20
|
* │ │
|
|
21
21
|
* │ useChunkCatchup back-fill missed chunks after │
|
|
22
22
|
* │ activation / reconnect │
|
|
@@ -24,53 +24,38 @@
|
|
|
24
24
|
* │ config.publishUserMessage consumer-owned send (HTTP/NATS) │
|
|
25
25
|
* └──────────────────────────────────────────────────────────────┘
|
|
26
26
|
*
|
|
27
|
+
* Phase 3 of the chat unification: the adapter's local message-mutation
|
|
28
|
+
* layer (`updateTrailingAssistant` / `appendToTrailingAssistant` /
|
|
29
|
+
* `upsertTrailingCompaction` / `applyToolExecutionToMessages` and the
|
|
30
|
+
* chunk-callback body that drove them) is DELETED — those semantics live in
|
|
31
|
+
* `../stream/chat-stream-reducer` + `../stream/message-mutations` now. The
|
|
32
|
+
* adapter owns only what is genuinely transport/host wiring: dialog-list
|
|
33
|
+
* management, history fetch, catchup orchestration, the NATS subscription,
|
|
34
|
+
* and the publish path.
|
|
35
|
+
*
|
|
27
36
|
* Two operating modes, distinguished by the presence of `fetchDialogs`:
|
|
28
37
|
*
|
|
29
38
|
* 1. **Bare-transport mode** (current Tauri Fae Chat usage):
|
|
30
39
|
* consumer supplies `config.dialogId` explicitly and owns dialog
|
|
31
|
-
* management above the adapter.
|
|
32
|
-
* bookkeeping — every dialog field on the return value falls back
|
|
33
|
-
* to an empty default. This is the original v0 API; preserved
|
|
34
|
-
* byte-compatible so existing pinned consumers keep working.
|
|
40
|
+
* management above the adapter.
|
|
35
41
|
*
|
|
36
42
|
* 2. **Managed-dialog mode** (openframe-frontend, EmbeddableChat
|
|
37
|
-
* sidebar): consumer supplies `fetchDialogs`,
|
|
38
|
-
* `
|
|
39
|
-
* owns the dialog state machine. The active dialog id, the list,
|
|
40
|
-
* pagination cursors, token usage hydration, and history merge
|
|
41
|
-
* all live inside this hook. The host renders the sidebar via
|
|
42
|
-
* `EmbeddableChat`'s built-in `<ChatSidebar>` slot and calls
|
|
43
|
-
* `selectDialog` / `startNewDialog` from there.
|
|
44
|
-
*
|
|
45
|
-
* The two modes are NOT mutually exclusive — passing `config.dialogId`
|
|
46
|
-
* alongside `fetchDialogs` forces the adapter into controlled mode (the
|
|
47
|
-
* external id wins) while still using the host's list fetchers for the
|
|
48
|
-
* sidebar.
|
|
49
|
-
*
|
|
50
|
-
* Why publish is consumer-owned: the NATS module exposes a low-level
|
|
51
|
-
* `publishBytes/String/Json` but the actual user-message endpoint varies
|
|
52
|
-
* by deployment (REST POST in some, NATS subject in others). The adapter
|
|
53
|
-
* stays agnostic — caller wires up "user typed something, do X" via
|
|
54
|
-
* the `publishUserMessage` callback.
|
|
43
|
+
* sidebar): consumer supplies `fetchDialogs`, `fetchDialogMessages`,
|
|
44
|
+
* `createDialog`, etc. and the adapter owns the dialog state machine.
|
|
55
45
|
*
|
|
56
46
|
* The `active` option gates the live subscription so the unified chat
|
|
57
47
|
* shell can keep both adapters mounted while only paying network cost
|
|
58
48
|
* for the currently-displayed mode.
|
|
59
49
|
*/
|
|
60
50
|
|
|
61
|
-
import {
|
|
62
|
-
useCallback,
|
|
63
|
-
useEffect,
|
|
64
|
-
useMemo,
|
|
65
|
-
useRef,
|
|
66
|
-
useState,
|
|
67
|
-
type MutableRefObject,
|
|
68
|
-
} from 'react'
|
|
51
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
|
69
52
|
import { useNatsDialogSubscription } from './use-nats-dialog-subscription'
|
|
70
|
-
import { useRealtimeChunkProcessor } from './use-realtime-chunk-processor'
|
|
71
53
|
import { useChunkCatchup } from './use-chunk-catchup'
|
|
54
|
+
import { decodeNatsChunk } from '../../../chat-protocol/nats-decoder'
|
|
55
|
+
import { createChatDialogStore, DEFAULT_DIALOG_SIDE } from '../stream/chat-dialog-store'
|
|
56
|
+
import { useChatStreamReducer } from '../stream/use-chat-stream-reducer'
|
|
72
57
|
import { processHistoricalMessagesWithErrors } from '../utils/process-historical-messages'
|
|
73
|
-
import {
|
|
58
|
+
import { extractIncompleteTailState } from '../utils/extract-incomplete-message-state'
|
|
74
59
|
import type {
|
|
75
60
|
ChunkData,
|
|
76
61
|
FetchChunksFunction,
|
|
@@ -78,11 +63,7 @@ import type {
|
|
|
78
63
|
MessageProcessingOptions,
|
|
79
64
|
MessageSegment,
|
|
80
65
|
NatsMessageType,
|
|
81
|
-
StreamingPhase,
|
|
82
66
|
ChatApprovalStatus,
|
|
83
|
-
SegmentsUpdateMetadata,
|
|
84
|
-
ToolExecutionSegment,
|
|
85
|
-
ApprovalBatchExecutionState,
|
|
86
67
|
} from '../types'
|
|
87
68
|
import type {
|
|
88
69
|
ChatConnectionState,
|
|
@@ -94,6 +75,16 @@ import type {
|
|
|
94
75
|
import type { DialogItem } from '../types/component.types'
|
|
95
76
|
import type { ChatRef } from '../chat-ref.types'
|
|
96
77
|
|
|
78
|
+
// Legacy public home of the pure message-mutation helpers ("exported for
|
|
79
|
+
// host reuse"). Canonical implementation moved to the stream module in
|
|
80
|
+
// Phase 3 — these re-exports keep the import path stable until Phase 4
|
|
81
|
+
// migrates consumers.
|
|
82
|
+
export {
|
|
83
|
+
appendToTrailingAssistant,
|
|
84
|
+
applyToolExecutionToMessages,
|
|
85
|
+
upsertTrailingCompaction,
|
|
86
|
+
} from '../stream/message-mutations'
|
|
87
|
+
|
|
97
88
|
// =============================================================================
|
|
98
89
|
// Config + options
|
|
99
90
|
// =============================================================================
|
|
@@ -210,19 +201,19 @@ export interface UseNatsChatAdapterConfig {
|
|
|
210
201
|
topics?: NatsMessageType[]
|
|
211
202
|
|
|
212
203
|
/**
|
|
213
|
-
* Mirrors `
|
|
214
|
-
*
|
|
215
|
-
* `
|
|
204
|
+
* Mirrors the reducer's `batchApprovalsEnabled`. Default `true` —
|
|
205
|
+
* single batch card per APPROVAL_REQUEST with `toolCalls[]`. Set
|
|
206
|
+
* `false` to fall back to legacy per-tool cards.
|
|
216
207
|
*/
|
|
217
208
|
batchApprovalsEnabled?: boolean
|
|
218
209
|
|
|
219
210
|
/**
|
|
220
|
-
* Approval types rendered as actionable cards inline. Mirrors
|
|
221
|
-
* `
|
|
222
|
-
*
|
|
223
|
-
*
|
|
224
|
-
*
|
|
225
|
-
*
|
|
211
|
+
* Approval types rendered as actionable cards inline. Mirrors the
|
|
212
|
+
* reducer's `displayApprovalTypes` (default `['CLIENT']`) and is
|
|
213
|
+
* forwarded to the history processor so both paths agree. Hosts whose
|
|
214
|
+
* backend emits other types (e.g. `USER`) MUST set this — otherwise
|
|
215
|
+
* those approvals are escalated to a callback this adapter doesn't
|
|
216
|
+
* surface and the card never renders.
|
|
226
217
|
*/
|
|
227
218
|
displayApprovalTypes?: string[]
|
|
228
219
|
|
|
@@ -245,7 +236,7 @@ export interface UseNatsChatAdapterConfig {
|
|
|
245
236
|
* Messages must arrive in the same wire shape the openframe backend
|
|
246
237
|
* emits (HistoricalMessage with messageData[]); the adapter feeds
|
|
247
238
|
* them through `processHistoricalMessagesWithErrors` to produce
|
|
248
|
-
*
|
|
239
|
+
* reducer-compatible messages.
|
|
249
240
|
*/
|
|
250
241
|
fetchDialogMessages?: (
|
|
251
242
|
params: FetchDialogMessagesParams,
|
|
@@ -283,9 +274,9 @@ export interface UseNatsChatAdapterConfig {
|
|
|
283
274
|
unarchiveDialog?: (dialogId: string) => Promise<void>
|
|
284
275
|
|
|
285
276
|
/**
|
|
286
|
-
* Approve a pending tool-call request. Wired into the
|
|
287
|
-
*
|
|
288
|
-
*
|
|
277
|
+
* Approve a pending tool-call request. Wired into the reducer's
|
|
278
|
+
* approval-card callbacks so card buttons fire this directly. When
|
|
279
|
+
* omitted, approval cards render disabled buttons.
|
|
289
280
|
*/
|
|
290
281
|
approveRequest?: (requestId: string) => Promise<void>
|
|
291
282
|
|
|
@@ -344,264 +335,9 @@ export interface UseNatsChatAdapterOptions {
|
|
|
344
335
|
}
|
|
345
336
|
|
|
346
337
|
// =============================================================================
|
|
347
|
-
//
|
|
338
|
+
// Historical-message mapping helpers (host-facing)
|
|
348
339
|
// =============================================================================
|
|
349
340
|
|
|
350
|
-
function nextId(role: 'user' | 'assistant'): string {
|
|
351
|
-
// Date.now() + counter sliver keeps ids monotonic even when two
|
|
352
|
-
// messages are produced inside the same ms tick (user + assistant
|
|
353
|
-
// placeholder fire back-to-back from a single sendMessage call).
|
|
354
|
-
return `${role}-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`
|
|
355
|
-
}
|
|
356
|
-
|
|
357
|
-
/**
|
|
358
|
-
* Replace (or append) the trailing assistant message with the latest
|
|
359
|
-
* accumulated segments. Mirrors the use-chat.ts pattern so render
|
|
360
|
-
* behaviour matches the SSE adapter exactly.
|
|
361
|
-
*/
|
|
362
|
-
function updateTrailingAssistant(
|
|
363
|
-
prev: UnifiedChatMessage[],
|
|
364
|
-
segments: MessageSegment[],
|
|
365
|
-
): UnifiedChatMessage[] {
|
|
366
|
-
const last = prev[prev.length - 1]
|
|
367
|
-
if (!last || last.role !== 'assistant') {
|
|
368
|
-
// No placeholder exists — append a fresh assistant message.
|
|
369
|
-
return [
|
|
370
|
-
...prev,
|
|
371
|
-
{
|
|
372
|
-
id: nextId('assistant'),
|
|
373
|
-
role: 'assistant',
|
|
374
|
-
content: '',
|
|
375
|
-
segments,
|
|
376
|
-
},
|
|
377
|
-
]
|
|
378
|
-
}
|
|
379
|
-
return [
|
|
380
|
-
...prev.slice(0, -1),
|
|
381
|
-
{ ...last, segments },
|
|
382
|
-
]
|
|
383
|
-
}
|
|
384
|
-
|
|
385
|
-
/**
|
|
386
|
-
* Realtime user/direct/system chunks can be replays of rows already on
|
|
387
|
-
* screen (our own optimistic send echoed back, catchup replaying the tail
|
|
388
|
-
* over just-loaded history, reconnect back-fill).
|
|
389
|
-
*
|
|
390
|
-
* Dedup layers, in priority order:
|
|
391
|
-
* 1. `streamSeq` identity (JetStream) — exact, never confuses a genuinely
|
|
392
|
-
* repeated text with a replay; tracked in a per-dialog seen-set.
|
|
393
|
-
* 2. One-shot optimistic-echo consumption — `sendMessage` records the text
|
|
394
|
-
* it optimistically rendered; the backend's MESSAGE_REQUEST echo removes
|
|
395
|
-
* exactly one entry.
|
|
396
|
-
* 3. Content match over a SHORT same-author tail window — only for
|
|
397
|
-
* seq-less transports (plain NATS replay over just-loaded history, where
|
|
398
|
-
* the persisted twin sits at most a couple of rows up). Same-author so
|
|
399
|
-
* an admin message can never suppress a user's identical text.
|
|
400
|
-
*/
|
|
401
|
-
const CONTENT_DEDUP_WINDOW = 4
|
|
402
|
-
const SYSTEM_DEDUP_WINDOW = 10
|
|
403
|
-
|
|
404
|
-
function hasRecentMessage(
|
|
405
|
-
prev: UnifiedChatMessage[],
|
|
406
|
-
predicate: (message: UnifiedChatMessage) => boolean,
|
|
407
|
-
window: number,
|
|
408
|
-
): boolean {
|
|
409
|
-
const start = Math.max(0, prev.length - window)
|
|
410
|
-
for (let i = prev.length - 1; i >= start; i--) {
|
|
411
|
-
if (predicate(prev[i])) return true
|
|
412
|
-
}
|
|
413
|
-
return false
|
|
414
|
-
}
|
|
415
|
-
|
|
416
|
-
/**
|
|
417
|
-
* Append-mode counterpart of `updateTrailingAssistant` for post-MESSAGE_END
|
|
418
|
-
* continuation chunks (`SegmentsUpdateMetadata.append`). The processor emits
|
|
419
|
-
* single text/thinking fragments there — replacing the bubble with them (the
|
|
420
|
-
* old behaviour, which dropped the metadata) wiped everything the bubble
|
|
421
|
-
* already showed. Coalesces trailing fragments of the same type, mirroring
|
|
422
|
-
* the accumulator.
|
|
423
|
-
*
|
|
424
|
-
* Exported for host reuse (custom stores wiring the same processor) + tests.
|
|
425
|
-
*/
|
|
426
|
-
export function appendToTrailingAssistant(
|
|
427
|
-
prev: UnifiedChatMessage[],
|
|
428
|
-
segments: MessageSegment[],
|
|
429
|
-
): UnifiedChatMessage[] {
|
|
430
|
-
if (segments.length === 0) return prev
|
|
431
|
-
const last = prev[prev.length - 1]
|
|
432
|
-
if (!last || last.role !== 'assistant') {
|
|
433
|
-
return [
|
|
434
|
-
...prev,
|
|
435
|
-
{ id: nextId('assistant'), role: 'assistant', content: '', segments },
|
|
436
|
-
]
|
|
437
|
-
}
|
|
438
|
-
const merged = [...(last.segments ?? [])]
|
|
439
|
-
for (const seg of segments) {
|
|
440
|
-
const tail = merged[merged.length - 1]
|
|
441
|
-
if (seg.type === 'text' && tail?.type === 'text') {
|
|
442
|
-
merged[merged.length - 1] = { type: 'text', text: tail.text + seg.text }
|
|
443
|
-
} else if (seg.type === 'thinking' && tail?.type === 'thinking') {
|
|
444
|
-
merged[merged.length - 1] = { type: 'thinking', text: tail.text + seg.text }
|
|
445
|
-
} else if (seg.type === 'guide' && tail?.type === 'guide') {
|
|
446
|
-
merged[merged.length - 1] = { type: 'guide', text: tail.text + seg.text }
|
|
447
|
-
} else if (seg.type === 'approval_batch') {
|
|
448
|
-
// Approval deltas must be IDEMPOTENT: the escalated-result emit can be
|
|
449
|
-
// seen twice (live + catch-up replay over hydrated history), so upsert
|
|
450
|
-
// by request id instead of raw-appending a duplicate card.
|
|
451
|
-
const idx = merged.findIndex(
|
|
452
|
-
(m) => m.type === 'approval_batch' && m.data.approvalRequestId === seg.data.approvalRequestId,
|
|
453
|
-
)
|
|
454
|
-
if (idx !== -1) merged[idx] = seg
|
|
455
|
-
else merged.push(seg)
|
|
456
|
-
} else if (seg.type === 'approval_request') {
|
|
457
|
-
// Legacy cards share one requestId across an unfolded batch — key on
|
|
458
|
-
// (requestId, command) so each tool's card upserts its own twin.
|
|
459
|
-
const idx = merged.findIndex(
|
|
460
|
-
(m) =>
|
|
461
|
-
m.type === 'approval_request' &&
|
|
462
|
-
m.data.requestId === seg.data.requestId &&
|
|
463
|
-
m.data.command === seg.data.command,
|
|
464
|
-
)
|
|
465
|
-
if (idx !== -1) merged[idx] = seg
|
|
466
|
-
else merged.push(seg)
|
|
467
|
-
} else {
|
|
468
|
-
// Other non-text segments are pushed RAW on purpose: EXECUTING↔EXECUTED
|
|
469
|
-
// pairing and batch merging are `applyToolExecutionToMessages`'s job
|
|
470
|
-
// (post-END tool chunks never reach this helper — the processor routes
|
|
471
|
-
// them to onToolExecuted). Running the accumulator here would
|
|
472
|
-
// double-apply those rules.
|
|
473
|
-
merged.push(seg)
|
|
474
|
-
}
|
|
475
|
-
}
|
|
476
|
-
return [...prev.slice(0, -1), { ...last, segments: merged }]
|
|
477
|
-
}
|
|
478
|
-
|
|
479
|
-
/**
|
|
480
|
-
* Upsert a standalone context-compaction segment into the trailing assistant
|
|
481
|
-
* bubble. Compaction emissions arrive as the accumulator's CUMULATIVE array —
|
|
482
|
-
* only the compaction segment itself may be applied, or interleaved
|
|
483
|
-
* continuation text would duplicate. A `completed` segment replaces the last
|
|
484
|
-
* `started` one in place.
|
|
485
|
-
*
|
|
486
|
-
* Exported for host reuse + tests.
|
|
487
|
-
*/
|
|
488
|
-
export function upsertTrailingCompaction(
|
|
489
|
-
prev: UnifiedChatMessage[],
|
|
490
|
-
segments: MessageSegment[],
|
|
491
|
-
): UnifiedChatMessage[] {
|
|
492
|
-
const compaction = [...segments].reverse().find((s) => s.type === 'context_compaction')
|
|
493
|
-
if (!compaction) return prev
|
|
494
|
-
const last = prev[prev.length - 1]
|
|
495
|
-
if (!last || last.role !== 'assistant') {
|
|
496
|
-
return [
|
|
497
|
-
...prev,
|
|
498
|
-
{ id: nextId('assistant'), role: 'assistant', content: '', segments: [compaction] },
|
|
499
|
-
]
|
|
500
|
-
}
|
|
501
|
-
const existing = last.segments ?? []
|
|
502
|
-
// LAST 'started' segment (not first): with repeated compactions in one
|
|
503
|
-
// bubble the earlier ones are already completed-in-place, so the newest
|
|
504
|
-
// 'started' is the only one this completion can belong to.
|
|
505
|
-
const startedIdx = existing.map((s) => s.type === 'context_compaction' && s.status === 'started').lastIndexOf(true)
|
|
506
|
-
const merged =
|
|
507
|
-
startedIdx !== -1
|
|
508
|
-
? existing.map((s, i) => (i === startedIdx ? compaction : s))
|
|
509
|
-
: [...existing, compaction]
|
|
510
|
-
return [...prev.slice(0, -1), { ...last, segments: merged }]
|
|
511
|
-
}
|
|
512
|
-
|
|
513
|
-
/**
|
|
514
|
-
* Cross-message tool-execution updater for post-MESSAGE_END tool chunks
|
|
515
|
-
* (approved commands executing after the approval bubble, async batch
|
|
516
|
-
* results). Scans messages from the end:
|
|
517
|
-
* 1) an `approval_batch` whose `toolCalls` contains the execution id →
|
|
518
|
-
* merge into its `executions` map;
|
|
519
|
-
* 2) a matching `tool_execution` segment (same id, or EXECUTING with the
|
|
520
|
-
* same tool for legacy id-less backends) → update in place;
|
|
521
|
-
* 3) no match → append the segment to the trailing assistant bubble.
|
|
522
|
-
*
|
|
523
|
-
* Exported for host reuse + tests.
|
|
524
|
-
*/
|
|
525
|
-
export function applyToolExecutionToMessages(
|
|
526
|
-
prev: UnifiedChatMessage[],
|
|
527
|
-
segment: ToolExecutionSegment,
|
|
528
|
-
): UnifiedChatMessage[] {
|
|
529
|
-
const toolData = segment.data
|
|
530
|
-
const execId = toolData.toolExecutionRequestId
|
|
531
|
-
|
|
532
|
-
for (let i = prev.length - 1; i >= 0; i--) {
|
|
533
|
-
const message = prev[i]
|
|
534
|
-
if (message.role !== 'assistant' || !message.segments) continue
|
|
535
|
-
|
|
536
|
-
for (let j = message.segments.length - 1; j >= 0; j--) {
|
|
537
|
-
const seg = message.segments[j]
|
|
538
|
-
|
|
539
|
-
if (
|
|
540
|
-
execId &&
|
|
541
|
-
seg.type === 'approval_batch' &&
|
|
542
|
-
seg.data.toolCalls.some((c) => c.toolExecutionRequestId === execId)
|
|
543
|
-
) {
|
|
544
|
-
const prevExec: ApprovalBatchExecutionState | undefined = seg.data.executions?.[execId]
|
|
545
|
-
// Never downgrade a done batch slot back to executing (JetStream
|
|
546
|
-
// redelivery of the EXECUTING chunk after EXECUTED landed). Returning
|
|
547
|
-
// prev = matched-with-no-change, so the caller doesn't append either.
|
|
548
|
-
if (toolData.type === 'EXECUTING_TOOL' && prevExec?.status === 'done') {
|
|
549
|
-
return prev
|
|
550
|
-
}
|
|
551
|
-
const nextExec: ApprovalBatchExecutionState =
|
|
552
|
-
toolData.type === 'EXECUTED_TOOL'
|
|
553
|
-
? { status: 'done', result: toolData.result, success: toolData.success }
|
|
554
|
-
: { status: 'executing', result: prevExec?.result, success: prevExec?.success }
|
|
555
|
-
const nextSegments = [...message.segments]
|
|
556
|
-
nextSegments[j] = {
|
|
557
|
-
...seg,
|
|
558
|
-
data: { ...seg.data, executions: { ...(seg.data.executions ?? {}), [execId]: nextExec } },
|
|
559
|
-
}
|
|
560
|
-
const next = [...prev]
|
|
561
|
-
next[i] = { ...message, segments: nextSegments }
|
|
562
|
-
return next
|
|
563
|
-
}
|
|
564
|
-
|
|
565
|
-
if (seg.type === 'tool_execution') {
|
|
566
|
-
// KNOWN LIMIT (id-less chunks only — current backends always send
|
|
567
|
-
// toolExecutionRequestId): the fuzzy fallback pairs only with a
|
|
568
|
-
// segment still EXECUTING, so a replayed id-less EXECUTING/EXECUTED
|
|
569
|
-
// arriving after the pair completed matches nothing and falls to the
|
|
570
|
-
// append below (duplicate card). Widening the predicate to EXECUTED
|
|
571
|
-
// twins would instead swallow a LEGITIMATE second run of the same
|
|
572
|
-
// tool — without ids the two are indistinguishable, and losing a
|
|
573
|
-
// real run is worse than a duplicate card on a legacy transport.
|
|
574
|
-
const matches = execId
|
|
575
|
-
? seg.data.toolExecutionRequestId === execId
|
|
576
|
-
: seg.data.type === 'EXECUTING_TOOL' &&
|
|
577
|
-
seg.data.integratedToolType === toolData.integratedToolType &&
|
|
578
|
-
seg.data.toolFunction === toolData.toolFunction
|
|
579
|
-
if (!matches) continue
|
|
580
|
-
// Never downgrade a completed segment back to EXECUTING (replayed
|
|
581
|
-
// EXECUTING chunk after its EXECUTED already landed).
|
|
582
|
-
if (toolData.type === 'EXECUTING_TOOL' && seg.data.type === 'EXECUTED_TOOL') {
|
|
583
|
-
return prev
|
|
584
|
-
}
|
|
585
|
-
const nextSegments = [...message.segments]
|
|
586
|
-
nextSegments[j] = {
|
|
587
|
-
type: 'tool_execution',
|
|
588
|
-
data: {
|
|
589
|
-
...toolData,
|
|
590
|
-
toolTitle: toolData.toolTitle ?? seg.data.toolTitle,
|
|
591
|
-
toolExplanation: toolData.toolExplanation ?? seg.data.toolExplanation,
|
|
592
|
-
parameters: toolData.parameters || seg.data.parameters,
|
|
593
|
-
},
|
|
594
|
-
}
|
|
595
|
-
const next = [...prev]
|
|
596
|
-
next[i] = { ...message, segments: nextSegments }
|
|
597
|
-
return next
|
|
598
|
-
}
|
|
599
|
-
}
|
|
600
|
-
}
|
|
601
|
-
|
|
602
|
-
return appendToTrailingAssistant(prev, [segment])
|
|
603
|
-
}
|
|
604
|
-
|
|
605
341
|
/**
|
|
606
342
|
* Map `ProcessedMessage` (lib's historical-message format) into
|
|
607
343
|
* `UnifiedChatMessage` (the unified-chat-state contract). Only `user`
|
|
@@ -671,6 +407,9 @@ export function historicalToUnified(
|
|
|
671
407
|
// Hook
|
|
672
408
|
// =============================================================================
|
|
673
409
|
|
|
410
|
+
/** Store key for the draft ("no dialog selected") state. */
|
|
411
|
+
const DRAFT_DIALOG_KEY = '__draft__'
|
|
412
|
+
|
|
674
413
|
export function useNatsChatAdapter(
|
|
675
414
|
config: UseNatsChatAdapterConfig,
|
|
676
415
|
options: UseNatsChatAdapterOptions = {},
|
|
@@ -726,56 +465,109 @@ export function useNatsChatAdapter(
|
|
|
726
465
|
const currentDialogIdRef = useRef<string | null>(dialogId)
|
|
727
466
|
currentDialogIdRef.current = dialogId
|
|
728
467
|
|
|
729
|
-
// ───
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
// Live mirror for click handlers that must read the CURRENT phase without
|
|
734
|
-
// re-creating on every phase change (handleApprove's scoped revert).
|
|
735
|
-
const streamingPhaseRef = useRef(streamingPhase)
|
|
736
|
-
streamingPhaseRef.current = streamingPhase
|
|
737
|
-
// Gate: suppress onAgentBusy while the initial catchup replays historical
|
|
738
|
-
// chunks. A dead tail (approval approved / tool started, then Stop or a
|
|
739
|
-
// crash — no continuation ever persisted) replays its APPROVAL_RESULT /
|
|
740
|
-
// EXECUTING_TOOL chunks on every dialog (re)open, and the releasing
|
|
741
|
-
// MESSAGE_END never replays — locking the composer forever. Live chunks
|
|
742
|
-
// after catchup lock normally; a reopened mid-execution dialog re-locks on
|
|
743
|
-
// the next live chunk instead.
|
|
744
|
-
// A COUNTER, not a boolean: the initial-catchup window and a reconnect
|
|
745
|
-
// back-fill window can overlap (reconnect during the initial fetch), and
|
|
746
|
-
// with a boolean whichever finished FIRST dropped the other's suppression
|
|
747
|
-
// mid-replay. Each window increments on entry / decrements in its finally.
|
|
748
|
-
const suppressAgentBusyRef = useRef(0)
|
|
749
|
-
|
|
750
|
-
// True when the trailing assistant loaded from history is an INCOMPLETE
|
|
751
|
-
// turn (mid-stream / mid-approval tail). The catchup replay re-streams that
|
|
752
|
-
// turn from its MESSAGE_START, and the replayed stream must ADOPT (replace)
|
|
753
|
-
// the partial history bubble. Any other MESSAGE_START over a non-empty
|
|
754
|
-
// trailing assistant is a NEW turn (observer / second device / post-approval
|
|
755
|
-
// continuation) and must open a fresh bubble instead of overwriting the
|
|
756
|
-
// completed one. Consumed (reset) by the first MESSAGE_START.
|
|
757
|
-
const adoptTrailingAssistantRef = useRef(false)
|
|
468
|
+
// ─── Approval handlers ────────────────────────────────────────────────────
|
|
469
|
+
// Defined before the reducer so the reducer's create-options can wire the
|
|
470
|
+
// approval-card buttons. Optimistically flip status before the network
|
|
471
|
+
// round-trip so the card reflects the user's choice immediately.
|
|
758
472
|
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
473
|
+
const handleApproveRef = useRef<(requestId: string) => Promise<void>>(async () => {})
|
|
474
|
+
const handleRejectRef = useRef<(requestId: string, reason?: string) => Promise<void>>(
|
|
475
|
+
async () => {},
|
|
476
|
+
)
|
|
763
477
|
|
|
764
|
-
//
|
|
765
|
-
//
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
//
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
478
|
+
// Reducer-card adapters. The card contract is `(requestId?) => void |
|
|
479
|
+
// Promise<void>` — single optional arg; narrow at the boundary.
|
|
480
|
+
const accumApprove = useCallback((requestId?: string): void | Promise<void> => {
|
|
481
|
+
if (!requestId) return
|
|
482
|
+
return handleApproveRef.current(requestId)
|
|
483
|
+
}, [])
|
|
484
|
+
const accumReject = useCallback((requestId?: string): void | Promise<void> => {
|
|
485
|
+
if (!requestId) return
|
|
486
|
+
return handleRejectRef.current(requestId)
|
|
487
|
+
}, [])
|
|
488
|
+
|
|
489
|
+
// ─── THE reducer (master stream reader) ───────────────────────────────────
|
|
490
|
+
// One `ChatDialogStore` per adapter instance; one reducer per dialog.
|
|
491
|
+
// Approval statuses are request-id-keyed and GLOBAL across dialogs (a
|
|
492
|
+
// resolved approval must not resurrect as actionable after a dialog
|
|
493
|
+
// switch), so the adapter accumulates them into a ref that seeds every
|
|
494
|
+
// newly-created per-dialog reducer.
|
|
495
|
+
|
|
496
|
+
const storeRef = useRef(createChatDialogStore())
|
|
497
|
+
const globalApprovalStatusesRef = useRef<Record<string, ChatApprovalStatus>>({})
|
|
498
|
+
const dialogKey = dialogId ?? DRAFT_DIALOG_KEY
|
|
499
|
+
|
|
500
|
+
const { state, applyEvent, mutate } = useChatStreamReducer({
|
|
501
|
+
store: storeRef.current,
|
|
502
|
+
dialogId: dialogKey,
|
|
503
|
+
createReducerOptions: () => ({
|
|
504
|
+
transport: 'nats',
|
|
505
|
+
batchApprovalsEnabled,
|
|
506
|
+
...(displayApprovalTypes ? { displayApprovalTypes } : {}),
|
|
507
|
+
approvalStatuses: globalApprovalStatusesRef.current,
|
|
508
|
+
callbacks: { onApprove: accumApprove, onReject: accumReject },
|
|
509
|
+
}),
|
|
510
|
+
})
|
|
511
|
+
|
|
512
|
+
// Accumulate resolved statuses globally (render-time merge is cheap and
|
|
513
|
+
// keeps the ref current for the next dialog's reducer seed).
|
|
514
|
+
globalApprovalStatusesRef.current = {
|
|
515
|
+
...globalApprovalStatusesRef.current,
|
|
516
|
+
...state.approvalStatuses,
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
// Live handleApprove/handleReject bodies (need `mutate` + state access).
|
|
520
|
+
handleApproveRef.current = async (requestId: string) => {
|
|
521
|
+
if (!approveRequestCallback) return
|
|
522
|
+
// Approval hands the turn back to the agent — lock the composer now
|
|
523
|
+
// rather than waiting for the APPROVAL_RESULT/EXECUTING_TOOL chunks.
|
|
524
|
+
// Remember whether THIS click took the lock: if the phase was already
|
|
525
|
+
// busy (another approval's command executing), a failure of this
|
|
526
|
+
// request must not release a lock it doesn't own.
|
|
527
|
+
const store = storeRef.current
|
|
528
|
+
const key = dialogKey
|
|
529
|
+
const tookLock = store.mutate(key, DEFAULT_DIALOG_SIDE, (r) => {
|
|
530
|
+
const took = r.state.streamingPhase === 'idle'
|
|
531
|
+
r.setApprovalStatus(requestId, 'approved')
|
|
532
|
+
if (took) r.setPhase('thinking')
|
|
533
|
+
return took
|
|
534
|
+
})
|
|
535
|
+
try {
|
|
536
|
+
await approveRequestCallback(requestId)
|
|
537
|
+
} catch (err) {
|
|
538
|
+
// Revert the optimistic flip on failure so the user can retry.
|
|
539
|
+
store.mutate(key, DEFAULT_DIALOG_SIDE, (r) => {
|
|
540
|
+
r.setApprovalStatus(requestId, null)
|
|
541
|
+
if (tookLock && r.state.streamingPhase === 'thinking') r.setPhase('idle')
|
|
542
|
+
})
|
|
543
|
+
console.error('[useNatsChatAdapter] approveRequest failed:', err)
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
handleRejectRef.current = async (requestId: string, reason?: string) => {
|
|
547
|
+
if (!rejectRequestCallback) return
|
|
548
|
+
// Deliberately NO phase lock here (asymmetric with approve): rejection
|
|
549
|
+
// keeps the composer free so the user can type a correction right away —
|
|
550
|
+
// the reducer's `approved`-only agent-busy gate is the same asymmetry.
|
|
551
|
+
const store = storeRef.current
|
|
552
|
+
const key = dialogKey
|
|
553
|
+
store.mutate(key, DEFAULT_DIALOG_SIDE, (r) => r.setApprovalStatus(requestId, 'rejected'))
|
|
554
|
+
try {
|
|
555
|
+
await rejectRequestCallback(requestId, reason)
|
|
556
|
+
} catch (err) {
|
|
557
|
+
store.mutate(key, DEFAULT_DIALOG_SIDE, (r) => r.setApprovalStatus(requestId, null))
|
|
558
|
+
console.error('[useNatsChatAdapter] rejectRequest failed:', err)
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
// Decode + apply — the ONLY chunk entry point (catchup routes the live
|
|
563
|
+
// tail through here too, so buffering/dedupe stay consistent).
|
|
564
|
+
const processChunk = useCallback(
|
|
565
|
+
(chunk: unknown) => {
|
|
566
|
+
const event = decodeNatsChunk(chunk)
|
|
567
|
+
if (event) applyEvent(event)
|
|
568
|
+
},
|
|
569
|
+
[applyEvent],
|
|
570
|
+
)
|
|
779
571
|
|
|
780
572
|
// ─── Dialog list state (managed-dialog mode only) ─────────────────────────
|
|
781
573
|
|
|
@@ -790,383 +582,15 @@ export function useNatsChatAdapter(
|
|
|
790
582
|
const [messagesNextCursor, setMessagesNextCursor] = useState<string | null>(null)
|
|
791
583
|
const [isMessagesLoading, setIsMessagesLoading] = useState<boolean>(false)
|
|
792
584
|
|
|
793
|
-
// ─── Token usage (Mingo per-dialog cumulative) ────────────────────────────
|
|
794
|
-
|
|
795
|
-
const [dialogTokenUsage, setDialogTokenUsage] = useState<DialogTokenUsage | null>(
|
|
796
|
-
null,
|
|
797
|
-
)
|
|
798
|
-
|
|
799
|
-
// ─── Live model metadata (from streaming `metadata` frames) ───────────────
|
|
800
|
-
// Refines the composer's model badge per-turn. Falls back to the config
|
|
801
|
-
// baseline (`modelProvider`/`modelLabel`) until the first frame lands.
|
|
802
|
-
const [liveModel, setLiveModel] = useState<{
|
|
803
|
-
provider: string | null
|
|
804
|
-
modelLabel: string | null
|
|
805
|
-
contextWindowMaxTokens: number | null
|
|
806
|
-
} | null>(null)
|
|
807
|
-
|
|
808
585
|
// ─── Connection state ─────────────────────────────────────────────────────
|
|
809
586
|
|
|
810
587
|
const [connectionState, setConnectionState] =
|
|
811
588
|
useState<ChatConnectionState>('connecting')
|
|
812
589
|
|
|
813
|
-
//
|
|
814
|
-
//
|
|
815
|
-
//
|
|
816
|
-
|
|
817
|
-
const handleApprove = useCallback(
|
|
818
|
-
async (requestId: string) => {
|
|
819
|
-
if (!approveRequestCallback) return
|
|
820
|
-
setApprovalStatuses((prev) => ({ ...prev, [requestId]: 'approved' }))
|
|
821
|
-
// Approval hands the turn back to the agent — lock the composer now
|
|
822
|
-
// rather than waiting for the APPROVAL_RESULT/EXECUTING_TOOL chunks.
|
|
823
|
-
// Remember whether THIS click took the lock: if the phase was already
|
|
824
|
-
// busy (another approval's command executing), a failure of this
|
|
825
|
-
// request must not release a lock it doesn't own.
|
|
826
|
-
const tookLock = streamingPhaseRef.current === 'idle'
|
|
827
|
-
setStreamingPhase((p) => (p === 'idle' ? 'thinking' : p))
|
|
828
|
-
try {
|
|
829
|
-
await approveRequestCallback(requestId)
|
|
830
|
-
} catch (err) {
|
|
831
|
-
// Revert the optimistic flip on failure so the user can retry.
|
|
832
|
-
setApprovalStatuses((prev) => {
|
|
833
|
-
const next = { ...prev }
|
|
834
|
-
delete next[requestId]
|
|
835
|
-
return next
|
|
836
|
-
})
|
|
837
|
-
if (tookLock) {
|
|
838
|
-
setStreamingPhase((p) => (p === 'thinking' ? 'idle' : p))
|
|
839
|
-
}
|
|
840
|
-
console.error('[useNatsChatAdapter] approveRequest failed:', err)
|
|
841
|
-
}
|
|
842
|
-
},
|
|
843
|
-
[approveRequestCallback],
|
|
844
|
-
)
|
|
845
|
-
|
|
846
|
-
const handleReject = useCallback(
|
|
847
|
-
async (requestId: string, reason?: string) => {
|
|
848
|
-
if (!rejectRequestCallback) return
|
|
849
|
-
// Deliberately NO phase lock here (asymmetric with handleApprove):
|
|
850
|
-
// rejection keeps the composer free so the user can type a correction
|
|
851
|
-
// right away — see the matching `approved`-only onAgentBusy guard in
|
|
852
|
-
// use-realtime-chunk-processor's approval_result case. If the agent
|
|
853
|
-
// does stream an acknowledgment, MESSAGE_START locks the composer then.
|
|
854
|
-
setApprovalStatuses((prev) => ({ ...prev, [requestId]: 'rejected' }))
|
|
855
|
-
try {
|
|
856
|
-
await rejectRequestCallback(requestId, reason)
|
|
857
|
-
} catch (err) {
|
|
858
|
-
setApprovalStatuses((prev) => {
|
|
859
|
-
const next = { ...prev }
|
|
860
|
-
delete next[requestId]
|
|
861
|
-
return next
|
|
862
|
-
})
|
|
863
|
-
console.error('[useNatsChatAdapter] rejectRequest failed:', err)
|
|
864
|
-
}
|
|
865
|
-
},
|
|
866
|
-
[rejectRequestCallback],
|
|
867
|
-
)
|
|
868
|
-
|
|
869
|
-
// Stable refs so the accumulator's callbacks don't churn on re-render.
|
|
870
|
-
const handleApproveRef = useRef(handleApprove)
|
|
871
|
-
handleApproveRef.current = handleApprove
|
|
872
|
-
const handleRejectRef = useRef(handleReject)
|
|
873
|
-
handleRejectRef.current = handleReject
|
|
874
|
-
|
|
875
|
-
// Accumulator-compatible adapters. The lib's accumulator contract is
|
|
876
|
-
// `(requestId?: string) => void | Promise<void>` — single optional arg,
|
|
877
|
-
// no rejection reason. Our public API surface widens that to require a
|
|
878
|
-
// string + accept a reason, so we narrow here at the boundary. Reason
|
|
879
|
-
// is unavailable from button-click callers (the accumulator doesn't
|
|
880
|
-
// pass it) so we only forward the requestId; consumers that need
|
|
881
|
-
// structured reject reasons can call `rejectRequest` directly.
|
|
882
|
-
const accumApprove = useCallback((requestId?: string): void | Promise<void> => {
|
|
883
|
-
if (!requestId) return
|
|
884
|
-
return handleApproveRef.current(requestId)
|
|
885
|
-
}, [])
|
|
886
|
-
const accumReject = useCallback((requestId?: string): void | Promise<void> => {
|
|
887
|
-
if (!requestId) return
|
|
888
|
-
return handleRejectRef.current(requestId)
|
|
889
|
-
}, [])
|
|
890
|
-
|
|
891
|
-
// Stable callback ref so `useRealtimeChunkProcessor`'s options object
|
|
892
|
-
// doesn't churn every render and tear down the accumulator state.
|
|
893
|
-
const callbacksRef: MutableRefObject<{
|
|
894
|
-
onSegmentsUpdate: (segments: MessageSegment[], meta?: SegmentsUpdateMetadata) => void
|
|
895
|
-
onStreamStart: () => void
|
|
896
|
-
onStreamEnd: () => void
|
|
897
|
-
onAgentBusy: () => void
|
|
898
|
-
onError: () => void
|
|
899
|
-
onTokenUsage: (data: DialogTokenUsage) => void
|
|
900
|
-
onMetadata: (meta: {
|
|
901
|
-
modelDisplayName?: string
|
|
902
|
-
modelName: string
|
|
903
|
-
providerName: string
|
|
904
|
-
contextWindow: number
|
|
905
|
-
}) => void
|
|
906
|
-
onToolExecuted: (segment: ToolExecutionSegment) => void
|
|
907
|
-
onApprovalResolved: (
|
|
908
|
-
requestId: string,
|
|
909
|
-
status: ChatApprovalStatus,
|
|
910
|
-
approvalType: string,
|
|
911
|
-
resolvedByName?: string | null,
|
|
912
|
-
) => void
|
|
913
|
-
onUserMessage: (
|
|
914
|
-
text: string,
|
|
915
|
-
meta?: { ownerType?: string; displayName?: string; userId?: string; streamSeq?: number; contextItems?: Array<{ type: string; id: string }> },
|
|
916
|
-
) => void
|
|
917
|
-
onDirectMessage: (
|
|
918
|
-
text: string,
|
|
919
|
-
meta?: { ownerType?: string; displayName?: string; userId?: string; streamSeq?: number },
|
|
920
|
-
) => void
|
|
921
|
-
onSystemMessage: (text: string, meta?: { streamSeq?: number }) => void
|
|
922
|
-
}> = useRef({
|
|
923
|
-
onSegmentsUpdate: (segments: MessageSegment[], meta?: SegmentsUpdateMetadata) => {
|
|
924
|
-
// Standalone compaction updates carry the accumulator's cumulative
|
|
925
|
-
// array — apply only the compaction segment (upsert) or interleaved
|
|
926
|
-
// continuation text would duplicate.
|
|
927
|
-
if (meta?.append && meta.isCompacting) {
|
|
928
|
-
setMessages((prev) => upsertTrailingCompaction(prev, segments))
|
|
929
|
-
return
|
|
930
|
-
}
|
|
931
|
-
// Post-MESSAGE_END continuation fragments append into the existing
|
|
932
|
-
// bubble; replacing (the pre-fix behaviour, which dropped `meta`)
|
|
933
|
-
// wiped the completed reply and left only the newest fragment.
|
|
934
|
-
if (meta?.append) {
|
|
935
|
-
setMessages((prev) => appendToTrailingAssistant(prev, segments))
|
|
936
|
-
return
|
|
937
|
-
}
|
|
938
|
-
setMessages((prev) => updateTrailingAssistant(prev, segments))
|
|
939
|
-
},
|
|
940
|
-
onStreamStart: () => {
|
|
941
|
-
setStreamingPhase('streaming')
|
|
942
|
-
// A new stream must never overwrite a COMPLETED trailing assistant
|
|
943
|
-
// bubble (observer tab / second device / post-approval continuation
|
|
944
|
-
// turn). Open a fresh bubble unless the trailing assistant is empty
|
|
945
|
-
// (our own optimistic placeholder) or is the incomplete history tail
|
|
946
|
-
// the catchup replay is legitimately re-streaming (adopt-once flag).
|
|
947
|
-
const adoptTail = adoptTrailingAssistantRef.current
|
|
948
|
-
adoptTrailingAssistantRef.current = false
|
|
949
|
-
setMessages((prev) => {
|
|
950
|
-
const last = prev[prev.length - 1]
|
|
951
|
-
if (!last || last.role !== 'assistant' || adoptTail) return prev
|
|
952
|
-
const hasContent = (last.segments?.length ?? 0) > 0 || last.content !== ''
|
|
953
|
-
if (!hasContent) return prev
|
|
954
|
-
return [
|
|
955
|
-
...prev,
|
|
956
|
-
{ id: nextId('assistant'), role: 'assistant', content: '', segments: [] },
|
|
957
|
-
]
|
|
958
|
-
})
|
|
959
|
-
},
|
|
960
|
-
onStreamEnd: () => setStreamingPhase('idle'),
|
|
961
|
-
// Agent is executing (approved) commands outside an open stream — keep
|
|
962
|
-
// the composer locked exactly like the in-stream phases. An open stream
|
|
963
|
-
// keeps ownership: only 'idle' upgrades to 'thinking'. Suppressed during
|
|
964
|
-
// initial catchup so a replayed dead tail can't lock a finished dialog.
|
|
965
|
-
onAgentBusy: () => {
|
|
966
|
-
if (suppressAgentBusyRef.current > 0) return
|
|
967
|
-
setStreamingPhase((p) => (p === 'idle' ? 'thinking' : p))
|
|
968
|
-
},
|
|
969
|
-
// Terminal turn failures can arrive without a MESSAGE_END; unlock the
|
|
970
|
-
// composer unless an open stream still owns the phase (in-stream errors
|
|
971
|
-
// are followed by the stream's own MESSAGE_END).
|
|
972
|
-
onError: () => setStreamingPhase((p) => (p === 'streaming' ? p : 'idle')),
|
|
973
|
-
// Live cumulative token counter for the active dialog — drives the
|
|
974
|
-
// composer's "X / Y tokens used" tail as the assistant streams.
|
|
975
|
-
onTokenUsage: (data: DialogTokenUsage) => setDialogTokenUsage(data),
|
|
976
|
-
// Per-turn model badge (provider/label/context window).
|
|
977
|
-
onMetadata: (meta) =>
|
|
978
|
-
setLiveModel({
|
|
979
|
-
provider: meta.providerName || null,
|
|
980
|
-
modelLabel: meta.modelDisplayName || meta.modelName || null,
|
|
981
|
-
contextWindowMaxTokens: meta.contextWindow || null,
|
|
982
|
-
}),
|
|
983
|
-
// Post-MESSAGE_END tool chunks (approved commands executing after the
|
|
984
|
-
// approval bubble, async batch results). Routed through the cross-message
|
|
985
|
-
// updater — the accumulator was reset at MESSAGE_END, so the old
|
|
986
|
-
// fallthrough replaced the whole trailing bubble with one tool segment.
|
|
987
|
-
onToolExecuted: (segment: ToolExecutionSegment) => {
|
|
988
|
-
setMessages((prev) => applyToolExecutionToMessages(prev, segment))
|
|
989
|
-
},
|
|
990
|
-
// Cross-message approval status flip. Wiring this also makes the
|
|
991
|
-
// processor skip its cumulative re-emit for resolved approvals — which,
|
|
992
|
-
// post-MESSAGE_END, was an empty array that blanked the trailing bubble.
|
|
993
|
-
onApprovalResolved: (requestId, status, _approvalType, resolvedByName) => {
|
|
994
|
-
setMessages((prev) => {
|
|
995
|
-
let changed = false
|
|
996
|
-
const next = prev.map((m) => {
|
|
997
|
-
if (m.role !== 'assistant' || !m.segments) return m
|
|
998
|
-
let msgChanged = false
|
|
999
|
-
const segs = m.segments.map((s) => {
|
|
1000
|
-
if (s.type === 'approval_request' && s.data.requestId === requestId && s.status !== status) {
|
|
1001
|
-
msgChanged = true
|
|
1002
|
-
return { ...s, status }
|
|
1003
|
-
}
|
|
1004
|
-
if (s.type === 'approval_batch' && s.data.approvalRequestId === requestId) {
|
|
1005
|
-
const nextResolvedBy = resolvedByName ?? s.resolvedByName
|
|
1006
|
-
if (s.status === status && nextResolvedBy === s.resolvedByName) return s
|
|
1007
|
-
msgChanged = true
|
|
1008
|
-
return { ...s, status, resolvedByName: nextResolvedBy }
|
|
1009
|
-
}
|
|
1010
|
-
return s
|
|
1011
|
-
})
|
|
1012
|
-
if (!msgChanged) return m
|
|
1013
|
-
changed = true
|
|
1014
|
-
return { ...m, segments: segs }
|
|
1015
|
-
})
|
|
1016
|
-
return changed ? next : prev
|
|
1017
|
-
})
|
|
1018
|
-
// Mirror into the status map so history re-processing (dialog reopen,
|
|
1019
|
-
// pagination) renders the card resolved instead of actionable.
|
|
1020
|
-
setApprovalStatuses((prev) =>
|
|
1021
|
-
prev[requestId] === status ? prev : { ...prev, [requestId]: status },
|
|
1022
|
-
)
|
|
1023
|
-
},
|
|
1024
|
-
// MESSAGE_REQUEST echo — a user message from THIS or another session.
|
|
1025
|
-
onUserMessage: (text, meta) => {
|
|
1026
|
-
if (!text) return
|
|
1027
|
-
// Layer 1 FIRST: exact event identity. A seq we've handled is a
|
|
1028
|
-
// replay; a fresh seq is appended with NO content matching, so
|
|
1029
|
-
// genuinely repeated texts from other participants always render.
|
|
1030
|
-
// Must run (and RECORD) before the echo consume below — the echo
|
|
1031
|
-
// branch early-returns, and skipping the record there let an
|
|
1032
|
-
// at-least-once redelivery of the same event pass as fresh (and eat
|
|
1033
|
-
// a second pending echo entry).
|
|
1034
|
-
const seq = meta?.streamSeq
|
|
1035
|
-
if (typeof seq === 'number') {
|
|
1036
|
-
if (seenParticipantSeqsRef.current.has(seq)) return
|
|
1037
|
-
seenParticipantSeqsRef.current.add(seq)
|
|
1038
|
-
}
|
|
1039
|
-
// Layer 2: our own optimistic echo — consume exactly one recorded send.
|
|
1040
|
-
if (meta?.ownerType !== 'ADMIN') {
|
|
1041
|
-
const echoIdx = pendingEchoTextsRef.current.indexOf(text)
|
|
1042
|
-
if (echoIdx !== -1) {
|
|
1043
|
-
pendingEchoTextsRef.current.splice(echoIdx, 1)
|
|
1044
|
-
return
|
|
1045
|
-
}
|
|
1046
|
-
}
|
|
1047
|
-
const authorType = meta?.ownerType === 'ADMIN' ? 'admin' : 'user'
|
|
1048
|
-
setMessages((prev) => {
|
|
1049
|
-
// Layer 3: seq-less transports only — same-author content twin in the
|
|
1050
|
-
// immediate tail (replay over just-loaded history).
|
|
1051
|
-
if (
|
|
1052
|
-
typeof seq !== 'number' &&
|
|
1053
|
-
hasRecentMessage(
|
|
1054
|
-
prev,
|
|
1055
|
-
(m) => m.role === 'user' && (m.authorType ?? 'user') === authorType && m.content === text,
|
|
1056
|
-
CONTENT_DEDUP_WINDOW,
|
|
1057
|
-
)
|
|
1058
|
-
) {
|
|
1059
|
-
return prev
|
|
1060
|
-
}
|
|
1061
|
-
return [
|
|
1062
|
-
...prev,
|
|
1063
|
-
{
|
|
1064
|
-
id: nextId('user'),
|
|
1065
|
-
role: 'user',
|
|
1066
|
-
content: text,
|
|
1067
|
-
...(meta?.displayName ? { name: meta.displayName } : {}),
|
|
1068
|
-
authorType,
|
|
1069
|
-
...(meta?.contextItems && meta.contextItems.length > 0
|
|
1070
|
-
? {
|
|
1071
|
-
contextItems: meta.contextItems.map((ci) => ({
|
|
1072
|
-
type: ci.type,
|
|
1073
|
-
id: ci.id,
|
|
1074
|
-
label: (ci as { label?: string }).label ?? ci.id,
|
|
1075
|
-
})),
|
|
1076
|
-
}
|
|
1077
|
-
: {}),
|
|
1078
|
-
},
|
|
1079
|
-
]
|
|
1080
|
-
})
|
|
1081
|
-
},
|
|
1082
|
-
// Technician / admin direct message into the dialog.
|
|
1083
|
-
onDirectMessage: (text, meta) => {
|
|
1084
|
-
if (!text) return
|
|
1085
|
-
const seq = meta?.streamSeq
|
|
1086
|
-
if (typeof seq === 'number') {
|
|
1087
|
-
if (seenParticipantSeqsRef.current.has(seq)) return
|
|
1088
|
-
seenParticipantSeqsRef.current.add(seq)
|
|
1089
|
-
}
|
|
1090
|
-
setMessages((prev) => {
|
|
1091
|
-
// Seq-less fallback matches only prior ADMIN-authored twins — a
|
|
1092
|
-
// client's identical text must never suppress the technician's.
|
|
1093
|
-
if (
|
|
1094
|
-
typeof seq !== 'number' &&
|
|
1095
|
-
hasRecentMessage(
|
|
1096
|
-
prev,
|
|
1097
|
-
(m) => m.role === 'user' && m.authorType === 'admin' && m.content === text,
|
|
1098
|
-
CONTENT_DEDUP_WINDOW,
|
|
1099
|
-
)
|
|
1100
|
-
) {
|
|
1101
|
-
return prev
|
|
1102
|
-
}
|
|
1103
|
-
return [
|
|
1104
|
-
...prev,
|
|
1105
|
-
{
|
|
1106
|
-
id: `direct-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`,
|
|
1107
|
-
role: 'user',
|
|
1108
|
-
content: text,
|
|
1109
|
-
name: meta?.displayName ?? 'Admin',
|
|
1110
|
-
authorType: 'admin',
|
|
1111
|
-
},
|
|
1112
|
-
]
|
|
1113
|
-
})
|
|
1114
|
-
},
|
|
1115
|
-
// System notice — rendered as a name-only row (same shape the history
|
|
1116
|
-
// processor produces via `pushStandaloneMessages`).
|
|
1117
|
-
onSystemMessage: (text, meta) => {
|
|
1118
|
-
if (!text) return
|
|
1119
|
-
const seq = meta?.streamSeq
|
|
1120
|
-
if (typeof seq === 'number') {
|
|
1121
|
-
if (seenParticipantSeqsRef.current.has(seq)) return
|
|
1122
|
-
seenParticipantSeqsRef.current.add(seq)
|
|
1123
|
-
}
|
|
1124
|
-
setMessages((prev) => {
|
|
1125
|
-
if (
|
|
1126
|
-
typeof seq !== 'number' &&
|
|
1127
|
-
hasRecentMessage(prev, (m) => m.authorType === 'system' && m.name === text, SYSTEM_DEDUP_WINDOW)
|
|
1128
|
-
) {
|
|
1129
|
-
return prev
|
|
1130
|
-
}
|
|
1131
|
-
return [
|
|
1132
|
-
...prev,
|
|
1133
|
-
{
|
|
1134
|
-
id: `system-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`,
|
|
1135
|
-
role: 'user',
|
|
1136
|
-
content: '',
|
|
1137
|
-
name: text,
|
|
1138
|
-
authorType: 'system',
|
|
1139
|
-
},
|
|
1140
|
-
]
|
|
1141
|
-
})
|
|
1142
|
-
},
|
|
1143
|
-
})
|
|
1144
|
-
|
|
1145
|
-
// Real-time chunk → segment processor. Approval handlers route through
|
|
1146
|
-
// the refs so a config change (e.g. callback identity churn from the
|
|
1147
|
-
// host) doesn't tear down the accumulator.
|
|
1148
|
-
const { processChunk, reset: resetAccumulator } = useRealtimeChunkProcessor({
|
|
1149
|
-
callbacks: {
|
|
1150
|
-
onSegmentsUpdate: (segments, meta) => callbacksRef.current.onSegmentsUpdate(segments, meta),
|
|
1151
|
-
onStreamStart: () => callbacksRef.current.onStreamStart(),
|
|
1152
|
-
onStreamEnd: () => callbacksRef.current.onStreamEnd(),
|
|
1153
|
-
onAgentBusy: () => callbacksRef.current.onAgentBusy(),
|
|
1154
|
-
onError: () => callbacksRef.current.onError(),
|
|
1155
|
-
onTokenUsage: (data) => callbacksRef.current.onTokenUsage(data),
|
|
1156
|
-
onMetadata: (meta) => callbacksRef.current.onMetadata(meta),
|
|
1157
|
-
onToolExecuted: (segment) => callbacksRef.current.onToolExecuted(segment),
|
|
1158
|
-
onApprovalResolved: (requestId, status, approvalType, resolvedByName) =>
|
|
1159
|
-
callbacksRef.current.onApprovalResolved(requestId, status, approvalType, resolvedByName),
|
|
1160
|
-
onUserMessage: (text, meta) => callbacksRef.current.onUserMessage(text, meta),
|
|
1161
|
-
onDirectMessage: (text, meta) => callbacksRef.current.onDirectMessage(text, meta),
|
|
1162
|
-
onSystemMessage: (text, meta) => callbacksRef.current.onSystemMessage(text, meta),
|
|
1163
|
-
onApprove: accumApprove,
|
|
1164
|
-
onReject: accumReject,
|
|
1165
|
-
},
|
|
1166
|
-
batchApprovalsEnabled,
|
|
1167
|
-
approvalStatuses,
|
|
1168
|
-
...(displayApprovalTypes ? { displayApprovalTypes } : {}),
|
|
1169
|
-
})
|
|
590
|
+
// Set after the first successful NATS connect. Later 'connected' events are
|
|
591
|
+
// RECONNECTS: plain NATS replays nothing, so the adapter must back-fill the
|
|
592
|
+
// disconnect gap via `resetAndCatchUp` or those chunks are lost forever.
|
|
593
|
+
const hasConnectedOnceRef = useRef(false)
|
|
1170
594
|
|
|
1171
595
|
// History catchup — back-fills chunks emitted while the adapter was
|
|
1172
596
|
// inactive or before the WS came online.
|
|
@@ -1186,8 +610,8 @@ export function useNatsChatAdapter(
|
|
|
1186
610
|
// ─── Historical message hydration ─────────────────────────────────────────
|
|
1187
611
|
// When the active dialog changes AND a `fetchDialogMessages` callback
|
|
1188
612
|
// is wired, load the first page of history, process it through the
|
|
1189
|
-
//
|
|
1190
|
-
// dialog then append on top.
|
|
613
|
+
// history decoder + envelope, and seed the reducer. Streaming chunks for
|
|
614
|
+
// the same dialog then append on top.
|
|
1191
615
|
|
|
1192
616
|
// Monotonic guard: each history load captures a sequence number; when a
|
|
1193
617
|
// response resolves, it only applies if it's still the latest request. This
|
|
@@ -1200,6 +624,7 @@ export function useNatsChatAdapter(
|
|
|
1200
624
|
if (!fetchDialogMessages) return
|
|
1201
625
|
historyLoadSeqRef.current += 1
|
|
1202
626
|
const seq = historyLoadSeqRef.current
|
|
627
|
+
const store = storeRef.current
|
|
1203
628
|
setIsMessagesLoading(true)
|
|
1204
629
|
try {
|
|
1205
630
|
const result = await fetchDialogMessages({
|
|
@@ -1217,34 +642,50 @@ export function useNatsChatAdapter(
|
|
|
1217
642
|
chatTypeFilter,
|
|
1218
643
|
onApprove: accumApprove,
|
|
1219
644
|
onReject: accumReject,
|
|
1220
|
-
approvalStatuses,
|
|
645
|
+
approvalStatuses: store.getSnapshot(id).approvalStatuses,
|
|
1221
646
|
batchApprovalsEnabled,
|
|
1222
647
|
...(displayApprovalTypes ? { displayApprovalTypes } : {}),
|
|
1223
648
|
},
|
|
1224
649
|
)
|
|
1225
650
|
const unified = mapProcessedToUnified(rawProcessed)
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
651
|
+
store.mutate(id, DEFAULT_DIALOG_SIDE, (r) => {
|
|
652
|
+
if (cursor === undefined) {
|
|
653
|
+
// First page — replace. When the trailing assistant is an
|
|
654
|
+
// INCOMPLETE turn, the catchup replay will re-stream it from its
|
|
655
|
+
// MESSAGE_START — let that stream adopt (replace) the partial
|
|
656
|
+
// bubble instead of opening a duplicate one.
|
|
657
|
+
// Gated on an ACTIVE catchup buffer: when the catchup already
|
|
658
|
+
// finalized before this history page resolved, no replay is
|
|
659
|
+
// coming to consume the flag — arming it anyway would hand the
|
|
660
|
+
// adoption to the NEXT genuine turn, which would overwrite the
|
|
661
|
+
// partial bubble instead of opening a fresh one.
|
|
662
|
+
// `extractIncompleteTailState` (not the single-row extractor):
|
|
663
|
+
// one logical turn can span several trailing assistant bubbles,
|
|
664
|
+
// so the unfinished artifact may sit above the last row.
|
|
665
|
+
r.armAdoptTrailingAssistant(
|
|
666
|
+
isBufferingActive() && extractIncompleteTailState(rawProcessed) !== undefined,
|
|
667
|
+
)
|
|
668
|
+
// `initializeWithState`, NOT the raw `setMessages` setter: it also
|
|
669
|
+
// derives `resumed` from the restored thread, and that flag is what
|
|
670
|
+
// makes a chunk arriving with no preceding `turn-start` APPEND to
|
|
671
|
+
// the hydrated bubble instead of replacing its segments. With
|
|
672
|
+
// `setMessages` the reducer still looked cold, so the first such
|
|
673
|
+
// chunk — routine, since the turn can open server-side before we
|
|
674
|
+
// subscribe, and the chunk store expires past ~10 min so the replay
|
|
675
|
+
// may deliver nothing — wiped the restored bubble. A pending
|
|
676
|
+
// approval card in that bubble went with it, leaving the agent
|
|
677
|
+
// blocked on a control the user could no longer see. The SSE
|
|
678
|
+
// adapter always hydrated through this door; now both do.
|
|
679
|
+
r.initializeWithState(unified)
|
|
680
|
+
} else {
|
|
681
|
+
// Older page — prepend.
|
|
682
|
+
r.prependMessages(unified)
|
|
683
|
+
}
|
|
684
|
+
if (result.tokenUsage !== undefined) {
|
|
685
|
+
r.setDialogTokenUsage(result.tokenUsage ?? null)
|
|
686
|
+
}
|
|
687
|
+
})
|
|
1244
688
|
setMessagesNextCursor(result.nextCursor)
|
|
1245
|
-
if (result.tokenUsage !== undefined) {
|
|
1246
|
-
setDialogTokenUsage(result.tokenUsage)
|
|
1247
|
-
}
|
|
1248
689
|
} catch (err) {
|
|
1249
690
|
console.error('[useNatsChatAdapter] fetchDialogMessages failed:', err)
|
|
1250
691
|
} finally {
|
|
@@ -1260,7 +701,6 @@ export function useNatsChatAdapter(
|
|
|
1260
701
|
messagesPageSize,
|
|
1261
702
|
assistantName,
|
|
1262
703
|
chatTypeFilter,
|
|
1263
|
-
approvalStatuses,
|
|
1264
704
|
batchApprovalsEnabled,
|
|
1265
705
|
displayApprovalTypes,
|
|
1266
706
|
accumApprove,
|
|
@@ -1281,20 +721,19 @@ export function useNatsChatAdapter(
|
|
|
1281
721
|
// this an in-flight response could repopulate the just-cleared state.
|
|
1282
722
|
historyLoadSeqRef.current += 1
|
|
1283
723
|
|
|
1284
|
-
//
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
setLiveModel(null)
|
|
1295
|
-
setStreamingPhase('idle')
|
|
724
|
+
// Clean slate for the (re)entered dialog: thread, per-turn kernel,
|
|
725
|
+
// participant dedup sets, seq gate, phase. Approval statuses survive
|
|
726
|
+
// per-reducer AND get topped up with the adapter's global map (request
|
|
727
|
+
// ids are dialog-independent).
|
|
728
|
+
storeRef.current.mutate(dialogKey, DEFAULT_DIALOG_SIDE, (r) => {
|
|
729
|
+
r.resetForDialogSwitch()
|
|
730
|
+
// Canonical precedence lives in the reducer: persisted (the adapter's
|
|
731
|
+
// global map) fills gaps, stream-learned statuses always win.
|
|
732
|
+
r.mergeApprovalStatuses(globalApprovalStatusesRef.current)
|
|
733
|
+
})
|
|
1296
734
|
// No load runs when there's no active dialog, so clear the flag here;
|
|
1297
735
|
// otherwise the superseded load's guarded `finally` leaves it stuck on.
|
|
736
|
+
setMessagesNextCursor(null)
|
|
1298
737
|
setIsMessagesLoading(false)
|
|
1299
738
|
|
|
1300
739
|
if (!active || !dialogId) return
|
|
@@ -1302,7 +741,7 @@ export function useNatsChatAdapter(
|
|
|
1302
741
|
// Hydrate history first; then catchup will fold in any chunks that
|
|
1303
742
|
// landed between the history snapshot and the live tail.
|
|
1304
743
|
void loadDialogHistory(dialogId)
|
|
1305
|
-
}, [active, dialogId,
|
|
744
|
+
}, [active, dialogId, dialogKey, loadDialogHistory])
|
|
1306
745
|
|
|
1307
746
|
// Trigger initial chunk backfill whenever a fresh dialog activates.
|
|
1308
747
|
// Runs alongside history hydration — history seeds processed messages,
|
|
@@ -1310,8 +749,14 @@ export function useNatsChatAdapter(
|
|
|
1310
749
|
// was already snapshotted.
|
|
1311
750
|
useEffect(() => {
|
|
1312
751
|
if (!active || !dialogId) return
|
|
1313
|
-
|
|
1314
|
-
|
|
752
|
+
const store = storeRef.current
|
|
753
|
+
// Gate agent-busy locks for the replay window: a dead tail (approval
|
|
754
|
+
// approved / tool started, then Stop or a crash) replays its
|
|
755
|
+
// APPROVAL_RESULT / EXECUTING_TOOL chunks on every dialog (re)open, and
|
|
756
|
+
// the releasing MESSAGE_END never replays — locking the composer
|
|
757
|
+
// forever. The suppression is a COUNTER inside the reducer: the
|
|
758
|
+
// initial-catchup window and a reconnect back-fill window can overlap.
|
|
759
|
+
store.mutate(dialogId, DEFAULT_DIALOG_SIDE, (r) => r.adjustAgentBusySuppression(1))
|
|
1315
760
|
resetChunkTracking()
|
|
1316
761
|
startInitialBuffering()
|
|
1317
762
|
catchUpChunks()
|
|
@@ -1319,16 +764,15 @@ export function useNatsChatAdapter(
|
|
|
1319
764
|
console.error('[useNatsChatAdapter] initial catchup failed:', err)
|
|
1320
765
|
})
|
|
1321
766
|
.finally(() => {
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
}
|
|
767
|
+
store.mutate(dialogId, DEFAULT_DIALOG_SIDE, (r) => {
|
|
768
|
+
r.adjustAgentBusySuppression(-1)
|
|
769
|
+
// The adopt-once flag targets the replayed MESSAGE_START of the
|
|
770
|
+
// incomplete history tail. When the replay produced none (chunk
|
|
771
|
+
// store expired past its ~10-min retention / empty), the armed
|
|
772
|
+
// flag would survive and make the NEXT genuine turn adopt-and-
|
|
773
|
+
// overwrite the partial bubble.
|
|
774
|
+
r.armAdoptTrailingAssistant(false)
|
|
775
|
+
})
|
|
1332
776
|
})
|
|
1333
777
|
}, [active, dialogId, resetChunkTracking, startInitialBuffering, catchUpChunks])
|
|
1334
778
|
|
|
@@ -1361,7 +805,13 @@ export function useNatsChatAdapter(
|
|
|
1361
805
|
hasConnectedOnceRef.current = true
|
|
1362
806
|
return
|
|
1363
807
|
}
|
|
1364
|
-
|
|
808
|
+
const store = storeRef.current
|
|
809
|
+
const reconnectDialogId = dialogId
|
|
810
|
+
if (reconnectDialogId) {
|
|
811
|
+
store.mutate(reconnectDialogId, DEFAULT_DIALOG_SIDE, (r) =>
|
|
812
|
+
r.adjustAgentBusySuppression(1),
|
|
813
|
+
)
|
|
814
|
+
}
|
|
1365
815
|
// Buffer live deliveries for the WHOLE back-fill, including the history
|
|
1366
816
|
// await — otherwise chunks landing mid-fetch are processed unbuffered
|
|
1367
817
|
// (advancing lastSequenceId + dedup keys) and the history replace then
|
|
@@ -1375,28 +825,27 @@ export function useNatsChatAdapter(
|
|
|
1375
825
|
// so the chunk back-fill can't recover them — the persisted history
|
|
1376
826
|
// page is the only source. Then replay the unsaved chunk tail on
|
|
1377
827
|
// top of the fresh snapshot.
|
|
1378
|
-
if (
|
|
1379
|
-
await loadDialogHistory(
|
|
828
|
+
if (reconnectDialogId && fetchDialogMessages) {
|
|
829
|
+
await loadDialogHistory(reconnectDialogId)
|
|
1380
830
|
}
|
|
1381
831
|
// The user may have switched dialogs during the await — the new
|
|
1382
832
|
// dialog's own effects run their catchup cycle; re-running it here
|
|
1383
|
-
//
|
|
1384
|
-
|
|
1385
|
-
if (dialogId !== currentDialogIdRef.current) return
|
|
833
|
+
// would reset that cycle mid-flight.
|
|
834
|
+
if (reconnectDialogId !== currentDialogIdRef.current) return
|
|
1386
835
|
await resetAndCatchUp()
|
|
1387
836
|
} catch (err) {
|
|
1388
837
|
console.error('[useNatsChatAdapter] reconnect catchup failed:', err)
|
|
1389
838
|
} finally {
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
|
|
839
|
+
if (reconnectDialogId) {
|
|
840
|
+
store.mutate(reconnectDialogId, DEFAULT_DIALOG_SIDE, (r) => {
|
|
841
|
+
r.adjustAgentBusySuppression(-1)
|
|
842
|
+
// The adopt-once flag targets the replayed MESSAGE_START of the
|
|
843
|
+
// incomplete tail — but on reconnect that START was already
|
|
844
|
+
// consumed live and is dedup-skipped by the replay, so it never
|
|
845
|
+
// fires. Clear the flag or the NEXT genuine turn adopts (and
|
|
846
|
+
// overwrites) the completed trailing bubble.
|
|
847
|
+
r.armAdoptTrailingAssistant(false)
|
|
848
|
+
})
|
|
1400
849
|
}
|
|
1401
850
|
}
|
|
1402
851
|
})()
|
|
@@ -1476,52 +925,30 @@ export function useNatsChatAdapter(
|
|
|
1476
925
|
): Promise<void> => {
|
|
1477
926
|
const hidden = sendOptions?.hidden ?? false
|
|
1478
927
|
|
|
1479
|
-
// Record the text so the backend's MESSAGE_REQUEST echo consumes this
|
|
1480
|
-
// send instead of rendering a duplicate row (cap keeps the list from
|
|
1481
|
-
// growing if a backend never echoes).
|
|
1482
|
-
pendingEchoTextsRef.current.push(text)
|
|
1483
|
-
if (pendingEchoTextsRef.current.length > 5) pendingEchoTextsRef.current.shift()
|
|
1484
|
-
|
|
1485
928
|
// Optimistically append the user bubble + an empty assistant
|
|
1486
|
-
// placeholder
|
|
1487
|
-
|
|
1488
|
-
|
|
1489
|
-
{
|
|
1490
|
-
id: nextId('user'),
|
|
1491
|
-
role: 'user',
|
|
1492
|
-
content: text,
|
|
1493
|
-
...(hidden ? { hidden: true } : {}),
|
|
1494
|
-
},
|
|
1495
|
-
{
|
|
1496
|
-
id: nextId('assistant'),
|
|
1497
|
-
role: 'assistant',
|
|
1498
|
-
content: '',
|
|
1499
|
-
segments: [],
|
|
1500
|
-
},
|
|
1501
|
-
])
|
|
1502
|
-
setStreamingPhase('thinking')
|
|
929
|
+
// placeholder, record the echo text, and flip the phase — all inside
|
|
930
|
+
// the reducer. The assistant body fills in as NATS chunks land.
|
|
931
|
+
mutate((r) => r.pushOptimisticSend(text, hidden))
|
|
1503
932
|
|
|
1504
933
|
await publishUserMessage(text, { hidden, dialogId })
|
|
1505
934
|
},
|
|
1506
|
-
[publishUserMessage, dialogId],
|
|
935
|
+
[mutate, publishUserMessage, dialogId],
|
|
1507
936
|
)
|
|
1508
937
|
|
|
1509
938
|
const stopMessage = useCallback(() => {
|
|
1510
939
|
// Best-effort UI flip. The actual backend cancellation is gated on
|
|
1511
940
|
// the host-supplied callback.
|
|
1512
|
-
|
|
941
|
+
mutate((r) => r.setPhase('idle'))
|
|
1513
942
|
if (stopGenerationCallback && dialogId) {
|
|
1514
943
|
void stopGenerationCallback(dialogId).catch((err) => {
|
|
1515
944
|
console.error('[useNatsChatAdapter] stopGeneration failed:', err)
|
|
1516
945
|
})
|
|
1517
946
|
}
|
|
1518
|
-
}, [stopGenerationCallback, dialogId])
|
|
947
|
+
}, [mutate, stopGenerationCallback, dialogId])
|
|
1519
948
|
|
|
1520
949
|
const clearMessages = useCallback(() => {
|
|
1521
|
-
|
|
1522
|
-
|
|
1523
|
-
setStreamingPhase('idle')
|
|
1524
|
-
}, [resetAccumulator])
|
|
950
|
+
mutate((r) => r.clearThread())
|
|
951
|
+
}, [mutate])
|
|
1525
952
|
|
|
1526
953
|
const selectDialog = useCallback(
|
|
1527
954
|
(id: string | null) => {
|
|
@@ -1572,6 +999,7 @@ export function useNatsChatAdapter(
|
|
|
1572
999
|
try {
|
|
1573
1000
|
await deleteDialogCallback(id)
|
|
1574
1001
|
setDialogs((prev) => prev.filter((d) => d.id !== id))
|
|
1002
|
+
storeRef.current.remove(id)
|
|
1575
1003
|
if (isManagedMode && internalDialogId === id) {
|
|
1576
1004
|
setInternalDialogId(null)
|
|
1577
1005
|
}
|
|
@@ -1658,6 +1086,7 @@ export function useNatsChatAdapter(
|
|
|
1658
1086
|
|
|
1659
1087
|
// ─── Return shape ─────────────────────────────────────────────────────────
|
|
1660
1088
|
|
|
1089
|
+
const { messages, streamingPhase, dialogTokenUsage = null, liveModel = null } = state
|
|
1661
1090
|
const isLoading = streamingPhase !== 'idle'
|
|
1662
1091
|
const hasMoreDialogs = dialogsNextCursor != null
|
|
1663
1092
|
const hasMoreMessages = messagesNextCursor != null
|