feat(agents): add Codex app restart and CLI directory history
This commit is contained in:
1 parent
6dc6cb02e2
commit
0c83be3727
9 files changed
+486
-7
No files matched your search
@@ -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<Path
|
||||
fn launch_codex_desktop(home: &Path) -> 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"));
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
+8
-1
@@ -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': '未インストール',
|
||||
|
||||
@@ -505,6 +505,8 @@ export const en: Record<MessageKey, string> = {
|
||||
'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<MessageKey, string> = {
|
||||
'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',
|
||||
|
||||
@@ -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': '未安装',
|
||||
|
||||
+109
-2
@@ -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 <img src={definition.icon} alt="" className="provider-logo" />;
|
||||
@@ -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<AgentLaunchTarget | null>(null);
|
||||
const [launchDirectoryError, setLaunchDirectoryError] = useState('');
|
||||
const [launchDirectoryHistory, setLaunchDirectoryHistory] = useState(
|
||||
readAgentLaunchDirectoryHistory,
|
||||
);
|
||||
const [oauthLoginRequiredAction, setOauthLoginRequiredAction] = useState<OAuthLoginRequiredAction | null>(null);
|
||||
const [oauthConfigurationDraft, setOauthConfigurationDraft] = useState<boolean | null>(null);
|
||||
const [piProviderUpdateStatus, setPiProviderUpdateStatus] = useState<PiProviderUpdateStatus | null>(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')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="secondary-button agent-launch-button"
|
||||
onClick={() => void restartCodexApp()}
|
||||
disabled={busy || !canLaunchTarget(appLaunchTarget) || draftChanged}
|
||||
title={draftChanged
|
||||
? t('agents.launch.applyFirst')
|
||||
: 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
|
||||
@@ -1953,6 +2024,42 @@ export function AgentsPage() {
|
||||
: t('agents.launchDirectory.browse')}</b>
|
||||
</button>
|
||||
</div>
|
||||
{activeLaunchDirectoryHistory.length ? (
|
||||
<div className="agent-launch-directory-history">
|
||||
<strong>{t('agents.launchDirectory.historyTitle')}</strong>
|
||||
<div className="agent-launch-directory-history-list">
|
||||
{activeLaunchDirectoryHistory.map((directory, index) => {
|
||||
const active = directory === launchDirectory;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={active ? 'active' : ''}
|
||||
key={directory}
|
||||
onClick={() => {
|
||||
setLaunchDirectory(directory);
|
||||
setLaunchDirectoryError('');
|
||||
}}
|
||||
disabled={busy}
|
||||
title={directory}
|
||||
>
|
||||
<span>
|
||||
<strong>{directory}</strong>
|
||||
<small>{index === 0
|
||||
? t('agents.launchDirectory.lastUsed')
|
||||
: t('agents.launchDirectory.recentItem')}</small>
|
||||
</span>
|
||||
<b>
|
||||
{active ? <Check size={15} aria-hidden /> : null}
|
||||
{active
|
||||
? t('agents.launchDirectory.historySelected')
|
||||
: t('agents.launchDirectory.historyUse')}
|
||||
</b>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
{launchDirectoryError ? (
|
||||
<span className="agent-inline-message error" role="alert" aria-live="polite">
|
||||
{launchDirectoryError}
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
export type AgentLaunchDirectoryHistory = Record<string, string[]>;
|
||||
|
||||
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<string, unknown>;
|
||||
return clients.reduce<AgentLaunchDirectoryHistory>((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),
|
||||
};
|
||||
};
|
||||
@@ -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));
|
||||
|
||||
@@ -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({});
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user