@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.
Files changed (131) hide show
  1. package/dist/{chunk-MKVKVJQQ.js → chunk-APGMNHPY.js} +19 -32
  2. package/dist/chunk-APGMNHPY.js.map +1 -0
  3. package/dist/{chunk-3TDLFCCS.cjs → chunk-DV3YZWGN.cjs} +20 -33
  4. package/dist/chunk-DV3YZWGN.cjs.map +1 -0
  5. package/dist/{chunk-BKPS6M2F.cjs → chunk-EJ7J4QAD.cjs} +12 -12
  6. package/dist/{chunk-BKPS6M2F.cjs.map → chunk-EJ7J4QAD.cjs.map} +1 -1
  7. package/dist/{chunk-CZITXP77.cjs → chunk-EPXROXYC.cjs} +713 -645
  8. package/dist/chunk-EPXROXYC.cjs.map +1 -0
  9. package/dist/{chunk-SGJBUL2Y.cjs → chunk-FSS2MIEA.cjs} +3 -20
  10. package/dist/chunk-FSS2MIEA.cjs.map +1 -0
  11. package/dist/{chunk-VAUI3CND.js → chunk-LJGBFD52.js} +1 -18
  12. package/dist/chunk-LJGBFD52.js.map +1 -0
  13. package/dist/{chunk-FRRMXLU5.js → chunk-M7DPUWAL.js} +188 -120
  14. package/dist/chunk-M7DPUWAL.js.map +1 -0
  15. package/dist/{chunk-SEVJT2HP.js → chunk-NKGNRFSS.js} +8 -8
  16. package/dist/{chunk-VFKQMAUF.cjs → chunk-OB45JHDY.cjs} +3 -3
  17. package/dist/{chunk-VFKQMAUF.cjs.map → chunk-OB45JHDY.cjs.map} +1 -1
  18. package/dist/{chunk-4XLJWX2N.js → chunk-ORJREQ2W.js} +4 -4
  19. package/dist/{chunk-7ZHFCEJH.js → chunk-P5C732SC.js} +6 -6
  20. package/dist/{chunk-JJV3XGCY.js → chunk-PTSAF7T2.js} +2 -2
  21. package/dist/{chunk-VXP553WT.cjs → chunk-RJC3L5DT.cjs} +7 -7
  22. package/dist/{chunk-VXP553WT.cjs.map → chunk-RJC3L5DT.cjs.map} +1 -1
  23. package/dist/{chunk-MP5HOP7N.cjs → chunk-STKMIMLP.cjs} +12 -12
  24. package/dist/{chunk-MP5HOP7N.cjs.map → chunk-STKMIMLP.cjs.map} +1 -1
  25. package/dist/{chunk-V35PZSUA.js → chunk-WD2MZVJI.js} +5 -5
  26. package/dist/{chunk-DUZBTUXF.cjs → chunk-Z6GGYZDR.cjs} +27 -27
  27. package/dist/{chunk-DUZBTUXF.cjs.map → chunk-Z6GGYZDR.cjs.map} +1 -1
  28. package/dist/components/chat/chat-message-list.d.ts.map +1 -1
  29. package/dist/components/chat/hooks/use-chat-card-item.d.ts.map +1 -1
  30. package/dist/components/chat/index.cjs +16 -6
  31. package/dist/components/chat/index.cjs.map +1 -1
  32. package/dist/components/chat/index.js +15 -5
  33. package/dist/components/chat/types/entities/content-ref.d.ts +20 -5
  34. package/dist/components/chat/types/entities/content-ref.d.ts.map +1 -1
  35. package/dist/components/chat/utils/history-merge.d.ts +92 -0
  36. package/dist/components/chat/utils/history-merge.d.ts.map +1 -0
  37. package/dist/components/chat/utils/index.d.ts +1 -0
  38. package/dist/components/chat/utils/index.d.ts.map +1 -1
  39. package/dist/components/contact/index.cjs +7 -7
  40. package/dist/components/contact/index.js +6 -6
  41. package/dist/components/faq/faq-section.d.ts +1 -7
  42. package/dist/components/faq/faq-section.d.ts.map +1 -1
  43. package/dist/components/faq/index.cjs +3 -3
  44. package/dist/components/faq/index.js +2 -2
  45. package/dist/components/features/index.cjs +6 -6
  46. package/dist/components/features/index.js +5 -5
  47. package/dist/components/features/policy-configuration-panel.d.ts.map +1 -1
  48. package/dist/components/index.cjs +93 -87
  49. package/dist/components/index.cjs.map +1 -1
  50. package/dist/components/index.d.ts +0 -1
  51. package/dist/components/index.d.ts.map +1 -1
  52. package/dist/components/index.js +37 -31
  53. package/dist/components/index.js.map +1 -1
  54. package/dist/components/navigation/index.cjs +6 -6
  55. package/dist/components/navigation/index.js +5 -5
  56. package/dist/components/onboarding-guides/index.cjs +30 -30
  57. package/dist/components/onboarding-guides/index.cjs.map +1 -1
  58. package/dist/components/onboarding-guides/index.js +11 -11
  59. package/dist/components/tickets/index.cjs +73 -73
  60. package/dist/components/tickets/index.cjs.map +1 -1
  61. package/dist/components/tickets/index.js +15 -15
  62. package/dist/components/ui/index.cjs +19 -9
  63. package/dist/components/ui/index.cjs.map +1 -1
  64. package/dist/components/ui/index.js +23 -13
  65. package/dist/embed-shims/index.cjs +3 -3
  66. package/dist/embed-shims/index.cjs.map +1 -1
  67. package/dist/embed-shims/index.js +5 -5
  68. package/dist/index.cjs +11 -17
  69. package/dist/index.cjs.map +1 -1
  70. package/dist/index.js +49 -55
  71. package/dist/types/index.d.ts +0 -1
  72. package/dist/types/index.d.ts.map +1 -1
  73. package/dist/utils/index.cjs +0 -81
  74. package/dist/utils/index.cjs.map +1 -1
  75. package/dist/utils/index.d.ts +1 -4
  76. package/dist/utils/index.d.ts.map +1 -1
  77. package/dist/utils/index.js +1 -74
  78. package/dist/utils/index.js.map +1 -1
  79. package/dist/utils/list-url.d.ts +0 -6
  80. package/dist/utils/list-url.d.ts.map +1 -1
  81. package/package.json +1 -7
  82. package/src/components/chat/__tests__/chat-message-list.test.tsx +92 -0
  83. package/src/components/chat/chat-message-list.tsx +74 -49
  84. package/src/components/chat/hooks/use-chat-card-item.ts +38 -3
  85. package/src/components/chat/types/entities/content-ref.ts +21 -5
  86. package/src/components/chat/utils/__tests__/history-merge.test.ts +339 -0
  87. package/src/components/chat/utils/history-merge.ts +255 -0
  88. package/src/components/chat/utils/index.ts +12 -0
  89. package/src/components/faq/faq-section.tsx +24 -69
  90. package/src/components/features/policy-configuration-panel.tsx +1 -0
  91. package/src/components/index.ts +0 -1
  92. package/src/types/index.ts +0 -1
  93. package/src/utils/index.ts +1 -16
  94. package/src/utils/list-url.ts +0 -9
  95. package/dist/chunk-3TDLFCCS.cjs.map +0 -1
  96. package/dist/chunk-CZITXP77.cjs.map +0 -1
  97. package/dist/chunk-F3GMWNAT.js +0 -285
  98. package/dist/chunk-F3GMWNAT.js.map +0 -1
  99. package/dist/chunk-FRRMXLU5.js.map +0 -1
  100. package/dist/chunk-HU252PYJ.cjs +0 -285
  101. package/dist/chunk-HU252PYJ.cjs.map +0 -1
  102. package/dist/chunk-MKVKVJQQ.js.map +0 -1
  103. package/dist/chunk-SGJBUL2Y.cjs.map +0 -1
  104. package/dist/chunk-VAUI3CND.js.map +0 -1
  105. package/dist/components/related-content/index.cjs +0 -26
  106. package/dist/components/related-content/index.cjs.map +0 -1
  107. package/dist/components/related-content/index.d.ts +0 -2
  108. package/dist/components/related-content/index.d.ts.map +0 -1
  109. package/dist/components/related-content/index.js +0 -26
  110. package/dist/components/related-content/index.js.map +0 -1
  111. package/dist/components/related-content/related-content-section.d.ts +0 -141
  112. package/dist/components/related-content/related-content-section.d.ts.map +0 -1
  113. package/dist/types/content-ref.d.ts +0 -35
  114. package/dist/types/content-ref.d.ts.map +0 -1
  115. package/dist/utils/content-ref-groups.d.ts +0 -58
  116. package/dist/utils/content-ref-groups.d.ts.map +0 -1
  117. package/dist/utils/extract-items.d.ts +0 -18
  118. package/dist/utils/extract-items.d.ts.map +0 -1
  119. package/dist/utils/suggestion-url.d.ts +0 -26
  120. package/dist/utils/suggestion-url.d.ts.map +0 -1
  121. package/src/components/related-content/index.ts +0 -9
  122. package/src/components/related-content/related-content-section.tsx +0 -612
  123. package/src/types/content-ref.ts +0 -34
  124. package/src/utils/content-ref-groups.ts +0 -89
  125. package/src/utils/extract-items.ts +0 -48
  126. package/src/utils/suggestion-url.ts +0 -39
  127. /package/dist/{chunk-SEVJT2HP.js.map → chunk-NKGNRFSS.js.map} +0 -0
  128. /package/dist/{chunk-4XLJWX2N.js.map → chunk-ORJREQ2W.js.map} +0 -0
  129. /package/dist/{chunk-7ZHFCEJH.js.map → chunk-P5C732SC.js.map} +0 -0
  130. /package/dist/{chunk-JJV3XGCY.js.map → chunk-PTSAF7T2.js.map} +0 -0
  131. /package/dist/{chunk-V35PZSUA.js.map → chunk-WD2MZVJI.js.map} +0 -0
@@ -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;AAWH;;;;iEAIiE;AACjE,wBAAgB,uBAAuB,CAAC,cAAc,EAAE,MAAM,GAAG,MAAM,CAEtE;AA4BD;;;;;;;;;;;;;;GAcG;AACH,wBAAgB,YAAY,CAAC,cAAc,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,EAAE,EAAE,IAAI,SAAK,GAAG,MAAM,GAAG,IAAI,CAa5F"}
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.243",
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
- // Snapshot of the first message id + scrollHeight observed BEFORE
144
- // a load-older prepend, so we can preserve the user's viewport
145
- // position when older messages stream in above their current
146
- // reading position.
147
- const prependRef = useRef<{
148
- firstMessageId: string | undefined
149
- firstMessageContent: unknown
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
- }>({ firstMessageId: undefined, firstMessageContent: undefined, scrollHeight: 0 })
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
- const newSlice = messages.slice(prevCount)
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 prepends
234
- // ABOVE the viewport. We do it ourselves: snapshot scrollHeight
235
- // BEFORE the new render, then add the delta after to keep the
236
- // user on the same message they were reading. `useLayoutEffect`
237
- // so the scrollTop write lands before paint (no visible jump).
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
- if (!el) {
241
- prependRef.current = { firstMessageId: undefined, firstMessageContent: undefined, scrollHeight: 0 }
242
- return
243
- }
244
-
245
- const currentFirstId = messages[0]?.id
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
- // Always keep `scrollHeight` snapshot fresh so the next prepend
269
- // computes the right delta. The id/content updates only when
270
- // those actually change to avoid churn.
271
- prependRef.current.scrollHeight = el.scrollHeight
272
- if (currentFirstContent !== prependRef.current.firstMessageContent) {
273
- prependRef.current.firstMessageContent = currentFirstContent
274
- }
275
- }, [messages, scrollRef])
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
- // `extractItems` / `extractItemId` hoisted to `src/utils/extract-items.ts`
34
- // (shared with the related-content rail react-query-free home).
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
- * RELOCATED — the canonical `ContentRef` now lives at `src/types/content-ref.ts`
3
- * (server-safe home, shared by the related-content rail + chat entities).
4
- * This stub keeps the chat entities barrel + `entities/investor-update.ts`
5
- * compiling unchanged. One definition package-wide.
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
- export type { ContentRef, ContentRefWithReason } from '../../../../types/content-ref';
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
+ }