onboarding: Add the AI page (#35351)

This PR starts the work on the AI onboarding page as well as the
configuration modal

Release Notes:

- N/A

---------

Co-authored-by: Danilo Leal <daniloleal09@gmail.com>
Co-authored-by: Anthony <anthony@zed.dev>
This commit is contained in:
Finn Evers 2025-08-01 16:43:59 +02:00 committed by GitHub
parent e5c6a596a9
commit b01d1872cc
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 550 additions and 63 deletions

View file

@ -16,6 +16,7 @@ default = []
[dependencies]
anyhow.workspace = true
ai_onboarding.workspace = true
client.workspace = true
command_palette_hooks.workspace = true
component.workspace = true
@ -25,7 +26,10 @@ editor.workspace = true
feature_flags.workspace = true
fs.workspace = true
gpui.workspace = true
itertools.workspace = true
language.workspace = true
language_model.workspace = true
menu.workspace = true
project.workspace = true
schemars.workspace = true
serde.workspace = true

View file

@ -0,0 +1,362 @@
use std::sync::Arc;
use ai_onboarding::{AiUpsellCard, SignInStatus};
use client::DisableAiSettings;
use fs::Fs;
use gpui::{
Action, AnyView, App, DismissEvent, EventEmitter, FocusHandle, Focusable, Window, prelude::*,
};
use itertools;
use language_model::{LanguageModelProvider, LanguageModelProviderId, LanguageModelRegistry};
use settings::{Settings, update_settings_file};
use ui::{
Badge, ButtonLike, Divider, Modal, ModalFooter, ModalHeader, Section, SwitchField, ToggleState,
prelude::*,
};
use workspace::ModalView;
use util::ResultExt;
use zed_actions::agent::OpenSettings;
use crate::Onboarding;
const FEATURED_PROVIDERS: [&'static str; 4] = ["anthropic", "google", "openai", "ollama"];
fn render_llm_provider_section(
onboarding: &Onboarding,
disabled: bool,
window: &mut Window,
cx: &mut App,
) -> impl IntoElement {
v_flex()
.gap_4()
.child(
v_flex()
.child(Label::new("Or use other LLM providers").size(LabelSize::Large))
.child(
Label::new("Bring your API keys to use the available providers with Zed's UI for free.")
.color(Color::Muted),
),
)
.child(render_llm_provider_card(onboarding, disabled, window, cx))
}
fn render_privacy_card(disabled: bool, cx: &mut App) -> impl IntoElement {
v_flex()
.relative()
.pt_2()
.pb_2p5()
.pl_3()
.pr_2()
.border_1()
.border_dashed()
.border_color(cx.theme().colors().border.opacity(0.5))
.bg(cx.theme().colors().surface_background.opacity(0.3))
.rounded_lg()
.overflow_hidden()
.map(|this| {
if disabled {
this.child(
h_flex()
.gap_2()
.justify_between()
.child(
h_flex()
.gap_1()
.child(Label::new("AI is disabled across Zed"))
.child(
Icon::new(IconName::Check)
.color(Color::Success)
.size(IconSize::XSmall),
),
)
.child(Badge::new("PRIVACY").icon(IconName::FileLock)),
)
.child(
Label::new("Re-enable it any time in Settings.")
.size(LabelSize::Small)
.color(Color::Muted),
)
} else {
this.child(
h_flex()
.gap_2()
.justify_between()
.child(Label::new("We don't train models using your data"))
.child(
h_flex()
.gap_1()
.child(Badge::new("Privacy").icon(IconName::FileLock))
.child(
Button::new("learn_more", "Learn More")
.style(ButtonStyle::Outlined)
.label_size(LabelSize::Small)
.icon(IconName::ArrowUpRight)
.icon_size(IconSize::XSmall)
.icon_color(Color::Muted)
.on_click(|_, _, cx| {
cx.open_url(
"https://zed.dev/docs/ai/privacy-and-security",
);
}),
),
),
)
.child(
Label::new(
"Feel confident in the security and privacy of your projects using Zed.",
)
.size(LabelSize::Small)
.color(Color::Muted),
)
}
})
}
fn render_llm_provider_card(
onboarding: &Onboarding,
disabled: bool,
_: &mut Window,
cx: &mut App,
) -> impl IntoElement {
let registry = LanguageModelRegistry::read_global(cx);
v_flex()
.border_1()
.border_color(cx.theme().colors().border)
.bg(cx.theme().colors().surface_background.opacity(0.5))
.rounded_lg()
.overflow_hidden()
.children(itertools::intersperse_with(
FEATURED_PROVIDERS
.into_iter()
.flat_map(|provider_name| {
registry.provider(&LanguageModelProviderId::new(provider_name))
})
.enumerate()
.map(|(index, provider)| {
let group_name = SharedString::new(format!("onboarding-hover-group-{}", index));
let is_authenticated = provider.is_authenticated(cx);
ButtonLike::new(("onboarding-ai-setup-buttons", index))
.size(ButtonSize::Large)
.child(
h_flex()
.group(&group_name)
.px_0p5()
.w_full()
.gap_2()
.justify_between()
.child(
h_flex()
.gap_1()
.child(
Icon::new(provider.icon())
.color(Color::Muted)
.size(IconSize::XSmall),
)
.child(Label::new(provider.name().0)),
)
.child(
h_flex()
.gap_1()
.when(!is_authenticated, |el| {
el.visible_on_hover(group_name.clone())
.child(
Icon::new(IconName::Settings)
.color(Color::Muted)
.size(IconSize::XSmall),
)
.child(
Label::new("Configure")
.color(Color::Muted)
.size(LabelSize::Small),
)
})
.when(is_authenticated && !disabled, |el| {
el.child(
Icon::new(IconName::Check)
.color(Color::Success)
.size(IconSize::XSmall),
)
.child(
Label::new("Configured")
.color(Color::Muted)
.size(LabelSize::Small),
)
}),
),
)
.on_click({
let workspace = onboarding.workspace.clone();
move |_, window, cx| {
workspace
.update(cx, |workspace, cx| {
workspace.toggle_modal(window, cx, |window, cx| {
let modal = AiConfigurationModal::new(
provider.clone(),
window,
cx,
);
window.focus(&modal.focus_handle(cx));
modal
});
})
.log_err();
}
})
.into_any_element()
}),
|| Divider::horizontal().into_any_element(),
))
.child(Divider::horizontal())
.child(
Button::new("agent_settings", "Add Many Others")
.size(ButtonSize::Large)
.icon(IconName::Plus)
.icon_position(IconPosition::Start)
.icon_color(Color::Muted)
.icon_size(IconSize::XSmall)
.on_click(|_event, window, cx| {
window.dispatch_action(OpenSettings.boxed_clone(), cx)
}),
)
}
pub(crate) fn render_ai_setup_page(
onboarding: &Onboarding,
window: &mut Window,
cx: &mut App,
) -> impl IntoElement {
let is_ai_disabled = DisableAiSettings::get_global(cx).disable_ai;
let backdrop = div()
.id("backdrop")
.size_full()
.absolute()
.inset_0()
.bg(cx.theme().colors().editor_background)
.opacity(0.8)
.block_mouse_except_scroll();
v_flex()
.gap_2()
.child(SwitchField::new(
"enable_ai",
"Enable AI features",
None,
if is_ai_disabled {
ToggleState::Unselected
} else {
ToggleState::Selected
},
|toggle_state, _, cx| {
let enabled = match toggle_state {
ToggleState::Indeterminate => {
return;
}
ToggleState::Unselected => false,
ToggleState::Selected => true,
};
let fs = <dyn Fs>::global(cx);
update_settings_file::<DisableAiSettings>(
fs,
cx,
move |ai_settings: &mut Option<bool>, _| {
*ai_settings = Some(!enabled);
},
);
},
))
.child(render_privacy_card(is_ai_disabled, cx))
.child(
v_flex()
.mt_2()
.gap_6()
.child(AiUpsellCard {
sign_in_status: SignInStatus::SignedIn,
sign_in: Arc::new(|_, _| {}),
user_plan: onboarding.cloud_user_store.read(cx).plan(),
})
.child(render_llm_provider_section(
onboarding,
is_ai_disabled,
window,
cx,
))
.when(is_ai_disabled, |this| this.child(backdrop)),
)
}
struct AiConfigurationModal {
focus_handle: FocusHandle,
selected_provider: Arc<dyn LanguageModelProvider>,
configuration_view: AnyView,
}
impl AiConfigurationModal {
fn new(
selected_provider: Arc<dyn LanguageModelProvider>,
window: &mut Window,
cx: &mut Context<Self>,
) -> Self {
let focus_handle = cx.focus_handle();
let configuration_view = selected_provider.configuration_view(window, cx);
Self {
focus_handle,
configuration_view,
selected_provider,
}
}
}
impl ModalView for AiConfigurationModal {}
impl EventEmitter<DismissEvent> for AiConfigurationModal {}
impl Focusable for AiConfigurationModal {
fn focus_handle(&self, _cx: &App) -> FocusHandle {
self.focus_handle.clone()
}
}
impl Render for AiConfigurationModal {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
v_flex()
.w(rems(34.))
.elevation_3(cx)
.track_focus(&self.focus_handle)
.child(
Modal::new("onboarding-ai-setup-modal", None)
.header(
ModalHeader::new()
.icon(
Icon::new(self.selected_provider.icon())
.color(Color::Muted)
.size(IconSize::Small),
)
.headline(self.selected_provider.name().0),
)
.section(Section::new().child(self.configuration_view.clone()))
.footer(
ModalFooter::new().end_slot(
h_flex()
.gap_1()
.child(
Button::new("onboarding-closing-cancel", "Cancel")
.on_click(cx.listener(|_, _, _, cx| cx.emit(DismissEvent))),
)
.child(Button::new("save-btn", "Done").on_click(cx.listener(
|_, _, window, cx| {
window.dispatch_action(menu::Confirm.boxed_clone(), cx);
cx.emit(DismissEvent);
},
))),
),
),
)
}
}

View file

@ -242,7 +242,7 @@ fn render_telemetry_section(cx: &App) -> impl IntoElement {
.child(SwitchField::new(
"onboarding-telemetry-metrics",
"Help Improve Zed",
"Sending anonymous usage data helps us build the right features and create the best experience.",
Some("Sending anonymous usage data helps us build the right features and create the best experience.".into()),
if TelemetrySettings::get_global(cx).metrics {
ui::ToggleState::Selected
} else {
@ -267,7 +267,7 @@ fn render_telemetry_section(cx: &App) -> impl IntoElement {
.child(SwitchField::new(
"onboarding-telemetry-crash-reports",
"Help Fix Zed",
"Send crash reports so we can fix critical issues fast.",
Some("Send crash reports so we can fix critical issues fast.".into()),
if TelemetrySettings::get_global(cx).diagnostics {
ui::ToggleState::Selected
} else {
@ -338,10 +338,10 @@ pub(crate) fn render_basics_page(window: &mut Window, cx: &mut App) -> impl Into
.style(ui::ToggleButtonGroupStyle::Outlined)
),
)
.child(v_flex().justify_center().child(div().h_0().child("hack").invisible()).child(SwitchField::new(
.child(SwitchField::new(
"onboarding-vim-mode",
"Vim Mode",
"Coming from Neovim? Zed's first-class implementation of Vim Mode has got your back.",
Some("Coming from Neovim? Zed's first-class implementation of Vim Mode has got your back.".into()),
if VimModeSetting::get_global(cx).0 {
ui::ToggleState::Selected
} else {
@ -363,6 +363,6 @@ pub(crate) fn render_basics_page(window: &mut Window, cx: &mut App) -> impl Into
);
}
},
)))
))
.child(render_telemetry_section(cx))
}

View file

@ -349,7 +349,7 @@ fn render_popular_settings_section(window: &mut Window, cx: &mut App) -> impl In
.child(SwitchField::new(
"onboarding-enable-inlay-hints",
"Inlay Hints",
"See parameter names for function and method calls inline.",
Some("See parameter names for function and method calls inline.".into()),
if read_inlay_hints(cx) {
ui::ToggleState::Selected
} else {
@ -362,7 +362,7 @@ fn render_popular_settings_section(window: &mut Window, cx: &mut App) -> impl In
.child(SwitchField::new(
"onboarding-git-blame-switch",
"Git Blame",
"See who committed each line on a given file.",
Some("See who committed each line on a given file.".into()),
if read_git_blame(cx) {
ui::ToggleState::Selected
} else {

View file

@ -1,5 +1,5 @@
use crate::welcome::{ShowWelcome, WelcomePage};
use client::{Client, UserStore};
use client::{Client, CloudUserStore, UserStore};
use command_palette_hooks::CommandPaletteFilter;
use db::kvp::KEY_VALUE_STORE;
use feature_flags::{FeatureFlag, FeatureFlagViewExt as _};
@ -25,6 +25,7 @@ use workspace::{
open_new, with_active_or_new_workspace,
};
mod ai_setup_page;
mod basics_page;
mod editing_page;
mod theme_preview;
@ -78,11 +79,7 @@ pub fn init(cx: &mut App) {
if let Some(existing) = existing {
workspace.activate_item(&existing, true, true, window, cx);
} else {
let settings_page = Onboarding::new(
workspace.weak_handle(),
workspace.user_store().clone(),
cx,
);
let settings_page = Onboarding::new(workspace, cx);
workspace.add_item_to_active_pane(
Box::new(settings_page),
None,
@ -198,8 +195,7 @@ pub fn show_onboarding_view(app_state: Arc<AppState>, cx: &mut App) -> Task<anyh
|workspace, window, cx| {
{
workspace.toggle_dock(DockPosition::Left, window, cx);
let onboarding_page =
Onboarding::new(workspace.weak_handle(), workspace.user_store().clone(), cx);
let onboarding_page = Onboarding::new(workspace, cx);
workspace.add_item_to_center(Box::new(onboarding_page.clone()), window, cx);
window.focus(&onboarding_page.focus_handle(cx));
@ -224,21 +220,19 @@ struct Onboarding {
workspace: WeakEntity<Workspace>,
focus_handle: FocusHandle,
selected_page: SelectedPage,
cloud_user_store: Entity<CloudUserStore>,
user_store: Entity<UserStore>,
_settings_subscription: Subscription,
}
impl Onboarding {
fn new(
workspace: WeakEntity<Workspace>,
user_store: Entity<UserStore>,
cx: &mut App,
) -> Entity<Self> {
fn new(workspace: &Workspace, cx: &mut App) -> Entity<Self> {
cx.new(|cx| Self {
workspace,
user_store,
workspace: workspace.weak_handle(),
focus_handle: cx.focus_handle(),
selected_page: SelectedPage::Basics,
cloud_user_store: workspace.app_state().cloud_user_store.clone(),
user_store: workspace.user_store().clone(),
_settings_subscription: cx.observe_global::<SettingsStore>(move |_, cx| cx.notify()),
})
}
@ -391,13 +385,11 @@ impl Onboarding {
SelectedPage::Editing => {
crate::editing_page::render_editing_page(window, cx).into_any_element()
}
SelectedPage::AiSetup => self.render_ai_setup_page(window, cx).into_any_element(),
SelectedPage::AiSetup => {
crate::ai_setup_page::render_ai_setup_page(&self, window, cx).into_any_element()
}
}
}
fn render_ai_setup_page(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
div().child("ai setup page")
}
}
impl Render for Onboarding {
@ -418,7 +410,9 @@ impl Render for Onboarding {
.gap_12()
.child(self.render_nav(window, cx))
.child(
div()
v_flex()
.max_w_full()
.min_w_0()
.pl_12()
.border_l_1()
.border_color(cx.theme().colors().border_variant.opacity(0.5))
@ -458,11 +452,9 @@ impl Item for Onboarding {
_: &mut Window,
cx: &mut Context<Self>,
) -> Option<Entity<Self>> {
Some(Onboarding::new(
self.workspace.clone(),
self.user_store.clone(),
cx,
))
self.workspace
.update(cx, |workspace, cx| Onboarding::new(workspace, cx))
.ok()
}
fn to_item_events(event: &Self::Event, mut f: impl FnMut(workspace::item::ItemEvent)) {