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}