diff --git a/packages/app/src/agent-stream/presentation.test.ts b/packages/app/src/agent-stream/presentation.test.ts
index ce3c0352ff..1d6dbf6492 100644
--- a/packages/app/src/agent-stream/presentation.test.ts
+++ b/packages/app/src/agent-stream/presentation.test.ts
@@ -200,7 +200,9 @@ describe("stream presentation through installed plugins", () => {
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" }]);
+ expect(rendered).toMatchObject([
+ { kind: "plugin", sourceId: "prompt-1", pluginItemId: "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"]);
});
diff --git a/packages/app/src/agent-stream/strategy-web.test.tsx b/packages/app/src/agent-stream/strategy-web.test.tsx
index 9e0fd81523..25e29a30c0 100644
--- a/packages/app/src/agent-stream/strategy-web.test.tsx
+++ b/packages/app/src/agent-stream/strategy-web.test.tsx
@@ -10,6 +10,7 @@ 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", {
@@ -55,10 +56,11 @@ function createRenderers(onRowRender: () => void): StreamSegmentRenderers {
};
}
-function MeasuredTestRow({ item }: { item: StreamItem }) {
+function MeasuredTestRow({ item, measuredHeight }: { item: StreamItem; measuredHeight?: number }) {
const [loaded, setLoaded] = React.useState(false);
const load = React.useCallback(() => setLoaded(true), []);
- const height = item.kind === "user_message" && item.text === "grown" ? 120 : 24;
+ const height =
+ measuredHeight ?? (item.kind === "user_message" && item.text === "grown" ? 120 : 24);
const measure = React.useCallback(
(node: HTMLDivElement | null) => {
if (!node?.parentElement) return;
@@ -252,20 +254,33 @@ describe("createWebStreamStrategy", () => {
expect(rowRenderCount.mock.calls.length).toBeLessThanOrEqual(historyVirtualized.length);
});
- it("settles row measurement when two history rows share an id", () => {
+ 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 duplicate = userMessage(2);
- const historyVirtualized = [
- userMessage(0),
- userMessage(1),
- { ...duplicate, text: "grown" },
- duplicate,
- userMessage(3),
- ];
+ 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: [] },
@@ -296,13 +311,9 @@ describe("createWebStreamStrategy", () => {
act(() => root?.render(strategy.render(input)));
const rows = [...container.querySelectorAll("[data-history-row-id]")];
- expect(rows.map((row) => row.dataset.historyRowId)).toEqual([
- "message-0",
- "message-1",
- "message-2",
- "message-2",
- "message-3",
- ]);
+ 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)",
diff --git a/packages/app/src/agent-stream/strategy-web.tsx b/packages/app/src/agent-stream/strategy-web.tsx
index f63b34c1c9..9dae42bcc0 100644
--- a/packages/app/src/agent-stream/strategy-web.tsx
+++ b/packages/app/src/agent-stream/strategy-web.tsx
@@ -443,18 +443,6 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
unsubscribe?.();
};
}, []);
- // The virtualizer caches measured sizes by key. Two rows with one key overwrite
- // each other's size on every measurement pass, so the layout never settles and
- // React aborts with "Maximum update depth exceeded". A repeated id gets a suffix.
- const historyVirtualizedKeys = useMemo(() => {
- const used = new Set();
- return segments.historyVirtualized.map((row) => {
- let key = row.id;
- for (let repeat = 1; used.has(key); repeat++) key = `${row.id}:${repeat}`;
- used.add(key);
- return key;
- });
- }, [segments.historyVirtualized]);
const contentWidth = contentRef.current?.getBoundingClientRect().width;
const rowVirtualizer = useVirtualizer({
count: segments.historyVirtualized.length,
@@ -464,7 +452,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
height: scrollContainerRef.current?.clientHeight ?? 0,
},
getScrollElement: () => scrollContainerRef.current,
- getItemKey: (index: number) => historyVirtualizedKeys[index] ?? index,
+ getItemKey: (index: number) => segments.historyVirtualized[index]?.id ?? index,
estimateSize: (index: number) => {
const row = segments.historyVirtualized[index];
const measured = row && measuredRowHeights.current.get(row.id);
diff --git a/packages/app/src/plugins/timeline/projection.test.ts b/packages/app/src/plugins/timeline/projection.test.ts
index 2dc95ed339..8b28bea4d2 100644
--- a/packages/app/src/plugins/timeline/projection.test.ts
+++ b/packages/app/src/plugins/timeline/projection.test.ts
@@ -14,6 +14,65 @@ function thought(text: string, status: "loading" | "ready" = "loading"): StreamI
}
describe("plugin timeline projection", () => {
+ it("keeps repeated plugin output IDs distinct across source messages", () => {
+ const transform: TimelineItemTransform = ({ item }) => [
+ {
+ type: "plugin",
+ id: "part-0",
+ pluginId: "split",
+ kind: "part",
+ version: 1,
+ data: { text: item.type === "assistant_message" ? item.text : "" },
+ },
+ ];
+ const first: StreamItem = {
+ kind: "assistant_message",
+ id: "message-1",
+ text: "Short",
+ timestamp: new Date("2026-01-01T00:00:00.000Z"),
+ };
+ const second: StreamItem = { ...first, id: "message-2", text: "A much longer reply" };
+ const rows = projectPluginTimelineItems([first, second], transform);
+ expect(new Set(rows.map((row) => row.id)).size).toBe(2);
+ expect(rows).toMatchObject([
+ { sourceId: "message-1", pluginItemId: "part-0", data: { text: "Short" } },
+ { sourceId: "message-2", pluginItemId: "part-0", data: { text: "A much longer reply" } },
+ ]);
+ const updated = projectPluginTimelineItems(
+ [{ ...second, text: "Updated reply" }],
+ transform,
+ "streaming",
+ );
+ expect(updated[0].id).toBe(rows[1].id);
+ const completed = projectPluginTimelineItems([{ ...second, text: "Final reply" }], transform);
+ expect(completed[0].id).toBe(rows[1].id);
+ const prepended = projectPluginTimelineItems(
+ [{ ...first, id: "older-message" }, first, second],
+ transform,
+ );
+ expect(prepended.slice(1).map((row) => row.id)).toEqual(rows.map((row) => row.id));
+ });
+
+ it("keeps source and output ID separators unambiguous", () => {
+ const rows = projectPluginTimelineItems(
+ [
+ { ...thought("First"), id: "message/a" },
+ { ...thought("Second"), id: "message" },
+ ],
+ ({ sourceId }) => [
+ {
+ type: "plugin",
+ id: sourceId === "message/a" ? "b" : "a/b",
+ pluginId: "split",
+ kind: "part",
+ version: 1,
+ data: null,
+ },
+ ],
+ );
+ expect(rows[0].id).not.toBe(rows[1].id);
+ });
+
it("does not expose mutable tool detail from stream state", () => {
const detail = { type: "read" as const, filePath: "/repo/original.ts" };
const source: StreamItem = {
@@ -58,7 +117,8 @@ describe("plugin timeline projection", () => {
expect(projectPluginTimelineItems([thought("First")], transform)).toMatchObject([
{
kind: "plugin",
- id: "inline-thinking/thought-1/0",
+ sourceId: "thought-1",
+ pluginItemId: "thought-1/0",
data: { text: "First", phase: "streaming" },
},
]);
@@ -145,10 +205,11 @@ describe("plugin timeline projection", () => {
]);
const filtered = projectPluginTimelineItems([source], () => []);
- expect(exploded.map((item) => item.id)).toEqual([
- "split/thought-1/left",
- "split/thought-1/right",
+ expect(exploded).toMatchObject([
+ { sourceId: "thought-1", pluginItemId: "thought-1/left" },
+ { sourceId: "thought-1", pluginItemId: "thought-1/right" },
]);
+ expect(new Set(exploded.map((item) => item.id)).size).toBe(2);
expect(filtered).toEqual([]);
});
});
diff --git a/packages/app/src/plugins/timeline/projection.ts b/packages/app/src/plugins/timeline/projection.ts
index 0750bdb66a..4857e35ac5 100644
--- a/packages/app/src/plugins/timeline/projection.ts
+++ b/packages/app/src/plugins/timeline/projection.ts
@@ -103,7 +103,9 @@ function transformSourceItem(
return transformed.map((pluginItem) => {
const projected: PluginTimelineStreamItem = {
kind: "plugin",
- id: `${pluginItem.pluginId}/${pluginItem.id}`,
+ // Output IDs are local to a source item. Encode each part so separators in
+ // source or output IDs cannot alias another replacement.
+ id: JSON.stringify([pluginItem.pluginId, item.id, pluginItem.id]),
sourceId: item.id,
timestamp: item.timestamp,
pluginId: pluginItem.pluginId,