diff --git a/docs/API-Reference/command/Commands.md b/docs/API-Reference/command/Commands.md
index e3a0e2ce91..36c87ac56c 100644
--- a/docs/API-Reference/command/Commands.md
+++ b/docs/API-Reference/command/Commands.md
@@ -992,7 +992,7 @@ Performs a mixed reset
## CMD\_GIT\_TOGGLE\_PANEL
Toggles the git panel
-**Kind**: global variable
+**Kind**: global variable
## CMD\_CUSTOM\_SNIPPETS\_PANEL
diff --git a/docs/API-Reference/document/Document.md b/docs/API-Reference/document/Document.md
index 17368b1391..21679b25d5 100644
--- a/docs/API-Reference/document/Document.md
+++ b/docs/API-Reference/document/Document.md
@@ -248,7 +248,7 @@ Given a character index within the document text (assuming \n newlines),
returns the corresponding {line, ch} position. Works whether or not
a master editor is attached.
-**Kind**: instance method of [Document](#Document)
+**Kind**: instance method of [Document](#Document)
| Param | Type | Description |
| --- | --- | --- |
diff --git a/docs/API-Reference/view/PanelView.md b/docs/API-Reference/view/PanelView.md
index 4bce903cdc..1a7cce00be 100644
--- a/docs/API-Reference/view/PanelView.md
+++ b/docs/API-Reference/view/PanelView.md
@@ -93,7 +93,7 @@ Sets the panel's visibility state
### panel.setTitle(newTitle)
Updates the display title shown in the tab bar for this panel.
-**Kind**: instance method of [Panel](#Panel)
+**Kind**: instance method of [Panel](#Panel)
| Param | Type | Description |
| --- | --- | --- |
@@ -105,7 +105,7 @@ Updates the display title shown in the tab bar for this panel.
Destroys the panel, removing it from the tab bar, internal maps, and the DOM.
After calling this, the Panel instance should not be reused.
-**Kind**: instance method of [Panel](#Panel)
+**Kind**: instance method of [Panel](#Panel)
### panel.getPanelType() ⇒ string
@@ -117,37 +117,61 @@ gets the Panel's type
## \_panelMap : Object.<string, Panel>
Maps panel ID to Panel instance
-**Kind**: global variable
+**Kind**: global variable
## \_$container : jQueryObject
The single container wrapping all bottom panels
-**Kind**: global variable
+**Kind**: global variable
## \_$tabBar : jQueryObject
The tab bar inside the container
-**Kind**: global variable
+**Kind**: global variable
## \_$tabsOverflow : jQueryObject
Scrollable area holding the tab elements
-**Kind**: global variable
+**Kind**: global variable
## \_openIds : Array.<string>
Ordered list of currently open (tabbed) panel IDs
-**Kind**: global variable
+**Kind**: global variable
## \_activeId : string \| null
The panel ID of the currently visible (active) tab
-**Kind**: global variable
+**Kind**: global variable
+
+
+## \_isMaximized : boolean
+Whether the bottom panel is currently maximized
+
+**Kind**: global variable
+
+
+## \_preMaximizeHeight : number \| null
+The panel height before maximize, for restore
+
+**Kind**: global variable
+
+
+## \_$editorHolder : jQueryObject
+The editor holder element, passed from WorkspaceManager
+
+**Kind**: global variable
+
+
+## \_recomputeLayout : function
+recomputeLayout callback from WorkspaceManager
+
+**Kind**: global variable
## EVENT\_PANEL\_HIDDEN : string
@@ -165,31 +189,80 @@ Event when panel is shown
## PANEL\_TYPE\_BOTTOM\_PANEL : string
type for bottom panel
+**Kind**: global constant
+
+
+## MAXIMIZE\_THRESHOLD : number
+Pixel threshold for detecting near-maximize state during resize.
+If the editor holder height is within this many pixels of zero, the
+panel is treated as maximized. Keeps the maximize icon responsive
+during drag without being overly sensitive.
+
+**Kind**: global constant
+
+
+## MIN\_PANEL\_HEIGHT : number
+Minimum panel height (matches Resizer minSize) used as a floor
+when computing a sensible restore height.
+
**Kind**: global constant
-## init($container, $tabBar, $tabsOverflow)
+## init($container, $tabBar, $tabsOverflow, $editorHolder, recomputeLayoutFn)
Initializes the PanelView module with references to the bottom panel container DOM elements.
Called by WorkspaceManager during htmlReady.
-**Kind**: global function
+**Kind**: global function
| Param | Type | Description |
| --- | --- | --- |
| $container | jQueryObject | The bottom panel container element. |
| $tabBar | jQueryObject | The tab bar element inside the container. |
| $tabsOverflow | jQueryObject | The scrollable area holding tab elements. |
+| $editorHolder | jQueryObject | The editor holder element (for maximize height calculation). |
+| recomputeLayoutFn | function | Callback to trigger workspace layout recomputation. |
+
+
+
+## exitMaximizeOnResize()
+Exit maximize state without resizing (for external callers like drag-resize).
+Clears internal maximize state and resets the button icon.
+
+**Kind**: global function
+
+
+## enterMaximizeOnResize()
+Enter maximize state during a drag-resize that reaches the maximum
+height. No pre-maximize height is stored because the user arrived
+here via continuous dragging; a sensible default will be computed if
+they later click the Restore button.
+
+**Kind**: global function
+
+
+## restoreIfMaximized()
+Restore the container's CSS height to the pre-maximize value and clear maximize state.
+Must be called BEFORE Resizer.hide() so the Resizer reads the correct height.
+If not maximized, this is a no-op.
+When the saved height is near-max or unknown, a sensible default is used.
+
+**Kind**: global function
+
+
+## isMaximized() ⇒ boolean
+Returns true if the bottom panel is currently maximized.
+**Kind**: global function
## getOpenBottomPanelIDs() ⇒ Array.<string>
Returns a copy of the currently open bottom panel IDs in tab order.
-**Kind**: global function
+**Kind**: global function
## hideAllOpenPanels() ⇒ Array.<string>
Hides every open bottom panel tab in a single batch
-**Kind**: global function
-**Returns**: Array.<string> - The IDs of panels that were open (useful for restoring later).
+**Kind**: global function
+**Returns**: Array.<string> - The IDs of panels that were open (useful for restoring later).
diff --git a/docs/API-Reference/view/WorkspaceManager.md b/docs/API-Reference/view/WorkspaceManager.md
index 57d4d627e2..f99f7f80a2 100644
--- a/docs/API-Reference/view/WorkspaceManager.md
+++ b/docs/API-Reference/view/WorkspaceManager.md
@@ -56,19 +56,19 @@ Constant representing the type of plugin panel
### view/WorkspaceManager.$bottomPanelContainer : jQueryObject
The single container wrapping all bottom panels
-**Kind**: inner property of [view/WorkspaceManager](#module_view/WorkspaceManager)
+**Kind**: inner property of [view/WorkspaceManager](#module_view/WorkspaceManager)
### view/WorkspaceManager.$statusBarPanelToggle : jQueryObject
Chevron toggle in the status bar
-**Kind**: inner property of [view/WorkspaceManager](#module_view/WorkspaceManager)
+**Kind**: inner property of [view/WorkspaceManager](#module_view/WorkspaceManager)
### view/WorkspaceManager.\_statusBarToggleInProgress : boolean
True while the status bar toggle button is handling a click
-**Kind**: inner property of [view/WorkspaceManager](#module_view/WorkspaceManager)
+**Kind**: inner property of [view/WorkspaceManager](#module_view/WorkspaceManager)
### view/WorkspaceManager.EVENT\_WORKSPACE\_UPDATE\_LAYOUT
@@ -108,7 +108,7 @@ The panel's size & visibility are automatically saved & restored as a view-state
Destroys a bottom panel, removing it from internal registries, the tab bar, and the DOM.
After calling this, the panel ID is no longer valid and the Panel instance should not be reused.
-**Kind**: inner method of [view/WorkspaceManager](#module_view/WorkspaceManager)
+**Kind**: inner method of [view/WorkspaceManager](#module_view/WorkspaceManager)
| Param | Type | Description |
| --- | --- | --- |
diff --git a/src-node/claude-code-agent.js b/src-node/claude-code-agent.js
index b281878b3f..2883d2eaa6 100644
--- a/src-node/claude-code-agent.js
+++ b/src-node/claude-code-agent.js
@@ -32,6 +32,10 @@ const { createEditorMcpServer } = require("./mcp-editor-tools");
const CONNECTOR_ID = "ph_ai_claude";
+const CLARIFICATION_HINT =
+ " IMPORTANT: The user has typed a follow-up clarification while you were working." +
+ " Call the getUserClarification tool to read it before proceeding.";
+
// Lazy-loaded ESM module reference
let queryModule = null;
@@ -47,6 +51,13 @@ let editorMcpServer = null;
// Streaming throttle
const TEXT_STREAM_THROTTLE_MS = 50;
+// Pending question resolver — used by AskUserQuestion hook
+let _questionResolve = null;
+
+// Queued clarification from the user (typed while AI is streaming)
+// Shape: { text: string, images: [{mediaType, base64Data}] } or null
+let _queuedClarification = null;
+
const nodeConnector = global.createNodeConnector(CONNECTOR_ID, exports);
/**
@@ -129,7 +140,7 @@ exports.checkAvailability = async function () {
* aiProgress, aiTextStream, aiToolEdit, aiError, aiComplete
*/
exports.sendPrompt = async function (params) {
- const { prompt, projectPath, sessionAction, model, locale, selectionContext } = params;
+ const { prompt, projectPath, sessionAction, model, locale, selectionContext, images } = params;
const requestId = Date.now().toString(36) + Math.random().toString(36).slice(2, 7);
// Handle session
@@ -137,6 +148,9 @@ exports.sendPrompt = async function (params) {
currentSessionId = null;
}
+ // Clear any stale clarification from a previous turn
+ _queuedClarification = null;
+
// Cancel any in-flight query
if (currentAbortController) {
currentAbortController.abort();
@@ -169,7 +183,7 @@ exports.sendPrompt = async function (params) {
}
// Run the query asynchronously — don't await here so we return requestId immediately
- _runQuery(requestId, enrichedPrompt, projectPath, model, currentAbortController.signal, locale)
+ _runQuery(requestId, enrichedPrompt, projectPath, model, currentAbortController.signal, locale, images)
.catch(err => {
console.error("[Phoenix AI] Query error:", err);
});
@@ -186,24 +200,79 @@ exports.cancelQuery = async function () {
currentAbortController = null;
// Clear session so next query starts fresh instead of resuming a killed session
currentSessionId = null;
+ // Clear any pending question
+ _questionResolve = null;
+ _queuedClarification = null;
return { success: true };
}
return { success: false };
};
+/**
+ * Receive the user's answer to an AskUserQuestion prompt.
+ * Called from browser via execPeer("answerQuestion", {answers}).
+ */
+exports.answerQuestion = async function (params) {
+ if (_questionResolve) {
+ _questionResolve(params);
+ _questionResolve = null;
+ }
+ return { success: true };
+};
+
/**
* Destroy the current session (clear session ID).
*/
exports.destroySession = async function () {
currentSessionId = null;
currentAbortController = null;
+ _queuedClarification = null;
+ return { success: true };
+};
+
+/**
+ * Queue a clarification message from the user (typed while AI is streaming).
+ * If text is already queued, appends with a newline.
+ */
+exports.queueClarification = async function (params) {
+ const newImages = params.images || [];
+ if (_queuedClarification) {
+ if (params.text) {
+ _queuedClarification.text += "\n" + params.text;
+ }
+ _queuedClarification.images = _queuedClarification.images.concat(newImages);
+ } else {
+ _queuedClarification = {
+ text: params.text || "",
+ images: newImages
+ };
+ }
+ return { success: true };
+};
+
+/**
+ * Get and clear the queued clarification (text + images).
+ * Called by the getUserClarification MCP tool.
+ */
+exports.getAndClearClarification = async function () {
+ const result = _queuedClarification;
+ _queuedClarification = null;
+ return result || { text: null, images: [] };
+};
+
+/**
+ * Clear any queued clarification without reading it.
+ * Used when the user clicks Edit on the queue bubble.
+ */
+exports.clearClarification = async function () {
+ _queuedClarification = null;
return { success: true };
};
/**
* Internal: run a Claude SDK query and stream results back to the browser.
*/
-async function _runQuery(requestId, prompt, projectPath, model, signal, locale) {
+async function _runQuery(requestId, prompt, projectPath, model, signal, locale, images) {
let editCount = 0;
let toolCounter = 0;
let queryFn;
@@ -211,7 +280,10 @@ async function _runQuery(requestId, prompt, projectPath, model, signal, locale)
try {
queryFn = await getQueryFn();
if (!editorMcpServer) {
- editorMcpServer = createEditorMcpServer(queryModule, nodeConnector);
+ editorMcpServer = createEditorMcpServer(queryModule, nodeConnector, {
+ hasClarification: function () { return !!_queuedClarification; },
+ getAndClearClarification: exports.getAndClearClarification
+ });
}
} catch (err) {
nodeConnector.triggerPeer("aiError", {
@@ -232,14 +304,41 @@ async function _runQuery(requestId, prompt, projectPath, model, signal, locale)
cwd: projectPath || process.cwd(),
maxTurns: undefined,
allowedTools: [
- "Read", "Edit", "Write", "Glob", "Grep",
+ "Read", "Edit", "Write", "Glob", "Grep", "Bash",
+ "AskUserQuestion", "Task",
+ "TodoRead", "TodoWrite",
+ "WebFetch", "WebSearch",
"mcp__phoenix-editor__getEditorState",
"mcp__phoenix-editor__takeScreenshot",
"mcp__phoenix-editor__execJsInLivePreview",
"mcp__phoenix-editor__controlEditor",
"mcp__phoenix-editor__resizeLivePreview",
- "mcp__phoenix-editor__wait"
+ "mcp__phoenix-editor__wait",
+ "mcp__phoenix-editor__getUserClarification"
],
+ agents: {
+ "researcher": {
+ description: "Explores the codebase, reads files, and searches" +
+ " for patterns. Use for research tasks.",
+ prompt: "You are a code research assistant. Search and read" +
+ " files to answer questions. Do not modify files.",
+ tools: ["Read", "Glob", "Grep",
+ "mcp__phoenix-editor__getEditorState",
+ "mcp__phoenix-editor__takeScreenshot",
+ "mcp__phoenix-editor__execJsInLivePreview"]
+ },
+ "coder": {
+ description: "Reads, edits, and writes code files." +
+ " Use for implementation tasks.",
+ prompt: "You are a coding assistant. Implement the requested" +
+ " changes using Edit for existing files and Write" +
+ " only for new files.",
+ tools: ["Read", "Edit", "Write", "Glob", "Grep",
+ "mcp__phoenix-editor__getEditorState",
+ "mcp__phoenix-editor__takeScreenshot",
+ "mcp__phoenix-editor__execJsInLivePreview"]
+ }
+ },
mcpServers: { "phoenix-editor": editorMcpServer },
permissionMode: "acceptEdits",
appendSystemPrompt:
@@ -249,6 +348,8 @@ async function _runQuery(requestId, prompt, projectPath, model, signal, locale)
"multiple Edit calls to make targeted changes rather than rewriting the entire " +
"file with Write. This is critical because Write replaces the entire file content " +
"which is slow and loses undo history." +
+ "\n\nWhen a tool response mentions the user has typed a clarification, immediately " +
+ "call getUserClarification to read it and incorporate the user's feedback into your current work." +
(locale && !locale.startsWith("en")
? "\n\nThe user's display language is " + locale + ". " +
"Respond in this language unless they write in a different language."
@@ -291,6 +392,9 @@ async function _runQuery(requestId, prompt, projectPath, model, signal, locale)
" Reload when ready with execJsInLivePreview: `location.reload()`";
}
}
+ if (_queuedClarification) {
+ reason += CLARIFICATION_HINT;
+ }
return {
hookSpecificOutput: {
hookEventName: "PreToolUse",
@@ -327,6 +431,9 @@ async function _runQuery(requestId, prompt, projectPath, model, signal, locale)
formatted = filePath + " (" +
lines.length + " lines total)\n\n" + formatted;
console.log("[Phoenix AI] Serving dirty file content for:", filePath);
+ if (_queuedClarification) {
+ formatted += CLARIFICATION_HINT;
+ }
return {
hookSpecificOutput: {
hookEventName: "PreToolUse",
@@ -376,6 +483,9 @@ async function _runQuery(requestId, prompt, projectPath, model, signal, locale)
" Reload when ready with execJsInLivePreview: `location.reload()`";
}
}
+ if (_queuedClarification) {
+ reason += CLARIFICATION_HINT;
+ }
return {
hookSpecificOutput: {
hookEventName: "PreToolUse",
@@ -385,6 +495,48 @@ async function _runQuery(requestId, prompt, projectPath, model, signal, locale)
};
}
]
+ },
+ {
+ matcher: "AskUserQuestion",
+ hooks: [
+ async (input) => {
+ console.log("[Phoenix AI] Intercepted AskUserQuestion");
+ const questions = input.tool_input.questions || [];
+ nodeConnector.triggerPeer("aiQuestion", {
+ requestId: requestId,
+ questions: questions
+ });
+ // Wait for the user's answer from the browser UI
+ const answer = await new Promise((resolve, reject) => {
+ _questionResolve = resolve;
+ if (signal.aborted) {
+ _questionResolve = null;
+ reject(new Error("Aborted"));
+ return;
+ }
+ const onAbort = () => {
+ _questionResolve = null;
+ reject(new Error("Aborted"));
+ };
+ signal.addEventListener("abort", onAbort, { once: true });
+ });
+ // Format answers as readable text for the AI
+ let answerText = "";
+ if (answer.answers) {
+ const keys = Object.keys(answer.answers);
+ keys.forEach(function (q) {
+ answerText += "Q: " + q + "\nA: " + answer.answers[q] + "\n\n";
+ });
+ }
+ return {
+ hookSpecificOutput: {
+ hookEventName: "PreToolUse",
+ permissionDecision: "deny",
+ permissionDecisionReason: answerText.trim() || "No answer provided"
+ }
+ };
+ }
+ ]
}
]
}
@@ -400,6 +552,7 @@ async function _runQuery(requestId, prompt, projectPath, model, signal, locale)
queryOptions.model = model;
}
+
// Resume session if we have an existing one (already cleared if sessionAction was "new")
if (currentSessionId) {
queryOptions.resume = currentSessionId;
@@ -410,8 +563,28 @@ async function _runQuery(requestId, prompt, projectPath, model, signal, locale)
try {
_log("Query start:", JSON.stringify(prompt).slice(0, 80), "cwd=" + (projectPath || "?"));
+ // Build prompt: multi-modal with images, or plain string
+ let sdkPrompt = prompt;
+ if (images && images.length > 0) {
+ const contentBlocks = [{ type: "text", text: prompt }];
+ images.forEach(function (img) {
+ contentBlocks.push({
+ type: "image",
+ source: { type: "base64", media_type: img.mediaType, data: img.base64Data }
+ });
+ });
+ sdkPrompt = (async function* () {
+ yield {
+ type: "user",
+ session_id: currentSessionId || "",
+ message: { role: "user", content: contentBlocks },
+ parent_tool_use_id: null
+ };
+ })();
+ }
+
const result = queryFn({
- prompt: prompt,
+ prompt: sdkPrompt,
options: queryOptions
});
@@ -670,6 +843,9 @@ async function _runQuery(requestId, prompt, projectPath, model, signal, locale)
_log("Error:", errMsg.slice(0, 200));
+ // Clear session after error to prevent cascading failures from resuming a broken session
+ currentSessionId = null;
+
nodeConnector.triggerPeer("aiError", {
requestId: requestId,
error: errMsg
@@ -678,7 +854,7 @@ async function _runQuery(requestId, prompt, projectPath, model, signal, locale)
// Always send aiComplete after aiError so the UI exits streaming state
nodeConnector.triggerPeer("aiComplete", {
requestId: requestId,
- sessionId: currentSessionId
+ sessionId: null
});
}
}
diff --git a/src-node/mcp-editor-tools.js b/src-node/mcp-editor-tools.js
index 66d64ec284..962456d682 100644
--- a/src-node/mcp-editor-tools.js
+++ b/src-node/mcp-editor-tools.js
@@ -31,14 +31,34 @@
const { z } = require("zod");
+const CLARIFICATION_HINT =
+ "IMPORTANT: The user has typed a follow-up clarification while you were working." +
+ " Call the getUserClarification tool to read it before proceeding.";
+
+/**
+ * Append a clarification hint to an MCP tool result if the user has queued a message.
+ */
+function _maybeAppendHint(result, hasClarification) {
+ if (hasClarification && hasClarification()) {
+ if (result && result.content && Array.isArray(result.content)) {
+ result.content.push({ type: "text", text: CLARIFICATION_HINT });
+ }
+ }
+ return result;
+}
+
/**
* Create an in-process MCP server exposing editor context tools.
*
* @param {Object} sdkModule - The imported @anthropic-ai/claude-code ESM module
* @param {Object} nodeConnector - The NodeConnector instance for communicating with the browser
+ * @param {Object} [clarificationAccessors] - Optional accessors for user clarification queue
+ * @param {Function} clarificationAccessors.hasClarification - Returns true if a clarification is queued
+ * @param {Function} clarificationAccessors.getAndClearClarification - Returns {text} and clears the queue
* @returns {McpSdkServerConfigWithInstance} MCP server config ready for queryOptions.mcpServers
*/
-function createEditorMcpServer(sdkModule, nodeConnector) {
+function createEditorMcpServer(sdkModule, nodeConnector, clarificationAccessors) {
+ const hasClarification = clarificationAccessors && clarificationAccessors.hasClarification;
const getEditorStateTool = sdkModule.tool(
"getEditorState",
"Get the current Phoenix editor state: active file, working set (open files), live preview file, " +
@@ -48,17 +68,19 @@ function createEditorMcpServer(sdkModule, nodeConnector) {
"Long lines are trimmed to 200 chars and selections to 10K chars — use the Read tool for full content.",
{},
async function () {
+ let result;
try {
const state = await nodeConnector.execPeer("getEditorState", {});
- return {
+ result = {
content: [{ type: "text", text: JSON.stringify(state) }]
};
} catch (err) {
- return {
+ result = {
content: [{ type: "text", text: "Error getting editor state: " + err.message }],
isError: true
};
}
+ return _maybeAppendHint(result, hasClarification);
}
);
@@ -68,28 +90,37 @@ function createEditorMcpServer(sdkModule, nodeConnector) {
"Prefer capturing specific regions instead of the full page: " +
"use selector '#panel-live-preview-frame' for the live preview content, " +
"or '.editor-holder' for the code editor area. " +
- "Only omit the selector when you need to see the full application layout.",
- { selector: z.string().optional().describe("CSS selector to capture a specific element. Use '#panel-live-preview-frame' for the live preview, '.editor-holder' for the code editor.") },
+ "Only omit the selector when you need to see the full application layout. " +
+ "Note: live preview screenshots may include Phoenix toolbox overlays on selected elements " +
+ "and other editor UI elements. Use purePreview=true to temporarily hide these overlays.",
+ {
+ selector: z.string().optional().describe("CSS selector to capture a specific element. Use '#panel-live-preview-frame' for the live preview, '.editor-holder' for the code editor."),
+ purePreview: z.boolean().optional().describe("When true, temporarily switches to preview mode to hide element highlight overlays and toolboxes before capturing, then restores the previous mode.")
+ },
async function (args) {
+ let toolResult;
try {
const result = await nodeConnector.execPeer("takeScreenshot", {
- selector: args.selector || undefined
+ selector: args.selector || undefined,
+ purePreview: args.purePreview || false
});
if (result.base64) {
- return {
+ toolResult = {
content: [{ type: "image", data: result.base64, mimeType: "image/png" }]
};
+ } else {
+ toolResult = {
+ content: [{ type: "text", text: result.error || "Screenshot failed" }],
+ isError: true
+ };
}
- return {
- content: [{ type: "text", text: result.error || "Screenshot failed" }],
- isError: true
- };
} catch (err) {
- return {
+ toolResult = {
content: [{ type: "text", text: "Error taking screenshot: " + err.message }],
isError: true
};
}
+ return _maybeAppendHint(toolResult, hasClarification);
}
);
@@ -103,25 +134,28 @@ function createEditorMcpServer(sdkModule, nodeConnector) {
"(e.g. document.title, DOM queries).",
{ code: z.string().describe("JavaScript code to execute in the live preview iframe") },
async function (args) {
+ let toolResult;
try {
const result = await nodeConnector.execPeer("execJsInLivePreview", {
code: args.code
});
if (result.error) {
- return {
+ toolResult = {
content: [{ type: "text", text: "Error: " + result.error }],
isError: true
};
+ } else {
+ toolResult = {
+ content: [{ type: "text", text: result.result || "undefined" }]
+ };
}
- return {
- content: [{ type: "text", text: result.result || "undefined" }]
- };
} catch (err) {
- return {
+ toolResult = {
content: [{ type: "text", text: "Error executing JS in live preview: " + err.message }],
isError: true
};
}
+ return _maybeAppendHint(toolResult, hasClarification);
}
);
@@ -152,21 +186,27 @@ function createEditorMcpServer(sdkModule, nodeConnector) {
const results = [];
let hasError = false;
for (const op of args.operations) {
+ console.log("[Phoenix AI] controlEditor:", op.operation, op.filePath);
try {
const result = await nodeConnector.execPeer("controlEditor", op);
results.push(result);
if (!result.success) {
hasError = true;
+ console.warn("[Phoenix AI] controlEditor failed:", op.operation, op.filePath, result.error);
+ } else {
+ console.log("[Phoenix AI] controlEditor success:", op.operation, op.filePath);
}
} catch (err) {
results.push({ success: false, error: err.message });
hasError = true;
+ console.error("[Phoenix AI] controlEditor error:", op.operation, op.filePath, err.message);
}
}
- return {
+ const toolResult = {
content: [{ type: "text", text: JSON.stringify(results) }],
isError: hasError
};
+ return _maybeAppendHint(toolResult, hasClarification);
}
);
@@ -178,25 +218,28 @@ function createEditorMcpServer(sdkModule, nodeConnector) {
width: z.number().describe("Target width in pixels")
},
async function (args) {
+ let toolResult;
try {
const result = await nodeConnector.execPeer("resizeLivePreview", {
width: args.width
});
if (result.error) {
- return {
+ toolResult = {
content: [{ type: "text", text: "Error: " + result.error }],
isError: true
};
+ } else {
+ toolResult = {
+ content: [{ type: "text", text: JSON.stringify(result) }]
+ };
}
- return {
- content: [{ type: "text", text: JSON.stringify(result) }]
- };
} catch (err) {
- return {
+ toolResult = {
content: [{ type: "text", text: "Error resizing live preview: " + err.message }],
isError: true
};
}
+ return _maybeAppendHint(toolResult, hasClarification);
}
);
@@ -211,16 +254,53 @@ function createEditorMcpServer(sdkModule, nodeConnector) {
async function (args) {
const ms = Math.round(args.seconds * 1000);
await new Promise(function (resolve) { setTimeout(resolve, ms); });
- return {
+ const toolResult = {
content: [{ type: "text", text: "Waited " + args.seconds + " seconds." }]
};
+ return _maybeAppendHint(toolResult, hasClarification);
+ }
+ );
+
+ const getUserClarificationTool = sdkModule.tool(
+ "getUserClarification",
+ "Retrieve a follow-up clarification message the user typed while you were working. " +
+ "Returns the clarification text and clears the queue. Only call this when a tool response " +
+ "indicates the user has typed a clarification.",
+ {},
+ async function () {
+ if (clarificationAccessors && clarificationAccessors.getAndClearClarification) {
+ const result = await clarificationAccessors.getAndClearClarification();
+ if (result && (result.text || (result.images && result.images.length > 0))) {
+ // Notify browser with the text so it can show it as a user message bubble
+ nodeConnector.triggerPeer("aiClarificationRead", {
+ text: result.text || ""
+ });
+ const content = [];
+ if (result.text) {
+ content.push({ type: "text", text: "User clarification: " + result.text });
+ }
+ if (result.images && result.images.length > 0) {
+ result.images.forEach(function (img) {
+ content.push({
+ type: "image",
+ data: img.base64Data,
+ mimeType: img.mediaType
+ });
+ });
+ }
+ return { content: content };
+ }
+ }
+ return {
+ content: [{ type: "text", text: "No clarification queued." }]
+ };
}
);
return sdkModule.createSdkMcpServer({
name: "phoenix-editor",
tools: [getEditorStateTool, takeScreenshotTool, execJsInLivePreviewTool, controlEditorTool,
- resizeLivePreviewTool, waitTool]
+ resizeLivePreviewTool, waitTool, getUserClarificationTool]
});
}
diff --git a/src/core-ai/AIChatPanel.js b/src/core-ai/AIChatPanel.js
index a786b82cd8..6d939d12c0 100644
--- a/src/core-ai/AIChatPanel.js
+++ b/src/core-ai/AIChatPanel.js
@@ -41,6 +41,7 @@ define(function (require, exports, module) {
let _nodeConnector = null;
let _isStreaming = false;
+ let _queuedMessage = null; // text queued by user while AI is streaming
let _currentRequestId = null;
let _segmentText = ""; // text for the current segment only
let _autoScroll = true;
@@ -48,6 +49,7 @@ define(function (require, exports, module) {
let _currentEdits = []; // edits in current response, for summary card
let _firstEditInResponse = true; // tracks first edit per response for initial PUC
let _undoApplied = false; // whether undo/restore has been clicked on any card
+ let _sessionError = false; // set when aiError fires; cleared on new send or new session
// --- AI event trace logging (compact, non-flooding) ---
let _traceTextChunks = 0;
let _traceToolStreamCounts = {}; // toolId → count
@@ -65,8 +67,16 @@ define(function (require, exports, module) {
let _livePreviewDismissed = false; // user dismissed live preview chip
let $contextBar; // DOM ref
+ // Image paste state
+ let _attachedImages = []; // [{dataUrl, mediaType, base64Data}]
+ const MAX_IMAGES = 10;
+ const MAX_IMAGE_BASE64_SIZE = 200 * 1024; // ~200KB base64
+ const ALLOWED_IMAGE_TYPES = [
+ "image/png", "image/jpeg", "image/gif", "image/webp", "image/svg+xml"
+ ];
+
// DOM references
- let $panel, $messages, $status, $statusText, $textarea, $sendBtn, $stopBtn;
+ let $panel, $messages, $status, $statusText, $textarea, $sendBtn, $stopBtn, $imagePreview;
// Live DOM query for $messages — the cached $messages reference can become stale
// after SidebarTabs reparents the panel. Use this for any deferred operations
@@ -89,6 +99,7 @@ define(function (require, exports, module) {
'' + Strings.AI_CHAT_THINKING + '' +
'' +
'