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:
committed by
GitHub
co-authored by
Bohdan Triapitsyn
parent
a23f5e7545
commit
03c9065c90
@@ -25,7 +25,6 @@ const STATUS_CHECK_ENDPOINT = '/auth/session';
|
||||
const TRUST_DEVICE_STORAGE_KEY = 'openchamber.uiAuth.trustDevice';
|
||||
|
||||
const fetchSessionStatus = async (): Promise<Response> => {
|
||||
console.log('[Frontend Auth] Checking session status...');
|
||||
const response = await fetch(STATUS_CHECK_ENDPOINT, {
|
||||
method: 'GET',
|
||||
credentials: 'include',
|
||||
@@ -33,7 +32,6 @@ const fetchSessionStatus = async (): Promise<Response> => {
|
||||
Accept: 'application/json',
|
||||
},
|
||||
});
|
||||
console.log('[Frontend Auth] Session status response:', response.status, response.statusText);
|
||||
return response;
|
||||
};
|
||||
|
||||
@@ -45,7 +43,6 @@ const readStoredTrustDevice = (): boolean => {
|
||||
};
|
||||
|
||||
const submitPassword = async (password: string, trustDevice: boolean): Promise<Response> => {
|
||||
console.log('[Frontend Auth] Submitting password...');
|
||||
const response = await fetch(STATUS_CHECK_ENDPOINT, {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
@@ -55,7 +52,6 @@ const submitPassword = async (password: string, trustDevice: boolean): Promise<R
|
||||
},
|
||||
body: JSON.stringify({ password, trustDevice }),
|
||||
});
|
||||
console.log('[Frontend Auth] Password submit response:', response.status, response.statusText);
|
||||
return response;
|
||||
};
|
||||
|
||||
@@ -202,7 +198,6 @@ export const SessionAuthGate: React.FC<SessionAuthGateProps> = ({ children }) =>
|
||||
|
||||
const checkStatus = React.useCallback(async () => {
|
||||
if (skipAuth) {
|
||||
console.log('[Frontend Auth] VSCode runtime, skipping auth');
|
||||
setState('authenticated');
|
||||
return;
|
||||
}
|
||||
@@ -214,10 +209,8 @@ export const SessionAuthGate: React.FC<SessionAuthGateProps> = ({ children }) =>
|
||||
refreshPasskeyStatus(),
|
||||
]);
|
||||
const responseText = await response.text();
|
||||
console.log('[Frontend Auth] Raw response:', response.status, responseText);
|
||||
|
||||
if (response.ok) {
|
||||
console.log('[Frontend Auth] Session is authenticated');
|
||||
setState('authenticated');
|
||||
setIsTunnelLocked(false);
|
||||
setErrorMessage('');
|
||||
@@ -231,10 +224,6 @@ export const SessionAuthGate: React.FC<SessionAuthGateProps> = ({ children }) =>
|
||||
} catch {
|
||||
data = {};
|
||||
}
|
||||
console.warn('[Frontend Auth] Session is locked (401)', data);
|
||||
if (data.debug) {
|
||||
console.warn('[Frontend Auth] Debug info:', data.debug);
|
||||
}
|
||||
setIsTunnelLocked(data.tunnelLocked === true);
|
||||
setPasskeyStatus(latestPasskeyStatus);
|
||||
setState('locked');
|
||||
@@ -253,7 +242,6 @@ export const SessionAuthGate: React.FC<SessionAuthGateProps> = ({ children }) =>
|
||||
setState('rate-limited');
|
||||
return;
|
||||
}
|
||||
console.error('[Frontend Auth] Unexpected response status:', response.status);
|
||||
setState('error');
|
||||
setIsTunnelLocked(false);
|
||||
} catch (error) {
|
||||
@@ -338,7 +326,6 @@ export const SessionAuthGate: React.FC<SessionAuthGateProps> = ({ children }) =>
|
||||
try {
|
||||
const response = await submitPassword(password, trustDevice);
|
||||
if (response.ok) {
|
||||
console.log('[Frontend Auth] Login successful');
|
||||
setPassword('');
|
||||
setIsTunnelLocked(false);
|
||||
if (enrollPasskey && supportsPasskeys) {
|
||||
@@ -363,7 +350,6 @@ export const SessionAuthGate: React.FC<SessionAuthGateProps> = ({ children }) =>
|
||||
}
|
||||
|
||||
if (response.status === 401) {
|
||||
console.warn('[Frontend Auth] Login failed: Invalid password');
|
||||
setErrorMessage(t('sessionAuth.error.incorrectPassword'));
|
||||
setIsTunnelLocked(false);
|
||||
setState('locked');
|
||||
@@ -371,7 +357,6 @@ export const SessionAuthGate: React.FC<SessionAuthGateProps> = ({ children }) =>
|
||||
}
|
||||
|
||||
if (response.status === 429) {
|
||||
console.warn('[Frontend Auth] Login failed: Rate limited');
|
||||
const data = await response.json().catch(() => ({}));
|
||||
setRetryAfter(data.retryAfter);
|
||||
setIsTunnelLocked(false);
|
||||
@@ -379,7 +364,6 @@ export const SessionAuthGate: React.FC<SessionAuthGateProps> = ({ children }) =>
|
||||
return;
|
||||
}
|
||||
|
||||
console.error('[Frontend Auth] Login failed: Unexpected response', response.status);
|
||||
setErrorMessage(t('sessionAuth.error.unexpectedResponse'));
|
||||
setIsTunnelLocked(false);
|
||||
setState('error');
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user