From c830834b28ed50e1e4849077d7d6eaebec999eac Mon Sep 17 00:00:00 2001 From: abose Date: Mon, 23 Feb 2026 18:06:56 +0530 Subject: [PATCH 1/7] feat(ai): add session history with visual state restoration Add the ability to browse, resume, and delete past AI chat sessions. Sessions are stored per-project using StateManager metadata and JSON files on disk. The history dropdown shows recent sessions with relative timestamps, and clicking one restores the full visual state (messages, tool indicators, edits, errors, questions). Resumed sessions continue the Claude conversation via the SDK resume option. Also adds a project-switch warning when AI is streaming, resets the chat UI on project open, and saves partial history when a query is cancelled so it can be resumed later. --- src-node/claude-code-agent.js | 21 +- src/core-ai/AIChatHistory.js | 542 +++++++++++++++++++++++++++++++ src/core-ai/AIChatPanel.js | 333 ++++++++++++++++++- src/nls/root/strings.js | 11 + src/project/ProjectManager.js | 26 +- src/styles/Extn-AIChatPanel.less | 157 +++++++++ 6 files changed, 1082 insertions(+), 8 deletions(-) create mode 100644 src/core-ai/AIChatHistory.js diff --git a/src-node/claude-code-agent.js b/src-node/claude-code-agent.js index 2883d2eaa6..d863e12011 100644 --- a/src-node/claude-code-agent.js +++ b/src-node/claude-code-agent.js @@ -220,6 +220,21 @@ exports.answerQuestion = async function (params) { return { success: true }; }; +/** + * Resume a previous session by setting the session ID. + * The next sendPrompt call will use queryOptions.resume with this session ID. + */ +exports.resumeSession = async function (params) { + if (currentAbortController) { + currentAbortController.abort(); + currentAbortController = null; + } + _questionResolve = null; + _queuedClarification = null; + currentSessionId = params.sessionId; + return { success: true }; +}; + /** * Destroy the current session (clear session ID). */ @@ -832,11 +847,13 @@ async function _runQuery(requestId, prompt, projectPath, model, signal, locale, if (isAbort) { _log("Cancelled"); - // Query was cancelled — clear session so next query starts fresh + // Send sessionId so browser side can save partial history for later resume + const cancelledSessionId = currentSessionId; + // Clear session so next query starts fresh currentSessionId = null; nodeConnector.triggerPeer("aiComplete", { requestId: requestId, - sessionId: null + sessionId: cancelledSessionId }); return; } diff --git a/src/core-ai/AIChatHistory.js b/src/core-ai/AIChatHistory.js new file mode 100644 index 0000000000..33e23e1c94 --- /dev/null +++ b/src/core-ai/AIChatHistory.js @@ -0,0 +1,542 @@ +/* + * 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 index 6d939d12c0..b7af5a7556 100644 --- a/src/core-ai/AIChatPanel.js +++ b/src/core-ai/AIChatPanel.js @@ -33,8 +33,10 @@ define(function (require, exports, module) { 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"); @@ -75,6 +77,13 @@ define(function (require, exports, module) { "image/png", "image/jpeg", "image/gif", "image/webp", "image/svg+xml" ]; + // Chat history recording state + let _chatHistory = []; // Array of message records for current session + let _firstUserMessage = null; // Captured on first send, used as session title + let _currentSessionId = null; // Browser-side session ID tracker + let _isResumedSession = false; // Whether current session was resumed from history + let _lastQuestions = null; // Last AskUserQuestion questions, for recording + // DOM references let $panel, $messages, $status, $statusText, $textarea, $sendBtn, $stopBtn, $imagePreview; @@ -89,10 +98,16 @@ define(function (require, exports, module) { '
' + '
' + '' + Strings.AI_CHAT_TITLE + '' + - '' + + '
' + + '' + + '' + + '
' + '
' + + '
' + '
' + '
' + '' + @@ -159,6 +174,14 @@ define(function (require, exports, module) { _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 }; + }) + }); } }); @@ -214,6 +237,9 @@ define(function (require, exports, module) { }); $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(); @@ -364,6 +390,35 @@ define(function (require, exports, module) { } }); + // 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(); + }); + SidebarTabs.addToTab("ai", $panel); } @@ -752,6 +807,11 @@ define(function (require, exports, module) { // 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) { @@ -763,6 +823,15 @@ define(function (require, exports, module) { // 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"); @@ -877,6 +946,11 @@ define(function (require, exports, module) { _removeQueueBubble(); _firstEditInResponse = true; _undoApplied = false; + _currentSessionId = null; + _isResumedSession = false; + _firstUserMessage = null; + _chatHistory = []; + _lastQuestions = null; _selectionDismissed = false; _lastSelectionInfo = null; _lastCursorLine = null; @@ -891,8 +965,11 @@ define(function (require, exports, module) { if ($messages) { $messages.empty(); } - // Hide "+ New" button since we're back to empty state + // 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) { @@ -1176,6 +1253,14 @@ define(function (require, exports, module) { 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 === "")); @@ -1265,6 +1350,8 @@ define(function (require, exports, module) { 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) } @@ -1275,14 +1362,60 @@ define(function (require, exports, module) { _traceTextChunks = 0; _traceToolStreamCounts = {}; + // Record finalized text segment before completing + if (_segmentText) { + const isFirst = !_chatHistory.some(function (m) { return m.type === "assistant"; }); + _chatHistory.push({ type: "assistant", markdown: _segmentText, isFirst: isFirst }); + } + // Append edit summary if there were edits (finalizeResponse called inside) if (_currentEdits.length > 0) { + // Record edit summary in chat history + const fileStats = {}; + const fileOrder = []; + _currentEdits.forEach(function (e) { + if (!fileStats[e.file]) { + fileStats[e.file] = { added: 0, removed: 0 }; + fileOrder.push(e.file); + } + fileStats[e.file].added += e.linesAdded; + fileStats[e.file].removed += e.linesRemoved; + }); + _chatHistory.push({ + type: "edit_summary", + files: fileOrder.map(function (f) { + return { file: f, added: fileStats[f].added, removed: fileStats[f].removed }; + }) + }); await _appendEditSummary(); } SnapshotStore.stopTracking(); _setStreaming(false); + // Save session to history + if (data.sessionId) { + _currentSessionId = data.sessionId; + const firstUserMsg = _chatHistory.find(function (m) { return m.type === "user"; }); + const sessionTitle = (firstUserMsg && firstUserMsg.text) + ? firstUserMsg.text.slice(0, AIChatHistory.SESSION_TITLE_MAX_LEN) + : "Untitled"; + // Record/update metadata (moves to top of history list) + AIChatHistory.recordSessionMetadata(data.sessionId, sessionTitle); + _firstUserMessage = null; + // Remove any trailing "complete" markers before adding new one + while (_chatHistory.length > 0 && _chatHistory[_chatHistory.length - 1].type === "complete") { + _chatHistory.pop(); + } + _chatHistory.push({ type: "complete" }); + AIChatHistory.saveChatHistory(data.sessionId, { + id: data.sessionId, + title: sessionTitle, + timestamp: Date.now(), + messages: _chatHistory + }); + } + // Fatal error (e.g. process exit code 1) — disable inputs, show "New Chat" if (_sessionError && !data.sessionId) { $textarea.prop("disabled", true); @@ -1502,6 +1635,9 @@ define(function (require, exports, module) { return; } + // Capture questions for history recording (answers recorded in _sendQuestionAnswers) + _lastQuestions = questions; + // Remove thinking indicator on first content if (!_hasReceivedContent) { _hasReceivedContent = true; @@ -1630,6 +1766,15 @@ define(function (require, exports, module) { * 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); }); @@ -1808,6 +1953,12 @@ define(function (require, exports, module) { $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 = ""; @@ -1849,6 +2000,15 @@ define(function (require, exports, module) { 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( '' + @@ -2243,6 +2403,171 @@ define(function (require, exports, module) { return str.replace(/&/g, "&").replace(/"/g, """).replace(//g, ">"); } + // --- Session History --- + + /** + * Toggle the history dropdown open/closed. + */ + function _toggleHistoryDropdown() { + const $dropdown = $panel.find(".ai-session-history-dropdown"); + const $btn = $panel.find(".ai-history-btn"); + const isOpen = $dropdown.hasClass("open"); + if (isOpen) { + $dropdown.removeClass("open"); + $btn.removeClass("active"); + $panel.removeClass("ai-history-open"); + } else { + _renderHistoryDropdown(); + $dropdown.addClass("open"); + $btn.addClass("active"); + $panel.addClass("ai-history-open"); + } + } + + /** + * Render the history dropdown contents from stored session metadata. + */ + function _renderHistoryDropdown() { + const $dropdown = $panel.find(".ai-session-history-dropdown"); + $dropdown.empty(); + + const history = AIChatHistory.loadSessionHistory(); + if (!history.length) { + $dropdown.append( + '
' + Strings.AI_CHAT_HISTORY_EMPTY + '
' + ); + return; + } + + history.forEach(function (session) { + const $item = $( + '
' + + '
' + + '
' + + '
' + + '
' + + '' + + '
' + ); + $item.find(".ai-history-item-title").text(session.title || "Untitled"); + $item.find(".ai-history-item-time").text(AIChatHistory.formatRelativeTime(session.timestamp)); + + if (_currentSessionId === session.id) { + $item.addClass("ai-history-active"); + } + + // Click to resume session + $item.find(".ai-history-item-info").on("click", function () { + _resumeSession(session.id, session.title); + }); + + // Delete button + $item.find(".ai-history-item-delete").on("click", function (e) { + e.stopPropagation(); + AIChatHistory.deleteSession(session.id, function () { + _renderHistoryDropdown(); + }); + }); + + $dropdown.append($item); + }); + + // Clear all link + const $clearAll = $('
' + Strings.AI_CHAT_HISTORY_CLEAR_ALL + '
'); + $clearAll.on("click", function () { + AIChatHistory.clearAllHistory(function () { + $panel.find(".ai-session-history-dropdown").removeClass("open"); + $panel.find(".ai-history-btn").removeClass("active"); + $panel.removeClass("ai-history-open"); + }); + }); + $dropdown.append($clearAll); + } + + /** + * Resume a past session: load history, restore visual state, tell node side. + */ + function _resumeSession(sessionId, title) { + // Close dropdown + $panel.find(".ai-session-history-dropdown").removeClass("open"); + $panel.find(".ai-history-btn").removeClass("active"); + $panel.removeClass("ai-history-open"); + + // Cancel any in-flight query + if (_isStreaming) { + _cancelQuery(); + } + + // Tell node side to set session ID for resume + _nodeConnector.execPeer("resumeSession", { sessionId: sessionId }).catch(function (err) { + console.warn("[AI UI] Failed to resume session:", err.message); + }); + + // Load chat history from disk + AIChatHistory.loadChatHistory(sessionId, function (err, data) { + if (err) { + console.warn("[AI UI] Failed to load chat history:", err); + // Remove stale metadata entry + AIChatHistory.deleteSession(sessionId); + return; + } + + // Reset state (similar to _newSession but keep session ID) + _currentRequestId = null; + _segmentText = ""; + _hasReceivedContent = false; + _isStreaming = false; + _sessionError = false; + _queuedMessage = null; + _removeQueueBubble(); + _firstEditInResponse = true; + _undoApplied = false; + _firstUserMessage = null; + _lastQuestions = null; + _attachedImages = []; + _renderImagePreview(); + SnapshotStore.reset(); + PhoenixConnectors.clearPreviousContentMap(); + + // Clear messages and render restored chat + $messages.empty(); + + // Show "Resumed session" indicator + const $indicator = $( + '
' + + ' ' + + Strings.AI_CHAT_SESSION_RESUMED + + '
' + ); + $messages.append($indicator); + + // Render restored messages + AIChatHistory.renderRestoredChat(data.messages, $messages, $panel); + + // Set state for continued conversation + _currentSessionId = sessionId; + _isResumedSession = true; + _chatHistory = data.messages ? data.messages.slice() : []; + + // Show "+ New" button, enable input + $panel.find(".ai-new-session-btn").show(); + if ($status) { + $status.removeClass("active"); + } + $textarea.prop("disabled", false); + $textarea.closest(".ai-chat-input-wrap").removeClass("disabled"); + $sendBtn.prop("disabled", false); + $textarea[0].focus({ preventScroll: true }); + + // Scroll to bottom + if ($messages && $messages.length) { + $messages[0].scrollTop = $messages[0].scrollHeight; + } + }); + } + // --- Path utilities --- /** diff --git a/src/nls/root/strings.js b/src/nls/root/strings.js index b77f1bc3ed..87ee4b7266 100644 --- a/src/nls/root/strings.js +++ b/src/nls/root/strings.js @@ -1887,6 +1887,17 @@ define({ "AI_CHAT_QUEUED": "Queued", "AI_CHAT_QUEUED_EDIT": "Edit", "AI_CHAT_TOOL_CLARIFICATION": "Reading your follow-up", + "AI_CHAT_HISTORY_TITLE": "Session history", + "AI_CHAT_HISTORY_EMPTY": "No previous sessions", + "AI_CHAT_SESSION_RESUMED": "Resumed session", + "AI_CHAT_HISTORY_JUST_NOW": "just now", + "AI_CHAT_HISTORY_MINS_AGO": "{0}m ago", + "AI_CHAT_HISTORY_HOURS_AGO": "{0}h ago", + "AI_CHAT_HISTORY_DAYS_AGO": "{0}d ago", + "AI_CHAT_HISTORY_CLEAR_ALL": "Clear all", + "AI_CHAT_HISTORY_DELETE_CONFIRM": "Delete this session?", + "AI_CHAT_SWITCH_PROJECT_TITLE": "AI is working", + "AI_CHAT_SWITCH_PROJECT_MSG": "AI is currently working on a task. Switching projects will stop it. Continue?", // demo start - Phoenix Code Playground - Interactive Onboarding "DEMO_SECTION1_TITLE": "Edit in Live Preview", diff --git a/src/project/ProjectManager.js b/src/project/ProjectManager.js index 08f5798182..87757e15a3 100644 --- a/src/project/ProjectManager.js +++ b/src/project/ProjectManager.js @@ -1349,11 +1349,33 @@ define(function (require, exports, module) { return (new $.Deferred()).resolve().promise(); } - // About to close current project (if any) + // About to close current project (if any) — collect async veto promises if (model.projectRoot) { - exports.trigger(EVENT_PROJECT_BEFORE_CLOSE, model.projectRoot); + const vetoPromises = []; + exports.trigger(EVENT_PROJECT_BEFORE_CLOSE, model.projectRoot, vetoPromises); + if (vetoPromises.length > 0) { + const deferred = new $.Deferred(); + Promise.all(vetoPromises) + .then(function () { + _continueLoadProject(rootPath) + .done(deferred.resolve) + .fail(deferred.reject); + }) + .catch(function () { + // A handler vetoed the project close + deferred.reject(); + }); + return deferred.promise(); + } } + return _continueLoadProject(rootPath); + } + + /** + * Internal: continue loading a project after beforeProjectClose handlers have resolved. + */ + function _continueLoadProject(rootPath) { // close all the old files MainViewManager._closeAll(MainViewManager.ALL_PANES); diff --git a/src/styles/Extn-AIChatPanel.less b/src/styles/Extn-AIChatPanel.less index 47f00d5535..fcfd3b0af0 100644 --- a/src/styles/Extn-AIChatPanel.less +++ b/src/styles/Extn-AIChatPanel.less @@ -47,6 +47,38 @@ line-height: 19px; } + .ai-chat-header-actions { + display: flex; + align-items: center; + gap: 2px; + } + + .ai-history-btn { + display: flex; + align-items: center; + justify-content: center; + background: none; + border: none; + color: @project-panel-text-2; + font-size: @menu-item-font-size; + width: 26px; + height: 26px; + border-radius: 3px; + cursor: pointer; + opacity: 0.7; + transition: opacity 0.15s ease, background-color 0.15s ease; + + &:hover { + opacity: 1; + background-color: rgba(255, 255, 255, 0.06); + } + + &.active { + opacity: 1; + background-color: rgba(255, 255, 255, 0.08); + } + } + .ai-new-session-btn { display: flex; align-items: center; @@ -68,6 +100,131 @@ } } +/* ── Session history dropdown ──────────────────────────────────────── */ +/* When history is open, hide chat content and show the dropdown instead */ +.ai-chat-panel.ai-history-open { + > .ai-chat-messages, + > .ai-chat-status, + > .ai-chat-input-area { + display: none !important; + } +} + +.ai-session-history-dropdown { + display: none; + flex-direction: column; + overflow-y: auto; + background-color: rgba(0, 0, 0, 0.15); + flex-shrink: 1; + min-height: 0; + + &.open { + display: flex; + flex: 1; + } +} + +.ai-history-item { + display: flex; + align-items: center; + gap: 8px; + padding: 6px 10px; + cursor: pointer; + transition: background-color 0.15s ease; + border-left: 2px solid transparent; + + &:hover { + background-color: rgba(255, 255, 255, 0.04); + } + + &.ai-history-active { + border-left-color: rgba(76, 175, 80, 0.5); + background-color: rgba(76, 175, 80, 0.04); + } + + .ai-history-item-info { + flex: 1; + min-width: 0; + overflow: hidden; + } + + .ai-history-item-title { + font-size: @sidebar-content-font-size; + color: @project-panel-text-1; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + .ai-history-item-time { + font-size: @sidebar-xs-font-size; + color: @project-panel-text-2; + opacity: 0.6; + } + + .ai-history-item-delete { + background: none; + border: none; + color: @project-panel-text-2; + font-size: @sidebar-small-font-size; + padding: 2px 4px; + cursor: pointer; + opacity: 0; + transition: opacity 0.15s ease, color 0.15s ease; + flex-shrink: 0; + + &:hover { + opacity: 1; + color: #e88; + } + } + + &:hover .ai-history-item-delete { + opacity: 0.6; + } +} + +.ai-history-empty { + padding: 16px 10px; + text-align: center; + font-size: @sidebar-small-font-size; + color: @project-panel-text-2; + opacity: 0.6; +} + +.ai-history-clear-all { + display: block; + padding: 6px 10px; + text-align: center; + font-size: @sidebar-xs-font-size; + color: @project-panel-text-2; + opacity: 0.5; + cursor: pointer; + transition: opacity 0.15s ease; + border-top: 1px solid rgba(255, 255, 255, 0.04); + + &:hover { + opacity: 1; + } +} + +/* ── Session resumed indicator ─────────────────────────────────────── */ +.ai-session-resumed-indicator { + display: flex; + align-items: center; + justify-content: center; + gap: 6px; + padding: 4px 10px; + font-size: @sidebar-xs-font-size; + color: @project-panel-text-2; + opacity: 0.6; + border-bottom: 1px solid rgba(255, 255, 255, 0.04); + + i { + font-size: 10px; + } +} + /* ── Message list ───────────────────────────────────────────────────── */ .ai-chat-messages { flex: 1; From 21f853dfe4de1a9c31c488e16510b06d28ecb33f Mon Sep 17 00:00:00 2001 From: abose Date: Mon, 23 Feb 2026 18:21:46 +0530 Subject: [PATCH 2/7] test(ai): add integration tests for AIChatHistory backup and restore 47 tests covering session metadata storage, chat history file round-trips, deletion, visual state restoration of all message bubble types (user, assistant, tool, tool_edit, error, question, edit_summary), and end-to-end save-load-render flows. Test data is stored as JSON fixtures in ai-history-test-files/. --- src/brackets.js | 1 + test/UnitTestSuite.js | 1 + .../multi-tool-session.json | 26 + .../session-with-errors.json | 14 + .../session-with-images.json | 17 + .../session-with-other-answer.json | 24 + .../session-with-questions.json | 37 + .../ai-history-test-files/simple-session.json | 10 + test/spec/ai-history-test.js | 741 ++++++++++++++++++ 9 files changed, 871 insertions(+) create mode 100644 test/spec/ai-history-test-files/multi-tool-session.json create mode 100644 test/spec/ai-history-test-files/session-with-errors.json create mode 100644 test/spec/ai-history-test-files/session-with-images.json create mode 100644 test/spec/ai-history-test-files/session-with-other-answer.json create mode 100644 test/spec/ai-history-test-files/session-with-questions.json create mode 100644 test/spec/ai-history-test-files/simple-session.json create mode 100644 test/spec/ai-history-test.js diff --git a/src/brackets.js b/src/brackets.js index 172467f66c..ae38ca4d7e 100644 --- a/src/brackets.js +++ b/src/brackets.js @@ -298,6 +298,7 @@ define(function (require, exports, module) { SidebarView: require("project/SidebarView"), WorkingSetView: require("project/WorkingSetView"), AISnapshotStore: require("core-ai/AISnapshotStore"), + AIChatHistory: require("core-ai/AIChatHistory"), doneLoading: false }; diff --git a/test/UnitTestSuite.js b/test/UnitTestSuite.js index c84fbec180..31f88c9ab9 100644 --- a/test/UnitTestSuite.js +++ b/test/UnitTestSuite.js @@ -63,6 +63,7 @@ define(function (require, exports, module) { 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-files/multi-tool-session.json b/test/spec/ai-history-test-files/multi-tool-session.json new file mode 100644 index 0000000000..d9a3bc4290 --- /dev/null +++ b/test/spec/ai-history-test-files/multi-tool-session.json @@ -0,0 +1,26 @@ +{ + "id": "multi-tool-sess", + "title": "Refactor the utils module", + "timestamp": 1700000000000, + "messages": [ + { "type": "user", "text": "Refactor the utils module" }, + { "type": "assistant", "markdown": "I'll analyze the code first.", "isFirst": true }, + { "type": "tool", "toolName": "Glob", "summary": "Finding utils files", "icon": "fa-solid fa-magnifying-glass", "color": "#6b9eff", "elapsed": 0.2 }, + { "type": "tool", "toolName": "Read", "summary": "Reading utils/index.js", "icon": "fa-solid fa-file-lines", "color": "#6bc76b", "elapsed": 0.4 }, + { "type": "tool", "toolName": "Read", "summary": "Reading utils/helpers.js", "icon": "fa-solid fa-file-lines", "color": "#6bc76b", "elapsed": 0.3 }, + { "type": "assistant", "markdown": "I see several opportunities for improvement." }, + { "type": "tool", "toolName": "Edit", "summary": "Editing utils/index.js", "icon": "fa-solid fa-pen", "color": "#e8a838", "elapsed": 1.1 }, + { "type": "tool_edit", "file": "/src/utils/index.js", "linesAdded": 15, "linesRemoved": 8 }, + { "type": "tool", "toolName": "Edit", "summary": "Editing utils/helpers.js", "icon": "fa-solid fa-pen", "color": "#e8a838", "elapsed": 0.9 }, + { "type": "tool_edit", "file": "/src/utils/helpers.js", "linesAdded": 7, "linesRemoved": 12 }, + { "type": "assistant", "markdown": "Refactoring complete! I've:\n- Extracted shared logic\n- Removed duplicate code\n- Added proper types" }, + { + "type": "edit_summary", + "files": [ + { "file": "/src/utils/index.js", "added": 15, "removed": 8 }, + { "file": "/src/utils/helpers.js", "added": 7, "removed": 12 } + ] + }, + { "type": "complete" } + ] +} diff --git a/test/spec/ai-history-test-files/session-with-errors.json b/test/spec/ai-history-test-files/session-with-errors.json new file mode 100644 index 0000000000..68bfe6ec4f --- /dev/null +++ b/test/spec/ai-history-test-files/session-with-errors.json @@ -0,0 +1,14 @@ +{ + "id": "err-sess", + "title": "Run the tests", + "timestamp": 1700000000000, + "messages": [ + { "type": "user", "text": "Run the tests" }, + { "type": "assistant", "markdown": "I'll run the test suite.", "isFirst": true }, + { "type": "tool", "toolName": "Bash", "summary": "Running npm test", "icon": "fa-solid fa-terminal", "color": "#c084fc", "elapsed": 5.2 }, + { "type": "error", "text": "Process exited with code 1: Tests failed" }, + { "type": "assistant", "markdown": "The tests failed. Let me investigate." }, + { "type": "tool", "toolName": "Read", "summary": "Reading test output", "icon": "fa-solid fa-file-lines", "color": "#6bc76b", "elapsed": 0.3 }, + { "type": "complete" } + ] +} diff --git a/test/spec/ai-history-test-files/session-with-images.json b/test/spec/ai-history-test-files/session-with-images.json new file mode 100644 index 0000000000..7700d5ea7b --- /dev/null +++ b/test/spec/ai-history-test-files/session-with-images.json @@ -0,0 +1,17 @@ +{ + "id": "img-sess", + "title": "Check this screenshot", + "timestamp": 1700000000000, + "messages": [ + { + "type": "user", + "text": "Check this screenshot", + "images": [ + { "dataUrl": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUg==", "mediaType": "image/png" }, + { "dataUrl": "data:image/jpeg;base64,/9j/4AAQSkZJRg==", "mediaType": "image/jpeg" } + ] + }, + { "type": "assistant", "markdown": "I can see two images in your screenshot.", "isFirst": true }, + { "type": "complete" } + ] +} diff --git a/test/spec/ai-history-test-files/session-with-other-answer.json b/test/spec/ai-history-test-files/session-with-other-answer.json new file mode 100644 index 0000000000..980d134893 --- /dev/null +++ b/test/spec/ai-history-test-files/session-with-other-answer.json @@ -0,0 +1,24 @@ +{ + "id": "other-ans-sess", + "title": "Configure the build", + "timestamp": 1700000000000, + "messages": [ + { "type": "user", "text": "Configure the build" }, + { + "type": "question", + "questions": [ + { + "question": "Which bundler?", + "options": [ + { "label": "Webpack", "description": "Established bundler" }, + { "label": "Vite", "description": "Fast dev server" } + ] + } + ], + "answers": { + "Which bundler?": "Rollup with custom plugins" + } + }, + { "type": "complete" } + ] +} diff --git a/test/spec/ai-history-test-files/session-with-questions.json b/test/spec/ai-history-test-files/session-with-questions.json new file mode 100644 index 0000000000..98a1f3eaa8 --- /dev/null +++ b/test/spec/ai-history-test-files/session-with-questions.json @@ -0,0 +1,37 @@ +{ + "id": "q-sess", + "title": "Set up authentication", + "timestamp": 1700000000000, + "messages": [ + { "type": "user", "text": "Set up authentication" }, + { "type": "assistant", "markdown": "I have a few questions before I start.", "isFirst": true }, + { + "type": "question", + "questions": [ + { + "question": "Which auth method do you prefer?", + "options": [ + { "label": "JWT", "description": "Stateless token-based auth" }, + { "label": "Session", "description": "Server-side session storage" }, + { "label": "OAuth", "description": "Third-party provider auth" } + ] + }, + { + "question": "Which database?", + "options": [ + { "label": "PostgreSQL", "description": "Relational database" }, + { "label": "MongoDB", "description": "Document database" } + ] + } + ], + "answers": { + "Which auth method do you prefer?": "JWT", + "Which database?": "PostgreSQL" + } + }, + { "type": "assistant", "markdown": "Great, I'll set up JWT auth with PostgreSQL." }, + { "type": "tool", "toolName": "Write", "summary": "Writing auth/jwt.js", "icon": "fa-solid fa-file-pen", "color": "#e8a838", "elapsed": 0.8 }, + { "type": "tool_edit", "file": "/src/auth/jwt.js", "linesAdded": 45, "linesRemoved": 0 }, + { "type": "complete" } + ] +} diff --git a/test/spec/ai-history-test-files/simple-session.json b/test/spec/ai-history-test-files/simple-session.json new file mode 100644 index 0000000000..144840efb1 --- /dev/null +++ b/test/spec/ai-history-test-files/simple-session.json @@ -0,0 +1,10 @@ +{ + "id": "simple-sess", + "title": "What is 2+2?", + "timestamp": 1700000000000, + "messages": [ + { "type": "user", "text": "What is 2+2?" }, + { "type": "assistant", "markdown": "The answer is **4**.", "isFirst": true }, + { "type": "complete" } + ] +} diff --git a/test/spec/ai-history-test.js b/test/spec/ai-history-test.js new file mode 100644 index 0000000000..783994db96 --- /dev/null +++ b/test/spec/ai-history-test.js @@ -0,0 +1,741 @@ +/* + * 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"); + }); + }); + }); +}); From 50d928d7ee5f82d684f39515ebf94ac334b1ac87 Mon Sep 17 00:00:00 2001 From: abose Date: Mon, 23 Feb 2026 18:49:38 +0530 Subject: [PATCH 3/7] fix(mcp): prevent test iframe from connecting to MCP and add exec_js_in_test_iframe tool The embedded Phoenix iframe inside SpecRunner was connecting to MCP independently, causing instance confusion. Now only the SpecRunner connects to MCP, and a new exec_js_in_test_iframe tool bridges JS execution to the iframe when needed. --- phoenix-builder-mcp/mcp-tools.js | 33 ++++++++++++++ phoenix-builder-mcp/ws-control-server.js | 49 +++++++++++++++++++++ src/phoenix-builder/phoenix-builder-boot.js | 7 +++ test/phoenix-test-runner-mcp.js | 40 +++++++++++++++++ 4 files changed, 129 insertions(+) diff --git a/phoenix-builder-mcp/mcp-tools.js b/phoenix-builder-mcp/mcp-tools.js index e63128a019..0f040f2d8f 100644 --- a/phoenix-builder-mcp/mcp-tools.js +++ b/phoenix-builder-mcp/mcp-tools.js @@ -383,6 +383,39 @@ export function registerTools(server, processManager, wsControlServer, phoenixDe } ); + server.tool( + "exec_js_in_test_iframe", + "Execute JavaScript in the embedded test Phoenix iframe inside the SpecRunner, NOT in the SpecRunner itself. " + + "The iframe is usually not present during unit tests, but for other categories tests may spawn it as needed — " + + "it can come and go at any time. " + + "Code runs async in the iframe's page context with access to the test Phoenix instance's globals " + + "(jQuery $, brackets.test.*, etc.). " + + "Returns an error if no iframe is present. " + + "Use exec_js to control the SpecRunner (run tests, get results); use this tool to inspect the test Phoenix instance.", + { + code: z.string().describe("JavaScript code to execute in the test Phoenix iframe"), + instance: z.string().optional().describe("Target a specific test runner instance by name. Required when multiple instances are connected.") + }, + async ({ code, instance }) => { + try { + const result = await wsControlServer.requestExecJsInTestIframe(code, instance); + return { + content: [{ + type: "text", + text: result !== undefined ? String(result) : "(undefined)" + }] + }; + } catch (err) { + return { + content: [{ + type: "text", + text: JSON.stringify({ error: err.message }) + }] + }; + } + } + ); + server.tool( "run_tests", "Run tests in the Phoenix test runner (SpecRunner.html). Reloads the test runner with the specified " + diff --git a/phoenix-builder-mcp/ws-control-server.js b/phoenix-builder-mcp/ws-control-server.js index 1ed071a63f..f09efab1e0 100644 --- a/phoenix-builder-mcp/ws-control-server.js +++ b/phoenix-builder-mcp/ws-control-server.js @@ -109,6 +109,19 @@ export function createWSControlServer(port) { break; } + case "exec_js_in_test_iframe_response": { + const pending7 = pendingRequests.get(msg.id); + if (pending7) { + pendingRequests.delete(msg.id); + if (msg.error) { + pending7.reject(new Error(msg.error)); + } else { + pending7.resolve(msg.result); + } + } + break; + } + case "run_tests_response": { const pendingRt = pendingRequests.get(msg.id); if (pendingRt) { @@ -412,6 +425,41 @@ export function createWSControlServer(port) { }); } + function requestExecJsInTestIframe(code, instanceName) { + return new Promise((resolve, reject) => { + const resolved = _resolveClient(instanceName); + if (resolved.error) { + reject(new Error(resolved.error)); + return; + } + + const { client } = resolved; + if (client.ws.readyState !== 1) { + reject(new Error("Phoenix client \"" + resolved.name + "\" is not connected")); + return; + } + + const id = ++requestIdCounter; + const timeout = setTimeout(() => { + pendingRequests.delete(id); + reject(new Error("exec_js_in_test_iframe request timed out (30s)")); + }, 30000); + + pendingRequests.set(id, { + resolve: (data) => { + clearTimeout(timeout); + resolve(data); + }, + reject: (err) => { + clearTimeout(timeout); + reject(err); + } + }); + + client.ws.send(JSON.stringify({ type: "exec_js_in_test_iframe_request", id, code })); + }); + } + function requestRunTests(category, spec, instanceName) { return new Promise((resolve, reject) => { const resolved = _resolveClient(instanceName); @@ -538,6 +586,7 @@ export function createWSControlServer(port) { requestLogs, requestExecJs, requestExecJsLivePreview, + requestExecJsInTestIframe, requestRunTests, requestTestResults, getBrowserLogs, diff --git a/src/phoenix-builder/phoenix-builder-boot.js b/src/phoenix-builder/phoenix-builder-boot.js index 94116ec1fb..f3d5269d00 100644 --- a/src/phoenix-builder/phoenix-builder-boot.js +++ b/src/phoenix-builder/phoenix-builder-boot.js @@ -33,6 +33,13 @@ if (!window.AppConfig || AppConfig.config.environment !== "dev") { return; } + // Skip MCP in test windows (the embedded Phoenix iframe inside SpecRunner). + // Only the SpecRunner itself and the normal Phoenix app should connect to MCP. + // Phoenix.isTestWindow is true for both SpecRunner and the test iframe, + // but Phoenix.isSpecRunnerWindow is only true for the SpecRunner itself. + if (window.Phoenix && window.Phoenix.isTestWindow && !window.Phoenix.isSpecRunnerWindow) { + return; + } // --- Constants --- const LOG_TO_CONSOLE_KEY = "logToConsole"; diff --git a/test/phoenix-test-runner-mcp.js b/test/phoenix-test-runner-mcp.js index 1284a49f4d..be19d8f42e 100644 --- a/test/phoenix-test-runner-mcp.js +++ b/test/phoenix-test-runner-mcp.js @@ -185,4 +185,44 @@ }; } + // --- exec_js_in_test_iframe_request --- + // When the SpecRunner has an embedded test iframe, forward exec_js to it + // so MCP tools can operate on the test Phoenix instance inside the iframe. + // Falls back to the SpecRunner context if no iframe is present (e.g. unit tests). + builder.registerHandler("exec_js_in_test_iframe_request", function (msg) { + var iframe = document.querySelector(".phoenixIframe"); + if (!iframe || !iframe.contentWindow) { + builder.sendMessage({ + type: "exec_js_in_test_iframe_response", + id: msg.id, + error: "No test iframe present. The embedded Phoenix instance is not currently loaded." + }); + return; + } + var targetWindow = iframe.contentWindow; + // Create the async function in the target window's context so globals + // like $, brackets, etc. resolve to the iframe's scope. + var AsyncFunction = targetWindow.eval("(async function(){}).constructor"); + var fn = new AsyncFunction(msg.code); + fn().then(function (result) { + var text; + try { + text = (result !== undefined && result !== null) ? JSON.stringify(result) : "(undefined)"; + } catch (e) { + text = String(result); + } + builder.sendMessage({ + type: "exec_js_in_test_iframe_response", + id: msg.id, + result: text + }); + }).catch(function (err) { + builder.sendMessage({ + type: "exec_js_in_test_iframe_response", + id: msg.id, + error: (err && err.stack) || (err && err.message) || String(err) + }); + }); + }); + }()); From cf51ac3b5b9695f69b01842680972cae28edbd06 Mon Sep 17 00:00:00 2001 From: abose Date: Mon, 23 Feb 2026 19:58:58 +0530 Subject: [PATCH 4/7] chore: wire in entitlements --- src/core-ai/AIChatPanel.js | 138 +++++++++++++++++++++++++++++-- src/nls/root/strings.js | 11 ++- src/styles/Extn-AIChatPanel.less | 26 ++++++ 3 files changed, 168 insertions(+), 7 deletions(-) diff --git a/src/core-ai/AIChatPanel.js b/src/core-ai/AIChatPanel.js index b7af5a7556..0446d0c2f2 100644 --- a/src/core-ai/AIChatPanel.js +++ b/src/core-ai/AIChatPanel.js @@ -41,6 +41,9 @@ define(function (require, exports, module) { 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 _isStreaming = false; let _queuedMessage = null; // text queued by user while AI is streaming @@ -86,6 +89,7 @@ define(function (require, exports, module) { // 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 @@ -148,6 +152,7 @@ define(function (require, exports, module) { '
' + Strings.AI_CHAT_DESKTOP_ONLY + '
' + + '' + '
' + '
'; @@ -185,16 +190,138 @@ define(function (require, exports, module) { } }); - // Check availability and render appropriate UI - _checkAvailability(); + // 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); - SidebarTabs.addToTab("ai", $placeholder); + $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() { + _removeCurrentPanel(); + const EntitlementsManager = _KernalModeTrust && _KernalModeTrust.EntitlementsManager; + if (!EntitlementsManager) { + // No entitlement system (test env or dev) — skip straight to CLI check + _checkAvailability(); + return; + } + EntitlementsManager.getAIEntitlement().then(function (entitlement) { + if (entitlement.aiDisabledByAdmin) { + _renderAdminDisabledUI(); + } else if (entitlement.activated) { + _checkAvailability(); + } else if (entitlement.needsLogin) { + _renderLoginUI(); + } else { + _renderUpsellUI(entitlement); + } + }).catch(function () { + _checkAvailability(); // fallback on error + }); + } + + /** + * Render the login prompt UI (user not signed in). + */ + function _renderLoginUI() { + 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) { + 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() { + const html = + '
' + + '
' + + '
' + + '
' + Strings.AI_CHAT_ADMIN_DISABLED_TITLE + '
' + + '
' + + Strings.AI_CHAT_ADMIN_DISABLED_MESSAGE + + '
' + + '
' + + '
'; + $aiTabContainer.empty().append($(html)); } /** @@ -419,7 +546,7 @@ define(function (require, exports, module) { _newSession(); }); - SidebarTabs.addToTab("ai", $panel); + $aiTabContainer.empty().append($panel); } /** @@ -428,10 +555,9 @@ define(function (require, exports, module) { function _renderUnavailableUI(error) { const $unavailable = $(UNAVAILABLE_HTML); $unavailable.find(".ai-retry-btn").on("click", function () { - $unavailable.remove(); _checkAvailability(); }); - SidebarTabs.addToTab("ai", $unavailable); + $aiTabContainer.empty().append($unavailable); } // --- Context bar chip management --- diff --git a/src/nls/root/strings.js b/src/nls/root/strings.js index 87ee4b7266..68a7d5db16 100644 --- a/src/nls/root/strings.js +++ b/src/nls/root/strings.js @@ -1823,7 +1823,16 @@ define({ "AI_CHAT_CLI_NOT_FOUND": "Claude CLI Not Found", "AI_CHAT_CLI_INSTALL_MSG": "Install the Claude CLI to use AI features:
npm install -g @anthropic-ai/claude-code

Then run claude login to authenticate.", "AI_CHAT_RETRY": "Retry", - "AI_CHAT_DESKTOP_ONLY": "AI features require the Phoenix desktop app.", + "AI_CHAT_DESKTOP_ONLY": "AI features require the Phoenix desktop app. Download it to get started.", + "AI_CHAT_DOWNLOAD_BTN": "Download Desktop App", + "AI_CHAT_LOGIN_TITLE": "Sign In to Use AI", + "AI_CHAT_LOGIN_MESSAGE": "Sign in to your {APP_NAME} account to access AI features.", + "AI_CHAT_LOGIN_BTN": "Sign In", + "AI_CHAT_UPSELL_TITLE": "Phoenix Pro + AI", + "AI_CHAT_UPSELL_MESSAGE": "AI features are available with Phoenix Pro.", + "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_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 fcfd3b0af0..ef56a4c16f 100644 --- a/src/styles/Extn-AIChatPanel.less +++ b/src/styles/Extn-AIChatPanel.less @@ -20,10 +20,20 @@ /* AI Chat Panel — sidebar chat UI for Claude Code integration */ +.ai-tab-container { + display: flex; + flex-direction: column; + -webkit-box-flex: 1; + flex: 1; + min-height: 0; + overflow: hidden; +} + .ai-chat-panel { display: flex; flex-direction: column; -webkit-box-flex: 1; + flex: 1; min-height: 0; overflow: hidden; background-color: @bc-sidebar-bg; @@ -1409,4 +1419,20 @@ color: @project-panel-text-1; } } + + .ai-upsell-btn { + background: #FF9900; + border: none; + color: #000; + font-weight: 600; + font-size: @sidebar-small-font-size; + padding: 3px 12px; + border-radius: 3px; + cursor: pointer; + transition: background-color 0.15s ease; + + &:hover { + background: #FFa820; + } + } } From d4057347521e3f18fc5acb3d1c11a6ca0ebd3bd6 Mon Sep 17 00:00:00 2001 From: abose Date: Mon, 23 Feb 2026 20:04:44 +0530 Subject: [PATCH 5/7] feat(ai): use liveEdit entitlement as temporary AI gate AI entitlement is not yet implemented in the backend. Use getLiveEditEntitlement as a proxy for Pro plan status and add a TODO with the full getAIEntitlement flow for when the backend is ready. --- src/core-ai/AIChatPanel.js | 26 ++++++++++++++++++++------ 1 file changed, 20 insertions(+), 6 deletions(-) diff --git a/src/core-ai/AIChatPanel.js b/src/core-ai/AIChatPanel.js index 0446d0c2f2..b6a6a616df 100644 --- a/src/core-ai/AIChatPanel.js +++ b/src/core-ai/AIChatPanel.js @@ -247,13 +247,27 @@ define(function (require, exports, module) { _checkAvailability(); return; } - EntitlementsManager.getAIEntitlement().then(function (entitlement) { - if (entitlement.aiDisabledByAdmin) { - _renderAdminDisabledUI(); - } else if (entitlement.activated) { + if (!EntitlementsManager.isLoggedIn()) { + _renderLoginUI(); + return; + } + // TODO: Switch to EntitlementsManager.getAIEntitlement() once AI entitlement is + // implemented in the backend. For now, reuse liveEdit entitlement as a proxy for + // "has Pro plan". Once AI entitlement is available, the check should be: + // EntitlementsManager.getAIEntitlement().then(function (entitlement) { + // if (entitlement.aiDisabledByAdmin) { + // _renderAdminDisabledUI(); + // } else if (entitlement.activated) { + // _checkAvailability(); + // } else if (entitlement.needsLogin) { + // _renderLoginUI(); + // } else { + // _renderUpsellUI(entitlement); + // } + // }); + EntitlementsManager.getLiveEditEntitlement().then(function (entitlement) { + if (entitlement.activated) { _checkAvailability(); - } else if (entitlement.needsLogin) { - _renderLoginUI(); } else { _renderUpsellUI(entitlement); } From 47ef4aeb99c5fed651e3e7378fe6e94084ff36e4 Mon Sep 17 00:00:00 2001 From: abose Date: Mon, 23 Feb 2026 20:06:06 +0530 Subject: [PATCH 6/7] chore: update pro repo --- tracking-repos.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tracking-repos.json b/tracking-repos.json index 15e87406bb..540dd2d76a 100644 --- a/tracking-repos.json +++ b/tracking-repos.json @@ -1,5 +1,5 @@ { "phoenixPro": { - "commitID": "968dd7418a998b9fbde87fd393e338d84d9ce4c3" + "commitID": "c7dd5ad19dea2ff31f77858e32be3dfb067bb6e1" } } From df65b26a76f7cdf3cf27ffdb1a7cdc3f1ec69f06 Mon Sep 17 00:00:00 2001 From: abose Date: Mon, 23 Feb 2026 20:55:14 +0530 Subject: [PATCH 7/7] chore: update pro deps --- tracking-repos.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tracking-repos.json b/tracking-repos.json index 540dd2d76a..b7e927d232 100644 --- a/tracking-repos.json +++ b/tracking-repos.json @@ -1,5 +1,5 @@ { "phoenixPro": { - "commitID": "c7dd5ad19dea2ff31f77858e32be3dfb067bb6e1" + "commitID": "73af25c3b01eee38a9d2a42773bdb4d709b637c1" } }