From b8df9474009ef2fcf6b95962458845f61775730b Mon Sep 17 00:00:00 2001 From: abose Date: Sun, 26 Apr 2026 12:42:45 +0530 Subject: [PATCH 01/19] feat(ai-chat): add file-not-found dialog and hunk-diff styling - New FILE_NOT_FOUND title/message strings for graceful open errors. - Diff hunks rendered with line-number column, dim context rows, and a dashed separator between multi-occurrence hunks (replace_all). --- src/nls/root/strings.js | 2 ++ src/styles/Extn-AIChatPanel.less | 25 +++++++++++++++++++++++++ 2 files changed, 27 insertions(+) diff --git a/src/nls/root/strings.js b/src/nls/root/strings.js index 509a1f7e97..4c67f84ff8 100644 --- a/src/nls/root/strings.js +++ b/src/nls/root/strings.js @@ -2214,6 +2214,8 @@ define({ "AI_CHAT_UNDO_TITLE": "Undo changes from this response", "AI_CHAT_RESTORE_TITLE": "Restore files to this point", "AI_CHAT_RESTORED": "Restored", + "AI_CHAT_FILE_NOT_FOUND_TITLE": "File not found", + "AI_CHAT_FILE_NOT_FOUND_MSG": "Could not open {0}. The file may have been moved or deleted.", "AI_CHAT_UNDO_RESTORE_WARNING_TITLE": "AI Undo & Restore", "AI_CHAT_UNDO_RESTORE_WARNING_BODY": "This will only undo changes made by the AI. Changes made outside the AI won’t be restored and may be lost. For full version history, use version control like Git.", "AI_CHAT_SHOW_DIFF": "Show diff", diff --git a/src/styles/Extn-AIChatPanel.less b/src/styles/Extn-AIChatPanel.less index e65503c6d4..06940b0f4d 100644 --- a/src/styles/Extn-AIChatPanel.less +++ b/src/styles/Extn-AIChatPanel.less @@ -658,6 +658,25 @@ display: block; } + .ai-diff-row { + white-space: pre; + } + + .ai-diff-num { + display: inline-block; + width: 3em; + text-align: right; + margin-right: 8px; + color: @project-panel-text-2; + opacity: 0.55; + user-select: none; + } + + .ai-diff-ctx { + color: @project-panel-text-2; + opacity: 0.8; + } + .ai-diff-old { color: #e88; background-color: rgba(255, 80, 80, 0.06); @@ -667,6 +686,12 @@ color: #8c8; background-color: rgba(80, 200, 80, 0.06); } + + .ai-diff-sep { + height: 0; + border-top: 1px dashed rgba(255, 255, 255, 0.12); + margin: 4px -8px 4px -28px; + } } .ai-tool-edit-error { From e39e36f5a825b9ee1ae99aeebf0f43a98d6e8292 Mon Sep 17 00:00:00 2001 From: abose Date: Sun, 26 Apr 2026 12:55:51 +0530 Subject: [PATCH 02/19] feat(ai-chat): surface SDK hook errors and applied-directly state MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Agent batches consecutive 'Error in hook callback' stderr lines into a single console.error block and emits an aiHookError peer event so the full SDK trace is easy to inspect. - New AI_CHAT_TOOL_APPLIED_DIRECTLY string for indicators where the SDK ran the tool natively (hook bypassed) — Phoenix can't capture a diff for these. - Inline edit actions move to the tool-header row (margin-left: auto) so 'Show diff' shares a line with the 'Edit ' label. --- src-node/claude-code-agent.js | 29 ++++++++++++++++++++++++++++- src/nls/root/strings.js | 1 + src/styles/Extn-AIChatPanel.less | 19 ++++++++++++++++--- 3 files changed, 45 insertions(+), 4 deletions(-) diff --git a/src-node/claude-code-agent.js b/src-node/claude-code-agent.js index 82a11d3f9f..21c4be16e5 100644 --- a/src-node/claude-code-agent.js +++ b/src-node/claude-code-agent.js @@ -422,7 +422,26 @@ async function _runQuery(requestId, prompt, projectPath, model, signal, locale, } let _lastStderrLines = []; - const MAX_STDERR_LINES = 20; + const MAX_STDERR_LINES = 50; + let _hookErrorBuffer = ""; + let _hookErrorTimer = null; + const HOOK_ERROR_FLUSH_MS = 200; + + function _flushHookError() { + if (_hookErrorBuffer) { + const trimmed = _hookErrorBuffer.trim(); + console.error("[AI hook callback error] SDK threw delivering hook payload" + + " — tool likely ran natively in acceptEdits mode:\n" + trimmed); + try { + nodeConnector.triggerPeer("aiHookError", { + requestId: requestId, + error: trimmed + }); + } catch (e) { /* peer may be gone — ignore */ } + _hookErrorBuffer = ""; + } + _hookErrorTimer = null; + } const queryOptions = { cwd: projectPath || process.cwd(), @@ -433,6 +452,14 @@ async function _runQuery(requestId, prompt, projectPath, model, signal, locale, if (_lastStderrLines.length > MAX_STDERR_LINES) { _lastStderrLines.shift(); } + // Collect consecutive lines belonging to a hook callback error so + // we can log the full burst as one block. The SDK fragments the + // error across multiple stderr writes which is hard to read. + if (_hookErrorBuffer || /Error in hook callback/.test(data)) { + _hookErrorBuffer += data + "\n"; + clearTimeout(_hookErrorTimer); + _hookErrorTimer = setTimeout(_flushHookError, HOOK_ERROR_FLUSH_MS); + } }, allowedTools: [ "Read", "Edit", "Write", "Glob", "Grep", "Bash", diff --git a/src/nls/root/strings.js b/src/nls/root/strings.js index 4c67f84ff8..344123dffe 100644 --- a/src/nls/root/strings.js +++ b/src/nls/root/strings.js @@ -2214,6 +2214,7 @@ define({ "AI_CHAT_UNDO_TITLE": "Undo changes from this response", "AI_CHAT_RESTORE_TITLE": "Restore files to this point", "AI_CHAT_RESTORED": "Restored", + "AI_CHAT_TOOL_APPLIED_DIRECTLY": "Applied directly to disk · diff not available", "AI_CHAT_FILE_NOT_FOUND_TITLE": "File not found", "AI_CHAT_FILE_NOT_FOUND_MSG": "Could not open {0}. The file may have been moved or deleted.", "AI_CHAT_UNDO_RESTORE_WARNING_TITLE": "AI Undo & Restore", diff --git a/src/styles/Extn-AIChatPanel.less b/src/styles/Extn-AIChatPanel.less index 06940b0f4d..6d476f9589 100644 --- a/src/styles/Extn-AIChatPanel.less +++ b/src/styles/Extn-AIChatPanel.less @@ -622,12 +622,14 @@ opacity: 1; } - // Inline edit actions (diff toggle) - .ai-tool-edit-actions { + // Inline edit actions (diff toggle) — sits at the right edge of the + // tool header so it shares a row with the "Edit " label. + .ai-tool-header .ai-tool-edit-actions { display: flex; align-items: center; gap: 6px; - padding: 2px 8px 4px 28px; + margin-left: auto; + padding: 0; } .ai-tool-diff-toggle { @@ -700,6 +702,17 @@ padding: 3px 8px 3px 28px; } + .ai-msg-tool.ai-tool-applied-directly { + opacity: 0.78; + + .ai-tool-direct-note { + font-size: @sidebar-xs-font-size; + color: @project-panel-text-2; + font-style: italic; + padding: 2px 8px 4px 28px; + } + } + .ai-tool-elapsed { font-size: @sidebar-xs-font-size; color: @project-panel-text-2; From 68a548d9259a54ec22d73b43012b7cae6385fd8d Mon Sep 17 00:00:00 2001 From: abose Date: Sun, 26 Apr 2026 13:12:43 +0530 Subject: [PATCH 03/19] feat(ai-chat): diff options menu strings and styles - New AI_CHAT_DIFF_* strings for the per-edit options menu (Expand all / Collapse all / Always show) and tooltip. - Style for the small ellipsis trigger; an invisible ::after pseudo pads the hit area ~10x14px around the visible icon (biased right toward empty header space) so it's easier to click without changing layout or stealing clicks from the adjacent Show diff button. --- src/nls/root/strings.js | 4 ++++ src/styles/Extn-AIChatPanel.less | 31 +++++++++++++++++++++++++++++++ 2 files changed, 35 insertions(+) diff --git a/src/nls/root/strings.js b/src/nls/root/strings.js index 344123dffe..b7ae87f666 100644 --- a/src/nls/root/strings.js +++ b/src/nls/root/strings.js @@ -2221,6 +2221,10 @@ define({ "AI_CHAT_UNDO_RESTORE_WARNING_BODY": "This will only undo changes made by the AI. Changes made outside the AI won’t be restored and may be lost. For full version history, use version control like Git.", "AI_CHAT_SHOW_DIFF": "Show diff", "AI_CHAT_HIDE_DIFF": "Hide diff", + "AI_CHAT_DIFF_MORE_TITLE": "Diff options", + "AI_CHAT_DIFF_EXPAND_ALL": "Expand all", + "AI_CHAT_DIFF_COLLAPSE_ALL": "Collapse all", + "AI_CHAT_DIFF_ALWAYS_SHOW": "Always show", "AI_CHAT_LABEL_YOU": "You", "AI_CHAT_LABEL_CLAUDE": "Claude", "AI_CHAT_SEND_ERROR": "Failed to send message: {0}", diff --git a/src/styles/Extn-AIChatPanel.less b/src/styles/Extn-AIChatPanel.less index 6d476f9589..9b5cc49cd3 100644 --- a/src/styles/Extn-AIChatPanel.less +++ b/src/styles/Extn-AIChatPanel.less @@ -647,6 +647,36 @@ } } + .ai-tool-diff-more { + background: none; + border: none; + color: @project-panel-text-2; + font-size: @sidebar-xs-font-size; + padding: 1px 4px; + cursor: pointer; + opacity: 0.5; + transition: opacity 0.15s ease; + position: relative; + + // Invisible hit-area expander so users don't have to land on the + // narrow ellipsis exactly. Doesn't affect layout because it's an + // absolute pseudo-element of the button itself. left: -2px keeps + // it from encroaching on the diff-toggle to its left; the right + // side has empty header space so we expand more there. + &::after { + content: ""; + position: absolute; + top: -6px; + right: -8px; + bottom: -6px; + left: -2px; + } + + &:hover { + opacity: 1; + } + } + .ai-tool-diff { display: none; padding: 4px 8px 4px 28px; @@ -2512,3 +2542,4 @@ color: @project-panel-text-2; text-align: center; } + From 1d5e867c8b3e6acf7aa376d7091582a5e2b72426 Mon Sep 17 00:00:00 2001 From: abose Date: Sun, 26 Apr 2026 14:41:30 +0530 Subject: [PATCH 04/19] feat(ai-chat): forward tool_result and refine post-edit styling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Agent maps each SDK tool_use id to its counter at content_block_start and emits a new aiToolResult event when the matching tool_result arrives, carrying isError + a short preview. - New strings for 'Edit rejected — file not modified' (with reason line) so unmatched Edit/Write indicators can be classified. - .ai-tool-direct-note now matches the .ai-tool-detail-line indent (28px) and typography so 'Applied directly' / 'Edit rejected' sit under the icon column instead of the card edge. Rejected variant switches the note to red (#e88) without italics; the secondary reason line is dimmer. --- src-node/claude-code-agent.js | 19 +++++++++++++++++++ src/nls/root/strings.js | 2 ++ src/styles/Extn-AIChatPanel.less | 29 ++++++++++++++++++++++++----- 3 files changed, 45 insertions(+), 5 deletions(-) diff --git a/src-node/claude-code-agent.js b/src-node/claude-code-agent.js index 21c4be16e5..b9865a0f4c 100644 --- a/src-node/claude-code-agent.js +++ b/src-node/claude-code-agent.js @@ -390,6 +390,9 @@ exports.clearClarification = async function () { async function _runQuery(requestId, prompt, projectPath, model, signal, locale, images, envOverrides, permissionMode) { let editCount = 0; let toolCounter = 0; + // SDK tool_use id (e.g. "toolu_01...") → our sequential toolCounter so a + // tool_result block can be mapped back to its indicator on the browser. + const _toolUseIdToCounter = {}; let queryFn; let connectionTimer = null; @@ -1065,6 +1068,11 @@ async function _runQuery(requestId, prompt, projectPath, model, signal, locale, toolDeltaCount = 0; toolStreamSendCount = 0; lastToolStreamTime = 0; + // Map the SDK's tool_use id → our toolCounter so we can + // correlate later tool_result blocks back to the indicator. + if (event.content_block.id) { + _toolUseIdToCounter[event.content_block.id] = toolCounter; + } _log("Tool start:", activeToolName, "#" + toolCounter); nodeConnector.triggerPeer("aiProgress", { requestId: requestId, @@ -1212,6 +1220,17 @@ async function _runQuery(requestId, prompt, projectPath, model, signal, locale, "isError=" + !!block.is_error, "len=" + len + "ch", preview ? ("preview=" + JSON.stringify(preview)) : ""); + // Forward the result so the browser can reflect outcome + // on the corresponding tool indicator (errored vs ran). + const counterId = _toolUseIdToCounter[block.tool_use_id]; + if (counterId !== undefined) { + nodeConnector.triggerPeer("aiToolResult", { + requestId: requestId, + toolId: counterId, + isError: !!block.is_error, + preview: preview + }); + } } } } diff --git a/src/nls/root/strings.js b/src/nls/root/strings.js index b7ae87f666..a3522eeb40 100644 --- a/src/nls/root/strings.js +++ b/src/nls/root/strings.js @@ -2215,6 +2215,8 @@ define({ "AI_CHAT_RESTORE_TITLE": "Restore files to this point", "AI_CHAT_RESTORED": "Restored", "AI_CHAT_TOOL_APPLIED_DIRECTLY": "Applied directly to disk · diff not available", + "AI_CHAT_TOOL_REJECTED": "Edit rejected — file not modified", + "AI_CHAT_TOOL_REJECTED_REASON": "Reason: {0}", "AI_CHAT_FILE_NOT_FOUND_TITLE": "File not found", "AI_CHAT_FILE_NOT_FOUND_MSG": "Could not open {0}. The file may have been moved or deleted.", "AI_CHAT_UNDO_RESTORE_WARNING_TITLE": "AI Undo & Restore", diff --git a/src/styles/Extn-AIChatPanel.less b/src/styles/Extn-AIChatPanel.less index 9b5cc49cd3..7ba3fe9537 100644 --- a/src/styles/Extn-AIChatPanel.less +++ b/src/styles/Extn-AIChatPanel.less @@ -732,14 +732,33 @@ padding: 3px 8px 3px 28px; } - .ai-msg-tool.ai-tool-applied-directly { + &.ai-tool-applied-directly { opacity: 0.78; + } + &.ai-tool-rejected { .ai-tool-direct-note { - font-size: @sidebar-xs-font-size; - color: @project-panel-text-2; - font-style: italic; - padding: 2px 8px 4px 28px; + color: #e88; + opacity: 0.85; + font-style: normal; + } + } + + .ai-tool-direct-note { + // Match the .ai-tool-detail-line indent so the note sits under the + // icon column the same way the file path does on expanded cards. + font-size: @sidebar-xs-font-size; + font-family: 'SourceCodePro-Medium', 'SourceCodePro', monospace; + color: @project-panel-text-2; + opacity: 0.7; + font-style: italic; + line-height: 1.5; + padding: 0 8px 4px 28px; + word-break: break-all; + + &.ai-tool-direct-note-sub { + opacity: 0.6; + padding-top: 0; } } From e706147056812ea0469843bb10d31cf50ca47706 Mon Sep 17 00:00:00 2001 From: abose Date: Sun, 26 Apr 2026 15:07:30 +0530 Subject: [PATCH 05/19] feat(ai-chat): readable Read tool range labels Adds AI_CHAT_TOOL_READ_FILE_RANGE / FROM strings so Read indicators say 'Read foo.html lines 120-200' or 'Read foo.html from line 120' instead of dropping the offset/limit. Lets the user tell apart consecutive reads of the same file at a glance. --- src/nls/root/strings.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/nls/root/strings.js b/src/nls/root/strings.js index a3522eeb40..2e1284fa7b 100644 --- a/src/nls/root/strings.js +++ b/src/nls/root/strings.js @@ -2199,6 +2199,8 @@ define({ "AI_CHAT_TOOL_SEARCHED": "Searched: {0}", "AI_CHAT_TOOL_GREP": "Grep: {0}", "AI_CHAT_TOOL_READ_FILE": "Read {0}", + "AI_CHAT_TOOL_READ_FILE_RANGE": "Read {0} lines {1}-{2}", + "AI_CHAT_TOOL_READ_FILE_FROM": "Read {0} from line {1}", "AI_CHAT_TOOL_EDIT_FILE": "Edit {0}", "AI_CHAT_TOOL_WRITE_FILE": "Write {0}", "AI_CHAT_TOOL_RAN_CMD": "Ran command", From 55749d7e8e12eb7f7b2851e63bd6c303e8d8ed61 Mon Sep 17 00:00:00 2001 From: abose Date: Sun, 26 Apr 2026 17:13:12 +0530 Subject: [PATCH 06/19] feat(ai-chat): let SDK run native Read/Edit/Write, preserve undo Restructure Phoenix's AI hooks so the Claude Code SDK runs Read, Edit, and Write natively against disk and our PreToolUse + PostToolUse hooks just sync the editor buffer around it. Avoids the SDK's read/mtime tracker tripping on Phoenix-applied edits, which was causing redundant Read+retry cycles after every Edit in the AI panel. PreToolUse: - Read: flush dirty buffer to disk so the SDK reads the live content, return {} so SDK runs native Read and updates its read tracker. - Edit: flush dirty buffer, capture pre-edit content for snapshot tracking, pre-check that old_string still exists in the file (deny with informative reason if the user changed the text since the last Read), otherwise return {}. - Write: flush dirty buffer, capture pre-write content, return {}. PostToolUse (new for Edit and Write): - Skip diff-card painting when input.tool_response indicates the SDK's native tool itself errored (new _isToolResponseError helper). - Edit: try applying old_string -> new_string directly to the open buffer via doc.replaceRange (preserves CodeMirror marks outside the edit region), fall back to refreshDocumentFromDisk only if the buffer no longer contains old_string. - Write: refreshDocumentFromDisk reads new content and pushes it through doc.refreshText. - Trigger aiToolEdit so existing diff-card and snapshot UI keep working. Editor._resetText (used by all refreshText callers, not just AI): - Initial document load still uses setValue + clearHistory. - External-content reload now does an O(n) common prefix/suffix scan and replaceRange's only the differing middle, instead of setValue + clearHistory. Disk-driven reloads (file watchers, git checkout, AI edits) become undoable AND CodeMirror marks at unchanged head/tail survive. markClean still sets the new saved generation so the dirty flag tracks divergence from disk correctly. Also tracks SDK tool_use_id -> our toolCounter so PostToolUse can fire aiToolEdit for the right indicator regardless of phantom partial re-emits. --- src-node/claude-code-agent.js | 280 +++++++++++++++++++++------------- src/editor/Editor.js | 54 ++++++- 2 files changed, 221 insertions(+), 113 deletions(-) diff --git a/src-node/claude-code-agent.js b/src-node/claude-code-agent.js index b9865a0f4c..f367faf06e 100644 --- a/src-node/claude-code-agent.js +++ b/src-node/claude-code-agent.js @@ -78,6 +78,30 @@ let _queuedClarification = null; const nodeConnector = global.createNodeConnector(CONNECTOR_ID, exports); +/** + * Detect whether a PostToolUse `tool_response` represents an error result. + * Used to suppress diff-card painting when the SDK's native Edit/Write itself + * failed (e.g. oldText not found on disk). The shape of tool_response is + * `unknown` per the SDK types — handle the common variants defensively. + */ +function _isToolResponseError(toolResponse) { + if (!toolResponse) { return false; } + if (typeof toolResponse === "object") { + if (toolResponse.is_error === true || toolResponse.isError === true) { return true; } + if (Array.isArray(toolResponse.content)) { + for (const c of toolResponse.content) { + if (c && typeof c.text === "string" && //i.test(c.text)) { + return true; + } + } + } + } + if (typeof toolResponse === "string" && //i.test(toolResponse)) { + return true; + } + return false; +} + /** * Lazily import the ESM @anthropic-ai/claude-code module. */ @@ -582,46 +606,46 @@ async function _runQuery(requestId, prompt, projectPath, model, signal, locale, } }; } - const myToolId = toolCounter; // capture before any await - const edit = { - file: input.tool_input.file_path, - oldText: input.tool_input.old_string, - newText: input.tool_input.new_string, - replaceAll: input.tool_input.replace_all === true - }; - editCount++; - let editResult; + // New flow: flush dirty buffer to disk so SDK reads + // the latest content, capture pre-edit content for + // snapshot tracking, then return {} so SDK runs + // native Edit on disk. Its mtime/read tracker stays + // consistent and the next Edit won't trip the + // "modified since read" safety check. + const filePath = input.tool_input.file_path; + const oldString = input.tool_input.old_string; + let captured = { content: "" }; try { - editResult = await nodeConnector.execPeer("applyEditToBuffer", edit); + await nodeConnector.execPeer("saveBufferToDisk", { filePath }); + captured = await nodeConnector.execPeer( + "captureFileContent", { filePath }) || captured; } catch (err) { - console.warn("[Phoenix AI] Failed to apply edit to buffer:", err.message); - editResult = { applied: false, error: err.message }; + console.warn("[Phoenix AI] Edit prep failed:", filePath, err.message); } - nodeConnector.triggerPeer("aiToolEdit", { - requestId: requestId, - toolId: myToolId, - edit: edit - }); - let reason; - if (editResult && editResult.applied === false) { - reason = "Edit FAILED: " + (editResult.error || "unknown error"); - } else { - reason = "Edit applied successfully via Phoenix editor."; - if (editResult && editResult.isLivePreviewRelated) { - reason += " The edited file is part of the active live preview." + - " Reload when ready with execJsInLivePreview: `location.reload()`"; + // Pre-check: if the text to replace is no longer in + // the file (user typed/changed it since the last + // Read), deny with an informative reason instead of + // letting the SDK fail with a generic "oldText not + // found". Phoenix sees the buffer state the SDK + // can't, so this is a more useful failure. + if (oldString && (captured.content || "").indexOf(oldString) === -1) { + let reason = "Edit FAILED: the text you wanted to replace is not " + + "present in the file. It may have been modified by the user " + + "or by another tool since you last read it. Read the file again " + + "to see the current content before retrying."; + if (_queuedClarification) { + reason += CLARIFICATION_HINT; } + return { + hookSpecificOutput: { + hookEventName: "PreToolUse", + permissionDecision: "deny", + permissionDecisionReason: reason + } + }; } - if (_queuedClarification) { - reason += CLARIFICATION_HINT; - } - return { - hookSpecificOutput: { - hookEventName: "PreToolUse", - permissionDecision: "deny", - permissionDecisionReason: reason - } - }; + editCount++; + return {}; } ] }, @@ -629,44 +653,20 @@ async function _runQuery(requestId, prompt, projectPath, model, signal, locale, matcher: "Read", hooks: [ async (input) => { - if (!input || !input.tool_input) { - return {}; - } - const filePath = input.tool_input.file_path; - if (!filePath) { + if (!input || !input.tool_input || !input.tool_input.file_path) { return {}; } + // Flush dirty buffer to disk so the SDK's native + // Read sees what the user is actually looking at. + // Returning {} lets the SDK run native Read so its + // read-tracker updates — required to avoid "file + // not read yet" rejections on subsequent edits. try { - const result = await nodeConnector.execPeer("getFileContent", { filePath }); - if (result && result.isDirty && result.content !== null) { - const MAX_LINES = 2000; - const MAX_LINE_LENGTH = 2000; - const lines = result.content.split("\n"); - const offset = input.tool_input.offset || 0; - const limit = input.tool_input.limit || MAX_LINES; - const selected = lines.slice(offset, offset + limit); - let formatted = selected.map((line, i) => { - const truncated = line.length > MAX_LINE_LENGTH - ? line.slice(0, MAX_LINE_LENGTH) + "..." - : line; - return String(offset + i + 1).padStart(6) + "\t" + truncated; - }).join("\n"); - 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", - permissionDecision: "deny", - permissionDecisionReason: formatted - } - }; - } + await nodeConnector.execPeer("saveBufferToDisk", + { filePath: input.tool_input.file_path }); } catch (err) { - console.warn("[Phoenix AI] Failed to check dirty state:", filePath, err.message); + console.warn("[Phoenix AI] Read prep failed:", + input.tool_input.file_path, err.message); } return {}; } @@ -708,45 +708,17 @@ async function _runQuery(requestId, prompt, projectPath, model, signal, locale, } }; } - const myToolId = toolCounter; // capture before any await - const edit = { - file: input.tool_input.file_path, - oldText: null, - newText: input.tool_input.content - }; - editCount++; - let writeResult; + // Mirror Edit: flush dirty buffer, capture pre-write + // content, return {} so SDK writes natively. + const filePath = input.tool_input.file_path; try { - writeResult = await nodeConnector.execPeer("applyEditToBuffer", edit); + await nodeConnector.execPeer("saveBufferToDisk", { filePath }); + await nodeConnector.execPeer("captureFileContent", { filePath }); } catch (err) { - console.warn("[Phoenix AI] Failed to apply write to buffer:", err.message); - writeResult = { applied: false, error: err.message }; - } - nodeConnector.triggerPeer("aiToolEdit", { - requestId: requestId, - toolId: myToolId, - edit: edit - }); - let reason; - if (writeResult && writeResult.applied === false) { - reason = "Write FAILED: " + (writeResult.error || "unknown error"); - } else { - reason = "Write applied successfully via Phoenix editor."; - if (writeResult && writeResult.isLivePreviewRelated) { - reason += " The written file is part of the active live preview." + - " Reload when ready with execJsInLivePreview: `location.reload()`"; - } - } - if (_queuedClarification) { - reason += CLARIFICATION_HINT; + console.warn("[Phoenix AI] Write prep failed:", filePath, err.message); } - return { - hookSpecificOutput: { - hookEventName: "PreToolUse", - permissionDecision: "deny", - permissionDecisionReason: reason - } - }; + editCount++; + return {}; } ] }, @@ -834,6 +806,104 @@ async function _runQuery(requestId, prompt, projectPath, model, signal, locale, } ] } + ], + PostToolUse: [ + { + matcher: "Edit", + hooks: [ + async (input, toolUseID) => { + const filePath = input && input.tool_input && input.tool_input.file_path; + if (!filePath) { return {}; } + // Plan files don't go through the editor + if (filePath.replace(/\\/g, "/").includes("/.claude/plans/")) { + return {}; + } + // If the SDK's native Edit itself failed (e.g. + // oldText not found on disk), don't paint a diff + // card. The existing aiToolResult flow will + // classify the indicator from the tool_result. + if (_isToolResponseError(input.tool_response)) { + return {}; + } + const editPayload = { + file: filePath, + oldText: input.tool_input.old_string, + newText: input.tool_input.new_string, + replaceAll: input.tool_input.replace_all === true + }; + // 1. Prefer applying the edit directly to the open + // buffer via doc.replaceRange — preserves + // CodeMirror marks outside the edit region (live + // preview HTML element marks). Falls back to a + // full refreshDocumentFromDisk if no doc is open + // or the buffer no longer contains old_string + // (e.g. user typed since save). + let result = {}; + try { + result = await nodeConnector.execPeer( + "applyEditToOpenBufferOnly", editPayload) || {}; + } catch (err) { + console.warn("[Phoenix AI] applyEditToOpenBufferOnly failed:", filePath, err.message); + } + if (!result.applied) { + try { + result = await nodeConnector.execPeer( + "refreshDocumentFromDisk", { filePath }) || result; + } catch (err) { + console.warn("[Phoenix AI] Edit refresh fallback failed:", filePath, err.message); + } + } + // 2. Trigger aiToolEdit so the AI panel renders the + // diff card and the snapshot store records it. + const counterId = _toolUseIdToCounter[toolUseID]; + if (counterId !== undefined) { + editPayload.isLivePreviewRelated = !!result.isLivePreviewRelated; + nodeConnector.triggerPeer("aiToolEdit", { + requestId: requestId, + toolId: counterId, + edit: editPayload + }); + } + return {}; + } + ] + }, + { + matcher: "Write", + hooks: [ + async (input, toolUseID) => { + const filePath = input && input.tool_input && input.tool_input.file_path; + if (!filePath) { return {}; } + if (filePath.replace(/\\/g, "/").includes("/.claude/plans/")) { + return {}; + } + if (_isToolResponseError(input.tool_response)) { + return {}; + } + let refreshResult = {}; + try { + refreshResult = await nodeConnector.execPeer( + "refreshDocumentFromDisk", { filePath }) || {}; + } catch (err) { + console.warn("[Phoenix AI] Write refresh failed:", filePath, err.message); + } + const counterId = _toolUseIdToCounter[toolUseID]; + if (counterId !== undefined) { + nodeConnector.triggerPeer("aiToolEdit", { + requestId: requestId, + toolId: counterId, + edit: { + file: filePath, + oldText: null, + newText: input.tool_input.content, + isLivePreviewRelated: !!refreshResult.isLivePreviewRelated + } + }); + } + return {}; + } + ] + } ] } }; diff --git a/src/editor/Editor.js b/src/editor/Editor.js index c19540a58f..3564a813c2 100644 --- a/src/editor/Editor.js +++ b/src/editor/Editor.js @@ -717,7 +717,8 @@ define(function (require, exports, module) { * @param {!string} text */ Editor.prototype._resetText = function (text) { - var currentText = this._codeMirror.getValue(); + var cm = this._codeMirror; + var currentText = cm.getValue(); // compare with ignoring line-endings, issue #11826 var textLF = text ? text.replace(/(\r\n|\r|\n)/g, "\n") : null; @@ -732,14 +733,51 @@ define(function (require, exports, module) { var cursorPos = this.getCursorPos(), scrollPos = this.getScrollPos(); - // This *will* fire a change event, but we clear the undo immediately afterward - this._codeMirror.setValue(text); - this._codeMirror.refresh(); + // First-time content load (e.g. opening a file): there's no useful + // undo state to preserve — fall back to setValue + clearHistory so + // the user can't ctrl-z into the empty doc that existed before open. + var isInitialLoad = currentText === "" && cm.historySize().undo === 0; - // Make sure we can't undo back to the empty state before setValue(), and mark - // the document clean. - this._codeMirror.clearHistory(); - this._codeMirror.markClean(); + if (isInitialLoad) { + cm.setValue(text); + cm.refresh(); + cm.clearHistory(); + } else { + // External-content reload (disk change, AI edit, git checkout, etc.): + // replace ONLY the differing middle of the document so the change + // is undoable AND CodeMirror marks outside the changed region are + // preserved. The latter matters for HTML files used in live + // preview (each rendered element holds a mark) — replacing the + // whole doc would clear every mark and force a full preview + // refresh on every edit. + // + // O(n) common prefix + suffix scan: cheap enough to apply to all + // languages, and AI edits are typically tiny relative to file size. + var prefixLen = 0; + var minLen = Math.min(currentText.length, text.length); + while (prefixLen < minLen && + currentText.charCodeAt(prefixLen) === text.charCodeAt(prefixLen)) { + prefixLen++; + } + var maxSuffix = Math.min(currentText.length - prefixLen, text.length - prefixLen); + var suffixLen = 0; + while (suffixLen < maxSuffix && + currentText.charCodeAt(currentText.length - 1 - suffixLen) === + text.charCodeAt(text.length - 1 - suffixLen)) { + suffixLen++; + } + var fromPos = cm.posFromIndex(prefixLen); + var toPos = cm.posFromIndex(currentText.length - suffixLen); + var middle = text.substring(prefixLen, text.length - suffixLen); + cm.operation(function () { + cm.replaceRange(middle, fromPos, toPos, "+disk"); + }); + cm.refresh(); + } + // markClean sets the new "saved" generation — disk == buffer right + // now, so dirty=false. Undoing past this generation will re-mark + // dirty, exactly like a manual edit. + cm.markClean(); // restore cursor and scroll positions this.setCursorPos(cursorPos); From bbd01dd08a8c052746da643f15fc644e7badf924 Mon Sep 17 00:00:00 2001 From: abose Date: Sun, 26 Apr 2026 17:17:11 +0530 Subject: [PATCH 07/19] build: update pro deps --- tracking-repos.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tracking-repos.json b/tracking-repos.json index ca92efe992..154ac0c0d2 100644 --- a/tracking-repos.json +++ b/tracking-repos.json @@ -1,5 +1,5 @@ { "phoenixPro": { - "commitID": "2ab18a599da4872d5519841b6b222dea7c321ac3" + "commitID": "152db65529c453648b6241f776a74852080a0002" } } From ef858f0b66cdf2a9f4ca07220f7b66b0a28715f7 Mon Sep 17 00:00:00 2001 From: abose Date: Sun, 26 Apr 2026 17:48:16 +0530 Subject: [PATCH 08/19] feat(ai-chat): surface queued user clarifications via PostToolUse MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With PreToolUse hooks now returning {} (allow), the old practice of appending CLARIFICATION_HINT to permissionDecisionReason no longer reaches Claude when a user types a follow-up mid-stream — Edit, Write, and Read all fall straight through to native execution. Add a catch-all PostToolUse hook (no matcher) that returns the hint as additionalContext whenever _queuedClarification is set. Fires after every tool — Bash, Grep, Glob, WebFetch, Task, the Phoenix MCP tools, etc. — so any in-flight checkpoint prompts Claude to call getUserClarification before continuing. Becomes a no-op once the queue is drained. Edit/Write specific PostToolUse hooks now return {} for all paths; clarification is centralised in the catch-all so we can't double-fire. --- src-node/claude-code-agent.js | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/src-node/claude-code-agent.js b/src-node/claude-code-agent.js index f367faf06e..06cd08290d 100644 --- a/src-node/claude-code-agent.js +++ b/src-node/claude-code-agent.js @@ -864,6 +864,7 @@ async function _runQuery(requestId, prompt, projectPath, model, signal, locale, edit: editPayload }); } + // Catch-all PostToolUse below handles clarification. return {}; } ] @@ -900,14 +901,45 @@ async function _runQuery(requestId, prompt, projectPath, model, signal, locale, } }); } + // Catch-all PostToolUse below handles clarification. return {}; } ] + }, + { + // Catch-all: surface a queued user follow-up after every + // tool. Edit/Write/Read have their own hooks above, but + // any tool can be a meaningful checkpoint (Bash, Grep, + // Glob, WebFetch, Task, the Phoenix MCP tools, etc.) so + // we register one matcher-less hook that just returns + // the clarification context if any is queued. Once + // getUserClarification runs and clears _queuedClarification, + // _maybeClarifyContext returns {} and this becomes a no-op. + hooks: [ + async () => { + return _maybeClarifyContext(); + } + ] } ] } }; + // Returns a PostToolUse SyncHookJSONOutput that injects the clarification + // hint as additionalContext when the user has typed a follow-up while the + // AI is streaming. With our PreToolUse hooks now returning {} (allow), the + // old practice of appending CLARIFICATION_HINT to permissionDecisionReason + // no longer reaches Claude — PostToolUse additionalContext is the new path. + function _maybeClarifyContext() { + if (!_queuedClarification) { return {}; } + return { + hookSpecificOutput: { + hookEventName: "PostToolUse", + additionalContext: CLARIFICATION_HINT + } + }; + } + // Set Claude CLI path if found const claudePath = findGlobalClaudeCli(); if (claudePath) { From 83ceb23a24f0490b27f3cb9de30d191422f9a90d Mon Sep 17 00:00:00 2001 From: abose Date: Sun, 26 Apr 2026 18:14:19 +0530 Subject: [PATCH 09/19] build: update pro deps --- tracking-repos.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tracking-repos.json b/tracking-repos.json index 154ac0c0d2..3bc306ec45 100644 --- a/tracking-repos.json +++ b/tracking-repos.json @@ -1,5 +1,5 @@ { "phoenixPro": { - "commitID": "152db65529c453648b6241f776a74852080a0002" + "commitID": "6c1e7d01a4ae04a598e36109e0e4a461938e4509" } } From 66549c2afd39abfb5cde6651eb1dd2a5155de0a4 Mon Sep 17 00:00:00 2001 From: abose Date: Sun, 26 Apr 2026 18:50:36 +0530 Subject: [PATCH 10/19] feat(ai-chat): confirm before writes during plan mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When Claude attempts an Edit or Write on a user file while the panel is in Plan Mode, surface an inline confirmation card asking the user to either Allow & Switch to Edit Mode, or Stay in Plan Mode. Mirrors the Bash confirmation flow (matcher 'Bash' hook) — agent blocks on a Promise until the browser sends the user's decision back via the new answerPlanModeWriteConfirm peer. Allow: - Browser flips _permissionMode to acceptEdits and updates the permission bar so the panel reflects the new state. - Agent caches the approval in _planExitApprovedThisTurn so a multi-edit turn doesn't pop a dialog before every edit. - Hook returns permissionDecision: 'allow' to override the SDK's default plan-mode block for the in-flight call. Stay: - Hook returns deny with reason instructing Claude to use ExitPlanMode to propose changes for approval first. - Permission bar stays on Plan Mode. Plan-file writes (.claude/plans/) keep their existing silent-save path — no prompt, the Proposed Plan card is the user-meaningful surface for those. The card uses a distinct blue accent (rgb(79,163,227)) versus the Bash confirm card's orange so users can tell at a glance which type of confirmation they're seeing. Allow/Deny button colours stay green/red for action consistency. --- src-node/claude-code-agent.js | 147 +++++++++++++++++++++++++++++-- src/nls/root/strings.js | 6 ++ src/styles/Extn-AIChatPanel.less | 81 +++++++++++++++++ 3 files changed, 229 insertions(+), 5 deletions(-) diff --git a/src-node/claude-code-agent.js b/src-node/claude-code-agent.js index 06cd08290d..3244e22fcc 100644 --- a/src-node/claude-code-agent.js +++ b/src-node/claude-code-agent.js @@ -63,6 +63,11 @@ let _planResolve = null; // Pending bash confirmation resolver — used by Bash PreToolUse hook (Edit Mode) let _bashConfirmResolve = null; +// Pending plan-mode write confirmation resolver — set when an Edit/Write +// fires in plan mode and we're awaiting the user's "Allow & Switch to Edit +// Mode" / "Stay in Plan Mode" choice from the browser. +let _planModeConfirmResolve = null; + // Stores rejection feedback when user rejects a plan let _planRejectionFeedback = null; @@ -300,6 +305,7 @@ exports.cancelQuery = async function () { _questionResolve = null; _planResolve = null; _bashConfirmResolve = null; + _planModeConfirmResolve = null; _queuedClarification = null; return { success: true }; } @@ -342,6 +348,18 @@ exports.answerBashConfirm = async function (params) { return { success: true }; }; +/** + * Receive the user's response to a plan-mode write confirmation prompt. + * Called from browser via execPeer("answerPlanModeWriteConfirm", {approved}). + */ +exports.answerPlanModeWriteConfirm = async function (params) { + if (_planModeConfirmResolve) { + _planModeConfirmResolve(params); + _planModeConfirmResolve = null; + } + return { success: true }; +}; + /** * Resume a previous session by setting the session ID. * The next sendPrompt call will use queryOptions.resume with this session ID. @@ -417,6 +435,11 @@ async function _runQuery(requestId, prompt, projectPath, model, signal, locale, // SDK tool_use id (e.g. "toolu_01...") → our sequential toolCounter so a // tool_result block can be mapped back to its indicator on the browser. const _toolUseIdToCounter = {}; + // Set true once the user clicks "Allow & Switch to Edit Mode" on a + // plan-mode write confirmation. Subsequent Edit/Write attempts in the same + // turn skip the prompt and use the cached "allow" decision so a multi-edit + // turn doesn't pop a dialog before every edit. + let _planExitApprovedThisTurn = false; let queryFn; let connectionTimer = null; @@ -606,13 +629,62 @@ async function _runQuery(requestId, prompt, projectPath, model, signal, locale, } }; } + // Plan mode + user-file Edit: ask the user whether + // to switch to Edit Mode. Mirrors the Bash confirm + // pattern (matcher: "Bash"). Once approved, the + // _planExitApprovedThisTurn flag suppresses the + // prompt for subsequent edits in the same turn. + const filePath = input.tool_input.file_path; + if (permissionMode === "plan" && !_planExitApprovedThisTurn) { + nodeConnector.triggerPeer("aiPlanModeWriteConfirm", { + requestId: requestId, + toolName: "Edit", + filePath: filePath + }); + let response; + try { + response = await new Promise((resolve, reject) => { + _planModeConfirmResolve = resolve; + if (signal.aborted) { + _planModeConfirmResolve = null; + reject(new Error("Aborted")); + return; + } + const onAbort = () => { + _planModeConfirmResolve = null; + reject(new Error("Aborted")); + }; + signal.addEventListener("abort", onAbort, { once: true }); + }); + } catch (err) { + return { + hookSpecificOutput: { + hookEventName: "PreToolUse", + permissionDecision: "deny", + permissionDecisionReason: "Edit cancelled." + } + }; + } + if (!response.approved) { + return { + hookSpecificOutput: { + hookEventName: "PreToolUse", + permissionDecision: "deny", + permissionDecisionReason: "User chose to stay in Plan Mode. " + + "Use the ExitPlanMode tool to propose your changes for " + + "approval before editing." + } + }; + } + _planExitApprovedThisTurn = true; + } // New flow: flush dirty buffer to disk so SDK reads // the latest content, capture pre-edit content for - // snapshot tracking, then return {} so SDK runs - // native Edit on disk. Its mtime/read tracker stays + // snapshot tracking, then return {} (or "allow" if + // we're auto-exiting plan mode) so SDK runs native + // Edit on disk. Its mtime/read tracker stays // consistent and the next Edit won't trip the // "modified since read" safety check. - const filePath = input.tool_input.file_path; const oldString = input.tool_input.old_string; let captured = { content: "" }; try { @@ -645,6 +717,17 @@ async function _runQuery(requestId, prompt, projectPath, model, signal, locale, }; } editCount++; + // In plan mode, after the user approved the + // confirmation prompt, we need an explicit "allow" + // to override the SDK's default plan-mode block. + if (permissionMode === "plan") { + return { + hookSpecificOutput: { + hookEventName: "PreToolUse", + permissionDecision: "allow" + } + }; + } return {}; } ] @@ -708,9 +791,55 @@ async function _runQuery(requestId, prompt, projectPath, model, signal, locale, } }; } - // Mirror Edit: flush dirty buffer, capture pre-write - // content, return {} so SDK writes natively. + // Plan mode + user-file Write: same confirmation + // path as Edit. See Edit hook above for rationale. const filePath = input.tool_input.file_path; + if (permissionMode === "plan" && !_planExitApprovedThisTurn) { + nodeConnector.triggerPeer("aiPlanModeWriteConfirm", { + requestId: requestId, + toolName: "Write", + filePath: filePath + }); + let response; + try { + response = await new Promise((resolve, reject) => { + _planModeConfirmResolve = resolve; + if (signal.aborted) { + _planModeConfirmResolve = null; + reject(new Error("Aborted")); + return; + } + const onAbort = () => { + _planModeConfirmResolve = null; + reject(new Error("Aborted")); + }; + signal.addEventListener("abort", onAbort, { once: true }); + }); + } catch (err) { + return { + hookSpecificOutput: { + hookEventName: "PreToolUse", + permissionDecision: "deny", + permissionDecisionReason: "Write cancelled." + } + }; + } + if (!response.approved) { + return { + hookSpecificOutput: { + hookEventName: "PreToolUse", + permissionDecision: "deny", + permissionDecisionReason: "User chose to stay in Plan Mode. " + + "Use the ExitPlanMode tool to propose your changes for " + + "approval before writing." + } + }; + } + _planExitApprovedThisTurn = true; + } + // Mirror Edit: flush dirty buffer, capture pre-write + // content, return {} (or "allow" in plan mode) so + // SDK writes natively. try { await nodeConnector.execPeer("saveBufferToDisk", { filePath }); await nodeConnector.execPeer("captureFileContent", { filePath }); @@ -718,6 +847,14 @@ async function _runQuery(requestId, prompt, projectPath, model, signal, locale, console.warn("[Phoenix AI] Write prep failed:", filePath, err.message); } editCount++; + if (permissionMode === "plan") { + return { + hookSpecificOutput: { + hookEventName: "PreToolUse", + permissionDecision: "allow" + } + }; + } return {}; } ] diff --git a/src/nls/root/strings.js b/src/nls/root/strings.js index 2e1284fa7b..eacba28a80 100644 --- a/src/nls/root/strings.js +++ b/src/nls/root/strings.js @@ -2266,6 +2266,12 @@ define({ "AI_CHAT_BASH_DENY": "Deny", "AI_CHAT_BASH_ALLOWED": "Command allowed", "AI_CHAT_BASH_DENIED": "Command denied", + "AI_CHAT_PLAN_WRITE_CONFIRM_TITLE": "Switch to Edit Mode?", + "AI_CHAT_PLAN_WRITE_CONFIRM_BODY": "Claude wants to edit {0}. You're currently in Plan Mode.", + "AI_CHAT_PLAN_WRITE_ALLOW": "Allow & Switch to Edit Mode", + "AI_CHAT_PLAN_WRITE_STAY": "Stay in Plan Mode", + "AI_CHAT_PLAN_WRITE_ALLOWED": "Switched to Edit Mode", + "AI_CHAT_PLAN_WRITE_STAYED": "Stayed in Plan Mode", "AI_CHAT_CODE_DEFAULT_LANG": "text", "AI_CHAT_CODE_COLLAPSE": "Collapse", "AI_CHAT_CODE_EXPAND": "Expand", diff --git a/src/styles/Extn-AIChatPanel.less b/src/styles/Extn-AIChatPanel.less index 7ba3fe9537..556d9a165e 100644 --- a/src/styles/Extn-AIChatPanel.less +++ b/src/styles/Extn-AIChatPanel.less @@ -1591,6 +1591,87 @@ } } +/* ── Plan-mode write confirmation card ─────────────────────────────── */ +/* Same structure as .ai-bash-confirm; distinct blue accent so users can + tell at a glance this is a "switch out of Plan Mode?" prompt rather + than a "run command?" prompt. Allow/deny button colours stay + green/red so the user-action affordance is consistent. */ +.ai-plan-write-confirm { + border: 1px solid rgba(79, 163, 227, 0.3); + border-radius: 8px; + background: rgba(79, 163, 227, 0.06); + margin: 8px 0; + overflow: hidden; + + .ai-plan-write-confirm-header { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 12px; + font-weight: 600; + font-size: @sidebar-content-font-size; + color: #4fa3e3; + border-bottom: 1px solid rgba(79, 163, 227, 0.15); + } + + .ai-plan-write-confirm-body { + padding: 8px 12px; + font-size: @sidebar-small-font-size; + color: @project-panel-text-2; + word-break: break-all; + } + + .ai-plan-write-confirm-actions { + display: flex; + gap: 8px; + padding: 8px 12px; + border-top: 1px solid rgba(79, 163, 227, 0.15); + } + + .ai-plan-write-allow-btn { + background: rgba(76, 175, 80, 0.15); + border: 1px solid rgba(76, 175, 80, 0.35); + color: #81c784; + padding: 4px 14px; + border-radius: 4px; + cursor: pointer; + font-size: @sidebar-xs-font-size; + + &:hover { + background: rgba(76, 175, 80, 0.25); + } + } + + .ai-plan-write-stay-btn { + background: rgba(231, 76, 60, 0.15); + border: 1px solid rgba(231, 76, 60, 0.35); + color: #e57373; + padding: 4px 14px; + border-radius: 4px; + cursor: pointer; + font-size: @sidebar-xs-font-size; + + &:hover { + background: rgba(231, 76, 60, 0.25); + } + } +} + +.ai-plan-write-result { + padding: 4px 10px; + border-radius: 4px; + font-size: @sidebar-xs-font-size; + margin: 4px 0; + + &.ai-plan-write-result-allowed { + color: #81c784; + } + + &.ai-plan-write-result-stayed { + color: #e57373; + } +} + /* ── Queued clarification bubble (static, above input) ─────────────── */ .ai-queued-msg { border: 1px dashed rgba(255, 255, 255, 0.15); From 44bd5b5c90be09c32a0b64eb380052e9dafa8b49 Mon Sep 17 00:00:00 2001 From: abose Date: Sun, 26 Apr 2026 18:51:13 +0530 Subject: [PATCH 11/19] fix(ai-chat): keep .selected highlight on .ai-question-option hover The :hover:not(:disabled) rule had higher CSS specificity than .selected, so hovering a selected AskUserQuestion option washed away its green tint. Add :not(.selected) to the hover rule so the selected state wins. --- src/styles/Extn-AIChatPanel.less | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/styles/Extn-AIChatPanel.less b/src/styles/Extn-AIChatPanel.less index 556d9a165e..203e71e291 100644 --- a/src/styles/Extn-AIChatPanel.less +++ b/src/styles/Extn-AIChatPanel.less @@ -1081,7 +1081,7 @@ overflow-wrap: anywhere; min-width: 0; - &:hover:not(:disabled) { + &:hover:not(:disabled):not(.selected) { background: rgba(255, 255, 255, 0.08); border-color: rgba(255, 255, 255, 0.18); } From 97ccbb4c70514e14fb56141347be9f91d33dc3e9 Mon Sep 17 00:00:00 2001 From: abose Date: Sun, 26 Apr 2026 19:02:51 +0530 Subject: [PATCH 12/19] test: update refreshText assertions for new undo-preserving behavior Editor._resetText now uses cm.replaceRange instead of cm.setValue + cm.clearHistory so external content reloads (Revert, FileSyncManager, AI hook flow) leave the undo stack intact and the user can ctrl-z back to their pre-reset content. Update the two affected assertions in Document-integ-test.js: - 'should clear dirty flag AND undo when text reset' renamed to 'should clear dirty flag but preserve undo history when text reset'; expects undo size 2 (original Foo edit + refreshText replaceRange) instead of 0. - The clean-text-reset case now expects undo size 1 (the single replaceRange the new code performs) instead of 0. The third pre-existing same-text and same-text-different-line-endings tests already expect history NOT to be cleared (because _resetText short-circuits when content matches), so they still pass unchanged. --- test/spec/Document-integ-test.js | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/test/spec/Document-integ-test.js b/test/spec/Document-integ-test.js index 5e53eb8bea..683547014e 100644 --- a/test/spec/Document-integ-test.js +++ b/test/spec/Document-integ-test.js @@ -117,7 +117,7 @@ define(function (require, exports, module) { DocumentManager.off("dirtyFlagChange", dirtyFlagListener); }); - it("should clear dirty flag AND undo when text reset", async function () { + it("should clear dirty flag but preserve undo history when text reset", async function () { let dirtyFlagListener = jasmine.createSpy(), changeListener = jasmine.createSpy(); DocumentManager.on("dirtyFlagChange", dirtyFlagListener); @@ -137,10 +137,15 @@ define(function (require, exports, module) { expect(dirtyFlagListener.calls.count()).toBe(1); expect(changeListener.calls.count()).toBe(1); - // Reset text (e.g. called by Revert command, or syncing external changes) + // Reset text (e.g. called by Revert command, or syncing external changes). + // Editor._resetText now uses replaceRange instead of setValue+clearHistory + // so the user can ctrl-z back to their pre-revert state. markClean + // still resets the dirty flag relative to the new generation. doc.refreshText("New content", Date.now()); expect(doc.isDirty).toBe(false); - expect(doc._masterEditor._codeMirror.historySize().undo).toBe(0); // undo history GONE + // Undo history is PRESERVED — the refreshText replaceRange adds a + // second entry on top of the original "Foo" edit. + expect(doc._masterEditor._codeMirror.historySize().undo).toBe(2); expect(dirtyFlagListener.calls.count()).toBe(2); expect(changeListener.calls.count()).toBe(2); @@ -165,7 +170,9 @@ define(function (require, exports, module) { doc.refreshText("New content", Date.now()); // e.g. syncing external changes expect(doc.isDirty).toBe(false); - expect(doc._masterEditor._codeMirror.historySize().undo).toBe(0); // still no undo history + // The replaceRange used by Editor._resetText records one undo entry + // so the user can ctrl-z back to the pre-reset content. + expect(doc._masterEditor._codeMirror.historySize().undo).toBe(1); expect(dirtyFlagListener.calls.count()).toBe(0); // isDirty hasn't changed expect(changeListener.calls.count()).toBe(1); // but still counts as a content change From 28d74158d5b50c22035e2434befb9734572ad1ea Mon Sep 17 00:00:00 2001 From: abose Date: Sun, 26 Apr 2026 19:17:02 +0530 Subject: [PATCH 13/19] feat(ai-chat): dim stale TodoWrite blocks and neutralise their icons Adds .ai-msg-tool.ai-todo-stale modifier that lowers a TodoWrite block's opacity to 0.65 and overrides .ai-todo-icon colour to a neutral muted tone with reduced alpha. Paired with the browser-side sweep that swaps the stale spinner glyph for fa-arrow-right and strips the in_progress class, so older TodoWrite cards read as historical snapshots rather than implying parallel activity. --- src/styles/Extn-AIChatPanel.less | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/src/styles/Extn-AIChatPanel.less b/src/styles/Extn-AIChatPanel.less index 203e71e291..04ff7b068b 100644 --- a/src/styles/Extn-AIChatPanel.less +++ b/src/styles/Extn-AIChatPanel.less @@ -794,6 +794,20 @@ &.pending { color: @project-panel-text-2; opacity: 0.4; } } +// Older TodoWrite blocks are dimmed and have their in-progress spinner +// swapped for a static arrow when a newer TodoWrite supersedes them. +// Only the latest block keeps live animation; everything above reads +// as historical "this was the next step at the time" rather than +// implying parallel activity. +.ai-msg-tool.ai-todo-stale { + opacity: 0.65; + + .ai-todo-icon { + color: @project-panel-text-2; + opacity: 0.6; + } +} + .ai-todo-content { color: @project-panel-text-2; line-height: 1.4; From dc4fc9e72cae24dbb9bc4fbfa78ede9889081ca4 Mon Sep 17 00:00:00 2001 From: abose Date: Sun, 26 Apr 2026 19:18:19 +0530 Subject: [PATCH 14/19] build: update pro deps --- tracking-repos.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tracking-repos.json b/tracking-repos.json index 3bc306ec45..084394e6b8 100644 --- a/tracking-repos.json +++ b/tracking-repos.json @@ -1,5 +1,5 @@ { "phoenixPro": { - "commitID": "6c1e7d01a4ae04a598e36109e0e4a461938e4509" + "commitID": "53f6f8a2c095a6dcea304efc50cd2ec7d2e685c8" } } From b6624d7882447bb3516d1cda1624b1187cce8273 Mon Sep 17 00:00:00 2001 From: abose Date: Sun, 26 Apr 2026 19:47:36 +0530 Subject: [PATCH 15/19] feat(ai-chat): apply mid-stream permission-mode cycles to bash hook MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Bash PreToolUse hook closed over the permissionMode argument set once at _runQuery start. Cycling the panel's permission bar from Edit Mode to Full Auto mid-stream had no effect on bash calls already queued in the same turn — they kept hitting the confirm prompt even though the user had already opted out of confirmation. Add a module-level _runtimePermissionMode that hooks read at decision time and a setPermissionMode peer the browser calls on every cycle. _runQuery still seeds it from the params at start; setPermissionMode overrides it for the rest of the turn. The Bash hook now reads from this mutable instead of the closure parameter. Other hooks (Edit/Write plan-mode confirm, plan-file branches) keep using the closure parameter intentionally — flipping into/out of plan mode mid-stream can't undo what the SDK already permitted at query start, so the runtime override would only confuse those paths. --- src-node/claude-code-agent.js | 34 +++++++++++++++++++++++++++++++++- 1 file changed, 33 insertions(+), 1 deletion(-) diff --git a/src-node/claude-code-agent.js b/src-node/claude-code-agent.js index 3244e22fcc..22be2d86bf 100644 --- a/src-node/claude-code-agent.js +++ b/src-node/claude-code-agent.js @@ -81,6 +81,13 @@ let _planApproved = false; // Shape: { text: string, images: [{mediaType, base64Data}] } or null let _queuedClarification = null; +// Module-level "runtime" permission mode that hooks read at decision time. +// Updated on every sendPrompt and via the setPermissionMode peer when the +// user cycles the panel's permission bar mid-stream — without this, the +// Bash hook would close over the value at query start and continue +// prompting for confirmation even after the user has flipped to Full Auto. +let _runtimePermissionMode = "acceptEdits"; + const nodeConnector = global.createNodeConnector(CONNECTOR_ID, exports); /** @@ -360,6 +367,22 @@ exports.answerPlanModeWriteConfirm = async function (params) { return { success: true }; }; +/** + * Apply a mid-stream permission-mode change so hooks running for the rest + * of the turn use the new value. Called from the browser when the user + * cycles the permission bar (so e.g. Bash stops prompting immediately + * after switching from Edit Mode to Full Auto). The next sendPrompt also + * passes permissionMode in params, so this peer is only strictly required + * during streaming — but calling it on every cycle keeps the agent's + * tracker authoritative. + */ +exports.setPermissionMode = async function (params) { + if (params && typeof params.mode === "string") { + _runtimePermissionMode = params.mode; + } + return { success: true }; +}; + /** * Resume a previous session by setting the session ID. * The next sendPrompt call will use queryOptions.resume with this session ID. @@ -430,6 +453,10 @@ exports.clearClarification = async function () { * Internal: run a Claude SDK query and stream results back to the browser. */ async function _runQuery(requestId, prompt, projectPath, model, signal, locale, images, envOverrides, permissionMode) { + // Sync the runtime mutable that hooks read for permission decisions — + // setPermissionMode (peer) updates this same variable when the user + // cycles modes mid-stream. + _runtimePermissionMode = permissionMode || "acceptEdits"; let editCount = 0; let toolCounter = 0; // SDK tool_use id (e.g. "toolu_01...") → our sequential toolCounter so a @@ -863,7 +890,12 @@ async function _runQuery(requestId, prompt, projectPath, model, signal, locale, matcher: "Bash", hooks: [ async (input) => { - if (permissionMode !== "acceptEdits") { + // Read from the runtime mutable so mid-stream + // permission-mode flips (e.g. user switches Edit + // Mode → Full Auto while bash is in flight) take + // effect on the NEXT bash call without waiting + // for the next prompt. + if (_runtimePermissionMode !== "acceptEdits") { // Plan mode: SDK handles. Full Auto: allow freely. return {}; } From 53f4b602300f7407df6205814f26ab9c875753d2 Mon Sep 17 00:00:00 2001 From: abose Date: Sun, 26 Apr 2026 19:48:47 +0530 Subject: [PATCH 16/19] build: update pro deps --- tracking-repos.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tracking-repos.json b/tracking-repos.json index 084394e6b8..76237f047e 100644 --- a/tracking-repos.json +++ b/tracking-repos.json @@ -1,5 +1,5 @@ { "phoenixPro": { - "commitID": "53f6f8a2c095a6dcea304efc50cd2ec7d2e685c8" + "commitID": "0718a817b04e481232f9eea94647edca7a46d49d" } } From 99d4ea7954cd54e3be70fd26f80b1903721dba61 Mon Sep 17 00:00:00 2001 From: abose Date: Sun, 26 Apr 2026 20:28:20 +0530 Subject: [PATCH 17/19] feat(ai-chat): typography and density refresh User reports of dense, inconsistent panel UX prompted a full audit of Extn-AIChatPanel.less. The panel was using five distinct font sizes (11/12/13/14/15 px) plus relative units, with several hard-coded pixel values bypassing the variable system. Tool indicator labels were 12 px in muted grey (barely above readability threshold); their detail lines (file paths) were 11 px; in-card buttons ranged from 11 px (bash, restore, diff toggle) to 13 px (plan approve/revise) for no semantic reason. Changes: - Introduce three named LESS aliases at the top of the file: @ai-text-body = @sidebar-content-font-size (13px) @ai-text-secondary = @sidebar-small-font-size (12px) @ai-text-meta = @sidebar-xs-font-size (11px) plus @ai-line-prose (1.55) and @ai-line-compact (1.4). - Per-surface tier remap (where the surface read like content rather than UI chrome): .ai-tool-label 12 -> 13 (body) .ai-tool-detail-line 11 -> 12 (secondary) .ai-tool-preview 11 -> 12 (secondary) .ai-edit-summary-header 12 -> 13 (body) .ai-todo-content 12 -> 13 (body) .ai-todo-icon 11 -> 12 (secondary) .ai-bash-confirm-body pre 13 -> 12 (secondary, code block) .ai-plan-write-confirm-body 12 -> 13 (body) - Standardise every in-card button at @ai-text-secondary (12 px) + padding 4px 12px + line-height @ai-line-compact: bash allow/deny, plan approve/revise, plan-write allow/stay, restore, restore-point, diff toggle, diff more. - Vertical rhythm: .ai-msg-tool gets a 4 px breather between consecutive tool indicators; .ai-tool-header padding 4/8 -> 6/10; .ai-tool-detail padding 0 8 4 28 -> 2 10 6 28; .ai-todo-item padding 2/0 -> 4/0; bash/plan-write confirm body padding 8/12 -> 10/12. - Line height standardisation: prose surfaces use @ai-line-prose (1.55), compact UI uses @ai-line-compact (1.4). - Replace hard-coded literals with the aliases: .ai-user-file-chip 11 -> meta, .ai-chat-quota-bar 12 -> secondary, .ai-image-remove / .ai-screenshot-toolbar 12 -> secondary, ai-info / ai-quota-dismiss / file-remove pixels -> aliases, settings dialog labels and inputs -> aliases. - Keep .ai-permission-bar kbd at literal 10 px with a comment explaining it's a visual chip, not body text. Net: panel reads as a single coherent product instead of a collage of independently styled cards. Panel-wide diff is ~80 lines in a ~1700-line file. --- src/styles/Extn-AIChatPanel.less | 265 +++++++++++++++++-------------- 1 file changed, 148 insertions(+), 117 deletions(-) diff --git a/src/styles/Extn-AIChatPanel.less b/src/styles/Extn-AIChatPanel.less index 04ff7b068b..38b8678f86 100644 --- a/src/styles/Extn-AIChatPanel.less +++ b/src/styles/Extn-AIChatPanel.less @@ -20,6 +20,17 @@ /* AI Chat Panel — sidebar chat UI for Claude Code integration */ +// AI panel typography contract — every font-size below MUST use one of these +// (or a relative unit for headings). Centralising the scale here keeps the +// panel coherent across cards: tool indicators, diff cards, confirm dialogs, +// the proposed plan, etc. all read as one product instead of independently +// styled fragments. +@ai-text-body: @sidebar-content-font-size; // 13px — message content, tool labels, prose +@ai-text-secondary: @sidebar-small-font-size; // 12px — in-card buttons, mono code, file paths +@ai-text-meta: @sidebar-xs-font-size; // 11px — stats, status, permission/context info +@ai-line-prose: 1.55; // multi-line body text +@ai-line-compact: 1.4; // single-line UI elements + .ai-tab-container { display: flex; flex-direction: column; @@ -38,7 +49,7 @@ overflow: hidden; background-color: @bc-ai-sidebar-bg; color: @project-panel-text-1; - font-size: @sidebar-content-font-size; + font-size: @ai-text-body; container-type: inline-size; } @@ -176,7 +187,7 @@ } .ai-history-item-title { - font-size: @sidebar-content-font-size; + font-size: @ai-text-body; color: @project-panel-text-1; white-space: nowrap; overflow: hidden; @@ -184,7 +195,7 @@ } .ai-history-item-time { - font-size: @sidebar-xs-font-size; + font-size: @ai-text-meta; color: @project-panel-text-2; opacity: 0.6; } @@ -193,7 +204,7 @@ background: none; border: none; color: @project-panel-text-2; - font-size: @sidebar-small-font-size; + font-size: @ai-text-secondary; padding: 2px 4px; cursor: pointer; opacity: 0; @@ -214,7 +225,7 @@ .ai-history-empty { padding: 16px 10px; text-align: center; - font-size: @sidebar-small-font-size; + font-size: @ai-text-secondary; color: @project-panel-text-2; opacity: 0.6; } @@ -223,7 +234,7 @@ display: block; padding: 6px 10px; text-align: center; - font-size: @sidebar-xs-font-size; + font-size: @ai-text-meta; color: @project-panel-text-2; opacity: 0.5; cursor: pointer; @@ -242,7 +253,7 @@ justify-content: center; gap: 6px; padding: 4px 10px; - font-size: @sidebar-xs-font-size; + font-size: @ai-text-meta; color: @project-panel-text-2; opacity: 0.6; border-bottom: 1px solid rgba(255, 255, 255, 0.04); @@ -277,7 +288,7 @@ contain-intrinsic-size: auto 300px; .ai-msg-label { - font-size: @sidebar-xs-font-size; + font-size: @ai-text-meta; color: @project-panel-text-2; margin-bottom: 3px; font-weight: 600; @@ -285,7 +296,7 @@ } .ai-msg-content { - font-size: @sidebar-content-font-size; + font-size: @ai-text-body; line-height: 1.55; white-space: normal; word-wrap: break-word; @@ -332,7 +343,7 @@ background: rgba(255, 255, 255, 0.06); border: 1px solid rgba(255, 255, 255, 0.1); border-radius: 4px; - font-size: 11px; + font-size: @ai-text-meta; color: @project-panel-text-2; max-width: 180px; @@ -484,7 +495,7 @@ overflow-x: auto; border-collapse: collapse; margin: 6px 0; - font-size: @sidebar-small-font-size; + font-size: @ai-text-secondary; cursor: default; } @@ -502,7 +513,7 @@ font-weight: 600; color: @project-panel-text-2; border-bottom-color: rgba(255, 255, 255, 0.1); - font-size: @sidebar-xs-font-size; + font-size: @ai-text-meta; text-transform: uppercase; letter-spacing: 0.3px; } @@ -517,22 +528,28 @@ /* ── Tool use indicator ─────────────────────────────────────────────── */ .ai-msg-tool { - margin-bottom: 3px; + margin-bottom: 5px; border-radius: 4px; background-color: rgba(255, 255, 255, 0.025); border: 1px solid rgba(255, 255, 255, 0.04); border-left: 2px solid var(--tool-color, @project-panel-text-2); + // Add a small breather between consecutive tool indicators so a stack + // of them doesn't read as a single dense block. + & + .ai-msg-tool { + margin-top: 4px; + } + .ai-tool-header { display: flex; align-items: center; gap: 6px; - padding: 4px 8px; + padding: 6px 10px; white-space: normal; } .ai-tool-icon { - font-size: @sidebar-xs-font-size; + font-size: @ai-text-secondary; width: 14px; text-align: center; flex-shrink: 0; @@ -550,9 +567,9 @@ } .ai-tool-label { - font-size: @sidebar-small-font-size; + font-size: @ai-text-body; color: @project-panel-text-2; - line-height: 1.3; + line-height: @ai-line-compact; &.ai-tool-label-clickable:hover { color: @project-panel-text-1; @@ -562,7 +579,7 @@ .ai-tool-detail { display: none; - padding: 0 8px 4px 28px; + padding: 2px 10px 6px 28px; } .ai-tool-screenshot { @@ -577,22 +594,22 @@ } .ai-tool-detail-line { - font-size: @sidebar-xs-font-size; + font-size: @ai-text-secondary; font-family: 'SourceCodePro-Medium', 'SourceCodePro', monospace; color: @project-panel-text-2; - opacity: 0.7; - line-height: 1.5; + opacity: 0.78; + line-height: @ai-line-compact; white-space: normal; word-break: break-all; } .ai-tool-preview { - font-size: @sidebar-xs-font-size; + font-size: @ai-text-secondary; font-family: 'SourceCodePro-Medium', 'SourceCodePro', monospace; color: @project-panel-text-2; - opacity: 0.5; - padding: 0 8px 4px 28px; - line-height: 1.4; + opacity: 0.55; + padding: 0 10px 4px 28px; + line-height: @ai-line-compact; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; @@ -635,11 +652,12 @@ .ai-tool-diff-toggle { background: none; border: none; - font-size: @sidebar-xs-font-size; + font-size: @ai-text-secondary; color: @project-panel-text-2; - padding: 1px 4px; + padding: 2px 8px; + line-height: @ai-line-compact; cursor: pointer; - opacity: 0.6; + opacity: 0.65; transition: opacity 0.15s ease; &:hover { @@ -651,10 +669,11 @@ background: none; border: none; color: @project-panel-text-2; - font-size: @sidebar-xs-font-size; - padding: 1px 4px; + font-size: @ai-text-secondary; + padding: 2px 6px; + line-height: @ai-line-compact; cursor: pointer; - opacity: 0.5; + opacity: 0.55; transition: opacity 0.15s ease; position: relative; @@ -680,9 +699,9 @@ .ai-tool-diff { display: none; padding: 4px 8px 4px 28px; - font-size: @sidebar-small-font-size; + font-size: @ai-text-secondary; font-family: 'SourceCodePro-Medium', 'SourceCodePro', monospace; - line-height: 1.5; + line-height: @ai-line-prose; overflow-x: auto; background-color: rgba(0, 0, 0, 0.15); @@ -727,7 +746,7 @@ } .ai-tool-edit-error { - font-size: @sidebar-small-font-size; + font-size: @ai-text-secondary; color: #e88; padding: 3px 8px 3px 28px; } @@ -747,7 +766,7 @@ .ai-tool-direct-note { // Match the .ai-tool-detail-line indent so the note sits under the // icon column the same way the file path does on expanded cards. - font-size: @sidebar-xs-font-size; + font-size: @ai-text-meta; font-family: 'SourceCodePro-Medium', 'SourceCodePro', monospace; color: @project-panel-text-2; opacity: 0.7; @@ -763,7 +782,7 @@ } .ai-tool-elapsed { - font-size: @sidebar-xs-font-size; + font-size: @ai-text-meta; color: @project-panel-text-2; opacity: 0.5; margin-left: auto; @@ -779,15 +798,15 @@ display: flex; align-items: flex-start; gap: 6px; - padding: 2px 0; - font-size: @sidebar-small-font-size; + padding: 4px 0; + font-size: @ai-text-body; } .ai-todo-icon { width: 14px; text-align: center; flex-shrink: 0; - font-size: @sidebar-xs-font-size; + font-size: @ai-text-secondary; &.completed { color: #66bb6a; } &.in_progress { color: #e8a838; } @@ -810,7 +829,7 @@ .ai-todo-content { color: @project-panel-text-2; - line-height: 1.4; + line-height: @ai-line-prose; white-space: normal; overflow-wrap: break-word; min-width: 0; @@ -851,7 +870,7 @@ align-items: center; gap: 3px; font-family: 'SourceCodePro-Medium', 'SourceCodePro', monospace; - font-size: @sidebar-small-font-size; + font-size: @ai-text-secondary; } .ai-color-swatch-preview { @@ -881,7 +900,7 @@ display: flex; align-items: center; justify-content: space-between; - font-size: @sidebar-small-font-size; + font-size: @ai-text-body; font-weight: 600; color: @project-panel-text-2; margin-bottom: 4px; @@ -891,9 +910,10 @@ background: none; border: 1px solid rgba(200, 160, 80, 0.3); color: rgba(220, 180, 100, 0.9); - font-size: @sidebar-xs-font-size; - padding: 1px 8px; - border-radius: 3px; + font-size: @ai-text-secondary; + line-height: @ai-line-compact; + padding: 4px 12px; + border-radius: 4px; cursor: pointer; flex-shrink: 0; transition: background-color 0.15s ease, color 0.15s ease, border-color 0.15s ease; @@ -921,7 +941,7 @@ } .ai-edit-summary-name { - font-size: @sidebar-small-font-size; + font-size: @ai-text-secondary; font-family: 'SourceCodePro-Medium', 'SourceCodePro', monospace; color: @project-panel-text-2; overflow: hidden; @@ -939,7 +959,7 @@ .ai-edit-summary-stats { display: flex; gap: 6px; - font-size: @sidebar-xs-font-size; + font-size: @ai-text-meta; font-family: 'SourceCodePro-Medium', 'SourceCodePro', monospace; flex-shrink: 0; } @@ -962,7 +982,7 @@ background: rgba(102, 187, 106, 0.1); border: 1px solid rgba(102, 187, 106, 0.25); color: #66bb6a; - font-size: @sidebar-small-font-size; + font-size: @ai-text-secondary; padding: 4px 12px; border-radius: 4px; cursor: pointer; @@ -1001,9 +1021,10 @@ background: none; border: 1px dashed rgba(200, 160, 80, 0.3); color: rgba(220, 180, 100, 0.7); - font-size: @sidebar-xs-font-size; - padding: 2px 14px; - border-radius: 3px; + font-size: @ai-text-secondary; + line-height: @ai-line-compact; + padding: 4px 14px; + border-radius: 4px; cursor: pointer; transition: background-color 0.15s ease, color 0.15s ease, border-color 0.15s ease; @@ -1063,7 +1084,7 @@ } .ai-question-text { - font-size: @sidebar-content-font-size; + font-size: @ai-text-body; color: @project-panel-text-1; line-height: 1.5; margin-bottom: 8px; @@ -1113,14 +1134,14 @@ } .ai-question-option-label { - font-size: @sidebar-content-font-size; + font-size: @ai-text-body; font-weight: 500; white-space: normal; word-wrap: break-word; } .ai-question-option-desc { - font-size: @sidebar-small-font-size; + font-size: @ai-text-secondary; opacity: 0.65; line-height: 1.4; white-space: normal; @@ -1136,7 +1157,7 @@ background: none; border: 1px solid rgba(76, 175, 80, 0.3); color: rgba(76, 175, 80, 0.85); - font-size: @sidebar-small-font-size; + font-size: @ai-text-secondary; padding: 3px 10px; border-radius: 4px; cursor: pointer; @@ -1167,7 +1188,7 @@ border: 1px solid rgba(255, 255, 255, 0.1) !important; border-radius: 4px; color: @project-panel-text-1; - font-size: @sidebar-content-font-size; + font-size: @ai-text-body; padding: 6px 10px !important; margin: 0 !important; line-height: 1.4; @@ -1190,7 +1211,7 @@ 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; + font-size: @ai-text-body; padding: 0 10px; border-radius: 4px; cursor: pointer; @@ -1222,7 +1243,7 @@ background-color: rgba(255, 255, 255, 0.04); border-bottom: 1px solid rgba(255, 255, 255, 0.06); border-radius: 3px 3px 0 0; - font-size: @sidebar-small-font-size; + font-size: @ai-text-secondary; user-select: none; } @@ -1245,7 +1266,7 @@ cursor: pointer; padding: 1px 5px; border-radius: 3px; - font-size: @sidebar-small-font-size; + font-size: @ai-text-secondary; line-height: 1; transition: background-color 0.15s ease, color 0.15s ease; @@ -1278,7 +1299,7 @@ border: none; color: @project-panel-text-2; cursor: pointer; - font-size: @sidebar-small-font-size; + font-size: @ai-text-secondary; padding: 1px 8px; border-radius: 3px; transition: background-color 0.15s ease, color 0.15s ease; @@ -1306,13 +1327,13 @@ background-color: rgba(107, 158, 255, 0.08); border-bottom: 1px solid rgba(107, 158, 255, 0.15); color: #6b9eff; - font-size: @sidebar-content-font-size; + font-size: @ai-text-body; font-weight: 600; } .ai-plan-body { padding: 10px 12px; - font-size: @sidebar-content-font-size; + font-size: @ai-text-body; color: @project-panel-text-1; line-height: 1.5; white-space: normal; @@ -1412,8 +1433,9 @@ background: rgba(76, 175, 80, 0.15); border: 1px solid rgba(76, 175, 80, 0.35); color: #4caf50; - font-size: @sidebar-content-font-size; - padding: 5px 14px; + font-size: @ai-text-secondary; + line-height: @ai-line-compact; + padding: 4px 12px; border-radius: 4px; cursor: pointer; transition: background-color 0.15s ease; @@ -1437,8 +1459,9 @@ background: rgba(255, 255, 255, 0.04); border: 1px solid rgba(255, 255, 255, 0.12); color: @project-panel-text-2; - font-size: @sidebar-content-font-size; - padding: 5px 14px; + font-size: @ai-text-secondary; + line-height: @ai-line-compact; + padding: 4px 12px; border-radius: 4px; cursor: pointer; transition: background-color 0.15s ease; @@ -1474,7 +1497,7 @@ border: 1px solid rgba(255, 255, 255, 0.1) !important; border-radius: 4px; color: @project-panel-text-1; - font-size: @sidebar-content-font-size; + font-size: @ai-text-body; padding: 6px 10px !important; margin: 0 !important; line-height: 1.4; @@ -1533,21 +1556,22 @@ gap: 8px; padding: 8px 12px; font-weight: 600; - font-size: @sidebar-content-font-size; + font-size: @ai-text-body; color: #f39c12; border-bottom: 1px solid rgba(243, 156, 18, 0.15); } .ai-bash-confirm-body { - padding: 8px 12px; + padding: 10px 12px; pre { margin: 0; - padding: 8px; + padding: 8px 10px; border-radius: 4px; background: rgba(0, 0, 0, 0.2); color: #e0e0e0; - font-size: @sidebar-content-font-size; + font-size: @ai-text-secondary; + line-height: @ai-line-prose; white-space: pre-wrap; word-break: break-all; } @@ -1564,10 +1588,11 @@ background: rgba(76, 175, 80, 0.15); border: 1px solid rgba(76, 175, 80, 0.35); color: #81c784; - padding: 4px 14px; + padding: 4px 12px; border-radius: 4px; cursor: pointer; - font-size: @sidebar-xs-font-size; + font-size: @ai-text-secondary; + line-height: @ai-line-compact; &:hover { background: rgba(76, 175, 80, 0.25); @@ -1578,10 +1603,11 @@ background: rgba(231, 76, 60, 0.15); border: 1px solid rgba(231, 76, 60, 0.35); color: #e57373; - padding: 4px 14px; + padding: 4px 12px; border-radius: 4px; cursor: pointer; - font-size: @sidebar-xs-font-size; + font-size: @ai-text-secondary; + line-height: @ai-line-compact; &:hover { background: rgba(231, 76, 60, 0.25); @@ -1593,7 +1619,7 @@ .ai-bash-result { padding: 4px 10px; border-radius: 4px; - font-size: @sidebar-xs-font-size; + font-size: @ai-text-meta; margin: 4px 0; &.ai-bash-result-allowed { @@ -1623,14 +1649,15 @@ gap: 8px; padding: 8px 12px; font-weight: 600; - font-size: @sidebar-content-font-size; + font-size: @ai-text-body; color: #4fa3e3; border-bottom: 1px solid rgba(79, 163, 227, 0.15); } .ai-plan-write-confirm-body { - padding: 8px 12px; - font-size: @sidebar-small-font-size; + padding: 10px 12px; + font-size: @ai-text-body; + line-height: @ai-line-prose; color: @project-panel-text-2; word-break: break-all; } @@ -1646,10 +1673,11 @@ background: rgba(76, 175, 80, 0.15); border: 1px solid rgba(76, 175, 80, 0.35); color: #81c784; - padding: 4px 14px; + padding: 4px 12px; border-radius: 4px; cursor: pointer; - font-size: @sidebar-xs-font-size; + font-size: @ai-text-secondary; + line-height: @ai-line-compact; &:hover { background: rgba(76, 175, 80, 0.25); @@ -1660,10 +1688,11 @@ background: rgba(231, 76, 60, 0.15); border: 1px solid rgba(231, 76, 60, 0.35); color: #e57373; - padding: 4px 14px; + padding: 4px 12px; border-radius: 4px; cursor: pointer; - font-size: @sidebar-xs-font-size; + font-size: @ai-text-secondary; + line-height: @ai-line-compact; &:hover { background: rgba(231, 76, 60, 0.25); @@ -1674,7 +1703,7 @@ .ai-plan-write-result { padding: 4px 10px; border-radius: 4px; - font-size: @sidebar-xs-font-size; + font-size: @ai-text-meta; margin: 4px 0; &.ai-plan-write-result-allowed { @@ -1702,7 +1731,7 @@ } .ai-queued-label { - font-size: @sidebar-xs-font-size; + font-size: @ai-text-meta; color: @project-panel-text-2; font-weight: 600; opacity: 0.6; @@ -1711,7 +1740,7 @@ .ai-queued-edit-btn { background: none; border: none; - font-size: @sidebar-xs-font-size; + font-size: @ai-text-meta; color: @project-panel-text-2; padding: 0 4px; cursor: pointer; @@ -1743,7 +1772,7 @@ } .ai-queued-text { - font-size: @sidebar-content-font-size; + font-size: @ai-text-body; color: @project-panel-text-2; line-height: 1.4; white-space: pre-wrap; @@ -1764,7 +1793,7 @@ color: #e88; border-left: 2px solid rgba(255, 80, 80, 0.4); padding: 4px 8px; - font-size: @sidebar-small-font-size; + font-size: @ai-text-secondary; background-color: rgba(255, 80, 80, 0.04); border-radius: 0 3px 3px 0; } @@ -1783,7 +1812,7 @@ background: none; border: 1px solid rgba(255, 255, 255, 0.12); color: @project-panel-text-2; - font-size: @sidebar-small-font-size; + font-size: @ai-text-secondary; padding: 4px 14px; border-radius: 4px; cursor: pointer; @@ -1802,7 +1831,7 @@ align-items: center; gap: 6px; padding: 4px 10px; - font-size: @sidebar-small-font-size; + font-size: @ai-text-secondary; color: @project-panel-text-2; flex-shrink: 0; opacity: 0.6; @@ -1833,7 +1862,7 @@ border-radius: 6px; background-color: rgba(255, 200, 50, 0.10); border: 1px solid rgba(255, 200, 50, 0.20); - font-size: 12px; + font-size: @ai-text-secondary; color: @project-panel-text-1; .ai-quota-content { @@ -1867,7 +1896,7 @@ cursor: pointer; padding: 2px 4px; margin-left: 6px; - font-size: 12px; + font-size: @ai-text-secondary; &:hover { opacity: 1; @@ -1934,7 +1963,7 @@ } .ai-permission-label { - font-size: @sidebar-xs-font-size; + font-size: @ai-text-meta; color: @project-panel-text-2; line-height: 1; } @@ -1942,7 +1971,7 @@ .ai-info { display: none; margin-left: auto; - font-size: 11px; + font-size: @ai-text-meta; line-height: 1; pointer-events: auto; white-space: nowrap; @@ -1954,6 +1983,8 @@ kbd { display: inline-block; padding: 1px 5px; + // Smaller than @ai-text-meta on purpose: kbd glyphs read as + // visual chips, not body text, and need to fit at a glance. font-size: 10px; font-family: inherit; line-height: 1.2; @@ -1994,7 +2025,7 @@ display: inline-flex; align-items: center; gap: 4px; - font-size: @sidebar-xs-font-size; + font-size: @ai-text-meta; line-height: 1; padding: 3px 6px; border-radius: 3px; @@ -2021,7 +2052,7 @@ background: none; border: none; color: @project-panel-text-2; - font-size: @sidebar-small-font-size; + font-size: @ai-text-secondary; line-height: 1; padding: 0 0 0 2px; cursor: pointer; @@ -2077,7 +2108,7 @@ background: rgba(0, 0, 0, 0.6); border: 1px solid rgba(255, 255, 255, 0.3); color: #fff; - font-size: 12px; + font-size: @ai-text-secondary; line-height: 1; padding: 0; display: flex; @@ -2101,7 +2132,7 @@ background: rgba(255, 255, 255, 0.08); border: 1px solid rgba(255, 255, 255, 0.12); border-radius: 4px; - font-size: 11px; + font-size: @ai-text-meta; color: @project-panel-text-2; max-width: 150px; position: relative; @@ -2122,7 +2153,7 @@ background: rgba(255, 255, 255, 0.12); border: 1px solid rgba(255, 255, 255, 0.18); color: rgba(255, 255, 255, 0.7); - font-size: 11px; + font-size: @ai-text-meta; padding: 0; display: none; align-items: center; @@ -2163,7 +2194,7 @@ } i { - font-size: @sidebar-content-font-size; + font-size: @ai-text-body; } } @@ -2173,7 +2204,7 @@ background: transparent !important; border: none !important; color: @project-panel-text-1; - font-size: @sidebar-content-font-size; + font-size: @ai-text-body; font-family: inherit; padding: 7px 0 7px 10px; margin: 0; @@ -2209,7 +2240,7 @@ } i { - font-size: @sidebar-content-font-size; + font-size: @ai-text-body; } } @@ -2239,7 +2270,7 @@ } i { - font-size: @sidebar-content-font-size; + font-size: @ai-text-body; } } @@ -2263,7 +2294,7 @@ } i { - font-size: @sidebar-content-font-size; + font-size: @ai-text-body; } } @@ -2281,7 +2312,7 @@ padding: 8px 12px; cursor: pointer; color: @project-panel-text-1; - font-size: @sidebar-content-font-size; + font-size: @ai-text-body; white-space: nowrap; display: flex; align-items: center; @@ -2327,7 +2358,7 @@ border-radius: 4px; padding: 4px 12px; cursor: pointer; - font-size: 12px; + font-size: @ai-text-secondary; font-family: inherit; } @@ -2378,7 +2409,7 @@ } .ai-unavailable-message { - font-size: @sidebar-small-font-size; + font-size: @ai-text-secondary; line-height: 1.5; margin-bottom: 12px; opacity: 0.6; @@ -2400,7 +2431,7 @@ background: none; border: 1px solid rgba(255, 255, 255, 0.12); color: @project-panel-text-2; - font-size: @sidebar-small-font-size; + font-size: @ai-text-secondary; padding: 3px 12px; border-radius: 3px; cursor: pointer; @@ -2417,7 +2448,7 @@ border: none; color: #000; font-weight: 600; - font-size: @sidebar-small-font-size; + font-size: @ai-text-secondary; padding: 3px 12px; border-radius: 3px; cursor: pointer; @@ -2448,7 +2479,7 @@ } .ai-install-restart-note { - font-size: @sidebar-small-font-size; + font-size: @ai-text-secondary; color: @project-panel-text-2; opacity: 0.6; margin-top: 8px; @@ -2459,7 +2490,7 @@ /* ── AI Settings Dialog ────────────────────────────────────────────── */ .ai-settings-dialog { .ai-settings-section-label { - font-size: 12px; + font-size: @ai-text-secondary; font-weight: 600; color: @bc-text-medium; margin-bottom: 6px; @@ -2515,7 +2546,7 @@ } .ai-settings-provider-name { - font-size: 13px; + font-size: @ai-text-body; font-weight: 600; color: @bc-text; white-space: nowrap; @@ -2528,7 +2559,7 @@ } .ai-settings-provider-url { - font-size: 11px; + font-size: @ai-text-meta; color: @bc-text-quiet; white-space: nowrap; overflow: hidden; @@ -2567,7 +2598,7 @@ } .ai-settings-edit-error { - font-size: 12px; + font-size: @ai-text-secondary; color: @bc-error; min-height: 0; @@ -2588,7 +2619,7 @@ gap: 4px; label { - font-size: 12px; + font-size: @ai-text-secondary; font-weight: 600; color: @bc-text-medium; @@ -2606,7 +2637,7 @@ border-radius: @bc-border-radius; background: @bc-input-bg; color: @bc-text; - font-size: 13px; + font-size: @ai-text-body; outline: none; &:focus { @@ -2652,7 +2683,7 @@ background: rgba(76, 175, 80, 0.08); border: 1px solid rgba(76, 175, 80, 0.2); border-radius: 4px; - font-size: @sidebar-xs-font-size; + font-size: @ai-text-meta; color: @project-panel-text-2; text-align: center; } From 3feedd150ab9a8a44fa82df796642b45e375809c Mon Sep 17 00:00:00 2001 From: abose Date: Sun, 26 Apr 2026 20:47:58 +0530 Subject: [PATCH 18/19] feat(ai-chat): bump prose rhythm across panel and plan card Lifts body text to 14px/1.6, widens message and card padding, raises list-item gaps with explicit line-height, and applies the same prose rhythm to the proposed-plan body so plan cards no longer feel denser than the assistant prose around them. In-card buttons stay at the 12px/4x12 standard. --- src/styles/Extn-AIChatPanel.less | 124 ++++++++++++++++++++----------- 1 file changed, 80 insertions(+), 44 deletions(-) diff --git a/src/styles/Extn-AIChatPanel.less b/src/styles/Extn-AIChatPanel.less index 38b8678f86..d3f03e162e 100644 --- a/src/styles/Extn-AIChatPanel.less +++ b/src/styles/Extn-AIChatPanel.less @@ -24,11 +24,13 @@ // (or a relative unit for headings). Centralising the scale here keeps the // panel coherent across cards: tool indicators, diff cards, confirm dialogs, // the proposed plan, etc. all read as one product instead of independently -// styled fragments. -@ai-text-body: @sidebar-content-font-size; // 13px — message content, tool labels, prose +// styled fragments. Body is 14px (one tick larger than Phoenix's 13px sidebar +// default) so prose breathes more — closer to what users get from Lovable +// /ChatGPT/Claude desktop. Secondary and meta stay at the smaller tiers. +@ai-text-body: @menu-item-font-size; // 14px — message content, tool labels, prose @ai-text-secondary: @sidebar-small-font-size; // 12px — in-card buttons, mono code, file paths @ai-text-meta: @sidebar-xs-font-size; // 11px — stats, status, permission/context info -@ai-line-prose: 1.55; // multi-line body text +@ai-line-prose: 1.6; // multi-line body text — generous reading rhythm @ai-line-compact: 1.4; // single-line UI elements .ai-tab-container { @@ -268,7 +270,7 @@ flex: 1; overflow-y: auto; overflow-x: hidden; - padding: 8px 10px; + padding: 12px 14px; min-height: 0; min-width: 0; -webkit-user-select: text; @@ -279,7 +281,7 @@ /* ── Individual messages ────────────────────────────────────────────── */ .ai-msg { - margin-bottom: 12px; + margin-bottom: 16px; max-width: 100%; // Skip layout / paint for messages that are far off-screen. For a large // chat history (>1k messages, >10k DOM nodes), showing the sidebar after @@ -297,7 +299,7 @@ .ai-msg-content { font-size: @ai-text-body; - line-height: 1.55; + line-height: @ai-line-prose; white-space: normal; word-wrap: break-word; overflow-wrap: anywhere; @@ -320,8 +322,8 @@ .ai-msg-content { background-color: rgba(255, 255, 255, 0.07); - padding: 6px 10px; - border-radius: 10px 10px 2px 10px; + padding: 8px 12px; + border-radius: 12px 12px 2px 12px; max-width: 88%; text-align: left; } @@ -400,6 +402,7 @@ .ai-msg-assistant { .ai-msg-content { padding: 2px 0; + line-height: @ai-line-prose; > *:first-child { margin-top: 0; @@ -409,8 +412,10 @@ margin-bottom: 0; } + // Paragraph margin tuned to roughly match a single line of prose + // so paragraphs read as separated but still related. p { - margin: 0 0 8px 0; + margin: 0 0 14px 0; &:last-child { margin-bottom: 0; @@ -425,7 +430,7 @@ code { background-color: rgba(255, 255, 255, 0.08); color: @project-panel-text-1; - padding: 1px 4px; + padding: 1px 5px; border-radius: 3px; font-size: @sidebar-md-code-font-size; font-family: 'SourceCodePro-Medium', 'SourceCodePro', monospace; @@ -434,10 +439,10 @@ pre { background-color: rgba(0, 0, 0, 0.3); border: 1px solid rgba(255, 255, 255, 0.06); - padding: 8px 10px; + padding: 10px 12px; border-radius: 4px; overflow-x: auto; - margin: 6px 0; + margin: 10px 0 12px; position: relative; &.collapsed code { @@ -451,24 +456,39 @@ padding: 0; border-radius: 0; font-size: 1em; - line-height: 1.5; + line-height: 1.55; color: @project-panel-text-1; } } + // Lists: visible vertical gap between items so they breathe but + // stay grouped. Block-level margin top/bottom matches a paragraph. ul, ol { - margin: 4px 0 8px 0; - padding-left: 18px; + margin: 8px 0 14px 0; + padding-left: 20px; } li { - margin-bottom: 2px; + margin-bottom: 6px; + // Brackets' default li rule clamps line-height to ~18px which + // makes wrapped bullet text look squished against the prose + // around it. Restore the prose rhythm explicitly. + line-height: @ai-line-prose; + + &:last-child { + margin-bottom: 0; + } + + // Nested lists shouldn't double-space + > ul, > ol { + margin: 6px 0 0 0; + } } blockquote { border-left: 2px solid rgba(255, 255, 255, 0.12); - margin: 6px 0; - padding: 2px 10px; + margin: 10px 0; + padding: 4px 12px; color: @project-panel-text-2; } @@ -527,11 +547,14 @@ } /* ── Tool use indicator ─────────────────────────────────────────────── */ +// Tool indicators are factual breadcrumbs, not the main signal. Keep them +// visually quieter than prose so the eye lands on the assistant's text +// first. Background and border alphas tuned down from earlier values. .ai-msg-tool { - margin-bottom: 5px; + margin-bottom: 6px; border-radius: 4px; - background-color: rgba(255, 255, 255, 0.025); - border: 1px solid rgba(255, 255, 255, 0.04); + background-color: rgba(255, 255, 255, 0.02); + border: 1px solid rgba(255, 255, 255, 0.025); border-left: 2px solid var(--tool-color, @project-panel-text-2); // Add a small breather between consecutive tool indicators so a stack @@ -891,9 +914,9 @@ /* ── Edit summary card ──────────────────────────────────────────────── */ .ai-msg-edit-summary { border: 1px solid rgba(255, 255, 255, 0.08); - border-radius: 4px; - margin-bottom: 6px; - padding: 6px 10px; + border-radius: 6px; + margin: 8px 0 12px 0; + padding: 10px 12px; background-color: rgba(255, 255, 255, 0.025); .ai-edit-summary-header { @@ -1332,22 +1355,24 @@ } .ai-plan-body { - padding: 10px 12px; + padding: 14px 16px; font-size: @ai-text-body; color: @project-panel-text-1; - line-height: 1.5; + line-height: @ai-line-prose; white-space: normal; word-wrap: break-word; - max-height: 400px; + max-height: 460px; overflow-y: auto; p, ul, ol, pre { - margin-bottom: 8px; + margin-bottom: 14px; &:last-child { margin-bottom: 0; } } + // Heading top margin needs to be larger than paragraph margin so a + // section header reads as a section header rather than another bullet. h1, h2, h3, h4 { color: @project-panel-text-1; line-height: 1.25em; @@ -1355,16 +1380,16 @@ h1 { font-size: @sidebar-md-h1-font-size; - margin: 12px 0 4px 0; + margin: 18px 0 8px 0; padding-bottom: .3em; } h2 { font-size: @sidebar-md-h2-font-size; - margin: 10px 0 4px 0; + margin: 16px 0 8px 0; padding-bottom: .3em; } - h3 { font-size: @sidebar-md-h3-font-size; margin: 8px 0 4px 0; } - h4 { font-size: @sidebar-md-h4-font-size; margin: 6px 0 4px 0; opacity: 0.85; } + h3 { font-size: @sidebar-md-h3-font-size; margin: 14px 0 6px 0; } + h4 { font-size: @sidebar-md-h4-font-size; margin: 12px 0 6px 0; opacity: 0.85; } strong { color: @project-panel-text-1; @@ -1376,15 +1401,15 @@ blockquote { border-left: 3px solid rgba(107, 158, 255, 0.3); - margin: 6px 0; - padding: 2px 10px; + margin: 10px 0; + padding: 4px 12px; color: @project-panel-text-2; } code { background-color: rgba(255, 255, 255, 0.08); color: @project-panel-text-1; - padding: 1px 4px; + padding: 1px 5px; border-radius: 3px; font-size: @sidebar-md-code-font-size; font-family: 'SourceCodePro-Medium', 'SourceCodePro', monospace; @@ -1393,7 +1418,7 @@ pre { background-color: rgba(0, 0, 0, 0.3); border: 1px solid rgba(255, 255, 255, 0.06); - padding: 8px 10px; + padding: 10px 12px; border-radius: 4px; overflow-x: auto; position: relative; @@ -1407,18 +1432,29 @@ padding: 0; border-radius: 0; font-size: 1em; - line-height: 1.5; + line-height: 1.55; color: @project-panel-text-1; } } ul, ol { - margin: 4px 0 8px 0; - padding-left: 18px; + margin: 8px 0 14px 0; + padding-left: 22px; } li { - margin-bottom: 2px; + margin-bottom: 6px; + // Same line-height fix as the assistant message — Brackets' + // default li rule clamps line-height too tight otherwise. + line-height: @ai-line-prose; + + &:last-child { + margin-bottom: 0; + } + + > ul, > ol { + margin: 6px 0 0 0; + } } } @@ -2206,12 +2242,12 @@ color: @project-panel-text-1; font-size: @ai-text-body; font-family: inherit; - padding: 7px 0 7px 10px; + padding: 10px 0 10px 12px; margin: 0; resize: none; - min-height: 20px; - max-height: 96px; - line-height: 1.4; + min-height: 24px; + max-height: 120px; + line-height: @ai-line-compact; outline: none !important; box-shadow: none !important; From 9dcd1f0583d23cf495832f5798d9c0bb2c46f96f Mon Sep 17 00:00:00 2001 From: abose Date: Sun, 26 Apr 2026 20:50:04 +0530 Subject: [PATCH 19/19] build: update pro deps --- tracking-repos.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tracking-repos.json b/tracking-repos.json index 76237f047e..49eec234e4 100644 --- a/tracking-repos.json +++ b/tracking-repos.json @@ -1,5 +1,5 @@ { "phoenixPro": { - "commitID": "0718a817b04e481232f9eea94647edca7a46d49d" + "commitID": "d175c1fb02062cd8ad27a3f942162383c132c630" } }