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

Danilo Leal created

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.

Change summary

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(-)

Detailed changes

crates/agent_ui/src/acp/thread_view.rs 🔗

@@ -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.),

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,

crates/ui/src/components/callout.rs 🔗

@@ -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
+                        }
                     }),
             )
     }