Keep mobile workspace switching responsive with long chats (#3610)

* perf(app): bound native workspace mount work

Native workspace switches retained inactive React trees and mounted large chat histories in aggressive list batches, making Hermes and Fabric work scale with prior usage.

Retain only the active native workspace, reveal a shared turn-aligned history window locally before daemon pagination, preserve desktop retention semantics, and pace native list mounting.

* test(app): cover bounded history journeys

Exercise local history reveal and hidden outline jumps through the production browser viewport while preserving held-page pagination and scroll anchoring.

* fix(app): reveal fetched outline targets

A bounded merged jump could load its target before the mounted history window, leaving the pending jump unable to scroll. Reveal fetched hidden targets through the existing history-window capability, then scroll once they mount.

* test(app): follow anchored history reveals

History-start helpers could stop at a transient edge before local window expansion restored the content anchor. Retry the real wheel or keyboard action after geometry settles until the timeline remains at history start.

* test(app): wait for history anchor release
This commit is contained in:
Mohamed Boudra
2026-08-20 20:52:12 +02:00
committed by GitHub
parent 5d7afd59ae
commit 2a0a0ff656
17 changed files with 646 additions and 59 deletions
@@ -91,14 +91,16 @@ test.describe("Agent timeline pagination", () => {
await expectTimelinePromptVisible(page, agent.newestPrompt);
await expectTimelinePromptCentered(page, agent.newestPrompt);
await expectTimelinePromptNotMounted(page, agent.oldestPrompt);
await expectTimelinePromptNotMounted(page, agent.initialTailOldestPrompt);
await userScrollsTimelineToHistoryStart(page);
await expectTimelinePromptVisible(page, agent.initialTailOldestPrompt);
await history.expectRequestedPages(1);
history.releasePage(1);
await history.expectSettledWithRequestedPages(1);
await expectTimelinePromptCentered(page, agent.firstOlderPagePrompt);
await userScrollsTimelineToHistoryStart(page);
await expectTimelinePromptCentered(page, agent.firstOlderPagePrompt);
await history.expectRequestedPages(2);
} finally {
await agent.cleanup();
@@ -119,6 +119,7 @@ test.describe("desktop chat outline", () => {
test("previews and jumps to the focused prompt from the keyboard", async ({ page }) => {
await focusChatOutlinePrompt(page, 1);
await expectChatOutlinePreview(page, agent.prompts[0]);
await expectTimelinePromptNotMounted(page, agent.oldestPrompt);
await pressEnterOnFocusedPrompt(page);
await expectTimelinePromptLandedBelowTop(page, agent.oldestPrompt);
@@ -532,30 +532,35 @@ export async function scrollTimelineToOldestLoadedEdge(page: Page): Promise<void
export async function userScrollsTimelineToHistoryStart(page: Page): Promise<void> {
const scroll = page.locator('[data-testid="agent-chat-scroll"]:visible').first();
await scroll.hover();
for (let step = 0; step < 60; step += 1) {
if ((await readTimelineViewport(page)).scrollTop <= HISTORY_START_THRESHOLD_PX) {
break;
}
await page.mouse.wheel(0, -1_000);
await page.evaluate(
() =>
new Promise<void>((resolve) => {
requestAnimationFrame(() => resolve());
}),
);
}
await expect
.poll(async () => (await readTimelineViewport(page)).scrollTop)
.toBeLessThanOrEqual(HISTORY_START_THRESHOLD_PX);
await returnTimelineToSettledHistoryStart(page, () => page.mouse.wheel(0, -1_000));
}
export async function userNavigatesTimelineToHistoryStartWithKeyboard(page: Page): Promise<void> {
const scroll = page.locator('[data-testid="agent-chat-scroll"]:visible').first();
await scroll.focus();
await page.keyboard.press("Home");
await expect
.poll(async () => (await readTimelineViewport(page)).scrollTop)
.toBeLessThanOrEqual(HISTORY_START_THRESHOLD_PX);
await returnTimelineToSettledHistoryStart(page, () => page.keyboard.press("Home"));
}
async function returnTimelineToSettledHistoryStart(
page: Page,
moveUp: () => Promise<unknown>,
): Promise<void> {
let previous = await readTimelineViewport(page);
while (true) {
await moveUp();
await waitForTimelineGeometryToSettle(page);
let viewport = await readTimelineViewport(page);
if (viewport.scrollTop <= HISTORY_START_THRESHOLD_PX) {
return;
}
await expect(page.getByTestId("load-older-history-spinner")).toBeHidden();
await waitForTimelineGeometryToSettle(page);
viewport = await readTimelineViewport(page);
expect(
viewport.scrollTop < previous.scrollTop || viewport.scrollHeight > previous.scrollHeight,
).toBe(true);
previous = viewport;
}
}
export async function scrollTimelineToNewestLoadedEdge(page: Page): Promise<void> {
@@ -174,4 +174,151 @@ describe("useChatOutline", () => {
await waitFor(() => expect(onJumpError).toHaveBeenCalledOnce());
});
it("reveals an already-loaded older prompt before scrolling to it", async () => {
runtime.listAgentTimelinePrompts.mockResolvedValue({
epoch: "epoch-1",
prompts: [{ seq: 1, timestamp: new Date(1).toISOString(), preview: "prompt" }],
});
const scrollToMessage = vi.fn();
const revealLoadedItem = vi.fn(() => true);
const viewport: StreamViewportHandle = {
scrollToBottom: vi.fn(),
prepareForViewportChange: vi.fn(),
scrollToMessage,
};
const viewportRef = { current: viewport };
const tail = [
{
id: "older-prompt",
kind: "user_message" as const,
text: "prompt",
timestamp: new Date(1),
timelineCursor: { epoch: "epoch-1", seq: 1 },
},
];
const { result, rerender } = renderHook(
({ visibleItemIds }) =>
useChatOutline({
agentId: "agent-1",
serverId: "server-1",
timelineEpoch: "epoch-1",
tail,
head: [],
enabled: true,
viewportRef,
onJumpError: vi.fn(),
visibleItemIds,
revealLoadedItem,
}),
{ initialProps: { visibleItemIds: new Set<string>() } },
);
await waitFor(() => expect(result.current.prompts).toHaveLength(1));
await act(async () => result.current.jumpToPrompt(1));
expect(revealLoadedItem).toHaveBeenCalledWith("older-prompt");
expect(scrollToMessage).not.toHaveBeenCalled();
rerender({ visibleItemIds: new Set(["older-prompt"]) });
await waitFor(() => expect(scrollToMessage).toHaveBeenCalledWith("older-prompt"));
});
it("reveals a fetched prompt that lands outside the mounted history window", async () => {
runtime.listAgentTimelinePrompts.mockResolvedValue({
epoch: "epoch-1",
prompts: [{ seq: 1, timestamp: new Date(1).toISOString(), preview: "prompt" }],
});
const fetch = deferred<void>();
runtime.fetchAgentTimeline.mockReturnValue(fetch.promise);
const scrollToMessage = vi.fn();
const revealLoadedItem = vi.fn(() => true);
const viewportRef = {
current: {
scrollToBottom: vi.fn(),
prepareForViewportChange: vi.fn(),
scrollToMessage,
},
};
const fetchedPrompt = {
id: "fetched-prompt",
kind: "user_message" as const,
text: "prompt",
timestamp: new Date(1),
timelineCursor: { epoch: "epoch-1", seq: 1 },
};
const { result, rerender } = renderHook(
({ tail, visibleItemIds }) =>
useChatOutline({
agentId: "agent-1",
serverId: "server-1",
timelineEpoch: "epoch-1",
tail,
head: [],
enabled: true,
viewportRef,
onJumpError: vi.fn(),
visibleItemIds,
revealLoadedItem,
}),
{
initialProps: {
tail: [] as (typeof fetchedPrompt)[],
visibleItemIds: new Set<string>(),
},
},
);
await waitFor(() => expect(result.current.prompts).toHaveLength(1));
act(() => result.current.jumpToPrompt(1));
await waitFor(() => expect(runtime.fetchAgentTimeline).toHaveBeenCalledOnce());
rerender({ tail: [fetchedPrompt], visibleItemIds: new Set<string>() });
await waitFor(() => expect(revealLoadedItem).toHaveBeenCalledWith(fetchedPrompt.id));
expect(scrollToMessage).not.toHaveBeenCalled();
rerender({ tail: [fetchedPrompt], visibleItemIds: new Set([fetchedPrompt.id]) });
await waitFor(() => expect(scrollToMessage).toHaveBeenCalledOnce());
expect(scrollToMessage).toHaveBeenCalledWith(fetchedPrompt.id);
await act(async () => fetch.resolve());
});
it("jumps directly to a loaded prompt in the live head", async () => {
runtime.listAgentTimelinePrompts.mockResolvedValue({
epoch: "epoch-1",
prompts: [{ seq: 2, timestamp: new Date(2).toISOString(), preview: "prompt" }],
});
const scrollToMessage = vi.fn();
const viewport: StreamViewportHandle = {
scrollToBottom: vi.fn(),
prepareForViewportChange: vi.fn(),
scrollToMessage,
};
const livePrompt = {
id: "live-prompt",
kind: "user_message" as const,
text: "prompt",
timestamp: new Date(2),
timelineCursor: { epoch: "epoch-1", seq: 2 },
};
const revealLoadedItem = vi.fn();
revealLoadedItem.mockReturnValue(false);
const { result } = renderHook(() =>
useChatOutline({
agentId: "agent-1",
serverId: "server-1",
timelineEpoch: "epoch-1",
tail: [],
head: [livePrompt],
enabled: true,
viewportRef: { current: viewport },
onJumpError: vi.fn(),
visibleItemIds: new Set([livePrompt.id]),
revealLoadedItem,
}),
);
await waitFor(() => expect(result.current.prompts).toHaveLength(1));
await act(async () => result.current.jumpToPrompt(2));
expect(scrollToMessage).toHaveBeenCalledWith("live-prompt");
});
});
@@ -33,6 +33,8 @@ export interface UseChatOutlineInput {
enabled: boolean;
viewportRef: RefObject<StreamViewportHandle | null>;
onJumpError: () => void;
visibleItemIds?: ReadonlySet<string>;
revealLoadedItem?: (itemId: string) => boolean;
}
export interface ChatOutline {
@@ -51,6 +53,8 @@ export function useChatOutline({
enabled,
viewportRef,
onJumpError,
visibleItemIds,
revealLoadedItem,
}: UseChatOutlineInput): ChatOutline {
const [index, setIndex] = useState<AgentTimelinePromptIndexPayload | null>(null);
const [pendingJump, setPendingJump] = useState<PendingPromptJump | null>(null);
@@ -135,7 +139,12 @@ export function useChatOutline({
useEffect(() => {
if (pendingJump === null) return;
const target = loadedItems.find((item) => item.timelineCursor?.seq === pendingJump.seq);
if (target && !pendingJump.hasScrolled) {
if (target) {
if (pendingJump.hasScrolled) return;
if (visibleItemIds?.has(target.id) === false) {
revealLoadedItem?.(target.id);
return;
}
viewportRef.current?.scrollToMessage?.(target.id);
setPendingJump((current) => {
if (current?.requestId !== pendingJump.requestId) return current;
@@ -144,7 +153,7 @@ export function useChatOutline({
return;
}
if (pendingJump.fetchSettled) setPendingJump(null);
}, [loadedItems, pendingJump, viewportRef]);
}, [loadedItems, pendingJump, revealLoadedItem, viewportRef, visibleItemIds]);
const jumpToPrompt = useCallback(
(seq: number) => {
@@ -152,6 +161,11 @@ export function useChatOutline({
setPendingJump(null);
const loaded = loadedItems.find((item) => item.timelineCursor?.seq === seq);
if (loaded) {
if (revealLoadedItem?.(loaded.id)) {
const requestId = nextJumpRequestIdRef.current;
setPendingJump({ requestId, seq, fetchSettled: true, hasScrolled: false });
return;
}
viewportRef.current?.scrollToMessage?.(loaded.id);
return;
}
@@ -171,7 +185,7 @@ export function useChatOutline({
});
});
},
[agentId, index, loadedItems, onJumpError, serverId, viewportRef],
[agentId, index, loadedItems, onJumpError, revealLoadedItem, serverId, viewportRef],
);
return { prompts, activePrompt, jumpToPrompt, reportReadingPosition };
@@ -22,6 +22,22 @@ const visibleHistoryStart: HistoryStartPaginationInput = {
};
describe("history start pagination", () => {
it("treats locally revealed history as progress before remote pagination", () => {
const first = evaluateHistoryStartPagination(
createArmedHistoryStartPaginationState(),
visibleHistoryStart,
);
const localReveal = evaluateHistoryStartPagination(first.state, {
...visibleHistoryStart,
progressKey: "epoch-1:20:local-60",
});
expect(first.shouldLoad).toBe(true);
expect(localReveal.state).toEqual({
status: "settling",
loadedProgressKey: "epoch-1:20:local-60",
});
});
it("waits for user scroll intent before loading", () => {
const dormant = createHistoryStartPaginationState();
const evaluated = evaluateHistoryStartPagination(dormant, visibleHistoryStart);
@@ -0,0 +1,39 @@
import type { StreamItem } from "@/types/stream";
export const DEFAULT_MOUNTED_RECENT_STREAM_ITEMS = 20;
type MountedRecentStreamItemsE2ETestGlobals = typeof globalThis & {
__PASEO_E2E_WEB_MOUNTED_RECENT_STREAM_ITEMS?: unknown;
};
function readPositiveIntegerOverride(value: unknown): number | null {
if (!Number.isFinite(value)) {
return null;
}
const normalized = Math.trunc(value as number);
return normalized > 0 ? normalized : null;
}
export function getMountedRecentStreamItems(): number {
const override = readPositiveIntegerOverride(
(globalThis as MountedRecentStreamItemsE2ETestGlobals)
.__PASEO_E2E_WEB_MOUNTED_RECENT_STREAM_ITEMS,
);
return override ?? DEFAULT_MOUNTED_RECENT_STREAM_ITEMS;
}
export function findMountedWindowStart(input: {
items: StreamItem[];
minMountedCount: number;
}): number {
const { items, minMountedCount } = input;
if (items.length <= minMountedCount) {
return 0;
}
let startIndex = Math.max(items.length - minMountedCount, 0);
while (startIndex > 0 && items[startIndex]?.kind !== "user_message") {
startIndex -= 1;
}
return startIndex;
}
@@ -25,6 +25,56 @@ function assistantMessage(id: string, seed: number): StreamItem {
}
describe("buildAgentStreamRenderModel", () => {
it("projects a bounded recent turn-aligned history window on every platform", () => {
const tail = [
userMessage("u1", 1),
assistantMessage("a1", 2),
userMessage("u2", 3),
assistantMessage("a2", 4),
userMessage("u3", 5),
assistantMessage("a3", 6),
];
const model = buildAgentStreamRenderModel({
isTurnActive: false,
activeTurnStartedAt: null,
tail,
head: [],
platform: "native",
isMobileBreakpoint: false,
historyStart: 2,
});
expect(model.history.map((item) => item.id)).toEqual(["a3", "u3", "a2", "u2"]);
expect(model.segments.historyVirtualized).toHaveLength(0);
});
it("derives timing only for the rendered history window", () => {
const tail = [
userMessage("hidden-u", 1),
assistantMessage("hidden-a", 2),
userMessage("visible-u", 3),
assistantMessage("visible-a", 4),
];
const model = buildAgentStreamRenderModel({
isTurnActive: false,
activeTurnStartedAt: null,
tail,
head: [],
platform: "web",
isMobileBreakpoint: false,
historyStart: 2,
});
expect(model.turnTiming.byAssistantId.has("hidden-a")).toBe(false);
expect(model.turnTiming.byAssistantId.get("visible-a")).toEqual({
startedAt: tail[2]?.timestamp,
completedAt: tail[3]?.timestamp,
durationMs: 1000,
});
});
it("keeps head separate from committed history on desktop web", () => {
const tail: StreamItem[] = [];
for (let index = 0; index < 60; index += 1) {
+8 -9
View File
@@ -1,11 +1,8 @@
import type { ReactNode } from "react";
import { deriveStreamTurnTiming, type StreamTurnTiming } from "@/timeline/turn-time";
import type { StreamItem } from "@/types/stream";
import {
findMountedWindowStart,
getWebMountedRecentStreamItems,
getWebPartialVirtualizationThreshold,
} from "./web-virtualization";
import { findMountedWindowStart, getMountedRecentStreamItems } from "./history-window";
import { getWebPartialVirtualizationThreshold } from "./web-virtualization";
import { orderHeadForStreamRenderStrategy, orderTailForStreamRenderStrategy } from "./strategy";
import { resolveStreamRenderStrategy } from "./strategy-resolver";
@@ -41,6 +38,7 @@ export interface BuildAgentStreamRenderModelInput {
head: StreamItem[];
platform: "web" | "native";
isMobileBreakpoint: boolean;
historyStart?: number;
}
const EMPTY_STREAM_ITEMS: StreamItem[] = [];
@@ -91,7 +89,7 @@ function splitOrderedTail(params: {
platform === "web" &&
!isMobileBreakpoint &&
orderedTail.length > getWebPartialVirtualizationThreshold();
const cacheKey = `${platform}:${isMobileBreakpoint}:${getWebMountedRecentStreamItems()}:${shouldSplitHistory}`;
const cacheKey = `${platform}:${isMobileBreakpoint}:${getMountedRecentStreamItems()}:${shouldSplitHistory}`;
let cachedByKey = splitHistoryCache.get(orderedTail);
if (!cachedByKey) {
cachedByKey = new Map();
@@ -117,7 +115,7 @@ function splitOrderedTail(params: {
const mountedWindowStart = findMountedWindowStart({
items: orderedTail,
minMountedCount: getWebMountedRecentStreamItems(),
minMountedCount: getMountedRecentStreamItems(),
});
const split = {
history: orderedTail,
@@ -165,9 +163,10 @@ export function buildAgentStreamRenderModel(
isMobileBreakpoint: input.isMobileBreakpoint,
});
const orderingCacheKey = `${input.platform}:${input.isMobileBreakpoint}`;
const renderedTail = input.historyStart ? input.tail.slice(input.historyStart) : input.tail;
const orderedTail = getOrderedItems({
cache: orderedTailCache,
source: input.tail,
source: renderedTail,
cacheKey: orderingCacheKey,
order: (items) =>
orderTailForStreamRenderStrategy({
@@ -193,7 +192,7 @@ export function buildAgentStreamRenderModel(
const turnTiming = getTurnTiming({
isTurnActive: input.isTurnActive,
activeTurnStartedAt: input.activeTurnStartedAt,
tail: input.tail,
tail: renderedTail,
head: input.head,
});
@@ -617,10 +617,8 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
scrollEventThrottle={16}
onContentSizeChange={handleContentSizeChange}
maintainVisibleContentPosition={maintainVisibleContentPosition}
initialNumToRender={40}
maxToRenderPerBatch={40}
updateCellsBatchingPeriod={0}
windowSize={21}
initialNumToRender={12}
windowSize={10}
removeClippedSubviews={false}
scrollEnabled={scrollEnabled}
showsVerticalScrollIndicator
@@ -0,0 +1,159 @@
// @vitest-environment jsdom
import { act, renderHook } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { StreamItem } from "@/types/stream";
import { useStreamHistoryWindow } from "./use-stream-history-window";
function message(id: string, kind: "user_message" | "assistant_message"): StreamItem {
return { id, kind, text: id, timestamp: new Date() };
}
afterEach(() => {
Reflect.deleteProperty(globalThis, "__PASEO_E2E_WEB_MOUNTED_RECENT_STREAM_ITEMS");
});
describe("useStreamHistoryWindow", () => {
it("mounts only the most recent 20 items by default", () => {
const items = Array.from({ length: 11 }, (_, turn) => [
message(`u${turn}`, "user_message"),
message(`a${turn}`, "assistant_message"),
]).flat();
const { result } = renderHook(() =>
useStreamHistoryWindow({ agentId: "agent-1", items, loadRemoteOlder: vi.fn() }),
);
expect(result.current.start).toBe(2);
});
it("reveals loaded history before requesting an older daemon page", async () => {
Object.defineProperty(globalThis, "__PASEO_E2E_WEB_MOUNTED_RECENT_STREAM_ITEMS", {
value: 2,
configurable: true,
});
const loadRemoteOlder = vi.fn(async () => true);
const { result } = renderHook(() =>
useStreamHistoryWindow({
agentId: "agent-1",
items: [
message("u1", "user_message"),
message("a1", "assistant_message"),
message("u2", "user_message"),
message("a2", "assistant_message"),
],
loadRemoteOlder,
}),
);
expect(result.current.start).toBe(2);
await act(async () => result.current.loadOlder());
expect(result.current.start).toBe(0);
expect(loadRemoteOlder).not.toHaveBeenCalled();
await act(async () => result.current.loadOlder());
expect(loadRemoteOlder).toHaveBeenCalledOnce();
});
it("keeps its boundary at the same item when loaded history is prepended", () => {
Object.defineProperty(globalThis, "__PASEO_E2E_WEB_MOUNTED_RECENT_STREAM_ITEMS", {
value: 2,
configurable: true,
});
const items = [
message("u1", "user_message"),
message("a1", "assistant_message"),
message("u2", "user_message"),
message("a2", "assistant_message"),
];
const { result, rerender } = renderHook(
({ history }) =>
useStreamHistoryWindow({ agentId: "agent-1", items: history, loadRemoteOlder: vi.fn() }),
{ initialProps: { history: items } },
);
expect(result.current.start).toBe(2);
rerender({
history: [message("u0", "user_message"), message("a0", "assistant_message"), ...items],
});
expect(result.current.start).toBe(4);
});
it("keeps live appends inside the rendered window", () => {
Object.defineProperty(globalThis, "__PASEO_E2E_WEB_MOUNTED_RECENT_STREAM_ITEMS", {
value: 2,
configurable: true,
});
const items = [
message("u1", "user_message"),
message("a1", "assistant_message"),
message("u2", "user_message"),
message("a2", "assistant_message"),
];
const { result, rerender } = renderHook(
({ history }) =>
useStreamHistoryWindow({ agentId: "agent-1", items: history, loadRemoteOlder: vi.fn() }),
{ initialProps: { history: items } },
);
rerender({
history: [...items, message("u3", "user_message"), message("a3", "assistant_message")],
});
expect(result.current.start).toBe(2);
});
it("restarts at a recent window when an item replacement removes the boundary", () => {
Object.defineProperty(globalThis, "__PASEO_E2E_WEB_MOUNTED_RECENT_STREAM_ITEMS", {
value: 2,
configurable: true,
});
const { result, rerender } = renderHook(
({ history }) =>
useStreamHistoryWindow({ agentId: "agent-1", items: history, loadRemoteOlder: vi.fn() }),
{
initialProps: {
history: [
message("u1", "user_message"),
message("a1", "assistant_message"),
message("u2", "user_message"),
message("a2", "assistant_message"),
],
},
},
);
rerender({
history: [
message("new-u1", "user_message"),
message("new-a1", "assistant_message"),
message("new-u2", "user_message"),
message("new-a2", "assistant_message"),
message("new-u3", "user_message"),
message("new-a3", "assistant_message"),
],
});
expect(result.current.start).toBe(4);
});
it("does not claim to reveal a missing or already-visible item", () => {
Object.defineProperty(globalThis, "__PASEO_E2E_WEB_MOUNTED_RECENT_STREAM_ITEMS", {
value: 2,
configurable: true,
});
const { result } = renderHook(() =>
useStreamHistoryWindow({
agentId: "agent-1",
items: [
message("u1", "user_message"),
message("a1", "assistant_message"),
message("u2", "user_message"),
message("a2", "assistant_message"),
],
loadRemoteOlder: vi.fn(),
}),
);
expect(result.current.revealLoadedHistory("missing")).toBe(false);
expect(result.current.revealLoadedHistory("u2")).toBe(false);
});
});
@@ -0,0 +1,115 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import type { StreamItem } from "@/types/stream";
import { findMountedWindowStart, getMountedRecentStreamItems } from "./history-window";
function findEarlierHistoryWindowStart(input: {
items: StreamItem[];
start: number;
itemId: string | undefined;
}): number {
const targetIndex = input.itemId ? input.items.findIndex((item) => item.id === input.itemId) : -1;
const targetStart = targetIndex >= 0 && targetIndex < input.start ? targetIndex : input.start;
if (!input.itemId) {
return findMountedWindowStart({
items: input.items.slice(0, targetStart),
minMountedCount: getMountedRecentStreamItems(),
});
}
return findMountedWindowStart({
items: input.items.slice(0, targetStart + 1),
minMountedCount: 1,
});
}
interface HistoryWindowBoundary {
agentId: string;
boundaryItemId: string | null;
initialized: boolean;
}
function reconcileHistoryWindow(input: {
current: HistoryWindowBoundary;
agentId: string;
boundaryIndex: number;
initialBoundaryItemId: string | null;
hasItems: boolean;
}): HistoryWindowBoundary {
const { current, agentId, boundaryIndex, initialBoundaryItemId, hasItems } = input;
if (current.agentId !== agentId) {
return { agentId, boundaryItemId: initialBoundaryItemId, initialized: hasItems };
}
if (!current.initialized && hasItems) {
return { ...current, boundaryItemId: initialBoundaryItemId, initialized: true };
}
if (current.boundaryItemId !== null && boundaryIndex < 0) {
return { ...current, boundaryItemId: initialBoundaryItemId };
}
return current;
}
export function useStreamHistoryWindow(input: {
agentId: string;
items: StreamItem[];
loadRemoteOlder: () => boolean | Promise<boolean>;
}) {
const { agentId, items, loadRemoteOlder } = input;
const initialStart = useMemo(
() => findMountedWindowStart({ items, minMountedCount: getMountedRecentStreamItems() }),
[items],
);
const initialBoundaryItemId = initialStart === 0 ? null : (items[initialStart]?.id ?? null);
const [window, setWindow] = useState(() => ({
agentId,
boundaryItemId: initialBoundaryItemId,
initialized: items.length > 0,
}));
const boundaryIndex =
window.agentId === agentId && window.boundaryItemId !== null
? items.findIndex((item) => item.id === window.boundaryItemId)
: -1;
let start = initialStart;
if (window.agentId === agentId && window.initialized) {
if (window.boundaryItemId === null) {
start = 0;
} else if (boundaryIndex >= 0) {
start = boundaryIndex;
}
}
useEffect(() => {
setWindow((current) =>
reconcileHistoryWindow({
current,
agentId,
boundaryIndex,
initialBoundaryItemId,
hasItems: items.length > 0,
}),
);
}, [agentId, boundaryIndex, initialBoundaryItemId, items.length]);
const revealLoadedHistory = useCallback(
(itemId?: string): boolean => {
const targetIndex = itemId ? items.findIndex((item) => item.id === itemId) : -1;
if (start === 0 || (itemId !== undefined && (targetIndex < 0 || targetIndex >= start))) {
return false;
}
const nextStart = findEarlierHistoryWindowStart({ items, start, itemId });
if (nextStart === start) {
return false;
}
setWindow({
agentId,
boundaryItemId: items[nextStart]?.id ?? null,
initialized: true,
});
return true;
},
[agentId, items, start],
);
const loadOlder = useCallback(async (): Promise<boolean> => {
return revealLoadedHistory() || (await loadRemoteOlder());
}, [loadRemoteOlder, revealLoadedHistory]);
return { start, hasLocalHistory: start > 0, revealLoadedHistory, loadOlder };
}
+31 -1
View File
@@ -105,6 +105,7 @@ import { isWeb } from "@/constants/platform";
import type { Theme } from "@/styles/theme";
import { recordRenderProfileReasons } from "@/utils/render-profiler";
import { useRetainedPanelActive } from "@/components/retained-panel";
import { useStreamHistoryWindow } from "./use-stream-history-window";
function renderLiveAuxiliaryNode(input: {
pendingPermissions: ReactNode;
@@ -376,7 +377,12 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
agentId,
toast,
});
const { isLoadingOlder, hasOlder, progressKey, loadOlder } = historyPagination
const {
isLoadingOlder: remoteIsLoadingOlder,
hasOlder: remoteHasOlder,
progressKey: remoteProgressKey,
loadOlder: loadRemoteOlder,
} = historyPagination
? {
isLoadingOlder: historyPagination.isLoadingOlder,
hasOlder: historyPagination.hasOlder,
@@ -520,6 +526,19 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
toolCallDetailLevel,
],
);
const {
start: historyWindowStart,
hasLocalHistory,
revealLoadedHistory,
loadOlder,
} = useStreamHistoryWindow({
agentId,
items: projectedToolCalls.tail,
loadRemoteOlder,
});
const isLoadingOlder = remoteIsLoadingOlder;
const hasOlder = hasLocalHistory || remoteHasOlder;
const progressKey = `${remoteProgressKey ?? "local"}:${historyWindowStart}`;
const baseRenderModel = useMemo(() => {
return buildAgentStreamRenderModel({
@@ -529,6 +548,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
head: projectedToolCalls.head,
platform: isWeb ? "web" : "native",
isMobileBreakpoint: isMobile,
historyStart: historyWindowStart,
});
}, [
isMobile,
@@ -536,6 +556,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
projectedToolCalls.head,
projectedToolCalls.tail,
effectiveTurnPresentation.startedAt,
historyWindowStart,
]);
const streamLayout = useMemo(
() =>
@@ -557,6 +578,13 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
const handleTimelineHistoryLoadError = useCallback(() => {
toast?.error(t("agentStream.historyLoadFailed"));
}, [t, toast]);
const visibleHistoryItemIds = useMemo(
() =>
new Set(
[...baseRenderModel.history, ...baseRenderModel.segments.liveHead].map((item) => item.id),
),
[baseRenderModel.history, baseRenderModel.segments.liveHead],
);
const chatOutline = useChatOutline({
agentId,
serverId: resolvedServerId,
@@ -566,6 +594,8 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
enabled: supportsChatOutline && chatOutlineEnabled,
viewportRef,
onJumpError: handleTimelineHistoryLoadError,
visibleItemIds: visibleHistoryItemIds,
revealLoadedItem: revealLoadedHistory,
});
useImperativeHandle(
@@ -1,13 +1,17 @@
import type { StreamItem } from "@/types/stream";
import { estimateAssistantMessageHeightFromCache } from "@/utils/assistant-message-height-estimate";
import {
DEFAULT_MOUNTED_RECENT_STREAM_ITEMS,
findMountedWindowStart,
getMountedRecentStreamItems,
} from "./history-window";
export const DEFAULT_WEB_PARTIAL_VIRTUALIZATION_THRESHOLD = 100;
export const DEFAULT_WEB_MOUNTED_RECENT_STREAM_ITEMS = 50;
export const DEFAULT_WEB_MOUNTED_RECENT_STREAM_ITEMS = DEFAULT_MOUNTED_RECENT_STREAM_ITEMS;
const COLLAPSED_TOOL_SEQUENCE_ROW_HEIGHT_ESTIMATE = 40;
type BottomAnchorE2ETestGlobals = typeof globalThis & {
__PASEO_E2E_WEB_PARTIAL_VIRTUALIZATION_THRESHOLD?: unknown;
__PASEO_E2E_WEB_MOUNTED_RECENT_STREAM_ITEMS?: unknown;
};
function readPositiveIntegerOverride(value: unknown): number | null {
@@ -26,10 +30,7 @@ export function getWebPartialVirtualizationThreshold(): number {
}
export function getWebMountedRecentStreamItems(): number {
const override = readPositiveIntegerOverride(
(globalThis as BottomAnchorE2ETestGlobals).__PASEO_E2E_WEB_MOUNTED_RECENT_STREAM_ITEMS,
);
return override ?? DEFAULT_WEB_MOUNTED_RECENT_STREAM_ITEMS;
return getMountedRecentStreamItems();
}
export interface IndexedStreamItem {
@@ -63,21 +64,7 @@ export function estimateStreamItemHeight(item: StreamItem): number {
}
}
export function findMountedWindowStart(input: {
items: StreamItem[];
minMountedCount: number;
}): number {
const { items, minMountedCount } = input;
if (items.length <= minMountedCount) {
return 0;
}
let startIndex = Math.max(items.length - minMountedCount, 0);
while (startIndex > 0 && items[startIndex]?.kind !== "user_message") {
startIndex -= 1;
}
return startIndex;
}
export { findMountedWindowStart };
export function splitWebVirtualizedHistory(input: {
entries: IndexedStreamItem[];
@@ -19,6 +19,7 @@ import {
getWorkspaceSelectionKey,
orderWorkspaceSelectionsForStableRender,
reconcileRetainedWorkspaceSelections,
resolveWorkspaceDeckRetentionLimit,
type RetainedWorkspaceSelection,
resolveWorkspaceDeckEntries,
shouldKeepWorkspaceDeckEntryMounted,
@@ -33,7 +34,7 @@ import {
stripHostWorkspaceRouteEchoSearchFromBrowserUrlAfterCommit,
} from "@/utils/host-route-browser";
import { prepareWorkspaceTab } from "@/utils/workspace-navigation";
import { isWeb } from "@/constants/platform";
import { isNative, isWeb } from "@/constants/platform";
import { RenderProfile } from "@/utils/render-profiler";
function getParamValue(value: string | string[] | undefined): string {
@@ -217,6 +218,7 @@ function WorkspaceDeck({
currentEntries: retainedSelections,
activeSelection,
now: reconciliationNow,
maxMountedWorkspaces: resolveWorkspaceDeckRetentionLimit({ isNative }),
}),
[activeSelection, reconciliationNow, retainedSelections],
);
@@ -253,6 +255,7 @@ function WorkspaceDeck({
currentEntries: current,
activeSelection,
now: Date.now(),
maxMountedWorkspaces: resolveWorkspaceDeckRetentionLimit({ isNative }),
}),
);
}, expirationDelay + 1);
@@ -4,6 +4,7 @@ import {
getNextWorkspaceDeckExpirationDelay,
orderWorkspaceSelectionsForStableRender,
reconcileRetainedWorkspaceSelections,
resolveWorkspaceDeckRetentionLimit,
resolveWorkspaceDeckEntries,
shouldKeepWorkspaceDeckEntryMounted,
WORKSPACE_DECK_INACTIVE_TTL_MS,
@@ -29,6 +30,23 @@ function retainedWorkspaceIds(
}
describe("reconcileRetainedWorkspaceSelections", () => {
it("retains only the active workspace on native", () => {
const entries = reconcileRetainedWorkspaceSelections({
currentEntries: [retained("A", null)],
activeSelection: workspace("B"),
now: 2,
maxMountedWorkspaces: resolveWorkspaceDeckRetentionLimit({ isNative: true }),
});
expect(retainedWorkspaceIds(entries)).toEqual(["B"]);
});
it("retains the desktop deck limit on web", () => {
expect(resolveWorkspaceDeckRetentionLimit({ isNative: false })).toBe(
WORKSPACE_DECK_MAX_MOUNTED_WORKSPACES,
);
});
it("retains inactive workspaces for ten minutes across app-wide routes", () => {
const retainedEntries = [retained("A", 1_000), retained("B", 2_000)];
@@ -8,6 +8,10 @@ export interface RetainedWorkspaceSelection {
inactiveSince: number | null;
}
export function resolveWorkspaceDeckRetentionLimit(input: { isNative: boolean }): number {
return input.isNative ? 1 : WORKSPACE_DECK_MAX_MOUNTED_WORKSPACES;
}
interface ReconcileRetainedWorkspaceSelectionsInput {
currentEntries: RetainedWorkspaceSelection[];
activeSelection: ActiveWorkspaceSelection | null;