From 62f168969e13920d756fc399920a95b5e4ee8b0a Mon Sep 17 00:00:00 2001 From: Danilo Leal <67129314+danilo-leal@users.noreply.github.com> Date: Mon, 23 Feb 2026 22:46:49 -0300 Subject: [PATCH] agent_ui: Add some design tweaks to the subagents UI (#49938) - 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 --- crates/acp_thread/src/acp_thread.rs | 11 + .../src/acp/thread_view/active_thread.rs | 424 ++++++++++-------- crates/ui/src/components/label/label_like.rs | 7 +- 3 files changed, 264 insertions(+), 178 deletions(-) diff --git a/crates/acp_thread/src/acp_thread.rs b/crates/acp_thread/src/acp_thread.rs index 83645226e5eb9cba3d19b37b587d15d1d80087c1..37fa2488524bf325755f1807125d9685821c04ee 100644 --- a/crates/acp_thread/src/acp_thread.rs +++ b/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) { let project = self.project.clone(); let Some((_, tool_call)) = self.tool_call_mut(&id) else { diff --git a/crates/agent_ui/src/acp/thread_view/active_thread.rs b/crates/agent_ui/src/acp/thread_view/active_thread.rs index 5ca4770a7dfc9df3c654f64855623227432b55c2..aa1a11ee2f65100d5bfa3c06801a98be16419af9 100644 --- a/crates/agent_ui/src/acp/thread_view/active_thread.rs +++ b/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) -> Option
{ 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 = 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() } } diff --git a/crates/ui/src/components/label/label_like.rs b/crates/ui/src/components/label/label_like.rs index ba39d7e16f7c5ac12cbfaba7abe921884d71e37f..d87bdf6c12323c4858881f36af62f1a91cdd2aa1 100644 --- a/crates/ui/src/components/label/label_like.rs +++ b/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.))