diff --git a/phoenix-builder-mcp/mcp-tools.js b/phoenix-builder-mcp/mcp-tools.js index ded3e6d45..7c466d3dd 100644 --- a/phoenix-builder-mcp/mcp-tools.js +++ b/phoenix-builder-mcp/mcp-tools.js @@ -179,7 +179,16 @@ export function registerTools(server, processManager, wsControlServer, phoenixDe const totalEntries = result.totalEntries || entries.length; const matchedEntries = result.matchedEntries != null ? result.matchedEntries : entries.length; const rangeEnd = result.rangeEnd != null ? result.rangeEnd : matchedEntries; - let lines = entries.map(e => `[${e.level}] ${e.message}`); + let lines = entries.map(e => { + let ts = ""; + if (e.timestamp) { + // Show HH:MM:SS.mmm for compact display + const d = new Date(e.timestamp); + ts = d.toTimeString().slice(0, 8) + "." + + String(d.getMilliseconds()).padStart(3, "0") + " "; + } + return `[${ts}${e.level}] ${e.message}`; + }); let trimmed = 0; if (maxChars > 0) { const trimResult = _trimToCharBudget(lines, maxChars); diff --git a/src-node/claude-code-agent.js b/src-node/claude-code-agent.js index 4bb66e5d3..acced693c 100644 --- a/src-node/claude-code-agent.js +++ b/src-node/claude-code-agent.js @@ -122,7 +122,7 @@ exports.checkAvailability = async function () { * Called from browser via execPeer("sendPrompt", {prompt, projectPath, sessionAction, model}). * * Returns immediately with a requestId. Results are sent as events: - * aiProgress, aiTextStream, aiEditResult, aiError, aiComplete + * aiProgress, aiTextStream, aiToolEdit, aiError, aiComplete */ exports.sendPrompt = async function (params) { const { prompt, projectPath, sessionAction, model } = params; @@ -177,7 +177,8 @@ exports.destroySession = async function () { * Internal: run a Claude SDK query and stream results back to the browser. */ async function _runQuery(requestId, prompt, projectPath, model, signal) { - const collectedEdits = []; + let editCount = 0; + let toolCounter = 0; let queryFn; try { @@ -202,6 +203,13 @@ async function _runQuery(requestId, prompt, projectPath, model, signal) { maxTurns: 10, allowedTools: ["Read", "Edit", "Write", "Glob", "Grep"], permissionMode: "acceptEdits", + appendSystemPrompt: + "When modifying an existing file, always prefer the Edit tool " + + "(find-and-replace) instead of the Write tool. The Write tool should ONLY be used " + + "to create brand new files that do not exist yet. For existing files, always use " + + "multiple Edit calls to make targeted changes rather than rewriting the entire " + + "file with Write. This is critical because Write replaces the entire file content " + + "which is slow and loses undo history.", includePartialMessages: true, abortController: currentAbortController, hooks: { @@ -211,17 +219,23 @@ async function _runQuery(requestId, prompt, projectPath, model, signal) { hooks: [ async (input) => { console.log("[Phoenix AI] Intercepted Edit tool"); + const myToolId = toolCounter; // capture before any await const edit = { file: input.tool_input.file_path, oldText: input.tool_input.old_string, newText: input.tool_input.new_string }; - collectedEdits.push(edit); + editCount++; try { await nodeConnector.execPeer("applyEditToBuffer", edit); } catch (err) { console.warn("[Phoenix AI] Failed to apply edit to buffer:", err.message); } + nodeConnector.triggerPeer("aiToolEdit", { + requestId: requestId, + toolId: myToolId, + edit: edit + }); return { hookSpecificOutput: { hookEventName: "PreToolUse", @@ -255,7 +269,7 @@ async function _runQuery(requestId, prompt, projectPath, model, signal) { : line; return String(offset + i + 1).padStart(6) + "\t" + truncated; }).join("\n"); - formatted = filePath + " (unsaved editor content, " + + formatted = filePath + " (" + lines.length + " lines total)\n\n" + formatted; console.log("[Phoenix AI] Serving dirty file content for:", filePath); return { @@ -278,17 +292,23 @@ async function _runQuery(requestId, prompt, projectPath, model, signal) { hooks: [ async (input) => { console.log("[Phoenix AI] Intercepted Write tool"); + const myToolId = toolCounter; // capture before any await const edit = { file: input.tool_input.file_path, oldText: null, newText: input.tool_input.content }; - collectedEdits.push(edit); + editCount++; try { await nodeConnector.execPeer("applyEditToBuffer", edit); } catch (err) { console.warn("[Phoenix AI] Failed to apply write to buffer:", err.message); } + nodeConnector.triggerPeer("aiToolEdit", { + requestId: requestId, + toolId: myToolId, + edit: edit + }); return { hookSpecificOutput: { hookEventName: "PreToolUse", @@ -318,7 +338,11 @@ async function _runQuery(requestId, prompt, projectPath, model, signal) { queryOptions.resume = currentSessionId; } + const _log = (...args) => console.log("[AI]", ...args); + try { + _log("Query start:", JSON.stringify(prompt).slice(0, 80), "cwd=" + (projectPath || "?")); + const result = queryFn({ prompt: prompt, options: queryOptions @@ -331,18 +355,25 @@ async function _runQuery(requestId, prompt, projectPath, model, signal) { let activeToolName = null; let activeToolIndex = null; let activeToolInputJson = ""; - let toolCounter = 0; let lastToolStreamTime = 0; + // Trace counters (logged at tool/query completion, not per-delta) + let toolDeltaCount = 0; + let toolStreamSendCount = 0; + let textDeltaCount = 0; + let textStreamSendCount = 0; + for await (const message of result) { // Check abort if (signal.aborted) { + _log("Aborted"); break; } // Capture session_id from first message if (message.session_id && !currentSessionId) { currentSessionId = message.session_id; + _log("Session:", currentSessionId); } // Handle streaming events @@ -356,6 +387,10 @@ async function _runQuery(requestId, prompt, projectPath, model, signal) { activeToolIndex = event.index; activeToolInputJson = ""; toolCounter++; + toolDeltaCount = 0; + toolStreamSendCount = 0; + lastToolStreamTime = 0; + _log("Tool start:", activeToolName, "#" + toolCounter); nodeConnector.triggerPeer("aiProgress", { requestId: requestId, toolName: activeToolName, @@ -369,9 +404,12 @@ async function _runQuery(requestId, prompt, projectPath, model, signal) { event.delta?.type === "input_json_delta" && event.index === activeToolIndex) { activeToolInputJson += event.delta.partial_json; + toolDeltaCount++; const now = Date.now(); - if (now - lastToolStreamTime >= TEXT_STREAM_THROTTLE_MS) { + if (activeToolInputJson && + now - lastToolStreamTime >= TEXT_STREAM_THROTTLE_MS) { lastToolStreamTime = now; + toolStreamSendCount++; nodeConnector.triggerPeer("aiToolStream", { requestId: requestId, toolId: toolCounter, @@ -387,6 +425,7 @@ async function _runQuery(requestId, prompt, projectPath, model, signal) { activeToolName) { // Final flush of tool stream (bypasses throttle) if (activeToolInputJson) { + toolStreamSendCount++; nodeConnector.triggerPeer("aiToolStream", { requestId: requestId, toolId: toolCounter, @@ -400,6 +439,9 @@ async function _runQuery(requestId, prompt, projectPath, model, signal) { } catch (e) { // ignore parse errors } + _log("Tool done:", activeToolName, "#" + toolCounter, + "deltas=" + toolDeltaCount, "sent=" + toolStreamSendCount, + "json=" + activeToolInputJson.length + "ch"); nodeConnector.triggerPeer("aiToolInfo", { requestId: requestId, toolName: activeToolName, @@ -415,9 +457,11 @@ async function _runQuery(requestId, prompt, projectPath, model, signal) { if (event.type === "content_block_delta" && event.delta?.type === "text_delta") { accumulatedText += event.delta.text; + textDeltaCount++; const now = Date.now(); if (now - lastStreamTime >= TEXT_STREAM_THROTTLE_MS) { lastStreamTime = now; + textStreamSendCount++; nodeConnector.triggerPeer("aiTextStream", { requestId: requestId, text: accumulatedText @@ -430,19 +474,15 @@ async function _runQuery(requestId, prompt, projectPath, model, signal) { // Flush any remaining accumulated text if (accumulatedText) { + textStreamSendCount++; nodeConnector.triggerPeer("aiTextStream", { requestId: requestId, text: accumulatedText }); } - // Send collected edits if any - if (collectedEdits.length > 0) { - nodeConnector.triggerPeer("aiEditResult", { - requestId: requestId, - edits: collectedEdits - }); - } + _log("Complete: tools=" + toolCounter, "edits=" + editCount, + "textDeltas=" + textDeltaCount, "textSent=" + textStreamSendCount); // Signal completion nodeConnector.triggerPeer("aiComplete", { @@ -455,6 +495,7 @@ async function _runQuery(requestId, prompt, projectPath, model, signal) { const isAbort = signal.aborted || /abort/i.test(errMsg); if (isAbort) { + _log("Cancelled"); // Query was cancelled — clear session so next query starts fresh currentSessionId = null; nodeConnector.triggerPeer("aiComplete", { @@ -464,13 +505,7 @@ async function _runQuery(requestId, prompt, projectPath, model, signal) { return; } - // If we collected edits before error, send them - if (collectedEdits.length > 0) { - nodeConnector.triggerPeer("aiEditResult", { - requestId: requestId, - edits: collectedEdits - }); - } + _log("Error:", errMsg.slice(0, 200)); nodeConnector.triggerPeer("aiError", { requestId: requestId, diff --git a/src/core-ai/AIChatPanel.js b/src/core-ai/AIChatPanel.js index 9244ec580..c555f517a 100644 --- a/src/core-ai/AIChatPanel.js +++ b/src/core-ai/AIChatPanel.js @@ -30,6 +30,7 @@ define(function (require, exports, module) { Commands = require("command/Commands"), ProjectManager = require("project/ProjectManager"), FileSystem = require("filesystem/FileSystem"), + SnapshotStore = require("core-ai/AISnapshotStore"), marked = require("thirdparty/marked.min"); let _nodeConnector = null; @@ -39,10 +40,22 @@ define(function (require, exports, module) { let _autoScroll = true; let _hasReceivedContent = false; // tracks if we've received any text/tool in current response const _previousContentMap = {}; // filePath → previous content before edit, for undo support + let _currentEdits = []; // edits in current response, for summary card + let _firstEditInResponse = true; // tracks first edit per response for initial PUC + // --- AI event trace logging (compact, non-flooding) --- + let _traceTextChunks = 0; + let _traceToolStreamCounts = {}; // toolId → count // DOM references let $panel, $messages, $status, $statusText, $textarea, $sendBtn, $stopBtn; + // Live DOM query for $messages — the cached $messages reference can become stale + // after SidebarTabs reparents the panel. Use this for any deferred operations + // (click handlers, callbacks) where the cached reference may no longer be in the DOM. + function _$msgs() { + return $(".ai-chat-messages"); + } + const PANEL_HTML = '