From c81c7bb7c09ee9fd30269adc111b01f0f2f0765a Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Thu, 23 Jul 2026 00:35:08 +0200 Subject: [PATCH 1/2] fix(app): keep chat scrollable during streaming Continuous Android stream updates kept bottom-anchor verification pending, so genuine finger drags were mistaken for programmatic corrections. Preserve explicit drag ownership so user scrolling can detach immediately. --- .../bottom-anchor-controller.test.ts | 14 +++++++++++ .../agent-stream/bottom-anchor-controller.ts | 18 +++++++++++---- .../app/src/agent-stream/strategy-native.tsx | 23 +++++++++++++++++++ 3 files changed, 50 insertions(+), 5 deletions(-) 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 f37b79a23e..43fefa606d 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 b9f77c13cf..c71c80ad33 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 4463aae6fd..fd34a11d50 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} From f00a940e74647d71e28aefb8fc5324bd8b097ec5 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Thu, 23 Jul 2026 01:04:00 +0200 Subject: [PATCH 2/2] fix(app): preserve scroll ownership through momentum --- .../bottom-anchor-controller.test.ts | 43 +++++++++----- .../agent-stream/bottom-anchor-controller.ts | 58 ++++++++++++++----- .../app/src/agent-stream/strategy-native.tsx | 32 ++++++++-- 3 files changed, 101 insertions(+), 32 deletions(-) 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 43fefa606d..7a701294fa 100644 --- a/packages/app/src/agent-stream/bottom-anchor-controller.test.ts +++ b/packages/app/src/agent-stream/bottom-anchor-controller.test.ts @@ -257,6 +257,35 @@ describe("bottom anchor controller driver", () => { expect(harness.scrollToBottom).not.toHaveBeenCalled(); }); + it("pauses sticky maintenance while a user scroll owns the viewport", () => { + const harness = createDriverHarness({ + transportBehavior: { + verificationDelayFrames: 2, + verificationRetryMode: "recheck", + }, + }); + + harness.driver.prepareForStickyContentChange(); + harness.driver.beginUserScroll(); + harness.driver.handleContentSizeChange({ + previousContentHeight: 1200, + contentHeight: 1400, + }); + harness.context.nearBottom = false; + harness.driver.handleScrollNearBottomChange({ + nextIsNearBottom: false, + scrollDelta: 1, + }); + harness.scheduler.flushAll(); + + expect(harness.scrollToBottom).toHaveBeenCalledTimes(1); + expect(harness.driver.getSnapshot()).toMatchObject({ + mode: "detached", + pendingRequest: null, + pendingVerification: null, + }); + }); + it("switches back to sticky-bottom for explicit jump-to-bottom", () => { const harness = createDriverHarness({ isNearBottom: false, @@ -774,18 +803,4 @@ 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 c71c80ad33..895343194a 100644 --- a/packages/app/src/agent-stream/bottom-anchor-controller.ts +++ b/packages/app/src/agent-stream/bottom-anchor-controller.ts @@ -68,6 +68,8 @@ interface BottomAnchorControllerDriver { resetForAgent: () => void; applyRouteRequest: (request: BottomAnchorRouteRequest | null) => void; requestLocalAnchor: (request: BottomAnchorLocalRequest) => void; + beginUserScroll: () => void; + endUserScroll: () => void; detachByUser: () => void; handleViewportMetricsChange: (params: { previousViewportWidth: number; @@ -84,7 +86,6 @@ interface BottomAnchorControllerDriver { handleScrollNearBottomChange: (params: { nextIsNearBottom: boolean; scrollDelta: number; - isUserScroll?: boolean; }) => void; notifyAuthoritativeHistoryMaybeChanged: () => void; reevaluate: (animated?: boolean) => void; @@ -244,6 +245,7 @@ function createBottomAnchorControllerDriver( let lastRouteRequestKey: string | null = null; let stickyMeasurementRevision = 0; let lastVerifiedStickyMeasurementRevision = 0; + let isUserScrollActive = false; const setBlockedReason = (nextBlockedReason: BottomAnchorBlockedReason | null) => { if (blockedReason === nextBlockedReason) { @@ -482,6 +484,7 @@ function createBottomAnchorControllerDriver( cancelPendingAttempt(); stickyMeasurementRevision = 0; lastVerifiedStickyMeasurementRevision = 0; + isUserScrollActive = false; mode = "sticky-bottom"; input.onModeChange("sticky-bottom"); }, @@ -498,6 +501,21 @@ function createBottomAnchorControllerDriver( requestLocalAnchor(request) { createRequest(request); }, + beginUserScroll() { + isUserScrollActive = true; + cancelPendingRequest("user_scroll_started"); + }, + endUserScroll() { + isUserScrollActive = false; + if (mode !== "sticky-bottom") { + return; + } + if (input.isNearBottom()) { + markStickyMeasurementVerified(); + return; + } + this.detachByUser(); + }, detachByUser() { if (mode === "detached") { return; @@ -512,6 +530,9 @@ function createBottomAnchorControllerDriver( ) { markStickyMeasurementChanged(); } + if (isUserScrollActive) { + return; + } const shouldRestick = __private__.shouldRestickOnViewportChange({ mode, previousViewportWidth: params.previousViewportWidth, @@ -530,6 +551,9 @@ function createBottomAnchorControllerDriver( if (params.previousContentHeight !== params.contentHeight) { markStickyMeasurementChanged(); } + if (isUserScrollActive) { + return; + } const shouldRestick = __private__.shouldRestickOnContentChange({ mode, previousContentHeight: params.previousContentHeight, @@ -559,6 +583,9 @@ function createBottomAnchorControllerDriver( return; } markStickyMeasurementChanged(); + if (isUserScrollActive) { + return; + } if (!pendingRequest) { pendingVerification = { requestId: null, retries: 0 }; if (attemptHandle) { @@ -571,7 +598,13 @@ function createBottomAnchorControllerDriver( evaluate(false, "content_size_change"); }, handleScrollNearBottomChange(params) { - const { nextIsNearBottom, scrollDelta, isUserScroll = false } = params; + const { nextIsNearBottom, scrollDelta } = params; + if (isUserScrollActive) { + if (mode === "sticky-bottom" && !nextIsNearBottom) { + this.detachByUser(); + } + return; + } if ( nextIsNearBottom && mode === "sticky-bottom" && @@ -589,7 +622,6 @@ function createBottomAnchorControllerDriver( hasPendingRequest: pendingRequest !== null, hasPendingVerification: pendingVerification !== null, hasUnverifiedStickyMeasurementChange, - isUserScroll, }) ) { this.detachByUser(); @@ -659,16 +691,14 @@ 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.isUserScroll || - (!input.hasPendingRequest && - !input.hasPendingVerification && - (!input.hasUnverifiedStickyMeasurementChange || scrolledAwayIntentionally))) + !input.hasPendingRequest && + !input.hasPendingVerification && + (!input.hasUnverifiedStickyMeasurementChange || scrolledAwayIntentionally) ); }, }; @@ -741,6 +771,12 @@ export function useBottomAnchorController(input: { requestLocalAnchor(request: BottomAnchorLocalRequest) { driverRef.current?.requestLocalAnchor(request); }, + beginUserScroll() { + driverRef.current?.beginUserScroll(); + }, + endUserScroll() { + driverRef.current?.endUserScroll(); + }, detachByUser() { driverRef.current?.detachByUser(); }, @@ -762,11 +798,7 @@ export function useBottomAnchorController(input: { prepareForStickyContentChange() { driverRef.current?.prepareForStickyContentChange(); }, - handleScrollNearBottomChange(params: { - nextIsNearBottom: boolean; - scrollDelta: number; - isUserScroll?: boolean; - }) { + handleScrollNearBottomChange(params: { nextIsNearBottom: boolean; scrollDelta: number }) { 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 fd34a11d50..ef710f53f4 100644 --- a/packages/app/src/agent-stream/strategy-native.tsx +++ b/packages/app/src/agent-stream/strategy-native.tsx @@ -90,6 +90,7 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat }); const scrollOffsetYRef = useRef(0); const isUserScrollActiveRef = useRef(false); + const userScrollEndFrameIdRef = useRef(null); const programmaticScrollEventBudgetRef = useRef(0); const [isNativeViewportSettling, setIsNativeViewportSettling] = useState(false); const nativeViewportSettlingFrameIdRef = useRef(null); @@ -130,6 +131,13 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat } }, []); + const clearPendingUserScrollEnd = useCallback(() => { + if (userScrollEndFrameIdRef.current !== null) { + cancelAnimationFrame(userScrollEndFrameIdRef.current); + userScrollEndFrameIdRef.current = null; + } + }, []); + const markNativeViewportSettling = useCallback(() => { clearNativeViewportSettling(); setIsNativeViewportSettling(true); @@ -210,6 +218,7 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat }; scrollOffsetYRef.current = 0; isUserScrollActiveRef.current = false; + clearPendingUserScrollEnd(); clearNativeViewportSettling(); setIsNativeViewportSettling(false); historyStartReadyRef.current = false; @@ -218,8 +227,9 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat }); return () => { cancelAnimationFrame(frame); + clearPendingUserScrollEnd(); }; - }, [agentId, clearNativeViewportSettling]); + }, [agentId, clearNativeViewportSettling, clearPendingUserScrollEnd]); useEffect(() => { const keyboardEvents = [ @@ -303,32 +313,44 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat onNearHistoryStart(); } - if (programmaticScrollEventBudgetRef.current > 0 && contentOffset.y <= 8) { + if ( + !isUserScrollActiveRef.current && + programmaticScrollEventBudgetRef.current > 0 && + contentOffset.y <= 8 + ) { programmaticScrollEventBudgetRef.current -= 1; } else { programmaticScrollEventBudgetRef.current = 0; bottomAnchorController.handleScrollNearBottomChange({ nextIsNearBottom: nearBottom, scrollDelta: contentOffset.y - previousOffsetY, - isUserScroll: isUserScrollActiveRef.current, }); } }); const handleScrollBeginDrag = useStableEvent(() => { + clearPendingUserScrollEnd(); isUserScrollActiveRef.current = true; + bottomAnchorController.beginUserScroll(); }); const handleScrollEndDrag = useStableEvent(() => { - isUserScrollActiveRef.current = false; + clearPendingUserScrollEnd(); + userScrollEndFrameIdRef.current = requestAnimationFrame(() => { + userScrollEndFrameIdRef.current = null; + isUserScrollActiveRef.current = false; + bottomAnchorController.endUserScroll(); + }); }); const handleMomentumScrollBegin = useStableEvent(() => { - isUserScrollActiveRef.current = true; + clearPendingUserScrollEnd(); }); const handleMomentumScrollEnd = useStableEvent(() => { + clearPendingUserScrollEnd(); isUserScrollActiveRef.current = false; + bottomAnchorController.endUserScroll(); }); const handleListLayout = useStableEvent((event: LayoutChangeEvent) => {