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;