From 29f3e62850f6bb63c0a4de48a4cb0a6f0e1e453b Mon Sep 17 00:00:00 2001 From: Danilo Leal <67129314+danilo-leal@users.noreply.github.com> Date: Fri, 13 Jun 2025 10:03:32 -0300 Subject: [PATCH] ui: Refactor the Callout component (#32684) What motivated me to refactor this component was the fact that I wanted a new variant to allow having _two CTAs_ instead of just one. This variant should work with either a single or multiline description. But, given we were using a `Callout::single_line` and `Callout::multi_line` API, I'd then need to have both `Callout::single_line_one_button` and `Callout::single_line_two_buttons` type of variants, which just points to a combinatorial problem. With this refactor, the Callout now follows the same structure of the Banner component, where it's all `Callout::new` and every method is passed as if they were props in a React component, allowing for a more flexible design where you can customize button styles. Also made it slightly more robust for wrapping and removed the top border as that should be defined by the place it is being used in. Release Notes: - N/A --- crates/agent/src/message_editor.rs | 50 ++-- crates/agent/src/ui/preview/usage_callouts.rs | 33 ++- crates/ui/src/components/callout.rs | 258 +++++++++++------- 3 files changed, 212 insertions(+), 129 deletions(-) diff --git a/crates/agent/src/message_editor.rs b/crates/agent/src/message_editor.rs index 6544eee4f6d349a3387d4b51eeac71066d16e17d..824f58d88830a1e1d3ca6d92b7628dd242adb184 100644 --- a/crates/agent/src/message_editor.rs +++ b/crates/agent/src/message_editor.rs @@ -39,7 +39,7 @@ use proto::Plan; use settings::Settings; use std::time::Duration; use theme::ThemeSettings; -use ui::{Disclosure, KeyBinding, PopoverMenuHandle, Tooltip, prelude::*}; +use ui::{Callout, Disclosure, KeyBinding, PopoverMenuHandle, Tooltip, prelude::*}; use util::{ResultExt as _, maybe}; use workspace::{CollaboratorId, Workspace}; use zed_llm_client::CompletionIntent; @@ -1175,6 +1175,7 @@ impl MessageEditor { .map_or(false, |model| { model.provider.id().0 == ZED_CLOUD_PROVIDER_ID }); + if !is_using_zed_provider { return None; } @@ -1229,14 +1230,6 @@ impl MessageEditor { token_usage_ratio: TokenUsageRatio, cx: &mut Context, ) -> Option
{ - let title = if token_usage_ratio == TokenUsageRatio::Exceeded { - "Thread reached the token limit" - } else { - "Thread reaching the token limit soon" - }; - - let message = "Start a new thread from a summary to continue the conversation."; - let icon = if token_usage_ratio == TokenUsageRatio::Exceeded { Icon::new(IconName::X) .color(Color::Error) @@ -1247,19 +1240,36 @@ impl MessageEditor { .size(IconSize::XSmall) }; + let title = if token_usage_ratio == TokenUsageRatio::Exceeded { + "Thread reached the token limit" + } else { + "Thread reaching the token limit soon" + }; + Some( div() - .child(ui::Callout::multi_line( - title, - message, - icon, - "Start New Thread", - 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), + .border_t_1() + .border_color(cx.theme().colors().border) + .child( + Callout::new() + .line_height(line_height) + .icon(icon) + .title(title) + .description( + "Start a new thread from a summary to continue the conversation.", + ) + .primary_action( + Button::new("start-new-thread", "Start New Thread") + .label_size(LabelSize::Small) + .on_click(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, + ); + })), + ), + ), ) } diff --git a/crates/agent/src/ui/preview/usage_callouts.rs b/crates/agent/src/ui/preview/usage_callouts.rs index 8d15829db09986bd917723ba92db1b50aba1e1f4..62e29094612186280c2be4696feedfe03df35589 100644 --- a/crates/agent/src/ui/preview/usage_callouts.rs +++ b/crates/agent/src/ui/preview/usage_callouts.rs @@ -2,7 +2,7 @@ use client::zed_urls; 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 ui::{Callout, prelude::*}; use zed_llm_client::{Plan, UsageLimit}; #[derive(IntoElement, RegisterComponent)] @@ -91,16 +91,23 @@ impl RenderOnce for UsageCallout { .size(IconSize::XSmall) }; - Callout::multi_line( - title, - message, - icon, - button_text, - Box::new(move |_, _, cx| { - cx.open_url(&url); - }), - ) - .into_any_element() + div() + .border_t_1() + .border_color(cx.theme().colors().border) + .child( + Callout::new() + .icon(icon) + .title(title) + .description(message) + .primary_action( + Button::new("upgrade", button_text) + .label_size(LabelSize::Small) + .on_click(move |_, _, cx| { + cx.open_url(&url); + }), + ), + ) + .into_any_element() } } @@ -189,10 +196,8 @@ impl Component for UsageCallout { ); Some( - div() + v_flex() .p_4() - .flex() - .flex_col() .gap_4() .child(free_examples) .child(trial_examples) diff --git a/crates/ui/src/components/callout.rs b/crates/ui/src/components/callout.rs index 9e632ce83363e553ec0a0dce93d05dc924fd71ef..3b9b2f6c2a3135325650ca28cc3ef2f020667d4a 100644 --- a/crates/ui/src/components/callout.rs +++ b/crates/ui/src/components/callout.rs @@ -1,51 +1,77 @@ -use gpui::ClickEvent; +use gpui::AnyElement; use crate::prelude::*; +/// A callout component for displaying important information that requires user attention. +/// +/// # Usage Example +/// +/// ``` +/// use ui::{Callout}; +/// +/// Callout::new() +/// .icon(Icon::new(IconName::Warning).color(Color::Warning)) +/// .title(Label::new("Be aware of your subscription!")) +/// .description(Label::new("Your subscription is about to expire. Renew now!")) +/// .primary_action(Button::new("renew", "Renew Now")) +/// .secondary_action(Button::new("remind", "Remind Me Later")) +/// ``` +/// #[derive(IntoElement, RegisterComponent)] pub struct Callout { - title: SharedString, - message: Option, - icon: Icon, - cta_label: SharedString, - cta_action: Box, + icon: Option, + title: Option, + description: Option, + primary_action: Option, + secondary_action: Option, line_height: Option, } impl Callout { - pub fn single_line( - title: impl Into, - icon: Icon, - cta_label: impl Into, - cta_action: Box, - ) -> Self { + /// Creates a new `Callout` component with default styling. + pub fn new() -> Self { Self { - title: title.into(), - message: None, - icon, - cta_label: cta_label.into(), - cta_action, + icon: None, + title: None, + description: None, + primary_action: None, + secondary_action: None, line_height: None, } } - pub fn multi_line( - title: impl Into, - message: impl Into, - icon: Icon, - cta_label: impl Into, - cta_action: Box, - ) -> Self { - Self { - title: title.into(), - message: Some(message.into()), - icon, - cta_label: cta_label.into(), - cta_action, - line_height: None, - } + /// Sets the icon to display in the callout. + pub fn icon(mut self, icon: Icon) -> Self { + self.icon = Some(icon); + self + } + + /// Sets the title of the callout. + pub fn title(mut self, title: impl Into) -> Self { + self.title = Some(title.into()); + self + } + + /// Sets the description of the callout. + /// The description can be single or multi-line text. + pub fn description(mut self, description: impl Into) -> Self { + self.description = Some(description.into()); + self } + /// Sets the primary call-to-action button. + pub fn primary_action(mut self, action: impl IntoElement) -> Self { + self.primary_action = Some(action.into_any_element()); + self + } + + /// Sets an optional secondary call-to-action button. + pub fn secondary_action(mut self, action: impl IntoElement) -> Self { + self.secondary_action = Some(action.into_any_element()); + self + } + + /// Sets a custom line height for the callout content. pub fn line_height(mut self, line_height: Pixels) -> Self { self.line_height = Some(line_height); self @@ -57,57 +83,54 @@ impl RenderOnce for Callout { let line_height = self.line_height.unwrap_or(window.line_height()); h_flex() + .w_full() .p_2() .gap_2() - .w_full() - .items_center() - .justify_between() + .items_start() .bg(cx.theme().colors().panel_background) - .border_t_1() - .border_color(cx.theme().colors().border) .overflow_x_hidden() + .when_some(self.icon, |this, icon| { + this.child(h_flex().h(line_height).justify_center().child(icon)) + }) .child( - h_flex() - .flex_shrink() - .overflow_hidden() - .gap_2() - .items_start() + v_flex() + .w_full() .child( h_flex() .h(line_height) - .items_center() - .justify_center() - .child(self.icon), + .w_full() + .gap_1() + .flex_wrap() + .justify_between() + .when_some(self.title, |this, title| { + this.child(h_flex().child(Label::new(title).size(LabelSize::Small))) + }) + .when( + self.primary_action.is_some() || self.secondary_action.is_some(), + |this| { + this.child( + h_flex() + .gap_1() + .when_some(self.secondary_action, |this, action| { + this.child(action) + }) + .when_some(self.primary_action, |this, action| { + this.child(action) + }), + ) + }, + ), ) - .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), - ), + .when_some(self.description, |this, description| { + this.child( + div() + .w_full() + .flex_1() + .child(description) + .text_ui_sm(cx) + .text_color(cx.theme().colors().text_muted), + ) + }), ) } } @@ -126,30 +149,75 @@ impl Component for Callout { fn preview(_window: &mut Window, _cx: &mut App) -> Option { let callout_examples = vec![ single_example( - "Single Line", - Callout::single_line( - "Your settings contain deprecated values, please update them.", - Icon::new(IconName::Warning) - .color(Color::Warning) - .size(IconSize::Small), - "Backup & Update", - Box::new(|_, _, _| {}), - ) - .into_any_element(), + "Simple with Title Only", + Callout::new() + .icon( + Icon::new(IconName::Info) + .color(Color::Accent) + .size(IconSize::Small), + ) + .title("System maintenance scheduled for tonight") + .primary_action(Button::new("got-it", "Got it").label_size(LabelSize::Small)) + .into_any_element(), ) .width(px(580.)), single_example( - "Multi Line", - Callout::multi_line( - "Thread reached the token limit", - "Start a new thread from a summary to continue the conversation.", - Icon::new(IconName::X) - .color(Color::Error) - .size(IconSize::Small), - "Start New Thread", - Box::new(|_, _, _| {}), - ) - .into_any_element(), + "With Title and Description", + Callout::new() + .icon( + Icon::new(IconName::Warning) + .color(Color::Warning) + .size(IconSize::Small), + ) + .title("Your settings contain deprecated values") + .description( + "We'll backup your current settings and update them to the new format.", + ) + .primary_action( + Button::new("update", "Backup & Update").label_size(LabelSize::Small), + ) + .secondary_action( + Button::new("dismiss", "Dismiss").label_size(LabelSize::Small), + ) + .into_any_element(), + ) + .width(px(580.)), + single_example( + "Error with Multiple Actions", + Callout::new() + .icon( + Icon::new(IconName::X) + .color(Color::Error) + .size(IconSize::Small), + ) + .title("Thread reached the token limit") + .description("Start a new thread from a summary to continue the conversation.") + .primary_action( + Button::new("new-thread", "Start New Thread").label_size(LabelSize::Small), + ) + .secondary_action( + Button::new("view-summary", "View Summary").label_size(LabelSize::Small), + ) + .into_any_element(), + ) + .width(px(580.)), + single_example( + "Multi-line Description", + Callout::new() + .icon( + Icon::new(IconName::Sparkle) + .color(Color::Accent) + .size(IconSize::Small), + ) + .title("Upgrade to Pro") + .description("• Unlimited threads\n• Priority support\n• Advanced analytics") + .primary_action( + Button::new("upgrade", "Upgrade Now").label_size(LabelSize::Small), + ) + .secondary_action( + Button::new("learn-more", "Learn More").label_size(LabelSize::Small), + ) + .into_any_element(), ) .width(px(580.)), ];