fix: scroll to bottom

This commit is contained in:
07akioni
2026-07-29 20:39:45 +08:00
parent 63e1054916
commit 382884e237
2 changed files with 29 additions and 4 deletions

View File

@@ -253,9 +253,15 @@ export function ChatView({ useSession, useSessions, useStore, renderSlot, sessio
const firstSeqRef = useRef<number | null>(null)
const openedRef = useRef(false)
const lastKeyRef = useRef<string | null>(null)
/** Flow tip signature — follow-scroll only when this moves, never on a
* scroll-driven at-bottom chrome re-render (that was snapping inertial
* scrolls the rest of the way to the floor). */
const followSigRef = useRef<string | null>(null)
const firstSeq = nodes[0]?.seq ?? null
const lastItem = items[items.length - 1]
const lastKey = lastItem?.key ?? null
const followSig = `${openState}:${firstSeq}:${lastKey}:${nodes.length}:${running ? 1 : 0}:${runningCalls.length}`
const toBottom = (el: HTMLElement): void => {
el.scrollTop = el.scrollHeight
@@ -273,7 +279,8 @@ export function ChatView({ useSession, useSessions, useStore, renderSlot, sessio
openedRef.current = true
toBottom(el)
firstSeqRef.current = firstSeq
lastKeyRef.current = lastItem?.key ?? null
lastKeyRef.current = lastKey
followSigRef.current = followSig
return
}
// Prepend (head seq decreased): compensate by the height delta.
@@ -282,17 +289,21 @@ export function ChatView({ useSession, useSessions, useStore, renderSlot, sessio
anchorRef.current = null
firstSeqRef.current = firstSeq
/* v8 ignore next -- ?? arm: a prepend adds nodes, so the flow list here is never empty. */
lastKeyRef.current = lastItem?.key ?? null
lastKeyRef.current = lastKey
followSigRef.current = followSig
return
}
firstSeqRef.current = firstSeq
// Own words must be visible: a new trailing user node force-scrolls
// (send lives in the composer, so arrival is detected here, not armed there).
const lastKey = lastItem?.key ?? null
const appendedUser = lastKey !== lastKeyRef.current
&& lastItem !== undefined && lastItem.kind === 'node' && lastItem.node.kind === 'user'
const tipMoved = followSigRef.current !== followSig
lastKeyRef.current = lastKey
if (appendedUser || atBottomRef.current) toBottom(el)
followSigRef.current = followSig
// Follow new flow content while pinned; do NOT re-pin on every render
// merely because atBottomRef is true (scroll threshold → setState → snap).
if (appendedUser || (tipMoved && atBottomRef.current)) toBottom(el)
})
const onScrollRef = useRef(() => {})

View File

@@ -371,6 +371,20 @@ describe('ChatView', () => {
expect(view.queryByLabelText('回到底部')).toBeNull()
})
it('entering the at-bottom threshold does not snap the remaining scroll distance', () => {
const h = makeHarness({ nodes: [user(1, 'q'), assistant(2, 'a')] })
const view = render(<h.ChatView {...h.props} />)
const scroller = view.container.querySelector('[class*="scroll"]') as HTMLDivElement
Object.defineProperty(scroller, 'scrollHeight', { value: 1000, writable: true })
Object.defineProperty(scroller, 'clientHeight', { value: 300, writable: true })
// Inside FOLLOW_THRESHOLD (24) but not flush with the floor — the chrome
// re-render from setAtBottom must not force scrollTop to scrollHeight.
scroller.scrollTop = 690 // distance-to-bottom = 10
fireEvent.scroll(scroller)
expect(view.queryByLabelText('回到底部')).toBeNull()
expect(scroller.scrollTop).toBe(690)
})
it('under data-conversation-scroll, bottom-follow targets the host scrollport', () => {
const host = document.createElement('div')
host.setAttribute('data-conversation-scroll', '')