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;