refactor(chat): enhance scroll management (#174)
- Add bottom detection with epsilon tolerance for precise positioning - Introduce pending anchor restoration for seamless session switching Include active turn anchor and spacer height in message store
This commit is contained in:
committed by
GitHub
parent
24969ba856
commit
85f41c39c1
@@ -109,22 +109,6 @@ export const ChatContainer: React.FC = () => {
|
|||||||
setIsLoadingOlder(false);
|
setIsLoadingOlder(false);
|
||||||
}, [currentSessionId]);
|
}, [currentSessionId]);
|
||||||
|
|
||||||
const lastScrolledSessionRef = React.useRef<string | null>(null);
|
|
||||||
React.useLayoutEffect(() => {
|
|
||||||
if (!currentSessionId || currentSessionId === lastScrolledSessionRef.current) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
lastScrolledSessionRef.current = currentSessionId;
|
|
||||||
|
|
||||||
// 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, hasActiveAnchor]);
|
|
||||||
|
|
||||||
const handleLoadOlder = React.useCallback(async () => {
|
const handleLoadOlder = React.useCallback(async () => {
|
||||||
if (!currentSessionId || isLoadingOlder) {
|
if (!currentSessionId || isLoadingOlder) {
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -70,6 +70,7 @@ const LONG_MESSAGE_THRESHOLD = 0.20;
|
|||||||
const LONG_MESSAGE_VISIBLE_PORTION = 0.10;
|
const LONG_MESSAGE_VISIBLE_PORTION = 0.10;
|
||||||
|
|
||||||
const VIEWPORT_RESIZE_DEBOUNCE_MS = 150;
|
const VIEWPORT_RESIZE_DEBOUNCE_MS = 150;
|
||||||
|
const BOTTOM_EPSILON_PX = 8;
|
||||||
|
|
||||||
const getMessageId = (message: ChatMessageRecord): string | null => {
|
const getMessageId = (message: ChatMessageRecord): string | null => {
|
||||||
const info = message.info;
|
const info = message.info;
|
||||||
@@ -109,6 +110,7 @@ export const useChatScrollManager = ({
|
|||||||
|
|
||||||
const lastScrolledAnchorIdRef = React.useRef<string | null>(null);
|
const lastScrolledAnchorIdRef = React.useRef<string | null>(null);
|
||||||
const lastSessionIdRef = React.useRef<string | null>(null);
|
const lastSessionIdRef = React.useRef<string | null>(null);
|
||||||
|
const currentSessionIdRef = React.useRef<string | null>(currentSessionId ?? null);
|
||||||
const lastMessageCountRef = React.useRef<number>(sessionMessages.length);
|
const lastMessageCountRef = React.useRef<number>(sessionMessages.length);
|
||||||
const lastFirstMessageIdRef = React.useRef<string | null>(sessionMessages.length > 0 ? getMessageId(sessionMessages[0]) : null);
|
const lastFirstMessageIdRef = React.useRef<string | null>(sessionMessages.length > 0 ? getMessageId(sessionMessages[0]) : null);
|
||||||
const lastLastMessageIdRef = React.useRef<string | null>(sessionMessages.length > 0 ? getMessageId(sessionMessages[sessionMessages.length - 1]) : null);
|
const lastLastMessageIdRef = React.useRef<string | null>(sessionMessages.length > 0 ? getMessageId(sessionMessages[sessionMessages.length - 1]) : null);
|
||||||
@@ -118,6 +120,7 @@ export const useChatScrollManager = ({
|
|||||||
const resizeTimeoutRef = React.useRef<number | undefined>(undefined);
|
const resizeTimeoutRef = React.useRef<number | undefined>(undefined);
|
||||||
|
|
||||||
const anchorIdRef = React.useRef<string | null>(null);
|
const anchorIdRef = React.useRef<string | null>(null);
|
||||||
|
const pendingRestoreAnchorRef = React.useRef<{ sessionId: string; anchorId: string } | null>(null);
|
||||||
|
|
||||||
const hasAnchoredOnceRef = React.useRef<boolean>(false);
|
const hasAnchoredOnceRef = React.useRef<boolean>(false);
|
||||||
const userScrollOverrideRef = React.useRef<boolean>(false);
|
const userScrollOverrideRef = React.useRef<boolean>(false);
|
||||||
@@ -127,6 +130,10 @@ export const useChatScrollManager = ({
|
|||||||
? sessionActivityPhase?.get(currentSessionId) ?? 'idle'
|
? sessionActivityPhase?.get(currentSessionId) ?? 'idle'
|
||||||
: 'idle';
|
: 'idle';
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
currentSessionIdRef.current = currentSessionId ?? null;
|
||||||
|
}, [currentSessionId]);
|
||||||
|
|
||||||
const updateSpacerHeight = React.useCallback((height: number) => {
|
const updateSpacerHeight = React.useCallback((height: number) => {
|
||||||
const newHeight = Math.max(0, height);
|
const newHeight = Math.max(0, height);
|
||||||
if (spacerHeightRef.current !== newHeight) {
|
if (spacerHeightRef.current !== newHeight) {
|
||||||
@@ -160,6 +167,13 @@ export const useChatScrollManager = ({
|
|||||||
return viewportBottom < spacerStartPosition;
|
return viewportBottom < spacerStartPosition;
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const isAtBottom = React.useCallback((): boolean => {
|
||||||
|
const container = scrollRef.current;
|
||||||
|
if (!container) return true;
|
||||||
|
const distanceFromBottom = container.scrollHeight - container.scrollTop - container.clientHeight;
|
||||||
|
return distanceFromBottom <= BOTTOM_EPSILON_PX;
|
||||||
|
}, []);
|
||||||
|
|
||||||
const calculateAnchorPosition = React.useCallback((
|
const calculateAnchorPosition = React.useCallback((
|
||||||
anchorElement: HTMLElement,
|
anchorElement: HTMLElement,
|
||||||
containerHeight: number
|
containerHeight: number
|
||||||
@@ -267,11 +281,20 @@ export const useChatScrollManager = ({
|
|||||||
|
|
||||||
if (options?.force) {
|
if (options?.force) {
|
||||||
userScrollOverrideRef.current = false;
|
userScrollOverrideRef.current = false;
|
||||||
|
if (currentSessionId && anchorIdRef.current) {
|
||||||
|
anchorIdRef.current = null;
|
||||||
|
hasAnchoredOnceRef.current = false;
|
||||||
|
lastScrolledAnchorIdRef.current = null;
|
||||||
|
setAnchorId(null);
|
||||||
|
pendingRestoreAnchorRef.current = null;
|
||||||
|
updateSpacerHeight(0);
|
||||||
|
updateActiveTurnAnchor(currentSessionId, null, 0);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const bottom = container.scrollHeight - container.clientHeight;
|
const bottom = container.scrollHeight - container.clientHeight;
|
||||||
scrollEngine.scrollToPosition(Math.max(0, bottom), options);
|
scrollEngine.scrollToPosition(Math.max(0, bottom), options);
|
||||||
}, [currentPhase, isSyncing, scrollEngine]);
|
}, [currentPhase, currentSessionId, isSyncing, scrollEngine, updateActiveTurnAnchor, updateSpacerHeight]);
|
||||||
|
|
||||||
const scrollToNewAnchor = React.useCallback((messageId: string) => {
|
const scrollToNewAnchor = React.useCallback((messageId: string) => {
|
||||||
if (lastScrolledAnchorIdRef.current === messageId) {
|
if (lastScrolledAnchorIdRef.current === messageId) {
|
||||||
@@ -280,6 +303,7 @@ export const useChatScrollManager = ({
|
|||||||
lastScrolledAnchorIdRef.current = messageId;
|
lastScrolledAnchorIdRef.current = messageId;
|
||||||
|
|
||||||
setPendingAnchorId(messageId);
|
setPendingAnchorId(messageId);
|
||||||
|
const expectedSessionId = currentSessionIdRef.current;
|
||||||
|
|
||||||
const container = scrollRef.current;
|
const container = scrollRef.current;
|
||||||
if (!container) {
|
if (!container) {
|
||||||
@@ -317,10 +341,16 @@ export const useChatScrollManager = ({
|
|||||||
hasAnchoredOnceRef.current = true;
|
hasAnchoredOnceRef.current = true;
|
||||||
|
|
||||||
window.requestAnimationFrame(() => {
|
window.requestAnimationFrame(() => {
|
||||||
|
if (expectedSessionId !== currentSessionIdRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
scrollEngine.scrollToPosition(targetScrollTop, { instant: true });
|
scrollEngine.scrollToPosition(targetScrollTop, { instant: true });
|
||||||
|
|
||||||
window.requestAnimationFrame(() => {
|
window.requestAnimationFrame(() => {
|
||||||
|
if (expectedSessionId !== currentSessionIdRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
setPendingAnchorId(null);
|
setPendingAnchorId(null);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -339,11 +369,17 @@ export const useChatScrollManager = ({
|
|||||||
scrollEngine.handleScroll();
|
scrollEngine.handleScroll();
|
||||||
updateScrollButtonVisibility();
|
updateScrollButtonVisibility();
|
||||||
|
|
||||||
if (currentPhase === 'idle' && spacerHeightRef.current > 0 && isSpacerOutOfViewport()) {
|
if (
|
||||||
|
event?.isTrusted &&
|
||||||
|
anchorIdRef.current !== null &&
|
||||||
|
(spacerHeightRef.current > 0 ? !isSpacerOutOfViewport() : isAtBottom())
|
||||||
|
) {
|
||||||
updateSpacerHeight(0);
|
updateSpacerHeight(0);
|
||||||
anchorIdRef.current = null;
|
anchorIdRef.current = null;
|
||||||
|
hasAnchoredOnceRef.current = false;
|
||||||
|
lastScrolledAnchorIdRef.current = null;
|
||||||
setAnchorId(null);
|
setAnchorId(null);
|
||||||
// Clear in store when user scrolls away from spacer
|
pendingRestoreAnchorRef.current = null;
|
||||||
updateActiveTurnAnchor(currentSessionId, null, 0);
|
updateActiveTurnAnchor(currentSessionId, null, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -353,7 +389,7 @@ export const useChatScrollManager = ({
|
|||||||
updateViewportAnchor(currentSessionId, estimatedIndex);
|
updateViewportAnchor(currentSessionId, estimatedIndex);
|
||||||
}, [
|
}, [
|
||||||
currentSessionId,
|
currentSessionId,
|
||||||
currentPhase,
|
isAtBottom,
|
||||||
isSpacerOutOfViewport,
|
isSpacerOutOfViewport,
|
||||||
scrollEngine,
|
scrollEngine,
|
||||||
sessionMessages.length,
|
sessionMessages.length,
|
||||||
@@ -410,34 +446,22 @@ export const useChatScrollManager = ({
|
|||||||
|
|
||||||
// Restore persisted anchor state from store
|
// Restore persisted anchor state from store
|
||||||
const persistedAnchor = getActiveTurnAnchor(currentSessionId);
|
const persistedAnchor = getActiveTurnAnchor(currentSessionId);
|
||||||
const shouldRestoreAnchor = currentPhase !== 'idle';
|
if (persistedAnchor && persistedAnchor.anchorId) {
|
||||||
if (shouldRestoreAnchor && persistedAnchor && persistedAnchor.anchorId) {
|
|
||||||
anchorIdRef.current = persistedAnchor.anchorId;
|
anchorIdRef.current = persistedAnchor.anchorId;
|
||||||
lastScrolledAnchorIdRef.current = persistedAnchor.anchorId;
|
lastScrolledAnchorIdRef.current = persistedAnchor.anchorId;
|
||||||
hasAnchoredOnceRef.current = true;
|
hasAnchoredOnceRef.current = true;
|
||||||
setAnchorId(persistedAnchor.anchorId);
|
setAnchorId(persistedAnchor.anchorId);
|
||||||
spacerHeightRef.current = persistedAnchor.spacerHeight;
|
updateSpacerHeight(persistedAnchor.spacerHeight);
|
||||||
setSpacerHeight(persistedAnchor.spacerHeight);
|
pendingRestoreAnchorRef.current = { sessionId: currentSessionId, anchorId: persistedAnchor.anchorId };
|
||||||
|
|
||||||
// Scroll to anchor position after DOM updates
|
userScrollOverrideRef.current = false;
|
||||||
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 {
|
} else {
|
||||||
lastScrolledAnchorIdRef.current = null;
|
lastScrolledAnchorIdRef.current = null;
|
||||||
anchorIdRef.current = null;
|
anchorIdRef.current = null;
|
||||||
hasAnchoredOnceRef.current = false;
|
hasAnchoredOnceRef.current = false;
|
||||||
setAnchorId(null);
|
setAnchorId(null);
|
||||||
spacerHeightRef.current = 0;
|
updateSpacerHeight(0);
|
||||||
setSpacerHeight(0);
|
pendingRestoreAnchorRef.current = null;
|
||||||
|
|
||||||
// Ensure idle/non-anchored sessions snap to bottom on switch
|
// Ensure idle/non-anchored sessions snap to bottom on switch
|
||||||
window.requestAnimationFrame(() => {
|
window.requestAnimationFrame(() => {
|
||||||
@@ -455,6 +479,37 @@ export const useChatScrollManager = ({
|
|||||||
// 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
|
||||||
}, [currentPhase, currentSessionId, scrollToBottom, sessionMessages.length, getActiveTurnAnchor]);
|
}, [currentPhase, currentSessionId, scrollToBottom, sessionMessages.length, getActiveTurnAnchor]);
|
||||||
|
|
||||||
|
useIsomorphicLayoutEffect(() => {
|
||||||
|
if (typeof window === 'undefined') return;
|
||||||
|
if (!currentSessionId) return;
|
||||||
|
|
||||||
|
const pending = pendingRestoreAnchorRef.current;
|
||||||
|
if (!pending || pending.sessionId !== currentSessionId) return;
|
||||||
|
|
||||||
|
const container = scrollRef.current;
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
const anchorInList = sessionMessages.some((message) => getMessageId(message) === pending.anchorId);
|
||||||
|
if (!anchorInList) {
|
||||||
|
pendingRestoreAnchorRef.current = null;
|
||||||
|
anchorIdRef.current = null;
|
||||||
|
hasAnchoredOnceRef.current = false;
|
||||||
|
lastScrolledAnchorIdRef.current = null;
|
||||||
|
setAnchorId(null);
|
||||||
|
updateSpacerHeight(0);
|
||||||
|
updateActiveTurnAnchor(currentSessionId, null, 0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const anchorElement = container.querySelector(`[data-message-id="${pending.anchorId}"]`) as HTMLElement | null;
|
||||||
|
if (!anchorElement) return;
|
||||||
|
|
||||||
|
const containerHeight = container.clientHeight;
|
||||||
|
const targetScrollTop = calculateAnchorPosition(anchorElement, containerHeight);
|
||||||
|
scrollEngine.scrollToPosition(targetScrollTop, { instant: true });
|
||||||
|
pendingRestoreAnchorRef.current = null;
|
||||||
|
}, [calculateAnchorPosition, currentSessionId, scrollEngine, sessionMessages, updateActiveTurnAnchor, updateSpacerHeight]);
|
||||||
|
|
||||||
useIsomorphicLayoutEffect(() => {
|
useIsomorphicLayoutEffect(() => {
|
||||||
|
|
||||||
if (isSyncing) {
|
if (isSyncing) {
|
||||||
@@ -564,8 +619,7 @@ export const useChatScrollManager = ({
|
|||||||
|
|
||||||
if (
|
if (
|
||||||
transitionedToIdle &&
|
transitionedToIdle &&
|
||||||
spacerHeightRef.current > 0 &&
|
(spacerHeightRef.current > 0 ? !isSpacerOutOfViewport() : isAtBottom()) &&
|
||||||
isSpacerOutOfViewport() &&
|
|
||||||
anchorIdRef.current !== null
|
anchorIdRef.current !== null
|
||||||
) {
|
) {
|
||||||
updateSpacerHeight(0);
|
updateSpacerHeight(0);
|
||||||
@@ -577,7 +631,7 @@ export const useChatScrollManager = ({
|
|||||||
updateActiveTurnAnchor(currentSessionId, null, 0);
|
updateActiveTurnAnchor(currentSessionId, null, 0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [currentPhase, currentSessionId, isSpacerOutOfViewport, updateActiveTurnAnchor, updateSpacerHeight]);
|
}, [currentPhase, currentSessionId, isAtBottom, isSpacerOutOfViewport, updateActiveTurnAnchor, updateSpacerHeight]);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
updateScrollButtonVisibility();
|
updateScrollButtonVisibility();
|
||||||
|
|||||||
@@ -2352,7 +2352,6 @@ export const useMessageStore = create<MessageStore>()(
|
|||||||
const newMemoryState = new Map(state.sessionMemoryState);
|
const newMemoryState = new Map(state.sessionMemoryState);
|
||||||
|
|
||||||
newMessages.delete(lruSessionId);
|
newMessages.delete(lruSessionId);
|
||||||
newMemoryState.delete(lruSessionId);
|
|
||||||
|
|
||||||
const result: Record<string, any> = {
|
const result: Record<string, any> = {
|
||||||
messages: newMessages,
|
messages: newMessages,
|
||||||
@@ -2500,6 +2499,8 @@ export const useMessageStore = create<MessageStore>()(
|
|||||||
totalAvailableMessages: memory.totalAvailableMessages,
|
totalAvailableMessages: memory.totalAvailableMessages,
|
||||||
hasMoreAbove: memory.hasMoreAbove,
|
hasMoreAbove: memory.hasMoreAbove,
|
||||||
trimmedHeadMaxId: memory.trimmedHeadMaxId,
|
trimmedHeadMaxId: memory.trimmedHeadMaxId,
|
||||||
|
activeTurnAnchorId: memory.activeTurnAnchorId,
|
||||||
|
activeTurnSpacerHeight: memory.activeTurnSpacerHeight,
|
||||||
},
|
},
|
||||||
]),
|
]),
|
||||||
sessionAbortFlags: Array.from(state.sessionAbortFlags.entries()).map(([sessionId, record]) => [
|
sessionAbortFlags: Array.from(state.sessionAbortFlags.entries()).map(([sessionId, record]) => [
|
||||||
|
|||||||
Reference in New Issue
Block a user