@flamingo-stack/openframe-frontend-core 0.0.243 → 0.0.244
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-MKVKVJQQ.js → chunk-APGMNHPY.js} +19 -32
- package/dist/chunk-APGMNHPY.js.map +1 -0
- package/dist/{chunk-3TDLFCCS.cjs → chunk-DV3YZWGN.cjs} +20 -33
- package/dist/chunk-DV3YZWGN.cjs.map +1 -0
- package/dist/{chunk-BKPS6M2F.cjs → chunk-EJ7J4QAD.cjs} +12 -12
- package/dist/{chunk-BKPS6M2F.cjs.map → chunk-EJ7J4QAD.cjs.map} +1 -1
- package/dist/{chunk-CZITXP77.cjs → chunk-EPXROXYC.cjs} +713 -645
- package/dist/chunk-EPXROXYC.cjs.map +1 -0
- package/dist/{chunk-SGJBUL2Y.cjs → chunk-FSS2MIEA.cjs} +3 -20
- package/dist/chunk-FSS2MIEA.cjs.map +1 -0
- package/dist/{chunk-VAUI3CND.js → chunk-LJGBFD52.js} +1 -18
- package/dist/chunk-LJGBFD52.js.map +1 -0
- package/dist/{chunk-FRRMXLU5.js → chunk-M7DPUWAL.js} +188 -120
- package/dist/chunk-M7DPUWAL.js.map +1 -0
- package/dist/{chunk-SEVJT2HP.js → chunk-NKGNRFSS.js} +8 -8
- package/dist/{chunk-VFKQMAUF.cjs → chunk-OB45JHDY.cjs} +3 -3
- package/dist/{chunk-VFKQMAUF.cjs.map → chunk-OB45JHDY.cjs.map} +1 -1
- package/dist/{chunk-4XLJWX2N.js → chunk-ORJREQ2W.js} +4 -4
- package/dist/{chunk-7ZHFCEJH.js → chunk-P5C732SC.js} +6 -6
- package/dist/{chunk-JJV3XGCY.js → chunk-PTSAF7T2.js} +2 -2
- package/dist/{chunk-VXP553WT.cjs → chunk-RJC3L5DT.cjs} +7 -7
- package/dist/{chunk-VXP553WT.cjs.map → chunk-RJC3L5DT.cjs.map} +1 -1
- package/dist/{chunk-MP5HOP7N.cjs → chunk-STKMIMLP.cjs} +12 -12
- package/dist/{chunk-MP5HOP7N.cjs.map → chunk-STKMIMLP.cjs.map} +1 -1
- package/dist/{chunk-V35PZSUA.js → chunk-WD2MZVJI.js} +5 -5
- package/dist/{chunk-DUZBTUXF.cjs → chunk-Z6GGYZDR.cjs} +27 -27
- package/dist/{chunk-DUZBTUXF.cjs.map → chunk-Z6GGYZDR.cjs.map} +1 -1
- package/dist/components/chat/chat-message-list.d.ts.map +1 -1
- package/dist/components/chat/hooks/use-chat-card-item.d.ts.map +1 -1
- package/dist/components/chat/index.cjs +16 -6
- package/dist/components/chat/index.cjs.map +1 -1
- package/dist/components/chat/index.js +15 -5
- package/dist/components/chat/types/entities/content-ref.d.ts +20 -5
- package/dist/components/chat/types/entities/content-ref.d.ts.map +1 -1
- 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 +7 -7
- package/dist/components/contact/index.js +6 -6
- package/dist/components/faq/faq-section.d.ts +1 -7
- package/dist/components/faq/faq-section.d.ts.map +1 -1
- package/dist/components/faq/index.cjs +3 -3
- package/dist/components/faq/index.js +2 -2
- package/dist/components/features/index.cjs +6 -6
- package/dist/components/features/index.js +5 -5
- package/dist/components/features/policy-configuration-panel.d.ts.map +1 -1
- package/dist/components/index.cjs +93 -87
- package/dist/components/index.cjs.map +1 -1
- package/dist/components/index.d.ts +0 -1
- package/dist/components/index.d.ts.map +1 -1
- package/dist/components/index.js +37 -31
- package/dist/components/index.js.map +1 -1
- package/dist/components/navigation/index.cjs +6 -6
- package/dist/components/navigation/index.js +5 -5
- package/dist/components/onboarding-guides/index.cjs +30 -30
- package/dist/components/onboarding-guides/index.cjs.map +1 -1
- package/dist/components/onboarding-guides/index.js +11 -11
- package/dist/components/tickets/index.cjs +73 -73
- package/dist/components/tickets/index.cjs.map +1 -1
- package/dist/components/tickets/index.js +15 -15
- package/dist/components/ui/index.cjs +19 -9
- package/dist/components/ui/index.cjs.map +1 -1
- package/dist/components/ui/index.js +23 -13
- package/dist/embed-shims/index.cjs +3 -3
- package/dist/embed-shims/index.cjs.map +1 -1
- package/dist/embed-shims/index.js +5 -5
- package/dist/index.cjs +11 -17
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +49 -55
- package/dist/types/index.d.ts +0 -1
- package/dist/types/index.d.ts.map +1 -1
- package/dist/utils/index.cjs +0 -81
- package/dist/utils/index.cjs.map +1 -1
- package/dist/utils/index.d.ts +1 -4
- package/dist/utils/index.d.ts.map +1 -1
- package/dist/utils/index.js +1 -74
- package/dist/utils/index.js.map +1 -1
- package/dist/utils/list-url.d.ts +0 -6
- package/dist/utils/list-url.d.ts.map +1 -1
- package/package.json +1 -7
- 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/hooks/use-chat-card-item.ts +38 -3
- package/src/components/chat/types/entities/content-ref.ts +21 -5
- 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/faq/faq-section.tsx +24 -69
- package/src/components/features/policy-configuration-panel.tsx +1 -0
- package/src/components/index.ts +0 -1
- package/src/types/index.ts +0 -1
- package/src/utils/index.ts +1 -16
- package/src/utils/list-url.ts +0 -9
- package/dist/chunk-3TDLFCCS.cjs.map +0 -1
- package/dist/chunk-CZITXP77.cjs.map +0 -1
- package/dist/chunk-F3GMWNAT.js +0 -285
- package/dist/chunk-F3GMWNAT.js.map +0 -1
- package/dist/chunk-FRRMXLU5.js.map +0 -1
- package/dist/chunk-HU252PYJ.cjs +0 -285
- package/dist/chunk-HU252PYJ.cjs.map +0 -1
- package/dist/chunk-MKVKVJQQ.js.map +0 -1
- package/dist/chunk-SGJBUL2Y.cjs.map +0 -1
- package/dist/chunk-VAUI3CND.js.map +0 -1
- package/dist/components/related-content/index.cjs +0 -26
- package/dist/components/related-content/index.cjs.map +0 -1
- package/dist/components/related-content/index.d.ts +0 -2
- package/dist/components/related-content/index.d.ts.map +0 -1
- package/dist/components/related-content/index.js +0 -26
- package/dist/components/related-content/index.js.map +0 -1
- package/dist/components/related-content/related-content-section.d.ts +0 -141
- package/dist/components/related-content/related-content-section.d.ts.map +0 -1
- package/dist/types/content-ref.d.ts +0 -35
- package/dist/types/content-ref.d.ts.map +0 -1
- package/dist/utils/content-ref-groups.d.ts +0 -58
- package/dist/utils/content-ref-groups.d.ts.map +0 -1
- package/dist/utils/extract-items.d.ts +0 -18
- package/dist/utils/extract-items.d.ts.map +0 -1
- package/dist/utils/suggestion-url.d.ts +0 -26
- package/dist/utils/suggestion-url.d.ts.map +0 -1
- package/src/components/related-content/index.ts +0 -9
- package/src/components/related-content/related-content-section.tsx +0 -612
- package/src/types/content-ref.ts +0 -34
- package/src/utils/content-ref-groups.ts +0 -89
- package/src/utils/extract-items.ts +0 -48
- package/src/utils/suggestion-url.ts +0 -39
- /package/dist/{chunk-SEVJT2HP.js.map → chunk-NKGNRFSS.js.map} +0 -0
- /package/dist/{chunk-4XLJWX2N.js.map → chunk-ORJREQ2W.js.map} +0 -0
- /package/dist/{chunk-7ZHFCEJH.js.map → chunk-P5C732SC.js.map} +0 -0
- /package/dist/{chunk-JJV3XGCY.js.map → chunk-PTSAF7T2.js.map} +0 -0
- /package/dist/{chunk-V35PZSUA.js.map → chunk-WD2MZVJI.js.map} +0 -0
package/dist/utils/list-url.d.ts
CHANGED
|
@@ -27,12 +27,6 @@
|
|
|
27
27
|
* Pure + server-safe (no React, no browser APIs) so the hub's server-side
|
|
28
28
|
* mappers can import it from `@flamingo-stack/openframe-frontend-core/utils`.
|
|
29
29
|
*/
|
|
30
|
-
/** Resolve a ContentRef rail-vocab type to its canonical `documentType`
|
|
31
|
-
* (registry vocabulary) — `blog_post_existing` → `blog_post`, everything
|
|
32
|
-
* else passes through. Exported so consumers comparing rail group keys to
|
|
33
|
-
* registry entityTypes (e.g. the related-content rail's same-type-first
|
|
34
|
-
* ordering) share THIS alias map instead of re-declaring it. */
|
|
35
|
-
export declare function canonicalContentRefType(contentRefType: string): string;
|
|
36
30
|
/**
|
|
37
31
|
* Build a list-API URL that returns full rows for the given ids, or `null`
|
|
38
32
|
* when the type has no list endpoint (the caller skips fetching rather than
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"list-url.d.ts","sourceRoot":"","sources":["../../src/utils/list-url.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA4BG;
|
|
1
|
+
{"version":3,"file":"list-url.d.ts","sourceRoot":"","sources":["../../src/utils/list-url.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA4BG;AAqCH;;;;;;;;;;;;;;GAcG;AACH,wBAAgB,YAAY,CAAC,cAAc,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,EAAE,EAAE,IAAI,SAAK,GAAG,MAAM,GAAG,IAAI,CAa5F"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@flamingo-stack/openframe-frontend-core",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.244",
|
|
4
4
|
"description": "Shared design system and components for all Flamingo platforms",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -60,12 +60,6 @@
|
|
|
60
60
|
"require": "./dist/components/faq/index.cjs",
|
|
61
61
|
"default": "./dist/components/faq/index.js"
|
|
62
62
|
},
|
|
63
|
-
"./components/related-content": {
|
|
64
|
-
"types": "./dist/components/related-content/index.d.ts",
|
|
65
|
-
"import": "./dist/components/related-content/index.js",
|
|
66
|
-
"require": "./dist/components/related-content/index.cjs",
|
|
67
|
-
"default": "./dist/components/related-content/index.js"
|
|
68
|
-
},
|
|
69
63
|
"./components/faq/json-ld": {
|
|
70
64
|
"types": "./dist/components/faq/json-ld.d.ts",
|
|
71
65
|
"import": "./dist/components/faq/json-ld.js",
|
|
@@ -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
|
|
@@ -22,7 +22,6 @@
|
|
|
22
22
|
import { useQuery } from '@tanstack/react-query'
|
|
23
23
|
import { useRequiredChatRuntime } from '../../../contexts/chat-runtime-context'
|
|
24
24
|
import { embedAuthedFetch } from '../../../utils/embed-authed-fetch'
|
|
25
|
-
import { extractItems, extractItemId } from '../../../utils/extract-items'
|
|
26
25
|
|
|
27
26
|
export interface UseChatCardItemResult<T = unknown> {
|
|
28
27
|
item: T | undefined
|
|
@@ -30,8 +29,44 @@ export interface UseChatCardItemResult<T = unknown> {
|
|
|
30
29
|
isError: boolean
|
|
31
30
|
}
|
|
32
31
|
|
|
33
|
-
|
|
34
|
-
|
|
32
|
+
/** Extract the items array from each endpoint's response shape (some
|
|
33
|
+
* return `{ items }`, others `{ posts }`, etc.). Single normalization
|
|
34
|
+
* point — callers always read `Item[]`. */
|
|
35
|
+
function extractItems(data: unknown): unknown[] {
|
|
36
|
+
if (!data || typeof data !== 'object') return []
|
|
37
|
+
const obj = data as Record<string, unknown>
|
|
38
|
+
if (Array.isArray(obj.items)) return obj.items
|
|
39
|
+
if (Array.isArray(obj.posts)) return obj.posts
|
|
40
|
+
if (Array.isArray(obj.campaigns)) return obj.campaigns
|
|
41
|
+
if (Array.isArray(obj.completed) || Array.isArray(obj.inProgress)) {
|
|
42
|
+
// Delivery endpoint splits into completed/inProgress arrays — flatten.
|
|
43
|
+
const completed = Array.isArray(obj.completed) ? obj.completed : []
|
|
44
|
+
const inProgress = Array.isArray(obj.inProgress) ? obj.inProgress : []
|
|
45
|
+
return [...completed, ...inProgress]
|
|
46
|
+
}
|
|
47
|
+
if (Array.isArray(obj.data)) return obj.data
|
|
48
|
+
if (Array.isArray(data)) return data
|
|
49
|
+
return []
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Extract a stable id from a fetched item — different shapes use
|
|
53
|
+
* `id` vs `external_id`. Used by the chat loader to match the fetched
|
|
54
|
+
* array element back to the marker's id. */
|
|
55
|
+
function extractItemId(type: string, item: unknown): string | null {
|
|
56
|
+
if (!item || typeof item !== 'object') return null
|
|
57
|
+
const obj = item as Record<string, unknown>
|
|
58
|
+
if (type === 'roadmap_item' || type === 'delivery_item' || type === 'internal_task') {
|
|
59
|
+
const ext = obj.external_id
|
|
60
|
+
if (typeof ext === 'string') return ext
|
|
61
|
+
}
|
|
62
|
+
// RoadmapItem-shaped responses use `id` to carry the external_id; the
|
|
63
|
+
// mapper renames external_id→id at the API boundary. Treat both id and
|
|
64
|
+
// external_id as primary-key candidates for these clickup-backed types.
|
|
65
|
+
const id = obj.id
|
|
66
|
+
if (typeof id === 'string') return id
|
|
67
|
+
if (typeof id === 'number') return String(id)
|
|
68
|
+
return null
|
|
69
|
+
}
|
|
35
70
|
|
|
36
71
|
export function useChatCardItem<T = unknown>(
|
|
37
72
|
type: string,
|
|
@@ -1,7 +1,23 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
2
|
+
* ContentRef — the unified link/embed shape stored in JSONB columns
|
|
3
|
+
* across investor updates, performance baselines, and any other entity
|
|
4
|
+
* that surfaces "related content" rails.
|
|
5
|
+
*
|
|
6
|
+
* Lifted from `lib/data/investor-update-utils-shared.ts` in the hub
|
|
7
|
+
* (the server-side data utils in the same file stay hub-side; only the
|
|
8
|
+
* TYPE moves here).
|
|
6
9
|
*/
|
|
7
|
-
|
|
10
|
+
|
|
11
|
+
export interface ContentRef {
|
|
12
|
+
type: string;
|
|
13
|
+
id: string;
|
|
14
|
+
slug: string;
|
|
15
|
+
url: string;
|
|
16
|
+
targetPlatform?: string | null;
|
|
17
|
+
title: string;
|
|
18
|
+
summary?: string;
|
|
19
|
+
image_url?: string;
|
|
20
|
+
image_bg_color?: string;
|
|
21
|
+
visibility: 'public' | 'internal';
|
|
22
|
+
display_order: number;
|
|
23
|
+
}
|