@flamingo-stack/openframe-frontend-core 0.0.518-1663.4312.1 → 0.0.519
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/events.d.ts +15 -1
- package/dist/chat-protocol/events.d.ts.map +1 -1
- package/dist/chat-protocol/index.cjs +25 -0
- package/dist/chat-protocol/index.cjs.map +1 -1
- package/dist/chat-protocol/index.js +25 -1
- package/dist/chat-protocol/index.js.map +1 -1
- package/dist/chat-protocol/nats-decoder.d.ts +5 -0
- package/dist/chat-protocol/nats-decoder.d.ts.map +1 -1
- package/dist/{chunk-UBTOOE55.js → chunk-5OGHZNY5.js} +10 -1
- package/dist/chunk-5OGHZNY5.js.map +1 -0
- package/dist/{chunk-KW7GDLSG.cjs → chunk-5PMRHRMM.cjs} +14 -14
- package/dist/{chunk-KW7GDLSG.cjs.map → chunk-5PMRHRMM.cjs.map} +1 -1
- package/dist/{chunk-MKN6S7SU.cjs → chunk-6FLRFCK4.cjs} +1352 -1152
- package/dist/chunk-6FLRFCK4.cjs.map +1 -0
- package/dist/{chunk-KRBH3PI4.js → chunk-6WQN7NLD.js} +4 -4
- package/dist/{chunk-IIPBTWQR.cjs → chunk-7INXXQVK.cjs} +10 -1
- package/dist/{chunk-IIPBTWQR.cjs.map → chunk-7INXXQVK.cjs.map} +1 -1
- package/dist/{chunk-IVTENGMF.js → chunk-7J4DGFHZ.js} +2 -2
- package/dist/{chunk-WQDDSJWN.cjs → chunk-BFCBR277.cjs} +11 -11
- package/dist/{chunk-WQDDSJWN.cjs.map → chunk-BFCBR277.cjs.map} +1 -1
- package/dist/{chunk-RJU7YCN3.js → chunk-BUZOZOQ2.js} +2 -2
- package/dist/{chunk-URU6YKOA.cjs → chunk-CCK2MFIQ.cjs} +28 -28
- package/dist/{chunk-URU6YKOA.cjs.map → chunk-CCK2MFIQ.cjs.map} +1 -1
- package/dist/{chunk-PV3LXEHJ.js → chunk-CRJRVUWB.js} +3 -3
- package/dist/{chunk-O4XLTA5G.cjs → chunk-FWCENA3G.cjs} +31 -31
- package/dist/{chunk-O4XLTA5G.cjs.map → chunk-FWCENA3G.cjs.map} +1 -1
- package/dist/{chunk-RLVJISO4.cjs → chunk-GLFQSFYV.cjs} +61 -61
- package/dist/{chunk-RLVJISO4.cjs.map → chunk-GLFQSFYV.cjs.map} +1 -1
- package/dist/{chunk-P6M5MN3L.js → chunk-HVXGSQ4V.js} +6 -6
- package/dist/{chunk-MVY3WDFZ.js → chunk-HWA3NZPK.js} +2 -2
- package/dist/{chunk-TFIPJAA5.js → chunk-JMGVYERE.js} +3 -3
- package/dist/{chunk-25JSVBPR.cjs → chunk-KCDKQXE5.cjs} +12 -12
- package/dist/{chunk-25JSVBPR.cjs.map → chunk-KCDKQXE5.cjs.map} +1 -1
- package/dist/{chunk-YUQNJXSC.js → chunk-MEWHQVOU.js} +2 -2
- package/dist/{chunk-K26W23C2.cjs → chunk-MKPJYEOR.cjs} +6 -6
- package/dist/{chunk-K26W23C2.cjs.map → chunk-MKPJYEOR.cjs.map} +1 -1
- package/dist/{chunk-BQXHC336.cjs → chunk-OBJSXQ67.cjs} +3 -3
- package/dist/{chunk-BQXHC336.cjs.map → chunk-OBJSXQ67.cjs.map} +1 -1
- package/dist/{chunk-QJHBYNHI.js → chunk-OQHHJDUB.js} +4 -4
- package/dist/{chunk-LQ2R7IA2.cjs → chunk-QEGGWVFV.cjs} +26 -26
- package/dist/{chunk-LQ2R7IA2.cjs.map → chunk-QEGGWVFV.cjs.map} +1 -1
- package/dist/{chunk-I6IWT62M.cjs → chunk-QGLEI6T2.cjs} +5 -5
- package/dist/{chunk-I6IWT62M.cjs.map → chunk-QGLEI6T2.cjs.map} +1 -1
- package/dist/{chunk-YH627NWE.cjs → chunk-QQZWLTYU.cjs} +9 -9
- package/dist/{chunk-YH627NWE.cjs.map → chunk-QQZWLTYU.cjs.map} +1 -1
- package/dist/{chunk-NJJWY7SO.js → chunk-S4OAWVKF.js} +2 -2
- package/dist/{chunk-MOZUJ7IN.js → chunk-SAPKMN4W.js} +2 -2
- package/dist/{chunk-MI7WNVWX.js → chunk-UBT2KBK7.js} +6 -6
- package/dist/{chunk-DN3GKQ47.cjs → chunk-V3MAG3G5.cjs} +98 -98
- package/dist/{chunk-DN3GKQ47.cjs.map → chunk-V3MAG3G5.cjs.map} +1 -1
- package/dist/{chunk-XARCNHWZ.js → chunk-VH7BOHRG.js} +3 -3
- package/dist/{chunk-CHMQCXYO.cjs → chunk-WEPJKF65.cjs} +37 -37
- package/dist/{chunk-CHMQCXYO.cjs.map → chunk-WEPJKF65.cjs.map} +1 -1
- package/dist/{chunk-PUBL2NCH.js → chunk-YACGPO7I.js} +2 -2
- package/dist/{chunk-LL4C2NXG.js → chunk-ZA5PFNHR.js} +6073 -5873
- package/dist/chunk-ZA5PFNHR.js.map +1 -0
- package/dist/{chunk-GH5YZSQQ.cjs → chunk-ZGGI4XMF.cjs} +9 -9
- package/dist/{chunk-GH5YZSQQ.cjs.map → chunk-ZGGI4XMF.cjs.map} +1 -1
- package/dist/components/calendar.cjs +3 -3
- package/dist/components/calendar.js +2 -2
- package/dist/components/case-studies/index.cjs +9 -9
- package/dist/components/case-studies/index.js +3 -3
- package/dist/components/chat/ask-display.d.ts +24 -0
- package/dist/components/chat/ask-display.d.ts.map +1 -0
- 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/use-jetstream-dialog-subscription.d.ts +1 -7
- package/dist/components/chat/hooks/use-jetstream-dialog-subscription.d.ts.map +1 -1
- package/dist/components/chat/index.cjs +3 -3
- 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 +4 -4
- package/dist/components/chat/stream/chat-stream-reducer.d.ts.map +1 -1
- package/dist/components/chat/types/api.types.d.ts +4 -22
- package/dist/components/chat/types/api.types.d.ts.map +1 -1
- package/dist/components/chat/types/component.types.d.ts +25 -1
- package/dist/components/chat/types/component.types.d.ts.map +1 -1
- package/dist/components/chat/types/message.types.d.ts +32 -2
- package/dist/components/chat/types/message.types.d.ts.map +1 -1
- package/dist/components/chat/utils/message-segment-accumulator.d.ts +8 -1
- package/dist/components/chat/utils/message-segment-accumulator.d.ts.map +1 -1
- package/dist/components/chat/utils/process-historical-messages.d.ts.map +1 -1
- package/dist/components/contact/index.cjs +4 -4
- package/dist/components/contact/index.js +3 -3
- package/dist/components/docs/index.cjs +6 -6
- package/dist/components/docs/index.js +5 -5
- package/dist/components/embeds/index.cjs +4 -4
- package/dist/components/embeds/index.js +3 -3
- package/dist/components/faq/index.cjs +4 -4
- package/dist/components/faq/index.js +3 -3
- package/dist/components/features/index.cjs +3 -3
- package/dist/components/features/index.js +2 -2
- package/dist/components/help-center-pages/index.cjs +23 -23
- package/dist/components/help-center-pages/index.js +14 -14
- package/dist/components/index.cjs +150 -150
- package/dist/components/index.js +14 -14
- package/dist/components/meeting-scheduler/index.cjs +41 -41
- package/dist/components/meeting-scheduler/index.js +5 -5
- package/dist/components/navigation/index.cjs +3 -3
- package/dist/components/navigation/index.js +2 -2
- package/dist/components/onboarding-guides/index.cjs +6 -6
- package/dist/components/onboarding-guides/index.js +5 -5
- package/dist/components/related-content/index.cjs +4 -4
- package/dist/components/related-content/index.js +3 -3
- package/dist/components/tickets/index.cjs +7 -7
- package/dist/components/tickets/index.js +6 -6
- package/dist/components/ui/button/button.d.ts +1 -1
- package/dist/components/ui/button/button.d.ts.map +1 -1
- package/dist/components/ui/file-manager/index.cjs +15 -15
- package/dist/components/ui/file-manager/index.js +1 -1
- package/dist/components/ui/index.cjs +3 -3
- package/dist/components/ui/index.cjs.map +1 -1
- package/dist/components/ui/index.js +4 -4
- package/dist/index.cjs +3 -3
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +4 -4
- package/dist/utils/index.cjs.map +1 -1
- package/dist/utils/index.js.map +1 -1
- package/package.json +1 -1
- package/src/chat-protocol/__tests__/__snapshots__/nats-decoder-golden.test.ts.snap +30 -0
- package/src/chat-protocol/__tests__/nats-decoder-golden.test.ts +23 -0
- package/src/chat-protocol/events.ts +20 -0
- package/src/chat-protocol/nats-decoder.ts +32 -0
- package/src/components/chat/__tests__/ask-display.test.tsx +101 -0
- package/src/components/chat/__tests__/chat-message-list-ask.test.tsx +100 -0
- package/src/components/chat/ask-display.tsx +151 -0
- package/src/components/chat/chat-message-enhanced.tsx +39 -2
- package/src/components/chat/chat-message-list.tsx +18 -1
- package/src/components/chat/embeddable-chat.tsx +22 -0
- package/src/components/chat/hooks/.use-jetstream-dialog-subscription.md +4 -11
- package/src/components/chat/hooks/use-jetstream-dialog-subscription.ts +6 -71
- package/src/components/chat/index.ts +1 -0
- package/src/components/chat/stream/__tests__/chat-stream-reducer.test.ts +72 -0
- package/src/components/chat/stream/chat-stream-reducer.ts +27 -0
- package/src/components/chat/types/api.types.ts +4 -22
- package/src/components/chat/types/component.types.ts +28 -1
- package/src/components/chat/types/message.types.ts +35 -1
- package/src/components/chat/utils/__tests__/history-merge.test.ts +0 -99
- package/src/components/chat/utils/__tests__/process-historical-messages-golden.test.ts +74 -0
- package/src/components/chat/utils/message-segment-accumulator.ts +15 -0
- package/src/components/chat/utils/process-historical-messages.ts +26 -0
- package/src/components/ui/button/button.tsx +9 -0
- package/dist/chunk-LL4C2NXG.js.map +0 -1
- package/dist/chunk-MKN6S7SU.cjs.map +0 -1
- package/dist/chunk-UBTOOE55.js.map +0 -1
- /package/dist/{chunk-KRBH3PI4.js.map → chunk-6WQN7NLD.js.map} +0 -0
- /package/dist/{chunk-IVTENGMF.js.map → chunk-7J4DGFHZ.js.map} +0 -0
- /package/dist/{chunk-RJU7YCN3.js.map → chunk-BUZOZOQ2.js.map} +0 -0
- /package/dist/{chunk-PV3LXEHJ.js.map → chunk-CRJRVUWB.js.map} +0 -0
- /package/dist/{chunk-P6M5MN3L.js.map → chunk-HVXGSQ4V.js.map} +0 -0
- /package/dist/{chunk-MVY3WDFZ.js.map → chunk-HWA3NZPK.js.map} +0 -0
- /package/dist/{chunk-TFIPJAA5.js.map → chunk-JMGVYERE.js.map} +0 -0
- /package/dist/{chunk-YUQNJXSC.js.map → chunk-MEWHQVOU.js.map} +0 -0
- /package/dist/{chunk-QJHBYNHI.js.map → chunk-OQHHJDUB.js.map} +0 -0
- /package/dist/{chunk-NJJWY7SO.js.map → chunk-S4OAWVKF.js.map} +0 -0
- /package/dist/{chunk-MOZUJ7IN.js.map → chunk-SAPKMN4W.js.map} +0 -0
- /package/dist/{chunk-MI7WNVWX.js.map → chunk-UBT2KBK7.js.map} +0 -0
- /package/dist/{chunk-XARCNHWZ.js.map → chunk-VH7BOHRG.js.map} +0 -0
- /package/dist/{chunk-PUBL2NCH.js.map → chunk-YACGPO7I.js.map} +0 -0
|
@@ -1584,6 +1584,24 @@ function EmbeddableChatInner({
|
|
|
1584
1584
|
[sendMessage, readyAttachments, viewUrlPrefix, clearAttachments, contextItems],
|
|
1585
1585
|
)
|
|
1586
1586
|
|
|
1587
|
+
/**
|
|
1588
|
+
* Picking an option on a clarification card — deliberately NOT `handleSend`.
|
|
1589
|
+
*
|
|
1590
|
+
* `handleSend` belongs to the composer: it appends the staged attachments'
|
|
1591
|
+
* markdown to the text, ships the staged context items, and then clears both.
|
|
1592
|
+
* Routing an ask click through it would (a) send `label + attachment markdown`
|
|
1593
|
+
* where the backend's classifier expects the label VERBATIM to resolve which
|
|
1594
|
+
* reading was picked, and (b) consume a draft's attachments/context into a
|
|
1595
|
+
* one-word answer the user never attached them to. So the label goes out on
|
|
1596
|
+
* its own and whatever is staged in the composer stays staged.
|
|
1597
|
+
*/
|
|
1598
|
+
const handleAskSelect = useCallback(
|
|
1599
|
+
(label: string) => {
|
|
1600
|
+
sendMessage(label)
|
|
1601
|
+
},
|
|
1602
|
+
[sendMessage],
|
|
1603
|
+
)
|
|
1604
|
+
|
|
1587
1605
|
// Admin "try-asking chips" → GUIDE-mode quick-action chips only (Mingo mode
|
|
1588
1606
|
// deliberately doesn't surface them). Clicking one SENDS the query immediately
|
|
1589
1607
|
// via the GuideWelcome `onQuickAction` → `handleSend` at the render site (no
|
|
@@ -2351,6 +2369,10 @@ function EmbeddableChatInner({
|
|
|
2351
2369
|
resolveContextIcon={resolveContextIcon}
|
|
2352
2370
|
renderContextItem={renderContextItem}
|
|
2353
2371
|
renderMention={renderMention}
|
|
2372
|
+
// Gated on `chatLoading` for the same reason the composer
|
|
2373
|
+
// is: no second send while a turn is in flight. Passive
|
|
2374
|
+
// demo hosts (previewMode) stay read-only.
|
|
2375
|
+
onAskSelect={chatLoading || previewMode ? undefined : handleAskSelect}
|
|
2354
2376
|
NavLinkAnchor={NavLinkAnchorViaRuntime}
|
|
2355
2377
|
// Real Mingo drawer: hide the message-list scrollbar
|
|
2356
2378
|
// (scroll stays functional). Scoped here via `className`
|
|
@@ -13,29 +13,22 @@ The single exported hook. Accepts `UseJetStreamDialogSubscriptionOptions` and re
|
|
|
13
13
|
|---|---|
|
|
14
14
|
| `isConnected` | Whether the NATS WebSocket connection is live |
|
|
15
15
|
| `isSubscribed` | Whether the JetStream consumer is active |
|
|
16
|
-
| `reconnectionCount` | Increments whenever the live tail is re-established: a NATS reconnect, a JetStream ordered-consumer recreation, a resync after the page was hidden
|
|
16
|
+
| `reconnectionCount` | Increments whenever the live tail is re-established: a NATS reconnect, a JetStream ordered-consumer recreation, or a resync after the page was hidden. Callers refetch persisted history on every increment |
|
|
17
17
|
| `currentStreamSeq` | Highest stream sequence number observed |
|
|
18
18
|
|
|
19
|
-
**Host-supplied resync (`resyncSignal` option):**
|
|
20
|
-
|
|
21
|
-
A counter the embedder bumps when it knows, from outside the page, that the tail may have missed something — an absence the page could not observe (a native shell whose window visibility never reaches `document.visibilityState`), or a write made on the page's behalf while it was away. Any increase is treated exactly like a reconnect. The value at mount is a baseline, rebased on every `dialogId` change so switching to a conversation with a lower count cannot swallow later resyncs.
|
|
22
|
-
|
|
23
19
|
**Internal refs (stable across renders):**
|
|
24
20
|
|
|
25
21
|
- `clientRef` — shared `NatsClient` instance
|
|
26
22
|
- `lastRecoveryReportRef` — timestamp floor so a flapping consumer cannot storm callers with refetches
|
|
27
|
-
- `resyncTimerRef` — trailing-edge timer collapsing resync requests within `RESYNC_COALESCE_MS`; cleared on dialog change and unmount so a request never crosses a conversation boundary
|
|
28
|
-
- `lastResyncSignalRef` — baseline for `resyncSignal`, rebased per dialog
|
|
29
23
|
- `subscriptionRef` — active `JetStreamSubscriptionHandle`
|
|
30
24
|
- `highestStreamSeqRef` — tracks max stream sequence for gap-free resume
|
|
31
25
|
|
|
32
26
|
**Effect order (declaration order matters):**
|
|
33
27
|
|
|
34
28
|
1. **Connection effect** — acquires/releases the shared NATS client when `enabled` or `wsUrl` changes; drives the `startConnectionLifecycle` which emits status events.
|
|
35
|
-
2. **Dialog reset effect** — resets `highestStreamSeqRef
|
|
36
|
-
3. **Visibility effect** — after the page has been hidden longer than `RESYNC_AFTER_HIDDEN_MS`, returning to view
|
|
37
|
-
4. **
|
|
38
|
-
5. **Subscription effect** — creates or recreates the ephemeral consumer when `isConnected`, `dialogId`, `topic`, `streamName`, or the reconnect counter change.
|
|
29
|
+
2. **Dialog reset effect** — resets `highestStreamSeqRef` when `dialogId` changes so a new dialog never inherits a stale start sequence.
|
|
30
|
+
3. **Visibility effect** — after the page has been hidden longer than `RESYNC_AFTER_HIDDEN_MS`, returning to view bumps the counter: a hidden page can stop receiving without the socket ever closing, and nothing else reports that.
|
|
31
|
+
4. **Subscription effect** — creates or recreates the ephemeral consumer when `isConnected`, `dialogId`, `topic`, `streamName`, or the reconnect counter change.
|
|
39
32
|
|
|
40
33
|
**Deliver policy logic:**
|
|
41
34
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
'use client'
|
|
2
2
|
|
|
3
|
-
import {
|
|
3
|
+
import { useEffect, useRef, useState } from 'react'
|
|
4
4
|
import {
|
|
5
5
|
acquireClient as acquireSharedClient,
|
|
6
6
|
releaseClient as releaseSharedClient,
|
|
@@ -20,24 +20,7 @@ const DEFAULT_STREAM_NAME = 'CHAT_CHUNKS'
|
|
|
20
20
|
* absence worth resyncing for. Alt-tabbing must not churn the consumer; a
|
|
21
21
|
* window that sat in the tray must not come back showing a stale conversation.
|
|
22
22
|
*/
|
|
23
|
-
|
|
24
|
-
/**
|
|
25
|
-
* How long resync requests are gathered before one is acted on.
|
|
26
|
-
*
|
|
27
|
-
* A single reveal can be reported twice: the page sees `visibilitychange`, and
|
|
28
|
-
* a host that knows the window was away reports it too (`resyncSignal`),
|
|
29
|
-
* neither able to tell whether the other noticed. Both land within a frame or
|
|
30
|
-
* two of each other, being driven by the same OS event, and acting on both
|
|
31
|
-
* rebuilds the consumer and refetches history twice for one reveal.
|
|
32
|
-
*
|
|
33
|
-
* Gathered on the TRAILING edge, so a request is deferred and never dropped.
|
|
34
|
-
* Two reports of one reveal collapse into one rebuild; two genuinely different
|
|
35
|
-
* signals — a notification reply landing just before the user opens the window
|
|
36
|
-
* — also collapse into one, which is correct, because a single refetch after
|
|
37
|
-
* the last of them covers both. The cost is a second of latency on a path whose
|
|
38
|
-
* next step is a network round trip.
|
|
39
|
-
*/
|
|
40
|
-
const RESYNC_COALESCE_MS = 1_000
|
|
23
|
+
const RESYNC_AFTER_HIDDEN_MS = 10_000
|
|
41
24
|
/**
|
|
42
25
|
* Floor between two reported recoveries. Every recovery costs the caller a
|
|
43
26
|
* history refetch, and a consumer that cannot hold its sequence — gap, reset,
|
|
@@ -77,7 +60,6 @@ export function useJetStreamDialogSubscription({
|
|
|
77
60
|
clientConfig = {},
|
|
78
61
|
reconnectionBackoff,
|
|
79
62
|
inactiveThresholdMs,
|
|
80
|
-
resyncSignal = 0,
|
|
81
63
|
}: UseJetStreamDialogSubscriptionOptions): UseJetStreamDialogSubscriptionReturn {
|
|
82
64
|
const [isConnected, setIsConnected] = useState(false)
|
|
83
65
|
const [isSubscribed, setIsSubscribed] = useState(false)
|
|
@@ -91,8 +73,6 @@ export function useJetStreamDialogSubscription({
|
|
|
91
73
|
const [currentStreamSeq, setCurrentStreamSeq] = useState<number | null>(null)
|
|
92
74
|
|
|
93
75
|
const lastRecoveryReportRef = useRef(0)
|
|
94
|
-
const resyncTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
|
95
|
-
const lastResyncSignalRef = useRef(resyncSignal)
|
|
96
76
|
const clientRef = useRef<NatsClient | null>(null)
|
|
97
77
|
const subscriptionRef = useRef<JetStreamSubscriptionHandle | null>(null)
|
|
98
78
|
const highestStreamSeqRef = useRef<number | null>(null)
|
|
@@ -265,26 +245,7 @@ export function useJetStreamDialogSubscription({
|
|
|
265
245
|
// ignoring optStartSeq).
|
|
266
246
|
useEffect(() => {
|
|
267
247
|
highestStreamSeqRef.current = null
|
|
268
|
-
// A host's signal counts per dialog (it can carry a per-conversation term),
|
|
269
|
-
// so the baseline has to move with the dialog or a switch to one with a
|
|
270
|
-
// LOWER count would swallow every later resync for it.
|
|
271
|
-
lastResyncSignalRef.current = resyncSignal
|
|
272
248
|
setCurrentStreamSeq(null)
|
|
273
|
-
// A resync gathered for the previous dialog must not land on this one: the
|
|
274
|
-
// counter says nothing about which conversation it meant, so callers would
|
|
275
|
-
// refetch the wrong one. The dialog change supersedes it — the new dialog
|
|
276
|
-
// loads its own history regardless. As cleanup, so it covers unmount too,
|
|
277
|
-
// which is the same rule.
|
|
278
|
-
return () => {
|
|
279
|
-
if (resyncTimerRef.current !== null) {
|
|
280
|
-
clearTimeout(resyncTimerRef.current)
|
|
281
|
-
resyncTimerRef.current = null
|
|
282
|
-
}
|
|
283
|
-
}
|
|
284
|
-
// `resyncSignal` is read as the new baseline, not depended on: reacting to
|
|
285
|
-
// it here would reset the baseline on the very change meant to trigger a
|
|
286
|
-
// resync, and the effect below owns that.
|
|
287
|
-
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
288
249
|
}, [dialogId])
|
|
289
250
|
|
|
290
251
|
// Coming back into view after a real absence is treated exactly like a
|
|
@@ -295,20 +256,8 @@ export function useJetStreamDialogSubscription({
|
|
|
295
256
|
// report that: the tail simply stays quiet, and the conversation on screen
|
|
296
257
|
// stays frozen at whatever was there when the window went away (the user's
|
|
297
258
|
// manual fix is a page refresh, which is exactly the history refetch this
|
|
298
|
-
// triggers).
|
|
299
|
-
// callers to refetch history.
|
|
300
|
-
//
|
|
301
|
-
// Every source funnels through here (see `RESYNC_COALESCE_MS`). Restarting
|
|
302
|
-
// the timer rather than dropping is what makes that safe: a request is only
|
|
303
|
-
// ever folded into the one that follows it, never discarded.
|
|
304
|
-
const requestResync = useCallback(() => {
|
|
305
|
-
if (resyncTimerRef.current !== null) clearTimeout(resyncTimerRef.current)
|
|
306
|
-
resyncTimerRef.current = setTimeout(() => {
|
|
307
|
-
resyncTimerRef.current = null
|
|
308
|
-
setReconnectionCount((c) => c + 1)
|
|
309
|
-
}, RESYNC_COALESCE_MS)
|
|
310
|
-
}, [])
|
|
311
|
-
|
|
259
|
+
// triggers). Bumping the counter rebuilds the consumer from the last sequence
|
|
260
|
+
// seen AND tells callers to refetch history.
|
|
312
261
|
useEffect(() => {
|
|
313
262
|
if (!enabled) return
|
|
314
263
|
let hiddenSince = document.visibilityState === 'hidden' ? Date.now() : 0
|
|
@@ -321,27 +270,13 @@ export function useJetStreamDialogSubscription({
|
|
|
321
270
|
const awayMs = hiddenSince === 0 ? 0 : Date.now() - hiddenSince
|
|
322
271
|
hiddenSince = 0
|
|
323
272
|
if (awayMs >= RESYNC_AFTER_HIDDEN_MS) {
|
|
324
|
-
|
|
273
|
+
setReconnectionCount((c) => c + 1)
|
|
325
274
|
}
|
|
326
275
|
}
|
|
327
276
|
|
|
328
277
|
document.addEventListener('visibilitychange', onVisibilityChange)
|
|
329
278
|
return () => document.removeEventListener('visibilitychange', onVisibilityChange)
|
|
330
|
-
}, [enabled
|
|
331
|
-
|
|
332
|
-
useEffect(() => {
|
|
333
|
-
// Held, not consumed, while disabled: it fires once on enable instead.
|
|
334
|
-
// A host reports a signal against the conversation, not against this hook's
|
|
335
|
-
// readiness, so one arriving while the caller is still assembling the
|
|
336
|
-
// subscription describes a gap in the history the caller is fetching right
|
|
337
|
-
// now. Dropping it would leave that fetch's result stale with nothing left
|
|
338
|
-
// to say so.
|
|
339
|
-
if (!enabled) return
|
|
340
|
-
// The mount value is a baseline, not a signal.
|
|
341
|
-
if (resyncSignal <= lastResyncSignalRef.current) return
|
|
342
|
-
lastResyncSignalRef.current = resyncSignal
|
|
343
|
-
requestResync()
|
|
344
|
-
}, [enabled, resyncSignal, requestResync])
|
|
279
|
+
}, [enabled])
|
|
345
280
|
|
|
346
281
|
// Subscription lifecycle: (re)create the ephemeral JetStream consumer whenever
|
|
347
282
|
// we transition into a connected state for a dialog, and whenever the dialog
|
|
@@ -7,6 +7,7 @@ export * from './context-compaction-display'
|
|
|
7
7
|
export * from './expand-chevron'
|
|
8
8
|
export * from './thinking-display'
|
|
9
9
|
export * from './guide-display'
|
|
10
|
+
export * from './ask-display'
|
|
10
11
|
export * from './error-message-display'
|
|
11
12
|
export * from './chat-container'
|
|
12
13
|
export * from './chat-input'
|
|
@@ -1328,3 +1328,75 @@ describe('agentBusy on initializeWithState', () => {
|
|
|
1328
1328
|
expect(r.state.streamingPhase).toBe('idle')
|
|
1329
1329
|
})
|
|
1330
1330
|
})
|
|
1331
|
+
|
|
1332
|
+
/**
|
|
1333
|
+
* `ask` — the guide-routing clarification card. Not a delta: one chunk carries
|
|
1334
|
+
* the whole card, plus the intro sentence that must render as ordinary answer
|
|
1335
|
+
* text IN FRONT of it. The post-MESSAGE_END path is the interesting one — the
|
|
1336
|
+
* card routinely lands in a continuation, where the delta is appended to the
|
|
1337
|
+
* finished bubble rather than replacing it.
|
|
1338
|
+
*/
|
|
1339
|
+
describe('createChatStreamReducer — ask cards', () => {
|
|
1340
|
+
const ask = (question: string, seq?: number, text?: string): ChatStreamEvent => ({
|
|
1341
|
+
type: 'ask',
|
|
1342
|
+
...(text ? { text } : {}),
|
|
1343
|
+
question,
|
|
1344
|
+
options: [
|
|
1345
|
+
{ label: 'Find documentation', description: 'How the feature works' },
|
|
1346
|
+
{ label: 'Work with workspace data' },
|
|
1347
|
+
],
|
|
1348
|
+
...(seq != null ? { seq } : {}),
|
|
1349
|
+
})
|
|
1350
|
+
|
|
1351
|
+
it('renders the intro as text ahead of the card, in one bubble', () => {
|
|
1352
|
+
const r = createChatStreamReducer({ transport: 'nats' })
|
|
1353
|
+
r.apply({ type: 'turn-start', seq: 1 })
|
|
1354
|
+
r.apply(ask('What do you want to work on?', 2, 'Docs, or your own workspace?'))
|
|
1355
|
+
|
|
1356
|
+
const last = r.state.messages[r.state.messages.length - 1]
|
|
1357
|
+
expect(last.segments).toEqual([
|
|
1358
|
+
{ type: 'text', text: 'Docs, or your own workspace?' },
|
|
1359
|
+
{
|
|
1360
|
+
type: 'ask',
|
|
1361
|
+
question: 'What do you want to work on?',
|
|
1362
|
+
options: [
|
|
1363
|
+
{ label: 'Find documentation', description: 'How the feature works' },
|
|
1364
|
+
{ label: 'Work with workspace data' },
|
|
1365
|
+
],
|
|
1366
|
+
},
|
|
1367
|
+
])
|
|
1368
|
+
})
|
|
1369
|
+
|
|
1370
|
+
it('appends into the finished bubble after MESSAGE_END, keeping the reply', () => {
|
|
1371
|
+
const r = createChatStreamReducer({ transport: 'nats' })
|
|
1372
|
+
r.apply({ type: 'turn-start', seq: 1 })
|
|
1373
|
+
r.apply({ type: 'text-delta', text: 'Working on it. ', seq: 2 })
|
|
1374
|
+
r.apply({ type: 'turn-end', seq: 3 })
|
|
1375
|
+
r.apply(ask('Which one?', 4, 'One thing first: '))
|
|
1376
|
+
|
|
1377
|
+
const last = r.state.messages[r.state.messages.length - 1]
|
|
1378
|
+
// The intro COALESCES onto the completed reply (same rule as a post-END
|
|
1379
|
+
// `text-delta`) — a slice off the accumulator used to drop it here.
|
|
1380
|
+
expect(last.segments).toEqual([
|
|
1381
|
+
{ type: 'text', text: 'Working on it. One thing first: ' },
|
|
1382
|
+
{
|
|
1383
|
+
type: 'ask',
|
|
1384
|
+
question: 'Which one?',
|
|
1385
|
+
options: [
|
|
1386
|
+
{ label: 'Find documentation', description: 'How the feature works' },
|
|
1387
|
+
{ label: 'Work with workspace data' },
|
|
1388
|
+
],
|
|
1389
|
+
},
|
|
1390
|
+
])
|
|
1391
|
+
})
|
|
1392
|
+
|
|
1393
|
+
it('keeps two cards in one turn as separate segments (the card pages them)', () => {
|
|
1394
|
+
const r = createChatStreamReducer({ transport: 'nats' })
|
|
1395
|
+
r.apply({ type: 'turn-start', seq: 1 })
|
|
1396
|
+
r.apply(ask('First?', 2))
|
|
1397
|
+
r.apply(ask('Second?', 3))
|
|
1398
|
+
|
|
1399
|
+
const last = r.state.messages[r.state.messages.length - 1]
|
|
1400
|
+
expect(last.segments?.map((s) => s.type)).toEqual(['ask', 'ask'])
|
|
1401
|
+
})
|
|
1402
|
+
})
|
|
@@ -1041,6 +1041,33 @@ export function createChatStreamReducer(
|
|
|
1041
1041
|
break
|
|
1042
1042
|
}
|
|
1043
1043
|
|
|
1044
|
+
// A clarification card arrives whole, not as deltas. The intro sentence
|
|
1045
|
+
// riding the same chunk goes in FIRST as ordinary answer text so it runs
|
|
1046
|
+
// through the markdown body pipeline (and coalesces with any preamble the
|
|
1047
|
+
// turn already streamed); the card follows as its own segment. Both land
|
|
1048
|
+
// in one emit, so the bubble never shows the card ahead of its lead-in.
|
|
1049
|
+
case 'ask': {
|
|
1050
|
+
if (event.text) accumulator.appendText(event.text)
|
|
1051
|
+
const segments = accumulator.addAsk(event.question, event.options)
|
|
1052
|
+
if (isInStream || !hasEverStreamed) {
|
|
1053
|
+
emitSegments(segments)
|
|
1054
|
+
applySegmentsToState(segments, withSeqMeta(undefined))
|
|
1055
|
+
break
|
|
1056
|
+
}
|
|
1057
|
+
// Post-MESSAGE_END: the delta is spelled out rather than sliced off the
|
|
1058
|
+
// accumulator. `appendText` COALESCES into a trailing text segment, so a
|
|
1059
|
+
// slice would silently drop the intro whenever the continuation already
|
|
1060
|
+
// had text in flight; `appendToTrailingAssistant` re-coalesces it here
|
|
1061
|
+
// instead, exactly like a `text-delta`.
|
|
1062
|
+
const delta: MessageSegment[] = [
|
|
1063
|
+
...(event.text ? [{ type: 'text' as const, text: event.text }] : []),
|
|
1064
|
+
{ type: 'ask' as const, question: event.question, options: event.options },
|
|
1065
|
+
]
|
|
1066
|
+
emitSegments(delta, { append: true })
|
|
1067
|
+
applySegmentsToState(delta, withSeqMeta({ append: true }))
|
|
1068
|
+
break
|
|
1069
|
+
}
|
|
1070
|
+
|
|
1044
1071
|
case 'tool-execution': {
|
|
1045
1072
|
const segment: ToolExecutionSegment = { type: 'tool_execution', data: event.data }
|
|
1046
1073
|
// A starting tool run means the agent's turn is in progress even
|
|
@@ -131,22 +131,6 @@ export interface UseJetStreamDialogSubscriptionOptions {
|
|
|
131
131
|
/** Consumer inactivity threshold in ms before NATS auto-cleans it. Defaults to the
|
|
132
132
|
* client's, and applies only to the first consumer — recreations use nats.ws's. */
|
|
133
133
|
inactiveThresholdMs?: number
|
|
134
|
-
/**
|
|
135
|
-
* A counter the host bumps when it knows, from outside the page, that the tail
|
|
136
|
-
* may have missed something.
|
|
137
|
-
*
|
|
138
|
-
* The hook's own detector is `visibilitychange`, which a browser tab reports
|
|
139
|
-
* honestly and an embedding shell may not — and which says nothing at all
|
|
140
|
-
* about a write made on the page's behalf while it was away. A host holding
|
|
141
|
-
* either kind of knowledge bumps this instead of hoping the page noticed.
|
|
142
|
-
*
|
|
143
|
-
* Treated exactly like a reconnect: the consumer is rebuilt from the highest
|
|
144
|
-
* sequence already seen, and `reconnectionCount` moves so callers refetch
|
|
145
|
-
* persisted history. Any increase counts; the value itself means nothing, and
|
|
146
|
-
* an increase arriving alongside a `visibilitychange` costs one rebuild, not
|
|
147
|
-
* two.
|
|
148
|
-
*/
|
|
149
|
-
resyncSignal?: number
|
|
150
134
|
}
|
|
151
135
|
|
|
152
136
|
export interface UseJetStreamDialogSubscriptionReturn {
|
|
@@ -154,12 +138,10 @@ export interface UseJetStreamDialogSubscriptionReturn {
|
|
|
154
138
|
isSubscribed: boolean
|
|
155
139
|
/**
|
|
156
140
|
* Incremented each time the live tail is re-established: a NATS reconnect, a
|
|
157
|
-
* JetStream ordered-consumer recreation, the page returning to view after
|
|
158
|
-
* being hidden long enough to have missed something
|
|
159
|
-
*
|
|
160
|
-
*
|
|
161
|
-
* refetched. One absence counts once even when two sources report it.
|
|
162
|
-
* Starts at 0.
|
|
141
|
+
* JetStream ordered-consumer recreation, or the page returning to view after
|
|
142
|
+
* being hidden long enough to have missed something. All three mean the same
|
|
143
|
+
* thing to the caller — the tail may have skipped messages, so persisted
|
|
144
|
+
* history has to be refetched. Starts at 0.
|
|
163
145
|
*/
|
|
164
146
|
reconnectionCount: number
|
|
165
147
|
/** Highest JetStream stream sequence observed so far (null before first delivery). */
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
import type { ComponentType, HTMLAttributes, ReactNode, TextareaHTMLAttributes } from 'react'
|
|
6
6
|
import type { ApprovalBlockVariant, AssistantType, AuthorType, ChatApprovalStatus, ConnectionStatus } from './chat.types'
|
|
7
|
-
import type { ApprovalRequestData, Message, MessageSegment, ToolExecutionData,
|
|
7
|
+
import type { ApprovalRequestData, AskSegment, Message, MessageSegment, ToolExecutionData,
|
|
8
8
|
ApprovalResolutionHandler,
|
|
9
9
|
} from './message.types'
|
|
10
10
|
import type { ChatRef } from '../chat-ref.types'
|
|
@@ -212,6 +212,15 @@ export interface ChatMessageEnhancedProps extends Omit<HTMLAttributes<HTMLDivEle
|
|
|
212
212
|
* simply default to undefined on the receiver.
|
|
213
213
|
*/
|
|
214
214
|
NavLinkAnchor?: NavLinkAnchorComponent
|
|
215
|
+
/**
|
|
216
|
+
* Picks an option on an `ask` (clarification) card in this message. Receives
|
|
217
|
+
* the option's label VERBATIM — the host sends it as the user's next message
|
|
218
|
+
* and the backend resolves the reply against the labels it offered.
|
|
219
|
+
*
|
|
220
|
+
* Omit to render ask cards read-only. Keep the identity stable
|
|
221
|
+
* (`useCallback` / module const): the message memo compares it by reference.
|
|
222
|
+
*/
|
|
223
|
+
onAskSelect?: (label: string) => void
|
|
215
224
|
}
|
|
216
225
|
|
|
217
226
|
// ========== Chat Message List Props ==========
|
|
@@ -285,6 +294,10 @@ export interface ChatMessageListProps extends HTMLAttributes<HTMLDivElement> {
|
|
|
285
294
|
* message's ChatMessageEnhanced. Owns the unified click rule
|
|
286
295
|
* (same-origin soft nav, cross-origin new tab). */
|
|
287
296
|
NavLinkAnchor?: NavLinkAnchorComponent
|
|
297
|
+
/** Ask-card option pick. Forwarded verbatim to every message's
|
|
298
|
+
* ChatMessageEnhanced; the host sends the received label as the user's next
|
|
299
|
+
* message. Omit to render ask cards read-only. */
|
|
300
|
+
onAskSelect?: (label: string) => void
|
|
288
301
|
}
|
|
289
302
|
|
|
290
303
|
export interface ChatMessageListRef {
|
|
@@ -567,6 +580,20 @@ export interface GuideDisplayProps extends HTMLAttributes<HTMLDivElement> {
|
|
|
567
580
|
children?: ReactNode
|
|
568
581
|
}
|
|
569
582
|
|
|
583
|
+
// ========== Ask Display Props ==========
|
|
584
|
+
|
|
585
|
+
export interface AskDisplayProps extends Omit<HTMLAttributes<HTMLDivElement>, 'onSelect'> {
|
|
586
|
+
/** The clarification cards to show. One entry per `ask` segment; a run of
|
|
587
|
+
* consecutive segments is passed as ONE card with a pager, so a multi-question
|
|
588
|
+
* turn reads as a single block instead of a stack of near-identical cards. */
|
|
589
|
+
cards: AskSegment[]
|
|
590
|
+
/** Picks an option. Receives the option's `label` VERBATIM — that string is
|
|
591
|
+
* sent as the user's next message and the backend's classifier resolves it
|
|
592
|
+
* against the labels it offered, so it must not be reworded. Omit to render
|
|
593
|
+
* the card read-only (replayed history, observer surfaces). */
|
|
594
|
+
onSelect?: (label: string) => void
|
|
595
|
+
}
|
|
596
|
+
|
|
570
597
|
// ========== Model Display Props ==========
|
|
571
598
|
|
|
572
599
|
export interface ModelDisplayProps extends HTMLAttributes<HTMLDivElement> {
|
|
@@ -11,6 +11,7 @@ export const MESSAGE_TYPE = {
|
|
|
11
11
|
TEXT: 'TEXT',
|
|
12
12
|
THINKING: 'THINKING',
|
|
13
13
|
GUIDE: 'GUIDE',
|
|
14
|
+
ASK: 'ASK',
|
|
14
15
|
EXECUTING_TOOL: 'EXECUTING_TOOL',
|
|
15
16
|
EXECUTED_TOOL: 'EXECUTED_TOOL',
|
|
16
17
|
APPROVAL_REQUEST: 'APPROVAL_REQUEST',
|
|
@@ -190,6 +191,28 @@ export type GuideSegment = {
|
|
|
190
191
|
text: string
|
|
191
192
|
}
|
|
192
193
|
|
|
194
|
+
/** One reading the assistant offers in an `ask` card. `label` is BOTH the row's
|
|
195
|
+
* headline and the exact text sent back when the row is picked — the backend's
|
|
196
|
+
* guide classifier resolves the user's next message against the labels it
|
|
197
|
+
* offered, so the reply must be the label verbatim. `description` is a short
|
|
198
|
+
* clarifying line rendered under it. */
|
|
199
|
+
export type AskOptionData = {
|
|
200
|
+
label: string
|
|
201
|
+
description?: string
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** Clarification card — the assistant asking WHICH reading of an ambiguous
|
|
205
|
+
* question it should answer, rendered as a heading plus a list of clickable
|
|
206
|
+
* options instead of prose bullets. NATS-only (the `ASK` chunk); the intro
|
|
207
|
+
* sentence riding the same chunk becomes an ordinary `text` segment in front
|
|
208
|
+
* of the card, so it goes through the normal markdown body pipeline. Unlike
|
|
209
|
+
* the three delta streams an ask arrives whole — it is never coalesced. */
|
|
210
|
+
export type AskSegment = {
|
|
211
|
+
type: 'ask'
|
|
212
|
+
question: string
|
|
213
|
+
options: AskOptionData[]
|
|
214
|
+
}
|
|
215
|
+
|
|
193
216
|
export type ToolExecutionSegment = {
|
|
194
217
|
type: 'tool_execution'
|
|
195
218
|
data: ToolExecutionData
|
|
@@ -228,7 +251,7 @@ export type ContextCompactionSegment = {
|
|
|
228
251
|
summary?: string
|
|
229
252
|
}
|
|
230
253
|
|
|
231
|
-
export type MessageSegment = TextSegment | ThinkingSegment | GuideSegment | ToolExecutionSegment | ApprovalRequestSegment | ApprovalBatchSegment | ErrorSegment | ContextCompactionSegment
|
|
254
|
+
export type MessageSegment = TextSegment | ThinkingSegment | GuideSegment | AskSegment | ToolExecutionSegment | ApprovalRequestSegment | ApprovalBatchSegment | ErrorSegment | ContextCompactionSegment
|
|
232
255
|
|
|
233
256
|
export type MessageContent = string | MessageSegment[]
|
|
234
257
|
|
|
@@ -253,6 +276,16 @@ export interface GuideMessageData extends MessageDataBase {
|
|
|
253
276
|
text?: string
|
|
254
277
|
}
|
|
255
278
|
|
|
279
|
+
/** Persisted `ASK` row (GraphQL `AskData`). `text` is the intro sentence, which
|
|
280
|
+
* history replays as a text segment ahead of the card — same split the live
|
|
281
|
+
* `ASK` chunk carries. */
|
|
282
|
+
export interface AskMessageData extends MessageDataBase {
|
|
283
|
+
type: 'ASK'
|
|
284
|
+
text?: string
|
|
285
|
+
question?: string
|
|
286
|
+
options?: AskOptionData[]
|
|
287
|
+
}
|
|
288
|
+
|
|
256
289
|
export interface ExecutingToolMessageData extends MessageDataBase {
|
|
257
290
|
type: 'EXECUTING_TOOL'
|
|
258
291
|
integratedToolType?: string
|
|
@@ -335,6 +368,7 @@ export type MessageData =
|
|
|
335
368
|
| TextMessageData
|
|
336
369
|
| ThinkingMessageData
|
|
337
370
|
| GuideMessageData
|
|
371
|
+
| AskMessageData
|
|
338
372
|
| ExecutingToolMessageData
|
|
339
373
|
| ExecutedToolMessageData
|
|
340
374
|
| ApprovalRequestMessageData
|
|
@@ -930,102 +930,3 @@ describe('mid-stream refetch (turn partially persisted)', () => {
|
|
|
930
930
|
expect(ids(merged)).toEqual([U0.id, A0.id, PERSISTED_PARTIAL.id, unrelatedLive.id])
|
|
931
931
|
})
|
|
932
932
|
})
|
|
933
|
-
|
|
934
|
-
describe('ordering of surviving realtime messages', () => {
|
|
935
|
-
// The merge concatenates: all persisted history, then every realtime message
|
|
936
|
-
// the coverage rules kept, each side in its own order. These pin that
|
|
937
|
-
// contract, which nothing asserted before — the suite tested only WHICH
|
|
938
|
-
// messages survive, never where they land — and which an attempt to
|
|
939
|
-
// chronologically interleave the two sides repeatedly broke.
|
|
940
|
-
const H_U0: TestMessage = { ...U0, streamSeq: 5 }
|
|
941
|
-
const H_A0: TestMessage = { ...A0, streamSeq: 10 }
|
|
942
|
-
/**
|
|
943
|
-
* Sent from a notification while the window was away, and persisted. NO
|
|
944
|
-
* `streamSeq`: the backend does not stamp user MESSAGE_REQUEST rows, which is
|
|
945
|
-
* the missing signal that makes interleaving guesswork.
|
|
946
|
-
*/
|
|
947
|
-
const AWAY_U: TestMessage = {
|
|
948
|
-
id: 'bbbb0001',
|
|
949
|
-
role: 'user',
|
|
950
|
-
content: 'replied from a notification',
|
|
951
|
-
timestamp: t(3000),
|
|
952
|
-
}
|
|
953
|
-
/** Mingo's answer to it. Assistant rows DO carry a sequence. */
|
|
954
|
-
const AWAY_A: TestMessage = {
|
|
955
|
-
id: 'bbbb0002',
|
|
956
|
-
role: 'assistant',
|
|
957
|
-
content: txt('answered while the window was away'),
|
|
958
|
-
timestamp: t(3100),
|
|
959
|
-
streamSeq: 80,
|
|
960
|
-
}
|
|
961
|
-
|
|
962
|
-
it('appends surviving realtime messages after all persisted history', () => {
|
|
963
|
-
// A technician direct message the snapshot has not caught up with. Its own
|
|
964
|
-
// role's persisted max (H_U0, seq 5) is below it, so coverage keeps it.
|
|
965
|
-
const unpersistedDirect: TestMessage = {
|
|
966
|
-
id: 'direct-1500-old',
|
|
967
|
-
role: 'user',
|
|
968
|
-
content: 'a direct message history has not persisted yet',
|
|
969
|
-
timestamp: t(1500),
|
|
970
|
-
streamSeq: 40,
|
|
971
|
-
}
|
|
972
|
-
const merged = mergeHistoryWithRealtime({
|
|
973
|
-
processedHistory: [H_U0, H_A0, AWAY_U, AWAY_A],
|
|
974
|
-
existingMessages: [H_U0, H_A0, unpersistedDirect],
|
|
975
|
-
streamingMessageId: null,
|
|
976
|
-
historyFetchedAt: 9000,
|
|
977
|
-
historyMaxStreamSeq: 80,
|
|
978
|
-
realtimeSeenStreamSeq: 40,
|
|
979
|
-
})
|
|
980
|
-
|
|
981
|
-
expect(ids(merged)).toEqual([H_U0.id, H_A0.id, AWAY_U.id, AWAY_A.id, unpersistedDirect.id])
|
|
982
|
-
})
|
|
983
|
-
|
|
984
|
-
it('keeps survivors in store order, sequence or no sequence', () => {
|
|
985
|
-
// The guard against reordering the two kinds against each other: an
|
|
986
|
-
// optimistic bubble carries no sequence and a replayed direct message does,
|
|
987
|
-
// and sorting by sequence would render the user's question below the reply
|
|
988
|
-
// that came after it.
|
|
989
|
-
const optimistic: TestMessage = {
|
|
990
|
-
id: 'optimistic-9400-x',
|
|
991
|
-
role: 'user',
|
|
992
|
-
content: 'just typed, no seq of its own',
|
|
993
|
-
timestamp: t(9400),
|
|
994
|
-
}
|
|
995
|
-
const laterDirect: TestMessage = {
|
|
996
|
-
id: 'direct-9500-x',
|
|
997
|
-
role: 'user',
|
|
998
|
-
content: 'and a direct message after it',
|
|
999
|
-
timestamp: t(9500),
|
|
1000
|
-
streamSeq: 40,
|
|
1001
|
-
}
|
|
1002
|
-
const merged = mergeHistoryWithRealtime({
|
|
1003
|
-
processedHistory: [H_U0, H_A0, AWAY_U, AWAY_A],
|
|
1004
|
-
existingMessages: [H_U0, H_A0, optimistic, laterDirect],
|
|
1005
|
-
streamingMessageId: null,
|
|
1006
|
-
// Before either was minted, so neither can be in the snapshot.
|
|
1007
|
-
historyFetchedAt: 9000,
|
|
1008
|
-
historyMaxStreamSeq: 80,
|
|
1009
|
-
realtimeSeenStreamSeq: 40,
|
|
1010
|
-
})
|
|
1011
|
-
|
|
1012
|
-
expect(ids(merged)).toEqual([H_U0.id, H_A0.id, AWAY_U.id, AWAY_A.id, optimistic.id, laterDirect.id])
|
|
1013
|
-
})
|
|
1014
|
-
|
|
1015
|
-
it('keeps history in snapshot order even when its sequences do not climb', () => {
|
|
1016
|
-
// The snapshot is the server's account of the conversation; the merge is
|
|
1017
|
-
// not entitled to second-guess it from a `streamSeq` that looks out of
|
|
1018
|
-
// order, which is what any sort of the two sides together would do.
|
|
1019
|
-
const outOfOrder: TestMessage = { ...H_A0, id: 'aaaa0009', streamSeq: 3 }
|
|
1020
|
-
const merged = mergeHistoryWithRealtime({
|
|
1021
|
-
processedHistory: [H_U0, outOfOrder, AWAY_U, AWAY_A],
|
|
1022
|
-
existingMessages: [H_U0],
|
|
1023
|
-
streamingMessageId: null,
|
|
1024
|
-
historyFetchedAt: 9000,
|
|
1025
|
-
historyMaxStreamSeq: 80,
|
|
1026
|
-
realtimeSeenStreamSeq: 0,
|
|
1027
|
-
})
|
|
1028
|
-
|
|
1029
|
-
expect(ids(merged)).toEqual([H_U0.id, outOfOrder.id, AWAY_U.id, AWAY_A.id])
|
|
1030
|
-
})
|
|
1031
|
-
})
|