diff --git a/packages/app/src/agent-stream/strategy-web.test.tsx b/packages/app/src/agent-stream/strategy-web.test.tsx index 7bd0c5f95..f407fefbb 100644 --- a/packages/app/src/agent-stream/strategy-web.test.tsx +++ b/packages/app/src/agent-stream/strategy-web.test.tsx @@ -199,6 +199,89 @@ describe("createWebStreamStrategy", () => { expect(renderLiveHeadRow).toHaveBeenCalledTimes(2); }); + it("keeps bottom anchoring through subpixel browser rounding", () => { + const scrollTo = vi.fn(); + HTMLElement.prototype.scrollTo = scrollTo; + const strategy = createWebStreamStrategy({ isMobileBreakpoint: false }); + const viewportRef = React.createRef(); + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + + act(() => { + root?.render( + strategy.render({ + agentId: "agent", + segments: { + historyVirtualized: [], + historyMounted: [userMessage(1)], + liveHead: [], + }, + boundary: { + hasVirtualizedHistory: false, + hasMountedHistory: true, + hasLiveHead: false, + }, + renderers: createRenderers(vi.fn()), + listEmptyComponent: null, + viewportRef, + routeBottomAnchorRequest: null, + isAuthoritativeHistoryReady: true, + onNearBottomChange: vi.fn(), + onNearHistoryStart: vi.fn(), + isLoadingOlderHistory: false, + hasOlderHistory: false, + scrollEnabled: true, + listStyle: null, + baseListContentContainerStyle: null, + forwardListContentContainerStyle: null, + }), + ); + }); + + const scrollContainer = container.querySelector('[data-testid="agent-chat-scroll"]'); + if (!(scrollContainer instanceof HTMLElement)) { + throw new Error("Expected agent chat scroll container"); + } + Object.defineProperty(scrollContainer, "clientHeight", { configurable: true, value: 766 }); + Object.defineProperty(scrollContainer, "scrollHeight", { configurable: true, value: 5725 }); + Object.defineProperty(scrollContainer, "scrollTop", { + configurable: true, + value: 4959.1708984375, + }); + scrollTo.mockClear(); + + act(() => { + viewportRef.current?.scrollToBottom("message-sent"); + }); + + expect(scrollTo).toHaveBeenCalledWith({ top: 5725, behavior: "auto" }); + + scrollTo.mockClear(); + Object.defineProperty(scrollContainer, "scrollTop", { + configurable: true, + value: 4960.5, + }); + + act(() => { + viewportRef.current?.scrollToBottom("message-sent"); + }); + + expect(scrollTo).toHaveBeenCalledWith({ top: 5725, behavior: "auto" }); + + scrollTo.mockClear(); + Object.defineProperty(scrollContainer, "scrollTop", { + configurable: true, + value: 4967, + }); + + act(() => { + viewportRef.current?.scrollToBottom("message-sent"); + }); + + expect(scrollTo).not.toHaveBeenCalled(); + }); + it("fires near-history-start when the user scrolls near the top", async () => { const strategy = createWebStreamStrategy({ isMobileBreakpoint: true }); const viewportRef = React.createRef(); diff --git a/packages/app/src/agent-stream/strategy-web.tsx b/packages/app/src/agent-stream/strategy-web.tsx index bc5c11604..abac0fac1 100644 --- a/packages/app/src/agent-stream/strategy-web.tsx +++ b/packages/app/src/agent-stream/strategy-web.tsx @@ -22,6 +22,7 @@ type ScrollBehaviorLike = "auto" | "smooth"; const WEB_BOTTOM_SETTLE_TIMEOUT_MS = 200; const USER_SCROLL_DELTA_EPSILON = 1; +const BOTTOM_OVERSCROLL_TOLERANCE_PX = 2; const AUTO_SCROLL_BOTTOM_THRESHOLD_PX = 64; const AUTO_SCROLL_RESUME_THRESHOLD_PX = 1; const HISTORY_START_THRESHOLD_PX = 96; @@ -88,7 +89,8 @@ function getScrollContainerDistanceFromBottom( function isScrollContainerOverscrolledPastBottom( scrollContainer: Pick, ): boolean { - return getScrollContainerDistanceFromBottom(scrollContainer) < 0; + // Browser zoom can leave scrollTop fractional while the height metrics remain integer-valued. + return getScrollContainerDistanceFromBottom(scrollContainer) < -BOTTOM_OVERSCROLL_TOLERANCE_PX; } function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: boolean }) {