Fix web chat stickiness at non-default zoom (#2368)
Treat subpixel browser scroll rounding as the visual bottom while preserving material overscroll protection.
This commit is contained in:
@@ -199,6 +199,89 @@ describe("createWebStreamStrategy", () => {
|
|||||||
expect(renderLiveHeadRow).toHaveBeenCalledTimes(2);
|
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<StreamViewportHandle>();
|
||||||
|
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 () => {
|
it("fires near-history-start when the user scrolls near the top", async () => {
|
||||||
const strategy = createWebStreamStrategy({ isMobileBreakpoint: true });
|
const strategy = createWebStreamStrategy({ isMobileBreakpoint: true });
|
||||||
const viewportRef = React.createRef<StreamViewportHandle>();
|
const viewportRef = React.createRef<StreamViewportHandle>();
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ type ScrollBehaviorLike = "auto" | "smooth";
|
|||||||
|
|
||||||
const WEB_BOTTOM_SETTLE_TIMEOUT_MS = 200;
|
const WEB_BOTTOM_SETTLE_TIMEOUT_MS = 200;
|
||||||
const USER_SCROLL_DELTA_EPSILON = 1;
|
const USER_SCROLL_DELTA_EPSILON = 1;
|
||||||
|
const BOTTOM_OVERSCROLL_TOLERANCE_PX = 2;
|
||||||
const AUTO_SCROLL_BOTTOM_THRESHOLD_PX = 64;
|
const AUTO_SCROLL_BOTTOM_THRESHOLD_PX = 64;
|
||||||
const AUTO_SCROLL_RESUME_THRESHOLD_PX = 1;
|
const AUTO_SCROLL_RESUME_THRESHOLD_PX = 1;
|
||||||
const HISTORY_START_THRESHOLD_PX = 96;
|
const HISTORY_START_THRESHOLD_PX = 96;
|
||||||
@@ -88,7 +89,8 @@ function getScrollContainerDistanceFromBottom(
|
|||||||
function isScrollContainerOverscrolledPastBottom(
|
function isScrollContainerOverscrolledPastBottom(
|
||||||
scrollContainer: Pick<HTMLElement, "scrollTop" | "clientHeight" | "scrollHeight">,
|
scrollContainer: Pick<HTMLElement, "scrollTop" | "clientHeight" | "scrollHeight">,
|
||||||
): boolean {
|
): 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 }) {
|
function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: boolean }) {
|
||||||
|
|||||||
Reference in New Issue
Block a user