diff --git a/packages/app/e2e/browser/plugin-timeline.spec.ts b/packages/app/e2e/browser/plugin-timeline.spec.ts index d75653298e..5596685b38 100644 --- a/packages/app/e2e/browser/plugin-timeline.spec.ts +++ b/packages/app/e2e/browser/plugin-timeline.spec.ts @@ -9,6 +9,9 @@ import { expectBothConsecutiveTools, trackPluginChatRecovery, reconnectPluginChat, + withRepeatedOutputTimeline, + readOldestPluginReplacement, + expectOldestPluginReplacementReadable, } from "../support/helpers/plugin-timeline"; for (const width of [1100, 390]) { @@ -54,3 +57,15 @@ test("inline-thinking example renders a streamed thought through host UI", async }, info) => { await viewInlineThinkingExample(page, info); }); + +test("keeps older plugin replacements readable when source messages reuse local output IDs", async ({ + page, +}, info) => { + await withRepeatedOutputTimeline(page, info, async () => { + await readOldestPluginReplacement(page); + await expectOldestPluginReplacementReadable(page); + await page.reload({ waitUntil: "domcontentloaded" }); + await readOldestPluginReplacement(page); + await expectOldestPluginReplacementReadable(page); + }); +}); diff --git a/packages/app/e2e/support/helpers/plugin-timeline.ts b/packages/app/e2e/support/helpers/plugin-timeline.ts index 98ab7d303b..9dc23f46bd 100644 --- a/packages/app/e2e/support/helpers/plugin-timeline.ts +++ b/packages/app/e2e/support/helpers/plugin-timeline.ts @@ -7,6 +7,7 @@ import { connectNewWorkspaceDaemonClient } from "./new-workspace"; import { openAgentRoute, seedMockAgentWorkspace, type MockAgentWorkspace } from "./mock-agent"; import { installDaemonWebSocketGate } from "./daemon-websocket-gate"; import { expectReconnectingToastVisible } from "./workspace-ui"; +import { userScrollsTimelineToHistoryStart } from "./timeline-pagination"; const PLUGIN_ID = "timeline-transform-e2e"; @@ -367,3 +368,81 @@ export async function viewInlineThinkingExample(page: Page, info: TestInfo): Pro await example.cleanup(); } } + +/** Reuse plugin-local IDs across distinct source messages in a real virtualized chat. */ +export async function withRepeatedOutputTimeline( + page: Page, + info: TestInfo, + run: () => Promise, +): Promise { + info.setTimeout(120_000); + const agent = await seedMockAgentWorkspace({ + repoPrefix: "timeline-local-ids-", + title: "Repeated plugin output IDs", + }); + try { + for (const label of ["First", "Second", "Third"]) { + await agent.client.sendAgentMessage( + agent.agentId, + `${label}: emit 1 coalesced agent stream updates`, + ); + await agent.client.waitForFinish(agent.agentId, 15_000); + } + await withDirectoryPlugin({ + pluginId: "timeline-local-ids", + source: `import React from "react"; +import { Text, View } from "react-native"; +import { z } from "zod"; +function Row({ item }) { + return + {item.data.label} + ; +} +export default function contribute(client) { + client.addTimelineTransformer({ + id: "local-ids", query: { itemType: "user_message" }, + transform({ item }) { + const label = item.text.split(":")[0]; + return { items: Array.from({ length: 60 }, (_, index) => ({ + type: "plugin", id: "part-" + index, kind: "reply", version: 1, + data: { label: label + " replacement " + index, tall: label === "Second" }, + })) }; + }, + }); + client.addTimelineRenderer({ + kind: "reply", version: 1, + schema: z.object({ label: z.string(), tall: z.boolean() }), Component: Row, + }); + return () => {}; +}`, + run: async () => { + await openAgentRoute(page, agent); + await expect(page.getByRole("textbox", { name: "Message agent..." }).first()).toBeVisible(); + await run(); + }, + }); + } finally { + await agent.cleanup(); + } +} + +export async function readOldestPluginReplacement(page: Page): Promise { + await expect(page.getByText("Third replacement 59", { exact: true }).first()).toBeAttached(); + await expect + .poll(async () => { + await userScrollsTimelineToHistoryStart(page); + return page.getByText("First replacement 0", { exact: true }).isVisible(); + }) + .toBe(true); +} + +export async function expectOldestPluginReplacementReadable(page: Page): Promise { + await expect( + page + .getByTestId("agent-chat-scroll") + .locator("[data-index]") + .getByText("First replacement 0", { exact: true }), + ).toBeVisible(); + await expect(page.getByRole("textbox", { name: "Message agent..." }).first()).toBeVisible(); + await expect(page.getByText("Paseo ran into a problem.", { exact: true })).toHaveCount(0); +} diff --git a/packages/app/src/agent-stream/strategy-web.test.tsx b/packages/app/src/agent-stream/strategy-web.test.tsx index 25e29a30c0..5ad0be03c2 100644 --- a/packages/app/src/agent-stream/strategy-web.test.tsx +++ b/packages/app/src/agent-stream/strategy-web.test.tsx @@ -10,7 +10,6 @@ import { RetainedPanelActivity } from "@/components/retained-panel"; import { reduceStreamUpdate, type StreamItem } from "@/types/stream"; import type { StreamRenderInput, StreamSegmentRenderers, StreamViewportHandle } from "./strategy"; import { createWebStreamStrategy } from "./strategy-web"; -import { projectPluginTimelineItems } from "@/plugins/timeline/projection"; vi.hoisted(() => { Object.defineProperty(window, "matchMedia", { @@ -56,11 +55,10 @@ function createRenderers(onRowRender: () => void): StreamSegmentRenderers { }; } -function MeasuredTestRow({ item, measuredHeight }: { item: StreamItem; measuredHeight?: number }) { +function MeasuredTestRow({ item }: { item: StreamItem }) { const [loaded, setLoaded] = React.useState(false); const load = React.useCallback(() => setLoaded(true), []); - const height = - measuredHeight ?? (item.kind === "user_message" && item.text === "grown" ? 120 : 24); + const height = item.kind === "user_message" && item.text === "grown" ? 120 : 24; const measure = React.useCallback( (node: HTMLDivElement | null) => { if (!node?.parentElement) return; @@ -254,75 +252,6 @@ describe("createWebStreamStrategy", () => { expect(rowRenderCount.mock.calls.length).toBeLessThanOrEqual(historyVirtualized.length); }); - it("settles row measurement when a plugin reuses output IDs across source messages", () => { - const strategy = createWebStreamStrategy({ isMobileBreakpoint: true }); - container = document.createElement("div"); - document.body.appendChild(container); - root = createRoot(container); - const renderRow = (rowItem: StreamItem) => ( - - ); - const historyVirtualized = projectPluginTimelineItems( - [userMessage(0), userMessage(1), userMessage(2), userMessage(3), userMessage(4)], - ({ sourceId }) => - sourceId === "message-2" || sourceId === "message-3" - ? [ - { - type: "plugin", - id: "part-0", - pluginId: "split", - kind: "part", - version: 1, - data: null, - }, - ] - : undefined, - ); - const input: StreamRenderInput = { - agentId: "agent", - segments: { historyVirtualized, historyMounted: [], liveHead: [] }, - boundary: { hasVirtualizedHistory: true, hasMountedHistory: false, hasLiveHead: false }, - renderers: { - renderHistoryVirtualizedRow: renderRow, - renderHistoryMountedRow: renderRow, - renderLiveHeadRow: renderRow, - renderLiveAuxiliary: () => null, - }, - listEmptyComponent: null, - viewportRef: React.createRef(), - routeBottomAnchorRequest: null, - isAuthoritativeHistoryReady: true, - onNearBottomChange: () => {}, - onNearHistoryStart: () => true, - isLoadingOlderHistory: false, - hasOlderHistory: false, - olderHistoryProgressKey: null, - scrollEnabled: true, - listStyle: null, - baseListContentContainerStyle: null, - forwardListContentContainerStyle: null, - contentMaxWidth: DEFAULT_CONTENT_MAX_WIDTH, - }; - - // Before the fix, this render aborted with "Maximum update depth exceeded". - act(() => root?.render(strategy.render(input))); - - const rows = [...container.querySelectorAll("[data-history-row-id]")]; - expect(rows.map((row) => row.dataset.historyRowId)).toEqual( - historyVirtualized.map((row) => row.id), - ); - expect(rows.map((row) => row.style.transform)).toEqual([ - "translateY(0px)", - "translateY(24px)", - "translateY(48px)", - "translateY(168px)", - "translateY(192px)", - ]); - }); - it("retains a row's DOM and local state when recent history becomes virtualized", () => { const strategy = createWebStreamStrategy({ isMobileBreakpoint: true }); container = document.createElement("div");