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.
This commit is contained in:
Danilo Leal 2025-07-01 09:00:20 -03:00 committed by GitHub
parent a5b2428897
commit 42f788185a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 155 additions and 174 deletions

View file

@ -41,7 +41,7 @@ use editor::{Anchor, AnchorRangeExt as _, Editor, EditorEvent, MultiBuffer};
use fs::Fs; use fs::Fs;
use gpui::{ use gpui::{
Action, Animation, AnimationExt as _, AnyElement, App, AsyncWindowContext, ClipboardItem, 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, KeyContext, Pixels, Subscription, Task, UpdateGlobal, WeakEntity, linear_color_stop,
linear_gradient, prelude::*, pulsating_between, linear_gradient, prelude::*, pulsating_between,
}; };
@ -59,7 +59,7 @@ use theme::ThemeSettings;
use time::UtcOffset; use time::UtcOffset;
use ui::utils::WithRemSize; use ui::utils::WithRemSize;
use ui::{ use ui::{
Banner, CheckboxWithLabel, ContextMenu, ElevationIndex, KeyBinding, PopoverMenu, Banner, Callout, CheckboxWithLabel, ContextMenu, ElevationIndex, KeyBinding, PopoverMenu,
PopoverMenuHandle, ProgressBar, Tab, Tooltip, Vector, VectorName, prelude::*, PopoverMenuHandle, ProgressBar, Tab, Tooltip, Vector, VectorName, prelude::*,
}; };
use util::ResultExt as _; use util::ResultExt as _;
@ -2689,58 +2689,90 @@ impl AgentPanel {
Some(div().px_2().pb_2().child(banner).into_any_element()) 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( fn render_payment_required_error(
&self, &self,
thread: &Entity<ActiveThread>, thread: &Entity<ActiveThread>,
cx: &mut Context<Self>, cx: &mut Context<Self>,
) -> AnyElement { ) -> 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() let icon = Icon::new(IconName::XCircle)
.gap_0p5() .size(IconSize::Small)
.child( .color(Color::Error);
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();
});
cx.open_url(&zed_urls::account_url(cx)); div()
cx.notify(); .border_t_1()
} .border_color(cx.theme().colors().border)
}))) .child(
.child(Button::new("dismiss", "Dismiss").on_click(cx.listener({ Callout::new()
let thread = thread.clone(); .icon(icon)
move |_, _, _, cx| { .title("Free Usage Exceeded")
thread.update(cx, |this, _cx| { .description(ERROR_MESSAGE)
this.clear_last_error(); .tertiary_action(self.upgrade_button(thread, cx))
}); .secondary_action(self.create_copy_button(ERROR_MESSAGE))
.primary_action(self.dismiss_error_button(thread, cx))
cx.notify(); .bg_color(self.error_callout_bg(cx)),
}
}))),
) )
.into_any() .into_any_element()
} }
fn render_model_request_limit_reached_error( fn render_model_request_limit_reached_error(
@ -2750,67 +2782,28 @@ impl AgentPanel {
cx: &mut Context<Self>, cx: &mut Context<Self>,
) -> AnyElement { ) -> AnyElement {
let error_message = match plan { let error_message = match plan {
Plan::ZedPro => { Plan::ZedPro => "Upgrade to usage-based billing for more prompts.",
"Model request limit reached. Upgrade to usage-based billing for more requests." Plan::ZedProTrial | Plan::Free => "Upgrade to Zed Pro for more prompts.",
}
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",
}; };
v_flex() let icon = Icon::new(IconName::XCircle)
.gap_0p5() .size(IconSize::Small)
.child( .color(Color::Error);
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)); div()
cx.notify(); .border_t_1()
} .border_color(cx.theme().colors().border)
})), .child(
) Callout::new()
.child(Button::new("dismiss", "Dismiss").on_click(cx.listener({ .icon(icon)
let thread = thread.clone(); .title("Model Prompt Limit Reached")
move |_, _, _, cx| { .description(error_message)
thread.update(cx, |this, _cx| { .tertiary_action(self.upgrade_button(thread, cx))
this.clear_last_error(); .secondary_action(self.create_copy_button(error_message))
}); .primary_action(self.dismiss_error_button(thread, cx))
.bg_color(self.error_callout_bg(cx)),
cx.notify();
}
}))),
) )
.into_any() .into_any_element()
} }
fn render_error_message( fn render_error_message(
@ -2821,40 +2814,24 @@ impl AgentPanel {
cx: &mut Context<Self>, cx: &mut Context<Self>,
) -> AnyElement { ) -> AnyElement {
let message_with_header = format!("{}\n{}", header, message); 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( 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 { fn key_context(&self) -> KeyContext {
let mut key_context = KeyContext::new_with_defaults(); let mut key_context = KeyContext::new_with_defaults();
key_context.add("AgentPanel"); key_context.add("AgentPanel");
@ -3089,18 +3057,9 @@ impl Render for AgentPanel {
thread.clone().into_any_element() thread.clone().into_any_element()
}) })
.children(self.render_tool_use_limit_reached(window, cx)) .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| { .when_some(thread.read(cx).last_error(), |this, last_error| {
this.child( this.child(
div() div()
.absolute()
.right_3()
.bottom_12()
.max_w_96()
.py_2()
.px_3()
.elevation_2(cx)
.occlude()
.child(match last_error { .child(match last_error {
ThreadError::PaymentRequired => { ThreadError::PaymentRequired => {
self.render_payment_required_error(thread, cx) self.render_payment_required_error(thread, cx)
@ -3114,6 +3073,7 @@ impl Render for AgentPanel {
.into_any(), .into_any(),
) )
}) })
.child(h_flex().child(message_editor.clone()))
.child(self.render_drag_target(cx)), .child(self.render_drag_target(cx)),
ActiveView::History => parent.child(self.history.clone()), ActiveView::History => parent.child(self.history.clone()),
ActiveView::TextThread { ActiveView::TextThread {

View file

@ -1,4 +1,4 @@
use gpui::AnyElement; use gpui::{AnyElement, Hsla};
use crate::prelude::*; use crate::prelude::*;
@ -24,7 +24,9 @@ pub struct Callout {
description: Option<SharedString>, description: Option<SharedString>,
primary_action: Option<AnyElement>, primary_action: Option<AnyElement>,
secondary_action: Option<AnyElement>, secondary_action: Option<AnyElement>,
tertiary_action: Option<AnyElement>,
line_height: Option<Pixels>, line_height: Option<Pixels>,
bg_color: Option<Hsla>,
} }
impl Callout { impl Callout {
@ -36,7 +38,9 @@ impl Callout {
description: None, description: None,
primary_action: None, primary_action: None,
secondary_action: None, secondary_action: None,
tertiary_action: None,
line_height: None, line_height: None,
bg_color: None,
} }
} }
@ -71,64 +75,81 @@ impl Callout {
self 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. /// Sets a custom line height for the callout content.
pub fn line_height(mut self, line_height: Pixels) -> Self { pub fn line_height(mut self, line_height: Pixels) -> Self {
self.line_height = Some(line_height); self.line_height = Some(line_height);
self 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 { impl RenderOnce for Callout {
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
let line_height = self.line_height.unwrap_or(window.line_height()); 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() h_flex()
.w_full()
.p_2() .p_2()
.gap_2() .gap_2()
.items_start() .items_start()
.bg(cx.theme().colors().panel_background) .bg(bg_color)
.overflow_x_hidden() .overflow_x_hidden()
.when_some(self.icon, |this, icon| { .when_some(self.icon, |this, icon| {
this.child(h_flex().h(line_height).justify_center().child(icon)) this.child(h_flex().h(line_height).justify_center().child(icon))
}) })
.child( .child(
v_flex() v_flex()
.min_w_0()
.w_full() .w_full()
.child( .child(
h_flex() h_flex()
.h(line_height) .h(line_height)
.w_full() .w_full()
.gap_1() .gap_1()
.flex_wrap()
.justify_between() .justify_between()
.when_some(self.title, |this, title| { .when_some(self.title, |this, title| {
this.child(h_flex().child(Label::new(title).size(LabelSize::Small))) this.child(h_flex().child(Label::new(title).size(LabelSize::Small)))
}) })
.when( .when(has_actions, |this| {
self.primary_action.is_some() || self.secondary_action.is_some(), this.child(
|this| { h_flex()
this.child( .gap_0p5()
h_flex() .when_some(self.tertiary_action, |this, action| {
.gap_0p5() this.child(action)
.when_some(self.secondary_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.primary_action, |this, action| {
}), this.child(action)
) }),
}, )
), }),
) )
.when_some(self.description, |this, description| { .when_some(self.description, |this, description| {
this.child( this.child(
div() div()
.w_full() .w_full()
.flex_1() .flex_1()
.child(description)
.text_ui_sm(cx) .text_ui_sm(cx)
.text_color(cx.theme().colors().text_muted), .text_color(cx.theme().colors().text_muted)
.child(description),
) )
}), }),
) )