11import type { UIMessage } from "@ai-sdk/react" ;
2- import { memo , useMemo , useRef } from "react" ;
2+ import { memo } from "react" ;
33import { Spinner } from "~/components/primitives/Spinner" ;
44import { MessageBubble , renderPart } from "~/components/runs/v3/agent/AgentMessageView" ;
55import { useAutoScrollToBottom } from "~/hooks/useAutoScrollToBottom" ;
6- import { reuseWinners } from "./investigation-winners" ;
7- import { answerContinuesAfter } from "./view-actions" ;
86import { ViewBlocks } from "./view-catalog" ;
97
10- // The shared MessageBubble renders `step-start` parts as a dashed "step" separator —
11- // useful in the run inspector / playground, just noise in this simple chat.
12- // Cached so a stripped message keeps its identity across renders and memoization holds.
13- const strippedMessages = new WeakMap < UIMessage , UIMessage > ( ) ;
14-
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).
1512function stripStepParts ( message : UIMessage ) : UIMessage {
1613 if ( ! message . parts ?. some ( ( p ) => p . type === "step-start" ) ) return message ;
17- const cached = strippedMessages . get ( message ) ;
18- if ( cached ) return cached ;
19- const stripped = { ...message , parts : message . parts . filter ( ( p ) => p . type !== "step-start" ) } ;
20- strippedMessages . set ( message , stripped ) ;
21- return stripped ;
14+ return { ...message , parts : message . parts . filter ( ( p ) => p . type !== "step-start" ) } ;
2215}
2316
2417// A completed render_view tool part carries a `{ blocks }` view spec the agent
@@ -30,109 +23,28 @@ function viewSpecFor(part: UIMessage["parts"][number]): { blocks: unknown[] } |
3023 return Array . isArray ( p . output ?. blocks ) ? { blocks : p . output ! . blocks ! } : null ;
3124}
3225
33- function hostViewBlocks ( part : UIMessage [ "parts" ] [ number ] ) : unknown [ ] | null {
34- const p = part as { type : string ; data ?: { blocks ?: unknown [ ] } } ;
35- if ( p . type !== "data-view" ) return null ;
36- return Array . isArray ( p . data ?. blocks ) ? p . data ! . blocks ! : null ;
37- }
38-
39- // Both carriers render as cards, so whichever one wins a revision is one the panel
40- // can actually draw — a host-written card can never suppress a tool-rendered one
41- // into nothing.
42- function viewBlocksFor ( part : UIMessage [ "parts" ] [ number ] ) : unknown [ ] | null {
43- return viewSpecFor ( part ) ?. blocks ?? hostViewBlocks ( part ) ;
44- }
45-
46- type InvestigationRef = { id : string ; revision : number } ;
47-
48- function investigationRef ( block : unknown ) : InvestigationRef | null {
49- const b = block as { type ?: string ; id ?: string ; revision ?: number } ;
50- if ( b ?. type !== "investigation" || typeof b . id !== "string" ) return null ;
51- return { id : b . id , revision : typeof b . revision === "number" ? b . revision : 0 } ;
52- }
53-
54- /**
55- * Per investigation id, the one `messageId:partIndex` allowed to render: highest revision.
56- * Indexed over the same stripped parts the renderer walks, so the two agree on what part 0 is.
57- */
58- export function winningInvestigationOccurrences ( messages : UIMessage [ ] ) : Map < string , string > {
59- const best = new Map < string , { revision : number ; occurrence : string } > ( ) ;
60- for ( const message of messages . map ( stripStepParts ) ) {
61- ( message . parts ?? [ ] ) . forEach ( ( part , partIndex ) => {
62- for ( const block of viewBlocksFor ( part ) ?? [ ] ) {
63- const ref = investigationRef ( block ) ;
64- if ( ! ref ) continue ;
65- const current = best . get ( ref . id ) ;
66- if ( ! current || ref . revision >= current . revision ) {
67- best . set ( ref . id , { revision : ref . revision , occurrence : `${ message . id } :${ partIndex } ` } ) ;
68- }
69- }
70- } ) ;
71- }
72- return new Map ( [ ...best . entries ( ) ] . map ( ( [ id , w ] ) => [ id , w . occurrence ] ) ) ;
73- }
74-
75- // The stable identity is the point: a fresh `Map` re-renders the whole transcript per token.
76- function useInvestigationWinners ( messages : UIMessage [ ] ) : Map < string , string > {
77- const previous = useRef < Map < string , string > > ( ) ;
78- const next = useMemo ( ( ) => winningInvestigationOccurrences ( messages ) , [ messages ] ) ;
79- previous . current = reuseWinners ( previous . current , next ) ;
80- return previous . current ;
81- }
82-
83- function withoutSupersededInvestigations (
84- blocks : unknown [ ] ,
85- occurrence : string ,
86- winners : Map < string , string > | undefined
87- ) : unknown [ ] {
88- if ( ! winners ) return blocks ;
89- return blocks . filter ( ( block ) => {
90- const ref = investigationRef ( block ) ;
91- return ! ref || winners . get ( ref . id ) === occurrence ;
92- } ) ;
93- }
94-
95- // Renders one message. Assistant messages carrying a view spec get the catalog
96- // cards (plus the gather tool rows / lead-in text for transparency); everything
97- // else uses the shared MessageBubble unchanged, so its streaming memoization is
98- // preserved for the common case.
99- export function DashboardAgentMessageBubble ( {
26+ // Renders one message. Assistant messages that include a completed render_view
27+ // part get the catalog cards (plus the gather tool rows / lead-in text for
28+ // transparency); everything else uses the shared MessageBubble unchanged, so
29+ // its streaming memoization is preserved for the common case.
30+ const DashboardAgentMessageBubble = memo ( function DashboardAgentMessageBubble ( {
10031 message,
101- investigationWinners,
10232} : {
10333 message : UIMessage ;
104- /** See {@link winningInvestigationOccurrences}. */
105- investigationWinners ?: Map < string , string > ;
10634} ) {
107- if ( message . role !== "assistant" || ! message . parts ?. some ( ( p ) => viewBlocksFor ( p ) ) ) {
35+ if ( message . role !== "assistant" || ! message . parts ?. some ( ( p ) => viewSpecFor ( p ) ) ) {
10836 return < MessageBubble message = { message } /> ;
10937 }
11038 return (
11139 < div className = "space-y-2" >
11240 { message . parts . map ( ( part , i ) => {
113- const spec = viewBlocksFor ( part ) ;
114- if ( ! spec ) return renderPart ( part , i ) ;
115- const blocks = withoutSupersededInvestigations (
116- spec ,
117- `${ message . id } :${ i } ` ,
118- investigationWinners
119- ) ;
120- if ( blocks . length === 0 ) return null ;
121- // No `onIntent`: nothing here can act on one yet, so the cards drop their
122- // action rows rather than offer buttons that would do nothing.
123- return (
124- < ViewBlocks
125- key = { i }
126- blocks = { blocks as never }
127- answered = { answerContinuesAfter ( message . parts as never , i ) }
128- />
129- ) ;
41+ const spec = viewSpecFor ( part ) ;
42+ if ( spec ) return < ViewBlocks key = { i } blocks = { spec . blocks as never } /> ;
43+ return renderPart ( part , i ) ;
13044 } ) }
13145 </ div >
13246 ) ;
133- }
134-
135- const MemoizedMessageBubble = memo ( DashboardAgentMessageBubble ) ;
47+ } ) ;
13648
13749// Renders the conversation with the shared agent message renderer — the same
13850// MessageBubble the run inspector and playground use, so agent output looks
@@ -148,19 +60,12 @@ export function DashboardAgentMessages({
14860 error ?: Error ;
14961} ) {
15062 const rootRef = useAutoScrollToBottom ( [ messages , isThinking ] ) ;
151- // Must be the exact parts the bubbles render: the winners map keys by part index.
152- const stripped = useMemo ( ( ) => messages . map ( stripStepParts ) , [ messages ] ) ;
153- const investigationWinners = useInvestigationWinners ( stripped ) ;
15463
15564 return (
15665 < div className = "flex-1 overflow-y-auto scrollbar-thin scrollbar-track-transparent scrollbar-thumb-surface-control" >
15766 < div ref = { rootRef } className = "space-y-4 p-4" >
158- { stripped . map ( ( message ) => (
159- < MemoizedMessageBubble
160- key = { message . id }
161- message = { message }
162- investigationWinners = { investigationWinners }
163- />
67+ { messages . map ( ( message ) => (
68+ < DashboardAgentMessageBubble key = { message . id } message = { stripStepParts ( message ) } />
16469 ) ) }
16570 { isThinking && (
16671 < div className = "flex items-center gap-2 text-sm text-text-dimmed" >
0 commit comments