From 42f788185a568b43fa6845968bff59a73cd1fbfd Mon Sep 17 00:00:00 2001 From: Danilo Leal <67129314+danilo-leal@users.noreply.github.com> Date: Tue, 1 Jul 2025 09:00:20 -0300 Subject: [PATCH] agent: Use callout for displaying errors instead of toasts (#33680) This PR makes all errors in the agent panel to use the `Callout` component instead of toasts. Reason for that is because the toasts obscured part of the panel's UI, which wasn't ideal. We can also be more expressive here with a background color, which I think helps with parsing the message. Release Notes: - agent: Improved how we display errors in the panel. --- crates/agent_ui/src/agent_panel.rs | 266 ++++++++++++---------------- crates/ui/src/components/callout.rs | 63 ++++--- 2 files changed, 155 insertions(+), 174 deletions(-) diff --git a/crates/agent_ui/src/agent_panel.rs b/crates/agent_ui/src/agent_panel.rs index 978a4a4f27..5f58e0bd8d 100644 --- a/crates/agent_ui/src/agent_panel.rs +++ b/crates/agent_ui/src/agent_panel.rs @@ -41,7 +41,7 @@ use editor::{Anchor, AnchorRangeExt as _, Editor, EditorEvent, MultiBuffer}; use fs::Fs; use gpui::{ Action, Animation, AnimationExt as _, AnyElement, App, AsyncWindowContext, ClipboardItem, - Corner, DismissEvent, Entity, EventEmitter, ExternalPaths, FocusHandle, Focusable, FontWeight, + Corner, DismissEvent, Entity, EventEmitter, ExternalPaths, FocusHandle, Focusable, Hsla, KeyContext, Pixels, Subscription, Task, UpdateGlobal, WeakEntity, linear_color_stop, linear_gradient, prelude::*, pulsating_between, }; @@ -59,7 +59,7 @@ use theme::ThemeSettings; use time::UtcOffset; use ui::utils::WithRemSize; use ui::{ - Banner, CheckboxWithLabel, ContextMenu, ElevationIndex, KeyBinding, PopoverMenu, + Banner, Callout, CheckboxWithLabel, ContextMenu, ElevationIndex, KeyBinding, PopoverMenu, PopoverMenuHandle, ProgressBar, Tab, Tooltip, Vector, VectorName, prelude::*, }; use util::ResultExt as _; @@ -2689,58 +2689,90 @@ impl AgentPanel { Some(div().px_2().pb_2().child(banner).into_any_element()) } + fn create_copy_button(&self, message: impl Into) -> impl IntoElement { + let message = message.into(); + + IconButton::new("copy", IconName::Copy) + .icon_size(IconSize::Small) + .icon_color(Color::Muted) + .tooltip(Tooltip::text("Copy Error Message")) + .on_click(move |_, _, cx| { + cx.write_to_clipboard(ClipboardItem::new_string(message.clone())) + }) + } + + fn dismiss_error_button( + &self, + thread: &Entity, + cx: &mut Context, + ) -> impl IntoElement { + IconButton::new("dismiss", IconName::Close) + .icon_size(IconSize::Small) + .icon_color(Color::Muted) + .tooltip(Tooltip::text("Dismiss Error")) + .on_click(cx.listener({ + let thread = thread.clone(); + move |_, _, _, cx| { + thread.update(cx, |this, _cx| { + this.clear_last_error(); + }); + + cx.notify(); + } + })) + } + + fn upgrade_button( + &self, + thread: &Entity, + cx: &mut Context, + ) -> impl IntoElement { + Button::new("upgrade", "Upgrade") + .label_size(LabelSize::Small) + .style(ButtonStyle::Tinted(ui::TintColor::Accent)) + .on_click(cx.listener({ + let thread = thread.clone(); + move |_, _, _, cx| { + thread.update(cx, |this, _cx| { + this.clear_last_error(); + }); + + cx.open_url(&zed_urls::account_url(cx)); + cx.notify(); + } + })) + } + + fn error_callout_bg(&self, cx: &Context) -> Hsla { + cx.theme().status().error.opacity(0.08) + } + fn render_payment_required_error( &self, thread: &Entity, cx: &mut Context, ) -> AnyElement { - const ERROR_MESSAGE: &str = "Free tier exceeded. Subscribe and add payment to continue using Zed LLMs. You'll be billed at cost for tokens used."; + const ERROR_MESSAGE: &str = + "You reached your free usage limit. Upgrade to Zed Pro for more prompts."; - v_flex() - .gap_0p5() - .child( - h_flex() - .gap_1p5() - .items_center() - .child(Icon::new(IconName::XCircle).color(Color::Error)) - .child(Label::new("Free Usage Exceeded").weight(FontWeight::MEDIUM)), - ) - .child( - div() - .id("error-message") - .max_h_24() - .overflow_y_scroll() - .child(Label::new(ERROR_MESSAGE)), - ) - .child( - h_flex() - .justify_end() - .mt_1() - .gap_1() - .child(self.create_copy_button(ERROR_MESSAGE)) - .child(Button::new("subscribe", "Subscribe").on_click(cx.listener({ - let thread = thread.clone(); - move |_, _, _, cx| { - thread.update(cx, |this, _cx| { - this.clear_last_error(); - }); + let icon = Icon::new(IconName::XCircle) + .size(IconSize::Small) + .color(Color::Error); - cx.open_url(&zed_urls::account_url(cx)); - cx.notify(); - } - }))) - .child(Button::new("dismiss", "Dismiss").on_click(cx.listener({ - let thread = thread.clone(); - move |_, _, _, cx| { - thread.update(cx, |this, _cx| { - this.clear_last_error(); - }); - - cx.notify(); - } - }))), + div() + .border_t_1() + .border_color(cx.theme().colors().border) + .child( + Callout::new() + .icon(icon) + .title("Free Usage Exceeded") + .description(ERROR_MESSAGE) + .tertiary_action(self.upgrade_button(thread, cx)) + .secondary_action(self.create_copy_button(ERROR_MESSAGE)) + .primary_action(self.dismiss_error_button(thread, cx)) + .bg_color(self.error_callout_bg(cx)), ) - .into_any() + .into_any_element() } fn render_model_request_limit_reached_error( @@ -2750,67 +2782,28 @@ impl AgentPanel { cx: &mut Context, ) -> AnyElement { let error_message = match plan { - Plan::ZedPro => { - "Model request limit reached. Upgrade to usage-based billing for more requests." - } - Plan::ZedProTrial => { - "Model request limit reached. Upgrade to Zed Pro for more requests." - } - Plan::Free => "Model request limit reached. Upgrade to Zed Pro for more requests.", - }; - let call_to_action = match plan { - Plan::ZedPro => "Upgrade to usage-based billing", - Plan::ZedProTrial => "Upgrade to Zed Pro", - Plan::Free => "Upgrade to Zed Pro", + Plan::ZedPro => "Upgrade to usage-based billing for more prompts.", + Plan::ZedProTrial | Plan::Free => "Upgrade to Zed Pro for more prompts.", }; - v_flex() - .gap_0p5() - .child( - h_flex() - .gap_1p5() - .items_center() - .child(Icon::new(IconName::XCircle).color(Color::Error)) - .child(Label::new("Model Request Limit Reached").weight(FontWeight::MEDIUM)), - ) - .child( - div() - .id("error-message") - .max_h_24() - .overflow_y_scroll() - .child(Label::new(error_message)), - ) - .child( - h_flex() - .justify_end() - .mt_1() - .gap_1() - .child(self.create_copy_button(error_message)) - .child( - Button::new("subscribe", call_to_action).on_click(cx.listener({ - let thread = thread.clone(); - move |_, _, _, cx| { - thread.update(cx, |this, _cx| { - this.clear_last_error(); - }); + let icon = Icon::new(IconName::XCircle) + .size(IconSize::Small) + .color(Color::Error); - cx.open_url(&zed_urls::account_url(cx)); - cx.notify(); - } - })), - ) - .child(Button::new("dismiss", "Dismiss").on_click(cx.listener({ - let thread = thread.clone(); - move |_, _, _, cx| { - thread.update(cx, |this, _cx| { - this.clear_last_error(); - }); - - cx.notify(); - } - }))), + div() + .border_t_1() + .border_color(cx.theme().colors().border) + .child( + Callout::new() + .icon(icon) + .title("Model Prompt Limit Reached") + .description(error_message) + .tertiary_action(self.upgrade_button(thread, cx)) + .secondary_action(self.create_copy_button(error_message)) + .primary_action(self.dismiss_error_button(thread, cx)) + .bg_color(self.error_callout_bg(cx)), ) - .into_any() + .into_any_element() } fn render_error_message( @@ -2821,40 +2814,24 @@ impl AgentPanel { cx: &mut Context, ) -> AnyElement { let message_with_header = format!("{}\n{}", header, message); - v_flex() - .gap_0p5() - .child( - h_flex() - .gap_1p5() - .items_center() - .child(Icon::new(IconName::XCircle).color(Color::Error)) - .child(Label::new(header).weight(FontWeight::MEDIUM)), - ) - .child( - div() - .id("error-message") - .max_h_32() - .overflow_y_scroll() - .child(Label::new(message.clone())), - ) - .child( - h_flex() - .justify_end() - .mt_1() - .gap_1() - .child(self.create_copy_button(message_with_header)) - .child(Button::new("dismiss", "Dismiss").on_click(cx.listener({ - let thread = thread.clone(); - move |_, _, _, cx| { - thread.update(cx, |this, _cx| { - this.clear_last_error(); - }); - cx.notify(); - } - }))), + let icon = Icon::new(IconName::XCircle) + .size(IconSize::Small) + .color(Color::Error); + + div() + .border_t_1() + .border_color(cx.theme().colors().border) + .child( + Callout::new() + .icon(icon) + .title(header) + .description(message.clone()) + .primary_action(self.dismiss_error_button(thread, cx)) + .secondary_action(self.create_copy_button(message_with_header)) + .bg_color(self.error_callout_bg(cx)), ) - .into_any() + .into_any_element() } fn render_prompt_editor( @@ -2999,15 +2976,6 @@ impl AgentPanel { } } - fn create_copy_button(&self, message: impl Into) -> impl IntoElement { - let message = message.into(); - IconButton::new("copy", IconName::Copy) - .on_click(move |_, _, cx| { - cx.write_to_clipboard(ClipboardItem::new_string(message.clone())) - }) - .tooltip(Tooltip::text("Copy Error Message")) - } - fn key_context(&self) -> KeyContext { let mut key_context = KeyContext::new_with_defaults(); key_context.add("AgentPanel"); @@ -3089,18 +3057,9 @@ impl Render for AgentPanel { thread.clone().into_any_element() }) .children(self.render_tool_use_limit_reached(window, cx)) - .child(h_flex().child(message_editor.clone())) .when_some(thread.read(cx).last_error(), |this, last_error| { this.child( div() - .absolute() - .right_3() - .bottom_12() - .max_w_96() - .py_2() - .px_3() - .elevation_2(cx) - .occlude() .child(match last_error { ThreadError::PaymentRequired => { self.render_payment_required_error(thread, cx) @@ -3114,6 +3073,7 @@ impl Render for AgentPanel { .into_any(), ) }) + .child(h_flex().child(message_editor.clone())) .child(self.render_drag_target(cx)), ActiveView::History => parent.child(self.history.clone()), ActiveView::TextThread { diff --git a/crates/ui/src/components/callout.rs b/crates/ui/src/components/callout.rs index b3f3758db6..d15fa122ed 100644 --- a/crates/ui/src/components/callout.rs +++ b/crates/ui/src/components/callout.rs @@ -1,4 +1,4 @@ -use gpui::AnyElement; +use gpui::{AnyElement, Hsla}; use crate::prelude::*; @@ -24,7 +24,9 @@ pub struct Callout { description: Option, primary_action: Option, secondary_action: Option, + tertiary_action: Option, line_height: Option, + bg_color: Option, } impl Callout { @@ -36,7 +38,9 @@ impl Callout { description: None, primary_action: None, secondary_action: None, + tertiary_action: None, line_height: None, + bg_color: None, } } @@ -71,64 +75,81 @@ impl Callout { self } + /// Sets an optional tertiary call-to-action button. + pub fn tertiary_action(mut self, action: impl IntoElement) -> Self { + self.tertiary_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 } + + /// Sets a custom background color for the callout content. + pub fn bg_color(mut self, color: Hsla) -> Self { + self.bg_color = Some(color); + 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()); + let bg_color = self + .bg_color + .unwrap_or(cx.theme().colors().panel_background); + let has_actions = self.primary_action.is_some() + || self.secondary_action.is_some() + || self.tertiary_action.is_some(); h_flex() - .w_full() .p_2() .gap_2() .items_start() - .bg(cx.theme().colors().panel_background) + .bg(bg_color) .overflow_x_hidden() .when_some(self.icon, |this, icon| { this.child(h_flex().h(line_height).justify_center().child(icon)) }) .child( v_flex() + .min_w_0() .w_full() .child( h_flex() .h(line_height) .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_0p5() - .when_some(self.secondary_action, |this, action| { - this.child(action) - }) - .when_some(self.primary_action, |this, action| { - this.child(action) - }), - ) - }, - ), + .when(has_actions, |this| { + this.child( + h_flex() + .gap_0p5() + .when_some(self.tertiary_action, |this, action| { + this.child(action) + }) + .when_some(self.secondary_action, |this, action| { + this.child(action) + }) + .when_some(self.primary_action, |this, action| { + this.child(action) + }), + ) + }), ) .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), + .text_color(cx.theme().colors().text_muted) + .child(description), ) }), )