Notify when tool is finished (#27459)

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>
This commit is contained in:
Richard Feldman 2025-03-25 20:52:44 -04:00 committed by GitHub
parent a605b66ce1
commit 931a6d6f40
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 216 additions and 3 deletions

1
Cargo.lock generated
View file

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

View file

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

View file

@ -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

View file

@ -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 {

View file

@ -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 {}

View file

@ -1,3 +1,5 @@
mod context_pill;
mod tool_ready_pop_up;
pub use context_pill::*;
pub use tool_ready_pop_up::*;

View file

@ -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);
})
})),
)
}
}

View file

@ -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,
}),
)
},