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
@@ -25,7 +25,6 @@ const STATUS_CHECK_ENDPOINT = '/auth/session';
const TRUST_DEVICE_STORAGE_KEY = 'openchamber.uiAuth.trustDevice'; const TRUST_DEVICE_STORAGE_KEY = 'openchamber.uiAuth.trustDevice';
const fetchSessionStatus = async (): Promise<Response> => { const fetchSessionStatus = async (): Promise<Response> => {
console.log('[Frontend Auth] Checking session status...');
const response = await fetch(STATUS_CHECK_ENDPOINT, { const response = await fetch(STATUS_CHECK_ENDPOINT, {
method: 'GET', method: 'GET',
credentials: 'include', credentials: 'include',
@@ -33,7 +32,6 @@ const fetchSessionStatus = async (): Promise<Response> => {
Accept: 'application/json', Accept: 'application/json',
}, },
}); });
console.log('[Frontend Auth] Session status response:', response.status, response.statusText);
return response; return response;
}; };
@@ -45,7 +43,6 @@ const readStoredTrustDevice = (): boolean => {
}; };
const submitPassword = async (password: string, trustDevice: boolean): Promise<Response> => { const submitPassword = async (password: string, trustDevice: boolean): Promise<Response> => {
console.log('[Frontend Auth] Submitting password...');
const response = await fetch(STATUS_CHECK_ENDPOINT, { const response = await fetch(STATUS_CHECK_ENDPOINT, {
method: 'POST', method: 'POST',
credentials: 'include', credentials: 'include',
@@ -55,7 +52,6 @@ const submitPassword = async (password: string, trustDevice: boolean): Promise<R
}, },
body: JSON.stringify({ password, trustDevice }), body: JSON.stringify({ password, trustDevice }),
}); });
console.log('[Frontend Auth] Password submit response:', response.status, response.statusText);
return response; return response;
}; };
@@ -202,7 +198,6 @@ export const SessionAuthGate: React.FC<SessionAuthGateProps> = ({ children }) =>
const checkStatus = React.useCallback(async () => { const checkStatus = React.useCallback(async () => {
if (skipAuth) { if (skipAuth) {
console.log('[Frontend Auth] VSCode runtime, skipping auth');
setState('authenticated'); setState('authenticated');
return; return;
} }
@@ -214,10 +209,8 @@ export const SessionAuthGate: React.FC<SessionAuthGateProps> = ({ children }) =>
refreshPasskeyStatus(), refreshPasskeyStatus(),
]); ]);
const responseText = await response.text(); const responseText = await response.text();
console.log('[Frontend Auth] Raw response:', response.status, responseText);
if (response.ok) { if (response.ok) {
console.log('[Frontend Auth] Session is authenticated');
setState('authenticated'); setState('authenticated');
setIsTunnelLocked(false); setIsTunnelLocked(false);
setErrorMessage(''); setErrorMessage('');
@@ -231,10 +224,6 @@ export const SessionAuthGate: React.FC<SessionAuthGateProps> = ({ children }) =>
} catch { } catch {
data = {}; 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); setIsTunnelLocked(data.tunnelLocked === true);
setPasskeyStatus(latestPasskeyStatus); setPasskeyStatus(latestPasskeyStatus);
setState('locked'); setState('locked');
@@ -253,7 +242,6 @@ export const SessionAuthGate: React.FC<SessionAuthGateProps> = ({ children }) =>
setState('rate-limited'); setState('rate-limited');
return; return;
} }
console.error('[Frontend Auth] Unexpected response status:', response.status);
setState('error'); setState('error');
setIsTunnelLocked(false); setIsTunnelLocked(false);
} catch (error) { } catch (error) {
@@ -338,7 +326,6 @@ export const SessionAuthGate: React.FC<SessionAuthGateProps> = ({ children }) =>
try { try {
const response = await submitPassword(password, trustDevice); const response = await submitPassword(password, trustDevice);
if (response.ok) { if (response.ok) {
console.log('[Frontend Auth] Login successful');
setPassword(''); setPassword('');
setIsTunnelLocked(false); setIsTunnelLocked(false);
if (enrollPasskey && supportsPasskeys) { if (enrollPasskey && supportsPasskeys) {
@@ -363,7 +350,6 @@ export const SessionAuthGate: React.FC<SessionAuthGateProps> = ({ children }) =>
} }
if (response.status === 401) { if (response.status === 401) {
console.warn('[Frontend Auth] Login failed: Invalid password');
setErrorMessage(t('sessionAuth.error.incorrectPassword')); setErrorMessage(t('sessionAuth.error.incorrectPassword'));
setIsTunnelLocked(false); setIsTunnelLocked(false);
setState('locked'); setState('locked');
@@ -371,7 +357,6 @@ export const SessionAuthGate: React.FC<SessionAuthGateProps> = ({ children }) =>
} }
if (response.status === 429) { if (response.status === 429) {
console.warn('[Frontend Auth] Login failed: Rate limited');
const data = await response.json().catch(() => ({})); const data = await response.json().catch(() => ({}));
setRetryAfter(data.retryAfter); setRetryAfter(data.retryAfter);
setIsTunnelLocked(false); setIsTunnelLocked(false);
@@ -379,7 +364,6 @@ export const SessionAuthGate: React.FC<SessionAuthGateProps> = ({ children }) =>
return; return;
} }
console.error('[Frontend Auth] Login failed: Unexpected response', response.status);
setErrorMessage(t('sessionAuth.error.unexpectedResponse')); setErrorMessage(t('sessionAuth.error.unexpectedResponse'));
setIsTunnelLocked(false); setIsTunnelLocked(false);
setState('error'); setState('error');
@@ -551,6 +551,7 @@ export const ChatContainer: React.FC = () => {
isPinned, isPinned,
isOverflowing, isOverflowing,
isProgrammaticFollowActive, isProgrammaticFollowActive,
clearRestoreInProgress,
} = useChatScrollManager({ } = useChatScrollManager({
currentSessionId, currentSessionId,
sessionMessageCount, sessionMessageCount,
@@ -578,15 +579,32 @@ export const ChatContainer: React.FC = () => {
isPinned, isPinned,
isOverflowing, isOverflowing,
}); });
const { loadEarlier, resumeToBottomInstant } = timelineController; const { loadEarlier, resumeToBottomInstant, restoreSavedScrollPosition } = timelineController;
const runLatestInstantResume = React.useCallback(async () => { const runLatestInstantResume = React.useCallback(async () => {
if (!currentSessionId) { if (!currentSessionId) {
scrollToBottom({ instant: true, force: true }); scrollToBottom({ instant: true, force: true });
return; 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(); await resumeToBottomInstant();
}, [currentSessionId, resumeToBottomInstant, scrollToBottom]); clearRestoreInProgress(currentSessionId);
}, [clearRestoreInProgress, currentSessionId, restoreSavedScrollPosition, resumeToBottomInstant, scrollToBottom, sessionIsWorking, sessionMemoryStateMap]);
const resumeToLatestInstant = React.useCallback(() => { const resumeToLatestInstant = React.useCallback(() => {
void runLatestInstantResume(); void runLatestInstantResume();
@@ -729,6 +747,7 @@ export const ChatContainer: React.FC = () => {
const hasHashTarget = typeof window !== 'undefined' && window.location.hash.length > 0; const hasHashTarget = typeof window !== 'undefined' && window.location.hash.length > 0;
if (hasHashTarget) { if (hasHashTarget) {
lastScrolledSessionRef.current = currentSessionId; lastScrolledSessionRef.current = currentSessionId;
clearRestoreInProgress(currentSessionId);
return; return;
} }
@@ -742,7 +761,7 @@ export const ChatContainer: React.FC = () => {
window.requestAnimationFrame(() => { window.requestAnimationFrame(() => {
resumeToLatestInstant(); resumeToLatestInstant();
}); });
}, [currentSessionId, resumeToLatestInstant]); }, [clearRestoreInProgress, currentSessionId, resumeToLatestInstant]);
React.useEffect(() => { React.useEffect(() => {
if (!currentSessionId) return; if (!currentSessionId) return;
@@ -13,6 +13,7 @@ import {
} from '../lib/turns/windowTurns'; } from '../lib/turns/windowTurns';
import type { TurnHistorySignals } from '../lib/turns/historySignals'; import type { TurnHistorySignals } from '../lib/turns/historySignals';
import { getMemoryLimits, type SessionHistoryMeta } from '@/stores/types/sessionTypes'; import { getMemoryLimits, type SessionHistoryMeta } from '@/stores/types/sessionTypes';
import { useViewportStore, type SessionMemoryState } from '@/sync/viewport-store';
type ViewportAnchor = { messageId: string; offsetTop: number }; type ViewportAnchor = { messageId: string; offsetTop: number };
@@ -52,6 +53,7 @@ export interface UseChatTimelineControllerResult {
revealBufferedTurns: () => Promise<boolean>; revealBufferedTurns: () => Promise<boolean>;
resumeToBottom: () => void; resumeToBottom: () => void;
resumeToBottomInstant: () => void; resumeToBottomInstant: () => void;
restoreSavedScrollPosition: (savedPos: NonNullable<SessionMemoryState['scrollPosition']>) => Promise<void>;
scrollToTurn: (turnId: string, options?: { behavior?: ScrollBehavior }) => Promise<boolean>; scrollToTurn: (turnId: string, options?: { behavior?: ScrollBehavior }) => Promise<boolean>;
scrollToMessage: (messageId: string, options?: { behavior?: ScrollBehavior }) => Promise<boolean>; scrollToMessage: (messageId: string, options?: { behavior?: ScrollBehavior }) => Promise<boolean>;
captureViewportAnchor: () => ViewportAnchor | null; captureViewportAnchor: () => ViewportAnchor | null;
@@ -490,6 +492,45 @@ export const useChatTimelineController = ({
scrollToBottom({ instant: true, force: true, followBottom: true }); scrollToBottom({ instant: true, force: true, followBottom: true });
}, [prepareForBottomResume, scrollToBottom, waitForNextRenderCommit]); }, [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) => { const handleActiveTurnChange = React.useCallback((turnId: string | null) => {
setActiveTurnId(turnId); setActiveTurnId(turnId);
}, []); }, []);
@@ -508,6 +549,7 @@ export const useChatTimelineController = ({
revealBufferedTurns, revealBufferedTurns,
resumeToBottom, resumeToBottom,
resumeToBottomInstant, resumeToBottomInstant,
restoreSavedScrollPosition,
scrollToTurn, scrollToTurn,
scrollToMessage, scrollToMessage,
captureViewportAnchor, captureViewportAnchor,
+79 -19
View File
@@ -8,11 +8,13 @@ import {
} from '@/components/chat/lib/scroll/scrollIntent'; } from '@/components/chat/lib/scroll/scrollIntent';
import { useScrollEngine } from './useScrollEngine'; import { useScrollEngine } from './useScrollEngine';
import { useViewportStore } from '@/sync/viewport-store';
export type ContentChangeReason = 'text' | 'structural' | 'permission'; export type ContentChangeReason = 'text' | 'structural' | 'permission';
interface SessionMemoryState { interface SessionMemoryState {
viewportAnchor: number; viewportAnchor: number;
scrollPosition?: { scrollTop: number; scrollHeight: number; clientHeight: number };
isStreaming: boolean; isStreaming: boolean;
lastAccessedAt: number; lastAccessedAt: number;
backgroundMessageCount: number; backgroundMessageCount: number;
@@ -28,7 +30,7 @@ interface UseChatScrollManagerOptions {
sessionPermissions: unknown[]; sessionPermissions: unknown[];
sessionIsWorking: boolean; sessionIsWorking: boolean;
sessionMemoryState: Map<string, SessionMemoryState>; 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; isSyncing: boolean;
isMobile: boolean; isMobile: boolean;
chatRenderMode?: 'sorted' | 'live'; chatRenderMode?: 'sorted' | 'live';
@@ -64,6 +66,7 @@ interface UseChatScrollManagerResult {
isPinned: boolean; isPinned: boolean;
isOverflowing: boolean; isOverflowing: boolean;
isProgrammaticFollowActive: boolean; isProgrammaticFollowActive: boolean;
clearRestoreInProgress: (sessionId: string) => void;
} }
const PROGRAMMATIC_SCROLL_SUPPRESS_MS = 200; const PROGRAMMATIC_SCROLL_SUPPRESS_MS = 200;
@@ -120,9 +123,14 @@ export const useChatScrollManager = ({
const followModeRef = React.useRef<FollowMode>('none'); const followModeRef = React.useRef<FollowMode>('none');
const autoScrollMarkerRef = React.useRef<AutoScrollMarker | null>(null); const autoScrollMarkerRef = React.useRef<AutoScrollMarker | null>(null);
const viewportAnchorTimerRef = React.useRef<ReturnType<typeof setTimeout> | null>(null); const viewportAnchorTimerRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
const pendingViewportAnchorRef = 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 } | null>(null); const lastViewportAnchorRef = React.useRef<{ sessionId: string; anchor: number; scrollTop: number } | null>(null);
const lastViewportAnchorWriteAtRef = React.useRef<number>(0); 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) => { const markAutoScroll = React.useCallback((top: number) => {
autoScrollMarkerRef.current = { autoScrollMarkerRef.current = {
@@ -292,25 +300,28 @@ export const useChatScrollManager = ({
return; return;
} }
// Skip only when both anchor AND pixel position are unchanged.
const lastPersisted = lastViewportAnchorRef.current; 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; pendingViewportAnchorRef.current = null;
return; return;
} }
updateViewportAnchor(pending.sessionId, pending.anchor); updateViewportAnchor(pending.sessionId, pending.anchor, pending.scrollPosition);
lastViewportAnchorRef.current = pending; lastViewportAnchorRef.current = { sessionId: pending.sessionId, anchor: pending.anchor, scrollTop: pending.scrollPosition?.scrollTop ?? 0 };
pendingViewportAnchorRef.current = null; pendingViewportAnchorRef.current = null;
lastViewportAnchorWriteAtRef.current = Date.now(); lastViewportAnchorWriteAtRef.current = Date.now();
}, [updateViewportAnchor]); }, [updateViewportAnchor]);
const queueViewportAnchor = React.useCallback((sessionId: string, anchor: number) => { const queueViewportAnchor = React.useCallback((sessionId: string, anchor: number, scrollPosition?: { scrollTop: number; scrollHeight: number; clientHeight: number }) => {
const lastPersisted = lastViewportAnchorRef.current; // Always update pending with latest pixel position, even if anchor
if (lastPersisted && lastPersisted.sessionId === sessionId && lastPersisted.anchor === anchor) { // hasn't changed — the user may have scrolled within the same
return; // coarse message-index bucket.
} pendingViewportAnchorRef.current = { sessionId, anchor, scrollPosition };
pendingViewportAnchorRef.current = { sessionId, anchor };
const now = Date.now(); const now = Date.now();
const elapsed = now - lastViewportAnchorWriteAtRef.current; const elapsed = now - lastViewportAnchorWriteAtRef.current;
if (elapsed >= VIEWPORT_ANCHOR_MIN_UPDATE_MS) { if (elapsed >= VIEWPORT_ANCHOR_MIN_UPDATE_MS) {
@@ -369,6 +380,15 @@ export const useChatScrollManager = ({
} }
scrollEngine.handleScroll(); 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(); schedulePinnedStateAndIndicators();
// Handle pin/unpin logic // Handle pin/unpin logic
@@ -396,7 +416,7 @@ export const useChatScrollManager = ({
const { scrollTop, scrollHeight, clientHeight } = container; const { scrollTop, scrollHeight, clientHeight } = container;
const position = (scrollTop + clientHeight / 2) / Math.max(scrollHeight, 1); const position = (scrollTop + clientHeight / 2) / Math.max(scrollHeight, 1);
const estimatedIndex = Math.floor(position * sessionMessageCount); const estimatedIndex = Math.floor(position * sessionMessageCount);
queueViewportAnchor(currentSessionId, estimatedIndex); queueViewportAnchor(currentSessionId, estimatedIndex, { scrollTop, scrollHeight, clientHeight });
}, [ }, [
currentSessionId, currentSessionId,
getDistanceFromBottom, getDistanceFromBottom,
@@ -498,7 +518,10 @@ export const useChatScrollManager = ({
}; };
}, [handleScrollEvent, handleWheelIntent, scrollEngine, setFollowMode, updatePinnedState]); }, [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(() => { React.useEffect(() => {
if (!currentSessionId || currentSessionId === lastSessionIdRef.current) { if (!currentSessionId || currentSessionId === lastSessionIdRef.current) {
return; return;
@@ -509,12 +532,42 @@ export const useChatScrollManager = ({
flushViewportAnchor(); flushViewportAnchor();
pendingViewportAnchorRef.current = null; pendingViewportAnchorRef.current = null;
// Always start pinned at bottom on session switch // Kill any in-flight scroll animations/follow-loops from the previous session.
setFollowMode(sessionIsWorking ? 'smooth' : 'none'); // Without this, a spring animation or follow-burst from the old session
updatePinnedState(true); // continues driving scrollTop and triggers repin via handleScrollEvent.
setShowScrollButtonState(false); scrollEngine.cancelAll();
}, [currentSessionId, flushViewportAnchor, sessionIsWorking, setFollowMode, setShowScrollButtonState, updatePinnedState]);
// 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 // Maintain pin-to-bottom when content changes
React.useEffect(() => { React.useEffect(() => {
if (!sessionIsWorking) { if (!sessionIsWorking) {
@@ -787,6 +840,12 @@ export const useChatScrollManager = ({
}; };
}, [currentSessionId, onActiveTurnChange, scrollRef, sessionMessageCount]); }, [currentSessionId, onActiveTurnChange, scrollRef, sessionMessageCount]);
const clearRestoreInProgress = React.useCallback((sessionId: string) => {
if (restoreInProgressForRef.current === sessionId) {
restoreInProgressForRef.current = null;
}
}, []);
return { return {
scrollRef, scrollRef,
handleMessageContentChange, handleMessageContentChange,
@@ -799,5 +858,6 @@ export const useChatScrollManager = ({
isPinned, isPinned,
isOverflowing, isOverflowing,
isProgrammaticFollowActive: scrollEngine.isFollowingBottom, isProgrammaticFollowActive: scrollEngine.isFollowingBottom,
clearRestoreInProgress,
}; };
}; };
+3
View File
@@ -17,6 +17,7 @@ type ScrollEngineResult = {
scrollToPosition: (position: number, options?: ScrollOptions) => void; scrollToPosition: (position: number, options?: ScrollOptions) => void;
forceManualMode: () => void; forceManualMode: () => void;
cancelFollow: () => void; cancelFollow: () => void;
cancelAll: () => void;
isAtTop: boolean; isAtTop: boolean;
isFollowingBottom: boolean; isFollowingBottom: boolean;
isManualOverrideActive: () => boolean; isManualOverrideActive: () => boolean;
@@ -304,6 +305,7 @@ export const useScrollEngine = ({
scrollToPosition, scrollToPosition,
forceManualMode, forceManualMode,
cancelFollow, cancelFollow,
cancelAll,
isAtTop, isAtTop,
isFollowingBottom, isFollowingBottom,
isManualOverrideActive, isManualOverrideActive,
@@ -316,6 +318,7 @@ export const useScrollEngine = ({
scrollToPosition, scrollToPosition,
forceManualMode, forceManualMode,
cancelFollow, cancelFollow,
cancelAll,
isAtTop, isAtTop,
isFollowingBottom, isFollowingBottom,
isManualOverrideActive, isManualOverrideActive,
+4 -3
View File
@@ -819,10 +819,11 @@ export const useSessionUIStore = create<SessionUIState>()((set, get) => ({
if (!memState || !memState.lastUserMessageAt) { if (!memState || !memState.lastUserMessageAt) {
const newMemState = new Map(viewportState.sessionMemoryState) const newMemState = new Map(viewportState.sessionMemoryState)
newMemState.set(currentSessionId, { newMemState.set(currentSessionId, {
viewportAnchor: memState?.viewportAnchor ?? 0, viewportAnchor: 0,
isStreaming: memState?.isStreaming ?? false, isStreaming: false,
lastAccessedAt: Date.now(), lastAccessedAt: Date.now(),
backgroundMessageCount: memState?.backgroundMessageCount ?? 0, backgroundMessageCount: 0,
...memState,
lastUserMessageAt: Date.now(), lastUserMessageAt: Date.now(),
}) })
useViewportStore.setState({ sessionMemoryState: newMemState }) useViewportStore.setState({ sessionMemoryState: newMemState })
+14 -3
View File
@@ -7,6 +7,12 @@ import { create } from "zustand"
export type SessionMemoryState = { export type SessionMemoryState = {
viewportAnchor: number viewportAnchor: number
/** Last known scrollbar pixel state — saved on every scroll event. */
scrollPosition?: {
scrollTop: number
scrollHeight: number
clientHeight: number
}
isStreaming: boolean isStreaming: boolean
streamStartTime?: number streamStartTime?: number
lastAccessedAt: number lastAccessedAt: number
@@ -27,14 +33,14 @@ export type ViewportState = {
sessionMemoryState: Map<string, SessionMemoryState> sessionMemoryState: Map<string, SessionMemoryState>
isSyncing: boolean isSyncing: boolean
updateViewportAnchor: (sessionId: string, anchor: number) => void updateViewportAnchor: (sessionId: string, anchor: number, scrollPosition?: SessionMemoryState['scrollPosition']) => void
} }
export const useViewportStore = create<ViewportState>()((set) => ({ export const useViewportStore = create<ViewportState>()((set) => ({
sessionMemoryState: new Map(), sessionMemoryState: new Map(),
isSyncing: false, isSyncing: false,
updateViewportAnchor: (sessionId, anchor) => updateViewportAnchor: (sessionId, anchor, scrollPosition) =>
set((s) => { set((s) => {
const map = new Map(s.sessionMemoryState) const map = new Map(s.sessionMemoryState)
const existing = map.get(sessionId) ?? { const existing = map.get(sessionId) ?? {
@@ -43,7 +49,12 @@ export const useViewportStore = create<ViewportState>()((set) => ({
lastAccessedAt: Date.now(), lastAccessedAt: Date.now(),
backgroundMessageCount: 0, backgroundMessageCount: 0,
} }
map.set(sessionId, { ...existing, viewportAnchor: anchor, lastAccessedAt: Date.now() }) map.set(sessionId, {
...existing,
viewportAnchor: anchor,
...(scrollPosition ? { scrollPosition } : {}),
lastAccessedAt: Date.now(),
})
return { sessionMemoryState: map } return { sessionMemoryState: map }
}), }),
})) }))