Skip to content
Draft
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
1 change: 1 addition & 0 deletions app/src/ai/agent_sdk/admin_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ fn team(uid: i64, name: &str, member_uids: &[&str]) -> Team {
uid: UserUid::new(uid),
email: format!("{uid}@example.com"),
role: MembershipRole::User,
is_disabled: false,
})
.collect(),
),
Expand Down
2 changes: 2 additions & 0 deletions app/src/persistence/sqlite.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2754,6 +2754,8 @@ fn read_sqlite_data(
email: row.email,
role: serde_json::from_str(&row.role)
.unwrap_or(crate::workspaces::team::MembershipRole::User),
// Not persisted; refreshed live from the server.
is_disabled: false,
};
acc.entry(row.team_id).or_default().push(member);
acc
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -392,6 +392,7 @@ fn workspace_member(uid: &str) -> WorkspaceMember {
uid: UserUid::new(uid),
email: format!("{uid}@warp.dev"),
role: MembershipRole::User,
is_disabled: false,
usage_info: WorkspaceMemberUsageInfo {
is_unlimited: false,
request_limit: 0,
Expand All @@ -408,6 +409,7 @@ fn team_with_members(uids: &[&str]) -> Team {
uid: UserUid::new(uid),
email: format!("{uid}@warp.dev"),
role: MembershipRole::User,
is_disabled: false,
})
.collect();
Team::from_local_cache(
Expand Down
61 changes: 49 additions & 12 deletions app/src/settings_view/billing_and_usage/billing_cycle_usage_rows.rs
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,16 @@ const ROW_PADDING: f32 = 12.;
const SELF_OWN_KEY: &str = "__self_own__";
const OTHER_MEMBERS_KEY: &str = "__other_members__";

const DISABLED_MEMBER_TOOLTIP_TEXT: &str = "This user's account is disabled";

fn dimmed_row_text_color(main: ColorU, dimmed: ColorU, is_dimmed: bool) -> ColorU {
if is_dimmed { dimmed } else { main }
}

fn disabled_member_tooltip_text(is_disabled: bool) -> Option<&'static str> {
is_disabled.then_some(DISABLED_MEMBER_TOOLTIP_TEXT)
}

#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum SourceFilter {
#[default]
Expand Down Expand Up @@ -82,6 +92,7 @@ pub struct MemberUsageRow {
/// Denominator the row's stacked bar fills against.
pub bar_max_credits: i64,
pub is_current_team_member: bool,
pub is_disabled: bool,
}

fn viewer_identity(app: &AppContext) -> (Option<String>, String) {
Expand Down Expand Up @@ -130,6 +141,7 @@ impl MemberUsageRow {
segments,
bar_max_credits: total_credits.max(1),
is_current_team_member: true,
is_disabled: false,
}
}

Expand Down Expand Up @@ -161,6 +173,7 @@ impl MemberUsageRow {
segments,
bar_max_credits: used.max(1),
is_current_team_member: true,
is_disabled: false,
}
}

Expand All @@ -182,6 +195,7 @@ impl MemberUsageRow {
segments,
bar_max_credits: total_credits.max(1),
is_current_team_member: true,
is_disabled: false,
}
}

Expand Down Expand Up @@ -257,6 +271,7 @@ impl MemberUsageRow {
segments,
bar_max_credits: 0,
is_current_team_member: true,
is_disabled: member.is_disabled,
});
}

Expand Down Expand Up @@ -285,6 +300,7 @@ impl MemberUsageRow {
segments,
bar_max_credits: 0,
is_current_team_member,
is_disabled: false,
});
}

Expand Down Expand Up @@ -478,6 +494,7 @@ fn render_row_card(
let card_bg = theme.background().into_solid();
let main = blended_colors::text_main(theme, card_bg);
let is_former_member = !row.is_current_team_member;
let is_dimmed = is_former_member || row.is_disabled;

let bar = render_stacked_bar(
&row.segments,
Expand Down Expand Up @@ -518,11 +535,11 @@ fn render_row_card(
appearance.ui_font_family(),
appearance.ui_font_size(),
)
.with_color(if is_former_member {
theme.sub_text_color(theme.background()).into()
} else {
main
})
.with_color(dimmed_row_text_color(
main,
theme.sub_text_color(theme.background()).into(),
is_dimmed,
))
.finish()
};

Expand Down Expand Up @@ -620,12 +637,28 @@ fn render_row_card(
.with_child(Container::new(cost_cluster).with_margin_left(6.).finish())
.finish();

let name_row_element: Box<dyn Element> = match disabled_member_tooltip_text(row.is_disabled) {
Some(tooltip_text) => {
let disabled_state =
mouse_states.tooltip_mouse_state(&format!("{}__disabled", row.subject_key));
appearance.ui_builder().overlay_tool_tip_on_element(
tooltip_text.to_string(),
disabled_state,
name_row.finish(),
ParentAnchor::TopLeft,
ChildAnchor::BottomLeft,
vec2f(0., -TOOLTIP_GAP),
)
}
None => name_row.finish(),
};

let body = Container::new(
Flex::row()
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.with_main_axis_alignment(MainAxisAlignment::SpaceBetween)
.with_main_axis_size(MainAxisSize::Max)
.with_child(Shrinkable::new(1., name_row.finish()).finish())
.with_child(Shrinkable::new(1., name_row_element).finish())
.with_child(
Container::new(credits_and_cost)
.with_margin_left(16.)
Expand All @@ -646,7 +679,7 @@ fn render_row_card(
.with_background_color(card_bg)
.with_border(Border::all(ROW_BORDER_WIDTH).with_border_color(theme.outline().into_solid()))
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(ROW_BORDER_RADIUS)));
if is_former_member {
if is_dimmed {
card = card.with_foreground_overlay(theme.background().with_opacity(40));
}
card.finish()
Expand All @@ -665,15 +698,16 @@ fn render_member_row(
return render_row_card(row, team_max_credits, mouse_states, appearance);
}

// The info icon sits inside the row card, so hovering it would otherwise
// trigger both this row's breakdown tooltip and the icon's own tooltip
// on top of each other. Pull the icon's hover state up so we can
// suppress the breakdown tooltip while the icon is hovered.
// Pull nested hover states up so the breakdown tooltip is suppressed
// while the info icon or disabled tooltip is hovered.
let info_state = matches!(
row.subject_type,
AiCreditsUsageAndCostSubjectType::ServiceAccount
)
.then(|| mouse_states.tooltip_mouse_state(&format!("{}__agent_info", row.subject_key)));
let disabled_state = row
.is_disabled
.then(|| mouse_states.tooltip_mouse_state(&format!("{}__disabled", row.subject_key)));

Hoverable::new(tooltip_mouse_state, move |state| {
let mut stack = Stack::new();
Expand All @@ -687,8 +721,11 @@ fn render_member_row(
let info_hovered = info_state
.as_ref()
.is_some_and(|s| s.lock().is_ok_and(|guard| guard.is_hovered()));
let disabled_hovered = disabled_state
.as_ref()
.is_some_and(|s| s.lock().is_ok_and(|guard| guard.is_hovered()));

if state.is_hovered() && !info_hovered {
if state.is_hovered() && !info_hovered && !disabled_hovered {
stack.add_positioned_overlay_child(
render_usage_tooltip_content(row, appearance),
OffsetPositioning::offset_from_parent(
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
use super::{MemberUsageRow, SourceFilter};
use pathfinder_color::ColorU;

use super::{
DISABLED_MEMBER_TOOLTIP_TEXT, MemberUsageRow, SourceFilter, dimmed_row_text_color,
disabled_member_tooltip_text,
};
use crate::auth::UserUid;
use crate::workspaces::team::MembershipRole;
use crate::workspaces::workspace::{
Expand Down Expand Up @@ -119,6 +124,7 @@ fn member(uid: &str) -> WorkspaceMember {
uid: UserUid::new(uid),
email: format!("{uid}@warp.dev"),
role: MembershipRole::User,
is_disabled: false,
usage_info: WorkspaceMemberUsageInfo {
is_unlimited: false,
request_limit: 0,
Expand All @@ -128,6 +134,13 @@ fn member(uid: &str) -> WorkspaceMember {
}
}

fn disabled_member(uid: &str) -> WorkspaceMember {
WorkspaceMember {
is_disabled: true,
..member(uid)
}
}

#[test]
fn per_member_rows_cover_exactly_the_supplied_roster() {
// Callers pass the selected team's roster, so a workspace member from
Expand Down Expand Up @@ -210,6 +223,61 @@ fn per_member_rows_do_not_mark_service_accounts_as_former_members() {
assert!(rows[0].is_current_team_member);
}

#[test]
fn per_member_rows_flag_disabled_members_from_the_roster() {
let rows = MemberUsageRow::for_each_member(
&[],
&[member(VIEWER_UID), disabled_member(OTHER_UID)],
SourceFilter::All,
);

assert!(
rows.iter()
.find(|row| row.subject_uid.as_deref() == Some(VIEWER_UID))
.is_some_and(|row| !row.is_disabled),
"an active member's row must not be flagged disabled"
);
assert!(
rows.iter()
.find(|row| row.subject_uid.as_deref() == Some(OTHER_UID))
.is_some_and(|row| row.is_disabled),
"a disabled member's row should carry the disabled flag for the dimmed/tooltip treatment"
);
}

#[test]
fn per_member_rows_never_flag_departed_members_as_disabled() {
let entries = vec![entry(
AiCreditsUsageAndCostSubjectType::User,
Some(OTHER_UID),
AiCreditsUsageSource::Local,
20,
10,
)];

let rows = MemberUsageRow::for_each_member(&entries, &[member(VIEWER_UID)], SourceFilter::All);

assert!(
rows.iter()
.find(|row| row.subject_uid.as_deref() == Some(OTHER_UID))
.is_some_and(|row| !row.is_disabled)
);
}

#[test]
fn disabled_row_renders_dimmed_and_tooltipped() {
let main = ColorU::new(255, 255, 255, 255);
let dimmed = ColorU::new(128, 128, 128, 255);

assert_eq!(dimmed_row_text_color(main, dimmed, true), dimmed);
assert_eq!(dimmed_row_text_color(main, dimmed, false), main);
assert_eq!(
disabled_member_tooltip_text(true),
Some(DISABLED_MEMBER_TOOLTIP_TEXT)
);
assert_eq!(disabled_member_tooltip_text(false), None);
}

#[test]
fn build_own_usage_row_cloud_filter_drops_local_entries() {
let entries = vec![
Expand Down
Loading