fix(chat): tighten auto-follow release thresholds

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.
This commit is contained in:
Bohdan Triapitsyn
2026-05-08 14:31:16 +03:00
parent 0ea573f766
commit d659929685
+18 -10
View File
@@ -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<SessionMemoryState['scrollPosition']>, 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 = () => {