use crate::ai::request_usage_model::{ AIRequestUsageModel, AIRequestUsageModelEvent, BonusGrant, BonusGrantScope, }; use crate::terminal::general_settings::GeneralSettings; use chrono::{Duration, Utc}; use std::collections::HashSet; use warp_core::settings::Setting; use warpui::{Entity, ModelContext, SingletonEntity}; pub struct BonusGrantNotificationModel { /// In-memory tracking of grants shown during this session. This prevents duplicate /// notifications when multiple `AIRequestUsageModelEvent::RequestUsageUpdated` events /// fire in quick succession before the persisted settings can be updated. shown_grants_session: HashSet, } #[derive(Debug, Clone)] pub enum BonusGrantNotificationEvent { ShowNotification { grant: BonusGrant, message: String }, } impl Entity for BonusGrantNotificationModel { type Event = BonusGrantNotificationEvent; } impl SingletonEntity for BonusGrantNotificationModel {} impl BonusGrantNotificationModel { pub fn new(ctx: &mut ModelContext) -> Self { ctx.subscribe_to_model(&AIRequestUsageModel::handle(ctx), |me, event, ctx| { if let AIRequestUsageModelEvent::RequestUsageUpdated = event { me.check_for_new_bonus_grants(ctx); } }); Self { shown_grants_session: HashSet::new(), } } fn check_for_new_bonus_grants(&mut self, ctx: &mut ModelContext) { let usage_model = AIRequestUsageModel::as_ref(ctx); let bonus_grants = usage_model.bonus_grants(); let shown_grants = GeneralSettings::as_ref(ctx) .bonus_grants_shown .value() .clone(); // Only show grants created in the past 2 weeks let cutoff_date = Utc::now() - Duration::days(14); let mut grants_to_notify = Vec::new(); let mut grants_to_persist_to_settings = Vec::new(); for grant in bonus_grants { // Only notify about Warp-granted credits (cost = 0), not user purchases if grant.cost_cents != 0 { continue; } // Only show grants created in the past 2 weeks to avoid overwhelming users // with old grant notifications if grant.created_at < cutoff_date { continue; } // doesn't make sense to show "you've got a bonus grant" message if no credits remain (i.e. your teammate used them all) if grant.request_credits_remaining <= 0 { continue; } // Use server-provided message if available, otherwise fall back to generic message let message = if let Some(user_facing_message) = &grant.user_facing_message { user_facing_message.clone() } else { Self::format_generic_grant_message(grant) }; let grant_key = Self::create_grant_key(grant); let in_persisted = shown_grants.contains(&grant_key); let in_session = self.shown_grants_session.contains(&grant_key); if !in_persisted && !in_session { grants_to_notify.push((grant.clone(), message, grant_key.clone())); } if !in_persisted { grants_to_persist_to_settings.push(grant_key.clone()); } } for (grant, message, grant_key) in grants_to_notify { self.shown_grants_session.insert(grant_key); ctx.emit(BonusGrantNotificationEvent::ShowNotification { grant, message }); } for grant_key in grants_to_persist_to_settings { self.mark_grant_as_shown(&grant_key, ctx); self.shown_grants_session.insert(grant_key); } } fn format_generic_grant_message(grant: &BonusGrant) -> String { let scope_text = match grant.scope { BonusGrantScope::User => "account", BonusGrantScope::Workspace(_) => "team", }; format!( "{} Reload Credits have been added to your {}.", grant.request_credits_granted, scope_text ) } fn create_grant_key(grant: &BonusGrant) -> String { format!("{}:{}", grant.reason, grant.created_at.timestamp()) } fn mark_grant_as_shown(&self, grant_key: &str, ctx: &mut ModelContext) { GeneralSettings::handle(ctx).update(ctx, |settings, ctx| { let mut shown_grants = settings.bonus_grants_shown.value().clone(); shown_grants.insert(grant_key.to_string()); if let Err(e) = settings.bonus_grants_shown.set_value(shown_grants, ctx) { log::warn!("Failed to mark bonus grant as shown: {e}"); } }); } }