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:
parent
cfd46ee602
commit
1f7951df1b
3 changed files with 85 additions and 54 deletions
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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("")
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue