feat: add active turn anchor management for improved scroll position handling

This commit is contained in:
Bohdan Triapitsyn
2025-12-29 12:12:52 +02:00
parent 66428cbded
commit 9b4283d618
7 changed files with 127 additions and 16 deletions
+2
View File
@@ -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. - Improved dev server HMR by reusing a healthy OpenCode process to avoid zombie instances.
- Added queued message mode with chips, batching, and idle autosend (including attachments). - Added queued message mode with chips, batching, and idle autosend (including attachments).
- Added queue mode toggle to OpenChamber settings (chat section) with persistence across runtimes. - 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 ## [1.3.7] - 2025-12-28
@@ -22,6 +22,8 @@ export const ChatContainer: React.FC = () => {
loadMessages, loadMessages,
loadMoreMessages, loadMoreMessages,
updateViewportAnchor, updateViewportAnchor,
updateActiveTurnAnchor,
getActiveTurnAnchor,
sessionMemoryState, sessionMemoryState,
openNewSessionDraft, openNewSessionDraft,
isSyncing, isSyncing,
@@ -69,6 +71,8 @@ export const ChatContainer: React.FC = () => {
streamingMessageId, streamingMessageId,
sessionMemoryState, sessionMemoryState,
updateViewportAnchor, updateViewportAnchor,
updateActiveTurnAnchor,
getActiveTurnAnchor,
isSyncing, isSyncing,
isMobile, isMobile,
messageStreamStates, messageStreamStates,
@@ -96,11 +100,14 @@ export const ChatContainer: React.FC = () => {
} }
lastScrolledSessionRef.current = currentSessionId; lastScrolledSessionRef.current = currentSessionId;
const container = scrollRef.current; // Only scroll to bottom if there's no active anchor (anchor handles its own scroll)
if (container) { if (!hasActiveAnchor) {
container.scrollTop = container.scrollHeight - container.clientHeight; const container = scrollRef.current;
if (container) {
container.scrollTop = container.scrollHeight - container.clientHeight;
}
} }
}, [currentSessionId, scrollRef]); }, [currentSessionId, scrollRef, hasActiveAnchor]);
const handleLoadOlder = React.useCallback(async () => { const handleLoadOlder = React.useCallback(async () => {
if (!currentSessionId || isLoadingOlder) { if (!currentSessionId || isLoadingOlder) {
@@ -495,7 +495,7 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
// Normal mode: Enter sends, Ctrl+Enter queues // Normal mode: Enter sends, Ctrl+Enter queues
// Note: Queueing only works when there's an existing session (currentSessionId) // Note: Queueing only works when there's an existing session (currentSessionId)
// For new sessions (draft), always send immediately // For new sessions (draft), always send immediately
const canQueue = hasContent && currentSessionId; const canQueue = hasContent && currentSessionId && sessionPhase !== 'idle';
if (queueModeEnabled) { if (queueModeEnabled) {
if (isCtrlEnter || !canQueue) { if (isCtrlEnter || !canQueue) {
+74 -11
View File
@@ -34,6 +34,8 @@ interface UseChatScrollManagerOptions {
streamingMessageId: string | null; streamingMessageId: string | null;
sessionMemoryState: Map<string, SessionMemoryState>; sessionMemoryState: Map<string, SessionMemoryState>;
updateViewportAnchor: (sessionId: string, anchor: number) => 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;
isSyncing: boolean; isSyncing: boolean;
isMobile: boolean; isMobile: boolean;
messageStreamStates: Map<string, unknown>; messageStreamStates: Map<string, unknown>;
@@ -91,6 +93,8 @@ export const useChatScrollManager = ({
currentSessionId, currentSessionId,
sessionMessages, sessionMessages,
updateViewportAnchor, updateViewportAnchor,
updateActiveTurnAnchor,
getActiveTurnAnchor,
isSyncing, isSyncing,
isMobile, isMobile,
sessionActivityPhase, sessionActivityPhase,
@@ -117,6 +121,7 @@ export const useChatScrollManager = ({
const hasAnchoredOnceRef = React.useRef<boolean>(false); const hasAnchoredOnceRef = React.useRef<boolean>(false);
const userScrollOverrideRef = React.useRef<boolean>(false); const userScrollOverrideRef = React.useRef<boolean>(false);
const previousPhaseRef = React.useRef<SessionActivityPhase | null>(null);
const currentPhase = currentSessionId const currentPhase = currentSessionId
? sessionActivityPhase?.get(currentSessionId) ?? 'idle' ? sessionActivityPhase?.get(currentSessionId) ?? 'idle'
@@ -201,10 +206,14 @@ export const useChatScrollManager = ({
if (needed > currentSpacerHeight) { if (needed > currentSpacerHeight) {
updateSpacerHeight(needed); 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 updateScrollButtonVisibility = React.useCallback(() => {
const container = scrollRef.current; const container = scrollRef.current;
@@ -300,6 +309,11 @@ export const useChatScrollManager = ({
updateSpacerHeight(newSpacerHeight); updateSpacerHeight(newSpacerHeight);
} }
// Persist anchor state to store
if (currentSessionId) {
updateActiveTurnAnchor(currentSessionId, messageId, newSpacerHeight);
}
hasAnchoredOnceRef.current = true; hasAnchoredOnceRef.current = true;
window.requestAnimationFrame(() => { window.requestAnimationFrame(() => {
@@ -310,7 +324,7 @@ export const useChatScrollManager = ({
setPendingAnchorId(null); setPendingAnchorId(null);
}); });
}); });
}, [scrollEngine, updateSpacerHeight]); }, [currentSessionId, scrollEngine, updateActiveTurnAnchor, updateSpacerHeight]);
const handleScrollEvent = React.useCallback((event?: Event) => { const handleScrollEvent = React.useCallback((event?: Event) => {
const container = scrollRef.current; const container = scrollRef.current;
@@ -329,6 +343,8 @@ export const useChatScrollManager = ({
updateSpacerHeight(0); updateSpacerHeight(0);
anchorIdRef.current = null; anchorIdRef.current = null;
setAnchorId(null); setAnchorId(null);
// Clear in store when user scrolls away from spacer
updateActiveTurnAnchor(currentSessionId, null, 0);
} }
const { scrollTop, scrollHeight, clientHeight } = container; const { scrollTop, scrollHeight, clientHeight } = container;
@@ -341,6 +357,7 @@ export const useChatScrollManager = ({
isSpacerOutOfViewport, isSpacerOutOfViewport,
scrollEngine, scrollEngine,
sessionMessages.length, sessionMessages.length,
updateActiveTurnAnchor,
updateScrollButtonVisibility, updateScrollButtonVisibility,
updateSpacerHeight, updateSpacerHeight,
updateViewportAnchor, updateViewportAnchor,
@@ -390,22 +407,53 @@ export const useChatScrollManager = ({
lastMessageCountRef.current = sessionMessages.length; lastMessageCountRef.current = sessionMessages.length;
lastFirstMessageIdRef.current = sessionMessages.length > 0 ? getMessageId(sessionMessages[0]) : null; lastFirstMessageIdRef.current = sessionMessages.length > 0 ? getMessageId(sessionMessages[0]) : null;
lastLastMessageIdRef.current = sessionMessages.length > 0 ? getMessageId(sessionMessages[sessionMessages.length - 1]) : null; lastLastMessageIdRef.current = sessionMessages.length > 0 ? getMessageId(sessionMessages[sessionMessages.length - 1]) : null;
lastScrolledAnchorIdRef.current = null;
anchorIdRef.current = null; // Restore persisted anchor state from store
hasAnchoredOnceRef.current = false; const persistedAnchor = getActiveTurnAnchor(currentSessionId);
setAnchorId(null); 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; // Scroll to anchor position after DOM updates
setSpacerHeight(0); 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); setPendingAnchorId(null);
setShowScrollButton(false); setShowScrollButton(false);
userScrollOverrideRef.current = 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 // 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(() => { useIsomorphicLayoutEffect(() => {
@@ -507,14 +555,29 @@ export const useChatScrollManager = ({
}, [anchorId, refreshSpacer, updateScrollButtonVisibility]); }, [anchorId, refreshSpacer, updateScrollButtonVisibility]);
React.useEffect(() => { 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); updateSpacerHeight(0);
anchorIdRef.current = null; anchorIdRef.current = null;
hasAnchoredOnceRef.current = false; hasAnchoredOnceRef.current = false;
setAnchorId(null); setAnchorId(null);
// Clear in store as well
if (currentSessionId) {
updateActiveTurnAnchor(currentSessionId, null, 0);
}
} }
}, [currentPhase, isSpacerOutOfViewport, updateSpacerHeight]); }, [currentPhase, currentSessionId, isSpacerOutOfViewport, updateActiveTurnAnchor, updateSpacerHeight]);
React.useEffect(() => { React.useEffect(() => {
updateScrollButtonVisibility(); updateScrollButtonVisibility();
+31
View File
@@ -361,6 +361,8 @@ interface MessageActions {
updateMessageInfo: (sessionId: string, messageId: string, messageInfo: any) => void; updateMessageInfo: (sessionId: string, messageId: string, messageInfo: any) => void;
syncMessages: (sessionId: string, messages: { info: Message; parts: Part[] }[]) => void; syncMessages: (sessionId: string, messages: { info: Message; parts: Part[] }[]) => void;
updateViewportAnchor: (sessionId: string, anchor: number) => 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; trimToViewportWindow: (sessionId: string, targetSize?: number, currentSessionId?: string) => void;
evictLeastRecentlyUsed: (currentSessionId?: string) => void; evictLeastRecentlyUsed: (currentSessionId?: string) => void;
loadMoreMessages: (sessionId: string, direction: "up" | "down") => Promise<void>; loadMoreMessages: (sessionId: string, direction: "up" | "down") => Promise<void>;
@@ -479,6 +481,7 @@ export const useMessageStore = create<MessageStore>()(
const newMemoryState = new Map(state.sessionMemoryState); const newMemoryState = new Map(state.sessionMemoryState);
const previousMemoryState = state.sessionMemoryState.get(sessionId); const previousMemoryState = state.sessionMemoryState.get(sessionId);
newMemoryState.set(sessionId, { newMemoryState.set(sessionId, {
...previousMemoryState,
viewportAnchor: mergedMessages.length - 1, viewportAnchor: mergedMessages.length - 1,
isStreaming: false, isStreaming: false,
lastAccessedAt: Date.now(), lastAccessedAt: Date.now(),
@@ -2202,6 +2205,34 @@ export const useMessageStore = create<MessageStore>()(
}); });
}, },
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) => { trimToViewportWindow: (sessionId: string, targetSize: number = MEMORY_LIMITS.VIEWPORT_MESSAGES, currentSessionId?: string) => {
const state = get(); const state = get();
const sessionMessages = state.messages.get(sessionId); const sessionMessages = state.messages.get(sessionId);
@@ -34,6 +34,10 @@ export interface SessionMemoryState {
hasMoreAbove?: boolean; hasMoreAbove?: boolean;
trimmedHeadMaxId?: string; trimmedHeadMaxId?: string;
streamingCooldownUntil?: number; 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 { export interface SessionContextUsage {
@@ -151,6 +155,8 @@ export interface SessionStore {
clearAttachedFiles: () => void; clearAttachedFiles: () => void;
updateViewportAnchor: (sessionId: string, anchor: number) => 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; trimToViewportWindow: (sessionId: string, targetSize?: number) => void;
evictLeastRecentlyUsed: () => void; evictLeastRecentlyUsed: () => void;
loadMoreMessages: (sessionId: string, direction: "up" | "down") => Promise<void>; loadMoreMessages: (sessionId: string, direction: "up" | "down") => Promise<void>;
@@ -442,6 +442,8 @@ export const useSessionStore = create<SessionStore>()(
clearAttachedFiles: () => useFileStore.getState().clearAttachedFiles(), clearAttachedFiles: () => useFileStore.getState().clearAttachedFiles(),
updateViewportAnchor: (sessionId: string, anchor: number) => useMessageStore.getState().updateViewportAnchor(sessionId, anchor), 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) => { trimToViewportWindow: (sessionId: string, targetSize?: number) => {
const currentSessionId = useSessionManagementStore.getState().currentSessionId; const currentSessionId = useSessionManagementStore.getState().currentSessionId;
return useMessageStore.getState().trimToViewportWindow(sessionId, targetSize, currentSessionId || undefined); return useMessageStore.getState().trimToViewportWindow(sessionId, targetSize, currentSessionId || undefined);