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 11b5703f13..34bcbf8917 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 @@ -200,7 +200,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 af7fb8ed09..d227a8631c 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 @@ -15,6 +15,7 @@ use warpui::platform::Cursor; use warpui::ui_components::components::UiComponent; use warpui::{AppContext, Element, EventContext, SingletonEntity}; +use crate::ai::AIRequestUsageModel; use crate::auth::AuthStateProvider; use crate::settings_view::billing_and_usage::billing_cycle_usage_common::{ aggregate_segments, cost_type_color, format_cost_cents, format_credits, @@ -24,8 +25,9 @@ use crate::settings_view::billing_and_usage::billing_cycle_usage_common::{ use crate::ui_components::blended_colors; use crate::ui_components::icons::Icon; use crate::workspaces::workspace::{ - AiCreditsUsageAndCostSubjectType, AiCreditsUsageSource, BillingCycleUsageEntry, - UsageVisibility, UsageVisibilityGranularity, Workspace, WorkspaceMember, + AiCreditsUsageAndCostSubjectType, AiCreditsUsageAndCostType, AiCreditsUsageBucket, + AiCreditsUsageSource, BillingCycleUsageEntry, UsageVisibility, UsageVisibilityGranularity, + Workspace, WorkspaceMember, }; const BAR_HEIGHT: f32 = 8.; @@ -92,57 +94,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 { @@ -151,102 +121,247 @@ 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; +impl MemberUsageRow { + 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: 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: total_credits.max(1), } - - 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), + /// 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 / no workspace data). + 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() }; - - rows.push(MemberUsageRow { + 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), + 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: 0, - }); + bar_max_credits: used.max(1), + } } - // 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(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, + bar_max_credits: total_credits.max(1), + } + } + + /// 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 } +} + +fn build_rows( + workspace: &Workspace, + entries: &[BillingCycleUsageEntry], + visibility: &UsageVisibility, + 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, + )]; + rows.push(MemberUsageRow::for_other_members(entries)); + rows + } + UsageVisibilityGranularity::PerUserTotals | UsageVisibilityGranularity::FullBreakdown => { + MemberUsageRow::for_each_member(entries, &workspace.members, source_filter) + } + }; - // 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)) - }); + 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; + } + } rows } @@ -367,8 +482,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 @@ -559,7 +674,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, @@ -646,36 +761,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)] @@ -683,28 +802,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, @@ -717,74 +827,14 @@ 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, 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); @@ -809,8 +859,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 71858c11d2..43a4ebf39e 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 @@ -23,7 +23,8 @@ use crate::settings_view::billing_and_usage::billing_cycle_usage_common::{ filter_legacy_buckets, has_non_viewer_data, BillingUsageMouseStates, }; use crate::settings_view::billing_and_usage::billing_cycle_usage_rows::{ - has_cloud_usage, render_rows, SourceFilter, + has_cloud_usage, render_own_usage_solo_row, render_own_usage_with_workspace_row, render_rows, + SourceFilter, }; use crate::settings_view::billing_and_usage::billing_cycle_usage_team_totals::render_team_totals_block; use crate::settings_view::billing_and_usage_page_v2::{ @@ -148,6 +149,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 { @@ -218,74 +246,67 @@ 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 + let is_source_filter_shown = visibility.granularity + == UsageVisibilityGranularity::FullBreakdown && has_cloud_usage(&entries); - let source_filter = if show_source_filter_toggle { + 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, source_filter, &self.row_mouse_states, appearance, @@ -300,12 +321,58 @@ 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, @@ -330,18 +397,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()); @@ -349,7 +419,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) diff --git a/app/src/settings_view/billing_and_usage_dispatch.rs b/app/src/settings_view/billing_and_usage_dispatch.rs index a7528a463b..d06f1f2aa8 100644 --- a/app/src/settings_view/billing_and_usage_dispatch.rs +++ b/app/src/settings_view/billing_and_usage_dispatch.rs @@ -14,6 +14,7 @@ use super::settings_page::{ use super::SettingsSection; use crate::auth::{AuthManager, AuthStateProvider}; use crate::workspaces::user_workspaces::UserWorkspaces; +use crate::workspaces::workspace::Workspace; pub struct BillingAndUsageDispatchView { page: PageType, @@ -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; }