Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
116 changes: 102 additions & 14 deletions app/src/ai/agent_management/agent_management_model.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down Expand Up @@ -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,
};
Expand All @@ -333,58 +376,94 @@ 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 => {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we purposefully skip 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,
);
}
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,
});
}
}
}
}
Expand Down Expand Up @@ -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 {
Expand Down
3 changes: 2 additions & 1 deletion app/src/ai/agent_management/notifications/toast_stack.rs
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,8 @@ impl AgentNotificationToastStack {
| AgentManagementEvent::AllNotificationsMarkedRead => {
me.remove_dismissed_toasts(ctx);
}
AgentManagementEvent::ConversationNeedsAttention { .. } => {}
AgentManagementEvent::ConversationNeedsAttention { .. }
| AgentManagementEvent::SendDesktopNotification { .. } => {}
});

Self {
Expand Down
5 changes: 3 additions & 2 deletions app/src/ai/agent_management/notifications/view.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(|_| {
Expand Down
49 changes: 49 additions & 0 deletions app/src/workspace/view.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3286,6 +3286,55 @@ impl Workspace {
// Re-render so the vertical tabs panel can update unread-activity dots.
ctx.notify();
}
AgentManagementEvent::SendDesktopNotification {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wonder if long term we would move to having agent management model fire notifications for non-child agents too?

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 {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ [IMPORTANT] This always returns before sending: handle_agent_management_event already exits unless active_window == ctx.window_id(), and when Warp is unfocused active_window is None and the outer guard exits as well. Handle this event before that guard or include the source window in the event so the notification can be evaluated from the owning workspace.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this might be legit

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,
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agent mode tells me we might need to specify BlockOrigin here for a click on the notification to focus the source

play_sound,
),
|_workspace, notification_error, ctx| {
if let NotificationSendError::Other { error_message } =
&notification_error
{
log::error!(
"Failed to send child agent desktop notification: {error_message}"
);
}
send_telemetry_from_ctx!(
TelemetryEvent::NotificationFailedToSend {
error: notification_error.clone()
},
ctx
);
},
);
}
}
}

Expand Down