fix(chat): keep session switch pinned to bottom without backward jump

Release auto-follow based on position (the user has left the near-bottom zone)
instead of scroll-delta direction. The old `currentTop < previousTop` check
treated the tiny scrollTop clamp the browser applies when the composer grows —
which keeps you at the bottom — as a user scroll-up and released follow, so
content finishing loading then drifted the view backward.

Also always return to the bottom on session switch, dropping the saved-ratio
restore: it had a low success rate and, by landing 'released' partway up,
produced the same visible backward jump as content finished loading.

overflow-anchor is already disabled on the chat scroll container, so no
delta-threshold workaround is needed; this is a net simplification.
This commit is contained in:
Bohdan Triapitsyn
2026-06-24 18:04:59 +03:00
parent 1abb0dc103
commit f2d4a7833d
+18 -39
View File
@@ -2,7 +2,7 @@ import React from 'react';
import { MessageFreshnessDetector } from '@/lib/messageFreshness';
import { createScrollSpy } from '@/components/chat/lib/scroll/scrollSpy';
import { getViewportSessionMemory, useViewportStore, type SessionMemoryState } from '@/sync/viewport-store';
import { useViewportStore } from '@/sync/viewport-store';
export type AutoFollowState = 'following' | 'released';
@@ -98,13 +98,6 @@ const nestedScrollableCanConsumeUp = (root: HTMLElement, target: EventTarget | n
return nested.scrollTop > 0;
};
const isAtBottomSnapshot = (snapshot: NonNullable<SessionMemoryState['scrollPosition']>, isMobile: boolean): boolean => {
const max = Math.max(0, snapshot.scrollHeight - snapshot.clientHeight);
if (max <= 0) return true;
const threshold = computeBottomZoneThreshold(isMobile, null);
return max - snapshot.scrollTop <= threshold;
};
export const useChatAutoFollow = ({
currentSessionId,
sessionMessageCount,
@@ -358,35 +351,17 @@ export const useChatAutoFollow = ({
}
pendingInitialRestoreRef.current = null;
const saved = getViewportSessionMemory(sessionId)?.scrollPosition;
if (!saved || isAtBottomSnapshot(saved, isMobile)) {
setStateValue('following');
lastUserReleaseAtRef.current = 0;
const target = Math.max(0, container.scrollHeight - container.clientHeight);
writeScrollTopInstant(target);
startFollowLoop();
startSettleBurst();
return false;
}
const savedMaxScroll = Math.max(0, saved.scrollHeight - saved.clientHeight);
const ratio = savedMaxScroll > 0 ? saved.scrollTop / savedMaxScroll : 0;
const currentMaxScroll = Math.max(0, container.scrollHeight - container.clientHeight);
const targetTop = Math.round(ratio * currentMaxScroll);
setStateValue('released');
writeScrollTopInstant(targetTop);
const memState = getViewportSessionMemory(sessionId);
updateViewportAnchor(sessionId, memState?.viewportAnchor ?? 0, {
scrollTop: container.scrollTop,
scrollHeight: container.scrollHeight,
clientHeight: container.clientHeight,
});
return true;
}, [isMobile, setStateValue, startFollowLoop, startSettleBurst, updateViewportAnchor, writeScrollTopInstant]);
// Always return to the bottom on session switch. The previous saved-ratio
// restore had a low success rate and, by landing 'released' partway up,
// produced the visible backward jump as content finished loading.
setStateValue('following');
lastUserReleaseAtRef.current = 0;
const target = Math.max(0, container.scrollHeight - container.clientHeight);
writeScrollTopInstant(target);
startFollowLoop();
startSettleBurst();
return false;
}, [setStateValue, startFollowLoop, startSettleBurst, writeScrollTopInstant]);
React.useEffect(() => {
if (!currentSessionId || currentSessionId === lastSessionIdRef.current) {
@@ -443,7 +418,6 @@ export const useChatAutoFollow = ({
const programmatic = isInProgrammaticWindow();
const currentTop = container.scrollTop;
const previousTop = lastScrollTopRef.current;
lastScrollTopRef.current = currentTop;
updateOverflowAndButton();
@@ -452,7 +426,12 @@ export const useChatAutoFollow = ({
return;
}
if (currentTop < previousTop && stateRef.current === 'following') {
// Release auto-follow only when the user has actually left the near-bottom
// zone — not on the small scrollTop clamp the browser applies when the
// composer grows and shrinks the viewport (which keeps you at the bottom).
// Position-based, mirroring the re-pin check below; this removes the false
// release that produced the visible backward jump on session switch.
if (stateRef.current === 'following' && !isNearBottom(container, isMobile)) {
stopFollowLoop();
stopSettleBurst();
lastUserReleaseAtRef.current = typeof performance !== 'undefined' ? performance.now() : Date.now();