use enum_iterator::Sequence; use instant::Instant; use uuid::Uuid; use warpui::EntityId; use crate::ai::agent::conversation::AIConversationId; use crate::ai::artifacts::Artifact; use crate::terminal::CLIAgent; #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub struct NotificationId(Uuid); impl NotificationId { fn new() -> Self { Self(Uuid::new_v4()) } } #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum NotificationCategory { /// The agent has stopped (i.e. successfully completed or was cancelled) Complete, /// The agent needs user action (i.e. blocked on some permission request or idle prompt) Request, Error, } #[derive(Debug, Clone, Copy, PartialEq, Eq, Sequence)] pub enum NotificationFilter { All, Unread, Errors, } impl NotificationFilter { pub(crate) fn label(&self) -> &'static str { match self { NotificationFilter::All => "All tabs", NotificationFilter::Unread => "Unread", NotificationFilter::Errors => "Errors", } } } /// Identifies the agent that produced a notification. #[derive(Debug, Clone, Copy)] #[allow(clippy::upper_case_acronyms)] pub enum NotificationSourceAgent { Oz, CLI(CLIAgent), } /// Identifies the conversation or session a notification belongs to. /// Used for de-duplication (replacing stale notifications on update) /// and cleanup (removing notifications when the source closes). #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub enum NotificationOrigin { Conversation(AIConversationId), /// CLI sessions are keyed by terminal view because we only track one session per pane. CLISession(EntityId), } #[derive(Debug, Clone)] pub struct NotificationItem { pub id: NotificationId, pub origin: NotificationOrigin, pub title: String, pub message: String, pub category: NotificationCategory, pub agent: NotificationSourceAgent, /// Whether the user has already seen this notification /// (either because they clicked into it or because it was emitted for a conversation /// that they've since navigated to). pub is_read: bool, pub created_at: Instant, pub terminal_view_id: EntityId, pub artifacts: Vec, /// The git branch associated with this notification's conversation/session. /// When present, the notification renders in "rich" layout with a branch header row. /// When absent, it falls back to the "simple" layout. pub branch: Option, } impl NotificationItem { /// Marks this notification as read. Returns true if it was previously unread. fn mark_as_read(&mut self) -> bool { if self.is_read { return false; } self.is_read = true; true } #[allow(clippy::too_many_arguments)] pub(crate) fn new( title: String, message: String, category: NotificationCategory, agent: NotificationSourceAgent, origin: NotificationOrigin, is_read: bool, terminal_view_id: EntityId, artifacts: Vec, branch: Option, ) -> Self { Self { id: NotificationId::new(), origin, title, message, category, agent, is_read, created_at: Instant::now(), terminal_view_id, artifacts, branch, } } } #[derive(Debug, Default)] pub struct NotificationItems { items: Vec, } impl NotificationItems { /// Push a notification items into the mailbox list /// (deleting older notifications if we've exceeded the max list size). pub(crate) fn push(&mut self, item: NotificationItem) { self.remove_by_origin(item.origin); self.items.insert(0, item); self.items.truncate(100); } pub(crate) fn remove_by_origin(&mut self, key: NotificationOrigin) -> bool { let before = self.items.len(); self.items.retain(|item| item.origin != key); self.items.len() != before } pub(crate) fn items_filtered( &self, filter: NotificationFilter, ) -> impl Iterator { self.items.iter().filter(move |item| match filter { NotificationFilter::All => true, NotificationFilter::Unread => !item.is_read, NotificationFilter::Errors => item.category == NotificationCategory::Error, }) } pub(crate) fn filtered_count(&self, filter: NotificationFilter) -> usize { self.items_filtered(filter).count() } /// Returns the filters that should be shown as tabs. /// "All" is always included; other filters are included only when they have at least one item. pub(crate) fn visible_filters(&self) -> Vec { enum_iterator::all::() .filter(|f| *f == NotificationFilter::All || self.filtered_count(*f) > 0) .collect() } pub(crate) fn get_by_id(&self, id: NotificationId) -> Option<&NotificationItem> { self.items.iter().find(|item| item.id == id) } /// Marks all notifications from the given terminal view as read. /// Returns true if any were changed. pub(crate) fn mark_all_terminal_view_items_as_read( &mut self, terminal_view_id: EntityId, ) -> bool { let mut any_changed = false; for item in &mut self.items { if item.terminal_view_id == terminal_view_id { any_changed |= item.mark_as_read(); } } any_changed } pub(crate) fn mark_item_read(&mut self, id: NotificationId) -> bool { self.items .iter_mut() .find(|item| item.id == id) .is_some_and(|item| item.mark_as_read()) } pub(crate) fn mark_all_items_read(&mut self) -> bool { let mut any_changed = false; for item in &mut self.items { any_changed |= item.mark_as_read(); } any_changed } pub(crate) fn has_unread_for_terminal_view(&self, terminal_view_id: EntityId) -> bool { self.items .iter() .any(|item| item.terminal_view_id == terminal_view_id && !item.is_read) } } #[cfg(test)] #[path = "item_tests.rs"] mod tests;