Skip to main content

agentmux_srv/server/service/
window_inherit.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! `window` service handlers — `MarkWindowOpen` and `InheritWindowAppearance`:
5//! a new window opens with the theme and opacity of the window that created
6//! it (docs/specs/SPEC_PER_WINDOW_THEME_AND_OPACITY_2026_10_12.md §6).
7//!
8//! The creator takes a mark (`MarkWindowOpen`), opens the window, learns its
9//! host label from that call's return, and then calls
10//! `InheritWindowAppearance` with its own window id, the label and the mark.
11//! The new window's renderer creates its Window row (`CreateWindow`) at about
12//! the same moment, so the two calls race. Each side first publishes what it
13//! has, then checks for the other: `InheritWindowAppearance` records a
14//! pending entry and then looks for the row; `CreateWindow` records its row
15//! and then takes any pending entry. Whichever side takes the entry applies
16//! it, so it is applied exactly once whatever the order.
17//!
18//! Host labels are reused: a pool window can be demoted and promoted again
19//! straight away, and the old row can outlive that (its close runs on
20//! another thread). The mark is what tells the new row from the old one.
21//! Marks and row records share one counter, so a row created after the mark
22//! was taken is the one this open made; any older row for the label belongs
23//! to an earlier use of it and never takes the entry.
24
25use std::collections::HashMap;
26use std::sync::atomic::{AtomicU64, Ordering};
27use std::sync::{LazyLock, Mutex};
28use std::time::{Duration, Instant};
29
30use crate::backend::obj::*;
31use crate::backend::service::{self, WebCallType, WebReturnType};
32
33use super::super::AppState;
34use super::reducer_helpers::{dispatch_to_reducer, publish_events};
35
36/// The per-window appearance keys a new window copies from its creator.
37pub(crate) const INHERITED_KEYS: &[&str] = &["window:theme", "window:opacity"];
38
39/// How long a pending entry waits for its window. A new window creates its
40/// row within a second or two of being opened.
41const PENDING_TTL: Duration = Duration::from_secs(30);
42
43/// Orders marks and row records: every mark and every recorded row gets the
44/// next value.
45static SEQ: AtomicU64 = AtomicU64::new(1);
46
47struct Pending {
48    source_window_id: String,
49    /// Only a row recorded after this mark is the window being opened.
50    after: u64,
51    at: Instant,
52}
53
54/// host label → the inheritance waiting for that label's new row.
55static PENDING: LazyLock<Mutex<HashMap<String, Pending>>> = LazyLock::new(|| Mutex::new(HashMap::new()));
56
57/// host label → (the window id `CreateWindow` last made for it, its sequence, when).
58static CREATED: LazyLock<Mutex<HashMap<String, (String, u64, Instant)>>> =
59    LazyLock::new(|| Mutex::new(HashMap::new()));
60
61fn next_seq() -> u64 {
62    SEQ.fetch_add(1, Ordering::SeqCst)
63}
64
65/// Called by `CreateWindow` once the row exists, before it takes a pending
66/// entry. Returns the row's sequence, for `take_pending`.
67pub(crate) fn record_created(label: &str, window_id: &str) -> u64 {
68    let seq = next_seq();
69    let mut created = CREATED.lock().unwrap_or_else(|e| e.into_inner());
70    created.retain(|_, (_, _, at)| at.elapsed() < PENDING_TTL);
71    created.insert(label.to_string(), (window_id.to_string(), seq, Instant::now()));
72    seq
73}
74
75/// Take the pending source for `label` if a row with sequence `row_seq` is
76/// the one it waits for: recorded after its mark, and the entry still fresh.
77pub(crate) fn take_pending(label: &str, row_seq: u64) -> Option<String> {
78    let mut pending = PENDING.lock().unwrap_or_else(|e| e.into_inner());
79    let entry = pending.get(label)?;
80    if row_seq <= entry.after {
81        return None;
82    }
83    let entry = pending.remove(label)?;
84    (entry.at.elapsed() < PENDING_TTL).then_some(entry.source_window_id)
85}
86
87fn record_pending(label: &str, source_window_id: &str, after: u64) {
88    let mut pending = PENDING.lock().unwrap_or_else(|e| e.into_inner());
89    pending.retain(|_, p| p.at.elapsed() < PENDING_TTL);
90    pending.insert(
91        label.to_string(),
92        Pending {
93            source_window_id: source_window_id.to_string(),
94            after,
95            at: Instant::now(),
96        },
97    );
98}
99
100/// The source window's appearance keys that are set, as a meta patch.
101/// `None` when the source is gone or has none of them set.
102pub(crate) fn appearance_patch(source: &Window) -> Option<serde_json::Value> {
103    let patch: serde_json::Map<String, serde_json::Value> = INHERITED_KEYS
104        .iter()
105        .filter_map(|k| {
106            source
107                .meta
108                .get(*k)
109                .filter(|v| !v.is_null())
110                .map(|v| (k.to_string(), v.clone()))
111        })
112        .collect();
113    (!patch.is_empty()).then(|| serde_json::Value::Object(patch))
114}
115
116/// Copy `source_window_id`'s appearance onto `target_window_id`. Returns the
117/// events to publish; empty when there is nothing to copy.
118pub(crate) async fn apply_inheritance(
119    state: &AppState,
120    source_window_id: &str,
121    target_window_id: &str,
122) -> Vec<agentmux_common::ipc::Event> {
123    let store = &state.mstore;
124    let Some(patch) = store
125        .get::<Window>(source_window_id)
126        .ok()
127        .flatten()
128        .and_then(|w| appearance_patch(&w))
129    else {
130        return Vec::new();
131    };
132    let evs = dispatch_to_reducer(
133        state,
134        agentmux_common::ipc::Command::UpdateWindowMeta {
135            window_id: target_window_id.to_string(),
136            meta_patch: patch,
137        },
138    )
139    .await;
140    for ev in &evs {
141        if let Err(e) = crate::persist_subscriber::apply_event_to_mstore(ev, store) {
142            tracing::warn!(
143                window_id = %target_window_id,
144                error = %e,
145                "InheritWindowAppearance: meta write failed — window keeps the defaults"
146            );
147        }
148    }
149    evs
150}
151
152/// No args. Returns a mark to pass to `InheritWindowAppearance`; take it
153/// before opening the window.
154pub(crate) async fn handle_mark_window_open(_state: &AppState, _call: &WebCallType) -> WebReturnType {
155    WebReturnType::success(serde_json::json!(next_seq()))
156}
157
158/// Args: `sourceWindowId`, `hostLabel` (the new window's host label), `mark`
159/// (from `MarkWindowOpen`, taken before the window was opened).
160pub(crate) async fn handle_inherit_window_appearance(state: &AppState, call: &WebCallType) -> WebReturnType {
161    let args = &call.args;
162    let source_window_id: String = match service::get_arg(args, 0) {
163        Ok(v) => v,
164        Err(e) => return WebReturnType::error(e),
165    };
166    let label: String = match service::get_arg(args, 1) {
167        Ok(v) => v,
168        Err(e) => return WebReturnType::error(e),
169    };
170    let mark: u64 = match service::get_arg(args, 2) {
171        Ok(v) => v,
172        Err(e) => return WebReturnType::error(e),
173    };
174    if source_window_id.is_empty() || label.is_empty() {
175        return WebReturnType::error("InheritWindowAppearance: empty sourceWindowId or hostLabel".to_string());
176    }
177    record_pending(&label, &source_window_id, mark);
178    // The row may already exist (its renderer won the race). If so, and
179    // CreateWindow hasn't already taken the entry, apply it here.
180    let created = CREATED.lock().unwrap_or_else(|e| e.into_inner()).get(&label).cloned();
181    if let Some((target, row_seq, _)) = created {
182        if target != source_window_id {
183            if let Some(source) = take_pending(&label, row_seq) {
184                let evs = apply_inheritance(state, &source, &target).await;
185                publish_events(state, &evs);
186            }
187        }
188    }
189    WebReturnType::success_empty()
190}
191
192#[cfg(test)]
193mod tests {
194    use super::super::window::handle_window_service;
195    use crate::backend::obj::Window;
196    use crate::backend::service::WebCallType;
197    use crate::server::tests::test_state;
198
199    fn call(method: &str, args: Vec<serde_json::Value>) -> WebCallType {
200        WebCallType {
201            service: "window".to_string(),
202            method: method.to_string(),
203            uicontext: None,
204            args,
205        }
206    }
207
208    async fn create(state: &crate::server::AppState, label: &str) -> String {
209        let ret = handle_window_service(
210            state,
211            &call("CreateWindow", vec![serde_json::Value::Null, "".into(), label.into()]),
212        )
213        .await;
214        assert!(ret.success, "CreateWindow failed: {:?}", ret.error);
215        ret.data
216            .as_ref()
217            .and_then(|d| d.get("oid"))
218            .and_then(|v| v.as_str())
219            .expect("CreateWindow returns the Window with oid")
220            .to_string()
221    }
222
223    async fn set_meta(state: &crate::server::AppState, window_id: &str, patch: serde_json::Value) {
224        let evs = super::dispatch_to_reducer(
225            state,
226            agentmux_common::ipc::Command::UpdateWindowMeta {
227                window_id: window_id.to_string(),
228                meta_patch: patch,
229            },
230        )
231        .await;
232        for ev in &evs {
233            crate::persist_subscriber::apply_event_to_mstore(ev, &state.mstore).unwrap();
234        }
235    }
236
237    fn meta(state: &crate::server::AppState, window_id: &str, key: &str) -> Option<serde_json::Value> {
238        state.mstore.must_get::<Window>(window_id).unwrap().meta.get(key).cloned()
239    }
240
241    async fn mark(state: &crate::server::AppState) -> u64 {
242        let ret = handle_window_service(state, &call("MarkWindowOpen", vec![])).await;
243        assert!(ret.success, "MarkWindowOpen failed: {:?}", ret.error);
244        ret.data.and_then(|d| d.as_u64()).expect("MarkWindowOpen returns a number")
245    }
246
247    async fn inherit(state: &crate::server::AppState, source: &str, label: &str, mark: u64) {
248        let ret = handle_window_service(
249            state,
250            &call("InheritWindowAppearance", vec![source.into(), label.into(), mark.into()]),
251        )
252        .await;
253        assert!(ret.success, "InheritWindowAppearance failed: {:?}", ret.error);
254    }
255
256    /// The creator's call lands first: CreateWindow applies it as it makes the row.
257    #[tokio::test]
258    async fn inherits_when_the_creator_calls_before_the_window_exists() {
259        let state = test_state();
260        let source = create(&state, "inherit-src-a").await;
261        set_meta(&state, &source, serde_json::json!({ "window:theme": "dracula", "window:opacity": 0.7 })).await;
262
263        let m = mark(&state).await;
264        inherit(&state, &source, "inherit-new-a", m).await;
265        let new = create(&state, "inherit-new-a").await;
266
267        assert_eq!(meta(&state, &new, "window:theme"), Some("dracula".into()));
268        assert_eq!(meta(&state, &new, "window:opacity"), Some(serde_json::json!(0.7)));
269    }
270
271    /// The new window's row exists first: the creator's call applies it.
272    #[tokio::test]
273    async fn inherits_when_the_window_exists_before_the_creator_calls() {
274        let state = test_state();
275        let source = create(&state, "inherit-src-b").await;
276        set_meta(&state, &source, serde_json::json!({ "window:theme": "nord" })).await;
277
278        let m = mark(&state).await;
279        let new = create(&state, "inherit-new-b").await;
280        assert_eq!(meta(&state, &new, "window:theme"), None);
281        inherit(&state, &source, "inherit-new-b", m).await;
282
283        assert_eq!(meta(&state, &new, "window:theme"), Some("nord".into()));
284        // Keys the source doesn't set aren't written as nulls.
285        assert_eq!(meta(&state, &new, "window:opacity"), None);
286    }
287
288    /// A window opened with no inheritance pending keeps the defaults, and an
289    /// entry is used once: a later window with the same label gets nothing.
290    #[tokio::test]
291    async fn an_entry_is_used_once() {
292        let state = test_state();
293        let source = create(&state, "inherit-src-c").await;
294        set_meta(&state, &source, serde_json::json!({ "window:theme": "dracula" })).await;
295
296        let plain = create(&state, "inherit-new-c").await;
297        assert_eq!(meta(&state, &plain, "window:theme"), None);
298
299        let m = mark(&state).await;
300        inherit(&state, &source, "inherit-new-d", m).await;
301        let first = create(&state, "inherit-new-d").await;
302        let second = create(&state, "inherit-new-d").await;
303        assert_eq!(meta(&state, &first, "window:theme"), Some("dracula".into()));
304        assert_eq!(meta(&state, &second, "window:theme"), None);
305    }
306
307    /// A label reused at once (a pool window demoted and promoted again): the
308    /// earlier use's row, created moments before the mark, never takes the
309    /// entry; the row made after the mark does.
310    #[tokio::test]
311    async fn a_row_from_an_earlier_use_of_the_label_does_not_take_it() {
312        let state = test_state();
313        let source = create(&state, "inherit-src-f").await;
314        set_meta(&state, &source, serde_json::json!({ "window:theme": "nord" })).await;
315        let earlier = create(&state, "inherit-new-f").await;
316
317        let m = mark(&state).await;
318        inherit(&state, &source, "inherit-new-f", m).await;
319        assert_eq!(meta(&state, &earlier, "window:theme"), None);
320        let new = create(&state, "inherit-new-f").await;
321        assert_eq!(meta(&state, &new, "window:theme"), Some("nord".into()));
322    }
323
324    /// A reloaded window other than the main one gets a new row; passing the
325    /// row it had before (CreateWindow arg 6) brings its own theme and
326    /// opacity across.
327    #[tokio::test]
328    async fn a_reload_keeps_the_windows_own_appearance() {
329        let state = test_state();
330        let before = create(&state, "inherit-reload-a").await;
331        set_meta(&state, &before, serde_json::json!({ "window:theme": "nord", "window:opacity": 0.5 })).await;
332
333        let ret = handle_window_service(
334            &state,
335            &call(
336                "CreateWindow",
337                vec![
338                    serde_json::Value::Null,
339                    "".into(),
340                    "inherit-reload-a".into(),
341                    serde_json::Value::Null,
342                    serde_json::Value::Null,
343                    serde_json::Value::Null,
344                    before.clone().into(),
345                ],
346            ),
347        )
348        .await;
349        assert!(ret.success, "CreateWindow failed: {:?}", ret.error);
350        let after = ret.data.as_ref().and_then(|d| d.get("oid")).and_then(|v| v.as_str()).unwrap().to_string();
351        assert_ne!(after, before);
352        assert_eq!(meta(&state, &after, "window:theme"), Some("nord".into()));
353        assert_eq!(meta(&state, &after, "window:opacity"), Some(serde_json::json!(0.5)));
354    }
355
356    /// A source with no appearance of its own copies nothing.
357    #[tokio::test]
358    async fn a_default_source_copies_nothing() {
359        let state = test_state();
360        let source = create(&state, "inherit-src-e").await;
361        let m = mark(&state).await;
362        inherit(&state, &source, "inherit-new-e", m).await;
363        let new = create(&state, "inherit-new-e").await;
364        assert_eq!(meta(&state, &new, "window:theme"), None);
365        assert_eq!(meta(&state, &new, "window:opacity"), None);
366    }
367
368    #[tokio::test]
369    async fn rejects_empty_or_missing_args() {
370        let state = test_state();
371        let ret = handle_window_service(&state, &call("InheritWindowAppearance", vec!["".into(), "x".into(), 1.into()])).await;
372        assert!(!ret.success);
373        let ret = handle_window_service(&state, &call("InheritWindowAppearance", vec!["w".into(), "x".into()])).await;
374        assert!(!ret.success, "a request without a mark is refused");
375    }
376}