perf: merge AI agent startup discovery PR #751

This commit is contained in:
lucaronin
2026-05-27 23:59:59 +02:00
8 changed files with 235 additions and 63 deletions

View File

@@ -837,6 +837,7 @@ Vault guidance is intentionally short and vault-specific. General Tolaria produc
- Reads a local dismissal flag for the AI agents prompt (with a legacy fallback to the older Claude-only key)
- 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
- 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
- Persists dismissal locally once the user continues
### Remote Git Operations

View File

@@ -239,7 +239,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. 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, 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.
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.

View File

@@ -477,6 +477,7 @@ BASE_URL="http://localhost:5173" npx playwright test tests/smoke/<slug>.spec.ts
4. **Permission-mode UI and request plumbing**: Edit `src/lib/aiAgentPermissionMode.ts`, `src/components/AiPanel*.tsx`, `src/hooks/useCliAiAgent.ts`, and `src/utils/streamAiAgent.ts`
5. **Shared CLI runtime behavior**: Edit `src-tauri/src/cli_agent_runtime.rs` for process lifecycle, prompt wrapping, version probing, and common Tolaria MCP path handling.
6. **Agent-specific arguments/events**: Edit the per-agent adapter modules (`claude_cli.rs`, `codex_cli.rs`, `opencode_*`, `pi_*`, `gemini_*`, `kiro_*`). Keep Codex Safe on `read-only` + `untrusted` and Codex Power User on active-vault `workspace-write` + `never`, keep Pi, Gemini, and Kiro on transient MCP config, and do not use dangerous permission bypasses unless an ADR explicitly designs a new mode. Pi's transient agent directory must be seeded from the user's existing Pi agent directory before Tolaria MCP is merged so standalone provider/auth setup keeps working. Gemini Power User intentionally uses Gemini's `yolo` mode per ADR-0103. Kiro receives prompt content over stdin and writes Tolaria MCP config into `.kiro/settings/mcp.json` in the active vault.
7. **Availability probing**: Edit `src/hooks/useAiAgentsStatus.ts` and `src-tauri/src/ai_agents.rs` for AI-agent install/status detection. Keep renderer probing deferred until after first paint, skip it when AI features or AI surfaces are unavailable, and keep backend per-agent CLI checks parallel so missing tools do not serialize shell startup cost.
### Work with external MCP setup

View File

@@ -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

View File

@@ -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)]

View File

@@ -349,7 +349,9 @@ function MainApp({ noteWindowParams }: { noteWindowParams: NoteWindowParams | nu
onVaultReady: handleOnboardingVaultReady,
registerVault: registerVaultSelection,
}, vaultSwitcher.loaded)
const aiAgentsStatus = useAiAgentsStatus()
const aiAgentsStatus = useAiAgentsStatus({
enabled: aiFeaturesEnabled && !noteWindowParams && !aiWorkspaceWindow,
})
const aiAgentsOnboarding = useAiAgentsOnboarding(
aiFeaturesEnabled && onboarding.state.status === 'ready' && !noteWindowParams && !aiWorkspaceWindow,
)

View File

@@ -1,6 +1,7 @@
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'
import { AI_AGENT_DEFINITIONS, type AiAgentsStatus } from '../lib/aiAgents'
vi.mock('@tauri-apps/api/core', () => ({
invoke: vi.fn(),
@@ -13,53 +14,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' },
kiro: { installed: true, version: '0.4.0' },
}
}
function expectStatuses(statuses: AiAgentsStatus, expectedStatus: AiAgentsStatus[keyof AiAgentsStatus]['status']) {
for (const definition of AI_AGENT_DEFINITIONS) {
expect(statuses[definition.id].status).toBe(expectedStatus)
}
}
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())
expectStatuses(result.current, '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' })
expect(result.current.kiro).toEqual({ status: 'installed', version: '0.4.0' })
})
})
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(() => {
expectStatuses(result.current, '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()
})
})
})

View File

@@ -11,30 +11,87 @@ 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 =
| { kind: 'idle'; id: number }
| { kind: 'timeout'; id: ReturnType<typeof setTimeout> }
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?.bind(window) : undefined)
if (typeof requestIdle === 'function') {
return { kind: 'idle', id: requestIdle(callback) }
}
return { kind: 'timeout', id: setTimeout(callback, 0) }
}
function cancelIdle(handle: IdleHandle): void {
if (handle.kind === 'idle') {
const cancelIdleFn = (typeof window !== 'undefined' ? window.cancelIdleCallback?.bind(window) : undefined)
if (typeof cancelIdleFn === 'function') {
cancelIdleFn(handle.id)
}
return
}
clearTimeout(handle.id)
}
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
}