diff --git a/CHANGELOG.md b/CHANGELOG.md index bf704c1d483..16c916c4e36 100755 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## [Windows] Unreleased + +- Show independent session and weekly usage for native saved Claude Code accounts, with account-scoped OAuth renewal and safe authentication warnings. +- Offer Refresh login beside Switch only for Claude and Codex accounts that need sign-in. Repair the selected saved login without switching to another account. +- Use expanded, collapsible Claude and Codex account sections with Add account below the rows; keep the floating bar's usage footprint when authentication expires. + ## [Windows] 0.70.0 - 2026-10-03 Windows port of upstream CodexBar **0.60.3 → 0.70.0**: every provider, cost/usage, privacy, tray, CLI and reliability port from upstream 0.60.4 through 0.70.0 integrated across the release branch, plus six community pull requests and fixes for open issues. This is also the first release to ship the changes in the unreleased 0.61.0 changelog section. diff --git a/apps/desktop-tauri/src-tauri/src/commands/claude_accounts.rs b/apps/desktop-tauri/src-tauri/src/commands/claude_accounts.rs index da3d6b90e47..8de0b21a223 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/claude_accounts.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/claude_accounts.rs @@ -345,7 +345,7 @@ fn changed(app: &tauri::AppHandle) { } #[tauri::command] -pub async fn claude_account_add(app: tauri::AppHandle) -> Result<(), String> { +pub async fn claude_account_add(app: tauri::AppHandle, id: Option) -> Result<(), String> { let _mutation = MUTATION .try_lock() .map_err(|_| "A Claude account operation is already in progress.")?; @@ -356,11 +356,14 @@ pub async fn claude_account_add(app: tauri::AppHandle) -> Result<(), String> { .map_err(|e| e.to_string())?; let _credentials = accounts::CREDENTIAL_OPERATION.lock().await; AccountManager::new() - .and_then(|m| m.import(login)) + .and_then(|m| match id.as_deref() { + Some(id) => m.reauthenticate(id, login), + None => m.import(login), + }) .map_err(|e| e.to_string())?; crate::auto_resume::clear(&app, ProviderId::Claude); - changed(&app); - Ok(()) + drop(_credentials); + refresh_after_claude_change(app).await.map(|_| ()) } #[tauri::command] diff --git a/apps/desktop-tauri/src-tauri/src/commands/claude_usage.rs b/apps/desktop-tauri/src-tauri/src/commands/claude_usage.rs new file mode 100644 index 00000000000..1ee24e7e24b --- /dev/null +++ b/apps/desktop-tauri/src-tauri/src/commands/claude_usage.rs @@ -0,0 +1,252 @@ +use std::collections::HashMap; +use std::sync::{Arc, Mutex}; + +use codexbar::core::{ProviderError, RateWindow, UsageSnapshot}; +use codexbar::providers::claude::accounts::{self, ClaudeAccount}; +use serde::{Deserialize, Serialize}; +use tauri::{Emitter, Manager}; + +use crate::state::AppState; + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ClaudeAccountWindow { + pub used_percent: f64, + pub resets_at: Option, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ClaudeAccountUsage { + pub five_hour: Option, + pub seven_day: Option, + pub updated_at: String, +} + +#[derive(Clone, Debug, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ClaudeAccountUsageState { + pub usage: Option, + pub usage_error: Option, + #[serde(default)] + pub needs_authentication: bool, +} + +#[derive(Serialize, Deserialize)] +pub struct ClaudeAccountState { + #[serde(flatten)] + pub account: ClaudeAccount, + #[serde(flatten)] + pub usage: ClaudeAccountUsageState, +} + +#[tauri::command] +pub fn get_claude_accounts_state( + state: tauri::State<'_, Mutex>, +) -> Result, String> { + if state + .lock() + .map_err(|e| e.to_string())? + .proof_config + .is_some() + && let Some(accounts) = crate::proof_harness::seed_claude_accounts_from_env() + { + return Ok(accounts); + } + let accounts = super::claude_accounts_list()?; + let allow_reading_credentials = + codexbar::settings::Settings::load().claude_allow_reading_claude_code_credentials; + let state = state.lock().map_err(|e| e.to_string())?; + Ok(accounts + .into_iter() + .map(|account| { + let mut usage = state + .claude_account_usage + .get(&account.id) + .cloned() + .unwrap_or_default(); + if !allow_reading_credentials { + usage = ClaudeAccountUsageState { usage: None, usage_error: Some( + "Enable Allow reading Claude Code's credentials in Settings to check saved accounts.".into() + ), needs_authentication: false }; + } + ClaudeAccountState { account, usage } + }) + .collect()) +} + +fn quota_window(window: &RateWindow) -> Option { + (!window.is_informational && window.used_percent.is_finite()).then(|| ClaudeAccountWindow { + used_percent: window.used_percent.clamp(0.0, 100.0), + resets_at: window.resets_at.map(|at| at.to_rfc3339()), + }) +} + +fn update_usage( + previous: &mut ClaudeAccountUsageState, + result: Result, +) { + match result { + Ok(usage) => { + previous.usage = Some(ClaudeAccountUsage { + five_hour: quota_window(&usage.primary), + seven_day: usage.secondary.as_ref().and_then(quota_window), + updated_at: usage.updated_at.to_rfc3339(), + }); + previous.usage_error = None; + previous.needs_authentication = false; + } + Err(error) => { + previous.needs_authentication = matches!( + error, + ProviderError::OAuthRevoked(_) + | ProviderError::OAuthExpired(_) + | ProviderError::AuthRequired + | ProviderError::NoCookies + ); + // Never expose response bodies or credentials through this bridge. + previous.usage_error = Some( + if previous.needs_authentication { + "Sign in again to check this Claude account." + } else { + "Claude usage check failed. Will retry automatically." + } + .into(), + ); + } + } +} + +fn publish_results( + state: &mut AppState, + generation: u64, + live_accounts: &[ClaudeAccount], + results: Vec<(String, Result)>, +) -> bool { + if state.provider_refresh_generation != generation { + return false; + } + let live_ids: HashMap<_, _> = live_accounts.iter().map(|a| (a.id.as_str(), a)).collect(); + state + .claude_account_usage + .retain(|id, _| live_ids.contains_key(id.as_str())); + for (id, result) in results { + if live_ids.contains_key(id.as_str()) { + update_usage(state.claude_account_usage.entry(id).or_default(), result); + } + } + true +} + +/// Share the provider refresh cadence and permits; each request uses only the +/// selected account's OAuth credentials and leaves the active CLI login intact. +pub(crate) async fn refresh_claude_account_lanes( + app: tauri::AppHandle, + permits: Arc, + generation: u64, +) { + if !codexbar::settings::Settings::load().claude_allow_reading_claude_code_credentials { + return; + } + let Ok(accounts) = super::claude_accounts_list() else { + return; + }; + let mut workers = Vec::new(); + for account in accounts { + let permits = Arc::clone(&permits); + workers.push(tokio::spawn(async move { + let Ok(_permit) = permits.acquire_owned().await else { + return None; + }; + // The worker owns the credential operation until renewal and + // persistence finish. Refresh rotation is never cancelled by a UI. + let result = accounts::fetch_usage(&account.id) + .await + .map(|result| result.usage); + Some((account.id, result)) + })); + } + let mut results = Vec::new(); + for worker in workers { + if let Ok(Some(result)) = worker.await { + results.push(result); + } + } + let Ok(accounts) = super::claude_accounts_list() else { + return; + }; + let state = app.state::>(); + let published = state + .lock() + .is_ok_and(|mut state| publish_results(&mut state, generation, &accounts, results)); + if published { + let _ = app.emit("claude-accounts-updated", ()); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn generic_api_errors_do_not_request_sign_in() { + let mut state = ClaudeAccountUsageState::default(); + update_usage( + &mut state, + Err(ProviderError::OAuth("API error 500".into())), + ); + assert!(!state.usage_error.unwrap().contains("Sign in")); + assert!(!state.needs_authentication); + } + + #[test] + fn failures_preserve_last_good_limits_and_recovery_clears_error() { + let mut state = ClaudeAccountUsageState::default(); + let usage = UsageSnapshot::new(RateWindow::new(23.0)); + update_usage(&mut state, Ok(usage.clone())); + update_usage( + &mut state, + Err(ProviderError::OAuthExpired("expired".into())), + ); + assert_eq!( + state + .usage + .as_ref() + .unwrap() + .five_hour + .as_ref() + .unwrap() + .used_percent, + 23.0 + ); + assert!(state.usage_error.as_ref().unwrap().contains("Sign in")); + assert!(state.needs_authentication); + update_usage(&mut state, Ok(usage)); + assert!(state.usage_error.is_none()); + assert!(!state.needs_authentication); + } + + #[test] + fn removed_accounts_and_superseded_generations_cannot_publish() { + let mut state = AppState::new(); + let generation = state.provider_refresh_generation; + state.provider_refresh_generation += 1; + assert!(!publish_results(&mut state, generation, &[], vec![])); + let generation = state.provider_refresh_generation; + assert!(publish_results( + &mut state, + generation, + &[], + vec![( + "removed".into(), + Ok(UsageSnapshot::new(RateWindow::new(10.0))) + )] + )); + assert!(state.claude_account_usage.is_empty()); + } + + #[test] + fn missing_session_is_unavailable_instead_of_zero_usage() { + assert!(quota_window(&RateWindow::no_active_session()).is_none()); + } +} diff --git a/apps/desktop-tauri/src-tauri/src/commands/codex_accounts.rs b/apps/desktop-tauri/src-tauri/src/commands/codex_accounts.rs index 848901c28d5..66d3fe9d319 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/codex_accounts.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/codex_accounts.rs @@ -181,6 +181,7 @@ pub(crate) async fn refresh_codex_account_lanes( .map(|account| (account.id, account)) .collect(); let mut updates = Vec::new(); + let mut authentication = Vec::new(); for (fetched_account, result) in outcomes { let Some(current) = current_by_id.get(&fetched_account.id).copied() else { continue; @@ -195,14 +196,18 @@ pub(crate) async fn refresh_codex_account_lanes( &fetched_account, FetchAttempt::Succeeded, ); + authentication.push((fetched_account.clone(), false)); updates.push((fetched_account, snapshot)); } Ok(_) => {} - Err(error) => observe_codex_account_lane_outcome( - &mut state.notification_manager, - &fetched_account, - FetchAttempt::Failed(error.state_kind()), - ), + Err(error) => { + authentication.push((fetched_account.clone(), error.state_kind().needs_sign_in())); + observe_codex_account_lane_outcome( + &mut state.notification_manager, + &fetched_account, + FetchAttempt::Failed(error.state_kind()), + ); + } } } match save_codex_lane_results(&state, generation, updates, ¤t_accounts) { @@ -210,9 +215,34 @@ pub(crate) async fn refresh_codex_account_lanes( Err(e) => tracing::warn!("codex account lanes: failed to persist snapshots: {e}"), Ok(true) => {} } + publish_codex_authentication(&mut state, generation, ¤t_accounts, authentication); events::emit_codex_accounts_updated(&app); } +fn publish_codex_authentication( + state: &mut AppState, + generation: u64, + live_accounts: &[CodexAccount], + results: Vec<(CodexAccount, bool)>, +) { + if !is_current_provider_refresh_generation(state, generation) { + return; + } + state + .codex_account_needs_authentication + .retain(|id, _| live_accounts.iter().any(|account| account.id == *id)); + for (fetched, needs_authentication) in results { + if live_accounts + .iter() + .any(|live| live.id == fetched.id && account_lane_is_current(&fetched, live)) + { + state + .codex_account_needs_authentication + .insert(fetched.id, needs_authentication); + } + } +} + fn observe_codex_account_lane_outcome( manager: &mut codexbar::notifications::NotificationManager, account: &CodexAccount, @@ -292,13 +322,16 @@ pub async fn codex_account_add(app: tauri::AppHandle) -> Result Result { +pub async fn codex_account_reauthenticate( + app: tauri::AppHandle, + id: Option, +) -> Result { let runtime = CodexAccountRuntime::new(); let _mutation = runtime.try_begin_mutation().map_err(into_user_message)?; - let target = ambient_account(&load_codex_accounts()?)?; + let target = reauthentication_target(&load_codex_accounts()?, id.as_deref())?; let manager = CodexAccountManager::new(); let authenticated = tauri::async_runtime::spawn_blocking(move || manager.reauthenticate(&target, None)) @@ -306,7 +339,7 @@ pub async fn codex_account_reauthenticate(app: tauri::AppHandle) -> Result>(); + let generation = state + .lock() + .map_err(|e| e.to_string())? + .provider_refresh_generation; + let result = tokio::time::timeout( std::time::Duration::from_secs(DEFAULT_FETCH_TIMEOUT_SECONDS), api.fetch_snapshot_for_workspace( &home_path, @@ -457,8 +495,25 @@ pub async fn codex_account_fetch( ), ) .await - .map_err(|_| "Timed out waiting for the Codex usage API.".to_string())? - .map_err(into_api_message)?; + .map_err(|_| "Timed out waiting for the Codex usage API.".to_string())?; + if let Ok(live_accounts) = load_codex_accounts() + && let Ok(mut state) = state.lock() + { + publish_codex_authentication( + &mut state, + generation, + &live_accounts, + vec![( + target.clone(), + result + .as_ref() + .err() + .is_some_and(|error| error.state_kind().needs_sign_in()), + )], + ); + } + events::emit_codex_accounts_updated(&app); + let snapshot = result.map_err(into_api_message)?; // Persist snapshot to the snapshot store, keyed by account id. if let Ok(mut snapshots) = SnapshotStore::new().load() @@ -536,6 +591,20 @@ fn ambient_account(accounts: &[CodexAccount]) -> Result { .ok_or_else(|| "No ambient Codex account found.".to_string()) } +fn reauthentication_target( + accounts: &[CodexAccount], + id: Option<&str>, +) -> Result { + match id { + None => ambient_account(accounts), + Some(id) => accounts + .iter() + .find(|account| account.id.to_string() == id) + .cloned() + .ok_or_else(|| "Codex account not found.".to_string()), + } +} + /// The account a reauthentication command should report. /// /// The persisted reconciled set is authoritative. A login that changes the @@ -546,10 +615,25 @@ fn ambient_account(accounts: &[CodexAccount]) -> Result { /// stores that contain no ambient record. /// When neither is present the login was never committed, so the command fails /// instead of exposing a dropped or replaced transient account. +/// Managed logins resolve within their own home, preferring the stable account +/// id when several saved workspace accounts share that home. fn canonical_reauthenticated_account( accounts: &[CodexAccount], authenticated: &CodexAccount, ) -> Result { + if authenticated.source.owns_files() { + let same_home = |account: &&CodexAccount| { + account.source.owns_files() + && account.standardized_home_path() == authenticated.standardized_home_path() + }; + return accounts + .iter() + .filter(same_home) + .find(|account| account.id == authenticated.id) + .or_else(|| accounts.iter().find(same_home)) + .cloned() + .ok_or_else(|| "Codex account login was not persisted.".to_string()); + } if let Some(account) = accounts .iter() .find(|account| account.source == codexbar::codex_accounts::CodexAccountSource::Ambient) @@ -648,326 +732,34 @@ pub struct CodexAccountsStateBridge { pub display_names: HashMap, pub account_ordinals: HashMap, pub snapshots: HashMap, + pub needs_authentication: HashMap, } #[tauri::command] pub fn get_codex_accounts_state( state: tauri::State<'_, Mutex>, ) -> Result { - let _guard = state.lock().map_err(|e| e.to_string())?; + // Disk reads first, so refresh lanes and the tray don't wait on them. let accounts = load_codex_accounts()?; let display_names = display_names_by_id(&accounts); let account_ordinals = ordinals_by_id(&accounts); let snapshots = snapshots_for_accounts(&accounts, codex_account_snapshots()?); + let guard = state.lock().map_err(|e| e.to_string())?; + let needs_authentication = guard + .codex_account_needs_authentication + .iter() + .filter(|(id, _)| accounts.iter().any(|account| account.id == **id)) + .map(|(id, needs)| (*id, *needs)) + .collect(); Ok(CodexAccountsStateBridge { accounts, display_names, account_ordinals, snapshots, + needs_authentication, }) } #[cfg(test)] -mod tests { - use super::*; - - #[test] - fn committed_switch_tolerates_unreadable_account_metadata() { - use codexbar::codex_accounts::file_locations; - let root = std::env::temp_dir().join(format!("codex-switch-metadata-{}", Uuid::new_v4())); - std::fs::create_dir_all(&root).unwrap(); - file_locations::with_app_support_directory(root.clone()); - let account_file = file_locations::accounts_file(); - std::fs::write(&account_file, "invalid account metadata").unwrap(); - // This post-commit operation cannot propagate an error to the switch - // command and skip the refresh/events that follow it. - persist_materialized_account(Some(&sample_account())); - assert_eq!( - std::fs::read_to_string(&account_file).unwrap(), - "invalid account metadata" - ); - file_locations::clear_app_support_directory_override(); - assert!(root.starts_with(std::env::temp_dir())); - std::fs::remove_dir_all(root).unwrap(); - } - - #[test] - fn superseded_lanes_cannot_overwrite_newer_snapshots() { - use codexbar::codex_accounts::{AccountUsageSnapshot, file_locations}; - let root = std::env::temp_dir().join(format!("codex-lane-generation-{}", Uuid::new_v4())); - std::fs::create_dir_all(&root).unwrap(); - file_locations::with_app_support_directory(root.clone()); - let mut state = AppState::new(); - let old_generation = state.provider_refresh_generation; - invalidate_account_usage(&mut state, ProviderId::Codex); - let id = Uuid::new_v4(); - let mut account = sample_account(); - account.id = id; - account.provider_account_id = Some("new".into()); - persist_codex_accounts(&[account.clone()]).unwrap(); - let snapshot = AccountUsageSnapshot { - email: Some("new@example.com".into()), - provider_account_id: Some("new".into()), - plan: None, - allowed: None, - limit_reached: None, - primary_window: None, - secondary_window: None, - credits: None, - cost: None, - subscription: None, - updated_at: codexbar::codex_accounts::utc_now(), - }; - assert!( - save_codex_lane_results( - &state, - state.provider_refresh_generation, - vec![(account.clone(), snapshot.clone())], - &[account.clone()] - ) - .unwrap() - ); - let before = std::fs::read(file_locations::snapshots_file()).unwrap(); - let stale = AccountUsageSnapshot { - email: Some("old@example.com".into()), - ..snapshot - }; - assert!( - !save_codex_lane_results( - &state, - old_generation, - vec![(account.clone(), stale)], - &[account] - ) - .unwrap() - ); - assert_eq!( - std::fs::read(file_locations::snapshots_file()).unwrap(), - before - ); - file_locations::clear_app_support_directory_override(); - assert!(root.starts_with(std::env::temp_dir())); - std::fs::remove_dir_all(root).unwrap(); - } - - #[test] - fn codex_switch_supersedes_inflight_usage_and_keeps_other_providers() { - let mut state = AppState::new(); - let other = invalidate_account_usage(&mut state, ProviderId::Claude); - let mut old = invalidate_account_usage(&mut state, ProviderId::Codex); - old.account_email = Some("old@example.com".into()); - old.primary.used_percent = 80.0; - old.error = None; - state.provider_cache = vec![other, old]; - state.is_refreshing = true; - let generation = state.provider_refresh_generation; - let pending = invalidate_account_usage(&mut state, ProviderId::Codex); - assert!(!is_current_provider_refresh_generation(&state, generation)); - assert!(!state.is_refreshing); - assert_eq!(state.provider_cache.len(), 2); - assert!( - state - .provider_cache - .iter() - .any(|s| s.provider_id == "claude") - ); - assert!(pending.account_email.is_none() && pending.error.is_some()); - assert_eq!(pending.primary.used_percent, 0.0); - } - - #[test] - fn reconciliation_replaces_changed_managed_identity_without_inheriting_metadata() { - let mut stale = sample_account(); - stale.nickname = Some("Former account".into()); - let mut fresh = sample_account(); - fresh.provider_account_id = Some("replacement".into()); - fresh.email_hint = Some("replacement@example.com".into()); - let accounts = reconcile_codex_accounts(&[stale.clone()], &[fresh.clone()], None); - assert_eq!(accounts.len(), 1); - assert_eq!(accounts[0].id, fresh.id); - assert_ne!(accounts[0].id, stale.id); - assert_eq!(accounts[0].nickname, None); - assert_eq!(accounts[0].email_hint, fresh.email_hint); - } - - #[test] - fn reconciliation_preserves_metadata_for_unchanged_managed_identity() { - let mut stored = sample_account(); - stored.nickname = Some("Work".into()); - let fresh = sample_account(); - let accounts = reconcile_codex_accounts(&[stored.clone()], &[fresh], None); - assert_eq!(accounts.len(), 1); - assert_eq!(accounts[0].id, stored.id); - assert_eq!(accounts[0].nickname, stored.nickname); - } - - fn sample_account() -> CodexAccount { - CodexAccount::new( - Uuid::new_v4(), - None, - Some("user@example.com".to_string()), - Some("auth0|acct".to_string()), - Some("acct".to_string()), - std::path::PathBuf::from("/tmp/fake-home"), - codexbar::codex_accounts::CodexAccountSource::ManagedByApp, - codexbar::codex_accounts::utc_now(), - codexbar::codex_accounts::utc_now(), - Some(codexbar::codex_accounts::utc_now()), - ) - } - - #[test] - fn into_user_message_preserves_friendly_text() { - assert_eq!( - into_user_message(CodexAccountManagerError::Message( - "The `codex` command could not be found.".to_string() - )), - "The `codex` command could not be found." - ); - } - - #[test] - fn ambient_account_selects_only_the_ambient_identity() { - let managed = sample_account(); - let mut ambient = managed.clone(); - ambient.source = codexbar::codex_accounts::CodexAccountSource::Ambient; - - let selected = ambient_account(&[managed, ambient.clone()]).unwrap(); - - assert_eq!(selected.id, ambient.id); - assert_eq!( - selected.source, - codexbar::codex_accounts::CodexAccountSource::Ambient - ); - } - - #[test] - fn ambient_account_reports_when_no_ambient_identity_exists() { - assert_eq!( - ambient_account(&[sample_account()]).unwrap_err(), - "No ambient Codex account found." - ); - } - - #[test] - fn reconciled_ambient_identity_change_replaces_the_login_result() { - let mut stored = sample_account(); - stored.source = codexbar::codex_accounts::CodexAccountSource::Ambient; - stored.provider_account_id = Some("old-workspace".into()); - stored.email_hint = Some("old@example.com".into()); - - // Logging in as a different identity at the same ambient home. - let mut fresh = sample_account(); - fresh.source = codexbar::codex_accounts::CodexAccountSource::Ambient; - fresh.provider_account_id = Some("new-workspace".into()); - fresh.email_hint = Some("new@example.com".into()); - - let reconciled = reconcile_codex_accounts(&[stored.clone()], &[], Some(fresh)); - assert_eq!(reconciled.len(), 1); - assert_ne!(reconciled[0].id, stored.id); - - // `reauthenticate` reuses the pre-login id; the command must report the - // reconciled record so it agrees with the persisted store and events. - let mut authenticated = stored.clone(); - authenticated.email_hint = Some("new@example.com".into()); - let account = canonical_reauthenticated_account(&reconciled, &authenticated).unwrap(); - assert_eq!(account.id, reconciled[0].id); - assert_ne!(account.id, authenticated.id); - assert_eq!( - account.source, - codexbar::codex_accounts::CodexAccountSource::Ambient - ); - assert_eq!( - account.provider_account_id.as_deref(), - Some("new-workspace") - ); - } - - #[test] - fn canonical_reauthenticated_account_returns_the_persisted_replacement() { - let mut authenticated = sample_account(); - authenticated.source = codexbar::codex_accounts::CodexAccountSource::Ambient; - authenticated.provider_account_id = Some("old-workspace".into()); - authenticated.email_hint = Some("old@example.com".into()); - - let mut persisted = sample_account(); - persisted.source = codexbar::codex_accounts::CodexAccountSource::Ambient; - persisted.provider_account_id = Some("new-workspace".into()); - persisted.email_hint = Some("new@example.com".into()); - - let account = - canonical_reauthenticated_account(&[persisted.clone()], &authenticated).unwrap(); - assert_eq!(account.id, persisted.id); - assert_ne!(account.id, authenticated.id); - assert_eq!( - account.provider_account_id.as_deref(), - Some("new-workspace") - ); - } - - #[test] - fn canonical_reauthenticated_account_returns_the_unchanged_persisted_reauth() { - let mut persisted = sample_account(); - persisted.source = codexbar::codex_accounts::CodexAccountSource::Ambient; - persisted.nickname = Some("Work".into()); - persisted.provider_account_id = Some("workspace".into()); - - // The login helper does not carry optional stored metadata. - let mut authenticated = persisted.clone(); - authenticated.nickname = None; - - let account = - canonical_reauthenticated_account(&[persisted.clone()], &authenticated).unwrap(); - assert_eq!(account.id, persisted.id); - assert_eq!(account.nickname.as_deref(), Some("Work")); - } - - #[test] - fn canonical_reauthenticated_account_prefers_ambient_over_matching_managed() { - let mut managed = sample_account(); - managed.provider_account_id = Some("shared-workspace".into()); - - let mut ambient = managed.clone(); - ambient.id = Uuid::new_v4(); - ambient.source = codexbar::codex_accounts::CodexAccountSource::Ambient; - - let account = canonical_reauthenticated_account(&[managed, ambient.clone()], &ambient) - .expect("persisted ambient account should be canonical"); - assert_eq!(account.id, ambient.id); - assert_eq!( - account.source, - codexbar::codex_accounts::CodexAccountSource::Ambient - ); - } - - #[test] - fn canonical_reauthenticated_account_does_not_return_a_dropped_login() { - let mut authenticated = sample_account(); - authenticated.source = codexbar::codex_accounts::CodexAccountSource::Ambient; - authenticated.provider_account_id = Some("dropped-workspace".into()); - authenticated.email_hint = Some("dropped@example.com".into()); - - // The reconciled set dropped the login identity and holds no ambient - // record to replace it with. - let error = - canonical_reauthenticated_account(&[sample_account()], &authenticated).unwrap_err(); - assert_eq!(error, "No ambient Codex account found."); - } - - #[test] - fn canonical_reauthenticated_account_rejects_an_uncommitted_persistence_failure() { - let authenticated = sample_account(); - - // A failed persistence leaves no committed reconciled set; the transient - // login result must not be surfaced in its place. - let error = canonical_reauthenticated_account(&[], &authenticated).unwrap_err(); - assert_eq!(error, "No ambient Codex account found."); - } - - #[test] - fn sample_account_serializes_camel_case() { - let json = serde_json::to_value(sample_account()).unwrap(); - assert!(json.get("codexHomePath").is_some()); - assert!(json.get("providerAccountId").is_some()); - } -} +#[path = "codex_accounts/tests.rs"] +mod tests; diff --git a/apps/desktop-tauri/src-tauri/src/commands/codex_accounts/tests.rs b/apps/desktop-tauri/src-tauri/src/commands/codex_accounts/tests.rs new file mode 100644 index 00000000000..0f19318f388 --- /dev/null +++ b/apps/desktop-tauri/src-tauri/src/commands/codex_accounts/tests.rs @@ -0,0 +1,358 @@ +use super::*; + +#[test] +fn authentication_status_recovers_and_rejects_stale_accounts() { + let mut state = AppState::new(); + let generation = state.provider_refresh_generation; + let account = sample_account(); + publish_codex_authentication( + &mut state, + generation, + std::slice::from_ref(&account), + vec![(account.clone(), true)], + ); + assert!(state.codex_account_needs_authentication[&account.id]); + publish_codex_authentication( + &mut state, + generation + 1, + std::slice::from_ref(&account), + vec![(account.clone(), false)], + ); + assert!(state.codex_account_needs_authentication[&account.id]); + publish_codex_authentication( + &mut state, + generation, + std::slice::from_ref(&account), + vec![(account.clone(), false)], + ); + assert!(!state.codex_account_needs_authentication[&account.id]); + publish_codex_authentication(&mut state, generation, &[], vec![(account, true)]); + assert!(state.codex_account_needs_authentication.is_empty()); +} + +#[test] +fn committed_switch_tolerates_unreadable_account_metadata() { + use codexbar::codex_accounts::file_locations; + let root = std::env::temp_dir().join(format!("codex-switch-metadata-{}", Uuid::new_v4())); + std::fs::create_dir_all(&root).unwrap(); + file_locations::with_app_support_directory(root.clone()); + let account_file = file_locations::accounts_file(); + std::fs::write(&account_file, "invalid account metadata").unwrap(); + // This post-commit operation cannot propagate an error to the switch + // command and skip the refresh/events that follow it. + persist_materialized_account(Some(&sample_account())); + assert_eq!( + std::fs::read_to_string(&account_file).unwrap(), + "invalid account metadata" + ); + file_locations::clear_app_support_directory_override(); + assert!(root.starts_with(std::env::temp_dir())); + std::fs::remove_dir_all(root).unwrap(); +} + +#[test] +fn superseded_lanes_cannot_overwrite_newer_snapshots() { + use codexbar::codex_accounts::{AccountUsageSnapshot, file_locations}; + let root = std::env::temp_dir().join(format!("codex-lane-generation-{}", Uuid::new_v4())); + std::fs::create_dir_all(&root).unwrap(); + file_locations::with_app_support_directory(root.clone()); + let mut state = AppState::new(); + let old_generation = state.provider_refresh_generation; + invalidate_account_usage(&mut state, ProviderId::Codex); + let id = Uuid::new_v4(); + let mut account = sample_account(); + account.id = id; + account.provider_account_id = Some("new".into()); + persist_codex_accounts(&[account.clone()]).unwrap(); + let snapshot = AccountUsageSnapshot { + email: Some("new@example.com".into()), + provider_account_id: Some("new".into()), + plan: None, + allowed: None, + limit_reached: None, + primary_window: None, + secondary_window: None, + credits: None, + cost: None, + subscription: None, + updated_at: codexbar::codex_accounts::utc_now(), + }; + assert!( + save_codex_lane_results( + &state, + state.provider_refresh_generation, + vec![(account.clone(), snapshot.clone())], + &[account.clone()] + ) + .unwrap() + ); + let before = std::fs::read(file_locations::snapshots_file()).unwrap(); + let stale = AccountUsageSnapshot { + email: Some("old@example.com".into()), + ..snapshot + }; + assert!( + !save_codex_lane_results( + &state, + old_generation, + vec![(account.clone(), stale)], + &[account] + ) + .unwrap() + ); + assert_eq!( + std::fs::read(file_locations::snapshots_file()).unwrap(), + before + ); + file_locations::clear_app_support_directory_override(); + assert!(root.starts_with(std::env::temp_dir())); + std::fs::remove_dir_all(root).unwrap(); +} + +#[test] +fn codex_switch_supersedes_inflight_usage_and_keeps_other_providers() { + let mut state = AppState::new(); + let other = invalidate_account_usage(&mut state, ProviderId::Claude); + let mut old = invalidate_account_usage(&mut state, ProviderId::Codex); + old.account_email = Some("old@example.com".into()); + old.primary.used_percent = 80.0; + old.error = None; + state.provider_cache = vec![other, old]; + state.is_refreshing = true; + let generation = state.provider_refresh_generation; + let pending = invalidate_account_usage(&mut state, ProviderId::Codex); + assert!(!is_current_provider_refresh_generation(&state, generation)); + assert!(!state.is_refreshing); + assert_eq!(state.provider_cache.len(), 2); + assert!( + state + .provider_cache + .iter() + .any(|s| s.provider_id == "claude") + ); + assert!(pending.account_email.is_none() && pending.error.is_some()); + assert_eq!(pending.primary.used_percent, 0.0); +} + +#[test] +fn reconciliation_replaces_changed_managed_identity_without_inheriting_metadata() { + let mut stale = sample_account(); + stale.nickname = Some("Former account".into()); + let mut fresh = sample_account(); + fresh.provider_account_id = Some("replacement".into()); + fresh.email_hint = Some("replacement@example.com".into()); + let accounts = reconcile_codex_accounts(&[stale.clone()], &[fresh.clone()], None); + assert_eq!(accounts.len(), 1); + assert_eq!(accounts[0].id, fresh.id); + assert_ne!(accounts[0].id, stale.id); + assert_eq!(accounts[0].nickname, None); + assert_eq!(accounts[0].email_hint, fresh.email_hint); +} + +#[test] +fn reconciliation_preserves_metadata_for_unchanged_managed_identity() { + let mut stored = sample_account(); + stored.nickname = Some("Work".into()); + let fresh = sample_account(); + let accounts = reconcile_codex_accounts(&[stored.clone()], &[fresh], None); + assert_eq!(accounts.len(), 1); + assert_eq!(accounts[0].id, stored.id); + assert_eq!(accounts[0].nickname, stored.nickname); +} + +fn sample_account() -> CodexAccount { + CodexAccount::new( + Uuid::new_v4(), + None, + Some("user@example.com".to_string()), + Some("auth0|acct".to_string()), + Some("acct".to_string()), + std::path::PathBuf::from("/tmp/fake-home"), + codexbar::codex_accounts::CodexAccountSource::ManagedByApp, + codexbar::codex_accounts::utc_now(), + codexbar::codex_accounts::utc_now(), + Some(codexbar::codex_accounts::utc_now()), + ) +} + +#[test] +fn into_user_message_preserves_friendly_text() { + assert_eq!( + into_user_message(CodexAccountManagerError::Message( + "The `codex` command could not be found.".to_string() + )), + "The `codex` command could not be found." + ); +} + +#[test] +fn ambient_account_selects_only_the_ambient_identity() { + let managed = sample_account(); + let mut ambient = managed.clone(); + ambient.source = codexbar::codex_accounts::CodexAccountSource::Ambient; + + let selected = ambient_account(&[managed, ambient.clone()]).unwrap(); + + assert_eq!(selected.id, ambient.id); + assert_eq!( + selected.source, + codexbar::codex_accounts::CodexAccountSource::Ambient + ); +} + +#[test] +fn ambient_account_reports_when_no_ambient_identity_exists() { + assert_eq!( + ambient_account(&[sample_account()]).unwrap_err(), + "No ambient Codex account found." + ); +} + +#[test] +fn reconciled_ambient_identity_change_replaces_the_login_result() { + let mut stored = sample_account(); + stored.source = codexbar::codex_accounts::CodexAccountSource::Ambient; + stored.provider_account_id = Some("old-workspace".into()); + stored.email_hint = Some("old@example.com".into()); + + // Logging in as a different identity at the same ambient home. + let mut fresh = sample_account(); + fresh.source = codexbar::codex_accounts::CodexAccountSource::Ambient; + fresh.provider_account_id = Some("new-workspace".into()); + fresh.email_hint = Some("new@example.com".into()); + + let reconciled = reconcile_codex_accounts(&[stored.clone()], &[], Some(fresh)); + assert_eq!(reconciled.len(), 1); + assert_ne!(reconciled[0].id, stored.id); + + // `reauthenticate` reuses the pre-login id; the command must report the + // reconciled record so it agrees with the persisted store and events. + let mut authenticated = stored.clone(); + authenticated.email_hint = Some("new@example.com".into()); + let account = canonical_reauthenticated_account(&reconciled, &authenticated).unwrap(); + assert_eq!(account.id, reconciled[0].id); + assert_ne!(account.id, authenticated.id); + assert_eq!( + account.source, + codexbar::codex_accounts::CodexAccountSource::Ambient + ); + assert_eq!( + account.provider_account_id.as_deref(), + Some("new-workspace") + ); +} + +#[test] +fn canonical_reauthenticated_account_returns_the_persisted_replacement() { + let mut authenticated = sample_account(); + authenticated.source = codexbar::codex_accounts::CodexAccountSource::Ambient; + authenticated.provider_account_id = Some("old-workspace".into()); + authenticated.email_hint = Some("old@example.com".into()); + + let mut persisted = sample_account(); + persisted.source = codexbar::codex_accounts::CodexAccountSource::Ambient; + persisted.provider_account_id = Some("new-workspace".into()); + persisted.email_hint = Some("new@example.com".into()); + + let account = canonical_reauthenticated_account(&[persisted.clone()], &authenticated).unwrap(); + assert_eq!(account.id, persisted.id); + assert_ne!(account.id, authenticated.id); + assert_eq!( + account.provider_account_id.as_deref(), + Some("new-workspace") + ); +} + +#[test] +fn canonical_reauthenticated_account_returns_the_unchanged_persisted_reauth() { + let mut persisted = sample_account(); + persisted.source = codexbar::codex_accounts::CodexAccountSource::Ambient; + persisted.nickname = Some("Work".into()); + persisted.provider_account_id = Some("workspace".into()); + + // The login helper does not carry optional stored metadata. + let mut authenticated = persisted.clone(); + authenticated.nickname = None; + + let account = canonical_reauthenticated_account(&[persisted.clone()], &authenticated).unwrap(); + assert_eq!(account.id, persisted.id); + assert_eq!(account.nickname.as_deref(), Some("Work")); +} + +#[test] +fn canonical_reauthenticated_account_prefers_ambient_over_matching_managed() { + let mut managed = sample_account(); + managed.provider_account_id = Some("shared-workspace".into()); + + let mut ambient = managed.clone(); + ambient.id = Uuid::new_v4(); + ambient.source = codexbar::codex_accounts::CodexAccountSource::Ambient; + + let account = canonical_reauthenticated_account(&[managed, ambient.clone()], &ambient) + .expect("persisted ambient account should be canonical"); + assert_eq!(account.id, ambient.id); + assert_eq!( + account.source, + codexbar::codex_accounts::CodexAccountSource::Ambient + ); +} + +#[test] +fn canonical_reauthenticated_account_does_not_return_a_dropped_login() { + let mut authenticated = sample_account(); + authenticated.source = codexbar::codex_accounts::CodexAccountSource::Ambient; + authenticated.provider_account_id = Some("dropped-workspace".into()); + authenticated.email_hint = Some("dropped@example.com".into()); + + // The reconciled set dropped the login identity and holds no ambient + // record to replace it with. + let error = canonical_reauthenticated_account(&[sample_account()], &authenticated).unwrap_err(); + assert_eq!(error, "No ambient Codex account found."); +} + +#[test] +fn canonical_reauthenticated_account_rejects_an_uncommitted_persistence_failure() { + let authenticated = sample_account(); + + // A failed persistence leaves no committed reconciled set; the transient + // login result must not be surfaced in its place. + let error = canonical_reauthenticated_account(&[], &authenticated).unwrap_err(); + assert_eq!(error, "Codex account login was not persisted."); +} + +#[test] +fn reauthentication_targets_saved_home_without_switching_to_ambient() { + let managed = sample_account(); + let mut ambient = sample_account(); + ambient.source = codexbar::codex_accounts::CodexAccountSource::Ambient; + let accounts = [managed.clone(), ambient.clone()]; + let selected = reauthentication_target(&accounts, Some(&managed.id.to_string())).unwrap(); + assert_eq!(selected.id, managed.id); + assert_eq!(selected.codex_home_path, managed.codex_home_path); + assert_eq!( + reauthentication_target(&accounts, None).unwrap().id, + ambient.id + ); + assert!(reauthentication_target(&accounts, Some("missing")).is_err()); +} + +#[test] +fn managed_reauthentication_reports_its_persisted_home_not_ambient() { + let authenticated = sample_account(); + let mut replacement = authenticated.clone(); + replacement.id = Uuid::new_v4(); + replacement.provider_account_id = Some("replacement-workspace".into()); + let mut ambient = sample_account(); + ambient.source = codexbar::codex_accounts::CodexAccountSource::Ambient; + let selected = + canonical_reauthenticated_account(&[ambient, replacement.clone()], &authenticated).unwrap(); + assert_eq!(selected.id, replacement.id); + assert_eq!(selected.source, authenticated.source); +} + +#[test] +fn sample_account_serializes_camel_case() { + let json = serde_json::to_value(sample_account()).unwrap(); + assert!(json.get("codexHomePath").is_some()); + assert!(json.get("providerAccountId").is_some()); +} diff --git a/apps/desktop-tauri/src-tauri/src/commands/mod.rs b/apps/desktop-tauri/src-tauri/src/commands/mod.rs index f8d6033e81a..42590235748 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/mod.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/mod.rs @@ -35,6 +35,7 @@ mod bridge; mod browser_import; mod claude_accounts; pub(crate) mod claude_reconciliation; +mod claude_usage; mod codex_accounts; mod codex_workspaces; mod credential_alerts; @@ -59,6 +60,7 @@ pub use agent_sessions::*; pub(crate) use bridge::*; pub use browser_import::*; pub use claude_accounts::*; +pub use claude_usage::*; pub use codex_accounts::*; pub use codex_workspaces::*; pub use credential_detection::*; diff --git a/apps/desktop-tauri/src-tauri/src/commands/providers.rs b/apps/desktop-tauri/src-tauri/src/commands/providers.rs index 2b6abaf22a0..14457ae57d1 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/providers.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/providers.rs @@ -786,6 +786,14 @@ fn spawn_provider_refreshes( })); } + if refresh_account_lanes && provider_ids.contains(&ProviderId::Claude) { + let app_handle = app.clone(); + let permits = Arc::clone(&fetch_permits); + handles.push(tokio::spawn(async move { + super::refresh_claude_account_lanes(app_handle, permits, generation).await; + })); + } + handles } diff --git a/apps/desktop-tauri/src-tauri/src/main.rs b/apps/desktop-tauri/src-tauri/src/main.rs index 2a2caf309e8..5aacf772818 100644 --- a/apps/desktop-tauri/src-tauri/src/main.rs +++ b/apps/desktop-tauri/src-tauri/src/main.rs @@ -219,6 +219,7 @@ fn main() { commands::get_deepseek_pricing_status, commands::codex_accounts_list, commands::claude_accounts_list, + commands::get_claude_accounts_state, commands::claude_reconciliation_state, commands::claude_account_add, commands::claude_account_cancel_login, diff --git a/apps/desktop-tauri/src-tauri/src/proof_harness.rs b/apps/desktop-tauri/src-tauri/src/proof_harness.rs index 24c245c19a4..69e34b7ccf7 100644 --- a/apps/desktop-tauri/src-tauri/src/proof_harness.rs +++ b/apps/desktop-tauri/src-tauri/src/proof_harness.rs @@ -277,6 +277,18 @@ pub fn is_proof_mode(app: &AppHandle) -> bool { .unwrap_or(false) } +/// Synthetic account rows are available only through a proof-mode command. +pub fn seed_claude_accounts_from_env() -> Option> { + let path = std::env::var_os("CODEXBAR_SEED_CLAUDE_ACCOUNTS_JSON")?; + let result = std::fs::read_to_string(path) + .ok() + .and_then(|raw| serde_json::from_str(&raw).ok()); + if result.is_none() { + tracing::warn!("Invalid Claude account proof seed; using real account metadata"); + } + result +} + // ── Provider-usage seed (CODEXBAR_SEED_USAGE_JSON) ─────────────────── /// Environment variable pointing at a JSON file with one synthetic, diff --git a/apps/desktop-tauri/src-tauri/src/state.rs b/apps/desktop-tauri/src-tauri/src/state.rs index 4d9fa3c1bba..09f2cf78ab4 100644 --- a/apps/desktop-tauri/src-tauri/src/state.rs +++ b/apps/desktop-tauri/src-tauri/src/state.rs @@ -121,6 +121,8 @@ pub struct AppState { pub current_target: SurfaceTarget, pub tray_anchor: Option, pub provider_cache: Vec, + pub claude_account_usage: HashMap, + pub codex_account_needs_authentication: HashMap, pub transient_provider_failure_counts: HashMap, /// Live session behind each provider's cached good snapshot, for /// owner-checked last-good retention. In memory only. @@ -199,6 +201,8 @@ impl AppState { current_target: SurfaceTarget::Summary, tray_anchor: None, provider_cache: Vec::new(), + claude_account_usage: HashMap::new(), + codex_account_needs_authentication: HashMap::new(), transient_provider_failure_counts: HashMap::new(), last_good_owners: HashMap::new(), provider_cache_updated_at: None, diff --git a/apps/desktop-tauri/src-tauri/src/tray_accounts.rs b/apps/desktop-tauri/src-tauri/src/tray_accounts.rs index f30a3de790c..eb9c897e387 100644 --- a/apps/desktop-tauri/src-tauri/src/tray_accounts.rs +++ b/apps/desktop-tauri/src-tauri/src/tray_accounts.rs @@ -176,7 +176,7 @@ pub(crate) fn handle_action(app: &AppHandle, action: AccountMenuAction) { use tauri_plugin_dialog::DialogExt; let (result, message) = match action { AccountMenuAction::AddClaudeAccount => ( - crate::commands::claude_account_add(handle.clone()).await, + crate::commands::claude_account_add(handle.clone(), None).await, "Claude Code account added. Select it to switch.", ), AccountMenuAction::SaveClaudeAccount => ( diff --git a/apps/desktop-tauri/src/components/ClaudeAccountUsage.test.tsx b/apps/desktop-tauri/src/components/ClaudeAccountUsage.test.tsx new file mode 100644 index 00000000000..b9ba43486d7 --- /dev/null +++ b/apps/desktop-tauri/src/components/ClaudeAccountUsage.test.tsx @@ -0,0 +1,35 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import type { ClaudeAccount } from "../types/bridge"; +import ClaudeAccountUsage from "./ClaudeAccountUsage"; + +vi.mock("../hooks/useLocale", () => ({ useLocale: () => ({ t: (key: string) => key }) })); +const t = (key: string) => key; +const account: ClaudeAccount = { + id: "one:org", email: "one@example.test", organization: null, plan: "max", isActive: false, isSaved: true, + usage: { fiveHour: { usedPercent: 23, resetsAt: null }, sevenDay: { usedPercent: 81, resetsAt: null }, updatedAt: "2026-10-03T12:00:00Z" }, +}; + +describe("ClaudeAccountUsage", () => { + it("shows independent session and weekly remaining bars with optional used values", () => { + const { container } = render(); + expect(screen.getByText("77% PanelLeftSuffix")).toBeInTheDocument(); + expect(screen.getByText("19% PanelLeftSuffix")).toBeInTheDocument(); + expect(screen.getByText("23% PanelUsedSuffix")).toBeInTheDocument(); + const bars = container.querySelectorAll(".codex-menu-accounts__bar-fill"); + expect(Array.from(bars, bar => bar.style.width)).toEqual(["77%", "19%"]); + }); + + it("keeps last known limits but marks failed checks visibly", () => { + const { container } = render(); + expect(screen.getByRole("status")).toHaveTextContent("Sign in again"); + expect(screen.getByText("77% PanelLeftSuffix")).toBeInTheDocument(); + expect(container.querySelector('[data-stale="true"]')).toBeInTheDocument(); + }); + + it("does not invent quota when an account has never returned usage", () => { + const { container } = render(); + expect(screen.getByText("CodexAccountsUsageUnavailable")).toBeInTheDocument(); + expect(container.querySelector(".codex-menu-accounts__bar-fill")).toBeNull(); + }); +}); diff --git a/apps/desktop-tauri/src/components/ClaudeAccountUsage.tsx b/apps/desktop-tauri/src/components/ClaudeAccountUsage.tsx new file mode 100644 index 00000000000..225ef92ceba --- /dev/null +++ b/apps/desktop-tauri/src/components/ClaudeAccountUsage.tsx @@ -0,0 +1,44 @@ +import type { ClaudeAccount } from "../types/bridge"; +import type { LocaleKey } from "../i18n/keys"; +import { useFormattedResetTime } from "../hooks/useFormattedResetTime"; + +export default function ClaudeAccountUsage({ account, t, showAsUsed = false, resetTimeRelative = true }: { + account: ClaudeAccount; + t: (key: LocaleKey) => string; + showAsUsed?: boolean; + resetTimeRelative?: boolean; +}) { + const windows = account.usage; + return
+ {windows?.fiveHour && } + {windows?.sevenDay && } + {!windows?.fiveHour && !windows?.sevenDay && !account.usageError && {t("CodexAccountsUsageUnavailable")}} + {account.usageError && + {account.usageError}{windows && ` · ${t("DetailUpdatedPrefix")} ${new Date(windows.updatedAt).toLocaleString()}`} + } +
; +} + +function UsageWindow({ window, label, t, showAsUsed, relative }: { + window: NonNullable["fiveHour"]>; + label: string; + t: (key: LocaleKey) => string; + showAsUsed: boolean; + relative: boolean; +}) { + const reset = useFormattedResetTime(window.resetsAt, null, relative); + const known = Number.isFinite(window.usedPercent); + const used = Math.max(0, Math.min(100, Math.round(window.usedPercent))); + const remaining = 100 - used; + return
+ + {label} + {known ? `${remaining}% ${t("PanelLeftSuffix")}` : t("CodexAccountsUsageUnavailable")} + {known && showAsUsed && {used}% {t("PanelUsedSuffix")}} + {reset && {reset}} + + + {known && } + +
; +} diff --git a/apps/desktop-tauri/src/components/ClaudeAccountsMenu.test.tsx b/apps/desktop-tauri/src/components/ClaudeAccountsMenu.test.tsx index a9fa68f3204..4906e1524a6 100644 --- a/apps/desktop-tauri/src/components/ClaudeAccountsMenu.test.tsx +++ b/apps/desktop-tauri/src/components/ClaudeAccountsMenu.test.tsx @@ -6,6 +6,9 @@ const mocks = vi.hoisted(() => { const listeners = new Map void>(); return { claudeAccountsList: vi.fn(), + claudeAccountAdd: vi.fn(), + claudeAccountReauthenticate: vi.fn(), + claudeAccountCancelLogin: vi.fn(), claudeAccountSwitch: vi.fn(), claudeReconciliationState: vi.fn(), refreshProviders: vi.fn(), @@ -28,6 +31,38 @@ const reconciliation = (generation: number, status: "pending" | "succeeded" | "f }); describe("ClaudeAccountsMenu", () => { + it("shows Refresh login only for the expired account and removes it after recovery", async () => { + mocks.claudeAccountsList.mockResolvedValue([first, { ...second, needsAuthentication: true, usageError: "Sign in again." }]); + mocks.claudeAccountReauthenticate.mockResolvedValue(undefined); + render(); + const refresh = await screen.findByRole("button", { name: `CodexAccountsReauthenticateButton: ${second.email}` }); + expect(screen.getAllByRole("button", { name: /CodexAccountsReauthenticateButton/ })).toHaveLength(1); + mocks.claudeAccountsList.mockResolvedValue([first, second]); + await act(async () => fireEvent.click(refresh)); + expect(mocks.claudeAccountReauthenticate).toHaveBeenCalledWith(second.id); + expect(mocks.claudeAccountAdd).not.toHaveBeenCalled(); + expect(mocks.claudeAccountSwitch).not.toHaveBeenCalled(); + expect(screen.queryByRole("button", { name: /CodexAccountsReauthenticateButton/ })).toBeNull(); + }); + + it("does not suggest login for a temporary usage error", async () => { + mocks.claudeAccountsList.mockResolvedValue([{ ...first, usageError: "Will retry automatically.", needsAuthentication: false }]); + render(); + await screen.findByText(first.email); + expect(screen.queryByRole("button", { name: /CodexAccountsReauthenticateButton/ })).toBeNull(); + }); + it("renders per-account usage and reloads it after background checks", async () => { + const usage = (used: number) => ({ fiveHour: { usedPercent: used, resetsAt: null }, sevenDay: null, updatedAt: "2026-10-03T12:00:00Z" }); + mocks.claudeAccountsList.mockResolvedValue([{ ...first, usage: usage(11) }, { ...second, usage: usage(63) }]); + render(); + await screen.findByText("89% PanelLeftSuffix"); + expect(screen.getByText("37% PanelLeftSuffix")).toBeInTheDocument(); + mocks.claudeAccountsList.mockResolvedValue([{ ...first, usage: usage(20) }, { ...second, usage: usage(70) }]); + await act(async () => mocks.listeners.get("claude-accounts-updated")?.({ payload: null })); + await screen.findByText("80% PanelLeftSuffix"); + expect(screen.getByText("30% PanelLeftSuffix")).toBeInTheDocument(); + expect(mocks.claudeAccountSwitch).not.toHaveBeenCalled(); + }); beforeEach(() => { vi.clearAllMocks(); mocks.listeners.clear(); @@ -58,6 +93,40 @@ describe("ClaudeAccountsMenu", () => { expect(mocks.claudeAccountSwitch).toHaveBeenCalledWith(second.id); }); + it("shows one active account and adds from the footer with cancellable sign-in", async () => { + mocks.claudeAccountsList.mockResolvedValue([first]); + let finishLogin: (() => void) | undefined; + mocks.claudeAccountAdd.mockImplementation(() => new Promise(resolve => { finishLogin = resolve; })); + mocks.claudeAccountCancelLogin.mockResolvedValue(undefined); + const { container } = render(); + const account = await screen.findByText(first.email); + expect(container.querySelector("details")?.open).toBe(true); + const add = screen.getByRole("button", { name: "CodexAccountsAddButton" }); + expect(account.compareDocumentPosition(add) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); + await act(async () => fireEvent.click(add)); + expect(mocks.claudeAccountAdd).toHaveBeenCalledOnce(); + expect(add).toBeDisabled(); + expect(screen.getByText("ClaudeAccountsSigningIn")).toBeInTheDocument(); + await act(async () => fireEvent.click(screen.getByRole("button", { name: "ClaudeAccountsCancelLogin" }))); + expect(mocks.claudeAccountCancelLogin).toHaveBeenCalledOnce(); + mocks.claudeAccountsList.mockResolvedValue([first, second]); + await act(async () => finishLogin?.()); + expect(await screen.findByText(second.email)).toBeInTheDocument(); + expect(add).toBeEnabled(); + expect(mocks.claudeAccountSwitch).not.toHaveBeenCalled(); + }); + + it("keeps the add action available when no accounts are saved and shows sign-in failures", async () => { + mocks.claudeAccountsList.mockResolvedValue([]); + mocks.claudeAccountAdd.mockRejectedValue("Login cancelled."); + render(); + const add = screen.getByRole("button", { name: "CodexAccountsAddButton" }); + await waitFor(() => expect(add).toBeEnabled()); + await act(async () => fireEvent.click(add)); + expect(screen.getByRole("alert")).toHaveTextContent("Login cancelled."); + expect(add).toBeEnabled(); + }); + it("keeps the menu in activating and reconciling phases until the switch settles", async () => { let resolveSwitch: ((value: ReturnType) => void) | undefined; mocks.claudeAccountSwitch.mockImplementation(() => new Promise(resolve => { @@ -163,7 +232,7 @@ describe("ClaudeAccountsMenu", () => { it("uses stable opaque account labels and redacts tooltips when hideEmail is enabled", async () => { mocks.claudeAccountsList.mockResolvedValue([first, { ...second, organization: `${second.email}'s Organization` }]); const { container } = render(); - await screen.findByText("ClaudeAccountsTitle"); + await screen.findByText("Account 1"); const labels = container.querySelectorAll(".codex-menu-accounts__email"); expect(labels[0].firstChild?.textContent).toBe("Account 1"); expect(labels[0].getAttribute("title")).toBe("Account 1"); @@ -177,7 +246,7 @@ describe("ClaudeAccountsMenu", () => { it("keeps opaque labels stable when the source reorders accounts", async () => { const { container } = render(); - await screen.findByText("ClaudeAccountsTitle"); + await screen.findByText("Account 1"); mocks.claudeAccountsList.mockResolvedValue([second, first]); await act(async () => window.dispatchEvent(new Event("focus"))); await waitFor(() => { diff --git a/apps/desktop-tauri/src/components/ClaudeAccountsMenu.tsx b/apps/desktop-tauri/src/components/ClaudeAccountsMenu.tsx index b57e6a1af4f..646f8924a61 100644 --- a/apps/desktop-tauri/src/components/ClaudeAccountsMenu.tsx +++ b/apps/desktop-tauri/src/components/ClaudeAccountsMenu.tsx @@ -1,8 +1,10 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { listen } from "@tauri-apps/api/event"; import type { ClaudeAccount } from "../types/bridge"; -import { claudeAccountsList, claudeAccountSwitch } from "../lib/tauri"; +import { claudeAccountsList, claudeAccountAdd, claudeAccountReauthenticate, claudeAccountCancelLogin, claudeAccountSwitch } from "../lib/tauri"; import { useLocale } from "../hooks/useLocale"; +import ClaudeAccountUsage from "./ClaudeAccountUsage"; +import ProviderAccountsMenu from "./ProviderAccountsMenu"; import { localClaudeReconciliationOutcome, useClaudeReconciliation, @@ -12,23 +14,31 @@ import { buildPrivateClaudeAccountLabel, } from "./claudeAccountDisplay"; -export default function ClaudeAccountsMenu({ hideEmail, onLayoutChange }: { +export default function ClaudeAccountsMenu({ hideEmail, onLayoutChange, showAsUsed = false, resetTimeRelative = true }: { hideEmail: boolean; onLayoutChange?: () => void; + showAsUsed?: boolean; + resetTimeRelative?: boolean; }) { const { t } = useLocale(); const [accounts, setAccounts] = useState([]); const [error, setError] = useState(null); const [switched, setSwitched] = useState(false); const [activating, setActivating] = useState(false); + const [signingIn, setSigningIn] = useState(false); + const [loading, setLoading] = useState(true); const [operationGeneration, setOperationGeneration] = useState(null); const { snapshot, accept, reconciling } = useClaudeReconciliation(); const mounted = useRef(false); const load = useCallback(async () => { - const next = await claudeAccountsList(); - if (mounted.current) { - setAccounts(next); - setError(null); + try { + const next = await claudeAccountsList(); + if (mounted.current) { + setAccounts(next); + setError(null); + } + } finally { + if (mounted.current) setLoading(false); } }, []); useEffect(() => { @@ -65,7 +75,7 @@ export default function ClaudeAccountsMenu({ hideEmail, onLayoutChange }: { : "idle"; useEffect(() => { onLayoutChange?.(); - }, [accounts.length, error, phase, switched, onLayoutChange]); + }, [accounts, error, phase, switched, signingIn, onLayoutChange]); const accountOrdinals = buildClaudeAccountOrdinals(accounts); @@ -88,21 +98,39 @@ export default function ClaudeAccountsMenu({ hideEmail, onLayoutChange }: { } }; - const hasSwitchableAccount = accounts.some(account => account.isSaved && !account.isActive); - if (accounts.length <= 1 && !hasSwitchableAccount && !error) return null; + const addAccount = async (id?: string) => { + setSigningIn(true); + setError(null); + setSwitched(false); + try { + await (id ? claudeAccountReauthenticate(id) : claudeAccountAdd()); + await load(); + } catch (e) { + if (mounted.current) setError(String(e)); + } finally { + if (mounted.current) setSigningIn(false); + } + }; + const busy = loading || activating || reconciling || signingIn; return ( -
+ + {signingIn && } + } > - - {t("ClaudeAccountsTitle")} - {accounts.length} - {error &&
{error}
} {switched &&

{t("ClaudeAccountsSwitched")}

} + {signingIn &&

{t("ClaudeAccountsSigningIn")}

}
    {accounts.map(account => { const privateLabel = buildPrivateClaudeAccountLabel( @@ -122,20 +150,34 @@ export default function ClaudeAccountsMenu({ hideEmail, onLayoutChange }: { {!hideEmail && account.organization && !account.organization.includes(account.email) && ( {account.organization} )} + + +
    + {account.needsAuthentication && ( + + )} +
    - ); })}
-
+ ); } diff --git a/apps/desktop-tauri/src/components/CodexAccountsMenu.test.tsx b/apps/desktop-tauri/src/components/CodexAccountsMenu.test.tsx index a6e9edeb035..449d0293e3e 100644 --- a/apps/desktop-tauri/src/components/CodexAccountsMenu.test.tsx +++ b/apps/desktop-tauri/src/components/CodexAccountsMenu.test.tsx @@ -11,12 +11,14 @@ import { LocaleProvider } from "../i18n/LocaleProvider"; const tauriMocks = vi.hoisted(() => ({ getCodexAccountsState: vi.fn(), codexAccountSwitch: vi.fn(), + codexAccountAdd: vi.fn(), + codexAccountReauthenticate: vi.fn(), refreshProviders: vi.fn(), getLocaleStrings: vi.fn(), })); const eventMocks = vi.hoisted(() => ({ - listen: vi.fn(() => Promise.resolve(() => {})), + listen: vi.fn((_event: string, _callback: () => void) => Promise.resolve(() => {})), })); vi.mock("../lib/tauri", () => tauriMocks); @@ -64,12 +66,14 @@ function renderMenu( hideEmail: boolean, state: CodexAccountsStateBridge, resetTimeRelative = true, + showAsUsed = false, ) { tauriMocks.getCodexAccountsState.mockResolvedValue(state); tauriMocks.getLocaleStrings.mockResolvedValue(buildBundle({})); return render( @@ -82,17 +86,80 @@ describe("CodexAccountsMenu", () => { vi.clearAllMocks(); }); - it("renders nothing for a single-account setup (single-account fallback)", async () => { - const { container } = renderMenu(false, { + it("offers login actions even for a single account", async () => { + renderMenu(false, { accounts: [account("1", { source: "ambient" })], - accountOrdinals: { "1": 1 }, - snapshots: {}, + accountOrdinals: { "1": 1 }, snapshots: {}, + needsAuthentication: { "1": true }, }); - await waitFor(() => { - expect( - container.querySelector(".codex-menu-accounts"), - ).toBeNull(); + expect(await screen.findByRole("button", { name: "CodexAccountsAddButton" })).toBeDefined(); + const refresh = await screen.findByRole("button", { name: "CodexAccountsReauthenticateButton: user-1@example.com" }); + await waitFor(() => expect((refresh as HTMLButtonElement).disabled).toBe(false)); + await act(async () => { refresh.click(); }); + expect(tauriMocks.codexAccountReauthenticate).toHaveBeenCalledOnce(); + }); + + it("expands the account list by default and places add account below the rows", async () => { + const { container } = renderMenu(false, { + accounts: [account("one"), account("two")], + accountOrdinals: { one: 1, two: 2 }, snapshots: {}, + }); + const row = await screen.findByText("user-two@example.com"); + expect(container.querySelector("details")?.open).toBe(true); + const add = screen.getByRole("button", { name: "CodexAccountsAddButton" }); + expect(row.compareDocumentPosition(add) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); + }); + + it("offers account sign-in with no saved accounts and reports a login failure", async () => { + renderMenu(false, { accounts: [], accountOrdinals: {}, snapshots: {} }); + const add = await screen.findByRole("button", { name: "CodexAccountsAddButton" }); + await waitFor(() => expect((add as HTMLButtonElement).disabled).toBe(false)); + tauriMocks.codexAccountAdd.mockRejectedValueOnce(new Error("Login cancelled")); + await act(async () => { add.click(); }); + expect(tauriMocks.codexAccountAdd).toHaveBeenCalledOnce(); + expect(await screen.findByRole("alert")).toHaveTextContent("Login cancelled"); + }); + + it("reauthenticates a single saved account without switching it and reports cancellation", async () => { + renderMenu(false, { accounts: [account("expired")], accountOrdinals: { expired: 1 }, snapshots: {}, needsAuthentication: { expired: true } }); + const refresh = await screen.findByRole("button", { + name: "CodexAccountsReauthenticateButton: user-expired@example.com", + }); + await waitFor(() => expect(refresh).toBeEnabled()); + tauriMocks.codexAccountReauthenticate.mockRejectedValueOnce(new Error("Account setup cancelled.")); + await act(async () => { refresh.click(); }); + expect(tauriMocks.codexAccountReauthenticate).toHaveBeenCalledWith("expired"); + expect(tauriMocks.codexAccountSwitch).not.toHaveBeenCalled(); + expect(await screen.findByRole("alert")).toHaveTextContent("Account setup cancelled."); + expect(refresh).toBeEnabled(); + }); + + it("shows both quota windows for each account without expanding a disclosure", async () => { + const both = { ...snapshot(30), secondaryWindow: { usedPercent: 65, resetAt: null, limitWindowSeconds: 604800 } }; + const { container } = renderMenu(false, { + accounts: [account("1", { source: "ambient" }), account("2")], + accountOrdinals: { "1": 1, "2": 2 }, snapshots: { "1": both, "2": snapshot(70) }, }); + await screen.findByText("user-1@example.com"); + expect(container.querySelector("details")?.open).toBe(true); + expect(screen.getByText("35% PanelLeftSuffix")).toBeDefined(); + expect(screen.getByText("7d")).toBeDefined(); + expect(screen.getByText("30% PanelLeftSuffix")).toBeDefined(); + expect(screen.queryByRole("button", { name: /CodexAccountsReauthenticateButton/ })).toBeNull(); + }); + + it("keeps login actions disabled while account updates arrive during sign-in", async () => { + let finish!: () => void; + tauriMocks.codexAccountAdd.mockImplementationOnce(() => new Promise((resolve) => { finish = resolve; })); + renderMenu(false, { accounts: [], accountOrdinals: {}, snapshots: {} }); + const add = await screen.findByRole("button", { name: "CodexAccountsAddButton" }) as HTMLButtonElement; + await waitFor(() => expect(add.disabled).toBe(false)); + await act(async () => { add.click(); }); + const callback = eventMocks.listen.mock.calls.find(([event]) => event === "codex-accounts-updated")?.[1] as (() => void) | undefined; + await act(async () => { callback?.(); }); + expect(add.disabled).toBe(true); + await act(async () => { finish(); }); + await waitFor(() => expect(add.disabled).toBe(false)); }); it("lists multiple accounts with usage bars and marks the ambient one active", async () => { @@ -120,8 +187,8 @@ describe("CodexAccountsMenu", () => { // Usage bar widths map to the snapshot percentages. const fills = container.querySelectorAll(".codex-menu-accounts__bar-fill"); - expect((fills[0] as HTMLElement).style.width).toBe("30%"); - expect((fills[1] as HTMLElement).style.width).toBe("70%"); + expect((fills[0] as HTMLElement).style.width).toBe("70%"); + expect((fills[1] as HTMLElement).style.width).toBe("30%"); }); it("renders a usage bar from a weekly-only snapshot (primaryWindow: null)", async () => { @@ -151,7 +218,7 @@ describe("CodexAccountsMenu", () => { ".codex-menu-accounts__bar-fill", ); expect(fills.length).toBe(1); - expect((fills[0] as HTMLElement).style.width).toBe("42%"); + expect((fills[0] as HTMLElement).style.width).toBe("58%"); }); it("shows the five-hour usage and local reset time for each account", async () => { @@ -175,10 +242,35 @@ describe("CodexAccountsMenu", () => { await screen.findByText("user-1@example.com"); expect(screen.getAllByText("5h")).toHaveLength(2); - expect(screen.getByText("30% PanelUsedSuffix")).toBeDefined(); + expect(screen.getByText("70% PanelLeftSuffix")).toBeDefined(); expect(screen.getAllByText(`MetricResetsIn ${expectedReset}`)).toHaveLength(2); }); + it("shows remaining capacity alongside used capacity when the used setting is enabled", async () => { + renderMenu(false, { + accounts: [account("1", { source: "ambient" }), account("2")], + accountOrdinals: { "1": 1, "2": 2 }, snapshots: { "1": snapshot(30) }, + }, true, true); + await screen.findByText("70% PanelLeftSuffix"); + expect(screen.getByText("30% PanelUsedSuffix")).toBeDefined(); + expect(screen.getByText("CodexAccountsUsageUnavailable")).toBeDefined(); + }); + + it("distinguishes exhausted and full quotas from unknown usage", async () => { + const { container } = renderMenu(false, { + accounts: [account("1"), account("2"), account("3")], + accountOrdinals: { "1": 1, "2": 2, "3": 3 }, + snapshots: { "1": snapshot(100), "2": snapshot(0), "3": snapshot(Number.NaN) }, + }); + await screen.findByText("0% PanelLeftSuffix"); + expect(screen.getByText("100% PanelLeftSuffix")).toBeDefined(); + expect(screen.getByText("CodexAccountsUsageUnavailable")).toBeDefined(); + const fills = container.querySelectorAll(".codex-menu-accounts__bar-fill"); + expect(fills).toHaveLength(2); + expect((fills[0] as HTMLElement).style.width).toBe("0%"); + expect((fills[1] as HTMLElement).style.width).toBe("100%"); + }); + it("switches an account and kicks a provider refresh", async () => { renderMenu(false, { accounts: [account("1", { source: "ambient" }), account("2")], diff --git a/apps/desktop-tauri/src/components/CodexAccountsMenu.tsx b/apps/desktop-tauri/src/components/CodexAccountsMenu.tsx index 228c7692aa2..6ebb036076e 100644 --- a/apps/desktop-tauri/src/components/CodexAccountsMenu.tsx +++ b/apps/desktop-tauri/src/components/CodexAccountsMenu.tsx @@ -7,29 +7,28 @@ import type { } from "../types/bridge"; import { useLocale } from "../hooks/useLocale"; import { useFormattedResetTime } from "../hooks/useFormattedResetTime"; +import ProviderAccountsMenu from "./ProviderAccountsMenu"; import { buildCodexAccountSurfaceLabels } from "./codexAccountDisplay"; import { + codexAccountAdd, + codexAccountReauthenticate, codexAccountSwitch, getCodexAccountsState, refreshProviders, } from "../lib/tauri"; -/** - * Multi-account lane surface for the Codex tray menu card (ADR 0003, - * option A). Renders only when more than one Codex account exists, so the - * common single-account menu stays unchanged (single-account fallback). - * - * Shows every account (ambient + managed) with a compact usage bar and a - * Switch action. Switching updates the ambient identity and triggers a - * provider refresh so the tray icon/menu reflect the now-active account. - */ +/** Visible account overview and login actions for the Codex tray card. */ export default function CodexAccountsMenu({ hideEmail, resetTimeRelative, + showAsUsed = false, + needsAuthentication = false, onLayoutChange, }: { hideEmail: boolean; resetTimeRelative: boolean; + showAsUsed?: boolean; + needsAuthentication?: boolean; onLayoutChange?: () => void; }) { const { t } = useLocale(); @@ -39,11 +38,15 @@ export default function CodexAccountsMenu({ >({}); const [displayNames, setDisplayNames] = useState>({}); const [accountOrdinals, setAccountOrdinals] = useState>({}); - const [busy, setBusy] = useState(false); + const [accountNeedsAuthentication, setAccountNeedsAuthentication] = useState>({}); + const [loading, setLoading] = useState(true); + const [pending, setPending] = useState(false); + const [signingIn, setSigningIn] = useState(false); + const busy = loading || pending; const [error, setError] = useState(null); const load = useCallback(async () => { - setBusy(true); + setLoading(true); setError(null); try { const next: CodexAccountsStateBridge = await getCodexAccountsState(); @@ -51,10 +54,11 @@ export default function CodexAccountsMenu({ setDisplayNames(next.displayNames ?? {}); setAccountOrdinals(next.accountOrdinals); setSnapshots(next.snapshots); + setAccountNeedsAuthentication(next.needsAuthentication ?? {}); } catch (err: unknown) { setError(err instanceof Error ? err.message : String(err)); } finally { - setBusy(false); + setLoading(false); } }, []); @@ -64,7 +68,7 @@ export default function CodexAccountsMenu({ useEffect(() => { onLayoutChange?.(); - }, [accounts.length, error, onLayoutChange]); + }, [accounts, snapshots, error, pending, onLayoutChange]); useEffect(() => { let cancelled = false; @@ -77,24 +81,24 @@ export default function CodexAccountsMenu({ }; }, [load]); - const handleSwitch = async (id: string) => { - setBusy(true); + const run = async (action: () => Promise, login = false) => { + setSigningIn(login); + setPending(true); setError(null); try { - await codexAccountSwitch(id); + await action(); await load(); - // Make the tray icon/menu reflect the newly active ambient identity. void refreshProviders().catch(() => {}); } catch (err: unknown) { setError(err instanceof Error ? err.message : String(err)); } finally { - setBusy(false); + setPending(false); + setSigningIn(false); } }; - if (accounts.length <= 1) { - return null; - } + const handleSwitch = (id: string) => run(() => codexAccountSwitch(id)); + const ambient = accounts.find((account) => account.source === "ambient"); const accountDisplayNames = buildCodexAccountSurfaceLabels( accounts, @@ -105,34 +109,56 @@ export default function CodexAccountsMenu({ ); return ( -
- - {t("CodexAccountsTitle")} - {accounts.length} - + + + } + > + + {pending && ( +
+ {t(signingIn ? "CodexAccountsSigningIn" : "TrayLoading")} +
+ )} {error && (
{error}
)} -
    - {accounts.map((account) => { - const label = accountDisplayNames[account.id]; - return ( - - ); - })} -
-
+ {accounts.length > 0 && ( +
    + {accounts.map((account) => { + const label = accountDisplayNames[account.id]; + return ( + run(() => codexAccountReauthenticate(id), true)} + /> + ); + })} +
+ )} + ); } @@ -141,36 +167,25 @@ function CodexAccountRow({ snapshot, displayName, tooltip, + showAsUsed, resetTimeRelative, busy, + needsAuthentication, onSwitch, + onReauthenticate, }: { account: CodexAccount; snapshot: CodexAccountUsageSnapshot | undefined; displayName: string; tooltip: string; + showAsUsed: boolean; resetTimeRelative: boolean; busy: boolean; + needsAuthentication: boolean; onSwitch: (id: string) => Promise; + onReauthenticate: (id: string) => Promise; }) { const { t } = useLocale(); - // Prefer the primary (normally five-hour) window. Accounts whose backend - // only returns a weekly window have primaryWindow: null, so keep the - // existing secondary-window fallback for their bar and reset detail. - const usageWindow = - snapshot?.primaryWindow ?? snapshot?.secondaryWindow ?? null; - const pct = usageWindow ? Math.round(usageWindow.usedPercent) : null; - const resetText = useFormattedResetTime( - usageWindow?.resetAt ?? null, - null, - resetTimeRelative, - ); - const resetLabel = resetText - ? resetTimeRelative - ? resetText - : `${t("MetricResetsIn")} ${resetText}` - : null; - const windowLabel = formatWindowLabel(usageWindow?.limitWindowSeconds); const isAmbient = account.source === "ambient"; return ( @@ -187,37 +202,80 @@ function CodexAccountRow({ )} - {(pct !== null || resetLabel) && ( - - {windowLabel && {windowLabel}} - {pct !== null && ( - {pct}% {t("PanelUsedSuffix")} - )} - {resetLabel && {resetLabel}} - + {snapshot?.plan && ( + {snapshot.plan} )} - {pct !== null && ( - - - + {snapshot?.primaryWindow && ( + + )} + {snapshot?.secondaryWindow && ( + + )} + {!snapshot?.primaryWindow && !snapshot?.secondaryWindow && ( + {t("CodexAccountsUsageUnavailable")} )} - +
+ {needsAuthentication && ( + + )} + +
); } +function AccountWindow({ + window, + resetTimeRelative, + showAsUsed, +}: { + showAsUsed: boolean; + window: NonNullable; + resetTimeRelative: boolean; +}) { + const { t } = useLocale(); + const known = Number.isFinite(window.usedPercent); + const used = Math.max(0, Math.min(100, Math.round(window.usedPercent))); + const remaining = 100 - used; + const resetText = useFormattedResetTime(window.resetAt, null, resetTimeRelative); + const resetLabel = resetText && ( + resetTimeRelative ? resetText : `${t("MetricResetsIn")} ${resetText}` + ); + return ( +
+ + {formatWindowLabel(window.limitWindowSeconds) ?? t("DetailWindowPrimary")} + + {known ? `${remaining}% ${t("PanelLeftSuffix")}` : t("CodexAccountsUsageUnavailable")} + + {known && showAsUsed && {used}% {t("PanelUsedSuffix")}} + {resetLabel && {resetLabel}} + + + {known && ( + + )} + +
+ ); +} function formatWindowLabel( limitWindowSeconds: number | null | undefined, ): string | null { diff --git a/apps/desktop-tauri/src/components/MenuCard.test.tsx b/apps/desktop-tauri/src/components/MenuCard.test.tsx index 140c682e56e..382c74023e0 100644 --- a/apps/desktop-tauri/src/components/MenuCard.test.tsx +++ b/apps/desktop-tauri/src/components/MenuCard.test.tsx @@ -8,6 +8,7 @@ const tauriMocks = vi.hoisted(() => ({ getLocaleStrings: vi.fn(), setUiLanguage: vi.fn(), claudeAccountsList: vi.fn(), + getCodexAccountsState: vi.fn(), })); const eventMocks = vi.hoisted(() => ({ @@ -185,6 +186,15 @@ describe("MenuCard", () => { eventMocks.listen.mockResolvedValue(() => {}); }); + it("keeps Codex sign-in discoverable beside an authentication error with no accounts", async () => { + tauriMocks.getCodexAccountsState.mockResolvedValueOnce({ accounts: [], accountOrdinals: {}, snapshots: {} }); + const codex = { ...provider("Authentication required"), providerId: "codex", displayName: "Codex", errorState: "needsAuthentication" as const }; + render(); + expect(await screen.findByText("Authentication required")).toBeDefined(); + const add = await screen.findByRole("button", { name: "CodexAccountsSignInButton" }); + await waitFor(() => expect(add).toBeEnabled()); + }); + it("keeps Fireworks vendor API spend visible when local cost summaries are hidden", async () => { const snapshot = provider(null, 0); snapshot.providerId = "fireworks"; diff --git a/apps/desktop-tauri/src/components/MenuCard.tsx b/apps/desktop-tauri/src/components/MenuCard.tsx index 516a6e75717..e8503f41c56 100644 --- a/apps/desktop-tauri/src/components/MenuCard.tsx +++ b/apps/desktop-tauri/src/components/MenuCard.tsx @@ -309,13 +309,15 @@ export default function MenuCard({ {provider.providerId === "codex" && ( )} {provider.providerId === "claude" && ( - + )} {provider.providerId === "grok" && ( { + it("shows accounts and the add action by default, and preserves a collapse across updates", async () => { + const onLayoutChange = vi.fn(); + const content = (count: number) => Add account}> +
  • Saved account
+
; + const { container, rerender } = render(content(1)); + const details = container.querySelector("details")!; + expect(details.open).toBe(true); + const add = screen.getByRole("button", { name: "Add account" }); + expect(screen.getByText("Saved account").compareDocumentPosition(add) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); + fireEvent.click(screen.getByText("Accounts")); + await waitFor(() => expect(details.open).toBe(false)); + await waitFor(() => expect(onLayoutChange).toHaveBeenCalled()); + rerender(content(2)); + expect(details.open).toBe(false); + fireEvent.click(screen.getByText("Accounts")); + await waitFor(() => expect(details.open).toBe(true)); + expect(screen.getByRole("button", { name: "Add account" })).toBeEnabled(); + }); +}); diff --git a/apps/desktop-tauri/src/components/ProviderAccountsMenu.tsx b/apps/desktop-tauri/src/components/ProviderAccountsMenu.tsx new file mode 100644 index 00000000000..5b29367cdc3 --- /dev/null +++ b/apps/desktop-tauri/src/components/ProviderAccountsMenu.tsx @@ -0,0 +1,33 @@ +import { useState, type ComponentPropsWithoutRef, type ReactNode } from "react"; + +type Props = Omit, "children" | "onToggle" | "open" | "title"> & { + title: string; + count: number; + children: ReactNode; + actions: ReactNode; + onLayoutChange?: () => void; +}; + +/** Shared account list and footer for providers with managed logins. */ +export default function ProviderAccountsMenu({ title, count, children, actions, onLayoutChange, ...props }: Props) { + const [expanded, setExpanded] = useState(true); + return ( +
{ + setExpanded(event.currentTarget.open); + onLayoutChange?.(); + }} + > + + {title} + {count} + + {children} +
{actions}
+
+ ); +} diff --git a/apps/desktop-tauri/src/floatbar/FloatBar.css b/apps/desktop-tauri/src/floatbar/FloatBar.css index 33ffce9346f..aeb03ddd92a 100644 --- a/apps/desktop-tauri/src/floatbar/FloatBar.css +++ b/apps/desktop-tauri/src/floatbar/FloatBar.css @@ -178,10 +178,16 @@ body.floatbar-window #root { line-height: 1; } .floatbar__pct { + position: relative; font-variant-numeric: tabular-nums; letter-spacing: 0; line-height: 1; } +.floatbar__warning { + position: absolute; + inset: 0; + text-align: center; +} .floatbar__reset { display: inline-flex; align-items: center; diff --git a/apps/desktop-tauri/src/floatbar/FloatBar.test.tsx b/apps/desktop-tauri/src/floatbar/FloatBar.test.tsx index f1535d82a2c..ed738204fd5 100644 --- a/apps/desktop-tauri/src/floatbar/FloatBar.test.tsx +++ b/apps/desktop-tauri/src/floatbar/FloatBar.test.tsx @@ -586,7 +586,8 @@ describe("FloatBar", () => { await waitFor(() => { const pill = container.querySelector(".floatbar__pill"); - expect(pill?.textContent).toContain("Usage unavailable"); + expect(pill?.textContent).toContain("!"); + expect(pill?.querySelector(".floatbar__pct")?.getAttribute("aria-label")).toBe("Usage unavailable"); expect(pill?.getAttribute("title")).toBe("Gemini: Usage unavailable"); expect(pill?.textContent).not.toContain("super-secret"); expect(pill?.getAttribute("title")).not.toContain("private.example.test"); @@ -606,7 +607,8 @@ describe("FloatBar", () => { await waitFor(() => { const pill = container.querySelector(".floatbar__pill"); - expect(pill?.textContent).toContain("Sign-in required"); + expect(pill?.textContent).toContain("!"); + expect(pill?.querySelector(".floatbar__pct")?.getAttribute("aria-label")).toBe("Sign-in required"); expect(pill?.textContent).not.toContain("12%"); expect(pill?.getAttribute("title")).toBe("GitHub Copilot: Sign-in required"); }); @@ -628,6 +630,43 @@ describe("FloatBar", () => { }); }); + it("preserves the previous percentage space when a session expires", async () => { + tauriMocks.getCachedProviders.mockResolvedValue([snapshot("codex", "Codex", 100)]); + tauriMocks.getSettingsSnapshot.mockResolvedValue(settings({ enabledProviders: ["codex"] })); + const { container } = renderFloatBar(bootstrap({ enabledProviders: ["codex"] })); + await waitFor(() => expect(container.querySelector(".floatbar__pct")?.textContent).toBe("100%")); + const updated = eventMocks.listen.mock.calls.find(([name]) => name === "provider-updated")?.[1]; + await act(async () => { + updated({ payload: snapshot("codex", "Codex", 0, { errorState: "expiredSession", error: "expired" }) }); + }); + await waitFor(() => { + const pct = container.querySelector(".floatbar__pct"); + const placeholder = pct?.querySelector("span[aria-hidden]"); + expect(placeholder?.textContent).toBe("100%"); + expect(placeholder?.style.visibility).toBe("hidden"); + expect(pct?.getAttribute("aria-label")).toBe("Session expired"); + expect(pct?.querySelector(".floatbar__warning")?.textContent).toBe("!"); + }); + }); + + it("keeps a percentage-sized placeholder after informational text", async () => { + tauriMocks.getCachedProviders.mockResolvedValue([ + snapshot("codex", "Codex", 0, { informational: true, resetDescription: "Unlimited plan" }), + ]); + tauriMocks.getSettingsSnapshot.mockResolvedValue(settings({ enabledProviders: ["codex"] })); + const { container } = renderFloatBar(bootstrap({ enabledProviders: ["codex"] })); + await waitFor(() => expect(container.querySelector(".floatbar__pct")?.textContent).toBe("Unlimited plan")); + const updated = eventMocks.listen.mock.calls.find(([name]) => name === "provider-updated")?.[1]; + await act(async () => { + updated({ payload: snapshot("codex", "Codex", 0, { errorState: "expiredSession", error: "expired" }) }); + }); + await waitFor(() => { + const pct = container.querySelector(".floatbar__pct"); + expect(pct?.getAttribute("aria-label")).toBe("Session expired"); + expect(pct?.querySelector("span[aria-hidden]")?.textContent).toBe("0%"); + }); + }); + it("applies warning tone when remaining drops below the high threshold", async () => { // highUsageThreshold = 70 → high-remaining cutoff = 30%. // claude at 80% used → 20% remaining → critical (also below crit cutoff 10). diff --git a/apps/desktop-tauri/src/floatbar/FloatBar.tsx b/apps/desktop-tauri/src/floatbar/FloatBar.tsx index d30e8f9577b..d0366c1aefc 100644 --- a/apps/desktop-tauri/src/floatbar/FloatBar.tsx +++ b/apps/desktop-tauri/src/floatbar/FloatBar.tsx @@ -234,6 +234,10 @@ function ProviderPill({ : informational ? infoText : `${Math.round(displayPercent)}%`; + // Only percentages size the warning placeholder; informational text would + // widen the pill after an error. + const lastPercentageLabel = useRef("0%"); + if (!state.isProblem && !informational) lastPercentageLabel.current = label; const resetText = useFormattedResetTime( rateWindow.resetsAt, informational ? null : resetDescriptionFallback(rateWindow), @@ -265,8 +269,13 @@ function ProviderPill({
- - {label} + + {state.isProblem ? ( + <> + + + + ) : label} {showResetInline && resetText && inlineReset && ( invoke("claude_accounts_list"); +export const claudeAccountsList = () => invoke("get_claude_accounts_state"); export const claudeReconciliationState = () => invoke("claude_reconciliation_state"); export const claudeAccountAdd = () => invoke("claude_account_add"); +export const claudeAccountReauthenticate = (id: string) => invoke("claude_account_add", { id }); export const claudeAccountCancelLogin = () => invoke("claude_account_cancel_login"); export const claudeAccountSaveCurrent = () => invoke("claude_account_save_current"); export const claudeAccountRemove = (id: string) => invoke("claude_account_remove", { id }); @@ -572,8 +573,8 @@ export function codexAccountAdd(): Promise { return invoke("codex_account_add"); } -export function codexAccountReauthenticate(): Promise { - return invoke("codex_account_reauthenticate"); +export function codexAccountReauthenticate(id?: string): Promise { + return invoke("codex_account_reauthenticate", id ? { id } : undefined); } export function codexAccountRemove(id: string): Promise { diff --git a/apps/desktop-tauri/src/styles.css b/apps/desktop-tauri/src/styles.css index 937a9c0157e..df4b343383b 100644 --- a/apps/desktop-tauri/src/styles.css +++ b/apps/desktop-tauri/src/styles.css @@ -3988,7 +3988,8 @@ html:has(.menu-surface--tray) { flex-shrink: 0; font-size: 0.68rem; font-weight: 600; - padding: 2px 8px; + padding: 2px 6px; + white-space: nowrap; border-radius: 5px; color: var(--text-primary); background: transparent; @@ -3996,6 +3997,14 @@ html:has(.menu-surface--tray) { cursor: pointer; } +.codex-menu-accounts__row-actions { + display: flex; + flex-direction: row; + align-items: center; + flex-shrink: 0; + gap: 4px; +} + .codex-menu-accounts__switch:hover:not(:disabled) { border-color: var(--accent, currentColor); } @@ -6030,3 +6039,28 @@ button, input, select, textarea { .select { max-width: 240px; } + +/* Account login actions follow each provider's account list. */ +.codex-menu-accounts__actions { + display: flex; + flex-wrap: wrap; + gap: 6px; + margin-top: 6px; +} +.codex-menu-accounts__usage { + flex-wrap: wrap; + white-space: normal; +} + +.codex-menu-accounts__action { + min-height: 28px; + padding: 3px 10px; + border: 1px solid var(--panel-border); + border-radius: 5px; + background: transparent; + color: var(--text-primary); + font: inherit; + font-size: 11px; + cursor: pointer; +} +.codex-menu-accounts__action:disabled { opacity: 0.5; cursor: default; } diff --git a/apps/desktop-tauri/src/surfaces/TrayPanel.test.tsx b/apps/desktop-tauri/src/surfaces/TrayPanel.test.tsx index b39b3a36cd1..66299946293 100644 --- a/apps/desktop-tauri/src/surfaces/TrayPanel.test.tsx +++ b/apps/desktop-tauri/src/surfaces/TrayPanel.test.tsx @@ -837,6 +837,7 @@ describe("TrayPanel provider grid", () => { await waitFor(() => { expect(container.querySelector(".menu-surface__footer-zoom")).not.toBeNull(); + expect(container.querySelector(".provider-grid")).not.toBeNull(); }); const surface = container.querySelector(".menu-surface"); diff --git a/apps/desktop-tauri/src/surfaces/settings/providers/sections/credentials/ClaudeAccountsSection.test.tsx b/apps/desktop-tauri/src/surfaces/settings/providers/sections/credentials/ClaudeAccountsSection.test.tsx index 4e993b752e6..e930485f27f 100644 --- a/apps/desktop-tauri/src/surfaces/settings/providers/sections/credentials/ClaudeAccountsSection.test.tsx +++ b/apps/desktop-tauri/src/surfaces/settings/providers/sections/credentials/ClaudeAccountsSection.test.tsx @@ -5,6 +5,7 @@ import type { ClaudeAccount } from "../../../../../types/bridge"; const mocks = vi.hoisted(() => ({ claudeAccountsList: vi.fn(), claudeAccountAdd: vi.fn(), claudeAccountCancelLogin: vi.fn(), claudeAccountSaveCurrent: vi.fn(), claudeAccountRemove: vi.fn(), claudeAccountSwitch: vi.fn(), + claudeAccountReauthenticate: vi.fn(), claudeReconciliationState: vi.fn(), claudeSwapAccountsList: vi.fn(), claudeSwapAccountSwitch: vi.fn(), getSettingsSnapshot: vi.fn(), updateSettings: vi.fn(), @@ -19,6 +20,18 @@ const current: ClaudeAccount = { id: "one:org", email: "one@example.com", organi const other: ClaudeAccount = { ...current, id: "two:org", email: "two@example.com", isActive: false, isSaved: true }; describe("ClaudeAccountsSection", () => { + it("offers targeted reauthentication only for the expired account", async () => { + mocks.claudeAccountsList.mockResolvedValue([current, { ...other, needsAuthentication: true }]); + mocks.claudeAccountReauthenticate.mockResolvedValue(undefined); + render(); + const refresh = await screen.findByRole("button", { name: `CodexAccountsReauthenticateButton: ${other.email}` }); + expect(screen.getAllByRole("button", { name: /CodexAccountsReauthenticateButton/ })).toHaveLength(1); + mocks.claudeAccountsList.mockResolvedValue([current, other]); + await act(async () => fireEvent.click(refresh)); + expect(mocks.claudeAccountReauthenticate).toHaveBeenCalledWith(other.id); + expect(screen.queryByRole("button", { name: /CodexAccountsReauthenticateButton/ })).toBeNull(); + expect(mocks.claudeAccountSwitch).not.toHaveBeenCalled(); + }); beforeEach(() => { vi.resetAllMocks(); events.listen.mockResolvedValue(() => {}); diff --git a/apps/desktop-tauri/src/surfaces/settings/providers/sections/credentials/ClaudeAccountsSection.tsx b/apps/desktop-tauri/src/surfaces/settings/providers/sections/credentials/ClaudeAccountsSection.tsx index 467e676b184..333c1c0083b 100644 --- a/apps/desktop-tauri/src/surfaces/settings/providers/sections/credentials/ClaudeAccountsSection.tsx +++ b/apps/desktop-tauri/src/surfaces/settings/providers/sections/credentials/ClaudeAccountsSection.tsx @@ -7,12 +7,14 @@ import type { LocaleKey } from "../../../../../i18n/keys"; import { claudeAccountsList, claudeAccountAdd, + claudeAccountReauthenticate, claudeAccountCancelLogin, claudeAccountSaveCurrent, claudeAccountRemove, claudeAccountSwitch, } from "../../../../../lib/tauri"; import { ClaudeSwapAccountsSection } from "./ClaudeSwapAccountsSection"; +import ClaudeAccountUsage from "../../../../../components/ClaudeAccountUsage"; import { localClaudeReconciliationOutcome, useClaudeReconciliation, @@ -109,6 +111,7 @@ export function ClaudeAccountsSection({ account.plan, ].filter(Boolean).join(" · ")} + {account.isActive && ( {t("TokenAccountActive")} @@ -116,6 +119,15 @@ export function ClaudeAccountsSection({ )}
+ {account.needsAuthentication && } {!account.isActive && account.isSaved && (
- {account.source === "ambient" && ( - - )} + {accountNeedsAuthentication[account.id] && }