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