agent: Use callout for displaying errors instead of toasts (#33680)

Danilo Leal created

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.

Change summary

crates/agent_ui/src/agent_panel.rs  | 266 +++++++++++++-----------------
crates/ui/src/components/callout.rs |  63 ++++--
2 files changed, 155 insertions(+), 174 deletions(-)

Detailed changes

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<String>) -> 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<ActiveThread>,
+        cx: &mut Context<Self>,
+    ) -> 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<ActiveThread>,
+        cx: &mut Context<Self>,
+    ) -> 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<Self>) -> Hsla {
+        cx.theme().status().error.opacity(0.08)
+    }
+
     fn render_payment_required_error(
         &self,
         thread: &Entity<ActiveThread>,
         cx: &mut Context<Self>,
     ) -> 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.";
-
-        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();
-                            });
+        const ERROR_MESSAGE: &str =
+            "You reached your free usage limit. Upgrade to Zed Pro for more prompts.";
 
-                            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();
-                            });
+        let icon = Icon::new(IconName::XCircle)
+            .size(IconSize::Small)
+            .color(Color::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<Self>,
     ) -> 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();
-                                });
-
-                                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();
-                            });
+        let icon = Icon::new(IconName::XCircle)
+            .size(IconSize::Small)
+            .color(Color::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<Self>,
     ) -> 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<String>) -> 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 {

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<SharedString>,
     primary_action: Option<AnyElement>,
     secondary_action: Option<AnyElement>,
+    tertiary_action: Option<AnyElement>,
     line_height: Option<Pixels>,
+    bg_color: Option<Hsla>,
 }
 
 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),
                         )
                     }),
             )