fix: stream pending Write content as Writer previews

Long Write tool inputs can spend minutes streaming the file body before the tool executes, so the desktop transcript now decodes the pending content field into a lightweight Writer preview. The preview intentionally uses a bounded plain-text window while the tool is pending, then keeps the existing full diff rendering once the Write call completes.

Constraint: Write.content arrives as streaming tool input before the filesystem write executes.
Rejected: Render a live DiffViewer for every input delta | repeated diff and syntax work would reintroduce long-session jank.
Confidence: high
Scope-risk: moderate
Directive: Keep pending Writer rendering lightweight and bounded; reserve full diff rendering for completed Write calls.
Tested: cd desktop && bun run test src/components/chat/chatBlocks.test.tsx -- --runInBand
Tested: cd desktop && bun run test src/stores/chatStore.test.ts -- --runInBand
Tested: bun run check:desktop
Not-tested: Live provider long Write smoke after the UI patch.
This commit is contained in:
程序员阿江(Relakkes) 2026-05-23 19:14:22 +08:00
parent 385435a04d
commit 6b1561cd1c
6 changed files with 271 additions and 24 deletions

View File

@ -33,6 +33,9 @@ const TOOL_ICONS: Record<string, string> = {
Skill: 'auto_awesome',
}
const WRITER_PREVIEW_MAX_LINES = 120
const WRITER_PREVIEW_MAX_CHARS = 30000
export function ToolCallBlock({ toolName, input, result, compact = false, isPending = false, partialInput }: Props) {
const [expanded, setExpanded] = useState(false)
const t = useTranslation()
@ -186,6 +189,12 @@ function renderDetails(
partialInput?: string,
) {
if (partialInput) {
if (toolName === 'Write') {
const writerContent = extractPartialJsonStringField(partialInput, 'content')
if (writerContent) {
return renderWriterPreview(writerContent, t)
}
}
return renderPartialInput(partialInput, t)
}
@ -208,6 +217,110 @@ function renderDetails(
)
}
function extractPartialJsonStringField(source: string, field: string): string | null {
const key = `"${field}"`
const keyIndex = source.indexOf(key)
if (keyIndex < 0) return null
const colonIndex = source.indexOf(':', keyIndex + key.length)
if (colonIndex < 0) return null
let index = colonIndex + 1
while (index < source.length && /\s/.test(source[index] ?? '')) index += 1
if (source[index] !== '"') return null
index += 1
let value = ''
while (index < source.length) {
const char = source[index]
if (char === '"') return value
if (char !== '\\') {
value += char
index += 1
continue
}
const escaped = source[index + 1]
if (escaped === undefined) break
switch (escaped) {
case 'n':
value += '\n'
index += 2
break
case 'r':
value += '\r'
index += 2
break
case 't':
value += '\t'
index += 2
break
case 'b':
value += '\b'
index += 2
break
case 'f':
value += '\f'
index += 2
break
case '"':
case '\\':
case '/':
value += escaped
index += 2
break
case 'u': {
const hex = source.slice(index + 2, index + 6)
if (/^[0-9a-fA-F]{4}$/.test(hex)) {
value += String.fromCharCode(Number.parseInt(hex, 16))
index += 6
} else {
index = source.length
}
break
}
default:
value += escaped
index += 2
break
}
}
return value
}
function renderWriterPreview(
content: string,
t?: (key: TranslationKey, params?: Record<string, string | number>) => string,
) {
const lines = content.split('\n')
const totalLines = lines.length
const visibleLines = lines.length > WRITER_PREVIEW_MAX_LINES
? lines.slice(-WRITER_PREVIEW_MAX_LINES)
: lines
let visibleContent = visibleLines.join('\n')
const charTruncated = visibleContent.length > WRITER_PREVIEW_MAX_CHARS
if (charTruncated) {
visibleContent = visibleContent.slice(-WRITER_PREVIEW_MAX_CHARS)
}
const lineWindowed = totalLines > visibleLines.length
const isWindowed = lineWindowed || charTruncated
return (
<div className="overflow-hidden rounded-lg border border-[var(--color-border)] bg-[var(--color-surface)]">
<div className="flex items-center justify-between border-b border-[var(--color-border)] px-3 py-2 text-[10px] uppercase tracking-[0.18em] text-[var(--color-outline)]">
<span>{t?.('tool.writerPreview') ?? 'Writer'}</span>
{isWindowed ? (
<span className="normal-case tracking-normal">
{t?.('tool.writerPreviewLatest', { visible: visibleLines.length, total: totalLines }) ?? `Showing latest ${visibleLines.length} of ${totalLines} lines`}
</span>
) : null}
</div>
<pre className="max-h-[420px] overflow-auto whitespace-pre-wrap break-words bg-[var(--color-code-bg)] px-3 py-2 font-[var(--font-mono)] text-[12px] leading-[1.45] text-[var(--color-code-fg)]">
{visibleContent}
</pre>
</div>
)
}
function renderPartialInput(
partialInput: string,
t?: (key: TranslationKey, params?: Record<string, string | number>) => string,

View File

@ -79,6 +79,43 @@ describe('chat blocks', () => {
expect(container.textContent).toContain('Generating content')
})
it('expands pending Write tool calls into a live writer preview instead of raw JSON', () => {
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":"# 第一章\\n\\n正文正在生成'}
/>,
)
fireEvent.click(screen.getByRole('button'))
expect(container.textContent).toContain('Writer')
expect(container.textContent).toContain('# 第一章')
expect(container.textContent).toContain('正文正在生成')
expect(container.textContent).not.toContain('"content"')
})
it('windows long pending Write previews to the latest content', () => {
const lines = Array.from({ length: 180 }, (_, index) => `line-${index + 1}`)
const escapedContent = lines.join('\\n')
const { container } = render(
<ToolCallBlock
toolName="Write"
input={{ file_path: '/private/tmp/generated.ts' }}
isPending
partialInput={`{"file_path":"/private/tmp/generated.ts","content":"${escapedContent}`}
/>,
)
fireEvent.click(screen.getByRole('button'))
expect(container.textContent).toContain('latest')
expect(container.textContent).toContain('line-180')
expect(container.textContent).not.toContain('line-30')
})
it('shows a collapsed error summary for failed bash commands', () => {
const { container } = render(
<ToolCallBlock

View File

@ -1234,6 +1234,8 @@ export const en = {
'tool.toolOutput': 'Tool Output',
'tool.toolInput': 'Tool Input',
'tool.partialInput': 'Partial input',
'tool.writerPreview': 'Writer',
'tool.writerPreviewLatest': 'Showing latest {visible} of {total} lines',
'tool.generatingContent': 'Generating content',
'tool.preparingEdit': 'Preparing edit',
'tool.preparingTool': 'Preparing tool',

View File

@ -1236,6 +1236,8 @@ export const zh: Record<TranslationKey, string> = {
'tool.toolOutput': '工具输出',
'tool.toolInput': '工具输入',
'tool.partialInput': '部分输入',
'tool.writerPreview': 'Writer',
'tool.writerPreviewLatest': '正在显示最新 {visible} / {total} 行',
'tool.generatingContent': '正在生成内容',
'tool.preparingEdit': '正在准备编辑',
'tool.preparingTool': '正在准备工具',

View File

@ -1332,6 +1332,8 @@ describe('chatStore history mapping', () => {
})
it('renders a pending tool call as soon as the tool stream starts', () => {
vi.useFakeTimers()
useChatStore.setState({
sessions: {
[TEST_SESSION_ID]: makeSession(),
@ -1359,6 +1361,7 @@ describe('chatStore history mapping', () => {
type: 'content_delta',
toolInput: '{"file_path":"/private/tmp/ai-code-novel.md","content":"第一章',
})
vi.advanceTimersByTime(60)
expect(useChatStore.getState().sessions[TEST_SESSION_ID]?.messages).toMatchObject([
{
@ -1395,6 +1398,54 @@ describe('chatStore history mapping', () => {
isPending: false,
})
expect(toolMessages[0]).not.toHaveProperty('partialInput')
vi.runOnlyPendingTimers()
vi.useRealTimers()
})
it('batches streaming tool input deltas before updating the pending card', () => {
vi.useFakeTimers()
useChatStore.setState({
sessions: {
[TEST_SESSION_ID]: makeSession(),
},
})
useChatStore.getState().handleServerMessage(TEST_SESSION_ID, {
type: 'content_start',
blockType: 'tool_use',
toolName: 'Write',
toolUseId: 'write-1',
})
useChatStore.getState().handleServerMessage(TEST_SESSION_ID, {
type: 'content_delta',
toolInput: '{"file_path":"/private/tmp/story.md","content":"第一',
})
useChatStore.getState().handleServerMessage(TEST_SESSION_ID, {
type: 'content_delta',
toolInput: '章\\n第二段',
})
const beforeFlush = useChatStore.getState().sessions[TEST_SESSION_ID]?.messages[0]
expect(beforeFlush).toMatchObject({
type: 'tool_use',
isPending: true,
input: {},
partialInput: '',
})
vi.advanceTimersByTime(60)
expect(useChatStore.getState().sessions[TEST_SESSION_ID]?.messages[0]).toMatchObject({
type: 'tool_use',
input: { file_path: '/private/tmp/story.md' },
partialInput: '{"file_path":"/private/tmp/story.md","content":"第一章\\n第二段',
})
vi.runOnlyPendingTimers()
vi.useRealTimers()
})
it('refreshes merged slash commands when a live CLI update omits project commands', async () => {

View File

@ -288,6 +288,8 @@ function upsertToolUseMessage(
// multiple desktop tabs can stream at the same time.
const pendingDeltaBySession = new Map<string, string>()
const flushTimerBySession = new Map<string, ReturnType<typeof setTimeout>>()
const pendingToolInputDeltaBySession = new Map<string, string>()
const toolInputFlushTimerBySession = new Map<string, ReturnType<typeof setTimeout>>()
function consumePendingDelta(sessionId: string): string {
const flushTimer = flushTimerBySession.get(sessionId)
@ -316,6 +318,33 @@ function clearPendingDelta(sessionId: string): void {
pendingDeltaBySession.delete(sessionId)
}
function consumePendingToolInputDelta(sessionId: string): string {
const flushTimer = toolInputFlushTimerBySession.get(sessionId)
if (flushTimer) {
clearTimeout(flushTimer)
toolInputFlushTimerBySession.delete(sessionId)
}
const text = pendingToolInputDeltaBySession.get(sessionId) ?? ''
pendingToolInputDeltaBySession.delete(sessionId)
return text
}
function appendPendingToolInputDelta(sessionId: string, text: string): void {
pendingToolInputDeltaBySession.set(
sessionId,
`${pendingToolInputDeltaBySession.get(sessionId) ?? ''}${text}`,
)
}
function clearPendingToolInputDelta(sessionId: string): void {
const flushTimer = toolInputFlushTimerBySession.get(sessionId)
if (flushTimer) {
clearTimeout(flushTimer)
toolInputFlushTimerBySession.delete(sessionId)
}
pendingToolInputDeltaBySession.delete(sessionId)
}
function appendAssistantTextMessage(
messages: UIMessage[],
content: string,
@ -767,6 +796,7 @@ export const useChatStore = create<ChatStore>((set, get) => ({
const text = consumePendingDelta(sessionId)
set((s) => ({ sessions: updateSessionIn(s.sessions, sessionId, (sess) => ({ streamingText: sess.streamingText + text })) }))
}
clearPendingToolInputDelta(sessionId)
clearPendingTaskToolUseIds(sessionId)
clearPendingToolParentUseIds(sessionId)
wsManager.disconnect(sessionId)
@ -932,6 +962,7 @@ export const useChatStore = create<ChatStore>((set, get) => ({
const text = consumePendingDelta(sessionId)
set((s) => ({ sessions: updateSessionIn(s.sessions, sessionId, (sess) => ({ streamingText: sess.streamingText + text })) }))
}
clearPendingToolInputDelta(sessionId)
set((s) => {
const session = s.sessions[sessionId]
if (!session) return s
@ -1090,6 +1121,7 @@ export const useChatStore = create<ChatStore>((set, get) => ({
clearMessages: (sessionId) => {
clearPendingTaskToolUseIds(sessionId)
clearPendingToolParentUseIds(sessionId)
clearPendingToolInputDelta(sessionId)
set((s) => ({ sessions: updateSessionIn(s.sessions, sessionId, () => ({
messages: [],
activeGoal: null,
@ -1180,6 +1212,7 @@ export const useChatStore = create<ChatStore>((set, get) => ({
apiRetry: null,
}))
} else if (msg.blockType === 'tool_use') {
clearPendingToolInputDelta(sessionId)
rememberPendingToolParentUseId(sessionId, msg.toolUseId, msg.parentToolUseId)
const toolUseId = msg.toolUseId ?? null
const toolName = msg.toolName ?? 'unknown'
@ -1247,31 +1280,39 @@ export const useChatStore = create<ChatStore>((set, get) => ({
}
}
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,
appendPendingToolInputDelta(sessionId, msg.toolInput)
if (!toolInputFlushTimerBySession.has(sessionId)) {
const timer = setTimeout(() => {
const text = consumePendingToolInputDelta(sessionId)
if (!text) return
update((s) => {
const partialInput = s.streamingToolInput + text
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,
}
}),
}
}),
}
: {}),
}
})
: {}),
}
})
}, 50)
toolInputFlushTimerBySession.set(sessionId, timer)
}
}
break
@ -1298,6 +1339,7 @@ export const useChatStore = create<ChatStore>((set, get) => ({
break
case 'tool_use_complete': {
clearPendingToolInputDelta(sessionId)
const session = get().sessions[sessionId]
const toolName = msg.toolName || session?.activeToolName || 'unknown'
const toolUseId = msg.toolUseId || session?.activeToolUseId || ''