mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-07 17:04:42 +00:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4f96142112 | ||
|
|
a5fa06b80b |
@@ -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)];
|
||||
|
||||
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user