Improve startup with async command resolution and lazy skill dialogs

- Async command resolution prevents blocking the main thread on slow PATH entries
- Lazy-loaded skill dialog defers loading terminal and Markdown modules
This commit is contained in:
Jinjing
2026-07-16 17:58:43 -07:00
parent f5090ae09a
commit ce12877a4b
5 changed files with 108 additions and 34 deletions
+8 -8
View File
@@ -235,7 +235,7 @@ describe('resolveCliCommands', () => {
delete process.env.Path
})
it('resolves a batch from PATH and install directories', () => {
it('resolves a batch from PATH and install directories', async () => {
const root = mkdtempSync(join(tmpdir(), 'orca-cli-commands-'))
const pathDir = join(root, 'bin')
const pathClaude = join(pathDir, 'claude')
@@ -245,7 +245,7 @@ describe('resolveCliCommands', () => {
makeExecutable(nvmCodex)
makeExecutable(pnpmOpencode)
const resolved = resolveCliCommands(['claude', 'codex', 'opencode', 'missing'], {
const resolved = await resolveCliCommands(['claude', 'codex', 'opencode', 'missing'], {
platform: 'darwin',
pathEnv: pathDir,
homePath: root
@@ -257,13 +257,13 @@ describe('resolveCliCommands', () => {
expect(resolved.get('missing')).toBe('missing')
})
it('deduplicates command names in the returned map', () => {
it('deduplicates command names in the returned map', async () => {
const root = mkdtempSync(join(tmpdir(), 'orca-cli-commands-'))
const pathDir = join(root, 'bin')
const pathClaude = join(pathDir, 'claude')
makeExecutable(pathClaude)
const resolved = resolveCliCommands(['claude', 'claude'], {
const resolved = await resolveCliCommands(['claude', 'claude'], {
platform: 'linux',
pathEnv: pathDir,
homePath: root
@@ -275,7 +275,7 @@ describe('resolveCliCommands', () => {
it.skipIf(process.platform === 'win32')(
'skips non-runnable batch candidates and continues through PATH',
() => {
async () => {
const root = mkdtempSync(join(tmpdir(), 'orca-cli-commands-'))
const badDir = join(root, 'bad-bin')
const goodDir = join(root, 'good-bin')
@@ -283,7 +283,7 @@ describe('resolveCliCommands', () => {
const goodCodex = join(goodDir, 'codex')
makeExecutable(goodCodex)
const resolved = resolveCliCommands(['codex'], {
const resolved = await resolveCliCommands(['codex'], {
platform: 'darwin',
pathEnv: [badDir, goodDir].join(delimiter),
homePath: root
@@ -293,13 +293,13 @@ describe('resolveCliCommands', () => {
}
)
it('matches Windows executable names case-insensitively, including .com', () => {
it('matches Windows executable names case-insensitively, including .com', async () => {
const root = mkdtempSync(join(tmpdir(), 'orca-cli-commands-'))
const pathDir = join(root, 'bin')
const commandPath = join(pathDir, 'CODEX.COM')
makeExecutable(commandPath)
const resolved = resolveCliCommands(['codex'], {
const resolved = await resolveCliCommands(['codex'], {
platform: 'win32',
pathEnv: pathDir,
homePath: root
+60 -21
View File
@@ -1,4 +1,5 @@
import { accessSync, constants, existsSync, readdirSync, statSync } from 'node:fs'
import { access, readdir, stat } from 'node:fs/promises'
import { homedir } from 'node:os'
import { delimiter, dirname, join } from 'node:path'
@@ -73,35 +74,49 @@ function findFirstExecutable(
return null
}
function resolveCommandsInDirectories(
// Why: bulk resolution runs on the Electron main process at startup, where a
// slow PATH entry (dead network mount, FUSE dir) must stall this promise, not
// the event loop — so this variant is async end to end.
async function resolveCommandsInDirectories(
platform: NodeJS.Platform,
directories: readonly string[],
executableNamesByCommand: ReadonlyMap<string, readonly string[]>,
resolved: Map<string, string>
): void {
): Promise<void> {
for (const directory of directories) {
let entries: string[]
let entries: string[] | null
try {
entries = readdirSync(directory)
} catch {
continue
entries = await readdir(directory)
} catch (error) {
const code = (error as NodeJS.ErrnoException).code
if (code === 'ENOENT' || code === 'ENOTDIR') {
continue
}
// Why: search-only directories (x without r) fail readdir but still
// resolve by direct candidate probing, like the per-command resolver.
entries = null
}
const entryByLookupName = new Map(
entries.map((entry) => [platform === 'win32' ? entry.toLowerCase() : entry, entry])
)
const entryByLookupName = entries
? new Map(entries.map((entry) => [platform === 'win32' ? entry.toLowerCase() : entry, entry]))
: null
for (const [commandName, executableNames] of executableNamesByCommand) {
if (resolved.has(commandName)) {
continue
}
for (const executableName of executableNames) {
const entry = entryByLookupName.get(
platform === 'win32' ? executableName.toLowerCase() : executableName
)
if (!entry) {
continue
let candidate: string
if (entryByLookupName) {
const entry = entryByLookupName.get(
platform === 'win32' ? executableName.toLowerCase() : executableName
)
if (!entry) {
continue
}
candidate = join(directory, entry)
} else {
candidate = join(directory, executableName)
}
const candidate = join(directory, entry)
if (isRunnableCommand(platform, candidate)) {
if (await isRunnableCommandAsync(platform, candidate)) {
resolved.set(commandName, candidate)
break
}
@@ -128,6 +143,25 @@ function isRunnableCommand(platform: NodeJS.Platform, candidate: string): boolea
}
}
async function isRunnableCommandAsync(
platform: NodeJS.Platform,
candidate: string
): Promise<boolean> {
try {
const stats = await stat(candidate)
if (!stats.isFile()) {
return false
}
if (platform === 'win32') {
return true
}
await access(candidate, constants.X_OK)
return true
} catch {
return false
}
}
function getBaseVersionManagerDirectories(platform: NodeJS.Platform, homePath: string): string[] {
const directories = [
join(homePath, '.volta', 'bin'),
@@ -221,10 +255,10 @@ export function resolveCliCommand(
return versionManagerCandidate ?? commandName
}
export function resolveCliCommands(
export async function resolveCliCommands(
commandNames: readonly string[],
options: ResolveCommandOptions = {}
): Map<string, string> {
): Promise<Map<string, string>> {
const platform = options.platform ?? process.platform
const pathEnv = options.pathEnv ?? process.env.PATH ?? process.env.Path ?? null
const pathDirectories = splitPath(pathEnv)
@@ -245,9 +279,14 @@ export function resolveCliCommands(
const resolved = new Map<string, string>()
// Why: agent detection resolves many commands at once. Reading each PATH
// directory once avoids hundreds of synchronous failed stat calls at startup.
resolveCommandsInDirectories(platform, pathDirectories, executableNamesByCommand, resolved)
resolveCommandsInDirectories(platform, installDirectories, executableNamesByCommand, resolved)
// directory once avoids hundreds of failed stat calls at startup.
await resolveCommandsInDirectories(platform, pathDirectories, executableNamesByCommand, resolved)
await resolveCommandsInDirectories(
platform,
installDirectories,
executableNamesByCommand,
resolved
)
for (const commandName of commandNamesUnique) {
if (!resolved.has(commandName)) {
resolved.set(commandName, commandName)
@@ -3,9 +3,14 @@ import { resolveCliCommands } from '../codex-cli/command'
// Why: one bulk filesystem pass avoids a `which`/`where` subprocess per agent;
// spawning that fan-out can hold Electron's main loop for over a second on macOS.
export function detectLocalCommands(commands: readonly string[]): Set<string> {
// The pass itself must stay async — it runs on the main process, where a slow
// PATH entry (dead network mount) would otherwise freeze the whole app.
export async function detectLocalCommands(commands: readonly string[]): Promise<Set<string>> {
if (commands.length === 0) {
return new Set()
}
try {
const resolvedCommands = resolveCliCommands(commands)
const resolvedCommands = await resolveCliCommands(commands)
return new Set(
commands.filter((command) => path.isAbsolute(resolvedCommands.get(command) ?? command))
)
+1 -1
View File
@@ -112,7 +112,7 @@ export async function detectInstalledAgents(context?: PreflightRuntimeContext):
KNOWN_TUI_AGENT_DETECTION_COMMANDS,
process.platform
)
const foundCommands = detectLocalCommands(probeCommands)
const foundCommands = await detectLocalCommands(probeCommands)
const agents = resolveDetectedTuiAgentIds(
KNOWN_TUI_AGENT_DETECTION_COMMANDS,
foundCommands,
+32 -2
View File
@@ -60,7 +60,10 @@ import { StarNagCard } from './components/StarNagCard'
import { StarNagAgentValueMomentObserver } from './components/star-nag/StarNagAgentValueMomentObserver'
import { StarNagToastHost } from './components/star-nag/StarNagToastHost'
import { SkillFreshnessNudge } from './components/skills/SkillFreshnessNudge'
import { SkillFreshnessUpdateDialog } from './components/skills/SkillFreshnessUpdateDialog'
import {
getSkillFreshnessUpdateDialogRequest,
subscribeSkillFreshnessUpdateDialog
} from './components/skills/skill-freshness-update-dialog'
import { TelemetryFirstLaunchSurface } from './components/TelemetryFirstLaunchSurface'
import { ZoomOverlay } from './components/ZoomOverlay'
import { onOnboardingReopened } from './components/onboarding/show-onboarding-event'
@@ -306,6 +309,33 @@ function WindowControls(): React.JSX.Element {
)
}
const SkillFreshnessUpdateDialog = lazy(
() =>
import('./components/skills/SkillFreshnessUpdateDialog').then((module) => ({
default: module.SkillFreshnessUpdateDialog
})),
{ reloadKey: 'skill-freshness-update-dialog' }
)
// Why: the dialog statically pulls the inline terminal (xterm/WebGL) and the
// Markdown engine; mount it only once an open request exists so those modules
// stay off the startup path.
function SkillFreshnessUpdateDialogGate(): React.JSX.Element | null {
const requested = useSyncExternalStore(
subscribeSkillFreshnessUpdateDialog,
getSkillFreshnessUpdateDialogRequest,
getSkillFreshnessUpdateDialogRequest
)
if (!requested) {
return null
}
return (
<Suspense fallback={null}>
<SkillFreshnessUpdateDialog />
</Suspense>
)
}
const Landing = lazy(() => import('./components/Landing'))
const WorktreeCreationPanel = lazy(
() => import('./components/worktree-creation/WorktreeCreationPanel')
@@ -2864,7 +2894,7 @@ function App(): React.JSX.Element {
surface="overlay"
compact
>
<SkillFreshnessUpdateDialog />
<SkillFreshnessUpdateDialogGate />
</RecoverableRenderErrorBoundary>
</LinkRoutingPreferenceDialogProvider>
</ConfirmationDialogProvider>