agentmux_cef/
browser_panes.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! BrowserPaneManager: embeds browsers as native OS child windows using
5//! CefBrowserHost::CreateBrowser. All creation runs on the CEF UI thread.
6//!
7//! The Browser instance is owned by the host reducer's `browsers` map (keyed
8//! by label, accessed via `AppState::get_browser` etc). We only need the
9//! block_id → label mapping, which also lives in the reducer's `panes` map.
10//!
11//! Lifecycle states are tracked explicitly via the reducer's `BrowserPaneLifecycle`:
12//!   Created → Closing → Closed (removed from `panes`)
13//! Every pane-facing op (focus/resize/navigate/…) short-circuits when the
14//! entry is already in `Closing`. This drops late IPC that the frontend
15//! fires after it has already asked for close but before CEF has destroyed
16//! the Browser — stale IPC against a mid-destruction HWND is the shape of
17//! the crash described in `docs/specs/SPEC_BROWSER_PANE_LIFECYCLE.md` §4c.
18//!
19//! **Phase H.1.d/e (PR #5):** The legacy `pane::lifecycle::PaneStateMachine`
20//! is gone; pane state lives only in `HostState.browser_panes`. All mutations go
21//! through `HostCommand::TryRegisterBrowserPaneLive` / `EnqueueBrowserPaneClose` /
22//! `CompleteBrowserPaneClose` / `DrainBrowserPaneByLabel` and read back via the reducer's
23//! atomic `DispatchOutput` fields.
24
25use std::sync::Arc;
26
27use cef::*;
28
29use crate::browser_pane::CreateBrowserPaneTask;
30use crate::reducer::RegisterResult;
31use crate::state::AppState;
32
33/// Abstraction over the CEF-side operations `close()` performs on the host
34/// process. Production implements this over `&Arc<AppState>` and Win32;
35/// tests implement it with a recording mock so the close-path state machine
36/// can be exercised without real CEF/HWNDs.
37///
38/// Kept minimal (just two methods) to avoid a dependency graph that has to
39/// be updated every time `close()` grows. Other ops (`focus`, `resize`, …)
40/// can gain their own traits when they need testing, or graduate to a
41/// unified `BrowserPaneCefBridge` once the shape is stable.
42pub trait BrowserPaneCloseOps {
43    /// Remove the Browser for this label from the registry. Return its
44    /// outer HWND as a pointer-sized value, or `None` if there is no
45    /// Browser or no HWND. Dropping the Browser Arc is the implementation's
46    /// responsibility — production drops before returning so Chromium's
47    /// refcount isn't held by our scope.
48    fn take_browser_hwnd(&self, label: &str) -> Option<usize>;
49
50    /// Destroy the given HWND. Production calls Win32 `DestroyWindow`.
51    /// Called only with values returned from `take_browser_hwnd`.
52    fn destroy_hwnd(&self, hwnd: usize);
53}
54
55/// Production implementation of `BrowserPaneCloseOps` backed by `AppState.browsers`
56/// and Win32 `DestroyWindow`.
57struct AppStateCloseOps<'a>(&'a Arc<AppState>);
58
59impl<'a> BrowserPaneCloseOps for AppStateCloseOps<'a> {
60    fn take_browser_hwnd(&self, label: &str) -> Option<usize> {
61        // Atomic take-and-return via reducer (codex P2 PR #660). Earlier
62        // round 1 separated `get_browser` + `UnregisterBrowser` dispatch,
63        // which left a window for concurrent readers to resolve the same
64        // label and act on the closing handle. `UnregisterBrowser` now
65        // returns the removed `Browser` via `DispatchOutput.removed_browser`
66        // — single host_state lock, single mutation, no race.
67        let out = self.0.host_dispatch(
68            crate::reducer::HostCommand::UnregisterBrowser {
69                label: label.to_string(),
70            },
71        );
72        let browser = out.removed_browser?;
73
74        #[cfg(target_os = "windows")]
75        let hwnd = browser.host().and_then(|h| {
76            let wh = h.window_handle();
77            if wh.0.is_null() {
78                None
79            } else {
80                Some(wh.0 as usize)
81            }
82        });
83        #[cfg(not(target_os = "windows"))]
84        let hwnd: Option<usize> = None;
85
86        // Drop our Arc before returning so Chromium's refcount doesn't wait
87        // for the caller's scope to unwind.
88        drop(browser);
89        hwnd
90    }
91
92    fn destroy_hwnd(&self, hwnd: usize) {
93        #[cfg(target_os = "windows")]
94        unsafe {
95            use windows_sys::Win32::UI::WindowsAndMessaging::{
96                DestroyWindow, GetParent, ShowWindow, SW_HIDE,
97            };
98            use windows_sys::Win32::Graphics::Gdi::{InvalidateRect, UpdateWindow};
99            let h = hwnd as *mut std::ffi::c_void;
100
101            // Capture the parent BEFORE we destroy the HWND — GetParent(h)
102            // on a destroyed HWND returns null.
103            let parent = GetParent(h);
104
105            // Hide first so DWM stops compositing the pane's GPU surface.
106            // Without this, even after DestroyWindow the Chromium compositor's
107            // last-rendered frame can stay "stuck" on-screen because the GPU
108            // process is still alive and DWM was caching that layer. Observed
109            // in v0.33.259 with a loaded google.com pane — close fires,
110            // lifecycle entry clears, HWND is gone — but the page pixels
111            // persist over the main frame until a resize/redraw.
112            ShowWindow(h, SW_HIDE);
113
114            DestroyWindow(h);
115
116            // Ask the parent (main's top-level) to repaint the area where the
117            // pane used to sit. Without InvalidateRect + UpdateWindow, DWM
118            // may keep showing the cached pane surface until unrelated UI
119            // activity happens to repaint over it.
120            if !parent.is_null() {
121                InvalidateRect(parent, std::ptr::null(), 1 /* TRUE erase */);
122                UpdateWindow(parent);
123            }
124        }
125        #[cfg(not(target_os = "windows"))]
126        {
127            let _ = hwnd;
128        }
129    }
130}
131
132pub struct BrowserPaneManager;
133
134impl BrowserPaneManager {
135    pub fn new() -> Self {
136        Self
137    }
138
139    /// Look up the Browser iff the pane is Live. Returns None when closing
140    /// so all ops short-circuit uniformly.
141    fn live_browser(&self, state: &Arc<AppState>, block_id: &str) -> Option<Browser> {
142        let label = state.live_browser_pane_label(block_id)?;
143        state.get_browser(&label)
144    }
145
146    /// Return the current URL of the pane's main frame, if the pane
147    /// is Live. Used by the browser DOM API resolver
148    /// (`crate::browser_api::resolver`) to match CEF `/json` targets
149    /// against block ids without a first-class `browserId` field on
150    /// the CEF side.
151    pub fn pane_url(&self, state: &Arc<AppState>, block_id: &str) -> Option<String> {
152        let browser = self.live_browser(state, block_id)?;
153        let frame = browser.main_frame()?;
154        Some(CefString::from(&frame.url()).to_string())
155    }
156
157    pub fn create(
158        &self,
159        state: &Arc<AppState>,
160        block_id: &str,
161        url: &str,
162        rect: Rect,
163        window_label: &str,
164    ) -> Result<(), String> {
165        // Phase H.1.d (PR #5) — sole pane-registration entry point. The
166        // reducer atomically generates the label and inserts the entry,
167        // returning Fresh / AlreadyLive / Closing via DispatchOutput.
168        let out = state.host_dispatch(
169            crate::reducer::HostCommand::TryRegisterBrowserPaneLive {
170                block_id: block_id.to_string(),
171            },
172        );
173        let result = out.browser_pane_register_result.ok_or_else(|| {
174            format!(
175                "try_register_browser_pane_live returned no result (block_id={}); host shutting down?",
176                block_id
177            )
178        })?;
179        match result {
180            RegisterResult::AlreadyLive(label) => {
181                // Existing Live entry — re-navigate the existing browser.
182                if let Some(browser) = state.get_browser(&label) {
183                    if let Some(frame) = browser.main_frame() {
184                        frame.load_url(Some(&CefString::from(url)));
185                    }
186                }
187                Ok(())
188            }
189            RegisterResult::Closing => {
190                // Reject rather than overwrite: the old CEF Browser is
191                // mid-teardown and its on_before_close will call
192                // DrainBrowserPaneByLabel — if we let create overwrite, drain
193                // would evict the NEW entry. Frontend retries on next tick.
194                Err(format!(
195                    "browser pane for block_id={} is still closing; retry after on_before_close",
196                    block_id
197                ))
198            }
199            RegisterResult::Fresh(label) => {
200                let mut task = CreateBrowserPaneTask::new(
201                    state.clone(),
202                    block_id.to_string(),
203                    label,
204                    url.to_string(),
205                    rect,
206                    window_label.to_string(),
207                );
208                post_task(ThreadId::UI, Some(&mut task));
209                Ok(())
210            }
211        }
212    }
213
214    pub fn navigate(&self, block_id: &str, url: &str, state: &Arc<AppState>) -> Result<(), String> {
215        if let Some(browser) = self.live_browser(state, block_id) {
216            if let Some(frame) = browser.main_frame() {
217                frame.load_url(Some(&CefString::from(url)));
218            }
219        }
220        Ok(())
221    }
222
223    pub fn resize(&self, block_id: &str, rect: Rect, state: &Arc<AppState>) {
224        #[cfg(target_os = "windows")]
225        if let Some(browser) = self.live_browser(state, block_id) {
226            if let Some(host) = browser.host() {
227                let hwnd = host.window_handle();
228                if !hwnd.0.is_null() {
229                    unsafe {
230                        windows_sys::Win32::UI::WindowsAndMessaging::SetWindowPos(
231                            hwnd.0 as _,
232                            std::ptr::null_mut(),
233                            rect.x, rect.y, rect.width, rect.height,
234                            0x0010, // SWP_NOACTIVATE
235                        );
236                    }
237                    host.notify_move_or_resize_started();
238                }
239            }
240        }
241        // Linux/macOS — Views path. The pane is a CefBrowserView in the main
242        // window's view hierarchy; resizing is `View::set_bounds` in DIP. Must
243        // run on the CEF UI thread (set_bounds is UI-thread-only).
244        #[cfg(not(target_os = "windows"))]
245        {
246            let label = match state.live_browser_pane_label(block_id) {
247                Some(l) => l,
248                None => return, // pane already closed or never created
249            };
250            let mut task = ResizeBrowserPaneViewTask::new(state.clone(), label, rect);
251            cef::post_task(cef::ThreadId::UI, Some(&mut task));
252        }
253    }
254
255    /// Close a pane by destroying its child HWND directly and dropping the
256    /// Browser Arc.
257    ///
258    /// We deliberately do **not** call `host.close_browser(force)`. Empirically
259    /// (host-log trace v0.33.251 and v0.33.252 in `SPEC_BROWSER_PANE_LIFECYCLE.md`
260    /// §4), CEF Alloy treats the pane Browser and the main Browser as a single
261    /// close unit when the pane's outer HWND is a child of main's top-level:
262    /// `close_browser(pane)` fires `do_close` on main too. Previous attempts
263    /// (force=0, force=1, a cascade-guard cancelling main's do_close) either
264    /// quit the whole app or orphaned the pane's pixels while blocking the
265    /// pane's own teardown.
266    ///
267    /// Instead:
268    /// 1. Remove the Browser from `state.browsers` so subsequent lookups miss.
269    /// 2. Win32 `DestroyWindow` on the pane's outer HWND. The pane HWND is a
270    ///    `WS_CHILD`; `WM_DESTROY` cascades to descendants only, never to the
271    ///    parent. Main stays up.
272    /// 3. Drop our `Browser` Arc. CEF still holds refs (browser_list etc.);
273    ///    `on_before_close` *may* eventually fire on the now-destroyed Browser,
274    ///    which is why `drain_closed_label` is idempotent.
275    ///
276    /// Trade-off: because we bypass `close_browser`, Chromium's `beforeunload`
277    /// handler doesn't run. Acceptable for a browser pane (no form data the
278    /// user expects to persist across close). If beforeunload becomes
279    /// important, revisit.
280    pub fn close(&self, block_id: &str, state: &Arc<AppState>) {
281        // Phase H.1.d (PR #5) — sole pane-close entry point. The reducer
282        // flips Live→Closing atomically and returns the entry's label iff
283        // the transition fired. None means missing or already-Closing —
284        // both idempotent no-ops; we don't dispatch CompleteBrowserPaneClose in
285        // those cases (codex P2 PR #655 race), avoiding the entry removal
286        // while another in-flight close is still tearing down the HWND.
287        let close_out = state.host_dispatch(
288            crate::reducer::HostCommand::EnqueueBrowserPaneClose {
289                block_id: block_id.to_string(),
290            },
291        );
292        let label = match close_out.closed_browser_pane_label {
293            Some(l) => l,
294            None => return,
295        };
296        #[cfg(target_os = "windows")]
297        {
298            let ops = AppStateCloseOps(state);
299            Self::close_with(&label, &ops);
300        }
301        // Linux/macOS — Views path. Marshal the BrowserView detach onto the
302        // CEF UI thread (remove_child_view is UI-thread-only); the underlying
303        // Browser's on_before_close fires asynchronously and clears
304        // state.browsers via the existing callback (callbacks::on_before_close_browser_pane).
305        #[cfg(not(target_os = "windows"))]
306        {
307            let mut task = DetachBrowserPaneViewTask::new(state.clone(), label.clone());
308            cef::post_task(cef::ThreadId::UI, Some(&mut task));
309        }
310        state.host_dispatch(
311            crate::reducer::HostCommand::CompleteBrowserPaneClose {
312                block_id: block_id.to_string(),
313            },
314        );
315        tracing::info!(block_id, label, "browser pane closed");
316
317        // PR #6 H.7 kick — top up the pool now that this pane has closed.
318        // `spawn_pool_window` is internally idempotent (single-flight +
319        // below-target check), so calling on every pane close is safe.
320        //
321        // Cross-platform: the original `weak_ptr.h:250` race that prompted
322        // an earlier Windows-only cfg-gate is gone. With the deferred
323        // OverlayController destroy (see
324        // browser_pane/creation_views.rs::detach_browser_pane_view),
325        // close() no longer destroys the controller synchronously — it
326        // just stashes it for on_before_close to destroy later. Creating
327        // a new pool window here therefore can't race a synchronous
328        // destroy of the just-closed pane's View. drain_closed_label's
329        // pool kick can't be relied on as the sole refill source either:
330        // CompleteBrowserPaneClose (dispatched above) already removed the
331        // reducer entry, so DrainBrowserPaneByLabel inside
332        // drain_closed_label is a no-op and never reaches its
333        // spawn_pool_window() call (codex P2 on PR #788).
334        crate::commands::window_pool::spawn_pool_window(state);
335    }
336
337    /// The testable side-effect body of `close()`. Given a pane's `label`,
338    /// remove its Browser handle and destroy its HWND. The state-machine
339    /// transition (Live→Closing) and the entry removal (CompleteBrowserPaneClose)
340    /// happen in `close()` via reducer dispatch — `close_with` is purely
341    /// the FFI side-effects that follow.
342    fn close_with(label: &str, ops: &dyn BrowserPaneCloseOps) {
343        if let Some(hwnd) = ops.take_browser_hwnd(label) {
344            ops.destroy_hwnd(hwnd);
345            tracing::info!(label, "pane HWND destroyed");
346        }
347    }
348
349    /// Called from CEF's `on_before_close` if/when it fires for a pane
350    /// browser. The explicit `close()` path usually clears the entry first,
351    /// so this is a no-op in that case — but `on_before_close` may still
352    /// fire async as Chromium's refcount hits zero, and `DrainBrowserPaneByLabel`
353    /// is idempotent so the callback is safe.
354    pub fn drain_closed_label(&self, state: &Arc<AppState>, label: &str) {
355        let out = state.host_dispatch(
356            crate::reducer::HostCommand::DrainBrowserPaneByLabel {
357                label: label.to_string(),
358            },
359        );
360        if let Some(block_id) = out.drained_browser_pane_block_id {
361            tracing::info!(label, block_id = %block_id, "browser pane drained via on_before_close");
362            // PR #6 H.7 kick — see `close()` for rationale. The
363            // on_before_close path is the async drain; pool refill that
364            // was deferred while the pane was Closing should now resume.
365            crate::commands::window_pool::spawn_pool_window(state);
366        }
367    }
368
369    pub fn go_back(&self, block_id: &str, state: &Arc<AppState>) {
370        if let Some(mut b) = self.live_browser(state, block_id) { b.go_back(); }
371    }
372    pub fn go_forward(&self, block_id: &str, state: &Arc<AppState>) {
373        if let Some(mut b) = self.live_browser(state, block_id) { b.go_forward(); }
374    }
375    pub fn reload(&self, block_id: &str, state: &Arc<AppState>) {
376        if let Some(mut b) = self.live_browser(state, block_id) { b.reload(); }
377    }
378
379    /// Tell every live pane browser it has lost focus, at the Chromium level.
380    /// Panes in `Closing` are skipped — their HWND may be mid-destruction and
381    /// `set_focus(0)` against it can hit an invalid render widget.
382    pub fn defocus_all(&self, state: &Arc<AppState>) {
383        // Phase H.1.b + H.2.b — read live labels via reducer-aware helper,
384        // then look up each browser via reducer-aware helper. Both with
385        // fallback + drift logging.
386        let labels = state.live_browser_pane_labels();
387        for label in &labels {
388            if let Some(browser) = state.get_browser(label) {
389                if let Some(host) = browser.host() {
390                    host.set_focus(0);
391                }
392            }
393        }
394    }
395
396    /// Apply a clip region to every live pane HWND that subtracts the given
397    /// overlay rects (in main-window client coordinates). The pane renders
398    /// normally outside the overlay region; inside it, the HWND is
399    /// transparent so the DOM overlay painted at the same screen position
400    /// shows through.
401    ///
402    /// This is the Win32 "airspace" workaround — native HWNDs always paint
403    /// above DOM regardless of CSS z-index, and `SetWindowRgn` is the one
404    /// mechanism that lets DOM bleed through a specific region of a child
405    /// HWND. Empty `overlay_rects` restores full pane visibility (same as
406    /// calling `clear_pane_overlay_clip`).
407    ///
408    /// No-op on non-Windows: other platforms don't use native child HWNDs
409    /// for panes, so there's no airspace to work around.
410    ///
411    /// `window_label` scopes the clip to panes whose top-level ancestor
412    /// matches the requesting window. Without it, a modal opened in
413    /// window B would clip panes in window A (see Codex P1 on PR #544).
414    /// Empty string matches today's legacy callers that don't know their
415    /// window label — falls through to the no-filter behaviour for
416    /// back-compat until every caller is updated.
417    #[cfg(target_os = "windows")]
418    pub fn set_pane_overlay_clip(
419        &self,
420        state: &Arc<AppState>,
421        window_label: &str,
422        overlay_rects: &[(i32, i32, i32, i32)],
423    ) {
424        use windows_sys::Win32::Foundation::{POINT, RECT};
425        use windows_sys::Win32::Graphics::Gdi::{
426            CombineRgn, CreateRectRgn, DeleteObject, MapWindowPoints, SetWindowRgn, RGN_DIFF,
427        };
428        use windows_sys::Win32::UI::WindowsAndMessaging::{
429            GetAncestor, GetParent, GetWindowRect, GA_ROOT,
430        };
431
432        // Resolve the requesting window's top-level HWND so we can filter
433        // panes by ownership. If the label is unknown we fall through with
434        // no filter — matches pre-scoping behaviour rather than silently
435        // doing nothing.
436        let requesting_top_level: *mut std::ffi::c_void = if window_label.is_empty() {
437            std::ptr::null_mut()
438        } else {
439            // Phase H.2.b — reducer-aware lookup with fallback.
440            match state.get_browser(window_label).and_then(|b| b.host()) {
441                Some(host) => {
442                    let h = host.window_handle();
443                    if h.0.is_null() {
444                        std::ptr::null_mut()
445                    } else {
446                        unsafe { GetAncestor(h.0 as _, GA_ROOT) as *mut std::ffi::c_void }
447                    }
448                }
449                None => std::ptr::null_mut(),
450            }
451        };
452
453        // Phase H.1.b + H.2.b — labels via reducer-aware helper; per-label
454        // browser lookup via reducer-aware helper. Drops the held-across-loop
455        // legacy lock; each iteration now snapshots independently.
456        let labels = state.live_browser_pane_labels();
457        for label in &labels {
458            let browser = match state.get_browser(label) {
459                Some(b) => b,
460                None => continue,
461            };
462            let host = match browser.host() {
463                Some(h) => h,
464                None => continue,
465            };
466            let hwnd_raw = host.window_handle();
467            if hwnd_raw.0.is_null() {
468                continue;
469            }
470            let pane_hwnd = hwnd_raw.0 as *mut std::ffi::c_void;
471
472            // Window-scope filter. Skip panes whose top-level HWND differs
473            // from the requesting window's. `null` requesting = legacy
474            // caller / no-op filter (applies to all panes).
475            if !requesting_top_level.is_null() {
476                let pane_top = unsafe { GetAncestor(pane_hwnd as _, GA_ROOT) as *mut std::ffi::c_void };
477                if pane_top != requesting_top_level {
478                    continue;
479                }
480            }
481
482            unsafe {
483                // Empty overlay list = restore full visibility (region=NULL).
484                if overlay_rects.is_empty() {
485                    SetWindowRgn(pane_hwnd as _, std::ptr::null_mut(), 1);
486                    continue;
487                }
488
489                // Resolve the pane's position in its parent (main window)
490                // client coords so we can translate overlay rects (which
491                // arrive in main-window client coords from the frontend)
492                // into pane-local coords for the region API.
493                let parent = GetParent(pane_hwnd as _);
494                if parent.is_null() {
495                    continue;
496                }
497                let mut pane_rect: RECT = std::mem::zeroed();
498                if GetWindowRect(pane_hwnd as _, &mut pane_rect) == 0 {
499                    continue;
500                }
501                // Convert pane_rect from screen coords to parent client
502                // coords by mapping its two corner points.
503                let pts_ptr = &mut pane_rect as *mut RECT as *mut POINT;
504                MapWindowPoints(std::ptr::null_mut(), parent, pts_ptr, 2);
505
506                let pane_w = pane_rect.right - pane_rect.left;
507                let pane_h = pane_rect.bottom - pane_rect.top;
508                if pane_w <= 0 || pane_h <= 0 {
509                    continue;
510                }
511
512                // Build region in pane-local coords: start with full pane,
513                // subtract every overlay rect that intersects it.
514                let region = CreateRectRgn(0, 0, pane_w, pane_h);
515                if region.is_null() {
516                    continue;
517                }
518                for (ox, oy, ow, oh) in overlay_rects {
519                    // Translate overlay rect (window client coords) →
520                    // pane-local coords by subtracting pane's window pos.
521                    let left = ox - pane_rect.left;
522                    let top = oy - pane_rect.top;
523                    let right = left + ow;
524                    let bottom = top + oh;
525                    // Skip if no intersection with the pane's local bounds.
526                    if right <= 0 || bottom <= 0 || left >= pane_w || top >= pane_h {
527                        continue;
528                    }
529                    let overlay_rgn = CreateRectRgn(left, top, right, bottom);
530                    if !overlay_rgn.is_null() {
531                        CombineRgn(region, region, overlay_rgn, RGN_DIFF);
532                        DeleteObject(overlay_rgn as _);
533                    }
534                }
535                // SetWindowRgn takes ownership of the region handle on
536                // success; the system frees it when the window is destroyed
537                // or a new region is set.
538                SetWindowRgn(pane_hwnd as _, region as _, 1);
539            }
540        }
541        tracing::info!(
542            pane_count = labels.len(),
543            overlay_count = overlay_rects.len(),
544            "[pane-airspace] applied overlay clip to pane HWNDs",
545        );
546    }
547    /// Linux/macOS — equivalent of the Windows SetWindowRgn airspace
548    /// workaround, but built on Views instead of HWND clip regions.
549    ///
550    /// `add_overlay_view` puts the pane on a higher z-layer than the host UI
551    /// BrowserView, so any host-side modal/dropdown/contextmenu that overlaps
552    /// a pane rect renders UNDERNEATH the pane and becomes unclickable. We
553    /// can't punch a clip hole through an Aura View the way Win32 SetWindowRgn
554    /// does on an HWND. The pragmatic workaround: when ANY overlay rect
555    /// overlaps a pane's bounds, hide that pane (`set_visible(false)`); when
556    /// no overlay rect intersects, show it again. The DOM modal renders in
557    /// the host UI BrowserView underneath, becomes the topmost paint at that
558    /// rect, and the pane's content briefly disappears — same UX trade-off
559    /// the Windows path makes (Win32 punches a hole; we hide the whole pane).
560    /// (Codex P1 on PR #682.)
561    ///
562    /// Future improvement: only hide the overlapping fraction of the pane
563    /// (would need a per-overlay set_size + position trick or a custom Layout).
564    /// Hiding the whole pane is acceptable for now — the airspace problem only
565    /// arises when modals open over panes, which is a transient case.
566    ///
567    /// `_window_label` is currently ignored on this path because we only
568    /// support a single primary window for panes (sub-window panes are a
569    /// follow-up — see PR #682's "Risks / follow-ups" section).
570    /// (See doc comment for the cfg(target_os = "windows") variant.)
571    /// Linux/macOS body — marshalled to the CEF UI thread because
572    /// `OverlayController::set_visible` and `bounds()` are UI-thread-only.
573    /// IPC handler runs on tokio so we post a task and return immediately.
574    /// `window_label` filters which panes get visibility-managed: only those
575    /// attached to the requesting window are affected.
576    #[cfg(not(target_os = "windows"))]
577    pub fn set_pane_overlay_clip(
578        &self,
579        state: &Arc<AppState>,
580        window_label: &str,
581        overlay_rects: &[(i32, i32, i32, i32)],
582    ) {
583        // Publish to AppState so resize_browser_pane_view can consult the
584        // same authoritative rect list when computing pane visibility on
585        // its own code path. Without this, a positive-dimension resize
586        // (e.g. user drags a splitter while a DOM modal is open) would
587        // call set_visible(1) and re-expose the pane on top of the modal.
588        // See state::pane_overlay_rects doc comment.
589        state
590            .pane_overlay_rects
591            .lock()
592            .insert(window_label.to_string(), overlay_rects.to_vec());
593
594        let mut task = SetPaneOverlayClipViewsTask::new(
595            state.clone(),
596            window_label.to_string(),
597            overlay_rects.to_vec(),
598        );
599        cef::post_task(cef::ThreadId::UI, Some(&mut task));
600    }
601
602    /// Give keyboard focus to the pane's child HWND so keystrokes reach the
603    /// embedded page. Called by the frontend's ViewModel.giveFocus() when the
604    /// pane becomes the active layout node — without this, focus falls back to
605    /// the main window's invisible "dummy-focus" input and keystrokes vanish.
606    ///
607    /// No-ops if the pane is `Closing`: a SetFocus against a HWND that CEF is
608    /// concurrently tearing down is the exact race documented in
609    /// `SPEC_BROWSER_PANE_LIFECYCLE.md` §5 race #2.
610    pub fn focus(&self, block_id: &str, state: &Arc<AppState>) {
611        if let Some(browser) = self.live_browser(state, block_id) {
612            if let Some(host) = browser.host() {
613                host.set_focus(1);
614                #[cfg(target_os = "windows")]
615                {
616                    let hwnd = host.window_handle();
617                    if !hwnd.0.is_null() {
618                        // Tell the subclass this focus request is intentional
619                        // (not Chromium's on-load focus steal) so it won't be
620                        // redirected back to the parent.
621                        crate::browser_pane::ALLOW_BROWSER_PANE_FOCUS_ONCE.store(
622                            true,
623                            std::sync::atomic::Ordering::Relaxed,
624                        );
625                        unsafe {
626                            windows_sys::Win32::UI::Input::KeyboardAndMouse::SetFocus(hwnd.0 as _);
627                        }
628                    }
629                }
630            }
631        }
632    }
633}
634
635// `CreateBrowserPaneTask` moved to `crate::browser_pane::creation` in Phase 3.
636
637/// Two axis-aligned rects intersect iff neither is fully to one side of the
638/// other. Coordinates: (x, y, width, height). Used by the Linux/macOS
639/// pane-airspace logic to decide whether an overlay rect from the frontend
640/// covers any part of a pane's bounds.
641#[cfg(not(target_os = "windows"))]
642fn rects_intersect(a: (i32, i32, i32, i32), b: (i32, i32, i32, i32)) -> bool {
643    let (ax, ay, aw, ah) = a;
644    let (bx, by, bw, bh) = b;
645    let a_right = ax + aw;
646    let a_bottom = ay + ah;
647    let b_right = bx + bw;
648    let b_bottom = by + bh;
649    !(a_right <= bx || b_right <= ax || a_bottom <= by || b_bottom <= ay)
650}
651
652/// Compute whether a pane with the given bounds should be visible, given
653/// the pane's parent window. Both pane-airspace (`SetPaneOverlayClipViewsTask`)
654/// and per-pane resize (`resize_browser_pane_view`) call this to converge on
655/// the same answer — without it, the two paths fight each other (Codex
656/// review on PR #881 caught the dragging-splitter-while-modal-open case
657/// where a positive resize re-exposed a pane that airspace had hidden).
658///
659/// A pane is visible iff BOTH conditions hold:
660/// - Its rect has non-zero width and height (frontend places it in a
661///   `display:none` placeholder when the tab is inactive → reports 0×0).
662/// - It does not intersect any registered overlay-clip rect for its window
663///   (e.g. a hamburger menu, tooltip, modal popover).
664#[cfg(not(target_os = "windows"))]
665pub fn compute_pane_visible(
666    state: &Arc<AppState>,
667    window_label: &str,
668    pane_rect: (i32, i32, i32, i32),
669) -> bool {
670    let (_, _, w, h) = pane_rect;
671    if w <= 0 || h <= 0 {
672        return false;
673    }
674    let rects = state.pane_overlay_rects.lock();
675    let overlays = match rects.get(window_label) {
676        Some(v) => v.clone(),
677        None => return true,
678    };
679    drop(rects);
680    !overlays.iter().any(|or| rects_intersect(*or, pane_rect))
681}
682
683// ── Linux/macOS UI-thread marshalling tasks ────────────────────────────────
684//
685// `View::set_bounds` and `Window::remove_child_view` must run on the CEF UI
686// thread. Both `BrowserPaneManager::resize` and `::close` are called from IPC
687// handler tasks on tokio threads, so we wrap the UI-thread bodies in
688// `wrap_task!` structs and post them via `post_task(ThreadId::UI, ...)` —
689// same pattern as `ui_tasks::CloseWindowTask` / `MaximizeWindowTask` / etc.
690
691#[cfg(not(target_os = "windows"))]
692wrap_task! {
693    pub struct ResizeBrowserPaneViewTask {
694        state: Arc<AppState>,
695        label: String,
696        rect: Rect,
697    }
698
699    impl Task {
700        fn execute(&self) {
701            crate::browser_pane::creation_views::resize_browser_pane_view(
702                &self.state, &self.label, self.rect.clone(),
703            );
704        }
705    }
706}
707
708#[cfg(not(target_os = "windows"))]
709wrap_task! {
710    pub struct DetachBrowserPaneViewTask {
711        state: Arc<AppState>,
712        label: String,
713    }
714
715    impl Task {
716        fn execute(&self) {
717            crate::browser_pane::creation_views::detach_browser_pane_view(
718                &self.state, &self.label,
719            );
720        }
721    }
722}
723
724/// Linux/macOS pane-airspace task — fired by `set_pane_overlay_clip` for the
725/// non-Windows code path. For each live OverlayController, hide it when any
726/// overlay rect intersects its current bounds; show it otherwise. See the
727/// doc comment on `set_pane_overlay_clip` (non-Windows variant) for why this
728/// is the equivalent of the Windows SetWindowRgn airspace dance.
729#[cfg(not(target_os = "windows"))]
730wrap_task! {
731    pub struct SetPaneOverlayClipViewsTask {
732        state: Arc<AppState>,
733        // Only panes attached to this window get visibility-managed; panes
734        // in other windows are unaffected by overlay rects from this window.
735        // Mirrors the window_label filtering in the Windows path.
736        window_label: String,
737        overlay_rects: Vec<(i32, i32, i32, i32)>,
738    }
739
740    impl Task {
741        fn execute(&self) {
742            // Snapshot the (pane label, parent window label, controller) tuples,
743            // filter by parent-window-label matching the requesting window,
744            // drop the mutex before any FFI call (snapshot-and-drop discipline
745            // per docs/specs/SPEC_PHASE_F_HOST_REDUCER §6).
746            let live: Vec<(String, cef::OverlayController)> = self
747                .state
748                .browser_pane_overlays
749                .lock()
750                .iter()
751                .filter(|(_, (win_label, _))| win_label == &self.window_label)
752                .map(|(k, (_, c))| (k.clone(), c.clone()))
753                .collect();
754            if live.is_empty() {
755                return;
756            }
757            for (label, controller) in live {
758                use cef::ImplOverlayController;
759                let pb = controller.bounds();
760                let pane_rect = (pb.x, pb.y, pb.width, pb.height);
761                // Shared visibility helper consults BOTH the pane's own rect
762                // (zero → hidden because tab inactive) and the latest
763                // overlay-clip rects published in AppState. Resize path uses
764                // the same helper so both decisions converge.
765                let visible = compute_pane_visible(&self.state, &self.window_label, pane_rect);
766                controller.set_visible(if visible { 1 } else { 0 });
767                tracing::debug!(
768                    label = %label,
769                    window_label = %self.window_label,
770                    visible,
771                    pane_x = pb.x, pane_y = pb.y, pane_w = pb.width, pane_h = pb.height,
772                    overlay_count = self.overlay_rects.len(),
773                    "[pane-airspace] views: applied visibility"
774                );
775            }
776        }
777    }
778}
779
780// ── Tests ───────────────────────────────────────────────────────────────────
781//
782// Phase H.1.d/e (PR #5): The pane state machine lives in the host reducer
783// (`HostState.browser_panes`). Lifecycle transition tests — Live→Closing, idempotent
784// no-ops for missing or already-Closing entries, label sequence monotonicity,
785// drain-by-label — are now in `crate::reducer::tests`.
786//
787// What remains here: the FFI seam. `close_with` only takes a label and
788// drives `BrowserPaneCloseOps`; tests verify it forwards label → take → destroy
789// in order, with a None-returning `take` short-circuiting the destroy.
790
791#[cfg(test)]
792mod tests {
793    use super::*;
794    use std::collections::HashMap;
795
796    /// Recording mock for `BrowserPaneCloseOps`. Tests inspect `taken` and
797    /// `destroyed` to assert what close_with did.
798    struct MockCloseOps {
799        registered: parking_lot::Mutex<HashMap<String, usize>>,
800        taken: parking_lot::Mutex<Vec<String>>,
801        destroyed: parking_lot::Mutex<Vec<usize>>,
802    }
803
804    impl MockCloseOps {
805        fn new() -> Self {
806            Self {
807                registered: parking_lot::Mutex::new(HashMap::new()),
808                taken: parking_lot::Mutex::new(Vec::new()),
809                destroyed: parking_lot::Mutex::new(Vec::new()),
810            }
811        }
812
813        fn register(&self, label: &str, hwnd: usize) {
814            self.registered.lock().insert(label.to_string(), hwnd);
815        }
816
817        fn taken_labels(&self) -> Vec<String> {
818            self.taken.lock().clone()
819        }
820
821        fn destroyed_hwnds(&self) -> Vec<usize> {
822            self.destroyed.lock().clone()
823        }
824    }
825
826    impl BrowserPaneCloseOps for MockCloseOps {
827        fn take_browser_hwnd(&self, label: &str) -> Option<usize> {
828            self.taken.lock().push(label.to_string());
829            self.registered.lock().remove(label)
830        }
831
832        fn destroy_hwnd(&self, hwnd: usize) {
833            self.destroyed.lock().push(hwnd);
834        }
835    }
836
837    #[test]
838    fn close_with_take_then_destroy_in_order() {
839        let ops = MockCloseOps::new();
840        ops.register("browser-pane-b1-1", 0xABCD);
841
842        BrowserPaneManager::close_with("browser-pane-b1-1", &ops);
843
844        assert_eq!(ops.taken_labels(), vec!["browser-pane-b1-1"]);
845        assert_eq!(ops.destroyed_hwnds(), vec![0xABCD]);
846    }
847
848    #[test]
849    fn close_with_no_hwnd_skips_destroy() {
850        // Browser was already gone (rare race — explicit close raced with
851        // an external close). take returns None; destroy must NOT be called.
852        let ops = MockCloseOps::new(); // no register() — lookup will miss
853
854        BrowserPaneManager::close_with("browser-pane-missing", &ops);
855
856        assert_eq!(ops.taken_labels(), vec!["browser-pane-missing"]);
857        assert!(ops.destroyed_hwnds().is_empty(),
858            "destroy_hwnd must not be called when take_browser_hwnd returns None");
859    }
860}