fix: preserve per-session scroll position on session switch (#1083)

* fix: restore scroll position when switching chat sessions

When switching between chat sessions, scroll position now restores to
where the user left off instead of always jumping to the bottom.

- Save pixel-level scrollPosition (scrollTop/scrollHeight/clientHeight)
  in viewport store on every scroll event
- Add restoreSavedScrollPosition to timeline controller for ratio-based
  restoration (handles content size changes between visits)
- Suppress intermediate scroll events during session transition with an
  explicit flag, cleared deterministically after restore completes
- Cancel in-flight animations/follow-loops on session switch
- Preserve scrollPosition when session-ui-store rebuilds SessionMemoryState

* fix: keep streaming sessions pinned on restore

---------

Co-authored-by: Bohdan Triapitsyn <artmore@protonmail.com>
This commit is contained in:
jwcrystal
2026-05-01 12:39:04 +03:00
committed by GitHub
co-authored by Bohdan Triapitsyn
parent a23f5e7545
commit 03c9065c90
7 changed files with 164 additions and 44 deletions
+79 -19
View File
@@ -8,11 +8,13 @@ import {
} from '@/components/chat/lib/scroll/scrollIntent';
import { useScrollEngine } from './useScrollEngine';
import { useViewportStore } from '@/sync/viewport-store';
export type ContentChangeReason = 'text' | 'structural' | 'permission';
interface SessionMemoryState {
viewportAnchor: number;
scrollPosition?: { scrollTop: number; scrollHeight: number; clientHeight: number };
isStreaming: boolean;
lastAccessedAt: number;
backgroundMessageCount: number;
@@ -28,7 +30,7 @@ interface UseChatScrollManagerOptions {
sessionPermissions: unknown[];
sessionIsWorking: boolean;
sessionMemoryState: Map<string, SessionMemoryState>;
updateViewportAnchor: (sessionId: string, anchor: number) => void;
updateViewportAnchor: (sessionId: string, anchor: number, scrollPosition?: { scrollTop: number; scrollHeight: number; clientHeight: number }) => void;
isSyncing: boolean;
isMobile: boolean;
chatRenderMode?: 'sorted' | 'live';
@@ -64,6 +66,7 @@ interface UseChatScrollManagerResult {
isPinned: boolean;
isOverflowing: boolean;
isProgrammaticFollowActive: boolean;
clearRestoreInProgress: (sessionId: string) => void;
}
const PROGRAMMATIC_SCROLL_SUPPRESS_MS = 200;
@@ -120,9 +123,14 @@ export const useChatScrollManager = ({
const followModeRef = React.useRef<FollowMode>('none');
const autoScrollMarkerRef = React.useRef<AutoScrollMarker | null>(null);
const viewportAnchorTimerRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
const pendingViewportAnchorRef = React.useRef<{ sessionId: string; anchor: number } | null>(null);
const lastViewportAnchorRef = React.useRef<{ sessionId: string; anchor: number } | null>(null);
const pendingViewportAnchorRef = React.useRef<{ sessionId: string; anchor: number; scrollPosition?: { scrollTop: number; scrollHeight: number; clientHeight: number } } | null>(null);
const lastViewportAnchorRef = React.useRef<{ sessionId: string; anchor: number; scrollTop: number } | null>(null);
const lastViewportAnchorWriteAtRef = React.useRef<number>(0);
// Guard: suppress scroll-position saving during session transition window.
// Without this, scroll events from content reshaping after session switch
// overwrite the saved position before restoreSavedScrollPosition reads it.
// Stores the session ID being restored; cleared after restoration completes.
const restoreInProgressForRef = React.useRef<string | null>(null);
const markAutoScroll = React.useCallback((top: number) => {
autoScrollMarkerRef.current = {
@@ -292,25 +300,28 @@ export const useChatScrollManager = ({
return;
}
// Skip only when both anchor AND pixel position are unchanged.
const lastPersisted = lastViewportAnchorRef.current;
if (lastPersisted && lastPersisted.sessionId === pending.sessionId && lastPersisted.anchor === pending.anchor) {
if (lastPersisted
&& lastPersisted.sessionId === pending.sessionId
&& lastPersisted.anchor === pending.anchor
&& lastPersisted.scrollTop === (pending.scrollPosition?.scrollTop ?? 0)) {
pendingViewportAnchorRef.current = null;
return;
}
updateViewportAnchor(pending.sessionId, pending.anchor);
lastViewportAnchorRef.current = pending;
updateViewportAnchor(pending.sessionId, pending.anchor, pending.scrollPosition);
lastViewportAnchorRef.current = { sessionId: pending.sessionId, anchor: pending.anchor, scrollTop: pending.scrollPosition?.scrollTop ?? 0 };
pendingViewportAnchorRef.current = null;
lastViewportAnchorWriteAtRef.current = Date.now();
}, [updateViewportAnchor]);
const queueViewportAnchor = React.useCallback((sessionId: string, anchor: number) => {
const lastPersisted = lastViewportAnchorRef.current;
if (lastPersisted && lastPersisted.sessionId === sessionId && lastPersisted.anchor === anchor) {
return;
}
const queueViewportAnchor = React.useCallback((sessionId: string, anchor: number, scrollPosition?: { scrollTop: number; scrollHeight: number; clientHeight: number }) => {
// Always update pending with latest pixel position, even if anchor
// hasn't changed — the user may have scrolled within the same
// coarse message-index bucket.
pendingViewportAnchorRef.current = { sessionId, anchor, scrollPosition };
pendingViewportAnchorRef.current = { sessionId, anchor };
const now = Date.now();
const elapsed = now - lastViewportAnchorWriteAtRef.current;
if (elapsed >= VIEWPORT_ANCHOR_MIN_UPDATE_MS) {
@@ -369,6 +380,15 @@ export const useChatScrollManager = ({
}
scrollEngine.handleScroll();
// During session restore, skip all pin/unpin and position-save logic.
// The session-switch effect and restoreSavedScrollPosition handle
// restoration; intermediate scroll events from content reshaping must
// not override the saved position or pinned state.
if (restoreInProgressForRef.current === currentSessionId) {
return;
}
schedulePinnedStateAndIndicators();
// Handle pin/unpin logic
@@ -396,7 +416,7 @@ export const useChatScrollManager = ({
const { scrollTop, scrollHeight, clientHeight } = container;
const position = (scrollTop + clientHeight / 2) / Math.max(scrollHeight, 1);
const estimatedIndex = Math.floor(position * sessionMessageCount);
queueViewportAnchor(currentSessionId, estimatedIndex);
queueViewportAnchor(currentSessionId, estimatedIndex, { scrollTop, scrollHeight, clientHeight });
}, [
currentSessionId,
getDistanceFromBottom,
@@ -498,7 +518,10 @@ export const useChatScrollManager = ({
};
}, [handleScrollEvent, handleWheelIntent, scrollEngine, setFollowMode, updatePinnedState]);
// Session switch - always start pinned at bottom
// Session switch — decide initial pinned state based on saved scroll position.
// If the user had scrolled away from bottom in this session before, start unpinned
// so that the restore logic in ChatContainer can set the position without
// being overridden by pinned-to-bottom logic.
React.useEffect(() => {
if (!currentSessionId || currentSessionId === lastSessionIdRef.current) {
return;
@@ -509,12 +532,42 @@ export const useChatScrollManager = ({
flushViewportAnchor();
pendingViewportAnchorRef.current = null;
// Always start pinned at bottom on session switch
setFollowMode(sessionIsWorking ? 'smooth' : 'none');
updatePinnedState(true);
setShowScrollButtonState(false);
}, [currentSessionId, flushViewportAnchor, sessionIsWorking, setFollowMode, setShowScrollButtonState, updatePinnedState]);
// Kill any in-flight scroll animations/follow-loops from the previous session.
// Without this, a spring animation or follow-burst from the old session
// continues driving scrollTop and triggers repin via handleScrollEvent.
scrollEngine.cancelAll();
// Mark session transition — suppresses scrollPosition saves
// so intermediate scroll events don't overwrite the saved position
// before the restore logic in ChatContainer reads it.
restoreInProgressForRef.current = currentSessionId;
// Check if this session has a saved non-bottom scroll position.
const savedMemState = useViewportStore.getState().sessionMemoryState.get(currentSessionId);
const savedScrollPos = savedMemState?.scrollPosition;
let hasNonBottomPosition = false;
if (savedScrollPos) {
const savedMaxScroll = Math.max(0, savedScrollPos.scrollHeight - savedScrollPos.clientHeight);
// Use the same pixel threshold as the pin logic for consistency.
const threshold = Math.max(24, Math.min(200, savedScrollPos.clientHeight * 0.10));
const distanceFromSavedBottom = savedMaxScroll - savedScrollPos.scrollTop;
if (savedMaxScroll > 0 && distanceFromSavedBottom > threshold) {
hasNonBottomPosition = true;
}
}
if (hasNonBottomPosition && !sessionIsWorking) {
setFollowMode('none');
updatePinnedState(false);
} else {
setFollowMode(sessionIsWorking ? 'smooth' : 'none');
updatePinnedState(true);
setShowScrollButtonState(false);
}
}, [currentSessionId, flushViewportAnchor, scrollEngine, sessionIsWorking, setFollowMode, setShowScrollButtonState, updatePinnedState]);
// Clear the restore-in-progress flag after the restore has committed.
// Maintain pin-to-bottom when content changes
React.useEffect(() => {
if (!sessionIsWorking) {
@@ -787,6 +840,12 @@ export const useChatScrollManager = ({
};
}, [currentSessionId, onActiveTurnChange, scrollRef, sessionMessageCount]);
const clearRestoreInProgress = React.useCallback((sessionId: string) => {
if (restoreInProgressForRef.current === sessionId) {
restoreInProgressForRef.current = null;
}
}, []);
return {
scrollRef,
handleMessageContentChange,
@@ -799,5 +858,6 @@ export const useChatScrollManager = ({
isPinned,
isOverflowing,
isProgrammaticFollowActive: scrollEngine.isFollowingBottom,
clearRestoreInProgress,
};
};
+3
View File
@@ -17,6 +17,7 @@ type ScrollEngineResult = {
scrollToPosition: (position: number, options?: ScrollOptions) => void;
forceManualMode: () => void;
cancelFollow: () => void;
cancelAll: () => void;
isAtTop: boolean;
isFollowingBottom: boolean;
isManualOverrideActive: () => boolean;
@@ -304,6 +305,7 @@ export const useScrollEngine = ({
scrollToPosition,
forceManualMode,
cancelFollow,
cancelAll,
isAtTop,
isFollowingBottom,
isManualOverrideActive,
@@ -316,6 +318,7 @@ export const useScrollEngine = ({
scrollToPosition,
forceManualMode,
cancelFollow,
cancelAll,
isAtTop,
isFollowingBottom,
isManualOverrideActive,