diff --git a/src-node/claude-code-agent.js b/src-node/claude-code-agent.js index f779b957e..ded0b0758 100644 --- a/src-node/claude-code-agent.js +++ b/src-node/claude-code-agent.js @@ -364,6 +364,9 @@ async function _runQuery(requestId, prompt, projectPath, model, signal, locale, "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." + + "\n\nWhen the user asks about the current file, open files, or what they are working on, " + + "call getEditorState first — it returns the active file path, working set, cursor position, " + + "and selection. Do NOT search the filesystem to answer these questions blindly." + "\n\nAlways use full absolute paths for all file operations (Read, Edit, Write, " + "controlEditor). Never use relative paths." + "\n\nWhen a tool response mentions the user has typed a clarification, immediately " + diff --git a/src-node/mcp-editor-tools.js b/src-node/mcp-editor-tools.js index d04b698ae..df5866897 100644 --- a/src-node/mcp-editor-tools.js +++ b/src-node/mcp-editor-tools.js @@ -169,17 +169,19 @@ function createEditorMcpServer(sdkModule, nodeConnector, clarificationAccessors) "- close: Close a file (force, no save prompt). Params: filePath\n" + "- openInWorkingSet: Open a file and pin it to the working set. Params: filePath\n" + "- setSelection: Open a file and select a range. Params: filePath, startLine, startCh, endLine, endCh\n" + - "- setCursorPos: Open a file and set cursor position. Params: filePath, line, ch", + "- setCursorPos: Open a file and set cursor position. Params: filePath, line, ch\n" + + "- toggleLivePreview: Show or hide the live preview panel. Params: show (boolean)", { operations: z.array(z.object({ - operation: z.enum(["open", "close", "openInWorkingSet", "setSelection", "setCursorPos"]), - filePath: z.string().describe("Absolute path to the file"), + operation: z.enum(["open", "close", "openInWorkingSet", "setSelection", "setCursorPos", "toggleLivePreview"]), + filePath: z.string().optional().describe("Absolute path to the file (not required for toggleLivePreview)"), startLine: z.number().optional().describe("Start line (1-based) for setSelection"), startCh: z.number().optional().describe("Start column (1-based) for setSelection"), endLine: z.number().optional().describe("End line (1-based) for setSelection"), endCh: z.number().optional().describe("End column (1-based) for setSelection"), line: z.number().optional().describe("Line number (1-based) for setCursorPos"), - ch: z.number().optional().describe("Column (1-based) for setCursorPos") + ch: z.number().optional().describe("Column (1-based) for setCursorPos"), + showPreview: z.boolean().optional().describe("true to show, false to hide live preview (for toggleLivePreview)") })).describe("Array of editor operations to execute sequentially") }, async function (args) { diff --git a/src/brackets.js b/src/brackets.js index ae38ca4d7..3a3e08c73 100644 --- a/src/brackets.js +++ b/src/brackets.js @@ -127,7 +127,6 @@ define(function (require, exports, module) { require("file/FileUtils"); require("project/SidebarView"); require("view/SidebarTabs"); - require("core-ai/main"); require("utils/Resizer"); require("LiveDevelopment/main"); require("utils/NodeConnection"); @@ -297,8 +296,6 @@ define(function (require, exports, module) { SidebarTabs: require("view/SidebarTabs"), SidebarView: require("project/SidebarView"), WorkingSetView: require("project/WorkingSetView"), - AISnapshotStore: require("core-ai/AISnapshotStore"), - AIChatHistory: require("core-ai/AIChatHistory"), doneLoading: false }; diff --git a/src/core-ai/AIChatHistory.js b/src/core-ai/AIChatHistory.js deleted file mode 100644 index 33e23e1c9..000000000 --- a/src/core-ai/AIChatHistory.js +++ /dev/null @@ -1,542 +0,0 @@ -/* - * 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 Chat History — manages storage, loading, and visual restoration of - * past AI chat sessions. Sessions are stored per-project using StateManager - * for metadata and JSON files on disk for full chat history. - */ -define(function (require, exports, module) { - - const StateManager = require("preferences/StateManager"), - ProjectManager = require("project/ProjectManager"), - FileSystem = require("filesystem/FileSystem"), - Strings = require("strings"), - StringUtils = require("utils/StringUtils"), - marked = require("thirdparty/marked.min"); - - const SESSION_HISTORY_KEY = "ai.sessionHistory"; - const MAX_SESSION_HISTORY = 50; - const SESSION_TITLE_MAX_LEN = 80; - - // --- Hash utility (reused from FileRecovery pattern) --- - - function _simpleHash(str) { - let hash = 0; - for (let i = 0; i < str.length; i++) { - const char = str.charCodeAt(i); - // eslint-disable-next-line no-bitwise - hash = ((hash << 5) - hash) + char; - // eslint-disable-next-line no-bitwise - hash = hash & hash; - } - return Math.abs(hash) + ""; - } - - // --- Storage infrastructure --- - - /** - * Get the per-project history directory path. - * @return {string|null} directory path or null if no project is open - */ - function _getHistoryDir() { - const projectRoot = ProjectManager.getProjectRoot(); - if (!projectRoot) { - return null; - } - const fullPath = projectRoot.fullPath; - const baseName = fullPath.split("/").filter(Boolean).pop() || "default"; - const hash = _simpleHash(fullPath); - return Phoenix.VFS.getAppSupportDir() + "aiHistory/" + baseName + "_" + hash + "/"; - } - - /** - * Load session metadata from StateManager (project-scoped). - * @return {Array} array of session metadata objects - */ - function loadSessionHistory() { - return StateManager.get(SESSION_HISTORY_KEY, StateManager.PROJECT_CONTEXT) || []; - } - - /** - * Save session metadata to StateManager (project-scoped). - * @param {Array} history - array of session metadata objects - */ - function _saveSessionHistory(history) { - // Trim to max entries - if (history.length > MAX_SESSION_HISTORY) { - history = history.slice(0, MAX_SESSION_HISTORY); - } - StateManager.set(SESSION_HISTORY_KEY, history, StateManager.PROJECT_CONTEXT); - } - - /** - * Record a session in metadata. Most recent first. - * @param {string} sessionId - * @param {string} title - first user message, truncated - */ - function recordSessionMetadata(sessionId, title) { - const history = loadSessionHistory(); - // Remove existing entry with same id (update case) - const filtered = history.filter(function (h) { return h.id !== sessionId; }); - filtered.unshift({ - id: sessionId, - title: (title || "Untitled").slice(0, SESSION_TITLE_MAX_LEN), - timestamp: Date.now() - }); - _saveSessionHistory(filtered); - } - - /** - * Save full chat history to disk. - * @param {string} sessionId - * @param {Object} data - {id, title, timestamp, messages} - * @param {Function} [callback] - optional callback(err) - */ - function saveChatHistory(sessionId, data, callback) { - const dir = _getHistoryDir(); - if (!dir) { - if (callback) { callback(new Error("No project open")); } - return; - } - Phoenix.VFS.ensureExistsDirAsync(dir) - .then(function () { - const file = FileSystem.getFileForPath(dir + sessionId + ".json"); - file.write(JSON.stringify(data), function (err) { - if (err) { - console.warn("[AI History] Failed to save chat history:", err); - } - if (callback) { callback(err); } - }); - }) - .catch(function (err) { - console.warn("[AI History] Failed to create history dir:", err); - if (callback) { callback(err); } - }); - } - - /** - * Load full chat history from disk. - * @param {string} sessionId - * @param {Function} callback - callback(err, data) - */ - function loadChatHistory(sessionId, callback) { - const dir = _getHistoryDir(); - if (!dir) { - callback(new Error("No project open")); - return; - } - const file = FileSystem.getFileForPath(dir + sessionId + ".json"); - file.read(function (err, content) { - if (err) { - callback(err); - return; - } - try { - callback(null, JSON.parse(content)); - } catch (parseErr) { - callback(parseErr); - } - }); - } - - /** - * Delete a single session's history file and remove from metadata. - * @param {string} sessionId - * @param {Function} [callback] - optional callback() - */ - function deleteSession(sessionId, callback) { - // Remove from metadata - const history = loadSessionHistory(); - const filtered = history.filter(function (h) { return h.id !== sessionId; }); - _saveSessionHistory(filtered); - - // Delete file - const dir = _getHistoryDir(); - if (dir) { - const file = FileSystem.getFileForPath(dir + sessionId + ".json"); - file.unlink(function (err) { - if (err) { - console.warn("[AI History] Failed to delete session file:", err); - } - if (callback) { callback(); } - }); - } else { - if (callback) { callback(); } - } - } - - /** - * Clear all session history (metadata + files). - * @param {Function} [callback] - optional callback() - */ - function clearAllHistory(callback) { - _saveSessionHistory([]); - const dir = _getHistoryDir(); - if (dir) { - const directory = FileSystem.getDirectoryForPath(dir); - directory.unlink(function (err) { - if (err) { - console.warn("[AI History] Failed to delete history dir:", err); - } - if (callback) { callback(); } - }); - } else { - if (callback) { callback(); } - } - } - - // --- Time formatting --- - - /** - * Format a timestamp as a relative time string. - * @param {number} timestamp - * @return {string} - */ - function formatRelativeTime(timestamp) { - const diff = Date.now() - timestamp; - const minutes = Math.floor(diff / 60000); - if (minutes < 1) { - return Strings.AI_CHAT_HISTORY_JUST_NOW; - } - if (minutes < 60) { - return StringUtils.format(Strings.AI_CHAT_HISTORY_MINS_AGO, minutes); - } - const hours = Math.floor(minutes / 60); - if (hours < 24) { - return StringUtils.format(Strings.AI_CHAT_HISTORY_HOURS_AGO, hours); - } - const days = Math.floor(hours / 24); - return StringUtils.format(Strings.AI_CHAT_HISTORY_DAYS_AGO, days); - } - - // --- Visual state restoration --- - - /** - * Inject a copy-to-clipboard button into each
block.
- * Idempotent: skips elements that already have a .ai-copy-btn.
- */
- function _addCopyButtons($container) {
- $container.find("pre").each(function () {
- const $pre = $(this);
- if ($pre.find(".ai-copy-btn").length) {
- return;
- }
- const $btn = $('');
- $btn.on("click", function (e) {
- e.stopPropagation();
- const $code = $pre.find("code");
- const text = ($code.length ? $code[0] : $pre[0]).textContent;
- Phoenix.app.copyToClipboard(text);
- const $icon = $btn.find("i");
- $icon.removeClass("fa-copy").addClass("fa-check");
- $btn.attr("title", Strings.AI_CHAT_COPIED_CODE);
- setTimeout(function () {
- $icon.removeClass("fa-check").addClass("fa-copy");
- $btn.attr("title", Strings.AI_CHAT_COPY_CODE);
- }, 1500);
- });
- $pre.append($btn);
- });
- }
-
- function _escapeAttr(str) {
- return str.replace(/&/g, "&").replace(/"/g, """).replace(//g, ">");
- }
-
- /**
- * Show a lightbox overlay with the full-size image.
- */
- function _showImageLightbox(src, $panel) {
- const $overlay = $(
- '' +
- '
' +
- ''
- );
- $overlay.find("img").attr("src", src);
- $overlay.on("click", function () {
- $overlay.remove();
- });
- $panel.append($overlay);
- }
-
- /**
- * Render restored chat messages into the given $messages container.
- * Creates static (non-interactive) versions of all recorded message types.
- *
- * @param {Array} messages - array of recorded message objects
- * @param {jQuery} $messages - the messages container
- * @param {jQuery} $panel - the panel container (for lightbox)
- */
- function renderRestoredChat(messages, $messages, $panel) {
- if (!messages || !messages.length) {
- return;
- }
-
- let isFirstAssistant = true;
-
- messages.forEach(function (msg) {
- switch (msg.type) {
- case "user":
- _renderRestoredUser(msg, $messages, $panel);
- break;
- case "assistant":
- _renderRestoredAssistant(msg, $messages, isFirstAssistant);
- if (isFirstAssistant) {
- isFirstAssistant = false;
- }
- break;
- case "tool":
- _renderRestoredTool(msg, $messages);
- break;
- case "tool_edit":
- _renderRestoredToolEdit(msg, $messages);
- break;
- case "error":
- _renderRestoredError(msg, $messages);
- break;
- case "question":
- _renderRestoredQuestion(msg, $messages);
- break;
- case "edit_summary":
- _renderRestoredEditSummary(msg, $messages);
- break;
- case "complete":
- // Skip — just a save marker
- break;
- default:
- break;
- }
- });
- }
-
- function _renderRestoredUser(msg, $messages, $panel) {
- const $msg = $(
- '' +
- '' + Strings.AI_CHAT_LABEL_YOU + '' +
- '' +
- ''
- );
- $msg.find(".ai-msg-content").text(msg.text);
- if (msg.images && msg.images.length > 0) {
- const $imgDiv = $('');
- msg.images.forEach(function (img) {
- const $thumb = $('
');
- $thumb.attr("src", img.dataUrl);
- $thumb.on("click", function () {
- _showImageLightbox(img.dataUrl, $panel);
- });
- $imgDiv.append($thumb);
- });
- $msg.find(".ai-msg-content").append($imgDiv);
- }
- $messages.append($msg);
- }
-
- function _renderRestoredAssistant(msg, $messages, isFirst) {
- const $msg = $(
- '' +
- (isFirst ? '' + Strings.AI_CHAT_LABEL_CLAUDE + '' : '') +
- '' +
- ''
- );
- try {
- $msg.find(".ai-msg-content").html(marked.parse(msg.markdown || "", { breaks: true, gfm: true }));
- _addCopyButtons($msg);
- } catch (e) {
- $msg.find(".ai-msg-content").text(msg.markdown || "");
- }
- $messages.append($msg);
- }
-
- // Tool type configuration (duplicated from AIChatPanel for independence)
- const TOOL_CONFIG = {
- Glob: { icon: "fa-solid fa-magnifying-glass", color: "#6b9eff" },
- Grep: { icon: "fa-solid fa-magnifying-glass-location", color: "#6b9eff" },
- Read: { icon: "fa-solid fa-file-lines", color: "#6bc76b" },
- Edit: { icon: "fa-solid fa-pen", color: "#e8a838" },
- Write: { icon: "fa-solid fa-file-pen", color: "#e8a838" },
- Bash: { icon: "fa-solid fa-terminal", color: "#c084fc" },
- Skill: { icon: "fa-solid fa-puzzle-piece", color: "#e0c060" },
- TodoWrite: { icon: "fa-solid fa-list-check", color: "#66bb6a" },
- AskUserQuestion: { icon: "fa-solid fa-circle-question", color: "#66bb6a" },
- Task: { icon: "fa-solid fa-diagram-project", color: "#6b9eff" },
- "mcp__phoenix-editor__getEditorState": { icon: "fa-solid fa-code", color: "#6bc76b" },
- "mcp__phoenix-editor__takeScreenshot": { icon: "fa-solid fa-camera", color: "#c084fc" },
- "mcp__phoenix-editor__execJsInLivePreview": { icon: "fa-solid fa-eye", color: "#66bb6a" },
- "mcp__phoenix-editor__controlEditor": { icon: "fa-solid fa-code", color: "#6bc76b" },
- "mcp__phoenix-editor__resizeLivePreview": { icon: "fa-solid fa-arrows-left-right", color: "#66bb6a" },
- "mcp__phoenix-editor__wait": { icon: "fa-solid fa-hourglass-half", color: "#adb9bd" },
- "mcp__phoenix-editor__getUserClarification": { icon: "fa-solid fa-comment-dots", color: "#6bc76b" }
- };
-
- function _renderRestoredTool(msg, $messages) {
- const config = TOOL_CONFIG[msg.toolName] || { icon: "fa-solid fa-gear", color: "#adb9bd" };
- const icon = msg.icon || config.icon;
- const color = msg.color || config.color;
- const $tool = $(
- '' +
- '' +
- '' +
- '' +
- (msg.elapsed ? '' + msg.elapsed.toFixed(1) + 's' : '') +
- '' +
- ''
- );
- $tool.css("--tool-color", color);
- $tool.find(".ai-tool-label").text(msg.summary || msg.toolName);
- $messages.append($tool);
- }
-
- function _renderRestoredToolEdit(msg, $messages) {
- const color = "#e8a838";
- const fileName = (msg.file || "").split("/").pop();
- const $tool = $(
- '' +
- '' +
- '' +
- '' +
- '' +
- '+' + (msg.linesAdded || 0) + ' ' +
- '-' + (msg.linesRemoved || 0) + '' +
- '' +
- '' +
- ''
- );
- $tool.css("--tool-color", color);
- $tool.find(".ai-tool-label").text("Edit " + fileName);
- $messages.append($tool);
- }
-
- function _renderRestoredError(msg, $messages) {
- const $msg = $(
- '' +
- '' +
- ''
- );
- $msg.find(".ai-msg-content").text(msg.text);
- $messages.append($msg);
- }
-
- function _renderRestoredQuestion(msg, $messages) {
- const questions = msg.questions || [];
- const answers = msg.answers || {};
- if (!questions.length) {
- return;
- }
-
- const $container = $('');
-
- questions.forEach(function (q) {
- const $qBlock = $('');
- const $qText = $('');
- $qText.text(q.question);
- $qBlock.append($qText);
-
- const $options = $('');
- const answerValue = answers[q.question] || "";
-
- q.options.forEach(function (opt) {
- const $opt = $('');
- const $label = $('');
- $label.text(opt.label);
- $opt.append($label);
- if (opt.description) {
- const $desc = $('');
- $desc.text(opt.description);
- $opt.append($desc);
- }
- // Highlight selected option
- if (answerValue === opt.label || answerValue.split(", ").indexOf(opt.label) !== -1) {
- $opt.addClass("selected");
- }
- $options.append($opt);
- });
-
- $qBlock.append($options);
-
- // If answered with a custom "Other" value, show it
- if (answerValue && !q.options.some(function (o) { return o.label === answerValue; })) {
- const isMultiAnswer = answerValue.split(", ").some(function (a) {
- return q.options.some(function (o) { return o.label === a; });
- });
- if (!isMultiAnswer) {
- const $other = $('');
- const $input = $('');
- $input.val(answerValue);
- $other.append($input);
- $qBlock.append($other);
- }
- }
-
- $container.append($qBlock);
- });
-
- $messages.append($container);
- }
-
- function _renderRestoredEditSummary(msg, $messages) {
- const files = msg.files || [];
- const fileCount = files.length;
- const $summary = $('');
- const $header = $(
- '' +
- '' +
- StringUtils.format(Strings.AI_CHAT_FILES_CHANGED, fileCount,
- fileCount === 1 ? Strings.AI_CHAT_FILE_SINGULAR : Strings.AI_CHAT_FILE_PLURAL) +
- '' +
- ''
- );
- $summary.append($header);
-
- files.forEach(function (f) {
- const displayName = (f.file || "").split("/").pop();
- const $file = $(
- '' +
- '' +
- '' +
- '+' + (f.added || 0) + '' +
- '-' + (f.removed || 0) + '' +
- '' +
- ''
- );
- $file.find(".ai-edit-summary-name").text(displayName);
- $summary.append($file);
- });
-
- $messages.append($summary);
- }
-
- // Public API
- exports.loadSessionHistory = loadSessionHistory;
- exports.recordSessionMetadata = recordSessionMetadata;
- exports.saveChatHistory = saveChatHistory;
- exports.loadChatHistory = loadChatHistory;
- exports.deleteSession = deleteSession;
- exports.clearAllHistory = clearAllHistory;
- exports.formatRelativeTime = formatRelativeTime;
- exports.renderRestoredChat = renderRestoredChat;
- exports.SESSION_TITLE_MAX_LEN = SESSION_TITLE_MAX_LEN;
-});
diff --git a/src/core-ai/AIChatPanel.js b/src/core-ai/AIChatPanel.js
deleted file mode 100644
index e73948ebc..000000000
--- a/src/core-ai/AIChatPanel.js
+++ /dev/null
@@ -1,2756 +0,0 @@
-/*
- * 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 Chat Panel — renders the chat UI in the AI sidebar tab, handles streaming
- * responses from Claude Code, and manages edit application to documents.
- */
-define(function (require, exports, module) {
-
- const SidebarTabs = require("view/SidebarTabs"),
- DocumentManager = require("document/DocumentManager"),
- CommandManager = require("command/CommandManager"),
- Commands = require("command/Commands"),
- ProjectManager = require("project/ProjectManager"),
- EditorManager = require("editor/EditorManager"),
- FileSystem = require("filesystem/FileSystem"),
- LiveDevMain = require("LiveDevelopment/main"),
- WorkspaceManager = require("view/WorkspaceManager"),
- Dialogs = require("widgets/Dialogs"),
- SnapshotStore = require("core-ai/AISnapshotStore"),
- PhoenixConnectors = require("core-ai/aiPhoenixConnectors"),
- AIChatHistory = require("core-ai/AIChatHistory"),
- Strings = require("strings"),
- StringUtils = require("utils/StringUtils"),
- marked = require("thirdparty/marked.min");
-
- // Capture at module load time — window.KernalModeTrust is deleted before extensions load
- const _KernalModeTrust = window.KernalModeTrust;
-
- let _nodeConnector = null;
- let _currentEntitlementState = null; // "chat" | "login" | "upsell" | "adminDisabled" | null
- let _isStreaming = false;
- let _queuedMessage = null; // text queued by user while AI is streaming
- let _currentRequestId = null;
- let _segmentText = ""; // text for the current segment only
- let _autoScroll = true;
- let _hasReceivedContent = false; // tracks if we've received any text/tool in current response
- let _currentEdits = []; // edits in current response, for summary card
- let _firstEditInResponse = true; // tracks first edit per response for initial PUC
- let _undoApplied = false; // whether undo/restore has been clicked on any card
- let _sessionError = false; // set when aiError fires; cleared on new send or new session
- // --- AI event trace logging (compact, non-flooding) ---
- let _traceTextChunks = 0;
- let _traceToolStreamCounts = {}; // toolId → count
- let _toolStreamStaleTimer = null; // timer to start rotating activity text
- let _toolStreamRotateTimer = null; // interval for cycling activity phrases
-
- // Context bar state
- let _selectionDismissed = false; // user dismissed selection chip
- let _lastSelectionInfo = null; // {filePath, fileName, startLine, endLine, selectedText}
- let _lastCursorLine = null; // cursor line when no selection
- let _lastCursorFile = null; // file name for cursor chip
- let _cursorDismissed = false; // user dismissed cursor chip
- let _cursorDismissedLine = null; // line that was dismissed
- let _livePreviewActive = false; // live preview panel is open
- let _livePreviewDismissed = false; // user dismissed live preview chip
- let $contextBar; // DOM ref
-
- // Image paste state
- let _attachedImages = []; // [{dataUrl, mediaType, base64Data}]
- const MAX_IMAGES = 10;
- const MAX_IMAGE_BASE64_SIZE = 200 * 1024; // ~200KB base64
- const ALLOWED_IMAGE_TYPES = [
- "image/png", "image/jpeg", "image/gif", "image/webp", "image/svg+xml"
- ];
-
- // Chat history recording state
- let _chatHistory = []; // Array of message records for current session
- let _firstUserMessage = null; // Captured on first send, used as session title
- let _currentSessionId = null; // Browser-side session ID tracker
- let _isResumedSession = false; // Whether current session was resumed from history
- let _lastQuestions = null; // Last AskUserQuestion questions, for recording
-
- // DOM references
- let $panel, $messages, $status, $statusText, $textarea, $sendBtn, $stopBtn, $imagePreview;
- let $aiTabContainer = null;
-
- // 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 =
- '' +
- '' +
- '' + Strings.AI_CHAT_TITLE + '' +
- '' +
- '' +
- '' +
- '' +
- '' +
- '' +
- '' +
- '' +
- '' +
- '' + Strings.AI_CHAT_THINKING + '' +
- '' +
- '' +
- '' +
- '' +
- '' +
- '' +
- '' +
- '' +
- '' +
- '' +
- '';
-
- const UNAVAILABLE_HTML =
- '' +
- '' +
- '' +
- '' + Strings.AI_CHAT_CLI_NOT_FOUND + '' +
- '' +
- '' +
- '' +
- '';
-
- const PLACEHOLDER_HTML =
- '' +
- '' +
- '' +
- '' + Strings.AI_CHAT_TITLE + '' +
- '' +
- '' +
- '' +
- '';
-
- /**
- * Initialize the chat panel with a NodeConnector instance.
- * @param {Object} nodeConnector - NodeConnector for communicating with the node-side Claude agent.
- */
- function init(nodeConnector) {
- _nodeConnector = nodeConnector;
-
- // Wire up events from node side
- _nodeConnector.on("aiTextStream", _onTextStream);
- _nodeConnector.on("aiProgress", _onProgress);
- _nodeConnector.on("aiToolInfo", _onToolInfo);
- _nodeConnector.on("aiToolStream", _onToolStream);
- _nodeConnector.on("aiToolEdit", _onToolEdit);
- _nodeConnector.on("aiError", _onError);
- _nodeConnector.on("aiComplete", _onComplete);
- _nodeConnector.on("aiQuestion", _onQuestion);
- _nodeConnector.on("aiClarificationRead", function (_event, data) {
- // Claude consumed the queued clarification — show it as a user message and remove the bubble
- const images = _queuedMessage ? _queuedMessage.images : [];
- _queuedMessage = null;
- _removeQueueBubble();
- if (data.text || images.length > 0) {
- _appendUserMessage(data.text, images);
- // Record clarification in chat history
- _chatHistory.push({
- type: "user",
- text: data.text,
- images: images.map(function (img) {
- return { dataUrl: img.dataUrl, mediaType: img.mediaType };
- })
- });
- }
- });
-
- // Create container once, add to AI tab
- $aiTabContainer = $('');
- SidebarTabs.addToTab("ai", $aiTabContainer);
-
- // Listen for entitlement changes to refresh UI on login/logout
- const EntitlementsManager = _KernalModeTrust && _KernalModeTrust.EntitlementsManager;
- if (EntitlementsManager) {
- EntitlementsManager.on(EntitlementsManager.EVENT_ENTITLEMENTS_CHANGED, _checkEntitlementAndInit);
- }
-
- // Check entitlements and render appropriate UI
- _checkEntitlementAndInit();
- }
-
- /**
- * Show placeholder UI for non-native (browser) builds.
- */
- function initPlaceholder() {
- $aiTabContainer = $('');
- SidebarTabs.addToTab("ai", $aiTabContainer);
- const $placeholder = $(PLACEHOLDER_HTML);
- $placeholder.find(".ai-download-btn").on("click", function () {
- window.open("https://phcode.io", "_blank");
- });
- $aiTabContainer.empty().append($placeholder);
- }
-
- /**
- * Remove any existing panel content from the AI tab container.
- */
- function _removeCurrentPanel() {
- if ($aiTabContainer) {
- $aiTabContainer.empty();
- }
- // Clear cached DOM references so stale jQuery objects aren't reused
- $panel = null;
- $messages = null;
- $status = null;
- $statusText = null;
- $textarea = null;
- $sendBtn = null;
- $stopBtn = null;
- $imagePreview = null;
- }
-
- /**
- * Gate AI UI behind entitlement checks. Shows login screen if not logged in,
- * upsell screen if no AI plan, or proceeds to CLI availability check if entitled.
- */
- function _checkEntitlementAndInit() {
- const EntitlementsManager = _KernalModeTrust && _KernalModeTrust.EntitlementsManager;
- if (!EntitlementsManager) {
- // No entitlement system (test env or dev) — skip straight to CLI check
- if (_currentEntitlementState !== "chat") {
- _removeCurrentPanel();
- _checkAvailability();
- }
- return;
- }
- if (!EntitlementsManager.isLoggedIn()) {
- if (_currentEntitlementState !== "login") {
- _removeCurrentPanel();
- _renderLoginUI();
- }
- return;
- }
- // TODO: Switch to EntitlementsManager.getAIEntitlement() once AI entitlement is
- // implemented in the backend. For now, reuse liveEdit entitlement as a proxy for
- // "has Pro plan". Once AI entitlement is available, the check should be:
- // EntitlementsManager.getAIEntitlement().then(function (entitlement) {
- // if (entitlement.aiDisabledByAdmin) {
- // _renderAdminDisabledUI();
- // } else if (entitlement.activated) {
- // _checkAvailability();
- // } else if (entitlement.needsLogin) {
- // _renderLoginUI();
- // } else {
- // _renderUpsellUI(entitlement);
- // }
- // });
- EntitlementsManager.getLiveEditEntitlement().then(function (entitlement) {
- if (entitlement.activated) {
- if (_currentEntitlementState !== "chat") {
- _removeCurrentPanel();
- _checkAvailability();
- }
- } else {
- if (_currentEntitlementState !== "upsell") {
- _removeCurrentPanel();
- _renderUpsellUI(entitlement);
- }
- }
- }).catch(function () {
- if (_currentEntitlementState !== "chat") {
- _removeCurrentPanel();
- _checkAvailability(); // fallback on error
- }
- });
- }
-
- /**
- * Render the login prompt UI (user not signed in).
- */
- function _renderLoginUI() {
- _currentEntitlementState = "login";
- const html =
- '' +
- '' +
- '' +
- '' + Strings.AI_CHAT_LOGIN_TITLE + '' +
- '' +
- '' +
- '' +
- '';
- const $login = $(html);
- $login.find(".ai-login-btn").on("click", function () {
- _KernalModeTrust.EntitlementsManager.loginToAccount();
- });
- $aiTabContainer.empty().append($login);
- }
-
- /**
- * Render the upsell UI (user logged in but no AI plan).
- */
- function _renderUpsellUI(entitlement) {
- _currentEntitlementState = "upsell";
- const html =
- '' +
- '' +
- '' +
- '' + Strings.AI_CHAT_UPSELL_TITLE + '' +
- '' +
- '' +
- '' +
- '';
- const $upsell = $(html);
- $upsell.find(".ai-upsell-btn").on("click", function () {
- const url = (entitlement && entitlement.buyURL) || brackets.config.purchase_url;
- Phoenix.app.openURLInDefaultBrowser(url);
- });
- $aiTabContainer.empty().append($upsell);
- }
-
- /**
- * Render the admin-disabled UI (AI turned off by system administrator).
- */
- function _renderAdminDisabledUI() {
- _currentEntitlementState = "adminDisabled";
- const html =
- '' +
- '' +
- '' +
- '' + Strings.AI_CHAT_ADMIN_DISABLED_TITLE + '' +
- '' +
- '' +
- '';
- $aiTabContainer.empty().append($(html));
- }
-
- /**
- * Check if Claude CLI is available and render the appropriate UI.
- */
- function _checkAvailability() {
- _nodeConnector.execPeer("checkAvailability")
- .then(function (result) {
- if (result.available) {
- _renderChatUI();
- } else {
- _renderUnavailableUI(result.error);
- }
- })
- .catch(function (err) {
- _renderUnavailableUI(err.message || String(err));
- });
- }
-
- /**
- * Render the full chat UI.
- */
- function _renderChatUI() {
- _currentEntitlementState = "chat";
- $panel = $(PANEL_HTML);
- $messages = $panel.find(".ai-chat-messages");
- $status = $panel.find(".ai-chat-status");
- $statusText = $panel.find(".ai-status-text");
- $textarea = $panel.find(".ai-chat-textarea");
- $sendBtn = $panel.find(".ai-send-btn");
- $stopBtn = $panel.find(".ai-stop-btn");
- $imagePreview = $panel.find(".ai-chat-image-preview");
-
- // Event handlers
- $sendBtn.on("click", function () {
- if (_isStreaming) {
- _queueMessage();
- } else {
- _sendMessage();
- }
- });
- $stopBtn.on("click", _cancelQuery);
- $panel.find(".ai-new-session-btn").on("click", _newSession);
- $panel.find(".ai-history-btn").on("click", function () {
- _toggleHistoryDropdown();
- });
-
- // Hide "+ New" button initially (no conversation yet)
- $panel.find(".ai-new-session-btn").hide();
-
- $textarea.on("keydown", function (e) {
- if (e.key === "Enter" && !e.shiftKey) {
- e.preventDefault();
- if (_isStreaming) {
- _queueMessage();
- } else {
- _sendMessage();
- }
- }
- if (e.key === "Escape") {
- if (_isStreaming) {
- _cancelQuery();
- } else {
- $textarea.val("");
- }
- }
- });
-
- // Auto-resize textarea
- $textarea.on("input", function () {
- this.style.height = "auto";
- this.style.height = Math.min(this.scrollHeight, 96) + "px"; // max ~6rem
- });
-
- // Paste handler for images
- $textarea.on("paste", function (e) {
- const items = (e.originalEvent || e).clipboardData && (e.originalEvent || e).clipboardData.items;
- if (!items) {
- return;
- }
- let imageFound = false;
- for (let i = 0; i < items.length; i++) {
- const item = items[i];
- if (item.kind === "file" && ALLOWED_IMAGE_TYPES.indexOf(item.type) !== -1) {
- if (_attachedImages.length >= MAX_IMAGES) {
- break;
- }
- imageFound = true;
- const blob = item.getAsFile();
- // Capture mediaType synchronously — DataTransferItem properties
- // become invalid after the paste event handler returns, but
- // the File object's type persists across the async boundary.
- const mediaType = blob.type || item.type;
- const reader = new FileReader();
- reader.onload = function (ev) {
- const dataUrl = ev.target.result;
- const commaIdx = dataUrl.indexOf(",");
- const base64Data = dataUrl.slice(commaIdx + 1);
- if (base64Data.length > MAX_IMAGE_BASE64_SIZE) {
- // Resize oversized images via canvas
- _resizeImage(dataUrl, function (resized) {
- _addImageIfUnique(resized.dataUrl, resized.mediaType, resized.base64Data);
- });
- } else {
- _addImageIfUnique(dataUrl, mediaType, base64Data);
- }
- };
- reader.readAsDataURL(blob);
- }
- }
- if (imageFound) {
- e.preventDefault();
- }
- });
-
- // Track scroll position for auto-scroll
- $messages.on("scroll", function () {
- const el = $messages[0];
- _autoScroll = (el.scrollHeight - el.scrollTop - el.clientHeight) < 50;
- });
-
- // Context bar
- $contextBar = $panel.find(".ai-chat-context-bar");
-
- // Track editor selection/cursor for context chips
- EditorManager.off("activeEditorChange.aiChat");
- EditorManager.on("activeEditorChange.aiChat", function (_event, newEditor, oldEditor) {
- if (oldEditor) {
- oldEditor.off("cursorActivity.aiContext");
- }
- if (newEditor) {
- newEditor.off("cursorActivity.aiContext");
- newEditor.on("cursorActivity.aiContext", function (_evt, editor) {
- _updateSelectionChip(editor);
- });
- }
- _updateSelectionChip(newEditor);
- });
- // Bind to current editor if already active
- const currentEditor = EditorManager.getActiveEditor();
- if (currentEditor) {
- currentEditor.off("cursorActivity.aiContext");
- currentEditor.on("cursorActivity.aiContext", function (_evt, editor) {
- _updateSelectionChip(editor);
- });
- }
- _updateSelectionChip(currentEditor);
-
- // Track live preview status — listen to both LiveDev status changes
- // and panel show/hide events so the chip updates when the panel is closed
- LiveDevMain.off("statusChange.aiChat");
- LiveDevMain.on("statusChange.aiChat", _updateLivePreviewChip);
- LiveDevMain.off(LiveDevMain.EVENT_OPEN_PREVIEW_URL + ".aiChat");
- LiveDevMain.on(LiveDevMain.EVENT_OPEN_PREVIEW_URL + ".aiChat", function () {
- _livePreviewDismissed = false;
- _updateLivePreviewChip();
- });
- WorkspaceManager.off(WorkspaceManager.EVENT_WORKSPACE_PANEL_SHOWN + ".aiChat");
- WorkspaceManager.on(WorkspaceManager.EVENT_WORKSPACE_PANEL_SHOWN + ".aiChat", _updateLivePreviewChip);
- WorkspaceManager.off(WorkspaceManager.EVENT_WORKSPACE_PANEL_HIDDEN + ".aiChat");
- WorkspaceManager.on(WorkspaceManager.EVENT_WORKSPACE_PANEL_HIDDEN + ".aiChat", _updateLivePreviewChip);
- _updateLivePreviewChip();
-
- // Refresh context bar when the AI tab becomes active (DOM updates
- // are deferred while the tab is hidden to avoid layout interference)
- SidebarTabs.off("tabChanged.aiChat");
- SidebarTabs.on("tabChanged.aiChat", function (_event, tabId) {
- if (tabId === "ai") {
- _updateSelectionChip();
- _updateLivePreviewChip();
- }
- });
-
- // When a screenshot is captured, attach the image to the awaiting tool indicator
- PhoenixConnectors.off("screenshotCaptured.aiChat");
- PhoenixConnectors.on("screenshotCaptured.aiChat", function (_event, base64) {
- const $tool = _$msgs().find('.ai-msg-tool').filter(function () {
- return $(this).data("awaitingScreenshot");
- }).last();
- if ($tool.length) {
- $tool.data("awaitingScreenshot", false);
- const $detail = $tool.find(".ai-tool-detail");
- const $img = $('
');
- $img.on("click", function (e) {
- e.stopPropagation();
- _showImageLightbox($img.attr("src"));
- });
- $img.on("load", function () {
- // Force scroll — the image load changes height after insertion,
- // which can cause the scroll listener to clear _autoScroll
- if ($messages && $messages.length) {
- $messages[0].scrollTop = $messages[0].scrollHeight;
- }
- });
- $detail.html($img);
- $tool.addClass("ai-tool-expanded");
- _scrollToBottom();
- }
- });
-
- // When switching projects, warn the user if AI is currently working
- // and cancel the query before proceeding.
- ProjectManager.off("beforeProjectClose.aiChat");
- ProjectManager.on("beforeProjectClose.aiChat", function (_event, _projectRoot, vetoPromises) {
- if (_isStreaming && vetoPromises) {
- const vetoPromise = new Promise(function (resolve, reject) {
- Dialogs.showConfirmDialog(
- Strings.AI_CHAT_SWITCH_PROJECT_TITLE,
- Strings.AI_CHAT_SWITCH_PROJECT_MSG
- ).done(function (btnId) {
- if (btnId === Dialogs.DIALOG_BTN_OK) {
- _cancelQuery();
- _setStreaming(false);
- resolve();
- } else {
- reject();
- }
- });
- });
- vetoPromises.push(vetoPromise);
- }
- });
-
- // When a new project opens, reset the AI chat to a blank state
- ProjectManager.off("projectOpen.aiChat");
- ProjectManager.on("projectOpen.aiChat", function () {
- _newSession();
- });
-
- $aiTabContainer.empty().append($panel);
- }
-
- /**
- * Render the unavailable UI (CLI not found).
- */
- function _renderUnavailableUI(error) {
- _currentEntitlementState = "chat";
- const $unavailable = $(UNAVAILABLE_HTML);
- $unavailable.find(".ai-retry-btn").on("click", function () {
- _checkAvailability();
- });
- $aiTabContainer.empty().append($unavailable);
- }
-
- // --- Context bar chip management ---
-
- /**
- * Update the selection/cursor chip based on the active editor state.
- * Skipped when the AI tab isn't active — calling getSelection()/getSelectedText()
- * from both activeEditorChange and cursorActivity during inline editor operations
- * interferes with the inline editor's cursor position tracking.
- */
- function _updateSelectionChip(editor) {
- if (SidebarTabs.getActiveTab() !== "ai") {
- return;
- }
- if (!editor) {
- editor = EditorManager.getActiveEditor();
- }
- if (!editor) {
- _lastSelectionInfo = null;
- _lastCursorLine = null;
- _lastCursorFile = null;
- _renderContextBar();
- return;
- }
-
- let filePath = editor.document.file.fullPath;
- if (filePath.startsWith("/tauri/")) {
- filePath = filePath.replace("/tauri", "");
- }
- const fileName = filePath.split("/").pop();
-
- if (editor.hasSelection()) {
- const sel = editor.getSelection();
- const startLine = sel.start.line + 1;
- const endLine = sel.end.line + 1;
- const selectedText = editor.getSelectedText();
-
- // Reset dismissed flag when selection changes
- if (!_lastSelectionInfo ||
- _lastSelectionInfo.startLine !== startLine ||
- _lastSelectionInfo.endLine !== endLine ||
- _lastSelectionInfo.filePath !== filePath) {
- _selectionDismissed = false;
- }
-
- _lastSelectionInfo = {
- filePath: filePath,
- fileName: fileName,
- startLine: startLine,
- endLine: endLine,
- selectedText: selectedText
- };
- _lastCursorLine = null;
- _lastCursorFile = null;
- } else {
- const cursor = editor.getCursorPos();
- const cursorLine = cursor.line + 1;
- // Reset cursor dismissed when cursor moves to a different line
- if (_cursorDismissed && _cursorDismissedLine !== cursorLine) {
- _cursorDismissed = false;
- }
- _lastSelectionInfo = null;
- _lastCursorLine = cursorLine;
- _lastCursorFile = fileName;
- }
-
- _renderContextBar();
- }
-
- /**
- * Update the live preview chip based on panel visibility.
- */
- function _updateLivePreviewChip() {
- if (SidebarTabs.getActiveTab() !== "ai") {
- return;
- }
- const panel = WorkspaceManager.getPanelForID("live-preview-panel");
- const wasActive = _livePreviewActive;
- _livePreviewActive = !!(panel && panel.isVisible());
- // Reset dismissed when live preview is re-opened
- if (_livePreviewActive && !wasActive) {
- _livePreviewDismissed = false;
- }
- _renderContextBar();
- }
-
- /**
- * Rebuild the context bar chips from current state.
- */
- function _renderContextBar() {
- if (!$contextBar) {
- return;
- }
- $contextBar.empty();
-
- // Live preview chip
- if (_livePreviewActive && !_livePreviewDismissed) {
- const $lpChip = $(
- '' +
- '' +
- '' + Strings.AI_CHAT_CONTEXT_LIVE_PREVIEW + '' +
- '' +
- ''
- );
- $lpChip.find(".ai-context-chip-close").on("click", function () {
- _livePreviewDismissed = true;
- _renderContextBar();
- });
- $contextBar.append($lpChip);
- }
-
- // Selection or cursor chip
- if (_lastSelectionInfo && !_selectionDismissed) {
- const label = StringUtils.format(Strings.AI_CHAT_CONTEXT_SELECTION,
- _lastSelectionInfo.startLine, _lastSelectionInfo.endLine) +
- " in " + _lastSelectionInfo.fileName;
- const $chip = $(
- '' +
- '' +
- '' +
- '' +
- ''
- );
- $chip.find(".ai-context-chip-label").text(label);
- $chip.find(".ai-context-chip-close").on("click", function () {
- _selectionDismissed = true;
- _renderContextBar();
- });
- $contextBar.append($chip);
- } else if (_lastCursorLine !== null && !_lastSelectionInfo && !_cursorDismissed) {
- const label = StringUtils.format(Strings.AI_CHAT_CONTEXT_CURSOR, _lastCursorLine) +
- " in " + _lastCursorFile;
- const $cursorChip = $(
- '' +
- '' +
- '' +
- '' +
- ''
- );
- $cursorChip.find(".ai-context-chip-label").text(label);
- $cursorChip.find(".ai-context-chip-close").on("click", function () {
- _cursorDismissed = true;
- _cursorDismissedLine = _lastCursorLine;
- _renderContextBar();
- });
- $contextBar.append($cursorChip);
- }
-
- // Toggle visibility
- $contextBar.toggleClass("has-chips", $contextBar.children().length > 0);
- }
-
- /**
- * Add an image to _attachedImages if it's not a duplicate.
- */
- function _addImageIfUnique(dataUrl, mediaType, base64Data) {
- const isDuplicate = _attachedImages.some(function (existing) {
- return existing.base64Data === base64Data;
- });
- if (!isDuplicate && _attachedImages.length < MAX_IMAGES) {
- _attachedImages.push({dataUrl: dataUrl, mediaType: mediaType, base64Data: base64Data});
- _renderImagePreview();
- }
- }
-
- /**
- * Resize an image so its base64 data stays under MAX_IMAGE_BASE64_SIZE.
- * Two-phase strategy using WebP for better quality-per-byte:
- * Phase 1 — reduce quality at original dimensions.
- * Phase 2 — scale dimensions down (75%, then 50%) and retry quality steps.
- */
- function _resizeImage(dataUrl, callback) {
- const img = new Image();
- img.onload = function () {
- const canvas = document.createElement("canvas");
- const ctx = canvas.getContext("2d");
- const qualitySteps = [0.92, 0.85, 0.75, 0.6, 0.45];
- const scaleSteps = [1, 0.75, 0.5];
- let result;
-
- for (let s = 0; s < scaleSteps.length; s++) {
- const scale = scaleSteps[s];
- canvas.width = Math.round(img.width * scale);
- canvas.height = Math.round(img.height * scale);
- ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
-
- for (let q = 0; q < qualitySteps.length; q++) {
- result = canvas.toDataURL("image/webp", qualitySteps[q]);
- if (result.split(",")[1].length <= MAX_IMAGE_BASE64_SIZE) {
- const base64 = result.split(",")[1];
- callback({dataUrl: result, mediaType: "image/webp", base64Data: base64});
- return;
- }
- }
- }
-
- // Last resort: use the smallest result we got
- const base64 = result.split(",")[1];
- callback({dataUrl: result, mediaType: "image/webp", base64Data: base64});
- };
- img.src = dataUrl;
- }
-
- /**
- * Show a lightbox overlay with the full-size image.
- */
- function _showImageLightbox(src) {
- const $overlay = $(
- '' +
- '
' +
- ''
- );
- $overlay.find("img").attr("src", src);
- $overlay.on("click", function () {
- $overlay.remove();
- });
- $panel.append($overlay);
- }
-
- /**
- * Render the image preview strip from _attachedImages.
- */
- function _renderImagePreview() {
- if (!$imagePreview) {
- return;
- }
- $imagePreview.empty();
- if (_attachedImages.length === 0) {
- $imagePreview.removeClass("has-images");
- return;
- }
- $imagePreview.addClass("has-images");
- _attachedImages.forEach(function (img, idx) {
- const $thumb = $(
- '' +
- '
' +
- '' +
- ''
- );
- $thumb.find("img").attr("src", img.dataUrl).on("click", function () {
- _showImageLightbox(img.dataUrl);
- });
- $thumb.find(".ai-image-remove").on("click", function () {
- _attachedImages.splice(idx, 1);
- _renderImagePreview();
- });
- $imagePreview.append($thumb);
- });
- }
-
- /**
- * Queue a clarification message while AI is streaming.
- * Places a static bubble above the input area and captures any attached images.
- */
- function _queueMessage() {
- const text = $textarea.val().trim();
- if (!text && _attachedImages.length === 0) {
- return;
- }
-
- // Capture images for the clarification
- const queuedImages = _attachedImages.slice();
- const imagesPayload = _attachedImages.map(function (img) {
- return { mediaType: img.mediaType, base64Data: img.base64Data };
- });
- _attachedImages = [];
- _renderImagePreview();
-
- // Append to existing queued text or start new
- if (_queuedMessage) {
- if (text) {
- _queuedMessage.text += "\n" + text;
- }
- _queuedMessage.images = _queuedMessage.images.concat(queuedImages);
- _queuedMessage.imagesPayload = _queuedMessage.imagesPayload.concat(imagesPayload);
- } else {
- _queuedMessage = {
- text: text,
- images: queuedImages,
- imagesPayload: imagesPayload
- };
- }
-
- // Send to node side (text + images)
- _nodeConnector.execPeer("queueClarification", {
- text: text,
- images: imagesPayload.length > 0 ? imagesPayload : undefined
- }).catch(function (err) {
- console.warn("[AI UI] Failed to queue clarification:", err.message);
- });
-
- // Update or create queue bubble in the input area
- const $inputArea = $textarea.closest(".ai-chat-input-area");
- let $bubble = $inputArea.find(".ai-queued-msg");
- if ($bubble.length) {
- $bubble.find(".ai-queued-text").text(_queuedMessage.text);
- // Re-render image thumbs
- const $thumbs = $bubble.find(".ai-queued-images");
- $thumbs.empty();
- _queuedMessage.images.forEach(function (img) {
- $thumbs.append($('
').attr("src", img.dataUrl));
- });
- if (_queuedMessage.images.length > 0) {
- $thumbs.show();
- }
- } else {
- $bubble = $(
- '' +
- '' +
- '' + Strings.AI_CHAT_QUEUED + '' +
- '' +
- '' +
- '' +
- '' +
- ''
- );
- $bubble.find(".ai-queued-text").text(_queuedMessage.text);
- if (_queuedMessage.images.length > 0) {
- const $thumbs = $bubble.find(".ai-queued-images");
- _queuedMessage.images.forEach(function (img) {
- $thumbs.append($('
').attr("src", img.dataUrl));
- });
- $thumbs.show();
- }
- $bubble.find(".ai-queued-edit-btn").on("click", _editQueuedMessage);
- $inputArea.prepend($bubble);
- }
-
- // Clear textarea
- $textarea.val("");
- $textarea.css("height", "auto");
- }
-
- /**
- * Remove the queue bubble from the UI.
- */
- function _removeQueueBubble() {
- const $inputArea = $textarea ? $textarea.closest(".ai-chat-input-area") : null;
- if ($inputArea) {
- $inputArea.find(".ai-queued-msg").remove();
- }
- }
-
- /**
- * Edit the queued message: move text back to textarea, restore images, and clear the queue.
- */
- function _editQueuedMessage() {
- if (_queuedMessage) {
- $textarea.val(_queuedMessage.text);
- $textarea.css("height", "auto");
- $textarea[0].style.height = Math.min($textarea[0].scrollHeight, 96) + "px";
- $textarea[0].focus({ preventScroll: true });
- // Restore images
- _attachedImages = _queuedMessage.images;
- _renderImagePreview();
- }
- _queuedMessage = null;
- _removeQueueBubble();
- _nodeConnector.execPeer("clearClarification").catch(function () {
- // ignore
- });
- }
-
- /**
- * Send the current input as a message to Claude.
- */
- function _sendMessage() {
- const text = $textarea.val().trim();
- if (!text || _isStreaming) {
- return;
- }
-
- // Show "+ New" button once a conversation starts
- $panel.find(".ai-new-session-btn").show();
-
- // Capture first user message for session title
- if (!_currentSessionId && !_isResumedSession && !_firstUserMessage) {
- _firstUserMessage = text;
- }
-
- // Capture attached images before clearing
- const imagesForDisplay = _attachedImages.slice();
- const imagesPayload = _attachedImages.map(function (img) {
- return {mediaType: img.mediaType, base64Data: img.base64Data};
- });
- _attachedImages = [];
- _renderImagePreview();
-
- // Append user message
- _appendUserMessage(text, imagesForDisplay);
-
- // Record user message in chat history
- _chatHistory.push({
- type: "user",
- text: text,
- images: imagesForDisplay.map(function (img) {
- return { dataUrl: img.dataUrl, mediaType: img.mediaType };
- })
- });
-
- // Clear input
- $textarea.val("");
- $textarea.css("height", "auto");
-
- // Set streaming state
- _sessionError = false;
- _setStreaming(true);
-
- // Reset segment tracking and show thinking indicator
- _segmentText = "";
- _hasReceivedContent = false;
- _currentEdits = [];
- _firstEditInResponse = true;
- SnapshotStore.startTracking();
- _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));
-
- // Gather selection context if available and not dismissed
- let selectionContext = null;
- if (_lastSelectionInfo && !_selectionDismissed && _lastSelectionInfo.selectedText) {
- const MAX_INLINE_SELECTION = 500;
- const MAX_PREVIEW_LINES = 3;
- const MAX_PREVIEW_LINE_LEN = 80;
- let selectedText = _lastSelectionInfo.selectedText;
- let selectionPreview = null;
- if (selectedText.length > MAX_INLINE_SELECTION) {
- const lines = selectedText.split("\n");
- const headLines = lines.slice(0, MAX_PREVIEW_LINES).map(function (l) {
- return l.length > MAX_PREVIEW_LINE_LEN ? l.slice(0, MAX_PREVIEW_LINE_LEN) + "..." : l;
- });
- const tailLines = lines.length > MAX_PREVIEW_LINES * 2
- ? lines.slice(-MAX_PREVIEW_LINES).map(function (l) {
- return l.length > MAX_PREVIEW_LINE_LEN ? l.slice(0, MAX_PREVIEW_LINE_LEN) + "..." : l;
- })
- : [];
- selectionPreview = headLines.join("\n") +
- (tailLines.length ? "\n...\n" + tailLines.join("\n") : "");
- selectedText = null;
- }
- selectionContext = {
- filePath: _lastSelectionInfo.filePath,
- startLine: _lastSelectionInfo.startLine,
- endLine: _lastSelectionInfo.endLine,
- selectedText: selectedText,
- selectionPreview: selectionPreview
- };
- }
-
- _nodeConnector.execPeer("sendPrompt", {
- prompt: prompt,
- projectPath: projectPath,
- sessionAction: "continue",
- locale: brackets.getLocale(),
- selectionContext: selectionContext,
- images: imagesPayload.length > 0 ? imagesPayload : undefined
- }).then(function (result) {
- _currentRequestId = result.requestId;
- console.log("[AI UI] RequestId:", result.requestId);
- }).catch(function (err) {
- _setStreaming(false);
- _appendErrorMessage(StringUtils.format(Strings.AI_CHAT_SEND_ERROR, err.message || String(err)));
- });
- }
-
- /**
- * Cancel the current streaming query.
- */
- function _cancelQuery() {
- if (_nodeConnector && _isStreaming) {
- _nodeConnector.execPeer("cancelQuery").catch(function () {
- // ignore cancel errors
- });
- }
- // Move queued text and images back to textarea on cancel
- if (_queuedMessage) {
- $textarea.val(_queuedMessage.text);
- $textarea.css("height", "auto");
- $textarea[0].style.height = Math.min($textarea[0].scrollHeight, 96) + "px";
- _attachedImages = _queuedMessage.images;
- _renderImagePreview();
- _queuedMessage = null;
- _removeQueueBubble();
- }
- }
-
- /**
- * Start a new session: destroy server-side session and clear chat.
- */
- function _newSession() {
- if (_nodeConnector) {
- _nodeConnector.execPeer("destroySession").catch(function () {
- // ignore
- });
- }
- _currentRequestId = null;
- _segmentText = "";
- _hasReceivedContent = false;
- _isStreaming = false;
- _sessionError = false;
- _queuedMessage = null;
- _removeQueueBubble();
- _firstEditInResponse = true;
- _undoApplied = false;
- _currentSessionId = null;
- _isResumedSession = false;
- _firstUserMessage = null;
- _chatHistory = [];
- _lastQuestions = null;
- _selectionDismissed = false;
- _lastSelectionInfo = null;
- _lastCursorLine = null;
- _lastCursorFile = null;
- _cursorDismissed = false;
- _cursorDismissedLine = null;
- _livePreviewDismissed = false;
- _attachedImages = [];
- _renderImagePreview();
- SnapshotStore.reset();
- PhoenixConnectors.clearPreviousContentMap();
- if ($messages) {
- $messages.empty();
- }
- // Close history dropdown and hide "+ New" button since we're back to empty state
- if ($panel) {
- $panel.find(".ai-session-history-dropdown").removeClass("open");
- $panel.find(".ai-history-btn").removeClass("active");
- $panel.removeClass("ai-history-open");
- $panel.find(".ai-new-session-btn").hide();
- }
- if ($status) {
- $status.removeClass("active");
- }
- if ($textarea) {
- $textarea.prop("disabled", false);
- $textarea[0].focus({ preventScroll: true });
- }
- if ($sendBtn) {
- $sendBtn.prop("disabled", false);
- }
- }
-
- // --- 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;
- $messages.find(".ai-thinking").remove();
- }
-
- // If no active stream target exists, create a new text segment
- if (!$messages.find(".ai-stream-target").length) {
- _appendAssistantSegment();
- }
-
- _segmentText += data.text;
- _renderAssistantStream();
- }
-
- // Tool type configuration: icon, color, label
- const TOOL_CONFIG = {
- Glob: { icon: "fa-solid fa-magnifying-glass", color: "#6b9eff", label: Strings.AI_CHAT_TOOL_SEARCH_FILES },
- Grep: { icon: "fa-solid fa-magnifying-glass-location", color: "#6b9eff", label: Strings.AI_CHAT_TOOL_SEARCH_CODE },
- Read: { icon: "fa-solid fa-file-lines", color: "#6bc76b", label: Strings.AI_CHAT_TOOL_READ },
- Edit: { icon: "fa-solid fa-pen", color: "#e8a838", label: Strings.AI_CHAT_TOOL_EDIT },
- Write: { icon: "fa-solid fa-file-pen", color: "#e8a838", label: Strings.AI_CHAT_TOOL_WRITE },
- Bash: { icon: "fa-solid fa-terminal", color: "#c084fc", label: Strings.AI_CHAT_TOOL_RUN_CMD },
- Skill: { icon: "fa-solid fa-puzzle-piece", color: "#e0c060", label: Strings.AI_CHAT_TOOL_SKILL },
- "mcp__phoenix-editor__getEditorState": { icon: "fa-solid fa-code", color: "#6bc76b", label: Strings.AI_CHAT_TOOL_EDITOR_STATE },
- "mcp__phoenix-editor__takeScreenshot": { icon: "fa-solid fa-camera", color: "#c084fc", label: Strings.AI_CHAT_TOOL_SCREENSHOT },
- "mcp__phoenix-editor__execJsInLivePreview": { icon: "fa-solid fa-eye", color: "#66bb6a", label: Strings.AI_CHAT_TOOL_LIVE_PREVIEW_JS },
- "mcp__phoenix-editor__controlEditor": { icon: "fa-solid fa-code", color: "#6bc76b", label: Strings.AI_CHAT_TOOL_CONTROL_EDITOR },
- "mcp__phoenix-editor__resizeLivePreview": { icon: "fa-solid fa-arrows-left-right", color: "#66bb6a", label: Strings.AI_CHAT_TOOL_RESIZE_PREVIEW },
- "mcp__phoenix-editor__wait": { icon: "fa-solid fa-hourglass-half", color: "#adb9bd", label: Strings.AI_CHAT_TOOL_WAIT },
- "mcp__phoenix-editor__getUserClarification": { icon: "fa-solid fa-comment-dots", color: "#6bc76b", label: Strings.AI_CHAT_TOOL_CLARIFICATION },
- TodoWrite: { icon: "fa-solid fa-list-check", color: "#66bb6a", label: Strings.AI_CHAT_TOOL_TASKS },
- AskUserQuestion: { icon: "fa-solid fa-circle-question", color: "#66bb6a", label: Strings.AI_CHAT_TOOL_QUESTION },
- Task: { icon: "fa-solid fa-diagram-project", color: "#6b9eff", label: Strings.AI_CHAT_TOOL_TASK }
- };
-
- 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];
- $statusText.text(config ? config.label + "..." : Strings.AI_CHAT_THINKING);
- }
- if (data.phase === "tool_use") {
- _appendToolIndicator(data.toolName, data.toolId);
- }
- }
-
- 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);
-
- // Capture content of files the AI reads (for snapshot delete tracking)
- if (data.toolName === "Read" && data.toolInput && data.toolInput.file_path) {
- const filePath = data.toolInput.file_path;
- const vfsPath = SnapshotStore.realToVfsPath(filePath);
- const openDoc = DocumentManager.getOpenDocumentForPath(vfsPath);
- if (openDoc) {
- SnapshotStore.recordFileRead(filePath, openDoc.getText());
- } else {
- const file = FileSystem.getFileForPath(vfsPath);
- file.read(function (err, readData) {
- if (!err && readData) {
- SnapshotStore.recordFileRead(filePath, readData);
- }
- });
- }
- }
- }
-
- /**
- * Start an elapsed-time counter on a tool indicator. Called when the tool's
- * stale timer fires (no streaming activity for 2s).
- */
- function _startElapsedTimer($tool) {
- if ($tool.data("elapsedTimer")) {
- return; // already running
- }
- const startTime = $tool.data("startTime") || Date.now();
- const $header = $tool.find(".ai-tool-header");
- let $elapsed = $header.find(".ai-tool-elapsed");
- if (!$elapsed.length) {
- $elapsed = $('');
- $header.append($elapsed);
- }
- function update() {
- const secs = Math.floor((Date.now() - startTime) / 1000);
- if (secs < 60) {
- $elapsed.text(secs + "s");
- } else {
- const m = Math.floor(secs / 60);
- const s = secs % 60;
- $elapsed.text(m + "m " + (s < 10 ? "0" : "") + s + "s");
- }
- }
- update();
- const timerId = setInterval(function () {
- if ($tool.hasClass("ai-tool-done")) {
- clearInterval(timerId);
- return;
- }
- update();
- }, 1000);
- $tool.data("elapsedTimer", timerId);
- }
-
- 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;
- }
-
- // Update label with filename as soon as file_path is available
- if (!$tool.data("labelUpdated")) {
- const filePath = _extractJsonStringValue(data.partialJson, "file_path");
- if (filePath) {
- const fileName = filePath.split("/").pop();
- const config = TOOL_CONFIG[data.toolName] || {};
- $tool.find(".ai-tool-label").text((config.label || data.toolName) + " " + fileName + "...");
- $tool.data("labelUpdated", true);
- }
- }
-
- 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();
- }
-
- // Reset staleness timer — if no new stream event arrives within 2s,
- // rotate through activity phrases so the user sees something is happening.
- clearTimeout(_toolStreamStaleTimer);
- clearInterval(_toolStreamRotateTimer);
- _toolStreamStaleTimer = setTimeout(function () {
- const phrases = [
- Strings.AI_CHAT_WORKING,
- Strings.AI_CHAT_WRITING,
- Strings.AI_CHAT_PROCESSING
- ];
- let idx = 0;
- const $livePreview = $tool.find(".ai-tool-preview");
- if ($livePreview.length && !$tool.hasClass("ai-tool-done")) {
- $livePreview.text(phrases[idx]);
- }
- _startElapsedTimer($tool);
- _toolStreamRotateTimer = setInterval(function () {
- idx = (idx + 1) % phrases.length;
- const $p = $tool.find(".ai-tool-preview");
- if ($p.length && !$tool.hasClass("ai-tool-done")) {
- $p.text(phrases[idx]);
- } else {
- clearInterval(_toolStreamRotateTimer);
- }
- }, 3000);
- }, 2000);
- }
-
- /**
- * Extract a complete string value for a given key from partial JSON.
- * Returns null if the key isn't found or the value isn't complete yet.
- */
- function _extractJsonStringValue(partialJson, key) {
- // Try both with and without space after colon: "key":"val" or "key": "val"
- let pattern = '"' + key + '":"';
- let idx = partialJson.indexOf(pattern);
- if (idx === -1) {
- pattern = '"' + key + '": "';
- idx = partialJson.indexOf(pattern);
- }
- if (idx === -1) {
- return null;
- }
- const start = idx + pattern.length;
- // Find the closing quote (not escaped)
- let end = start;
- while (end < partialJson.length) {
- if (partialJson[end] === '"' && partialJson[end - 1] !== '\\') {
- return partialJson.slice(start, end).replace(/\\"/g, '"').replace(/\\\\/g, '\\');
- }
- end++;
- }
- return null; // value not complete yet
- }
-
- /**
- * Extract a readable one-line preview from partial tool input JSON.
- * Looks for the "interesting" key per tool type (e.g. content for Write).
- */
- function _extractToolPreview(toolName, partialJson) {
- if (!partialJson) {
- return "";
- }
- // 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",
- Bash: "command",
- Grep: "pattern",
- Glob: "pattern",
- "mcp__phoenix-editor__execJsInLivePreview": "code"
- }[toolName];
-
- if (!interestingKey) {
- return "";
- }
-
- let raw = "";
- // 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 StringUtils.format(Strings.AI_CHAT_RECEIVING_BYTES, partialJson.length);
- }
- if (!raw) {
- return "";
- }
- // Clean up JSON syntax noise into readable text
- let preview = raw
- .replace(/\\n/g, " ")
- .replace(/\\t/g, " ")
- .replace(/\\"/g, '"')
- .replace(/\s+/g, " ")
- .trim();
- // Strip leading JSON artifacts (quotes, whitespace)
- preview = preview.replace(/^[\s"]+/, "");
- // Strip trailing incomplete JSON artifacts
- preview = preview.replace(/["{}\[\]]*$/, "").trim();
- return preview;
- }
-
- 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
- });
-
- // Record tool edit in chat history
- _chatHistory.push({
- type: "tool_edit",
- file: edit.file,
- linesAdded: newLines,
- linesRemoved: oldLines
- });
-
- // Capture pre-edit content for snapshot tracking
- const previousContent = PhoenixConnectors.getPreviousContent(edit.file);
- const isNewFile = (edit.oldText === null && (previousContent === undefined || previousContent === ""));
-
- // On first edit per response, insert initial PUC if needed.
- // Create initial snapshot *before* recordFileBeforeEdit so it pushes
- // an empty {} that recordFileBeforeEdit will back-fill directly.
- if (_firstEditInResponse) {
- _firstEditInResponse = false;
- if (SnapshotStore.getSnapshotCount() === 0) {
- 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);
- }
- }
- }
-
- // Record pre-edit content into pending snapshot and back-fill
- SnapshotStore.recordFileBeforeEdit(edit.file, previousContent, isNewFile);
-
- // 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") ? Strings.AI_CHAT_HIDE_DIFF : Strings.AI_CHAT_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));
- _sessionError = true;
- _appendErrorMessage(data.error);
- // Record error in chat history
- _chatHistory.push({ type: "error", text: data.error });
- // Don't stop streaming — the node side may continue (partial results)
- }
-
- async function _onComplete(_event, data) {
- console.log("[AI UI]", "Complete. textChunks=" + _traceTextChunks,
- "toolStreams=" + JSON.stringify(_traceToolStreamCounts));
- // Reset trace counters for next query
- _traceTextChunks = 0;
- _traceToolStreamCounts = {};
-
- // Record finalized text segment before completing
- if (_segmentText) {
- const isFirst = !_chatHistory.some(function (m) { return m.type === "assistant"; });
- _chatHistory.push({ type: "assistant", markdown: _segmentText, isFirst: isFirst });
- }
-
- // Append edit summary if there were edits (finalizeResponse called inside)
- if (_currentEdits.length > 0) {
- // Record edit summary in chat history
- 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;
- });
- _chatHistory.push({
- type: "edit_summary",
- files: fileOrder.map(function (f) {
- return { file: f, added: fileStats[f].added, removed: fileStats[f].removed };
- })
- });
- await _appendEditSummary();
- }
-
- SnapshotStore.stopTracking();
- _setStreaming(false);
-
- // Save session to history
- if (data.sessionId) {
- _currentSessionId = data.sessionId;
- const firstUserMsg = _chatHistory.find(function (m) { return m.type === "user"; });
- const sessionTitle = (firstUserMsg && firstUserMsg.text)
- ? firstUserMsg.text.slice(0, AIChatHistory.SESSION_TITLE_MAX_LEN)
- : "Untitled";
- // Record/update metadata (moves to top of history list)
- AIChatHistory.recordSessionMetadata(data.sessionId, sessionTitle);
- _firstUserMessage = null;
- // Remove any trailing "complete" markers before adding new one
- while (_chatHistory.length > 0 && _chatHistory[_chatHistory.length - 1].type === "complete") {
- _chatHistory.pop();
- }
- _chatHistory.push({ type: "complete" });
- AIChatHistory.saveChatHistory(data.sessionId, {
- id: data.sessionId,
- title: sessionTitle,
- timestamp: Date.now(),
- messages: _chatHistory
- });
- }
-
- // Fatal error (e.g. process exit code 1) — disable inputs, show "New Chat"
- if (_sessionError && !data.sessionId) {
- $textarea.prop("disabled", true);
- $textarea.closest(".ai-chat-input-wrap").addClass("disabled");
- $sendBtn.prop("disabled", true);
- // Move queued text back to textarea for user to reuse after new session
- if (_queuedMessage) {
- $textarea.val(_queuedMessage.text);
- _attachedImages = _queuedMessage.images;
- _renderImagePreview();
- _queuedMessage = null;
- _removeQueueBubble();
- }
- // Append inline "New Chat" button below the error
- const $newChat = $(
- '' +
- '' +
- ''
- );
- $newChat.find(".ai-error-new-chat-btn").on("click", function () {
- // Preserve textarea content and images across the new session
- const savedText = $textarea.val();
- const savedImages = _attachedImages.slice();
- $newChat.remove();
- _newSession();
- $textarea.prop("disabled", false);
- $textarea.closest(".ai-chat-input-wrap").removeClass("disabled");
- $sendBtn.prop("disabled", false);
- $textarea.val(savedText);
- _attachedImages = savedImages;
- _renderImagePreview();
- $textarea[0].focus({ preventScroll: true });
- });
- $messages.append($newChat);
- _scrollToBottom();
- return;
- }
-
- // If user had a queued message, auto-send it as the next turn
- if (_queuedMessage) {
- const pending = _queuedMessage;
- _queuedMessage = null;
- _removeQueueBubble();
- $textarea.val(pending.text);
- _attachedImages = pending.images;
- _renderImagePreview();
- _sendMessage();
- }
- }
-
- /**
- * Append a compact summary card showing all files modified during this response.
- */
- async function _appendEditSummary() {
- // Finalize snapshot and get the after-snapshot index
- const afterIndex = await SnapshotStore.finalizeResponse();
- _undoApplied = false;
-
- // 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 = $(
- '' +
- '' +
- StringUtils.format(Strings.AI_CHAT_FILES_CHANGED, fileCount,
- fileCount === 1 ? Strings.AI_CHAT_FILE_SINGULAR : Strings.AI_CHAT_FILE_PLURAL) +
- '' +
- ''
- );
-
- if (afterIndex >= 0) {
- // Update any previous summary card buttons to say "Restore to this point"
- _$msgs().find('.ai-edit-restore-btn').text(Strings.AI_CHAT_RESTORE_POINT)
- .attr("title", Strings.AI_CHAT_RESTORE_TITLE)
- .data("action", "restore");
-
- // Determine button label: "Undo" if not undone, else "Restore to this point"
- const isUndo = !_undoApplied;
- const label = isUndo ? Strings.AI_CHAT_UNDO : Strings.AI_CHAT_RESTORE_POINT;
- const title = isUndo ? Strings.AI_CHAT_UNDO_TITLE : Strings.AI_CHAT_RESTORE_TITLE;
-
- const $restoreBtn = $(
- ''
- );
- $restoreBtn.data("action", isUndo ? "undo" : "restore");
- $restoreBtn.on("click", function (e) {
- e.stopPropagation();
- if (_isStreaming) {
- return;
- }
- if ($(this).data("action") === "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
- _undoApplied = true;
-
- // Reset all buttons to "Restore to this point"
- $msgs.find('.ai-edit-restore-btn').each(function () {
- $(this).text(Strings.AI_CHAT_RESTORE_POINT)
- .attr("title", Strings.AI_CHAT_RESTORE_TITLE)
- .data("action", "restore");
- });
- $msgs.find('.ai-restore-point-btn').text(Strings.AI_CHAT_RESTORE_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(Strings.AI_CHAT_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();
- _undoApplied = true;
- const targetIndex = afterIndex - 1;
-
- // Reset all buttons to "Restore to this point"
- $msgs.find('.ai-edit-restore-btn').each(function () {
- $(this).text(Strings.AI_CHAT_RESTORE_POINT)
- .attr("title", Strings.AI_CHAT_RESTORE_TITLE)
- .data("action", "restore");
- });
- $msgs.find('.ai-restore-point-btn').text(Strings.AI_CHAT_RESTORE_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(Strings.AI_CHAT_RESTORED);
- }
- });
- }
-
- /**
- * Handle an AskUserQuestion tool call — render interactive question UI.
- */
- function _onQuestion(_event, data) {
- const questions = data.questions || [];
- if (!questions.length) {
- return;
- }
-
- // Capture questions for history recording (answers recorded in _sendQuestionAnswers)
- _lastQuestions = questions;
-
- // Remove thinking indicator on first content
- if (!_hasReceivedContent) {
- _hasReceivedContent = true;
- $messages.find(".ai-thinking").remove();
- }
-
- // Finalize current text segment so question appears after it
- $messages.find(".ai-stream-target").removeClass("ai-stream-target");
- _segmentText = "";
-
- const answers = {};
- const totalQuestions = questions.length;
- let answeredCount = 0;
-
- const $container = $('');
-
- questions.forEach(function (q) {
- const $qBlock = $('');
- const $qText = $('');
- $qText.text(q.question);
- $qBlock.append($qText);
-
- const $options = $('');
-
- q.options.forEach(function (opt) {
- const $opt = $('');
- const $label = $('');
- $label.text(opt.label);
- $opt.append($label);
- if (opt.description) {
- const $desc = $('');
- $desc.text(opt.description);
- $opt.append($desc);
- }
-
- $opt.on("click", function () {
- if ($qBlock.data("answered")) {
- return;
- }
- if (q.multiSelect) {
- $opt.toggleClass("selected");
- } else {
- // Single select — answer immediately
- $qBlock.data("answered", true);
- $options.find(".ai-question-option").prop("disabled", true);
- $opt.addClass("selected");
- $qBlock.find(".ai-question-other").hide();
- answers[q.question] = opt.label;
- answeredCount++;
- if (answeredCount >= totalQuestions) {
- _sendQuestionAnswers(answers);
- }
- }
- });
- $options.append($opt);
- });
-
- // Multi-select submit button
- if (q.multiSelect) {
- const $submit = $('');
- $submit.on("click", function () {
- if ($qBlock.data("answered")) {
- return;
- }
- const selected = [];
- $options.find(".ai-question-option.selected").each(function () {
- selected.push($(this).find(".ai-question-option-label").text());
- });
- if (!selected.length) {
- return;
- }
- $qBlock.data("answered", true);
- $options.find(".ai-question-option").prop("disabled", true);
- $submit.prop("disabled", true);
- $qBlock.find(".ai-question-other").hide();
- answers[q.question] = selected.join(", ");
- answeredCount++;
- if (answeredCount >= totalQuestions) {
- _sendQuestionAnswers(answers);
- }
- });
- $options.append($submit);
- }
-
- $qBlock.append($options);
-
- // "Other" free-text input
- const $other = $('');
- const $input = $('');
- const $sendOther = $('');
- function submitOther() {
- const val = $input.val().trim();
- if (!val || $qBlock.data("answered")) {
- return;
- }
- $qBlock.data("answered", true);
- $options.find(".ai-question-option").prop("disabled", true);
- $input.prop("disabled", true);
- $sendOther.prop("disabled", true);
- answers[q.question] = val;
- answeredCount++;
- if (answeredCount >= totalQuestions) {
- _sendQuestionAnswers(answers);
- }
- }
- $sendOther.on("click", submitOther);
- $input.on("keydown", function (e) {
- if (e.key === "Enter") {
- submitOther();
- }
- });
- $other.append($input).append($sendOther);
- $qBlock.append($other);
-
- $container.append($qBlock);
- });
-
- $messages.append($container);
- _scrollToBottom();
- }
-
- /**
- * Send collected question answers to the node side.
- */
- function _sendQuestionAnswers(answers) {
- // Record question + answers in chat history
- if (_lastQuestions) {
- _chatHistory.push({
- type: "question",
- questions: _lastQuestions,
- answers: answers
- });
- _lastQuestions = null;
- }
- _nodeConnector.execPeer("answerQuestion", { answers: answers }).catch(function (err) {
- console.warn("[AI UI] Failed to send question answer:", err.message);
- });
- }
-
- // --- DOM helpers ---
-
- function _appendUserMessage(text, images) {
- const $msg = $(
- '' +
- '' + Strings.AI_CHAT_LABEL_YOU + '' +
- '' +
- ''
- );
- $msg.find(".ai-msg-content").text(text);
- if (images && images.length > 0) {
- const $imgDiv = $('');
- images.forEach(function (img) {
- const $thumb = $('
');
- $thumb.attr("src", img.dataUrl);
- $thumb.on("click", function () {
- _showImageLightbox(img.dataUrl);
- });
- $imgDiv.append($thumb);
- });
- $msg.find(".ai-msg-content").append($imgDiv);
- }
- $messages.append($msg);
- _scrollToBottom();
- }
-
- /**
- * Append a thinking/typing indicator while waiting for first content.
- */
- function _appendThinkingIndicator() {
- const $thinking = $(
- '' +
- '' + Strings.AI_CHAT_LABEL_CLAUDE + '' +
- '' +
- '' +
- '' +
- '' +
- '' +
- ''
- );
- $messages.append($thinking);
- _scrollToBottom();
- }
-
- /**
- * Append a new assistant text segment. Creates a fresh content block
- * that subsequent text deltas will stream into. Shows the "Claude" label
- * only for the first segment in a response.
- */
- function _appendAssistantSegment() {
- // Check if this is a continuation (there's already assistant content or tools above)
- const isFirst = !$messages.find(".ai-msg-assistant").not(".ai-thinking").length;
- const $msg = $(
- '' +
- (isFirst ? 'Claude' : '') +
- '' +
- ''
- );
- $messages.append($msg);
- }
-
- /**
- * Re-render the current streaming segment from accumulated segment text.
- */
- function _renderAssistantStream() {
- const $target = $messages.find(".ai-stream-target").last();
- if ($target.length) {
- try {
- $target.html(marked.parse(_segmentText, { breaks: true, gfm: true }));
- _enhanceColorCodes($target);
- _addCopyButtons($target);
- } catch (e) {
- $target.text(_segmentText);
- }
- _scrollToBottom();
- }
- }
-
- const HEX_COLOR_RE = /#[a-f0-9]{3,8}\b/gi;
-
- /**
- * Scan text nodes inside an element for hex color codes and wrap each
- * with an inline swatch element showing the actual color.
- */
- function _enhanceColorCodes($el) {
- // Walk text nodes inside the element, but skip blocks and already-enhanced swatches
- const walker = document.createTreeWalker(
- $el[0],
- NodeFilter.SHOW_TEXT,
- {
- acceptNode: function (node) {
- const parent = node.parentNode;
- if (parent.closest && parent.closest("pre, .ai-color-swatch")) {
- return NodeFilter.FILTER_REJECT;
- }
- return HEX_COLOR_RE.test(node.nodeValue)
- ? NodeFilter.FILTER_ACCEPT
- : NodeFilter.FILTER_REJECT;
- }
- }
- );
-
- const textNodes = [];
- let n;
- while ((n = walker.nextNode())) {
- textNodes.push(n);
- }
-
- textNodes.forEach(function (textNode) {
- const text = textNode.nodeValue;
- HEX_COLOR_RE.lastIndex = 0;
- const frag = document.createDocumentFragment();
- let lastIndex = 0;
- let match;
- while ((match = HEX_COLOR_RE.exec(text)) !== null) {
- // Append any text before the match
- if (match.index > lastIndex) {
- frag.appendChild(document.createTextNode(text.slice(lastIndex, match.index)));
- }
- const color = match[0];
- const swatch = document.createElement("span");
- swatch.className = "ai-color-swatch";
- swatch.style.setProperty("--swatch-color", color);
- const preview = document.createElement("span");
- preview.className = "ai-color-swatch-preview";
- swatch.appendChild(preview);
- swatch.appendChild(document.createTextNode(color));
- frag.appendChild(swatch);
- lastIndex = HEX_COLOR_RE.lastIndex;
- }
- if (lastIndex < text.length) {
- frag.appendChild(document.createTextNode(text.slice(lastIndex)));
- }
- textNode.parentNode.replaceChild(frag, textNode);
- });
- }
-
- /**
- * Inject a copy-to-clipboard button into each block inside the given container.
- * Idempotent: skips elements that already have a .ai-copy-btn.
- */
- function _addCopyButtons($container) {
- $container.find("pre").each(function () {
- const $pre = $(this);
- if ($pre.find(".ai-copy-btn").length) {
- return;
- }
- const $btn = $('');
- $btn.on("click", function (e) {
- e.stopPropagation();
- const $code = $pre.find("code");
- const text = ($code.length ? $code[0] : $pre[0]).textContent;
- Phoenix.app.copyToClipboard(text);
- const $icon = $btn.find("i");
- $icon.removeClass("fa-copy").addClass("fa-check");
- $btn.attr("title", Strings.AI_CHAT_COPIED_CODE);
- setTimeout(function () {
- $icon.removeClass("fa-check").addClass("fa-copy");
- $btn.attr("title", Strings.AI_CHAT_COPY_CODE);
- }, 1500);
- });
- $pre.append($btn);
- });
- }
-
- function _appendToolIndicator(toolName, toolId) {
- // Remove thinking indicator on first content
- if (!_hasReceivedContent) {
- _hasReceivedContent = true;
- $messages.find(".ai-thinking").remove();
- }
-
- // Record finalized text segment before clearing
- if (_segmentText) {
- const isFirst = !_chatHistory.some(function (m) { return m.type === "assistant"; });
- _chatHistory.push({ type: "assistant", markdown: _segmentText, isFirst: isFirst });
- }
-
- // Finalize the current text segment so tool appears after it, not at the end
- $messages.find(".ai-stream-target").removeClass("ai-stream-target");
- _segmentText = "";
-
- // Mark any previous active tool indicator as done
- _finishActiveTools();
-
- const config = TOOL_CONFIG[toolName] || { icon: "fa-solid fa-gear", color: "#adb9bd", label: toolName };
-
- // Use requestId + toolId to ensure globally unique data-tool-id
- const uniqueToolId = (_currentRequestId || "") + "-" + toolId;
- const $tool = $(
- '' +
- '' +
- '' +
- '' +
- '' +
- '' +
- ''
- );
- $tool.find(".ai-tool-label").text(config.label + "...");
- $tool.css("--tool-color", config.color);
- $tool.attr("data-tool-icon", config.icon);
- $tool.data("startTime", Date.now());
- $messages.append($tool);
- _scrollToBottom();
- }
-
- /**
- * Update an existing tool indicator with details once tool input is known.
- */
- function _updateToolIndicator(toolId, toolName, toolInput) {
- const uniqueToolId = (_currentRequestId || "") + "-" + toolId;
- const $tool = $messages.find('.ai-msg-tool[data-tool-id="' + uniqueToolId + '"]');
- if (!$tool.length) {
- return;
- }
-
- const config = TOOL_CONFIG[toolName] || { icon: "fa-solid fa-gear", color: "#adb9bd", label: toolName };
- const detail = _getToolDetail(toolName, toolInput);
-
- // Record tool in chat history
- _chatHistory.push({
- type: "tool",
- toolName: toolName,
- summary: detail.summary,
- icon: config.icon,
- color: config.color
- });
-
- // Replace spinner with colored icon immediately
- $tool.find(".ai-tool-spinner").replaceWith(
- ''
- );
-
- // Update label to include summary
- $tool.find(".ai-tool-label").text(detail.summary);
-
- // For TodoWrite, render a mini task-list widget and auto-expand
- if (toolName === "TodoWrite" && toolInput && toolInput.todos) {
- const $detail = $('');
- const $todoList = $('');
- toolInput.todos.forEach(function (todo) {
- let iconClass, statusClass;
- if (todo.status === "completed") {
- iconClass = "fa-solid fa-circle-check";
- statusClass = "completed";
- } else if (todo.status === "in_progress") {
- iconClass = "fa-solid fa-spinner fa-spin";
- statusClass = "in_progress";
- } else {
- iconClass = "fa-regular fa-circle";
- statusClass = "pending";
- }
- const $item = $(
- '' +
- '' +
- '' +
- ''
- );
- $item.find(".ai-todo-content").text(todo.content);
- $todoList.append($item);
- });
- $detail.append($todoList);
- $tool.append($detail);
- $tool.addClass("ai-tool-expanded");
- $tool.find(".ai-tool-header").on("click", function () {
- $tool.toggleClass("ai-tool-expanded");
- }).css("cursor", "pointer");
- } else if (toolName === "mcp__phoenix-editor__wait" && toolInput && toolInput.seconds) {
- // Countdown timer: update label every second
- const totalSec = Math.ceil(toolInput.seconds);
- let remaining = totalSec;
- const $label = $tool.find(".ai-tool-label");
- const countdownId = setInterval(function () {
- remaining--;
- if (remaining <= 0) {
- clearInterval(countdownId);
- $label.text(StringUtils.format(Strings.AI_CHAT_TOOL_WAITED, totalSec));
- } else {
- $label.text(StringUtils.format(Strings.AI_CHAT_TOOL_WAITING, remaining));
- }
- }, 1000);
- } else if (toolName === "mcp__phoenix-editor__takeScreenshot") {
- const $detail = $('');
- $tool.append($detail);
- $tool.data("awaitingScreenshot", true);
- $tool.find(".ai-tool-header").on("click", function () {
- $tool.toggleClass("ai-tool-expanded");
- }).css("cursor", "pointer");
- } else if (toolName === "Task" && toolInput) {
- const $detail = $('');
- const desc = toolInput.description || toolInput.prompt || "";
- if (desc) {
- $detail.append($('').text(desc.slice(0, 200)));
- }
- $tool.append($detail);
- $tool.addClass("ai-tool-expanded");
- $tool.find(".ai-tool-header").on("click", function () {
- $tool.toggleClass("ai-tool-expanded");
- }).css("cursor", "pointer");
- } else if (detail.lines && detail.lines.length) {
- // Add expandable detail if available
- const $detail = $('');
- detail.lines.forEach(function (line) {
- $detail.append($('').text(line));
- });
- $tool.append($detail);
-
- // Make header clickable to expand
- $tool.find(".ai-tool-header").on("click", function () {
- $tool.toggleClass("ai-tool-expanded");
- }).css("cursor", "pointer");
- }
-
- // For file-related tools, make label clickable to open the file
- if (toolInput && toolInput.file_path &&
- (toolName === "Read" || toolName === "Write" || toolName === "Edit")) {
- const filePath = toolInput.file_path;
- $tool.find(".ai-tool-label").on("click", function (e) {
- e.stopPropagation();
- const vfsPath = SnapshotStore.realToVfsPath(filePath);
- CommandManager.execute(Commands.CMD_OPEN, { fullPath: vfsPath });
- }).css("cursor", "pointer").addClass("ai-tool-label-clickable");
- }
-
- // Clear any stale-preview timers now that tool info arrived
- clearTimeout(_toolStreamStaleTimer);
- clearInterval(_toolStreamRotateTimer);
-
- // Stop the elapsed timer and remove the element
- const elapsedTimer = $tool.data("elapsedTimer");
- if (elapsedTimer) {
- clearInterval(elapsedTimer);
- $tool.removeData("elapsedTimer");
- }
- $tool.find(".ai-tool-elapsed").remove();
-
- // Delay marking as done so the streaming preview stays visible briefly.
- // The ai-tool-done class hides the preview via CSS; deferring it lets the
- // browser paint the preview before it disappears.
- setTimeout(function () {
- $tool.addClass("ai-tool-done");
- $tool.find(".ai-tool-preview").text("");
- }, 1500);
-
- _scrollToBottom();
- }
-
- /**
- * Extract a summary and detail lines from tool input.
- */
- function _getToolDetail(toolName, input) {
- if (!input) {
- return { summary: toolName, lines: [] };
- }
- switch (toolName) {
- case "Glob":
- return {
- summary: StringUtils.format(Strings.AI_CHAT_TOOL_SEARCHED, input.pattern || ""),
- lines: input.path ? [StringUtils.format(Strings.AI_CHAT_TOOL_IN_PATH, input.path)] : []
- };
- case "Grep":
- return {
- summary: StringUtils.format(Strings.AI_CHAT_TOOL_GREP, input.pattern || ""),
- lines: [
- input.path ? StringUtils.format(Strings.AI_CHAT_TOOL_IN_PATH, input.path) : "",
- input.include ? StringUtils.format(Strings.AI_CHAT_TOOL_INCLUDE, input.include) : ""
- ].filter(Boolean)
- };
- case "Read":
- return {
- summary: StringUtils.format(Strings.AI_CHAT_TOOL_READ_FILE, (input.file_path || "").split("/").pop()),
- lines: [input.file_path || ""]
- };
- case "Edit":
- return {
- summary: StringUtils.format(Strings.AI_CHAT_TOOL_EDIT_FILE, (input.file_path || "").split("/").pop()),
- lines: [input.file_path || ""]
- };
- case "Write":
- return {
- summary: StringUtils.format(Strings.AI_CHAT_TOOL_WRITE_FILE, (input.file_path || "").split("/").pop()),
- lines: [input.file_path || ""]
- };
- case "Bash":
- return {
- summary: Strings.AI_CHAT_TOOL_RAN_CMD,
- lines: input.command ? [input.command] : []
- };
- case "Skill":
- return {
- summary: input.skill ? StringUtils.format(Strings.AI_CHAT_TOOL_SKILL_NAME, input.skill) : Strings.AI_CHAT_TOOL_SKILL,
- lines: input.args ? [input.args] : []
- };
- case "mcp__phoenix-editor__getEditorState":
- return { summary: Strings.AI_CHAT_TOOL_EDITOR_STATE, lines: [] };
- case "mcp__phoenix-editor__takeScreenshot": {
- let screenshotTarget = Strings.AI_CHAT_TOOL_SCREENSHOT_FULL_PAGE;
- if (input.selector) {
- if (input.selector.indexOf("live-preview") !== -1 || input.selector.indexOf("panel-live-preview") !== -1) {
- screenshotTarget = Strings.AI_CHAT_TOOL_SCREENSHOT_LIVE_PREVIEW;
- } else {
- screenshotTarget = input.selector;
- }
- }
- return {
- summary: StringUtils.format(Strings.AI_CHAT_TOOL_SCREENSHOT_OF, screenshotTarget),
- lines: input.selector ? [input.selector] : []
- };
- }
- case "mcp__phoenix-editor__execJsInLivePreview":
- return {
- summary: Strings.AI_CHAT_TOOL_LIVE_PREVIEW_JS,
- lines: input.code ? input.code.split("\n").slice(0, 20) : []
- };
- case "mcp__phoenix-editor__resizeLivePreview":
- return {
- summary: Strings.AI_CHAT_TOOL_RESIZE_PREVIEW,
- lines: input.width ? [input.width + "px"] : []
- };
- case "mcp__phoenix-editor__wait":
- return {
- summary: StringUtils.format(Strings.AI_CHAT_TOOL_WAITING, input.seconds || "?"),
- lines: []
- };
- case "mcp__phoenix-editor__getUserClarification":
- return {
- summary: Strings.AI_CHAT_TOOL_CLARIFICATION,
- lines: []
- };
- case "AskUserQuestion": {
- const qs = input.questions || [];
- return {
- summary: Strings.AI_CHAT_TOOL_QUESTION,
- lines: qs.map(function (q) { return q.question; })
- };
- }
- case "Task": {
- const desc = input.description || input.prompt || "";
- const agentType = input.subagent_type || "";
- const summary = agentType
- ? StringUtils.format(Strings.AI_CHAT_TOOL_TASK_NAME, agentType)
- : Strings.AI_CHAT_TOOL_TASK;
- return {
- summary: summary,
- lines: desc ? [desc.split("\n")[0].slice(0, 120)] : []
- };
- }
- case "TodoWrite": {
- const todos = input.todos || [];
- const completed = todos.filter(function (t) { return t.status === "completed"; }).length;
- return {
- summary: StringUtils.format(Strings.AI_CHAT_TOOL_TASKS_SUMMARY, completed, todos.length),
- lines: []
- };
- }
- case "mcp__phoenix-editor__controlEditor": {
- // Multi-operation batch format
- if (input.operations && input.operations.length) {
- if (input.operations.length === 1) {
- // Single operation — show its detail
- const op = input.operations[0];
- const fn = (op.filePath || "").split("/").pop();
- let opSummary;
- switch (op.operation) {
- case "open":
- case "openInWorkingSet":
- opSummary = "Open " + fn;
- break;
- case "close":
- opSummary = "Close " + fn;
- break;
- case "setCursorPos":
- opSummary = "Go to L" + (op.line || "?") + " in " + fn;
- break;
- case "setSelection":
- opSummary = "Select L" + (op.startLine || "?") + "-L" + (op.endLine || "?") + " in " + fn;
- break;
- default:
- opSummary = Strings.AI_CHAT_TOOL_CONTROL_EDITOR;
- }
- return { summary: opSummary, lines: [op.filePath || ""] };
- }
- // Multiple operations — summarize
- const count = input.operations.length;
- const opTypes = {};
- input.operations.forEach(function (op) {
- const t = op.operation || "open";
- opTypes[t] = (opTypes[t] || 0) + 1;
- });
- const parts = Object.keys(opTypes).map(function (t) {
- const label = (t === "open" || t === "openInWorkingSet") ? "Open" :
- t === "close" ? "Close" :
- t === "setCursorPos" ? "Navigate" :
- t === "setSelection" ? "Select" : t;
- return label + " " + opTypes[t];
- });
- return { summary: parts.join(", ") + " files", lines: [] };
- }
- // Legacy single-operation format
- const fileName = (input.filePath || "").split("/").pop();
- let opSummary;
- switch (input.operation) {
- case "open":
- case "openInWorkingSet":
- opSummary = "Open " + fileName;
- break;
- case "close":
- opSummary = "Close " + fileName;
- break;
- case "setCursorPos":
- opSummary = "Go to L" + (input.line || "?") + " in " + fileName;
- break;
- case "setSelection":
- opSummary = "Select L" + (input.startLine || "?") + "-L" + (input.endLine || "?") + " in " + fileName;
- break;
- default:
- opSummary = Strings.AI_CHAT_TOOL_CONTROL_EDITOR;
- }
- return { summary: opSummary, lines: [input.filePath || ""] };
- }
- default: {
- // Fallback: use TOOL_CONFIG label if available
- const cfg = TOOL_CONFIG[toolName];
- return { summary: cfg ? cfg.label : toolName, lines: [] };
- }
- }
- }
-
- /**
- * 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);
- // Clear any running elapsed timer
- const et = $prev.data("elapsedTimer");
- if (et) {
- clearInterval(et);
- $prev.removeData("elapsedTimer");
- }
- $prev.find(".ai-tool-elapsed").remove();
- // _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";
- $prev.find(".ai-tool-spinner").replaceWith(
- ''
- );
- });
- }
-
-
- function _appendErrorMessage(text) {
- const $msg = $(
- '' +
- '' +
- ''
- );
- $msg.find(".ai-msg-content").text(text);
- $messages.append($msg);
- _scrollToBottom();
- }
-
- function _setStreaming(streaming) {
- _isStreaming = streaming;
- if ($status) {
- $status.toggleClass("active", streaming);
- }
- if ($textarea) {
- // Keep textarea enabled during streaming so users can type a queued clarification
- if (!streaming) {
- $textarea[0].focus({ preventScroll: true });
- }
- }
- if ($sendBtn && $stopBtn) {
- if (streaming) {
- $sendBtn.hide();
- $stopBtn.show();
- } else {
- $stopBtn.hide();
- $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();
-
- // Finalize: remove ai-stream-target class so future messages get their own container
- $messages.find(".ai-stream-target").removeClass("ai-stream-target");
-
- // Ensure copy buttons are present on all code blocks
- _addCopyButtons($messages);
-
- // Mark all active tool indicators as done
- _finishActiveTools();
- }
- }
-
- function _scrollToBottom() {
- if (_autoScroll && $messages && $messages.length) {
- const el = $messages[0];
- el.scrollTop = el.scrollHeight;
- }
- }
-
- function _escapeAttr(str) {
- return str.replace(/&/g, "&").replace(/"/g, """).replace(//g, ">");
- }
-
- // --- Session History ---
-
- /**
- * Toggle the history dropdown open/closed.
- */
- function _toggleHistoryDropdown() {
- const $dropdown = $panel.find(".ai-session-history-dropdown");
- const $btn = $panel.find(".ai-history-btn");
- const isOpen = $dropdown.hasClass("open");
- if (isOpen) {
- $dropdown.removeClass("open");
- $btn.removeClass("active");
- $panel.removeClass("ai-history-open");
- } else {
- _renderHistoryDropdown();
- $dropdown.addClass("open");
- $btn.addClass("active");
- $panel.addClass("ai-history-open");
- }
- }
-
- /**
- * Render the history dropdown contents from stored session metadata.
- */
- function _renderHistoryDropdown() {
- const $dropdown = $panel.find(".ai-session-history-dropdown");
- $dropdown.empty();
-
- const history = AIChatHistory.loadSessionHistory();
- if (!history.length) {
- $dropdown.append(
- '' + Strings.AI_CHAT_HISTORY_EMPTY + ''
- );
- return;
- }
-
- history.forEach(function (session) {
- const $item = $(
- '' +
- '' +
- '' +
- '' +
- '' +
- '' +
- ''
- );
- $item.find(".ai-history-item-title").text(session.title || "Untitled");
- $item.find(".ai-history-item-time").text(AIChatHistory.formatRelativeTime(session.timestamp));
-
- if (_currentSessionId === session.id) {
- $item.addClass("ai-history-active");
- }
-
- // Click to resume session
- $item.find(".ai-history-item-info").on("click", function () {
- _resumeSession(session.id, session.title);
- });
-
- // Delete button
- $item.find(".ai-history-item-delete").on("click", function (e) {
- e.stopPropagation();
- AIChatHistory.deleteSession(session.id, function () {
- _renderHistoryDropdown();
- });
- });
-
- $dropdown.append($item);
- });
-
- // Clear all link
- const $clearAll = $('' + Strings.AI_CHAT_HISTORY_CLEAR_ALL + '');
- $clearAll.on("click", function () {
- AIChatHistory.clearAllHistory(function () {
- $panel.find(".ai-session-history-dropdown").removeClass("open");
- $panel.find(".ai-history-btn").removeClass("active");
- $panel.removeClass("ai-history-open");
- });
- });
- $dropdown.append($clearAll);
- }
-
- /**
- * Resume a past session: load history, restore visual state, tell node side.
- */
- function _resumeSession(sessionId, title) {
- // Close dropdown
- $panel.find(".ai-session-history-dropdown").removeClass("open");
- $panel.find(".ai-history-btn").removeClass("active");
- $panel.removeClass("ai-history-open");
-
- // Cancel any in-flight query
- if (_isStreaming) {
- _cancelQuery();
- }
-
- // Tell node side to set session ID for resume
- _nodeConnector.execPeer("resumeSession", { sessionId: sessionId }).catch(function (err) {
- console.warn("[AI UI] Failed to resume session:", err.message);
- });
-
- // Load chat history from disk
- AIChatHistory.loadChatHistory(sessionId, function (err, data) {
- if (err) {
- console.warn("[AI UI] Failed to load chat history:", err);
- // Remove stale metadata entry
- AIChatHistory.deleteSession(sessionId);
- return;
- }
-
- // Reset state (similar to _newSession but keep session ID)
- _currentRequestId = null;
- _segmentText = "";
- _hasReceivedContent = false;
- _isStreaming = false;
- _sessionError = false;
- _queuedMessage = null;
- _removeQueueBubble();
- _firstEditInResponse = true;
- _undoApplied = false;
- _firstUserMessage = null;
- _lastQuestions = null;
- _attachedImages = [];
- _renderImagePreview();
- SnapshotStore.reset();
- PhoenixConnectors.clearPreviousContentMap();
-
- // Clear messages and render restored chat
- $messages.empty();
-
- // Show "Resumed session" indicator
- const $indicator = $(
- '' +
- ' ' +
- Strings.AI_CHAT_SESSION_RESUMED +
- ''
- );
- $messages.append($indicator);
-
- // Render restored messages
- AIChatHistory.renderRestoredChat(data.messages, $messages, $panel);
-
- // Set state for continued conversation
- _currentSessionId = sessionId;
- _isResumedSession = true;
- _chatHistory = data.messages ? data.messages.slice() : [];
-
- // Show "+ New" button, enable input
- $panel.find(".ai-new-session-btn").show();
- if ($status) {
- $status.removeClass("active");
- }
- $textarea.prop("disabled", false);
- $textarea.closest(".ai-chat-input-wrap").removeClass("disabled");
- $sendBtn.prop("disabled", false);
- $textarea[0].focus({ preventScroll: true });
-
- // Scroll to bottom
- if ($messages && $messages.length) {
- $messages[0].scrollTop = $messages[0].scrollHeight;
- }
- });
- }
-
- // --- Path utilities ---
-
- /**
- * Get the real filesystem path for the current project root.
- */
- function _getProjectRealPath() {
- const root = ProjectManager.getProjectRoot();
- if (!root) {
- return "/";
- }
- const fullPath = root.fullPath;
- // Desktop (Tauri) paths: /tauri/real/path → /real/path
- if (fullPath.startsWith("/tauri/")) {
- return fullPath.replace("/tauri", "");
- }
- return fullPath;
- }
-
- // Public API
- exports.init = init;
- exports.initPlaceholder = initPlaceholder;
-});
diff --git a/src/core-ai/AISnapshotStore.js b/src/core-ai/AISnapshotStore.js
deleted file mode 100644
index 2c3873e75..000000000
--- a/src/core-ai/AISnapshotStore.js
+++ /dev/null
@@ -1,648 +0,0 @@
-/*
- * 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.
- *
- * Content is stored in memory during an AI turn and flushed to disk at
- * finalizeResponse() time. Reads check memory first, then fall back to disk.
- * A per-instance heartbeat + GC mechanism cleans up stale instance data.
- */
-define(function (require, exports, module) {
-
- const DocumentManager = require("document/DocumentManager"),
- CommandManager = require("command/CommandManager"),
- Commands = require("command/Commands"),
- FileSystem = require("filesystem/FileSystem"),
- ProjectManager = require("project/ProjectManager");
-
- // --- Constants ---
- const HEARTBEAT_INTERVAL_MS = 60 * 1000;
- const STALE_THRESHOLD_MS = 10 * 60 * 1000;
-
- // --- Disk store state ---
- let _instanceDir; // "/instanceData//"
- let _aiSnapDir; // "/instanceData//aiSnap/"
- let _heartbeatIntervalId = null;
- let _diskReady = false;
- let _diskReadyResolve;
- const _diskReadyPromise = new Promise(function (resolve) {
- _diskReadyResolve = resolve;
- });
-
- // --- Private state ---
- const _memoryBuffer = {}; // hash → content (in-memory during AI turn)
- const _writtenHashes = new Set(); // hashes confirmed on disk
- let _snapshots = []; // flat: _snapshots[i] = { filePath: hash|null }
- let _pendingBeforeSnap = {}; // built during current response: filePath → hash|null
- const _pendingDeleted = new Set(); // file paths deleted during current response
- const _readFiles = {}; // filePath → raw content string (files AI has read)
- let _isTracking = false; // true while AI is streaming
-
- // --- 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);
- if (!_writtenHashes.has(hash) && !_memoryBuffer[hash]) {
- _memoryBuffer[hash] = content;
- }
- return hash;
- }
-
- // --- Disk store ---
-
- function _initDiskStore() {
- const appSupportDir = Phoenix.VFS.getAppSupportDir();
- const instanceId = Phoenix.PHOENIX_INSTANCE_ID;
- _instanceDir = appSupportDir + "instanceData/" + instanceId + "/";
- _aiSnapDir = _instanceDir + "aiSnap/";
- Phoenix.VFS.ensureExistsDirAsync(_aiSnapDir)
- .then(function () {
- _diskReady = true;
- _diskReadyResolve();
- })
- .catch(function (err) {
- console.error("[AISnapshotStore] Failed to init disk store:", err);
- // _diskReadyPromise stays pending — heartbeat/GC never fire
- });
- }
-
- function _flushToDisk() {
- if (!_diskReady) {
- return;
- }
- const hashes = Object.keys(_memoryBuffer);
- hashes.forEach(function (hash) {
- const content = _memoryBuffer[hash];
- const file = FileSystem.getFileForPath(_aiSnapDir + hash);
- file.write(content, {blind: true}, function (err) {
- if (err) {
- console.error("[AISnapshotStore] Flush failed for hash " + hash + ":", err);
- // Keep in _memoryBuffer so reads still work
- } else {
- _writtenHashes.add(hash);
- delete _memoryBuffer[hash];
- }
- });
- });
- }
-
- function _readContent(hash) {
- // Check memory buffer first (content may not have flushed yet)
- if (_memoryBuffer.hasOwnProperty(hash)) {
- return Promise.resolve(_memoryBuffer[hash]);
- }
- // Read from disk
- return new Promise(function (resolve, reject) {
- const file = FileSystem.getFileForPath(_aiSnapDir + hash);
- file.read(function (err, data) {
- if (err) {
- reject(err);
- } else {
- resolve(data);
- }
- });
- });
- }
-
- function _readFileFromDisk(vfsPath) {
- return new Promise(function (resolve, reject) {
- const file = FileSystem.getFileForPath(vfsPath);
- file.read(function (err, data) {
- if (err) { reject(err); } else { resolve(data); }
- });
- });
- }
-
- // --- 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);
-
- function _unlinkFile() {
- file.unlink(function (err) {
- if (err) {
- // File already gone — desired state achieved, treat as success
- file.exists(function (_existErr, exists) {
- if (!exists) {
- result.resolve();
- } else {
- result.reject(err);
- }
- });
- } else {
- result.resolve();
- }
- });
- }
-
- const openDoc = DocumentManager.getOpenDocumentForPath(vfsPath);
- if (openDoc) {
- if (openDoc.isDirty) {
- openDoc.setText("");
- }
- CommandManager.execute(Commands.FILE_CLOSE, { file: file, _forceClose: true })
- .always(_unlinkFile);
- } else {
- _unlinkFile();
- }
-
- 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 })
- .always(function () {
- result.resolve();
- });
- });
- } catch (err) {
- result.reject(err);
- }
- })
- .fail(function (err) {
- result.reject(err || new Error("Could not open document"));
- });
- }
-
- function _createThenSet() {
- const file = FileSystem.getFileForPath(vfsPath);
- file.write("", function (writeErr) {
- if (writeErr) {
- result.reject(new Error("Could not create file: " + writeErr));
- return;
- }
- _setContent();
- });
- }
-
- const file = FileSystem.getFileForPath(vfsPath);
- file.exists(function (existErr, exists) {
- if (exists) {
- // File may appear to exist due to stale FS cache after a
- // delete+recreate cycle. Try opening first; if it fails,
- // recreate the file on disk and retry.
- DocumentManager.getDocumentForPath(vfsPath)
- .done(function (doc) {
- try {
- doc.setText(content);
- saveDocToDisk(doc).always(function () {
- CommandManager.execute(Commands.CMD_OPEN, { fullPath: vfsPath })
- .always(function () {
- result.resolve();
- });
- });
- } catch (err) {
- result.reject(err);
- }
- })
- .fail(function () {
- _createThenSet();
- });
- } else {
- _createThenSet();
- }
- });
-
- return result.promise();
- }
-
- // --- Snapshot logic ---
-
- /**
- * Apply a snapshot to files. hash=null means delete the file.
- * Reads content from memory buffer first, then disk.
- * @param {Object} snapshot - { filePath: hash|null }
- * @return {Promise} resolves with errorCount
- */
- async function _applySnapshot(snapshot) {
- const filePaths = Object.keys(snapshot);
- if (filePaths.length === 0) {
- return 0;
- }
- const promises = filePaths.map(function (fp) {
- const hash = snapshot[fp];
- if (hash === null) {
- return _closeAndDeleteFile(fp);
- }
- return _readContent(hash).then(function (content) {
- return _createOrUpdateFile(fp, content);
- });
- });
- const results = await Promise.allSettled(promises);
- let errorCount = 0;
- results.forEach(function (r) {
- if (r.status === "rejected") { errorCount++; }
- });
- return errorCount;
- }
-
- // --- 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;
- }
- });
- }
- }
-
- /**
- * Record a file the AI has read, storing its content hash for potential
- * delete/rename tracking. If the file is later deleted, this content
- * can be promoted into a snapshot for restore.
- * @param {string} filePath - real filesystem path
- * @param {string} content - file content at read time
- */
- function recordFileRead(filePath, content) {
- _readFiles[filePath] = content;
- }
-
- /**
- * Record that a file has been deleted during this response.
- * If the file hasn't been tracked yet, its previousContent is stored
- * and back-filled into existing snapshots.
- * @param {string} filePath - real filesystem path
- * @param {string} previousContent - content before deletion
- */
- function recordFileDeletion(filePath, previousContent) {
- if (!_pendingBeforeSnap.hasOwnProperty(filePath)) {
- const hash = storeContent(previousContent);
- _pendingBeforeSnap[filePath] = hash;
- _snapshots.forEach(function (snap) {
- if (snap[filePath] === undefined) {
- snap[filePath] = hash;
- }
- });
- }
- _pendingDeleted.add(filePath);
- }
-
- /**
- * 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() {
- _snapshots.push({});
- return 0;
- }
-
- /**
- * 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.
- * Flushes in-memory content to disk for long-term storage.
- *
- * Priority for each file:
- * 1. If in _pendingDeleted → null
- * 2. If doc is open → storeContent(openDoc.getText())
- * 3. Fallback: read from disk → storeContent(content)
- * 4. If disk read fails (file gone) → null
- *
- * @return {Promise} the after-snapshot index, or -1 if no edits happened
- */
- async function finalizeResponse() {
- let afterIndex = -1;
- if (Object.keys(_pendingBeforeSnap).length > 0) {
- const afterSnap = Object.assign({}, _snapshots[_snapshots.length - 1]);
- const editedPaths = Object.keys(_pendingBeforeSnap);
- for (let i = 0; i < editedPaths.length; i++) {
- const fp = editedPaths[i];
- if (_pendingDeleted.has(fp)) {
- afterSnap[fp] = null;
- continue;
- }
- const vfsPath = realToVfsPath(fp);
- const openDoc = DocumentManager.getOpenDocumentForPath(vfsPath);
- if (openDoc) {
- afterSnap[fp] = storeContent(openDoc.getText());
- } else {
- try {
- const content = await _readFileFromDisk(vfsPath);
- afterSnap[fp] = storeContent(content);
- } catch (e) {
- afterSnap[fp] = null;
- }
- }
- }
- _snapshots.push(afterSnap);
- afterIndex = _snapshots.length - 1;
- }
- _pendingBeforeSnap = {};
- _pendingDeleted.clear();
- _flushToDisk();
- return afterIndex;
- }
-
- /**
- * Restore files to the state captured in a specific snapshot.
- * @param {number} index - index into _snapshots
- * @param {Function} onComplete - callback(errorCount)
- */
- async function restoreToSnapshot(index, onComplete) {
- if (index < 0 || index >= _snapshots.length) {
- onComplete(0);
- return;
- }
- const errorCount = await _applySnapshot(_snapshots[index]);
- onComplete(errorCount);
- }
-
- // --- FS event tracking ---
-
- function _onProjectFileChanged(_event, entry, addedInProject, removedInProject) {
- if (!removedInProject || !removedInProject.length) { return; }
- removedInProject.forEach(function (removedEntry) {
- if (!removedEntry.isFile) { return; }
- const fp = removedEntry.fullPath;
- // Check if AI has edited this file (already in snapshots)
- const isEdited = _pendingBeforeSnap.hasOwnProperty(fp) ||
- _snapshots.some(function (snap) { return snap.hasOwnProperty(fp); });
- if (isEdited) {
- _pendingDeleted.add(fp);
- return;
- }
- // Check if AI has read this file (raw content available)
- if (_readFiles.hasOwnProperty(fp)) {
- // Promote from read-tracked to snapshot-tracked, then mark deleted
- const hash = storeContent(_readFiles[fp]);
- _pendingBeforeSnap[fp] = hash;
- _snapshots.forEach(function (snap) {
- if (snap[fp] === undefined) {
- snap[fp] = hash;
- }
- });
- _pendingDeleted.add(fp);
- }
- });
- }
-
- function _onProjectFileRenamed(_event, oldPath, newPath) {
- // Update _pendingBeforeSnap
- if (_pendingBeforeSnap.hasOwnProperty(oldPath)) {
- _pendingBeforeSnap[newPath] = _pendingBeforeSnap[oldPath];
- delete _pendingBeforeSnap[oldPath];
- }
- // Update _pendingDeleted
- if (_pendingDeleted.has(oldPath)) {
- _pendingDeleted.delete(oldPath);
- _pendingDeleted.add(newPath);
- }
- // Update all snapshots
- _snapshots.forEach(function (snap) {
- if (snap.hasOwnProperty(oldPath)) {
- snap[newPath] = snap[oldPath];
- delete snap[oldPath];
- }
- });
- // Update _readFiles
- if (_readFiles.hasOwnProperty(oldPath)) {
- _readFiles[newPath] = _readFiles[oldPath];
- delete _readFiles[oldPath];
- }
- }
-
- function startTracking() {
- if (_isTracking) { return; }
- _isTracking = true;
- ProjectManager.on("projectFileChanged", _onProjectFileChanged);
- ProjectManager.on("projectFileRenamed", _onProjectFileRenamed);
- }
-
- function stopTracking() {
- if (!_isTracking) { return; }
- _isTracking = false;
- ProjectManager.off("projectFileChanged", _onProjectFileChanged);
- ProjectManager.off("projectFileRenamed", _onProjectFileRenamed);
- }
-
- /**
- * @return {number} number of snapshots
- */
- function getSnapshotCount() {
- return _snapshots.length;
- }
-
- /**
- * Clear all snapshot state. Called when starting a new session.
- * Also deletes and recreates the aiSnap directory on disk.
- */
- function reset() {
- Object.keys(_memoryBuffer).forEach(function (k) { delete _memoryBuffer[k]; });
- _writtenHashes.clear();
- _snapshots = [];
- _pendingBeforeSnap = {};
- _pendingDeleted.clear();
- Object.keys(_readFiles).forEach(function (k) { delete _readFiles[k]; });
- stopTracking();
-
- // Delete and recreate aiSnap directory
- if (_diskReady && _aiSnapDir) {
- const dir = FileSystem.getDirectoryForPath(_aiSnapDir);
- dir.unlink(function (err) {
- if (err) {
- console.error("[AISnapshotStore] Failed to delete aiSnap dir:", err);
- }
- Phoenix.VFS.ensureExistsDirAsync(_aiSnapDir).catch(function (e) {
- console.error("[AISnapshotStore] Failed to recreate aiSnap dir:", e);
- });
- });
- }
- }
-
- // --- Heartbeat ---
-
- function _writeHeartbeat() {
- if (!_diskReady || !_instanceDir) {
- return;
- }
- const file = FileSystem.getFileForPath(_instanceDir + "heartbeat");
- file.write(String(Date.now()), {blind: true}, function (err) {
- if (err) {
- console.error("[AISnapshotStore] Heartbeat write failed:", err);
- }
- });
- }
-
- function _startHeartbeat() {
- _diskReadyPromise.then(function () {
- _writeHeartbeat();
- _heartbeatIntervalId = setInterval(_writeHeartbeat, HEARTBEAT_INTERVAL_MS);
- });
- }
-
- function _stopHeartbeat() {
- if (_heartbeatIntervalId !== null) {
- clearInterval(_heartbeatIntervalId);
- _heartbeatIntervalId = null;
- }
- }
-
- // --- Garbage Collection ---
-
- function _runGarbageCollection() {
- _diskReadyPromise.then(function () {
- const appSupportDir = Phoenix.VFS.getAppSupportDir();
- const instanceDataBaseDir = appSupportDir + "instanceData/";
- const ownId = Phoenix.PHOENIX_INSTANCE_ID;
- const baseDir = FileSystem.getDirectoryForPath(instanceDataBaseDir);
- baseDir.getContents(function (err, entries) {
- if (err) {
- console.error("[AISnapshotStore] GC: failed to list instanceData:", err);
- return;
- }
- const now = Date.now();
- entries.forEach(function (entry) {
- if (!entry.isDirectory || entry.name === ownId) {
- return;
- }
- const heartbeatFile = FileSystem.getFileForPath(
- instanceDataBaseDir + entry.name + "/heartbeat"
- );
- heartbeatFile.read(function (readErr, data) {
- let isStale = false;
- if (readErr) {
- // No heartbeat file — treat as stale
- isStale = true;
- } else {
- const ts = parseInt(data, 10);
- if (isNaN(ts) || (now - ts) > STALE_THRESHOLD_MS) {
- isStale = true;
- }
- }
- if (isStale) {
- entry.unlink(function (unlinkErr) {
- if (unlinkErr) {
- console.error("[AISnapshotStore] GC: failed to remove stale dir "
- + entry.name + ":", unlinkErr);
- }
- });
- }
- });
- });
- }, true); // true = filterNothing
- });
- }
-
- // --- Module init ---
- _initDiskStore();
- _startHeartbeat();
- _runGarbageCollection();
- window.addEventListener("beforeunload", _stopHeartbeat);
-
- exports.realToVfsPath = realToVfsPath;
- exports.saveDocToDisk = saveDocToDisk;
- exports.storeContent = storeContent;
- exports.recordFileBeforeEdit = recordFileBeforeEdit;
- exports.recordFileRead = recordFileRead;
- exports.recordFileDeletion = recordFileDeletion;
- exports.createInitialSnapshot = createInitialSnapshot;
- exports.finalizeResponse = finalizeResponse;
- exports.restoreToSnapshot = restoreToSnapshot;
- exports.getSnapshotCount = getSnapshotCount;
- exports.startTracking = startTracking;
- exports.stopTracking = stopTracking;
- exports.reset = reset;
-});
diff --git a/src/core-ai/aiPhoenixConnectors.js b/src/core-ai/aiPhoenixConnectors.js
deleted file mode 100644
index bc89b8a56..000000000
--- a/src/core-ai/aiPhoenixConnectors.js
+++ /dev/null
@@ -1,772 +0,0 @@
-/*
- * 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.
- *
- */
-
-/**
- * NodeConnector handlers for bridging the node-side Claude Code agent with
- * the Phoenix browser runtime. Handles editor state queries, screenshot
- * capture, file content reads, and edit application to document buffers.
- *
- * Called via execPeer from src-node/claude-code-agent.js and
- * src-node/mcp-editor-tools.js.
- */
-define(function (require, exports, module) {
-
- const DocumentManager = require("document/DocumentManager"),
- CommandManager = require("command/CommandManager"),
- Commands = require("command/Commands"),
- MainViewManager = require("view/MainViewManager"),
- EditorManager = require("editor/EditorManager"),
- FileSystem = require("filesystem/FileSystem"),
- LiveDevProtocol = require("LiveDevelopment/MultiBrowserImpl/protocol/LiveDevProtocol"),
- LiveDevMain = require("LiveDevelopment/main"),
- LivePreviewConstants = require("LiveDevelopment/LivePreviewConstants"),
- WorkspaceManager = require("view/WorkspaceManager"),
- SnapshotStore = require("core-ai/AISnapshotStore"),
- EventDispatcher = require("utils/EventDispatcher"),
- StringUtils = require("utils/StringUtils"),
- Strings = require("strings");
-
- // filePath → previous content before edit, for undo/snapshot support
- const _previousContentMap = {};
-
- // Last screenshot base64 data, for displaying in tool indicators
- let _lastScreenshotBase64 = null;
-
- // Banner / live preview mode state
- let _activeExecJsCount = 0;
- let _savedLivePreviewMode = null;
- let _bannerDismissed = false;
- let _bannerEl = null;
- let _bannerStyleInjected = false;
- let _bannerAutoHideTimer = null;
-
- /**
- * Inject banner CSS once into the document head.
- */
- function _injectBannerStyles() {
- if (_bannerStyleInjected) {
- return;
- }
- _bannerStyleInjected = true;
- const style = document.createElement("style");
- style.textContent =
- "@keyframes ai-banner-pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.4; } }" +
- ".ai-lp-banner {" +
- " position: absolute; top: 0; left: 0; right: 0; bottom: 0;" +
- " display: flex; align-items: center; justify-content: center; gap: 8px;" +
- " background: rgba(24,24,28,0.52);" +
- " backdrop-filter: blur(8px); -webkit-backdrop-filter: blur(8px);" +
- " z-index: 10; border-radius: 3px;" +
- " font-size: 12px; color: #e0e0e0; pointer-events: auto;" +
- " transition: opacity 0.3s ease;" +
- "}" +
- ".ai-lp-banner .ai-lp-banner-icon {" +
- " color: #66bb6a; animation: ai-banner-pulse 1.5s ease-in-out infinite;" +
- "}" +
- ".ai-lp-banner .ai-lp-banner-close {" +
- " position: absolute; right: 6px; top: 50%; transform: translateY(-50%);" +
- " background: none; border: none; color: #aaa; cursor: pointer;" +
- " font-size: 14px; padding: 2px 5px; line-height: 1;" +
- "}" +
- ".ai-lp-banner .ai-lp-banner-close:hover { color: #fff; }";
- document.head.appendChild(style);
- }
-
- /**
- * Show a banner overlay on the live preview toolbar.
- * @param {string} text - Banner message text
- */
- function _showBanner(text) {
- if (_bannerDismissed) {
- return;
- }
- _injectBannerStyles();
- const toolbar = document.getElementById("live-preview-plugin-toolbar");
- if (!toolbar) {
- return;
- }
- // Ensure toolbar can host absolutely positioned children
- if (getComputedStyle(toolbar).position === "static") {
- toolbar.style.position = "relative";
- }
- if (_bannerEl && _bannerEl.parentNode) {
- // Update text on existing banner
- const textSpan = _bannerEl.querySelector(".ai-lp-banner-text");
- if (textSpan) {
- textSpan.textContent = text;
- }
- _bannerEl.style.opacity = "1";
- return;
- }
- const banner = document.createElement("div");
- banner.className = "ai-lp-banner";
- banner.innerHTML =
- '' +
- '