agent2: Add custom UI for resource link content blocks (#36005)

Danilo Leal and Agus Zubiaga created

Release Notes:

- N/A

---------

Co-authored-by: Agus Zubiaga <agus@zed.dev>

Change summary

crates/acp_thread/src/acp_thread.rs    |  89 ++++++++---
crates/acp_thread/src/mention.rs       |   5 
crates/agent_ui/src/acp/thread_view.rs | 212 ++++++++++++++++-----------
3 files changed, 192 insertions(+), 114 deletions(-)

Detailed changes

crates/acp_thread/src/acp_thread.rs 🔗

@@ -299,6 +299,7 @@ impl Display for ToolCallStatus {
 pub enum ContentBlock {
     Empty,
     Markdown { markdown: Entity<Markdown> },
+    ResourceLink { resource_link: acp::ResourceLink },
 }
 
 impl ContentBlock {
@@ -330,8 +331,56 @@ impl ContentBlock {
         language_registry: &Arc<LanguageRegistry>,
         cx: &mut App,
     ) {
-        let new_content = match block {
+        if matches!(self, ContentBlock::Empty) {
+            if let acp::ContentBlock::ResourceLink(resource_link) = block {
+                *self = ContentBlock::ResourceLink { resource_link };
+                return;
+            }
+        }
+
+        let new_content = self.extract_content_from_block(block);
+
+        match self {
+            ContentBlock::Empty => {
+                *self = Self::create_markdown_block(new_content, language_registry, cx);
+            }
+            ContentBlock::Markdown { markdown } => {
+                markdown.update(cx, |markdown, cx| markdown.append(&new_content, cx));
+            }
+            ContentBlock::ResourceLink { resource_link } => {
+                let existing_content = Self::resource_link_to_content(&resource_link.uri);
+                let combined = format!("{}\n{}", existing_content, new_content);
+
+                *self = Self::create_markdown_block(combined, language_registry, cx);
+            }
+        }
+    }
+
+    fn resource_link_to_content(uri: &str) -> String {
+        if let Some(uri) = MentionUri::parse(&uri).log_err() {
+            uri.to_link()
+        } else {
+            uri.to_string().clone()
+        }
+    }
+
+    fn create_markdown_block(
+        content: String,
+        language_registry: &Arc<LanguageRegistry>,
+        cx: &mut App,
+    ) -> ContentBlock {
+        ContentBlock::Markdown {
+            markdown: cx
+                .new(|cx| Markdown::new(content.into(), Some(language_registry.clone()), None, cx)),
+        }
+    }
+
+    fn extract_content_from_block(&self, block: acp::ContentBlock) -> String {
+        match block {
             acp::ContentBlock::Text(text_content) => text_content.text.clone(),
+            acp::ContentBlock::ResourceLink(resource_link) => {
+                Self::resource_link_to_content(&resource_link.uri)
+            }
             acp::ContentBlock::Resource(acp::EmbeddedResource {
                 resource:
                     acp::EmbeddedResourceResource::TextResourceContents(acp::TextResourceContents {
@@ -339,35 +388,10 @@ impl ContentBlock {
                         ..
                     }),
                 ..
-            }) => {
-                if let Some(uri) = MentionUri::parse(&uri).log_err() {
-                    uri.to_link()
-                } else {
-                    uri.clone()
-                }
-            }
+            }) => Self::resource_link_to_content(&uri),
             acp::ContentBlock::Image(_)
             | acp::ContentBlock::Audio(_)
-            | acp::ContentBlock::Resource(acp::EmbeddedResource { .. })
-            | acp::ContentBlock::ResourceLink(_) => String::new(),
-        };
-
-        match self {
-            ContentBlock::Empty => {
-                *self = ContentBlock::Markdown {
-                    markdown: cx.new(|cx| {
-                        Markdown::new(
-                            new_content.into(),
-                            Some(language_registry.clone()),
-                            None,
-                            cx,
-                        )
-                    }),
-                };
-            }
-            ContentBlock::Markdown { markdown } => {
-                markdown.update(cx, |markdown, cx| markdown.append(&new_content, cx));
-            }
+            | acp::ContentBlock::Resource(_) => String::new(),
         }
     }
 
@@ -375,6 +399,7 @@ impl ContentBlock {
         match self {
             ContentBlock::Empty => "",
             ContentBlock::Markdown { markdown } => markdown.read(cx).source(),
+            ContentBlock::ResourceLink { resource_link } => &resource_link.uri,
         }
     }
 
@@ -382,6 +407,14 @@ impl ContentBlock {
         match self {
             ContentBlock::Empty => None,
             ContentBlock::Markdown { markdown } => Some(markdown),
+            ContentBlock::ResourceLink { .. } => None,
+        }
+    }
+
+    pub fn resource_link(&self) -> Option<&acp::ResourceLink> {
+        match self {
+            ContentBlock::ResourceLink { resource_link } => Some(resource_link),
+            _ => None,
         }
     }
 }

crates/acp_thread/src/mention.rs 🔗

@@ -19,7 +19,10 @@ impl MentionUri {
                 if let Some(fragment) = url.fragment() {
                     Ok(Self::Symbol(path.into(), fragment.into()))
                 } else {
-                    Ok(Self::File(path.into()))
+                    let file_path =
+                        PathBuf::from(format!("{}{}", url.host_str().unwrap_or(""), path));
+
+                    Ok(Self::File(file_path))
                 }
             }
             "zed" => {

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

@@ -1108,10 +1108,10 @@ impl AcpThreadView {
         .size(IconSize::Small)
         .color(Color::Muted);
 
+        let base_container = h_flex().size_4().justify_center();
+
         if is_collapsible {
-            h_flex()
-                .size_4()
-                .justify_center()
+            base_container
                 .child(
                     div()
                         .group_hover(&group_name, |s| s.invisible().w_0())
@@ -1142,7 +1142,7 @@ impl AcpThreadView {
                         ),
                 )
         } else {
-            div().child(tool_icon)
+            base_container.child(tool_icon)
         }
     }
 
@@ -1205,8 +1205,10 @@ impl AcpThreadView {
             ToolCallContent::Diff(diff) => diff.read(cx).has_revealed_range(cx),
             _ => false,
         });
-        let is_collapsible =
-            !tool_call.content.is_empty() && !needs_confirmation && !is_edit && !has_diff;
+        let use_card_layout = needs_confirmation || is_edit || has_diff;
+
+        let is_collapsible = !tool_call.content.is_empty() && !use_card_layout;
+
         let is_open = tool_call.content.is_empty()
             || needs_confirmation
             || has_nonempty_diff
@@ -1225,9 +1227,39 @@ impl AcpThreadView {
                     linear_color_stop(color.opacity(0.2), 0.),
                 ))
         };
+        let gradient_color = if use_card_layout {
+            self.tool_card_header_bg(cx)
+        } else {
+            cx.theme().colors().panel_background
+        };
+
+        let tool_output_display = match &tool_call.status {
+            ToolCallStatus::WaitingForConfirmation { options, .. } => v_flex()
+                .w_full()
+                .children(tool_call.content.iter().map(|content| {
+                    div()
+                        .child(self.render_tool_call_content(content, tool_call, window, cx))
+                        .into_any_element()
+                }))
+                .child(self.render_permission_buttons(
+                    options,
+                    entry_ix,
+                    tool_call.id.clone(),
+                    tool_call.content.is_empty(),
+                    cx,
+                )),
+            ToolCallStatus::Allowed { .. } | ToolCallStatus::Canceled => v_flex()
+                .w_full()
+                .children(tool_call.content.iter().map(|content| {
+                    div()
+                        .child(self.render_tool_call_content(content, tool_call, window, cx))
+                        .into_any_element()
+                })),
+            ToolCallStatus::Rejected => v_flex().size_0(),
+        };
 
         v_flex()
-            .when(needs_confirmation || is_edit || has_diff, |this| {
+            .when(use_card_layout, |this| {
                 this.rounded_lg()
                     .border_1()
                     .border_color(self.tool_card_border_color(cx))
@@ -1241,7 +1273,7 @@ impl AcpThreadView {
                     .gap_1()
                     .justify_between()
                     .map(|this| {
-                        if needs_confirmation || is_edit || has_diff {
+                        if use_card_layout {
                             this.pl_2()
                                 .pr_1()
                                 .py_1()
@@ -1258,13 +1290,6 @@ impl AcpThreadView {
                             .group(&card_header_id)
                             .relative()
                             .w_full()
-                            .map(|this| {
-                                if tool_call.locations.len() == 1 {
-                                    this.gap_0()
-                                } else {
-                                    this.gap_1p5()
-                                }
-                            })
                             .text_size(self.tool_name_font_size())
                             .child(self.render_tool_call_icon(
                                 card_header_id,
@@ -1308,6 +1333,7 @@ impl AcpThreadView {
                                     .id("non-card-label-container")
                                     .w_full()
                                     .relative()
+                                    .ml_1p5()
                                     .overflow_hidden()
                                     .child(
                                         h_flex()
@@ -1324,17 +1350,7 @@ impl AcpThreadView {
                                                 ),
                                             )),
                                     )
-                                    .map(|this| {
-                                        if needs_confirmation {
-                                            this.child(gradient_overlay(
-                                                self.tool_card_header_bg(cx),
-                                            ))
-                                        } else {
-                                            this.child(gradient_overlay(
-                                                cx.theme().colors().panel_background,
-                                            ))
-                                        }
-                                    })
+                                    .child(gradient_overlay(gradient_color))
                                     .on_click(cx.listener({
                                         let id = tool_call.id.clone();
                                         move |this: &mut Self, _, _, cx: &mut Context<Self>| {
@@ -1351,54 +1367,7 @@ impl AcpThreadView {
                     )
                     .children(status_icon),
             )
-            .when(is_open, |this| {
-                this.child(
-                    v_flex()
-                        .text_xs()
-                        .when(is_collapsible, |this| {
-                            this.mt_1()
-                                .border_1()
-                                .border_color(self.tool_card_border_color(cx))
-                                .bg(cx.theme().colors().editor_background)
-                                .rounded_lg()
-                        })
-                        .map(|this| {
-                            if is_open {
-                                match &tool_call.status {
-                                    ToolCallStatus::WaitingForConfirmation { options, .. } => this
-                                        .children(tool_call.content.iter().map(|content| {
-                                            div()
-                                                .py_1p5()
-                                                .child(self.render_tool_call_content(
-                                                    content, tool_call, window, cx,
-                                                ))
-                                                .into_any_element()
-                                        }))
-                                        .child(self.render_permission_buttons(
-                                            options,
-                                            entry_ix,
-                                            tool_call.id.clone(),
-                                            tool_call.content.is_empty(),
-                                            cx,
-                                        )),
-                                    ToolCallStatus::Allowed { .. } | ToolCallStatus::Canceled => {
-                                        this.children(tool_call.content.iter().map(|content| {
-                                            div()
-                                                .py_1p5()
-                                                .child(self.render_tool_call_content(
-                                                    content, tool_call, window, cx,
-                                                ))
-                                                .into_any_element()
-                                        }))
-                                    }
-                                    ToolCallStatus::Rejected => this,
-                                }
-                            } else {
-                                this
-                            }
-                        }),
-                )
-            })
+            .when(is_open, |this| this.child(tool_output_display))
     }
 
     fn render_tool_call_content(
@@ -1410,16 +1379,10 @@ impl AcpThreadView {
     ) -> AnyElement {
         match content {
             ToolCallContent::ContentBlock(content) => {
-                if let Some(md) = content.markdown() {
-                    div()
-                        .p_2()
-                        .child(
-                            self.render_markdown(
-                                md.clone(),
-                                default_markdown_style(false, window, cx),
-                            ),
-                        )
-                        .into_any_element()
+                if let Some(resource_link) = content.resource_link() {
+                    self.render_resource_link(resource_link, cx)
+                } else if let Some(markdown) = content.markdown() {
+                    self.render_markdown_output(markdown.clone(), tool_call.id.clone(), window, cx)
                 } else {
                     Empty.into_any_element()
                 }
@@ -1431,6 +1394,83 @@ impl AcpThreadView {
         }
     }
 
+    fn render_markdown_output(
+        &self,
+        markdown: Entity<Markdown>,
+        tool_call_id: acp::ToolCallId,
+        window: &Window,
+        cx: &Context<Self>,
+    ) -> AnyElement {
+        let button_id = SharedString::from(format!("tool_output-{:?}", tool_call_id.clone()));
+
+        v_flex()
+            .mt_1p5()
+            .ml(px(7.))
+            .px_3p5()
+            .gap_2()
+            .border_l_1()
+            .border_color(self.tool_card_border_color(cx))
+            .text_sm()
+            .text_color(cx.theme().colors().text_muted)
+            .child(self.render_markdown(markdown, default_markdown_style(false, window, cx)))
+            .child(
+                Button::new(button_id, "Collapse Output")
+                    .full_width()
+                    .style(ButtonStyle::Outlined)
+                    .label_size(LabelSize::Small)
+                    .icon(IconName::ChevronUp)
+                    .icon_color(Color::Muted)
+                    .icon_position(IconPosition::Start)
+                    .on_click(cx.listener({
+                        let id = tool_call_id.clone();
+                        move |this: &mut Self, _, _, cx: &mut Context<Self>| {
+                            this.expanded_tool_calls.remove(&id);
+                            cx.notify();
+                        }
+                    })),
+            )
+            .into_any_element()
+    }
+
+    fn render_resource_link(
+        &self,
+        resource_link: &acp::ResourceLink,
+        cx: &Context<Self>,
+    ) -> AnyElement {
+        let uri: SharedString = resource_link.uri.clone().into();
+
+        let label: SharedString = if let Some(path) = resource_link.uri.strip_prefix("file://") {
+            path.to_string().into()
+        } else {
+            uri.clone()
+        };
+
+        let button_id = SharedString::from(format!("item-{}", uri.clone()));
+
+        div()
+            .ml(px(7.))
+            .pl_2p5()
+            .border_l_1()
+            .border_color(self.tool_card_border_color(cx))
+            .overflow_hidden()
+            .child(
+                Button::new(button_id, label)
+                    .label_size(LabelSize::Small)
+                    .color(Color::Muted)
+                    .icon(IconName::ArrowUpRight)
+                    .icon_size(IconSize::XSmall)
+                    .icon_color(Color::Muted)
+                    .truncate(true)
+                    .on_click(cx.listener({
+                        let workspace = self.workspace.clone();
+                        move |_, _, window, cx: &mut Context<Self>| {
+                            Self::open_link(uri.clone(), &workspace, window, cx);
+                        }
+                    })),
+            )
+            .into_any_element()
+    }
+
     fn render_permission_buttons(
         &self,
         options: &[acp::PermissionOption],
@@ -1706,7 +1746,9 @@ impl AcpThreadView {
             .overflow_hidden()
             .child(
                 v_flex()
-                    .p_2()
+                    .pt_1()
+                    .pb_2()
+                    .px_2()
                     .gap_0p5()
                     .bg(header_bg)
                     .text_xs()