Better messaging for accounts that are too young (#31212)

Right now you find this out the first time you try and submit a
completion.

These changes communicate much earlier to the user what the issue is
with their account and what they can do about it.

Release Notes:

- N/A

---------

Co-authored-by: Antonio Scandurra <me@as-cii.com>
This commit is contained in:
Ben Brandt 2025-05-23 11:32:03 +02:00 committed by GitHub
parent 9f7987c532
commit 508ccde363
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 207 additions and 77 deletions

View file

@ -4,7 +4,6 @@ use std::sync::Arc;
use std::time::Duration;
use db::kvp::{Dismissable, KEY_VALUE_STORE};
use markdown::Markdown;
use serde::{Deserialize, Serialize};
use anyhow::{Result, anyhow};
@ -157,7 +156,7 @@ pub fn init(cx: &mut App) {
window.refresh();
})
.register_action(|_workspace, _: &ResetTrialUpsell, _window, cx| {
TrialUpsell::set_dismissed(false, cx);
Upsell::set_dismissed(false, cx);
})
.register_action(|_workspace, _: &ResetTrialEndUpsell, _window, cx| {
TrialEndUpsell::set_dismissed(false, cx);
@ -370,8 +369,7 @@ pub struct AgentPanel {
height: Option<Pixels>,
zoomed: bool,
pending_serialization: Option<Task<Result<()>>>,
hide_trial_upsell: bool,
_trial_markdown: Entity<Markdown>,
hide_upsell: bool,
}
impl AgentPanel {
@ -676,15 +674,6 @@ impl AgentPanel {
},
);
let trial_markdown = cx.new(|cx| {
Markdown::new(
include_str!("trial_markdown.md").into(),
Some(language_registry.clone()),
None,
cx,
)
});
Self {
active_view,
workspace,
@ -721,8 +710,7 @@ impl AgentPanel {
height: None,
zoomed: false,
pending_serialization: None,
hide_trial_upsell: false,
_trial_markdown: trial_markdown,
hide_upsell: false,
}
}
@ -1946,7 +1934,7 @@ impl AgentPanel {
return false;
}
if self.hide_trial_upsell || TrialUpsell::dismissed() {
if self.hide_upsell || Upsell::dismissed() {
return false;
}
@ -1976,7 +1964,7 @@ impl AgentPanel {
true
}
fn render_trial_upsell(
fn render_upsell(
&self,
_window: &mut Window,
cx: &mut Context<Self>,
@ -1985,6 +1973,14 @@ impl AgentPanel {
return None;
}
if self.user_store.read(cx).current_user_account_too_young() {
Some(self.render_young_account_upsell(cx).into_any_element())
} else {
Some(self.render_trial_upsell(cx).into_any_element())
}
}
fn render_young_account_upsell(&self, cx: &mut Context<Self>) -> impl IntoElement {
let checkbox = CheckboxWithLabel::new(
"dont-show-again",
Label::new("Don't show again").color(Color::Muted),
@ -1992,7 +1988,70 @@ impl AgentPanel {
move |toggle_state, _window, cx| {
let toggle_state_bool = toggle_state.selected();
TrialUpsell::set_dismissed(toggle_state_bool, cx);
Upsell::set_dismissed(toggle_state_bool, cx);
},
);
let contents = div()
.size_full()
.gap_2()
.flex()
.flex_col()
.child(Headline::new("Build better with Zed Pro").size(HeadlineSize::Small))
.child(
Label::new("Your GitHub account was created less than 30 days ago, so we can't offer you a free trial.")
.size(LabelSize::Small),
)
.child(
Label::new(
"Use your own API keys, upgrade to Zed Pro or send an email to billing-support@zed.dev.",
)
.color(Color::Muted),
)
.child(
h_flex()
.w_full()
.px_neg_1()
.justify_between()
.items_center()
.child(h_flex().items_center().gap_1().child(checkbox))
.child(
h_flex()
.gap_2()
.child(
Button::new("dismiss-button", "Not Now")
.style(ButtonStyle::Transparent)
.color(Color::Muted)
.on_click({
let agent_panel = cx.entity();
move |_, _, cx| {
agent_panel.update(cx, |this, cx| {
this.hide_upsell = true;
cx.notify();
});
}
}),
)
.child(
Button::new("cta-button", "Upgrade to Zed Pro")
.style(ButtonStyle::Transparent)
.on_click(|_, _, cx| cx.open_url(&zed_urls::account_url(cx))),
),
),
);
self.render_upsell_container(cx, contents)
}
fn render_trial_upsell(&self, cx: &mut Context<Self>) -> impl IntoElement {
let checkbox = CheckboxWithLabel::new(
"dont-show-again",
Label::new("Don't show again").color(Color::Muted),
ToggleState::Unselected,
move |toggle_state, _window, cx| {
let toggle_state_bool = toggle_state.selected();
Upsell::set_dismissed(toggle_state_bool, cx);
},
);
@ -2030,7 +2089,7 @@ impl AgentPanel {
let agent_panel = cx.entity();
move |_, _, cx| {
agent_panel.update(cx, |this, cx| {
this.hide_trial_upsell = true;
this.hide_upsell = true;
cx.notify();
});
}
@ -2044,7 +2103,7 @@ impl AgentPanel {
),
);
Some(self.render_upsell_container(cx, contents))
self.render_upsell_container(cx, contents)
}
fn render_trial_end_upsell(
@ -2910,7 +2969,7 @@ impl Render for AgentPanel {
.on_action(cx.listener(Self::reset_font_size))
.on_action(cx.listener(Self::toggle_zoom))
.child(self.render_toolbar(window, cx))
.children(self.render_trial_upsell(window, cx))
.children(self.render_upsell(window, cx))
.children(self.render_trial_end_upsell(window, cx))
.map(|parent| match &self.active_view {
ActiveView::Thread { .. } => parent
@ -3099,9 +3158,9 @@ impl AgentPanelDelegate for ConcreteAssistantPanelDelegate {
}
}
struct TrialUpsell;
struct Upsell;
impl Dismissable for TrialUpsell {
impl Dismissable for Upsell {
const KEY: &'static str = "dismissed-trial-upsell";
}

View file

@ -1,3 +0,0 @@
# Build better with Zed Pro
Try [Zed Pro](https://zed.dev/pricing) for free for 14 days - no credit card required. Only $20/month afterward. Cancel anytime.

View file

@ -108,6 +108,7 @@ pub struct UserStore {
edit_predictions_usage_amount: Option<u32>,
edit_predictions_usage_limit: Option<proto::UsageLimit>,
is_usage_based_billing_enabled: Option<bool>,
account_too_young: Option<bool>,
current_user: watch::Receiver<Option<Arc<User>>>,
accepted_tos_at: Option<Option<DateTime<Utc>>>,
contacts: Vec<Arc<Contact>>,
@ -174,6 +175,7 @@ impl UserStore {
edit_predictions_usage_amount: None,
edit_predictions_usage_limit: None,
is_usage_based_billing_enabled: None,
account_too_young: None,
accepted_tos_at: None,
contacts: Default::default(),
incoming_contact_requests: Default::default(),
@ -347,6 +349,7 @@ impl UserStore {
.trial_started_at
.and_then(|trial_started_at| DateTime::from_timestamp(trial_started_at as i64, 0));
this.is_usage_based_billing_enabled = message.payload.is_usage_based_billing_enabled;
this.account_too_young = message.payload.account_too_young;
if let Some(usage) = message.payload.usage {
this.model_request_usage_amount = Some(usage.model_requests_usage_amount);
@ -752,6 +755,11 @@ impl UserStore {
self.current_user.clone()
}
/// Check if the current user's account is too new to use the service
pub fn current_user_account_too_young(&self) -> bool {
self.account_too_young.unwrap_or(false)
}
pub fn current_user_has_accepted_terms(&self) -> Option<bool> {
self.accepted_tos_at
.map(|accepted_tos_at| accepted_tos_at.is_some())

View file

@ -2716,6 +2716,7 @@ async fn make_update_user_plan_message(
let plan = current_plan(db, user_id, is_staff).await?;
let billing_customer = db.get_billing_customer_by_user_id(user_id).await?;
let billing_preferences = db.get_billing_preferences(user_id).await?;
let user = db.get_user_by_id(user_id).await?;
let (subscription_period, usage) = if let Some(llm_db) = llm_db {
let subscription = db.get_active_billing_subscription(user_id).await?;
@ -2736,6 +2737,18 @@ async fn make_update_user_plan_message(
(None, None)
};
// Calculate account_too_young
let account_too_young = if matches!(plan, proto::Plan::ZedPro) {
// If they have paid, then we allow them to use all of the features
false
} else if let Some(user) = user {
// If we have access to the profile age, we use that
chrono::Utc::now().naive_utc() - user.account_created_at() < MIN_ACCOUNT_AGE_FOR_LLM_USE
} else {
// Default to false otherwise
false
};
Ok(proto::UpdateUserPlan {
plan: plan.into(),
trial_started_at: billing_customer
@ -2752,6 +2765,7 @@ async fn make_update_user_plan_message(
ended_at: ended_at.timestamp() as u64,
}
}),
account_too_young: Some(account_too_young),
usage: usage.map(|usage| {
let plan = match plan {
proto::Plan::Free => zed_llm_client::Plan::ZedFree,

View file

@ -420,56 +420,6 @@ impl InlineCompletionButton {
let fs = self.fs.clone();
let line_height = window.line_height();
if let Some(usage) = self
.edit_prediction_provider
.as_ref()
.and_then(|provider| provider.usage(cx))
{
menu = menu.header("Usage");
menu = menu
.custom_entry(
move |_window, cx| {
let used_percentage = match usage.limit {
UsageLimit::Limited(limit) => {
Some((usage.amount as f32 / limit as f32) * 100.)
}
UsageLimit::Unlimited => None,
};
h_flex()
.flex_1()
.gap_1p5()
.children(
used_percentage
.map(|percent| ProgressBar::new("usage", percent, 100., cx)),
)
.child(
Label::new(match usage.limit {
UsageLimit::Limited(limit) => {
format!("{} / {limit}", usage.amount)
}
UsageLimit::Unlimited => format!("{} / ∞", usage.amount),
})
.size(LabelSize::Small)
.color(Color::Muted),
)
.into_any_element()
},
move |_, cx| cx.open_url(&zed_urls::account_url(cx)),
)
.when(usage.over_limit(), |menu| -> ContextMenu {
menu.entry("Subscribe to increase your limit", None, |window, cx| {
window.dispatch_action(
Box::new(OpenZedUrl {
url: zed_urls::account_url(cx),
}),
cx,
);
})
})
.separator();
}
menu = menu.header("Show Edit Predictions For");
let language_state = self.language.as_ref().map(|language| {
@ -745,7 +695,98 @@ impl InlineCompletionButton {
window: &mut Window,
cx: &mut Context<Self>,
) -> Entity<ContextMenu> {
ContextMenu::build(window, cx, |menu, window, cx| {
ContextMenu::build(window, cx, |mut menu, window, cx| {
if let Some(usage) = self
.edit_prediction_provider
.as_ref()
.and_then(|provider| provider.usage(cx))
{
menu = menu.header("Usage");
menu = menu
.custom_entry(
move |_window, cx| {
let used_percentage = match usage.limit {
UsageLimit::Limited(limit) => {
Some((usage.amount as f32 / limit as f32) * 100.)
}
UsageLimit::Unlimited => None,
};
h_flex()
.flex_1()
.gap_1p5()
.children(
used_percentage.map(|percent| {
ProgressBar::new("usage", percent, 100., cx)
}),
)
.child(
Label::new(match usage.limit {
UsageLimit::Limited(limit) => {
format!("{} / {limit}", usage.amount)
}
UsageLimit::Unlimited => format!("{} / ∞", usage.amount),
})
.size(LabelSize::Small)
.color(Color::Muted),
)
.into_any_element()
},
move |_, cx| cx.open_url(&zed_urls::account_url(cx)),
)
.when(usage.over_limit(), |menu| -> ContextMenu {
menu.entry("Subscribe to increase your limit", None, |window, cx| {
window.dispatch_action(
Box::new(OpenZedUrl {
url: zed_urls::account_url(cx),
}),
cx,
);
})
})
.separator();
} else if self.user_store.read(cx).current_user_account_too_young() {
menu = menu
.custom_entry(
|_window, _cx| {
h_flex()
.gap_1()
.child(
Icon::new(IconName::Warning)
.size(IconSize::Small)
.color(Color::Warning),
)
.child(
Label::new("Your GitHub account is less than 30 days old")
.size(LabelSize::Small)
.color(Color::Warning),
)
.into_any_element()
},
|window, cx| {
window.dispatch_action(
Box::new(OpenZedUrl {
url: zed_urls::account_url(cx),
}),
cx,
);
},
)
.entry(
"You need to upgrade to Zed Pro or contact us.",
None,
|window, cx| {
window.dispatch_action(
Box::new(OpenZedUrl {
url: zed_urls::account_url(cx),
}),
cx,
);
},
)
.separator();
}
self.build_language_settings_menu(menu, window, cx).when(
cx.has_flag::<PredictEditsRateCompletionsFeatureFlag>(),
|this| this.action("Rate Completions", RateCompletions.boxed_clone()),

View file

@ -27,6 +27,7 @@ message UpdateUserPlan {
optional bool is_usage_based_billing_enabled = 3;
optional SubscriptionUsage usage = 4;
optional SubscriptionPeriod subscription_period = 5;
optional bool account_too_young = 6;
}
message SubscriptionPeriod {

View file

@ -1574,6 +1574,16 @@ impl inline_completion::EditPredictionProvider for ZetaInlineCompletionProvider
return;
}
if self
.zeta
.read(cx)
.user_store
.read(cx)
.current_user_account_too_young()
{
return;
}
if let Some(current_completion) = self.current_completion.as_ref() {
let snapshot = buffer.read(cx).snapshot();
if current_completion