Merge Writer preview fix into local main

Bring the detached-worktree desktop Writer preview patch into the canonical local main checkout so long pending Write calls show visible progress from main.

Constraint: Local main had advanced beyond the detached worktree base, so this landing keeps an explicit merge boundary.
Confidence: high
Scope-risk: moderate
Directive: Preserve bounded pending Writer rendering; do not replace it with live diff rendering without performance evidence.
Tested: bun run check:desktop in detached worktree before merge.
Not-tested: Post-merge desktop gate before this merge commit was created.
This commit is contained in:
程序员阿江(Relakkes) 2026-05-23 19:14:32 +08:00
commit 91224cbd20
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

@ -1236,6 +1236,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

@ -1238,6 +1238,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 || ''