From 0acbe6252bd3ddc9ad5c8dbe50b2a9279705a72a Mon Sep 17 00:00:00 2001 From: abose Date: Fri, 24 Apr 2026 14:51:14 +0530 Subject: [PATCH 1/2] fix(ai): safety-net timeouts and tool-result logging for MCP editor tools Adds per-tool timeout budgets around execPeer in the MCP editor tools so a stalled browser round-trip surfaces a deterministic error to Claude instead of hanging until the CLI kills the call. execJsInLivePreview is excluded because user code has no natural upper bound. Also logs each tool_result content block (isError, length, preview) so a tool that silently returns an error payload can be correlated with its earlier "Tool done" input log. --- src-node/claude-code-agent.js | 30 +++++++++++++++++++++++ src-node/mcp-editor-tools.js | 46 +++++++++++++++++++++++++++++------ 2 files changed, 68 insertions(+), 8 deletions(-) diff --git a/src-node/claude-code-agent.js b/src-node/claude-code-agent.js index ace042841c..40eb387683 100644 --- a/src-node/claude-code-agent.js +++ b/src-node/claude-code-agent.js @@ -1151,6 +1151,36 @@ async function _runQuery(requestId, prompt, projectPath, model, signal, locale, } } } + + // Tool results come back as user-typed messages with content blocks + // of type tool_result. Log isError + content size so we can correlate + // a "Tool done" (input stream) with what Claude actually saw as the reply. + if (message.type === "user" && message.message && Array.isArray(message.message.content)) { + for (const block of message.message.content) { + if (block && block.type === "tool_result") { + let len = 0; + let preview = ""; + if (typeof block.content === "string") { + len = block.content.length; + preview = block.content.slice(0, 120); + } else if (Array.isArray(block.content)) { + for (const c of block.content) { + if (c && c.type === "text" && typeof c.text === "string") { + len += c.text.length; + if (!preview) { preview = c.text.slice(0, 120); } + } else if (c && c.type === "image" && typeof c.data === "string") { + len += c.data.length; + if (!preview) { preview = "[image " + c.data.length + "ch]"; } + } + } + } + _log("Tool result:", block.tool_use_id || "?", + "isError=" + !!block.is_error, + "len=" + len + "ch", + preview ? ("preview=" + JSON.stringify(preview)) : ""); + } + } + } } // Flush any remaining accumulated text diff --git a/src-node/mcp-editor-tools.js b/src-node/mcp-editor-tools.js index 17b9ca4e57..4896a598f1 100644 --- a/src-node/mcp-editor-tools.js +++ b/src-node/mcp-editor-tools.js @@ -35,6 +35,36 @@ const CLARIFICATION_HINT = "IMPORTANT: The user has typed a follow-up clarification while you were working." + " Call the getUserClarification tool to read it before proceeding."; +// Per-tool safety-net budgets for the browser round-trip. The node connector +// is reliable in practice, so these should never fire during normal use — +// they exist so a stalled promise chain (live preview wedged, etc.) surfaces +// a deterministic error to Claude instead of the handler hanging forever. +// Tools whose runtime is bounded by user-supplied code (execJsInLivePreview) +// intentionally have no timeout — the code is allowed to run as long as it takes. +const EXEC_PEER_TIMEOUT_MS = { + getEditorState: 5000, + takeScreenshot: 15000, + controlEditor: 5000, + resizeLivePreview: 5000 +}; + +function _execPeerWithTimeout(nodeConnector, fn, args, label) { + const ms = EXEC_PEER_TIMEOUT_MS[fn]; + const call = nodeConnector.execPeer(fn, args); + if (!ms) { + return call; // no timeout configured for this tool + } + let timer; + const timeout = new Promise(function (_resolve, reject) { + timer = setTimeout(function () { + reject(new Error(label + " timed out after " + ms + "ms")); + }, ms); + }); + return Promise.race([call, timeout]).finally(function () { + clearTimeout(timer); + }); +} + /** * Append a clarification hint to an MCP tool result if the user has queued a message. */ @@ -70,7 +100,7 @@ function createEditorMcpServer(sdkModule, nodeConnector, clarificationAccessors) async function () { let result; try { - const state = await nodeConnector.execPeer("getEditorState", {}); + const state = await _execPeerWithTimeout(nodeConnector, "getEditorState", {}, "getEditorState"); result = { content: [{ type: "text", text: JSON.stringify(state) }] }; @@ -107,11 +137,11 @@ function createEditorMcpServer(sdkModule, nodeConnector, clarificationAccessors) async function (args) { let toolResult; try { - const result = await nodeConnector.execPeer("takeScreenshot", { + const result = await _execPeerWithTimeout(nodeConnector, "takeScreenshot", { selector: args.selector || undefined, purePreview: args.purePreview || false, filePath: args.filePath || undefined - }); + }, "takeScreenshot"); if (result.filePath) { toolResult = { content: [{ type: "text", text: "Screenshot saved to: " + result.filePath }] @@ -148,9 +178,9 @@ function createEditorMcpServer(sdkModule, nodeConnector, clarificationAccessors) async function (args) { let toolResult; try { - const result = await nodeConnector.execPeer("execJsInLivePreview", { + const result = await _execPeerWithTimeout(nodeConnector, "execJsInLivePreview", { code: args.code - }); + }, "execJsInLivePreview"); if (result.error) { toolResult = { content: [{ type: "text", text: "Error: " + result.error }], @@ -202,7 +232,7 @@ function createEditorMcpServer(sdkModule, nodeConnector, clarificationAccessors) for (const op of args.operations) { console.log("[Phoenix AI] controlEditor:", op.operation, op.filePath); try { - const result = await nodeConnector.execPeer("controlEditor", op); + const result = await _execPeerWithTimeout(nodeConnector, "controlEditor", op, "controlEditor:" + op.operation); results.push(result); if (!result.success) { hasError = true; @@ -234,9 +264,9 @@ function createEditorMcpServer(sdkModule, nodeConnector, clarificationAccessors) async function (args) { let toolResult; try { - const result = await nodeConnector.execPeer("resizeLivePreview", { + const result = await _execPeerWithTimeout(nodeConnector, "resizeLivePreview", { width: args.width - }); + }, "resizeLivePreview"); if (result.error) { toolResult = { content: [{ type: "text", text: "Error: " + result.error }], From e1bf4df741ee616be905e001fc8166ddcedc0b70 Mon Sep 17 00:00:00 2001 From: abose Date: Fri, 24 Apr 2026 15:06:24 +0530 Subject: [PATCH 2/2] feat(ai): split AI panel header actions into left and right groups Mirrors the existing right-actions absolute layout on the left so session ops (new, history) live on the left edge of the header while Settings stays on the far right. Both groups share the hover-to-reveal behavior. --- src/styles/Extn-AIChatPanel.less | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/src/styles/Extn-AIChatPanel.less b/src/styles/Extn-AIChatPanel.less index 10f73f8bd6..60903bfdcd 100644 --- a/src/styles/Extn-AIChatPanel.less +++ b/src/styles/Extn-AIChatPanel.less @@ -77,9 +77,9 @@ line-height: 19px; } - .ai-chat-header-actions { + .ai-chat-header-actions, + .ai-chat-header-left-actions { position: absolute; - right: 10px; display: flex; align-items: center; gap: 2px; @@ -88,6 +88,14 @@ transition: opacity 0.5s ease; } + .ai-chat-header-actions { + right: 10px; + } + + .ai-chat-header-left-actions { + left: 10px; + } + .ai-history-btn, .ai-settings-btn { display: flex; @@ -113,7 +121,8 @@ } /* Show header actions on tab container hover */ -.ai-tab-container:hover .ai-chat-header-actions { +.ai-tab-container:hover .ai-chat-header-actions, +.ai-tab-container:hover .ai-chat-header-left-actions { opacity: 1; pointer-events: auto; transition: opacity 0.15s ease;