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 <bennetbo@gmx.de>
This commit is contained in:
Danilo Leal 2025-07-18 13:25:36 -03:00 committed by GitHub
parent 9a20843ba2
commit 4476860664
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
33 changed files with 1465 additions and 1215 deletions

22
Cargo.lock generated
View file

@ -195,6 +195,7 @@ dependencies = [
"agent_servers", "agent_servers",
"agent_settings", "agent_settings",
"agentic-coding-protocol", "agentic-coding-protocol",
"ai_onboarding",
"anyhow", "anyhow",
"assistant_context", "assistant_context",
"assistant_slash_command", "assistant_slash_command",
@ -329,6 +330,22 @@ dependencies = [
"memchr", "memchr",
] ]
[[package]]
name = "ai_onboarding"
version = "0.1.0"
dependencies = [
"client",
"component",
"gpui",
"language_model",
"proto",
"serde",
"smallvec",
"ui",
"workspace-hack",
"zed_actions",
]
[[package]] [[package]]
name = "alacritty_terminal" name = "alacritty_terminal"
version = "0.25.1-dev" version = "0.25.1-dev"
@ -9066,6 +9083,7 @@ dependencies = [
name = "language_models" name = "language_models"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"ai_onboarding",
"anthropic", "anthropic",
"anyhow", "anyhow",
"aws-config", "aws-config",
@ -20510,6 +20528,7 @@ dependencies = [
name = "zeta" name = "zeta"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"ai_onboarding",
"anyhow", "anyhow",
"arrayvec", "arrayvec",
"call", "call",
@ -20517,6 +20536,7 @@ dependencies = [
"clock", "clock",
"collections", "collections",
"command_palette_hooks", "command_palette_hooks",
"copilot",
"ctor", "ctor",
"db", "db",
"editor", "editor",
@ -20531,8 +20551,6 @@ dependencies = [
"language_model", "language_model",
"log", "log",
"menu", "menu",
"migrator",
"paths",
"postage", "postage",
"project", "project",
"proto", "proto",

View file

@ -6,6 +6,7 @@ members = [
"crates/agent_ui", "crates/agent_ui",
"crates/agent", "crates/agent",
"crates/agent_settings", "crates/agent_settings",
"crates/ai_onboarding",
"crates/agent_servers", "crates/agent_servers",
"crates/anthropic", "crates/anthropic",
"crates/askpass", "crates/askpass",
@ -227,6 +228,7 @@ agent_ui = { path = "crates/agent_ui" }
agent_settings = { path = "crates/agent_settings" } agent_settings = { path = "crates/agent_settings" }
agent_servers = { path = "crates/agent_servers" } agent_servers = { path = "crates/agent_servers" }
ai = { path = "crates/ai" } ai = { path = "crates/ai" }
ai_onboarding = { path = "crates/ai_onboarding" }
anthropic = { path = "crates/anthropic" } anthropic = { path = "crates/anthropic" }
askpass = { path = "crates/askpass" } askpass = { path = "crates/askpass" }
assets = { path = "crates/assets" } assets = { path = "crates/assets" }

View file

@ -21,6 +21,7 @@ agent.workspace = true
agentic-coding-protocol.workspace = true agentic-coding-protocol.workspace = true
agent_settings.workspace = true agent_settings.workspace = true
agent_servers.workspace = true agent_servers.workspace = true
ai_onboarding.workspace = true
anyhow.workspace = true anyhow.workspace = true
assistant_context.workspace = true assistant_context.workspace = true
assistant_slash_command.workspace = true assistant_slash_command.workspace = true

View file

@ -1,8 +1,6 @@
use crate::{ use crate::{
ModelUsageContext, ModelUsageContext,
language_model_selector::{ language_model_selector::{LanguageModelSelector, language_model_selector},
LanguageModelSelector, ToggleModelSelector, language_model_selector,
},
}; };
use agent_settings::AgentSettings; use agent_settings::AgentSettings;
use fs::Fs; use fs::Fs;
@ -12,6 +10,7 @@ use picker::popover_menu::PickerPopoverMenu;
use settings::update_settings_file; use settings::update_settings_file;
use std::sync::Arc; use std::sync::Arc;
use ui::{ButtonLike, PopoverMenuHandle, Tooltip, prelude::*}; use ui::{ButtonLike, PopoverMenuHandle, Tooltip, prelude::*};
use zed_actions::agent::ToggleModelSelector;
pub struct AgentModelSelector { pub struct AgentModelSelector {
selector: Entity<LanguageModelSelector>, selector: Entity<LanguageModelSelector>,
@ -96,22 +95,18 @@ impl Render for AgentModelSelector {
let model_name = model let model_name = model
.as_ref() .as_ref()
.map(|model| model.model.name().0) .map(|model| model.model.name().0)
.unwrap_or_else(|| SharedString::from("No model selected")); .unwrap_or_else(|| SharedString::from("Select a Model"));
let provider_icon = model
.as_ref() let provider_icon = model.as_ref().map(|model| model.provider.icon());
.map(|model| model.provider.icon())
.unwrap_or_else(|| IconName::Ai);
let focus_handle = self.focus_handle.clone(); let focus_handle = self.focus_handle.clone();
PickerPopoverMenu::new( PickerPopoverMenu::new(
self.selector.clone(), self.selector.clone(),
ButtonLike::new("active-model") ButtonLike::new("active-model")
.child( .when_some(provider_icon, |this, icon| {
Icon::new(provider_icon) this.child(Icon::new(icon).color(Color::Muted).size(IconSize::XSmall))
.color(Color::Muted) })
.size(IconSize::XSmall),
)
.child( .child(
Label::new(model_name) Label::new(model_name)
.color(Color::Muted) .color(Color::Muted)

View file

@ -11,7 +11,6 @@ use serde::{Deserialize, Serialize};
use crate::NewExternalAgentThread; use crate::NewExternalAgentThread;
use crate::agent_diff::AgentDiffThread; use crate::agent_diff::AgentDiffThread;
use crate::language_model_selector::ToggleModelSelector;
use crate::{ use crate::{
AddContextServer, AgentDiffPane, ContinueThread, ContinueWithBurnMode, AddContextServer, AgentDiffPane, ContinueThread, ContinueWithBurnMode,
DeleteRecentlyOpenThread, ExpandMessageEditor, Follow, InlineAssistant, NewTextThread, DeleteRecentlyOpenThread, ExpandMessageEditor, Follow, InlineAssistant, NewTextThread,
@ -28,7 +27,7 @@ use crate::{
render_remaining_tokens, render_remaining_tokens,
}, },
thread_history::{HistoryEntryElement, ThreadHistory}, thread_history::{HistoryEntryElement, ThreadHistory},
ui::AgentOnboardingModal, ui::{AgentOnboardingModal, EndTrialUpsell},
}; };
use agent::{ use agent::{
Thread, ThreadError, ThreadEvent, ThreadId, ThreadSummary, TokenUsageRatio, Thread, ThreadError, ThreadEvent, ThreadId, ThreadSummary, TokenUsageRatio,
@ -37,6 +36,7 @@ use agent::{
thread_store::{TextThreadStore, ThreadStore}, thread_store::{TextThreadStore, ThreadStore},
}; };
use agent_settings::{AgentDockPosition, AgentSettings, CompletionMode, DefaultView}; use agent_settings::{AgentDockPosition, AgentSettings, CompletionMode, DefaultView};
use ai_onboarding::AgentPanelOnboarding;
use anyhow::{Result, anyhow}; use anyhow::{Result, anyhow};
use assistant_context::{AssistantContext, ContextEvent, ContextSummary}; use assistant_context::{AssistantContext, ContextEvent, ContextSummary};
use assistant_slash_command::SlashCommandWorkingSet; use assistant_slash_command::SlashCommandWorkingSet;
@ -48,13 +48,12 @@ use fs::Fs;
use gpui::{ use gpui::{
Action, Animation, AnimationExt as _, AnyElement, App, AsyncWindowContext, ClipboardItem, Action, Animation, AnimationExt as _, AnyElement, App, AsyncWindowContext, ClipboardItem,
Corner, DismissEvent, Entity, EventEmitter, ExternalPaths, FocusHandle, Focusable, Hsla, Corner, DismissEvent, Entity, EventEmitter, ExternalPaths, FocusHandle, Focusable, Hsla,
KeyContext, Pixels, Subscription, Task, UpdateGlobal, WeakEntity, linear_color_stop, KeyContext, Pixels, Subscription, Task, UpdateGlobal, WeakEntity, prelude::*,
linear_gradient, prelude::*, pulsating_between, pulsating_between,
}; };
use language::LanguageRegistry; use language::LanguageRegistry;
use language_model::{ use language_model::{
ConfigurationError, ConfiguredModel, LanguageModelProviderTosView, LanguageModelRegistry, ConfigurationError, ConfiguredModel, LanguageModelProviderTosView, LanguageModelRegistry,
ZED_CLOUD_PROVIDER_ID,
}; };
use project::{Project, ProjectPath, Worktree}; use project::{Project, ProjectPath, Worktree};
use prompt_store::{PromptBuilder, PromptStore, UserPromptId}; use prompt_store::{PromptBuilder, PromptStore, UserPromptId};
@ -66,9 +65,8 @@ use theme::ThemeSettings;
use time::UtcOffset; use time::UtcOffset;
use ui::utils::WithRemSize; use ui::utils::WithRemSize;
use ui::{ use ui::{
Banner, Button, Callout, CheckboxWithLabel, ContextMenu, ElevationIndex, IconPosition, Banner, Callout, ContextMenu, ElevationIndex, KeyBinding, PopoverMenu, PopoverMenuHandle,
KeyBinding, PopoverMenu, PopoverMenuHandle, ProgressBar, Tab, Tooltip, Vector, VectorName, ProgressBar, Tab, Tooltip, prelude::*,
prelude::*,
}; };
use util::ResultExt as _; use util::ResultExt as _;
use workspace::{ use workspace::{
@ -77,7 +75,7 @@ use workspace::{
}; };
use zed_actions::{ use zed_actions::{
DecreaseBufferFontSize, IncreaseBufferFontSize, ResetBufferFontSize, DecreaseBufferFontSize, IncreaseBufferFontSize, ResetBufferFontSize,
agent::{OpenConfiguration, OpenOnboardingModal, ResetOnboarding}, agent::{OpenConfiguration, OpenOnboardingModal, ResetOnboarding, ToggleModelSelector},
assistant::{OpenRulesLibrary, ToggleFocus}, assistant::{OpenRulesLibrary, ToggleFocus},
}; };
use zed_llm_client::{CompletionIntent, UsageLimit}; use zed_llm_client::{CompletionIntent, UsageLimit};
@ -188,7 +186,7 @@ pub fn init(cx: &mut App) {
window.refresh(); window.refresh();
}) })
.register_action(|_workspace, _: &ResetTrialUpsell, _window, cx| { .register_action(|_workspace, _: &ResetTrialUpsell, _window, cx| {
Upsell::set_dismissed(false, cx); OnboardingUpsell::set_dismissed(false, cx);
}) })
.register_action(|_workspace, _: &ResetTrialEndUpsell, _window, cx| { .register_action(|_workspace, _: &ResetTrialEndUpsell, _window, cx| {
TrialEndUpsell::set_dismissed(false, cx); TrialEndUpsell::set_dismissed(false, cx);
@ -453,7 +451,7 @@ pub struct AgentPanel {
height: Option<Pixels>, height: Option<Pixels>,
zoomed: bool, zoomed: bool,
pending_serialization: Option<Task<Result<()>>>, pending_serialization: Option<Task<Result<()>>>,
hide_upsell: bool, onboarding: Entity<AgentPanelOnboarding>,
} }
impl AgentPanel { impl AgentPanel {
@ -555,6 +553,7 @@ impl AgentPanel {
let user_store = workspace.app_state().user_store.clone(); let user_store = workspace.app_state().user_store.clone();
let project = workspace.project(); let project = workspace.project();
let language_registry = project.read(cx).languages().clone(); let language_registry = project.read(cx).languages().clone();
let client = workspace.client().clone();
let workspace = workspace.weak_handle(); let workspace = workspace.weak_handle();
let weak_self = cx.entity().downgrade(); 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 { Self {
active_view, active_view,
workspace, workspace,
@ -719,7 +729,7 @@ impl AgentPanel {
height: None, height: None,
zoomed: false, zoomed: false,
pending_serialization: None, pending_serialization: None,
hide_upsell: false, onboarding,
} }
} }
@ -2178,191 +2188,78 @@ impl AgentPanel {
return false; return false;
} }
match &self.active_view {
ActiveView::Thread { thread, .. } => {
if thread
.read(cx)
.thread()
.read(cx)
.configured_model()
.map_or(false, |model| {
model.provider.id() != language_model::ZED_CLOUD_PROVIDER_ID
})
{
return false;
}
}
ActiveView::TextThread { .. } => {
if LanguageModelRegistry::global(cx)
.read(cx)
.default_model()
.map_or(false, |model| {
model.provider.id() != language_model::ZED_CLOUD_PROVIDER_ID
})
{
return false;
}
}
ActiveView::ExternalAgentThread { .. }
| ActiveView::History
| ActiveView::Configuration => return false,
}
let plan = self.user_store.read(cx).current_plan(); let plan = self.user_store.read(cx).current_plan();
let has_previous_trial = self.user_store.read(cx).trial_started_at().is_some(); let has_previous_trial = self.user_store.read(cx).trial_started_at().is_some();
matches!(plan, Some(Plan::Free)) && has_previous_trial matches!(plan, Some(Plan::Free)) && has_previous_trial
} }
fn should_render_upsell(&self, cx: &mut Context<Self>) -> bool { fn should_render_onboarding(&self, cx: &mut Context<Self>) -> bool {
if OnboardingUpsell::dismissed() {
return false;
}
match &self.active_view { match &self.active_view {
ActiveView::Thread { thread, .. } => { ActiveView::Thread { thread, .. } => thread
let is_using_zed_provider = thread .read(cx)
.read(cx) .thread()
.thread() .read(cx)
.read(cx) .configured_model()
.configured_model() .map_or(true, |model| {
.map_or(false, |model| model.provider.id() == ZED_CLOUD_PROVIDER_ID); model.provider.id() == language_model::ZED_CLOUD_PROVIDER_ID
}),
if !is_using_zed_provider { ActiveView::TextThread { .. } => LanguageModelRegistry::global(cx)
return false; .read(cx)
} .default_model()
} .map_or(true, |model| {
ActiveView::ExternalAgentThread { .. } => { model.provider.id() == language_model::ZED_CLOUD_PROVIDER_ID
return false; }),
} ActiveView::ExternalAgentThread { .. }
ActiveView::TextThread { .. } | ActiveView::History | ActiveView::Configuration => { | ActiveView::History
return false; | ActiveView::Configuration => false,
}
};
if self.hide_upsell || Upsell::dismissed() {
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 {
return false;
}
true
} }
fn render_upsell( fn render_onboarding(
&self, &self,
_window: &mut Window, _window: &mut Window,
cx: &mut Context<Self>, cx: &mut Context<Self>,
) -> Option<impl IntoElement> { ) -> Option<impl IntoElement> {
if !self.should_render_upsell(cx) { if !self.should_render_onboarding(cx) {
return None; return None;
} }
if self.user_store.read(cx).account_too_young() { Some(div().size_full().child(self.onboarding.clone()))
Some(self.render_young_account_upsell(cx).into_any_element())
} else {
Some(self.render_trial_upsell(cx).into_any_element())
}
}
fn render_young_account_upsell(&self, cx: &mut Context<Self>) -> impl IntoElement {
let checkbox = CheckboxWithLabel::new(
"dont-show-again",
Label::new("Don't show again").color(Color::Muted),
ToggleState::Unselected,
move |toggle_state, _window, cx| {
let toggle_state_bool = toggle_state.selected();
Upsell::set_dismissed(toggle_state_bool, cx);
},
);
let contents = div()
.size_full()
.gap_2()
.flex()
.flex_col()
.child(Headline::new("Build better with Zed Pro").size(HeadlineSize::Small))
.child(
Label::new("Your GitHub account was created less than 30 days ago, so we can't offer you a free trial.")
.size(LabelSize::Small),
)
.child(
Label::new(
"Use your own API keys, upgrade to Zed Pro or send an email to billing-support@zed.dev.",
)
.color(Color::Muted),
)
.child(
h_flex()
.w_full()
.px_neg_1()
.justify_between()
.items_center()
.child(h_flex().items_center().gap_1().child(checkbox))
.child(
h_flex()
.gap_2()
.child(
Button::new("dismiss-button", "Not Now")
.style(ButtonStyle::Transparent)
.color(Color::Muted)
.on_click({
let agent_panel = cx.entity();
move |_, _, cx| {
agent_panel.update(cx, |this, cx| {
this.hide_upsell = true;
cx.notify();
});
}
}),
)
.child(
Button::new("cta-button", "Upgrade to Zed Pro")
.style(ButtonStyle::Transparent)
.on_click(|_, _, cx| cx.open_url(&zed_urls::account_url(cx))),
),
),
);
self.render_upsell_container(cx, contents)
}
fn render_trial_upsell(&self, cx: &mut Context<Self>) -> impl IntoElement {
let checkbox = CheckboxWithLabel::new(
"dont-show-again",
Label::new("Don't show again").color(Color::Muted),
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)
} }
fn render_trial_end_upsell( fn render_trial_end_upsell(
@ -2374,141 +2271,15 @@ impl AgentPanel {
return None; return None;
} }
Some( Some(EndTrialUpsell::new(Arc::new({
self.render_upsell_container( let this = cx.entity();
cx, move |_, cx| {
div() this.update(cx, |_this, cx| {
.size_full() TrialEndUpsell::set_dismissed(true, cx);
.gap_2() cx.notify();
.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<Self>, content: Div) -> Div {
div().p_2().child(
v_flex()
.w_full()
.elevation_2(cx)
.rounded(px(8.))
.bg(cx.theme().colors().background.alpha(0.5))
.p(px(3.))
.child(
div()
.gap_2()
.flex()
.flex_col()
.size_full()
.border_1()
.rounded(px(5.))
.border_color(cx.theme().colors().text.alpha(0.1))
.overflow_hidden()
.relative()
.bg(cx.theme().colors().panel_background)
.px_4()
.py_3()
.child(
div()
.absolute()
.top_0()
.right(px(-1.0))
.w(px(441.))
.h(px(167.))
.child(
Vector::new(
VectorName::Grid,
rems_from_px(441.),
rems_from_px(167.),
)
.color(ui::Color::Custom(cx.theme().colors().text.alpha(0.1))),
),
)
.child(
div()
.absolute()
.top(px(-8.0))
.right_0()
.w(px(400.))
.h(px(92.))
.child(
Vector::new(
VectorName::AiGrid,
rems_from_px(400.),
rems_from_px(92.),
)
.color(ui::Color::Custom(cx.theme().colors().text.alpha(0.32))),
),
)
// .child(
// div()
// .absolute()
// .top_0()
// .right(px(360.))
// .size(px(401.))
// .overflow_hidden()
// .bg(cx.theme().colors().panel_background)
// )
.child(
div()
.absolute()
.top_0()
.right_0()
.w(px(660.))
.h(px(401.))
.overflow_hidden()
.bg(linear_gradient(
75.,
linear_color_stop(
cx.theme().colors().panel_background.alpha(0.01),
1.0,
),
linear_color_stop(cx.theme().colors().panel_background, 0.45),
)),
)
.child(content),
),
)
} }
fn render_thread_empty_state( fn render_thread_empty_state(
@ -2521,8 +2292,10 @@ impl AgentPanel {
.update(cx, |this, cx| this.recent_entries(6, cx)); .update(cx, |this, cx| this.recent_entries(6, cx));
let model_registry = LanguageModelRegistry::read_global(cx); let model_registry = LanguageModelRegistry::read_global(cx);
let configuration_error = let configuration_error =
model_registry.configuration_error(model_registry.default_model(), cx); model_registry.configuration_error(model_registry.default_model(), cx);
let no_error = configuration_error.is_none(); let no_error = configuration_error.is_none();
let focus_handle = self.focus_handle(cx); let focus_handle = self.focus_handle(cx);
@ -2530,11 +2303,9 @@ impl AgentPanel {
.size_full() .size_full()
.bg(cx.theme().colors().panel_background) .bg(cx.theme().colors().panel_background)
.when(recent_history.is_empty(), |this| { .when(recent_history.is_empty(), |this| {
let configuration_error_ref = &configuration_error;
this.child( this.child(
v_flex() v_flex()
.size_full() .size_full()
.max_w_80()
.mx_auto() .mx_auto()
.justify_center() .justify_center()
.items_center() .items_center()
@ -2542,137 +2313,91 @@ impl AgentPanel {
.child(h_flex().child(Headline::new("Welcome to the Agent Panel"))) .child(h_flex().child(Headline::new("Welcome to the Agent Panel")))
.when(no_error, |parent| { .when(no_error, |parent| {
parent parent
.child(h_flex().child(
Label::new("Ask and build anything.").color(Color::Muted),
))
.child( .child(
h_flex().child( v_flex()
Label::new("Ask and build anything.") .mt_2()
.color(Color::Muted) .gap_1()
.mb_2p5(), .max_w_48()
), .child(
) Button::new("context", "Add Context")
.child( .label_size(LabelSize::Small)
Button::new("new-thread", "Start New Thread") .icon(IconName::FileCode)
.icon(IconName::Plus) .icon_position(IconPosition::Start)
.icon_position(IconPosition::Start) .icon_size(IconSize::Small)
.icon_size(IconSize::Small) .icon_color(Color::Muted)
.icon_color(Color::Muted) .full_width()
.full_width() .key_binding(KeyBinding::for_action_in(
.key_binding(KeyBinding::for_action_in( &ToggleContextPicker,
&NewThread::default(), &focus_handle,
&focus_handle, window,
window, cx,
cx, ))
)) .on_click(|_event, window, cx| {
.on_click(|_event, window, cx| { window.dispatch_action(
window.dispatch_action( ToggleContextPicker.boxed_clone(),
NewThread::default().boxed_clone(), cx,
cx, )
) }),
}), )
) .child(
.child( Button::new("mode", "Switch Model")
Button::new("context", "Add Context") .label_size(LabelSize::Small)
.icon(IconName::FileCode) .icon(IconName::DatabaseZap)
.icon_position(IconPosition::Start) .icon_position(IconPosition::Start)
.icon_size(IconSize::Small) .icon_size(IconSize::Small)
.icon_color(Color::Muted) .icon_color(Color::Muted)
.full_width() .full_width()
.key_binding(KeyBinding::for_action_in( .key_binding(KeyBinding::for_action_in(
&ToggleContextPicker, &ToggleModelSelector,
&focus_handle, &focus_handle,
window, window,
cx, cx,
)) ))
.on_click(|_event, window, cx| { .on_click(|_event, window, cx| {
window.dispatch_action( window.dispatch_action(
ToggleContextPicker.boxed_clone(), ToggleModelSelector.boxed_clone(),
cx, cx,
) )
}), }),
) )
.child( .child(
Button::new("mode", "Switch Model") Button::new("settings", "View Settings")
.icon(IconName::DatabaseZap) .label_size(LabelSize::Small)
.icon_position(IconPosition::Start) .icon(IconName::Settings)
.icon_size(IconSize::Small) .icon_position(IconPosition::Start)
.icon_color(Color::Muted) .icon_size(IconSize::Small)
.full_width() .icon_color(Color::Muted)
.key_binding(KeyBinding::for_action_in( .full_width()
&ToggleModelSelector, .key_binding(KeyBinding::for_action_in(
&focus_handle, &OpenConfiguration,
window, &focus_handle,
cx, window,
)) cx,
.on_click(|_event, window, cx| { ))
window.dispatch_action( .on_click(|_event, window, cx| {
ToggleModelSelector.boxed_clone(), window.dispatch_action(
cx, OpenConfiguration.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 { .when_some(configuration_error.as_ref(), |this, err| {
Some( this.child(self.render_configuration_error(
err @ (ConfigurationError::ModelNotFound err,
| ConfigurationError::ProviderNotAuthenticated(_) &focus_handle,
| ConfigurationError::NoProvider), window,
) => parent cx,
.child(h_flex().child( ))
Label::new(err.to_string()).color(Color::Muted).mb_2p5(),
))
.child(
Button::new("settings", "Configure a Provider")
.icon(IconName::Settings)
.icon_position(IconPosition::Start)
.icon_size(IconSize::Small)
.icon_color(Color::Muted)
.full_width()
.key_binding(KeyBinding::for_action_in(
&OpenConfiguration,
&focus_handle,
window,
cx,
))
.on_click(|_event, window, cx| {
window.dispatch_action(
OpenConfiguration.boxed_clone(),
cx,
)
}),
),
Some(ConfigurationError::ProviderPendingTermsAcceptance(provider)) => {
parent.children(provider.render_accept_terms(
LanguageModelProviderTosView::ThreadFreshStart,
cx,
))
}
None => parent,
}), }),
) )
}) })
.when(!recent_history.is_empty(), |parent| { .when(!recent_history.is_empty(), |parent| {
let focus_handle = focus_handle.clone(); let focus_handle = focus_handle.clone();
let configuration_error_ref = &configuration_error;
parent parent
.overflow_hidden() .overflow_hidden()
.p_1p5() .p_1p5()
@ -2735,49 +2460,55 @@ impl AgentPanel {
}, },
)), )),
) )
.map(|parent| match configuration_error_ref { .when_some(configuration_error.as_ref(), |this, err| {
Some( this.child(self.render_configuration_error(err, &focus_handle, window, cx))
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,
}) })
}) })
} }
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( fn render_tool_use_limit_reached(
&self, &self,
window: &mut Window, window: &mut Window,
@ -2910,7 +2641,7 @@ impl AgentPanel {
this.clear_last_error(); 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(); cx.notify();
} }
})) }))
@ -3300,7 +3031,7 @@ impl Render for AgentPanel {
})) }))
.on_action(cx.listener(Self::toggle_burn_mode)) .on_action(cx.listener(Self::toggle_burn_mode))
.child(self.render_toolbar(window, cx)) .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)) .children(self.render_trial_end_upsell(window, cx))
.map(|parent| match &self.active_view { .map(|parent| match &self.active_view {
ActiveView::Thread { ActiveView::Thread {
@ -3309,12 +3040,14 @@ impl Render for AgentPanel {
.. ..
} => parent } => parent
.relative() .relative()
.child(if thread.read(cx).is_empty() { .child(
self.render_thread_empty_state(window, cx) if thread.read(cx).is_empty() && !self.should_render_onboarding(cx) {
.into_any_element() self.render_thread_empty_state(window, cx)
} else { .into_any_element()
thread.clone().into_any_element() } else {
}) thread.clone().into_any_element()
},
)
.children(self.render_tool_use_limit_reached(window, cx)) .children(self.render_tool_use_limit_reached(window, cx))
.when_some(thread.read(cx).last_error(), |this, last_error| { .when_some(thread.read(cx).last_error(), |this, last_error| {
this.child( this.child(
@ -3352,12 +3085,36 @@ impl Render for AgentPanel {
context_editor, context_editor,
buffer_search_bar, buffer_search_bar,
.. ..
} => parent.child(self.render_prompt_editor( } => {
context_editor, let model_registry = LanguageModelRegistry::read_global(cx);
buffer_search_bar, let configuration_error =
window, model_registry.configuration_error(model_registry.default_model(), cx);
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()), 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"; const KEY: &'static str = "dismissed-trial-upsell";
} }

View file

@ -2,7 +2,6 @@ use crate::agent_model_selector::AgentModelSelector;
use crate::buffer_codegen::BufferCodegen; use crate::buffer_codegen::BufferCodegen;
use crate::context_picker::{ContextPicker, ContextPickerCompletionProvider}; use crate::context_picker::{ContextPicker, ContextPickerCompletionProvider};
use crate::context_strip::{ContextStrip, ContextStripEvent, SuggestContextKind}; 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::message_editor::{ContextCreasesAddon, extract_message_creases, insert_message_creases};
use crate::terminal_codegen::TerminalCodegen; use crate::terminal_codegen::TerminalCodegen;
use crate::{CycleNextInlineAssist, CyclePreviousInlineAssist, ModelUsageContext}; use crate::{CycleNextInlineAssist, CyclePreviousInlineAssist, ModelUsageContext};
@ -38,6 +37,7 @@ use ui::{
CheckboxWithLabel, IconButtonShape, KeyBinding, Popover, PopoverMenuHandle, Tooltip, prelude::*, CheckboxWithLabel, IconButtonShape, KeyBinding, Popover, PopoverMenuHandle, Tooltip, prelude::*,
}; };
use workspace::Workspace; use workspace::Workspace;
use zed_actions::agent::ToggleModelSelector;
pub struct PromptEditor<T> { pub struct PromptEditor<T> {
pub editor: Entity<Editor>, pub editor: Entity<Editor>,

View file

@ -3,9 +3,7 @@ use std::{cmp::Reverse, sync::Arc};
use collections::{HashSet, IndexMap}; use collections::{HashSet, IndexMap};
use feature_flags::ZedProFeatureFlag; use feature_flags::ZedProFeatureFlag;
use fuzzy::{StringMatch, StringMatchCandidate, match_strings}; use fuzzy::{StringMatch, StringMatchCandidate, match_strings};
use gpui::{ use gpui::{Action, AnyElement, App, BackgroundExecutor, DismissEvent, Subscription, Task};
Action, AnyElement, App, BackgroundExecutor, DismissEvent, Subscription, Task, actions,
};
use language_model::{ use language_model::{
AuthenticateError, ConfiguredModel, LanguageModel, LanguageModelProviderId, AuthenticateError, ConfiguredModel, LanguageModel, LanguageModelProviderId,
LanguageModelRegistry, LanguageModelRegistry,
@ -15,15 +13,6 @@ use picker::{Picker, PickerDelegate};
use proto::Plan; use proto::Plan;
use ui::{ListItem, ListItemSpacing, prelude::*}; 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"; const TRY_ZED_PRO_URL: &str = "https://zed.dev/pro";
type OnModelChanged = Arc<dyn Fn(Arc<dyn LanguageModel>, &mut App) + 'static>; type OnModelChanged = Arc<dyn Fn(Arc<dyn LanguageModel>, &mut App) + 'static>;

View file

@ -4,7 +4,6 @@ use std::sync::Arc;
use crate::agent_diff::AgentDiffThread; use crate::agent_diff::AgentDiffThread;
use crate::agent_model_selector::AgentModelSelector; use crate::agent_model_selector::AgentModelSelector;
use crate::language_model_selector::ToggleModelSelector;
use crate::tool_compatibility::{IncompatibleToolsState, IncompatibleToolsTooltip}; use crate::tool_compatibility::{IncompatibleToolsState, IncompatibleToolsTooltip};
use crate::ui::{ use crate::ui::{
MaxModeTooltip, MaxModeTooltip,
@ -49,6 +48,7 @@ use ui::{
use util::ResultExt as _; use util::ResultExt as _;
use workspace::{CollaboratorId, Workspace}; use workspace::{CollaboratorId, Workspace};
use zed_actions::agent::Chat; use zed_actions::agent::Chat;
use zed_actions::agent::ToggleModelSelector;
use zed_llm_client::CompletionIntent; use zed_llm_client::CompletionIntent;
use crate::context_picker::{ContextPicker, ContextPickerCompletionProvider, crease_for_mention}; use crate::context_picker::{ContextPicker, ContextPickerCompletionProvider, crease_for_mention};
@ -609,7 +609,11 @@ impl MessageEditor {
) )
} }
fn render_follow_toggle(&self, cx: &mut Context<Self>) -> impl IntoElement { fn render_follow_toggle(
&self,
is_model_selected: bool,
cx: &mut Context<Self>,
) -> impl IntoElement {
let following = self let following = self
.workspace .workspace
.read_with(cx, |workspace, _| { .read_with(cx, |workspace, _| {
@ -618,6 +622,7 @@ impl MessageEditor {
.unwrap_or(false); .unwrap_or(false);
IconButton::new("follow-agent", IconName::Crosshair) IconButton::new("follow-agent", IconName::Crosshair)
.disabled(is_model_selected)
.icon_size(IconSize::Small) .icon_size(IconSize::Small)
.icon_color(Color::Muted) .icon_color(Color::Muted)
.toggle_state(following) .toggle_state(following)
@ -786,7 +791,7 @@ impl MessageEditor {
.justify_between() .justify_between()
.child( .child(
h_flex() h_flex()
.child(self.render_follow_toggle(cx)) .child(self.render_follow_toggle(is_model_selected, cx))
.children(self.render_burn_mode_toggle(cx)), .children(self.render_burn_mode_toggle(cx)),
) )
.child( .child(

View file

@ -1,8 +1,6 @@
use crate::{ use crate::{
burn_mode_tooltip::BurnModeTooltip, burn_mode_tooltip::BurnModeTooltip,
language_model_selector::{ language_model_selector::{LanguageModelSelector, language_model_selector},
LanguageModelSelector, ToggleModelSelector, language_model_selector,
},
}; };
use agent_settings::{AgentSettings, CompletionMode}; use agent_settings::{AgentSettings, CompletionMode};
use anyhow::Result; use anyhow::Result;
@ -38,8 +36,7 @@ use language::{
language_settings::{SoftWrap, all_language_settings}, language_settings::{SoftWrap, all_language_settings},
}; };
use language_model::{ use language_model::{
ConfigurationError, LanguageModelExt, LanguageModelImage, LanguageModelProviderTosView, ConfigurationError, LanguageModelExt, LanguageModelImage, LanguageModelRegistry, Role,
LanguageModelRegistry, Role,
}; };
use multi_buffer::MultiBufferRow; use multi_buffer::MultiBufferRow;
use picker::{Picker, popover_menu::PickerPopoverMenu}; use picker::{Picker, popover_menu::PickerPopoverMenu};
@ -74,6 +71,7 @@ use workspace::{
pane, pane,
searchable::{SearchEvent, SearchableItem}, searchable::{SearchEvent, SearchableItem},
}; };
use zed_actions::agent::ToggleModelSelector;
use crate::{slash_command::SlashCommandCompletionProvider, slash_command_picker}; use crate::{slash_command::SlashCommandCompletionProvider, slash_command_picker};
use assistant_context::{ use assistant_context::{
@ -1895,108 +1893,6 @@ impl TextThreadEditor {
.update(cx, |context, cx| context.summarize(true, cx)); .update(cx, |context, cx| context.summarize(true, cx));
} }
fn render_notice(&self, cx: &mut Context<Self>) -> Option<AnyElement> {
// 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<Self>) -> impl IntoElement { fn render_send_button(&self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let focus_handle = self.focus_handle(cx).clone(); let focus_handle = self.focus_handle(cx).clone();
@ -2128,12 +2024,13 @@ impl TextThreadEditor {
.map(|default| default.model); .map(|default| default.model);
let model_name = match active_model { let model_name = match active_model {
Some(model) => model.name().0, Some(model) => model.name().0,
None => SharedString::from("No model selected"), None => SharedString::from("Select Model"),
}; };
let active_provider = LanguageModelRegistry::read_global(cx) let active_provider = LanguageModelRegistry::read_global(cx)
.default_model() .default_model()
.map(|default| default.provider); .map(|default| default.provider);
let provider_icon = match active_provider { let provider_icon = match active_provider {
Some(provider) => provider.icon(), Some(provider) => provider.icon(),
None => IconName::Ai, None => IconName::Ai,
@ -2581,20 +2478,7 @@ impl EventEmitter<SearchEvent> for TextThreadEditor {}
impl Render for TextThreadEditor { impl Render for TextThreadEditor {
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement { fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> 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 language_model_selector = self.language_model_selector_menu_handle.clone();
let burn_mode_toggle = self.render_burn_mode_toggle(cx);
v_flex() v_flex()
.key_context("ContextEditor") .key_context("ContextEditor")
@ -2611,28 +2495,12 @@ impl Render for TextThreadEditor {
language_model_selector.toggle(window, cx); language_model_selector.toggle(window, cx);
}) })
.size_full() .size_full()
.children(self.render_notice(cx))
.child( .child(
div() div()
.flex_grow() .flex_grow()
.bg(cx.theme().colors().editor_background) .bg(cx.theme().colors().editor_background)
.child(self.editor.clone()), .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)) .children(self.render_last_error(cx))
.child( .child(
h_flex() h_flex()
@ -2649,7 +2517,7 @@ impl Render for TextThreadEditor {
h_flex() h_flex()
.gap_0p5() .gap_0p5()
.child(self.render_inject_context_menu(cx)) .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( .child(
h_flex() h_flex()

View file

@ -1,6 +1,7 @@
mod agent_notification; mod agent_notification;
mod burn_mode_tooltip; mod burn_mode_tooltip;
mod context_pill; mod context_pill;
mod end_trial_upsell;
mod onboarding_modal; mod onboarding_modal;
pub mod preview; pub mod preview;
mod upsell; mod upsell;
@ -8,4 +9,5 @@ mod upsell;
pub use agent_notification::*; pub use agent_notification::*;
pub use burn_mode_tooltip::*; pub use burn_mode_tooltip::*;
pub use context_pill::*; pub use context_pill::*;
pub use end_trial_upsell::*;
pub use onboarding_modal::*; pub use onboarding_modal::*;

View file

@ -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<dyn Fn(&mut Window, &mut App)>,
}
impl EndTrialUpsell {
pub fn new(dismiss_upsell: Arc<dyn Fn(&mut Window, &mut App)>) -> 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<AnyElement> {
Some(
v_flex()
.p_4()
.gap_4()
.child(EndTrialUpsell {
dismiss_upsell: Arc::new(|_, _| {}),
})
.into_any_element(),
)
}
}

View file

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

View file

@ -0,0 +1 @@
../../LICENSE-GPL

View file

@ -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<Item = AnyElement>) {
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),
)
}
}

View file

@ -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<UserStore>,
client: Arc<Client>,
configured_providers: Vec<(IconName, SharedString)>,
continue_with_zed_ai: Arc<dyn Fn(&mut Window, &mut App)>,
}
impl AgentPanelOnboarding {
pub fn new(
user_store: Entity<UserStore>,
client: Arc<Client>,
continue_with_zed_ai: impl Fn(&mut Window, &mut App) + 'static,
cx: &mut Context<Self>,
) -> 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<Self>) {
window.dispatch_action(OpenConfiguration.boxed_clone(), cx);
cx.notify();
}
fn render_api_keys_section(&mut self, cx: &mut Context<Self>) -> 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<Self>) -> 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))
}
}

View file

@ -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<SharedString>) -> 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<client::Status> 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<proto::Plan>,
pub account_too_young: bool,
pub continue_with_zed_ai: Arc<dyn Fn(&mut Window, &mut App)>,
pub sign_in: Arc<dyn Fn(&mut Window, &mut App)>,
pub accept_terms_of_service: Arc<dyn Fn(&mut Window, &mut App)>,
}
impl ZedAiOnboarding {
pub fn new(
client: Arc<Client>,
user_store: &Entity<UserStore>,
continue_with_zed_ai: Arc<dyn Fn(&mut Window, &mut App)>,
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<AnyElement> {
fn onboarding(
sign_in_status: SignInStatus,
has_accepted_terms_of_service: bool,
plan: Option<proto::Plan>,
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(),
)
}
}

View file

@ -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<UserStore>,
client: Arc<Client>,
copilot_is_configured: bool,
continue_with_zed_ai: Arc<dyn Fn(&mut Window, &mut App)>,
continue_with_copilot: Arc<dyn Fn(&mut Window, &mut App)>,
}
impl EditPredictionOnboarding {
pub fn new(
user_store: Entity<UserStore>,
client: Arc<Client>,
copilot_is_configured: bool,
continue_with_zed_ai: Arc<dyn Fn(&mut Window, &mut App)>,
continue_with_copilot: Arc<dyn Fn(&mut Window, &mut App)>,
_cx: &mut Context<Self>,
) -> 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<Self>) -> 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)
}
}

View file

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

View file

@ -301,6 +301,13 @@ impl Status {
matches!(self, Self::Connected { .. }) 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 { pub fn is_signed_out(&self) -> bool {
matches!(self, Self::SignedOut | Self::UpgradeRequired) matches!(self, Self::SignedOut | Self::UpgradeRequired)
} }

View file

@ -764,6 +764,16 @@ impl UserStore {
} }
pub fn current_plan(&self) -> Option<proto::Plan> { pub fn current_plan(&self) -> Option<proto::Plan> {
#[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 self.current_plan
} }

View file

@ -17,3 +17,8 @@ fn server_url(cx: &App) -> &str {
pub fn account_url(cx: &App) -> String { pub fn account_url(cx: &App) -> String {
format!("{server_url}/account", server_url = server_url(cx)) 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))
}

View file

@ -209,8 +209,14 @@ impl Status {
matches!(self, Status::Authorized) matches!(self, Status::Authorized)
} }
pub fn is_disabled(&self) -> bool { pub fn is_configured(&self) -> bool {
matches!(self, Status::Disabled) matches!(
self,
Status::Starting { .. }
| Status::Error(_)
| Status::SigningIn { .. }
| Status::Authorized
)
} }
} }

View file

@ -46,6 +46,7 @@ actions!(
); );
const COPILOT_SETTINGS_URL: &str = "https://github.com/settings/copilot"; const COPILOT_SETTINGS_URL: &str = "https://github.com/settings/copilot";
const PRIVACY_DOCS: &str = "https://zed.dev/docs/ai/privacy-and-security";
struct CopilotErrorToast; struct CopilotErrorToast;
@ -193,13 +194,13 @@ impl Render for InlineCompletionButton {
cx.open_url(activate_url.as_str()) cx.open_url(activate_url.as_str())
}) })
.entry( .entry(
"Use Copilot", "Use Zed AI",
None, None,
move |_, cx| { move |_, cx| {
set_completion_provider( set_completion_provider(
fs.clone(), fs.clone(),
cx, cx,
EditPredictionProvider::Copilot, EditPredictionProvider::Zed,
) )
}, },
) )
@ -239,22 +240,13 @@ impl Render for InlineCompletionButton {
IconName::ZedPredictDisabled IconName::ZedPredictDisabled
}; };
let current_user_terms_accepted = if zeta::should_show_upsell_modal(&self.user_store, cx) {
self.user_store.read(cx).current_user_has_accepted_terms(); let tooltip_meta =
let has_subscription = self.user_store.read(cx).current_plan().is_some() match self.user_store.read(cx).current_user_has_accepted_terms() {
&& self.user_store.read(cx).subscription_period().is_some(); Some(true) => "Choose a Plan",
Some(false) => "Accept the Terms of Service",
if !has_subscription || !current_user_terms_accepted.unwrap_or(false) { None => "Sign In",
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"
};
return div().child( return div().child(
IconButton::new("zed-predict-pending-button", zeta_icon) IconButton::new("zed-predict-pending-button", zeta_icon)
@ -403,15 +395,16 @@ impl InlineCompletionButton {
) -> Entity<ContextMenu> { ) -> Entity<ContextMenu> {
let fs = self.fs.clone(); let fs = self.fs.clone();
ContextMenu::build(window, cx, |menu, _, _| { 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, { .entry("Disable Copilot", None, {
let fs = fs.clone(); let fs = fs.clone();
move |_window, cx| hide_copilot(fs.clone(), cx) move |_window, cx| hide_copilot(fs.clone(), cx)
}) })
.entry("Use Supermaven", None, { .separator()
.entry("Use Zed AI", None, {
let fs = fs.clone(); let fs = fs.clone();
move |_window, cx| { 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 { if let Some(provider) = &self.edit_prediction_provider {
let data_collection = provider.data_collection_state(cx); let data_collection = provider.data_collection_state(cx);
if data_collection.is_supported() { if data_collection.is_supported() {
@ -569,13 +562,15 @@ impl InlineCompletionButton {
.child( .child(
Label::new(indoc!{ Label::new(indoc!{
"Help us improve our open dataset model by sharing data from open source repositories. \ "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( .child(
h_flex() h_flex()
.items_start() .items_start()
.pt_2() .pt_2()
.pr_1()
.flex_1() .flex_1()
.gap_1p5() .gap_1p5()
.border_t_1() .border_t_1()
@ -635,6 +630,13 @@ impl InlineCompletionButton {
.detach_and_log_err(cx); .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) { if !self.editor_enabled.unwrap_or(true) {
@ -672,6 +674,13 @@ impl InlineCompletionButton {
) -> Entity<ContextMenu> { ) -> Entity<ContextMenu> {
ContextMenu::build(window, cx, |menu, window, cx| { ContextMenu::build(window, cx, |menu, window, cx| {
self.build_language_settings_menu(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() .separator()
.link( .link(
"Go to Copilot Settings", "Go to Copilot Settings",
@ -750,44 +759,24 @@ impl InlineCompletionButton {
menu = menu menu = menu
.custom_entry( .custom_entry(
|_window, _cx| { |_window, _cx| {
h_flex() Label::new("Your GitHub account is less than 30 days old.")
.gap_1() .size(LabelSize::Small)
.child( .color(Color::Warning)
Icon::new(IconName::Warning)
.size(IconSize::Small)
.color(Color::Warning),
)
.child(
Label::new("Your GitHub account is less than 30 days old")
.size(LabelSize::Small)
.color(Color::Warning),
)
.into_any_element() .into_any_element()
}, },
|_window, cx| cx.open_url(&zed_urls::account_url(cx)), |_window, cx| cx.open_url(&zed_urls::account_url(cx)),
) )
.entry( .entry("Upgrade to Zed Pro or contact us.", None, |_window, cx| {
"You need to upgrade to Zed Pro or contact us.", cx.open_url(&zed_urls::account_url(cx))
None, })
|_window, cx| cx.open_url(&zed_urls::account_url(cx)),
)
.separator(); .separator();
} else if self.user_store.read(cx).has_overdue_invoices() { } else if self.user_store.read(cx).has_overdue_invoices() {
menu = menu menu = menu
.custom_entry( .custom_entry(
|_window, _cx| { |_window, _cx| {
h_flex() Label::new("You have an outstanding invoice")
.gap_1() .size(LabelSize::Small)
.child( .color(Color::Warning)
Icon::new(IconName::Warning)
.size(IconSize::Small)
.color(Color::Warning),
)
.child(
Label::new("You have an outstanding invoice")
.size(LabelSize::Small)
.color(Color::Warning),
)
.into_any_element() .into_any_element()
}, },
|_window, cx| { |_window, cx| {

View file

@ -654,7 +654,7 @@ pub enum LanguageModelProviderTosView {
ThreadEmptyState, ThreadEmptyState,
/// When there are no past interactions in the Agent Panel. /// When there are no past interactions in the Agent Panel.
ThreadFreshStart, ThreadFreshStart,
PromptEditorPopup, TextThreadPopup,
Configuration, Configuration,
} }

View file

@ -12,6 +12,7 @@ workspace = true
path = "src/language_models.rs" path = "src/language_models.rs"
[dependencies] [dependencies]
ai_onboarding.workspace = true
anthropic = { workspace = true, features = ["schemars"] } anthropic = { workspace = true, features = ["schemars"] }
anyhow.workspace = true anyhow.workspace = true
aws-config = { workspace = true, features = ["behavior-version-latest"] } aws-config = { workspace = true, features = ["behavior-version-latest"] }

View file

@ -1,3 +1,4 @@
use ai_onboarding::YoungAccountBanner;
use anthropic::AnthropicModelMode; use anthropic::AnthropicModelMode;
use anyhow::{Context as _, Result, anyhow}; use anyhow::{Context as _, Result, anyhow};
use chrono::{DateTime, Utc}; use chrono::{DateTime, Utc};
@ -500,7 +501,7 @@ fn render_accept_terms(
) )
.child({ .child({
match view_kind { match view_kind {
LanguageModelProviderTosView::PromptEditorPopup => { LanguageModelProviderTosView::TextThreadPopup => {
button_container.w_full().justify_end() button_container.w_full().justify_end()
} }
LanguageModelProviderTosView::Configuration => { LanguageModelProviderTosView::Configuration => {
@ -1126,6 +1127,7 @@ struct ZedAiConfiguration {
subscription_period: Option<(DateTime<Utc>, DateTime<Utc>)>, subscription_period: Option<(DateTime<Utc>, DateTime<Utc>)>,
eligible_for_trial: bool, eligible_for_trial: bool,
has_accepted_terms_of_service: bool, has_accepted_terms_of_service: bool,
account_too_young: bool,
accept_terms_of_service_in_progress: bool, accept_terms_of_service_in_progress: bool,
accept_terms_of_service_callback: Arc<dyn Fn(&mut Window, &mut App) + Send + Sync>, accept_terms_of_service_callback: Arc<dyn Fn(&mut Window, &mut App) + Send + Sync>,
sign_in_callback: Arc<dyn Fn(&mut Window, &mut App) + Send + Sync>, sign_in_callback: Arc<dyn Fn(&mut Window, &mut App) + Send + Sync>,
@ -1133,18 +1135,18 @@ struct ZedAiConfiguration {
impl RenderOnce for ZedAiConfiguration { impl RenderOnce for ZedAiConfiguration {
fn render(self, _window: &mut Window, _cx: &mut App) -> impl IntoElement { 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 is_pro = self.plan == Some(proto::Plan::ZedPro);
let subscription_text = match (self.plan, self.subscription_period) { let subscription_text = match (self.plan, self.subscription_period) {
(Some(proto::Plan::ZedPro), Some(_)) => { (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(_)) => { (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(_)) => { (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 { if self.eligible_for_trial {
@ -1154,68 +1156,76 @@ impl RenderOnce for ZedAiConfiguration {
} }
} }
}; };
let manage_subscription_buttons = if is_pro { let manage_subscription_buttons = if is_pro {
h_flex().child( Button::new("manage_settings", "Manage Subscription")
Button::new("manage_settings", "Manage Subscription") .style(ButtonStyle::Tinted(TintColor::Accent))
.style(ButtonStyle::Tinted(TintColor::Accent)) .on_click(|_, _, cx| cx.open_url(&zed_urls::account_url(cx)))
.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 { } else {
h_flex() Button::new("upgrade", "Upgrade to Pro")
.gap_2() .style(ui::ButtonStyle::Tinted(ui::TintColor::Accent))
.child( .full_width()
Button::new("learn_more", "Learn more") .on_click(|_, _, cx| cx.open_url(&zed_urls::upgrade_to_zed_pro_url(cx)))
.style(ButtonStyle::Subtle) .into_any_element()
.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))),
)
}; };
if self.is_connected { if !self.is_connected {
v_flex() return 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()
.gap_2() .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( .child(
Button::new("sign_in", "Sign In") Button::new("sign_in", "Sign In to use Zed AI")
.icon_color(Color::Muted) .icon_color(Color::Muted)
.icon(IconName::Github) .icon(IconName::Github)
.icon_size(IconSize::Small)
.icon_position(IconPosition::Start) .icon_position(IconPosition::Start)
.full_width()
.on_click({ .on_click({
let callback = self.sign_in_callback.clone(); let callback = self.sign_in_callback.clone();
move |_, window, cx| (callback)(window, cx) 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(), subscription_period: user_store.subscription_period(),
eligible_for_trial: user_store.trial_started_at().is_none(), eligible_for_trial: user_store.trial_started_at().is_none(),
has_accepted_terms_of_service: state.has_accepted_terms_of_service(cx), 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_in_progress: state.accept_terms_of_service_task.is_some(),
accept_terms_of_service_callback: self.accept_terms_of_service_callback.clone(), accept_terms_of_service_callback: self.accept_terms_of_service_callback.clone(),
sign_in_callback: self.sign_in_callback.clone(), sign_in_callback: self.sign_in_callback.clone(),
@ -1281,6 +1292,7 @@ impl Component for ZedAiConfiguration {
is_connected: bool, is_connected: bool,
plan: Option<proto::Plan>, plan: Option<proto::Plan>,
eligible_for_trial: bool, eligible_for_trial: bool,
account_too_young: bool,
has_accepted_terms_of_service: bool, has_accepted_terms_of_service: bool,
) -> AnyElement { ) -> AnyElement {
ZedAiConfiguration { ZedAiConfiguration {
@ -1291,6 +1303,7 @@ impl Component for ZedAiConfiguration {
.then(|| (Utc::now(), Utc::now() + chrono::Duration::days(7))), .then(|| (Utc::now(), Utc::now() + chrono::Duration::days(7))),
eligible_for_trial, eligible_for_trial,
has_accepted_terms_of_service, has_accepted_terms_of_service,
account_too_young,
accept_terms_of_service_in_progress: false, accept_terms_of_service_in_progress: false,
accept_terms_of_service_callback: Arc::new(|_, _| {}), accept_terms_of_service_callback: Arc::new(|_, _| {}),
sign_in_callback: Arc::new(|_, _| {}), sign_in_callback: Arc::new(|_, _| {}),
@ -1303,30 +1316,33 @@ impl Component for ZedAiConfiguration {
.p_4() .p_4()
.gap_4() .gap_4()
.children(vec![ .children(vec![
single_example("Not connected", configuration(false, None, false, true)), single_example(
"Not connected",
configuration(false, None, false, false, true),
),
single_example( single_example(
"Accept Terms of Service", "Accept Terms of Service",
configuration(true, None, true, false), configuration(true, None, true, false, false),
), ),
single_example( single_example(
"No Plan - Not eligible for trial", "No Plan - Not eligible for trial",
configuration(true, None, false, true), configuration(true, None, false, false, true),
), ),
single_example( single_example(
"No Plan - Eligible for trial", "No Plan - Eligible for trial",
configuration(true, None, true, true), configuration(true, None, true, false, true),
), ),
single_example( single_example(
"Free Plan", "Free Plan",
configuration(true, Some(proto::Plan::Free), true, true), configuration(true, Some(proto::Plan::Free), true, false, true),
), ),
single_example( single_example(
"Zed Pro Trial Plan", "Zed Pro Trial Plan",
configuration(true, Some(proto::Plan::ZedProTrial), true, true), configuration(true, Some(proto::Plan::ZedProTrial), true, false, true),
), ),
single_example( single_example(
"Zed Pro Plan", "Zed Pro Plan",
configuration(true, Some(proto::Plan::ZedPro), true, true), configuration(true, Some(proto::Plan::ZedPro), true, false, true),
), ),
]) ])
.into_any_element(), .into_any_element(),

View file

@ -20,7 +20,7 @@ use crate::application_menu::{
use auto_update::AutoUpdateStatus; use auto_update::AutoUpdateStatus;
use call::ActiveCall; use call::ActiveCall;
use client::{Client, UserStore}; use client::{Client, UserStore, zed_urls};
use gpui::{ use gpui::{
Action, AnyElement, App, Context, Corner, Element, Entity, Focusable, InteractiveElement, Action, AnyElement, App, Context, Corner, Element, Entity, Focusable, InteractiveElement,
IntoElement, MouseButton, ParentElement, Render, StatefulInteractiveElement, Styled, IntoElement, MouseButton, ParentElement, Render, StatefulInteractiveElement, Styled,
@ -656,8 +656,9 @@ impl TitleBar {
let user_login = user.github_login.clone(); let user_login = user.github_login.clone();
let (plan_name, label_color, bg_color) = match plan { let (plan_name, label_color, bg_color) = match plan {
None => ("None", Color::Default, free_chip_bg), None | Some(proto::Plan::Free) => {
Some(proto::Plan::Free) => ("Free", Color::Default, free_chip_bg), ("Free", Color::Default, free_chip_bg)
}
Some(proto::Plan::ZedProTrial) => { Some(proto::Plan::ZedProTrial) => {
("Pro Trial", Color::Accent, pro_chip_bg) ("Pro Trial", Color::Accent, pro_chip_bg)
} }
@ -680,7 +681,7 @@ impl TitleBar {
.into_any_element() .into_any_element()
}, },
move |_, cx| { move |_, cx| {
cx.open_url("https://zed.dev/account"); cx.open_url(&zed_urls::account_url(cx));
}, },
) )
.separator() .separator()

View file

@ -126,6 +126,10 @@ pub enum ButtonStyle {
/// coloring like an error or success button. /// coloring like an error or success button.
Tinted(TintColor), 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, /// The default button style, used for most buttons. Has a transparent background,
/// but has a background color to indicate states like hover and active. /// but has a background color to indicate states like hover and active.
#[default] #[default]
@ -180,6 +184,12 @@ impl ButtonStyle {
icon_color: Color::Default.color(cx), icon_color: Color::Default.color(cx),
}, },
ButtonStyle::Tinted(tint) => tint.button_like_style(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 { ButtonStyle::Subtle => ButtonLikeStyles {
background: cx.theme().colors().ghost_element_background, background: cx.theme().colors().ghost_element_background,
border_color: transparent_black(), border_color: transparent_black(),
@ -219,6 +229,12 @@ impl ButtonStyle {
styles.background = theme.darken(styles.background, 0.05, 0.2); styles.background = theme.darken(styles.background, 0.05, 0.2);
styles 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 { ButtonStyle::Subtle => ButtonLikeStyles {
background: cx.theme().colors().ghost_element_hover, background: cx.theme().colors().ghost_element_hover,
border_color: transparent_black(), border_color: transparent_black(),
@ -251,6 +267,12 @@ impl ButtonStyle {
label_color: Color::Default.color(cx), label_color: Color::Default.color(cx),
icon_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 { ButtonStyle::Transparent => ButtonLikeStyles {
background: transparent_black(), background: transparent_black(),
border_color: transparent_black(), border_color: transparent_black(),
@ -278,6 +300,12 @@ impl ButtonStyle {
label_color: Color::Default.color(cx), label_color: Color::Default.color(cx),
icon_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 { ButtonStyle::Transparent => ButtonLikeStyles {
background: transparent_black(), background: transparent_black(),
border_color: cx.theme().colors().border_focused, border_color: cx.theme().colors().border_focused,
@ -308,6 +336,12 @@ impl ButtonStyle {
label_color: Color::Disabled.color(cx), label_color: Color::Disabled.color(cx),
icon_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 { ButtonStyle::Transparent => ButtonLikeStyles {
background: transparent_black(), background: transparent_black(),
border_color: transparent_black(), border_color: transparent_black(),
@ -525,6 +559,13 @@ impl RenderOnce for ButtonLike {
.when_some(self.width, |this, width| { .when_some(self.width, |this, width| {
this.w(width).justify_center().text_center() 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 { .when_some(self.rounding, |this, rounding| match rounding {
ButtonLikeRounding::All => this.rounded_sm(), ButtonLikeRounding::All => this.rounded_sm(),
ButtonLikeRounding::Left => this.rounded_l_sm(), ButtonLikeRounding::Left => this.rounded_l_sm(),
@ -538,6 +579,7 @@ impl RenderOnce for ButtonLike {
} }
ButtonSize::None => this, ButtonSize::None => this,
}) })
.border_color(style.enabled(self.layer, cx).border_color)
.bg(style.enabled(self.layer, cx).background) .bg(style.enabled(self.layer, cx).background)
.when(self.disabled, |this| { .when(self.disabled, |this| {
if self.cursor_style == CursorStyle::PointingHand { if self.cursor_style == CursorStyle::PointingHand {

View file

@ -277,7 +277,10 @@ pub mod agent {
/// Displays the previous message in the history. /// Displays the previous message in the history.
PreviousHistoryMessage, PreviousHistoryMessage,
/// Displays the next message in the history. /// Displays the next message in the history.
NextHistoryMessage NextHistoryMessage,
/// Toggles the language model selector dropdown.
#[action(deprecated_aliases = ["assistant::ToggleModelSelector", "assistant2::ToggleModelSelector"])]
ToggleModelSelector
] ]
); );
} }

View file

@ -17,11 +17,13 @@ doctest = false
test-support = [] test-support = []
[dependencies] [dependencies]
ai_onboarding.workspace = true
anyhow.workspace = true anyhow.workspace = true
arrayvec.workspace = true arrayvec.workspace = true
client.workspace = true client.workspace = true
collections.workspace = true collections.workspace = true
command_palette_hooks.workspace = true command_palette_hooks.workspace = true
copilot.workspace = true
db.workspace = true db.workspace = true
editor.workspace = true editor.workspace = true
feature_flags.workspace = true feature_flags.workspace = true
@ -35,8 +37,6 @@ language.workspace = true
language_model.workspace = true language_model.workspace = true
log.workspace = true log.workspace = true
menu.workspace = true menu.workspace = true
migrator.workspace = true
paths.workspace = true
postage.workspace = true postage.workspace = true
project.workspace = true project.workspace = true
proto.workspace = true proto.workspace = true

View file

@ -34,7 +34,6 @@ pub fn init(cx: &mut App) {
workspace, workspace,
workspace.user_store().clone(), workspace.user_store().clone(),
workspace.client().clone(), workspace.client().clone(),
workspace.app_state().fs.clone(),
window, window,
cx, cx,
) )

View file

@ -1,40 +1,33 @@
use std::{sync::Arc, time::Duration}; use std::sync::Arc;
use crate::{ZED_PREDICT_DATA_COLLECTION_CHOICE, onboarding_event}; use crate::{ZedPredictUpsell, onboarding_event};
use anyhow::Context as _; use ai_onboarding::EditPredictionOnboarding;
use client::{Client, UserStore}; use client::{Client, UserStore};
use db::kvp::KEY_VALUE_STORE; use db::kvp::Dismissable;
use fs::Fs; use fs::Fs;
use gpui::{ use gpui::{
Animation, AnimationExt as _, ClickEvent, DismissEvent, Entity, EventEmitter, FocusHandle, ClickEvent, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, MouseDownEvent, Render,
Focusable, MouseDownEvent, Render, ease_in_out, svg, linear_color_stop, linear_gradient,
}; };
use language::language_settings::{AllLanguageSettings, EditPredictionProvider}; use language::language_settings::{AllLanguageSettings, EditPredictionProvider};
use settings::{Settings, update_settings_file}; use settings::update_settings_file;
use ui::{Checkbox, TintColor, prelude::*}; use ui::{Vector, VectorName, prelude::*};
use util::ResultExt; use workspace::{ModalView, Workspace};
use workspace::{ModalView, Workspace, notifications::NotifyTaskExt};
/// Introduces user to Zed's Edit Prediction feature and terms of service /// Introduces user to Zed's Edit Prediction feature and terms of service
pub struct ZedPredictModal { pub struct ZedPredictModal {
user_store: Entity<UserStore>, onboarding: Entity<EditPredictionOnboarding>,
client: Arc<Client>,
fs: Arc<dyn Fs>,
focus_handle: FocusHandle, focus_handle: FocusHandle,
sign_in_status: SignInStatus,
terms_of_service: bool,
data_collection_expanded: bool,
data_collection_opted_in: bool,
} }
#[derive(PartialEq, Eq)] pub(crate) fn set_edit_prediction_provider(provider: EditPredictionProvider, cx: &mut App) {
enum SignInStatus { let fs = <dyn Fs>::global(cx);
/// Signed out or signed in but not from this modal update_settings_file::<AllLanguageSettings>(fs, cx, move |settings, _| {
Idle, settings
/// Authentication triggered from this modal .features
Waiting, .get_or_insert(Default::default())
/// Signed in after authentication from this modal .edit_prediction_provider = Some(provider);
SignedIn, });
} }
impl ZedPredictModal { impl ZedPredictModal {
@ -42,127 +35,45 @@ impl ZedPredictModal {
workspace: &mut Workspace, workspace: &mut Workspace,
user_store: Entity<UserStore>, user_store: Entity<UserStore>,
client: Arc<Client>, client: Arc<Client>,
fs: Arc<dyn Fs>,
window: &mut Window, window: &mut Window,
cx: &mut Context<Workspace>, cx: &mut Context<Workspace>,
) { ) {
workspace.toggle_modal(window, cx, |_window, cx| Self { workspace.toggle_modal(window, cx, |_window, cx| {
user_store, let weak_entity = cx.weak_entity();
client, Self {
fs, onboarding: cx.new(|cx| {
focus_handle: cx.focus_handle(), EditPredictionOnboarding::new(
sign_in_status: SignInStatus::Idle, user_store.clone(),
terms_of_service: false, client.clone(),
data_collection_expanded: false, copilot::Copilot::global(cx)
data_collection_opted_in: false, .map_or(false, |copilot| copilot.read(cx).status().is_configured()),
Arc::new({
let this = weak_entity.clone();
move |_window, cx| {
ZedPredictUpsell::set_dismissed(true, cx);
set_edit_prediction_provider(EditPredictionProvider::Zed, cx);
this.update(cx, |_, cx| cx.emit(DismissEvent)).ok();
}
}),
Arc::new({
let this = weak_entity.clone();
move |window, cx| {
ZedPredictUpsell::set_dismissed(true, cx);
set_edit_prediction_provider(EditPredictionProvider::Copilot, cx);
this.update(cx, |_, cx| cx.emit(DismissEvent)).ok();
copilot::initiate_sign_in(window, cx);
}
}),
cx,
)
}),
focus_handle: cx.focus_handle(),
}
}); });
} }
fn view_terms(&mut self, _: &ClickEvent, _: &mut Window, cx: &mut Context<Self>) {
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<Self>) {
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<Self>) {
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<Self>) {
let task = self
.user_store
.update(cx, |this, cx| this.accept_terms_of_service(cx));
let fs = self.fs.clone();
cx.spawn(async move |this, cx| {
task.await?;
let mut data_collection_opted_in = false;
this.update(cx, |this, _cx| {
data_collection_opted_in = this.data_collection_opted_in;
})
.ok();
KEY_VALUE_STORE
.write_kvp(
ZED_PREDICT_DATA_COLLECTION_CHOICE.into(),
data_collection_opted_in.to_string(),
)
.await
.log_err();
// Make sure edit prediction provider setting is using the new key
let settings_path = paths::settings_file().as_path();
let settings_path = fs.canonicalize(settings_path).await.with_context(|| {
format!("Failed to canonicalize settings path {:?}", settings_path)
})?;
if let Some(settings) = fs.load(&settings_path).await.log_err() {
if let Some(new_settings) =
migrator::migrate_edit_prediction_provider_settings(&settings)?
{
fs.atomic_write(settings_path, new_settings).await?;
}
}
this.update(cx, |this, cx| {
update_settings_file::<AllLanguageSettings>(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<Self>) {
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<Self>) { fn cancel(&mut self, _: &menu::Cancel, _: &mut Window, cx: &mut Context<Self>) {
ZedPredictUpsell::set_dismissed(true, cx);
cx.emit(DismissEvent); cx.emit(DismissEvent);
} }
} }
@ -177,85 +88,12 @@ impl Focusable for ZedPredictModal {
impl ModalView for ZedPredictModal {} impl ModalView for ZedPredictModal {}
impl ZedPredictModal {
fn render_data_collection_explanation(&self, cx: &Context<Self>) -> impl IntoElement {
fn label_item(label_text: impl Into<SharedString>) -> impl Element {
Label::new(label_text).color(Color::Muted).into_element()
}
fn info_item(label_text: impl Into<SharedString>) -> 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<E1: Into<SharedString>, 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 { impl Render for ZedPredictModal {
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement { fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let window_height = window.viewport_size().height; let window_height = window.viewport_size().height;
let max_height = window_height - px(200.); let max_height = window_height - px(200.);
let has_subscription_period = self.user_store.read(cx).subscription_period().is_some(); v_flex()
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()
.id("edit-prediction-onboarding") .id("edit-prediction-onboarding")
.key_context("ZedPredictModal") .key_context("ZedPredictModal")
.relative() .relative()
@ -264,14 +102,9 @@ impl Render for ZedPredictModal {
.max_h(max_height) .max_h(max_height)
.p_4() .p_4()
.gap_2() .gap_2()
.when(self.data_collection_expanded, |element| {
element.overflow_y_scroll()
})
.when(!self.data_collection_expanded, |element| {
element.overflow_hidden()
})
.elevation_3(cx) .elevation_3(cx)
.track_focus(&self.focus_handle(cx)) .track_focus(&self.focus_handle(cx))
.overflow_hidden()
.on_action(cx.listener(Self::cancel)) .on_action(cx.listener(Self::cancel))
.on_action(cx.listener(|_, _: &menu::Cancel, _window, cx| { .on_action(cx.listener(|_, _: &menu::Cancel, _window, cx| {
onboarding_event!("Cancelled", trigger = "Action"); onboarding_event!("Cancelled", trigger = "Action");
@ -282,77 +115,30 @@ impl Render for ZedPredictModal {
})) }))
.child( .child(
div() div()
.p_1p5() .opacity(0.5)
.absolute() .absolute()
.top_1() .top(px(-8.0))
.left_1()
.right_0() .right_0()
.h(px(200.)) .w(px(400.))
.h(px(92.))
.child( .child(
svg() Vector::new(VectorName::AiGrid, rems_from_px(400.), rems_from_px(92.))
.path("icons/zed_predict_bg.svg") .color(Color::Custom(cx.theme().colors().text.alpha(0.32))),
.text_color(cx.theme().colors().icon_disabled)
.w(px(530.))
.h(px(128.))
.overflow_hidden(),
), ),
) )
.child( .child(
h_flex() div()
.w_full() .absolute()
.mb_2() .top_0()
.justify_between() .right_0()
.child( .w(px(660.))
v_flex() .h(px(401.))
.gap_1() .overflow_hidden()
.child( .bg(linear_gradient(
Label::new("Introducing Zed AI's") 75.,
.size(LabelSize::Small) linear_color_stop(cx.theme().colors().panel_background.alpha(0.01), 1.0),
.color(Color::Muted), linear_color_stop(cx.theme().colors().panel_background, 0.45),
) )),
.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())
}),
) )
.child(h_flex().absolute().top_2().right_2().child( .child(h_flex().absolute().top_2().right_2().child(
IconButton::new("cancel", IconName::X).on_click(cx.listener( IconButton::new("cancel", IconName::X).on_click(cx.listener(
@ -361,148 +147,7 @@ impl Render for ZedPredictModal {
cx.emit(DismissEvent); cx.emit(DismissEvent);
}, },
)), )),
)); ))
.child(self.onboarding.clone())
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),
)
}
} }
} }

View file

@ -7,7 +7,7 @@ mod onboarding_telemetry;
mod rate_completion_modal; mod rate_completion_modal;
pub(crate) use completion_diff_element::*; pub(crate) use completion_diff_element::*;
use db::kvp::KEY_VALUE_STORE; use db::kvp::{Dismissable, KEY_VALUE_STORE};
pub use init::*; pub use init::*;
use inline_completion::DataCollectionState; use inline_completion::DataCollectionState;
use license_detection::LICENSE_FILES_TO_CHECK; 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<UserStore>, cx: &App) -> bool {
match user_store.read(cx).current_user_has_accepted_terms() {
Some(true) => !ZedPredictUpsell::dismissed(),
Some(false) | None => true,
}
}
#[derive(Clone)] #[derive(Clone)]
struct ZetaGlobal(Entity<Zeta>); struct ZetaGlobal(Entity<Zeta>);