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 { MessageFreshnessDetector } from '@/lib/messageFreshness';
|
||||||
import { createScrollSpy } from '@/components/chat/lib/scroll/scrollSpy';
|
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';
|
export type AutoFollowState = 'following' | 'released';
|
||||||
|
|
||||||
@@ -98,13 +98,6 @@ const nestedScrollableCanConsumeUp = (root: HTMLElement, target: EventTarget | n
|
|||||||
return nested.scrollTop > 0;
|
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 = ({
|
export const useChatAutoFollow = ({
|
||||||
currentSessionId,
|
currentSessionId,
|
||||||
sessionMessageCount,
|
sessionMessageCount,
|
||||||
@@ -358,35 +351,17 @@ export const useChatAutoFollow = ({
|
|||||||
}
|
}
|
||||||
pendingInitialRestoreRef.current = null;
|
pendingInitialRestoreRef.current = null;
|
||||||
|
|
||||||
const saved = getViewportSessionMemory(sessionId)?.scrollPosition;
|
// Always return to the bottom on session switch. The previous saved-ratio
|
||||||
|
// restore had a low success rate and, by landing 'released' partway up,
|
||||||
if (!saved || isAtBottomSnapshot(saved, isMobile)) {
|
// produced the visible backward jump as content finished loading.
|
||||||
setStateValue('following');
|
setStateValue('following');
|
||||||
lastUserReleaseAtRef.current = 0;
|
lastUserReleaseAtRef.current = 0;
|
||||||
const target = Math.max(0, container.scrollHeight - container.clientHeight);
|
const target = Math.max(0, container.scrollHeight - container.clientHeight);
|
||||||
writeScrollTopInstant(target);
|
writeScrollTopInstant(target);
|
||||||
startFollowLoop();
|
startFollowLoop();
|
||||||
startSettleBurst();
|
startSettleBurst();
|
||||||
return false;
|
return false;
|
||||||
}
|
}, [setStateValue, startFollowLoop, startSettleBurst, writeScrollTopInstant]);
|
||||||
|
|
||||||
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]);
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (!currentSessionId || currentSessionId === lastSessionIdRef.current) {
|
if (!currentSessionId || currentSessionId === lastSessionIdRef.current) {
|
||||||
@@ -443,7 +418,6 @@ export const useChatAutoFollow = ({
|
|||||||
|
|
||||||
const programmatic = isInProgrammaticWindow();
|
const programmatic = isInProgrammaticWindow();
|
||||||
const currentTop = container.scrollTop;
|
const currentTop = container.scrollTop;
|
||||||
const previousTop = lastScrollTopRef.current;
|
|
||||||
lastScrollTopRef.current = currentTop;
|
lastScrollTopRef.current = currentTop;
|
||||||
|
|
||||||
updateOverflowAndButton();
|
updateOverflowAndButton();
|
||||||
@@ -452,7 +426,12 @@ export const useChatAutoFollow = ({
|
|||||||
return;
|
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();
|
stopFollowLoop();
|
||||||
stopSettleBurst();
|
stopSettleBurst();
|
||||||
lastUserReleaseAtRef.current = typeof performance !== 'undefined' ? performance.now() : Date.now();
|
lastUserReleaseAtRef.current = typeof performance !== 'undefined' ? performance.now() : Date.now();
|
||||||
|
|||||||
Reference in New Issue
Block a user