diff --git a/src/main/codex-cli/command.test.ts b/src/main/codex-cli/command.test.ts index c6d627f19d3..fbc1830443b 100644 --- a/src/main/codex-cli/command.test.ts +++ b/src/main/codex-cli/command.test.ts @@ -327,6 +327,25 @@ describe('resolveCliCommands', () => { expect(resolved.get('codex')).toBe(join(pathDir, 'codex')) }) + it('still resolves PATH commands when the nvm versions dir is unreadable', async () => { + const root = mkdtempSync(join(tmpdir(), 'orca-cli-commands-')) + const pathDir = join(root, 'bin') + const pathClaude = join(pathDir, 'claude') + makeExecutable(pathClaude) + // Why: a file where a directory is expected makes readdir throw ENOTDIR, + // standing in for stale-mount/EACCES failures on ~/.nvm/versions/node. + makeNonExecutableFile(join(root, '.nvm', 'versions', 'node')) + + const resolved = await resolveCliCommands(['claude', 'missing'], { + platform: 'darwin', + pathEnv: pathDir, + homePath: root + }) + + expect(resolved.get('claude')).toBe(pathClaude) + expect(resolved.get('missing')).toBe('missing') + }) + it('keeps Linux executable matching case-sensitive', async () => { const root = mkdtempSync(join(tmpdir(), 'orca-cli-commands-')) const pathDir = join(root, 'bin') diff --git a/src/main/codex-cli/command.ts b/src/main/codex-cli/command.ts index 55d36a56163..a51bb4c8337 100644 --- a/src/main/codex-cli/command.ts +++ b/src/main/codex-cli/command.ts @@ -1,4 +1,4 @@ -import { accessSync, constants, existsSync, readdirSync, statSync } from 'node:fs' +import { accessSync, constants, existsSync, readdirSync, statSync, type Dirent } from 'node:fs' import { access, readdir, stat } from 'node:fs/promises' import { homedir } from 'node:os' import { delimiter, dirname, join } from 'node:path' @@ -214,7 +214,17 @@ function getNvmVersionDirectories(homePath: string): string[] { return [] } - return readdirSync(nvmVersionsDir, { withFileTypes: true }) + // Why: bulk detection folds PATH and install dirs into one pass, so an + // unreadable nvm dir (stale mount, dangling symlink) must degrade to "no + // nvm dirs" instead of aborting resolution and discarding PATH-found CLIs. + let entries: Dirent[] + try { + entries = readdirSync(nvmVersionsDir, { withFileTypes: true }) + } catch { + return [] + } + + return entries .filter((entry) => entry.isDirectory()) .map((entry) => entry.name) .sort(compareVersionDesc) diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index a4ac1ffeec2..638e9eaefaa 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -326,7 +326,14 @@ function SkillFreshnessUpdateDialogGate(): React.JSX.Element | null { getSkillFreshnessUpdateDialogRequest, getSkillFreshnessUpdateDialogRequest ) - if (!requested) { + // Why: stay mounted after the first request — closing consumes the request + // in the same commit `open` flips false, and unmounting then would cut off + // Radix's exit animation. + const everRequestedRef = useRef(false) + if (requested) { + everRequestedRef.current = true + } + if (!everRequestedRef.current) { return null } return ( diff --git a/src/renderer/src/app-startup-routing.test.ts b/src/renderer/src/app-startup-routing.test.ts index 1f77ef4340d..a2a8437c960 100644 --- a/src/renderer/src/app-startup-routing.test.ts +++ b/src/renderer/src/app-startup-routing.test.ts @@ -238,7 +238,7 @@ describe('renderer startup runtime routing', () => { expect(source).toContain("import('./LinearAgentSkillSetupDialog').then") expect(source).not.toContain("from './LinearAgentSkillSetupDialog'") - expect(source).toContain('const setupDialog = setupDialogOpen ?') + expect(source).toContain('const setupDialog = setupDialogEverOpenedRef.current ?') }) it('loads worktree note Markdown only with the hover detail surface', () => { diff --git a/src/renderer/src/components/sidebar/LinearAgentSkillSetupPrompt.tsx b/src/renderer/src/components/sidebar/LinearAgentSkillSetupPrompt.tsx index e008c13956e..a7e8f4f471c 100644 --- a/src/renderer/src/components/sidebar/LinearAgentSkillSetupPrompt.tsx +++ b/src/renderer/src/components/sidebar/LinearAgentSkillSetupPrompt.tsx @@ -86,6 +86,12 @@ export function LinearAgentSkillSetupPrompt({ const [cliStatus, setCliStatus] = useState(null) const [cliLoading, setCliLoading] = useState(linked) const [setupDialogOpen, setSetupDialogOpen] = useState(false) + // Why: keep the dialog mounted once opened so closing can play Radix's exit + // animation; only the very first mount is deferred off the startup path. + const setupDialogEverOpenedRef = useRef(false) + if (setupDialogOpen) { + setupDialogEverOpenedRef.current = true + } const [setupCheckResult, setSetupCheckResult] = useState('idle') const [activeSetupCheckIdentity, setActiveSetupCheckIdentity] = useState(null) const agentRuntime = useMemo( @@ -287,8 +293,9 @@ export function LinearAgentSkillSetupPrompt({ } // Why: rendering a lazy component with open=false still fetches its module. - // Gate the element itself so the inline installer terminal stays off startup. - const setupDialog = setupDialogOpen ? ( + // Gate the element until the first open so the inline installer terminal + // stays off startup. + const setupDialog = setupDialogEverOpenedRef.current ? ( root))) { + rmSync(join(fixtureDir, bucketRoot), { recursive: true, force: true }) + } const payload = 'x'.repeat(1024) let written = 0 const started = Date.now()