fix: improve anchor management by adding tolerance for anchor position and refining scroll behavior
This commit is contained in:
@@ -73,6 +73,7 @@ const PROGRAMMATIC_SCROLL_SUPPRESS_MS = 200;
|
|||||||
const ANCHOR_CLEAR_GRACE_MS = 1200;
|
const ANCHOR_CLEAR_GRACE_MS = 1200;
|
||||||
// Require recent direct user input (wheel/touch) to treat scroll as intentional.
|
// Require recent direct user input (wheel/touch) to treat scroll as intentional.
|
||||||
const DIRECT_SCROLL_INTENT_WINDOW_MS = 250;
|
const DIRECT_SCROLL_INTENT_WINDOW_MS = 250;
|
||||||
|
const ANCHOR_CLEAR_TOLERANCE_PX = 24;
|
||||||
|
|
||||||
const getMessageId = (message: ChatMessageRecord): string | null => {
|
const getMessageId = (message: ChatMessageRecord): string | null => {
|
||||||
const info = message.info;
|
const info = message.info;
|
||||||
@@ -128,7 +129,7 @@ export const useChatScrollManager = ({
|
|||||||
const spacerHeightRef = React.useRef(0);
|
const spacerHeightRef = React.useRef(0);
|
||||||
|
|
||||||
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 pendingRestoreAnchorRef = React.useRef<{ sessionId: string; anchorId: string; startedAt: number } | null>(null);
|
||||||
|
|
||||||
const userScrollOverrideRef = React.useRef<boolean>(false);
|
const userScrollOverrideRef = React.useRef<boolean>(false);
|
||||||
|
|
||||||
@@ -149,17 +150,24 @@ export const useChatScrollManager = ({
|
|||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const isSpacerOutOfViewport = React.useCallback((): boolean => {
|
const calculateAnchorPosition = React.useCallback((anchorElement: HTMLElement): number => {
|
||||||
const container = scrollRef.current;
|
const messageTop = anchorElement.offsetTop;
|
||||||
const currentSpacerHeight = spacerHeightRef.current;
|
return messageTop - ANCHOR_TARGET_OFFSET;
|
||||||
if (!container || currentSpacerHeight <= 0) return true;
|
|
||||||
|
|
||||||
const spacerStartPosition = container.scrollHeight - currentSpacerHeight;
|
|
||||||
const viewportBottom = container.scrollTop + container.clientHeight;
|
|
||||||
|
|
||||||
return viewportBottom < spacerStartPosition;
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const isAnchorStillPinned = React.useCallback((): boolean => {
|
||||||
|
const container = scrollRef.current;
|
||||||
|
const anchorId = anchorIdRef.current;
|
||||||
|
if (!container || !anchorId) return false;
|
||||||
|
|
||||||
|
const anchorElement = container.querySelector(`[data-message-id="${anchorId}"]`) as HTMLElement | null;
|
||||||
|
if (!anchorElement) return false;
|
||||||
|
|
||||||
|
const expectedTop = calculateAnchorPosition(anchorElement);
|
||||||
|
const distance = Math.abs(container.scrollTop - expectedTop);
|
||||||
|
return distance <= ANCHOR_CLEAR_TOLERANCE_PX;
|
||||||
|
}, [calculateAnchorPosition]);
|
||||||
|
|
||||||
const clearActiveTurnAnchor = React.useCallback((sessionId: string) => {
|
const clearActiveTurnAnchor = React.useCallback((sessionId: string) => {
|
||||||
anchorIdRef.current = null;
|
anchorIdRef.current = null;
|
||||||
lastScrolledAnchorIdRef.current = null;
|
lastScrolledAnchorIdRef.current = null;
|
||||||
@@ -173,11 +181,6 @@ export const useChatScrollManager = ({
|
|||||||
suppressUserScrollUntilRef.current = Date.now() + PROGRAMMATIC_SCROLL_SUPPRESS_MS;
|
suppressUserScrollUntilRef.current = Date.now() + PROGRAMMATIC_SCROLL_SUPPRESS_MS;
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const calculateAnchorPosition = React.useCallback((anchorElement: HTMLElement): number => {
|
|
||||||
const messageTop = anchorElement.offsetTop;
|
|
||||||
return messageTop - ANCHOR_TARGET_OFFSET;
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const updateScrollButtonVisibility = React.useCallback(() => {
|
const updateScrollButtonVisibility = React.useCallback(() => {
|
||||||
const container = scrollRef.current;
|
const container = scrollRef.current;
|
||||||
if (!container) {
|
if (!container) {
|
||||||
@@ -334,7 +337,9 @@ export const useChatScrollManager = ({
|
|||||||
currentPhase === 'idle' &&
|
currentPhase === 'idle' &&
|
||||||
anchorIdRef.current !== null &&
|
anchorIdRef.current !== null &&
|
||||||
spacerHeightRef.current > 0 &&
|
spacerHeightRef.current > 0 &&
|
||||||
isSpacerOutOfViewport()
|
// Only clear when the user actually scrolls away from the pinned anchor.
|
||||||
|
// (Spacer being out of viewport is expected while anchored.)
|
||||||
|
!isAnchorStillPinned()
|
||||||
) {
|
) {
|
||||||
clearActiveTurnAnchor(currentSessionId);
|
clearActiveTurnAnchor(currentSessionId);
|
||||||
}
|
}
|
||||||
@@ -347,7 +352,7 @@ export const useChatScrollManager = ({
|
|||||||
clearActiveTurnAnchor,
|
clearActiveTurnAnchor,
|
||||||
currentPhase,
|
currentPhase,
|
||||||
currentSessionId,
|
currentSessionId,
|
||||||
isSpacerOutOfViewport,
|
isAnchorStillPinned,
|
||||||
pendingAnchorId,
|
pendingAnchorId,
|
||||||
scrollEngine,
|
scrollEngine,
|
||||||
sessionMessages.length,
|
sessionMessages.length,
|
||||||
@@ -405,7 +410,7 @@ export const useChatScrollManager = ({
|
|||||||
updateSpacerHeight(restoredSpacerHeight);
|
updateSpacerHeight(restoredSpacerHeight);
|
||||||
});
|
});
|
||||||
|
|
||||||
pendingRestoreAnchorRef.current = { sessionId: currentSessionId, anchorId: persistedAnchor.anchorId };
|
pendingRestoreAnchorRef.current = { sessionId: currentSessionId, anchorId: persistedAnchor.anchorId, startedAt: Date.now() };
|
||||||
} else {
|
} else {
|
||||||
lastScrolledAnchorIdRef.current = null;
|
lastScrolledAnchorIdRef.current = null;
|
||||||
anchorIdRef.current = null;
|
anchorIdRef.current = null;
|
||||||
@@ -460,15 +465,17 @@ export const useChatScrollManager = ({
|
|||||||
const container = scrollRef.current;
|
const container = scrollRef.current;
|
||||||
if (!container) return;
|
if (!container) return;
|
||||||
|
|
||||||
const anchorInList = sessionMessages.some((message) => getMessageId(message) === pending.anchorId);
|
const anchorElement = container.querySelector(`[data-message-id="${pending.anchorId}"]`) as HTMLElement | null;
|
||||||
if (!anchorInList) {
|
if (!anchorElement) {
|
||||||
|
// When the anchor is created from a just-sent user message, the persisted anchor can
|
||||||
|
// show up before the message is in the rendered list. Give it a short window.
|
||||||
|
if (Date.now() - pending.startedAt < 1200) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
clearActiveTurnAnchor(currentSessionId);
|
clearActiveTurnAnchor(currentSessionId);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const anchorElement = container.querySelector(`[data-message-id="${pending.anchorId}"]`) as HTMLElement | null;
|
|
||||||
if (!anchorElement) return;
|
|
||||||
|
|
||||||
const targetScrollTop = calculateAnchorPosition(anchorElement);
|
const targetScrollTop = calculateAnchorPosition(anchorElement);
|
||||||
markProgrammaticScroll();
|
markProgrammaticScroll();
|
||||||
scrollEngine.scrollToPosition(targetScrollTop, { instant: true });
|
scrollEngine.scrollToPosition(targetScrollTop, { instant: true });
|
||||||
|
|||||||
Reference in New Issue
Block a user