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.
This commit is contained in:
@@ -1534,6 +1534,54 @@ const MessageList = React.forwardRef<MessageListHandle, MessageListProps>(({
|
|||||||
return true;
|
return true;
|
||||||
}, [allEntries.length]);
|
}, [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 scrollMessageElementIntoView = React.useCallback((messageId: string, behavior: ScrollBehavior = 'auto') => {
|
||||||
const container = resolveScrollContainer();
|
const container = resolveScrollContainer();
|
||||||
if (!container) {
|
if (!container) {
|
||||||
@@ -1569,14 +1617,19 @@ const MessageList = React.forwardRef<MessageListHandle, MessageListProps>(({
|
|||||||
if (!container) {
|
if (!container) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
const turnElement = container.querySelector<HTMLElement>(`[data-turn-id="${turnId}"]`);
|
const findTurnElement = () => container.querySelector<HTMLElement>(`[data-turn-id="${turnId}"]`);
|
||||||
|
const turnElement = findTurnElement();
|
||||||
if (turnElement) {
|
if (turnElement) {
|
||||||
turnElement.scrollIntoView({ behavior, block: 'start' });
|
turnElement.scrollIntoView({ behavior, block: 'start' });
|
||||||
|
if (behavior !== 'smooth') settleNavigationTarget(findTurnElement, 0);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!scrollHistoryIndexIntoView(index)) {
|
||||||
return scrollHistoryIndexIntoView(index);
|
return false;
|
||||||
|
}
|
||||||
|
if (behavior !== 'smooth') settleNavigationTarget(findTurnElement, 0);
|
||||||
|
return true;
|
||||||
},
|
},
|
||||||
|
|
||||||
scrollToMessageId: (messageId: string, options?: { behavior?: ScrollBehavior }) => {
|
scrollToMessageId: (messageId: string, options?: { behavior?: ScrollBehavior }) => {
|
||||||
@@ -1586,8 +1639,12 @@ const MessageList = React.forwardRef<MessageListHandle, MessageListProps>(({
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
return scrollMessageElementIntoView(messageId, behavior)
|
const didScroll = scrollMessageElementIntoView(messageId, behavior)
|
||||||
|| scrollHistoryIndexIntoView(index);
|
|| scrollHistoryIndexIntoView(index);
|
||||||
|
if (didScroll && behavior !== 'smooth') {
|
||||||
|
settleNavigationTarget(() => findMessageElement(messageId), 50);
|
||||||
|
}
|
||||||
|
return didScroll;
|
||||||
},
|
},
|
||||||
|
|
||||||
holdViewportAnchor: (anchor) => {
|
holdViewportAnchor: (anchor) => {
|
||||||
@@ -1730,7 +1787,7 @@ const MessageList = React.forwardRef<MessageListHandle, MessageListProps>(({
|
|||||||
return () => {
|
return () => {
|
||||||
objectRef.current = null;
|
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<TimelineAnchoredEndSpace | undefined>(() => {
|
const anchoredEndSpace = React.useMemo<TimelineAnchoredEndSpace | undefined>(() => {
|
||||||
const resolved = resolveChatListAnchoredEndSpace(
|
const resolved = resolveChatListAnchoredEndSpace(
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { useUIStore } from '@/stores/useUIStore';
|
|||||||
import {
|
import {
|
||||||
CHAT_LIST_ANCHOR_OFFSET,
|
CHAT_LIST_ANCHOR_OFFSET,
|
||||||
getAnchoredTurnMetrics,
|
getAnchoredTurnMetrics,
|
||||||
|
getRowBottom,
|
||||||
resolveTimelineIsAtEnd,
|
resolveTimelineIsAtEnd,
|
||||||
type TimelineListMeasurementState,
|
type TimelineListMeasurementState,
|
||||||
type TimelineScrollMode,
|
type TimelineScrollMode,
|
||||||
@@ -129,6 +130,8 @@ export const useChatTimelineScroll = ({
|
|||||||
// True after a real gesture until an explicit opt back in; drives the
|
// True after a real gesture until an explicit opt back in; drives the
|
||||||
// overlay scrollbar suppression instead of the anchor's mere existence.
|
// overlay scrollbar suppression instead of the anchor's mere existence.
|
||||||
const [userOwnsScroll, setUserOwnsScroll] = React.useState(false);
|
const [userOwnsScroll, setUserOwnsScroll] = React.useState(false);
|
||||||
|
const userOwnsScrollRef = React.useRef(userOwnsScroll);
|
||||||
|
userOwnsScrollRef.current = userOwnsScroll;
|
||||||
|
|
||||||
const modeRef = React.useRef<TimelineScrollMode>('following-end');
|
const modeRef = React.useRef<TimelineScrollMode>('following-end');
|
||||||
const isAtEndRef = React.useRef(true);
|
const isAtEndRef = React.useRef(true);
|
||||||
@@ -547,9 +550,12 @@ export const useChatTimelineScroll = ({
|
|||||||
// per-frame row re-measure and the pinned viewport shakes. Corrections
|
// 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
|
// 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
|
// list's size compensation instead. Deliberately NO snap back to the end
|
||||||
// afterwards: a slow drag settles repeatedly, and each snap reads as the
|
// afterwards for a mid-conversation reader: a slow drag settles
|
||||||
// very jump this suspension removes — geometry changed, staying where the
|
// repeatedly, and each snap reads as the very jump this suspension
|
||||||
// reader is beats re-asserting the edge.
|
// 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);
|
const widthResizingRef = React.useRef(false);
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (!scrollNode || typeof ResizeObserver === 'undefined') return;
|
if (!scrollNode || typeof ResizeObserver === 'undefined') return;
|
||||||
@@ -569,6 +575,9 @@ export const useChatTimelineScroll = ({
|
|||||||
quietTimer = setTimeout(() => {
|
quietTimer = setTimeout(() => {
|
||||||
quietTimer = null;
|
quietTimer = null;
|
||||||
widthResizingRef.current = false;
|
widthResizingRef.current = false;
|
||||||
|
if (isAtEndRef.current && pendingAnchorRef.current === null) {
|
||||||
|
void listRef.current?.scrollToEnd({ animated: false });
|
||||||
|
}
|
||||||
}, 350);
|
}, 350);
|
||||||
});
|
});
|
||||||
observer.observe(scrollNode);
|
observer.observe(scrollNode);
|
||||||
@@ -580,6 +589,34 @@ export const useChatTimelineScroll = ({
|
|||||||
|
|
||||||
const onTimelineDataChange = React.useCallback(() => {
|
const onTimelineDataChange = React.useCallback(() => {
|
||||||
if (widthResizingRef.current) return;
|
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 (!streamingAutoFollowEnabledRef.current) return;
|
||||||
if (!isLiveFollowActive()) return;
|
if (!isLiveFollowActive()) return;
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user