sidebar: Surface subagent permission requests (#53428)

Bennet Bo Fenner created

Self-Review Checklist:

- [x] I've reviewed my own diff for quality, security, and reliability
- [x] Unsafe blocks (if any) have justifying comments
- [x] The content is consistent with the [UI/UX
checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist)
- [x] Tests cover the new/changed behavior
- [x] Performance impact has been considered and is acceptable

Release Notes:

- (preview only) sidebar: Fixed issue where tool confirmation indicator
would not show up when subagent asks for permissions

Change summary

crates/agent_ui/src/agent_panel.rs       |  8 ++
crates/agent_ui/src/conversation_view.rs | 14 ++++
crates/sidebar/src/sidebar.rs            | 29 ++++++--
crates/sidebar/src/sidebar_tests.rs      | 85 +++++++++++++++++++++++++
4 files changed, 127 insertions(+), 9 deletions(-)

Detailed changes

crates/agent_ui/src/agent_panel.rs 🔗

@@ -1893,6 +1893,14 @@ impl AgentPanel {
         }
     }
 
+    pub fn conversation_views(&self) -> Vec<Entity<ConversationView>> {
+        self.active_conversation_view()
+            .into_iter()
+            .cloned()
+            .chain(self.background_threads.values().cloned())
+            .collect()
+    }
+
     pub fn active_thread_view(&self, cx: &App) -> Option<Entity<ThreadView>> {
         let server_view = self.active_conversation_view()?;
         server_view.read(cx).active_thread().cloned()

crates/agent_ui/src/conversation_view.rs 🔗

@@ -354,6 +354,20 @@ impl ConversationView {
             .pending_tool_call(id, cx)
     }
 
+    pub fn root_thread_has_pending_tool_call(&self, cx: &App) -> bool {
+        let Some(root_thread) = self.root_thread(cx) else {
+            return false;
+        };
+        let root_id = root_thread.read(cx).id.clone();
+        self.as_connected().is_some_and(|connected| {
+            connected
+                .conversation
+                .read(cx)
+                .pending_tool_call(&root_id, cx)
+                .is_some()
+        })
+    }
+
     pub fn root_thread(&self, cx: &App) -> Option<Entity<ThreadView>> {
         match &self.server_state {
             ServerState::Connected(connected) => {

crates/sidebar/src/sidebar.rs 🔗

@@ -4107,11 +4107,14 @@ fn all_thread_infos_for_workspace(
         return None.into_iter().flatten();
     };
     let agent_panel = agent_panel.read(cx);
-
     let threads = agent_panel
-        .parent_threads(cx)
+        .conversation_views()
         .into_iter()
-        .map(|thread_view| {
+        .filter_map(|conversation_view| {
+            let has_pending_tool_call = conversation_view
+                .read(cx)
+                .root_thread_has_pending_tool_call(cx);
+            let thread_view = conversation_view.read(cx).root_thread(cx)?;
             let thread_view_ref = thread_view.read(cx);
             let thread = thread_view_ref.thread.read(cx);
 
@@ -4125,7 +4128,7 @@ fn all_thread_infos_for_workspace(
             let session_id = thread.session_id().clone();
             let is_background = agent_panel.is_background_thread(&session_id);
 
-            let status = if thread.is_waiting_for_confirmation() {
+            let status = if has_pending_tool_call {
                 AgentThreadStatus::WaitingForConfirmation
             } else if thread.had_error() {
                 AgentThreadStatus::Error
@@ -4138,7 +4141,7 @@ fn all_thread_infos_for_workspace(
 
             let diff_stats = thread.action_log().read(cx).diff_stats(cx);
 
-            ActiveThreadInfo {
+            Some(ActiveThreadInfo {
                 session_id,
                 title,
                 status,
@@ -4147,7 +4150,7 @@ fn all_thread_infos_for_workspace(
                 is_background,
                 is_title_generating,
                 diff_stats,
-            }
+            })
         });
 
     Some(threads).into_iter().flatten()
@@ -4310,7 +4313,14 @@ fn dump_single_workspace(workspace: &Workspace, output: &mut String, cx: &gpui::
             let entry_count = thread.entries().len();
             write!(output, "Active thread: {title} (session: {session_id})").ok();
             write!(output, " [{status}, {entry_count} entries").ok();
-            if thread.is_waiting_for_confirmation() {
+            if panel
+                .active_conversation_view()
+                .is_some_and(|conversation_view| {
+                    conversation_view
+                        .read(cx)
+                        .root_thread_has_pending_tool_call(cx)
+                })
+            {
                 write!(output, ", awaiting confirmation").ok();
             }
             writeln!(output, "]").ok();
@@ -4337,7 +4347,10 @@ fn dump_single_workspace(workspace: &Workspace, output: &mut String, cx: &gpui::
                     let entry_count = thread.entries().len();
                     write!(output, "  - {title} (session: {session_id})").ok();
                     write!(output, " [{status}, {entry_count} entries").ok();
-                    if thread.is_waiting_for_confirmation() {
+                    if conversation_view
+                        .read(cx)
+                        .root_thread_has_pending_tool_call(cx)
+                    {
                         write!(output, ", awaiting confirmation").ok();
                     }
                     writeln!(output, "]").ok();

crates/sidebar/src/sidebar_tests.rs 🔗

@@ -1,5 +1,5 @@
 use super::*;
-use acp_thread::StubAgentConnection;
+use acp_thread::{AcpThread, PermissionOptions, StubAgentConnection};
 use agent::ThreadStore;
 use agent_ui::{
     test_support::{active_session_id, open_thread_with_connection, send_message},
@@ -189,6 +189,35 @@ fn focus_sidebar(sidebar: &Entity<Sidebar>, cx: &mut gpui::VisualTestContext) {
     cx.run_until_parked();
 }
 
+fn request_test_tool_authorization(
+    thread: &Entity<AcpThread>,
+    tool_call_id: &str,
+    option_id: &str,
+    cx: &mut gpui::VisualTestContext,
+) {
+    let tool_call_id = acp::ToolCallId::new(tool_call_id);
+    let label = format!("Tool {tool_call_id}");
+    let option_id = acp::PermissionOptionId::new(option_id);
+    let _authorization_task = cx.update(|_, cx| {
+        thread.update(cx, |thread, cx| {
+            thread
+                .request_tool_call_authorization(
+                    acp::ToolCall::new(tool_call_id, label)
+                        .kind(acp::ToolKind::Edit)
+                        .into(),
+                    PermissionOptions::Flat(vec![acp::PermissionOption::new(
+                        option_id,
+                        "Allow",
+                        acp::PermissionOptionKind::AllowOnce,
+                    )]),
+                    cx,
+                )
+                .unwrap()
+        })
+    });
+    cx.run_until_parked();
+}
+
 fn format_linked_worktree_chips(worktrees: &[WorktreeInfo]) -> String {
     let mut seen = Vec::new();
     let mut chips = Vec::new();
@@ -1284,6 +1313,60 @@ async fn test_parallel_threads_shown_with_live_status(cx: &mut TestAppContext) {
     );
 }
 
+#[gpui::test]
+async fn test_subagent_permission_request_marks_parent_sidebar_thread_waiting(
+    cx: &mut TestAppContext,
+) {
+    let project = init_test_project_with_agent_panel("/my-project", cx).await;
+    let (multi_workspace, cx) =
+        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
+    let (sidebar, panel) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
+
+    let connection = StubAgentConnection::new().with_supports_load_session(true);
+    connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
+        acp::ContentChunk::new("Done".into()),
+    )]);
+    open_thread_with_connection(&panel, connection, cx);
+    send_message(&panel, cx);
+
+    let parent_session_id = active_session_id(&panel, cx);
+    save_test_thread_metadata(&parent_session_id, &project, cx).await;
+
+    let subagent_session_id = acp::SessionId::new("subagent-session");
+    cx.update(|_, cx| {
+        let parent_thread = panel.read(cx).active_agent_thread(cx).unwrap();
+        parent_thread.update(cx, |thread: &mut AcpThread, cx| {
+            thread.subagent_spawned(subagent_session_id.clone(), cx);
+        });
+    });
+    cx.run_until_parked();
+
+    let subagent_thread = panel.read_with(cx, |panel, cx| {
+        panel
+            .active_conversation_view()
+            .and_then(|conversation| conversation.read(cx).thread_view(&subagent_session_id))
+            .map(|thread_view| thread_view.read(cx).thread.clone())
+            .expect("Expected subagent thread to be loaded into the conversation")
+    });
+    request_test_tool_authorization(&subagent_thread, "subagent-tool-call", "allow-subagent", cx);
+
+    let parent_status = sidebar.read_with(cx, |sidebar, _cx| {
+        sidebar
+            .contents
+            .entries
+            .iter()
+            .find_map(|entry| match entry {
+                ListEntry::Thread(thread) if thread.metadata.session_id == parent_session_id => {
+                    Some(thread.status)
+                }
+                _ => None,
+            })
+            .expect("Expected parent thread entry in sidebar")
+    });
+
+    assert_eq!(parent_status, AgentThreadStatus::WaitingForConfirmation);
+}
+
 #[gpui::test]
 async fn test_background_thread_completion_triggers_notification(cx: &mut TestAppContext) {
     let project_a = init_test_project_with_agent_panel("/project-a", cx).await;