mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
perf(site/src/pages/AgentsPage): convert renderBlockList to BlockList component (#23673)
This commit is contained in:
@@ -348,7 +348,7 @@ export const UserMessageTextOnly: Story = {
|
||||
},
|
||||
};
|
||||
|
||||
/** Assistant-side images go through renderBlockList, not the user path. */
|
||||
/** Assistant-side images go through BlockList, not the user path. */
|
||||
export const AssistantMessageWithImage: Story = {
|
||||
args: {
|
||||
...defaultArgs,
|
||||
|
||||
@@ -3,7 +3,6 @@ import {
|
||||
type FC,
|
||||
Fragment,
|
||||
memo,
|
||||
type ReactNode,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useRef,
|
||||
@@ -88,22 +87,6 @@ const ReasoningDisclosure = memo<{
|
||||
);
|
||||
});
|
||||
|
||||
// Shared block renderer used by both ChatMessageItem (historical
|
||||
// messages) and StreamingOutput (live stream). Encapsulates the
|
||||
// response / thinking / tool switch so the two consumers stay in sync.
|
||||
type RenderBlockListParams = {
|
||||
blocks: readonly RenderBlock[];
|
||||
toolByID: ReadonlyMap<string, MergedTool>;
|
||||
keyPrefix: string;
|
||||
isStreaming?: boolean;
|
||||
subagentTitles?: Map<string, string>;
|
||||
subagentStatusOverrides?: Map<string, TypesGen.ChatStatus>;
|
||||
mcpServers?: readonly TypesGen.MCPServerConfig[];
|
||||
onImageClick?: (src: string) => void;
|
||||
onTextFileClick?: (content: string) => void;
|
||||
urlTransform?: UrlTransform;
|
||||
};
|
||||
|
||||
// Wrapper that runs the smooth-streaming jitter buffer on a single
|
||||
// response block. Only used during live streaming — historical
|
||||
// messages render through <Response> directly.
|
||||
@@ -207,24 +190,15 @@ const TextAttachmentButton: FC<{
|
||||
|
||||
type FileRenderBlock = Extract<RenderBlock, { type: "file" }>;
|
||||
|
||||
type RenderFileBlockParams = {
|
||||
const FileBlock: FC<{
|
||||
block: FileRenderBlock;
|
||||
key: string;
|
||||
onImageClick?: (src: string) => void;
|
||||
onTextFileClick?: (content: string) => void;
|
||||
};
|
||||
|
||||
const renderFileBlock = ({
|
||||
block,
|
||||
key,
|
||||
onImageClick,
|
||||
onTextFileClick,
|
||||
}: RenderFileBlockParams): ReactNode => {
|
||||
}> = ({ block, onImageClick, onTextFileClick }) => {
|
||||
if (block.media_type === "text/plain") {
|
||||
if (block.file_id) {
|
||||
return (
|
||||
<TextAttachmentButton
|
||||
key={key}
|
||||
fileId={block.file_id}
|
||||
onPreview={onTextFileClick}
|
||||
/>
|
||||
@@ -233,7 +207,6 @@ const renderFileBlock = ({
|
||||
if (block.data != null) {
|
||||
return (
|
||||
<InlineTextAttachmentButton
|
||||
key={key}
|
||||
content={decodeInlineTextAttachment(block.data)}
|
||||
onPreview={onTextFileClick}
|
||||
/>
|
||||
@@ -248,7 +221,6 @@ const renderFileBlock = ({
|
||||
: `data:${block.media_type};base64,${block.data}`;
|
||||
return (
|
||||
<button
|
||||
key={key}
|
||||
type="button"
|
||||
aria-label="View image"
|
||||
className="inline-block rounded-md border-0 bg-transparent p-0"
|
||||
@@ -266,14 +238,25 @@ const renderFileBlock = ({
|
||||
);
|
||||
};
|
||||
|
||||
type RenderBlockListResult = {
|
||||
elements: ReactNode[];
|
||||
renderedToolIDs: ReadonlySet<string>;
|
||||
};
|
||||
|
||||
function renderBlockList({
|
||||
// Shared block renderer used by both ChatMessageItem (historical
|
||||
// messages) and StreamingOutput (live stream). Encapsulates the
|
||||
// response / thinking / tool / file / sources switch so both
|
||||
// consumers stay in sync. PascalCase so the React Compiler
|
||||
// auto-memoizes every element inside.
|
||||
const BlockList: FC<{
|
||||
blocks: readonly RenderBlock[];
|
||||
tools: readonly MergedTool[];
|
||||
keyPrefix: string;
|
||||
isStreaming?: boolean;
|
||||
subagentTitles?: Map<string, string>;
|
||||
subagentStatusOverrides?: Map<string, TypesGen.ChatStatus>;
|
||||
mcpServers?: readonly TypesGen.MCPServerConfig[];
|
||||
onImageClick?: (src: string) => void;
|
||||
onTextFileClick?: (content: string) => void;
|
||||
urlTransform?: UrlTransform;
|
||||
}> = ({
|
||||
blocks,
|
||||
toolByID,
|
||||
tools,
|
||||
keyPrefix,
|
||||
isStreaming = false,
|
||||
subagentTitles,
|
||||
@@ -282,110 +265,142 @@ function renderBlockList({
|
||||
onImageClick,
|
||||
onTextFileClick,
|
||||
urlTransform,
|
||||
}: RenderBlockListParams): RenderBlockListResult {
|
||||
const renderedToolIDs = new Set<string>();
|
||||
const elements = blocks
|
||||
.map((block, index) => {
|
||||
switch (block.type) {
|
||||
case "response":
|
||||
return isStreaming ? (
|
||||
<SmoothedResponse
|
||||
key={`${keyPrefix}-response-${index}`}
|
||||
text={block.text}
|
||||
streamKey={keyPrefix}
|
||||
urlTransform={urlTransform}
|
||||
/>
|
||||
) : (
|
||||
<Response
|
||||
key={`${keyPrefix}-response-${index}`}
|
||||
urlTransform={urlTransform}
|
||||
>
|
||||
{block.text}
|
||||
</Response>
|
||||
);
|
||||
case "thinking":
|
||||
return (
|
||||
<ReasoningDisclosure
|
||||
key={`${keyPrefix}-thinking-${index}`}
|
||||
id={`${keyPrefix}-thinking-${index}`}
|
||||
text={block.text}
|
||||
isStreaming={isStreaming}
|
||||
urlTransform={urlTransform}
|
||||
/>
|
||||
);
|
||||
case "file-reference":
|
||||
return (
|
||||
<div
|
||||
key={`${keyPrefix}-file-reference-${index}`}
|
||||
className="my-1 flex items-start gap-2 rounded-md border border-content-link/20 bg-content-link/5 px-2.5 py-1.5"
|
||||
>
|
||||
<span className="shrink-0 text-xs font-medium text-content-link">
|
||||
{block.file_name}:
|
||||
{block.start_line === block.end_line
|
||||
? block.start_line
|
||||
: `${block.start_line}\u2013${block.end_line}`}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
case "tool": {
|
||||
const tool = toolByID.get(block.id);
|
||||
if (!tool) {
|
||||
if (!isStreaming) {
|
||||
return null;
|
||||
}) => {
|
||||
const toolByID = new Map(tools.map((tool) => [tool.id, tool]));
|
||||
|
||||
// Pre-compute which tool IDs have a corresponding block so
|
||||
// we can render "remaining" (block-less) tools afterwards.
|
||||
const blockToolIDs = new Set(
|
||||
blocks
|
||||
.filter(
|
||||
(b): b is Extract<RenderBlock, { type: "tool" }> =>
|
||||
b.type === "tool" && (toolByID.has(b.id) || isStreaming),
|
||||
)
|
||||
.map((b) => b.id),
|
||||
);
|
||||
|
||||
const remainingTools = tools.filter((tool) => !blockToolIDs.has(tool.id));
|
||||
|
||||
return (
|
||||
<>
|
||||
{blocks.map((block, index) => {
|
||||
switch (block.type) {
|
||||
case "response":
|
||||
return isStreaming ? (
|
||||
<SmoothedResponse
|
||||
key={`${keyPrefix}-response-${index}`}
|
||||
text={block.text}
|
||||
streamKey={keyPrefix}
|
||||
urlTransform={urlTransform}
|
||||
/>
|
||||
) : (
|
||||
<Response
|
||||
key={`${keyPrefix}-response-${index}`}
|
||||
urlTransform={urlTransform}
|
||||
>
|
||||
{block.text}
|
||||
</Response>
|
||||
);
|
||||
case "thinking":
|
||||
return (
|
||||
<ReasoningDisclosure
|
||||
key={`${keyPrefix}-thinking-${index}`}
|
||||
id={`${keyPrefix}-thinking-${index}`}
|
||||
text={block.text}
|
||||
isStreaming={isStreaming}
|
||||
urlTransform={urlTransform}
|
||||
/>
|
||||
);
|
||||
case "file-reference":
|
||||
return (
|
||||
<div
|
||||
key={`${keyPrefix}-file-reference-${index}`}
|
||||
className="my-1 flex items-start gap-2 rounded-md border border-content-link/20 bg-content-link/5 px-2.5 py-1.5"
|
||||
>
|
||||
<span className="shrink-0 text-xs font-medium text-content-link">
|
||||
{block.file_name}:
|
||||
{block.start_line === block.end_line
|
||||
? block.start_line
|
||||
: `${block.start_line}\u2013${block.end_line}`}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
case "tool": {
|
||||
const tool = toolByID.get(block.id);
|
||||
if (!tool) {
|
||||
if (!isStreaming) {
|
||||
return null;
|
||||
}
|
||||
// Streaming placeholder for not-yet-resolved tool.
|
||||
return (
|
||||
<Tool
|
||||
key={block.id}
|
||||
name="Tool"
|
||||
status="running"
|
||||
isError={false}
|
||||
subagentTitles={subagentTitles}
|
||||
subagentStatusOverrides={subagentStatusOverrides}
|
||||
mcpServers={mcpServers}
|
||||
/>
|
||||
);
|
||||
}
|
||||
// Streaming placeholder for not-yet-resolved tool.
|
||||
renderedToolIDs.add(block.id);
|
||||
return (
|
||||
<Tool
|
||||
key={block.id}
|
||||
name="Tool"
|
||||
status="running"
|
||||
isError={false}
|
||||
key={tool.id}
|
||||
name={tool.name}
|
||||
args={tool.args}
|
||||
result={tool.result}
|
||||
status={tool.status}
|
||||
isError={tool.isError}
|
||||
subagentTitles={subagentTitles}
|
||||
subagentStatusOverrides={subagentStatusOverrides}
|
||||
subagentStatusOverrides={
|
||||
isStreaming ? subagentStatusOverrides : undefined
|
||||
}
|
||||
mcpServerConfigId={tool.mcpServerConfigId}
|
||||
mcpServers={mcpServers}
|
||||
/>
|
||||
);
|
||||
}
|
||||
renderedToolIDs.add(tool.id);
|
||||
return (
|
||||
<Tool
|
||||
key={tool.id}
|
||||
name={tool.name}
|
||||
args={tool.args}
|
||||
result={tool.result}
|
||||
status={tool.status}
|
||||
isError={tool.isError}
|
||||
subagentTitles={isStreaming ? subagentTitles : undefined}
|
||||
subagentStatusOverrides={
|
||||
isStreaming ? subagentStatusOverrides : undefined
|
||||
}
|
||||
mcpServerConfigId={tool.mcpServerConfigId}
|
||||
mcpServers={mcpServers}
|
||||
/>
|
||||
);
|
||||
case "file":
|
||||
return (
|
||||
<FileBlock
|
||||
key={`${keyPrefix}-file-${block.file_id ?? index}`}
|
||||
block={block}
|
||||
onImageClick={onImageClick}
|
||||
onTextFileClick={onTextFileClick}
|
||||
/>
|
||||
);
|
||||
case "sources":
|
||||
return (
|
||||
<WebSearchSources
|
||||
key={`${keyPrefix}-sources-${index}`}
|
||||
sources={block.sources}
|
||||
/>
|
||||
);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
case "file":
|
||||
return renderFileBlock({
|
||||
block,
|
||||
key: `${keyPrefix}-file-${block.file_id ?? index}`,
|
||||
onImageClick,
|
||||
onTextFileClick,
|
||||
});
|
||||
case "sources":
|
||||
return (
|
||||
<WebSearchSources
|
||||
key={`${keyPrefix}-sources-${index}`}
|
||||
sources={block.sources}
|
||||
/>
|
||||
);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
})
|
||||
.filter((el): el is NonNullable<typeof el> => el != null);
|
||||
return { elements, renderedToolIDs };
|
||||
}
|
||||
})}
|
||||
|
||||
{remainingTools.map((tool) => (
|
||||
<Tool
|
||||
key={tool.id}
|
||||
name={tool.name}
|
||||
args={tool.args}
|
||||
result={tool.result}
|
||||
status={tool.status}
|
||||
isError={tool.isError}
|
||||
subagentTitles={subagentTitles}
|
||||
subagentStatusOverrides={
|
||||
isStreaming ? subagentStatusOverrides : undefined
|
||||
}
|
||||
mcpServerConfigId={tool.mcpServerConfigId}
|
||||
mcpServers={mcpServers}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const ChatMessageItem = memo<{
|
||||
message: TypesGen.ChatMessage;
|
||||
@@ -422,8 +437,6 @@ const ChatMessageItem = memo<{
|
||||
const isSavingMessage = savingMessageId === message.id;
|
||||
const [previewImage, setPreviewImage] = useState<string | null>(null);
|
||||
const [previewText, setPreviewText] = useState<string | null>(null);
|
||||
const toolByID = new Map(parsed.tools.map((tool) => [tool.id, tool]));
|
||||
|
||||
if (
|
||||
parsed.toolResults.length > 0 &&
|
||||
parsed.toolCalls.length === 0 &&
|
||||
@@ -479,19 +492,6 @@ const ChatMessageItem = memo<{
|
||||
const conversationItemProps: { role: "user" | "assistant" } = {
|
||||
role: isUser ? "user" : "assistant",
|
||||
};
|
||||
const { elements: orderedBlocks, renderedToolIDs } = renderBlockList({
|
||||
blocks: parsed.blocks,
|
||||
toolByID,
|
||||
keyPrefix: String(message.id),
|
||||
subagentTitles,
|
||||
onImageClick: setPreviewImage,
|
||||
onTextFileClick: (content) => setPreviewText(content),
|
||||
urlTransform,
|
||||
mcpServers,
|
||||
});
|
||||
const remainingTools = parsed.tools.filter(
|
||||
(tool) => !renderedToolIDs.has(tool.id),
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -584,26 +584,23 @@ const ChatMessageItem = memo<{
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{(() => {
|
||||
if (userFileBlocks.length === 0) return null;
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
hasUserMessageBody && "mt-2",
|
||||
"flex flex-wrap gap-2",
|
||||
)}
|
||||
>
|
||||
{userFileBlocks.map((block, i) =>
|
||||
renderFileBlock({
|
||||
block,
|
||||
key: `user-file-${block.file_id ?? i}`,
|
||||
onImageClick: setPreviewImage,
|
||||
onTextFileClick: setPreviewText,
|
||||
}),
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
{hasFileBlocks && (
|
||||
<div
|
||||
className={cn(
|
||||
hasUserMessageBody && "mt-2",
|
||||
"flex flex-wrap gap-2",
|
||||
)}
|
||||
>
|
||||
{userFileBlocks.map((block, i) => (
|
||||
<FileBlock
|
||||
key={`user-file-${block.file_id ?? i}`}
|
||||
block={block}
|
||||
onImageClick={setPreviewImage}
|
||||
onTextFileClick={setPreviewText}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{fadeFromBottom && (
|
||||
<div
|
||||
className="pointer-events-none absolute inset-x-0 bottom-0 h-1/2 max-h-12"
|
||||
@@ -620,20 +617,16 @@ const ChatMessageItem = memo<{
|
||||
<Message className="w-full">
|
||||
<MessageContent className="whitespace-normal">
|
||||
<div className="space-y-3">
|
||||
{orderedBlocks}
|
||||
{remainingTools.map((tool) => (
|
||||
<Tool
|
||||
key={tool.id}
|
||||
name={tool.name}
|
||||
args={tool.args}
|
||||
result={tool.result}
|
||||
status={tool.status}
|
||||
isError={tool.isError}
|
||||
subagentTitles={subagentTitles}
|
||||
mcpServerConfigId={tool.mcpServerConfigId}
|
||||
mcpServers={mcpServers}
|
||||
/>
|
||||
))}
|
||||
<BlockList
|
||||
blocks={parsed.blocks}
|
||||
tools={parsed.tools}
|
||||
keyPrefix={String(message.id)}
|
||||
subagentTitles={subagentTitles}
|
||||
onImageClick={setPreviewImage}
|
||||
onTextFileClick={setPreviewText}
|
||||
urlTransform={urlTransform}
|
||||
mcpServers={mcpServers}
|
||||
/>
|
||||
{!hasRenderableContent && (
|
||||
<div className="text-xs text-content-secondary">
|
||||
Message has no renderable content.
|
||||
@@ -698,21 +691,7 @@ export const StreamingOutput: FC<{
|
||||
}
|
||||
|
||||
const conversationItemProps = { role: "assistant" as const };
|
||||
const toolByID = new Map(streamTools.map((tool) => [tool.id, tool]));
|
||||
const blocks = shouldShowBlocks ? (streamState?.blocks ?? []) : [];
|
||||
const { elements: orderedBlocks, renderedToolIDs } = renderBlockList({
|
||||
blocks,
|
||||
toolByID,
|
||||
keyPrefix: "stream",
|
||||
isStreaming,
|
||||
subagentTitles,
|
||||
subagentStatusOverrides,
|
||||
urlTransform,
|
||||
mcpServers,
|
||||
});
|
||||
const remainingTools = shouldShowBlocks
|
||||
? streamTools.filter((tool) => !renderedToolIDs.has(tool.id))
|
||||
: [];
|
||||
|
||||
return (
|
||||
<ConversationItem {...conversationItemProps}>
|
||||
@@ -720,25 +699,16 @@ export const StreamingOutput: FC<{
|
||||
<MessageContent className="whitespace-normal">
|
||||
<div className="space-y-3">
|
||||
{shouldShowBlocks && (
|
||||
<>
|
||||
{orderedBlocks}
|
||||
{remainingTools.map((tool) => (
|
||||
<Tool
|
||||
key={tool.id}
|
||||
name={tool.name}
|
||||
args={tool.args}
|
||||
result={tool.result}
|
||||
status={tool.status}
|
||||
isError={tool.isError}
|
||||
subagentTitles={isStreaming ? subagentTitles : undefined}
|
||||
subagentStatusOverrides={
|
||||
isStreaming ? subagentStatusOverrides : undefined
|
||||
}
|
||||
mcpServerConfigId={tool.mcpServerConfigId}
|
||||
mcpServers={mcpServers}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
<BlockList
|
||||
blocks={blocks}
|
||||
tools={streamTools}
|
||||
keyPrefix="stream"
|
||||
isStreaming={isStreaming}
|
||||
subagentTitles={subagentTitles}
|
||||
subagentStatusOverrides={subagentStatusOverrides}
|
||||
urlTransform={urlTransform}
|
||||
mcpServers={mcpServers}
|
||||
/>
|
||||
)}
|
||||
{shouldShowStatusCallout && (
|
||||
<ChatStatusCallout
|
||||
|
||||
Reference in New Issue
Block a user