From 6ade58b701d9a98100f3492c870b85ac4c137769 Mon Sep 17 00:00:00 2001 From: IsaiahWitzke Date: Wed, 20 May 2026 20:18:29 +0000 Subject: [PATCH 1/5] [REV-1603] Show billing V2 for solo free users Co-Authored-By: Oz --- .../billing_and_usage_dispatch.rs | 75 ++++++++++++++++--- 1 file changed, 65 insertions(+), 10 deletions(-) diff --git a/app/src/settings_view/billing_and_usage_dispatch.rs b/app/src/settings_view/billing_and_usage_dispatch.rs index 7dd37ea0ac..63362a770f 100644 --- a/app/src/settings_view/billing_and_usage_dispatch.rs +++ b/app/src/settings_view/billing_and_usage_dispatch.rs @@ -7,6 +7,7 @@ use warpui::{AppContext, Element, Entity, SingletonEntity, View, ViewContext, Vi use crate::auth::{AuthManager, AuthStateProvider}; use crate::workspaces::user_workspaces::UserWorkspaces; +use crate::workspaces::workspace::Workspace; use super::billing_and_usage_page::{BillingAndUsagePageEvent, BillingAndUsagePageView}; use super::billing_and_usage_page_v2::BillingAndUsagePageV2View; @@ -55,16 +56,18 @@ impl BillingAndUsageDispatchView { if !FeatureFlag::BillingAndUsagePageV2.is_enabled() { return false; } - UserWorkspaces::as_ref(ctx) - .current_workspace() - .is_some_and(|workspace| { - let bm = &workspace.billing_metadata; - bm.is_on_build_plan() - || bm.is_on_build_max_plan() - || bm.is_on_build_business_plan() - || bm.is_enterprise_plan() - || bm.is_free_plan() - }) + Self::workspace_uses_v2(UserWorkspaces::as_ref(ctx).current_workspace()) + } + + fn workspace_uses_v2(workspace: Option<&Workspace>) -> bool { + workspace.is_none_or(|workspace| { + let bm = &workspace.billing_metadata; + bm.is_on_build_plan() + || bm.is_on_build_max_plan() + || bm.is_on_build_business_plan() + || bm.is_enterprise_plan() + || bm.is_free_plan() + }) } pub fn get_modal_content(&self, app: &AppContext) -> Option> { @@ -76,6 +79,58 @@ impl BillingAndUsageDispatchView { } } +#[cfg(test)] +mod tests { + use super::*; + use crate::workspaces::workspace::{BillingMetadata, CustomerType}; + + fn workspace_with_customer_type(customer_type: CustomerType) -> Workspace { + Workspace { + uid: "workspace_uid123456789".to_string().into(), + name: "test".to_string(), + stripe_customer_id: None, + teams: vec![], + billing_metadata: BillingMetadata { + customer_type, + ..Default::default() + }, + bonus_grants_purchased_this_month: Default::default(), + billing_cycle_usage: None, + has_billing_history: false, + settings: Default::default(), + invite_code: None, + invite_link_domain_restrictions: vec![], + pending_email_invites: vec![], + is_eligible_for_discovery: false, + members: vec![], + total_requests_used_since_last_refresh: 0, + } + } + + #[test] + fn uses_v2_when_user_has_no_workspace() { + assert!(BillingAndUsageDispatchView::workspace_uses_v2(None)); + } + + #[test] + fn uses_v2_for_free_workspace() { + let workspace = workspace_with_customer_type(CustomerType::Free); + + assert!(BillingAndUsageDispatchView::workspace_uses_v2(Some( + &workspace + ))); + } + + #[test] + fn does_not_use_v2_for_legacy_paid_workspace() { + let workspace = workspace_with_customer_type(CustomerType::Prosumer); + + assert!(!BillingAndUsageDispatchView::workspace_uses_v2(Some( + &workspace + ))); + } +} + impl Entity for BillingAndUsageDispatchView { type Event = BillingAndUsagePageEvent; } From 19599fdece8e3794106b9d80021b769c7d4ed47e Mon Sep 17 00:00:00 2001 From: Isaiah Date: Thu, 21 May 2026 18:02:14 -0400 Subject: [PATCH 2/5] [REV-1603] Refactor billing cycle usage section + own-usage rendering MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Split BillingCycleUsageSectionView::render into three cases: render_team_usage, render_own_usage_with_workspace, render_own_usage_solo, dispatched via a new shows_team_section() helper. - Move row constructors onto MemberUsageRow as methods (for_viewer, for_viewer_from_total, for_other_members, for_each_member) so they only depend on entries/members/primitives — no Workspace, AppContext, or AIRequestUsageModel. - Add viewer_base_limit helper for the workspace -> base_limit lookup; render helpers and build_rows resolve identity + base_limit themselves. - Rename build_row_card to render_row_card to match the render_* convention for Element-producing helpers. - Update tests to call MemberUsageRow::for_viewer. Co-Authored-By: Oz --- .../billing_cycle_usage_rows.rs | 505 ++++++++++-------- .../billing_cycle_usage_rows_tests.rs | 12 +- .../billing_cycle_usage_section.rs | 193 +++++-- 3 files changed, 430 insertions(+), 280 deletions(-) diff --git a/app/src/settings_view/billing_and_usage/billing_cycle_usage_rows.rs b/app/src/settings_view/billing_and_usage/billing_cycle_usage_rows.rs index 93661e668e..44e7290d18 100644 --- a/app/src/settings_view/billing_and_usage/billing_cycle_usage_rows.rs +++ b/app/src/settings_view/billing_and_usage/billing_cycle_usage_rows.rs @@ -18,6 +18,7 @@ use warpui::{ }; use crate::{ + ai::AIRequestUsageModel, auth::AuthStateProvider, settings_view::billing_and_usage::billing_cycle_usage_common::{ aggregate_segments, cost_type_color, format_cost_cents, format_credits, @@ -26,8 +27,9 @@ use crate::{ }, ui_components::{blended_colors, icons::Icon}, workspaces::workspace::{ - AiCreditsUsageAndCostSubjectType, AiCreditsUsageSource, BillingCycleUsageEntry, - UsageVisibility, UsageVisibilityGranularity, Workspace, WorkspaceMember, + AiCreditsUsageAndCostSubjectType, AiCreditsUsageAndCostType, AiCreditsUsageBucket, + AiCreditsUsageSource, BillingCycleUsageEntry, UsageVisibility, UsageVisibilityGranularity, + Workspace, WorkspaceMember, }, }; @@ -95,57 +97,25 @@ fn member_base_limit(member: &WorkspaceMember) -> Option { } } -/// Single row for `OwnOnly` viewers — the viewer's own aggregated usage. -pub fn build_own_usage_row( - entries: &[BillingCycleUsageEntry], - viewer_uid: Option<&str>, - viewer_display_name: String, - viewer_base_limit: Option, - source_filter: SourceFilter, -) -> MemberUsageRow { - let viewer_entries = entries - .iter() - .filter(|e| source_filter.matches(&e.usage_source)) - // Defensive: positive-attribute to the viewer only. - .filter(|e| match (viewer_uid, e.subject_uid.as_deref()) { - (Some(uid), Some(entry_uid)) => uid == entry_uid, - _ => false, - }) - .collect_vec(); - - let (segments, total_credits, total_cost_cents) = - aggregate_segments(viewer_entries.iter().copied()); - - MemberUsageRow { - subject_type: AiCreditsUsageAndCostSubjectType::User, - subject_key: SELF_OWN_KEY.to_string(), - subject_uid: viewer_uid.map(str::to_string), - display_name: viewer_display_name, - total_credits, - total_cost_cents, - base_limit: viewer_base_limit, - segments, - bar_max_credits: 0, - } +fn viewer_identity(app: &AppContext) -> (Option, String) { + let auth_state = AuthStateProvider::as_ref(app).get(); + let viewer_uid = auth_state.user_id().map(|uid| uid.as_string()); + let display_name = auth_state + .display_name() + .or_else(|| auth_state.username_for_display()) + .or_else(|| auth_state.user_email()) + .unwrap_or_else(|| "Your usage".to_string()); + (viewer_uid, display_name) } -fn build_other_members_usage_row(entries: &[BillingCycleUsageEntry]) -> MemberUsageRow { - let team_entries = entries - .iter() - .filter(|e| e.subject_type == AiCreditsUsageAndCostSubjectType::Team); - let (segments, total_credits, total_cost_cents) = aggregate_segments(team_entries); - - MemberUsageRow { - subject_type: AiCreditsUsageAndCostSubjectType::Team, - subject_key: OTHER_MEMBERS_KEY.to_string(), - subject_uid: None, - display_name: "Other members".to_string(), - total_credits, - total_cost_cents, - base_limit: None, - segments, - bar_max_credits: 0, - } +fn viewer_base_limit(workspace: &Workspace, viewer_uid: Option<&str>) -> Option { + viewer_uid.and_then(|u| { + workspace + .members + .iter() + .find(|m| m.uid.as_str() == u) + .and_then(member_base_limit) + }) } struct GroupedSubjectUsage { @@ -154,102 +124,258 @@ struct GroupedSubjectUsage { entries: Vec, } -/// Per-member rows for `PerUserTotals` viewers. Iterates the workspace member -/// list so zero-usage members still get a row. Service accounts and other -/// non-member subjects surface as extra rows at the bottom. -pub fn build_member_usage_rows( - entries: &[BillingCycleUsageEntry], - members: &[WorkspaceMember], - source_filter: SourceFilter, -) -> Vec { - // Group entries by subject for joining against the member list below. - let mut grouped: HashMap = HashMap::new(); - let mut unknown_counter = 0usize; - - for entry in entries - .iter() - .filter(|e| e.subject_type != AiCreditsUsageAndCostSubjectType::Team) - { - if !source_filter.matches(&entry.usage_source) { - continue; - } - - let key = match entry.subject_uid.as_deref() { - Some(uid) => format!("{:?}:{uid}", entry.subject_type), - None => { - unknown_counter += 1; - format!("{:?}:unknown-{unknown_counter}", entry.subject_type) - } - }; - let group = grouped.entry(key).or_insert_with(|| GroupedSubjectUsage { - subject_type: entry.subject_type.clone(), - display_name: entry - .subject_display_name - .clone() - .unwrap_or_else(|| "Unknown".to_string()), - entries: Vec::new(), - }); - group.entries.push(entry.clone()); - } - - let mut rows: Vec = Vec::with_capacity(members.len()); - - // One row per workspace member, including zero-usage members. - let mut seen_keys: std::collections::HashSet = Default::default(); - for member in members { - let key = format!( - "{:?}:{}", - AiCreditsUsageAndCostSubjectType::User, - member.uid.as_str() - ); - seen_keys.insert(key.clone()); - - let (segments, total_credits, total_cost_cents) = match grouped.remove(&key) { - Some(group) => aggregate_segments(group.entries.iter()), - None => (Vec::new(), 0, 0), - }; - - rows.push(MemberUsageRow { +impl MemberUsageRow { + /// Viewer row aggregated from billing-cycle entries — full segment + /// and cost fidelity. The viewer's identity and per-user + /// `base_limit` are passed in by callers that already have + /// workspace/app context. + fn for_viewer( + entries: &[BillingCycleUsageEntry], + viewer_uid: Option<&str>, + viewer_display_name: String, + viewer_base_limit: Option, + source_filter: SourceFilter, + ) -> Self { + let viewer_entries = entries + .iter() + .filter(|e| source_filter.matches(&e.usage_source)) + // Defensive: positive-attribute to the viewer only. + .filter(|e| match (viewer_uid, e.subject_uid.as_deref()) { + (Some(uid), Some(entry_uid)) => uid == entry_uid, + _ => false, + }) + .collect_vec(); + let (segments, total_credits, total_cost_cents) = + aggregate_segments(viewer_entries.iter().copied()); + + Self { subject_type: AiCreditsUsageAndCostSubjectType::User, - subject_key: key, - subject_uid: Some(member.uid.as_str().to_string()), - display_name: member.email.clone(), + subject_key: SELF_OWN_KEY.to_string(), + subject_uid: viewer_uid.map(str::to_string), + display_name: viewer_display_name, total_credits, total_cost_cents, - base_limit: member_base_limit(member), + base_limit: viewer_base_limit, segments, bar_max_credits: 0, - }); + } } - // Subjects not in the member list (typically service accounts) render after. - for (key, group) in grouped { - if seen_keys.contains(&key) { - continue; + /// Viewer row built from a raw used-credits count, with no segment + /// breakdown. For callers that only have `AIRequestUsageModel`-style + /// data (no `billing_cycle_usage` entries). + fn for_viewer_from_total( + viewer_uid: Option, + viewer_display_name: String, + base_limit: Option, + used: i64, + ) -> Self { + let segments = if used > 0 { + vec![BarSegment { + cost_type: AiCreditsUsageAndCostType::BaseLimit, + usage_bucket: AiCreditsUsageBucket::Ai, + credits: used, + cost_cents: 0, + }] + } else { + Vec::new() + }; + Self { + subject_type: AiCreditsUsageAndCostSubjectType::User, + subject_key: SELF_OWN_KEY.to_string(), + subject_uid: viewer_uid, + display_name: viewer_display_name, + total_credits: used, + total_cost_cents: 0, + base_limit, + segments, + bar_max_credits: used.max(1), } - // All entries in a group share the same subject_uid by construction - // (it's part of the grouping key), so first.is representative. - let subject_uid = group.entries.first().and_then(|e| e.subject_uid.clone()); - let (segments, total_credits, total_cost_cents) = aggregate_segments(group.entries.iter()); - rows.push(MemberUsageRow { - subject_type: group.subject_type, - subject_key: key, - subject_uid, - display_name: group.display_name, + } + + /// Synthetic "Other members" aggregate row used by TeamAggregate + /// visibility — represents everyone except the viewer. + fn for_other_members(entries: &[BillingCycleUsageEntry]) -> Self { + let team_entries = entries + .iter() + .filter(|e| e.subject_type == AiCreditsUsageAndCostSubjectType::Team); + let (segments, total_credits, total_cost_cents) = aggregate_segments(team_entries); + + Self { + subject_type: AiCreditsUsageAndCostSubjectType::Team, + subject_key: OTHER_MEMBERS_KEY.to_string(), + subject_uid: None, + display_name: "Other members".to_string(), total_credits, total_cost_cents, base_limit: None, segments, bar_max_credits: 0, + } + } + + /// Per-member rows for `PerUserTotals` / `FullBreakdown` visibility. + /// Iterates the workspace member list so zero-usage members still + /// get a row. Service accounts and other non-member subjects surface + /// as extra rows at the bottom, sorted by total credits desc. + fn for_each_member( + entries: &[BillingCycleUsageEntry], + members: &[WorkspaceMember], + source_filter: SourceFilter, + ) -> Vec { + // Group entries by subject for joining against the member list below. + let mut grouped: HashMap = HashMap::new(); + let mut unknown_counter = 0usize; + + for entry in entries + .iter() + .filter(|e| e.subject_type != AiCreditsUsageAndCostSubjectType::Team) + { + if !source_filter.matches(&entry.usage_source) { + continue; + } + + let key = match entry.subject_uid.as_deref() { + Some(uid) => format!("{:?}:{uid}", entry.subject_type), + None => { + unknown_counter += 1; + format!("{:?}:unknown-{unknown_counter}", entry.subject_type) + } + }; + let group = grouped.entry(key).or_insert_with(|| GroupedSubjectUsage { + subject_type: entry.subject_type.clone(), + display_name: entry + .subject_display_name + .clone() + .unwrap_or_else(|| "Unknown".to_string()), + entries: Vec::new(), + }); + group.entries.push(entry.clone()); + } + + let mut rows: Vec = Vec::with_capacity(members.len()); + + // One row per workspace member, including zero-usage members. + let mut seen_keys: std::collections::HashSet = Default::default(); + for member in members { + let key = format!( + "{:?}:{}", + AiCreditsUsageAndCostSubjectType::User, + member.uid.as_str() + ); + seen_keys.insert(key.clone()); + + let (segments, total_credits, total_cost_cents) = match grouped.remove(&key) { + Some(group) => aggregate_segments(group.entries.iter()), + None => (Vec::new(), 0, 0), + }; + + rows.push(Self { + subject_type: AiCreditsUsageAndCostSubjectType::User, + subject_key: key, + subject_uid: Some(member.uid.as_str().to_string()), + display_name: member.email.clone(), + total_credits, + total_cost_cents, + base_limit: member_base_limit(member), + segments, + bar_max_credits: 0, + }); + } + + // Subjects not in the member list (typically service accounts) render after. + for (key, group) in grouped { + if seen_keys.contains(&key) { + continue; + } + // All entries in a group share the same subject_uid by construction + // (it's part of the grouping key), so first.is representative. + let subject_uid = group.entries.first().and_then(|e| e.subject_uid.clone()); + let (segments, total_credits, total_cost_cents) = + aggregate_segments(group.entries.iter()); + rows.push(Self { + subject_type: group.subject_type, + subject_key: key, + subject_uid, + display_name: group.display_name, + total_credits, + total_cost_cents, + base_limit: None, + segments, + bar_max_credits: 0, + }); + } + + // Sort by total credits desc, stable by subject_key. + rows.sort_by(|a, b| { + b.total_credits + .cmp(&a.total_credits) + .then_with(|| a.subject_key.cmp(&b.subject_key)) }); + + rows } +} - // Sort by total credits desc, stable by subject_key. - rows.sort_by(|a, b| { - b.total_credits - .cmp(&a.total_credits) - .then_with(|| a.subject_key.cmp(&b.subject_key)) - }); +fn build_rows( + workspace: &Workspace, + entries: &[BillingCycleUsageEntry], + visibility: &UsageVisibility, + shows_team_section: bool, + source_filter: SourceFilter, + app: &AppContext, +) -> Vec { + let mut rows: Vec = match visibility.granularity { + UsageVisibilityGranularity::OwnOnly => { + let (viewer_uid, display_name) = viewer_identity(app); + let base_limit = viewer_base_limit(workspace, viewer_uid.as_deref()); + vec![MemberUsageRow::for_viewer( + entries, + viewer_uid.as_deref(), + display_name, + base_limit, + source_filter, + )] + } + UsageVisibilityGranularity::TeamAggregate => { + // Force SourceFilter::All — TeamAggregate has no toggle. + let (viewer_uid, display_name) = viewer_identity(app); + let base_limit = viewer_base_limit(workspace, viewer_uid.as_deref()); + let mut rows = vec![MemberUsageRow::for_viewer( + entries, + viewer_uid.as_deref(), + display_name, + base_limit, + SourceFilter::All, + )]; + if shows_team_section { + rows.push(MemberUsageRow::for_other_members(entries)); + } + rows + } + UsageVisibilityGranularity::PerUserTotals | UsageVisibilityGranularity::FullBreakdown => { + MemberUsageRow::for_each_member(entries, &workspace.members, source_filter) + } + }; + + match visibility.granularity { + UsageVisibilityGranularity::OwnOnly | UsageVisibilityGranularity::TeamAggregate => { + for row in &mut rows { + row.bar_max_credits = row.total_credits.max(1); + } + } + UsageVisibilityGranularity::PerUserTotals | UsageVisibilityGranularity::FullBreakdown => { + let top = rows + .iter() + .map(|r| r.total_credits) + .max() + .unwrap_or(0) + .max(1); + for row in &mut rows { + row.bar_max_credits = top; + } + } + } rows } @@ -370,8 +496,8 @@ fn render_service_account_info_tooltip(appearance: &Appearance) -> Box Box { // No segments => no tooltip needed. if row.segments.is_empty() { - return build_row_card(row, team_max_credits, mouse_states, appearance); + return render_row_card(row, team_max_credits, mouse_states, appearance); } // The info icon sits inside the row card, so hovering it would otherwise @@ -562,7 +688,7 @@ fn render_member_row( Hoverable::new(tooltip_mouse_state, move |state| { let mut stack = Stack::new(); - stack.add_child(build_row_card( + stack.add_child(render_row_card( row, team_max_credits, mouse_states, @@ -649,36 +775,40 @@ fn render_source_filter_toggle( .finish() } -/// Resolves the current viewer's own usage row from the auth state, picking -/// up their display name and base credit limit from the workspace member list. -fn build_viewer_own_usage_row( +pub fn render_own_usage_with_workspace_row( workspace: &Workspace, entries: &[BillingCycleUsageEntry], + mouse_states: &BillingUsageMouseStates, + appearance: &Appearance, app: &AppContext, - source_filter: SourceFilter, -) -> MemberUsageRow { - let auth_state = AuthStateProvider::as_ref(app).get(); - let viewer_uid = auth_state.user_id().map(|uid| uid.as_string()); - let display_name = auth_state - .display_name() - .or_else(|| auth_state.username_for_display()) - .or_else(|| auth_state.user_email()) - .unwrap_or_else(|| "Your usage".to_string()); - // Surface the viewer's own base limit so they see `used / limit`. - let viewer_base_limit = viewer_uid.as_deref().and_then(|uid| { - workspace - .members - .iter() - .find(|m| m.uid.as_str() == uid) - .and_then(member_base_limit) - }); - build_own_usage_row( +) -> Box { + let (viewer_uid, display_name) = viewer_identity(app); + let base_limit = viewer_base_limit(workspace, viewer_uid.as_deref()); + let row = MemberUsageRow::for_viewer( entries, viewer_uid.as_deref(), display_name, - viewer_base_limit, - source_filter, - ) + base_limit, + SourceFilter::All, + ); + render_member_row_list(std::slice::from_ref(&row), mouse_states, appearance) +} + +pub fn render_own_usage_solo_row( + mouse_states: &BillingUsageMouseStates, + appearance: &Appearance, + app: &AppContext, +) -> Box { + let (viewer_uid, display_name) = viewer_identity(app); + let model = AIRequestUsageModel::as_ref(app); + let base_limit = (!model.is_unlimited()).then(|| model.request_limit() as i64); + let row = MemberUsageRow::for_viewer_from_total( + viewer_uid, + display_name, + base_limit, + model.requests_used() as i64, + ); + render_member_row_list(std::slice::from_ref(&row), mouse_states, appearance) } #[allow(clippy::too_many_arguments)] @@ -720,61 +850,6 @@ pub fn render_rows( column.finish() } -fn build_rows( - workspace: &Workspace, - entries: &[BillingCycleUsageEntry], - visibility: &UsageVisibility, - shows_team_section: bool, - source_filter: SourceFilter, - app: &AppContext, -) -> Vec { - let mut rows: Vec = match visibility.granularity { - UsageVisibilityGranularity::OwnOnly => vec![build_viewer_own_usage_row( - workspace, - entries, - app, - source_filter, - )], - UsageVisibilityGranularity::TeamAggregate => { - // Force SourceFilter::All — TeamAggregate has no toggle. - let mut rows = vec![build_viewer_own_usage_row( - workspace, - entries, - app, - SourceFilter::All, - )]; - if shows_team_section { - rows.push(build_other_members_usage_row(entries)); - } - rows - } - UsageVisibilityGranularity::PerUserTotals | UsageVisibilityGranularity::FullBreakdown => { - build_member_usage_rows(entries, &workspace.members, source_filter) - } - }; - - match visibility.granularity { - UsageVisibilityGranularity::OwnOnly | UsageVisibilityGranularity::TeamAggregate => { - for row in &mut rows { - row.bar_max_credits = row.total_credits.max(1); - } - } - UsageVisibilityGranularity::PerUserTotals | UsageVisibilityGranularity::FullBreakdown => { - let top = rows - .iter() - .map(|r| r.total_credits) - .max() - .unwrap_or(0) - .max(1); - for row in &mut rows { - row.bar_max_credits = top; - } - } - } - - rows -} - fn render_member_header( visibility: &UsageVisibility, shows_team_section: bool, @@ -812,8 +887,6 @@ fn render_member_header( Some(Container::new(header).with_margin_bottom(8.).finish()) } -/// Dumb iteration over the row vec. Each row carries its own -/// `bar_max_credits` so we don't need any cross-row context here. fn render_member_row_list( rows: &[MemberUsageRow], mouse_states: &BillingUsageMouseStates, diff --git a/app/src/settings_view/billing_and_usage/billing_cycle_usage_rows_tests.rs b/app/src/settings_view/billing_and_usage/billing_cycle_usage_rows_tests.rs index 417c6457ee..770d45e9c1 100644 --- a/app/src/settings_view/billing_and_usage/billing_cycle_usage_rows_tests.rs +++ b/app/src/settings_view/billing_and_usage/billing_cycle_usage_rows_tests.rs @@ -1,4 +1,4 @@ -use super::{build_own_usage_row, SourceFilter}; +use super::{MemberUsageRow, SourceFilter}; use crate::workspaces::workspace::{ AiCreditsUsageAndCostSubjectType, AiCreditsUsageAndCostType, AiCreditsUsageBucket, AiCreditsUsageSource, BillingCycleUsageEntry, @@ -46,7 +46,7 @@ fn build_own_usage_row_drops_team_subject_entries() { 999, ), ]; - let row = build_own_usage_row( + let row = MemberUsageRow::for_viewer( &entries, Some(VIEWER_UID), "viewer".to_string(), @@ -75,7 +75,7 @@ fn build_own_usage_row_drops_other_users_entries() { 999, ), ]; - let row = build_own_usage_row( + let row = MemberUsageRow::for_viewer( &entries, Some(VIEWER_UID), "viewer".to_string(), @@ -104,7 +104,7 @@ fn build_own_usage_row_local_filter_drops_cloud_entries() { 0, ), ]; - let row = build_own_usage_row( + let row = MemberUsageRow::for_viewer( &entries, Some(VIEWER_UID), "viewer".to_string(), @@ -132,7 +132,7 @@ fn build_own_usage_row_cloud_filter_drops_local_entries() { 0, ), ]; - let row = build_own_usage_row( + let row = MemberUsageRow::for_viewer( &entries, Some(VIEWER_UID), "viewer".to_string(), @@ -144,7 +144,7 @@ fn build_own_usage_row_cloud_filter_drops_local_entries() { #[test] fn build_own_usage_row_surfaces_supplied_base_limit() { - let row = build_own_usage_row( + let row = MemberUsageRow::for_viewer( &[], Some(VIEWER_UID), "viewer".to_string(), diff --git a/app/src/settings_view/billing_and_usage/billing_cycle_usage_section.rs b/app/src/settings_view/billing_and_usage/billing_cycle_usage_section.rs index 4d414a322e..179411ff67 100644 --- a/app/src/settings_view/billing_and_usage/billing_cycle_usage_section.rs +++ b/app/src/settings_view/billing_and_usage/billing_cycle_usage_section.rs @@ -25,7 +25,10 @@ use crate::{ billing_cycle_usage_common::{ filter_legacy_buckets, has_non_viewer_data, BillingUsageMouseStates, }, - billing_cycle_usage_rows::{has_cloud_usage, render_rows, SourceFilter}, + billing_cycle_usage_rows::{ + has_cloud_usage, render_own_usage_solo_row, render_own_usage_with_workspace_row, + render_rows, SourceFilter, + }, billing_cycle_usage_team_totals::render_team_totals_block, }, billing_and_usage_page_v2::{ @@ -155,6 +158,33 @@ impl BillingCycleUsageSectionView { self.selected_period_end = None; } } + + /// Whether the "Team" block + "Members" subheader should render. We + /// hide them when the viewer has no team data to show: `members.len() + /// > 1` covers the common multi-member case; `has_non_viewer_data` + /// catches the edge case where the roster shrank to one after a + /// teammate left mid-cycle but their usage is still attributed against + /// this cycle. Together they keep solo teams from showing orphan + /// scaffolding without dropping legitimate team data on departure. + /// + /// Note: per the backend invariant `VIS != OwnOnly => viewer is admin`, + /// so we don't need a separate admin gate here. + fn shows_team_section(&self, workspace: &Workspace, app: &AppContext) -> bool { + let visibility = workspace.resolve_usage_visibility(Self::viewer_is_admin(app)); + if visibility.granularity == UsageVisibilityGranularity::OwnOnly { + return false; + } + let entries = filter_legacy_buckets( + self.current_summary(workspace) + .map(|s| s.entries.as_slice()) + .unwrap_or_default(), + ); + let viewer_uid = AuthStateProvider::as_ref(app) + .get() + .user_id() + .map(|uid| uid.as_string()); + workspace.members.len() > 1 || has_non_viewer_data(&entries, viewer_uid.as_deref()) + } } impl TypedActionView for BillingCycleUsageSectionView { @@ -228,74 +258,68 @@ impl View for BillingCycleUsageSectionView { fn render(&self, app: &AppContext) -> Box { let appearance = Appearance::as_ref(app); - let Some(workspace) = UserWorkspaces::as_ref(app).current_workspace().cloned() else { - return Empty::new().finish(); - }; + let workspace = UserWorkspaces::as_ref(app).current_workspace().cloned(); + match workspace.as_ref() { + Some(w) if self.shows_team_section(w, app) => { + self.render_team_usage(w, appearance, app) + } + Some(w) => self.render_own_usage_with_workspace(w, appearance, app), + None => self.render_own_usage_solo(appearance, app), + } + } +} + +impl BillingCycleUsageSectionView { + fn render_team_usage( + &self, + workspace: &Workspace, + appearance: &Appearance, + app: &AppContext, + ) -> Box { let is_admin = Self::viewer_is_admin(app); let visibility = workspace.resolve_usage_visibility(is_admin); let mut column = Flex::column().with_cross_axis_alignment(CrossAxisAlignment::Stretch); + column.add_child(self.render_header(Some(workspace), &visibility, appearance, app)); - column.add_child(self.render_header(&workspace, &visibility, appearance, app)); - - // Drop Voice / SuggestedCodeDiffs entries once before the team- - // totals block and the per-row renderer consume them. See - // `filter_legacy_buckets` for why these buckets don't belong in - // the base/add-on credit accounting this section visualizes. let entries = filter_legacy_buckets( - self.current_summary(&workspace) + self.current_summary(workspace) .map(|summary| summary.entries.as_slice()) .unwrap_or_default(), ); - // Only show the "Team" block + "Members" subheader when the viewer - // actually gets data about other people. `members.len() > 1` covers - // the common multi-member case; `has_non_viewer_data` catches the - // edge case where the roster shrank to one after a teammate left - // mid-cycle but their usage is still attributed against this cycle. - // Together they keep solo teams from showing orphan scaffolding - // without dropping legitimate team data on departure. - let viewer_uid = AuthStateProvider::as_ref(app) - .get() - .user_id() - .map(|uid| uid.as_string()); - let shows_team_section = visibility.granularity != UsageVisibilityGranularity::OwnOnly - && (workspace.members.len() > 1 - || has_non_viewer_data(&entries, viewer_uid.as_deref())); - let show_source_filter_toggle = shows_team_section - && visibility.granularity == UsageVisibilityGranularity::FullBreakdown - && has_cloud_usage(&entries); - let source_filter = if show_source_filter_toggle { + let is_source_filter_shown = + visibility.granularity == UsageVisibilityGranularity::FullBreakdown + && has_cloud_usage(&entries); + let source_filter = if is_source_filter_shown { self.source_filter } else { SourceFilter::All }; - if shows_team_section { - column.add_child( - Container::new(render_team_totals_block( - &entries, - &visibility, - &self.row_mouse_states, - appearance, - )) - .with_margin_top(16.) - .finish(), - ); - } + column.add_child( + Container::new(render_team_totals_block( + &entries, + &visibility, + &self.row_mouse_states, + appearance, + )) + .with_margin_top(16.) + .finish(), + ); if is_admin { - if let Some(banner) = self.render_visibility_cta_banner(&workspace, appearance) { + if let Some(banner) = self.render_visibility_cta_banner(workspace, appearance) { column.add_child(Container::new(banner).with_margin_top(16.).finish()); } } column.add_child( Container::new(render_rows( - &workspace, + workspace, &entries, &visibility, - shows_team_section, + /* shows_team_section */ true, source_filter, &self.row_mouse_states, appearance, @@ -310,12 +334,62 @@ impl View for BillingCycleUsageSectionView { column.finish() } + + fn render_own_usage_with_workspace( + &self, + workspace: &Workspace, + appearance: &Appearance, + app: &AppContext, + ) -> Box { + let visibility = workspace.resolve_usage_visibility(Self::viewer_is_admin(app)); + let entries = filter_legacy_buckets( + self.current_summary(workspace) + .map(|s| s.entries.as_slice()) + .unwrap_or_default(), + ); + + let mut column = Flex::column().with_cross_axis_alignment(CrossAxisAlignment::Stretch); + column.add_child(self.render_header(Some(workspace), &visibility, appearance, app)); + column.add_child( + Container::new(render_own_usage_with_workspace_row( + workspace, + &entries, + &self.row_mouse_states, + appearance, + app, + )) + .with_margin_top(16.) + .finish(), + ); + column.finish() + } + + // Here when you're not on a team, there's no workspace to pull billing_cycle_usage data from. + // So we "fake" a row and source data from the AIRequestUsageModel instead + fn render_own_usage_solo( + &self, + appearance: &Appearance, + app: &AppContext, + ) -> Box { + let mut column = Flex::column().with_cross_axis_alignment(CrossAxisAlignment::Stretch); + column.add_child(self.render_header(None, &UsageVisibility::default(), appearance, app)); + column.add_child( + Container::new(render_own_usage_solo_row( + &self.row_mouse_states, + appearance, + app, + )) + .with_margin_top(16.) + .finish(), + ); + column.finish() + } } impl BillingCycleUsageSectionView { fn render_header( &self, - workspace: &Workspace, + workspace: Option<&Workspace>, visibility: &UsageVisibility, appearance: &Appearance, app: &AppContext, @@ -340,18 +414,21 @@ impl BillingCycleUsageSectionView { // Collapse to a static label when there's effectively one period to // pick from: either the tier policy doesn't expose history at all, or // the server returned a single canonical cycle. - let summary_count = workspace - .billing_cycle_usage - .as_ref() - .map(|d| d.summaries.len()) - .unwrap_or(0); - let use_selector = visibility.max_prior_cycles != MaxPriorCycles::None && summary_count > 1; - let period_element = if use_selector { - self.render_period_selector(workspace, appearance) - } else { - self.render_period_range_static(workspace, appearance) - }; - right_side.add_child(period_element); + if let Some(workspace) = workspace { + let summary_count = workspace + .billing_cycle_usage + .as_ref() + .map(|d| d.summaries.len()) + .unwrap_or(0); + let use_selector = + visibility.max_prior_cycles != MaxPriorCycles::None && summary_count > 1; + let period_element = if use_selector { + self.render_period_selector(workspace, appearance) + } else { + self.render_period_range_static(workspace, appearance) + }; + right_side.add_child(period_element); + } row.add_child(right_side.finish()); @@ -359,7 +436,7 @@ impl BillingCycleUsageSectionView { column.add_child(row.finish()); let resets_text = self.render_resets_label(appearance, app); - let legend = self.render_legend(workspace, appearance); + let legend = workspace.and_then(|workspace| self.render_legend(workspace, appearance)); if resets_text.is_some() || legend.is_some() { let mut secondary_row = Flex::row() .with_cross_axis_alignment(CrossAxisAlignment::Center) From ecc890701413c223b5b3cbb1f9652c2c38876a33 Mon Sep 17 00:00:00 2001 From: Isaiah Date: Thu, 21 May 2026 18:24:40 -0400 Subject: [PATCH 3/5] fix no pink bar for free team solo user --- .../billing_cycle_usage_rows.rs | 38 ++++++++----------- 1 file changed, 15 insertions(+), 23 deletions(-) diff --git a/app/src/settings_view/billing_and_usage/billing_cycle_usage_rows.rs b/app/src/settings_view/billing_and_usage/billing_cycle_usage_rows.rs index 44e7290d18..d11244198c 100644 --- a/app/src/settings_view/billing_and_usage/billing_cycle_usage_rows.rs +++ b/app/src/settings_view/billing_and_usage/billing_cycle_usage_rows.rs @@ -125,10 +125,6 @@ struct GroupedSubjectUsage { } impl MemberUsageRow { - /// Viewer row aggregated from billing-cycle entries — full segment - /// and cost fidelity. The viewer's identity and per-user - /// `base_limit` are passed in by callers that already have - /// workspace/app context. fn for_viewer( entries: &[BillingCycleUsageEntry], viewer_uid: Option<&str>, @@ -157,13 +153,13 @@ impl MemberUsageRow { total_cost_cents, base_limit: viewer_base_limit, segments, - bar_max_credits: 0, + bar_max_credits: total_credits.max(1), } } /// Viewer row built from a raw used-credits count, with no segment /// breakdown. For callers that only have `AIRequestUsageModel`-style - /// data (no `billing_cycle_usage` entries). + /// data (no `billing_cycle_usage` entries / no workspace data). fn for_viewer_from_total( viewer_uid: Option, viewer_display_name: String, @@ -210,7 +206,7 @@ impl MemberUsageRow { total_cost_cents, base_limit: None, segments, - bar_max_credits: 0, + bar_max_credits: total_credits.max(1), } } @@ -358,22 +354,18 @@ fn build_rows( } }; - match visibility.granularity { - UsageVisibilityGranularity::OwnOnly | UsageVisibilityGranularity::TeamAggregate => { - for row in &mut rows { - row.bar_max_credits = row.total_credits.max(1); - } - } - UsageVisibilityGranularity::PerUserTotals | UsageVisibilityGranularity::FullBreakdown => { - let top = rows - .iter() - .map(|r| r.total_credits) - .max() - .unwrap_or(0) - .max(1); - for row in &mut rows { - row.bar_max_credits = top; - } + if matches!( + visibility.granularity, + UsageVisibilityGranularity::PerUserTotals | UsageVisibilityGranularity::FullBreakdown + ) { + let top = rows + .iter() + .map(|r| r.total_credits) + .max() + .unwrap_or(0) + .max(1); + for row in &mut rows { + row.bar_max_credits = top; } } From 76dceb3d8ecc5497481663acaef0c2e6f7a39826 Mon Sep 17 00:00:00 2001 From: Isaiah Date: Fri, 22 May 2026 15:28:30 -0400 Subject: [PATCH 4/5] address REV-1606 --- .../billing_and_usage/billing_cycle_usage_rows.rs | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/app/src/settings_view/billing_and_usage/billing_cycle_usage_rows.rs b/app/src/settings_view/billing_and_usage/billing_cycle_usage_rows.rs index d11244198c..0a9b1a4dd8 100644 --- a/app/src/settings_view/billing_and_usage/billing_cycle_usage_rows.rs +++ b/app/src/settings_view/billing_and_usage/billing_cycle_usage_rows.rs @@ -345,7 +345,13 @@ fn build_rows( SourceFilter::All, )]; if shows_team_section { - rows.push(MemberUsageRow::for_other_members(entries)); + let other_row = MemberUsageRow::for_other_members(entries); + // only show the other users' usage if there's something useful to show in it... + // i.e. for an admin of a 1-user-only team, it doesn't make sense for use to show + // "other users: 0" all the time + if other_row.total_credits > 0 || workspace.members.len() > 1 { + rows.push(other_row); + } } rows } From 0a00f63ace661f9b6cd29e1756a8ca072362f7a4 Mon Sep 17 00:00:00 2001 From: Isaiah Date: Fri, 22 May 2026 17:10:22 -0400 Subject: [PATCH 5/5] Remove dead shows_team_section parameter; gate Team has_non_viewer_data on credits_used Co-Authored-By: Oz --- .../billing_cycle_usage_common.rs | 2 +- .../billing_cycle_usage_rows.rs | 27 ++----------------- .../billing_cycle_usage_section.rs | 13 +++------ 3 files changed, 7 insertions(+), 35 deletions(-) diff --git a/app/src/settings_view/billing_and_usage/billing_cycle_usage_common.rs b/app/src/settings_view/billing_and_usage/billing_cycle_usage_common.rs index b75d9912a4..9656fb2140 100644 --- a/app/src/settings_view/billing_and_usage/billing_cycle_usage_common.rs +++ b/app/src/settings_view/billing_and_usage/billing_cycle_usage_common.rs @@ -204,7 +204,7 @@ pub fn filter_legacy_buckets(entries: &[BillingCycleUsageEntry]) -> Vec) -> bool { entries.iter().any(|e| match &e.subject_type { - AiCreditsUsageAndCostSubjectType::Team => true, + AiCreditsUsageAndCostSubjectType::Team => e.credits_used > 0, _ => match (e.subject_uid.as_deref(), viewer_uid) { (Some(uid), Some(viewer)) => uid != viewer, // Unknown subject — conservatively treat as non-viewer. diff --git a/app/src/settings_view/billing_and_usage/billing_cycle_usage_rows.rs b/app/src/settings_view/billing_and_usage/billing_cycle_usage_rows.rs index 0a9b1a4dd8..7999342185 100644 --- a/app/src/settings_view/billing_and_usage/billing_cycle_usage_rows.rs +++ b/app/src/settings_view/billing_and_usage/billing_cycle_usage_rows.rs @@ -317,7 +317,6 @@ fn build_rows( workspace: &Workspace, entries: &[BillingCycleUsageEntry], visibility: &UsageVisibility, - shows_team_section: bool, source_filter: SourceFilter, app: &AppContext, ) -> Vec { @@ -344,15 +343,7 @@ fn build_rows( base_limit, SourceFilter::All, )]; - if shows_team_section { - let other_row = MemberUsageRow::for_other_members(entries); - // only show the other users' usage if there's something useful to show in it... - // i.e. for an admin of a 1-user-only team, it doesn't make sense for use to show - // "other users: 0" all the time - if other_row.total_credits > 0 || workspace.members.len() > 1 { - rows.push(other_row); - } - } + rows.push(MemberUsageRow::for_other_members(entries)); rows } UsageVisibilityGranularity::PerUserTotals | UsageVisibilityGranularity::FullBreakdown => { @@ -814,28 +805,19 @@ pub fn render_rows( workspace: &Workspace, entries: &[BillingCycleUsageEntry], visibility: &UsageVisibility, - shows_team_section: bool, source_filter: SourceFilter, mouse_states: &BillingUsageMouseStates, appearance: &Appearance, app: &AppContext, on_filter_change: FilterChangeFn, ) -> Box { - let rows = build_rows( - workspace, - entries, - visibility, - shows_team_section, - source_filter, - app, - ); + let rows = build_rows(workspace, entries, visibility, source_filter, app); let mut column = Flex::column() .with_cross_axis_alignment(CrossAxisAlignment::Stretch) .with_spacing(8.); if let Some(header) = render_member_header( visibility, - shows_team_section, entries, source_filter, mouse_states, @@ -850,17 +832,12 @@ pub fn render_rows( fn render_member_header( visibility: &UsageVisibility, - shows_team_section: bool, entries: &[BillingCycleUsageEntry], source_filter: SourceFilter, mouse_states: &BillingUsageMouseStates, appearance: &Appearance, on_filter_change: FilterChangeFn, ) -> Option> { - if !shows_team_section { - return None; - } - let show_toggle = visibility.granularity == UsageVisibilityGranularity::FullBreakdown && has_cloud_usage(entries); diff --git a/app/src/settings_view/billing_and_usage/billing_cycle_usage_section.rs b/app/src/settings_view/billing_and_usage/billing_cycle_usage_section.rs index 179411ff67..5299858467 100644 --- a/app/src/settings_view/billing_and_usage/billing_cycle_usage_section.rs +++ b/app/src/settings_view/billing_and_usage/billing_cycle_usage_section.rs @@ -288,9 +288,9 @@ impl BillingCycleUsageSectionView { .unwrap_or_default(), ); - let is_source_filter_shown = - visibility.granularity == UsageVisibilityGranularity::FullBreakdown - && has_cloud_usage(&entries); + let is_source_filter_shown = visibility.granularity + == UsageVisibilityGranularity::FullBreakdown + && has_cloud_usage(&entries); let source_filter = if is_source_filter_shown { self.source_filter } else { @@ -319,7 +319,6 @@ impl BillingCycleUsageSectionView { workspace, &entries, &visibility, - /* shows_team_section */ true, source_filter, &self.row_mouse_states, appearance, @@ -366,11 +365,7 @@ impl BillingCycleUsageSectionView { // Here when you're not on a team, there's no workspace to pull billing_cycle_usage data from. // So we "fake" a row and source data from the AIRequestUsageModel instead - fn render_own_usage_solo( - &self, - appearance: &Appearance, - app: &AppContext, - ) -> Box { + fn render_own_usage_solo(&self, appearance: &Appearance, app: &AppContext) -> Box { let mut column = Flex::column().with_cross_axis_alignment(CrossAxisAlignment::Stretch); column.add_child(self.render_header(None, &UsageVisibility::default(), appearance, app)); column.add_child(