diff --git a/packages/app/e2e/browser/agent-timeline-pagination.spec.ts b/packages/app/e2e/browser/agent-timeline-pagination.spec.ts index f2772b0b1b..57ee050fa4 100644 --- a/packages/app/e2e/browser/agent-timeline-pagination.spec.ts +++ b/packages/app/e2e/browser/agent-timeline-pagination.spec.ts @@ -27,6 +27,10 @@ import { seedRealisticOlderTimeline, sendLiveTurnBeforeHydration, expectTimelineViewportAnchoredAfterPrepend, + expectToolCallGroupPositionPreserved, + rememberToolCallGroupPosition, + seedItemPagedToolGroupTimeline, + useChatDisplay, userNavigatesTimelineToHistoryStartWithKeyboard, userScrollsTimelineToHistoryStart, } from "../support/helpers/timeline-pagination"; @@ -100,6 +104,32 @@ test.describe("Agent timeline pagination", () => { } }); + // Hosts paged by items can end a page inside a tool run. The older page extends the group + // the reader is on, which changes the row hosting it; the reader must stay with that group + // rather than land at history start and drain every older page. + test("keeps the reading position when an older item page extends the tool-call group being read", async ({ + page, + }) => { + test.setTimeout(120_000); + await useChatDisplay(page, "collapseToolCalls"); + const agent = await seedItemPagedToolGroupTimeline(); + try { + const history = await holdOlderHistoryPages(page, agent, { itemPagedHost: true }); + await openAgentTimeline(page, agent); + await expectTimelinePromptVisible(page, agent.newestPrompt); + + await userScrollsTimelineToHistoryStart(page); + await history.expectRequestedPages(1); + const reading = await rememberToolCallGroupPosition(page, agent.loadedCommands); + + history.releasePage(1); + await expectToolCallGroupPositionPreserved(page, agent.totalCommands, reading); + await history.expectSettledWithRequestedPages(1); + } finally { + await agent.cleanup(); + } + }); + test("loads older history when the user navigates to history start with the keyboard", async ({ page, }) => { diff --git a/packages/app/e2e/browser/owned-subscriptions-old-daemon.spec.ts b/packages/app/e2e/browser/owned-subscriptions-old-daemon.spec.ts index 1fdef794a0..5f1f36e409 100644 --- a/packages/app/e2e/browser/owned-subscriptions-old-daemon.spec.ts +++ b/packages/app/e2e/browser/owned-subscriptions-old-daemon.spec.ts @@ -5,22 +5,86 @@ import { getTerminalBufferText, } from "../support/helpers/terminal-perf"; import { buildHostWorkspaceRoute } from "../../src/utils/host-routes"; -import { metroTest as test, expect } from "../support/fixtures"; +import { metroTest as test, expect, type Page } from "../support/fixtures"; import { buildCreateAgentPreferences, buildSeededHost } from "../support/helpers/daemon-registry"; import { startIsolatedHostDaemon } from "../support/helpers/isolated-host-daemon"; import type { MockAgentWorkspace } from "../support/helpers/mock-agent"; import { seedWorkspace } from "../support/helpers/seed-client"; import { + expectFoldedTurnAtReadingPosition, + expectOldestLoadedTurnPartial, + holdOlderHistoryPages, openAgentTimeline, expectTimelinePromptVisible, + userScrollsTimelineToHistoryStart, } from "../support/helpers/timeline-pagination"; +interface PublishedDaemonAgent { + serverId: string; + daemon: Awaited>; + agent: MockAgentWorkspace; + /** Register the host in the browser and apply app settings before the first load. */ + seedBrowser(page: Page, appSettings?: Record): Promise; + close(): Promise; +} + +async function startPublishedDaemonAgent(options: { + publishedVersion: string; + serverIdPrefix: string; + repoPrefix: string; + title: string; +}): Promise { + const serverId = `${options.serverIdPrefix}${randomUUID().replaceAll("-", "").slice(0, 12)}`; + const daemon = await startIsolatedHostDaemon(serverId, { + publishedVersion: options.publishedVersion, + }); + const workspace = await seedWorkspace({ repoPrefix: options.repoPrefix, port: daemon.port }); + const createdAgent = await workspace.client.createAgent({ + provider: "mock", + cwd: workspace.repoPath, + workspaceId: workspace.workspaceId, + title: options.title, + modeId: "load-test", + model: "ten-second-stream", + }); + const agent: MockAgentWorkspace = { + agentId: createdAgent.id, + workspaceId: workspace.workspaceId, + cwd: workspace.repoPath, + client: workspace.client, + cleanup: workspace.cleanup, + }; + return { + serverId, + daemon, + agent, + async seedBrowser(page, appSettings) { + const host = buildSeededHost({ + serverId, + endpoint: `127.0.0.1:${daemon.port}`, + nowIso: new Date().toISOString(), + }); + await page.addInitScript( + ({ seededHost, preferences, settings }) => { + localStorage.setItem("@paseo:e2e", "1"); + localStorage.setItem("@paseo:daemon-registry", JSON.stringify([seededHost])); + localStorage.setItem("@paseo:create-agent-preferences", JSON.stringify(preferences)); + if (settings) localStorage.setItem("@paseo:app-settings", JSON.stringify(settings)); + }, + { seededHost: host, preferences: buildCreateAgentPreferences(), settings: appSettings }, + ); + }, + async close() { + await agent.cleanup(); + await daemon.close(); + }, + }; +} + test("loads history across browser reconnection while retaining old-daemon connections and terminals", async ({ page, }) => { test.setTimeout(120_000); - const serverId = `srv_old_pagination_${randomUUID().replaceAll("-", "").slice(0, 12)}`; - const daemon = await startIsolatedHostDaemon(serverId, { publishedVersion: "0.2.5" }); const observationSockets = new Set(); page.on("websocket", (socket) => { socket.on("framesent", ({ payload }) => { @@ -34,43 +98,18 @@ test("loads history across browser reconnection while retaining old-daemon conne observationSockets.add(socket); }); }); - const workspace = await seedWorkspace({ + const published = await startPublishedDaemonAgent({ + publishedVersion: "0.2.5", + serverIdPrefix: "srv_old_pagination_", repoPrefix: "timeline-old-daemon-pagination-", - port: daemon.port, - }); - const createdAgent = await workspace.client.createAgent({ - provider: "mock", - cwd: workspace.repoPath, - workspaceId: workspace.workspaceId, title: "Published daemon pagination regression", - modeId: "load-test", - model: "ten-second-stream", }); - const agent: MockAgentWorkspace = { - agentId: createdAgent.id, - workspaceId: workspace.workspaceId, - cwd: workspace.repoPath, - client: workspace.client, - cleanup: workspace.cleanup, - }; + const { agent, serverId } = published; try { await agent.client.sendAgentMessage(agent.agentId, "emit 1 coalesced agent stream updates"); await agent.client.waitForFinish(agent.agentId, 15_000); - - const host = buildSeededHost({ - serverId, - endpoint: `127.0.0.1:${daemon.port}`, - nowIso: new Date().toISOString(), - }); - await page.addInitScript( - ({ seededHost, preferences }) => { - localStorage.setItem("@paseo:e2e", "1"); - localStorage.setItem("@paseo:daemon-registry", JSON.stringify([seededHost])); - localStorage.setItem("@paseo:create-agent-preferences", JSON.stringify(preferences)); - }, - { seededHost: host, preferences: buildCreateAgentPreferences() }, - ); + await published.seedBrowser(page); await openAgentTimeline(page, agent, serverId); await expectTimelinePromptVisible(page, "emit 1 coalesced agent stream updates"); @@ -87,7 +126,7 @@ test("loads history across browser reconnection while retaining old-daemon conne const terminal = await agent.client.createTerminal(agent.cwd, "Compatibility terminal"); expect(terminal.error).toBeNull(); const terminalId = terminal.terminal!.id; - const route = buildHostWorkspaceRoute(serverId, workspace.workspaceId); + const route = buildHostWorkspaceRoute(serverId, agent.workspaceId); await page.goto(`${route}?open=${encodeURIComponent(`terminal:${terminalId}`)}`); await focusTerminalSurface(page); await typeInTerminal(page, "printf 'compatibility-%s\\n' terminal-output\n"); @@ -97,7 +136,46 @@ test("loads history across browser reconnection while retaining old-daemon conne await agent.client.killTerminal(terminalId); }); } finally { - await agent.cleanup(); - await daemon.close(); + await published.close(); + } +}); + +// Hosts without whole-turn pages split turns across item pages. The oldest loaded turn +// starts mid-turn and only folds once the next page completes it; the reader must stay +// with that turn instead of landing at history start and draining every older page. +test("keeps the reader on the turn that folds when an older item page completes it", async ({ + page, +}) => { + test.setTimeout(240_000); + const published = await startPublishedDaemonAgent({ + publishedVersion: "0.13.0-beta.2", + serverIdPrefix: "srv_old_fold_", + repoPrefix: "timeline-old-daemon-fold-", + title: "Published daemon folded turn pagination", + }); + const { agent, serverId } = published; + const prompts = Array.from({ length: 5 }, (_, index) => `Realistic turn ${index}: inspect it`); + + try { + for (const prompt of prompts) { + await agent.client.sendAgentMessage(agent.agentId, prompt); + await agent.client.waitForFinish(agent.agentId, 30_000); + } + await published.seedBrowser(page, { chatDisplay: "collapseTurns" }); + const history = await holdOlderHistoryPages(page, agent, { + daemonPort: published.daemon.port, + }); + await openAgentTimeline(page, agent, serverId); + await expectTimelinePromptVisible(page, prompts.at(-1)!); + + await userScrollsTimelineToHistoryStart(page); + await history.expectRequestedPages(1); + const partialTurnPrompt = await expectOldestLoadedTurnPartial(page, prompts); + + history.releasePage(1); + await expectFoldedTurnAtReadingPosition(page, partialTurnPrompt); + await history.expectSettledWithRequestedPages(1); + } finally { + await published.close(); } }); diff --git a/packages/app/e2e/browser/turn-collapse.spec.ts b/packages/app/e2e/browser/turn-collapse.spec.ts index c170386266..4d98dea506 100644 --- a/packages/app/e2e/browser/turn-collapse.spec.ts +++ b/packages/app/e2e/browser/turn-collapse.spec.ts @@ -193,6 +193,7 @@ test("received messages show their sender and open that agent", async ({ page }) .getByText("Sent by Reviewer", { exact: true }) .evaluate((element) => getComputedStyle(element).color); await expect(attribution.locator("svg")).toHaveCSS("fill", labelColor); + await expectSenderAlignedWithBubble(attribution); await attribution.scrollIntoViewIfNeeded(); await page.screenshot({ path: "/tmp/sender-attribution.png" }); await attribution.click(); @@ -202,6 +203,135 @@ test("received messages show their sender and open that agent", async ({ page }) } }); +interface MessagePreviewWatch { + /** Frames after the watch started on which the fade or "Show more" control was missing. */ + framesWithoutClip: number[]; + /** Frames on which the fade was a different DOM node than when the watch started. */ + framesRemounted: number[]; +} + +declare global { + interface Window { + __paseoMessagePreviewWatch?: { stop(): MessagePreviewWatch }; + } +} + +async function seedReceivedReviewWithToolTurn() { + const recipient = await seedMockAgentWorkspace({ + repoPrefix: "preview-clip-", + title: "Implementation", + }); + const sender = await recipient.client.createAgent({ + provider: "mock", + cwd: recipient.cwd, + workspaceId: recipient.workspaceId, + title: "Reviewer", + modeId: "load-test", + model: "e2e-fast-stream", + }); + // Long enough to clip behind the preview fade; the instruction makes the recipient run tools. + await recipient.client.sendAgentMessage( + recipient.agentId, + "Review complete: the regression is red on the baseline and green with the fix.\n" + + "Keep the previous response visible while the reader scrolls.\n".repeat(30) + + "Now emit 7 tool agent stream updates", + { sourceAgentId: sender.id }, + ); + return recipient; +} + +/** Samples the received message's bubble on every animation frame until stopped. */ +async function watchReceivedMessagePreview(page: Page, senderLabel: string): Promise { + await page.evaluate((label) => { + const findBubble = () => + [...document.querySelectorAll("[data-testid=user-message]")].find((row) => + row.textContent?.includes(label), + ) ?? null; + const initialFade = findBubble()?.querySelector("svg") ?? null; + if (!initialFade) throw new Error(`No faded bubble for ${label}`); + const framesWithoutClip: number[] = []; + const framesRemounted: number[] = []; + let frame = 0; + let running = true; + const sample = () => { + if (!running) return; + const bubble = findBubble(); + const fade = bubble?.querySelector("svg") ?? null; + if (!fade || !bubble?.textContent?.includes("Show more")) framesWithoutClip.push(frame); + else if (fade !== initialFade) framesRemounted.push(frame); + frame += 1; + requestAnimationFrame(sample); + }; + requestAnimationFrame(sample); + window.__paseoMessagePreviewWatch = { + stop() { + running = false; + return { framesWithoutClip, framesRemounted }; + }, + }; + }, senderLabel); +} + +async function expectReceivedMessagePreviewUndisturbed(page: Page): Promise { + // Give the opened turn's layout a few frames to settle before judging. + await page.evaluate( + () => new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve))), + ); + const watch = await page.evaluate(() => window.__paseoMessagePreviewWatch!.stop()); + expect( + watch.framesWithoutClip, + "the preview fade and Show more control stay on every frame", + ).toEqual([]); + expect(watch.framesRemounted, "the preview keeps its DOM node (no remount)").toEqual([]); +} + +// Opening a turn must not rebuild its host row: the preview above the summary loses its +// measured height when remounted, which drops the fade and "Show more" for a frame. +test("opening a turn keeps the received message above it clipped on every frame", async ({ + page, +}) => { + await useChatDisplay(page, "collapseTurns"); + const recipient = await seedReceivedReviewWithToolTurn(); + try { + await openAgentRoute(page, recipient); + await expectComposerVisible(page); + await expect(page.getByText("Completed 7 commands", { exact: true })).toBeVisible({ + timeout: 30_000, + }); + const bubble = page + .getByTestId("user-message") + .filter({ has: page.getByRole("button", { name: "Sent by Reviewer", exact: true }) }); + await expect(bubble.getByRole("button", { name: "Show more", exact: true })).toBeVisible(); + await bubble.scrollIntoViewIfNeeded(); + + await watchReceivedMessagePreview(page, "Sent by Reviewer"); + await page + .getByTestId("turn-summary") + .getByRole("button", { name: "Ran 7 commands", exact: true }) + .click(); + await expect(page.getByText("echo stress-6", { exact: true })).toBeVisible(); + await expectReceivedMessagePreviewUndisturbed(page); + } finally { + await recipient.cleanup(); + } +}); + +async function expectSenderAlignedWithBubble(attribution: Locator) { + const edges = await attribution.evaluate((button) => { + const bubble = button.nextElementSibling; + const label = button.querySelector('[dir="auto"]'); + if (!bubble || !label) throw new Error("Sender label or message bubble missing"); + return { + labelRight: label.getBoundingClientRect().right, + bubbleRight: bubble.getBoundingClientRect().right, + }; + }); + expect( + Math.abs(edges.labelRight - edges.bubbleRight), + "sender and bubble share the right edge", + ).toBeLessThanOrEqual(1); +} + async function seedWholeTurnHistory() { const agent = await seedMockAgentWorkspace({ repoPrefix: "whole-turn-history-", @@ -301,6 +431,7 @@ for (const autoExpandReasoning of [false, true]) { await expectComposerVisible(page); const summary = page.getByTestId("turn-summary").last().getByRole("button"); await expect(summary).toBeVisible(); + await expect(summary).not.toContainText(/thought/i); await expect(reasoning).toHaveCount(0); await summary.click(); await expect(reasoning).toHaveCount(autoExpandReasoning ? 1 : 0); @@ -339,9 +470,10 @@ async function expectThinkingPresentation(page: Page, autoExpandReasoning: boole await expect(reasoning).toHaveCount(0); const group = page .getByTestId("tool-call-group") - .filter({ hasText: /thought/, visible: true }) + .filter({ hasText: /read 1 file/i, visible: true }) .last(); await expect(group).toContainText(/read 1 file/i); + await expect(group.getByRole("button").first()).not.toContainText(/thought/i); await group.getByRole("button").first().click(); await expect(reasoning).toBeVisible(); } diff --git a/packages/app/e2e/support/helpers/agent-timeline-gate.ts b/packages/app/e2e/support/helpers/agent-timeline-gate.ts index 408bd8af24..89a1d8c364 100644 --- a/packages/app/e2e/support/helpers/agent-timeline-gate.ts +++ b/packages/app/e2e/support/helpers/agent-timeline-gate.ts @@ -338,11 +338,32 @@ export async function delayAgentOlderTimelineResponse( return delayAgentTimelineResponse(page, agentId, "before"); } +export interface OlderTimelinePagesOptions { + daemonPort?: number; + /** + * Hide whole-turn paging from the app so it reads this host with item pages, as it reads + * daemons released before whole-turn pages. An item page can end inside a turn or a tool run. + */ + itemPagedHost?: boolean; +} + +function withoutWholeTurnPages(message: WebSocketMessage): WebSocketMessage { + const sessionMessage = getSessionMessage(message); + const payload = sessionMessage ? getPayload(sessionMessage) : null; + if (sessionMessage?.type !== "status" || payload?.status !== "server_info") return message; + const envelope = parseWebSocketJson(message) as { + message: { payload: { features?: Record } }; + }; + delete envelope.message.payload.features?.wholeTurnTimeline; + return JSON.stringify(envelope); +} + export async function holdAgentOlderTimelinePages( page: Page, agentId: string, - daemonPort?: number, + options: OlderTimelinePagesOptions = {}, ): Promise { + const { daemonPort } = options; let requestCount = 0; let responseCount = 0; let repeatedEntryCount = 0; @@ -376,7 +397,8 @@ export async function holdAgentOlderTimelinePages( } server.send(message); }); - server.onMessage((message) => { + server.onMessage((received) => { + const message = options.itemPagedHost ? withoutWholeTurnPages(received) : received; const sessionMessage = getSessionMessage(message); const payload = sessionMessage ? getPayload(sessionMessage) : null; if ( diff --git a/packages/app/e2e/support/helpers/timeline-pagination.ts b/packages/app/e2e/support/helpers/timeline-pagination.ts index 52027df956..e575f4f0d9 100644 --- a/packages/app/e2e/support/helpers/timeline-pagination.ts +++ b/packages/app/e2e/support/helpers/timeline-pagination.ts @@ -1,4 +1,4 @@ -import { expect, type TestInfo, type Page } from "@playwright/test"; +import { expect, type Locator, type TestInfo, type Page } from "@playwright/test"; import { buildAgentRoute, seedMockAgentWorkspace, type MockAgentWorkspace } from "./mock-agent"; import { readReplicaCache } from "./replica-cache-storage"; import { @@ -9,6 +9,7 @@ import { type AgentTimelineResponseGate, type BootstrapTimelineGate, type OlderTimelinePagesGate, + type OlderTimelinePagesOptions, } from "./agent-timeline-gate"; export { holdDaemonHydration }; @@ -35,6 +36,19 @@ interface RealisticOlderTimelineAgent extends MockAgentWorkspace { newestPrompt: string; } +export interface ItemPagedToolGroupAgent extends MockAgentWorkspace { + newestPrompt: string; + /** Commands in one run, more than an item page holds. */ + totalCommands: number; + /** Commands of that run the initial tail loads, so the size of the group as first shown. */ + loadedCommands: number; +} + +interface ToolCallGroupPositionSnapshot { + top: number; +} + +const ITEM_PAGE_SIZE = 40; const PROMPT_PREFIX = "timeline-pagination-turn"; const LIVE_BEFORE_HYDRATION_PROMPT = "timeline live before authoritative hydration"; const HISTORY_START_THRESHOLD_PX = 96; @@ -126,6 +140,93 @@ export async function seedRealisticOlderTimeline(): Promise { + const agent = await seedMockAgentWorkspace({ + repoPrefix: "timeline-item-page-tool-group-", + title: "Item page tool group regression", + }); + const olderTurns = 10; + const newerTurns = 12; + const totalCommands = 50; + // A short turn is its prompt and one reply; the run's turn ends with one reply. + const loadedCommands = ITEM_PAGE_SIZE - newerTurns * 2 - 1; + const shortTurnPrompt = (label: string, index: number) => + `item-page-${label}-turn-${index}: emit 1 coalesced agent stream updates`; + const sendTurn = async (prompt: string) => { + await agent.client.sendAgentMessage(agent.agentId, prompt); + await agent.client.waitForFinish(agent.agentId, 15_000); + }; + + for (let index = 0; index < olderTurns; index += 1) { + await sendTurn(shortTurnPrompt("older", index)); + } + await sendTurn(`emit ${totalCommands} tool agent stream updates`); + for (let index = 0; index < newerTurns; index += 1) { + await sendTurn(shortTurnPrompt("newer", index)); + } + + return { + ...agent, + newestPrompt: shortTurnPrompt("newer", newerTurns - 1), + totalCommands, + loadedCommands, + }; +} + +export async function useChatDisplay( + page: Page, + chatDisplay: "full" | "collapseToolCalls" | "collapseTurns", +): Promise { + await page.addInitScript((value) => { + localStorage.setItem("@paseo:app-settings", JSON.stringify({ chatDisplay: value })); + }, chatDisplay); +} + +function toolCallGroup(page: Page, commandCount: number): Locator { + const timeline = page.locator('[data-testid="agent-chat-scroll"]:visible').first(); + return timeline + .getByTestId("tool-call-group") + .getByRole("button", { name: `Ran ${commandCount} commands`, exact: true }); +} + +export async function rememberToolCallGroupPosition( + page: Page, + commandCount: number, +): Promise { + const group = toolCallGroup(page, commandCount); + await expect(group).toBeVisible(); + const box = await group.boundingBox(); + if (!box) { + throw new Error(`Expected a rendered tool-call group of ${commandCount} commands`); + } + return { top: box.y }; +} + +/** The group the reader was on keeps its place on screen, now counting the older page's calls. */ +export async function expectToolCallGroupPositionPreserved( + page: Page, + commandCount: number, + before: ToolCallGroupPositionSnapshot, +): Promise { + const group = toolCallGroup(page, commandCount); + await expect(group).toBeVisible(); + await expect + .poll( + async () => { + const box = await group.boundingBox(); + return box ? Math.abs(box.y - before.top) : Number.POSITIVE_INFINITY; + }, + { message: `the group of ${commandCount} commands keeps its viewport position` }, + ) + .toBeLessThanOrEqual(2); +} + export async function scrollThroughOlderHistoryPages( page: Page, pageCount: number, @@ -316,13 +417,14 @@ export async function holdNextOlderTimelinePage( export async function holdOlderHistoryPages( page: Page, agent: MockAgentWorkspace, - daemonPort?: number, + options?: OlderTimelinePagesOptions, ): Promise { const gate: OlderTimelinePagesGate = await holdAgentOlderTimelinePages( page, agent.agentId, - daemonPort, + options, ); + const loader = page.getByTestId("load-older-history-spinner"); return { async expectRequestedPages(count) { await gate.waitForRequestCount(count); @@ -330,7 +432,17 @@ export async function holdOlderHistoryPages( }, async expectSettledWithRequestedPages(count) { await waitForTimelineGeometryToSettle(page); - expect(gate.getRequestCount()).toBe(count); + // The loader stays until the prepend settles and the viewport decides whether to + // keep loading. Once it hides, no further page loads without new user intent. + await expect + .poll( + async () => ({ + requestedPages: gate.getRequestCount(), + loading: await loader.isVisible(), + }), + { message: `older history settles after ${count} requested page(s)` }, + ) + .toEqual({ requestedPages: count, loading: false }); }, expectNoRepeatedEntries() { expect(gate.getRepeatedEntryCount()).toBe(0); @@ -423,6 +535,53 @@ export async function expectTimelineViewportAnchoredAfterPrepend( expect(Math.abs(contentGrowth - scrollAdjustment)).toBeLessThanOrEqual(2); } +/** + * Item-based pages from older hosts split turns, so the oldest loaded turn starts + * mid-turn: its prompt is above loaded history. Returns that prompt. + */ +export async function expectOldestLoadedTurnPartial( + page: Page, + prompts: readonly string[], +): Promise { + const timeline = page.locator('[data-testid="agent-chat-scroll"]:visible').first(); + const oldestLoadedPrompt = await timeline + .getByTestId("user-message") + .first() + .evaluate((element) => element.textContent ?? ""); + const loadedIndex = prompts.findIndex((prompt) => oldestLoadedPrompt.includes(prompt)); + expect(loadedIndex, "oldest loaded prompt is a seeded turn").toBeGreaterThan(0); + const oldestRow = timeline.locator("[data-history-row-id]").first(); + await expect(oldestRow.getByTestId("user-message"), "rows above the oldest prompt").toHaveCount( + 0, + ); + const partialPrompt = prompts[loadedIndex - 1]!; + await expectTimelinePromptNotMounted(page, partialPrompt); + return partialPrompt; +} + +/** The reader stays with the turn they were reading once it folds behind its summary. */ +export async function expectFoldedTurnAtReadingPosition(page: Page, prompt: string): Promise { + const timeline = page.locator('[data-testid="agent-chat-scroll"]:visible').first(); + const promptRow = timeline.locator("[data-history-row-id]").filter({ hasText: prompt }); + await expect(promptRow).toBeVisible({ timeout: 15_000 }); + // The host row carries the folded turn's summary line. + await expect(promptRow.getByTestId("turn-summary")).toBeVisible(); + await expect + .poll( + async () => { + const [timelineBox, promptBox] = await Promise.all([ + timeline.boundingBox(), + promptRow.boundingBox(), + ]); + if (!timelineBox || !promptBox) return false; + const offset = promptBox.y - timelineBox.y; + return offset >= 0 && offset <= timelineBox.height / 2; + }, + { message: "folded turn sits in the upper half of the viewport" }, + ) + .toBe(true); +} + export async function expectTimelineAtHistoryStart(page: Page): Promise { await expect .poll(async () => (await readTimelineViewport(page)).scrollTop) diff --git a/packages/app/src/agent-stream/presentation.test.ts b/packages/app/src/agent-stream/presentation.test.ts index 0cfc793228..f7d146d92a 100644 --- a/packages/app/src/agent-stream/presentation.test.ts +++ b/packages/app/src/agent-stream/presentation.test.ts @@ -15,6 +15,7 @@ import { QueryClient } from "@tanstack/react-query"; import { describe, expect, it } from "vitest"; import { buildAgentStreamRenderModel } from "./model"; import { createStreamPresentation } from "./presentation"; +import { createReadingAnchor } from "./reading-anchor"; const runtime = { paseo: {}, @@ -609,8 +610,54 @@ it("opening a turn reveals individual calls rather than another tool group", () }); expect(result.tail.map((item) => item.id)).toEqual(tail.map((item) => item.id)); expect(result.groupsByHostId.size).toBe(0); + expect(result.readingRowReplacements.get("tool-two")).toBeUndefined(); }); +it.each(["collapseToolCalls", "collapseTurns"] as const)( + "preserves the reader when a prepend extends a group in %s", + (display) => { + const present = createStreamPresentation(); + const anchor = createReadingAnchor(); + const calls = [toolCall("one", "Bash"), toolCall("two", "Bash")]; + const input = { + ...presentationOptions, + transform: undefined, + display, + head: [], + isTurnActive: false, + }; + const partial = present({ ...input, tail: calls.slice(1) }); + anchor.reconcile(0, [{ id: partial.tail[0]!.id, top: 48, height: 200 }]); + const completed = present({ ...input, tail: calls }); + const groupId = completed.tail[0]!.id; + expect(anchor.getRowId(completed.readingRowReplacements)).toBe(groupId); + expect( + anchor.reconcile( + 0, + [{ id: groupId, top: 448, height: 200 }], + false, + completed.readingRowReplacements, + ), + ).toBe(400); + expect(anchor.getRowId()).toBe(groupId); + const timestamp = new Date(); + const folded = present({ + ...input, + display: "collapseTurns", + tail: [ + { kind: "user_message", id: "prompt", text: "Work", timestamp }, + ...calls, + { kind: "assistant_message", id: "reply", text: "Done", timestamp }, + ], + }); + // Both the original partial group and its replacement resolve directly to the turn. + expect(folded.readingRowReplacements.get(calls[1]!.id)).toBe("prompt"); + expect(anchor.getRowId(folded.readingRowReplacements)).toBe("prompt"); + const opened = present({ ...input, tail: calls, display: "full" }); + expect(opened.readingRowReplacements.get(calls[1]!.id)).toBeUndefined(); + }, +); + it("presents received agent messages as attributed bubbles with the original turn identity", () => { const call = toolCall("received", "agent_message"); if (call.payload.source !== "agent") throw new Error("Expected agent tool"); diff --git a/packages/app/src/agent-stream/presentation.ts b/packages/app/src/agent-stream/presentation.ts index 5138cc1647..b449cb63cc 100644 --- a/packages/app/src/agent-stream/presentation.ts +++ b/packages/app/src/agent-stream/presentation.ts @@ -11,6 +11,7 @@ import { } from "@/tool-calls/detail-level/projection"; import type { StreamItem, UserMessageItem } from "@/types/stream"; import { collapseTurns, type TurnCollapse } from "./turn-collapse/model"; +import type { ReadingRowReplacements } from "./reading-anchor"; interface PresentationInput { tail: StreamItem[]; @@ -26,6 +27,22 @@ interface PresentationInput { export interface StreamPresentation extends ToolCallDetailProjection { turns: Omit; + readingRowReplacements: ReadingRowReplacements; +} + +function groupedRowHosts( + rows: readonly StreamItem[], + groups: ToolCallDetailProjection["groupsByHostId"], +): ReadonlyMap { + const hosts = new Map(); + for (const row of rows) { + const group = groups.get(row.id); + if (!group) continue; + for (const call of group.run.calls) { + if (call.id !== row.id) hosts.set(call.id, row.id); + } + } + return hosts; } const NO_COLLAPSED_TURNS: StreamPresentation["turns"] = collapseTurns({ @@ -61,6 +78,7 @@ export function createStreamPresentation() { let preparedAutoExpandReasoning: boolean | undefined; let preparedLevel: ToolCallDetailLevel | undefined; let preparedHistory: PreparedToolCallHistory | null = null; + let historyGroupHosts: ReadonlyMap = new Map(); let collapsedSource: StreamItem[] | undefined; let collapsedIsLatestTurnOpen = false; let collapsedExpandedHostIds: ReadonlySet | undefined; @@ -89,7 +107,7 @@ export function createStreamPresentation() { function collapse( projection: ToolCallDetailProjection, input: PresentationInput, - ): StreamPresentation { + ): Omit { if (input.display !== "collapseTurns") { return { ...projection, turns: NO_COLLAPSED_TURNS }; } @@ -157,19 +175,42 @@ export function createStreamPresentation() { preparedAutoExpandReasoning !== input.autoExpandReasoning ) { preparedHistory = prepareToolCallHistory(level, historyRows, input.autoExpandReasoning); + historyGroupHosts = preparedHistory + ? groupedRowHosts(preparedHistory.grouped.tail, preparedHistory.grouped.groupsByHostId) + : new Map(); preparedTail = historyRows; preparedLevel = level; preparedAutoExpandReasoning = input.autoExpandReasoning; } - return collapse( - projectToolCallDetailLevel({ - level, - tail: historyRows, - head, - preparedHistory, - isTurnActive: input.isTurnActive, - }), - input, + const projection = projectToolCallDetailLevel({ + level, + tail: historyRows, + head, + preparedHistory, + isTurnActive: input.isTurnActive, + }); + const result = collapse(projection, input); + // Only the trailing retained group can absorb head rows. Reuse the retained + // index across streaming updates rather than walking the full history again. + const last = projection.tail.at(-1); + const liveGroupHosts = groupedRowHosts( + last ? [last, ...projection.head] : projection.head, + projection.groupsByHostId, ); + const retainedGroupHosts = historyGroupHosts; + return { + ...result, + readingRowReplacements: { + get(id) { + if (result.turns.nestedItemIds.has(id)) return undefined; + const groupHost = liveGroupHosts.get(id) ?? retainedGroupHosts.get(id) ?? id; + return ( + result.turns.hostIdByHiddenRowId.get(id) ?? + result.turns.hostIdByHiddenRowId.get(groupHost) ?? + (groupHost === id ? undefined : groupHost) + ); + }, + }, + }; }; } diff --git a/packages/app/src/agent-stream/reading-anchor.test.ts b/packages/app/src/agent-stream/reading-anchor.test.ts index 74b2bd5bcd..d7d1b2beb4 100644 --- a/packages/app/src/agent-stream/reading-anchor.test.ts +++ b/packages/app/src/agent-stream/reading-anchor.test.ts @@ -2,6 +2,24 @@ import { describe, expect, it } from "vitest"; import { createReadingAnchor } from "./reading-anchor"; describe("reading anchor", () => { + it("pins the folded turn host when a prepend replaces the row being read", () => { + const anchor = createReadingAnchor(); + anchor.reconcile(0, [{ id: "tool", top: 48, height: 200 }]); + const replacements = new Map([["tool", "prompt"]]); + expect(anchor.getRowId(replacements)).toBe("prompt"); + expect(anchor.project(0, { id: "prompt", top: 1048 }, replacements)).toBe(1000); + // Projection does not consume the correction; an unmounted host keeps the pin. + expect(anchor.reconcile(0, [], false, replacements)).toBe(0); + expect(anchor.getRowId()).toBe("tool"); + expect( + anchor.reconcile(0, [{ id: "prompt", top: 1048, height: 100 }], false, replacements), + ).toBe(1000); + expect(anchor.getRowId()).toBe("prompt"); + expect( + anchor.reconcile(1000, [{ id: "prompt", top: 1048, height: 100 }], false, replacements), + ).toBe(1000); + }); + const rows = [ { id: "above", top: 0, height: 500 }, { id: "reading", top: 500, height: 500 }, diff --git a/packages/app/src/agent-stream/reading-anchor.ts b/packages/app/src/agent-stream/reading-anchor.ts index 4299f1d9c8..aa915c6ae7 100644 --- a/packages/app/src/agent-stream/reading-anchor.ts +++ b/packages/app/src/agent-stream/reading-anchor.ts @@ -6,6 +6,10 @@ interface RowGeometry { // A row must clear the reading line before the next row takes ownership. const READING_POSITION_OFFSET_PX = 8; +export interface ReadingRowReplacements { + get(id: string): string | undefined; +} +const NO_REPLACEMENTS: ReadingRowReplacements = new Map(); // Content coordinates survive user scrolling. Layout commits replace the geometry; // scroll events reuse it, without another DOM measurement pass. @@ -14,10 +18,15 @@ export function createReadingAnchor() { let geometry: readonly RowGeometry[] = []; const readingRow = (scrollTop: number) => geometry.find((row) => row.top + row.height > scrollTop + READING_POSITION_OFFSET_PX); - const project = (scrollTop: number, row: Pick | undefined) => - scrollTop + (anchor && row?.id === anchor.id ? row.top - anchor.top : 0); + const getRowId = (replacements = NO_REPLACEMENTS) => + anchor ? (replacements.get(anchor.id) ?? anchor.id) : null; + const project = ( + scrollTop: number, + row: Pick | undefined, + replacements = NO_REPLACEMENTS, + ) => scrollTop + (anchor && row && row.id === getRowId(replacements) ? row.top - anchor.top : 0); return { - getRowId: () => anchor?.id ?? null, + getRowId, getReadingRowId: (scrollTop: number) => readingRow(scrollTop)?.id ?? geometry.at(-1)?.id ?? null, project, @@ -28,13 +37,18 @@ export function createReadingAnchor() { const next = readingRow(scrollTop); anchor = next ? { id: next.id, top: next.top } : null; }, - reconcile(scrollTop: number, rows: readonly RowGeometry[], userScrolled = false): number { + reconcile( + scrollTop: number, + rows: readonly RowGeometry[], + userScrolled = false, + replacements = NO_REPLACEMENTS, + ): number { geometry = rows; - const previous = anchor && rows.find((row) => row.id === anchor?.id); + const previous = anchor && rows.find((row) => row.id === getRowId(replacements)); // The first virtualized commit can precede its mounted range. Keep the // pinned reader until it mounts, rather than adopting an unrelated row. if (anchor && !previous && !userScrolled) return scrollTop; - const correctedTop = project(scrollTop, previous ?? undefined); + const correctedTop = project(scrollTop, previous ?? undefined, replacements); // A prepend can expose the bottom of an estimated row above the reader. // Do not transfer ownership to it until the user moves the reading position. const next = previous && !userScrolled ? previous : readingRow(correctedTop); diff --git a/packages/app/src/agent-stream/strategy-web.tsx b/packages/app/src/agent-stream/strategy-web.tsx index 8e8bdf7680..9dae42bcc0 100644 --- a/packages/app/src/agent-stream/strategy-web.tsx +++ b/packages/app/src/agent-stream/strategy-web.tsx @@ -394,7 +394,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool const rangeExtractor = useCallback( (range: VirtualRange): number[] => { const anchorIndex = segments.historyVirtualized.findIndex( - (row) => row.id === readingAnchor.getRowId(), + (row) => row.id === readingAnchor.getRowId(props.readingRowReplacements), ); const container = scrollContainerRef.current; // TanStack has computed the new measurements before calling the extractor. @@ -404,10 +404,14 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool const measurement = virtualizer.measurementsCache[anchorIndex]; const correctedOffset = container && measurement - ? readingAnchor.project(container.scrollTop, { - id: String(measurement.key), - top: measurement.start, - }) + ? readingAnchor.project( + container.scrollTop, + { + id: String(measurement.key), + top: measurement.start, + }, + props.readingRowReplacements, + ) : container?.scrollTop; let visibleRange = range; if ( @@ -428,7 +432,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool ]), ].sort((left, right) => left - right); }, - [chatFindRowIndexes, readingAnchor, segments.historyVirtualized], + [chatFindRowIndexes, readingAnchor, segments.historyVirtualized, props.readingRowReplacements], ); const virtualOffsetListener = useRef<((offset: number, scrolling: boolean) => void) | null>(null); const observeVirtualOffset = useCallback((instance, onOffset) => { @@ -742,7 +746,12 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool ); const shouldAnchor = !followOutputRef.current && !isJumpSettling(); if (!shouldAnchor) readingAnchor.reset(); - const correctedTop = readingAnchor.reconcile(container.scrollTop, rows); + const correctedTop = readingAnchor.reconcile( + container.scrollTop, + rows, + false, + props.readingRowReplacements, + ); if (!shouldAnchor) { readingAnchor.reset(); return; diff --git a/packages/app/src/agent-stream/strategy.ts b/packages/app/src/agent-stream/strategy.ts index 3d9df4d201..364eba1043 100644 --- a/packages/app/src/agent-stream/strategy.ts +++ b/packages/app/src/agent-stream/strategy.ts @@ -1,6 +1,7 @@ import type { ComponentType, ReactElement, ReactNode, RefObject } from "react"; import type { StyleProp, ViewStyle } from "react-native"; import type { StreamItem } from "@/types/stream"; +import type { ReadingRowReplacements } from "./reading-anchor"; import { continuesResponse } from "./turn-membership"; import type { StreamHistoryBoundary, StreamRenderSegments } from "./model"; import type { @@ -78,6 +79,8 @@ export interface StreamRenderInput { segments: StreamRenderSegments; historyRowRevision?: StreamHistoryRowRevision; liveHeadRowRevision?: unknown; + /** Presentation rows replaced by another row after history is folded. */ + readingRowReplacements?: ReadingRowReplacements; boundary: StreamHistoryBoundary; renderers: StreamSegmentRenderers; listEmptyComponent: ReactNode; diff --git a/packages/app/src/agent-stream/turn-collapse/model.test.ts b/packages/app/src/agent-stream/turn-collapse/model.test.ts index 6cefaf3651..eca6d8757c 100644 --- a/packages/app/src/agent-stream/turn-collapse/model.test.ts +++ b/packages/app/src/agent-stream/turn-collapse/model.test.ts @@ -48,6 +48,14 @@ function collapse(tail: StreamItem[], options: Partial = {}) const ids = (items: StreamItem[]) => items.map((item) => item.id); describe("collapseTurns", () => { + it("maps hidden rows only while their turn is closed", () => { + const result = collapse([user("u1"), shell("c1"), reply("final")]); + expect(result.hostIdByHiddenRowId.get("c1")).toBe("u1"); + const expanded = collapse([user("u1"), shell("c1"), reply("final")], { + expandedHostIds: new Set(["u1"]), + }); + expect(expanded.hostIdByHiddenRowId.size).toBe(0); + }); it("keeps lifecycle events and compaction recognizable beside a semantic action summary", () => { const create = shell("create"); if (create.payload.source !== "agent") throw new Error("Expected agent tool"); diff --git a/packages/app/src/agent-stream/turn-collapse/model.ts b/packages/app/src/agent-stream/turn-collapse/model.ts index 20ab84a817..a5985e600f 100644 --- a/packages/app/src/agent-stream/turn-collapse/model.ts +++ b/packages/app/src/agent-stream/turn-collapse/model.ts @@ -25,6 +25,8 @@ export interface TurnCollapse { nestedItemIds: ReadonlySet; /** The turn a folded message belongs to, for surfaces that must reveal it. */ hostIdByFoldedMessageId: ReadonlyMap; + /** Mounted rows replaced by a closed turn's host, for preserving the reading position. */ + hostIdByHiddenRowId: ReadonlyMap; } export interface CollapseTurnsInput { @@ -80,6 +82,7 @@ export function collapseTurns(input: CollapseTurnsInput): TurnCollapse { const turnsByHostId = new Map(); const nestedItemIds = new Set(); const hostIdByFoldedMessageId = new Map(); + const hostIdByHiddenRowId = new Map(); let runStart = 0; const flushRun = (runEnd: number) => { @@ -114,6 +117,7 @@ export function collapseTurns(input: CollapseTurnsInput): TurnCollapse { if (staysVisibleWhenCollapsed(item)) continue; hostIdByFoldedMessageId.set(getStreamItemMessageId(item), host.id); if (expanded) nestedItemIds.add(item.id); + else hostIdByHiddenRowId.set(item.id, host.id); } output.push( ...(expanded ? revealed : folded.filter((item) => staysVisibleWhenCollapsed(item))), @@ -139,6 +143,7 @@ export function collapseTurns(input: CollapseTurnsInput): TurnCollapse { turnsByHostId: EMPTY_TURNS, nestedItemIds: EMPTY_IDS, hostIdByFoldedMessageId: EMPTY_HOSTS, + hostIdByHiddenRowId: EMPTY_HOSTS, }; } return { @@ -149,5 +154,6 @@ export function collapseTurns(input: CollapseTurnsInput): TurnCollapse { turnsByHostId, nestedItemIds, hostIdByFoldedMessageId, + hostIdByHiddenRowId, }; } diff --git a/packages/app/src/agent-stream/view.tsx b/packages/app/src/agent-stream/view.tsx index 5d8fe3d913..f35af10498 100644 --- a/packages/app/src/agent-stream/view.tsx +++ b/packages/app/src/agent-stream/view.tsx @@ -1206,6 +1206,7 @@ const AgentStreamViewComponent = forwardRef {streamRenderStrategy.render({ + readingRowReplacements: presentation.readingRowReplacements, agentId, segments: renderModel.segments, historyRowRevision, @@ -1898,12 +1899,11 @@ function StreamItemWrapper({ gapBelow, turnIndent, children }: StreamItemWrapper const gapStyle = useMemo(() => ({ paddingBottom: gapBelow }), [gapBelow]); switch (turnIndent) { case "none": - return {children}; case "openedHost": return ( {children} - + {turnIndent === "openedHost" ? : null} ); case "nested": diff --git a/packages/app/src/components/user-message/index.tsx b/packages/app/src/components/user-message/index.tsx index d420ffdde5..ba383eb0d3 100644 --- a/packages/app/src/components/user-message/index.tsx +++ b/packages/app/src/components/user-message/index.tsx @@ -36,7 +36,7 @@ export function MessagePreview({ children }: { children: ReactNode }) {