From 99e4b53aa093a9e1eb6569d69eeec863cc90aa6e Mon Sep 17 00:00:00 2001 From: "paseo-bot[bot]" <266920839+paseo-bot[bot]@users.noreply.github.com> Date: Mon, 12 Oct 2026 12:10:54 +0200 Subject: [PATCH] fix(app): restore outline jumps to prompts rendered by plugins (#6690) * fix(app): jump the chat outline to prompts a timeline plugin draws A timeline transformer replaces a stream item with plugin rows whose ids are "/". The chat outline, chat find and history reveal look a message up by the source item's id, so once a plugin drew the prompts the outline found no row: the rail showed the prompts and clicking one did nothing, for mounted, virtualized and unloaded prompts alike. Projected plugin rows now carry the id of the item they replaced, and getStreamItemMessageId answers with it. Row wrappers, the virtualized index lookup, the visible-message set and history reveal all read that function, so they now find the plugin row. Co-Authored-By: Claude Opus 5.5 * test(app): verify outline jumps to virtualized plugin prompts Replacing every prompt keeps all loaded rows mounted, so the original plugin regression never exercised a virtualized target. Retain newer native prompts and verify that a loaded unmounted plugin prompt lands without fetching. Co-authored-by: luopeihuan * test(app): retain outline navigation screenshots --------- Co-authored-by: luopeihuan Co-authored-by: Claude Opus 5.5 Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com> --- packages/app/e2e/browser/chat-outline.spec.ts | 60 ++++++++ .../e2e/support/helpers/plugin-timeline.ts | 145 ++++++++++++++---- .../app/src/agent-stream/presentation.test.ts | 21 ++- packages/app/src/agent-stream/presentation.ts | 8 +- .../app/src/plugins/timeline/projection.ts | 1 + packages/app/src/types/stream.ts | 2 + 6 files changed, 205 insertions(+), 32 deletions(-) diff --git a/packages/app/e2e/browser/chat-outline.spec.ts b/packages/app/e2e/browser/chat-outline.spec.ts index 9dd3c33313..640b141f53 100644 --- a/packages/app/e2e/browser/chat-outline.spec.ts +++ b/packages/app/e2e/browser/chat-outline.spec.ts @@ -25,6 +25,12 @@ import { expectReadingStreamedMarkdown, } from "../support/helpers/chat-outline"; import { createCreationScenario } from "../support/helpers/creation"; +import { + attachPromptTimelineEvidence, + expectPromptsDrawnByPlugin, + expectVirtualizedPluginPromptVisible, + withPromptTimelinePlugin, +} from "../support/helpers/plugin-timeline"; import { expectTimelineAtMaximumScrollWithPromptVisible, expectTimelinePromptNotMounted, @@ -105,6 +111,60 @@ test.describe("desktop chat outline", () => { } }); + test("jumps to prompts a timeline plugin draws", async ({ page }) => { + test.setTimeout(180_000); + const agent = await seedLongMockAgentTimeline({ turns: 80 }); + try { + await withPromptTimelinePlugin(async () => { + await page.setViewportSize(WIDE_VIEWPORT); + await openAgentTimeline(page, agent); + await expectChatOutlinePrompts(page, 80); + await expectPromptsDrawnByPlugin(page); + await expectTimelinePromptNotMounted(page, agent.prompts[39]); + + // Unloaded, then loaded, then near the newest edge. + await clickChatOutlineRowEdge(page, 40); + await expectTimelinePromptLandedBelowTop(page, agent.prompts[39]); + await attachPromptTimelineEvidence(page, test.info()); + await clickChatOutlineRowEdge(page, 45); + await expectTimelinePromptLandedBelowTop(page, agent.prompts[44]); + await clickChatOutlineRowEdge(page, 78); + await expectTimelineAtMaximumScrollWithPromptVisible(page, agent.prompts[77]); + await expectPromptsDrawnByPlugin(page); + }); + } finally { + await agent.cleanup(); + } + }); + + test("jumps to a loaded virtualized prompt a timeline plugin draws without fetching", async ({ + page, + }) => { + test.setTimeout(180_000); + const agent = await seedLongMockAgentTimeline({ turns: 80 }); + const prompt = agent.prompts[29]!; + try { + await withPromptTimelinePlugin(async () => { + const history = await holdOlderHistoryPages(page, agent); + await page.setViewportSize(WIDE_VIEWPORT); + await openAgentTimeline(page, agent); + await expectChatOutlinePrompts(page, 80); + await scrollThroughOlderHistoryPages(page, 2, history); + await scrollTimelineToNewestLoadedEdge(page); + await expectTimelinePromptNotMounted(page, prompt); + const requestsBeforeJump = history.requestCount(); + + await clickChatOutlineRowEdge(page, 30); + await expectTimelinePromptLandedBelowTop(page, prompt); + await expectVirtualizedPluginPromptVisible(page, prompt); + await attachPromptTimelineEvidence(page, test.info()); + expect(history.requestCount()).toBe(requestsBeforeJump); + }, [prompt]); + } finally { + await agent.cleanup(); + } + }); + test.describe("with the whole conversation loaded", () => { let agent: LongTimelineAgent; diff --git a/packages/app/e2e/support/helpers/plugin-timeline.ts b/packages/app/e2e/support/helpers/plugin-timeline.ts index d6656f3d3a..8ad7e0a632 100644 --- a/packages/app/e2e/support/helpers/plugin-timeline.ts +++ b/packages/app/e2e/support/helpers/plugin-timeline.ts @@ -47,6 +47,65 @@ export default function contribute(client) { return () => {}; }`; +const PROMPT_PLUGIN_ID = "timeline-prompt-e2e"; + +function promptClientSource(prompts?: readonly string[]): string { + return `import React from "react"; +import { Text, View } from "react-native"; +import { z } from "zod"; + +const selectedPrompts = ${JSON.stringify(prompts ?? null)}; + +function PromptCard({ item }) { + return {item.data.text}; +} + +export default function contribute(client) { + client.addTimelineTransformer({ + id: "prompt", + query: { itemType: "user_message" }, + transform({ item }) { + if (selectedPrompts && !selectedPrompts.includes(item.text)) return undefined; + return { items: [{ type: "plugin", kind: "prompt", version: 1, data: { text: item.text } }] }; + }, + }); + client.addTimelineRenderer({ + kind: "prompt", version: 1, + schema: z.object({ text: z.string() }), + Component: PromptCard, + }); + return () => {}; +}`; +} + +async function withDirectoryPlugin(options: { + pluginId: string; + source: string; + run: () => Promise; +}): Promise { + const { pluginId, source, run } = options; + const directory = await mkdtemp(path.join(tmpdir(), "paseo-timeline-plugin-")); + const pluginClient = await connectNewWorkspaceDaemonClient({ ownProjects: false }); + const previous = await pluginClient.getDaemonConfig(); + try { + await writeFile( + path.join(directory, "paseo-plugin.json"), + JSON.stringify({ id: pluginId, requirements: pluginRequirements }), + ); + await writeFile(path.join(directory, "index.client.tsx"), source); + await pluginClient.patchDaemonConfig({ pluginsEnabled: true }); + await pluginClient.installDirectoryPlugin(directory); + await run(); + } finally { + await pluginClient.removePlugin(pluginId); + await pluginClient.patchDaemonConfig({ + pluginsEnabled: previous.config.pluginsEnabled ?? false, + }); + await pluginClient.close(); + await rm(directory, { recursive: true, force: true }); + } +} + export async function withTimelinePlugin( page: Page, info: TestInfo, @@ -60,7 +119,6 @@ export async function withTimelinePlugin( JSON.stringify({ toolCallDetailLevel: "overview" }), ); }); - const directory = await mkdtemp(path.join(tmpdir(), "paseo-timeline-plugin-")); const agent = await seedMockAgentWorkspace({ repoPrefix: "timeline-plugin-", title: "Timeline plugin regression", @@ -73,40 +131,53 @@ export async function withTimelinePlugin( } : {}, }); - const pluginClient = await connectNewWorkspaceDaemonClient({ ownProjects: false }); - const previous = await pluginClient.getDaemonConfig(); try { - await writeFile( - path.join(directory, "paseo-plugin.json"), - JSON.stringify({ id: PLUGIN_ID, requirements: pluginRequirements }), - ); - await writeFile(path.join(directory, "index.client.tsx"), CLIENT_SOURCE); - await pluginClient.patchDaemonConfig({ pluginsEnabled: true }); - await pluginClient.installDirectoryPlugin(directory); - await openAgentRoute(page, agent); - await expect(page.getByRole("textbox", { name: "Message agent..." }).first()).toBeVisible(); - await run(agent); - await info.attach("timeline-plugin", { - body: await page.screenshot({ path: info.outputPath("timeline-plugin.png") }), - contentType: "image/png", + await withDirectoryPlugin({ + pluginId: PLUGIN_ID, + source: CLIENT_SOURCE, + run: async () => { + try { + await openAgentRoute(page, agent); + await expect( + page.getByRole("textbox", { name: "Message agent..." }).first(), + ).toBeVisible(); + await run(agent); + await info.attach("timeline-plugin", { + body: await page.screenshot({ path: info.outputPath("timeline-plugin.png") }), + contentType: "image/png", + }); + } catch (error) { + await info.attach("timeline-before-cleanup", { + body: await page.screenshot({ path: info.outputPath("timeline-before-cleanup.png") }), + contentType: "image/png", + }); + throw error; + } + }, }); - } catch (error) { - await info.attach("timeline-before-cleanup", { - body: await page.screenshot({ path: info.outputPath("timeline-before-cleanup.png") }), - contentType: "image/png", - }); - throw error; } finally { - await pluginClient.removePlugin(PLUGIN_ID); - await pluginClient.patchDaemonConfig({ - pluginsEnabled: previous.config.pluginsEnabled ?? false, - }); - await pluginClient.close(); await agent.cleanup(); - await rm(directory, { recursive: true, force: true }); } } +/** Draws all prompts, or only the selected prompts, as plugin cards. */ +export async function withPromptTimelinePlugin( + run: () => Promise, + prompts?: readonly string[], +): Promise { + await withDirectoryPlugin({ + pluginId: PROMPT_PLUGIN_ID, + source: promptClientSource(prompts), + run, + }); +} + +export async function expectPromptsDrawnByPlugin(page: Page): Promise { + const timeline = page.locator('[data-testid="agent-chat-scroll"]:visible').first(); + await expect(timeline.getByTestId("plugin-prompt").first()).toBeVisible({ timeout: 30_000 }); + await expect(timeline.getByTestId("user-message")).toHaveCount(0); +} + export async function requestPluginTimeline(agent: MockAgentWorkspace): Promise { await agent.client.sendAgentMessage(agent.agentId, "Exercise timeline transforms."); } @@ -142,3 +213,21 @@ export async function expectBothConsecutiveTools(page: Page): Promise { await grep.scrollIntoViewIfNeeded(); await expect(grep).toBeVisible(); } + +/** The row belongs to the virtualized history, not the always-mounted recent window. */ +export async function expectVirtualizedPluginPromptVisible( + page: Page, + prompt: string, +): Promise { + const timeline = page.locator('[data-testid="agent-chat-scroll"]:visible').first(); + await expect( + timeline.locator("[data-index]").getByTestId("plugin-prompt").filter({ hasText: prompt }), + ).toBeVisible(); +} + +export async function attachPromptTimelineEvidence(page: Page, info: TestInfo): Promise { + await info.attach("plugin-outline-jump", { + body: await page.screenshot({ path: info.outputPath("plugin-outline-jump.png") }), + contentType: "image/png", + }); +} diff --git a/packages/app/src/agent-stream/presentation.test.ts b/packages/app/src/agent-stream/presentation.test.ts index 54160ebcb9..70ed8862ae 100644 --- a/packages/app/src/agent-stream/presentation.test.ts +++ b/packages/app/src/agent-stream/presentation.test.ts @@ -11,7 +11,7 @@ import { type UserMessageItem, } from "@/types/stream"; import { transformTimelineItem, type TimelineItemTransform } from "@/plugins/timeline/model"; -import { createStreamPresentation } from "./presentation"; +import { createStreamPresentation, getStreamItemMessageId } from "./presentation"; import { buildAgentStreamRenderModel } from "./model"; const runtime = { @@ -29,7 +29,7 @@ const runtime = { } as unknown as PluginClientRuntime; function installProbe( - itemType: "assistant_message" | "tool_call" | "reasoning", + itemType: "user_message" | "assistant_message" | "tool_call" | "reasoning", condition = "true", ): InstalledPlugin { const clientBundle = `(function() { @@ -160,6 +160,23 @@ describe("stream presentation through installed plugins", () => { }, ); + it("addresses a prompt a plugin replaced by the prompt's own id", () => { + const harness = streamHarness(installedTransform(installProbe("user_message"))); + const rendered = rows( + harness.send({ + type: "timeline", + provider: "claude", + item: { type: "user_message", messageId: "prompt-1", text: "Earlier prompt" }, + }), + ); + const source = rows(harness.source()); + + expect(source).toMatchObject([{ kind: "user_message", id: "prompt-1" }]); + expect(rendered).toMatchObject([{ kind: "plugin", id: "probe-plugin/prompt-1/0" }]); + // The chat outline jumps to the source id; the row it lands on is the plugin's. + expect(rendered.map(getStreamItemMessageId)).toEqual(["prompt-1"]); + }); + it("finishes one message before streaming a different message ID", () => { const harness = streamHarness(installedTransform(installProbe("assistant_message"))); harness.send(assistant("First\n\nBody", "first")); diff --git a/packages/app/src/agent-stream/presentation.ts b/packages/app/src/agent-stream/presentation.ts index 8300996348..b89f91f6c0 100644 --- a/packages/app/src/agent-stream/presentation.ts +++ b/packages/app/src/agent-stream/presentation.ts @@ -26,10 +26,14 @@ function retainItems(previous: StreamItem[], next: StreamItem[]): StreamItem[] { /** * The source message a display row belongs to. Every assistant message is split into * Markdown blocks, so an assistant row id never equals its message id; anything that - * addresses a message — find, scroll-to-message, history reveal — asks for this. + * addresses a message — find, scroll-to-message, history reveal — asks for this. A + * plugin row a timeline transformer projected answers for the item it replaced, whose + * id is what the chat outline and find hold. */ export function getStreamItemMessageId(item: StreamItem): string { - return item.kind === "assistant_message" ? (item.blockGroupId ?? item.id) : item.id; + if (item.kind === "assistant_message") return item.blockGroupId ?? item.id; + if (item.kind === "plugin") return item.sourceId ?? item.id; + return item.id; } /** diff --git a/packages/app/src/plugins/timeline/projection.ts b/packages/app/src/plugins/timeline/projection.ts index 22f65f05a0..0750bdb66a 100644 --- a/packages/app/src/plugins/timeline/projection.ts +++ b/packages/app/src/plugins/timeline/projection.ts @@ -104,6 +104,7 @@ function transformSourceItem( const projected: PluginTimelineStreamItem = { kind: "plugin", id: `${pluginItem.pluginId}/${pluginItem.id}`, + sourceId: item.id, timestamp: item.timestamp, pluginId: pluginItem.pluginId, pluginItemId: pluginItem.id, diff --git a/packages/app/src/types/stream.ts b/packages/app/src/types/stream.ts index 7b057b0c4c..56cf14558b 100644 --- a/packages/app/src/types/stream.ts +++ b/packages/app/src/types/stream.ts @@ -804,6 +804,8 @@ export interface CompactionItem { export interface PluginTimelineStreamItem { kind: "plugin"; id: string; + /** The id of the stream item a timeline transformer replaced; set only by projection. */ + sourceId?: string; timelineCursor?: TimelinePosition; turnId?: string; timestamp: Date;