From 248cc134c6f8ecf1e537d1f43f0b5643bea82c94 Mon Sep 17 00:00:00 2001 From: Bennet Bo Fenner Date: Wed, 8 Apr 2026 14:39:12 +0100 Subject: [PATCH] agent: Show notifications when thread is not the active one (#53149) We now always show a notification for a thread if it is not the active one (even if the sidebar is open). Also made clicking on the notification actually open the corresponding thread (previously we would just changed the workspace) 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: - N/A --- crates/agent_ui/src/conversation_view.rs | 219 +++++++++++++++++-- crates/agent_ui/src/ui/agent_notification.rs | 2 +- 2 files changed, 206 insertions(+), 15 deletions(-) diff --git a/crates/agent_ui/src/conversation_view.rs b/crates/agent_ui/src/conversation_view.rs index cb42cb92ddcd0799acb4ccb6839a38cd5690d93c..80190858151b2cf79500290a95ee0d0b6a4e8c97 100644 --- a/crates/agent_ui/src/conversation_view.rs +++ b/crates/agent_ui/src/conversation_view.rs @@ -2308,22 +2308,31 @@ impl ConversationView { self.show_notification(caption, icon, window, cx); } - fn agent_panel_visible(&self, multi_workspace: &Entity, cx: &App) -> bool { + fn is_visible(&self, multi_workspace: &Entity, cx: &Context) -> bool { let Some(workspace) = self.workspace.upgrade() else { return false; }; - multi_workspace.read(cx).workspace() == &workspace && AgentPanel::is_visible(&workspace, cx) + multi_workspace.read(cx).workspace() == &workspace + && AgentPanel::is_visible(&workspace, cx) + && multi_workspace + .read(cx) + .workspace() + .read(cx) + .panel::(cx) + .map_or(false, |p| { + p.read(cx).active_conversation_view().map(|c| c.entity_id()) + == Some(cx.entity_id()) + }) } - fn agent_status_visible(&self, window: &Window, cx: &App) -> bool { + fn agent_status_visible(&self, window: &Window, cx: &Context) -> bool { if !window.is_window_active() { return false; } if let Some(multi_workspace) = window.root::().flatten() { - multi_workspace.read(cx).sidebar_open() - || self.agent_panel_visible(&multi_workspace, cx) + self.is_visible(&multi_workspace, cx) } else { self.workspace .upgrade() @@ -2332,10 +2341,10 @@ impl ConversationView { } #[cfg(feature = "audio")] - fn play_notification_sound(&self, window: &Window, cx: &mut App) { + fn play_notification_sound(&self, window: &Window, cx: &mut Context) { let visible = window.is_window_active() && if let Some(mw) = window.root::().flatten() { - self.agent_panel_visible(&mw, cx) + self.is_visible(&mw, cx) } else { self.workspace .upgrade() @@ -2366,19 +2375,47 @@ impl ConversationView { return; } + let Some(root_thread) = self.root_thread(cx) else { + return; + }; + let root_thread = root_thread.read(cx).thread.read(cx); + let root_session_id = root_thread.session_id().clone(); + let root_work_dirs = root_thread.work_dirs().cloned(); + let root_title = root_thread.title(); + // TODO: Change this once we have title summarization for external agents. let title = self.agent.agent_id().0; match settings.notify_when_agent_waiting { NotifyWhenAgentWaiting::PrimaryScreen => { if let Some(primary) = cx.primary_display() { - self.pop_up(icon, caption.into(), title, window, primary, cx); + self.pop_up( + icon, + caption.into(), + title, + root_session_id, + root_work_dirs, + root_title, + window, + primary, + cx, + ); } } NotifyWhenAgentWaiting::AllScreens => { let caption = caption.into(); for screen in cx.displays() { - self.pop_up(icon, caption.clone(), title.clone(), window, screen, cx); + self.pop_up( + icon, + caption.clone(), + title.clone(), + root_session_id.clone(), + root_work_dirs.clone(), + root_title.clone(), + window, + screen, + cx, + ); } } NotifyWhenAgentWaiting::Never => { @@ -2392,6 +2429,9 @@ impl ConversationView { icon: IconName, caption: SharedString, title: SharedString, + root_session_id: acp::SessionId, + root_work_dirs: Option, + root_title: Option, window: &mut Window, screen: Rc, cx: &mut Context, @@ -2421,7 +2461,7 @@ impl ConversationView { .entry(screen_window) .or_insert_with(Vec::new) .push(cx.subscribe_in(&pop_up, window, { - |this, _, event, window, cx| match event { + move |this, _, event, window, cx| match event { AgentNotificationEvent::Accepted => { let Some(handle) = window.window_handle().downcast::() else { @@ -2431,6 +2471,10 @@ impl ConversationView { cx.activate(true); let workspace_handle = this.workspace.clone(); + let agent = this.connection_key.clone(); + let root_session_id = root_session_id.clone(); + let root_work_dirs = root_work_dirs.clone(); + let root_title = root_title.clone(); cx.defer(move |cx| { handle @@ -2439,6 +2483,22 @@ impl ConversationView { if let Some(workspace) = workspace_handle.upgrade() { multi_workspace.activate(workspace.clone(), window, cx); workspace.update(cx, |workspace, cx| { + workspace.reveal_panel::(window, cx); + if let Some(panel) = + workspace.panel::(cx) + { + panel.update(cx, |panel, cx| { + panel.load_agent_thread( + agent.clone(), + root_session_id.clone(), + root_work_dirs.clone(), + root_title.clone(), + true, + window, + cx, + ); + }); + } workspace.focus_panel::(window, cx); }); } @@ -3426,6 +3486,109 @@ pub(crate) mod tests { ); } + #[gpui::test] + async fn test_notification_when_different_conversation_is_active_in_visible_panel( + cx: &mut TestAppContext, + ) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + + cx.update(|cx| { + cx.update_flags(true, vec!["agent-v2".to_string()]); + agent::ThreadStore::init_global(cx); + language_model::LanguageModelRegistry::test(cx); + ::set_global(fs.clone(), cx); + }); + + let project = Project::test(fs, [], cx).await; + let multi_workspace_handle = + cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + + let workspace = multi_workspace_handle + .read_with(cx, |mw, _cx| mw.workspace().clone()) + .unwrap(); + + let cx = &mut VisualTestContext::from_window(multi_workspace_handle.into(), cx); + + let panel = workspace.update_in(cx, |workspace, window, cx| { + let panel = cx.new(|cx| crate::AgentPanel::new(workspace, None, window, cx)); + workspace.add_panel(panel.clone(), window, cx); + workspace.focus_panel::(window, cx); + panel + }); + + cx.run_until_parked(); + + panel.update_in(cx, |panel, window, cx| { + panel.open_external_thread_with_server( + Rc::new(StubAgentServer::default_response()), + window, + cx, + ); + }); + + cx.run_until_parked(); + + panel.read_with(cx, |panel, cx| { + assert!(crate::AgentPanel::is_visible(&workspace, cx)); + assert!(panel.active_conversation_view().is_some()); + }); + + let thread_store = cx.update(|_window, cx| cx.new(|cx| ThreadStore::new(cx))); + let connection_store = + cx.update(|_window, cx| cx.new(|cx| AgentConnectionStore::new(project.clone(), cx))); + + let conversation_view = cx.update(|window, cx| { + cx.new(|cx| { + ConversationView::new( + Rc::new(StubAgentServer::default_response()), + connection_store, + Agent::Custom { id: "Test".into() }, + None, + None, + None, + None, + workspace.downgrade(), + project.clone(), + Some(thread_store), + None, + window, + cx, + ) + }) + }); + + cx.run_until_parked(); + + panel.read_with(cx, |panel, _cx| { + assert_ne!( + panel + .active_conversation_view() + .map(|view| view.entity_id()), + Some(conversation_view.entity_id()), + "The visible panel should still be showing a different conversation" + ); + }); + + let message_editor = message_editor(&conversation_view, cx); + message_editor.update_in(cx, |editor, window, cx| { + editor.set_text("Hello", window, cx); + }); + + active_thread(&conversation_view, cx) + .update_in(cx, |view, window, cx| view.send(window, cx)); + + cx.run_until_parked(); + + assert!( + cx.windows() + .iter() + .any(|window| window.downcast::().is_some()), + "Expected notification when a different conversation is active in the visible panel" + ); + } + #[gpui::test] async fn test_notification_when_workspace_is_background_in_multi_workspace( cx: &mut TestAppContext, @@ -3454,12 +3617,23 @@ pub(crate) mod tests { let cx = &mut VisualTestContext::from_window(multi_workspace_handle.into(), cx); - workspace1.update_in(cx, |workspace, window, cx| { + let panel = workspace1.update_in(cx, |workspace, window, cx| { let panel = cx.new(|cx| crate::AgentPanel::new(workspace, None, window, cx)); - workspace.add_panel(panel, window, cx); + workspace.add_panel(panel.clone(), window, cx); // Open the dock and activate the agent panel so it's visible workspace.focus_panel::(window, cx); + panel + }); + + cx.run_until_parked(); + + panel.update_in(cx, |panel, window, cx| { + panel.open_external_thread_with_server( + Rc::new(StubAgentServer::new(RestoredAvailableCommandsConnection)), + window, + cx, + ); }); cx.run_until_parked(); @@ -3476,11 +3650,10 @@ pub(crate) mod tests { let connection_store = cx.update(|_window, cx| cx.new(|cx| AgentConnectionStore::new(project1.clone(), cx))); - let agent = StubAgentServer::default_response(); let conversation_view = cx.update(|window, cx| { cx.new(|cx| { ConversationView::new( - Rc::new(agent), + Rc::new(StubAgentServer::new(RestoredAvailableCommandsConnection)), connection_store, Agent::Custom { id: "Test".into() }, None, @@ -3498,6 +3671,13 @@ pub(crate) mod tests { }); cx.run_until_parked(); + let root_session_id = conversation_view + .read_with(cx, |view, cx| { + view.root_thread(cx) + .map(|thread| thread.read(cx).thread.read(cx).session_id().clone()) + }) + .expect("Conversation view should have a root thread"); + let message_editor = message_editor(&conversation_view, cx); message_editor.update_in(cx, |editor, window, cx| { editor.set_text("Hello", window, cx); @@ -3555,6 +3735,17 @@ pub(crate) mod tests { ); }) .unwrap(); + + panel.read_with(cx, |panel, cx| { + let active_session_id = panel + .active_agent_thread(cx) + .map(|thread| thread.read(cx).session_id().clone()); + assert_eq!( + active_session_id, + Some(root_session_id), + "Expected accepting the notification to load the notified thread in AgentPanel" + ); + }); } #[gpui::test] diff --git a/crates/agent_ui/src/ui/agent_notification.rs b/crates/agent_ui/src/ui/agent_notification.rs index 18a4161f1df99988177462059870234f81e48b5c..bcd67df50d8366ef24be034ce70de04fe22ed1d4 100644 --- a/crates/agent_ui/src/ui/agent_notification.rs +++ b/crates/agent_ui/src/ui/agent_notification.rs @@ -179,7 +179,7 @@ impl Render for AgentNotification { .gap_1() .items_center() .child( - Button::new("open", "View Panel") + Button::new("open", "View") .style(ButtonStyle::Tinted(ui::TintColor::Accent)) .full_width() .on_click({