Skip to content

Commit 0fd886c

Browse files
committed
perf(webapp): stop the agent transcript re-rendering on every streamed token
The winners map was rebuilt on each render, so every memoized bubble saw a new prop and re-rendered. Stripped messages were rebuilt too, so the shared MessageBubble's own memo missed for any tool-calling message. Reuse the map when the winners are unchanged and cache the stripped message per identity.
1 parent 8fd5369 commit 0fd886c

3 files changed

Lines changed: 84 additions & 9 deletions

File tree

apps/webapp/app/components/dashboard-agent/DashboardAgentMessages.tsx

Lines changed: 25 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,23 @@
11
import type { UIMessage } from "@ai-sdk/react";
2-
import { memo } from "react";
2+
import { memo, useMemo, useRef } from "react";
33
import { Spinner } from "~/components/primitives/Spinner";
44
import { MessageBubble, renderPart } from "~/components/runs/v3/agent/AgentMessageView";
55
import { useAutoScrollToBottom } from "~/hooks/useAutoScrollToBottom";
6+
import { reuseWinners } from "./investigation-winners";
67
import { ViewBlocks } from "./view-catalog";
78

8-
// The shared MessageBubble renders `step-start` parts as a dashed "step"
9-
// separator — useful in the run inspector / playground, just noise in this
10-
// simple chat. Drop them before rendering (reference preserved when there are
11-
// none, so memoization still holds for those messages).
9+
// The shared MessageBubble renders `step-start` parts as a dashed "step" separator —
10+
// useful in the run inspector / playground, just noise in this simple chat.
11+
// Cached so a stripped message keeps its identity across renders and memoization holds.
12+
const strippedMessages = new WeakMap<UIMessage, UIMessage>();
13+
1214
function stripStepParts(message: UIMessage): UIMessage {
1315
if (!message.parts?.some((p) => p.type === "step-start")) return message;
14-
return { ...message, parts: message.parts.filter((p) => p.type !== "step-start") };
16+
const cached = strippedMessages.get(message);
17+
if (cached) return cached;
18+
const stripped = { ...message, parts: message.parts.filter((p) => p.type !== "step-start") };
19+
strippedMessages.set(message, stripped);
20+
return stripped;
1521
}
1622

1723
// A completed render_view tool part carries a `{ blocks }` view spec the agent
@@ -65,6 +71,14 @@ export function winningInvestigationOccurrences(messages: UIMessage[]): Map<stri
6571
return new Map([...best.entries()].map(([id, w]) => [id, w.occurrence]));
6672
}
6773

74+
// The stable identity is the point: a fresh `Map` re-renders the whole transcript per token.
75+
function useInvestigationWinners(messages: UIMessage[]): Map<string, string> {
76+
const previous = useRef<Map<string, string>>();
77+
const next = useMemo(() => winningInvestigationOccurrences(messages), [messages]);
78+
previous.current = reuseWinners(previous.current, next);
79+
return previous.current;
80+
}
81+
6882
function withoutSupersededInvestigations(
6983
blocks: unknown[],
7084
occurrence: string,
@@ -127,15 +141,17 @@ export function DashboardAgentMessages({
127141
error?: Error;
128142
}) {
129143
const rootRef = useAutoScrollToBottom([messages, isThinking]);
130-
const investigationWinners = winningInvestigationOccurrences(messages);
144+
// Must be the exact parts the bubbles render: the winners map keys by part index.
145+
const stripped = useMemo(() => messages.map(stripStepParts), [messages]);
146+
const investigationWinners = useInvestigationWinners(stripped);
131147

132148
return (
133149
<div className="flex-1 overflow-y-auto scrollbar-thin scrollbar-track-transparent scrollbar-thumb-surface-control">
134150
<div ref={rootRef} className="space-y-4 p-4">
135-
{messages.map((message) => (
151+
{stripped.map((message) => (
136152
<MemoizedMessageBubble
137153
key={message.id}
138-
message={stripStepParts(message)}
154+
message={message}
139155
investigationWinners={investigationWinners}
140156
/>
141157
))}
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import { readFileSync } from "node:fs";
2+
import { join } from "node:path";
3+
import { describe, expect, it } from "vitest";
4+
import { reuseWinners, sameOccurrences } from "./investigation-winners";
5+
6+
describe("reuseWinners", () => {
7+
it("keeps the previous map when the winners are unchanged", () => {
8+
const first = new Map([["inv_1", "msg_a:0"]]);
9+
const second = new Map([["inv_1", "msg_a:0"]]);
10+
expect(sameOccurrences(first, second)).toBe(true);
11+
expect(reuseWinners(first, second)).toBe(first);
12+
});
13+
14+
it("takes the next map when a winner moves", () => {
15+
const first = new Map([["inv_1", "msg_a:0"]]);
16+
const moved = new Map([["inv_1", "msg_b:2"]]);
17+
expect(reuseWinners(first, moved)).toBe(moved);
18+
});
19+
20+
it("takes the next map when an investigation appears", () => {
21+
const first = new Map([["inv_1", "msg_a:0"]]);
22+
const grown = new Map([
23+
["inv_1", "msg_a:0"],
24+
["inv_2", "msg_b:1"],
25+
]);
26+
expect(reuseWinners(first, grown)).toBe(grown);
27+
});
28+
29+
it("takes the next map on the first render", () => {
30+
const only = new Map([["inv_1", "msg_a:0"]]);
31+
expect(reuseWinners(undefined, only)).toBe(only);
32+
});
33+
});
34+
35+
// Structural: there is no jsdom here, so the wiring is asserted against the source.
36+
describe("DashboardAgentMessages wiring", () => {
37+
const source = readFileSync(join(__dirname, "DashboardAgentMessages.tsx"), "utf8");
38+
39+
it("stabilises the winners map and the stripped messages it renders", () => {
40+
expect(source).toContain("reuseWinners(previous.current, next)");
41+
expect(source).toContain("useInvestigationWinners(stripped)");
42+
expect(source).toContain("useMemo(() => messages.map(stripStepParts), [messages])");
43+
expect(source).toContain("strippedMessages.set(message, stripped)");
44+
});
45+
});
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
export function sameOccurrences(a: Map<string, string>, b: Map<string, string>): boolean {
2+
if (a.size !== b.size) return false;
3+
for (const [id, occurrence] of a) {
4+
if (b.get(id) !== occurrence) return false;
5+
}
6+
return true;
7+
}
8+
9+
export function reuseWinners(
10+
previous: Map<string, string> | undefined,
11+
next: Map<string, string>
12+
): Map<string, string> {
13+
return previous && sameOccurrences(previous, next) ? previous : next;
14+
}

0 commit comments

Comments
 (0)