Skip to content

Commit 2aeb32f

Browse files
committed
refactor(webapp): move dashboard-agent render UI to the UI PR
1 parent db414b7 commit 2aeb32f

23 files changed

Lines changed: 65 additions & 2726 deletions

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

Lines changed: 0 additions & 31 deletions
This file was deleted.

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

Lines changed: 18 additions & 113 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,17 @@
11
import type { UIMessage } from "@ai-sdk/react";
2-
import { memo, useMemo, useRef } from "react";
2+
import { memo } 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";
7-
import { answerContinuesAfter } from "./view-actions";
86
import { 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).
1512
function 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">

apps/webapp/app/components/dashboard-agent/InvestigationCard.render.test.ts

Lines changed: 0 additions & 89 deletions
This file was deleted.

apps/webapp/app/components/dashboard-agent/InvestigationCard.test.ts

Lines changed: 0 additions & 41 deletions
This file was deleted.

0 commit comments

Comments
 (0)