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
@@ -551,6 +551,7 @@ export const ChatContainer: React.FC = () => {
isPinned,
isOverflowing,
isProgrammaticFollowActive,
clearRestoreInProgress,
} = useChatScrollManager({
currentSessionId,
sessionMessageCount,
@@ -578,15 +579,32 @@ export const ChatContainer: React.FC = () => {
isPinned,
isOverflowing,
});
const { loadEarlier, resumeToBottomInstant } = timelineController;
const { loadEarlier, resumeToBottomInstant, restoreSavedScrollPosition } = timelineController;
const runLatestInstantResume = React.useCallback(async () => {
if (!currentSessionId) {
scrollToBottom({ instant: true, force: true });
return;
}
// Check if this session has a saved non-bottom scroll position.
const savedMemState = sessionMemoryStateMap.get(currentSessionId);
const savedPos = savedMemState?.scrollPosition;
if (savedPos && !sessionIsWorking) {
const savedMaxScroll = Math.max(0, savedPos.scrollHeight - savedPos.clientHeight);
// Use the same pixel threshold as the pin logic: 10% of clientHeight, clamped.
const threshold = Math.max(24, Math.min(200, savedPos.clientHeight * 0.10));
const distanceFromSavedBottom = savedMaxScroll - savedPos.scrollTop;
if (savedMaxScroll > 0 && distanceFromSavedBottom > threshold) {
await restoreSavedScrollPosition(savedPos);
clearRestoreInProgress(currentSessionId);
return;
}
}
await resumeToBottomInstant();
}, [currentSessionId, resumeToBottomInstant, scrollToBottom]);
clearRestoreInProgress(currentSessionId);
}, [clearRestoreInProgress, currentSessionId, restoreSavedScrollPosition, resumeToBottomInstant, scrollToBottom, sessionIsWorking, sessionMemoryStateMap]);
const resumeToLatestInstant = React.useCallback(() => {
void runLatestInstantResume();
@@ -729,6 +747,7 @@ export const ChatContainer: React.FC = () => {
const hasHashTarget = typeof window !== 'undefined' && window.location.hash.length > 0;
if (hasHashTarget) {
lastScrolledSessionRef.current = currentSessionId;
clearRestoreInProgress(currentSessionId);
return;
}
@@ -742,7 +761,7 @@ export const ChatContainer: React.FC = () => {
window.requestAnimationFrame(() => {
resumeToLatestInstant();
});
}, [currentSessionId, resumeToLatestInstant]);
}, [clearRestoreInProgress, currentSessionId, resumeToLatestInstant]);
React.useEffect(() => {
if (!currentSessionId) return;
@@ -13,6 +13,7 @@ import {
} from '../lib/turns/windowTurns';
import type { TurnHistorySignals } from '../lib/turns/historySignals';
import { getMemoryLimits, type SessionHistoryMeta } from '@/stores/types/sessionTypes';
import { useViewportStore, type SessionMemoryState } from '@/sync/viewport-store';
type ViewportAnchor = { messageId: string; offsetTop: number };
@@ -52,6 +53,7 @@ export interface UseChatTimelineControllerResult {
revealBufferedTurns: () => Promise<boolean>;
resumeToBottom: () => void;
resumeToBottomInstant: () => void;
restoreSavedScrollPosition: (savedPos: NonNullable<SessionMemoryState['scrollPosition']>) => Promise<void>;
scrollToTurn: (turnId: string, options?: { behavior?: ScrollBehavior }) => Promise<boolean>;
scrollToMessage: (messageId: string, options?: { behavior?: ScrollBehavior }) => Promise<boolean>;
captureViewportAnchor: () => ViewportAnchor | null;
@@ -490,6 +492,45 @@ export const useChatTimelineController = ({
scrollToBottom({ instant: true, force: true, followBottom: true });
}, [prepareForBottomResume, scrollToBottom, waitForNextRenderCommit]);
// Restore scroll position from a saved pixel snapshot using ratio mapping.
// Separate from resumeToBottomInstant to preserve "always go to bottom" semantics.
const restoreSavedScrollPosition = React.useCallback(async (savedPos: NonNullable<SessionMemoryState['scrollPosition']>) => {
const nextStart = getInitialTurnStart(turnModelRef.current.turnCount);
setPendingRevealWork(false);
setIsLoadingOlder(false);
const shouldWaitForRender = nextStart !== turnStartRef.current;
if (shouldWaitForRender) {
setTurnStart(nextStart);
await waitForNextRenderCommit();
}
const container = scrollRef.current;
if (!container) return;
const savedMaxScroll = Math.max(0, savedPos.scrollHeight - savedPos.clientHeight);
if (savedMaxScroll <= 0) return;
const ratio = savedPos.scrollTop / savedMaxScroll;
const currentMaxScroll = Math.max(0, container.scrollHeight - container.clientHeight);
const restoredTop = Math.round(ratio * currentMaxScroll);
container.scrollTop = restoredTop;
// Re-persist the restored position so intermediate scroll events
// during the transition don't leave stale data for the next switch.
const sid = sessionIdRef.current;
if (sid) {
const memState = useViewportStore.getState().sessionMemoryState.get(sid);
if (memState) {
useViewportStore.getState().updateViewportAnchor(sid, memState.viewportAnchor, {
scrollTop: restoredTop,
scrollHeight: container.scrollHeight,
clientHeight: container.clientHeight,
});
}
}
}, [scrollRef, waitForNextRenderCommit]);
const handleActiveTurnChange = React.useCallback((turnId: string | null) => {
setActiveTurnId(turnId);
}, []);
@@ -508,6 +549,7 @@ export const useChatTimelineController = ({
revealBufferedTurns,
resumeToBottom,
resumeToBottomInstant,
restoreSavedScrollPosition,
scrollToTurn,
scrollToMessage,
captureViewportAnchor,