diff --git a/src-tauri/src/ai_agents.rs b/src-tauri/src/ai_agents.rs index 927892c9..ed084167 100644 --- a/src-tauri/src/ai_agents.rs +++ b/src-tauri/src/ai_agents.rs @@ -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 '` 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 diff --git a/src-tauri/src/commands/ai.rs b/src-tauri/src/commands/ai.rs index c552dd8c..5cb2f21d 100644 --- a/src-tauri/src/commands/ai.rs +++ b/src-tauri/src/commands/ai.rs @@ -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)] diff --git a/src/App.tsx b/src/App.tsx index d3b1bc39..ec8de3dc 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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 diff --git a/src/hooks/useAiAgentsStatus.test.ts b/src/hooks/useAiAgentsStatus.test.ts index bb7296ae..54199390 100644 --- a/src/hooks/useAiAgentsStatus.test.ts +++ b/src/hooks/useAiAgentsStatus.test.ts @@ -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 } +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() }) }) }) diff --git a/src/hooks/useAiAgentsStatus.ts b/src/hooks/useAiAgentsStatus.ts index 887f573c..43b940a3 100644 --- a/src/hooks/useAiAgentsStatus.ts +++ b/src/hooks/useAiAgentsStatus.ts @@ -11,30 +11,84 @@ import { type RawAiAgentsStatus = Partial> +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(command: string): Promise { return isTauri() ? invoke(command) : mockInvoke(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) +} + +export function useAiAgentsStatus(options?: UseAiAgentsStatusOptions): AiAgentsStatus { + const enabled = options?.enabled ?? true const [statuses, setStatuses] = useState(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('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('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 }