mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-03 23:23:21 +00:00
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.
This commit is contained in:
@@ -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