From 718ee1e5fcb552d1253139abb1e86a3d9fcd2f97 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Sat, 1 Aug 2026 02:28:07 +0200 Subject: [PATCH] fix(timeline): load history after keyboard scrolling Treat observed upward displacement as user intent so keyboard and scrollbar navigation can wake dormant pagination. --- .../app/e2e/agent-timeline-pagination.spec.ts | 16 +++++++++ .../app/e2e/helpers/timeline-pagination.ts | 9 +++++ .../app/src/agent-stream/strategy-web.tsx | 36 ++++++++----------- 3 files changed, 40 insertions(+), 21 deletions(-) diff --git a/packages/app/e2e/agent-timeline-pagination.spec.ts b/packages/app/e2e/agent-timeline-pagination.spec.ts index 9b485caf3c..8cb1fdca6e 100644 --- a/packages/app/e2e/agent-timeline-pagination.spec.ts +++ b/packages/app/e2e/agent-timeline-pagination.spec.ts @@ -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(); diff --git a/packages/app/e2e/helpers/timeline-pagination.ts b/packages/app/e2e/helpers/timeline-pagination.ts index 866782521c..2d4320de59 100644 --- a/packages/app/e2e/helpers/timeline-pagination.ts +++ b/packages/app/e2e/helpers/timeline-pagination.ts @@ -446,6 +446,15 @@ export async function userScrollsTimelineToHistoryStart(page: Page): Promise { + 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 { const scroll = page.locator('[data-testid="agent-chat-scroll"]:visible').first(); await scroll.evaluate((element) => { diff --git a/packages/app/src/agent-stream/strategy-web.tsx b/packages/app/src/agent-stream/strategy-web.tsx index 3a83007e23..5faf2112d6 100644 --- a/packages/app/src/agent-stream/strategy-web.tsx +++ b/packages/app/src/agent-stream/strategy-web.tsx @@ -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(null); const pendingAutoScrollFrameRef = useRef(null); const pendingAutoScrollTimeoutRef = useRef(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);