From 382884e2378987e0500f915c59682372aba72d0d Mon Sep 17 00:00:00 2001 From: 07akioni <07akioni2@gmail.com> Date: Wed, 29 Jul 2026 20:39:45 +0800 Subject: [PATCH] fix: scroll to bottom --- .../src/client/chat/ChatView.tsx | 19 +++++++++++++++---- .../ui-conversation/tests/chat-view.spec.tsx | 14 ++++++++++++++ 2 files changed, 29 insertions(+), 4 deletions(-) diff --git a/packages/client/ui-conversation/src/client/chat/ChatView.tsx b/packages/client/ui-conversation/src/client/chat/ChatView.tsx index e281c0e11d..b9e1e3351f 100644 --- a/packages/client/ui-conversation/src/client/chat/ChatView.tsx +++ b/packages/client/ui-conversation/src/client/chat/ChatView.tsx @@ -253,9 +253,15 @@ export function ChatView({ useSession, useSessions, useStore, renderSlot, sessio const firstSeqRef = useRef(null) const openedRef = useRef(false) const lastKeyRef = useRef(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(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(() => {}) diff --git a/packages/client/ui-conversation/tests/chat-view.spec.tsx b/packages/client/ui-conversation/tests/chat-view.spec.tsx index 7254add4ca..c0cb4dcb78 100644 --- a/packages/client/ui-conversation/tests/chat-view.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-view.spec.tsx @@ -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() + 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', '')