From 70b819f680318452e4468282a32d7581993abeb0 Mon Sep 17 00:00:00 2001 From: Hocopor <3dsmaxer@mail.ru> Date: Fri, 10 Apr 2026 20:32:37 +0300 Subject: [PATCH] Add saved account switching Add support for saving multiple login profiles and switching between them from the sidebar account menu. Temporarily disable Windows dictation in builds to unblock release packaging. Co-authored-by: Codex --- src-tauri/Cargo.lock | 114 ++------- src-tauri/Cargo.toml | 6 +- src-tauri/src/codex/mod.rs | 49 ++++ src-tauri/src/dictation/mod.rs | 10 +- src-tauri/src/lib.rs | 3 + src-tauri/src/shared/account.rs | 238 +++++++++++++++++- src-tauri/src/shared/codex_core.rs | 32 ++- src/features/app/components/MainApp.tsx | 15 +- src/features/app/components/Sidebar.test.tsx | 4 + src/features/app/components/Sidebar.tsx | 14 ++ .../app/components/SidebarBottomRail.tsx | 112 ++++++++- .../app/hooks/useAccountSwitching.test.tsx | 125 ++++++++- src/features/app/hooks/useAccountSwitching.ts | 218 +++++++++++++++- .../app/hooks/useMainAppLayoutSurfaces.ts | 20 ++ src/services/tauri.ts | 26 ++ src/styles/sidebar.css | 75 ++++++ src/types.ts | 11 + 17 files changed, 963 insertions(+), 109 deletions(-) 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;