agent: Add UI for upsell scenarios (#29805)

Release Notes:

- N/A

---------

Co-authored-by: Marshall Bowers <git@maxdeviant.com>
This commit is contained in:
Nate Butler 2025-05-04 20:48:06 -04:00 committed by GitHub
parent a19687a815
commit fe177f5d69
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 659 additions and 135 deletions

View file

@ -9,6 +9,7 @@ mod context_picker;
mod context_server_configuration; mod context_server_configuration;
mod context_store; mod context_store;
mod context_strip; mod context_strip;
mod debug;
mod history_store; mod history_store;
mod inline_assistant; mod inline_assistant;
mod inline_prompt_editor; mod inline_prompt_editor;
@ -47,7 +48,7 @@ pub use crate::thread::{Message, MessageSegment, Thread, ThreadEvent};
pub use crate::thread_store::ThreadStore; pub use crate::thread_store::ThreadStore;
pub use agent_diff::{AgentDiffPane, AgentDiffToolbar}; pub use agent_diff::{AgentDiffPane, AgentDiffToolbar};
pub use context_store::ContextStore; pub use context_store::ContextStore;
pub use ui::{all_agent_previews, get_agent_preview}; pub use ui::preview::{all_agent_previews, get_agent_preview};
actions!( actions!(
agent, agent,

View file

@ -50,7 +50,6 @@ use crate::message_editor::{MessageEditor, MessageEditorEvent};
use crate::thread::{Thread, ThreadError, ThreadId, TokenUsageRatio}; use crate::thread::{Thread, ThreadError, ThreadId, TokenUsageRatio};
use crate::thread_history::{PastContext, PastThread, ThreadHistory}; use crate::thread_history::{PastContext, PastThread, ThreadHistory};
use crate::thread_store::ThreadStore; use crate::thread_store::ThreadStore;
use crate::ui::UsageBanner;
use crate::{ use crate::{
AddContextServer, AgentDiffPane, DeleteRecentlyOpenThread, ExpandMessageEditor, Follow, AddContextServer, AgentDiffPane, DeleteRecentlyOpenThread, ExpandMessageEditor, Follow,
InlineAssistant, NewTextThread, NewThread, OpenActiveThreadAsMarkdown, OpenAgentDiff, InlineAssistant, NewTextThread, NewThread, OpenActiveThreadAsMarkdown, OpenAgentDiff,
@ -432,6 +431,7 @@ impl AssistantPanel {
MessageEditor::new( MessageEditor::new(
fs.clone(), fs.clone(),
workspace.clone(), workspace.clone(),
user_store.clone(),
message_editor_context_store.clone(), message_editor_context_store.clone(),
prompt_store.clone(), prompt_store.clone(),
thread_store.downgrade(), thread_store.downgrade(),
@ -735,6 +735,7 @@ impl AssistantPanel {
MessageEditor::new( MessageEditor::new(
self.fs.clone(), self.fs.clone(),
self.workspace.clone(), self.workspace.clone(),
self.user_store.clone(),
context_store, context_store,
self.prompt_store.clone(), self.prompt_store.clone(),
self.thread_store.downgrade(), self.thread_store.downgrade(),
@ -933,6 +934,7 @@ impl AssistantPanel {
MessageEditor::new( MessageEditor::new(
self.fs.clone(), self.fs.clone(),
self.workspace.clone(), self.workspace.clone(),
self.user_store.clone(),
context_store, context_store,
self.prompt_store.clone(), self.prompt_store.clone(),
self.thread_store.downgrade(), self.thread_store.downgrade(),
@ -1944,22 +1946,6 @@ impl AssistantPanel {
}) })
} }
fn render_usage_banner(&self, cx: &mut Context<Self>) -> Option<AnyElement> {
let plan = self
.user_store
.read(cx)
.current_plan()
.map(|plan| match plan {
Plan::Free => zed_llm_client::Plan::Free,
Plan::ZedPro => zed_llm_client::Plan::ZedPro,
Plan::ZedProTrial => zed_llm_client::Plan::ZedProTrial,
})
.unwrap_or(zed_llm_client::Plan::Free);
let usage = self.thread.read(cx).last_usage()?;
Some(UsageBanner::new(plan, usage).into_any_element())
}
fn render_tool_use_limit_reached(&self, cx: &mut Context<Self>) -> Option<AnyElement> { fn render_tool_use_limit_reached(&self, cx: &mut Context<Self>) -> Option<AnyElement> {
let tool_use_limit_reached = self let tool_use_limit_reached = self
.thread .thread
@ -2277,7 +2263,6 @@ impl Render for AssistantPanel {
ActiveView::Thread { .. } => parent ActiveView::Thread { .. } => parent
.child(self.render_active_thread_or_empty_state(window, cx)) .child(self.render_active_thread_or_empty_state(window, cx))
.children(self.render_tool_use_limit_reached(cx)) .children(self.render_tool_use_limit_reached(cx))
.children(self.render_usage_banner(cx))
.child(h_flex().child(self.message_editor.clone())) .child(h_flex().child(self.message_editor.clone()))
.children(self.render_last_error(cx)), .children(self.render_last_error(cx)),
ActiveView::History => parent.child(self.history.clone()), ActiveView::History => parent.child(self.history.clone()),

124
crates/agent/src/debug.rs Normal file
View file

@ -0,0 +1,124 @@
#![allow(unused, dead_code)]
use gpui::Global;
use language_model::RequestUsage;
use std::ops::{Deref, DerefMut};
use ui::prelude::*;
use zed_llm_client::{Plan, UsageLimit};
/// Debug only: Used for testing various account states
///
/// Use this by initializing it with
/// `cx.set_global(DebugAccountState::default());` somewhere
///
/// Then call `cx.debug_account()` to get access
#[derive(Clone, Debug)]
pub struct DebugAccountState {
pub enabled: bool,
pub trial_expired: bool,
pub plan: Plan,
pub custom_prompt_usage: RequestUsage,
pub usage_based_billing_enabled: bool,
pub monthly_spending_cap: i32,
pub custom_edit_prediction_usage: UsageLimit,
}
impl DebugAccountState {
pub fn enabled(&self) -> bool {
self.enabled
}
pub fn set_enabled(&mut self, enabled: bool) -> &mut Self {
self.enabled = enabled;
self
}
pub fn set_trial_expired(&mut self, trial_expired: bool) -> &mut Self {
self.trial_expired = trial_expired;
self
}
pub fn set_plan(&mut self, plan: Plan) -> &mut Self {
self.plan = plan;
self
}
pub fn set_custom_prompt_usage(&mut self, custom_prompt_usage: RequestUsage) -> &mut Self {
self.custom_prompt_usage = custom_prompt_usage;
self
}
pub fn set_usage_based_billing_enabled(
&mut self,
usage_based_billing_enabled: bool,
) -> &mut Self {
self.usage_based_billing_enabled = usage_based_billing_enabled;
self
}
pub fn set_monthly_spending_cap(&mut self, monthly_spending_cap: i32) -> &mut Self {
self.monthly_spending_cap = monthly_spending_cap;
self
}
pub fn set_custom_edit_prediction_usage(
&mut self,
custom_edit_prediction_usage: UsageLimit,
) -> &mut Self {
self.custom_edit_prediction_usage = custom_edit_prediction_usage;
self
}
}
impl Default for DebugAccountState {
fn default() -> Self {
Self {
enabled: false,
trial_expired: false,
plan: Plan::Free,
custom_prompt_usage: RequestUsage {
limit: UsageLimit::Unlimited,
amount: 0,
},
usage_based_billing_enabled: false,
// $50.00
monthly_spending_cap: 5000,
custom_edit_prediction_usage: UsageLimit::Unlimited,
}
}
}
impl DebugAccountState {
pub fn get_global(cx: &App) -> &Self {
&cx.global::<GlobalDebugAccountState>().0
}
}
#[derive(Clone, Debug)]
pub struct GlobalDebugAccountState(pub DebugAccountState);
impl Global for GlobalDebugAccountState {}
impl Deref for GlobalDebugAccountState {
type Target = DebugAccountState;
fn deref(&self) -> &Self::Target {
&self.0
}
}
impl DerefMut for GlobalDebugAccountState {
fn deref_mut(&mut self) -> &mut Self::Target {
&mut self.0
}
}
pub trait DebugAccount {
fn debug_account(&self) -> &DebugAccountState;
}
impl DebugAccount for App {
fn debug_account(&self) -> &DebugAccountState {
&self.global::<GlobalDebugAccountState>().0
}
}

View file

@ -4,8 +4,12 @@ use std::sync::Arc;
use crate::assistant_model_selector::{AssistantModelSelector, ModelType}; use crate::assistant_model_selector::{AssistantModelSelector, ModelType};
use crate::context::{AgentContextKey, ContextCreasesAddon, ContextLoadResult, load_context}; use crate::context::{AgentContextKey, ContextCreasesAddon, ContextLoadResult, load_context};
use crate::tool_compatibility::{IncompatibleToolsState, IncompatibleToolsTooltip}; use crate::tool_compatibility::{IncompatibleToolsState, IncompatibleToolsTooltip};
use crate::ui::{AgentPreview, AnimatedLabel, MaxModeTooltip}; use crate::ui::{
AnimatedLabel, MaxModeTooltip,
preview::{AgentPreview, UsageCallout},
};
use buffer_diff::BufferDiff; use buffer_diff::BufferDiff;
use client::UserStore;
use collections::{HashMap, HashSet}; use collections::{HashMap, HashSet};
use editor::actions::{MoveUp, Paste}; use editor::actions::{MoveUp, Paste};
use editor::{ use editor::{
@ -27,6 +31,7 @@ use language_model_selector::ToggleModelSelector;
use multi_buffer; use multi_buffer;
use project::Project; use project::Project;
use prompt_store::PromptStore; use prompt_store::PromptStore;
use proto::Plan;
use settings::Settings; use settings::Settings;
use std::time::Duration; use std::time::Duration;
use theme::ThemeSettings; use theme::ThemeSettings;
@ -53,6 +58,7 @@ pub struct MessageEditor {
editor: Entity<Editor>, editor: Entity<Editor>,
workspace: WeakEntity<Workspace>, workspace: WeakEntity<Workspace>,
project: Entity<Project>, project: Entity<Project>,
user_store: Entity<UserStore>,
context_store: Entity<ContextStore>, context_store: Entity<ContextStore>,
prompt_store: Option<Entity<PromptStore>>, prompt_store: Option<Entity<PromptStore>>,
context_strip: Entity<ContextStrip>, context_strip: Entity<ContextStrip>,
@ -126,6 +132,7 @@ impl MessageEditor {
pub fn new( pub fn new(
fs: Arc<dyn Fs>, fs: Arc<dyn Fs>,
workspace: WeakEntity<Workspace>, workspace: WeakEntity<Workspace>,
user_store: Entity<UserStore>,
context_store: Entity<ContextStore>, context_store: Entity<ContextStore>,
prompt_store: Option<Entity<PromptStore>>, prompt_store: Option<Entity<PromptStore>>,
thread_store: WeakEntity<ThreadStore>, thread_store: WeakEntity<ThreadStore>,
@ -188,6 +195,7 @@ impl MessageEditor {
Self { Self {
editor: editor.clone(), editor: editor.clone(),
project: thread.read(cx).project().clone(), project: thread.read(cx).project().clone(),
user_store,
thread, thread,
incompatible_tools_state: incompatible_tools.clone(), incompatible_tools_state: incompatible_tools.clone(),
workspace, workspace,
@ -1030,79 +1038,72 @@ impl MessageEditor {
}) })
} }
fn render_usage_callout(&self, line_height: Pixels, cx: &mut Context<Self>) -> Option<Div> {
if !cx.has_flag::<NewBillingFeatureFlag>() {
return None;
}
let plan = self
.user_store
.read(cx)
.current_plan()
.map(|plan| match plan {
Plan::Free => zed_llm_client::Plan::Free,
Plan::ZedPro => zed_llm_client::Plan::ZedPro,
Plan::ZedProTrial => zed_llm_client::Plan::ZedProTrial,
})
.unwrap_or(zed_llm_client::Plan::Free);
let usage = self.thread.read(cx).last_usage()?;
Some(
div()
.child(UsageCallout::new(plan, usage))
.line_height(line_height),
)
}
fn render_token_limit_callout( fn render_token_limit_callout(
&self, &self,
line_height: Pixels, line_height: Pixels,
token_usage_ratio: TokenUsageRatio, token_usage_ratio: TokenUsageRatio,
cx: &mut Context<Self>, cx: &mut Context<Self>,
) -> Div { ) -> Option<Div> {
let heading = if token_usage_ratio == TokenUsageRatio::Exceeded { if !cx.has_flag::<NewBillingFeatureFlag>() {
return None;
}
let title = if token_usage_ratio == TokenUsageRatio::Exceeded {
"Thread reached the token limit" "Thread reached the token limit"
} else { } else {
"Thread reaching the token limit soon" "Thread reaching the token limit soon"
}; };
h_flex() let message = "Start a new thread from a summary to continue the conversation.";
.p_2()
.gap_2()
.flex_wrap()
.justify_between()
.bg(
if token_usage_ratio == TokenUsageRatio::Exceeded {
cx.theme().status().error_background.opacity(0.1)
} else {
cx.theme().status().warning_background.opacity(0.1)
})
.border_t_1()
.border_color(cx.theme().colors().border)
.child(
h_flex()
.gap_2()
.items_start()
.child(
h_flex()
.h(line_height)
.justify_center()
.child(
if token_usage_ratio == TokenUsageRatio::Exceeded {
Icon::new(IconName::X)
.color(Color::Error)
.size(IconSize::XSmall)
} else {
Icon::new(IconName::Warning)
.color(Color::Warning)
.size(IconSize::XSmall)
}
),
)
.child(
v_flex()
.mr_auto()
.child(Label::new(heading).size(LabelSize::Small))
.child(
Label::new(
"Start a new thread from a summary to continue the conversation.",
)
.size(LabelSize::Small)
.color(Color::Muted),
),
),
)
.child(
Button::new("new-thread", "Start New Thread")
.on_click(cx.listener(|this, _, window, cx| {
let from_thread_id = Some(this.thread.read(cx).id().clone());
window.dispatch_action(Box::new(NewThread { let icon = if token_usage_ratio == TokenUsageRatio::Exceeded {
from_thread_id Icon::new(IconName::X)
}), cx); .color(Color::Error)
})) .size(IconSize::XSmall)
.icon(IconName::Plus) } else {
.icon_position(IconPosition::Start) Icon::new(IconName::Warning)
.icon_size(IconSize::Small) .color(Color::Warning)
.style(ButtonStyle::Tinted(ui::TintColor::Accent)) .size(IconSize::XSmall)
.label_size(LabelSize::Small), };
)
Some(
div()
.child(ui::Callout::multi_line(
title.into(),
message.into(),
icon,
"Start New Thread".into(),
Box::new(cx.listener(|this, _, window, cx| {
let from_thread_id = Some(this.thread.read(cx).id().clone());
window.dispatch_action(Box::new(NewThread { from_thread_id }), cx);
})),
))
.line_height(line_height),
)
} }
pub fn last_estimated_token_count(&self) -> Option<usize> { pub fn last_estimated_token_count(&self) -> Option<usize> {
@ -1307,8 +1308,16 @@ impl Render for MessageEditor {
parent.child(self.render_changed_buffers(&changed_buffers, window, cx)) parent.child(self.render_changed_buffers(&changed_buffers, window, cx))
}) })
.child(self.render_editor(font_size, line_height, window, cx)) .child(self.render_editor(font_size, line_height, window, cx))
.when(token_usage_ratio != TokenUsageRatio::Normal, |parent| { .children({
parent.child(self.render_token_limit_callout(line_height, token_usage_ratio, cx)) let usage_callout = self.render_usage_callout(line_height, cx);
if usage_callout.is_some() {
usage_callout
} else if token_usage_ratio != TokenUsageRatio::Normal {
self.render_token_limit_callout(line_height, token_usage_ratio, cx)
} else {
None
}
}) })
} }
} }
@ -1354,6 +1363,12 @@ impl Component for MessageEditor {
fn scope() -> ComponentScope { fn scope() -> ComponentScope {
ComponentScope::Agent ComponentScope::Agent
} }
fn description() -> Option<&'static str> {
Some(
"The composer experience of the Agent Panel. This interface handles context, composing messages, switching profiles, models and more.",
)
}
} }
impl AgentPreview for MessageEditor { impl AgentPreview for MessageEditor {
@ -1364,16 +1379,18 @@ impl AgentPreview for MessageEditor {
window: &mut Window, window: &mut Window,
cx: &mut App, cx: &mut App,
) -> Option<AnyElement> { ) -> Option<AnyElement> {
if let Some(workspace_entity) = workspace.upgrade() { if let Some(workspace) = workspace.upgrade() {
let fs = workspace_entity.read(cx).app_state().fs.clone(); let fs = workspace.read(cx).app_state().fs.clone();
let weak_project = workspace_entity.read(cx).project().clone().downgrade(); let user_store = workspace.read(cx).app_state().user_store.clone();
let weak_project = workspace.read(cx).project().clone().downgrade();
let context_store = cx.new(|_cx| ContextStore::new(weak_project, None)); let context_store = cx.new(|_cx| ContextStore::new(weak_project, None));
let thread = active_thread.read(cx).thread().clone(); let thread = active_thread.read(cx).thread().clone();
let example_message_editor = cx.new(|cx| { let default_message_editor = cx.new(|cx| {
MessageEditor::new( MessageEditor::new(
fs, fs,
workspace, workspace.downgrade(),
user_store,
context_store, context_store,
None, None,
thread_store, thread_store,
@ -1387,8 +1404,15 @@ impl AgentPreview for MessageEditor {
v_flex() v_flex()
.gap_4() .gap_4()
.children(vec![single_example( .children(vec![single_example(
"Default", "Default Message Editor",
example_message_editor.clone().into_any_element(), div()
.w(px(540.))
.pt_12()
.bg(cx.theme().colors().panel_background)
.border_1()
.border_color(cx.theme().colors().border)
.child(default_message_editor.clone())
.into_any_element(),
)]) )])
.into_any_element(), .into_any_element(),
) )

View file

@ -355,6 +355,7 @@ pub struct Thread {
request_token_usage: Vec<TokenUsage>, request_token_usage: Vec<TokenUsage>,
cumulative_token_usage: TokenUsage, cumulative_token_usage: TokenUsage,
exceeded_window_error: Option<ExceededWindowError>, exceeded_window_error: Option<ExceededWindowError>,
last_usage: Option<RequestUsage>,
tool_use_limit_reached: bool, tool_use_limit_reached: bool,
feedback: Option<ThreadFeedback>, feedback: Option<ThreadFeedback>,
message_feedback: HashMap<MessageId, ThreadFeedback>, message_feedback: HashMap<MessageId, ThreadFeedback>,
@ -418,6 +419,7 @@ impl Thread {
request_token_usage: Vec::new(), request_token_usage: Vec::new(),
cumulative_token_usage: TokenUsage::default(), cumulative_token_usage: TokenUsage::default(),
exceeded_window_error: None, exceeded_window_error: None,
last_usage: None,
tool_use_limit_reached: false, tool_use_limit_reached: false,
feedback: None, feedback: None,
message_feedback: HashMap::default(), message_feedback: HashMap::default(),
@ -526,6 +528,7 @@ impl Thread {
request_token_usage: serialized.request_token_usage, request_token_usage: serialized.request_token_usage,
cumulative_token_usage: serialized.cumulative_token_usage, cumulative_token_usage: serialized.cumulative_token_usage,
exceeded_window_error: None, exceeded_window_error: None,
last_usage: None,
tool_use_limit_reached: false, tool_use_limit_reached: false,
feedback: None, feedback: None,
message_feedback: HashMap::default(), message_feedback: HashMap::default(),
@ -817,6 +820,10 @@ impl Thread {
.unwrap_or(false) .unwrap_or(false)
} }
pub fn last_usage(&self) -> Option<RequestUsage> {
self.last_usage
}
pub fn tool_use_limit_reached(&self) -> bool { pub fn tool_use_limit_reached(&self) -> bool {
self.tool_use_limit_reached self.tool_use_limit_reached
} }
@ -1535,7 +1542,10 @@ impl Thread {
CompletionRequestStatus::UsageUpdated { CompletionRequestStatus::UsageUpdated {
amount, limit amount, limit
} => { } => {
cx.emit(ThreadEvent::UsageUpdated(RequestUsage { limit, amount: amount as i32 })); let usage = RequestUsage { limit, amount: amount as i32 };
thread.last_usage = Some(usage);
cx.emit(ThreadEvent::UsageUpdated(usage));
} }
CompletionRequestStatus::ToolUseLimitReached => { CompletionRequestStatus::ToolUseLimitReached => {
thread.tool_use_limit_reached = true; thread.tool_use_limit_reached = true;

View file

@ -1,14 +1,12 @@
mod agent_notification; mod agent_notification;
pub mod agent_preview;
mod animated_label; mod animated_label;
mod context_pill; mod context_pill;
mod max_mode_tooltip; mod max_mode_tooltip;
pub mod preview;
mod upsell; mod upsell;
mod usage_banner; mod usage_banner;
pub use agent_notification::*; pub use agent_notification::*;
pub use agent_preview::*;
pub use animated_label::*; pub use animated_label::*;
pub use context_pill::*; pub use context_pill::*;
pub use max_mode_tooltip::*; pub use max_mode_tooltip::*;
pub use usage_banner::*;

View file

@ -0,0 +1,5 @@
mod agent_preview;
mod usage_callouts;
pub use agent_preview::*;
pub use usage_callouts::*;

View file

@ -42,14 +42,14 @@ pub trait AgentPreview: Component + Sized {
#[macro_export] #[macro_export]
macro_rules! register_agent_preview { macro_rules! register_agent_preview {
($type:ty) => { ($type:ty) => {
#[linkme::distributed_slice($crate::ui::agent_preview::__ALL_AGENT_PREVIEWS)] #[linkme::distributed_slice($crate::ui::preview::__ALL_AGENT_PREVIEWS)]
static __REGISTER_AGENT_PREVIEW: fn() -> ( static __REGISTER_AGENT_PREVIEW: fn() -> (
component::ComponentId, component::ComponentId,
$crate::ui::agent_preview::PreviewFn, $crate::ui::preview::PreviewFn,
) = || { ) = || {
( (
<$type as component::Component>::id(), <$type as component::Component>::id(),
<$type as $crate::ui::agent_preview::AgentPreview>::agent_preview, <$type as $crate::ui::preview::AgentPreview>::agent_preview,
) )
}; };
}; };

View file

@ -0,0 +1,204 @@
use component::{empty_example, example_group_with_title, single_example};
use gpui::{AnyElement, App, IntoElement, RenderOnce, Window};
use language_model::RequestUsage;
use ui::{Callout, Color, Icon, IconName, IconSize, prelude::*};
use zed_llm_client::{Plan, UsageLimit};
#[derive(IntoElement, RegisterComponent)]
pub struct UsageCallout {
plan: Plan,
usage: RequestUsage,
}
impl UsageCallout {
pub fn new(plan: Plan, usage: RequestUsage) -> Self {
Self { plan, usage }
}
}
impl RenderOnce for UsageCallout {
fn render(self, _window: &mut Window, _cx: &mut App) -> impl IntoElement {
let (is_limit_reached, is_approaching_limit, remaining) = match self.usage.limit {
UsageLimit::Limited(limit) => {
let percentage = self.usage.amount as f32 / limit as f32;
let is_limit_reached = percentage >= 1.0;
let is_near_limit = percentage >= 0.9 && percentage < 1.0;
(
is_limit_reached,
is_near_limit,
limit.saturating_sub(self.usage.amount),
)
}
UsageLimit::Unlimited => (false, false, 0),
};
if !is_limit_reached && !is_approaching_limit {
return div().into_any_element();
}
let (title, message, button_text, url) = if is_limit_reached {
match self.plan {
Plan::Free => (
"Out of free prompts",
"Upgrade to continue, wait for the next reset, or switch to API key."
.to_string(),
"Upgrade",
"https://zed.dev/pricing",
),
Plan::ZedProTrial => (
"Out of trial prompts",
"Upgrade to Zed Pro to continue, or switch to API key.".to_string(),
"Upgrade",
"https://zed.dev/pricing",
),
Plan::ZedPro => (
"Out of included prompts",
"Enable usage based billing to continue.".to_string(),
"Manage",
"https://zed.dev/account",
),
}
} else {
match self.plan {
Plan::Free => (
"Reaching Free tier limit soon",
format!(
"{} remaining - Upgrade to increase limit, or switch providers",
remaining
),
"Upgrade",
"https://zed.dev/pricing",
),
Plan::ZedProTrial => (
"Reaching Trial limit soon",
format!(
"{} remaining - Upgrade to increase limit, or switch providers",
remaining
),
"Upgrade",
"https://zed.dev/pricing",
),
_ => return div().into_any_element(),
}
};
let icon = if is_limit_reached {
Icon::new(IconName::X)
.color(Color::Error)
.size(IconSize::XSmall)
} else {
Icon::new(IconName::Warning)
.color(Color::Warning)
.size(IconSize::XSmall)
};
Callout::multi_line(
title.into(),
message.into(),
icon,
button_text.into(),
Box::new(move |_, _, cx| {
cx.open_url(url);
}),
)
.into_any_element()
}
}
impl Component for UsageCallout {
fn scope() -> ComponentScope {
ComponentScope::Agent
}
fn sort_name() -> &'static str {
"AgentUsageCallout"
}
fn preview(_window: &mut Window, _cx: &mut App) -> Option<AnyElement> {
let free_examples = example_group_with_title(
"Free Plan",
vec![
single_example(
"Approaching limit (90%)",
UsageCallout::new(
Plan::Free,
RequestUsage {
limit: UsageLimit::Limited(50),
amount: 45, // 90% of limit
},
)
.into_any_element(),
),
single_example(
"Limit reached (100%)",
UsageCallout::new(
Plan::Free,
RequestUsage {
limit: UsageLimit::Limited(50),
amount: 50, // 100% of limit
},
)
.into_any_element(),
),
],
);
let trial_examples = example_group_with_title(
"Zed Pro Trial",
vec![
single_example(
"Approaching limit (90%)",
UsageCallout::new(
Plan::ZedProTrial,
RequestUsage {
limit: UsageLimit::Limited(150),
amount: 135, // 90% of limit
},
)
.into_any_element(),
),
single_example(
"Limit reached (100%)",
UsageCallout::new(
Plan::ZedProTrial,
RequestUsage {
limit: UsageLimit::Limited(150),
amount: 150, // 100% of limit
},
)
.into_any_element(),
),
],
);
let pro_examples = example_group_with_title(
"Zed Pro",
vec![
single_example(
"Limit reached (100%)",
UsageCallout::new(
Plan::ZedPro,
RequestUsage {
limit: UsageLimit::Limited(500),
amount: 500, // 100% of limit
},
)
.into_any_element(),
),
empty_example("Unlimited plan (no callout shown)"),
],
);
Some(
div()
.p_4()
.flex()
.flex_col()
.gap_4()
.child(free_examples)
.child(trial_examples)
.child(pro_examples)
.into_any_element(),
)
}
}

View file

@ -4,8 +4,8 @@ use std::sync::LazyLock;
use collections::HashMap; use collections::HashMap;
use gpui::{ use gpui::{
AnyElement, App, IntoElement, RenderOnce, SharedString, Window, div, pattern_slash, prelude::*, AnyElement, App, IntoElement, Pixels, RenderOnce, SharedString, Window, div, pattern_slash,
px, rems, prelude::*, px, rems,
}; };
use linkme::distributed_slice; use linkme::distributed_slice;
use parking_lot::RwLock; use parking_lot::RwLock;
@ -249,13 +249,20 @@ pub struct ComponentExample {
pub variant_name: SharedString, pub variant_name: SharedString,
pub description: Option<SharedString>, pub description: Option<SharedString>,
pub element: AnyElement, pub element: AnyElement,
pub width: Option<Pixels>,
} }
impl RenderOnce for ComponentExample { impl RenderOnce for ComponentExample {
fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement { fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
div() div()
.pt_2() .pt_2()
.w_full() .map(|this| {
if let Some(width) = self.width {
this.w(width)
} else {
this.w_full()
}
})
.flex() .flex()
.flex_col() .flex_col()
.gap_3() .gap_3()
@ -306,6 +313,7 @@ impl ComponentExample {
variant_name: variant_name.into(), variant_name: variant_name.into(),
element, element,
description: None, description: None,
width: None,
} }
} }
@ -313,6 +321,11 @@ impl ComponentExample {
self.description = Some(description.into()); self.description = Some(description.into());
self self
} }
pub fn width(mut self, width: Pixels) -> Self {
self.width = Some(width);
self
}
} }
/// A group of component examples. /// A group of component examples.
@ -320,6 +333,7 @@ impl ComponentExample {
pub struct ComponentExampleGroup { pub struct ComponentExampleGroup {
pub title: Option<SharedString>, pub title: Option<SharedString>,
pub examples: Vec<ComponentExample>, pub examples: Vec<ComponentExample>,
pub width: Option<Pixels>,
pub grow: bool, pub grow: bool,
pub vertical: bool, pub vertical: bool,
} }
@ -330,7 +344,13 @@ impl RenderOnce for ComponentExampleGroup {
.flex_col() .flex_col()
.text_sm() .text_sm()
.text_color(cx.theme().colors().text_muted) .text_color(cx.theme().colors().text_muted)
.w_full() .map(|this| {
if let Some(width) = self.width {
this.w(width)
} else {
this.w_full()
}
})
.when_some(self.title, |this, title| { .when_some(self.title, |this, title| {
this.gap_4().child( this.gap_4().child(
div() div()
@ -373,6 +393,7 @@ impl ComponentExampleGroup {
Self { Self {
title: None, title: None,
examples, examples,
width: None,
grow: false, grow: false,
vertical: false, vertical: false,
} }
@ -381,10 +402,15 @@ impl ComponentExampleGroup {
Self { Self {
title: Some(title.into()), title: Some(title.into()),
examples, examples,
width: None,
grow: false, grow: false,
vertical: false, vertical: false,
} }
} }
pub fn width(mut self, width: Pixels) -> Self {
self.width = Some(width);
self
}
pub fn grow(mut self) -> Self { pub fn grow(mut self) -> Self {
self.grow = true; self.grow = true;
self self
@ -402,6 +428,10 @@ pub fn single_example(
ComponentExample::new(variant_name, example) ComponentExample::new(variant_name, example)
} }
pub fn empty_example(variant_name: impl Into<SharedString>) -> ComponentExample {
ComponentExample::new(variant_name, div().w_full().text_center().items_center().text_xs().opacity(0.4).child("This space is intentionally left blank. It indicates a case that should render nothing.").into_any_element())
}
pub fn example_group(examples: Vec<ComponentExample>) -> ComponentExampleGroup { pub fn example_group(examples: Vec<ComponentExample>) -> ComponentExampleGroup {
ComponentExampleGroup::new(examples) ComponentExampleGroup::new(examples)
} }

View file

@ -110,7 +110,6 @@ struct ComponentPreview {
active_page: PreviewPage, active_page: PreviewPage,
components: Vec<ComponentMetadata>, components: Vec<ComponentMetadata>,
component_list: ListState, component_list: ListState,
agent_previews: Vec<ComponentId>,
cursor_index: usize, cursor_index: usize,
language_registry: Arc<LanguageRegistry>, language_registry: Arc<LanguageRegistry>,
workspace: WeakEntity<Workspace>, workspace: WeakEntity<Workspace>,
@ -179,9 +178,6 @@ impl ComponentPreview {
}, },
); );
// Initialize agent previews
let agent_previews = agent::all_agent_previews();
let mut component_preview = Self { let mut component_preview = Self {
workspace_id: None, workspace_id: None,
focus_handle: cx.focus_handle(), focus_handle: cx.focus_handle(),
@ -195,7 +191,6 @@ impl ComponentPreview {
component_map: components().0, component_map: components().0,
components: sorted_components, components: sorted_components,
component_list, component_list,
agent_previews,
cursor_index: selected_index, cursor_index: selected_index,
filter_editor, filter_editor,
filter_text: String::new(), filter_text: String::new(),
@ -707,38 +702,22 @@ impl ComponentPreview {
} }
} }
fn render_active_thread( fn render_active_thread(&self, cx: &mut Context<Self>) -> impl IntoElement {
&self,
window: &mut Window,
cx: &mut Context<Self>,
) -> impl IntoElement {
v_flex() v_flex()
.id("render-active-thread") .id("render-active-thread")
.size_full() .size_full()
.child( .child(
v_flex().children(self.agent_previews.iter().filter_map(|component_id| { div()
if let (Some(thread_store), Some(active_thread)) = ( .mx_auto()
self.thread_store.as_ref().map(|ts| ts.downgrade()), .w(px(640.))
self.active_thread.clone(), .h_full()
) { .py_8()
agent::get_agent_preview( .bg(cx.theme().colors().panel_background)
component_id, .children(self.active_thread.clone().map(|thread| thread.clone()))
self.workspace.clone(), .when_none(&self.active_thread.clone(), |this| {
active_thread, this.child("No active thread")
thread_store, }),
window,
cx,
)
.map(|element| div().child(element))
} else {
None
}
})),
) )
.children(self.active_thread.clone().map(|thread| thread.clone()))
.when_none(&self.active_thread.clone(), |this| {
this.child("No active thread")
})
.into_any_element() .into_any_element()
} }
@ -852,7 +831,7 @@ impl Render for ComponentPreview {
.render_component_page(&id, window, cx) .render_component_page(&id, window, cx)
.into_any_element(), .into_any_element(),
PreviewPage::ActiveThread => { PreviewPage::ActiveThread => {
self.render_active_thread(window, cx).into_any_element() self.render_active_thread(cx).into_any_element()
} }
}), }),
) )

View file

@ -1,6 +1,7 @@
mod avatar; mod avatar;
mod banner; mod banner;
mod button; mod button;
mod callout;
mod content_group; mod content_group;
mod context_menu; mod context_menu;
mod disclosure; mod disclosure;
@ -41,6 +42,7 @@ mod stories;
pub use avatar::*; pub use avatar::*;
pub use banner::*; pub use banner::*;
pub use button::*; pub use button::*;
pub use callout::*;
pub use content_group::*; pub use content_group::*;
pub use context_menu::*; pub use context_menu::*;
pub use disclosure::*; pub use disclosure::*;

View file

@ -0,0 +1,162 @@
use crate::prelude::*;
use gpui::ClickEvent;
#[derive(IntoElement, RegisterComponent)]
pub struct Callout {
title: SharedString,
message: Option<SharedString>,
icon: Icon,
cta_label: SharedString,
cta_action: Box<dyn Fn(&ClickEvent, &mut Window, &mut App) + 'static>,
line_height: Option<Pixels>,
}
impl Callout {
pub fn single_line(
title: SharedString,
icon: Icon,
cta_label: SharedString,
cta_action: Box<dyn Fn(&ClickEvent, &mut Window, &mut App) + 'static>,
) -> Self {
Self {
title,
message: None,
icon,
cta_label,
cta_action,
line_height: None,
}
}
pub fn multi_line(
title: SharedString,
message: SharedString,
icon: Icon,
cta_label: SharedString,
cta_action: Box<dyn Fn(&ClickEvent, &mut Window, &mut App) + 'static>,
) -> Self {
Self {
title,
message: Some(message),
icon,
cta_label,
cta_action,
line_height: None,
}
}
pub fn line_height(mut self, line_height: Pixels) -> Self {
self.line_height = Some(line_height);
self
}
}
impl RenderOnce for Callout {
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
let line_height = self.line_height.unwrap_or(window.line_height());
h_flex()
.p_2()
.gap_2()
.w_full()
.items_center()
.justify_between()
.bg(cx.theme().colors().panel_background)
.border_t_1()
.border_color(cx.theme().colors().border)
.overflow_x_hidden()
.child(
h_flex()
.flex_shrink()
.overflow_hidden()
.gap_2()
.items_start()
.child(
h_flex()
.h(line_height)
.items_center()
.justify_center()
.child(self.icon),
)
.child(
v_flex()
.flex_shrink()
.overflow_hidden()
.child(
h_flex()
.h(line_height)
.items_center()
.child(Label::new(self.title).size(LabelSize::Small)),
)
.when_some(self.message, |this, message| {
this.child(
div()
.w_full()
.flex_1()
.child(message)
.text_ui_sm(cx)
.text_color(cx.theme().colors().text_muted),
)
}),
),
)
.child(
div().flex_none().child(
Button::new("cta", self.cta_label)
.on_click(self.cta_action)
.style(ButtonStyle::Filled)
.label_size(LabelSize::Small),
),
)
}
}
impl Component for Callout {
fn scope() -> ComponentScope {
ComponentScope::Notification
}
fn description() -> Option<&'static str> {
Some(
"Used to display a callout for situations where the user needs to know some information, and likely make a decision. This might be a thread running out of tokens, or running out of prompts on a plan and needing to upgrade.",
)
}
fn preview(_window: &mut Window, _cx: &mut App) -> Option<AnyElement> {
let callout_examples = vec![
single_example(
"Single Line",
Callout::single_line(
"Your settings contain deprecated values, please update them.".into(),
Icon::new(IconName::Warning)
.color(Color::Warning)
.size(IconSize::Small),
"Backup & Update".into(),
Box::new(|_, _, _| {}),
)
.into_any_element(),
)
.width(px(580.)),
single_example(
"Multi Line",
Callout::multi_line(
"Thread reached the token limit".into(),
"Start a new thread from a summary to continue the conversation.".into(),
Icon::new(IconName::X)
.color(Color::Error)
.size(IconSize::Small),
"Start New Thread".into(),
Box::new(|_, _, _| {}),
)
.into_any_element(),
)
.width(px(580.)),
];
Some(
example_group(callout_examples)
.vertical()
.into_any_element(),
)
}
}