Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 51 additions & 2 deletions apps/desktop-tauri/src-tauri/src/commands/providers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ use super::warning_identity::WarningIdentity;
use super::*;
use chrono::{Local, Utc};
use codexbar::core::HookUsageWindow;
use codexbar::notifications::WarningScope;
use serde::Serialize;
use std::sync::Arc;

Expand Down Expand Up @@ -976,12 +977,24 @@ fn notify_usage_thresholds(
snapshot.account_organization.as_deref(),
token_account_id,
);
// Hooks keep their own per-source baselines (edge-triggered, first sample
// never fires), so they stay on the source key; only toast dedupe below
// bridges account-identity gaps.
let account = warning_identity.threshold_key();
let scope = warning_identity.gap_scope();
// Skip all session consumers for synthetic/no-session
// placeholders (e.g. Claude OAuth five_hour: null).
let session_account = resolve_toast_account(
&mut guard.notification_manager,
provider,
&scope,
"session",
&snapshot.primary,
settings,
);
if guard.notification_manager.check_session_lane(
provider,
&account,
&session_account,
snapshot.primary.used_percent,
snapshot.primary.is_informational,
settings,
Expand All @@ -997,9 +1010,17 @@ fn notify_usage_thresholds(
if let Some(weekly) = &snapshot.secondary
&& !weekly.is_informational
{
let weekly_account = resolve_toast_account(
&mut guard.notification_manager,
provider,
&scope,
"weekly",
weekly,
settings,
);
guard.notification_manager.check_and_notify(
provider,
&account,
&weekly_account,
"weekly",
weekly.used_percent,
settings,
Expand All @@ -1024,6 +1045,34 @@ fn notify_usage_thresholds(
}
}

/// Account key a toast lane is deduped under (see `NotificationManager::resolve_warning_account`).
/// Informational placeholders are not observed, matching `check_session_lane`.
fn resolve_toast_account(
manager: &mut codexbar::notifications::NotificationManager,
provider: ProviderId,
scope: &WarningScope,
window: &str,
lane: &RateWindowSnapshot,
settings: &Settings,
) -> String {
if lane.is_informational {
return scope.key().to_string();
}
let resets_at = lane
.resets_at
.as_deref()
.and_then(|value| chrono::DateTime::parse_from_rfc3339(value).ok())
.map(|date| date.with_timezone(&chrono::Utc));
manager.resolve_warning_account(
provider,
scope,
window,
lane.used_percent,
resets_at,
settings,
)
}

fn dispatch_quota_hooks(
settings: &Settings,
provider: ProviderId,
Expand Down
56 changes: 56 additions & 0 deletions apps/desktop-tauri/src-tauri/src/commands/warning_identity.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
use codexbar::core::ProviderId;
use codexbar::notifications::WarningScope;

#[derive(Debug, Clone, PartialEq, Eq)]
enum WarningSourceLane {
Expand Down Expand Up @@ -106,6 +107,21 @@ impl WarningIdentity {
}
}

/// How this identity takes part in threshold-warning continuity across
/// identity gaps. Only resolved emails and unresolved Claude CLI/OAuth
/// samples are bridged; token accounts and organizations stay independent.
pub(super) fn gap_scope(&self) -> WarningScope {
let key = self.threshold_key();
match (&self.account, self.unresolved_key()) {
(WarningAccountState::Unresolved, _) => WarningScope::Unresolved(key),
(WarningAccountState::Email(_), Some(unresolved)) => WarningScope::Resolved {
account: key,
unresolved,
},
_ => WarningScope::Independent(key),
}
}

pub(super) fn predictive_key(&self) -> Option<String> {
match &self.account {
WarningAccountState::Token(id) => Some(format!("token-account:{}", id.as_hyphenated())),
Expand Down Expand Up @@ -223,4 +239,44 @@ mod tests {
Some("cli:person@example.com")
);
}

#[test]
fn gap_scope_bridges_only_resolved_email_and_unresolved_claude_sources() {
let unresolved = WarningIdentity::new(ProviderId::Claude, "cli", None, None, None);
assert_eq!(
unresolved.gap_scope(),
WarningScope::Unresolved("claude:cli:unknown".to_string())
);
let resolved = WarningIdentity::new(
ProviderId::Claude,
"cli",
Some("Person@Example.com"),
None,
None,
);
assert_eq!(
resolved.gap_scope(),
WarningScope::Resolved {
account: "person@example.com".to_string(),
unresolved: "claude:cli:unknown".to_string(),
}
);
let token = WarningIdentity::new(
ProviderId::Claude,
"cli",
Some("person@example.com"),
None,
Some(uuid::Uuid::nil()),
);
assert!(matches!(token.gap_scope(), WarningScope::Independent(_)));
let org = WarningIdentity::new(ProviderId::Claude, "cli", None, Some("Acme"), None);
assert_eq!(
org.gap_scope(),
WarningScope::Independent("org:acme".to_string())
);
let web = WarningIdentity::new(ProviderId::Claude, "web", Some("a@b.c"), None, None);
assert!(matches!(web.gap_scope(), WarningScope::Independent(_)));
let codex = WarningIdentity::new(ProviderId::Codex, "cli", Some("a@b.c"), None, None);
assert!(matches!(codex.gap_scope(), WarningScope::Independent(_)));
}
}
20 changes: 18 additions & 2 deletions rust/src/notifications.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,10 @@ use crate::settings::Settings;
use crate::sound::{NotificationSoundEvent, play_alert};
use chrono::{DateTime, Utc};

mod identity_gaps;

pub use identity_gaps::WarningScope;

/// Notification types
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum NotificationType {
Expand Down Expand Up @@ -132,6 +136,10 @@ pub struct NotificationManager {
previous_session_percent: std::collections::HashMap<SessionTransitionKey, f64>,
predictive_warning_keys: std::collections::HashSet<PredictiveWarningKey>,
deepseek_pricing_period: Option<String>,
identity_gaps: identity_gaps::IdentityGapState,
/// Toasts that would have been shown; tests assert on this instead of popping real toasts.
#[cfg(test)]
toasts: std::cell::RefCell<Vec<String>>,
}

impl NotificationManager {
Expand All @@ -141,6 +149,9 @@ impl NotificationManager {
previous_session_percent: std::collections::HashMap::new(),
predictive_warning_keys: std::collections::HashSet::new(),
deepseek_pricing_period: None,
identity_gaps: identity_gaps::IdentityGapState::default(),
#[cfg(test)]
toasts: std::cell::RefCell::new(Vec::new()),
}
}

Expand Down Expand Up @@ -514,7 +525,12 @@ impl NotificationManager {
Self::play_notification_sound(NotificationSoundEvent::StatusIssue, settings);
}

#[cfg(target_os = "windows")]
#[cfg(test)]
fn show_toast(&self, title: &str, body: &str) {
self.toasts.borrow_mut().push(format!("{title}: {body}"));
}

#[cfg(all(target_os = "windows", not(test)))]
fn show_toast(&self, title: &str, body: &str) {
use std::os::windows::process::CommandExt;
use std::process::Command;
Expand Down Expand Up @@ -578,7 +594,7 @@ impl NotificationManager {
}
}

#[cfg(not(target_os = "windows"))]
#[cfg(all(not(target_os = "windows"), not(test)))]
fn show_toast(&self, title: &str, body: &str) {
use std::process::Command;

Expand Down
Loading