onboarding: Add the AI page (#35351)

Finn Evers , Danilo Leal , and Anthony created

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>

Change summary

Cargo.lock                                 |   4 
crates/agent_ui/src/agent_configuration.rs |  14 
crates/ai_onboarding/src/ai_upsell_card.rs |  27 +
crates/onboarding/Cargo.toml               |   4 
crates/onboarding/src/ai_setup_page.rs     | 362 ++++++++++++++++++++++++
crates/onboarding/src/basics_page.rs       |  10 
crates/onboarding/src/editing_page.rs      |   4 
crates/onboarding/src/onboarding.rs        |  44 +-
crates/ui/src/components.rs                |   2 
crates/ui/src/components/badge.rs          |  71 ++++
crates/ui/src/components/modal.rs          |  24 +
crates/ui/src/components/toggle.rs         |  47 ++
12 files changed, 550 insertions(+), 63 deletions(-)

Detailed changes

Cargo.lock 🔗

@@ -10923,6 +10923,7 @@ dependencies = [
 name = "onboarding"
 version = "0.1.0"
 dependencies = [
+ "ai_onboarding",
  "anyhow",
  "client",
  "command_palette_hooks",
@@ -10933,7 +10934,10 @@ dependencies = [
  "feature_flags",
  "fs",
  "gpui",
+ "itertools 0.14.0",
  "language",
+ "language_model",
+ "menu",
  "project",
  "schemars",
  "serde",

crates/agent_ui/src/agent_configuration.rs 🔗

@@ -406,7 +406,9 @@ impl AgentConfiguration {
         SwitchField::new(
             "always-allow-tool-actions-switch",
             "Allow running commands without asking for confirmation",
-            "The agent can perform potentially destructive actions without asking for your confirmation.",
+            Some(
+                "The agent can perform potentially destructive actions without asking for your confirmation.".into(),
+            ),
             always_allow_tool_actions,
             move |state, _window, cx| {
                 let allow = state == &ToggleState::Selected;
@@ -424,7 +426,7 @@ impl AgentConfiguration {
         SwitchField::new(
             "single-file-review",
             "Enable single-file agent reviews",
-            "Agent edits are also displayed in single-file editors for review.",
+            Some("Agent edits are also displayed in single-file editors for review.".into()),
             single_file_review,
             move |state, _window, cx| {
                 let allow = state == &ToggleState::Selected;
@@ -442,7 +444,9 @@ impl AgentConfiguration {
         SwitchField::new(
             "sound-notification",
             "Play sound when finished generating",
-            "Hear a notification sound when the agent is done generating changes or needs your input.",
+            Some(
+                "Hear a notification sound when the agent is done generating changes or needs your input.".into(),
+            ),
             play_sound_when_agent_done,
             move |state, _window, cx| {
                 let allow = state == &ToggleState::Selected;
@@ -460,7 +464,9 @@ impl AgentConfiguration {
         SwitchField::new(
             "modifier-send",
             "Use modifier to submit a message",
-            "Make a modifier (cmd-enter on macOS, ctrl-enter on Linux) required to send messages.",
+            Some(
+                "Make a modifier (cmd-enter on macOS, ctrl-enter on Linux) required to send messages.".into(),
+            ),
             use_modifier_to_send,
             move |state, _window, cx| {
                 let allow = state == &ToggleState::Selected;

crates/ai_onboarding/src/ai_upsell_card.rs 🔗

@@ -1,6 +1,7 @@
 use std::sync::Arc;
 
 use client::{Client, zed_urls};
+use cloud_llm_client::Plan;
 use gpui::{AnyElement, App, IntoElement, RenderOnce, Window};
 use ui::{Divider, List, Vector, VectorName, prelude::*};
 
@@ -10,13 +11,15 @@ use crate::{BulletItem, SignInStatus};
 pub struct AiUpsellCard {
     pub sign_in_status: SignInStatus,
     pub sign_in: Arc<dyn Fn(&mut Window, &mut App)>,
+    pub user_plan: Option<Plan>,
 }
 
 impl AiUpsellCard {
-    pub fn new(client: Arc<Client>) -> Self {
+    pub fn new(client: Arc<Client>, user_plan: Option<Plan>) -> Self {
         let status = *client.status().borrow();
 
         Self {
+            user_plan,
             sign_in_status: status.into(),
             sign_in: Arc::new(move |_window, cx| {
                 cx.spawn({
@@ -34,6 +37,7 @@ impl AiUpsellCard {
 impl RenderOnce for AiUpsellCard {
     fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
         let pro_section = v_flex()
+            .flex_grow()
             .w_full()
             .gap_1()
             .child(
@@ -56,6 +60,7 @@ impl RenderOnce for AiUpsellCard {
             );
 
         let free_section = v_flex()
+            .flex_grow()
             .w_full()
             .gap_1()
             .child(
@@ -71,7 +76,7 @@ impl RenderOnce for AiUpsellCard {
             )
             .child(
                 List::new()
-                    .child(BulletItem::new("50 prompts with the Claude models"))
+                    .child(BulletItem::new("50 prompts with Claude models"))
                     .child(BulletItem::new("2,000 accepted edit predictions")),
             );
 
@@ -132,22 +137,28 @@ impl RenderOnce for AiUpsellCard {
 
         v_flex()
             .relative()
-            .p_6()
-            .pt_4()
+            .p_4()
+            .pt_3()
             .border_1()
             .border_color(cx.theme().colors().border)
             .rounded_lg()
             .overflow_hidden()
             .child(grid_bg)
             .child(gradient_bg)
-            .child(Headline::new("Try Zed AI"))
-            .child(Label::new(DESCRIPTION).color(Color::Muted).mb_2())
+            .child(Label::new("Try Zed AI").size(LabelSize::Large))
+            .child(
+                div()
+                    .max_w_3_4()
+                    .mb_2()
+                    .child(Label::new(DESCRIPTION).color(Color::Muted)),
+            )
             .child(
                 h_flex()
+                    .w_full()
                     .mt_1p5()
                     .mb_2p5()
                     .items_start()
-                    .gap_12()
+                    .gap_6()
                     .child(free_section)
                     .child(pro_section),
             )
@@ -183,6 +194,7 @@ impl Component for AiUpsellCard {
                         AiUpsellCard {
                             sign_in_status: SignInStatus::SignedOut,
                             sign_in: Arc::new(|_, _| {}),
+                            user_plan: None,
                         }
                         .into_any_element(),
                     ),
@@ -191,6 +203,7 @@ impl Component for AiUpsellCard {
                         AiUpsellCard {
                             sign_in_status: SignInStatus::SignedIn,
                             sign_in: Arc::new(|_, _| {}),
+                            user_plan: None,
                         }
                         .into_any_element(),
                     ),

crates/onboarding/Cargo.toml 🔗

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

crates/onboarding/src/ai_setup_page.rs 🔗

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

crates/onboarding/src/basics_page.rs 🔗

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

crates/onboarding/src/editing_page.rs 🔗

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

crates/onboarding/src/onboarding.rs 🔗

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

crates/ui/src/components.rs 🔗

@@ -1,4 +1,5 @@
 mod avatar;
+mod badge;
 mod banner;
 mod button;
 mod callout;
@@ -41,6 +42,7 @@ mod tooltip;
 mod stories;
 
 pub use avatar::*;
+pub use badge::*;
 pub use banner::*;
 pub use button::*;
 pub use callout::*;

crates/ui/src/components/badge.rs 🔗

@@ -0,0 +1,71 @@
+use crate::Divider;
+use crate::DividerColor;
+use crate::component_prelude::*;
+use crate::prelude::*;
+use gpui::{AnyElement, IntoElement, SharedString, Window};
+
+#[derive(IntoElement, RegisterComponent)]
+pub struct Badge {
+    label: SharedString,
+    icon: IconName,
+}
+
+impl Badge {
+    pub fn new(label: impl Into<SharedString>) -> Self {
+        Self {
+            label: label.into(),
+            icon: IconName::Check,
+        }
+    }
+
+    pub fn icon(mut self, icon: IconName) -> Self {
+        self.icon = icon;
+        self
+    }
+}
+
+impl RenderOnce for Badge {
+    fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
+        h_flex()
+            .h_full()
+            .gap_1()
+            .pl_1()
+            .pr_2()
+            .border_1()
+            .border_color(cx.theme().colors().border)
+            .bg(cx.theme().colors().element_background)
+            .rounded_sm()
+            .overflow_hidden()
+            .child(
+                Icon::new(self.icon)
+                    .size(IconSize::XSmall)
+                    .color(Color::Muted),
+            )
+            .child(Divider::vertical().color(DividerColor::Border))
+            .child(
+                Label::new(self.label.clone())
+                    .size(LabelSize::XSmall)
+                    .buffer_font(cx)
+                    .ml_1(),
+            )
+    }
+}
+
+impl Component for Badge {
+    fn scope() -> ComponentScope {
+        ComponentScope::DataDisplay
+    }
+
+    fn description() -> Option<&'static str> {
+        Some(
+            "A compact, labeled component with optional icon for displaying status, categories, or metadata.",
+        )
+    }
+
+    fn preview(_window: &mut Window, _cx: &mut App) -> Option<AnyElement> {
+        Some(
+            single_example("Basic Badge", Badge::new("Default").into_any_element())
+                .into_any_element(),
+        )
+    }
+}

crates/ui/src/components/modal.rs 🔗

@@ -1,5 +1,5 @@
 use crate::{
-    Clickable, Color, DynamicSpacing, Headline, HeadlineSize, IconButton, IconButtonShape,
+    Clickable, Color, DynamicSpacing, Headline, HeadlineSize, Icon, IconButton, IconButtonShape,
     IconName, Label, LabelCommon, LabelSize, h_flex, v_flex,
 };
 use gpui::{prelude::FluentBuilder, *};
@@ -92,6 +92,7 @@ impl RenderOnce for Modal {
 
 #[derive(IntoElement)]
 pub struct ModalHeader {
+    icon: Option<Icon>,
     headline: Option<SharedString>,
     description: Option<SharedString>,
     children: SmallVec<[AnyElement; 2]>,
@@ -108,6 +109,7 @@ impl Default for ModalHeader {
 impl ModalHeader {
     pub fn new() -> Self {
         Self {
+            icon: None,
             headline: None,
             description: None,
             children: SmallVec::new(),
@@ -116,6 +118,11 @@ impl ModalHeader {
         }
     }
 
+    pub fn icon(mut self, icon: Icon) -> Self {
+        self.icon = Some(icon);
+        self
+    }
+
     /// Set the headline of the modal.
     ///
     /// This will insert the headline as the first item
@@ -179,12 +186,17 @@ impl RenderOnce for ModalHeader {
                 )
             })
             .child(
-                v_flex().flex_1().children(children).when_some(
-                    self.description,
-                    |this, description| {
+                v_flex()
+                    .flex_1()
+                    .child(
+                        h_flex()
+                            .gap_1()
+                            .when_some(self.icon, |this, icon| this.child(icon))
+                            .children(children),
+                    )
+                    .when_some(self.description, |this, description| {
                         this.child(Label::new(description).color(Color::Muted).mb_2())
-                    },
-                ),
+                    }),
             )
             .when(self.show_dismiss_button, |this| {
                 this.child(

crates/ui/src/components/toggle.rs 🔗

@@ -566,7 +566,7 @@ impl RenderOnce for Switch {
 pub struct SwitchField {
     id: ElementId,
     label: SharedString,
-    description: SharedString,
+    description: Option<SharedString>,
     toggle_state: ToggleState,
     on_click: Arc<dyn Fn(&ToggleState, &mut Window, &mut App) + 'static>,
     disabled: bool,
@@ -577,14 +577,14 @@ impl SwitchField {
     pub fn new(
         id: impl Into<ElementId>,
         label: impl Into<SharedString>,
-        description: impl Into<SharedString>,
+        description: Option<SharedString>,
         toggle_state: impl Into<ToggleState>,
         on_click: impl Fn(&ToggleState, &mut Window, &mut App) + 'static,
     ) -> Self {
         Self {
             id: id.into(),
             label: label.into(),
-            description: description.into(),
+            description: description,
             toggle_state: toggle_state.into(),
             on_click: Arc::new(on_click),
             disabled: false,
@@ -592,6 +592,11 @@ impl SwitchField {
         }
     }
 
+    pub fn description(mut self, description: impl Into<SharedString>) -> Self {
+        self.description = Some(description.into());
+        self
+    }
+
     pub fn disabled(mut self, disabled: bool) -> Self {
         self.disabled = disabled;
         self
@@ -616,13 +621,15 @@ impl RenderOnce for SwitchField {
             .gap_4()
             .justify_between()
             .flex_wrap()
-            .child(
-                v_flex()
+            .child(match &self.description {
+                Some(description) => v_flex()
                     .gap_0p5()
                     .max_w_5_6()
-                    .child(Label::new(self.label))
-                    .child(Label::new(self.description).color(Color::Muted)),
-            )
+                    .child(Label::new(self.label.clone()))
+                    .child(Label::new(description.clone()).color(Color::Muted))
+                    .into_any_element(),
+                None => Label::new(self.label.clone()).into_any_element(),
+            })
             .child(
                 Switch::new(
                     SharedString::from(format!("{}-switch", self.id)),
@@ -671,7 +678,7 @@ impl Component for SwitchField {
                                 SwitchField::new(
                                     "switch_field_unselected",
                                     "Enable notifications",
-                                    "Receive notifications when new messages arrive.",
+                                    Some("Receive notifications when new messages arrive.".into()),
                                     ToggleState::Unselected,
                                     |_, _, _| {},
                                 )
@@ -682,7 +689,7 @@ impl Component for SwitchField {
                                 SwitchField::new(
                                     "switch_field_selected",
                                     "Enable notifications",
-                                    "Receive notifications when new messages arrive.",
+                                    Some("Receive notifications when new messages arrive.".into()),
                                     ToggleState::Selected,
                                     |_, _, _| {},
                                 )
@@ -698,7 +705,7 @@ impl Component for SwitchField {
                                 SwitchField::new(
                                     "switch_field_default",
                                     "Default color",
-                                    "This uses the default switch color.",
+                                    Some("This uses the default switch color.".into()),
                                     ToggleState::Selected,
                                     |_, _, _| {},
                                 )
@@ -709,7 +716,7 @@ impl Component for SwitchField {
                                 SwitchField::new(
                                     "switch_field_accent",
                                     "Accent color",
-                                    "This uses the accent color scheme.",
+                                    Some("This uses the accent color scheme.".into()),
                                     ToggleState::Selected,
                                     |_, _, _| {},
                                 )
@@ -725,7 +732,7 @@ impl Component for SwitchField {
                             SwitchField::new(
                                 "switch_field_disabled",
                                 "Disabled field",
-                                "This field is disabled and cannot be toggled.",
+                                Some("This field is disabled and cannot be toggled.".into()),
                                 ToggleState::Selected,
                                 |_, _, _| {},
                             )
@@ -733,6 +740,20 @@ impl Component for SwitchField {
                             .into_any_element(),
                         )],
                     ),
+                    example_group_with_title(
+                        "No Description",
+                        vec![single_example(
+                            "No Description",
+                            SwitchField::new(
+                                "switch_field_disabled",
+                                "Disabled field",
+                                None,
+                                ToggleState::Selected,
+                                |_, _, _| {},
+                            )
+                            .into_any_element(),
+                        )],
+                    ),
                 ])
                 .into_any_element(),
         )