agent_ui: Render error descriptions as markdown in thread view callouts (#42732)

This PR makes the description in the callout that display general errors
in the agent panel be rendered as markdown. This allow us to pass URLs
to these error strings that will be clickable, improving the overall
interaction with them. Here's an example:

<img width="500" height="396" alt="Screenshot 2025-11-14 at 11  43@2x"
src="https://github.com/user-attachments/assets/f4fc629a-6314-4da1-8c19-b60e1a09653b"
/>

Release Notes:

- agent: Improved the interaction with errors by allowing links to be
clickable.
This commit is contained in:
Danilo Leal
2025-11-14 12:12:47 -03:00
committed by GitHub
parent 092071a2f0
commit c1096d8b63
3 changed files with 62 additions and 22 deletions
+31 -12
View File
@@ -278,6 +278,7 @@ pub struct AcpThreadView {
notification_subscriptions: HashMap<WindowHandle<AgentNotification>, Vec<Subscription>>,
thread_retry_status: Option<RetryStatus>,
thread_error: Option<ThreadError>,
thread_error_markdown: Option<Entity<Markdown>>,
thread_feedback: ThreadFeedbackState,
list_state: ListState,
auth_task: Option<Task<()>>,
@@ -415,6 +416,7 @@ impl AcpThreadView {
list_state: list_state,
thread_retry_status: None,
thread_error: None,
thread_error_markdown: None,
thread_feedback: Default::default(),
auth_task: None,
expanded_tool_calls: HashSet::default(),
@@ -798,6 +800,7 @@ impl AcpThreadView {
if should_retry {
self.thread_error = None;
self.thread_error_markdown = None;
self.reset(window, cx);
}
}
@@ -1327,6 +1330,7 @@ impl AcpThreadView {
fn clear_thread_error(&mut self, cx: &mut Context<Self>) {
self.thread_error = None;
self.thread_error_markdown = None;
cx.notify();
}
@@ -5344,9 +5348,9 @@ impl AcpThreadView {
}
}
fn render_thread_error(&self, cx: &mut Context<Self>) -> Option<Div> {
fn render_thread_error(&mut self, window: &mut Window, cx: &mut Context<Self>) -> Option<Div> {
let content = match self.thread_error.as_ref()? {
ThreadError::Other(error) => self.render_any_thread_error(error.clone(), cx),
ThreadError::Other(error) => self.render_any_thread_error(error.clone(), window, cx),
ThreadError::Refusal => self.render_refusal_error(cx),
ThreadError::AuthenticationRequired(error) => {
self.render_authentication_required_error(error.clone(), cx)
@@ -5431,7 +5435,12 @@ impl AcpThreadView {
.dismiss_action(self.dismiss_error_button(cx))
}
fn render_any_thread_error(&self, error: SharedString, cx: &mut Context<'_, Self>) -> Callout {
fn render_any_thread_error(
&mut self,
error: SharedString,
window: &mut Window,
cx: &mut Context<'_, Self>,
) -> Callout {
let can_resume = self
.thread()
.map_or(false, |thread| thread.read(cx).can_resume(cx));
@@ -5444,11 +5453,24 @@ impl AcpThreadView {
supports_burn_mode && thread.completion_mode() == CompletionMode::Normal
});
let markdown = if let Some(markdown) = &self.thread_error_markdown {
markdown.clone()
} else {
let markdown = cx.new(|cx| Markdown::new(error.clone(), None, None, cx));
self.thread_error_markdown = Some(markdown.clone());
markdown
};
let markdown_style = default_markdown_style(false, true, window, cx);
let description = self
.render_markdown(markdown, markdown_style)
.into_any_element();
Callout::new()
.severity(Severity::Error)
.title("Error")
.icon(IconName::XCircle)
.description(error.clone())
.title("An Error Happened")
.description_slot(description)
.actions_slot(
h_flex()
.gap_0p5()
@@ -5467,11 +5489,9 @@ impl AcpThreadView {
})
.when(can_resume, |this| {
this.child(
Button::new("retry", "Retry")
.icon(IconName::RotateCw)
.icon_position(IconPosition::Start)
IconButton::new("retry", IconName::RotateCw)
.icon_size(IconSize::Small)
.label_size(LabelSize::Small)
.tooltip(Tooltip::text("Retry Generation"))
.on_click(cx.listener(|this, _, _window, cx| {
this.resume_chat(cx);
})),
@@ -5613,7 +5633,6 @@ impl AcpThreadView {
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()))
@@ -5623,7 +5642,6 @@ impl AcpThreadView {
fn dismiss_error_button(&self, 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({
move |this, _, _, cx| {
@@ -5841,7 +5859,7 @@ impl Render for AcpThreadView {
None
}
})
.children(self.render_thread_error(cx))
.children(self.render_thread_error(window, cx))
.when_some(
self.new_server_version_available.as_ref().filter(|_| {
!has_messages || !matches!(self.thread_state, ThreadState::Ready { .. })
@@ -5974,6 +5992,7 @@ fn default_markdown_style(
},
link: TextStyleRefinement {
background_color: Some(colors.editor_foreground.opacity(0.025)),
color: Some(colors.text_accent),
underline: Some(UnderlineStyle {
color: Some(colors.text_accent.opacity(0.5)),
thickness: px(1.),
+1 -1
View File
@@ -138,7 +138,7 @@ pub enum LanguageModelCompletionError {
provider: LanguageModelProviderName,
message: String,
},
#[error("permission error with {provider}'s API: {message}")]
#[error("Permission error with {provider}'s API: {message}")]
PermissionError {
provider: LanguageModelProviderName,
message: String,
+30 -9
View File
@@ -30,6 +30,7 @@ pub struct Callout {
icon: Option<IconName>,
title: Option<SharedString>,
description: Option<SharedString>,
description_slot: Option<AnyElement>,
actions_slot: Option<AnyElement>,
dismiss_action: Option<AnyElement>,
line_height: Option<Pixels>,
@@ -44,6 +45,7 @@ impl Callout {
icon: None,
title: None,
description: None,
description_slot: None,
actions_slot: None,
dismiss_action: None,
line_height: None,
@@ -76,6 +78,13 @@ impl Callout {
self
}
/// Allows for any element—like markdown elements—to fill the description slot of the callout.
/// This method wins over `description` if both happen to be set.
pub fn description_slot(mut self, description: impl IntoElement) -> Self {
self.description_slot = Some(description.into_any_element());
self
}
/// Sets the primary call-to-action button.
pub fn actions_slot(mut self, action: impl IntoElement) -> Self {
self.actions_slot = Some(action.into_any_element());
@@ -179,15 +188,27 @@ impl RenderOnce for Callout {
)
}),
)
.when_some(self.description, |this, description| {
this.child(
div()
.w_full()
.flex_1()
.text_ui_sm(cx)
.text_color(cx.theme().colors().text_muted)
.child(description),
)
.map(|this| {
if let Some(description_slot) = self.description_slot {
this.child(
div()
.w_full()
.flex_1()
.text_ui_sm(cx)
.child(description_slot),
)
} else if let Some(description) = self.description {
this.child(
div()
.w_full()
.flex_1()
.text_ui_sm(cx)
.text_color(cx.theme().colors().text_muted)
.child(description),
)
} else {
this
}
}),
)
}