From 2a0a0ff6566cd1acad056a9a2d4918e349b522b0 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Thu, 20 Aug 2026 20:52:12 +0200 Subject: [PATCH] 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 --- .../browser/agent-timeline-pagination.spec.ts | 4 +- packages/app/e2e/browser/chat-outline.spec.ts | 1 + .../support/helpers/timeline-pagination.ts | 43 ++--- .../chat-outline/use-chat-outline.test.tsx | 147 ++++++++++++++++ .../chat-outline/use-chat-outline.ts | 20 ++- .../history-start-pagination.test.ts | 16 ++ .../app/src/agent-stream/history-window.ts | 39 +++++ packages/app/src/agent-stream/model.test.ts | 50 ++++++ packages/app/src/agent-stream/model.ts | 17 +- .../app/src/agent-stream/strategy-native.tsx | 6 +- .../use-stream-history-window.test.tsx | 159 ++++++++++++++++++ .../agent-stream/use-stream-history-window.ts | 115 +++++++++++++ packages/app/src/agent-stream/view.tsx | 32 +++- .../src/agent-stream/web-virtualization.ts | 29 +--- .../workspace/[workspaceId]/index.tsx | 5 +- .../workspace-deck-retention.test.ts | 18 ++ .../workspace/workspace-deck-retention.ts | 4 + 17 files changed, 646 insertions(+), 59 deletions(-) create mode 100644 packages/app/src/agent-stream/history-window.ts create mode 100644 packages/app/src/agent-stream/use-stream-history-window.test.tsx create mode 100644 packages/app/src/agent-stream/use-stream-history-window.ts diff --git a/packages/app/e2e/browser/agent-timeline-pagination.spec.ts b/packages/app/e2e/browser/agent-timeline-pagination.spec.ts index af6b0009c..980d3e3ab 100644 --- a/packages/app/e2e/browser/agent-timeline-pagination.spec.ts +++ b/packages/app/e2e/browser/agent-timeline-pagination.spec.ts @@ -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(); diff --git a/packages/app/e2e/browser/chat-outline.spec.ts b/packages/app/e2e/browser/chat-outline.spec.ts index 758d2b7b8..efece690b 100644 --- a/packages/app/e2e/browser/chat-outline.spec.ts +++ b/packages/app/e2e/browser/chat-outline.spec.ts @@ -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); diff --git a/packages/app/e2e/support/helpers/timeline-pagination.ts b/packages/app/e2e/support/helpers/timeline-pagination.ts index cf02760e9..7cc617280 100644 --- a/packages/app/e2e/support/helpers/timeline-pagination.ts +++ b/packages/app/e2e/support/helpers/timeline-pagination.ts @@ -532,30 +532,35 @@ export async function scrollTimelineToOldestLoadedEdge(page: Page): Promise { 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((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 { 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, +): Promise { + 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 { diff --git a/packages/app/src/agent-stream/chat-outline/use-chat-outline.test.tsx b/packages/app/src/agent-stream/chat-outline/use-chat-outline.test.tsx index 28d836137..6200b8b0f 100644 --- a/packages/app/src/agent-stream/chat-outline/use-chat-outline.test.tsx +++ b/packages/app/src/agent-stream/chat-outline/use-chat-outline.test.tsx @@ -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() } }, + ); + + 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(); + 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(), + }, + }, + ); + + 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() }); + 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"); + }); }); diff --git a/packages/app/src/agent-stream/chat-outline/use-chat-outline.ts b/packages/app/src/agent-stream/chat-outline/use-chat-outline.ts index 96f780e7c..b88876ef4 100644 --- a/packages/app/src/agent-stream/chat-outline/use-chat-outline.ts +++ b/packages/app/src/agent-stream/chat-outline/use-chat-outline.ts @@ -33,6 +33,8 @@ export interface UseChatOutlineInput { enabled: boolean; viewportRef: RefObject; onJumpError: () => void; + visibleItemIds?: ReadonlySet; + 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(null); const [pendingJump, setPendingJump] = useState(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 }; diff --git a/packages/app/src/agent-stream/history-start-pagination.test.ts b/packages/app/src/agent-stream/history-start-pagination.test.ts index 475dbc771..9663d87e2 100644 --- a/packages/app/src/agent-stream/history-start-pagination.test.ts +++ b/packages/app/src/agent-stream/history-start-pagination.test.ts @@ -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); diff --git a/packages/app/src/agent-stream/history-window.ts b/packages/app/src/agent-stream/history-window.ts new file mode 100644 index 000000000..347f78002 --- /dev/null +++ b/packages/app/src/agent-stream/history-window.ts @@ -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; +} diff --git a/packages/app/src/agent-stream/model.test.ts b/packages/app/src/agent-stream/model.test.ts index c55c51dda..1aec54824 100644 --- a/packages/app/src/agent-stream/model.test.ts +++ b/packages/app/src/agent-stream/model.test.ts @@ -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) { diff --git a/packages/app/src/agent-stream/model.ts b/packages/app/src/agent-stream/model.ts index f7a385497..89eb73a7b 100644 --- a/packages/app/src/agent-stream/model.ts +++ b/packages/app/src/agent-stream/model.ts @@ -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, }); diff --git a/packages/app/src/agent-stream/strategy-native.tsx b/packages/app/src/agent-stream/strategy-native.tsx index 788e4a38a..cc314b476 100644 --- a/packages/app/src/agent-stream/strategy-native.tsx +++ b/packages/app/src/agent-stream/strategy-native.tsx @@ -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 diff --git a/packages/app/src/agent-stream/use-stream-history-window.test.tsx b/packages/app/src/agent-stream/use-stream-history-window.test.tsx new file mode 100644 index 000000000..2d8f0b432 --- /dev/null +++ b/packages/app/src/agent-stream/use-stream-history-window.test.tsx @@ -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); + }); +}); diff --git a/packages/app/src/agent-stream/use-stream-history-window.ts b/packages/app/src/agent-stream/use-stream-history-window.ts new file mode 100644 index 000000000..03303d712 --- /dev/null +++ b/packages/app/src/agent-stream/use-stream-history-window.ts @@ -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; +}) { + 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 => { + return revealLoadedHistory() || (await loadRemoteOlder()); + }, [loadRemoteOlder, revealLoadedHistory]); + + return { start, hasLocalHistory: start > 0, revealLoadedHistory, loadOlder }; +} diff --git a/packages/app/src/agent-stream/view.tsx b/packages/app/src/agent-stream/view.tsx index df84ea4f4..c003366f0 100644 --- a/packages/app/src/agent-stream/view.tsx +++ b/packages/app/src/agent-stream/view.tsx @@ -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 { return buildAgentStreamRenderModel({ @@ -529,6 +548,7 @@ const AgentStreamViewComponent = forwardRef @@ -557,6 +578,13 @@ const AgentStreamViewComponent = forwardRef { 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 0 && items[startIndex]?.kind !== "user_message") { - startIndex -= 1; - } - return startIndex; -} +export { findMountedWindowStart }; export function splitWebVirtualizedHistory(input: { entries: IndexedStreamItem[]; diff --git a/packages/app/src/app/h/[serverId]/workspace/[workspaceId]/index.tsx b/packages/app/src/app/h/[serverId]/workspace/[workspaceId]/index.tsx index 791e66626..8396d00b3 100644 --- a/packages/app/src/app/h/[serverId]/workspace/[workspaceId]/index.tsx +++ b/packages/app/src/app/h/[serverId]/workspace/[workspaceId]/index.tsx @@ -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); diff --git a/packages/app/src/screens/workspace/workspace-deck-retention.test.ts b/packages/app/src/screens/workspace/workspace-deck-retention.test.ts index e942760d0..44fa3f817 100644 --- a/packages/app/src/screens/workspace/workspace-deck-retention.test.ts +++ b/packages/app/src/screens/workspace/workspace-deck-retention.test.ts @@ -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)]; diff --git a/packages/app/src/screens/workspace/workspace-deck-retention.ts b/packages/app/src/screens/workspace/workspace-deck-retention.ts index fee4f8bcd..07b1be067 100644 --- a/packages/app/src/screens/workspace/workspace-deck-retention.ts +++ b/packages/app/src/screens/workspace/workspace-deck-retention.ts @@ -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;