From 27d8eae354595f7e32f218728e4094c9e80a9e28 Mon Sep 17 00:00:00 2001 From: abose Date: Sun, 8 Mar 2026 13:59:06 +0530 Subject: [PATCH 1/4] feat(ai): switch to getAIEntitlement with quota-limited chat for free users --- src/core-ai/AIChatPanel.js | 157 +++++++++++++++++++++++++++---- src/nls/root/strings.js | 5 + src/styles/Extn-AIChatPanel.less | 53 +++++++++++ 3 files changed, 199 insertions(+), 16 deletions(-) diff --git a/src/core-ai/AIChatPanel.js b/src/core-ai/AIChatPanel.js index e73948ebcc..9ceafa9835 100644 --- a/src/core-ai/AIChatPanel.js +++ b/src/core-ai/AIChatPanel.js @@ -88,6 +88,10 @@ define(function (require, exports, module) { let _isResumedSession = false; // Whether current session was resumed from history let _lastQuestions = null; // Last AskUserQuestion questions, for recording + // Quota state + let _cachedAIEntitlement = null; + let _quotaBarDismissed = false; + // DOM references let $panel, $messages, $status, $statusText, $textarea, $sendBtn, $stopBtn, $imagePreview; let $aiTabContainer = null; @@ -118,6 +122,7 @@ define(function (require, exports, module) { '' + '' + Strings.AI_CHAT_THINKING + '' + '' + + '' + '
' + '
' + '
' + @@ -257,26 +262,32 @@ define(function (require, exports, module) { } 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) { + EntitlementsManager.getAIEntitlement().then(function (entitlement) { + if (entitlement.aiDisabledByAdmin) { + if (_currentEntitlementState !== "adminDisabled") { + _removeCurrentPanel(); + _renderAdminDisabledUI(); + } + } else if (entitlement.needsLogin) { + if (_currentEntitlementState !== "login") { + _removeCurrentPanel(); + _renderLoginUI(); + } + } else if (entitlement.activated && entitlement.quotaLocal === null) { + // Pro user — unlimited access + _cachedAIEntitlement = entitlement; + if (_currentEntitlementState !== "chat") { + _removeCurrentPanel(); + _checkAvailability(); + } + } else if (entitlement.quotaLocal || !entitlement.activated) { + // Quota-limited user — allow chat with limits + _cachedAIEntitlement = entitlement; if (_currentEntitlementState !== "chat") { _removeCurrentPanel(); _checkAvailability(); } + _updateQuotaBar(); } else { if (_currentEntitlementState !== "upsell") { _removeCurrentPanel(); @@ -356,6 +367,105 @@ define(function (require, exports, module) { $aiTabContainer.empty().append($(html)); } + /** + * Update the quota usage bar in the chat panel. + * Shows daily or monthly usage — whichever is closer to its limit. + * Only visible when >50% of a limit is used. User can dismiss it. + */ + function _updateQuotaBar() { + const EntitlementsManager = _KernalModeTrust && _KernalModeTrust.EntitlementsManager; + if (!EntitlementsManager) { + return; + } + EntitlementsManager.getAIQuotaStatus().then(function (quota) { + const $quotaBar = $(".ai-chat-quota-bar"); + if (!$quotaBar.length || !quota || quota.unlimited) { + $quotaBar.hide(); + return; + } + if (_quotaBarDismissed) { + // User dismissed, but still block if fully exhausted + if (quota.monthlyUsed >= quota.monthlyLimit) { + _quotaBarDismissed = false; // force show exhausted + } else if (quota.dailyUsed >= quota.dailyLimit) { + _quotaBarDismissed = false; + } else { + $quotaBar.hide(); + return; + } + } + + const dailyPct = quota.dailyLimit ? quota.dailyUsed / quota.dailyLimit : 0; + const monthlyPct = quota.monthlyLimit ? quota.monthlyUsed / quota.monthlyLimit : 0; + const monthlyExhausted = quota.monthlyUsed >= quota.monthlyLimit; + const dailyExhausted = quota.dailyUsed >= quota.dailyLimit; + + // Determine which message to show: + // Show monthly if exhausted, or if >90% used and closer to limit than daily + // Show daily if >50% used + // Show whichever is closest to being fully used + let statusText = ""; + let isExhausted = false; + const buyURL = (_cachedAIEntitlement && _cachedAIEntitlement.buyURL) || + brackets.config.purchase_url; + + if (monthlyExhausted) { + statusText = StringUtils.format( + Strings.AI_CHAT_QUOTA_MONTHLY_EXHAUSTED, quota.monthlyLimit); + isExhausted = true; + } else if (dailyExhausted) { + statusText = StringUtils.format( + Strings.AI_CHAT_QUOTA_DAILY_EXHAUSTED, quota.dailyLimit); + isExhausted = true; + } else if (monthlyPct > 0.9 && monthlyPct >= dailyPct) { + statusText = StringUtils.format( + Strings.AI_CHAT_QUOTA_MONTHLY_STATUS, + quota.monthlyUsed, quota.monthlyLimit); + } else if (dailyPct > 0.5) { + statusText = StringUtils.format( + Strings.AI_CHAT_QUOTA_DAILY_STATUS, + quota.dailyUsed, quota.dailyLimit); + } else if (monthlyPct > 0.5) { + statusText = StringUtils.format( + Strings.AI_CHAT_QUOTA_MONTHLY_STATUS, + quota.monthlyUsed, quota.monthlyLimit); + } else { + $quotaBar.hide(); + return; + } + + let html = '
' + + '' + statusText + ''; + if (isExhausted) { + html += '' + + Strings.AI_CHAT_QUOTA_UPGRADE_BTN + ''; + } + html += '
'; + if (!isExhausted) { + html += ''; + } + $quotaBar.html(html).show(); + $quotaBar.find(".ai-quota-dismiss").on("click", function () { + _quotaBarDismissed = true; + $quotaBar.hide(); + }); + $quotaBar.find(".ai-quota-upgrade-link").on("click", function (e) { + e.preventDefault(); + Phoenix.app.openURLInDefaultBrowser(buyURL); + }); + + // Disable input when exhausted + if (isExhausted && $textarea) { + $textarea.prop("disabled", true); + $sendBtn.prop("disabled", true); + } else if ($textarea) { + $textarea.prop("disabled", false); + $sendBtn.prop("disabled", false); + } + }); + } + /** * Check if Claude CLI is available and render the appropriate UI. */ @@ -967,6 +1077,10 @@ define(function (require, exports, module) { if (!text || _isStreaming) { return; } + // Block sending if textarea is disabled (quota exhausted) + if ($textarea.prop("disabled")) { + return; + } // Show "+ New" button once a conversation starts $panel.find(".ai-new-session-btn").show(); @@ -1146,6 +1260,7 @@ define(function (require, exports, module) { if ($sendBtn) { $sendBtn.prop("disabled", false); } + _updateQuotaBar(); } // --- Event handlers for node-side events --- @@ -1557,6 +1672,15 @@ define(function (require, exports, module) { SnapshotStore.stopTracking(); _setStreaming(false); + // Consume one AI chat quota unit and refresh quota bar + const EntitlementsManager = _KernalModeTrust && _KernalModeTrust.EntitlementsManager; + if (EntitlementsManager) { + EntitlementsManager.aiChatDone().then(function () { + _quotaBarDismissed = false; + _updateQuotaBar(); + }); + } + // Save session to history if (data.sessionId) { _currentSessionId = data.sessionId; @@ -2724,6 +2848,7 @@ define(function (require, exports, module) { $textarea.closest(".ai-chat-input-wrap").removeClass("disabled"); $sendBtn.prop("disabled", false); $textarea[0].focus({ preventScroll: true }); + _updateQuotaBar(); // Scroll to bottom if ($messages && $messages.length) { diff --git a/src/nls/root/strings.js b/src/nls/root/strings.js index fa76f2918b..4e68215184 100644 --- a/src/nls/root/strings.js +++ b/src/nls/root/strings.js @@ -1849,6 +1849,11 @@ define({ "AI_CHAT_UPSELL_BTN": "Get Phoenix Pro", "AI_CHAT_ADMIN_DISABLED_TITLE": "AI Disabled", "AI_CHAT_ADMIN_DISABLED_MESSAGE": "AI features have been disabled by your system administrator.", + "AI_CHAT_QUOTA_DAILY_STATUS": "{0} / {1} daily chats used", + "AI_CHAT_QUOTA_MONTHLY_STATUS": "{0} / {1} monthly chats used", + "AI_CHAT_QUOTA_DAILY_EXHAUSTED": "You've used all {0} daily chats. Resets tomorrow.", + "AI_CHAT_QUOTA_MONTHLY_EXHAUSTED": "You've used all {0} monthly chats.", + "AI_CHAT_QUOTA_UPGRADE_BTN": "Get Phoenix Pro for unlimited chats", "AI_CHAT_TOOL_SEARCH_FILES": "Search files", "AI_CHAT_TOOL_SEARCH_CODE": "Search code", "AI_CHAT_TOOL_READ": "Read", diff --git a/src/styles/Extn-AIChatPanel.less b/src/styles/Extn-AIChatPanel.less index 915bd0c602..f1acab839e 100644 --- a/src/styles/Extn-AIChatPanel.less +++ b/src/styles/Extn-AIChatPanel.less @@ -1166,6 +1166,59 @@ } } +/* ── Quota bar ──────────────────────────────────────────────────────── */ +.ai-chat-quota-bar { + flex-shrink: 0; + display: flex; + align-items: center; + justify-content: space-between; + padding: 6px 10px; + margin: 0 8px; + border-radius: 6px; + background-color: rgba(255, 200, 50, 0.10); + border: 1px solid rgba(255, 200, 50, 0.20); + font-size: 12px; + color: @project-panel-text-1; + + .ai-quota-content { + display: flex; + flex-direction: column; + gap: 4px; + flex: 1; + min-width: 0; + } + + .ai-quota-text { + opacity: 0.85; + } + + .ai-quota-upgrade-link { + color: @bc-text-link; + font-weight: 600; + text-decoration: none; + cursor: pointer; + + &:hover { + text-decoration: underline; + } + } + + .ai-quota-dismiss { + background: none; + border: none; + color: @project-panel-text-1; + opacity: 0.5; + cursor: pointer; + padding: 2px 4px; + margin-left: 6px; + font-size: 12px; + + &:hover { + opacity: 1; + } + } +} + /* ── Input area ─────────────────────────────────────────────────────── */ .ai-chat-input-area { flex-shrink: 0; From 45eed92b6f52f1fd5afff1cdac488a80fa16dab0 Mon Sep 17 00:00:00 2001 From: abose Date: Sun, 8 Mar 2026 14:12:15 +0530 Subject: [PATCH 2/4] refactor: move out ai extension from core --- src/brackets.js | 5 +- src/core-ai/AIChatHistory.js | 542 ------ src/core-ai/AIChatPanel.js | 2881 ---------------------------- src/core-ai/AISnapshotStore.js | 648 ------- src/core-ai/aiPhoenixConnectors.js | 772 -------- src/core-ai/main.js | 75 - 6 files changed, 2 insertions(+), 4921 deletions(-) delete mode 100644 src/core-ai/AIChatHistory.js delete mode 100644 src/core-ai/AIChatPanel.js delete mode 100644 src/core-ai/AISnapshotStore.js delete mode 100644 src/core-ai/aiPhoenixConnectors.js delete mode 100644 src/core-ai/main.js diff --git a/src/brackets.js b/src/brackets.js index ae38ca4d7e..fb5045b96a 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,8 @@ 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"), + AISnapshotStore: require("extensionsIntegrated/phoenix-pro/core-ai/AISnapshotStore"), + AIChatHistory: require("extensionsIntegrated/phoenix-pro/core-ai/AIChatHistory"), doneLoading: false }; diff --git a/src/core-ai/AIChatHistory.js b/src/core-ai/AIChatHistory.js deleted file mode 100644 index 33e23e1c94..0000000000 --- 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 9ceafa9835..0000000000 --- a/src/core-ai/AIChatPanel.js +++ /dev/null @@ -1,2881 +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 - - // Quota state - let _cachedAIEntitlement = null; - let _quotaBarDismissed = false; - - // 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 + '
' + - '
' + - Strings.AI_CHAT_CLI_INSTALL_MSG + - '
' + - '' + - '
' + - '
'; - - const PLACEHOLDER_HTML = - '
' + - '
' + - '
' + - '
' + Strings.AI_CHAT_TITLE + '
' + - '
' + - Strings.AI_CHAT_DESKTOP_ONLY + - '
' + - '' + - '
' + - '
'; - - /** - * 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; - } - EntitlementsManager.getAIEntitlement().then(function (entitlement) { - if (entitlement.aiDisabledByAdmin) { - if (_currentEntitlementState !== "adminDisabled") { - _removeCurrentPanel(); - _renderAdminDisabledUI(); - } - } else if (entitlement.needsLogin) { - if (_currentEntitlementState !== "login") { - _removeCurrentPanel(); - _renderLoginUI(); - } - } else if (entitlement.activated && entitlement.quotaLocal === null) { - // Pro user — unlimited access - _cachedAIEntitlement = entitlement; - if (_currentEntitlementState !== "chat") { - _removeCurrentPanel(); - _checkAvailability(); - } - } else if (entitlement.quotaLocal || !entitlement.activated) { - // Quota-limited user — allow chat with limits - _cachedAIEntitlement = entitlement; - if (_currentEntitlementState !== "chat") { - _removeCurrentPanel(); - _checkAvailability(); - } - _updateQuotaBar(); - } 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 + '
' + - '
' + - Strings.AI_CHAT_LOGIN_MESSAGE + - '
' + - '' + - '
' + - '
'; - 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 + '
' + - '
' + - Strings.AI_CHAT_UPSELL_MESSAGE + - '
' + - '' + - '
' + - '
'; - 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 + '
' + - '
' + - Strings.AI_CHAT_ADMIN_DISABLED_MESSAGE + - '
' + - '
' + - '
'; - $aiTabContainer.empty().append($(html)); - } - - /** - * Update the quota usage bar in the chat panel. - * Shows daily or monthly usage — whichever is closer to its limit. - * Only visible when >50% of a limit is used. User can dismiss it. - */ - function _updateQuotaBar() { - const EntitlementsManager = _KernalModeTrust && _KernalModeTrust.EntitlementsManager; - if (!EntitlementsManager) { - return; - } - EntitlementsManager.getAIQuotaStatus().then(function (quota) { - const $quotaBar = $(".ai-chat-quota-bar"); - if (!$quotaBar.length || !quota || quota.unlimited) { - $quotaBar.hide(); - return; - } - if (_quotaBarDismissed) { - // User dismissed, but still block if fully exhausted - if (quota.monthlyUsed >= quota.monthlyLimit) { - _quotaBarDismissed = false; // force show exhausted - } else if (quota.dailyUsed >= quota.dailyLimit) { - _quotaBarDismissed = false; - } else { - $quotaBar.hide(); - return; - } - } - - const dailyPct = quota.dailyLimit ? quota.dailyUsed / quota.dailyLimit : 0; - const monthlyPct = quota.monthlyLimit ? quota.monthlyUsed / quota.monthlyLimit : 0; - const monthlyExhausted = quota.monthlyUsed >= quota.monthlyLimit; - const dailyExhausted = quota.dailyUsed >= quota.dailyLimit; - - // Determine which message to show: - // Show monthly if exhausted, or if >90% used and closer to limit than daily - // Show daily if >50% used - // Show whichever is closest to being fully used - let statusText = ""; - let isExhausted = false; - const buyURL = (_cachedAIEntitlement && _cachedAIEntitlement.buyURL) || - brackets.config.purchase_url; - - if (monthlyExhausted) { - statusText = StringUtils.format( - Strings.AI_CHAT_QUOTA_MONTHLY_EXHAUSTED, quota.monthlyLimit); - isExhausted = true; - } else if (dailyExhausted) { - statusText = StringUtils.format( - Strings.AI_CHAT_QUOTA_DAILY_EXHAUSTED, quota.dailyLimit); - isExhausted = true; - } else if (monthlyPct > 0.9 && monthlyPct >= dailyPct) { - statusText = StringUtils.format( - Strings.AI_CHAT_QUOTA_MONTHLY_STATUS, - quota.monthlyUsed, quota.monthlyLimit); - } else if (dailyPct > 0.5) { - statusText = StringUtils.format( - Strings.AI_CHAT_QUOTA_DAILY_STATUS, - quota.dailyUsed, quota.dailyLimit); - } else if (monthlyPct > 0.5) { - statusText = StringUtils.format( - Strings.AI_CHAT_QUOTA_MONTHLY_STATUS, - quota.monthlyUsed, quota.monthlyLimit); - } else { - $quotaBar.hide(); - return; - } - - let html = '
' + - '' + statusText + ''; - if (isExhausted) { - html += '' + - Strings.AI_CHAT_QUOTA_UPGRADE_BTN + ''; - } - html += '
'; - if (!isExhausted) { - html += ''; - } - $quotaBar.html(html).show(); - $quotaBar.find(".ai-quota-dismiss").on("click", function () { - _quotaBarDismissed = true; - $quotaBar.hide(); - }); - $quotaBar.find(".ai-quota-upgrade-link").on("click", function (e) { - e.preventDefault(); - Phoenix.app.openURLInDefaultBrowser(buyURL); - }); - - // Disable input when exhausted - if (isExhausted && $textarea) { - $textarea.prop("disabled", true); - $sendBtn.prop("disabled", true); - } else if ($textarea) { - $textarea.prop("disabled", false); - $sendBtn.prop("disabled", false); - } - }); - } - - /** - * 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; - } - // Block sending if textarea is disabled (quota exhausted) - if ($textarea.prop("disabled")) { - 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); - } - _updateQuotaBar(); - } - - // --- 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); - - // Consume one AI chat quota unit and refresh quota bar - const EntitlementsManager = _KernalModeTrust && _KernalModeTrust.EntitlementsManager; - if (EntitlementsManager) { - EntitlementsManager.aiChatDone().then(function () { - _quotaBarDismissed = false; - _updateQuotaBar(); - }); - } - - // 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 }); - _updateQuotaBar(); - - // 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 2c3873e758..0000000000 --- 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 bc89b8a568..0000000000 --- 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 = - '' + - '' + text.replace(/' + - ''; - banner.querySelector(".ai-lp-banner-close").addEventListener("click", function () { - _bannerDismissed = true; - _hideBanner(); - }); - toolbar.appendChild(banner); - _bannerEl = banner; - } - - /** - * Hide and remove the banner overlay with a fade-out transition. - */ - function _hideBanner() { - if (!_bannerEl) { - return; - } - _bannerEl.style.opacity = "0"; - const el = _bannerEl; - setTimeout(function () { - if (el.parentNode) { - el.parentNode.removeChild(el); - } - }, 300); - _bannerEl = null; - } - - /** - * Called when an execJsInLivePreview call starts. Increments the active - * count, saves mode and shows banner on first call. - */ - function _onExecJsStart() { - _activeExecJsCount++; - if (_activeExecJsCount === 1) { - // Cancel any pending auto-hide from a previous exec batch - if (_bannerAutoHideTimer) { - clearTimeout(_bannerAutoHideTimer); - _bannerAutoHideTimer = null; - } - _savedLivePreviewMode = LiveDevMain.getCurrentMode(); - if (_savedLivePreviewMode !== LivePreviewConstants.LIVE_PREVIEW_MODE) { - LiveDevMain.setMode(LivePreviewConstants.LIVE_PREVIEW_MODE); - } - _bannerDismissed = false; - _showBanner(Strings.AI_LIVE_PREVIEW_BANNER_TEXT); - } - } - - /** - * Called when an execJsInLivePreview call finishes. Decrements the count - * and restores mode / hides banner when all calls are done. - */ - function _onExecJsDone() { - _activeExecJsCount = Math.max(0, _activeExecJsCount - 1); - if (_activeExecJsCount === 0) { - if (_savedLivePreviewMode && _savedLivePreviewMode !== LivePreviewConstants.LIVE_PREVIEW_MODE) { - LiveDevMain.setMode(_savedLivePreviewMode); - } - _savedLivePreviewMode = null; - // Keep the banner visible briefly so the user can read it - if (_bannerAutoHideTimer) { - clearTimeout(_bannerAutoHideTimer); - } - _bannerAutoHideTimer = setTimeout(function () { - _hideBanner(); - _bannerAutoHideTimer = null; - }, 5000); - } - } - - // --- Editor state --- - - /** - * Get the current editor state: active file, working set, live preview file. - * Called from the node-side MCP server via execPeer. - */ - function getEditorState() { - const deferred = new $.Deferred(); - const activeFile = MainViewManager.getCurrentlyViewedFile(MainViewManager.ACTIVE_PANE); - const workingSet = MainViewManager.getWorkingSet(MainViewManager.ALL_PANES); - - let activeFilePath = null; - if (activeFile) { - activeFilePath = activeFile.fullPath; - if (activeFilePath.startsWith("/tauri/")) { - activeFilePath = activeFilePath.replace("/tauri", ""); - } - } - - const workingSetPaths = workingSet.map(function (file) { - let p = file.fullPath; - if (p.startsWith("/tauri/")) { - p = p.replace("/tauri", ""); - } - const doc = DocumentManager.getOpenDocumentForPath(file.fullPath); - const isDirty = doc ? doc.isDirty : false; - return { path: p, isDirty: isDirty }; - }); - - let livePreviewFile = null; - const $panelTitle = $("#panel-live-preview-title"); - if ($panelTitle.length) { - livePreviewFile = $panelTitle.attr("data-fullPath") || null; - if (livePreviewFile && livePreviewFile.startsWith("/tauri/")) { - livePreviewFile = livePreviewFile.replace("/tauri", ""); - } - } - - const result = { - activeFile: activeFilePath, - workingSet: workingSetPaths, - livePreviewFile: livePreviewFile - }; - - // Include cursor/selection info from the active editor - const editor = EditorManager.getActiveEditor(); - if (editor) { - const doc = editor.document; - const totalLines = doc.getLine(doc.getText().split("\n").length - 1) !== undefined - ? doc.getText().split("\n").length : 0; - - if (editor.hasSelection()) { - const sel = editor.getSelection(); - let selectedText = editor.getSelectedText(); - if (selectedText.length > 10000) { - selectedText = selectedText.slice(0, 10000) + "...(truncated, use Read tool for full content)"; - } - result.cursorInfo = { - hasSelection: true, - startLine: sel.start.line + 1, - endLine: sel.end.line + 1, - selectedText: selectedText, - totalLines: totalLines - }; - } else { - const cursor = editor.getCursorPos(); - const lineNum = cursor.line; - const lineText = doc.getLine(lineNum) || ""; - // Include a few surrounding lines for context - const contextStart = Math.max(0, lineNum - 2); - const contextEnd = Math.min(totalLines - 1, lineNum + 2); - const MAX_LINE_LEN = 200; - const contextLines = []; - for (let i = contextStart; i <= contextEnd; i++) { - const prefix = (i === lineNum) ? "> " : " "; - let text = doc.getLine(i) || ""; - if (text.length > MAX_LINE_LEN) { - text = text.slice(0, MAX_LINE_LEN) + "..."; - } - contextLines.push(prefix + (i + 1) + ": " + text); - } - const trimmedLineText = lineText.length > MAX_LINE_LEN - ? lineText.slice(0, MAX_LINE_LEN) + "..." : lineText; - result.cursorInfo = { - hasSelection: false, - line: lineNum + 1, - column: cursor.ch + 1, - lineText: trimmedLineText, - context: contextLines.join("\n"), - totalLines: totalLines - }; - } - } - - // If live preview is connected, query the selected element (best-effort) - if (LiveDevProtocol.getConnectionIds().length > 0) { - const LP_SELECTED_EL_JS = - "(function(){" + - "var el=window.__current_ph_lp_selected;" + - "if(!el||!el.isConnected)return null;" + - "var tag=el.tagName.toLowerCase();" + - "var id=el.id?'#'+el.id:'';" + - "var cls=el.className&&typeof el.className==='string'?" + - "'.'+el.className.trim().split(/\\s+/).join('.'):" + - "'';" + - "var text=el.textContent||'';" + - "if(text.length>80)text=text.slice(0,80)+'...';" + - "text=text.replace(/\\n/g,' ').trim();" + - "return{tag:tag,selector:tag+id+cls,textPreview:text};" + - "})()"; - LiveDevProtocol.evaluate(LP_SELECTED_EL_JS) - .done(function (evalResult) { - if (evalResult && evalResult.tag) { - result.livePreviewSelectedElement = evalResult; - } - deferred.resolve(result); - }) - .fail(function () { - deferred.resolve(result); - }); - } else { - deferred.resolve(result); - } - - return deferred.promise(); - } - - // --- Screenshot --- - - /** - * Take a screenshot of the Phoenix editor window. - * Called from the node-side MCP server via execPeer. - * @param {Object} params - { selector?: string } - * @return {{ base64: string|null, error?: string }} - */ - function _captureScreenshot(selector) { - const deferred = new $.Deferred(); - Phoenix.app.screenShotBinary(selector || undefined) - .then(function (bytes) { - let binary = ""; - const chunkSize = 8192; - for (let i = 0; i < bytes.length; i += chunkSize) { - const chunk = bytes.subarray(i, Math.min(i + chunkSize, bytes.length)); - binary += String.fromCharCode.apply(null, chunk); - } - const base64 = btoa(binary); - _lastScreenshotBase64 = base64; - exports.trigger("screenshotCaptured", base64); - deferred.resolve({ base64: base64 }); - }) - .catch(function (err) { - console.error("[AI Control] Screenshot failed:", err); - deferred.resolve({ base64: null, error: err.message || String(err) }); - }); - return deferred.promise(); - } - - function takeScreenshot(params) { - const deferred = new $.Deferred(); - if (!Phoenix || !Phoenix.app || !Phoenix.app.screenShotBinary) { - deferred.resolve({ base64: null, error: "Screenshot API not available" }); - return deferred.promise(); - } - - function _onResult(result) { - if (result.base64) { - console.log("[AI Control] Screenshot taken:", params.selector || "full window", - params.purePreview ? "(pure preview)" : ""); - } - deferred.resolve(result); - } - - if (params.purePreview) { - const previousMode = LiveDevMain.getCurrentMode(); - LiveDevMain.setMode(LivePreviewConstants.LIVE_PREVIEW_MODE); - // Allow time for the mode change to propagate to the live preview iframe - setTimeout(function () { - _captureScreenshot(params.selector) - .done(function (result) { - LiveDevMain.setMode(previousMode); - _onResult(result); - }); - }, 150); - } else { - _captureScreenshot(params.selector) - .done(function (result) { _onResult(result); }); - } - - return deferred.promise(); - } - - // --- File content --- - - /** - * Check if a file has unsaved changes in the editor and return its content. - * Used by the node-side Read hook to serve dirty buffer content to Claude. - */ - function getFileContent(params) { - const vfsPath = SnapshotStore.realToVfsPath(params.filePath); - const doc = DocumentManager.getOpenDocumentForPath(vfsPath); - if (doc && doc.isDirty) { - return { isDirty: true, content: doc.getText() }; - } - return { isDirty: false, content: null }; - } - - // --- Edit application --- - - /** - * Apply a single edit to a document buffer and save to disk. - * Called immediately when Claude's Write/Edit is intercepted, so - * subsequent Reads see the new content both in the buffer and on disk. - * @param {Object} edit - {file, oldText, newText} - * @return {$.Promise} resolves with {previousContent} for undo support - */ - function _applySingleEdit(edit) { - const result = new $.Deferred(); - const vfsPath = SnapshotStore.realToVfsPath(edit.file); - - function _applyToDoc() { - DocumentManager.getDocumentForPath(vfsPath) - .done(function (doc) { - try { - const previousContent = doc.getText(); - if (edit.oldText === null) { - // Write (new file or full replacement) - doc.setText(edit.newText); - } else { - // Edit — find oldText and replace - const docText = doc.getText(); - const idx = docText.indexOf(edit.oldText); - if (idx === -1) { - result.reject(new Error(Strings.AI_CHAT_EDIT_NOT_FOUND)); - return; - } - const startPos = doc.posFromIndex(idx); - const endPos = doc.posFromIndex(idx + edit.oldText.length); - doc.replaceRange(edit.newText, startPos, endPos); - } - // Open the file in the editor and save to disk - CommandManager.execute(Commands.CMD_OPEN, { fullPath: vfsPath }); - SnapshotStore.saveDocToDisk(doc).always(function () { - result.resolve({ previousContent: previousContent }); - }); - } catch (err) { - result.reject(err); - } - }) - .fail(function (err) { - result.reject(err || new Error("Could not open document")); - }); - } - - if (edit.oldText === null) { - // Write — file may not exist yet. Only create on disk if it doesn't - // already exist, to avoid triggering "external change" warnings. - const file = FileSystem.getFileForPath(vfsPath); - file.exists(function (existErr, exists) { - if (exists) { - // File exists — just open and set content, no disk write - _applyToDoc(); - } else { - // New file — create on disk first so getDocumentForPath works - file.write("", function (writeErr) { - if (writeErr) { - result.reject(new Error("Could not create file: " + writeErr)); - return; - } - _applyToDoc(); - }); - } - }); - } else { - // Edit — file must already exist - _applyToDoc(); - } - - return result.promise(); - } - - /** - * Apply an edit to the editor buffer immediately (called by node-side hooks). - * The file appears as a dirty tab so subsequent Reads see the new content. - * @param {Object} params - {file, oldText, newText} - * @return {Promise<{applied: boolean, error?: string}>} - */ - function _isFileInLivePreview(filePath) { - const liveDetails = LiveDevMain.getLivePreviewDetails(); - if (!liveDetails || !liveDetails.liveDocument) { - return false; - } - const vfsPath = SnapshotStore.realToVfsPath(filePath); - const liveDocPath = liveDetails.liveDocument.doc.file.fullPath; - if (vfsPath === liveDocPath) { - return true; - } - return !!(liveDetails.liveDocument.isRelated && liveDetails.liveDocument.isRelated(vfsPath)); - } - - function applyEditToBuffer(params) { - const deferred = new $.Deferred(); - _applySingleEdit(params) - .done(function (result) { - if (result && result.previousContent !== undefined) { - _previousContentMap[params.file] = result.previousContent; - } - deferred.resolve({ - applied: true, - isLivePreviewRelated: _isFileInLivePreview(params.file) - }); - }) - .fail(function (err) { - console.error("[AI Control] applyEditToBuffer failed:", err); - deferred.resolve({ applied: false, error: err.message || String(err) }); - }); - return deferred.promise(); - } - - // --- Previous content map access (used by AIChatPanel for snapshot tracking) --- - - /** - * Get the previous content recorded for a file before the last edit. - * @param {string} filePath - * @return {string|undefined} - */ - function getPreviousContent(filePath) { - return _previousContentMap[filePath]; - } - - /** - * Clear all recorded previous content entries (called on new session). - */ - function clearPreviousContentMap() { - Object.keys(_previousContentMap).forEach(function (key) { - delete _previousContentMap[key]; - }); - } - - /** - * Get the last captured screenshot as base64 PNG. - * @return {string|null} - */ - function getLastScreenshot() { - return _lastScreenshotBase64; - } - - // --- Live preview JS execution --- - - /** - * Execute JavaScript in the live preview iframe. - * Reuses the pattern from phoenix-builder-client.js: fast path if connected, - * otherwise auto-opens live preview and waits for connection. - * @param {Object} params - { code: string } - * @return {$.Promise} resolves with { result } or { error } - */ - function execJsInLivePreview(params) { - const deferred = new $.Deferred(); - _onExecJsStart(); - - function _evaluate() { - LiveDevProtocol.evaluate(params.code) - .done(function (evalResult) { - _onExecJsDone(); - deferred.resolve({ result: JSON.stringify(evalResult) }); - }) - .fail(function (err) { - _onExecJsDone(); - console.error("[AI Control] execJsInLivePreview failed:", err); - deferred.resolve({ error: (err && err.message) || String(err) || "evaluate() failed" }); - }); - } - - // Fast path: already connected - if (LiveDevProtocol.getConnectionIds().length > 0) { - _evaluate(); - return deferred.promise(); - } - - // Need to ensure live preview is open and connected - const panel = WorkspaceManager.getPanelForID("live-preview-panel"); - if (!panel || !panel.isVisible()) { - CommandManager.execute("file.liveFilePreview"); - } else { - LiveDevMain.openLivePreview(); - } - - // Wait for a live preview connection (up to 30s) - const TIMEOUT = 30000; - const POLL_INTERVAL = 500; - let settled = false; - let pollTimer = null; - - function cleanup() { - settled = true; - if (pollTimer) { - clearInterval(pollTimer); - pollTimer = null; - } - LiveDevProtocol.off("ConnectionConnect.aiExecJsLivePreview"); - } - - const timeoutTimer = setTimeout(function () { - if (settled) { return; } - cleanup(); - _onExecJsDone(); - console.error("[AI Control] Timed out waiting for live preview connection (30s)"); - deferred.resolve({ error: "Timed out waiting for live preview connection (30s)" }); - }, TIMEOUT); - - function onConnected() { - if (settled) { return; } - cleanup(); - clearTimeout(timeoutTimer); - _evaluate(); - } - - LiveDevProtocol.on("ConnectionConnect.aiExecJsLivePreview", onConnected); - - // Safety-net poll in case the event was missed - pollTimer = setInterval(function () { - if (settled) { - clearInterval(pollTimer); - return; - } - if (LiveDevProtocol.getConnectionIds().length > 0) { - onConnected(); - } - }, POLL_INTERVAL); - - return deferred.promise(); - } - - // --- Editor control --- - - /** - * Control the editor: open/close files, set cursor, set selection. - * Called from the node-side MCP server via execPeer. - * @param {Object} params - { operation, filePath, startLine, startCh, endLine, endCh, line, ch } - * @return {$.Promise} resolves with { success: true } or { success: false, error: "..." } - */ - function controlEditor(params) { - const deferred = new $.Deferred(); - const vfsPath = SnapshotStore.realToVfsPath(params.filePath); - console.log("[AI Control] controlEditor:", params.operation, params.filePath, "-> vfs:", vfsPath); - - function _resolve(result) { - if (!result.success) { - console.error("[AI Control] controlEditor failed:", params.operation, vfsPath, result.error); - } - deferred.resolve(result); - } - - switch (params.operation) { - case "open": - CommandManager.execute(Commands.CMD_ADD_TO_WORKINGSET_AND_OPEN, { fullPath: vfsPath }) - .done(function () { _resolve({ success: true }); }) - .fail(function (err) { _resolve({ success: false, error: String(err) }); }); - break; - - case "close": { - const file = FileSystem.getFileForPath(vfsPath); - CommandManager.execute(Commands.FILE_CLOSE, { file: file, _forceClose: true }) - .done(function () { _resolve({ success: true }); }) - .fail(function (err) { _resolve({ success: false, error: String(err) }); }); - break; - } - - case "openInWorkingSet": - CommandManager.execute(Commands.CMD_ADD_TO_WORKINGSET_AND_OPEN, { fullPath: vfsPath }) - .done(function () { _resolve({ success: true }); }) - .fail(function (err) { _resolve({ success: false, error: String(err) }); }); - break; - - case "setSelection": - CommandManager.execute(Commands.CMD_OPEN, { fullPath: vfsPath }) - .done(function () { - const editor = EditorManager.getActiveEditor(); - if (editor) { - editor.setSelection( - { line: params.startLine - 1, ch: params.startCh - 1 }, - { line: params.endLine - 1, ch: params.endCh - 1 }, - true - ); - _resolve({ success: true }); - } else { - _resolve({ success: false, error: "No active editor after opening file" }); - } - }) - .fail(function (err) { _resolve({ success: false, error: String(err) }); }); - break; - - case "setCursorPos": - CommandManager.execute(Commands.CMD_OPEN, { fullPath: vfsPath }) - .done(function () { - const editor = EditorManager.getActiveEditor(); - if (editor) { - editor.setCursorPos(params.line - 1, params.ch - 1, true); - _resolve({ success: true }); - } else { - _resolve({ success: false, error: "No active editor after opening file" }); - } - }) - .fail(function (err) { _resolve({ success: false, error: String(err) }); }); - break; - - default: - _resolve({ success: false, error: "Unknown operation: " + params.operation }); - } - - return deferred.promise(); - } - - // --- Live preview resize --- - - /** - * Resize the live preview panel to a specific width in pixels. - * @param {Object} params - { width: number } - * @return {$.Promise} resolves with { actualWidth } or { error } - */ - function resizeLivePreview(params) { - const deferred = new $.Deferred(); - - if (!params.width) { - deferred.resolve({ error: "Provide 'width' as a number in pixels" }); - return deferred.promise(); - } - - const targetWidth = params.width; - const label = targetWidth + "px"; - - // Ensure live preview panel is open - const panel = WorkspaceManager.getPanelForID("live-preview-panel"); - if (!panel || !panel.isVisible()) { - CommandManager.execute("file.liveFilePreview"); - } - - // Give the panel a moment to open, then resize - setTimeout(function () { - WorkspaceManager.setPluginPanelWidth(targetWidth); - - // Read back actual width from the toolbar - const toolbar = document.getElementById("live-preview-plugin-toolbar"); - const actualWidth = toolbar ? toolbar.offsetWidth : targetWidth; - - // Show brief banner - _bannerDismissed = false; - _showBanner(StringUtils.format(Strings.AI_LIVE_PREVIEW_BANNER_RESIZE, label)); - if (_bannerAutoHideTimer) { - clearTimeout(_bannerAutoHideTimer); - } - _bannerAutoHideTimer = setTimeout(function () { - _hideBanner(); - _bannerAutoHideTimer = null; - }, 5000); - - const result = { actualWidth: actualWidth }; - if (actualWidth !== targetWidth) { - result.clamped = true; - result.note = "Requested " + targetWidth + "px but the editor window can only " + - "accommodate " + actualWidth + "px. The user needs to increase the editor " + - "window size to allow a wider preview."; - } - deferred.resolve(result); - }, 100); - - return deferred.promise(); - } - - exports.getEditorState = getEditorState; - exports.takeScreenshot = takeScreenshot; - exports.getFileContent = getFileContent; - exports.applyEditToBuffer = applyEditToBuffer; - exports.getPreviousContent = getPreviousContent; - exports.clearPreviousContentMap = clearPreviousContentMap; - exports.getLastScreenshot = getLastScreenshot; - exports.execJsInLivePreview = execJsInLivePreview; - exports.controlEditor = controlEditor; - exports.resizeLivePreview = resizeLivePreview; - - EventDispatcher.makeEventDispatcher(exports); -}); diff --git a/src/core-ai/main.js b/src/core-ai/main.js deleted file mode 100644 index 794aae4f8a..0000000000 --- a/src/core-ai/main.js +++ /dev/null @@ -1,75 +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 sidebar tab integration. Sets up a NodeConnector to the claude-code-agent - * running in the node process and initializes the AIChatPanel UI. - * - * In non-native (browser) builds, shows a placeholder message instead. - */ -define(function (require, exports, module) { - - const AppInit = require("utils/AppInit"), - SidebarTabs = require("view/SidebarTabs"), - NodeConnector = require("NodeConnector"), - AIChatPanel = require("core-ai/AIChatPanel"), - PhoenixConnectors = require("core-ai/aiPhoenixConnectors"); - - const AI_CONNECTOR_ID = "ph_ai_claude"; - - exports.getFileContent = async function (params) { - return PhoenixConnectors.getFileContent(params); - }; - - exports.applyEditToBuffer = async function (params) { - return PhoenixConnectors.applyEditToBuffer(params); - }; - - exports.getEditorState = async function () { - return PhoenixConnectors.getEditorState(); - }; - - exports.takeScreenshot = async function (params) { - return PhoenixConnectors.takeScreenshot(params); - }; - - exports.execJsInLivePreview = async function (params) { - return PhoenixConnectors.execJsInLivePreview(params); - }; - - exports.controlEditor = async function (params) { - return PhoenixConnectors.controlEditor(params); - }; - - exports.resizeLivePreview = async function (params) { - return PhoenixConnectors.resizeLivePreview(params); - }; - - AppInit.appReady(function () { - SidebarTabs.addTab("ai", "AI", "fa-solid fa-wand-magic-sparkles", { priority: 200 }); - - if (Phoenix.isNativeApp) { - const nodeConnector = NodeConnector.createNodeConnector(AI_CONNECTOR_ID, exports); - AIChatPanel.init(nodeConnector); - } else { - AIChatPanel.initPlaceholder(); - } - }); -}); From 82307a33021ac8ea4066bf0159b7c9cdcccbfa5b Mon Sep 17 00:00:00 2001 From: abose Date: Sun, 8 Mar 2026 14:21:57 +0530 Subject: [PATCH 3/4] refactor: remove core-ai test exports from brackets.js Move AI test module injection to core-ai/main.js and relocate ai-snapshot-test and ai-history-test to phoenix-pro/unit-tests. --- src/brackets.js | 2 - test/UnitTestSuite.js | 2 - test/spec/ai-history-test.js | 741 ---------------------------------- test/spec/ai-snapshot-test.js | 717 -------------------------------- 4 files changed, 1462 deletions(-) delete mode 100644 test/spec/ai-history-test.js delete mode 100644 test/spec/ai-snapshot-test.js diff --git a/src/brackets.js b/src/brackets.js index fb5045b96a..3a3e08c739 100644 --- a/src/brackets.js +++ b/src/brackets.js @@ -296,8 +296,6 @@ define(function (require, exports, module) { SidebarTabs: require("view/SidebarTabs"), SidebarView: require("project/SidebarView"), WorkingSetView: require("project/WorkingSetView"), - AISnapshotStore: require("extensionsIntegrated/phoenix-pro/core-ai/AISnapshotStore"), - AIChatHistory: require("extensionsIntegrated/phoenix-pro/core-ai/AIChatHistory"), doneLoading: false }; diff --git a/test/UnitTestSuite.js b/test/UnitTestSuite.js index 946fb6913d..0e181c58c0 100644 --- a/test/UnitTestSuite.js +++ b/test/UnitTestSuite.js @@ -62,8 +62,6 @@ define(function (require, exports, module) { require("spec/KeybindingManager-integ-test"); require("spec/LanguageManager-test"); require("spec/LanguageManager-integ-test"); - require("spec/ai-snapshot-test"); - require("spec/ai-history-test"); require("spec/LowLevelFileIO-test"); require("spec/Metrics-test"); require("spec/MultiRangeInlineEditor-test"); diff --git a/test/spec/ai-history-test.js b/test/spec/ai-history-test.js deleted file mode 100644 index 783994db96..0000000000 --- a/test/spec/ai-history-test.js +++ /dev/null @@ -1,741 +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. - * - */ - -/*global describe, beforeAll, afterAll, beforeEach, afterEach, it, expect, jsPromise */ - -define(function (require, exports, module) { - - const SpecRunnerUtils = require("spec/SpecRunnerUtils"); - - const tempDir = SpecRunnerUtils.getTempDirectory(); - - // Test fixture data - const SIMPLE_SESSION = require("text!spec/ai-history-test-files/simple-session.json"); - const MULTI_TOOL_SESSION = require("text!spec/ai-history-test-files/multi-tool-session.json"); - const SESSION_WITH_IMAGES = require("text!spec/ai-history-test-files/session-with-images.json"); - const SESSION_WITH_ERRORS = require("text!spec/ai-history-test-files/session-with-errors.json"); - const SESSION_WITH_QUESTIONS = require("text!spec/ai-history-test-files/session-with-questions.json"); - const SESSION_WITH_OTHER = require("text!spec/ai-history-test-files/session-with-other-answer.json"); - - let AIChatHistory, - FileSystem, - testWindow; - - describe("integration:AIChatHistory", function () { - - beforeAll(async function () { - testWindow = await SpecRunnerUtils.createTestWindowAndRun(); - AIChatHistory = testWindow.brackets.test.AIChatHistory; - FileSystem = testWindow.brackets.test.FileSystem; - }, 30000); - - afterAll(async function () { - AIChatHistory = null; - FileSystem = null; - testWindow = null; - await SpecRunnerUtils.closeTestWindow(); - }, 30000); - - beforeEach(async function () { - await SpecRunnerUtils.createTempDirectory(); - await SpecRunnerUtils.loadProjectInTestWindow(tempDir); - }); - - afterEach(async function () { - // Clean up: clear history metadata and files - await jsPromise(new Promise(function (resolve) { - AIChatHistory.clearAllHistory(resolve); - })); - await SpecRunnerUtils.removeTempDirectory(); - }); - - // --- Helpers --- - - function saveChatHistory(sessionId, data) { - return new Promise(function (resolve, reject) { - AIChatHistory.saveChatHistory(sessionId, data, function (err) { - if (err) { reject(err); } else { resolve(); } - }); - }); - } - - function loadChatHistory(sessionId) { - return new Promise(function (resolve, reject) { - AIChatHistory.loadChatHistory(sessionId, function (err, data) { - if (err) { reject(err); } else { resolve(data); } - }); - }); - } - - function deleteSession(sessionId) { - return new Promise(function (resolve) { - AIChatHistory.deleteSession(sessionId, resolve); - }); - } - - function clearAllHistory() { - return new Promise(function (resolve) { - AIChatHistory.clearAllHistory(resolve); - }); - } - - function loadFixture(jsonText) { - return JSON.parse(jsonText); - } - - function makeSampleSession(id, title, messages) { - return { - id: id, - title: title || "Test session", - timestamp: Date.now(), - messages: messages || [ - { type: "user", text: title || "Hello" }, - { type: "assistant", markdown: "Hi there!", isFirst: true }, - { type: "complete" } - ] - }; - } - - // --- Session metadata (StateManager) --- - - describe("session metadata", function () { - it("should return empty array when no history exists", function () { - const history = AIChatHistory.loadSessionHistory(); - expect(Array.isArray(history)).toBe(true); - expect(history.length).toBe(0); - }); - - it("should record and load session metadata", function () { - AIChatHistory.recordSessionMetadata("sess-1", "First message"); - const history = AIChatHistory.loadSessionHistory(); - expect(history.length).toBe(1); - expect(history[0].id).toBe("sess-1"); - expect(history[0].title).toBe("First message"); - expect(typeof history[0].timestamp).toBe("number"); - }); - - it("should store most recent session first", function () { - AIChatHistory.recordSessionMetadata("sess-1", "First"); - AIChatHistory.recordSessionMetadata("sess-2", "Second"); - const history = AIChatHistory.loadSessionHistory(); - expect(history.length).toBe(2); - expect(history[0].id).toBe("sess-2"); - expect(history[1].id).toBe("sess-1"); - }); - - it("should move existing session to top on re-record", function () { - AIChatHistory.recordSessionMetadata("sess-1", "First"); - AIChatHistory.recordSessionMetadata("sess-2", "Second"); - AIChatHistory.recordSessionMetadata("sess-1", "First updated"); - const history = AIChatHistory.loadSessionHistory(); - expect(history.length).toBe(2); - expect(history[0].id).toBe("sess-1"); - expect(history[0].title).toBe("First updated"); - expect(history[1].id).toBe("sess-2"); - }); - - it("should truncate title to SESSION_TITLE_MAX_LEN", function () { - const longTitle = "A".repeat(200); - AIChatHistory.recordSessionMetadata("sess-1", longTitle); - const history = AIChatHistory.loadSessionHistory(); - expect(history[0].title.length).toBe(AIChatHistory.SESSION_TITLE_MAX_LEN); - }); - - it("should cap history at 50 entries", function () { - for (let i = 0; i < 55; i++) { - AIChatHistory.recordSessionMetadata("sess-" + i, "Session " + i); - } - const history = AIChatHistory.loadSessionHistory(); - expect(history.length).toBe(50); - // Most recent should be first - expect(history[0].id).toBe("sess-54"); - }); - - it("should use 'Untitled' for null or empty title", function () { - AIChatHistory.recordSessionMetadata("sess-1", null); - AIChatHistory.recordSessionMetadata("sess-2", ""); - const history = AIChatHistory.loadSessionHistory(); - expect(history[0].title).toBe("Untitled"); - expect(history[1].title).toBe("Untitled"); - }); - }); - - // --- Chat history file storage --- - - describe("chat history file storage", function () { - it("should save and load a simple session from fixture", async function () { - const fixture = loadFixture(SIMPLE_SESSION); - await saveChatHistory(fixture.id, fixture); - const loaded = await loadChatHistory(fixture.id); - expect(loaded.id).toBe(fixture.id); - expect(loaded.title).toBe("What is 2+2?"); - expect(loaded.messages.length).toBe(3); - expect(loaded.messages[0].type).toBe("user"); - expect(loaded.messages[0].text).toBe("What is 2+2?"); - expect(loaded.messages[1].type).toBe("assistant"); - expect(loaded.messages[1].markdown).toBe("The answer is **4**."); - expect(loaded.messages[2].type).toBe("complete"); - }); - - it("should save and load a multi-tool session from fixture", async function () { - const fixture = loadFixture(MULTI_TOOL_SESSION); - await saveChatHistory(fixture.id, fixture); - const loaded = await loadChatHistory(fixture.id); - expect(loaded.id).toBe("multi-tool-sess"); - expect(loaded.messages.length).toBe(13); - // Verify various message types survived round-trip - expect(loaded.messages[2].type).toBe("tool"); - expect(loaded.messages[2].toolName).toBe("Glob"); - expect(loaded.messages[2].elapsed).toBe(0.2); - expect(loaded.messages[7].type).toBe("tool_edit"); - expect(loaded.messages[7].linesAdded).toBe(15); - expect(loaded.messages[11].type).toBe("edit_summary"); - expect(loaded.messages[11].files.length).toBe(2); - }); - - it("should preserve images through round-trip", async function () { - const fixture = loadFixture(SESSION_WITH_IMAGES); - await saveChatHistory(fixture.id, fixture); - const loaded = await loadChatHistory(fixture.id); - expect(loaded.messages[0].images.length).toBe(2); - expect(loaded.messages[0].images[0].dataUrl).toBe("data:image/png;base64,iVBORw0KGgoAAAANSUhEUg=="); - expect(loaded.messages[0].images[0].mediaType).toBe("image/png"); - expect(loaded.messages[0].images[1].mediaType).toBe("image/jpeg"); - }); - - it("should preserve error messages through round-trip", async function () { - const fixture = loadFixture(SESSION_WITH_ERRORS); - await saveChatHistory(fixture.id, fixture); - const loaded = await loadChatHistory(fixture.id); - expect(loaded.messages[3].type).toBe("error"); - expect(loaded.messages[3].text).toBe("Process exited with code 1: Tests failed"); - }); - - it("should preserve question/answer data through round-trip", async function () { - const fixture = loadFixture(SESSION_WITH_QUESTIONS); - await saveChatHistory(fixture.id, fixture); - const loaded = await loadChatHistory(fixture.id); - const q = loaded.messages[2]; - expect(q.type).toBe("question"); - expect(q.questions.length).toBe(2); - expect(q.questions[0].question).toBe("Which auth method do you prefer?"); - expect(q.questions[0].options.length).toBe(3); - expect(q.answers["Which auth method do you prefer?"]).toBe("JWT"); - expect(q.answers["Which database?"]).toBe("PostgreSQL"); - }); - - it("should overwrite existing session file on re-save", async function () { - const session1 = makeSampleSession("sess-overwrite", "Original"); - await saveChatHistory("sess-overwrite", session1); - - const session2 = makeSampleSession("sess-overwrite", "Updated"); - session2.messages.push({ type: "user", text: "Follow-up" }); - await saveChatHistory("sess-overwrite", session2); - - const loaded = await loadChatHistory("sess-overwrite"); - expect(loaded.title).toBe("Updated"); - expect(loaded.messages.length).toBe(4); - }); - - it("should fail gracefully when loading non-existent session", async function () { - let error = null; - try { - await loadChatHistory("does-not-exist"); - } catch (err) { - error = err; - } - expect(error).not.toBeNull(); - }); - - it("should save multiple sessions independently", async function () { - const fixture1 = loadFixture(SIMPLE_SESSION); - const fixture2 = loadFixture(MULTI_TOOL_SESSION); - await saveChatHistory(fixture1.id, fixture1); - await saveChatHistory(fixture2.id, fixture2); - - const loaded1 = await loadChatHistory(fixture1.id); - const loaded2 = await loadChatHistory(fixture2.id); - expect(loaded1.title).toBe("What is 2+2?"); - expect(loaded2.title).toBe("Refactor the utils module"); - }); - }); - - // --- Deletion --- - - describe("deletion", function () { - it("should delete a single session (metadata + file)", async function () { - AIChatHistory.recordSessionMetadata("sess-del", "To delete"); - await saveChatHistory("sess-del", makeSampleSession("sess-del", "To delete")); - AIChatHistory.recordSessionMetadata("sess-keep", "Keep me"); - await saveChatHistory("sess-keep", makeSampleSession("sess-keep", "Keep me")); - - await deleteSession("sess-del"); - - const history = AIChatHistory.loadSessionHistory(); - expect(history.length).toBe(1); - expect(history[0].id).toBe("sess-keep"); - - let loadError = null; - try { - await loadChatHistory("sess-del"); - } catch (err) { - loadError = err; - } - expect(loadError).not.toBeNull(); - - const kept = await loadChatHistory("sess-keep"); - expect(kept.id).toBe("sess-keep"); - }); - - it("should clear all history (metadata + all files)", async function () { - AIChatHistory.recordSessionMetadata("sess-1", "First"); - AIChatHistory.recordSessionMetadata("sess-2", "Second"); - await saveChatHistory("sess-1", makeSampleSession("sess-1", "First")); - await saveChatHistory("sess-2", makeSampleSession("sess-2", "Second")); - - await clearAllHistory(); - - const history = AIChatHistory.loadSessionHistory(); - expect(history.length).toBe(0); - - let err1 = null, err2 = null; - try { await loadChatHistory("sess-1"); } catch (e) { err1 = e; } - try { await loadChatHistory("sess-2"); } catch (e) { err2 = e; } - expect(err1).not.toBeNull(); - expect(err2).not.toBeNull(); - }); - - it("should handle deleting non-existent session gracefully", async function () { - await deleteSession("non-existent-id"); - const history = AIChatHistory.loadSessionHistory(); - expect(Array.isArray(history)).toBe(true); - }); - }); - - // --- Visual state restoration (DOM rendering) --- - - describe("renderRestoredChat", function () { - let $container, $panel; - - beforeEach(function () { - $container = testWindow.$('
'); - $panel = testWindow.$('
'); - $panel.append($container); - testWindow.$("body").append($panel); - }); - - afterEach(function () { - $panel.remove(); - $container = null; - $panel = null; - }); - - it("should render user message with correct text", function () { - const fixture = loadFixture(SIMPLE_SESSION); - AIChatHistory.renderRestoredChat([fixture.messages[0]], $container, $panel); - const $msg = $container.find(".ai-msg-user"); - expect($msg.length).toBe(1); - expect($msg.find(".ai-msg-content").text()).toContain("What is 2+2?"); - expect($msg.find(".ai-msg-label").text()).not.toBe(""); - }); - - it("should render user message with image thumbnails", function () { - const fixture = loadFixture(SESSION_WITH_IMAGES); - AIChatHistory.renderRestoredChat([fixture.messages[0]], $container, $panel); - const $thumbs = $container.find(".ai-user-image-thumb"); - expect($thumbs.length).toBe(2); - expect($thumbs.eq(0).attr("src")).toBe("data:image/png;base64,iVBORw0KGgoAAAANSUhEUg=="); - expect($thumbs.eq(1).attr("src")).toBe("data:image/jpeg;base64,/9j/4AAQSkZJRg=="); - }); - - it("should render assistant message with parsed markdown", function () { - const fixture = loadFixture(SIMPLE_SESSION); - AIChatHistory.renderRestoredChat([fixture.messages[1]], $container, $panel); - const $msg = $container.find(".ai-msg-assistant"); - expect($msg.length).toBe(1); - // First assistant message should have the Claude label - expect($msg.find(".ai-msg-label").length).toBe(1); - // Markdown **4** should be rendered as - expect($msg.find("strong").text()).toBe("4"); - }); - - it("should show Claude label only on first assistant message", function () { - AIChatHistory.renderRestoredChat([ - { type: "assistant", markdown: "First response", isFirst: true }, - { type: "assistant", markdown: "Continued response" } - ], $container, $panel); - const $msgs = $container.find(".ai-msg-assistant"); - expect($msgs.length).toBe(2); - expect($msgs.eq(0).find(".ai-msg-label").length).toBe(1); - expect($msgs.eq(1).find(".ai-msg-label").length).toBe(0); - }); - - it("should render assistant markdown with code blocks and copy buttons", function () { - AIChatHistory.renderRestoredChat([ - { type: "assistant", markdown: "```js\nconsole.log('hi');\n```", isFirst: true } - ], $container, $panel); - const $pre = $container.find("pre"); - expect($pre.length).toBe(1); - expect($pre.find("code").text()).toContain("console.log"); - // Copy button should be injected - expect($pre.find(".ai-copy-btn").length).toBe(1); - }); - - it("should render tool indicators with correct icon, color, and elapsed time", function () { - const fixture = loadFixture(MULTI_TOOL_SESSION); - // Render just the Glob tool message - AIChatHistory.renderRestoredChat([fixture.messages[2]], $container, $panel); - const $tool = $container.find(".ai-msg-tool"); - expect($tool.length).toBe(1); - expect($tool.hasClass("ai-tool-done")).toBe(true); - expect($tool.find(".ai-tool-label").text()).toBe("Finding utils files"); - expect($tool.find(".ai-tool-elapsed").text()).toBe("0.2s"); - expect($tool.find(".fa-magnifying-glass").length).toBe(1); - }); - - it("should render tool with default icon when toolName is unknown", function () { - AIChatHistory.renderRestoredChat([ - { type: "tool", toolName: "UnknownTool", summary: "Doing something" } - ], $container, $panel); - const $tool = $container.find(".ai-msg-tool"); - expect($tool.length).toBe(1); - // Should use fallback gear icon - expect($tool.find(".fa-gear").length).toBe(1); - expect($tool.find(".ai-tool-label").text()).toBe("Doing something"); - }); - - it("should render tool without elapsed time when not provided", function () { - AIChatHistory.renderRestoredChat([ - { type: "tool", toolName: "Read", summary: "Reading" } - ], $container, $panel); - const $elapsed = $container.find(".ai-tool-elapsed"); - expect($elapsed.length).toBe(0); - }); - - it("should render tool_edit with file name and line stats", function () { - const fixture = loadFixture(MULTI_TOOL_SESSION); - // tool_edit is at index 7 - AIChatHistory.renderRestoredChat([fixture.messages[7]], $container, $panel); - const $tool = $container.find(".ai-msg-tool"); - expect($tool.length).toBe(1); - expect($tool.find(".ai-tool-label").text()).toBe("Edit index.js"); - expect($tool.find(".ai-edit-summary-add").text()).toBe("+15"); - expect($tool.find(".ai-edit-summary-del").text()).toBe("-8"); - }); - - it("should render error message with correct text", function () { - const fixture = loadFixture(SESSION_WITH_ERRORS); - AIChatHistory.renderRestoredChat([fixture.messages[3]], $container, $panel); - const $err = $container.find(".ai-msg-error"); - expect($err.length).toBe(1); - expect($err.find(".ai-msg-content").text()).toBe("Process exited with code 1: Tests failed"); - }); - - it("should render question with selected answer highlighted", function () { - const fixture = loadFixture(SESSION_WITH_QUESTIONS); - AIChatHistory.renderRestoredChat([fixture.messages[2]], $container, $panel); - const $question = $container.find(".ai-msg-question"); - expect($question.length).toBe(1); - - // First question block - const $qBlocks = $question.find(".ai-question-block"); - expect($qBlocks.length).toBe(2); - - // First question: "Which auth method do you prefer?" - const $q1Text = $qBlocks.eq(0).find(".ai-question-text"); - expect($q1Text.text()).toBe("Which auth method do you prefer?"); - const $q1Options = $qBlocks.eq(0).find(".ai-question-option"); - expect($q1Options.length).toBe(3); - // All options should be disabled - expect($q1Options.eq(0).prop("disabled")).toBe(true); - expect($q1Options.eq(1).prop("disabled")).toBe(true); - expect($q1Options.eq(2).prop("disabled")).toBe(true); - // JWT should be selected (index 0) - expect($q1Options.eq(0).hasClass("selected")).toBe(true); - expect($q1Options.eq(1).hasClass("selected")).toBe(false); - expect($q1Options.eq(2).hasClass("selected")).toBe(false); - - // Second question: "Which database?" - const $q2Options = $qBlocks.eq(1).find(".ai-question-option"); - expect($q2Options.length).toBe(2); - // PostgreSQL should be selected (index 0) - expect($q2Options.eq(0).hasClass("selected")).toBe(true); - expect($q2Options.eq(1).hasClass("selected")).toBe(false); - }); - - it("should render question with option descriptions", function () { - const fixture = loadFixture(SESSION_WITH_QUESTIONS); - AIChatHistory.renderRestoredChat([fixture.messages[2]], $container, $panel); - const $descs = $container.find(".ai-question-option-desc"); - // 3 options for Q1 + 2 options for Q2 = 5 descriptions - expect($descs.length).toBe(5); - expect($descs.eq(0).text()).toBe("Stateless token-based auth"); - }); - - it("should render question with 'Other' custom answer", function () { - const fixture = loadFixture(SESSION_WITH_OTHER); - AIChatHistory.renderRestoredChat([fixture.messages[1]], $container, $panel); - const $other = $container.find(".ai-question-other-input"); - expect($other.length).toBe(1); - expect($other.val()).toBe("Rollup with custom plugins"); - expect($other.prop("disabled")).toBe(true); - }); - - it("should render edit summary with file list and stats", function () { - const fixture = loadFixture(MULTI_TOOL_SESSION); - // edit_summary is at index 11 - AIChatHistory.renderRestoredChat([fixture.messages[11]], $container, $panel); - const $summary = $container.find(".ai-msg-edit-summary"); - expect($summary.length).toBe(1); - const $files = $summary.find(".ai-edit-summary-file"); - expect($files.length).toBe(2); - expect($files.eq(0).find(".ai-edit-summary-name").text()).toBe("index.js"); - expect($files.eq(0).find(".ai-edit-summary-add").text()).toBe("+15"); - expect($files.eq(0).find(".ai-edit-summary-del").text()).toBe("-8"); - expect($files.eq(1).find(".ai-edit-summary-name").text()).toBe("helpers.js"); - }); - - it("should skip 'complete' markers without rendering anything", function () { - AIChatHistory.renderRestoredChat([ - { type: "user", text: "hi" }, - { type: "complete" } - ], $container, $panel); - expect($container.children().length).toBe(1); - }); - - it("should handle empty messages array", function () { - AIChatHistory.renderRestoredChat([], $container, $panel); - expect($container.children().length).toBe(0); - }); - - it("should handle null messages", function () { - AIChatHistory.renderRestoredChat(null, $container, $panel); - expect($container.children().length).toBe(0); - }); - - it("should ignore unknown message types without crashing", function () { - AIChatHistory.renderRestoredChat([ - { type: "user", text: "hi" }, - { type: "some_future_type", data: "whatever" }, - { type: "assistant", markdown: "hello", isFirst: true } - ], $container, $panel); - // Unknown type is skipped, user + assistant rendered - expect($container.children().length).toBe(2); - }); - }); - - // --- End-to-end: save to disk, load, and render --- - - describe("end-to-end save and restore", function () { - let $container, $panel; - - beforeEach(function () { - $container = testWindow.$('
'); - $panel = testWindow.$('
'); - $panel.append($container); - testWindow.$("body").append($panel); - }); - - afterEach(function () { - $panel.remove(); - $container = null; - $panel = null; - }); - - it("should save simple session to disk and restore visuals", async function () { - const fixture = loadFixture(SIMPLE_SESSION); - AIChatHistory.recordSessionMetadata(fixture.id, fixture.title); - await saveChatHistory(fixture.id, fixture); - - // Simulate resume: load from disk and render - const loaded = await loadChatHistory(fixture.id); - AIChatHistory.renderRestoredChat(loaded.messages, $container, $panel); - - // Verify rendered output - expect($container.children().length).toBe(2); // user + assistant (complete skipped) - expect($container.find(".ai-msg-user .ai-msg-content").text()).toContain("What is 2+2?"); - expect($container.find(".ai-msg-assistant strong").text()).toBe("4"); - - // Verify metadata - const history = AIChatHistory.loadSessionHistory(); - expect(history[0].id).toBe(fixture.id); - }); - - it("should save multi-tool session and restore all message bubbles", async function () { - const fixture = loadFixture(MULTI_TOOL_SESSION); - AIChatHistory.recordSessionMetadata(fixture.id, fixture.title); - await saveChatHistory(fixture.id, fixture); - - const loaded = await loadChatHistory(fixture.id); - AIChatHistory.renderRestoredChat(loaded.messages, $container, $panel); - - // 12 visible items (complete skipped) - expect($container.children().length).toBe(12); - - // Verify message order and types - const children = $container.children(); - expect(children.eq(0).hasClass("ai-msg-user")).toBe(true); - expect(children.eq(1).hasClass("ai-msg-assistant")).toBe(true); - expect(children.eq(2).hasClass("ai-msg-tool")).toBe(true); // Glob - expect(children.eq(3).hasClass("ai-msg-tool")).toBe(true); // Read - expect(children.eq(4).hasClass("ai-msg-tool")).toBe(true); // Read - expect(children.eq(5).hasClass("ai-msg-assistant")).toBe(true); - expect(children.eq(6).hasClass("ai-msg-tool")).toBe(true); // Edit - expect(children.eq(7).hasClass("ai-msg-tool")).toBe(true); // tool_edit - expect(children.eq(8).hasClass("ai-msg-tool")).toBe(true); // Edit - expect(children.eq(9).hasClass("ai-msg-tool")).toBe(true); // tool_edit - expect(children.eq(10).hasClass("ai-msg-assistant")).toBe(true); - expect(children.eq(11).hasClass("ai-msg-edit-summary")).toBe(true); - - // All tool indicators should be in done state (5 tools + 2 tool_edits) - expect($container.find(".ai-msg-tool.ai-tool-done").length).toBe(7); - - // Edit summary should have 2 files - expect($container.find(".ai-edit-summary-file").length).toBe(2); - }); - - it("should save session with images and restore thumbnails", async function () { - const fixture = loadFixture(SESSION_WITH_IMAGES); - await saveChatHistory(fixture.id, fixture); - - const loaded = await loadChatHistory(fixture.id); - AIChatHistory.renderRestoredChat(loaded.messages, $container, $panel); - - const $thumbs = $container.find(".ai-user-image-thumb"); - expect($thumbs.length).toBe(2); - expect($thumbs.eq(0).attr("src")).toContain("data:image/png"); - expect($thumbs.eq(1).attr("src")).toContain("data:image/jpeg"); - }); - - it("should save session with errors and restore error bubbles", async function () { - const fixture = loadFixture(SESSION_WITH_ERRORS); - await saveChatHistory(fixture.id, fixture); - - const loaded = await loadChatHistory(fixture.id); - AIChatHistory.renderRestoredChat(loaded.messages, $container, $panel); - - // 6 visible items (complete skipped) - expect($container.children().length).toBe(6); - const $err = $container.find(".ai-msg-error"); - expect($err.length).toBe(1); - expect($err.find(".ai-msg-content").text()).toContain("Tests failed"); - }); - - it("should save session with questions and restore answered state", async function () { - const fixture = loadFixture(SESSION_WITH_QUESTIONS); - await saveChatHistory(fixture.id, fixture); - - const loaded = await loadChatHistory(fixture.id); - AIChatHistory.renderRestoredChat(loaded.messages, $container, $panel); - - // Verify question block rendered with answered state - const $question = $container.find(".ai-msg-question"); - expect($question.length).toBe(1); - - // JWT should be selected for first question - const $q1Options = $question.find(".ai-question-block").eq(0).find(".ai-question-option"); - const selectedLabels = []; - $q1Options.filter(".selected").each(function () { - selectedLabels.push(testWindow.$(this).find(".ai-question-option-label").text()); - }); - expect(selectedLabels).toEqual(["JWT"]); - - // PostgreSQL should be selected for second question - const $q2Options = $question.find(".ai-question-block").eq(1).find(".ai-question-option"); - const selectedLabels2 = []; - $q2Options.filter(".selected").each(function () { - selectedLabels2.push(testWindow.$(this).find(".ai-question-option-label").text()); - }); - expect(selectedLabels2).toEqual(["PostgreSQL"]); - }); - - it("should save and restore session with 'Other' custom answer", async function () { - const fixture = loadFixture(SESSION_WITH_OTHER); - await saveChatHistory(fixture.id, fixture); - - const loaded = await loadChatHistory(fixture.id); - AIChatHistory.renderRestoredChat(loaded.messages, $container, $panel); - - // No predefined option should be selected - const $options = $container.find(".ai-question-option.selected"); - expect($options.length).toBe(0); - - // Custom "Other" input should show the answer - const $other = $container.find(".ai-question-other-input"); - expect($other.length).toBe(1); - expect($other.val()).toBe("Rollup with custom plugins"); - expect($other.prop("disabled")).toBe(true); - }); - - it("should save, delete, and verify deletion end-to-end", async function () { - const fixture = loadFixture(SIMPLE_SESSION); - AIChatHistory.recordSessionMetadata(fixture.id, fixture.title); - await saveChatHistory(fixture.id, fixture); - - // Verify exists - const loaded = await loadChatHistory(fixture.id); - expect(loaded.id).toBe(fixture.id); - - // Delete - await deleteSession(fixture.id); - - // Verify metadata gone - const history = AIChatHistory.loadSessionHistory(); - expect(history.some(function (h) { return h.id === fixture.id; })).toBe(false); - - // Verify file gone - let error = null; - try { - await loadChatHistory(fixture.id); - } catch (e) { - error = e; - } - expect(error).not.toBeNull(); - }); - }); - - // --- formatRelativeTime --- - - describe("formatRelativeTime", function () { - it("should return 'just now' for recent timestamps", function () { - const result = AIChatHistory.formatRelativeTime(Date.now()); - expect(result).toContain("just now"); - }); - - it("should return minutes ago for timestamps within an hour", function () { - const fiveMinAgo = Date.now() - (5 * 60 * 1000); - const result = AIChatHistory.formatRelativeTime(fiveMinAgo); - expect(result).toContain("5"); - }); - - it("should return hours ago for timestamps within a day", function () { - const threeHoursAgo = Date.now() - (3 * 60 * 60 * 1000); - const result = AIChatHistory.formatRelativeTime(threeHoursAgo); - expect(result).toContain("3"); - }); - - it("should return days ago for timestamps older than a day", function () { - const twoDaysAgo = Date.now() - (2 * 24 * 60 * 60 * 1000); - const result = AIChatHistory.formatRelativeTime(twoDaysAgo); - expect(result).toContain("2"); - }); - }); - }); -}); diff --git a/test/spec/ai-snapshot-test.js b/test/spec/ai-snapshot-test.js deleted file mode 100644 index f83b5f685e..0000000000 --- a/test/spec/ai-snapshot-test.js +++ /dev/null @@ -1,717 +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. - * - */ - -/*global describe, beforeAll, afterAll, beforeEach, afterEach, it, expect, awaitsFor, awaitsForDone, jsPromise */ - -define(function (require, exports, module) { - - const SpecRunnerUtils = require("spec/SpecRunnerUtils"); - - const tempDir = SpecRunnerUtils.getTempDirectory(); - - let AISnapshotStore, - DocumentManager, - CommandManager, - Commands, - FileSystem, - testWindow; - - describe("integration:AISnapshotStore", function () { - - beforeAll(async function () { - testWindow = await SpecRunnerUtils.createTestWindowAndRun(); - AISnapshotStore = testWindow.brackets.test.AISnapshotStore; - DocumentManager = testWindow.brackets.test.DocumentManager; - CommandManager = testWindow.brackets.test.CommandManager; - Commands = testWindow.brackets.test.Commands; - FileSystem = testWindow.brackets.test.FileSystem; - }, 30000); - - afterAll(async function () { - AISnapshotStore = null; - DocumentManager = null; - CommandManager = null; - Commands = null; - FileSystem = null; - testWindow = null; - await SpecRunnerUtils.closeTestWindow(); - }, 30000); - - beforeEach(async function () { - await SpecRunnerUtils.createTempDirectory(); - await SpecRunnerUtils.loadProjectInTestWindow(tempDir); - }); - - afterEach(async function () { - await testWindow.closeAllFiles(); - AISnapshotStore.reset(); - await SpecRunnerUtils.removeTempDirectory(); - }); - - // --- helpers --- - - // Convert a file name to a VFS path that matches what realToVfsPath produces. - // In native (Tauri) builds, realToVfsPath adds /tauri/ prefix to native paths. - // By opening docs with VFS paths, doc.file.fullPath matches what finalizeResponse - // will look up via realToVfsPath. - function toVfsPath(name) { - return AISnapshotStore.realToVfsPath(tempDir + "/" + name); - } - - async function createFile(name, content) { - // Write through the test window's FileSystem (not the host's) so - // the document cache stays consistent across tests. - const path = toVfsPath(name); - return new Promise(function (resolve, reject) { - const file = FileSystem.getFileForPath(path); - file.write(content, {blind: true}, function (err) { - if (err) { reject(err); } else { resolve(); } - }); - }); - } - - async function openDoc(name) { - const fullPath = toVfsPath(name); - await awaitsForDone( - CommandManager.execute(Commands.FILE_OPEN, { fullPath: fullPath }), - "open " + name - ); - return DocumentManager.getOpenDocumentForPath(fullPath); - } - - function simulateEdit(doc, newContent, isNewFile) { - AISnapshotStore.recordFileBeforeEdit(doc.file.fullPath, doc.getText(), isNewFile); - doc.setText(newContent); - } - - async function simulateCreateFile(name, content) { - await createFile(name, ""); - const doc = await openDoc(name); - AISnapshotStore.recordFileBeforeEdit(doc.file.fullPath, "", true); - doc.setText(content); - return doc; - } - - function restoreToSnapshot(index) { - return new Promise(function (resolve) { - AISnapshotStore.restoreToSnapshot(index, function (errorCount) { - resolve(errorCount); - }); - }); - } - - async function readFile(name) { - // Read from the open Document to avoid FileSystem read-cache staleness. - // _createOrUpdateFile always updates the document text before resolving. - const path = toVfsPath(name); - const doc = DocumentManager.getOpenDocumentForPath(path); - if (doc) { - return doc.getText(); - } - return new Promise(function (resolve, reject) { - DocumentManager.getDocumentForPath(path) - .done(function (d) { resolve(d.getText()); }) - .fail(function (err) { reject(err); }); - }); - } - - async function fileExists(name) { - // Use FileSystem.existsAsync which bypasses the cached _stat on - // File objects — file.exists() can return stale true when - // _handleDirectoryChange re-populates _stat from a racing readdir. - return FileSystem.existsAsync(toVfsPath(name)); - } - - async function expectFileDeleted(name) { - let gone = false; - let checking = false; - await awaitsFor(function () { - if (!checking && !gone) { - checking = true; - fileExists(name).then(function (e) { - gone = !e; - checking = false; - }); - } - return gone; - }, name + " to be deleted", 5000); - } - - function unlinkFile(name) { - return new Promise(function (resolve, reject) { - const file = FileSystem.getFileForPath(toVfsPath(name)); - file.unlink(function (err) { - if (err) { reject(err); } else { resolve(); } - }); - }); - } - - function beginResponse() { - if (AISnapshotStore.getSnapshotCount() === 0) { - AISnapshotStore.createInitialSnapshot(); - } - } - - // --- storeContent --- - - describe("storeContent", function () { - it("should return same hash for identical content", function () { - const h1 = AISnapshotStore.storeContent("hello world"); - const h2 = AISnapshotStore.storeContent("hello world"); - expect(h1).toBe(h2); - }); - - it("should return different hashes for different content", function () { - const h1 = AISnapshotStore.storeContent("aaa"); - const h2 = AISnapshotStore.storeContent("bbb"); - expect(h1).not.toBe(h2); - }); - - it("should return a valid hash for empty string", function () { - const h = AISnapshotStore.storeContent(""); - expect(typeof h).toBe("string"); - expect(h.length).toBeGreaterThan(0); - }); - }); - - // --- createInitialSnapshot and recordFileBeforeEdit --- - - describe("createInitialSnapshot and recordFileBeforeEdit", function () { - it("should create initial snapshot at index 0 with count 1", function () { - const idx = AISnapshotStore.createInitialSnapshot(); - expect(idx).toBe(0); - expect(AISnapshotStore.getSnapshotCount()).toBe(1); - }); - - it("should back-fill snapshot 0 when recording before-edit", async function () { - await createFile("a.txt", "original"); - const doc = await openDoc("a.txt"); - - beginResponse(); - AISnapshotStore.recordFileBeforeEdit(doc.file.fullPath, "original", false); - doc.setText("modified"); - - // Snapshot 0 should now contain a hash for "original" - const errorCount = await restoreToSnapshot(0); - expect(errorCount).toBe(0); - const content = await readFile("a.txt"); - expect(content).toBe("original"); - }); - - it("should store null for isNewFile=true", async function () { - await createFile("new.txt", ""); - const doc = await openDoc("new.txt"); - - beginResponse(); - AISnapshotStore.recordFileBeforeEdit(doc.file.fullPath, "", true); - doc.setText("new content"); - await AISnapshotStore.finalizeResponse(); - - // Snapshot 0 has null → restore deletes file - const errorCount = await restoreToSnapshot(0); - expect(errorCount).toBe(0); - await expectFileDeleted("new.txt"); - }); - - it("should ignore duplicate recordFileBeforeEdit for same file", async function () { - await createFile("a.txt", "v0"); - const doc = await openDoc("a.txt"); - - beginResponse(); - AISnapshotStore.recordFileBeforeEdit(doc.file.fullPath, "v0", false); - doc.setText("v1"); - // Second call with different content should be ignored (first-edit-wins) - AISnapshotStore.recordFileBeforeEdit(doc.file.fullPath, "v1", false); - await AISnapshotStore.finalizeResponse(); - - // Restore to snapshot 0 should give v0, not v1 - const errorCount = await restoreToSnapshot(0); - expect(errorCount).toBe(0); - const content = await readFile("a.txt"); - expect(content).toBe("v0"); - }); - }); - - // --- finalizeResponse --- - - describe("finalizeResponse", function () { - it("should return -1 when no pending edits", async function () { - beginResponse(); - const idx = await AISnapshotStore.finalizeResponse(); - expect(idx).toBe(-1); - }); - - it("should build after-snapshot from open doc content", async function () { - await createFile("a.txt", "before"); - const doc = await openDoc("a.txt"); - - beginResponse(); - simulateEdit(doc, "after", false); - await AISnapshotStore.finalizeResponse(); - - // Snapshot 1 should have "after" - const errorCount = await restoreToSnapshot(1); - expect(errorCount).toBe(0); - const content = await readFile("a.txt"); - expect(content).toBe("after"); - }); - - it("should increment snapshot count", async function () { - await createFile("a.txt", "v0"); - const doc = await openDoc("a.txt"); - - beginResponse(); - simulateEdit(doc, "v1", false); - expect(AISnapshotStore.getSnapshotCount()).toBe(1); - await AISnapshotStore.finalizeResponse(); - expect(AISnapshotStore.getSnapshotCount()).toBe(2); - }); - - it("should clear pending state (second finalize returns -1)", async function () { - await createFile("a.txt", "v0"); - const doc = await openDoc("a.txt"); - - beginResponse(); - simulateEdit(doc, "v1", false); - const idx = await AISnapshotStore.finalizeResponse(); - expect(idx).toBe(1); - const idx2 = await AISnapshotStore.finalizeResponse(); - expect(idx2).toBe(-1); - }); - - it("should capture closed doc content from disk", async function () { - await createFile("a.txt", "on-disk-content"); - const doc = await openDoc("a.txt"); - - beginResponse(); - simulateEdit(doc, "edited", false); - - // Save to disk then close the tab - const file = doc.file; - await new Promise(function (resolve) { - file.write("edited", function () { resolve(); }); - }); - await awaitsForDone( - CommandManager.execute(Commands.FILE_CLOSE, - { file: file, _forceClose: true }), - "close a.txt" - ); - - await AISnapshotStore.finalizeResponse(); - - // After-snapshot should have captured "edited" from disk fallback - const err = await restoreToSnapshot(1); - expect(err).toBe(0); - expect(await readFile("a.txt")).toBe("edited"); - }); - - it("should capture deleted file as null in after-snapshot", async function () { - await createFile("a.txt", "content"); - const doc = await openDoc("a.txt"); - - beginResponse(); - simulateEdit(doc, "modified", false); - - // Close tab and delete the file - await awaitsForDone( - CommandManager.execute(Commands.FILE_CLOSE, - { file: doc.file, _forceClose: true }), - "close a.txt" - ); - await unlinkFile("a.txt"); - - await AISnapshotStore.finalizeResponse(); - - expect(AISnapshotStore.getSnapshotCount()).toBe(2); - - // snap 0 has original content; restore recreates the file - let err = await restoreToSnapshot(0); - expect(err).toBe(0); - expect(await readFile("a.txt")).toBe("content"); - - // snap 1 was captured as null (disk read failed → null fallback) - // Explicitly open to ensure doc is in working set (avoids CMD_OPEN race) - await openDoc("a.txt"); - err = await restoreToSnapshot(1); - expect(err).toBe(0); - await expectFileDeleted("a.txt"); - }); - }); - - // --- snapshot consistency (editApplyVerification cases) --- - - describe("snapshot consistency", function () { - - // Case 1: Single response, 2 files - it("should handle single response editing 2 files", async function () { - await createFile("a.txt", "a0"); - await createFile("b.txt", "b0"); - const docA = await openDoc("a.txt"); - const docB = await openDoc("b.txt"); - - // R1 - beginResponse(); - simulateEdit(docA, "a1", false); - simulateEdit(docB, "b1", false); - await AISnapshotStore.finalizeResponse(); - - expect(AISnapshotStore.getSnapshotCount()).toBe(2); - - // restore(0) → a0, b0 - let err = await restoreToSnapshot(0); - expect(err).toBe(0); - expect(await readFile("a.txt")).toBe("a0"); - expect(await readFile("b.txt")).toBe("b0"); - - // restore(1) → a1, b1 - err = await restoreToSnapshot(1); - expect(err).toBe(0); - expect(await readFile("a.txt")).toBe("a1"); - expect(await readFile("b.txt")).toBe("b1"); - }); - - // Case 2: Two responses, same file - it("should handle two responses editing same file", async function () { - await createFile("a.txt", "v0"); - const doc = await openDoc("a.txt"); - - // R1 - beginResponse(); - simulateEdit(doc, "v1", false); - await AISnapshotStore.finalizeResponse(); - - // R2 - simulateEdit(doc, "v2", false); - await AISnapshotStore.finalizeResponse(); - - expect(AISnapshotStore.getSnapshotCount()).toBe(3); - - let err = await restoreToSnapshot(0); - expect(err).toBe(0); - expect(await readFile("a.txt")).toBe("v0"); - - err = await restoreToSnapshot(1); - expect(err).toBe(0); - expect(await readFile("a.txt")).toBe("v1"); - - err = await restoreToSnapshot(2); - expect(err).toBe(0); - expect(await readFile("a.txt")).toBe("v2"); - }); - - // Case 4: Three responses, restore middle - it("should restore to middle snapshot", async function () { - await createFile("a.txt", "v0"); - const doc = await openDoc("a.txt"); - - // R1 - beginResponse(); - simulateEdit(doc, "v1", false); - await AISnapshotStore.finalizeResponse(); - - // R2 - simulateEdit(doc, "v2", false); - await AISnapshotStore.finalizeResponse(); - - // R3 - simulateEdit(doc, "v3", false); - await AISnapshotStore.finalizeResponse(); - - expect(AISnapshotStore.getSnapshotCount()).toBe(4); - - let err = await restoreToSnapshot(1); - expect(err).toBe(0); - expect(await readFile("a.txt")).toBe("v1"); - - err = await restoreToSnapshot(2); - expect(err).toBe(0); - expect(await readFile("a.txt")).toBe("v2"); - }); - - // Case 5: Different files, back-fill - it("should back-fill when different files edited in different responses", async function () { - await createFile("a.txt", "a0"); - await createFile("b.txt", "b0"); - const docA = await openDoc("a.txt"); - - // R1: edit A only - beginResponse(); - simulateEdit(docA, "a1", false); - await AISnapshotStore.finalizeResponse(); - - // R2: edit B only - const docB = await openDoc("b.txt"); - simulateEdit(docB, "b1", false); - await AISnapshotStore.finalizeResponse(); - - expect(AISnapshotStore.getSnapshotCount()).toBe(3); - - // snap 0 & 1 should have been back-filled with B:b0 - let err = await restoreToSnapshot(0); - expect(err).toBe(0); - expect(await readFile("a.txt")).toBe("a0"); - expect(await readFile("b.txt")).toBe("b0"); - - err = await restoreToSnapshot(1); - expect(err).toBe(0); - expect(await readFile("a.txt")).toBe("a1"); - expect(await readFile("b.txt")).toBe("b0"); - - err = await restoreToSnapshot(2); - expect(err).toBe(0); - expect(await readFile("a.txt")).toBe("a1"); - expect(await readFile("b.txt")).toBe("b1"); - }); - - // Case 6: File created in R1, edited in R2 - it("should handle file creation and subsequent edit", async function () { - // R1: create file A - beginResponse(); - const docA = await simulateCreateFile("a.txt", "new"); - await AISnapshotStore.finalizeResponse(); - - // R2: edit A - simulateEdit(docA, "edited", false); - await AISnapshotStore.finalizeResponse(); - - expect(AISnapshotStore.getSnapshotCount()).toBe(3); - - // snap 2 → A="edited" - let err = await restoreToSnapshot(2); - expect(err).toBe(0); - expect(await readFile("a.txt")).toBe("edited"); - - // snap 1 → A="new" - err = await restoreToSnapshot(1); - expect(err).toBe(0); - expect(await readFile("a.txt")).toBe("new"); - - // snap 0 has A:null → file deleted - err = await restoreToSnapshot(0); - expect(err).toBe(0); - await expectFileDeleted("a.txt"); - }); - - // Case 7: File created in R2 - it("should handle file created in second response", async function () { - await createFile("a.txt", "a0"); - const docA = await openDoc("a.txt"); - - // R1: edit A - beginResponse(); - simulateEdit(docA, "a1", false); - await AISnapshotStore.finalizeResponse(); - - // R2: create B - const docB = await simulateCreateFile("b.txt", "new"); - await AISnapshotStore.finalizeResponse(); - - expect(AISnapshotStore.getSnapshotCount()).toBe(3); - - // snap 0 → A=a0, B deleted (back-filled null) - let err = await restoreToSnapshot(0); - expect(await readFile("a.txt")).toBe("a0"); - await expectFileDeleted("b.txt"); - - // snap 1 → A=a1, B deleted (back-filled null) - err = await restoreToSnapshot(1); - expect(await readFile("a.txt")).toBe("a1"); - await expectFileDeleted("b.txt"); - - // snap 2 → A=a1, B="new" - err = await restoreToSnapshot(2); - expect(err).toBe(0); - expect(await readFile("a.txt")).toBe("a1"); - expect(await readFile("b.txt")).toBe("new"); - }); - - // File created and document closed in same turn — disk fallback reads empty content - it("should handle file created and closed in same turn", async function () { - await createFile("a.txt", "a0"); - const docA = await openDoc("a.txt"); - - // R1: edit A, create B then close B's document - beginResponse(); - simulateEdit(docA, "a1", false); - const docB = await simulateCreateFile("b.txt", "created"); - // Close B — simulates file created then removed in same turn - await awaitsForDone( - CommandManager.execute(Commands.FILE_CLOSE, - { file: docB.file, _forceClose: true }), - "close b.txt" - ); - await AISnapshotStore.finalizeResponse(); - - expect(AISnapshotStore.getSnapshotCount()).toBe(2); - - // snap 0: A="a0", B=null (isNewFile). B still on disk from simulateCreateFile. - let err = await restoreToSnapshot(0); - expect(err).toBe(0); - expect(await readFile("a.txt")).toBe("a0"); - await expectFileDeleted("b.txt"); - - // snap 1 (after): A="a1", B read from disk fallback (createFile wrote "") - err = await restoreToSnapshot(1); - expect(await readFile("a.txt")).toBe("a1"); - // Disk fallback reads the empty string that createFile wrote - expect(await readFile("b.txt")).toBe(""); - }); - - // Delete → recreate → delete round-trip - it("should handle delete-restore-delete round-trip", async function () { - // R1: create file A - beginResponse(); - await simulateCreateFile("a.txt", "content"); - await AISnapshotStore.finalizeResponse(); - - expect(AISnapshotStore.getSnapshotCount()).toBe(2); - - // snap 0 → A=null → file deleted - let err = await restoreToSnapshot(0); - expect(err).toBe(0); - await expectFileDeleted("a.txt"); - - // snap 1 → A="content" → file recreated from deleted state - err = await restoreToSnapshot(1); - expect(err).toBe(0); - expect(await readFile("a.txt")).toBe("content"); - }); - - // Case 9: Response with no edits - it("should return -1 for response with no edits", async function () { - beginResponse(); - const idx = await AISnapshotStore.finalizeResponse(); - expect(idx).toBe(-1); - expect(AISnapshotStore.getSnapshotCount()).toBe(1); - }); - }); - - // --- recordFileDeletion --- - - describe("recordFileDeletion", function () { - it("should track explicit deletion with before-content and null after", async function () { - await createFile("a.txt", "original"); - const doc = await openDoc("a.txt"); - - beginResponse(); - // Record deletion with known previous content - AISnapshotStore.recordFileDeletion(doc.file.fullPath, "original"); - await AISnapshotStore.finalizeResponse(); - - expect(AISnapshotStore.getSnapshotCount()).toBe(2); - - // snap 1 has null — doc still open from openDoc(), close+delete works - let err = await restoreToSnapshot(1); - expect(err).toBe(0); - await expectFileDeleted("a.txt"); - - // snap 0 has "original" (back-filled before content) — recreates file - err = await restoreToSnapshot(0); - expect(err).toBe(0); - expect(await readFile("a.txt")).toBe("original"); - }); - }); - - // --- recordFileRead --- - - describe("recordFileRead", function () { - it("should enable restore when read-tracked file is later deleted", async function () { - await createFile("a.txt", "a0"); - await createFile("b.txt", "b-content"); - const docA = await openDoc("a.txt"); - - // Record that AI has read b.txt - AISnapshotStore.recordFileRead(toVfsPath("b.txt"), "b-content"); - - beginResponse(); - // Edit a.txt (so we have at least one pending edit) - simulateEdit(docA, "a1", false); - - // Simulate deletion of the read file by calling recordFileDeletion - // (mirrors what _onProjectFileChanged would do after promoting from _readFiles) - AISnapshotStore.recordFileDeletion(toVfsPath("b.txt"), "b-content"); - - await AISnapshotStore.finalizeResponse(); - - expect(AISnapshotStore.getSnapshotCount()).toBe(2); - - // snap 1: A="a1", B=null (deleted) — b.txt still on disk, delete first - let err = await restoreToSnapshot(1); - expect(err).toBe(0); - expect(await readFile("a.txt")).toBe("a1"); - await expectFileDeleted("b.txt"); - - // snap 0: A="a0", B="b-content" — recreates b.txt - err = await restoreToSnapshot(0); - expect(err).toBe(0); - expect(await readFile("a.txt")).toBe("a0"); - expect(await readFile("b.txt")).toBe("b-content"); - }); - }); - - // --- reset --- - - describe("reset", function () { - it("should clear snapshot count to 0", function () { - AISnapshotStore.createInitialSnapshot(); - expect(AISnapshotStore.getSnapshotCount()).toBe(1); - AISnapshotStore.reset(); - expect(AISnapshotStore.getSnapshotCount()).toBe(0); - }); - - it("should allow fresh start after operations", async function () { - await createFile("a.txt", "v0"); - const doc = await openDoc("a.txt"); - - beginResponse(); - simulateEdit(doc, "v1", false); - await AISnapshotStore.finalizeResponse(); - expect(AISnapshotStore.getSnapshotCount()).toBe(2); - - AISnapshotStore.reset(); - expect(AISnapshotStore.getSnapshotCount()).toBe(0); - - // Start fresh - beginResponse(); - simulateEdit(doc, "v2", false); - await AISnapshotStore.finalizeResponse(); - expect(AISnapshotStore.getSnapshotCount()).toBe(2); - - const err = await restoreToSnapshot(0); - expect(err).toBe(0); - expect(await readFile("a.txt")).toBe("v1"); - }); - }); - - // --- realToVfsPath --- - - describe("realToVfsPath", function () { - it("should pass through /tauri/ paths unchanged", function () { - const p = "/tauri/some/path/file.txt"; - expect(AISnapshotStore.realToVfsPath(p)).toBe(p); - }); - - it("should pass through /mnt/ paths unchanged", function () { - const p = "/mnt/some/path/file.txt"; - expect(AISnapshotStore.realToVfsPath(p)).toBe(p); - }); - }); - }); -}); From be2dce7670c965384c7774a9291f0cb2968ed4b9 Mon Sep 17 00:00:00 2001 From: abose Date: Sun, 8 Mar 2026 14:38:53 +0530 Subject: [PATCH 4/4] feat(ai): add getEditorState hint and toggleLivePreview operation Hint the AI to call getEditorState when asked about the current file. Add toggleLivePreview operation to controlEditor MCP tool. --- src-node/claude-code-agent.js | 3 +++ src-node/mcp-editor-tools.js | 10 ++++++---- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/src-node/claude-code-agent.js b/src-node/claude-code-agent.js index f779b957ea..ded0b07589 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 d04b698ae7..df58668979 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) {