perf: defer and parallelize AI agent CLI discovery at startup
get_ai_agents_status used to probe all six agent CLIs (claude_code,
codex, opencode, pi, gemini, kiro) sequentially during cold start. For
each agent missing from PATH it would fall through to three login-shell
spawns (`<shell> -lc 'command -v <agent>'`), each evaluating the user's
full zsh init. On a machine with no agents installed this added ~5 s to
cold start.
The result is not on the first-paint render path. It feeds the status
bar AI badge, the AI tab in settings, and the AI agents onboarding
prompt — all of which are gated behind aiFeaturesEnabled and the
onboarding flow, so they happily render nothing while the probe is
pending.
Three additive changes:
* useAiAgentsStatus now takes an `enabled` option. When false (AI
features off, or running in a detached note window) the probe never
fires. App.tsx passes `aiFeaturesEnabled && !noteWindowParams`.
* When enabled, the invoke call is deferred via requestIdleCallback
(with setTimeout(0) fallback because WKWebView lacks the API) so it
lands after first paint instead of during it.
* Rust get_ai_agents_status is now async and fans the six check_cli()
calls out under tokio::task::spawn_blocking + tokio::join!. Per-agent
check_cli() signatures stay sync, so run_agent_stream callers are
unaffected and the per-agent unit tests need no changes.
Cold-start measurements on this machine (no agents on PATH, dev mode):
Baseline (main, AI on): cascade settles at t+15.5 s
Patched, AI features off: t+5.0 s
Patched, AI features on: t+6.0 s,
AI status arrives ~1.6 s after first
paint, fully off the critical path.
Fixes: https://github.com/refactoringhq/tolaria/issues/726
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -81,14 +81,44 @@ impl AiAgentStreamRequest {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_ai_agents_status() -> AiAgentsStatus {
|
||||
/// Probe every supported AI-agent CLI in parallel.
|
||||
///
|
||||
/// Each per-agent `check_cli()` is synchronous and can block for up to ~1 s
|
||||
/// when the binary is missing and we fall through to the login-shell
|
||||
/// fallback (`/bin/zsh -lc 'command -v <agent>'` etc., evaluating the full
|
||||
/// shell startup). Running them sequentially used to add ~5 s to cold start
|
||||
/// when no agents are installed. Fan them out across Tokio's blocking pool
|
||||
/// so the user-perceived wall time is the slowest single probe rather than
|
||||
/// the sum of all six.
|
||||
///
|
||||
/// A panicking probe is mapped to `installed: false` so the IPC handler
|
||||
/// always returns a fully populated `AiAgentsStatus` and the frontend can
|
||||
/// keep rendering.
|
||||
pub async fn get_ai_agents_status() -> AiAgentsStatus {
|
||||
let claude = tokio::task::spawn_blocking(availability_from_claude);
|
||||
let codex = tokio::task::spawn_blocking(crate::codex_cli::check_cli);
|
||||
let opencode = tokio::task::spawn_blocking(crate::opencode_cli::check_cli);
|
||||
let pi = tokio::task::spawn_blocking(crate::pi_cli::check_cli);
|
||||
let gemini = tokio::task::spawn_blocking(crate::gemini_cli::check_cli);
|
||||
let kiro = tokio::task::spawn_blocking(crate::kiro_cli::check_cli);
|
||||
|
||||
let (claude, codex, opencode, pi, gemini, kiro) =
|
||||
tokio::join!(claude, codex, opencode, pi, gemini, kiro);
|
||||
|
||||
AiAgentsStatus {
|
||||
claude_code: availability_from_claude(),
|
||||
codex: crate::codex_cli::check_cli(),
|
||||
opencode: crate::opencode_cli::check_cli(),
|
||||
pi: crate::pi_cli::check_cli(),
|
||||
gemini: crate::gemini_cli::check_cli(),
|
||||
kiro: crate::kiro_cli::check_cli(),
|
||||
claude_code: claude.unwrap_or_else(|_| missing_availability()),
|
||||
codex: codex.unwrap_or_else(|_| missing_availability()),
|
||||
opencode: opencode.unwrap_or_else(|_| missing_availability()),
|
||||
pi: pi.unwrap_or_else(|_| missing_availability()),
|
||||
gemini: gemini.unwrap_or_else(|_| missing_availability()),
|
||||
kiro: kiro.unwrap_or_else(|_| missing_availability()),
|
||||
}
|
||||
}
|
||||
|
||||
fn missing_availability() -> AiAgentAvailability {
|
||||
AiAgentAvailability {
|
||||
installed: false,
|
||||
version: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -245,15 +275,16 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_status_contains_all_agents() {
|
||||
let status = get_ai_agents_status();
|
||||
#[tokio::test]
|
||||
async fn normalize_status_contains_all_agents() {
|
||||
let status = get_ai_agents_status().await;
|
||||
let install_flags = [
|
||||
status.claude_code.installed,
|
||||
status.codex.installed,
|
||||
status.opencode.installed,
|
||||
status.pi.installed,
|
||||
status.gemini.installed,
|
||||
status.kiro.installed,
|
||||
];
|
||||
|
||||
assert!(install_flags
|
||||
|
||||
@@ -62,8 +62,8 @@ pub fn check_claude_cli() -> ClaudeCliStatus {
|
||||
|
||||
#[cfg(desktop)]
|
||||
#[tauri::command]
|
||||
pub fn get_ai_agents_status() -> AiAgentsStatus {
|
||||
crate::ai_agents::get_ai_agents_status()
|
||||
pub async fn get_ai_agents_status() -> AiAgentsStatus {
|
||||
crate::ai_agents::get_ai_agents_status().await
|
||||
}
|
||||
|
||||
#[cfg(desktop)]
|
||||
|
||||
@@ -330,7 +330,9 @@ function App() {
|
||||
onVaultReady: handleOnboardingVaultReady,
|
||||
registerVault: registerVaultSelection,
|
||||
}, vaultSwitcher.loaded)
|
||||
const aiAgentsStatus = useAiAgentsStatus()
|
||||
const aiAgentsStatus = useAiAgentsStatus({
|
||||
enabled: aiFeaturesEnabled && !noteWindowParams,
|
||||
})
|
||||
const aiAgentsOnboarding = useAiAgentsOnboarding(aiFeaturesEnabled && onboarding.state.status === 'ready' && !noteWindowParams)
|
||||
|
||||
// Onboarding can briefly own the vault path for a newly created/opened vault
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { renderHook, waitFor } from '@testing-library/react'
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import { act, renderHook, waitFor } from '@testing-library/react'
|
||||
import { useAiAgentsStatus } from './useAiAgentsStatus'
|
||||
|
||||
vi.mock('@tauri-apps/api/core', () => ({
|
||||
@@ -13,53 +13,132 @@ vi.mock('../mock-tauri', () => ({
|
||||
|
||||
const { mockInvoke } = await import('../mock-tauri') as { mockInvoke: ReturnType<typeof vi.fn> }
|
||||
|
||||
function installedStatusResponse() {
|
||||
return {
|
||||
claude_code: { installed: true, version: '1.0.20' },
|
||||
codex: { installed: false, version: null },
|
||||
opencode: { installed: true, version: '0.3.1' },
|
||||
pi: { installed: true, version: '0.70.2' },
|
||||
gemini: { installed: true, version: '0.5.1' },
|
||||
}
|
||||
}
|
||||
|
||||
describe('useAiAgentsStatus', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('starts in checking state and resolves agent statuses', async () => {
|
||||
mockInvoke.mockImplementation((command: string) => {
|
||||
if (command === 'get_ai_agents_status') {
|
||||
return Promise.resolve({
|
||||
claude_code: { installed: true, version: '1.0.20' },
|
||||
codex: { installed: false, version: null },
|
||||
opencode: { installed: true, version: '0.3.1' },
|
||||
pi: { installed: true, version: '0.70.2' },
|
||||
gemini: { installed: true, version: '0.5.1' },
|
||||
})
|
||||
}
|
||||
return Promise.resolve(null)
|
||||
describe('with real timers', () => {
|
||||
it('starts in checking state and resolves agent statuses', async () => {
|
||||
mockInvoke.mockImplementation((command: string) => {
|
||||
if (command === 'get_ai_agents_status') {
|
||||
return Promise.resolve(installedStatusResponse())
|
||||
}
|
||||
return Promise.resolve(null)
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => useAiAgentsStatus())
|
||||
|
||||
expect(result.current.claude_code.status).toBe('checking')
|
||||
expect(result.current.codex.status).toBe('checking')
|
||||
expect(result.current.opencode.status).toBe('checking')
|
||||
expect(result.current.pi.status).toBe('checking')
|
||||
expect(result.current.gemini.status).toBe('checking')
|
||||
|
||||
// The probe is deferred via requestIdleCallback / setTimeout(0). With
|
||||
// real timers the deferred callback fires on the next event-loop tick.
|
||||
await waitFor(() => {
|
||||
expect(result.current.claude_code).toEqual({ status: 'installed', version: '1.0.20' })
|
||||
expect(result.current.codex).toEqual({ status: 'missing', version: null })
|
||||
expect(result.current.opencode).toEqual({ status: 'installed', version: '0.3.1' })
|
||||
expect(result.current.pi).toEqual({ status: 'installed', version: '0.70.2' })
|
||||
expect(result.current.gemini).toEqual({ status: 'installed', version: '0.5.1' })
|
||||
})
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => useAiAgentsStatus())
|
||||
it('falls back to missing when the status call fails', async () => {
|
||||
mockInvoke.mockRejectedValue(new Error('failed'))
|
||||
|
||||
expect(result.current.claude_code.status).toBe('checking')
|
||||
expect(result.current.codex.status).toBe('checking')
|
||||
expect(result.current.opencode.status).toBe('checking')
|
||||
expect(result.current.pi.status).toBe('checking')
|
||||
expect(result.current.gemini.status).toBe('checking')
|
||||
const { result } = renderHook(() => useAiAgentsStatus())
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.claude_code).toEqual({ status: 'installed', version: '1.0.20' })
|
||||
expect(result.current.codex).toEqual({ status: 'missing', version: null })
|
||||
expect(result.current.opencode).toEqual({ status: 'installed', version: '0.3.1' })
|
||||
expect(result.current.pi).toEqual({ status: 'installed', version: '0.70.2' })
|
||||
expect(result.current.gemini).toEqual({ status: 'installed', version: '0.5.1' })
|
||||
await waitFor(() => {
|
||||
expect(result.current.claude_code.status).toBe('missing')
|
||||
expect(result.current.codex.status).toBe('missing')
|
||||
expect(result.current.opencode.status).toBe('missing')
|
||||
expect(result.current.pi.status).toBe('missing')
|
||||
expect(result.current.gemini.status).toBe('missing')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('falls back to missing when the status call fails', async () => {
|
||||
mockInvoke.mockRejectedValue(new Error('failed'))
|
||||
describe('deferral and gating (fake timers)', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => useAiAgentsStatus())
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.claude_code.status).toBe('missing')
|
||||
expect(result.current.codex.status).toBe('missing')
|
||||
expect(result.current.opencode.status).toBe('missing')
|
||||
expect(result.current.pi.status).toBe('missing')
|
||||
expect(result.current.gemini.status).toBe('missing')
|
||||
it('does not invoke the probe when disabled', async () => {
|
||||
mockInvoke.mockResolvedValue(installedStatusResponse())
|
||||
|
||||
const { result } = renderHook(() => useAiAgentsStatus({ enabled: false }))
|
||||
|
||||
await vi.runAllTimersAsync()
|
||||
|
||||
expect(mockInvoke).not.toHaveBeenCalled()
|
||||
// Status stays in initial 'checking' state because no consumer renders it
|
||||
// when the gate is off, so it does not need to be reset.
|
||||
expect(result.current.claude_code.status).toBe('checking')
|
||||
})
|
||||
|
||||
it('defers the probe until the next idle callback / timeout tick', async () => {
|
||||
mockInvoke.mockResolvedValue(installedStatusResponse())
|
||||
|
||||
renderHook(() => useAiAgentsStatus({ enabled: true }))
|
||||
|
||||
// Immediately after mount, the probe should not have fired yet — it is
|
||||
// queued behind requestIdleCallback (or setTimeout(0) in jsdom/WebKit).
|
||||
expect(mockInvoke).not.toHaveBeenCalled()
|
||||
|
||||
await act(async () => {
|
||||
await vi.runAllTimersAsync()
|
||||
})
|
||||
|
||||
expect(mockInvoke).toHaveBeenCalledWith('get_ai_agents_status')
|
||||
})
|
||||
|
||||
it('fires a fresh probe when enabled toggles from false to true', async () => {
|
||||
mockInvoke.mockResolvedValue(installedStatusResponse())
|
||||
|
||||
const { rerender } = renderHook(
|
||||
({ enabled }: { enabled: boolean }) => useAiAgentsStatus({ enabled }),
|
||||
{ initialProps: { enabled: false } },
|
||||
)
|
||||
|
||||
await vi.runAllTimersAsync()
|
||||
expect(mockInvoke).not.toHaveBeenCalled()
|
||||
|
||||
rerender({ enabled: true })
|
||||
await act(async () => {
|
||||
await vi.runAllTimersAsync()
|
||||
})
|
||||
|
||||
expect(mockInvoke).toHaveBeenCalledTimes(1)
|
||||
expect(mockInvoke).toHaveBeenCalledWith('get_ai_agents_status')
|
||||
})
|
||||
|
||||
it('cancels the deferred probe when the hook unmounts before idle fires', async () => {
|
||||
mockInvoke.mockResolvedValue(installedStatusResponse())
|
||||
|
||||
const { unmount } = renderHook(() => useAiAgentsStatus({ enabled: true }))
|
||||
|
||||
// Unmount before the idle callback runs.
|
||||
unmount()
|
||||
await vi.runAllTimersAsync()
|
||||
|
||||
expect(mockInvoke).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -11,30 +11,84 @@ import {
|
||||
|
||||
type RawAiAgentsStatus = Partial<Record<AiAgentId, { installed?: boolean | null; version?: string | null }>>
|
||||
|
||||
interface UseAiAgentsStatusOptions {
|
||||
/**
|
||||
* When false, the hook stays in its initial state and never calls the
|
||||
* Tauri probe. Used to skip the ~1 s discovery cost when AI features are
|
||||
* disabled or when running in a detached note window where the result is
|
||||
* never rendered.
|
||||
*
|
||||
* Defaults to true to preserve existing behaviour for callers that pass
|
||||
* no options.
|
||||
*/
|
||||
enabled?: boolean
|
||||
}
|
||||
|
||||
type IdleHandle = number
|
||||
|
||||
function tauriCall<T>(command: string): Promise<T> {
|
||||
return isTauri() ? invoke<T>(command) : mockInvoke<T>(command)
|
||||
}
|
||||
|
||||
export function useAiAgentsStatus(): AiAgentsStatus {
|
||||
function scheduleIdle(callback: () => void): IdleHandle {
|
||||
const requestIdle = (typeof window !== 'undefined' ? window.requestIdleCallback : undefined)
|
||||
if (typeof requestIdle === 'function') {
|
||||
return requestIdle(callback) as IdleHandle
|
||||
}
|
||||
return setTimeout(callback, 0) as unknown as IdleHandle
|
||||
}
|
||||
|
||||
function cancelIdle(handle: IdleHandle): void {
|
||||
const cancelIdleFn = (typeof window !== 'undefined' ? window.cancelIdleCallback : undefined)
|
||||
if (typeof cancelIdleFn === 'function') {
|
||||
cancelIdleFn(handle)
|
||||
}
|
||||
// Always also clearTimeout — in environments without requestIdleCallback the
|
||||
// handle was created by setTimeout, and clearTimeout silently no-ops on
|
||||
// unknown handles, so it is safe to call unconditionally.
|
||||
clearTimeout(handle as unknown as ReturnType<typeof setTimeout>)
|
||||
}
|
||||
|
||||
export function useAiAgentsStatus(options?: UseAiAgentsStatusOptions): AiAgentsStatus {
|
||||
const enabled = options?.enabled ?? true
|
||||
const [statuses, setStatuses] = useState<AiAgentsStatus>(createCheckingAiAgentsStatus())
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) {
|
||||
// Skip the probe entirely. Status is intentionally NOT reset — last-known
|
||||
// results stay in memory across enabled/disabled toggles so that a brief
|
||||
// disable does not blank out the badge.
|
||||
return
|
||||
}
|
||||
|
||||
let cancelled = false
|
||||
|
||||
tauriCall<RawAiAgentsStatus>('get_ai_agents_status')
|
||||
.then((result) => {
|
||||
if (!cancelled) {
|
||||
setStatuses(normalizeAiAgentsStatus(result))
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) {
|
||||
setStatuses(createMissingAiAgentsStatus())
|
||||
}
|
||||
})
|
||||
const fire = () => {
|
||||
if (cancelled) return
|
||||
|
||||
return () => { cancelled = true }
|
||||
}, [])
|
||||
tauriCall<RawAiAgentsStatus>('get_ai_agents_status')
|
||||
.then((result) => {
|
||||
if (!cancelled) {
|
||||
setStatuses(normalizeAiAgentsStatus(result))
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) {
|
||||
setStatuses(createMissingAiAgentsStatus())
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Defer the probe so it does not run on the cold-start critical path.
|
||||
// requestIdleCallback is unavailable in WKWebView (Tauri's macOS web view),
|
||||
// so fall back to setTimeout(0).
|
||||
const handle = scheduleIdle(fire)
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
cancelIdle(handle)
|
||||
}
|
||||
}, [enabled])
|
||||
|
||||
return statuses
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user