aboutsummaryrefslogtreecommitdiff
path: root/src/client/shell/notifications.rs
diff options
context:
space:
mode:
authorCan Celik <ogulcancelik@gmail.com>2026-09-06 02:27:06 +0300
committerGitHub <noreply@github.com>2026-09-06 02:27:06 +0300
commit9e9bc8a144667a6b7debfd4f0c7a31ff0e11493a (patch)
tree03f1259f91fd9edeacfd4b904f2e84d4acf25232 /src/client/shell/notifications.rs
parent35b0dff96579f099e0436290085024424ffb289c (diff)
feat: manage multiple ssh machines from one client (#3670)
Diffstat (limited to 'src/client/shell/notifications.rs')
-rw-r--r--src/client/shell/notifications.rs417
1 files changed, 204 insertions, 213 deletions
diff --git a/src/client/shell/notifications.rs b/src/client/shell/notifications.rs
index 43fe566a..b8437ea7 100644
--- a/src/client/shell/notifications.rs
+++ b/src/client/shell/notifications.rs
@@ -1,3 +1,5 @@
+#[cfg(test)]
+use super::notification_policy::COMPLETION_RECHECK_INTERVAL;
use super::*;
use ratatui::{
style::Color,
@@ -5,7 +7,7 @@ use ratatui::{
widgets::{Block, Borders, Clear, Paragraph, Widget},
};
-fn render_mobile_notice_banner(
+pub(super) fn render_mobile_notice_banner(
buffer: &mut Buffer,
area: Rect,
title: &str,
@@ -102,25 +104,7 @@ pub(super) fn render_mobile_notification_banner(
)
}
-pub(super) fn render_mobile_endpoint_notice_banner(
- buffer: &mut Buffer,
- area: Rect,
- notice: &ClientVisibleEndpointNotice,
- offset_for_warning: bool,
- palette: &Palette,
-) -> Rect {
- render_mobile_notice_banner(
- buffer,
- area,
- &notice.title,
- Some(&notice.body),
- palette.red,
- offset_for_warning,
- palette,
- )
-}
-
-fn render_notification_card(
+pub(super) fn render_notification_card(
buffer: &mut Buffer,
area: Rect,
title: &str,
@@ -221,205 +205,13 @@ pub(super) fn render_visible_notification(
)
}
-pub(super) fn render_endpoint_notice(
- buffer: &mut Buffer,
- area: Rect,
- notice: &ClientVisibleEndpointNotice,
- top_offset: u16,
- palette: &Palette,
-) -> Rect {
- render_notification_card(
- buffer,
- area,
- &notice.title,
- &notice.body,
- crate::config::ToastHerdrPosition::TopRight,
- top_offset,
- match notice.key.kind {
- ClientEndpointNoticeKind::Unsupported | ClientEndpointNoticeKind::Rejected => {
- palette.red
- }
- ClientEndpointNoticeKind::Timeout | ClientEndpointNoticeKind::Unavailable => {
- palette.yellow
- }
- },
- palette,
- )
-}
-
-impl ClientShellState {
- pub(super) fn focus_visible_notification(&mut self, outcome: &mut ClientShellInput) {
- let Some(notification) = self.visible_notification.take() else {
- return;
- };
- outcome.repaint = true;
- if let Some(pane_id) = notification.event.pane_id {
- self.push_endpoint_method(
- crate::api::schema::Method::PaneFocus(crate::api::schema::PaneTarget { pane_id }),
- outcome,
- );
- }
- }
-
- pub(crate) fn receive_notification(
- &mut self,
- event: SemanticNotification,
- now: std::time::Instant,
- ) -> (Vec<ClientShellNotificationEffect>, bool) {
- let delay = if event.kind == SemanticNotificationKind::Custom {
- 0
- } else {
- self.config.toast_delay_seconds
- };
- let deadline = now
- .checked_add(std::time::Duration::from_secs(delay))
- .unwrap_or(now);
- let cleared_visible = event.pane_id.as_deref().is_some_and(|pane_id| {
- self.visible_notification
- .as_ref()
- .is_some_and(|visible| visible.event.pane_id.as_deref() == Some(pane_id))
- });
- if let Some(pane_id) = event.pane_id.as_deref() {
- self.pending_notifications
- .retain(|pending| pending.event.pane_id.as_deref() != Some(pane_id));
- if cleared_visible {
- self.visible_notification = None;
- }
- }
- self.pending_notifications.push(ClientPendingNotification {
- event,
- deadline,
- validate_state: delay > 0,
- });
- let (effects, repaint) = self.tick_notifications(now);
- (effects, repaint || cleared_visible)
- }
-
- pub(crate) fn tick_notifications(
- &mut self,
- now: std::time::Instant,
- ) -> (Vec<ClientShellNotificationEffect>, bool) {
- let mut repaint = false;
- if self
- .visible_notification
- .as_ref()
- .is_some_and(|visible| now >= visible.deadline)
- {
- self.visible_notification = None;
- repaint = true;
- }
- if self
- .visible_endpoint_notice
- .as_ref()
- .is_some_and(|visible| now >= visible.deadline)
- {
- self.visible_endpoint_notice = None;
- repaint = true;
- }
-
- let pending = std::mem::take(&mut self.pending_notifications);
- let mut effects = Vec::new();
- for pending in pending {
- if pending.deadline > now {
- self.pending_notifications.push(pending);
- continue;
- }
- if pending.validate_state && !self.notification_still_current(&pending.event) {
- continue;
- }
- let target_active = self.notification_target_is_active(&pending.event);
- let suppress_external = target_active && self.outer_focused != Some(false);
- if let Some(sound) = pending.event.sound {
- let suppress_sound =
- pending.event.kind == SemanticNotificationKind::Finished && suppress_external;
- if !suppress_sound {
- effects.push(ClientShellNotificationEffect::Sound {
- sound: match sound {
- SemanticNotificationSound::Done => crate::sound::Sound::Done,
- SemanticNotificationSound::Request => crate::sound::Sound::Request,
- },
- agent: pending.event.agent.clone(),
- });
- }
- }
-
- match self.config.toast_delivery {
- crate::config::ToastDelivery::Off => {}
- crate::config::ToastDelivery::Herdr if !target_active => {
- let duration = match pending.event.kind {
- SemanticNotificationKind::NeedsAttention => 8,
- SemanticNotificationKind::Finished => 5,
- SemanticNotificationKind::UpdateInstalled => 3,
- SemanticNotificationKind::Custom => 5,
- };
- self.visible_notification = Some(ClientVisibleNotification {
- event: pending.event,
- deadline: now + std::time::Duration::from_secs(duration),
- });
- repaint = true;
- }
- crate::config::ToastDelivery::Herdr => {}
- crate::config::ToastDelivery::Terminal if !suppress_external => {
- effects.push(ClientShellNotificationEffect::Terminal {
- title: pending.event.title,
- body: pending.event.body,
- });
- }
- crate::config::ToastDelivery::System if !suppress_external => {
- effects.push(ClientShellNotificationEffect::System {
- title: pending.event.title,
- body: pending.event.body,
- });
- }
- crate::config::ToastDelivery::Terminal | crate::config::ToastDelivery::System => {}
- }
- }
- (effects, repaint)
- }
-
- fn notification_target_is_active(&self, event: &SemanticNotification) -> bool {
- let Some(snapshot) = self.snapshot.as_deref() else {
- return false;
- };
- if let Some(tab_id) = event.tab_id.as_deref() {
- return snapshot.focused_tab_id.as_deref() == Some(tab_id);
- }
- event.workspace_id.as_deref().is_some_and(|workspace_id| {
- snapshot.focused_workspace_id.as_deref() == Some(workspace_id)
- })
- }
-
- fn notification_still_current(&self, event: &SemanticNotification) -> bool {
- let Some(pane_id) = event.pane_id.as_deref() else {
- return true;
- };
- let Some(agent) = self.snapshot.as_deref().and_then(|snapshot| {
- snapshot
- .agents
- .iter()
- .find(|agent| agent.pane_id == pane_id)
- }) else {
- return false;
- };
- match event.kind {
- SemanticNotificationKind::NeedsAttention => {
- agent.agent_status == crate::api::schema::AgentStatus::Blocked
- }
- SemanticNotificationKind::Finished => matches!(
- agent.agent_status,
- crate::api::schema::AgentStatus::Idle | crate::api::schema::AgentStatus::Done
- ),
- SemanticNotificationKind::UpdateInstalled | SemanticNotificationKind::Custom => true,
- }
- }
-}
-
#[cfg(test)]
mod tests {
use super::*;
fn notification() -> ClientVisibleNotification {
ClientVisibleNotification {
+ endpoint_id: ClientEndpointId::Local,
event: SemanticNotification {
kind: SemanticNotificationKind::Custom,
title: "notice".into(),
@@ -463,6 +255,205 @@ mod tests {
}
#[test]
+ fn unavailable_notification_target_stays_visible_and_reports_the_machine() {
+ let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default()));
+ state.visible_notification = Some(ClientVisibleNotification {
+ endpoint_id: ClientEndpointId::Local,
+ event: SemanticNotification {
+ kind: SemanticNotificationKind::NeedsAttention,
+ title: "agent needs attention".into(),
+ body: None,
+ sound: None,
+ agent: Some("agent".into()),
+ workspace_id: Some("workspace".into()),
+ tab_id: Some("tab".into()),
+ pane_id: Some("pane".into()),
+ position: None,
+ },
+ deadline: std::time::Instant::now() + std::time::Duration::from_secs(5),
+ });
+ let mut outcome = ClientShellInput::default();
+
+ state.focus_visible_notification(&mut outcome);
+
+ assert!(state.visible_notification.is_some());
+ assert!(state
+ .visible_endpoint_notice
+ .as_ref()
+ .is_some_and(|notice| notice.body.contains("Local is unavailable")));
+ assert!(outcome.repaint);
+ }
+
+ #[test]
+ fn immediate_finished_evidence_for_acknowledged_idle_never_toasts_or_sounds() {
+ let mut config = ClientShellConfig::from_config(&Config::default());
+ config.toast_delivery = crate::config::ToastDelivery::Herdr;
+ config.toast_delay_seconds = 0;
+ let mut state = ClientShellState::new(config);
+ let mut snapshot = super::super::tests::snapshot();
+ snapshot.agents.push(crate::protocol::ClientShellAgent {
+ pane_id: "pane_1".into(),
+ workspace_id: "ws_1".into(),
+ tab_id: "tab_1".into(),
+ name: Some("agent".into()),
+ display_agent: None,
+ agent: Some("agent".into()),
+ title: None,
+ terminal_title: None,
+ terminal_title_stripped: None,
+ agent_status: crate::api::schema::AgentStatus::Idle,
+ state_change_seq: 2,
+ state_labels: Vec::new(),
+ tokens: Vec::new(),
+ focused: true,
+ });
+ state.set_snapshot(Box::new(snapshot));
+
+ let (effects, repaint) = state.receive_notification(
+ &ClientEndpointId::Local,
+ SemanticNotification {
+ kind: SemanticNotificationKind::Finished,
+ title: "agent finished".into(),
+ body: None,
+ sound: Some(SemanticNotificationSound::Done),
+ agent: Some("agent".into()),
+ workspace_id: Some("ws_1".into()),
+ tab_id: Some("tab_1".into()),
+ pane_id: Some("pane_1".into()),
+ position: None,
+ },
+ std::time::Instant::now(),
+ );
+
+ assert!(effects.is_empty());
+ assert!(!repaint);
+ assert!(state.visible_notification.is_none());
+ }
+
+ #[test]
+ fn finished_hint_waits_for_the_projected_completion_snapshot() {
+ let mut config = ClientShellConfig::from_config(&Config::default());
+ config.toast_delivery = crate::config::ToastDelivery::Terminal;
+ config.toast_delay_seconds = 0;
+ let mut state = ClientShellState::new(config);
+ let mut snapshot = super::super::tests::snapshot();
+ snapshot.agents.push(crate::protocol::ClientShellAgent {
+ pane_id: "pane_1".into(),
+ workspace_id: "ws_1".into(),
+ tab_id: "tab_1".into(),
+ name: Some("agent".into()),
+ display_agent: None,
+ agent: Some("agent".into()),
+ title: None,
+ terminal_title: None,
+ terminal_title_stripped: None,
+ agent_status: crate::api::schema::AgentStatus::Working,
+ state_change_seq: 1,
+ state_labels: Vec::new(),
+ tokens: Vec::new(),
+ focused: false,
+ });
+ state.set_snapshot(Box::new(snapshot.clone()));
+ let now = std::time::Instant::now();
+ let event = SemanticNotification {
+ kind: SemanticNotificationKind::Finished,
+ title: "agent finished".into(),
+ body: None,
+ sound: None,
+ agent: Some("agent".into()),
+ workspace_id: Some("ws_1".into()),
+ tab_id: Some("background-tab".into()),
+ pane_id: Some("pane_1".into()),
+ position: None,
+ };
+
+ let (effects, _) = state.receive_notification(&ClientEndpointId::Local, event, now);
+ assert!(effects.is_empty());
+ assert_eq!(state.pending_notifications.len(), 1);
+
+ snapshot.revision = snapshot.revision.saturating_add(1);
+ snapshot.agents[0].agent_status = crate::api::schema::AgentStatus::Idle;
+ snapshot.agents[0].state_change_seq = 2;
+ state.set_snapshot(Box::new(snapshot));
+ let (effects, _) = state.tick_notifications(now + COMPLETION_RECHECK_INTERVAL);
+ assert!(matches!(
+ effects.as_slice(),
+ [ClientShellNotificationEffect::Terminal { title, .. }] if title == "agent finished"
+ ));
+ assert!(state.pending_notifications.is_empty());
+ }
+
+ #[test]
+ fn retiring_one_endpoint_preserves_other_endpoint_notifications() {
+ let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default()));
+ let mut local = notification();
+ local.event.title = "local".into();
+ let remote_id = ClientEndpointId::Ssh(
+ crate::client::endpoint::ProfileId::parse("0123456789abcdef0123456789abcdef").unwrap(),
+ );
+ let mut remote = notification();
+ remote.endpoint_id = remote_id.clone();
+ remote.event.title = "remote".into();
+ state.visible_notification = Some(local);
+ state.queued_notifications.push_back(remote);
+
+ state.retire_endpoint_notifications(&ClientEndpointId::Local);
+
+ assert_eq!(
+ state
+ .visible_notification
+ .as_ref()
+ .map(|notification| (&notification.endpoint_id, notification.event.title.as_str())),
+ Some((&remote_id, "remote"))
+ );
+ }
+
+ #[test]
+ fn concurrent_endpoint_notifications_are_queued_in_arrival_order() {
+ let mut config = Config::default();
+ config.ui.toast.delivery = crate::config::ToastDelivery::Herdr;
+ let mut state = ClientShellState::new(ClientShellConfig::from_config(&config));
+ let now = std::time::Instant::now();
+ for title in ["first", "second"] {
+ state.receive_notification(
+ &ClientEndpointId::Local,
+ SemanticNotification {
+ kind: SemanticNotificationKind::Custom,
+ title: title.into(),
+ body: None,
+ sound: None,
+ agent: None,
+ workspace_id: None,
+ tab_id: None,
+ pane_id: Some(title.into()),
+ position: None,
+ },
+ now,
+ );
+ }
+
+ assert_eq!(
+ state
+ .visible_notification
+ .as_ref()
+ .map(|notification| notification.event.title.as_str()),
+ Some("first")
+ );
+ assert_eq!(state.queued_notifications.len(), 1);
+
+ state.tick_notifications(now + std::time::Duration::from_secs(5));
+
+ assert_eq!(
+ state
+ .visible_notification
+ .as_ref()
+ .map(|notification| notification.event.title.as_str()),
+ Some("second")
+ );
+ assert!(state.queued_notifications.is_empty());
+ }
+
+ #[test]
fn notification_rect_stays_inside_short_nonzero_area() {
let palette = crate::app::client_palette_from_config(&Config::default());
for height in [1, 2] {