From d0609e48f0047bee4decde2d4a73730a90c38b03 Mon Sep 17 00:00:00 2001 From: lzt404 <2596933790@qq.com> Date: Sun, 16 Aug 2026 01:37:38 +0800 Subject: [PATCH 1/3] feat(agents): restore quick launch support for agent clients --- bun.lock | 3 + package.json | 1 + src-tauri/Cargo.lock | 144 +++++++++- src-tauri/Cargo.toml | 1 + src-tauri/capabilities/default.json | 3 +- src-tauri/src/agents.rs | 2 + src-tauri/src/agents/discovery.rs | 97 ++++++- src-tauri/src/agents/launch.rs | 397 ++++++++++++++++++++++++++++ src-tauri/src/main.rs | 11 + src/i18n/ja.ts | 18 ++ src/i18n/locales/en.ts | 18 ++ src/i18n/locales/zh-CN.ts | 18 ++ src/pages/AgentsPage.tsx | 260 +++++++++++++++++- src/styles.css | 123 ++++++++- 14 files changed, 1083 insertions(+), 13 deletions(-) create mode 100644 src-tauri/src/agents/launch.rs diff --git a/bun.lock b/bun.lock index a9153b2..4c4d04e 100644 --- a/bun.lock +++ b/bun.lock @@ -9,6 +9,7 @@ "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", "@tauri-apps/api": "^2.0.0", + "@tauri-apps/plugin-dialog": "^2.7.1", "lucide-react": "^1.24.0", "react": "^19.0.0", "react-dom": "^19.0.0", @@ -210,6 +211,8 @@ "@tauri-apps/cli-win32-x64-msvc": ["@tauri-apps/cli-win32-x64-msvc@2.11.4", "", { "os": "win32", "cpu": "x64" }, "sha512-+vDiqBIU5dMISg/wNvX3sF+ZHfgJGJ5T0AcO+EHNXV9GGAG+P5fzodlDXD3QdKCRgZxMoCm5PPvj3BqLNjBthw=="], + "@tauri-apps/plugin-dialog": ["@tauri-apps/plugin-dialog@2.7.2", "", { "dependencies": { "@tauri-apps/api": "^2.11.0" } }, "sha512-pX0IGm1I3I6wc+zeKYcq1GSqogK6okCNX5fOdaNU5ab1AjGS6l1E5wFNjEb7meg7ZFSp0JUs+0jQGQNyOvLrsg=="], + "@types/babel__core": ["@types/babel__core@7.20.5", "", { "dependencies": { "@babel/parser": "^7.20.7", "@babel/types": "^7.20.7", "@types/babel__generator": "*", "@types/babel__template": "*", "@types/babel__traverse": "*" } }, "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA=="], "@types/babel__generator": ["@types/babel__generator@7.27.0", "", { "dependencies": { "@babel/types": "^7.0.0" } }, "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg=="], diff --git a/package.json b/package.json index 4a1c659..b51334e 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,7 @@ "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", "@tauri-apps/api": "^2.0.0", + "@tauri-apps/plugin-dialog": "^2.7.1", "lucide-react": "^1.24.0", "react": "^19.0.0", "react-dom": "^19.0.0" diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index cf25c25..4b4be79 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -617,6 +617,7 @@ dependencies = [ "tauri", "tauri-build", "tauri-plugin-autostart", + "tauri-plugin-dialog", "tauri-plugin-opener", "tokio", "tokio-util", @@ -2519,6 +2520,7 @@ checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" dependencies = [ "bitflags 2.13.0", "block2", + "libc", "objc2", "objc2-core-foundation", ] @@ -3211,6 +3213,30 @@ dependencies = [ "web-sys", ] +[[package]] +name = "rfd" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a15ad77d9e70a92437d8f74c35d99b4e4691128df018833e99f90bcd36152672" +dependencies = [ + "block2", + "dispatch2", + "glib-sys", + "gobject-sys", + "gtk-sys", + "js-sys", + "log", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", + "raw-window-handle", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "windows-sys 0.60.2", +] + [[package]] name = "ring" version = "0.17.14" @@ -4061,6 +4087,48 @@ dependencies = [ "thiserror 2.0.18", ] +[[package]] +name = "tauri-plugin-dialog" +version = "2.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2d3c1dbe38037e7f590cdf2492594d5ceebe031e7bc7e827509b22a999d2940" +dependencies = [ + "log", + "raw-window-handle", + "rfd", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "tauri-plugin-fs", + "thiserror 2.0.18", + "url", +] + +[[package]] +name = "tauri-plugin-fs" +version = "2.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7ecc274121aca0c036a2b42d1cbe83d368d348f54e0bb8a735c2b1548e8f371" +dependencies = [ + "anyhow", + "dunce", + "glob", + "log", + "objc2-foundation", + "percent-encoding", + "schemars 0.8.22", + "serde", + "serde_json", + "serde_repr", + "tauri", + "tauri-plugin", + "tauri-utils", + "thiserror 2.0.18", + "toml 1.1.3+spec-1.1.0", + "url", +] + [[package]] name = "tauri-plugin-opener" version = "2.5.4" @@ -5208,6 +5276,15 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", +] + [[package]] name = "windows-sys" version = "0.61.2" @@ -5241,13 +5318,30 @@ dependencies = [ "windows_aarch64_gnullvm 0.52.6", "windows_aarch64_msvc 0.52.6", "windows_i686_gnu 0.52.6", - "windows_i686_gnullvm", + "windows_i686_gnullvm 0.52.6", "windows_i686_msvc 0.52.6", "windows_x86_64_gnu 0.52.6", "windows_x86_64_gnullvm 0.52.6", "windows_x86_64_msvc 0.52.6", ] +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link 0.2.1", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] + [[package]] name = "windows-threading" version = "0.1.0" @@ -5278,6 +5372,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + [[package]] name = "windows_aarch64_msvc" version = "0.42.2" @@ -5290,6 +5390,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + [[package]] name = "windows_i686_gnu" version = "0.42.2" @@ -5302,12 +5408,24 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + [[package]] name = "windows_i686_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + [[package]] name = "windows_i686_msvc" version = "0.42.2" @@ -5320,6 +5438,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + [[package]] name = "windows_x86_64_gnu" version = "0.42.2" @@ -5332,6 +5456,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + [[package]] name = "windows_x86_64_gnullvm" version = "0.42.2" @@ -5344,6 +5474,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + [[package]] name = "windows_x86_64_msvc" version = "0.42.2" @@ -5356,6 +5492,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + [[package]] name = "winnow" version = "0.5.40" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 67ffab7..979b968 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -28,6 +28,7 @@ sha2 = "0.10" tar = "0.4" tauri = { version = "~2.11", features = ["tray-icon"] } tauri-plugin-autostart = "2" +tauri-plugin-dialog = "2.7.1" tauri-plugin-opener = "2" tokio = { version = "1", features = ["io-util", "macros", "net", "sync", "time"] } tokio-util = "0.7" diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index 0a29d28..e06f87d 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -8,6 +8,7 @@ "core:event:allow-unlisten", "core:window:allow-set-theme", "core:window:allow-set-background-color", - "core:webview:allow-set-webview-background-color" + "core:webview:allow-set-webview-background-color", + "dialog:allow-open" ] } diff --git a/src-tauri/src/agents.rs b/src-tauri/src/agents.rs index 0af46f0..4887ed0 100644 --- a/src-tauri/src/agents.rs +++ b/src-tauri/src/agents.rs @@ -5,8 +5,10 @@ use super::*; mod commands; mod configuration; mod discovery; +mod launch; mod state; pub(crate) use commands::*; pub(crate) use configuration::*; pub(crate) use discovery::*; +pub(crate) use launch::*; pub(crate) use state::*; diff --git a/src-tauri/src/agents/discovery.rs b/src-tauri/src/agents/discovery.rs index a73aa08..2cea8a0 100644 --- a/src-tauri/src/agents/discovery.rs +++ b/src-tauri/src/agents/discovery.rs @@ -360,6 +360,16 @@ pub(crate) fn inspect_pi_provider_status( supported_platform: true, installed: executable.is_some(), plugin_installed, + launch_targets: executable + .as_ref() + .map(|path| { + vec![AgentLaunchTarget { + id: "cli".to_string(), + label: PI_AGENT_NAME.to_string(), + detail: path_to_string(path), + }] + }) + .unwrap_or_default(), version: cli_version.clone(), cli_version, app_version: None, @@ -831,15 +841,28 @@ pub(crate) fn inspect_agent_config( } else { executable.as_deref().and_then(read_agent_version) }; + let codex_app_installation = (client == AgentClient::Codex) + .then(|| find_codex_app_installation(home)) + .flatten(); let app_version = match client { AgentClient::ClaudeDesktop => read_claude_desktop_version(home), - AgentClient::Codex => read_codex_app_version(home), + AgentClient::Codex => codex_app_installation + .as_ref() + .and_then(read_codex_app_installation_version), AgentClient::DeepSeekHarness => read_deepseek_harness_profile_version(home), AgentClient::ZCode => read_zcode_app_version(home), _ => None, }; let version = cli_version.clone().or_else(|| app_version.clone()); - let installed = version.is_some() || (client == AgentClient::ZCode && executable.is_some()); + let installed = version.is_some() + || (client == AgentClient::ZCode && executable.is_some()) + || codex_app_installation.is_some(); + let launch_targets = agent_launch_targets( + client, + executable.as_deref(), + app_version.as_deref(), + codex_app_installation.is_some(), + ); let mut warnings = Vec::new(); if !client.supported_platform() { warnings.push("当前平台不支持 Claude Desktop 3P 配置".to_string()); @@ -859,6 +882,7 @@ pub(crate) fn inspect_agent_config( supported_platform: client.supported_platform(), installed, plugin_installed: true, + launch_targets, version, cli_version, app_version, @@ -883,6 +907,63 @@ pub(crate) fn inspect_agent_config( } } +pub(crate) fn agent_launch_targets( + client: AgentClient, + executable: Option<&Path>, + app_version: Option<&str>, + app_installed: bool, +) -> Vec { + let mut targets = Vec::new(); + match client { + AgentClient::ClaudeDesktop => { + if executable.is_some() || app_version.is_some() { + targets.push(AgentLaunchTarget { + id: "app".to_string(), + label: "Claude Desktop".to_string(), + detail: executable + .map(path_to_string) + .unwrap_or_else(|| "Claude Desktop application".to_string()), + }); + } + } + AgentClient::Codex => { + if app_installed { + targets.push(AgentLaunchTarget { + id: "app".to_string(), + label: "Codex App".to_string(), + detail: "Codex desktop application".to_string(), + }); + } + if let Some(executable) = executable { + targets.push(AgentLaunchTarget { + id: "cli".to_string(), + label: "Codex CLI".to_string(), + detail: path_to_string(executable), + }); + } + } + AgentClient::ZCode => { + if let Some(executable) = executable { + targets.push(AgentLaunchTarget { + id: "app".to_string(), + label: "ZCode".to_string(), + detail: path_to_string(executable), + }); + } + } + _ => { + if let Some(executable) = executable { + targets.push(AgentLaunchTarget { + id: "cli".to_string(), + label: client.name().to_string(), + detail: path_to_string(executable), + }); + } + } + } + targets +} + pub(crate) fn agent_configuration_is_synchronized( client: AgentClient, home: &Path, @@ -1260,27 +1341,27 @@ pub(crate) fn read_claude_desktop_version(home: &Path) -> Option { find_claude_desktop_executable(home).and_then(|path| read_agent_version(&path)) } -pub(crate) fn read_codex_app_version(home: &Path) -> Option { +pub(crate) fn read_codex_app_installation_version(installation: &CodexAppTarget) -> Option { #[cfg(target_os = "windows")] { - match find_codex_app_installation(home)? { + match installation { CodexAppTarget::WindowsAppId(app_id) => { let _ = app_id; read_windows_codex_store_version() } CodexAppTarget::Application(path) => { - read_windows_executable_version(&path).or_else(|| read_agent_version(&path)) + read_windows_executable_version(path).or_else(|| read_agent_version(path)) } } } #[cfg(target_os = "macos")] { - let CodexAppTarget::Application(path) = find_codex_app_installation(home)?; - read_macos_app_version(&path) + let CodexAppTarget::Application(path) = installation; + read_macos_app_version(path) } #[cfg(not(any(target_os = "windows", target_os = "macos")))] { - let _ = home; + let _ = installation; None } } diff --git a/src-tauri/src/agents/launch.rs b/src-tauri/src/agents/launch.rs new file mode 100644 index 0000000..14c317b --- /dev/null +++ b/src-tauri/src/agents/launch.rs @@ -0,0 +1,397 @@ +use super::*; + +#[tauri::command] +pub(crate) fn launch_agent( + app: tauri::AppHandle, + gui_config_state: tauri::State<'_, GuiConfigState>, + client: String, + target: Option, + working_directory: Option, +) -> Result<(), String> { + let home = app + .path() + .home_dir() + .map_err(|error| format!("无法获取用户目录: {error}"))?; + let config = gui_config_state.snapshot()?; + let requested_target = target + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()); + + if client.trim().eq_ignore_ascii_case(PI_AGENT_ID) { + if requested_target.is_some_and(|value| value != "cli") { + return Err("Pi 只支持 CLI 启动方式".to_string()); + } + let status = + inspect_pi_provider_status(&home, config.port, effective_agent_api_key(&config)); + if !status.installed { + return Err("未检测到 Pi CLI,请先安装 Pi 并重新检测".to_string()); + } + if !status.configured { + return Err("请先安装并配置 Pi CLIProxyAPI provider 插件".to_string()); + } + let executable = + find_pi_executable(&home).ok_or_else(|| "未找到 Pi CLI 可执行文件".to_string())?; + let launch_directory = resolve_launch_directory(working_directory.as_deref(), &home)?; + return launch_cli_agent(&executable, PI_AGENT_NAME, &launch_directory, &[]); + } + + let client = AgentClient::parse(&client)?; + if !client.supported_platform() { + return Err(format!("当前平台不支持启动 {}", client.name())); + } + let status = inspect_agent_config(client, &home, config.port, effective_agent_api_key(&config)); + if !status.installed { + return Err(format!("未检测到 {},请先安装并重新检测", client.name())); + } + validate_agent_launch_configuration(client, &status)?; + if client == AgentClient::Codex && status.oauth_configuration { + validate_codex_oauth_login(&home)?; + } + + let default_target = match client { + AgentClient::ClaudeDesktop | AgentClient::ZCode => "app", + AgentClient::Codex if find_codex_app_installation(&home).is_some() => "app", + _ => "cli", + }; + let requested_target = requested_target.unwrap_or(default_target); + match (client, requested_target) { + (AgentClient::ClaudeDesktop, "app") => launch_claude_desktop(&home), + (AgentClient::ZCode, "app") => { + let executable = find_zcode_desktop_executable(&home) + .ok_or_else(|| "未找到 ZCode 应用程序".to_string())?; + launch_desktop_agent(&executable, client.name()) + } + (AgentClient::Codex, "app") => launch_codex_desktop(&home), + (AgentClient::ClaudeDesktop | AgentClient::ZCode, "cli") => { + Err(format!("{} 不支持 CLI 启动方式", client.name())) + } + (_, "cli") => { + let executable = find_agent_executable(client, &home) + .ok_or_else(|| format!("未找到 {} 的可执行文件", client.name()))?; + let launch_directory = resolve_launch_directory(working_directory.as_deref(), &home)?; + let environment_to_remove = if client == AgentClient::ClaudeCode { + &["ANTHROPIC_API_KEY"][..] + } else { + &[] + }; + launch_cli_agent( + &executable, + client.name(), + &launch_directory, + environment_to_remove, + ) + } + (_, "app") => Err(format!("{} 不支持桌面 App 启动方式", client.name())), + _ => Err("不支持的智能体启动方式".to_string()), + } +} + +fn validate_agent_launch_configuration( + client: AgentClient, + status: &AgentConfigStatus, +) -> Result<(), String> { + if client == AgentClient::Codex + || (status.modification_enabled && status.modification_state == "applied") + { + return Ok(()); + } + Err(format!( + "请先为 {} 应用配置修改,确保 CPA 配置生效后再启动", + client.name() + )) +} + +fn resolve_launch_directory(value: Option<&str>, fallback: &Path) -> Result { + let Some(value) = value.map(str::trim).filter(|value| !value.is_empty()) else { + return Ok(fallback.to_path_buf()); + }; + if value.chars().any(char::is_control) { + return Err("工作目录包含无效字符".to_string()); + } + let path = PathBuf::from(value); + if !path.is_absolute() { + return Err("工作目录必须是绝对路径".to_string()); + } + if !path.is_dir() { + return Err(format!("工作目录不存在: {}", path_to_string(&path))); + } + Ok(path) +} + +fn launch_codex_desktop(home: &Path) -> Result<(), String> { + let target = find_codex_app_installation(home) + .ok_or_else(|| "未检测到 Codex 桌面应用,请重新检测或改用 Codex CLI".to_string())?; + match target { + #[cfg(target_os = "windows")] + CodexAppTarget::WindowsAppId(app_id) => launch_windows_store_app(&app_id, "Codex App"), + CodexAppTarget::Application(path) => launch_desktop_agent(&path, "Codex App"), + } +} + +fn launch_claude_desktop(home: &Path) -> Result<(), String> { + if let Some(executable) = find_claude_desktop_executable(home) { + return launch_desktop_agent(&executable, "Claude Desktop"); + } + #[cfg(target_os = "windows")] + { + launch_windows_claude_store_app() + } + #[cfg(not(target_os = "windows"))] + Err("未检测到 Claude Desktop 应用,请先安装或重新检测".to_string()) +} + +fn launch_desktop_agent(executable: &Path, label: &str) -> Result<(), String> { + #[cfg(target_os = "macos")] + let mut command = { + let application = executable + .ancestors() + .find(|path| path.extension().and_then(|value| value.to_str()) == Some("app")) + .unwrap_or(executable); + let mut command = Command::new("open"); + command.arg(application); + command + }; + + #[cfg(any(target_os = "windows", target_os = "linux"))] + let mut command = Command::new(executable); + + #[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))] + { + let _ = (executable, label); + return Err("当前平台不支持桌面智能体".to_string()); + } + + #[cfg(any(target_os = "macos", target_os = "windows", target_os = "linux"))] + { + command + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()); + configure_background_command(&mut command); + command + .spawn() + .map(|_| ()) + .map_err(|error| format!("启动 {label} 失败: {error}")) + } +} + +#[cfg(target_os = "windows")] +fn launch_windows_store_app(app_id: &str, label: &str) -> Result<(), String> { + let mut command = Command::new(windows_explorer_executable()); + command + .arg(format!("shell:AppsFolder\\{app_id}")) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()); + configure_background_command(&mut command); + command + .spawn() + .map(|_| ()) + .map_err(|error| format!("启动 {label} 失败: {error}")) +} + +#[cfg(target_os = "windows")] +fn launch_windows_claude_store_app() -> Result<(), String> { + const SCRIPT: &str = r#" +$ErrorActionPreference = 'Stop' +$appId = @(Get-StartApps) | + Where-Object { + $_.AppID -like 'Claude_*!*' -or + $_.AppID -like 'Anthropic.Claude_*!*' + } | + Select-Object -First 1 -ExpandProperty AppID +if (-not $appId) { throw 'Claude Desktop app entry was not found' } +Start-Process "shell:AppsFolder\$appId" +"#; + let encoded = windows_powershell_encoded_command(SCRIPT); + let mut command = Command::new(windows_powershell_executable()); + command.args([ + "-NoLogo", + "-NoProfile", + "-NonInteractive", + "-ExecutionPolicy", + "Bypass", + "-EncodedCommand", + &encoded, + ]); + configure_background_command(&mut command); + let output = command + .output() + .map_err(|error| format!("启动 Claude Desktop 失败: {error}"))?; + if output.status.success() { + Ok(()) + } else { + let detail = String::from_utf8_lossy(&output.stderr).trim().to_string(); + Err(if detail.is_empty() { + "未找到可启动的 Claude Desktop 应用".to_string() + } else { + format!("启动 Claude Desktop 失败: {detail}") + }) + } +} + +#[cfg(target_os = "macos")] +fn shell_single_quote(value: &str) -> String { + format!("'{}'", value.replace('\'', "'\"'\"'")) +} + +#[cfg(target_os = "macos")] +fn launch_cli_agent( + executable: &Path, + label: &str, + working_directory: &Path, + environment_to_remove: &[&str], +) -> Result<(), String> { + let removals = environment_to_remove + .iter() + .map(|key| format!("-u {}", shell_single_quote(key))) + .collect::>() + .join(" "); + let command_line = format!( + "cd {} && exec env {} {}", + shell_single_quote(&path_to_string(working_directory)), + removals, + shell_single_quote(&path_to_string(executable)), + ); + let script = format!( + "tell application \"Terminal\"\nactivate\ndo script \"{}\"\nend tell", + command_line.replace('\\', "\\\\").replace('"', "\\\"") + ); + let output = Command::new("osascript") + .arg("-e") + .arg(script) + .output() + .map_err(|error| format!("启动 {label} 终端失败: {error}"))?; + if output.status.success() { + Ok(()) + } else { + let detail = String::from_utf8_lossy(&output.stderr).trim().to_string(); + Err(format!("启动 {label} 终端失败: {detail}")) + } +} + +#[cfg(target_os = "linux")] +fn launch_cli_agent( + executable: &Path, + label: &str, + working_directory: &Path, + environment_to_remove: &[&str], +) -> Result<(), String> { + let terminals: &[(&str, &[&str])] = &[ + ("x-terminal-emulator", &["-e"]), + ("gnome-terminal", &["--"]), + ("konsole", &["-e"]), + ("xfce4-terminal", &["-e"]), + ("mate-terminal", &["--"]), + ("kitty", &["-e"]), + ("alacritty", &["-e"]), + ("ghostty", &["-e"]), + ("xterm", &["-e"]), + ]; + let path = env::var_os("PATH").unwrap_or_default(); + let mut last_error = None; + for (terminal, arguments) in terminals { + if !env::split_paths(&path).any(|directory| directory.join(terminal).is_file()) { + continue; + } + let mut command = Command::new(terminal); + command + .args(*arguments) + .arg(executable) + .current_dir(working_directory) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()); + for key in environment_to_remove { + command.env_remove(key); + } + match command.spawn() { + Ok(_) => return Ok(()), + Err(error) => last_error = Some(error.to_string()), + } + } + Err(match last_error { + Some(error) => format!("启动 {label} 失败: {error}"), + None => format!("启动 {label} 失败:未找到可用的终端程序"), + }) +} + +#[cfg(target_os = "windows")] +fn launch_cli_agent( + executable: &Path, + label: &str, + working_directory: &Path, + environment_to_remove: &[&str], +) -> Result<(), String> { + use std::os::windows::process::CommandExt; + + const CREATE_NEW_CONSOLE: u32 = 0x0000_0010; + let mut command = windows_command_for_executable(executable, true); + command + .current_dir(working_directory) + .creation_flags(CREATE_NEW_CONSOLE); + for key in environment_to_remove { + command.env_remove(key); + } + command + .spawn() + .map(|_| ()) + .map_err(|error| format!("启动 {label} 失败: {error}")) +} + +#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))] +fn launch_cli_agent( + _executable: &Path, + label: &str, + _working_directory: &Path, + _environment_to_remove: &[&str], +) -> Result<(), String> { + Err(format!("当前平台不支持启动 {label}")) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn relative_launch_directory_is_rejected() { + let error = resolve_launch_directory(Some("relative/project"), Path::new("/fallback")) + .expect_err("relative path should be rejected"); + assert!(error.contains("绝对路径")); + } + + #[test] + fn omitted_launch_directory_uses_fallback() { + let fallback = if cfg!(target_os = "windows") { + Path::new(r"C:\Users\tester") + } else { + Path::new("/home/tester") + }; + assert_eq!(resolve_launch_directory(None, fallback).unwrap(), fallback); + } + + #[test] + fn codex_exposes_independent_app_and_cli_targets() { + let executable = Path::new("codex"); + let targets = + agent_launch_targets(AgentClient::Codex, Some(executable), Some("1.0.0"), true); + assert_eq!( + targets + .iter() + .map(|target| target.id.as_str()) + .collect::>(), + ["app", "cli"] + ); + } + + #[test] + fn desktop_and_cli_clients_get_the_correct_target_kind() { + let executable = Path::new("agent"); + let zcode = + agent_launch_targets(AgentClient::ZCode, Some(executable), Some("1.0.0"), false); + let kimi = agent_launch_targets(AgentClient::KimiCode, Some(executable), None, false); + assert_eq!(zcode[0].id, "app"); + assert_eq!(kimi[0].id, "cli"); + } +} diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 5336ec3..a24a01d 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -763,6 +763,7 @@ struct AgentConfigStatus { supported_platform: bool, installed: bool, plugin_installed: bool, + launch_targets: Vec, version: Option, cli_version: Option, app_version: Option, @@ -784,6 +785,14 @@ struct AgentConfigStatus { error: Option, } +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct AgentLaunchTarget { + id: String, + label: String, + detail: String, +} + #[derive(Clone, Debug, Serialize)] #[serde(rename_all = "camelCase")] struct AgentConfigActionResult { @@ -1770,6 +1779,7 @@ fn main() { let start_hidden = should_start_hidden(&gui_config); let app = tauri::Builder::default() + .plugin(tauri_plugin_dialog::init()) .plugin(tauri_plugin_autostart::init( tauri_plugin_autostart::MacosLauncher::LaunchAgent, None, @@ -1974,6 +1984,7 @@ fn main() { clear_codex_config, set_agent_config_enabled, update_agent_config, + launch_agent, get_lan_ipv4, save_gui_settings, save_network_routing_settings, diff --git a/src/i18n/ja.ts b/src/i18n/ja.ts index d328521..3c29477 100644 --- a/src/i18n/ja.ts +++ b/src/i18n/ja.ts @@ -500,6 +500,23 @@ export const jaOverrides = { 'agents.error.mappingSelectionGone': 'Claude Desktop のモデルマッピングに利用できないモデルがあります。モデルを更新して選び直してください', 'agents.error.claudeCodeRuntimeSettingsInvalid': '最大コンテキストは 100000~1000000、自動圧縮率は 1~100 にしてください', 'agents.error.applyFirst': '選択したモデルはまだ適用されていません。先に「設定変更を適用」をクリックしてください', + 'agents.launch.enableFirst': '先に設定変更を適用してください', + 'agents.launch.applyFirst': '先に選択中のモデルを適用してください', + 'agents.launch.starting': '起動中', + 'agents.launch.startCli': 'CLI を起動', + 'agents.launch.startApp': 'App を起動', + 'agents.launch.start': '{target} を起動', + 'agents.launch.unavailable': '起動できません', + 'agents.launchDirectory.noDirectory': '未選択', + 'agents.launchDirectory.chooseDirectory': 'ディレクトリを選択', + 'agents.launchDirectory.selectedDirectory': '選択した作業ディレクトリ', + 'agents.launchDirectory.choosing': '選択中', + 'agents.launchDirectory.dialogTitle': '{client} の起動ディレクトリを選択', + 'agents.launchDirectory.dialogDescription': 'プロジェクトの作業ディレクトリを選択してから、{client} を起動します。', + 'agents.launchDirectory.workingDirectory': '作業ディレクトリ', + 'agents.launchDirectory.browse': '参照', + 'agents.launchDirectory.directoryRequired': '先に作業ディレクトリを選択してください', + 'agents.launchDirectory.launch': '{client} を起動', 'agents.status.detecting': '検出中', 'agents.status.unsupported': 'プラットフォーム非対応', 'agents.status.notInstalled': '未インストール', @@ -613,6 +630,7 @@ export const jaOverrides = { 'agents.oauthLoginRequired.enableClearConfiguration': '設定を消去', 'agents.oauthLoginRequired.enableDescriptionSuffix': 'を使用してください。', 'agents.oauthLoginRequired.applyDescription': '有効な Codex のログイン認証情報が見つからないため、OAuth 設定を適用できません。Codex で ChatGPT アカウントにログインしてから、設定を適用してください。', + 'agents.oauthLoginRequired.launchDescription': '有効なログイン認証情報が見つからないため Codex を起動できません。先に Codex アプリで ChatGPT アカウントにログインしてください。', 'agents.oauthLoginRequired.confirm': '了解しました', 'agents.modify.apply': '設定変更を適用', 'agents.modify.update': '設定を更新', diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index af1536c..0d9aff4 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -500,6 +500,23 @@ export const en: Record = { 'agents.error.mappingSelectionGone': 'A Claude Desktop mapping is no longer available. Refresh the models and select it again.', 'agents.error.claudeCodeRuntimeSettingsInvalid': 'Maximum context must be 100000–1000000 and the auto-compact percentage must be 1–100.', 'agents.error.applyFirst': 'The selected model has not been applied. Click “Apply Configuration Changes” first.', + 'agents.launch.enableFirst': 'Apply the configuration changes first', + 'agents.launch.applyFirst': 'Apply the selected model first', + 'agents.launch.starting': 'Starting', + 'agents.launch.startCli': 'Start CLI', + 'agents.launch.startApp': 'Start App', + 'agents.launch.start': 'Start {target}', + 'agents.launch.unavailable': 'Cannot Start', + 'agents.launchDirectory.noDirectory': 'Not selected', + 'agents.launchDirectory.chooseDirectory': 'Choose Directory', + 'agents.launchDirectory.selectedDirectory': 'Selected working directory', + 'agents.launchDirectory.choosing': 'Choosing', + 'agents.launchDirectory.dialogTitle': 'Choose a Launch Directory for {client}', + 'agents.launchDirectory.dialogDescription': 'Choose a project working directory, then start {client}.', + 'agents.launchDirectory.workingDirectory': 'Working directory', + 'agents.launchDirectory.browse': 'Browse', + 'agents.launchDirectory.directoryRequired': 'Select a working directory first', + 'agents.launchDirectory.launch': 'Start {client}', 'agents.status.detecting': 'Detecting', 'agents.status.unsupported': 'Unsupported Platform', 'agents.status.notInstalled': 'Not Installed', @@ -613,6 +630,7 @@ export const en: Record = { 'agents.oauthLoginRequired.enableClearConfiguration': 'Clear Configuration', 'agents.oauthLoginRequired.enableDescriptionSuffix': 'first.', 'agents.oauthLoginRequired.applyDescription': 'OAuth configuration cannot be applied because no valid Codex sign-in credentials were found. Sign in to your ChatGPT account in Codex, then apply the configuration.', + 'agents.oauthLoginRequired.launchDescription': 'Codex cannot be started because no valid sign-in credentials were found. Sign in to your ChatGPT account in the Codex app first.', 'agents.oauthLoginRequired.confirm': 'Got it', 'agents.modify.apply': 'Apply Configuration Changes', 'agents.modify.update': 'Update Configuration', diff --git a/src/i18n/locales/zh-CN.ts b/src/i18n/locales/zh-CN.ts index 2e38336..c151822 100644 --- a/src/i18n/locales/zh-CN.ts +++ b/src/i18n/locales/zh-CN.ts @@ -498,6 +498,23 @@ export const zhCN = { 'agents.error.mappingSelectionGone': 'Claude Desktop 模型映射包含不可用模型,请刷新模型后重新选择', 'agents.error.claudeCodeRuntimeSettingsInvalid': '最大窗口必须为 100000–1000000,触发压缩百分比必须为 1–100', 'agents.error.applyFirst': '模型选择尚未应用,请先点击“应用配置修改”', + 'agents.launch.enableFirst': '请先应用配置修改', + 'agents.launch.applyFirst': '请先应用当前模型选择', + 'agents.launch.starting': '启动中', + 'agents.launch.startCli': '启动 CLI', + 'agents.launch.startApp': '启动 App', + 'agents.launch.start': '启动 {target}', + 'agents.launch.unavailable': '无法启动', + 'agents.launchDirectory.noDirectory': '尚未选择', + 'agents.launchDirectory.chooseDirectory': '选择目录', + 'agents.launchDirectory.selectedDirectory': '已选择的工作目录', + 'agents.launchDirectory.choosing': '选择中', + 'agents.launchDirectory.dialogTitle': '选择 {client} 启动目录', + 'agents.launchDirectory.dialogDescription': '选择项目工作目录,然后启动 {client}。', + 'agents.launchDirectory.workingDirectory': '工作目录', + 'agents.launchDirectory.browse': '浏览', + 'agents.launchDirectory.directoryRequired': '请先选择工作目录', + 'agents.launchDirectory.launch': '启动 {client}', 'agents.status.detecting': '检测中', 'agents.status.unsupported': '平台不支持', 'agents.status.notInstalled': '未安装', @@ -611,6 +628,7 @@ export const zhCN = { 'agents.oauthLoginRequired.enableClearConfiguration': '清空配置', 'agents.oauthLoginRequired.enableDescriptionSuffix': '功能。', 'agents.oauthLoginRequired.applyDescription': '无法应用 OAuth 配置:未检测到有效的 Codex 登录凭据。请先在 Codex 中登录 ChatGPT 账号,然后再应用配置。', + 'agents.oauthLoginRequired.launchDescription': '无法启动 Codex:未检测到有效的登录凭据。请先在 Codex 应用中登录 ChatGPT 账号。', 'agents.oauthLoginRequired.confirm': '我知道了', 'agents.modify.apply': '应用配置修改', 'agents.modify.update': '更新配置', diff --git a/src/pages/AgentsPage.tsx b/src/pages/AgentsPage.tsx index 9a10840..0f3a3ef 100644 --- a/src/pages/AgentsPage.tsx +++ b/src/pages/AgentsPage.tsx @@ -10,16 +10,20 @@ import { } from 'react'; import { invoke } from '@tauri-apps/api/core'; import { listen } from '@tauri-apps/api/event'; +import { open } from '@tauri-apps/plugin-dialog'; import { AlertTriangle, + AppWindow, BadgeCheck, Bot, Check, ChevronDown, LoaderCircle, + Play, RefreshCw, Search, Trash2, + Terminal, Wrench, X, } from 'lucide-react'; @@ -74,6 +78,7 @@ type AgentConfigStatus = { supportedPlatform: boolean; installed: boolean; pluginInstalled: boolean; + launchTargets: AgentLaunchTarget[]; version: string | null; cliVersion: string | null; appVersion: string | null; @@ -93,6 +98,12 @@ type AgentConfigStatus = { error: string | null; }; +type AgentLaunchTarget = { + id: 'app' | 'cli'; + label: string; + detail: string; +}; + type AgentConfigActionResult = { outcome: 'applied' | 'default'; enabled: boolean; @@ -107,7 +118,7 @@ type PiProviderUpdateStatus = { updateAvailable: boolean; }; -type OAuthLoginRequiredAction = 'enable' | 'apply'; +type OAuthLoginRequiredAction = 'enable' | 'apply' | 'launch'; type ClaudeModelMappings = { opus: string; @@ -612,7 +623,7 @@ export function AgentsPage() { const [loading, setLoading] = useState(true); const [modelLoading, setModelLoading] = useState(false); const [busyAction, setBusyAction] = useState< - 'apply' | 'close-config' | 'default' | 'clear' | 'install-pi' | 'update-pi' | 'repair-pi' | 'uninstall-pi' | 'oauth-check' | null + 'apply' | 'close-config' | 'default' | 'clear' | 'install-pi' | 'update-pi' | 'repair-pi' | 'uninstall-pi' | 'oauth-check' | 'directory' | 'launch' | 'launch-cli' | 'launch-app' | null >(null); const busy = busyAction !== null; const [detectionError, setDetectionError] = useState(''); @@ -624,6 +635,11 @@ export function AgentsPage() { const [clearError, setClearError] = useState(''); const [clearNotice, setClearNotice] = useState(''); const [clearConfirmOpen, setClearConfirmOpen] = useState(false); + const [launchError, setLaunchError] = useState(''); + const [launchDirectoryDialogOpen, setLaunchDirectoryDialogOpen] = useState(false); + const [launchDirectory, setLaunchDirectory] = useState(''); + const [launchDirectoryTarget, setLaunchDirectoryTarget] = useState(null); + const [launchDirectoryError, setLaunchDirectoryError] = useState(''); const [oauthLoginRequiredAction, setOauthLoginRequiredAction] = useState(null); const [oauthConfigurationDraft, setOauthConfigurationDraft] = useState(null); const [piProviderUpdateStatus, setPiProviderUpdateStatus] = useState(null); @@ -747,6 +763,10 @@ export function AgentsPage() { setClearError(''); setClearNotice(''); setClearConfirmOpen(false); + setLaunchError(''); + setLaunchDirectoryDialogOpen(false); + setLaunchDirectoryTarget(null); + setLaunchDirectoryError(''); setOauthLoginRequiredAction(null); setOauthConfigurationDraft(null); // Preserve each Claude client's unsaved draft while navigating between clients. @@ -903,6 +923,19 @@ export function AgentsPage() { ? claudeMappingsReady && claudeCodeRuntimeSettingsReady : selectedModelOption), ); + const activeLaunchTargets = activeStatus?.launchTargets ?? []; + const defaultLaunchTarget = activeLaunchTargets[0] ?? null; + const cliLaunchTarget = activeLaunchTargets.find((target) => target.id === 'cli') ?? null; + const appLaunchTarget = activeLaunchTargets.find((target) => target.id === 'app') ?? null; + const launchEnabled = Boolean( + activeStatus?.supportedPlatform + && activeStatus.installed + && (selected === 'codex' + || (selected === 'pi' + ? activeStatus.configured + : activeStatus.modificationEnabled && activeStatus.modificationState === 'applied')), + ); + const canLaunchTarget = (target: AgentLaunchTarget | null) => launchEnabled && Boolean(target); const modelHint = modelSelectionError || modelError || (modelLoading @@ -1249,6 +1282,82 @@ export function AgentsPage() { } }; + const openLaunchDirectoryDialog = (target: AgentLaunchTarget) => { + setLaunchDirectory(''); + setLaunchDirectoryTarget(target); + setLaunchDirectoryError(''); + setLaunchDirectoryDialogOpen(true); + }; + + const chooseLaunchDirectory = async () => { + setBusyAction('directory'); + setLaunchDirectoryError(''); + try { + const selectedDirectory = await open({ + directory: true, + multiple: false, + defaultPath: launchDirectory || undefined, + title: t('agents.launchDirectory.dialogTitle', { client: activeDefinition.name }), + }); + if (typeof selectedDirectory === 'string') setLaunchDirectory(selectedDirectory); + } catch (requestError) { + setLaunchDirectoryError(String(requestError)); + } finally { + setBusyAction(null); + } + }; + + const invokeAgentLaunch = async ( + target: AgentLaunchTarget, + workingDirectory: string | null = null, + ) => { + const action = selected === 'codex' + ? target.id === 'cli' ? 'launch-cli' : 'launch-app' + : 'launch'; + setBusyAction(action); + setLaunchError(''); + try { + if (draftChanged) throw new Error(t('agents.error.applyFirst')); + await invoke('launch_agent', { + client: selected, + target: target.id, + workingDirectory, + }); + if (workingDirectory) { + setLaunchDirectoryDialogOpen(false); + setLaunchDirectoryTarget(null); + } + } catch (requestError) { + if (workingDirectory) { + setLaunchDirectoryError(String(requestError)); + } else if (!handleOAuthLoginError(requestError, 'launch')) { + setLaunchError(String(requestError)); + } + } finally { + setBusyAction(null); + } + }; + + const launchAgent = async (target: AgentLaunchTarget | null) => { + if (!target) return; + if (target.id === 'cli') { + openLaunchDirectoryDialog(target); + return; + } + await invokeAgentLaunch(target); + }; + + const launchFromDirectoryDialog = async () => { + if (!launchDirectoryTarget) return; + const workingDirectory = launchDirectory.trim(); + if (!workingDirectory) { + setLaunchDirectoryError(t('agents.launchDirectory.directoryRequired')); + return; + } + setLaunchDirectoryError(''); + await invokeAgentLaunch(launchDirectoryTarget, workingDirectory); + }; + const openDefaultConfirmation = () => { setDefaultError(''); setDefaultConfirmOpen(true); @@ -1714,6 +1823,77 @@ export function AgentsPage() { )} +
+
+
+ {selected === 'codex' ? ( + <> + + + + ) : ( + + )} +
+ {launchError ? ( + + {launchError} + + ) : null} +
+
+ ) : null} @@ -1730,6 +1910,82 @@ export function AgentsPage() { + {launchDirectoryDialogOpen ? ( +
{ + if (event.currentTarget === event.target && !busy) { + setLaunchDirectoryDialogOpen(false); + setLaunchDirectoryTarget(null); + } + }}> +
+
+
+

+ {t('agents.launchDirectory.dialogTitle', { client: activeDefinition.name })} +

+
+
+

{t('agents.launchDirectory.dialogDescription', { client: activeDefinition.name })}

+
+ {t('agents.launchDirectory.workingDirectory')} + +
+ {launchDirectoryError ? ( + + {launchDirectoryError} + + ) : null} +
+ + +
+
+
+ ) : null} + {defaultConfirmOpen ? (
diff --git a/src/styles.css b/src/styles.css index d3c0e24..73481d9 100644 --- a/src/styles.css +++ b/src/styles.css @@ -5523,8 +5523,104 @@ textarea:focus-visible { } .agent-config-footer { + display: flex; + align-items: center; justify-content: flex-end; gap: 14px; + min-height: 48px; + padding-top: 14px; + border-top: 1px solid var(--theme-e3e1db); +} + +.agent-launch-control { + display: grid; + min-width: 0; + justify-items: end; + gap: 5px; +} + +.agent-launch-actions { + display: flex; + min-width: 0; + max-width: 100%; + align-items: center; + justify-content: flex-end; + gap: 8px; + flex-wrap: wrap; +} + +.agent-launch-actions > button { + min-width: 120px; +} + +.agent-launch-control > .agent-inline-message { + max-width: 420px; + text-align: right; +} + +.agent-launch-directory-dialog { + grid-template-rows: auto; + width: min(620px, calc(100vw - 32px)); + height: auto; + min-height: 0; +} + +.agent-launch-directory-dialog > p { + margin: 0; + color: var(--theme-5f5a53); + font-size: var(--font-size-label); + line-height: var(--line-height-relaxed); +} + +.agent-launch-directory-picker { + display: flex; + width: 100%; + min-width: 0; + min-height: 64px; + align-items: center; + justify-content: space-between; + gap: 16px; + padding: 11px 13px; + border: 1px solid var(--theme-d8d2c8); + border-radius: 9px; + background: var(--theme-fff); + color: var(--theme-2d2a26); + text-align: left; +} + +.agent-launch-directory-picker:hover:not(:disabled) { + border-color: var(--theme-bdb5a9); + background: var(--theme-faf8f4); +} + +.agent-launch-directory-picker:focus-visible { + border-color: var(--theme-8d8377); + outline: 0; + box-shadow: 0 0 0 3px rgba(45, 42, 38, 0.1); +} + +.agent-launch-directory-picker > span { + display: grid; + min-width: 0; + gap: 4px; +} + +.agent-launch-directory-picker small { + color: var(--theme-8a847c); + font-size: var(--font-size-meta); +} + +.agent-launch-directory-picker strong { + overflow: hidden; + font-size: var(--font-size-label); + text-overflow: ellipsis; + white-space: nowrap; +} + +.agent-launch-directory-picker > b { + flex: none; + color: var(--theme-8069ad); + font-size: var(--font-size-label); } .agent-status-grid { @@ -7423,7 +7519,8 @@ textarea:focus-visible { } .agent-status-grid, - .agent-status-messages { + .agent-status-messages, + .agent-config-footer { grid-column: 1 / -1; } @@ -8052,6 +8149,30 @@ textarea:focus-visible { text-align: left; } + .agent-config-footer, + .agent-launch-actions { + width: 100%; + } + + .agent-launch-control { + width: 100%; + justify-items: stretch; + } + + .agent-launch-actions { + display: grid; + grid-template-columns: 1fr; + } + + .agent-launch-actions > button { + width: 100%; + } + + .agent-launch-control > .agent-inline-message { + max-width: none; + text-align: left; + } + .oauth-card { grid-template-rows: auto auto auto; min-height: 0; From 6dc6cb02e2970726510a64db4f57ccabad5c0891 Mon Sep 17 00:00:00 2001 From: lzt404 <2596933790@qq.com> Date: Sun, 16 Aug 2026 01:43:08 +0800 Subject: [PATCH 2/3] fix(agents): prevent launch directory dialog content overlap --- src/styles.css | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/styles.css b/src/styles.css index 73481d9..36df846 100644 --- a/src/styles.css +++ b/src/styles.css @@ -5560,9 +5560,12 @@ textarea:focus-visible { .agent-launch-directory-dialog { grid-template-rows: auto; - width: min(620px, calc(100vw - 32px)); + align-content: start; + width: min(620px, 100%); + max-height: calc(100vh - 40px); height: auto; min-height: 0; + overflow-y: auto; } .agent-launch-directory-dialog > p { @@ -5572,6 +5575,11 @@ textarea:focus-visible { line-height: var(--line-height-relaxed); } +.agent-launch-directory-dialog .config-dialog-field { + grid-template-rows: auto auto; + min-height: 0; +} + .agent-launch-directory-picker { display: flex; width: 100%; From 0c83be37275de2b55b8ce52f3bede0e99d591d84 Mon Sep 17 00:00:00 2001 From: lzt404 <2596933790@qq.com> Date: Sun, 16 Aug 2026 02:04:17 +0800 Subject: [PATCH 3/3] feat(agents): add Codex app restart and CLI directory history --- src-tauri/src/agents/launch.rs | 191 +++++++++++++++++++- src-tauri/src/main.rs | 1 + src/i18n/ja.ts | 9 +- src/i18n/locales/en.ts | 9 +- src/i18n/locales/zh-CN.ts | 9 +- src/pages/AgentsPage.tsx | 111 +++++++++++- src/services/agentLaunchDirectoryHistory.ts | 50 +++++ src/styles.css | 79 ++++++++ tests/agentLaunchDirectoryHistory.test.ts | 34 ++++ 9 files changed, 486 insertions(+), 7 deletions(-) create mode 100644 src/services/agentLaunchDirectoryHistory.ts create mode 100644 tests/agentLaunchDirectoryHistory.test.ts diff --git a/src-tauri/src/agents/launch.rs b/src-tauri/src/agents/launch.rs index 14c317b..adf42a7 100644 --- a/src-tauri/src/agents/launch.rs +++ b/src-tauri/src/agents/launch.rs @@ -87,6 +87,39 @@ pub(crate) fn launch_agent( } } +#[tauri::command] +pub(crate) async fn restart_codex_app( + app: tauri::AppHandle, + gui_config_state: tauri::State<'_, GuiConfigState>, +) -> Result<(), String> { + let home = app + .path() + .home_dir() + .map_err(|error| format!("无法获取用户目录: {error}"))?; + let config = gui_config_state.snapshot()?; + let status = inspect_agent_config( + AgentClient::Codex, + &home, + config.port, + effective_agent_api_key(&config), + ); + if !status.installed { + return Err("未检测到 Codex,请先安装并重新检测".to_string()); + } + if status.oauth_configuration { + validate_codex_oauth_login(&home)?; + } + let target = find_codex_app_installation(&home) + .ok_or_else(|| "未检测到 Codex 桌面应用,请重新检测或改用 Codex CLI".to_string())?; + + tauri::async_runtime::spawn_blocking(move || { + stop_codex_desktop(&target)?; + launch_codex_target(&target) + }) + .await + .map_err(|error| format!("重启 Codex App 任务失败: {error}"))? +} + fn validate_agent_launch_configuration( client: AgentClient, status: &AgentConfigStatus, @@ -122,13 +155,152 @@ fn resolve_launch_directory(value: Option<&str>, fallback: &Path) -> Result Result<(), String> { let target = find_codex_app_installation(home) .ok_or_else(|| "未检测到 Codex 桌面应用,请重新检测或改用 Codex CLI".to_string())?; + launch_codex_target(&target) +} + +fn launch_codex_target(target: &CodexAppTarget) -> Result<(), String> { match target { #[cfg(target_os = "windows")] - CodexAppTarget::WindowsAppId(app_id) => launch_windows_store_app(&app_id, "Codex App"), - CodexAppTarget::Application(path) => launch_desktop_agent(&path, "Codex App"), + CodexAppTarget::WindowsAppId(app_id) => launch_windows_store_app(app_id, "Codex App"), + CodexAppTarget::Application(path) => launch_desktop_agent(path, "Codex App"), } } +#[cfg(target_os = "windows")] +fn stop_codex_desktop(target: &CodexAppTarget) -> Result<(), String> { + let script = windows_codex_stop_script(target); + let encoded = windows_powershell_encoded_command(&script); + let mut command = Command::new(windows_powershell_executable()); + command.args([ + "-NoLogo", + "-NoProfile", + "-NonInteractive", + "-ExecutionPolicy", + "Bypass", + "-EncodedCommand", + &encoded, + ]); + configure_background_command(&mut command); + let output = command + .output() + .map_err(|error| format!("关闭 Codex App 失败: {error}"))?; + if output.status.success() { + return Ok(()); + } + let detail = String::from_utf8_lossy(if output.stderr.is_empty() { + &output.stdout + } else { + &output.stderr + }) + .trim() + .to_string(); + Err(if detail.is_empty() { + "Codex App 未能完全关闭".to_string() + } else { + format!("关闭 Codex App 失败: {detail}") + }) +} + +#[cfg(target_os = "windows")] +fn windows_codex_stop_script(target: &CodexAppTarget) -> String { + let selector = match target { + CodexAppTarget::Application(path) => format!( + "$targetExecutable = {}\n$targetRoot = $null", + windows_powershell_single_quoted_literal(&path_to_string(path)) + ), + CodexAppTarget::WindowsAppId(app_id) => { + let package_family = app_id.split('!').next().unwrap_or(app_id); + format!( + concat!( + "$targetExecutable = $null\n", + "$packageFamily = {}\n", + "$package = @(Get-AppxPackage | Where-Object {{ $_.PackageFamilyName -eq $packageFamily }}) | Select-Object -First 1\n", + "$targetRoot = if ($package) {{ $package.InstallLocation }} else {{ $null }}\n", + "if (-not $targetRoot) {{ throw 'Codex App package directory was not found' }}" + ), + windows_powershell_single_quoted_literal(package_family) + ) + } + }; + format!( + r#"$ErrorActionPreference = 'Stop' +{selector} +function Get-CodexAppProcesses {{ + @(Get-CimInstance Win32_Process | Where-Object {{ + $_.Name -in @('ChatGPT.exe', 'Codex.exe') -and + $_.ExecutablePath -and + (($targetExecutable -and [string]::Equals($_.ExecutablePath, $targetExecutable, [System.StringComparison]::OrdinalIgnoreCase)) -or + ($targetRoot -and $_.ExecutablePath.StartsWith(($targetRoot.TrimEnd('\') + '\'), [System.StringComparison]::OrdinalIgnoreCase))) + }}) +}} +$processes = @(Get-CodexAppProcesses) +foreach ($process in $processes) {{ + Stop-Process -Id $process.ProcessId -Force -ErrorAction SilentlyContinue +}} +$deadline = [DateTime]::UtcNow.AddSeconds(5) +do {{ + $remaining = @(Get-CodexAppProcesses) + if ($remaining.Count -eq 0) {{ exit 0 }} + Start-Sleep -Milliseconds 100 +}} while ([DateTime]::UtcNow -lt $deadline) +throw "Codex App did not exit; remaining process IDs: $($remaining.ProcessId -join ', ')" +"# + ) +} + +#[cfg(target_os = "macos")] +fn stop_codex_desktop(target: &CodexAppTarget) -> Result<(), String> { + let CodexAppTarget::Application(executable) = target; + let process_name = executable + .file_name() + .and_then(|name| name.to_str()) + .filter(|name| !name.is_empty()) + .ok_or_else(|| "无法识别 Codex App 进程名称".to_string())?; + let application_name = executable + .ancestors() + .find(|path| path.extension().and_then(|value| value.to_str()) == Some("app")) + .and_then(Path::file_stem) + .and_then(|name| name.to_str()) + .unwrap_or(process_name); + let escaped_name = application_name.replace('\\', "\\\\").replace('"', "\\\""); + let _ = Command::new("osascript") + .args([ + "-e", + &format!("tell application \"{escaped_name}\" to quit"), + ]) + .status(); + wait_for_unix_process_exit(process_name) +} + +#[cfg(target_os = "macos")] +fn wait_for_unix_process_exit(process_name: &str) -> Result<(), String> { + for _ in 0..50 { + let status = Command::new("pgrep").args(["-x", process_name]).status(); + if status.is_ok_and(|status| !status.success()) { + return Ok(()); + } + thread::sleep(Duration::from_millis(100)); + } + let _ = Command::new("pkill") + .args(["-KILL", "-x", process_name]) + .status(); + thread::sleep(Duration::from_millis(100)); + let status = Command::new("pgrep") + .args(["-x", process_name]) + .status() + .map_err(|error| format!("检查 Codex App 进程失败: {error}"))?; + if status.success() { + Err("Codex App 未能完全关闭".to_string()) + } else { + Ok(()) + } +} + +#[cfg(not(any(target_os = "windows", target_os = "macos")))] +fn stop_codex_desktop(_target: &CodexAppTarget) -> Result<(), String> { + Err("当前平台不支持重启 Codex App".to_string()) +} + fn launch_claude_desktop(home: &Path) -> Result<(), String> { if let Some(executable) = find_claude_desktop_executable(home) { return launch_desktop_agent(&executable, "Claude Desktop"); @@ -394,4 +566,19 @@ mod tests { assert_eq!(zcode[0].id, "app"); assert_eq!(kimi[0].id, "cli"); } + + #[cfg(target_os = "windows")] + #[test] + fn codex_restart_script_is_scoped_to_the_detected_installation() { + let executable = CodexAppTarget::Application(PathBuf::from(r"C:\Apps\Codex\Codex.exe")); + let executable_script = windows_codex_stop_script(&executable); + assert!(executable_script.contains(r"C:\Apps\Codex\Codex.exe")); + assert!(executable_script.contains("Get-CimInstance Win32_Process")); + assert!(!executable_script.contains("taskkill")); + + let store = CodexAppTarget::WindowsAppId("OpenAI.Codex_123!App".to_string()); + let store_script = windows_codex_stop_script(&store); + assert!(store_script.contains("OpenAI.Codex_123")); + assert!(store_script.contains("PackageFamilyName")); + } } diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index a24a01d..5936601 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -1985,6 +1985,7 @@ fn main() { set_agent_config_enabled, update_agent_config, launch_agent, + restart_codex_app, get_lan_ipv4, save_gui_settings, save_network_routing_settings, diff --git a/src/i18n/ja.ts b/src/i18n/ja.ts index 3c29477..7ceee64 100644 --- a/src/i18n/ja.ts +++ b/src/i18n/ja.ts @@ -505,6 +505,8 @@ export const jaOverrides = { 'agents.launch.starting': '起動中', 'agents.launch.startCli': 'CLI を起動', 'agents.launch.startApp': 'App を起動', + 'agents.launch.restartApp': 'App を再起動', + 'agents.launch.restartingApp': '再起動中', 'agents.launch.start': '{target} を起動', 'agents.launch.unavailable': '起動できません', 'agents.launchDirectory.noDirectory': '未選択', @@ -514,9 +516,14 @@ export const jaOverrides = { 'agents.launchDirectory.dialogTitle': '{client} の起動ディレクトリを選択', 'agents.launchDirectory.dialogDescription': 'プロジェクトの作業ディレクトリを選択してから、{client} を起動します。', 'agents.launchDirectory.workingDirectory': '作業ディレクトリ', - 'agents.launchDirectory.browse': '参照', + 'agents.launchDirectory.browse': '変更', 'agents.launchDirectory.directoryRequired': '先に作業ディレクトリを選択してください', 'agents.launchDirectory.launch': '{client} を起動', + 'agents.launchDirectory.historyTitle': '最近使用した項目', + 'agents.launchDirectory.lastUsed': '前回使用', + 'agents.launchDirectory.recentItem': '履歴ディレクトリ', + 'agents.launchDirectory.historyUse': '選択', + 'agents.launchDirectory.historySelected': '選択済み', 'agents.status.detecting': '検出中', 'agents.status.unsupported': 'プラットフォーム非対応', 'agents.status.notInstalled': '未インストール', diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index 0d9aff4..2fdb9b6 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -505,6 +505,8 @@ export const en: Record = { 'agents.launch.starting': 'Starting', 'agents.launch.startCli': 'Start CLI', 'agents.launch.startApp': 'Start App', + 'agents.launch.restartApp': 'Restart App', + 'agents.launch.restartingApp': 'Restarting', 'agents.launch.start': 'Start {target}', 'agents.launch.unavailable': 'Cannot Start', 'agents.launchDirectory.noDirectory': 'Not selected', @@ -514,9 +516,14 @@ export const en: Record = { 'agents.launchDirectory.dialogTitle': 'Choose a Launch Directory for {client}', 'agents.launchDirectory.dialogDescription': 'Choose a project working directory, then start {client}.', 'agents.launchDirectory.workingDirectory': 'Working directory', - 'agents.launchDirectory.browse': 'Browse', + 'agents.launchDirectory.browse': 'Change', 'agents.launchDirectory.directoryRequired': 'Select a working directory first', 'agents.launchDirectory.launch': 'Start {client}', + 'agents.launchDirectory.historyTitle': 'Recently Used', + 'agents.launchDirectory.lastUsed': 'Last used', + 'agents.launchDirectory.recentItem': 'Recent directory', + 'agents.launchDirectory.historyUse': 'Select', + 'agents.launchDirectory.historySelected': 'Selected', 'agents.status.detecting': 'Detecting', 'agents.status.unsupported': 'Unsupported Platform', 'agents.status.notInstalled': 'Not Installed', diff --git a/src/i18n/locales/zh-CN.ts b/src/i18n/locales/zh-CN.ts index c151822..159ee74 100644 --- a/src/i18n/locales/zh-CN.ts +++ b/src/i18n/locales/zh-CN.ts @@ -503,6 +503,8 @@ export const zhCN = { 'agents.launch.starting': '启动中', 'agents.launch.startCli': '启动 CLI', 'agents.launch.startApp': '启动 App', + 'agents.launch.restartApp': '重启 App', + 'agents.launch.restartingApp': '重启中', 'agents.launch.start': '启动 {target}', 'agents.launch.unavailable': '无法启动', 'agents.launchDirectory.noDirectory': '尚未选择', @@ -512,9 +514,14 @@ export const zhCN = { 'agents.launchDirectory.dialogTitle': '选择 {client} 启动目录', 'agents.launchDirectory.dialogDescription': '选择项目工作目录,然后启动 {client}。', 'agents.launchDirectory.workingDirectory': '工作目录', - 'agents.launchDirectory.browse': '浏览', + 'agents.launchDirectory.browse': '修改', 'agents.launchDirectory.directoryRequired': '请先选择工作目录', 'agents.launchDirectory.launch': '启动 {client}', + 'agents.launchDirectory.historyTitle': '最近使用', + 'agents.launchDirectory.lastUsed': '上次使用', + 'agents.launchDirectory.recentItem': '历史目录', + 'agents.launchDirectory.historyUse': '选择', + 'agents.launchDirectory.historySelected': '已选择', 'agents.status.detecting': '检测中', 'agents.status.unsupported': '平台不支持', 'agents.status.notInstalled': '未安装', diff --git a/src/pages/AgentsPage.tsx b/src/pages/AgentsPage.tsx index 0f3a3ef..20190d9 100644 --- a/src/pages/AgentsPage.tsx +++ b/src/pages/AgentsPage.tsx @@ -51,6 +51,11 @@ import { sameAgentModel, sameAgentModelMappings, } from '../services/agentConfigurationDraft'; +import { + parseAgentLaunchDirectoryHistory, + rememberAgentLaunchDirectory, + type AgentLaunchDirectoryHistory, +} from '../services/agentLaunchDirectoryHistory'; import type { ModelOption } from '../services/modelService'; import { getCurrentLocale, translate, useI18n } from '../i18n'; import { CodexSessionsPanel } from './CodexSessionsPanel'; @@ -284,6 +289,7 @@ const DEFAULT_AGENT_SUBPAGE: AgentSubpageId = 'core'; const AGENT_MODEL_SELECTIONS_KEY = 'cpa-gui.agent-model-selections.v1'; const AGENT_SELECTED_CLIENT_KEY = 'cpa-gui.agent-selected-client.v1'; +const AGENT_LAUNCH_DIRECTORY_HISTORY_KEY = 'cpa-gui.agent-launch-directory-history.v1'; const readSelectedAgentClient = (): AgentClientId => { const fallback = agentDefinitions[0].id; @@ -334,6 +340,27 @@ const writeAgentModelSelections = ( } }; +const readAgentLaunchDirectoryHistory = (): AgentLaunchDirectoryHistory => { + if (typeof window === 'undefined') return {}; + try { + return parseAgentLaunchDirectoryHistory( + window.localStorage.getItem(AGENT_LAUNCH_DIRECTORY_HISTORY_KEY), + agentDefinitions.map((agent) => agent.id), + ); + } catch { + return {}; + } +}; + +const writeAgentLaunchDirectoryHistory = (history: AgentLaunchDirectoryHistory) => { + if (typeof window === 'undefined') return; + try { + window.localStorage.setItem(AGENT_LAUNCH_DIRECTORY_HISTORY_KEY, JSON.stringify(history)); + } catch { + // Keep the current in-memory history when persistent storage is unavailable. + } +}; + function AgentMark({ definition, size = 26 }: { definition: AgentDefinition; size?: number }) { if (definition.icon) { return ; @@ -623,7 +650,7 @@ export function AgentsPage() { const [loading, setLoading] = useState(true); const [modelLoading, setModelLoading] = useState(false); const [busyAction, setBusyAction] = useState< - 'apply' | 'close-config' | 'default' | 'clear' | 'install-pi' | 'update-pi' | 'repair-pi' | 'uninstall-pi' | 'oauth-check' | 'directory' | 'launch' | 'launch-cli' | 'launch-app' | null + 'apply' | 'close-config' | 'default' | 'clear' | 'install-pi' | 'update-pi' | 'repair-pi' | 'uninstall-pi' | 'oauth-check' | 'directory' | 'launch' | 'launch-cli' | 'launch-app' | 'restart-app' | null >(null); const busy = busyAction !== null; const [detectionError, setDetectionError] = useState(''); @@ -640,6 +667,9 @@ export function AgentsPage() { const [launchDirectory, setLaunchDirectory] = useState(''); const [launchDirectoryTarget, setLaunchDirectoryTarget] = useState(null); const [launchDirectoryError, setLaunchDirectoryError] = useState(''); + const [launchDirectoryHistory, setLaunchDirectoryHistory] = useState( + readAgentLaunchDirectoryHistory, + ); const [oauthLoginRequiredAction, setOauthLoginRequiredAction] = useState(null); const [oauthConfigurationDraft, setOauthConfigurationDraft] = useState(null); const [piProviderUpdateStatus, setPiProviderUpdateStatus] = useState(null); @@ -936,6 +966,7 @@ export function AgentsPage() { : activeStatus.modificationEnabled && activeStatus.modificationState === 'applied')), ); const canLaunchTarget = (target: AgentLaunchTarget | null) => launchEnabled && Boolean(target); + const activeLaunchDirectoryHistory = launchDirectoryHistory[selected] ?? []; const modelHint = modelSelectionError || modelError || (modelLoading @@ -1283,7 +1314,7 @@ export function AgentsPage() { }; const openLaunchDirectoryDialog = (target: AgentLaunchTarget) => { - setLaunchDirectory(''); + setLaunchDirectory(activeLaunchDirectoryHistory[0] ?? ''); setLaunchDirectoryTarget(target); setLaunchDirectoryError(''); setLaunchDirectoryDialogOpen(true); @@ -1307,6 +1338,14 @@ export function AgentsPage() { } }; + const rememberLaunchDirectory = (client: AgentClientId, directory: string) => { + setLaunchDirectoryHistory((current) => { + const next = rememberAgentLaunchDirectory(current, client, directory); + writeAgentLaunchDirectoryHistory(next); + return next; + }); + }; + const invokeAgentLaunch = async ( target: AgentLaunchTarget, workingDirectory: string | null = null, @@ -1324,6 +1363,7 @@ export function AgentsPage() { workingDirectory, }); if (workingDirectory) { + rememberLaunchDirectory(selected, workingDirectory); setLaunchDirectoryDialogOpen(false); setLaunchDirectoryTarget(null); } @@ -1347,6 +1387,21 @@ export function AgentsPage() { await invokeAgentLaunch(target); }; + const restartCodexApp = async () => { + setBusyAction('restart-app'); + setLaunchError(''); + try { + if (draftChanged) throw new Error(t('agents.error.applyFirst')); + await invoke('restart_codex_app'); + } catch (requestError) { + if (!handleOAuthLoginError(requestError, 'launch')) { + setLaunchError(String(requestError)); + } + } finally { + setBusyAction(null); + } + }; + const launchFromDirectoryDialog = async () => { if (!launchDirectoryTarget) return; const workingDirectory = launchDirectory.trim(); @@ -1860,6 +1915,22 @@ export function AgentsPage() { ? t('agents.launch.starting') : t('agents.launch.startApp')} + ) : (
+ {activeLaunchDirectoryHistory.length ? ( +
+ {t('agents.launchDirectory.historyTitle')} +
+ {activeLaunchDirectoryHistory.map((directory, index) => { + const active = directory === launchDirectory; + return ( + + ); + })} +
+
+ ) : null} {launchDirectoryError ? ( {launchDirectoryError} diff --git a/src/services/agentLaunchDirectoryHistory.ts b/src/services/agentLaunchDirectoryHistory.ts new file mode 100644 index 0000000..715277b --- /dev/null +++ b/src/services/agentLaunchDirectoryHistory.ts @@ -0,0 +1,50 @@ +export type AgentLaunchDirectoryHistory = Record; + +export const MAX_AGENT_LAUNCH_DIRECTORY_HISTORY = 8; + +const validDirectory = (value: unknown): value is string => ( + typeof value === 'string' + && Boolean(value.trim()) + && value.length <= 4096 + && !Array.from(value).some((character) => /[\u0000-\u001f\u007f]/u.test(character)) +); + +export const parseAgentLaunchDirectoryHistory = ( + payload: string | null, + clients: readonly string[], +): AgentLaunchDirectoryHistory => { + if (!payload) return {}; + try { + const parsed = JSON.parse(payload) as unknown; + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return {}; + const source = parsed as Record; + return clients.reduce((history, client) => { + const entries = source[client]; + if (!Array.isArray(entries)) return history; + const normalized = entries + .filter(validDirectory) + .map((directory) => directory.trim()) + .filter((directory, index, directories) => directories.indexOf(directory) === index) + .slice(0, MAX_AGENT_LAUNCH_DIRECTORY_HISTORY); + if (normalized.length) history[client] = normalized; + return history; + }, {}); + } catch { + return {}; + } +}; + +export const rememberAgentLaunchDirectory = ( + history: AgentLaunchDirectoryHistory, + client: string, + directory: string, +): AgentLaunchDirectoryHistory => { + const normalized = directory.trim(); + if (!validDirectory(normalized)) return history; + const previous = history[client] ?? []; + return { + ...history, + [client]: [normalized, ...previous.filter((item) => item !== normalized)] + .slice(0, MAX_AGENT_LAUNCH_DIRECTORY_HISTORY), + }; +}; diff --git a/src/styles.css b/src/styles.css index 36df846..1eb5646 100644 --- a/src/styles.css +++ b/src/styles.css @@ -5631,6 +5631,85 @@ textarea:focus-visible { font-size: var(--font-size-label); } +.agent-launch-directory-history { + display: grid; + min-height: 0; + gap: 7px; +} + +.agent-launch-directory-history > strong { + color: var(--theme-5f5a53); + font-size: var(--font-size-body); +} + +.agent-launch-directory-history-list { + display: grid; + min-height: 64px; + max-height: 176px; + overflow-y: auto; + overscroll-behavior: contain; + scrollbar-gutter: stable; + border: 1px solid var(--theme-d8d2c8); + border-radius: 9px; + background: var(--theme-fff); +} + +.agent-launch-directory-history-list > button { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: center; + gap: 12px; + width: 100%; + min-height: 58px; + padding: 8px 11px; + border: 0; + border-bottom: 1px solid var(--theme-eeeae2); + border-radius: 0; + background: transparent; + color: var(--theme-2d2a26); + text-align: left; +} + +.agent-launch-directory-history-list > button:last-child { + border-bottom: 0; +} + +.agent-launch-directory-history-list > button:hover:not(:disabled) { + background: var(--theme-faf8f4); +} + +.agent-launch-directory-history-list > button.active { + background: var(--theme-f1ecfb); + box-shadow: inset 3px 0 0 var(--theme-8069ad); +} + +.agent-launch-directory-history-list > button > span { + display: grid; + min-width: 0; + gap: 3px; +} + +.agent-launch-directory-history-list > button strong { + overflow: hidden; + font-size: var(--font-size-label); + text-overflow: ellipsis; + white-space: nowrap; +} + +.agent-launch-directory-history-list > button small { + color: var(--theme-8a847c); + font-size: var(--font-size-meta); +} + +.agent-launch-directory-history-list > button > b { + display: inline-flex; + flex: none; + align-items: center; + gap: 5px; + color: var(--theme-8069ad); + font-size: var(--font-size-label); +} + .agent-status-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); diff --git a/tests/agentLaunchDirectoryHistory.test.ts b/tests/agentLaunchDirectoryHistory.test.ts new file mode 100644 index 0000000..5bb3f4d --- /dev/null +++ b/tests/agentLaunchDirectoryHistory.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from 'bun:test'; +import { + MAX_AGENT_LAUNCH_DIRECTORY_HISTORY, + parseAgentLaunchDirectoryHistory, + rememberAgentLaunchDirectory, +} from '../src/services/agentLaunchDirectoryHistory'; + +describe('agent launch directory history', () => { + it('keeps only valid known-client entries', () => { + const history = parseAgentLaunchDirectoryHistory(JSON.stringify({ + codex: [' C:\\work\\one ', '', 'C:\\work\\one', 42], + unknown: ['C:\\private'], + }), ['codex', 'claude-code']); + expect(history).toEqual({ codex: ['C:\\work\\one'] }); + }); + + it('moves the successful directory to the front and limits history size', () => { + const existing = Array.from( + { length: MAX_AGENT_LAUNCH_DIRECTORY_HISTORY }, + (_, index) => `/work/${index}`, + ); + const added = rememberAgentLaunchDirectory({ codex: existing }, 'codex', '/work/new'); + expect(added.codex[0]).toBe('/work/new'); + expect(added.codex).toHaveLength(MAX_AGENT_LAUNCH_DIRECTORY_HISTORY); + + const reused = rememberAgentLaunchDirectory(added, 'codex', '/work/2'); + expect(reused.codex[0]).toBe('/work/2'); + expect(reused.codex.filter((directory) => directory === '/work/2')).toHaveLength(1); + }); + + it('falls back to an empty history for malformed storage', () => { + expect(parseAgentLaunchDirectoryHistory('{broken', ['codex'])).toEqual({}); + }); +});