ZIm/crates/ai_onboarding/src/ai_onboarding.rs
Danilo Leal f6f7762f32 ai onboarding: Add overall fixes to the whole flow (#34996)
Closes https://github.com/zed-industries/zed/issues/34979

Release Notes:

- N/A

---------

Co-authored-by: Agus Zubiaga <hi@aguz.me>
Co-authored-by: Ben Kunkle <Ben.kunkle@gmail.com>
2025-07-24 11:20:26 -04:00

492 lines
18 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

mod agent_api_keys_onboarding;
mod agent_panel_onboarding_card;
mod agent_panel_onboarding_content;
mod edit_prediction_onboarding_content;
mod young_account_banner;
pub use agent_api_keys_onboarding::{ApiKeysWithProviders, ApiKeysWithoutProviders};
pub use agent_panel_onboarding_card::AgentPanelOnboardingCard;
pub use agent_panel_onboarding_content::AgentPanelOnboarding;
pub use edit_prediction_onboarding_content::EditPredictionOnboarding;
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()
}
}
pub enum SignInStatus {
SignedIn,
SigningIn,
SignedOut,
}
impl From<client::Status> for SignInStatus {
fn from(status: client::Status) -> Self {
if status.is_signing_in() {
Self::SigningIn
} else if status.is_signed_out() {
Self::SignedOut
} else {
Self::SignedIn
}
}
}
#[derive(RegisterComponent, IntoElement)]
pub struct ZedAiOnboarding {
pub sign_in_status: SignInStatus,
pub has_accepted_terms_of_service: bool,
pub plan: Option<proto::Plan>,
pub account_too_young: bool,
pub continue_with_zed_ai: Arc<dyn Fn(&mut Window, &mut App)>,
pub sign_in: Arc<dyn Fn(&mut Window, &mut App)>,
pub accept_terms_of_service: Arc<dyn Fn(&mut Window, &mut App)>,
pub dismiss_onboarding: Option<Arc<dyn Fn(&mut Window, &mut App)>>,
}
impl ZedAiOnboarding {
pub fn new(
client: Arc<Client>,
user_store: &Entity<UserStore>,
continue_with_zed_ai: Arc<dyn Fn(&mut Window, &mut App)>,
cx: &mut App,
) -> Self {
let store = user_store.read(cx);
let status = *client.status().borrow();
Self {
sign_in_status: status.into(),
has_accepted_terms_of_service: store.current_user_has_accepted_terms().unwrap_or(false),
plan: store.current_plan(),
account_too_young: store.account_too_young(),
continue_with_zed_ai,
accept_terms_of_service: Arc::new({
let store = user_store.clone();
move |_window, cx| {
let task = store.update(cx, |store, cx| store.accept_terms_of_service(cx));
task.detach_and_log_err(cx);
}
}),
sign_in: Arc::new(move |_window, cx| {
cx.spawn({
let client = client.clone();
async move |cx| {
client.authenticate_and_connect(true, cx).await;
}
})
.detach();
}),
dismiss_onboarding: None,
}
}
pub fn with_dismiss(
mut self,
dismiss_callback: impl Fn(&mut Window, &mut App) + 'static,
) -> Self {
self.dismiss_onboarding = Some(Arc::new(dismiss_callback));
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()
.w_full()
.child(Headline::new("Accept Terms of Service"))
.child(
Label::new("We dont sell your data, track you across the web, or compromise your privacy.")
.color(Color::Muted)
.mb_2(),
)
.child(
Button::new("terms_of_service", "Review Terms of Service")
.full_width()
.style(ButtonStyle::Outlined)
.icon(IconName::ArrowUpRight)
.icon_color(Color::Muted)
.icon_size(IconSize::XSmall)
.on_click(move |_, _window, cx| {
telemetry::event!("Review Terms of Service Clicked");
cx.open_url(&zed_urls::terms_of_service(cx))
}),
)
.child(
Button::new("accept_terms", "Accept")
.full_width()
.style(ButtonStyle::Tinted(TintColor::Accent))
.on_click({
let callback = self.accept_terms_of_service.clone();
move |_, window, cx| {
telemetry::event!("Terms of Service Accepted");
(callback)(window, cx)}
}),
)
.into_any_element()
}
fn render_sign_in_disclaimer(&self, _cx: &mut App) -> AnyElement {
let signing_in = matches!(self.sign_in_status, SignInStatus::SigningIn);
v_flex()
.gap_1()
.child(Headline::new("Welcome to Zed AI"))
.child(
Label::new("Sign in to try Zed Pro for 14 days, no credit card required.")
.color(Color::Muted)
.mb_2(),
)
.child(self.pro_trial_definition())
.child(
Button::new("sign_in", "Try Zed Pro for Free")
.disabled(signing_in)
.full_width()
.style(ButtonStyle::Tinted(ui::TintColor::Accent))
.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()
}
fn render_free_plan_state(&self, cx: &mut App) -> AnyElement {
let young_account_banner = YoungAccountBanner;
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();
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()
}
fn render_trial_state(&self, _cx: &mut App) -> AnyElement {
v_flex()
.relative()
.gap_1()
.child(Headline::new("Welcome to the Zed Pro Trial"))
.child(
Label::new("Here's what you get for the next 14 days:")
.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",
)),
)
.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)
}),
),
)
},
)
.into_any_element()
}
fn render_pro_plan_state(&self, _cx: &mut App) -> AnyElement {
v_flex()
.gap_1()
.child(Headline::new("Welcome to Zed Pro"))
.child(
Label::new("Here's what you get:")
.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(
Button::new("pro", "Continue with Zed Pro")
.full_width()
.style(ButtonStyle::Outlined)
.on_click({
let callback = self.continue_with_zed_ai.clone();
move |_, window, cx| {
telemetry::event!("Banner Dismissed", source = "AI Onboarding");
callback(window, cx)
}
}),
)
.into_any_element()
}
}
impl RenderOnce for ZedAiOnboarding {
fn render(self, _window: &mut ui::Window, cx: &mut App) -> impl IntoElement {
if matches!(self.sign_in_status, SignInStatus::SignedIn) {
if self.has_accepted_terms_of_service {
match self.plan {
None | Some(proto::Plan::Free) => self.render_free_plan_state(cx),
Some(proto::Plan::ZedProTrial) => self.render_trial_state(cx),
Some(proto::Plan::ZedPro) => self.render_pro_plan_state(cx),
}
} else {
self.render_accept_terms_of_service()
}
} else {
self.render_sign_in_disclaimer(cx)
}
}
}
impl Component for ZedAiOnboarding {
fn scope() -> ComponentScope {
ComponentScope::Agent
}
fn preview(_window: &mut Window, _cx: &mut App) -> Option<AnyElement> {
fn onboarding(
sign_in_status: SignInStatus,
has_accepted_terms_of_service: bool,
plan: Option<proto::Plan>,
account_too_young: bool,
) -> AnyElement {
ZedAiOnboarding {
sign_in_status,
has_accepted_terms_of_service,
plan,
account_too_young,
continue_with_zed_ai: Arc::new(|_, _| {}),
sign_in: Arc::new(|_, _| {}),
accept_terms_of_service: Arc::new(|_, _| {}),
dismiss_onboarding: None,
}
.into_any_element()
}
Some(
v_flex()
.p_4()
.gap_4()
.children(vec![
single_example(
"Not Signed-in",
onboarding(SignInStatus::SignedOut, false, None, false),
),
single_example(
"Not Accepted ToS",
onboarding(SignInStatus::SignedIn, false, None, false),
),
single_example(
"Account too young",
onboarding(SignInStatus::SignedIn, false, None, true),
),
single_example(
"Free Plan",
onboarding(SignInStatus::SignedIn, true, Some(proto::Plan::Free), false),
),
single_example(
"Pro Trial",
onboarding(
SignInStatus::SignedIn,
true,
Some(proto::Plan::ZedProTrial),
false,
),
),
single_example(
"Pro Plan",
onboarding(
SignInStatus::SignedIn,
true,
Some(proto::Plan::ZedPro),
false,
),
),
])
.into_any_element(),
)
}
}