mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-16 13:03:31 +08:00
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:
parent
385435a04d
commit
6b1561cd1c
@ -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,
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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': '正在准备工具',
|
||||
|
||||
@ -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 () => {
|
||||
|
||||
@ -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 || ''
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user