From 6c3e2bd703a003f310d6d789ff4dd63f159e7552 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Tue, 9 Jun 2026 10:46:24 +0700 Subject: [PATCH] Fix sticky bottom anchoring on initial content growth --- .../bottom-anchor-controller.test.ts | 48 +++++++++++++++++++ .../agent-stream/bottom-anchor-controller.ts | 12 +++-- 2 files changed, 55 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 06bcc4cd5..56e9e9ff2 100644 --- a/packages/app/src/agent-stream/bottom-anchor-controller.test.ts +++ b/packages/app/src/agent-stream/bottom-anchor-controller.test.ts @@ -560,6 +560,54 @@ describe("bottom anchor controller driver", () => { expect(harness.driver.getSnapshot().mode).toBe("detached"); }); + + it("keeps initial native content growth anchored before layout scroll events arrive", () => { + const harness = createDriverHarness({ + transportBehavior: { + verificationDelayFrames: 2, + verificationRetryMode: "recheck", + }, + measurementState: createMeasurementState({ + containerKey: "native-virtualized", + viewportWidth: 0, + viewportHeight: 0, + contentHeight: 0, + offsetY: 0, + viewportMeasuredForKey: null, + contentMeasuredForKey: null, + }), + }); + harness.scrollToBottom.mockImplementation(() => { + harness.context.measurementState.offsetY = 0; + }); + + harness.context.measurementState.contentHeight = 1348; + harness.context.measurementState.contentMeasuredForKey = "native-virtualized"; + harness.driver.handleContentSizeChange({ + previousContentHeight: 0, + contentHeight: 1348, + }); + + expect(harness.scrollToBottom).toHaveBeenCalledTimes(1); + expect(harness.driver.getSnapshot()).toMatchObject({ + mode: "sticky-bottom", + pendingVerification: { + requestId: null, + }, + }); + + harness.context.measurementState.viewportWidth = 390; + harness.context.measurementState.viewportHeight = 546; + harness.context.measurementState.viewportMeasuredForKey = "native-virtualized"; + harness.context.measurementState.offsetY = 50; + harness.context.nearBottom = false; + harness.driver.handleScrollNearBottomChange({ + nextIsNearBottom: false, + scrollDelta: 50, + }); + + expect(harness.driver.getSnapshot().mode).toBe("sticky-bottom"); + }); }); describe("controller helper predicates", () => { diff --git a/packages/app/src/agent-stream/bottom-anchor-controller.ts b/packages/app/src/agent-stream/bottom-anchor-controller.ts index dd78dd5f7..8cab0e4e5 100644 --- a/packages/app/src/agent-stream/bottom-anchor-controller.ts +++ b/packages/app/src/agent-stream/bottom-anchor-controller.ts @@ -560,6 +560,12 @@ function createBottomAnchorControllerDriver( }); if (shouldRestick && !pendingRequest) { pendingVerification = { requestId: null, retries: 0 }; + if (attemptHandle) { + input.cancelFrame(attemptHandle); + attemptHandle = null; + } + runAttempt(false); + return; } if (shouldRestick || pendingRequest) { evaluate(false, "content_size_change"); @@ -656,11 +662,7 @@ export const __private__ = { previousContentHeight: number; contentHeight: number; }): boolean { - return ( - input.mode === "sticky-bottom" && - input.previousContentHeight > 0 && - input.contentHeight > input.previousContentHeight - ); + return input.mode === "sticky-bottom" && input.contentHeight > input.previousContentHeight; }, shouldDetachFromScrollAway(input: { mode: BottomAnchorMode;