mirror of
https://github.com/stablyai/orca.git
synced 2026-10-18 00:03:20 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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))
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user