diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 94b401cd4..ba47ecce3 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -129,7 +129,7 @@ dependencies = [ "futures-lite", "parking", "polling", - "rustix 1.1.3", + "rustix", "slab", "windows-sys 0.61.2", ] @@ -160,7 +160,7 @@ dependencies = [ "cfg-if", "event-listener", "futures-lite", - "rustix 1.1.3", + "rustix", ] [[package]] @@ -186,7 +186,7 @@ dependencies = [ "cfg-if", "futures-core", "futures-io", - "rustix 1.1.3", + "rustix", "signal-hook-registry", "slab", "windows-sys 0.61.2", @@ -256,29 +256,6 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" -[[package]] -name = "bindgen" -version = "0.69.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "271383c67ccabffb7381723dea0672a673f292304fcb45c01cc648c7a8d58088" -dependencies = [ - "bitflags 2.11.0", - "cexpr", - "clang-sys", - "itertools 0.12.1", - "lazy_static", - "lazycell", - "log", - "prettyplease", - "proc-macro2", - "quote", - "regex", - "rustc-hash 1.1.0", - "shlex", - "syn 2.0.114", - "which", -] - [[package]] name = "bindgen" version = "0.72.1" @@ -288,11 +265,13 @@ dependencies = [ "bitflags 2.11.0", "cexpr", "clang-sys", - "itertools 0.13.0", + "itertools", + "log", + "prettyplease", "proc-macro2", "quote", "regex", - "rustc-hash 2.1.1", + "rustc-hash", "shlex", "syn 2.0.114", ] @@ -734,7 +713,7 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ceec7a6067e62d6f931a2baf6f3a751f4a892595bcec1461a3c94ef9949864b6" dependencies = [ - "bindgen 0.72.1", + "bindgen", ] [[package]] @@ -2125,15 +2104,6 @@ dependencies = [ "once_cell", ] -[[package]] -name = "itertools" -version = "0.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba291022dbbd398a455acf126c1e341954079855bc60dfdda641363bd6922569" -dependencies = [ - "either", -] - [[package]] name = "itertools" version = "0.13.0" @@ -2265,12 +2235,6 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" -[[package]] -name = "lazycell" -version = "1.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "830d08ce1d1d941e6b30645f1a0eb5643013d835ce3779a5fc208261dbe10f55" - [[package]] name = "libappindicator" version = "0.9.0" @@ -2372,12 +2336,6 @@ dependencies = [ "vcpkg", ] -[[package]] -name = "linux-raw-sys" -version = "0.4.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" - [[package]] name = "linux-raw-sys" version = "0.11.0" @@ -3408,7 +3366,7 @@ dependencies = [ "concurrent-queue", "hermit-abi", "pin-project-lite", - "rustix 1.1.3", + "rustix", "windows-sys 0.61.2", ] @@ -3579,7 +3537,7 @@ dependencies = [ "pin-project-lite", "quinn-proto", "quinn-udp", - "rustc-hash 2.1.1", + "rustc-hash", "rustls", "socket2", "thiserror 2.0.18", @@ -3599,7 +3557,7 @@ dependencies = [ "lru-slab", "rand 0.9.2", "ring", - "rustc-hash 2.1.1", + "rustc-hash", "rustls", "rustls-pki-types", "slab", @@ -3950,12 +3908,6 @@ dependencies = [ "windows-sys 0.52.0", ] -[[package]] -name = "rustc-hash" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" - [[package]] name = "rustc-hash" version = "2.1.1" @@ -3971,19 +3923,6 @@ dependencies = [ "semver", ] -[[package]] -name = "rustix" -version = "0.38.44" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" -dependencies = [ - "bitflags 2.11.0", - "errno", - "libc", - "linux-raw-sys 0.4.15", - "windows-sys 0.59.0", -] - [[package]] name = "rustix" version = "1.1.3" @@ -3993,7 +3932,7 @@ dependencies = [ "bitflags 2.11.0", "errno", "libc", - "linux-raw-sys 0.11.0", + "linux-raw-sys", "windows-sys 0.61.2", ] @@ -5160,7 +5099,7 @@ dependencies = [ "fastrand", "getrandom 0.3.4", "once_cell", - "rustix 1.1.3", + "rustix", "windows-sys 0.61.2", ] @@ -5984,37 +5923,26 @@ dependencies = [ "windows-core 0.61.2", ] -[[package]] -name = "which" -version = "4.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "87ba24419a2078cd2b0f2ede2691b6c66d8e47836da3b6db8265ebad47afbfc7" -dependencies = [ - "either", - "home", - "once_cell", - "rustix 0.38.44", -] - [[package]] name = "whisper-rs" -version = "0.12.0" +version = "0.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c597ac8a9d5c4719fee232abc871da184ea50a4fea38d2d00348fd95072b2b0" +checksum = "2088172d00f936c348d6a72f488dc2660ab3f507263a195df308a3c2383229f6" dependencies = [ "whisper-rs-sys", ] [[package]] name = "whisper-rs-sys" -version = "0.10.0" +version = "0.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d22f00ed0995463eecc34ef89905845f6bf6fd37ea70789fed180520050da8f8" +checksum = "6986c0fe081241d391f09b9a071fbcbb59720c3563628c3c829057cf69f2a56f" dependencies = [ - "bindgen 0.69.5", + "bindgen", "cfg-if", "cmake", "fs_extra", + "semver", ] [[package]] @@ -6602,7 +6530,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156" dependencies = [ "libc", - "rustix 1.1.3", + "rustix", ] [[package]] @@ -6650,7 +6578,7 @@ dependencies = [ "hex", "libc", "ordered-stream", - "rustix 1.1.3", + "rustix", "serde", "serde_repr", "tracing", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 44a153820..71833927a 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -47,10 +47,12 @@ toml_edit = "0.20.2" [target."cfg(not(any(target_os = \"android\", target_os = \"ios\")))".dependencies] tauri-plugin-updater = "2.10.0" tauri-plugin-window-state = "2" +portable-pty = "0.8" + +[target."cfg(all(not(any(target_os = \"android\", target_os = \"ios\", target_os = \"windows\"))))".dependencies] cpal = "0.15" -whisper-rs = "0.12" +whisper-rs = "0.16" sha2 = "0.10" -portable-pty = "0.8" [target."cfg(target_os = \"macos\")".dependencies] objc2 = "0.6" diff --git a/src-tauri/src/codex/mod.rs b/src-tauri/src/codex/mod.rs index e55d1e9ee..c494de46e 100644 --- a/src-tauri/src/codex/mod.rs +++ b/src-tauri/src/codex/mod.rs @@ -750,6 +750,55 @@ pub(crate) async fn account_read( codex_core::account_read_core(&state.sessions, &state.workspaces, workspace_id).await } +#[tauri::command] +pub(crate) async fn saved_auth_profiles_list( + workspace_id: String, + state: State<'_, AppState>, + _app: AppHandle, +) -> Result { + if remote_backend::is_remote_mode(&*state).await { + return Err("Saved auth profiles are not supported in remote mode".to_string()); + } + + codex_core::saved_auth_profiles_list_core(&state.workspaces, workspace_id).await +} + +#[tauri::command] +pub(crate) async fn saved_auth_profile_sync_current( + workspace_id: String, + account: Option, + rate_limits: Option, + state: State<'_, AppState>, + _app: AppHandle, +) -> Result { + if remote_backend::is_remote_mode(&*state).await { + return Err("Saved auth profiles are not supported in remote mode".to_string()); + } + + codex_core::saved_auth_profile_sync_current_core( + &state.workspaces, + workspace_id, + account, + rate_limits, + ) + .await +} + +#[tauri::command] +pub(crate) async fn saved_auth_profile_activate( + workspace_id: String, + profile_id: String, + state: State<'_, AppState>, + _app: AppHandle, +) -> Result { + if remote_backend::is_remote_mode(&*state).await { + return Err("Saved auth profiles are not supported in remote mode".to_string()); + } + + codex_core::saved_auth_profile_activate_core(&state.workspaces, workspace_id, profile_id) + .await +} + #[tauri::command] pub(crate) async fn codex_login( workspace_id: String, diff --git a/src-tauri/src/dictation/mod.rs b/src-tauri/src/dictation/mod.rs index dec4e7697..774305629 100644 --- a/src-tauri/src/dictation/mod.rs +++ b/src-tauri/src/dictation/mod.rs @@ -1,5 +1,11 @@ -#[cfg_attr(any(target_os = "ios", target_os = "android"), path = "stub.rs")] -#[cfg_attr(not(any(target_os = "ios", target_os = "android")), path = "real.rs")] +#[cfg_attr( + any(target_os = "ios", target_os = "android", target_os = "windows"), + path = "stub.rs" +)] +#[cfg_attr( + not(any(target_os = "ios", target_os = "android", target_os = "windows")), + path = "real.rs" +)] mod imp; pub(crate) use imp::*; diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 83e9dacae..9be09c606 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -272,6 +272,9 @@ pub fn run() { codex::write_agent_config_toml, codex::account_rate_limits, codex::account_read, + codex::saved_auth_profiles_list, + codex::saved_auth_profile_sync_current, + codex::saved_auth_profile_activate, codex::codex_login, codex::codex_login_cancel, codex::skills_list, diff --git a/src-tauri/src/shared/account.rs b/src-tauri/src/shared/account.rs index 6ad987ef7..97bc1ee4c 100644 --- a/src-tauri/src/shared/account.rs +++ b/src-tauri/src/shared/account.rs @@ -1,14 +1,40 @@ use base64::Engine; -use serde_json::{Map, Value}; +use serde::{Deserialize, Serialize}; +use serde_json::{json, Map, Value}; use std::fs; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; +use std::time::{SystemTime, UNIX_EPOCH}; #[derive(Clone, Debug)] pub(crate) struct AuthAccount { + pub(crate) profile_id: String, pub(crate) email: Option, pub(crate) plan_type: Option, } +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct SavedAuthProfile { + pub(crate) id: String, + pub(crate) account_type: String, + pub(crate) email: Option, + pub(crate) plan_type: Option, + pub(crate) requires_openai_auth: Option, + pub(crate) rate_limits: Option, + pub(crate) updated_at: i64, + pub(crate) auth: Value, +} + +#[derive(Clone, Debug, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +struct SavedAuthProfilesStore { + active_profile_id: Option, + profiles: Vec, +} + +const AUTH_FILE_NAME: &str = "auth.json"; +const SAVED_AUTH_PROFILES_FILE_NAME: &str = "auth-profiles.json"; + pub(crate) fn build_account_response( response: Option, fallback: Option, @@ -62,9 +88,91 @@ pub(crate) fn build_account_response( pub(crate) fn read_auth_account(codex_home: Option) -> Option { let codex_home = codex_home?; - let auth_path = codex_home.join("auth.json"); + let auth_path = codex_home.join(AUTH_FILE_NAME); let data = fs::read(auth_path).ok()?; let auth_value: Value = serde_json::from_slice(&data).ok()?; + read_auth_account_from_value(&auth_value) +} + +pub(crate) fn list_saved_auth_profiles(codex_home: PathBuf) -> Result { + let store = load_saved_auth_profiles_store(&codex_home)?; + Ok(saved_auth_profiles_store_to_value(&store)) +} + +pub(crate) fn sync_saved_auth_profile( + codex_home: PathBuf, + account: Option, + rate_limits: Option, +) -> Result { + let auth_value = read_auth_value(&codex_home)?; + let auth_account = read_auth_account_from_value(&auth_value) + .ok_or_else(|| "Unable to identify the current auth profile".to_string())?; + + let mut store = load_saved_auth_profiles_store(&codex_home)?; + let synced_profile = SavedAuthProfile { + id: auth_account.profile_id.clone(), + account_type: normalize_account_type( + account + .as_ref() + .and_then(extract_account_map) + .as_ref() + .and_then(|map| map.get("type")) + .and_then(Value::as_str), + ) + .to_string(), + email: auth_account.email.clone(), + plan_type: account + .as_ref() + .and_then(extract_account_map) + .as_ref() + .and_then(|map| map.get("planType")) + .and_then(Value::as_str) + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) + .or(auth_account.plan_type.clone()), + requires_openai_auth: account.as_ref().and_then(extract_requires_openai_auth), + rate_limits: rate_limits.and_then(normalize_rate_limits_value), + updated_at: current_timestamp_ms(), + auth: auth_value, + }; + + upsert_saved_auth_profile(&mut store, synced_profile); + store.active_profile_id = Some(auth_account.profile_id); + save_saved_auth_profiles_store(&codex_home, &store)?; + + Ok(saved_auth_profiles_store_to_value(&store)) +} + +pub(crate) fn activate_saved_auth_profile( + codex_home: PathBuf, + profile_id: &str, +) -> Result { + let mut store = load_saved_auth_profiles_store(&codex_home)?; + let profile = store + .profiles + .iter() + .find(|entry| entry.id == profile_id) + .cloned() + .ok_or_else(|| "Saved auth profile not found".to_string())?; + + fs::create_dir_all(&codex_home) + .map_err(|err| format!("Failed to prepare CODEX_HOME at {}: {err}", codex_home.display()))?; + let auth_path = codex_home.join(AUTH_FILE_NAME); + let auth_bytes = serde_json::to_vec_pretty(&profile.auth) + .map_err(|err| format!("Failed to serialize saved auth profile: {err}"))?; + fs::write(&auth_path, auth_bytes) + .map_err(|err| format!("Failed to activate saved auth profile at {}: {err}", auth_path.display()))?; + + store.active_profile_id = Some(profile.id.clone()); + if let Some(existing_profile) = store.profiles.iter_mut().find(|entry| entry.id == profile.id) { + existing_profile.updated_at = current_timestamp_ms(); + } + save_saved_auth_profiles_store(&codex_home, &store)?; + + Ok(saved_auth_profiles_store_to_value(&store)) +} + +fn read_auth_account_from_value(auth_value: &Value) -> Option { let tokens = auth_value.get("tokens")?; let id_token = tokens .get("idToken") @@ -94,11 +202,116 @@ pub(crate) fn read_auth_account(codex_home: Option) -> Option Value { + let mut profiles = store.profiles.clone(); + profiles.sort_by(|left, right| { + let left_is_active = store.active_profile_id.as_deref() == Some(left.id.as_str()); + let right_is_active = store.active_profile_id.as_deref() == Some(right.id.as_str()); + right_is_active + .cmp(&left_is_active) + .then_with(|| right.updated_at.cmp(&left.updated_at)) + .then_with(|| left.email.cmp(&right.email)) + }); + + json!({ + "activeProfileId": store.active_profile_id, + "profiles": profiles.into_iter().map(|profile| { + json!({ + "id": profile.id, + "accountType": profile.account_type, + "email": profile.email, + "planType": profile.plan_type, + "requiresOpenaiAuth": profile.requires_openai_auth, + "rateLimits": profile.rate_limits, + "updatedAt": profile.updated_at, + }) + }).collect::>(), + }) +} + +fn upsert_saved_auth_profile(store: &mut SavedAuthProfilesStore, profile: SavedAuthProfile) { + if let Some(existing) = store.profiles.iter_mut().find(|entry| entry.id == profile.id) { + *existing = profile; + return; + } + store.profiles.push(profile); +} + +fn load_saved_auth_profiles_store(codex_home: &Path) -> Result { + let store_path = codex_home.join(SAVED_AUTH_PROFILES_FILE_NAME); + if !store_path.exists() { + return Ok(SavedAuthProfilesStore::default()); + } + let data = fs::read(&store_path) + .map_err(|err| format!("Failed to read saved auth profiles at {}: {err}", store_path.display()))?; + serde_json::from_slice(&data) + .map_err(|err| format!("Failed to parse saved auth profiles at {}: {err}", store_path.display())) +} + +fn save_saved_auth_profiles_store( + codex_home: &Path, + store: &SavedAuthProfilesStore, +) -> Result<(), String> { + fs::create_dir_all(codex_home) + .map_err(|err| format!("Failed to prepare CODEX_HOME at {}: {err}", codex_home.display()))?; + let store_path = codex_home.join(SAVED_AUTH_PROFILES_FILE_NAME); + let data = serde_json::to_vec_pretty(store) + .map_err(|err| format!("Failed to serialize saved auth profiles: {err}"))?; + fs::write(&store_path, data) + .map_err(|err| format!("Failed to write saved auth profiles at {}: {err}", store_path.display())) +} + +fn read_auth_value(codex_home: &Path) -> Result { + let auth_path = codex_home.join(AUTH_FILE_NAME); + let data = fs::read(&auth_path) + .map_err(|err| format!("Failed to read auth data at {}: {err}", auth_path.display()))?; + serde_json::from_slice(&data) + .map_err(|err| format!("Failed to parse auth data at {}: {err}", auth_path.display())) +} + +fn derive_profile_id(payload: &Value, email: Option<&str>, id_token: &str) -> String { + let subject = normalize_string(payload.get("sub")); + let source = if let Some(subject) = subject { + format!("sub:{subject}") + } else if let Some(email) = email { + format!("email:{}", email.to_ascii_lowercase()) + } else { + format!("token:{id_token}") + }; + + let encoded = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(source.as_bytes()); + let compact = encoded.chars().take(24).collect::(); + format!("profile-{compact}") +} + +fn normalize_account_type(value: Option<&str>) -> &'static str { + match value.unwrap_or_default().trim().to_ascii_lowercase().as_str() { + "chatgpt" => "chatgpt", + "apikey" => "apikey", + _ => "unknown", + } +} + +fn normalize_rate_limits_value(value: Value) -> Option { + match value { + Value::Object(map) => Some(Value::Object(map)), + _ => None, + } +} + +fn current_timestamp_ms() -> i64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|value| value.as_millis() as i64) + .unwrap_or_default() +} + fn extract_account_map(value: &Value) -> Option> { let account = value .get("account") @@ -154,6 +367,7 @@ mod tests { fn fallback_account() -> AuthAccount { AuthAccount { + profile_id: "profile-test".to_string(), email: Some("chatgpt@example.com".to_string()), plan_type: Some("plus".to_string()), } @@ -218,4 +432,22 @@ mod tests { Some("plus") ); } + + #[test] + fn read_auth_account_from_value_derives_stable_profile_id() { + let payload = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode( + br#"{"sub":"user-123","email":"user@example.com","https://api.openai.com/auth":{"chatgpt_plan_type":"pro"}}"#, + ); + let auth_value = json!({ + "tokens": { + "idToken": format!("header.{payload}.signature") + } + }); + + let account = read_auth_account_from_value(&auth_value).expect("expected account"); + + assert_eq!(account.email.as_deref(), Some("user@example.com")); + assert_eq!(account.plan_type.as_deref(), Some("pro")); + assert!(account.profile_id.starts_with("profile-")); + } } diff --git a/src-tauri/src/shared/codex_core.rs b/src-tauri/src/shared/codex_core.rs index a0f2a4ea6..ba14022b7 100644 --- a/src-tauri/src/shared/codex_core.rs +++ b/src-tauri/src/shared/codex_core.rs @@ -15,7 +15,10 @@ use crate::backend::app_server::WorkspaceSession; use crate::codex::config as codex_config; use crate::codex::home::{resolve_default_codex_home, resolve_workspace_codex_home}; use crate::rules; -use crate::shared::account::{build_account_response, read_auth_account}; +use crate::shared::account::{ + activate_saved_auth_profile, build_account_response, list_saved_auth_profiles, read_auth_account, + sync_saved_auth_profile, +}; use crate::types::WorkspaceEntry; const LOGIN_START_TIMEOUT: Duration = Duration::from_secs(30); @@ -650,6 +653,33 @@ pub(crate) async fn account_read_core( Ok(build_account_response(response, fallback)) } +pub(crate) async fn saved_auth_profiles_list_core( + workspaces: &Mutex>, + workspace_id: String, +) -> Result { + let codex_home = resolve_codex_home_for_workspace_core(workspaces, &workspace_id).await?; + list_saved_auth_profiles(codex_home) +} + +pub(crate) async fn saved_auth_profile_sync_current_core( + workspaces: &Mutex>, + workspace_id: String, + account: Option, + rate_limits: Option, +) -> Result { + let codex_home = resolve_codex_home_for_workspace_core(workspaces, &workspace_id).await?; + sync_saved_auth_profile(codex_home, account, rate_limits) +} + +pub(crate) async fn saved_auth_profile_activate_core( + workspaces: &Mutex>, + workspace_id: String, + profile_id: String, +) -> Result { + let codex_home = resolve_codex_home_for_workspace_core(workspaces, &workspace_id).await?; + activate_saved_auth_profile(codex_home, &profile_id) +} + pub(crate) async fn codex_login_core( sessions: &Mutex>>, codex_login_cancels: &Mutex>, diff --git a/src/features/app/components/MainApp.tsx b/src/features/app/components/MainApp.tsx index 1ce7cc587..c0e26281b 100644 --- a/src/features/app/components/MainApp.tsx +++ b/src/features/app/components/MainApp.tsx @@ -516,6 +516,9 @@ export default function MainApp() { threadSortKey: threadListSortKey, onThreadCodexMetadataDetected: handleThreadCodexMetadataDetected, }); + const activeRateLimits = activeWorkspaceId + ? rateLimitsByWorkspace[activeWorkspaceId] ?? null + : null; const { connectionState: remoteThreadConnectionState, reconnectLive } = useRemoteThreadLiveConnection({ backendMode: appSettings.backendMode, @@ -715,11 +718,16 @@ export default function MainApp() { const { activeAccount, accountSwitching, + savedProfiles, + savedProfilesLoading, + activatingProfileId, handleSwitchAccount, handleCancelSwitchAccount, + handleActivateSavedProfile, } = useAccountSwitching({ activeWorkspaceId, accountByWorkspace, + activeRateLimits, refreshAccountInfo, refreshAccountRateLimits, alertError, @@ -1063,9 +1071,6 @@ export default function MainApp() { getWorkspaceGroupName, }); - const activeRateLimits = activeWorkspaceId - ? rateLimitsByWorkspace[activeWorkspaceId] ?? null - : null; const { homeAccount, homeRateLimits, @@ -1620,8 +1625,12 @@ export default function MainApp() { homeRateLimits, homeAccount, accountSwitching, + savedProfiles, + savedProfilesLoading, + activatingProfileId, onSwitchAccount: handleSwitchAccount, onCancelSwitchAccount: handleCancelSwitchAccount, + onActivateSavedProfile: handleActivateSavedProfile, onDecision: handleApprovalDecision, onRemember: handleApprovalRemember, onUserInputSubmit: handleUserInputSubmit, diff --git a/src/features/app/components/Sidebar.test.tsx b/src/features/app/components/Sidebar.test.tsx index b9700b24a..f7c519e5e 100644 --- a/src/features/app/components/Sidebar.test.tsx +++ b/src/features/app/components/Sidebar.test.tsx @@ -34,8 +34,12 @@ const baseProps = { accountRateLimits: null, usageShowRemaining: false, accountInfo: null, + savedProfiles: [], + savedProfilesLoading: false, + activatingProfileId: null, onSwitchAccount: vi.fn(), onCancelSwitchAccount: vi.fn(), + onActivateSavedProfile: vi.fn(), accountSwitching: false, onOpenSettings: vi.fn(), onOpenDebug: vi.fn(), diff --git a/src/features/app/components/Sidebar.tsx b/src/features/app/components/Sidebar.tsx index 395d62010..e51ad10a7 100644 --- a/src/features/app/components/Sidebar.tsx +++ b/src/features/app/components/Sidebar.tsx @@ -2,6 +2,7 @@ import type { AccountSnapshot, RequestUserInputRequest, RateLimitSnapshot, + SavedAccountProfile, ThreadListOrganizeMode, ThreadListSortKey, ThreadSummary, @@ -121,8 +122,12 @@ type SidebarProps = { accountRateLimits: RateLimitSnapshot | null; usageShowRemaining: boolean; accountInfo: AccountSnapshot | null; + savedProfiles: SavedAccountProfile[]; + savedProfilesLoading: boolean; + activatingProfileId: string | null; onSwitchAccount: () => void; onCancelSwitchAccount: () => void; + onActivateSavedProfile: (profileId: string) => void; accountSwitching: boolean; onOpenSettings: () => void; onOpenDebug: () => void; @@ -182,8 +187,12 @@ export const Sidebar = memo(function Sidebar({ accountRateLimits, usageShowRemaining, accountInfo, + savedProfiles, + savedProfilesLoading, + activatingProfileId, onSwitchAccount, onCancelSwitchAccount, + onActivateSavedProfile, accountSwitching, onOpenSettings, onOpenDebug, @@ -1040,11 +1049,16 @@ export const Sidebar = memo(function Sidebar({ showAccountSwitcher={showAccountSwitcher} accountLabel={accountButtonLabel} accountActionLabel={accountActionLabel} + savedProfiles={savedProfiles} + savedProfilesLoading={savedProfilesLoading} + activatingProfileId={activatingProfileId} accountDisabled={accountSwitchDisabled} accountSwitching={accountSwitching} accountCancelDisabled={accountCancelDisabled} onSwitchAccount={onSwitchAccount} onCancelSwitchAccount={onCancelSwitchAccount} + onActivateSavedProfile={onActivateSavedProfile} + usageShowRemaining={usageShowRemaining} /> ); diff --git a/src/features/app/components/SidebarBottomRail.tsx b/src/features/app/components/SidebarBottomRail.tsx index e14576399..99a53ff89 100644 --- a/src/features/app/components/SidebarBottomRail.tsx +++ b/src/features/app/components/SidebarBottomRail.tsx @@ -2,7 +2,9 @@ import ScrollText from "lucide-react/dist/esm/icons/scroll-text"; import Settings from "lucide-react/dist/esm/icons/settings"; import User from "lucide-react/dist/esm/icons/user"; import X from "lucide-react/dist/esm/icons/x"; -import { useEffect } from "react"; +import { useEffect, useMemo, useState } from "react"; +import type { SavedAccountProfile } from "../../../types"; +import { getUsageLabels } from "../utils/usageLabels"; import { MenuTrigger, PopoverSurface, @@ -22,11 +24,16 @@ type SidebarBottomRailProps = { showAccountSwitcher: boolean; accountLabel: string; accountActionLabel: string; + savedProfiles: SavedAccountProfile[]; + savedProfilesLoading: boolean; + activatingProfileId: string | null; accountDisabled: boolean; accountSwitching: boolean; accountCancelDisabled: boolean; onSwitchAccount: () => void; onCancelSwitchAccount: () => void; + onActivateSavedProfile: (profileId: string) => void; + usageShowRemaining: boolean; }; type UsageRowProps = { @@ -52,6 +59,26 @@ function UsageRow({ label, percent, resetLabel }: UsageRowProps) { ); } +function formatSavedProfileUsage( + profile: SavedAccountProfile, + usageShowRemaining: boolean, +): string | null { + const usage = getUsageLabels(profile.rateLimits, usageShowRemaining); + const parts: string[] = []; + + if (usage.sessionPercent !== null) { + parts.push(`Session ${usage.sessionPercent}%`); + } + if (usage.showWeekly && usage.weeklyPercent !== null) { + parts.push(`Weekly ${usage.weeklyPercent}%`); + } + if (usage.creditsLabel) { + parts.push(usage.creditsLabel.replace(/^Available credits:\s*/i, "Credits ")); + } + + return parts.length > 0 ? parts.join(" · ") : null; +} + export function SidebarBottomRail({ sessionPercent, weeklyPercent, @@ -65,13 +92,19 @@ export function SidebarBottomRail({ showAccountSwitcher, accountLabel, accountActionLabel, + savedProfiles, + savedProfilesLoading, + activatingProfileId, accountDisabled, accountSwitching, accountCancelDisabled, onSwitchAccount, onCancelSwitchAccount, + onActivateSavedProfile, + usageShowRemaining, }: SidebarBottomRailProps) { const accountMenu = useMenuController(); + const [savedProfilesOpen, setSavedProfilesOpen] = useState(false); const { isOpen: accountMenuOpen, containerRef: accountMenuRef, @@ -82,9 +115,21 @@ export function SidebarBottomRail({ useEffect(() => { if (!showAccountSwitcher) { closeAccountMenu(); + setSavedProfilesOpen(false); } }, [closeAccountMenu, showAccountSwitcher]); + useEffect(() => { + if (!accountMenuOpen) { + setSavedProfilesOpen(false); + } + }, [accountMenuOpen]); + + const selectableProfiles = useMemo( + () => savedProfiles.filter((profile) => !profile.isActive), + [savedProfiles], + ); + return (
@@ -159,6 +204,71 @@ export function SidebarBottomRail({ )}
+ + {savedProfilesOpen && ( +
+ {savedProfiles.map((profile) => { + const usageSummary = formatSavedProfileUsage( + profile, + usageShowRemaining, + ); + const label = + profile.email?.trim() || + (profile.accountType === "apikey" ? "API key" : "Saved account"); + const meta = [profile.planType, usageSummary] + .filter((value): value is string => Boolean(value?.trim())) + .join(" · "); + const isBusy = activatingProfileId === profile.id; + return ( + + ); + })} + {!savedProfilesLoading && selectableProfiles.length === 0 && ( +
+ Only the current login is saved right now. +
+ )} +
+ )} )}
diff --git a/src/features/app/hooks/useAccountSwitching.test.tsx b/src/features/app/hooks/useAccountSwitching.test.tsx index 4b124e452..a06f192e2 100644 --- a/src/features/app/hooks/useAccountSwitching.test.tsx +++ b/src/features/app/hooks/useAccountSwitching.test.tsx @@ -1,16 +1,26 @@ // @vitest-environment jsdom import { act } from "react"; import { createRoot } from "react-dom/client"; +import { waitFor } from "@testing-library/react"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import type { AppServerEvent, AccountSnapshot } from "../../../types"; -import { cancelCodexLogin, runCodexLogin } from "../../../services/tauri"; +import type { AppServerEvent, AccountSnapshot, RateLimitSnapshot } from "../../../types"; +import { + activateSavedAuthProfile, + cancelCodexLogin, + listSavedAuthProfiles, + runCodexLogin, + syncCurrentSavedAuthProfile, +} from "../../../services/tauri"; import { subscribeAppServerEvents } from "../../../services/events"; import { openUrl } from "@tauri-apps/plugin-opener"; import { useAccountSwitching } from "./useAccountSwitching"; vi.mock("../../../services/tauri", () => ({ + activateSavedAuthProfile: vi.fn(), runCodexLogin: vi.fn(), cancelCodexLogin: vi.fn(), + listSavedAuthProfiles: vi.fn(), + syncCurrentSavedAuthProfile: vi.fn(), })); vi.mock("../../../services/events", () => ({ @@ -38,6 +48,15 @@ beforeEach(() => { listener = null; latest = null; unlisten.mockReset(); + vi.mocked(listSavedAuthProfiles).mockResolvedValue({ activeProfileId: null, profiles: [] }); + vi.mocked(syncCurrentSavedAuthProfile).mockResolvedValue({ + activeProfileId: null, + profiles: [], + }); + vi.mocked(activateSavedAuthProfile).mockResolvedValue({ + activeProfileId: null, + profiles: [], + }); vi.mocked(subscribeAppServerEvents).mockImplementation((cb) => { listener = cb; return unlisten; @@ -69,6 +88,19 @@ function makeAccount(): AccountSnapshot { }; } +function makeRateLimits(): RateLimitSnapshot { + return { + primary: { + usedPercent: 40, + windowDurationMins: 300, + resetsAt: 1_900_000_000, + }, + secondary: null, + credits: null, + planType: "pro", + }; +} + describe("useAccountSwitching", () => { it("opens the auth URL and refreshes after account/login/completed", async () => { vi.mocked(runCodexLogin).mockResolvedValue({ @@ -83,6 +115,7 @@ describe("useAccountSwitching", () => { const { root } = await mount({ activeWorkspaceId: "ws-1", accountByWorkspace: { "ws-1": makeAccount() }, + activeRateLimits: makeRateLimits(), refreshAccountInfo, refreshAccountRateLimits, alertError, @@ -135,6 +168,7 @@ describe("useAccountSwitching", () => { const { root } = await mount({ activeWorkspaceId: "ws-1", accountByWorkspace: { "ws-1": makeAccount() }, + activeRateLimits: makeRateLimits(), refreshAccountInfo, refreshAccountRateLimits, alertError, @@ -187,6 +221,7 @@ describe("useAccountSwitching", () => { const { root } = await mount({ activeWorkspaceId: "ws-1", accountByWorkspace: { "ws-1": makeAccount() }, + activeRateLimits: makeRateLimits(), refreshAccountInfo, refreshAccountRateLimits, alertError, @@ -228,6 +263,7 @@ describe("useAccountSwitching", () => { const { root } = await mount({ activeWorkspaceId: "ws-1", accountByWorkspace: { "ws-1": makeAccount() }, + activeRateLimits: makeRateLimits(), refreshAccountInfo, refreshAccountRateLimits, alertError, @@ -260,6 +296,7 @@ describe("useAccountSwitching", () => { const { root, render } = await mount({ activeWorkspaceId: "ws-1", accountByWorkspace: { "ws-1": makeAccount() }, + activeRateLimits: makeRateLimits(), refreshAccountInfo, refreshAccountRateLimits, alertError, @@ -273,6 +310,7 @@ describe("useAccountSwitching", () => { await render({ activeWorkspaceId: "ws-2", accountByWorkspace: { "ws-1": makeAccount(), "ws-2": makeAccount() }, + activeRateLimits: makeRateLimits(), refreshAccountInfo, refreshAccountRateLimits, alertError, @@ -297,4 +335,87 @@ describe("useAccountSwitching", () => { root.unmount(); }); }); + + it("loads and activates a saved profile", async () => { + const profilesResponse = { + activeProfileId: "profile-1", + profiles: [ + { + id: "profile-1", + accountType: "chatgpt", + email: "one@example.com", + planType: "pro", + requiresOpenaiAuth: true, + rateLimits: makeRateLimits(), + updatedAt: 1, + }, + { + id: "profile-2", + accountType: "chatgpt", + email: "two@example.com", + planType: "plus", + requiresOpenaiAuth: true, + rateLimits: makeRateLimits(), + updatedAt: 2, + }, + ], + }; + vi.mocked(listSavedAuthProfiles).mockResolvedValue(profilesResponse); + vi.mocked(syncCurrentSavedAuthProfile).mockResolvedValue(profilesResponse); + vi.mocked(activateSavedAuthProfile).mockResolvedValue({ + activeProfileId: "profile-2", + profiles: [ + { + id: "profile-1", + accountType: "chatgpt", + email: "one@example.com", + planType: "pro", + requiresOpenaiAuth: true, + rateLimits: makeRateLimits(), + updatedAt: 1, + }, + { + id: "profile-2", + accountType: "chatgpt", + email: "two@example.com", + planType: "plus", + requiresOpenaiAuth: true, + rateLimits: makeRateLimits(), + updatedAt: 2, + }, + ], + }); + + const refreshAccountInfo = vi.fn().mockResolvedValue(undefined); + const refreshAccountRateLimits = vi.fn().mockResolvedValue(undefined); + const alertError = vi.fn(); + + const { root } = await mount({ + activeWorkspaceId: "ws-1", + accountByWorkspace: { "ws-1": makeAccount() }, + activeRateLimits: makeRateLimits(), + refreshAccountInfo, + refreshAccountRateLimits, + alertError, + }); + + await waitFor(() => { + expect(listSavedAuthProfiles).toHaveBeenCalledWith("ws-1"); + expect(latest?.savedProfiles).toHaveLength(2); + }); + expect(latest?.savedProfiles[0]?.isActive).toBe(true); + + await act(async () => { + await latest?.handleActivateSavedProfile("profile-2"); + }); + + expect(activateSavedAuthProfile).toHaveBeenCalledWith("ws-1", "profile-2"); + expect(refreshAccountInfo).toHaveBeenCalledWith("ws-1"); + expect(refreshAccountRateLimits).toHaveBeenCalledWith("ws-1"); + expect(alertError).not.toHaveBeenCalled(); + + await act(async () => { + root.unmount(); + }); + }); }); diff --git a/src/features/app/hooks/useAccountSwitching.ts b/src/features/app/hooks/useAccountSwitching.ts index 8e42d2e85..b9aa6ab23 100644 --- a/src/features/app/hooks/useAccountSwitching.ts +++ b/src/features/app/hooks/useAccountSwitching.ts @@ -1,13 +1,20 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { cancelCodexLogin, runCodexLogin } from "../../../services/tauri"; +import { + activateSavedAuthProfile, + cancelCodexLogin, + listSavedAuthProfiles, + runCodexLogin, + syncCurrentSavedAuthProfile, +} from "../../../services/tauri"; import { subscribeAppServerEvents } from "../../../services/events"; -import type { AccountSnapshot } from "../../../types"; +import type { AccountSnapshot, RateLimitSnapshot, SavedAccountProfile } from "../../../types"; import { getAppServerParams, getAppServerRawMethod } from "../../../utils/appServerEvents"; import { openUrl } from "@tauri-apps/plugin-opener"; type UseAccountSwitchingArgs = { activeWorkspaceId: string | null; accountByWorkspace: Record; + activeRateLimits: RateLimitSnapshot | null; refreshAccountInfo: (workspaceId: string) => Promise | void; refreshAccountRateLimits: (workspaceId: string) => Promise | void; alertError: (error: unknown) => void; @@ -16,18 +23,127 @@ type UseAccountSwitchingArgs = { type UseAccountSwitchingResult = { activeAccount: AccountSnapshot | null; accountSwitching: boolean; + savedProfiles: SavedAccountProfile[]; + savedProfilesLoading: boolean; + activatingProfileId: string | null; handleSwitchAccount: () => Promise; handleCancelSwitchAccount: () => Promise; + handleActivateSavedProfile: (profileId: string) => Promise; }; +function hasUsableAccountSnapshot(account: AccountSnapshot | null | undefined): boolean { + if (!account) { + return false; + } + return ( + account.type !== "unknown" || + Boolean(account.email?.trim()) || + Boolean(account.planType?.trim()) + ); +} + +function hasUsableRateLimitSnapshot(rateLimits: RateLimitSnapshot | null | undefined): boolean { + if (!rateLimits) { + return false; + } + const balance = rateLimits.credits?.balance?.trim() ?? ""; + return ( + rateLimits.primary !== null || + rateLimits.secondary !== null || + Boolean(rateLimits.planType?.trim()) || + Boolean( + rateLimits.credits && + (rateLimits.credits.hasCredits || + rateLimits.credits.unlimited || + balance.length > 0), + ) + ); +} + +function normalizeAccountType(value: unknown): SavedAccountProfile["accountType"] { + const normalized = typeof value === "string" ? value.trim().toLowerCase() : ""; + if (normalized === "chatgpt" || normalized === "apikey") { + return normalized; + } + return "unknown"; +} + +function normalizeSavedProfiles( + response: Record | null, +): SavedAccountProfile[] { + const activeProfileId = + typeof response?.activeProfileId === "string" ? response.activeProfileId : null; + const profiles = Array.isArray(response?.profiles) ? response.profiles : []; + + return profiles + .map((entry) => { + if (!entry || typeof entry !== "object") { + return null; + } + const profile = entry as Record; + const rateLimitsRaw = + profile.rateLimits && typeof profile.rateLimits === "object" + ? (profile.rateLimits as RateLimitSnapshot) + : null; + return { + id: typeof profile.id === "string" ? profile.id : "", + accountType: normalizeAccountType(profile.accountType), + email: typeof profile.email === "string" ? profile.email.trim() || null : null, + planType: + typeof profile.planType === "string" ? profile.planType.trim() || null : null, + requiresOpenaiAuth: + typeof profile.requiresOpenaiAuth === "boolean" + ? profile.requiresOpenaiAuth + : null, + rateLimits: rateLimitsRaw, + updatedAt: + typeof profile.updatedAt === "number" && Number.isFinite(profile.updatedAt) + ? profile.updatedAt + : null, + isActive: typeof profile.id === "string" && profile.id === activeProfileId, + } satisfies SavedAccountProfile; + }) + .filter((profile): profile is SavedAccountProfile => Boolean(profile?.id)); +} + +function accountToPayload(account: AccountSnapshot | null): Record | null { + if (!account || !hasUsableAccountSnapshot(account)) { + return null; + } + return { + type: account.type, + email: account.email, + planType: account.planType, + requiresOpenaiAuth: account.requiresOpenaiAuth, + }; +} + +function rateLimitsToPayload( + rateLimits: RateLimitSnapshot | null, +): Record | null { + if (!rateLimits || !hasUsableRateLimitSnapshot(rateLimits)) { + return null; + } + return { + primary: rateLimits.primary, + secondary: rateLimits.secondary, + credits: rateLimits.credits, + planType: rateLimits.planType, + }; +} + export function useAccountSwitching({ activeWorkspaceId, accountByWorkspace, + activeRateLimits, refreshAccountInfo, refreshAccountRateLimits, alertError, }: UseAccountSwitchingArgs): UseAccountSwitchingResult { const [accountSwitching, setAccountSwitching] = useState(false); + const [savedProfiles, setSavedProfiles] = useState([]); + const [savedProfilesLoading, setSavedProfilesLoading] = useState(false); + const [activatingProfileId, setActivatingProfileId] = useState(null); const accountSwitchCanceledRef = useRef(false); const loginIdRef = useRef(null); const loginWorkspaceIdRef = useRef(null); @@ -44,6 +160,16 @@ export function useAccountSwitching({ return accountByWorkspace[activeWorkspaceId] ?? null; }, [activeWorkspaceId, accountByWorkspace]); + const syncFingerprint = useMemo( + () => + JSON.stringify({ + workspaceId: activeWorkspaceId, + account: accountToPayload(activeAccount), + rateLimits: rateLimitsToPayload(activeRateLimits), + }), + [activeWorkspaceId, activeAccount, activeRateLimits], + ); + const isCodexLoginCanceled = useCallback((error: unknown) => { const message = typeof error === "string" ? error : error instanceof Error ? error.message : ""; @@ -75,6 +201,18 @@ export function useAccountSwitching({ alertErrorRef.current = alertError; }, [alertError]); + const reloadSavedProfiles = useCallback(async (workspaceId: string) => { + setSavedProfilesLoading(true); + try { + const response = await listSavedAuthProfiles(workspaceId); + setSavedProfiles(normalizeSavedProfiles(response)); + } catch (error) { + alertErrorRef.current(error); + } finally { + setSavedProfilesLoading(false); + } + }, []); + useEffect(() => { const currentWorkspaceId = activeWorkspaceId; const inFlightWorkspaceId = loginWorkspaceIdRef.current; @@ -90,6 +228,41 @@ export function useAccountSwitching({ } }, [activeWorkspaceId]); + useEffect(() => { + if (!activeWorkspaceId) { + setSavedProfiles([]); + setSavedProfilesLoading(false); + return; + } + void reloadSavedProfiles(activeWorkspaceId); + }, [activeWorkspaceId, reloadSavedProfiles]); + + useEffect(() => { + if (!activeWorkspaceId) { + return; + } + const accountPayload = accountToPayload(activeAccount); + const rateLimitsPayload = rateLimitsToPayload(activeRateLimits); + if (!accountPayload && !rateLimitsPayload) { + return; + } + let canceled = false; + void syncCurrentSavedAuthProfile(activeWorkspaceId, accountPayload, rateLimitsPayload) + .then((response) => { + if (canceled) { + return; + } + setSavedProfiles(normalizeSavedProfiles(response)); + }) + .catch(() => { + // Some workspaces may not have auth.json yet; avoid noisy errors here. + }); + + return () => { + canceled = true; + }; + }, [activeWorkspaceId, syncFingerprint, activeAccount, activeRateLimits]); + useEffect(() => { const unlisten = subscribeAppServerEvents((payload) => { const matchWorkspaceId = loginWorkspaceIdRef.current ?? activeWorkspaceIdRef.current; @@ -217,10 +390,51 @@ export function useAccountSwitching({ } }, [activeWorkspaceId, alertError]); + const handleActivateSavedProfile = useCallback( + async (profileId: string) => { + if (!activeWorkspaceId || !profileId || accountSwitching || activatingProfileId) { + return; + } + const existingProfile = savedProfiles.find((profile) => profile.id === profileId); + if (existingProfile?.isActive) { + return; + } + + setActivatingProfileId(profileId); + try { + const response = await activateSavedAuthProfile(activeWorkspaceId, profileId); + setSavedProfiles(normalizeSavedProfiles(response)); + await Promise.all([ + refreshAccountInfo(activeWorkspaceId), + refreshAccountRateLimits(activeWorkspaceId), + ]); + await reloadSavedProfiles(activeWorkspaceId); + } catch (error) { + alertError(error); + } finally { + setActivatingProfileId(null); + } + }, + [ + activeWorkspaceId, + accountSwitching, + activatingProfileId, + alertError, + refreshAccountInfo, + refreshAccountRateLimits, + reloadSavedProfiles, + savedProfiles, + ], + ); + return { activeAccount, accountSwitching, + savedProfiles, + savedProfilesLoading, + activatingProfileId, handleSwitchAccount, handleCancelSwitchAccount, + handleActivateSavedProfile, }; } diff --git a/src/features/app/hooks/useMainAppLayoutSurfaces.ts b/src/features/app/hooks/useMainAppLayoutSurfaces.ts index b6ac05279..7182c7025 100644 --- a/src/features/app/hooks/useMainAppLayoutSurfaces.ts +++ b/src/features/app/hooks/useMainAppLayoutSurfaces.ts @@ -61,8 +61,12 @@ type UseMainAppLayoutSurfacesArgs = { homeRateLimits: LayoutNodesOptions["primary"]["homeProps"]["accountRateLimits"]; homeAccount: LayoutNodesOptions["primary"]["homeProps"]["accountInfo"]; accountSwitching: SidebarProps["accountSwitching"]; + savedProfiles: SidebarProps["savedProfiles"]; + savedProfilesLoading: SidebarProps["savedProfilesLoading"]; + activatingProfileId: SidebarProps["activatingProfileId"]; onSwitchAccount: SidebarProps["onSwitchAccount"]; onCancelSwitchAccount: SidebarProps["onCancelSwitchAccount"]; + onActivateSavedProfile: SidebarProps["onActivateSavedProfile"]; onDecision: LayoutNodesOptions["primary"]["approvalToastsProps"]["onDecision"]; onRemember: LayoutNodesOptions["primary"]["approvalToastsProps"]["onRemember"]; onUserInputSubmit: LayoutNodesOptions["primary"]["messagesProps"]["onUserInputSubmit"]; @@ -264,8 +268,12 @@ function buildPrimarySurface({ homeRateLimits, homeAccount, accountSwitching, + savedProfiles, + savedProfilesLoading, + activatingProfileId, onSwitchAccount, onCancelSwitchAccount, + onActivateSavedProfile, onDecision, onRemember, onUserInputSubmit, @@ -401,8 +409,12 @@ function buildPrimarySurface({ accountRateLimits: sidebarRateLimits, usageShowRemaining: appSettings.usageShowRemaining, accountInfo: sidebarAccount, + savedProfiles, + savedProfilesLoading, + activatingProfileId, onSwitchAccount, onCancelSwitchAccount, + onActivateSavedProfile, accountSwitching, onOpenSettings: sidebarHandlers.onOpenSettings, onOpenDebug: handleDebugClick, @@ -971,8 +983,12 @@ export function useMainAppLayoutSurfaces({ homeRateLimits, homeAccount, accountSwitching, + savedProfiles, + savedProfilesLoading, + activatingProfileId, onSwitchAccount, onCancelSwitchAccount, + onActivateSavedProfile, onDecision, onRemember, onUserInputSubmit, @@ -1133,8 +1149,12 @@ export function useMainAppLayoutSurfaces({ homeRateLimits, homeAccount, accountSwitching, + savedProfiles, + savedProfilesLoading, + activatingProfileId, onSwitchAccount, onCancelSwitchAccount, + onActivateSavedProfile, onDecision, onRemember, onUserInputSubmit, diff --git a/src/services/tauri.ts b/src/services/tauri.ts index 029e0d31b..95d230366 100644 --- a/src/services/tauri.ts +++ b/src/services/tauri.ts @@ -768,6 +768,32 @@ export async function getAccountInfo(workspaceId: string) { return invoke("account_read", { workspaceId }); } +export async function listSavedAuthProfiles(workspaceId: string) { + return invoke("saved_auth_profiles_list", { workspaceId }); +} + +export async function syncCurrentSavedAuthProfile( + workspaceId: string, + account?: Record | null, + rateLimits?: Record | null, +) { + return invoke("saved_auth_profile_sync_current", { + workspaceId, + account: account ?? null, + rateLimits: rateLimits ?? null, + }); +} + +export async function activateSavedAuthProfile( + workspaceId: string, + profileId: string, +) { + return invoke("saved_auth_profile_activate", { + workspaceId, + profileId, + }); +} + export async function runCodexLogin(workspaceId: string) { return invoke<{ loginId: string; authUrl: string; raw?: unknown }>("codex_login", { workspaceId, diff --git a/src/styles/sidebar.css b/src/styles/sidebar.css index dd6da4ba2..24e49e994 100644 --- a/src/styles/sidebar.css +++ b/src/styles/sidebar.css @@ -1930,6 +1930,81 @@ height: 12px; } +.sidebar-account-saved-toggle { + width: 100%; + justify-content: center; + font-size: 11px; +} + +.sidebar-saved-profiles-list { + display: grid; + gap: 6px; + max-height: 220px; + overflow-y: auto; + padding-top: 2px; +} + +.sidebar-saved-profile { + width: 100%; + padding: 8px 10px; + border-radius: 10px; + border: 1px solid var(--border-quiet); + background: color-mix(in srgb, var(--surface-hover) 72%, transparent); + color: var(--text-muted); + display: grid; + gap: 4px; + text-align: left; +} + +.sidebar-saved-profile:hover, +.sidebar-saved-profile:focus-visible { + border-color: var(--border-subtle); + background: var(--surface-hover); + color: var(--text-stronger); + transform: none; +} + +.sidebar-saved-profile.is-active { + border-color: color-mix(in srgb, var(--border-accent) 36%, transparent); + background: color-mix(in srgb, var(--surface-active) 86%, transparent); + color: var(--text-stronger); +} + +.sidebar-saved-profile-head { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 8px; +} + +.sidebar-saved-profile-label { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 11px; + font-weight: 600; +} + +.sidebar-saved-profile-state { + flex-shrink: 0; + font-size: 10px; + color: var(--text-subtle); +} + +.sidebar-saved-profile-meta { + font-size: 10px; + line-height: 1.35; + color: var(--text-subtle); +} + +.sidebar-saved-profiles-empty { + padding: 6px 2px 0; + font-size: 10px; + line-height: 1.4; + color: var(--text-subtle); +} + .sidebar-utility-actions { display: inline-flex; flex-direction: row; diff --git a/src/types.ts b/src/types.ts index 51b1515c9..610ad2259 100644 --- a/src/types.ts +++ b/src/types.ts @@ -609,6 +609,17 @@ export type AccountSnapshot = { requiresOpenaiAuth: boolean | null; }; +export type SavedAccountProfile = { + id: string; + accountType: "chatgpt" | "apikey" | "unknown"; + email: string | null; + planType: string | null; + requiresOpenaiAuth: boolean | null; + rateLimits: RateLimitSnapshot | null; + updatedAt: number | null; + isActive: boolean; +}; + export type QueuedMessage = { id: string; text: string;