Compare commits

..

3 Commits

Author SHA1 Message Date
lucaronin
bf3caab862 fix: recover blocked native folder picker 2026-06-07 15:00:51 +02:00
lucaronin
2556907f36 fix: launch Pi CLI from Windows shims 2026-06-07 12:04:55 +02:00
lucaronin
8e3f8fbb77 fix: bound ai agent startup status probe 2026-06-07 05:46:41 +02:00
9 changed files with 240 additions and 41 deletions

View File

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

View File

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

View File

@@ -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<AiAgentAvailability>,
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);

View File

@@ -18,8 +18,12 @@ pub(crate) fn build_command(
request.permission_mode,
)?;
let mut command = crate::hidden_command(binary);
let target = crate::cli_agent_runtime::command_target_avoiding_windows_cmd_shim(binary)?;
let mut command = crate::hidden_command(&target.program);
crate::cli_agent_runtime::configure_agent_command_environment(&mut command, binary);
if let Some(first_arg) = target.first_arg {
command.arg(first_arg);
}
command
.args(build_args())
.arg(build_prompt(request))
@@ -300,10 +304,15 @@ mod tests {
}
fn assert_pi_json_mode_args(args: &[String]) {
assert_eq!(args[0], "--mode");
assert_eq!(args[1], "json");
assert!(args.contains(&"--no-session".to_string()));
assert!(args.contains(&"--extension".to_string()));
assert_eq!(
(
args.first().map(String::as_str),
args.get(1).map(String::as_str),
args.iter().any(|arg| arg == "--no-session"),
args.iter().any(|arg| arg == "--extension"),
),
(Some("--mode"), Some("json"), true, true)
);
}
#[test]
@@ -324,10 +333,20 @@ mod tests {
}
fn assert_command_identity(command: &std::process::Command, actual_args: &[&OsStr]) {
assert_eq!(command.get_program(), OsStr::new("pi"));
assert_eq!(actual_args[0], OsStr::new("--mode"));
assert_eq!(actual_args[1], OsStr::new("json"));
assert_eq!(actual_args.last(), Some(&OsStr::new("Rename the note")));
assert_eq!(
(
command.get_program(),
actual_args.first().copied(),
actual_args.get(1).copied(),
actual_args.last().copied(),
),
(
OsStr::new("pi"),
Some(OsStr::new("--mode")),
Some(OsStr::new("json")),
Some(OsStr::new("Rename the note")),
)
);
}
fn assert_command_vault_scope(
@@ -340,6 +359,50 @@ mod tests {
assert!(agent_dir.join("mcp.json").exists());
}
#[test]
fn command_avoids_windows_cmd_shim_for_prompt_args() {
let _env_lock = PI_AGENT_ENV_LOCK.lock().unwrap();
let source_agent_dir = tempfile::tempdir().unwrap();
let agent_dir = tempfile::tempdir().unwrap();
let _guard = EnvGuard::set("PI_CODING_AGENT_DIR", source_agent_dir.path());
let shim = agent_dir.path().join("pi.cmd");
let launcher = agent_dir
.path()
.join("node_modules")
.join("@withpi")
.join("pi")
.join("bin")
.join("pi.exe");
std::fs::create_dir_all(launcher.parent().unwrap()).unwrap();
std::fs::write(&launcher, "native pi launcher").unwrap();
std::fs::write(
&shim,
r#"@ECHO off
"%~dp0\node_modules\@withpi\pi\bin\pi.exe" %*
"#,
)
.unwrap();
let command = build_command(&shim, &request(), agent_dir.path()).unwrap();
let actual_args = command.get_args().collect::<Vec<_>>();
assert_eq!(
(
command.get_program() != shim.as_os_str(),
command.get_program(),
actual_args.first().copied(),
actual_args.last().copied(),
),
(
true,
launcher.as_os_str(),
Some(OsStr::new("--mode")),
Some(OsStr::new("Rename the note")),
),
"Pi npm .cmd shims cannot be spawned directly on Windows"
);
}
#[test]
fn command_seeds_temp_agent_dir_from_existing_pi_config() {
let _env_lock = PI_AGENT_ENV_LOCK.lock().unwrap();
@@ -429,10 +492,20 @@ mod tests {
}
fn assert_base_mcp_config(json: &serde_json::Value) {
assert_eq!(json["settings"]["toolPrefix"], "none");
assert_eq!(json["mcpServers"]["tolaria"]["command"], "node");
assert_eq!(json["mcpServers"]["tolaria"]["lifecycle"], "lazy");
assert_eq!(json["mcpServers"]["tolaria"]["directTools"], true);
assert_eq!(
(
&json["settings"]["toolPrefix"],
&json["mcpServers"]["tolaria"]["command"],
&json["mcpServers"]["tolaria"]["lifecycle"],
&json["mcpServers"]["tolaria"]["directTools"],
),
(
&serde_json::json!("none"),
&serde_json::json!("node"),
&serde_json::json!("lazy"),
&serde_json::json!(true),
)
);
}
fn assert_tolaria_mcp_env(json: &serde_json::Value) {

View File

@@ -88,12 +88,19 @@ fn path_from_successful_output(output: &std::process::Output) -> Option<PathBuf>
}
fn first_existing_path(stdout: &str) -> Option<PathBuf> {
first_existing_path_for_platform(stdout, cfg!(windows))
}
fn first_existing_path_for_platform(stdout: &str, windows: bool) -> Option<PathBuf> {
stdout.lines().find_map(|line| {
let trimmed = line.trim();
if trimmed.is_empty() {
return None;
}
let candidate = PathBuf::from(trimmed);
if windows && !crate::cli_agent_runtime::has_windows_cli_extension(&candidate) {
return None;
}
candidate.exists().then_some(candidate)
})
}
@@ -358,6 +365,18 @@ mod tests {
assert_eq!(first_existing_path(&stdout), Some(pi));
}
#[test]
fn first_existing_windows_path_skips_extensionless_npm_wrapper() {
let dir = tempfile::tempdir().unwrap();
let wrapper = dir.path().join("pi");
let shim = dir.path().join("pi.cmd");
std::fs::write(&wrapper, "#!/bin/sh\n").unwrap();
std::fs::write(&shim, "@ECHO off\n").unwrap();
let stdout = format!("{}\n{}\n", wrapper.display(), shim.display());
assert_eq!(first_existing_path_for_platform(&stdout, true), Some(shim));
}
#[cfg(unix)]
#[test]
fn command_path_from_shell_finds_pi_from_interactive_login_shell() {

View File

@@ -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')
})
})
})

View File

@@ -11,6 +11,8 @@ import {
type RawAiAgentsStatus = Partial<Record<AiAgentId, { installed?: boolean | null; version?: string | null }>>
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<typeof setTimeout> | 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<RawAiAgentsStatus>('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])

View File

@@ -9,6 +9,7 @@ vi.mock('../lib/appUpdater', () => ({
RESTART_REQUIRED_FOLDER_PICKER_MESSAGE:
'Tolaria needs a restart before macOS can open another folder picker. Restart to apply the downloaded update and try again.',
isRestartRequiredAfterUpdate: vi.fn(() => false),
markRestartRequiredAfterUpdate: vi.fn(),
}))
const openMock = vi.fn()
@@ -21,12 +22,16 @@ import { pickFolder } from './vault-dialog'
import { isTauri } from '../mock-tauri'
import {
isRestartRequiredAfterUpdate,
markRestartRequiredAfterUpdate,
RESTART_REQUIRED_FOLDER_PICKER_MESSAGE,
} from '../lib/appUpdater'
describe('pickFolder', () => {
beforeEach(() => {
vi.restoreAllMocks()
vi.clearAllMocks()
openMock.mockReset()
vi.mocked(isRestartRequiredAfterUpdate).mockReturnValue(false)
})
it('returns user input from prompt in browser mode', async () => {
@@ -70,6 +75,15 @@ describe('pickFolder', () => {
await expect(pickFolder('Select vault')).rejects.toThrow(RESTART_REQUIRED_FOLDER_PICKER_MESSAGE)
})
it('translates an NSOpenPanel panic into the restart-required folder picker error', async () => {
vi.mocked(isTauri).mockReturnValue(true)
vi.mocked(isRestartRequiredAfterUpdate).mockReturnValue(false)
openMock.mockRejectedValue('panic: unexpected NULL returned from +[NSOpenPanel openPanel]')
await expect(pickFolder('Select vault')).rejects.toThrow(RESTART_REQUIRED_FOLDER_PICKER_MESSAGE)
expect(markRestartRequiredAfterUpdate).toHaveBeenCalledOnce()
})
it('normalizes a native single-selection array to its first folder path', async () => {
vi.mocked(isTauri).mockReturnValue(true)
vi.mocked(isRestartRequiredAfterUpdate).mockReturnValue(false)
@@ -98,6 +112,7 @@ describe('pickFolder', () => {
const secondRequest = pickFolder('Open vault folder')
await expect(secondRequest).resolves.toBeNull()
await new Promise(resolve => setTimeout(resolve, 0))
expect(openMock).toHaveBeenCalledTimes(1)
resolveOpen?.('/Users/test/restored-vault')

View File

@@ -7,9 +7,12 @@
import { isTauri } from '../mock-tauri'
import {
isRestartRequiredAfterUpdate,
markRestartRequiredAfterUpdate,
RESTART_REQUIRED_FOLDER_PICKER_MESSAGE,
} from '../lib/appUpdater'
const NS_OPEN_PANEL_UNAVAILABLE_MARKER = 'unexpected NULL returned from +[NSOpenPanel openPanel]'
export class NativeFolderPickerBlockedError extends Error {
constructor(message = RESTART_REQUIRED_FOLDER_PICKER_MESSAGE) {
super(message)
@@ -23,6 +26,16 @@ export function isNativeFolderPickerBlockedError(
return error instanceof NativeFolderPickerBlockedError
}
function errorMessage(error: unknown): string {
if (typeof error === 'string') return error
if (error instanceof Error) return error.message
return ''
}
function isUnavailableNativeFolderPicker(error: unknown): boolean {
return errorMessage(error).includes(NS_OPEN_PANEL_UNAVAILABLE_MARKER)
}
export function formatFolderPickerActionError(
action: string,
error: unknown,
@@ -31,12 +44,7 @@ export function formatFolderPickerActionError(
return error.message
}
const message =
typeof error === 'string'
? error
: error instanceof Error
? error.message
: ''
const message = errorMessage(error)
return message ? `${action}: ${message}` : action
}
@@ -77,6 +85,28 @@ function normalizePickedFolderPath(selected: string | string[] | null): string |
let folderPickerRequestInFlight = false
async function pickNativeFolder(title?: string): Promise<string | null> {
if (isRestartRequiredAfterUpdate()) {
throw new NativeFolderPickerBlockedError()
}
try {
const { open } = await import('@tauri-apps/plugin-dialog')
const selected = await open({
directory: true,
multiple: false,
title: title ?? 'Select folder',
})
return normalizePickedFolderPath(selected)
} catch (error) {
if (isUnavailableNativeFolderPicker(error)) {
markRestartRequiredAfterUpdate()
throw new NativeFolderPickerBlockedError()
}
throw error
}
}
/**
* Opens a native folder picker dialog (Tauri) or falls back to prompt (browser).
* Returns the selected folder path, or null if the user cancelled.
@@ -87,17 +117,7 @@ export async function pickFolder(title?: string): Promise<string | null> {
folderPickerRequestInFlight = true
try {
if (isTauri()) {
if (isRestartRequiredAfterUpdate()) {
throw new NativeFolderPickerBlockedError()
}
const { open } = await import('@tauri-apps/plugin-dialog')
const selected = await open({
directory: true,
multiple: false,
title: title ?? 'Select folder',
})
return normalizePickedFolderPath(selected)
return await pickNativeFolder(title)
}
return normalizePickedFolderPath(prompt(title ?? 'Enter folder path:'))
} finally {