agentmux_cef\browser_pane/
callbacks.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Pane-specific CEF callback bodies.
5//!
6//! Extracted from `client.rs` in Phase 4 of the modularization split
7//! (see `docs/specs/SPEC_BROWSER_PANE_MODULARIZATION.md` §6). `AgentMuxHandler`
8//! still owns the CEF callback plumbing; this module holds the pane-branch
9//! bodies so pane-specific logic lives in one place instead of threaded
10//! through `if self.is_browser_pane` branches in `client.rs`.
11//!
12//! Notable: this is where `install_browser_pane_focus_redirect` actually gets wired
13//! in. Before this phase the function existed in `browser_pane::hwnd` but had zero
14//! callers (see `SPEC_BROWSER_PANE_LIFECYCLE.md` §5 race #5). Now
15//! `on_after_created_browser_pane` and `on_load_end_browser_pane` both reinstall the focus
16//! subclass — required because Chromium recreates the
17//! `Chrome_RenderWidgetHostHWND` child on every navigation, stranding the
18//! old subclass on a destroyed HWND.
19
20use std::sync::Arc;
21
22use cef::*;
23
24use crate::state::AppState;
25
26/// Called from `AgentMuxHandler::on_after_created` when the browser being
27/// registered is a pane (label prefix `browser-pane-*`).
28///
29/// Responsibilities:
30/// 1. Raise the pane's outer HWND to the top of its parent's Z-order so
31///    mouse-wheel events reach the pane renderer rather than main's.
32/// 2. Install the WM_SETFOCUS redirect subclass on the pane's HWND tree so
33///    Chromium's internal focus-steals on page load don't yank keyboard
34///    focus away from the main window.
35pub fn on_after_created_browser_pane(state: &Arc<AppState>, browser: &Browser) {
36    #[cfg(target_os = "windows")]
37    {
38        if let Some(host) = browser.host() {
39            let wh = host.window_handle();
40            if !wh.0.is_null() {
41                let hwnd = wh.0 as *mut std::ffi::c_void;
42
43                // Z-order: bring pane above main's widget.
44                unsafe {
45                    windows_sys::Win32::UI::WindowsAndMessaging::SetWindowPos(
46                        hwnd as _,
47                        std::ptr::null_mut(), // HWND_TOP
48                        0, 0, 0, 0,
49                        0x0001 | 0x0002 | 0x0010, // SWP_NOSIZE | SWP_NOMOVE | SWP_NOACTIVATE
50                    );
51                }
52                tracing::info!("[pane-zorder] raised pane to top of Z-order");
53
54                // Subclass the pane HWND + its descendants so WM_SETFOCUS
55                // from Chromium gets redirected to the parent. The state
56                // and block_id let the subclass emit `browser-pane-clicked`
57                // directly on WM_LBUTTONDOWN without relying on CEF focus
58                // callbacks (which don't fire for clicks inside an already-
59                // focused pane).
60                let block_id = resolve_pane_block_id(state, browser).unwrap_or_default();
61                unsafe {
62                    crate::browser_pane::hwnd::install_browser_pane_focus_redirect(
63                        hwnd,
64                        state.clone(),
65                        block_id,
66                    );
67                }
68            }
69        }
70    }
71    #[cfg(not(target_os = "windows"))]
72    {
73        let _ = (state, browser);
74    }
75}
76
77/// Called from `AgentMuxHandler::on_before_close` after the browser has
78/// been removed from `state.browsers` and the label has been identified
79/// as a pane label (prefix `browser-pane-*`).
80///
81/// On Linux/macOS, runs the deferred `OverlayController::destroy()` for
82/// any controller that `detach_browser_pane_view` stashed — see the long
83/// comment on `state.pending_overlay_destroy` for why destroy can't run
84/// synchronously with the close request. Drains the reducer entry next so
85/// a re-create with the same block_id gets a fresh Live state. Idempotent
86/// — if the explicit `close()` path already drained the reducer, the
87/// drain is a no-op; if no controller was stashed (Windows or already
88/// destroyed), the destroy step is a no-op.
89pub fn on_before_close_browser_pane(state: &Arc<AppState>, label: &str) {
90    // Step 1 (Linux/macOS): destroy the deferred OverlayController.
91    // Safe now because the Browser is fully torn down and Chromium has
92    // drained any queued tasks holding `WeakPtr<View>` to its BrowserView.
93    #[cfg(not(target_os = "windows"))]
94    {
95        let stashed = state.pending_overlay_destroy.lock().remove(label);
96        if let Some(controller) = stashed {
97            controller.destroy();
98            tracing::info!(
99                label = %label,
100                "[browser-pane] views: deferred OverlayController destroyed at on_before_close"
101            );
102        }
103    }
104
105    // Step 2: drain the reducer's pane entry (idempotent).
106    state.browser_panes.drain_closed_label(state, label);
107
108    // Labels are `browser-pane-<uuid>-<seq>`; strip prefix + trailing `-<seq>`
109    // to recover the block_id, then wipe any HWND context entries the
110    // WndProc subclass registered for that block.
111    #[cfg(target_os = "windows")]
112    {
113        if let Some(rest) = label.strip_prefix("browser-pane-") {
114            if let Some(dash) = rest.rfind('-') {
115                let block_id = &rest[..dash];
116                crate::browser_pane::hwnd::remove_contexts_for_block(block_id);
117            }
118        }
119    }
120}
121
122/// Called from `AgentMuxHandler::on_load_end` when `is_browser_pane` is true.
123///
124/// Chromium creates a fresh `Chrome_RenderWidgetHostHWND` on every
125/// navigation. The subclass installed at `on_after_created` is on the
126/// OLD widget HWND, which was destroyed during navigation — so without
127/// reinstalling here, keyboard focus steals by the new page bypass our
128/// redirect and end up stuck on the pane.
129///
130/// Does NOT force focus back to main. `WM_MOUSEWHEEL` is routed to the
131/// focused HWND; stealing focus away from the pane breaks scrolling.
132/// The FocusHandler cancel + WndProc redirect already keep focus off
133/// the pane during the *initial* navigation focus steal.
134pub fn on_load_end_browser_pane(state: &Arc<AppState>, browser: &Browser) {
135    tracing::info!("[pane-load-end] pane page loaded; reinstalling focus subclass");
136
137    #[cfg(target_os = "windows")]
138    {
139        if let Some(host) = browser.host() {
140            let wh = host.window_handle();
141            if !wh.0.is_null() {
142                let block_id = resolve_pane_block_id(state, browser).unwrap_or_default();
143                unsafe {
144                    crate::browser_pane::hwnd::install_browser_pane_focus_redirect(
145                        wh.0 as *mut std::ffi::c_void,
146                        state.clone(),
147                        block_id,
148                    );
149                }
150            }
151        }
152    }
153
154    // URL-only event emit at load_end so the address bar catches redirects
155    // that resolve during frame load (e.g. google.com → www.google.com).
156    // `can_go_back` / `can_go_forward` are intentionally not read here —
157    // `on_load_end` fires before the navigation controller commits the
158    // history entry, so calling `browser.can_go_back()` from this hook
159    // can return the pre-navigation state. Those flags flow through the
160    // dedicated `on_loading_state_change_browser_pane` callback below, which CEF
161    // provides with correct values as direct parameters.
162    if let Some(block_id) = resolve_pane_block_id(state, browser) {
163        let url = {
164            let mut b: cef::Browser = browser.clone();
165            b.main_frame()
166                .map(|f| cef::CefString::from(&cef::ImplFrame::url(&f)).to_string())
167                .unwrap_or_default()
168        };
169        let block_id_short: String = block_id.chars().take(7).collect();
170        tracing::info!(
171            "[browser-pane:diag][{}] emit-nav-state url={:?} url_only=true",
172            block_id_short, url,
173        );
174        crate::events::emit_event_from_state(
175            state,
176            "browser-pane-nav-state",
177            &serde_json::json!({
178                "block_id": block_id,
179                "url": url,
180                // can_* omitted on purpose — frontend treats missing
181                // fields as "no change" and keeps the last values from
182                // on_loading_state_change.
183                "url_only": true,
184            }),
185        );
186    } else {
187        tracing::warn!("[pane-load-end] couldn't resolve block_id for nav-state emit");
188    }
189
190    #[cfg(not(target_os = "windows"))]
191    {
192        let _ = browser;
193    }
194}
195
196/// Pane-specific `on_loading_state_change` body. Called from
197/// `AgentMuxHandler::on_loading_state_change` when `is_browser_pane == true`.
198///
199/// CEF invokes `on_loading_state_change` whenever the navigation controller's
200/// history state changes — navigation start, navigation commit, and after
201/// back/forward. `can_go_back` / `can_go_forward` are provided as direct
202/// parameters (not queried after the fact), so they're guaranteed to reflect
203/// the real committed state rather than the pre-commit race window.
204pub fn on_loading_state_change_browser_pane(
205    state: &Arc<AppState>,
206    browser: &Browser,
207    can_go_back: bool,
208    can_go_forward: bool,
209) {
210    if let Some(block_id) = resolve_pane_block_id(state, browser) {
211        let url = {
212            let mut b: cef::Browser = browser.clone();
213            b.main_frame()
214                .map(|f| cef::CefString::from(&cef::ImplFrame::url(&f)).to_string())
215                .unwrap_or_default()
216        };
217        let block_id_short: String = block_id.chars().take(7).collect();
218        tracing::info!(
219            "[browser-pane:diag][{}] emit-nav-state url={:?} url_only=false can_back={} can_forward={}",
220            block_id_short, url, can_go_back, can_go_forward,
221        );
222        crate::events::emit_event_from_state(
223            state,
224            "browser-pane-nav-state",
225            &serde_json::json!({
226                "block_id": block_id,
227                "url": url,
228                "can_go_back": can_go_back,
229                "can_go_forward": can_go_forward,
230            }),
231        );
232    } else {
233        tracing::warn!("[pane-loading-state] couldn't resolve block_id for nav-state emit");
234    }
235}
236
237/// Resolve the `block_id` for a pane browser. Panes are registered in
238/// `state.browsers` under labels like `browser-pane-<uuid>-<seq>`. Find the
239/// label whose browser handle matches the given one by `is_same`, then
240/// strip the prefix and the trailing `-<seq>` to recover the uuid.
241pub(crate) fn resolve_pane_block_id(state: &Arc<AppState>, browser: &Browser) -> Option<String> {
242    // Phase H.2.b — reducer-aware iteration with fallback.
243    state
244        .list_browsers()
245        .into_iter()
246        .find(|(_k, b)| {
247            let mut b_clone = b.clone();
248            let mut browser_clone: cef::Browser = browser.clone();
249            b_clone.is_same(Some(&mut browser_clone)) != 0
250        })
251        .and_then(|(label, _)| {
252            let rest = label.strip_prefix("browser-pane-")?;
253            let dash = rest.rfind('-')?;
254            Some(rest[..dash].to_string())
255        })
256}