Vectrum
Sign In

Add a subagents panel: who's working on what, live

Each Task/Agent call now tracks when it started and finished, its tokens,
its latest tool call and the CLI's progress summary (task_progress), saved
with the thread so remote clients see it too. The panel (Ctrl+Alt+S, or
the robot in the top bar) lists the open thread's working subagents with
what each is doing, then the finished and failed ones, then subagents
working in other threads; a click jumps to the call in its conversation.

Also fixes a crash when a dimmed working thread card got a second hover
style in the sidebar.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
JjohannSocommitted 1 parent 31037aacommit00c6f6e

Showing 10 changed files with 572 additions and 22 deletions.

+7−0README.mdView File
@@ -104,6 +104,12 @@ Gruvbox, Rosé Pine, Solarized and Dracula.
104104 Sublime Text, Kate or the file manager, whichever are installed), the branch with a
105105 switcher, **Commit** (asks the agent to commit, or commit and push), the size of the
106106 uncommitted changes, and the thread's cost.
107+ - A subagents panel (Ctrl+Alt+S, or the robot in the top bar), in the project panel's
108+ place: every subagent Claude started in the thread, the working ones first with what each
109+ is doing right now (its latest tool call and progress summary), how long it has run, its
110+ kind, tool uses and tokens, then the finished and failed ones. Subagents working in other
111+ threads list underneath. Click one to jump to its call in the conversation. While the
112+ panel is closed, a light on the robot says subagents are working.
107113 - Background tasks: when a command, monitor or subagent Claude left running finishes, the
108114 transcript says so and Claude picks the work back up and reports back on its own. A
109115 long-running command can be sent to the background from its tool card, like Ctrl+B in
@@ -254,6 +260,7 @@ The shortcuts follow T3 Code's default keymap.
254260 | Ctrl+Shift+C / Ctrl+Shift+V | Copy / paste in the terminal |
255261 | Ctrl+Alt+B | Show or hide the right panel (the browser) |
256262 | Ctrl+Shift+B | Show or hide the thread details (project panel) |
263+ | Ctrl+Alt+S | Show or hide the subagents panel |
257264 | Ctrl+O | Add a project folder |
258265 | Ctrl+, | Open or close settings |
259266 | Ctrl+Q | Quit |
+2−0src/main.rsView File
@@ -39,6 +39,7 @@ gpui_kit::actions!(
3939 Interrupt,
4040 ToggleSidebar,
4141 ToggleProjectPanel,
42+ ToggleSubagents,
4243 ToggleBrowser,
4344 FocusSearch,
4445 OpenSettings,
@@ -127,6 +128,7 @@ fn main() {
127128 // The app's own.
128129 KeyBinding::new("ctrl-o", OpenInEditor, None),
129130 KeyBinding::new("ctrl-shift-b", ToggleProjectPanel, None),
131+ KeyBinding::new("ctrl-alt-s", ToggleSubagents, None),
130132 KeyBinding::new("ctrl-shift-j", ToggleBrowser, None),
131133 KeyBinding::new("ctrl-,", OpenSettings, None),
132134 KeyBinding::new("ctrl-q", Quit, None),
+134−9src/session.rsView File
@@ -108,6 +108,78 @@ pub struct ToolCall {
108108 pub task_id: Option<String>,
109109 /// Set once the tool runs as a background task.
110110 pub background: Option<TaskStatus>,
111+ /// What a subagent (Task or Agent tool) is up to; `None` for other tools.
112+ pub subagent: Option<SubagentProgress>,
113+ }
114+
115+ impl ToolCall {
116+ /// Where a subagent stands: running until its tool returns, or, sent to
117+ /// the background, until its task ends. `None` for other tools.
118+ pub fn subagent_status(&self) -> Option<TaskStatus> {
119+ self.subagent.as_ref()?;
120+ Some(match (self.background, self.state) {
121+ (Some(status), ToolState::Running | ToolState::Done) => status,
122+ (_, ToolState::Error) => TaskStatus::Failed,
123+ (None, ToolState::Running) => TaskStatus::Running,
124+ (None, ToolState::Done) => TaskStatus::Completed,
125+ })
126+ }
127+ }
128+
129+ /// A subagent's progress, for the subagents panel. It's saved with the
130+ /// thread, so it reaches remote clients too.
131+ #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
132+ pub struct SubagentProgress {
133+ /// When it started and finished, in milliseconds since the epoch.
134+ pub started: u64,
135+ #[serde(default, skip_serializing_if = "Option::is_none")]
136+ pub finished: Option<u64>,
137+ /// The tokens it has used, as the CLI reports them.
138+ #[serde(default, skip_serializing_if = "Option::is_none")]
139+ pub tokens: Option<u64>,
140+ /// Its latest tool call: the tool and the input's short text fields.
141+ #[serde(default, skip_serializing_if = "Option::is_none")]
142+ pub tool: Option<(String, Value)>,
143+ /// The CLI's running summary of what it's doing.
144+ #[serde(default, skip_serializing_if = "Option::is_none")]
145+ pub summary: Option<String>,
146+ }
147+
148+ impl SubagentProgress {
149+ fn finish(&mut self) {
150+ self.finished.get_or_insert_with(|| to_millis(SystemTime::now()));
151+ }
152+
153+ /// How long it has run, or ran, if that's known: threads saved before
154+ /// subagents were timed, and ones cut off by a restart, don't say.
155+ pub fn elapsed(&self, running: bool) -> Option<Duration> {
156+ let end = match self.finished {
157+ Some(finished) => finished,
158+ None if running => to_millis(SystemTime::now()),
159+ None => return None,
160+ };
161+ (self.started > 0).then(|| Duration::from_millis(end.saturating_sub(self.started)))
162+ }
163+ }
164+
165+ /// Whether a tool starts a subagent.
166+ pub fn is_subagent_tool(name: &str) -> bool {
167+ matches!(name, "Task" | "Agent")
168+ }
169+
170+ /// A tool input cut down to its short text fields: enough to say what the
171+ /// call does, without a file's contents.
172+ fn brief_input(input: &Value) -> Value {
173+ let Some(fields) = input.as_object() else { return Value::Null };
174+ fields
175+ .iter()
176+ .filter_map(|(key, value)| {
177+ let text = value.as_str()?;
178+ let first = text.lines().next().unwrap_or_default();
179+ Some((key.clone(), Value::String(first.chars().take(200).collect())))
180+ })
181+ .collect::<serde_json::Map<_, _>>()
182+ .into()
111183 }
112184
113185 /// Where a background task stands.
@@ -2188,6 +2260,27 @@ impl Session {
21882260 }
21892261 }
21902262 }
2263+ // A subagent's running totals, as it works.
2264+ Some("task_progress") => {
2265+ let Some(tool) = self.find_tool_mut(text("tool_use_id").unwrap_or_default()) else { return };
2266+ let uses = message.pointer("/usage/tool_uses").and_then(Value::as_u64);
2267+ let tokens = message.pointer("/usage/total_tokens").and_then(Value::as_u64);
2268+ if let Some(uses) = uses {
2269+ tool.subagent_tool_count = tool.subagent_tool_count.max(uses as usize);
2270+ }
2271+ if let Some(progress) = tool.subagent.as_mut() {
2272+ progress.tokens = tokens.or(progress.tokens);
2273+ // A background subagent's own messages don't come through.
2274+ if let Some(name) = text("last_tool_name")
2275+ && progress.tool.as_ref().is_none_or(|(tool, _)| tool != name)
2276+ {
2277+ progress.tool = Some((name.to_string(), Value::Null));
2278+ }
2279+ if let Some(summary) = text("summary").filter(|summary| !summary.trim().is_empty()) {
2280+ progress.summary = Some(summary.trim().to_string());
2281+ }
2282+ }
2283+ }
21912284 Some("task_updated") => {
21922285 let backgrounded = message.pointer("/patch/is_backgrounded").and_then(Value::as_bool) == Some(true);
21932286 let tool_use_id = text("task_id")
@@ -2232,6 +2325,14 @@ impl Session {
22322325 if tool.background.is_some() {
22332326 tool.background = Some(status);
22342327 }
2328+ if let Some(progress) = tool.subagent.as_mut() {
2329+ if let Some(tokens) = message.pointer("/usage/total_tokens").and_then(Value::as_u64) {
2330+ progress.tokens = Some(tokens);
2331+ }
2332+ if finished && tool.background.is_some() {
2333+ progress.finish();
2334+ }
2335+ }
22352336 }
22362337 // Long foreground commands report finishing too; only
22372338 // background tasks wake Claude.
@@ -2353,19 +2454,21 @@ impl Session {
23532454 }
23542455 }
23552456
2457+ /// A subagent's own message: counts its tool calls and notes the latest.
23562458 fn count_subagent_tools(&mut self, parent: &str, message: &Value) {
2357- let count = message
2459+ let calls: Vec<&Value> = message
23582460 .get("content")
23592461 .and_then(Value::as_array)
2360- .map(|blocks| {
2361- blocks
2362- .iter()
2363- .filter(|block| block.get("type").and_then(Value::as_str) == Some("tool_use"))
2364- .count()
2365- })
2366- .unwrap_or(0);
2462+ .into_iter()
2463+ .flatten()
2464+ .filter(|block| block.get("type").and_then(Value::as_str) == Some("tool_use"))
2465+ .collect();
23672466 if let Some(tool) = self.find_tool_mut(parent) {
2368- tool.subagent_tool_count += count;
2467+ tool.subagent_tool_count += calls.len();
2468+ if let (Some(progress), Some(call)) = (tool.subagent.as_mut(), calls.last()) {
2469+ let name = call.get("name").and_then(Value::as_str).unwrap_or("Tool").to_string();
2470+ progress.tool = Some((name, brief_input(&call["input"])));
2471+ }
23692472 }
23702473 }
23712474
@@ -2384,6 +2487,12 @@ impl Session {
23842487 if let Some(tool) = self.find_tool_mut(id) {
23852488 tool.result = Some(text);
23862489 tool.state = if is_error { ToolState::Error } else { ToolState::Done };
2490+ // A background subagent's tool returns as it starts.
2491+ if (is_error || tool.background.is_none())
2492+ && let Some(progress) = tool.subagent.as_mut()
2493+ {
2494+ progress.finish();
2495+ }
23872496 if let Some(prompt) = tool.permission.as_mut()
23882497 && prompt.state == PermissionState::Pending
23892498 {
@@ -3073,6 +3182,8 @@ impl Session {
30733182 ask: None,
30743183 task_id: None,
30753184 background: None,
3185+ subagent: is_subagent_tool(name)
3186+ .then(|| SubagentProgress { started: to_millis(SystemTime::now()), ..Default::default() }),
30763187 }));
30773188 self.items.len() - 1
30783189 }
@@ -3131,6 +3242,9 @@ impl Session {
31313242 && tool.background == Some(TaskStatus::Running)
31323243 {
31333244 tool.background = Some(TaskStatus::Stopped);
3245+ if let Some(progress) = tool.subagent.as_mut() {
3246+ progress.finish();
3247+ }
31343248 }
31353249 }
31363250 }
@@ -3142,6 +3256,9 @@ impl Session {
31423256 {
31433257 tool.state = ToolState::Error;
31443258 tool.result.get_or_insert_with(|| reason.to_string());
3259+ if let Some(progress) = tool.subagent.as_mut() {
3260+ progress.finish();
3261+ }
31453262 }
31463263 }
31473264 }
@@ -3232,6 +3349,8 @@ pub enum SavedItem {
32323349 /// background.
32333350 #[serde(default, skip_serializing_if = "Option::is_none")]
32343351 background: Option<String>,
3352+ #[serde(default, skip_serializing_if = "Option::is_none")]
3353+ subagent: Option<SubagentProgress>,
32353354 },
32363355 Notice { error: bool, text: String },
32373356 TaskEvent { summary: String, status: String },
@@ -3308,6 +3427,7 @@ impl Session {
33083427 .as_ref()
33093428 .map(|ask| ask.answers.iter().map(|answer| answer.as_ref().map(ToString::to_string)).collect()),
33103429 background: tool.background.map(|status| status.key().to_string()),
3430+ subagent: tool.subagent.clone(),
33113431 },
33123432 ChatItem::Notice(kind, text) => {
33133433 SavedItem::Notice { error: *kind == NoticeKind::Error, text: text.to_string() }
@@ -3448,6 +3568,7 @@ impl Session {
34483568 subagent_tool_count,
34493569 answers,
34503570 background,
3571+ subagent,
34513572 } => {
34523573 let pending = live && permission.as_deref() == Some("pending");
34533574 let ask = answers.and_then(|answers| {
@@ -3458,6 +3579,8 @@ impl Session {
34583579 ask.current = ask.answers.iter().position(Option::is_none).unwrap_or(0);
34593580 Some(ask)
34603581 });
3582+ // Threads saved before subagents were tracked still list them.
3583+ let subagent = subagent.or_else(|| is_subagent_tool(&name).then(SubagentProgress::default));
34613584 let background = background.map(|status| match TaskStatus::from_cli(&status) {
34623585 // Background tasks die with the agent process.
34633586 TaskStatus::Running if !live => TaskStatus::Stopped,
@@ -3491,6 +3614,8 @@ impl Session {
34913614 ask,
34923615 task_id: None,
34933616 background,
3617+ // Threads saved before subagents were tracked still list them.
3618+ subagent,
34943619 })
34953620 }
34963621 SavedItem::Notice { error, text } => {
+35−0src/ui/chat.rsView File
@@ -9,6 +9,8 @@ mod footer;
99 mod strip;
1010 mod timeline;
1111
12+ pub(crate) use timeline::{describe_call, format_duration};
13+
1214 use std::{
1315 path::PathBuf,
1416 time::{Duration, SystemTime},
@@ -374,6 +376,39 @@ impl ChatView {
374376 self.composer.update(cx, |composer, cx| composer.set_submit_on_enter(enter_sends, cx));
375377 }
376378
379+ /// Shows a tool call in the conversation, unfolded: opens the finished
380+ /// turn and the run of calls it's folded into, and scrolls to it. The
381+ /// subagents panel's rows lead here.
382+ pub fn reveal_tool(&mut self, ix: usize, cx: &mut Context<Self>) {
383+ let session = self.session.read(cx);
384+ if !matches!(session.items.get(ix), Some(ChatItem::Tool(_))) {
385+ return;
386+ }
387+ let fold = settings::get(|settings| settings.fold_turns);
388+ let hidden = session
389+ .finished_turn(ix)
390+ .filter(|turn| turn.answer != Some(ix) && session.turn_expanded(turn.end) != fold)
391+ .map(|turn| turn.end);
392+ let group = timeline::group_of(&session.items, ix);
393+ if group.len() > 1 {
394+ self.expanded_groups.insert(group[0]);
395+ }
396+ self.session.update(cx, |session, cx| {
397+ if let Some(end) = hidden {
398+ session.toggle_turn(end, cx);
399+ }
400+ if let Some(ChatItem::Tool(tool)) = session.items.get_mut(ix) {
401+ tool.expanded = true;
402+ }
403+ cx.notify();
404+ });
405+ self.scroller.update(cx, |scroller, cx| {
406+ scroller.remeasure(cx);
407+ scroller.scroll_to_item(ix, cx);
408+ });
409+ cx.notify();
410+ }
411+
377412 pub fn focus_composer(&self, window: &mut Window, cx: &mut App) {
378413 self.composer.update(cx, |composer, cx| composer.focus(window, cx));
379414 }
+17−5src/ui/chat/timeline.rsView File
@@ -9,6 +9,8 @@ use std::collections::HashSet;
99 use std::path::Path;
1010 use std::time::SystemTime;
1111
12+ use serde_json::Value;
13+
1214 use gpui_kit::component::{
1315 Icon, IconName, h_flex,
1416 menu::{DropdownMenu as _, PopupMenuItem},
@@ -99,9 +101,19 @@ fn relative_path(path: &str, cwd: &Path) -> String {
99101 }
100102
101103 fn present(tool: &ToolCall, cwd: &Path) -> Presentation {
102- let field = |keys: &[&str]| tool_field(&tool.input, keys);
104+ present_call(&tool.name, &tool.input, cwd)
105+ }
106+
107+ /// A tool call's icon and label as the timeline shows them, for a call
108+ /// known only by its name and input (a subagent's).
109+ pub(crate) fn describe_call(name: &str, input: &Value, cwd: &Path) -> (Icon, String) {
110+ let presentation = present_call(name, input, cwd);
111+ (presentation.icon, presentation.label)
112+ }
113+
114+ fn present_call(name: &str, input: &Value, cwd: &Path) -> Presentation {
115+ let field = |keys: &[&str]| tool_field(input, keys);
103116 let first_line = |text: String| text.lines().next().unwrap_or_default().chars().take(200).collect::<String>();
104- let name = tool.name.as_str();
105117 let (action, label) = match name.to_ascii_lowercase().as_str() {
106118 "bash" => (Action::Command, field(&["command"]).map(first_line).unwrap_or_else(|| "Ran command".into())),
107119 "bashoutput" | "killshell" => (Action::Command, name.to_string()),
@@ -129,7 +141,7 @@ fn present(tool: &ToolCall, cwd: &Path) -> Presentation {
129141 (Action::Other, format!("{server}: {tool_name}"))
130142 }
131143 _ => {
132- let summary = crate::session::tool_summary(name, &tool.input, cwd);
144+ let summary = crate::session::tool_summary(name, input, cwd);
133145 (Action::Other, if summary.is_empty() { name.to_string() } else { format!("{name} {summary}") })
134146 }
135147 };
@@ -188,7 +200,7 @@ fn group_summary(actions: &[Action]) -> String {
188200 }
189201
190202 /// `4.2s`, `42s`, `2m 14s`, `1h 3m 20s`, as T3 Code writes durations.
191- pub(super) fn format_duration(ms: u64) -> String {
203+ pub(crate) fn format_duration(ms: u64) -> String {
192204 if ms < 1_000 {
193205 return format!("{}ms", ms.max(1));
194206 }
@@ -220,7 +232,7 @@ fn is_hidden(item: &ChatItem) -> bool {
220232 }
221233
222234 /// The run of consecutive tool calls `ix` belongs to: its work rows' indices.
223- fn group_of(items: &[ChatItem], ix: usize) -> Vec<usize> {
235+ pub(super) fn group_of(items: &[ChatItem], ix: usize) -> Vec<usize> {
224236 let mut start = ix;
225237 while start > 0 && (is_work(&items[start - 1]) || is_hidden(&items[start - 1])) {
226238 start -= 1;
+2−1src/ui/settings/keybindings.rsView File
@@ -27,6 +27,7 @@ const COMMANDS: &[Command] = &[
2727 Command { id: "sidebar.toggle", label: "Sidebar: Toggle", actions: &["vectrum_code::ToggleSidebar"] },
2828 Command { id: "rightPanel.toggle", label: "Right Panel: Toggle", actions: &["vectrum_code::ToggleRightPanel"] },
2929 Command { id: "threadPanel.toggle", label: "Thread Panel: Toggle", actions: &["vectrum_code::ToggleProjectPanel"] },
30+ Command { id: "subagentsPanel.toggle", label: "Subagents Panel: Toggle", actions: &["vectrum_code::ToggleSubagents"] },
3031 Command {
3132 id: "commandPalette.toggle",
3233 label: "Command Palette: Toggle",
@@ -99,7 +100,7 @@ const COMPOSER_COMMANDS: &[(&str, &str)] = &[
99100 /// T3 Code's groups, in page order; a command joins the first group
100101 /// listing its id's prefix.
101102 const GROUPS: &[(&str, &[&str])] = &[
102- ("Navigation", &["sidebar", "rightPanel", "commandPalette", "filePicker", "projectSearch", "editor"]),
103+ ("Navigation", &["sidebar", "rightPanel", "threadPanel", "subagentsPanel", "commandPalette", "filePicker", "projectSearch", "editor"]),
103104 ("Threads", &["thread", "chat", "pullRequest"]),
104105 ("Composer", &["composer", "modelPicker"]),
105106 ("Terminal", &["terminal"]),
+12−3src/ui/workspace.rsView File
@@ -42,7 +42,7 @@ use crate::{
4242 CopyThreadReference, CycleAppearance, FocusSearch, NewThread, NewThreadLocal, NewThreadWithoutProject, NextThread,
4343 CloseTerminal, NewTerminal, OpenInEditor, OpenProject, OpenSettings, OpenUsage, PinThread, PreviousThread, SelectTheme,
4444 SettleThread, SplitTerminal, SplitTerminalVertical, ToggleBrowser, ToggleCommandPalette, ToggleProjectPanel,
45- ToggleRightPanel, ToggleSidebar, ToggleTerminal, UndoThreadAction,
45+ ToggleRightPanel, ToggleSidebar, ToggleSubagents, ToggleTerminal, UndoThreadAction,
4646 };
4747
4848 mod actions;
@@ -51,6 +51,7 @@ mod header;
5151 mod palette;
5252 mod remote;
5353 mod sidebar;
54+ mod subagents;
5455 mod terminal_drawer;
5556
5657 use actions::UndoEntry;
@@ -219,6 +220,8 @@ pub struct Workspace {
219220 panel_open: bool,
220221 /// The browser takes the project panel's place while open.
221222 browser_open: bool,
223+ /// The subagents panel, in the project panel's place.
224+ subagents_open: bool,
222225 settled_open: bool,
223226 snoozed_open: bool,
224227 working_open: bool,
@@ -371,6 +374,7 @@ impl Workspace {
371374 sidebar_open: true,
372375 panel_open: true,
373376 browser_open: false,
377+ subagents_open: false,
374378 settled_open: false,
375379 snoozed_open: false,
376380 working_open: false,
@@ -595,7 +599,7 @@ impl Workspace {
595599 }
596600 // Working threads show how long they've worked.
597601 let working = self.projects.iter().flat_map(|project| &project.sessions).any(|session| session.read(cx).is_working());
598- if working && self.sidebar_open {
602+ if working && (self.sidebar_open || self.subagents_open) {
599603 cx.notify();
600604 }
601605 // Repaint about once a minute so thread ages stay current.
@@ -1750,8 +1754,9 @@ impl Workspace {
17501754
17511755 fn toggle_project_panel(&mut self, cx: &mut Context<Self>) {
17521756 // With the browser in its place, the project panel comes back.
1753- if self.browser_open {
1757+ if self.browser_open || self.subagents_open {
17541758 self.browser_open = false;
1759+ self.subagents_open = false;
17551760 self.panel_open = true;
17561761 } else {
17571762 self.panel_open = !self.panel_open;
@@ -2069,7 +2074,9 @@ impl Render for Workspace {
20692074 cx.set_global(directory);
20702075 let browser = self.shown_browser(window, cx);
20712076 let p = metal::palette(cx);
2077+ let subagents_visible = self.subagents_open && browser.is_none();
20722078 let panel_visible = self.panel_open
2079+ && !subagents_visible
20732080 && browser.is_none()
20742081 && self.active_project().is_some()
20752082 && window.viewport_size().width >= px(PANEL_MIN_WINDOW);
@@ -2167,6 +2174,7 @@ impl Render for Workspace {
21672174 cx.notify();
21682175 }))
21692176 .on_action(cx.listener(|this, _: &ToggleProjectPanel, _, cx| this.toggle_project_panel(cx)))
2177+ .on_action(cx.listener(|this, _: &ToggleSubagents, _, cx| this.toggle_subagents(cx)))
21702178 .on_action(cx.listener(|this, _: &ToggleBrowser, _, cx| {
21712179 this.browser_open = !this.browser_open;
21722180 cx.notify();
@@ -2217,6 +2225,7 @@ impl Render for Workspace {
22172225 .children(self.render_terminal_drawer(window, cx)),
22182226 )
22192227 .when(panel_visible, |this| this.child(self.render_panel(cx)))
2228+ .when(subagents_visible, |this| this.child(self.render_subagents_panel(cx)))
22202229 .when_some(browser, |this, browser| this.child(browser)),
22212230 ),
22222231 ),
+19−1src/ui/workspace/header.rsView File
@@ -210,9 +210,11 @@ impl Workspace {
210210 }
211211
212212 /// T3's panel toggles: thread details (our project panel), the terminal
213- /// drawer, and the right panel (the browser).
213+ /// drawer, and the right panel (the browser), plus the subagents panel.
214214 fn render_layout_controls(&self, panel_visible: bool, cx: &mut Context<Self>) -> impl IntoElement {
215215 let p = metal::palette(cx);
216+ let subagents_shown = self.subagents_open && !self.browser_open;
217+ let subagents_working = !subagents_shown && self.working_subagents(cx) > 0;
216218 let live = !self.browser_open
217219 && self.active.as_ref().is_some_and(|session| BrowserWatch::is_live(&session.read(cx).id, cx));
218220 h_flex()
@@ -227,6 +229,22 @@ impl Workspace {
227229 .tooltip("Toggle thread details (Ctrl+Shift+B)")
228230 .on_click(cx.listener(|this, _, _, cx| this.toggle_project_panel(cx))),
229231 )
232+ .child(
233+ div()
234+ .relative()
235+ .child(
236+ MetalButton::new("toggle-subagents")
237+ .ghost()
238+ .mini()
239+ .icon(Lucide::Bot)
240+ .selected(subagents_shown)
241+ .tooltip("Toggle subagents (Ctrl+Alt+S)")
242+ .on_click(cx.listener(|this, _, _, cx| this.toggle_subagents(cx))),
243+ )
244+ .when(subagents_working, |this| {
245+ this.child(div().absolute().top(px(1.)).right(px(1.)).child(metal::led("subagents-live", Led::Live, true, p)))
246+ }),
247+ )
230248 .child(if self.terminal_available(cx) {
231249 MetalButton::new("toggle-terminal-drawer")
232250 .ghost()
+21−3src/ui/workspace/sidebar.rsView File
@@ -1372,12 +1372,30 @@ impl Workspace {
13721372 } else if selected {
13731373 this.bg(metal::vertical(p.pressed)).shadow(p.pressed_sh.clone())
13741374 } else if draft {
1375- this.bg(p.amber.opacity(0.06)).hover(|style| style.bg(p.amber.opacity(0.12)))
1375+ this.bg(p.amber.opacity(0.06))
13761376 } else {
1377- this.hover(|style| style.bg(p.row_hover))
1377+ this
1378+ }
1379+ })
1380+ .when(working_dim, |this| this.opacity(0.7))
1381+ // A row takes one hover style, so the tint and the undimming share it.
1382+ .map(|this| {
1383+ let tint = match (active || selected, draft) {
1384+ (true, _) => None,
1385+ (false, true) => Some(p.amber.opacity(0.12)),
1386+ (false, false) => Some(p.row_hover),
1387+ };
1388+ if tint.is_none() && !working_dim {
1389+ return this;
13781390 }
1391+ this.hover(move |style| {
1392+ let style = match tint {
1393+ Some(tint) => style.bg(tint),
1394+ None => style,
1395+ };
1396+ if working_dim { style.opacity(1.) } else { style }
1397+ })
13791398 })
1380- .when(working_dim, |this| this.opacity(0.7).hover(|style| style.opacity(1.)))
13811399 .map(|this| self.drag_and_drop(this, session, if pinned { DropZone::Pinned } else { DropZone::Active }, cx))
13821400 .map(|this| self.with_hover_card(this, project_ix, session, cx))
13831401 .on_click({
+323−0src/ui/workspace/subagents.rsaddedView File
@@ -0,0 +1,323 @@
1+ //! The subagents panel: every subagent the open thread's agent started, the
2+ //! working ones first with what each is doing right now (its latest tool
3+ //! call, its progress summary, how long it's run, its tool uses and
4+ //! tokens), then the finished ones. Subagents working in other threads
5+ //! list under them. A click shows the subagent's call in its conversation.
6+
7+ use std::time::Duration;
8+
9+ use gpui_kit::component::{Icon, IconName, h_flex, v_flex};
10+ use gpui_kit::prelude::FluentBuilder as _;
11+ use gpui_kit::*;
12+
13+ use super::{PANEL_WIDTH, Workspace};
14+ use crate::session::{ChatItem, Session, TaskStatus, tool_field};
15+ use crate::ui::chat::{describe_call, format_duration};
16+ use crate::ui::icons::Lucide;
17+ use crate::ui::metal::{self, Led, MetalButton, MetalStyled as _, Palette};
18+
19+ /// A subagent as the panel lists it.
20+ struct Subagent {
21+ session: Entity<Session>,
22+ /// Its tool call's place in the thread's transcript.
23+ ix: usize,
24+ description: SharedString,
25+ /// The kind of agent, e.g. `Explore`.
26+ kind: Option<SharedString>,
27+ status: TaskStatus,
28+ background: bool,
29+ tool_uses: usize,
30+ tokens: Option<u64>,
31+ elapsed: Option<Duration>,
32+ /// What it's doing: its latest tool call while it works.
33+ activity: Option<(Icon, String)>,
34+ summary: Option<SharedString>,
35+ }
36+
37+ /// A thread's subagents, in the order they started.
38+ fn subagents_of(session: &Entity<Session>, cx: &App) -> Vec<Subagent> {
39+ let state = session.read(cx);
40+ state
41+ .items
42+ .iter()
43+ .enumerate()
44+ .filter_map(|(ix, item)| {
45+ let ChatItem::Tool(tool) = item else { return None };
46+ let progress = tool.subagent.as_ref()?;
47+ let status = tool.subagent_status()?;
48+ let running = status == TaskStatus::Running;
49+ let description = tool_field(&tool.input, &["description"])
50+ .filter(|description| !description.trim().is_empty())
51+ .unwrap_or_else(|| "Subagent".into());
52+ Some(Subagent {
53+ session: session.clone(),
54+ ix,
55+ description: description.into(),
56+ // The default kind goes without saying.
57+ kind: tool_field(&tool.input, &["subagent_type"])
58+ .filter(|kind| !kind.is_empty() && kind != "general-purpose")
59+ .map(Into::into),
60+ status,
61+ background: tool.background.is_some(),
62+ tool_uses: tool.subagent_tool_count,
63+ tokens: progress.tokens,
64+ elapsed: progress.elapsed(running),
65+ activity: progress
66+ .tool
67+ .as_ref()
68+ .filter(|_| running)
69+ .map(|(name, input)| describe_call(name, input, &state.cwd)),
70+ summary: progress.summary.clone().map(Into::into),
71+ })
72+ })
73+ .collect()
74+ }
75+
76+ /// `812 tokens`, `23.4k tokens`, `1.2M tokens`.
77+ fn format_tokens(tokens: u64) -> String {
78+ match tokens {
79+ 0..1_000 => format!("{tokens} tokens"),
80+ 1_000..1_000_000 => format!("{:.1}k tokens", tokens as f64 / 1_000.).replace(".0k", "k"),
81+ _ => format!("{:.1}M tokens", tokens as f64 / 1_000_000.).replace(".0M", "M"),
82+ }
83+ }
84+
85+ impl Workspace {
86+ /// How many subagents the open thread has working, for the header's light.
87+ pub(super) fn working_subagents(&self, cx: &App) -> usize {
88+ self.active.as_ref().map_or(0, |session| {
89+ session
90+ .read(cx)
91+ .items
92+ .iter()
93+ .filter(|item| matches!(item, ChatItem::Tool(tool) if tool.subagent_status() == Some(TaskStatus::Running)))
94+ .count()
95+ })
96+ }
97+
98+ pub(super) fn toggle_subagents(&mut self, cx: &mut Context<Self>) {
99+ // With the browser in its place, the subagents come back.
100+ if self.browser_open {
101+ self.browser_open = false;
102+ self.subagents_open = true;
103+ } else {
104+ self.subagents_open = !self.subagents_open;
105+ }
106+ cx.notify();
107+ }
108+
109+ /// Opens a subagent's thread, if it isn't open, and shows its call.
110+ fn reveal_subagent(&mut self, session: Entity<Session>, ix: usize, window: &mut Window, cx: &mut Context<Self>) {
111+ if self.active.as_ref() != Some(&session) {
112+ let Some(project) = self.project_of(&session) else { return };
113+ self.select(project, session.clone(), window, cx);
114+ }
115+ if let Some(chat) = self.chats.get(&session.entity_id()).cloned() {
116+ chat.update(cx, |chat, cx| chat.reveal_tool(ix, cx));
117+ }
118+ }
119+
120+ pub(super) fn render_subagents_panel(&self, cx: &mut Context<Self>) -> impl IntoElement {
121+ let p = metal::palette(cx);
122+ let mine = self.active.as_ref().map(|session| subagents_of(session, cx)).unwrap_or_default();
123+ let (working, mut finished): (Vec<Subagent>, Vec<Subagent>) =
124+ mine.into_iter().partition(|subagent| subagent.status == TaskStatus::Running);
125+ finished.reverse();
126+ // Subagents working in the other threads.
127+ let elsewhere: Vec<Subagent> = self
128+ .projects
129+ .iter()
130+ .flat_map(|project| &project.sessions)
131+ .filter(|session| Some(*session) != self.active.as_ref())
132+ .flat_map(|session| subagents_of(session, cx))
133+ .filter(|subagent| subagent.status == TaskStatus::Running)
134+ .collect();
135+ let empty = working.is_empty() && finished.is_empty();
136+
137+ let header = h_flex()
138+ .h(px(36.))
139+ .pl(px(10.))
140+ .gap(px(10.))
141+ .child(Icon::new(Lucide::Bot).size(px(16.)).text_color(p.icon))
142+ .child(div().flex_1().text_size(px(13.5)).font_weight(FontWeight::MEDIUM).child("Subagents"))
143+ .when(!working.is_empty(), |this| this.child(metal::tag(format!("{} working", working.len()), p)))
144+ .child(
145+ MetalButton::new("close-subagents")
146+ .ghost()
147+ .mini()
148+ .icon(IconName::Close)
149+ .tooltip("Close subagents")
150+ .on_click(cx.listener(|this, _, _, cx| {
151+ this.subagents_open = false;
152+ cx.notify();
153+ })),
154+ );
155+
156+ let section = |title: String, rows: Vec<AnyElement>| {
157+ v_flex()
158+ .gap(px(2.))
159+ .child(div().px(px(10.)).pt(px(8.)).pb(px(4.)).child(metal::engraved(title, p)))
160+ .children(rows)
161+ };
162+ let rows = |subagents: Vec<Subagent>, other: bool, cx: &mut Context<Self>| -> Vec<AnyElement> {
163+ subagents.into_iter().map(|subagent| self.render_subagent(subagent, other, p, cx)).collect()
164+ };
165+ let working_count = working.len();
166+ let finished_count = finished.len();
167+ let elsewhere_count = elsewhere.len();
168+ let working_rows = rows(working, false, cx);
169+ let finished_rows = rows(finished, false, cx);
170+ let elsewhere_rows = rows(elsewhere, true, cx);
171+
172+ v_flex()
173+ .w(px(PANEL_WIDTH))
174+ .flex_none()
175+ .h_full()
176+ .p(px(12.))
177+ .pl(px(0.))
178+ .child(
179+ v_flex()
180+ .max_h_full()
181+ .p(px(6.))
182+ .rounded(px(18.))
183+ .raised(p)
184+ .child(header)
185+ .child(div().px(px(8.)).py(px(4.)).child(metal::rule(p)))
186+ .child(
187+ v_flex()
188+ .id("subagents-list")
189+ .flex_shrink(1.)
190+ .min_h_0()
191+ .overflow_y_scroll()
192+ .when(empty, |this| {
193+ this.child(
194+ v_flex()
195+ .px(px(10.))
196+ .py(px(10.))
197+ .gap(px(4.))
198+ .child(div().text_size(px(13.)).text_color(p.ink2).child("No subagents in this thread"))
199+ .child(div().text_size(px(12.)).text_color(p.ink3).child(
200+ "When the agent hands work to a subagent, it shows up here with what it's doing.",
201+ )),
202+ )
203+ })
204+ .when(working_count > 0, |this| this.child(section("Working".into(), working_rows)))
205+ .when(finished_count > 0, |this| this.child(section(format!("Finished · {finished_count}"), finished_rows)))
206+ .when(elsewhere_count > 0, |this| this.child(section("In other threads".into(), elsewhere_rows))),
207+ ),
208+ )
209+ }
210+
211+ /// One subagent: its light, what it was asked to do and for how long,
212+ /// its kind and totals, and while it works, what it's doing now.
213+ fn render_subagent(&self, subagent: Subagent, other: bool, p: &Palette, cx: &mut Context<Self>) -> AnyElement {
214+ let running = subagent.status == TaskStatus::Running;
215+ let (led, status) = match subagent.status {
216+ TaskStatus::Running => (Led::Live, None),
217+ TaskStatus::Completed => (Led::Off, None),
218+ TaskStatus::Failed => (Led::Failed, Some("Failed")),
219+ TaskStatus::Stopped => (Led::Off, Some("Stopped")),
220+ };
221+ let id = (subagent.session.entity_id().as_u64() as usize) << 20 | subagent.ix;
222+ let thread = other.then(|| subagent.session.read(cx).title.clone());
223+ let meta: Vec<String> = [
224+ subagent.kind.as_ref().map(ToString::to_string),
225+ (subagent.tool_uses > 0)
226+ .then(|| format!("{} {}", subagent.tool_uses, if subagent.tool_uses == 1 { "tool" } else { "tools" })),
227+ subagent.tokens.filter(|tokens| *tokens > 0).map(format_tokens),
228+ subagent.background.then(|| "background".to_string()),
229+ ]
230+ .into_iter()
231+ .flatten()
232+ .collect();
233+ let (session, ix) = (subagent.session.clone(), subagent.ix);
234+ h_flex()
235+ .id(("subagent", id))
236+ .items_start()
237+ .gap(px(10.))
238+ .px(px(10.))
239+ .py(px(7.))
240+ .rounded(px(12.))
241+ .cursor_pointer()
242+ .hover(|style| style.bg(p.row_hover))
243+ .on_click(cx.listener(move |this, _, window, cx| this.reveal_subagent(session.clone(), ix, window, cx)))
244+ .child(div().flex_none().h(px(18.)).flex().items_center().child(metal::led(("subagent-led", id), led, running, p)))
245+ .child(
246+ v_flex()
247+ .flex_1()
248+ .min_w_0()
249+ .gap(px(2.))
250+ .child(
251+ h_flex()
252+ .gap(px(8.))
253+ .child(
254+ div()
255+ .flex_1()
256+ .min_w_0()
257+ .truncate()
258+ .text_size(px(13.))
259+ .text_color(if running { p.ink } else { p.ink2 })
260+ .child(subagent.description.clone()),
261+ )
262+ .when_some(status, |this, status| {
263+ this.child(
264+ div()
265+ .flex_none()
266+ .text_size(px(11.5))
267+ .text_color(if subagent.status == TaskStatus::Failed { p.red } else { p.ink3 })
268+ .child(status),
269+ )
270+ })
271+ .when_some(subagent.elapsed, |this, elapsed| {
272+ this.child(
273+ div()
274+ .flex_none()
275+ .font_family(metal::MONO)
276+ .text_size(px(11.))
277+ .text_color(p.ink3)
278+ .child(format_duration(elapsed.as_millis() as u64)),
279+ )
280+ }),
281+ )
282+ .when_some(thread, |this, thread| {
283+ this.child(div().truncate().text_size(px(11.5)).text_color(p.ink3).child(thread))
284+ })
285+ .when_some(subagent.activity, |this, (icon, label)| {
286+ this.child(
287+ h_flex()
288+ .gap(px(6.))
289+ .text_size(px(12.))
290+ .text_color(p.ink2)
291+ .child(icon.size(px(13.)).text_color(p.ink3))
292+ .child(div().flex_1().min_w_0().truncate().child(label)),
293+ )
294+ })
295+ .when_some(subagent.summary.filter(|_| running), |this, summary| {
296+ this.child(div().text_size(px(12.)).text_color(p.ink3).line_clamp(2).child(summary))
297+ })
298+ .when(!meta.is_empty(), |this| {
299+ this.child(
300+ div()
301+ .font_family(metal::MONO)
302+ .text_size(px(10.5))
303+ .text_color(p.ink3)
304+ .child(meta.join(" · ")),
305+ )
306+ }),
307+ )
308+ .into_any_element()
309+ }
310+ }
311+
312+ #[cfg(test)]
313+ mod tests {
314+ use super::format_tokens;
315+
316+ #[::core::prelude::v1::test]
317+ fn token_counts() {
318+ assert_eq!(format_tokens(812), "812 tokens");
319+ assert_eq!(format_tokens(23_412), "23.4k tokens");
320+ assert_eq!(format_tokens(40_000), "40k tokens");
321+ assert_eq!(format_tokens(1_340_000), "1.3M tokens");
322+ }
323+ }