1use std::sync::{Mutex, OnceLock};
16
17use serde::Deserialize;
18
19#[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 pub title: String,
34}
35
36#[derive(Clone, Copy, Debug, PartialEq, Eq)]
37pub enum PauseChoice {
38 Minutes(i64),
39 UntilTomorrow,
41}
42
43#[derive(Clone, Debug, PartialEq, Eq)]
44pub enum NotifyMenuAction {
45 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
62pub 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
80pub fn wake() {
82 if let Some(w) = WAKE.get() {
83 w();
84 }
85}
86
87pub fn set_wake(f: Wake) {
89 let _ = WAKE.set(f);
90}
91
92pub 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
111pub 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
145pub 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
162pub 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 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}