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:
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user