diff --git a/src-node/claude-code-agent.js b/src-node/claude-code-agent.js index ea92907003..64523ab0b6 100644 --- a/src-node/claude-code-agent.js +++ b/src-node/claude-code-agent.js @@ -799,7 +799,7 @@ exports.validateCliPath = async function (params) { * @return {Promise<{downloader: ?string}>} "curl", "wget", or null */ exports.getInstallDownloader = async function () { - return { downloader: CliLocator.findDownloader() }; + return { downloader: await CliLocator.findDownloader() }; }; /** diff --git a/src-node/cli-locator.js b/src-node/cli-locator.js index 088912d1f5..af9ff8dd40 100644 --- a/src-node/cli-locator.js +++ b/src-node/cli-locator.js @@ -32,15 +32,14 @@ * and known locations, each proved by actually running `--version`. */ -const { execSync, spawn } = require("child_process"); +const { execFile, spawn } = require("child_process"); const fs = require("fs"); const path = require("path"); const isWindows = process.platform === "win32"; -// PATH lookups (`where`/`which`) run synchronously and block this whole Node -// process — the integrated terminal, file watchers and MCP all share it. An -// unreachable network drive on PATH can wedge them, so cap the wait. +// Bound asynchronous PATH lookups: an unreachable network drive must not +// leave discovery pending indefinitely or block the shared Node event loop. const PATH_LOOKUP_TIMEOUT_MS = 3000; // How long a `--version` probe may take before we give up on a candidate. @@ -102,11 +101,11 @@ const CLI_REGISTRY = { posixNative: function (home) { return [path.join(home, ".local", "bin", "claude")]; // claude.ai/install.sh default }, - posixExtra: function (home) { + posixExtra: async function (home) { return [ "/usr/local/bin/claude", // System-wide / Intel Mac Homebrew "/usr/bin/claude", // Distro package - ..._nvmCandidates(home, "claude"), // npm global via nvm + ...await _nvmCandidates(home, "claude"), // npm global via nvm "/opt/homebrew/bin/claude", // Homebrew on Apple Silicon "/home/linuxbrew/.linuxbrew/bin/claude" // Linuxbrew ]; @@ -147,11 +146,11 @@ const CLI_REGISTRY = { path.join(home, ".codex", "packages", "standalone", "current", "bin", "codex") ]; }, - posixExtra: function (home) { + posixExtra: async function (home) { return [ "/usr/local/bin/codex", "/usr/bin/codex", - ..._nvmCandidates(home, "codex"), + ...await _nvmCandidates(home, "codex"), "/opt/homebrew/bin/codex", // brew install --cask codex "/home/linuxbrew/.linuxbrew/bin/codex" ]; @@ -172,7 +171,7 @@ let _bundledClaude = null; // Promise: looked up once per // cliId -> { path, at, overrideSig, source, version, errorCode, override, searched } const _cache = new Map(); -// cliId -> in-flight discovery promise, so concurrent callers share one walk +// cliId + override signature -> in-flight discovery promise, sharing one walk // of the fallback chain instead of each spawning their own --version probes. const _inFlight = new Map(); // cliId -> user-configured override path ("" when unset) @@ -187,29 +186,28 @@ const _overrides = new Map(); * `$NVM_DIR` points at). Fall back to enumerating installed versions, newest * first, so we still find the CLI when the default alias is a label like * `lts/*` or `node` that we don't expand here. + * @param {string} home - User's home directory. + * @param {string} bin - CLI executable name. + * @return {Promise>} Candidate paths in preference order. */ -function _nvmCandidates(home, bin) { +async function _nvmCandidates(home, bin) { const nvmRoot = process.env.NVM_DIR || path.join(home, ".nvm"); const versionsDir = path.join(nvmRoot, "versions", "node"); const candidates = []; try { const aliasFile = path.join(nvmRoot, "alias", "default"); - if (fs.existsSync(aliasFile)) { - const alias = fs.readFileSync(aliasFile, "utf8").trim(); - if (/^v?\d/.test(alias)) { - const v = alias.startsWith("v") ? alias : "v" + alias; - candidates.push(path.join(versionsDir, v, "bin", bin)); - } + const alias = (await fs.promises.readFile(aliasFile, "utf8")).trim(); + if (/^v?\d/.test(alias)) { + const v = alias.startsWith("v") ? alias : "v" + alias; + candidates.push(path.join(versionsDir, v, "bin", bin)); } } catch { /* nvm not installed or unreadable */ } try { - if (fs.existsSync(versionsDir)) { - const versions = fs.readdirSync(versionsDir) - .filter(v => /^v\d/.test(v)) - .sort((a, b) => b.localeCompare(a, undefined, { numeric: true })); - for (const v of versions) { - candidates.push(path.join(versionsDir, v, "bin", bin)); - } + const versions = (await fs.promises.readdir(versionsDir)) + .filter(v => /^v\d/.test(v)) + .sort((a, b) => b.localeCompare(a, undefined, { numeric: true })); + for (const v of versions) { + candidates.push(path.join(versionsDir, v, "bin", bin)); } } catch { /* ignore */ } return candidates; @@ -236,18 +234,27 @@ function _dedupe(paths) { /** * Ask the OS where `bin` lives. Returns [] when the lookup tool is missing, * finds nothing, or takes too long. + * @param {string} bin - Executable name to find. + * @return {Promise>} Matching executable paths in preference order. */ -function _pathLookup(bin) { +async function _pathLookup(bin) { try { - const cmd = isWindows - ? "where " + bin - : "which -a " + bin + " 2>/dev/null || which " + bin; - const out = execSync(cmd, { - encoding: "utf8", - timeout: PATH_LOOKUP_TIMEOUT_MS, - windowsHide: true - }).trim(); - let paths = out.split(isWindows ? "\r\n" : "\n") + const started = Date.now(); + const lookup = (args, timeout) => new Promise(resolve => { + execFile(isWindows ? "where.exe" : "which", args, { + encoding: "utf8", timeout, windowsHide: true, env: process.env + }, (error, stdout) => resolve({error, stdout})); + }); + let result = await lookup(isWindows ? [bin] : ["-a", bin], PATH_LOOKUP_TIMEOUT_MS); + const remaining = PATH_LOOKUP_TIMEOUT_MS - (Date.now() - started); + // Some which implementations do not support -a. Retain the single-match + // fallback without retrying a timeout or exceeding the original deadline. + if (!isWindows && result.error && !result.error.killed && + typeof result.error.code === "number" && remaining > 0) { + result = await lookup([bin], remaining); + } + if (result.error) { return []; } + let paths = (result.stdout || "").trim().split(/\r?\n/) .map(p => p.trim()) .filter(p => p && !p.includes("node_modules")); if (isWindows) { @@ -266,41 +273,43 @@ function _pathLookup(bin) { } /** - * Ordered candidate paths for a CLI, split into two tiers: - * - `native`: installers known to drop a real executable. No shim chain to - * break, so file existence is enough confidence — we skip `--version`. - * - `fallback`: PATH discovery and known locations. Broken installs are - * common here (an orphan `.cmd` whose cli.js got deleted), so every - * candidate is proved by running `--version` before we return it. + * Installer locations that can be checked before starting PATH discovery. * @param {Object} cli - a CLI_REGISTRY entry - * @return {{native: Array, fallback: Array}} + * @return {Array} Native candidates in preference order. + */ +function _nativeCandidates(cli) { + const home = (isWindows ? process.env.USERPROFILE : process.env.HOME) || process.env.HOME || ""; + return _dedupe(isWindows ? cli.winNative() : cli.posixNative(home)); +} + +/** + * Find PATH and other fallback candidates only after native candidates fail. + * @param {Object} cli - CLI registry entry. + * @param {Array} nativePaths - Previously considered native paths. + * @return {Promise>} Fallback paths, each requiring a version probe. */ -function _candidates(cli) { +async function _fallbackCandidates(cli, nativePaths) { const home = (isWindows ? process.env.USERPROFILE : process.env.HOME) || process.env.HOME || ""; - const native = isWindows ? cli.winNative() : cli.posixNative(home); - const extra = isWindows ? cli.winExtra() : cli.posixExtra(home); - const nativePaths = _dedupe(native); + const [onPath, extra] = await Promise.all([ + _pathLookup(cli.bin), isWindows ? cli.winExtra() : cli.posixExtra(home) + ]); // Dedupe the fallback tier against native too: `which` reports the same // file the native tier already listed, and a user reading searchedPaths // should not see it twice. const seenNative = new Set(nativePaths.map(p => (isWindows ? p.toLowerCase() : p))); - const fallback = _dedupe([..._pathLookup(cli.bin), ...extra]) + return _dedupe([...onPath, ...extra]) .filter(p => !seenNative.has(isWindows ? p.toLowerCase() : p)); - return { native: nativePaths, fallback: fallback }; } /** - * Existence + executability check. On Windows executability is derived from - * extension/PATHEXT not a file attribute, so existsSync is the right test; - * on posix we want the +x bit. + * Check existence on Windows and executable permission on POSIX without blocking Node. + * @param {string} p - Candidate path. + * @return {Promise} Whether the candidate can be used. */ -function canAccess(p) { +async function canAccess(p) { if (!p) { return false; } try { - if (isWindows) { - return fs.existsSync(p); - } - fs.accessSync(p, fs.constants.X_OK); + await fs.promises.access(p, isWindows ? fs.constants.F_OK : fs.constants.X_OK); return true; } catch { return false; @@ -454,16 +463,17 @@ async function _resolveOverride(cli, override) { // affordance the Git extension's gitPath setting allows. let resolved = raw; if (!raw.includes("/") && !raw.includes("\\")) { - const found = _pathLookup(raw)[0]; + const found = (await _pathLookup(raw))[0]; if (!found) { return { used: true, valid: false, path: raw, errorCode: ERROR_CODES.OVERRIDE_MISSING, onPath: true }; } resolved = found; } - if (!canAccess(resolved)) { + if (!await canAccess(resolved)) { // Split "no such file" from "there but not executable": the latter is // the common chmod mistake and deserves its own advice. - const code = (!isWindows && fs.existsSync(resolved)) + const exists = !isWindows && await fs.promises.access(resolved).then(() => true, () => false); + const code = exists ? ERROR_CODES.OVERRIDE_NOT_EXECUTABLE : ERROR_CODES.OVERRIDE_MISSING; return { used: true, valid: false, path: resolved, errorCode: code }; @@ -539,7 +549,7 @@ function _locateBundledClaude() { _bundledClaude = (async function () { const cli = CLI_REGISTRY.claude; for (const p of _bundledClaudeCandidates()) { - if (!canAccess(p)) { continue; } + if (!await canAccess(p)) { continue; } const probe = await _probeVersion(cli, p); if (probe.ok) { console.log("[Phoenix AI] Bundled claude CLI at:", p, probe.version); @@ -584,14 +594,20 @@ function setBundledRoot(dir) { _cache.delete("claude"); } -function _cacheHit(cliId, overrideSig) { +/** + * Check that a cached result still matches this request and its executable exists. + * @param {string} cliId - CLI registry ID. + * @param {string} overrideSig - Trimmed override for this request. + * @return {Promise} A matching, usable cache entry. + */ +async function _cacheHit(cliId, overrideSig) { const entry = _cache.get(cliId); if (!entry || entry.overrideSig !== overrideSig) { return null; } - if (entry.path !== null && !canAccess(entry.path)) { + if (entry.path !== null && !await canAccess(entry.path)) { console.log("[Phoenix AI] " + cliId + " CLI is no longer at", entry.path); - _cache.delete(cliId); + if (_cache.get(cliId) === entry) { _cache.delete(cliId); } return null; } const fresh = entry.path !== null || (Date.now() - entry.at) < NULL_CACHE_TTL_MS; @@ -631,19 +647,20 @@ function locateCli(cliId, opts) { const force = !!(opts && opts.force); const override = (opts && opts.override !== undefined) ? opts.override : getOverride(cliId); const overrideSig = (override || "").trim(); + const pendingKey = cliId + ":" + overrideSig; if (!force) { - const hit = _cacheHit(cliId, overrideSig); - if (hit) { - return Promise.resolve(_toResult(cliId, hit)); - } - const pending = _inFlight.get(cliId); + const pending = _inFlight.get(pendingKey); if (pending) { return pending; } } const discovery = (async function () { + if (!force) { + const hit = await _cacheHit(cliId, overrideSig); + if (hit) { return _toResult(cliId, hit); } + } const entry = { path: null, at: Date.now(), overrideSig, searched: [] }; if (overrideSig) { @@ -668,8 +685,8 @@ function locateCli(cliId, opts) { return _toResult(cliId, entry); } - const { native, fallback } = _candidates(cli); - entry.searched = [...native, ...fallback]; + const native = _nativeCandidates(cli); + entry.searched = native.slice(); // Only claude ships with Phoenix. With a bundled copy to weigh a // system install against, the system install's version has to be // known, so the native tier is probed too. @@ -679,7 +696,7 @@ function locateCli(cliId, opts) { } let found = null; // {path, source, version} for (const p of native) { - if (!canAccess(p)) { continue; } + if (!await canAccess(p)) { continue; } let version = null; if (bundled) { const probe = await _probeVersion(cli, p); @@ -694,8 +711,10 @@ function locateCli(cliId, opts) { break; } if (!found) { + const fallback = await _fallbackCandidates(cli, native); + entry.searched.push(...fallback); for (const p of fallback) { - if (!canAccess(p)) { continue; } + if (!await canAccess(p)) { continue; } const probe = await _probeVersion(cli, p); if (probe.ok) { console.log("[Phoenix AI] Validated " + cli.bin + " CLI at:", p); @@ -729,12 +748,13 @@ function locateCli(cliId, opts) { }()); if (!force) { - _inFlight.set(cliId, discovery); - discovery.finally(function () { - if (_inFlight.get(cliId) === discovery) { - _inFlight.delete(cliId); + _inFlight.set(pendingKey, discovery); + const clearPending = function () { + if (_inFlight.get(pendingKey) === discovery) { + _inFlight.delete(pendingKey); } - }); + }; + discovery.then(clearPending, clearPending); } return discovery; } @@ -769,11 +789,11 @@ const DOWNLOADERS = ["curl", "wget"]; /** * Which downloader is on PATH, so the install command offered to the user is * one that can actually run. - * @return {?string} "curl", "wget", or null when neither is installed + * @return {Promise} "curl", "wget", or null when neither is installed */ -function findDownloader() { +async function findDownloader() { for (const bin of DOWNLOADERS) { - if (_pathLookup(bin).length) { + if ((await _pathLookup(bin)).length) { return bin; } } diff --git a/src-node/lmdb.js b/src-node/lmdb.js index 518a21e104..e2d0112b4d 100644 --- a/src-node/lmdb.js +++ b/src-node/lmdb.js @@ -38,6 +38,11 @@ function _validateDBIntegrity(lmdbDir) { db.getKeys({ limit: 1 }).next(); db.close(); `; + // Keep this synchronous: the 2026-10-25 async experiment had slower app-ready + // in 7/10 Windows, 6/10 Linux and 8/15 Mac matched warm app-reload pairs. + // Better Node responsiveness did not meet the startup requirement, so it was rejected. + // Baseline and raw results: src/extensionsIntegrated/phoenix-pro/docs/ + // boot-01-startup-benchmark-2026-10-25.json (BOOT-01). execFileSync(process.execPath, ["-e", script], { timeout: 10000, stdio: "pipe", diff --git a/src-node/test/test-cli-locator.js b/src-node/test/test-cli-locator.js index 1dd8a4a3d9..1ff544c79b 100644 --- a/src-node/test/test-cli-locator.js +++ b/src-node/test/test-cli-locator.js @@ -18,7 +18,7 @@ const PROCESS_TIMEOUT_MS = 3000; * @param {string} platform - Node platform name * @param {Object} env - Simulated desktop environment * @param {Function} [spawnImpl] - Optional probe process substitute - * @param {{fs: Object, execSync: Function}} [stubs] - Optional file system and PATH lookup substitutes + * @param {{fs: Object, execFile: Function}} [stubs] - Optional file system and PATH lookup substitutes * @return {Object} Locator exports */ function _loadLocator(platform, env, spawnImpl, stubs) { @@ -27,8 +27,8 @@ function _loadLocator(platform, env, spawnImpl, stubs) { if (spawnImpl) { processStubs.spawn = spawnImpl; } - if (stubs && stubs.execSync) { - processStubs.execSync = stubs.execSync; + if (stubs && stubs.execFile) { + processStubs.execFile = stubs.execFile; } const dependencies = { path: platform === "win32" ? path.win32 : path.posix, @@ -177,13 +177,22 @@ async function runStandaloneFixture() { async function locateWindowsStandaloneCodex() { const env = { LOCALAPPDATA: "C:\\Users\\me\\AppData\\Local", USERPROFILE: "C:\\Users\\me" }; const installedPath = "C:\\Users\\me\\AppData\\Local\\Programs\\OpenAI\\Codex\\bin\\codex.exe"; + let pathLookups = 0; const locator = _loadLocator("win32", env, null, { fs: Object.assign({}, fs, { - existsSync: function (candidate) { return candidate === installedPath; } + promises: Object.assign({}, fs.promises, { + access: async function (candidate) { + if (candidate !== installedPath) { throw new Error("ENOENT"); } + } + }) }), - execSync: function () { throw new Error("not on PATH"); } + execFile: function (_command, _args, _options, callback) { + pathLookups++; + callback(new Error("not on PATH"), ""); + } }); - return { located: await locator.locateCli("codex"), installedPath }; + const located = await locator.locateCli("codex"); + return { located, installedPath, pathLookups }; } /** @@ -212,12 +221,17 @@ function _bundledLocator(versions) { : "process.exit(1)"]); }, { fs: Object.assign({}, fs, { - accessSync: function (candidate) { - if (!versions[candidate]) { throw new Error("ENOENT"); } - }, - existsSync: function (candidate) { return !!versions[candidate]; } + promises: { + access: async function (candidate) { + if (!versions[candidate]) { throw new Error("ENOENT"); } + }, + readFile: async function () { throw new Error("ENOENT"); }, + readdir: async function () { return []; } + } }), - execSync: function () { throw new Error("not on PATH"); } + execFile: function (_command, _args, _options, callback) { + callback(new Error("not on PATH"), ""); + } }); locator.setBundledRoot(BUNDLED_ROOT); return locator; @@ -256,20 +270,94 @@ async function relocateAfterRemoval() { * @return {Promise>} Chosen downloader per scenario */ async function findDownloaders({installed}) { - return installed.map(function (tools) { + return Promise.all(installed.map(function (tools) { const locator = _loadLocator("linux", { PATH: "/usr/bin" }, null, { - execSync: function (command) { - const found = tools.find(function (tool) { return command.endsWith("which " + tool); }); - if (!found) { - throw new Error("not on PATH"); - } - return "/usr/bin/" + found + "\n"; + execFile: function (_command, args, _options, callback) { + const found = tools.find(function (tool) { return args[args.length - 1] === tool; }); + callback(found ? null : new Error("not on PATH"), found ? "/usr/bin/" + found + "\n" : ""); } }); return locator.findDownloader(); + })); +} + +/** + * Hold a real lookup child on stdin while the parent services a file read and timer. + * @return {Promise} Responsiveness and bounded, shell-less lookup options. + */ +async function lookupWhileBusy() { + let child, options, lookupStarted; + const started = new Promise(resolve => { lookupStarted = resolve; }); + const locator = _loadLocator("linux", { PATH: "/usr/bin" }, null, { + execFile: function (_command, _args, opts, callback) { + options = opts; + child = childProcess.execFile(process.execPath, + ["-e", "process.stdin.once('data', () => { console.log('/usr/bin/curl'); process.exit(); });"], + { timeout: PROCESS_TIMEOUT_MS }, callback); + lookupStarted(); + return child; + } + }); + let settled = false; + const lookup = locator.findDownloader().then(result => { settled = true; return result; }); + try { + await started; + const source = await fs.promises.readFile(__filename, "utf8"); + await new Promise(resolve => setImmediate(resolve)); + const pendingDuringRead = !settled; + child.stdin.end("finish"); + return { downloader: await lookup, pendingDuringRead, readCompleted: source.length > 0, + timeout: options.timeout, shell: !!options.shell, windowsHide: options.windowsHide }; + } finally { + if (child) { child.kill(); } + } +} + +/** + * Simulate which variants or a failed lookup without depending on host-installed tools. + * @param {{failure: string}} params - unsupported, timeout, or missing. + * @return {Promise} Downloader choice and lookup arguments/deadlines. + */ +async function lookupFailure({failure}) { + const calls = []; + const locator = _loadLocator("linux", {}, null, { + execFile: function (command, args, options, callback) { + calls.push({command, args, timeout: options.timeout}); + if (failure === "unsupported" && args.length === 1) { + callback(null, "/usr/bin/curl\n"); + } else { + const error = new Error(failure); + error.code = failure === "missing" ? "ENOENT" : 1; + error.killed = failure === "timeout"; + callback(error, ""); + } + } }); + return { downloader: await locator.findDownloader(), calls }; +} + +/** + * Concurrent callers share the same override, while different overrides stay independent. + * @return {Promise} Promise identity and located paths. + */ +async function concurrentOverrides() { + const paths = ["/test/one/codex", "/test/two/codex"]; + const locator = _loadLocator("linux", {}, function () { + return childProcess.spawn(process.execPath, ["-e", "console.log('codex-cli 1.0.0');"]); + }, { fs: Object.assign({}, fs, { promises: { + access: async function (candidate) { + if (!paths.includes(candidate)) { throw new Error("ENOENT"); } + } + } }) }); + const first = locator.locateCli("codex", {override: paths[0]}); + const shared = locator.locateCli("codex", {override: paths[0]}); + const second = locator.locateCli("codex", {override: paths[1]}); + return { shared: first === shared, independent: first !== second, results: await Promise.all([first, second]) }; } +exports.lookupWhileBusy = lookupWhileBusy; +exports.lookupFailure = lookupFailure; +exports.concurrentOverrides = concurrentOverrides; exports.getSpawnProfile = getSpawnProfile; exports.locateWindowsStandaloneCodex = locateWindowsStandaloneCodex; exports.findDownloaders = findDownloaders; diff --git a/src/nls/root/strings.js b/src/nls/root/strings.js index bdeaef1344..5767f81215 100644 --- a/src/nls/root/strings.js +++ b/src/nls/root/strings.js @@ -2753,6 +2753,7 @@ define({ "AI_CHAT_SCROLL_TO_BOTTOM": "Scroll to bottom", "AI_CHAT_PLACEHOLDER": "Ask Claude...", "AI_CHAT_SETUP_PLACEHOLDER": "Set up Claude Code to start chatting", + "AI_CHAT_NOT_READY_YET": "AI is getting ready. Please wait a moment.", "AI_CHAT_PLACEHOLDER_MODEL": "Ask {0}...", "AI_CHAT_SEND_TITLE": "Send message", "AI_CHAT_STOP_TITLE": "Stop generation (Esc)", diff --git a/src/styles/Extn-AIChatPanel.less b/src/styles/Extn-AIChatPanel.less index 1727bff072..ca3f3b0346 100644 --- a/src/styles/Extn-AIChatPanel.less +++ b/src/styles/Extn-AIChatPanel.less @@ -138,6 +138,26 @@ color: @project-panel-text-1; font-size: @ai-text-body; container-type: inline-size; + + // Match Layers' dark sidebar scrollbars, including Windows light-theme hover. + #sidebar & { + .ai-chat-messages, + .ai-chat-start, + .ai-session-history-dropdown { + &::-webkit-scrollbar { + width: 9px; + height: 9px; + background-color: transparent; + } + + &::-webkit-scrollbar-thumb { + border-radius: 999px; + background-color: rgba(255, 255, 255, 0.12); + background-clip: padding-box; + border: 2px solid transparent; + } + } + } } /* ── Header ─────────────────────────────────────────────────────────── */ @@ -173,7 +193,8 @@ } } - .ai-chat-title { + .ai-chat-title, + .ai-chat-history-title { min-width: 0; overflow: hidden; text-overflow: ellipsis; @@ -182,6 +203,10 @@ color: inherit; } + .ai-chat-history-title { + display: none; + } + .ai-chat-header-actions { display: flex; align-items: center; @@ -281,6 +306,12 @@ cursor: pointer; } + .ai-history-btn.active { + color: @ai-start-accent; + background-color: fade(@ai-start-accent, 15%); + border-color: fade(@ai-start-accent, 35%); + } + .ai-new-session-btn, .ai-stop-cli-btn { display: flex; @@ -368,10 +399,29 @@ } } -.ai-chat-panel.ai-chat-started:not(.ai-mode-cli):not(.ai-home) .ai-back-btn { +.ai-chat-panel.ai-chat-started:not(.ai-mode-cli):not(.ai-home) .ai-back-btn, +.ai-chat-panel.ai-history-open .ai-back-btn { display: flex; } +.ai-chat-panel.ai-history-open .ai-chat-title-group { + pointer-events: none; + + .ai-chat-title, + .ai-chat-title-icon, + .ai-chat-title-chevron { + display: none; + } + + .ai-chat-history-title { + display: inline; + } +} + +.ai-chat-panel.ai-history-open .ai-model-select { + display: none; +} + /* Home: the start screen laid over a chat that has started. The chat keeps running underneath, its messages and input out of sight until it is picked again. */ @@ -707,9 +757,10 @@ } } -/* Show header actions on tab container hover */ +/* Keep header actions visible while History or connection details are open. */ .ai-tab-container:hover .ai-chat-header-actions, .ai-chat-header:focus-within .ai-chat-header-actions, +.ai-chat-panel.ai-history-open .ai-chat-header-actions, .ai-chat-header.ai-cli-connection-open .ai-chat-header-actions { opacity: 1; pointer-events: auto; diff --git a/test/spec/CLILocator-test.js b/test/spec/CLILocator-test.js index 9b3aea2525..da77bea7df 100644 --- a/test/spec/CLILocator-test.js +++ b/test/spec/CLILocator-test.js @@ -121,6 +121,40 @@ define(function (require, exports, module) { const result = await nodeConnector.execPeer("locateWindowsStandaloneCodex"); expect(result.located.path).withContext(JSON.stringify(result.located)).toBe(result.installedPath); expect(result.located.source).toBe("native"); + expect(result.pathLookups).toBe(0); + }); + + it("should service a file read and timer while a PATH lookup is still running", async function () { + const result = await nodeConnector.execPeer("lookupWhileBusy"); + expect(result.pendingDuringRead).toBeTrue(); + expect(result.readCompleted).toBeTrue(); + expect(result.downloader).toBe("curl"); + expect(result.timeout).toBe(3000); + expect(result.shell).toBeFalse(); + expect(result.windowsHide).toBeTrue(); + }); + + it("should fall back to plain which when which -a is unsupported within the same deadline", async function () { + const result = await nodeConnector.execPeer("lookupFailure", {failure: "unsupported"}); + expect(result.downloader).toBe("curl"); + expect(result.calls.map(call => call.args)).toEqual([["-a", "curl"], ["curl"]]); + expect(result.calls[1].timeout).toBeGreaterThan(0); + expect(result.calls[1].timeout).toBeLessThanOrEqual(result.calls[0].timeout); + }); + + ["timeout", "missing"].forEach(function (failure) { + it("should not retry a PATH lookup after " + failure, async function () { + const result = await nodeConnector.execPeer("lookupFailure", {failure}); + expect(result.downloader).toBeNull(); + expect(result.calls.map(call => call.args)).toEqual([["-a", "curl"], ["-a", "wget"]]); + }); + }); + + it("should share concurrent checks without mixing different overrides", async function () { + const result = await nodeConnector.execPeer("concurrentOverrides"); + expect(result.shared).toBeTrue(); + expect(result.independent).toBeTrue(); + expect(result.results.map(item => item.path)).toEqual(["/test/one/codex", "/test/two/codex"]); }); it("should use the bundled Claude Code when the system has none, or an older one", async function () { diff --git a/tracking-repos.json b/tracking-repos.json index 95935f0376..bf04fe490a 100644 --- a/tracking-repos.json +++ b/tracking-repos.json @@ -1,5 +1,5 @@ { "phoenixPro": { - "commitID": "f0dcc76600882aed9a3f9adcf6c58a59b9aab482" + "commitID": "c63b994897b344c757263ee4acfe484d64faa61b" } }