From 56b241f8fb0f159999fdd77ae9c6fd3450fe37d7 Mon Sep 17 00:00:00 2001 From: abose Date: Sat, 10 Oct 2026 11:53:30 +0530 Subject: [PATCH 01/11] feat(ai): style the Surprise Me dismiss control Replace the card's chevron area with a separate dismiss button and divider. Keep the demo action independently clickable, and provide hover, keyboard-focus and localized accessible-label support. Verified the approved layout in the connected app. The full AI Chat CLI integration suite passes 42 checks; LESS compilation and lint pass. --- src/nls/root/strings.js | 1 + src/styles/Extn-AIChatPanel.less | 41 +++++++++++++++++++++++++++----- 2 files changed, 36 insertions(+), 6 deletions(-) diff --git a/src/nls/root/strings.js b/src/nls/root/strings.js index b9dbbaa27e..5bf6e5fa80 100644 --- a/src/nls/root/strings.js +++ b/src/nls/root/strings.js @@ -2667,6 +2667,7 @@ define({ "AI_CHAT_START_GREETING": "Hi {0},", "AI_CHAT_START_TITLE": "What are we building today?", "AI_CHAT_START_SURPRISE_TITLE": "Surprise Me", + "AI_CHAT_START_DISMISS_DEMO": "Dismiss demo", "AI_CHAT_START_SURPRISE_DESC": "Show me a demo of what you can do", "AI_CHAT_START_SURPRISE_CHOICE_DESC": "Watch another demo, or have me build something live", "AI_CHAT_START_MAKE_NEW": "Make something new", diff --git a/src/styles/Extn-AIChatPanel.less b/src/styles/Extn-AIChatPanel.less index 8485fde7ad..f43f511146 100644 --- a/src/styles/Extn-AIChatPanel.less +++ b/src/styles/Extn-AIChatPanel.less @@ -1228,11 +1228,18 @@ } } + .ai-start-surprise-row { + display: flex; + align-items: stretch; + } + .ai-start-surprise-head { display: flex; + flex: 1; + min-width: 0; align-items: center; gap: 12px; - width: 100%; + width: auto; margin: 0; padding: 12px; box-sizing: border-box; @@ -1241,7 +1248,7 @@ color: inherit; background: none; border: 0; - border-radius: 8px; + border-radius: 8px 0 0 8px; cursor: pointer; &:focus-visible { @@ -1250,6 +1257,32 @@ } } + .ai-start-surprise-dismiss { + flex: 0 0 46px; + width: 46px; + margin: 0; + padding: 0; + background: transparent; + border: 0; + border-left: 1px solid fade(@project-panel-text-2, 18%); + border-radius: 0 8px 8px 0; + color: @project-panel-text-2; + font-size: @ai-text-body; + box-shadow: none; + text-shadow: none; + cursor: pointer; + + &:hover { + color: @project-panel-text-1; + background: rgba(255, 255, 255, 0.06); + } + + &:focus-visible { + outline: 1px solid @ai-start-accent; + outline-offset: -3px; + } + } + .ai-start-surprise-choice, .ai-start-surprise-choice-desc { display: none; @@ -1272,10 +1305,6 @@ .ai-start-surprise-choice-desc { display: block; } - - .ai-start-chevron { - transform: rotate(90deg); - } } .ai-start-surprise-actions { From 76e56fbdf430d3529013d7d52cc18013e552435e Mon Sep 17 00:00:00 2001 From: abose Date: Sat, 10 Oct 2026 11:55:08 +0530 Subject: [PATCH 02/11] feat(ai): clarify account options on home cards Describe the Claude subscription or API key option for Visual AI and add localized subscription guidance for the default CLI cards. Verified all three subtitles in the connected app. Changed-file lint and whitespace checks pass. --- src/nls/root/strings.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/nls/root/strings.js b/src/nls/root/strings.js index 5bf6e5fa80..ae81889ce6 100644 --- a/src/nls/root/strings.js +++ b/src/nls/root/strings.js @@ -2689,8 +2689,8 @@ define({ "AI_CHAT_DEMO_SPEED_TITLE": "Playback speed (click to change)", "AI_CHAT_START_WORK_WITH": "Work with", "AI_CHAT_START_CHAT_NAME": "Visual AI with {0}", - "AI_CHAT_START_CHAT_DESC": "Built-in chat for visual workflows", - "AI_CHAT_START_BY_PROVIDER": "By {0}", + "AI_CHAT_START_CHAT_DESC": "Use your Claude subscription or API key", + "AI_CHAT_START_SUBSCRIPTION": "Use your {0} subscription", "AI_CHAT_START_CLI_SEPARATOR": "Command-line tools · connects with {APP_NAME}", "AI_CHAT_START_SETUP": "Set up", "AI_CHAT_START_CHAT_RUNNING": "In progress", From 8691b4519fb22a61e4695db28128a7ea0f78a2b0 Mon Sep 17 00:00:00 2001 From: abose Date: Sat, 10 Oct 2026 12:04:55 +0530 Subject: [PATCH 03/11] fix(ai): use account wording on home cards Replace subscription with account in the Visual AI and CLI descriptions, while retaining the API key option for Visual AI. Verified the localized strings and layout in the connected app. Changed-file lint and whitespace checks pass. --- src/nls/root/strings.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/nls/root/strings.js b/src/nls/root/strings.js index ae81889ce6..315db4afa3 100644 --- a/src/nls/root/strings.js +++ b/src/nls/root/strings.js @@ -2689,8 +2689,8 @@ define({ "AI_CHAT_DEMO_SPEED_TITLE": "Playback speed (click to change)", "AI_CHAT_START_WORK_WITH": "Work with", "AI_CHAT_START_CHAT_NAME": "Visual AI with {0}", - "AI_CHAT_START_CHAT_DESC": "Use your Claude subscription or API key", - "AI_CHAT_START_SUBSCRIPTION": "Use your {0} subscription", + "AI_CHAT_START_CHAT_DESC": "Use your Claude account or API key", + "AI_CHAT_START_ACCOUNT": "Use your {0} account", "AI_CHAT_START_CLI_SEPARATOR": "Command-line tools · connects with {APP_NAME}", "AI_CHAT_START_SETUP": "Set up", "AI_CHAT_START_CHAT_RUNNING": "In progress", From e570ad98555670de4e5ad62dfe4b7be3a979c3c9 Mon Sep 17 00:00:00 2001 From: abose Date: Sat, 10 Oct 2026 12:39:51 +0530 Subject: [PATCH 04/11] fix(ai): keep sign-in and demo actions together Place the demo and sign-in controls in equal-width columns and let the sign-in illustration scroll when vertical space is limited. Label the demo action See a demo with No sign-in needed as supporting text. Verified compact layouts and all 26 native Linux recorder/demo tests. LESS compilation, changed-file lint and whitespace checks pass. --- src/nls/root/strings.js | 3 ++- src/styles/Extn-AIChatPanel.less | 45 +++++++++++++++++++++++++++----- 2 files changed, 40 insertions(+), 8 deletions(-) diff --git a/src/nls/root/strings.js b/src/nls/root/strings.js index 315db4afa3..184d5a21db 100644 --- a/src/nls/root/strings.js +++ b/src/nls/root/strings.js @@ -2675,7 +2675,8 @@ define({ "AI_CHAT_START_MAKE_NEW_NOTE": "Making something new uses your AI credits. Pick demo to use none.", "AI_CHAT_START_DEMO_LINK": "Want a demo? {0}.", "AI_CHAT_START_DEMO_LINK_LABEL": "Surprise me", - "AI_CHAT_SEE_DEMO_DESC": "See a demo", + "AI_CHAT_SEE_DEMO_TITLE": "See a demo", + "AI_CHAT_SEE_DEMO_DESC": "No sign-in needed", "AI_CHAT_DEMO_NOTICE_OFFLINE": "You're offline, so this is a recorded demo.", "AI_CHAT_DEMO_BAR_TEXT": "Sign in to build your own with AI.", "AI_CHAT_DEMO_BACK_BTN": "Back", diff --git a/src/styles/Extn-AIChatPanel.less b/src/styles/Extn-AIChatPanel.less index f43f511146..c8a1501056 100644 --- a/src/styles/Extn-AIChatPanel.less +++ b/src/styles/Extn-AIChatPanel.less @@ -1776,8 +1776,7 @@ } } -/* Surprise Me on the sign-in screen: the start screen's hero card, there - playing a recorded build for someone who cannot use the panel yet. */ +/* Preview a recorded build before signing in. */ .ai-see-demo-card { display: flex; align-items: center; @@ -1826,11 +1825,6 @@ color: @project-panel-text-2; margin-top: 1px; } - - .ai-see-demo-chevron { - font-size: 11px; - color: fade(@project-panel-text-2, 60%); - } } /* Demo mode: the chat UI shown to someone signed out, to watch a recorded @@ -5404,6 +5398,43 @@ } } +// Keep both sign-in actions visible when the illustration exceeds the available height. +.ai-signin { + .ai-welcome-carousel { + flex-shrink: 1; + min-height: 0; + overflow-y: auto; + scrollbar-width: thin; + } + + .ai-placeholder-body { + flex-shrink: 0; + } +} + +.ai-chat-panel .ai-placeholder .ai-signin-actions { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 12px; + align-items: stretch; + flex: 0 0 auto; + width: 100%; + min-width: 0; + margin-top: 4px; + + > .ai-placeholder-cta, + > .ai-see-demo-card { + width: auto; + min-width: 0; + margin: 0; + box-sizing: border-box; + } + + .ai-see-demo-name { + overflow-wrap: anywhere; + } +} + .ai-welcome-carousel { flex: 0 0 auto; min-width: 0; From aa5faf09c29ddb7a94422617c5a5cf50b6c2ec70 Mon Sep 17 00:00:00 2001 From: abose Date: Sat, 10 Oct 2026 13:56:37 +0530 Subject: [PATCH 05/11] fix(builder): expose settings in enabled production builds Register the Builder MCP dialog when production instrumentation is enabled at startup. Show its diagnostics menu entry when the command is available and document the menu path. Verified the menu, connection settings, console logs and production tab in Windows. Confirmed disabled production builds keep the command hidden. Full core lint passed. --- phoenix-builder-mcp/README.md | 3 +++ src/extensions/default/DebugCommands/main.js | 4 +++- src/phoenix-builder/main.js | 22 +++++++++----------- 3 files changed, 16 insertions(+), 13 deletions(-) diff --git a/phoenix-builder-mcp/README.md b/phoenix-builder-mcp/README.md index 5ecbd1b862..60fa7d3975 100644 --- a/phoenix-builder-mcp/README.md +++ b/phoenix-builder-mcp/README.md @@ -167,6 +167,9 @@ for that local calendar day; run this command again on another day to renew it. disconnect an already running session. Start the Builder MCP server separately using the setup above; the script only manages the desktop app's permission file. +Once Builder is enabled at startup, production builds also show **Debug → Phoenix Code Diagnostic +Tools → Phoenix Builder MCP…** for connection settings and console logs. + ### Optional remote machines Use two independent MCP servers: **Phoenix Builder** for app interaction, screenshots and Jasmine tests, and **remote-control** for machine discovery, remote commands, file transfers, Git sync and agent coordination. Builder has no framework package dependency and opens no orchestrator agent session. Local Builder use needs no remote framework. diff --git a/src/extensions/default/DebugCommands/main.js b/src/extensions/default/DebugCommands/main.js index ab443718c5..541dc510fd 100644 --- a/src/extensions/default/DebugCommands/main.js +++ b/src/extensions/default/DebugCommands/main.js @@ -828,8 +828,10 @@ define(function (require, exports, module) { diagnosticsSubmenu.addMenuItem(DEBUG_RUN_UNIT_TESTS); CommandManager.register(Strings.CMD_BUILD_TESTS, DEBUG_BUILD_TESTS, TestBuilder.toggleTestBuilder, { supportsDesignMode: true }); diagnosticsSubmenu.addMenuItem(DEBUG_BUILD_TESTS); - if (AppConfig.config.environment === "dev") { + if (CommandManager.get("debug.phoenixBuilderConnect")) { diagnosticsSubmenu.addMenuItem("debug.phoenixBuilderConnect"); + } + if (AppConfig.config.environment === "dev") { diagnosticsSubmenu.addMenuItem("debug.debugOverrides"); } diagnosticsSubmenu.addMenuDivider(); diff --git a/src/phoenix-builder/main.js b/src/phoenix-builder/main.js index 185b263408..57cd76367b 100644 --- a/src/phoenix-builder/main.js +++ b/src/phoenix-builder/main.js @@ -25,7 +25,12 @@ define(function (require, exports, module) { const SystemConfigOverride = require("utils/SystemConfigOverride"), AppInit = require("utils/AppInit"), StatusBar = require("widgets/StatusBar"), - BuilderStrings = require("strings"); + CommandManager = require("command/CommandManager"), + Dialogs = require("widgets/Dialogs"), + Strings = require("strings"), + Mustache = require("thirdparty/mustache/mustache"), + PhoenixBuilderClient = require("./phoenix-builder-client"), + BuilderConnectTemplate = require("text!./builder-connect-dialog.html"); // Where the boot script looks to decide whether a non dev build may be // instrumented, see phoenix-builder-boot.js. @@ -101,13 +106,13 @@ define(function (require, exports, module) { if (!boot || !boot.setConnectionListener) { return; } - const $indicator = $("
").text(BuilderStrings.STATUSBAR_MCP_CONTROLLED); + const $indicator = $("
").text(Strings.STATUSBAR_MCP_CONTROLLED); let shown = false; boot.setConnectionListener(function (connected) { if (connected && !shown) { StatusBar.addIndicator(STATUS_INDICATOR_ID, $indicator, true, "mcp-controlled-indicator", - BuilderStrings.STATUSBAR_MCP_CONTROLLED_TOOLTIP); + Strings.STATUSBAR_MCP_CONTROLLED_TOOLTIP); shown = true; } else if (!connected && shown) { StatusBar.updateIndicator(STATUS_INDICATOR_ID, false); @@ -121,18 +126,11 @@ define(function (require, exports, module) { _watchInstrumentationState(); }); - // Only register the command in dev builds - if (!window.AppConfig || AppConfig.config.environment !== "dev") { + // Production boot creates the Builder only when enabled with today's admin permission. + if (!window.AppConfig || (AppConfig.config.environment !== "dev" && !window._phoenixBuilder)) { return; } - const CommandManager = require("command/CommandManager"), - Dialogs = require("widgets/Dialogs"), - Strings = require("strings"), - Mustache = require("thirdparty/mustache/mustache"), - PhoenixBuilderClient = require("./phoenix-builder-client"), - BuilderConnectTemplate = require("text!./builder-connect-dialog.html"); - const COMMAND_ID = "debug.phoenixBuilderConnect"; const DEFAULT_WS_URL = "ws://localhost:38571"; From 8fe610dac963307b2fc928b0ef4ad3f2432c3431 Mon Sep 17 00:00:00 2001 From: abose Date: Sat, 10 Oct 2026 14:15:41 +0530 Subject: [PATCH 06/11] fix(lsp): limit automatic setup retries and show failure details on demand Persist one automatic PHP or Python setup attempt per day, including self-repair. Keep manual installation available through a quiet Tasks entry after that limit. Replace raw failure toasts with a failed task and a copyable error-details dialog. Share in-flight installs so automatic repair cannot delete an active installation. Verified all 16 LSP installation specs on Linux browser and native Windows runners. Windows PHP and Python integration suites passed 7 and 8 specs respectively. Full core lint passed. Reviewed the failure task and dialog in the connected app. --- src/brackets.js | 3 + .../default/PHPSupport/ServerInstaller.js | 86 ++++---- src/extensions/default/PHPSupport/main.js | 8 +- .../default/PythonSupport/ServerInstaller.js | 88 ++++---- src/extensions/default/PythonSupport/main.js | 8 +- src/features/TaskManager.js | 3 + src/languageTools/LSPInstallSupport.js | 121 +++++++++++ src/nls/root/strings.js | 15 +- test/UnitTestSuite.js | 1 + test/spec/LSPInstall-test.js | 188 ++++++++++++++++++ 10 files changed, 436 insertions(+), 85 deletions(-) create mode 100644 src/languageTools/LSPInstallSupport.js create mode 100644 test/spec/LSPInstall-test.js diff --git a/src/brackets.js b/src/brackets.js index 137bfef671..50f8a6a79b 100644 --- a/src/brackets.js +++ b/src/brackets.js @@ -214,6 +214,9 @@ define(function (require, exports, module) { require("JSUtils/Session"); require("JSUtils/ScopeManager"); + // Shared setup policy/UI is lightweight; the language server clients themselves stay lazy. + require("languageTools/LSPInstallSupport"); + // Language Tools (LSP) are desktop-only and loaded lazily by languageTools/LSPClient the // first time a language server is started, so they are intentionally not required at boot. diff --git a/src/extensions/default/PHPSupport/ServerInstaller.js b/src/extensions/default/PHPSupport/ServerInstaller.js index 1414d1f707..c759143921 100644 --- a/src/extensions/default/PHPSupport/ServerInstaller.js +++ b/src/extensions/default/PHPSupport/ServerInstaller.js @@ -26,11 +26,11 @@ * pinned version is npm-installed AUTOMATICALLY on the first php file opened (autoInstall) from * the public registry into the app-data directory (the user's machine acquires it locally - the * license's intended use). Installation runs as a status-bar TASK (TaskManager) whose popup is - * opened so the download is always visible, with a stop (x) icon to cancel (cancel = no retry - * until the next app launch; the master pref is the durable opt-out). It uses the bundled npm + * opened so the download is always visible, with a stop (x) icon to cancel (automatic setup runs at most once + * per day; manual retry is always available). It uses the bundled npm * via the existing node plumbing (NodeUtils._npmInstallInFolder) - no system npm or PHP runtime * needed. Offline is a non-event: install silently waits for connectivity. Real failures roll - * back the partial tree, turn the task red (with a retry icon) and raise a toast. Works + * back the partial tree, leave a failed task with manual retry and copyable error details. Works * identically on Windows, macOS and Linux (no shell, no .bin shims). * * Layout under /lspServers/intelephense/: @@ -48,11 +48,10 @@ define(function (require, exports, module) { const NodeUtils = brackets.getModule("utils/NodeUtils"), - NotificationUI = brackets.getModule("widgets/NotificationUI"), + LSPInstallSupport = brackets.getModule("languageTools/LSPInstallSupport"), TaskManager = brackets.getModule("features/TaskManager"), PreferencesManager = brackets.getModule("preferences/PreferencesManager"), Metrics = brackets.getModule("utils/Metrics"), - StringUtils = brackets.getModule("utils/StringUtils"), Strings = brackets.getModule("strings"); // Exact tested pin - never a range. The authoritative value lives in src/config.json @@ -68,10 +67,11 @@ define(function (require, exports, module) { } const INTELEPHENSE_VERSION = _PINS.intelephense || "1.18.5"; const PREF_PHP_CODE_INTELLIGENCE = "codeIntelligence.php"; + const installSupport = LSPInstallSupport.create("php", Strings.PHP_INSTALL_TITLE, + ""); let _onInstalled = null; // main.js callback: ({entryPath, upgraded}) => void let _inFlight = null; // single-flight install promise - let _cancelledThisSession = false; // user hit the task's stop icon - no retry until relaunch let _cancelRequested = false; // stop clicked while an install is in flight let _activeNpmInstall = null; // cancellable handle of the in-flight npm install let _onlineRetryArmed = false; // one-shot window "online" retry listener armed @@ -174,24 +174,24 @@ define(function (require, exports, module) { }, { once: true }); } - // Real-failure surface (in addition to the red task): the user learns setup failed even with - // the Tasks popup closed. - function _showFailureToast(message) { - const $tpl = $("
").text(message); - NotificationUI.createToastFromTemplate(Strings.PHP_INSTALL_TITLE, $tpl, { - dismissOnClick: true, - toastStyle: NotificationUI.NOTIFICATION_STYLES_CSS_CLASS.SUBTLE, - autoCloseTimeS: 30, - instantOpen: true - }); - } - - async function _doInstall() { + /** + * Acquire the pinned server, optionally replacing a damaged installation. + * @param {boolean} repair Whether to remove the old installation before checking its state. + * @return {Promise} Installed server details, or null after a handled failure. + */ + async function _doInstall(repair) { + if (repair) { + installSupport.recordAttempt(); + await Phoenix.VFS.unlinkAsync(_installDirVfs()).catch(function () {}); + } const state = await installedState(); if (state.installed && state.pinMatches) { return { entryPath: getEntryPlatformPath(), upgraded: false }; } const upgrading = state.installed; + if (!repair) { + installSupport.recordAttempt(); + } _cancelRequested = false; // Status-bar task whose popup is OPENED for every install - with no consent dialog // anymore, visibility is the transparency: the user must be able to see any download we @@ -200,7 +200,6 @@ define(function (require, exports, module) { "", { progressPercent: 5, onStopClick: function () { - _cancelledThisSession = true; // no auto-retry until the next app launch _cancelRequested = true; if (_activeNpmInstall) { _activeNpmInstall.cancel().catch(function () {}); @@ -258,7 +257,7 @@ define(function (require, exports, module) { } if (_isNetworkError(err) || !navigator.onLine) { // QUIET path: offline is normal life, not an error. Retry when connectivity - // returns (same session) or on the next launch. + // returns, subject to the same daily limit. Metrics.countEvent("lsp", "phpInst", "waitNet"); task.setMessage(Strings.PHP_INSTALL_WAITING_NETWORK); setTimeout(task.close, 4000); @@ -268,12 +267,7 @@ define(function (require, exports, module) { console.error("[PHPSupport] install failed", err); Metrics.countEvent("lsp", "phpInst", "fail"); window.logger && window.logger.reportError(err, "[PHPSupport] LSP install failed"); - task.setFailed(); - task.setMessage(StringUtils.format(Strings.PHP_INSTALL_FAILED, message)); - task.showRestartIcon(); - task.show(); - _showFailureToast(StringUtils.format(Strings.PHP_INSTALL_FAILED, message)); - setTimeout(task.close, 30000); + installSupport.showFailure(task, err); return null; } finally { _activeNpmInstall = null; @@ -283,13 +277,14 @@ define(function (require, exports, module) { /** * Install (or upgrade) now - single-flighted. On success, notifies the onInstalled callback so * the language server can start immediately. + * @param {boolean} [repair=false] Remove the existing installation before setup. * @return {Promise} null on failure */ - function installNow() { + function _startInstall(repair = false) { if (_inFlight) { return _inFlight; } - _inFlight = _doInstall().then(function (result) { + _inFlight = _doInstall(repair).then(function (result) { if (result && _onInstalled) { _onInstalled(result); } @@ -300,13 +295,21 @@ define(function (require, exports, module) { return _inFlight; } + /** + * Start a user-requested installation, regardless of the daily automatic limit. + * @return {Promise} Installed server details, or null on failure. + */ + function installNow() { + return _startInstall(); + } + /** * Auto-install without any consent UI - the status-bar task (opened popup, stop icon) is the * announcement. Guards, in order: * - never in test windows (suites call installNow() explicitly; opening a .php fixture must * not start surprise downloads), * - master pref false = durable opt-out, - * - user cancelled this session = wait for the next launch, + * - today's attempt already ran = leave a quiet task with a manual install action, * - offline = wait silently for connectivity (one-shot window "online" retry). * @return {Promise} null when skipped/failed */ @@ -314,8 +317,14 @@ define(function (require, exports, module) { if (typeof Phoenix !== "undefined" && Phoenix.isTestWindow) { return Promise.resolve(null); } - if (_cancelledThisSession || - PreferencesManager.get(PREF_PHP_CODE_INTELLIGENCE) === false) { + if (PreferencesManager.get(PREF_PHP_CODE_INTELLIGENCE) === false) { + return Promise.resolve(null); + } + if (_inFlight) { + return _inFlight; + } + if (!installSupport.canAutoInstall()) { + installSupport.showManualInstall(installNow); return Promise.resolve(null); } if (!navigator.onLine) { @@ -328,12 +337,19 @@ define(function (require, exports, module) { /** * Wipe the whole install (including the index cache) and reacquire it - self-repair for a * supposedly-installed server that fails to start (entry corrupt beyond the existence check). + * @param {boolean} [automatic=false] Apply the daily limit to background self-repair. * The onInstalled callback re-registers the server on success. * @return {Promise} null on failure */ - async function repairInstall() { - await Phoenix.VFS.unlinkAsync(_installDirVfs()).catch(function () {}); - return installNow(); + function repairInstall(automatic = false) { + if (_inFlight) { + return _inFlight; + } + if (automatic && !installSupport.canAutoInstall()) { + installSupport.showManualInstall(repairInstall); + return Promise.resolve(null); + } + return _startInstall(true); } /** diff --git a/src/extensions/default/PHPSupport/main.js b/src/extensions/default/PHPSupport/main.js index 51c2d17314..1727864234 100644 --- a/src/extensions/default/PHPSupport/main.js +++ b/src/extensions/default/PHPSupport/main.js @@ -66,7 +66,7 @@ define(function (require, exports, module) { let starting = false; let pendingRepoint = false; let initErrorReported = false; - let _repairAttempted = false; // one self-repair (wipe + reinstall) per session + let _repairAttempted = false; // one self-repair request per session, subject to the daily install limit function canRun() { return Phoenix.isNativeApp && NodeConnector.isNodeAvailable(); @@ -141,7 +141,7 @@ define(function (require, exports, module) { const state = await ServerInstaller.installedState(); if (!state.installed || !state.pinMatches) { // acquire (or upgrade) the server automatically - announced only through the - // status-bar task. autoInstall's guards (pref off, user cancelled this session, + // status-bar task. autoInstall's guards (pref off, daily attempt already used, // offline, test window) decide whether anything actually runs; its onInstalled // callback registers the server. await ServerInstaller.autoInstall(); @@ -150,12 +150,12 @@ define(function (require, exports, module) { await _registerServer(ServerInstaller.getEntryPlatformPath(), false); if (!registered && !_repairAttempted && !Phoenix.isTestWindow) { // Self-repair: the entry existed but the server would not start (tree corrupt beyond - // the existence check). Wipe and reacquire once per session - the onInstalled + // the existence check). Wipe and reacquire within the daily install limit - the onInstalled // callback re-registers on success. A second failure surfaces through the // installer's normal failure UX. _repairAttempted = true; console.error("[PHPSupport] installed server failed to start - reinstalling"); - await ServerInstaller.repairInstall(); + await ServerInstaller.repairInstall(true); } } diff --git a/src/extensions/default/PythonSupport/ServerInstaller.js b/src/extensions/default/PythonSupport/ServerInstaller.js index 1c603a2edf..a5107948ae 100644 --- a/src/extensions/default/PythonSupport/ServerInstaller.js +++ b/src/extensions/default/PythonSupport/ServerInstaller.js @@ -30,10 +30,10 @@ * wheels for this platform+arch are resolved through the PyPI JSON API, downloaded, * sha256-verified and extracted - all node-side (NodeUtils.downloadFile/extractZipFile; a wheel * is a plain zip). The install is announced only through the status-bar task, whose popup is - * opened so the download is always visible, with a stop (x) icon to cancel it (cancel = no - * retry until the next app launch; the master pref is the durable opt-out). Offline is a + * opened so the download is always visible, with a stop (x) icon to cancel it (automatic setup runs at most + * once per day; manual retry is always available). Offline is a * non-event: install silently waits for connectivity. Real failures roll back the partial - * install, turn the task red (with a retry icon) and raise a toast. Works identically on + * install, leave a failed task with manual retry and copyable error details. Works identically on * Windows, macOS and Linux. * * Layout under /lspServers/: @@ -53,11 +53,10 @@ define(function (require, exports, module) { const NodeUtils = brackets.getModule("utils/NodeUtils"), - NotificationUI = brackets.getModule("widgets/NotificationUI"), + LSPInstallSupport = brackets.getModule("languageTools/LSPInstallSupport"), TaskManager = brackets.getModule("features/TaskManager"), PreferencesManager = brackets.getModule("preferences/PreferencesManager"), Metrics = brackets.getModule("utils/Metrics"), - StringUtils = brackets.getModule("utils/StringUtils"), Strings = brackets.getModule("strings"); // Exact tested pins - never ranges. The wheels are sha256-verified against PyPI's manifest, @@ -75,6 +74,8 @@ define(function (require, exports, module) { const PYREFLY_VERSION = _PINS.pyrefly || "1.1.1"; const RUFF_VERSION = _PINS.ruff || "0.15.20"; const PREF_PYTHON_CODE_INTELLIGENCE = "codeIntelligence.python"; + const installSupport = LSPInstallSupport.create("python", Strings.PYTHON_INSTALL_TITLE, + ""); // The independently pinned/installed pieces. `pkg` is the PyPI package name; the binary of a // wheel build lives at -.data/scripts/[.exe]. @@ -85,7 +86,6 @@ define(function (require, exports, module) { let _onInstalled = null; // main.js callback: ({binaryPath, upgraded}) => void let _inFlight = null; // single-flight install promise - let _cancelledThisSession = false; // user hit the task's stop icon - no retry until relaunch let _cancelRequested = false; // stop clicked while an install is in flight let _activeDownload = null; // cancellable handle of the in-flight NodeUtils.downloadFile let _onlineRetryArmed = false; // one-shot window "online" retry listener armed @@ -238,18 +238,6 @@ define(function (require, exports, module) { }, { once: true }); } - // Real-failure surface (per product decision): a subtle toast in addition to the red task, - // so the user learns setup failed even with the Tasks popup closed. - function _showFailureToast(message) { - const $tpl = $("
").text(message); - NotificationUI.createToastFromTemplate(Strings.PYTHON_INSTALL_TITLE, $tpl, { - dismissOnClick: true, - toastStyle: NotificationUI.NOTIFICATION_STYLES_CSS_CLASS.SUBTLE, - autoCloseTimeS: 30, - instantOpen: true - }); - } - // Download + extract one unit's wheel, reporting progress in [pctFrom, pctTo] of `task`. async function _installUnit(unit, tag, task, pctFrom, pctTo) { const wheel = await _resolveWheel(unit, tag); @@ -290,12 +278,26 @@ define(function (require, exports, module) { task.setProgressPercent(pctTo); } - async function _doInstall() { + /** + * Acquire the pinned server, optionally replacing a damaged installation. + * @param {boolean} repair Whether to remove the old installation before checking its state. + * @return {Promise} Installed server details, or null after a handled failure. + */ + async function _doInstall(repair) { + if (repair) { + installSupport.recordAttempt(); + for (const key of Object.keys(UNITS)) { + await Phoenix.VFS.unlinkAsync(_installDirVfs(UNITS[key])).catch(function () {}); + } + } const state = await installedState(); if (state.installed && state.pinMatches) { return { binaryPath: getBinaryPlatformPath(), upgraded: false }; } const upgrading = state.installed; + if (!repair) { + installSupport.recordAttempt(); + } _cancelRequested = false; let currentUnit = null; // the unit being installed, for rollback on failure/cancel // Status-bar task with real download progress. Its popup is OPENED for every install - @@ -305,7 +307,6 @@ define(function (require, exports, module) { "", { progressPercent: 2, onStopClick: function () { - _cancelledThisSession = true; // no auto-retry until the next app launch _cancelRequested = true; if (_activeDownload) { _activeDownload.cancel().catch(function () {}); @@ -359,7 +360,7 @@ define(function (require, exports, module) { } if (_isNetworkError(err) || !navigator.onLine) { // QUIET path: offline is normal life, not an error. Retry when connectivity - // returns (same session) or on the next launch. + // returns, subject to the same daily limit. Metrics.countEvent("lsp", "pyInst", "waitNet"); task.setMessage(Strings.PYTHON_INSTALL_WAITING_NETWORK); setTimeout(task.close, 4000); @@ -369,12 +370,7 @@ define(function (require, exports, module) { console.error("[PythonSupport] install failed", err); Metrics.countEvent("lsp", "pyInst", "fail"); window.logger && window.logger.reportError(err, "[PythonSupport] LSP install failed"); - task.setFailed(); - task.setMessage(StringUtils.format(Strings.PYTHON_INSTALL_FAILED, message)); - task.showRestartIcon(); - task.show(); - _showFailureToast(StringUtils.format(Strings.PYTHON_INSTALL_FAILED, message)); - setTimeout(task.close, 30000); + installSupport.showFailure(task, err); return null; } finally { _activeDownload = null; @@ -384,13 +380,14 @@ define(function (require, exports, module) { /** * Install (or upgrade) now - single-flighted. On success, notifies the onInstalled callback so * the language server can start immediately. + * @param {boolean} [repair=false] Remove the existing installation before setup. * @return {Promise} null on failure */ - function installNow() { + function _startInstall(repair = false) { if (_inFlight) { return _inFlight; } - _inFlight = _doInstall().then(function (result) { + _inFlight = _doInstall(repair).then(function (result) { if (result && _onInstalled) { _onInstalled(result); } @@ -401,13 +398,21 @@ define(function (require, exports, module) { return _inFlight; } + /** + * Start a user-requested installation, regardless of the daily automatic limit. + * @return {Promise} Installed server details, or null on failure. + */ + function installNow() { + return _startInstall(); + } + /** * Auto-install without any consent UI - the status-bar task (opened popup, stop icon) is the * announcement. Guards, in order: * - never in test windows (suites call installNow() explicitly; opening a .py fixture must * not start surprise downloads), * - master pref false = durable opt-out, - * - user cancelled this session = wait for the next launch, + * - today's attempt already ran = leave a quiet task with a manual install action, * - offline = wait silently for connectivity (one-shot window "online" retry). * @return {Promise} null when skipped/failed */ @@ -415,8 +420,14 @@ define(function (require, exports, module) { if (typeof Phoenix !== "undefined" && Phoenix.isTestWindow) { return Promise.resolve(null); } - if (_cancelledThisSession || - PreferencesManager.get(PREF_PYTHON_CODE_INTELLIGENCE) === false) { + if (PreferencesManager.get(PREF_PYTHON_CODE_INTELLIGENCE) === false) { + return Promise.resolve(null); + } + if (_inFlight) { + return _inFlight; + } + if (!installSupport.canAutoInstall()) { + installSupport.showManualInstall(installNow); return Promise.resolve(null); } if (!navigator.onLine) { @@ -430,13 +441,18 @@ define(function (require, exports, module) { * Wipe the whole install and reacquire it - self-repair for a supposedly-installed server * that fails to start (binary corrupt beyond the existence checks). The onInstalled callback * re-registers the server on success. + * @param {boolean} [automatic=false] Apply the daily limit to background self-repair. * @return {Promise} null on failure */ - async function repairInstall() { - for (const key of Object.keys(UNITS)) { - await Phoenix.VFS.unlinkAsync(_installDirVfs(UNITS[key])).catch(function () {}); + function repairInstall(automatic = false) { + if (_inFlight) { + return _inFlight; } - return installNow(); + if (automatic && !installSupport.canAutoInstall()) { + installSupport.showManualInstall(repairInstall); + return Promise.resolve(null); + } + return _startInstall(true); } /** diff --git a/src/extensions/default/PythonSupport/main.js b/src/extensions/default/PythonSupport/main.js index 6661d69005..a60b91d097 100644 --- a/src/extensions/default/PythonSupport/main.js +++ b/src/extensions/default/PythonSupport/main.js @@ -55,7 +55,7 @@ define(function (require, exports, module) { let starting = false; let pendingRepoint = false; let initErrorReported = false; - let _repairAttempted = false; // one self-repair (wipe + reinstall) per session + let _repairAttempted = false; // one self-repair request per session, subject to the daily install limit let _client = null; function canRun() { @@ -129,7 +129,7 @@ define(function (require, exports, module) { const state = await ServerInstaller.installedState(); if (!state.installed || !state.pinMatches) { // acquire (or complete/upgrade) the tooling automatically - announced only through - // the status-bar task. autoInstall's guards (pref off, user cancelled this session, + // the status-bar task. autoInstall's guards (pref off, daily attempt already used, // offline, test window) decide whether anything actually runs; its onInstalled // callback registers the server. await ServerInstaller.autoInstall(); @@ -138,12 +138,12 @@ define(function (require, exports, module) { await _registerServer(ServerInstaller.getBinaryPlatformPath()); if (!registered && !_repairAttempted && !Phoenix.isTestWindow) { // Self-repair: the marker said installed but the server would not start (binary - // corrupt beyond the existence checks). Wipe and reacquire once per session - the + // corrupt beyond the existence checks). Wipe and reacquire within the daily install limit - the // onInstalled callback re-registers on success. A second failure surfaces through // the installer's normal failure UX. _repairAttempted = true; console.error("[PythonSupport] installed server failed to start - reinstalling"); - await ServerInstaller.repairInstall(); + await ServerInstaller.repairInstall(true); } } diff --git a/src/features/TaskManager.js b/src/features/TaskManager.js index 31b558f8d5..e1a1855532 100644 --- a/src/features/TaskManager.js +++ b/src/features/TaskManager.js @@ -237,6 +237,7 @@ define(function (require, exports, module) { if(!task._$html){ return; } + $html.find(".progress-bar-background").toggle(!task._hideProgressBar); $html.find(".progress") .removeClass("progress-bar-foreground") .removeClass("progress-bar-foreground-pulse") @@ -346,6 +347,7 @@ define(function (require, exports, module) { * @param {Function} [options.onRetryClick] - Callback function triggered when the retry button is clicked. * @param {Function} [options.onSelect] - Callback function triggered when the task is selected from the dropdown. * @param {number} [options.progressPercent] - Initial progress percentage of the task. + * @param {boolean} [options.hideProgressBar] - Hide progress for tasks waiting on a user action. * @param {boolean} [options.noSpinnerNotification] - If set to true, will not show the task spinners for this task. * This can be used for silent background tasks where user attention is not needed. * @returns {TaskObject} Returns a task object with methods for updating the task's state and UI representation, @@ -403,6 +405,7 @@ define(function (require, exports, module) { onRetryClick: options && options.onRetryClick, onSelect: options && options.onSelect, _percent: options && options.progressPercent, + _hideProgressBar: options && options.hideProgressBar, _completedStatus: STATUS_INCOMPLETE, _iconHTML: iconHTML, _noSpinnerNotification: options && options.noSpinnerNotification, diff --git a/src/languageTools/LSPInstallSupport.js b/src/languageTools/LSPInstallSupport.js new file mode 100644 index 0000000000..6584ec8298 --- /dev/null +++ b/src/languageTools/LSPInstallSupport.js @@ -0,0 +1,121 @@ +/* + * Copyright (c) 2026 core.ai + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +define(function (require, exports, module) { + const TaskManager = require("features/TaskManager"), + Dialogs = require("widgets/Dialogs"), + Strings = require("strings"), + _ = require("thirdparty/lodash"); + + /** @return {string} Today's local calendar date, shared across app restarts. */ + function today() { + const now = new Date(); + return now.getFullYear() + "-" + (now.getMonth() + 1) + "-" + now.getDate(); + } + + /** + * Keep automatic setup to one attempt per day and leave manual recovery in the task list. + * @param {string} id Stable language/tool identifier. + * @param {string} title Localized task and error dialog title. + * @param {string} iconHTML Task icon markup. + * @return {Object} Install attempt policy and task helpers. + */ + function create(id, title, iconHTML) { + const storageKey = "lsp-install-" + id; + let recoveryTask = null; + + /** @return {Object} The last attempt and optional error, without affecting other tools. */ + function readState() { + try { + const state = JSON.parse(PhStore.getItem(storageKey) || "null"); + return state && typeof state === "object" ? state : {}; + } catch (err) { + return {}; + } + } + + /** @return {boolean} Whether automatic setup may start today. */ + function canAutoInstall() { + return readState().date !== today(); + } + + /** Remove an old recovery task before starting a new attempt or after dismissal. */ + function clearTask() { + if (recoveryTask) { + recoveryTask.close(); + recoveryTask = null; + } + } + + /** Record actual attempts, including manual retries, before downloads or file changes. */ + function recordAttempt() { + clearTask(); + PhStore.setItem(storageKey, JSON.stringify({ date: today() })); + } + + /** Show technical details only when requested; Copy leaves the dialog open. */ + function showDetails() { + const error = readState().error; + if (!error) { + return; + } + const dialog = Dialogs.showModalDialog("lsp-install-error", title, + "

" + _.escape(Strings.LSP_INSTALL_ERROR_DETAILS) + "

" + + '
' +
+                _.escape(error) + "
", [ + { className: "left", id: "copy", text: Strings.COPY_ERROR }, + { className: "primary", id: Dialogs.DIALOG_BTN_OK, text: Strings.OK } + ], false); + dialog.getElement().on("buttonClick", function (event, buttonId) { + if (buttonId === "copy") { + Phoenix.app.copyToClipboard(error); + } else { + dialog.close(); + } + }); + } + + /** + * Reuse one quiet task when today's attempt has already run. + * @param {function(): Promise} install Explicit install/retry action, unaffected by the daily limit. + */ + function showManualInstall(install) { + if (recoveryTask) { + return; + } + recoveryTask = TaskManager.addNewTask(title, Strings.LSP_INSTALL_MANUAL, iconHTML, { + noSpinnerNotification: true, + hideProgressBar: true, + onRetryClick: function () { clearTask(); install(); }, + onStopClick: clearTask, + onSelect: showDetails + }); + recoveryTask.showRestartIcon(Strings.LSP_INSTALL_NOW); + recoveryTask.showStopIcon(Strings.CLOSE); + } + + /** + * Keep a failed task actionable without a toast or raw error text in the task list. + * @param {Object} task The existing installation task. + * @param {Error} error Failure whose details can be copied on request. + */ + function showFailure(task, error) { + const state = readState(); + state.error = (error && error.message) || String(error); + PhStore.setItem(storageKey, JSON.stringify(state)); + recoveryTask = task; + task.onSelect = showDetails; + task.onStopClick = clearTask; + task.setFailed(); + task.setMessage(Strings.LSP_INSTALL_FAILED); + task.showRestartIcon(Strings.LSP_INSTALL_NOW); + task.showStopIcon(Strings.CLOSE); + } + + return { canAutoInstall, recordAttempt, showManualInstall, showFailure }; + } + + exports.create = create; +}); diff --git a/src/nls/root/strings.js b/src/nls/root/strings.js index 184d5a21db..444fc7aa9b 100644 --- a/src/nls/root/strings.js +++ b/src/nls/root/strings.js @@ -1923,23 +1923,26 @@ define({ "CODE_INTEL_PANEL_TEXT": "Project-wide code intelligence is off. Enable it for Find Usages, Rename, and Go to Definition across every file — adds a jsconfig.json to the project root.", "CODE_INTEL_PANEL_ENABLE": "Enable", "CODE_INTEL_PANEL_DISMISS": "Dismiss", + // On-demand language tool installation + "LSP_INSTALL_FAILED": "Setup failed. Select this task for details, or retry the installation.", + "LSP_INSTALL_ERROR_DETAILS": "Setup could not be completed. You can copy the details below when reporting the problem.", + "LSP_INSTALL_MANUAL": "Automatic setup is paused for today. You can install manually at any time.", + "LSP_INSTALL_NOW": "Install now", // PHP language support (PHPSupport) "PHP_INSTALL_TITLE": "PHP Code Intelligence", "PHP_INSTALLING": "Setting up PHP support — downloading the language server (9 MB)…", "PHP_INSTALL_DONE": "PHP code intelligence is ready.", - "PHP_INSTALL_FAILED": "Could not set up PHP support: {0}. {APP_NAME} will retry when you open a PHP file again.", - "PHP_INSTALL_STOP": "Stop setting up PHP support ({APP_NAME} will try again on the next launch)", + "PHP_INSTALL_STOP": "Stop setting up PHP support (you can retry from Tasks)", "PHP_INSTALL_WAITING_NETWORK": "Waiting for an internet connection to set up PHP support…", - "DESCRIPTION_PHP_CODE_INTELLIGENCE": "false to disable PHP code intelligence (Intelephense). Setting it back to true downloads the language server again automatically.", + "DESCRIPTION_PHP_CODE_INTELLIGENCE": "false to disable PHP code intelligence (Intelephense). Missing language tools are installed automatically, at most once per day.", "DESCRIPTION_PHP_LICENSE_KEY": "Intelephense premium licence key (or an absolute path to a key file) enabling premium features like rename and code actions. Not needed if your licence is already in the standard global intelephense licence file.", // Python language support (PythonSupport) "PYTHON_INSTALL_TITLE": "Python Code Intelligence", "PYTHON_INSTALLING": "Setting up Python support — downloading the language tools (25 MB)…", "PYTHON_INSTALL_DONE": "Python code intelligence is ready.", - "PYTHON_INSTALL_FAILED": "Could not set up Python support: {0}. {APP_NAME} will retry when you open a Python file again.", - "PYTHON_INSTALL_STOP": "Stop setting up Python support ({APP_NAME} will try again on the next launch)", + "PYTHON_INSTALL_STOP": "Stop setting up Python support (you can retry from Tasks)", "PYTHON_INSTALL_WAITING_NETWORK": "Waiting for an internet connection to set up Python support…", - "DESCRIPTION_PYTHON_CODE_INTELLIGENCE": "false to disable Python code intelligence (Pyrefly). Setting it back to true downloads the language server again automatically.", + "DESCRIPTION_PYTHON_CODE_INTELLIGENCE": "false to disable Python code intelligence (Pyrefly). Missing language tools are installed automatically, at most once per day.", // Language server on/off switches (LSPClient and the built-in language support extensions) "DESCRIPTION_TYPESCRIPT_CODE_INTELLIGENCE": "false to disable JavaScript/TypeScript code intelligence (completion, hover docs, jump to definition, diagnostics)", "DESCRIPTION_JSON_CODE_INTELLIGENCE": "false to disable JSON code intelligence (schema based completion, hover docs and validation)", diff --git a/test/UnitTestSuite.js b/test/UnitTestSuite.js index c7ea5e6432..3828f7da42 100644 --- a/test/UnitTestSuite.js +++ b/test/UnitTestSuite.js @@ -70,6 +70,7 @@ define(function (require, exports, module) { require("spec/LanguageManager-test"); require("spec/LanguageManager-integ-test"); require("spec/LanguageToolsQuickFix-test"); + require("spec/LSPInstall-test"); require("spec/LowLevelFileIO-test"); require("spec/Metrics-test"); require("spec/MultiRangeInlineEditor-test"); diff --git a/test/spec/LSPInstall-test.js b/test/spec/LSPInstall-test.js new file mode 100644 index 0000000000..a2de35fb25 --- /dev/null +++ b/test/spec/LSPInstall-test.js @@ -0,0 +1,188 @@ +/* + * Copyright (c) 2026 core.ai + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +/*global describe, it, expect, jasmine */ + +define(function (require, exports, module) { + const supportSource = require("text!languageTools/LSPInstallSupport.js"), + phpSource = require("text!extensions/default/PHPSupport/ServerInstaller.js"), + pythonSource = require("text!extensions/default/PythonSupport/ServerInstaller.js"), + Strings = require("strings"), + _ = require("thirdparty/lodash"); + + /** + * Run the shipped modules with isolated storage, tasks and failed downloads. No real server + * installation or browser state is touched; reloading keeps only the fixture's persisted state. + * @param {string} source Installer module source. + * @return {Object} Installer, task/dialog observations and controllable clock/network. + */ + function createHarness(source) { + const storage = new Map(); + const tasks = []; + const dialogs = []; + const state = { attempts: 0, removed: [], day: 10, online: true, enabled: true }; + const error = new Error("fixture failure "); + const copy = jasmine.createSpy("copy"); + const modules = { + strings: Strings, + "thirdparty/lodash": _, + "utils/Metrics": { countEvent() {} }, + "preferences/PreferencesManager": { get: () => state.enabled }, + "utils/NodeUtils": { + _npmInstallInFolder: async () => { state.attempts++; throw error; } + }, + "features/TaskManager": { + addNewTask(title, message, icon, options) { + const task = Object.assign({ title, message, options, + show: jasmine.createSpy("show"), close: jasmine.createSpy("close"), + setFailed: jasmine.createSpy("failed"), setSucceeded() {}, setProgressPercent() {}, + setMessage(value) { this.message = value; }, showRestartIcon() {}, showStopIcon() {} + }, options); + tasks.push(task); + return task; + } + }, + "widgets/Dialogs": { + DIALOG_BTN_OK: "ok", + showModalDialog(kind, title, message) { + const dialog = { message, close: jasmine.createSpy("close"), + getElement: () => ({ on: (event, callback) => { dialog.onButton = callback; } }) }; + dialogs.push(dialog); + return dialog; + } + } + }; + const platform = { + isTestWindow: false, + app: { getPlatformArch: async () => "x64", copyToClipboard: copy }, + fs: { getTauriPlatformPath: path => path }, + VFS: { + getAppSupportDir: () => "/fixture/", + existsAsync: async () => false, + unlinkAsync: async path => { state.removed.push(path); }, + ensureExistsDirAsync: async () => {}, writeFileAsync: async () => {} + } + }; + const browser = { addEventListener(type, callback) { state.onOnline = callback; } }; + const fixtureBrackets = { getModule: id => modules[id], platform: "linux", + config: { lsp_server_pins: { intelephense: "1.18.5", pyrefly: "1.1.1", ruff: "0.15.20" } } }; + const store = { getItem: key => storage.get(key), setItem: (key, value) => storage.set(key, value) }; + class FixtureDate extends Date { + constructor() { super(2026, 9, state.day, 12); } + } + + /** @return {Object} Fresh module instance bound to this fixture's dependencies. */ + function load(moduleSource) { + const result = {}; + const define = factory => factory(id => modules[id], result, { exports: result }); + const execute = eval("(function(define, brackets, Phoenix, PhStore, window, navigator, " + + "fetch, console, setTimeout, Date) {\n" + moduleSource + "\n})"); + execute(define, fixtureBrackets, platform, store, browser, { get onLine() { return state.online; } }, + async () => { state.attempts++; throw error; }, { error() {} }, () => {}, FixtureDate); + return result; + } + modules["languageTools/LSPInstallSupport"] = load(supportSource); + return { state, tasks, dialogs, copy, error, reload: () => load(source), installer: load(source), platform }; + } + + describe("unit:LSP Installation", function () { + [["PHP", phpSource], ["Python", pythonSource]].forEach(function ([name, source]) { + it(name + " attempts automatic setup only once across file switches and app restarts", async function () { + const h = createHarness(source); + await h.installer.autoInstall(); + await h.installer.autoInstall(); + expect(h.state.attempts).toBe(1); + expect(h.tasks.length).toBe(1); + expect(h.tasks[0].show.calls.count()).toBe(1); + const restarted = h.reload(); + await restarted.autoInstall(); + await restarted.autoInstall(); + expect(h.state.attempts).toBe(1); + expect(h.tasks.length).toBe(2); + expect(h.tasks[1].options.noSpinnerNotification).toBe(true); + expect(h.tasks[1].show).not.toHaveBeenCalled(); + }); + + it(name + " allows a fresh automatic attempt on the next day", async function () { + const h = createHarness(source); + await h.installer.autoInstall(); + h.state.day++; + await h.installer.autoInstall(); + expect(h.state.attempts).toBe(2); + }); + + it(name + " keeps the quiet task's manual install action available today", async function () { + const h = createHarness(source); + await h.installer.autoInstall(); + const restarted = h.reload(); + await restarted.autoInstall(); + h.tasks[1].onRetryClick(); + await restarted.installNow(); + expect(h.state.attempts).toBe(2); + expect(h.tasks[1].close).toHaveBeenCalled(); + }); + + it(name + " shares an in-flight install with file switches and repair requests", async function () { + const h = createHarness(source); + const first = h.installer.autoInstall(); + expect(h.installer.autoInstall()).toBe(first); + expect(h.installer.repairInstall(true)).toBe(first); + await first; + expect(h.state.attempts).toBe(1); + }); + + it(name + " does not consume an attempt while offline or disabled", async function () { + const h = createHarness(source); + h.state.enabled = false; + await h.installer.autoInstall(); + h.state.enabled = true; + h.state.online = false; + await h.installer.autoInstall(); + expect(h.state.attempts).toBe(0); + expect(h.tasks.length).toBe(0); + h.state.online = true; + h.state.onOnline(); + await h.installer.installNow(); + expect(h.state.attempts).toBe(1); + }); + + it(name + " blocks automatic repair before deleting files but permits manual repair", async function () { + const h = createHarness(source); + await h.installer.autoInstall(); + const deleted = h.state.removed.length; + await h.installer.repairInstall(true); + expect(h.state.removed.length).toBe(deleted); + expect(h.state.attempts).toBe(1); + await h.installer.repairInstall(); + expect(h.state.removed.length).toBeGreaterThan(deleted); + expect(h.state.attempts).toBe(2); + }); + + it(name + " reveals copyable error details only when its failed task is selected", async function () { + const h = createHarness(source); + await h.installer.autoInstall(); + expect(h.dialogs.length).toBe(0); + expect(h.tasks[0].message).not.toContain(h.error.message); + h.tasks[0].onSelect(); + const dialog = h.dialogs[0]; + expect(dialog.message).toContain(_.escape(h.error.message)); + expect(dialog.message).not.toContain(""); + dialog.onButton({}, "copy"); + expect(h.copy).toHaveBeenCalledWith(h.error.message); + expect(dialog.close).not.toHaveBeenCalled(); + dialog.onButton({}, "ok"); + expect(dialog.close).toHaveBeenCalled(); + }); + + it(name + " never starts automatic downloads in test windows", async function () { + const h = createHarness(source); + h.platform.isTestWindow = true; + await h.installer.autoInstall(); + expect(h.state.attempts).toBe(0); + expect(h.tasks.length).toBe(0); + }); + }); + }); +}); From e2f69d4a3f7413dba5ce4e94eb096ccb62b1a8ba Mon Sep 17 00:00:00 2001 From: abose Date: Sat, 10 Oct 2026 15:15:20 +0530 Subject: [PATCH 07/11] fix: build brak due to size increase --- gulpfile.js/validate-build.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gulpfile.js/validate-build.js b/gulpfile.js/validate-build.js index edfd5cdc0d..f18edfb066 100644 --- a/gulpfile.js/validate-build.js +++ b/gulpfile.js/validate-build.js @@ -26,7 +26,7 @@ const glob = require('glob'); // Size limits for development builds (in MB). Same margin policy as the prod limits // below: bump only enough to restore the headroom, so real size jumps still get caught. const DEV_MAX_FILE_SIZE_MB = 6; -const DEV_MAX_TOTAL_SIZE_MB = 105; // dev dist is ~100 MB + 5 MB margin +const DEV_MAX_TOTAL_SIZE_MB = 110; // dev dist is ~105 MB + 5 MB margin // Custom size limits for known large files (size in MB) For development builds const LARGE_FILE_LIST_DEV = { 'dist/thirdparty/no-minify/language-worker.js.map': 10, From 0bf36e6892a78c428dd9c4e386bb1d51053f52aa Mon Sep 17 00:00:00 2001 From: abose Date: Sat, 10 Oct 2026 16:26:17 +0530 Subject: [PATCH 08/11] fix(ai): accept multiline edits across line endings Normalize CRLF only when comparing edit text with captured file content. Preserve raw snapshots, SDK inputs, file line endings and genuine stale-content checks. Add four regressions to the registered AI File Permissions suite. All 39 specs pass on native Windows and macOS. A real Windows SDK query also edited LF and CRLF fixtures successfully while retaining their line endings. --- src-node/claude-code-agent.js | 6 +++++- src-node/test/test-ai-file-permissions.js | 9 ++++++--- test/spec/AIFilePermissions-test.js | 24 +++++++++++++++++++++++ 3 files changed, 35 insertions(+), 4 deletions(-) diff --git a/src-node/claude-code-agent.js b/src-node/claude-code-agent.js index 04a9877d77..ea92907003 100644 --- a/src-node/claude-code-agent.js +++ b/src-node/claude-code-agent.js @@ -1632,7 +1632,11 @@ async function _runQuery(requestId, prompt, projectPath, model, signal, locale, // 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) { + // Native Read presents LF text even when the disk file uses CRLF. + // Compare logical lines without changing the snapshot or SDK input. + const contentToMatch = (captured.content || "").replace(/\r\n/g, "\n"); + const oldStringToMatch = oldString && oldString.replace(/\r\n/g, "\n"); + if (oldStringToMatch && contentToMatch.indexOf(oldStringToMatch) === -1) { const 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 " + diff --git a/src-node/test/test-ai-file-permissions.js b/src-node/test/test-ai-file-permissions.js index aa271ed75f..5c50c5099c 100644 --- a/src-node/test/test-ai-file-permissions.js +++ b/src-node/test/test-ai-file-permissions.js @@ -12,7 +12,7 @@ const NodeConnector = require("../node-connector"); /** * Exercise one bounded permission scenario with a fake SDK and browser connector. * No installed CLI, network, credentials, user files or global agent state are used. - * @param {Object} params Mode, tool, target category and optional mode/answer scenario. + * @param {Object} params Mode, tool, target category and optional mode/answer scenario or edit text. * @return {Promise} Hook decisions, SDK options and observed browser operations. */ async function exercise(params) { @@ -50,7 +50,9 @@ async function exercise(params) { switchedDuringPrep = true; await exported.setPermissionMode({mode: params.switchDuringPrep}); } - return name === "captureFileContent" ? {content: params.stale ? "changed" : "before"} : {}; + return name === "captureFileContent" ? { + content: params.fileContent !== undefined ? params.fileContent : (params.stale ? "changed" : "before") + } : {}; }, triggerPeer: function (name, data) { events.push({name, data}); @@ -158,7 +160,8 @@ async function exercise(params) { await postExit.hooks[0](); } } - const toolInput = {file_path: filePath, content: "after", old_string: "before", new_string: "after"}; + const toolInput = {file_path: filePath, content: "after", + old_string: params.oldString !== undefined ? params.oldString : "before", new_string: "after"}; const hook = options.hooks.PreToolUse.find(entry => entry.matcher === tool).hooks[0]; const decision = await Promise.race([ hook({tool_name: tool, tool_input: toolInput}, "fixture-tool", {}), deadline diff --git a/test/spec/AIFilePermissions-test.js b/test/spec/AIFilePermissions-test.js index 7e3c488955..703cccf02a 100644 --- a/test/spec/AIFilePermissions-test.js +++ b/test/spec/AIFilePermissions-test.js @@ -175,6 +175,30 @@ define(function (require, exports, module) { expect(result.promptMessages[0].message.content.map(item => item.type)).toEqual(["text", "image"]); }); + ["\n", "\r\n"].forEach(function (lineEnding) { + it("accepts a multiline edit read from a " + (lineEnding === "\n" ? "LF" : "CRLF") + + " file", async function () { + const result = await exercise({mode: "auto", tool: "Edit", + fileContent: ["body {", " background: #000a2e;", " overflow: hidden;", "}"].join(lineEnding), + oldString: "body {\n background: #000a2e;\n overflow: hidden;"}); + expect(result.decision).toEqual({}); + expect(result.events).toEqual([]); + }); + }); + + it("accepts CRLF edit text when the file uses LF", async function () { + const result = await exercise({mode: "auto", tool: "Edit", + fileContent: "body {\n color: red;\n}\n", oldString: "body {\r\n color: red;\r\n}"}); + expect(result.decision).toEqual({}); + }); + + it("still rejects changed text in a CRLF file instead of overlooking whitespace changes", async function () { + const result = await exercise({mode: "auto", tool: "Edit", + fileContent: "body {\r\n color: red;\r\n}\r\n", oldString: "body {\n color: red;\n}"}); + expect(result.decision.hookSpecificOutput.permissionDecision).toBe("deny"); + expect(result.decision.hookSpecificOutput.permissionDecisionReason).toContain("modified by the user"); + }); + it("Auto still rejects an edit that no longer matches the current buffer", async function () { const result = await exercise({mode: "auto", tool: "Edit", stale: true}); expect(result.decision.hookSpecificOutput.permissionDecision).toBe("deny"); From b5d88831e32b5099ac6e90232ceb46ba2f10d961 Mon Sep 17 00:00:00 2001 From: abose Date: Sat, 10 Oct 2026 17:17:29 +0530 Subject: [PATCH 09/11] feat(ai): add phrases for the rotating thinking status Thirty general phrases the AI panel shows in turn while the model thinks for a while, since the model reports no detail of what it is considering. --- src/nls/root/strings.js | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/src/nls/root/strings.js b/src/nls/root/strings.js index 444fc7aa9b..455651f51e 100644 --- a/src/nls/root/strings.js +++ b/src/nls/root/strings.js @@ -2719,6 +2719,37 @@ define({ "AI_CHAT_BACK_BTN": "Back", "AI_CHAT_BACK_TITLE": "Back to start", "AI_CHAT_THINKING": "Thinking...", + // Shown in turn while the model thinks for a while; it reports no detail of what it is considering. + "AI_CHAT_THINKING_STEP_1": "Planning the changes...", + "AI_CHAT_THINKING_STEP_2": "Comparing options...", + "AI_CHAT_THINKING_STEP_3": "Working out the approach...", + "AI_CHAT_THINKING_STEP_4": "Weighing trade-offs...", + "AI_CHAT_THINKING_STEP_5": "Mapping out the steps...", + "AI_CHAT_THINKING_STEP_6": "Checking the details...", + "AI_CHAT_THINKING_STEP_7": "Sketching a plan...", + "AI_CHAT_THINKING_STEP_8": "Thinking it through...", + "AI_CHAT_THINKING_STEP_9": "Reasoning about the request...", + "AI_CHAT_THINKING_STEP_10": "Looking at the structure...", + "AI_CHAT_THINKING_STEP_11": "Choosing an approach...", + "AI_CHAT_THINKING_STEP_12": "Organizing the steps...", + "AI_CHAT_THINKING_STEP_13": "Double-checking assumptions...", + "AI_CHAT_THINKING_STEP_14": "Connecting the pieces...", + "AI_CHAT_THINKING_STEP_15": "Refining the plan...", + "AI_CHAT_THINKING_STEP_16": "Considering edge cases...", + "AI_CHAT_THINKING_STEP_17": "Evaluating alternatives...", + "AI_CHAT_THINKING_STEP_18": "Working through the details...", + "AI_CHAT_THINKING_STEP_19": "Piecing it together...", + "AI_CHAT_THINKING_STEP_20": "Shaping the solution...", + "AI_CHAT_THINKING_STEP_21": "Reviewing what is needed...", + "AI_CHAT_THINKING_STEP_22": "Putting a plan together...", + "AI_CHAT_THINKING_STEP_23": "Finding the best way...", + "AI_CHAT_THINKING_STEP_24": "Thinking about the design...", + "AI_CHAT_THINKING_STEP_25": "Checking how things fit...", + "AI_CHAT_THINKING_STEP_26": "Deciding the order of changes...", + "AI_CHAT_THINKING_STEP_27": "Weighing the options...", + "AI_CHAT_THINKING_STEP_28": "Reviewing the context...", + "AI_CHAT_THINKING_STEP_29": "Considering the details...", + "AI_CHAT_THINKING_STEP_30": "Working on a plan...", "AI_CHAT_WAITING_FOR_MODEL": "Waiting for the model...", "AI_CHAT_SCROLL_TO_BOTTOM": "Scroll to bottom", "AI_CHAT_PLACEHOLDER": "Ask Claude...", From a1a632114c2112420d5e0dae752c7614aabcefd5 Mon Sep 17 00:00:00 2001 From: abose Date: Sat, 10 Oct 2026 18:28:27 +0530 Subject: [PATCH 10/11] fix(ai): guide Live Preview choices through explicit confirmation Tell models that Phoenix owns selection and the reply composer confirms it. Replace generated Pin and Choose handlers with declarative choice markers and the select API, retaining reusable previews and cleanup guidance. Advance the Pro pin to the matching implementation. All 42 choice and 6 reminder checks passed on each native Windows, macOS and Linux runner; targeted ESLint and whitespace checks passed. Also align the existing Node lockfile package metadata with version 5.5.5-0; dependency versions and resolutions are unchanged. --- src-node/ai-editor-tool-specs.js | 26 ++++++++++++-------------- src-node/package-lock.json | 4 ++-- 2 files changed, 14 insertions(+), 16 deletions(-) diff --git a/src-node/ai-editor-tool-specs.js b/src-node/ai-editor-tool-specs.js index 6d1ac9948d..992a97ecf5 100644 --- a/src-node/ai-editor-tool-specs.js +++ b/src-node/ai-editor-tool-specs.js @@ -737,13 +737,16 @@ function getEditorToolSpecs(peerCall, options = {}) { "askInLivePreview", "Ask a visual question over the HTML live preview and wait for the answer. Prefer choices: each has id, " + "label, optional description/color, and preview:{css or cssFile, optional scriptFile, selector, params}. Phoenix " + - "handles hover/focus previews, restore on leave, keyboard/touch, click-to-answer and cleanup. A click " + - "returns {choice:id,label}, closes the question, and retains the preview while you apply the choice to " + + "owns hover/focus previews, selection and cleanup: clicking a choice keeps its preview and fills the reply " + + "box; only the user's Send/Enter confirms. Hovering elsewhere is temporary; leaving restores the selection. " + + "Clearing the reply clears selection. Confirmation returns {choice:id,label}, closes the question, " + + "and retains the preview while you apply the choice to " + "source. Runtime effects are restored before file preparation so live edits use the original DOM; " + "CSS-only effects may remain until verification or turn end. Apply the chosen result before finishing. " + "Without uiFile, Phoenix renders styled choice cards: no UI files or JavaScript needed. For expressive " + "custom content write an HTML fragment with its own