@@ -13,6 +13,23 @@ import { __resetClientSettingsPersistenceForTests } from "../../hooks/useSetting
1313const scrollToEndSpy = vi . fn ( ) ;
1414const 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+
1633vi . 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+
117157async 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 (
0 commit comments