Vectrum
Sign In

Subagents a build without them saved as threads become subagents again

A build from before subagents drops a thread's delegation when it saves,
so after going back to it and forward again a subagent's thread showed in
the sidebar. On load, each delegate_task result names the thread it
started; that thread is marked again and its parent gets its row back.
The legacy sidebar leaves subagents out too.

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

Showing 3 changed files with 87 additions and 3 deletions.

+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))
+84−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
@@ -469,3 +469,85 @@ fn capabilities(thread: &Session, cx: &App) -> Value {
469469 "tools": [DELEGATE, TASK_STATUS, TASK_CANCEL, CAPABILITIES],
470470 })
471471 }
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) {