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 = '
' + '
' + @@ -106,7 +119,7 @@ define(function (require, exports, module) { _nodeConnector.on("aiProgress", _onProgress); _nodeConnector.on("aiToolInfo", _onToolInfo); _nodeConnector.on("aiToolStream", _onToolStream); - _nodeConnector.on("aiEditResult", _onEditResult); + _nodeConnector.on("aiToolEdit", _onToolEdit); _nodeConnector.on("aiError", _onError); _nodeConnector.on("aiComplete", _onComplete); @@ -225,17 +238,29 @@ define(function (require, exports, module) { // Reset segment tracking and show thinking indicator _segmentText = ""; _hasReceivedContent = false; + _currentEdits = []; + _firstEditInResponse = true; _appendThinkingIndicator(); + // Remove restore highlights from previous interactions + _$msgs().find(".ai-restore-highlighted").removeClass("ai-restore-highlighted"); + // Get project path const projectPath = _getProjectRealPath(); + _traceTextChunks = 0; + _traceToolStreamCounts = {}; + + const prompt = text; + console.log("[AI UI] Sending prompt:", text.slice(0, 60)); + _nodeConnector.execPeer("sendPrompt", { - prompt: text, + prompt: prompt, projectPath: projectPath, sessionAction: "continue" }).then(function (result) { _currentRequestId = result.requestId; + console.log("[AI UI] RequestId:", result.requestId); }).catch(function (err) { _setStreaming(false); _appendErrorMessage("Failed to send message: " + (err.message || String(err))); @@ -266,6 +291,11 @@ define(function (require, exports, module) { _segmentText = ""; _hasReceivedContent = false; _isStreaming = false; + _firstEditInResponse = true; + SnapshotStore.reset(); + Object.keys(_previousContentMap).forEach(function (key) { + delete _previousContentMap[key]; + }); if ($messages) { $messages.empty(); } @@ -288,6 +318,11 @@ define(function (require, exports, module) { // --- Event handlers for node-side events --- function _onTextStream(_event, data) { + _traceTextChunks++; + if (_traceTextChunks === 1) { + console.log("[AI UI]", "First text chunk"); + } + // Remove thinking indicator on first content if (!_hasReceivedContent) { _hasReceivedContent = true; @@ -315,6 +350,7 @@ define(function (require, exports, module) { }; function _onProgress(_event, data) { + console.log("[AI UI]", "Progress:", data.phase, data.toolName ? data.toolName + " #" + data.toolId : ""); if ($statusText) { const toolName = data.toolName || ""; const config = TOOL_CONFIG[toolName]; @@ -326,11 +362,17 @@ define(function (require, exports, module) { } function _onToolInfo(_event, data) { + const uid = (_currentRequestId || "") + "-" + data.toolId; + const streamCount = _traceToolStreamCounts[uid] || 0; + console.log("[AI UI]", "ToolInfo:", data.toolName, "#" + data.toolId, + "file=" + (data.toolInput && data.toolInput.file_path || "?").split("/").pop(), + "streamEvents=" + streamCount); _updateToolIndicator(data.toolId, data.toolName, data.toolInput); } function _onToolStream(_event, data) { const uniqueToolId = (_currentRequestId || "") + "-" + data.toolId; + _traceToolStreamCounts[uniqueToolId] = (_traceToolStreamCounts[uniqueToolId] || 0) + 1; const $tool = $messages.find('.ai-msg-tool[data-tool-id="' + uniqueToolId + '"]'); if (!$tool.length) { return; @@ -348,6 +390,11 @@ define(function (require, exports, module) { } const preview = _extractToolPreview(data.toolName, data.partialJson); + const count = _traceToolStreamCounts[uniqueToolId]; + if (count === 1) { + console.log("[AI UI]", "ToolStream first:", data.toolName, "#" + data.toolId, + "json=" + (data.partialJson || "").length + "ch"); + } if (preview) { $tool.find(".ai-tool-preview").text(preview); _scrollToBottom(); @@ -389,7 +436,8 @@ define(function (require, exports, module) { if (!partialJson) { return ""; } - // Map tool names to the key whose value we want to preview + // Map tool names to the key whose value we want to preview. + // Tools not listed here get no streaming preview. const interestingKey = { Write: "content", Edit: "new_string", @@ -398,19 +446,21 @@ define(function (require, exports, module) { Glob: "pattern" }[toolName]; + if (!interestingKey) { + return ""; + } + let raw = ""; - if (interestingKey) { - // Find the interesting key and grab everything after it - const keyPattern = '"' + interestingKey + '":'; - const idx = partialJson.indexOf(keyPattern); - if (idx !== -1) { - raw = partialJson.slice(idx + keyPattern.length).slice(-120); - } - // If the interesting key hasn't appeared yet, show nothing - // rather than raw JSON noise like {"file_path":... - } else { - // No interesting key defined for this tool — use the tail - raw = partialJson.slice(-120); + // Find the interesting key and grab everything after it + const keyPattern = '"' + interestingKey + '":'; + const idx = partialJson.indexOf(keyPattern); + if (idx !== -1) { + raw = partialJson.slice(idx + keyPattern.length).slice(-120); + } + // If the interesting key hasn't appeared yet, show a byte counter + // so the user sees streaming activity during the file_path phase + if (!raw && partialJson.length > 3) { + return "receiving " + partialJson.length + " bytes..."; } if (!raw) { return ""; @@ -429,23 +479,274 @@ define(function (require, exports, module) { return preview; } - function _onEditResult(_event, data) { - if (data.edits && data.edits.length > 0) { - data.edits.forEach(function (edit) { - _appendEditCard(edit); + function _onToolEdit(_event, data) { + const edit = data.edit; + const uniqueToolId = (_currentRequestId || "") + "-" + data.toolId; + console.log("[AI UI]", "ToolEdit:", edit.file.split("/").pop(), "#" + data.toolId); + + // Track for summary card + const oldLines = edit.oldText ? edit.oldText.split("\n").length : 0; + const newLines = edit.newText ? edit.newText.split("\n").length : 0; + _currentEdits.push({ + file: edit.file, + linesAdded: newLines, + linesRemoved: oldLines + }); + + // Capture pre-edit content into pending snapshot and back-fill + const previousContent = _previousContentMap[edit.file]; + const isNewFile = (edit.oldText === null && (previousContent === undefined || previousContent === "")); + SnapshotStore.recordFileBeforeEdit(edit.file, previousContent, isNewFile); + + // On first edit per response, insert initial PUC if needed + if (_firstEditInResponse) { + _firstEditInResponse = false; + if (!SnapshotStore.isInitialSnapshotCreated()) { + const initialIndex = SnapshotStore.createInitialSnapshot(); + // Insert initial restore point PUC before the current tool indicator + const $puc = $( + '
' + + '' + + '
' + ); + $puc.find(".ai-restore-point-btn").on("click", function () { + if (!_isStreaming) { + _onRestoreClick(initialIndex); + } + }); + // Find the last tool indicator and insert the PUC right before it + const $liveMsg = _$msgs(); + const $lastTool = $liveMsg.find(".ai-msg-tool").last(); + if ($lastTool.length) { + $lastTool.before($puc); + } else { + $liveMsg.append($puc); + } + } + } + + // Find the oldest Edit/Write tool indicator for this file that doesn't + // already have edit actions. This is more robust than matching by toolId + // because the SDK with includePartialMessages may re-emit tool_use blocks + // as phantom indicators, causing toolId mismatches. + const fileName = edit.file.split("/").pop(); + const $tool = $messages.find('.ai-msg-tool').filter(function () { + const label = $(this).find(".ai-tool-label").text(); + const hasActions = $(this).find(".ai-tool-edit-actions").length > 0; + return !hasActions && (label.includes("Edit " + fileName) || label.includes("Write " + fileName)); + }).first(); + if (!$tool.length) { + return; + } + + // Remove any existing edit actions (in case of duplicate events) + $tool.find(".ai-tool-edit-actions").remove(); + + // Build the inline edit actions (diff toggle only — undo is on summary card) + const $actions = $('
'); + + // Diff toggle + const $diffToggle = $(''); + const $diff = $('
'); + + if (edit.oldText) { + edit.oldText.split("\n").forEach(function (line) { + $diff.append($('
').text("- " + line)); + }); + edit.newText.split("\n").forEach(function (line) { + $diff.append($('
').text("+ " + line)); + }); + } else { + // Write (new file) — show all as new + edit.newText.split("\n").forEach(function (line) { + $diff.append($('
').text("+ " + line)); }); } + + $diffToggle.on("click", function () { + $diff.toggleClass("expanded"); + $diffToggle.text($diff.hasClass("expanded") ? "Hide diff" : "Show diff"); + }); + + $actions.append($diffToggle); + $tool.append($actions); + $tool.append($diff); + _scrollToBottom(); } function _onError(_event, data) { + console.log("[AI UI]", "Error:", (data.error || "").slice(0, 200)); _appendErrorMessage(data.error); // Don't stop streaming — the node side may continue (partial results) } function _onComplete(_event, data) { + console.log("[AI UI]", "Complete. textChunks=" + _traceTextChunks, + "toolStreams=" + JSON.stringify(_traceToolStreamCounts)); + // Reset trace counters for next query + _traceTextChunks = 0; + _traceToolStreamCounts = {}; + + // Append edit summary if there were edits (finalizeResponse called inside) + if (_currentEdits.length > 0) { + _appendEditSummary(); + } + _setStreaming(false); } + /** + * Append a compact summary card showing all files modified during this response. + */ + function _appendEditSummary() { + // Finalize snapshot and get the after-snapshot index + const afterIndex = SnapshotStore.finalizeResponse(); + + // Aggregate per-file stats + const fileStats = {}; + const fileOrder = []; + _currentEdits.forEach(function (e) { + if (!fileStats[e.file]) { + fileStats[e.file] = { added: 0, removed: 0 }; + fileOrder.push(e.file); + } + fileStats[e.file].added += e.linesAdded; + fileStats[e.file].removed += e.linesRemoved; + }); + + const fileCount = fileOrder.length; + const $summary = $('
'); + const $header = $( + '
' + + '' + + fileCount + (fileCount === 1 ? " file" : " files") + " changed" + + '' + + '
' + ); + + if (afterIndex >= 0) { + // Update any previous summary card buttons to say "Restore to this point" + _$msgs().find('.ai-edit-restore-btn').text("Restore to this point") + .attr("title", "Restore files to this point"); + + // Determine button label: "Undo" if not undone, else "Restore to this point" + const isUndo = !SnapshotStore.isUndoApplied(); + const label = isUndo ? "Undo" : "Restore to this point"; + const title = isUndo ? "Undo changes from this response" : "Restore files to this point"; + + const $restoreBtn = $( + '' + ); + $restoreBtn.on("click", function (e) { + e.stopPropagation(); + if (_isStreaming) { + return; + } + if ($(this).text() === "Undo") { + _onUndoClick(afterIndex); + } else { + _onRestoreClick(afterIndex); + } + }); + $header.append($restoreBtn); + } + $summary.append($header); + + fileOrder.forEach(function (filePath) { + const stats = fileStats[filePath]; + const displayName = filePath.split("/").pop(); + const $file = $( + '
' + + '' + + '' + + '+' + stats.added + '' + + '-' + stats.removed + '' + + '' + + '
' + ); + $file.find(".ai-edit-summary-name").text(displayName); + $file.on("click", function () { + const vfsPath = SnapshotStore.realToVfsPath(filePath); + CommandManager.execute(Commands.CMD_OPEN, { fullPath: vfsPath }); + }); + $summary.append($file); + }); + + $messages.append($summary); + _scrollToBottom(); + } + + /** + * Handle "Restore to this point" click on any restore point element. + * @param {number} snapshotIndex - index into the snapshots array + */ + function _onRestoreClick(snapshotIndex) { + const $msgs = _$msgs(); + // Remove all existing highlights + $msgs.find(".ai-restore-highlighted").removeClass("ai-restore-highlighted"); + + // Once any "Restore to this point" is clicked, undo is no longer applicable + SnapshotStore.setUndoApplied(true); + + // Reset all buttons to "Restore to this point" + $msgs.find('.ai-edit-restore-btn').each(function () { + $(this).text("Restore to this point") + .attr("title", "Restore files to this point"); + }); + $msgs.find('.ai-restore-point-btn').text("Restore to this point"); + + SnapshotStore.restoreToSnapshot(snapshotIndex, function (errorCount) { + if (errorCount > 0) { + console.warn("[AI UI] Restore had", errorCount, "errors"); + } + + // Mark the clicked element as "Restored" + const $m = _$msgs(); + const $target = $m.find('[data-snapshot-index="' + snapshotIndex + '"]'); + if ($target.length) { + $target.addClass("ai-restore-highlighted"); + const $btn = $target.find(".ai-edit-restore-btn, .ai-restore-point-btn"); + $btn.text("Restored"); + } + }); + } + + /** + * Handle "Undo" click on the latest summary card. + * @param {number} afterIndex - snapshot index of the latest after-snapshot + */ + function _onUndoClick(afterIndex) { + const $msgs = _$msgs(); + SnapshotStore.setUndoApplied(true); + const targetIndex = afterIndex - 1; + + // Reset all buttons to "Restore to this point" + $msgs.find('.ai-edit-restore-btn').each(function () { + $(this).text("Restore to this point") + .attr("title", "Restore files to this point"); + }); + $msgs.find('.ai-restore-point-btn').text("Restore to this point"); + + SnapshotStore.restoreToSnapshot(targetIndex, function (errorCount) { + if (errorCount > 0) { + console.warn("[AI UI] Undo had", errorCount, "errors"); + } + + // Find the DOM element for the target snapshot and highlight it + const $m = _$msgs(); + const $target = $m.find('[data-snapshot-index="' + targetIndex + '"]'); + if ($target.length) { + $m.find(".ai-restore-highlighted").removeClass("ai-restore-highlighted"); + $target.addClass("ai-restore-highlighted"); + $target[0].scrollIntoView({ behavior: "smooth", block: "center" }); + // Mark the target as "Restored" + const $btn = $target.find(".ai-edit-restore-btn, .ai-restore-point-btn"); + $btn.text("Restored"); + } + }); + } + // --- DOM helpers --- function _appendUserMessage(text) { @@ -587,7 +888,7 @@ define(function (require, exports, module) { const filePath = toolInput.file_path; $tool.find(".ai-tool-label").on("click", function (e) { e.stopPropagation(); - const vfsPath = _realToVfsPath(filePath); + const vfsPath = SnapshotStore.realToVfsPath(filePath); CommandManager.execute(Commands.CMD_OPEN, { fullPath: vfsPath }); }).css("cursor", "pointer").addClass("ai-tool-label-clickable"); } @@ -654,10 +955,17 @@ define(function (require, exports, module) { /** * Mark all active (non-done) tool indicators as finished. + * Tools that already received _updateToolIndicator (spinner replaced with + * .ai-tool-icon) are skipped — their delayed timeout will add .ai-tool-done. + * This only force-finishes tools that never got a toolInfo (e.g. interrupted). */ function _finishActiveTools() { $messages.find(".ai-msg-tool:not(.ai-tool-done)").each(function () { const $prev = $(this); + // _updateToolIndicator already ran — let the delayed timeout handle it + if ($prev.find(".ai-tool-icon").length) { + return; + } $prev.addClass("ai-tool-done"); const iconClass = $prev.attr("data-tool-icon") || "fa-solid fa-check"; const color = $prev.css("--tool-color") || "#adb9bd"; @@ -669,85 +977,6 @@ define(function (require, exports, module) { }); } - function _appendEditCard(edit) { - const fileName = edit.file; - // Show just the filename, not full path - const displayName = fileName.split("/").pop(); - - const $card = $('
'); - const $header = $( - '
' + - '' + - '' + - '
' - ); - $header.find(".ai-edit-file").text(displayName); - - // Click filename to open file in editor - $header.find(".ai-edit-file").on("click", function () { - const vfsPath = _realToVfsPath(fileName); - CommandManager.execute(Commands.CMD_OPEN, { fullPath: vfsPath }); - }); - - const $toggle = $(''); - const $diff = $('
'); - - // Build diff content - if (edit.oldText) { - const oldLines = edit.oldText.split("\n"); - const newLines = edit.newText.split("\n"); - oldLines.forEach(function (line) { - $diff.append($('
').text("- " + line)); - }); - newLines.forEach(function (line) { - $diff.append($('
').text("+ " + line)); - }); - } else { - // Write (new file) — show all as new - edit.newText.split("\n").forEach(function (line) { - $diff.append($('
').text("+ " + line)); - }); - } - - $toggle.on("click", function () { - $diff.toggleClass("expanded"); - $toggle.text($diff.hasClass("expanded") ? "Hide diff" : "Show diff"); - }); - - // Undo button — restores previous content - $header.find(".ai-edit-undo-btn").on("click", function () { - const $btn = $(this); - if ($btn.hasClass("undone")) { - return; - } - if (edit.oldText === null) { - // Write (new file) — undo by restoring previous content - _undoEdit(edit.file, _previousContentMap[edit.file] || "") - .done(function () { - $btn.text("Undone").addClass("undone"); - }) - .fail(function (err) { - $card.append($('
').text(err.message || String(err))); - }); - } else { - // Edit — undo by reversing the replacement - const reverseEdit = { file: edit.file, oldText: edit.newText, newText: edit.oldText }; - _applySingleEdit(reverseEdit) - .done(function () { - $btn.text("Undone").addClass("undone"); - }) - .fail(function (err) { - $card.append($('
').text(err.message || String(err))); - }); - } - }); - - $card.append($header); - $card.append($toggle); - $card.append($diff); - $messages.append($card); - _scrollToBottom(); - } function _appendErrorMessage(text) { const $msg = $( @@ -781,6 +1010,9 @@ define(function (require, exports, module) { $sendBtn.show(); } } + // Disable/enable all restore buttons during streaming (use live query) + _$msgs().find(".ai-restore-point-btn, .ai-edit-restore-btn") + .prop("disabled", streaming); if (!streaming && $messages) { // Clean up thinking indicator if still present $messages.find(".ai-thinking").remove(); @@ -807,15 +1039,15 @@ define(function (require, exports, module) { // --- Edit application --- /** - * Apply a single edit to a document buffer (makes it a dirty tab). + * Apply a single edit to a document buffer and save to disk. * Called immediately when Claude's Write/Edit is intercepted, so - * subsequent Reads see the new content via the dirty-file hook. + * subsequent Reads see the new content both in the buffer and on disk. * @param {Object} edit - {file, oldText, newText} * @return {$.Promise} resolves with {previousContent} for undo support */ function _applySingleEdit(edit) { const result = new $.Deferred(); - const vfsPath = _realToVfsPath(edit.file); + const vfsPath = SnapshotStore.realToVfsPath(edit.file); function _applyToDoc() { DocumentManager.getDocumentForPath(vfsPath) @@ -841,9 +1073,11 @@ define(function (require, exports, module) { _indexToPos(docText, idx + edit.oldText.length); doc.replaceRange(edit.newText, startPos, endPos); } - // Open the file in the editor + // Open the file in the editor and save to disk CommandManager.execute(Commands.CMD_OPEN, { fullPath: vfsPath }); - result.resolve({ previousContent: previousContent }); + SnapshotStore.saveDocToDisk(doc).always(function () { + result.resolve({ previousContent: previousContent }); + }); } catch (err) { result.reject(err); } @@ -854,15 +1088,23 @@ define(function (require, exports, module) { } if (edit.oldText === null) { - // Write — file may not exist yet. Create it on disk first so - // getDocumentForPath succeeds, then set content in the buffer. + // Write — file may not exist yet. Only create on disk if it doesn't + // already exist, to avoid triggering "external change" warnings. const file = FileSystem.getFileForPath(vfsPath); - file.write("", function (err) { - if (err) { - result.reject(new Error("Could not create file: " + err)); - return; + file.exists(function (existErr, exists) { + if (exists) { + // File exists — just open and set content, no disk write + _applyToDoc(); + } else { + // New file — create on disk first so getDocumentForPath works + file.write("", function (writeErr) { + if (writeErr) { + result.reject(new Error("Could not create file: " + writeErr)); + return; + } + _applyToDoc(); + }); } - _applyToDoc(); }); } else { // Edit — file must already exist @@ -872,33 +1114,6 @@ define(function (require, exports, module) { return result.promise(); } - /** - * Undo a previously applied edit by restoring the document content. - * For new files (previousContent is empty string), closes the document. - * @param {string} filePath - real filesystem path - * @param {string} previousContent - content to restore - * @return {$.Promise} - */ - function _undoEdit(filePath, previousContent) { - const result = new $.Deferred(); - const vfsPath = _realToVfsPath(filePath); - - DocumentManager.getDocumentForPath(vfsPath) - .done(function (doc) { - try { - doc.setText(previousContent); - result.resolve(); - } catch (err) { - result.reject(err); - } - }) - .fail(function (err) { - result.reject(err || new Error("Could not open document for undo")); - }); - - return result.promise(); - } - /** * Convert a character index in text to a {line, ch} position. */ @@ -933,27 +1148,12 @@ define(function (require, exports, module) { return fullPath; } - /** - * Convert a real filesystem path back to a VFS path that Phoenix understands. - */ - function _realToVfsPath(realPath) { - // If it already looks like a VFS path, return as-is - if (realPath.startsWith("/tauri/") || realPath.startsWith("/mnt/")) { - return realPath; - } - // Desktop builds use /tauri/ prefix - if (Phoenix.isNativeApp) { - return "/tauri" + realPath; - } - return realPath; - } - /** * Check if a file has unsaved changes in the editor and return its content. * Used by the node-side Read hook to serve dirty buffer content to Claude. */ function getFileContent(params) { - const vfsPath = _realToVfsPath(params.filePath); + const vfsPath = SnapshotStore.realToVfsPath(params.filePath); const doc = DocumentManager.getOpenDocumentForPath(vfsPath); if (doc && doc.isDirty) { return { isDirty: true, content: doc.getText() }; diff --git a/src/core-ai/AISnapshotStore.js b/src/core-ai/AISnapshotStore.js new file mode 100644 index 000000000..d5eb7c200 --- /dev/null +++ b/src/core-ai/AISnapshotStore.js @@ -0,0 +1,342 @@ +/* + * GNU AGPL-3.0 License + * + * Copyright (c) 2021 - present core.ai . All rights reserved. + * + * This program is free software: you can redistribute it and/or modify it + * under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License + * for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see https://opensource.org/licenses/AGPL-3.0. + * + */ + +/** + * AI Snapshot Store — content-addressable store and snapshot/restore logic + * for tracking file states across AI responses. Extracted from AIChatPanel + * to separate data/logic concerns from the DOM/UI layer. + */ +define(function (require, exports, module) { + + const DocumentManager = require("document/DocumentManager"), + CommandManager = require("command/CommandManager"), + Commands = require("command/Commands"), + FileSystem = require("filesystem/FileSystem"); + + // --- Private state --- + const _contentStore = {}; // hash → content string (content-addressable dedup) + let _snapshots = []; // flat: _snapshots[i] = { filePath: hash|null } + let _lastSnapshotAfter = {}; // cumulative state after last completed response + let _pendingBeforeSnap = {}; // built during current response: filePath → hash|null + let _initialSnapshotCreated = false; // has the initial (pre-AI) snapshot been pushed? + let _undoApplied = false; + + // --- Path utility --- + + /** + * Convert a real filesystem path back to a VFS path that Phoenix understands. + */ + function realToVfsPath(realPath) { + // If it already looks like a VFS path, return as-is + if (realPath.startsWith("/tauri/") || realPath.startsWith("/mnt/")) { + return realPath; + } + // Desktop builds use /tauri/ prefix + if (Phoenix.isNativeApp) { + return "/tauri" + realPath; + } + return realPath; + } + + // --- Content-addressable store --- + + function _hashContent(str) { + let h = 0x811c9dc5; // FNV-1a + for (let i = 0; i < str.length; i++) { + h ^= str.charCodeAt(i); // eslint-disable-line no-bitwise + h = (h * 0x01000193) >>> 0; // eslint-disable-line no-bitwise + } + return h.toString(36); + } + + function storeContent(content) { + const hash = _hashContent(content); + _contentStore[hash] = content; + return hash; + } + + // --- File operations --- + + /** + * Save a document's current content to disk so editors and disk stay in sync. + * @param {Document} doc - Brackets document to save + * @return {$.Promise} + */ + function saveDocToDisk(doc) { + const d = new $.Deferred(); + const file = doc.file; + const content = doc.getText(); + file.write(content, function (err) { + if (err) { + console.error("[AI UI] Save to disk failed:", doc.file.fullPath, err); + d.reject(err); + } else { + doc.notifySaved(); + d.resolve(); + } + }); + return d.promise(); + } + + /** + * Close a document tab (if open) and delete the file from disk. + * Used during restore to remove files that were created by the AI. + * @param {string} filePath - real filesystem path + * @return {$.Promise} + */ + function _closeAndDeleteFile(filePath) { + const result = new $.Deferred(); + const vfsPath = realToVfsPath(filePath); + const file = FileSystem.getFileForPath(vfsPath); + + const openDoc = DocumentManager.getOpenDocumentForPath(vfsPath); + if (openDoc) { + if (openDoc.isDirty) { + openDoc.setText(""); + } + CommandManager.execute(Commands.FILE_CLOSE, { file: file, _forceClose: true }) + .always(function () { + file.unlink(function (err) { + if (err) { + result.reject(err); + } else { + result.resolve(); + } + }); + }); + } else { + file.unlink(function (err) { + if (err) { + result.reject(err); + } else { + result.resolve(); + } + }); + } + + return result.promise(); + } + + /** + * Create or update a file with the given content. + * @param {string} filePath - real filesystem path + * @param {string} content - content to set + * @return {$.Promise} + */ + function _createOrUpdateFile(filePath, content) { + const result = new $.Deferred(); + const vfsPath = realToVfsPath(filePath); + + function _setContent() { + DocumentManager.getDocumentForPath(vfsPath) + .done(function (doc) { + try { + doc.setText(content); + saveDocToDisk(doc).always(function () { + CommandManager.execute(Commands.CMD_OPEN, { fullPath: vfsPath }); + result.resolve(); + }); + } catch (err) { + result.reject(err); + } + }) + .fail(function (err) { + result.reject(err || new Error("Could not open document")); + }); + } + + const file = FileSystem.getFileForPath(vfsPath); + file.exists(function (existErr, exists) { + if (exists) { + _setContent(); + } else { + file.write("", function (writeErr) { + if (writeErr) { + result.reject(new Error("Could not create file: " + writeErr)); + return; + } + _setContent(); + }); + } + }); + + return result.promise(); + } + + // --- Snapshot logic --- + + /** + * Apply a snapshot to files. hash=null means delete the file. + * @param {Object} snapshot - { filePath: hash|null } + * @return {$.Promise} resolves with errorCount + */ + function _applySnapshot(snapshot) { + const result = new $.Deferred(); + const filePaths = Object.keys(snapshot); + const promises = []; + let errorCount = 0; + filePaths.forEach(function (fp) { + const hash = snapshot[fp]; + const p = hash === null + ? _closeAndDeleteFile(fp) + : _createOrUpdateFile(fp, _contentStore[hash]); + p.fail(function () { errorCount++; }); + promises.push(p); + }); + if (promises.length === 0) { + return result.resolve(0).promise(); + } + $.when.apply($, promises).always(function () { result.resolve(errorCount); }); + return result.promise(); + } + + // --- Public API --- + + /** + * Record a file's pre-edit state into the pending snapshot and back-fill + * existing snapshots. Called once per file per response (first edit wins). + * @param {string} filePath - real filesystem path + * @param {string} previousContent - content before edit + * @param {boolean} isNewFile - true if the file was created by this edit + */ + function recordFileBeforeEdit(filePath, previousContent, isNewFile) { + if (!_pendingBeforeSnap.hasOwnProperty(filePath)) { + const hash = isNewFile ? null : storeContent(previousContent); + _pendingBeforeSnap[filePath] = hash; + // Back-fill all existing snapshots with this file's pre-AI state + _snapshots.forEach(function (snap) { + if (snap[filePath] === undefined) { + snap[filePath] = hash; + } + }); + // Also back-fill _lastSnapshotAfter + if (_lastSnapshotAfter[filePath] === undefined) { + _lastSnapshotAfter[filePath] = hash; + } + } + } + + /** + * Create the initial snapshot (snapshot 0) capturing file state before any + * AI edits. Called once per session on the first edit. + * @return {number} the snapshot index (always 0) + */ + function createInitialSnapshot() { + const snap = Object.assign({}, _lastSnapshotAfter); + _snapshots.push(snap); + _initialSnapshotCreated = true; + return 0; + } + + /** + * @return {boolean} whether the initial snapshot has been created this session + */ + function isInitialSnapshotCreated() { + return _initialSnapshotCreated; + } + + /** + * Finalize snapshot state when a response completes. + * Builds an "after" snapshot from current document content for edited files, + * pushes it, and resets transient tracking variables. + * @return {number} the after-snapshot index, or -1 if no edits happened + */ + function finalizeResponse() { + let afterIndex = -1; + if (Object.keys(_pendingBeforeSnap).length > 0) { + // Build "after" snapshot = current _lastSnapshotAfter + current content of edited files + const afterSnap = Object.assign({}, _lastSnapshotAfter); + Object.keys(_pendingBeforeSnap).forEach(function (fp) { + const vfsPath = realToVfsPath(fp); + const openDoc = DocumentManager.getOpenDocumentForPath(vfsPath); + if (openDoc) { + afterSnap[fp] = storeContent(openDoc.getText()); + } + }); + _snapshots.push(afterSnap); + _lastSnapshotAfter = afterSnap; + afterIndex = _snapshots.length - 1; + } + _pendingBeforeSnap = {}; + _undoApplied = false; + return afterIndex; + } + + /** + * Restore files to the state captured in a specific snapshot. + * @param {number} index - index into _snapshots + * @param {Function} onComplete - callback(errorCount) + */ + function restoreToSnapshot(index, onComplete) { + if (index < 0 || index >= _snapshots.length) { + onComplete(0); + return; + } + _applySnapshot(_snapshots[index]).done(function (errorCount) { + onComplete(errorCount); + }); + } + + /** + * @return {boolean} whether undo has been applied (latest summary clicked) + */ + function isUndoApplied() { + return _undoApplied; + } + + /** + * @param {boolean} val + */ + function setUndoApplied(val) { + _undoApplied = val; + } + + /** + * @return {number} number of snapshots + */ + function getSnapshotCount() { + return _snapshots.length; + } + + /** + * Clear all snapshot state. Called when starting a new session. + */ + function reset() { + Object.keys(_contentStore).forEach(function (k) { delete _contentStore[k]; }); + _snapshots = []; + _lastSnapshotAfter = {}; + _pendingBeforeSnap = {}; + _initialSnapshotCreated = false; + _undoApplied = false; + } + + exports.realToVfsPath = realToVfsPath; + exports.saveDocToDisk = saveDocToDisk; + exports.storeContent = storeContent; + exports.recordFileBeforeEdit = recordFileBeforeEdit; + exports.createInitialSnapshot = createInitialSnapshot; + exports.isInitialSnapshotCreated = isInitialSnapshotCreated; + exports.finalizeResponse = finalizeResponse; + exports.restoreToSnapshot = restoreToSnapshot; + exports.isUndoApplied = isUndoApplied; + exports.setUndoApplied = setUndoApplied; + exports.getSnapshotCount = getSnapshotCount; + exports.reset = reset; +}); diff --git a/src/core-ai/editApplyVerification.md b/src/core-ai/editApplyVerification.md new file mode 100644 index 000000000..f8891953b --- /dev/null +++ b/src/core-ai/editApplyVerification.md @@ -0,0 +1,164 @@ +# Edit Apply & Restore Point Verification + +Formal verification cases for the timeline-of-restore-points UX in AIChatPanel. + +## UX Model + +- **Initial PUC** (snapshot 0): appears once per session before the first edit tool indicator. Always shows "Restore to this point". +- **Summary card** (latest): shows "Undo" (when `_undoApplied` is false) or "Restore to this point" (when `_undoApplied` is true). +- **Summary card** (not latest): always shows "Restore to this point". +- After any restore/undo is clicked, `_undoApplied = true` and ALL buttons become "Restore to this point" until the next AI response creates new edits. +- The clicked restore point shows **"Restored"** text with green highlight styling. Clicking a different restore point moves the "Restored" indicator to that one. +- All restore/undo buttons are **disabled during AI streaming** and re-enabled when the response completes. + +## Snapshot List (flat, one per restore point) + +``` +_snapshots[0] = initial state (original files, before any AI edits) +_snapshots[1] = after R1 edits +_snapshots[2] = after R2 edits +... +``` + +## State Variables + +- `_snapshots[]`: flat array of `{ filePath: hash|null }` snapshots +- `_lastSnapshotAfter`: cumulative state after last completed response +- `_pendingBeforeSnap`: per-file pre-edit tracking during current response +- `_initialSnapshotCreated`: whether snapshot 0 has been pushed +- `_undoApplied`: whether undo/restore has been clicked on any card + +## DOM Layout Example + +``` +[User: "fix bugs"] +[── Restore to this point ──] <- initial PUC (snapshot 0), session-first only +[Claude: "I'll fix..."] +[Edit file1.js] +[Edit file2.js] +[Summary: 2 files changed | Undo] <- snapshot 1 + +[User: "also refactor"] +[Claude: "Refactoring..."] +[Edit file1.js] +[Summary: 1 file changed | Undo] <- snapshot 2 (snapshot 1 becomes "Restore to this point") +``` + +## Key API Methods + +### AISnapshotStore + +- `recordFileBeforeEdit(filePath, previousContent, isNewFile)`: tracks pre-edit state, back-fills all existing snapshots +- `createInitialSnapshot()`: pushes snapshot 0 from `_lastSnapshotAfter`, returns index 0 +- `isInitialSnapshotCreated()`: returns whether snapshot 0 exists +- `finalizeResponse()`: builds after-snapshot from current doc content, pushes it, resets `_undoApplied`, returns index (or -1) +- `restoreToSnapshot(index, callback)`: applies `_snapshots[index]` to files, calls `callback(errorCount)` +- `isUndoApplied()` / `setUndoApplied(val)`: getter/setter for undo state +- `reset()`: clears all state for new session + +### AIChatPanel + +- `_$msgs()`: live DOM query helper — returns `$(".ai-chat-messages")` to avoid stale cached `$messages` reference (see Implementation Notes) +- `_onToolEdit()`: on first edit per response, inserts initial PUC if not yet created. Diff toggle only (no per-edit undo). +- `_appendEditSummary()`: calls `finalizeResponse()`, creates summary card with "Undo" or "Restore to this point" button +- `_onUndoClick(afterIndex)`: sets `_undoApplied`, resets all buttons to "Restore to this point", restores to `afterIndex - 1`, highlights target element as "Restored", scrolls to it +- `_onRestoreClick(snapshotIndex)`: sets `_undoApplied`, resets all buttons to "Restore to this point", restores to the given snapshot, marks clicked element as "Restored" +- `_setStreaming(streaming)`: disables/enables all restore buttons during AI streaming + +## Verification Cases + +### Case 1: Single response editing 2 files — Undo then Restore +- R1 edits A: "v0" -> "v1", edits B: "b0" -> "b1" +- Snapshots: [0: {A:v0, B:b0}], [1: {A:v1, B:b1}] +- Initial PUC appears (snapshot 0), summary card shows "Undo" (snapshot 1) +- Click "Undo" on summary -> files revert to snapshot 0 (A=v0, B=b0) +- Scroll to initial PUC, highlighted green, button says "Restored". Summary says "Restore to this point" +- Click "Restore to this point" on summary (snapshot 1) -> files forward to A=v1, B=b1 +- Summary now says "Restored", initial PUC says "Restore to this point" + +### Case 2: Two responses — Undo latest +- R1: A "v0"->"v1", R2: A "v1"->"v2" +- Snapshots: [0: {A:v0}], [1: {A:v1}], [2: {A:v2}] +- Card 1 shows "Restore to this point", card 2 shows "Undo" +- Click "Undo" on card 2 -> A="v1" (snapshot 1), card 1 highlighted with "Restored" +- All other buttons become "Restore to this point" + +### Case 3: Two responses — Restore to initial +- Same setup as Case 2 +- Click "Restore to this point" on initial PUC (snapshot 0) -> A="v0" +- Initial PUC shows "Restored", all others show "Restore to this point" + +### Case 4: Restore to middle point +- R1: A "v0"->"v1", R2: A "v1"->"v2", R3: A "v2"->"v3" +- Snapshots: [0: {A:v0}], [1: {A:v1}], [2: {A:v2}], [3: {A:v3}] +- Click "Restore to this point" on card 1 (snapshot 1) -> A="v1", card 1 shows "Restored" +- Click "Restore to this point" on card 2 (snapshot 2) -> A="v2", card 2 shows "Restored", card 1 back to "Restore to this point" +- Click "Restore to this point" on initial PUC (snapshot 0) -> A="v0" + +### Case 5: Two responses editing different files +- R1: A "a0"->"a1", R2: B "b0"->"b1" +- Snapshots: [0: {A:a0}], [1: {A:a1}], [2: {A:a1, B:b1}] +- Back-fill: when B is first seen in R2, snapshot 0 and 1 get B:b0 added +- Click initial PUC (snapshot 0) -> A=a0, B=b0 +- Click card 1 (snapshot 1) -> A=a1, B=b0 (B not yet edited) +- Click card 2 (snapshot 2) -> A=a1, B=b1 + +### Case 6: File created by R1, edited by R2 +- R1 creates A (null -> "new"), R2 edits A: "new"->"edited" +- Snapshots: [0: {A:null}], [1: {A:new}], [2: {A:edited}] +- Click initial PUC (snapshot 0) -> A deleted (hash=null) +- Click card 1 (snapshot 1) -> A="new" (re-created) +- Click card 2 (snapshot 2) -> A="edited" + +### Case 7: File created by R2 +- R1 edits A, R2 creates B +- Snapshots: [0: {A:a0}], [1: {A:a1}], [2: {A:a1, B:new}] +- Back-fill: snapshot 0 and 1 get B:null +- Click initial PUC (snapshot 0) -> A=a0, B deleted +- Click card 1 (snapshot 1) -> A=a1, B deleted (null) +- Click card 2 (snapshot 2) -> A=a1, B=new + +### Case 8: Undo resets on next AI response +- R1 edits A. Click "Undo" -> `_undoApplied = true`, all buttons "Restore to this point" +- User sends new message, R2 edits B +- `_undoApplied` resets to false via `finalizeResponse()` +- New summary card shows "Undo", previous cards show "Restore to this point" + +### Case 9: Response with no edits +- R1 only reads files, no edits +- No initial PUC inserted, no summary card, no restore buttons + +### Case 10: Cancelled partial response +- `_onComplete` fires, `_appendEditSummary()` calls `finalizeResponse()` with partial edits. Works identically to a complete response. + +### Case 11: Buttons disabled during streaming +- User sends message, AI starts streaming with edits +- Initial PUC and all summary card buttons have `disabled` attribute set +- Clicking them does nothing (`_isStreaming` guard in handlers) +- When streaming completes, `_setStreaming(false)` re-enables all buttons + +## Implementation Notes + +### Stale `$messages` reference +The cached `$messages` jQuery variable (set in `_renderChatUI()`) can become stale after `SidebarTabs.addToTab()` reparents the panel. DOM queries via the stale reference silently fail — mutations apply to a detached node instead of the visible DOM. + +**Fix**: `_$msgs()` helper returns `$(".ai-chat-messages")` (live DOM query). Used in all deferred operations: `_onRestoreClick`, `_onUndoClick`, `_setStreaming` (button disable/enable), `_sendMessage` (highlight removal), `_appendEditSummary` (previous button update), and PUC insertion in `_onToolEdit`. + +The cached `$messages` is still used for synchronous operations during rendering (appending messages, streaming updates) where it remains valid. + +## Manual Testing Plan + +1. Reload Phoenix, open AI tab +2. Ask Claude to edit a file (two changes) +3. Verify initial PUC appears before the first Edit tool indicator +4. Verify summary card with "Undo" button appears after response completes +5. Verify all restore buttons are disabled during streaming, enabled after +6. Click "Undo" -> verify file reverts, scroll to initial PUC, highlighted green with "Restored" text +7. Verify all other buttons now show "Restore to this point" +8. Click "Restore to this point" on summary card -> verify file returns to edited state, summary shows "Restored", PUC shows "Restore to this point" +9. Ask Claude to make another edit (second response) +10. Verify first summary card says "Restore to this point", second says "Undo" +11. Click "Undo" on second -> verify files revert to state after first response, first card highlighted with "Restored" +12. Click "Restore to this point" on any card -> verify files match that snapshot, clicked card shows "Restored" +13. Ask Claude a question (no edits) -> verify no PUC or restore buttons appear +14. Start new session -> verify all state cleared diff --git a/src/styles/Extn-AIChatPanel.less b/src/styles/Extn-AIChatPanel.less index ac7b5a067..972c6a744 100644 --- a/src/styles/Extn-AIChatPanel.less +++ b/src/styles/Extn-AIChatPanel.less @@ -324,6 +324,59 @@ &.ai-tool-done .ai-tool-header:hover .ai-tool-label { opacity: 1; } + + // Inline edit actions (diff toggle) + .ai-tool-edit-actions { + display: flex; + align-items: center; + gap: 6px; + padding: 2px 8px 4px 28px; + } + + .ai-tool-diff-toggle { + background: none; + border: none; + font-size: 10px; + color: @project-panel-text-2; + padding: 1px 4px; + cursor: pointer; + opacity: 0.6; + transition: opacity 0.15s ease; + + &:hover { + opacity: 1; + } + } + + .ai-tool-diff { + display: none; + padding: 4px 8px 4px 28px; + font-size: 11px; + font-family: 'SourceCodePro-Medium', 'SourceCodePro', monospace; + line-height: 1.5; + overflow-x: auto; + background-color: rgba(0, 0, 0, 0.15); + + &.expanded { + display: block; + } + + .ai-diff-old { + color: #e88; + background-color: rgba(255, 80, 80, 0.06); + } + + .ai-diff-new { + color: #8c8; + background-color: rgba(80, 200, 80, 0.06); + } + } + + .ai-tool-edit-error { + font-size: 11px; + color: #e88; + padding: 3px 8px 3px 28px; + } } @keyframes ai-spin { @@ -355,22 +408,58 @@ 30% { opacity: 0.8; transform: scale(1); } } -/* ── Edit card ──────────────────────────────────────────────────────── */ -.ai-msg-edit { +/* ── Edit summary card ──────────────────────────────────────────────── */ +.ai-msg-edit-summary { border: 1px solid rgba(255, 255, 255, 0.08); border-radius: 4px; margin-bottom: 6px; - overflow: hidden; + padding: 6px 10px; + background-color: rgba(255, 255, 255, 0.025); - .ai-edit-header { + .ai-edit-summary-header { display: flex; align-items: center; justify-content: space-between; - padding: 4px 8px; - background-color: rgba(255, 255, 255, 0.03); - border-bottom: 1px solid rgba(255, 255, 255, 0.04); + font-size: 11px; + font-weight: 600; + color: @project-panel-text-2; + margin-bottom: 4px; + } + + .ai-edit-restore-btn { + background: none; + border: 1px solid rgba(200, 160, 80, 0.3); + color: rgba(220, 180, 100, 0.9); + font-size: 10px; + padding: 1px 8px; + border-radius: 3px; + cursor: pointer; + flex-shrink: 0; + transition: background-color 0.15s ease, color 0.15s ease, border-color 0.15s ease; + + &:hover { + background-color: rgba(200, 160, 80, 0.08); + } + + &:disabled { + opacity: 0.5; + cursor: default; + } + } + + .ai-edit-summary-file { + display: flex; + align-items: center; + justify-content: space-between; + padding: 2px 0; + cursor: pointer; + transition: background-color 0.15s ease; - .ai-edit-file { + &:hover { + background-color: rgba(255, 255, 255, 0.04); + } + + .ai-edit-summary-name { font-size: 11px; font-family: 'SourceCodePro-Medium', 'SourceCodePro', monospace; color: @project-panel-text-2; @@ -378,8 +467,7 @@ text-overflow: ellipsis; white-space: nowrap; flex: 1; - margin-right: 6px; - cursor: pointer; + margin-right: 8px; &:hover { color: @project-panel-text-1; @@ -387,90 +475,76 @@ } } - .ai-edit-apply-btn { - background: none; - border: 1px solid rgba(100, 200, 100, 0.3); - color: rgba(140, 200, 140, 0.9); + .ai-edit-summary-stats { + display: flex; + gap: 6px; font-size: 10px; - padding: 1px 8px; - border-radius: 3px; - cursor: pointer; + font-family: 'SourceCodePro-Medium', 'SourceCodePro', monospace; flex-shrink: 0; - transition: background-color 0.15s ease; - - &:hover { - background-color: rgba(100, 200, 100, 0.08); - } - - &.applied { - border-color: rgba(100, 200, 100, 0.15); - color: rgba(100, 200, 100, 0.4); - cursor: default; - } - - &.ai-edit-undo-btn { - border-color: rgba(200, 160, 80, 0.3); - color: rgba(220, 180, 100, 0.9); + } - &:hover { - background-color: rgba(200, 160, 80, 0.08); - } + .ai-edit-summary-add { + color: #8c8; + } - &.undone { - border-color: rgba(200, 160, 80, 0.15); - color: rgba(200, 160, 80, 0.4); - cursor: default; - } - } + .ai-edit-summary-del { + color: #e88; } } +} - .ai-edit-toggle { - display: block; - width: 100%; +/* ── Initial restore point (PUC) ────────────────────────────────────── */ +.ai-msg-restore-point { + display: flex; + justify-content: center; + margin-bottom: 6px; + padding: 2px 0; + + .ai-restore-point-btn { background: none; - border: none; - text-align: left; + border: 1px dashed rgba(200, 160, 80, 0.3); + color: rgba(220, 180, 100, 0.7); font-size: 10px; - color: @project-panel-text-2; - padding: 3px 8px; + padding: 2px 14px; + border-radius: 3px; cursor: pointer; - opacity: 0.6; - transition: opacity 0.15s ease; + transition: background-color 0.15s ease, color 0.15s ease, border-color 0.15s ease; - &:hover { - opacity: 1; + &:hover:not(:disabled) { + background-color: rgba(200, 160, 80, 0.08); + border-color: rgba(200, 160, 80, 0.5); + color: rgba(220, 180, 100, 0.9); } - } - .ai-edit-diff { - display: none; - padding: 4px 8px; - font-size: 11px; - font-family: 'SourceCodePro-Medium', 'SourceCodePro', monospace; - line-height: 1.5; - overflow-x: auto; - background-color: rgba(0, 0, 0, 0.15); - - &.expanded { - display: block; + &:disabled { + opacity: 0.35; + cursor: default; } } - .ai-diff-old { - color: #e88; - background-color: rgba(255, 80, 80, 0.06); + &.ai-restore-highlighted { + .ai-restore-point-btn { + border-color: rgba(100, 200, 100, 0.5); + border-style: solid; + color: rgba(140, 200, 140, 0.9); + background-color: rgba(100, 200, 100, 0.06); + } } +} - .ai-diff-new { - color: #8c8; - background-color: rgba(80, 200, 80, 0.06); - } +/* ── Restore highlight on summary cards ────────────────────────────── */ +.ai-msg-edit-summary.ai-restore-highlighted { + border-color: rgba(100, 200, 100, 0.3); + background-color: rgba(100, 200, 100, 0.04); - .ai-edit-error { - font-size: 11px; - color: #e88; - padding: 3px 8px; + .ai-edit-restore-btn { + border-color: rgba(100, 200, 100, 0.4); + color: rgba(140, 200, 140, 0.9); + cursor: default; + + &:hover { + background-color: rgba(100, 200, 100, 0.06); + } } }