diff --git a/packages/ui/src/components/auth/SessionAuthGate.tsx b/packages/ui/src/components/auth/SessionAuthGate.tsx index 779a8f5c..51b8c6d0 100644 --- a/packages/ui/src/components/auth/SessionAuthGate.tsx +++ b/packages/ui/src/components/auth/SessionAuthGate.tsx @@ -25,7 +25,6 @@ const STATUS_CHECK_ENDPOINT = '/auth/session'; const TRUST_DEVICE_STORAGE_KEY = 'openchamber.uiAuth.trustDevice'; const fetchSessionStatus = async (): Promise => { - 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 => { 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 => { - 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 = ({ 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 = ({ 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 = ({ 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 = ({ 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 = ({ 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 = ({ 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 = ({ 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 = ({ children }) => return; } - console.error('[Frontend Auth] Login failed: Unexpected response', response.status); setErrorMessage(t('sessionAuth.error.unexpectedResponse')); setIsTunnelLocked(false); setState('error'); diff --git a/packages/ui/src/components/chat/ChatContainer.tsx b/packages/ui/src/components/chat/ChatContainer.tsx index ff3401ae..d6e8cc7c 100644 --- a/packages/ui/src/components/chat/ChatContainer.tsx +++ b/packages/ui/src/components/chat/ChatContainer.tsx @@ -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; diff --git a/packages/ui/src/components/chat/hooks/useChatTimelineController.ts b/packages/ui/src/components/chat/hooks/useChatTimelineController.ts index b520b92e..2f6f3d7e 100644 --- a/packages/ui/src/components/chat/hooks/useChatTimelineController.ts +++ b/packages/ui/src/components/chat/hooks/useChatTimelineController.ts @@ -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; resumeToBottom: () => void; resumeToBottomInstant: () => void; + restoreSavedScrollPosition: (savedPos: NonNullable) => Promise; scrollToTurn: (turnId: string, options?: { behavior?: ScrollBehavior }) => Promise; scrollToMessage: (messageId: string, options?: { behavior?: ScrollBehavior }) => Promise; 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) => { + 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, diff --git a/packages/ui/src/hooks/useChatScrollManager.ts b/packages/ui/src/hooks/useChatScrollManager.ts index 14c97997..ce373b7c 100644 --- a/packages/ui/src/hooks/useChatScrollManager.ts +++ b/packages/ui/src/hooks/useChatScrollManager.ts @@ -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; - 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('none'); const autoScrollMarkerRef = React.useRef(null); const viewportAnchorTimerRef = React.useRef | 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(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(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, }; }; diff --git a/packages/ui/src/hooks/useScrollEngine.ts b/packages/ui/src/hooks/useScrollEngine.ts index f381482b..9011d47d 100644 --- a/packages/ui/src/hooks/useScrollEngine.ts +++ b/packages/ui/src/hooks/useScrollEngine.ts @@ -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, diff --git a/packages/ui/src/sync/session-ui-store.ts b/packages/ui/src/sync/session-ui-store.ts index eb258000..acd9f598 100644 --- a/packages/ui/src/sync/session-ui-store.ts +++ b/packages/ui/src/sync/session-ui-store.ts @@ -819,10 +819,11 @@ export const useSessionUIStore = create()((set, get) => ({ if (!memState || !memState.lastUserMessageAt) { const newMemState = new Map(viewportState.sessionMemoryState) newMemState.set(currentSessionId, { - viewportAnchor: memState?.viewportAnchor ?? 0, - isStreaming: memState?.isStreaming ?? false, + viewportAnchor: 0, + isStreaming: false, lastAccessedAt: Date.now(), - backgroundMessageCount: memState?.backgroundMessageCount ?? 0, + backgroundMessageCount: 0, + ...memState, lastUserMessageAt: Date.now(), }) useViewportStore.setState({ sessionMemoryState: newMemState }) diff --git a/packages/ui/src/sync/viewport-store.ts b/packages/ui/src/sync/viewport-store.ts index 2a7a03d9..cf136fe5 100644 --- a/packages/ui/src/sync/viewport-store.ts +++ b/packages/ui/src/sync/viewport-store.ts @@ -7,6 +7,12 @@ import { create } from "zustand" export type SessionMemoryState = { viewportAnchor: number + /** Last known scrollbar pixel state — saved on every scroll event. */ + scrollPosition?: { + scrollTop: number + scrollHeight: number + clientHeight: number + } isStreaming: boolean streamStartTime?: number lastAccessedAt: number @@ -27,14 +33,14 @@ export type ViewportState = { sessionMemoryState: Map isSyncing: boolean - updateViewportAnchor: (sessionId: string, anchor: number) => void + updateViewportAnchor: (sessionId: string, anchor: number, scrollPosition?: SessionMemoryState['scrollPosition']) => void } export const useViewportStore = create()((set) => ({ sessionMemoryState: new Map(), isSyncing: false, - updateViewportAnchor: (sessionId, anchor) => + updateViewportAnchor: (sessionId, anchor, scrollPosition) => set((s) => { const map = new Map(s.sessionMemoryState) const existing = map.get(sessionId) ?? { @@ -43,7 +49,12 @@ export const useViewportStore = create()((set) => ({ lastAccessedAt: Date.now(), 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 } }), }))