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:
1 parent
034a111c0b
commit
9d0baa4c69
14 files changed
+1314
-492
No files matched your search
@@ -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
@@ -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
@@ -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,
|
||||
|
||||
@@ -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
@@ -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
@@ -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",
|
||||
|
||||
@@ -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": "更多操作",
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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); });
|
||||
@@ -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); });
|
||||
@@ -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); });
|
||||
Vendored
+28
-5
@@ -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'&¤tOauth,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();
|
||||
Reference in new issue
Block a user