fix: prevent long tool streams from looking stuck

Desktop already receives tool_use start and input deltas before the final tool_use_complete event, but the chat transcript only rendered a visible tool card after the complete event. This makes long Write payloads look frozen while the model is still generating the tool input. The store now upserts a pending tool_use message on stream start, updates a lightweight input preview from deltas, and resolves that same message when the complete event arrives.

Constraint: Long Write calls spend most perceived time streaming JSON tool input, not executing the filesystem write

Rejected: Show only the global active tool label | it does not anchor progress in the transcript where the user is looking

Confidence: high

Scope-risk: moderate

Directive: Keep pending tool_use messages keyed by toolUseId so the complete event updates in place instead of duplicating cards

Tested: bun run check:desktop

Not-tested: Live provider smoke with a real 10k-word Write stream
This commit is contained in:
程序员阿江(Relakkes) 2026-05-23 17:08:25 +08:00
parent 77552375e9
commit a6d9adbc38
9 changed files with 297 additions and 17 deletions

View File

@ -1371,7 +1371,7 @@ export const MessageBlock = memo(function MessageBlock({
case 'thinking':
return <ThinkingBlock content={message.content} isActive={message.id === activeThinkingId} />
case 'tool_use':
if (message.toolName === 'AskUserQuestion') {
if (message.toolName === 'AskUserQuestion' && !message.isPending) {
return (
<AskUserQuestion
sessionId={sessionId}
@ -1386,6 +1386,8 @@ export const MessageBlock = memo(function MessageBlock({
toolName={message.toolName}
input={message.input}
result={toolResult}
isPending={message.isPending}
partialInput={message.partialInput}
agentTaskNotification={
message.toolName === 'Agent'
? agentTaskNotifications[message.toolUseId]

View File

@ -1,4 +1,5 @@
import { useMemo, useState } from 'react'
import { LoaderCircle } from 'lucide-react'
import { CodeViewer } from './CodeViewer'
import { DiffViewer } from './DiffViewer'
import { TerminalChrome } from './TerminalChrome'
@ -14,6 +15,8 @@ type Props = {
result?: { content: unknown; isError: boolean } | null
agentTaskNotification?: AgentTaskNotification
compact?: boolean
isPending?: boolean
partialInput?: string
}
const TOOL_ICONS: Record<string, string> = {
@ -30,7 +33,7 @@ const TOOL_ICONS: Record<string, string> = {
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<string, unknown>) : {}
@ -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 (
<div className={`overflow-hidden rounded-lg border border-[var(--color-border)]/50 bg-[var(--color-surface-container-lowest)] ${
@ -77,7 +85,12 @@ export function ToolCallBlock({ toolName, input, result, compact = false }: Prop
) : (
<span className="flex-1" />
)}
{result && outputSummary && (
{pendingSummary ? (
<span className="inline-flex shrink-0 items-center gap-1 text-[10px] text-[var(--color-outline)]">
<LoaderCircle size={12} strokeWidth={2.4} className="animate-spin" aria-hidden="true" />
{pendingSummary}
</span>
) : result && outputSummary ? (
<span
className={`shrink-0 text-[10px] ${
result.isError
@ -87,7 +100,7 @@ export function ToolCallBlock({ toolName, input, result, compact = false }: Prop
>
{outputSummary}
</span>
)}
) : null}
{result?.isError && (
<span className="material-symbols-outlined shrink-0 text-[14px] text-[var(--color-error)]">error</span>
)}
@ -166,7 +179,16 @@ function renderPreview(
return null
}
function renderDetails(toolName: string, obj: Record<string, unknown>, t?: (key: TranslationKey, params?: Record<string, string | number>) => string) {
function renderDetails(
toolName: string,
obj: Record<string, unknown>,
t?: (key: TranslationKey, params?: Record<string, string | number>) => 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<string, unknown>, t?: (key:
)
}
function renderPartialInput(
partialInput: string,
t?: (key: TranslationKey, params?: Record<string, string | number>) => string,
) {
return (
<div className="overflow-hidden rounded-lg border border-[var(--color-border)] bg-[var(--color-surface)]">
<div className="border-b border-[var(--color-border)] px-3 py-2 text-[10px] uppercase tracking-[0.18em] text-[var(--color-outline)]">
{t?.('tool.partialInput') ?? 'Partial input'}
</div>
<CodeViewer code={partialInput} language="json" maxLines={8} />
</div>
)
}
function getPendingSummary(
toolName: string,
t?: (key: TranslationKey, params?: Record<string, string | number>) => 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,

View File

@ -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 && (
<div className={compact ? 'ml-4 border-l border-[var(--color-border)]/60 pl-3' : 'mb-2 ml-16 border-l border-[var(--color-border)]/60 pl-3'}>
@ -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)

View File

@ -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(
<ToolCallBlock
toolName="Write"
input={{ file_path: '/private/tmp/ai-code-novel.md' }}
isPending
partialInput={'{"file_path":"/private/tmp/ai-code-novel.md","content":"第一章'}
/>,
)
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(
<ToolCallBlock

View File

@ -1233,6 +1233,10 @@ export const en = {
'tool.errorOutput': 'Error Output',
'tool.toolOutput': 'Tool Output',
'tool.toolInput': 'Tool Input',
'tool.partialInput': 'Partial input',
'tool.generatingContent': 'Generating content',
'tool.preparingEdit': 'Preparing edit',
'tool.preparingTool': 'Preparing tool',
'tool.readFileContents': 'Read file contents',
'tool.createFile': 'Create file',
'tool.updateFileContents': 'Update file contents',

View File

@ -1235,6 +1235,10 @@ export const zh: Record<TranslationKey, string> = {
'tool.errorOutput': '错误输出',
'tool.toolOutput': '工具输出',
'tool.toolInput': '工具输入',
'tool.partialInput': '部分输入',
'tool.generatingContent': '正在生成内容',
'tool.preparingEdit': '正在准备编辑',
'tool.preparingTool': '正在准备工具',
'tool.readFileContents': '读取文件内容',
'tool.createFile': '创建文件',
'tool.updateFileContents': '更新文件内容',

View File

@ -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' }

View File

@ -209,6 +209,81 @@ const COMPACT_SUMMARY_CUTOFFS = [
let msgCounter = 0
const nextId = () => `msg-${++msgCounter}-${Date.now()}`
function isRecord(value: unknown): value is Record<string, unknown> {
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<string, unknown> {
const previous = isRecord(previousInput) ? previousInput : {}
const preview: Record<string, unknown> = { ...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<string, string>()
@ -1106,9 +1181,26 @@ export const useChatStore = create<ChatStore>((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<ChatStore>((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<ChatStore>((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)) {

View File

@ -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 }