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-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/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/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..b6a6a616df 100644 --- a/src/core-ai/AIChatPanel.js +++ b/src/core-ai/AIChatPanel.js @@ -33,12 +33,17 @@ 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"); + // 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 @@ -75,8 +80,16 @@ 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; + 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 @@ -89,10 +102,16 @@ define(function (require, exports, module) { '
' + '
' + '' + Strings.AI_CHAT_TITLE + '' + - '' + + '
' + + '' + + '' + + '
' + '
' + + '
' + '
' + '
' + '' + @@ -133,6 +152,7 @@ define(function (require, exports, module) { '
' + Strings.AI_CHAT_DESKTOP_ONLY + '
' + + '' + '
' + '
'; @@ -159,19 +179,163 @@ 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 }; + }) + }); } }); - // 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; + } + 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 { + _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)); } /** @@ -214,6 +378,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,7 +531,36 @@ define(function (require, exports, module) { } }); - SidebarTabs.addToTab("ai", $panel); + // When switching projects, warn the user if AI is currently working + // and cancel the query before proceeding. + ProjectManager.off("beforeProjectClose.aiChat"); + ProjectManager.on("beforeProjectClose.aiChat", function (_event, _projectRoot, vetoPromises) { + if (_isStreaming && vetoPromises) { + const vetoPromise = new Promise(function (resolve, reject) { + Dialogs.showConfirmDialog( + Strings.AI_CHAT_SWITCH_PROJECT_TITLE, + Strings.AI_CHAT_SWITCH_PROJECT_MSG + ).done(function (btnId) { + if (btnId === Dialogs.DIALOG_BTN_OK) { + _cancelQuery(); + _setStreaming(false); + resolve(); + } else { + reject(); + } + }); + }); + vetoPromises.push(vetoPromise); + } + }); + + // When a new project opens, reset the AI chat to a blank state + ProjectManager.off("projectOpen.aiChat"); + ProjectManager.on("projectOpen.aiChat", function () { + _newSession(); + }); + + $aiTabContainer.empty().append($panel); } /** @@ -373,10 +569,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 --- @@ -752,6 +947,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 +963,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 +1086,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 +1105,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 +1393,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 +1490,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 +1502,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 +1775,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 +1906,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 +2093,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 +2140,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 +2543,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..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", @@ -1887,6 +1896,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/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/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..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; @@ -47,6 +57,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 +110,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; @@ -1252,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; + } + } } 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/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) + }); + }); + }); + }()); 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"); + }); + }); + }); +}); diff --git a/tracking-repos.json b/tracking-repos.json index 15e87406bb..b7e927d232 100644 --- a/tracking-repos.json +++ b/tracking-repos.json @@ -1,5 +1,5 @@ { "phoenixPro": { - "commitID": "968dd7418a998b9fbde87fd393e338d84d9ce4c3" + "commitID": "73af25c3b01eee38a9d2a42773bdb4d709b637c1" } }