Vectrum
Sign In
312 lines · 13.6 KB · Rust
//! 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. Subagents working in other threads
//! list under them. 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.

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>,
}

/// 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),
            });
        }
    }
    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.
    fn reveal_subagent(&mut self, session: Entity<Session>, ix: usize, window: &mut Window, cx: &mut Context<Self>) {
        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();
        // Subagents working in the other threads.
        let elsewhere: Vec<Subagent> = self
            .projects
            .iter()
            .flat_map(|project| &project.sessions)
            .filter(|session| Some(*session) != self.active.as_ref())
            .flat_map(|session| subagents_of(session, cx))
            .filter(|subagent| subagent.status == TaskStatus::Running)
            .collect();
        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>, other: bool, cx: &mut Context<Self>| -> Vec<AnyElement> {
            subagents.into_iter().map(|subagent| self.render_subagent(subagent, other, p, cx)).collect()
        };
        let working_count = working.len();
        let finished_count = finished.len();
        let elsewhere_count = elsewhere.len();
        let working_rows = rows(working, false, cx);
        let finished_rows = rows(finished, false, cx);
        let elsewhere_rows = rows(elsewhere, true, 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)))
                            .when(elsewhere_count > 0, |this| this.child(section("In other threads".into(), elsewhere_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, other: bool, 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;
        let thread = other.then(|| subagent.session.read(cx).title.clone());
        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) = (subagent.session.clone(), subagent.ix);
        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, 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(thread, |this, thread| {
                        this.child(div().truncate().text_size(px(11.5)).text_color(p.ink3).child(thread))
                    })
                    .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");
    }
}