fix(app): keep chat reading position stable while scrolling (#5945)

* fix(app): keep chat reading position stable while scrolling

Prepend restoration froze the reading offset while wheel input and delayed image measurements changed layout. Give the viewport one reading anchor, preserve concurrent user movement, and apply virtual row measurements before paint. Suspend automatic pagination while explicit message navigation settles.

* fix(app): type the reading anchor scroll intent explicitly

* fix(app): retain the reading anchor through virtualization startup

Keep the reader pinned while the first virtual range mounts. Strengthen the scroll regression detector to compare shared rows when the original anchor unmounts, and consume fixture replies only when they are emitted.

* test(app): distinguish delayed wheel input from idle scroll drift

CI frames can arrive long after their wheel events. Start idle assertions after the input driver finishes, and account for an image entered by wheel input expanding below the new reading line. Replayed all six CI captures without false positives while retaining the original 76 baseline jumps.

* test(app): bound image growth allowance to displaced reading rows

Require the entered image top to follow wheel input before attributing movement of a later row to its growth. Calibrate the detector against simultaneous viewport jumps and jumps of the growing row itself.

* test(app): clamp scroll expectations at the history boundary
This commit is contained in:
Mohamed Boudra
2026-10-03 10:12:01 +01:00
committed by GitHub
parent 5293ddac3f
commit 8aec0787d2
9 changed files with 773 additions and 224 deletions
@@ -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);
});
});
}
@@ -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<void>,
): Promise<void> {
const timers = new Set<ReturnType<typeof setTimeout>>();
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(
`<svg xmlns="http://www.w3.org/2000/svg" width="${portrait ? 320 : 960}" height="${portrait ? 640 : 240}"><rect width="100%" height="100%" fill="${portrait ? "#936" : "#369"}"/><text x="20" y="50" fill="white" font-size="24">Delayed ${portrait ? "portrait" : "landscape"}</text></svg>`,
);
},
portrait ? 900 : 350,
);
timers.add(timer);
});
await new Promise<void>((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<void>((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<void> {
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<ScrollFrame[]> {
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<string>();
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<Element>();
const capture = (replaceLast: boolean) => {
const rect = scroll.getBoundingClientRect();
const elements = Array.from(scroll.querySelectorAll<HTMLElement>("[data-history-row-id]"));
const current = new Set<Element>([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<void> {
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<void>> {
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<string>((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" });
};
}
@@ -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);
});
});
@@ -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;
},
};
}
+110 -157
View File
@@ -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<HTMLElement>("[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<number | null>(null);
const pendingAutoScrollFrameRef = useRef<number | null>(null);
const pendingAutoScrollTimeoutRef = useRef<number | null>(null);
const pendingVirtualRowMeasureFramesRef = useRef(new Map<Element, number>());
const historyStartReadyRef = useRef(false);
const [historyStartPaginationState, setHistoryStartPaginationState] = useState(
createHistoryStartPaginationState,
);
const historyStartPaginationStateRef = useRef(historyStartPaginationState);
const historyStartPrependAnchorRef = useRef<HistoryStartPrependAnchor | null>(null);
const historyStartPrependAnchorActiveRef = useRef(false);
const readingAnchor = useMemo(createReadingAnchor, []);
const measuredRowHeights = useRef(new Map<string, { width: number; height: number }>());
const historyStartSettleSchedulerRef = useRef<HistoryStartSettleScheduler | null>(null);
const lastActiveFollowOutputLayoutRef = useRef<ActiveFollowOutputLayout | null>(null);
const lastObservedViewportGeometryRef = useRef<ObservedViewportGeometry | null>(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<typeof observeElementOffset>((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<HTMLElement>("[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",
@@ -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 & {
@@ -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;
};
@@ -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();
@@ -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) {