diff --git a/docs/ABSTRACTIONS.md b/docs/ABSTRACTIONS.md index 43e1d49f..9aa34c60 100644 --- a/docs/ABSTRACTIONS.md +++ b/docs/ABSTRACTIONS.md @@ -856,7 +856,7 @@ Vault guidance is intentionally short and vault-specific. General Tolaria produc - Only shows after vault onboarding has already resolved to a ready state - Uses `get_ai_agents_status`, whose backend checks Claude Code, Codex, OpenCode, Pi, Gemini, and Kiro by treating the app process path, login-shell path, and supported local/toolchain/app install locations, including nvm-managed Node installs plus Windows `.exe` and npm/pnpm/Scoop shim paths, as valid CLI-agent sources - App-managed Claude Code runs preserve the same user-managed Anthropic/provider env behavior by forwarding selected exported variables from the app process or the user's zsh/bash startup files without persisting those secrets -- The shared `useAiAgentsStatus` hook defers that command until after the first render and skips it when AI features are disabled or the current window cannot render AI status surfaces +- The shared `useAiAgentsStatus` hook defers that command until after the first render, skips it when AI features are disabled or the current window cannot render AI status surfaces, and falls back to missing-agent statuses if the native probe does not return promptly so first-launch onboarding keeps a recovery path - Persists dismissal locally once the user continues ### Remote Git Operations diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 415142c7..8fb83606 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -242,7 +242,7 @@ The main Tauri window derives its minimum width from the visible panes instead o The main Tauri window also persists its last normal size and screen position in the app config directory as `window-state.json`. The state stores logical window points, while `window_state.rs` migrates older physical-pixel state on read so Retina and non-Retina launches restore the same user-facing bounds. On startup, the restored frame applies only to the main window and clamps to the currently available monitor work areas, so stale coordinates from a disconnected display fall back to a visible placement. Maximized, fullscreen, minimized, and detached note-window frames are not written as the restore baseline. -Tauri setup keeps launch-time filesystem and subprocess work off the window creation critical path. Legacy `~/Laputa` housekeeping and the initial persisted-vault MCP bridge sync run on named background threads, so large legacy vaults, stale active-vault paths, or slow process startup cannot beachball the macOS app before React mounts. React still resyncs the bridge from `useVaultSwitcher` after the persisted selection loads, and no selected vault stops the bridge. AI-agent CLI availability probing is also off the first-paint path: the renderer defers `get_ai_agents_status` until an idle/timeout tick, skips it for disabled AI surfaces and secondary windows, and the Rust command fans per-agent CLI checks across Tokio's blocking pool. The HTML bootstrap also installs a Tauri-only one-shot watchdog: React reports readiness from an effect after the root commits, and if that readiness signal never arrives the WebView reloads once instead of leaving macOS users in an inert rendered shell. +Tauri setup keeps launch-time filesystem and subprocess work off the window creation critical path. Legacy `~/Laputa` housekeeping and the initial persisted-vault MCP bridge sync run on named background threads, so large legacy vaults, stale active-vault paths, or slow process startup cannot beachball the macOS app before React mounts. React still resyncs the bridge from `useVaultSwitcher` after the persisted selection loads, and no selected vault stops the bridge. AI-agent CLI availability probing is also off the first-paint path: the renderer defers `get_ai_agents_status` until an idle/timeout tick, skips it for disabled AI surfaces and secondary windows, falls back to missing-agent onboarding state if the status IPC does not settle promptly, and the Rust command fans per-agent CLI checks across Tokio's blocking pool with per-agent timeouts. The HTML bootstrap also installs a Tauri-only one-shot watchdog: React reports readiness from an effect after the root commits, and if that readiness signal never arrives the WebView reloads once instead of leaving macOS users in an inert rendered shell. Desktop startup registers `tauri-plugin-deep-link` and `tauri-plugin-single-instance` before setup so `tolaria://` links can focus the existing main window and deliver URL events to the renderer. `tauri.conf.json` declares the `tolaria` scheme for bundled desktop builds; Windows and Linux also run `register_all()` as a runtime repair path, while macOS relies on bundle registration. diff --git a/src-tauri/src/ai_agents.rs b/src-tauri/src/ai_agents.rs index f5818501..f42b2e73 100644 --- a/src-tauri/src/ai_agents.rs +++ b/src-tauri/src/ai_agents.rs @@ -1,4 +1,8 @@ use serde::{Deserialize, Serialize}; +use std::time::Duration; +use tokio::task::JoinHandle; + +const AI_AGENT_STATUS_PROBE_TIMEOUT: Duration = Duration::from_secs(5); #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "snake_case")] @@ -104,16 +108,32 @@ pub async fn get_ai_agents_status() -> AiAgentsStatus { 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); + let (claude, codex, opencode, pi, gemini, kiro) = tokio::join!( + availability_or_missing(claude, AI_AGENT_STATUS_PROBE_TIMEOUT), + availability_or_missing(codex, AI_AGENT_STATUS_PROBE_TIMEOUT), + availability_or_missing(opencode, AI_AGENT_STATUS_PROBE_TIMEOUT), + availability_or_missing(pi, AI_AGENT_STATUS_PROBE_TIMEOUT), + availability_or_missing(gemini, AI_AGENT_STATUS_PROBE_TIMEOUT), + availability_or_missing(kiro, AI_AGENT_STATUS_PROBE_TIMEOUT) + ); AiAgentsStatus { - 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()), + claude_code: claude, + codex, + opencode, + pi, + gemini, + kiro, + } +} + +async fn availability_or_missing( + probe: JoinHandle, + timeout: Duration, +) -> AiAgentAvailability { + match tokio::time::timeout(timeout, probe).await { + Ok(Ok(availability)) => availability, + Ok(Err(_)) | Err(_) => missing_availability(), } } @@ -295,6 +315,22 @@ mod tests { .all(|installed| matches!(installed, true | false))); } + #[tokio::test] + async fn availability_probe_timeout_returns_missing_status() { + let handle = tokio::task::spawn_blocking(|| { + std::thread::sleep(std::time::Duration::from_millis(50)); + AiAgentAvailability { + installed: true, + version: Some("late".into()), + } + }); + + let status = availability_or_missing(handle, std::time::Duration::from_millis(1)).await; + + assert!(!status.installed); + assert_eq!(status.version, None); + } + #[test] fn map_claude_done_event_preserves_completion_signal() { let mapped = map_claude_event(crate::claude_cli::ClaudeStreamEvent::Done); diff --git a/src/hooks/useAiAgentsStatus.test.ts b/src/hooks/useAiAgentsStatus.test.ts index 0e902c78..6c265b43 100644 --- a/src/hooks/useAiAgentsStatus.test.ts +++ b/src/hooks/useAiAgentsStatus.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' import { act, renderHook, waitFor } from '@testing-library/react' -import { useAiAgentsStatus } from './useAiAgentsStatus' +import { AI_AGENTS_STATUS_PROBE_TIMEOUT_MS, useAiAgentsStatus } from './useAiAgentsStatus' import { AI_AGENT_DEFINITIONS, type AiAgentsStatus } from '../lib/aiAgents' vi.mock('@tauri-apps/api/core', () => ({ @@ -141,5 +141,23 @@ describe('useAiAgentsStatus', () => { expect(mockInvoke).not.toHaveBeenCalled() }) + + it('falls back to missing when the status probe never resolves', async () => { + mockInvoke.mockReturnValue(new Promise(() => {})) + + const { result } = renderHook(() => useAiAgentsStatus({ enabled: true })) + + await act(async () => { + await vi.advanceTimersByTimeAsync(0) + }) + expect(mockInvoke).toHaveBeenCalledWith('get_ai_agents_status') + expectStatuses(result.current, 'checking') + + await act(async () => { + await vi.advanceTimersByTimeAsync(AI_AGENTS_STATUS_PROBE_TIMEOUT_MS + 1) + }) + + expectStatuses(result.current, 'missing') + }) }) }) diff --git a/src/hooks/useAiAgentsStatus.ts b/src/hooks/useAiAgentsStatus.ts index 07ca2985..a551d1d0 100644 --- a/src/hooks/useAiAgentsStatus.ts +++ b/src/hooks/useAiAgentsStatus.ts @@ -11,6 +11,8 @@ import { type RawAiAgentsStatus = Partial> +export const AI_AGENTS_STATUS_PROBE_TIMEOUT_MS = 5000 + interface UseAiAgentsStatusOptions { /** * When false, the hook stays in its initial state and never calls the @@ -65,17 +67,32 @@ export function useAiAgentsStatus(options?: UseAiAgentsStatusOptions): AiAgentsS } let cancelled = false + let timeoutId: ReturnType | null = null + + const clearProbeTimeout = () => { + if (timeoutId === null) return + clearTimeout(timeoutId) + timeoutId = null + } const fire = () => { if (cancelled) return + timeoutId = setTimeout(() => { + if (!cancelled) { + setStatuses(createMissingAiAgentsStatus()) + } + }, AI_AGENTS_STATUS_PROBE_TIMEOUT_MS) + tauriCall('get_ai_agents_status') .then((result) => { + clearProbeTimeout() if (!cancelled) { setStatuses(normalizeAiAgentsStatus(result)) } }) .catch(() => { + clearProbeTimeout() if (!cancelled) { setStatuses(createMissingAiAgentsStatus()) } @@ -89,6 +106,7 @@ export function useAiAgentsStatus(options?: UseAiAgentsStatusOptions): AiAgentsS return () => { cancelled = true + clearProbeTimeout() cancelIdle(handle) } }, [enabled])