From 44768606641ba3c9d40de8b38d96569d0ed69661 Mon Sep 17 00:00:00 2001 From: Danilo Leal <67129314+danilo-leal@users.noreply.github.com> Date: Fri, 18 Jul 2025 13:25:36 -0300 Subject: [PATCH] Add refinements to the AI onboarding flow (#33738) This includes making sure that both the agent panel and Zed's edit prediction have a consistent narrative when it comes to onboarding users into the AI features, considering the possible different plans and conditions (such as being signed in/out, account age, etc.) Release Notes: - N/A --------- Co-authored-by: Bennet Bo Fenner <53836821+bennetbo@users.noreply.github.com> Co-authored-by: Bennet Bo Fenner --- Cargo.lock | 22 +- Cargo.toml | 2 + crates/agent_ui/Cargo.toml | 1 + crates/agent_ui/src/agent_model_selector.rs | 21 +- crates/agent_ui/src/agent_panel.rs | 729 ++++++------------ crates/agent_ui/src/inline_prompt_editor.rs | 2 +- .../agent_ui/src/language_model_selector.rs | 13 +- crates/agent_ui/src/message_editor.rs | 11 +- crates/agent_ui/src/text_thread_editor.rs | 144 +--- crates/agent_ui/src/ui.rs | 2 + crates/agent_ui/src/ui/end_trial_upsell.rs | 112 +++ crates/ai_onboarding/Cargo.toml | 27 + crates/ai_onboarding/LICENSE-GPL | 1 + .../src/agent_panel_onboarding_card.rs | 81 ++ .../src/agent_panel_onboarding_content.rs | 145 ++++ crates/ai_onboarding/src/ai_onboarding.rs | 397 ++++++++++ .../src/edit_prediction_onboarding_content.rs | 73 ++ .../ai_onboarding/src/young_account_banner.rs | 21 + crates/client/src/client.rs | 7 + crates/client/src/user.rs | 10 + crates/client/src/zed_urls.rs | 5 + crates/copilot/src/copilot.rs | 10 +- .../src/inline_completion_button.rs | 93 +-- crates/language_model/src/language_model.rs | 2 +- crates/language_models/Cargo.toml | 1 + crates/language_models/src/provider/cloud.rs | 136 ++-- crates/title_bar/src/title_bar.rs | 9 +- .../ui/src/components/button/button_like.rs | 42 + crates/zed_actions/src/lib.rs | 5 +- crates/zeta/Cargo.toml | 4 +- crates/zeta/src/init.rs | 1 - crates/zeta/src/onboarding_modal.rs | 499 ++---------- crates/zeta/src/zeta.rs | 34 +- 33 files changed, 1456 insertions(+), 1206 deletions(-) create mode 100644 crates/agent_ui/src/ui/end_trial_upsell.rs create mode 100644 crates/ai_onboarding/Cargo.toml create mode 120000 crates/ai_onboarding/LICENSE-GPL create mode 100644 crates/ai_onboarding/src/agent_panel_onboarding_card.rs create mode 100644 crates/ai_onboarding/src/agent_panel_onboarding_content.rs create mode 100644 crates/ai_onboarding/src/ai_onboarding.rs create mode 100644 crates/ai_onboarding/src/edit_prediction_onboarding_content.rs create mode 100644 crates/ai_onboarding/src/young_account_banner.rs diff --git a/Cargo.lock b/Cargo.lock index 8bf26543705042204358c61e6844aefabb8ede04..cbed9f5988b1ec96308f89c3c37309bab0b13bb1 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 8d942a4c73c5ae1306e4e81a6a064759b0f1c782..aa9af9a423eb0d283df821a46424a4702154bce5 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 d4feceb0b67628052887e805d9b04eeb11c40040..e55ae86fb726f6aeeacb822b62720365d64514b1 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 f7b9157bbb9c07abac6a80dddfc014443165a712..b989e7bf1e9147c7f6beb90b5054120cef7b818f 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 103e4396154e3073daed1063cb0239ba18ddb4cc..7f2fbce189280887ff8a7cbbcfa719542a0598d6 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; } - 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 { match &self.active_view { ActiveView::Thread { thread, .. } => { - let is_using_zed_provider = thread + if thread .read(cx) .thread() .read(cx) .configured_model() - .map_or(false, |model| model.provider.id() == ZED_CLOUD_PROVIDER_ID); - - if !is_using_zed_provider { + .map_or(false, |model| { + model.provider.id() != language_model::ZED_CLOUD_PROVIDER_ID + }) + { return false; } } - ActiveView::ExternalAgentThread { .. } => { - return false; - } - ActiveView::TextThread { .. } | ActiveView::History | ActiveView::Configuration => { - 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; + } } - }; - - if self.hide_upsell || Upsell::dismissed() { - return false; + ActiveView::ExternalAgentThread { .. } + | ActiveView::History + | ActiveView::Configuration => return 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 { + + matches!(plan, Some(Plan::Free)) && has_previous_trial + } + + fn should_render_onboarding(&self, cx: &mut Context) -> bool { + if OnboardingUpsell::dismissed() { return false; } - true + match &self.active_view { + 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, + } } - 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) - .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, - ) - }), - ) - }) - .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(), + Label::new("Ask and build anything.").color(Color::Muted), )) .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, + 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, + ) + }), + ), + ) + }) + .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 7a61eef7486de92bc181a3f28e032865a4452fe2..ade7a5e13deb08f2de6b044683f7038395e0f5b5 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 ff18a95f3f8b84eb0876a099cb664aa0908bed8f..655e87d7cdc394e182aa01089c081673991660b5 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 d2b136f274f98842ee248016b400883083ab62d5..6967c8ab3ee0d928cd094c26b844c66069755243 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 2941da19653fa6ebbd581663ed675af6b57a2d30..3df0a48aa418fcf7078a52972e3ea0659376e0ea 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 43cd0f5e8937d860ce0f453d40ece8d230f7d16d..6398f64abb65bb6c9639c71c59e31e1d1a214bba 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 0000000000000000000000000000000000000000..9c2dd98d2000d83733ad41147c3fa4486240de55 --- /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 0000000000000000000000000000000000000000..e9208a724865e2d0d5288f493925f5a944d67642 --- /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 0000000000000000000000000000000000000000..89e542f750cd3860a0598eff0dc34b56d7336dc4 --- /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 0000000000000000000000000000000000000000..8ec9ccfe2230cedd921d3a18d0cb6236a043c716 --- /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 0000000000000000000000000000000000000000..f3f7d6c3d7e152ee8e46c6cf28b1d0bc0322c057 --- /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 0000000000000000000000000000000000000000..131d385e7891644ea512676d49cc2ec9206c7784 --- /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 0000000000000000000000000000000000000000..e883d8da8ce01bfea3f08676666c308a90f6d650 --- /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 0000000000000000000000000000000000000000..f6e1446fd05cc719e8a6674ae9246084185162c7 --- /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 c4211f72c819cfed5c0ee2f555356aa970968bc5..1be8ffdb55b088ba7e9b8e0b1525b89e7dd0a48a 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 61e3064eb496b59910ce8ab25797b9b4b4848201..f5213fbcb6c42db9d6a63ab312d024ca0e909f3f 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 bfdae468fbb6cc9d829d820a7d9cb0828a8763dd..442875b45132c1d7990f82ac93248ebd0477362c 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 e4370d2e67cef9c5c4db68123edfb7dca5d7fa00..1966d1a3890157e76a44bcddce89d225af8ea923 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 7e6b77b93deafbb971980d8b2d19f33f2fa348b4..8a8eacdc6a5855db435dd4d5e476f67fbe207910 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 6bd33fcdf508b33fb397ccc602de2b719d4906a2..72455b382199fc503256e33706045baef2c1b1ec 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 5d158e84f4fb072d40e43a43cd53b5b996274351..ed38ac76605e5b9554f3c5cd2a91a6650c20393d 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 6aea576258e6e46f5d1b9355a12007852296724a..736107570b395c3014e25dce1cbe21737de9e96b 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 977b5c3ecd0e5170a46941f2f3459e9d0d77f06e..c4fdb16f4f5d8b18b7e2b536198cc1ba61ec04d8 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 a0158b2fe745f383be179594c49ce1874b181176..135ecdfe62a632909ac36d05ffaa157824e220f6 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 fc7d98178edfce397ae4600b17b7bbac4a1cb9c6..4b4bf016c4b06293bda3769a5545a2fa1bd6195b 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 1609773339a57df929ce317ce2a793fb8b067bca..c2b1de08aea4d096dea25b50d54077306489482d 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 6411e423a4d2e0b0f8b9e8b6e2e745a11e7864e6..4bcd50df885a43ade7bb04bbde8b8c3d3a1f54d1 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 c123d76c53c801fb8eb7eb95416b8f53fc3f58f6..1d59f36b0532429f8cc24f3fc6adcdd468279d33 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, - }); - } - - 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?; - } + 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(), } - - 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 87cd1e604c3fd422c2ea9c218cbed755e72925cf..d6f033899de8443fa736ec92774b9363e6da459b 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);