Skip to main content

agentmux_launcher/tray/
notify_menu.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! The tray as a notification hub — `SPEC_OS_NOTIFICATIONS_SYSTEM_2026_09_24.md` §4.2.
5//!
6//! Platform-neutral half: the state the srv Router publishes
7//! (`notification:state`), the menu model built from it, pause resolution,
8//! and the tooltip line. Backends render it; `notify::tray_request` carries
9//! the user's picks back to srv.
10//!
11//! Kept separate from `TrayAction` / `menu_model` on purpose: those are
12//! `Copy` and shared with the macOS backend, which this phase does not touch
13//! (Phase 3 brings the same hub to the menu bar).
14
15use std::sync::{Mutex, OnceLock};
16
17use serde::Deserialize;
18
19/// Mirrors srv's `NotifyTrayState` (`backend/notify/policy.rs`).
20#[derive(Clone, Debug, Default, PartialEq, Eq, Deserialize)]
21pub struct NotifyTrayState {
22    #[serde(default)]
23    pub attention: Vec<AttentionItem>,
24    #[serde(default)]
25    pub paused_until_ms: i64,
26}
27
28#[derive(Clone, Debug, PartialEq, Eq, Deserialize)]
29pub struct AttentionItem {
30    pub id: String,
31    pub block_id: String,
32    /// App-controlled template ("lark needs your input") — safe for a menu.
33    pub title: String,
34}
35
36#[derive(Clone, Copy, Debug, PartialEq, Eq)]
37pub enum PauseChoice {
38    Minutes(i64),
39    /// Until 08:00 local time tomorrow.
40    UntilTomorrow,
41}
42
43#[derive(Clone, Debug, PartialEq, Eq)]
44pub enum NotifyMenuAction {
45    /// Same as clicking that notification's toast.
46    Open(String),
47    Pause(PauseChoice),
48    Resume,
49}
50
51#[derive(Clone, Debug, PartialEq, Eq)]
52pub enum NotifyMenuEntry {
53    Item { label: String, action: NotifyMenuAction },
54    Submenu { label: String, items: Vec<NotifyMenuEntry> },
55    Separator,
56}
57
58static STATE: Mutex<Option<NotifyTrayState>> = Mutex::new(None);
59type Wake = Box<dyn Fn() + Send + Sync>;
60static WAKE: OnceLock<Wake> = OnceLock::new();
61
62/// Called by the notify presenter when srv publishes a new snapshot.
63pub fn set(state: NotifyTrayState) {
64    {
65        let mut cur = STATE.lock().unwrap_or_else(|e| e.into_inner());
66        if cur.as_ref() == Some(&state) {
67            return;
68        }
69        *cur = Some(state);
70    }
71    if let Some(w) = WAKE.get() {
72        w();
73    }
74}
75
76pub fn get() -> NotifyTrayState {
77    STATE.lock().unwrap_or_else(|e| e.into_inner()).clone().unwrap_or_default()
78}
79
80/// Ask the tray to redraw its menu (also used for the start-at-login item).
81pub fn wake() {
82    if let Some(w) = WAKE.get() {
83        w();
84    }
85}
86
87/// A backend registers how to wake its UI thread when the state changes.
88pub fn set_wake(f: Wake) {
89    let _ = WAKE.set(f);
90}
91
92/// muda treats `&` as a mnemonic marker on Windows; agent names are
93/// user-chosen, so escape it rather than let "R&D" render as "RD".
94pub fn menu_label(s: &str) -> String {
95    let mut out: String = s.chars().take(60).collect();
96    if s.chars().count() > 60 {
97        out.push('…');
98    }
99    out.replace('&', "&&")
100}
101
102fn format_local_hm(epoch_ms: i64) -> String {
103    use chrono::TimeZone;
104    chrono::Local
105        .timestamp_millis_opt(epoch_ms)
106        .single()
107        .map(|t| t.format("%H:%M").to_string())
108        .unwrap_or_default()
109}
110
111/// The notification section of the tray menu (placed above New Window/Quit).
112pub fn menu(state: &NotifyTrayState, now_ms: i64) -> Vec<NotifyMenuEntry> {
113    let mut out = Vec::new();
114    if !state.attention.is_empty() {
115        let n = state.attention.len();
116        out.push(NotifyMenuEntry::Submenu {
117            label: if n == 1 { "1 agent needs you".to_string() } else { format!("{n} agents need you") },
118            items: state
119                .attention
120                .iter()
121                .map(|a| NotifyMenuEntry::Item { label: menu_label(&a.title), action: NotifyMenuAction::Open(a.id.clone()) })
122                .collect(),
123        });
124    }
125    if state.paused_until_ms > now_ms {
126        out.push(NotifyMenuEntry::Item {
127            label: format!("Resume notifications (paused until {})", format_local_hm(state.paused_until_ms)),
128            action: NotifyMenuAction::Resume,
129        });
130    } else {
131        out.push(NotifyMenuEntry::Submenu {
132            label: "Pause notifications".to_string(),
133            items: vec![
134                NotifyMenuEntry::Item { label: "For 30 minutes".into(), action: NotifyMenuAction::Pause(PauseChoice::Minutes(30)) },
135                NotifyMenuEntry::Item { label: "For 1 hour".into(), action: NotifyMenuAction::Pause(PauseChoice::Minutes(60)) },
136                NotifyMenuEntry::Item { label: "For 4 hours".into(), action: NotifyMenuAction::Pause(PauseChoice::Minutes(240)) },
137                NotifyMenuEntry::Item { label: "Until tomorrow".into(), action: NotifyMenuAction::Pause(PauseChoice::UntilTomorrow) },
138            ],
139        });
140    }
141    out.push(NotifyMenuEntry::Separator);
142    out
143}
144
145/// Absolute epoch-ms deadline for a pause choice, resolved at click time.
146pub fn resolve_pause(choice: PauseChoice, now: chrono::DateTime<chrono::Local>) -> i64 {
147    match choice {
148        PauseChoice::Minutes(m) => now.timestamp_millis() + m * 60_000,
149        PauseChoice::UntilTomorrow => {
150            use chrono::TimeZone;
151            let tomorrow = now.date_naive() + chrono::Days::new(1);
152            let at = tomorrow.and_hms_opt(8, 0, 0).expect("valid time");
153            chrono::Local
154                .from_local_datetime(&at)
155                .earliest()
156                .map(|t| t.timestamp_millis())
157                .unwrap_or(now.timestamp_millis() + 12 * 3_600_000)
158        }
159    }
160}
161
162/// Extra tooltip line, if any.
163pub fn tooltip_suffix(state: &NotifyTrayState, now_ms: i64) -> Option<String> {
164    let n = state.attention.len();
165    let mut parts = Vec::new();
166    if n == 1 {
167        parts.push("1 agent needs you".to_string());
168    } else if n > 1 {
169        parts.push(format!("{n} agents need you"));
170    }
171    if state.paused_until_ms > now_ms {
172        parts.push("notifications paused".to_string());
173    }
174    (!parts.is_empty()).then(|| parts.join(" · "))
175}
176
177#[cfg(test)]
178mod tests {
179    use super::*;
180
181    fn item(id: &str, title: &str) -> AttentionItem {
182        AttentionItem { id: id.into(), block_id: format!("b-{id}"), title: title.into() }
183    }
184
185    #[test]
186    fn idle_menu_offers_pause_only() {
187        let m = menu(&NotifyTrayState::default(), 0);
188        assert_eq!(m.len(), 2);
189        match &m[0] {
190            NotifyMenuEntry::Submenu { label, items } => {
191                assert_eq!(label, "Pause notifications");
192                assert_eq!(items.len(), 4);
193            }
194            other => panic!("{other:?}"),
195        }
196        assert_eq!(m[1], NotifyMenuEntry::Separator);
197    }
198
199    #[test]
200    fn attention_submenu_lists_items_and_opens_by_id() {
201        let s = NotifyTrayState { attention: vec![item("n1", "lark needs your input"), item("n2", "R&D stopped unexpectedly")], paused_until_ms: 0 };
202        let m = menu(&s, 0);
203        match &m[0] {
204            NotifyMenuEntry::Submenu { label, items } => {
205                assert_eq!(label, "2 agents need you");
206                assert_eq!(items[0], NotifyMenuEntry::Item { label: "lark needs your input".into(), action: NotifyMenuAction::Open("n1".into()) });
207                match &items[1] {
208                    NotifyMenuEntry::Item { label, .. } => assert_eq!(label, "R&&D stopped unexpectedly"),
209                    other => panic!("{other:?}"),
210                }
211            }
212            other => panic!("{other:?}"),
213        }
214    }
215
216    #[test]
217    fn paused_menu_offers_resume() {
218        let s = NotifyTrayState { attention: vec![], paused_until_ms: 10_000 };
219        let m = menu(&s, 5_000);
220        match &m[0] {
221            NotifyMenuEntry::Item { label, action } => {
222                assert!(label.starts_with("Resume notifications (paused until "), "{label}");
223                assert_eq!(*action, NotifyMenuAction::Resume);
224            }
225            other => panic!("{other:?}"),
226        }
227        // Expired pause → back to the Pause submenu.
228        assert!(matches!(&menu(&s, 20_000)[0], NotifyMenuEntry::Submenu { .. }));
229    }
230
231    #[test]
232    fn pause_resolution() {
233        use chrono::TimeZone;
234        let now = chrono::Local.with_ymd_and_hms(2026, 9, 24, 22, 15, 0).unwrap();
235        assert_eq!(resolve_pause(PauseChoice::Minutes(30), now), now.timestamp_millis() + 1_800_000);
236        let t = resolve_pause(PauseChoice::UntilTomorrow, now);
237        let expect = chrono::Local.with_ymd_and_hms(2026, 9, 25, 8, 0, 0).unwrap().timestamp_millis();
238        assert_eq!(t, expect);
239    }
240
241    #[test]
242    fn tooltip_line() {
243        assert_eq!(tooltip_suffix(&NotifyTrayState::default(), 0), None);
244        let s = NotifyTrayState { attention: vec![item("n1", "x")], paused_until_ms: 100 };
245        assert_eq!(tooltip_suffix(&s, 0).as_deref(), Some("1 agent needs you · notifications paused"));
246    }
247
248    #[test]
249    fn long_titles_are_truncated() {
250        let l = menu_label(&"x".repeat(100));
251        assert_eq!(l.chars().count(), 61);
252    }
253
254    #[test]
255    fn parses_srv_payload() {
256        let s: NotifyTrayState = serde_json::from_str(r#"{"attention":[{"id":"n1","block_id":"b1","title":"lark needs your input"}],"paused_until_ms":0}"#).unwrap();
257        assert_eq!(s.attention[0].title, "lark needs your input");
258    }
259}