fix(chat): register touch opt-out by finger direction

On touch surfaces the gesture opt-out waited for the viewport to have
already left the end, but streaming corrections re-pin the viewport every
chunk, so the drag never registered: the user could not scroll away
mid-stream, the scroll-to-bottom pill (which waits for a gesture) never
appeared, and live-follow stayed armed — which also made the mobile
load-older button throw the viewport to the bottom, since the prepend's
content growth ran the end correction.

Track the finger's Y between touch events and treat a downward drag (the
touch mirror of wheel-up) as an immediate opt-out. Loading older history
also releases live follow explicitly before the prepend, covering the
case where the button is reachable without any prior scroll.
This commit is contained in:
Bohdan Triapitsyn
2026-08-25 15:06:31 +03:00
parent 34ae8b059e
commit 4d3542fdb4
2 changed files with 31 additions and 5 deletions
@@ -1009,8 +1009,12 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({
&& timelineController.historySignals.canLoadEarlier;
const timelineLoadEarlier = timelineController.loadEarlier;
const handleLoadOlderClick = React.useCallback(() => {
// Loading older history is an explicit move INTO the past: release
// live follow first, or the prepend's content growth would trigger an
// end correction and throw the viewport to the bottom.
onManualNavigation();
void timelineLoadEarlier({ userInitiated: true });
}, [timelineLoadEarlier]);
}, [onManualNavigation, timelineLoadEarlier]);
React.useEffect(() => {
activeTurnChangeRef.current = timelineController.handleActiveTurnChange;
+26 -4
View File
@@ -629,10 +629,26 @@ export const useChatTimelineScroll = ({
// Scrolling toward the end is not opting out of follow.
if (event.deltaY < 0 && canScrollUp()) gesture();
};
const handleTouchMove = () => {
// Touch is continuous: the first move may still read as at-end,
// but the next one lands after the viewport left it.
if (!isAtEndRef.current && canScrollUp()) gesture();
// Touch mirrors wheel by finger direction, not by having already left
// the end: while a stream keeps re-pinning the viewport, waiting for
// an at-end transition means the drag never registers — the user
// cannot scroll, the pill never appears, and live-follow stays armed
// under a viewport they are fighting for.
let touchLastY: number | null = null;
const handleTouchStart = (event: TouchEvent) => {
touchLastY = event.touches[0]?.clientY ?? null;
};
const handleTouchMove = (event: TouchEvent) => {
const y = event.touches[0]?.clientY ?? null;
const lastY = touchLastY;
touchLastY = y;
if (y === null) return;
// A downward finger drags the content up — the touch wheel-up.
const draggedUp = lastY !== null && y > lastY;
if ((draggedUp || !isAtEndRef.current) && canScrollUp()) gesture();
};
const handleTouchEnd = () => {
touchLastY = null;
};
const handlePointerDown = (event: PointerEvent) => {
// The scrollbar track is the scroll node itself; a tap on a row
@@ -649,14 +665,20 @@ export const useChatTimelineScroll = ({
};
scrollNode.addEventListener('wheel', handleWheel, { passive: true });
scrollNode.addEventListener('touchstart', handleTouchStart, { passive: true });
scrollNode.addEventListener('touchmove', handleTouchMove, { passive: true });
scrollNode.addEventListener('touchend', handleTouchEnd, { passive: true });
scrollNode.addEventListener('touchcancel', handleTouchEnd, { passive: true });
scrollNode.addEventListener('pointerdown', handlePointerDown, { passive: true });
scrollNode.addEventListener('keydown', handleKeyDown);
scrollNode.addEventListener('scroll', handleScroll, { passive: true });
return () => {
scrollNode.removeEventListener('wheel', handleWheel);
scrollNode.removeEventListener('touchstart', handleTouchStart);
scrollNode.removeEventListener('touchmove', handleTouchMove);
scrollNode.removeEventListener('touchend', handleTouchEnd);
scrollNode.removeEventListener('touchcancel', handleTouchEnd);
scrollNode.removeEventListener('pointerdown', handlePointerDown);
scrollNode.removeEventListener('keydown', handleKeyDown);
scrollNode.removeEventListener('scroll', handleScroll);