Vectrum
Sign In

Merge subagent sidebar fixes: lost subagent marks come back, mirrors stay out

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
JjohannSocommitted 2 parents 6ec9d8f 87389dacommitc2561d5

Showing 5 changed files with 99 additions and 3 deletions.

Merge commit — showing changes against the first parent.

+3−0src/remote/mod.rsView File
@@ -59,6 +59,9 @@ pub struct ThreadSummary {
5959 pub total_cost: f64,
6060 /// Whether anything was sent in it yet.
6161 pub has_items: bool,
62+ /// For a subagent's thread, the thread that delegated its task.
63+ #[serde(skip_serializing_if = "Option::is_none")]
64+ pub parent: Option<String>,
6265 }
6366
6467 /// The rest of an open thread's state, sent with its transcript.
+5−0src/session.rsView File
@@ -4521,6 +4521,10 @@ impl Session {
45214521 if let Some(remote) = self.remote.as_mut() {
45224522 remote.attention = summary.attention;
45234523 }
4524+ // The host hands the task's result over; the mirror only stays out
4525+ // of the sidebar.
4526+ self.delegation =
4527+ summary.parent.clone().map(|parent| Delegation { parent, delivered: true, cancelled: false });
45244528 }
45254529
45264530 /// Takes a host's update of a subscribed thread: its state, and its
@@ -4619,6 +4623,7 @@ impl Session {
46194623 branch: self.worktree.as_ref().map(|worktree| worktree.branch.clone()),
46204624 total_cost: self.total_cost,
46214625 has_items: !self.items.is_empty(),
4626+ parent: self.delegation.as_ref().map(|delegation| delegation.parent.clone()),
46224627 }
46234628 }
46244629
+1−0src/ui/workspace.rsView File
@@ -489,6 +489,7 @@ impl Workspace {
489489 session.update(cx, |session, cx| session.send_message(CONTINUE_AFTER_RESTART.into(), Vec::new(), cx));
490490 }
491491 }
492+ self.recover_delegations(cx);
492493 self.project_scope = state
493494 .project_scope
494495 .filter(|path| self.projects.iter().any(|project| project.environment.is_none() && &project.path == path))
+88−2src/ui/workspace/delegation.rsView File
@@ -14,8 +14,8 @@ use super::Workspace;
1414 use crate::models::{Catalog, ProbeStatus};
1515 use crate::orchestrator::{self, CAPABILITIES, DELEGATE, TASK_CANCEL, TASK_STATUS, ToolRequest};
1616 use crate::session::{
17- ChatItem, NewSession, Reasoning, Session, SessionEvent, SessionStatus, SubagentMember, SubagentParent, TaskStatus,
18- brief_input, to_millis,
17+ ChatItem, Delegation, NewSession, Reasoning, Session, SessionEvent, SessionStatus, SubagentMember, SubagentParent,
18+ TaskStatus, brief_input, to_millis,
1919 };
2020 use crate::settings;
2121
@@ -357,6 +357,10 @@ impl Workspace {
357357 /// done, hands the result over.
358358 pub(super) fn track_delegation(&mut self, child: &Entity<Session>, cx: &mut Context<Self>) {
359359 let state = child.read(cx);
360+ // A mirrored subagent's host keeps its parent current.
361+ if state.remote.is_some() {
362+ return;
363+ }
360364 let Some(delegation) = state.delegation.clone() else { return };
361365 let task_state = TaskState::of(state);
362366 let task = state.id.clone();
@@ -465,3 +469,85 @@ fn capabilities(thread: &Session, cx: &App) -> Value {
465469 "tools": [DELEGATE, TASK_STATUS, TASK_CANCEL, CAPABILITIES],
466470 })
467471 }
472+
473+ /// The thread a `delegate_task` call started, from its result: the reply's
474+ /// `childThreadId`, however the agent quoted it.
475+ fn delegated_thread(result: &str) -> Option<String> {
476+ let (_, rest) = result.split_once("childThreadId")?;
477+ let id: String = rest
478+ .trim_start_matches(|c: char| matches!(c, '"' | '\\' | ':') || c.is_whitespace())
479+ .chars()
480+ .take_while(|c| c.is_ascii_alphanumeric() || *c == '-')
481+ .collect();
482+ (!id.is_empty()).then_some(id)
483+ }
484+
485+ impl Workspace {
486+ /// Marks subagents' threads again when a build that didn't know them
487+ /// saved them as ordinary threads, from the `delegate_task` calls that
488+ /// started them, and gives their parents back their rows.
489+ pub(super) fn recover_delegations(&mut self, cx: &mut Context<Self>) {
490+ let sessions: Vec<Entity<Session>> = self
491+ .projects
492+ .iter()
493+ .filter(|project| project.environment.is_none())
494+ .flat_map(|project| project.sessions.clone())
495+ .collect();
496+ let mut started = Vec::new();
497+ for parent in &sessions {
498+ for (ix, item) in parent.read(cx).items.iter().enumerate() {
499+ if let ChatItem::Tool(tool) = item
500+ && tool.is_delegation()
501+ && let Some(child) = tool.result.as_deref().and_then(delegated_thread)
502+ {
503+ started.push((parent.clone(), ix, child));
504+ }
505+ }
506+ }
507+ for (parent, ix, child_id) in started {
508+ let Some(child) = sessions.iter().find(|session| session.read(cx).id == child_id) else { continue };
509+ if child.read(cx).delegation.is_some() || child == &parent {
510+ continue;
511+ }
512+ let parent_id = parent.read(cx).id.clone();
513+ child.update(cx, |child, _| {
514+ child.delegation = Some(Delegation { parent: parent_id, delivered: true, cancelled: false });
515+ });
516+ let state = child.read(cx);
517+ let status = TaskState::of(state).panel_status();
518+ let member = SubagentMember {
519+ id: child_id.clone(),
520+ label: state.title.to_string(),
521+ status: status.key().to_string(),
522+ started: to_millis(state.created),
523+ finished: (status != TaskStatus::Running).then(|| to_millis(state.updated)),
524+ tokens: state.context_tokens,
525+ summary: state.last_answer().as_deref().and_then(last_line),
526+ ..Default::default()
527+ };
528+ parent.update(cx, |parent, _| {
529+ if let Some(ChatItem::Tool(tool)) = parent.items.get_mut(ix) {
530+ let progress = tool.subagent.get_or_insert_with(Default::default);
531+ if !progress.members.iter().any(|member| member.id == child_id) {
532+ progress.members.push(member);
533+ }
534+ }
535+ });
536+ self.dirty.insert(child.entity_id());
537+ self.dirty.insert(parent.entity_id());
538+ }
539+ }
540+ }
541+
542+ #[cfg(test)]
543+ mod tests {
544+ use super::delegated_thread;
545+
546+ #[test]
547+ fn finds_the_child_thread_however_the_result_is_quoted() {
548+ let id = Some("1a127-118828-4".to_string());
549+ assert_eq!(delegated_thread("{\n \"taskId\": \"1a127-118828-4\",\n \"childThreadId\": \"1a127-118828-4\"\n}"), id);
550+ assert_eq!(delegated_thread(r#"[{"type":"text","text":"{\"childThreadId\":\"1a127-118828-4\"}"}]"#), id);
551+ assert_eq!(delegated_thread("The task failed to start."), None);
552+ }
553+ }
+2−1src/ui/workspace/sidebar/legacy.rsView File
@@ -67,7 +67,8 @@ impl Workspace {
6767 .flat_map(|&ix| self.projects[ix].sessions.iter().map(move |session| (ix, session.clone())))
6868 .filter(|(_, session)| {
6969 let state = session.read(cx);
70- !state.archived && (!state.is_blank() || self.draft_of(session, cx).is_some())
70+ // Subagents' threads open from the subagents panel.
71+ !state.archived && !state.is_subagent() && (!state.is_blank() || self.draft_of(session, cx).is_some())
7172 })
7273 .collect();
7374 match settings::get(|settings| settings.thread_order) {