diff --git a/apps/desktop-tauri/src-tauri/src/commands/bridge.rs b/apps/desktop-tauri/src-tauri/src/commands/bridge.rs index 1a85da249b..d24d087870 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/bridge.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/bridge.rs @@ -103,6 +103,10 @@ pub struct CostSnapshotBridge { #[serde(default)] pub balance: Option, #[serde(default)] + pub balance_updated_at: Option, + #[serde(default)] + pub account_id: Option, + #[serde(default)] pub formatted_balance: Option, #[serde(default)] pub daily: Vec, @@ -195,6 +199,8 @@ pub struct ProviderUsageSnapshot { pub account_email: Option, #[serde(default = "default_source_label")] pub source_label: String, + #[serde(default)] + pub has_successful_claude_cli_quota: bool, /// Defaults to launch time when absent so the card renders as fresh. #[serde(default)] pub updated_at: String, @@ -378,6 +384,8 @@ impl ProviderUsageSnapshot { formatted_used: c.format_used(), formatted_limit: c.format_limit(), balance: c.balance, + balance_updated_at: c.balance_updated_at.map(|dt| dt.to_rfc3339()), + account_id: c.account_id.clone(), formatted_balance: c.format_balance(), daily: c .daily @@ -392,6 +400,7 @@ impl ProviderUsageSnapshot { plan_name: usage.login_method.clone(), account_email: usage.account_email.clone(), source_label: result.source_label.clone(), + has_successful_claude_cli_quota: result.has_successful_claude_cli_quota, updated_at: usage.updated_at.to_rfc3339(), error: None, error_state: codexbar::core::ProviderStateKind::Ready, @@ -438,6 +447,7 @@ impl ProviderUsageSnapshot { plan_name: None, account_email: None, source_label: String::new(), + has_successful_claude_cli_quota: false, updated_at: chrono::Utc::now().to_rfc3339(), error: Some(error), error_state: state_kind, diff --git a/apps/desktop-tauri/src-tauri/src/commands/providers.rs b/apps/desktop-tauri/src-tauri/src/commands/providers.rs index 237e03f88e..6d39d6ca24 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/providers.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/providers.rs @@ -447,11 +447,13 @@ async fn refresh_provider( /// F6 (upstream 0.48.0 UsageStore+CodexResetBackfill): backfill missing /// `resets_at` / `reset_description` on fresh Codex windows from the cached -/// lane data when the cached reset is still future. Fresh `used_percent` is -/// untouched; only the reset timestamp/description are backfilled. +/// lane data when the cached reset is still future. z.ai five-hour cached +/// resets use the same plausibility bound as the provider parser, so an +/// impossible rejected reset cannot be restored from the cache. Fresh +/// `used_percent` is untouched; only reset metadata is backfilled. /// -/// This is Codex-scoped by design (upstream: "Provider-specific by design"): -/// other providers do not carry bounded resume state. +/// This remains provider-scoped by design (upstream: "Provider-specific by +/// design"): only Codex and z.ai carry the relevant bounded reset semantics. /// /// Applies to the bridge snapshot before publishing so every surface (tray, /// CLI, frontend) sees the backfilled reset instead of a missing one. @@ -460,19 +462,23 @@ pub(super) fn codex_reset_backfill( cached: Option<&ProviderUsageSnapshot>, ) { let Some(cached) = cached else { return }; - if snapshot.provider_id != "codex" { + if !matches!(snapshot.provider_id.as_str(), "codex" | "zai") { return; } // Backfill each slot from the corresponding cached slot. - backfill_slot_window(&mut snapshot.primary, &cached.primary); + backfill_slot_window( + &snapshot.provider_id, + &mut snapshot.primary, + &cached.primary, + ); if let (Some(fresh), Some(cached_sec)) = (&mut snapshot.secondary, &cached.secondary) { - backfill_slot_window(fresh, cached_sec); + backfill_slot_window(&snapshot.provider_id, fresh, cached_sec); } // Tertiary (monthly/other): the Codex bridge doesn't normally populate this, // but the slot exists for forward-compat. Backfill when available. if let (Some(fresh), Some(cached_ter)) = (&mut snapshot.tertiary, &cached.tertiary) { - backfill_slot_window(fresh, cached_ter); + backfill_slot_window(&snapshot.provider_id, fresh, cached_ter); } } @@ -480,6 +486,7 @@ pub(super) fn codex_reset_backfill( /// cached window whose reset is still in the future. `used_percent` is never /// overwritten (upstream: "fresh used_percent untouched"). fn backfill_slot_window( + provider_id: &str, fresh: &mut bridge::RateWindowSnapshot, cached: &bridge::RateWindowSnapshot, ) { @@ -491,11 +498,20 @@ fn backfill_slot_window( }; // Only backfill when the cached reset is still future — a stale reset is // worse than a missing one. - if let Ok(cached_dt) = chrono::DateTime::parse_from_rfc3339(cached_reset) { - if cached_dt <= chrono::Utc::now() { - return; - } - } else { + let Ok(cached_dt) = chrono::DateTime::parse_from_rfc3339(cached_reset) else { + return; + }; + let now = chrono::Utc::now(); + if cached_dt <= now { + return; + } + // A missing z.ai five-hour reset can mean the provider rejected an + // impossible future timestamp. Do not let equally impossible cached + // evidence undo that rejection, but preserve a plausible cached reset. + if provider_id == "zai" + && fresh.window_minutes == Some(300) + && cached_dt > now + chrono::Duration::minutes(5 * 60 + 1) + { return; } fresh.resets_at = Some(cached_reset.clone()); @@ -521,7 +537,7 @@ pub(super) fn preserve_last_good_transient_failure( return snapshot; } - let Some(previous) = guard + let Some(mut previous) = guard .provider_cache .iter() .find(|cached| cached.provider_id == id.cli_name() && cached.error.is_none()) @@ -529,6 +545,9 @@ pub(super) fn preserve_last_good_transient_failure( else { return snapshot; }; + // Preserved quota remains useful for display, but the failed current + // attempt cannot prove that Claude CLI is available for account actions. + previous.has_successful_claude_cli_quota = false; let count = guard .transient_provider_failure_counts @@ -1133,6 +1152,7 @@ mod reset_backfill_tests { plan_name: None, account_email: None, source_label: String::new(), + has_successful_claude_cli_quota: false, updated_at: "2026-01-01T00:00:00Z".into(), error: None, error_state: codexbar::core::ProviderStateKind::Ready, @@ -1192,6 +1212,24 @@ mod reset_backfill_tests { assert!(fresh.primary.resets_at.is_none(), "non-codex skip"); } + #[test] + fn zai_five_hour_backfill_rejects_impossible_cached_reset() { + for (offset, should_backfill) in [ + (chrono::Duration::hours(1), true), + (chrono::Duration::hours(10), false), + ] { + let future = (chrono::Utc::now() + offset).to_rfc3339(); + let mut cached = codex_snapshot(win(50.0, Some(&future))); + cached.provider_id = "zai".into(); + let mut fresh = codex_snapshot(win(30.0, None)); + fresh.provider_id = "zai".into(); + fresh.primary.reset_description = Some("5-hour".into()); + codex_reset_backfill(&mut fresh, Some(&cached)); + assert_eq!(fresh.primary.resets_at.is_some(), should_backfill); + assert!((fresh.primary.used_percent - 30.0).abs() < f64::EPSILON); + } + } + #[test] fn f6_skips_when_no_cached_snapshot() { let mut fresh = codex_snapshot(win(30.0, None)); diff --git a/apps/desktop-tauri/src-tauri/src/commands/tests.rs b/apps/desktop-tauri/src-tauri/src/commands/tests.rs index bab3dc1731..8b38489ac5 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/tests.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/tests.rs @@ -939,6 +939,7 @@ fn provider_cache_upsert_replaces_existing_provider() { cost: None, wayfinder_usage: None, source_label: "CLI".to_string(), + has_successful_claude_cli_quota: false, pace_authoritative: true, }; let mut first = @@ -963,6 +964,7 @@ fn provider_cache_prunes_disabled_providers() { cost: None, wayfinder_usage: None, source_label: "CLI".to_string(), + has_successful_claude_cli_quota: false, pace_authoritative: true, }; let codex = @@ -994,6 +996,7 @@ fn hiding_codex_spark_rows_preserves_other_extra_usage() { cost: None, wayfinder_usage: None, source_label: "CLI".to_string(), + has_successful_claude_cli_quota: false, pace_authoritative: true, }; let mut snapshot = @@ -1025,6 +1028,7 @@ fn claude_transient_auth_failure_preserves_first_last_good_snapshot() { cost: None, wayfinder_usage: None, source_label: "OAuth".to_string(), + has_successful_claude_cli_quota: false, pace_authoritative: true, }; let good = @@ -1056,6 +1060,7 @@ fn claude_repeated_auth_failure_surfaces_error() { cost: None, wayfinder_usage: None, source_label: "OAuth".to_string(), + has_successful_claude_cli_quota: false, pace_authoritative: true, }; let good = @@ -1092,6 +1097,7 @@ fn claude_cloudflare_challenge_retains_prior_usage_while_surfaceing_guidance() { cost: None, wayfinder_usage: None, source_label: "OAuth".to_string(), + has_successful_claude_cli_quota: false, pace_authoritative: true, }; let good = @@ -1139,6 +1145,7 @@ fn claude_cloudflare_challenge_keeps_prior_usage_when_guidance_surfaces() { cost: None, wayfinder_usage: None, source_label: "Web".to_string(), + has_successful_claude_cli_quota: false, pace_authoritative: true, }; let mut good = @@ -1182,6 +1189,7 @@ fn claude_cli_parse_failure_keeps_last_good_every_time() { cost: None, wayfinder_usage: None, source_label: "CLI".to_string(), + has_successful_claude_cli_quota: true, pace_authoritative: true, }; let good = @@ -1205,6 +1213,7 @@ fn claude_cli_parse_failure_keeps_last_good_every_time() { assert_eq!(first.error, None); assert_eq!(first.primary.used_percent, 17.0); + assert!(!first.has_successful_claude_cli_quota); // Parse failures keep last-good on every refresh (upstream #2247), unlike one-shot auth. assert_eq!(second.error, None); assert_eq!(second.primary.used_percent, 17.0); @@ -1218,6 +1227,7 @@ fn claude_hard_credentials_missing_does_not_preserve_stale() { cost: None, wayfinder_usage: None, source_label: "OAuth".to_string(), + has_successful_claude_cli_quota: false, pace_authoritative: true, }; let good = @@ -1379,6 +1389,7 @@ fn japanese_provider_snapshot_localizes_weekly_label() { cost: None, wayfinder_usage: None, source_label: "OAuth".to_string(), + has_successful_claude_cli_quota: false, pace_authoritative: true, }; @@ -1409,6 +1420,7 @@ fn japanese_provider_snapshot_localizes_pace_reserve_description() { cost: None, wayfinder_usage: None, source_label: "OAuth".to_string(), + has_successful_claude_cli_quota: false, pace_authoritative: true, }; diff --git a/apps/desktop-tauri/src-tauri/src/commands/usage_spend.rs b/apps/desktop-tauri/src-tauri/src/commands/usage_spend.rs index 04c0b620ef..c1012551c0 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/usage_spend.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/usage_spend.rs @@ -62,17 +62,118 @@ pub struct UsageSpendSummary { pub contract: SpendContract, } -#[derive(Clone)] +#[derive(Debug, Clone)] struct CachedUsageSpendSummary { key: String, summary: UsageSpendSummary, + refresh_owner: Option, } -static USAGE_SPEND_SUMMARY_CACHE: OnceLock>> = - OnceLock::new(); +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum UsageSpendRefreshPhase { + Indexing, + Paused, +} -fn usage_spend_summary_cache() -> &'static Mutex> { - USAGE_SPEND_SUMMARY_CACHE.get_or_init(|| Mutex::new(None)) +#[derive(Debug, Clone, PartialEq, Eq)] +struct UsageSpendRefreshOwner { + generation: u64, + scope: String, +} + +#[derive(Default)] +struct UsageSpendCoordinator { + next_generation: u64, + current: Option<(UsageSpendRefreshOwner, UsageSpendRefreshPhase)>, + cache: Option, +} + +impl UsageSpendCoordinator { + fn begin(&mut self, scope: String) -> UsageSpendRefreshOwner { + self.next_generation = self.next_generation.wrapping_add(1).max(1); + let owner = UsageSpendRefreshOwner { + generation: self.next_generation, + scope, + }; + self.current = Some((owner.clone(), UsageSpendRefreshPhase::Indexing)); + owner + } + + fn pause(&mut self, owner: &UsageSpendRefreshOwner) { + if let Some((current, phase)) = self.current.as_mut() + && current == owner + && *phase == UsageSpendRefreshPhase::Indexing + { + *phase = UsageSpendRefreshPhase::Paused; + } + } + + fn is_current(&self, owner: &UsageSpendRefreshOwner) -> bool { + self.current + .as_ref() + .is_some_and(|(current, _)| current == owner) + } + + fn clear_if_indexing(&mut self, owner: &UsageSpendRefreshOwner) -> bool { + let Some((current, phase)) = self.current.as_ref() else { + return false; + }; + if current != owner || *phase != UsageSpendRefreshPhase::Indexing { + return false; + } + self.current = None; + true + } +} + +static USAGE_SPEND_COORDINATOR: OnceLock> = OnceLock::new(); + +fn usage_spend_coordinator() -> &'static Mutex { + USAGE_SPEND_COORDINATOR.get_or_init(|| Mutex::new(UsageSpendCoordinator::default())) +} + +fn clear_summary_refreshing(summary: &mut UsageSpendSummary) { + for row in &mut summary.rows { + row.refreshing = false; + row.stale_updated_at = None; + } +} + +fn summary_is_refreshing(summary: &UsageSpendSummary) -> bool { + summary.rows.iter().any(|row| row.refreshing) +} + +fn mark_refresh_paused_if_codex_scan_paused( + coordinator: &mut UsageSpendCoordinator, + owner: &UsageSpendRefreshOwner, + refreshing: bool, + codex_scan_pause_reason: Option<&codexbar::core::CodexScanPauseReason>, +) { + if refreshing && codex_scan_pause_reason.is_some() { + coordinator.pause(owner); + } +} + +/// Retire an invalidated owner without allowing it to clear a replacement. +fn clear_usage_spend_refresh_if_owned(owner: &UsageSpendRefreshOwner) { + let Ok(mut coordinator) = usage_spend_coordinator().lock() else { + return; + }; + if !coordinator.clear_if_indexing(owner) { + return; + } + if let Some(existing) = coordinator.cache.as_mut() + && existing.refresh_owner.as_ref() == Some(owner) + { + clear_summary_refreshing(&mut existing.summary); + existing.refresh_owner = None; + } +} + +struct BuiltUsageSpendSummary { + key: String, + summary: UsageSpendSummary, + refresh_owner: Option, } #[tauri::command] @@ -88,11 +189,29 @@ pub async fn get_usage_spend_summary( let selected_days = history_days.unwrap_or(30); let force_refresh = force_refresh.unwrap_or(false); - tauri::async_runtime::spawn_blocking(move || { + let built = tauri::async_runtime::spawn_blocking(move || { build_usage_spend_summary_cached(&cached, selected_days, force_refresh) }) .await - .map_err(|e| format!("usage spend worker failed: {e}"))? + .map_err(|e| format!("usage spend worker failed: {e}"))??; + let current_cached = state + .lock() + .map_err(|e| e.to_string()) + .map(|guard| guard.provider_cache.clone())?; + let current_key = usage_spend_cache_key( + ¤t_cached, + selected_days, + &codexbar::settings::Settings::load(), + ); + if current_key != built.key { + if let Some(owner) = built.refresh_owner.as_ref() { + clear_usage_spend_refresh_if_owned(owner); + } + let mut summary = built.summary; + clear_summary_refreshing(&mut summary); + return Ok(summary); + } + Ok(built.summary) } #[tauri::command] @@ -112,26 +231,68 @@ fn build_usage_spend_summary_cached( cached: &[ProviderUsageSnapshot], selected_days: u32, force_refresh: bool, -) -> Result { +) -> Result { let settings = codexbar::settings::Settings::load(); let key = usage_spend_cache_key(cached, selected_days, &settings); - let mut guard = usage_spend_summary_cache() - .lock() - .map_err(|error| error.to_string())?; - if !force_refresh - && let Some(existing) = guard.as_ref() - && existing.key == key { - return Ok(existing.summary.clone()); + let guard = usage_spend_coordinator() + .lock() + .map_err(|error| error.to_string())?; + if !force_refresh + && let Some(existing) = guard.cache.as_ref() + && existing.key == key + { + return Ok(BuiltUsageSpendSummary { + key: existing.key.clone(), + summary: existing.summary.clone(), + refresh_owner: existing.refresh_owner.clone(), + }); + } } - // Hold the cache mutex while building: callers for the same app revision - // coalesce behind this single scan instead of starting parallel rescans. + let owner = { + let mut coordinator = usage_spend_coordinator() + .lock() + .map_err(|error| error.to_string())?; + coordinator.begin(key.clone()) + }; let summary = build_usage_spend_summary(cached, selected_days, &settings, force_refresh); - *guard = Some(CachedUsageSpendSummary { - key, + let refreshing = summary_is_refreshing(&summary); + let codex_scan_pause_reason = + codexbar::core::JsonlScanner::load_cache_status(codexbar::core::ProviderId::Codex, None) + .codex_scan_pause_reason; + + let mut coordinator = usage_spend_coordinator() + .lock() + .map_err(|error| error.to_string())?; + mark_refresh_paused_if_codex_scan_paused( + &mut coordinator, + &owner, + refreshing, + codex_scan_pause_reason.as_ref(), + ); + if !coordinator.is_current(&owner) { + let mut summary = summary; + clear_summary_refreshing(&mut summary); + return Ok(BuiltUsageSpendSummary { + key, + summary, + refresh_owner: Some(owner), + }); + } + if !refreshing { + coordinator.clear_if_indexing(&owner); + } + let refresh_owner = refreshing.then(|| owner.clone()); + coordinator.cache = Some(CachedUsageSpendSummary { + key: key.clone(), summary: summary.clone(), + refresh_owner: refresh_owner.clone(), }); - Ok(summary) + Ok(BuiltUsageSpendSummary { + key, + summary, + refresh_owner, + }) } fn usage_spend_cache_key( @@ -545,6 +706,48 @@ fn cached_spend(snapshot: Option<&ProviderUsageSnapshot>) -> SpendValues { mod cache_key_tests { use super::*; + #[test] + fn invalidated_owner_clears_orphaned_indexing_activity() { + let mut coordinator = UsageSpendCoordinator::default(); + let owner = coordinator.begin("account:old".to_string()); + + assert!(coordinator.clear_if_indexing(&owner)); + assert!(!coordinator.is_current(&owner)); + } + + #[test] + fn old_owner_cleanup_cannot_clear_a_replacement() { + let mut coordinator = UsageSpendCoordinator::default(); + let old = coordinator.begin("account:old".to_string()); + let replacement = coordinator.begin("account:new".to_string()); + + assert!(!coordinator.clear_if_indexing(&old)); + assert!(coordinator.is_current(&replacement)); + } + + #[test] + fn settings_replacement_preserves_an_intentional_pause() { + let mut coordinator = UsageSpendCoordinator::default(); + let old = coordinator.begin("settings:old".to_string()); + let replacement = coordinator.begin("settings:new".to_string()); + let status = codexbar::core::CachedCostReadStatus { + codex_scan_pause_reason: Some(codexbar::core::CodexScanPauseReason::NoProgress), + ..Default::default() + }; + mark_refresh_paused_if_codex_scan_paused( + &mut coordinator, + &replacement, + true, + status.codex_scan_pause_reason.as_ref(), + ); + + assert!(!coordinator.clear_if_indexing(&old)); + assert_eq!( + coordinator.current.as_ref().map(|(_, phase)| *phase), + Some(UsageSpendRefreshPhase::Paused) + ); + } + #[test] fn privacy_mode_is_part_of_usage_spend_cache_identity() { let public = usage_spend_cache_key_with_privacy(&[], 30, false, false, false); diff --git a/apps/desktop-tauri/src-tauri/src/powertoys.rs b/apps/desktop-tauri/src-tauri/src/powertoys.rs index 492385c336..5c6352157e 100644 --- a/apps/desktop-tauri/src-tauri/src/powertoys.rs +++ b/apps/desktop-tauri/src-tauri/src/powertoys.rs @@ -199,6 +199,7 @@ mod tests { plan_name: Some("Team".to_string()), account_email: Some("dev@example.com".to_string()), source_label: "web".to_string(), + has_successful_claude_cli_quota: false, updated_at: "2026-07-09T00:00:00Z".to_string(), error: None, error_state: codexbar::core::ProviderStateKind::Ready, @@ -245,6 +246,7 @@ mod tests { plan_name: None, account_email: None, source_label: "web".to_string(), + has_successful_claude_cli_quota: false, updated_at: "2026-07-09T00:00:00Z".to_string(), error: None, error_state: codexbar::core::ProviderStateKind::Ready, diff --git a/apps/desktop-tauri/src-tauri/src/tray_bridge.rs b/apps/desktop-tauri/src-tauri/src/tray_bridge.rs index e0b38d55cf..5c83e2cac1 100644 --- a/apps/desktop-tauri/src-tauri/src/tray_bridge.rs +++ b/apps/desktop-tauri/src-tauri/src/tray_bridge.rs @@ -1078,12 +1078,15 @@ mod tests { formatted_limit: Some(format!("${limit:.2}")), balance: None, formatted_balance: None, + balance_updated_at: None, + account_id: None, daily: Vec::new(), always_visible: false, }), plan_name: None, account_email: None, source_label: String::new(), + has_successful_claude_cli_quota: false, updated_at: "2025-01-01T00:00:00Z".into(), error: None, error_state: codexbar::core::ProviderStateKind::Ready, diff --git a/apps/desktop-tauri/src-tauri/src/usage_metric.rs b/apps/desktop-tauri/src-tauri/src/usage_metric.rs index 48a84426de..9d762c392f 100644 --- a/apps/desktop-tauri/src-tauri/src/usage_metric.rs +++ b/apps/desktop-tauri/src-tauri/src/usage_metric.rs @@ -211,6 +211,7 @@ mod tests { plan_name: None, account_email: None, source_label: "test".to_string(), + has_successful_claude_cli_quota: false, updated_at: "2026-08-16T00:00:00Z".to_string(), error: None, error_state: codexbar::core::ProviderStateKind::Ready, diff --git a/apps/desktop-tauri/src/lib/claudeAccountActions.test.ts b/apps/desktop-tauri/src/lib/claudeAccountActions.test.ts new file mode 100644 index 0000000000..dc54c1b1bd --- /dev/null +++ b/apps/desktop-tauri/src/lib/claudeAccountActions.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it } from "vitest"; +import type { ProviderUsageSnapshot } from "../types/bridge"; +import { hasSuccessfulClaudeCliQuota } from "./claudeAccountActions"; + +function provider( + overrides: Partial = {}, +): Pick< + ProviderUsageSnapshot, + | "providerId" + | "sourceLabel" + | "hasSuccessfulClaudeCliQuota" + | "error" + | "primary" + | "secondary" +> { + return { + providerId: "claude", + sourceLabel: "Claude CLI", + hasSuccessfulClaudeCliQuota: true, + error: null, + primary: { + usedPercent: 25, + remainingPercent: 75, + windowMinutes: null, + isExhausted: false, + resetsAt: null, + resetDescription: null, + reservePercent: null, + reserveDescription: null, + isInformational: false, + }, + secondary: null, + ...overrides, + }; +} + +describe("hasSuccessfulClaudeCliQuota", () => { + it("allows account actions without an identity field", () => { + expect(hasSuccessfulClaudeCliQuota(provider())).toBe(true); + }); + + it("does not treat retained, failed, or non-CLI data as account proof", () => { + expect(hasSuccessfulClaudeCliQuota(provider({ hasSuccessfulClaudeCliQuota: false }))).toBe( + false, + ); + expect(hasSuccessfulClaudeCliQuota(provider({ error: "timed out" }))).toBe(false); + expect( + hasSuccessfulClaudeCliQuota( + provider({ + primary: { + usedPercent: 25, + remainingPercent: 75, + windowMinutes: null, + isExhausted: false, + resetsAt: null, + resetDescription: "Status", + reservePercent: null, + reserveDescription: null, + isInformational: true, + }, + }), + ), + ).toBe(false); + }); +}); diff --git a/apps/desktop-tauri/src/lib/claudeAccountActions.ts b/apps/desktop-tauri/src/lib/claudeAccountActions.ts new file mode 100644 index 0000000000..f5e68d1ab3 --- /dev/null +++ b/apps/desktop-tauri/src/lib/claudeAccountActions.ts @@ -0,0 +1,33 @@ +import type { ProviderUsageSnapshot, RateWindowSnapshot } from "../types/bridge"; + +/** + * A Claude CLI quota response proves that account actions are useful even + * when Claude does not expose an email or other account identity. + * + * Keep this deliberately narrower than "has Claude data": retained data, + * web/OAuth data, and failed refreshes must not authenticate an account. + */ +export function hasSuccessfulClaudeCliQuota( + provider: Pick< + ProviderUsageSnapshot, + | "providerId" + | "hasSuccessfulClaudeCliQuota" + | "error" + | "primary" + | "secondary" + >, +): boolean { + if ( + provider.providerId !== "claude" || + provider.error !== null || + !provider.hasSuccessfulClaudeCliQuota + ) { + return false; + } + + return hasQuotaWindow(provider.primary) || hasQuotaWindow(provider.secondary); +} + +function hasQuotaWindow(window: RateWindowSnapshot | null): boolean { + return window !== null && !window.isInformational && Number.isFinite(window.usedPercent); +} diff --git a/apps/desktop-tauri/src/surfaces/TrayPanel.tsx b/apps/desktop-tauri/src/surfaces/TrayPanel.tsx index 19317626b9..68b1e758c8 100644 --- a/apps/desktop-tauri/src/surfaces/TrayPanel.tsx +++ b/apps/desktop-tauri/src/surfaces/TrayPanel.tsx @@ -2,7 +2,13 @@ import { Fragment, useEffect, useState, type CSSProperties } from "react"; import { getCurrentWindow } from "@tauri-apps/api/window"; import type { BootstrapState, ProviderUsageSnapshot, UsageSpendSummary } from "../types/bridge"; import type { LocaleKey } from "../i18n/keys"; -import { beginFlyoutGesture, getUsageSpendSummary, openProviderDashboard, openProviderStatusPage } from "../lib/tauri"; +import { + beginFlyoutGesture, + getUsageSpendSummary, + openProviderDashboard, + openProviderStatusPage, + openSettingsWindow, +} from "../lib/tauri"; import { TRAY_SCALE_MAX, TRAY_SCALE_MIN, @@ -14,6 +20,7 @@ import MenuSurface, { MenuEmpty } from "../components/MenuSurface"; import UpdateBanner from "../components/UpdateBanner"; import ProviderGrid from "../components/ProviderGrid"; import AgentSessions from "../components/AgentSessions"; +import { hasSuccessfulClaudeCliQuota } from "../lib/claudeAccountActions"; /** Provider IDs that have a dashboard URL in the backend */ const HAS_DASHBOARD = new Set([ @@ -137,6 +144,12 @@ export default function TrayPanel({ state }: { state: BootstrapState }) { ); }; + const selectedProvider = selectedProviderId + ? sorted.find((provider) => provider.providerId === selectedProviderId) ?? null + : null; + const canSwitchClaudeAccount = + selectedProvider !== null && hasSuccessfulClaudeCliQuota(selectedProvider); + if (sorted.length === 0) { return (
@@ -208,9 +221,24 @@ export default function TrayPanel({ state }: { state: BootstrapState }) { ))}
{/* Context actions — detail mode only, matches macOS actionsSection */} - {selectedProviderId && (HAS_DASHBOARD.has(selectedProviderId) || HAS_STATUS_PAGE.has(selectedProviderId)) && ( + {selectedProviderId && + (HAS_DASHBOARD.has(selectedProviderId) || + HAS_STATUS_PAGE.has(selectedProviderId) || + canSwitchClaudeAccount) && (
+ {canSwitchClaudeAccount && ( + + )} {HAS_DASHBOARD.has(selectedProviderId) && (