diff --git a/packages/app/src/agent-stream/history-window.ts b/packages/app/src/agent-stream/history-window.ts index 43cca2bbbf..a223c2d575 100644 --- a/packages/app/src/agent-stream/history-window.ts +++ b/packages/app/src/agent-stream/history-window.ts @@ -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; diff --git a/packages/app/src/agent-stream/model.test.ts b/packages/app/src/agent-stream/model.test.ts index 9c6ef3384d..d963118528 100644 --- a/packages/app/src/agent-stream/model.test.ts +++ b/packages/app/src/agent-stream/model.test.ts @@ -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)]; diff --git a/packages/app/src/agent-stream/model.ts b/packages/app/src/agent-stream/model.ts index 92f8be7141..2008611064 100644 --- a/packages/app/src/agent-stream/model.ts +++ b/packages/app/src/agent-stream/model.ts @@ -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, diff --git a/packages/app/src/agent-stream/web-virtualization.test.ts b/packages/app/src/agent-stream/web-virtualization.test.ts index 2d1f1b5b75..fcf4da470f 100644 --- a/packages/app/src/agent-stream/web-virtualization.test.ts +++ b/packages/app/src/agent-stream/web-virtualization.test.ts @@ -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", () => { diff --git a/packages/app/src/agent-stream/web-virtualization.ts b/packages/app/src/agent-stream/web-virtualization.ts index daf01af558..319164ab3c 100644 --- a/packages/app/src/agent-stream/web-virtualization.ts +++ b/packages/app/src/agent-stream/web-virtualization.ts @@ -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),