Add onboarding banner for claude code support (#37443)

Bennet Bo Fenner and Danilo Leal created

Release Notes:

- N/A

---------

Co-authored-by: Danilo Leal <daniloleal09@gmail.com>

Change summary

assets/images/acp_logo_serif.svg                       |   1 
crates/agent_ui/src/agent_panel.rs                     |   7 
crates/agent_ui/src/ui.rs                              |   2 
crates/agent_ui/src/ui/acp_onboarding_modal.rs         |  20 
crates/agent_ui/src/ui/claude_code_onboarding_modal.rs | 254 ++++++++++++
crates/title_bar/src/onboarding_banner.rs              |  11 
crates/title_bar/src/title_bar.rs                      |  10 
crates/zed_actions/src/lib.rs                          |   2 
8 files changed, 284 insertions(+), 23 deletions(-)

Detailed changes

crates/agent_ui/src/agent_panel.rs 🔗

@@ -10,11 +10,11 @@ use agent2::{DbThreadMetadata, HistoryEntry};
 use db::kvp::{Dismissable, KEY_VALUE_STORE};
 use serde::{Deserialize, Serialize};
 use zed_actions::OpenBrowser;
-use zed_actions::agent::ReauthenticateAgent;
+use zed_actions::agent::{OpenClaudeCodeOnboardingModal, ReauthenticateAgent};
 
 use crate::acp::{AcpThreadHistory, ThreadHistoryEvent};
 use crate::agent_diff::AgentDiffThread;
-use crate::ui::AcpOnboardingModal;
+use crate::ui::{AcpOnboardingModal, ClaudeCodeOnboardingModal};
 use crate::{
     AddContextServer, AgentDiffPane, ContinueThread, ContinueWithBurnMode,
     DeleteRecentlyOpenThread, ExpandMessageEditor, Follow, InlineAssistant, NewTextThread,
@@ -207,6 +207,9 @@ pub fn init(cx: &mut App) {
                 .register_action(|workspace, _: &OpenAcpOnboardingModal, window, cx| {
                     AcpOnboardingModal::toggle(workspace, window, cx)
                 })
+                .register_action(|workspace, _: &OpenClaudeCodeOnboardingModal, window, cx| {
+                    ClaudeCodeOnboardingModal::toggle(workspace, window, cx)
+                })
                 .register_action(|_workspace, _: &ResetOnboarding, window, cx| {
                     window.dispatch_action(workspace::RestoreBanner.boxed_clone(), cx);
                     window.refresh();

crates/agent_ui/src/ui.rs 🔗

@@ -1,6 +1,7 @@
 mod acp_onboarding_modal;
 mod agent_notification;
 mod burn_mode_tooltip;
+mod claude_code_onboarding_modal;
 mod context_pill;
 mod end_trial_upsell;
 mod onboarding_modal;
@@ -10,6 +11,7 @@ mod unavailable_editing_tooltip;
 pub use acp_onboarding_modal::*;
 pub use agent_notification::*;
 pub use burn_mode_tooltip::*;
+pub use claude_code_onboarding_modal::*;
 pub use context_pill::*;
 pub use end_trial_upsell::*;
 pub use onboarding_modal::*;

crates/agent_ui/src/ui/acp_onboarding_modal.rs 🔗

@@ -141,20 +141,12 @@ impl Render for AcpOnboardingModal {
                     .bg(gpui::black().opacity(0.15)),
             )
             .child(
-                h_flex()
-                    .gap_4()
-                    .child(
-                        Vector::new(VectorName::AcpLogo, rems_from_px(106.), rems_from_px(40.))
-                            .color(ui::Color::Custom(cx.theme().colors().text.opacity(0.8))),
-                    )
-                    .child(
-                        Vector::new(
-                            VectorName::AcpLogoSerif,
-                            rems_from_px(111.),
-                            rems_from_px(41.),
-                        )
-                        .color(ui::Color::Custom(cx.theme().colors().text.opacity(0.8))),
-                    ),
+                Vector::new(
+                    VectorName::AcpLogoSerif,
+                    rems_from_px(257.),
+                    rems_from_px(47.),
+                )
+                .color(ui::Color::Custom(cx.theme().colors().text.opacity(0.8))),
             )
             .child(
                 v_flex()

crates/agent_ui/src/ui/claude_code_onboarding_modal.rs 🔗

@@ -0,0 +1,254 @@
+use client::zed_urls;
+use gpui::{
+    ClickEvent, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, MouseDownEvent, Render,
+    linear_color_stop, linear_gradient,
+};
+use ui::{TintColor, Vector, VectorName, prelude::*};
+use workspace::{ModalView, Workspace};
+
+use crate::agent_panel::{AgentPanel, AgentType};
+
+macro_rules! claude_code_onboarding_event {
+    ($name:expr) => {
+        telemetry::event!($name, source = "ACP Claude Code Onboarding");
+    };
+    ($name:expr, $($key:ident $(= $value:expr)?),+ $(,)?) => {
+        telemetry::event!($name, source = "ACP Claude Code Onboarding", $($key $(= $value)?),+);
+    };
+}
+
+pub struct ClaudeCodeOnboardingModal {
+    focus_handle: FocusHandle,
+    workspace: Entity<Workspace>,
+}
+
+impl ClaudeCodeOnboardingModal {
+    pub fn toggle(workspace: &mut Workspace, window: &mut Window, cx: &mut Context<Workspace>) {
+        let workspace_entity = cx.entity();
+        workspace.toggle_modal(window, cx, |_window, cx| Self {
+            workspace: workspace_entity,
+            focus_handle: cx.focus_handle(),
+        });
+    }
+
+    fn open_panel(&mut self, _: &ClickEvent, window: &mut Window, cx: &mut Context<Self>) {
+        self.workspace.update(cx, |workspace, cx| {
+            workspace.focus_panel::<AgentPanel>(window, cx);
+
+            if let Some(panel) = workspace.panel::<AgentPanel>(cx) {
+                panel.update(cx, |panel, cx| {
+                    panel.new_agent_thread(AgentType::ClaudeCode, window, cx);
+                });
+            }
+        });
+
+        cx.emit(DismissEvent);
+
+        claude_code_onboarding_event!("Open Panel Clicked");
+    }
+
+    fn view_docs(&mut self, _: &ClickEvent, _: &mut Window, cx: &mut Context<Self>) {
+        cx.open_url(&zed_urls::external_agents_docs(cx));
+        cx.notify();
+
+        claude_code_onboarding_event!("Documentation Link Clicked");
+    }
+
+    fn cancel(&mut self, _: &menu::Cancel, _: &mut Window, cx: &mut Context<Self>) {
+        cx.emit(DismissEvent);
+    }
+}
+
+impl EventEmitter<DismissEvent> for ClaudeCodeOnboardingModal {}
+
+impl Focusable for ClaudeCodeOnboardingModal {
+    fn focus_handle(&self, _cx: &App) -> FocusHandle {
+        self.focus_handle.clone()
+    }
+}
+
+impl ModalView for ClaudeCodeOnboardingModal {}
+
+impl Render for ClaudeCodeOnboardingModal {
+    fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
+        let illustration_element = |icon: IconName, label: Option<SharedString>, opacity: f32| {
+            h_flex()
+                .px_1()
+                .py_0p5()
+                .gap_1()
+                .rounded_sm()
+                .bg(cx.theme().colors().element_active.opacity(0.05))
+                .border_1()
+                .border_color(cx.theme().colors().border)
+                .border_dashed()
+                .child(
+                    Icon::new(icon)
+                        .size(IconSize::Small)
+                        .color(Color::Custom(cx.theme().colors().text_muted.opacity(0.15))),
+                )
+                .map(|this| {
+                    if let Some(label_text) = label {
+                        this.child(
+                            Label::new(label_text)
+                                .size(LabelSize::Small)
+                                .color(Color::Muted),
+                        )
+                    } else {
+                        this.child(
+                            div().w_16().h_1().rounded_full().bg(cx
+                                .theme()
+                                .colors()
+                                .element_active
+                                .opacity(0.6)),
+                        )
+                    }
+                })
+                .opacity(opacity)
+        };
+
+        let illustration = h_flex()
+            .relative()
+            .h(rems_from_px(126.))
+            .bg(cx.theme().colors().editor_background)
+            .border_b_1()
+            .border_color(cx.theme().colors().border_variant)
+            .justify_center()
+            .gap_8()
+            .rounded_t_md()
+            .overflow_hidden()
+            .child(
+                div().absolute().inset_0().w(px(515.)).h(px(126.)).child(
+                    Vector::new(VectorName::AcpGrid, rems_from_px(515.), rems_from_px(126.))
+                        .color(ui::Color::Custom(cx.theme().colors().text.opacity(0.02))),
+                ),
+            )
+            .child(div().absolute().inset_0().size_full().bg(linear_gradient(
+                0.,
+                linear_color_stop(
+                    cx.theme().colors().elevated_surface_background.opacity(0.1),
+                    0.9,
+                ),
+                linear_color_stop(
+                    cx.theme().colors().elevated_surface_background.opacity(0.),
+                    0.,
+                ),
+            )))
+            .child(
+                div()
+                    .absolute()
+                    .inset_0()
+                    .size_full()
+                    .bg(gpui::black().opacity(0.15)),
+            )
+            .child(
+                Vector::new(
+                    VectorName::AcpLogoSerif,
+                    rems_from_px(257.),
+                    rems_from_px(47.),
+                )
+                .color(ui::Color::Custom(cx.theme().colors().text.opacity(0.8))),
+            )
+            .child(
+                v_flex()
+                    .gap_1p5()
+                    .child(illustration_element(IconName::Stop, None, 0.15))
+                    .child(illustration_element(
+                        IconName::AiGemini,
+                        Some("New Gemini CLI Thread".into()),
+                        0.3,
+                    ))
+                    .child(
+                        h_flex()
+                            .pl_1()
+                            .pr_2()
+                            .py_0p5()
+                            .gap_1()
+                            .rounded_sm()
+                            .bg(cx.theme().colors().element_active.opacity(0.2))
+                            .border_1()
+                            .border_color(cx.theme().colors().border)
+                            .child(
+                                Icon::new(IconName::AiClaude)
+                                    .size(IconSize::Small)
+                                    .color(Color::Muted),
+                            )
+                            .child(Label::new("New Claude Code Thread").size(LabelSize::Small)),
+                    )
+                    .child(illustration_element(
+                        IconName::Stop,
+                        Some("Your Agent Here".into()),
+                        0.3,
+                    ))
+                    .child(illustration_element(IconName::Stop, None, 0.15)),
+            );
+
+        let heading = v_flex()
+            .w_full()
+            .gap_1()
+            .child(
+                Label::new("Beta Release")
+                    .size(LabelSize::Small)
+                    .color(Color::Muted),
+            )
+            .child(Headline::new("Claude Code: Natively in Zed").size(HeadlineSize::Large));
+
+        let copy = "Powered by the Agent Client Protocol, you can now run Claude Code as\na first-class citizen in Zed's agent panel.";
+
+        let open_panel_button = Button::new("open-panel", "Start with Claude Code")
+            .icon_size(IconSize::Indicator)
+            .style(ButtonStyle::Tinted(TintColor::Accent))
+            .full_width()
+            .on_click(cx.listener(Self::open_panel));
+
+        let docs_button = Button::new("add-other-agents", "Add Other Agents")
+            .icon(IconName::ArrowUpRight)
+            .icon_size(IconSize::Indicator)
+            .icon_color(Color::Muted)
+            .full_width()
+            .on_click(cx.listener(Self::view_docs));
+
+        let close_button = h_flex().absolute().top_2().right_2().child(
+            IconButton::new("cancel", IconName::Close).on_click(cx.listener(
+                |_, _: &ClickEvent, _window, cx| {
+                    claude_code_onboarding_event!("Canceled", trigger = "X click");
+                    cx.emit(DismissEvent);
+                },
+            )),
+        );
+
+        v_flex()
+            .id("acp-onboarding")
+            .key_context("AcpOnboardingModal")
+            .relative()
+            .w(rems(34.))
+            .h_full()
+            .elevation_3(cx)
+            .track_focus(&self.focus_handle(cx))
+            .overflow_hidden()
+            .on_action(cx.listener(Self::cancel))
+            .on_action(cx.listener(|_, _: &menu::Cancel, _window, cx| {
+                claude_code_onboarding_event!("Canceled", trigger = "Action");
+                cx.emit(DismissEvent);
+            }))
+            .on_any_mouse_down(cx.listener(|this, _: &MouseDownEvent, window, _cx| {
+                this.focus_handle.focus(window);
+            }))
+            .child(illustration)
+            .child(
+                v_flex()
+                    .p_4()
+                    .gap_2()
+                    .child(heading)
+                    .child(Label::new(copy).color(Color::Muted))
+                    .child(
+                        v_flex()
+                            .w_full()
+                            .mt_2()
+                            .gap_1()
+                            .child(open_panel_button)
+                            .child(docs_button),
+                    ),
+            )
+            .child(close_button)
+    }
+}

crates/title_bar/src/onboarding_banner.rs 🔗

@@ -7,6 +7,7 @@ pub struct OnboardingBanner {
     dismissed: bool,
     source: String,
     details: BannerDetails,
+    visible_when: Option<Box<dyn Fn(&mut App) -> bool>>,
 }
 
 #[derive(Clone)]
@@ -42,12 +43,18 @@ impl OnboardingBanner {
                 label: label.into(),
                 subtitle: subtitle.or(Some(SharedString::from("Introducing:"))),
             },
+            visible_when: None,
             dismissed: get_dismissed(source),
         }
     }
 
-    fn should_show(&self, _cx: &mut App) -> bool {
-        !self.dismissed
+    pub fn visible_when(mut self, predicate: impl Fn(&mut App) -> bool + 'static) -> Self {
+        self.visible_when = Some(Box::new(predicate));
+        self
+    }
+
+    fn should_show(&self, cx: &mut App) -> bool {
+        !self.dismissed && self.visible_when.as_ref().map_or(true, |f| f(cx))
     }
 
     fn dismiss(&mut self, cx: &mut Context<Self>) {

crates/title_bar/src/title_bar.rs 🔗

@@ -279,13 +279,15 @@ impl TitleBar {
 
         let banner = cx.new(|cx| {
             OnboardingBanner::new(
-                "ACP Onboarding",
-                IconName::Sparkle,
-                "Bring Your Own Agent",
+                "ACP Claude Code Onboarding",
+                IconName::AiClaude,
+                "Claude Code",
                 Some("Introducing:".into()),
-                zed_actions::agent::OpenAcpOnboardingModal.boxed_clone(),
+                zed_actions::agent::OpenClaudeCodeOnboardingModal.boxed_clone(),
                 cx,
             )
+            // When updating this to a non-AI feature release, remove this line.
+            .visible_when(|cx| !project::DisableAiSettings::get_global(cx).disable_ai)
         });
 
         let platform_titlebar = cx.new(|cx| PlatformTitleBar::new(id, cx));

crates/zed_actions/src/lib.rs 🔗

@@ -286,6 +286,8 @@ pub mod agent {
             OpenOnboardingModal,
             /// Opens the ACP onboarding modal.
             OpenAcpOnboardingModal,
+            /// Opens the Claude Code onboarding modal.
+            OpenClaudeCodeOnboardingModal,
             /// Resets the agent onboarding state.
             ResetOnboarding,
             /// Starts a chat conversation with the agent.