fix(timeline): load history after keyboard scrolling

Treat observed upward displacement as user intent so keyboard and scrollbar navigation can wake dormant pagination.
This commit is contained in:
Mohamed Boudra
2026-08-01 02:28:07 +02:00
parent 8d285ad026
commit 718ee1e5fc
3 changed files with 40 additions and 21 deletions
@@ -24,6 +24,7 @@ import {
seedRealisticOlderTimeline,
sendLiveTurnBeforeHydration,
expectTimelineViewportAnchoredAfterPrepend,
userNavigatesTimelineToHistoryStartWithKeyboard,
userScrollsTimelineToHistoryStart,
} from "./helpers/timeline-pagination";
@@ -72,6 +73,21 @@ test.describe("Agent timeline pagination", () => {
}
});
test("loads older history when the user navigates to history start with the keyboard", async ({
page,
}) => {
const agent = await seedLongMockAgentTimeline({ turns: 40 });
try {
const history = await holdOlderHistoryPages(page, agent);
await openAgentTimeline(page, agent);
await userNavigatesTimelineToHistoryStartWithKeyboard(page);
await history.expectRequestedPages(1);
} finally {
await agent.cleanup();
}
});
test("does not repeat an assistant block that spans older history pages", async ({ page }) => {
test.setTimeout(120_000);
const agent = await seedRealisticOlderTimeline();
@@ -446,6 +446,15 @@ export async function userScrollsTimelineToHistoryStart(page: Page): Promise<voi
.toBeLessThanOrEqual(HISTORY_START_THRESHOLD_PX);
}
export async function userNavigatesTimelineToHistoryStartWithKeyboard(page: Page): Promise<void> {
const scroll = page.locator('[data-testid="agent-chat-scroll"]:visible').first();
await scroll.focus();
await page.keyboard.press("Home");
await expect
.poll(async () => (await readTimelineViewport(page)).scrollTop)
.toBeLessThanOrEqual(HISTORY_START_THRESHOLD_PX);
}
export async function scrollTimelineToNewestLoadedEdge(page: Page): Promise<void> {
const scroll = page.locator('[data-testid="agent-chat-scroll"]:visible').first();
await scroll.evaluate((element) => {
+15 -21
View File
@@ -170,7 +170,6 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
};
const lastKnownScrollTopRef = useRef(0);
const pendingUserScrollUpIntentRef = useRef(false);
const isPointerScrollActiveRef = useRef(false);
const lastTouchClientYRef = useRef<number | null>(null);
const pendingAutoScrollFrameRef = useRef<number | null>(null);
const pendingAutoScrollTimeoutRef = useRef<number | null>(null);
@@ -489,26 +488,33 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
const scrolledDown =
currentScrollTop > lastKnownScrollTopRef.current + USER_SCROLL_DELTA_EPSILON;
if (!followOutputRef.current && isAtBottom && scrolledDown) {
if (scrolledUp) {
cancelPendingStickToBottom();
if (followOutputRef.current) {
setFollowOutput(false);
}
pendingUserScrollUpIntentRef.current = false;
rearmHistoryStartFromUserIntent();
} else if (!followOutputRef.current && isAtBottom && scrolledDown) {
setFollowOutput(true);
pendingUserScrollUpIntentRef.current = false;
} else if (followOutputRef.current && pendingUserScrollUpIntentRef.current) {
if (scrolledUp || !isAtBottom) {
if (!isAtBottom) {
cancelPendingStickToBottom();
setFollowOutput(false);
}
pendingUserScrollUpIntentRef.current = false;
} else if (followOutputRef.current && isPointerScrollActiveRef.current) {
if (scrolledUp) {
cancelPendingStickToBottom();
setFollowOutput(false);
}
}
lastKnownScrollTopRef.current = currentScrollTop;
updateScrollMetrics();
evaluateHistoryStart();
}, [cancelPendingStickToBottom, evaluateHistoryStart, updateScrollMetrics]);
}, [
cancelPendingStickToBottom,
evaluateHistoryStart,
rearmHistoryStartFromUserIntent,
updateScrollMetrics,
]);
useEffect(() => {
const initialHistoryStartState = createHistoryStartPaginationState();
@@ -649,12 +655,6 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
rearmHistoryStartFromUserIntent();
}
};
const handlePointerDown = () => {
isPointerScrollActiveRef.current = true;
};
const handlePointerUp = () => {
isPointerScrollActiveRef.current = false;
};
const handleTouchStart = (event: TouchEvent) => {
const touch = event.touches[0];
if (!touch) {
@@ -681,9 +681,6 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
scrollContainer.addEventListener("scroll", handleDomScroll, { passive: true });
scrollContainer.addEventListener("wheel", handleWheel, { passive: true });
scrollContainer.addEventListener("pointerdown", handlePointerDown, { passive: true });
scrollContainer.addEventListener("pointerup", handlePointerUp, { passive: true });
scrollContainer.addEventListener("pointercancel", handlePointerUp, { passive: true });
scrollContainer.addEventListener("touchstart", handleTouchStart, { passive: true });
scrollContainer.addEventListener("touchmove", handleTouchMove, { passive: true });
scrollContainer.addEventListener("touchend", handleTouchEnd, { passive: true });
@@ -692,9 +689,6 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
return () => {
scrollContainer.removeEventListener("scroll", handleDomScroll);
scrollContainer.removeEventListener("wheel", handleWheel);
scrollContainer.removeEventListener("pointerdown", handlePointerDown);
scrollContainer.removeEventListener("pointerup", handlePointerUp);
scrollContainer.removeEventListener("pointercancel", handlePointerUp);
scrollContainer.removeEventListener("touchstart", handleTouchStart);
scrollContainer.removeEventListener("touchmove", handleTouchMove);
scrollContainer.removeEventListener("touchend", handleTouchEnd);