diff --git a/app/src/ai/agent_management/agent_management_model.rs b/app/src/ai/agent_management/agent_management_model.rs index c17f10518..650d12e19 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,57 @@ 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()); + + let active_views = ActiveAgentViewsModel::as_ref(ctx); + + // 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 — 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)); + let nav_terminal_view_id = if child_open { + 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()); + (child_open || parent_open, nav_terminal_view_id, child_name) + } else { + let title = latest_query.unwrap_or_else(|| "Agent task".to_owned()); + ( + 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 !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); + let metadata = TerminalViewMetadata::lookup(effective_terminal_view_id, ctx); let oz_agent = NotificationSourceAgent::Oz { is_ambient: metadata.is_ambient, }; @@ -333,27 +376,44 @@ 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(), + title.clone(), + message.to_owned(), NotificationCategory::Complete, oz_agent, origin, - terminal_view_id, + effective_terminal_view_id, artifacts, 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); + 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, @@ -361,30 +421,49 @@ impl AgentNotificationsModel { } ConversationStatus::Blocked { blocked_action } => { self.add_notification( - title, + title.clone(), blocked_action.clone(), NotificationCategory::Request, oz_agent, origin, - terminal_view_id, + effective_terminal_view_id, vec![], 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); + let message = if is_child { + "Child agent encountered an error." + } else { + "Something went wrong." + }; self.add_notification( - title, - "Something went wrong.".to_owned(), + title.clone(), + message.to_owned(), NotificationCategory::Error, oz_agent, origin, - terminal_view_id, + effective_terminal_view_id, artifacts, metadata.branch, ctx, ); + if is_child { + ctx.emit(AgentManagementEvent::SendDesktopNotification { + title, + body: message.to_owned(), + is_completed: false, + }); + } } } } @@ -467,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 + ); + }, + ); + } } }