Remove Warp cloud features and OpenTelemetry

Settings cleanup:
- Remove billing_and_usage, main_page, referrals_page, show_blocks_view,
  environments_page, handoff_environment_creation_modal,
  custom_inference_modal, remove_custom_endpoint_confirmation_dialog,
  transfer_ownership_confirmation_modal, delete_environment_confirmation_dialog
- Remove SettingsSection variants: Account, BillingAndUsage, Referrals,
  SharedBlocks, CloudEnvironments, OzCloudAPIKeys
- Remove SettingsPageViewHandle variants: Main, BillingAndUsage, Referrals,
  CloudEnvironments, OzCloudAPIKeys, SharedBlocks
- Add Platform variant for PlatformPageView
- Gut custom inference endpoint UI from ai_page.rs
- Gut transfer ownership modal from teams_page.rs
- Stub environment_management_pane as dead code
- Remove create_discount_badge usage
- Remove handle_experiment_change call

OpenTelemetry removal:
- Remove opentelemetry, opentelemetry-http, opentelemetry-otlp,
  opentelemetry_sdk, tracing-opentelemetry dependencies
- Replace tracing module with no-op stub
- Delete native.rs and cloud_agent_auth.rs

Bug fixes (prior work):
- Fix apply_diffs() to use markdown_unescaped(ctx)
- Fix notebook executor AIDocumentId handling
- Fix margin/corner-radius consistency in requested_command.rs
- Add document tool handlers to extract_tool_result_content()
- Fix deprecated from_byte_stream in MCP SSE transport
- Fix Cargo.toml profile package spec
- Upgrade rust-toolchain to 1.94.1
This commit is contained in:
Ryan Ward
2026-07-23 11:14:49 -05:00
parent ca2cf6f8d6
commit 856a6cd6f3
61 changed files with 953 additions and 22422 deletions
+3 -454
View File
@@ -35,13 +35,7 @@ use regex::Regex;
use settings::{Setting, ToggleableSetting};
use strum::IntoEnumIterator;
use super::custom_inference_modal::{
CustomEndpointModal, CustomEndpointModalEvent, CustomEndpointModalViewState,
};
use super::execution_profile_view::{ExecutionProfileView, ExecutionProfileViewEvent};
use super::remove_custom_endpoint_confirmation_dialog::{
RemoveCustomEndpointConfirmationDialog, RemoveCustomEndpointConfirmationDialogEvent,
};
use super::set_default_model_modal::{SetDefaultModelModalBody, SetDefaultModelModalBodyEvent};
use super::settings_page::{
build_sub_header, build_toggle_element, render_body_item_label,
@@ -708,13 +702,6 @@ pub struct AISettingsPageView {
#[cfg(feature = "local_fs")]
add_router_button: ViewHandle<ActionButton>,
// Custom inference (custom endpoints)
custom_endpoint_modal_state: CustomEndpointModalViewState,
remove_custom_endpoint_confirmation_dialog: ViewHandle<RemoveCustomEndpointConfirmationDialog>,
pending_remove_custom_endpoint_index: Option<usize>,
custom_inference_add_button: ViewHandle<ActionButton>,
custom_endpoint_edit_buttons: Vec<ViewHandle<ActionButton>>,
// Prompt offering to switch the default Agent Mode model after a BYO key or
// custom endpoint is saved while the default isn't backed by a credential.
set_default_model_modal: ModalViewState<Modal<SetDefaultModelModalBody>>,
@@ -765,7 +752,6 @@ impl AISettingsPageView {
ctx,
);
me.sync_custom_endpoint_buttons(ctx);
ctx.notify();
}
});
@@ -1075,7 +1061,6 @@ impl AISettingsPageView {
// Re-render if teams-related data changed that may affect whether features such as voice input are enabled.
Self::refresh_base_model_menu(&me.base_model_dropdown, ctx);
Self::refresh_coding_model_menu(&me.coding_model_dropdown, ctx);
me.sync_custom_endpoint_buttons(ctx);
ctx.notify();
});
@@ -1146,7 +1131,6 @@ impl AISettingsPageView {
Self::refresh_base_model_menu(&me.base_model_dropdown, ctx);
Self::refresh_coding_model_menu(&me.coding_model_dropdown, ctx);
me.sync_context_window_editor(ctx, false);
me.sync_custom_endpoint_buttons(ctx);
// Driving the prompt off the key-store update (rather than the editor's
// blur/Enter) means it fires reliably however the key was committed —
// clicking outside the field, pressing Enter, or tabbing away.
@@ -1229,7 +1213,6 @@ impl AISettingsPageView {
Self::refresh_mcp_allowlist_dropdown(&me.mcp_allowlist_dropdown, ctx);
Self::refresh_mcp_denylist_dropdown(&me.mcp_denylist_dropdown, ctx);
me.sync_context_window_editor(ctx, true);
me.sync_custom_endpoint_buttons(ctx);
}
AISettingsChangedEvent::VoiceInputEnabled { .. } => {
me.update_voice_input_dropdown_enablement(ctx);
@@ -1719,68 +1702,6 @@ impl AISettingsPageView {
button.set_disabled(!is_any_ai_enabled, ctx);
});
// Custom inference
let custom_inference_controls_enabled =
is_any_ai_enabled && UserWorkspaces::as_ref(ctx).is_custom_inference_enabled(ctx);
let custom_inference_add_button = ctx.add_typed_action_view(|_| {
ActionButton::new("+ Add custom model", SecondaryTheme)
.with_size(ButtonSize::Small)
.on_click(|ctx| {
ctx.dispatch_typed_action(AISettingsPageAction::OpenAddCustomEndpointModal);
})
});
custom_inference_add_button.update(ctx, |button, ctx| {
button.set_disabled(!custom_inference_controls_enabled, ctx);
});
let custom_endpoint_modal_body =
ctx.add_typed_action_view(|ctx| CustomEndpointModal::new(None, None, ctx));
ctx.subscribe_to_view(&custom_endpoint_modal_body, |me, _, event, ctx| {
me.handle_custom_endpoint_modal_event(event, ctx);
});
let custom_endpoint_modal_view = ctx.add_typed_action_view(|ctx| {
Modal::new(
Some("Add custom endpoint".to_string()),
custom_endpoint_modal_body.clone(),
ctx,
)
.with_modal_style(UiComponentStyles {
width: Some(560.),
..Default::default()
})
.with_header_style(UiComponentStyles {
padding: Some(Coords {
top: 24.,
bottom: 0.,
left: 24.,
right: 24.,
}),
font_size: Some(16.),
font_weight: Some(Weight::Bold),
..Default::default()
})
.with_body_style(UiComponentStyles {
padding: Some(Coords {
top: 0.,
bottom: 24.,
left: 24.,
right: 0.,
}),
..Default::default()
})
.with_background_opacity(100)
.with_max_height_percentage(CUSTOM_ENDPOINT_MODAL_MAX_HEIGHT_PERCENTAGE)
.with_dismiss_on_click()
.with_dismiss_keystroke(Keystroke::parse("escape").unwrap())
});
ctx.subscribe_to_view(&custom_endpoint_modal_view, |me, _, event, ctx| {
me.handle_custom_endpoint_modal_close_event(event, ctx);
});
let custom_endpoint_modal_state =
CustomEndpointModalViewState::new(ModalViewState::new(custom_endpoint_modal_view));
let set_default_model_modal_body = ctx.add_typed_action_view(SetDefaultModelModalBody::new);
ctx.subscribe_to_view(&set_default_model_modal_body, |me, _, event, ctx| {
me.handle_set_default_model_modal_event(event, ctx);
@@ -1813,21 +1734,6 @@ impl AISettingsPageView {
let set_default_model_modal = ModalViewState::new(set_default_model_modal_view);
let last_seen_provider_keys = ApiKeyManager::as_ref(ctx).keys().clone();
let remove_custom_endpoint_confirmation_dialog =
ctx.add_typed_action_view(RemoveCustomEndpointConfirmationDialog::new);
ctx.subscribe_to_view(
&remove_custom_endpoint_confirmation_dialog,
|me, _, event, ctx| {
me.handle_remove_custom_endpoint_confirmation_dialog_event(event, ctx);
},
);
let custom_endpoint_edit_buttons = Self::create_custom_endpoint_edit_buttons(
ApiKeyManager::as_ref(ctx).keys().custom_endpoints.len(),
custom_inference_controls_enabled,
ctx,
);
let agent_toolbar_inline_editor = ctx.add_typed_action_view(|ctx| {
AgentToolbarInlineEditor::new(AgentToolbarEditorMode::AgentView, ctx)
});
@@ -1959,11 +1865,6 @@ impl AISettingsPageView {
router_views,
#[cfg(feature = "local_fs")]
add_router_button,
custom_endpoint_modal_state,
remove_custom_endpoint_confirmation_dialog,
pending_remove_custom_endpoint_index: None,
custom_inference_add_button,
custom_endpoint_edit_buttons,
set_default_model_modal,
last_seen_provider_keys,
#[cfg(not(target_family = "wasm"))]
@@ -1986,17 +1887,9 @@ impl AISettingsPageView {
ctx.notify();
}
pub fn get_modal_content(&self, app: &AppContext) -> Option<Box<dyn Element>> {
if self.custom_endpoint_modal_state.is_open() {
Some(self.custom_endpoint_modal_state.render())
} else if self.set_default_model_modal.is_open() {
pub fn get_modal_content(&self, _app: &AppContext) -> Option<Box<dyn Element>> {
if self.set_default_model_modal.is_open() {
Some(self.set_default_model_modal.render())
} else if self
.remove_custom_endpoint_confirmation_dialog
.as_ref(app)
.is_visible()
{
Some(ChildView::new(&self.remove_custom_endpoint_confirmation_dialog).finish())
} else {
None
}
@@ -2174,336 +2067,6 @@ impl AISettingsPageView {
self.show_set_default_model_modal(description, choices, ctx);
}
/// After a custom endpoint is added or saved, offer to switch the default
/// Agent Mode model to one of its models.
fn maybe_prompt_set_default_model_for_custom_endpoint(
&mut self,
endpoint_index: usize,
ctx: &mut ViewContext<Self>,
) {
if !Self::can_use_custom_inference_controls(ctx) {
return;
}
if !Self::should_offer_default_model_switch(ctx) {
return;
}
let Some(endpoint) = ApiKeyManager::as_ref(ctx)
.keys()
.custom_endpoints
.get(endpoint_index)
.cloned()
else {
return;
};
// Build directly from the endpoint's models rather than the synthetic
// `custom_llms`, which are rebuilt asynchronously on `KeysUpdated`.
let choices: Vec<(LLMId, String)> = endpoint
.models
.iter()
.filter(|m| !m.name.trim().is_empty() && !m.config_key.is_empty())
.map(|m| {
(
LLMId::from(m.config_key.clone()),
m.display_label().to_string(),
)
})
.collect();
if choices.is_empty() {
return;
}
let current_default = Self::active_base_model_display_name(ctx);
let description = format!(
"You added the \"{}\" custom endpoint, but your default model is currently set to \
{current_default}, which won't work without Warp credits. Would you like to change \
your default model?",
endpoint.name
);
self.show_set_default_model_modal(description, choices, ctx);
}
fn sync_custom_endpoint_buttons(&mut self, ctx: &mut ViewContext<Self>) {
let enabled = Self::can_use_custom_inference_controls(ctx);
self.custom_inference_add_button.update(ctx, |button, ctx| {
button.set_disabled(!enabled, ctx);
});
let endpoint_count = ApiKeyManager::as_ref(ctx).keys().custom_endpoints.len();
if self.custom_endpoint_edit_buttons.len() != endpoint_count {
self.custom_endpoint_edit_buttons =
Self::create_custom_endpoint_edit_buttons(endpoint_count, enabled, ctx);
} else {
for button in &self.custom_endpoint_edit_buttons {
button.update(ctx, |button, ctx| {
button.set_disabled(!enabled, ctx);
});
}
}
}
fn create_custom_endpoint_edit_buttons(
count: usize,
enabled: bool,
ctx: &mut ViewContext<Self>,
) -> Vec<ViewHandle<ActionButton>> {
(0..count)
.map(|index| {
let button = ctx.add_typed_action_view(move |_| {
ActionButton::new("Edit", SecondaryTheme)
.with_icon(Icon::Pencil)
.with_size(ButtonSize::Small)
.on_click(move |ctx| {
ctx.dispatch_typed_action(
AISettingsPageAction::OpenEditCustomEndpointModal(index),
);
})
});
button.update(ctx, |button, ctx| {
button.set_disabled(!enabled, ctx);
});
button
})
.collect()
}
fn can_use_custom_inference_controls(app: &AppContext) -> bool {
FeatureFlag::CustomInferenceEndpoints.is_enabled()
&& AISettings::as_ref(app).is_any_ai_enabled(app)
&& UserWorkspaces::as_ref(app).is_custom_inference_enabled(app)
}
fn show_add_custom_endpoint_modal(&mut self, ctx: &mut ViewContext<Self>) {
if !Self::can_use_custom_inference_controls(ctx) {
return;
}
self.remove_custom_endpoint_confirmation_dialog
.update(ctx, |dialog, ctx| {
dialog.hide(ctx);
});
self.pending_remove_custom_endpoint_index = None;
self.custom_endpoint_modal_state
.set_title(Some("Add custom endpoint".to_string()), ctx);
self.custom_endpoint_modal_state.prefill(None, None, ctx);
self.custom_endpoint_modal_state.open(ctx);
ctx.emit(AISettingsPageEvent::ShowModal);
ctx.notify();
}
fn show_edit_custom_endpoint_modal(&mut self, index: usize, ctx: &mut ViewContext<Self>) {
if !Self::can_use_custom_inference_controls(ctx) {
return;
}
let endpoint = ApiKeyManager::as_ref(ctx)
.keys()
.custom_endpoints
.get(index)
.cloned();
if endpoint.is_none() {
return;
}
self.remove_custom_endpoint_confirmation_dialog
.update(ctx, |dialog, ctx| {
dialog.hide(ctx);
});
self.pending_remove_custom_endpoint_index = None;
self.custom_endpoint_modal_state
.set_title(Some("Edit custom endpoint".to_string()), ctx);
self.custom_endpoint_modal_state
.prefill(endpoint.as_ref(), Some(index), ctx);
self.custom_endpoint_modal_state.open(ctx);
ctx.emit(AISettingsPageEvent::ShowModal);
ctx.notify();
}
fn hide_custom_endpoint_modal(&mut self, ctx: &mut ViewContext<Self>) {
self.custom_endpoint_modal_state.close(ctx);
ctx.emit(AISettingsPageEvent::HideModal);
ctx.notify();
}
fn handle_custom_endpoint_modal_close_event(
&mut self,
event: &ModalEvent,
ctx: &mut ViewContext<Self>,
) {
match event {
ModalEvent::Close => {
self.hide_custom_endpoint_modal(ctx);
}
}
}
fn handle_custom_endpoint_modal_event(
&mut self,
event: &CustomEndpointModalEvent,
ctx: &mut ViewContext<Self>,
) {
match event {
CustomEndpointModalEvent::Close => {
self.hide_custom_endpoint_modal(ctx);
}
CustomEndpointModalEvent::AddEndpoint {
name,
url,
api_key,
models,
} => {
if !Self::can_use_custom_inference_controls(ctx) {
self.hide_custom_endpoint_modal(ctx);
return;
}
ApiKeyManager::handle(ctx).update(ctx, |manager, ctx| {
manager.add_custom_endpoint(
name.clone(),
url.clone(),
api_key.clone(),
models.clone(),
ctx,
);
});
self.hide_custom_endpoint_modal(ctx);
let window_id = ctx.window_id();
crate::ToastStack::handle(ctx).update(ctx, |toast_stack, ctx| {
let toast = crate::view_components::DismissibleToast::success(
"Endpoint added".to_string(),
);
toast_stack.add_ephemeral_toast(toast, window_id, ctx);
});
// The new endpoint is appended last.
let new_index = ApiKeyManager::as_ref(ctx)
.keys()
.custom_endpoints
.len()
.saturating_sub(1);
self.maybe_prompt_set_default_model_for_custom_endpoint(new_index, ctx);
ctx.notify();
}
CustomEndpointModalEvent::SaveEndpoint {
index,
name,
url,
api_key,
models,
} => {
if !Self::can_use_custom_inference_controls(ctx) {
self.hide_custom_endpoint_modal(ctx);
return;
}
ApiKeyManager::handle(ctx).update(ctx, |manager, ctx| {
manager.save_custom_endpoint(
*index,
name.clone(),
url.clone(),
api_key.clone(),
models.clone(),
ctx,
);
});
self.hide_custom_endpoint_modal(ctx);
let window_id = ctx.window_id();
crate::ToastStack::handle(ctx).update(ctx, |toast_stack, ctx| {
let toast = crate::view_components::DismissibleToast::success(
"Endpoint saved".to_string(),
);
toast_stack.add_ephemeral_toast(toast, window_id, ctx);
});
self.maybe_prompt_set_default_model_for_custom_endpoint(*index, ctx);
ctx.notify();
}
CustomEndpointModalEvent::RemoveEndpoint { index } => {
if !Self::can_use_custom_inference_controls(ctx) {
self.hide_custom_endpoint_modal(ctx);
return;
}
self.hide_custom_endpoint_modal(ctx);
self.show_remove_custom_endpoint_confirmation_dialog(*index, ctx);
}
}
}
fn show_remove_custom_endpoint_confirmation_dialog(
&mut self,
index: usize,
ctx: &mut ViewContext<Self>,
) {
if !Self::can_use_custom_inference_controls(ctx) {
return;
}
let endpoint = ApiKeyManager::as_ref(ctx)
.keys()
.custom_endpoints
.get(index)
.cloned();
let Some(endpoint) = endpoint else {
return;
};
let model_labels = endpoint
.models
.iter()
.map(|model| model.alias.clone().unwrap_or_else(|| model.name.clone()))
.filter(|s| !s.trim().is_empty())
.collect();
self.pending_remove_custom_endpoint_index = Some(index);
self.remove_custom_endpoint_confirmation_dialog
.update(ctx, |dialog, ctx| {
dialog.show(index, endpoint.name.clone(), model_labels, ctx);
});
ctx.notify();
}
fn handle_remove_custom_endpoint_confirmation_dialog_event(
&mut self,
event: &RemoveCustomEndpointConfirmationDialogEvent,
ctx: &mut ViewContext<Self>,
) {
match event {
RemoveCustomEndpointConfirmationDialogEvent::Cancel => {
self.pending_remove_custom_endpoint_index = None;
self.remove_custom_endpoint_confirmation_dialog
.update(ctx, |dialog, ctx| {
dialog.hide(ctx);
});
ctx.notify();
}
RemoveCustomEndpointConfirmationDialogEvent::Confirm(index) => {
if !Self::can_use_custom_inference_controls(ctx) {
self.pending_remove_custom_endpoint_index = None;
self.remove_custom_endpoint_confirmation_dialog
.update(ctx, |dialog, ctx| {
dialog.hide(ctx);
});
ctx.notify();
return;
}
ApiKeyManager::handle(ctx).update(ctx, |manager, ctx| {
manager.remove_custom_endpoint(*index, ctx);
});
self.pending_remove_custom_endpoint_index = None;
self.remove_custom_endpoint_confirmation_dialog
.update(ctx, |dialog, ctx| {
dialog.hide(ctx);
});
self.sync_custom_endpoint_buttons(ctx);
let window_id = ctx.window_id();
crate::ToastStack::handle(ctx).update(ctx, |toast_stack, ctx| {
let toast = crate::view_components::DismissibleToast::success(
"Endpoint removed".to_string(),
);
toast_stack.add_ephemeral_toast(toast, window_id, ctx);
});
ctx.notify();
}
}
}
#[cfg(not(target_family = "wasm"))]
fn create_grok_code_editor(ctx: &mut ViewContext<Self>) -> ViewHandle<EditorView> {
ctx.add_typed_action_view(|ctx| {
@@ -3635,9 +3198,6 @@ pub enum AISettingsPageAction {
#[cfg(feature = "local_fs")]
OpenAddCustomRouter,
// Custom inference
OpenAddCustomEndpointModal,
OpenEditCustomEndpointModal(usize),
ConnectGrokSubscription,
DisconnectGrokSubscription,
@@ -4440,12 +4000,6 @@ impl TypedActionView for AISettingsPageView {
AISettingsPageAction::OpenAddCustomRouter => {
ctx.emit(AISettingsPageEvent::OpenCustomRouterEditor(None));
}
AISettingsPageAction::OpenAddCustomEndpointModal => {
self.show_add_custom_endpoint_modal(ctx);
}
AISettingsPageAction::OpenEditCustomEndpointModal(index) => {
self.show_edit_custom_endpoint_modal(*index, ctx);
}
AISettingsPageAction::ToggleCloudHandoff => {
AISettings::handle(ctx).update(ctx, |settings, ctx| {
report_if_error!(settings
@@ -8528,11 +8082,7 @@ impl ApiKeysWidget {
.with_child(chips)
.finish();
let edit_button = view
.custom_endpoint_edit_buttons
.get(index)
.map(|button| button.as_ref(app).render(app))
.unwrap_or_else(|| Empty::new().finish());
let edit_button = Empty::new().finish();
let row = Flex::row()
.with_main_axis_size(MainAxisSize::Max)
@@ -8782,7 +8332,6 @@ impl SettingsWidget for ApiKeysWidget {
.with_main_axis_alignment(MainAxisAlignment::SpaceBetween)
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.with_child(header_left)
.with_child(view.custom_inference_add_button.as_ref(app).render(app))
.finish();
column.add_child(
@@ -1,421 +0,0 @@
use std::cell::RefCell;
use std::collections::HashMap;
use galaxy_core::ui::appearance::Appearance;
use pathfinder_color::ColorU;
use pathfinder_geometry::vector::vec2f;
use thousands::Separable;
use warpui::elements::{
Align, Border, ConstrainedBox, Container, CornerRadius, CrossAxisAlignment, DropShadow, Empty,
Flex, MainAxisAlignment, MainAxisSize, MouseStateHandle, ParentElement, Radius, Shrinkable,
Text,
};
use warpui::fonts::{Properties, Weight};
use warpui::Element;
use crate::settings_view::billing_and_usage_page_v2::{
AGGREGATE_CREDITS_DOT_COLOR, AMBIENT_CREDITS_DOT_COLOR, BASE_CREDITS_DOT_COLOR,
BONUS_CREDITS_DOT_COLOR, PAYG_CREDITS_DOT_COLOR,
};
use crate::ui_components::blended_colors;
use crate::workspaces::workspace::{
AiCreditsUsageAndCostSubjectType, AiCreditsUsageAndCostType, AiCreditsUsageBucket,
BillingCycleUsageEntry,
};
// for a bunch of this (min fill ratio, cost type order, ... )
// you will find analogous ts code in warp-server
pub const ROW_BORDER_RADIUS: f32 = 8.;
pub const ROW_BORDER_WIDTH: f32 = 1.;
pub const TOOLTIP_GAP: f32 = 6.;
const COST_TYPE_ORDER: &[AiCreditsUsageAndCostType] = &[
AiCreditsUsageAndCostType::BaseLimit,
AiCreditsUsageAndCostType::BonusGrant,
AiCreditsUsageAndCostType::Payg,
AiCreditsUsageAndCostType::AmbientBonusGrant,
];
const BUCKET_ORDER: &[AiCreditsUsageBucket] = &[
AiCreditsUsageBucket::Ai,
AiCreditsUsageBucket::Compute,
AiCreditsUsageBucket::Platform,
];
/// One colored slice of the stacked bar. `cost_type` drives color; `usage_bucket`
/// drives the tooltip breakdown.
#[derive(Clone, Debug)]
pub struct BarSegment {
pub cost_type: AiCreditsUsageAndCostType,
pub usage_bucket: AiCreditsUsageBucket,
pub credits: i64,
pub cost_cents: i64,
}
/// Shared mouse-state bag for the billing-and-usage section: the
/// All/Local/Cloud filter pills plus a tooltip handle for every interactive
/// element keyed by string id (per-member rows, team-totals cards, ...).
pub struct BillingUsageMouseStates {
pub filter_all: MouseStateHandle,
pub filter_local: MouseStateHandle,
pub filter_cloud: MouseStateHandle,
tooltip_by_subject: RefCell<HashMap<String, MouseStateHandle>>,
}
impl Default for BillingUsageMouseStates {
fn default() -> Self {
Self {
filter_all: MouseStateHandle::default(),
filter_local: MouseStateHandle::default(),
filter_cloud: MouseStateHandle::default(),
tooltip_by_subject: RefCell::new(HashMap::new()),
}
}
}
impl BillingUsageMouseStates {
pub fn tooltip_mouse_state(&self, key: &str) -> MouseStateHandle {
let mut map = self.tooltip_by_subject.borrow_mut();
map.entry(key.to_string()).or_default().clone()
}
}
/// Swatch color for one cost-type bucket, mirroring the legend palette.
pub fn cost_type_color(cost_type: &AiCreditsUsageAndCostType) -> ColorU {
match cost_type {
AiCreditsUsageAndCostType::BaseLimit => BASE_CREDITS_DOT_COLOR,
AiCreditsUsageAndCostType::BonusGrant => BONUS_CREDITS_DOT_COLOR,
AiCreditsUsageAndCostType::Payg => PAYG_CREDITS_DOT_COLOR,
AiCreditsUsageAndCostType::AmbientBonusGrant => AMBIENT_CREDITS_DOT_COLOR,
AiCreditsUsageAndCostType::Aggregate => AGGREGATE_CREDITS_DOT_COLOR,
AiCreditsUsageAndCostType::Other(_) => BASE_CREDITS_DOT_COLOR,
}
}
fn cost_type_label(cost_type: &AiCreditsUsageAndCostType) -> &'static str {
match cost_type {
AiCreditsUsageAndCostType::BaseLimit => "Base",
AiCreditsUsageAndCostType::BonusGrant => "Add-ons",
AiCreditsUsageAndCostType::Payg => "Pay-as-you-go",
AiCreditsUsageAndCostType::AmbientBonusGrant => "Cloud-only",
AiCreditsUsageAndCostType::Aggregate => "Combined",
AiCreditsUsageAndCostType::Other(_) => "Other",
}
}
fn bucket_label(bucket: &AiCreditsUsageBucket) -> &'static str {
match bucket {
AiCreditsUsageBucket::Ai => "AI",
AiCreditsUsageBucket::Compute => "Compute",
AiCreditsUsageBucket::Platform => "Platform",
AiCreditsUsageBucket::SuggestedCodeDiffs => "Suggested code diffs",
AiCreditsUsageBucket::Voice => "Voice",
AiCreditsUsageBucket::Aggregate => "Total",
AiCreditsUsageBucket::Other(_) => "Other",
}
}
fn cost_type_rank(cost_type: &AiCreditsUsageAndCostType) -> usize {
COST_TYPE_ORDER
.iter()
.position(|c| c == cost_type)
.unwrap_or(COST_TYPE_ORDER.len())
}
fn bucket_rank(bucket: &AiCreditsUsageBucket) -> usize {
BUCKET_ORDER
.iter()
.position(|b| b == bucket)
.unwrap_or(BUCKET_ORDER.len())
}
fn segment_sort_key(segment: &BarSegment) -> (usize, usize) {
(
cost_type_rank(&segment.cost_type),
bucket_rank(&segment.usage_bucket),
)
}
/// Group `entries` by `(cost_type, usage_bucket)` into [`BarSegment`]s; returns
/// sorted segments plus row totals. Linear Vec lookup since cynic enums don't
/// impl Hash and per-row entry counts are small.
pub fn aggregate_segments<'a>(
entries: impl IntoIterator<Item = &'a BillingCycleUsageEntry>,
) -> (Vec<BarSegment>, i64, i64) {
let mut segments: Vec<BarSegment> = Vec::new();
for entry in entries {
if let Some(existing) = segments
.iter_mut()
.find(|s| s.cost_type == entry.cost_type && s.usage_bucket == entry.usage_bucket)
{
existing.credits += entry.credits_used as i64;
existing.cost_cents += entry.cost_cents as i64;
} else {
segments.push(BarSegment {
cost_type: entry.cost_type.clone(),
usage_bucket: entry.usage_bucket.clone(),
credits: entry.credits_used as i64,
cost_cents: entry.cost_cents as i64,
});
}
}
segments.retain(|s| s.credits > 0);
segments.sort_by_key(segment_sort_key);
let total_credits = segments.iter().map(|s| s.credits).sum();
let total_cost_cents = segments.iter().map(|s| s.cost_cents).sum();
(segments, total_credits, total_cost_cents)
}
/// Drops Voice / SuggestedCodeDiffs entries from the usage view.
///
/// These buckets are tracked server-side against their own dedicated per-cycle
/// limits (`VoiceRequestLimit` / `SuggestedCodeDiffsLimit`) rather than the
/// AI/Compute base credit pool — see
/// `model/sql/ai_credits_usage_and_cost/get_base_limits_usage.sql` and
/// `isBaseLimitExhaustedForBucket` in warp-server. Records are written with
/// `cost_type = BASE_LIMIT` and `cost_cents = 0`, so surfacing them here
/// would inflate the per-row `total_credits` and skew the `used / limit`
/// math without contributing to anything the user is actually billed for.
///
/// TODO: this also hides the rare case where a user blows past their
/// dedicated Voice or SuggestedCodeDiffs limit and the resolver falls
/// through to bonus grants — those entries would have real `cost_cents`
/// and *do* draw down add-on credits. In practice ~nobody (maybe ZL?) hits
/// those limits, so we filter unconditionally for now; revisit if usage of
/// those features ever grows enough that the overflow matters.
pub fn filter_legacy_buckets(entries: &[BillingCycleUsageEntry]) -> Vec<BillingCycleUsageEntry> {
entries
.iter()
.filter(|e| {
e.usage_bucket != AiCreditsUsageBucket::Voice
&& e.usage_bucket != AiCreditsUsageBucket::SuggestedCodeDiffs
})
.cloned()
.collect()
}
/// Cost-type buckets to surface in the usage legend, in display order.
///
/// Mirrors the buckets the stacked bars actually render: legacy buckets are
/// dropped (see [`filter_legacy_buckets`]) and a cost type only counts when it
/// has real usage (`credits_used > 0`), exactly like [`aggregate_segments`],
/// which retains only segments with `credits > 0`. Without the usage check a
/// zero-credit entry (e.g. an untouched base-limit row) would list "Base" in
/// the legend even though it contributed nothing to the chart.
pub fn legend_cost_types(entries: &[BillingCycleUsageEntry]) -> Vec<AiCreditsUsageAndCostType> {
let filtered = filter_legacy_buckets(entries);
[
AiCreditsUsageAndCostType::BaseLimit,
AiCreditsUsageAndCostType::BonusGrant,
AiCreditsUsageAndCostType::Payg,
AiCreditsUsageAndCostType::AmbientBonusGrant,
AiCreditsUsageAndCostType::Aggregate,
]
.into_iter()
.filter(|cost_type| {
filtered
.iter()
.any(|e| e.cost_type == *cost_type && e.credits_used > 0)
})
.collect()
}
/// "Is there any data in `entries` that's not my own?"
pub fn has_non_viewer_data(entries: &[BillingCycleUsageEntry], viewer_uid: Option<&str>) -> bool {
entries.iter().any(|e| match &e.subject_type {
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.
_ => true,
},
})
}
pub fn format_credits(credits: i64) -> String {
credits.separate_with_commas()
}
pub fn format_cost_cents(cents: i64) -> String {
let dollars = cents / 100;
let remainder = (cents.abs() % 100) as u8;
if dollars < 0 {
format!(
"-${}.{remainder:02}",
dollars.unsigned_abs().separate_with_commas()
)
} else {
format!("${}.{remainder:02}", dollars.separate_with_commas())
}
}
/// Section subheader (e.g. "Team totals", "Member usage"). One step below
/// the v2 page's bold section title.
pub fn render_section_subheader(label: &str, appearance: &Appearance) -> Box<dyn Element> {
Text::new_inline(label.to_string(), appearance.ui_font_family(), 14.)
.with_color(appearance.theme().active_ui_text_color().into())
.with_style(Properties::default().weight(Weight::Medium))
.finish()
}
/// Per-cost-type breakdown card. Parameterized by raw segments and totals so
/// it can back team-totals card hovers as well as per-member row hovers.
pub fn render_breakdown_tooltip(
segments: &[BarSegment],
total_credits: i64,
total_cost_cents: i64,
appearance: &Appearance,
) -> Box<dyn Element> {
let theme = appearance.theme();
let font_family = appearance.ui_font_family();
let bg = theme.background().into_solid();
let main = blended_colors::text_main(theme, bg);
let sub = blended_colors::text_sub(theme, bg);
let mut column = Flex::column()
.with_cross_axis_alignment(CrossAxisAlignment::Stretch)
.with_spacing(6.);
for line in segments {
let label = if matches!(line.usage_bucket, AiCreditsUsageBucket::Aggregate) {
cost_type_label(&line.cost_type).to_string()
} else {
format!(
"{} ({})",
cost_type_label(&line.cost_type),
bucket_label(&line.usage_bucket)
)
};
column.add_child(render_tooltip_row(
Some(cost_type_color(&line.cost_type)),
label,
line.credits,
line.cost_cents,
sub,
main,
font_family,
/* bold */ false,
));
}
// Divider before the total row.
column.add_child(
Container::new(Empty::new().finish())
.with_padding_top(1.)
.with_background_color(theme.outline().into_solid())
.finish(),
);
column.add_child(render_tooltip_row(
/* no swatch on the total row */ None,
"Total usage".to_string(),
total_credits,
total_cost_cents,
main,
main,
font_family,
/* bold */ true,
));
ConstrainedBox::new(
Container::new(column.finish())
.with_background_color(bg)
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(6.)))
.with_border(Border::all(1.).with_border_color(theme.outline().into_solid()))
.with_uniform_padding(10.)
.with_drop_shadow(
DropShadow::new_with_standard_offset_and_spread(ColorU::new(0, 0, 0, 48))
.with_offset(vec2f(0., 4.)),
)
.finish(),
)
.with_min_width(200.)
.with_max_width(320.)
.finish()
}
/// Single tooltip row: `[swatch + label] [spacer] [credits / cost]` with
/// fixed-width right-aligned number columns.
#[allow(clippy::too_many_arguments)]
fn render_tooltip_row(
swatch_color: Option<ColorU>,
label: String,
credits: i64,
cost_cents: i64,
label_color: ColorU,
value_color: ColorU,
font_family: warpui::fonts::FamilyId,
bold: bool,
) -> Box<dyn Element> {
let style = if bold {
Properties::default().weight(Weight::Semibold)
} else {
Properties::default()
};
let label_text = Text::new_inline(label, font_family, 12.)
.with_color(label_color)
.with_style(style)
.finish();
let mut left = Flex::row().with_cross_axis_alignment(CrossAxisAlignment::Center);
if let Some(color) = swatch_color {
left.add_child(
ConstrainedBox::new(
Container::new(Empty::new().finish())
.with_background_color(color)
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(2.)))
.finish(),
)
.with_width(10.)
.with_height(10.)
.finish(),
);
left.add_child(Container::new(label_text).with_margin_left(8.).finish());
} else {
left.add_child(label_text);
}
let credits_text = Text::new_inline(format_credits(credits), font_family, 12.)
.with_color(value_color)
.with_style(style)
.finish();
let cost_text = Text::new_inline(format_cost_cents(cost_cents), font_family, 12.)
.with_color(value_color)
.with_style(style)
.finish();
let divider = Text::new_inline("/".to_string(), font_family, 12.)
.with_color(label_color)
.with_style(style)
.finish();
let credits_col = ConstrainedBox::new(Align::new(credits_text).right().finish())
.with_width(60.)
.finish();
let cost_col = ConstrainedBox::new(Align::new(cost_text).right().finish())
.with_width(64.)
.finish();
let right = Flex::row()
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.with_child(credits_col)
.with_child(Container::new(divider).with_horizontal_margin(3.).finish())
.with_child(cost_col)
.finish();
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., left.finish()).finish())
.with_child(right)
.finish()
}
#[cfg(test)]
#[path = "billing_cycle_usage_common_tests.rs"]
mod tests;
@@ -1,376 +0,0 @@
use super::{
aggregate_segments, filter_legacy_buckets, has_non_viewer_data, legend_cost_types, BarSegment,
};
use crate::workspaces::workspace::{
AiCreditsUsageAndCostSubjectType, AiCreditsUsageAndCostType, AiCreditsUsageBucket,
AiCreditsUsageSource, BillingCycleUsageEntry,
};
const VIEWER_UID: &str = "viewer-uid";
const OTHER_UID: &str = "other-uid";
fn entry(
subject_type: AiCreditsUsageAndCostSubjectType,
subject_uid: Option<&str>,
cost_type: AiCreditsUsageAndCostType,
usage_bucket: AiCreditsUsageBucket,
usage_source: AiCreditsUsageSource,
credits_used: i32,
cost_cents: i32,
) -> BillingCycleUsageEntry {
BillingCycleUsageEntry {
subject_type,
subject_uid: subject_uid.map(|s| s.to_string()),
subject_display_name: None,
cost_type,
usage_bucket,
usage_source,
credits_used,
cost_cents,
}
}
/// Boilerplate viewer-owned User row for predicate tests.
fn viewer_user_entry() -> BillingCycleUsageEntry {
entry(
AiCreditsUsageAndCostSubjectType::User,
Some(VIEWER_UID),
AiCreditsUsageAndCostType::BaseLimit,
AiCreditsUsageBucket::Ai,
AiCreditsUsageSource::Local,
10,
0,
)
}
#[test]
fn has_non_viewer_data_returns_false_when_entries_empty() {
assert!(!has_non_viewer_data(&[], Some(VIEWER_UID)));
}
#[test]
fn has_non_viewer_data_returns_false_when_only_viewer_user_rows() {
let entries = vec![viewer_user_entry(), viewer_user_entry()];
assert!(!has_non_viewer_data(&entries, Some(VIEWER_UID)));
}
#[test]
fn has_non_viewer_data_returns_true_for_team_aggregate_row() {
// TeamAggregate visibility represents "everyone else's usage" as a single
// Team-typed row, even when the workspace currently has only one member
// (e.g. a teammate left mid-cycle after incurring AI costs).
let entries = vec![
viewer_user_entry(),
entry(
AiCreditsUsageAndCostSubjectType::Team,
None,
AiCreditsUsageAndCostType::Aggregate,
AiCreditsUsageBucket::Aggregate,
AiCreditsUsageSource::Aggregate,
500,
300,
),
];
assert!(has_non_viewer_data(&entries, Some(VIEWER_UID)));
}
#[test]
fn has_non_viewer_data_returns_true_for_other_user_row() {
// PerUserTotals / FullBreakdown emit per-user rows, so a departed teammate
// shows up as a User entry with a non-viewer UID.
let entries = vec![entry(
AiCreditsUsageAndCostSubjectType::User,
Some(OTHER_UID),
AiCreditsUsageAndCostType::BaseLimit,
AiCreditsUsageBucket::Ai,
AiCreditsUsageSource::Local,
50,
0,
)];
assert!(has_non_viewer_data(&entries, Some(VIEWER_UID)));
}
#[test]
fn has_non_viewer_data_returns_true_for_service_account_row() {
let entries = vec![entry(
AiCreditsUsageAndCostSubjectType::ServiceAccount,
Some("sa-uid"),
AiCreditsUsageAndCostType::BaseLimit,
AiCreditsUsageBucket::Ai,
AiCreditsUsageSource::Cloud,
25,
0,
)];
assert!(has_non_viewer_data(&entries, Some(VIEWER_UID)));
}
#[test]
fn has_non_viewer_data_treats_missing_subject_uid_as_non_viewer() {
// Defensive: a User row with no UID is conservatively treated as a non-
// viewer subject so we never accidentally drop team scaffolding.
let entries = vec![entry(
AiCreditsUsageAndCostSubjectType::User,
None,
AiCreditsUsageAndCostType::BaseLimit,
AiCreditsUsageBucket::Ai,
AiCreditsUsageSource::Local,
1,
0,
)];
assert!(has_non_viewer_data(&entries, Some(VIEWER_UID)));
}
#[test]
fn has_non_viewer_data_treats_missing_viewer_uid_as_non_viewer() {
// Signed-out / unidentified viewer: any subject we can't prove belongs
// to them counts as non-viewer data.
let entries = vec![viewer_user_entry()];
assert!(has_non_viewer_data(&entries, None));
}
#[test]
fn filter_legacy_buckets_drops_voice_and_suggested_code_diffs_in_input_order() {
let entries = vec![
entry(
AiCreditsUsageAndCostSubjectType::User,
Some(VIEWER_UID),
AiCreditsUsageAndCostType::BaseLimit,
AiCreditsUsageBucket::Ai,
AiCreditsUsageSource::Local,
10,
0,
),
entry(
AiCreditsUsageAndCostSubjectType::User,
Some(VIEWER_UID),
AiCreditsUsageAndCostType::BaseLimit,
AiCreditsUsageBucket::Voice,
AiCreditsUsageSource::Local,
3,
0,
),
entry(
AiCreditsUsageAndCostSubjectType::User,
Some(VIEWER_UID),
AiCreditsUsageAndCostType::BaseLimit,
AiCreditsUsageBucket::Compute,
AiCreditsUsageSource::Local,
5,
0,
),
entry(
AiCreditsUsageAndCostSubjectType::User,
Some(VIEWER_UID),
AiCreditsUsageAndCostType::BaseLimit,
AiCreditsUsageBucket::SuggestedCodeDiffs,
AiCreditsUsageSource::Local,
7,
0,
),
entry(
AiCreditsUsageAndCostSubjectType::User,
Some(VIEWER_UID),
AiCreditsUsageAndCostType::Aggregate,
AiCreditsUsageBucket::Aggregate,
AiCreditsUsageSource::Aggregate,
100,
50,
),
entry(
AiCreditsUsageAndCostSubjectType::User,
Some(VIEWER_UID),
AiCreditsUsageAndCostType::BaseLimit,
AiCreditsUsageBucket::Platform,
AiCreditsUsageSource::Cloud,
2,
0,
),
];
let filtered = filter_legacy_buckets(&entries);
let kept_buckets: Vec<_> = filtered.iter().map(|e| e.usage_bucket.clone()).collect();
assert_eq!(
kept_buckets,
vec![
AiCreditsUsageBucket::Ai,
AiCreditsUsageBucket::Compute,
AiCreditsUsageBucket::Aggregate,
AiCreditsUsageBucket::Platform,
],
"expected Voice + SuggestedCodeDiffs dropped while preserving the rest in input order"
);
}
#[test]
fn aggregate_segments_merges_dupes_drops_zeros_and_sorts() {
let entries = [
// Same (BonusGrant, Compute) appears twice across different sources;
// should merge into one segment.
entry(
AiCreditsUsageAndCostSubjectType::User,
Some(VIEWER_UID),
AiCreditsUsageAndCostType::BonusGrant,
AiCreditsUsageBucket::Compute,
AiCreditsUsageSource::Local,
10,
5,
),
entry(
AiCreditsUsageAndCostSubjectType::User,
Some(VIEWER_UID),
AiCreditsUsageAndCostType::BonusGrant,
AiCreditsUsageBucket::Compute,
AiCreditsUsageSource::Cloud,
7,
3,
),
// BaseLimit/Ai — should sort before any BonusGrant entry.
entry(
AiCreditsUsageAndCostSubjectType::User,
Some(VIEWER_UID),
AiCreditsUsageAndCostType::BaseLimit,
AiCreditsUsageBucket::Ai,
AiCreditsUsageSource::Local,
20,
0,
),
// Zero-credit entry: must be dropped before totals are computed (so
// the stray cost_cents don't leak into the row total).
entry(
AiCreditsUsageAndCostSubjectType::User,
Some(VIEWER_UID),
AiCreditsUsageAndCostType::Payg,
AiCreditsUsageBucket::Ai,
AiCreditsUsageSource::Local,
0,
42,
),
];
let (segments, total_credits, total_cost_cents) = aggregate_segments(entries.iter());
let key = |s: &BarSegment| (s.cost_type.clone(), s.usage_bucket.clone());
let keys: Vec<_> = segments.iter().map(key).collect();
assert_eq!(
keys,
vec![
(
AiCreditsUsageAndCostType::BaseLimit,
AiCreditsUsageBucket::Ai
),
(
AiCreditsUsageAndCostType::BonusGrant,
AiCreditsUsageBucket::Compute
),
],
"expected BaseLimit/Ai before BonusGrant/Compute, Payg zero-credit dropped"
);
let bonus = &segments[1];
assert_eq!(bonus.credits, 17, "10 + 7 merged credits");
assert_eq!(bonus.cost_cents, 8, "5 + 3 merged cost cents");
// Totals are summed *after* the zero-credit segment is dropped, so the
// stray 42 cents on the Payg/Ai entry must not appear here.
assert_eq!(total_credits, 20 + 17);
assert_eq!(total_cost_cents, 8);
}
#[test]
fn legend_cost_types_excludes_zero_credit_bucket() {
// Regression: a base-limit row with no usage must not surface "Base" in
// the legend while only Pay-as-you-go credits were actually spent.
let entries = vec![
entry(
AiCreditsUsageAndCostSubjectType::User,
Some(VIEWER_UID),
AiCreditsUsageAndCostType::BaseLimit,
AiCreditsUsageBucket::Ai,
AiCreditsUsageSource::Local,
0,
0,
),
entry(
AiCreditsUsageAndCostSubjectType::User,
Some(VIEWER_UID),
AiCreditsUsageAndCostType::Payg,
AiCreditsUsageBucket::Ai,
AiCreditsUsageSource::Local,
50,
120,
),
];
assert_eq!(
legend_cost_types(&entries),
vec![AiCreditsUsageAndCostType::Payg],
"zero-credit BaseLimit row must be dropped from the legend"
);
}
#[test]
fn legend_cost_types_includes_used_buckets_in_display_order() {
// Buckets with real usage appear in the canonical legend order regardless
// of input order (Payg listed before BaseLimit here).
let entries = vec![
entry(
AiCreditsUsageAndCostSubjectType::User,
Some(VIEWER_UID),
AiCreditsUsageAndCostType::Payg,
AiCreditsUsageBucket::Ai,
AiCreditsUsageSource::Local,
5,
10,
),
entry(
AiCreditsUsageAndCostSubjectType::User,
Some(VIEWER_UID),
AiCreditsUsageAndCostType::BaseLimit,
AiCreditsUsageBucket::Ai,
AiCreditsUsageSource::Local,
8,
0,
),
];
assert_eq!(
legend_cost_types(&entries),
vec![
AiCreditsUsageAndCostType::BaseLimit,
AiCreditsUsageAndCostType::Payg,
],
"used buckets should render in canonical order, not input order"
);
}
#[test]
fn legend_cost_types_excludes_legacy_only_buckets() {
// Voice / SuggestedCodeDiffs usage is written as BaseLimit credits but is
// dropped from the bars; the legend must match and not show "Base".
let entries = vec![
entry(
AiCreditsUsageAndCostSubjectType::User,
Some(VIEWER_UID),
AiCreditsUsageAndCostType::BaseLimit,
AiCreditsUsageBucket::Voice,
AiCreditsUsageSource::Local,
12,
0,
),
entry(
AiCreditsUsageAndCostSubjectType::User,
Some(VIEWER_UID),
AiCreditsUsageAndCostType::BaseLimit,
AiCreditsUsageBucket::SuggestedCodeDiffs,
AiCreditsUsageSource::Local,
4,
0,
),
];
assert!(
legend_cost_types(&entries).is_empty(),
"legacy-only base-limit usage must not surface any legend bucket"
);
}
@@ -1,838 +0,0 @@
use std::collections::HashMap;
use galaxy_core::channel::ChannelState;
use galaxy_core::ui::appearance::Appearance;
use itertools::Itertools as _;
use pathfinder_color::ColorU;
use pathfinder_geometry::vector::vec2f;
use warpui::elements::{
Border, ChildAnchor, ConstrainedBox, Container, CornerRadius, CrossAxisAlignment, DropShadow,
Empty, Expanded, Flex, Hoverable, MainAxisAlignment, MainAxisSize, MouseStateHandle,
OffsetPositioning, ParentAnchor, ParentElement, ParentOffsetBounds, Radius, Shrinkable, Stack,
Text,
};
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,
render_breakdown_tooltip, render_section_subheader, BarSegment, BillingUsageMouseStates,
ROW_BORDER_RADIUS, ROW_BORDER_WIDTH, TOOLTIP_GAP,
};
use crate::ui_components::blended_colors;
use crate::ui_components::icons::Icon;
use crate::workspaces::workspace::{
AiCreditsUsageAndCostSubjectType, AiCreditsUsageAndCostType, AiCreditsUsageBucket,
AiCreditsUsageSource, BillingCycleUsageEntry, UsageVisibility, UsageVisibilityGranularity,
Workspace, WorkspaceMember,
};
const BAR_HEIGHT: f32 = 8.;
const MIN_FILL_RATIO: f32 = 0.05;
/// Size of the leading icons in the row credit cluster (coin + credit-card).
const ROW_ICON_SIZE: f32 = 12.;
/// Inner radius so the bar's curve sits flush against the card's inner border.
const BAR_CORNER_RADIUS: f32 = ROW_BORDER_RADIUS - ROW_BORDER_WIDTH;
const ROW_PADDING: f32 = 12.;
const SELF_OWN_KEY: &str = "__self_own__";
const OTHER_MEMBERS_KEY: &str = "__other_members__";
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum SourceFilter {
#[default]
All,
Local,
Cloud,
}
impl SourceFilter {
pub fn label(self) -> &'static str {
match self {
SourceFilter::All => "All",
SourceFilter::Local => "Local",
SourceFilter::Cloud => "Cloud",
}
}
fn matches(self, source: &AiCreditsUsageSource) -> bool {
match self {
SourceFilter::All => true,
SourceFilter::Local => *source == AiCreditsUsageSource::Local,
SourceFilter::Cloud => *source == AiCreditsUsageSource::Cloud,
}
}
}
/// Aggregated usage for one subject (or the synthetic team aggregate).
#[derive(Debug)]
pub struct MemberUsageRow {
pub subject_type: AiCreditsUsageAndCostSubjectType,
pub subject_key: String,
/// Used to deep-link `ServiceAccount` rows to their Oz agent page.
pub subject_uid: Option<String>,
pub display_name: String,
pub total_credits: i64,
pub total_cost_cents: i64,
/// Sorted by cost-type then bucket order; zero-credit entries dropped.
pub segments: Vec<BarSegment>,
/// Denominator the row's stacked bar fills against.
pub bar_max_credits: i64,
}
fn viewer_identity(app: &AppContext) -> (Option<String>, 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)
}
struct GroupedSubjectUsage {
subject_type: AiCreditsUsageAndCostSubjectType,
display_name: String,
entries: Vec<BillingCycleUsageEntry>,
}
impl MemberUsageRow {
fn for_viewer(
entries: &[BillingCycleUsageEntry],
viewer_uid: Option<&str>,
viewer_display_name: String,
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,
segments,
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 / no workspace data).
fn for_viewer_from_total(
viewer_uid: Option<String>,
viewer_display_name: String,
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,
segments,
bar_max_credits: used.max(1),
}
}
/// 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,
segments,
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<Self> {
// Group entries by subject for joining against the member list below.
let mut grouped: HashMap<String, GroupedSubjectUsage> = 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<Self> = Vec::with_capacity(members.len());
// One row per workspace member, including zero-usage members.
let mut seen_keys: std::collections::HashSet<String> = 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,
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,
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<MemberUsageRow> {
let mut rows: Vec<MemberUsageRow> = match visibility.granularity {
UsageVisibilityGranularity::OwnOnly => {
let (viewer_uid, display_name) = viewer_identity(app);
vec![MemberUsageRow::for_viewer(
entries,
viewer_uid.as_deref(),
display_name,
source_filter,
)]
}
UsageVisibilityGranularity::TeamAggregate => {
// Force SourceFilter::All — TeamAggregate has no toggle.
let (viewer_uid, display_name) = viewer_identity(app);
let mut rows = vec![MemberUsageRow::for_viewer(
entries,
viewer_uid.as_deref(),
display_name,
SourceFilter::All,
)];
rows.push(MemberUsageRow::for_other_members(entries));
rows
}
UsageVisibilityGranularity::PerUserTotals | UsageVisibilityGranularity::FullBreakdown => {
MemberUsageRow::for_each_member(entries, &workspace.members, source_filter)
}
};
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
}
/// True if any entry is cloud-sourced; gates the source filter toggle.
pub fn has_cloud_usage(entries: &[BillingCycleUsageEntry]) -> bool {
entries
.iter()
.any(|e| e.usage_source == AiCreditsUsageSource::Cloud)
}
fn render_stacked_bar(
segments: &[BarSegment],
total_credits: i64,
team_max_credits: i64,
appearance: &Appearance,
) -> Box<dyn Element> {
let theme = appearance.theme();
let track_bg = theme.surface_overlay_1();
let corner = Radius::Pixels(BAR_CORNER_RADIUS);
if team_max_credits == 0 || total_credits == 0 || segments.is_empty() {
// Empty track, top-rounded on both ends.
return ConstrainedBox::new(
Container::new(Empty::new().finish())
.with_background(track_bg)
.with_corner_radius(CornerRadius::with_top(corner))
.finish(),
)
.with_height(BAR_HEIGHT)
.finish();
}
let fill_ratio = (total_credits as f32 / team_max_credits as f32).clamp(MIN_FILL_RATIO, 1.0);
let unfill_ratio = 1.0 - fill_ratio;
let has_unfill = unfill_ratio > 0.0;
let last_segment_idx = segments.len() - 1;
// One Expanded per segment, weighted by share of total_credits. First/last
// segment get rounded top corners (last only if no muted tail).
let mut filled = Flex::row();
for (idx, seg) in segments.iter().enumerate() {
let weight = seg.credits as f32 / total_credits as f32;
if weight <= 0.0 {
continue;
}
let is_first = idx == 0;
let is_last_visible = idx == last_segment_idx && !has_unfill;
let segment_corner = match (is_first, is_last_visible) {
(true, true) => CornerRadius::with_top(corner),
(true, false) => CornerRadius::with_top_left(corner),
(false, true) => CornerRadius::with_top_right(corner),
(false, false) => CornerRadius::default(),
};
filled.add_child(
Expanded::new(
weight,
Container::new(Empty::new().finish())
.with_background_color(cost_type_color(&seg.cost_type))
.with_corner_radius(segment_corner)
.finish(),
)
.finish(),
);
}
let mut bar = Flex::row();
bar.add_child(Expanded::new(fill_ratio, filled.finish()).finish());
if has_unfill {
bar.add_child(
Expanded::new(
unfill_ratio,
Container::new(Empty::new().finish())
.with_background(track_bg)
.with_corner_radius(CornerRadius::with_top_right(corner))
.finish(),
)
.finish(),
);
}
ConstrainedBox::new(bar.finish())
.with_height(BAR_HEIGHT)
.finish()
}
/// Per-cost-type tooltip breakdown with a "Total usage" footer.
fn render_usage_tooltip_content(row: &MemberUsageRow, appearance: &Appearance) -> Box<dyn Element> {
render_breakdown_tooltip(
&row.segments,
row.total_credits,
row.total_cost_cents,
appearance,
)
}
/// Small text-only tooltip surfaced on hover of the service-account info
/// icon. Mirrors the visual treatment of `render_aggregate_legend_tooltip`.
fn render_service_account_info_tooltip(appearance: &Appearance) -> Box<dyn Element> {
let theme = appearance.theme();
let text = Text::new_inline(
"This is an automated agent on your team.".to_string(),
appearance.ui_font_family(),
12.,
)
.with_color(theme.sub_text_color(theme.background()).into())
.finish();
Container::new(text)
.with_background_color(theme.background().into_solid())
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(6.)))
.with_border(Border::all(1.).with_border_color(theme.outline().into_solid()))
.with_horizontal_padding(12.)
.with_vertical_padding(6.)
.with_drop_shadow(
DropShadow::new_with_standard_offset_and_spread(ColorU::new(0, 0, 0, 48))
.with_offset(vec2f(0., 4.)),
)
.finish()
}
/// Renders one row card (stacked bar + name/totals).
fn render_row_card(
row: &MemberUsageRow,
team_max_credits: i64,
mouse_states: &BillingUsageMouseStates,
appearance: &Appearance,
) -> Box<dyn Element> {
let theme = appearance.theme();
let card_bg = theme.background().into_solid();
let main = blended_colors::text_main(theme, card_bg);
let bar = render_stacked_bar(
&row.segments,
row.total_credits,
team_max_credits,
appearance,
);
let is_service_account = matches!(
row.subject_type,
AiCreditsUsageAndCostSubjectType::ServiceAccount
);
// Service accounts with a known UID deep-link to their Oz agent page,
// mirroring the web admin panel's `getOzAgentHref` behavior.
let agent_href = if is_service_account {
row.subject_uid.as_deref().map(|uid| {
format!(
"{}/agents/{}",
ChannelState::oz_root_url(),
urlencoding::encode(uid)
)
})
} else {
None
};
let display_name_element: Box<dyn Element> = if let Some(href) = agent_href {
let link_state =
mouse_states.tooltip_mouse_state(&format!("{}__agent_link", row.subject_key));
appearance
.ui_builder()
.link(row.display_name.clone(), Some(href), None, link_state)
.build()
.finish()
} else {
Text::new_inline(
row.display_name.clone(),
appearance.ui_font_family(),
appearance.ui_font_size(),
)
.with_color(main)
.finish()
};
let mut name_row = Flex::row()
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.with_child(display_name_element);
if is_service_account {
let info_state =
mouse_states.tooltip_mouse_state(&format!("{}__agent_info", row.subject_key));
let info_icon = Hoverable::new(info_state, move |state| {
let info_color = appearance
.theme()
.sub_text_color(appearance.theme().background());
let icon = ConstrainedBox::new(Icon::Info.to_warpui_icon(info_color).finish())
.with_width(ROW_ICON_SIZE)
.with_height(ROW_ICON_SIZE)
.finish();
let mut stack = Stack::new();
stack.add_child(icon);
if state.is_hovered() {
stack.add_positioned_overlay_child(
render_service_account_info_tooltip(appearance),
OffsetPositioning::offset_from_parent(
vec2f(0., -TOOLTIP_GAP),
ParentOffsetBounds::WindowByPosition,
ParentAnchor::TopMiddle,
ChildAnchor::BottomMiddle,
),
);
}
stack.finish()
})
.finish();
name_row.add_child(Container::new(info_icon).with_margin_left(6.).finish());
}
let credits_text = Text::new_inline(
format_credits(row.total_credits),
appearance.ui_font_family(),
appearance.ui_font_size(),
)
.with_color(main)
.finish();
let cost_text = Text::new_inline(
format_cost_cents(row.total_cost_cents),
appearance.ui_font_family(),
appearance.ui_font_size(),
)
.with_color(main)
.finish();
let icon_color = theme.sub_text_color(theme.background());
let coin_icon = ConstrainedBox::new(Icon::Credits.to_warpui_icon(icon_color).finish())
.with_width(ROW_ICON_SIZE)
.with_height(ROW_ICON_SIZE)
.finish();
let card_icon = ConstrainedBox::new(Icon::CreditCard.to_warpui_icon(icon_color).finish())
.with_width(ROW_ICON_SIZE)
.with_height(ROW_ICON_SIZE)
.finish();
let credits_cluster = Flex::row()
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.with_child(coin_icon)
.with_child(Container::new(credits_text).with_margin_left(4.).finish())
.finish();
let cost_cluster = Flex::row()
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.with_child(card_icon)
.with_child(Container::new(cost_text).with_margin_left(4.).finish())
.finish();
let credits_and_cost = Flex::row()
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.with_child(credits_cluster)
.with_child(Container::new(cost_cluster).with_margin_left(6.).finish())
.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(
Container::new(credits_and_cost)
.with_margin_left(16.)
.finish(),
)
.finish(),
)
.with_uniform_padding(ROW_PADDING)
.finish();
Container::new(
Flex::column()
.with_cross_axis_alignment(CrossAxisAlignment::Stretch)
.with_child(bar)
.with_child(body)
.finish(),
)
.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)))
.finish()
}
/// Row card wrapped in a Hoverable that opens the breakdown tooltip.
fn render_member_row(
row: &MemberUsageRow,
team_max_credits: i64,
tooltip_mouse_state: MouseStateHandle,
mouse_states: &BillingUsageMouseStates,
appearance: &Appearance,
) -> Box<dyn Element> {
// No segments => no tooltip needed.
if row.segments.is_empty() {
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.
let info_state = matches!(
row.subject_type,
AiCreditsUsageAndCostSubjectType::ServiceAccount
)
.then(|| mouse_states.tooltip_mouse_state(&format!("{}__agent_info", row.subject_key)));
Hoverable::new(tooltip_mouse_state, move |state| {
let mut stack = Stack::new();
stack.add_child(render_row_card(
row,
team_max_credits,
mouse_states,
appearance,
));
let info_hovered = info_state
.as_ref()
.is_some_and(|s| s.lock().is_ok_and(|guard| guard.is_hovered()));
if state.is_hovered() && !info_hovered {
stack.add_positioned_overlay_child(
render_usage_tooltip_content(row, appearance),
OffsetPositioning::offset_from_parent(
vec2f(0., -TOOLTIP_GAP),
ParentOffsetBounds::WindowByPosition,
ParentAnchor::TopMiddle,
ChildAnchor::BottomMiddle,
),
);
}
stack.finish()
})
.finish()
}
pub type FilterChangeFn = std::sync::Arc<dyn Fn(SourceFilter, &mut EventContext) + 'static>;
/// All / Local / Cloud pill toggle.
fn render_source_filter_toggle(
current: SourceFilter,
mouse_states: &BillingUsageMouseStates,
appearance: &Appearance,
on_change: FilterChangeFn,
) -> Box<dyn Element> {
let theme = appearance.theme();
let bg = theme.surface_1();
let main = blended_colors::text_main(theme, bg);
let sub = blended_colors::text_sub(theme, bg);
let options: [(SourceFilter, MouseStateHandle); 3] = [
(SourceFilter::All, mouse_states.filter_all.clone()),
(SourceFilter::Local, mouse_states.filter_local.clone()),
(SourceFilter::Cloud, mouse_states.filter_cloud.clone()),
];
let mut row = Flex::row()
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.with_main_axis_size(MainAxisSize::Min);
for (filter, mouse_state) in options {
let label = filter.label();
let is_selected = filter == current;
let fg = if is_selected { main } else { sub };
let font_family = appearance.ui_font_family();
let on_change = on_change.clone();
let cell = Hoverable::new(mouse_state, move |_state| {
let mut cell = Container::new(
Text::new_inline(label, font_family, 11.)
.with_color(fg)
.finish(),
)
.with_horizontal_padding(10.)
.with_vertical_padding(4.);
if is_selected {
cell = cell.with_background(theme.surface_overlay_1());
}
cell.finish()
})
.with_cursor(Cursor::PointingHand)
.on_click(move |ctx, _, _| {
on_change(filter, ctx);
})
.finish();
row.add_child(cell);
}
Container::new(row.finish())
.with_border(Border::all(1.).with_border_color(theme.surface_3().into_solid()))
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(6.)))
.finish()
}
pub fn render_own_usage_with_workspace_row(
entries: &[BillingCycleUsageEntry],
mouse_states: &BillingUsageMouseStates,
appearance: &Appearance,
app: &AppContext,
) -> Box<dyn Element> {
let (viewer_uid, display_name) = viewer_identity(app);
let row = MemberUsageRow::for_viewer(
entries,
viewer_uid.as_deref(),
display_name,
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<dyn Element> {
let (viewer_uid, display_name) = viewer_identity(app);
let model = AIRequestUsageModel::as_ref(app);
let row = MemberUsageRow::for_viewer_from_total(
viewer_uid,
display_name,
model.requests_used() as i64,
);
render_member_row_list(std::slice::from_ref(&row), mouse_states, appearance)
}
#[allow(clippy::too_many_arguments)]
pub fn render_rows(
workspace: &Workspace,
entries: &[BillingCycleUsageEntry],
visibility: &UsageVisibility,
source_filter: SourceFilter,
mouse_states: &BillingUsageMouseStates,
appearance: &Appearance,
app: &AppContext,
on_filter_change: FilterChangeFn,
) -> Box<dyn Element> {
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,
entries,
source_filter,
mouse_states,
appearance,
on_filter_change,
) {
column.add_child(header);
}
column.add_child(render_member_row_list(&rows, mouse_states, appearance));
column.finish()
}
fn render_member_header(
visibility: &UsageVisibility,
entries: &[BillingCycleUsageEntry],
source_filter: SourceFilter,
mouse_states: &BillingUsageMouseStates,
appearance: &Appearance,
on_filter_change: FilterChangeFn,
) -> Option<Box<dyn Element>> {
let show_toggle = visibility.granularity == UsageVisibilityGranularity::FullBreakdown
&& has_cloud_usage(entries);
let subheader = render_section_subheader("Members", appearance);
let header = if show_toggle {
Flex::row()
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.with_main_axis_alignment(MainAxisAlignment::SpaceBetween)
.with_main_axis_size(MainAxisSize::Max)
.with_child(subheader)
.with_child(render_source_filter_toggle(
source_filter,
mouse_states,
appearance,
on_filter_change,
))
.finish()
} else {
subheader
};
Some(Container::new(header).with_margin_bottom(8.).finish())
}
fn render_member_row_list(
rows: &[MemberUsageRow],
mouse_states: &BillingUsageMouseStates,
appearance: &Appearance,
) -> Box<dyn Element> {
let mut column = Flex::column()
.with_cross_axis_alignment(CrossAxisAlignment::Stretch)
.with_spacing(8.);
for row in rows {
let tooltip_state = mouse_states.tooltip_mouse_state(&row.subject_key);
column.add_child(render_member_row(
row,
row.bar_max_credits,
tooltip_state,
mouse_states,
appearance,
));
}
column.finish()
}
#[cfg(test)]
#[path = "billing_cycle_usage_rows_tests.rs"]
mod tests;
@@ -1,139 +0,0 @@
use super::{MemberUsageRow, SourceFilter};
use crate::workspaces::workspace::{
AiCreditsUsageAndCostSubjectType, AiCreditsUsageAndCostType, AiCreditsUsageBucket,
AiCreditsUsageSource, BillingCycleUsageEntry,
};
const VIEWER_UID: &str = "viewer-uid";
const OTHER_UID: &str = "other-uid";
fn entry(
subject_type: AiCreditsUsageAndCostSubjectType,
subject_uid: Option<&str>,
usage_source: AiCreditsUsageSource,
credits_used: i32,
cost_cents: i32,
) -> BillingCycleUsageEntry {
BillingCycleUsageEntry {
subject_type,
subject_uid: subject_uid.map(|s| s.to_string()),
subject_display_name: None,
cost_type: AiCreditsUsageAndCostType::BaseLimit,
usage_bucket: AiCreditsUsageBucket::Ai,
usage_source,
credits_used,
cost_cents,
}
}
#[test]
fn build_own_usage_row_drops_team_subject_entries() {
// Team-aggregate rows belong to "everyone else" by construction; they
// must never contribute to the viewer's own row totals.
let entries = vec![
entry(
AiCreditsUsageAndCostSubjectType::User,
Some(VIEWER_UID),
AiCreditsUsageSource::Local,
10,
5,
),
entry(
AiCreditsUsageAndCostSubjectType::Team,
None,
AiCreditsUsageSource::Aggregate,
999,
999,
),
];
let row = MemberUsageRow::for_viewer(
&entries,
Some(VIEWER_UID),
"viewer".to_string(),
SourceFilter::All,
);
assert_eq!(row.total_credits, 10);
assert_eq!(row.total_cost_cents, 5);
}
#[test]
fn build_own_usage_row_drops_other_users_entries() {
let entries = vec![
entry(
AiCreditsUsageAndCostSubjectType::User,
Some(VIEWER_UID),
AiCreditsUsageSource::Local,
10,
0,
),
entry(
AiCreditsUsageAndCostSubjectType::User,
Some(OTHER_UID),
AiCreditsUsageSource::Local,
999,
999,
),
];
let row = MemberUsageRow::for_viewer(
&entries,
Some(VIEWER_UID),
"viewer".to_string(),
SourceFilter::All,
);
assert_eq!(row.total_credits, 10);
assert_eq!(row.total_cost_cents, 0);
}
#[test]
fn build_own_usage_row_local_filter_drops_cloud_entries() {
let entries = vec![
entry(
AiCreditsUsageAndCostSubjectType::User,
Some(VIEWER_UID),
AiCreditsUsageSource::Local,
10,
0,
),
entry(
AiCreditsUsageAndCostSubjectType::User,
Some(VIEWER_UID),
AiCreditsUsageSource::Cloud,
20,
0,
),
];
let row = MemberUsageRow::for_viewer(
&entries,
Some(VIEWER_UID),
"viewer".to_string(),
SourceFilter::Local,
);
assert_eq!(row.total_credits, 10);
}
#[test]
fn build_own_usage_row_cloud_filter_drops_local_entries() {
let entries = vec![
entry(
AiCreditsUsageAndCostSubjectType::User,
Some(VIEWER_UID),
AiCreditsUsageSource::Local,
10,
0,
),
entry(
AiCreditsUsageAndCostSubjectType::User,
Some(VIEWER_UID),
AiCreditsUsageSource::Cloud,
20,
0,
),
];
let row = MemberUsageRow::for_viewer(
&entries,
Some(VIEWER_UID),
"viewer".to_string(),
SourceFilter::Cloud,
);
assert_eq!(row.total_credits, 20);
}
@@ -1,801 +0,0 @@
use chrono::{DateTime, Datelike, Local, Utc};
use galaxy_core::ui::appearance::Appearance;
use markdown_parser::{FormattedText, FormattedTextFragment, FormattedTextLine};
use pathfinder_color::ColorU;
use pathfinder_geometry::vector::vec2f;
use warpui::elements::{
Border, ChildAnchor, ChildView, ConstrainedBox, Container, CornerRadius, CrossAxisAlignment,
DropShadow, Empty, Flex, FormattedTextElement, HighlightedHyperlink, Hoverable, HyperlinkLens,
MainAxisAlignment, MainAxisSize, MouseStateHandle, OffsetPositioning, ParentAnchor,
ParentElement, ParentOffsetBounds, Radius, Stack, Text,
};
use warpui::fonts::{Properties, Weight};
use warpui::platform::Cursor;
use warpui::{
AppContext, Element, Entity, SingletonEntity, TypedActionView, View, ViewContext, ViewHandle,
};
use crate::ai::AIRequestUsageModel;
use crate::auth::{AuthManager, AuthStateProvider};
use crate::menu::{self, Menu, MenuItem, MenuItemFields};
use crate::settings_view::admin_actions::AdminActions;
use crate::settings_view::billing_and_usage::billing_cycle_usage_common::{
filter_legacy_buckets, has_non_viewer_data, legend_cost_types, BillingUsageMouseStates,
};
use crate::settings_view::billing_and_usage::billing_cycle_usage_rows::{
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::{
AGGREGATE_CREDITS_DOT_COLOR, AMBIENT_CREDITS_DOT_COLOR, BASE_CREDITS_DOT_COLOR,
BONUS_CREDITS_DOT_COLOR, PAYG_CREDITS_DOT_COLOR,
};
use crate::ui_components::icons::Icon;
use crate::workspaces::update_manager::TeamUpdateManager;
use crate::workspaces::user_workspaces::UserWorkspaces;
use crate::workspaces::workspace::{
AiCreditsUsageAndCostType, BillingCycleUsageSummary, MaxPriorCycles, UsageVisibility,
UsageVisibilityGranularity, Workspace,
};
const HEADER_FONT_SIZE: f32 = 16.;
const LEGEND_DOT_SIZE: f32 = 8.;
pub struct BillingCycleUsageSectionView {
selected_period_end: Option<DateTime<Utc>>,
period_selector_mouse_state: MouseStateHandle,
aggregate_legend_mouse_state: MouseStateHandle,
period_menu: ViewHandle<Menu<BillingCycleUsageAction>>,
period_menu_open: bool,
source_filter: SourceFilter,
row_mouse_states: BillingUsageMouseStates,
}
#[derive(Clone, Debug)]
pub enum BillingCycleUsageAction {
SelectPeriod(Option<DateTime<Utc>>),
TogglePeriodMenu,
ChangeSourceFilter(SourceFilter),
OpenUpgrade,
OpenAdminPanel,
}
impl Entity for BillingCycleUsageSectionView {
type Event = ();
}
impl BillingCycleUsageSectionView {
pub fn new(ctx: &mut ViewContext<Self>) -> Self {
ctx.subscribe_to_model(&UserWorkspaces::handle(ctx), |me, _, _, ctx| {
me.reconcile_selected_period(ctx);
// If the period menu is open while the workspace or usage data
// changes, the menu's items become stale and clicking one could
// select a period_end that no longer exists in the new data
// (which `current_summary` would then fail to resolve). Rebuild
// the items in-place so the menu always reflects the live data.
if me.period_menu_open {
me.refresh_period_menu_items(ctx);
}
ctx.notify();
});
ctx.subscribe_to_model(&AIRequestUsageModel::handle(ctx), |_, _, _, ctx| {
ctx.notify()
});
ctx.subscribe_to_model(&AuthManager::handle(ctx), |_, _, _, ctx| ctx.notify());
ctx.subscribe_to_model(&TeamUpdateManager::handle(ctx), |_, _, _, ctx| ctx.notify());
// `prevent_interaction_with_other_elements` so a click on the
// trigger button while the menu is open is consumed by the menu's
// outside-click dismiss handler — without it, the trigger also
// received the click and immediately re-toggled the menu open.
let period_menu = ctx.add_typed_action_view(|_| {
Menu::new()
.with_drop_shadow()
.prevent_interaction_with_other_elements()
});
ctx.subscribe_to_view(&period_menu, |me, _, event, ctx| {
if let menu::Event::Close { .. } = event {
me.period_menu_open = false;
ctx.notify();
}
});
Self {
selected_period_end: None,
period_selector_mouse_state: MouseStateHandle::default(),
aggregate_legend_mouse_state: MouseStateHandle::default(),
period_menu,
period_menu_open: false,
source_filter: SourceFilter::default(),
row_mouse_states: BillingUsageMouseStates::default(),
}
}
fn resolved_viewer_email(app: &AppContext) -> Option<String> {
AuthStateProvider::as_ref(app).get().user_email()
}
fn viewer_is_admin(app: &AppContext) -> bool {
let Some(team) = UserWorkspaces::as_ref(app).current_team() else {
return false;
};
Self::resolved_viewer_email(app)
.as_deref()
.is_some_and(|email| team.has_admin_permissions(email))
}
fn current_summary<'a>(
&self,
workspace: &'a Workspace,
) -> Option<&'a BillingCycleUsageSummary> {
let summaries = &workspace.billing_cycle_usage.as_ref()?.summaries;
match self.selected_period_end {
Some(end) => summaries.iter().find(|s| s.period_end == end),
None => summaries.first(),
}
}
fn reconcile_selected_period(&mut self, ctx: &AppContext) {
let Some(selected) = self.selected_period_end else {
return;
};
let still_present = UserWorkspaces::as_ref(ctx)
.current_workspace()
.and_then(|ws| ws.billing_cycle_usage.as_ref())
.map(|data| data.summaries.iter().any(|s| s.period_end == selected))
.unwrap_or(false);
if !still_present {
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 {
type Action = BillingCycleUsageAction;
fn handle_action(&mut self, action: &Self::Action, ctx: &mut ViewContext<Self>) {
match action {
BillingCycleUsageAction::SelectPeriod(period_end) => {
self.selected_period_end = *period_end;
self.period_menu_open = false;
ctx.notify();
}
BillingCycleUsageAction::TogglePeriodMenu => {
self.period_menu_open = !self.period_menu_open;
if self.period_menu_open {
self.refresh_period_menu_items(ctx);
}
ctx.notify();
}
BillingCycleUsageAction::ChangeSourceFilter(filter) => {
self.source_filter = *filter;
ctx.notify();
}
BillingCycleUsageAction::OpenUpgrade => {
if let Some(team_uid) = UserWorkspaces::as_ref(ctx).current_team_uid() {
ctx.open_url(&UserWorkspaces::upgrade_link_for_team(team_uid));
}
}
BillingCycleUsageAction::OpenAdminPanel => {
if let Some(team_uid) = UserWorkspaces::as_ref(ctx).current_team_uid() {
AdminActions::open_admin_panel(team_uid, ctx);
}
}
}
}
}
impl BillingCycleUsageSectionView {
fn refresh_period_menu_items(&self, ctx: &mut ViewContext<Self>) {
let Some(workspace) = UserWorkspaces::as_ref(ctx).current_workspace().cloned() else {
return;
};
let Some(data) = workspace.billing_cycle_usage.as_ref() else {
return;
};
let items: Vec<MenuItem<BillingCycleUsageAction>> = data
.summaries
.iter()
.map(|summary| {
let label = format_period_range(summary.period_start, summary.period_end);
MenuItem::Item(MenuItemFields::new(label).with_on_select_action(
BillingCycleUsageAction::SelectPeriod(Some(summary.period_end)),
))
})
.collect();
self.period_menu
.update(ctx, |menu: &mut Menu<BillingCycleUsageAction>, ctx| {
menu.set_items(items, ctx);
});
}
}
impl View for BillingCycleUsageSectionView {
fn ui_name() -> &'static str {
"BillingCycleUsageSection"
}
fn render(&self, app: &AppContext) -> Box<dyn Element> {
let appearance = Appearance::as_ref(app);
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<dyn Element> {
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));
let entries = filter_legacy_buckets(
self.current_summary(workspace)
.map(|summary| summary.entries.as_slice())
.unwrap_or_default(),
);
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
};
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) {
column.add_child(Container::new(banner).with_margin_top(16.).finish());
}
}
column.add_child(
Container::new(render_rows(
workspace,
&entries,
&visibility,
source_filter,
&self.row_mouse_states,
appearance,
app,
std::sync::Arc::new(|filter, ctx| {
ctx.dispatch_typed_action(BillingCycleUsageAction::ChangeSourceFilter(filter));
}),
))
.with_margin_top(16.)
.finish(),
);
column.finish()
}
fn render_own_usage_with_workspace(
&self,
workspace: &Workspace,
appearance: &Appearance,
app: &AppContext,
) -> Box<dyn Element> {
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(
&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<dyn Element> {
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: Option<&Workspace>,
visibility: &UsageVisibility,
appearance: &Appearance,
app: &AppContext,
) -> Box<dyn Element> {
let theme = appearance.theme();
let mut row = Flex::row()
.with_main_axis_alignment(MainAxisAlignment::SpaceBetween)
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.with_main_axis_size(MainAxisSize::Max);
row.add_child(
Text::new_inline("Usage", appearance.ui_font_family(), HEADER_FONT_SIZE)
.with_style(Properties::default().weight(Weight::Bold))
.with_color(theme.active_ui_text_color().into())
.finish(),
);
let mut right_side = Flex::row()
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.with_main_axis_alignment(MainAxisAlignment::End);
// 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.
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());
let mut column = Flex::column().with_cross_axis_alignment(CrossAxisAlignment::Stretch);
column.add_child(row.finish());
let resets_text = self.render_resets_label(appearance, app);
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)
.with_main_axis_alignment(MainAxisAlignment::SpaceBetween)
.with_main_axis_size(MainAxisSize::Max);
secondary_row.add_child(resets_text.unwrap_or_else(|| Empty::new().finish()));
secondary_row.add_child(legend.unwrap_or_else(|| Empty::new().finish()));
column.add_child(
Container::new(secondary_row.finish())
.with_margin_top(4.)
.finish(),
);
}
Container::new(column.finish()).finish()
}
/// "Resets May 27, 11:24 PM EDT"
fn render_resets_label(
&self,
appearance: &Appearance,
app: &AppContext,
) -> Option<Box<dyn Element>> {
if self.selected_period_end.is_some() {
return None;
}
let theme = appearance.theme();
let reset_str = AIRequestUsageModel::as_ref(app)
.next_refresh_time_local()
.format("Resets %b %d, %-I:%M %p")
.to_string();
Some(
Text::new_inline(
reset_str,
appearance.ui_font_family(),
appearance.ui_font_size(),
)
.with_color(theme.sub_text_color(theme.background()).into())
.finish(),
)
}
// "May 13 - Jun 13, 2026"
fn render_period_range_static(
&self,
workspace: &Workspace,
appearance: &Appearance,
) -> Box<dyn Element> {
let theme = appearance.theme();
let label = self
.current_summary(workspace)
.map(|s| format_period_range(s.period_start, s.period_end))
.or_else(|| {
workspace.billing_cycle_usage.as_ref().map(|data| {
format_period_range(data.current_period_start, data.current_period_end)
})
})
.unwrap_or_default();
Text::new_inline(
label,
appearance.ui_font_family(),
appearance.ui_font_size(),
)
.with_color(theme.sub_text_color(theme.background()).into())
.finish()
}
fn render_period_selector(
&self,
workspace: &Workspace,
appearance: &Appearance,
) -> Box<dyn Element> {
let theme = appearance.theme();
let bg = theme.background();
let label = self
.current_summary(workspace)
.map(|s| format_period_range(s.period_start, s.period_end))
.unwrap_or_default();
let mouse_state = self.period_selector_mouse_state.clone();
let font_family = appearance.ui_font_family();
let font_size = appearance.ui_font_size();
let main_text = theme.sub_text_color(bg);
let button = Hoverable::new(mouse_state, move |_| {
let mut inner = Flex::row()
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.with_main_axis_size(MainAxisSize::Min);
inner.add_child(
Text::new_inline(label.clone(), font_family, font_size)
.with_color(main_text.into())
.finish(),
);
inner.add_child(
Container::new(
ConstrainedBox::new(Icon::ChevronDown.to_warpui_icon(main_text).finish())
.with_width(12.)
.with_height(12.)
.finish(),
)
.with_margin_left(4.)
.finish(),
);
inner.finish()
})
.with_cursor(Cursor::PointingHand)
.on_click(|ctx, _, _| {
ctx.dispatch_typed_action(BillingCycleUsageAction::TogglePeriodMenu);
})
.finish();
let mut stack = Stack::new();
stack.add_child(button);
if self.period_menu_open {
stack.add_positioned_overlay_child(
ChildView::new(&self.period_menu).finish(),
OffsetPositioning::offset_from_parent(
vec2f(0., 4.),
ParentOffsetBounds::WindowByPosition,
ParentAnchor::BottomRight,
ChildAnchor::TopRight,
),
);
}
stack.finish()
}
fn render_legend(
&self,
workspace: &Workspace,
appearance: &Appearance,
) -> Option<Box<dyn Element>> {
let summary = self.current_summary(workspace)?;
// Only list buckets that actually contribute to the stacked bars: drop
// legacy buckets and cost types with no usage, so the legend never
// shows a bucket (e.g. "Base") that has zero credits in the data.
let present_buckets = legend_cost_types(&summary.entries);
if present_buckets.is_empty() {
return None;
}
let mut row = Flex::row()
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.with_main_axis_size(MainAxisSize::Min);
for (idx, bucket) in present_buckets.iter().enumerate() {
if idx > 0 {
row.add_child(
Container::new(Empty::new().finish())
.with_margin_right(12.)
.finish(),
);
}
row.add_child(self.render_legend_entry(bucket.clone(), appearance));
}
Some(row.finish())
}
fn render_legend_entry(
&self,
cost_type: AiCreditsUsageAndCostType,
appearance: &Appearance,
) -> Box<dyn Element> {
let (color, label) = legend_style_for(cost_type.clone());
let theme = appearance.theme();
let entry = {
let mut row = Flex::row()
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.with_main_axis_size(MainAxisSize::Min);
row.add_child(
ConstrainedBox::new(
Container::new(Empty::new().finish())
.with_background_color(color)
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(
LEGEND_DOT_SIZE / 2.,
)))
.finish(),
)
.with_height(LEGEND_DOT_SIZE)
.with_width(LEGEND_DOT_SIZE)
.finish(),
);
row.add_child(
Container::new(
Text::new_inline(
label,
appearance.ui_font_family(),
appearance.ui_font_size(),
)
.with_color(theme.sub_text_color(theme.background()).into())
.finish(),
)
.with_margin_left(6.)
.finish(),
);
row.finish()
};
// The Aggregate bucket replaces per-cost-type detail with a single
// "Combined" row, which isn't self-explanatory; surface a small
// hover tooltip clarifying what it includes.
if !matches!(cost_type, AiCreditsUsageAndCostType::Aggregate) {
return entry;
}
let mouse_state = self.aggregate_legend_mouse_state.clone();
Hoverable::new(mouse_state, move |state| {
let mut stack = Stack::new();
stack.add_child(entry);
if state.is_hovered() {
stack.add_positioned_overlay_child(
render_aggregate_legend_tooltip(appearance),
OffsetPositioning::offset_from_parent(
vec2f(0., 6.),
ParentOffsetBounds::WindowByPosition,
ParentAnchor::BottomMiddle,
ChildAnchor::TopMiddle,
),
);
}
stack.finish()
})
.finish()
}
/// Renders the CTA banner that sits between the team-totals block and
/// the per-member rows. The copy and action vary by visibility tier:
/// non-FullBreakdown admins see an upgrade nudge; FullBreakdown admins
/// see a pointer to the admin panel where per-user spend limits actually
/// get configured.
fn render_visibility_cta_banner(
&self,
workspace: &Workspace,
appearance: &Appearance,
) -> Option<Box<dyn Element>> {
let admin_granularity = workspace
.billing_metadata
.tier
.usage_visibility_policy?
.admin_granularity;
if admin_granularity == UsageVisibilityGranularity::FullBreakdown
&& !workspace.billing_metadata.is_enterprise_plan()
{
return None;
}
let (link_text, trailing_copy, action, leading_icon) =
visibility_cta_for(admin_granularity)?;
// Only show when there are teammates -- a single-member workspace
// doesn't benefit from any of the team-level visibility CTAs.
if workspace.members.len() <= 1 {
return None;
}
let theme = appearance.theme();
let sub_text = theme.sub_text_color(theme.background());
let body = FormattedTextElement::new(
FormattedText::new([FormattedTextLine::Line(vec![
FormattedTextFragment::hyperlink_action(link_text, action),
FormattedTextFragment::plain_text(format!(" {trailing_copy}")),
])]),
appearance.ui_font_size(),
appearance.ui_font_family(),
appearance.ui_font_family(),
sub_text.into(),
HighlightedHyperlink::default(),
)
.with_hyperlink_font_color(theme.accent().into_solid())
.register_default_click_handlers_with_action_support(|lens, event, ctx| match lens {
HyperlinkLens::Url(u) => ctx.open_url(u),
HyperlinkLens::Action(a) => {
if let Some(act) = a.as_any().downcast_ref::<BillingCycleUsageAction>() {
event.dispatch_typed_action(act.clone());
}
}
})
.finish();
let icon = ConstrainedBox::new(leading_icon.to_warpui_icon(sub_text).finish())
.with_width(14.)
.with_height(14.)
.finish();
let row = Flex::row()
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.with_child(Container::new(icon).with_margin_right(8.).finish())
.with_child(body)
.finish();
Some(
Container::new(row)
.with_background_color(theme.surface_1().into_solid())
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(8.)))
.with_uniform_padding(12.)
.finish(),
)
}
}
/// Returns the (link text, trailing copy, action, icon) tuple for the
/// visibility CTA banner, or `None` to suppress the banner entirely.
fn visibility_cta_for(
granularity: UsageVisibilityGranularity,
) -> Option<(&'static str, &'static str, BillingCycleUsageAction, Icon)> {
match granularity {
UsageVisibilityGranularity::OwnOnly => Some((
"Upgrade to Build",
"to see team-level credit usage.",
BillingCycleUsageAction::OpenUpgrade,
Icon::ArrowCircleBrokenUp,
)),
UsageVisibilityGranularity::TeamAggregate => Some((
"Upgrade to Business",
"to see per-user credit attribution.",
BillingCycleUsageAction::OpenUpgrade,
Icon::ArrowCircleBrokenUp,
)),
UsageVisibilityGranularity::PerUserTotals => Some((
"Upgrade to Enterprise",
"to see fine-grained credit attribution and set per-user spend limits.",
BillingCycleUsageAction::OpenUpgrade,
Icon::ArrowCircleBrokenUp,
)),
// FullBreakdown viewers already have full visibility; nudge them to
// the admin panel where per-user spend limits actually get configured.
UsageVisibilityGranularity::FullBreakdown => Some((
"Open the admin panel",
"to set per-user spend limits.",
BillingCycleUsageAction::OpenAdminPanel,
Icon::Users,
)),
}
}
fn legend_style_for(cost_type: AiCreditsUsageAndCostType) -> (ColorU, &'static str) {
match cost_type {
AiCreditsUsageAndCostType::BaseLimit => (BASE_CREDITS_DOT_COLOR, "Base"),
AiCreditsUsageAndCostType::BonusGrant => (BONUS_CREDITS_DOT_COLOR, "Add-ons"),
AiCreditsUsageAndCostType::Payg => (PAYG_CREDITS_DOT_COLOR, "Pay-as-you-go"),
AiCreditsUsageAndCostType::AmbientBonusGrant => (AMBIENT_CREDITS_DOT_COLOR, "Cloud-only"),
AiCreditsUsageAndCostType::Aggregate => (AGGREGATE_CREDITS_DOT_COLOR, "Combined"),
AiCreditsUsageAndCostType::Other(_) => (BASE_CREDITS_DOT_COLOR, ""),
}
}
fn render_aggregate_legend_tooltip(appearance: &Appearance) -> Box<dyn Element> {
let theme = appearance.theme();
let text = Text::new_inline(
"Other team members' usage across add-on, pay-as-you-go, and cloud-only credits."
.to_string(),
appearance.ui_font_family(),
12.,
)
.with_color(theme.sub_text_color(theme.background()).into())
.finish();
Container::new(text)
.with_background_color(theme.background().into_solid())
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(6.)))
.with_border(Border::all(1.).with_border_color(theme.outline().into_solid()))
.with_horizontal_padding(12.)
.with_vertical_padding(6.)
.with_drop_shadow(
DropShadow::new_with_standard_offset_and_spread(ColorU::new(0, 0, 0, 48))
.with_offset(vec2f(0., 4.)),
)
.finish()
}
fn format_period_range(start: DateTime<Utc>, end: DateTime<Utc>) -> String {
let start = start.with_timezone(&Local);
let end = end.with_timezone(&Local);
if start.year() == end.year() {
format!("{} - {}", start.format("%b %d"), end.format("%b %d, %Y"))
} else {
format!(
"{} - {}",
start.format("%b %d, %Y"),
end.format("%b %d, %Y")
)
}
}
@@ -1,371 +0,0 @@
use galaxy_core::ui::appearance::Appearance;
use pathfinder_geometry::vector::vec2f;
use warpui::elements::{
Border, ChildAnchor, ConstrainedBox, Container, CornerRadius, CrossAxisAlignment, Empty,
Expanded, Flex, Hoverable, MainAxisAlignment, MainAxisSize, MouseStateHandle,
OffsetPositioning, ParentAnchor, ParentElement, ParentOffsetBounds, Radius, Shrinkable, Stack,
Text,
};
use warpui::fonts::{Properties, Weight};
use warpui::Element;
use crate::settings_view::billing_and_usage::billing_cycle_usage_common::{
aggregate_segments, cost_type_color, format_cost_cents, format_credits,
render_breakdown_tooltip, render_section_subheader, BarSegment, BillingUsageMouseStates,
ROW_BORDER_RADIUS, ROW_BORDER_WIDTH, TOOLTIP_GAP,
};
use crate::ui_components::blended_colors;
use crate::workspaces::workspace::{
AiCreditsUsageBucket, AiCreditsUsageSource, BillingCycleUsageEntry, UsageVisibility,
UsageVisibilityGranularity,
};
fn collapse_segments_to_cost_type(segments: Vec<BarSegment>) -> Vec<BarSegment> {
let mut out: Vec<BarSegment> = Vec::new();
for seg in segments {
if let Some(existing) = out.iter_mut().find(|s| s.cost_type == seg.cost_type) {
existing.credits += seg.credits;
existing.cost_cents += seg.cost_cents;
} else {
out.push(BarSegment {
cost_type: seg.cost_type,
usage_bucket: AiCreditsUsageBucket::Aggregate,
credits: seg.credits,
cost_cents: seg.cost_cents,
});
}
}
out
}
/// Pill-shaped bar at the bottom of each team-totals card.
const CARD_BAR_HEIGHT: f32 = 8.;
const CARD_BAR_RADIUS: f32 = CARD_BAR_HEIGHT / 2.;
/// Summary backing a single team-totals card (Overall / Local / Cloud).
#[derive(Debug)]
pub struct TeamTotalCardSummary {
pub title: &'static str,
pub card_key: &'static str,
pub segments: Vec<BarSegment>,
pub total_credits: i64,
pub total_cost_cents: i64,
pub limit_cents: Option<i64>,
}
pub fn build_team_total_card_summaries(
entries: &[BillingCycleUsageEntry],
visibility: &UsageVisibility,
) -> Vec<TeamTotalCardSummary> {
let (overall_segments, overall_credits, overall_cost) = aggregate_segments(entries.iter());
let mut summaries = vec![TeamTotalCardSummary {
title: "Overall usage",
card_key: "__card_overall__",
segments: overall_segments,
total_credits: overall_credits,
total_cost_cents: overall_cost,
limit_cents: None,
}];
let shows_per_source = matches!(
visibility.granularity,
UsageVisibilityGranularity::FullBreakdown
);
if shows_per_source {
let (local_segments, local_credits, local_cost) = aggregate_segments(
entries
.iter()
.filter(|e| e.usage_source == AiCreditsUsageSource::Local),
);
let (cloud_segments, cloud_credits, cloud_cost) = aggregate_segments(
entries
.iter()
.filter(|e| e.usage_source == AiCreditsUsageSource::Cloud),
);
summaries.push(TeamTotalCardSummary {
title: "Local agent usage",
card_key: "__card_local__",
segments: local_segments,
total_credits: local_credits,
total_cost_cents: local_cost,
limit_cents: None,
});
summaries.push(TeamTotalCardSummary {
title: "Cloud agent usage",
card_key: "__card_cloud__",
segments: cloud_segments,
total_credits: cloud_credits,
total_cost_cents: cloud_cost,
limit_cents: None,
});
}
// Visibility tiers below FullBreakdown don't expose per-bucket detail,
// so collapse bucket-dimensioned segments into single per-cost-type lines.
// Otherwise we get a "Base (AI)" row + a separate bare "Base" row in the team aggregate card.
if !matches!(
visibility.granularity,
UsageVisibilityGranularity::FullBreakdown
) {
for summary in &mut summaries {
summary.segments =
collapse_segments_to_cost_type(std::mem::take(&mut summary.segments));
}
}
summaries
}
fn render_card_pill_bar(
segments: &[BarSegment],
total_credits: i64,
total_cost_cents: i64,
limit_cents: Option<i64>,
appearance: &Appearance,
) -> Box<dyn Element> {
let theme = appearance.theme();
let track_bg = theme.surface_overlay_1();
let corner = Radius::Pixels(CARD_BAR_RADIUS);
if total_credits == 0 || segments.is_empty() {
return ConstrainedBox::new(
Container::new(Empty::new().finish())
.with_background(track_bg)
.with_corner_radius(CornerRadius::with_all(corner))
.finish(),
)
.with_height(CARD_BAR_HEIGHT)
.finish();
}
let fill_ratio = match limit_cents {
Some(limit) if limit > 0 => (total_cost_cents as f32 / limit as f32).clamp(0.0, 1.0),
_ => 1.0,
};
let unfill_ratio = 1.0 - fill_ratio;
let has_unfill = unfill_ratio > 0.0;
let last_segment_idx = segments.len() - 1;
let mut filled = Flex::row();
for (idx, seg) in segments.iter().enumerate() {
let weight = seg.credits as f32 / total_credits as f32;
if weight <= 0.0 {
continue;
}
let is_first = idx == 0;
let is_last_visible = idx == last_segment_idx && !has_unfill;
let segment_corner = match (is_first, is_last_visible) {
(true, true) => CornerRadius::with_all(corner),
(true, false) => CornerRadius::with_left(corner),
(false, true) => CornerRadius::with_right(corner),
(false, false) => CornerRadius::default(),
};
filled.add_child(
Expanded::new(
weight,
Container::new(Empty::new().finish())
.with_background_color(cost_type_color(&seg.cost_type))
.with_corner_radius(segment_corner)
.finish(),
)
.finish(),
);
}
let mut bar = Flex::row();
bar.add_child(Expanded::new(fill_ratio, filled.finish()).finish());
if has_unfill {
bar.add_child(
Expanded::new(
unfill_ratio,
Container::new(Empty::new().finish())
.with_background(track_bg)
.with_corner_radius(CornerRadius::with_right(corner))
.finish(),
)
.finish(),
);
}
ConstrainedBox::new(bar.finish())
.with_height(CARD_BAR_HEIGHT)
.finish()
}
/// Card body for one team-totals slice. Layout (top to bottom):
/// [title]
/// [$X.XX] [Limit: $Y.YY] (limit optional)
/// [(N credits)]
/// [pill stacked bar]
fn build_team_total_card(
summary: &TeamTotalCardSummary,
appearance: &Appearance,
) -> Box<dyn Element> {
let theme = appearance.theme();
let card_bg = theme.background().into_solid();
let main = blended_colors::text_main(theme, card_bg);
let sub = blended_colors::text_sub(theme, card_bg);
let title_text = Text::new_inline(summary.title.to_string(), appearance.ui_font_family(), 13.)
.with_color(sub)
.with_style(Properties::default().weight(Weight::Medium))
.finish();
let cost_text = Text::new_inline(
format_cost_cents(summary.total_cost_cents),
appearance.ui_font_family(),
24.,
)
.with_color(main)
.with_style(Properties::default().weight(Weight::Semibold))
.finish();
let credits_text = Text::new_inline(
format!("({} credits)", format_credits(summary.total_credits)),
appearance.ui_font_family(),
13.,
)
.with_color(sub)
.finish();
let totals_col = Flex::column()
.with_cross_axis_alignment(CrossAxisAlignment::Start)
.with_child(cost_text)
.with_child(Container::new(credits_text).with_margin_top(2.).finish())
.finish();
let totals_row: Box<dyn Element> = match summary.limit_cents {
Some(limit) => {
let limit_text = Text::new_inline(
format!("Limit: {}", format_cost_cents(limit)),
appearance.ui_font_family(),
12.,
)
.with_color(sub)
.finish();
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., totals_col).finish())
.with_child(Container::new(limit_text).with_margin_left(16.).finish())
.finish()
}
None => totals_col,
};
let bar = render_card_pill_bar(
&summary.segments,
summary.total_credits,
summary.total_cost_cents,
summary.limit_cents,
appearance,
);
let body = Flex::column()
.with_cross_axis_alignment(CrossAxisAlignment::Stretch)
.with_spacing(12.)
.with_child(
Flex::column()
.with_cross_axis_alignment(CrossAxisAlignment::Start)
.with_spacing(6.)
.with_child(title_text)
.with_child(totals_row)
.finish(),
)
.with_child(bar)
.finish();
Container::new(body)
.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)))
.with_uniform_padding(16.)
.finish()
}
fn render_team_total_card(
summary: &TeamTotalCardSummary,
tooltip_mouse_state: MouseStateHandle,
appearance: &Appearance,
) -> Box<dyn Element> {
if summary.segments.is_empty() {
return build_team_total_card(summary, appearance);
}
Hoverable::new(tooltip_mouse_state, move |state| {
let mut stack = Stack::new();
stack.add_child(build_team_total_card(summary, appearance));
if state.is_hovered() {
stack.add_positioned_overlay_child(
render_breakdown_tooltip(
&summary.segments,
summary.total_credits,
summary.total_cost_cents,
appearance,
),
OffsetPositioning::offset_from_parent(
vec2f(0., -TOOLTIP_GAP),
ParentOffsetBounds::WindowByPosition,
ParentAnchor::TopMiddle,
ChildAnchor::BottomMiddle,
),
);
}
stack.finish()
})
.finish()
}
/// Horizontal row of team-totals cards (Overall + Local + Cloud).
fn render_team_totals_section(
entries: &[BillingCycleUsageEntry],
visibility: &UsageVisibility,
mouse_states: &BillingUsageMouseStates,
appearance: &Appearance,
) -> Box<dyn Element> {
let summaries = build_team_total_card_summaries(entries, visibility);
let mut row = Flex::row()
.with_cross_axis_alignment(CrossAxisAlignment::Stretch)
.with_main_axis_size(MainAxisSize::Max)
.with_spacing(12.);
for summary in &summaries {
let tooltip_state = mouse_states.tooltip_mouse_state(summary.card_key);
row.add_child(
Expanded::new(
1.,
render_team_total_card(summary, tooltip_state, appearance),
)
.finish(),
);
}
row.finish()
}
/// "Team" subheader + cards
pub fn render_team_totals_block(
entries: &[BillingCycleUsageEntry],
visibility: &UsageVisibility,
mouse_states: &BillingUsageMouseStates,
appearance: &Appearance,
) -> Box<dyn Element> {
let mut column = Flex::column().with_cross_axis_alignment(CrossAxisAlignment::Stretch);
column.add_child(
Container::new(render_section_subheader("Team", appearance))
.with_margin_bottom(8.)
.finish(),
);
column.add_child(render_team_totals_section(
entries,
visibility,
mouse_states,
appearance,
));
column.finish()
}
#[cfg(test)]
#[path = "billing_cycle_usage_team_totals_tests.rs"]
mod tests;
@@ -1,95 +0,0 @@
use super::{build_team_total_card_summaries, TeamTotalCardSummary};
use crate::workspaces::workspace::{
AiCreditsUsageAndCostSubjectType, AiCreditsUsageAndCostType, AiCreditsUsageBucket,
AiCreditsUsageSource, BillingCycleUsageEntry, UsageVisibility, UsageVisibilityGranularity,
};
fn entry(
usage_source: AiCreditsUsageSource,
credits_used: i32,
cost_cents: i32,
) -> BillingCycleUsageEntry {
BillingCycleUsageEntry {
subject_type: AiCreditsUsageAndCostSubjectType::User,
subject_uid: Some("u".to_string()),
subject_display_name: None,
cost_type: AiCreditsUsageAndCostType::BaseLimit,
usage_bucket: AiCreditsUsageBucket::Ai,
usage_source,
credits_used,
cost_cents,
}
}
fn visibility(granularity: UsageVisibilityGranularity) -> UsageVisibility {
UsageVisibility {
granularity,
max_prior_cycles: Default::default(),
}
}
fn entries_two_per_source() -> Vec<BillingCycleUsageEntry> {
vec![
entry(AiCreditsUsageSource::Local, 30, 10),
entry(AiCreditsUsageSource::Cloud, 70, 25),
]
}
fn titles(summaries: &[TeamTotalCardSummary]) -> Vec<&'static str> {
summaries.iter().map(|s| s.title).collect()
}
#[test]
fn team_aggregate_visibility_yields_overall_card_only() {
// Server collapses teammates' usage into an `Aggregate`-source row under
// TeamAggregate, so the Local/Cloud split can't be honestly attributed
// — only the Overall card is meaningful.
let summaries = build_team_total_card_summaries(
&entries_two_per_source(),
&visibility(UsageVisibilityGranularity::TeamAggregate),
);
assert_eq!(titles(&summaries), vec!["Overall usage"]);
}
#[test]
fn own_only_visibility_yields_overall_card_only() {
// OwnOnly viewers don't normally render the team-totals block at all,
// but the builder should still degrade gracefully to a single card.
let summaries = build_team_total_card_summaries(
&entries_two_per_source(),
&visibility(UsageVisibilityGranularity::OwnOnly),
);
assert_eq!(titles(&summaries), vec!["Overall usage"]);
}
#[test]
fn per_user_totals_visibility_yields_overall_card_only() {
let summaries = build_team_total_card_summaries(
&entries_two_per_source(),
&visibility(UsageVisibilityGranularity::PerUserTotals),
);
assert_eq!(titles(&summaries), vec!["Overall usage"]);
}
#[test]
fn full_breakdown_visibility_returns_three_cards_with_partitioned_sums() {
let summaries = build_team_total_card_summaries(
&entries_two_per_source(),
&visibility(UsageVisibilityGranularity::FullBreakdown),
);
assert_eq!(
titles(&summaries),
vec!["Overall usage", "Local agent usage", "Cloud agent usage"]
);
// Overall = Local + Cloud; Local card = only Local entries; Cloud card =
// only Cloud entries. Distinct credits/cost per source catch any swapped
// filter.
assert_eq!(summaries[0].total_credits, 30 + 70);
assert_eq!(summaries[0].total_cost_cents, 10 + 25);
assert_eq!(summaries[1].total_credits, 30);
assert_eq!(summaries[1].total_cost_cents, 10);
assert_eq!(summaries[2].total_credits, 70);
assert_eq!(summaries[2].total_cost_cents, 25);
}
@@ -1,7 +0,0 @@
pub mod billing_cycle_usage_common;
pub mod billing_cycle_usage_rows;
pub mod billing_cycle_usage_section;
pub mod billing_cycle_usage_team_totals;
pub mod overage_limit_modal;
pub mod usage_history_entry;
pub mod usage_history_model;
@@ -1,352 +0,0 @@
use galaxyui::elements::{
Align, Border, ChildView, Clipped, Container, CornerRadius, CrossAxisAlignment, Expanded, Flex,
MouseStateHandle, Padding, ParentElement, Radius, Text,
};
use galaxyui::ui_components::button::ButtonVariant;
use galaxyui::ui_components::components::{Coords, UiComponent, UiComponentStyles};
use galaxyui::{
AppContext, Element, Entity, SingletonEntity, TypedActionView, View, ViewContext, ViewHandle,
};
use crate::editor::{EditorView, Event as EditorEvent, SingleLineEditorOptions, TextOptions};
use crate::Appearance;
const MAXIMUM_SPENDING_LIMIT_CENTS: u32 = 999999999;
pub struct SpendingLimitModal {
amount_editor: ViewHandle<EditorView>,
cancel_button_mouse_state: MouseStateHandle,
update_button_mouse_state: MouseStateHandle,
input_error_state: Option<SpendingLimitModalInputErrorState>,
}
#[derive(Debug, Clone)]
pub enum SpendingLimitModalEvent {
Close,
Update { amount_cents: u32 },
}
impl Entity for SpendingLimitModal {
type Event = SpendingLimitModalEvent;
}
#[derive(Debug, Clone)]
pub enum SpendingLimitModalAction {
Close,
Update,
}
pub enum SpendingLimitModalInputErrorState {
InvalidNumberFormat,
NumberOutOfRange,
}
impl SpendingLimitModal {
pub fn new(ctx: &mut ViewContext<Self>) -> Self {
let font_family = Appearance::as_ref(ctx).ui_font_family();
let amount_editor = ctx.add_typed_action_view(|ctx| {
let options = SingleLineEditorOptions {
text: TextOptions {
font_family_override: Some(font_family),
..Default::default()
},
..Default::default()
};
let mut editor = EditorView::single_line(options, ctx);
editor.set_placeholder_text("50.00", ctx);
editor
});
ctx.subscribe_to_view(&amount_editor, |me, _, event, ctx| {
me.handle_editor_event(event, ctx);
});
Self {
amount_editor,
cancel_button_mouse_state: MouseStateHandle::default(),
update_button_mouse_state: MouseStateHandle::default(),
input_error_state: None,
}
}
fn parse_amount(&self, app: &AppContext) -> Option<u32> {
let text = self.amount_editor.as_ref(app).buffer_text(app);
let text = text.trim();
if text.is_empty() {
return None;
}
let cleaned = text.strip_prefix('$').unwrap_or(text);
if let Ok(dollars) = cleaned.parse::<f64>() {
Some((dollars * 100.0).round() as u32)
} else {
None
}
}
fn validate_input(&mut self, ctx: &mut ViewContext<Self>) {
let text = self.amount_editor.as_ref(ctx).buffer_text(ctx);
if !self.is_valid_us_currency_format(&text) {
self.input_error_state = Some(SpendingLimitModalInputErrorState::InvalidNumberFormat);
} else if self
.parse_amount(ctx)
.is_some_and(|cents| !self.is_valid_number_range(cents))
{
self.input_error_state = Some(SpendingLimitModalInputErrorState::NumberOutOfRange);
} else {
self.input_error_state = None;
}
ctx.notify();
}
fn is_valid_number_range(&self, amount_cents: u32) -> bool {
if amount_cents < 1 {
return false;
}
if amount_cents > MAXIMUM_SPENDING_LIMIT_CENTS {
return false;
}
true
}
fn is_valid_us_currency_format(&self, text: &str) -> bool {
if text.is_empty() {
return true;
}
let cleaned = text.strip_prefix('$').unwrap_or(text);
let decimal_count = cleaned.chars().filter(|&c| c == '.').count();
if decimal_count > 1 {
return false;
}
if !cleaned.chars().all(|c| c.is_ascii_digit() || c == '.') {
return false;
}
if let Some(decimal_pos) = cleaned.find('.') {
let after_decimal = &cleaned[decimal_pos + 1..];
if after_decimal.len() > 2 {
return false;
}
}
true
}
pub fn update_amount_editor(&self, cents: u32, ctx: &mut ViewContext<Self>) {
let placeholder_text = format!("{:.2}", cents as f64 / 100.0);
self.amount_editor.update(ctx, |editor, ctx| {
editor.set_placeholder_text(&placeholder_text, ctx);
editor.clear_buffer(ctx);
});
ctx.notify();
}
fn error_text(&self) -> Option<String> {
match self.input_error_state {
Some(SpendingLimitModalInputErrorState::InvalidNumberFormat) => {
Some("Please enter a valid currency amount".to_string())
}
Some(SpendingLimitModalInputErrorState::NumberOutOfRange) => {
Some("Please enter a price between $0.01 and $10,000,000".to_string())
}
None => None,
}
}
pub fn focus_input(&self, ctx: &mut ViewContext<Self>) {
ctx.focus(&self.amount_editor);
}
fn handle_editor_event(&mut self, event: &EditorEvent, ctx: &mut ViewContext<Self>) {
match event {
EditorEvent::Enter => {
if self.input_error_state.is_none() {
if let Some(amount_cents) = self.parse_amount(ctx) {
ctx.emit(SpendingLimitModalEvent::Update { amount_cents });
}
}
}
EditorEvent::Escape => {
ctx.emit(SpendingLimitModalEvent::Close);
}
EditorEvent::Edited(_) => {
self.validate_input(ctx);
}
_ => {}
}
}
}
impl View for SpendingLimitModal {
fn ui_name() -> &'static str {
"SpendingLimitModal"
}
fn render(&self, app: &AppContext) -> Box<dyn Element> {
let appearance = Appearance::as_ref(app);
let theme = appearance.theme();
let description_text = Text::new(
"Warp will prevent use of premium models when this dollar limit is reached. Resets on a monthly basis.",
appearance.ui_font_family(),
14.,
)
.with_color(theme.sub_text_color(theme.surface_2()).into())
.finish();
let additional_note_text = Text::new(
"Note that AI credits made near your chosen limit may exceed it by a few dollars.",
appearance.ui_font_family(),
12.,
)
.with_color(theme.sub_text_color(theme.surface_2()).into())
.finish();
let description_section = Flex::column()
.with_child(
Container::new(description_text)
.with_margin_bottom(8.)
.finish(),
)
.with_child(additional_note_text)
.finish();
let input_row = Flex::row()
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.with_child(
Text::new("$", appearance.ui_font_family(), appearance.ui_font_size())
.with_color(theme.active_ui_text_color().into())
.finish(),
)
.with_child(
Expanded::new(
1.,
Container::new(
Clipped::new(ChildView::new(&self.amount_editor).finish()).finish(),
)
.with_margin_left(12.)
.finish(),
)
.finish(),
)
.finish();
let border_color = if self.input_error_state.is_some() {
theme.ui_error_color().into()
} else {
theme.outline()
};
let input_container = Container::new(input_row)
.with_padding(Padding::uniform(8.).with_left(16.).with_right(16.))
.with_border(Border::all(1.).with_border_fill(border_color))
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(4.)))
.finish();
let button_style = UiComponentStyles {
font_size: Some(14.),
padding: Some(Coords::uniform(8.).left(12.).right(12.)),
..Default::default()
};
let mut update_button = appearance
.ui_builder()
.button(
ButtonVariant::Accent,
self.update_button_mouse_state.clone(),
)
.with_text_label("Update".to_string())
.with_style(button_style);
if self.input_error_state.is_some() {
update_button = update_button.disabled();
}
let buttons_row = Flex::row()
.with_child(
appearance
.ui_builder()
.button(
ButtonVariant::Secondary,
self.cancel_button_mouse_state.clone(),
)
.with_text_label("Cancel".to_string())
.with_style(button_style)
.build()
.on_click(|ctx, _, _| {
ctx.dispatch_typed_action(SpendingLimitModalAction::Close);
})
.finish(),
)
.with_child(
Container::new(
update_button
.build()
.on_click(|ctx, _, _| {
ctx.dispatch_typed_action(SpendingLimitModalAction::Update);
})
.finish(),
)
.with_margin_left(12.)
.finish(),
)
.finish();
let mut main_column = Flex::column()
.with_child(
Container::new(description_section)
.with_margin_bottom(16.)
.finish(),
)
.with_child(
Container::new(input_container)
.with_margin_bottom(if self.input_error_state.is_some() {
8.
} else {
24.
})
.finish(),
);
if let Some(error_text) = self.error_text() {
let error_text = Text::new(error_text, appearance.ui_font_family(), 12.)
.with_color(theme.ui_error_color())
.finish();
main_column =
main_column.with_child(Container::new(error_text).with_margin_bottom(24.).finish());
}
main_column
.with_child(Align::new(buttons_row).right().finish())
.finish()
}
}
impl TypedActionView for SpendingLimitModal {
type Action = SpendingLimitModalAction;
fn handle_action(&mut self, action: &Self::Action, ctx: &mut ViewContext<Self>) {
match action {
SpendingLimitModalAction::Close => {
self.amount_editor.update(ctx, |editor, ctx| {
editor.clear_buffer(ctx);
});
ctx.emit(SpendingLimitModalEvent::Close);
}
SpendingLimitModalAction::Update => {
if let Some(amount_cents) = self.parse_amount(ctx) {
ctx.emit(SpendingLimitModalEvent::Update { amount_cents });
}
}
}
}
}
@@ -1,224 +0,0 @@
use chrono::Local;
use galaxy_core::ui::appearance::Appearance;
use galaxy_graphql::queries::get_conversation_usage::ConversationUsage;
use galaxyui::elements::{
Border, ConstrainedBox, Container, CornerRadius, CrossAxisAlignment, Empty, Flex, Hoverable,
MainAxisAlignment, MainAxisSize, MouseStateHandle, ParentElement, Radius, Shrinkable, Text,
};
use warpui::platform::Cursor;
use warpui::{AppContext, Element, View};
use crate::ai::blocklist::format_credits;
use crate::ai::blocklist::usage::conversation_usage_view::{
ConversationUsageInfo, ConversationUsageView, DisplayMode,
};
use crate::settings_view::billing_and_usage_page::BillingAndUsagePageAction;
use crate::ui_components::blended_colors;
use crate::ui_components::icons::Icon;
pub struct UsageHistoryEntry {
// If no entry is provided, we will assume that this is a placeholder entry
// to display in the loading UI.
entry: Option<ConversationUsage>,
is_expanded: bool,
mouse_state: Option<MouseStateHandle>,
tooltip_mouse_state: MouseStateHandle,
}
impl UsageHistoryEntry {
pub fn new(
entry: Option<ConversationUsage>,
is_expanded: bool,
mouse_state: Option<MouseStateHandle>,
tooltip_mouse_state: MouseStateHandle,
) -> Self {
Self {
entry,
mouse_state,
is_expanded,
tooltip_mouse_state,
}
}
pub fn render(&self, appearance: &Appearance, app: &AppContext) -> Box<dyn Element> {
let mut res = Flex::column()
.with_cross_axis_alignment(CrossAxisAlignment::Stretch)
.with_child(self.render_header(appearance));
if let Some(entry) = &self.entry {
if self.is_expanded {
res = res
.with_child(
// Separator between header and usage component
Container::new(Empty::new().finish())
.with_border(
Border::top(2.0).with_border_fill(appearance.theme().outline()),
)
.with_overdraw_bottom(0.)
.finish(),
)
.with_child(
ConversationUsageView::new(
ConversationUsageInfo::from(entry),
DisplayMode::Settings,
None,
self.tooltip_mouse_state.clone(),
)
.render(app),
);
}
}
Container::new(res.finish())
.with_border(Border::all(2.).with_border_fill(appearance.theme().surface_3()))
.with_background(appearance.theme().surface_2())
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(8.)))
.finish()
}
fn render_header(&self, appearance: &Appearance) -> Box<dyn Element> {
let Some(entry) = &self.entry else {
return self.render_loading_entry(appearance);
};
let Some(mouse_state) = &self.mouse_state else {
// If there is a provided entry, there should always be a mouse state as well.
log::error!("Mouse state is required to render usage history entry header");
return Empty::new().finish();
};
let title_text = Text::new_inline(entry.title.clone(), appearance.ui_font_family(), 14.)
.with_color(
appearance
.theme()
.main_text_color(appearance.theme().surface_2())
.into(),
)
.finish();
let formatted_time = entry
.last_updated
.utc()
.with_timezone(&Local)
.format("%-m/%-d/%y %-I:%M %p")
.to_string();
let time_text = Text::new_inline(formatted_time, appearance.ui_font_family(), 12.)
.with_color(blended_colors::text_sub(
appearance.theme(),
appearance.theme().surface_1(),
))
.finish();
let total_credits =
entry.usage_metadata.credits_spent + entry.usage_metadata.platform_credits_spent;
let credits_spent = Text::new_inline(
format_credits(total_credits as f32),
appearance.ui_font_family(),
14.,
)
.with_color(blended_colors::text_sub(
appearance.theme(),
appearance.theme().surface_1(),
))
.finish();
let chevron_icon = if self.is_expanded {
Icon::ChevronDown
} else {
Icon::ChevronRight
};
let chevron = ConstrainedBox::new(
chevron_icon
.to_galaxyui_icon(appearance.theme().foreground())
.finish(),
)
.with_width(16.)
.with_height(16.)
.finish();
let header_row = Container::new(
Flex::row()
.with_child(
Shrinkable::new(
1.,
Container::new(
Flex::column()
.with_child(title_text)
.with_child(Container::new(time_text).with_margin_top(4.).finish())
.with_cross_axis_alignment(CrossAxisAlignment::Start)
.finish(),
)
.finish(),
)
.finish(),
)
.with_child(
Flex::row()
.with_child(credits_spent)
.with_child(chevron)
.with_cross_axis_alignment(CrossAxisAlignment::Start)
.finish(),
)
.with_cross_axis_alignment(CrossAxisAlignment::Start)
.with_main_axis_alignment(MainAxisAlignment::SpaceBetween)
.with_main_axis_size(MainAxisSize::Max)
.finish(),
)
.with_uniform_padding(12.)
.with_corner_radius(if self.is_expanded {
CornerRadius::with_top(Radius::Pixels(6.))
} else {
CornerRadius::with_all(Radius::Pixels(6.))
})
.with_background(appearance.theme().surface_2())
.finish();
let conversation_id = entry.conversation_id.clone();
Hoverable::new(mouse_state.clone(), |_| header_row)
.with_cursor(Cursor::PointingHand)
.on_click(move |ctx, _, _| {
ctx.dispatch_typed_action(BillingAndUsagePageAction::ToggleUsageEntryExpanded {
conversation_id: conversation_id.clone(),
});
})
.finish()
}
/// Render a placeholder entry for the loading state
fn render_loading_entry(&self, appearance: &Appearance) -> Box<dyn Element> {
let left_side = Flex::column()
.with_child(self.render_empty_text_placeholder(360., 16., appearance))
.with_child(self.render_empty_text_placeholder(160., 12., appearance))
.with_spacing(4.)
.with_cross_axis_alignment(CrossAxisAlignment::Start)
.finish();
Container::new(
Flex::row()
.with_child(left_side)
.with_child(self.render_empty_text_placeholder(52., 16., appearance))
.with_cross_axis_alignment(CrossAxisAlignment::Start)
.with_main_axis_alignment(MainAxisAlignment::SpaceBetween)
.with_main_axis_size(MainAxisSize::Max)
.finish(),
)
.with_uniform_padding(12.)
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(6.)))
.with_background(appearance.theme().surface_2())
.finish()
}
/// Renders an empty rectangle to represent loading text
fn render_empty_text_placeholder(
&self,
width: f32,
height: f32,
appearance: &Appearance,
) -> Box<dyn Element> {
Container::new(Empty::new().finish())
.with_padding_left(width)
.with_padding_top(height)
.with_background(appearance.theme().surface_3())
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(4.)))
.finish()
}
}
@@ -1,140 +0,0 @@
use std::sync::Arc;
use galaxy_core::report_error;
use galaxy_graphql::scalars::Time;
use galaxyui::{Entity, ModelContext, SingletonEntity};
use crate::auth::AuthStateProvider;
use crate::server::server_api::ai::AIClient;
use crate::server::server_api::ServerApiProvider;
const PAGE_SIZE: i32 = 20;
pub struct UsageHistoryModel {
ai_client: Arc<dyn AIClient>,
entries: Vec<galaxy_graphql::queries::get_conversation_usage::ConversationUsage>,
is_loading: bool,
// Whether the server indicated that there may be more entries to load.
has_more_entries: bool,
}
impl Entity for UsageHistoryModel {
type Event = ();
}
impl SingletonEntity for UsageHistoryModel {}
impl UsageHistoryModel {
pub fn new(ctx: &mut ModelContext<Self>) -> Self {
let ai_client = ServerApiProvider::as_ref(ctx).get_ai_client();
Self {
ai_client,
entries: Vec::new(),
is_loading: false,
has_more_entries: true,
}
}
pub fn entries(&self) -> &[galaxy_graphql::queries::get_conversation_usage::ConversationUsage] {
&self.entries
}
pub fn is_loading(&self) -> bool {
self.is_loading
}
pub fn has_more_entries(&self) -> bool {
self.has_more_entries
}
/// Fetches conversation usage over the past 30 days.
/// If some usage has already been loaded, this fetches the same number of entries.
/// If no usage has been loaded, this fetches PAGE_SIZE entries.
pub fn refresh_usage_history_async(&mut self, ctx: &mut ModelContext<Self>) {
if self.is_loading || !AuthStateProvider::as_ref(ctx).get().is_logged_in() {
return;
}
// If the user has already loaded some number of entries,
// we should load that same number of items on refresh so that the list doesn't shrink
// every time the page is refreshed.
let num_items_to_fetch = if self.entries.is_empty() {
PAGE_SIZE
} else {
self.entries.len() as i32
};
// Reset pagination state and clear any existing entries.
self.entries.clear();
self.has_more_entries = true;
self.fetch_next_page(num_items_to_fetch, None, ctx);
}
/// Fetches the next page of conversation usage entries, appending them to the existing list.
pub fn load_more_usage_history_async(&mut self, ctx: &mut ModelContext<Self>) {
if self.is_loading || !self.has_more_entries {
return;
}
let last_updated_end_timestamp: Option<Time> =
self.entries.last().map(|entry| entry.last_updated);
if last_updated_end_timestamp.is_none() {
return;
}
self.fetch_next_page(PAGE_SIZE, last_updated_end_timestamp, ctx);
}
/// Fetches the next page of conversation usage entries, appending them to the existing list.
/// last_updated_end_timestamp is the timestamp of the last entry in the existing list,
/// and is used to paginate the results and only return entries that we don't already have.
fn fetch_next_page(
&mut self,
limit: i32,
last_updated_end_timestamp: Option<Time>,
ctx: &mut ModelContext<Self>,
) {
// If no time stamp is provided for pagination, we can assume that this is the first page of results.
let is_initial_load = last_updated_end_timestamp.is_none();
let ai_client = self.ai_client.clone();
if is_initial_load {
self.is_loading = true;
ctx.notify();
}
ctx.spawn(
async move {
ai_client
.get_conversation_usage_history(
Some(30),
Some(limit),
last_updated_end_timestamp,
)
.await
},
move |me, result, ctx| {
me.is_loading = false;
match result {
Ok(entries) => {
let fetched_count = entries.len() as i32;
// If we received fewer than requested, assume there are no more entries.
me.has_more_entries = fetched_count == limit;
if !is_initial_load {
me.entries.extend(entries);
} else {
me.entries = entries;
}
}
Err(e) => {
report_error!(e.context("Failed to fetch conversation usage"));
}
}
ctx.notify();
},
);
}
}
@@ -1,167 +0,0 @@
//! Dispatch wrapper that routes between the legacy and v2 billing & usage
//! pages.
use galaxy_core::features::FeatureFlag;
use galaxy_core::ui::appearance::Appearance;
use warpui::elements::{ChildView, Container};
use warpui::{AppContext, Element, Entity, SingletonEntity, View, ViewContext, ViewHandle};
use super::billing_and_usage_page::{BillingAndUsagePageEvent, BillingAndUsagePageView};
use super::billing_and_usage_page_v2::BillingAndUsagePageV2View;
use super::settings_page::{
MatchData, PageType, SettingsPageMeta, SettingsPageViewHandle, SettingsWidget, HEADER_PADDING,
};
use super::SettingsSection;
use crate::auth::{AuthManager, AuthStateProvider};
use crate::workspaces::user_workspaces::UserWorkspaces;
use crate::workspaces::workspace::Workspace;
pub struct BillingAndUsageDispatchView {
page: PageType<Self>,
v1: ViewHandle<BillingAndUsagePageView>,
v2: ViewHandle<BillingAndUsagePageV2View>,
}
impl BillingAndUsageDispatchView {
pub fn new(ctx: &mut ViewContext<Self>) -> Self {
let v1 = ctx.add_typed_action_view(BillingAndUsagePageView::new);
let v2 = ctx.add_typed_action_view(BillingAndUsagePageV2View::new);
// Both children stay alive; only forward events from the active one
// to avoid duplicate toasts.
ctx.subscribe_to_view(&v1, |this, _, event, ctx| {
if !this.use_v2(ctx) {
ctx.emit(event.clone());
}
});
ctx.subscribe_to_view(&v2, |this, _, event, ctx| {
if this.use_v2(ctx) {
ctx.emit(event.clone());
}
});
ctx.subscribe_to_model(&UserWorkspaces::handle(ctx), |_, _, _, ctx| {
ctx.notify();
});
ctx.subscribe_to_model(&AuthManager::handle(ctx), |_, _, _, ctx| {
ctx.notify();
});
let page = PageType::new_monolith(BillingAndUsageWidget, Some("Billing and Usage"), true);
Self { page, v1, v2 }
}
fn use_v2(&self, ctx: &AppContext) -> bool {
if !FeatureFlag::BillingAndUsagePageV2.is_enabled() {
return false;
}
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<Box<dyn Element>> {
if self.use_v2(app) {
self.v2.read(app, |view, _| view.get_modal_content())
} else {
self.v1.read(app, |view, _| view.get_modal_content())
}
}
}
#[cfg(test)]
#[path = "billing_and_usage_dispatch_tests.rs"]
mod tests;
impl Entity for BillingAndUsageDispatchView {
type Event = BillingAndUsagePageEvent;
}
impl View for BillingAndUsageDispatchView {
fn ui_name() -> &'static str {
"Billing and usage"
}
fn render(&self, app: &AppContext) -> Box<dyn Element> {
self.page.render(self, app)
}
}
impl SettingsPageMeta for BillingAndUsageDispatchView {
fn section() -> SettingsSection {
SettingsSection::BillingAndUsage
}
fn should_render(&self, ctx: &AppContext) -> bool {
!AuthStateProvider::as_ref(ctx)
.get()
.is_anonymous_or_logged_out()
}
fn on_page_selected(&mut self, allow_steal_focus: bool, ctx: &mut ViewContext<Self>) {
if self.use_v2(ctx) {
self.v2.update(ctx, |view, ctx| {
view.on_page_selected(allow_steal_focus, ctx)
});
} else {
self.v1.update(ctx, |view, ctx| {
view.on_page_selected(allow_steal_focus, ctx)
});
}
}
fn update_filter(&mut self, query: &str, ctx: &mut ViewContext<Self>) -> MatchData {
self.page.update_filter(query, ctx)
}
fn scroll_to_widget(&mut self, widget_id: &'static str) {
self.page.scroll_to_widget(widget_id);
}
fn clear_highlighted_widget(&mut self) {
self.page.clear_highlighted_widget();
}
}
impl From<ViewHandle<BillingAndUsageDispatchView>> for SettingsPageViewHandle {
fn from(view_handle: ViewHandle<BillingAndUsageDispatchView>) -> Self {
SettingsPageViewHandle::BillingAndUsage(view_handle)
}
}
#[derive(Default)]
struct BillingAndUsageWidget;
impl SettingsWidget for BillingAndUsageWidget {
type View = BillingAndUsageDispatchView;
fn search_terms(&self) -> &str {
"plan billing a.i. ai usage limit credits balance overview"
}
fn render(
&self,
view: &Self::View,
_appearance: &Appearance,
app: &AppContext,
) -> Box<dyn Element> {
let inner = if view.use_v2(app) {
ChildView::new(&view.v2).finish()
} else {
ChildView::new(&view.v1).finish()
};
Container::new(inner)
.with_margin_top(HEADER_PADDING)
.finish()
}
}
@@ -1,48 +0,0 @@
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
)));
}
File diff suppressed because it is too large Load Diff
@@ -1,146 +0,0 @@
use super::{sort_user_items_in_place, SortKey, SortOrder, UserSortingCriteria};
#[test]
pub fn test_default_sorting_pins_current_user_first_then_display_name_asc() {
let mut items = vec![
UserSortingCriteria::new("Zed".to_string(), 10, ()),
UserSortingCriteria::new("Alice".to_string(), 5, ()),
UserSortingCriteria::new("Bob".to_string(), 15, ()),
];
sort_user_items_in_place(&mut items, "Bob", None, SortOrder::Asc);
// Expected: Bob (current user) first, then Alice, then Zed (by display name asc)
assert_eq!(items[0].display_name, "Bob");
assert_eq!(items[1].display_name, "Alice");
assert_eq!(items[2].display_name, "Zed");
}
#[test]
fn test_display_name_az_sorting_pins_current_user() {
let mut items = vec![
UserSortingCriteria::new("Zed".to_string(), 10, ()),
UserSortingCriteria::new("Alice".to_string(), 5, ()),
UserSortingCriteria::new("Bob".to_string(), 15, ()),
UserSortingCriteria::new("charlie@example.com".to_string(), 8, ()), // Using email as display name fallback
];
sort_user_items_in_place(
&mut items,
"Bob",
Some(SortKey::DisplayName),
SortOrder::Asc,
);
// Expected: Bob (current user) first, then Alice, charlie@ (fallback to email), Zed
assert_eq!(items[0].display_name, "Bob");
assert_eq!(items[1].display_name, "Alice");
assert_eq!(items[2].display_name, "charlie@example.com"); // Email as display name fallback
assert_eq!(items[3].display_name, "Zed");
}
#[test]
fn test_display_name_za_sorting_pins_current_user() {
let mut items = vec![
UserSortingCriteria::new("Zed".to_string(), 10, ()),
UserSortingCriteria::new("Alice".to_string(), 5, ()),
UserSortingCriteria::new("Bob".to_string(), 15, ()),
];
sort_user_items_in_place(
&mut items,
"Alice",
Some(SortKey::DisplayName),
SortOrder::Desc,
);
// Expected: Alice (current user) first, then Zed, Bob (by name desc)
assert_eq!(items[0].display_name, "Alice");
assert_eq!(items[1].display_name, "Zed");
assert_eq!(items[2].display_name, "Bob");
}
#[test]
fn test_requests_usage_desc_sorting_pins_current_user_with_display_name_tie_breaker() {
let mut items = vec![
UserSortingCriteria::new("Alice".to_string(), 10, ()),
UserSortingCriteria::new("Bob".to_string(), 15, ()),
UserSortingCriteria::new("Charlie".to_string(), 10, ()), // Same usage as Alice
UserSortingCriteria::new("Diana".to_string(), 5, ()),
];
sort_user_items_in_place(
&mut items,
"Diana",
Some(SortKey::Requests),
SortOrder::Desc,
);
// Expected: Diana (current user) first, then Bob (15), then Alice/Charlie by name (10 tie)
assert_eq!(items[0].display_name, "Diana");
assert_eq!(items[1].display_name, "Bob"); // Highest usage (15)
assert_eq!(items[2].display_name, "Alice"); // Tied at 10, "Alice" < "Charlie"
assert_eq!(items[3].display_name, "Charlie");
}
#[test]
fn test_requests_usage_asc_sorting_pins_current_user_with_display_name_tie_breaker() {
let mut items = vec![
UserSortingCriteria::new("Alice".to_string(), 10, ()),
UserSortingCriteria::new("Bob".to_string(), 15, ()),
UserSortingCriteria::new("Charlie".to_string(), 10, ()), // Same usage as Alice
UserSortingCriteria::new("Diana".to_string(), 5, ()),
];
sort_user_items_in_place(&mut items, "Bob", Some(SortKey::Requests), SortOrder::Asc);
// Expected: Bob (current user) first, then Diana (5), then Alice/Charlie by name (10 tie)
assert_eq!(items[0].display_name, "Bob");
assert_eq!(items[1].display_name, "Diana"); // Lowest usage (5)
assert_eq!(items[2].display_name, "Alice"); // Tied at 10, "Alice" < "Charlie"
assert_eq!(items[3].display_name, "Charlie");
}
#[test]
fn test_display_name_az_sorting_with_emails() {
let mut items = vec![
UserSortingCriteria::new("zuser@example.com".to_string(), 10, ()),
UserSortingCriteria::new("Alice".to_string(), 5, ()),
UserSortingCriteria::new("buser@example.com".to_string(), 15, ()),
];
sort_user_items_in_place(
&mut items,
"Alice",
Some(SortKey::DisplayName),
SortOrder::Asc,
);
// Expected: Alice (current user) first, then buser@... < zuser@... (by email fallback)
assert_eq!(items[0].display_name, "Alice");
assert_eq!(items[1].display_name, "buser@example.com"); // Email as display name
assert_eq!(items[2].display_name, "zuser@example.com"); // Email as display name
}
#[test]
fn test_case_insensitive_display_name_sorting() {
let mut items = vec![
UserSortingCriteria::new("alice".to_string(), 10, ()),
UserSortingCriteria::new("Bob".to_string(), 5, ()),
UserSortingCriteria::new("CHARLIE".to_string(), 8, ()),
UserSortingCriteria::new("Diana".to_string(), 12, ()),
];
sort_user_items_in_place(
&mut items,
"Diana",
Some(SortKey::DisplayName),
SortOrder::Asc,
);
// Expected: Diana (current user) first, then alice, Bob, CHARLIE (case-insensitive asc)
assert_eq!(items[0].display_name, "Diana");
assert_eq!(items[1].display_name, "alice"); // "alice" (lowercase)
assert_eq!(items[2].display_name, "Bob"); // "Bob"
assert_eq!(items[3].display_name, "CHARLIE"); // "CHARLIE"
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -1,401 +0,0 @@
use ai::api_keys::CustomEndpointModel;
use pathfinder_geometry::vector::vec2f;
use warpui::platform::WindowStyle;
use warpui::scene::Scene;
use warpui::units::Pixels;
use warpui::{App, EntityIdSet, Presenter, WindowInvalidation};
use super::*;
use crate::test_util::terminal::initialize_app_for_terminal_view;
fn endpoint_with_models(model_count: usize) -> CustomEndpoint {
CustomEndpoint {
name: "Test endpoint".to_string(),
url: "https://api.example.com/v1".to_string(),
api_key: "key".to_string(),
models: (0..model_count)
.map(|index| CustomEndpointModel {
name: format!("model-{index}"),
alias: None,
config_key: format!("config-{index}"),
})
.collect(),
}
}
fn init_modal_test_models(app: &mut App) {
initialize_app_for_terminal_view(app);
}
fn custom_endpoint_modal_height(scene: &Scene) -> f32 {
let rects = scene
.layers()
.flat_map(|layer| &layer.rects)
.map(|rect| (rect.bounds.width(), rect.bounds.height(), rect.border.width))
.collect::<Vec<_>>();
rects
.iter()
.filter(|(width, _, _)| *width > INPUT_WIDTH && *width <= 560.)
.map(|(_, height, _)| *height)
.max_by(f32::total_cmp)
.unwrap_or_else(|| panic!("custom endpoint modal rect should exist: {rects:?}"))
}
#[test]
fn modal_resizes_with_window_and_added_models() {
App::test((), |mut app| async move {
init_modal_test_models(&mut app);
let endpoint = endpoint_with_models(1);
let (window_id, modal) = app.add_window(WindowStyle::NotStealFocus, move |ctx| {
let body = ctx.add_typed_action_view(|ctx| {
CustomEndpointModal::new(Some(&endpoint), Some(0), ctx)
});
Modal::new(Some("Edit custom endpoint".to_string()), body, ctx)
.with_modal_style(UiComponentStyles {
width: Some(560.),
..Default::default()
})
.with_max_height_percentage(0.8)
});
let body = modal.read(&app, |modal, _| modal.body().clone());
let mut presenter = Presenter::new(window_id);
let invalidation = WindowInvalidation {
updated: EntityIdSet::from_iter([
app.root_view_id(window_id).expect("root view should exist"),
body.id(),
]),
..Default::default()
};
app.update(move |ctx| {
presenter.invalidate(invalidation.clone(), ctx);
let initial_modal_height = {
let scene = presenter.build_scene(vec2f(800., 1000.), 1., None, ctx);
custom_endpoint_modal_height(&scene)
};
body.update(ctx, |body, ctx| {
for _ in 0..20 {
body.add_model(ctx);
}
});
presenter.invalidate(invalidation, ctx);
let expanded_modal_height = {
let scene = presenter.build_scene(vec2f(800., 1000.), 1., None, ctx);
custom_endpoint_modal_height(&scene)
};
let small_window_height = {
let scene = presenter.build_scene(vec2f(800., 500.), 1., None, ctx);
custom_endpoint_modal_height(&scene)
};
assert!(
expanded_modal_height > initial_modal_height,
"expanded modal height {expanded_modal_height} should be greater than initial modal height {initial_modal_height}"
);
assert!(
(expanded_modal_height - 765.).abs() < 0.1,
"expanded modal height {expanded_modal_height} should reach the 80% window-height cap"
);
assert!(
small_window_height < expanded_modal_height,
"small modal height {small_window_height} should be less than expanded modal height {expanded_modal_height}"
);
assert!(
(small_window_height - 365.).abs() < 0.1,
"small modal height {small_window_height} should reach the 80% window-height cap"
);
});
})
}
#[test]
fn modal_with_many_models_lays_out() {
App::test((), |mut app| async move {
init_modal_test_models(&mut app);
let endpoint = endpoint_with_models(20);
let (window_id, modal) = app.add_window(WindowStyle::NotStealFocus, move |ctx| {
CustomEndpointModal::new(Some(&endpoint), Some(0), ctx)
});
app.update(|ctx| {
ctx.presenter(window_id)
.expect("presenter should exist")
.borrow_mut()
.build_scene(vec2f(560., 600.), 1., None, ctx);
assert_eq!(modal.as_ref(ctx).model_rows.len(), 20);
});
})
}
#[test]
fn model_row_inputs_align_and_controls_fit_gutter() {
assert_eq!(MODEL_INPUT_WIDTH * 2. + MODEL_ROW_SPACING, INPUT_WIDTH);
// SCROLL_CONTENT_RIGHT_MARGIN already includes MODAL_SCROLLBAR_WIDTH, so the
// right gutter (button spacing + remove-button column + content right margin)
// is 56 without adding the scrollbar width again.
assert_eq!(
REMOVE_MODEL_BUTTON_SPACING + REMOVE_MODEL_BUTTON_COL_WIDTH + SCROLL_CONTENT_RIGHT_MARGIN,
56.
);
}
#[test]
fn action_row_remains_fixed_when_form_scrolls() {
App::test((), |mut app| async move {
init_modal_test_models(&mut app);
let endpoint = endpoint_with_models(20);
let (window_id, modal) = app.add_window(WindowStyle::NotStealFocus, move |ctx| {
let body = ctx.add_typed_action_view(|ctx| {
CustomEndpointModal::new(Some(&endpoint), Some(0), ctx)
});
Modal::new(Some("Edit custom endpoint".to_string()), body, ctx)
.with_modal_style(UiComponentStyles {
width: Some(560.),
..Default::default()
})
.with_max_height_percentage(0.8)
});
let body = modal.read(&app, |modal, _| modal.body().clone());
let invalidation = WindowInvalidation {
updated: EntityIdSet::from_iter([
app.root_view_id(window_id).expect("root view should exist"),
body.id(),
]),
..Default::default()
};
let action_row_position = app.update(|ctx| {
let presenter = ctx.presenter(window_id).expect("presenter should exist");
let mut presenter = presenter.borrow_mut();
presenter.invalidate(invalidation.clone(), ctx);
presenter.build_scene(vec2f(560., 600.), 1., None, ctx);
presenter
.position_cache()
.get_position(ACTIONS_POSITION_ID)
.expect("action row position should exist")
});
body.update(&mut app, |body, ctx| {
body.scroll_state.scroll_to(Pixels::new(f32::MAX));
ctx.notify();
});
let scrolled_action_row_position = app.update(|ctx| {
let presenter = ctx.presenter(window_id).expect("presenter should exist");
let mut presenter = presenter.borrow_mut();
presenter.invalidate(invalidation, ctx);
presenter.build_scene(vec2f(560., 600.), 1., None, ctx);
presenter
.position_cache()
.get_position(ACTIONS_POSITION_ID)
.expect("action row position should exist")
});
assert!(body.read(&app, |body, _| body.scroll_state.scroll_start()) > Pixels::zero());
assert_eq!(
action_row_position, scrolled_action_row_position,
"action row should remain fixed while form content scrolls"
);
})
}
#[test]
fn focus_editor_scrolls_whole_form_to_field() {
App::test((), |mut app| async move {
init_modal_test_models(&mut app);
let endpoint = endpoint_with_models(20);
let (window_id, modal) = app.add_window(WindowStyle::NotStealFocus, move |ctx| {
CustomEndpointModal::new(Some(&endpoint), Some(0), ctx)
});
app.update(|ctx| {
ctx.presenter(window_id)
.expect("presenter should exist")
.borrow_mut()
.build_scene(vec2f(560., 600.), 1., None, ctx);
});
modal.update(&mut app, |modal, ctx| {
let editor = modal
.model_rows
.last()
.expect("model row should exist")
.name_editor
.clone();
modal.focus_editor(&editor, ctx);
});
app.update(|ctx| {
ctx.presenter(window_id)
.expect("presenter should exist")
.borrow_mut()
.build_scene(vec2f(560., 600.), 1., None, ctx);
assert!(modal.as_ref(ctx).scroll_state.scroll_start() > Pixels::zero());
});
let model_scroll_start = modal.read(&app, |modal, _| modal.scroll_state.scroll_start());
modal.update(&mut app, |modal, ctx| {
modal.focus_editor(&modal.endpoint_name_editor.clone(), ctx);
});
app.update(|ctx| {
ctx.presenter(window_id)
.expect("presenter should exist")
.borrow_mut()
.build_scene(vec2f(560., 600.), 1., None, ctx);
assert!(modal.as_ref(ctx).scroll_state.scroll_start() < model_scroll_start);
});
})
}
#[test]
fn add_model_scrolls_only_after_form_is_full() {
App::test((), |mut app| async move {
init_modal_test_models(&mut app);
let endpoint = endpoint_with_models(1);
let (window_id, modal) = app.add_window(WindowStyle::NotStealFocus, move |ctx| {
CustomEndpointModal::new(Some(&endpoint), Some(0), ctx)
});
modal.update(&mut app, |modal, ctx| modal.add_model(ctx));
app.update(|ctx| {
ctx.presenter(window_id)
.expect("presenter should exist")
.borrow_mut()
.build_scene(vec2f(560., 600.), 1., None, ctx);
assert_eq!(
modal.as_ref(ctx).scroll_state.scroll_start(),
Pixels::zero()
);
});
modal.update(&mut app, |modal, ctx| {
for _ in 0..20 {
modal.add_model(ctx);
}
assert_eq!(modal.scroll_state.scroll_start(), Pixels::new(f32::MAX));
});
app.update(|ctx| {
ctx.presenter(window_id)
.expect("presenter should exist")
.borrow_mut()
.build_scene(vec2f(560., 600.), 1., None, ctx);
let scroll_start = modal.as_ref(ctx).scroll_state.scroll_start();
assert!(scroll_start > Pixels::zero());
assert!(scroll_start < Pixels::new(f32::MAX));
});
})
}
#[test]
fn prefill_resets_form_scroll_position() {
App::test((), |mut app| async move {
init_modal_test_models(&mut app);
let endpoint = endpoint_with_models(20);
let (_window_id, modal) = app.add_window(WindowStyle::NotStealFocus, move |ctx| {
CustomEndpointModal::new(Some(&endpoint), Some(0), ctx)
});
modal.update(&mut app, |modal, ctx| {
modal.scroll_state.scroll_to(Pixels::new(100.));
assert_eq!(modal.scroll_state.scroll_start(), Pixels::new(100.));
modal.prefill(None, None, ctx);
assert_eq!(modal.scroll_state.scroll_start(), Pixels::zero());
});
})
}
#[test]
fn validate_url_accepts_https_with_host() {
assert!(validate_url("https://api.example.com/v1").is_ok());
assert!(validate_url("https://example.com").is_ok());
assert!(validate_url("https://8.8.8.8/v1").is_ok());
}
#[test]
fn validate_url_rejects_http() {
assert_eq!(
validate_url("http://api.example.com/v1"),
Err("URL must use HTTPS")
);
assert_eq!(
validate_url("http://example.com"),
Err("URL must use HTTPS")
);
}
#[test]
fn validate_url_rejects_ftp_and_other_schemes() {
assert_eq!(
validate_url("ftp://files.example.com"),
Err("URL must use HTTPS")
);
assert_eq!(
validate_url("file:///etc/passwd"),
Err("URL must use HTTPS")
);
assert_eq!(
validate_url("ws://socket.example.com"),
Err("URL must use HTTPS")
);
}
#[test]
fn validate_url_rejects_malformed_strings() {
assert_eq!(validate_url("not a url"), Err("Invalid URL"));
assert_eq!(validate_url("https://"), Err("Invalid URL"));
}
#[test]
fn validate_url_rejects_empty_host() {
assert_eq!(validate_url("https://?query=1"), Err("Invalid URL"));
}
#[test]
fn validate_url_allows_empty_string() {
assert!(validate_url("").is_ok());
}
#[test]
fn validate_url_allows_whitespace_only() {
assert!(validate_url(" ").is_ok());
}
#[test]
fn validate_url_rejects_localhost_and_private_ips() {
let error = Err("URL must not use a local or private host");
assert_eq!(validate_url("https://localhost:8080"), error);
assert_eq!(validate_url("https://127.0.0.1/v1"), error);
assert_eq!(validate_url("https://0.0.0.0/v1"), error);
assert_eq!(validate_url("https://10.0.0.1/v1"), error);
assert_eq!(validate_url("https://172.16.0.1/v1"), error);
assert_eq!(validate_url("https://192.168.0.1/v1"), error);
assert_eq!(validate_url("https://169.254.0.1/v1"), error);
assert_eq!(validate_url("https://[::1]/v1"), error);
assert_eq!(validate_url("https://[::]/v1"), error);
assert_eq!(validate_url("https://[fc00::1]/v1"), error);
assert_eq!(validate_url("https://[fe80::1]/v1"), error);
assert_eq!(validate_url("https://[::ffff:192.168.0.1]/v1"), error);
}
#[test]
fn endpoint_form_valid_rejects_invalid_current_url() {
assert!(!is_endpoint_form_valid(
"Endpoint",
"http://api.example.com/v1",
"key",
true
));
}
#[test]
fn endpoint_form_valid_requires_non_empty_url() {
assert!(!is_endpoint_form_valid("Endpoint", "", "key", true));
assert!(!is_endpoint_form_valid("Endpoint", " ", "key", true));
}
#[test]
fn endpoint_form_valid_accepts_complete_valid_form() {
assert!(is_endpoint_form_valid(
"Endpoint",
"https://api.example.com/v1",
"key",
true
));
}
@@ -1,129 +0,0 @@
use galaxyui::elements::{ChildView, Container, Dismiss, Empty};
use galaxyui::ui_components::components::UiComponent;
use galaxyui::{
AppContext, Element, Entity, SingletonEntity, TypedActionView, View, ViewContext, ViewHandle,
};
use crate::appearance::Appearance;
use crate::server::ids::SyncId;
use crate::ui_components::dialog::{dialog_styles, Dialog};
use crate::view_components::action_button::{ActionButton, DangerPrimaryTheme, NakedTheme};
const DIALOG_WIDTH: f32 = 450.;
pub enum DeleteEnvironmentConfirmationDialogEvent {
Cancel,
Confirm(SyncId),
}
#[derive(Debug)]
pub enum DeleteEnvironmentConfirmationDialogAction {
Cancel,
Confirm,
}
pub struct DeleteEnvironmentConfirmationDialog {
pub(crate) visible: bool,
pub(crate) env_id: Option<SyncId>,
pub(crate) env_name: String,
cancel_button: ViewHandle<ActionButton>,
confirm_button: ViewHandle<ActionButton>,
}
impl DeleteEnvironmentConfirmationDialog {
pub fn new(ctx: &mut ViewContext<Self>) -> Self {
let cancel_button = ctx.add_typed_action_view(|_| {
ActionButton::new("Cancel", NakedTheme).on_click(|ctx| {
ctx.dispatch_typed_action(DeleteEnvironmentConfirmationDialogAction::Cancel);
})
});
let confirm_button = ctx.add_typed_action_view(|_| {
ActionButton::new("Delete environment", DangerPrimaryTheme).on_click(|ctx| {
ctx.dispatch_typed_action(DeleteEnvironmentConfirmationDialogAction::Confirm);
})
});
Self {
visible: false,
env_id: None,
env_name: String::new(),
cancel_button,
confirm_button,
}
}
pub fn show(&mut self, env_id: SyncId, env_name: String, ctx: &mut ViewContext<Self>) {
self.env_id = Some(env_id);
self.env_name = env_name;
self.visible = true;
ctx.notify();
}
pub fn hide(&mut self, ctx: &mut ViewContext<Self>) {
self.visible = false;
ctx.notify();
}
}
impl Entity for DeleteEnvironmentConfirmationDialog {
type Event = DeleteEnvironmentConfirmationDialogEvent;
}
impl View for DeleteEnvironmentConfirmationDialog {
fn ui_name() -> &'static str {
"DeleteEnvironmentConfirmationDialog"
}
fn render(&self, app: &AppContext) -> Box<dyn Element> {
if !self.visible {
return Empty::new().finish();
}
let appearance = Appearance::as_ref(app);
let description = format!(
"Are you sure you want to remove the {} environment?",
self.env_name
);
let dialog = Dialog::new(
"Delete environment?".to_string(),
Some(description),
dialog_styles(appearance),
)
.with_bottom_row_child(ChildView::new(&self.cancel_button).finish())
.with_bottom_row_child(
Container::new(ChildView::new(&self.confirm_button).finish())
.with_margin_left(12.)
.finish(),
)
.with_width(DIALOG_WIDTH)
.build()
.finish();
Dismiss::new(dialog)
.prevent_interaction_with_other_elements()
.on_dismiss(|ctx, _app| {
ctx.dispatch_typed_action(DeleteEnvironmentConfirmationDialogAction::Cancel)
})
.finish()
}
}
impl TypedActionView for DeleteEnvironmentConfirmationDialog {
type Action = DeleteEnvironmentConfirmationDialogAction;
fn handle_action(&mut self, action: &Self::Action, ctx: &mut ViewContext<Self>) {
match action {
DeleteEnvironmentConfirmationDialogAction::Cancel => {
ctx.emit(DeleteEnvironmentConfirmationDialogEvent::Cancel)
}
DeleteEnvironmentConfirmationDialogAction::Confirm => {
if let Some(env_id) = self.env_id {
ctx.emit(DeleteEnvironmentConfirmationDialogEvent::Confirm(env_id));
}
}
}
}
}
File diff suppressed because it is too large Load Diff
@@ -1,141 +0,0 @@
use galaxy_core::ui::appearance::Appearance;
use galaxyui::elements::{
Border, Container, CornerRadius, DispatchEventResult, EventHandler, Flex, MainAxisAlignment,
MouseStateHandle, ParentElement as _, Radius, Text,
};
use galaxyui::fonts::{Properties, Weight};
use galaxyui::platform::Cursor;
use galaxyui::{
AppContext, BlurContext, Element, Entity, FocusContext, SingletonEntity, TypedActionView, View,
ViewContext, ViewHandle, WeakViewHandle,
};
use super::EnvironmentsPageAction;
use crate::editor::EditorView;
pub struct NewEnvironmentButtonView {
trigger_mouse_state: MouseStateHandle,
search_editor: ViewHandle<EditorView>,
self_handle: WeakViewHandle<Self>,
}
#[derive(Debug, Clone)]
pub enum NewEnvironmentButtonAction {
OpenSelector,
FocusSearch,
}
impl NewEnvironmentButtonView {
pub fn new(search_editor: ViewHandle<EditorView>, ctx: &mut ViewContext<Self>) -> Self {
Self {
trigger_mouse_state: Default::default(),
search_editor,
self_handle: ctx.handle(),
}
}
fn is_focused(&self, app: &AppContext) -> bool {
self.self_handle
.upgrade(app)
.is_some_and(|v| v.is_focused(app))
}
}
impl Entity for NewEnvironmentButtonView {
type Event = ();
}
impl TypedActionView for NewEnvironmentButtonView {
type Action = NewEnvironmentButtonAction;
fn handle_action(&mut self, action: &Self::Action, ctx: &mut ViewContext<Self>) {
match action {
NewEnvironmentButtonAction::OpenSelector => {
ctx.dispatch_typed_action(
&EnvironmentsPageAction::OpenEnvironmentSetupModeSelector,
);
}
NewEnvironmentButtonAction::FocusSearch => {
ctx.focus(&self.search_editor);
}
}
}
}
impl View for NewEnvironmentButtonView {
fn ui_name() -> &'static str {
"NewEnvironmentButton"
}
fn on_focus(&mut self, focus_ctx: &FocusContext, ctx: &mut ViewContext<Self>) {
if focus_ctx.is_self_focused() {
ctx.notify();
}
}
fn on_blur(&mut self, blur_ctx: &BlurContext, ctx: &mut ViewContext<Self>) {
if blur_ctx.is_self_blurred() {
ctx.notify();
}
}
fn render(&self, app: &AppContext) -> Box<dyn Element> {
let appearance = Appearance::as_ref(app);
let theme = appearance.theme();
let is_focused = self.is_focused(app);
let trigger = {
galaxyui::elements::Hoverable::new(self.trigger_mouse_state.clone(), move |s| {
let is_hovered = s.is_hovered();
let background = if is_hovered || is_focused {
Some(theme.surface_3())
} else {
None
};
let row = Flex::row()
.with_main_axis_alignment(MainAxisAlignment::Center)
.with_spacing(4.)
.with_child(
Text::new(
"New environment",
appearance.ui_font_family(),
appearance.ui_font_size(),
)
.with_style(Properties::default().weight(Weight::Medium))
.with_color(theme.active_ui_text_color().into())
.finish(),
);
let mut container = Container::new(row.finish())
.with_horizontal_padding(12.)
.with_vertical_padding(6.)
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(4.)))
.with_border(Border::all(1.).with_border_fill(theme.surface_3()));
if let Some(bg) = background {
container = container.with_background(bg);
}
container.finish()
})
.with_cursor(Cursor::PointingHand)
.on_click(|ctx, _, _| {
ctx.dispatch_typed_action(NewEnvironmentButtonAction::OpenSelector);
})
.finish()
};
EventHandler::new(trigger)
.on_keydown(move |ctx, _app, keystroke| {
if keystroke.is_shift_tab() {
ctx.dispatch_typed_action(NewEnvironmentButtonAction::FocusSearch);
DispatchEventResult::StopPropagation
} else if keystroke.is_unmodified_enter() {
ctx.dispatch_typed_action(NewEnvironmentButtonAction::OpenSelector);
DispatchEventResult::StopPropagation
} else {
DispatchEventResult::PropagateToParent
}
})
.finish()
}
}
File diff suppressed because it is too large Load Diff
@@ -1,251 +0,0 @@
use pathfinder_color::ColorU;
use warpui::elements::{
Align, ChildView, ClippedScrollStateHandle, ClippedScrollable, CrossAxisAlignment, Dismiss,
Element, Flex, MouseStateHandle, ParentElement, ScrollbarWidth,
};
use warpui::ui_components::components::UiComponent;
use warpui::{
AppContext, Entity, FocusContext, SingletonEntity, TypedActionView, View, ViewContext,
ViewHandle,
};
use crate::ai::ambient_agents::github_auth_url::{AuthSource, GithubAuthRedirectTarget};
use crate::ai::cloud_environments;
use crate::appearance::Appearance;
use crate::modal::MODAL_BACKDROP_OPACITY;
use crate::server::cloud_objects::update_manager::UpdateManager;
use crate::server::ids::{ClientId, SyncId};
use crate::settings_view::update_environment_form::{
EnvironmentFormInitArgs, UpdateEnvironmentForm, UpdateEnvironmentFormEvent,
};
use crate::ui_components::buttons::icon_button;
use crate::ui_components::dialog::{dialog_styles, Dialog};
use crate::ui_components::icons::Icon;
const DIALOG_WIDTH: f32 = 600.;
#[derive(Clone, Copy, Debug)]
enum HandoffEnvironmentCreationModalContext {
Handoff,
Orchestration,
}
#[derive(Debug, Clone)]
pub(crate) enum HandoffEnvironmentCreationModalEvent {
Created { env_id: SyncId },
Cancelled,
CreationFailed { error_message: String },
}
#[derive(Debug, Clone)]
pub(crate) enum HandoffEnvironmentCreationModalAction {
Cancel,
}
pub(crate) struct HandoffEnvironmentCreationModal {
environment_form: ViewHandle<UpdateEnvironmentForm>,
close_button_mouse_state: MouseStateHandle,
scroll_state: ClippedScrollStateHandle,
}
impl HandoffEnvironmentCreationModal {
pub(crate) fn new(ctx: &mut ViewContext<Self>) -> Self {
Self::new_impl(HandoffEnvironmentCreationModalContext::Handoff, ctx)
}
pub(crate) fn new_for_orchestration(ctx: &mut ViewContext<Self>) -> Self {
Self::new_impl(HandoffEnvironmentCreationModalContext::Orchestration, ctx)
}
fn new_impl(
context: HandoffEnvironmentCreationModalContext,
ctx: &mut ViewContext<Self>,
) -> Self {
let environment_form = ctx.add_typed_action_view(move |ctx| {
let mut form = UpdateEnvironmentForm::new(EnvironmentFormInitArgs::Create, ctx);
form.set_github_auth_redirect_target(GithubAuthRedirectTarget::FocusCloudMode);
form.set_show_header(false, ctx);
form.set_should_handle_escape_from_editor(true);
form.set_auth_source(AuthSource::CloudSetup);
match context {
HandoffEnvironmentCreationModalContext::Handoff => {}
HandoffEnvironmentCreationModalContext::Orchestration => {
form.configure_for_orchestration_modal(ctx);
}
}
form
});
ctx.subscribe_to_view(&environment_form, |me, _, event, ctx| {
me.handle_environment_form_event(event, ctx);
});
Self {
environment_form,
close_button_mouse_state: MouseStateHandle::default(),
scroll_state: ClippedScrollStateHandle::default(),
}
}
pub(crate) fn show(&mut self, ctx: &mut ViewContext<Self>) {
self.scroll_state = ClippedScrollStateHandle::default();
self.environment_form.update(ctx, |form, ctx| {
form.set_mode(EnvironmentFormInitArgs::Create, ctx);
form.focus(ctx);
});
ctx.notify();
}
fn handle_environment_form_event(
&mut self,
event: &UpdateEnvironmentFormEvent,
ctx: &mut ViewContext<Self>,
) {
match event {
UpdateEnvironmentFormEvent::Created {
environment,
share_with_team,
} => {
let owner = if *share_with_team {
cloud_environments::owner_for_new_environment(ctx)
} else {
cloud_environments::owner_for_new_personal_environment(ctx)
};
let Some(owner) = owner else {
log::error!("Unable to create environment: not logged in");
ctx.emit(HandoffEnvironmentCreationModalEvent::CreationFailed {
error_message: "Not logged in".to_string(),
});
return;
};
let client_id = ClientId::default();
let create_future =
UpdateManager::handle(ctx).update(ctx, |update_manager, ctx| {
update_manager.create_ambient_agent_environment_online(
environment.clone(),
client_id,
owner,
ctx,
)
});
ctx.spawn(create_future, |_me, result, ctx| match result {
Ok(server_id) => {
let env_id = SyncId::ServerId(server_id);
ctx.emit(HandoffEnvironmentCreationModalEvent::Created { env_id });
}
Err(err) => {
log::error!("Failed to create environment for handoff: {err:#}");
ctx.emit(HandoffEnvironmentCreationModalEvent::CreationFailed {
error_message: err.to_string(),
});
}
});
}
UpdateEnvironmentFormEvent::Cancelled => {
ctx.emit(HandoffEnvironmentCreationModalEvent::Cancelled);
}
UpdateEnvironmentFormEvent::Updated { .. }
| UpdateEnvironmentFormEvent::DeleteRequested { .. } => {}
}
}
#[cfg(test)]
pub(crate) fn uses_orchestration_form_configuration_for_test(&self, app: &AppContext) -> bool {
self.environment_form
.as_ref(app)
.uses_orchestration_modal_configuration_for_test()
}
fn render_dialog(&self, appearance: &Appearance, app: &AppContext) -> Box<dyn Element> {
let theme = appearance.theme();
let close_button = icon_button(
appearance,
Icon::X,
false,
self.close_button_mouse_state.clone(),
)
.build()
.on_click(|ctx, _, _| {
ctx.dispatch_typed_action(HandoffEnvironmentCreationModalAction::Cancel);
})
.finish();
let form_content = Flex::column()
.with_cross_axis_alignment(CrossAxisAlignment::Stretch)
.with_child(ChildView::new(&self.environment_form).finish())
.finish();
let scrollable_form = ClippedScrollable::vertical(
self.scroll_state.clone(),
form_content,
ScrollbarWidth::Auto,
theme.nonactive_ui_text_color().into(),
theme.active_ui_text_color().into(),
warpui::elements::Fill::None,
)
.finish();
let padded_form = warpui::elements::Container::new(scrollable_form)
.with_uniform_padding(8.)
.finish();
let dialog = Dialog::new(
"Create environment".to_string(),
None,
dialog_styles(appearance),
)
.with_close_button(close_button)
.with_child(padded_form)
.with_width(DIALOG_WIDTH)
.build();
let dialog = Dismiss::new(dialog.finish())
.prevent_interaction_with_other_elements()
.on_dismiss(|ctx, _app| {
ctx.dispatch_typed_action(HandoffEnvironmentCreationModalAction::Cancel);
})
.finish();
warpui::elements::Container::new(Align::new(dialog).finish())
.with_background_color(ColorU::new(0, 0, 0, MODAL_BACKDROP_OPACITY))
.with_corner_radius(app.windows().window_corner_radius())
.finish()
}
}
impl Entity for HandoffEnvironmentCreationModal {
type Event = HandoffEnvironmentCreationModalEvent;
}
impl TypedActionView for HandoffEnvironmentCreationModal {
type Action = HandoffEnvironmentCreationModalAction;
fn handle_action(&mut self, action: &Self::Action, ctx: &mut ViewContext<Self>) {
match action {
HandoffEnvironmentCreationModalAction::Cancel => {
ctx.emit(HandoffEnvironmentCreationModalEvent::Cancelled);
}
}
}
}
impl View for HandoffEnvironmentCreationModal {
fn ui_name() -> &'static str {
"HandoffEnvironmentCreationModal"
}
fn render(&self, app: &AppContext) -> Box<dyn Element> {
let appearance = Appearance::as_ref(app);
self.render_dialog(appearance, app)
}
fn on_focus(&mut self, focus_ctx: &FocusContext, ctx: &mut ViewContext<Self>) {
if focus_ctx.is_self_focused() {
ctx.focus(&self.environment_form);
}
}
}
File diff suppressed because it is too large Load Diff
+4 -121
View File
@@ -5,10 +5,7 @@ use std::str::FromStr;
use about_page::AboutPageView;
use ai_page::{AISettingsPageAction, AISettingsPageEvent, AISettingsPageView, AISubpage};
use appearance_page::{AppearancePageAction, AppearanceSettingsPageView};
use billing_and_usage_dispatch::BillingAndUsageDispatchView;
use billing_and_usage_page::BillingAndUsagePageEvent;
use code_page::{CodeSettingsPageAction, CodeSettingsPageEvent, CodeSubpage};
use environments_page::EnvironmentsPageView;
use features_page::{FeaturesPageView, FeaturesSettingsPageEvent};
use galaxy_core::channel::ChannelState;
use galaxy_core::context_flag::ContextFlag;
@@ -32,19 +29,16 @@ use galaxyui::{
};
use itertools::Itertools as _;
use keybindings::KeybindingsView;
use main_page::{MainPageAction, MainSettingsPageEvent, MainSettingsPageView};
use mcp_servers_page::MCPServersSettingsPageView;
use nav::{SettingsNavItem, SettingsUmbrella};
use pathfinder_geometry::vector::Vector2F;
use privacy_page::{PrivacyPageView, PrivacyPageViewEvent};
use referrals_page::{ReferralsPageEvent, ReferralsPageView};
use scripting_page::ScriptingSettingsPageView;
use settings_file_footer::{render_footer, SettingsFooterKind, SettingsFooterMouseStates};
use settings_page::{
MatchData, SettingsPage, SettingsPageEvent, SettingsPageMeta, SettingsPageViewHandle,
HEADER_PADDING,
};
use show_blocks_view::{ShowBlocksEvent, ShowBlocksView};
use teams_page::{TeamsPageView, TeamsPageViewEvent};
use warpify_page::{WarpifyPageAction, WarpifyPageView};
@@ -77,22 +71,13 @@ mod admin_actions;
mod agent_assisted_environment_modal;
mod ai_page;
mod appearance_page;
mod billing_and_usage;
mod billing_and_usage_dispatch;
mod billing_and_usage_page;
mod billing_and_usage_page_v2;
mod code_page;
pub(crate) mod custom_inference_modal;
mod custom_router_view;
mod delete_environment_confirmation_dialog;
mod directory_color_add_picker;
pub(crate) mod environments_page;
mod execution_profile_view;
mod features;
mod features_page;
pub(crate) mod handoff_environment_creation_modal;
pub mod keybindings;
mod main_page;
pub mod mcp_servers;
pub mod mcp_servers_page;
mod nav;
@@ -101,17 +86,13 @@ mod platform;
mod platform_page;
mod privacy;
mod privacy_page;
mod referrals_page;
mod remove_custom_endpoint_confirmation_dialog;
mod scripting_page;
mod set_default_model_modal;
mod settings_file_footer;
pub(crate) mod settings_page;
mod show_blocks_view;
mod tab_menu;
mod teams_page;
mod telemetry;
mod transfer_ownership_confirmation_modal;
pub mod update_environment_form;
mod warp_drive_page;
mod warpify_page;
@@ -119,10 +100,8 @@ mod warpify_page;
#[cfg(not(target_family = "wasm"))]
pub(crate) use ai_page::cli_agent_settings_widget_id;
pub(crate) use ai_page::custom_model_routers_widget_id;
pub use billing_and_usage_page::create_discount_badge;
pub use code_page::CodeSettingsPageView;
pub use features_page::FeaturesPageAction;
pub use main_page::handle_experiment_change;
pub use privacy_page::PrivacyPageAction;
pub use settings_page::{
render_body_item_label, render_info_icon, render_input_list, render_separator, AdditionalInfo,
@@ -245,17 +224,13 @@ pub enum SettingsViewEvent {
#[derive(Copy, Clone, Debug, Default, PartialEq, Eq, Hash)]
pub enum SettingsSection {
About,
Account,
MCPServers,
BillingAndUsage,
#[default]
Appearance,
Features,
Keybindings,
Privacy,
Referrals,
Scripting,
SharedBlocks,
Teams,
WarpDrive,
Warpify,
@@ -280,11 +255,6 @@ pub enum SettingsSection {
// ── Code umbrella subpages ──
CodeIndexing,
EditorAndCodeReview,
// Dead variants — pages removed from nav but files still reference these
#[allow(dead_code)]
CloudEnvironments,
#[allow(dead_code)]
OzCloudAPIKeys,
}
use std::fmt::{self, Display};
@@ -294,7 +264,6 @@ use crate::util::bindings::custom_tag_to_keystroke;
impl Display for SettingsSection {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
SettingsSection::BillingAndUsage => write!(f, "Billing and usage"),
SettingsSection::Keybindings => write!(f, "Keyboard shortcuts"),
SettingsSection::MCPServers => write!(f, "MCP Servers"),
SettingsSection::Scripting => write!(f, "Scripting"),
@@ -378,22 +347,17 @@ impl FromStr for SettingsSection {
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s {
"About" => Ok(Self::About),
"Account" => Ok(Self::Account),
"AI" => Ok(Self::AI),
"MCP Servers" => Ok(Self::MCPServers),
"Billing and usage" => Ok(Self::BillingAndUsage),
"Appearance" => Ok(Self::Appearance),
"Code" => Ok(Self::Code),
"Features" => Ok(Self::Features),
"Keyboard shortcuts" => Ok(Self::Keybindings),
"Privacy" => Ok(Self::Privacy),
"Referrals" => Ok(Self::Referrals),
"Scripting" => Ok(Self::Scripting),
"Shared blocks" => Ok(Self::SharedBlocks),
"Teams" => Ok(Self::Teams),
"Warpify" => Ok(Self::Warpify),
"WarpDrive" | "Galaxy Drive" => Ok(Self::WarpDrive),
// This page was called "Oz" at one point, keep for backward compatibility.
"Oz" | "Warp Agent" | "Galaxy Agent" => Ok(Self::WarpAgent),
"Profiles" | "AgentProfiles" => Ok(Self::AgentProfiles),
"MCP servers" | "AgentMCPServers" => Ok(Self::AgentMCPServers),
@@ -402,9 +366,6 @@ impl FromStr for SettingsSection {
"AWS Bedrock" | "Bedrock" => Ok(Self::Bedrock),
"Indexing and projects" | "CodeIndexing" => Ok(Self::CodeIndexing),
"Editor and Code Review" | "EditorAndCodeReview" => Ok(Self::EditorAndCodeReview),
"CloudEnvironments" | "Environments" => Ok(Self::CloudEnvironments),
"OzCloudAPIKeys" => Ok(Self::OzCloudAPIKeys),
"SharedBlocks" | "Shared Blocks" => Ok(Self::SharedBlocks),
_ => Err(()),
}
}
@@ -636,7 +597,6 @@ pub fn init_actions_from_parent_view<T: Action + Clone>(
context: &ContextPredicate,
builder: fn(SettingsAction) -> T,
) {
main_page::init_actions_from_parent_view(app, context, builder);
appearance_page::init_actions_from_parent_view(app, context, builder);
features_page::init_actions_from_parent_view(app, context, builder);
warpify_page::init_actions_from_parent_view(app, context, builder);
@@ -941,7 +901,6 @@ pub enum DebugSettingsAction {
pub enum SettingsAction {
SelectAndRefresh(SettingsSection),
ToggleUmbrella(usize),
MainPageToggle(MainPageAction),
AppearancePageToggle(AppearancePageAction),
FeaturesPageToggle(FeaturesPageAction),
PrivacyPageToggle(PrivacyPageAction),
@@ -1088,24 +1047,19 @@ fn next_stop_index(current: usize, len: usize, direction: CycleDirection) -> usi
macro_rules! update_page {
($handle:expr, $update:expr, $ctx:expr) => {
match $handle {
SettingsPageViewHandle::Main(handle) => $ctx.update_view(handle, $update),
SettingsPageViewHandle::Appearance(handle) => $ctx.update_view(handle, $update),
SettingsPageViewHandle::Features(handle) => $ctx.update_view(handle, $update),
SettingsPageViewHandle::Keybindings(handle) => $ctx.update_view(handle, $update),
SettingsPageViewHandle::Warpify(handle) => $ctx.update_view(handle, $update),
SettingsPageViewHandle::Privacy(handle) => $ctx.update_view(handle, $update),
SettingsPageViewHandle::Referrals(handle) => $ctx.update_view(handle, $update),
SettingsPageViewHandle::Scripting(handle) => $ctx.update_view(handle, $update),
SettingsPageViewHandle::AI(handle) => $ctx.update_view(handle, $update),
SettingsPageViewHandle::About(handle) => $ctx.update_view(handle, $update),
SettingsPageViewHandle::Code(handle) => $ctx.update_view(handle, $update),
SettingsPageViewHandle::BillingAndUsage(handle) => $ctx.update_view(handle, $update),
SettingsPageViewHandle::MCPServers(handle) => $ctx.update_view(handle, $update),
SettingsPageViewHandle::WarpDrive(handle) => $ctx.update_view(handle, $update),
SettingsPageViewHandle::CloudEnvironments(handle) => $ctx.update_view(handle, $update),
SettingsPageViewHandle::OzCloudAPIKeys(handle) => $ctx.update_view(handle, $update),
SettingsPageViewHandle::Teams(handle) => $ctx.update_view(handle, $update),
SettingsPageViewHandle::SharedBlocks(handle) => $ctx.update_view(handle, $update),
SettingsPageViewHandle::Platform(handle) => $ctx.update_view(handle, $update),
}
};
}
@@ -1147,11 +1101,6 @@ impl SettingsView {
let pane_configuration = ctx.add_model(|_ctx| PaneConfiguration::new("Settings"));
let global_resource_handles = GlobalResourceHandlesProvider::as_ref(ctx).get().clone();
// Main settings page with accounts info
let main_page_handle = ctx.add_typed_action_view(MainSettingsPageView::new);
ctx.subscribe_to_view(&main_page_handle, |me, _, event, ctx| {
me.handle_main_page_event(event, ctx);
});
// Appearance & themes page
let appearance_page_handle = ctx.add_typed_action_view(AppearanceSettingsPageView::new);
@@ -1178,13 +1127,6 @@ impl SettingsView {
me.handle_ai_page_event(event, ctx);
});
// Billing & Usage page (internally, this routes to the v1 or v2 version. Depending on FFs and current plan).
let billing_and_usage_handle = ctx.add_view(BillingAndUsageDispatchView::new);
ctx.subscribe_to_view(&billing_and_usage_handle, |me, _, event, ctx| {
me.handle_billing_and_usage_page_event(event, ctx);
});
let billing_and_usage_page = SettingsPage::new(billing_and_usage_handle);
// Keybindings page
let keybindings_handle = ctx.add_typed_action_view(KeybindingsView::new);
@@ -1259,9 +1201,7 @@ impl SettingsView {
});
let mut settings_pages = vec![
SettingsPage::new(main_page_handle),
SettingsPage::new(ai_page_handle),
billing_and_usage_page,
SettingsPage::new(code_page_handle),
SettingsPage::new(appearance_page_handle),
SettingsPage::new(features_page_handle),
@@ -1305,16 +1245,7 @@ impl SettingsView {
];
if FeatureFlag::WarpControlCli.is_enabled() {
let shared_blocks_index = nav_items
.iter()
.position(|item| {
matches!(item, SettingsNavItem::Page(SettingsSection::SharedBlocks))
})
.unwrap_or(nav_items.len());
nav_items.insert(
shared_blocks_index,
SettingsNavItem::Page(SettingsSection::Scripting),
);
nav_items.push(SettingsNavItem::Page(SettingsSection::Scripting));
}
// Resolve the initial page: map internal backing-page sections to their default subpage.
@@ -1322,7 +1253,7 @@ impl SettingsView {
Some(SettingsSection::AI) => SettingsSection::WarpAgent,
Some(SettingsSection::Code) => SettingsSection::CodeIndexing,
Some(SettingsSection::Scripting) if !FeatureFlag::WarpControlCli.is_enabled() => {
SettingsSection::Account
SettingsSection::About
}
Some(section) if section.is_subpage() => section,
other => other.unwrap_or_default(),
@@ -1686,40 +1617,6 @@ impl SettingsView {
.collect();
}
fn handle_main_page_event(
&mut self,
event: &MainSettingsPageEvent,
ctx: &mut ViewContext<Self>,
) {
match event {
MainSettingsPageEvent::CheckForUpdate => ctx.emit(SettingsViewEvent::CheckForUpdate),
MainSettingsPageEvent::SignupAnonymousUser => {
ctx.emit(SettingsViewEvent::SignupAnonymousUser)
}
_ => (),
}
}
fn handle_billing_and_usage_page_event(
&mut self,
event: &BillingAndUsagePageEvent,
ctx: &mut ViewContext<Self>,
) {
match event {
BillingAndUsagePageEvent::SignupAnonymousUser => {
ctx.emit(SettingsViewEvent::SignupAnonymousUser)
}
BillingAndUsagePageEvent::ShowToast { message, flavor } => {
ctx.emit(SettingsViewEvent::ShowToast {
message: message.clone(),
flavor: *flavor,
})
}
BillingAndUsagePageEvent::ShowModal => ctx.notify(),
BillingAndUsagePageEvent::HideModal => ctx.notify(),
}
}
fn handle_appearance_page_event(
&mut self,
event: &SettingsPageEvent,
@@ -2021,20 +1918,18 @@ impl SettingsView {
fn should_render_page(&self, settings_page: &SettingsPage, app: &AppContext) -> bool {
match &settings_page.view_handle {
SettingsPageViewHandle::Main(v) => v.as_ref(app).should_render(app),
SettingsPageViewHandle::Keybindings(v) => v.as_ref(app).should_render(app),
SettingsPageViewHandle::Features(v) => v.as_ref(app).should_render(app),
SettingsPageViewHandle::Appearance(v) => v.as_ref(app).should_render(app),
SettingsPageViewHandle::BillingAndUsage(v) => v.as_ref(app).should_render(app),
SettingsPageViewHandle::About(v) => v.as_ref(app).should_render(app),
SettingsPageViewHandle::Privacy(v) => v.as_ref(app).should_render(app),
SettingsPageViewHandle::Warpify(v) => v.as_ref(app).should_render(app),
SettingsPageViewHandle::Referrals(v) => v.as_ref(app).should_render(app),
SettingsPageViewHandle::Scripting(v) => v.as_ref(app).should_render(app),
SettingsPageViewHandle::AI(v) => v.as_ref(app).should_render(app),
SettingsPageViewHandle::MCPServers(v) => v.as_ref(app).should_render(app),
SettingsPageViewHandle::Code(v) => v.as_ref(app).should_render(app),
SettingsPageViewHandle::WarpDrive(v) => v.as_ref(app).should_render(app),
SettingsPageViewHandle::Platform(v) => v.as_ref(app).should_render(app),
_ => true,
}
}
@@ -2222,9 +2117,6 @@ impl SettingsView {
app: &AppContext,
) -> Option<Box<dyn Element>> {
match page_handle {
SettingsPageViewHandle::BillingAndUsage(view) => {
view.read(app, |view, _| view.get_modal_content(app))
}
SettingsPageViewHandle::Privacy(view) => {
view.read(app, |view, _| view.get_modal_content())
}
@@ -2560,15 +2452,6 @@ impl TypedActionView for SettingsView {
ctx.notify();
}
}
SettingsAction::MainPageToggle(main_page_action) => {
if let Some(main_page) = self.settings_page(SettingsSection::Account) {
if let SettingsPageViewHandle::Main(view) = &main_page.view_handle {
view.update(ctx, |view, ctx| {
view.handle_action(main_page_action, ctx);
})
}
}
}
SettingsAction::AppearancePageToggle(appearance_action) => {
if let Some(appearance_page) = self.settings_page(SettingsSection::Appearance) {
if let SettingsPageViewHandle::Appearance(view) = &appearance_page.view_handle {
+2 -2
View File
@@ -910,7 +910,7 @@ impl PlatformPageWidget {
impl SettingsPageMeta for PlatformPageView {
fn section() -> SettingsSection {
SettingsSection::OzCloudAPIKeys
SettingsSection::About
}
fn should_render(&self, ctx: &AppContext) -> bool {
@@ -941,7 +941,7 @@ impl SettingsPageMeta for PlatformPageView {
impl From<ViewHandle<PlatformPageView>> for SettingsPageViewHandle {
fn from(view_handle: ViewHandle<PlatformPageView>) -> Self {
SettingsPageViewHandle::OzCloudAPIKeys(view_handle)
SettingsPageViewHandle::Platform(view_handle)
}
}
File diff suppressed because it is too large Load Diff
@@ -1,172 +0,0 @@
use galaxy_core::ui::theme::color::internal_colors;
use warpui::elements::{
Border, ChildView, Container, CornerRadius, Dismiss, Empty, Flex, ParentElement, Radius, Text,
};
use warpui::fonts::{Properties, Weight};
use warpui::ui_components::components::UiComponent;
use warpui::{
AppContext, Element, Entity, SingletonEntity, TypedActionView, View, ViewContext, ViewHandle,
};
use crate::appearance::Appearance;
use crate::ui_components::dialog::{dialog_styles, Dialog};
use crate::view_components::action_button::{ActionButton, DangerPrimaryTheme, NakedTheme};
const DIALOG_WIDTH: f32 = 450.;
pub enum RemoveCustomEndpointConfirmationDialogEvent {
Cancel,
Confirm(usize),
}
#[derive(Debug)]
pub enum RemoveCustomEndpointConfirmationDialogAction {
Cancel,
Confirm,
}
pub struct RemoveCustomEndpointConfirmationDialog {
visible: bool,
endpoint_index: Option<usize>,
endpoint_name: String,
model_labels: Vec<String>,
cancel_button: ViewHandle<ActionButton>,
confirm_button: ViewHandle<ActionButton>,
}
impl RemoveCustomEndpointConfirmationDialog {
pub fn new(ctx: &mut ViewContext<Self>) -> Self {
let cancel_button = ctx.add_typed_action_view(|_| {
ActionButton::new("Cancel", NakedTheme).on_click(|ctx| {
ctx.dispatch_typed_action(RemoveCustomEndpointConfirmationDialogAction::Cancel);
})
});
let confirm_button = ctx.add_typed_action_view(|_| {
ActionButton::new("Remove endpoint", DangerPrimaryTheme).on_click(|ctx| {
ctx.dispatch_typed_action(RemoveCustomEndpointConfirmationDialogAction::Confirm);
})
});
Self {
visible: false,
endpoint_index: None,
endpoint_name: String::new(),
model_labels: Vec::new(),
cancel_button,
confirm_button,
}
}
pub fn show(
&mut self,
endpoint_index: usize,
endpoint_name: String,
model_labels: Vec<String>,
ctx: &mut ViewContext<Self>,
) {
self.endpoint_index = Some(endpoint_index);
self.endpoint_name = endpoint_name;
self.model_labels = model_labels;
self.visible = true;
ctx.notify();
}
pub fn hide(&mut self, ctx: &mut ViewContext<Self>) {
self.visible = false;
ctx.notify();
}
pub fn is_visible(&self) -> bool {
self.visible
}
}
impl Entity for RemoveCustomEndpointConfirmationDialog {
type Event = RemoveCustomEndpointConfirmationDialogEvent;
}
impl View for RemoveCustomEndpointConfirmationDialog {
fn ui_name() -> &'static str {
"RemoveCustomEndpointConfirmationDialog"
}
fn render(&self, app: &AppContext) -> Box<dyn Element> {
if !self.visible {
return Empty::new().finish();
}
let appearance = Appearance::as_ref(app);
let theme = appearance.theme();
let description = "Are you sure you want to remove this endpoint? You won't be able to use its models in your agent sessions moving forward.".to_string();
let endpoint_title = Text::new_inline(
self.endpoint_name.clone(),
appearance.ui_font_family(),
appearance.ui_font_size(),
)
.with_style(Properties::default().weight(Weight::Semibold))
.with_color(theme.active_ui_text_color().into())
.finish();
let chip_border = internal_colors::fg_overlay_3(theme);
let chip_text = theme.active_ui_text_color();
let chips =
super::render_model_chips(self.model_labels.iter().cloned(), appearance, chip_text);
let endpoint_card = Container::new(
Flex::column()
.with_spacing(8.)
.with_child(endpoint_title)
.with_child(chips)
.finish(),
)
.with_uniform_padding(12.)
.with_background(internal_colors::fg_overlay_1(theme))
.with_border(Border::all(1.).with_border_fill(chip_border))
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(6.)))
.finish();
let dialog = Dialog::new(
"Remove endpoint?".to_string(),
Some(description),
dialog_styles(appearance),
)
.with_child(endpoint_card)
.with_bottom_row_child(ChildView::new(&self.cancel_button).finish())
.with_bottom_row_child(
Container::new(ChildView::new(&self.confirm_button).finish())
.with_margin_left(12.)
.finish(),
)
.with_width(DIALOG_WIDTH)
.build()
.finish();
Dismiss::new(dialog)
.prevent_interaction_with_other_elements()
.on_dismiss(|ctx, _app| {
ctx.dispatch_typed_action(RemoveCustomEndpointConfirmationDialogAction::Cancel)
})
.finish()
}
}
impl TypedActionView for RemoveCustomEndpointConfirmationDialog {
type Action = RemoveCustomEndpointConfirmationDialogAction;
fn handle_action(&mut self, action: &Self::Action, ctx: &mut ViewContext<Self>) {
match action {
RemoveCustomEndpointConfirmationDialogAction::Cancel => {
ctx.emit(RemoveCustomEndpointConfirmationDialogEvent::Cancel)
}
RemoveCustomEndpointConfirmationDialogAction::Confirm => {
if let Some(index) = self.endpoint_index {
ctx.emit(RemoveCustomEndpointConfirmationDialogEvent::Confirm(index));
}
}
}
}
}
+3 -18
View File
@@ -29,17 +29,12 @@ use settings::Setting;
use super::about_page::AboutPageView;
use super::ai_page::{AISettingsPageAction, AISettingsPageView};
use super::appearance_page::AppearanceSettingsPageView;
use super::billing_and_usage_dispatch::BillingAndUsageDispatchView;
use super::code_page::CodeSettingsPageView;
use super::environments_page::EnvironmentsPageView;
use super::features_page::FeaturesPageView;
use super::keybindings::KeybindingsView;
use super::main_page::MainSettingsPageView;
use super::mcp_servers_page::MCPServersSettingsPageView;
use super::privacy_page::PrivacyPageView;
use super::referrals_page::ReferralsPageView;
use super::scripting_page::ScriptingSettingsPageView;
use super::show_blocks_view::ShowBlocksView;
use super::teams_page::TeamsPageView;
use super::warp_drive_page::WarpDriveSettingsPageView;
use super::warpify_page::WarpifyPageView;
@@ -100,7 +95,6 @@ pub trait SettingsPageMeta {
/// It is required to allow for SettingsPage struct be put in the collection (ie. vector).
#[derive(Clone)]
pub enum SettingsPageViewHandle {
Main(ViewHandle<MainSettingsPageView>),
Appearance(ViewHandle<AppearanceSettingsPageView>),
Features(ViewHandle<FeaturesPageView>),
Keybindings(ViewHandle<KeybindingsView>),
@@ -108,23 +102,18 @@ pub enum SettingsPageViewHandle {
Code(ViewHandle<CodeSettingsPageView>),
Privacy(ViewHandle<PrivacyPageView>),
Warpify(ViewHandle<WarpifyPageView>),
Referrals(ViewHandle<ReferralsPageView>),
Scripting(ViewHandle<ScriptingSettingsPageView>),
AI(ViewHandle<AISettingsPageView>),
CloudEnvironments(ViewHandle<EnvironmentsPageView>),
BillingAndUsage(ViewHandle<BillingAndUsageDispatchView>),
MCPServers(ViewHandle<MCPServersSettingsPageView>),
WarpDrive(ViewHandle<WarpDriveSettingsPageView>),
OzCloudAPIKeys(ViewHandle<super::platform_page::PlatformPageView>),
Teams(ViewHandle<super::teams_page::TeamsPageView>),
SharedBlocks(ViewHandle<ShowBlocksView>),
Teams(ViewHandle<TeamsPageView>),
Platform(ViewHandle<super::platform_page::PlatformPageView>),
}
impl SettingsPageViewHandle {
pub fn child_view(&self) -> Box<dyn Element> {
use SettingsPageViewHandle::*;
match self {
Main(view_handle) => ChildView::new(view_handle).finish(),
Appearance(view_handle) => ChildView::new(view_handle).finish(),
Features(view_handle) => ChildView::new(view_handle).finish(),
Keybindings(view_handle) => ChildView::new(view_handle).finish(),
@@ -132,16 +121,12 @@ impl SettingsPageViewHandle {
Code(view_handle) => ChildView::new(view_handle).finish(),
Privacy(view_handle) => ChildView::new(view_handle).finish(),
Warpify(view_handle) => ChildView::new(view_handle).finish(),
Referrals(view_handle) => ChildView::new(view_handle).finish(),
Scripting(view_handle) => ChildView::new(view_handle).finish(),
AI(view_handle) => ChildView::new(view_handle).finish(),
BillingAndUsage(view_handle) => ChildView::new(view_handle).finish(),
MCPServers(view_handle) => ChildView::new(view_handle).finish(),
WarpDrive(view_handle) => ChildView::new(view_handle).finish(),
CloudEnvironments(view_handle) => ChildView::new(view_handle).finish(),
OzCloudAPIKeys(view_handle) => ChildView::new(view_handle).finish(),
Teams(view_handle) => ChildView::new(view_handle).finish(),
SharedBlocks(view_handle) => ChildView::new(view_handle).finish(),
Platform(view_handle) => ChildView::new(view_handle).finish(),
}
}
}
-811
View File
@@ -1,811 +0,0 @@
#![allow(dead_code)]
use std::sync::Arc;
use anyhow::Result;
use chrono::{DateTime, FixedOffset, Local};
use galaxy_core::ui::theme::color::internal_colors;
use galaxyui::clipboard::ClipboardContent;
use galaxyui::color::ColorU;
use galaxyui::elements::{
Align, ChildAnchor, ChildView, ConstrainedBox, Container, CornerRadius, CrossAxisAlignment,
Dismiss, Expanded, Fill, Flex, Hoverable, Icon, MouseStateHandle, OffsetPositioning,
ParentAnchor, ParentElement, ParentOffsetBounds, PositionedElementAnchor,
PositionedElementOffsetBounds, Radius, SavePosition, ScrollStateHandle, Scrollable,
ScrollableElement, ScrollbarWidth, Shrinkable, Stack, UniformList, UniformListState,
};
use galaxyui::fonts::Weight;
use galaxyui::ui_components::button::ButtonVariant;
use galaxyui::ui_components::components::{UiComponent, UiComponentStyles};
use galaxyui::{
AppContext, Element, Entity, SingletonEntity, TypedActionView, View, ViewContext, ViewHandle,
};
use pathfinder_geometry::vector::vec2f;
use super::settings_page::{
render_page_title, MatchData, PageType, SettingsPageMeta, SettingsPageViewHandle,
SettingsWidget, HEADER_FONT_SIZE, PAGE_PADDING,
};
use super::SettingsSection;
use crate::appearance::Appearance;
use crate::auth::AuthStateProvider;
use crate::channel::{Channel, ChannelState};
use crate::menu::{Event as MenuEvent, Event, Menu, MenuItem, MenuItemFields};
use crate::server::block::Block;
use crate::server::server_api::block::BlockClient;
use crate::view_components::ToastFlavor;
const SCROLLBAR_WIDTH: ScrollbarWidth = ScrollbarWidth::Auto;
const UNSHARE_BLOCK_CONFIRMATION_DIALOG_TEXT: &str =
"Are you sure you want to unshare this block?\n\
\nIt will no longer be accessible by link and will be permanently deleted from Warp servers.";
#[derive(Clone, Debug)]
struct UserOwnedBlock {
id: String,
command: String,
link_mouse_state_handle: MouseStateHandle,
copy_button_mouse_state_handle: MouseStateHandle,
overflow_button_mouse_state_handle: MouseStateHandle,
unshare_request_status: UnshareBlockRequestState,
time_started: DateTime<FixedOffset>,
}
impl From<Block> for UserOwnedBlock {
fn from(block: Block) -> Self {
UserOwnedBlock::new(
block.id.unwrap_or_default(),
block.command.unwrap_or_default(),
block.time_started_term,
)
}
}
impl UserOwnedBlock {
fn new(
id: impl Into<String>,
command: impl Into<String>,
time_started: DateTime<FixedOffset>,
) -> Self {
Self {
id: id.into(),
command: command.into(),
unshare_request_status: UnshareBlockRequestState::NotStarted,
link_mouse_state_handle: Default::default(),
copy_button_mouse_state_handle: Default::default(),
overflow_button_mouse_state_handle: Default::default(),
time_started,
}
}
fn is_shared(&self) -> bool {
!matches!(self.unshare_request_status, UnshareBlockRequestState::Done)
}
fn block_url(&self) -> String {
// New block IDs are 22 characters long and are accessible at /block/{id}, whereas as old
// (hashId) block IDs are 6 characters long and are accessible at /{id}.
let mut url = if self.id.len() == 22 {
format!(
"{}/block/{}",
ChannelState::server_root_url(),
self.id.as_str()
)
} else {
format!("{}/{}", ChannelState::server_root_url(), self.id.as_str())
};
// If this is a preview build, ensure the link routes to a preview build.
if matches!(ChannelState::channel(), Channel::Preview) {
url.push_str("?preview=true");
}
url
}
fn render_overflow_icon(&self, appearance: &Appearance, index: usize) -> Box<dyn Element> {
let mut hoverable =
Hoverable::new(self.overflow_button_mouse_state_handle.clone(), |state| {
let container = Container::new(
ConstrainedBox::new(
Icon::new("bundled/svg/overflow.svg", ColorU::new(179, 186, 184, 255))
.finish(),
)
.with_height(20.)
.with_width(20.)
.finish(),
)
.with_uniform_padding(4.)
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(5.)));
let container = if state.is_clicked() || state.is_hovered() {
container.with_background(appearance.theme().surface_2())
} else {
container
};
container.finish()
});
// Disable the overflow button if the request is in flight since the user shouldn't be able
// to unshare it again.
if self.unshare_request_status == UnshareBlockRequestState::InFlight {
hoverable = hoverable.disable();
} else {
hoverable = hoverable.on_click(move |ctx, _, _| {
ctx.dispatch_typed_action(ShowBlocksAction::OverflowClick(index));
});
};
SavePosition::new(
hoverable.finish(),
format!("show_blocks_view:overflow_{index}").as_str(),
)
.finish()
}
fn copy_link_button(&self, appearance: &Appearance, block_url: String) -> Box<dyn Element> {
let button = appearance
.ui_builder()
.button(
ButtonVariant::Basic,
self.copy_button_mouse_state_handle.clone(),
)
.with_text_label("Copy link".into());
let button = if self.unshare_request_status == UnshareBlockRequestState::InFlight {
button.disabled().build()
} else {
button.build().on_click(move |ctx, _, _| {
ctx.dispatch_typed_action(ShowBlocksAction::CopyUrl(block_url.clone()));
})
};
button.finish()
}
fn link_text(&self, appearance: &Appearance, block_url: String) -> Box<dyn Element> {
if self.unshare_request_status == UnshareBlockRequestState::InFlight {
appearance
.ui_builder()
.label("Deleting...")
.with_style(
UiComponentStyles::default()
.set_font_family_id(appearance.monospace_font_family())
.set_font_size(14.),
)
.build()
.finish()
} else {
appearance
.ui_builder()
.link(
block_url.clone(),
Some(block_url),
None,
self.link_mouse_state_handle.clone(),
)
.soft_wrap(false)
.with_style(UiComponentStyles::default().set_font_size(14.))
.build()
.finish()
}
}
fn render(&self, appearance: &Appearance, index: usize) -> Box<dyn Element> {
let block_url = self.block_url();
let command = appearance
.ui_builder()
.label(self.command.clone())
.with_style(UiComponentStyles {
font_size: Some(14.),
font_family_id: Some(appearance.monospace_font_family()),
font_weight: Some(Weight::Bold),
..Default::default()
})
.build()
.finish();
let command_row = Container::new(
Flex::row()
.with_child(Shrinkable::new(1., Align::new(command).left().finish()).finish())
.with_child(
Container::new(self.render_overflow_icon(appearance, index))
.with_padding_left(15.)
.finish(),
)
.finish(),
)
.finish();
let url_row = Container::new(
Flex::row()
.with_child(
Shrinkable::new(
1.,
Container::new(self.link_text(appearance, block_url.clone())).finish(),
)
.finish(),
)
.with_child(
Shrinkable::new(
0.3,
Container::new(
Align::new(self.copy_link_button(appearance, block_url))
.right()
.finish(),
)
.finish(),
)
.finish(),
)
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.finish(),
)
.finish();
let timestamp_row = Container::new(
appearance
.ui_builder()
.label(format!(
"Executed on: {}",
self.time_started
.with_timezone(&Local)
.format("%a, %b %-d %Y at %-I:%M %p")
))
.with_style(
UiComponentStyles::default()
.set_font_color(
appearance
.theme()
.hint_text_color(appearance.theme().surface_2())
.into(),
)
// make it slightly smaller than the url text
.set_font_size(appearance.ui_builder().ui_font_size() - 2.),
)
.build()
.finish(),
)
.finish();
Container::new(
ConstrainedBox::new(
Flex::column()
.with_child(Shrinkable::new(1.0, command_row).finish())
.with_child(Shrinkable::new(1., url_row).finish())
.with_child(timestamp_row)
.finish(),
)
.with_max_height(90.)
.finish(),
)
.finish()
}
}
/// Status for the request to fetch the blocks owned by the current user.
#[derive(Debug)]
enum GetBlocksForUserRequestState {
NotStarted,
InFlight,
Failed,
Done(Vec<UserOwnedBlock>),
}
fn pad(element: Box<dyn Element>) -> Box<dyn Element> {
Container::new(element)
.with_uniform_padding(PAGE_PADDING)
.finish()
}
impl GetBlocksForUserRequestState {
fn render(
&self,
appearance: &Appearance,
list_state: UniformListState,
scroll_state_handle: ScrollStateHandle,
) -> Box<dyn Element> {
let ui_builder = appearance.ui_builder();
match self {
GetBlocksForUserRequestState::NotStarted => pad(ui_builder
.label("You don't have any shared blocks yet.")
.build()
.finish()),
GetBlocksForUserRequestState::InFlight => {
pad(ui_builder.label("Getting blocks...").build().finish())
}
GetBlocksForUserRequestState::Failed => pad(ui_builder
.label("Failed to load blocks. Please try again.")
.build()
.finish()),
GetBlocksForUserRequestState::Done(user_blocks) => {
let user_blocks = user_blocks.clone();
// Only consider the unshared blocks when rendering. We don't remove them from the
// list of blocks so that the indexing is consistent for the lifetime of the app.
let num_visible_blocks =
user_blocks.iter().filter(|block| block.is_shared()).count();
if num_visible_blocks > 0 {
let list =
UniformList::new(list_state, num_visible_blocks, move |range, app| {
let appearance = Appearance::as_ref(app);
user_blocks
.iter()
.enumerate()
.filter(|(_, block)| block.is_shared())
.skip(range.start)
.take(range.end - range.start)
.enumerate()
.map(|(visible_index, (index, user_block))| {
let user_block_element =
Container::new(user_block.render(appearance, index))
.with_uniform_padding(10.);
// Add a background on alternating blocks.
if visible_index % 2 == 0 {
user_block_element
.with_background(internal_colors::fg_overlay_1(
appearance.theme(),
))
.finish()
} else {
user_block_element.finish()
}
})
.collect::<Vec<_>>()
.into_iter()
});
Scrollable::vertical(
scroll_state_handle,
list.finish_scrollable(),
SCROLLBAR_WIDTH,
Fill::Solid(appearance.theme().nonactive_ui_detail().into_solid()),
Fill::Solid(appearance.theme().active_ui_detail().into_solid()),
Fill::None, // Leave the background transparent
)
.with_padding_start(5.)
.finish()
} else {
pad(ui_builder
.label("You don't have any shared blocks yet.")
.build()
.finish())
}
}
}
}
}
/// Status for the request to unshare a block.
#[derive(Debug, Clone, PartialEq)]
enum UnshareBlockRequestState {
NotStarted,
InFlight,
Failed,
Done,
}
#[derive(Default)]
struct StateHandles {
scroll_state_handle: ScrollStateHandle,
confirm_dialog_handle: MouseStateHandle,
cancel_dialog_handle: MouseStateHandle,
}
/// A view that lists all the blocks owned by the user.
pub struct ShowBlocksView {
page: PageType<Self>,
list_state: UniformListState,
overflow_menu: ViewHandle<Menu<ShowBlocksAction>>,
overflow_menu_index: Option<usize>,
get_blocks_for_user_status: GetBlocksForUserRequestState,
pending_unshared_block_index: Option<usize>,
block_client: Arc<dyn BlockClient>,
state_handles: StateHandles,
}
#[derive(Debug, Clone)]
pub enum ShowBlocksAction {
CopyUrl(String),
OverflowClick(usize),
Unshare,
ConfirmUnshare,
CancelUnshare,
}
#[derive(Debug, Clone)]
pub enum ShowBlocksEvent {
ShowToast {
message: String,
flavor: ToastFlavor,
},
}
impl ShowBlocksView {
pub fn new(block_client: Arc<dyn BlockClient>, ctx: &mut ViewContext<Self>) -> Self {
let menu = ctx.add_typed_action_view(|ctx| {
let mut menu = Menu::new().prevent_interaction_with_other_elements();
menu.set_items(
vec![MenuItem::Item(
MenuItemFields::new("Unshare").with_on_select_action(ShowBlocksAction::Unshare),
)],
ctx,
);
menu
});
ctx.subscribe_to_view(&menu, move |me, _, event, ctx| {
me.handle_overflow_menu_event(event, ctx);
});
let page = PageType::new_monolith(ShowBlocksWidget::default(), None, false);
Self {
page,
list_state: Default::default(),
state_handles: Default::default(),
overflow_menu: menu,
overflow_menu_index: None,
get_blocks_for_user_status: GetBlocksForUserRequestState::NotStarted,
block_client,
pending_unshared_block_index: None,
}
}
fn load_blocks(&mut self, ctx: &mut ViewContext<Self>) {
if !matches!(
self.get_blocks_for_user_status,
GetBlocksForUserRequestState::InFlight
) {
let block_client = self.block_client.clone();
self.get_blocks_for_user_status = GetBlocksForUserRequestState::InFlight;
let _ = ctx.spawn(
async move {
block_client
.blocks_owned_by_user()
.await
.map(|blocks| blocks.into_iter().map(Into::into).collect())
},
Self::on_load_complete,
);
}
}
fn on_load_complete(
&mut self,
result: Result<Vec<UserOwnedBlock>>,
ctx: &mut ViewContext<Self>,
) {
match result {
Ok(mut blocks) => {
blocks.sort_by_key(|b| std::cmp::Reverse(b.time_started));
self.get_blocks_for_user_status = GetBlocksForUserRequestState::Done(blocks)
}
Err(_) => {
log::info!("Failed to fetch blocks owned by user from server");
self.get_blocks_for_user_status = GetBlocksForUserRequestState::Failed;
}
}
ctx.notify()
}
pub fn copy_url(&mut self, block_url: &str, ctx: &mut ViewContext<Self>) {
ctx.clipboard()
.write(ClipboardContent::plain_text(block_url.to_string()));
ctx.emit(ShowBlocksEvent::ShowToast {
message: "Link copied.".to_string(),
flavor: ToastFlavor::Default,
})
}
pub fn overflow_click(&mut self, index: usize, ctx: &mut ViewContext<Self>) {
self.overflow_menu_index = Some(index);
ctx.notify();
}
pub fn cancel_unshare(&mut self, ctx: &mut ViewContext<Self>) {
self.pending_unshared_block_index = None;
ctx.notify();
}
pub fn confirm_unshare(&mut self, ctx: &mut ViewContext<Self>) {
if let Some(selected_index) = self.pending_unshared_block_index.take() {
if let GetBlocksForUserRequestState::Done(blocks) = &mut self.get_blocks_for_user_status
{
// Only attempt to unshare if there isn't already an inflight request to unshare
// the block.
let user_block = &mut blocks[selected_index];
if !matches!(
user_block.unshare_request_status,
UnshareBlockRequestState::InFlight
) {
user_block.unshare_request_status = UnshareBlockRequestState::InFlight;
let block_client = self.block_client.clone();
let block_id = user_block.id.clone();
let _ = ctx.spawn(
async move { (block_client.unshare_block(block_id).await, selected_index) },
Self::on_block_unshare_complete,
);
}
}
}
ctx.notify();
}
pub fn unshare_button_click(&mut self, ctx: &mut ViewContext<Self>) {
self.pending_unshared_block_index = self.overflow_menu_index.take();
ctx.notify();
}
fn on_block_unshare_complete(
&mut self,
(request_result, block_index): (Result<()>, usize),
ctx: &mut ViewContext<Self>,
) {
log::info!(
"on_block_unshare_complete with result {:?}",
&request_result
);
if let GetBlocksForUserRequestState::Done(blocks) = &mut self.get_blocks_for_user_status {
let user_block = &mut blocks[block_index];
match request_result {
Ok(_) => {
ctx.emit(ShowBlocksEvent::ShowToast {
message: "Block was successfully unshared.".to_string(),
flavor: ToastFlavor::Success,
});
user_block.unshare_request_status = UnshareBlockRequestState::Done;
}
Err(_) => {
ctx.emit(ShowBlocksEvent::ShowToast {
message: "Failed to unshare block. Please try again.".to_string(),
flavor: ToastFlavor::Error,
});
user_block.unshare_request_status = UnshareBlockRequestState::Failed;
}
}
ctx.notify();
}
}
fn handle_overflow_menu_event(&mut self, event: &MenuEvent, ctx: &mut ViewContext<Self>) {
if let Event::Close { via_select_item: _ } = event {
self.overflow_menu_index = None;
ctx.notify();
}
}
}
impl Entity for ShowBlocksView {
type Event = ShowBlocksEvent;
}
impl TypedActionView for ShowBlocksView {
type Action = ShowBlocksAction;
fn handle_action(&mut self, action: &Self::Action, ctx: &mut ViewContext<Self>) {
use ShowBlocksAction::*;
match action {
CopyUrl(url) => self.copy_url(url, ctx),
OverflowClick(index) => self.overflow_click(*index, ctx),
Unshare => self.unshare_button_click(ctx),
ConfirmUnshare => self.confirm_unshare(ctx),
CancelUnshare => self.cancel_unshare(ctx),
}
}
}
impl View for ShowBlocksView {
fn ui_name() -> &'static str {
"ShowBlockView"
}
fn render(&self, app: &AppContext) -> Box<dyn Element> {
self.page.render(self, app)
}
}
impl SettingsPageMeta for ShowBlocksView {
fn section() -> SettingsSection {
SettingsSection::SharedBlocks
}
fn should_render(&self, ctx: &AppContext) -> bool {
let is_anonymous = AuthStateProvider::as_ref(ctx)
.get()
.is_anonymous_or_logged_out();
!is_anonymous
}
fn on_page_selected(&mut self, _: bool, ctx: &mut ViewContext<Self>) {
self.load_blocks(ctx);
}
fn update_filter(&mut self, query: &str, ctx: &mut ViewContext<Self>) -> MatchData {
self.page.update_filter(query, ctx)
}
fn scroll_to_widget(&mut self, widget_id: &'static str) {
self.page.scroll_to_widget(widget_id)
}
fn clear_highlighted_widget(&mut self) {
self.page.clear_highlighted_widget();
}
}
impl From<ViewHandle<ShowBlocksView>> for SettingsPageViewHandle {
fn from(view_handle: ViewHandle<ShowBlocksView>) -> Self {
SettingsPageViewHandle::SharedBlocks(view_handle)
}
}
#[derive(Default)]
struct ShowBlocksWidget {}
impl ShowBlocksWidget {
fn render_confirm_delete_block_dialog(
&self,
view: &ShowBlocksView,
appearance: &Appearance,
) -> Box<dyn Element> {
let ui_builder = appearance.ui_builder();
ConstrainedBox::new(
Container::new(
Flex::column()
.with_child(
Align::new(
ui_builder
.label("Unshare block")
.with_style(UiComponentStyles {
font_size: Some(appearance.header_font_size()),
..Default::default()
})
.build()
.finish(),
)
.top_center()
.finish(),
)
.with_child(
Container::new(
ui_builder
.paragraph(UNSHARE_BLOCK_CONFIRMATION_DIALOG_TEXT)
.with_style(UiComponentStyles {
font_size: Some(appearance.ui_font_size() * 1.16),
..Default::default()
})
.build()
.finish(),
)
.with_padding_top(9.)
.finish(),
)
.with_child(
Container::new(
Align::new(
Flex::row()
.with_child(
ui_builder
.button(
ButtonVariant::Basic,
view.state_handles.cancel_dialog_handle.clone(),
)
.with_text_label("Cancel".into())
.build()
.on_click(|ctx, _, _| {
ctx.dispatch_typed_action(
ShowBlocksAction::CancelUnshare,
);
})
.finish(),
)
.with_child(
Container::new(
ui_builder
.button(
ButtonVariant::Accent,
view.state_handles
.confirm_dialog_handle
.clone(),
)
.with_text_label("Unshare".into())
.build()
.on_click(|ctx, _, _| {
ctx.dispatch_typed_action(
ShowBlocksAction::ConfirmUnshare,
);
})
.finish(),
)
.with_padding_left(10.)
.finish(),
)
.finish(),
)
.top_center()
.finish(),
)
.with_padding_top(20.)
.finish(),
)
.finish(),
)
.with_background(appearance.theme().surface_2())
.with_uniform_padding(20.)
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(4.)))
.finish(),
)
.with_height(220.)
.with_width(300.)
.finish()
}
}
impl SettingsWidget for ShowBlocksWidget {
type View = ShowBlocksView;
fn search_terms(&self) -> &str {
"shared blocks"
}
fn render(
&self,
view: &Self::View,
appearance: &Appearance,
_app: &AppContext,
) -> Box<dyn Element> {
let element_for_state = view.get_blocks_for_user_status.render(
appearance,
view.list_state.clone(),
view.state_handles.scroll_state_handle.clone(),
);
let inner_container = SavePosition::new(
Container::new(element_for_state).finish(),
"show_blocks_view:modal",
)
.finish();
let mut stack = Stack::new().with_child(inner_container);
if let Some(menu_index) = view.overflow_menu_index {
stack.add_positioned_overlay_child(
ChildView::new(&view.overflow_menu).finish(),
OffsetPositioning::offset_from_save_position_element(
format!("show_blocks_view:overflow_{menu_index}").as_str(),
vec2f(0., 2.),
PositionedElementOffsetBounds::Unbounded,
PositionedElementAnchor::BottomLeft,
ChildAnchor::TopLeft,
),
);
}
if view.pending_unshared_block_index.is_some() {
stack.add_positioned_child(
Dismiss::new(
Align::new(self.render_confirm_delete_block_dialog(view, appearance)).finish(),
)
.on_dismiss(|ctx, _app| ctx.dispatch_typed_action(ShowBlocksAction::CancelUnshare))
.finish(),
OffsetPositioning::offset_from_parent(
vec2f(0., 0.),
ParentOffsetBounds::ParentByPosition,
ParentAnchor::Center,
ChildAnchor::Center,
),
);
}
let header = render_page_title("Shared blocks", HEADER_FONT_SIZE, appearance);
let col = Flex::column()
.with_child(Container::new(header).with_margin_bottom(24.).finish())
.with_child(Expanded::new(1., stack.finish()).finish());
col.finish()
}
}
+1 -110
View File
@@ -39,9 +39,6 @@ use super::settings_page::{
SettingsPageMeta, SettingsPageViewHandle, SettingsWidget,
};
use super::tab_menu::Tabs;
use super::transfer_ownership_confirmation_modal::{
TransferOwnershipConfirmationEvent, TransferOwnershipConfirmationModal,
};
use super::SettingsSection;
use crate::ai::AIRequestUsageModel;
use crate::appearance::Appearance;
@@ -475,7 +472,6 @@ pub struct TeamsPageView {
team_action_confirmation_dialog: ViewHandle<CloudActionConfirmationDialog>,
show_team_action_confirmation_dialog: bool,
pending_team_action_confirmation: Option<TeamActionConfirmationTarget>,
transfer_ownership_modal_state: ModalViewState<Modal<TransferOwnershipConfirmationModal>>,
clipped_scroll_state: ClippedScrollStateHandle,
discoverable_teams_states: Vec<DiscoverableTeamState>,
rename_team_editor: ViewHandle<ClickableTextInput>,
@@ -605,18 +601,7 @@ impl TypedActionView for TeamsPageView {
self.join_team_with_team_discovery(*team_uid, ctx);
ctx.notify();
}
TeamsPageAction::ShowTransferOwnershipModal {
new_owner_email,
new_owner_uid,
team_uid,
} => {
self.show_transfer_ownership_modal(
new_owner_email.clone(),
*new_owner_uid,
*team_uid,
ctx,
);
}
TeamsPageAction::ShowTransferOwnershipModal { .. } => {}
TeamsPageAction::ToggleTeamDiscoverabilityBeforeCreation => {
self.checkbox_value = !self.checkbox_value;
}
@@ -796,35 +781,6 @@ impl TeamsPageView {
me.handle_cloud_action_confirmation_dialog_event(event, ctx);
});
let transfer_ownership_modal_body =
ctx.add_typed_action_view(|_| TransferOwnershipConfirmationModal::new());
ctx.subscribe_to_view(&transfer_ownership_modal_body, |me, _, event, ctx| {
me.handle_transfer_ownership_modal_event(event, ctx);
});
let transfer_ownership_modal = ctx.add_typed_action_view(|ctx| {
Modal::new(
Some("Transfer team ownership?".to_string()),
transfer_ownership_modal_body,
ctx,
)
.with_modal_style(UiComponentStyles {
height: Some(220.),
..Default::default()
})
.with_header_style(UiComponentStyles {
padding: Some(Coords::uniform(24.).bottom(16.)),
..Default::default()
})
.with_body_style(UiComponentStyles {
padding: Some(Coords::uniform(24.).top(0.).bottom(12.)),
height: Some(150.),
..Default::default()
})
});
ctx.subscribe_to_view(&transfer_ownership_modal, |me, _, event, ctx| {
me.handle_transfer_ownership_modal_close_event(event, ctx);
});
let member_actions_menu = ctx.add_typed_action_view(|_| Menu::new().with_drop_shadow());
ctx.subscribe_to_view(&member_actions_menu, |me, _, event, ctx| {
if let menu::Event::Close { .. } = event {
@@ -861,7 +817,6 @@ impl TeamsPageView {
team_action_confirmation_dialog,
show_team_action_confirmation_dialog: false,
pending_team_action_confirmation: None,
transfer_ownership_modal_state: ModalViewState::new(transfer_ownership_modal),
discoverable_teams_states: Vec::new(),
rename_team_editor,
checkbox_value: true,
@@ -1183,59 +1138,6 @@ impl TeamsPageView {
}
}
fn handle_transfer_ownership_modal_event(
&mut self,
event: &TransferOwnershipConfirmationEvent,
ctx: &mut ViewContext<Self>,
) {
match event {
TransferOwnershipConfirmationEvent::Confirm {
new_owner_uid,
team_uid,
} => {
self.set_team_member_role(*new_owner_uid, *team_uid, MembershipRole::Owner, ctx);
self.transfer_ownership_modal_state.close();
ctx.notify();
}
TransferOwnershipConfirmationEvent::Cancel => {
self.transfer_ownership_modal_state.close();
ctx.notify();
}
}
}
fn handle_transfer_ownership_modal_close_event(
&mut self,
event: &ModalEvent,
ctx: &mut ViewContext<Self>,
) {
match event {
ModalEvent::Close => {
self.transfer_ownership_modal_state.close();
ctx.notify();
}
}
}
fn show_transfer_ownership_modal(
&mut self,
new_owner_email: String,
new_owner_uid: UserUid,
team_uid: ServerId,
ctx: &mut ViewContext<Self>,
) {
self.transfer_ownership_modal_state
.view
.update(ctx, |modal, ctx| {
modal.body().update(ctx, |body, ctx| {
body.set_new_owner(new_owner_email, new_owner_uid, team_uid);
ctx.notify();
});
});
self.transfer_ownership_modal_state.open();
ctx.notify();
}
fn handle_approve_domains_block_editor_event(
&mut self,
event: &WordBlockEditorViewEvent,
@@ -4396,17 +4298,6 @@ impl SettingsWidget for TeamsWidget {
let mut stack = Stack::new();
stack.add_child(Flex::column().with_child(content).finish());
if view.transfer_ownership_modal_state.is_open() {
stack.add_positioned_overlay_child(
view.transfer_ownership_modal_state.render(),
OffsetPositioning::offset_from_parent(
vec2f(0., 0.),
ParentOffsetBounds::WindowByPosition,
ParentAnchor::Center,
ChildAnchor::Center,
),
);
}
if view.should_show_remove_user_from_team_confirmation_dialog() {
stack.add_positioned_overlay_child(
ChildView::new(&view.team_action_confirmation_dialog).finish(),
@@ -1,154 +0,0 @@
#![allow(dead_code)]
use galaxyui::elements::{
Align, Container, CrossAxisAlignment, Flex, MouseStateHandle, ParentElement, Text,
};
use warpui::ui_components::button::ButtonVariant;
use warpui::ui_components::components::{Coords, UiComponent, UiComponentStyles};
use warpui::{AppContext, Element, Entity, SingletonEntity, TypedActionView, View, ViewContext};
use crate::appearance::Appearance;
use crate::auth::UserUid;
use crate::server::ids::ServerId;
pub struct TransferOwnershipConfirmationModal {
cancel_mouse_state: MouseStateHandle,
confirm_mouse_state: MouseStateHandle,
new_owner_email: Option<String>,
new_owner_uid: Option<UserUid>,
team_uid: Option<ServerId>,
}
impl TransferOwnershipConfirmationModal {
pub fn new() -> Self {
Self {
cancel_mouse_state: Default::default(),
confirm_mouse_state: Default::default(),
new_owner_email: None,
new_owner_uid: None,
team_uid: None,
}
}
pub fn set_new_owner(&mut self, email: String, user_uid: UserUid, team_uid: ServerId) {
self.new_owner_email = Some(email);
self.new_owner_uid = Some(user_uid);
self.team_uid = Some(team_uid);
}
}
impl Entity for TransferOwnershipConfirmationModal {
type Event = TransferOwnershipConfirmationEvent;
}
impl View for TransferOwnershipConfirmationModal {
fn ui_name() -> &'static str {
"TransferOwnershipConfirmationModal"
}
fn render(&self, app: &AppContext) -> Box<dyn Element> {
let appearance = Appearance::as_ref(app);
let theme = appearance.theme();
let email = self.new_owner_email.as_deref().unwrap_or_default();
let description_text = Text::new(
format!(
"Are you sure you want to transfer team ownership to {}? You will no longer be the owner and will not be able to take any administrative actions for this team.",
email
),
appearance.ui_font_family(),
14.,
)
.with_color(theme.sub_text_color(theme.surface_2()).into())
.finish();
let button_style = UiComponentStyles {
font_size: Some(14.),
padding: Some(Coords::uniform(8.).left(12.).right(12.)),
..Default::default()
};
let buttons_row = Flex::row()
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.with_child(
appearance
.ui_builder()
.button(ButtonVariant::Secondary, self.cancel_mouse_state.clone())
.with_text_label("Cancel".to_string())
.with_style(button_style)
.build()
.on_click(|ctx, _, _| {
ctx.dispatch_typed_action(TransferOwnershipConfirmationAction::Cancel);
})
.finish(),
)
.with_child(
Container::new(
appearance
.ui_builder()
.button(ButtonVariant::Accent, self.confirm_mouse_state.clone())
.with_text_label("Transfer".to_string())
.with_style(button_style)
.build()
.on_click(|ctx, _, _| {
ctx.dispatch_typed_action(TransferOwnershipConfirmationAction::Confirm);
})
.finish(),
)
.with_margin_left(12.)
.finish(),
)
.finish();
Flex::column()
.with_child(
Container::new(description_text)
.with_margin_bottom(24.)
.finish(),
)
.with_child(Align::new(buttons_row).right().finish())
.finish()
}
}
pub enum TransferOwnershipConfirmationEvent {
Confirm {
new_owner_uid: UserUid,
team_uid: ServerId,
},
Cancel,
}
#[derive(Debug)]
pub enum TransferOwnershipConfirmationAction {
Confirm,
Cancel,
}
impl TypedActionView for TransferOwnershipConfirmationModal {
type Action = TransferOwnershipConfirmationAction;
fn handle_action(
&mut self,
action: &TransferOwnershipConfirmationAction,
ctx: &mut ViewContext<Self>,
) {
match action {
TransferOwnershipConfirmationAction::Confirm => {
let (Some(new_owner_uid), Some(team_uid)) = (self.new_owner_uid, self.team_uid)
else {
log::error!("Transfer ownership confirm button pressed with no new owner set");
return;
};
ctx.emit(TransferOwnershipConfirmationEvent::Confirm {
new_owner_uid,
team_uid,
});
}
TransferOwnershipConfirmationAction::Cancel => {
ctx.emit(TransferOwnershipConfirmationEvent::Cancel);
}
}
}
}