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 + '' + '' + '
' + + '
' + '
' + '
' + '' + @@ -140,6 +151,16 @@ define(function (require, exports, module) { _nodeConnector.on("aiToolEdit", _onToolEdit); _nodeConnector.on("aiError", _onError); _nodeConnector.on("aiComplete", _onComplete); + _nodeConnector.on("aiQuestion", _onQuestion); + _nodeConnector.on("aiClarificationRead", function (_event, data) { + // Claude consumed the queued clarification — show it as a user message and remove the bubble + const images = _queuedMessage ? _queuedMessage.images : []; + _queuedMessage = null; + _removeQueueBubble(); + if (data.text || images.length > 0) { + _appendUserMessage(data.text, images); + } + }); // Check availability and render appropriate UI _checkAvailability(); @@ -181,9 +202,16 @@ define(function (require, exports, module) { $textarea = $panel.find(".ai-chat-textarea"); $sendBtn = $panel.find(".ai-send-btn"); $stopBtn = $panel.find(".ai-stop-btn"); + $imagePreview = $panel.find(".ai-chat-image-preview"); // Event handlers - $sendBtn.on("click", _sendMessage); + $sendBtn.on("click", function () { + if (_isStreaming) { + _queueMessage(); + } else { + _sendMessage(); + } + }); $stopBtn.on("click", _cancelQuery); $panel.find(".ai-new-session-btn").on("click", _newSession); @@ -193,7 +221,11 @@ define(function (require, exports, module) { $textarea.on("keydown", function (e) { if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); - _sendMessage(); + if (_isStreaming) { + _queueMessage(); + } else { + _sendMessage(); + } } if (e.key === "Escape") { if (_isStreaming) { @@ -210,6 +242,43 @@ define(function (require, exports, module) { this.style.height = Math.min(this.scrollHeight, 96) + "px"; // max ~6rem }); + // Paste handler for images + $textarea.on("paste", function (e) { + const items = (e.originalEvent || e).clipboardData && (e.originalEvent || e).clipboardData.items; + if (!items) { + return; + } + let imageFound = false; + for (let i = 0; i < items.length; i++) { + const item = items[i]; + if (item.kind === "file" && ALLOWED_IMAGE_TYPES.indexOf(item.type) !== -1) { + if (_attachedImages.length >= MAX_IMAGES) { + break; + } + imageFound = true; + const blob = item.getAsFile(); + const reader = new FileReader(); + reader.onload = function (ev) { + const dataUrl = ev.target.result; + const commaIdx = dataUrl.indexOf(","); + const base64Data = dataUrl.slice(commaIdx + 1); + if (base64Data.length > MAX_IMAGE_BASE64_SIZE) { + // Resize oversized images via canvas + _resizeImage(dataUrl, function (resized) { + _addImageIfUnique(resized.dataUrl, resized.mediaType, resized.base64Data); + }); + } else { + _addImageIfUnique(dataUrl, item.type, base64Data); + } + }; + reader.readAsDataURL(blob); + } + } + if (imageFound) { + e.preventDefault(); + } + }); + // Track scroll position for auto-scroll $messages.on("scroll", function () { const el = $messages[0]; @@ -280,8 +349,7 @@ define(function (require, exports, module) { const $img = $(''); $img.on("click", function (e) { e.stopPropagation(); - $img.toggleClass("expanded"); - _scrollToBottom(); + _showImageLightbox($img.attr("src")); }); $img.on("load", function () { // Force scroll — the image load changes height after insertion, @@ -461,6 +529,217 @@ define(function (require, exports, module) { $contextBar.toggleClass("has-chips", $contextBar.children().length > 0); } + /** + * Add an image to _attachedImages if it's not a duplicate. + */ + function _addImageIfUnique(dataUrl, mediaType, base64Data) { + const isDuplicate = _attachedImages.some(function (existing) { + return existing.base64Data === base64Data; + }); + if (!isDuplicate && _attachedImages.length < MAX_IMAGES) { + _attachedImages.push({dataUrl: dataUrl, mediaType: mediaType, base64Data: base64Data}); + _renderImagePreview(); + } + } + + /** + * Resize an image so its base64 data stays under MAX_IMAGE_BASE64_SIZE. + * Two-phase strategy using WebP for better quality-per-byte: + * Phase 1 — reduce quality at original dimensions. + * Phase 2 — scale dimensions down (75%, then 50%) and retry quality steps. + */ + function _resizeImage(dataUrl, callback) { + const img = new Image(); + img.onload = function () { + const canvas = document.createElement("canvas"); + const ctx = canvas.getContext("2d"); + const qualitySteps = [0.92, 0.85, 0.75, 0.6, 0.45]; + const scaleSteps = [1, 0.75, 0.5]; + let result; + + for (let s = 0; s < scaleSteps.length; s++) { + const scale = scaleSteps[s]; + canvas.width = Math.round(img.width * scale); + canvas.height = Math.round(img.height * scale); + ctx.drawImage(img, 0, 0, canvas.width, canvas.height); + + for (let q = 0; q < qualitySteps.length; q++) { + result = canvas.toDataURL("image/webp", qualitySteps[q]); + if (result.split(",")[1].length <= MAX_IMAGE_BASE64_SIZE) { + const base64 = result.split(",")[1]; + callback({dataUrl: result, mediaType: "image/webp", base64Data: base64}); + return; + } + } + } + + // Last resort: use the smallest result we got + const base64 = result.split(",")[1]; + callback({dataUrl: result, mediaType: "image/webp", base64Data: base64}); + }; + img.src = dataUrl; + } + + /** + * Show a lightbox overlay with the full-size image. + */ + function _showImageLightbox(src) { + const $overlay = $( + '
' + + '' + + '
' + ); + $overlay.find("img").attr("src", src); + $overlay.on("click", function () { + $overlay.remove(); + }); + $panel.append($overlay); + } + + /** + * Render the image preview strip from _attachedImages. + */ + function _renderImagePreview() { + if (!$imagePreview) { + return; + } + $imagePreview.empty(); + if (_attachedImages.length === 0) { + $imagePreview.removeClass("has-images"); + return; + } + $imagePreview.addClass("has-images"); + _attachedImages.forEach(function (img, idx) { + const $thumb = $( + '' + + '' + + '' + + '' + ); + $thumb.find("img").attr("src", img.dataUrl).on("click", function () { + _showImageLightbox(img.dataUrl); + }); + $thumb.find(".ai-image-remove").on("click", function () { + _attachedImages.splice(idx, 1); + _renderImagePreview(); + }); + $imagePreview.append($thumb); + }); + } + + /** + * Queue a clarification message while AI is streaming. + * Places a static bubble above the input area and captures any attached images. + */ + function _queueMessage() { + const text = $textarea.val().trim(); + if (!text && _attachedImages.length === 0) { + return; + } + + // Capture images for the clarification + const queuedImages = _attachedImages.slice(); + const imagesPayload = _attachedImages.map(function (img) { + return { mediaType: img.mediaType, base64Data: img.base64Data }; + }); + _attachedImages = []; + _renderImagePreview(); + + // Append to existing queued text or start new + if (_queuedMessage) { + if (text) { + _queuedMessage.text += "\n" + text; + } + _queuedMessage.images = _queuedMessage.images.concat(queuedImages); + _queuedMessage.imagesPayload = _queuedMessage.imagesPayload.concat(imagesPayload); + } else { + _queuedMessage = { + text: text, + images: queuedImages, + imagesPayload: imagesPayload + }; + } + + // Send to node side (text + images) + _nodeConnector.execPeer("queueClarification", { + text: text, + images: imagesPayload.length > 0 ? imagesPayload : undefined + }).catch(function (err) { + console.warn("[AI UI] Failed to queue clarification:", err.message); + }); + + // Update or create queue bubble in the input area + const $inputArea = $textarea.closest(".ai-chat-input-area"); + let $bubble = $inputArea.find(".ai-queued-msg"); + if ($bubble.length) { + $bubble.find(".ai-queued-text").text(_queuedMessage.text); + // Re-render image thumbs + const $thumbs = $bubble.find(".ai-queued-images"); + $thumbs.empty(); + _queuedMessage.images.forEach(function (img) { + $thumbs.append($('').attr("src", img.dataUrl)); + }); + if (_queuedMessage.images.length > 0) { + $thumbs.show(); + } + } else { + $bubble = $( + '
' + + '
' + + '' + Strings.AI_CHAT_QUEUED + '' + + '' + + '
' + + '
' + + '
' + + '
' + ); + $bubble.find(".ai-queued-text").text(_queuedMessage.text); + if (_queuedMessage.images.length > 0) { + const $thumbs = $bubble.find(".ai-queued-images"); + _queuedMessage.images.forEach(function (img) { + $thumbs.append($('').attr("src", img.dataUrl)); + }); + $thumbs.show(); + } + $bubble.find(".ai-queued-edit-btn").on("click", _editQueuedMessage); + $inputArea.prepend($bubble); + } + + // Clear textarea + $textarea.val(""); + $textarea.css("height", "auto"); + } + + /** + * Remove the queue bubble from the UI. + */ + function _removeQueueBubble() { + const $inputArea = $textarea ? $textarea.closest(".ai-chat-input-area") : null; + if ($inputArea) { + $inputArea.find(".ai-queued-msg").remove(); + } + } + + /** + * Edit the queued message: move text back to textarea, restore images, and clear the queue. + */ + function _editQueuedMessage() { + if (_queuedMessage) { + $textarea.val(_queuedMessage.text); + $textarea.css("height", "auto"); + $textarea[0].style.height = Math.min($textarea[0].scrollHeight, 96) + "px"; + $textarea[0].focus({ preventScroll: true }); + // Restore images + _attachedImages = _queuedMessage.images; + _renderImagePreview(); + } + _queuedMessage = null; + _removeQueueBubble(); + _nodeConnector.execPeer("clearClarification").catch(function () { + // ignore + }); + } + /** * Send the current input as a message to Claude. */ @@ -473,14 +752,23 @@ define(function (require, exports, module) { // Show "+ New" button once a conversation starts $panel.find(".ai-new-session-btn").show(); + // Capture attached images before clearing + const imagesForDisplay = _attachedImages.slice(); + const imagesPayload = _attachedImages.map(function (img) { + return {mediaType: img.mediaType, base64Data: img.base64Data}; + }); + _attachedImages = []; + _renderImagePreview(); + // Append user message - _appendUserMessage(text); + _appendUserMessage(text, imagesForDisplay); // Clear input $textarea.val(""); $textarea.css("height", "auto"); // Set streaming state + _sessionError = false; _setStreaming(true); // Reset segment tracking and show thinking indicator @@ -539,7 +827,8 @@ define(function (require, exports, module) { projectPath: projectPath, sessionAction: "continue", locale: brackets.getLocale(), - selectionContext: selectionContext + selectionContext: selectionContext, + images: imagesPayload.length > 0 ? imagesPayload : undefined }).then(function (result) { _currentRequestId = result.requestId; console.log("[AI UI] RequestId:", result.requestId); @@ -558,6 +847,16 @@ define(function (require, exports, module) { // ignore cancel errors }); } + // Move queued text and images back to textarea on cancel + if (_queuedMessage) { + $textarea.val(_queuedMessage.text); + $textarea.css("height", "auto"); + $textarea[0].style.height = Math.min($textarea[0].scrollHeight, 96) + "px"; + _attachedImages = _queuedMessage.images; + _renderImagePreview(); + _queuedMessage = null; + _removeQueueBubble(); + } } /** @@ -573,6 +872,9 @@ define(function (require, exports, module) { _segmentText = ""; _hasReceivedContent = false; _isStreaming = false; + _sessionError = false; + _queuedMessage = null; + _removeQueueBubble(); _firstEditInResponse = true; _undoApplied = false; _selectionDismissed = false; @@ -582,6 +884,8 @@ define(function (require, exports, module) { _cursorDismissed = false; _cursorDismissedLine = null; _livePreviewDismissed = false; + _attachedImages = []; + _renderImagePreview(); SnapshotStore.reset(); PhoenixConnectors.clearPreviousContentMap(); if ($messages) { @@ -641,7 +945,10 @@ define(function (require, exports, module) { "mcp__phoenix-editor__controlEditor": { icon: "fa-solid fa-code", color: "#6bc76b", label: Strings.AI_CHAT_TOOL_CONTROL_EDITOR }, "mcp__phoenix-editor__resizeLivePreview": { icon: "fa-solid fa-arrows-left-right", color: "#66bb6a", label: Strings.AI_CHAT_TOOL_RESIZE_PREVIEW }, "mcp__phoenix-editor__wait": { icon: "fa-solid fa-hourglass-half", color: "#adb9bd", label: Strings.AI_CHAT_TOOL_WAIT }, - TodoWrite: { icon: "fa-solid fa-list-check", color: "#66bb6a", label: Strings.AI_CHAT_TOOL_TASKS } + "mcp__phoenix-editor__getUserClarification": { icon: "fa-solid fa-comment-dots", color: "#6bc76b", label: Strings.AI_CHAT_TOOL_CLARIFICATION }, + TodoWrite: { icon: "fa-solid fa-list-check", color: "#66bb6a", label: Strings.AI_CHAT_TOOL_TASKS }, + AskUserQuestion: { icon: "fa-solid fa-circle-question", color: "#66bb6a", label: Strings.AI_CHAT_TOOL_QUESTION }, + Task: { icon: "fa-solid fa-diagram-project", color: "#6b9eff", label: Strings.AI_CHAT_TOOL_TASK } }; function _onProgress(_event, data) { @@ -956,6 +1263,7 @@ define(function (require, exports, module) { function _onError(_event, data) { console.log("[AI UI]", "Error:", (data.error || "").slice(0, 200)); + _sessionError = true; _appendErrorMessage(data.error); // Don't stop streaming — the node side may continue (partial results) } @@ -974,6 +1282,57 @@ define(function (require, exports, module) { SnapshotStore.stopTracking(); _setStreaming(false); + + // Fatal error (e.g. process exit code 1) — disable inputs, show "New Chat" + if (_sessionError && !data.sessionId) { + $textarea.prop("disabled", true); + $textarea.closest(".ai-chat-input-wrap").addClass("disabled"); + $sendBtn.prop("disabled", true); + // Move queued text back to textarea for user to reuse after new session + if (_queuedMessage) { + $textarea.val(_queuedMessage.text); + _attachedImages = _queuedMessage.images; + _renderImagePreview(); + _queuedMessage = null; + _removeQueueBubble(); + } + // Append inline "New Chat" button below the error + const $newChat = $( + '
' + + '' + + '
' + ); + $newChat.find(".ai-error-new-chat-btn").on("click", function () { + // Preserve textarea content and images across the new session + const savedText = $textarea.val(); + const savedImages = _attachedImages.slice(); + $newChat.remove(); + _newSession(); + $textarea.prop("disabled", false); + $textarea.closest(".ai-chat-input-wrap").removeClass("disabled"); + $sendBtn.prop("disabled", false); + $textarea.val(savedText); + _attachedImages = savedImages; + _renderImagePreview(); + $textarea[0].focus({ preventScroll: true }); + }); + $messages.append($newChat); + _scrollToBottom(); + return; + } + + // If user had a queued message, auto-send it as the next turn + if (_queuedMessage) { + const pending = _queuedMessage; + _queuedMessage = null; + _removeQueueBubble(); + $textarea.val(pending.text); + _attachedImages = pending.images; + _renderImagePreview(); + _sendMessage(); + } } /** @@ -1134,9 +1493,151 @@ define(function (require, exports, module) { }); } + /** + * Handle an AskUserQuestion tool call — render interactive question UI. + */ + function _onQuestion(_event, data) { + const questions = data.questions || []; + if (!questions.length) { + return; + } + + // Remove thinking indicator on first content + if (!_hasReceivedContent) { + _hasReceivedContent = true; + $messages.find(".ai-thinking").remove(); + } + + // Finalize current text segment so question appears after it + $messages.find(".ai-stream-target").removeClass("ai-stream-target"); + _segmentText = ""; + + const answers = {}; + const totalQuestions = questions.length; + let answeredCount = 0; + + const $container = $('
'); + + questions.forEach(function (q) { + const $qBlock = $('
'); + const $qText = $('
'); + $qText.text(q.question); + $qBlock.append($qText); + + const $options = $('
'); + + q.options.forEach(function (opt) { + const $opt = $(''); + const $label = $(''); + $label.text(opt.label); + $opt.append($label); + if (opt.description) { + const $desc = $(''); + $desc.text(opt.description); + $opt.append($desc); + } + + $opt.on("click", function () { + if ($qBlock.data("answered")) { + return; + } + if (q.multiSelect) { + $opt.toggleClass("selected"); + } else { + // Single select — answer immediately + $qBlock.data("answered", true); + $options.find(".ai-question-option").prop("disabled", true); + $opt.addClass("selected"); + $qBlock.find(".ai-question-other").hide(); + answers[q.question] = opt.label; + answeredCount++; + if (answeredCount >= totalQuestions) { + _sendQuestionAnswers(answers); + } + } + }); + $options.append($opt); + }); + + // Multi-select submit button + if (q.multiSelect) { + const $submit = $(''); + $submit.on("click", function () { + if ($qBlock.data("answered")) { + return; + } + const selected = []; + $options.find(".ai-question-option.selected").each(function () { + selected.push($(this).find(".ai-question-option-label").text()); + }); + if (!selected.length) { + return; + } + $qBlock.data("answered", true); + $options.find(".ai-question-option").prop("disabled", true); + $submit.prop("disabled", true); + $qBlock.find(".ai-question-other").hide(); + answers[q.question] = selected.join(", "); + answeredCount++; + if (answeredCount >= totalQuestions) { + _sendQuestionAnswers(answers); + } + }); + $options.append($submit); + } + + $qBlock.append($options); + + // "Other" free-text input + const $other = $('
'); + const $input = $(''); + const $sendOther = $(''); + function submitOther() { + const val = $input.val().trim(); + if (!val || $qBlock.data("answered")) { + return; + } + $qBlock.data("answered", true); + $options.find(".ai-question-option").prop("disabled", true); + $input.prop("disabled", true); + $sendOther.prop("disabled", true); + answers[q.question] = val; + answeredCount++; + if (answeredCount >= totalQuestions) { + _sendQuestionAnswers(answers); + } + } + $sendOther.on("click", submitOther); + $input.on("keydown", function (e) { + if (e.key === "Enter") { + submitOther(); + } + }); + $other.append($input).append($sendOther); + $qBlock.append($other); + + $container.append($qBlock); + }); + + $messages.append($container); + _scrollToBottom(); + } + + /** + * Send collected question answers to the node side. + */ + function _sendQuestionAnswers(answers) { + _nodeConnector.execPeer("answerQuestion", { answers: answers }).catch(function (err) { + console.warn("[AI UI] Failed to send question answer:", err.message); + }); + } + // --- DOM helpers --- - function _appendUserMessage(text) { + function _appendUserMessage(text, images) { const $msg = $( '
' + '
' + Strings.AI_CHAT_LABEL_YOU + '
' + @@ -1144,6 +1645,18 @@ define(function (require, exports, module) { '
' ); $msg.find(".ai-msg-content").text(text); + if (images && images.length > 0) { + const $imgDiv = $('
'); + images.forEach(function (img) { + const $thumb = $(''); + $thumb.attr("src", img.dataUrl); + $thumb.on("click", function () { + _showImageLightbox(img.dataUrl); + }); + $imgDiv.append($thumb); + }); + $msg.find(".ai-msg-content").append($imgDiv); + } $messages.append($msg); _scrollToBottom(); } @@ -1398,6 +1911,17 @@ define(function (require, exports, module) { $tool.find(".ai-tool-header").on("click", function () { $tool.toggleClass("ai-tool-expanded"); }).css("cursor", "pointer"); + } else if (toolName === "Task" && toolInput) { + const $detail = $('
'); + const desc = toolInput.description || toolInput.prompt || ""; + if (desc) { + $detail.append($('
').text(desc.slice(0, 200))); + } + $tool.append($detail); + $tool.addClass("ai-tool-expanded"); + $tool.find(".ai-tool-header").on("click", function () { + $tool.toggleClass("ai-tool-expanded"); + }).css("cursor", "pointer"); } else if (detail.lines && detail.lines.length) { // Add expandable detail if available const $detail = $('
'); @@ -1523,6 +2047,29 @@ define(function (require, exports, module) { summary: StringUtils.format(Strings.AI_CHAT_TOOL_WAITING, input.seconds || "?"), lines: [] }; + case "mcp__phoenix-editor__getUserClarification": + return { + summary: Strings.AI_CHAT_TOOL_CLARIFICATION, + lines: [] + }; + case "AskUserQuestion": { + const qs = input.questions || []; + return { + summary: Strings.AI_CHAT_TOOL_QUESTION, + lines: qs.map(function (q) { return q.question; }) + }; + } + case "Task": { + const desc = input.description || input.prompt || ""; + const agentType = input.subagent_type || ""; + const summary = agentType + ? StringUtils.format(Strings.AI_CHAT_TOOL_TASK_NAME, agentType) + : Strings.AI_CHAT_TOOL_TASK; + return { + summary: summary, + lines: desc ? [desc.split("\n")[0].slice(0, 120)] : [] + }; + } case "TodoWrite": { const todos = input.todos || []; const completed = todos.filter(function (t) { return t.status === "completed"; }).length; @@ -1653,8 +2200,7 @@ define(function (require, exports, module) { $status.toggleClass("active", streaming); } if ($textarea) { - $textarea.prop("disabled", streaming); - $textarea.closest(".ai-chat-input-wrap").toggleClass("disabled", streaming); + // Keep textarea enabled during streaming so users can type a queued clarification if (!streaming) { $textarea[0].focus({ preventScroll: true }); } diff --git a/src/core-ai/aiPhoenixConnectors.js b/src/core-ai/aiPhoenixConnectors.js index ee51f73c85..d29430f190 100644 --- a/src/core-ai/aiPhoenixConnectors.js +++ b/src/core-ai/aiPhoenixConnectors.js @@ -322,13 +322,9 @@ define(function (require, exports, module) { * @param {Object} params - { selector?: string } * @return {{ base64: string|null, error?: string }} */ - function takeScreenshot(params) { + function _captureScreenshot(selector) { const deferred = new $.Deferred(); - if (!Phoenix || !Phoenix.app || !Phoenix.app.screenShotBinary) { - deferred.resolve({ base64: null, error: "Screenshot API not available" }); - return deferred.promise(); - } - Phoenix.app.screenShotBinary(params.selector || undefined) + Phoenix.app.screenShotBinary(selector || undefined) .then(function (bytes) { let binary = ""; const chunkSize = 8192; @@ -347,6 +343,32 @@ define(function (require, exports, module) { return deferred.promise(); } + function takeScreenshot(params) { + const deferred = new $.Deferred(); + if (!Phoenix || !Phoenix.app || !Phoenix.app.screenShotBinary) { + deferred.resolve({ base64: null, error: "Screenshot API not available" }); + return deferred.promise(); + } + + if (params.purePreview) { + const previousMode = LiveDevMain.getCurrentMode(); + LiveDevMain.setMode(LivePreviewConstants.LIVE_PREVIEW_MODE); + // Allow time for the mode change to propagate to the live preview iframe + setTimeout(function () { + _captureScreenshot(params.selector) + .done(function (result) { + LiveDevMain.setMode(previousMode); + deferred.resolve(result); + }); + }, 150); + } else { + _captureScreenshot(params.selector) + .done(function (result) { deferred.resolve(result); }); + } + + return deferred.promise(); + } + // --- File content --- /** @@ -596,26 +618,34 @@ define(function (require, exports, module) { function controlEditor(params) { const deferred = new $.Deferred(); const vfsPath = SnapshotStore.realToVfsPath(params.filePath); + console.log("controlEditor:", params.operation, params.filePath, "-> vfs:", vfsPath); + + function _resolve(result) { + if (!result.success) { + console.error("controlEditor failed:", params.operation, vfsPath, result.error); + } + deferred.resolve(result); + } switch (params.operation) { case "open": CommandManager.execute(Commands.CMD_OPEN, { fullPath: vfsPath }) - .done(function () { deferred.resolve({ success: true }); }) - .fail(function (err) { deferred.resolve({ success: false, error: String(err) }); }); + .done(function () { _resolve({ success: true }); }) + .fail(function (err) { _resolve({ success: false, error: String(err) }); }); break; case "close": { const file = FileSystem.getFileForPath(vfsPath); CommandManager.execute(Commands.FILE_CLOSE, { file: file, _forceClose: true }) - .done(function () { deferred.resolve({ success: true }); }) - .fail(function (err) { deferred.resolve({ success: false, error: String(err) }); }); + .done(function () { _resolve({ success: true }); }) + .fail(function (err) { _resolve({ success: false, error: String(err) }); }); break; } case "openInWorkingSet": CommandManager.execute(Commands.CMD_ADD_TO_WORKINGSET_AND_OPEN, { fullPath: vfsPath }) - .done(function () { deferred.resolve({ success: true }); }) - .fail(function (err) { deferred.resolve({ success: false, error: String(err) }); }); + .done(function () { _resolve({ success: true }); }) + .fail(function (err) { _resolve({ success: false, error: String(err) }); }); break; case "setSelection": @@ -628,12 +658,12 @@ define(function (require, exports, module) { { line: params.endLine - 1, ch: params.endCh - 1 }, true ); - deferred.resolve({ success: true }); + _resolve({ success: true }); } else { - deferred.resolve({ success: false, error: "No active editor after opening file" }); + _resolve({ success: false, error: "No active editor after opening file" }); } }) - .fail(function (err) { deferred.resolve({ success: false, error: String(err) }); }); + .fail(function (err) { _resolve({ success: false, error: String(err) }); }); break; case "setCursorPos": @@ -642,16 +672,16 @@ define(function (require, exports, module) { const editor = EditorManager.getActiveEditor(); if (editor) { editor.setCursorPos(params.line - 1, params.ch - 1, true); - deferred.resolve({ success: true }); + _resolve({ success: true }); } else { - deferred.resolve({ success: false, error: "No active editor after opening file" }); + _resolve({ success: false, error: "No active editor after opening file" }); } }) - .fail(function (err) { deferred.resolve({ success: false, error: String(err) }); }); + .fail(function (err) { _resolve({ success: false, error: String(err) }); }); break; default: - deferred.resolve({ success: false, error: "Unknown operation: " + params.operation }); + _resolve({ success: false, error: "Unknown operation: " + params.operation }); } return deferred.promise(); diff --git a/src/nls/root/strings.js b/src/nls/root/strings.js index ab60f88f0d..b77f1bc3ed 100644 --- a/src/nls/root/strings.js +++ b/src/nls/root/strings.js @@ -1878,6 +1878,15 @@ define({ "AI_CHAT_TOOL_WAITED": "Done waiting {0}s", "AI_CHAT_COPY_CODE": "Copy", "AI_CHAT_COPIED_CODE": "Copied!", + "AI_CHAT_TOOL_QUESTION": "Question", + "AI_CHAT_TOOL_TASK": "Subagent", + "AI_CHAT_TOOL_TASK_NAME": "Subagent: {0}", + "AI_CHAT_QUESTION_OTHER": "Type a custom answer\u2026", + "AI_CHAT_IMAGE_LIMIT": "Maximum {0} images allowed", + "AI_CHAT_IMAGE_REMOVE": "Remove image", + "AI_CHAT_QUEUED": "Queued", + "AI_CHAT_QUEUED_EDIT": "Edit", + "AI_CHAT_TOOL_CLARIFICATION": "Reading your follow-up", // demo start - Phoenix Code Playground - Interactive Onboarding "DEMO_SECTION1_TITLE": "Edit in Live Preview", diff --git a/src/styles/Extn-AIChatPanel.less b/src/styles/Extn-AIChatPanel.less index c9ed5e3bb0..47f00d5535 100644 --- a/src/styles/Extn-AIChatPanel.less +++ b/src/styles/Extn-AIChatPanel.less @@ -122,6 +122,47 @@ } } +/* ── User message image thumbnails ──────────────────────────────────── */ +.ai-user-images { + display: flex; + flex-wrap: wrap; + gap: 4px; + margin-top: 6px; + justify-content: flex-end; + + .ai-user-image-thumb { + max-width: 80px; + max-height: 60px; + object-fit: cover; + border-radius: 4px; + border: 1px solid rgba(255, 255, 255, 0.1); + cursor: pointer; + } +} + +/* ── Image lightbox overlay ────────────────────────────────────────── */ +.ai-image-lightbox { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + z-index: 100; + background: rgba(0, 0, 0, 0.8); + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + + img { + max-width: 90%; + max-height: 90%; + object-fit: contain; + border-radius: 6px; + box-shadow: 0 4px 24px rgba(0, 0, 0, 0.5); + } +} + /* ── Assistant message — markdown content ───────────────────────────── */ .ai-msg-assistant { .ai-msg-content { @@ -320,11 +361,6 @@ object-fit: cover; object-position: top left; cursor: pointer; - transition: max-height 0.2s ease; - } - - &.ai-tool-expanded .ai-tool-screenshot.expanded { - max-height: none; } .ai-tool-detail-line { @@ -659,6 +695,241 @@ } } +/* ── AskUserQuestion interactive card ──────────────────────────────── */ +.ai-msg-question { + margin-bottom: 8px; +} + +.ai-question-block { + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 6px; + padding: 10px; + background-color: rgba(255, 255, 255, 0.025); + margin-bottom: 6px; + + &:last-child { + margin-bottom: 0; + } +} + +.ai-question-text { + font-size: @sidebar-content-font-size; + color: @project-panel-text-1; + line-height: 1.5; + margin-bottom: 8px; + white-space: normal; + word-wrap: break-word; +} + +.ai-question-options { + display: flex; + flex-direction: column; + gap: 6px; +} + +.ai-question-option { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 2px; + background: rgba(255, 255, 255, 0.04); + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 4px; + padding: 6px 10px; + cursor: pointer; + text-align: left; + transition: background-color 0.15s ease, border-color 0.15s ease; + color: @project-panel-text-2; + white-space: normal; + word-wrap: break-word; + overflow-wrap: anywhere; + min-width: 0; + + &:hover:not(:disabled) { + background: rgba(255, 255, 255, 0.08); + border-color: rgba(255, 255, 255, 0.18); + } + + &.selected { + background: rgba(76, 175, 80, 0.1); + border-color: rgba(76, 175, 80, 0.4); + color: @project-panel-text-1; + } + + &:disabled { + cursor: default; + opacity: 0.6; + } +} + +.ai-question-option-label { + font-size: @sidebar-content-font-size; + font-weight: 500; + white-space: normal; + word-wrap: break-word; +} + +.ai-question-option-desc { + font-size: @sidebar-small-font-size; + opacity: 0.65; + line-height: 1.4; + white-space: normal; + word-wrap: break-word; + overflow-wrap: anywhere; +} + +.ai-question-submit { + align-self: flex-end; + background: none; + border: 1px solid rgba(76, 175, 80, 0.3); + color: rgba(76, 175, 80, 0.85); + font-size: @sidebar-small-font-size; + padding: 3px 10px; + border-radius: 4px; + cursor: pointer; + margin-top: 4px; + transition: background-color 0.15s ease; + + &:hover:not(:disabled) { + background: rgba(76, 175, 80, 0.1); + } + + &:disabled { + opacity: 0.4; + cursor: default; + } +} + +.ai-question-other { + display: flex; + align-items: stretch; + gap: 4px; + margin-top: 8px; +} + +.ai-question-other-input { + flex: 1; + min-width: 0; + background: rgba(255, 255, 255, 0.04); + border: 1px solid rgba(255, 255, 255, 0.1) !important; + border-radius: 4px; + color: @project-panel-text-1; + font-size: @sidebar-content-font-size; + padding: 6px 10px !important; + margin: 0 !important; + line-height: 1.4; + height: auto !important; + box-sizing: content-box; + outline: none !important; + box-shadow: none !important; + + &:focus { + border-color: @bc-btn-border-focused !important; + } + + &:disabled { + opacity: 0.5; + cursor: not-allowed; + } +} + +.ai-question-other-submit { + background: rgba(255, 255, 255, 0.04); + border: 1px solid rgba(255, 255, 255, 0.1); + color: @project-panel-text-2; + font-size: @sidebar-content-font-size; + padding: 0 10px; + border-radius: 4px; + cursor: pointer; + flex-shrink: 0; + display: flex; + align-items: center; + justify-content: center; + transition: background-color 0.15s ease, color 0.15s ease; + + &:hover:not(:disabled) { + background: rgba(255, 255, 255, 0.08); + color: @project-panel-text-1; + } + + &:disabled { + opacity: 0.4; + cursor: default; + } +} + +/* ── Queued clarification bubble (static, above input) ─────────────── */ +.ai-queued-msg { + border: 1px dashed rgba(255, 255, 255, 0.15); + border-radius: 6px; + padding: 6px 8px; + margin: 0 0 4px 0; + background-color: rgba(255, 255, 255, 0.04); + + .ai-queued-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 2px; + } + + .ai-queued-label { + font-size: @sidebar-xs-font-size; + color: @project-panel-text-2; + font-weight: 600; + opacity: 0.6; + } + + .ai-queued-edit-btn { + background: none; + border: none; + font-size: @sidebar-xs-font-size; + color: @project-panel-text-2; + padding: 0 4px; + cursor: pointer; + opacity: 0.6; + transition: opacity 0.15s ease; + + &:hover { + opacity: 1; + } + } + + .ai-queued-images { + display: none; + flex-wrap: wrap; + gap: 4px; + margin-bottom: 4px; + + &:not(:empty) { + display: flex; + } + + img { + max-width: 48px; + max-height: 36px; + object-fit: cover; + border-radius: 3px; + border: 1px solid rgba(255, 255, 255, 0.1); + } + } + + .ai-queued-text { + font-size: @sidebar-content-font-size; + color: @project-panel-text-2; + line-height: 1.4; + white-space: pre-wrap; + word-wrap: break-word; + overflow-wrap: anywhere; + max-height: 60px; + overflow-y: auto; + + &:empty { + display: none; + } + } +} + /* ── Error message ──────────────────────────────────────────────────── */ .ai-msg-error { .ai-msg-content { @@ -671,6 +942,32 @@ } } +/* ── New Chat button after fatal error ─────────────────────────────── */ +.ai-msg-new-chat { + display: flex; + justify-content: center; + padding: 6px 0; + + .ai-error-new-chat-btn { + display: flex; + align-items: center; + gap: 4px; + background: none; + border: 1px solid rgba(255, 255, 255, 0.12); + color: @project-panel-text-2; + font-size: @sidebar-small-font-size; + padding: 4px 14px; + border-radius: 4px; + cursor: pointer; + transition: background-color 0.15s ease, color 0.15s ease; + + &:hover { + background-color: rgba(255, 255, 255, 0.06); + color: @project-panel-text-1; + } + } +} + /* ── Status bar ─────────────────────────────────────────────────────── */ .ai-chat-status { display: none; @@ -784,6 +1081,54 @@ .ai-context-chip-icon { color: #6b9eff; } } + /* ── Image preview strip (between context bar and textarea) ──────── */ + .ai-chat-image-preview { + display: none; + flex-wrap: wrap; + gap: 6px; + padding: 6px 8px; + + &.has-images { + display: flex; + } + + .ai-image-thumb { + position: relative; + + img { + display: block; + max-width: 60px; + max-height: 48px; + object-fit: cover; + border-radius: 4px; + border: 1px solid rgba(255, 255, 255, 0.1); + cursor: pointer; + } + + .ai-image-remove { + position: absolute; + top: -6px; + right: -6px; + width: 16px; + height: 16px; + border-radius: 50%; + background: rgba(0, 0, 0, 0.7); + border: 1px solid rgba(255, 255, 255, 0.2); + color: @project-panel-text-2; + font-size: 11px; + padding: 0; + display: none; + align-items: center; + justify-content: center; + cursor: pointer; + } + + &:hover .ai-image-remove { + display: flex; + } + } + } + .ai-chat-textarea { flex: 1; min-width: 0;