first pass of merging in warp (doesn't build)
This commit is contained in:
@@ -0,0 +1,433 @@
|
||||
use pathfinder_color::ColorU;
|
||||
use pathfinder_geometry::vector::vec2f;
|
||||
use serde_json::{json, Value};
|
||||
use strum_macros::{EnumDiscriminants, EnumIter};
|
||||
use galaxy_core::send_telemetry_from_ctx;
|
||||
use galaxy_core::telemetry::{EnablementState, TelemetryEvent, TelemetryEventDesc};
|
||||
use galaxy_core::ui::appearance::Appearance;
|
||||
use galaxy_core::ui::theme::Fill;
|
||||
use warpui::elements::{
|
||||
Align, Border, ChildAnchor, ConstrainedBox, Container, CornerRadius, CrossAxisAlignment,
|
||||
DropShadow, Flex, FormattedTextElement, MainAxisAlignment, MainAxisSize, MouseStateHandle,
|
||||
OffsetPositioning, ParentAnchor, ParentElement, ParentOffsetBounds, Radius, Stack,
|
||||
};
|
||||
use warpui::fonts::Weight;
|
||||
use warpui::keymap::FixedBinding;
|
||||
use warpui::platform::Cursor;
|
||||
use warpui::ui_components::button::ButtonVariant;
|
||||
use warpui::ui_components::components::{UiComponent, UiComponentStyles};
|
||||
use warpui::{AppContext, Element, Entity, SingletonEntity, TypedActionView, View, ViewContext};
|
||||
|
||||
use crate::auth::AuthStateProvider;
|
||||
use crate::settings_view::SettingsSection;
|
||||
use crate::ui_components::blended_colors;
|
||||
use crate::workspace::WorkspaceAction;
|
||||
use crate::workspaces::user_workspaces::UserWorkspaces;
|
||||
|
||||
const MODAL_WIDTH: f32 = 480.;
|
||||
const CORNER_RADIUS: f32 = 12.;
|
||||
const PANEL_PADDING: f32 = 24.;
|
||||
const CLOSE_BUTTON_DIAMETER: f32 = 20.;
|
||||
|
||||
const NOTICE_TITLE_TEXT: &str = "Warp is no longer providing inference on the free plan.";
|
||||
const NOTICE_BODY_TEXT: &str = "To keep using Warp's AI features, please upgrade to a paid plan, \
|
||||
bring your own API key or endpoint, or log in with your Grok subscription.";
|
||||
const NOTICE_BONUS_CREDITS_TEXT: &str = "If you have any unused bonus credits, AI will keep \
|
||||
working until these run out.";
|
||||
|
||||
const PROMPT_SUGGESTIONS_TITLE_TEXT: &str = "How to use AI features in Warp";
|
||||
const PROMPT_SUGGESTIONS_BODY_TEXT: &str = "To use AI features in Warp, subscribe to a paid plan, \
|
||||
add an API key (OpenAI, Anthropic, or Google), add a custom inference endpoint (OpenRouter, \
|
||||
LiteLLM), or log in using your SuperGrok subscription.";
|
||||
|
||||
/// Which surface opened the modal. Selects the copy and disambiguates telemetry;
|
||||
/// the layout and CTAs are identical across variants.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum FreeAiRemovalModalVariant {
|
||||
/// One-time notice shown to Free users when Warp-provided AI is removed from their plan.
|
||||
Notice,
|
||||
/// Shown on demand when a Free user activates Prompt Suggestions while out of credits.
|
||||
PromptSuggestions,
|
||||
}
|
||||
|
||||
impl FreeAiRemovalModalVariant {
|
||||
fn title(self) -> &'static str {
|
||||
match self {
|
||||
Self::Notice => NOTICE_TITLE_TEXT,
|
||||
Self::PromptSuggestions => PROMPT_SUGGESTIONS_TITLE_TEXT,
|
||||
}
|
||||
}
|
||||
|
||||
fn body(self) -> &'static str {
|
||||
match self {
|
||||
Self::Notice => NOTICE_BODY_TEXT,
|
||||
Self::PromptSuggestions => PROMPT_SUGGESTIONS_BODY_TEXT,
|
||||
}
|
||||
}
|
||||
|
||||
/// Secondary note rendered under the body. The on-demand Prompt Suggestions
|
||||
/// variant only fires once the user is already out of credits, so the
|
||||
/// bonus-credits note doesn't apply there.
|
||||
fn secondary(self) -> Option<&'static str> {
|
||||
match self {
|
||||
Self::Notice => Some(NOTICE_BONUS_CREDITS_TEXT),
|
||||
Self::PromptSuggestions => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Notice => "notice",
|
||||
Self::PromptSuggestions => "prompt_suggestions",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn init(app: &mut AppContext) {
|
||||
use warpui::keymap::macros::*;
|
||||
|
||||
app.register_fixed_bindings([FixedBinding::new(
|
||||
"escape",
|
||||
FreeAiRemovalModalAction::Close,
|
||||
id!("FreeAiRemovalModal"),
|
||||
)]);
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum FreeAiRemovalModalAction {
|
||||
Close,
|
||||
SetUpByok,
|
||||
Upgrade,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub enum FreeAiRemovalModalEvent {
|
||||
Close,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct StateHandles {
|
||||
close_button: MouseStateHandle,
|
||||
byok_button: MouseStateHandle,
|
||||
upgrade_button: MouseStateHandle,
|
||||
}
|
||||
|
||||
/// Notice shown to Free-plan users about the removal of Warp-provided AI. The
|
||||
/// `variant` selects the copy: a one-time rollout notice, or an on-demand prompt
|
||||
/// when a Free user activates a gated feature (e.g. Prompt Suggestions).
|
||||
pub struct FreeAiRemovalModal {
|
||||
variant: FreeAiRemovalModalVariant,
|
||||
state_handles: StateHandles,
|
||||
}
|
||||
|
||||
impl FreeAiRemovalModal {
|
||||
pub fn new(variant: FreeAiRemovalModalVariant, _ctx: &mut ViewContext<Self>) -> Self {
|
||||
Self {
|
||||
variant,
|
||||
state_handles: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
fn upgrade_url(ctx: &ViewContext<Self>) -> String {
|
||||
if let Some(team) = UserWorkspaces::as_ref(ctx).current_team() {
|
||||
UserWorkspaces::upgrade_link_for_team(team.uid)
|
||||
} else {
|
||||
let user_id = AuthStateProvider::as_ref(ctx)
|
||||
.get()
|
||||
.user_id()
|
||||
.unwrap_or_default();
|
||||
UserWorkspaces::upgrade_link(user_id)
|
||||
}
|
||||
}
|
||||
|
||||
fn render_buttons(&self, appearance: &Appearance) -> Box<dyn Element> {
|
||||
let byok_button = appearance
|
||||
.ui_builder()
|
||||
.button(
|
||||
ButtonVariant::Secondary,
|
||||
self.state_handles.byok_button.clone(),
|
||||
)
|
||||
.with_style(UiComponentStyles {
|
||||
font_size: Some(14.),
|
||||
height: Some(32.),
|
||||
..Default::default()
|
||||
})
|
||||
.with_centered_text_label("Bring your own AI".to_string())
|
||||
.build()
|
||||
.with_cursor(Cursor::PointingHand)
|
||||
.on_click(|ctx, _, _| {
|
||||
ctx.dispatch_typed_action(FreeAiRemovalModalAction::SetUpByok);
|
||||
})
|
||||
.finish();
|
||||
|
||||
let upgrade_button = appearance
|
||||
.ui_builder()
|
||||
.button(
|
||||
ButtonVariant::Accent,
|
||||
self.state_handles.upgrade_button.clone(),
|
||||
)
|
||||
.with_style(UiComponentStyles {
|
||||
font_size: Some(14.),
|
||||
height: Some(32.),
|
||||
..Default::default()
|
||||
})
|
||||
.with_centered_text_label("View pricing".to_string())
|
||||
.build()
|
||||
.with_cursor(Cursor::PointingHand)
|
||||
.on_click(|ctx, _, _| {
|
||||
ctx.dispatch_typed_action(FreeAiRemovalModalAction::Upgrade);
|
||||
})
|
||||
.finish();
|
||||
|
||||
Flex::row()
|
||||
.with_main_axis_size(MainAxisSize::Max)
|
||||
.with_main_axis_alignment(MainAxisAlignment::End)
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Center)
|
||||
.with_spacing(8.)
|
||||
.with_child(byok_button)
|
||||
.with_child(upgrade_button)
|
||||
.finish()
|
||||
}
|
||||
|
||||
fn render_close_button(&self, appearance: &Appearance) -> Box<dyn Element> {
|
||||
appearance
|
||||
.ui_builder()
|
||||
.close_button(
|
||||
CLOSE_BUTTON_DIAMETER,
|
||||
self.state_handles.close_button.clone(),
|
||||
)
|
||||
.build()
|
||||
.on_click(|ctx, _, _| ctx.dispatch_typed_action(FreeAiRemovalModalAction::Close))
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl Entity for FreeAiRemovalModal {
|
||||
type Event = FreeAiRemovalModalEvent;
|
||||
}
|
||||
|
||||
impl View for FreeAiRemovalModal {
|
||||
fn ui_name() -> &'static str {
|
||||
"FreeAiRemovalModal"
|
||||
}
|
||||
|
||||
fn render(&self, app: &AppContext) -> Box<dyn Element> {
|
||||
let appearance = Appearance::as_ref(app);
|
||||
let theme = appearance.theme();
|
||||
let bg = blended_colors::neutral_1(theme);
|
||||
let font_family = appearance.ui_font_family();
|
||||
|
||||
let title = FormattedTextElement::from_str(self.variant.title(), font_family, 18.)
|
||||
.with_color(blended_colors::text_main(theme, bg))
|
||||
.with_weight(Weight::Bold)
|
||||
.finish();
|
||||
|
||||
let body_color = blended_colors::text_sub(theme, bg);
|
||||
let body = FormattedTextElement::from_str(self.variant.body(), font_family, 14.)
|
||||
.with_color(body_color)
|
||||
.finish();
|
||||
|
||||
let mut content = Flex::column()
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Start)
|
||||
.with_child(Container::new(title).with_margin_bottom(12.).finish());
|
||||
|
||||
// Tighten the gap below the body when a secondary note follows; otherwise
|
||||
// keep the full gap above the buttons.
|
||||
let body_margin_bottom = if self.variant.secondary().is_some() {
|
||||
8.
|
||||
} else {
|
||||
20.
|
||||
};
|
||||
content.add_child(
|
||||
Container::new(body)
|
||||
.with_margin_bottom(body_margin_bottom)
|
||||
.finish(),
|
||||
);
|
||||
|
||||
if let Some(secondary_text) = self.variant.secondary() {
|
||||
let secondary = FormattedTextElement::from_str(secondary_text, font_family, 14.)
|
||||
.with_color(body_color)
|
||||
.finish();
|
||||
content.add_child(Container::new(secondary).with_margin_bottom(20.).finish());
|
||||
}
|
||||
|
||||
let content = content.with_child(self.render_buttons(appearance)).finish();
|
||||
|
||||
let mut modal = Stack::new();
|
||||
modal.add_child(
|
||||
Container::new(
|
||||
ConstrainedBox::new(content)
|
||||
.with_width(MODAL_WIDTH)
|
||||
.finish(),
|
||||
)
|
||||
.with_background_color(bg)
|
||||
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(CORNER_RADIUS)))
|
||||
.with_border(Border::all(1.).with_border_fill(theme.outline()))
|
||||
.with_uniform_padding(PANEL_PADDING)
|
||||
.with_drop_shadow(DropShadow::default())
|
||||
.finish(),
|
||||
);
|
||||
modal.add_positioned_child(
|
||||
self.render_close_button(appearance),
|
||||
OffsetPositioning::offset_from_parent(
|
||||
vec2f(-8., 8.),
|
||||
ParentOffsetBounds::ParentByPosition,
|
||||
ParentAnchor::TopRight,
|
||||
ChildAnchor::TopRight,
|
||||
),
|
||||
);
|
||||
|
||||
let mut stack = Stack::new();
|
||||
stack.add_positioned_child(
|
||||
modal.finish(),
|
||||
OffsetPositioning::offset_from_parent(
|
||||
vec2f(0., 0.),
|
||||
ParentOffsetBounds::WindowByPosition,
|
||||
ParentAnchor::Center,
|
||||
ChildAnchor::Center,
|
||||
),
|
||||
);
|
||||
|
||||
Container::new(Align::new(stack.finish()).finish())
|
||||
.with_background(Fill::Solid(ColorU::new(97, 97, 97, 255)).with_opacity(50))
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl TypedActionView for FreeAiRemovalModal {
|
||||
type Action = FreeAiRemovalModalAction;
|
||||
|
||||
fn handle_action(&mut self, action: &Self::Action, ctx: &mut ViewContext<Self>) {
|
||||
match action {
|
||||
FreeAiRemovalModalAction::Close => {
|
||||
send_telemetry_from_ctx!(
|
||||
FreeAiRemovalModalTelemetryEvent::Dismissed {
|
||||
variant: self.variant,
|
||||
},
|
||||
ctx
|
||||
);
|
||||
ctx.emit(FreeAiRemovalModalEvent::Close);
|
||||
}
|
||||
FreeAiRemovalModalAction::SetUpByok => {
|
||||
send_telemetry_from_ctx!(
|
||||
FreeAiRemovalModalTelemetryEvent::CtaClicked {
|
||||
variant: self.variant,
|
||||
cta: FreeAiRemovalModalCta::SetUpByok,
|
||||
},
|
||||
ctx
|
||||
);
|
||||
// Deferred so the close-driven refocus below doesn't steal focus from
|
||||
// the settings page this opens.
|
||||
ctx.dispatch_typed_action_deferred(WorkspaceAction::ShowSettingsPageWithSearch {
|
||||
search_query: "api".to_string(),
|
||||
section: Some(SettingsSection::WarpAgent),
|
||||
});
|
||||
ctx.emit(FreeAiRemovalModalEvent::Close);
|
||||
}
|
||||
FreeAiRemovalModalAction::Upgrade => {
|
||||
send_telemetry_from_ctx!(
|
||||
FreeAiRemovalModalTelemetryEvent::CtaClicked {
|
||||
variant: self.variant,
|
||||
cta: FreeAiRemovalModalCta::Upgrade,
|
||||
},
|
||||
ctx
|
||||
);
|
||||
let upgrade_url = Self::upgrade_url(ctx);
|
||||
ctx.open_url(&upgrade_url);
|
||||
ctx.emit(FreeAiRemovalModalEvent::Close);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub enum FreeAiRemovalModalCta {
|
||||
SetUpByok,
|
||||
Upgrade,
|
||||
}
|
||||
|
||||
impl FreeAiRemovalModalCta {
|
||||
fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
Self::SetUpByok => "set_up_byok",
|
||||
Self::Upgrade => "upgrade",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, EnumDiscriminants)]
|
||||
#[strum_discriminants(derive(EnumIter))]
|
||||
pub enum FreeAiRemovalModalTelemetryEvent {
|
||||
Shown {
|
||||
variant: FreeAiRemovalModalVariant,
|
||||
},
|
||||
Dismissed {
|
||||
variant: FreeAiRemovalModalVariant,
|
||||
},
|
||||
CtaClicked {
|
||||
variant: FreeAiRemovalModalVariant,
|
||||
cta: FreeAiRemovalModalCta,
|
||||
},
|
||||
}
|
||||
|
||||
impl TelemetryEvent for FreeAiRemovalModalTelemetryEvent {
|
||||
fn name(&self) -> &'static str {
|
||||
FreeAiRemovalModalTelemetryEventDiscriminants::from(self).name()
|
||||
}
|
||||
|
||||
fn payload(&self) -> Option<Value> {
|
||||
match self {
|
||||
Self::Shown { variant } | Self::Dismissed { variant } => Some(json!({
|
||||
"variant": variant.as_str(),
|
||||
})),
|
||||
Self::CtaClicked { variant, cta } => Some(json!({
|
||||
"variant": variant.as_str(),
|
||||
"cta": cta.as_str(),
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
fn description(&self) -> &'static str {
|
||||
FreeAiRemovalModalTelemetryEventDiscriminants::from(self).description()
|
||||
}
|
||||
|
||||
fn enablement_state(&self) -> EnablementState {
|
||||
FreeAiRemovalModalTelemetryEventDiscriminants::from(self).enablement_state()
|
||||
}
|
||||
|
||||
fn contains_ugc(&self) -> bool {
|
||||
match self {
|
||||
Self::Shown { .. } | Self::Dismissed { .. } | Self::CtaClicked { .. } => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn event_descs() -> impl Iterator<Item = Box<dyn TelemetryEventDesc>> {
|
||||
galaxy_core::telemetry::enum_events::<Self>()
|
||||
}
|
||||
}
|
||||
|
||||
impl TelemetryEventDesc for FreeAiRemovalModalTelemetryEventDiscriminants {
|
||||
fn name(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Shown => "FreeAiRemovalModal.Shown",
|
||||
Self::Dismissed => "FreeAiRemovalModal.Dismissed",
|
||||
Self::CtaClicked => "FreeAiRemovalModal.CtaClicked",
|
||||
}
|
||||
}
|
||||
|
||||
fn description(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Shown => "The free AI removal notice modal was shown to the user",
|
||||
Self::Dismissed => "The user dismissed the free AI removal notice modal",
|
||||
Self::CtaClicked => "The user clicked a CTA in the free AI removal notice modal",
|
||||
}
|
||||
}
|
||||
|
||||
fn enablement_state(&self) -> EnablementState {
|
||||
match self {
|
||||
Self::Shown | Self::Dismissed | Self::CtaClicked => EnablementState::Always,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
galaxy_core::register_telemetry_event!(FreeAiRemovalModalTelemetryEvent);
|
||||
Reference in New Issue
Block a user