//! The subagents panel: every subagent the open thread's agent started, the
//! working ones first with what each is doing right now (its latest tool
//! call, its progress summary, how long it's run, its tool uses and
//! tokens), then the finished ones. A click shows the subagent's call in
//! its conversation.
//!
//! Claude's Task and Agent calls are one subagent each. Pi's `subagent`
//! extension and jcode's swarm spawns can start several, each its own row.
//! A task delegated to the app runs in a thread of its own, which a click
//! opens.
use std::time::Duration;
use gpui_kit::component::{Icon, IconName, h_flex, v_flex};
use gpui_kit::prelude::FluentBuilder as _;
use gpui_kit::*;
use super::{PANEL_WIDTH, Workspace};
use crate::session::{ChatItem, Session, TaskStatus};
use crate::ui::chat::{describe_call, format_duration};
use crate::ui::icons::Lucide;
use crate::ui::metal::{self, Led, MetalButton, MetalStyled as _, Palette};
/// A subagent as the panel lists it.
struct Subagent {
session: Entity<Session>,
/// Its tool call's place in the thread's transcript.
ix: usize,
/// Tells apart the rows, several of which can share a call.
id: usize,
description: SharedString,
/// The kind of agent, e.g. `Explore`.
kind: Option<SharedString>,
status: TaskStatus,
background: bool,
tool_uses: usize,
tokens: Option<u64>,
elapsed: Option<Duration>,
/// What it's doing: its latest tool call while it works.
activity: Option<(Icon, String)>,
summary: Option<SharedString>,
/// The thread a task delegated to the app runs in, by id.
thread: Option<String>,
}
/// A thread's subagents, in the order they started.
fn subagents_of(session: &Entity<Session>, cx: &App) -> Vec<Subagent> {
let state = session.read(cx);
let thread = session.entity_id().as_u64() as usize;
let mut subagents = Vec::new();
for (ix, item) in state.items.iter().enumerate() {
let ChatItem::Tool(tool) = item else { continue };
for (member, entry) in tool.subagents().into_iter().enumerate() {
subagents.push(Subagent {
session: session.clone(),
ix,
id: thread << 32 | ix << 8 | member,
description: entry.label.into(),
// The default kind goes without saying.
kind: entry.kind.filter(|kind| !kind.is_empty() && kind != "general-purpose").map(Into::into),
status: entry.status,
background: entry.background,
tool_uses: entry.tool_uses,
tokens: entry.tokens,
elapsed: entry.elapsed,
activity: entry.tool.map(|(name, input)| describe_call(&name, &input, &state.cwd)),
summary: entry.summary.map(Into::into),
thread: entry.thread,
});
}
}
subagents
}
/// `812 tokens`, `23.4k tokens`, `1.2M tokens`.
fn format_tokens(tokens: u64) -> String {
match tokens {
0..1_000 => format!("{tokens} tokens"),
1_000..1_000_000 => format!("{:.1}k tokens", tokens as f64 / 1_000.).replace(".0k", "k"),
_ => format!("{:.1}M tokens", tokens as f64 / 1_000_000.).replace(".0M", "M"),
}
}
impl Workspace {
/// How many subagents the open thread has working, for the header's light.
pub(super) fn working_subagents(&self, cx: &App) -> usize {
self.active.as_ref().map_or(0, |session| {
subagents_of(session, cx).iter().filter(|subagent| subagent.status == TaskStatus::Running).count()
})
}
pub(super) fn toggle_subagents(&mut self, cx: &mut Context<Self>) {
// With the browser in its place, the subagents come back.
if self.browser_open {
self.browser_open = false;
self.subagents_open = true;
} else {
self.subagents_open = !self.subagents_open;
}
cx.notify();
}
/// Opens a subagent's thread, if it isn't open, and shows its call; or
/// for a task delegated to the app, the thread it runs in.
fn reveal_subagent(
&mut self,
session: Entity<Session>,
ix: usize,
thread: Option<String>,
window: &mut Window,
cx: &mut Context<Self>,
) {
if let Some((project, child)) = thread.and_then(|thread| self.local_thread(&thread, cx)) {
self.select(project, child, window, cx);
return;
}
if self.active.as_ref() != Some(&session) {
let Some(project) = self.project_of(&session) else { return };
self.select(project, session.clone(), window, cx);
}
if let Some(chat) = self.chats.get(&session.entity_id()).cloned() {
chat.update(cx, |chat, cx| chat.reveal_tool(ix, cx));
}
}
pub(super) fn render_subagents_panel(&self, cx: &mut Context<Self>) -> impl IntoElement {
let p = metal::palette(cx);
let mine = self.active.as_ref().map(|session| subagents_of(session, cx)).unwrap_or_default();
let (working, mut finished): (Vec<Subagent>, Vec<Subagent>) =
mine.into_iter().partition(|subagent| subagent.status == TaskStatus::Running);
finished.reverse();
let empty = working.is_empty() && finished.is_empty();
let header = h_flex()
.h(px(36.))
.pl(px(10.))
.gap(px(10.))
.child(Icon::new(Lucide::Bot).size(px(16.)).text_color(p.icon))
.child(div().flex_1().text_size(px(13.5)).font_weight(FontWeight::MEDIUM).child("Subagents"))
.when(!working.is_empty(), |this| this.child(metal::tag(format!("{} working", working.len()), p)))
.child(
MetalButton::new("close-subagents")
.ghost()
.mini()
.icon(IconName::Close)
.tooltip("Close subagents")
.on_click(cx.listener(|this, _, _, cx| {
this.subagents_open = false;
cx.notify();
})),
);
let section = |title: String, rows: Vec<AnyElement>| {
v_flex()
.gap(px(2.))
.child(div().px(px(10.)).pt(px(8.)).pb(px(4.)).child(metal::engraved(title, p)))
.children(rows)
};
let rows = |subagents: Vec<Subagent>, cx: &mut Context<Self>| -> Vec<AnyElement> {
subagents.into_iter().map(|subagent| self.render_subagent(subagent, p, cx)).collect()
};
let working_count = working.len();
let finished_count = finished.len();
let working_rows = rows(working, cx);
let finished_rows = rows(finished, cx);
v_flex()
.w(px(PANEL_WIDTH))
.flex_none()
.h_full()
.p(px(12.))
.pl(px(0.))
.child(
v_flex()
.max_h_full()
.p(px(6.))
.rounded(px(18.))
.raised(p)
.child(header)
.child(div().px(px(8.)).py(px(4.)).child(metal::rule(p)))
.child(
v_flex()
.id("subagents-list")
.flex_shrink(1.)
.min_h_0()
.overflow_y_scroll()
.when(empty, |this| {
this.child(
v_flex()
.px(px(10.))
.py(px(10.))
.gap(px(4.))
.child(div().text_size(px(13.)).text_color(p.ink2).child("No subagents in this thread"))
.child(div().text_size(px(12.)).text_color(p.ink3).child(
"When the agent hands work to a subagent, it shows up here with what it's doing.",
)),
)
})
.when(working_count > 0, |this| this.child(section("Working".into(), working_rows)))
.when(finished_count > 0, |this| this.child(section(format!("Finished · {finished_count}"), finished_rows))),
),
)
}
/// One subagent: its light, what it was asked to do and for how long,
/// its kind and totals, and while it works, what it's doing now.
fn render_subagent(&self, subagent: Subagent, p: &Palette, cx: &mut Context<Self>) -> AnyElement {
let running = subagent.status == TaskStatus::Running;
let (led, status) = match subagent.status {
TaskStatus::Running => (Led::Live, None),
TaskStatus::Completed => (Led::Off, None),
TaskStatus::Failed => (Led::Failed, Some("Failed")),
TaskStatus::Stopped => (Led::Off, Some("Stopped")),
};
let id = subagent.id;
// A delegated task's agent can stop to ask the user something.
let waiting = running
&& subagent
.thread
.as_ref()
.and_then(|thread| self.local_thread(thread, cx))
.is_some_and(|(_, child)| child.read(cx).needs_attention());
let status = if waiting { Some("Waiting for you") } else { status };
let meta: Vec<String> = [
subagent.kind.as_ref().map(ToString::to_string),
(subagent.tool_uses > 0)
.then(|| format!("{} {}", subagent.tool_uses, if subagent.tool_uses == 1 { "tool" } else { "tools" })),
subagent.tokens.filter(|tokens| *tokens > 0).map(format_tokens),
subagent.background.then(|| "background".to_string()),
]
.into_iter()
.flatten()
.collect();
let (session, ix, child) = (subagent.session.clone(), subagent.ix, subagent.thread.clone());
h_flex()
.id(("subagent", id))
.items_start()
.gap(px(10.))
.px(px(10.))
.py(px(7.))
.rounded(px(12.))
.cursor_pointer()
.hover(|style| style.bg(p.row_hover))
.on_click(cx.listener(move |this, _, window, cx| this.reveal_subagent(session.clone(), ix, child.clone(), window, cx)))
.child(div().flex_none().h(px(18.)).flex().items_center().child(metal::led(("subagent-led", id), led, running, p)))
.child(
v_flex()
.flex_1()
.min_w_0()
.gap(px(2.))
.child(
h_flex()
.gap(px(8.))
.child(
div()
.flex_1()
.min_w_0()
.truncate()
.text_size(px(13.))
.text_color(if running { p.ink } else { p.ink2 })
.child(subagent.description.clone()),
)
.when_some(status, |this, status| {
this.child(
div()
.flex_none()
.text_size(px(11.5))
.text_color(if subagent.status == TaskStatus::Failed { p.red } else { p.ink3 })
.child(status),
)
})
.when_some(subagent.elapsed, |this, elapsed| {
this.child(
div()
.flex_none()
.font_family(metal::MONO)
.text_size(px(11.))
.text_color(p.ink3)
.child(format_duration(elapsed.as_millis() as u64)),
)
}),
)
.when_some(subagent.activity, |this, (icon, label)| {
this.child(
h_flex()
.gap(px(6.))
.text_size(px(12.))
.text_color(p.ink2)
.child(icon.size(px(13.)).text_color(p.ink3))
.child(div().flex_1().min_w_0().truncate().child(label)),
)
})
.when_some(subagent.summary.filter(|_| running), |this, summary| {
this.child(div().text_size(px(12.)).text_color(p.ink3).line_clamp(2).child(summary))
})
.when(!meta.is_empty(), |this| {
this.child(
div()
.font_family(metal::MONO)
.text_size(px(10.5))
.text_color(p.ink3)
.child(meta.join(" · ")),
)
}),
)
.into_any_element()
}
}
#[cfg(test)]
mod tests {
use super::format_tokens;
#[::core::prelude::v1::test]
fn token_counts() {
assert_eq!(format_tokens(812), "812 tokens");
assert_eq!(format_tokens(23_412), "23.4k tokens");
assert_eq!(format_tokens(40_000), "40k tokens");
assert_eq!(format_tokens(1_340_000), "1.3M tokens");
}
}