fix(desktop): show tool write/edit progress (#707, #751)

Add live line and character stats to desktop tool cards while Write and Edit inputs stream, and show Writer preview stats before the 120-line windowing threshold.

Tested: cd desktop && bun run test -- --run src/components/chat/chatBlocks.test.tsx
Tested: cd desktop && bun run lint
Tested: bun run check:desktop
Not-tested: bun run verify / coverage; scoped desktop UI handoff only.
Confidence: high
Scope-risk: narrow
This commit is contained in:
程序员阿江(Relakkes) 2026-06-15 17:15:14 +08:00
parent 52682a4c76
commit 384fcea85e
7 changed files with 208 additions and 10 deletions

View File

@ -38,6 +38,13 @@ const TOOL_ICONS: Record<string, string> = {
const WRITER_PREVIEW_MAX_LINES = 120
const WRITER_PREVIEW_MAX_CHARS = 30000
type ContentStats = {
lines: number
chars: number
visibleLines?: number
windowed?: boolean
}
export const ToolCallBlock = memo(function ToolCallBlock({ toolName, input, result, compact = false, isPending = false, status, partialInput }: Props) {
const isPlanTool = isExitPlanModeTool(toolName)
const [expanded, setExpanded] = useState(isPlanTool)
@ -58,6 +65,11 @@ export const ToolCallBlock = memo(function ToolCallBlock({ toolName, input, resu
const stoppedSummary = status === 'stopped' && !result
? t('tool.stopped')
: ''
const liveStats = useMemo(
() => getToolContentStats(toolName, obj, isPending ? partialInput : undefined),
[isPending, obj, partialInput, toolName],
)
const liveStatsSummary = liveStats ? formatContentStats(liveStats, t) : ''
const preview = useMemo(() => renderPreview(toolName, obj, result, t), [obj, result, toolName, t])
const details = useMemo(() => renderDetails(toolName, obj, t, isPending ? partialInput : undefined), [isPending, obj, partialInput, toolName, t])
@ -108,9 +120,20 @@ export const ToolCallBlock = memo(function ToolCallBlock({ toolName, input, resu
<span className="flex-1" />
)}
{pendingSummary ? (
<span className="inline-flex shrink-0 items-center gap-1 text-[10px] text-[var(--color-outline)]">
<span
className="inline-flex min-w-0 max-w-[58%] shrink-0 items-center gap-1 text-[10px] text-[var(--color-outline)]"
title={liveStatsSummary ? `${pendingSummary} · ${liveStatsSummary}` : pendingSummary}
>
<LoaderCircle size={12} strokeWidth={2.4} className="animate-spin" aria-hidden="true" />
{pendingSummary}
<span className="truncate">{pendingSummary}</span>
{liveStatsSummary ? (
<>
<span className="shrink-0 text-[var(--color-text-tertiary)]">·</span>
<span className="shrink-0 font-[var(--font-mono)] tabular-nums text-[var(--color-text-tertiary)]">
{liveStatsSummary}
</span>
</>
) : null}
</span>
) : stoppedSummary ? (
<span className="inline-flex shrink-0 items-center gap-1 text-[10px] text-[var(--color-outline)]">
@ -127,6 +150,10 @@ export const ToolCallBlock = memo(function ToolCallBlock({ toolName, input, resu
>
{outputSummary}
</span>
) : liveStatsSummary ? (
<span className="shrink-0 font-[var(--font-mono)] text-[10px] tabular-nums text-[var(--color-outline)]">
{liveStatsSummary}
</span>
) : null}
{result?.isError && (
<span className="material-symbols-outlined shrink-0 text-[14px] text-[var(--color-error)]">error</span>
@ -324,7 +351,7 @@ function renderDetails(
if (partialInput) {
if (toolName === 'Write') {
const writerContent = extractPartialJsonStringField(partialInput, 'content')
if (writerContent) {
if (writerContent !== null) {
return renderWriterPreview(writerContent, t)
}
}
@ -420,12 +447,99 @@ function extractPartialJsonStringField(source: string, field: string): string |
return value
}
function getToolContentStats(
toolName: string,
obj: Record<string, unknown>,
partialInput?: string,
): ContentStats | null {
const content = getToolContentForStats(toolName, obj, partialInput)
return content === null ? null : countContentStats(content)
}
function getToolContentForStats(
toolName: string,
obj: Record<string, unknown>,
partialInput?: string,
): string | null {
if (toolName === 'Write') {
if (typeof obj.content === 'string') return obj.content
return partialInput ? extractPartialJsonStringField(partialInput, 'content') : null
}
if (toolName === 'Edit') {
if (typeof obj.new_string === 'string') return obj.new_string
return partialInput ? extractPartialJsonStringField(partialInput, 'new_string') : null
}
if (toolName === 'MultiEdit' && Array.isArray(obj.edits)) {
const replacements = obj.edits
.map((edit) => (
edit && typeof edit === 'object' && typeof (edit as Record<string, unknown>).new_string === 'string'
? (edit as Record<string, string>).new_string
: ''
))
.filter(Boolean)
return replacements.length > 0 ? replacements.join('\n') : null
}
return null
}
function countContentStats(content: string): ContentStats {
return {
lines: content.length === 0 ? 0 : content.split('\n').length,
chars: content.length,
}
}
function formatContentStats(
stats: ContentStats,
t?: (key: TranslationKey, params?: Record<string, string | number>) => string,
): string {
const chars = formatCharCount(stats.chars, t)
if (stats.windowed && typeof stats.visibleLines === 'number' && stats.visibleLines < stats.lines) {
return t?.('tool.contentStatsLatest', {
visible: formatCount(stats.visibleLines),
total: formatCount(stats.lines),
chars,
}) ?? `Latest ${formatCount(stats.visibleLines)} / ${formatCount(stats.lines)} lines · ${chars}`
}
return t?.('tool.contentStats', {
lines: formatLineCount(stats.lines, t),
chars,
}) ?? `${formatLineCount(stats.lines, t)} · ${chars}`
}
function formatLineCount(
count: number,
t?: (key: TranslationKey, params?: Record<string, string | number>) => string,
): string {
return count === 1
? (t?.('tool.lineCountSingular', { count: formatCount(count) }) ?? `${formatCount(count)} line`)
: (t?.('tool.lineCountPlural', { count: formatCount(count) }) ?? `${formatCount(count)} lines`)
}
function formatCharCount(
count: number,
t?: (key: TranslationKey, params?: Record<string, string | number>) => string,
): string {
return count === 1
? (t?.('tool.charCountSingular', { count: formatCount(count) }) ?? `${formatCount(count)} char`)
: (t?.('tool.charCountPlural', { count: formatCount(count) }) ?? `${formatCount(count)} chars`)
}
function formatCount(count: number): string {
return new Intl.NumberFormat().format(count)
}
function renderWriterPreview(
content: string,
t?: (key: TranslationKey, params?: Record<string, string | number>) => string,
) {
const lines = content.split('\n')
const totalLines = lines.length
const contentStats = countContentStats(content)
const lines = content.length === 0 ? [] : content.split('\n')
const totalLines = contentStats.lines
const visibleLines = lines.length > WRITER_PREVIEW_MAX_LINES
? lines.slice(-WRITER_PREVIEW_MAX_LINES)
: lines
@ -436,16 +550,21 @@ function renderWriterPreview(
}
const lineWindowed = totalLines > visibleLines.length
const isWindowed = lineWindowed || charTruncated
const visibleLineCount = visibleContent.length === 0 ? 0 : visibleContent.split('\n').length
const statsSummary = formatContentStats({
lines: totalLines,
chars: contentStats.chars,
visibleLines: visibleLineCount,
windowed: isWindowed,
}, t)
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}
<span className="font-[var(--font-mono)] normal-case tracking-normal tabular-nums">
{statsSummary}
</span>
</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}

View File

@ -109,6 +109,22 @@ describe('chat blocks', () => {
expect(container.textContent).toContain('Generating content')
})
it('shows pending Write line and character progress in the collapsed header', () => {
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":"alpha\\nbeta'}
/>,
)
expect(container.textContent).toContain('Generating content')
expect(container.textContent).toContain('2 lines')
expect(container.textContent).toContain('10 chars')
expect(container.textContent).not.toContain('latest')
})
it('expands pending Write tool calls into a live writer preview instead of raw JSON', () => {
const { container } = render(
<ToolCallBlock
@ -127,6 +143,39 @@ describe('chat blocks', () => {
expect(container.textContent).not.toContain('"content"')
})
it('shows non-windowed Writer preview stats before the 120-line limit', () => {
const { container } = render(
<ToolCallBlock
toolName="Write"
input={{ file_path: '/private/tmp/generated.ts' }}
isPending
partialInput={'{"file_path":"/private/tmp/generated.ts","content":"alpha\\nbeta\\ngamma'}
/>,
)
fireEvent.click(screen.getByRole('button'))
expect(container.textContent).toContain('Writer')
expect(container.textContent).toContain('3 lines')
expect(container.textContent).toContain('16 chars')
expect(container.textContent).not.toContain('latest')
})
it('shows pending Edit replacement character progress in the collapsed header', () => {
const { container } = render(
<ToolCallBlock
toolName="Edit"
input={{ file_path: '/tmp/example.ts' }}
isPending
partialInput={'{"file_path":"/tmp/example.ts","old_string":"const ready = false","new_string":"const ready = true'}
/>,
)
expect(container.textContent).toContain('Preparing edit')
expect(container.textContent).toContain('1 line')
expect(container.textContent).toContain('18 chars')
})
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')

View File

@ -1413,6 +1413,12 @@ export const en = {
'tool.partialInput': 'Partial input',
'tool.writerPreview': 'Writer',
'tool.writerPreviewLatest': 'Showing latest {visible} of {total} lines',
'tool.contentStats': '{lines} · {chars}',
'tool.contentStatsLatest': 'Showing latest {visible} / {total} lines · {chars}',
'tool.lineCountSingular': '{count} line',
'tool.lineCountPlural': '{count} lines',
'tool.charCountSingular': '{count} char',
'tool.charCountPlural': '{count} chars',
'tool.generatingContent': 'Generating content',
'tool.preparingEdit': 'Preparing edit',
'tool.preparingTool': 'Preparing tool',

View File

@ -1415,6 +1415,12 @@ export const jp: Record<TranslationKey, string> = {
'tool.partialInput': '部分的な入力',
'tool.writerPreview': 'Writer',
'tool.writerPreviewLatest': '{total} 行のうち最新の {visible} 行を表示中',
'tool.contentStats': '{lines} · {chars}',
'tool.contentStatsLatest': '最新 {visible} / {total} 行 · {chars}',
'tool.lineCountSingular': '{count} 行',
'tool.lineCountPlural': '{count} 行',
'tool.charCountSingular': '{count} 文字',
'tool.charCountPlural': '{count} 文字',
'tool.generatingContent': 'コンテンツを生成中',
'tool.preparingEdit': '編集を準備中',
'tool.preparingTool': 'ツールを準備中',

View File

@ -1415,6 +1415,12 @@ export const kr: Record<TranslationKey, string> = {
'tool.partialInput': '부분 입력',
'tool.writerPreview': 'Writer',
'tool.writerPreviewLatest': '{total}줄 중 최신 {visible}줄 표시 중',
'tool.contentStats': '{lines} · {chars}',
'tool.contentStatsLatest': '최신 {visible} / {total}줄 · {chars}',
'tool.lineCountSingular': '{count}줄',
'tool.lineCountPlural': '{count}줄',
'tool.charCountSingular': '{count}자',
'tool.charCountPlural': '{count}자',
'tool.generatingContent': '콘텐츠 생성 중',
'tool.preparingEdit': '편집 준비 중',
'tool.preparingTool': '도구 준비 중',

View File

@ -1415,6 +1415,12 @@ export const zh: Record<TranslationKey, string> = {
'tool.partialInput': '部分輸入',
'tool.writerPreview': 'Writer',
'tool.writerPreviewLatest': '正在顯示最新 {visible} / {total} 行',
'tool.contentStats': '{lines} · {chars}',
'tool.contentStatsLatest': '最新 {visible} / {total} 行 · {chars}',
'tool.lineCountSingular': '{count} 行',
'tool.lineCountPlural': '{count} 行',
'tool.charCountSingular': '{count} 字元',
'tool.charCountPlural': '{count} 字元',
'tool.generatingContent': '正在生成內容',
'tool.preparingEdit': '正在準備編輯',
'tool.preparingTool': '正在準備工具',

View File

@ -1415,6 +1415,12 @@ export const zh: Record<TranslationKey, string> = {
'tool.partialInput': '部分输入',
'tool.writerPreview': 'Writer',
'tool.writerPreviewLatest': '正在显示最新 {visible} / {total} 行',
'tool.contentStats': '{lines} · {chars}',
'tool.contentStatsLatest': '最新 {visible} / {total} 行 · {chars}',
'tool.lineCountSingular': '{count} 行',
'tool.lineCountPlural': '{count} 行',
'tool.charCountSingular': '{count} 字符',
'tool.charCountPlural': '{count} 字符',
'tool.generatingContent': '正在生成内容',
'tool.preparingEdit': '正在准备编辑',
'tool.preparingTool': '正在准备工具',