From 1f7951df1b2dff6666adcfde1d8664d593fe0667 Mon Sep 17 00:00:00 2001 From: Naiyuan Qing <145280634+NevilleQingNY@users.noreply.github.com> Date: Wed, 4 Feb 2026 18:12:34 +0800 Subject: [PATCH] fix(ui): render tool calls immediately from assistant toolCall blocks Tools were only visible after tool_execution_end because the UI relied solely on toolResult messages (created at tool_execution_start, ~8ms before end). Now MessageList detects toolCall blocks in the streaming assistant message and renders them as "running" ToolCallItems immediately. Once a real toolResult message arrives, the synthetic one is replaced. - Add resolvedToolCallIds set to deduplicate assistant vs toolResult renders - Extract getTextContent to shared utils to avoid duplication - Wrap MessageList and ToolCallItem in memo for performance - Add accessible region/tabIndex to expanded result panel Co-Authored-By: Claude Opus 4.5 --- packages/ui/src/components/message-list.tsx | 104 +++++++++++------- packages/ui/src/components/tool-call-item.tsx | 26 ++--- packages/ui/src/lib/utils.ts | 9 ++ 3 files changed, 85 insertions(+), 54 deletions(-) diff --git a/packages/ui/src/components/message-list.tsx b/packages/ui/src/components/message-list.tsx index b372d96f..2afc246a 100644 --- a/packages/ui/src/components/message-list.tsx +++ b/packages/ui/src/components/message-list.tsx @@ -1,23 +1,30 @@ "use client"; +import { memo, useMemo } from "react"; import { MemoizedMarkdown } from "@multica/ui/components/markdown"; import { StreamingMarkdown } from "@multica/ui/components/markdown/StreamingMarkdown"; import { ToolCallItem } from "@multica/ui/components/tool-call-item"; -import { cn } from "@multica/ui/lib/utils"; +import { cn, getTextContent } from "@multica/ui/lib/utils"; import type { Message } from "@multica/store"; -import type { ContentBlock } from "@multica/sdk"; +import type { ContentBlock, ToolCall } from "@multica/sdk"; -/** Extract plain text from ContentBlock[] */ -function getTextContent(blocks: ContentBlock[]): string { - return blocks - .filter((b): b is { type: "text"; text: string } => b.type === "text") - .map((b) => b.text) - .join("") +/** Extract toolCall blocks from content */ +function getToolCalls(blocks: ContentBlock[]): ToolCall[] { + return blocks.filter((b): b is ToolCall => b.type === "toolCall") } -/** Check if content has any toolCall blocks */ -function getToolCalls(blocks: ContentBlock[]) { - return blocks.filter((b): b is Extract => b.type === "toolCall") +/** Build a synthetic "running" toolResult Message from a ToolCall block */ +function toRunningMessage(tc: ToolCall, agentId: string): Message { + return { + id: tc.id, + role: "toolResult", + content: [], + agentId, + toolCallId: tc.id, + toolName: tc.name, + toolArgs: tc.arguments, + toolStatus: "running", + } } interface MessageListProps { @@ -25,7 +32,19 @@ interface MessageListProps { streamingIds: Set } -export function MessageList({ messages, streamingIds }: MessageListProps) { +export const MessageList = memo(function MessageList({ messages, streamingIds }: MessageListProps) { + // Build a set of toolCallIds that already have a toolResult message, + // so we don't render duplicate items from the assistant's toolCall blocks + const resolvedToolCallIds = useMemo(() => { + const ids = new Set() + for (const msg of messages) { + if (msg.role === "toolResult" && msg.toolCallId) { + ids.add(msg.toolCallId) + } + } + return ids + }, [messages]) + return (
{messages.map((msg) => { @@ -38,39 +57,46 @@ export function MessageList({ messages, streamingIds }: MessageListProps) { const toolCalls = msg.role === "assistant" ? getToolCalls(msg.content) : [] const isStreaming = streamingIds.has(msg.id) - // Skip empty assistant messages that only contain toolCalls (no text) - // The toolCalls are visible via the subsequent toolResult entries - if (msg.role === "assistant" && !text && toolCalls.length > 0 && !isStreaming) { - return null - } + // Find toolCall blocks that don't have a toolResult message yet — + // these are tools the LLM decided to call but haven't started executing + const unresolvedToolCalls = toolCalls.filter((tc) => !resolvedToolCallIds.has(tc.id)) - // Skip completely empty messages - if (!text && !isStreaming) return null + // Skip completely empty messages (no text, no unresolved tools, not streaming) + if (!text && unresolvedToolCalls.length === 0 && !isStreaming) return null return ( -
+ {/* Render text content (if any) */} + {(text || isStreaming) && ( +
+
+ {isStreaming ? ( + + ) : ( + + {text} + + )} +
+
)} - > -
- {isStreaming ? ( - - ) : ( - - {text} - - )} -
+ + {/* Render unresolved toolCall blocks as "running" tool items */} + {unresolvedToolCalls.map((tc) => ( + + ))}
) })}
) -} +}) diff --git a/packages/ui/src/components/tool-call-item.tsx b/packages/ui/src/components/tool-call-item.tsx index eaf97d88..e8374cc8 100644 --- a/packages/ui/src/components/tool-call-item.tsx +++ b/packages/ui/src/components/tool-call-item.tsx @@ -1,6 +1,6 @@ "use client" -import { useState } from "react" +import { memo, useState } from "react" import { HugeiconsIcon } from "@hugeicons/react" import { File01Icon, @@ -14,9 +14,8 @@ import { GitBranchIcon, ArrowRight01Icon, } from "@hugeicons/core-free-icons" -import { cn } from "@multica/ui/lib/utils" +import { cn, getTextContent } from "@multica/ui/lib/utils" import type { Message } from "@multica/store" -import type { ContentBlock } from "@multica/sdk" // --------------------------------------------------------------------------- // Tool display config @@ -46,14 +45,6 @@ const TOOL_DISPLAY: Record = // Helpers // --------------------------------------------------------------------------- -/** Extract plain text from ContentBlock[] */ -function getResultText(blocks: ContentBlock[]): string { - return blocks - .filter((b): b is { type: "text"; text: string } => b.type === "text") - .map((b) => b.text) - .join("") -} - /** Extract a short basename from a file path */ function basename(path: string): string { return path.split("/").pop() ?? path @@ -130,13 +121,13 @@ function getStats(toolName: string, toolStatus: string, resultText: string): str // Component // --------------------------------------------------------------------------- -export function ToolCallItem({ message }: { message: Message }) { +export const ToolCallItem = memo(function ToolCallItem({ message }: { message: Message }) { const [expanded, setExpanded] = useState(false) const { toolName = "", toolStatus = "running", toolArgs, content } = message const display = TOOL_DISPLAY[toolName] ?? { label: toolName, icon: CommandLineIcon } const isFinished = toolStatus !== "running" - const resultText = getResultText(content) + const resultText = getTextContent(content) const hasDetails = isFinished && !!resultText const subtitle = getSubtitle(toolName, toolArgs) const stats = getStats(toolName, toolStatus, resultText) @@ -219,10 +210,15 @@ export function ToolCallItem({ message }: { message: Message }) { {/* Expanded result */} {expanded && resultText && ( -
+
{resultText}
)}
) -} +}) diff --git a/packages/ui/src/lib/utils.ts b/packages/ui/src/lib/utils.ts index bd0c391d..68b3498d 100644 --- a/packages/ui/src/lib/utils.ts +++ b/packages/ui/src/lib/utils.ts @@ -1,6 +1,15 @@ import { clsx, type ClassValue } from "clsx" import { twMerge } from "tailwind-merge" +import type { ContentBlock } from "@multica/sdk" export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)) } + +/** Extract concatenated plain text from a ContentBlock array */ +export function getTextContent(blocks: ContentBlock[]): string { + return blocks + .filter((b): b is { type: "text"; text: string } => b.type === "text") + .map((b) => b.text) + .join("") +}