From 1812f648aae2928fea97f7bbb552a96f5150a25d Mon Sep 17 00:00:00 2001 From: abose Date: Mon, 23 Feb 2026 10:56:30 +0530 Subject: [PATCH 1/6] feat: handle AskUserQuestion tool with interactive UI in AI chat Add support for the Claude SDK's AskUserQuestion tool so the AI can ask clarifying questions with clickable option buttons instead of showing raw tool output. Includes single-select, multi-select, and free-text "other" input. Answers are sent back to the SDK via a PreToolUse hook. --- src-node/claude-code-agent.js | 60 ++++++++++++ src/core-ai/AIChatPanel.js | 153 ++++++++++++++++++++++++++++- src/nls/root/strings.js | 2 + src/styles/Extn-AIChatPanel.less | 163 +++++++++++++++++++++++++++++++ 4 files changed, 377 insertions(+), 1 deletion(-) diff --git a/src-node/claude-code-agent.js b/src-node/claude-code-agent.js index b281878b3f..c801b0fe70 100644 --- a/src-node/claude-code-agent.js +++ b/src-node/claude-code-agent.js @@ -47,6 +47,9 @@ let editorMcpServer = null; // Streaming throttle const TEXT_STREAM_THROTTLE_MS = 50; +// Pending question resolver — used by AskUserQuestion hook +let _questionResolve = null; + const nodeConnector = global.createNodeConnector(CONNECTOR_ID, exports); /** @@ -186,11 +189,25 @@ 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; 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). */ @@ -233,6 +250,7 @@ async function _runQuery(requestId, prompt, projectPath, model, signal, locale) maxTurns: undefined, allowedTools: [ "Read", "Edit", "Write", "Glob", "Grep", + "AskUserQuestion", "mcp__phoenix-editor__getEditorState", "mcp__phoenix-editor__takeScreenshot", "mcp__phoenix-editor__execJsInLivePreview", @@ -385,6 +403,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" + } + }; + } + ] } ] } diff --git a/src/core-ai/AIChatPanel.js b/src/core-ai/AIChatPanel.js index a786b82cd8..fccbc5d255 100644 --- a/src/core-ai/AIChatPanel.js +++ b/src/core-ai/AIChatPanel.js @@ -140,6 +140,7 @@ define(function (require, exports, module) { _nodeConnector.on("aiToolEdit", _onToolEdit); _nodeConnector.on("aiError", _onError); _nodeConnector.on("aiComplete", _onComplete); + _nodeConnector.on("aiQuestion", _onQuestion); // Check availability and render appropriate UI _checkAvailability(); @@ -641,7 +642,8 @@ 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 } + 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 } }; function _onProgress(_event, data) { @@ -1134,6 +1136,148 @@ 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) { @@ -1523,6 +1667,13 @@ define(function (require, exports, module) { summary: StringUtils.format(Strings.AI_CHAT_TOOL_WAITING, input.seconds || "?"), lines: [] }; + case "AskUserQuestion": { + const qs = input.questions || []; + return { + summary: Strings.AI_CHAT_TOOL_QUESTION, + lines: qs.map(function (q) { return q.question; }) + }; + } case "TodoWrite": { const todos = input.todos || []; const completed = todos.filter(function (t) { return t.status === "completed"; }).length; diff --git a/src/nls/root/strings.js b/src/nls/root/strings.js index ab60f88f0d..acf65f7a1f 100644 --- a/src/nls/root/strings.js +++ b/src/nls/root/strings.js @@ -1878,6 +1878,8 @@ 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_QUESTION_OTHER": "Type a custom answer\u2026", // 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..7b422136c5 100644 --- a/src/styles/Extn-AIChatPanel.less +++ b/src/styles/Extn-AIChatPanel.less @@ -659,6 +659,169 @@ } } +/* ── 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; + } +} + /* ── Error message ──────────────────────────────────────────────────── */ .ai-msg-error { .ai-msg-content { From 2d03ba856a3d0b8e63b0952bc3b60a9b450fce21 Mon Sep 17 00:00:00 2001 From: abose Date: Mon, 23 Feb 2026 11:31:53 +0530 Subject: [PATCH 2/6] feat: add Task tool and subagent support to AI chat Enable the Task tool so the AI can spawn researcher and coder subagents for focused subtasks. Render subagent invocations in the chat UI with a dedicated icon, label, and expandable description. --- src-node/claude-code-agent.js | 25 ++++++++++++++++++++++++- src/core-ai/AIChatPanel.js | 25 ++++++++++++++++++++++++- src/nls/root/strings.js | 2 ++ 3 files changed, 50 insertions(+), 2 deletions(-) diff --git a/src-node/claude-code-agent.js b/src-node/claude-code-agent.js index c801b0fe70..493c0a08a3 100644 --- a/src-node/claude-code-agent.js +++ b/src-node/claude-code-agent.js @@ -250,7 +250,7 @@ async function _runQuery(requestId, prompt, projectPath, model, signal, locale) maxTurns: undefined, allowedTools: [ "Read", "Edit", "Write", "Glob", "Grep", - "AskUserQuestion", + "AskUserQuestion", "Task", "mcp__phoenix-editor__getEditorState", "mcp__phoenix-editor__takeScreenshot", "mcp__phoenix-editor__execJsInLivePreview", @@ -258,6 +258,29 @@ async function _runQuery(requestId, prompt, projectPath, model, signal, locale) "mcp__phoenix-editor__resizeLivePreview", "mcp__phoenix-editor__wait" ], + 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: diff --git a/src/core-ai/AIChatPanel.js b/src/core-ai/AIChatPanel.js index fccbc5d255..8b2479915f 100644 --- a/src/core-ai/AIChatPanel.js +++ b/src/core-ai/AIChatPanel.js @@ -643,7 +643,8 @@ define(function (require, exports, module) { "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 }, - AskUserQuestion: { icon: "fa-solid fa-circle-question", color: "#66bb6a", label: Strings.AI_CHAT_TOOL_QUESTION } + 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) { @@ -1542,6 +1543,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 = $('
'); @@ -1674,6 +1686,17 @@ define(function (require, exports, module) { 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; diff --git a/src/nls/root/strings.js b/src/nls/root/strings.js index acf65f7a1f..01271904fc 100644 --- a/src/nls/root/strings.js +++ b/src/nls/root/strings.js @@ -1879,6 +1879,8 @@ define({ "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", // demo start - Phoenix Code Playground - Interactive Onboarding From 849c217c528b645a7a69f39707631bd0737b9012 Mon Sep 17 00:00:00 2001 From: abose Date: Mon, 23 Feb 2026 14:19:10 +0530 Subject: [PATCH 3/6] feat: improve AI image compression, add missing tools, and enhance MCP tooling - Replace JPEG image compression with two-phase WebP strategy that preserves quality better (quality reduction first, then dimension scaling as last resort), bump limit to 200KB - Add Bash, TodoRead, TodoWrite, WebFetch, WebSearch to allowedTools to prevent Claude Code process exit code 1 from permission blocking - Add purePreview param to takeScreenshot MCP tool to capture live preview without element highlight overlays/toolboxes - Add logging to controlEditor for debugging file open failures --- src-node/claude-code-agent.js | 38 +++++- src-node/mcp-editor-tools.js | 17 ++- src/core-ai/AIChatPanel.js | 179 ++++++++++++++++++++++++++++- src/core-ai/aiPhoenixConnectors.js | 68 ++++++++--- src/nls/root/strings.js | 2 + src/styles/Extn-AIChatPanel.less | 94 ++++++++++++++- 6 files changed, 359 insertions(+), 39 deletions(-) diff --git a/src-node/claude-code-agent.js b/src-node/claude-code-agent.js index 493c0a08a3..b468470480 100644 --- a/src-node/claude-code-agent.js +++ b/src-node/claude-code-agent.js @@ -132,7 +132,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 @@ -172,7 +172,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); }); @@ -220,7 +220,7 @@ exports.destroySession = async function () { /** * 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; @@ -249,8 +249,10 @@ 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", @@ -483,6 +485,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; @@ -493,8 +496,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 }); @@ -753,6 +776,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 @@ -761,7 +787,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..40b5878ca0 100644 --- a/src-node/mcp-editor-tools.js +++ b/src-node/mcp-editor-tools.js @@ -68,12 +68,18 @@ 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) { try { const result = await nodeConnector.execPeer("takeScreenshot", { - selector: args.selector || undefined + selector: args.selector || undefined, + purePreview: args.purePreview || false }); if (result.base64) { return { @@ -152,15 +158,20 @@ 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 { diff --git a/src/core-ai/AIChatPanel.js b/src/core-ai/AIChatPanel.js index 8b2479915f..6dfaaebbef 100644 --- a/src/core-ai/AIChatPanel.js +++ b/src/core-ai/AIChatPanel.js @@ -65,8 +65,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 +97,7 @@ define(function (require, exports, module) { '' + Strings.AI_CHAT_THINKING + '' + '' + '
' + + '
' + '
' + '
' + '' + @@ -182,6 +191,7 @@ 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); @@ -211,6 +221,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]; @@ -281,8 +328,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, @@ -462,6 +508,104 @@ 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); + }); + } + /** * Send the current input as a message to Claude. */ @@ -474,8 +618,16 @@ 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(""); @@ -540,7 +692,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); @@ -583,6 +736,8 @@ define(function (require, exports, module) { _cursorDismissed = false; _cursorDismissedLine = null; _livePreviewDismissed = false; + _attachedImages = []; + _renderImagePreview(); SnapshotStore.reset(); PhoenixConnectors.clearPreviousContentMap(); if ($messages) { @@ -1281,7 +1436,7 @@ define(function (require, exports, module) { // --- DOM helpers --- - function _appendUserMessage(text) { + function _appendUserMessage(text, images) { const $msg = $( '
' + '
' + Strings.AI_CHAT_LABEL_YOU + '
' + @@ -1289,6 +1444,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(); } 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 01271904fc..242cd1b2fa 100644 --- a/src/nls/root/strings.js +++ b/src/nls/root/strings.js @@ -1882,6 +1882,8 @@ define({ "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", // 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 7b422136c5..fb810d529a 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 { @@ -947,6 +983,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; From b446e38b178d5e687fd64ad4bcbbd3ad059bf49e Mon Sep 17 00:00:00 2001 From: abose Date: Mon, 23 Feb 2026 15:47:51 +0530 Subject: [PATCH 4/6] feat(ai): allow typing and message queuing during AI streaming Keep the textarea enabled while AI is responding so users can type a follow-up clarification mid-turn. Queued messages are injected into Claude's context via tool response hints and a new getUserClarification MCP tool, letting Claude incorporate feedback naturally without waiting for the turn to end. - Add queueClarification/getAndClearClarification/clearClarification node endpoints with image support - Add getUserClarification MCP tool and clarification hints on all tool responses (both hook-based and MCP) - Static queue bubble above input area with Edit button - Auto-send queued message as next turn if not consumed mid-turn - Show consumed clarification as user message bubble in chat --- docs/API-Reference/command/Commands.md | 2 +- docs/API-Reference/document/Document.md | 2 +- docs/API-Reference/view/PanelView.md | 99 ++++++++++-- docs/API-Reference/view/WorkspaceManager.md | 8 +- src-node/claude-code-agent.js | 68 +++++++- src-node/mcp-editor-tools.js | 113 ++++++++++--- src/core-ai/AIChatPanel.js | 169 +++++++++++++++++++- src/nls/root/strings.js | 3 + src/styles/Extn-AIChatPanel.less | 72 +++++++++ 9 files changed, 489 insertions(+), 47 deletions(-) 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 b468470480..793bb17699 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; @@ -50,6 +54,10 @@ 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); /** @@ -191,6 +199,7 @@ exports.cancelQuery = async function () { currentSessionId = null; // Clear any pending question _questionResolve = null; + _queuedClarification = null; return { success: true }; } return { success: false }; @@ -214,6 +223,46 @@ exports.answerQuestion = async function (params) { 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 }; }; @@ -228,7 +277,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", { @@ -258,7 +310,8 @@ async function _runQuery(requestId, prompt, projectPath, model, signal, locale, "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": { @@ -292,6 +345,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." @@ -334,6 +389,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", @@ -370,6 +428,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", @@ -419,6 +480,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", diff --git a/src-node/mcp-editor-tools.js b/src-node/mcp-editor-tools.js index 40b5878ca0..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); } ); @@ -76,26 +98,29 @@ function createEditorMcpServer(sdkModule, nodeConnector) { 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, 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); } ); @@ -109,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); } ); @@ -174,10 +202,11 @@ function createEditorMcpServer(sdkModule, nodeConnector) { 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); } ); @@ -189,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); } ); @@ -222,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 6dfaaebbef..d7197cda2e 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; @@ -150,6 +151,15 @@ define(function (require, exports, module) { _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(); @@ -194,7 +204,13 @@ define(function (require, exports, module) { $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); @@ -204,7 +220,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) { @@ -606,6 +626,119 @@ define(function (require, exports, module) { }); } + /** + * 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. */ @@ -712,6 +845,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(); + } } /** @@ -727,6 +870,8 @@ define(function (require, exports, module) { _segmentText = ""; _hasReceivedContent = false; _isStreaming = false; + _queuedMessage = null; + _removeQueueBubble(); _firstEditInResponse = true; _undoApplied = false; _selectionDismissed = false; @@ -797,6 +942,7 @@ 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 }, + "mcp__phoenix-editor__getUserClarification": { icon: "fa-solid fa-comment-dots", color: "#e8a838", 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 } @@ -1132,6 +1278,17 @@ define(function (require, exports, module) { SnapshotStore.stopTracking(); _setStreaming(false); + + // 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(); + } } /** @@ -1846,6 +2003,11 @@ 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 { @@ -1994,8 +2156,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/nls/root/strings.js b/src/nls/root/strings.js index 242cd1b2fa..b77f1bc3ed 100644 --- a/src/nls/root/strings.js +++ b/src/nls/root/strings.js @@ -1884,6 +1884,9 @@ define({ "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 fb810d529a..c8abbc7a2a 100644 --- a/src/styles/Extn-AIChatPanel.less +++ b/src/styles/Extn-AIChatPanel.less @@ -858,6 +858,78 @@ } } +/* ── 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 { From 978fba2aeb7178b918af9bf74e3fe58d697e19c4 Mon Sep 17 00:00:00 2001 From: abose Date: Mon, 23 Feb 2026 16:45:26 +0530 Subject: [PATCH 5/6] fix(ai): clear stale clarification on new turn and use green tool color Clear _queuedClarification at the start of sendPrompt to prevent text from a previous turn leaking into the next clarification read. Also change getUserClarification tool icon color to green (#6bc76b) to match Read file tool bubbles. --- src-node/claude-code-agent.js | 3 +++ src/core-ai/AIChatPanel.js | 2 +- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/src-node/claude-code-agent.js b/src-node/claude-code-agent.js index 793bb17699..2883d2eaa6 100644 --- a/src-node/claude-code-agent.js +++ b/src-node/claude-code-agent.js @@ -148,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(); diff --git a/src/core-ai/AIChatPanel.js b/src/core-ai/AIChatPanel.js index d7197cda2e..dab848debb 100644 --- a/src/core-ai/AIChatPanel.js +++ b/src/core-ai/AIChatPanel.js @@ -942,7 +942,7 @@ 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 }, - "mcp__phoenix-editor__getUserClarification": { icon: "fa-solid fa-comment-dots", color: "#e8a838", label: Strings.AI_CHAT_TOOL_CLARIFICATION }, + "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 } From 8797847f60f1f6c646116ab653566b2e2002ee42 Mon Sep 17 00:00:00 2001 From: abose Date: Mon, 23 Feb 2026 16:57:28 +0530 Subject: [PATCH 6/6] fix(ai): disable inputs and show New Chat button on fatal process error When the Claude process exits with code 1, disable the textarea and send button, move any queued message back to the textarea, and show an inline New Chat button. Clicking it starts a fresh session while preserving the textarea content so the user can re-send. --- src/core-ai/AIChatPanel.js | 44 ++++++++++++++++++++++++++++++++ src/styles/Extn-AIChatPanel.less | 26 +++++++++++++++++++ 2 files changed, 70 insertions(+) diff --git a/src/core-ai/AIChatPanel.js b/src/core-ai/AIChatPanel.js index dab848debb..6d939d12c0 100644 --- a/src/core-ai/AIChatPanel.js +++ b/src/core-ai/AIChatPanel.js @@ -49,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 @@ -767,6 +768,7 @@ define(function (require, exports, module) { $textarea.css("height", "auto"); // Set streaming state + _sessionError = false; _setStreaming(true); // Reset segment tracking and show thinking indicator @@ -870,6 +872,7 @@ define(function (require, exports, module) { _segmentText = ""; _hasReceivedContent = false; _isStreaming = false; + _sessionError = false; _queuedMessage = null; _removeQueueBubble(); _firstEditInResponse = true; @@ -1260,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) } @@ -1279,6 +1283,46 @@ 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; diff --git a/src/styles/Extn-AIChatPanel.less b/src/styles/Extn-AIChatPanel.less index c8abbc7a2a..47f00d5535 100644 --- a/src/styles/Extn-AIChatPanel.less +++ b/src/styles/Extn-AIChatPanel.less @@ -942,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;