agent: Improve error and warnings display (#36425)

This PR refactors the callout component and improves how we display
errors and warnings in the agent panel, along with improvements for
specific cases (e.g., you have `zed.dev` as your LLM provider and is
signed out).

Still a work in progress, though, wrapping up some details.

Release Notes:

- N/A
This commit is contained in:
Danilo Leal 2025-08-18 21:44:07 -03:00 committed by GitHub
parent b578031120
commit b7edc89a87
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 436 additions and 394 deletions

View file

@ -3259,44 +3259,33 @@ impl AcpThreadView {
}
};
Some(
div()
.border_t_1()
.border_color(cx.theme().colors().border)
.child(content),
)
Some(div().child(content))
}
fn render_any_thread_error(&self, error: SharedString, cx: &mut Context<'_, Self>) -> Callout {
let icon = Icon::new(IconName::XCircle)
.size(IconSize::Small)
.color(Color::Error);
Callout::new()
.icon(icon)
.severity(Severity::Error)
.title("Error")
.description(error.clone())
.secondary_action(self.create_copy_button(error.to_string()))
.primary_action(self.dismiss_error_button(cx))
.bg_color(self.error_callout_bg(cx))
.actions_slot(self.create_copy_button(error.to_string()))
.dismiss_action(self.dismiss_error_button(cx))
}
fn render_payment_required_error(&self, cx: &mut Context<Self>) -> Callout {
const ERROR_MESSAGE: &str =
"You reached your free usage limit. Upgrade to Zed Pro for more prompts.";
let icon = Icon::new(IconName::XCircle)
.size(IconSize::Small)
.color(Color::Error);
Callout::new()
.icon(icon)
.severity(Severity::Error)
.title("Free Usage Exceeded")
.description(ERROR_MESSAGE)
.tertiary_action(self.upgrade_button(cx))
.secondary_action(self.create_copy_button(ERROR_MESSAGE))
.primary_action(self.dismiss_error_button(cx))
.bg_color(self.error_callout_bg(cx))
.actions_slot(
h_flex()
.gap_0p5()
.child(self.upgrade_button(cx))
.child(self.create_copy_button(ERROR_MESSAGE)),
)
.dismiss_action(self.dismiss_error_button(cx))
}
fn render_model_request_limit_reached_error(
@ -3311,18 +3300,17 @@ impl AcpThreadView {
}
};
let icon = Icon::new(IconName::XCircle)
.size(IconSize::Small)
.color(Color::Error);
Callout::new()
.icon(icon)
.severity(Severity::Error)
.title("Model Prompt Limit Reached")
.description(error_message)
.tertiary_action(self.upgrade_button(cx))
.secondary_action(self.create_copy_button(error_message))
.primary_action(self.dismiss_error_button(cx))
.bg_color(self.error_callout_bg(cx))
.actions_slot(
h_flex()
.gap_0p5()
.child(self.upgrade_button(cx))
.child(self.create_copy_button(error_message)),
)
.dismiss_action(self.dismiss_error_button(cx))
}
fn render_tool_use_limit_reached_error(
@ -3338,52 +3326,59 @@ impl AcpThreadView {
let focus_handle = self.focus_handle(cx);
let icon = Icon::new(IconName::Info)
.size(IconSize::Small)
.color(Color::Info);
Some(
Callout::new()
.icon(icon)
.icon(IconName::Info)
.title("Consecutive tool use limit reached.")
.when(supports_burn_mode, |this| {
this.secondary_action(
Button::new("continue-burn-mode", "Continue with Burn Mode")
.style(ButtonStyle::Filled)
.style(ButtonStyle::Tinted(ui::TintColor::Accent))
.layer(ElevationIndex::ModalSurface)
.label_size(LabelSize::Small)
.key_binding(
KeyBinding::for_action_in(
&ContinueWithBurnMode,
&focus_handle,
window,
cx,
)
.map(|kb| kb.size(rems_from_px(10.))),
.actions_slot(
h_flex()
.gap_0p5()
.when(supports_burn_mode, |this| {
this.child(
Button::new("continue-burn-mode", "Continue with Burn Mode")
.style(ButtonStyle::Filled)
.style(ButtonStyle::Tinted(ui::TintColor::Accent))
.layer(ElevationIndex::ModalSurface)
.label_size(LabelSize::Small)
.key_binding(
KeyBinding::for_action_in(
&ContinueWithBurnMode,
&focus_handle,
window,
cx,
)
.map(|kb| kb.size(rems_from_px(10.))),
)
.tooltip(Tooltip::text(
"Enable Burn Mode for unlimited tool use.",
))
.on_click({
cx.listener(move |this, _, _window, cx| {
thread.update(cx, |thread, _cx| {
thread.set_completion_mode(CompletionMode::Burn);
});
this.resume_chat(cx);
})
}),
)
.tooltip(Tooltip::text("Enable Burn Mode for unlimited tool use."))
.on_click({
cx.listener(move |this, _, _window, cx| {
thread.update(cx, |thread, _cx| {
thread.set_completion_mode(CompletionMode::Burn);
});
})
.child(
Button::new("continue-conversation", "Continue")
.layer(ElevationIndex::ModalSurface)
.label_size(LabelSize::Small)
.key_binding(
KeyBinding::for_action_in(
&ContinueThread,
&focus_handle,
window,
cx,
)
.map(|kb| kb.size(rems_from_px(10.))),
)
.on_click(cx.listener(|this, _, _window, cx| {
this.resume_chat(cx);
})
}),
)
})
.primary_action(
Button::new("continue-conversation", "Continue")
.layer(ElevationIndex::ModalSurface)
.label_size(LabelSize::Small)
.key_binding(
KeyBinding::for_action_in(&ContinueThread, &focus_handle, window, cx)
.map(|kb| kb.size(rems_from_px(10.))),
)
.on_click(cx.listener(|this, _, _window, cx| {
this.resume_chat(cx);
})),
})),
),
),
)
}
@ -3424,10 +3419,6 @@ impl AcpThreadView {
}
}))
}
fn error_callout_bg(&self, cx: &Context<Self>) -> Hsla {
cx.theme().status().error.opacity(0.08)
}
}
impl Focusable for AcpThreadView {

View file

@ -2597,7 +2597,7 @@ impl ActiveThread {
.id(("message-container", ix))
.py_1()
.px_2p5()
.child(Banner::new().severity(ui::Severity::Warning).child(message))
.child(Banner::new().severity(Severity::Warning).child(message))
}
fn render_message_thinking_segment(

View file

@ -454,7 +454,7 @@ impl Render for AddLlmProviderModal {
this.section(
Section::new().child(
Banner::new()
.severity(ui::Severity::Warning)
.severity(Severity::Warning)
.child(div().text_xs().child(error)),
),
)

View file

@ -48,9 +48,8 @@ use feature_flags::{self, FeatureFlagAppExt};
use fs::Fs;
use gpui::{
Action, Animation, AnimationExt as _, AnyElement, App, AsyncWindowContext, ClipboardItem,
Corner, DismissEvent, Entity, EventEmitter, ExternalPaths, FocusHandle, Focusable, Hsla,
KeyContext, Pixels, Subscription, Task, UpdateGlobal, WeakEntity, prelude::*,
pulsating_between,
Corner, DismissEvent, Entity, EventEmitter, ExternalPaths, FocusHandle, Focusable, KeyContext,
Pixels, Subscription, Task, UpdateGlobal, WeakEntity, prelude::*, pulsating_between,
};
use language::LanguageRegistry;
use language_model::{
@ -2712,20 +2711,22 @@ impl AgentPanel {
action_slot: Option<AnyElement>,
cx: &mut Context<Self>,
) -> impl IntoElement {
h_flex()
.mt_2()
.pl_1p5()
.pb_1()
.w_full()
.justify_between()
.border_b_1()
.border_color(cx.theme().colors().border_variant)
.child(
Label::new(label.into())
.size(LabelSize::Small)
.color(Color::Muted),
)
.children(action_slot)
div().pl_1().pr_1p5().child(
h_flex()
.mt_2()
.pl_1p5()
.pb_1()
.w_full()
.justify_between()
.border_b_1()
.border_color(cx.theme().colors().border_variant)
.child(
Label::new(label.into())
.size(LabelSize::Small)
.color(Color::Muted),
)
.children(action_slot),
)
}
fn render_thread_empty_state(
@ -2831,22 +2832,12 @@ impl AgentPanel {
}),
),
)
})
.when_some(configuration_error.as_ref(), |this, err| {
this.child(self.render_configuration_error(
err,
&focus_handle,
window,
cx,
))
}),
)
})
.when(!recent_history.is_empty(), |parent| {
let focus_handle = focus_handle.clone();
parent
.overflow_hidden()
.p_1p5()
.justify_end()
.gap_1()
.child(
@ -2874,10 +2865,11 @@ impl AgentPanel {
),
)
.child(
v_flex()
.gap_1()
.children(recent_history.into_iter().enumerate().map(
|(index, entry)| {
v_flex().p_1().pr_1p5().gap_1().children(
recent_history
.into_iter()
.enumerate()
.map(|(index, entry)| {
// TODO: Add keyboard navigation.
let is_hovered =
self.hovered_recent_history_item == Some(index);
@ -2896,30 +2888,68 @@ impl AgentPanel {
},
))
.into_any_element()
},
)),
}),
),
)
.when_some(configuration_error.as_ref(), |this, err| {
this.child(self.render_configuration_error(err, &focus_handle, window, cx))
})
})
.when_some(configuration_error.as_ref(), |this, err| {
this.child(self.render_configuration_error(false, err, &focus_handle, window, cx))
})
}
fn render_configuration_error(
&self,
border_bottom: bool,
configuration_error: &ConfigurationError,
focus_handle: &FocusHandle,
window: &mut Window,
cx: &mut App,
) -> impl IntoElement {
match configuration_error {
ConfigurationError::ModelNotFound
| ConfigurationError::ProviderNotAuthenticated(_)
| ConfigurationError::NoProvider => Banner::new()
.severity(ui::Severity::Warning)
.child(Label::new(configuration_error.to_string()))
.action_slot(
Button::new("settings", "Configure Provider")
let zed_provider_configured = AgentSettings::get_global(cx)
.default_model
.as_ref()
.map_or(false, |selection| {
selection.provider.0.as_str() == "zed.dev"
});
let callout = if zed_provider_configured {
Callout::new()
.icon(IconName::Warning)
.severity(Severity::Warning)
.when(border_bottom, |this| {
this.border_position(ui::BorderPosition::Bottom)
})
.title("Sign in to continue using Zed as your LLM provider.")
.actions_slot(
Button::new("sign_in", "Sign In")
.style(ButtonStyle::Tinted(ui::TintColor::Warning))
.label_size(LabelSize::Small)
.on_click({
let workspace = self.workspace.clone();
move |_, _, cx| {
let Ok(client) =
workspace.update(cx, |workspace, _| workspace.client().clone())
else {
return;
};
cx.spawn(async move |cx| {
client.sign_in_with_optional_connect(true, cx).await
})
.detach_and_log_err(cx);
}
}),
)
} else {
Callout::new()
.icon(IconName::Warning)
.severity(Severity::Warning)
.when(border_bottom, |this| {
this.border_position(ui::BorderPosition::Bottom)
})
.title(configuration_error.to_string())
.actions_slot(
Button::new("settings", "Configure")
.style(ButtonStyle::Tinted(ui::TintColor::Warning))
.label_size(LabelSize::Small)
.key_binding(
@ -2929,16 +2959,23 @@ impl AgentPanel {
.on_click(|_event, window, cx| {
window.dispatch_action(OpenSettings.boxed_clone(), cx)
}),
),
)
};
match configuration_error {
ConfigurationError::ModelNotFound
| ConfigurationError::ProviderNotAuthenticated(_)
| ConfigurationError::NoProvider => callout.into_any_element(),
ConfigurationError::ProviderPendingTermsAcceptance(provider) => {
Banner::new().severity(ui::Severity::Warning).child(
h_flex().w_full().children(
Banner::new()
.severity(Severity::Warning)
.child(h_flex().w_full().children(
provider.render_accept_terms(
LanguageModelProviderTosView::ThreadEmptyState,
cx,
),
),
)
))
.into_any_element()
}
}
}
@ -2970,7 +3007,7 @@ impl AgentPanel {
let focus_handle = self.focus_handle(cx);
let banner = Banner::new()
.severity(ui::Severity::Info)
.severity(Severity::Info)
.child(Label::new("Consecutive tool use limit reached.").size(LabelSize::Small))
.action_slot(
h_flex()
@ -3081,10 +3118,6 @@ impl AgentPanel {
}))
}
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>,
@ -3093,23 +3126,18 @@ impl AgentPanel {
const ERROR_MESSAGE: &str =
"You reached your free usage limit. Upgrade to Zed Pro for more prompts.";
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("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)),
Callout::new()
.severity(Severity::Error)
.icon(IconName::XCircle)
.title("Free Usage Exceeded")
.description(ERROR_MESSAGE)
.actions_slot(
h_flex()
.gap_0p5()
.child(self.upgrade_button(thread, cx))
.child(self.create_copy_button(ERROR_MESSAGE)),
)
.dismiss_action(self.dismiss_error_button(thread, cx))
.into_any_element()
}
@ -3124,23 +3152,37 @@ impl AgentPanel {
Plan::ZedProTrial | Plan::ZedFree => "Upgrade to Zed Pro for more prompts.",
};
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("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)),
Callout::new()
.severity(Severity::Error)
.title("Model Prompt Limit Reached")
.description(error_message)
.actions_slot(
h_flex()
.gap_0p5()
.child(self.upgrade_button(thread, cx))
.child(self.create_copy_button(error_message)),
)
.dismiss_action(self.dismiss_error_button(thread, cx))
.into_any_element()
}
fn render_retry_button(&self, thread: &Entity<ActiveThread>) -> AnyElement {
Button::new("retry", "Retry")
.icon(IconName::RotateCw)
.icon_position(IconPosition::Start)
.icon_size(IconSize::Small)
.label_size(LabelSize::Small)
.on_click({
let thread = thread.clone();
move |_, window, cx| {
thread.update(cx, |thread, cx| {
thread.clear_last_error();
thread.thread().update(cx, |thread, cx| {
thread.retry_last_completion(Some(window.window_handle()), cx);
});
});
}
})
.into_any_element()
}
@ -3153,40 +3195,18 @@ impl AgentPanel {
) -> AnyElement {
let message_with_header = format!("{}\n{}", header, message);
let icon = Icon::new(IconName::XCircle)
.size(IconSize::Small)
.color(Color::Error);
let retry_button = Button::new("retry", "Retry")
.icon(IconName::RotateCw)
.icon_position(IconPosition::Start)
.icon_size(IconSize::Small)
.label_size(LabelSize::Small)
.on_click({
let thread = thread.clone();
move |_, window, cx| {
thread.update(cx, |thread, cx| {
thread.clear_last_error();
thread.thread().update(cx, |thread, cx| {
thread.retry_last_completion(Some(window.window_handle()), cx);
});
});
}
});
div()
.border_t_1()
.border_color(cx.theme().colors().border)
.child(
Callout::new()
.icon(icon)
.title(header)
.description(message.clone())
.primary_action(retry_button)
.secondary_action(self.dismiss_error_button(thread, cx))
.tertiary_action(self.create_copy_button(message_with_header))
.bg_color(self.error_callout_bg(cx)),
Callout::new()
.severity(Severity::Error)
.icon(IconName::XCircle)
.title(header)
.description(message.clone())
.actions_slot(
h_flex()
.gap_0p5()
.child(self.render_retry_button(thread))
.child(self.create_copy_button(message_with_header)),
)
.dismiss_action(self.dismiss_error_button(thread, cx))
.into_any_element()
}
@ -3195,60 +3215,39 @@ impl AgentPanel {
message: SharedString,
can_enable_burn_mode: bool,
thread: &Entity<ActiveThread>,
cx: &mut Context<Self>,
) -> AnyElement {
let icon = Icon::new(IconName::XCircle)
.size(IconSize::Small)
.color(Color::Error);
let retry_button = Button::new("retry", "Retry")
.icon(IconName::RotateCw)
.icon_position(IconPosition::Start)
.icon_size(IconSize::Small)
.label_size(LabelSize::Small)
.on_click({
let thread = thread.clone();
move |_, window, cx| {
thread.update(cx, |thread, cx| {
thread.clear_last_error();
thread.thread().update(cx, |thread, cx| {
thread.retry_last_completion(Some(window.window_handle()), cx);
});
});
}
});
let mut callout = Callout::new()
.icon(icon)
Callout::new()
.severity(Severity::Error)
.title("Error")
.description(message.clone())
.bg_color(self.error_callout_bg(cx))
.primary_action(retry_button);
if can_enable_burn_mode {
let burn_mode_button = Button::new("enable_burn_retry", "Enable Burn Mode and Retry")
.icon(IconName::ZedBurnMode)
.icon_position(IconPosition::Start)
.icon_size(IconSize::Small)
.label_size(LabelSize::Small)
.on_click({
let thread = thread.clone();
move |_, window, cx| {
thread.update(cx, |thread, cx| {
thread.clear_last_error();
thread.thread().update(cx, |thread, cx| {
thread.enable_burn_mode_and_retry(Some(window.window_handle()), cx);
});
});
}
});
callout = callout.secondary_action(burn_mode_button);
}
div()
.border_t_1()
.border_color(cx.theme().colors().border)
.child(callout)
.actions_slot(
h_flex()
.gap_0p5()
.when(can_enable_burn_mode, |this| {
this.child(
Button::new("enable_burn_retry", "Enable Burn Mode and Retry")
.icon(IconName::ZedBurnMode)
.icon_position(IconPosition::Start)
.icon_size(IconSize::Small)
.label_size(LabelSize::Small)
.on_click({
let thread = thread.clone();
move |_, window, cx| {
thread.update(cx, |thread, cx| {
thread.clear_last_error();
thread.thread().update(cx, |thread, cx| {
thread.enable_burn_mode_and_retry(
Some(window.window_handle()),
cx,
);
});
});
}
}),
)
})
.child(self.render_retry_button(thread)),
)
.into_any_element()
}
@ -3503,7 +3502,6 @@ impl Render for AgentPanel {
message,
can_enable_burn_mode,
thread,
cx,
),
})
.into_any(),
@ -3531,16 +3529,13 @@ impl Render for AgentPanel {
if !self.should_render_onboarding(cx)
&& let Some(err) = configuration_error.as_ref()
{
this.child(
div().bg(cx.theme().colors().editor_background).p_2().child(
self.render_configuration_error(
err,
&self.focus_handle(cx),
window,
cx,
),
),
)
this.child(self.render_configuration_error(
true,
err,
&self.focus_handle(cx),
window,
cx,
))
} else {
this
}

View file

@ -1323,14 +1323,10 @@ impl MessageEditor {
token_usage_ratio: TokenUsageRatio,
cx: &mut Context<Self>,
) -> Option<Div> {
let icon = if token_usage_ratio == TokenUsageRatio::Exceeded {
Icon::new(IconName::Close)
.color(Color::Error)
.size(IconSize::XSmall)
let (icon, severity) = if token_usage_ratio == TokenUsageRatio::Exceeded {
(IconName::Close, Severity::Error)
} else {
Icon::new(IconName::Warning)
.color(Color::Warning)
.size(IconSize::XSmall)
(IconName::Warning, Severity::Warning)
};
let title = if token_usage_ratio == TokenUsageRatio::Exceeded {
@ -1345,30 +1341,34 @@ impl MessageEditor {
"To continue, start a new thread from a summary."
};
let mut callout = Callout::new()
let callout = Callout::new()
.line_height(line_height)
.severity(severity)
.icon(icon)
.title(title)
.description(description)
.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);
})),
.actions_slot(
h_flex()
.gap_0p5()
.when(self.is_using_zed_provider(cx), |this| {
this.child(
IconButton::new("burn-mode-callout", IconName::ZedBurnMode)
.icon_size(IconSize::XSmall)
.on_click(cx.listener(|this, _event, window, cx| {
this.toggle_burn_mode(&ToggleBurnMode, window, cx);
})),
)
})
.child(
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);
})),
),
);
if self.is_using_zed_provider(cx) {
callout = callout.secondary_action(
IconButton::new("burn-mode-callout", IconName::ZedBurnMode)
.icon_size(IconSize::XSmall)
.on_click(cx.listener(|this, _event, window, cx| {
this.toggle_burn_mode(&ToggleBurnMode, window, cx);
})),
);
}
Some(
div()
.border_t_1()

View file

@ -80,14 +80,10 @@ impl RenderOnce for UsageCallout {
}
};
let icon = if is_limit_reached {
Icon::new(IconName::Close)
.color(Color::Error)
.size(IconSize::XSmall)
let (icon, severity) = if is_limit_reached {
(IconName::Close, Severity::Error)
} else {
Icon::new(IconName::Warning)
.color(Color::Warning)
.size(IconSize::XSmall)
(IconName::Warning, Severity::Warning)
};
div()
@ -95,10 +91,12 @@ impl RenderOnce for UsageCallout {
.border_color(cx.theme().colors().border)
.child(
Callout::new()
.icon(icon)
.severity(severity)
.icon(icon)
.title(title)
.description(message)
.primary_action(
.actions_slot(
Button::new("upgrade", button_text)
.label_size(LabelSize::Small)
.on_click(move |_, _, cx| {