Compare commits

...
Author SHA1 Message Date
paseo-bot[bot]andluke 4f96142112 test(app): read expanded turns across the virtual window
Verify every command remains accessible while scrolling and after older history loads. Requiring all expanded rows in the DOM at once contradicts bounded desktop mounting.

Co-authored-by: luke <luopeihuan@gmail.com>
2026-11-03 10:04:10 +00:00
luke a5fa06b80b fix(app): virtualize chat turns that exceed the mounted window
- Desktop web aligns the mounted tail to the current turn, so one long tool run stayed fully mounted and stalled the chat open.
- Cap that rewind at 60 rows for the web virtualizer. A larger minimum still wins, and history paging plus the replica cache still keep a whole turn.
2026-11-03 16:16:32 +08:00
6 changed files with 144 additions and 5 deletions
+36 -3
View File
@@ -3,6 +3,7 @@ import path from "node:path";
import {
holdOlderHistoryPages,
userScrollsTimelineToHistoryStart,
waitForTimelineGeometryToSettle,
} from "../support/helpers/timeline-pagination";
import { getActiveTabTestId } from "../support/helpers/launcher";
import type { Page, Locator } from "@playwright/test";
@@ -407,6 +408,38 @@ async function seedWholeTurnHistory() {
return agent;
}
/** Read every command across the scroll window, without requiring offscreen rows to mount. */
async function expectExpandedCommandsReadable(page: Page, count: number) {
const timeline = page.getByTestId("agent-chat-scroll");
const commands = new Set<number>();
await timeline.hover();
for (let step = 0; step < count && commands.size < count; step += 1) {
const labels = await timeline
.getByRole("button", { name: /echo stress-\d+/ })
.allTextContents();
for (const label of labels) {
const command = /echo stress-(\d+)/.exec(label);
if (command) commands.add(Number(command[1]));
}
if (commands.size < count) {
await page.mouse.wheel(0, 300);
await waitForTimelineGeometryToSettle(page);
}
}
expect([...commands].sort((a, b) => a - b)).toEqual(
Array.from({ length: count }, (_, index) => index),
);
}
async function returnToExpandedSummary(page: Page, summary: Locator) {
await page.getByTestId("agent-chat-scroll").hover();
for (let step = 0; step < 120 && !(await summary.isVisible()); step += 1) {
await page.mouse.wheel(0, -300);
await waitForTimelineGeometryToSettle(page);
}
await summary.scrollIntoViewIfNeeded();
}
test("loads complete long turns and preserves expansion while revealing older history", async ({
page,
}) => {
@@ -423,7 +456,7 @@ test("loads complete long turns and preserves expansion while revealing older hi
await expect(latest).toBeVisible();
await expect.poll(() => rowsBetweenLatestSummaryAndReply(page)).toBe(0);
await latest.click();
await expect.poll(() => rowsBetweenLatestSummaryAndReply(page)).toBe(120);
await expectExpandedCommandsReadable(page, 120);
await userScrollsTimelineToHistoryStart(page);
await history.expectRequestedPages(1);
history.releasePage(1);
@@ -432,9 +465,9 @@ test("loads complete long turns and preserves expansion while revealing older hi
.getByTestId("turn-summary")
.getByRole("button", { name: "Ran 60 commands", exact: true }),
).toBeVisible();
await expect.poll(() => rowsBetweenLatestSummaryAndReply(page)).toBe(120);
await expectExpandedCommandsReadable(page, 120);
history.expectNoRepeatedEntries();
await latest.scrollIntoViewIfNeeded();
await returnToExpandedSummary(page, latest);
await latest.click();
await expect.poll(() => rowsBetweenLatestSummaryAndReply(page)).toBe(0);
} finally {
@@ -26,14 +26,22 @@ export function getMountedRecentStreamItems(): number {
export function findMountedWindowStart(input: {
items: StreamItem[];
minMountedCount: number;
// Caps how far turn alignment may rewind. History paging and the replica
// cache omit it and still keep a whole turn.
maxMountedCount?: number;
}): number {
const { items, minMountedCount } = input;
if (items.length <= minMountedCount) {
return 0;
}
const mountedLimit =
input.maxMountedCount === undefined
? items.length
: Math.max(minMountedCount, input.maxMountedCount);
const earliestStart = Math.max(0, items.length - mountedLimit);
let startIndex = Math.max(items.length - minMountedCount, 0);
while (startIndex > 0 && continuesTurn(items[startIndex - 1], items[startIndex])) {
while (startIndex > earliestStart && continuesTurn(items[startIndex - 1], items[startIndex])) {
startIndex -= 1;
}
return startIndex;
@@ -3,6 +3,7 @@ import type { StreamItem } from "@/types/stream";
import { projectPluginTimelineItems } from "@/plugins/timeline/projection";
import { findMountedWindowStart } from "./history-window";
import { buildAgentStreamRenderModel } from "./model";
import { MAX_MOUNTED_WEB_WINDOW_ITEMS } from "./web-virtualization";
function createTimestamp(seed: number): Date {
return new Date(`2026-01-01T00:00:${seed.toString().padStart(2, "0")}.000Z`);
@@ -125,6 +126,30 @@ describe("buildAgentStreamRenderModel", () => {
expect(model.history).not.toContain(head[0]);
});
it("virtualizes a desktop web turn that runs past the mounted cap", () => {
const length = MAX_MOUNTED_WEB_WINDOW_ITEMS + 80;
const tail = Array.from({ length }, (_, index) => ({
...userMessage(`row-${index}`, index % 60),
turnId: "long-run",
}));
const model = buildAgentStreamRenderModel({
isTurnActive: false,
activeTurnStartedAt: null,
tail,
head: [],
platform: "web",
isMobileBreakpoint: false,
});
expect(model.segments.historyMounted).toHaveLength(MAX_MOUNTED_WEB_WINDOW_ITEMS);
expect(model.segments.historyVirtualized).toHaveLength(80);
expect(model.segments.historyMounted[0]?.id).toBe(
`row-${length - MAX_MOUNTED_WEB_WINDOW_ITEMS}`,
);
expect(model.segments.historyVirtualized[0]?.id).toBe("row-0");
});
it("keeps the full committed tail mounted on mobile web", () => {
const tail = [userMessage("u1", 1), assistantMessage("a1", 2)];
const head = [assistantMessage("live-a", 3)];
+5 -1
View File
@@ -2,7 +2,10 @@ import type { ReactNode } from "react";
import { deriveStreamTurnTiming, type StreamTurnTiming } from "@/timeline/turn-time";
import type { StreamItem } from "@/types/stream";
import { findMountedWindowStart, getMountedRecentStreamItems } from "./history-window";
import { getWebPartialVirtualizationThreshold } from "./web-virtualization";
import {
getWebPartialVirtualizationThreshold,
MAX_MOUNTED_WEB_WINDOW_ITEMS,
} from "./web-virtualization";
import { orderHeadForStreamRenderStrategy, orderTailForStreamRenderStrategy } from "./strategy";
import { resolveStreamRenderStrategy } from "./strategy-resolver";
@@ -134,6 +137,7 @@ function splitOrderedTail(params: {
const mountedWindowStart = findMountedWindowStart({
items: orderedTail,
minMountedCount: getMountedRecentStreamItems(),
maxMountedCount: MAX_MOUNTED_WEB_WINDOW_ITEMS,
});
const split = {
history: orderedTail,
@@ -10,6 +10,7 @@ import {
DEFAULT_WEB_PARTIAL_VIRTUALIZATION_THRESHOLD,
estimateStreamItemHeight,
findMountedWindowStart,
MAX_MOUNTED_WEB_WINDOW_ITEMS,
getWebMountedRecentStreamItems,
getWebPartialVirtualizationThreshold,
splitWebVirtualizedHistory,
@@ -108,6 +109,37 @@ describe("findMountedWindowStart", () => {
}),
).toBe(39);
});
it("keeps a whole turn when the caller does not cap the rewind", () => {
const items = Array.from({ length: MAX_MOUNTED_WEB_WINDOW_ITEMS + 40 }, (_, index) => ({
...toolCall(`t${index}`, index % 60),
turnId: "long",
}));
expect(findMountedWindowStart({ items, minMountedCount: 20 })).toBe(0);
});
it("stops a long turn at the cap and still honors a larger minimum", () => {
const items = Array.from({ length: 200 }, (_, index) => ({
...toolCall(`t${index}`, index % 60),
turnId: "long",
}));
expect(
findMountedWindowStart({
items,
minMountedCount: 20,
maxMountedCount: MAX_MOUNTED_WEB_WINDOW_ITEMS,
}),
).toBe(200 - MAX_MOUNTED_WEB_WINDOW_ITEMS);
expect(
findMountedWindowStart({
items,
minMountedCount: 80,
maxMountedCount: MAX_MOUNTED_WEB_WINDOW_ITEMS,
}),
).toBe(120);
});
});
describe("splitWebVirtualizedHistory", () => {
@@ -130,6 +162,39 @@ describe("splitWebVirtualizedHistory", () => {
expect(window.mountedEntries[0]?.item.id).toBe("u5");
expect(window.mountedEntries).toHaveLength(50);
});
it("virtualizes the older rows of a turn that is longer than the mounted cap", () => {
const length = MAX_MOUNTED_WEB_WINDOW_ITEMS + 25;
const items = Array.from({ length }, (_, index) => ({
...toolCall(`t${index}`, index % 60),
turnId: "long",
}));
const window = splitWebVirtualizedHistory({
entries: indexEntries(items),
minMountedCount: DEFAULT_WEB_MOUNTED_RECENT_STREAM_ITEMS,
});
expect(window.mountedEntries).toHaveLength(MAX_MOUNTED_WEB_WINDOW_ITEMS);
expect(window.virtualizedEntries).toHaveLength(25);
expect(window.mountedEntries[0]?.item.id).toBe(`t${length - MAX_MOUNTED_WEB_WINDOW_ITEMS}`);
expect(window.virtualizedEntries[0]?.item.id).toBe("t0");
});
it("keeps a turn that fits under the cap mounted together", () => {
const items = Array.from({ length: 40 }, (_, index) => ({
...toolCall(`t${index}`, index),
turnId: "short",
}));
const window = splitWebVirtualizedHistory({
entries: indexEntries(items),
minMountedCount: DEFAULT_WEB_MOUNTED_RECENT_STREAM_ITEMS,
});
expect(window.virtualizedEntries).toHaveLength(0);
expect(window.mountedEntries).toHaveLength(40);
});
});
describe("estimateStreamItemHeight", () => {
@@ -8,6 +8,9 @@ import {
export const DEFAULT_WEB_PARTIAL_VIRTUALIZATION_THRESHOLD = 100;
export const DEFAULT_WEB_MOUNTED_RECENT_STREAM_ITEMS = DEFAULT_MOUNTED_RECENT_STREAM_ITEMS;
// Collapsed tool rows are about 40px, so this stays past a desktop viewport.
// Opening a long run mounts that tail and virtualizes the older rows.
export const MAX_MOUNTED_WEB_WINDOW_ITEMS = 60;
const COLLAPSED_TOOL_SEQUENCE_ROW_HEIGHT_ESTIMATE = 40;
type BottomAnchorE2ETestGlobals = typeof globalThis & {
@@ -86,6 +89,7 @@ export function splitWebVirtualizedHistory(input: {
const startIndex = findMountedWindowStart({
items: input.entries.map((entry) => entry.item),
minMountedCount: input.minMountedCount,
maxMountedCount: MAX_MOUNTED_WEB_WINDOW_ITEMS,
});
return {
virtualizedEntries: input.entries.slice(0, startIndex),