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 04b2471fe0..317cdc9f5e 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/claude_accounts.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/claude_accounts.rs @@ -1,4 +1,4 @@ -use super::invalidate_account_usage; +use super::{claude_reconciliation, invalidate_account_usage}; use crate::state::AppState; use codexbar::core::ProviderId; use codexbar::providers::claude::accounts::{self, AccountManager, ClaudeAccount}; @@ -8,10 +8,12 @@ use codexbar::providers::claude::claude_swap::{ }; use serde::Serialize; use std::sync::Mutex; +use std::time::Duration; use tauri::Emitter; use tauri::Manager; static MUTATION: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(()); +const AMBIENT_RECONCILIATION_GRACE: Duration = Duration::from_secs(5); #[tauri::command] pub fn claude_accounts_list() -> Result, String> { @@ -99,47 +101,64 @@ fn account_row_for_slot( .ok_or_else(|| "claude-swap did not report that account slot.".to_string()) } -/// Emitted when a Claude account change needs a provider refresh before the -/// settle. Event order is load-bearing for every listener: -/// -/// 1. `claude-accounts-reconciling` — listeners show the reconciling phase. -/// 2. the bounded provider refresh runs; superseded batches are detected below. -/// 3. `claude-accounts-reconciled` — the terminal marker; settling must be -/// event-driven, never inferred from a switch promise resolving. -/// 4. `claude-accounts-updated` + tray rebuild — the settled reload. -/// -/// Do not reorder these emits. A refresh that never started or was superseded -/// mid-flight keeps the reconciling phase armed instead of settling, so no -/// surface shows "settled" while a refresh is still in flight. -async fn refresh_after_claude_change(app: tauri::AppHandle) -> Result<(), String> { - let pending = { +fn grace_outcome( + completed: Option, + pending: &claude_reconciliation::ClaudeReconciliationSnapshot, +) -> claude_reconciliation::ClaudeReconciliationSnapshot { + completed.unwrap_or_else(|| pending.clone()) +} + +/// Start a generation-owned provider refresh and return its authoritative +/// snapshot. A slow refresh returns `pending`; its detached worker later +/// updates application state and emits one best-effort state-change event. +async fn refresh_after_claude_change( + app: tauri::AppHandle, +) -> Result { + let (usage_pending, reconciliation, reconciliation_pending) = { let state = app.state::>(); let mut state = state.lock().map_err(|e| e.to_string())?; - invalidate_account_usage(&mut state, ProviderId::Claude) + let usage_pending = invalidate_account_usage(&mut state, ProviderId::Claude); + let (token, snapshot) = state.claude_reconciliation.begin(); + (usage_pending, token, snapshot) }; - crate::events::emit_provider_updated(&app, &pending); - let _emit = app.emit("claude-accounts-reconciling", ()); - let refresh_result = super::refresh_providers(app.clone()).await; - if !refresh_providers_ran(&app) { - // begin_provider_refresh skipped (another batch owns the refresh) or - // finish_provider_refresh dropped a superseded generation: a refresh - // is still in flight, so stay in the reconciling phase and let the - // owning batch's completion settle listeners. - return refresh_result; + crate::events::emit_provider_updated(&app, &usage_pending); + claude_reconciliation::emit(&app, &reconciliation_pending); + let refresh_app = app.clone(); + let mut ambient = tauri::async_runtime::spawn(async move { + let result = claude_reconciliation::ClaudeReconciliationResult::from_refresh( + super::do_refresh_providers_with_outcome(&refresh_app).await, + ); + let (is_current, snapshot) = { + let state = refresh_app.state::>(); + let mut state = state + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + state.claude_reconciliation.complete(reconciliation, result) + }; + if is_current { + claude_reconciliation::emit(&refresh_app, &snapshot); + changed(&refresh_app); + } else { + tracing::debug!( + generation = snapshot.generation, + detail = %snapshot.detail, + "Claude reconciliation completed after a newer generation" + ); + } + snapshot + }); + match tokio::time::timeout(AMBIENT_RECONCILIATION_GRACE, &mut ambient).await { + Ok(joined) => joined.map_err(|error| error.to_string()), + Err(_) => Ok(grace_outcome(None, &reconciliation_pending)), } - let _reconciled = app.emit("claude-accounts-reconciled", ()); - changed(&app); - refresh_result } -/// Whether the completed refresh batch owned the generation it published -/// under. A skipped or superseded batch must not settle account listeners. -fn refresh_providers_ran(app: &tauri::AppHandle) -> bool { - let state = app.state::>(); - state - .lock() - .map(|guard| !guard.is_refreshing) - .unwrap_or(false) +#[tauri::command] +pub fn claude_reconciliation_state( + state: tauri::State<'_, Mutex>, +) -> Result, String> { + let state = state.lock().map_err(|error| error.to_string())?; + Ok(state.claude_reconciliation.snapshot()) } #[derive(Debug, Clone, Copy)] @@ -198,6 +217,10 @@ fn reauthentication_is_repaired(account: &ClaudeSwapAccountRow) -> bool { account.is_active && account.usage_status == ClaudeSwapUsageStatus::Ok } +fn switch_is_reconciled(account: &ClaudeSwapAccountRow) -> bool { + account.is_active +} + fn run_claude_swap_operation( config: &ClaudeSwapConfig, slot: u32, @@ -214,15 +237,11 @@ fn run_claude_swap_operation( if !result.switched { return Err(result.reason); } - if matches!(operation, ClaudeSwapAccountOperation::Switch) { - return Ok(ClaudeSwapMutationOutcome::confirmed()); - } - let after = match claude_swap::read_account_list(&config.executable_path) { Ok(list) => list, Err(error) => { return Ok(ClaudeSwapMutationOutcome::applied_unconfirmed(format!( - "claude-swap re-authentication was applied, but confirmation failed: {error}" + "claude-swap account change was applied, but confirmation failed: {error}" ))); } }; @@ -230,11 +249,15 @@ fn run_claude_swap_operation( Ok(account) => account, Err(error) => return Ok(ClaudeSwapMutationOutcome::applied_unconfirmed(error)), }; - if reauthentication_is_repaired(account) { + let confirmed = match operation { + ClaudeSwapAccountOperation::Switch => switch_is_reconciled(account), + ClaudeSwapAccountOperation::Reauthenticate => reauthentication_is_repaired(account), + }; + if confirmed { Ok(ClaudeSwapMutationOutcome::confirmed()) } else { Ok(ClaudeSwapMutationOutcome::applied_unconfirmed( - "claude-swap re-authentication completed without a confirmed account repair.", + "claude-swap account change completed without a confirmed active account.", )) } } @@ -242,18 +265,16 @@ fn run_claude_swap_operation( async fn finish_claude_swap_mutation( app: tauri::AppHandle, outcome: ClaudeSwapMutationOutcome, -) -> Result<(), String> { +) -> Result { if !outcome.applied { - return outcome.error.map_or(Ok(()), Err); + return Err(outcome + .error + .unwrap_or_else(|| "Claude account operation was not applied.".to_string())); } - let refresh_error = refresh_after_claude_change(app).await.err(); - match (outcome.error, refresh_error) { - (None, None) => Ok(()), - (Some(operation_error), None) => Err(operation_error), - (None, Some(refresh_error)) => Err(refresh_error), - (Some(operation_error), Some(refresh_error)) => Err(format!( - "{operation_error} Refresh also failed: {refresh_error}" - )), + let reconciliation = refresh_after_claude_change(app).await?; + match outcome.error { + Some(operation_error) => Err(operation_error), + None => Ok(reconciliation), } } @@ -265,7 +286,10 @@ pub async fn claude_swap_accounts_list() -> Result Result<(), String> { +pub async fn claude_swap_account_switch( + app: tauri::AppHandle, + slot: u32, +) -> Result { // MUTATION is held for the whole command body, including // `finish_claude_swap_mutation`'s awaited provider refresh below. This is // deliberate: it serializes account mutations across the full @@ -293,7 +317,7 @@ pub async fn claude_swap_account_switch(app: tauri::AppHandle, slot: u32) -> Res pub async fn claude_swap_account_reauthenticate( app: tauri::AppHandle, slot: u32, -) -> Result<(), String> { +) -> Result { let _mutation = MUTATION .try_lock() .map_err(|_| "A Claude account operation is already in progress.")?; @@ -366,7 +390,10 @@ pub async fn claude_account_remove(app: tauri::AppHandle, id: String) -> Result< } #[tauri::command] -pub async fn claude_account_switch(app: tauri::AppHandle, id: String) -> Result<(), String> { +pub async fn claude_account_switch( + app: tauri::AppHandle, + id: String, +) -> Result { let _mutation = MUTATION .try_lock() .map_err(|_| "A Claude account operation is already in progress.")?; @@ -429,6 +456,12 @@ mod tests { assert!(!reauthentication_is_repaired(&account_row(true, "unknown"))); } + #[test] + fn switch_reconciliation_requires_the_requested_slot_to_be_active() { + assert!(switch_is_reconciled(&account_row(true, "ok"))); + assert!(!switch_is_reconciled(&account_row(false, "ok"))); + } + #[test] fn applied_but_unconfirmed_outcome_remains_refreshable() { let outcome = ClaudeSwapMutationOutcome::applied_unconfirmed("confirmation unavailable"); @@ -437,6 +470,17 @@ mod tests { assert_eq!(ClaudeSwapMutationOutcome::confirmed().error, None); } + #[test] + fn grace_timeout_returns_the_explicit_pending_snapshot() { + let pending = claude_reconciliation::ClaudeReconciliationSnapshot { + generation: 7, + status: claude_reconciliation::ClaudeReconciliationStatus::Pending, + provider_refresh_generation: None, + detail: "refreshing".to_string(), + }; + assert_eq!(grace_outcome(None, &pending), pending); + } + #[test] fn switching_invalidates_old_identity_usage_and_inflight_results() { let mut state = AppState::new(); diff --git a/apps/desktop-tauri/src-tauri/src/commands/claude_reconciliation.rs b/apps/desktop-tauri/src-tauri/src/commands/claude_reconciliation.rs new file mode 100644 index 0000000000..4396284692 --- /dev/null +++ b/apps/desktop-tauri/src-tauri/src/commands/claude_reconciliation.rs @@ -0,0 +1,209 @@ +use super::{ProviderRefreshOutcome, ProviderRefreshSkipReason}; +use serde::Serialize; +use tauri::Emitter; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) struct ClaudeReconciliationToken(u64); + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub enum ClaudeReconciliationStatus { + Pending, + Succeeded, + Failed, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ClaudeReconciliationSnapshot { + pub generation: u64, + pub status: ClaudeReconciliationStatus, + pub provider_refresh_generation: Option, + pub detail: String, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(super) struct ClaudeReconciliationResult { + status: ClaudeReconciliationStatus, + provider_refresh_generation: Option, + detail: String, +} + +impl ClaudeReconciliationResult { + pub(super) fn from_refresh(result: Result) -> Self { + match result { + Ok(ProviderRefreshOutcome::Published { generation }) => { + Self::succeeded(Some(generation), "published") + } + Ok(ProviderRefreshOutcome::Skipped { + reason: ProviderRefreshSkipReason::NoEnabledProviders, + }) => Self::succeeded(None, "noEnabledProviders"), + Ok(ProviderRefreshOutcome::Skipped { + reason: ProviderRefreshSkipReason::CacheFresh { generation }, + }) => Self::succeeded(Some(generation), "cacheFresh"), + Ok(ProviderRefreshOutcome::Skipped { + reason: ProviderRefreshSkipReason::Active { generation }, + }) => Self::failed( + Some(generation), + format!("provider refresh generation {generation} is already active"), + ), + Ok(ProviderRefreshOutcome::Skipped { + reason: ProviderRefreshSkipReason::InputSuperseded { expected, current }, + }) => Self::failed( + Some(current), + format!("provider refresh input generation {expected} was superseded by {current}"), + ), + Ok(ProviderRefreshOutcome::Superseded { + generation, + current_generation, + }) => Self::failed( + Some(current_generation), + format!( + "provider refresh generation {generation} was superseded by {current_generation}" + ), + ), + Err(error) => Self::failed(None, error), + } + } + + fn succeeded(provider_refresh_generation: Option, detail: impl Into) -> Self { + Self { + status: ClaudeReconciliationStatus::Succeeded, + provider_refresh_generation, + detail: detail.into(), + } + } + + fn failed(provider_refresh_generation: Option, detail: impl Into) -> Self { + Self { + status: ClaudeReconciliationStatus::Failed, + provider_refresh_generation, + detail: detail.into(), + } + } +} + +#[derive(Debug, Default)] +pub struct ClaudeReconciliationState { + next_generation: u64, + current: Option, +} + +impl ClaudeReconciliationState { + pub(crate) fn begin(&mut self) -> (ClaudeReconciliationToken, ClaudeReconciliationSnapshot) { + self.next_generation = self.next_generation.wrapping_add(1); + let token = ClaudeReconciliationToken(self.next_generation); + let snapshot = ClaudeReconciliationSnapshot { + generation: token.0, + status: ClaudeReconciliationStatus::Pending, + provider_refresh_generation: None, + detail: "refreshing".to_string(), + }; + self.current = Some(snapshot.clone()); + (token, snapshot) + } + + pub(super) fn complete( + &mut self, + token: ClaudeReconciliationToken, + result: ClaudeReconciliationResult, + ) -> (bool, ClaudeReconciliationSnapshot) { + let is_current = self.current.as_ref().is_some_and(|snapshot| { + snapshot.generation == token.0 && snapshot.status == ClaudeReconciliationStatus::Pending + }); + let snapshot = if is_current { + ClaudeReconciliationSnapshot { + generation: token.0, + status: result.status, + provider_refresh_generation: result.provider_refresh_generation, + detail: result.detail, + } + } else { + let successor = self + .current + .as_ref() + .map(|snapshot| snapshot.generation) + .unwrap_or(self.next_generation); + ClaudeReconciliationSnapshot { + generation: token.0, + status: ClaudeReconciliationStatus::Failed, + provider_refresh_generation: result.provider_refresh_generation, + detail: format!("superseded by Claude reconciliation generation {successor}"), + } + }; + if is_current { + self.current = Some(snapshot.clone()); + } + (is_current, snapshot) + } + + pub(crate) fn snapshot(&self) -> Option { + self.current.clone() + } +} + +pub(super) fn emit(app: &tauri::AppHandle, snapshot: &ClaudeReconciliationSnapshot) { + if let Err(error) = app.emit("claude-reconciliation-changed", snapshot) { + tracing::warn!( + %error, + generation = snapshot.generation, + "Failed to emit Claude reconciliation state" + ); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn overlapping_reconciliations_keep_the_latest_generation_authoritative() { + let mut state = ClaudeReconciliationState::default(); + let (first, _) = state.begin(); + let (second, second_pending) = state.begin(); + + let (accepted, stale) = + state.complete(first, ClaudeReconciliationResult::succeeded(None, "first")); + assert!(!accepted); + assert_eq!(stale.generation, first.0); + assert_eq!(stale.status, ClaudeReconciliationStatus::Failed); + assert_eq!(state.snapshot(), Some(second_pending)); + + let (accepted, terminal) = state.complete( + second, + ClaudeReconciliationResult::succeeded(Some(8), "second"), + ); + assert!(accepted); + assert_eq!(terminal.generation, second.0); + assert_eq!(terminal.status, ClaudeReconciliationStatus::Succeeded); + assert_eq!(state.snapshot(), Some(terminal)); + } + + #[test] + fn pending_snapshot_survives_until_late_failure_replaces_it() { + let mut state = ClaudeReconciliationState::default(); + let (token, pending) = state.begin(); + assert_eq!(pending.status, ClaudeReconciliationStatus::Pending); + assert_eq!(state.snapshot(), Some(pending)); + + let (accepted, failed) = state.complete( + token, + ClaudeReconciliationResult::failed(None, "late failure"), + ); + assert!(accepted); + assert_eq!(failed.status, ClaudeReconciliationStatus::Failed); + assert_eq!(failed.detail, "late failure"); + assert_eq!(state.snapshot(), Some(failed)); + } + + #[test] + fn provider_refresh_supersession_is_an_explicit_failure() { + let result = + ClaudeReconciliationResult::from_refresh(Ok(ProviderRefreshOutcome::Superseded { + generation: 4, + current_generation: 5, + })); + assert_eq!(result.status, ClaudeReconciliationStatus::Failed); + assert_eq!(result.provider_refresh_generation, Some(5)); + } +} diff --git a/apps/desktop-tauri/src-tauri/src/commands/mod.rs b/apps/desktop-tauri/src-tauri/src/commands/mod.rs index b7d71cf937..fdfaeda967 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/mod.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/mod.rs @@ -33,6 +33,7 @@ mod agent_sessions; mod bridge; mod browser_import; mod claude_accounts; +pub(crate) mod claude_reconciliation; mod codex_accounts; mod codex_workspaces; mod credential_detection; @@ -41,6 +42,7 @@ mod diagnostics; mod grok_accounts; mod locale_cmd; mod provider_detail; +mod provider_refresh; mod provider_settings; mod providers; mod settings; @@ -48,6 +50,7 @@ mod shortcuts; mod surface; mod system; mod usage_items; +mod warning_identity; pub use agent_sessions::*; pub(crate) use bridge::*; @@ -61,6 +64,9 @@ pub use diagnostics::*; pub use grok_accounts::*; pub use locale_cmd::*; pub use provider_detail::*; +#[cfg(test)] +pub(crate) use provider_refresh::is_provider_cache_fresh; +pub(crate) use provider_refresh::{ProviderRefreshOutcome, ProviderRefreshSkipReason}; pub use provider_settings::*; pub use providers::*; pub use settings::*; diff --git a/apps/desktop-tauri/src-tauri/src/commands/provider_refresh.rs b/apps/desktop-tauri/src-tauri/src/commands/provider_refresh.rs new file mode 100644 index 0000000000..80675e1eca --- /dev/null +++ b/apps/desktop-tauri/src-tauri/src/commands/provider_refresh.rs @@ -0,0 +1,225 @@ +use crate::state::AppState; +use codexbar::core::ProviderId; + +use super::PROVIDER_CACHE_STALE_AFTER; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum ProviderRefreshOutcome { + Skipped { + reason: ProviderRefreshSkipReason, + }, + Published { + generation: u64, + }, + Superseded { + generation: u64, + current_generation: u64, + }, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum ProviderRefreshSkipReason { + NoEnabledProviders, + Active { generation: u64 }, + InputSuperseded { expected: u64, current: u64 }, + CacheFresh { generation: u64 }, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) enum ProviderRefreshReservation { + Reserved { generation: u64 }, + Skipped(ProviderRefreshSkipReason), +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) enum ProviderRefreshCompletion { + Published { error_count: usize }, + Superseded { current_generation: u64 }, +} + +pub(crate) fn is_provider_cache_fresh( + updated_at: Option, + stale_after: std::time::Duration, +) -> bool { + updated_at + .map(|updated| updated.elapsed() <= stale_after) + .unwrap_or(false) +} + +pub(super) fn reserve_provider_refresh( + state: &mut AppState, + force: bool, + provider_ids: &[ProviderId], + expected_generation: u64, +) -> ProviderRefreshReservation { + if state.is_refreshing { + return ProviderRefreshReservation::Skipped(ProviderRefreshSkipReason::Active { + generation: state.provider_refresh_generation, + }); + } + if state.provider_refresh_generation != expected_generation { + return ProviderRefreshReservation::Skipped(ProviderRefreshSkipReason::InputSuperseded { + expected: expected_generation, + current: state.provider_refresh_generation, + }); + } + if provider_cache_can_skip_refresh(state, force, provider_ids) { + return ProviderRefreshReservation::Skipped(ProviderRefreshSkipReason::CacheFresh { + generation: state.provider_refresh_generation, + }); + } + + state.provider_refresh_generation = state.provider_refresh_generation.wrapping_add(1); + let generation = state.provider_refresh_generation; + state.is_refreshing = true; + state.provider_refresh_started_at = Some(std::time::Instant::now()); + ProviderRefreshReservation::Reserved { generation } +} + +fn provider_cache_can_skip_refresh( + state: &AppState, + force: bool, + provider_ids: &[ProviderId], +) -> bool { + let cache_has_all = provider_ids.iter().all(|id| { + state + .provider_cache + .iter() + .any(|snapshot| snapshot.provider_id == id.cli_name()) + }); + if !force && crate::proof_harness::seed_usage_json_active() && cache_has_all { + return true; + } + !force + && cache_has_all + && provider_ids.iter().all(|id| { + is_provider_cache_fresh( + state.provider_cache_updated_at_by_provider.get(id).copied(), + PROVIDER_CACHE_STALE_AFTER, + ) + }) +} + +pub(super) fn complete_provider_refresh( + state: &mut AppState, + generation: u64, +) -> ProviderRefreshCompletion { + if state.provider_refresh_generation != generation { + return ProviderRefreshCompletion::Superseded { + current_generation: state.provider_refresh_generation, + }; + } + state.is_refreshing = false; + state.provider_refresh_started_at = None; + state.provider_cache_updated_at = Some(std::time::Instant::now()); + ProviderRefreshCompletion::Published { + error_count: state + .provider_cache + .iter() + .filter(|snapshot| snapshot.error.is_some()) + .count(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn stale_inputs_cannot_reserve_a_new_generation_after_invalidation() { + let mut state = AppState::new(); + let expected_generation = state.provider_refresh_generation; + state.provider_refresh_generation = state.provider_refresh_generation.wrapping_add(1); + + assert_eq!( + reserve_provider_refresh(&mut state, true, &[ProviderId::Codex], expected_generation,), + ProviderRefreshReservation::Skipped(ProviderRefreshSkipReason::InputSuperseded { + expected: expected_generation, + current: state.provider_refresh_generation, + }) + ); + assert!(!state.is_refreshing); + } + + #[test] + fn matching_generation_reserves_the_next_generation() { + let mut state = AppState::new(); + let expected_generation = state.provider_refresh_generation; + + let ProviderRefreshReservation::Reserved { generation } = + reserve_provider_refresh(&mut state, true, &[ProviderId::Codex], expected_generation) + else { + panic!("refresh should be reserved"); + }; + + assert_eq!(generation, expected_generation.wrapping_add(1)); + assert!(state.is_refreshing); + } + + #[test] + fn superseded_generation_cannot_publish_or_release_its_successor() { + let mut state = AppState::new(); + let initial_generation = state.provider_refresh_generation; + let ProviderRefreshReservation::Reserved { + generation: first_generation, + } = reserve_provider_refresh(&mut state, true, &[ProviderId::Claude], initial_generation) + else { + panic!("first refresh should be reserved"); + }; + + state.provider_refresh_generation = state.provider_refresh_generation.wrapping_add(1); + state.is_refreshing = false; + let successor_input_generation = state.provider_refresh_generation; + let ProviderRefreshReservation::Reserved { + generation: successor_generation, + } = reserve_provider_refresh( + &mut state, + true, + &[ProviderId::Claude], + successor_input_generation, + ) + else { + panic!("successor refresh should be reserved"); + }; + + assert_eq!( + complete_provider_refresh(&mut state, first_generation), + ProviderRefreshCompletion::Superseded { + current_generation: successor_generation + } + ); + assert!(state.is_refreshing); + + assert!(matches!( + complete_provider_refresh(&mut state, successor_generation), + ProviderRefreshCompletion::Published { .. } + )); + assert!(!state.is_refreshing); + } + + #[test] + fn public_outcome_preserves_generation_ownership_and_skip_reason() { + assert_eq!( + ProviderRefreshOutcome::Published { generation: 42 }, + ProviderRefreshOutcome::Published { generation: 42 } + ); + assert_eq!( + ProviderRefreshOutcome::Superseded { + generation: 41, + current_generation: 42, + }, + ProviderRefreshOutcome::Superseded { + generation: 41, + current_generation: 42, + } + ); + assert_eq!( + ProviderRefreshOutcome::Skipped { + reason: ProviderRefreshSkipReason::Active { generation: 42 }, + }, + ProviderRefreshOutcome::Skipped { + reason: ProviderRefreshSkipReason::Active { generation: 42 }, + } + ); + } +} diff --git a/apps/desktop-tauri/src-tauri/src/commands/providers.rs b/apps/desktop-tauri/src-tauri/src/commands/providers.rs index 0500016b22..103d47b869 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/providers.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/providers.rs @@ -1,3 +1,8 @@ +use super::provider_refresh::{ + ProviderRefreshCompletion, ProviderRefreshReservation, complete_provider_refresh, + reserve_provider_refresh, +}; +use super::warning_identity::WarningIdentity; use super::*; use chrono::{Local, Utc}; use codexbar::core::HookUsageWindow; @@ -285,15 +290,6 @@ pub(crate) fn provider_fetch_timeout(id: ProviderId, ctx: &FetchContext) -> std: provider_timeout.max(context_timeout.min(MAX_CONTEXT_FETCH_TIMEOUT)) } -pub(crate) fn is_provider_cache_fresh( - updated_at: Option, - stale_after: std::time::Duration, -) -> bool { - updated_at - .map(|updated| updated.elapsed() <= stale_after) - .unwrap_or(false) -} - pub(crate) fn upsert_provider_cache( cache: &mut Vec, snapshot: ProviderUsageSnapshot, @@ -351,11 +347,19 @@ pub(crate) fn is_current_provider_refresh_generation(guard: &AppState, generatio /// Core refresh logic, usable from both the Tauri command and tray menu actions. pub(crate) async fn do_refresh_providers(app: &tauri::AppHandle) -> Result<(), String> { + do_refresh_providers_with_outcome(app).await.map(|_| ()) +} + +pub(crate) async fn do_refresh_providers_with_outcome( + app: &tauri::AppHandle, +) -> Result { do_refresh_providers_with_policy(app, true, RefreshScope::AllEnabled).await } pub(crate) async fn do_refresh_providers_if_stale(app: &tauri::AppHandle) -> Result<(), String> { - do_refresh_providers_with_policy(app, false, RefreshScope::AllEnabled).await + do_refresh_providers_with_policy(app, false, RefreshScope::AllEnabled) + .await + .map(|_| ()) } /// Refresh only enabled providers with the opt-in exact-session watcher. This @@ -364,14 +368,16 @@ pub(crate) async fn do_refresh_providers_if_stale(app: &tauri::AppHandle) -> Res pub(crate) async fn do_refresh_auto_resume_providers_if_stale( app: &tauri::AppHandle, ) -> Result<(), String> { - do_refresh_providers_with_policy(app, false, RefreshScope::AutoResume).await + do_refresh_providers_with_policy(app, false, RefreshScope::AutoResume) + .await + .map(|_| ()) } async fn do_refresh_providers_with_policy( app: &tauri::AppHandle, force: bool, scope: RefreshScope, -) -> Result<(), String> { +) -> Result { let state = app.state::>(); let expected_generation = state .lock() @@ -381,16 +387,20 @@ async fn do_refresh_providers_with_policy( let enabled_ids = settings.get_enabled_provider_ids(); let refresh_ids = scope.provider_ids(&settings, &enabled_ids); if refresh_ids.is_empty() { - return Ok(()); + return Ok(ProviderRefreshOutcome::Skipped { + reason: ProviderRefreshSkipReason::NoEnabledProviders, + }); } let inputs = ProviderRefreshInputs::load(settings, enabled_ids); - let Some(generation) = - begin_provider_refresh(&state, force, &refresh_ids, expected_generation)? - else { - // Settings or account identity changed while inputs were loading, or a - // newer batch already owns the refresh. Discard this input snapshot. - return Ok(()); + let generation = match begin_provider_refresh(&state, force, &refresh_ids, expected_generation)? + { + ProviderRefreshReservation::Reserved { generation } => generation, + ProviderRefreshReservation::Skipped(reason) => { + // Settings or account identity changed while inputs were loading, + // another batch owns the refresh, or the cache is already fresh. + return Ok(ProviderRefreshOutcome::Skipped { reason }); + } }; // Ensure cache only contains currently enabled providers for this generation. @@ -419,17 +429,23 @@ async fn do_refresh_providers_with_policy( ); await_provider_refreshes(handles).await; - let Some(error_count) = finish_provider_refresh(&state, generation)? else { - // Superseded by a newer generation (or invalidate). Do not clear UI - // "refreshing" for a dead batch or stamp tray from incomplete work. - return Ok(()); + let error_count = match finish_provider_refresh(&state, generation)? { + ProviderRefreshCompletion::Published { error_count } => error_count, + ProviderRefreshCompletion::Superseded { current_generation } => { + // Superseded by a newer generation (or invalidate). Do not clear UI + // "refreshing" for dead work or stamp tray from incomplete work. + return Ok(ProviderRefreshOutcome::Superseded { + generation, + current_generation, + }); + } }; update_tray_and_notifications(app, &state, &inputs.settings, &inputs.token_accounts)?; events::emit_refresh_complete(app, enabled_count, error_count); crate::auto_refresh::schedule_refresh_enrichment(&inputs.settings); - Ok(()) + Ok(ProviderRefreshOutcome::Published { generation }) } fn begin_provider_refresh( @@ -437,58 +453,14 @@ fn begin_provider_refresh( force: bool, provider_ids: &[ProviderId], expected_generation: u64, -) -> Result, String> { +) -> Result { let mut guard = state.lock().map_err(|e| e.to_string())?; - reserve_provider_refresh(&mut guard, force, provider_ids, expected_generation) -} - -fn reserve_provider_refresh( - guard: &mut AppState, - force: bool, - provider_ids: &[ProviderId], - expected_generation: u64, -) -> Result, String> { - if guard.is_refreshing { - return Ok(None); - } - if guard.provider_refresh_generation != expected_generation { - return Ok(None); - } - if provider_cache_can_skip_refresh(guard, force, provider_ids) { - return Ok(None); - } - - guard.provider_refresh_generation = guard.provider_refresh_generation.wrapping_add(1); - let generation = guard.provider_refresh_generation; - guard.is_refreshing = true; - guard.provider_refresh_started_at = Some(std::time::Instant::now()); - Ok(Some(generation)) -} - -fn provider_cache_can_skip_refresh( - guard: &AppState, - force: bool, - provider_ids: &[ProviderId], -) -> bool { - let cache_has_all = provider_ids.iter().all(|id| { - guard - .provider_cache - .iter() - .any(|snapshot| snapshot.provider_id == id.cli_name()) - }); - // Proof-harness seed: pin the synthetic snapshot for the whole run so a - // periodic auto-refresh cannot overwrite seeded capture conditions. - if !force && crate::proof_harness::seed_usage_json_active() && cache_has_all { - return true; - } - !force - && cache_has_all - && provider_ids.iter().all(|id| { - is_provider_cache_fresh( - guard.provider_cache_updated_at_by_provider.get(id).copied(), - PROVIDER_CACHE_STALE_AFTER, - ) - }) + Ok(reserve_provider_refresh( + &mut guard, + force, + provider_ids, + expected_generation, + )) } struct ProviderRefreshInputs { @@ -960,23 +932,9 @@ async fn await_provider_refreshes(handles: Vec>) { fn finish_provider_refresh( state: &tauri::State<'_, Mutex>, generation: u64, -) -> Result, String> { +) -> Result { let mut guard = state.lock().map_err(|e| e.to_string())?; - if !is_current_provider_refresh_generation(&guard, generation) { - // A newer begin or invalidate owns the lock/generation. Do not clear - // is_refreshing — that would race a live successor batch. - return Ok(None); - } - guard.is_refreshing = false; - guard.provider_refresh_started_at = None; - guard.provider_cache_updated_at = Some(std::time::Instant::now()); - Ok(Some( - guard - .provider_cache - .iter() - .filter(|s| s.error.is_some()) - .count(), - )) + Ok(complete_provider_refresh(&mut guard, generation)) } fn update_tray_and_notifications( @@ -1011,7 +969,14 @@ fn notify_usage_thresholds( .get(&provider) .and_then(ProviderAccountData::active_account) .map(|account| account.id); - let account = quota_notification_account_identity(snapshot, token_account_id); + let warning_identity = WarningIdentity::new( + provider, + &snapshot.source_label, + snapshot.account_email.as_deref(), + snapshot.account_organization.as_deref(), + token_account_id, + ); + let account = warning_identity.threshold_key(); // Skip all session consumers for synthetic/no-session // placeholders (e.g. Claude OAuth five_hour: null). if guard.notification_manager.check_session_lane( @@ -1092,28 +1057,18 @@ fn quota_notification_account_identity( snapshot: &ProviderUsageSnapshot, token_account_id: Option, ) -> String { - if let Some(id) = token_account_id { - return format!("token-account:{}", id.as_hyphenated()); - } - if let Some(email) = snapshot - .account_email - .as_deref() - .map(str::trim) - .filter(|s| !s.is_empty()) - { - return email.to_ascii_lowercase(); - } - if let Some(org) = snapshot - .account_organization - .as_deref() - .map(str::trim) - .filter(|s| !s.is_empty()) - { - return format!("org:{}", org.to_ascii_lowercase()); - } - // Do not fall back to plan_name/login_method — those are display tiers and - // flicker across refreshes, re-arming still-hot windows for a new identity. - String::new() + ProviderId::from_cli_name(&snapshot.provider_id) + .map(|provider| { + WarningIdentity::new( + provider, + &snapshot.source_label, + snapshot.account_email.as_deref(), + snapshot.account_organization.as_deref(), + token_account_id, + ) + .threshold_key() + }) + .unwrap_or_default() } fn notify_predictive_pace( @@ -1133,12 +1088,14 @@ fn notify_predictive_pace( .get(&provider) .and_then(ProviderAccountData::active_account) .map(|account| account.id); - let Some(identity) = predictive_warning_identity( + let warning_identity = WarningIdentity::new( provider, &snapshot.source_label, snapshot.account_email.as_deref(), + None, token_account_id, - ) else { + ); + let Some(identity) = warning_identity.predictive_key() else { return; }; let observed_at = chrono::DateTime::parse_from_rfc3339(&snapshot.updated_at) @@ -1189,26 +1146,6 @@ fn notify_predictive_pace( } } -fn predictive_warning_identity( - provider: ProviderId, - source_label: &str, - account_email: Option<&str>, - token_account_id: Option, -) -> Option { - if !matches!(provider, ProviderId::Claude | ProviderId::Codex) { - return None; - } - if let Some(id) = token_account_id { - return Some(format!("token-account:{}", id.as_hyphenated())); - } - let source = source_label.trim().to_ascii_lowercase(); - let account = account_email?.trim().to_ascii_lowercase(); - if source.is_empty() || account.is_empty() { - return None; - } - Some(format!("{source}:{account}")) -} - #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] pub struct DeepSeekPricingStatus { @@ -1282,54 +1219,6 @@ pub fn get_cached_providers( mod predictive_warning_tests { use super::*; - #[test] - fn predictive_warning_identity_keeps_claude_sources_and_token_accounts_separate() { - let account_id = uuid::Uuid::parse_str("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa").unwrap(); - - assert_eq!( - predictive_warning_identity( - ProviderId::Claude, - "cli", - Some("Person@Example.com"), - None, - ) - .as_deref(), - Some("cli:person@example.com") - ); - assert_eq!( - predictive_warning_identity( - ProviderId::Claude, - "oauth", - Some("Person@Example.com"), - None, - ) - .as_deref(), - Some("oauth:person@example.com") - ); - assert_eq!( - predictive_warning_identity( - ProviderId::Claude, - "oauth", - Some("Person@Example.com"), - Some(account_id), - ) - .as_deref(), - Some("token-account:aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa") - ); - } - - #[test] - fn predictive_warning_identity_skips_unidentified_accounts() { - assert_eq!( - predictive_warning_identity(ProviderId::Claude, "oauth", None, None), - None - ); - assert_eq!( - predictive_warning_identity(ProviderId::Codex, "cli", Some(" "), None), - None - ); - } - fn empty_snapshot() -> ProviderUsageSnapshot { let metadata = codexbar::core::instantiate_provider(ProviderId::Claude) .metadata() @@ -1366,11 +1255,18 @@ mod predictive_warning_tests { ); snapshot.account_organization = None; - // plan_name/login_method is not a stable ownership key — fall through to "". - assert_eq!(quota_notification_account_identity(&snapshot, None), ""); + snapshot.source_label = "oauth".to_string(); + assert_eq!( + quota_notification_account_identity(&snapshot, None), + "claude:oauth:unknown" + ); snapshot.plan_name = None; - assert_eq!(quota_notification_account_identity(&snapshot, None), ""); + snapshot.source_label = "cli (reduced fidelity)".to_string(); + assert_eq!( + quota_notification_account_identity(&snapshot, None), + "claude:cli:unknown" + ); } /// The forecast scope key and the notification identity must never disagree. @@ -1531,37 +1427,3 @@ mod reset_backfill_tests { assert!(fresh.primary.resets_at.is_none()); } } - -#[cfg(test)] -mod refresh_generation_tests { - use super::*; - - #[test] - fn stale_inputs_cannot_reserve_a_new_generation_after_invalidation() { - let mut state = AppState::new(); - let expected_generation = state.provider_refresh_generation; - - invalidate_account_usage(&mut state, ProviderId::Codex); - - assert_eq!( - reserve_provider_refresh(&mut state, true, &[ProviderId::Codex], expected_generation,) - .expect("reservation should not fail"), - None - ); - assert!(!state.is_refreshing); - } - - #[test] - fn a_matching_generation_reserves_the_next_refresh_generation() { - let mut state = AppState::new(); - let expected_generation = state.provider_refresh_generation; - - let generation = - reserve_provider_refresh(&mut state, true, &[ProviderId::Codex], expected_generation) - .expect("reservation should succeed") - .expect("refresh should be reserved"); - - assert_eq!(generation, expected_generation.wrapping_add(1)); - assert!(state.is_refreshing); - } -} diff --git a/apps/desktop-tauri/src-tauri/src/commands/warning_identity.rs b/apps/desktop-tauri/src-tauri/src/commands/warning_identity.rs new file mode 100644 index 0000000000..7a9bd92b88 --- /dev/null +++ b/apps/desktop-tauri/src-tauri/src/commands/warning_identity.rs @@ -0,0 +1,226 @@ +use codexbar::core::ProviderId; + +#[derive(Debug, Clone, PartialEq, Eq)] +enum WarningSourceLane { + ClaudeOauth, + ClaudeCli, + Named(String), +} + +impl WarningSourceLane { + fn from_label(provider: ProviderId, source_label: &str) -> Option { + let source = source_label.trim().to_ascii_lowercase(); + if source.is_empty() { + return None; + } + if provider == ProviderId::Claude { + if source == "oauth" { + return Some(Self::ClaudeOauth); + } + if source == "cli" || source.starts_with("cli ") { + return Some(Self::ClaudeCli); + } + } + Some(Self::Named(source)) + } + + fn key(&self) -> &str { + match self { + Self::ClaudeOauth => "oauth", + Self::ClaudeCli => "cli", + Self::Named(source) => source, + } + } + + fn supports_unresolved_account(&self) -> bool { + matches!(self, Self::ClaudeOauth | Self::ClaudeCli) + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +enum WarningAccountState { + Token(uuid::Uuid), + Email(String), + Organization(String), + Unresolved, + Missing, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(super) struct WarningIdentity { + provider: ProviderId, + source_lane: Option, + account: WarningAccountState, +} + +impl WarningIdentity { + pub(super) fn new( + provider: ProviderId, + source_label: &str, + account_email: Option<&str>, + account_organization: Option<&str>, + token_account_id: Option, + ) -> Self { + let source_lane = WarningSourceLane::from_label(provider, source_label); + let normalized = |value: Option<&str>| { + value + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_ascii_lowercase) + }; + let account = if let Some(id) = token_account_id { + WarningAccountState::Token(id) + } else if let Some(email) = normalized(account_email) { + WarningAccountState::Email(email) + } else if let Some(organization) = normalized(account_organization) { + WarningAccountState::Organization(organization) + } else if provider == ProviderId::Claude + && source_lane + .as_ref() + .is_some_and(WarningSourceLane::supports_unresolved_account) + { + WarningAccountState::Unresolved + } else { + WarningAccountState::Missing + }; + Self { + provider, + source_lane, + account, + } + } + + pub(super) fn unresolved_key(&self) -> Option { + let lane = self.source_lane.as_ref()?; + (self.provider == ProviderId::Claude && lane.supports_unresolved_account()) + .then(|| format!("{}:{}:unknown", self.provider.cli_name(), lane.key())) + } + + pub(super) fn threshold_key(&self) -> String { + match &self.account { + WarningAccountState::Token(id) => format!("token-account:{}", id.as_hyphenated()), + WarningAccountState::Email(email) => email.clone(), + WarningAccountState::Organization(organization) => format!("org:{organization}"), + WarningAccountState::Unresolved => self.unresolved_key().unwrap_or_default(), + WarningAccountState::Missing => String::new(), + } + } + + pub(super) fn predictive_key(&self) -> Option { + match &self.account { + WarningAccountState::Token(id) => Some(format!("token-account:{}", id.as_hyphenated())), + WarningAccountState::Email(email) => self + .source_lane + .as_ref() + .map(|lane| format!("{}:{email}", lane.key())), + WarningAccountState::Unresolved => self.unresolved_key(), + WarningAccountState::Organization(_) | WarningAccountState::Missing => None, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn source_lanes_and_token_accounts_remain_separate() { + let account_id = uuid::Uuid::parse_str("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa").unwrap(); + + assert_eq!( + WarningIdentity::new( + ProviderId::Claude, + "cli", + Some("Person@Example.com"), + None, + None, + ) + .predictive_key() + .as_deref(), + Some("cli:person@example.com") + ); + assert_eq!( + WarningIdentity::new( + ProviderId::Claude, + "oauth", + Some("Person@Example.com"), + None, + None, + ) + .predictive_key() + .as_deref(), + Some("oauth:person@example.com") + ); + assert_eq!( + WarningIdentity::new( + ProviderId::Claude, + "oauth", + Some("Person@Example.com"), + None, + Some(account_id), + ) + .predictive_key() + .as_deref(), + Some("token-account:aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa") + ); + } + + #[test] + fn unresolved_claude_sources_are_scoped_to_their_lane() { + assert_eq!( + WarningIdentity::new(ProviderId::Claude, "oauth", None, None, None).predictive_key(), + Some("claude:oauth:unknown".to_string()) + ); + assert_eq!( + WarningIdentity::new( + ProviderId::Claude, + "cli (reduced fidelity)", + None, + None, + None, + ) + .predictive_key(), + Some("claude:cli:unknown".to_string()) + ); + assert_eq!( + WarningIdentity::new(ProviderId::Claude, "web", None, None, None).predictive_key(), + None + ); + assert_eq!( + WarningIdentity::new(ProviderId::Codex, "cli", Some(" "), None, None).predictive_key(), + None + ); + } + + #[test] + fn unresolved_and_resolved_accounts_keep_separate_warning_history() { + let oauth = WarningIdentity::new(ProviderId::Claude, "oauth", None, None, None); + let cli = WarningIdentity::new( + ProviderId::Claude, + "cli (reduced fidelity)", + None, + None, + None, + ); + let resolved_cli = WarningIdentity::new( + ProviderId::Claude, + "cli", + Some("person@example.com"), + None, + None, + ); + + assert_eq!( + oauth.unresolved_key().as_deref(), + Some("claude:oauth:unknown") + ); + assert_eq!(cli.unresolved_key().as_deref(), Some("claude:cli:unknown")); + assert_ne!(cli.threshold_key(), resolved_cli.threshold_key()); + assert_ne!(cli.predictive_key(), resolved_cli.predictive_key()); + assert_ne!(oauth.predictive_key(), resolved_cli.predictive_key()); + assert_eq!( + resolved_cli.predictive_key().as_deref(), + Some("cli:person@example.com") + ); + } +} diff --git a/apps/desktop-tauri/src-tauri/src/main.rs b/apps/desktop-tauri/src-tauri/src/main.rs index 83720c9d6b..02f48672b6 100644 --- a/apps/desktop-tauri/src-tauri/src/main.rs +++ b/apps/desktop-tauri/src-tauri/src/main.rs @@ -179,6 +179,7 @@ fn main() { commands::get_deepseek_pricing_status, commands::codex_accounts_list, commands::claude_accounts_list, + commands::claude_reconciliation_state, commands::claude_account_add, commands::claude_account_cancel_login, commands::claude_account_save_current, diff --git a/apps/desktop-tauri/src-tauri/src/state.rs b/apps/desktop-tauri/src-tauri/src/state.rs index 04baf239e6..10d626ec43 100644 --- a/apps/desktop-tauri/src-tauri/src/state.rs +++ b/apps/desktop-tauri/src-tauri/src/state.rs @@ -131,6 +131,9 @@ pub struct AppState { /// refresh starts or enablement changes so superseded results are ignored. pub provider_refresh_generation: u64, pub is_refreshing: bool, + /// Authoritative Claude account reconciliation state for mounted and + /// newly-mounted frontend surfaces. + pub claude_reconciliation: crate::commands::claude_reconciliation::ClaudeReconciliationState, pub update_state: UpdateState, /// Full update metadata from the last successful check. pub update_info: Option, @@ -195,6 +198,7 @@ impl AppState { provider_refresh_started_at: None, provider_refresh_generation: 0, is_refreshing: false, + claude_reconciliation: Default::default(), update_state: UpdateState::Idle, update_info: None, last_update_check_ms: None, diff --git a/apps/desktop-tauri/src-tauri/src/tray_accounts.rs b/apps/desktop-tauri/src-tauri/src/tray_accounts.rs index aeb51580cf..c2ffcea9b6 100644 --- a/apps/desktop-tauri/src-tauri/src/tray_accounts.rs +++ b/apps/desktop-tauri/src-tauri/src/tray_accounts.rs @@ -184,7 +184,9 @@ pub(crate) fn handle_action(app: &AppHandle, action: AccountMenuAction) { "Current Claude Code account saved.", ), AccountMenuAction::SwitchClaudeAccount(id) => ( - crate::commands::claude_account_switch(handle.clone(), id).await, + crate::commands::claude_account_switch(handle.clone(), id) + .await + .map(|_| ()), "Claude Code account switched. Reopen the Claude Code CLI to use it.", ), _ => unreachable!(), diff --git a/apps/desktop-tauri/src/components/ClaudeAccountsMenu.test.tsx b/apps/desktop-tauri/src/components/ClaudeAccountsMenu.test.tsx index 8fba1f54f7..a9fa68f320 100644 --- a/apps/desktop-tauri/src/components/ClaudeAccountsMenu.test.tsx +++ b/apps/desktop-tauri/src/components/ClaudeAccountsMenu.test.tsx @@ -3,13 +3,14 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import type { ClaudeAccount } from "../types/bridge"; const mocks = vi.hoisted(() => { - const listeners = new Map void>(); + const listeners = new Map void>(); return { claudeAccountsList: vi.fn(), claudeAccountSwitch: vi.fn(), + claudeReconciliationState: vi.fn(), refreshProviders: vi.fn(), listeners, - listen: vi.fn((event: string, callback: () => void) => { + listen: vi.fn((event: string, callback: (event: { payload: unknown }) => void) => { listeners.set(event, callback); return Promise.resolve(() => listeners.delete(event)); }), @@ -22,13 +23,17 @@ import ClaudeAccountsMenu from "./ClaudeAccountsMenu"; const first: ClaudeAccount = { id: "first:org", email: "first@example.com", organization: "Personal", plan: "max", isActive: true, isSaved: true }; const second: ClaudeAccount = { ...first, id: "second:org", email: "second@example.com", organization: "Work", isActive: false }; +const reconciliation = (generation: number, status: "pending" | "succeeded" | "failed", detail: string = status) => ({ + generation, status, detail, providerRefreshGeneration: null, +}); describe("ClaudeAccountsMenu", () => { beforeEach(() => { vi.clearAllMocks(); mocks.listeners.clear(); mocks.claudeAccountsList.mockResolvedValue([first, second]); - mocks.claudeAccountSwitch.mockResolvedValue(undefined); + mocks.claudeReconciliationState.mockResolvedValue(null); + mocks.claudeAccountSwitch.mockResolvedValue(reconciliation(1, "succeeded")); mocks.refreshProviders.mockResolvedValue(undefined); }); @@ -54,8 +59,8 @@ describe("ClaudeAccountsMenu", () => { }); it("keeps the menu in activating and reconciling phases until the switch settles", async () => { - let resolveSwitch: (() => void) | undefined; - mocks.claudeAccountSwitch.mockImplementation(() => new Promise(resolve => { + let resolveSwitch: ((value: ReturnType) => void) | undefined; + mocks.claudeAccountSwitch.mockImplementation(() => new Promise(resolve => { resolveSwitch = resolve; })); render(); @@ -69,18 +74,18 @@ describe("ClaudeAccountsMenu", () => { expect(details()).toHaveAttribute("aria-busy", "true"); await act(async () => { - mocks.listeners.get("claude-accounts-reconciling")?.(); + mocks.listeners.get("claude-reconciliation-changed")?.({ payload: reconciliation(1, "pending") }); }); expect(details().dataset.claudeAccountPhase).toBe("reconciling"); await act(async () => { - resolveSwitch?.(); + resolveSwitch?.(reconciliation(1, "pending")); }); // Settling is event-driven; the resolving switch promise alone stays in // the reconciling phase until the backend emits the terminal event. await waitFor(() => expect(details().dataset.claudeAccountPhase).toBe("reconciling")); await act(async () => { - mocks.listeners.get("claude-accounts-reconciled")?.(); + mocks.listeners.get("claude-reconciliation-changed")?.({ payload: reconciliation(1, "succeeded") }); }); await waitFor(() => expect(details().dataset.claudeAccountPhase).toBe("settled")); expect(details()).toHaveAttribute("aria-busy", "false"); @@ -94,14 +99,14 @@ describe("ClaudeAccountsMenu", () => { const button = within(row).getByRole("button"); await act(async () => { - mocks.listeners.get("claude-accounts-reconciling")?.(); + mocks.listeners.get("claude-reconciliation-changed")?.({ payload: reconciliation(2, "pending") }); }); expect(details().dataset.claudeAccountPhase).toBe("reconciling"); expect(details()).toHaveAttribute("aria-busy", "true"); expect(button).toBeDisabled(); await act(async () => { - mocks.listeners.get("claude-accounts-reconciled")?.(); + mocks.listeners.get("claude-reconciliation-changed")?.({ payload: reconciliation(2, "succeeded") }); }); expect(details().dataset.claudeAccountPhase).toBe("settled"); expect(details()).toHaveAttribute("aria-busy", "false"); @@ -109,8 +114,8 @@ describe("ClaudeAccountsMenu", () => { }); it("waits for the reconciled event before settling a local switch", async () => { - let resolveSwitch: (() => void) | undefined; - mocks.claudeAccountSwitch.mockImplementation(() => new Promise(resolve => { + let resolveSwitch: ((value: ReturnType) => void) | undefined; + mocks.claudeAccountSwitch.mockImplementation(() => new Promise(resolve => { resolveSwitch = resolve; })); render(); @@ -121,19 +126,40 @@ describe("ClaudeAccountsMenu", () => { const button = within(row).getByRole("button"); await act(async () => fireEvent.click(button)); await act(async () => { - mocks.listeners.get("claude-accounts-reconciling")?.(); + mocks.listeners.get("claude-reconciliation-changed")?.({ payload: reconciliation(3, "pending") }); }); await act(async () => { - resolveSwitch?.(); + resolveSwitch?.(reconciliation(3, "pending")); }); // The switch promise resolving is not enough: settling is event-driven. await waitFor(() => expect(details().dataset.claudeAccountPhase).toBe("reconciling")); await act(async () => { - mocks.listeners.get("claude-accounts-reconciled")?.(); + mocks.listeners.get("claude-reconciliation-changed")?.({ payload: reconciliation(3, "succeeded") }); }); await waitFor(() => expect(details().dataset.claudeAccountPhase).toBe("settled")); }); + it("surfaces a late failure for the matching pending generation", async () => { + mocks.claudeAccountSwitch.mockResolvedValue(reconciliation(8, "pending")); + render(); + await screen.findByText(second.email); + const row = screen.getByText(second.email).closest("li") as HTMLElement; + await act(async () => fireEvent.click(within(row).getByRole("button"))); + await waitFor(() => { + expect(document.querySelector("details")?.dataset.claudeAccountPhase).toBe("reconciling"); + }); + + await act(async () => { + mocks.listeners.get("claude-reconciliation-changed")?.({ + payload: reconciliation(8, "failed", "late refresh failed"), + }); + }); + await waitFor(() => { + expect(screen.getByRole("alert").textContent).toContain("late refresh failed"); + }); + expect(screen.queryByText("ClaudeAccountsSwitched")).toBeNull(); + }); + 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(); diff --git a/apps/desktop-tauri/src/components/ClaudeAccountsMenu.tsx b/apps/desktop-tauri/src/components/ClaudeAccountsMenu.tsx index d8afc5c5e9..b57e6a1af4 100644 --- a/apps/desktop-tauri/src/components/ClaudeAccountsMenu.tsx +++ b/apps/desktop-tauri/src/components/ClaudeAccountsMenu.tsx @@ -3,13 +3,15 @@ import { listen } from "@tauri-apps/api/event"; import type { ClaudeAccount } from "../types/bridge"; import { claudeAccountsList, claudeAccountSwitch } from "../lib/tauri"; import { useLocale } from "../hooks/useLocale"; +import { + localClaudeReconciliationOutcome, + useClaudeReconciliation, +} from "../hooks/useClaudeReconciliation"; import { buildClaudeAccountOrdinals, buildPrivateClaudeAccountLabel, } from "./claudeAccountDisplay"; -type ClaudeAccountPhase = "idle" | "activating" | "reconciling" | "settled"; - export default function ClaudeAccountsMenu({ hideEmail, onLayoutChange }: { hideEmail: boolean; onLayoutChange?: () => void; @@ -18,7 +20,9 @@ export default function ClaudeAccountsMenu({ hideEmail, onLayoutChange }: { const [accounts, setAccounts] = useState([]); const [error, setError] = useState(null); const [switched, setSwitched] = useState(false); - const [phase, setPhase] = useState("idle"); + const [activating, setActivating] = useState(false); + const [operationGeneration, setOperationGeneration] = useState(null); + const { snapshot, accept, reconciling } = useClaudeReconciliation(); const mounted = useRef(false); const load = useCallback(async () => { const next = await claudeAccountsList(); @@ -35,25 +39,30 @@ export default function ClaudeAccountsMenu({ hideEmail, onLayoutChange }: { reload(); window.addEventListener("focus", reload); const unlisten = listen("claude-accounts-updated", reload); - const unlistenReconciling = listen("claude-accounts-reconciling", () => { - if (mounted.current) { - setPhase("reconciling"); - setSwitched(false); - } - }); - const unlistenReconciled = listen("claude-accounts-reconciled", () => { - if (mounted.current) { - setPhase("settled"); - } - }); return () => { mounted.current = false; window.removeEventListener("focus", reload); void unlisten.then(fn => fn()).catch(() => {}); - void unlistenReconciling.then(fn => fn()).catch(() => {}); - void unlistenReconciled.then(fn => fn()).catch(() => {}); }; }, [load]); + useEffect(() => { + const outcome = localClaudeReconciliationOutcome(snapshot, operationGeneration); + if (!outcome) return; + if (outcome.status === "failed") { + setSwitched(false); + setError(outcome.detail); + } else { + setSwitched(true); + setError(null); + } + }, [operationGeneration, snapshot]); + const phase = reconciling + ? "reconciling" + : activating + ? "activating" + : snapshot + ? "settled" + : "idle"; useEffect(() => { onLayoutChange?.(); }, [accounts.length, error, phase, switched, onLayoutChange]); @@ -61,20 +70,21 @@ export default function ClaudeAccountsMenu({ hideEmail, onLayoutChange }: { const accountOrdinals = buildClaudeAccountOrdinals(accounts); const switchAccount = async (id: string) => { - setPhase("activating"); + setActivating(true); + setOperationGeneration(null); setError(null); setSwitched(false); try { - await claudeAccountSwitch(id); + const result = await claudeAccountSwitch(id); + setOperationGeneration(result.generation); + accept(result); await load(); - // Settling is event-driven: the backend emits claude-accounts-reconciled - // after the awaited refresh. The promise resolving does not settle. - if (mounted.current) setSwitched(true); } catch (e) { if (mounted.current) { - setPhase("idle"); setError(String(e)); } + } finally { + if (mounted.current) setActivating(false); } }; diff --git a/apps/desktop-tauri/src/hooks/useClaudeReconciliation.test.ts b/apps/desktop-tauri/src/hooks/useClaudeReconciliation.test.ts new file mode 100644 index 0000000000..fd88547703 --- /dev/null +++ b/apps/desktop-tauri/src/hooks/useClaudeReconciliation.test.ts @@ -0,0 +1,86 @@ +import { renderHook, waitFor } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { ClaudeReconciliationSnapshot } from "../types/bridge"; + +const mocks = vi.hoisted(() => ({ + claudeReconciliationState: vi.fn(), + listen: vi.fn(), +})); +vi.mock("../lib/tauri", () => ({ + claudeReconciliationState: mocks.claudeReconciliationState, +})); +vi.mock("@tauri-apps/api/event", () => ({ listen: mocks.listen })); +import { + localClaudeReconciliationOutcome, + selectClaudeReconciliation, + useClaudeReconciliation, +} from "./useClaudeReconciliation"; + +const snapshot = ( + generation: number, + status: ClaudeReconciliationSnapshot["status"], + detail: string = status, +): ClaudeReconciliationSnapshot => ({ + generation, + status, + detail, + providerRefreshGeneration: null, +}); + +describe("selectClaudeReconciliation", () => { + beforeEach(() => { + vi.resetAllMocks(); + mocks.listen.mockResolvedValue(() => {}); + mocks.claudeReconciliationState.mockResolvedValue(null); + }); + + it("hydrates a newly mounted surface from the authoritative query", async () => { + mocks.claudeReconciliationState.mockResolvedValue(snapshot(7, "pending")); + const { result } = renderHook(() => useClaudeReconciliation()); + await waitFor(() => expect(result.current.snapshot).toEqual(snapshot(7, "pending"))); + expect(result.current.reconciling).toBe(true); + }); + + it("recovers a pending operation from the mount-time query", () => { + expect(selectClaudeReconciliation(null, snapshot(4, "pending"))).toEqual( + snapshot(4, "pending"), + ); + }); + + it("accepts the matching late failure", () => { + expect( + selectClaudeReconciliation(snapshot(4, "pending"), snapshot(4, "failed", "late")), + ).toEqual(snapshot(4, "failed", "late")); + }); + + it("ignores stale and duplicate terminal states", () => { + const current = snapshot(5, "succeeded"); + expect(selectClaudeReconciliation(current, snapshot(4, "failed"))).toBe(current); + expect(selectClaudeReconciliation(current, snapshot(5, "failed"))).toBe(current); + }); + + it("lets a newer generation supersede an older terminal", () => { + expect( + selectClaudeReconciliation(snapshot(4, "failed"), snapshot(5, "pending")), + ).toEqual(snapshot(5, "pending")); + }); +}); + +describe("localClaudeReconciliationOutcome", () => { + it("ignores an old hydrated failure when no local operation owns it", () => { + expect( + localClaudeReconciliationOutcome(snapshot(7, "failed", "old failure"), null), + ).toBeNull(); + }); + + it("ignores a terminal failure from a different generation", () => { + expect( + localClaudeReconciliationOutcome(snapshot(8, "failed", "other failure"), 9), + ).toBeNull(); + }); + + it("returns a matching late failure", () => { + const failure = snapshot(10, "failed", "late failure"); + expect(localClaudeReconciliationOutcome(failure, 10)).toBe(failure); + }); +}); diff --git a/apps/desktop-tauri/src/hooks/useClaudeReconciliation.ts b/apps/desktop-tauri/src/hooks/useClaudeReconciliation.ts new file mode 100644 index 0000000000..d5f375e2be --- /dev/null +++ b/apps/desktop-tauri/src/hooks/useClaudeReconciliation.ts @@ -0,0 +1,62 @@ +import { useCallback, useEffect, useState } from "react"; +import { listen } from "@tauri-apps/api/event"; +import { claudeReconciliationState } from "../lib/tauri"; +import type { ClaudeReconciliationSnapshot } from "../types/bridge"; + +const isTerminal = (snapshot: ClaudeReconciliationSnapshot) => snapshot.status !== "pending"; + +export function localClaudeReconciliationOutcome( + snapshot: ClaudeReconciliationSnapshot | null, + operationGeneration: number | null, +): ClaudeReconciliationSnapshot | null { + if ( + !snapshot + || snapshot.status === "pending" + || snapshot.generation !== operationGeneration + ) { + return null; + } + return snapshot; +} + +export function selectClaudeReconciliation( + current: ClaudeReconciliationSnapshot | null, + candidate: ClaudeReconciliationSnapshot, +): ClaudeReconciliationSnapshot { + if (!current || candidate.generation > current.generation) return candidate; + if (candidate.generation < current.generation) return current; + if (isTerminal(current) || candidate.status === "pending") return current; + return candidate; +} + +export function useClaudeReconciliation() { + const [snapshot, setSnapshot] = useState(null); + const accept = useCallback((candidate: ClaudeReconciliationSnapshot) => { + setSnapshot(current => selectClaudeReconciliation(current, candidate)); + }, []); + + useEffect(() => { + let mounted = true; + const unlisten = listen( + "claude-reconciliation-changed", + event => { + if (mounted) accept(event.payload); + }, + ); + void claudeReconciliationState() + .then(current => { + if (mounted && current) accept(current); + }) + .catch(() => {}); + return () => { + mounted = false; + void unlisten.then(dispose => dispose()).catch(() => {}); + }; + }, [accept]); + + return { + snapshot, + accept, + reconciling: snapshot?.status === "pending", + }; +} diff --git a/apps/desktop-tauri/src/lib/tauri.ts b/apps/desktop-tauri/src/lib/tauri.ts index 7de5475e02..6ccd63c0de 100644 --- a/apps/desktop-tauri/src/lib/tauri.ts +++ b/apps/desktop-tauri/src/lib/tauri.ts @@ -1,6 +1,7 @@ import { invoke } from "@tauri-apps/api/core"; import type { ClaudeAccount, + ClaudeReconciliationSnapshot, GrokAccount, GrokAccountUsage, ClaudeSwapAccountsState, @@ -47,11 +48,14 @@ import type { } from "../types/bridge"; export const claudeAccountsList = () => invoke("claude_accounts_list"); +export const claudeReconciliationState = () => + invoke("claude_reconciliation_state"); export const claudeAccountAdd = () => invoke("claude_account_add"); 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 }); -export const claudeAccountSwitch = (id: string) => invoke("claude_account_switch", { id }); +export const claudeAccountSwitch = (id: string) => + invoke("claude_account_switch", { id }); export const grokAccountsList = () => invoke("grok_accounts_list"); export const grokAccountAdd = () => invoke("grok_account_add"); export const grokAccountCancelLogin = () => invoke("grok_account_cancel_login"); @@ -63,9 +67,9 @@ export const grokAccountFetch = (id: string) => export const claudeSwapAccountsList = () => invoke("claude_swap_accounts_list"); export const claudeSwapAccountSwitch = (slot: number) => - invoke("claude_swap_account_switch", { slot }); + invoke("claude_swap_account_switch", { slot }); export const claudeSwapAccountReauthenticate = (slot: number) => - invoke("claude_swap_account_reauthenticate", { slot }); + invoke("claude_swap_account_reauthenticate", { slot }); export function getBootstrapState(): Promise { return invoke("get_bootstrap_state"); diff --git a/apps/desktop-tauri/src/surfaces/PopOutPanel.test.tsx b/apps/desktop-tauri/src/surfaces/PopOutPanel.test.tsx index b287b35754..0e7f3053a9 100644 --- a/apps/desktop-tauri/src/surfaces/PopOutPanel.test.tsx +++ b/apps/desktop-tauri/src/surfaces/PopOutPanel.test.tsx @@ -19,6 +19,7 @@ const tauriMocks = vi.hoisted(() => ({ getLocaleStrings: vi.fn(), setUiLanguage: vi.fn(), getDeepSeekPricingStatus: vi.fn().mockResolvedValue(null), + claudeReconciliationState: vi.fn().mockResolvedValue(null), })); const eventMocks = vi.hoisted(() => ({ @@ -232,6 +233,7 @@ function renderPopOut( describe("PopOutPanel", () => { beforeEach(() => { vi.clearAllMocks(); + tauriMocks.claudeReconciliationState.mockResolvedValue(null); tauriMocks.refreshProviders.mockResolvedValue(undefined); tauriMocks.refreshProvidersIfStale.mockResolvedValue(undefined); tauriMocks.getSettingsSnapshot.mockResolvedValue(settings()); diff --git a/apps/desktop-tauri/src/surfaces/TrayPanel.test.tsx b/apps/desktop-tauri/src/surfaces/TrayPanel.test.tsx index 09a5b01e1c..8e156a34bf 100644 --- a/apps/desktop-tauri/src/surfaces/TrayPanel.test.tsx +++ b/apps/desktop-tauri/src/surfaces/TrayPanel.test.tsx @@ -32,6 +32,7 @@ const tauriMocks = vi.hoisted(() => ({ setUiLanguage: vi.fn(), getDeepSeekPricingStatus: vi.fn().mockResolvedValue(null), getUsageSpendSummary: vi.fn(), + claudeReconciliationState: vi.fn().mockResolvedValue(null), })); const eventMocks = vi.hoisted(() => ({ @@ -228,6 +229,7 @@ describe("TrayPanel provider grid", () => { beforeEach(() => { vi.clearAllMocks(); eventMocks.listeners.clear(); + tauriMocks.claudeReconciliationState.mockResolvedValue(null); tauriMocks.getDeepSeekPricingStatus.mockResolvedValue(null); tauriMocks.getUsageSpendSummary.mockResolvedValue({ rows: [], models: [] }); tauriMocks.flyoutStoredSize.mockResolvedValue(null); 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 c7e7b9929d..4e993b752e 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(), + claudeReconciliationState: vi.fn(), claudeSwapAccountsList: vi.fn(), claudeSwapAccountSwitch: vi.fn(), getSettingsSnapshot: vi.fn(), updateSettings: vi.fn(), })); @@ -21,6 +22,10 @@ describe("ClaudeAccountsSection", () => { beforeEach(() => { vi.resetAllMocks(); events.listen.mockResolvedValue(() => {}); + mocks.claudeReconciliationState.mockResolvedValue(null); + mocks.claudeAccountSwitch.mockResolvedValue({ + generation: 1, status: "succeeded", providerRefreshGeneration: 1, detail: "published", + }); mocks.claudeAccountsList.mockResolvedValue([current, other]); mocks.claudeSwapAccountsList.mockResolvedValue({ enabled: false, @@ -52,7 +57,7 @@ describe("ClaudeAccountsSection", () => { render(); await screen.findByText(current.email); fireEvent.click(screen.getByText("CodexAccountsAddButton")); - const eventCallback = events.listen.mock.calls[0][1] as unknown as () => void; + const eventCallback = events.listen.mock.calls.find(([event]) => event === "claude-accounts-updated")?.[1] as unknown as () => void; await act(async () => eventCallback()); expect((screen.getByText("CodexAccountsSwitchButton") as HTMLButtonElement).disabled).toBe(true); await act(async () => fireEvent.click(screen.getByText("ClaudeAccountsCancelLogin"))); 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 acb8b7da44..467e676b18 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 @@ -1,6 +1,7 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { listen } from "@tauri-apps/api/event"; import type { ClaudeAccount } from "../../../../../types/bridge"; +import type { ClaudeReconciliationSnapshot } from "../../../../../types/bridge"; import type { Language } from "../../../../../types/bridge"; import type { LocaleKey } from "../../../../../i18n/keys"; import { @@ -12,6 +13,10 @@ import { claudeAccountSwitch, } from "../../../../../lib/tauri"; import { ClaudeSwapAccountsSection } from "./ClaudeSwapAccountsSection"; +import { + localClaudeReconciliationOutcome, + useClaudeReconciliation, +} from "../../../../../hooks/useClaudeReconciliation"; export function ClaudeAccountsSection({ t, @@ -25,7 +30,8 @@ export function ClaudeAccountsSection({ const [loggingIn, setLoggingIn] = useState(false); const [error, setError] = useState(null); const [message, setMessage] = useState(null); - const [reconciling, setReconciling] = useState(false); + const [operation, setOperation] = useState<{ generation: number; success?: LocaleKey } | null>(null); + const { snapshot, accept, reconciling } = useClaudeReconciliation(); const mounted = useRef(false); const load = useCallback(async () => { const next = await claudeAccountsList(); @@ -40,27 +46,38 @@ export function ClaudeAccountsSection({ }; reload(); const unlisten = listen("claude-accounts-updated", reload); - const unlistenReconciling = listen("claude-accounts-reconciling", () => { - if (mounted.current) setReconciling(true); - }); - const unlistenReconciled = listen("claude-accounts-reconciled", () => { - if (mounted.current) setReconciling(false); - }); return () => { mounted.current = false; void unlisten.then(fn => fn()); - void unlistenReconciling.then(fn => fn()).catch(() => {}); - void unlistenReconciled.then(fn => fn()).catch(() => {}); }; }, [load]); - const run = async (operation: () => Promise, success?: LocaleKey) => { + useEffect(() => { + const outcome = localClaudeReconciliationOutcome(snapshot, operation?.generation ?? null); + if (!outcome) return; + if (outcome.status === "failed") { + setMessage(null); + setError(outcome.detail); + } else if (operation) { + if (operation.success) setMessage(t(operation.success)); + setError(null); + } + }, [operation, snapshot, t]); + const run = async ( + action: () => Promise, + success?: LocaleKey, + ) => { setBusy(true); setError(null); setMessage(null); try { - await operation(); + const result = await action(); await load(); - if (mounted.current && success) setMessage(t(success)); + if (result) { + setOperation({ generation: result.generation, success }); + accept(result); + } else if (mounted.current && success) { + setMessage(t(success)); + } } catch (e) { if (mounted.current) setError(String(e)); } finally { diff --git a/apps/desktop-tauri/src/surfaces/settings/providers/sections/credentials/ClaudeSwapAccountsSection.test.tsx b/apps/desktop-tauri/src/surfaces/settings/providers/sections/credentials/ClaudeSwapAccountsSection.test.tsx index 2b8a764438..9e1a0643fe 100644 --- a/apps/desktop-tauri/src/surfaces/settings/providers/sections/credentials/ClaudeSwapAccountsSection.test.tsx +++ b/apps/desktop-tauri/src/surfaces/settings/providers/sections/credentials/ClaudeSwapAccountsSection.test.tsx @@ -6,6 +6,7 @@ const mocks = vi.hoisted(() => ({ claudeSwapAccountsList: vi.fn(), claudeSwapAccountSwitch: vi.fn(), claudeSwapAccountReauthenticate: vi.fn(), + claudeReconciliationState: vi.fn(), getSettingsSnapshot: vi.fn(), updateSettings: vi.fn(), })); @@ -65,6 +66,7 @@ describe("ClaudeSwapAccountsSection", () => { beforeEach(() => { vi.resetAllMocks(); events.listen.mockResolvedValue(() => {}); + mocks.claudeReconciliationState.mockResolvedValue(null); mocks.getSettingsSnapshot.mockResolvedValue({ claudeSwapEnabled: false, claudeSwapExecutablePath: "", @@ -97,7 +99,9 @@ describe("ClaudeSwapAccountsSection", () => { mocks.claudeSwapAccountsList.mockResolvedValue( enabledState([active, switchable, blocked]), ); - mocks.claudeSwapAccountSwitch.mockResolvedValue(undefined); + mocks.claudeSwapAccountSwitch.mockResolvedValue({ + generation: 1, status: "succeeded", providerRefreshGeneration: 1, detail: "published", + }); render(); await screen.findByText("work@example.com"); @@ -179,7 +183,9 @@ describe("ClaudeSwapAccountsSection", () => { claudeSwapExecutablePath: "~/bin/cswap", }); mocks.claudeSwapAccountsList.mockResolvedValue(enabledState([foreign])); - mocks.claudeSwapAccountReauthenticate.mockResolvedValue(undefined); + mocks.claudeSwapAccountReauthenticate.mockResolvedValue({ + generation: 2, status: "succeeded", providerRefreshGeneration: 2, detail: "published", + }); render(); await screen.findByText("work@example.com"); await act(async () => diff --git a/apps/desktop-tauri/src/surfaces/settings/providers/sections/credentials/ClaudeSwapAccountsSection.tsx b/apps/desktop-tauri/src/surfaces/settings/providers/sections/credentials/ClaudeSwapAccountsSection.tsx index 8925558f03..ef7509ad63 100644 --- a/apps/desktop-tauri/src/surfaces/settings/providers/sections/credentials/ClaudeSwapAccountsSection.tsx +++ b/apps/desktop-tauri/src/surfaces/settings/providers/sections/credentials/ClaudeSwapAccountsSection.tsx @@ -15,6 +15,10 @@ import { getSettingsSnapshot, updateSettings, } from "../../../../../lib/tauri"; +import { + localClaudeReconciliationOutcome, + useClaudeReconciliation, +} from "../../../../../hooks/useClaudeReconciliation"; interface Props { t: (key: LocaleKey) => string; @@ -99,7 +103,8 @@ export function ClaudeSwapAccountsSection({ t, language = "english" }: Props) { const [state, setState] = useState(EMPTY_STATE); const locale = languageLocale(language); const [busy, setBusy] = useState(false); - const [reconciling, setReconciling] = useState(false); + const [operation, setOperation] = useState<{ generation: number; success: LocaleKey } | null>(null); + const { snapshot, accept, reconciling } = useClaudeReconciliation(); const [message, setMessage] = useState(null); const [error, setError] = useState(null); const mounted = useRef(false); @@ -132,20 +137,24 @@ export function ClaudeSwapAccountsSection({ t, language = "english" }: Props) { }; load(); const unlisten = listen("claude-accounts-updated", load); - const unlistenReconciling = listen("claude-accounts-reconciling", () => { - if (mounted.current) setReconciling(true); - }); - const unlistenReconciled = listen("claude-accounts-reconciled", () => { - if (mounted.current) setReconciling(false); - }); return () => { mounted.current = false; void unlisten.then((fn) => fn()).catch(() => {}); - void unlistenReconciling.then((fn) => fn()).catch(() => {}); - void unlistenReconciled.then((fn) => fn()).catch(() => {}); }; }, [reload]); + useEffect(() => { + const outcome = localClaudeReconciliationOutcome(snapshot, operation?.generation ?? null); + if (!outcome) return; + if (outcome.status === "failed") { + setMessage(null); + setError(outcome.detail); + } else if (operation) { + setMessage(t(operation.success)); + setError(null); + } + }, [operation, snapshot, t]); + const runSettings = async ( patch: { claudeSwapEnabled?: boolean; claudeSwapExecutablePath?: string }, ) => { @@ -179,24 +188,19 @@ export function ClaudeSwapAccountsSection({ t, language = "english" }: Props) { setError(null); setMessage(null); try { + const success = account.action === "reauthenticate" + ? "ClaudeSwapReauthenticated" + : "ClaudeSwapSwitched"; + let result; if (account.action === "reauthenticate") { - await claudeSwapAccountReauthenticate(account.slot); + result = await claudeSwapAccountReauthenticate(account.slot); } else if (account.action === "switch") { - await claudeSwapAccountSwitch(account.slot); + result = await claudeSwapAccountSwitch(account.slot); } else { throw new Error("This claude-swap account is not actionable."); } - // The backend emits `claude-accounts-updated` after reconciliation; - // the listener above performs the single reload. - if (mounted.current) { - setMessage( - t( - account.action === "reauthenticate" - ? "ClaudeSwapReauthenticated" - : "ClaudeSwapSwitched", - ), - ); - } + setOperation({ generation: result.generation, success }); + accept(result); } catch (e) { if (mounted.current) setError(String(e)); } finally { diff --git a/apps/desktop-tauri/src/types/bridge.ts b/apps/desktop-tauri/src/types/bridge.ts index 5aa0a40f27..88754baa7d 100644 --- a/apps/desktop-tauri/src/types/bridge.ts +++ b/apps/desktop-tauri/src/types/bridge.ts @@ -1082,6 +1082,13 @@ export interface ClaudeAccount { isSaved: boolean; } +export interface ClaudeReconciliationSnapshot { + generation: number; + status: "pending" | "succeeded" | "failed"; + providerRefreshGeneration: number | null; + detail: string; +} + export interface GrokAccount { id: string; email: string; diff --git a/rust/src/notifications.rs b/rust/src/notifications.rs index d815bb868a..94a109bf40 100755 --- a/rust/src/notifications.rs +++ b/rust/src/notifications.rs @@ -855,6 +855,32 @@ mod tests { )); } + #[test] + fn unresolved_and_resolved_warning_histories_remain_separate() { + let now = DateTime::from_timestamp(1_800_000_000, 0).unwrap(); + let reset = window(now, Duration::hours(3), 300); + let risk = pace(false, Some(3600.0)); + let mut manager = NotificationManager::new(); + + assert!(manager.record_predictive_observation( + true, + ProviderId::Claude, + "claude:oauth:unknown", + PredictiveWarningWindow::Session, + &reset, + &risk, + )); + assert!(manager.record_predictive_observation( + true, + ProviderId::Claude, + "oauth:person@example.com", + PredictiveWarningWindow::Session, + &reset, + &risk, + )); + assert_eq!(manager.predictive_warning_keys.len(), 2); + } + #[test] fn session_below_high_does_not_rearm_weekly_high_toast() { // Repro for #198: session cool + weekly hot on every refresh used to