diff --git a/CHANGELOG.md b/CHANGELOG.md index 8c4422df..81e0d40d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,8 @@ All notable changes to this project will be documented in this file. - Improved dev server HMR by reusing a healthy OpenCode process to avoid zombie instances. - Added queued message mode with chips, batching, and idle auto‑send (including attachments). - Added queue mode toggle to OpenChamber settings (chat section) with persistence across runtimes. +- Fixed scroll position persistence for active conversation turns across session switches. + ## [1.3.7] - 2025-12-28 diff --git a/packages/ui/src/components/chat/ChatContainer.tsx b/packages/ui/src/components/chat/ChatContainer.tsx index 5dad0ef1..ad04a264 100644 --- a/packages/ui/src/components/chat/ChatContainer.tsx +++ b/packages/ui/src/components/chat/ChatContainer.tsx @@ -22,6 +22,8 @@ export const ChatContainer: React.FC = () => { loadMessages, loadMoreMessages, updateViewportAnchor, + updateActiveTurnAnchor, + getActiveTurnAnchor, sessionMemoryState, openNewSessionDraft, isSyncing, @@ -69,6 +71,8 @@ export const ChatContainer: React.FC = () => { streamingMessageId, sessionMemoryState, updateViewportAnchor, + updateActiveTurnAnchor, + getActiveTurnAnchor, isSyncing, isMobile, messageStreamStates, @@ -96,11 +100,14 @@ export const ChatContainer: React.FC = () => { } lastScrolledSessionRef.current = currentSessionId; - const container = scrollRef.current; - if (container) { - container.scrollTop = container.scrollHeight - container.clientHeight; + // Only scroll to bottom if there's no active anchor (anchor handles its own scroll) + if (!hasActiveAnchor) { + const container = scrollRef.current; + if (container) { + container.scrollTop = container.scrollHeight - container.clientHeight; + } } - }, [currentSessionId, scrollRef]); + }, [currentSessionId, scrollRef, hasActiveAnchor]); const handleLoadOlder = React.useCallback(async () => { if (!currentSessionId || isLoadingOlder) { diff --git a/packages/ui/src/components/chat/ChatInput.tsx b/packages/ui/src/components/chat/ChatInput.tsx index 14601330..4bb10975 100644 --- a/packages/ui/src/components/chat/ChatInput.tsx +++ b/packages/ui/src/components/chat/ChatInput.tsx @@ -495,7 +495,7 @@ export const ChatInput: React.FC = ({ onOpenSettings, scrollToBo // Normal mode: Enter sends, Ctrl+Enter queues // Note: Queueing only works when there's an existing session (currentSessionId) // For new sessions (draft), always send immediately - const canQueue = hasContent && currentSessionId; + const canQueue = hasContent && currentSessionId && sessionPhase !== 'idle'; if (queueModeEnabled) { if (isCtrlEnter || !canQueue) { diff --git a/packages/ui/src/hooks/useChatScrollManager.ts b/packages/ui/src/hooks/useChatScrollManager.ts index 64d81813..e10a88e3 100644 --- a/packages/ui/src/hooks/useChatScrollManager.ts +++ b/packages/ui/src/hooks/useChatScrollManager.ts @@ -34,6 +34,8 @@ interface UseChatScrollManagerOptions { streamingMessageId: string | null; sessionMemoryState: Map; updateViewportAnchor: (sessionId: string, anchor: number) => void; + updateActiveTurnAnchor: (sessionId: string, anchorId: string | null, spacerHeight: number) => void; + getActiveTurnAnchor: (sessionId: string) => { anchorId: string | null; spacerHeight: number } | null; isSyncing: boolean; isMobile: boolean; messageStreamStates: Map; @@ -91,6 +93,8 @@ export const useChatScrollManager = ({ currentSessionId, sessionMessages, updateViewportAnchor, + updateActiveTurnAnchor, + getActiveTurnAnchor, isSyncing, isMobile, sessionActivityPhase, @@ -117,6 +121,7 @@ export const useChatScrollManager = ({ const hasAnchoredOnceRef = React.useRef(false); const userScrollOverrideRef = React.useRef(false); + const previousPhaseRef = React.useRef(null); const currentPhase = currentSessionId ? sessionActivityPhase?.get(currentSessionId) ?? 'idle' @@ -201,10 +206,14 @@ export const useChatScrollManager = ({ if (needed > currentSpacerHeight) { updateSpacerHeight(needed); + // Persist updated spacer height to store + if (currentSessionId && anchorIdRef.current) { + updateActiveTurnAnchor(currentSessionId, anchorIdRef.current, needed); + } } } - }, [calculateAnchorPosition, getAnchorElement, updateSpacerHeight]); + }, [calculateAnchorPosition, currentSessionId, getAnchorElement, updateActiveTurnAnchor, updateSpacerHeight]); const updateScrollButtonVisibility = React.useCallback(() => { const container = scrollRef.current; @@ -300,6 +309,11 @@ export const useChatScrollManager = ({ updateSpacerHeight(newSpacerHeight); } + // Persist anchor state to store + if (currentSessionId) { + updateActiveTurnAnchor(currentSessionId, messageId, newSpacerHeight); + } + hasAnchoredOnceRef.current = true; window.requestAnimationFrame(() => { @@ -310,7 +324,7 @@ export const useChatScrollManager = ({ setPendingAnchorId(null); }); }); - }, [scrollEngine, updateSpacerHeight]); + }, [currentSessionId, scrollEngine, updateActiveTurnAnchor, updateSpacerHeight]); const handleScrollEvent = React.useCallback((event?: Event) => { const container = scrollRef.current; @@ -329,6 +343,8 @@ export const useChatScrollManager = ({ updateSpacerHeight(0); anchorIdRef.current = null; setAnchorId(null); + // Clear in store when user scrolls away from spacer + updateActiveTurnAnchor(currentSessionId, null, 0); } const { scrollTop, scrollHeight, clientHeight } = container; @@ -341,6 +357,7 @@ export const useChatScrollManager = ({ isSpacerOutOfViewport, scrollEngine, sessionMessages.length, + updateActiveTurnAnchor, updateScrollButtonVisibility, updateSpacerHeight, updateViewportAnchor, @@ -390,22 +407,53 @@ export const useChatScrollManager = ({ lastMessageCountRef.current = sessionMessages.length; lastFirstMessageIdRef.current = sessionMessages.length > 0 ? getMessageId(sessionMessages[0]) : null; lastLastMessageIdRef.current = sessionMessages.length > 0 ? getMessageId(sessionMessages[sessionMessages.length - 1]) : null; - lastScrolledAnchorIdRef.current = null; - anchorIdRef.current = null; - hasAnchoredOnceRef.current = false; - setAnchorId(null); + // Restore persisted anchor state from store + const persistedAnchor = getActiveTurnAnchor(currentSessionId); + const shouldRestoreAnchor = currentPhase !== 'idle'; + if (shouldRestoreAnchor && persistedAnchor && persistedAnchor.anchorId) { + anchorIdRef.current = persistedAnchor.anchorId; + lastScrolledAnchorIdRef.current = persistedAnchor.anchorId; + hasAnchoredOnceRef.current = true; + setAnchorId(persistedAnchor.anchorId); + spacerHeightRef.current = persistedAnchor.spacerHeight; + setSpacerHeight(persistedAnchor.spacerHeight); - spacerHeightRef.current = 0; - setSpacerHeight(0); + // Scroll to anchor position after DOM updates + window.requestAnimationFrame(() => { + const container = scrollRef.current; + if (!container) return; + + const anchorElement = container.querySelector(`[data-message-id="${persistedAnchor.anchorId}"]`) as HTMLElement | null; + if (anchorElement) { + const containerHeight = container.clientHeight; + const targetScrollTop = calculateAnchorPosition(anchorElement, containerHeight); + scrollEngine.scrollToPosition(targetScrollTop, { instant: true }); + } + }); + } else { + lastScrolledAnchorIdRef.current = null; + anchorIdRef.current = null; + hasAnchoredOnceRef.current = false; + setAnchorId(null); + spacerHeightRef.current = 0; + setSpacerHeight(0); + + // Ensure idle/non-anchored sessions snap to bottom on switch + window.requestAnimationFrame(() => { + scrollToBottom({ instant: true, force: true }); + }); + } setPendingAnchorId(null); setShowScrollButton(false); userScrollOverrideRef.current = false; + // Reset phase tracking to prevent false "transition to idle" detection + previousPhaseRef.current = null; } // eslint-disable-next-line react-hooks/exhaustive-deps -- only run on session change, not message changes - }, [currentSessionId, sessionMessages.length]); + }, [currentPhase, currentSessionId, scrollToBottom, sessionMessages.length, getActiveTurnAnchor]); useIsomorphicLayoutEffect(() => { @@ -507,14 +555,29 @@ export const useChatScrollManager = ({ }, [anchorId, refreshSpacer, updateScrollButtonVisibility]); React.useEffect(() => { + const prevPhase = previousPhaseRef.current; + previousPhaseRef.current = currentPhase; - if (currentPhase === 'idle' && spacerHeightRef.current > 0 && isSpacerOutOfViewport()) { + // Only clear anchor when session TRANSITIONS to idle (from busy/cooldown) + // Not when we switch to an already-idle session + const transitionedToIdle = prevPhase !== null && prevPhase !== 'idle' && currentPhase === 'idle'; + + if ( + transitionedToIdle && + spacerHeightRef.current > 0 && + isSpacerOutOfViewport() && + anchorIdRef.current !== null + ) { updateSpacerHeight(0); anchorIdRef.current = null; hasAnchoredOnceRef.current = false; setAnchorId(null); + // Clear in store as well + if (currentSessionId) { + updateActiveTurnAnchor(currentSessionId, null, 0); + } } - }, [currentPhase, isSpacerOutOfViewport, updateSpacerHeight]); + }, [currentPhase, currentSessionId, isSpacerOutOfViewport, updateActiveTurnAnchor, updateSpacerHeight]); React.useEffect(() => { updateScrollButtonVisibility(); diff --git a/packages/ui/src/stores/messageStore.ts b/packages/ui/src/stores/messageStore.ts index a63a64c5..0e63a5af 100644 --- a/packages/ui/src/stores/messageStore.ts +++ b/packages/ui/src/stores/messageStore.ts @@ -361,6 +361,8 @@ interface MessageActions { updateMessageInfo: (sessionId: string, messageId: string, messageInfo: any) => void; syncMessages: (sessionId: string, messages: { info: Message; parts: Part[] }[]) => void; updateViewportAnchor: (sessionId: string, anchor: number) => void; + updateActiveTurnAnchor: (sessionId: string, anchorId: string | null, spacerHeight: number) => void; + getActiveTurnAnchor: (sessionId: string) => { anchorId: string | null; spacerHeight: number } | null; trimToViewportWindow: (sessionId: string, targetSize?: number, currentSessionId?: string) => void; evictLeastRecentlyUsed: (currentSessionId?: string) => void; loadMoreMessages: (sessionId: string, direction: "up" | "down") => Promise; @@ -479,6 +481,7 @@ export const useMessageStore = create()( const newMemoryState = new Map(state.sessionMemoryState); const previousMemoryState = state.sessionMemoryState.get(sessionId); newMemoryState.set(sessionId, { + ...previousMemoryState, viewportAnchor: mergedMessages.length - 1, isStreaming: false, lastAccessedAt: Date.now(), @@ -2202,6 +2205,34 @@ export const useMessageStore = create()( }); }, + updateActiveTurnAnchor: (sessionId: string, anchorId: string | null, spacerHeight: number) => { + set((state) => { + const memoryState = state.sessionMemoryState.get(sessionId) || { + viewportAnchor: 0, + isStreaming: false, + lastAccessedAt: Date.now(), + backgroundMessageCount: 0, + }; + + const newMemoryState = new Map(state.sessionMemoryState); + newMemoryState.set(sessionId, { + ...memoryState, + activeTurnAnchorId: anchorId ?? undefined, + activeTurnSpacerHeight: spacerHeight, + }); + return { sessionMemoryState: newMemoryState }; + }); + }, + + getActiveTurnAnchor: (sessionId: string) => { + const memoryState = get().sessionMemoryState.get(sessionId); + if (!memoryState) return null; + return { + anchorId: memoryState.activeTurnAnchorId ?? null, + spacerHeight: memoryState.activeTurnSpacerHeight ?? 0, + }; + }, + trimToViewportWindow: (sessionId: string, targetSize: number = MEMORY_LIMITS.VIEWPORT_MESSAGES, currentSessionId?: string) => { const state = get(); const sessionMessages = state.messages.get(sessionId); diff --git a/packages/ui/src/stores/types/sessionTypes.ts b/packages/ui/src/stores/types/sessionTypes.ts index 4cdcbc4f..4059aef3 100644 --- a/packages/ui/src/stores/types/sessionTypes.ts +++ b/packages/ui/src/stores/types/sessionTypes.ts @@ -34,6 +34,10 @@ export interface SessionMemoryState { hasMoreAbove?: boolean; trimmedHeadMaxId?: string; streamingCooldownUntil?: number; + /** Message ID of the user's active turn anchor (for scroll position preservation) */ + activeTurnAnchorId?: string; + /** Height of the spacer below messages for active turn positioning */ + activeTurnSpacerHeight?: number; } export interface SessionContextUsage { @@ -151,6 +155,8 @@ export interface SessionStore { clearAttachedFiles: () => void; updateViewportAnchor: (sessionId: string, anchor: number) => void; + updateActiveTurnAnchor: (sessionId: string, anchorId: string | null, spacerHeight: number) => void; + getActiveTurnAnchor: (sessionId: string) => { anchorId: string | null; spacerHeight: number } | null; trimToViewportWindow: (sessionId: string, targetSize?: number) => void; evictLeastRecentlyUsed: () => void; loadMoreMessages: (sessionId: string, direction: "up" | "down") => Promise; diff --git a/packages/ui/src/stores/useSessionStore.ts b/packages/ui/src/stores/useSessionStore.ts index fc8d831b..2ea3fd11 100644 --- a/packages/ui/src/stores/useSessionStore.ts +++ b/packages/ui/src/stores/useSessionStore.ts @@ -442,6 +442,8 @@ export const useSessionStore = create()( clearAttachedFiles: () => useFileStore.getState().clearAttachedFiles(), updateViewportAnchor: (sessionId: string, anchor: number) => useMessageStore.getState().updateViewportAnchor(sessionId, anchor), + updateActiveTurnAnchor: (sessionId: string, anchorId: string | null, spacerHeight: number) => useMessageStore.getState().updateActiveTurnAnchor(sessionId, anchorId, spacerHeight), + getActiveTurnAnchor: (sessionId: string) => useMessageStore.getState().getActiveTurnAnchor(sessionId), trimToViewportWindow: (sessionId: string, targetSize?: number) => { const currentSessionId = useSessionManagementStore.getState().currentSessionId; return useMessageStore.getState().trimToViewportWindow(sessionId, targetSize, currentSessionId || undefined);