diff options
| author | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-04-25 23:21:42 +0300 |
|---|---|---|
| committer | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-04-25 23:21:42 +0300 |
| commit | 88cc44c7892296b1a2fcf91314a4c11651833aa4 (patch) | |
| tree | a0bf12815ec2dd2bf68a7f29f5cdfc888b32cbea /src/server | |
| parent | 692327f38b752e868988e789d27e9098e4b7e34e (diff) | |
fix: keep herdr toast delivery in-app
Diffstat (limited to 'src/server')
| -rw-r--r-- | src/server/headless.rs | 269 |
1 files changed, 136 insertions, 133 deletions
diff --git a/src/server/headless.rs b/src/server/headless.rs index b567158c..d5829f71 100644 --- a/src/server/headless.rs +++ b/src/server/headless.rs @@ -34,7 +34,9 @@ use crate::api; use crate::app; use crate::app::state::AppState; use crate::config; +use crate::detect::AgentState; use crate::events::AppEvent; +use crate::layout::PaneId; use crate::server::protocol::{ self, ClientMessage, CursorState, FrameData, ServerMessage, MAX_FRAME_SIZE, PROTOCOL_VERSION, }; @@ -100,6 +102,46 @@ const MAX_INPUT_PAYLOAD: usize = 1024 * 1024; // 1 MB /// avoid reintroducing the idle CPU spin. const CLIENT_ACCEPT_POLL_INTERVAL: Duration = Duration::from_millis(250); +fn should_forward_toast_to_clients(delivery: config::ToastDelivery) -> bool { + matches!(delivery, config::ToastDelivery::Terminal) +} + +fn toast_event_text(kind: app::state::ToastKind) -> &'static str { + match kind { + app::state::ToastKind::NeedsAttention => "needs attention", + app::state::ToastKind::Finished => "finished", + app::state::ToastKind::UpdateInstalled => "updated", + } +} + +fn toast_message_from_state_change( + state: &AppState, + pane_id: PaneId, + is_active_tab: bool, + prev_state: AgentState, + new_state: AgentState, +) -> Option<String> { + let kind = + app::actions::notification_toast_for_state_change(is_active_tab, prev_state, new_state)?; + + state + .workspaces + .iter() + .enumerate() + .find_map(|(ws_idx, ws)| { + ws.tabs.iter().find_map(|tab| { + let pane = tab.panes.get(&pane_id)?; + let agent_label = pane.effective_agent_label()?; + Some(format!( + "{} {}: {}", + agent_label, + toast_event_text(kind), + app::actions::notification_context(ws, ws_idx, pane_id) + )) + }) + }) +} + // --------------------------------------------------------------------------- // Socket path helpers // --------------------------------------------------------------------------- @@ -800,57 +842,22 @@ impl HeadlessServer { } let toast_msg = - if self.app.state.toast.is_some() && self.app.state.toast != toast_before { - self.app - .state - .toast - .as_ref() - .map(|toast| format!("{}: {}", toast.title, toast.context)) - } else if matches!( - self.app.state.toast_config.delivery, - crate::config::ToastDelivery::Terminal - ) { - self.app - .state - .workspaces - .iter() - .enumerate() - .find_map(|(ws_idx, ws)| { - ws.tabs.iter().find_map(|tab| { - tab.panes.get(&pane_id_val).and_then(|pane| { - pane.effective_agent_label().and_then(|agent_label| { - crate::app::actions::notification_toast_for_state_change( - is_active_tab, - prev_state, - state_val, - ) - .map(|kind| { - let event_text = match kind { - crate::app::state::ToastKind::NeedsAttention => { - "needs attention" - } - crate::app::state::ToastKind::Finished => { - "finished" - } - crate::app::state::ToastKind::UpdateInstalled => { - "updated" - } - }; - format!( - "{} {}: {}", - agent_label, - event_text, - crate::app::actions::notification_context( - ws, - ws_idx, - pane_id_val, - ) - ) - }) - }) - }) - }) - }) + if should_forward_toast_to_clients(self.app.state.toast_config.delivery) { + if self.app.state.toast.is_some() && self.app.state.toast != toast_before { + self.app + .state + .toast + .as_ref() + .map(|toast| format!("{}: {}", toast.title, toast.context)) + } else { + toast_message_from_state_change( + &self.app.state, + pane_id_val, + is_active_tab, + prev_state, + state_val, + ) + } } else { None }; @@ -933,57 +940,22 @@ impl HeadlessServer { } let toast_msg = - if self.app.state.toast.is_some() && self.app.state.toast != toast_before { - self.app - .state - .toast - .as_ref() - .map(|toast| format!("{}: {}", toast.title, toast.context)) - } else if matches!( - self.app.state.toast_config.delivery, - crate::config::ToastDelivery::Terminal - ) { - self.app - .state - .workspaces - .iter() - .enumerate() - .find_map(|(ws_idx, ws)| { - ws.tabs.iter().find_map(|tab| { - tab.panes.get(&pane_id_val).and_then(|pane| { - pane.effective_agent_label().and_then(|agent_label| { - crate::app::actions::notification_toast_for_state_change( - is_active_tab, - prev_hook_state, - hook_state_val, - ) - .map(|kind| { - let event_text = match kind { - crate::app::state::ToastKind::NeedsAttention => { - "needs attention" - } - crate::app::state::ToastKind::Finished => { - "finished" - } - crate::app::state::ToastKind::UpdateInstalled => { - "updated" - } - }; - format!( - "{} {}: {}", - agent_label, - event_text, - crate::app::actions::notification_context( - ws, - ws_idx, - pane_id_val, - ) - ) - }) - }) - }) - }) - }) + if should_forward_toast_to_clients(self.app.state.toast_config.delivery) { + if self.app.state.toast.is_some() && self.app.state.toast != toast_before { + self.app + .state + .toast + .as_ref() + .map(|toast| format!("{}: {}", toast.title, toast.context)) + } else { + toast_message_from_state_change( + &self.app.state, + pane_id_val, + is_active_tab, + prev_hook_state, + hook_state_val, + ) + } } else { None }; @@ -1004,19 +976,18 @@ impl HeadlessServer { self.app.handle_internal_event(ev); let toast_msg = - if self.app.state.toast.is_some() && self.app.state.toast != toast_before { - self.app - .state - .toast - .as_ref() - .map(|toast| format!("{}: {}", toast.title, toast.context)) - } else if matches!( - self.app.state.toast_config.delivery, - crate::config::ToastDelivery::Terminal - ) { - Some(format!( - "v{version} available: detach, then run `herdr update`" - )) + if should_forward_toast_to_clients(self.app.state.toast_config.delivery) { + if self.app.state.toast.is_some() && self.app.state.toast != toast_before { + self.app + .state + .toast + .as_ref() + .map(|toast| format!("{}: {}", toast.title, toast.context)) + } else { + Some(format!( + "v{version} available: detach, then run `herdr update`" + )) + } } else { None }; @@ -1326,23 +1297,29 @@ impl HeadlessServer { let response = self.app.handle_api_request(msg.request); let _ = msg.respond_to.send(response); - // Forward any new toast as a notification to clients. + // Forward new toast state only when terminal delivery is selected. + // Herdr delivery renders the toast in-frame and must not ask clients to + // show a terminal/desktop notification. let toast_after = self.app.state.toast.clone(); - let forwarded_toast_from_state = if toast_after.is_some() && toast_after != toast_before { - if let Some(toast) = &toast_after { - let msg_text = format!("{}: {}", toast.title, toast.context); - debug!(msg = %msg_text, "forwarding toast notification from API request"); - self.send_to_all_clients(ServerMessage::Notify { - kind: protocol::NotifyKind::Toast, - message: msg_text, - }); - true + let forwarded_toast_from_state = + if should_forward_toast_to_clients(self.app.state.toast_config.delivery) + && toast_after.is_some() + && toast_after != toast_before + { + if let Some(toast) = &toast_after { + let msg_text = format!("{}: {}", toast.title, toast.context); + debug!(msg = %msg_text, "forwarding toast notification from API request"); + self.send_to_all_clients(ServerMessage::Notify { + kind: protocol::NotifyKind::Toast, + message: msg_text, + }); + true + } else { + false + } } else { false - } - } else { - false - }; + }; // Forward sound notifications for any pane state changes that occurred // during the API request. Compare before/after pane states (including @@ -1403,10 +1380,7 @@ impl HeadlessServer { ); if !forwarded_toast_from_state - && matches!( - self.app.state.toast_config.delivery, - crate::config::ToastDelivery::Terminal - ) + && should_forward_toast_to_clients(self.app.state.toast_config.delivery) { if let Some(kind) = crate::app::actions::notification_toast_for_state_change( is_active_tab, @@ -2461,6 +2435,35 @@ mod tests { } #[test] + fn herdr_toast_delivery_keeps_toast_in_frame_without_client_notify() { + let mut server = test_headless_server(); + let (client_tx, client_rx) = std::sync::mpsc::channel(); + + server.clients.insert( + 1, + ClientConnection { + terminal_size: (80, 24), + host_terminal_theme: crate::terminal_theme::TerminalTheme::default(), + last_activity: 1, + last_frame: None, + writer: Some(client_tx), + }, + ); + server.app.state.toast_config.delivery = crate::config::ToastDelivery::Herdr; + + let changed = server.handle_internal_event_with_forwarding(AppEvent::UpdateReady { + version: "9.9.9".to_string(), + }); + + assert!(changed); + assert!(server.app.state.toast.is_some()); + assert!( + client_rx.recv_timeout(Duration::from_millis(50)).is_err(), + "herdr delivery should render in-frame instead of forwarding a terminal notification" + ); + } + + #[test] fn handshake_timeout_is_within_five_second_deadline() { // The handshake timeout must be short enough that // the connection is guaranteed to close within 5 seconds even with |
