From 2e2dc2104751cfcc538b03284ca4da1ef8a14f16 Mon Sep 17 00:00:00 2001 From: shiyue Date: Mon, 30 Mar 2026 10:23:51 +0800 Subject: [PATCH] fix: improve codex tool call live updates --- lib/agent-runtime.js | 21 ++++ public/app.js | 225 +++++++++++++++++++++++++++++++++++++------ public/style.css | 12 ++- 3 files changed, 228 insertions(+), 30 deletions(-) diff --git a/lib/agent-runtime.js b/lib/agent-runtime.js index 01490fb..1b72bd3 100644 --- a/lib/agent-runtime.js +++ b/lib/agent-runtime.js @@ -334,6 +334,26 @@ function createAgentRuntime(deps) { break; } + case 'item.updated': { + const item = event.item; + if (!item || !item.id || item.type === 'agent_message') break; + const tc = ensureCodexToolCall(entry, item); + const resultText = codexToolResult(item).slice(0, 2000); + tc.done = false; + tc.result = resultText; + + wsSend(entry.ws, { + type: 'tool_update', + toolUseId: item.id, + name: tc.name, + input: tc.input, + result: resultText, + kind: tc.kind, + meta: tc.meta, + }); + break; + } + case 'item.completed': { const item = event.item; if (!item || !item.id) break; @@ -361,6 +381,7 @@ function createAgentRuntime(deps) { const resultText = codexToolResult(item).slice(0, 2000); tc.done = true; tc.result = resultText; + wsSend(entry.ws, { type: 'tool_end', toolUseId: item.id, diff --git a/public/app.js b/public/app.js index 111ebbe..ffb5ec1 100644 --- a/public/app.js +++ b/public/app.js @@ -84,6 +84,8 @@ let pendingText = ''; let renderTimer = null; let activeToolCalls = new Map(); + let activeTodoCallTargets = new Map(); + let toolDomSeq = 0; let toolGroupCount = 0; // 当前 .msg-tools 直接子节点数(含已有父目录) let hasGrouped = false; // 本次输出是否已触发过折叠 let cmdMenuIndex = -1; @@ -1365,6 +1367,7 @@ pendingAttachments = []; uploadingAttachments = []; activeToolCalls.clear(); + activeTodoCallTargets.clear(); sendBtn.hidden = false; abortBtn.hidden = true; chatTitle.textContent = '新会话'; @@ -1387,6 +1390,7 @@ abortBtn.hidden = true; pendingText = ''; activeToolCalls.clear(); + activeTodoCallTargets.clear(); } currentSessionId = snapshot.sessionId; loadedHistorySessionId = snapshot.sessionId; @@ -1814,6 +1818,27 @@ appendToolCall(msg.toolUseId, msg.name, msg.input, false, msg.kind || null, msg.meta || null); break; + case 'tool_update': + if (!isGenerating) startGenerating(); + if (!activeToolCalls.has(msg.toolUseId)) { + activeToolCalls.set(msg.toolUseId, { + name: msg.name, + input: msg.input, + kind: msg.kind || null, + meta: msg.meta || null, + done: false, + }); + appendToolCall(msg.toolUseId, msg.name, msg.input, false, msg.kind || null, msg.meta || null); + } + activeToolCalls.get(msg.toolUseId).done = false; + if (msg.name) activeToolCalls.get(msg.toolUseId).name = msg.name; + if (msg.input !== undefined) activeToolCalls.get(msg.toolUseId).input = msg.input; + if (msg.kind) activeToolCalls.get(msg.toolUseId).kind = msg.kind; + if (msg.meta) activeToolCalls.get(msg.toolUseId).meta = msg.meta; + activeToolCalls.get(msg.toolUseId).result = msg.result; + updateToolCall(msg.toolUseId, msg.result, false); + break; + case 'tool_end': if (activeToolCalls.has(msg.toolUseId)) { activeToolCalls.get(msg.toolUseId).done = true; @@ -1821,7 +1846,7 @@ if (msg.meta) activeToolCalls.get(msg.toolUseId).meta = msg.meta; activeToolCalls.get(msg.toolUseId).result = msg.result; } - updateToolCall(msg.toolUseId, msg.result); + updateToolCall(msg.toolUseId, msg.result, true); break; case 'cost': @@ -1880,6 +1905,7 @@ toolGroupCount = 0; hasGrouped = false; activeToolCalls.clear(); + activeTodoCallTargets.clear(); const toolsDiv = document.querySelector('#streaming-msg .msg-tools'); if (toolsDiv) toolsDiv.innerHTML = ''; } @@ -1896,8 +1922,8 @@ done: tc.done, }); appendToolCall(tc.id, tc.name, tc.input, tc.done, tc.kind || null, tc.meta || null); - if (tc.done && tc.result) { - updateToolCall(tc.id, tc.result); + if (tc.result !== undefined && tc.result !== null) { + updateToolCall(tc.id, tc.result, !!tc.done); } } } @@ -2015,6 +2041,7 @@ pendingText = ''; window.pendingContentBlocks = []; activeToolCalls.clear(); + activeTodoCallTargets.clear(); toolGroupCount = 0; hasGrouped = false; sendBtn.hidden = true; @@ -2062,7 +2089,7 @@ if (hasGrouped) { const toolsDiv = streamEl.querySelector('.msg-tools'); if (toolsDiv) { - const loose = Array.from(toolsDiv.children).filter(c => c.classList.contains('tool-call')); + const loose = Array.from(toolsDiv.children).filter(isGroupableToolCall); if (loose.length > 0) { let group = toolsDiv.querySelector(':scope > .tool-group'); if (!group) { @@ -2088,6 +2115,7 @@ if (sessionId) currentSessionId = sessionId; pendingText = ''; activeToolCalls.clear(); + activeTodoCallTargets.clear(); toolGroupCount = 0; hasGrouped = false; } @@ -2197,7 +2225,7 @@ } return; } - } catch {} + } catch (e) {} } // 尝试直接解析 JSON @@ -2209,7 +2237,7 @@ bubble.appendChild(createTodoListElement(parsed)); return; } - } catch {} + } catch (e) {} } bubble.innerHTML = renderMarkdown(content); return; @@ -2270,17 +2298,52 @@ return tool?.kind || tool?.meta?.kind || ''; } - function toolTitle(tool) { - if (tool?.meta?.title) return tool.meta.title; - if (toolKind(tool) === 'file_change') { - const filePath = tool?.meta?.subtitle || tool?.input?.file_path || ''; - const action = tool?.input?.new_string && tool?.input?.old_string ? '更新' : '创建'; - return filePath ? `${action} ${filePath}` : 'File Change'; + function normalizeDisplayPath(filePath) { + const rawPath = typeof filePath === 'string' ? filePath.trim() : ''; + if (!rawPath) return ''; + const normalizedPath = rawPath.replace(/\\/g, '/'); + const normalizedCwd = typeof currentCwd === 'string' ? currentCwd.trim().replace(/\\/g, '/') : ''; + if (normalizedCwd && normalizedPath.startsWith(`${normalizedCwd}/`)) { + return normalizedPath.slice(normalizedCwd.length + 1); } + return normalizedPath; + } + + function getFileChangeDisplay(tool) { + const changes = Array.isArray(tool?.input?.changes) ? tool.input.changes : []; + const primaryChange = changes[0] || null; + const rawPath = tool?.meta?.subtitle || primaryChange?.path || tool?.input?.path || tool?.input?.file_path || ''; + const filePath = normalizeDisplayPath(rawPath); + const rawKind = primaryChange?.kind || tool?.input?.kind || ''; + let action = '修改'; + if (rawKind === 'create' || rawKind === 'new') { + action = '创建'; + } else if (rawKind === 'delete' || rawKind === 'remove') { + action = '删除'; + } else if (rawKind === 'update' || rawKind === 'edit') { + action = '更新'; + } else if (tool?.input?.new_string && tool?.input?.old_string) { + action = '更新'; + } + return { action, filePath, changeCount: changes.length }; + } + + function toolTitle(tool) { + if (toolKind(tool) === 'file_change') { + const { action, filePath, changeCount } = getFileChangeDisplay(tool); + if (filePath && changeCount > 1) return `${action} ${filePath} 等 ${changeCount} 个文件`; + if (filePath) return `${action} ${filePath}`; + if (changeCount > 1) return `修改 ${changeCount} 个文件`; + return tool?.meta?.title || 'File Change'; + } + if (tool?.meta?.title) return tool.meta.title; return tool?.name || 'Tool'; } function toolSubtitle(tool) { + if (toolKind(tool) === 'file_change') { + return ''; + } if (tool?.meta?.subtitle) return tool.meta.subtitle; if (toolKind(tool) === 'command_execution') { return tool?.input?.command || ''; @@ -2357,6 +2420,33 @@ return section; } + function isGroupableToolCall(node) { + return !!(node?.classList?.contains('tool-call') && node.dataset.toolKind !== 'todo_list'); + } + + function rememberToolCallTarget(toolUseId, tool, element) { + if (!element) return; + const entry = activeToolCalls.get(toolUseId); + if (entry) { + entry.domElement = element; + } + if (toolKind(tool) === 'todo_list' && tool?.input?.id) { + activeTodoCallTargets.set(tool.input.id, element); + } + } + + function getLatestAssistantToolScope() { + const streamEl = document.getElementById('streaming-msg'); + if (streamEl) return streamEl; + const agentSelector = `.msg.assistant.agent-${normalizeAgent(currentAgent)}`; + const assistantMessages = messagesDiv.querySelectorAll(agentSelector); + if (assistantMessages.length > 0) { + return assistantMessages[assistantMessages.length - 1]; + } + const fallbackMessages = messagesDiv.querySelectorAll('.msg.assistant'); + return fallbackMessages.length > 0 ? fallbackMessages[fallbackMessages.length - 1] : null; + } + function buildMsgElement(m) { const el = createMsgElement(m.role, m.content, m.attachments || []); if (m.role === 'assistant' && m.toolCalls && m.toolCalls.length > 0) { @@ -2367,7 +2457,7 @@ const details = createToolCallElement(tc.id || `saved-${Math.random().toString(36).slice(2)}`, tc, true); // 散落的 .tool-call 达到 FOLD_AT 个时,移入唯一 .tool-group - const loose = Array.from(bubble.children).filter(c => c.classList.contains('tool-call')); + const loose = Array.from(bubble.children).filter(isGroupableToolCall); if (loose.length >= FOLD_AT) { let group = bubble.querySelector(':scope > .tool-group'); if (!group) { @@ -2390,7 +2480,7 @@ } // 结束时若出现过父目录,收尾散落项 if (grouped) { - const loose = Array.from(bubble.children).filter(c => c.classList.contains('tool-call')); + const loose = Array.from(bubble.children).filter(isGroupableToolCall); if (loose.length > 0) { const group = bubble.querySelector(':scope > .tool-group'); if (group) { @@ -2633,6 +2723,23 @@ const effectiveInput = tool.input !== undefined ? tool.input : input; const effectiveResult = tool.result; const kind = toolKind(tool); + + if (kind === 'todo_list') { + let todoData = effectiveInput; + // 如果有 result 且是字符串,尝试解析 + if (effectiveResult && typeof effectiveResult === 'string') { + try { + todoData = JSON.parse(effectiveResult); + } catch (e) { + } + } else { + } + const wrapper = document.createElement('div'); + wrapper.className = 'tool-call-content todo-list-content'; + wrapper.appendChild(createTodoListElement(todoData && typeof todoData === 'object' ? todoData : { items: [] })); + return wrapper; + } + if (effectiveName === 'AskUserQuestion') { const questions = extractAskUserQuestions(effectiveInput); if (questions.length > 0) { @@ -2693,7 +2800,8 @@ function createToolCallElement(toolUseId, tool, done) { const details = document.createElement('details'); details.className = 'tool-call'; - details.id = `tool-${toolUseId}`; + details.id = `tool-node-${++toolDomSeq}`; + details.dataset.toolUseId = toolUseId ? String(toolUseId) : ''; details.dataset.toolName = tool.name || ''; if (toolKind(tool)) { details.dataset.toolKind = toolKind(tool); @@ -2728,12 +2836,24 @@ const tool = { id: toolUseId, name, input, kind, meta, done }; + // 如果是 todo_list,检查是否已存在相同 id 的 todo_list + if (kind === 'todo_list' && input?.id) { + const existingTodo = findTodoToolCallByTodoId(toolsDiv, input.id); + if (existingTodo) { + const details = createToolCallElement(toolUseId, tool, done); + existingTodo.replaceWith(details); + rememberToolCallTarget(toolUseId, tool, details); + scrollToBottom(); + return; + } + } + const details = createToolCallElement(toolUseId, tool, done); // 折叠策略:只维护唯一一个 .tool-group 父节点 // 散落的 .tool-call 直接子节点达到3个时,将它们全部移入父节点;之后继续散落,再达3个再移入 const FOLD_AT = 3; - const looseBefore = Array.from(toolsDiv.children).filter(c => c.classList.contains('tool-call')); + const looseBefore = Array.from(toolsDiv.children).filter(isGroupableToolCall); if (looseBefore.length >= FOLD_AT) { // 确保存在唯一的 .tool-group let group = toolsDiv.querySelector(':scope > .tool-group'); @@ -2754,6 +2874,7 @@ _refreshGroupSummary(group); } toolsDiv.appendChild(details); + rememberToolCallTarget(toolUseId, tool, details); scrollToBottom(); } @@ -2764,23 +2885,71 @@ if (summary) summary.textContent = `展开 ${count} 个工具调用`; } - function updateToolCall(toolUseId, result) { - const el = document.getElementById(`tool-${toolUseId}`); - if (!el) return; - const tool = activeToolCalls.get(toolUseId) || { + function findLatestToolCallElement(root, matcher) { + if (!root || typeof matcher !== 'function') return null; + const allTools = root.querySelectorAll('.tool-call'); + for (let i = allTools.length - 1; i >= 0; i--) { + const el = allTools[i]; + if (matcher(el)) return el; + } + return null; + } + + function findTodoToolCallByTodoId(root, todoId) { + if (!todoId) return null; + return findLatestToolCallElement(root, (el) => { + if (el.dataset.toolKind !== 'todo_list') return false; + const currentTodoId = el.querySelector('.todo-list-container')?.dataset.todoId; + return currentTodoId === todoId; + }); + } + + function updateToolCall(toolUseId, result, done = true) { + const tool = activeToolCalls.get(toolUseId) || null; + const toolUseIdText = toolUseId ? String(toolUseId) : ''; + const scope = getLatestAssistantToolScope(); + let el = tool?.domElement && tool.domElement.isConnected ? tool.domElement : null; + + if (!el) { + if (tool?.kind === 'todo_list' && tool?.input?.id) { + const markedTodo = activeTodoCallTargets.get(tool.input.id); + if (markedTodo && markedTodo.isConnected) { + el = markedTodo; + } + } + } + + if (!el) { + el = findLatestToolCallElement(scope, (candidate) => candidate.dataset.toolUseId === toolUseIdText); + } + + if (!el && tool?.kind === 'todo_list' && tool?.input?.id) { + el = findTodoToolCallByTodoId(scope, tool.input.id); + } + + if (!el) { + return; + } + + const nextTool = tool || { id: toolUseId, name: el.dataset.toolName || '', kind: el.dataset.toolKind || null, - done: true, + done, }; - tool.done = true; - if (result !== undefined) tool.result = result; + nextTool.done = done; + if (result !== undefined) nextTool.result = result; + rememberToolCallTarget(toolUseId, nextTool, el); const summary = el.querySelector('summary'); - if (summary) applyToolSummary(summary, tool, true); - if (tool.name === 'AskUserQuestion') return; - const nextContent = buildToolContentElement(tool); - const content = el.querySelector('.tool-call-content'); - if (content) content.replaceWith(nextContent); + if (summary) applyToolSummary(summary, nextTool, done); + if (nextTool.name === 'AskUserQuestion') return; + const nextContent = buildToolContentElement(nextTool); + const content = Array.from(el.children).find((child) => child.tagName !== 'SUMMARY') || null; + if (content) { + content.replaceWith(nextContent); + } else { + el.appendChild(nextContent); + } } function getDeleteConfirmMessage(agent) { diff --git a/public/style.css b/public/style.css index 4780498..a2f7e99 100644 --- a/public/style.css +++ b/public/style.css @@ -1221,7 +1221,7 @@ body.session-loading-active { color: var(--text-secondary); background: var(--bg-secondary); display: flex; - align-items: flex-start; + align-items: anchor-center; gap: 8px; user-select: none; list-style: none; @@ -1313,6 +1313,14 @@ body.session-loading-active { color: var(--text-primary); background: linear-gradient(180deg, rgba(255, 249, 242, 0.92), rgba(245, 221, 212, 0.32)); } +.tool-call-content.todo-list-content { + font-family: inherit; + white-space: normal; + word-break: normal; +} +.tool-call-content.todo-list-content .todo-list-container { + margin: 0; +} .tool-call-content.command, .tool-call-content.file-change { background: linear-gradient(180deg, rgba(255, 255, 255, 0.94), rgba(250, 246, 240, 0.92)); @@ -1631,7 +1639,7 @@ body.session-loading-active { } .input-wrapper { display: flex; - align-items: flex-end; + align-items: anchor-center; gap: 8px; background: #fff; border: 1px solid var(--border-color);