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 = $(
+ '' +
+ '' +
+ '
'
+ );
+ $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 = $(
+ '' +
+ '' +
+ '
'
+ );
+ $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 = $(
+ ''
+ );
+ $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) {
'' +
'' +
+ '
' +
'
' +
'
' +
'
' +
@@ -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"
}
}