diff --git a/packages/app/src/agent-stream/bottom-anchor-controller.test.ts b/packages/app/src/agent-stream/bottom-anchor-controller.test.ts index f37b79a23..43fefa606 100644 --- a/packages/app/src/agent-stream/bottom-anchor-controller.test.ts +++ b/packages/app/src/agent-stream/bottom-anchor-controller.test.ts @@ -774,4 +774,18 @@ describe("controller helper predicates", () => { }), ).toBe(true); }); + + it("lets an active user drag override pending sticky verification", () => { + expect( + __private__.shouldDetachFromScrollAway({ + mode: "sticky-bottom", + nextIsNearBottom: false, + scrollDelta: 48, + hasPendingRequest: false, + hasPendingVerification: true, + hasUnverifiedStickyMeasurementChange: true, + isUserScroll: true, + }), + ).toBe(true); + }); }); diff --git a/packages/app/src/agent-stream/bottom-anchor-controller.ts b/packages/app/src/agent-stream/bottom-anchor-controller.ts index b9f77c13c..c71c80ad3 100644 --- a/packages/app/src/agent-stream/bottom-anchor-controller.ts +++ b/packages/app/src/agent-stream/bottom-anchor-controller.ts @@ -84,6 +84,7 @@ interface BottomAnchorControllerDriver { handleScrollNearBottomChange: (params: { nextIsNearBottom: boolean; scrollDelta: number; + isUserScroll?: boolean; }) => void; notifyAuthoritativeHistoryMaybeChanged: () => void; reevaluate: (animated?: boolean) => void; @@ -570,7 +571,7 @@ function createBottomAnchorControllerDriver( evaluate(false, "content_size_change"); }, handleScrollNearBottomChange(params) { - const { nextIsNearBottom, scrollDelta } = params; + const { nextIsNearBottom, scrollDelta, isUserScroll = false } = params; if ( nextIsNearBottom && mode === "sticky-bottom" && @@ -588,6 +589,7 @@ function createBottomAnchorControllerDriver( hasPendingRequest: pendingRequest !== null, hasPendingVerification: pendingVerification !== null, hasUnverifiedStickyMeasurementChange, + isUserScroll, }) ) { this.detachByUser(); @@ -657,14 +659,16 @@ export const __private__ = { hasPendingRequest: boolean; hasPendingVerification: boolean; hasUnverifiedStickyMeasurementChange: boolean; + isUserScroll?: boolean; }): boolean { const scrolledAwayIntentionally = Math.abs(input.scrollDelta) >= USER_SCROLL_AWAY_DELTA_PX; return ( input.mode === "sticky-bottom" && !input.nextIsNearBottom && - !input.hasPendingRequest && - !input.hasPendingVerification && - (!input.hasUnverifiedStickyMeasurementChange || scrolledAwayIntentionally) + (input.isUserScroll || + (!input.hasPendingRequest && + !input.hasPendingVerification && + (!input.hasUnverifiedStickyMeasurementChange || scrolledAwayIntentionally))) ); }, }; @@ -758,7 +762,11 @@ export function useBottomAnchorController(input: { prepareForStickyContentChange() { driverRef.current?.prepareForStickyContentChange(); }, - handleScrollNearBottomChange(params: { nextIsNearBottom: boolean; scrollDelta: number }) { + handleScrollNearBottomChange(params: { + nextIsNearBottom: boolean; + scrollDelta: number; + isUserScroll?: boolean; + }) { driverRef.current?.handleScrollNearBottomChange(params); }, reevaluate(animated = false) { diff --git a/packages/app/src/agent-stream/strategy-native.tsx b/packages/app/src/agent-stream/strategy-native.tsx index 4463aae6f..fd34a11d5 100644 --- a/packages/app/src/agent-stream/strategy-native.tsx +++ b/packages/app/src/agent-stream/strategy-native.tsx @@ -89,6 +89,7 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat contentMeasuredForKey: null as string | null, }); const scrollOffsetYRef = useRef(0); + const isUserScrollActiveRef = useRef(false); const programmaticScrollEventBudgetRef = useRef(0); const [isNativeViewportSettling, setIsNativeViewportSettling] = useState(false); const nativeViewportSettlingFrameIdRef = useRef(null); @@ -208,6 +209,7 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat contentMeasuredForKey: null, }; scrollOffsetYRef.current = 0; + isUserScrollActiveRef.current = false; clearNativeViewportSettling(); setIsNativeViewportSettling(false); historyStartReadyRef.current = false; @@ -308,10 +310,27 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat bottomAnchorController.handleScrollNearBottomChange({ nextIsNearBottom: nearBottom, scrollDelta: contentOffset.y - previousOffsetY, + isUserScroll: isUserScrollActiveRef.current, }); } }); + const handleScrollBeginDrag = useStableEvent(() => { + isUserScrollActiveRef.current = true; + }); + + const handleScrollEndDrag = useStableEvent(() => { + isUserScrollActiveRef.current = false; + }); + + const handleMomentumScrollBegin = useStableEvent(() => { + isUserScrollActiveRef.current = true; + }); + + const handleMomentumScrollEnd = useStableEvent(() => { + isUserScrollActiveRef.current = false; + }); + const handleListLayout = useStableEvent((event: LayoutChangeEvent) => { const previousViewportWidth = streamViewportMetricsRef.current.viewportWidth; const previousViewportHeight = streamViewportMetricsRef.current.viewportHeight; @@ -419,6 +438,10 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat style={listStyle} onLayout={handleListLayout} onScroll={handleScroll} + onScrollBeginDrag={handleScrollBeginDrag} + onScrollEndDrag={handleScrollEndDrag} + onMomentumScrollBegin={handleMomentumScrollBegin} + onMomentumScrollEnd={handleMomentumScrollEnd} scrollEventThrottle={16} onContentSizeChange={handleContentSizeChange} maintainVisibleContentPosition={maintainVisibleContentPosition}