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 <noreply@anthropic.com>
This commit is contained in:
Naiyuan Qing 2026-02-04 18:12:34 +08:00
parent cfd46ee602
commit 1f7951df1b
3 changed files with 85 additions and 54 deletions

View file

@ -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<ContentBlock, { type: "toolCall" }> => 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<string>
}
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<string>()
for (const msg of messages) {
if (msg.role === "toolResult" && msg.toolCallId) {
ids.add(msg.toolCallId)
}
}
return ids
}, [messages])
return (
<div className="relative px-4 py-6 max-w-4xl mx-auto">
{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 (
<div
key={msg.id}
className={cn(
"flex",
msg.role === "user" ? "justify-end" : "justify-start"
<div key={msg.id}>
{/* Render text content (if any) */}
{(text || isStreaming) && (
<div
className={cn(
"flex",
msg.role === "user" ? "justify-end" : "justify-start"
)}
>
<div
className={cn(
msg.role === "user" ? "bg-muted rounded-md max-w-[60%] py-1 px-2.5 my-2" : "w-full py-1 px-2.5 my-1"
)}
>
{isStreaming ? (
<StreamingMarkdown content={text} isStreaming={true} mode="minimal" />
) : (
<MemoizedMarkdown mode="minimal" id={msg.id}>
{text}
</MemoizedMarkdown>
)}
</div>
</div>
)}
>
<div
className={cn(
msg.role === "user" ? "bg-muted rounded-md max-w-[60%] py-1 px-2.5 my-2" : "w-full py-1 px-2.5 my-1"
)}
>
{isStreaming ? (
<StreamingMarkdown content={text} isStreaming={true} mode="minimal" />
) : (
<MemoizedMarkdown mode="minimal" id={msg.id}>
{text}
</MemoizedMarkdown>
)}
</div>
{/* Render unresolved toolCall blocks as "running" tool items */}
{unresolvedToolCalls.map((tc) => (
<ToolCallItem key={tc.id} message={toRunningMessage(tc, msg.agentId)} />
))}
</div>
)
})}
</div>
)
}
})

View file

@ -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<string, { label: string; icon: typeof File01Icon }> =
// 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 && (
<div className="mt-1 ml-7 text-xs bg-muted rounded p-2 max-h-48 overflow-y-auto whitespace-pre-wrap break-all">
<div
role="region"
aria-label={`${display.label} result`}
tabIndex={0}
className="mt-1 ml-7 text-xs bg-muted rounded p-2 max-h-48 overflow-y-auto whitespace-pre-wrap break-all"
>
{resultText}
</div>
)}
</div>
)
}
})

View file

@ -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("")
}