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}