agentmux_srv/backend/
user_attention.rs1use std::collections::HashMap;
16use std::sync::{Arc, Mutex, OnceLock};
17
18use super::mps::Broker;
19use super::storage::store::Store;
20
21#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize, ts_rs::TS)]
24#[ts(export, export_to = "../../../frontend/types/rpc/")]
25pub struct UserAttentionEvent {
26 pub key: String,
27 pub block_id: String,
29 pub kind: String,
31 pub text: String,
32 pub window_ids: Vec<String>,
34 #[ts(type = "number")]
35 pub since_ms: u64,
36 pub active: bool,
38}
39
40fn open() -> &'static Mutex<HashMap<String, UserAttentionEvent>> {
42 static OPEN: OnceLock<Mutex<HashMap<String, UserAttentionEvent>>> = OnceLock::new();
43 OPEN.get_or_init(Default::default)
44}
45
46pub fn open_requests() -> Vec<UserAttentionEvent> {
48 let mut v: Vec<UserAttentionEvent> = open().lock().unwrap_or_else(|p| p.into_inner()).values().cloned().collect();
49 v.sort_by_key(|a| a.since_ms);
50 v
51}
52
53pub struct Asking {
55 broker: Arc<Broker>,
56 payload: UserAttentionEvent,
57}
58
59impl Asking {
60 pub fn start(broker: &Arc<Broker>, store: &Store, block_id: &str, kind: &str, text: &str) -> Self {
63 let window_ids = if block_id.is_empty() {
64 Vec::new()
65 } else {
66 super::notify::router::resolve_click_target(store, block_id).map(|t| t.window_ids).unwrap_or_default()
67 };
68 let payload = UserAttentionEvent {
69 key: uuid::Uuid::new_v4().to_string(),
70 block_id: block_id.to_string(),
71 kind: kind.to_string(),
72 text: text.chars().take(200).collect::<String>(),
73 window_ids,
74 since_ms: agentmux_common::time::now_ms_u64(),
75 active: true,
76 };
77 open().lock().unwrap_or_else(|p| p.into_inner()).insert(payload.key.clone(), payload.clone());
78 publish(broker, &payload, true);
79 Self { broker: broker.clone(), payload }
80 }
81}
82
83impl Drop for Asking {
84 fn drop(&mut self) {
85 open().lock().unwrap_or_else(|p| p.into_inner()).remove(&self.payload.key);
86 publish(&self.broker, &self.payload, false);
87 }
88}
89
90fn publish(broker: &Broker, payload: &UserAttentionEvent, active: bool) {
91 let event = UserAttentionEvent { active, ..payload.clone() };
92 broker.publish_event::<super::mps_events::UserAttention>(&event, vec![], 0);
93}
94
95#[cfg(test)]
96mod tests {
97 use super::*;
98 use crate::backend::mps::EVENT_USER_ATTENTION;
99 use serde_json::{json, Value};
100 use std::path::Path;
101
102 #[test]
103 fn announces_the_start_and_always_the_end() {
104 let seen = Arc::new(std::sync::Mutex::new(Vec::<Value>::new()));
105 let broker = Arc::new(Broker::new());
106 let log = seen.clone();
107 broker.add_observer(Arc::new(move |ev| {
108 if ev.event == EVENT_USER_ATTENTION {
109 log.lock().unwrap().push(ev.data.clone().unwrap_or_default());
110 }
111 }));
112 let store = Store::open(Path::new(":memory:")).unwrap();
113 {
114 let _asking = Asking::start(&broker, &store, "", "consent", "Agent access to area54");
115 let s = seen.lock().unwrap();
116 assert_eq!(s.len(), 1);
117 assert_eq!((s[0]["active"].clone(), s[0]["kind"].clone()), (json!(true), json!("consent")));
118 assert_eq!(s[0]["window_ids"], json!([]), "no pane: every window");
119 let key = s[0]["key"].clone();
120 assert!(open_requests().iter().any(|a| json!(a.key) == key && a.active), "listed while open");
121 }
122 assert!(open_requests().iter().all(|a| json!(a.key) != seen.lock().unwrap()[0]["key"]), "gone once ended");
123 let s = seen.lock().unwrap();
124 assert_eq!(s.len(), 2, "the end is announced when it's dropped");
125 assert_eq!(s[1]["active"], json!(false));
126 assert_eq!(s[1]["key"], s[0]["key"]);
127 }
128}