Skip to content

Commit 1fc7ee6

Browse files
committed
Stop auto-follow from fighting touch scrolls
A touch drag registers scroll intent after 4px, but a scroll position counts as at-end until 24px, so the first frames of every upward drag landed in the gap: the scroll handler re-armed bottom sticking and cleared the user-scroll lock, snapping the timeline back under the finger. Wheel devices clear the tolerance in one notch and never saw it. - Only re-arm from a scroll event once the user-scroll lock has expired - Hold off the lock-expiry re-check while a finger is still on the glass, and re-check on touch end instead - Skip the viewport-resize re-stick mid-gesture, since mobile browsers collapse the URL bar as the user scrolls - Cover the drag-inside-tolerance case in the timeline browser tests
1 parent 2b1f623 commit 1fc7ee6

2 files changed

Lines changed: 144 additions & 20 deletions

File tree

apps/web/src/components/chat/MessagesTimeline.browser.tsx

Lines changed: 110 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,23 @@ import { __resetClientSettingsPersistenceForTests } from "../../hooks/useSetting
1313
const scrollToEndSpy = vi.fn();
1414
const getStateSpy = vi.fn(() => ({ isAtEnd: true }));
1515

16+
// Viewport 100 / content 200 at offset 0 — a full viewport away from the end.
17+
const AWAY_FROM_END_SCROLL_METRICS = {
18+
layoutMeasurement: { height: 100 },
19+
contentSize: { height: 200 },
20+
contentOffset: { y: 0 },
21+
contentInset: { bottom: 0 },
22+
};
23+
// 10px from the end: past the touch intent threshold, still inside the 24px
24+
// at-end tolerance — the window a mobile drag has to cross.
25+
const NEAR_END_SCROLL_METRICS = {
26+
layoutMeasurement: { height: 100 },
27+
contentSize: { height: 210 },
28+
contentOffset: { y: 100 },
29+
contentInset: { bottom: 0 },
30+
};
31+
let timelineScrollMetrics: typeof AWAY_FROM_END_SCROLL_METRICS = AWAY_FROM_END_SCROLL_METRICS;
32+
1633
vi.mock("@legendapp/list/react", async () => {
1734
const React = await import("react");
1835

@@ -32,6 +49,9 @@ vi.mock("@legendapp/list/react", async () => {
3249
};
3350
}) => void;
3451
onWheelCapture?: React.WheelEventHandler<HTMLDivElement>;
52+
onTouchStartCapture?: React.TouchEventHandler<HTMLDivElement>;
53+
onTouchMoveCapture?: React.TouchEventHandler<HTMLDivElement>;
54+
onTouchEndCapture?: React.TouchEventHandler<HTMLDivElement>;
3555
ref?: React.Ref<LegendListRef>;
3656
}) {
3757
React.useImperativeHandle(
@@ -48,16 +68,12 @@ vi.mock("@legendapp/list/react", async () => {
4868
data-testid="legend-list"
4969
data-maintain-scroll-at-end={props.maintainScrollAtEnd ? "true" : "false"}
5070
onScroll={() => {
51-
props.onScroll?.({
52-
nativeEvent: {
53-
layoutMeasurement: { height: 100 },
54-
contentSize: { height: 200 },
55-
contentOffset: { y: 0 },
56-
contentInset: { bottom: 0 },
57-
},
58-
});
71+
props.onScroll?.({ nativeEvent: timelineScrollMetrics });
5972
}}
6073
onWheelCapture={props.onWheelCapture}
74+
onTouchStartCapture={props.onTouchStartCapture}
75+
onTouchMoveCapture={props.onTouchMoveCapture}
76+
onTouchEndCapture={props.onTouchEndCapture}
6177
>
6278
{props.ListHeaderComponent}
6379
{props.data.map((item) => (
@@ -114,6 +130,30 @@ function buildProps() {
114130
};
115131
}
116132

133+
function dispatchTouch(
134+
target: HTMLElement,
135+
type: "touchstart" | "touchmove" | "touchend",
136+
clientY: number,
137+
) {
138+
const touch = new Touch({ identifier: 1, target, clientY, clientX: 0 });
139+
target.dispatchEvent(
140+
new TouchEvent(type, {
141+
bubbles: true,
142+
touches: type === "touchend" ? [] : [touch],
143+
changedTouches: [touch],
144+
}),
145+
);
146+
}
147+
148+
// Native events dispatched outside React's act() flush their state updates on a
149+
// later task, so settle them before asserting a render *didn't* happen. Stays
150+
// well under USER_SCROLL_STICK_LOCK_MS so the lock is still held on the far side.
151+
function flushPendingRenders() {
152+
return new Promise<void>((resolve) => {
153+
window.setTimeout(resolve, 100);
154+
});
155+
}
156+
117157
async function resetBrowserHoverState() {
118158
const resetTarget = document.createElement("button");
119159
resetTarget.type = "button";
@@ -189,6 +229,7 @@ describe("MessagesTimeline", () => {
189229
afterEach(() => {
190230
scrollToEndSpy.mockReset();
191231
getStateSpy.mockClear();
232+
timelineScrollMetrics = AWAY_FROM_END_SCROLL_METRICS;
192233
useUiStateStore.setState({ threadChangedFilesExpandedById: {} });
193234
__resetClientSettingsPersistenceForTests();
194235
vi.restoreAllMocks();
@@ -708,6 +749,67 @@ describe("MessagesTimeline", () => {
708749
}
709750
});
710751

752+
it("keeps a touch drag disarmed while it is still inside the at-end tolerance", async () => {
753+
vi.spyOn(window, "requestAnimationFrame").mockImplementation(
754+
(callback: FrameRequestCallback) => {
755+
callback(0);
756+
return 1;
757+
},
758+
);
759+
vi.spyOn(window, "cancelAnimationFrame").mockImplementation(() => undefined);
760+
761+
const props = buildProps();
762+
const screen = await renderTimeline(
763+
<MessagesTimeline
764+
{...props}
765+
activeTurnInProgress
766+
isWorking
767+
timelineEntries={[buildUserTimelineEntry("Streaming reply in progress.")]}
768+
/>,
769+
);
770+
771+
try {
772+
const legendList = document.querySelector<HTMLElement>('[data-testid="legend-list"]');
773+
expect(legendList).not.toBeNull();
774+
expect(legendList?.getAttribute("data-maintain-scroll-at-end")).toBe("true");
775+
776+
dispatchTouch(legendList!, "touchstart", 400);
777+
dispatchTouch(legendList!, "touchmove", 412);
778+
779+
await vi.waitFor(() => {
780+
const list = document.querySelector<HTMLElement>('[data-testid="legend-list"]');
781+
expect(list?.getAttribute("data-maintain-scroll-at-end")).toBe("false");
782+
});
783+
784+
// The drag has moved further than the intent threshold but is still
785+
// within the at-end tolerance, so the list keeps reporting itself at the
786+
// end. Re-arming on those scroll events is what snapped the timeline back
787+
// under the finger.
788+
timelineScrollMetrics = NEAR_END_SCROLL_METRICS;
789+
scrollToEndSpy.mockClear();
790+
legendList?.dispatchEvent(new Event("scroll", { bubbles: true }));
791+
dispatchTouch(legendList!, "touchmove", 424);
792+
legendList?.dispatchEvent(new Event("scroll", { bubbles: true }));
793+
await flushPendingRenders();
794+
795+
expect(
796+
document
797+
.querySelector<HTMLElement>('[data-testid="legend-list"]')
798+
?.getAttribute("data-maintain-scroll-at-end"),
799+
).toBe("false");
800+
expect(scrollToEndSpy).not.toHaveBeenCalled();
801+
802+
// Lifting the finger at the bottom re-arms once the gesture settles.
803+
dispatchTouch(legendList!, "touchend", 424);
804+
await vi.waitFor(() => {
805+
const list = document.querySelector<HTMLElement>('[data-testid="legend-list"]');
806+
expect(list?.getAttribute("data-maintain-scroll-at-end")).toBe("true");
807+
});
808+
} finally {
809+
await screen.unmount();
810+
}
811+
});
812+
711813
it("exposes an edit-and-branch action on user messages", async () => {
712814
const onContinueInNewThread = vi.fn();
713815
const screen = await renderTimeline(

apps/web/src/components/chat/MessagesTimeline.tsx

Lines changed: 34 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -872,26 +872,34 @@ export const MessagesTimeline = memo(function MessagesTimeline({
872872
}, [clearUserScrollLockTimer, listRef, onIsAtEndChange, setAutoStickToBottomState]);
873873

874874
const enableAutoStickIfAtEnd = useCallback(() => {
875-
if (!isTimelineListAtEnd(listRef.current)) {
875+
// A finger still on the glass is mid-gesture even when it pauses, and a
876+
// paused drag that has only just left the bottom is still inside the at-end
877+
// tolerance. Re-arming here would yank the list back under the touch.
878+
// `handleTouchEndCapture` re-checks once the gesture lifts.
879+
if (touchStartYRef.current !== null || !isTimelineListAtEnd(listRef.current)) {
876880
return;
877881
}
878882
setAutoStickToBottomState(true);
879883
onIsAtEndChange(true);
880884
}, [listRef, onIsAtEndChange, setAutoStickToBottomState]);
881885

886+
const scheduleStickReArmCheck = useCallback(() => {
887+
clearUserScrollLockTimer();
888+
userScrollLockTimerRef.current = window.setTimeout(() => {
889+
userScrollLockTimerRef.current = null;
890+
enableAutoStickIfAtEnd();
891+
}, USER_SCROLL_STICK_LOCK_MS);
892+
}, [clearUserScrollLockTimer, enableAutoStickIfAtEnd]);
893+
882894
const markUserScrollIntent = useCallback(
883895
(options?: { notifyAwayFromEnd?: boolean }) => {
884-
clearUserScrollLockTimer();
885896
setAutoStickToBottomState(false);
886897
if (options?.notifyAwayFromEnd) {
887898
onIsAtEndChange(false);
888899
}
889-
userScrollLockTimerRef.current = window.setTimeout(() => {
890-
userScrollLockTimerRef.current = null;
891-
enableAutoStickIfAtEnd();
892-
}, USER_SCROLL_STICK_LOCK_MS);
900+
scheduleStickReArmCheck();
893901
},
894-
[clearUserScrollLockTimer, enableAutoStickIfAtEnd, onIsAtEndChange, setAutoStickToBottomState],
902+
[onIsAtEndChange, scheduleStickReArmCheck, setAutoStickToBottomState],
895903
);
896904

897905
const stickToBottomRequestPending =
@@ -917,14 +925,19 @@ export const MessagesTimeline = memo(function MessagesTimeline({
917925
onIsAtEndChange(true);
918926
return;
919927
}
920-
if (nextIsAtEnd) {
921-
clearUserScrollLockTimer();
928+
// Only re-arm from a scroll event once the user-scroll lock has expired.
929+
// A touch drag clears the intent threshold (a few px) long before it
930+
// clears the at-end tolerance (24px), so re-arming here would stick the
931+
// list back to the bottom under the moving finger, report at-end on the
932+
// next frame, and stick again — the list flickers instead of scrolling.
933+
// Pointer devices never hit this because one wheel notch clears the
934+
// tolerance outright. The lock timer re-checks once scrolling settles.
935+
if (nextIsAtEnd && userScrollLockTimerRef.current === null) {
922936
setAutoStickToBottomState(true);
923937
}
924938
onIsAtEndChange(nextIsAtEnd);
925939
},
926940
[
927-
clearUserScrollLockTimer,
928941
listRef,
929942
onIsAtEndChange,
930943
refreshTranscriptNoteHighlightRects,
@@ -1042,7 +1055,13 @@ export const MessagesTimeline = memo(function MessagesTimeline({
10421055

10431056
const handleTouchEndCapture = useCallback(() => {
10441057
touchStartYRef.current = null;
1045-
}, []);
1058+
if (autoStickToBottomRef.current) {
1059+
return;
1060+
}
1061+
// The gesture may have ended at the bottom, or momentum may still be
1062+
// running. Re-check after it settles rather than mid-flick.
1063+
scheduleStickReArmCheck();
1064+
}, [scheduleStickReArmCheck]);
10461065

10471066
const handleKeyDownCapture = useCallback(
10481067
(event: ReactKeyboardEvent) => {
@@ -1093,7 +1112,10 @@ export const MessagesTimeline = memo(function MessagesTimeline({
10931112

10941113
let cancelStickFrames: (() => void) | null = null;
10951114
const restickIfArmed = () => {
1096-
if (!autoStickToBottomRef.current) {
1115+
// Mobile browsers collapse and expand the URL bar as the user scrolls,
1116+
// which resizes the visual viewport mid-gesture. Never re-stick under a
1117+
// finger that is on the glass — the touch handlers own the scroll then.
1118+
if (!autoStickToBottomRef.current || touchStartYRef.current !== null) {
10971119
return;
10981120
}
10991121
cancelStickFrames?.();

0 commit comments

Comments
 (0)