diff --git a/desktop/src/components/chat/MessageList.tsx b/desktop/src/components/chat/MessageList.tsx index 8d16578a..a47b2474 100644 --- a/desktop/src/components/chat/MessageList.tsx +++ b/desktop/src/components/chat/MessageList.tsx @@ -1371,7 +1371,7 @@ export const MessageBlock = memo(function MessageBlock({ case 'thinking': return case 'tool_use': - if (message.toolName === 'AskUserQuestion') { + if (message.toolName === 'AskUserQuestion' && !message.isPending) { return ( = { @@ -30,7 +33,7 @@ const TOOL_ICONS: Record = { Skill: 'auto_awesome', } -export function ToolCallBlock({ toolName, input, result, compact = false }: Props) { +export function ToolCallBlock({ toolName, input, result, compact = false, isPending = false, partialInput }: Props) { const [expanded, setExpanded] = useState(false) const t = useTranslation() const obj = input && typeof input === 'object' ? (input as Record) : {} @@ -43,11 +46,16 @@ export function ToolCallBlock({ toolName, input, result, compact = false }: Prop result?.isError ?? false, t, ) + const pendingSummary = isPending && !result + ? getPendingSummary(toolName, t) + : '' const preview = useMemo(() => renderPreview(toolName, obj, result, t), [obj, result, toolName, t]) - const details = useMemo(() => renderDetails(toolName, obj, t), [obj, toolName, t]) + const details = useMemo(() => renderDetails(toolName, obj, t, isPending ? partialInput : undefined), [isPending, obj, partialInput, toolName, t]) const hasResultDetails = Boolean(result && extractTextContent(result.content)) - const expandable = toolName === 'Edit' || toolName === 'Write' || hasResultDetails + const hasEditPreview = toolName === 'Edit' && typeof obj.old_string === 'string' && typeof obj.new_string === 'string' + const hasWritePreview = toolName === 'Write' && typeof obj.content === 'string' + const expandable = hasEditPreview || hasWritePreview || hasResultDetails || Boolean(isPending && partialInput) return (
)} - {result && outputSummary && ( + {pendingSummary ? ( + + + ) : result && outputSummary ? ( {outputSummary} - )} + ) : null} {result?.isError && ( error )} @@ -166,7 +179,16 @@ function renderPreview( return null } -function renderDetails(toolName: string, obj: Record, t?: (key: TranslationKey, params?: Record) => string) { +function renderDetails( + toolName: string, + obj: Record, + t?: (key: TranslationKey, params?: Record) => string, + partialInput?: string, +) { + if (partialInput) { + return renderPartialInput(partialInput, t) + } + if (toolName === 'Edit' || toolName === 'Write') { return null } @@ -186,6 +208,29 @@ function renderDetails(toolName: string, obj: Record, t?: (key: ) } +function renderPartialInput( + partialInput: string, + t?: (key: TranslationKey, params?: Record) => string, +) { + return ( +
+
+ {t?.('tool.partialInput') ?? 'Partial input'} +
+ +
+ ) +} + +function getPendingSummary( + toolName: string, + t?: (key: TranslationKey, params?: Record) => string, +): string { + if (toolName === 'Write') return t?.('tool.generatingContent') ?? 'Generating content' + if (toolName === 'Edit' || toolName === 'MultiEdit') return t?.('tool.preparingEdit') ?? 'Preparing edit' + return t?.('tool.preparingTool') ?? 'Preparing tool' +} + function getToolResultSummary( toolName: string, content: unknown, diff --git a/desktop/src/components/chat/ToolCallGroup.tsx b/desktop/src/components/chat/ToolCallGroup.tsx index ecdd7d56..97a9938b 100644 --- a/desktop/src/components/chat/ToolCallGroup.tsx +++ b/desktop/src/components/chat/ToolCallGroup.tsx @@ -651,6 +651,8 @@ function ToolCallTree({ input={toolCall.input} result={result ? { content: result.content, isError: result.isError } : null} compact={compact} + isPending={toolCall.isPending} + partialInput={toolCall.partialInput} /> {childToolCalls.length > 0 && (
@@ -686,6 +688,7 @@ function getMemoryToolActivity( let sawSave = false for (const toolCall of toolCalls) { + if (toolCall.isPending) continue const path = getToolFilePath(toolCall.input) if (!path || !isMemoryMarkdownPath(path)) continue @@ -714,6 +717,7 @@ function getMemoryToolActivity( } function isMemoryToolCall(toolCall: ToolCall): boolean { + if (toolCall.isPending) return false const path = getToolFilePath(toolCall.input) if (!path || !isMemoryMarkdownPath(path)) return false return toolCall.toolName === 'Read' || isMemoryWriteTool(toolCall.toolName) diff --git a/desktop/src/components/chat/chatBlocks.test.tsx b/desktop/src/components/chat/chatBlocks.test.tsx index 8daead2d..57800df0 100644 --- a/desktop/src/components/chat/chatBlocks.test.tsx +++ b/desktop/src/components/chat/chatBlocks.test.tsx @@ -64,6 +64,21 @@ describe('chat blocks', () => { expect(container.textContent).not.toContain('file-a') }) + it('shows pending Write tool calls while input is still streaming', () => { + const { container } = render( + , + ) + + expect(container.textContent).toContain('Write') + expect(container.textContent).toContain('ai-code-novel.md') + expect(container.textContent).toContain('Generating content') + }) + it('shows a collapsed error summary for failed bash commands', () => { const { container } = render( = { 'tool.errorOutput': '错误输出', 'tool.toolOutput': '工具输出', 'tool.toolInput': '工具输入', + 'tool.partialInput': '部分输入', + 'tool.generatingContent': '正在生成内容', + 'tool.preparingEdit': '正在准备编辑', + 'tool.preparingTool': '正在准备工具', 'tool.readFileContents': '读取文件内容', 'tool.createFile': '创建文件', 'tool.updateFileContents': '更新文件内容', diff --git a/desktop/src/stores/chatStore.test.ts b/desktop/src/stores/chatStore.test.ts index 129452d2..08dae6d5 100644 --- a/desktop/src/stores/chatStore.test.ts +++ b/desktop/src/stores/chatStore.test.ts @@ -1331,6 +1331,72 @@ describe('chatStore history mapping', () => { ]) }) + it('renders a pending tool call as soon as the tool stream starts', () => { + useChatStore.setState({ + sessions: { + [TEST_SESSION_ID]: makeSession(), + }, + }) + + useChatStore.getState().handleServerMessage(TEST_SESSION_ID, { + type: 'content_start', + blockType: 'tool_use', + toolName: 'Write', + toolUseId: 'write-1', + }) + + expect(useChatStore.getState().sessions[TEST_SESSION_ID]?.messages).toMatchObject([ + { + type: 'tool_use', + toolName: 'Write', + toolUseId: 'write-1', + input: {}, + isPending: true, + }, + ]) + + useChatStore.getState().handleServerMessage(TEST_SESSION_ID, { + type: 'content_delta', + toolInput: '{"file_path":"/private/tmp/ai-code-novel.md","content":"第一章', + }) + + expect(useChatStore.getState().sessions[TEST_SESSION_ID]?.messages).toMatchObject([ + { + type: 'tool_use', + toolName: 'Write', + toolUseId: 'write-1', + input: { file_path: '/private/tmp/ai-code-novel.md' }, + isPending: true, + partialInput: '{"file_path":"/private/tmp/ai-code-novel.md","content":"第一章', + }, + ]) + + useChatStore.getState().handleServerMessage(TEST_SESSION_ID, { + type: 'tool_use_complete', + toolName: 'Write', + toolUseId: 'write-1', + input: { + file_path: '/private/tmp/ai-code-novel.md', + content: '第一章\n正文', + }, + }) + + const messages = useChatStore.getState().sessions[TEST_SESSION_ID]?.messages ?? [] + const toolMessages = messages.filter((message) => message.type === 'tool_use') + expect(toolMessages).toHaveLength(1) + expect(toolMessages[0]).toMatchObject({ + type: 'tool_use', + toolName: 'Write', + toolUseId: 'write-1', + input: { + file_path: '/private/tmp/ai-code-novel.md', + content: '第一章\n正文', + }, + isPending: false, + }) + expect(toolMessages[0]).not.toHaveProperty('partialInput') + }) + it('refreshes merged slash commands when a live CLI update omits project commands', async () => { const cliCommand = { name: 'builtin-help', description: 'Built-in command' } const projectCommand = { name: 'project-probe', description: 'Project custom command' } diff --git a/desktop/src/stores/chatStore.ts b/desktop/src/stores/chatStore.ts index 69d6d9db..3088143c 100644 --- a/desktop/src/stores/chatStore.ts +++ b/desktop/src/stores/chatStore.ts @@ -209,6 +209,81 @@ const COMPACT_SUMMARY_CUTOFFS = [ let msgCounter = 0 const nextId = () => `msg-${++msgCounter}-${Date.now()}` +function isRecord(value: unknown): value is Record { + return Boolean(value) && typeof value === 'object' && !Array.isArray(value) +} + +function readJsonStringLiteral(source: string, quoteIndex: number): string | undefined { + if (source[quoteIndex] !== '"') return undefined + let value = '' + for (let index = quoteIndex + 1; index < source.length; index += 1) { + const char = source[index] + if (char === '\\') { + const escaped = source[index + 1] + if (escaped === undefined) return undefined + value += char + escaped + index += 1 + continue + } + if (char === '"') { + try { + return JSON.parse(`"${value}"`) as string + } catch { + return value + } + } + value += char + } + return undefined +} + +function extractPartialJsonStringField(source: string, field: string): string | undefined { + const key = `"${field}"` + const keyIndex = source.indexOf(key) + if (keyIndex < 0) return undefined + const colonIndex = source.indexOf(':', keyIndex + key.length) + if (colonIndex < 0) return undefined + + let valueIndex = colonIndex + 1 + while (valueIndex < source.length && /\s/.test(source[valueIndex] ?? '')) { + valueIndex += 1 + } + return readJsonStringLiteral(source, valueIndex) +} + +function buildPartialToolInputPreview( + partialInput: string, + previousInput: unknown, +): Record { + const previous = isRecord(previousInput) ? previousInput : {} + const preview: Record = { ...previous } + for (const field of ['file_path', 'filePath', 'path', 'command', 'pattern', 'url', 'query', 'description']) { + const value = extractPartialJsonStringField(partialInput, field) + if (value !== undefined) { + preview[field] = value + } + } + return preview +} + +function upsertToolUseMessage( + messages: UIMessage[], + toolUseId: string, + build: (existing?: ToolCall) => ToolCall, +): UIMessage[] { + const existingIndex = messages.findIndex( + (message): message is ToolCall => + message.type === 'tool_use' && message.toolUseId === toolUseId, + ) + if (existingIndex < 0) { + return [...messages, build()] + } + + const next = [...messages] + next[existingIndex] = build(messages[existingIndex] as ToolCall) + return next +} + // Streaming throttle for content_delta. Buffers must be per-session because // multiple desktop tabs can stream at the same time. const pendingDeltaBySession = new Map() @@ -1106,9 +1181,26 @@ export const useChatStore = create((set, get) => ({ })) } else if (msg.blockType === 'tool_use') { rememberPendingToolParentUseId(sessionId, msg.toolUseId, msg.parentToolUseId) - update(() => ({ - activeToolUseId: msg.toolUseId ?? null, - activeToolName: msg.toolName ?? null, + const toolUseId = msg.toolUseId ?? null + const toolName = msg.toolName ?? 'unknown' + update((s) => ({ + ...(toolUseId + ? { + messages: upsertToolUseMessage(s.messages, toolUseId, (existing) => ({ + id: existing?.id ?? nextId(), + type: 'tool_use', + toolName, + toolUseId, + input: existing?.input ?? {}, + timestamp: existing?.timestamp ?? Date.now(), + parentToolUseId: msg.parentToolUseId ?? existing?.parentToolUseId, + isPending: true, + partialInput: existing?.partialInput ?? '', + })), + } + : {}), + activeToolUseId: toolUseId, + activeToolName: toolName, streamingToolInput: '', chatState: 'tool_executing', activeThinkingId: null, @@ -1154,7 +1246,33 @@ export const useChatStore = create((set, get) => ({ flushTimerBySession.set(sessionId, timer) } } - if (msg.toolInput !== undefined) update((s) => ({ streamingToolInput: s.streamingToolInput + msg.toolInput })) + if (msg.toolInput !== undefined) { + update((s) => { + const partialInput = s.streamingToolInput + msg.toolInput + const activeToolUseId = s.activeToolUseId + return { + streamingToolInput: partialInput, + ...(activeToolUseId + ? { + messages: upsertToolUseMessage(s.messages, activeToolUseId, (existing) => { + const toolName = existing?.toolName ?? s.activeToolName ?? 'unknown' + return { + id: existing?.id ?? nextId(), + type: 'tool_use', + toolName, + toolUseId: activeToolUseId, + input: buildPartialToolInputPreview(partialInput, existing?.input), + timestamp: existing?.timestamp ?? Date.now(), + parentToolUseId: existing?.parentToolUseId ?? getPendingToolParentUseId(sessionId, activeToolUseId), + isPending: true, + partialInput, + } + }), + } + : {}), + } + }) + } break case 'thinking': @@ -1186,11 +1304,23 @@ export const useChatStore = create((set, get) => ({ const parentToolUseId = msg.parentToolUseId ?? getPendingToolParentUseId(sessionId, toolUseId) rememberPendingToolParentUseId(sessionId, toolUseId, parentToolUseId) update((s) => ({ - messages: [...s.messages, { - id: nextId(), type: 'tool_use', toolName, - toolUseId, - input: msg.input, timestamp: Date.now(), parentToolUseId, - }], + messages: toolUseId + ? upsertToolUseMessage(s.messages, toolUseId, (existing) => ({ + id: existing?.id ?? nextId(), + type: 'tool_use', + toolName, + toolUseId, + input: msg.input, + timestamp: existing?.timestamp ?? Date.now(), + parentToolUseId, + isPending: false, + })) + : [...s.messages, { + id: nextId(), type: 'tool_use', toolName, + toolUseId, + input: msg.input, timestamp: Date.now(), parentToolUseId, + isPending: false, + }], activeToolUseId: null, activeToolName: null, activeThinkingId: null, streamingToolInput: '', })) if (toolName === 'TodoWrite' && Array.isArray((msg.input as any)?.todos)) { diff --git a/desktop/src/types/chat.ts b/desktop/src/types/chat.ts index aa1316f4..4819499d 100644 --- a/desktop/src/types/chat.ts +++ b/desktop/src/types/chat.ts @@ -234,7 +234,17 @@ export type UIMessage = | { id: string; type: 'user_text'; content: string; modelContent?: string; transcriptMessageId?: string; timestamp: number; attachments?: UIAttachment[]; pending?: boolean } | { id: string; type: 'assistant_text'; content: string; transcriptMessageId?: string; timestamp: number; model?: string } | { id: string; type: 'thinking'; content: string; timestamp: number } - | { id: string; type: 'tool_use'; toolName: string; toolUseId: string; input: unknown; timestamp: number; parentToolUseId?: string } + | { + id: string + type: 'tool_use' + toolName: string + toolUseId: string + input: unknown + timestamp: number + parentToolUseId?: string + isPending?: boolean + partialInput?: string + } | { id: string; type: 'tool_result'; toolUseId: string; content: unknown; isError: boolean; timestamp: number; parentToolUseId?: string } | { id: string; type: 'background_task'; task: BackgroundAgentTask; timestamp: number } | { id: string; type: 'system'; content: string; timestamp: number }