From d1871d5cf868223e8ea8763ac80557e1b8911fca Mon Sep 17 00:00:00 2001 From: Bennet Bo Fenner Date: Wed, 8 Apr 2026 19:39:06 +0100 Subject: [PATCH] sidebar: Surface subagent permission requests (#53428) 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 --- 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(-) diff --git a/crates/agent_ui/src/agent_panel.rs b/crates/agent_ui/src/agent_panel.rs index 291a5ff0d9da2c2c48f705358e159bc0cbfe7fcb..f7ab64385874925afb014ba3520a28931a8e8e07 100644 --- a/crates/agent_ui/src/agent_panel.rs +++ b/crates/agent_ui/src/agent_panel.rs @@ -1893,6 +1893,14 @@ impl AgentPanel { } } + pub fn conversation_views(&self) -> Vec> { + self.active_conversation_view() + .into_iter() + .cloned() + .chain(self.background_threads.values().cloned()) + .collect() + } + pub fn active_thread_view(&self, cx: &App) -> Option> { let server_view = self.active_conversation_view()?; server_view.read(cx).active_thread().cloned() diff --git a/crates/agent_ui/src/conversation_view.rs b/crates/agent_ui/src/conversation_view.rs index 1bad3c55646f9e912f79210db4afcde89c00e68a..8d3d325f402e8568b31fd4cb4e3774be863643d1 100644 --- a/crates/agent_ui/src/conversation_view.rs +++ b/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> { match &self.server_state { ServerState::Connected(connected) => { diff --git a/crates/sidebar/src/sidebar.rs b/crates/sidebar/src/sidebar.rs index 77d2db85f4ea1269111ad49e6b484647faeed0d2..6d9117fbd86b2273269b8d667e41ebaed13ca996 100644 --- a/crates/sidebar/src/sidebar.rs +++ b/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(); diff --git a/crates/sidebar/src/sidebar_tests.rs b/crates/sidebar/src/sidebar_tests.rs index 72517d732a28594bc2b853227928a5e15d6fe74a..4145b2ab2587aea562c9db0aa2089ec863830b87 100644 --- a/crates/sidebar/src/sidebar_tests.rs +++ b/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, cx: &mut gpui::VisualTestContext) { cx.run_until_parked(); } +fn request_test_tool_authorization( + thread: &Entity, + 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;