agent: Add component preview for Zed AI configuration (#33704)

Bennet Bo Fenner created

As we are in the process of improving our Onboarding UX for Zed AI, I
added component previews for the Zed AI Configuration section. This
should make it easier to inspect the different states we can run into.

<img width="1198" alt="image"
src="https://github.com/user-attachments/assets/eb774f27-9091-450d-bfae-c688d533c25e"
/>


Release Notes:

- N/A

Change summary

Cargo.lock                                   |   2 
crates/agent_ui/src/agent_panel.rs           |   2 
crates/language_model/src/language_model.rs  |   2 
crates/language_models/Cargo.toml            |   2 
crates/language_models/src/provider/cloud.rs | 247 +++++++++++++++------
5 files changed, 184 insertions(+), 71 deletions(-)

Detailed changes

Cargo.lock 🔗

@@ -8946,8 +8946,10 @@ dependencies = [
  "aws-credential-types",
  "aws_http_client",
  "bedrock",
+ "chrono",
  "client",
  "collections",
+ "component",
  "copilot",
  "credentials_provider",
  "deepseek",

crates/agent_ui/src/agent_panel.rs 🔗

@@ -2598,7 +2598,7 @@ impl AgentPanel {
                         Some(ConfigurationError::ProviderPendingTermsAcceptance(provider)) => {
                             parent.child(Banner::new().severity(ui::Severity::Warning).child(
                                 h_flex().w_full().children(provider.render_accept_terms(
-                                    LanguageModelProviderTosView::ThreadtEmptyState,
+                                    LanguageModelProviderTosView::ThreadEmptyState,
                                     cx,
                                 )),
                             ))

crates/language_model/src/language_model.rs 🔗

@@ -602,7 +602,7 @@ pub trait LanguageModelProvider: 'static {
 #[derive(PartialEq, Eq)]
 pub enum LanguageModelProviderTosView {
     /// When there are some past interactions in the Agent Panel.
-    ThreadtEmptyState,
+    ThreadEmptyState,
     /// When there are no past interactions in the Agent Panel.
     ThreadFreshStart,
     PromptEditorPopup,

crates/language_models/Cargo.toml 🔗

@@ -20,8 +20,10 @@ aws-credential-types = { workspace = true, features = [
 ] }
 aws_http_client.workspace = true
 bedrock.workspace = true
+chrono.workspace = true
 client.workspace = true
 collections.workspace = true
+component.workspace = true
 credentials_provider.workspace = true
 copilot.workspace = true
 deepseek = { workspace = true, features = ["schemars"] }

crates/language_models/src/provider/cloud.rs 🔗

@@ -1,5 +1,6 @@
 use anthropic::AnthropicModelMode;
 use anyhow::{Context as _, Result, anyhow};
+use chrono::{DateTime, Utc};
 use client::{Client, ModelRequestUsage, UserStore, zed_urls};
 use futures::{
     AsyncBufReadExt, FutureExt, Stream, StreamExt, future::BoxFuture, stream::BoxStream,
@@ -117,7 +118,7 @@ pub struct State {
     llm_api_token: LlmApiToken,
     user_store: Entity<UserStore>,
     status: client::Status,
-    accept_terms: Option<Task<Result<()>>>,
+    accept_terms_of_service_task: Option<Task<Result<()>>>,
     models: Vec<Arc<zed_llm_client::LanguageModel>>,
     default_model: Option<Arc<zed_llm_client::LanguageModel>>,
     default_fast_model: Option<Arc<zed_llm_client::LanguageModel>>,
@@ -141,7 +142,7 @@ impl State {
             llm_api_token: LlmApiToken::default(),
             user_store,
             status,
-            accept_terms: None,
+            accept_terms_of_service_task: None,
             models: Vec::new(),
             default_model: None,
             default_fast_model: None,
@@ -250,12 +251,12 @@ impl State {
 
     fn accept_terms_of_service(&mut self, cx: &mut Context<Self>) {
         let user_store = self.user_store.clone();
-        self.accept_terms = Some(cx.spawn(async move |this, cx| {
+        self.accept_terms_of_service_task = Some(cx.spawn(async move |this, cx| {
             let _ = user_store
                 .update(cx, |store, cx| store.accept_terms_of_service(cx))?
                 .await;
             this.update(cx, |this, cx| {
-                this.accept_terms = None;
+                this.accept_terms_of_service_task = None;
                 cx.notify()
             })
         }));
@@ -403,10 +404,8 @@ impl LanguageModelProvider for CloudLanguageModelProvider {
     }
 
     fn configuration_view(&self, _: &mut Window, cx: &mut App) -> AnyView {
-        cx.new(|_| ConfigurationView {
-            state: self.state.clone(),
-        })
-        .into()
+        cx.new(|_| ConfigurationView::new(self.state.clone()))
+            .into()
     }
 
     fn must_accept_terms(&self, cx: &App) -> bool {
@@ -418,7 +417,19 @@ impl LanguageModelProvider for CloudLanguageModelProvider {
         view: LanguageModelProviderTosView,
         cx: &mut App,
     ) -> Option<AnyElement> {
-        render_accept_terms(self.state.clone(), view, cx)
+        let state = self.state.read(cx);
+        if state.has_accepted_terms_of_service(cx) {
+            return None;
+        }
+        Some(
+            render_accept_terms(view, state.accept_terms_of_service_task.is_some(), {
+                let state = self.state.clone();
+                move |_window, cx| {
+                    state.update(cx, |state, cx| state.accept_terms_of_service(cx));
+                }
+            })
+            .into_any_element(),
+        )
     }
 
     fn reset_credentials(&self, _cx: &mut App) -> Task<Result<()>> {
@@ -427,18 +438,12 @@ impl LanguageModelProvider for CloudLanguageModelProvider {
 }
 
 fn render_accept_terms(
-    state: Entity<State>,
     view_kind: LanguageModelProviderTosView,
-    cx: &mut App,
-) -> Option<AnyElement> {
-    if state.read(cx).has_accepted_terms_of_service(cx) {
-        return None;
-    }
-
-    let accept_terms_disabled = state.read(cx).accept_terms.is_some();
-
+    accept_terms_of_service_in_progress: bool,
+    accept_terms_callback: impl Fn(&mut Window, &mut App) + 'static,
+) -> impl IntoElement {
     let thread_fresh_start = matches!(view_kind, LanguageModelProviderTosView::ThreadFreshStart);
-    let thread_empty_state = matches!(view_kind, LanguageModelProviderTosView::ThreadtEmptyState);
+    let thread_empty_state = matches!(view_kind, LanguageModelProviderTosView::ThreadEmptyState);
 
     let terms_button = Button::new("terms_of_service", "Terms of Service")
         .style(ButtonStyle::Subtle)
@@ -461,18 +466,11 @@ fn render_accept_terms(
                 this.style(ButtonStyle::Tinted(TintColor::Warning))
                     .label_size(LabelSize::Small)
             })
-            .disabled(accept_terms_disabled)
-            .on_click({
-                let state = state.downgrade();
-                move |_, _window, cx| {
-                    state
-                        .update(cx, |state, cx| state.accept_terms_of_service(cx))
-                        .ok();
-                }
-            }),
+            .disabled(accept_terms_of_service_in_progress)
+            .on_click(move |_, window, cx| (accept_terms_callback)(window, cx)),
     );
 
-    let form = if thread_empty_state {
+    if thread_empty_state {
         h_flex()
             .w_full()
             .flex_wrap()
@@ -510,12 +508,10 @@ fn render_accept_terms(
                     LanguageModelProviderTosView::ThreadFreshStart => {
                         button_container.w_full().justify_center()
                     }
-                    LanguageModelProviderTosView::ThreadtEmptyState => div().w_0(),
+                    LanguageModelProviderTosView::ThreadEmptyState => div().w_0(),
                 }
             })
-    };
-
-    Some(form.into_any())
+    }
 }
 
 pub struct CloudLanguageModel {
@@ -1060,32 +1056,24 @@ fn response_lines<T: DeserializeOwned>(
     )
 }
 
-struct ConfigurationView {
-    state: gpui::Entity<State>,
-}
-
-impl ConfigurationView {
-    fn authenticate(&mut self, cx: &mut Context<Self>) {
-        self.state.update(cx, |state, cx| {
-            state.authenticate(cx).detach_and_log_err(cx);
-        });
-        cx.notify();
-    }
+#[derive(IntoElement, RegisterComponent)]
+struct ZedAIConfiguration {
+    is_connected: bool,
+    plan: Option<proto::Plan>,
+    subscription_period: Option<(DateTime<Utc>, DateTime<Utc>)>,
+    eligible_for_trial: bool,
+    has_accepted_terms_of_service: bool,
+    accept_terms_of_service_in_progress: bool,
+    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>,
 }
 
-impl Render for ConfigurationView {
-    fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
+impl RenderOnce for ZedAIConfiguration {
+    fn render(self, _window: &mut Window, _cx: &mut App) -> impl IntoElement {
         const ZED_PRICING_URL: &str = "https://zed.dev/pricing";
 
-        let is_connected = !self.state.read(cx).is_signed_out();
-        let user_store = self.state.read(cx).user_store.read(cx);
-        let plan = user_store.current_plan();
-        let subscription_period = user_store.subscription_period();
-        let eligible_for_trial = user_store.trial_started_at().is_none();
-        let has_accepted_terms = self.state.read(cx).has_accepted_terms_of_service(cx);
-
-        let is_pro = plan == Some(proto::Plan::ZedPro);
-        let subscription_text = match (plan, subscription_period) {
+        let is_pro = self.plan == Some(proto::Plan::ZedPro);
+        let subscription_text = match (self.plan, self.subscription_period) {
             (Some(proto::Plan::ZedPro), Some(_)) => {
                 "You have access to Zed's hosted LLMs through your Zed Pro subscription."
             }
@@ -1096,7 +1084,7 @@ impl Render for ConfigurationView {
                 "You have basic access to Zed's hosted LLMs through your Zed Free subscription."
             }
             _ => {
-                if eligible_for_trial {
+                if self.eligible_for_trial {
                     "Subscribe for access to Zed's hosted LLMs. Start with a 14 day free trial."
                 } else {
                     "Subscribe for access to Zed's hosted LLMs."
@@ -1107,7 +1095,7 @@ impl Render for ConfigurationView {
             h_flex().child(
                 Button::new("manage_settings", "Manage Subscription")
                     .style(ButtonStyle::Tinted(TintColor::Accent))
-                    .on_click(cx.listener(|_, _, _, cx| cx.open_url(&zed_urls::account_url(cx)))),
+                    .on_click(|_, _, cx| cx.open_url(&zed_urls::account_url(cx))),
             )
         } else {
             h_flex()
@@ -1115,28 +1103,31 @@ impl Render for ConfigurationView {
                 .child(
                     Button::new("learn_more", "Learn more")
                         .style(ButtonStyle::Subtle)
-                        .on_click(cx.listener(|_, _, _, cx| cx.open_url(ZED_PRICING_URL))),
+                        .on_click(|_, _, cx| cx.open_url(ZED_PRICING_URL)),
                 )
                 .child(
                     Button::new("upgrade", "Upgrade")
                         .style(ButtonStyle::Subtle)
                         .color(Color::Accent)
-                        .on_click(
-                            cx.listener(|_, _, _, cx| cx.open_url(&zed_urls::account_url(cx))),
-                        ),
+                        .on_click(|_, _, cx| cx.open_url(&zed_urls::account_url(cx))),
                 )
         };
 
-        if is_connected {
+        if self.is_connected {
             v_flex()
                 .gap_3()
                 .w_full()
-                .children(render_accept_terms(
-                    self.state.clone(),
-                    LanguageModelProviderTosView::Configuration,
-                    cx,
-                ))
-                .when(has_accepted_terms, |this| {
+                .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)
                 })
@@ -1149,8 +1140,126 @@ impl Render for ConfigurationView {
                         .icon_color(Color::Muted)
                         .icon(IconName::Github)
                         .icon_position(IconPosition::Start)
-                        .on_click(cx.listener(move |this, _, _, cx| this.authenticate(cx))),
+                        .on_click({
+                            let callback = self.sign_in_callback.clone();
+                            move |_, window, cx| (callback)(window, cx)
+                        }),
                 )
         }
     }
 }
+
+struct ConfigurationView {
+    state: Entity<State>,
+    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>,
+}
+
+impl ConfigurationView {
+    fn new(state: Entity<State>) -> Self {
+        let accept_terms_of_service_callback = Arc::new({
+            let state = state.clone();
+            move |_window: &mut Window, cx: &mut App| {
+                state.update(cx, |state, cx| {
+                    state.accept_terms_of_service(cx);
+                });
+            }
+        });
+
+        let sign_in_callback = Arc::new({
+            let state = state.clone();
+            move |_window: &mut Window, cx: &mut App| {
+                state.update(cx, |state, cx| {
+                    state.authenticate(cx).detach_and_log_err(cx);
+                });
+            }
+        });
+
+        Self {
+            state,
+            accept_terms_of_service_callback,
+            sign_in_callback,
+        }
+    }
+}
+
+impl Render for ConfigurationView {
+    fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
+        let state = self.state.read(cx);
+        let user_store = state.user_store.read(cx);
+
+        ZedAIConfiguration {
+            is_connected: !state.is_signed_out(),
+            plan: user_store.current_plan(),
+            subscription_period: user_store.subscription_period(),
+            eligible_for_trial: user_store.trial_started_at().is_none(),
+            has_accepted_terms_of_service: state.has_accepted_terms_of_service(cx),
+            accept_terms_of_service_in_progress: state.accept_terms_of_service_task.is_some(),
+            accept_terms_of_service_callback: self.accept_terms_of_service_callback.clone(),
+            sign_in_callback: self.sign_in_callback.clone(),
+        }
+    }
+}
+
+impl Component for ZedAIConfiguration {
+    fn scope() -> ComponentScope {
+        ComponentScope::Agent
+    }
+
+    fn preview(_window: &mut Window, _cx: &mut App) -> Option<AnyElement> {
+        fn configuration(
+            is_connected: bool,
+            plan: Option<proto::Plan>,
+            eligible_for_trial: bool,
+            has_accepted_terms_of_service: bool,
+        ) -> AnyElement {
+            ZedAIConfiguration {
+                is_connected,
+                plan,
+                subscription_period: plan
+                    .is_some()
+                    .then(|| (Utc::now(), Utc::now() + chrono::Duration::days(7))),
+                eligible_for_trial,
+                has_accepted_terms_of_service,
+                accept_terms_of_service_in_progress: false,
+                accept_terms_of_service_callback: Arc::new(|_, _| {}),
+                sign_in_callback: Arc::new(|_, _| {}),
+            }
+            .into_any_element()
+        }
+
+        Some(
+            v_flex()
+                .p_4()
+                .gap_4()
+                .children(vec![
+                    single_example("Not connected", configuration(false, None, false, true)),
+                    single_example(
+                        "Accept Terms of Service",
+                        configuration(true, None, true, false),
+                    ),
+                    single_example(
+                        "No Plan - Not eligible for trial",
+                        configuration(true, None, false, true),
+                    ),
+                    single_example(
+                        "No Plan - Eligible for trial",
+                        configuration(true, None, true, true),
+                    ),
+                    single_example(
+                        "Free Plan",
+                        configuration(true, Some(proto::Plan::Free), true, true),
+                    ),
+                    single_example(
+                        "Zed Pro Trial Plan",
+                        configuration(true, Some(proto::Plan::ZedProTrial), true, true),
+                    ),
+                    single_example(
+                        "Zed Pro Plan",
+                        configuration(true, Some(proto::Plan::ZedPro), true, true),
+                    ),
+                ])
+                .into_any_element(),
+        )
+    }
+}