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

Ben Brandt and Antonio Scandurra created

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>

Change summary

crates/agent/src/agent_panel.rs                                 | 105 +
crates/agent/src/trial_markdown.md                              |   3 
crates/client/src/user.rs                                       |   8 
crates/collab/src/rpc.rs                                        |  14 
crates/inline_completion_button/src/inline_completion_button.rs | 143 +-
crates/proto/proto/app.proto                                    |   1 
crates/zeta/src/zeta.rs                                         |  10 
7 files changed, 207 insertions(+), 77 deletions(-)

Detailed changes

crates/agent/src/agent_panel.rs 🔗

@@ -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,77 @@ 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),
+            ToggleState::Unselected,
+            move |toggle_state, _window, cx| {
+                let toggle_state_bool = toggle_state.selected();
+
+                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),
@@ -1992,7 +2051,7 @@ 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);
             },
         );
 
@@ -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";
 }
 

crates/agent/src/trial_markdown.md 🔗

@@ -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.

crates/client/src/user.rs 🔗

@@ -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())

crates/collab/src/rpc.rs 🔗

@@ -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,

crates/inline_completion_button/src/inline_completion_button.rs 🔗

@@ -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()),

crates/proto/proto/app.proto 🔗

@@ -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 {

crates/zeta/src/zeta.rs 🔗

@@ -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