From c1096d8b631b414580c0184fc21a9431e931e743 Mon Sep 17 00:00:00 2001 From: Danilo Leal <67129314+danilo-leal@users.noreply.github.com> Date: Fri, 14 Nov 2025 12:12:47 -0300 Subject: [PATCH] agent_ui: Render error descriptions as markdown in thread view callouts (#42732) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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: Screenshot 2025-11-14 at 11  43@2x Release Notes: - agent: Improved the interaction with errors by allowing links to be clickable. --- crates/agent_ui/src/acp/thread_view.rs | 43 +++++++++++++++------ crates/language_model/src/language_model.rs | 2 +- crates/ui/src/components/callout.rs | 39 ++++++++++++++----- 3 files changed, 62 insertions(+), 22 deletions(-) diff --git a/crates/agent_ui/src/acp/thread_view.rs b/crates/agent_ui/src/acp/thread_view.rs index 8a0b282d9b..f4c76b1057 100644 --- a/crates/agent_ui/src/acp/thread_view.rs +++ b/crates/agent_ui/src/acp/thread_view.rs @@ -278,6 +278,7 @@ pub struct AcpThreadView { notification_subscriptions: HashMap, Vec>, thread_retry_status: Option, thread_error: Option, + thread_error_markdown: Option>, thread_feedback: ThreadFeedbackState, list_state: ListState, auth_task: Option>, @@ -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.thread_error = None; + self.thread_error_markdown = None; cx.notify(); } @@ -5344,9 +5348,9 @@ impl AcpThreadView { } } - fn render_thread_error(&self, cx: &mut Context) -> Option
{ + fn render_thread_error(&mut self, window: &mut Window, cx: &mut Context) -> Option
{ 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) -> 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.), diff --git a/crates/language_model/src/language_model.rs b/crates/language_model/src/language_model.rs index 4f0eed3433..606b0921b2 100644 --- a/crates/language_model/src/language_model.rs +++ b/crates/language_model/src/language_model.rs @@ -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, diff --git a/crates/ui/src/components/callout.rs b/crates/ui/src/components/callout.rs index b5d1d7f255..4eb849d7f6 100644 --- a/crates/ui/src/components/callout.rs +++ b/crates/ui/src/components/callout.rs @@ -30,6 +30,7 @@ pub struct Callout { icon: Option, title: Option, description: Option, + description_slot: Option, actions_slot: Option, dismiss_action: Option, line_height: Option, @@ -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 + } }), ) }