feat: add active turn anchor management for improved scroll position handling
This commit is contained in:
@@ -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 auto‑send (including attachments).
|
- 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.
|
- 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) {
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
Reference in New Issue
Block a user