agent: Add UI for upsell scenarios (#29805)

Nate Butler and Marshall Bowers created

Release Notes:

- N/A

---------

Co-authored-by: Marshall Bowers <git@maxdeviant.com>

Change summary

crates/agent/src/assistant.rs                     |   3 
crates/agent/src/assistant_panel.rs               |  21 -
crates/agent/src/debug.rs                         | 124 ++++++++++
crates/agent/src/message_editor.rs                | 168 ++++++++------
crates/agent/src/thread.rs                        |  12 
crates/agent/src/ui.rs                            |   4 
crates/agent/src/ui/preview.rs                    |   5 
crates/agent/src/ui/preview/agent_preview.rs      |   6 
crates/agent/src/ui/preview/usage_callouts.rs     | 204 +++++++++++++++++
crates/component/src/component.rs                 |  38 ++
crates/component_preview/src/component_preview.rs |  45 +--
crates/ui/src/components.rs                       |   2 
crates/ui/src/components/callout.rs               | 162 +++++++++++++
13 files changed, 659 insertions(+), 135 deletions(-)

Detailed changes

crates/agent/src/assistant.rs 🔗

@@ -9,6 +9,7 @@ mod context_picker;
 mod context_server_configuration;
 mod context_store;
 mod context_strip;
+mod debug;
 mod history_store;
 mod inline_assistant;
 mod inline_prompt_editor;
@@ -47,7 +48,7 @@ pub use crate::thread::{Message, MessageSegment, Thread, ThreadEvent};
 pub use crate::thread_store::ThreadStore;
 pub use agent_diff::{AgentDiffPane, AgentDiffToolbar};
 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!(
     agent,

crates/agent/src/assistant_panel.rs 🔗

@@ -50,7 +50,6 @@ use crate::message_editor::{MessageEditor, MessageEditorEvent};
 use crate::thread::{Thread, ThreadError, ThreadId, TokenUsageRatio};
 use crate::thread_history::{PastContext, PastThread, ThreadHistory};
 use crate::thread_store::ThreadStore;
-use crate::ui::UsageBanner;
 use crate::{
     AddContextServer, AgentDiffPane, DeleteRecentlyOpenThread, ExpandMessageEditor, Follow,
     InlineAssistant, NewTextThread, NewThread, OpenActiveThreadAsMarkdown, OpenAgentDiff,
@@ -432,6 +431,7 @@ impl AssistantPanel {
             MessageEditor::new(
                 fs.clone(),
                 workspace.clone(),
+                user_store.clone(),
                 message_editor_context_store.clone(),
                 prompt_store.clone(),
                 thread_store.downgrade(),
@@ -735,6 +735,7 @@ impl AssistantPanel {
             MessageEditor::new(
                 self.fs.clone(),
                 self.workspace.clone(),
+                self.user_store.clone(),
                 context_store,
                 self.prompt_store.clone(),
                 self.thread_store.downgrade(),
@@ -933,6 +934,7 @@ impl AssistantPanel {
             MessageEditor::new(
                 self.fs.clone(),
                 self.workspace.clone(),
+                self.user_store.clone(),
                 context_store,
                 self.prompt_store.clone(),
                 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> {
         let tool_use_limit_reached = self
             .thread
@@ -2277,7 +2263,6 @@ impl Render for AssistantPanel {
                 ActiveView::Thread { .. } => parent
                     .child(self.render_active_thread_or_empty_state(window, cx))
                     .children(self.render_tool_use_limit_reached(cx))
-                    .children(self.render_usage_banner(cx))
                     .child(h_flex().child(self.message_editor.clone()))
                     .children(self.render_last_error(cx)),
                 ActiveView::History => parent.child(self.history.clone()),

crates/agent/src/debug.rs 🔗

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

crates/agent/src/message_editor.rs 🔗

@@ -4,8 +4,12 @@ use std::sync::Arc;
 use crate::assistant_model_selector::{AssistantModelSelector, ModelType};
 use crate::context::{AgentContextKey, ContextCreasesAddon, ContextLoadResult, load_context};
 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 client::UserStore;
 use collections::{HashMap, HashSet};
 use editor::actions::{MoveUp, Paste};
 use editor::{
@@ -27,6 +31,7 @@ use language_model_selector::ToggleModelSelector;
 use multi_buffer;
 use project::Project;
 use prompt_store::PromptStore;
+use proto::Plan;
 use settings::Settings;
 use std::time::Duration;
 use theme::ThemeSettings;
@@ -53,6 +58,7 @@ pub struct MessageEditor {
     editor: Entity<Editor>,
     workspace: WeakEntity<Workspace>,
     project: Entity<Project>,
+    user_store: Entity<UserStore>,
     context_store: Entity<ContextStore>,
     prompt_store: Option<Entity<PromptStore>>,
     context_strip: Entity<ContextStrip>,
@@ -126,6 +132,7 @@ impl MessageEditor {
     pub fn new(
         fs: Arc<dyn Fs>,
         workspace: WeakEntity<Workspace>,
+        user_store: Entity<UserStore>,
         context_store: Entity<ContextStore>,
         prompt_store: Option<Entity<PromptStore>>,
         thread_store: WeakEntity<ThreadStore>,
@@ -188,6 +195,7 @@ impl MessageEditor {
         Self {
             editor: editor.clone(),
             project: thread.read(cx).project().clone(),
+            user_store,
             thread,
             incompatible_tools_state: incompatible_tools.clone(),
             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(
         &self,
         line_height: Pixels,
         token_usage_ratio: TokenUsageRatio,
         cx: &mut Context<Self>,
-    ) -> Div {
-        let heading = if token_usage_ratio == TokenUsageRatio::Exceeded {
+    ) -> Option<Div> {
+        if !cx.has_flag::<NewBillingFeatureFlag>() {
+            return None;
+        }
+
+        let title = if token_usage_ratio == TokenUsageRatio::Exceeded {
             "Thread reached the token limit"
         } else {
             "Thread reaching the token limit soon"
         };
 
-        h_flex()
-            .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());
+        let message = "Start a new thread from a summary to continue the conversation.";
 
-                        window.dispatch_action(Box::new(NewThread {
-                            from_thread_id
-                        }), cx);
-                    }))
-                    .icon(IconName::Plus)
-                    .icon_position(IconPosition::Start)
-                    .icon_size(IconSize::Small)
-                    .style(ButtonStyle::Tinted(ui::TintColor::Accent))
-                    .label_size(LabelSize::Small),
-            )
+        let icon = 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)
+        };
+
+        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> {
@@ -1307,8 +1308,16 @@ impl Render for MessageEditor {
                 parent.child(self.render_changed_buffers(&changed_buffers, window, cx))
             })
             .child(self.render_editor(font_size, line_height, window, cx))
-            .when(token_usage_ratio != TokenUsageRatio::Normal, |parent| {
-                parent.child(self.render_token_limit_callout(line_height, token_usage_ratio, cx))
+            .children({
+                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 {
         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 {
@@ -1364,16 +1379,18 @@ impl AgentPreview for MessageEditor {
         window: &mut Window,
         cx: &mut App,
     ) -> Option<AnyElement> {
-        if let Some(workspace_entity) = workspace.upgrade() {
-            let fs = workspace_entity.read(cx).app_state().fs.clone();
-            let weak_project = workspace_entity.read(cx).project().clone().downgrade();
+        if let Some(workspace) = workspace.upgrade() {
+            let fs = workspace.read(cx).app_state().fs.clone();
+            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 thread = active_thread.read(cx).thread().clone();
 
-            let example_message_editor = cx.new(|cx| {
+            let default_message_editor = cx.new(|cx| {
                 MessageEditor::new(
                     fs,
-                    workspace,
+                    workspace.downgrade(),
+                    user_store,
                     context_store,
                     None,
                     thread_store,
@@ -1387,8 +1404,15 @@ impl AgentPreview for MessageEditor {
                 v_flex()
                     .gap_4()
                     .children(vec![single_example(
-                        "Default",
-                        example_message_editor.clone().into_any_element(),
+                        "Default Message Editor",
+                        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(),
             )

crates/agent/src/thread.rs 🔗

@@ -355,6 +355,7 @@ pub struct Thread {
     request_token_usage: Vec<TokenUsage>,
     cumulative_token_usage: TokenUsage,
     exceeded_window_error: Option<ExceededWindowError>,
+    last_usage: Option<RequestUsage>,
     tool_use_limit_reached: bool,
     feedback: Option<ThreadFeedback>,
     message_feedback: HashMap<MessageId, ThreadFeedback>,
@@ -418,6 +419,7 @@ impl Thread {
             request_token_usage: Vec::new(),
             cumulative_token_usage: TokenUsage::default(),
             exceeded_window_error: None,
+            last_usage: None,
             tool_use_limit_reached: false,
             feedback: None,
             message_feedback: HashMap::default(),
@@ -526,6 +528,7 @@ impl Thread {
             request_token_usage: serialized.request_token_usage,
             cumulative_token_usage: serialized.cumulative_token_usage,
             exceeded_window_error: None,
+            last_usage: None,
             tool_use_limit_reached: false,
             feedback: None,
             message_feedback: HashMap::default(),
@@ -817,6 +820,10 @@ impl Thread {
             .unwrap_or(false)
     }
 
+    pub fn last_usage(&self) -> Option<RequestUsage> {
+        self.last_usage
+    }
+
     pub fn tool_use_limit_reached(&self) -> bool {
         self.tool_use_limit_reached
     }
@@ -1535,7 +1542,10 @@ impl Thread {
                                         CompletionRequestStatus::UsageUpdated {
                                             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 => {
                                             thread.tool_use_limit_reached = true;

crates/agent/src/ui.rs 🔗

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

crates/agent/src/ui/agent_preview.rs → crates/agent/src/ui/preview/agent_preview.rs 🔗

@@ -42,14 +42,14 @@ pub trait AgentPreview: Component + Sized {
 #[macro_export]
 macro_rules! register_agent_preview {
     ($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() -> (
             component::ComponentId,
-            $crate::ui::agent_preview::PreviewFn,
+            $crate::ui::preview::PreviewFn,
         ) = || {
             (
                 <$type as component::Component>::id(),
-                <$type as $crate::ui::agent_preview::AgentPreview>::agent_preview,
+                <$type as $crate::ui::preview::AgentPreview>::agent_preview,
             )
         };
     };

crates/agent/src/ui/preview/usage_callouts.rs 🔗

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

crates/component/src/component.rs 🔗

@@ -4,8 +4,8 @@ use std::sync::LazyLock;
 
 use collections::HashMap;
 use gpui::{
-    AnyElement, App, IntoElement, RenderOnce, SharedString, Window, div, pattern_slash, prelude::*,
-    px, rems,
+    AnyElement, App, IntoElement, Pixels, RenderOnce, SharedString, Window, div, pattern_slash,
+    prelude::*, px, rems,
 };
 use linkme::distributed_slice;
 use parking_lot::RwLock;
@@ -249,13 +249,20 @@ pub struct ComponentExample {
     pub variant_name: SharedString,
     pub description: Option<SharedString>,
     pub element: AnyElement,
+    pub width: Option<Pixels>,
 }
 
 impl RenderOnce for ComponentExample {
     fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
         div()
             .pt_2()
-            .w_full()
+            .map(|this| {
+                if let Some(width) = self.width {
+                    this.w(width)
+                } else {
+                    this.w_full()
+                }
+            })
             .flex()
             .flex_col()
             .gap_3()
@@ -306,6 +313,7 @@ impl ComponentExample {
             variant_name: variant_name.into(),
             element,
             description: None,
+            width: None,
         }
     }
 
@@ -313,6 +321,11 @@ impl ComponentExample {
         self.description = Some(description.into());
         self
     }
+
+    pub fn width(mut self, width: Pixels) -> Self {
+        self.width = Some(width);
+        self
+    }
 }
 
 /// A group of component examples.
@@ -320,6 +333,7 @@ impl ComponentExample {
 pub struct ComponentExampleGroup {
     pub title: Option<SharedString>,
     pub examples: Vec<ComponentExample>,
+    pub width: Option<Pixels>,
     pub grow: bool,
     pub vertical: bool,
 }
@@ -330,7 +344,13 @@ impl RenderOnce for ComponentExampleGroup {
             .flex_col()
             .text_sm()
             .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| {
                 this.gap_4().child(
                     div()
@@ -373,6 +393,7 @@ impl ComponentExampleGroup {
         Self {
             title: None,
             examples,
+            width: None,
             grow: false,
             vertical: false,
         }
@@ -381,10 +402,15 @@ impl ComponentExampleGroup {
         Self {
             title: Some(title.into()),
             examples,
+            width: None,
             grow: false,
             vertical: false,
         }
     }
+    pub fn width(mut self, width: Pixels) -> Self {
+        self.width = Some(width);
+        self
+    }
     pub fn grow(mut self) -> Self {
         self.grow = true;
         self
@@ -402,6 +428,10 @@ pub fn single_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 {
     ComponentExampleGroup::new(examples)
 }

crates/component_preview/src/component_preview.rs 🔗

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

crates/ui/src/components.rs 🔗

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

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

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