mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-16 13:03:31 +08:00
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:
parent
77552375e9
commit
a6d9adbc38
@ -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]
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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': '更新文件内容',
|
||||
|
||||
@ -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' }
|
||||
|
||||
@ -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)) {
|
||||
|
||||
@ -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 }
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user