From f2d4a7833dcf2e4bcd90800225ccb1ee80af670c Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Wed, 24 Jun 2026 18:04:59 +0300 Subject: [PATCH] fix(chat): keep session switch pinned to bottom without backward jump MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- packages/ui/src/hooks/useChatAutoFollow.ts | 57 +++++++--------------- 1 file changed, 18 insertions(+), 39 deletions(-) diff --git a/packages/ui/src/hooks/useChatAutoFollow.ts b/packages/ui/src/hooks/useChatAutoFollow.ts index 7b37e272..ec1c7aa0 100644 --- a/packages/ui/src/hooks/useChatAutoFollow.ts +++ b/packages/ui/src/hooks/useChatAutoFollow.ts @@ -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, 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();