mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-18 13:23:33 +08:00
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:
commit
91224cbd20
@ -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
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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': '正在准备工具',
|
||||
|
||||
@ -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