mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-16 13:03:31 +08:00
The desktop transcript renderer was continuing to inline later nested tool calls under an older parent card even after a new user message had been submitted mid-session. That made the session timeline diverge from CLI behavior, where queued user input becomes the boundary for the next tool round. This change rebuilds the render model around visible message boundaries so nested tool calls only stay attached to a parent within the same contiguous segment. A regression test now covers the interleaved user-message case. Constraint: Preserve existing grouped tool rendering for contiguous parent-child tool activity Rejected: Rework the websocket/session pipeline to synthesize extra transcript events | the bug was in render ordering, not transport semantics Confidence: high Scope-risk: narrow Reversibility: clean Directive: If mid-turn follow-up prompts change again, validate both render ordering and nested child attachment rules together Tested: bun run test --run src/components/chat/MessageList.test.tsx; bun run lint Not-tested: Full desktop app manual session playback against a live running tool turn
274 lines
8.8 KiB
TypeScript
274 lines
8.8 KiB
TypeScript
import { useRef, useEffect, useMemo, memo } from 'react'
|
|
import { useChatStore } from '../../stores/chatStore'
|
|
import { useTabStore } from '../../stores/tabStore'
|
|
import { useTranslation } from '../../i18n'
|
|
import type { TranslationKey } from '../../i18n/locales/en'
|
|
import { UserMessage } from './UserMessage'
|
|
import { AssistantMessage } from './AssistantMessage'
|
|
import { ThinkingBlock } from './ThinkingBlock'
|
|
import { ToolCallBlock } from './ToolCallBlock'
|
|
import { ToolCallGroup } from './ToolCallGroup'
|
|
import { ToolResultBlock } from './ToolResultBlock'
|
|
import { PermissionDialog } from './PermissionDialog'
|
|
import { AskUserQuestion } from './AskUserQuestion'
|
|
import { StreamingIndicator } from './StreamingIndicator'
|
|
import { InlineTaskSummary } from './InlineTaskSummary'
|
|
import type { AgentTaskNotification, 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 }
|
|
|
|
type RenderModel = {
|
|
renderItems: RenderItem[]
|
|
toolResultMap: Map<string, ToolResult>
|
|
childToolCallsByParent: Map<string, ToolCall[]>
|
|
}
|
|
|
|
function appendChildToolCall(
|
|
childToolCallsByParent: Map<string, ToolCall[]>,
|
|
parentToolUseId: string,
|
|
toolCall: ToolCall,
|
|
) {
|
|
const siblings = childToolCallsByParent.get(parentToolUseId)
|
|
if (siblings) {
|
|
siblings.push(toolCall)
|
|
} else {
|
|
childToolCallsByParent.set(parentToolUseId, [toolCall])
|
|
}
|
|
}
|
|
|
|
export function buildRenderModel(messages: UIMessage[]): RenderModel {
|
|
const items: RenderItem[] = []
|
|
const toolResultMap = new Map<string, ToolResult>()
|
|
const childToolCallsByParent = new Map<string, ToolCall[]>()
|
|
const toolUseIds = new Set<string>()
|
|
let pendingToolCalls: ToolCall[] = []
|
|
const inlineParentToolUseIds = new Set<string>()
|
|
|
|
const flushGroup = (resetInlineParents = false) => {
|
|
if (pendingToolCalls.length > 0) {
|
|
items.push({
|
|
kind: 'tool_group',
|
|
toolCalls: [...pendingToolCalls],
|
|
id: `group-${pendingToolCalls[0]!.id}`,
|
|
})
|
|
for (const toolCall of pendingToolCalls) {
|
|
inlineParentToolUseIds.add(toolCall.toolUseId)
|
|
}
|
|
pendingToolCalls = []
|
|
}
|
|
|
|
if (resetInlineParents) {
|
|
inlineParentToolUseIds.clear()
|
|
}
|
|
}
|
|
|
|
for (const msg of messages) {
|
|
if (msg.type === 'tool_use') {
|
|
toolUseIds.add(msg.toolUseId)
|
|
}
|
|
if (msg.type === 'tool_result') {
|
|
toolResultMap.set(msg.toolUseId, msg)
|
|
}
|
|
}
|
|
|
|
for (const msg of messages) {
|
|
if (msg.type === 'tool_result' && toolUseIds.has(msg.toolUseId)) {
|
|
continue
|
|
}
|
|
|
|
if (msg.type === 'tool_use') {
|
|
const parentIsPending = msg.parentToolUseId
|
|
? pendingToolCalls.some((toolCall) => toolCall.toolUseId === msg.parentToolUseId)
|
|
: false
|
|
|
|
if (msg.parentToolUseId && (inlineParentToolUseIds.has(msg.parentToolUseId) || parentIsPending)) {
|
|
flushGroup()
|
|
appendChildToolCall(childToolCallsByParent, msg.parentToolUseId, msg)
|
|
inlineParentToolUseIds.add(msg.toolUseId)
|
|
continue
|
|
}
|
|
if (msg.toolName === 'AskUserQuestion') {
|
|
flushGroup(true)
|
|
items.push({ kind: 'message', message: msg })
|
|
} else {
|
|
pendingToolCalls.push(msg)
|
|
}
|
|
} else {
|
|
flushGroup(true)
|
|
items.push({ kind: 'message', message: msg })
|
|
}
|
|
}
|
|
|
|
flushGroup()
|
|
return { renderItems: items, toolResultMap, childToolCallsByParent }
|
|
}
|
|
|
|
export function MessageList() {
|
|
const activeTabId = useTabStore((s) => s.activeTabId)
|
|
const sessionState = useChatStore((s) => activeTabId ? s.sessions[activeTabId] : undefined)
|
|
const messages = sessionState?.messages ?? []
|
|
const chatState = sessionState?.chatState ?? 'idle'
|
|
const streamingText = sessionState?.streamingText ?? ''
|
|
const activeThinkingId = sessionState?.activeThinkingId ?? null
|
|
const agentTaskNotifications = sessionState?.agentTaskNotifications ?? {}
|
|
const bottomRef = useRef<HTMLDivElement>(null)
|
|
|
|
useEffect(() => {
|
|
bottomRef.current?.scrollIntoView?.({ behavior: 'smooth' })
|
|
}, [messages.length, streamingText])
|
|
|
|
const { toolResultMap, childToolCallsByParent, renderItems } = useMemo(
|
|
() => buildRenderModel(messages),
|
|
[messages],
|
|
)
|
|
|
|
return (
|
|
<div className="flex-1 overflow-y-auto px-4 py-4">
|
|
<div className="mx-auto max-w-[860px]">
|
|
{renderItems.map((item) => {
|
|
if (item.kind === 'tool_group') {
|
|
return (
|
|
<ToolCallGroup
|
|
key={item.id}
|
|
toolCalls={item.toolCalls}
|
|
resultMap={toolResultMap}
|
|
childToolCallsByParent={childToolCallsByParent}
|
|
agentTaskNotifications={agentTaskNotifications}
|
|
isStreaming={
|
|
chatState === 'tool_executing' &&
|
|
item.toolCalls.some((tc) => !toolResultMap.has(tc.toolUseId))
|
|
}
|
|
/>
|
|
)
|
|
}
|
|
|
|
const msg = item.message
|
|
return (
|
|
<MessageBlock
|
|
key={msg.id}
|
|
message={msg}
|
|
activeThinkingId={activeThinkingId}
|
|
agentTaskNotifications={agentTaskNotifications}
|
|
toolResult={
|
|
msg.type === 'tool_use'
|
|
? (() => {
|
|
const r = toolResultMap.get(msg.toolUseId)
|
|
return r ? { content: r.content, isError: r.isError } : null
|
|
})()
|
|
: null
|
|
}
|
|
/>
|
|
)
|
|
})}
|
|
|
|
{streamingText && (
|
|
<AssistantMessage content={streamingText} isStreaming={chatState === 'streaming'} />
|
|
)}
|
|
|
|
{/* Show StreamingIndicator when:
|
|
- tool_executing: tool is running
|
|
- thinking but no active ThinkingBlock yet: the gap between
|
|
sending a message and receiving the first thinking delta */}
|
|
{(chatState === 'tool_executing' || (chatState === 'thinking' && !activeThinkingId)) && (
|
|
<StreamingIndicator />
|
|
)}
|
|
|
|
<div ref={bottomRef} />
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
export const MessageBlock = memo(function MessageBlock({
|
|
message,
|
|
activeThinkingId,
|
|
agentTaskNotifications,
|
|
toolResult,
|
|
}: {
|
|
message: UIMessage
|
|
activeThinkingId: string | null
|
|
agentTaskNotifications: Record<string, AgentTaskNotification>
|
|
toolResult?: { content: unknown; isError: boolean } | null
|
|
}) {
|
|
const t = useTranslation()
|
|
|
|
switch (message.type) {
|
|
case 'user_text':
|
|
return <UserMessage content={message.content} attachments={message.attachments} />
|
|
case 'assistant_text':
|
|
return <AssistantMessage content={message.content} />
|
|
case 'thinking':
|
|
return <ThinkingBlock content={message.content} isActive={message.id === activeThinkingId} />
|
|
case 'tool_use':
|
|
if (message.toolName === 'AskUserQuestion') {
|
|
return (
|
|
<AskUserQuestion
|
|
toolUseId={message.toolUseId}
|
|
input={message.input}
|
|
/>
|
|
)
|
|
}
|
|
return (
|
|
<ToolCallBlock
|
|
toolName={message.toolName}
|
|
input={message.input}
|
|
result={toolResult}
|
|
agentTaskNotification={
|
|
message.toolName === 'Agent'
|
|
? agentTaskNotifications[message.toolUseId]
|
|
: undefined
|
|
}
|
|
/>
|
|
)
|
|
case 'tool_result':
|
|
return (
|
|
<ToolResultBlock
|
|
content={message.content}
|
|
isError={message.isError}
|
|
standalone
|
|
/>
|
|
)
|
|
case 'permission_request':
|
|
return (
|
|
<PermissionDialog
|
|
requestId={message.requestId}
|
|
toolName={message.toolName}
|
|
input={message.input}
|
|
description={message.description}
|
|
/>
|
|
)
|
|
case 'error': {
|
|
const errorKey = message.code ? `error.${message.code}` as TranslationKey : null
|
|
const errorText = errorKey ? t(errorKey) : null
|
|
const displayMessage = (errorText && errorText !== errorKey) ? errorText : message.message
|
|
const showRawDetail =
|
|
Boolean(message.message) &&
|
|
message.message.trim() !== '' &&
|
|
message.message !== displayMessage
|
|
return (
|
|
<div className="mb-3 px-4 py-2.5 rounded-lg border border-[var(--color-error)]/20 bg-[var(--color-error-container)]/28 text-sm text-[var(--color-error)]">
|
|
<strong>Error:</strong> {displayMessage}
|
|
{showRawDetail && (
|
|
<div className="mt-1 whitespace-pre-wrap text-xs text-[var(--color-on-error-container)]/85">
|
|
{message.message}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
case 'task_summary':
|
|
return <InlineTaskSummary tasks={message.tasks} />
|
|
case 'system':
|
|
return (
|
|
<div className="mb-3 text-center text-xs text-[var(--color-text-tertiary)]">
|
|
{message.content}
|
|
</div>
|
|
)
|
|
}
|
|
})
|