Notify when tool is finished (#27459)

Richard Feldman , Agus Zubiaga , and Danilo Leal created

Now if a tool call finishes (or is blocked on confirmation) and the Zed
window is not active, you get a notification popup. You can turn it off
with a setting.

<img width="420" alt="Screenshot 2025-03-25 at 5 19 25 PM"
src="https://github.com/user-attachments/assets/bdf7b6b8-4428-4b46-8b09-e0be140f8a51"
/>
<img width="420 alt="Screenshot 2025-03-25 at 5 18 13 PM"
src="https://github.com/user-attachments/assets/1325e7b8-cd5a-44b9-a82d-5db928ad3cfc"
/>

Release Notes:

- N/A

---------

Co-authored-by: Agus Zubiaga <hi@aguz.me>
Co-authored-by: Danilo Leal <daniloleal09@gmail.com>

Change summary

Cargo.lock                                          |   1 
assets/settings/default.json                        |   3 
crates/assistant2/Cargo.toml                        |   1 
crates/assistant2/src/active_thread.rs              |  78 +++++++++
crates/assistant2/src/thread.rs                     |   6 
crates/assistant2/src/ui.rs                         |   2 
crates/assistant2/src/ui/tool_ready_pop_up.rs       | 115 +++++++++++++++
crates/assistant_settings/src/assistant_settings.rs |  13 +
8 files changed, 216 insertions(+), 3 deletions(-)

Detailed changes

Cargo.lock 🔗

@@ -491,6 +491,7 @@ dependencies = [
  "prompt_store",
  "proto",
  "rand 0.8.5",
+ "release_channel",
  "rope",
  "serde",
  "serde_json",

assets/settings/default.json 🔗

@@ -654,7 +654,8 @@
           "thinking": true
         }
       }
-    }
+    },
+    "notify_when_agent_waiting": true
   },
   // The settings for slash commands.
   "slash_commands": {

crates/assistant2/Cargo.toml 🔗

@@ -61,6 +61,7 @@ project.workspace = true
 prompt_library.workspace = true
 prompt_store.workspace = true
 proto.workspace = true
+release_channel.workspace = true
 rope.workspace = true
 serde.workspace = true
 serde_json.workspace = true

crates/assistant2/src/active_thread.rs 🔗

@@ -4,8 +4,9 @@ use crate::thread::{
 };
 use crate::thread_store::ThreadStore;
 use crate::tool_use::{PendingToolUseStatus, ToolUse, ToolUseStatus};
-use crate::ui::ContextPill;
+use crate::ui::{ContextPill, ToolReadyPopUp, ToolReadyPopupEvent};
 
+use assistant_settings::AssistantSettings;
 use collections::HashMap;
 use editor::{Editor, MultiBuffer};
 use gpui::{
@@ -13,6 +14,7 @@ use gpui::{
     Animation, AnimationExt, AnyElement, App, ClickEvent, DefiniteLength, EdgesRefinement, Empty,
     Entity, Focusable, Length, ListAlignment, ListOffset, ListState, ScrollHandle, StyleRefinement,
     Subscription, Task, TextStyleRefinement, Transformation, UnderlineStyle, WeakEntity,
+    WindowHandle,
 };
 use language::{Buffer, LanguageRegistry};
 use language_model::{LanguageModelRegistry, LanguageModelToolUseId, Role};
@@ -42,6 +44,7 @@ pub struct ActiveThread {
     expanded_tool_uses: HashMap<LanguageModelToolUseId, bool>,
     expanded_thinking_segments: HashMap<(MessageId, usize), bool>,
     last_error: Option<ThreadError>,
+    pop_ups: Vec<WindowHandle<ToolReadyPopUp>>,
     _subscriptions: Vec<Subscription>,
 }
 
@@ -244,6 +247,7 @@ impl ActiveThread {
             }),
             editing_message: None,
             last_error: None,
+            pop_ups: Vec::new(),
             _subscriptions: subscriptions,
         };
 
@@ -370,7 +374,14 @@ impl ActiveThread {
             ThreadEvent::StreamedCompletion | ThreadEvent::SummaryChanged => {
                 self.save_thread(cx);
             }
-            ThreadEvent::DoneStreaming => {}
+            ThreadEvent::DoneStreaming => {
+                if !self.thread().read(cx).is_generating() {
+                    self.show_notification("Your changes have been applied.", window, cx);
+                }
+            }
+            ThreadEvent::ToolConfirmationNeeded => {
+                self.show_notification("There's a tool confirmation needed.", window, cx);
+            }
             ThreadEvent::StreamedAssistantText(message_id, text) => {
                 if let Some(rendered_message) = self.rendered_messages_by_id.get_mut(&message_id) {
                     rendered_message.append_text(text, window, cx);
@@ -497,6 +508,59 @@ impl ActiveThread {
         }
     }
 
+    fn show_notification(
+        &mut self,
+        caption: impl Into<SharedString>,
+        window: &mut Window,
+        cx: &mut Context<'_, ActiveThread>,
+    ) {
+        if !window.is_window_active()
+            && self.pop_ups.is_empty()
+            && AssistantSettings::get_global(cx).notify_when_agent_waiting
+        {
+            let caption = caption.into();
+
+            for screen in cx.displays() {
+                let options = ToolReadyPopUp::window_options(screen, cx);
+
+                if let Some(screen_window) = cx
+                    .open_window(options, |_, cx| {
+                        cx.new(|_| ToolReadyPopUp::new(caption.clone()))
+                    })
+                    .log_err()
+                {
+                    if let Some(pop_up) = screen_window.entity(cx).log_err() {
+                        cx.subscribe_in(&pop_up, window, {
+                            |this, _, event, window, cx| match event {
+                                ToolReadyPopupEvent::Accepted => {
+                                    let handle = window.window_handle();
+                                    cx.activate(true); // Switch back to the Zed application
+
+                                    // If there are multiple Zed windows, activate the correct one.
+                                    cx.defer(move |cx| {
+                                        handle
+                                            .update(cx, |_view, window, _cx| {
+                                                window.activate_window();
+                                            })
+                                            .log_err();
+                                    });
+
+                                    this.dismiss_notifications(cx);
+                                }
+                                ToolReadyPopupEvent::Dismissed => {
+                                    this.dismiss_notifications(cx);
+                                }
+                            }
+                        })
+                        .detach();
+
+                        self.pop_ups.push(screen_window);
+                    }
+                }
+            }
+        }
+    }
+
     /// Spawns a task to save the active thread.
     ///
     /// Only one task to save the thread will be in flight at a time.
@@ -1635,6 +1699,16 @@ impl ActiveThread {
                     .into_any()
             })
     }
+
+    fn dismiss_notifications(&mut self, cx: &mut Context<'_, ActiveThread>) {
+        for window in self.pop_ups.drain(..) {
+            window
+                .update(cx, |_, window, _| {
+                    window.remove_window();
+                })
+                .ok();
+        }
+    }
 }
 
 impl Render for ActiveThread {

crates/assistant2/src/thread.rs 🔗

@@ -352,6 +352,10 @@ impl Thread {
             .filter(|tool_use| tool_use.status.needs_confirmation())
     }
 
+    pub fn has_pending_tool_uses(&self) -> bool {
+        !self.tool_use.pending_tool_uses().is_empty()
+    }
+
     pub fn checkpoint_for_message(&self, id: MessageId) -> Option<ThreadCheckpoint> {
         self.checkpoints_by_message.get(&id).cloned()
     }
@@ -1161,6 +1165,7 @@ impl Thread {
                         messages.clone(),
                         tool,
                     );
+                    cx.emit(ThreadEvent::ToolConfirmationNeeded);
                 } else {
                     self.run_tool(
                         tool_use.id.clone(),
@@ -1539,6 +1544,7 @@ pub enum ThreadEvent {
         canceled: bool,
     },
     CheckpointChanged,
+    ToolConfirmationNeeded,
 }
 
 impl EventEmitter<ThreadEvent> for Thread {}

crates/assistant2/src/ui/tool_ready_pop_up.rs 🔗

@@ -0,0 +1,115 @@
+use gpui::{
+    point, App, Context, EventEmitter, IntoElement, PlatformDisplay, Size, Window,
+    WindowBackgroundAppearance, WindowBounds, WindowDecorations, WindowKind, WindowOptions,
+};
+use release_channel::ReleaseChannel;
+use std::rc::Rc;
+use theme;
+use ui::{prelude::*, Render};
+
+pub struct ToolReadyPopUp {
+    caption: SharedString,
+}
+
+impl ToolReadyPopUp {
+    pub fn new(caption: impl Into<SharedString>) -> Self {
+        Self {
+            caption: caption.into(),
+        }
+    }
+
+    pub fn window_options(screen: Rc<dyn PlatformDisplay>, cx: &App) -> WindowOptions {
+        let size = Size {
+            width: px(440.),
+            height: px(72.),
+        };
+
+        let notification_margin_width = px(16.);
+        let notification_margin_height = px(-48.);
+
+        let bounds = gpui::Bounds::<Pixels> {
+            origin: screen.bounds().top_right()
+                - point(
+                    size.width + notification_margin_width,
+                    notification_margin_height,
+                ),
+            size,
+        };
+
+        let app_id = ReleaseChannel::global(cx).app_id();
+
+        WindowOptions {
+            window_bounds: Some(WindowBounds::Windowed(bounds)),
+            titlebar: None,
+            focus: false,
+            show: true,
+            kind: WindowKind::PopUp,
+            is_movable: false,
+            display_id: Some(screen.id()),
+            window_background: WindowBackgroundAppearance::Transparent,
+            app_id: Some(app_id.to_owned()),
+            window_min_size: None,
+            window_decorations: Some(WindowDecorations::Client),
+        }
+    }
+}
+
+pub enum ToolReadyPopupEvent {
+    Accepted,
+    Dismissed,
+}
+
+impl EventEmitter<ToolReadyPopupEvent> for ToolReadyPopUp {}
+
+impl Render for ToolReadyPopUp {
+    fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
+        let ui_font = theme::setup_ui_font(window, cx);
+        let line_height = window.line_height();
+
+        h_flex()
+            .size_full()
+            .p_3()
+            .gap_4()
+            .justify_between()
+            .elevation_3(cx)
+            .text_ui(cx)
+            .font(ui_font)
+            .border_color(cx.theme().colors().border)
+            .rounded_xl()
+            .child(
+                h_flex()
+                    .items_start()
+                    .gap_2()
+                    .child(
+                        h_flex().h(line_height).justify_center().child(
+                            Icon::new(IconName::Info)
+                                .size(IconSize::Small)
+                                .color(Color::Muted),
+                        ),
+                    )
+                    .child(
+                        v_flex()
+                            .child(Headline::new("Agent Panel").size(HeadlineSize::XSmall))
+                            .child(Label::new(self.caption.clone()).color(Color::Muted)),
+                    ),
+            )
+            .child(
+                h_flex()
+                    .gap_0p5()
+                    .child(
+                        Button::new("open", "View Panel")
+                            .style(ButtonStyle::Tinted(ui::TintColor::Accent))
+                            .on_click({
+                                cx.listener(move |_this, _event, _, cx| {
+                                    cx.emit(ToolReadyPopupEvent::Accepted);
+                                })
+                            }),
+                    )
+                    .child(Button::new("dismiss", "Dismiss").on_click({
+                        cx.listener(move |_, _event, _, cx| {
+                            cx.emit(ToolReadyPopupEvent::Dismissed);
+                        })
+                    })),
+            )
+    }
+}

crates/assistant_settings/src/assistant_settings.rs 🔗

@@ -73,6 +73,7 @@ pub struct AssistantSettings {
     pub enable_experimental_live_diffs: bool,
     pub profiles: IndexMap<Arc<str>, AgentProfile>,
     pub always_allow_tool_actions: bool,
+    pub notify_when_agent_waiting: bool,
 }
 
 impl AssistantSettings {
@@ -175,6 +176,7 @@ impl AssistantSettingsContent {
                     enable_experimental_live_diffs: None,
                     profiles: None,
                     always_allow_tool_actions: None,
+                    notify_when_agent_waiting: None,
                 },
                 VersionedAssistantSettingsContent::V2(settings) => settings.clone(),
             },
@@ -198,6 +200,7 @@ impl AssistantSettingsContent {
                 enable_experimental_live_diffs: None,
                 profiles: None,
                 always_allow_tool_actions: None,
+                notify_when_agent_waiting: None,
             },
         }
     }
@@ -329,6 +332,7 @@ impl Default for VersionedAssistantSettingsContent {
             enable_experimental_live_diffs: None,
             profiles: None,
             always_allow_tool_actions: None,
+            notify_when_agent_waiting: None,
         })
     }
 }
@@ -372,6 +376,10 @@ pub struct AssistantSettingsContentV2 {
     ///
     /// Default: false
     always_allow_tool_actions: Option<bool>,
+    /// Whether to show a popup notification when the agent is waiting for user input.
+    ///
+    /// Default: true
+    notify_when_agent_waiting: Option<bool>,
 }
 
 #[derive(Clone, Debug, Serialize, Deserialize, JsonSchema, PartialEq)]
@@ -519,6 +527,10 @@ impl Settings for AssistantSettings {
                 &mut settings.always_allow_tool_actions,
                 value.always_allow_tool_actions,
             );
+            merge(
+                &mut settings.notify_when_agent_waiting,
+                value.notify_when_agent_waiting,
+            );
 
             if let Some(profiles) = value.profiles {
                 settings
@@ -611,6 +623,7 @@ mod tests {
                             enable_experimental_live_diffs: None,
                             profiles: None,
                             always_allow_tool_actions: None,
+                            notify_when_agent_waiting: None,
                         }),
                     )
                 },