|
1 | 1 | import type { UIMessage } from "@ai-sdk/react"; |
2 | | -import { memo } from "react"; |
| 2 | +import { memo, useMemo, useRef } from "react"; |
3 | 3 | import { Spinner } from "~/components/primitives/Spinner"; |
4 | 4 | import { MessageBubble, renderPart } from "~/components/runs/v3/agent/AgentMessageView"; |
5 | 5 | import { useAutoScrollToBottom } from "~/hooks/useAutoScrollToBottom"; |
| 6 | +import { reuseWinners } from "./investigation-winners"; |
6 | 7 | import { ViewBlocks } from "./view-catalog"; |
7 | 8 |
|
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 | + |
12 | 14 | function stripStepParts(message: UIMessage): UIMessage { |
13 | 15 | 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; |
15 | 21 | } |
16 | 22 |
|
17 | 23 | // A completed render_view tool part carries a `{ blocks }` view spec the agent |
@@ -65,6 +71,14 @@ export function winningInvestigationOccurrences(messages: UIMessage[]): Map<stri |
65 | 71 | return new Map([...best.entries()].map(([id, w]) => [id, w.occurrence])); |
66 | 72 | } |
67 | 73 |
|
| 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 | + |
68 | 82 | function withoutSupersededInvestigations( |
69 | 83 | blocks: unknown[], |
70 | 84 | occurrence: string, |
@@ -127,15 +141,17 @@ export function DashboardAgentMessages({ |
127 | 141 | error?: Error; |
128 | 142 | }) { |
129 | 143 | 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); |
131 | 147 |
|
132 | 148 | return ( |
133 | 149 | <div className="flex-1 overflow-y-auto scrollbar-thin scrollbar-track-transparent scrollbar-thumb-surface-control"> |
134 | 150 | <div ref={rootRef} className="space-y-4 p-4"> |
135 | | - {messages.map((message) => ( |
| 151 | + {stripped.map((message) => ( |
136 | 152 | <MemoizedMessageBubble |
137 | 153 | key={message.id} |
138 | | - message={stripStepParts(message)} |
| 154 | + message={message} |
139 | 155 | investigationWinners={investigationWinners} |
140 | 156 | /> |
141 | 157 | ))} |
|
0 commit comments