feat: group consecutive tool calls into collapsible summaries in MessageList

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
程序员阿江(Relakkes) 2026-04-06 21:36:39 +08:00
parent 458945da52
commit 001994d0bd

View File

@ -4,47 +4,98 @@ import { UserMessage } from './UserMessage'
import { AssistantMessage } from './AssistantMessage' import { AssistantMessage } from './AssistantMessage'
import { ThinkingBlock } from './ThinkingBlock' import { ThinkingBlock } from './ThinkingBlock'
import { ToolCallBlock } from './ToolCallBlock' import { ToolCallBlock } from './ToolCallBlock'
import { ToolCallGroup } from './ToolCallGroup'
import { ToolResultBlock } from './ToolResultBlock' import { ToolResultBlock } from './ToolResultBlock'
import { PermissionDialog } from './PermissionDialog' import { PermissionDialog } from './PermissionDialog'
import { AskUserQuestion } from './AskUserQuestion' import { AskUserQuestion } from './AskUserQuestion'
import { StreamingIndicator } from './StreamingIndicator' import { StreamingIndicator } from './StreamingIndicator'
import type { UIMessage } from '../../types/chat' import type { UIMessage } from '../../types/chat'
type ToolCall = Extract<UIMessage, { type: 'tool_use' }>
type ToolResult = Extract<UIMessage, { type: 'tool_result' }>
type RenderItem =
| { kind: 'tool_group'; toolCalls: ToolCall[]; id: string }
| { kind: 'message'; message: UIMessage }
function buildRenderItems(messages: UIMessage[], toolUseIds: Set<string>): RenderItem[] {
const items: RenderItem[] = []
let pendingToolCalls: ToolCall[] = []
const flushGroup = () => {
if (pendingToolCalls.length > 0) {
items.push({
kind: 'tool_group',
toolCalls: [...pendingToolCalls],
id: `group-${pendingToolCalls[0]!.id}`,
})
pendingToolCalls = []
}
}
for (const msg of messages) {
if (msg.type === 'tool_result' && toolUseIds.has(msg.toolUseId)) {
continue
}
if (msg.type === 'tool_use') {
if (msg.toolName === 'AskUserQuestion') {
flushGroup()
items.push({ kind: 'message', message: msg })
} else {
pendingToolCalls.push(msg)
}
} else {
flushGroup()
items.push({ kind: 'message', message: msg })
}
}
flushGroup()
return items
}
export function MessageList() { export function MessageList() {
const { messages, chatState, streamingText, activeThinkingId } = useChatStore() const { messages, chatState, streamingText, activeThinkingId } = useChatStore()
const bottomRef = useRef<HTMLDivElement>(null) const bottomRef = useRef<HTMLDivElement>(null)
// Auto-scroll to bottom on new messages
useEffect(() => { useEffect(() => {
bottomRef.current?.scrollIntoView?.({ behavior: 'smooth' }) bottomRef.current?.scrollIntoView?.({ behavior: 'smooth' })
}, [messages.length, streamingText]) }, [messages.length, streamingText])
// Build a map of toolUseId → tool_use message for linking results const toolUseIds = new Set<string>()
const toolUseMap = new Map<string, Extract<UIMessage, { type: 'tool_use' }>>() const toolResultMap = new Map<string, ToolResult>()
for (const msg of messages) { for (const msg of messages) {
if (msg.type === 'tool_use') { if (msg.type === 'tool_use') {
toolUseMap.set(msg.toolUseId, msg) toolUseIds.add(msg.toolUseId)
} }
}
// For each tool_use, find its matching tool_result
const toolResultMap = new Map<string, Extract<UIMessage, { type: 'tool_result' }>>()
for (const msg of messages) {
if (msg.type === 'tool_result' && msg.toolUseId) { if (msg.type === 'tool_result' && msg.toolUseId) {
toolResultMap.set(msg.toolUseId, msg) toolResultMap.set(msg.toolUseId, msg)
} }
} }
const renderItems = buildRenderItems(messages, toolUseIds)
return ( return (
<div className="flex-1 overflow-y-auto px-4 py-4"> <div className="flex-1 overflow-y-auto px-4 py-4">
<div className="mx-auto max-w-[860px]"> <div className="mx-auto max-w-[860px]">
{messages.map((msg) => { {renderItems.map((item) => {
// Skip tool_results that are rendered inline within their ToolCallBlock if (item.kind === 'tool_group') {
if (msg.type === 'tool_result' && toolUseMap.has(msg.toolUseId)) { return (
return null <ToolCallGroup
key={item.id}
toolCalls={item.toolCalls}
resultMap={toolResultMap}
isStreaming={
chatState === 'tool_executing' &&
item.toolCalls.some((tc) => !toolResultMap.has(tc.toolUseId))
}
/>
)
} }
const msg = item.message
return ( return (
<MessageBlock <MessageBlock
key={msg.id} key={msg.id}
@ -52,19 +103,20 @@ export function MessageList() {
activeThinkingId={activeThinkingId} activeThinkingId={activeThinkingId}
toolResult={ toolResult={
msg.type === 'tool_use' msg.type === 'tool_use'
? toolResultMap.get(msg.toolUseId) ?? null ? (() => {
const r = toolResultMap.get(msg.toolUseId)
return r ? { content: r.content, isError: r.isError } : null
})()
: null : null
} }
/> />
) )
})} })}
{/* Streaming text (not yet committed as a message) */}
{streamingText && chatState === 'streaming' && ( {streamingText && chatState === 'streaming' && (
<AssistantMessage content={streamingText} isStreaming /> <AssistantMessage content={streamingText} isStreaming />
)} )}
{/* Loading indicator */}
{chatState !== 'idle' && chatState !== 'streaming' && chatState !== 'permission_pending' && ( {chatState !== 'idle' && chatState !== 'streaming' && chatState !== 'permission_pending' && (
<StreamingIndicator /> <StreamingIndicator />
)} )}
@ -92,7 +144,6 @@ function MessageBlock({
case 'thinking': case 'thinking':
return <ThinkingBlock content={message.content} isActive={message.id === activeThinkingId} /> return <ThinkingBlock content={message.content} isActive={message.id === activeThinkingId} />
case 'tool_use': case 'tool_use':
// Special handling for AskUserQuestion tool calls
if (message.toolName === 'AskUserQuestion') { if (message.toolName === 'AskUserQuestion') {
return ( return (
<AskUserQuestion <AskUserQuestion
@ -109,7 +160,6 @@ function MessageBlock({
/> />
) )
case 'tool_result': case 'tool_result':
// Standalone tool_result (no matching tool_use found)
return ( return (
<ToolResultBlock <ToolResultBlock
content={message.content} content={message.content}
@ -128,13 +178,13 @@ function MessageBlock({
) )
case 'error': case 'error':
return ( return (
<div className="mb-4 px-4 py-3 rounded-[var(--radius-md)] bg-red-50 border border-red-200 text-sm text-[var(--color-error)]"> <div className="mb-3 px-4 py-2.5 rounded-lg bg-red-50 border border-red-200 text-sm text-[var(--color-error)]">
<strong>Error:</strong> {message.message} <strong>Error:</strong> {message.message}
</div> </div>
) )
case 'system': case 'system':
return ( return (
<div className="mb-4 text-center text-xs text-[var(--color-text-tertiary)]"> <div className="mb-3 text-center text-xs text-[var(--color-text-tertiary)]">
{message.content} {message.content}
</div> </div>
) )