diff --git a/src-tauri/src/commands/account.rs b/src-tauri/src/commands/account.rs index 6743d7f..2e2d089 100644 --- a/src-tauri/src/commands/account.rs +++ b/src-tauri/src/commands/account.rs @@ -5,6 +5,9 @@ use crate::auth::{ import_from_auth_json, load_accounts, remove_account, save_accounts, set_active_account, switch_to_account, touch_account, }; +use crate::commands::{ + collect_running_codex_processes, gracefully_stop_codex_processes, restart_codex_processes, +}; use crate::types::{AccountInfo, AccountsStore, AuthData, ImportAccountsSummary, StoredAccount}; use anyhow::Context; @@ -22,12 +25,6 @@ use std::collections::HashSet; use std::fs; use std::io::{Read, Write}; -#[cfg(windows)] -use std::os::windows::process::CommandExt; - -#[cfg(windows)] -const CREATE_NO_WINDOW: u32 = 0x08000000; - const SLIM_EXPORT_PREFIX: &str = "css1."; const SLIM_FORMAT_VERSION: u8 = 1; const SLIM_AUTH_API_KEY: u8 = 0; @@ -39,7 +36,6 @@ const FULL_SALT_LEN: usize = 16; const FULL_NONCE_LEN: usize = 24; const FULL_KDF_ITERATIONS: u32 = 210_000; const FULL_PRESET_PASSPHRASE: &str = "gT7kQ9mV2xN4pL8sR1dH6zW3cB5yF0uJ_aE7nK2tP9vM4rX1"; - const MAX_IMPORT_JSON_BYTES: u64 = 2 * 1024 * 1024; const MAX_IMPORT_FILE_BYTES: u64 = 8 * 1024 * 1024; const SLIM_IMPORT_CONCURRENCY: usize = 6; @@ -111,8 +107,12 @@ pub async fn add_account_from_file(path: String, name: String) -> Result Result<(), String> { +pub async fn switch_account( + account_id: String, + restart_running_codex: Option, +) -> Result<(), String> { let store = load_accounts().map_err(|e| e.to_string())?; + let running_processes = collect_running_codex_processes().map_err(|e| e.to_string())?; // Find the account let account = store @@ -121,33 +121,32 @@ pub async fn switch_account(account_id: String) -> Result<(), String> { .find(|a| a.id == account_id) .ok_or_else(|| format!("Account not found: {account_id}"))?; - // Write to ~/.codex/auth.json - switch_to_account(account).map_err(|e| e.to_string())?; + // Only foreground processes (desktop app, CLI) require a restart confirmation. + // Background processes (IDE extensions) are never stopped — they pick up the + // new auth.json on their own. + let foreground_processes: Vec<_> = running_processes + .iter() + .filter(|p| !p.is_background()) + .cloned() + .collect(); - // Update the active account in our store - set_active_account(&account_id).map_err(|e| e.to_string())?; + let should_restart = restart_running_codex.unwrap_or(false); + if !foreground_processes.is_empty() && !should_restart { + return Err(String::from( + "Codex is currently running. Confirm a graceful restart before switching accounts.", + )); + } - // Update last_used_at + if should_restart { + gracefully_stop_codex_processes(&foreground_processes).map_err(|e| e.to_string())?; + } + + switch_to_account(account).map_err(|e| e.to_string())?; + set_active_account(&account_id).map_err(|e| e.to_string())?; touch_account(&account_id).map_err(|e| e.to_string())?; - // Restart Antigravity background process if it is running - // This allows it to pick up the new authorization file seamlessly - if let Ok(pids) = find_antigravity_processes() { - for pid in pids { - #[cfg(unix)] - { - let _ = std::process::Command::new("kill") - .arg("-9") - .arg(pid.to_string()) - .output(); - } - #[cfg(windows)] - { - let _ = std::process::Command::new("taskkill") - .args(["/F", "/PID", &pid.to_string()]) - .output(); - } - } + if should_restart { + restart_codex_processes(&foreground_processes).map_err(|e| e.to_string())?; } Ok(()) @@ -205,7 +204,9 @@ pub async fn import_accounts_slim_text(payload: String) -> Result Result<(), String> { +pub async fn export_accounts_full_encrypted_file( + path: String, +) -> Result<(), String> { let store = load_accounts().map_err(|e| e.to_string())?; let encrypted = encode_full_encrypted_store(&store, FULL_PRESET_PASSPHRASE).map_err(|e| e.to_string())?; @@ -229,71 +230,6 @@ pub async fn import_accounts_full_encrypted_file( Ok(summary) } -/// Find all running Antigravity codex assistant processes -fn find_antigravity_processes() -> anyhow::Result> { - let mut pids = Vec::new(); - - #[cfg(unix)] - { - // Use ps with custom format to get the pid and full command line - let output = std::process::Command::new("ps") - .args(["-eo", "pid,command"]) - .output()?; - - let stdout = String::from_utf8_lossy(&output.stdout); - for line in stdout.lines().skip(1) { - let line = line.trim(); - if line.is_empty() { - continue; - } - - if let Some((pid_str, command)) = line.split_once(' ') { - let pid_str = pid_str.trim(); - let command = command.trim(); - - // Antigravity processes have a specific path format - let is_antigravity = (command.contains(".antigravity/extensions/openai.chatgpt") - || command.contains(".vscode/extensions/openai.chatgpt")) - && (command.ends_with("codex app-server --analytics-default-enabled") - || command.contains("/codex app-server")); - - if is_antigravity { - if let Ok(pid) = pid_str.parse::() { - pids.push(pid); - } - } - } - } - } - - #[cfg(windows)] - { - // Use tasklist on Windows - // For Windows we might need a more precise WMI query to get command line args, - // but for now we look for codex.exe PIDs and verify they're not ours - let output = std::process::Command::new("tasklist") - .creation_flags(CREATE_NO_WINDOW) - .args(["/FI", "IMAGENAME eq codex.exe", "/FO", "CSV", "/NH"]) - .output()?; - - let stdout = String::from_utf8_lossy(&output.stdout); - for line in stdout.lines() { - let parts: Vec<&str> = line.split(',').collect(); - if parts.len() > 1 { - let name = parts[0].trim_matches('"').to_lowercase(); - if name == "codex.exe" { - let pid_str = parts[1].trim_matches('"'); - if let Ok(pid) = pid_str.parse::() { - pids.push(pid); - } - } - } - } - } - - Ok(pids) -} - fn encode_slim_payload_from_store(store: &AccountsStore) -> anyhow::Result { let active_name = store.active_account_id.as_ref().and_then(|active_id| { store diff --git a/src-tauri/src/commands/process.rs b/src-tauri/src/commands/process.rs index 7cc35d8..6882fca 100644 --- a/src-tauri/src/commands/process.rs +++ b/src-tauri/src/commands/process.rs @@ -1,6 +1,8 @@ //! Process detection commands -use std::process::Command; +use std::process::{Command, Stdio}; +use std::thread; +use std::time::{Duration, Instant}; #[cfg(windows)] use std::os::windows::process::CommandExt; @@ -8,120 +10,480 @@ use std::os::windows::process::CommandExt; #[cfg(windows)] const CREATE_NO_WINDOW: u32 = 0x08000000; +/// The kind of Codex process detected. +#[derive(Debug, Clone, PartialEq)] +pub enum CodexProcessKind { + /// The Codex desktop GUI (Electron app at /Applications/Codex.app/Contents/MacOS/Codex) + DesktopApp, + /// A standalone `codex` CLI process (not inside an app bundle, not an IDE extension) + Cli, + /// A background IDE/extension process (Antigravity, VSCode, ChatGPT extension) — never stopped + Background, +} + +#[derive(Debug, Clone)] +pub struct RunningCodexProcess { + pub pid: u32, + pub command: String, + pub kind: CodexProcessKind, +} + +impl RunningCodexProcess { + pub fn is_background(&self) -> bool { + self.kind == CodexProcessKind::Background + } +} + /// Information about running Codex processes #[derive(Debug, Clone, serde::Serialize)] pub struct CodexProcessInfo { - /// Number of running codex processes + /// Number of running foreground codex processes (desktop app + CLI) pub count: usize, - /// Number of background IDE/extension codex processes (like Antigravity) + /// Number of background IDE/extension codex processes (Antigravity, VSCode, etc.) pub background_count: usize, - /// Whether switching is allowed (no processes running) + /// Whether switching is allowed without a restart prompt (no foreground processes) pub can_switch: bool, - /// Process IDs of running codex processes + /// Process IDs of running foreground codex processes pub pids: Vec, } /// Check for running Codex processes #[tauri::command] pub async fn check_codex_processes() -> Result { - let (pids, bg_count) = find_codex_processes().map_err(|e| e.to_string())?; - let count = pids.len(); + let processes = collect_running_codex_processes().map_err(|e| e.to_string())?; + let foreground: Vec<_> = processes + .iter() + .filter(|p| !p.is_background()) + .collect(); + let pids: Vec = foreground.iter().map(|p| p.pid).collect(); + let background_count = processes.iter().filter(|p| p.is_background()).count(); Ok(CodexProcessInfo { - count, - background_count: bg_count, - can_switch: count == 0, + count: pids.len(), + background_count, + can_switch: pids.is_empty(), pids, }) } -/// Find all running codex processes. Returns (active_pids, background_count) -fn find_codex_processes() -> anyhow::Result<(Vec, usize)> { - let mut pids = Vec::new(); - #[allow(unused_mut)] - let mut bg_count = 0; - +pub fn collect_running_codex_processes() -> anyhow::Result> { #[cfg(unix)] { - // Use ps with custom format to get the pid and full command line - let output = Command::new("ps").args(["-eo", "pid,command"]).output(); - - if let Ok(output) = output { - let stdout = String::from_utf8_lossy(&output.stdout); - for line in stdout.lines().skip(1) { - // Skip header - let line = line.trim(); - if line.is_empty() { - continue; + collect_running_codex_processes_unix() + } + + #[cfg(windows)] + { + collect_running_codex_processes_windows() + } +} + +pub fn gracefully_stop_codex_processes(processes: &[RunningCodexProcess]) -> anyhow::Result<()> { + let foreground: Vec<_> = processes.iter().filter(|p| !p.is_background()).collect(); + + if foreground.is_empty() { + return Ok(()); + } + + #[cfg(target_os = "macos")] + { + let has_desktop = foreground + .iter() + .any(|p| p.kind == CodexProcessKind::DesktopApp); + + if has_desktop { + // The Codex Electron app intercepts AppleScript quit (returns "User canceled" + // error -128) and ignores SIGTERM, so we must SIGKILL the GUI process directly. + if let Some(desktop) = foreground.iter().find(|p| p.kind == CodexProcessKind::DesktopApp) { + let _ = Command::new("kill") + .args(["-9", &desktop.pid.to_string()]) + .output(); + } + + // Wait up to 5 seconds for the Codex GUI process to exit. + // Match only /MacOS/Codex — not codex app-server orphans. + let start = Instant::now(); + while start.elapsed() < Duration::from_secs(5) { + let still_running = Command::new("pgrep") + .args(["-a", "Codex"]) + .output() + .map(|o| { + String::from_utf8_lossy(&o.stdout) + .lines() + .any(|l| l.contains("MacOS/Codex")) + }) + .unwrap_or(false); + + if !still_running { + break; } + thread::sleep(Duration::from_millis(200)); + } + } - // The first part is PID, the rest is the command string - if let Some((pid_str, command)) = line.split_once(' ') { - let command = command.trim(); - - // Get the executable path/name (first word of the command string before args) - let executable = command.split_whitespace().next().unwrap_or(""); - - // Check if the executable is exactly "codex" or ends with "/codex" - let is_codex = executable == "codex" || executable.ends_with("/codex"); - - // Exclude if it's running from an extension or IDE integration (like Antigravity) - // These are expected background processes we shouldn't block on - let is_ide_plugin = command.contains(".antigravity") - || command.contains("openai.chatgpt") - || command.contains(".vscode"); - - // Skip our own app - let is_switcher = - command.contains("codex-switcher") || command.contains("Codex Switcher"); - - if is_codex && !is_switcher { - if let Ok(pid) = pid_str.trim().parse::() { - if pid != std::process::id() && !pids.contains(&pid) { - if is_ide_plugin { - bg_count += 1; - } else { - pids.push(pid); - } - } - } - } + // SIGTERM any remaining CLI (non-desktop, non-background) processes + let cli_processes: Vec<_> = foreground + .iter() + .filter(|p| p.kind == CodexProcessKind::Cli) + .collect(); + + for process in &cli_processes { + let _ = Command::new("kill") + .args(["-TERM", &process.pid.to_string()]) + .output(); + } + + if !cli_processes.is_empty() { + let start = Instant::now(); + while start.elapsed() < Duration::from_secs(3) { + let any_running = cli_processes.iter().any(|process| { + Command::new("kill") + .args(["-0", &process.pid.to_string()]) + .status() + .map(|status| status.success()) + .unwrap_or(false) + }); + + if !any_running { + break; } + thread::sleep(Duration::from_millis(100)); } } + + return Ok(()); + } + + #[cfg(target_os = "linux")] + { + for process in &foreground { + let _ = Command::new("kill") + .args(["-TERM", &process.pid.to_string()]) + .output(); + } + + let start = Instant::now(); + while start.elapsed() < Duration::from_secs(5) { + let any_running = foreground.iter().any(|process| { + Command::new("kill") + .args(["-0", &process.pid.to_string()]) + .status() + .map(|status| status.success()) + .unwrap_or(false) + }); + + if !any_running { + return Ok(()); + } + + thread::sleep(Duration::from_millis(100)); + } } #[cfg(windows)] { - // Use tasklist on Windows - match exact "codex.exe" - let output = Command::new("tasklist") - // Prevent a console window from flashing when this command is invoked from the GUI app. - .creation_flags(CREATE_NO_WINDOW) - .args(["/FI", "IMAGENAME eq codex.exe", "/FO", "CSV", "/NH"]) - .output(); - - if let Ok(output) = output { - let stdout = String::from_utf8_lossy(&output.stdout); - for line in stdout.lines() { - // CSV format: "name","pid",... - let parts: Vec<&str> = line.split(',').collect(); - if parts.len() > 1 { - let name = parts[0].trim_matches('"').to_lowercase(); - // Only match exact "codex.exe", not "codex-switcher.exe" - if name == "codex.exe" { - let pid_str = parts[1].trim_matches('"'); - if let Ok(pid) = pid_str.parse::() { - if pid != std::process::id() { - // For Windows, we don't have an easy way to check if it's an IDE plugin - // just from the tasklist output, so assume they're regular for now - pids.push(pid); - } - } + let has_desktop = foreground + .iter() + .any(|p| p.kind == CodexProcessKind::DesktopApp); + + if has_desktop { + let _ = Command::new("taskkill") + .creation_flags(CREATE_NO_WINDOW) + .args(["/IM", "Codex.exe", "/F"]) + .output(); + + let start = Instant::now(); + while start.elapsed() < Duration::from_secs(5) { + let still_running = Command::new("tasklist") + .creation_flags(CREATE_NO_WINDOW) + .args(["/FI", "IMAGENAME eq Codex.exe", "/NH"]) + .output() + .map(|o| { + String::from_utf8_lossy(&o.stdout) + .to_lowercase() + .contains("codex.exe") + }) + .unwrap_or(false); + + if !still_running { + break; + } + thread::sleep(Duration::from_millis(200)); + } + } + + // Kill remaining CLI processes + for process in foreground.iter().filter(|p| p.kind == CodexProcessKind::Cli) { + let _ = Command::new("taskkill") + .creation_flags(CREATE_NO_WINDOW) + .args(["/PID", &process.pid.to_string()]) + .output(); + } + + thread::sleep(Duration::from_secs(1)); + return Ok(()); + } + + Ok(()) +} + +pub fn restart_codex_processes(processes: &[RunningCodexProcess]) -> anyhow::Result<()> { + let has_desktop = processes + .iter() + .any(|p| p.kind == CodexProcessKind::DesktopApp); + + #[cfg(target_os = "macos")] + { + if has_desktop { + // Wait until the Codex GUI process is fully gone before reopening, + // so the new app instance reads the freshly written auth.json. + // Match only /MacOS/Codex — not codex app-server orphans. + let deadline = Instant::now() + Duration::from_secs(8); + loop { + let still_alive = Command::new("pgrep") + .args(["-a", "Codex"]) + .output() + .map(|o| { + String::from_utf8_lossy(&o.stdout) + .lines() + .any(|l| l.contains("MacOS/Codex")) + }) + .unwrap_or(false); + + if !still_alive || Instant::now() >= deadline { + break; + } + thread::sleep(Duration::from_millis(200)); + } + + Command::new("open") + .args(["-a", "Codex"]) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn()?; + } + + // Restart any CLI processes + for process in processes.iter().filter(|p| p.kind == CodexProcessKind::Cli) { + if process.command.trim().is_empty() { + continue; + } + Command::new("sh") + .arg("-c") + .arg("nohup sh -lc \"$1\" >/dev/null 2>&1 &") + .arg("sh") + .arg(&process.command) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn()?; + } + } + + #[cfg(target_os = "linux")] + { + for process in processes.iter().filter(|p| !p.is_background()) { + if process.command.trim().is_empty() { + continue; + } + Command::new("sh") + .arg("-c") + .arg("nohup sh -lc \"$1\" >/dev/null 2>&1 &") + .arg("sh") + .arg(&process.command) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn()?; + } + } + + #[cfg(windows)] + { + if has_desktop { + let deadline = Instant::now() + Duration::from_secs(8); + loop { + let still_alive = Command::new("tasklist") + .creation_flags(CREATE_NO_WINDOW) + .args(["/FI", "IMAGENAME eq Codex.exe", "/NH"]) + .output() + .map(|o| { + String::from_utf8_lossy(&o.stdout) + .to_lowercase() + .contains("codex.exe") + }) + .unwrap_or(false); + + if !still_alive || Instant::now() >= deadline { + break; + } + thread::sleep(Duration::from_millis(200)); + } + + Command::new("cmd") + .creation_flags(CREATE_NO_WINDOW) + .args(["/C", "start", "", "Codex"]) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn()?; + } + + // Restart any CLI processes + for process in processes.iter().filter(|p| p.kind == CodexProcessKind::Cli) { + if process.command.trim().is_empty() { + continue; + } + Command::new("cmd") + .creation_flags(CREATE_NO_WINDOW) + .args(["/C", "start", "", "/B", &process.command]) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn()?; + } + } + + Ok(()) +} + +#[cfg(unix)] +fn collect_running_codex_processes_unix() -> anyhow::Result> { + let mut processes = Vec::new(); + let mut seen_desktop = false; + + let output = Command::new("ps").args(["-eo", "pid=,command="]).output()?; + let stdout = String::from_utf8_lossy(&output.stdout); + + for line in stdout.lines() { + let line = line.trim(); + if line.is_empty() { + continue; + } + + if let Some((pid_str, command)) = line.split_once(' ') { + let command = command.trim(); + let executable = command.split_whitespace().next().unwrap_or(""); + + // Skip ourselves + let is_switcher = + command.contains("codex-switcher") || command.contains("Codex Switcher"); + if is_switcher { + continue; + } + + // Detect the Codex desktop app GUI process (Electron, capital C) + // e.g. /Applications/Codex.app/Contents/MacOS/Codex + let is_desktop_gui = (executable.ends_with("/Codex") || executable == "Codex") + && command.contains("Codex.app") + && !command.contains("Helper") + && !command.contains("crashpad"); + + // Detect background IDE/extension codex processes — never stop these + let is_background = (executable == "codex" || executable.ends_with("/codex")) + && (command.contains(".antigravity") + || command.contains("openai.chatgpt") + || command.contains(".vscode")); + + // Detect standalone CLI codex (not desktop, not background) + // Explicitly exclude app-server processes (they are managed by the app bundle) + let is_cli = (executable == "codex" || executable.ends_with("/codex")) + && !command.contains("Codex.app") + && !is_background; + + let kind = if is_desktop_gui { + // Deduplicate: record the desktop app only once via the GUI process. + // We intentionally ignore codex app-server processes — they are + // managed by the app bundle and quitting via osascript handles them. + // Tracking orphaned servers would cause the "wait for exit" loop to + // stall indefinitely on stale PIDs. + if seen_desktop { + continue; + } + seen_desktop = true; + CodexProcessKind::DesktopApp + } else if is_background { + CodexProcessKind::Background + } else if is_cli { + CodexProcessKind::Cli + } else { + continue; + }; + + if let Ok(pid) = pid_str.trim().parse::() { + if pid != std::process::id() + && !processes.iter().any(|p: &RunningCodexProcess| p.pid == pid) + { + processes.push(RunningCodexProcess { + pid, + command: command.to_string(), + kind, + }); + } + } + } + } + + Ok(processes) +} + +#[cfg(windows)] +fn collect_running_codex_processes_windows() -> anyhow::Result> { + let mut processes = Vec::new(); + + // Check for the desktop app (Codex.exe with capital C) + let output = Command::new("tasklist") + .creation_flags(CREATE_NO_WINDOW) + .args(["/FI", "IMAGENAME eq Codex.exe", "/FO", "CSV", "/NH"]) + .output()?; + let stdout = String::from_utf8_lossy(&output.stdout); + + for line in stdout.lines() { + let parts: Vec<&str> = line.split(',').collect(); + if parts.len() > 1 { + let name = parts[0].trim_matches('"'); + if name == "Codex.exe" { + let pid_str = parts[1].trim_matches('"'); + if let Ok(pid) = pid_str.parse::() { + if pid != std::process::id() { + processes.push(RunningCodexProcess { + pid, + command: String::from("Codex.exe"), + kind: CodexProcessKind::DesktopApp, + }); + break; // Only need one desktop app entry + } + } + } + } + } + + // Check for CLI codex processes (lowercase codex.exe) + let output = Command::new("tasklist") + .creation_flags(CREATE_NO_WINDOW) + .args(["/FI", "IMAGENAME eq codex.exe", "/FO", "CSV", "/NH"]) + .output()?; + let stdout = String::from_utf8_lossy(&output.stdout); + + for line in stdout.lines() { + let parts: Vec<&str> = line.split(',').collect(); + if parts.len() > 1 { + let name = parts[0].trim_matches('"').to_lowercase(); + if name == "codex.exe" { + let pid_str = parts[1].trim_matches('"'); + if let Ok(pid) = pid_str.parse::() { + if pid != std::process::id() { + processes.push(RunningCodexProcess { + pid, + command: String::from("codex"), + kind: CodexProcessKind::Cli, + }); } } } } } - Ok((pids, bg_count)) + Ok(processes) } diff --git a/src/App.tsx b/src/App.tsx index 13d9002..99b9536 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,6 +1,6 @@ import { useState, useEffect, useCallback, useMemo, useRef } from "react"; import { invoke } from "@tauri-apps/api/core"; -import { open, save } from "@tauri-apps/plugin-dialog"; +import { open, save, ask } from "@tauri-apps/plugin-dialog"; import { useAccounts } from "./hooks/useAccounts"; import { AccountCard, AddAccountModal } from "./components"; import type { CodexProcessInfo } from "./types"; @@ -114,15 +114,38 @@ function App() { }, [isActionsMenuOpen]); const handleSwitch = async (accountId: string) => { - // Check processes before switching - await checkProcesses(); - if (processInfo && !processInfo.can_switch) { - return; - } - try { setSwitchingId(accountId); - await switchAccount(accountId); + const latestProcessInfo = await invoke("check_codex_processes").catch( + (err) => { + console.error("Failed to check processes before switching:", err); + return processInfo; + } + ); + + if (latestProcessInfo) { + setProcessInfo(latestProcessInfo); + } + + // Only foreground Codex processes (desktop app, CLI) require a restart. + // Background processes (IDE extensions) are ignored — they pick up the new + // auth.json automatically and must never be stopped. + const hasRunningCodex = !!latestProcessInfo && latestProcessInfo.count > 0; + + let restartRunningCodex = false; + if (hasRunningCodex) { + restartRunningCodex = await ask( + "Codex is running. Codex Switcher will close and reopen it to apply the new account. Continue?", + { title: "Codex Switcher", kind: "warning" } + ); + + if (!restartRunningCodex) { + return; + } + } + + await switchAccount(accountId, restartRunningCodex); + await checkProcesses(); } catch (err) { console.error("Failed to switch account:", err); } finally { @@ -564,7 +587,7 @@ function App() { onRefresh={() => refreshSingleUsage(activeAccount.id)} onRename={(newName) => renameAccount(activeAccount.id, newName)} switching={switchingId === activeAccount.id} - switchDisabled={hasRunningProcesses ?? false} + switchDisabled={false} warmingUp={isWarmingAll || warmingUpId === activeAccount.id} masked={maskedAccounts.has(activeAccount.id)} onToggleMask={() => toggleMask(activeAccount.id)} @@ -632,7 +655,7 @@ function App() { onRefresh={() => refreshSingleUsage(account.id)} onRename={(newName) => renameAccount(account.id, newName)} switching={switchingId === account.id} - switchDisabled={hasRunningProcesses ?? false} + switchDisabled={false} warmingUp={isWarmingAll || warmingUpId === account.id} masked={maskedAccounts.has(account.id)} onToggleMask={() => toggleMask(account.id)} diff --git a/src/hooks/useAccounts.ts b/src/hooks/useAccounts.ts index bcda97b..b5155e3 100644 --- a/src/hooks/useAccounts.ts +++ b/src/hooks/useAccounts.ts @@ -96,9 +96,9 @@ export function useAccounts() { }, []); const switchAccount = useCallback( - async (accountId: string) => { + async (accountId: string, restartRunningCodex?: boolean) => { try { - await invoke("switch_account", { accountId }); + await invoke("switch_account", { accountId, restartRunningCodex }); await loadAccounts(true); // Preserve usage data } catch (err) { throw err;