agentmux_cef/floating_pane.rs
1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Win32-native owned-window creator for floating panes. Phase 1 of
5//! issue #810 (floating-pane tear-off).
6//!
7//! This module is intentionally Windows-only and intentionally
8//! separate from `crate::ui_tasks` (which posts the standard CEF Views
9//! top-level windows). A floating pane is a *raw* `WS_POPUP` HWND with
10//! `WS_EX_TOOLWINDOW`, owner = source main window. CEF Views does not
11//! expose tool-window / owner semantics in a way that lets us achieve
12//! the no-taskbar + minimize-cascade behavior the spec requires, so we
13//! drop down to `CreateWindowExW` and then embed a CEF browser inside
14//! the resulting HWND via `WindowInfo::set_as_child` — the same
15//! mechanism the browser-pane creation path uses
16//! (`browser_pane/creation.rs`).
17//!
18//! See issue #810 / `docs/specs/SPEC_FLOATING_PANE_TEAROFF_2026_05_11.md`
19//! for the full design and phase plan.
20//!
21//! ## Scope of Phase 1
22//!
23//! - Register the IPC command (`open_floating_pane_window`).
24//! - Allocate a stable window label.
25//! - Create the owned `WS_POPUP | WS_EX_TOOLWINDOW` HWND.
26//! - Embed a CEF browser inside it via `WindowInfo::set_as_child`.
27//! - Browser loads `<frontend>?floatingPaneId=<id>&windowLabel=<lbl>`.
28//!
29//! ## Out of scope for Phase 1 (per spec §9)
30//!
31//! - Drag-to-tear-off wiring (Phase 3).
32//! - Floating-pane frontend shell that renders the full `<Block>`
33//! (Phase 2). Phase 1's stub shell renders only a placeholder so the
34//! primitive can be validated end-to-end.
35//! - Re-dock (Phase 4).
36//! - Persistence (Phase 5).
37//! - macOS / Linux ports (deferred).
38
39#![cfg(target_os = "windows")]
40
41use std::sync::Arc;
42
43use cef::*;
44
45use crate::state::AppState;
46
47wrap_task! {
48 pub struct CreateFloatingWindowTask {
49 state: Arc<AppState>,
50 pane_id: String,
51 window_label: String,
52 url: String,
53 x: i32,
54 y: i32,
55 width: i32,
56 height: i32,
57 }
58
59 impl Task {
60 fn execute(&self) {
61 // Runs on the CEF UI thread.
62 //
63 // 1. Find the source main window's HWND (we own this).
64 // 2. CreateWindowExW with WS_EX_TOOLWINDOW + WS_POPUP +
65 // owner HWND. That's what gives us no taskbar / no
66 // Alt-Tab and the minimize / restore / destroy cascade.
67 // 3. Embed a CEF browser inside via `set_as_child` —
68 // same pattern as `browser_pane/creation.rs:109`.
69
70 // TODO(phase-6, codex P2 on #811): With multiple main
71 // windows in the same process (future tab-tear-off-to-same-
72 // process scenarios, sub-windows, etc.), `find_own_top_level_window`
73 // returns the FIRST visible window of this process, which
74 // may not be the source of the tear-off. The right fix is
75 // an API change to thread the source window's label/HWND
76 // through `OpenFloatingPaneArgs`. Today (Phase 1) there's
77 // exactly one main window per process, so this is harmless.
78 // Every early-return from execute() AFTER the host's
79 // `post_create_floating_window` enqueued a
80 // `PendingWindowCreation` must dispatch
81 // `DequeuePendingWindowCreation` — `on_after_created`
82 // only fires on success. The `floating-` exclusion in
83 // `orphan_reconcile.rs` is belt-and-suspenders; this is
84 // the actual cleanup. Codex/reagent P1 round 2 on #811.
85 let dequeue = || {
86 self.state.host_dispatch(
87 crate::reducer::HostCommand::DequeuePendingWindowCreation,
88 );
89 };
90
91 let owner_hwnd_raw = unsafe { crate::commands::window::find_own_top_level_window() };
92 if owner_hwnd_raw.is_null() {
93 tracing::error!(
94 pane_id = %self.pane_id,
95 label = %self.window_label,
96 "[floating-pane] cannot find source main HWND — aborting",
97 );
98 dequeue();
99 return;
100 }
101
102 let outer_hwnd = match create_owned_popup(
103 owner_hwnd_raw,
104 &self.window_label,
105 self.x,
106 self.y,
107 self.width,
108 self.height,
109 ) {
110 Ok(h) => h,
111 Err(e) => {
112 tracing::error!(
113 pane_id = %self.pane_id,
114 label = %self.window_label,
115 error = %e,
116 "[floating-pane] CreateWindowExW failed",
117 );
118 dequeue();
119 return;
120 }
121 };
122
123 tracing::info!(
124 pane_id = %self.pane_id,
125 label = %self.window_label,
126 hwnd = ?outer_hwnd,
127 "[floating-pane] outer HWND created",
128 );
129
130 // CEF embed — the browser is a WS_CHILD of the outer HWND.
131 let rect = Rect {
132 x: 0,
133 y: 0,
134 width: self.width,
135 height: self.height,
136 };
137
138 let handler = crate::client::AgentMuxHandler::new_with_browser_pane(
139 self.state.clone(),
140 0,
141 true,
142 );
143 let mut client = Some(crate::client::AgentMuxClient::new(handler, true));
144
145 let url_cef = CefString::from(self.url.as_str());
146 let settings = BrowserSettings::default();
147
148 let parent_hwnd = sys::HWND(outer_hwnd as *mut _);
149 let mut window_info = WindowInfo::default().set_as_child(parent_hwnd, &rect);
150 window_info.runtime_style = RuntimeStyle::ALLOY;
151
152 let result = browser_host_create_browser(
153 Some(&window_info),
154 client.as_mut(),
155 Some(&url_cef),
156 Some(&settings),
157 None, // extra_info
158 None, // request_context
159 );
160
161 if result == 0 {
162 tracing::error!(
163 pane_id = %self.pane_id,
164 label = %self.window_label,
165 "[floating-pane] browser_host_create_browser returned 0",
166 );
167 // Cleanup-on-failure (codex P1 on #811). The outer
168 // HWND was already created + shown via
169 // `SW_SHOWNOACTIVATE` inside `create_owned_popup`; if
170 // we return here without `DestroyWindow` it sits on
171 // screen as a phantom empty tool window. Also dequeue
172 // the pending-creation entry that
173 // `post_create_floating_window` enqueued — without
174 // this, `on_after_created` (which fires only on
175 // success) never dequeues, and the leaked entry
176 // permanently blocks orphan reconciliation despite
177 // the `floating-` exclusion in orphan_reconcile.rs
178 // (belt-and-suspenders).
179 unsafe {
180 use windows_sys::Win32::UI::WindowsAndMessaging::DestroyWindow;
181 DestroyWindow(outer_hwnd as *mut std::ffi::c_void);
182 }
183 dequeue();
184 return;
185 }
186
187 tracing::info!(
188 pane_id = %self.pane_id,
189 label = %self.window_label,
190 "[floating-pane] CEF browser embedded in floating HWND",
191 );
192 }
193 }
194}
195
196/// Posts the create-floating-window task to the CEF UI thread. Returns
197/// immediately. Mirrors the shape of `ui_tasks::post_create_window` but
198/// goes through this module so the path is grep-able.
199pub fn post_create_floating_window(
200 state: &Arc<AppState>,
201 args: &crate::commands::floating_pane::OpenFloatingPaneArgs,
202 window_label: &str,
203) {
204 // Compose the URL the floating window's CEF browser will load. The
205 // frontend's cef-init detects `floatingPaneId` and routes to the
206 // floating-pane shell instead of the main workspace.
207 let ipc_port = *state.ipc_port.lock();
208 let ipc_token = &state.ipc_token;
209 let base_url = crate::commands::window::resolve_frontend_base_url(ipc_port);
210 let separator = if base_url.contains('?') { "&" } else { "?" };
211 // pane_id is a UUID-ish identifier in current callers — no
212 // percent-encoding needed today. Use a minimal escape that handles
213 // a few special chars in case future callers pass arbitrary names.
214 let url = format!(
215 "{}{}ipc_port={}&ipc_token={}&windowLabel={}&floatingPaneId={}",
216 base_url,
217 separator,
218 ipc_port,
219 ipc_token,
220 window_label,
221 escape_query_value(&args.pane_id),
222 );
223
224 // Phase B.5 pre-create handoff — same shape as the main
225 // open-window path so the existing window_meta plumbing (label →
226 // kind → parent) sees the floater as a recognized creation.
227 // Phase 6 will introduce a dedicated `WindowKind::FloatingPane`
228 // to skip the taskbar / report-open logic in `on_after_created`;
229 // Phase 1 reuses `Subwindow` (also hidden from taskbar today) so
230 // the existing handler path holds.
231 state.host_dispatch(
232 crate::reducer::HostCommand::EnqueuePendingWindowCreation {
233 entry: crate::state::PendingWindowCreation {
234 label: window_label.to_string(),
235 kind: crate::state::WindowKind::Subwindow,
236 parent_instance_id: None,
237 },
238 },
239 );
240
241 let mut task = CreateFloatingWindowTask::new(
242 state.clone(),
243 args.pane_id.clone(),
244 window_label.to_string(),
245 url,
246 args.x,
247 args.y,
248 args.width,
249 args.height,
250 );
251 post_task(ThreadId::UI, Some(&mut task));
252}
253
254/// Minimal query-string escaping for the pane id. Encodes the small
255/// set of characters that would break query-string parsing. Avoids
256/// pulling in a `url`/`urlencoding` dependency for a single-call site.
257fn escape_query_value(s: &str) -> String {
258 let mut out = String::with_capacity(s.len());
259 for ch in s.chars() {
260 match ch {
261 'A'..='Z' | 'a'..='z' | '0'..='9' | '-' | '_' | '.' | '~' => out.push(ch),
262 _ => {
263 // Encode as %XX for each UTF-8 byte.
264 let mut buf = [0u8; 4];
265 for byte in ch.encode_utf8(&mut buf).bytes() {
266 out.push_str(&format!("%{byte:02X}"));
267 }
268 }
269 }
270 }
271 out
272}
273
274/// CreateWindowExW wrapper that produces the owned `WS_POPUP +
275/// WS_EX_TOOLWINDOW` HWND used as the floating-pane outer shell.
276///
277/// The class is registered once per process; subsequent calls reuse
278/// the registered atom.
279fn create_owned_popup(
280 owner_hwnd_raw: *mut std::ffi::c_void,
281 window_label: &str,
282 x: i32,
283 y: i32,
284 width: i32,
285 height: i32,
286) -> Result<*mut std::ffi::c_void, String> {
287 use std::ffi::OsStr;
288 use std::os::windows::ffi::OsStrExt;
289 use windows_sys::Win32::Foundation::HWND;
290 use windows_sys::Win32::UI::WindowsAndMessaging::{
291 CreateWindowExW, DefWindowProcW, RegisterClassExW, ShowWindow, CS_HREDRAW, CS_VREDRAW,
292 SW_SHOWNOACTIVATE, WNDCLASSEXW, WS_EX_TOOLWINDOW, WS_OVERLAPPEDWINDOW, WS_POPUP,
293 };
294
295 // ---- Register the class once per process ----
296 static CLASS_REGISTERED: std::sync::Once = std::sync::Once::new();
297 static CLASS_NAME: &str = "AgentMuxFloatingPane";
298
299 let mut class_name_utf16: Vec<u16> = OsStr::new(CLASS_NAME).encode_wide().collect();
300 class_name_utf16.push(0);
301
302 // TODO(phase-6, codex P1 on #811 — explicitly deferred): The
303 // documented CEF embedding pattern is for the host's wndproc to
304 // intercept WM_CLOSE and route through `CloseBrowser(false)` so
305 // DoClose fires before destroy. Phase 1 uses DefWindowProcW —
306 // the OS X-button cascade still works end-to-end:
307 //
308 // 1. User clicks X → DefWindowProcW(WM_CLOSE) → DestroyWindow.
309 // 2. Outer HWND's WM_DESTROY cascades into the CEF child HWND
310 // (WS_CHILD of outer).
311 // 3. CEF's wndproc on the child runs its destroy handler →
312 // OnBeforeClose fires on AgentMuxHandler → reducer
313 // UnregisterBrowser cleans `state.browsers` + `window_meta`.
314 //
315 // What's *skipped*: the DoClose hook's chance to cancel close
316 // (e.g. for a "Are you sure?" prompt). Floating panes have no
317 // such prompt, so this is harmless for Phase 1. The full custom
318 // wndproc is Phase 6 polish per spec §9. Files this needs to
319 // touch: replace `lpfnWndProc: Some(DefWindowProcW)` with a
320 // custom proc; add a `OnceLock<Arc<AppState>>` accessor (mirror
321 // `wrr::win_event::app_state`); in WM_CLOSE iterate
322 // `state.list_browsers()` and call `host.close_browser(false)`
323 // on any whose `window_handle()`'s GA_ROOT ancestor matches our
324 // outer HWND.
325 CLASS_REGISTERED.call_once(|| unsafe {
326 let h_instance =
327 windows_sys::Win32::System::LibraryLoader::GetModuleHandleW(std::ptr::null());
328 let wnd_class = WNDCLASSEXW {
329 cbSize: std::mem::size_of::<WNDCLASSEXW>() as u32,
330 style: CS_HREDRAW | CS_VREDRAW,
331 lpfnWndProc: Some(DefWindowProcW),
332 cbClsExtra: 0,
333 cbWndExtra: 0,
334 hInstance: h_instance,
335 hIcon: std::ptr::null_mut(),
336 hCursor: std::ptr::null_mut(),
337 hbrBackground: std::ptr::null_mut(),
338 lpszMenuName: std::ptr::null(),
339 lpszClassName: class_name_utf16.as_ptr(),
340 hIconSm: std::ptr::null_mut(),
341 };
342 let atom = RegisterClassExW(&wnd_class);
343 if atom == 0 {
344 tracing::error!(
345 "[floating-pane] RegisterClassExW failed for {CLASS_NAME}; CreateWindowExW will fail",
346 );
347 }
348 });
349
350 let mut title_utf16: Vec<u16> = OsStr::new(&format!("AgentMux — {window_label}"))
351 .encode_wide()
352 .collect();
353 title_utf16.push(0);
354
355 let hwnd = unsafe {
356 CreateWindowExW(
357 WS_EX_TOOLWINDOW,
358 class_name_utf16.as_ptr(),
359 title_utf16.as_ptr(),
360 // WS_POPUP for free positioning (NOT WS_CHILD — children
361 // are clipped to parent's client area). WS_OVERLAPPEDWINDOW
362 // for the resizable border + sysmenu. Phase 6 will
363 // customize the title bar via WM_NCHITTEST; Phase 1 ships
364 // with the default chrome so drag works out of the box.
365 WS_POPUP | WS_OVERLAPPEDWINDOW,
366 x,
367 y,
368 width,
369 height,
370 owner_hwnd_raw as HWND,
371 std::ptr::null_mut(),
372 windows_sys::Win32::System::LibraryLoader::GetModuleHandleW(std::ptr::null()),
373 std::ptr::null(),
374 )
375 };
376
377 if hwnd.is_null() {
378 let err = unsafe { windows_sys::Win32::Foundation::GetLastError() };
379 return Err(format!("CreateWindowExW returned NULL (GetLastError={err})"));
380 }
381
382 unsafe {
383 ShowWindow(hwnd, SW_SHOWNOACTIVATE);
384 }
385
386 Ok(hwnd as *mut std::ffi::c_void)
387}
388
389#[cfg(test)]
390mod tests {
391 use super::escape_query_value;
392
393 #[test]
394 fn escape_passes_through_safe_chars() {
395 assert_eq!(escape_query_value("abc-XYZ_123.~"), "abc-XYZ_123.~");
396 }
397
398 #[test]
399 fn escape_encodes_special_chars() {
400 assert_eq!(escape_query_value("a b"), "a%20b");
401 assert_eq!(escape_query_value("a&b=c"), "a%26b%3Dc");
402 assert_eq!(escape_query_value("a/b"), "a%2Fb");
403 }
404
405 #[test]
406 fn escape_encodes_multibyte_utf8() {
407 // U+00E9 'é' is 0xC3 0xA9 in UTF-8.
408 assert_eq!(escape_query_value("é"), "%C3%A9");
409 }
410}