Skip to main content

agentmux_srv/backend/
user_attention.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! "An agent is waiting for you", announced by srv for the calls to action it
5//! holds open: a browser hand-off or approval, an SSH consent or other host
6//! question, a widget install
7//! (docs/reports/REPORT_AGENT_ATTENTION_CTA_CONTRAST_AND_TONE_2026_10_10.md ยง2).
8//!
9//! Each one publishes `userattention` with `active: true` when it starts and
10//! `active: false` when it ends, however it ends: the guard's `Drop` sends
11//! the end. The frontend's waiting-for-you registry turns them into the
12//! waiting tone, the tab flash and the OS notification for the pane, in the
13//! windows that show it (`window_ids`; empty means every window).
14
15use std::collections::HashMap;
16use std::sync::{Arc, Mutex, OnceLock};
17
18use super::mps::Broker;
19use super::storage::store::Store;
20
21/// One call to action srv holds open: the `userattention` event's payload
22/// (`mps_events::UserAttention`), and a row of `notify.attention`'s list.
23#[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    /// The pane it's in; empty for none.
28    pub block_id: String,
29    /// `browser`, `consent` or `widget`.
30    pub kind: String,
31    pub text: String,
32    /// The windows showing it; empty means every window.
33    pub window_ids: Vec<String>,
34    #[ts(type = "number")]
35    pub since_ms: u64,
36    /// True while it's open; false once, when it ends.
37    pub active: bool,
38}
39
40/// The announcements open now, for a window that starts while one is.
41fn 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
46/// Every open announcement (`active: true`), oldest first.
47pub 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
53/// The request is open while this lives.
54pub struct Asking {
55    broker: Arc<Broker>,
56    payload: UserAttentionEvent,
57}
58
59impl Asking {
60    /// Announces a call to action in pane `block_id` (empty: no pane) of
61    /// kind `kind` (`browser`, `consent`, `widget`), described by `text`.
62    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}