onboarding: Adjust the AI upsell card depending on user's state (#35658)

Use includes centralizing what each plan delivers in one single file
(`plan_definitions.rs`).

Release Notes:

- N/A
This commit is contained in:
Danilo Leal 2025-08-05 19:22:48 -03:00 committed by GitHub
parent 0025019db4
commit 30414d154e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 398 additions and 284 deletions

View file

@ -1,8 +1,6 @@
use gpui::{Action, IntoElement, ParentElement, RenderOnce, point};
use language_model::{LanguageModelRegistry, ZED_CLOUD_PROVIDER_ID};
use ui::{Divider, List, prelude::*};
use crate::BulletItem;
use ui::{Divider, List, ListBulletItem, prelude::*};
pub struct ApiKeysWithProviders {
configured_providers: Vec<(IconName, SharedString)>,
@ -128,7 +126,7 @@ impl RenderOnce for ApiKeysWithoutProviders {
)
.child(Divider::horizontal()),
)
.child(List::new().child(BulletItem::new(
.child(List::new().child(ListBulletItem::new(
"Add your own keys to use AI without signing in.",
)))
.child(

View file

@ -3,6 +3,7 @@ mod agent_panel_onboarding_card;
mod agent_panel_onboarding_content;
mod ai_upsell_card;
mod edit_prediction_onboarding_content;
mod plan_definitions;
mod young_account_banner;
pub use agent_api_keys_onboarding::{ApiKeysWithProviders, ApiKeysWithoutProviders};
@ -11,51 +12,14 @@ pub use agent_panel_onboarding_content::AgentPanelOnboarding;
pub use ai_upsell_card::AiUpsellCard;
use cloud_llm_client::Plan;
pub use edit_prediction_onboarding_content::EditPredictionOnboarding;
pub use plan_definitions::PlanDefinitions;
pub use young_account_banner::YoungAccountBanner;
use std::sync::Arc;
use client::{Client, UserStore, zed_urls};
use gpui::{AnyElement, Entity, IntoElement, ParentElement, SharedString};
use ui::{Divider, List, ListItem, RegisterComponent, TintColor, Tooltip, prelude::*};
#[derive(IntoElement)]
pub struct BulletItem {
label: SharedString,
}
impl BulletItem {
pub fn new(label: impl Into<SharedString>) -> Self {
Self {
label: label.into(),
}
}
}
impl RenderOnce for BulletItem {
fn render(self, window: &mut Window, _cx: &mut App) -> impl IntoElement {
let line_height = 0.85 * window.line_height();
ListItem::new("list-item")
.selectable(false)
.child(
h_flex()
.w_full()
.min_w_0()
.gap_1()
.items_start()
.child(
h_flex().h(line_height).justify_center().child(
Icon::new(IconName::Dash)
.size(IconSize::XSmall)
.color(Color::Hidden),
),
)
.child(div().w_full().min_w_0().child(Label::new(self.label))),
)
.into_any_element()
}
}
use gpui::{AnyElement, Entity, IntoElement, ParentElement};
use ui::{Divider, RegisterComponent, TintColor, Tooltip, prelude::*};
#[derive(PartialEq)]
pub enum SignInStatus {
@ -130,107 +94,6 @@ impl ZedAiOnboarding {
self
}
fn free_plan_definition(&self, cx: &mut App) -> impl IntoElement {
v_flex()
.mt_2()
.gap_1()
.child(
h_flex()
.gap_2()
.child(
Label::new("Free")
.size(LabelSize::Small)
.color(Color::Muted)
.buffer_font(cx),
)
.child(
Label::new("(Current Plan)")
.size(LabelSize::Small)
.color(Color::Custom(cx.theme().colors().text_muted.opacity(0.6)))
.buffer_font(cx),
)
.child(Divider::horizontal()),
)
.child(
List::new()
.child(BulletItem::new("50 prompts per month with Claude models"))
.child(BulletItem::new(
"2,000 accepted edit predictions with Zeta, our open-source model",
)),
)
}
fn pro_trial_definition(&self) -> impl IntoElement {
List::new()
.child(BulletItem::new("150 prompts with Claude models"))
.child(BulletItem::new(
"Unlimited accepted edit predictions with Zeta, our open-source model",
))
}
fn pro_plan_definition(&self, cx: &mut App) -> impl IntoElement {
v_flex().mt_2().gap_1().map(|this| {
if self.account_too_young {
this.child(
h_flex()
.gap_2()
.child(
Label::new("Pro")
.size(LabelSize::Small)
.color(Color::Accent)
.buffer_font(cx),
)
.child(Divider::horizontal()),
)
.child(
List::new()
.child(BulletItem::new("500 prompts per month with Claude models"))
.child(BulletItem::new(
"Unlimited accepted edit predictions with Zeta, our open-source model",
))
.child(BulletItem::new("$20 USD per month")),
)
.child(
Button::new("pro", "Get Started")
.full_width()
.style(ButtonStyle::Tinted(ui::TintColor::Accent))
.on_click(move |_, _window, cx| {
telemetry::event!("Upgrade To Pro Clicked", state = "young-account");
cx.open_url(&zed_urls::upgrade_to_zed_pro_url(cx))
}),
)
} else {
this.child(
h_flex()
.gap_2()
.child(
Label::new("Pro Trial")
.size(LabelSize::Small)
.color(Color::Accent)
.buffer_font(cx),
)
.child(Divider::horizontal()),
)
.child(
List::new()
.child(self.pro_trial_definition())
.child(BulletItem::new(
"Try it out for 14 days for free, no credit card required",
)),
)
.child(
Button::new("pro", "Start Free Trial")
.full_width()
.style(ButtonStyle::Tinted(ui::TintColor::Accent))
.on_click(move |_, _window, cx| {
telemetry::event!("Start Trial Clicked", state = "post-sign-in");
cx.open_url(&zed_urls::start_trial_url(cx))
}),
)
}
})
}
fn render_accept_terms_of_service(&self) -> AnyElement {
v_flex()
.gap_1()
@ -269,6 +132,7 @@ impl ZedAiOnboarding {
fn render_sign_in_disclaimer(&self, _cx: &mut App) -> AnyElement {
let signing_in = matches!(self.sign_in_status, SignInStatus::SigningIn);
let plan_definitions = PlanDefinitions;
v_flex()
.gap_1()
@ -278,7 +142,7 @@ impl ZedAiOnboarding {
.color(Color::Muted)
.mb_2(),
)
.child(self.pro_trial_definition())
.child(plan_definitions.pro_plan(false))
.child(
Button::new("sign_in", "Try Zed Pro for Free")
.disabled(signing_in)
@ -297,43 +161,132 @@ impl ZedAiOnboarding {
fn render_free_plan_state(&self, cx: &mut App) -> AnyElement {
let young_account_banner = YoungAccountBanner;
let plan_definitions = PlanDefinitions;
v_flex()
.relative()
.gap_1()
.child(Headline::new("Welcome to Zed AI"))
.map(|this| {
if self.account_too_young {
this.child(young_account_banner)
} else {
this.child(self.free_plan_definition(cx)).when_some(
self.dismiss_onboarding.as_ref(),
|this, dismiss_callback| {
let callback = dismiss_callback.clone();
if self.account_too_young {
v_flex()
.relative()
.max_w_full()
.gap_1()
.child(Headline::new("Welcome to Zed AI"))
.child(young_account_banner)
.child(
v_flex()
.mt_2()
.gap_1()
.child(
h_flex()
.gap_2()
.child(
Label::new("Pro")
.size(LabelSize::Small)
.color(Color::Accent)
.buffer_font(cx),
)
.child(Divider::horizontal()),
)
.child(plan_definitions.pro_plan(true))
.child(
Button::new("pro", "Get Started")
.full_width()
.style(ButtonStyle::Tinted(ui::TintColor::Accent))
.on_click(move |_, _window, cx| {
telemetry::event!(
"Upgrade To Pro Clicked",
state = "young-account"
);
cx.open_url(&zed_urls::upgrade_to_zed_pro_url(cx))
}),
),
)
.into_any_element()
} else {
v_flex()
.relative()
.gap_1()
.child(Headline::new("Welcome to Zed AI"))
.child(
v_flex()
.mt_2()
.gap_1()
.child(
h_flex()
.gap_2()
.child(
Label::new("Free")
.size(LabelSize::Small)
.color(Color::Muted)
.buffer_font(cx),
)
.child(
Label::new("(Current Plan)")
.size(LabelSize::Small)
.color(Color::Custom(
cx.theme().colors().text_muted.opacity(0.6),
))
.buffer_font(cx),
)
.child(Divider::horizontal()),
)
.child(plan_definitions.free_plan()),
)
.when_some(
self.dismiss_onboarding.as_ref(),
|this, dismiss_callback| {
let callback = dismiss_callback.clone();
this.child(
h_flex().absolute().top_0().right_0().child(
IconButton::new("dismiss_onboarding", IconName::Close)
.icon_size(IconSize::Small)
.tooltip(Tooltip::text("Dismiss"))
.on_click(move |_, window, cx| {
telemetry::event!(
"Banner Dismissed",
source = "AI Onboarding",
);
callback(window, cx)
}),
),
)
},
)
}
})
.child(self.pro_plan_definition(cx))
.into_any_element()
this.child(
h_flex().absolute().top_0().right_0().child(
IconButton::new("dismiss_onboarding", IconName::Close)
.icon_size(IconSize::Small)
.tooltip(Tooltip::text("Dismiss"))
.on_click(move |_, window, cx| {
telemetry::event!(
"Banner Dismissed",
source = "AI Onboarding",
);
callback(window, cx)
}),
),
)
},
)
.child(
v_flex()
.mt_2()
.gap_1()
.child(
h_flex()
.gap_2()
.child(
Label::new("Pro Trial")
.size(LabelSize::Small)
.color(Color::Accent)
.buffer_font(cx),
)
.child(Divider::horizontal()),
)
.child(plan_definitions.pro_trial(true))
.child(
Button::new("pro", "Start Free Trial")
.full_width()
.style(ButtonStyle::Tinted(ui::TintColor::Accent))
.on_click(move |_, _window, cx| {
telemetry::event!(
"Start Trial Clicked",
state = "post-sign-in"
);
cx.open_url(&zed_urls::start_trial_url(cx))
}),
),
)
.into_any_element()
}
}
fn render_trial_state(&self, _cx: &mut App) -> AnyElement {
let plan_definitions = PlanDefinitions;
v_flex()
.relative()
.gap_1()
@ -343,13 +296,7 @@ impl ZedAiOnboarding {
.color(Color::Muted)
.mb_2(),
)
.child(
List::new()
.child(BulletItem::new("150 prompts with Claude models"))
.child(BulletItem::new(
"Unlimited edit predictions with Zeta, our open-source model",
)),
)
.child(plan_definitions.pro_trial(false))
.when_some(
self.dismiss_onboarding.as_ref(),
|this, dismiss_callback| {
@ -374,6 +321,8 @@ impl ZedAiOnboarding {
}
fn render_pro_plan_state(&self, _cx: &mut App) -> AnyElement {
let plan_definitions = PlanDefinitions;
v_flex()
.gap_1()
.child(Headline::new("Welcome to Zed Pro"))
@ -382,13 +331,7 @@ impl ZedAiOnboarding {
.color(Color::Muted)
.mb_2(),
)
.child(
List::new()
.child(BulletItem::new("500 prompts with Claude models"))
.child(BulletItem::new(
"Unlimited edit predictions with Zeta, our open-source model",
)),
)
.child(plan_definitions.pro_plan(false))
.child(
Button::new("pro", "Continue with Zed Pro")
.full_width()
@ -450,8 +393,9 @@ impl Component for ZedAiOnboarding {
Some(
v_flex()
.p_4()
.gap_4()
.items_center()
.max_w_4_5()
.children(vec![
single_example(
"Not Signed-in",
@ -462,8 +406,8 @@ impl Component for ZedAiOnboarding {
onboarding(SignInStatus::SignedIn, false, None, false),
),
single_example(
"Account too young",
onboarding(SignInStatus::SignedIn, false, None, true),
"Young Account",
onboarding(SignInStatus::SignedIn, true, None, true),
),
single_example(
"Free Plan",

View file

@ -1,11 +1,14 @@
use std::sync::Arc;
use std::{sync::Arc, time::Duration};
use client::{Client, zed_urls};
use cloud_llm_client::Plan;
use gpui::{AnyElement, App, IntoElement, RenderOnce, Window};
use ui::{Divider, List, Vector, VectorName, prelude::*};
use gpui::{
Animation, AnimationExt, AnyElement, App, IntoElement, RenderOnce, Transformation, Window,
percentage,
};
use ui::{Divider, Vector, VectorName, prelude::*};
use crate::{BulletItem, SignInStatus};
use crate::{SignInStatus, plan_definitions::PlanDefinitions};
#[derive(IntoElement, RegisterComponent)]
pub struct AiUpsellCard {
@ -36,6 +39,8 @@ impl AiUpsellCard {
impl RenderOnce for AiUpsellCard {
fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
let plan_definitions = PlanDefinitions;
let pro_section = v_flex()
.flex_grow()
.w_full()
@ -51,13 +56,7 @@ impl RenderOnce for AiUpsellCard {
)
.child(Divider::horizontal()),
)
.child(
List::new()
.child(BulletItem::new("500 prompts with Claude models"))
.child(BulletItem::new(
"Unlimited edit predictions with Zeta, our open-source model",
)),
);
.child(plan_definitions.pro_plan(false));
let free_section = v_flex()
.flex_grow()
@ -74,11 +73,7 @@ impl RenderOnce for AiUpsellCard {
)
.child(Divider::horizontal()),
)
.child(
List::new()
.child(BulletItem::new("50 prompts with Claude models"))
.child(BulletItem::new("2,000 accepted edit predictions")),
);
.child(plan_definitions.free_plan());
let grid_bg = h_flex().absolute().inset_0().w_full().h(px(240.)).child(
Vector::new(VectorName::Grid, rems_from_px(500.), rems_from_px(240.))
@ -101,44 +96,11 @@ impl RenderOnce for AiUpsellCard {
),
));
const DESCRIPTION: &str = "Zed offers a complete agentic experience, with robust editing and reviewing features to collaborate with AI.";
let description = PlanDefinitions::AI_DESCRIPTION;
let footer_buttons = match self.sign_in_status {
SignInStatus::SignedIn => v_flex()
.items_center()
.gap_1()
.child(
Button::new("sign_in", "Start 14-day Free Pro Trial")
.full_width()
.style(ButtonStyle::Tinted(ui::TintColor::Accent))
.on_click(move |_, _window, cx| {
telemetry::event!("Start Trial Clicked", state = "post-sign-in");
cx.open_url(&zed_urls::start_trial_url(cx))
})
.when_some(self.tab_index, |this, tab_index| this.tab_index(tab_index)),
)
.child(
Label::new("No credit card required")
.size(LabelSize::Small)
.color(Color::Muted),
)
.into_any_element(),
_ => Button::new("sign_in", "Sign In")
.full_width()
.style(ButtonStyle::Tinted(ui::TintColor::Accent))
.when_some(self.tab_index, |this, tab_index| this.tab_index(tab_index))
.on_click({
let callback = self.sign_in.clone();
move |_, window, cx| {
telemetry::event!("Start Trial Clicked", state = "pre-sign-in");
callback(window, cx)
}
})
.into_any_element(),
};
v_flex()
let card = v_flex()
.relative()
.flex_grow()
.p_4()
.pt_3()
.border_1()
@ -146,25 +108,129 @@ impl RenderOnce for AiUpsellCard {
.rounded_lg()
.overflow_hidden()
.child(grid_bg)
.child(gradient_bg)
.child(Label::new("Try Zed AI").size(LabelSize::Large))
.child(gradient_bg);
let plans_section = h_flex()
.w_full()
.mt_1p5()
.mb_2p5()
.items_start()
.gap_6()
.child(free_section)
.child(pro_section);
let footer_container = v_flex().items_center().gap_1();
let certified_user_stamp = div()
.absolute()
.top_2()
.right_2()
.size(rems_from_px(72.))
.child(
div()
.max_w_3_4()
.mb_2()
.child(Label::new(DESCRIPTION).color(Color::Muted)),
)
Vector::new(
VectorName::CertifiedUserStamp,
rems_from_px(72.),
rems_from_px(72.),
)
.color(Color::Custom(cx.theme().colors().text_accent.alpha(0.3)))
.with_animation(
"loading_stamp",
Animation::new(Duration::from_secs(10)).repeat(),
|this, delta| this.transform(Transformation::rotate(percentage(delta))),
),
);
let pro_trial_stamp = div()
.absolute()
.top_2()
.right_2()
.size(rems_from_px(72.))
.child(
h_flex()
.w_full()
.mt_1p5()
.mb_2p5()
.items_start()
.gap_6()
.child(free_section)
.child(pro_section),
)
.child(footer_buttons)
Vector::new(
VectorName::ProTrialStamp,
rems_from_px(72.),
rems_from_px(72.),
)
.color(Color::Custom(cx.theme().colors().text.alpha(0.2))),
);
match self.sign_in_status {
SignInStatus::SignedIn => match self.user_plan {
None | Some(Plan::ZedFree) => card
.child(Label::new("Try Zed AI").size(LabelSize::Large))
.child(
div()
.max_w_3_4()
.mb_2()
.child(Label::new(description).color(Color::Muted)),
)
.child(plans_section)
.child(
footer_container
.child(
Button::new("start_trial", "Start 14-day Free Pro Trial")
.full_width()
.style(ButtonStyle::Tinted(ui::TintColor::Accent))
.when_some(self.tab_index, |this, tab_index| {
this.tab_index(tab_index)
})
.on_click(move |_, _window, cx| {
telemetry::event!(
"Start Trial Clicked",
state = "post-sign-in"
);
cx.open_url(&zed_urls::start_trial_url(cx))
}),
)
.child(
Label::new("No credit card required")
.size(LabelSize::Small)
.color(Color::Muted),
),
),
Some(Plan::ZedProTrial) => card
.child(pro_trial_stamp)
.child(Label::new("You're in the Zed Pro Trial").size(LabelSize::Large))
.child(
Label::new("Here's what you get for the next 14 days:")
.color(Color::Muted)
.mb_2(),
)
.child(plan_definitions.pro_trial(false)),
Some(Plan::ZedPro) => card
.child(certified_user_stamp)
.child(Label::new("You're in the Zed Pro plan").size(LabelSize::Large))
.child(
Label::new("Here's what you get:")
.color(Color::Muted)
.mb_2(),
)
.child(plan_definitions.pro_plan(false)),
},
// Signed Out State
_ => card
.child(Label::new("Try Zed AI").size(LabelSize::Large))
.child(
div()
.max_w_3_4()
.mb_2()
.child(Label::new(description).color(Color::Muted)),
)
.child(plans_section)
.child(
Button::new("sign_in", "Sign In")
.full_width()
.style(ButtonStyle::Tinted(ui::TintColor::Accent))
.when_some(self.tab_index, |this, tab_index| this.tab_index(tab_index))
.on_click({
let callback = self.sign_in.clone();
move |_, window, cx| {
telemetry::event!("Start Trial Clicked", state = "pre-sign-in");
callback(window, cx)
}
}),
),
}
}
}
@ -188,7 +254,6 @@ impl Component for AiUpsellCard {
fn preview(_window: &mut Window, _cx: &mut App) -> Option<AnyElement> {
Some(
v_flex()
.p_4()
.gap_4()
.children(vec![example_group(vec![
single_example(
@ -202,11 +267,31 @@ impl Component for AiUpsellCard {
.into_any_element(),
),
single_example(
"Signed In State",
"Free Plan",
AiUpsellCard {
sign_in_status: SignInStatus::SignedIn,
sign_in: Arc::new(|_, _| {}),
user_plan: None,
user_plan: Some(Plan::ZedFree),
tab_index: Some(1),
}
.into_any_element(),
),
single_example(
"Pro Trial",
AiUpsellCard {
sign_in_status: SignInStatus::SignedIn,
sign_in: Arc::new(|_, _| {}),
user_plan: Some(Plan::ZedProTrial),
tab_index: Some(1),
}
.into_any_element(),
),
single_example(
"Pro Plan",
AiUpsellCard {
sign_in_status: SignInStatus::SignedIn,
sign_in: Arc::new(|_, _| {}),
user_plan: Some(Plan::ZedPro),
tab_index: Some(1),
}
.into_any_element(),

View file

@ -0,0 +1,39 @@
use gpui::{IntoElement, ParentElement};
use ui::{List, ListBulletItem, prelude::*};
/// Centralized definitions for Zed AI plans
pub struct PlanDefinitions;
impl PlanDefinitions {
pub const AI_DESCRIPTION: &'static str = "Zed offers a complete agentic experience, with robust editing and reviewing features to collaborate with AI.";
pub fn free_plan(&self) -> impl IntoElement {
List::new()
.child(ListBulletItem::new("50 prompts with Claude models"))
.child(ListBulletItem::new("2,000 accepted edit predictions"))
}
pub fn pro_trial(&self, period: bool) -> impl IntoElement {
List::new()
.child(ListBulletItem::new("150 prompts with Claude models"))
.child(ListBulletItem::new(
"Unlimited edit predictions with Zeta, our open-source model",
))
.when(period, |this| {
this.child(ListBulletItem::new(
"Try it out for 14 days for free, no credit card required",
))
})
}
pub fn pro_plan(&self, price: bool) -> impl IntoElement {
List::new()
.child(ListBulletItem::new("500 prompts with Claude models"))
.child(ListBulletItem::new(
"Unlimited edit predictions with Zeta, our open-source model",
))
.when(price, |this| {
this.child(ListBulletItem::new("$20 USD per month"))
})
}
}

View file

@ -15,6 +15,7 @@ impl RenderOnce for YoungAccountBanner {
.child(YOUNG_ACCOUNT_DISCLAIMER);
div()
.max_w_full()
.my_1()
.child(Banner::new().severity(ui::Severity::Warning).child(label))
}