From b95957fe9cde5685abe222d7712464cb327808e5 Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Mon, 19 Jan 2026 15:38:15 +0200 Subject: [PATCH] fix: enhance scroll management by adding grace period for anchor clearing and direct scroll intent detection --- packages/ui/src/hooks/useChatScrollManager.ts | 28 +++++++++++++++++-- 1 file changed, 26 insertions(+), 2 deletions(-) diff --git a/packages/ui/src/hooks/useChatScrollManager.ts b/packages/ui/src/hooks/useChatScrollManager.ts index 82baeef8..555c154a 100644 --- a/packages/ui/src/hooks/useChatScrollManager.ts +++ b/packages/ui/src/hooks/useChatScrollManager.ts @@ -69,6 +69,10 @@ const ANCHOR_TARGET_OFFSET = 8; const DEFAULT_SCROLL_BUTTON_THRESHOLD = 40; const NEW_USER_ANCHOR_WINDOW_MS = 20_000; const PROGRAMMATIC_SCROLL_SUPPRESS_MS = 200; +// After we set an anchor/spacer, ignore incidental scroll events for a bit. +const ANCHOR_CLEAR_GRACE_MS = 1200; +// Require recent direct user input (wheel/touch) to treat scroll as intentional. +const DIRECT_SCROLL_INTENT_WINDOW_MS = 250; const getMessageId = (message: ChatMessageRecord): string | null => { const info = message.info; @@ -117,6 +121,8 @@ export const useChatScrollManager = ({ const lastSessionIdRef = React.useRef(null); const currentSessionIdRef = React.useRef(currentSessionId ?? null); const suppressUserScrollUntilRef = React.useRef(0); + const anchorClearIgnoreUntilRef = React.useRef(0); + const lastDirectScrollIntentAtRef = React.useRef(0); const previousMessageIdsRef = React.useRef>(new Set()); const lastMessageCountRef = React.useRef(sessionMessages.length); const spacerHeightRef = React.useRef(0); @@ -238,6 +244,9 @@ export const useChatScrollManager = ({ } lastScrolledAnchorIdRef.current = messageId; + // Give the UI a grace window so incidental scroll/layout events don't clear the anchor. + anchorClearIgnoreUntilRef.current = Date.now() + ANCHOR_CLEAR_GRACE_MS; + setPendingAnchorId(messageId); const expectedSessionId = currentSessionIdRef.current; @@ -303,18 +312,25 @@ export const useChatScrollManager = ({ return; } - const isProgrammatic = Date.now() < suppressUserScrollUntilRef.current || pendingAnchorId !== null; + const now = Date.now(); + const isProgrammatic = now < suppressUserScrollUntilRef.current || pendingAnchorId !== null; - if (event?.isTrusted && !isProgrammatic) { + const hasDirectIntent = now - lastDirectScrollIntentAtRef.current <= DIRECT_SCROLL_INTENT_WINDOW_MS; + + if (event?.isTrusted && !isProgrammatic && hasDirectIntent) { userScrollOverrideRef.current = true; } scrollEngine.handleScroll(); updateScrollButtonVisibility(); + const shouldIgnoreAnchorClear = now < anchorClearIgnoreUntilRef.current; + if ( event?.isTrusted && !isProgrammatic && + !shouldIgnoreAnchorClear && + hasDirectIntent && currentPhase === 'idle' && anchorIdRef.current !== null && spacerHeightRef.current > 0 && @@ -343,10 +359,18 @@ export const useChatScrollManager = ({ const container = scrollRef.current; if (!container) return; + const markDirectIntent = () => { + lastDirectScrollIntentAtRef.current = Date.now(); + }; + container.addEventListener('scroll', handleScrollEvent as EventListener, { passive: true }); + container.addEventListener('wheel', markDirectIntent as EventListener, { passive: true }); + container.addEventListener('touchmove', markDirectIntent as EventListener, { passive: true }); return () => { container.removeEventListener('scroll', handleScrollEvent as EventListener); + container.removeEventListener('wheel', markDirectIntent as EventListener); + container.removeEventListener('touchmove', markDirectIntent as EventListener); }; }, [handleScrollEvent]);