Add end of service notifications (#30982)

Release Notes:

- N/A

---------

Co-authored-by: Max Brunsfeld <maxbrunsfeld@gmail.com>
Co-authored-by: Marshall Bowers <git@maxdeviant.com>
This commit is contained in:
Mikayla Maki 2025-05-20 02:20:00 +02:00 committed by GitHub
parent c747a57b7e
commit 315321bf8c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 327 additions and 201 deletions

View file

@ -85,6 +85,7 @@ actions!(
KeepAll,
Follow,
ResetTrialUpsell,
ResetTrialEndUpsell,
]
);

View file

@ -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<Self>) -> 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<Self>) -> 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<Self>,
) -> Option<impl IntoElement> {
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<Self>, 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";
}

View file

@ -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<T> {
@ -722,7 +722,7 @@ impl<T: 'static> PromptEditor<T> {
.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<T: 'static> PromptEditor<T> {
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<BufferCodegen> {
CodegenStatus::Error(error) => {
if cx.has_flag::<ZedProFeatureFlag>()
&& 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<TerminalCodegen> {
}
}
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 {

View file

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

View file

@ -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<T>(self, option: Option<T>, then: impl FnOnce(Self, T) -> Self) -> Self
where

View file

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

View file

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

View file

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

View file

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