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

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
This commit is contained in:
Bennet Bo Fenner 2025-07-01 13:12:51 +02:00 committed by GitHub
parent 2caa19214b
commit 782fbfad90
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 184 additions and 71 deletions

2
Cargo.lock generated
View file

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

View file

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

View file

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

View file

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

View file

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