diff --git a/crates/agent/src/agent.rs b/crates/agent/src/agent.rs index 2a0b9ebc65..352a699443 100644 --- a/crates/agent/src/agent.rs +++ b/crates/agent/src/agent.rs @@ -85,6 +85,7 @@ actions!( KeepAll, Follow, ResetTrialUpsell, + ResetTrialEndUpsell, ] ); diff --git a/crates/agent/src/agent_panel.rs b/crates/agent/src/agent_panel.rs index d1f0e816f5..1de3b79ba6 100644 --- a/crates/agent/src/agent_panel.rs +++ b/crates/agent/src/agent_panel.rs @@ -3,7 +3,7 @@ use std::path::Path; use std::sync::Arc; use std::time::Duration; -use db::kvp::KEY_VALUE_STORE; +use db::kvp::{Dismissable, KEY_VALUE_STORE}; use markdown::Markdown; use serde::{Deserialize, Serialize}; @@ -66,8 +66,8 @@ use crate::ui::AgentOnboardingModal; use crate::{ AddContextServer, AgentDiffPane, ContextStore, DeleteRecentlyOpenThread, ExpandMessageEditor, Follow, InlineAssistant, NewTextThread, NewThread, OpenActiveThreadAsMarkdown, OpenAgentDiff, - OpenHistory, ResetTrialUpsell, TextThreadStore, ThreadEvent, ToggleContextPicker, - ToggleNavigationMenu, ToggleOptionsMenu, + OpenHistory, ResetTrialEndUpsell, ResetTrialUpsell, TextThreadStore, ThreadEvent, + ToggleContextPicker, ToggleNavigationMenu, ToggleOptionsMenu, }; const AGENT_PANEL_KEY: &str = "agent_panel"; @@ -157,7 +157,10 @@ pub fn init(cx: &mut App) { window.refresh(); }) .register_action(|_workspace, _: &ResetTrialUpsell, _window, cx| { - set_trial_upsell_dismissed(false, cx); + TrialUpsell::set_dismissed(false, cx); + }) + .register_action(|_workspace, _: &ResetTrialEndUpsell, _window, cx| { + TrialEndUpsell::set_dismissed(false, cx); }); }, ) @@ -1932,12 +1935,23 @@ impl AgentPanel { } } + fn should_render_trial_end_upsell(&self, cx: &mut Context) -> bool { + if TrialEndUpsell::dismissed() { + return false; + } + + let plan = self.user_store.read(cx).current_plan(); + let has_previous_trial = self.user_store.read(cx).trial_started_at().is_some(); + + matches!(plan, Some(Plan::Free)) && has_previous_trial + } + fn should_render_upsell(&self, cx: &mut Context) -> bool { if !matches!(self.active_view, ActiveView::Thread { .. }) { return false; } - if self.hide_trial_upsell || dismissed_trial_upsell() { + if self.hide_trial_upsell || TrialUpsell::dismissed() { return false; } @@ -1983,125 +1997,115 @@ impl AgentPanel { move |toggle_state, _window, cx| { let toggle_state_bool = toggle_state.selected(); - set_trial_upsell_dismissed(toggle_state_bool, cx); + TrialUpsell::set_dismissed(toggle_state_bool, cx); }, ); - Some( - div().p_2().child( - v_flex() + let contents = div() + .size_full() + .gap_2() + .flex() + .flex_col() + .child(Headline::new("Build better with Zed Pro").size(HeadlineSize::Small)) + .child( + Label::new("Try Zed Pro for free for 14 days - no credit card required.") + .size(LabelSize::Small), + ) + .child( + Label::new( + "Use your own API keys or enable usage-based billing once you hit the cap.", + ) + .color(Color::Muted), + ) + .child( + h_flex() .w_full() - .elevation_2(cx) - .rounded(px(8.)) - .bg(cx.theme().colors().background.alpha(0.5)) - .p(px(3.)) - + .px_neg_1() + .justify_between() + .items_center() + .child(h_flex().items_center().gap_1().child(checkbox)) .child( - div() + h_flex() .gap_2() - .flex() - .flex_col() - .size_full() - .border_1() - .rounded(px(5.)) - .border_color(cx.theme().colors().text.alpha(0.1)) - .overflow_hidden() - .relative() - .bg(cx.theme().colors().panel_background) - .px_4() - .py_3() .child( - div() - .absolute() - .top_0() - .right(px(-1.0)) - .w(px(441.)) - .h(px(167.)) - .child( - Vector::new(VectorName::Grid, rems_from_px(441.), rems_from_px(167.)).color(ui::Color::Custom(cx.theme().colors().text.alpha(0.1))) - ) + 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_trial_upsell = true; + cx.notify(); + }); + } + }), ) .child( - div() - .absolute() - .top(px(-8.0)) - .right_0() - .w(px(400.)) - .h(px(92.)) - .child( - Vector::new(VectorName::AiGrid, rems_from_px(400.), rems_from_px(92.)).color(ui::Color::Custom(cx.theme().colors().text.alpha(0.32))) - ) - ) - // .child( - // div() - // .absolute() - // .top_0() - // .right(px(360.)) - // .size(px(401.)) - // .overflow_hidden() - // .bg(cx.theme().colors().panel_background) - // ) - .child( - div() - .absolute() - .top_0() - .right_0() - .w(px(660.)) - .h(px(401.)) - .overflow_hidden() - .bg(linear_gradient( - 75., - linear_color_stop(cx.theme().colors().panel_background.alpha(0.01), 1.0), - linear_color_stop(cx.theme().colors().panel_background, 0.45), - )) - ) - .child(Headline::new("Build better with Zed Pro").size(HeadlineSize::Small)) - .child(Label::new("Try Zed Pro for free for 14 days - no credit card required.").size(LabelSize::Small)) - .child(Label::new("Use your own API keys or enable usage-based billing once you hit the cap.").color(Color::Muted)) + Button::new("cta-button", "Start Trial") + .style(ButtonStyle::Transparent) + .on_click(|_, _, cx| cx.open_url(&zed_urls::account_url(cx))), + ), + ), + ); + + Some(self.render_upsell_container(cx, contents)) + } + + fn render_trial_end_upsell( + &self, + _window: &mut Window, + cx: &mut Context, + ) -> Option { + if !self.should_render_trial_end_upsell(cx) { + return None; + } + + Some( + self.render_upsell_container( + cx, + div() + .size_full() + .gap_2() + .flex() + .flex_col() + .child( + Headline::new("Your Zed Pro trial has expired.").size(HeadlineSize::Small), + ) + .child( + Label::new("You've been automatically reset to the free plan.") + .size(LabelSize::Small), + ) + .child( + h_flex() + .w_full() + .px_neg_1() + .justify_between() + .items_center() + .child(div()) .child( h_flex() - .w_full() - .px_neg_1() - .justify_between() - .items_center() - .child(h_flex().items_center().gap_1().child(checkbox)) + .gap_2() .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| { - let hidden = - this.hide_trial_upsell; - println!("hidden: {}", hidden); - this.hide_trial_upsell = true; - let new_hidden = - this.hide_trial_upsell; - println!( - "new_hidden: {}", - new_hidden - ); - - cx.notify(); - }, - ); - } - }), - ) - .child( - Button::new("cta-button", "Start Trial") - .style(ButtonStyle::Transparent) - .on_click(|_, _, cx| { - cx.open_url(&zed_urls::account_url(cx)) - }), - ), + Button::new("dismiss-button", "Stay on Free") + .style(ButtonStyle::Transparent) + .color(Color::Muted) + .on_click({ + let agent_panel = cx.entity(); + move |_, _, cx| { + agent_panel.update(cx, |_this, cx| { + TrialEndUpsell::set_dismissed(true, cx); + 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)) + }), ), ), ), @@ -2109,6 +2113,91 @@ impl AgentPanel { ) } + fn render_upsell_container(&self, cx: &mut Context, content: Div) -> Div { + div().p_2().child( + v_flex() + .w_full() + .elevation_2(cx) + .rounded(px(8.)) + .bg(cx.theme().colors().background.alpha(0.5)) + .p(px(3.)) + .child( + div() + .gap_2() + .flex() + .flex_col() + .size_full() + .border_1() + .rounded(px(5.)) + .border_color(cx.theme().colors().text.alpha(0.1)) + .overflow_hidden() + .relative() + .bg(cx.theme().colors().panel_background) + .px_4() + .py_3() + .child( + div() + .absolute() + .top_0() + .right(px(-1.0)) + .w(px(441.)) + .h(px(167.)) + .child( + Vector::new( + VectorName::Grid, + rems_from_px(441.), + rems_from_px(167.), + ) + .color(ui::Color::Custom(cx.theme().colors().text.alpha(0.1))), + ), + ) + .child( + div() + .absolute() + .top(px(-8.0)) + .right_0() + .w(px(400.)) + .h(px(92.)) + .child( + Vector::new( + VectorName::AiGrid, + rems_from_px(400.), + rems_from_px(92.), + ) + .color(ui::Color::Custom(cx.theme().colors().text.alpha(0.32))), + ), + ) + // .child( + // div() + // .absolute() + // .top_0() + // .right(px(360.)) + // .size(px(401.)) + // .overflow_hidden() + // .bg(cx.theme().colors().panel_background) + // ) + .child( + div() + .absolute() + .top_0() + .right_0() + .w(px(660.)) + .h(px(401.)) + .overflow_hidden() + .bg(linear_gradient( + 75., + linear_color_stop( + cx.theme().colors().panel_background.alpha(0.01), + 1.0, + ), + linear_color_stop(cx.theme().colors().panel_background, 0.45), + )), + ) + .child(content), + ), + ) + } + fn render_active_thread_or_empty_state( &self, window: &mut Window, @@ -2827,6 +2916,7 @@ impl Render for AgentPanel { .on_action(cx.listener(Self::toggle_zoom)) .child(self.render_toolbar(window, cx)) .children(self.render_trial_upsell(window, cx)) + .children(self.render_trial_end_upsell(window, cx)) .map(|parent| match &self.active_view { ActiveView::Thread { .. } => parent .relative() @@ -3014,25 +3104,14 @@ impl AgentPanelDelegate for ConcreteAssistantPanelDelegate { } } -const DISMISSED_TRIAL_UPSELL_KEY: &str = "dismissed-trial-upsell"; +struct TrialUpsell; -fn dismissed_trial_upsell() -> bool { - db::kvp::KEY_VALUE_STORE - .read_kvp(DISMISSED_TRIAL_UPSELL_KEY) - .log_err() - .map_or(false, |s| s.is_some()) +impl Dismissable for TrialUpsell { + const KEY: &'static str = "dismissed-trial-upsell"; } -fn set_trial_upsell_dismissed(is_dismissed: bool, cx: &mut App) { - db::write_and_log(cx, move || async move { - if is_dismissed { - db::kvp::KEY_VALUE_STORE - .write_kvp(DISMISSED_TRIAL_UPSELL_KEY.into(), "1".into()) - .await - } else { - db::kvp::KEY_VALUE_STORE - .delete_kvp(DISMISSED_TRIAL_UPSELL_KEY.into()) - .await - } - }) +struct TrialEndUpsell; + +impl Dismissable for TrialEndUpsell { + const KEY: &'static str = "dismissed-trial-end-upsell"; } diff --git a/crates/agent/src/inline_prompt_editor.rs b/crates/agent/src/inline_prompt_editor.rs index 693786ca07..78e1d00c0d 100644 --- a/crates/agent/src/inline_prompt_editor.rs +++ b/crates/agent/src/inline_prompt_editor.rs @@ -11,6 +11,7 @@ use crate::{CycleNextInlineAssist, CyclePreviousInlineAssist}; use crate::{RemoveAllContext, ToggleContextPicker}; use client::ErrorExt; use collections::VecDeque; +use db::kvp::Dismissable; use editor::display_map::EditorMargins; use editor::{ ContextMenuOptions, Editor, EditorElement, EditorEvent, EditorMode, EditorStyle, MultiBuffer, @@ -33,7 +34,6 @@ use ui::utils::WithRemSize; use ui::{ CheckboxWithLabel, IconButtonShape, KeyBinding, Popover, PopoverMenuHandle, Tooltip, prelude::*, }; -use util::ResultExt; use workspace::Workspace; pub struct PromptEditor { @@ -722,7 +722,7 @@ impl PromptEditor { .child(CheckboxWithLabel::new( "dont-show-again", Label::new("Don't show again"), - if dismissed_rate_limit_notice() { + if RateLimitNotice::dismissed() { ui::ToggleState::Selected } else { ui::ToggleState::Unselected @@ -734,7 +734,7 @@ impl PromptEditor { ui::ToggleState::Selected => true, }; - set_rate_limit_notice_dismissed(is_dismissed, cx) + RateLimitNotice::set_dismissed(is_dismissed, cx); }, )) .child( @@ -974,7 +974,7 @@ impl PromptEditor { CodegenStatus::Error(error) => { if cx.has_flag::() && error.error_code() == proto::ErrorCode::RateLimitExceeded - && !dismissed_rate_limit_notice() + && !RateLimitNotice::dismissed() { self.show_rate_limit_notice = true; cx.notify(); @@ -1180,27 +1180,10 @@ impl PromptEditor { } } -const DISMISSED_RATE_LIMIT_NOTICE_KEY: &str = "dismissed-rate-limit-notice"; +struct RateLimitNotice; -fn dismissed_rate_limit_notice() -> bool { - db::kvp::KEY_VALUE_STORE - .read_kvp(DISMISSED_RATE_LIMIT_NOTICE_KEY) - .log_err() - .map_or(false, |s| s.is_some()) -} - -fn set_rate_limit_notice_dismissed(is_dismissed: bool, cx: &mut App) { - db::write_and_log(cx, move || async move { - if is_dismissed { - db::kvp::KEY_VALUE_STORE - .write_kvp(DISMISSED_RATE_LIMIT_NOTICE_KEY.into(), "1".into()) - .await - } else { - db::kvp::KEY_VALUE_STORE - .delete_kvp(DISMISSED_RATE_LIMIT_NOTICE_KEY.into()) - .await - } - }) +impl Dismissable for RateLimitNotice { + const KEY: &'static str = "dismissed-rate-limit-notice"; } pub enum CodegenStatus { diff --git a/crates/db/src/kvp.rs b/crates/db/src/kvp.rs index f0ddb2bd2c..daf0b136fd 100644 --- a/crates/db/src/kvp.rs +++ b/crates/db/src/kvp.rs @@ -1,6 +1,8 @@ +use gpui::App; use sqlez_macros::sql; +use util::ResultExt as _; -use crate::{define_connection, query}; +use crate::{define_connection, query, write_and_log}; define_connection!(pub static ref KEY_VALUE_STORE: KeyValueStore<()> = &[sql!( @@ -11,6 +13,29 @@ define_connection!(pub static ref KEY_VALUE_STORE: KeyValueStore<()> = )]; ); +pub trait Dismissable { + const KEY: &'static str; + + fn dismissed() -> bool { + KEY_VALUE_STORE + .read_kvp(Self::KEY) + .log_err() + .map_or(false, |s| s.is_some()) + } + + fn set_dismissed(is_dismissed: bool, cx: &mut App) { + write_and_log(cx, move || async move { + if is_dismissed { + KEY_VALUE_STORE + .write_kvp(Self::KEY.into(), "1".into()) + .await + } else { + KEY_VALUE_STORE.delete_kvp(Self::KEY.into()).await + } + }) + } +} + impl KeyValueStore { query! { pub fn read_kvp(key: &str) -> Result> { diff --git a/crates/gpui/src/util.rs b/crates/gpui/src/util.rs index a1bb6a69b3..af761dfdcf 100644 --- a/crates/gpui/src/util.rs +++ b/crates/gpui/src/util.rs @@ -27,6 +27,19 @@ pub trait FluentBuilder { self.map(|this| if condition { then(this) } else { this }) } + /// Conditionally modify self with the given closure. + fn when_else( + self, + condition: bool, + then: impl FnOnce(Self) -> Self, + else_fn: impl FnOnce(Self) -> Self, + ) -> Self + where + Self: Sized, + { + self.map(|this| if condition { then(this) } else { else_fn(this) }) + } + /// Conditionally unwrap and modify self with the given closure, if the given option is Some. fn when_some(self, option: Option, then: impl FnOnce(Self, T) -> Self) -> Self where diff --git a/crates/inline_completion/src/inline_completion.rs b/crates/inline_completion/src/inline_completion.rs index 7733fec1cb..91ebdafb1c 100644 --- a/crates/inline_completion/src/inline_completion.rs +++ b/crates/inline_completion/src/inline_completion.rs @@ -83,6 +83,13 @@ impl EditPredictionUsage { Ok(Self { limit, amount }) } + + pub fn over_limit(&self) -> bool { + match self.limit { + UsageLimit::Limited(limit) => self.amount >= limit, + UsageLimit::Unlimited => false, + } + } } pub trait EditPredictionProvider: 'static + Sized { diff --git a/crates/inline_completion_button/src/inline_completion_button.rs b/crates/inline_completion_button/src/inline_completion_button.rs index 0e0177b138..150b9cdacf 100644 --- a/crates/inline_completion_button/src/inline_completion_button.rs +++ b/crates/inline_completion_button/src/inline_completion_button.rs @@ -33,7 +33,7 @@ use workspace::{ StatusItemView, Toast, Workspace, create_and_open_local_file, item::ItemHandle, notifications::NotificationId, }; -use zed_actions::OpenBrowser; +use zed_actions::{OpenBrowser, OpenZedUrl}; use zed_llm_client::UsageLimit; use zeta::RateCompletions; @@ -277,14 +277,31 @@ impl Render for InlineCompletionButton { ); } + let mut over_limit = false; + + if let Some(usage) = self + .edit_prediction_provider + .as_ref() + .and_then(|provider| provider.usage(cx)) + { + over_limit = usage.over_limit() + } + let show_editor_predictions = self.editor_show_predictions; let icon_button = IconButton::new("zed-predict-pending-button", zeta_icon) .shape(IconButtonShape::Square) - .when(enabled && !show_editor_predictions, |this| { - this.indicator(Indicator::dot().color(Color::Muted)) + .when( + enabled && (!show_editor_predictions || over_limit), + |this| { + this.indicator(Indicator::dot().when_else( + over_limit, + |dot| dot.color(Color::Error), + |dot| dot.color(Color::Muted), + )) .indicator_border_color(Some(cx.theme().colors().status_bar_background)) - }) + }, + ) .when(!self.popover_menu_handle.is_deployed(), |element| { element.tooltip(move |window, cx| { if enabled { @@ -440,6 +457,16 @@ impl InlineCompletionButton { }, 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(); } diff --git a/crates/ui/src/components/progress/progress_bar.rs b/crates/ui/src/components/progress/progress_bar.rs index a151222277..3ea214082c 100644 --- a/crates/ui/src/components/progress/progress_bar.rs +++ b/crates/ui/src/components/progress/progress_bar.rs @@ -13,6 +13,7 @@ pub struct ProgressBar { value: f32, max_value: f32, bg_color: Hsla, + over_color: Hsla, fg_color: Hsla, } @@ -23,6 +24,7 @@ impl ProgressBar { value, max_value, bg_color: cx.theme().colors().background, + over_color: cx.theme().status().error, fg_color: cx.theme().status().info, } } @@ -50,6 +52,12 @@ impl ProgressBar { self.fg_color = color; self } + + /// Sets the over limit color of the progress bar. + pub fn over_color(mut self, color: Hsla) -> Self { + self.over_color = color; + self + } } impl RenderOnce for ProgressBar { @@ -74,7 +82,8 @@ impl RenderOnce for ProgressBar { div() .h_full() .rounded_full() - .bg(self.fg_color) + .when(self.value > self.max_value, |div| div.bg(self.over_color)) + .when(self.value <= self.max_value, |div| div.bg(self.fg_color)) .w(relative(fill_width)), ) } diff --git a/crates/zeta/src/onboarding_modal.rs b/crates/zeta/src/onboarding_modal.rs index bfd9e611b2..c123d76c53 100644 --- a/crates/zeta/src/onboarding_modal.rs +++ b/crates/zeta/src/onboarding_modal.rs @@ -2,7 +2,7 @@ use std::{sync::Arc, time::Duration}; use crate::{ZED_PREDICT_DATA_COLLECTION_CHOICE, onboarding_event}; use anyhow::Context as _; -use client::{Client, UserStore, zed_urls}; +use client::{Client, UserStore}; use db::kvp::KEY_VALUE_STORE; use fs::Fs; use gpui::{ @@ -384,47 +384,29 @@ impl Render for ZedPredictModal { } else { (IconName::ChevronDown, IconName::ChevronUp) }; + let plan = plan.unwrap_or(proto::Plan::Free); base.child(Label::new(copy).color(Color::Muted)) - .child(h_flex().map(|parent| { - if let Some(plan) = plan { - parent.child( - Checkbox::new("plan", ToggleState::Selected) - .fill() - .disabled(true) - .label(format!( - "You get {} edit predictions through your {}.", - if plan == proto::Plan::Free { - "2,000" - } else { - "unlimited" - }, - match plan { - proto::Plan::Free => "Zed Free plan", - proto::Plan::ZedPro => "Zed Pro plan", - proto::Plan::ZedProTrial => "Zed Pro trial", - } - )), - ) - } else { - parent - .child( - Checkbox::new("plan-required", ToggleState::Unselected) - .fill() - .disabled(true) - .label("To get started with edit prediction"), - ) - .child( - Button::new("subscribe", "choose a plan") - .icon(IconName::ArrowUpRight) - .icon_size(IconSize::Indicator) - .icon_color(Color::Muted) - .on_click(|_event, _window, cx| { - cx.open_url(&zed_urls::account_url(cx)); - }), - ) - } - })) + .child( + h_flex().child( + Checkbox::new("plan", ToggleState::Selected) + .fill() + .disabled(true) + .label(format!( + "You get {} edit predictions through your {}.", + if plan == proto::Plan::Free { + "2,000" + } else { + "unlimited" + }, + match plan { + proto::Plan::Free => "Zed Free plan", + proto::Plan::ZedPro => "Zed Pro plan", + proto::Plan::ZedProTrial => "Zed Pro trial", + } + )), + ), + ) .child( h_flex() .child( @@ -495,7 +477,7 @@ impl Render for ZedPredictModal { .w_full() .child( Button::new("accept-tos", "Enable Edit Prediction") - .disabled(plan.is_none() || !self.terms_of_service) + .disabled(!self.terms_of_service) .style(ButtonStyle::Tinted(TintColor::Accent)) .full_width() .on_click(cx.listener(Self::accept_and_enable)),