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

View file

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

View file

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

View file

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

View file

@ -1323,14 +1323,10 @@ impl MessageEditor {
token_usage_ratio: TokenUsageRatio, token_usage_ratio: TokenUsageRatio,
cx: &mut Context<Self>, cx: &mut Context<Self>,
) -> Option<Div> { ) -> Option<Div> {
let icon = if token_usage_ratio == TokenUsageRatio::Exceeded { let (icon, severity) = if token_usage_ratio == TokenUsageRatio::Exceeded {
Icon::new(IconName::Close) (IconName::Close, Severity::Error)
.color(Color::Error)
.size(IconSize::XSmall)
} else { } else {
Icon::new(IconName::Warning) (IconName::Warning, Severity::Warning)
.color(Color::Warning)
.size(IconSize::XSmall)
}; };
let title = if token_usage_ratio == TokenUsageRatio::Exceeded { let title = if token_usage_ratio == TokenUsageRatio::Exceeded {
@ -1345,30 +1341,34 @@ impl MessageEditor {
"To continue, start a new thread from a summary." "To continue, start a new thread from a summary."
}; };
let mut callout = Callout::new() let callout = Callout::new()
.line_height(line_height) .line_height(line_height)
.severity(severity)
.icon(icon) .icon(icon)
.title(title) .title(title)
.description(description) .description(description)
.primary_action( .actions_slot(
Button::new("start-new-thread", "Start New Thread") h_flex()
.label_size(LabelSize::Small) .gap_0p5()
.on_click(cx.listener(|this, _, window, cx| { .when(self.is_using_zed_provider(cx), |this| {
let from_thread_id = Some(this.thread.read(cx).id().clone()); this.child(
window.dispatch_action(Box::new(NewThread { from_thread_id }), cx); 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( Some(
div() div()
.border_t_1() .border_t_1()

View file

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

View file

@ -17,6 +17,6 @@ impl RenderOnce for YoungAccountBanner {
div() div()
.max_w_full() .max_w_full()
.my_1() .my_1()
.child(Banner::new().severity(ui::Severity::Warning).child(label)) .child(Banner::new().severity(Severity::Warning).child(label))
} }
} }

View file

@ -21,7 +21,7 @@ impl Global for GlobalLanguageModelRegistry {}
pub enum ConfigurationError { pub enum ConfigurationError {
#[error("Configure at least one LLM provider to start using the panel.")] #[error("Configure at least one LLM provider to start using the panel.")]
NoProvider, NoProvider,
#[error("LLM Provider is not configured or does not support the configured model.")] #[error("LLM provider is not configured or does not support the configured model.")]
ModelNotFound, ModelNotFound,
#[error("{} LLM provider is not configured.", .0.name().0)] #[error("{} LLM provider is not configured.", .0.name().0)]
ProviderNotAuthenticated(Arc<dyn LanguageModelProvider>), ProviderNotAuthenticated(Arc<dyn LanguageModelProvider>),

View file

@ -2021,21 +2021,21 @@ impl RenderOnce for SyntaxHighlightedText {
#[derive(PartialEq)] #[derive(PartialEq)]
struct InputError { struct InputError {
severity: ui::Severity, severity: Severity,
content: SharedString, content: SharedString,
} }
impl InputError { impl InputError {
fn warning(message: impl Into<SharedString>) -> Self { fn warning(message: impl Into<SharedString>) -> Self {
Self { Self {
severity: ui::Severity::Warning, severity: Severity::Warning,
content: message.into(), content: message.into(),
} }
} }
fn error(message: anyhow::Error) -> Self { fn error(message: anyhow::Error) -> Self {
Self { Self {
severity: ui::Severity::Error, severity: Severity::Error,
content: message.to_string().into(), content: message.to_string().into(),
} }
} }
@ -2162,9 +2162,11 @@ impl KeybindingEditorModal {
} }
fn set_error(&mut self, error: InputError, cx: &mut Context<Self>) -> bool { fn set_error(&mut self, error: InputError, cx: &mut Context<Self>) -> bool {
if self.error.as_ref().is_some_and(|old_error| { if self
old_error.severity == ui::Severity::Warning && *old_error == error .error
}) { .as_ref()
.is_some_and(|old_error| old_error.severity == Severity::Warning && *old_error == error)
{
false false
} else { } else {
self.error = Some(error); self.error = Some(error);

View file

@ -1,15 +1,6 @@
use crate::prelude::*; use crate::prelude::*;
use gpui::{AnyElement, IntoElement, ParentElement, Styled}; use gpui::{AnyElement, IntoElement, ParentElement, Styled};
/// Severity levels that determine the style of the banner.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Severity {
Info,
Success,
Warning,
Error,
}
/// Banners provide informative and brief messages without interrupting the user. /// Banners provide informative and brief messages without interrupting the user.
/// This component offers four severity levels that can be used depending on the message. /// This component offers four severity levels that can be used depending on the message.
/// ///

View file

@ -1,7 +1,13 @@
use gpui::{AnyElement, Hsla}; use gpui::AnyElement;
use crate::prelude::*; use crate::prelude::*;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum BorderPosition {
Top,
Bottom,
}
/// A callout component for displaying important information that requires user attention. /// A callout component for displaying important information that requires user attention.
/// ///
/// # Usage Example /// # Usage Example
@ -10,42 +16,48 @@ use crate::prelude::*;
/// use ui::{Callout}; /// use ui::{Callout};
/// ///
/// Callout::new() /// Callout::new()
/// .icon(Icon::new(IconName::Warning).color(Color::Warning)) /// .severity(Severity::Warning)
/// .icon(IconName::Warning)
/// .title(Label::new("Be aware of your subscription!")) /// .title(Label::new("Be aware of your subscription!"))
/// .description(Label::new("Your subscription is about to expire. Renew now!")) /// .description(Label::new("Your subscription is about to expire. Renew now!"))
/// .primary_action(Button::new("renew", "Renew Now")) /// .actions_slot(Button::new("renew", "Renew Now"))
/// .secondary_action(Button::new("remind", "Remind Me Later"))
/// ``` /// ```
/// ///
#[derive(IntoElement, RegisterComponent)] #[derive(IntoElement, RegisterComponent)]
pub struct Callout { pub struct Callout {
icon: Option<Icon>, severity: Severity,
icon: Option<IconName>,
title: Option<SharedString>, title: Option<SharedString>,
description: Option<SharedString>, description: Option<SharedString>,
primary_action: Option<AnyElement>, actions_slot: Option<AnyElement>,
secondary_action: Option<AnyElement>, dismiss_action: Option<AnyElement>,
tertiary_action: Option<AnyElement>,
line_height: Option<Pixels>, line_height: Option<Pixels>,
bg_color: Option<Hsla>, border_position: BorderPosition,
} }
impl Callout { impl Callout {
/// Creates a new `Callout` component with default styling. /// Creates a new `Callout` component with default styling.
pub fn new() -> Self { pub fn new() -> Self {
Self { Self {
severity: Severity::Info,
icon: None, icon: None,
title: None, title: None,
description: None, description: None,
primary_action: None, actions_slot: None,
secondary_action: None, dismiss_action: None,
tertiary_action: None,
line_height: None, line_height: None,
bg_color: None, border_position: BorderPosition::Top,
} }
} }
/// Sets the severity of the callout.
pub fn severity(mut self, severity: Severity) -> Self {
self.severity = severity;
self
}
/// Sets the icon to display in the callout. /// Sets the icon to display in the callout.
pub fn icon(mut self, icon: Icon) -> Self { pub fn icon(mut self, icon: IconName) -> Self {
self.icon = Some(icon); self.icon = Some(icon);
self self
} }
@ -64,20 +76,14 @@ impl Callout {
} }
/// Sets the primary call-to-action button. /// Sets the primary call-to-action button.
pub fn primary_action(mut self, action: impl IntoElement) -> Self { pub fn actions_slot(mut self, action: impl IntoElement) -> Self {
self.primary_action = Some(action.into_any_element()); self.actions_slot = 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 self
} }
/// Sets an optional tertiary call-to-action button. /// Sets an optional tertiary call-to-action button.
pub fn tertiary_action(mut self, action: impl IntoElement) -> Self { pub fn dismiss_action(mut self, action: impl IntoElement) -> Self {
self.tertiary_action = Some(action.into_any_element()); self.dismiss_action = Some(action.into_any_element());
self self
} }
@ -87,9 +93,9 @@ impl Callout {
self self
} }
/// Sets a custom background color for the callout content. /// Sets the border position in the callout.
pub fn bg_color(mut self, color: Hsla) -> Self { pub fn border_position(mut self, border_position: BorderPosition) -> Self {
self.bg_color = Some(color); self.border_position = border_position;
self self
} }
} }
@ -97,21 +103,51 @@ impl Callout {
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 let has_actions = self.actions_slot.is_some() || self.dismiss_action.is_some();
.unwrap_or(cx.theme().colors().panel_background);
let has_actions = self.primary_action.is_some() let (icon, icon_color, bg_color) = match self.severity {
|| self.secondary_action.is_some() Severity::Info => (
|| self.tertiary_action.is_some(); IconName::Info,
Color::Muted,
cx.theme().colors().panel_background.opacity(0.),
),
Severity::Success => (
IconName::Check,
Color::Success,
cx.theme().status().success.opacity(0.1),
),
Severity::Warning => (
IconName::Warning,
Color::Warning,
cx.theme().status().warning_background.opacity(0.2),
),
Severity::Error => (
IconName::XCircle,
Color::Error,
cx.theme().status().error.opacity(0.08),
),
};
h_flex() h_flex()
.min_w_0()
.p_2() .p_2()
.gap_2() .gap_2()
.items_start() .items_start()
.map(|this| match self.border_position {
BorderPosition::Top => this.border_t_1(),
BorderPosition::Bottom => this.border_b_1(),
})
.border_color(cx.theme().colors().border)
.bg(bg_color) .bg(bg_color)
.overflow_x_hidden() .overflow_x_hidden()
.when_some(self.icon, |this, icon| { .when(self.icon.is_some(), |this| {
this.child(h_flex().h(line_height).justify_center().child(icon)) this.child(
h_flex()
.h(line_height)
.justify_center()
.child(Icon::new(icon).size(IconSize::Small).color(icon_color)),
)
}) })
.child( .child(
v_flex() v_flex()
@ -119,10 +155,11 @@ impl RenderOnce for Callout {
.w_full() .w_full()
.child( .child(
h_flex() h_flex()
.h(line_height) .min_h(line_height)
.w_full() .w_full()
.gap_1() .gap_1()
.justify_between() .justify_between()
.flex_wrap()
.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)))
}) })
@ -130,13 +167,10 @@ impl RenderOnce for Callout {
this.child( this.child(
h_flex() h_flex()
.gap_0p5() .gap_0p5()
.when_some(self.tertiary_action, |this, action| { .when_some(self.actions_slot, |this, action| {
this.child(action) this.child(action)
}) })
.when_some(self.secondary_action, |this, action| { .when_some(self.dismiss_action, |this, action| {
this.child(action)
})
.when_some(self.primary_action, |this, action| {
this.child(action) this.child(action)
}), }),
) )
@ -168,84 +202,101 @@ impl Component for Callout {
} }
fn preview(_window: &mut Window, _cx: &mut App) -> Option<AnyElement> { fn preview(_window: &mut Window, _cx: &mut App) -> Option<AnyElement> {
let callout_examples = vec![ let single_action = || Button::new("got-it", "Got it").label_size(LabelSize::Small);
let multiple_actions = || {
h_flex()
.gap_0p5()
.child(Button::new("update", "Backup & Update").label_size(LabelSize::Small))
.child(Button::new("dismiss", "Dismiss").label_size(LabelSize::Small))
};
let basic_examples = vec![
single_example( single_example(
"Simple with Title Only", "Simple with Title Only",
Callout::new() Callout::new()
.icon( .icon(IconName::Info)
Icon::new(IconName::Info)
.color(Color::Accent)
.size(IconSize::Small),
)
.title("System maintenance scheduled for tonight") .title("System maintenance scheduled for tonight")
.primary_action(Button::new("got-it", "Got it").label_size(LabelSize::Small)) .actions_slot(single_action())
.into_any_element(), .into_any_element(),
) )
.width(px(580.)), .width(px(580.)),
single_example( single_example(
"With Title and Description", "With Title and Description",
Callout::new() Callout::new()
.icon( .icon(IconName::Warning)
Icon::new(IconName::Warning)
.color(Color::Warning)
.size(IconSize::Small),
)
.title("Your settings contain deprecated values") .title("Your settings contain deprecated values")
.description( .description(
"We'll backup your current settings and update them to the new format.", "We'll backup your current settings and update them to the new format.",
) )
.primary_action( .actions_slot(single_action())
Button::new("update", "Backup & Update").label_size(LabelSize::Small),
)
.secondary_action(
Button::new("dismiss", "Dismiss").label_size(LabelSize::Small),
)
.into_any_element(), .into_any_element(),
) )
.width(px(580.)), .width(px(580.)),
single_example( single_example(
"Error with Multiple Actions", "Error with Multiple Actions",
Callout::new() Callout::new()
.icon( .icon(IconName::Close)
Icon::new(IconName::Close)
.color(Color::Error)
.size(IconSize::Small),
)
.title("Thread reached the token limit") .title("Thread reached the token limit")
.description("Start a new thread from a summary to continue the conversation.") .description("Start a new thread from a summary to continue the conversation.")
.primary_action( .actions_slot(multiple_actions())
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(), .into_any_element(),
) )
.width(px(580.)), .width(px(580.)),
single_example( single_example(
"Multi-line Description", "Multi-line Description",
Callout::new() Callout::new()
.icon( .icon(IconName::Sparkle)
Icon::new(IconName::Sparkle)
.color(Color::Accent)
.size(IconSize::Small),
)
.title("Upgrade to Pro") .title("Upgrade to Pro")
.description("• Unlimited threads\n• Priority support\n• Advanced analytics") .description("• Unlimited threads\n• Priority support\n• Advanced analytics")
.primary_action( .actions_slot(multiple_actions())
Button::new("upgrade", "Upgrade Now").label_size(LabelSize::Small),
)
.secondary_action(
Button::new("learn-more", "Learn More").label_size(LabelSize::Small),
)
.into_any_element(), .into_any_element(),
) )
.width(px(580.)), .width(px(580.)),
]; ];
let severity_examples = vec![
single_example(
"Info",
Callout::new()
.icon(IconName::Info)
.title("System maintenance scheduled for tonight")
.actions_slot(single_action())
.into_any_element(),
),
single_example(
"Warning",
Callout::new()
.severity(Severity::Warning)
.icon(IconName::Triangle)
.title("System maintenance scheduled for tonight")
.actions_slot(single_action())
.into_any_element(),
),
single_example(
"Error",
Callout::new()
.severity(Severity::Error)
.icon(IconName::XCircle)
.title("System maintenance scheduled for tonight")
.actions_slot(single_action())
.into_any_element(),
),
single_example(
"Success",
Callout::new()
.severity(Severity::Success)
.icon(IconName::Check)
.title("System maintenance scheduled for tonight")
.actions_slot(single_action())
.into_any_element(),
),
];
Some( Some(
example_group(callout_examples) v_flex()
.vertical() .gap_4()
.child(example_group(basic_examples).vertical())
.child(example_group_with_title("Severity", severity_examples).vertical())
.into_any_element(), .into_any_element(),
) )
} }

View file

@ -14,7 +14,9 @@ pub use ui_macros::RegisterComponent;
pub use crate::DynamicSpacing; pub use crate::DynamicSpacing;
pub use crate::animation::{AnimationDirection, AnimationDuration, DefaultAnimations}; pub use crate::animation::{AnimationDirection, AnimationDuration, DefaultAnimations};
pub use crate::styles::{PlatformStyle, StyledTypography, TextSize, rems_from_px, vh, vw}; pub use crate::styles::{
PlatformStyle, Severity, StyledTypography, TextSize, rems_from_px, vh, vw,
};
pub use crate::traits::clickable::*; pub use crate::traits::clickable::*;
pub use crate::traits::disableable::*; pub use crate::traits::disableable::*;
pub use crate::traits::fixed::*; pub use crate::traits::fixed::*;

View file

@ -3,6 +3,7 @@ mod appearance;
mod color; mod color;
mod elevation; mod elevation;
mod platform; mod platform;
mod severity;
mod spacing; mod spacing;
mod typography; mod typography;
mod units; mod units;
@ -11,6 +12,7 @@ pub use appearance::*;
pub use color::*; pub use color::*;
pub use elevation::*; pub use elevation::*;
pub use platform::*; pub use platform::*;
pub use severity::*;
pub use spacing::*; pub use spacing::*;
pub use typography::*; pub use typography::*;
pub use units::*; pub use units::*;

View file

@ -0,0 +1,10 @@
/// Severity levels that determine the style of the component.
/// Usually, it affects the background. Most of the time,
/// it also follows with an icon corresponding the severity level.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Severity {
Info,
Success,
Warning,
Error,
}