@flamingo-stack/openframe-frontend-core 0.0.248 → 0.0.250
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/{chunk-AGU44B7B.js → chunk-2FCXJTOV.js} +55 -357
- package/dist/chunk-2FCXJTOV.js.map +1 -0
- package/dist/{chunk-RV2PYHCJ.js → chunk-4HHYUEMD.js} +3 -3
- package/dist/{chunk-UECRKCKH.js → chunk-4RDJZIHW.js} +115 -279
- package/dist/chunk-4RDJZIHW.js.map +1 -0
- package/dist/chunk-6JINAOI7.cjs +311 -0
- package/dist/chunk-6JINAOI7.cjs.map +1 -0
- package/dist/chunk-6LK4VEXE.cjs +4514 -0
- package/dist/chunk-6LK4VEXE.cjs.map +1 -0
- package/dist/{chunk-OTFJKBDW.js → chunk-D2BC3NZ2.js} +5061 -9377
- package/dist/chunk-D2BC3NZ2.js.map +1 -0
- package/dist/chunk-DAAFVOTL.cjs +172 -0
- package/dist/chunk-DAAFVOTL.cjs.map +1 -0
- package/dist/{chunk-672QV33W.js → chunk-DZJ53RX6.js} +167 -16
- package/dist/chunk-DZJ53RX6.js.map +1 -0
- package/dist/{chunk-27VVOBDB.cjs → chunk-ELF4OOHC.cjs} +16 -180
- package/dist/chunk-ELF4OOHC.cjs.map +1 -0
- package/dist/chunk-LKI3YCAX.js +4514 -0
- package/dist/chunk-LKI3YCAX.js.map +1 -0
- package/dist/{chunk-2Q5GVNXP.cjs → chunk-LZL6P7SN.cjs} +12 -12
- package/dist/{chunk-2Q5GVNXP.cjs.map → chunk-LZL6P7SN.cjs.map} +1 -1
- package/dist/chunk-M2OCXTNT.js +311 -0
- package/dist/chunk-M2OCXTNT.js.map +1 -0
- package/dist/{chunk-PMYD4WKR.cjs → chunk-NCXHTQH2.cjs} +1677 -5993
- package/dist/chunk-NCXHTQH2.cjs.map +1 -0
- package/dist/{chunk-QHYCWQBH.cjs → chunk-PDKBCCCD.cjs} +166 -15
- package/dist/chunk-PDKBCCCD.cjs.map +1 -0
- package/dist/{chunk-OZKPUBKX.cjs → chunk-PDYT7T6K.cjs} +38 -33
- package/dist/chunk-PDYT7T6K.cjs.map +1 -0
- package/dist/{chunk-PJT6RRSP.js → chunk-PSG7H4KG.js} +7 -5
- package/dist/{chunk-PJT6RRSP.js.map → chunk-PSG7H4KG.js.map} +1 -1
- package/dist/{chunk-NFUXCXYN.js → chunk-SGEO3W3F.js} +11 -6
- package/dist/chunk-SGEO3W3F.js.map +1 -0
- package/dist/{chunk-GRTZ23GL.cjs → chunk-TU26IZPO.cjs} +16 -318
- package/dist/chunk-TU26IZPO.cjs.map +1 -0
- package/dist/{chunk-4VSPLMB6.cjs → chunk-UMH4RR2F.cjs} +34 -32
- package/dist/chunk-UMH4RR2F.cjs.map +1 -0
- package/dist/chunk-ZXZJSTNV.js +172 -0
- package/dist/chunk-ZXZJSTNV.js.map +1 -0
- package/dist/components/chat/chat-message-list.d.ts.map +1 -1
- package/dist/components/chat/index.cjs +21 -7
- package/dist/components/chat/index.cjs.map +1 -1
- package/dist/components/chat/index.js +63 -49
- package/dist/components/chat/utils/history-merge.d.ts +92 -0
- package/dist/components/chat/utils/history-merge.d.ts.map +1 -0
- package/dist/components/chat/utils/index.d.ts +1 -0
- package/dist/components/chat/utils/index.d.ts.map +1 -1
- package/dist/components/contact/index.cjs +11 -8
- package/dist/components/contact/index.cjs.map +1 -1
- package/dist/components/contact/index.js +10 -7
- package/dist/components/features/index.cjs +11 -7
- package/dist/components/features/index.cjs.map +1 -1
- package/dist/components/features/index.js +26 -22
- package/dist/components/features/policy-configuration-panel.d.ts.map +1 -1
- package/dist/components/index.cjs +121 -108
- package/dist/components/index.cjs.map +1 -1
- package/dist/components/index.js +144 -131
- package/dist/components/index.js.map +1 -1
- package/dist/components/navigation/index.cjs +10 -7
- package/dist/components/navigation/index.cjs.map +1 -1
- package/dist/components/navigation/index.js +9 -6
- package/dist/components/onboarding-guides/index.cjs +43 -53
- package/dist/components/onboarding-guides/index.cjs.map +1 -1
- package/dist/components/onboarding-guides/index.js +25 -35
- package/dist/components/onboarding-guides/index.js.map +1 -1
- package/dist/components/onboarding-guides/onboarding-guides-catalog-skeleton.d.ts +11 -11
- package/dist/components/onboarding-guides/onboarding-guides-catalog-skeleton.d.ts.map +1 -1
- package/dist/components/related-content/index.cjs +11 -8
- package/dist/components/related-content/index.cjs.map +1 -1
- package/dist/components/related-content/index.js +10 -7
- package/dist/components/related-content/related-content-section.d.ts.map +1 -1
- package/dist/components/tickets/index.cjs +96 -93
- package/dist/components/tickets/index.cjs.map +1 -1
- package/dist/components/tickets/index.js +21 -18
- package/dist/components/tickets/index.js.map +1 -1
- package/dist/components/ui/file-manager/index.cjs +52 -50
- package/dist/components/ui/file-manager/index.cjs.map +1 -1
- package/dist/components/ui/file-manager/index.js +6 -4
- package/dist/components/ui/file-manager/index.js.map +1 -1
- package/dist/components/ui/index.cjs +26 -11
- package/dist/components/ui/index.cjs.map +1 -1
- package/dist/components/ui/index.js +112 -97
- package/dist/contexts/index.cjs +2 -2
- package/dist/contexts/index.js +5 -5
- package/dist/hooks/index.cjs +6 -4
- package/dist/hooks/index.cjs.map +1 -1
- package/dist/hooks/index.js +8 -6
- package/dist/index.cjs +27 -11
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +167 -151
- package/package.json +1 -1
- package/src/components/chat/__tests__/chat-message-list.test.tsx +92 -0
- package/src/components/chat/chat-message-list.tsx +74 -49
- package/src/components/chat/utils/__tests__/history-merge.test.ts +339 -0
- package/src/components/chat/utils/history-merge.ts +255 -0
- package/src/components/chat/utils/index.ts +12 -0
- package/src/components/features/policy-configuration-panel.tsx +1 -0
- package/src/components/onboarding-guides/onboarding-guides-catalog-skeleton.tsx +28 -33
- package/src/components/related-content/related-content-section.tsx +6 -2
- package/dist/chunk-27VVOBDB.cjs.map +0 -1
- package/dist/chunk-4VSPLMB6.cjs.map +0 -1
- package/dist/chunk-672QV33W.js.map +0 -1
- package/dist/chunk-AGU44B7B.js.map +0 -1
- package/dist/chunk-GRTZ23GL.cjs.map +0 -1
- package/dist/chunk-NFUXCXYN.js.map +0 -1
- package/dist/chunk-OTFJKBDW.js.map +0 -1
- package/dist/chunk-OZKPUBKX.cjs.map +0 -1
- package/dist/chunk-PMYD4WKR.cjs.map +0 -1
- package/dist/chunk-QHYCWQBH.cjs.map +0 -1
- package/dist/chunk-QWYJETDL.cjs +0 -162
- package/dist/chunk-QWYJETDL.cjs.map +0 -1
- package/dist/chunk-UECRKCKH.js.map +0 -1
- package/dist/chunk-WTTLEGEO.js +0 -162
- package/dist/chunk-WTTLEGEO.js.map +0 -1
- /package/dist/{chunk-RV2PYHCJ.js.map → chunk-4HHYUEMD.js.map} +0 -0
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { render } from '@testing-library/react'
|
|
2
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
|
3
|
+
import type { Message } from '../types/message.types'
|
|
4
|
+
|
|
5
|
+
const scrollToBottom = vi.fn()
|
|
6
|
+
const stopScroll = vi.fn()
|
|
7
|
+
|
|
8
|
+
function makeRef() {
|
|
9
|
+
const fn = ((el: HTMLElement | null) => {
|
|
10
|
+
;(fn as { current: HTMLElement | null }).current = el
|
|
11
|
+
}) as ((el: HTMLElement | null) => void) & { current: HTMLElement | null }
|
|
12
|
+
fn.current = null
|
|
13
|
+
return fn
|
|
14
|
+
}
|
|
15
|
+
const scrollRefMock = makeRef()
|
|
16
|
+
const contentRefMock = makeRef()
|
|
17
|
+
|
|
18
|
+
vi.mock('use-stick-to-bottom', () => ({
|
|
19
|
+
useStickToBottom: () => ({
|
|
20
|
+
scrollRef: scrollRefMock,
|
|
21
|
+
contentRef: contentRefMock,
|
|
22
|
+
scrollToBottom,
|
|
23
|
+
stopScroll,
|
|
24
|
+
}),
|
|
25
|
+
}))
|
|
26
|
+
|
|
27
|
+
// Keep the row renderer trivial — these tests exercise ChatMessageList's
|
|
28
|
+
// scroll orchestration, not markdown rendering.
|
|
29
|
+
vi.mock('../chat-message-enhanced', () => ({
|
|
30
|
+
ChatMessageEnhanced: ({ content }: { content: string }) => <div>{content}</div>,
|
|
31
|
+
}))
|
|
32
|
+
|
|
33
|
+
import { ChatMessageList } from '../chat-message-list'
|
|
34
|
+
|
|
35
|
+
// jsdom ships neither observer; the component instantiates both
|
|
36
|
+
// (load-more sentinel + top-anchor settle watcher).
|
|
37
|
+
class ObserverStub {
|
|
38
|
+
observe() {}
|
|
39
|
+
unobserve() {}
|
|
40
|
+
disconnect() {}
|
|
41
|
+
}
|
|
42
|
+
vi.stubGlobal('IntersectionObserver', ObserverStub)
|
|
43
|
+
vi.stubGlobal('ResizeObserver', ObserverStub)
|
|
44
|
+
|
|
45
|
+
const msg = (id: string, role: Message['role']): Message => ({
|
|
46
|
+
id,
|
|
47
|
+
role,
|
|
48
|
+
content: `body of ${id}`,
|
|
49
|
+
timestamp: new Date(),
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
describe('ChatMessageList force-scroll decisions', () => {
|
|
53
|
+
beforeEach(() => {
|
|
54
|
+
scrollToBottom.mockClear()
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
it('snaps to bottom when the dialog changes', () => {
|
|
58
|
+
const { rerender } = render(<ChatMessageList dialogId="d1" messages={[msg('m1', 'user')]} />)
|
|
59
|
+
scrollToBottom.mockClear()
|
|
60
|
+
rerender(<ChatMessageList dialogId="d2" messages={[msg('x1', 'user')]} />)
|
|
61
|
+
expect(scrollToBottom).toHaveBeenCalled()
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
it('snaps to bottom when a new user message is appended', () => {
|
|
65
|
+
const initial = [msg('m1', 'user'), msg('m2', 'assistant')]
|
|
66
|
+
const { rerender } = render(<ChatMessageList dialogId="d1" messages={initial} />)
|
|
67
|
+
scrollToBottom.mockClear()
|
|
68
|
+
rerender(<ChatMessageList dialogId="d1" messages={[...initial, msg('m3', 'user')]} />)
|
|
69
|
+
expect(scrollToBottom).toHaveBeenCalled()
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
it('does NOT scroll when an older page is prepended (load-more)', () => {
|
|
73
|
+
const initial = [msg('m3', 'user'), msg('m4', 'assistant')]
|
|
74
|
+
const { rerender } = render(
|
|
75
|
+
<ChatMessageList dialogId="d1" messages={initial} hasNextPage onLoadMore={() => {}} />,
|
|
76
|
+
)
|
|
77
|
+
scrollToBottom.mockClear()
|
|
78
|
+
const olderPage = [msg('m1', 'user'), msg('m2', 'assistant')]
|
|
79
|
+
rerender(
|
|
80
|
+
<ChatMessageList dialogId="d1" messages={[...olderPage, ...initial]} hasNextPage onLoadMore={() => {}} />,
|
|
81
|
+
)
|
|
82
|
+
expect(scrollToBottom).not.toHaveBeenCalled()
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
it('does NOT scroll on assistant-only appends (library owns streaming follow)', () => {
|
|
86
|
+
const initial = [msg('m1', 'user'), msg('m2', 'assistant')]
|
|
87
|
+
const { rerender } = render(<ChatMessageList dialogId="d1" messages={initial} />)
|
|
88
|
+
scrollToBottom.mockClear()
|
|
89
|
+
rerender(<ChatMessageList dialogId="d1" messages={[...initial, msg('m3', 'assistant')]} />)
|
|
90
|
+
expect(scrollToBottom).not.toHaveBeenCalled()
|
|
91
|
+
})
|
|
92
|
+
})
|
|
@@ -130,6 +130,11 @@ const ChatMessageList = forwardRef<HTMLDivElement, ChatMessageListProps>(
|
|
|
130
130
|
onLoadMoreRef.current = onLoadMore
|
|
131
131
|
const isFetchingRef = useRef(isFetchingNextPage)
|
|
132
132
|
isFetchingRef.current = isFetchingNextPage
|
|
133
|
+
// Fresh dialogId for the IntersectionObserver callback (its effect must
|
|
134
|
+
// not depend on `dialogId` or it would re-create on every dialog switch
|
|
135
|
+
// mid-stream; the ref keeps the snapshot's dialog tag current).
|
|
136
|
+
const dialogIdRef = useRef(dialogId)
|
|
137
|
+
dialogIdRef.current = dialogId
|
|
133
138
|
|
|
134
139
|
// Tracks previous render state for explicit "force-stick" decisions
|
|
135
140
|
// (dialog change, first message, new user message). The library
|
|
@@ -140,15 +145,19 @@ const ChatMessageList = forwardRef<HTMLDivElement, ChatMessageListProps>(
|
|
|
140
145
|
lastMessageId: string | undefined
|
|
141
146
|
}>({ dialogId: undefined, messageCount: 0, lastMessageId: undefined })
|
|
142
147
|
|
|
143
|
-
//
|
|
144
|
-
//
|
|
145
|
-
//
|
|
146
|
-
//
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
148
|
+
// Geometry snapshot captured the instant the top sentinel triggers
|
|
149
|
+
// onLoadMore (BEFORE the older page lands), applied once when the taller
|
|
150
|
+
// content commits. Keyed on the LOAD LIFECYCLE, not `messages[0].id`: the
|
|
151
|
+
// history processor groups consecutive assistant turns into one bubble
|
|
152
|
+
// keyed by the group's LAST id, so an older page that is all continuation
|
|
153
|
+
// of the first bubble grows that bubble in place WITHOUT changing its id.
|
|
154
|
+
// An id-based anchor misses that growth and leaves the reader pinned at
|
|
155
|
+
// the top (and the still-visible sentinel never re-fires).
|
|
156
|
+
const loadOlderAnchorRef = useRef<{
|
|
157
|
+
dialogId: string | undefined
|
|
150
158
|
scrollHeight: number
|
|
151
|
-
|
|
159
|
+
scrollTop: number
|
|
160
|
+
} | null>(null)
|
|
152
161
|
|
|
153
162
|
// ---- Force-stick: dialog change / first-load / new user message --
|
|
154
163
|
useEffect(() => {
|
|
@@ -156,6 +165,7 @@ const ChatMessageList = forwardRef<HTMLDivElement, ChatMessageListProps>(
|
|
|
156
165
|
|
|
157
166
|
const dialogChanged = dialogId !== prevRenderRef.current.dialogId
|
|
158
167
|
const prevCount = prevRenderRef.current.messageCount
|
|
168
|
+
const prevLastId = prevRenderRef.current.lastMessageId
|
|
159
169
|
const newCount = messages.length
|
|
160
170
|
const currentLastId = messages[messages.length - 1]?.id
|
|
161
171
|
prevRenderRef.current = { dialogId, messageCount: newCount, lastMessageId: currentLastId }
|
|
@@ -177,7 +187,18 @@ const ChatMessageList = forwardRef<HTMLDivElement, ChatMessageListProps>(
|
|
|
177
187
|
// user message DID arrive). When the user just sent a
|
|
178
188
|
// message, they expect their bubble pinned to the bottom,
|
|
179
189
|
// regardless of where they were scrolled.
|
|
180
|
-
|
|
190
|
+
//
|
|
191
|
+
// The tail must be located via the previous LAST message id,
|
|
192
|
+
// not `slice(prevCount)`: when load-older PREPENDS a page,
|
|
193
|
+
// count grows but the new messages are at the HEAD —
|
|
194
|
+
// `slice(prevCount)` would return already-rendered tail
|
|
195
|
+
// messages (which contain user bubbles) and force-scroll the
|
|
196
|
+
// reader away from the older page they just loaded. With the
|
|
197
|
+
// id-based slice a pure prepend yields an empty tail → no
|
|
198
|
+
// scroll, and the prepend-anchoring effect below keeps their
|
|
199
|
+
// viewport position.
|
|
200
|
+
const prevLastIdx = prevLastId ? messages.findIndex((m) => m.id === prevLastId) : -1
|
|
201
|
+
const newSlice = prevLastIdx >= 0 ? messages.slice(prevLastIdx + 1) : messages.slice(prevCount)
|
|
181
202
|
const hasNewUser = newSlice.some((m) => m.role === 'user')
|
|
182
203
|
if (hasNewUser) {
|
|
183
204
|
void scrollToBottom({ animation: 'instant', ignoreEscapes: true })
|
|
@@ -230,49 +251,35 @@ const ChatMessageList = forwardRef<HTMLDivElement, ChatMessageListProps>(
|
|
|
230
251
|
}, [autoScroll, messages, dialogId, scrollToBottom, contentRef])
|
|
231
252
|
|
|
232
253
|
// ---- Prepend anchoring (load-older) ------------------------------
|
|
233
|
-
// The library doesn't preserve user position when content
|
|
234
|
-
//
|
|
235
|
-
//
|
|
236
|
-
//
|
|
237
|
-
//
|
|
254
|
+
// The library doesn't preserve user position when content grows ABOVE
|
|
255
|
+
// the viewport. We do: from the pre-load geometry snapshot (taken in the
|
|
256
|
+
// sentinel callback), add the height the list gained and re-pin scrollTop
|
|
257
|
+
// so the message the user was reading stays put. `useLayoutEffect` so the
|
|
258
|
+
// write lands before paint (no visible jump). Covers both a pure prepend
|
|
259
|
+
// (new ids on top) and a boundary-merge (first bubble grows in place) —
|
|
260
|
+
// it measures total height delta, not message identity.
|
|
238
261
|
useLayoutEffect(() => {
|
|
239
262
|
const el = scrollRef.current
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
const currentFirstContent = messages[0]?.content
|
|
247
|
-
const prevFirstId = prependRef.current.firstMessageId
|
|
248
|
-
const prevHeight = prependRef.current.scrollHeight
|
|
249
|
-
|
|
250
|
-
if (currentFirstId !== prevFirstId) {
|
|
251
|
-
// First-id changed → older messages prepended (verified by
|
|
252
|
-
// confirming the previous-first is still in the array at
|
|
253
|
-
// idx > 0; otherwise this is some other mutation).
|
|
254
|
-
if (prevFirstId && prevHeight > 0) {
|
|
255
|
-
const prevIdx = messages.findIndex((m) => m.id === prevFirstId)
|
|
256
|
-
if (prevIdx > 0) {
|
|
257
|
-
const addedHeight = el.scrollHeight - prevHeight
|
|
258
|
-
if (addedHeight > 0) el.scrollTop += addedHeight
|
|
259
|
-
}
|
|
260
|
-
}
|
|
261
|
-
prependRef.current = {
|
|
262
|
-
firstMessageId: currentFirstId,
|
|
263
|
-
firstMessageContent: currentFirstContent,
|
|
264
|
-
scrollHeight: el.scrollHeight,
|
|
265
|
-
}
|
|
263
|
+
const anchor = loadOlderAnchorRef.current
|
|
264
|
+
if (!el || !anchor) return
|
|
265
|
+
// Dialog switched out from under a pending load — abandon; the
|
|
266
|
+
// force-stick effect owns the new dialog's position.
|
|
267
|
+
if (anchor.dialogId !== dialogId) {
|
|
268
|
+
loadOlderAnchorRef.current = null
|
|
266
269
|
return
|
|
267
270
|
}
|
|
268
|
-
//
|
|
269
|
-
//
|
|
270
|
-
//
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
271
|
+
// Hold the anchor across the intermediate renders between firing
|
|
272
|
+
// onLoadMore and the older page committing (the isFetchingNextPage
|
|
273
|
+
// flip, and in some hosts a two-stage store update) — those don't grow
|
|
274
|
+
// the list. Apply on the first render where it actually grew taller,
|
|
275
|
+
// which is the prepend (new ids on top) or a boundary-merge (first
|
|
276
|
+
// bubble grew in place). Same-id boundary-merge is why this can't key
|
|
277
|
+
// on message count.
|
|
278
|
+
const addedHeight = el.scrollHeight - anchor.scrollHeight
|
|
279
|
+
if (addedHeight <= 0) return
|
|
280
|
+
loadOlderAnchorRef.current = null
|
|
281
|
+
el.scrollTop = anchor.scrollTop + addedHeight
|
|
282
|
+
}, [messages, dialogId, scrollRef])
|
|
276
283
|
|
|
277
284
|
// ---- Per-message scrollAnchor (server-driven) --------------------
|
|
278
285
|
// Server emits `scrollAnchor: 'top'` on the metadata leading frame
|
|
@@ -382,6 +389,14 @@ const ChatMessageList = forwardRef<HTMLDivElement, ChatMessageListProps>(
|
|
|
382
389
|
// ---- Load-more (infinite-scroll UP) ------------------------------
|
|
383
390
|
// Distinct from stick-to-bottom; uses its own IntersectionObserver
|
|
384
391
|
// on the top sentinel.
|
|
392
|
+
//
|
|
393
|
+
// `isFetchingNextPage` is in the deps so the observer is RE-CREATED when a
|
|
394
|
+
// fetch completes: IntersectionObserver fires no new callback while an
|
|
395
|
+
// element stays continuously intersecting, so after an older page lands
|
|
396
|
+
// with the sentinel still in view (small prepend, or rootMargin overlap)
|
|
397
|
+
// nothing would re-trigger until the user scrolled it out and back in.
|
|
398
|
+
// Re-observing re-evaluates the current intersection and fires again if
|
|
399
|
+
// the sentinel is still visible — load continues until it's pushed out.
|
|
385
400
|
useEffect(() => {
|
|
386
401
|
const scrollContainer = scrollRef.current
|
|
387
402
|
const sentinelElement = sentinelRef.current
|
|
@@ -392,6 +407,16 @@ const ChatMessageList = forwardRef<HTMLDivElement, ChatMessageListProps>(
|
|
|
392
407
|
const entry = entries[0]
|
|
393
408
|
if (!entry) return
|
|
394
409
|
if (entry.isIntersecting && !isFetchingRef.current) {
|
|
410
|
+
// Snapshot pre-load geometry so the prepend-anchoring layout
|
|
411
|
+
// effect can re-pin scrollTop once the taller content commits.
|
|
412
|
+
const el = scrollRef.current
|
|
413
|
+
if (el) {
|
|
414
|
+
loadOlderAnchorRef.current = {
|
|
415
|
+
dialogId: dialogIdRef.current,
|
|
416
|
+
scrollHeight: el.scrollHeight,
|
|
417
|
+
scrollTop: el.scrollTop,
|
|
418
|
+
}
|
|
419
|
+
}
|
|
395
420
|
onLoadMoreRef.current?.()
|
|
396
421
|
}
|
|
397
422
|
},
|
|
@@ -399,7 +424,7 @@ const ChatMessageList = forwardRef<HTMLDivElement, ChatMessageListProps>(
|
|
|
399
424
|
)
|
|
400
425
|
observer.observe(sentinelElement)
|
|
401
426
|
return () => observer.disconnect()
|
|
402
|
-
}, [hasNextPage, scrollRef])
|
|
427
|
+
}, [hasNextPage, isFetchingNextPage, scrollRef])
|
|
403
428
|
|
|
404
429
|
// Expose the scroll container ref to parents that need it (rare,
|
|
405
430
|
// but the existing public contract). Library's `scrollRef` is a
|
|
@@ -0,0 +1,339 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import type { MessageSegment } from '../../types'
|
|
3
|
+
import {
|
|
4
|
+
computeHistoryPrepend,
|
|
5
|
+
flattenMessagePagesChronological,
|
|
6
|
+
maxPersistedStreamSeq,
|
|
7
|
+
type MergeableChatMessage,
|
|
8
|
+
mergeHistoryWithRealtime,
|
|
9
|
+
} from '../history-merge'
|
|
10
|
+
|
|
11
|
+
interface TestMessage extends MergeableChatMessage {
|
|
12
|
+
role: 'user' | 'assistant'
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const txt = (s: string): MessageSegment[] => [{ type: 'text', text: s }]
|
|
16
|
+
const t = (ms: number) => new Date(ms)
|
|
17
|
+
const ids = (msgs: TestMessage[]) => msgs.map((m) => m.id)
|
|
18
|
+
const batchSeg = {
|
|
19
|
+
type: 'approval_batch',
|
|
20
|
+
data: { approvalRequestId: 'req-9', toolCalls: [] },
|
|
21
|
+
} as unknown as MessageSegment
|
|
22
|
+
|
|
23
|
+
// Persisted history (Mongo ids), first fetched at t=1000
|
|
24
|
+
const U0: TestMessage = { id: 'aaaa0001', role: 'user', content: 'first question', timestamp: t(500) }
|
|
25
|
+
const A0: TestMessage = { id: 'aaaa0002', role: 'assistant', content: txt('first answer'), timestamp: t(600) }
|
|
26
|
+
|
|
27
|
+
// A later turn that happened via realtime (optimistic user + streamed reply)
|
|
28
|
+
const OPT1: TestMessage = { id: 'optimistic-2000-x', role: 'user', content: 'second question', timestamp: t(2000) }
|
|
29
|
+
const SYN1: TestMessage = { id: 'assistant-2100-x', role: 'assistant', content: txt('second answer'), timestamp: t(2100) }
|
|
30
|
+
|
|
31
|
+
// Persisted counterparts of that turn (what a FRESH fetch returns)
|
|
32
|
+
const U1: TestMessage = { id: 'aaaa0003', role: 'user', content: 'second question', timestamp: t(2000) }
|
|
33
|
+
const A1: TestMessage = { id: 'aaaa0004', role: 'assistant', content: txt('second answer'), timestamp: t(2100) }
|
|
34
|
+
|
|
35
|
+
describe('mergeHistoryWithRealtime', () => {
|
|
36
|
+
it('keeps realtime messages newer than a STALE history snapshot (no data loss)', () => {
|
|
37
|
+
// Reopen a dialog: first merge runs against the cached snapshot fetched
|
|
38
|
+
// BEFORE the second turn existed. The completed reply must survive — the
|
|
39
|
+
// realtime transport will not redeliver chunks this client already consumed.
|
|
40
|
+
const merged = mergeHistoryWithRealtime({
|
|
41
|
+
processedHistory: [U0, A0],
|
|
42
|
+
existingMessages: [U0, A0, OPT1, SYN1],
|
|
43
|
+
streamingMessageId: null,
|
|
44
|
+
historyFetchedAt: 1000,
|
|
45
|
+
})
|
|
46
|
+
expect(ids(merged)).toEqual([U0.id, A0.id, OPT1.id, SYN1.id])
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
it('replaces optimistic + synthetic messages with their persisted twins on a FRESH snapshot', () => {
|
|
50
|
+
const merged = mergeHistoryWithRealtime({
|
|
51
|
+
processedHistory: [U0, A0, U1, A1],
|
|
52
|
+
existingMessages: [U0, A0, OPT1, SYN1],
|
|
53
|
+
streamingMessageId: null,
|
|
54
|
+
historyFetchedAt: 5000,
|
|
55
|
+
})
|
|
56
|
+
expect(ids(merged)).toEqual([U0.id, A0.id, U1.id, A1.id])
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
it('never drops the in-flight streaming synthetic', () => {
|
|
60
|
+
const merged = mergeHistoryWithRealtime({
|
|
61
|
+
processedHistory: [U0, A0, U1], // assistant turn not persisted yet
|
|
62
|
+
existingMessages: [U0, A0, OPT1, SYN1],
|
|
63
|
+
streamingMessageId: SYN1.id,
|
|
64
|
+
historyFetchedAt: 5000,
|
|
65
|
+
})
|
|
66
|
+
expect(ids(merged)).toContain(SYN1.id)
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
it('dedupes mid-list synthetics against fresh history (no duplicated turns)', () => {
|
|
70
|
+
const OPT2: TestMessage = { id: 'optimistic-3000-x', role: 'user', content: 'third question', timestamp: t(3000) }
|
|
71
|
+
const SYN2: TestMessage = { id: 'assistant-3100-x', role: 'assistant', content: txt('third answer'), timestamp: t(3100) }
|
|
72
|
+
const U2: TestMessage = { id: 'aaaa0006', role: 'user', content: 'third question', timestamp: t(3000) }
|
|
73
|
+
const A2: TestMessage = { id: 'aaaa0007', role: 'assistant', content: txt('third answer'), timestamp: t(3100) }
|
|
74
|
+
|
|
75
|
+
const merged = mergeHistoryWithRealtime({
|
|
76
|
+
processedHistory: [U0, A0, U1, A1, U2, A2],
|
|
77
|
+
existingMessages: [U0, A0, OPT1, SYN1, OPT2, SYN2],
|
|
78
|
+
streamingMessageId: null,
|
|
79
|
+
historyFetchedAt: 5000,
|
|
80
|
+
})
|
|
81
|
+
expect(ids(merged)).toEqual([U0.id, A0.id, U1.id, A1.id, U2.id, A2.id])
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
it('pins the more-complete synthetic over a partial history assistant (approval persistence race)', () => {
|
|
85
|
+
const partialHistA: TestMessage = { id: 'aaaa0005', role: 'assistant', content: [batchSeg], timestamp: t(2100) }
|
|
86
|
+
const fullSyn: TestMessage = {
|
|
87
|
+
id: 'assistant-2100-y',
|
|
88
|
+
role: 'assistant',
|
|
89
|
+
content: [...txt('second answer'), batchSeg],
|
|
90
|
+
timestamp: t(2100), // OLDER than the snapshot — pin must beat the freshness rule
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const merged = mergeHistoryWithRealtime({
|
|
94
|
+
processedHistory: [U0, A0, U1, partialHistA],
|
|
95
|
+
existingMessages: [U0, A0, OPT1, fullSyn],
|
|
96
|
+
streamingMessageId: null,
|
|
97
|
+
historyFetchedAt: 5000,
|
|
98
|
+
})
|
|
99
|
+
expect(ids(merged)).toContain(fullSyn.id)
|
|
100
|
+
expect(ids(merged)).not.toContain(partialHistA.id)
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
it('drops the synthetic when history is at least as complete for the same approval batch', () => {
|
|
104
|
+
const fullHistA: TestMessage = {
|
|
105
|
+
id: 'aaaa0005',
|
|
106
|
+
role: 'assistant',
|
|
107
|
+
content: [...txt('second answer'), batchSeg],
|
|
108
|
+
timestamp: t(2100),
|
|
109
|
+
}
|
|
110
|
+
const lesserSyn: TestMessage = {
|
|
111
|
+
id: 'assistant-2100-y',
|
|
112
|
+
role: 'assistant',
|
|
113
|
+
content: [batchSeg],
|
|
114
|
+
timestamp: t(9000), // even NEWER than the snapshot — explicit drop wins
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const merged = mergeHistoryWithRealtime({
|
|
118
|
+
processedHistory: [U0, A0, U1, fullHistA],
|
|
119
|
+
existingMessages: [U0, A0, OPT1, lesserSyn],
|
|
120
|
+
streamingMessageId: null,
|
|
121
|
+
historyFetchedAt: 5000,
|
|
122
|
+
})
|
|
123
|
+
expect(ids(merged)).toContain(fullHistA.id)
|
|
124
|
+
expect(ids(merged)).not.toContain(lesserSyn.id)
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
it('drops a replay-minted synthetic with a FRESH timestamp when seq coverage proves history contains it', () => {
|
|
128
|
+
// Chunk replay re-materializes an old turn into a synthetic stamped with
|
|
129
|
+
// the CURRENT time — newer than the snapshot, so the wall-clock rule
|
|
130
|
+
// would wrongly keep it next to its persisted twin (the "duplicated
|
|
131
|
+
// assistant message after navigating back" bug). Seq coverage is exact:
|
|
132
|
+
// history persisted through seq 80 >= client consumed through seq 80.
|
|
133
|
+
const replayedSyn: TestMessage = {
|
|
134
|
+
id: 'assistant-9999-r',
|
|
135
|
+
role: 'assistant',
|
|
136
|
+
content: txt('second answer'),
|
|
137
|
+
timestamp: t(9999), // newer than historyFetchedAt
|
|
138
|
+
}
|
|
139
|
+
const merged = mergeHistoryWithRealtime({
|
|
140
|
+
processedHistory: [U0, A0, U1, A1],
|
|
141
|
+
existingMessages: [U0, A0, replayedSyn],
|
|
142
|
+
streamingMessageId: null,
|
|
143
|
+
historyFetchedAt: 5000,
|
|
144
|
+
historyMaxStreamSeq: 80,
|
|
145
|
+
realtimeSeenStreamSeq: 80,
|
|
146
|
+
})
|
|
147
|
+
expect(ids(merged)).toEqual([U0.id, A0.id, U1.id, A1.id])
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
it('keeps an old-looking synthetic when seq coverage proves history is BEHIND (persistence lag)', () => {
|
|
151
|
+
// Client consumed through seq 90 but history only persisted through 80 —
|
|
152
|
+
// dropping the synthetic would lose content, no matter how old its
|
|
153
|
+
// wall-clock timestamp looks.
|
|
154
|
+
const merged = mergeHistoryWithRealtime({
|
|
155
|
+
processedHistory: [U0, A0],
|
|
156
|
+
existingMessages: [U0, A0, OPT1, SYN1], // SYN1 timestamp 2100 <= fetchedAt
|
|
157
|
+
streamingMessageId: null,
|
|
158
|
+
historyFetchedAt: 5000,
|
|
159
|
+
historyMaxStreamSeq: 80,
|
|
160
|
+
realtimeSeenStreamSeq: 90,
|
|
161
|
+
})
|
|
162
|
+
expect(ids(merged)).toContain(SYN1.id)
|
|
163
|
+
})
|
|
164
|
+
|
|
165
|
+
it('falls back to the wall-clock rule when seq signals are unavailable', () => {
|
|
166
|
+
const merged = mergeHistoryWithRealtime({
|
|
167
|
+
processedHistory: [U0, A0, U1, A1],
|
|
168
|
+
existingMessages: [U0, A0, OPT1, SYN1],
|
|
169
|
+
streamingMessageId: null,
|
|
170
|
+
historyFetchedAt: 5000,
|
|
171
|
+
historyMaxStreamSeq: 0,
|
|
172
|
+
realtimeSeenStreamSeq: 0,
|
|
173
|
+
})
|
|
174
|
+
expect(ids(merged)).toEqual([U0.id, A0.id, U1.id, A1.id])
|
|
175
|
+
})
|
|
176
|
+
|
|
177
|
+
it('keeps a just-sent optimistic message whose text repeats an older turn (stale snapshot)', () => {
|
|
178
|
+
// The user re-sends "first question" and the merge runs against a STALE
|
|
179
|
+
// snapshot (e.g. triggered by stream start). Content-dedup must not match
|
|
180
|
+
// it against the OLD persisted turn with the same text.
|
|
181
|
+
const repeat: TestMessage = { id: 'optimistic-9000-x', role: 'user', content: 'first question', timestamp: t(9000) }
|
|
182
|
+
const merged = mergeHistoryWithRealtime({
|
|
183
|
+
processedHistory: [U0, A0],
|
|
184
|
+
existingMessages: [U0, A0, repeat],
|
|
185
|
+
streamingMessageId: null,
|
|
186
|
+
historyFetchedAt: 1000,
|
|
187
|
+
})
|
|
188
|
+
expect(ids(merged)).toContain(repeat.id)
|
|
189
|
+
})
|
|
190
|
+
|
|
191
|
+
it('drops an optimistic repeat once the snapshot is fresh enough to contain its twin', () => {
|
|
192
|
+
const repeatPersisted: TestMessage = { id: 'bbbb0001', role: 'user', content: 'first question', timestamp: t(9000) }
|
|
193
|
+
const repeat: TestMessage = { id: 'optimistic-9000-x', role: 'user', content: 'first question', timestamp: t(9000) }
|
|
194
|
+
const merged = mergeHistoryWithRealtime({
|
|
195
|
+
processedHistory: [U0, A0, repeatPersisted],
|
|
196
|
+
existingMessages: [U0, A0, repeat],
|
|
197
|
+
streamingMessageId: null,
|
|
198
|
+
historyFetchedAt: 10_000,
|
|
199
|
+
})
|
|
200
|
+
expect(ids(merged)).toEqual([U0.id, A0.id, repeatPersisted.id])
|
|
201
|
+
})
|
|
202
|
+
|
|
203
|
+
it('returns existing messages untouched when the history snapshot is empty', () => {
|
|
204
|
+
const merged = mergeHistoryWithRealtime({
|
|
205
|
+
processedHistory: [],
|
|
206
|
+
existingMessages: [OPT1, SYN1],
|
|
207
|
+
streamingMessageId: null,
|
|
208
|
+
historyFetchedAt: 10_000,
|
|
209
|
+
})
|
|
210
|
+
expect(merged).toEqual([OPT1, SYN1])
|
|
211
|
+
})
|
|
212
|
+
|
|
213
|
+
it('keeps a just-sent optimistic repeat even when seq coverage reports covered', () => {
|
|
214
|
+
// Optimistic messages are minted on send, not by chunks — seq coverage
|
|
215
|
+
// says nothing about them. Sent AFTER the snapshot → must survive even
|
|
216
|
+
// though its text matches an old persisted turn.
|
|
217
|
+
const repeat: TestMessage = { id: 'optimistic-9000-x', role: 'user', content: 'first question', timestamp: t(9000) }
|
|
218
|
+
const merged = mergeHistoryWithRealtime({
|
|
219
|
+
processedHistory: [U0, A0],
|
|
220
|
+
existingMessages: [U0, A0, repeat],
|
|
221
|
+
streamingMessageId: null,
|
|
222
|
+
historyFetchedAt: 5000,
|
|
223
|
+
historyMaxStreamSeq: 80,
|
|
224
|
+
realtimeSeenStreamSeq: 80,
|
|
225
|
+
})
|
|
226
|
+
expect(ids(merged)).toContain(repeat.id)
|
|
227
|
+
})
|
|
228
|
+
|
|
229
|
+
it('pins the streaming twin even when it carries a persisted history id (adoption path)', () => {
|
|
230
|
+
// Chunk processors ADOPT an in-progress trailing assistant after a prior
|
|
231
|
+
// merge, so the streaming twin can have the SAME Mongo id as history's
|
|
232
|
+
// trailing message. The pin must beat the processed/raw id dedup or the
|
|
233
|
+
// turn vanishes from both lists.
|
|
234
|
+
const adopted: TestMessage = { id: 'aaaa0005', role: 'assistant', content: [batchSeg], timestamp: t(2100) }
|
|
235
|
+
const merged = mergeHistoryWithRealtime({
|
|
236
|
+
processedHistory: [U0, A0, U1, adopted],
|
|
237
|
+
rawHistoryIds: new Set([U0.id, A0.id, U1.id, adopted.id]),
|
|
238
|
+
existingMessages: [U0, A0, adopted],
|
|
239
|
+
streamingMessageId: adopted.id,
|
|
240
|
+
historyFetchedAt: 5000,
|
|
241
|
+
})
|
|
242
|
+
expect(ids(merged)).toEqual([U0.id, A0.id, U1.id, adopted.id])
|
|
243
|
+
})
|
|
244
|
+
|
|
245
|
+
it('never drops the streaming twin in the approval-batch resolution (pins it instead)', () => {
|
|
246
|
+
// History's trailing assistant fully persisted the approval turn, but the
|
|
247
|
+
// same batch id belongs to the LIVE streaming bubble (continuation after
|
|
248
|
+
// approve). Dropping it would orphan the host's streaming pointer and
|
|
249
|
+
// make continuation chunks invisible.
|
|
250
|
+
const fullHistA: TestMessage = {
|
|
251
|
+
id: 'aaaa0005',
|
|
252
|
+
role: 'assistant',
|
|
253
|
+
content: [...txt('second answer'), batchSeg],
|
|
254
|
+
timestamp: t(2100),
|
|
255
|
+
}
|
|
256
|
+
const liveTwin: TestMessage = { id: 'assistant-2100-y', role: 'assistant', content: [batchSeg], timestamp: t(2100) }
|
|
257
|
+
const merged = mergeHistoryWithRealtime({
|
|
258
|
+
processedHistory: [U0, A0, U1, fullHistA],
|
|
259
|
+
existingMessages: [U0, A0, OPT1, liveTwin],
|
|
260
|
+
streamingMessageId: liveTwin.id,
|
|
261
|
+
historyFetchedAt: 5000,
|
|
262
|
+
})
|
|
263
|
+
expect(ids(merged)).toContain(liveTwin.id)
|
|
264
|
+
expect(ids(merged)).not.toContain(fullHistA.id)
|
|
265
|
+
})
|
|
266
|
+
|
|
267
|
+
it('resolves the approval batch against the most recent twin when several share the batch id', () => {
|
|
268
|
+
const partialHistA: TestMessage = { id: 'aaaa0005', role: 'assistant', content: [batchSeg], timestamp: t(2100) }
|
|
269
|
+
const staleTwin: TestMessage = { id: 'assistant-1000-old', role: 'assistant', content: [batchSeg], timestamp: t(1000) }
|
|
270
|
+
const fullTwin: TestMessage = {
|
|
271
|
+
id: 'assistant-2100-y',
|
|
272
|
+
role: 'assistant',
|
|
273
|
+
content: [...txt('second answer'), batchSeg],
|
|
274
|
+
timestamp: t(2100),
|
|
275
|
+
}
|
|
276
|
+
const merged = mergeHistoryWithRealtime({
|
|
277
|
+
processedHistory: [U0, A0, U1, partialHistA],
|
|
278
|
+
existingMessages: [U0, A0, staleTwin, fullTwin],
|
|
279
|
+
streamingMessageId: null,
|
|
280
|
+
historyFetchedAt: 5000,
|
|
281
|
+
})
|
|
282
|
+
expect(ids(merged)).toContain(fullTwin.id)
|
|
283
|
+
expect(ids(merged)).not.toContain(partialHistA.id)
|
|
284
|
+
expect(ids(merged)).not.toContain(staleTwin.id)
|
|
285
|
+
})
|
|
286
|
+
|
|
287
|
+
it('keeps welcome bubbles (never persisted server-side)', () => {
|
|
288
|
+
const welcome: TestMessage = { id: 'welcome-d1', role: 'assistant', content: 'Hi!', timestamp: t(100) }
|
|
289
|
+
const merged = mergeHistoryWithRealtime({
|
|
290
|
+
processedHistory: [U0, A0],
|
|
291
|
+
existingMessages: [welcome],
|
|
292
|
+
streamingMessageId: null,
|
|
293
|
+
historyFetchedAt: 5000,
|
|
294
|
+
})
|
|
295
|
+
expect(ids(merged)).toContain(welcome.id)
|
|
296
|
+
})
|
|
297
|
+
})
|
|
298
|
+
|
|
299
|
+
describe('computeHistoryPrepend', () => {
|
|
300
|
+
it('collects only messages above the first known id', () => {
|
|
301
|
+
const older: TestMessage = { id: 'aaaa0000', role: 'user', content: 'zeroth', timestamp: t(100) }
|
|
302
|
+
const result = computeHistoryPrepend([older, U0, A0], [U0, A0, SYN1])
|
|
303
|
+
expect(result).not.toBeNull()
|
|
304
|
+
expect(ids(result!.newMessages)).toEqual([older.id])
|
|
305
|
+
expect(result!.boundaryUpdates).toBeUndefined()
|
|
306
|
+
})
|
|
307
|
+
|
|
308
|
+
it('reports a boundary content refresh when the known boundary message changed', () => {
|
|
309
|
+
const updatedU0: TestMessage = { ...U0, content: 'first question (edited)' }
|
|
310
|
+
const result = computeHistoryPrepend([updatedU0, A0], [U0, A0])
|
|
311
|
+
expect(result).not.toBeNull()
|
|
312
|
+
expect(result!.newMessages).toEqual([])
|
|
313
|
+
expect(result!.boundaryMessageId).toBe(U0.id)
|
|
314
|
+
expect(result!.boundaryUpdates).toEqual({ content: 'first question (edited)' })
|
|
315
|
+
})
|
|
316
|
+
|
|
317
|
+
it('returns null when there is nothing to apply', () => {
|
|
318
|
+
expect(computeHistoryPrepend([U0, A0], [U0, A0])).toBeNull()
|
|
319
|
+
})
|
|
320
|
+
})
|
|
321
|
+
|
|
322
|
+
describe('page helpers', () => {
|
|
323
|
+
// Pages arrive DESC (newest page first, newest message first within a page).
|
|
324
|
+
const pages: { messages: { id: string; lastChunkStreamSeq?: number | null }[] }[] = [
|
|
325
|
+
{ messages: [{ id: 'd', lastChunkStreamSeq: 40 }, { id: 'c', lastChunkStreamSeq: null }] },
|
|
326
|
+
{ messages: [{ id: 'b', lastChunkStreamSeq: 20 }, { id: 'a' }] },
|
|
327
|
+
]
|
|
328
|
+
|
|
329
|
+
it('flattenMessagePagesChronological reverses pages and messages into chronological order', () => {
|
|
330
|
+
expect(flattenMessagePagesChronological(pages).map((m) => m.id)).toEqual(['a', 'b', 'c', 'd'])
|
|
331
|
+
expect(flattenMessagePagesChronological(undefined)).toEqual([])
|
|
332
|
+
})
|
|
333
|
+
|
|
334
|
+
it('maxPersistedStreamSeq returns the highest stamped seq, 0 when absent', () => {
|
|
335
|
+
expect(maxPersistedStreamSeq(pages)).toBe(40)
|
|
336
|
+
expect(maxPersistedStreamSeq([{ messages: [{}] }])).toBe(0)
|
|
337
|
+
expect(maxPersistedStreamSeq(undefined)).toBe(0)
|
|
338
|
+
})
|
|
339
|
+
})
|