diff --git a/packages/app/e2e/browser/agent-timeline-scroll-smoothness.spec.ts b/packages/app/e2e/browser/agent-timeline-scroll-smoothness.spec.ts new file mode 100644 index 0000000000..b45007fa0b --- /dev/null +++ b/packages/app/e2e/browser/agent-timeline-scroll-smoothness.spec.ts @@ -0,0 +1,81 @@ +import { test } from "../support/fixtures"; +import { expect } from "@playwright/test"; +import { + findScrollJumps, + type ScrollFrame, + observeTimelinePages, + openOnlyTimelineTail, + recordUpwardTraversal, + reportScrollJumps, + scrollCadences, + withVariedTimeline, +} from "../support/helpers/timeline-scroll-smoothness"; + +test("scroll detector distinguishes entered image growth from a simultaneous viewport jump", () => { + const before: ScrollFrame = { + at: 1000, + scrollTop: 1000, + scrollHeight: 5000, + viewportHeight: 800, + virtualized: true, + loading: false, + rows: [ + { id: "image", top: -150, height: 150 }, + { id: "reading", top: 0, height: 100 }, + ], + anchor: "reading", + wheelTotal: 0, + lastWheelAt: 0, + inputFinishedAt: null, + imageLoads: 0, + mounted: 0, + unmounted: 0, + }; + const after: ScrollFrame = { + ...before, + at: 1400, + wheelTotal: 100, + lastWheelAt: 1050, + rows: [ + { id: "image", top: -50, height: 1150 }, + { id: "reading", top: 1100, height: 100 }, + ], + }; + expect(findScrollJumps([before, after])).toEqual([]); + expect( + findScrollJumps([ + { ...before, scrollTop: 200 }, + { + ...after, + wheelTotal: 1000, + rows: after.rows.map((row) => ({ ...row, top: row.top + 100 })), + }, + ]), + ).toEqual([]); + expect( + findScrollJumps([ + before, + { ...after, rows: after.rows.map((row) => ({ ...row, top: row.top - 500 })) }, + ]), + ).toHaveLength(1); + expect( + findScrollJumps([ + { ...before, anchor: "image", rows: [{ id: "image", top: 0, height: 150 }] }, + { ...after, rows: [{ id: "image", top: 600, height: 1150 }] }, + ]), + ).toHaveLength(1); +}); + +for (const cadence of scrollCadences) { + test(`varied timeline preserves reading position during ${cadence.name} upward scrolling`, async ({ + page, + }, testInfo) => { + test.setTimeout(180_000); + await withVariedTimeline(async (agent, newestPrompt) => { + const pages = observeTimelinePages(page, agent.agentId); + await openOnlyTimelineTail(page, agent, newestPrompt, pages); + const frames = await recordUpwardTraversal(page, cadence, testInfo); + await reportScrollJumps(page, testInfo, frames, pages); + }); + }); +} diff --git a/packages/app/e2e/support/helpers/timeline-scroll-smoothness.ts b/packages/app/e2e/support/helpers/timeline-scroll-smoothness.ts new file mode 100644 index 0000000000..63ef1c10a0 --- /dev/null +++ b/packages/app/e2e/support/helpers/timeline-scroll-smoothness.ts @@ -0,0 +1,430 @@ +import { writeFile } from "node:fs/promises"; +import { createServer } from "node:http"; +import { expect, type Page, type TestInfo } from "@playwright/test"; +import { seedMockAgentWorkspace, type MockAgentWorkspace } from "./mock-agent"; +import { openAgentTimeline, expectTimelinePromptVisible } from "./timeline-pagination"; + +export const scrollCadences = [ + { name: "slow", delta: 160, intervalMs: 50, steps: 1000 }, + { name: "medium", delta: 480, intervalMs: 32, steps: 400 }, + { name: "fast", delta: 1200, intervalMs: 16, steps: 180 }, +] as const; + +type Cadence = (typeof scrollCadences)[number]; +interface RowFrame { + id: string; + top: number; + height: number; +} +export interface ScrollFrame { + at: number; + scrollTop: number; + scrollHeight: number; + viewportHeight: number; + virtualized: boolean; + loading: boolean; + rows: RowFrame[]; + anchor: string | null; + wheelTotal: number; + lastWheelAt: number; + inputFinishedAt: number | null; + imageLoads: number; + mounted: number; + unmounted: number; +} +interface Recording { + frames: ScrollFrame[]; + finishInput(): void; + stop(): void; +} +interface TimelinePage { + direction: string; + count: number; + hasOlder: boolean; + at: number; +} + +/** Real HTTP image responses, with intrinsic dimensions unavailable until the response arrives. */ +export async function withVariedTimeline( + run: (agent: MockAgentWorkspace, newestPrompt: string) => Promise, +): Promise { + const timers = new Set>(); + const server = createServer((request, response) => { + const portrait = request.url?.includes("portrait"); + const timer = setTimeout( + () => { + timers.delete(timer); + response.writeHead(200, { + "Content-Type": "image/svg+xml", + "Cache-Control": "no-store", + "Access-Control-Allow-Origin": "*", + }); + response.end( + `Delayed ${portrait ? "portrait" : "landscape"}`, + ); + }, + portrait ? 900 : 350, + ); + timers.add(timer); + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const address = server.address(); + if (!address || typeof address === "string") throw new Error("Image server did not listen"); + const imageUrl = `http://127.0.0.1:${address.port}`; + let agent: MockAgentWorkspace | undefined; + try { + agent = await seedMockAgentWorkspace({ + repoPrefix: "timeline-scroll-smoothness-", + title: "Varied timeline scroll reproduction", + featureValues: { + mockAssistantResponses: Array.from({ length: 70 }, (_, turn) => + [ + `## Review of turn ${turn}`, + "A short response followed by blocks with different heights. Read the comparison, inspect the screenshot, and continue to the next turn.", + "| Component | Observation | Next step |\n| --- | --- | --- |\n| Pagination | Older turns arrive together | Keep the visible text anchored |\n| Images | Intrinsic sizes arrive later | Preserve the reading position |\n| Virtualization | Estimated heights become measured heights | Keep wheel input moving upward |", + `![Delayed landscape](${imageUrl}/${turn}/landscape.svg)`, + "```typescript\nconst viewport = { top: 0, height: 800 };\nfor (const row of history) {\n inspect(row, viewport);\n}\n```", + "> Keep reading while content above the viewport changes.", + "- Short item\n- A longer item that wraps when the available width is smaller and adds another height variation\n- Final item", + `![Delayed portrait](${imageUrl}/${turn}/portrait.svg)`, + "Review complete. The next prompt starts a separate turn.", + ].join("\n\n"), + ), + }, + }); + for (let turn = 0; turn < 70; turn += 1) { + await agent.client.sendAgentMessage( + agent.agentId, + `Review turn ${turn}. ${"Compare the image and table with the earlier result. ".repeat(1 + (turn % 4))}`, + ); + await agent.client.waitForFinish(agent.agentId, 15_000); + } + await run( + agent, + `Review turn 69. ${"Compare the image and table with the earlier result. ".repeat(2)}`, + ); + } finally { + await agent?.cleanup(); + for (const timer of timers) clearTimeout(timer); + server.closeAllConnections(); + await new Promise((resolve) => server.close(() => resolve())); + } +} + +export function observeTimelinePages(page: Page, agentId: string): TimelinePage[] { + const pages: TimelinePage[] = []; + page.on("websocket", (socket) => { + socket.on("framereceived", ({ payload }) => { + const envelope = JSON.parse(String(payload)); + const message = envelope.message; + if (message?.type !== "fetch_agent_timeline_response" || message.payload.agentId !== agentId) + return; + pages.push({ + direction: message.payload.direction, + count: message.payload.entries.length, + hasOlder: message.payload.hasOlder, + at: Date.now(), + }); + }); + }); + return pages; +} + +export async function openOnlyTimelineTail( + page: Page, + agent: MockAgentWorkspace, + newestPrompt: string, + pages: TimelinePage[], +): Promise { + await page.addInitScript(() => Reflect.set(globalThis, "__PASEO_RENDER_PROFILE_ENABLED__", true)); + await openAgentTimeline(page, agent); + await expectTimelinePromptVisible(page, newestPrompt.trim()); + await expect + .poll(() => pages.filter((entry) => entry.direction === "tail").length) + .toBeGreaterThanOrEqual(1); + + expect(pages[0]).toMatchObject({ direction: "tail", hasOlder: true }); + expect(pages[0].count).toBe(40); + expect(pages.filter((entry) => entry.direction === "tail")).toHaveLength(1); + expect( + pages.filter((entry) => entry.direction === "after").every((entry) => entry.count === 0), + ).toBe(true); + await expect(page.getByText(/^Review turn 0\./)).toHaveCount(0); + // Start each cadence after the tail's initial image loads and bottom anchoring. + await page.waitForTimeout(1500); + expect(pages.filter((entry) => entry.direction === "before")).toHaveLength(0); +} + +export async function recordUpwardTraversal( + page: Page, + cadence: Cadence, + testInfo: TestInfo, +): Promise { + const stopTrace = await traceScrollIfRequested(page, testInfo, cadence); + const timeline = page.getByTestId("agent-chat-scroll"); + await timeline.hover(); + await timeline.evaluate((viewport) => { + const scroll = viewport as HTMLElement; + const frames: ScrollFrame[] = []; + let frame = 0; + let wheelTotal = 0; + let lastWheelAt = -Infinity; + let inputFinishedAt: number | null = null; + let imageLoads = 0; + let mounted = 0; + let unmounted = 0; + let previousIds = new Set(); + const wheel = (event: WheelEvent) => { + wheelTotal += -event.deltaY; + lastWheelAt = performance.now(); + }; + const imageLoad = (event: Event) => { + if (event.target instanceof HTMLImageElement) imageLoads += 1; + }; + scroll.addEventListener("wheel", wheel, { passive: true }); + scroll.addEventListener("load", imageLoad, true); + const observed = new Set(); + const capture = (replaceLast: boolean) => { + const rect = scroll.getBoundingClientRect(); + const elements = Array.from(scroll.querySelectorAll("[data-history-row-id]")); + const current = new Set([scroll, ...elements]); + if (scroll.firstElementChild) current.add(scroll.firstElementChild); + for (const element of observed) { + if (!current.has(element)) { + resizeObserver.unobserve(element); + observed.delete(element); + } + } + for (const element of current) { + if (!observed.has(element)) { + resizeObserver.observe(element); + observed.add(element); + } + } + const rows = elements.map((row) => { + const box = row.getBoundingClientRect(); + return { id: row.dataset.historyRowId!, top: box.top - rect.top, height: box.height }; + }); + const ids = new Set(rows.map((row) => row.id)); + mounted += [...ids].filter((id) => !previousIds.has(id)).length; + unmounted += [...previousIds].filter((id) => !ids.has(id)).length; + previousIds = ids; + // Preserve the row crossing the reading line. Rows below an expanding + // visible image legitimately move even when the reading position is stable. + const anchor = rows.find((row) => row.top < rect.height && row.top + row.height > 8); + const snapshot: ScrollFrame = { + at: performance.now(), + scrollTop: scroll.scrollTop, + scrollHeight: scroll.scrollHeight, + viewportHeight: rect.height, + rows, + anchor: anchor?.id ?? null, + virtualized: scroll.id.includes("virtualized"), + loading: !!scroll.querySelector('[data-testid="load-older-history-spinner"]'), + wheelTotal, + lastWheelAt, + inputFinishedAt, + imageLoads, + mounted, + unmounted, + }; + if (replaceLast && frames.length > 0) frames[frames.length - 1] = snapshot; + else frames.push(snapshot); + }; + // rAF precedes ResizeObserver. Refresh that frame after resize delivery so + // it represents the final layout, not geometry repaired before the paint. + const resizeObserver = new ResizeObserver(() => capture(true)); + const sample = () => { + capture(false); + frame = requestAnimationFrame(sample); + }; + const recording: Recording = { + frames, + finishInput() { + inputFinishedAt = performance.now(); + }, + stop() { + cancelAnimationFrame(frame); + resizeObserver.disconnect(); + scroll.removeEventListener("wheel", wheel); + scroll.removeEventListener("load", imageLoad, true); + }, + }; + Reflect.set(window, "__timelineScrollRecording", recording); + const reset = Reflect.get(window, "__PASEO_RESET_RENDER_PROFILE__"); + if (typeof reset === "function") reset(); + sample(); + }); + // No settling between inputs: exercise wheel input overlapping prepend and measurement. + for (let step = 0; step < cadence.steps; step += 1) { + await page.mouse.wheel(0, -cadence.delta); + await page.waitForTimeout(cadence.intervalMs); + } + await page.evaluate(() => { + (Reflect.get(window, "__timelineScrollRecording") as Recording).finishInput(); + }); + await page.waitForTimeout(1500); + await stopTrace(); + return page.evaluate(() => { + const recording = Reflect.get(window, "__timelineScrollRecording") as Recording; + recording.stop(); + return recording.frames; + }); +} + +export function findScrollJumps(frames: ScrollFrame[]) { + return frames.flatMap((current, index) => { + const previous = frames[index - 1]; + if (!previous?.anchor) return []; + // Wheel input can move the reading line onto an image before it expands. + // Follow that intended row, not text now below the image. + const inputDelta = current.wheelTotal - previous.wheelTotal; + const availableScroll = Math.min(inputDelta, previous.scrollTop); + const readingLine = 8 - availableScroll; + const currentRows = new Map(current.rows.map((row) => [row.id, row])); + const intendedRow = previous.rows.find((row) => row.top + row.height > readingLine); + const intendedHeight = intendedRow && currentRows.get(intendedRow.id)?.height; + const anchor = previous.anchor; + const before = currentRows.has(anchor) + ? previous.rows.find((row) => row.id === anchor)! + : previous.rows + .filter((row) => currentRows.has(row.id)) + .sort((left, right) => Math.abs(left.top - 8) - Math.abs(right.top - 8))[0]; + if (!before) { + throw new Error(`No shared reading geometry between frames ${index - 1} and ${index}`); + } + const after = currentRows.get(before.id)!; + const movement = after.top - before.top; + // A busy main thread can deliver wheel movement hundreds of milliseconds + // after its event. Only assert idle stability after the driver stops input. + const idle = current.inputFinishedAt !== null && previous.at - current.inputFinishedAt > 250; + const recent = frames.findLast((frame) => frame.at <= previous.at - 100); + const wheelBudget = current.wheelTotal - (recent?.wheelTotal ?? 0); + // Growth displaces only later rows. It never grants extra movement to the + // image itself, and its top must still follow the input budget. + const enteredRowGrowth = + inputDelta > 0 && + intendedRow && + intendedHeight !== undefined && + before.top >= intendedRow.top + intendedRow.height && + Math.abs(currentRows.get(intendedRow.id)!.top - intendedRow.top - availableScroll) <= 32 + ? Math.max(0, intendedHeight - intendedRow.height) + : 0; + const excessForward = movement > wheelBudget + enteredRowGrowth + 32; + // Upward wheel input moves the same text DOWN the viewport. A negative move + // is a reversal, independent of legitimate scrollTop compensation on prepend. + if (movement >= -8 && (!idle || Math.abs(movement) <= 8) && !excessForward) return []; + return [ + { + frame: index, + at: current.at, + row: before.id, + movement, + idle, + excessForward, + resizedRows: current.rows.flatMap((row) => { + const old = previous.rows.find((candidate) => candidate.id === row.id); + return old && old.height !== row.height + ? [{ id: row.id, before: old.height, after: row.height }] + : []; + }), + loading: current.loading || previous.loading, + virtualized: current.virtualized, + heightChange: current.scrollHeight - previous.scrollHeight, + imageLoads: current.imageLoads - previous.imageLoads, + frameGapMs: current.at - previous.at, + }, + ]; + }); +} + +export async function reportScrollJumps( + page: Page, + testInfo: TestInfo, + frames: ScrollFrame[], + pages: TimelinePage[], +): Promise { + const jumps = findScrollJumps(frames); + const react = await page.evaluate(() => Reflect.get(window, "__PASEO_RENDER_PROFILE__") ?? []); + const gaps = frames + .slice(1) + .map((frame, index) => frame.at - frames[index].at) + .sort((a, b) => a - b); + const summary = { + browserTimeOrigin: await page.evaluate(() => performance.timeOrigin), + frames: frames.length, + pages, + jumpCount: jumps.length, + worstReversePx: Math.max(0, ...jumps.map((jump) => -jump.movement)), + frameGapP95Ms: gaps[Math.floor(gaps.length * 0.95)], + frameGapMaxMs: gaps.at(-1), + imageLoads: frames.at(-1)?.imageLoads, + mounted: frames.at(-1)?.mounted, + unmounted: frames.at(-1)?.unmounted, + jumps, + }; + await writeFile(testInfo.outputPath("scroll-summary.json"), JSON.stringify(summary, null, 2)); + await writeFile(testInfo.outputPath("scroll-frames.json"), JSON.stringify(frames)); + await writeFile(testInfo.outputPath("scroll-react-profile.json"), JSON.stringify(react)); + await testInfo.attach("scroll-summary", { + body: JSON.stringify(summary, null, 2), + contentType: "application/json", + }); + await testInfo.attach("scroll-frames", { + body: JSON.stringify(frames), + contentType: "application/json", + }); + await testInfo.attach("scroll-react-profile", { + body: JSON.stringify(react), + contentType: "application/json", + }); + await testInfo.attach("scroll-end", { body: await page.screenshot(), contentType: "image/png" }); + console.log( + `[scroll] ${testInfo.title}: ${JSON.stringify({ ...summary, jumps: jumps.slice(0, 5) })}`, + ); + expect( + pages.filter((entry) => entry.direction === "before").length, + "must exercise multiple older pages", + ).toBeGreaterThanOrEqual(2); + expect( + frames.some((frame) => frame.virtualized), + "must exercise virtualization", + ).toBe(true); + expect(summary.imageLoads, "must exercise image completion during traversal").toBeGreaterThan(0); + expect( + jumps, + "visible text must not reverse direction while scrolling upward or move after input stops", + ).toEqual([]); +} + +async function traceScrollIfRequested( + page: Page, + testInfo: TestInfo, + cadence: Cadence, +): Promise<() => Promise> { + if (process.env.PASEO_TIMELINE_SCROLL_TRACE !== cadence.name) return async () => {}; + const cdp = await page.context().newCDPSession(page); + await cdp.send("Tracing.start", { + categories: + "devtools.timeline,v8.execute,blink.user_timing,disabled-by-default-v8.cpu_profiler", + transferMode: "ReturnAsStream", + }); + return async () => { + const completed = new Promise((resolve) => { + cdp.once("Tracing.tracingComplete", ({ stream }) => resolve(stream!)); + }); + await cdp.send("Tracing.end"); + const stream = await completed; + const chunks: string[] = []; + for (;;) { + const chunk = await cdp.send("IO.read", { handle: stream }); + chunks.push(chunk.base64Encoded ? Buffer.from(chunk.data, "base64").toString() : chunk.data); + if (chunk.eof) break; + } + await cdp.send("IO.close", { handle: stream }); + await cdp.detach(); + const path = testInfo.outputPath("scroll-chromium-trace.json"); + await writeFile(path, chunks.join("")); + await testInfo.attach("scroll-chromium-trace", { path, contentType: "application/json" }); + }; +} diff --git a/packages/app/src/agent-stream/reading-anchor.test.ts b/packages/app/src/agent-stream/reading-anchor.test.ts new file mode 100644 index 0000000000..19b6bc9e31 --- /dev/null +++ b/packages/app/src/agent-stream/reading-anchor.test.ts @@ -0,0 +1,93 @@ +import { describe, expect, it } from "vitest"; +import { createReadingAnchor } from "./reading-anchor"; + +describe("reading anchor", () => { + const rows = [ + { id: "above", top: 0, height: 500 }, + { id: "reading", top: 500, height: 500 }, + { id: "below", top: 1000, height: 500 }, + ]; + + it("preserves wheel movement during an asynchronous prepend", () => { + const anchor = createReadingAnchor(); + anchor.reconcile(600, rows); + expect( + anchor.reconcile( + 440, + rows.map((row) => ({ id: row.id, height: row.height, top: row.top + 2000 })), + ), + ).toBe(2440); + expect( + anchor.reconcile( + 2280, + rows.map((row) => ({ id: row.id, height: row.height, top: row.top + 2000 })), + ), + ).toBe(2280); + }); + + it("ignores growth below the reader and compensates growth above", () => { + const anchor = createReadingAnchor(); + anchor.reconcile(600, rows); + expect(anchor.reconcile(600, [rows[0]!, rows[1]!, { ...rows[2]!, height: 2000 }])).toBe(600); + expect( + anchor.reconcile(600, [ + { ...rows[0]!, height: 1000 }, + { ...rows[1]!, top: 1000 }, + { ...rows[2]!, top: 1500 }, + ]), + ).toBe(1100); + }); + + it("leaves expansion of the row being read in place", () => { + const anchor = createReadingAnchor(); + anchor.reconcile(600, rows); + expect( + anchor.reconcile(600, [rows[0]!, { ...rows[1]!, height: 1000 }, { ...rows[2]!, top: 1500 }]), + ).toBe(600); + }); + + it("keeps the existing reader through prepend measurements until the user scrolls", () => { + const anchor = createReadingAnchor(); + anchor.reconcile(0, [{ id: "reading", top: 48, height: 100 }]); + const prepended = [ + { id: "new", top: 2000, height: 57 }, + { id: "reading", top: 2057, height: 100 }, + ]; + expect(anchor.reconcile(0, prepended)).toBe(2009); + expect(anchor.getRowId()).toBe("reading"); + const measured = [ + { id: "new", top: 2000, height: 100 }, + { id: "reading", top: 2100, height: 100 }, + ]; + expect(anchor.reconcile(2009, measured)).toBe(2052); + expect(anchor.getRowId()).toBe("reading"); + expect(anchor.reconcile(2000, measured, true)).toBe(2000); + expect(anchor.getRowId()).toBe("new"); + }); + + it("retains the reader while the virtualizer initializes its mounted range", () => { + const anchor = createReadingAnchor(); + anchor.reconcile(600, rows); + anchor.reconcile(600, [rows[2]!]); + expect(anchor.getRowId()).toBe("reading"); + expect( + anchor.reconcile( + 600, + rows.map((row) => ({ ...row, top: row.top + 2000 })), + ), + ).toBe(2600); + }); + + it("releases the old reading position for explicit navigation", () => { + const anchor = createReadingAnchor(); + anchor.reconcile(600, rows); + anchor.reset(); + expect(anchor.getRowId()).toBeNull(); + expect( + anchor.reconcile( + 0, + rows.map((row) => ({ id: row.id, height: row.height, top: row.top + 2000 })), + ), + ).toBe(0); + }); +}); diff --git a/packages/app/src/agent-stream/reading-anchor.ts b/packages/app/src/agent-stream/reading-anchor.ts new file mode 100644 index 0000000000..31b965f134 --- /dev/null +++ b/packages/app/src/agent-stream/reading-anchor.ts @@ -0,0 +1,32 @@ +interface RowGeometry { + id: string; + top: number; + height: number; +} + +// Store content coordinates, never a frozen viewport offset: user scrolling must +// survive a layout correction, including input delivered while a page is loading. +export function createReadingAnchor() { + let anchor: { id: string; top: number } | null = null; + return { + getRowId: () => anchor?.id ?? null, + reset() { + anchor = null; + }, + reconcile(scrollTop: number, rows: readonly RowGeometry[], userScrolled = false): number { + const previous = anchor && rows.find((row) => row.id === anchor?.id); + // The first virtualized commit can precede its mounted range. Keep the + // pinned reader until it mounts, rather than adopting an unrelated row. + if (anchor && !previous && !userScrolled) return scrollTop; + const correctedTop = scrollTop + (previous && anchor ? previous.top - anchor.top : 0); + // A prepend can expose the bottom of an estimated row above the reader. + // Do not transfer ownership to it until the user moves the reading position. + const next = + previous && !userScrolled + ? previous + : rows.find((row) => row.top + row.height > correctedTop + 8); + anchor = next ? { id: next.id, top: next.top } : null; + return correctedTop; + }, + }; +} diff --git a/packages/app/src/agent-stream/strategy-web.tsx b/packages/app/src/agent-stream/strategy-web.tsx index c798c374d4..eee4557ab7 100644 --- a/packages/app/src/agent-stream/strategy-web.tsx +++ b/packages/app/src/agent-stream/strategy-web.tsx @@ -9,10 +9,12 @@ import React, { } from "react"; import { defaultRangeExtractor, + observeElementOffset, measureElement as measureVirtualElement, useVirtualizer, type Range as VirtualRange, } from "@tanstack/react-virtual"; +import { flushSync } from "react-dom"; import { withUnistyles } from "react-native-unistyles"; import { useRetainedPanelActive } from "@/components/retained-panel"; import { LoadingSpinner } from "@/components/ui/loading-spinner"; @@ -20,10 +22,8 @@ import { useStableEvent } from "@/hooks/use-stable-event"; import type { Theme } from "@/styles/theme"; import { WEB_SCROLLBAR_SIZE_PX } from "@/styles/web-scrollbar"; import { DomOverlayScrollbar } from "@/components/ui/overlay-scrollbar/dom-overlay-scrollbar"; -import { - estimateStreamItemHeight, - shouldAdjustScrollForVirtualRowResize, -} from "./web-virtualization"; +import { estimateStreamItemHeight } from "./web-virtualization"; +import { createReadingAnchor } from "./reading-anchor"; import type { StreamRenderInput, StreamStrategy, StreamViewportHandle } from "./strategy"; import { useRevisedHistoryRows } from "./history-row-revision"; import { createStreamStrategy } from "./strategy"; @@ -49,12 +49,6 @@ interface CreateWebStreamStrategyInput { isMobileBreakpoint: boolean; } -interface HistoryStartPrependAnchor { - progressKey: string; - rowId: string; - viewportOffset: number; -} - type ScrollBehaviorLike = "auto" | "smooth"; const WEB_BOTTOM_SETTLE_TIMEOUT_MS = 200; @@ -76,15 +70,6 @@ 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", @@ -348,14 +333,13 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool const lastTouchClientYRef = useRef(null); const pendingAutoScrollFrameRef = useRef(null); const pendingAutoScrollTimeoutRef = useRef(null); - const pendingVirtualRowMeasureFramesRef = useRef(new Map()); const historyStartReadyRef = useRef(false); const [historyStartPaginationState, setHistoryStartPaginationState] = useState( createHistoryStartPaginationState, ); const historyStartPaginationStateRef = useRef(historyStartPaginationState); - const historyStartPrependAnchorRef = useRef(null); - const historyStartPrependAnchorActiveRef = useRef(false); + const readingAnchor = useMemo(createReadingAnchor, []); + const measuredRowHeights = useRef(new Map()); const historyStartSettleSchedulerRef = useRef(null); const lastActiveFollowOutputLayoutRef = useRef(null); const lastObservedViewportGeometryRef = useRef(null); @@ -392,11 +376,28 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool const rangeExtractor = useCallback( (range: VirtualRange) => { const visible = defaultRangeExtractor(range); - if (!chatFindRowIndexes) return visible; - return [...new Set([...visible, ...chatFindRowIndexes])].sort((left, right) => left - right); + const anchorIndex = segments.historyVirtualized.findIndex( + (row) => row.id === readingAnchor.getRowId(), + ); + return [ + ...new Set([ + ...visible, + ...(chatFindRowIndexes ?? []), + ...(anchorIndex < 0 ? [] : [anchorIndex]), + ]), + ].sort((left, right) => left - right); }, - [chatFindRowIndexes], + [chatFindRowIndexes, readingAnchor, segments.historyVirtualized], ); + const virtualOffsetListener = useRef<((offset: number, scrolling: boolean) => void) | null>(null); + const observeVirtualOffset = useCallback((instance, onOffset) => { + virtualOffsetListener.current = onOffset; + const unsubscribe = observeElementOffset(instance, onOffset); + return () => { + virtualOffsetListener.current = null; + unsubscribe?.(); + }; + }, []); const rowVirtualizer = useVirtualizer({ count: segments.historyVirtualized.length, enabled: shouldUseVirtualizer, @@ -404,31 +405,19 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool getItemKey: (index: number) => segments.historyVirtualized[index]?.id ?? index, estimateSize: (index: number) => { const row = segments.historyVirtualized[index]; + const measured = row && measuredRowHeights.current.get(row.id); + const width = contentRef.current?.getBoundingClientRect().width; + if (measured && measured.width === width) return measured.height; return row ? estimateStreamItemHeight(row, contentMaxWidth) : 120; }, - measureElement: measureVirtualElement, + observeElementOffset: observeVirtualOffset, rangeExtractor, scrollMargin: VIRTUALIZER_SCROLL_MARGIN_PX, - useAnimationFrameWithResizeObserver: true, overscan: 8, }); - useEffect(() => { - rowVirtualizer.shouldAdjustScrollPositionOnItemSizeChange = (item, _delta, instance) => { - const viewportHeight = instance.scrollRect?.height ?? 0; - const scrollOffset = instance.scrollOffset ?? 0; - const remainingDistance = instance.getTotalSize() - (scrollOffset + viewportHeight); - return shouldAdjustScrollForVirtualRowResize({ - isHistoryStartPrependActive: historyStartPrependAnchorActiveRef.current, - rowStart: item.start, - scrollOffset, - remainingDistanceFromBottom: remainingDistance, - bottomThreshold: AUTO_SCROLL_BOTTOM_THRESHOLD_PX, - }); - }; - return () => { - rowVirtualizer.shouldAdjustScrollPositionOnItemSizeChange = undefined; - }; - }, [rowVirtualizer]); + // Scroll correction belongs to the viewport's committed layout. The virtualizer + // otherwise writes scrollTop before React applies its new row positions. + rowVirtualizer.shouldAdjustScrollPositionOnItemSizeChange = () => false; const virtualRows = rowVirtualizer.getVirtualItems(); const virtualTotalSize = rowVirtualizer.getTotalSize(); const getHistoryStartPaginationInput = useStableEvent((): HistoryStartPaginationInput | null => { @@ -442,7 +431,9 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool distanceFromHistoryStart: scrollContainer.scrollTop, hasOlderHistory, isLoadingOlderHistory, - isReady: historyStartReadyRef.current && bottomAnchorSettled, + // An explicit jump owns the landing position. Revealing another local + // page here changes the virtual index before the target has mounted. + isReady: historyStartReadyRef.current && bottomAnchorSettled && !isJumpSettling(), progressKey: olderHistoryProgressKey, }; }); @@ -453,29 +444,9 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool if (transition.state !== previousState) { setHistoryStartPaginationState(transition.state); } - if (!isHistoryStartLoadingOperation(transition.state)) { - historyStartPrependAnchorRef.current = null; - historyStartPrependAnchorActiveRef.current = false; - } if (!transition.shouldLoad || olderHistoryProgressKey === null) { return; } - const scrollContainer = scrollContainerRef.current; - 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) { - historyStartPrependAnchorRef.current = { - progressKey: olderHistoryProgressKey, - rowId: anchorRow.id, - viewportOffset: - anchorElement.getBoundingClientRect().top - scrollContainer.getBoundingClientRect().top, - }; - } else { - historyStartPrependAnchorRef.current = null; - } - historyStartPrependAnchorActiveRef.current = false; const requestedProgressKey = olderHistoryProgressKey; void (async () => { const started = await onNearHistoryStart(); @@ -512,27 +483,6 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool 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(() => { let scheduler = historyStartSettleSchedulerRef.current; if (!scheduler) { @@ -543,24 +493,17 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool isSettling: () => historyStartPaginationStateRef.current.status === "settling", isLoading: () => { const input = getHistoryStartPaginationInput(); - return ( - !input || - input.isLoadingOlderHistory || - pendingVirtualRowMeasureFramesRef.current.size > 0 - ); + return !input || input.isLoadingOlderHistory; }, - onFrame: applyHistoryStartPrependAnchor, onSettle: () => { const input = getHistoryStartPaginationInput(); if (!input) { return; } - historyStartPrependAnchorActiveRef.current = false; const transition = settleHistoryStartPagination( historyStartPaginationStateRef.current, input, ); - historyStartPrependAnchorRef.current = null; applyHistoryStartPaginationTransition(transition); }, }); @@ -569,59 +512,35 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool scheduler.schedule(); }); - useLayoutEffect(() => { - if (!isActiveRef.current) { - return; - } - const anchor = historyStartPrependAnchorRef.current; - if (!anchor || anchor.progressKey === olderHistoryProgressKey) { - return; - } - historyStartPrependAnchorActiveRef.current = true; - evaluateHistoryStart(); - applyHistoryStartPrependAnchor(); - scheduleHistoryStartPrependSettle(); - }, [ - applyHistoryStartPrependAnchor, - evaluateHistoryStart, - olderHistoryProgressKey, - scheduleHistoryStartPrependSettle, - segments.historyMounted, - segments.historyVirtualized, - virtualTotalSize, - ]); - - const measureVirtualizedRowElement = useCallback( - (node: HTMLDivElement | null) => { - if (!node) { - rowVirtualizer.measureElement(null); - return; - } - const pendingFrames = pendingVirtualRowMeasureFramesRef.current; - const existingFrame = pendingFrames.get(node); - if (existingFrame !== undefined) { - window.cancelAnimationFrame(existingFrame); - } - const frame = window.requestAnimationFrame(() => { - pendingFrames.delete(node); - if (isActiveRef.current && node.isConnected) { - rowVirtualizer.measureElement(node); - } - }); - pendingFrames.set(node, frame); - }, + // ResizeObserver runs before paint. Batch measurements and commit React's new + // positions here so a resized image cannot overlap rows for an extra frame. + // Use resizeItem exclusively; measureElement would install a second observer. + const virtualRowObserver = useMemo( + () => + new ResizeObserver((entries) => { + if (!isActiveRef.current) return; + flushSync(() => { + for (const entry of entries) { + const element = entry.target as HTMLElement; + if (!element.isConnected) continue; + rowVirtualizer.resizeItem( + Number(element.dataset.index), + measureVirtualElement(element, entry, rowVirtualizer), + ); + } + }); + }), [rowVirtualizer], ); - - useEffect(() => { - const pendingFrames = pendingVirtualRowMeasureFramesRef.current; - return () => { - for (const frame of pendingFrames.values()) { - window.cancelAnimationFrame(frame); - } - pendingFrames.clear(); - }; - }, []); + const measureVirtualizedRowElement = useCallback( + (node: HTMLDivElement | null) => { + if (!node) return; + virtualRowObserver.observe(node, { box: "border-box" }); + return () => virtualRowObserver.unobserve(node); + }, + [virtualRowObserver], + ); + useEffect(() => () => virtualRowObserver.disconnect(), [virtualRowObserver]); const cancelPendingStickToBottom = useCallback(() => { const pendingFrame = pendingAutoScrollFrameRef.current; @@ -660,10 +579,6 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool } cancelPendingStickToBottom(); historyStartSettleSchedulerRef.current?.cancel(); - for (const frame of pendingVirtualRowMeasureFramesRef.current.values()) { - window.cancelAnimationFrame(frame); - } - pendingVirtualRowMeasureFramesRef.current.clear(); clearMouseScrollGesture(); clearUpwardInputEvidence(); lastTouchClientYRef.current = null; @@ -769,6 +684,42 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool onNearBottomChange, }); + const reconcileReadingPosition = useStableEvent((userScrolled: boolean = false) => { + const container = scrollContainerRef.current; + const content = contentRef.current; + if (!isActiveRef.current || !container || !content) return; + const viewportTop = container.getBoundingClientRect().top; + const scrollTop = container.scrollTop; + const width = content.getBoundingClientRect().width; + const rows = Array.from( + content.querySelectorAll("[data-history-row-id]"), + (element) => { + const bounds = element.getBoundingClientRect(); + const id = element.dataset.historyRowId!; + measuredRowHeights.current.set(id, { width, height: bounds.height }); + return { id, top: bounds.top - viewportTop + scrollTop, height: bounds.height }; + }, + ); + if (followOutputRef.current || isJumpSettling()) { + readingAnchor.reset(); + return; + } + const correctedTop = readingAnchor.reconcile(container.scrollTop, rows, userScrolled); + if (Math.abs(correctedTop - container.scrollTop) > 0.5) { + container.scrollTop = correctedTop; + lastKnownScrollTopRef.current = container.scrollTop; + // The DOM scroll event arrives later. Fill the corrected virtual range in + // this layout so the reader does not get a frame containing only the pin. + virtualOffsetListener.current?.(container.scrollTop, false); + } + }); + + // Apply row positions and their scroll correction in the same paint, including + // the ordinary-to-virtualized handoff and measurements of newly mounted rows. + useLayoutEffect(() => { + reconcileReadingPosition(); + }); + const stopFollowingOutputFromUserIntent = useStableEvent(() => { cancelPendingStickToBottom(); if (followOutputRef.current) { @@ -801,11 +752,13 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool } lastKnownScrollTopRef.current = currentScrollTop; + reconcileReadingPosition(scrolledUp || scrolledDown); updateScrollMetrics(); evaluateHistoryStart(); }, [ evaluateHistoryStart, isJumpSettling, + reconcileReadingPosition, stopFollowingOutputFromUserIntent, updateScrollMetrics, ]); @@ -814,8 +767,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool const initialHistoryStartState = createHistoryStartPaginationState(); historyStartPaginationStateRef.current = initialHistoryStartState; setHistoryStartPaginationState(initialHistoryStartState); - historyStartPrependAnchorRef.current = null; - historyStartPrependAnchorActiveRef.current = false; + readingAnchor.reset(); const frame = window.requestAnimationFrame(() => { historyStartReadyRef.current = true; evaluateHistoryStart(); @@ -826,7 +778,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool historyStartSettleSchedulerRef.current?.cancel(); historyStartSettleSchedulerRef.current = null; }; - }, [evaluateHistoryStart, props.agentId]); + }, [evaluateHistoryStart, props.agentId, readingAnchor]); useLayoutEffect(() => { if (!isActiveRef.current || !isActivationReady) { @@ -959,9 +911,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool } else { lastObservedViewportGeometryRef.current = nextGeometry; } - if (historyStartPrependAnchorActiveRef.current) { - applyHistoryStartPrependAnchor(); - } + reconcileReadingPosition(); if (historyStartPaginationStateRef.current.status === "settling") { scheduleHistoryStartPrependSettle(); } @@ -980,7 +930,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool observer.disconnect(); }; }, [ - applyHistoryStartPrependAnchor, + reconcileReadingPosition, evaluateHistoryStart, isActive, reportReadingPosition, @@ -1188,9 +1138,12 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool overflowX: "hidden", overflowY: scrollEnabled ? "auto" : "hidden", overscrollBehaviorY: "contain", + // The browser still anchors delayed image growth while following output. + // Detached reading has one owner: reconcileReadingPosition. + overflowAnchor: followOutput ? "auto" : "none", scrollbarWidth: overlayScrollbarEnabled ? "none" : undefined, }; - }, [isMobileBreakpoint, scrollEnabled]); + }, [followOutput, isMobileBreakpoint, scrollEnabled]); const viewportStyle = useMemo( (): CSSProperties => ({ position: "relative", diff --git a/packages/app/src/agent-stream/web-virtualization.test.ts b/packages/app/src/agent-stream/web-virtualization.test.ts index fc3d82b35c..197b7ae75f 100644 --- a/packages/app/src/agent-stream/web-virtualization.test.ts +++ b/packages/app/src/agent-stream/web-virtualization.test.ts @@ -12,7 +12,6 @@ import { findMountedWindowStart, getWebMountedRecentStreamItems, getWebPartialVirtualizationThreshold, - shouldAdjustScrollForVirtualRowResize, splitWebVirtualizedHistory, type IndexedStreamItem, } from "./web-virtualization"; @@ -170,55 +169,6 @@ describe("estimateStreamItemHeight", () => { }); }); -describe("virtual row resize anchoring", () => { - it("does not move the viewport when a visible row expands while detached", () => { - expect( - shouldAdjustScrollForVirtualRowResize({ - isHistoryStartPrependActive: false, - rowStart: 1200, - scrollOffset: 1000, - remainingDistanceFromBottom: 5000, - bottomThreshold: 64, - }), - ).toBe(false); - }); - - it("keeps the reading position when a row above the viewport changes size", () => { - expect( - shouldAdjustScrollForVirtualRowResize({ - isHistoryStartPrependActive: false, - rowStart: 800, - scrollOffset: 1000, - remainingDistanceFromBottom: 5000, - bottomThreshold: 64, - }), - ).toBe(true); - }); - - it("leaves history prepend and bottom following to their existing anchors", () => { - const baseInput = { - rowStart: 800, - scrollOffset: 1000, - remainingDistanceFromBottom: 5000, - bottomThreshold: 64, - }; - - expect( - shouldAdjustScrollForVirtualRowResize({ - ...baseInput, - isHistoryStartPrependActive: true, - }), - ).toBe(false); - expect( - shouldAdjustScrollForVirtualRowResize({ - ...baseInput, - isHistoryStartPrependActive: false, - remainingDistanceFromBottom: 64, - }), - ).toBe(false); - }); -}); - describe("web virtualization test overrides", () => { it("uses defaults unless explicit positive integer overrides are present", () => { const globalWithOverrides = globalThis as typeof globalThis & { diff --git a/packages/app/src/agent-stream/web-virtualization.ts b/packages/app/src/agent-stream/web-virtualization.ts index 13bc737e5f..5445783178 100644 --- a/packages/app/src/agent-stream/web-virtualization.ts +++ b/packages/app/src/agent-stream/web-virtualization.ts @@ -10,21 +10,6 @@ export const DEFAULT_WEB_PARTIAL_VIRTUALIZATION_THRESHOLD = 100; export const DEFAULT_WEB_MOUNTED_RECENT_STREAM_ITEMS = DEFAULT_MOUNTED_RECENT_STREAM_ITEMS; const COLLAPSED_TOOL_SEQUENCE_ROW_HEIGHT_ESTIMATE = 40; -export function shouldAdjustScrollForVirtualRowResize(input: { - isHistoryStartPrependActive: boolean; - rowStart: number; - scrollOffset: number; - remainingDistanceFromBottom: number; - bottomThreshold: number; -}): boolean { - if (input.isHistoryStartPrependActive) { - return false; - } - return ( - input.remainingDistanceFromBottom > input.bottomThreshold && input.rowStart < input.scrollOffset - ); -} - type BottomAnchorE2ETestGlobals = typeof globalThis & { __PASEO_E2E_WEB_PARTIAL_VIRTUALIZATION_THRESHOLD?: unknown; }; diff --git a/packages/server/src/server/agent/providers/mock-load-test-agent.test.ts b/packages/server/src/server/agent/providers/mock-load-test-agent.test.ts index 27afed7b9a..c9c0f07161 100644 --- a/packages/server/src/server/agent/providers/mock-load-test-agent.test.ts +++ b/packages/server/src/server/agent/providers/mock-load-test-agent.test.ts @@ -449,6 +449,18 @@ describe("MockLoadTestAgentClient", () => { unsubscribe(); }); + test("emits distinct configured responses across consecutive fixture turns", async () => { + const session = await new MockLoadTestAgentClient().createSession({ + provider: "mock", + cwd: process.cwd(), + model: "e2e-fast-stream", + featureValues: { mockAssistantResponses: ["First response", "![Second](second.png)"] }, + }); + await session.run("Emit a synthetic turn failure."); + expect((await session.run("First prompt")).finalText).toBe("First response"); + expect((await session.run("Second prompt")).finalText).toBe("![Second](second.png)"); + }); + test("emits a settled assistant Markdown image path selected by prompt", async () => { vi.useFakeTimers(); const client = new MockLoadTestAgentClient(); diff --git a/packages/server/src/server/agent/providers/mock-load-test-agent.ts b/packages/server/src/server/agent/providers/mock-load-test-agent.ts index 2ebcc0b23e..fccc89224b 100644 --- a/packages/server/src/server/agent/providers/mock-load-test-agent.ts +++ b/packages/server/src/server/agent/providers/mock-load-test-agent.ts @@ -726,6 +726,12 @@ export class MockLoadTestAgentClient implements AgentClient { } } +function getConfiguredAssistantResponses(value: unknown): string[] { + return Array.isArray(value) && value.every((response) => typeof response === "string") + ? [...value] + : []; +} + export class MockLoadTestAgentSession implements AgentSession { readonly provider: AgentProvider = MOCK_LOAD_TEST_PROVIDER_ID; readonly capabilities = CAPABILITIES; @@ -739,6 +745,7 @@ export class MockLoadTestAgentSession implements AgentSession { private modeId: string | null; private modelId: string | null; private readonly assistantResponse: string | null; + private readonly assistantResponses: string[]; private readonly streamingAssistantResponse: string | null; private readonly streamingAssistantIntervalMs: number; private readonly rewindError: string | null; @@ -754,6 +761,9 @@ export class MockLoadTestAgentSession implements AgentSession { typeof options.config.featureValues?.mockAssistantResponse === "string" ? options.config.featureValues.mockAssistantResponse : null; + this.assistantResponses = getConfiguredAssistantResponses( + options.config.featureValues?.mockAssistantResponses, + ); this.streamingAssistantResponse = typeof options.config.featureValues?.mockStreamingAssistantResponse === "string" ? options.config.featureValues.mockStreamingAssistantResponse @@ -841,8 +851,11 @@ export class MockLoadTestAgentSession implements AgentSession { this.scheduleSteeringReplayTurn(turn, steeringReplayShape); } else if (this.streamingAssistantResponse !== null) { this.scheduleStreamingAssistantTurn(turn, this.streamingAssistantResponse); - } else if (this.assistantResponse !== null) { - this.scheduleSettledAssistantTurn(turn, this.assistantResponse); + } else if (this.assistantResponses.length > 0 || this.assistantResponse !== null) { + this.scheduleSettledAssistantTurn( + turn, + (this.assistantResponses.shift() ?? this.assistantResponse)!, + ); } else if (structuredBranchName) { this.scheduleSettledAssistantTurn(turn, JSON.stringify(structuredBranchName)); } else if (settledAssistantImageMarkdown) {