diff --git a/Cargo.lock b/Cargo.lock index 8bf2654370..cbed9f5988 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -195,6 +195,7 @@ dependencies = [ "agent_servers", "agent_settings", "agentic-coding-protocol", + "ai_onboarding", "anyhow", "assistant_context", "assistant_slash_command", @@ -329,6 +330,22 @@ dependencies = [ "memchr", ] +[[package]] +name = "ai_onboarding" +version = "0.1.0" +dependencies = [ + "client", + "component", + "gpui", + "language_model", + "proto", + "serde", + "smallvec", + "ui", + "workspace-hack", + "zed_actions", +] + [[package]] name = "alacritty_terminal" version = "0.25.1-dev" @@ -9066,6 +9083,7 @@ dependencies = [ name = "language_models" version = "0.1.0" dependencies = [ + "ai_onboarding", "anthropic", "anyhow", "aws-config", @@ -20510,6 +20528,7 @@ dependencies = [ name = "zeta" version = "0.1.0" dependencies = [ + "ai_onboarding", "anyhow", "arrayvec", "call", @@ -20517,6 +20536,7 @@ dependencies = [ "clock", "collections", "command_palette_hooks", + "copilot", "ctor", "db", "editor", @@ -20531,8 +20551,6 @@ dependencies = [ "language_model", "log", "menu", - "migrator", - "paths", "postage", "project", "proto", diff --git a/Cargo.toml b/Cargo.toml index 8d942a4c73..aa9af9a423 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,6 +6,7 @@ members = [ "crates/agent_ui", "crates/agent", "crates/agent_settings", + "crates/ai_onboarding", "crates/agent_servers", "crates/anthropic", "crates/askpass", @@ -227,6 +228,7 @@ agent_ui = { path = "crates/agent_ui" } agent_settings = { path = "crates/agent_settings" } agent_servers = { path = "crates/agent_servers" } ai = { path = "crates/ai" } +ai_onboarding = { path = "crates/ai_onboarding" } anthropic = { path = "crates/anthropic" } askpass = { path = "crates/askpass" } assets = { path = "crates/assets" } diff --git a/crates/agent_ui/Cargo.toml b/crates/agent_ui/Cargo.toml index d4feceb0b6..e55ae86fb7 100644 --- a/crates/agent_ui/Cargo.toml +++ b/crates/agent_ui/Cargo.toml @@ -21,6 +21,7 @@ agent.workspace = true agentic-coding-protocol.workspace = true agent_settings.workspace = true agent_servers.workspace = true +ai_onboarding.workspace = true anyhow.workspace = true assistant_context.workspace = true assistant_slash_command.workspace = true diff --git a/crates/agent_ui/src/agent_model_selector.rs b/crates/agent_ui/src/agent_model_selector.rs index f7b9157bbb..b989e7bf1e 100644 --- a/crates/agent_ui/src/agent_model_selector.rs +++ b/crates/agent_ui/src/agent_model_selector.rs @@ -1,8 +1,6 @@ use crate::{ ModelUsageContext, - language_model_selector::{ - LanguageModelSelector, ToggleModelSelector, language_model_selector, - }, + language_model_selector::{LanguageModelSelector, language_model_selector}, }; use agent_settings::AgentSettings; use fs::Fs; @@ -12,6 +10,7 @@ use picker::popover_menu::PickerPopoverMenu; use settings::update_settings_file; use std::sync::Arc; use ui::{ButtonLike, PopoverMenuHandle, Tooltip, prelude::*}; +use zed_actions::agent::ToggleModelSelector; pub struct AgentModelSelector { selector: Entity, @@ -96,22 +95,18 @@ impl Render for AgentModelSelector { let model_name = model .as_ref() .map(|model| model.model.name().0) - .unwrap_or_else(|| SharedString::from("No model selected")); - let provider_icon = model - .as_ref() - .map(|model| model.provider.icon()) - .unwrap_or_else(|| IconName::Ai); + .unwrap_or_else(|| SharedString::from("Select a Model")); + + let provider_icon = model.as_ref().map(|model| model.provider.icon()); let focus_handle = self.focus_handle.clone(); PickerPopoverMenu::new( self.selector.clone(), ButtonLike::new("active-model") - .child( - Icon::new(provider_icon) - .color(Color::Muted) - .size(IconSize::XSmall), - ) + .when_some(provider_icon, |this, icon| { + this.child(Icon::new(icon).color(Color::Muted).size(IconSize::XSmall)) + }) .child( Label::new(model_name) .color(Color::Muted) diff --git a/crates/agent_ui/src/agent_panel.rs b/crates/agent_ui/src/agent_panel.rs index 103e439615..7f2fbce189 100644 --- a/crates/agent_ui/src/agent_panel.rs +++ b/crates/agent_ui/src/agent_panel.rs @@ -11,7 +11,6 @@ use serde::{Deserialize, Serialize}; use crate::NewExternalAgentThread; use crate::agent_diff::AgentDiffThread; -use crate::language_model_selector::ToggleModelSelector; use crate::{ AddContextServer, AgentDiffPane, ContinueThread, ContinueWithBurnMode, DeleteRecentlyOpenThread, ExpandMessageEditor, Follow, InlineAssistant, NewTextThread, @@ -28,7 +27,7 @@ use crate::{ render_remaining_tokens, }, thread_history::{HistoryEntryElement, ThreadHistory}, - ui::AgentOnboardingModal, + ui::{AgentOnboardingModal, EndTrialUpsell}, }; use agent::{ Thread, ThreadError, ThreadEvent, ThreadId, ThreadSummary, TokenUsageRatio, @@ -37,6 +36,7 @@ use agent::{ thread_store::{TextThreadStore, ThreadStore}, }; use agent_settings::{AgentDockPosition, AgentSettings, CompletionMode, DefaultView}; +use ai_onboarding::AgentPanelOnboarding; use anyhow::{Result, anyhow}; use assistant_context::{AssistantContext, ContextEvent, ContextSummary}; use assistant_slash_command::SlashCommandWorkingSet; @@ -48,13 +48,12 @@ use fs::Fs; use gpui::{ Action, Animation, AnimationExt as _, AnyElement, App, AsyncWindowContext, ClipboardItem, Corner, DismissEvent, Entity, EventEmitter, ExternalPaths, FocusHandle, Focusable, Hsla, - KeyContext, Pixels, Subscription, Task, UpdateGlobal, WeakEntity, linear_color_stop, - linear_gradient, prelude::*, pulsating_between, + KeyContext, Pixels, Subscription, Task, UpdateGlobal, WeakEntity, prelude::*, + pulsating_between, }; use language::LanguageRegistry; use language_model::{ ConfigurationError, ConfiguredModel, LanguageModelProviderTosView, LanguageModelRegistry, - ZED_CLOUD_PROVIDER_ID, }; use project::{Project, ProjectPath, Worktree}; use prompt_store::{PromptBuilder, PromptStore, UserPromptId}; @@ -66,9 +65,8 @@ use theme::ThemeSettings; use time::UtcOffset; use ui::utils::WithRemSize; use ui::{ - Banner, Button, Callout, CheckboxWithLabel, ContextMenu, ElevationIndex, IconPosition, - KeyBinding, PopoverMenu, PopoverMenuHandle, ProgressBar, Tab, Tooltip, Vector, VectorName, - prelude::*, + Banner, Callout, ContextMenu, ElevationIndex, KeyBinding, PopoverMenu, PopoverMenuHandle, + ProgressBar, Tab, Tooltip, prelude::*, }; use util::ResultExt as _; use workspace::{ @@ -77,7 +75,7 @@ use workspace::{ }; use zed_actions::{ DecreaseBufferFontSize, IncreaseBufferFontSize, ResetBufferFontSize, - agent::{OpenConfiguration, OpenOnboardingModal, ResetOnboarding}, + agent::{OpenConfiguration, OpenOnboardingModal, ResetOnboarding, ToggleModelSelector}, assistant::{OpenRulesLibrary, ToggleFocus}, }; use zed_llm_client::{CompletionIntent, UsageLimit}; @@ -188,7 +186,7 @@ pub fn init(cx: &mut App) { window.refresh(); }) .register_action(|_workspace, _: &ResetTrialUpsell, _window, cx| { - Upsell::set_dismissed(false, cx); + OnboardingUpsell::set_dismissed(false, cx); }) .register_action(|_workspace, _: &ResetTrialEndUpsell, _window, cx| { TrialEndUpsell::set_dismissed(false, cx); @@ -453,7 +451,7 @@ pub struct AgentPanel { height: Option, zoomed: bool, pending_serialization: Option>>, - hide_upsell: bool, + onboarding: Entity, } impl AgentPanel { @@ -555,6 +553,7 @@ impl AgentPanel { let user_store = workspace.app_state().user_store.clone(); let project = workspace.project(); let language_registry = project.read(cx).languages().clone(); + let client = workspace.client().clone(); let workspace = workspace.weak_handle(); let weak_self = cx.entity().downgrade(); @@ -688,6 +687,17 @@ impl AgentPanel { }, ); + let onboarding = cx.new(|cx| { + AgentPanelOnboarding::new( + user_store.clone(), + client, + |_window, cx| { + OnboardingUpsell::set_dismissed(true, cx); + }, + cx, + ) + }); + Self { active_view, workspace, @@ -719,7 +729,7 @@ impl AgentPanel { height: None, zoomed: false, pending_serialization: None, - hide_upsell: false, + onboarding, } } @@ -2178,191 +2188,78 @@ impl AgentPanel { return false; } + match &self.active_view { + ActiveView::Thread { thread, .. } => { + if thread + .read(cx) + .thread() + .read(cx) + .configured_model() + .map_or(false, |model| { + model.provider.id() != language_model::ZED_CLOUD_PROVIDER_ID + }) + { + return false; + } + } + ActiveView::TextThread { .. } => { + if LanguageModelRegistry::global(cx) + .read(cx) + .default_model() + .map_or(false, |model| { + model.provider.id() != language_model::ZED_CLOUD_PROVIDER_ID + }) + { + return false; + } + } + ActiveView::ExternalAgentThread { .. } + | ActiveView::History + | ActiveView::Configuration => 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 { + fn should_render_onboarding(&self, cx: &mut Context) -> bool { + if OnboardingUpsell::dismissed() { + return false; + } + match &self.active_view { - ActiveView::Thread { thread, .. } => { - let is_using_zed_provider = thread - .read(cx) - .thread() - .read(cx) - .configured_model() - .map_or(false, |model| model.provider.id() == ZED_CLOUD_PROVIDER_ID); - - if !is_using_zed_provider { - return false; - } - } - ActiveView::ExternalAgentThread { .. } => { - return false; - } - ActiveView::TextThread { .. } | ActiveView::History | ActiveView::Configuration => { - return false; - } - }; - - if self.hide_upsell || Upsell::dismissed() { - return false; + ActiveView::Thread { thread, .. } => thread + .read(cx) + .thread() + .read(cx) + .configured_model() + .map_or(true, |model| { + model.provider.id() == language_model::ZED_CLOUD_PROVIDER_ID + }), + ActiveView::TextThread { .. } => LanguageModelRegistry::global(cx) + .read(cx) + .default_model() + .map_or(true, |model| { + model.provider.id() == language_model::ZED_CLOUD_PROVIDER_ID + }), + ActiveView::ExternalAgentThread { .. } + | ActiveView::History + | ActiveView::Configuration => false, } - - let plan = self.user_store.read(cx).current_plan(); - if matches!(plan, Some(Plan::ZedPro | Plan::ZedProTrial)) { - return false; - } - - let has_previous_trial = self.user_store.read(cx).trial_started_at().is_some(); - if has_previous_trial { - return false; - } - - true } - fn render_upsell( + fn render_onboarding( &self, _window: &mut Window, cx: &mut Context, ) -> Option { - if !self.should_render_upsell(cx) { + if !self.should_render_onboarding(cx) { return None; } - if self.user_store.read(cx).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) -> 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) -> 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("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() - .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", "Start Trial") - .style(ButtonStyle::Transparent) - .on_click(|_, _, cx| cx.open_url(&zed_urls::account_url(cx))), - ), - ), - ); - - self.render_upsell_container(cx, contents) + Some(div().size_full().child(self.onboarding.clone())) } fn render_trial_end_upsell( @@ -2374,141 +2271,15 @@ impl AgentPanel { 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() - .gap_2() - .child( - 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)) - }), - ), - ), - ), - ), - ) - } - - 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), - ), - ) + Some(EndTrialUpsell::new(Arc::new({ + let this = cx.entity(); + move |_, cx| { + this.update(cx, |_this, cx| { + TrialEndUpsell::set_dismissed(true, cx); + cx.notify(); + }); + } + }))) } fn render_thread_empty_state( @@ -2521,8 +2292,10 @@ impl AgentPanel { .update(cx, |this, cx| this.recent_entries(6, cx)); let model_registry = LanguageModelRegistry::read_global(cx); + let configuration_error = model_registry.configuration_error(model_registry.default_model(), cx); + let no_error = configuration_error.is_none(); let focus_handle = self.focus_handle(cx); @@ -2530,11 +2303,9 @@ impl AgentPanel { .size_full() .bg(cx.theme().colors().panel_background) .when(recent_history.is_empty(), |this| { - let configuration_error_ref = &configuration_error; this.child( v_flex() .size_full() - .max_w_80() .mx_auto() .justify_center() .items_center() @@ -2542,137 +2313,91 @@ impl AgentPanel { .child(h_flex().child(Headline::new("Welcome to the Agent Panel"))) .when(no_error, |parent| { parent + .child(h_flex().child( + Label::new("Ask and build anything.").color(Color::Muted), + )) .child( - h_flex().child( - Label::new("Ask and build anything.") - .color(Color::Muted) - .mb_2p5(), - ), - ) - .child( - Button::new("new-thread", "Start New Thread") - .icon(IconName::Plus) - .icon_position(IconPosition::Start) - .icon_size(IconSize::Small) - .icon_color(Color::Muted) - .full_width() - .key_binding(KeyBinding::for_action_in( - &NewThread::default(), - &focus_handle, - window, - cx, - )) - .on_click(|_event, window, cx| { - window.dispatch_action( - NewThread::default().boxed_clone(), - cx, - ) - }), - ) - .child( - Button::new("context", "Add Context") - .icon(IconName::FileCode) - .icon_position(IconPosition::Start) - .icon_size(IconSize::Small) - .icon_color(Color::Muted) - .full_width() - .key_binding(KeyBinding::for_action_in( - &ToggleContextPicker, - &focus_handle, - window, - cx, - )) - .on_click(|_event, window, cx| { - window.dispatch_action( - ToggleContextPicker.boxed_clone(), - cx, - ) - }), - ) - .child( - Button::new("mode", "Switch Model") - .icon(IconName::DatabaseZap) - .icon_position(IconPosition::Start) - .icon_size(IconSize::Small) - .icon_color(Color::Muted) - .full_width() - .key_binding(KeyBinding::for_action_in( - &ToggleModelSelector, - &focus_handle, - window, - cx, - )) - .on_click(|_event, window, cx| { - window.dispatch_action( - ToggleModelSelector.boxed_clone(), - cx, - ) - }), - ) - .child( - Button::new("settings", "View Settings") - .icon(IconName::Settings) - .icon_position(IconPosition::Start) - .icon_size(IconSize::Small) - .icon_color(Color::Muted) - .full_width() - .key_binding(KeyBinding::for_action_in( - &OpenConfiguration, - &focus_handle, - window, - cx, - )) - .on_click(|_event, window, cx| { - window.dispatch_action( - OpenConfiguration.boxed_clone(), - cx, - ) - }), + v_flex() + .mt_2() + .gap_1() + .max_w_48() + .child( + Button::new("context", "Add Context") + .label_size(LabelSize::Small) + .icon(IconName::FileCode) + .icon_position(IconPosition::Start) + .icon_size(IconSize::Small) + .icon_color(Color::Muted) + .full_width() + .key_binding(KeyBinding::for_action_in( + &ToggleContextPicker, + &focus_handle, + window, + cx, + )) + .on_click(|_event, window, cx| { + window.dispatch_action( + ToggleContextPicker.boxed_clone(), + cx, + ) + }), + ) + .child( + Button::new("mode", "Switch Model") + .label_size(LabelSize::Small) + .icon(IconName::DatabaseZap) + .icon_position(IconPosition::Start) + .icon_size(IconSize::Small) + .icon_color(Color::Muted) + .full_width() + .key_binding(KeyBinding::for_action_in( + &ToggleModelSelector, + &focus_handle, + window, + cx, + )) + .on_click(|_event, window, cx| { + window.dispatch_action( + ToggleModelSelector.boxed_clone(), + cx, + ) + }), + ) + .child( + Button::new("settings", "View Settings") + .label_size(LabelSize::Small) + .icon(IconName::Settings) + .icon_position(IconPosition::Start) + .icon_size(IconSize::Small) + .icon_color(Color::Muted) + .full_width() + .key_binding(KeyBinding::for_action_in( + &OpenConfiguration, + &focus_handle, + window, + cx, + )) + .on_click(|_event, window, cx| { + window.dispatch_action( + OpenConfiguration.boxed_clone(), + cx, + ) + }), + ), ) }) - .map(|parent| match configuration_error_ref { - Some( - err @ (ConfigurationError::ModelNotFound - | ConfigurationError::ProviderNotAuthenticated(_) - | ConfigurationError::NoProvider), - ) => parent - .child(h_flex().child( - Label::new(err.to_string()).color(Color::Muted).mb_2p5(), - )) - .child( - Button::new("settings", "Configure a Provider") - .icon(IconName::Settings) - .icon_position(IconPosition::Start) - .icon_size(IconSize::Small) - .icon_color(Color::Muted) - .full_width() - .key_binding(KeyBinding::for_action_in( - &OpenConfiguration, - &focus_handle, - window, - cx, - )) - .on_click(|_event, window, cx| { - window.dispatch_action( - OpenConfiguration.boxed_clone(), - cx, - ) - }), - ), - Some(ConfigurationError::ProviderPendingTermsAcceptance(provider)) => { - parent.children(provider.render_accept_terms( - LanguageModelProviderTosView::ThreadFreshStart, - cx, - )) - } - None => parent, + .when_some(configuration_error.as_ref(), |this, err| { + this.child(self.render_configuration_error( + err, + &focus_handle, + window, + cx, + )) }), ) }) .when(!recent_history.is_empty(), |parent| { let focus_handle = focus_handle.clone(); - let configuration_error_ref = &configuration_error; - parent .overflow_hidden() .p_1p5() @@ -2735,49 +2460,55 @@ impl AgentPanel { }, )), ) - .map(|parent| match configuration_error_ref { - Some( - err @ (ConfigurationError::ModelNotFound - | ConfigurationError::ProviderNotAuthenticated(_) - | ConfigurationError::NoProvider), - ) => parent.child( - Banner::new() - .severity(ui::Severity::Warning) - .child(Label::new(err.to_string()).size(LabelSize::Small)) - .action_slot( - Button::new("settings", "Configure Provider") - .style(ButtonStyle::Tinted(ui::TintColor::Warning)) - .label_size(LabelSize::Small) - .key_binding( - KeyBinding::for_action_in( - &OpenConfiguration, - &focus_handle, - window, - cx, - ) - .map(|kb| kb.size(rems_from_px(12.))), - ) - .on_click(|_event, window, cx| { - window.dispatch_action( - OpenConfiguration.boxed_clone(), - cx, - ) - }), - ), - ), - Some(ConfigurationError::ProviderPendingTermsAcceptance(provider)) => { - parent.child(Banner::new().severity(ui::Severity::Warning).child( - h_flex().w_full().children(provider.render_accept_terms( - LanguageModelProviderTosView::ThreadEmptyState, - cx, - )), - )) - } - None => parent, + .when_some(configuration_error.as_ref(), |this, err| { + this.child(self.render_configuration_error(err, &focus_handle, window, cx)) }) }) } + fn render_configuration_error( + &self, + configuration_error: &ConfigurationError, + focus_handle: &FocusHandle, + window: &mut Window, + cx: &mut App, + ) -> impl IntoElement { + match configuration_error { + ConfigurationError::ModelNotFound + | ConfigurationError::ProviderNotAuthenticated(_) + | ConfigurationError::NoProvider => Banner::new() + .severity(ui::Severity::Warning) + .child(Label::new(configuration_error.to_string())) + .action_slot( + Button::new("settings", "Configure Provider") + .style(ButtonStyle::Tinted(ui::TintColor::Warning)) + .label_size(LabelSize::Small) + .key_binding( + KeyBinding::for_action_in( + &OpenConfiguration, + &focus_handle, + window, + cx, + ) + .map(|kb| kb.size(rems_from_px(12.))), + ) + .on_click(|_event, window, cx| { + window.dispatch_action(OpenConfiguration.boxed_clone(), cx) + }), + ), + ConfigurationError::ProviderPendingTermsAcceptance(provider) => { + Banner::new().severity(ui::Severity::Warning).child( + h_flex().w_full().children( + provider.render_accept_terms( + LanguageModelProviderTosView::ThreadEmptyState, + cx, + ), + ), + ) + } + } + } + fn render_tool_use_limit_reached( &self, window: &mut Window, @@ -2910,7 +2641,7 @@ impl AgentPanel { this.clear_last_error(); }); - cx.open_url(&zed_urls::account_url(cx)); + cx.open_url(&zed_urls::upgrade_to_zed_pro_url(cx)); cx.notify(); } })) @@ -3300,7 +3031,7 @@ impl Render for AgentPanel { })) .on_action(cx.listener(Self::toggle_burn_mode)) .child(self.render_toolbar(window, cx)) - .children(self.render_upsell(window, cx)) + .children(self.render_onboarding(window, cx)) .children(self.render_trial_end_upsell(window, cx)) .map(|parent| match &self.active_view { ActiveView::Thread { @@ -3309,12 +3040,14 @@ impl Render for AgentPanel { .. } => parent .relative() - .child(if thread.read(cx).is_empty() { - self.render_thread_empty_state(window, cx) - .into_any_element() - } else { - thread.clone().into_any_element() - }) + .child( + if thread.read(cx).is_empty() && !self.should_render_onboarding(cx) { + self.render_thread_empty_state(window, cx) + .into_any_element() + } else { + thread.clone().into_any_element() + }, + ) .children(self.render_tool_use_limit_reached(window, cx)) .when_some(thread.read(cx).last_error(), |this, last_error| { this.child( @@ -3352,12 +3085,36 @@ impl Render for AgentPanel { context_editor, buffer_search_bar, .. - } => parent.child(self.render_prompt_editor( - context_editor, - buffer_search_bar, - window, - cx, - )), + } => { + let model_registry = LanguageModelRegistry::read_global(cx); + let configuration_error = + model_registry.configuration_error(model_registry.default_model(), cx); + parent + .map(|this| { + if !self.should_render_onboarding(cx) + && let Some(err) = configuration_error.as_ref() + { + this.child( + div().bg(cx.theme().colors().editor_background).p_2().child( + self.render_configuration_error( + err, + &self.focus_handle(cx), + window, + cx, + ), + ), + ) + } else { + this + } + }) + .child(self.render_prompt_editor( + context_editor, + buffer_search_bar, + window, + cx, + )) + } ActiveView::Configuration => parent.children(self.configuration.clone()), }); @@ -3526,9 +3283,9 @@ impl AgentPanelDelegate for ConcreteAssistantPanelDelegate { } } -struct Upsell; +struct OnboardingUpsell; -impl Dismissable for Upsell { +impl Dismissable for OnboardingUpsell { const KEY: &'static str = "dismissed-trial-upsell"; } diff --git a/crates/agent_ui/src/inline_prompt_editor.rs b/crates/agent_ui/src/inline_prompt_editor.rs index 7a61eef748..ade7a5e13d 100644 --- a/crates/agent_ui/src/inline_prompt_editor.rs +++ b/crates/agent_ui/src/inline_prompt_editor.rs @@ -2,7 +2,6 @@ use crate::agent_model_selector::AgentModelSelector; use crate::buffer_codegen::BufferCodegen; use crate::context_picker::{ContextPicker, ContextPickerCompletionProvider}; use crate::context_strip::{ContextStrip, ContextStripEvent, SuggestContextKind}; -use crate::language_model_selector::ToggleModelSelector; use crate::message_editor::{ContextCreasesAddon, extract_message_creases, insert_message_creases}; use crate::terminal_codegen::TerminalCodegen; use crate::{CycleNextInlineAssist, CyclePreviousInlineAssist, ModelUsageContext}; @@ -38,6 +37,7 @@ use ui::{ CheckboxWithLabel, IconButtonShape, KeyBinding, Popover, PopoverMenuHandle, Tooltip, prelude::*, }; use workspace::Workspace; +use zed_actions::agent::ToggleModelSelector; pub struct PromptEditor { pub editor: Entity, diff --git a/crates/agent_ui/src/language_model_selector.rs b/crates/agent_ui/src/language_model_selector.rs index ff18a95f3f..655e87d7cd 100644 --- a/crates/agent_ui/src/language_model_selector.rs +++ b/crates/agent_ui/src/language_model_selector.rs @@ -3,9 +3,7 @@ use std::{cmp::Reverse, sync::Arc}; use collections::{HashSet, IndexMap}; use feature_flags::ZedProFeatureFlag; use fuzzy::{StringMatch, StringMatchCandidate, match_strings}; -use gpui::{ - Action, AnyElement, App, BackgroundExecutor, DismissEvent, Subscription, Task, actions, -}; +use gpui::{Action, AnyElement, App, BackgroundExecutor, DismissEvent, Subscription, Task}; use language_model::{ AuthenticateError, ConfiguredModel, LanguageModel, LanguageModelProviderId, LanguageModelRegistry, @@ -15,15 +13,6 @@ use picker::{Picker, PickerDelegate}; use proto::Plan; use ui::{ListItem, ListItemSpacing, prelude::*}; -actions!( - agent, - [ - /// Toggles the language model selector dropdown. - #[action(deprecated_aliases = ["assistant::ToggleModelSelector", "assistant2::ToggleModelSelector"])] - ToggleModelSelector - ] -); - const TRY_ZED_PRO_URL: &str = "https://zed.dev/pro"; type OnModelChanged = Arc, &mut App) + 'static>; diff --git a/crates/agent_ui/src/message_editor.rs b/crates/agent_ui/src/message_editor.rs index d2b136f274..6967c8ab3e 100644 --- a/crates/agent_ui/src/message_editor.rs +++ b/crates/agent_ui/src/message_editor.rs @@ -4,7 +4,6 @@ use std::sync::Arc; use crate::agent_diff::AgentDiffThread; use crate::agent_model_selector::AgentModelSelector; -use crate::language_model_selector::ToggleModelSelector; use crate::tool_compatibility::{IncompatibleToolsState, IncompatibleToolsTooltip}; use crate::ui::{ MaxModeTooltip, @@ -49,6 +48,7 @@ use ui::{ use util::ResultExt as _; use workspace::{CollaboratorId, Workspace}; use zed_actions::agent::Chat; +use zed_actions::agent::ToggleModelSelector; use zed_llm_client::CompletionIntent; use crate::context_picker::{ContextPicker, ContextPickerCompletionProvider, crease_for_mention}; @@ -609,7 +609,11 @@ impl MessageEditor { ) } - fn render_follow_toggle(&self, cx: &mut Context) -> impl IntoElement { + fn render_follow_toggle( + &self, + is_model_selected: bool, + cx: &mut Context, + ) -> impl IntoElement { let following = self .workspace .read_with(cx, |workspace, _| { @@ -618,6 +622,7 @@ impl MessageEditor { .unwrap_or(false); IconButton::new("follow-agent", IconName::Crosshair) + .disabled(is_model_selected) .icon_size(IconSize::Small) .icon_color(Color::Muted) .toggle_state(following) @@ -786,7 +791,7 @@ impl MessageEditor { .justify_between() .child( h_flex() - .child(self.render_follow_toggle(cx)) + .child(self.render_follow_toggle(is_model_selected, cx)) .children(self.render_burn_mode_toggle(cx)), ) .child( diff --git a/crates/agent_ui/src/text_thread_editor.rs b/crates/agent_ui/src/text_thread_editor.rs index 2941da1965..3df0a48aa4 100644 --- a/crates/agent_ui/src/text_thread_editor.rs +++ b/crates/agent_ui/src/text_thread_editor.rs @@ -1,8 +1,6 @@ use crate::{ burn_mode_tooltip::BurnModeTooltip, - language_model_selector::{ - LanguageModelSelector, ToggleModelSelector, language_model_selector, - }, + language_model_selector::{LanguageModelSelector, language_model_selector}, }; use agent_settings::{AgentSettings, CompletionMode}; use anyhow::Result; @@ -38,8 +36,7 @@ use language::{ language_settings::{SoftWrap, all_language_settings}, }; use language_model::{ - ConfigurationError, LanguageModelExt, LanguageModelImage, LanguageModelProviderTosView, - LanguageModelRegistry, Role, + ConfigurationError, LanguageModelExt, LanguageModelImage, LanguageModelRegistry, Role, }; use multi_buffer::MultiBufferRow; use picker::{Picker, popover_menu::PickerPopoverMenu}; @@ -74,6 +71,7 @@ use workspace::{ pane, searchable::{SearchEvent, SearchableItem}, }; +use zed_actions::agent::ToggleModelSelector; use crate::{slash_command::SlashCommandCompletionProvider, slash_command_picker}; use assistant_context::{ @@ -1895,108 +1893,6 @@ impl TextThreadEditor { .update(cx, |context, cx| context.summarize(true, cx)); } - fn render_notice(&self, cx: &mut Context) -> Option { - // This was previously gated behind the `zed-pro` feature flag. Since we - // aren't planning to ship that right now, we're just hard-coding this - // value to not show the nudge. - let nudge = Some(false); - - let model_registry = LanguageModelRegistry::read_global(cx); - - if nudge.map_or(false, |value| value) { - Some( - h_flex() - .p_3() - .border_b_1() - .border_color(cx.theme().colors().border_variant) - .bg(cx.theme().colors().editor_background) - .justify_between() - .child( - h_flex() - .gap_3() - .child(Icon::new(IconName::ZedAssistant).color(Color::Accent)) - .child(Label::new("Zed AI is here! Get started by signing in →")), - ) - .child( - Button::new("sign-in", "Sign in") - .size(ButtonSize::Compact) - .style(ButtonStyle::Filled) - .on_click(cx.listener(|this, _event, _window, cx| { - let client = this - .workspace - .read_with(cx, |workspace, _| workspace.client().clone()) - .log_err(); - - if let Some(client) = client { - cx.spawn(async move |context_editor, cx| { - match client.authenticate_and_connect(true, cx).await { - util::ConnectionResult::Timeout => { - log::error!("Authentication timeout") - } - util::ConnectionResult::ConnectionReset => { - log::error!("Connection reset") - } - util::ConnectionResult::Result(r) => { - if r.log_err().is_some() { - context_editor - .update(cx, |_, cx| cx.notify()) - .ok(); - } - } - } - }) - .detach() - } - })), - ) - .into_any_element(), - ) - } else if let Some(configuration_error) = - model_registry.configuration_error(model_registry.default_model(), cx) - { - Some( - h_flex() - .px_3() - .py_2() - .border_b_1() - .border_color(cx.theme().colors().border_variant) - .bg(cx.theme().colors().editor_background) - .justify_between() - .child( - h_flex() - .gap_3() - .child( - Icon::new(IconName::Warning) - .size(IconSize::Small) - .color(Color::Warning), - ) - .child(Label::new(configuration_error.to_string())), - ) - .child( - Button::new("open-configuration", "Configure Providers") - .size(ButtonSize::Compact) - .icon(Some(IconName::SlidersVertical)) - .icon_size(IconSize::Small) - .icon_position(IconPosition::Start) - .style(ButtonStyle::Filled) - .on_click({ - let focus_handle = self.focus_handle(cx).clone(); - move |_event, window, cx| { - focus_handle.dispatch_action( - &zed_actions::agent::OpenConfiguration, - window, - cx, - ); - } - }), - ) - .into_any_element(), - ) - } else { - None - } - } - fn render_send_button(&self, window: &mut Window, cx: &mut Context) -> impl IntoElement { let focus_handle = self.focus_handle(cx).clone(); @@ -2128,12 +2024,13 @@ impl TextThreadEditor { .map(|default| default.model); let model_name = match active_model { Some(model) => model.name().0, - None => SharedString::from("No model selected"), + None => SharedString::from("Select Model"), }; let active_provider = LanguageModelRegistry::read_global(cx) .default_model() .map(|default| default.provider); + let provider_icon = match active_provider { Some(provider) => provider.icon(), None => IconName::Ai, @@ -2581,20 +2478,7 @@ impl EventEmitter for TextThreadEditor {} impl Render for TextThreadEditor { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { - let provider = LanguageModelRegistry::read_global(cx) - .default_model() - .map(|default| default.provider); - - let accept_terms = if self.show_accept_terms { - provider.as_ref().and_then(|provider| { - provider.render_accept_terms(LanguageModelProviderTosView::PromptEditorPopup, cx) - }) - } else { - None - }; - let language_model_selector = self.language_model_selector_menu_handle.clone(); - let burn_mode_toggle = self.render_burn_mode_toggle(cx); v_flex() .key_context("ContextEditor") @@ -2611,28 +2495,12 @@ impl Render for TextThreadEditor { language_model_selector.toggle(window, cx); }) .size_full() - .children(self.render_notice(cx)) .child( div() .flex_grow() .bg(cx.theme().colors().editor_background) .child(self.editor.clone()), ) - .when_some(accept_terms, |this, element| { - this.child( - div() - .absolute() - .right_3() - .bottom_12() - .max_w_96() - .py_2() - .px_3() - .elevation_2(cx) - .bg(cx.theme().colors().surface_background) - .occlude() - .child(element), - ) - }) .children(self.render_last_error(cx)) .child( h_flex() @@ -2649,7 +2517,7 @@ impl Render for TextThreadEditor { h_flex() .gap_0p5() .child(self.render_inject_context_menu(cx)) - .when_some(burn_mode_toggle, |this, element| this.child(element)), + .children(self.render_burn_mode_toggle(cx)), ) .child( h_flex() diff --git a/crates/agent_ui/src/ui.rs b/crates/agent_ui/src/ui.rs index 43cd0f5e89..6398f64abb 100644 --- a/crates/agent_ui/src/ui.rs +++ b/crates/agent_ui/src/ui.rs @@ -1,6 +1,7 @@ mod agent_notification; mod burn_mode_tooltip; mod context_pill; +mod end_trial_upsell; mod onboarding_modal; pub mod preview; mod upsell; @@ -8,4 +9,5 @@ mod upsell; pub use agent_notification::*; pub use burn_mode_tooltip::*; pub use context_pill::*; +pub use end_trial_upsell::*; pub use onboarding_modal::*; diff --git a/crates/agent_ui/src/ui/end_trial_upsell.rs b/crates/agent_ui/src/ui/end_trial_upsell.rs new file mode 100644 index 0000000000..9c2dd98d20 --- /dev/null +++ b/crates/agent_ui/src/ui/end_trial_upsell.rs @@ -0,0 +1,112 @@ +use std::sync::Arc; + +use ai_onboarding::{AgentPanelOnboardingCard, BulletItem}; +use client::zed_urls; +use gpui::{AnyElement, App, IntoElement, RenderOnce, Window}; +use ui::{Divider, List, prelude::*}; + +#[derive(IntoElement, RegisterComponent)] +pub struct EndTrialUpsell { + dismiss_upsell: Arc, +} + +impl EndTrialUpsell { + pub fn new(dismiss_upsell: Arc) -> Self { + Self { dismiss_upsell } + } +} + +impl RenderOnce for EndTrialUpsell { + fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement { + let pro_section = v_flex() + .gap_1() + .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 edit predictions")), + ) + .child( + Button::new("cta-button", "Upgrade to Zed Pro") + .full_width() + .style(ButtonStyle::Tinted(ui::TintColor::Accent)) + .on_click(|_, _, cx| cx.open_url(&zed_urls::upgrade_to_zed_pro_url(cx))), + ); + + let free_section = v_flex() + .mt_1p5() + .gap_1() + .child( + h_flex() + .gap_2() + .child( + Label::new("Free") + .size(LabelSize::Small) + .color(Color::Muted) + .buffer_font(cx), + ) + .child(Divider::horizontal()), + ) + .child( + List::new() + .child(BulletItem::new( + "50 prompts per month with the Claude models", + )) + .child(BulletItem::new( + "2000 accepted edit predictions using our open-source Zeta model", + )), + ) + .child( + Button::new("dismiss-button", "Stay on Free") + .full_width() + .style(ButtonStyle::Outlined) + .on_click({ + let callback = self.dismiss_upsell.clone(); + move |_, window, cx| callback(window, cx) + }), + ); + + AgentPanelOnboardingCard::new() + .child(Headline::new("Your Zed Pro trial has expired.")) + .child( + Label::new("You've been automatically reset to the Free plan.") + .size(LabelSize::Small) + .color(Color::Muted) + .mb_1(), + ) + .child(pro_section) + .child(free_section) + } +} + +impl Component for EndTrialUpsell { + fn scope() -> ComponentScope { + ComponentScope::Agent + } + + fn sort_name() -> &'static str { + "AgentEndTrialUpsell" + } + + fn preview(_window: &mut Window, _cx: &mut App) -> Option { + Some( + v_flex() + .p_4() + .gap_4() + .child(EndTrialUpsell { + dismiss_upsell: Arc::new(|_, _| {}), + }) + .into_any_element(), + ) + } +} diff --git a/crates/ai_onboarding/Cargo.toml b/crates/ai_onboarding/Cargo.toml new file mode 100644 index 0000000000..e9208a7248 --- /dev/null +++ b/crates/ai_onboarding/Cargo.toml @@ -0,0 +1,27 @@ +[package] +name = "ai_onboarding" +version = "0.1.0" +edition.workspace = true +publish.workspace = true +license = "GPL-3.0-or-later" + +[lints] +workspace = true + +[lib] +path = "src/ai_onboarding.rs" + +[features] +default = [] + +[dependencies] +client.workspace = true +component.workspace = true +gpui.workspace = true +language_model.workspace = true +proto.workspace = true +serde.workspace = true +smallvec.workspace = true +ui.workspace = true +workspace-hack.workspace = true +zed_actions.workspace = true diff --git a/crates/ai_onboarding/LICENSE-GPL b/crates/ai_onboarding/LICENSE-GPL new file mode 120000 index 0000000000..89e542f750 --- /dev/null +++ b/crates/ai_onboarding/LICENSE-GPL @@ -0,0 +1 @@ +../../LICENSE-GPL \ No newline at end of file diff --git a/crates/ai_onboarding/src/agent_panel_onboarding_card.rs b/crates/ai_onboarding/src/agent_panel_onboarding_card.rs new file mode 100644 index 0000000000..8ec9ccfe22 --- /dev/null +++ b/crates/ai_onboarding/src/agent_panel_onboarding_card.rs @@ -0,0 +1,81 @@ +use gpui::{AnyElement, IntoElement, ParentElement, linear_color_stop, linear_gradient}; +use smallvec::SmallVec; +use ui::{Vector, VectorName, prelude::*}; + +#[derive(IntoElement)] +pub struct AgentPanelOnboardingCard { + children: SmallVec<[AnyElement; 2]>, +} + +impl AgentPanelOnboardingCard { + pub fn new() -> Self { + Self { + children: SmallVec::new(), + } + } +} + +impl ParentElement for AgentPanelOnboardingCard { + fn extend(&mut self, elements: impl IntoIterator) { + self.children.extend(elements) + } +} + +impl RenderOnce for AgentPanelOnboardingCard { + fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement { + div() + .m_4() + .p(px(3.)) + .elevation_2(cx) + .rounded_lg() + .bg(cx.theme().colors().background.alpha(0.5)) + .child( + v_flex() + .relative() + .size_full() + .px_4() + .py_3() + .gap_2() + .border_1() + .rounded(px(5.)) + .border_color(cx.theme().colors().text.alpha(0.1)) + .overflow_hidden() + .bg(cx.theme().colors().panel_background) + .child( + div() + .opacity(0.5) + .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(Color::Custom(cx.theme().colors().text.alpha(0.32))), + ), + ) + .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), + )), + ) + .children(self.children), + ) + } +} diff --git a/crates/ai_onboarding/src/agent_panel_onboarding_content.rs b/crates/ai_onboarding/src/agent_panel_onboarding_content.rs new file mode 100644 index 0000000000..f3f7d6c3d7 --- /dev/null +++ b/crates/ai_onboarding/src/agent_panel_onboarding_content.rs @@ -0,0 +1,145 @@ +use std::sync::Arc; + +use client::{Client, UserStore}; +use gpui::{Action, ClickEvent, Entity, IntoElement, ParentElement}; +use language_model::{LanguageModelRegistry, ZED_CLOUD_PROVIDER_ID}; +use ui::{Divider, List, prelude::*}; +use zed_actions::agent::{OpenConfiguration, ToggleModelSelector}; + +use crate::{AgentPanelOnboardingCard, BulletItem, ZedAiOnboarding}; + +pub struct AgentPanelOnboarding { + user_store: Entity, + client: Arc, + configured_providers: Vec<(IconName, SharedString)>, + continue_with_zed_ai: Arc, +} + +impl AgentPanelOnboarding { + pub fn new( + user_store: Entity, + client: Arc, + continue_with_zed_ai: impl Fn(&mut Window, &mut App) + 'static, + cx: &mut Context, + ) -> Self { + cx.subscribe( + &LanguageModelRegistry::global(cx), + |this: &mut Self, _registry, event: &language_model::Event, cx| match event { + language_model::Event::ProviderStateChanged + | language_model::Event::AddedProvider(_) + | language_model::Event::RemovedProvider(_) => { + this.configured_providers = Self::compute_available_providers(cx) + } + _ => {} + }, + ) + .detach(); + + Self { + user_store, + client, + configured_providers: Self::compute_available_providers(cx), + continue_with_zed_ai: Arc::new(continue_with_zed_ai), + } + } + + fn compute_available_providers(cx: &App) -> Vec<(IconName, SharedString)> { + LanguageModelRegistry::read_global(cx) + .providers() + .iter() + .filter(|provider| { + provider.is_authenticated(cx) && provider.id() != ZED_CLOUD_PROVIDER_ID + }) + .map(|provider| (provider.icon(), provider.name().0.clone())) + .collect() + } + + fn configure_providers(&mut self, _: &ClickEvent, window: &mut Window, cx: &mut Context) { + window.dispatch_action(OpenConfiguration.boxed_clone(), cx); + cx.notify(); + } + + fn render_api_keys_section(&mut self, cx: &mut Context) -> impl IntoElement { + let has_existing_providers = self.configured_providers.len() > 0; + let configure_provider_label = if has_existing_providers { + "Configure Other Provider" + } else { + "Configure Providers" + }; + + let content = if has_existing_providers { + List::new() + .child(BulletItem::new( + "Or start now using API keys from your environment for the following providers:" + )) + .child( + h_flex() + .px_5() + .gap_2() + .flex_wrap() + .children(self.configured_providers.iter().cloned().map(|(icon, name)| + h_flex() + .gap_1p5() + .child(Icon::new(icon).size(IconSize::Small).color(Color::Muted)) + .child(Label::new(name)) + )) + ) + .child(BulletItem::new( + "No need for any of the plans or even to sign in", + )) + } else { + List::new() + .child(BulletItem::new( + "You can also use AI in Zed by bringing your own API keys", + )) + .child(BulletItem::new( + "No need for any of the plans or even to sign in", + )) + }; + + v_flex() + .mt_2() + .gap_1() + .child( + h_flex() + .gap_2() + .child( + Label::new("API Keys") + .size(LabelSize::Small) + .color(Color::Muted) + .buffer_font(cx), + ) + .child(Divider::horizontal()), + ) + .child(content) + .when(has_existing_providers, |this| { + this.child( + Button::new("pick-model", "Choose Model") + .full_width() + .style(ButtonStyle::Outlined) + .on_click(|_event, window, cx| { + window.dispatch_action(ToggleModelSelector.boxed_clone(), cx) + }), + ) + }) + .child( + Button::new("configure-providers", configure_provider_label) + .full_width() + .style(ButtonStyle::Outlined) + .on_click(cx.listener(Self::configure_providers)), + ) + } +} + +impl Render for AgentPanelOnboarding { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + AgentPanelOnboardingCard::new() + .child(ZedAiOnboarding::new( + self.client.clone(), + &self.user_store, + self.continue_with_zed_ai.clone(), + cx, + )) + .child(self.render_api_keys_section(cx)) + } +} diff --git a/crates/ai_onboarding/src/ai_onboarding.rs b/crates/ai_onboarding/src/ai_onboarding.rs new file mode 100644 index 0000000000..131d385e78 --- /dev/null +++ b/crates/ai_onboarding/src/ai_onboarding.rs @@ -0,0 +1,397 @@ +mod agent_panel_onboarding_card; +mod agent_panel_onboarding_content; +mod edit_prediction_onboarding_content; +mod young_account_banner; + +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, prelude::*}; + +pub struct BulletItem { + label: SharedString, +} + +impl BulletItem { + pub fn new(label: impl Into) -> Self { + Self { + label: label.into(), + } + } +} + +impl IntoElement for BulletItem { + type Element = AnyElement; + + fn into_element(self) -> Self::Element { + ListItem::new("list-item") + .selectable(false) + .start_slot( + Icon::new(IconName::Dash) + .size(IconSize::XSmall) + .color(Color::Hidden), + ) + .child(div().w_full().child(Label::new(self.label))) + .into_any_element() + } +} + +pub enum SignInStatus { + SignedIn, + SigningIn, + SignedOut, +} + +impl From 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, + pub account_too_young: bool, + pub continue_with_zed_ai: Arc, + pub sign_in: Arc, + pub accept_terms_of_service: Arc, +} + +impl ZedAiOnboarding { + pub fn new( + client: Arc, + user_store: &Entity, + continue_with_zed_ai: Arc, + 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(); + }), + } + } + + fn render_free_plan_section(&self, cx: &mut App) -> impl IntoElement { + v_flex() + .mt_2() + .gap_1() + .when(self.account_too_young, |this| this.opacity(0.4)) + .child( + h_flex() + .gap_2() + .child( + Label::new("Free") + .size(LabelSize::Small) + .color(Color::Muted) + .buffer_font(cx), + ) + .child(Divider::horizontal()), + ) + .child( + List::new() + .child(BulletItem::new( + "50 prompts per month with the Claude models", + )) + .child(BulletItem::new( + "2000 accepted edit predictions using our open-source Zeta model", + )), + ) + .child( + Button::new("continue", "Continue Free") + .disabled(self.account_too_young) + .full_width() + .style(ButtonStyle::Outlined) + .on_click({ + let callback = self.continue_with_zed_ai.clone(); + move |_, window, cx| callback(window, cx) + }), + ) + } + + fn render_pro_plan_section(&self, cx: &mut App) -> impl IntoElement { + let (button_label, button_url) = if self.account_too_young { + ("Start with Pro", zed_urls::upgrade_to_zed_pro_url(cx)) + } else { + ("Start Pro Trial", zed_urls::account_url(cx)) + }; + + v_flex() + .mt_2() + .gap_1() + .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 edit predictions")) + .when(!self.account_too_young, |this| { + this.child(BulletItem::new( + "Try it out for 14 days with no charge, no credit card required", + )) + }), + ) + .child( + Button::new("pro", button_label) + .full_width() + .style(ButtonStyle::Tinted(ui::TintColor::Accent)) + .on_click(move |_, _window, cx| cx.open_url(&button_url)), + ) + } + + fn render_accept_terms_of_service(&self) -> Div { + v_flex() + .w_full() + .gap_1() + .child(Headline::new("Before starting…")) + .child(Label::new( + "Make sure you have read and accepted Zed AI's terms of service.", + )) + .child( + Button::new("terms_of_service", "View and Read the Terms of Service") + .full_width() + .style(ButtonStyle::Outlined) + .icon(IconName::ArrowUpRight) + .icon_color(Color::Muted) + .icon_size(IconSize::XSmall) + .on_click(move |_, _window, cx| { + cx.open_url("https://zed.dev/terms-of-service") + }), + ) + .child( + Button::new("accept_terms", "I've read it and accept it") + .full_width() + .style(ButtonStyle::Tinted(TintColor::Accent)) + .on_click({ + let callback = self.accept_terms_of_service.clone(); + move |_, window, cx| (callback)(window, cx) + }), + ) + } + + fn render_sign_in_disclaimer(&self, _cx: &mut App) -> Div { + const SIGN_IN_DISCLAIMER: &str = + "To start using AI in Zed with our hosted models, sign in and subscribe to a plan."; + let signing_in = matches!(self.sign_in_status, SignInStatus::SigningIn); + + v_flex() + .gap_2() + .child(Headline::new("Welcome to Zed AI")) + .child(div().w_full().child(Label::new(SIGN_IN_DISCLAIMER))) + .child( + Button::new("sign_in", "Sign In with GitHub") + .icon(IconName::Github) + .icon_position(IconPosition::Start) + .icon_size(IconSize::Small) + .icon_color(Color::Muted) + .disabled(signing_in) + .full_width() + .style(ButtonStyle::Tinted(ui::TintColor::Accent)) + .on_click({ + let callback = self.sign_in.clone(); + move |_, window, cx| callback(window, cx) + }), + ) + } + + fn render_free_plan_onboarding(&self, cx: &mut App) -> Div { + const PLANS_DESCRIPTION: &str = "Choose how you want to start."; + let young_account_banner = YoungAccountBanner; + + v_flex() + .child(Headline::new("Welcome to Zed AI")) + .child( + Label::new(PLANS_DESCRIPTION) + .size(LabelSize::Small) + .color(Color::Muted) + .mt_1() + .mb_3(), + ) + .when(self.account_too_young, |this| { + this.child(young_account_banner) + }) + .child(self.render_free_plan_section(cx)) + .child(self.render_pro_plan_section(cx)) + } + + fn render_trial_onboarding(&self, _cx: &mut App) -> Div { + v_flex() + .child(Headline::new("Welcome to the trial of Zed Pro")) + .child( + Label::new("Here's what you get for the next 14 days:") + .size(LabelSize::Small) + .color(Color::Muted) + .mt_1(), + ) + .child( + List::new() + .child(BulletItem::new("150 prompts with Claude models")) + .child(BulletItem::new( + "Unlimited edit predictions with Zeta, our open-source model", + )), + ) + .child( + Button::new("trial", "Start Trial") + .full_width() + .style(ButtonStyle::Outlined) + .on_click({ + let callback = self.continue_with_zed_ai.clone(); + move |_, window, cx| callback(window, cx) + }), + ) + } + + fn render_pro_plan_onboarding(&self, _cx: &mut App) -> Div { + v_flex() + .child(Headline::new("Welcome to Zed Pro")) + .child( + Label::new("Here's what you get:") + .size(LabelSize::Small) + .color(Color::Muted) + .mt_1(), + ) + .child( + List::new() + .child(BulletItem::new("500 prompts with Claude models")) + .child(BulletItem::new("Unlimited edit predictions")), + ) + .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| callback(window, cx) + }), + ) + } +} + +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_onboarding(cx), + Some(proto::Plan::ZedProTrial) => self.render_trial_onboarding(cx), + Some(proto::Plan::ZedPro) => self.render_pro_plan_onboarding(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 { + fn onboarding( + sign_in_status: SignInStatus, + has_accepted_terms_of_service: bool, + plan: Option, + 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(|_, _| {}), + } + .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(), + ) + } +} diff --git a/crates/ai_onboarding/src/edit_prediction_onboarding_content.rs b/crates/ai_onboarding/src/edit_prediction_onboarding_content.rs new file mode 100644 index 0000000000..e883d8da8c --- /dev/null +++ b/crates/ai_onboarding/src/edit_prediction_onboarding_content.rs @@ -0,0 +1,73 @@ +use std::sync::Arc; + +use client::{Client, UserStore}; +use gpui::{Entity, IntoElement, ParentElement}; +use ui::prelude::*; + +use crate::ZedAiOnboarding; + +pub struct EditPredictionOnboarding { + user_store: Entity, + client: Arc, + copilot_is_configured: bool, + continue_with_zed_ai: Arc, + continue_with_copilot: Arc, +} + +impl EditPredictionOnboarding { + pub fn new( + user_store: Entity, + client: Arc, + copilot_is_configured: bool, + continue_with_zed_ai: Arc, + continue_with_copilot: Arc, + _cx: &mut Context, + ) -> Self { + Self { + user_store, + copilot_is_configured, + client, + continue_with_zed_ai, + continue_with_copilot, + } + } +} + +impl Render for EditPredictionOnboarding { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + let github_copilot = v_flex() + .gap_1() + .child(Label::new(if self.copilot_is_configured { + "Alternatively, you can continue to use GitHub Copilot as that's already set up." + } else { + "Alternatively, you can use GitHub Copilot as your edit prediction provider." + })) + .child( + Button::new( + "configure-copilot", + if self.copilot_is_configured { + "Use Copilot" + } else { + "Configure Copilot" + }, + ) + .full_width() + .style(ButtonStyle::Outlined) + .on_click({ + let callback = self.continue_with_copilot.clone(); + move |_, window, cx| callback(window, cx) + }), + ); + + v_flex() + .gap_2() + .child(ZedAiOnboarding::new( + self.client.clone(), + &self.user_store, + self.continue_with_zed_ai.clone(), + cx, + )) + .child(ui::Divider::horizontal()) + .child(github_copilot) + } +} diff --git a/crates/ai_onboarding/src/young_account_banner.rs b/crates/ai_onboarding/src/young_account_banner.rs new file mode 100644 index 0000000000..f6e1446fd0 --- /dev/null +++ b/crates/ai_onboarding/src/young_account_banner.rs @@ -0,0 +1,21 @@ +use gpui::{IntoElement, ParentElement}; +use ui::{Banner, prelude::*}; + +#[derive(IntoElement)] +pub struct YoungAccountBanner; + +impl RenderOnce for YoungAccountBanner { + fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement { + const YOUNG_ACCOUNT_DISCLAIMER: &str = "Given your GitHub account was created less than 30 days ago, we cannot put you in the Free plan or offer you a free trial of the Pro plan. We hope you'll understand, as this is unfortunately required to prevent abuse of our service. To continue, upgrade to Pro or use your own API keys for other providers."; + + let label = div() + .w_full() + .text_sm() + .text_color(cx.theme().colors().text_muted) + .child(YOUNG_ACCOUNT_DISCLAIMER); + + div() + .my_1() + .child(Banner::new().severity(ui::Severity::Warning).child(label)) + } +} diff --git a/crates/client/src/client.rs b/crates/client/src/client.rs index c4211f72c8..1be8ffdb55 100644 --- a/crates/client/src/client.rs +++ b/crates/client/src/client.rs @@ -301,6 +301,13 @@ impl Status { matches!(self, Self::Connected { .. }) } + pub fn is_signing_in(&self) -> bool { + matches!( + self, + Self::Authenticating | Self::Reauthenticating | Self::Connecting | Self::Reconnecting + ) + } + pub fn is_signed_out(&self) -> bool { matches!(self, Self::SignedOut | Self::UpgradeRequired) } diff --git a/crates/client/src/user.rs b/crates/client/src/user.rs index 61e3064eb4..f5213fbcb6 100644 --- a/crates/client/src/user.rs +++ b/crates/client/src/user.rs @@ -764,6 +764,16 @@ impl UserStore { } pub fn current_plan(&self) -> Option { + #[cfg(debug_assertions)] + if let Ok(plan) = std::env::var("ZED_SIMULATE_ZED_PRO_PLAN").as_ref() { + return match plan.as_str() { + "free" => Some(proto::Plan::Free), + "trial" => Some(proto::Plan::ZedProTrial), + "pro" => Some(proto::Plan::ZedPro), + _ => None, + }; + } + self.current_plan } diff --git a/crates/client/src/zed_urls.rs b/crates/client/src/zed_urls.rs index bfdae468fb..442875b451 100644 --- a/crates/client/src/zed_urls.rs +++ b/crates/client/src/zed_urls.rs @@ -17,3 +17,8 @@ fn server_url(cx: &App) -> &str { pub fn account_url(cx: &App) -> String { format!("{server_url}/account", server_url = server_url(cx)) } + +/// Returns the URL to the upgrade page on zed.dev. +pub fn upgrade_to_zed_pro_url(cx: &App) -> String { + format!("{server_url}/account/upgrade", server_url = server_url(cx)) +} diff --git a/crates/copilot/src/copilot.rs b/crates/copilot/src/copilot.rs index e4370d2e67..1966d1a389 100644 --- a/crates/copilot/src/copilot.rs +++ b/crates/copilot/src/copilot.rs @@ -209,8 +209,14 @@ impl Status { matches!(self, Status::Authorized) } - pub fn is_disabled(&self) -> bool { - matches!(self, Status::Disabled) + pub fn is_configured(&self) -> bool { + matches!( + self, + Status::Starting { .. } + | Status::Error(_) + | Status::SigningIn { .. } + | Status::Authorized + ) } } diff --git a/crates/inline_completion_button/src/inline_completion_button.rs b/crates/inline_completion_button/src/inline_completion_button.rs index 7e6b77b93d..8a8eacdc6a 100644 --- a/crates/inline_completion_button/src/inline_completion_button.rs +++ b/crates/inline_completion_button/src/inline_completion_button.rs @@ -46,6 +46,7 @@ actions!( ); const COPILOT_SETTINGS_URL: &str = "https://github.com/settings/copilot"; +const PRIVACY_DOCS: &str = "https://zed.dev/docs/ai/privacy-and-security"; struct CopilotErrorToast; @@ -193,13 +194,13 @@ impl Render for InlineCompletionButton { cx.open_url(activate_url.as_str()) }) .entry( - "Use Copilot", + "Use Zed AI", None, move |_, cx| { set_completion_provider( fs.clone(), cx, - EditPredictionProvider::Copilot, + EditPredictionProvider::Zed, ) }, ) @@ -239,22 +240,13 @@ impl Render for InlineCompletionButton { IconName::ZedPredictDisabled }; - let current_user_terms_accepted = - self.user_store.read(cx).current_user_has_accepted_terms(); - let has_subscription = self.user_store.read(cx).current_plan().is_some() - && self.user_store.read(cx).subscription_period().is_some(); - - if !has_subscription || !current_user_terms_accepted.unwrap_or(false) { - let signed_in = current_user_terms_accepted.is_some(); - let tooltip_meta = if signed_in { - if has_subscription { - "Read Terms of Service" - } else { - "Choose a Plan" - } - } else { - "Sign in to use" - }; + if zeta::should_show_upsell_modal(&self.user_store, cx) { + let tooltip_meta = + match self.user_store.read(cx).current_user_has_accepted_terms() { + Some(true) => "Choose a Plan", + Some(false) => "Accept the Terms of Service", + None => "Sign In", + }; return div().child( IconButton::new("zed-predict-pending-button", zeta_icon) @@ -403,15 +395,16 @@ impl InlineCompletionButton { ) -> Entity { let fs = self.fs.clone(); ContextMenu::build(window, cx, |menu, _, _| { - menu.entry("Sign In", None, copilot::initiate_sign_in) + menu.entry("Sign In to Copilot", None, copilot::initiate_sign_in) .entry("Disable Copilot", None, { let fs = fs.clone(); move |_window, cx| hide_copilot(fs.clone(), cx) }) - .entry("Use Supermaven", None, { + .separator() + .entry("Use Zed AI", None, { let fs = fs.clone(); move |_window, cx| { - set_completion_provider(fs.clone(), cx, EditPredictionProvider::Supermaven) + set_completion_provider(fs.clone(), cx, EditPredictionProvider::Zed) } }) }) @@ -518,7 +511,7 @@ impl InlineCompletionButton { ); } - menu = menu.separator().header("Privacy Settings"); + menu = menu.separator().header("Privacy"); if let Some(provider) = &self.edit_prediction_provider { let data_collection = provider.data_collection_state(cx); if data_collection.is_supported() { @@ -569,13 +562,15 @@ impl InlineCompletionButton { .child( Label::new(indoc!{ "Help us improve our open dataset model by sharing data from open source repositories. \ - Zed must detect a license file in your repo for this setting to take effect." + Zed must detect a license file in your repo for this setting to take effect. \ + Files with sensitive data and secrets are excluded by default." }) ) .child( h_flex() .items_start() .pt_2() + .pr_1() .flex_1() .gap_1p5() .border_t_1() @@ -635,6 +630,13 @@ impl InlineCompletionButton { .detach_and_log_err(cx); } }), + ).item( + ContextMenuEntry::new("View Documentation") + .icon(IconName::FileGeneric) + .icon_color(Color::Muted) + .handler(move |_, cx| { + cx.open_url(PRIVACY_DOCS); + }) ); if !self.editor_enabled.unwrap_or(true) { @@ -672,6 +674,13 @@ impl InlineCompletionButton { ) -> Entity { ContextMenu::build(window, cx, |menu, window, cx| { self.build_language_settings_menu(menu, window, cx) + .separator() + .entry("Use Zed AI instead", None, { + let fs = self.fs.clone(); + move |_window, cx| { + set_completion_provider(fs.clone(), cx, EditPredictionProvider::Zed) + } + }) .separator() .link( "Go to Copilot Settings", @@ -750,44 +759,24 @@ impl InlineCompletionButton { 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), - ) + Label::new("Your GitHub account is less than 30 days old.") + .size(LabelSize::Small) + .color(Color::Warning) .into_any_element() }, |_window, cx| cx.open_url(&zed_urls::account_url(cx)), ) - .entry( - "You need to upgrade to Zed Pro or contact us.", - None, - |_window, cx| cx.open_url(&zed_urls::account_url(cx)), - ) + .entry("Upgrade to Zed Pro or contact us.", None, |_window, cx| { + cx.open_url(&zed_urls::account_url(cx)) + }) .separator(); } else if self.user_store.read(cx).has_overdue_invoices() { menu = menu .custom_entry( |_window, _cx| { - h_flex() - .gap_1() - .child( - Icon::new(IconName::Warning) - .size(IconSize::Small) - .color(Color::Warning), - ) - .child( - Label::new("You have an outstanding invoice") - .size(LabelSize::Small) - .color(Color::Warning), - ) + Label::new("You have an outstanding invoice") + .size(LabelSize::Small) + .color(Color::Warning) .into_any_element() }, |_window, cx| { diff --git a/crates/language_model/src/language_model.rs b/crates/language_model/src/language_model.rs index 6bd33fcdf5..72455b3821 100644 --- a/crates/language_model/src/language_model.rs +++ b/crates/language_model/src/language_model.rs @@ -654,7 +654,7 @@ pub enum LanguageModelProviderTosView { ThreadEmptyState, /// When there are no past interactions in the Agent Panel. ThreadFreshStart, - PromptEditorPopup, + TextThreadPopup, Configuration, } diff --git a/crates/language_models/Cargo.toml b/crates/language_models/Cargo.toml index 5d158e84f4..ed38ac7660 100644 --- a/crates/language_models/Cargo.toml +++ b/crates/language_models/Cargo.toml @@ -12,6 +12,7 @@ workspace = true path = "src/language_models.rs" [dependencies] +ai_onboarding.workspace = true anthropic = { workspace = true, features = ["schemars"] } anyhow.workspace = true aws-config = { workspace = true, features = ["behavior-version-latest"] } diff --git a/crates/language_models/src/provider/cloud.rs b/crates/language_models/src/provider/cloud.rs index 6aea576258..736107570b 100644 --- a/crates/language_models/src/provider/cloud.rs +++ b/crates/language_models/src/provider/cloud.rs @@ -1,3 +1,4 @@ +use ai_onboarding::YoungAccountBanner; use anthropic::AnthropicModelMode; use anyhow::{Context as _, Result, anyhow}; use chrono::{DateTime, Utc}; @@ -500,7 +501,7 @@ fn render_accept_terms( ) .child({ match view_kind { - LanguageModelProviderTosView::PromptEditorPopup => { + LanguageModelProviderTosView::TextThreadPopup => { button_container.w_full().justify_end() } LanguageModelProviderTosView::Configuration => { @@ -1126,6 +1127,7 @@ struct ZedAiConfiguration { subscription_period: Option<(DateTime, DateTime)>, eligible_for_trial: bool, has_accepted_terms_of_service: bool, + account_too_young: bool, accept_terms_of_service_in_progress: bool, accept_terms_of_service_callback: Arc, sign_in_callback: Arc, @@ -1133,18 +1135,18 @@ struct ZedAiConfiguration { impl RenderOnce for ZedAiConfiguration { fn render(self, _window: &mut Window, _cx: &mut App) -> impl IntoElement { - const ZED_PRICING_URL: &str = "https://zed.dev/pricing"; + let young_account_banner = YoungAccountBanner; let is_pro = self.plan == Some(proto::Plan::ZedPro); let subscription_text = match (self.plan, self.subscription_period) { (Some(proto::Plan::ZedPro), Some(_)) => { - "You have access to Zed's hosted LLMs through your Zed Pro subscription." + "You have access to Zed's hosted LLMs through your Pro subscription." } (Some(proto::Plan::ZedProTrial), Some(_)) => { - "You have access to Zed's hosted LLMs through your Zed Pro trial." + "You have access to Zed's hosted LLMs through your Pro trial." } (Some(proto::Plan::Free), Some(_)) => { - "You have basic access to Zed's hosted LLMs through your Zed Free subscription." + "You have basic access to Zed's hosted LLMs through the Free plan." } _ => { if self.eligible_for_trial { @@ -1154,68 +1156,76 @@ impl RenderOnce for ZedAiConfiguration { } } }; + let manage_subscription_buttons = if is_pro { - h_flex().child( - Button::new("manage_settings", "Manage Subscription") - .style(ButtonStyle::Tinted(TintColor::Accent)) - .on_click(|_, _, cx| cx.open_url(&zed_urls::account_url(cx))), - ) + Button::new("manage_settings", "Manage Subscription") + .style(ButtonStyle::Tinted(TintColor::Accent)) + .on_click(|_, _, cx| cx.open_url(&zed_urls::account_url(cx))) + .into_any_element() + } else if self.plan.is_none() || self.eligible_for_trial { + Button::new("start_trial", "Start 14-day Free Pro Trial") + .style(ui::ButtonStyle::Tinted(ui::TintColor::Accent)) + .full_width() + .on_click(|_, _, cx| cx.open_url(&zed_urls::account_url(cx))) + .into_any_element() } else { - h_flex() - .gap_2() - .child( - Button::new("learn_more", "Learn more") - .style(ButtonStyle::Subtle) - .on_click(|_, _, cx| cx.open_url(ZED_PRICING_URL)), - ) - .child( - Button::new( - "upgrade", - if self.plan.is_none() && self.eligible_for_trial { - "Start Trial" - } else { - "Upgrade" - }, - ) - .style(ButtonStyle::Subtle) - .color(Color::Accent) - .on_click(|_, _, cx| cx.open_url(&zed_urls::account_url(cx))), - ) + Button::new("upgrade", "Upgrade to Pro") + .style(ui::ButtonStyle::Tinted(ui::TintColor::Accent)) + .full_width() + .on_click(|_, _, cx| cx.open_url(&zed_urls::upgrade_to_zed_pro_url(cx))) + .into_any_element() }; - if self.is_connected { - v_flex() - .gap_3() - .w_full() - .when(!self.has_accepted_terms_of_service, |this| { - this.child(render_accept_terms( - LanguageModelProviderTosView::Configuration, - self.accept_terms_of_service_in_progress, - { - let callback = self.accept_terms_of_service_callback.clone(); - move |window, cx| (callback)(window, cx) - }, - )) - }) - .when(self.has_accepted_terms_of_service, |this| { - this.child(subscription_text) - .child(manage_subscription_buttons) - }) - } else { - v_flex() + if !self.is_connected { + return v_flex() .gap_2() - .child(Label::new("Use Zed AI to access hosted language models.")) + .child(Label::new("Sign in to have access to Zed's complete agentic experience with hosted models.")) .child( - Button::new("sign_in", "Sign In") + Button::new("sign_in", "Sign In to use Zed AI") .icon_color(Color::Muted) .icon(IconName::Github) + .icon_size(IconSize::Small) .icon_position(IconPosition::Start) + .full_width() .on_click({ let callback = self.sign_in_callback.clone(); move |_, window, cx| (callback)(window, cx) }), - ) + ); } + + v_flex() + .gap_2() + .w_full() + .when(!self.has_accepted_terms_of_service, |this| { + this.child(render_accept_terms( + LanguageModelProviderTosView::Configuration, + self.accept_terms_of_service_in_progress, + { + let callback = self.accept_terms_of_service_callback.clone(); + move |window, cx| (callback)(window, cx) + }, + )) + }) + .map(|this| { + if self.has_accepted_terms_of_service && self.account_too_young { + this.child(young_account_banner).child( + Button::new("upgrade", "Upgrade to Pro") + .style(ui::ButtonStyle::Tinted(ui::TintColor::Accent)) + .full_width() + .on_click(|_, _, cx| { + cx.open_url(&zed_urls::upgrade_to_zed_pro_url(cx)) + }), + ) + } else if self.has_accepted_terms_of_service { + this.text_sm() + .child(subscription_text) + .child(manage_subscription_buttons) + } else { + this + } + }) + .when(self.has_accepted_terms_of_service, |this| this) } } @@ -1264,6 +1274,7 @@ impl Render for ConfigurationView { subscription_period: user_store.subscription_period(), eligible_for_trial: user_store.trial_started_at().is_none(), has_accepted_terms_of_service: state.has_accepted_terms_of_service(cx), + account_too_young: user_store.account_too_young(), accept_terms_of_service_in_progress: state.accept_terms_of_service_task.is_some(), accept_terms_of_service_callback: self.accept_terms_of_service_callback.clone(), sign_in_callback: self.sign_in_callback.clone(), @@ -1281,6 +1292,7 @@ impl Component for ZedAiConfiguration { is_connected: bool, plan: Option, eligible_for_trial: bool, + account_too_young: bool, has_accepted_terms_of_service: bool, ) -> AnyElement { ZedAiConfiguration { @@ -1291,6 +1303,7 @@ impl Component for ZedAiConfiguration { .then(|| (Utc::now(), Utc::now() + chrono::Duration::days(7))), eligible_for_trial, has_accepted_terms_of_service, + account_too_young, accept_terms_of_service_in_progress: false, accept_terms_of_service_callback: Arc::new(|_, _| {}), sign_in_callback: Arc::new(|_, _| {}), @@ -1303,30 +1316,33 @@ impl Component for ZedAiConfiguration { .p_4() .gap_4() .children(vec![ - single_example("Not connected", configuration(false, None, false, true)), + single_example( + "Not connected", + configuration(false, None, false, false, true), + ), single_example( "Accept Terms of Service", - configuration(true, None, true, false), + configuration(true, None, true, false, false), ), single_example( "No Plan - Not eligible for trial", - configuration(true, None, false, true), + configuration(true, None, false, false, true), ), single_example( "No Plan - Eligible for trial", - configuration(true, None, true, true), + configuration(true, None, true, false, true), ), single_example( "Free Plan", - configuration(true, Some(proto::Plan::Free), true, true), + configuration(true, Some(proto::Plan::Free), true, false, true), ), single_example( "Zed Pro Trial Plan", - configuration(true, Some(proto::Plan::ZedProTrial), true, true), + configuration(true, Some(proto::Plan::ZedProTrial), true, false, true), ), single_example( "Zed Pro Plan", - configuration(true, Some(proto::Plan::ZedPro), true, true), + configuration(true, Some(proto::Plan::ZedPro), true, false, true), ), ]) .into_any_element(), diff --git a/crates/title_bar/src/title_bar.rs b/crates/title_bar/src/title_bar.rs index 977b5c3ecd..c4fdb16f4f 100644 --- a/crates/title_bar/src/title_bar.rs +++ b/crates/title_bar/src/title_bar.rs @@ -20,7 +20,7 @@ use crate::application_menu::{ use auto_update::AutoUpdateStatus; use call::ActiveCall; -use client::{Client, UserStore}; +use client::{Client, UserStore, zed_urls}; use gpui::{ Action, AnyElement, App, Context, Corner, Element, Entity, Focusable, InteractiveElement, IntoElement, MouseButton, ParentElement, Render, StatefulInteractiveElement, Styled, @@ -656,8 +656,9 @@ impl TitleBar { let user_login = user.github_login.clone(); let (plan_name, label_color, bg_color) = match plan { - None => ("None", Color::Default, free_chip_bg), - Some(proto::Plan::Free) => ("Free", Color::Default, free_chip_bg), + None | Some(proto::Plan::Free) => { + ("Free", Color::Default, free_chip_bg) + } Some(proto::Plan::ZedProTrial) => { ("Pro Trial", Color::Accent, pro_chip_bg) } @@ -680,7 +681,7 @@ impl TitleBar { .into_any_element() }, move |_, cx| { - cx.open_url("https://zed.dev/account"); + cx.open_url(&zed_urls::account_url(cx)); }, ) .separator() diff --git a/crates/ui/src/components/button/button_like.rs b/crates/ui/src/components/button/button_like.rs index a0158b2fe7..135ecdfe62 100644 --- a/crates/ui/src/components/button/button_like.rs +++ b/crates/ui/src/components/button/button_like.rs @@ -126,6 +126,10 @@ pub enum ButtonStyle { /// coloring like an error or success button. Tinted(TintColor), + /// Usually used as a secondary action that should have more emphasis than + /// a fully transparent button. + Outlined, + /// The default button style, used for most buttons. Has a transparent background, /// but has a background color to indicate states like hover and active. #[default] @@ -180,6 +184,12 @@ impl ButtonStyle { icon_color: Color::Default.color(cx), }, ButtonStyle::Tinted(tint) => tint.button_like_style(cx), + ButtonStyle::Outlined => ButtonLikeStyles { + background: element_bg_from_elevation(elevation, cx), + border_color: cx.theme().colors().border_variant, + label_color: Color::Default.color(cx), + icon_color: Color::Default.color(cx), + }, ButtonStyle::Subtle => ButtonLikeStyles { background: cx.theme().colors().ghost_element_background, border_color: transparent_black(), @@ -219,6 +229,12 @@ impl ButtonStyle { styles.background = theme.darken(styles.background, 0.05, 0.2); styles } + ButtonStyle::Outlined => ButtonLikeStyles { + background: cx.theme().colors().ghost_element_hover, + border_color: cx.theme().colors().border, + label_color: Color::Default.color(cx), + icon_color: Color::Default.color(cx), + }, ButtonStyle::Subtle => ButtonLikeStyles { background: cx.theme().colors().ghost_element_hover, border_color: transparent_black(), @@ -251,6 +267,12 @@ impl ButtonStyle { label_color: Color::Default.color(cx), icon_color: Color::Default.color(cx), }, + ButtonStyle::Outlined => ButtonLikeStyles { + background: cx.theme().colors().element_active, + border_color: cx.theme().colors().border_variant, + label_color: Color::Default.color(cx), + icon_color: Color::Default.color(cx), + }, ButtonStyle::Transparent => ButtonLikeStyles { background: transparent_black(), border_color: transparent_black(), @@ -278,6 +300,12 @@ impl ButtonStyle { label_color: Color::Default.color(cx), icon_color: Color::Default.color(cx), }, + ButtonStyle::Outlined => ButtonLikeStyles { + background: cx.theme().colors().ghost_element_background, + border_color: cx.theme().colors().border, + label_color: Color::Default.color(cx), + icon_color: Color::Default.color(cx), + }, ButtonStyle::Transparent => ButtonLikeStyles { background: transparent_black(), border_color: cx.theme().colors().border_focused, @@ -308,6 +336,12 @@ impl ButtonStyle { label_color: Color::Disabled.color(cx), icon_color: Color::Disabled.color(cx), }, + ButtonStyle::Outlined => ButtonLikeStyles { + background: cx.theme().colors().element_disabled, + border_color: cx.theme().colors().border_disabled, + label_color: Color::Default.color(cx), + icon_color: Color::Default.color(cx), + }, ButtonStyle::Transparent => ButtonLikeStyles { background: transparent_black(), border_color: transparent_black(), @@ -525,6 +559,13 @@ impl RenderOnce for ButtonLike { .when_some(self.width, |this, width| { this.w(width).justify_center().text_center() }) + .when( + match self.style { + ButtonStyle::Outlined => true, + _ => false, + }, + |this| this.border_1(), + ) .when_some(self.rounding, |this, rounding| match rounding { ButtonLikeRounding::All => this.rounded_sm(), ButtonLikeRounding::Left => this.rounded_l_sm(), @@ -538,6 +579,7 @@ impl RenderOnce for ButtonLike { } ButtonSize::None => this, }) + .border_color(style.enabled(self.layer, cx).border_color) .bg(style.enabled(self.layer, cx).background) .when(self.disabled, |this| { if self.cursor_style == CursorStyle::PointingHand { diff --git a/crates/zed_actions/src/lib.rs b/crates/zed_actions/src/lib.rs index fc7d98178e..4b4bf016c4 100644 --- a/crates/zed_actions/src/lib.rs +++ b/crates/zed_actions/src/lib.rs @@ -277,7 +277,10 @@ pub mod agent { /// Displays the previous message in the history. PreviousHistoryMessage, /// Displays the next message in the history. - NextHistoryMessage + NextHistoryMessage, + /// Toggles the language model selector dropdown. + #[action(deprecated_aliases = ["assistant::ToggleModelSelector", "assistant2::ToggleModelSelector"])] + ToggleModelSelector ] ); } diff --git a/crates/zeta/Cargo.toml b/crates/zeta/Cargo.toml index 1609773339..c2b1de08ae 100644 --- a/crates/zeta/Cargo.toml +++ b/crates/zeta/Cargo.toml @@ -17,11 +17,13 @@ doctest = false test-support = [] [dependencies] +ai_onboarding.workspace = true anyhow.workspace = true arrayvec.workspace = true client.workspace = true collections.workspace = true command_palette_hooks.workspace = true +copilot.workspace = true db.workspace = true editor.workspace = true feature_flags.workspace = true @@ -35,8 +37,6 @@ language.workspace = true language_model.workspace = true log.workspace = true menu.workspace = true -migrator.workspace = true -paths.workspace = true postage.workspace = true project.workspace = true proto.workspace = true diff --git a/crates/zeta/src/init.rs b/crates/zeta/src/init.rs index 6411e423a4..4bcd50df88 100644 --- a/crates/zeta/src/init.rs +++ b/crates/zeta/src/init.rs @@ -34,7 +34,6 @@ pub fn init(cx: &mut App) { workspace, workspace.user_store().clone(), workspace.client().clone(), - workspace.app_state().fs.clone(), window, cx, ) diff --git a/crates/zeta/src/onboarding_modal.rs b/crates/zeta/src/onboarding_modal.rs index c123d76c53..1d59f36b05 100644 --- a/crates/zeta/src/onboarding_modal.rs +++ b/crates/zeta/src/onboarding_modal.rs @@ -1,40 +1,33 @@ -use std::{sync::Arc, time::Duration}; +use std::sync::Arc; -use crate::{ZED_PREDICT_DATA_COLLECTION_CHOICE, onboarding_event}; -use anyhow::Context as _; +use crate::{ZedPredictUpsell, onboarding_event}; +use ai_onboarding::EditPredictionOnboarding; use client::{Client, UserStore}; -use db::kvp::KEY_VALUE_STORE; +use db::kvp::Dismissable; use fs::Fs; use gpui::{ - Animation, AnimationExt as _, ClickEvent, DismissEvent, Entity, EventEmitter, FocusHandle, - Focusable, MouseDownEvent, Render, ease_in_out, svg, + ClickEvent, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, MouseDownEvent, Render, + linear_color_stop, linear_gradient, }; use language::language_settings::{AllLanguageSettings, EditPredictionProvider}; -use settings::{Settings, update_settings_file}; -use ui::{Checkbox, TintColor, prelude::*}; -use util::ResultExt; -use workspace::{ModalView, Workspace, notifications::NotifyTaskExt}; +use settings::update_settings_file; +use ui::{Vector, VectorName, prelude::*}; +use workspace::{ModalView, Workspace}; /// Introduces user to Zed's Edit Prediction feature and terms of service pub struct ZedPredictModal { - user_store: Entity, - client: Arc, - fs: Arc, + onboarding: Entity, focus_handle: FocusHandle, - sign_in_status: SignInStatus, - terms_of_service: bool, - data_collection_expanded: bool, - data_collection_opted_in: bool, } -#[derive(PartialEq, Eq)] -enum SignInStatus { - /// Signed out or signed in but not from this modal - Idle, - /// Authentication triggered from this modal - Waiting, - /// Signed in after authentication from this modal - SignedIn, +pub(crate) fn set_edit_prediction_provider(provider: EditPredictionProvider, cx: &mut App) { + let fs = ::global(cx); + update_settings_file::(fs, cx, move |settings, _| { + settings + .features + .get_or_insert(Default::default()) + .edit_prediction_provider = Some(provider); + }); } impl ZedPredictModal { @@ -42,127 +35,45 @@ impl ZedPredictModal { workspace: &mut Workspace, user_store: Entity, client: Arc, - fs: Arc, window: &mut Window, cx: &mut Context, ) { - workspace.toggle_modal(window, cx, |_window, cx| Self { - user_store, - client, - fs, - focus_handle: cx.focus_handle(), - sign_in_status: SignInStatus::Idle, - terms_of_service: false, - data_collection_expanded: false, - data_collection_opted_in: false, + workspace.toggle_modal(window, cx, |_window, cx| { + let weak_entity = cx.weak_entity(); + Self { + onboarding: cx.new(|cx| { + EditPredictionOnboarding::new( + user_store.clone(), + client.clone(), + copilot::Copilot::global(cx) + .map_or(false, |copilot| copilot.read(cx).status().is_configured()), + Arc::new({ + let this = weak_entity.clone(); + move |_window, cx| { + ZedPredictUpsell::set_dismissed(true, cx); + set_edit_prediction_provider(EditPredictionProvider::Zed, cx); + this.update(cx, |_, cx| cx.emit(DismissEvent)).ok(); + } + }), + Arc::new({ + let this = weak_entity.clone(); + move |window, cx| { + ZedPredictUpsell::set_dismissed(true, cx); + set_edit_prediction_provider(EditPredictionProvider::Copilot, cx); + this.update(cx, |_, cx| cx.emit(DismissEvent)).ok(); + copilot::initiate_sign_in(window, cx); + } + }), + cx, + ) + }), + focus_handle: cx.focus_handle(), + } }); } - fn view_terms(&mut self, _: &ClickEvent, _: &mut Window, cx: &mut Context) { - cx.open_url("https://zed.dev/terms-of-service"); - cx.notify(); - - onboarding_event!("ToS Link Clicked"); - } - - fn view_blog(&mut self, _: &ClickEvent, _: &mut Window, cx: &mut Context) { - cx.open_url("https://zed.dev/blog/edit-prediction"); - cx.notify(); - - onboarding_event!("Blog Link clicked"); - } - - fn inline_completions_doc(&mut self, _: &ClickEvent, _: &mut Window, cx: &mut Context) { - cx.open_url("https://zed.dev/docs/configuring-zed#disabled-globs"); - cx.notify(); - - onboarding_event!("Docs Link Clicked"); - } - - fn accept_and_enable(&mut self, _: &ClickEvent, window: &mut Window, cx: &mut Context) { - let task = self - .user_store - .update(cx, |this, cx| this.accept_terms_of_service(cx)); - let fs = self.fs.clone(); - - cx.spawn(async move |this, cx| { - task.await?; - - let mut data_collection_opted_in = false; - this.update(cx, |this, _cx| { - data_collection_opted_in = this.data_collection_opted_in; - }) - .ok(); - - KEY_VALUE_STORE - .write_kvp( - ZED_PREDICT_DATA_COLLECTION_CHOICE.into(), - data_collection_opted_in.to_string(), - ) - .await - .log_err(); - - // Make sure edit prediction provider setting is using the new key - let settings_path = paths::settings_file().as_path(); - let settings_path = fs.canonicalize(settings_path).await.with_context(|| { - format!("Failed to canonicalize settings path {:?}", settings_path) - })?; - - if let Some(settings) = fs.load(&settings_path).await.log_err() { - if let Some(new_settings) = - migrator::migrate_edit_prediction_provider_settings(&settings)? - { - fs.atomic_write(settings_path, new_settings).await?; - } - } - - this.update(cx, |this, cx| { - update_settings_file::(this.fs.clone(), cx, move |file, _| { - file.features - .get_or_insert(Default::default()) - .edit_prediction_provider = Some(EditPredictionProvider::Zed); - }); - - cx.emit(DismissEvent); - }) - }) - .detach_and_notify_err(window, cx); - - onboarding_event!( - "Enable Clicked", - data_collection_opted_in = self.data_collection_opted_in, - ); - } - - fn sign_in(&mut self, _: &ClickEvent, window: &mut Window, cx: &mut Context) { - let client = self.client.clone(); - self.sign_in_status = SignInStatus::Waiting; - - cx.spawn(async move |this, cx| { - let result = client - .authenticate_and_connect(true, &cx) - .await - .into_response(); - - let status = match result { - Ok(_) => SignInStatus::SignedIn, - Err(_) => SignInStatus::Idle, - }; - - this.update(cx, |this, cx| { - this.sign_in_status = status; - onboarding_event!("Signed In"); - cx.notify() - })?; - - result - }) - .detach_and_notify_err(window, cx); - - onboarding_event!("Sign In Clicked"); - } - fn cancel(&mut self, _: &menu::Cancel, _: &mut Window, cx: &mut Context) { + ZedPredictUpsell::set_dismissed(true, cx); cx.emit(DismissEvent); } } @@ -177,85 +88,12 @@ impl Focusable for ZedPredictModal { impl ModalView for ZedPredictModal {} -impl ZedPredictModal { - fn render_data_collection_explanation(&self, cx: &Context) -> impl IntoElement { - fn label_item(label_text: impl Into) -> impl Element { - Label::new(label_text).color(Color::Muted).into_element() - } - - fn info_item(label_text: impl Into) -> impl Element { - h_flex() - .items_start() - .gap_2() - .child( - div() - .mt_1p5() - .child(Icon::new(IconName::Check).size(IconSize::XSmall)), - ) - .child(div().w_full().child(label_item(label_text))) - } - - fn multiline_info_item, E2: IntoElement>( - first_line: E1, - second_line: E2, - ) -> impl Element { - v_flex() - .child(info_item(first_line)) - .child(div().pl_5().child(second_line)) - } - - v_flex() - .mt_2() - .p_2() - .rounded_sm() - .bg(cx.theme().colors().editor_background.opacity(0.5)) - .border_1() - .border_color(cx.theme().colors().border_variant) - .child( - div().child( - Label::new("To improve edit predictions, please consider contributing to our open dataset based on your interactions within open source repositories.") - .mb_1() - ) - ) - .child(info_item( - "We collect data exclusively from open source projects.", - )) - .child(info_item( - "Zed automatically detects if your project is open source.", - )) - .child(info_item("Toggle participation at any time via the status bar menu.")) - .child(multiline_info_item( - "If turned on, this setting applies for all open source repositories", - label_item("you open in Zed.") - )) - .child(multiline_info_item( - "Files with sensitive data, like `.env`, are excluded by default", - h_flex() - .w_full() - .flex_wrap() - .child(label_item("via the")) - .child( - Button::new("doc-link", "disabled_globs").on_click( - cx.listener(Self::inline_completions_doc), - ), - ) - .child(label_item("setting.")), - )) - } -} - impl Render for ZedPredictModal { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { let window_height = window.viewport_size().height; let max_height = window_height - px(200.); - let has_subscription_period = self.user_store.read(cx).subscription_period().is_some(); - let plan = self.user_store.read(cx).current_plan().filter(|_| { - // Since the user might be on the legacy free plan we filter based on whether we have a subscription period. - has_subscription_period - }); - - let base = v_flex() + v_flex() .id("edit-prediction-onboarding") .key_context("ZedPredictModal") .relative() @@ -264,14 +102,9 @@ impl Render for ZedPredictModal { .max_h(max_height) .p_4() .gap_2() - .when(self.data_collection_expanded, |element| { - element.overflow_y_scroll() - }) - .when(!self.data_collection_expanded, |element| { - element.overflow_hidden() - }) .elevation_3(cx) .track_focus(&self.focus_handle(cx)) + .overflow_hidden() .on_action(cx.listener(Self::cancel)) .on_action(cx.listener(|_, _: &menu::Cancel, _window, cx| { onboarding_event!("Cancelled", trigger = "Action"); @@ -282,77 +115,30 @@ impl Render for ZedPredictModal { })) .child( div() - .p_1p5() + .opacity(0.5) .absolute() - .top_1() - .left_1() + .top(px(-8.0)) .right_0() - .h(px(200.)) + .w(px(400.)) + .h(px(92.)) .child( - svg() - .path("icons/zed_predict_bg.svg") - .text_color(cx.theme().colors().icon_disabled) - .w(px(530.)) - .h(px(128.)) - .overflow_hidden(), + Vector::new(VectorName::AiGrid, rems_from_px(400.), rems_from_px(92.)) + .color(Color::Custom(cx.theme().colors().text.alpha(0.32))), ), ) .child( - h_flex() - .w_full() - .mb_2() - .justify_between() - .child( - v_flex() - .gap_1() - .child( - Label::new("Introducing Zed AI's") - .size(LabelSize::Small) - .color(Color::Muted), - ) - .child(Headline::new("Edit Prediction").size(HeadlineSize::Large)), - ) - .child({ - let tab = |n: usize| { - let text_color = cx.theme().colors().text; - let border_color = cx.theme().colors().text_accent.opacity(0.4); - - h_flex().child( - h_flex() - .px_4() - .py_0p5() - .bg(cx.theme().colors().editor_background) - .border_1() - .border_color(border_color) - .rounded_sm() - .font(theme::ThemeSettings::get_global(cx).buffer_font.clone()) - .text_size(TextSize::XSmall.rems(cx)) - .text_color(text_color) - .child("tab") - .with_animation( - n, - Animation::new(Duration::from_secs(2)).repeat(), - move |tab, delta| { - let delta = (delta - 0.15 * n as f32) / 0.7; - let delta = 1.0 - (0.5 - delta).abs() * 2.; - let delta = ease_in_out(delta.clamp(0., 1.)); - let delta = 0.1 + 0.9 * delta; - - tab.border_color(border_color.opacity(delta)) - .text_color(text_color.opacity(delta)) - }, - ), - ) - }; - - v_flex() - .gap_2() - .items_center() - .pr_2p5() - .child(tab(0).ml_neg_20()) - .child(tab(1)) - .child(tab(2).ml_20()) - }), + 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(h_flex().absolute().top_2().right_2().child( IconButton::new("cancel", IconName::X).on_click(cx.listener( @@ -361,148 +147,7 @@ impl Render for ZedPredictModal { cx.emit(DismissEvent); }, )), - )); - - let blog_post_button = Button::new("view-blog", "Read the Blog Post") - .full_width() - .icon(IconName::ArrowUpRight) - .icon_size(IconSize::Indicator) - .icon_color(Color::Muted) - .on_click(cx.listener(Self::view_blog)); - - if self.user_store.read(cx).current_user().is_some() { - let copy = match self.sign_in_status { - SignInStatus::Idle => { - "Zed can now predict your next edit on every keystroke. Powered by Zeta, our open-source, open-dataset language model." - } - SignInStatus::SignedIn => "Almost there! Ensure you:", - SignInStatus::Waiting => unreachable!(), - }; - - let accordion_icons = if self.data_collection_expanded { - (IconName::ChevronUp, IconName::ChevronDown) - } else { - (IconName::ChevronDown, IconName::ChevronUp) - }; - let plan = plan.unwrap_or(proto::Plan::Free); - - base.child(Label::new(copy).color(Color::Muted)) - .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( - Checkbox::new("tos-checkbox", self.terms_of_service.into()) - .fill() - .label("I have read and accept the") - .on_click(cx.listener(move |this, state, _window, cx| { - this.terms_of_service = *state == ToggleState::Selected; - cx.notify(); - })), - ) - .child( - Button::new("view-tos", "Terms of Service") - .icon(IconName::ArrowUpRight) - .icon_size(IconSize::Indicator) - .icon_color(Color::Muted) - .on_click(cx.listener(Self::view_terms)), - ), - ) - .child( - v_flex() - .child( - h_flex() - .flex_wrap() - .child( - Checkbox::new( - "training-data-checkbox", - self.data_collection_opted_in.into(), - ) - .label( - "Contribute to the open dataset when editing open source.", - ) - .fill() - .on_click(cx.listener( - move |this, state, _window, cx| { - this.data_collection_opted_in = - *state == ToggleState::Selected; - cx.notify() - }, - )), - ) - .child( - Button::new("learn-more", "Learn More") - .icon(accordion_icons.0) - .icon_size(IconSize::Indicator) - .icon_color(Color::Muted) - .on_click(cx.listener(|this, _, _, cx| { - this.data_collection_expanded = - !this.data_collection_expanded; - cx.notify(); - - if this.data_collection_expanded { - onboarding_event!( - "Data Collection Learn More Clicked" - ); - } - })), - ), - ) - .when(self.data_collection_expanded, |element| { - element.child(self.render_data_collection_explanation(cx)) - }), - ) - .child( - v_flex() - .mt_2() - .gap_2() - .w_full() - .child( - Button::new("accept-tos", "Enable Edit Prediction") - .disabled(!self.terms_of_service) - .style(ButtonStyle::Tinted(TintColor::Accent)) - .full_width() - .on_click(cx.listener(Self::accept_and_enable)), - ) - .child(blog_post_button), - ) - } else { - base.child( - Label::new("To set Zed as your edit prediction provider, please sign in.") - .color(Color::Muted), - ) - .child( - v_flex() - .mt_2() - .gap_2() - .w_full() - .child( - Button::new("accept-tos", "Sign in with GitHub") - .disabled(self.sign_in_status == SignInStatus::Waiting) - .style(ButtonStyle::Tinted(TintColor::Accent)) - .full_width() - .on_click(cx.listener(Self::sign_in)), - ) - .child(blog_post_button), - ) - } + )) + .child(self.onboarding.clone()) } } diff --git a/crates/zeta/src/zeta.rs b/crates/zeta/src/zeta.rs index 87cd1e604c..d6f033899d 100644 --- a/crates/zeta/src/zeta.rs +++ b/crates/zeta/src/zeta.rs @@ -7,7 +7,7 @@ mod onboarding_telemetry; mod rate_completion_modal; pub(crate) use completion_diff_element::*; -use db::kvp::KEY_VALUE_STORE; +use db::kvp::{Dismissable, KEY_VALUE_STORE}; pub use init::*; use inline_completion::DataCollectionState; use license_detection::LICENSE_FILES_TO_CHECK; @@ -95,6 +95,38 @@ impl std::fmt::Display for InlineCompletionId { } } +struct ZedPredictUpsell; + +impl Dismissable for ZedPredictUpsell { + const KEY: &'static str = "dismissed-edit-predict-upsell"; + + fn dismissed() -> bool { + // To make this backwards compatible with older versions of Zed, we + // check if the user has seen the previous Edit Prediction Onboarding + // before, by checking the data collection choice which was written to + // the database once the user clicked on "Accept and Enable" + if KEY_VALUE_STORE + .read_kvp(ZED_PREDICT_DATA_COLLECTION_CHOICE) + .log_err() + .map_or(false, |s| s.is_some()) + { + return true; + } + + KEY_VALUE_STORE + .read_kvp(Self::KEY) + .log_err() + .map_or(false, |s| s.is_some()) + } +} + +pub fn should_show_upsell_modal(user_store: &Entity, cx: &App) -> bool { + match user_store.read(cx).current_user_has_accepted_terms() { + Some(true) => !ZedPredictUpsell::dismissed(), + Some(false) | None => true, + } +} + #[derive(Clone)] struct ZetaGlobal(Entity);