agent_ui: Add some design tweaks to the subagents UI (#49938)

Danilo Leal created

- Increase hit area of both the preview expansion as well as the full
screen expansion
- Add the ability to stop a subagent from the full screen view
- Fix subagent state display in the full screen view (e.g., we were
showing the green check mark even when the subagent was cancelled)
- Make card header font size consistent with the thread through a new
enum value in `LabelSize`
- Refine tooltip content and display
- Fix slight layout shift happening between the "there is no thread" and
"there is a thread" states

---
 
Closes #ISSUE

Before you mark this PR as ready for review, make sure that you have:
- [x] Added a solid test coverage and/or screenshots from doing manual
testing
- [x] Done a self-review taking into account security and performance
aspects
- [x] Aligned any UI changes with the [UI
checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist)

Release Notes:

- N/A

Change summary

crates/acp_thread/src/acp_thread.rs                  |  11 
crates/agent_ui/src/acp/thread_view/active_thread.rs | 424 ++++++++-----
crates/ui/src/components/label/label_like.rs         |   7 
3 files changed, 264 insertions(+), 178 deletions(-)

Detailed changes

crates/acp_thread/src/acp_thread.rs 🔗

@@ -1685,6 +1685,17 @@ impl AcpThread {
             })
     }
 
+    pub fn tool_call_for_subagent(&self, session_id: &acp::SessionId) -> Option<&ToolCall> {
+        self.entries.iter().find_map(|entry| match entry {
+            AgentThreadEntry::ToolCall(tool_call)
+                if tool_call.subagent_session_id.as_ref() == Some(session_id) =>
+            {
+                Some(tool_call)
+            }
+            _ => None,
+        })
+    }
+
     pub fn resolve_locations(&mut self, id: acp::ToolCallId, cx: &mut Context<Self>) {
         let project = self.project.clone();
         let Some((_, tool_call)) = self.tool_call_mut(&id) else {

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

@@ -2351,14 +2351,42 @@ impl AcpThreadView {
             )
     }
 
+    fn is_subagent_canceled_or_failed(&self, cx: &App) -> bool {
+        let Some(parent_session_id) = self.parent_id.as_ref() else {
+            return false;
+        };
+
+        let my_session_id = self.thread.read(cx).session_id().clone();
+
+        self.server_view
+            .upgrade()
+            .and_then(|sv| sv.read(cx).thread_view(parent_session_id))
+            .is_some_and(|parent_view| {
+                parent_view
+                    .read(cx)
+                    .thread
+                    .read(cx)
+                    .tool_call_for_subagent(&my_session_id)
+                    .is_some_and(|tc| {
+                        matches!(
+                            tc.status,
+                            ToolCallStatus::Canceled
+                                | ToolCallStatus::Failed
+                                | ToolCallStatus::Rejected
+                        )
+                    })
+            })
+    }
+
     pub(crate) fn render_subagent_titlebar(&mut self, cx: &mut Context<Self>) -> Option<Div> {
         let Some(parent_session_id) = self.parent_id.clone() else {
             return None;
         };
 
         let server_view = self.server_view.clone();
-
-        let is_done = self.thread.read(cx).status() == ThreadStatus::Idle;
+        let thread = self.thread.clone();
+        let is_done = thread.read(cx).status() == ThreadStatus::Idle;
+        let is_canceled_or_failed = self.is_subagent_canceled_or_failed(cx);
 
         Some(
             h_flex()
@@ -2369,6 +2397,9 @@ impl AcpThreadView {
                 .justify_between()
                 .gap_1()
                 .border_b_1()
+                .when(is_done && is_canceled_or_failed, |this| {
+                    this.border_dashed()
+                })
                 .border_color(cx.theme().colors().border)
                 .bg(cx.theme().colors().editor_background.opacity(0.2))
                 .child(
@@ -2381,23 +2412,43 @@ impl AcpThreadView {
                                 .color(Color::Muted),
                         )
                         .child(self.title_editor.clone())
-                        .when(is_done, |this| {
+                        .when(is_done && is_canceled_or_failed, |this| {
+                            this.child(Icon::new(IconName::Close).color(Color::Error))
+                        })
+                        .when(is_done && !is_canceled_or_failed, |this| {
                             this.child(Icon::new(IconName::Check).color(Color::Success))
                         }),
                 )
                 .child(
-                    IconButton::new("minimize_subagent", IconName::Minimize)
-                        .icon_size(IconSize::Small)
-                        .tooltip(Tooltip::text("Minimize Subagent"))
-                        .on_click(move |_, window, cx| {
-                            let _ = server_view.update(cx, |server_view, cx| {
-                                server_view.navigate_to_session(
-                                    parent_session_id.clone(),
-                                    window,
-                                    cx,
-                                );
-                            });
-                        }),
+                    h_flex()
+                        .gap_0p5()
+                        .when(!is_done, |this| {
+                            this.child(
+                                IconButton::new("stop_subagent", IconName::Stop)
+                                    .icon_size(IconSize::Small)
+                                    .icon_color(Color::Error)
+                                    .tooltip(Tooltip::text("Stop Subagent"))
+                                    .on_click(move |_, _, cx| {
+                                        thread.update(cx, |thread, cx| {
+                                            thread.cancel(cx).detach();
+                                        });
+                                    }),
+                            )
+                        })
+                        .child(
+                            IconButton::new("minimize_subagent", IconName::Minimize)
+                                .icon_size(IconSize::Small)
+                                .tooltip(Tooltip::text("Minimize Subagent"))
+                                .on_click(move |_, window, cx| {
+                                    let _ = server_view.update(cx, |server_view, cx| {
+                                        server_view.navigate_to_session(
+                                            parent_session_id.clone(),
+                                            window,
+                                            cx,
+                                        );
+                                    });
+                                }),
+                        ),
                 ),
         )
     }
@@ -6112,6 +6163,11 @@ impl AcpThreadView {
             ToolCallStatus::Canceled | ToolCallStatus::Failed | ToolCallStatus::Rejected
         );
 
+        let has_title = thread
+            .as_ref()
+            .is_some_and(|t| !t.read(cx).title().is_empty());
+        let has_no_title_or_canceled = !has_title || is_canceled_or_failed;
+
         let title = thread
             .as_ref()
             .map(|t| t.read(cx).title())
@@ -6147,155 +6203,130 @@ impl AcpThreadView {
             .as_ref()
             .map_or(false, |thread| !thread.read(cx).entries().is_empty());
 
+        let tooltip_meta_description = if is_expanded {
+            "Click to Collapse"
+        } else {
+            "Click to Preview"
+        };
+
         v_flex()
             .w_full()
             .rounded_md()
             .border_1()
+            .when(has_no_title_or_canceled, |this| this.border_dashed())
             .border_color(self.tool_card_border_color(cx))
             .overflow_hidden()
             .child(
                 h_flex()
-                    .id(format!("subagent-header-click-{}", entry_ix))
                     .group(&card_header_id)
+                    .h_8()
                     .p_1()
-                    .pl_1p5()
                     .w_full()
-                    .gap_1()
                     .justify_between()
-                    .bg(self.tool_card_header_bg(cx))
-                    .when(has_expandable_content, |this| {
-                        this.cursor_pointer().on_click(cx.listener({
-                            let tool_call_id = tool_call.id.clone();
-                            move |this, _, _, cx| {
-                                if this.expanded_tool_calls.contains(&tool_call_id) {
-                                    this.expanded_tool_calls.remove(&tool_call_id);
-                                } else {
-                                    this.expanded_tool_calls.insert(tool_call_id.clone());
-                                }
-                                cx.notify();
-                            }
-                        }))
+                    .when(!has_no_title_or_canceled, |this| {
+                        this.bg(self.tool_card_header_bg(cx))
                     })
                     .child(
                         h_flex()
                             .id(format!("subagent-title-{}", entry_ix))
+                            .px_1()
                             .min_w_0()
+                            .size_full()
+                            .gap_2()
+                            .justify_between()
+                            .rounded_sm()
                             .overflow_hidden()
-                            .gap_1p5()
-                            .child(icon)
                             .child(
-                                Label::new(title.to_string())
-                                    .size(LabelSize::Small)
-                                    .truncate(),
-                            )
-                            .when(files_changed > 0, |this| {
-                                this.child(
-                                    h_flex()
-                                        .gap_1()
-                                        .child(
+                                h_flex()
+                                    .min_w_0()
+                                    .w_full()
+                                    .gap_1p5()
+                                    .child(icon)
+                                    .child(
+                                        Label::new(title.to_string())
+                                            .size(LabelSize::Custom(self.tool_name_font_size()))
+                                            .truncate(),
+                                    )
+                                    .when(files_changed > 0, |this| {
+                                        this.child(
                                             Label::new(format!(
                                                 "— {} {} changed",
                                                 files_changed,
                                                 if files_changed == 1 { "file" } else { "files" }
                                             ))
-                                            .size(LabelSize::Small)
+                                            .size(LabelSize::Custom(self.tool_name_font_size()))
                                             .color(Color::Muted),
                                         )
-                                        .child(DiffStat::new(
-                                            diff_stat_id.clone(),
-                                            diff_stats.lines_added as usize,
-                                            diff_stats.lines_removed as usize,
-                                        )),
-                                )
+                                        .child(
+                                            DiffStat::new(
+                                                diff_stat_id.clone(),
+                                                diff_stats.lines_added as usize,
+                                                diff_stats.lines_removed as usize,
+                                            )
+                                            .label_size(LabelSize::Custom(
+                                                self.tool_name_font_size(),
+                                            )),
+                                        )
+                                    }),
+                            )
+                            .when(!has_no_title_or_canceled, |this| {
+                                this.tooltip(move |_, cx| {
+                                    Tooltip::with_meta(
+                                        title.to_string(),
+                                        None,
+                                        tooltip_meta_description,
+                                        cx,
+                                    )
+                                })
                             })
-                            .tooltip(Tooltip::text(title.to_string())),
-                    )
-                    .when_some(subagent_session_id, |this, subagent_session_id| {
-                        this.child(
-                            h_flex()
-                                .flex_shrink_0()
-                                .when(has_expandable_content, |this| {
-                                    this.child(
-                                        IconButton::new(
-                                            format!("subagent-disclosure-{}", entry_ix),
-                                            if is_expanded {
+                            .when(has_expandable_content, |this| {
+                                this.cursor_pointer()
+                                    .hover(|s| s.bg(cx.theme().colors().element_hover))
+                                    .child(
+                                        div().visible_on_hover(card_header_id).child(
+                                            Icon::new(if is_expanded {
                                                 IconName::ChevronUp
                                             } else {
                                                 IconName::ChevronDown
-                                            },
-                                        )
-                                        .icon_color(Color::Muted)
-                                        .icon_size(IconSize::Small)
-                                        .disabled(!has_expandable_content)
-                                        .visible_on_hover(card_header_id.clone())
-                                        .on_click(
-                                            cx.listener({
-                                                let tool_call_id = tool_call.id.clone();
-                                                move |this, _, _, cx| {
-                                                    if this
-                                                        .expanded_tool_calls
-                                                        .contains(&tool_call_id)
-                                                    {
-                                                        this.expanded_tool_calls
-                                                            .remove(&tool_call_id);
-                                                    } else {
-                                                        this.expanded_tool_calls
-                                                            .insert(tool_call_id.clone());
-                                                    }
-                                                    cx.notify();
-                                                }
-                                            }),
+                                            })
+                                            .color(Color::Muted)
+                                            .size(IconSize::Small),
                                         ),
                                     )
-                                })
-                                .child(
-                                    IconButton::new(
-                                        format!("expand-subagent-{}", entry_ix),
-                                        IconName::Maximize,
-                                    )
-                                    .icon_color(Color::Muted)
-                                    .icon_size(IconSize::Small)
-                                    .tooltip(Tooltip::text("Expand Subagent"))
-                                    .visible_on_hover(card_header_id)
-                                    .on_click(cx.listener(
-                                        move |this, _event, window, cx| {
-                                            this.server_view
-                                                .update(cx, |this, cx| {
-                                                    this.navigate_to_session(
-                                                        subagent_session_id.clone(),
-                                                        window,
-                                                        cx,
-                                                    );
-                                                })
-                                                .ok();
-                                        },
-                                    )),
-                                )
-                                .when(is_running, |buttons| {
-                                    buttons.child(
-                                        IconButton::new(
-                                            format!("stop-subagent-{}", entry_ix),
-                                            IconName::Stop,
-                                        )
-                                        .icon_size(IconSize::Small)
-                                        .icon_color(Color::Error)
-                                        .tooltip(Tooltip::text("Stop Subagent"))
-                                        .when_some(
-                                            thread_view
-                                                .as_ref()
-                                                .map(|view| view.read(cx).thread.clone()),
-                                            |this, thread| {
-                                                this.on_click(cx.listener(
-                                                    move |_this, _event, _window, cx| {
-                                                        thread.update(cx, |thread, cx| {
-                                                            thread.cancel(cx).detach();
-                                                        });
-                                                    },
-                                                ))
+                                    .on_click(cx.listener({
+                                        let tool_call_id = tool_call.id.clone();
+                                        move |this, _, _, cx| {
+                                            if this.expanded_tool_calls.contains(&tool_call_id) {
+                                                this.expanded_tool_calls.remove(&tool_call_id);
+                                            } else {
+                                                this.expanded_tool_calls
+                                                    .insert(tool_call_id.clone());
+                                            }
+                                            cx.notify();
+                                        }
+                                    }))
+                            }),
+                    )
+                    .when(is_running && subagent_session_id.is_some(), |buttons| {
+                        buttons.child(
+                            IconButton::new(format!("stop-subagent-{}", entry_ix), IconName::Stop)
+                                .icon_size(IconSize::Small)
+                                .icon_color(Color::Error)
+                                .tooltip(Tooltip::text("Stop Subagent"))
+                                .when_some(
+                                    thread_view
+                                        .as_ref()
+                                        .map(|view| view.read(cx).thread.clone()),
+                                    |this, thread| {
+                                        this.on_click(cx.listener(
+                                            move |_this, _event, _window, cx| {
+                                                thread.update(cx, |thread, cx| {
+                                                    thread.cancel(cx).detach();
+                                                });
                                             },
-                                        ),
-                                    )
-                                }),
+                                        ))
+                                    },
+                                ),
                         )
                     }),
             )
@@ -6322,6 +6353,7 @@ impl AcpThreadView {
                         this
                     }
                 } else {
+                    let session_id = thread.read(cx).session_id().clone();
                     this.when(is_expanded, |this| {
                         this.child(self.render_subagent_expanded_content(
                             active_session_id,
@@ -6333,6 +6365,40 @@ impl AcpThreadView {
                             window,
                             cx,
                         ))
+                        .child(
+                            h_flex()
+                                .p_1()
+                                .w_full()
+                                .border_t_1()
+                                .when(is_canceled_or_failed, |this| this.border_dashed())
+                                .border_color(cx.theme().colors().border_variant)
+                                .child(
+                                    Button::new(
+                                        format!("expand-subagent-{}", entry_ix),
+                                        "Full Screen",
+                                    )
+                                    .full_width()
+                                    .style(ButtonStyle::Outlined)
+                                    .label_size(LabelSize::Small)
+                                    .icon(IconName::Maximize)
+                                    .icon_color(Color::Muted)
+                                    .icon_size(IconSize::Small)
+                                    .icon_position(IconPosition::Start)
+                                    .on_click(cx.listener(
+                                        move |this, _event, window, cx| {
+                                            this.server_view
+                                                .update(cx, |this, cx| {
+                                                    this.navigate_to_session(
+                                                        session_id.clone(),
+                                                        window,
+                                                        cx,
+                                                    );
+                                                })
+                                                .ok();
+                                        },
+                                    )),
+                                ),
+                        )
                     })
                 }
             })
@@ -6355,18 +6421,33 @@ impl AcpThreadView {
         let subagent_view = thread_view.read(cx);
         let session_id = subagent_view.thread.read(cx).session_id().clone();
 
-        if is_running {
-            let entries = subagent_view.thread.read(cx).entries();
-            let total_entries = entries.len();
-            let start_ix = total_entries.saturating_sub(MAX_PREVIEW_ENTRIES);
+        let base_container = || {
+            div()
+                .id(format!("subagent-content-{}", session_id))
+                .relative()
+                .w_full()
+                .h_56()
+                .border_t_1()
+                .border_color(self.tool_card_border_color(cx))
+                .overflow_hidden()
+        };
+
+        let show_thread_entries = is_running || tool_call.content.is_empty();
 
+        if show_thread_entries {
             let scroll_handle = self
                 .subagent_scroll_handles
                 .borrow_mut()
                 .entry(session_id.clone())
                 .or_default()
                 .clone();
-            scroll_handle.scroll_to_bottom();
+            if is_running {
+                scroll_handle.scroll_to_bottom();
+            }
+
+            let entries = subagent_view.thread.read(cx).entries();
+            let total_entries = entries.len();
+            let start_ix = total_entries.saturating_sub(MAX_PREVIEW_ENTRIES);
 
             let rendered_entries: Vec<AnyElement> = entries[start_ix..]
                 .iter()
@@ -6377,51 +6458,41 @@ impl AcpThreadView {
                 })
                 .collect();
 
-            let editor_bg = cx.theme().colors().editor_background;
-
-            let gradient_overlay = div().absolute().inset_0().bg(linear_gradient(
-                180.,
-                linear_color_stop(editor_bg, 0.),
-                linear_color_stop(editor_bg.opacity(0.), 0.15),
-            ));
-
-            let interaction_blocker = div()
-                .absolute()
-                .inset_0()
-                .size_full()
-                .block_mouse_except_scroll();
-
-            div()
-                .id(format!("subagent-content-{}", session_id))
-                .relative()
-                .w_full()
-                .h_56()
-                .border_t_1()
-                .border_color(self.tool_card_border_color(cx))
-                .bg(editor_bg.opacity(0.4))
-                .overflow_hidden()
+            base_container()
                 .child(
                     div()
-                        .id("entries")
+                        .id(format!("subagent-entries-{}", session_id))
                         .size_full()
                         .track_scroll(&scroll_handle)
                         .pb_1()
                         .children(rendered_entries),
                 )
-                .child(gradient_overlay)
-                .child(interaction_blocker)
+                .when(is_running, |this| {
+                    let editor_bg = cx.theme().colors().editor_background;
+                    this.child(
+                        div()
+                            .absolute()
+                            .inset_0()
+                            .size_full()
+                            .bg(linear_gradient(
+                                180.,
+                                linear_color_stop(editor_bg, 0.),
+                                linear_color_stop(editor_bg.opacity(0.), 0.15),
+                            ))
+                            .block_mouse_except_scroll(),
+                    )
+                })
+                .into_any_element()
         } else {
-            div()
-                .id(format!("subagent-content-{}", session_id))
-                .p_2()
-                .children(
-                    tool_call
-                        .content
-                        .iter()
-                        .enumerate()
-                        .map(|(content_ix, content)| {
-                            div().id(("tool-call-output", entry_ix)).child(
-                                self.render_tool_call_content(
+            base_container()
+                .child(
+                    v_flex()
+                        .id(format!("subagent-done-content-{}", session_id))
+                        .size_full()
+                        .justify_end()
+                        .children(tool_call.content.iter().enumerate().map(
+                            |(content_ix, content)| {
+                                div().p_2().child(self.render_tool_call_content(
                                     active_session_id,
                                     entry_ix,
                                     content,
@@ -6438,10 +6509,11 @@ impl AcpThreadView {
                                     focus_handle,
                                     window,
                                     cx,
-                                ),
-                            )
-                        }),
+                                ))
+                            },
+                        )),
                 )
+                .into_any_element()
         }
     }
 

crates/ui/src/components/label/label_like.rs 🔗

@@ -1,11 +1,11 @@
 use crate::prelude::*;
-use gpui::{FontWeight, StyleRefinement, UnderlineStyle};
+use gpui::{FontWeight, Rems, StyleRefinement, UnderlineStyle};
 use settings::Settings;
 use smallvec::SmallVec;
 use theme::ThemeSettings;
 
 /// Sets the size of a label
-#[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Clone, Copy, Default)]
+#[derive(Debug, PartialEq, Clone, Copy, Default)]
 pub enum LabelSize {
     /// The default size of a label.
     #[default]
@@ -16,6 +16,8 @@ pub enum LabelSize {
     Small,
     /// The extra small size of a label.
     XSmall,
+    /// An arbitrary custom size specified in rems.
+    Custom(Rems),
 }
 
 /// Sets the line height of a label
@@ -225,6 +227,7 @@ impl RenderOnce for LabelLike {
                 LabelSize::Default => this.text_ui(cx),
                 LabelSize::Small => this.text_ui_sm(cx),
                 LabelSize::XSmall => this.text_ui_xs(cx),
+                LabelSize::Custom(size) => this.text_size(size),
             })
             .when(self.line_height_style == LineHeightStyle::UiLabel, |this| {
                 this.line_height(relative(1.))