agent_ui: Add more improvements to the subagent UI (#50260)

This PR includes the following adjustments:

- Add divider in the full screen view to expose what part of a
subagent's output is actually sent to the parent model
- Auto-expand the subagent card if there's only one running on a turn
- Adjust errors display within the subagent card

Release Notes:

- N/A
This commit is contained in:
Danilo Leal authored and GitHub committed 2026-02-27 11:04:39 +01:00
1 parent 7cf55a2690
commit 64c2b3eaff
4 files changed
+176 -91

No files matched your search

+15 -2
View File
@@ -102,6 +102,7 @@ impl UserMessage {
pub struct AssistantMessage {
pub chunks: Vec<AssistantMessageChunk>,
pub indented: bool,
pub is_subagent_output: bool,
}
impl AssistantMessage {
@@ -983,7 +984,7 @@ pub enum AcpThreadEvent {
ToolAuthorizationReceived(acp::ToolCallId),
Retry(RetryStatus),
SubagentSpawned(acp::SessionId),
Stopped,
Stopped(acp::StopReason),
Error,
LoadError(LoadError),
PromptCapabilitiesUpdated,
@@ -1425,6 +1426,7 @@ impl AcpThread {
&& let AgentThreadEntry::AssistantMessage(AssistantMessage {
chunks,
indented: existing_indented,
is_subagent_output: _,
}) = last_entry
&& *existing_indented == indented
{
@@ -1456,6 +1458,7 @@ impl AcpThread {
AgentThreadEntry::AssistantMessage(AssistantMessage {
chunks: vec![chunk],
indented,
is_subagent_output: false,
}),
cx,
);
@@ -2033,7 +2036,7 @@ impl AcpThread {
}
}
cx.emit(AcpThreadEvent::Stopped);
cx.emit(AcpThreadEvent::Stopped(r.stop_reason));
Ok(Some(r))
}
Err(e) => {
@@ -2549,6 +2552,16 @@ impl AcpThread {
self.terminals.insert(terminal_id.clone(), entity.clone());
entity
}
pub fn mark_as_subagent_output(&mut self, cx: &mut Context<Self>) {
for entry in self.entries.iter_mut().rev() {
if let AgentThreadEntry::AssistantMessage(assistant_message) = entry {
assistant_message.is_subagent_output = true;
cx.notify();
return;
}
}
}
}
fn markdown_for_raw_output(
+1 -1
View File
@@ -1403,7 +1403,7 @@ impl AgentDiff {
self.update_reviewing_editors(workspace, window, cx);
}
}
AcpThreadEvent::Stopped => {
AcpThreadEvent::Stopped(_) => {
self.update_reviewing_editors(workspace, window, cx);
}
AcpThreadEvent::Error | AcpThreadEvent::LoadError(_) | AcpThreadEvent::Refusal => {
+7 -2
View File
@@ -182,7 +182,7 @@ impl Conversation {
| AcpThreadEvent::EntriesRemoved(_)
| AcpThreadEvent::Retry(_)
| AcpThreadEvent::SubagentSpawned(_)
| AcpThreadEvent::Stopped
| AcpThreadEvent::Stopped(_)
| AcpThreadEvent::Error
| AcpThreadEvent::LoadError(_)
| AcpThreadEvent::PromptCapabilitiesUpdated
@@ -1190,13 +1190,18 @@ impl ConnectionView {
});
}
}
AcpThreadEvent::Stopped => {
AcpThreadEvent::Stopped(stop_reason) => {
if let Some(active) = self.thread_view(&thread_id) {
active.update(cx, |active, _cx| {
active.thread_retry_status.take();
});
}
if is_subagent {
if *stop_reason == acp::StopReason::EndTurn {
thread.update(cx, |thread, cx| {
thread.mark_as_subagent_output(cx);
});
}
return;
}
@@ -3711,6 +3711,7 @@ impl ThreadView {
AgentThreadEntry::AssistantMessage(AssistantMessage {
chunks,
indented: _,
is_subagent_output: _,
}) => {
let mut is_blank = true;
let is_last = entry_ix + 1 == total_entries;
@@ -3783,6 +3784,42 @@ impl ThreadView {
.into_any(),
};
let is_subagent_output = self.is_subagent()
&& matches!(entry, AgentThreadEntry::AssistantMessage(msg) if msg.is_subagent_output);
let primary = if is_subagent_output {
v_flex()
.w_full()
.child(
h_flex()
.id("subagent_output")
.px_5()
.py_1()
.gap_2()
.child(Divider::horizontal())
.child(
h_flex()
.gap_1()
.child(
Icon::new(IconName::ForwardArrowUp)
.color(Color::Muted)
.size(IconSize::Small),
)
.child(
Label::new("Subagent Output")
.size(LabelSize::Custom(self.tool_name_font_size()))
.color(Color::Muted),
),
)
.child(Divider::horizontal())
.tooltip(Tooltip::text("Everything below this line was sent as output from this subagent to the main agent.")),
)
.child(primary)
.into_any_element()
} else {
primary
};
let primary = if is_indented {
let line_top = if is_first_indented {
rems_from_px(-12.0)
@@ -6397,12 +6434,9 @@ impl ThreadView {
let session_id = thread.read(cx).session_id().clone();
this.when(is_expanded, |this| {
this.child(self.render_subagent_expanded_content(
active_session_id,
entry_ix,
thread_view,
is_running,
tool_call,
focus_handle,
window,
cx,
))
@@ -6442,12 +6476,9 @@ impl ThreadView {
fn render_subagent_expanded_content(
&self,
active_session_id: &acp::SessionId,
entry_ix: usize,
thread_view: &Entity<ThreadView>,
is_running: bool,
tool_call: &ToolCall,
focus_handle: &FocusHandle,
window: &Window,
cx: &Context<Self>,
) -> impl IntoElement {
@@ -6456,103 +6487,139 @@ impl ThreadView {
let subagent_view = thread_view.read(cx);
let session_id = subagent_view.thread.read(cx).session_id().clone();
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 is_canceled_or_failed = matches!(
tool_call.status,
ToolCallStatus::Canceled | ToolCallStatus::Failed | ToolCallStatus::Rejected
);
let editor_bg = cx.theme().colors().editor_background;
let overlay = || {
let overlay = {
div()
.absolute()
.inset_0()
.size_full()
.bg(linear_gradient(
180.,
linear_color_stop(editor_bg, 0.),
linear_color_stop(editor_bg.opacity(0.5), 0.),
linear_color_stop(editor_bg.opacity(0.), 0.1),
))
.block_mouse_except_scroll()
};
let show_thread_entries = is_running || tool_call.content.is_empty();
let entries = subagent_view.thread.read(cx).entries();
let total_entries = entries.len();
let start_ix = total_entries.saturating_sub(MAX_PREVIEW_ENTRIES);
if show_thread_entries {
let scroll_handle = self
.subagent_scroll_handles
.borrow_mut()
.entry(session_id.clone())
.or_default()
.clone();
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()
.enumerate()
.map(|(i, entry)| {
let actual_ix = start_ix + i;
subagent_view.render_entry(actual_ix, total_entries + 1, entry, window, cx)
})
.collect();
base_container()
.child(
div()
.id(format!("subagent-entries-{}", session_id))
.size_full()
.track_scroll(&scroll_handle)
.pb_1()
.children(rendered_entries),
)
.child(overlay())
.into_any_element()
} else {
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,
content_ix,
tool_call,
true,
false,
matches!(
tool_call.status,
ToolCallStatus::Failed
| ToolCallStatus::Rejected
| ToolCallStatus::Canceled
),
focus_handle,
window,
cx,
))
},
)),
)
.child(overlay())
.into_any_element()
let scroll_handle = self
.subagent_scroll_handles
.borrow_mut()
.entry(session_id.clone())
.or_default()
.clone();
if is_running {
scroll_handle.scroll_to_bottom();
}
let rendered_entries: Vec<AnyElement> = entries[start_ix..]
.iter()
.enumerate()
.map(|(i, entry)| {
let actual_ix = start_ix + i;
subagent_view.render_entry(actual_ix, total_entries + 1, entry, window, cx)
})
.collect();
let error_message =
self.subagent_error_message(subagent_view, &tool_call.status, tool_call, cx);
let parent_thread = self.thread.read(cx);
let mut started_subagent_count = 0usize;
let mut turn_has_our_call = false;
for entry in parent_thread.entries().iter() {
match entry {
AgentThreadEntry::UserMessage(_) => {
if turn_has_our_call {
break;
}
started_subagent_count = 0;
turn_has_our_call = false;
}
AgentThreadEntry::ToolCall(tc)
if tc.is_subagent() && !matches!(tc.status, ToolCallStatus::Pending) =>
{
started_subagent_count += 1;
if tc.id == tool_call.id {
turn_has_our_call = true;
}
}
_ => {}
}
}
v_flex()
.relative()
.w_full()
.border_t_1()
.when(is_canceled_or_failed, |this| this.border_dashed())
.border_color(self.tool_card_border_color(cx))
.overflow_hidden()
.child(
div()
.id(format!("subagent-entries-{}", session_id))
.flex_1()
.min_h_0()
.pb_1()
.overflow_hidden()
.track_scroll(&scroll_handle)
.children(rendered_entries),
)
.when_some(error_message, |this, message| {
this.child(
Callout::new()
.severity(Severity::Error)
.icon(IconName::XCircle)
.title(message),
)
})
.when(started_subagent_count > 1, |this| {
this.h_56().child(overlay)
})
.into_any_element()
}
fn subagent_error_message(
&self,
subagent_view: &ThreadView,
status: &ToolCallStatus,
tool_call: &ToolCall,
cx: &App,
) -> Option<SharedString> {
if matches!(status, ToolCallStatus::Canceled | ToolCallStatus::Rejected) {
return None;
}
subagent_view
.thread_error
.as_ref()
.and_then(|e| match e {
ThreadError::Refusal => Some("The agent refused to respond to this prompt.".into()),
ThreadError::Other { message, .. } => Some(message.clone()),
ThreadError::PaymentRequired | ThreadError::AuthenticationRequired(_) => None,
})
.or_else(|| {
tool_call.content.iter().find_map(|content| {
if let ToolCallContent::ContentBlock(block) = content {
if let acp_thread::ContentBlock::Markdown { markdown } = block {
let source = markdown.read(cx).source().to_string();
if !source.is_empty() {
return Some(SharedString::from(source));
}
}
}
None
})
})
}
fn render_rules_item(&self, cx: &Context<Self>) -> Option<AnyElement> {
let project_context = self
.as_native_thread(cx)?