From 087c148b2eadcc6e8b19b252e97f51839cad3bdd Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Wed, 26 Aug 2026 16:42:42 +0300 Subject: [PATCH] fix(chat): rescue stranded viewports and settle navigation jumps Opening a session (or any relayout that shrinks off-screen size estimates) could leave the viewport in a phantom tail below the measured content, with every row out of reach above; a totalSize-change check now detects the fully blank viewport and returns to the real end, and settling a width resize re-asserts the end for a reader who was on it. Prompt-rail and message jumps land on estimated offsets that shift as the target mounts and measures; a short settle loop now re-aligns the target until layout rests, backing off on the first user gesture. --- .../ui/src/components/chat/MessageList.tsx | 67 +++++++++++++++++-- .../ui/src/hooks/useChatTimelineScroll.ts | 43 +++++++++++- 2 files changed, 102 insertions(+), 8 deletions(-) diff --git a/packages/ui/src/components/chat/MessageList.tsx b/packages/ui/src/components/chat/MessageList.tsx index 4197f067..07ce00a3 100644 --- a/packages/ui/src/components/chat/MessageList.tsx +++ b/packages/ui/src/components/chat/MessageList.tsx @@ -1534,6 +1534,54 @@ const MessageList = React.forwardRef(({ return true; }, [allEntries.length]); + // A navigation scroll lands on estimates: an unmounted target teleports + // to its estimated offset, and even a mounted one drifts when neighbours + // finish measuring a frame later. This settle loop re-aligns the target to + // the requested viewport position until the layout stops moving, and backs + // off the moment the user touches the scroll. + const settleNavigationTarget = React.useCallback(( + findElement: () => HTMLElement | null, + desiredOffsetTop: number, + ) => { + const container = resolveScrollContainer(); + if (!container || typeof window === 'undefined') { + return; + } + let frames = 0; + let stable = 0; + let cancelled = false; + const cancelOnUserInput = () => { + cancelled = true; + container.removeEventListener('touchstart', cancelOnUserInput); + container.removeEventListener('wheel', cancelOnUserInput); + }; + container.addEventListener('touchstart', cancelOnUserInput, { passive: true }); + container.addEventListener('wheel', cancelOnUserInput, { passive: true }); + const step = () => { + if (cancelled) return; + const element = findElement(); + if (element) { + const delta = element.getBoundingClientRect().top + - container.getBoundingClientRect().top + - desiredOffsetTop; + if (Math.abs(delta) > 0.5) { + container.scrollTop += delta; + stable = 0; + } else { + stable += 1; + } + } + frames += 1; + if (stable >= ANCHOR_HOLD_STABLE_FRAMES || frames >= ANCHOR_HOLD_MAX_FRAMES) { + container.removeEventListener('touchstart', cancelOnUserInput); + container.removeEventListener('wheel', cancelOnUserInput); + return; + } + window.requestAnimationFrame(step); + }; + window.requestAnimationFrame(step); + }, [resolveScrollContainer]); + const scrollMessageElementIntoView = React.useCallback((messageId: string, behavior: ScrollBehavior = 'auto') => { const container = resolveScrollContainer(); if (!container) { @@ -1569,14 +1617,19 @@ const MessageList = React.forwardRef(({ if (!container) { return false; } - const turnElement = container.querySelector(`[data-turn-id="${turnId}"]`); + const findTurnElement = () => container.querySelector(`[data-turn-id="${turnId}"]`); + const turnElement = findTurnElement(); if (turnElement) { turnElement.scrollIntoView({ behavior, block: 'start' }); + if (behavior !== 'smooth') settleNavigationTarget(findTurnElement, 0); return true; } - - return scrollHistoryIndexIntoView(index); + if (!scrollHistoryIndexIntoView(index)) { + return false; + } + if (behavior !== 'smooth') settleNavigationTarget(findTurnElement, 0); + return true; }, scrollToMessageId: (messageId: string, options?: { behavior?: ScrollBehavior }) => { @@ -1586,8 +1639,12 @@ const MessageList = React.forwardRef(({ return false; } - return scrollMessageElementIntoView(messageId, behavior) + const didScroll = scrollMessageElementIntoView(messageId, behavior) || scrollHistoryIndexIntoView(index); + if (didScroll && behavior !== 'smooth') { + settleNavigationTarget(() => findMessageElement(messageId), 50); + } + return didScroll; }, holdViewportAnchor: (anchor) => { @@ -1730,7 +1787,7 @@ const MessageList = React.forwardRef(({ return () => { objectRef.current = null; }; - }, [findMessageElement, historyEntries.length, messageIndexMap, resolveScrollContainer, scrollHistoryIndexIntoView, scrollMessageElementIntoView, turnIndexMap, ref]); + }, [findMessageElement, historyEntries.length, messageIndexMap, resolveScrollContainer, scrollHistoryIndexIntoView, scrollMessageElementIntoView, settleNavigationTarget, turnIndexMap, ref]); const anchoredEndSpace = React.useMemo(() => { const resolved = resolveChatListAnchoredEndSpace( diff --git a/packages/ui/src/hooks/useChatTimelineScroll.ts b/packages/ui/src/hooks/useChatTimelineScroll.ts index 161d9eca..544d02af 100644 --- a/packages/ui/src/hooks/useChatTimelineScroll.ts +++ b/packages/ui/src/hooks/useChatTimelineScroll.ts @@ -7,6 +7,7 @@ import { useUIStore } from '@/stores/useUIStore'; import { CHAT_LIST_ANCHOR_OFFSET, getAnchoredTurnMetrics, + getRowBottom, resolveTimelineIsAtEnd, type TimelineListMeasurementState, type TimelineScrollMode, @@ -129,6 +130,8 @@ export const useChatTimelineScroll = ({ // True after a real gesture until an explicit opt back in; drives the // overlay scrollbar suppression instead of the anchor's mere existence. const [userOwnsScroll, setUserOwnsScroll] = React.useState(false); + const userOwnsScrollRef = React.useRef(userOwnsScroll); + userOwnsScrollRef.current = userOwnsScroll; const modeRef = React.useRef('following-end'); const isAtEndRef = React.useRef(true); @@ -547,9 +550,12 @@ export const useChatTimelineScroll = ({ // per-frame row re-measure and the pinned viewport shakes. Corrections // stand down for the whole resize and the visible content is held by the // list's size compensation instead. Deliberately NO snap back to the end - // afterwards: a slow drag settles repeatedly, and each snap reads as the - // very jump this suspension removes — geometry changed, staying where the - // reader is beats re-asserting the edge. + // afterwards for a mid-conversation reader: a slow drag settles + // repeatedly, and each snap reads as the very jump this suspension + // removes. A reader who WAS at the end is the exception — after rows + // re-wrap, stale cached sizes can leave a large phantom gap below the + // last row, so re-asserting the end once on settle is what "staying + // where the reader is" means for them. const widthResizingRef = React.useRef(false); React.useEffect(() => { if (!scrollNode || typeof ResizeObserver === 'undefined') return; @@ -569,6 +575,9 @@ export const useChatTimelineScroll = ({ quietTimer = setTimeout(() => { quietTimer = null; widthResizingRef.current = false; + if (isAtEndRef.current && pendingAnchorRef.current === null) { + void listRef.current?.scrollToEnd({ animated: false }); + } }, 350); }); observer.observe(scrollNode); @@ -580,6 +589,34 @@ export const useChatTimelineScroll = ({ const onTimelineDataChange = React.useCallback(() => { if (widthResizingRef.current) return; + + // Stranded-viewport rescue, independent of any follow mode or + // preference: when off-screen size estimates settle smaller than + // estimated, the measured content can end ABOVE the viewport while + // the scroll offset stays at the stale end — the reader faces a blank + // phantom tail with every row out of reach above. That state is never + // intentional, so it is corrected even when auto-follow is off. Only + // a fully blank viewport qualifies; partial visibility is left alone. + if (!userOwnsScrollRef.current) { + const list = listRef.current; + if (list) { + const state = list.getState(); + const lastIndex = state.data.length - 1; + const lastBottom = lastIndex >= 0 ? getRowBottom(state, lastIndex) : null; + if (lastBottom !== null && state.scroll > lastBottom) { + const visibleLength = Math.max( + 0, + state.scrollLength - composerOverlayHeightRef.current - CHAT_LIST_ANCHOR_OFFSET, + ); + void list.scrollToOffset({ + offset: Math.max(0, lastBottom - visibleLength), + animated: false, + }); + return; + } + } + } + if (!streamingAutoFollowEnabledRef.current) return; if (!isLiveFollowActive()) return;