From 139e6c9b9a447b4e5c9fe0620ea0627cca284b36 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Mon, 27 Jul 2026 19:01:18 +0200 Subject: [PATCH] fix(app): stabilize history pagination edges --- .../app/e2e/agent-timeline-pagination.spec.ts | 27 +++ .../app/e2e/helpers/timeline-pagination.ts | 36 ++++ .../history-start-pagination.test.ts | 20 ++ .../agent-stream/history-start-pagination.ts | 6 + .../app/src/agent-stream/strategy-native.tsx | 7 + .../src/agent-stream/strategy-web.test.tsx | 173 ++++++++++++++++++ .../app/src/agent-stream/strategy-web.tsx | 66 +++++-- .../assistant-image/use-assistant-image.ts | 1 + packages/app/src/attachments/utils.test.ts | 26 +++ packages/app/src/attachments/utils.ts | 7 +- .../app/src/components/ui/loading-spinner.tsx | 1 + 11 files changed, 354 insertions(+), 16 deletions(-) diff --git a/packages/app/e2e/agent-timeline-pagination.spec.ts b/packages/app/e2e/agent-timeline-pagination.spec.ts index 209e37063d..64a83ba434 100644 --- a/packages/app/e2e/agent-timeline-pagination.spec.ts +++ b/packages/app/e2e/agent-timeline-pagination.spec.ts @@ -3,6 +3,7 @@ import { expectSameOlderHistoryLoadingOperation, expectTimelineAtHistoryStart, expectTimelinePromptNotMounted, + expectTimelinePromptPositionPreserved, expectTimelinePromptVisible, holdBootstrapTimelinePage, holdDaemonHydration, @@ -11,6 +12,7 @@ import { openAgentTimeline, rememberOlderHistoryLoadingOperation, rememberTimelineViewport, + rememberTimelinePromptPosition, reloadAgentTimelineFromPersistedReplica, scrollTimelineUntilOlderHistoryIsReachable, scrollTimelineToNewestLoadedEdge, @@ -59,6 +61,31 @@ test.describe("Agent timeline pagination", () => { } }); + test("keeps visible history anchored when live output grows during a prepend", async ({ + page, + }) => { + test.setTimeout(120_000); + const agent = await seedLongMockAgentTimeline({ turns: 80 }); + try { + const history = await holdOlderHistoryPages(page, agent); + await openAgentTimeline(page, agent); + await userScrollsTimelineToHistoryStart(page); + await history.expectRequestedPages(1); + const position = await rememberTimelinePromptPosition(page, agent.initialTailOldestPrompt); + + await agent.client.sendAgentMessage( + agent.agentId, + "timeline live during held older page: emit 20 coalesced agent stream updates", + ); + await agent.client.waitForFinish(agent.agentId, 15_000); + history.releasePage(1); + + await expectTimelinePromptPositionPreserved(page, position); + } finally { + await agent.cleanup(); + } + }); + test("continues one loading operation while older pages still leave history start exposed", async ({ page, }) => { diff --git a/packages/app/e2e/helpers/timeline-pagination.ts b/packages/app/e2e/helpers/timeline-pagination.ts index b522ff6c58..cc11dc82e4 100644 --- a/packages/app/e2e/helpers/timeline-pagination.ts +++ b/packages/app/e2e/helpers/timeline-pagination.ts @@ -17,6 +17,7 @@ interface LongTimelineAgentOptions { } interface LongTimelineAgent extends MockAgentWorkspace { + initialTailOldestPrompt: string; oldestPrompt: string; newestPrompt: string; } @@ -30,6 +31,11 @@ interface TimelineViewportSnapshot { scrollTop: number; } +interface TimelinePromptPositionSnapshot { + prompt: string; + top: number; +} + interface OlderHistoryLoadingOperation { animationStartTime: number | null; marker: string; @@ -61,11 +67,41 @@ export async function seedLongMockAgentTimeline( return { ...agent, + initialTailOldestPrompt: promptForTurn(Math.max(0, options.turns - 20)), oldestPrompt: promptForTurn(0), newestPrompt: promptForTurn(options.turns - 1), }; } +export async function rememberTimelinePromptPosition( + page: Page, + prompt: string, +): Promise { + const timeline = page.locator('[data-testid="agent-chat-scroll"]:visible').first(); + const item = timeline.getByText(prompt, { exact: true }); + await expect(item).toBeVisible(); + const box = await item.boundingBox(); + if (!box) { + throw new Error(`Expected a rendered timeline item for ${prompt}`); + } + return { prompt, top: box.y }; +} + +export async function expectTimelinePromptPositionPreserved( + page: Page, + before: TimelinePromptPositionSnapshot, +): Promise { + const timeline = page.locator('[data-testid="agent-chat-scroll"]:visible').first(); + const item = timeline.getByText(before.prompt, { exact: true }); + await expect(item).toBeVisible(); + await expect + .poll(async () => { + const box = await item.boundingBox(); + return box ? Math.abs(box.y - before.top) : Number.POSITIVE_INFINITY; + }) + .toBeLessThanOrEqual(2); +} + export async function openAgentTimeline(page: Page, agent: LongTimelineAgent): Promise { await page.goto(buildAgentRoute(agent.workspaceId, agent.agentId)); await page.waitForURL( diff --git a/packages/app/src/agent-stream/history-start-pagination.test.ts b/packages/app/src/agent-stream/history-start-pagination.test.ts index a5e25cc99d..d5f0d5910d 100644 --- a/packages/app/src/agent-stream/history-start-pagination.test.ts +++ b/packages/app/src/agent-stream/history-start-pagination.test.ts @@ -3,6 +3,7 @@ import { createHistoryStartPaginationState, evaluateHistoryStartPagination, isHistoryStartLoadingOperation, + rearmHistoryStartPagination, settleHistoryStartPagination, type HistoryStartPaginationInput, } from "./history-start-pagination"; @@ -111,6 +112,25 @@ describe("history start pagination", () => { expect(away.state).toEqual({ status: "ready" }); }); + it("allows another user attempt after a request finishes without progress", () => { + const first = evaluateHistoryStartPagination( + createHistoryStartPaginationState(), + visibleHistoryStart, + ); + const inFlight = evaluateHistoryStartPagination(first.state, { + ...visibleHistoryStart, + isLoadingOlderHistory: true, + }); + const failed = evaluateHistoryStartPagination(inFlight.state, visibleHistoryStart); + const retried = evaluateHistoryStartPagination( + rearmHistoryStartPagination(failed.state), + visibleHistoryStart, + ); + + expect(failed.state).toEqual({ status: "latched" }); + expect(retried.shouldLoad).toBe(true); + }); + it("does not mistake repeated edge observations for a finished request", () => { const first = evaluateHistoryStartPagination( createHistoryStartPaginationState(), diff --git a/packages/app/src/agent-stream/history-start-pagination.ts b/packages/app/src/agent-stream/history-start-pagination.ts index ffaee4bb23..5547c7df77 100644 --- a/packages/app/src/agent-stream/history-start-pagination.ts +++ b/packages/app/src/agent-stream/history-start-pagination.ts @@ -27,6 +27,12 @@ export function isHistoryStartLoadingOperation(state: HistoryStartPaginationStat return state.status === "loading" || state.status === "settling"; } +export function rearmHistoryStartPagination( + state: HistoryStartPaginationState, +): HistoryStartPaginationState { + return state.status === "latched" ? { status: "ready" } : state; +} + export function evaluateHistoryStartPagination( state: HistoryStartPaginationState, input: HistoryStartPaginationInput, diff --git a/packages/app/src/agent-stream/strategy-native.tsx b/packages/app/src/agent-stream/strategy-native.tsx index 75264ce634..dfc9ef36e7 100644 --- a/packages/app/src/agent-stream/strategy-native.tsx +++ b/packages/app/src/agent-stream/strategy-native.tsx @@ -35,6 +35,7 @@ import { createHistoryStartPaginationState, evaluateHistoryStartPagination, isHistoryStartLoadingOperation, + rearmHistoryStartPagination, settleHistoryStartPagination, type HistoryStartPaginationInput, type HistoryStartPaginationTransition, @@ -430,6 +431,12 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat isUserScrollActiveRef.current = true; scrollKeyboardDismiss.onScrollBeginDrag(event); bottomAnchorController.beginUserScroll(); + const rearmed = rearmHistoryStartPagination(historyStartPaginationStateRef.current); + if (rearmed !== historyStartPaginationStateRef.current) { + historyStartPaginationStateRef.current = rearmed; + setHistoryStartPaginationState(rearmed); + evaluateHistoryStart(); + } }); // Defer drag end so momentum can take ownership, but capture the terminal diff --git a/packages/app/src/agent-stream/strategy-web.test.tsx b/packages/app/src/agent-stream/strategy-web.test.tsx index 43e4a2cc58..6e7013b0ef 100644 --- a/packages/app/src/agent-stream/strategy-web.test.tsx +++ b/packages/app/src/agent-stream/strategy-web.test.tsx @@ -370,6 +370,179 @@ describe("createWebStreamStrategy", () => { expect(onNearHistoryStart).not.toHaveBeenCalled(); }); + it("retries a latched history request when the user scrolls up in a short timeline", async () => { + const strategy = createWebStreamStrategy({ isMobileBreakpoint: true }); + const viewportRef = React.createRef(); + const onNearHistoryStart = vi.fn(); + const renderInput: StreamRenderInput = { + agentId: "agent", + segments: { + historyVirtualized: [], + historyMounted: [userMessage(1), userMessage(2)], + liveHead: [], + }, + boundary: { + hasVirtualizedHistory: false, + hasMountedHistory: true, + hasLiveHead: false, + }, + renderers: createRenderers(vi.fn()), + listEmptyComponent: null, + viewportRef, + routeBottomAnchorRequest: null, + isAuthoritativeHistoryReady: true, + onNearBottomChange: vi.fn(), + onNearHistoryStart, + isLoadingOlderHistory: false, + hasOlderHistory: true, + olderHistoryProgressKey: "epoch-1:20", + scrollEnabled: true, + listStyle: null, + baseListContentContainerStyle: null, + forwardListContentContainerStyle: null, + }; + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + + act(() => { + root?.render(strategy.render(renderInput)); + }); + 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: 1000 }); + Object.defineProperty(scrollContainer, "scrollHeight", { configurable: true, value: 500 }); + Object.defineProperty(scrollContainer, "scrollTop", { configurable: true, value: 0 }); + await act(async () => { + await new Promise((resolve) => requestAnimationFrame(resolve)); + }); + act(() => { + scrollContainer.dispatchEvent(new WheelEvent("wheel", { deltaY: -120 })); + scrollContainer.dispatchEvent(new Event("scroll")); + }); + expect(onNearHistoryStart).toHaveBeenCalledTimes(1); + + act(() => { + root?.render(strategy.render({ ...renderInput, isLoadingOlderHistory: true })); + }); + act(() => { + root?.render(strategy.render(renderInput)); + }); + expect(onNearHistoryStart).toHaveBeenCalledTimes(1); + + act(() => { + scrollContainer.dispatchEvent(new WheelEvent("wheel", { deltaY: -120 })); + }); + expect(onNearHistoryStart).toHaveBeenCalledTimes(2); + }); + + it("anchors prepended history independently from live content growth below it", async () => { + let anchorTop = 0; + let measuredScrollTop = 0; + vi.spyOn(HTMLElement.prototype, "getBoundingClientRect").mockImplementation( + function (this: HTMLElement) { + const top = this.dataset.historyRowId === "message-79" ? anchorTop - measuredScrollTop : 0; + return { + x: 0, + y: top, + top, + right: 0, + bottom: top + 24, + left: 0, + width: 0, + height: 24, + toJSON: () => ({}), + }; + }, + ); + const strategy = createWebStreamStrategy({ isMobileBreakpoint: true }); + const viewportRef = React.createRef(); + const onNearHistoryStart = vi.fn(); + const initialHistory = Array.from({ length: 20 }, (_, index) => userMessage(index + 60)); + const renderInput: StreamRenderInput = { + agentId: "agent", + segments: { historyVirtualized: [], historyMounted: initialHistory, liveHead: [] }, + boundary: { + hasVirtualizedHistory: false, + hasMountedHistory: true, + hasLiveHead: false, + }, + renderers: createRenderers(vi.fn()), + listEmptyComponent: null, + viewportRef, + routeBottomAnchorRequest: null, + isAuthoritativeHistoryReady: true, + onNearBottomChange: vi.fn(), + onNearHistoryStart, + isLoadingOlderHistory: false, + hasOlderHistory: true, + olderHistoryProgressKey: "epoch-1:60", + scrollEnabled: true, + listStyle: null, + baseListContentContainerStyle: null, + forwardListContentContainerStyle: null, + }; + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + act(() => { + root?.render(strategy.render(renderInput)); + }); + const scrollContainer = container.querySelector('[data-testid="agent-chat-scroll"]'); + if (!(scrollContainer instanceof HTMLElement)) { + throw new Error("Expected agent chat scroll container"); + } + let scrollHeight = 1000; + let scrollTop = 0; + Object.defineProperties(scrollContainer, { + clientHeight: { configurable: true, value: 400 }, + scrollHeight: { configurable: true, get: () => scrollHeight }, + scrollTop: { + configurable: true, + get: () => scrollTop, + set: (value: number) => { + scrollTop = value; + measuredScrollTop = value; + }, + }, + }); + await act(async () => { + await new Promise((resolve) => requestAnimationFrame(resolve)); + }); + act(() => { + scrollContainer.dispatchEvent(new WheelEvent("wheel", { deltaY: -120 })); + scrollContainer.dispatchEvent(new Event("scroll")); + }); + expect(onNearHistoryStart).toHaveBeenCalledTimes(1); + + anchorTop = 600; + scrollHeight = 1700; + const prependedHistory = Array.from({ length: 40 }, (_, index) => userMessage(index + 40)); + act(() => { + root?.render( + strategy.render({ + ...renderInput, + segments: { + historyVirtualized: [], + historyMounted: [...prependedHistory, ...initialHistory], + liveHead: [userMessage(80)], + }, + boundary: { + hasVirtualizedHistory: false, + hasMountedHistory: true, + hasLiveHead: true, + }, + isLoadingOlderHistory: true, + olderHistoryProgressKey: "epoch-1:40", + }), + ); + }); + + expect(scrollTop).toBe(600); + }); + it("keeps initial route entry anchored when delayed route readiness arrives before user scroll", async () => { const scrollTo = vi.fn(function ( this: HTMLElement, diff --git a/packages/app/src/agent-stream/strategy-web.tsx b/packages/app/src/agent-stream/strategy-web.tsx index 91d277b326..c6f51c69f0 100644 --- a/packages/app/src/agent-stream/strategy-web.tsx +++ b/packages/app/src/agent-stream/strategy-web.tsx @@ -20,6 +20,7 @@ import { createHistoryStartPaginationState, evaluateHistoryStartPagination, isHistoryStartLoadingOperation, + rearmHistoryStartPagination, settleHistoryStartPagination, type HistoryStartPaginationInput, type HistoryStartPaginationTransition, @@ -31,8 +32,8 @@ interface CreateWebStreamStrategyInput { interface HistoryStartPrependAnchor { progressKey: string; - scrollHeight: number; - scrollTop: number; + rowId: string; + viewportOffset: number; } type ScrollBehaviorLike = "auto" | "smooth"; @@ -49,6 +50,15 @@ const foregroundMutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted, }); +function findHistoryRowElement(contentNode: HTMLElement, rowId: string): HTMLElement | null { + for (const element of contentNode.querySelectorAll("[data-history-row-id]")) { + if (element.dataset.historyRowId === rowId) { + return element; + } + } + return null; +} + const historyStartSlotStyle: CSSProperties = { display: "flex", alignItems: "center", @@ -236,13 +246,18 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool return; } const scrollContainer = scrollContainerRef.current; - if (!scrollContainer) { + const contentNode = contentRef.current; + const anchorRow = segments.historyMounted.at(-1) ?? segments.historyVirtualized.at(-1); + const anchorElement = + contentNode && anchorRow ? findHistoryRowElement(contentNode, anchorRow.id) : null; + if (!scrollContainer || !anchorRow || !anchorElement) { return; } historyStartPrependAnchorRef.current = { progressKey: olderHistoryProgressKey, - scrollHeight: scrollContainer.scrollHeight, - scrollTop: scrollContainer.scrollTop, + rowId: anchorRow.id, + viewportOffset: + anchorElement.getBoundingClientRect().top - scrollContainer.getBoundingClientRect().top, }; historyStartPrependAnchorActiveRef.current = false; onNearHistoryStart(); @@ -259,14 +274,34 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool ); applyHistoryStartPaginationTransition(transition); }); - const applyHistoryStartPrependAnchor = useStableEvent(() => { - const scrollContainer = scrollContainerRef.current; - const anchor = historyStartPrependAnchorRef.current; - if (!scrollContainer || !anchor || !historyStartPrependAnchorActiveRef.current) { + const rearmHistoryStartFromUserIntent = useStableEvent(() => { + const rearmed = rearmHistoryStartPagination(historyStartPaginationStateRef.current); + if (rearmed === historyStartPaginationStateRef.current) { return; } - const contentGrowth = scrollContainer.scrollHeight - anchor.scrollHeight; - scrollContainer.scrollTop = anchor.scrollTop + contentGrowth; + historyStartPaginationStateRef.current = rearmed; + setHistoryStartPaginationState(rearmed); + evaluateHistoryStart(); + }); + const applyHistoryStartPrependAnchor = useStableEvent(() => { + const scrollContainer = scrollContainerRef.current; + const contentNode = contentRef.current; + const anchor = historyStartPrependAnchorRef.current; + if ( + !scrollContainer || + !contentNode || + !anchor || + !historyStartPrependAnchorActiveRef.current + ) { + return; + } + const anchorElement = findHistoryRowElement(contentNode, anchor.rowId); + if (!anchorElement) { + return; + } + const viewportOffset = + anchorElement.getBoundingClientRect().top - scrollContainer.getBoundingClientRect().top; + scrollContainer.scrollTop += viewportOffset - anchor.viewportOffset; lastKnownScrollTopRef.current = scrollContainer.scrollTop; }); const scheduleHistoryStartPrependSettle = useStableEvent(() => { @@ -592,6 +627,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool if (event.deltaY < 0) { pendingUserScrollUpIntentRef.current = true; cancelPendingStickToBottom(); + rearmHistoryStartFromUserIntent(); } }; const handlePointerDown = () => { @@ -616,6 +652,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool if (previousTouchY !== null && touch.clientY > previousTouchY + 1) { pendingUserScrollUpIntentRef.current = true; cancelPendingStickToBottom(); + rearmHistoryStartFromUserIntent(); } lastTouchClientYRef.current = touch.clientY; }; @@ -644,7 +681,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool scrollContainer.removeEventListener("touchend", handleTouchEnd); scrollContainer.removeEventListener("touchcancel", handleTouchEnd); }; - }, [cancelPendingStickToBottom, handleDomScroll]); + }, [cancelPendingStickToBottom, handleDomScroll, rearmHistoryStartFromUserIntent]); useEffect(() => { const handle: StreamViewportHandle = { @@ -711,9 +748,9 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool ); const mountedHistoryRows = useMemo(() => { return segments.historyMounted.map((item, index) => ( - +
{renderHistoryMountedRow(item, index, segments.historyMounted)} - +
)); }, [renderHistoryMountedRow, segments.historyMounted]); const liveHeadRows = useMemo(() => { @@ -767,6 +804,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
diff --git a/packages/app/src/assistant-image/use-assistant-image.ts b/packages/app/src/assistant-image/use-assistant-image.ts index fd60222823..22ebf66256 100644 --- a/packages/app/src/assistant-image/use-assistant-image.ts +++ b/packages/app/src/assistant-image/use-assistant-image.ts @@ -179,6 +179,7 @@ function createDataImageAcquisition(input: { id: createPreviewAttachmentId({ mimeType: dataImage.mimeType, contentLength: dataImage.base64.length, + contentKey: dataImage.cacheKey, }), dataUrl: source, mimeType: dataImage.mimeType, diff --git a/packages/app/src/attachments/utils.test.ts b/packages/app/src/attachments/utils.test.ts index 319f247d20..d6fce078f2 100644 --- a/packages/app/src/attachments/utils.test.ts +++ b/packages/app/src/attachments/utils.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; import { createImageSourceCacheKey, + createPreviewAttachmentId, fileUriToPath, localFileSourceToPath, parseDataUrl, @@ -86,4 +87,29 @@ describe("parseImageDataUrl", () => { it("ignores SVG data URLs", () => { expect(parseImageDataUrl("data:image/svg+xml;base64,PHN2ZyAvPg==")).toBeNull(); }); + + it("distinguishes image data that differs only in the middle", () => { + const prefix = "a".repeat(64); + const suffix = "z".repeat(64); + const first = `data:image/png;base64,${prefix}${"b".repeat(256)}${suffix}`; + const second = `data:image/png;base64,${prefix}${"c".repeat(256)}${suffix}`; + + expect(createImageSourceCacheKey(first)).not.toBe(createImageSourceCacheKey(second)); + }); + + it("gives equal-length preview content distinct attachment identities", () => { + expect( + createPreviewAttachmentId({ + mimeType: "image/png", + contentLength: 512, + contentKey: "first-content", + }), + ).not.toBe( + createPreviewAttachmentId({ + mimeType: "image/png", + contentLength: 512, + contentKey: "second-content", + }), + ); + }); }); diff --git a/packages/app/src/attachments/utils.ts b/packages/app/src/attachments/utils.ts index bb1903f9f3..5fa0334359 100644 --- a/packages/app/src/attachments/utils.ts +++ b/packages/app/src/attachments/utils.ts @@ -56,7 +56,7 @@ export function parseImageDataUrl( if (!isRasterImageMimeType(parsed.mimeType)) { return null; } - const fingerprint = `${parsed.mimeType}\0${parsed.base64.length}\0${parsed.base64.slice(0, 64)}\0${parsed.base64.slice(-64)}`; + const fingerprint = `${parsed.mimeType}\0${parsed.base64}`; return { ...parsed, cacheKey: `data-image:${parsed.mimeType}:${parsed.base64.length}:${hashString(fingerprint)}`, @@ -87,12 +87,15 @@ export function createPreviewAttachmentId(input: { size?: number | null; modifiedAt?: string | null; contentLength?: number | null; + contentKey?: string | null; }): string { const path = input.path?.trim() ?? ""; const size = Number.isFinite(input.size) ? String(input.size) : ""; const modifiedAt = input.modifiedAt?.trim() ?? ""; const contentLength = Number.isFinite(input.contentLength) ? String(input.contentLength) : ""; - const hash = hashString(`${input.mimeType}\0${path}\0${size}\0${modifiedAt}\0${contentLength}`); + const contentKey = input.contentKey?.trim() ?? ""; + const identity = `${input.mimeType}\0${path}\0${size}\0${modifiedAt}\0${contentLength}`; + const hash = hashString(contentKey ? `${identity}\0${contentKey}` : identity); return `preview_${size || contentLength || "unknown"}_${hash}`; } diff --git a/packages/app/src/components/ui/loading-spinner.tsx b/packages/app/src/components/ui/loading-spinner.tsx index f73fd037bb..abcbc64e8e 100644 --- a/packages/app/src/components/ui/loading-spinner.tsx +++ b/packages/app/src/components/ui/loading-spinner.tsx @@ -1,3 +1,4 @@ +import React from "react"; import { ActivityIndicator, type ActivityIndicatorProps } from "react-native"; interface LoadingSpinnerProps {