From 28500fffd95256553b053f0a99660616e172d161 Mon Sep 17 00:00:00 2001 From: Xubin Ren <52506698+Re-bin@users.noreply.github.com> Date: Tue, 25 Aug 2026 20:37:18 +0800 Subject: [PATCH] fix(webui): stabilize fast history scrolling --- .../src/components/thread/ThreadMessages.tsx | 1 + .../src/components/thread/ThreadViewport.tsx | 95 +++++++++++++++++-- webui/src/tests/thread-viewport.test.tsx | 58 +++++++++++ 3 files changed, 144 insertions(+), 10 deletions(-) diff --git a/webui/src/components/thread/ThreadMessages.tsx b/webui/src/components/thread/ThreadMessages.tsx index 01e77ee82..00d41bd42 100644 --- a/webui/src/components/thread/ThreadMessages.tsx +++ b/webui/src/components/thread/ThreadMessages.tsx @@ -273,6 +273,7 @@ const ThreadDisplayUnit = memo(function ThreadDisplayUnit({ <>
{unit.type === "activity" ? ( diff --git a/webui/src/components/thread/ThreadViewport.tsx b/webui/src/components/thread/ThreadViewport.tsx index 71eab3d2e..b540a9218 100644 --- a/webui/src/components/thread/ThreadViewport.tsx +++ b/webui/src/components/thread/ThreadViewport.tsx @@ -72,6 +72,11 @@ const SESSION_HANDOFF_OPACITY = 0.82; export const INITIAL_HISTORY_WINDOW = 160; export const HISTORY_WINDOW_INCREMENT = 120; +interface HistoryScrollAnchor { + element: HTMLElement; + offsetTop: number; +} + export function windowMessages(messages: UIMessage[], visibleCount: number): UIMessage[] { if (messages.length <= visibleCount) return messages; let start = Math.max(0, messages.length - visibleCount); @@ -210,6 +215,7 @@ export const ThreadViewport = forwardRef(null); const restoreScrollAfterPrependRef = useRef<{ height: number; top: number } | null>(null); + const historyScrollAnchorRef = useRef(null); const composerInputScrollTopRef = useRef(null); const composerDockHeightRef = useRef(0); const [atBottom, setAtBottom] = useState(true); @@ -298,7 +304,50 @@ export const ThreadViewport = forwardRef { + const scroller = scrollRef.current; + const content = messageContentRef.current; + if (!scroller || !content) { + historyScrollAnchorRef.current = null; + return false; + } + const scrollerTop = scroller.getBoundingClientRect().top; + const units = content.querySelectorAll("[data-thread-display-unit]"); + for (const element of units) { + const bounds = element.getBoundingClientRect(); + if (bounds.bottom <= scrollerTop + 0.5) continue; + historyScrollAnchorRef.current = { + element, + offsetTop: bounds.top - scrollerTop, + }; + return true; + } + historyScrollAnchorRef.current = null; + return false; + }, []); + + const reconcileHistoryScrollAnchor = useCallback(() => { + const scroller = scrollRef.current; + const anchor = historyScrollAnchorRef.current; + if (!scroller || !anchor) return false; + if (!anchor.element.isConnected) { + historyScrollAnchorRef.current = null; + return false; + } + + const nextOffset = + anchor.element.getBoundingClientRect().top + - scroller.getBoundingClientRect().top; + const delta = nextOffset - anchor.offsetTop; + if (Math.abs(delta) < 0.5) return true; + const maxScrollTop = Math.max(0, scroller.scrollHeight - scroller.clientHeight); + const nextTop = Math.min(maxScrollTop, Math.max(0, scroller.scrollTop + delta)); + threadMotionRef.current?.jumpTo(nextTop); + return true; + }, [captureHistoryScrollAnchor]); + const scrollToBottomNow = useCallback((smooth = false) => { + historyScrollAnchorRef.current = null; const el = scrollRef.current; const marker = bottomRef.current; const behavior: ScrollBehavior = smooth ? "smooth" : "auto"; @@ -328,10 +377,14 @@ export const ThreadViewport = forwardRef { const el = scrollRef.current; if (el) { - restoreScrollAfterPrependRef.current = { - height: el.scrollHeight, - top: el.scrollTop, - }; + if (captureHistoryScrollAnchor()) { + restoreScrollAfterPrependRef.current = null; + } else { + restoreScrollAfterPrependRef.current = { + height: el.scrollHeight, + top: el.scrollTop, + }; + } } threadMotionRef.current?.takeUserControl(); setAtBottom(false); @@ -345,7 +398,14 @@ export const ThreadViewport = forwardRef count + HISTORY_WINDOW_INCREMENT); void onLoadOlder(); } - }, [hasMoreBefore, hiddenMessageCount, loadingOlder, messages.length, onLoadOlder]); + }, [ + captureHistoryScrollAnchor, + hasMoreBefore, + hiddenMessageCount, + loadingOlder, + messages.length, + onLoadOlder, + ]); const maybeLoadEarlierFromScroll = useCallback(() => { const el = scrollRef.current; @@ -360,6 +420,7 @@ export const ThreadViewport = forwardRef { const pending = restoreScrollAfterPrependRef.current; - if (!pending) return; const el = scrollRef.current; restoreScrollAfterPrependRef.current = null; if (!el) return; + if (reconcileHistoryScrollAnchor()) return; + if (!pending) return; const delta = el.scrollHeight - pending.height; const nextTop = Math.min( Math.max(0, el.scrollHeight - el.clientHeight), Math.max(0, pending.top + delta), ); threadMotionRef.current?.jumpTo(nextTop); - }, [visibleMessages.length, messages.length]); + }, [reconcileHistoryScrollAnchor, visibleMessages.length, messages.length]); useLayoutEffect(() => { const promptId = pendingPromptJumpRef.current; @@ -593,6 +657,7 @@ export const ThreadViewport = forwardRef { + reconcileHistoryScrollAnchor(); threadMotionRef.current?.reconcileObservedGeometry(); }; reconcileObservedGeometry(); @@ -609,7 +674,7 @@ export const ThreadViewport = forwardRef { const el = scrollRef.current; @@ -623,7 +688,12 @@ export const ThreadViewport = forwardRef current === logicallyAtBottom ? current : logicallyAtBottom, ); - if (allowHistoryLoad && owner === "user") maybeLoadEarlierFromScroll(); + if (owner === "user") { + captureHistoryScrollAnchor(); + if (allowHistoryLoad) maybeLoadEarlierFromScroll(); + } else if (near) { + historyScrollAnchorRef.current = null; + } }; onScroll(false); @@ -709,7 +779,12 @@ export const ThreadViewport = forwardRef diff --git a/webui/src/tests/thread-viewport.test.tsx b/webui/src/tests/thread-viewport.test.tsx index f9eb7582f..4535b62dd 100644 --- a/webui/src/tests/thread-viewport.test.tsx +++ b/webui/src/tests/thread-viewport.test.tsx @@ -1481,6 +1481,64 @@ describe("ThreadViewport", () => { expect(screen.getAllByText("message 299").length).toBeGreaterThan(0); }); + it("keeps the first visible history item fixed while deferred rows materialize", () => { + const resizeObserver = stubResizeObserver(); + try { + const { container } = render( + } + />, + ); + + const scroller = getScroller(container); + let scrollHeight = 2_400; + Object.defineProperties(scroller, { + scrollHeight: { configurable: true, get: () => scrollHeight }, + clientHeight: { configurable: true, value: 600 }, + scrollTop: { configurable: true, writable: true, value: 80 }, + getBoundingClientRect: { + configurable: true, + value: () => DOMRect.fromRect({ y: 0, width: 800, height: 600 }), + }, + }); + + const anchor = screen.getByText("message 140") + .closest("[data-thread-display-unit]"); + expect(anchor).not.toBeNull(); + let anchorDocumentTop = 200; + Object.defineProperty(anchor, "getBoundingClientRect", { + configurable: true, + value: () => DOMRect.fromRect({ + y: anchorDocumentTop - scroller.scrollTop, + width: 800, + height: 40, + }), + }); + + act(() => { + dispatchUserScroll(scroller); + }); + + anchorDocumentTop += 180; + scrollHeight += 180; + const content = screen.getByTestId("thread-message-region").firstElementChild; + const observer = resizeObserver.observers.find((candidate) => + content ? candidate.elements.includes(content) : false, + ); + expect(observer).toBeDefined(); + act(() => { + observer?.callback([], observer as unknown as ResizeObserver); + }); + + expect(scroller.scrollTop).toBe(260); + expect(anchor.getBoundingClientRect().top).toBe(120); + } finally { + resizeObserver.restore(); + } + }); + it("automatically requests older transcript pages near the top", () => { const onLoadOlder = vi.fn();