Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions packages/app/src/agent-stream/bottom-anchor-controller.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
40 changes: 40 additions & 0 deletions packages/app/src/agent-stream/bottom-anchor-controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -243,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) {
Expand Down Expand Up @@ -481,6 +484,7 @@ function createBottomAnchorControllerDriver(
cancelPendingAttempt();
stickyMeasurementRevision = 0;
lastVerifiedStickyMeasurementRevision = 0;
isUserScrollActive = false;
mode = "sticky-bottom";
input.onModeChange("sticky-bottom");
},
Expand All @@ -497,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;
Expand All @@ -511,6 +530,9 @@ function createBottomAnchorControllerDriver(
) {
markStickyMeasurementChanged();
}
if (isUserScrollActive) {
return;
}
const shouldRestick = __private__.shouldRestickOnViewportChange({
mode,
previousViewportWidth: params.previousViewportWidth,
Expand All @@ -529,6 +551,9 @@ function createBottomAnchorControllerDriver(
if (params.previousContentHeight !== params.contentHeight) {
markStickyMeasurementChanged();
}
if (isUserScrollActive) {
return;
}
const shouldRestick = __private__.shouldRestickOnContentChange({
mode,
previousContentHeight: params.previousContentHeight,
Expand Down Expand Up @@ -558,6 +583,9 @@ function createBottomAnchorControllerDriver(
return;
}
markStickyMeasurementChanged();
if (isUserScrollActive) {
return;
}
if (!pendingRequest) {
pendingVerification = { requestId: null, retries: 0 };
if (attemptHandle) {
Expand All @@ -571,6 +599,12 @@ function createBottomAnchorControllerDriver(
},
handleScrollNearBottomChange(params) {
const { nextIsNearBottom, scrollDelta } = params;
if (isUserScrollActive) {
if (mode === "sticky-bottom" && !nextIsNearBottom) {
this.detachByUser();
}
return;
}
if (
nextIsNearBottom &&
mode === "sticky-bottom" &&
Expand Down Expand Up @@ -737,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();
},
Expand Down
49 changes: 47 additions & 2 deletions packages/app/src/agent-stream/strategy-native.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,8 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
contentMeasuredForKey: null as string | null,
});
const scrollOffsetYRef = useRef(0);
const isUserScrollActiveRef = useRef(false);
const userScrollEndFrameIdRef = useRef<number | null>(null);
const programmaticScrollEventBudgetRef = useRef(0);
const [isNativeViewportSettling, setIsNativeViewportSettling] = useState(false);
const nativeViewportSettlingFrameIdRef = useRef<number | null>(null);
Expand Down Expand Up @@ -129,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);
Expand Down Expand Up @@ -208,6 +217,8 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
contentMeasuredForKey: null,
};
scrollOffsetYRef.current = 0;
isUserScrollActiveRef.current = false;
clearPendingUserScrollEnd();
clearNativeViewportSettling();
setIsNativeViewportSettling(false);
historyStartReadyRef.current = false;
Expand All @@ -216,8 +227,9 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
});
return () => {
cancelAnimationFrame(frame);
clearPendingUserScrollEnd();
};
}, [agentId, clearNativeViewportSettling]);
}, [agentId, clearNativeViewportSettling, clearPendingUserScrollEnd]);

useEffect(() => {
const keyboardEvents = [
Expand Down Expand Up @@ -301,7 +313,11 @@ 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;
Expand All @@ -312,6 +328,31 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
}
});

const handleScrollBeginDrag = useStableEvent(() => {
clearPendingUserScrollEnd();
isUserScrollActiveRef.current = true;
bottomAnchorController.beginUserScroll();
});

const handleScrollEndDrag = useStableEvent(() => {
clearPendingUserScrollEnd();
userScrollEndFrameIdRef.current = requestAnimationFrame(() => {
userScrollEndFrameIdRef.current = null;
isUserScrollActiveRef.current = false;
bottomAnchorController.endUserScroll();
});
});

const handleMomentumScrollBegin = useStableEvent(() => {
clearPendingUserScrollEnd();
});

const handleMomentumScrollEnd = useStableEvent(() => {
clearPendingUserScrollEnd();
isUserScrollActiveRef.current = false;
bottomAnchorController.endUserScroll();
});
Comment thread
greptile-apps[bot] marked this conversation as resolved.

const handleListLayout = useStableEvent((event: LayoutChangeEvent) => {
const previousViewportWidth = streamViewportMetricsRef.current.viewportWidth;
const previousViewportHeight = streamViewportMetricsRef.current.viewportHeight;
Expand Down Expand Up @@ -419,6 +460,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}
Expand Down
Loading