agent_ui: Retain running threads when navigating away

Mikayla Maki and Eric Holk created

Add support for keeping background agent threads alive in the AgentPanel.
When the user navigates away from a running thread, its ConnectionView is
stashed in a background_views map instead of being dropped. This preserves
execution, event subscriptions, and OS notifications.

Key changes:
- Add background_views HashMap to AgentPanel for retaining running threads
- Replace _active_view_observation with a single detached observation per
  ConnectionView, set up at creation time, that dynamically emits either
  ActiveViewChanged or BackgroundThreadChanged
- Add retain_running_thread to stash running ConnectionViews on navigation
- Add load_agent_thread fast path to promote background threads back to active
- Add parent_threads() API for sidebar consumption
- Add AgentPanelEvent::BackgroundThreadChanged event variant
Co-authored-by: Eric Holk <eric@zed.dev>

Change summary

crates/agent_ui/src/agent_panel.rs | 98 +++++++++++++++++++++++++------
1 file changed, 77 insertions(+), 21 deletions(-)

Detailed changes

crates/agent_ui/src/agent_panel.rs 🔗

@@ -9,7 +9,7 @@ use std::{
     time::Duration,
 };
 
-use acp_thread::{AcpThread, AgentSessionInfo, MentionUri};
+use acp_thread::{AcpThread, AgentSessionInfo, MentionUri, ThreadStatus};
 use agent::{ContextServerRegistry, SharedThread, ThreadStore};
 use agent_client_protocol as acp;
 use agent_servers::AgentServer;
@@ -50,6 +50,7 @@ use assistant_slash_command::SlashCommandWorkingSet;
 use assistant_text_thread::{TextThread, TextThreadEvent, TextThreadSummary};
 use client::UserStore;
 use cloud_api_types::Plan;
+use collections::HashMap;
 use editor::{Anchor, AnchorRangeExt as _, Editor, EditorEvent, MultiBuffer};
 use extension::ExtensionEvents;
 use extension_host::ExtensionStore;
@@ -513,7 +514,7 @@ pub struct AgentPanel {
     focus_handle: FocusHandle,
     active_view: ActiveView,
     previous_view: Option<ActiveView>,
-    _active_view_observation: Option<Subscription>,
+    background_views: HashMap<acp::SessionId, Entity<ConnectionView>>,
     new_thread_menu_handle: PopoverMenuHandle<ContextMenu>,
     agent_panel_menu_handle: PopoverMenuHandle<ContextMenu>,
     agent_navigation_menu_handle: PopoverMenuHandle<ContextMenu>,
@@ -798,7 +799,7 @@ impl AgentPanel {
             focus_handle: cx.focus_handle(),
             context_server_registry,
             previous_view: None,
-            _active_view_observation: None,
+            background_views: HashMap::default(),
             new_thread_menu_handle: PopoverMenuHandle::default(),
             agent_panel_menu_handle: PopoverMenuHandle::default(),
             agent_navigation_menu_handle: PopoverMenuHandle::default(),
@@ -1566,6 +1567,49 @@ impl AgentPanel {
         }
     }
 
+    /// Returns the primary thread views for all retained connections: the
+    /// active thread plus any background threads that are still running or
+    /// completed but unseen.
+    pub fn parent_threads(&self, cx: &App) -> Vec<Entity<ThreadView>> {
+        let mut views = Vec::new();
+
+        if let Some(server_view) = self.as_active_server_view() {
+            if let Some(thread_view) = server_view.read(cx).parent_thread(cx) {
+                views.push(thread_view);
+            }
+        }
+
+        for server_view in self.background_views.values() {
+            if let Some(thread_view) = server_view.read(cx).parent_thread(cx) {
+                views.push(thread_view);
+            }
+        }
+
+        views
+    }
+
+    fn retain_running_thread(&mut self, old_view: ActiveView, cx: &mut Context<Self>) {
+        let ActiveView::AgentThread { server_view } = old_view else {
+            return;
+        };
+
+        let Some(thread_view) = server_view.read(cx).parent_thread(cx) else {
+            return;
+        };
+
+        let thread = &thread_view.read(cx).thread;
+        let (status, session_id) = {
+            let thread = thread.read(cx);
+            (thread.status(), thread.session_id().clone())
+        };
+
+        if status != ThreadStatus::Generating {
+            return;
+        }
+
+        self.background_views.insert(session_id, server_view);
+    }
+
     pub(crate) fn active_native_agent_thread(&self, cx: &App) -> Option<Entity<agent::Thread>> {
         match &self.active_view {
             ActiveView::AgentThread { server_view, .. } => {
@@ -1604,31 +1648,23 @@ impl AgentPanel {
         let current_is_config = matches!(self.active_view, ActiveView::Configuration);
         let new_is_config = matches!(new_view, ActiveView::Configuration);
 
-        let current_is_special = current_is_history || current_is_config;
-        let new_is_special = new_is_history || new_is_config;
+        let current_is_overlay = current_is_history || current_is_config;
+        let new_is_overlay = new_is_history || new_is_config;
 
-        if current_is_uninitialized || (current_is_special && !new_is_special) {
+        if current_is_uninitialized || (current_is_overlay && !new_is_overlay) {
             self.active_view = new_view;
-        } else if !current_is_special && new_is_special {
+        } else if !current_is_overlay && new_is_overlay {
             self.previous_view = Some(std::mem::replace(&mut self.active_view, new_view));
         } else {
-            if !new_is_special {
-                self.previous_view = None;
+            let old_view = std::mem::replace(&mut self.active_view, new_view);
+            if !new_is_overlay {
+                if let Some(previous) = self.previous_view.take() {
+                    self.retain_running_thread(previous, cx);
+                }
             }
-            self.active_view = new_view;
+            self.retain_running_thread(old_view, cx);
         }
 
-        self._active_view_observation = match &self.active_view {
-            ActiveView::AgentThread { server_view } => {
-                Some(cx.observe(server_view, |this, _, cx| {
-                    cx.emit(AgentPanelEvent::ActiveViewChanged);
-                    this.serialize(cx);
-                    cx.notify();
-                }))
-            }
-            _ => None,
-        };
-
         let is_in_agent_history = matches!(
             self.active_view,
             ActiveView::History {
@@ -1824,6 +1860,11 @@ impl AgentPanel {
         window: &mut Window,
         cx: &mut Context<Self>,
     ) {
+        if let Some(server_view) = self.background_views.remove(&thread.session_id) {
+            self.set_active_view(ActiveView::AgentThread { server_view }, true, window, cx);
+            return;
+        }
+
         let Some(agent) = self.selected_external_agent() else {
             return;
         };
@@ -1867,6 +1908,20 @@ impl AgentPanel {
             )
         });
 
+        cx.observe(&server_view, |this, server_view, cx| {
+            let is_active = this
+                .as_active_server_view()
+                .is_some_and(|active| active.entity_id() == server_view.entity_id());
+            if is_active {
+                cx.emit(AgentPanelEvent::ActiveViewChanged);
+                this.serialize(cx);
+            } else {
+                cx.emit(AgentPanelEvent::BackgroundThreadChanged);
+            }
+            cx.notify();
+        })
+        .detach();
+
         self.set_active_view(ActiveView::AgentThread { server_view }, true, window, cx);
     }
 }
@@ -1900,6 +1955,7 @@ fn agent_panel_dock_position(cx: &App) -> DockPosition {
 
 pub enum AgentPanelEvent {
     ActiveViewChanged,
+    BackgroundThreadChanged,
 }
 
 impl EventEmitter<PanelEvent> for AgentPanel {}