From d6599296859cb9c91918b6d8b8e443ba7aa2093c Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Fri, 8 May 2026 14:31:16 +0300 Subject: [PATCH] fix(chat): tighten auto-follow release thresholds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bottom-zone threshold now scales with the scroll container's clientHeight rather than window.innerHeight. The threshold is meant to mirror the empty spacer at the end of the chat, but what users perceive as "the empty tail" is relative to the visible chat area, not the whole viewport — on desktop the chat is shorter than the window because of the input row below it. This keeps the scroll-to-bottom button hidden until the user has actually scrolled past the spacer they see, regardless of viewport size. Wheel and touch release detection no longer treats every nested [data-scrollable] as a blocker. The old check returned true for any nested scrollable ancestor, so wheel-up over a code block already at its own scrollTop=0 produced no effect — the inner element could not consume the gesture and the outer chat refused to release auto-follow. The new check returns true only when the nested element can actually scroll up (scrollTop > 0); otherwise the gesture bubbles to the chat and releases auto-follow as expected. --- packages/ui/src/hooks/useChatAutoFollow.ts | 28 ++++++++++++++-------- 1 file changed, 18 insertions(+), 10 deletions(-) diff --git a/packages/ui/src/hooks/useChatAutoFollow.ts b/packages/ui/src/hooks/useChatAutoFollow.ts index 6c7e0bb9..b22540d9 100644 --- a/packages/ui/src/hooks/useChatAutoFollow.ts +++ b/packages/ui/src/hooks/useChatAutoFollow.ts @@ -56,10 +56,11 @@ const REPIN_GRACE_AFTER_RELEASE_MS = 1200; // — its height is exactly how far above scrollHeight the user can be while still // looking at "empty" space. We use that same value as the threshold for both // re-pinning auto-follow and showing the scroll-to-bottom button. -const computeBottomZoneThreshold = (isMobile: boolean): number => { +const computeBottomZoneThreshold = (isMobile: boolean, container?: HTMLElement | null): number => { if (isMobile) return BOTTOM_SPACER_MOBILE_PX; - if (typeof window === 'undefined') return 96; - return Math.max(48, window.innerHeight * BOTTOM_SPACER_DESKTOP_VH); + const height = container?.clientHeight ?? 0; + if (height <= 0) return 96; + return Math.max(48, height * BOTTOM_SPACER_DESKTOP_VH); }; const distanceFromBottom = (el: HTMLElement): number => { @@ -67,7 +68,7 @@ const distanceFromBottom = (el: HTMLElement): number => { }; const isNearBottom = (el: HTMLElement, isMobile: boolean): boolean => { - return distanceFromBottom(el) <= computeBottomZoneThreshold(isMobile); + return distanceFromBottom(el) <= computeBottomZoneThreshold(isMobile, el); }; const isReleaseKey = (event: KeyboardEvent): boolean => { @@ -84,16 +85,23 @@ const isReleaseKey = (event: KeyboardEvent): boolean => { } }; -const targetIsNestedScrollable = (root: HTMLElement, target: EventTarget | null): boolean => { - if (!(target instanceof Element)) return false; +const nestedScrollableTarget = (root: HTMLElement, target: EventTarget | null): HTMLElement | null => { + if (!(target instanceof Element)) return null; const nested = target.closest('[data-scrollable]'); - return Boolean(nested) && nested !== root; + if (!nested || nested === root || !(nested instanceof HTMLElement)) return null; + return nested; +}; + +const nestedScrollableCanConsumeUp = (root: HTMLElement, target: EventTarget | null): boolean => { + const nested = nestedScrollableTarget(root, target); + if (!nested) return false; + return nested.scrollTop > 0; }; const isAtBottomSnapshot = (snapshot: NonNullable, isMobile: boolean): boolean => { const max = Math.max(0, snapshot.scrollHeight - snapshot.clientHeight); if (max <= 0) return true; - const threshold = computeBottomZoneThreshold(isMobile); + const threshold = computeBottomZoneThreshold(isMobile, null); return max - snapshot.scrollTop <= threshold; }; @@ -486,7 +494,7 @@ export const useChatAutoFollow = ({ const handleWheel = (event: WheelEvent) => { if (event.deltaY >= 0) return; - if (targetIsNestedScrollable(container, event.target)) return; + if (nestedScrollableCanConsumeUp(container, event.target)) return; releaseFromUserIntent(); }; @@ -506,7 +514,7 @@ export const useChatAutoFollow = ({ if (previousY === null) return; const fingerDelta = touch.clientY - previousY; if (fingerDelta <= TOUCH_FINGER_DOWN_THRESHOLD) return; - if (targetIsNestedScrollable(container, event.target)) return; + if (nestedScrollableCanConsumeUp(container, event.target)) return; releaseFromUserIntent(); }; const handleTouchEnd = () => {