) => 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 }