feat(agents): simplify configuration UI and expand restart support

- Add configuration management tabs to full and easy modes
- Share run controls and feedback while preserving drafts and Claude settings
- Support desktop app restarts and Harness Web restarts with original parameters
- Improve responsive layouts, keyboard navigation, and localization
- Add UI regression and isolated process restart tests
This commit is contained in:
lzt404 committed 2026-09-11 16:25:14 +08:00
1 parent 034a111c0b
commit 9d0baa4c69
14 files changed
+1314 -492

No files matched your search

+16 -16
View File
@@ -1474,7 +1474,7 @@ pub(crate) fn inspect_managed_toml_model_marker(
Ok(provider_exists && catalog_has_selected)
}
pub(crate) fn find_codex_app_installation(home: &Path) -> Option<CodexAppTarget> {
pub(crate) fn find_codex_app_installation(home: &Path) -> Option<DesktopAppTarget> {
#[cfg(target_os = "macos")]
{
[PathBuf::from("/Applications"), home.join("Applications")]
@@ -1490,7 +1490,7 @@ pub(crate) fn find_codex_app_installation(home: &Path) -> Option<CodexAppTarget>
.map(move |name| directory.join(name))
})
.find(|path| path.is_dir())
.map(CodexAppTarget::Application)
.map(DesktopAppTarget::Application)
}
#[cfg(target_os = "windows")]
@@ -1995,19 +1995,19 @@ pub(crate) fn read_claude_desktop_version(home: &Path) -> Option<String> {
}
pub(crate) fn read_codex_app_installation_version(
installation: &CodexAppTarget,
installation: &DesktopAppTarget,
_home: &Path,
) -> Option<String> {
#[cfg(target_os = "windows")]
{
match installation {
CodexAppTarget::WindowsAppId(app_id) => read_windows_codex_store_version(app_id),
CodexAppTarget::Application(path) => read_windows_codex_desktop_version(path),
DesktopAppTarget::WindowsAppId(app_id) => read_windows_codex_store_version(app_id),
DesktopAppTarget::Application(path) => read_windows_codex_desktop_version(path),
}
}
#[cfg(target_os = "macos")]
{
let CodexAppTarget::Application(path) = installation;
let DesktopAppTarget::Application(path) = installation;
read_macos_app_version(path)
}
#[cfg(not(any(target_os = "windows", target_os = "macos")))]
@@ -2166,10 +2166,10 @@ pub(crate) fn windows_command_processor() -> PathBuf {
}
#[cfg(target_os = "windows")]
pub(crate) fn find_windows_codex_app_installation(home: &Path) -> Option<CodexAppTarget> {
pub(crate) fn find_windows_codex_app_installation(home: &Path) -> Option<DesktopAppTarget> {
find_windows_registered_codex_app_installation()
.or_else(|| find_windows_codex_app_id_via_registry().map(CodexAppTarget::WindowsAppId))
.or_else(|| find_windows_codex_app_executable(home).map(CodexAppTarget::Application))
.or_else(|| find_windows_codex_app_id_via_registry().map(DesktopAppTarget::WindowsAppId))
.or_else(|| find_windows_codex_app_executable(home).map(DesktopAppTarget::Application))
}
#[cfg(target_os = "windows")]
@@ -2227,8 +2227,8 @@ pub(crate) fn read_windows_codex_store_version(app_id: &str) -> Option<String> {
return None;
}
match parse_windows_codex_app_discovery_output(&String::from_utf8_lossy(&output.stdout))? {
CodexAppTarget::Application(path) => read_windows_codex_desktop_version(&path),
CodexAppTarget::WindowsAppId(_) => None,
DesktopAppTarget::Application(path) => read_windows_codex_desktop_version(&path),
DesktopAppTarget::WindowsAppId(_) => None,
}
}
@@ -2345,7 +2345,7 @@ pub(crate) fn read_codex_asar_version(path: &Path) -> Option<String> {
}
#[cfg(target_os = "windows")]
pub(crate) fn find_windows_registered_codex_app_installation() -> Option<CodexAppTarget> {
pub(crate) fn find_windows_registered_codex_app_installation() -> Option<DesktopAppTarget> {
const DISCOVERY_SCRIPT: &str = r#"
$ErrorActionPreference = 'SilentlyContinue'
$ProgressPreference = 'SilentlyContinue'
@@ -2430,7 +2430,7 @@ foreach ($shortcutFile in (Get-ChildItem -LiteralPath $shortcutRoots -Filter '*.
}
parse_windows_codex_app_discovery_output(&String::from_utf8_lossy(&output.stdout)).and_then(
|target| match &target {
CodexAppTarget::Application(path) if !path.is_file() => None,
DesktopAppTarget::Application(path) if !path.is_file() => None,
_ => Some(target),
},
)
@@ -2485,16 +2485,16 @@ if ($version) {{
}
#[cfg(target_os = "windows")]
pub(crate) fn parse_windows_codex_app_discovery_output(output: &str) -> Option<CodexAppTarget> {
pub(crate) fn parse_windows_codex_app_discovery_output(output: &str) -> Option<DesktopAppTarget> {
output.lines().find_map(|line| {
let line = line.trim();
if let Some(app_id) = line.strip_prefix("APPID:").map(str::trim) {
return (!app_id.is_empty()).then(|| CodexAppTarget::WindowsAppId(app_id.to_string()));
return (!app_id.is_empty()).then(|| DesktopAppTarget::WindowsAppId(app_id.to_string()));
}
line.strip_prefix("EXE:")
.map(str::trim)
.filter(|path| !path.is_empty())
.map(|path| CodexAppTarget::Application(PathBuf::from(path)))
.map(|path| DesktopAppTarget::Application(PathBuf::from(path)))
})
}
+577 -76
View File
@@ -385,10 +385,89 @@ fn launch_managed_deepseek_harness(
*process = Some(ManagedDeepSeekHarnessProcess {
child,
mode: mode.to_string(),
launch: DeepSeekHarnessLaunchSnapshot {
executable: executable.to_path_buf(),
working_directory: working_directory.to_path_buf(),
arguments: arguments.to_vec(),
options: options.cloned(),
},
});
Ok(())
}
#[tauri::command]
pub(crate) async fn restart_deepseek_harness_process(
app: tauri::AppHandle,
) -> Result<DeepSeekHarnessProcessStatus, String> {
tauri::async_runtime::spawn_blocking(move || {
restart_managed_deepseek_harness(app.state::<DeepSeekHarnessProcessState>().inner())
})
.await
.map_err(|error| format!("重启 DeepSeek Harness Web 任务失败: {error}"))?
}
fn restart_managed_deepseek_harness(
process_state: &DeepSeekHarnessProcessState,
) -> Result<DeepSeekHarnessProcessStatus, String> {
restart_managed_deepseek_harness_with(
process_state,
terminate_deepseek_harness_process_tree,
spawn_managed_deepseek_harness,
)
}
fn restart_managed_deepseek_harness_with(
process_state: &DeepSeekHarnessProcessState,
stop: impl FnOnce(&mut Child) -> Result<(), String>,
spawn: impl FnOnce(&Path, &Path, &[String]) -> Result<Child, String>,
) -> Result<DeepSeekHarnessProcessStatus, String> {
// Keep the lock across stop and spawn so launch/stop/status cannot interleave.
let mut process = process_state
.process
.lock()
.map_err(|_| "DeepSeek Harness 进程状态锁已损坏".to_string())?;
let managed = process
.as_mut()
.ok_or_else(|| "没有正在运行的 DeepSeek Harness Web 服务".to_string())?;
if managed
.child
.try_wait()
.map_err(|error| format!("检查 DeepSeek Harness 进程状态失败: {error}"))?
.is_some()
{
*process = None;
return Err("DeepSeek Harness Web 已退出,请重新启动".to_string());
}
if managed.mode != "web" {
return Err("只有 Web 模式支持重启".to_string());
}
let launch = managed.launch.clone();
// Validate paths before shutting down a working service.
if !launch.executable.is_file() || !launch.working_directory.is_dir() {
return Err("DeepSeek Harness 启动程序或工作目录已不存在".to_string());
}
stop(&mut managed.child)?;
*process = None;
// The old process must release its endpoint before the replacement starts.
ensure_deepseek_harness_web_endpoint_available(launch.options.as_ref())?;
let child = spawn(
&launch.executable,
&launch.working_directory,
&launch.arguments,
)?;
let status = DeepSeekHarnessProcessStatus {
running: true,
pid: Some(child.id()),
mode: Some("web".to_string()),
};
*process = Some(ManagedDeepSeekHarnessProcess {
child,
mode: "web".to_string(),
launch,
});
Ok(status)
}
fn ensure_deepseek_harness_web_endpoint_available(
options: Option<&DeepSeekHarnessLaunchOptions>,
) -> Result<(), String> {
@@ -431,7 +510,13 @@ fn spawn_managed_deepseek_harness(
command
.args(arguments)
.current_dir(working_directory)
.creation_flags(CREATE_NEW_CONSOLE | CREATE_NEW_PROCESS_GROUP);
.creation_flags(
(if cfg!(test) {
0x0800_0000
} else {
CREATE_NEW_CONSOLE
}) | CREATE_NEW_PROCESS_GROUP,
);
return command
.spawn()
.map_err(|error| format!("启动 DeepSeek Harness 失败: {error}"));
@@ -585,37 +670,153 @@ fn validate_deepseek_harness_argument(value: &str, label: &str) -> Result<String
#[tauri::command]
pub(crate) async fn restart_codex_app(app: tauri::AppHandle) -> Result<(), String> {
let home = app
.path()
.home_dir()
.map_err(|error| format!("无法获取用户目录: {error}"))?;
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}"))?
restart_agent_app(app, "codex".to_string()).await
}
#[tauri::command]
pub(crate) async fn restart_opencode_app(app: tauri::AppHandle) -> Result<(), String> {
restart_agent_app(app, "opencode".to_string()).await
}
#[tauri::command]
pub(crate) async fn restart_agent_app(app: tauri::AppHandle, client: String) -> Result<(), String> {
let client = AgentClient::parse(&client)?;
if !matches!(
client,
AgentClient::Codex
| AgentClient::OpenCode
| AgentClient::ClaudeDesktop
| AgentClient::ZCode
) {
return Err(format!("{} 不支持桌面应用重启", client.name()));
}
let home = app
.path()
.home_dir()
.map_err(|error| format!("无法获取用户目录: {error}"))?;
let application = find_opencode_desktop_application(&home).ok_or_else(|| {
"未检测到 OpenCode Desktop 应用,请重新检测或改用 OpenCode CLI".to_string()
})?;
tauri::async_runtime::spawn_blocking(move || {
stop_opencode_desktop(&application)?;
launch_desktop_agent(&application, "OpenCode Desktop")
let target = find_desktop_restart_target(client, &home)?;
match client {
AgentClient::Codex => stop_codex_desktop(&target)?,
AgentClient::OpenCode => {
let DesktopAppTarget::Application(path) = &target else {
return Err("OpenCode 桌面安装类型无效".to_string());
};
stop_opencode_desktop(path)?;
}
_ => stop_other_desktop(&target, client.name())?,
}
match &target {
DesktopAppTarget::Application(path) => launch_desktop_agent(path, client.name()),
#[cfg(target_os = "windows")]
DesktopAppTarget::WindowsAppId(app_id) => {
launch_windows_store_app(app_id, client.name())
}
}
})
.await
.map_err(|error| format!("重启 OpenCode Desktop 任务失败: {error}"))?
.map_err(|error| format!("重启桌面应用任务失败: {error}"))?
}
fn find_desktop_restart_target(
client: AgentClient,
home: &Path,
) -> Result<DesktopAppTarget, String> {
let target = match client {
AgentClient::Codex => find_codex_app_installation(home),
AgentClient::OpenCode => {
find_opencode_desktop_application(home).map(DesktopAppTarget::Application)
}
AgentClient::ZCode => {
find_zcode_desktop_executable(home).map(DesktopAppTarget::Application)
}
AgentClient::ClaudeDesktop => {
let executable =
find_claude_desktop_executable(home).map(DesktopAppTarget::Application);
#[cfg(target_os = "windows")]
{
executable
.or_else(|| find_windows_claude_app_id().map(DesktopAppTarget::WindowsAppId))
}
#[cfg(not(target_os = "windows"))]
{
executable
}
}
_ => None,
};
target.ok_or_else(|| format!("未检测到 {} 桌面应用,请重新检测", client.name()))
}
#[cfg(target_os = "windows")]
fn find_windows_claude_app_id() -> Option<String> {
let script = "@(Get-StartApps) | Where-Object { $_.AppID -like 'Claude_*!*' -or $_.AppID -like 'Anthropic.Claude_*!*' } | Select-Object -First 1 -ExpandProperty AppID";
let mut command = Command::new(windows_powershell_executable());
command.args([
"-NoLogo",
"-NoProfile",
"-NonInteractive",
"-EncodedCommand",
&windows_powershell_encoded_command(script),
]);
configure_background_command(&mut command);
let output = command_output_with_timeout(&mut command, Duration::from_secs(5)).ok()??;
let id = String::from_utf8_lossy(&output.stdout).trim().to_string();
(output.status.success() && !id.is_empty()).then_some(id)
}
#[cfg(target_os = "windows")]
fn stop_other_desktop(target: &DesktopAppTarget, label: &str) -> Result<(), String> {
run_windows_desktop_stop_script(&windows_desktop_stop_script(target), label)
}
#[cfg(target_os = "windows")]
fn windows_desktop_stop_script(target: &DesktopAppTarget) -> String {
let selector = match target {
DesktopAppTarget::Application(path) => format!("$targetExecutable = {}\n$targetRoot = $null", windows_powershell_single_quoted_literal(&path_to_string(path))),
DesktopAppTarget::WindowsAppId(app_id) => format!(
"$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 }}\nif (-not $targetRoot) {{ throw 'Application package directory was not found' }}",
windows_powershell_single_quoted_literal(app_id.split('!').next().unwrap_or(app_id))),
};
format!(
r#"$ErrorActionPreference = 'Stop'
{selector}
function Get-TargetProcesses {{
@(Get-CimInstance Win32_Process | Where-Object {{
$_.ExecutablePath -and
(($targetExecutable -and [string]::Equals($_.ExecutablePath, $targetExecutable, [System.StringComparison]::OrdinalIgnoreCase)) -or
($targetRoot -and $_.ExecutablePath.StartsWith(($targetRoot.TrimEnd('\') + '\'), [System.StringComparison]::OrdinalIgnoreCase)))
}})
}}
foreach ($process in @(Get-TargetProcesses)) {{
Stop-Process -Id $process.ProcessId -Force -ErrorAction SilentlyContinue
}}
$deadline = [DateTime]::UtcNow.AddSeconds(5)
do {{
$remaining = @(Get-TargetProcesses)
if ($remaining.Count -eq 0) {{ exit 0 }}
Start-Sleep -Milliseconds 100
}} while ([DateTime]::UtcNow -lt $deadline)
throw "Application did not exit; remaining process IDs: $($remaining.ProcessId -join ', ')"
"#
)
}
#[cfg(target_os = "macos")]
fn stop_other_desktop(target: &DesktopAppTarget, label: &str) -> Result<(), String> {
let DesktopAppTarget::Application(path) = target;
stop_macos_desktop_application(path, label)
}
#[cfg(target_os = "linux")]
fn stop_other_desktop(target: &DesktopAppTarget, label: &str) -> Result<(), String> {
let DesktopAppTarget::Application(path) = target;
stop_linux_desktop_application(path, label)
}
#[cfg(not(any(target_os = "windows", target_os = "macos", target_os = "linux")))]
fn stop_other_desktop(_target: &DesktopAppTarget, label: &str) -> Result<(), String> {
Err(format!("当前平台不支持重启 {label}"))
}
fn resolve_launch_directory(value: Option<&str>, fallback: &Path) -> Result<PathBuf, String> {
@@ -641,16 +842,16 @@ fn launch_codex_desktop(home: &Path) -> Result<(), String> {
launch_codex_target(&target)
}
fn launch_codex_target(target: &CodexAppTarget) -> Result<(), String> {
fn launch_codex_target(target: &DesktopAppTarget) -> 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"),
DesktopAppTarget::WindowsAppId(app_id) => launch_windows_store_app(app_id, "Codex App"),
DesktopAppTarget::Application(path) => launch_desktop_agent(path, "Codex App"),
}
}
#[cfg(target_os = "windows")]
fn stop_codex_desktop(target: &CodexAppTarget) -> Result<(), String> {
fn stop_codex_desktop(target: &DesktopAppTarget) -> Result<(), String> {
let script = windows_codex_stop_script(target);
run_windows_desktop_stop_script(&script, "Codex App")
}
@@ -696,13 +897,13 @@ fn run_windows_desktop_stop_script(script: &str, label: &str) -> Result<(), Stri
}
#[cfg(target_os = "windows")]
fn windows_codex_stop_script(target: &CodexAppTarget) -> String {
fn windows_codex_stop_script(target: &DesktopAppTarget) -> String {
let selector = match target {
CodexAppTarget::Application(path) => format!(
DesktopAppTarget::Application(path) => format!(
"$targetExecutable = {}\n$targetRoot = $null",
windows_powershell_single_quoted_literal(&path_to_string(path))
),
CodexAppTarget::WindowsAppId(app_id) => {
DesktopAppTarget::WindowsAppId(app_id) => {
let package_family = app_id.split('!').next().unwrap_or(app_id);
format!(
concat!(
@@ -771,8 +972,8 @@ throw "OpenCode Desktop did not exit; remaining process IDs: $($remaining.Proces
}
#[cfg(target_os = "macos")]
fn stop_codex_desktop(target: &CodexAppTarget) -> Result<(), String> {
let CodexAppTarget::Application(application) = target;
fn stop_codex_desktop(target: &DesktopAppTarget) -> Result<(), String> {
let DesktopAppTarget::Application(application) = target;
stop_macos_desktop_application(application, "Codex App")
}
@@ -781,57 +982,87 @@ fn stop_opencode_desktop(application: &Path) -> Result<(), String> {
stop_macos_desktop_application(application, "OpenCode Desktop")
}
#[cfg(target_os = "macos")]
fn stop_macos_desktop_application(application: &Path, label: &str) -> Result<(), String> {
let application_bundle = application
#[cfg(any(target_os = "macos", test))]
fn desktop_process_ids_from_ps(output: &str, application: &Path) -> Vec<i32> {
let bundle = application
.ancestors()
.find(|path| path.extension().and_then(|value| value.to_str()) == Some("app"))
.unwrap_or(application);
let process_name = application_bundle
.file_stem()
.and_then(|name| name.to_str())
.filter(|name| !name.is_empty())
.ok_or_else(|| format!("无法识别 {label} 进程名称"))?;
let application_name = application_bundle
.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, label)
.find(|path| path.extension().and_then(|value| value.to_str()) == Some("app"));
output
.lines()
.filter_map(|line| {
let line = line.trim();
let split = line.find(char::is_whitespace)?;
let pid = line[..split].parse::<i32>().ok()?;
if pid <= 0 || pid == std::process::id() as i32 {
return None;
}
let executable = Path::new(line[split..].trim());
(executable == application || bundle.is_some_and(|root| executable.starts_with(root)))
.then_some(pid)
})
.collect()
}
#[cfg(target_os = "macos")]
fn wait_for_unix_process_exit(process_name: &str, label: &str) -> Result<(), String> {
fn macos_processes_for_application(application: &Path) -> Result<Vec<i32>, String> {
let output = Command::new("ps")
.args(["-axww", "-o", "pid=", "-o", "comm="])
.output()
.map_err(|error| format!("读取桌面应用进程失败: {error}"))?;
if !output.status.success() {
return Err("读取桌面应用进程失败".to_string());
}
Ok(desktop_process_ids_from_ps(
&String::from_utf8_lossy(&output.stdout),
application,
))
}
#[cfg(target_os = "macos")]
fn stop_macos_desktop_application(application: &Path, label: &str) -> Result<(), String> {
if macos_processes_for_application(application)?.is_empty() {
return Ok(());
}
let bundle = application
.ancestors()
.find(|path| path.extension().and_then(|value| value.to_str()) == Some("app"))
.unwrap_or(application);
let escaped = path_to_string(bundle)
.replace('\\', "\\\\")
.replace('"', "\\\"");
let mut quit = Command::new("osascript");
quit.args(["-e", &format!("tell application \"{escaped}\" to quit")]);
let _ = command_output_with_timeout(&mut quit, Duration::from_secs(5));
for _ in 0..50 {
let status = Command::new("pgrep").args(["-x", process_name]).status();
if status.is_ok_and(|status| !status.success()) {
if macos_processes_for_application(application)?.is_empty() {
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!("检查 {label} 进程失败: {error}"))?;
if status.success() {
Err(format!("{label} 未能完全关闭"))
} else {
Ok(())
for pid in macos_processes_for_application(application)? {
if unsafe { libc::kill(pid, libc::SIGKILL) } != 0 {
let error = io::Error::last_os_error();
if error.raw_os_error() != Some(libc::ESRCH) {
return Err(format!("关闭 {label} 进程 {pid} 失败: {error}"));
}
}
}
for _ in 0..10 {
if macos_processes_for_application(application)?.is_empty() {
return Ok(());
}
thread::sleep(Duration::from_millis(100));
}
Err(format!("{label} 未能完全关闭"))
}
#[cfg(target_os = "linux")]
fn stop_opencode_desktop(application: &Path) -> Result<(), String> {
stop_linux_desktop_application(application, "OpenCode Desktop")
}
#[cfg(target_os = "linux")]
fn stop_linux_desktop_application(application: &Path, label: &str) -> Result<(), String> {
let application = fs::canonicalize(application).unwrap_or_else(|_| application.to_path_buf());
let processes = linux_processes_for_application(&application);
let mut signal_error = None;
@@ -868,7 +1099,7 @@ fn stop_opencode_desktop(application: &Path) -> Result<(), String> {
Err(error)
} else {
Err(format!(
"OpenCode Desktop 未能完全关闭;剩余进程 ID: {}",
"{label} 未能完全关闭;剩余进程 ID: {}",
remaining
.iter()
.map(i32::to_string)
@@ -913,8 +1144,11 @@ fn linux_process_matches_application(process_id: i32, application: &Path) -> boo
.split(|byte| *byte == 0)
.filter(|argument| !argument.is_empty())
.map(|argument| Path::new(OsStr::from_bytes(argument)))
.any(|argument| {
linux_process_path_matches(argument, application)
.enumerate()
.any(|(index, argument)| {
// An unrelated CLI may mention the desktop executable as an input file.
// Only argv[0] identifies a launcher; retain Nix wrapper discovery below.
(index == 0 && linux_process_path_matches(argument, application))
|| installation_root.is_some_and(|root| {
fs::canonicalize(argument)
.ok()
@@ -970,14 +1204,12 @@ fn signal_linux_process(process_id: i32, signal: i32) -> Result<(), String> {
if error.raw_os_error() == Some(libc::ESRCH) {
Ok(())
} else {
Err(format!(
"关闭 OpenCode Desktop 进程 {process_id} 失败: {error}"
))
Err(format!("关闭桌面应用进程 {process_id} 失败: {error}"))
}
}
#[cfg(not(any(target_os = "windows", target_os = "macos")))]
fn stop_codex_desktop(_target: &CodexAppTarget) -> Result<(), String> {
fn stop_codex_desktop(_target: &DesktopAppTarget) -> Result<(), String> {
Err("当前平台不支持重启 Codex App".to_string())
}
@@ -1325,6 +1557,269 @@ mod tests {
assert_eq!(normalize_agent_terminal(" auto "), "auto");
}
#[test]
fn desktop_process_matching_is_scoped_to_the_exact_bundle() {
let application = Path::new("/Applications/Claude Desktop.app/Contents/MacOS/Claude");
let processes = "10 /Applications/Claude Desktop.app/Contents/MacOS/Claude\n11 /Applications/Claude Desktop.app/Contents/Frameworks/Helper.app/Contents/MacOS/Helper\n12 /Applications/Claude Desktop.app.old/Contents/MacOS/Claude\n13 /Users/test/Claude Desktop.app/Contents/MacOS/Claude\n14 /usr/local/bin/claude\n";
assert_eq!(
desktop_process_ids_from_ps(processes, application),
vec![10, 11]
);
assert!(desktop_process_ids_from_ps(
"bad input\n-1 /opt/desktop",
Path::new("/opt/desktop")
)
.is_empty());
}
#[cfg(windows)]
#[test]
fn desktop_restart_script_handles_paths_and_store_packages_without_name_wide_kills() {
for path in [
r"C:\Apps\Claude's Desktop\Claude.exe",
r"C:\Apps\ZCode\ZCode.exe",
] {
let script =
windows_desktop_stop_script(&DesktopAppTarget::Application(PathBuf::from(path)));
assert!(script.contains(&windows_powershell_single_quoted_literal(path)));
assert!(script.contains("[string]::Equals($_.ExecutablePath, $targetExecutable"));
assert!(script.contains("$remaining.Count -eq 0"));
assert!(!script.contains("taskkill"));
assert!(!script.contains("Stop-Process -Name"));
}
let script = windows_desktop_stop_script(&DesktopAppTarget::WindowsAppId(
"Anthropic.Claude_family!App".to_string(),
));
assert!(script.contains("$packageFamily = 'Anthropic.Claude_family'"));
assert!(script.contains("$_.PackageFamilyName -eq $packageFamily"));
assert!(script.contains("$targetRoot.TrimEnd('\\') + '\\'"));
}
#[cfg(windows)]
#[test]
#[ignore = "helper launched only by the isolated desktop restart test"]
fn desktop_restart_test_process() {
if let Some(marker) = env::var_os("CPA_DESKTOP_RESTART_TEST_MARKER") {
fs::write(marker, "ready").unwrap();
thread::sleep(Duration::from_secs(30));
}
}
#[cfg(windows)]
#[test]
fn desktop_stop_waits_for_only_the_selected_installation() {
struct Processes {
children: Vec<Child>,
directory: PathBuf,
}
impl Drop for Processes {
fn drop(&mut self) {
for child in &mut self.children {
let _ = child.kill();
let _ = child.wait();
}
let _ = fs::remove_dir_all(&self.directory);
}
}
let directory = env::temp_dir().join(format!(
"cpa-desktop-restart-{}-{}",
std::process::id(),
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos()
));
fs::create_dir_all(&directory).unwrap();
let executable = env::current_exe().unwrap();
let target = directory.join("DesktopTest.exe");
fs::copy(&executable, &target).unwrap();
let mut processes = Processes {
children: Vec::new(),
directory,
};
for (index, application) in [&target, &executable].into_iter().enumerate() {
let marker = processes.directory.join(format!("ready-{index}"));
let mut command = Command::new(application);
command
.args([
"--exact",
"agents::launch::tests::desktop_restart_test_process",
"--ignored",
])
.env("CPA_DESKTOP_RESTART_TEST_MARKER", &marker)
.stdout(Stdio::null())
.stderr(Stdio::null());
configure_background_command(&mut command);
processes.children.push(command.spawn().unwrap());
for _ in 0..100 {
if marker.exists() {
break;
}
thread::sleep(Duration::from_millis(50));
}
assert!(marker.exists(), "desktop helper did not start");
}
let installation = DesktopAppTarget::Application(target);
stop_other_desktop(&installation, "Test Desktop").unwrap();
assert!(processes.children[0].try_wait().unwrap().is_some());
assert!(
processes.children[1].try_wait().unwrap().is_none(),
"unrelated installation must keep running"
);
// Stopping an installation with no running processes succeeds as well.
stop_other_desktop(&installation, "Test Desktop").unwrap();
}
struct HarnessRestartFixture {
state: DeepSeekHarnessProcessState,
directory: PathBuf,
}
impl Drop for HarnessRestartFixture {
fn drop(&mut self) {
let _ = stop_managed_deepseek_harness(&self.state);
let _ = fs::remove_dir_all(&self.directory);
}
}
fn harness_restart_fixture() -> HarnessRestartFixture {
let directory = env::temp_dir().join(format!(
"cpa-harness-restart-{}-{}",
std::process::id(),
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos()
));
fs::create_dir_all(&directory).unwrap();
#[cfg(windows)]
let (executable, arguments) = (windows_powershell_executable(), vec![
"-NoLogo".to_string(), "-NoProfile".to_string(), "-NonInteractive".to_string(), "-Command".to_string(),
"[IO.File]::AppendAllText((Join-Path (Get-Location).Path 'launches.txt'), ((Get-Location).Path + [Environment]::NewLine)); Start-Sleep -Seconds 60".to_string(),
]);
#[cfg(unix)]
let (executable, arguments) = (
PathBuf::from("/bin/sh"),
vec![
"-c".to_string(),
"pwd >> launches.txt; sleep 60".to_string(),
],
);
let mut options = deepseek_harness_options("web");
options.web_port = Some(0);
options.trusted_hosts = vec!["localhost".to_string()];
options.patches = vec!["preserve a value with spaces".to_string()];
let fixture = HarnessRestartFixture {
state: DeepSeekHarnessProcessState::default(),
directory,
};
launch_managed_deepseek_harness(
&fixture.state,
&executable,
&fixture.directory,
&arguments,
"web",
Some(&options),
)
.unwrap();
wait_for_harness_launches(&fixture, 1);
fixture
}
fn wait_for_harness_launches(fixture: &HarnessRestartFixture, expected: usize) {
for _ in 0..100 {
if fs::read_to_string(fixture.directory.join("launches.txt"))
.unwrap_or_default()
.lines()
.count()
>= expected
{
return;
}
thread::sleep(Duration::from_millis(50));
}
panic!("test process did not record launch {expected}");
}
#[test]
fn harness_web_restart_preserves_snapshot_and_serializes_stop_and_spawn() {
let fixture = harness_restart_fixture();
let old_pid = fixture.state.status().unwrap().pid;
let snapshot = fixture
.state
.process
.lock()
.unwrap()
.as_ref()
.unwrap()
.launch
.clone();
let status = restart_managed_deepseek_harness_with(
&fixture.state,
|child| {
assert!(fixture.state.process.try_lock().is_err());
terminate_deepseek_harness_process_tree(child)
},
|executable, directory, arguments| {
assert!(fixture.state.process.try_lock().is_err());
assert_eq!(executable, snapshot.executable);
assert_eq!(directory, snapshot.working_directory);
assert_eq!(arguments, snapshot.arguments);
spawn_managed_deepseek_harness(executable, directory, arguments)
},
)
.unwrap();
assert!(status.running);
assert_ne!(status.pid, old_pid);
wait_for_harness_launches(&fixture, 2);
let launches = fs::read_to_string(fixture.directory.join("launches.txt")).unwrap();
let lines = launches.lines().collect::<Vec<_>>();
assert_eq!(lines.len(), 2);
assert_eq!(lines[0], lines[1]);
let process = fixture.state.process.lock().unwrap();
let options = process.as_ref().unwrap().launch.options.as_ref().unwrap();
assert_eq!(options.web_port, Some(0));
assert_eq!(options.trusted_hosts, vec!["localhost"]);
assert_eq!(options.patches, vec!["preserve a value with spaces"]);
}
#[test]
fn harness_restart_stop_failure_retains_process_and_does_not_spawn() {
let fixture = harness_restart_fixture();
let pid = fixture.state.status().unwrap().pid;
let result = restart_managed_deepseek_harness_with(
&fixture.state,
|_| Err("stop failed".to_string()),
|_, _, _| panic!("must not spawn after stop failure"),
);
assert_eq!(result.unwrap_err(), "stop failed");
assert_eq!(fixture.state.status().unwrap().pid, pid);
}
#[test]
fn harness_restart_spawn_failure_reports_stopped_state() {
let fixture = harness_restart_fixture();
let result = restart_managed_deepseek_harness_with(
&fixture.state,
terminate_deepseek_harness_process_tree,
|_, _, _| Err("spawn failed".to_string()),
);
assert_eq!(result.unwrap_err(), "spawn failed");
assert!(!fixture.state.status().unwrap().running);
}
#[test]
fn harness_restart_rejects_missing_or_non_web_process_without_stopping_it() {
assert!(restart_managed_deepseek_harness(&DeepSeekHarnessProcessState::default()).is_err());
let fixture = harness_restart_fixture();
let pid = fixture.state.status().unwrap().pid;
fixture.state.process.lock().unwrap().as_mut().unwrap().mode = "headless".to_string();
assert!(restart_managed_deepseek_harness(&fixture.state)
.unwrap_err()
.contains("Web"));
assert_eq!(fixture.state.status().unwrap().pid, pid);
}
#[test]
fn relative_launch_directory_is_rejected() {
let error = resolve_launch_directory(Some("relative/project"), Path::new("/fallback"))
@@ -1458,6 +1953,12 @@ mod tests {
*state.process.lock().unwrap() = Some(ManagedDeepSeekHarnessProcess {
child,
mode: "test".to_string(),
launch: DeepSeekHarnessLaunchSnapshot {
executable: PathBuf::new(),
working_directory: PathBuf::new(),
arguments: Vec::new(),
options: None,
},
});
let running = state.status().unwrap();
@@ -1569,13 +2070,13 @@ mod tests {
#[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 = DesktopAppTarget::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 = DesktopAppTarget::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"));
+12 -2
View File
@@ -327,6 +327,15 @@ struct DeepSeekHarnessProcessState {
struct ManagedDeepSeekHarnessProcess {
child: Child,
mode: String,
launch: DeepSeekHarnessLaunchSnapshot,
}
#[derive(Clone)]
struct DeepSeekHarnessLaunchSnapshot {
executable: PathBuf,
working_directory: PathBuf,
arguments: Vec<String>,
options: Option<DeepSeekHarnessLaunchOptions>,
}
#[derive(Clone)]
@@ -1291,8 +1300,7 @@ enum AgentClient {
}
#[derive(Clone, Debug)]
#[cfg_attr(not(any(target_os = "macos", target_os = "windows")), allow(dead_code))] // The desktop-app variants are constructed only on macOS/Windows builds.
enum CodexAppTarget {
enum DesktopAppTarget {
Application(PathBuf),
#[cfg(target_os = "windows")]
WindowsAppId(String),
@@ -2532,6 +2540,8 @@ fn main() {
launch_agent,
get_deepseek_harness_process_status,
stop_deepseek_harness_process,
restart_deepseek_harness_process,
restart_agent_app,
restart_codex_app,
restart_opencode_app,
get_lan_ipv4,
+5 -5
View File
@@ -66,10 +66,10 @@ fn windows_chatgpt_discovery_parser_accepts_registered_app_and_executable() {
let app = parse_windows_codex_app_discovery_output("APPID:OpenAI.Codex_2p2nqsd0c76g0!App\r\n")
.unwrap();
match app {
CodexAppTarget::WindowsAppId(app_id) => {
DesktopAppTarget::WindowsAppId(app_id) => {
assert_eq!(app_id, "OpenAI.Codex_2p2nqsd0c76g0!App");
}
CodexAppTarget::Application(_) => panic!("expected Store application ID"),
DesktopAppTarget::Application(_) => panic!("expected Store application ID"),
}
let executable = parse_windows_codex_app_discovery_output(
@@ -77,13 +77,13 @@ fn windows_chatgpt_discovery_parser_accepts_registered_app_and_executable() {
)
.unwrap();
match executable {
CodexAppTarget::Application(path) => {
DesktopAppTarget::Application(path) => {
assert_eq!(
path,
PathBuf::from(r"C:\Program Files\OpenAI\ChatGPT\ChatGPT.exe")
);
}
CodexAppTarget::WindowsAppId(_) => panic!("expected desktop executable"),
DesktopAppTarget::WindowsAppId(_) => panic!("expected desktop executable"),
}
assert!(parse_windows_codex_app_discovery_output("MSEdgePWA:ChatGPT\r\n").is_none());
@@ -279,7 +279,7 @@ function Get-AppxPackageManifest($Package) {{
parse_windows_codex_app_discovery_output(&String::from_utf8_lossy(&output.stdout));
if expected {
assert!(
matches!(target, Some(CodexAppTarget::Application(path)) if path == executable)
matches!(target, Some(DesktopAppTarget::Application(path)) if path == executable)
);
} else {
assert!(target.is_none());
+18 -2
View File
@@ -843,7 +843,23 @@ export const jaOverrides = {
'agents.embedded.subtitle': 'ない場合は先にインストールしてください',
'agents.redetect': '再検出',
'agents.tabs.label': 'エージェント設定ページ',
'agents.tabs.core': 'コア設定',
"agents.tabs.management": "設定管理",
"agents.run.title": "起動操作",
"agents.management.backups": "バックアップと復元",
"agents.management.backupsDescription": "ディスク上の設定を保存するか、手動バックアップから復元します。未適用の変更はバックアップに含まれません。",
"agents.management.models": "モデル一覧",
"agents.management.modelsDescription": "Codex に表示するモデルとその属性を管理します。",
"agents.management.reset": "設定のリセット",
"agents.management.resetDescription": "テンプレートはカスタム設定を上書きし、クリア操作は設定ファイルを削除します。実行前に手動バックアップを作成できます。",
"agents.management.plugin": "プラグイン管理",
"agents.management.pluginVersion": "インストール済みのバージョン:{version}",
"agents.management.pluginInstalled": "Pi プラグインをインストールしました。",
"agents.management.pluginUpdated": "Pi プラグインを更新しました。",
"agents.management.pluginUninstalled": "Pi プラグインをアンインストールしました。",
"agents.management.pluginMissing": "Pi プラグインが未インストールです。基本設定でモデルを選択してインストールしてください。",
"agents.deepseekLaunch.restart": "Web を再起動",
"agents.deepseekLaunch.restarting": "Web を再起動中",
'agents.tabs.core': "基本設定",
'agents.tabs.sessions': 'セッション管理',
'agents.sessions.title': 'Codex セッション管理',
'agents.sessions.description': 'ローカルの Codex セッションをスキャンし、過去のセッションの復元、バックアップ、削除を安全に行います。',
@@ -937,7 +953,7 @@ export const jaOverrides = {
"agents.modify.authApiKey": "API キー",
"agents.modify.authOAuth": "OAuth 設定方式",
"agents.modify.matches": "保存済み",
"agents.modify.pending": "未保存",
"agents.modify.pending": "適用待ち",
"agents.modify.checking": "設定を読み込み中…",
"agents.modify.invalidState": "現在の設定を解析できません",
"agents.modify.more": "その他の操作",
+18 -2
View File
@@ -843,7 +843,23 @@ export const en: Record<MessageKey, string> = {
'agents.embedded.subtitle': 'Install it first if it is not available',
'agents.redetect': 'Detect Again',
'agents.tabs.label': 'Agent configuration sections',
'agents.tabs.core': 'Core Configuration',
"agents.tabs.management": "Configuration management",
"agents.run.title": "Run controls",
"agents.management.backups": "Backup and restore",
"agents.management.backupsDescription": "Save the configuration on disk, or restore a manual backup. Pending edits are not included in backups.",
"agents.management.models": "Model list",
"agents.management.modelsDescription": "Manage the models shown in Codex and their properties.",
"agents.management.reset": "Reset configuration",
"agents.management.resetDescription": "Templates replace custom configuration; clearing deletes configuration files. You can create a manual backup first.",
"agents.management.plugin": "Plugin management",
"agents.management.pluginVersion": "Installed plugin version: {version}",
"agents.management.pluginInstalled": "The Pi plugin is installed.",
"agents.management.pluginUpdated": "The Pi plugin is updated.",
"agents.management.pluginUninstalled": "The Pi plugin is uninstalled.",
"agents.management.pluginMissing": "The Pi plugin is not installed. Select a model and install it in Basic configuration.",
"agents.deepseekLaunch.restart": "Restart Web",
"agents.deepseekLaunch.restarting": "Restarting Web",
'agents.tabs.core': "Basic configuration",
'agents.tabs.sessions': 'Session Management',
'agents.sessions.title': 'Codex Session Management',
'agents.sessions.description': 'Scan local Codex sessions and safely restore historical sessions, back them up, or delete them.',
@@ -955,7 +971,7 @@ export const en: Record<MessageKey, string> = {
"agents.modify.authApiKey": "API key",
"agents.modify.authOAuth": "OAuth configuration mode",
"agents.modify.matches": "Saved",
"agents.modify.pending": "Unsaved changes",
"agents.modify.pending": "Changes to apply",
"agents.modify.checking": "Reading configuration…",
"agents.modify.invalidState": "Cannot parse current configuration",
"agents.modify.more": "More actions",
+18 -2
View File
@@ -842,7 +842,23 @@ export const zhCN = {
'agents.embedded.subtitle': '如果没有客户端请自行安装',
'agents.redetect': '重新检测',
'agents.tabs.label': '智能体配置子页面',
'agents.tabs.core': '核心配置',
"agents.tabs.management": "配置管理",
"agents.run.title": "运行控制",
"agents.management.backups": "备份与恢复",
"agents.management.backupsDescription": "保存当前磁盘配置,或从手动备份恢复。备份不包含待应用的修改。",
"agents.management.models": "模型列表",
"agents.management.modelsDescription": "管理 Codex 中显示的模型及其属性。",
"agents.management.reset": "重置配置",
"agents.management.resetDescription": "模板会覆盖自定义配置;清空操作会删除配置文件。执行前可先手动备份。",
"agents.management.plugin": "插件管理",
"agents.management.pluginVersion": "当前插件版本:{version}",
"agents.management.pluginInstalled": "Pi 插件已安装。",
"agents.management.pluginUpdated": "Pi 插件已更新。",
"agents.management.pluginUninstalled": "Pi 插件已卸载。",
"agents.management.pluginMissing": "尚未安装 Pi 插件,请在基础配置中选择模型并安装。",
"agents.deepseekLaunch.restart": "重启 Web",
"agents.deepseekLaunch.restarting": "正在重启 Web",
'agents.tabs.core': "基础配置",
'agents.tabs.sessions': '会话管理',
'agents.sessions.title': 'Codex 会话管理',
'agents.sessions.description': '扫描本地 Codex 会话,并安全地恢复历史会话、备份或删除。',
@@ -954,7 +970,7 @@ export const zhCN = {
"agents.modify.authApiKey": "API 密钥",
"agents.modify.authOAuth": "OAuth配置方式",
"agents.modify.matches": "已保存",
"agents.modify.pending": "未保存",
"agents.modify.pending": "待应用",
"agents.modify.checking": "正在读取配置…",
"agents.modify.invalidState": "当前配置无法解析",
"agents.modify.more": "更多操作",
+124
View File
@@ -0,0 +1,124 @@
import { AppWindow, LoaderCircle, Play, RefreshCw, Square, Terminal, Trash2 } from 'lucide-react';
import { useI18n } from '../i18n';
type LaunchTarget = { id: 'app' | 'cli'; label: string; detail: string };
export function AgentRunControls({
name, dualTargets, desktop, targets, enabled, busyAction, harness, onLaunch, onRestart, onStop, onRestartWeb, error,
}: {
name: string;
dualTargets: boolean;
desktop: boolean;
targets: LaunchTarget[];
enabled: boolean;
busyAction: string | null;
harness: { running: boolean; pid: number | null; mode: string | null } | null;
onLaunch: (target: LaunchTarget | null) => void;
onRestart: () => void;
onStop: () => void;
onRestartWeb: () => void;
error: string;
}) {
const { t } = useI18n();
const busy = busyAction !== null;
const cli = targets.find((target) => target.id === 'cli') ?? null;
const app = targets.find((target) => target.id === 'app') ?? null;
const launchButton = (target: LaunchTarget | null, kind?: 'cli' | 'app') => {
const action = dualTargets ? `launch-${kind}` : 'launch';
const starting = busyAction === action;
return <button type="button" className="secondary-button agent-launch-button"
disabled={busy || !enabled || !target} onClick={() => onLaunch(target)}
title={target?.detail ?? t('agents.launch.unavailable')}>
{starting ? <LoaderCircle size={16} className="spin" />
: target?.id === 'cli' ? <Terminal size={16} />
: target?.id === 'app' ? <AppWindow size={16} /> : <Play size={16} />}
{starting ? t('agents.launch.starting') : kind ? t(kind === 'cli' ? 'agents.launch.startCli' : 'agents.launch.startApp')
: t('agents.launch.start', { target: target?.label ?? name })}
</button>;
};
return <section className="agent-run-controls" aria-label={t('agents.run.title')}>
<div className="agent-section-heading"><div><strong>{t('agents.run.title')}</strong></div></div>
<div className="agent-launch-actions">
{dualTargets ? <>{launchButton(cli, 'cli')}{launchButton(app, 'app')}</>
: harness?.running ? <button type="button" className="danger-button agent-launch-button"
disabled={busy} onClick={onStop} title={t('agents.deepseekLaunch.runningDetail', { pid: harness.pid ?? '—', mode: harness.mode ?? '—' })}>
{busyAction === 'stop-deepseek' ? <LoaderCircle size={16} className="spin" /> : <Square size={16} />}
{t(busyAction === 'stop-deepseek' ? 'agents.deepseekLaunch.stopping' : 'agents.deepseekLaunch.stop')}
</button> : launchButton(targets[0] ?? null)}
{desktop ? <button type="button" className="secondary-button agent-launch-button"
onClick={onRestart} disabled={busy || !enabled || !app} title={app?.detail ?? t('agents.launch.unavailable')}>
{busyAction === 'restart-app' ? <LoaderCircle size={16} className="spin" /> : <RefreshCw size={16} />}
{t(busyAction === 'restart-app' ? 'agents.launch.restartingApp' : 'agents.launch.restartApp')}
</button> : null}
{harness?.running && harness.mode === 'web' ? <button type="button" className="secondary-button agent-launch-button"
onClick={onRestartWeb} disabled={busy}>
{busyAction === 'restart-deepseek' ? <LoaderCircle size={16} className="spin" /> : <RefreshCw size={16} />}
{t(busyAction === 'restart-deepseek' ? 'agents.deepseekLaunch.restarting' : 'agents.deepseekLaunch.restart')}
</button> : null}
</div>
{error ? <span className="agent-inline-message error" role="alert">{error}</span> : null}
</section>;
}
export function AgentConfigurationFeedback({ pending, notice, error, description }: {
pending: boolean; notice: string; error: string; description: string;
}) {
const { t } = useI18n();
if (!pending && !notice && !error && !description) return null;
return <div className="agent-save-feedback agent-shared-feedback" aria-live="polite">
{pending ? <span className="agent-write-state">{t('agents.modify.pending')}</span> : null}
{error ? <span className="agent-inline-message error" role="alert">{error}</span> : null}
{notice ? <span className="agent-inline-message">{notice}</span> : null}
{description ? <small>{description}</small> : null}
</div>;
}
export function AgentConfigManagementPanel({
pi, codex, busyAction, canTemplate, canUpdatePi, canUninstallPi, pluginInstalled, pluginVersion, updateLabel,
onBackup, onRestore, onTemplate, onClear, onCatalog, onUpdatePi, onUninstallPi,
}: {
pi: boolean; codex: boolean; busyAction: string | null; canTemplate: boolean;
canUpdatePi: boolean; canUninstallPi: boolean; pluginInstalled: boolean; pluginVersion: string | null; updateLabel: string;
onBackup: () => void; onRestore: () => void; onTemplate: () => void; onClear: () => void;
onCatalog: () => void; onUpdatePi: () => void; onUninstallPi: () => void;
}) {
const { t } = useI18n();
const busy = busyAction !== null;
return <div className="agent-management-sections">
{!pi ? <section className="agent-management-row">
<div><h3>{t('agents.management.backups')}</h3><p>{t('agents.management.backupsDescription')}</p></div>
<div className="agent-management-actions">
<button type="button" className="secondary-button" onClick={onBackup} disabled={busy}>
{busyAction === 'backup' ? <LoaderCircle size={16} className="spin" /> : null}{t('agents.backup.create')}
</button>
<button type="button" className="secondary-button" onClick={onRestore} disabled={busy}>{t('agents.backup.button')}</button>
</div>
</section> : null}
{codex ? <section className="agent-management-row">
<div><h3>{t('agents.management.models')}</h3><p>{t('agents.management.modelsDescription')}</p></div>
<div className="agent-management-actions"><button type="button" className="secondary-button" onClick={onCatalog} disabled={busy}>{t('agents.catalog.button')}</button></div>
</section> : null}
{!pi ? <section className="agent-management-row agent-management-reset">
<div><h3>{t('agents.management.reset')}</h3><p>{t('agents.management.resetDescription')}</p></div>
<div className="agent-management-actions">
<button type="button" className="secondary-button" onClick={onTemplate} disabled={busy || !canTemplate}>{t('agents.modify.default')}</button>
{codex ? <button type="button" className="danger-button" onClick={onClear} disabled={busy}><Trash2 size={16} />{t('agents.modify.clear')}</button> : null}
</div>
</section> : null}
{pi ? <section className="agent-management-row">
<div><h3>{t('agents.management.plugin')}</h3><p>{pluginInstalled
? t('agents.management.pluginVersion', { version: pluginVersion ?? t('agents.notFetched') })
: t('agents.management.pluginMissing')}</p>{updateLabel ? <small className="agent-inline-message">{updateLabel}</small> : null}</div>
{pluginInstalled ? <div className="agent-management-actions">
<button type="button" className="secondary-button" onClick={onUpdatePi} disabled={busy || !canUpdatePi}>
{busyAction === 'update-pi' ? <LoaderCircle size={16} className="spin" /> : <RefreshCw size={16} />}
{t(busyAction === 'update-pi' ? 'agents.pi.updating' : 'agents.pi.update')}
</button>
<button type="button" className="danger-button" onClick={onUninstallPi} disabled={busy || !canUninstallPi}>
{busyAction === 'uninstall-pi' ? <LoaderCircle size={16} className="spin" /> : <Trash2 size={16} />}
{t(busyAction === 'uninstall-pi' ? 'agents.pi.uninstalling' : 'agents.pi.uninstall')}
</button>
</div> : null}
</section> : null}
</div>;
}
+190 -359
View File
@@ -13,20 +13,14 @@ 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,
SlidersHorizontal,
Square,
Trash2,
Terminal,
Wrench,
X,
} from 'lucide-react';
import claudeIcon from '../assets/icons/claude.svg';
@@ -69,6 +63,7 @@ import { getCurrentLocale, translate, useI18n } from '../i18n';
import { CodexSessionsPanel } from './CodexSessionsPanel';
import { CodexModelCatalogDialog } from './CodexModelCatalogDialog';
import { AgentConfigBackupDialog } from './AgentConfigBackupDialog';
import { AgentConfigManagementPanel, AgentConfigurationFeedback, AgentRunControls } from './AgentControls';
type AgentClientId =
| 'claude-code'
@@ -154,6 +149,20 @@ type ClaudeModelMappings = {
disableAutoCompact: boolean;
};
type AgentFormValues = {
model: string;
oauthConfiguration: boolean;
mappings: ClaudeModelMappings | null;
};
const sameAgentFormValues = (left: AgentFormValues, right: AgentFormValues) => (
sameAgentModel(left.model, right.model)
&& left.oauthConfiguration === right.oauthConfiguration
&& (left.mappings && right.mappings
? sameAgentModelMappings(left.mappings, right.mappings)
: left.mappings === right.mappings)
);
const CODEX_OAUTH_LOGIN_REQUIRED_ERROR = 'CODEX_OAUTH_LOGIN_REQUIRED';
const DEFAULT_CLAUDE_CODE_MAX_CONTEXT_TOKENS = 200_000;
const DEFAULT_CLAUDE_AUTO_COMPACT_PCT = 90;
@@ -187,6 +196,7 @@ let claudeModelMappingsDraftCache = createClaudeModelMappingsByClient();
let claudeCustomMappingCache = createClaudeBooleanByClient();
const claudeModelMappingsDirtyCache = createClaudeBooleanByClient();
let codexOauthConfigurationDraftCache: boolean | null = null;
let agentFormEditBaselineCache: Partial<Record<AgentClientId, AgentFormValues>> = {};
const claudeMappingRoles = [
{
@@ -214,11 +224,11 @@ type AgentDefinition = {
descriptionKey: 'agents.description.claudeCode' | 'agents.description.claudeDesktop' | 'agents.description.codex' | 'agents.description.opencode' | 'agents.description.openclaw' | 'agents.description.hermes' | 'agents.description.deepseekHarness' | 'agents.description.zcode' | 'agents.description.kimiCode' | 'agents.description.grokBuild' | 'agents.description.pi';
};
type AgentSubpageId = 'core' | 'sessions';
type AgentSubpageId = 'core' | 'management' | 'sessions';
type AgentSubpageDefinition = {
id: AgentSubpageId;
labelKey: 'agents.tabs.core' | 'agents.tabs.sessions';
labelKey: 'agents.tabs.core' | 'agents.tabs.management' | 'agents.tabs.sessions';
clients?: readonly AgentClientId[];
};
@@ -296,6 +306,10 @@ const agentSubpages: AgentSubpageDefinition[] = [
id: 'core',
labelKey: 'agents.tabs.core',
},
{
id: 'management',
labelKey: 'agents.tabs.management',
},
{
id: 'sessions',
labelKey: 'agents.tabs.sessions',
@@ -661,6 +675,9 @@ export function AgentsPage({ embedded = false, onConfigurationApplied }: AgentsP
const [modelByClient, setModelByClient] = useState<Partial<Record<AgentClientId, string>>>(
readAgentModelSelections,
);
const [formEditBaselineByClient, setFormEditBaselineByClient] = useState(
() => ({ ...agentFormEditBaselineCache }),
);
const [claudeModelMappingsDraftByClient, setClaudeModelMappingsDraftByClientState] = useState(
() => ({
'claude-code': { ...claudeModelMappingsDraftCache['claude-code'] },
@@ -673,7 +690,7 @@ export function AgentsPage({ embedded = false, onConfigurationApplied }: AgentsP
const [loading, setLoading] = useState(true);
const [modelLoading, setModelLoading] = useState(false);
const [busyAction, setBusyAction] = useState<
'backup' | 'apply' | 'default' | 'clear' | 'install-pi' | 'update-pi' | 'repair-pi' | 'uninstall-pi' | 'oauth-check' | 'directory' | 'launch' | 'launch-cli' | 'launch-app' | 'restart-app' | 'stop-deepseek' | null
'backup' | 'apply' | 'default' | 'clear' | 'install-pi' | 'update-pi' | 'repair-pi' | 'uninstall-pi' | 'oauth-check' | 'directory' | 'launch' | 'launch-cli' | 'launch-app' | 'restart-app' | 'stop-deepseek' | 'restart-deepseek' | null
>(null);
const busy = busyAction !== null;
const [detectionError, setDetectionError] = useState('');
@@ -979,14 +996,35 @@ export function AgentsPage({ embedded = false, onConfigurationApplied }: AgentsP
: connectionState === 'needs-update' ? 'agents.modify.repair' : 'agents.modify.update');
const configurationWriteBlocked = loading || !activeStatus || connectionState === 'invalid';
const appliedModel = activeStatus?.currentModel ?? activeStatus?.appliedModel ?? '';
const appliedMappings = selected === 'claude-code'
? activeStatus?.claudeCodeModelMappings
: activeStatus?.claudeDesktopModelMappings;
const formMatchesConfiguration = Boolean(activeStatus?.configValid && activeStatus?.configured)
&& (isClaudeModelMappingClient
? Boolean(appliedMappings && sameAgentModelMappings(claudeModelMappingsDraft, appliedMappings))
: sameAgentModel(selectedModel, appliedModel))
&& (selected !== 'codex' || oauthConfiguration === Boolean(activeStatus?.oauthConfiguration));
const formValues: AgentFormValues = {
model: isClaudeModelMappingClient ? '' : selectedModel,
oauthConfiguration: selected === 'codex' && oauthConfiguration,
mappings: isClaudeModelMappingClient ? claudeModelMappingsDraft : null,
};
const hasPendingChanges = Boolean(formEditBaselineByClient[selected]);
// Only user edits establish a baseline. Detection and default selections are not edits.
const trackFormEdit = (nextValues: AgentFormValues) => {
setConfigurationNotice('');
setClearNotice('');
setFormEditBaselineByClient((current) => {
const baseline = current[selected] ?? formValues;
const next = { ...current };
if (sameAgentFormValues(baseline, nextValues)) delete next[selected];
else next[selected] = baseline;
agentFormEditBaselineCache = next;
return next;
});
};
const clearPendingChanges = () => {
setFormEditBaselineByClient((current) => {
const next = { ...current };
delete next[selected];
agentFormEditBaselineCache = next;
return next;
});
};
const claudeMappingsReady = !isClaudeModelMappingClient
|| claudeMappingRoles.every((role) =>
Boolean(findAgentModel(models, claudeModelMappingsDraft[role.key])),
@@ -1006,14 +1044,10 @@ export function AgentsPage({ embedded = false, onConfigurationApplied }: AgentsP
: 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,
);
const canLaunchTarget = (target: AgentLaunchTarget | null) => launchEnabled && Boolean(target);
const activeLaunchDirectoryHistory = launchDirectoryHistory[selected] ?? [];
const deepSeekHarnessLaunchModeLabel = {
web: t('agents.deepseekLaunch.mode.web'),
@@ -1049,7 +1083,7 @@ export function AgentsPage({ embedded = false, onConfigurationApplied }: AgentsP
void loadModels(selected);
};
const runEmbeddedPrimaryAction = () => {
const applySelectedConfiguration = () => {
if (isPiClient) {
void (activeStatus?.pluginInstalled ? repairPiProvider() : installPiProvider());
return;
@@ -1071,6 +1105,7 @@ export function AgentsPage({ embedded = false, onConfigurationApplied }: AgentsP
setClearNotice('');
const model = findAgentModel(models, value);
if (!model) return;
if (!isClaudeModelMappingClient) trackFormEdit({ ...formValues, model: model.name });
setModelSelectionError('');
setModelByClient((current) => {
const next = { ...current, [selected]: model.name };
@@ -1079,20 +1114,25 @@ export function AgentsPage({ embedded = false, onConfigurationApplied }: AgentsP
});
};
const editClaudeModelMappings = (update: (current: ClaudeModelMappings) => ClaudeModelMappings) => {
if (!isClaudeModelMappingClient) return;
const next = update(claudeModelMappingsDraft);
trackFormEdit({ ...formValues, mappings: next });
if (!sameAgentModelMappings(claudeModelMappingsDraft, next)) {
claudeModelMappingsDirtyRef.current[selected] = true;
}
setClaudeModelMappingsDraftByClient((current) => ({ ...current, [selected]: next }));
};
const selectEmbeddedModel = (value: string) => {
const model = findAgentModel(models, value);
if (!model) return;
if (isClaudeModelMappingClient) {
setConfigurationNotice('');
claudeModelMappingsDirtyRef.current[selected] = true;
setClaudeModelMappingsDraftByClient((current) => ({
editClaudeModelMappings((current) => ({
...current,
[selected]: {
...current[selected],
opus: model.name,
sonnet: model.name,
haiku: model.name,
},
opus: model.name,
sonnet: model.name,
haiku: model.name,
}));
}
selectModel(model.name);
@@ -1104,13 +1144,8 @@ export function AgentsPage({ embedded = false, onConfigurationApplied }: AgentsP
) => {
const model = findAgentModel(models, value);
if (!model || !isClaudeModelMappingClient) return;
setConfigurationNotice('');
claudeModelMappingsDirtyRef.current[selected] = true;
setModelSelectionError('');
setClaudeModelMappingsDraftByClient((current) => ({
...current,
[selected]: { ...current[selected], [role]: model.name },
}));
editClaudeModelMappings((current) => ({ ...current, [role]: model.name }));
};
const changeClaude1mPreference = (
@@ -1118,17 +1153,15 @@ export function AgentsPage({ embedded = false, onConfigurationApplied }: AgentsP
enabled: boolean,
) => {
if (!isClaudeModelMappingClient) return;
setConfigurationNotice('');
claudeModelMappingsDirtyRef.current[selected] = true;
setClaudeModelMappingsDraftByClient((current) => {
const next = { ...current[selected], [preference]: enabled };
editClaudeModelMappings((current) => {
const next = { ...current, [preference]: enabled };
if (selected === 'claude-code') {
const any1mEnabled = next.opus1m || next.sonnet1m || next.haiku1m;
next.maxContextTokens = any1mEnabled
? 1_000_000
: DEFAULT_CLAUDE_CODE_MAX_CONTEXT_TOKENS;
}
return { ...current, [selected]: next };
return next;
});
};
@@ -1137,50 +1170,24 @@ export function AgentsPage({ embedded = false, onConfigurationApplied }: AgentsP
value: number,
) => {
if (selected !== 'claude-code') return;
setConfigurationNotice('');
claudeModelMappingsDirtyRef.current[selected] = true;
setClaudeModelMappingsDraftByClient((current) => ({
...current,
[selected]: { ...current[selected], [key]: value },
}));
editClaudeModelMappings((current) => ({ ...current, [key]: value }));
};
const changeClaudeCodeAutoCompactDisabled = (disabled: boolean) => {
if (selected !== 'claude-code') return;
setConfigurationNotice('');
claudeModelMappingsDirtyRef.current[selected] = true;
setClaudeModelMappingsDraftByClient((current) => ({
...current,
[selected]: {
...current[selected],
disableAutoCompact: disabled,
},
}));
editClaudeModelMappings((current) => ({ ...current, disableAutoCompact: disabled }));
};
const changeClaudeCustomMapping = (enabled: boolean) => {
if (!isClaudeModelMappingClient) return;
setClaudeCustomMappingByClient((current) => ({ ...current, [selected]: enabled }));
setModelSelectionError('');
setClaudeModelMappingsDraftByClient((current) => {
const currentClientDraft = current[selected];
const next: ClaudeModelMappings = {
opus: resolveAgentModelForAliasMode(models, currentClientDraft.opus, enabled),
sonnet: resolveAgentModelForAliasMode(models, currentClientDraft.sonnet, enabled),
haiku: resolveAgentModelForAliasMode(models, currentClientDraft.haiku, enabled),
opus1m: currentClientDraft.opus1m,
sonnet1m: currentClientDraft.sonnet1m,
haiku1m: currentClientDraft.haiku1m,
maxContextTokens: currentClientDraft.maxContextTokens,
autoCompactPct: currentClientDraft.autoCompactPct,
disableAutoCompact: currentClientDraft.disableAutoCompact,
};
if (!sameAgentModelMappings(currentClientDraft, next)) {
setConfigurationNotice('');
claudeModelMappingsDirtyRef.current[selected] = true;
}
return { ...current, [selected]: next };
});
editClaudeModelMappings((current) => ({
...current,
opus: resolveAgentModelForAliasMode(models, current.opus, enabled),
sonnet: resolveAgentModelForAliasMode(models, current.sonnet, enabled),
haiku: resolveAgentModelForAliasMode(models, current.haiku, enabled),
}));
};
const requireSelectedModel = () => {
@@ -1238,6 +1245,7 @@ export function AgentsPage({ embedded = false, onConfigurationApplied }: AgentsP
setConfigurationNotice('');
setClearNotice('');
if (!enabled) {
trackFormEdit({ ...formValues, oauthConfiguration: false });
setOauthConfigurationDraft(false);
return;
}
@@ -1245,6 +1253,7 @@ export function AgentsPage({ embedded = false, onConfigurationApplied }: AgentsP
setBusyAction('oauth-check');
try {
await invoke('check_codex_oauth_login');
trackFormEdit({ ...formValues, oauthConfiguration: true });
setOauthConfigurationDraft(true);
} catch (requestError) {
if (!handleOAuthLoginError(requestError, 'enable')) {
@@ -1273,6 +1282,7 @@ export function AgentsPage({ embedded = false, onConfigurationApplied }: AgentsP
claudeCodeModelMappings: selected === 'claude-code' ? claudeModelMappings : null,
claudeDesktopModelMappings: selected === 'claude-desktop' ? claudeModelMappings : null,
});
clearPendingChanges();
if (isClaudeModelMappingClient) {
claudeModelMappingsDirtyRef.current[selected] = false;
}
@@ -1294,9 +1304,13 @@ export function AgentsPage({ embedded = false, onConfigurationApplied }: AgentsP
if (!model) return;
setBusyAction('install-pi');
setConfigurationError('');
setConfigurationNotice('');
setClearNotice('');
try {
await invoke<AgentConfigActionResult>('install_pi_provider', { model });
clearPendingChanges();
await reloadStatusesAfterAction();
setConfigurationNotice(t('agents.management.pluginInstalled'));
onConfigurationApplied?.();
} catch (requestError) {
setConfigurationError(String(requestError));
@@ -1310,10 +1324,14 @@ export function AgentsPage({ embedded = false, onConfigurationApplied }: AgentsP
if (!model) return;
setBusyAction('update-pi');
setConfigurationError('');
setConfigurationNotice('');
setClearNotice('');
setPiProviderUpdateStatus(null);
try {
await invoke<AgentConfigActionResult>('update_pi_provider', { model });
clearPendingChanges();
await reloadStatusesAfterAction();
setConfigurationNotice(t('agents.management.pluginUpdated'));
await loadPiProviderUpdateStatus();
} catch (requestError) {
setConfigurationError(String(requestError));
@@ -1329,6 +1347,7 @@ export function AgentsPage({ embedded = false, onConfigurationApplied }: AgentsP
setConfigurationError('');
try {
const result = await invoke<AgentConfigActionResult>('repair_pi_provider', { model });
clearPendingChanges();
await reloadStatusesAfterAction();
setConfigurationNotice(t(result.outcome === 'unchanged' ? 'agents.backup.unchanged' : 'agents.backup.updated'));
onConfigurationApplied?.();
@@ -1342,10 +1361,14 @@ export function AgentsPage({ embedded = false, onConfigurationApplied }: AgentsP
const uninstallPiProvider = async () => {
setBusyAction('uninstall-pi');
setConfigurationError('');
setConfigurationNotice('');
setClearNotice('');
setPiProviderUpdateStatus(null);
try {
await invoke<AgentConfigActionResult>('uninstall_pi_provider');
clearPendingChanges();
await reloadStatusesAfterAction();
setConfigurationNotice(t('agents.management.pluginUninstalled'));
} catch (requestError) {
setConfigurationError(String(requestError));
} finally {
@@ -1372,6 +1395,7 @@ export function AgentsPage({ embedded = false, onConfigurationApplied }: AgentsP
claudeCodeModelMappings: selected === 'claude-code' ? claudeModelMappings : null,
claudeDesktopModelMappings: selected === 'claude-desktop' ? claudeModelMappings : null,
});
clearPendingChanges();
setDefaultConfirmOpen(false);
if (isClaudeModelMappingClient) {
claudeModelMappingsDirtyRef.current[selected] = false;
@@ -1399,6 +1423,7 @@ export function AgentsPage({ embedded = false, onConfigurationApplied }: AgentsP
setClearNotice('');
try {
await invoke<string[]>('clear_codex_config');
clearPendingChanges();
setClearConfirmOpen(false);
setClearNotice(t('agents.clear.success'));
await reloadStatusesAfterAction();
@@ -1507,11 +1532,11 @@ export function AgentsPage({ embedded = false, onConfigurationApplied }: AgentsP
};
const restartDesktopApp = async () => {
if (!hasIndependentCliAndApp) return;
if (!['codex', 'opencode', 'claude-desktop', 'zcode'].includes(selected)) return;
setBusyAction('restart-app');
setLaunchError('');
try {
await invoke(selected === 'codex' ? 'restart_codex_app' : 'restart_opencode_app');
await invoke('restart_agent_app', { client: selected });
} catch (requestError) {
if (!handleOAuthLoginError(requestError, 'launch')) {
setLaunchError(String(requestError));
@@ -1521,6 +1546,19 @@ export function AgentsPage({ embedded = false, onConfigurationApplied }: AgentsP
}
};
const restartDeepSeekHarness = async () => {
setBusyAction('restart-deepseek');
setLaunchError('');
try {
setDeepSeekHarnessProcessStatus(await invoke<DeepSeekHarnessProcessStatus>('restart_deepseek_harness_process'));
} catch (requestError) {
setLaunchError(String(requestError));
await loadDeepSeekHarnessProcessStatus().catch(() => undefined);
} finally {
setBusyAction(null);
}
};
const launchFromDirectoryDialog = async () => {
if (!launchDirectoryTarget) return;
const workingDirectory = launchDirectory.trim();
@@ -1589,7 +1627,7 @@ export function AgentsPage({ embedded = false, onConfigurationApplied }: AgentsP
};
const availableSubpages = agentSubpages.filter(
(subpage) => !subpage.clients || subpage.clients.includes(selected),
(subpage) => (!subpage.clients || subpage.clients.includes(selected)) && (!embedded || subpage.id !== 'sessions'),
);
const oauthLoginRequiredDescription = oauthLoginRequiredAction === 'enable' ? (
<>
@@ -1649,6 +1687,9 @@ export function AgentsPage({ embedded = false, onConfigurationApplied }: AgentsP
>
<span className="agent-client-icon"><AgentMark definition={agent} /></span>
<span><strong>{agent.name}</strong><small>{listStatusText(status)}</small></span>
{status?.installed ? (
<i className="agent-installed-indicator" title={t('agents.clientDetected')} aria-hidden="true" />
) : null}
</button>
);
})}
@@ -1656,8 +1697,36 @@ export function AgentsPage({ embedded = false, onConfigurationApplied }: AgentsP
</aside>
<section className="panel agent-config-panel">
{embedded ? (
<div className="agent-minimal-config">
<div className="agent-subpage-tabs" role="tablist" aria-label={t('agents.tabs.label')}>
{availableSubpages.map((subpage) => (
<button
type="button"
id={`agent-subpage-tab-${subpage.id}`}
role="tab"
className={activeSubpage === subpage.id ? 'active' : ''}
aria-selected={activeSubpage === subpage.id}
aria-controls={`agent-subpage-panel-${subpage.id}`}
tabIndex={activeSubpage === subpage.id ? 0 : -1}
key={subpage.id}
disabled={busy}
onClick={() => setActiveSubpage(subpage.id)}
onKeyDown={(event) => {
const index = availableSubpages.findIndex((item) => item.id === subpage.id);
const next = event.key === 'Home' ? 0 : event.key === 'End' ? availableSubpages.length - 1
: event.key === 'ArrowRight' ? (index + 1) % availableSubpages.length
: event.key === 'ArrowLeft' ? (index - 1 + availableSubpages.length) % availableSubpages.length : -1;
if (next < 0) return;
event.preventDefault();
setActiveSubpage(availableSubpages[next].id);
document.getElementById('agent-subpage-tab-' + availableSubpages[next].id)?.focus();
}}
>
{t(subpage.labelKey)}
</button>
))}
</div>
{embedded && activeSubpage === 'core' ? (
<div className="agent-minimal-config" id="agent-subpage-panel-core" role="tabpanel" aria-labelledby="agent-subpage-tab-core">
<div className="agent-minimal-client-summary">
<span className="agent-minimal-client-icon"><AgentMark definition={activeDefinition} size={24} /></span>
<div>
@@ -1697,97 +1766,27 @@ export function AgentsPage({ embedded = false, onConfigurationApplied }: AgentsP
<button
type="button"
className="primary-button"
onClick={runEmbeddedPrimaryAction}
onClick={applySelectedConfiguration}
disabled={busy || !canEnable || configurationWriteBlocked}
>
{busyAction ? <LoaderCircle size={16} className="spin" /> : null}
{['apply', 'install-pi', 'repair-pi'].includes(busyAction ?? '') ? <LoaderCircle size={16} className="spin" /> : null}
{isPiClient
? activeStatus?.pluginInstalled ? configurationActionLabel : t('agents.pi.install')
: configurationActionLabel}
</button>
<button type="button" className="secondary-button" onClick={() => void createManualBackup()} disabled={busy}>{t('agents.backup.create')}</button>
<button type="button" className="secondary-button" onClick={() => setBackupsOpen(true)} disabled={busy}>{t('agents.backup.button')}</button>
<button type="button" className="secondary-button" onClick={() => void openDefaultConfirmation()} disabled={busy || !canEnable}>{t('agents.modify.default')}</button>
<button
type="button"
className={isDeepSeekHarnessClient && deepSeekHarnessProcessStatus.running
? 'danger-button'
: 'secondary-button'}
onClick={() => void (isDeepSeekHarnessClient && deepSeekHarnessProcessStatus.running
? stopDeepSeekHarness()
: launchAgent(defaultLaunchTarget))}
disabled={busy || (isDeepSeekHarnessClient && deepSeekHarnessProcessStatus.running
? false
: !canLaunchTarget(defaultLaunchTarget))}
title={isDeepSeekHarnessClient && deepSeekHarnessProcessStatus.running
? t('agents.deepseekLaunch.runningDetail', {
pid: deepSeekHarnessProcessStatus.pid ?? '—',
mode: deepSeekHarnessProcessStatus.mode ?? '—',
})
: defaultLaunchTarget?.detail ?? t('agents.launch.unavailable')}
>
{busyAction === 'launch' || busyAction === 'launch-cli' || busyAction === 'stop-deepseek'
? <LoaderCircle size={16} className="spin" />
: isDeepSeekHarnessClient && deepSeekHarnessProcessStatus.running
? <Square size={16} />
: <Play size={16} />}
{busyAction === 'stop-deepseek'
? t('agents.deepseekLaunch.stopping')
: busyAction === 'launch' || busyAction === 'launch-cli'
? t('agents.launch.starting')
: isDeepSeekHarnessClient && deepSeekHarnessProcessStatus.running
? t('agents.deepseekLaunch.stop')
: t('agents.launch.start', { target: defaultLaunchTarget?.label ?? activeDefinition.name })}
</button>
</div>
{configurationError || modelSelectionError || launchError ? (
<div className="agent-minimal-message" aria-live="polite">
{configurationError || modelSelectionError || launchError}
</div>
) : null}
</div>
) : (
<>
<div className="agent-subpage-tabs" role="tablist" aria-label={t('agents.tabs.label')}>
{availableSubpages.map((subpage) => (
<button
type="button"
id={`agent-subpage-tab-${subpage.id}`}
role="tab"
className={activeSubpage === subpage.id ? 'active' : ''}
aria-selected={activeSubpage === subpage.id}
aria-controls={`agent-subpage-panel-${subpage.id}`}
tabIndex={activeSubpage === subpage.id ? 0 : -1}
key={subpage.id}
onClick={() => setActiveSubpage(subpage.id)}
>
{t(subpage.labelKey)}
</button>
))}
</div>
) : null}
{activeSubpage === 'core' ? (
{!embedded && activeSubpage === 'core' ? (
<div
className="agent-core-config"
id="agent-subpage-panel-core"
role="tabpanel"
aria-labelledby="agent-subpage-tab-core"
>
{!isPiClient ? <div className="agent-settings-toolbar">
<div className="agent-settings-title">
<strong>{activeDefinition.name}</strong>
<p className={`agent-connection-state ${activeStatus?.configured ? 'connected' : ''}`} role="status">
{t(loading || !activeStatus
? 'agents.modify.connectionChecking'
: connectionState === 'invalid'
? 'agents.modify.connectionUnknown'
: connectionState === 'needs-update' ? 'agents.modify.connectionRepair'
: connectionState === 'configured' ? 'agents.modify.connected' : 'agents.modify.disconnected')}
</p>
</div>
</div> : null}
<div className={`agent-status-grid ${hasIndependentCliAndApp ? 'dual-install-status-grid' : isPiClient ? 'pi-status-grid' : ''}`}>
<div>
<span><BadgeCheck size={14} />{t('agents.installStatus')}</span>
@@ -1880,20 +1879,12 @@ export function AgentsPage({ embedded = false, onConfigurationApplied }: AgentsP
</div>
<select id="agent-connection-method" aria-label={t('agents.modify.authMethod')} aria-describedby={connectionHelpOpen ? "agent-signin-hint" : undefined} value={oauthConfiguration ? 'oauth' : 'apikey'}
onChange={(event) => void changeOauthConfiguration(event.currentTarget.value === 'oauth')}
disabled={busy}>
disabled={busy || loading || modelLoading}>
<option value="apikey">{t('agents.modify.authApiKey')}</option>
<option value="oauth">{t('agents.modify.authOAuth')}</option>
</select>
</div>
<button
type="button"
className="secondary-button agent-codex-catalog-button"
onClick={() => setCodexCatalogDialogOpen(true)}
disabled={busy}
>
<SlidersHorizontal size={16} />
{t('agents.catalog.button')}
</button>
<div id="agent-signin-hint" className="agent-signin-explanation" hidden={!connectionHelpOpen}>
<dl>
<div><dt>{t('agents.modify.authApiKey')}</dt><dd>{t('agents.modify.authApiKeyHint')}</dd></div>
@@ -1927,7 +1918,7 @@ export function AgentsPage({ embedded = false, onConfigurationApplied }: AgentsP
type="checkbox"
checked={claudeCustomMapping}
onChange={(event) => changeClaudeCustomMapping(event.currentTarget.checked)}
disabled={busy || modelLoading}
disabled={busy || loading || modelLoading}
/>
<span className="switch-track" />
</span>
@@ -1948,7 +1939,7 @@ export function AgentsPage({ embedded = false, onConfigurationApplied }: AgentsP
'maxContextTokens',
Math.trunc(event.currentTarget.valueAsNumber || 0),
)}
disabled={busy || modelLoading}
disabled={busy || loading || modelLoading}
aria-label={t('agents.claudeCodeRuntime.maxContextTokens')}
aria-describedby="claude-code-max-context-hint"
/>
@@ -1969,7 +1960,7 @@ export function AgentsPage({ embedded = false, onConfigurationApplied }: AgentsP
'autoCompactPct',
Math.trunc(event.currentTarget.valueAsNumber || 0),
)}
disabled={busy || modelLoading || claudeModelMappingsDraft.disableAutoCompact}
disabled={busy || loading || modelLoading || claudeModelMappingsDraft.disableAutoCompact}
aria-describedby="claude-code-auto-compact-hint"
/>
<span>%</span>
@@ -1993,7 +1984,7 @@ export function AgentsPage({ embedded = false, onConfigurationApplied }: AgentsP
onChange={(event) => changeClaudeCodeAutoCompactDisabled(
event.currentTarget.checked,
)}
disabled={busy || modelLoading}
disabled={busy || loading || modelLoading}
/>
<span className="switch-track" />
</span>
@@ -2020,7 +2011,7 @@ export function AgentsPage({ embedded = false, onConfigurationApplied }: AgentsP
role.contextKey,
event.currentTarget.checked,
)}
disabled={busy || modelLoading}
disabled={busy || loading || modelLoading}
/>
<span className="switch-track" />
</span>
@@ -2041,201 +2032,20 @@ export function AgentsPage({ embedded = false, onConfigurationApplied }: AgentsP
</section>
) : null}
{isPiClient ? (
<section className="agent-core-setting-section agent-modification-actions">
<div className="agent-section-heading">
<div>
<strong>{t('agents.pi.installTitle')}</strong>
<button type="button" className="secondary-button" onClick={() => void createManualBackup()} disabled={busy}>{t('agents.backup.create')}</button>
<button type="button" className="secondary-button" onClick={() => setBackupsOpen(true)} disabled={busy}>{t('agents.backup.button')}</button>
<button type="button" className="secondary-button" onClick={() => void openDefaultConfirmation()} disabled={busy || !canEnable}>{t('agents.modify.default')}</button>
<span>{t('agents.pi.installDescription')}</span>
</div>
</div>
<div className="agent-modification-control">
<div className="agent-modification-buttons">
<button
type="button"
className={activeStatus?.pluginInstalled ? 'danger-button' : 'primary-button'}
onClick={() => void (activeStatus?.pluginInstalled ? uninstallPiProvider() : installPiProvider())}
disabled={busy || !activeStatus?.installed || !activeStatus.supportedPlatform || (!activeStatus.pluginInstalled && !selectedModelOption)}
>
{busyAction === 'install-pi' || busyAction === 'uninstall-pi'
? <LoaderCircle size={16} className="spin" />
: null}
{activeStatus?.pluginInstalled
? busyAction === 'uninstall-pi' ? t('agents.pi.uninstalling') : t('agents.pi.uninstall')
: busyAction === 'install-pi' ? t('agents.pi.installing') : t('agents.pi.install')}
</button>
<button
type="button"
className="secondary-button"
onClick={() => void repairPiProvider()}
disabled={busy || !activeStatus?.installed || !activeStatus.supportedPlatform || !activeStatus.pluginInstalled || !selectedModelOption || configurationWriteBlocked}
>
{busyAction === 'repair-pi' ? <LoaderCircle size={16} className="spin" /> : <Wrench size={16} />}
{configurationActionLabel}
</button>
<button
type="button"
className="secondary-button"
onClick={() => void updatePiProvider()}
disabled={busy || !activeStatus?.installed || !activeStatus.supportedPlatform || !activeStatus.pluginInstalled || !selectedModelOption || configurationWriteBlocked}
>
{busyAction === 'update-pi' ? <LoaderCircle size={16} className="spin" /> : <RefreshCw size={16} />}
{busyAction === 'update-pi' ? t('agents.pi.updating') : t('agents.pi.update')}
</button>
</div>
{configurationError ? (
<span className="agent-inline-message error" role="alert" aria-live="polite">
{configurationError}
</span>
) : null}
</div>
</section>
) : (
<div className="agent-save-bar">
{selected === 'claude-desktop' && !activeStatus?.claudeDesktopModelMappings ? <p className="agent-inline-message warning">{t('agents.backup.mappingRequired')}</p> : null}
<div className="agent-save-feedback" aria-live="polite">
{configurationError ? <span className="agent-inline-message error" role="alert">{configurationError}</span>
: <span className="agent-write-state">{configurationNotice || clearNotice || (modelLoading || !activeStatus
? t('agents.modify.checking')
: activeStatus.modificationState === 'invalid' ? t('agents.modify.invalidState')
: formMatchesConfiguration ? '' : t('agents.modify.pending'))}</span>}
{modificationDescription ? <small>{modificationDescription}</small> : null}
</div>
<div className="agent-save-actions">
<button type="button" className="secondary-button" onClick={() => void createManualBackup()} disabled={busy}>{t('agents.backup.create')}</button>
<button type="button" className="secondary-button" onClick={() => setBackupsOpen(true)} disabled={busy}>{t('agents.backup.button')}</button>
<button
type="button"
className="secondary-button"
onClick={openDefaultConfirmation}
disabled={busy || !canEnable}
>
{busyAction === 'default'
? <LoaderCircle size={16} className="spin" />
: <RefreshCw size={16} />}
{t('agents.modify.default')}
</button>
{selected === 'codex' ? (
<button
type="button"
className="danger-button"
onClick={openClearConfirmation}
disabled={busy}
>
{busyAction === 'clear' ? <LoaderCircle size={16} className="spin" /> : <Trash2 size={16} />}
{t('agents.modify.clear')}
</button>
) : null}
<button type="button" className="primary-button" onClick={() => void applyConfigurationChanges()} disabled={busy || !canEnable || configurationWriteBlocked}>
{busyAction === 'apply' ? <LoaderCircle size={16} className="spin" /> : null}
{configurationActionLabel}
<button type="button" className="primary-button" onClick={applySelectedConfiguration} disabled={busy || !canEnable || configurationWriteBlocked}>
{['apply', 'install-pi', 'repair-pi'].includes(busyAction ?? '') ? <LoaderCircle size={16} className="spin" /> : null}
{isPiClient && !activeStatus?.pluginInstalled ? t('agents.pi.install') : configurationActionLabel}
</button>
</div>
</div>
)}
<div className="agent-config-footer">
<div className="agent-launch-control">
<div className="agent-launch-actions">
{hasIndependentCliAndApp ? (
<>
<button
type="button"
className="secondary-button agent-launch-button"
onClick={() => void launchAgent(cliLaunchTarget)}
disabled={busy || !canLaunchTarget(cliLaunchTarget)}
title={cliLaunchTarget?.detail ?? t('agents.launch.unavailable')}
>
{busyAction === 'launch-cli'
? <LoaderCircle size={16} className="spin" />
: <Terminal size={16} />}
{busyAction === 'launch-cli'
? t('agents.launch.starting')
: t('agents.launch.startCli')}
</button>
<button
type="button"
className="primary-button agent-launch-button"
onClick={() => void launchAgent(appLaunchTarget)}
disabled={busy || !canLaunchTarget(appLaunchTarget)}
title={appLaunchTarget?.detail ?? t('agents.launch.unavailable')}
>
{busyAction === 'launch-app'
? <LoaderCircle size={16} className="spin" />
: <AppWindow size={16} />}
{busyAction === 'launch-app'
? t('agents.launch.starting')
: t('agents.launch.startApp')}
</button>
<button
type="button"
className="secondary-button agent-launch-button"
onClick={() => void restartDesktopApp()}
disabled={busy || !canLaunchTarget(appLaunchTarget)}
title={appLaunchTarget?.detail ?? t('agents.launch.unavailable')}
>
{busyAction === 'restart-app'
? <LoaderCircle size={16} className="spin" />
: <RefreshCw size={16} />}
{busyAction === 'restart-app'
? t('agents.launch.restartingApp')
: t('agents.launch.restartApp')}
</button>
</>
) : (
<button
type="button"
className={`${isDeepSeekHarnessClient && deepSeekHarnessProcessStatus.running
? 'danger-button'
: 'primary-button'} agent-launch-button`}
onClick={() => void (isDeepSeekHarnessClient && deepSeekHarnessProcessStatus.running
? stopDeepSeekHarness()
: launchAgent(defaultLaunchTarget))}
disabled={busy || (isDeepSeekHarnessClient && deepSeekHarnessProcessStatus.running
? false
: !canLaunchTarget(defaultLaunchTarget))}
title={isDeepSeekHarnessClient && deepSeekHarnessProcessStatus.running
? t('agents.deepseekLaunch.runningDetail', {
pid: deepSeekHarnessProcessStatus.pid ?? '—',
mode: deepSeekHarnessProcessStatus.mode ?? '—',
})
: defaultLaunchTarget?.detail ?? t('agents.launch.unavailable')}
>
{busyAction === 'launch' || busyAction === 'stop-deepseek'
? <LoaderCircle size={16} className="spin" />
: isDeepSeekHarnessClient && deepSeekHarnessProcessStatus.running
? <Square size={16} />
: defaultLaunchTarget?.id === 'cli'
? <Terminal size={16} />
: <Play size={16} />}
{busyAction === 'stop-deepseek'
? t('agents.deepseekLaunch.stopping')
: busyAction === 'launch'
? t('agents.launch.starting')
: isDeepSeekHarnessClient && deepSeekHarnessProcessStatus.running
? t('agents.deepseekLaunch.stop')
: defaultLaunchTarget
? t('agents.launch.start', { target: defaultLaunchTarget.label })
: t('agents.launch.unavailable')}
</button>
)}
</div>
{launchError ? (
<span className="agent-inline-message error" role="alert" aria-live="polite">
{launchError}
</span>
) : null}
</div>
</div>
</div>
) : null}
{selected === 'codex' && activeSubpage === 'sessions' ? (
{!embedded && selected === 'codex' && activeSubpage === 'sessions' ? (
<div
className="agent-sessions-page"
id="agent-subpage-panel-sessions"
@@ -2245,12 +2055,34 @@ export function AgentsPage({ embedded = false, onConfigurationApplied }: AgentsP
<CodexSessionsPanel />
</div>
) : null}
</>
)}
{activeSubpage === 'management' ? (
<div id="agent-subpage-panel-management" role="tabpanel" aria-labelledby="agent-subpage-tab-management">
<AgentConfigManagementPanel pi={isPiClient} codex={selected === 'codex'} busyAction={busyAction}
canTemplate={canEnable} canUpdatePi={canEnable && !configurationWriteBlocked && Boolean(activeStatus?.pluginInstalled)}
canUninstallPi={launchEnabled && Boolean(activeStatus?.pluginInstalled)}
pluginInstalled={Boolean(activeStatus?.pluginInstalled)} pluginVersion={activeStatus?.pluginVersion ?? null}
updateLabel={piPluginUpdateAvailable ? piPluginUpdateTitle ?? '' : ''}
onBackup={() => void createManualBackup()} onRestore={() => setBackupsOpen(true)}
onTemplate={() => void openDefaultConfirmation()} onClear={openClearConfirmation}
onCatalog={() => setCodexCatalogDialogOpen(true)} onUpdatePi={() => void updatePiProvider()}
onUninstallPi={() => void uninstallPiProvider()} />
</div>
) : null}
<AgentConfigurationFeedback pending={hasPendingChanges}
notice={configurationNotice || clearNotice || (modelLoading || !activeStatus ? t('agents.modify.checking')
: activeStatus.modificationState === 'invalid' ? t('agents.modify.invalidState') : '')}
error={configurationError || modelSelectionError} description={modificationDescription} />
{activeSubpage === 'core' ? <AgentRunControls name={activeDefinition.name} dualTargets={hasIndependentCliAndApp}
desktop={hasIndependentCliAndApp || selected === 'claude-desktop' || selected === 'zcode'}
targets={activeLaunchTargets} enabled={launchEnabled} busyAction={busyAction}
harness={isDeepSeekHarnessClient ? deepSeekHarnessProcessStatus : null}
onLaunch={(target) => void launchAgent(target)} onRestart={() => void restartDesktopApp()}
onStop={() => void stopDeepSeekHarness()} onRestartWeb={() => void restartDeepSeekHarness()} error={launchError} /> : null}
</section>
</div>
{backupsOpen ? <AgentConfigBackupDialog client={selected} onClose={() => setBackupsOpen(false)} onRestored={async () => {
clearPendingChanges();
if (selected === 'claude-code' || selected === 'claude-desktop') claudeModelMappingsDirtyRef.current[selected] = false;
setOauthConfigurationDraft(null);
const refreshed = await invoke<AgentConfigStatus[]>('refresh_agent_config_statuses');
@@ -2259,7 +2091,6 @@ export function AgentsPage({ embedded = false, onConfigurationApplied }: AgentsP
setModelByClient((values) => { const next = { ...values, [selected]: current ?? '' }; writeAgentModelSelections(next); return next; });
setConfigurationNotice(t('agents.backup.restored'));
}} /> : null}
{configurationNotice && (embedded || isPiClient) ? <p className="agent-inline-message" role="status">{configurationNotice}</p> : null}
{codexCatalogDialogOpen ? (
<CodexModelCatalogDialog
+33 -10
View File
@@ -5529,7 +5529,7 @@ textarea:focus-visible {
.agent-list-items button {
display: grid;
grid-template-columns: 28px minmax(0, 1fr);
grid-template-columns: 28px minmax(0, 1fr) 8px;
align-items: center;
gap: 9px;
width: 100%;
@@ -5568,6 +5568,13 @@ textarea:focus-visible {
object-fit: contain;
}
.agent-installed-indicator {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--theme-5d9a6c);
}
.agent-config-panel {
display: grid;
grid-template-rows: auto auto;
@@ -10125,7 +10132,7 @@ body.table-col-resizing * {
}
.agent-list-items button {
grid-template-columns: 26px minmax(0, 1fr);
grid-template-columns: 26px minmax(0, 1fr) 8px;
}
.auth-filter-grid {
@@ -12772,7 +12779,7 @@ body.table-col-resizing * {
}
.agents-page-embedded .agent-list-items button {
grid-template-columns: 30px minmax(0, 1fr);
grid-template-columns: 30px minmax(0, 1fr) 8px;
gap: 11px;
min-height: 66px;
padding: 10px 11px;
@@ -13752,15 +13759,9 @@ body.table-col-resizing * {
.agent-model-section .agent-codex-catalog-button { min-height: 38px; height: 38px; padding: 8px 10px; }
.agent-write-state { margin: 0; font-size: var(--font-size-meta); color: var(--theme-7a746c); }
.agent-connection-state { margin: 0; font-size: var(--font-size-meta); font-weight: 600; color: var(--theme-7a746c); }
.agent-connection-state.connected { color: var(--theme-4f825c); }
.agent-signin-hint { grid-column: 1 / -1; margin: 0; color: var(--theme-7a746c); font-size: var(--font-size-meta); line-height: 1.6; }
/* Agent settings: one form and one explicit save action. */
.agent-settings-toolbar { grid-column: 1 / -1; display: flex; align-items: center; justify-content: space-between; gap: 16px; padding-bottom: 16px; border-bottom: 1px solid var(--theme-e3e1db); }
.agent-settings-title { display: grid; gap: 4px; min-width: 0; }
.agent-settings-title > strong { font-size: var(--font-size-section); color: var(--theme-2d2a26); }
.agent-settings-tools { display: flex; align-items: flex-start; gap: 8px; flex-wrap: wrap; }
.agent-model-section { grid-column: 1 / -1; }
.agent-save-bar { grid-column: 1 / -1; display: flex; align-items: center; justify-content: space-between; gap: 20px; padding: 16px 0 0; border-top: 1px solid var(--theme-e3e1db); }
@@ -13768,6 +13769,29 @@ body.table-col-resizing * {
.agent-save-feedback small { color: var(--theme-7a746c); font-size: var(--font-size-meta); }
.agent-save-actions { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 8px; margin-left: auto; min-width: 0; }
.agent-save-actions button { min-width: 128px; min-height: 40px; }
.agent-shared-feedback { min-width: 0; }
.agent-run-controls { display: grid; gap: 12px; min-width: 0; padding-top: 16px; border-top: 1px solid var(--theme-e3e1db); }
.agent-run-controls .agent-launch-actions { justify-content: flex-start; flex-wrap: wrap; }
.agent-run-controls .agent-launch-button { display: inline-flex; align-items: center; justify-content: center; gap: 8px; min-height: 40px; }
.agent-management-sections { display: grid; min-width: 0; }
.agent-management-row { display: flex; align-items: center; justify-content: space-between; gap: 20px; padding: 20px 0; min-width: 0; border-bottom: 1px solid var(--theme-e3e1db); }
.agent-management-row:first-child { padding-top: 6px; }
.agent-management-row:last-child { border-bottom: 0; }
.agent-management-row > div:first-child { min-width: 0; flex: 1; }
.agent-management-row h3 { margin: 0 0 6px; color: var(--theme-2d2a26); font-size: var(--font-size-section); }
.agent-management-row p { margin: 0; color: var(--theme-7a746c); font-size: var(--font-size-label); line-height: var(--line-height-body); overflow-wrap: anywhere; }
.agent-management-actions { display: flex; flex-wrap: wrap; gap: 8px; justify-content: flex-end; max-width: 50%; }
.agent-management-actions button { display: inline-flex; align-items: center; justify-content: center; gap: 8px; min-height: 40px; }
.agents-page-embedded .agent-config-panel { display: grid; gap: 16px; max-height: none; overflow: visible; padding: 20px; }
.agents-page-embedded .agent-minimal-config { padding: 0; }
.agents-page-embedded .agent-minimal-actions { grid-template-columns: minmax(0, 1fr); }
.agent-subpage-tabs button:focus-visible { outline: 2px solid var(--theme-2d2a26); outline-offset: -2px; }
@container agent-panel (max-width: 640px) {
.agent-management-row { flex-direction: column; align-items: stretch; gap: 12px; }
.agent-management-actions { max-width: 100%; justify-content: flex-start; }
.agent-run-controls .agent-launch-actions { display: flex; }
.agent-run-controls .agent-launch-button { flex: 1 1 140px; min-width: 0; }
}
.agent-signin-label { display: flex; align-items: center; gap: 8px; }
.agent-signin-help-toggle { display: inline-flex; align-items: center; gap: 4px; min-height: 30px; padding: 3px 6px; border: 0; border-radius: 4px; background: transparent; color: var(--theme-7a746c); font: inherit; cursor: pointer; }
.agent-signin-help-toggle:hover { color: var(--theme-2d2a26); background: var(--theme-fffdf8); }
@@ -13781,7 +13805,6 @@ body.table-col-resizing * {
.agent-signin-explanation p { margin: 12px 0 0; }
@media (max-width: 540px) { .agent-signin-explanation dl > div { grid-template-columns: 1fr; gap: 3px; } }
@media (max-width: 760px) {
.agent-settings-toolbar { align-items: flex-start; flex-wrap: wrap; }
.agent-save-bar { flex-wrap: wrap; gap: 12px; }
.agent-save-actions { margin-left: auto; }
}
+21 -13
View File
@@ -9,20 +9,19 @@ const assert = require('node:assert/strict');
page.setDefaultTimeout(10000);
const errors = []; page.on('pageerror', e => errors.push(String(e)));
const calls = cmd => page.evaluate(cmd => window.fixtureCalls.filter(c => c.cmd === cmd), cmd);
const open = async query => { await page.goto('http://localhost:1421/tests/fixtures/agent-backups.html?' + query); await page.getByRole('button', { name: '手动备份', exact: true }).waitFor(); };
const backup = async () => { await page.getByRole('button', { name: '手动备份', exact: true }).click(); await page.getByText('已手动备份当前磁盘配置,未包含未保存的表单修改。', { exact: true }).waitFor(); };
const choose = async () => { await page.getByRole('button', { name: '恢复备份', exact: true }).click(); await page.locator('.agent-backup-columns nav button').first().click(); };
for (const query of ['fresh', 'embedded&fresh', 'client=pi']) {
const manage = () => page.getByRole('tab', { name: '配置管理', exact: true }).click();
const open = async query => { await page.goto('http://localhost:1421/tests/fixtures/agent-backups.html?' + query); await page.getByRole('tab', { name: '基础配置', exact: true }).waitFor(); };
const backup = async () => { await manage(); await page.getByRole('button', { name: '手动备份', exact: true }).click(); await page.getByText('已手动备份当前磁盘配置,未包含未保存的表单修改。', { exact: true }).waitFor(); };
const choose = async () => { await manage(); await page.getByRole('button', { name: '恢复备份', exact: true }).click(); await page.locator('.agent-backup-columns nav button').first().click(); };
for (const query of ['fresh', 'embedded&fresh']) {
await open(query);
if (!query.includes('pi')) {
await page.getByRole('button', { name: '一键接入', exact: true }).click();
await page.getByText('配置已更新。', { exact: true }).waitFor();
await page.getByRole('button', { name: '更新配置', exact: true }).click();
await page.getByText('配置已是最新,无需写入。', { exact: true }).waitFor();
}
await page.getByRole('button', { name: '一键接入', exact: true }).click();
await page.getByText('配置已更新。', { exact: true }).waitFor();
await page.getByRole('button', { name: '更新配置', exact: true }).click();
await page.getByText('配置已是最新,无需写入。', { exact: true }).waitFor();
assert.equal((await calls('create_agent_config_backup')).length, 0);
await backup();
assert.deepEqual((await calls('create_agent_config_backup'))[0].args, { client: query.includes('pi') ? 'pi' : 'codex' });
assert.deepEqual((await calls('create_agent_config_backup'))[0].args, { client: 'codex' });
await page.getByRole('button', { name: '基础配置模板', exact: true }).click();
await page.getByRole('button', { name: '确认覆盖', exact: true }).waitFor();
assert.equal(await page.locator('.agent-template-files li').count(), 3);
@@ -51,6 +50,7 @@ const assert = require('node:assert/strict');
}
await open('state=invalid');
assert.ok(await page.getByRole('button', { name: '更新配置', exact: true }).isDisabled());
await manage();
assert.ok(await page.getByRole('button', { name: '基础配置模板', exact: true }).isEnabled());
await backup(); await choose();
assert.ok(await page.getByRole('button', { name: '恢复此版本', exact: true }).isDisabled());
@@ -63,11 +63,19 @@ const assert = require('node:assert/strict');
await page.setViewportSize({ width: 540, height: 820 });
assert.ok(await page.locator('.agent-backup-modal').isVisible());
assert.ok(await page.locator('.agent-backup-modal').evaluate(el => el.scrollWidth <= el.clientWidth));
await page.screenshot({ path: 'misc/agent-backups-narrow.png', fullPage: true });
await page.screenshot({ path: process.env.TEMP + '/agent-backups-narrow.png', fullPage: true });
await open('client=claude-desktop');
await page.getByText('没有可靠的 Claude Desktop 模型映射,请重新选择模型。', { exact: true }).waitFor();
assert.ok(await page.getByRole('button', { name: '更新配置', exact: true }).isDisabled());
for (const query of ['client=pi', 'embedded&client=pi']) {
await page.goto('http://localhost:1421/tests/fixtures/agent-backups.html?' + query);
await page.getByRole('button', { name: '更新配置', exact: true }).waitFor();
await manage();
for (const name of ['手动备份', '恢复备份', '基础配置模板']) {
assert.equal(await page.getByRole('button', { name, exact: true }).count(), 0);
}
}
assert.deepEqual(errors, []);
console.log('PASS: full/compact/Pi backup, disk-only payload, template preview, restore/delete confirmations, corrupt backups, conflict invalidation, narrow layout, missing Desktop mapping');
console.log('PASS: full/compact backup, no Pi backup/template controls, disk-only payload, template preview, restore/delete confirmations, corrupt backups, conflict invalidation, narrow layout, missing Desktop mapping');
} finally { await browser.close(); }
})().catch(e => { console.error(e); process.exit(1); });
+121
View File
@@ -0,0 +1,121 @@
// Run Vite on port 1421, then node tests/agent-management-ui.cjs.
const { chromium } = require(process.env.PLAYWRIGHT_MODULE || 'playwright');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
(async () => {
const browser = await chromium.launch({ channel: 'msedge', headless: true });
const screenshots = path.join(os.tmpdir(), 'cpa-agent-ui-review');
fs.mkdirSync(screenshots, { recursive: true });
try {
const page = await browser.newPage({ viewport: { width: 1280, height: 900 } });
const errors = [];
page.on('pageerror', error => errors.push(String(error)));
const open = async query => {
await page.goto('http://localhost:1421/tests/fixtures/agent-backups.html?reset-selections&' + query);
await page.waitForFunction(() => {
const refresh = document.querySelector('.agent-header-actions button');
return refresh && !refresh.disabled && document.querySelector('.agent-model-trigger')
&& window.fixtureCalls.some(call => call.cmd === 'get_agent_models');
});
};
const tab = name => page.getByRole('tab', { name, exact: true });
const button = name => page.getByRole('button', { name, exact: true });
const calls = cmd => page.evaluate(cmd => window.fixtureCalls.filter(call => call.cmd === cmd), cmd);
const pending = () => page.getByText('待应用', { exact: true }).waitFor();
for (const mode of ['', 'embedded&']) {
await open(mode + 'defer-restart');
assert.equal(await button('手动备份').count(), 0);
assert.equal(await button('基础配置模板').count(), 0);
assert.equal(await button('模型列表设置').count(), 0);
assert.equal(await page.locator('.agent-launch-actions button').count(), 3);
assert.equal(await tab('会话管理').count(), mode ? 0 : 1);
await page.locator('.agent-model-trigger').click();
await page.getByRole('option', { name: 'gpt-two' }).click();
await pending();
await button('重启 App').click();
await page.waitForFunction(() => !!window.fixtureFinishRestart);
assert.ok(await tab('配置管理').isDisabled());
assert.ok(await button('启动 CLI').isDisabled());
assert.ok(await page.locator('.agent-model-trigger').isDisabled());
await page.evaluate(() => window.fixtureFinishRestart());
await button('重启 App').waitFor();
await pending();
assert.deepEqual((await calls('restart_agent_app')).map(call => call.args), [{ client: 'codex' }]);
assert.equal((await calls('update_agent_config')).length, 0);
await tab('配置管理').click();
await pending();
assert.equal(await page.locator('.agent-launch-actions').count(), 0);
assert.equal(await button('更新配置').count(), 0);
for (const name of ['手动备份', '恢复备份', '基础配置模板', '清空配置', '模型列表设置']) {
assert.ok(await button(name).isVisible());
}
await tab('配置管理').press('ArrowLeft');
assert.equal(await tab('基础配置').getAttribute('aria-selected'), 'true');
assert.equal(await tab('基础配置').evaluate(el => el === document.activeElement), true);
assert.equal(await page.locator('.agent-model-trigger strong').textContent(), 'gpt-two');
await button('更新配置').click();
await page.waitForFunction(() => document.documentElement.dataset.fixtureApplied === '1');
await tab('配置管理').click();
await page.locator('.agent-list-items button').filter({ hasText: 'OpenCode' }).click();
assert.equal(await tab('基础配置').getAttribute('aria-selected'), 'true');
assert.equal(await tab('会话管理').count(), 0);
for (const client of ['claude-desktop', 'zcode', 'opencode']) {
await open(mode + 'client=' + client + '&app-only');
assert.ok(await button('重启 App').isEnabled());
await button('重启 App').click();
await page.waitForFunction(() => window.fixtureCalls.some(call => call.cmd === 'restart_agent_app'));
assert.deepEqual((await calls('restart_agent_app'))[0].args, { client });
if (client === 'opencode') assert.ok(await button('启动 CLI').isDisabled());
}
await open(mode + 'not-installed');
assert.ok(await button('启动 App').isDisabled());
assert.ok(await button('重启 App').isDisabled());
await open(mode + 'client=kimi-code');
assert.equal(await button('重启 App').count(), 0);
await open(mode + 'client=pi&update');
assert.equal(await button('卸载 Pi 插件').count(), 0);
await tab('配置管理').click();
assert.ok(await button('更新 Pi 插件').isEnabled());
assert.ok(await button('卸载 Pi 插件').isEnabled());
assert.equal(await button('手动备份').count(), 0);
await open(mode + 'client=pi&no-plugin');
assert.ok(await button('安装 Pi 插件').isEnabled());
await open(mode + 'client=deepseek-harness&running');
await button('重启 Web').click();
assert.equal((await calls('restart_deepseek_harness_process')).length, 1);
await button('关闭 DeepSeek Harness').waitFor();
await open(mode + 'client=deepseek-harness&running&harness-mode=headless');
assert.equal(await button('重启 Web').count(), 0);
await open(mode + 'client=deepseek-harness&running&fail-restart');
await button('重启 Web').click();
await page.getByText(/模拟重启失败/).waitFor();
await button('重启 Web').waitFor({ state: 'detached' });
assert.equal(await button('关闭 DeepSeek Harness').count(), 0);
assert.equal((await calls('launch_agent')).length, 0);
}
for (const width of [1280, 540]) for (const theme of ['light', 'dark']) for (const embedded of [false, true]) {
await page.setViewportSize({ width, height: 900 });
await open(`theme=${theme}&locale=en${embedded ? '&embedded' : ''}`);
for (const subpage of ['Basic configuration', 'Configuration management']) {
await tab(subpage).click();
const overflow = await page.evaluate(() => document.documentElement.scrollWidth > window.innerWidth);
assert.equal(overflow, false, `${width}/${theme}/${embedded}/${subpage} overflow`);
assert.ok(await page.locator('.agent-config-panel').evaluate(el => el.scrollHeight <= el.clientHeight + 1), 'panel content must not be clipped');
assert.ok(await tab(subpage).isVisible());
await page.screenshot({ path: path.join(screenshots, `${width}-${theme}-${embedded ? 'compact' : 'full'}-${subpage.startsWith('Basic') ? 'core' : 'management'}.png`), fullPage: true });
}
}
assert.deepEqual(errors, []);
console.log('PASS: management navigation, preserved drafts, shared runtime actions, all desktop restarts, Pi, Harness, disabled states, keyboard, guide callback, responsive themes');
console.log('Screenshots: ' + screenshots);
} finally { await browser.close(); }
})().catch(error => { console.error(error); process.exit(1); });
+133
View File
@@ -0,0 +1,133 @@
// Run Vite on port 1421, then node tests/agent-pending-ui.cjs.
const { chromium } = require(process.env.PLAYWRIGHT_MODULE || 'playwright');
const assert = require('node:assert/strict');
(async () => {
const browser = await chromium.launch({ channel: 'msedge', headless: true });
try {
const page = await browser.newPage({ viewport: { width: 1280, height: 1000 } });
page.setDefaultTimeout(10000);
const errors = [];
page.on('pageerror', error => errors.push(String(error)));
const pending = page.getByText('待应用', { exact: true });
const expectPending = async expected => {
await pending.waitFor({ state: expected ? 'visible' : 'detached' });
assert.equal(await page.getByText('未保存', { exact: true }).count(), 0);
};
const ready = () => page.waitForFunction(() => {
const button = document.querySelector('.agent-model-trigger');
return button && !button.disabled && !button.textContent.includes('加载');
});
const open = async query => {
await page.goto('http://localhost:1421/tests/fixtures/agent-backups.html?reset-selections&' + query);
await ready();
};
const manage = () => page.getByRole('tab', { name: '配置管理', exact: true }).click();
const core = () => page.getByRole('tab', { name: '基础配置', exact: true }).click();
const selectModel = async (model, index = 0) => {
await core();
await page.locator('.agent-model-trigger').nth(index).click();
await page.getByRole('option').filter({ has: page.getByText(model, { exact: true }) }).click();
};
const apply = async () => {
await core();
await page.getByRole('button', { name: /^(更新配置|一键接入)$/ }).click();
await page.getByText(/^(配置已更新。|配置已是最新,无需写入。)$/).waitFor();
await expectPending(false);
};
// Initial detection, missing mappings and refreshes do not count as user edits.
for (const query of ['', 'fresh', 'state=needs-update', 'state=invalid', 'client=claude-desktop']) {
await open(query);
await expectPending(false);
await page.getByRole('button', { name: '重新检测', exact: true }).click();
await ready();
await expectPending(false);
}
// Model edits, reversions, backups, apply and component remounts.
for (const query of ['fresh', 'embedded&fresh', 'client=pi', 'embedded&client=pi']) {
await open(query);
const original = await page.locator('.agent-model-trigger strong').first().textContent();
const changed = original === 'gpt-one' ? 'gpt-two' : 'gpt-one';
await selectModel(changed); await expectPending(true);
await selectModel(original); await expectPending(false);
await selectModel(changed); await expectPending(true);
if (!query.includes('client=pi')) {
await manage();
await page.getByRole('button', { name: '手动备份', exact: true }).click();
await page.getByText('已手动备份当前磁盘配置,未包含未保存的表单修改。', { exact: true }).waitFor();
}
await expectPending(true);
await page.evaluate(() => window.fixtureRemount());
await ready(); await expectPending(true);
await apply();
await selectModel(original); await expectPending(true);
await selectModel(changed); await expectPending(false);
}
// Independent client drafts and authentication changes.
await open('');
await selectModel('gpt-two'); await expectPending(true);
await page.locator('.agent-list-items button').filter({ hasText: 'OpenCode' }).click();
await ready(); await expectPending(false);
await page.locator('.agent-list-items button').filter({ hasText: 'Codex' }).click();
await ready(); await expectPending(true);
await selectModel('gpt-one'); await expectPending(false);
await page.locator('#agent-connection-method').selectOption('oauth');
await expectPending(true);
await selectModel('gpt-two');
await page.locator('#agent-connection-method').selectOption('apikey');
await expectPending(true);
await selectModel('gpt-one'); await expectPending(false);
// Claude role mappings and runtime settings are real form edits, too.
await open('client=claude-code');
await selectModel('gpt-two', 0); await expectPending(true);
await selectModel('gpt-one', 0); await expectPending(false);
const context = page.locator('.agent-claude-context-toggle input').first();
await context.check(); await expectPending(true);
await context.uncheck(); await expectPending(false);
await page.getByRole('spinbutton', { name: '最大窗口', exact: true }).fill('300000');
await expectPending(true);
await page.getByRole('spinbutton', { name: '最大窗口', exact: true }).fill('200000');
await expectPending(false);
const compact = page.locator('.agent-claude-code-disable-compact input');
await compact.check(); await expectPending(true);
await compact.uncheck(); await expectPending(false);
await selectModel('gpt-two', 1); await apply();
// A failed write must retain the pending edit; a successful retry clears it.
await open('fail-apply');
await selectModel('gpt-two');
await page.getByRole('button', { name: '更新配置', exact: true }).click();
await page.getByText(/模拟配置写入失败/).waitFor();
await expectPending(true);
await apply();
// Applying a template or restoring a backup replaces the draft baseline.
for (const restore of [false, true]) {
await open('');
await manage();
await page.getByRole('button', { name: '手动备份', exact: true }).click();
await selectModel('gpt-two'); await expectPending(true);
await manage(); await expectPending(true);
if (restore) {
await page.getByRole('button', { name: '恢复备份', exact: true }).click();
await page.locator('.agent-backup-columns nav button').first().click();
await page.getByRole('button', { name: '恢复此版本', exact: true }).click();
await page.getByRole('button', { name: '确认恢复', exact: true }).click();
await page.locator('.agent-backup-modal').waitFor({ state: 'detached' });
} else {
await page.getByRole('button', { name: '基础配置模板', exact: true }).click();
await page.getByRole('button', { name: '确认覆盖', exact: true }).click();
await page.getByRole('button', { name: '确认覆盖', exact: true }).waitFor({ state: 'detached' });
}
await expectPending(false);
}
assert.deepEqual(errors, []);
console.log('PASS: initial detection, edits/reverts, refresh, per-client drafts, remount, OAuth, Claude settings, Pi, backup, apply/failure, template and restore pending states');
} finally {
await browser.close();
}
})().catch(error => { console.error(error); process.exit(1); });
+28 -5
View File
@@ -5,19 +5,39 @@ import { AgentsPage } from '../../src/pages/AgentsPage';
import { I18nProvider } from '../../src/i18n';
import '../../src/styles.css';
const params = new URLSearchParams(location.search);
localStorage.setItem('easy-cli-proxy-api.locale','zh-CN');
if(params.has('reset-selections'))localStorage.removeItem('cpa-gui.agent-model-selections.v1');
localStorage.setItem('easy-cli-proxy-api.locale',params.get('locale') || 'zh-CN');
document.documentElement.dataset.theme = params.get('theme') || 'light';
localStorage.setItem('cpa-gui.agent-selected-client.v1', params.get('client') || 'codex');
const ids = ['claude-code','claude-desktop','codex','opencode','openclaw','hermes','deepseek-harness','zcode','kimi-code','grok-build','pi'];
let count=0; let backupCount=0; const backups:any[]=[]; let currentModel=params.has('fresh')?null:'gpt-one';
let currentOauth=false; const currentMappings:Record<string,any>={};
let harnessStatus={running:params.has('running'),pid:params.has('running')?100:null as number|null,mode:params.has('running')?params.get('harness-mode')||'web':null as string|null};
let appliedCount=0;
const calls:any[]=[];(window as any).fixtureCalls=calls;
mockIPC(async (cmd,args:any) => {
calls.push({cmd,args});
if(cmd==='plugin:event|listen') return 1;
if(cmd==='plugin:event|unlisten'||cmd==='set_app_locale') return null;
if(cmd==='get_agent_config_statuses'||cmd==='refresh_agent_config_statuses') return ids.map(id=>({id,name:id,supportedPlatform:true,installed:true,pluginInstalled:true,launchTargets:[{id:'cli',label:'CLI',detail:'test'}],version:'1.0',cliVersion:'1.0',appVersion:null,pluginVersion:'1.0',configValid:params.get('state')!=='invalid',connectionState:params.get('state') || (currentModel?'configured':'not-configured'),configured:!!currentModel,configurationSynchronized:!!currentModel,currentModel,oauthConfiguration:false,modificationEnabled:!!currentModel,modificationState:currentModel?'applied':'unconfigured',backupAvailable:false,appliedModel:currentModel,claudeCodeModelMappings:null,claudeDesktopModelMappings:null,warnings:[],error:null}));
if(cmd==='get_agent_config_statuses'||cmd==='refresh_agent_config_statuses') return ids.map(id=>({id,name:id,supportedPlatform:true,installed:!params.has('not-installed'),pluginInstalled:!params.has('no-plugin'),launchTargets:params.has('not-installed')?[]:['claude-desktop','zcode'].includes(id)?[{id:'app',label:id,detail:'test desktop'}]:['codex','opencode'].includes(id)&&!params.has('cli-only')?[...(params.has('app-only')?[]:[{id:'cli',label:'CLI',detail:'test CLI'}]),{id:'app',label:'APP',detail:'test desktop'}]:[{id:'cli',label:'CLI',detail:'test CLI'}],version:'1.0',cliVersion:'1.0',appVersion:null,pluginVersion:'1.0',configValid:params.get('state')!=='invalid',connectionState:params.get('state') || (currentModel?'configured':'not-configured'),configured:!!currentModel,configurationSynchronized:!!currentModel,currentModel,oauthConfiguration:id==='codex'&&currentOauth,modificationEnabled:!!currentModel,modificationState:currentModel?'applied':'unconfigured',backupAvailable:false,appliedModel:currentModel,claudeCodeModelMappings:id==='claude-code'?currentMappings[id]??null:null,claudeDesktopModelMappings:id==='claude-desktop'?currentMappings[id]??null:null,warnings:[],error:null}));
if(cmd==='get_agent_models') return [{name:'gpt-one'},{name:'gpt-two'}];
if(cmd==='get_deepseek_harness_process_status') return {running:false,pid:null,mode:null};
if(cmd==='update_agent_config') {count++;currentModel=args.model;return {outcome:count>1?'unchanged':'updated',model:currentModel,enabled:true,changedFiles:[],conflictFiles:[]};}
if(cmd==='get_deepseek_harness_process_status') return harnessStatus;
if(cmd==='restart_agent_app'||cmd==='restart_deepseek_harness_process') {
if(params.has('defer-restart'))await new Promise<void>(resolve=>{(window as any).fixtureFinishRestart=resolve;});
if(params.has('fail-restart')) { if(cmd==='restart_deepseek_harness_process')harnessStatus={running:false,pid:null,mode:null}; throw new Error('模拟重启失败'); }
if(cmd==='restart_deepseek_harness_process')return harnessStatus={running:true,pid:101,mode:'web'};
return null;
}
if(cmd==='stop_deepseek_harness_process')return harnessStatus={running:false,pid:null,mode:null};
if(cmd==='launch_agent')return null;
if(cmd==='clear_codex_config') { currentModel=null; return []; }
if(cmd==='uninstall_pi_provider')return null;
if(['update_agent_config','repair_pi_provider','install_pi_provider','update_pi_provider'].includes(cmd)) {
count++;if(params.has('fail-apply')&&count===1)throw new Error('模拟配置写入失败');
currentModel=args.model;currentOauth=!!args.oauthConfiguration;
currentMappings[args.client]=args.claudeCodeModelMappings??args.claudeDesktopModelMappings;
return {outcome:count>1?'unchanged':'updated',model:currentModel,enabled:true,changedFiles:[],conflictFiles:[]};
}
if(cmd==='create_agent_config_backup') {
const id=String(++backupCount);
const files=[{path:'C:/test/.codex/config.toml',exists:true,size:120},{path:'C:/test/.codex/models.json',exists:true,size:60},{path:'C:/test/.codex/auth.json',exists:false,size:null}];
@@ -35,4 +55,7 @@ mockIPC(async (cmd,args:any) => {
if(cmd==='check_codex_oauth_login')return null;
throw new Error('Unhandled fixture command: '+cmd);
});
createRoot(document.getElementById('root')!).render(<I18nProvider><AgentsPage embedded={params.has('embedded')}/></I18nProvider>);
let root=createRoot(document.getElementById('root')!);
const render=()=>root.render(<I18nProvider><AgentsPage embedded={params.has('embedded')} onConfigurationApplied={()=>{document.documentElement.dataset.fixtureApplied=String(++appliedCount);}}/></I18nProvider>);
(window as any).fixtureRemount=()=>{root.unmount();root=createRoot(document.getElementById('root')!);render();};
render();