From b4e090c1cdb88027e089ae5fd0c239d91fdb80d5 Mon Sep 17 00:00:00 2001 From: Daniel Peng Date: Mon, 4 May 2026 17:08:27 -0700 Subject: [PATCH 1/5] In-app notifications for child agents --- .../agent_management_model.rs | 76 ++++++++++++++++--- 1 file changed, 65 insertions(+), 11 deletions(-) diff --git a/app/src/ai/agent_management/agent_management_model.rs b/app/src/ai/agent_management/agent_management_model.rs index c17f10518..d252d8df1 100644 --- a/app/src/ai/agent_management/agent_management_model.rs +++ b/app/src/ai/agent_management/agent_management_model.rs @@ -262,7 +262,9 @@ impl AgentNotificationsModel { return; }; - if updated_conversation.should_exclude_from_navigation() { + if updated_conversation.should_exclude_from_navigation() + && !updated_conversation.is_child_agent_conversation() + { return; } @@ -312,16 +314,53 @@ impl AgentNotificationsModel { ) { let origin = NotificationOrigin::Conversation(conversation_id); + let ai_history_model = BlocklistAIHistoryModel::as_ref(ctx); + let conversation = ai_history_model.conversation(&conversation_id); + let is_child = conversation.is_some_and(|c| c.is_child_agent_conversation()); + + // For child conversations, check whether the parent conversation is open + // instead, since child agents don't have their own agent view — they are + // visible via the parent's ChildAgentStatusCard. + let is_open = if is_child { + conversation + .and_then(|c| c.parent_conversation_id()) + .is_some_and(|parent_id| { + ActiveAgentViewsModel::as_ref(ctx).is_conversation_open(parent_id, ctx) + }) + } else { + ActiveAgentViewsModel::as_ref(ctx).is_conversation_open(conversation_id, ctx) + }; + // If the conversation view is no longer open, don't create notifications for it // (there's nothing to navigate to when clicking them). - if !ActiveAgentViewsModel::as_ref(ctx).is_conversation_open(conversation_id, ctx) { + if !is_open { self.pending_artifacts.remove(&conversation_id); self.remove_notification_by_source(origin, ctx); return; } - let title = latest_query.unwrap_or_else(|| "Agent task".to_owned()); - let metadata = TerminalViewMetadata::lookup(terminal_view_id, ctx); + // For child agent conversations, resolve the parent's terminal_view_id so that + // clicking the notification navigates to the parent's pane (the child's pane is + // hidden). Also use the child's agent_name as the notification title. + let (effective_terminal_view_id, title) = if is_child { + let parent_terminal_view_id = conversation + .and_then(|c| c.parent_conversation_id()) + .and_then(|parent_id| { + ai_history_model.terminal_view_id_for_conversation(&parent_id) + }) + .unwrap_or(terminal_view_id); + let child_name = conversation + .and_then(|c| c.agent_name()) + .map(|name| name.to_owned()) + .or(latest_query) + .unwrap_or_else(|| "Child agent".to_owned()); + (parent_terminal_view_id, child_name) + } else { + let title = latest_query.unwrap_or_else(|| "Agent task".to_owned()); + (terminal_view_id, title) + }; + + let metadata = TerminalViewMetadata::lookup(effective_terminal_view_id, ctx); let oz_agent = NotificationSourceAgent::Oz { is_ambient: metadata.is_ambient, }; @@ -333,13 +372,18 @@ impl AgentNotificationsModel { } ConversationStatus::Success => { let artifacts = self.flush_pending_artifacts(conversation_id); + let message = if is_child { + "Child agent completed." + } else { + "Task completed." + }; self.add_notification( title, - "Task completed.".to_owned(), + message.to_owned(), NotificationCategory::Complete, oz_agent, origin, - terminal_view_id, + effective_terminal_view_id, artifacts, metadata.branch, ctx, @@ -347,13 +391,18 @@ impl AgentNotificationsModel { } ConversationStatus::Cancelled => { let artifacts = self.flush_pending_artifacts(conversation_id); + let message = if is_child { + "Child agent was cancelled." + } else { + "Task was cancelled." + }; self.add_notification( title, - "Task was cancelled.".to_owned(), + message.to_owned(), NotificationCategory::Complete, oz_agent, origin, - terminal_view_id, + effective_terminal_view_id, artifacts, metadata.branch, ctx, @@ -366,7 +415,7 @@ impl AgentNotificationsModel { NotificationCategory::Request, oz_agent, origin, - terminal_view_id, + effective_terminal_view_id, vec![], metadata.branch, ctx, @@ -374,13 +423,18 @@ impl AgentNotificationsModel { } ConversationStatus::Error => { let artifacts = self.flush_pending_artifacts(conversation_id); + let message = if is_child { + "Child agent encountered an error." + } else { + "Something went wrong." + }; self.add_notification( title, - "Something went wrong.".to_owned(), + message.to_owned(), NotificationCategory::Error, oz_agent, origin, - terminal_view_id, + effective_terminal_view_id, artifacts, metadata.branch, ctx, From e37a277c724196416696e4d28946a2ebce279d3b Mon Sep 17 00:00:00 2001 From: Daniel Peng Date: Mon, 4 May 2026 17:35:07 -0700 Subject: [PATCH 2/5] Open the child view if open --- .../agent_management_model.rs | 51 +++++++++++-------- 1 file changed, 31 insertions(+), 20 deletions(-) diff --git a/app/src/ai/agent_management/agent_management_model.rs b/app/src/ai/agent_management/agent_management_model.rs index d252d8df1..1014b795c 100644 --- a/app/src/ai/agent_management/agent_management_model.rs +++ b/app/src/ai/agent_management/agent_management_model.rs @@ -318,17 +318,23 @@ impl AgentNotificationsModel { let conversation = ai_history_model.conversation(&conversation_id); let is_child = conversation.is_some_and(|c| c.is_child_agent_conversation()); - // For child conversations, check whether the parent conversation is open - // instead, since child agents don't have their own agent view — they are - // visible via the parent's ChildAgentStatusCard. - let is_open = if is_child { - conversation - .and_then(|c| c.parent_conversation_id()) - .is_some_and(|parent_id| { - ActiveAgentViewsModel::as_ref(ctx).is_conversation_open(parent_id, ctx) - }) + let active_views = ActiveAgentViewsModel::as_ref(ctx); + + // For child conversations, the child pane may be revealed (visible) or + // hidden. If the child's own conversation is open in an agent view, + // navigate to it directly. Otherwise, check whether the parent + // conversation is open (the child is visible via the parent's + // ChildAgentStatusCard). For non-child conversations, just check + // whether the conversation itself is open. + let (is_open, child_pane_is_revealed) = if is_child { + let child_open = active_views.is_conversation_open(conversation_id, ctx); + let parent_open = !child_open + && conversation + .and_then(|c| c.parent_conversation_id()) + .is_some_and(|parent_id| active_views.is_conversation_open(parent_id, ctx)); + (child_open || parent_open, child_open) } else { - ActiveAgentViewsModel::as_ref(ctx).is_conversation_open(conversation_id, ctx) + (active_views.is_conversation_open(conversation_id, ctx), false) }; // If the conversation view is no longer open, don't create notifications for it @@ -339,22 +345,27 @@ impl AgentNotificationsModel { return; } - // For child agent conversations, resolve the parent's terminal_view_id so that - // clicking the notification navigates to the parent's pane (the child's pane is - // hidden). Also use the child's agent_name as the notification title. + // For child agent conversations, use the child's own terminal_view_id + // if the child pane is revealed (visible), otherwise resolve the + // parent's terminal_view_id so clicking the notification navigates to + // the parent's pane. Also use the child's agent_name as the title. let (effective_terminal_view_id, title) = if is_child { - let parent_terminal_view_id = conversation - .and_then(|c| c.parent_conversation_id()) - .and_then(|parent_id| { - ai_history_model.terminal_view_id_for_conversation(&parent_id) - }) - .unwrap_or(terminal_view_id); + let nav_terminal_view_id = if child_pane_is_revealed { + terminal_view_id + } else { + conversation + .and_then(|c| c.parent_conversation_id()) + .and_then(|parent_id| { + ai_history_model.terminal_view_id_for_conversation(&parent_id) + }) + .unwrap_or(terminal_view_id) + }; let child_name = conversation .and_then(|c| c.agent_name()) .map(|name| name.to_owned()) .or(latest_query) .unwrap_or_else(|| "Child agent".to_owned()); - (parent_terminal_view_id, child_name) + (nav_terminal_view_id, child_name) } else { let title = latest_query.unwrap_or_else(|| "Agent task".to_owned()); (terminal_view_id, title) From 454f82ee9a1310e0283359d1f69240ad9ee0b739 Mon Sep 17 00:00:00 2001 From: Daniel Peng Date: Wed, 6 May 2026 14:24:21 -0700 Subject: [PATCH 3/5] Format --- app/src/ai/agent_management/agent_management_model.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/app/src/ai/agent_management/agent_management_model.rs b/app/src/ai/agent_management/agent_management_model.rs index 1014b795c..d21d32f47 100644 --- a/app/src/ai/agent_management/agent_management_model.rs +++ b/app/src/ai/agent_management/agent_management_model.rs @@ -334,7 +334,10 @@ impl AgentNotificationsModel { .is_some_and(|parent_id| active_views.is_conversation_open(parent_id, ctx)); (child_open || parent_open, child_open) } else { - (active_views.is_conversation_open(conversation_id, ctx), false) + ( + active_views.is_conversation_open(conversation_id, ctx), + false, + ) }; // If the conversation view is no longer open, don't create notifications for it From 548977d86572260597fdeadffb9060b27dffb19f Mon Sep 17 00:00:00 2001 From: Daniel Peng Date: Wed, 6 May 2026 15:25:27 -0700 Subject: [PATCH 4/5] PR comment --- .../agent_management_model.rs | 50 ++++++++----------- 1 file changed, 20 insertions(+), 30 deletions(-) diff --git a/app/src/ai/agent_management/agent_management_model.rs b/app/src/ai/agent_management/agent_management_model.rs index d21d32f47..56ef828b8 100644 --- a/app/src/ai/agent_management/agent_management_model.rs +++ b/app/src/ai/agent_management/agent_management_model.rs @@ -320,40 +320,18 @@ impl AgentNotificationsModel { let active_views = ActiveAgentViewsModel::as_ref(ctx); - // For child conversations, the child pane may be revealed (visible) or - // hidden. If the child's own conversation is open in an agent view, - // navigate to it directly. Otherwise, check whether the parent + // For child conversations, check if the child's own conversation is + // open in an agent view (navigate directly) or if the parent // conversation is open (the child is visible via the parent's - // ChildAgentStatusCard). For non-child conversations, just check - // whether the conversation itself is open. - let (is_open, child_pane_is_revealed) = if is_child { + // ChildAgentStatusCard — navigate to the parent's pane). For non-child + // conversations, just check whether the conversation itself is open. + let (is_open, effective_terminal_view_id, title) = if is_child { let child_open = active_views.is_conversation_open(conversation_id, ctx); let parent_open = !child_open && conversation .and_then(|c| c.parent_conversation_id()) .is_some_and(|parent_id| active_views.is_conversation_open(parent_id, ctx)); - (child_open || parent_open, child_open) - } else { - ( - active_views.is_conversation_open(conversation_id, ctx), - false, - ) - }; - - // If the conversation view is no longer open, don't create notifications for it - // (there's nothing to navigate to when clicking them). - if !is_open { - self.pending_artifacts.remove(&conversation_id); - self.remove_notification_by_source(origin, ctx); - return; - } - - // For child agent conversations, use the child's own terminal_view_id - // if the child pane is revealed (visible), otherwise resolve the - // parent's terminal_view_id so clicking the notification navigates to - // the parent's pane. Also use the child's agent_name as the title. - let (effective_terminal_view_id, title) = if is_child { - let nav_terminal_view_id = if child_pane_is_revealed { + let nav_terminal_view_id = if child_open { terminal_view_id } else { conversation @@ -368,12 +346,24 @@ impl AgentNotificationsModel { .map(|name| name.to_owned()) .or(latest_query) .unwrap_or_else(|| "Child agent".to_owned()); - (nav_terminal_view_id, child_name) + (child_open || parent_open, nav_terminal_view_id, child_name) } else { let title = latest_query.unwrap_or_else(|| "Agent task".to_owned()); - (terminal_view_id, title) + ( + active_views.is_conversation_open(conversation_id, ctx), + terminal_view_id, + title, + ) }; + // If the conversation view is no longer open, don't create notifications for it + // (there's nothing to navigate to when clicking them). + if !is_open { + self.pending_artifacts.remove(&conversation_id); + self.remove_notification_by_source(origin, ctx); + return; + } + let metadata = TerminalViewMetadata::lookup(effective_terminal_view_id, ctx); let oz_agent = NotificationSourceAgent::Oz { is_ambient: metadata.is_ambient, From 344c8a94c60771bcd3b3388fb47330f2a8496444 Mon Sep 17 00:00:00 2001 From: Daniel Peng Date: Tue, 5 May 2026 00:23:29 -0700 Subject: [PATCH 5/5] Desktop notifications for child agents --- .../agent_management_model.rs | 36 ++++++++++++-- .../notifications/toast_stack.rs | 3 +- .../ai/agent_management/notifications/view.rs | 5 +- app/src/workspace/view.rs | 49 +++++++++++++++++++ 4 files changed, 87 insertions(+), 6 deletions(-) diff --git a/app/src/ai/agent_management/agent_management_model.rs b/app/src/ai/agent_management/agent_management_model.rs index 56ef828b8..650d12e19 100644 --- a/app/src/ai/agent_management/agent_management_model.rs +++ b/app/src/ai/agent_management/agent_management_model.rs @@ -382,7 +382,7 @@ impl AgentNotificationsModel { "Task completed." }; self.add_notification( - title, + title.clone(), message.to_owned(), NotificationCategory::Complete, oz_agent, @@ -392,6 +392,13 @@ impl AgentNotificationsModel { metadata.branch, ctx, ); + if is_child { + ctx.emit(AgentManagementEvent::SendDesktopNotification { + title, + body: message.to_owned(), + is_completed: true, + }); + } } ConversationStatus::Cancelled => { let artifacts = self.flush_pending_artifacts(conversation_id); @@ -414,7 +421,7 @@ impl AgentNotificationsModel { } ConversationStatus::Blocked { blocked_action } => { self.add_notification( - title, + title.clone(), blocked_action.clone(), NotificationCategory::Request, oz_agent, @@ -424,6 +431,13 @@ impl AgentNotificationsModel { metadata.branch, ctx, ); + if is_child { + ctx.emit(AgentManagementEvent::SendDesktopNotification { + title, + body: blocked_action.clone(), + is_completed: false, + }); + } } ConversationStatus::Error => { let artifacts = self.flush_pending_artifacts(conversation_id); @@ -433,7 +447,7 @@ impl AgentNotificationsModel { "Something went wrong." }; self.add_notification( - title, + title.clone(), message.to_owned(), NotificationCategory::Error, oz_agent, @@ -443,6 +457,13 @@ impl AgentNotificationsModel { metadata.branch, ctx, ); + if is_child { + ctx.emit(AgentManagementEvent::SendDesktopNotification { + title, + body: message.to_owned(), + is_completed: false, + }); + } } } } @@ -525,6 +546,15 @@ pub enum AgentManagementEvent { NotificationUpdated, /// All notifications were marked as read. AllNotificationsMarkedRead, + /// Request the workspace to send a native desktop notification for a child + /// agent. Emitted from `AgentNotificationsModel` because child agents' + /// hidden terminal views cannot reliably trigger the normal + /// `TerminalView`-based desktop notification path. + SendDesktopNotification { + title: String, + body: String, + is_completed: bool, + }, } impl ConversationStatus { diff --git a/app/src/ai/agent_management/notifications/toast_stack.rs b/app/src/ai/agent_management/notifications/toast_stack.rs index 1efc923ac..5b2ef9083 100644 --- a/app/src/ai/agent_management/notifications/toast_stack.rs +++ b/app/src/ai/agent_management/notifications/toast_stack.rs @@ -72,7 +72,8 @@ impl AgentNotificationToastStack { | AgentManagementEvent::AllNotificationsMarkedRead => { me.remove_dismissed_toasts(ctx); } - AgentManagementEvent::ConversationNeedsAttention { .. } => {} + AgentManagementEvent::ConversationNeedsAttention { .. } + | AgentManagementEvent::SendDesktopNotification { .. } => {} }); Self { diff --git a/app/src/ai/agent_management/notifications/view.rs b/app/src/ai/agent_management/notifications/view.rs index 4680e8cff..a4690f8f8 100644 --- a/app/src/ai/agent_management/notifications/view.rs +++ b/app/src/ai/agent_management/notifications/view.rs @@ -112,8 +112,9 @@ impl NotificationMailboxView { me.rebuild_filtered_ids(ctx); ctx.notify(); } - // Legacy toast path. - AgentManagementEvent::ConversationNeedsAttention { .. } => {} + // Legacy toast path / desktop notification path — not relevant for the mailbox. + AgentManagementEvent::ConversationNeedsAttention { .. } + | AgentManagementEvent::SendDesktopNotification { .. } => {} }); let close_button = ctx.add_typed_action_view(|_| { diff --git a/app/src/workspace/view.rs b/app/src/workspace/view.rs index 1110de2ad..735b1aed0 100644 --- a/app/src/workspace/view.rs +++ b/app/src/workspace/view.rs @@ -3286,6 +3286,55 @@ impl Workspace { // Re-render so the vertical tabs panel can update unread-activity dots. ctx.notify(); } + AgentManagementEvent::SendDesktopNotification { + title, + body, + is_completed, + } => { + // Only send desktop notifications when the user has navigated + // away from the Warp window (matching regular agent behavior). + let active_window = ctx.windows().active_window(); + if Some(ctx.window_id()) == active_window { + return; + } + + let notification_settings = + SessionSettings::as_ref(ctx).notifications.value().clone(); + if notification_settings.mode != NotificationsMode::Enabled { + return; + } + if *is_completed && !notification_settings.is_agent_task_completed_enabled { + return; + } + if !*is_completed && !notification_settings.is_needs_attention_enabled { + return; + } + + let play_sound = notification_settings.play_notification_sound; + ctx.send_desktop_notification( + UserNotification::new_with_sound( + title.clone(), + body.clone(), + None, + play_sound, + ), + |_workspace, notification_error, ctx| { + if let NotificationSendError::Other { error_message } = + ¬ification_error + { + log::error!( + "Failed to send child agent desktop notification: {error_message}" + ); + } + send_telemetry_from_ctx!( + TelemetryEvent::NotificationFailedToSend { + error: notification_error.clone() + }, + ctx + ); + }, + ); + } } }