agentmux_cef\commands/
window_pool.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3//
4// Tear-off Phase 6 — pre-warmed window pool.
5//
6// Spec §0 makes a 0 ms first-paint flash mandatory. The cold path
7// (open_window_at_position → wait for HWND register → SC_MOVE)
8// has an inherent 150-300 ms gap while CEF spawns the renderer
9// process and paints first frame. Pre-spawning hidden windows
10// eliminates that gap: on tear-off we POP an already-painted
11// window from the pool, reposition it under the cursor, show it,
12// and emit `pool:promote` so the renderer bootstraps the
13// workspace in-place (no reload, no renderer restart).
14//
15// Pool windows live with URL `?pool=1`; the frontend init flow
16// detects this and defers `initHostNewWindow()` until the
17// `pool:promote` event arrives with a workspace ID.
18//
19// Sizing: N=2. One window is "next destination," the other is
20// "buffer while respawn completes." With N=1 a back-to-back
21// tear-off would cold-path. With N>2 the RAM cost outweighs the
22// rare-second-tearoff benefit.
23//
24// Lifecycle:
25// - App startup → spawn N pool windows after primary first-paint.
26// - On tear-off → pop, reposition, show, emit promote, enqueue
27//   refill. Refills are serialised (single in-flight) so a burst
28//   of tear-offs can't spawn unbounded windows.
29// - App shutdown → pool windows close cleanly with the rest of
30//   the process.
31
32use std::sync::Arc;
33use std::sync::Mutex;
34use std::collections::HashMap;
35
36use crate::state::{AppState, WindowKind, WindowMeta};
37
38/// HWND cache for pool windows. Populated at `on_after_created`
39/// (register_pool_window) and consulted at `promote_pool_window` as
40/// the source of truth — `BrowserHost::window_handle()` returns null
41/// once the page loads even though the underlying Win32 window is
42/// alive (verified by `IsWindow` — see
43/// `SPEC_POOL_WINDOW_HWND_NULL_2026_05_06.md` §4.1 diagnostic run).
44///
45/// Entries are removed on pool-window destruction
46/// (`on_pool_window_destroyed`) so the map can't leak across the
47/// process lifetime. The HWND is stored as `usize` so this state is
48/// `Send + Sync` without `unsafe`; callers cast back to `HWND` /
49/// `*mut c_void` at use site.
50#[cfg(target_os = "windows")]
51static POOL_HWND_CACHE: std::sync::OnceLock<Mutex<HashMap<String, usize>>> =
52    std::sync::OnceLock::new();
53
54#[cfg(target_os = "windows")]
55fn pool_hwnd_cache() -> &'static Mutex<HashMap<String, usize>> {
56    POOL_HWND_CACHE.get_or_init(|| Mutex::new(HashMap::new()))
57}
58
59/// Target pool size. See module-level comment for rationale.
60pub const POOL_TARGET_SIZE: usize = 2;
61
62/// Pool windows are spawned at this off-screen position so they
63/// don't appear on the user's desktop while pre-painting. On
64/// promote they're moved to the cursor and shown.
65const POOL_OFFSCREEN_X: i32 = -32000;
66const POOL_OFFSCREEN_Y: i32 = -32000;
67const POOL_WIDTH: i32 = 1200;
68const POOL_HEIGHT: i32 = 800;
69/// Pixels above the cursor where the title bar sits — matches
70/// open_window_at_position so the cursor lands near the top-center
71/// of the title bar after promotion.
72const TITLE_BAR_OFFSET_PX: i32 = 16;
73
74// Tab-anchor placement: PR #730 hardcoded FIRST_TAB_INSET_X /
75// TAB_STRIP_TOP_OFFSET_PX as best-effort window-chrome offsets.
76// Smoke on v0.33.704 showed they were inaccurate (new window's
77// first tab not landing where the dragged tab was). The frontend
78// now measures the source window's chrome dynamically and computes
79// the new window's outer top-left position itself; backend just
80// uses the supplied anchor verbatim. The constants are gone.
81
82/// Spawn a single pool window. Called at startup (N times) and
83/// after each promote (1 refill). Idempotent against the
84/// in-flight semaphore — concurrent calls collapse to one spawn
85/// in flight at a time.
86pub fn spawn_pool_window(state: &Arc<AppState>) {
87    // Phase B.9.3 — if the host has decided to quit (last user-
88    // visible window closed, draining pool), skip refill. Without
89    // this guard, every pool close triggers a refill, keeping
90    // state.browsers non-empty forever and quit_message_loop's
91    // QuitWhenIdle never reaches idle.
92    // PR #5 H.5 — early-out before dispatching to the reducer. The
93    // reducer's PoolWindowSpawnStart arm ALSO checks `quit_state !=
94    // Running` and would no-op, but the early-out keeps the warn-log
95    // shape identical to pre-PR for diagnostic continuity.
96    if state.is_quitting() {
97        tracing::warn!(
98            target: "wrr",
99            "[wrr] spawn_pool_window skipped — quit_state != Running (drain mode)"
100        );
101        return;
102    }
103
104    // PR #6 H.7 — refuse pool refill while any pane is mid-close. Pool
105    // windows are CEF top-levels just like user-visible ones; the v146
106    // deadlock fires regardless of whether the new window is on-screen.
107    // See `commands/window.rs::open_window_with_kind` for rationale.
108    if state.any_browser_pane_closing() {
109        tracing::warn!(
110            target: "wfr:gate",
111            "[wfr:gate] spawn_pool_window deferred — pane is mid-close (H.7 invariant)"
112        );
113        return;
114    }
115
116    // PR #6 codex P1 — capacity check. The semaphore (PoolWindowSpawnStart)
117    // only single-flights; it does not enforce the target size. Without
118    // this guard, the new H.7 always-on-pane-close kick (in
119    // `BrowserPaneManager::close` / `drain_closed_label`) would add a
120    // pool window on every pane close once no spawn is in flight, growing
121    // `pool.queue` indefinitely past `POOL_TARGET_SIZE`. The legacy
122    // callers (`mark_pool_window_renderer_ready`,
123    // `on_pool_window_destroyed`) already gate on the same check; this
124    // moves it inside spawn_pool_window so every entry point is covered.
125    if state.pool_queue_size() >= POOL_TARGET_SIZE {
126        tracing::debug!(
127            target: "dnd:tearoff:pool",
128            current = %state.pool_queue_size(),
129            target = %POOL_TARGET_SIZE,
130            "[pool] spawn skipped — pool already at target size"
131        );
132        return;
133    }
134
135    let window_id = uuid::Uuid::new_v4();
136    // Use the `window-pool-` prefix so existing `is_instance_label`
137    // checks (tear_off_hook.rs, app-init.ts) pass naturally — they
138    // accept anything starting with `window-`. After promotion the
139    // label stays the same; the reducer's `pool.unpromoted` is the
140    // authoritative pool-vs-promoted distinction (cleared on
141    // promote — `pool.queue` is populated only after the
142    // renderer-ready handshake, so it's not reliable as the
143    // distinguisher during the ~100ms spawn → ready gap).
144    let label = format!("window-pool-{}", window_id.simple());
145
146    // PR #5 H.4 — atomic single-flight + label-into-unpromoted via the
147    // reducer. `pool_spawn_proceeding=false` means another spawn was
148    // already in flight (or quit_state != Running) and we should skip
149    // — the in-flight spawn will catch up to TARGET_SIZE.
150    let dispatch = state.host_dispatch(
151        crate::reducer::HostCommand::PoolWindowSpawnStart { label: label.clone() },
152    );
153    if !dispatch.pool_spawn_proceeding {
154        tracing::debug!(
155            target: "dnd:tearoff:pool",
156            "[pool] spawn skipped — respawn already in flight or pool draining"
157        );
158        return;
159    }
160
161    // Phase B.4 follow-up — mirror the pool inventory in the launcher
162    // and check pool drift. We use the pool-only variant
163    // (`report_host_pool_count`) rather than the full
164    // `report_host_counts` because `spawn_pool_window` is invoked
165    // from the refill chain inside `on_pool_window_destroyed`, which
166    // runs during `on_before_close` BEFORE the matching
167    // `ReportWindowClosed` is sent for the closing window. A
168    // full-counts snapshot at this moment would see browsers
169    // shrunk (closing window already removed) but the launcher
170    // mirror still holding it (close not yet reported), producing
171    // transient false windows-drift on every normal
172    // promoted-window close that triggers a refill. Pool count IS
173    // stable at this moment (the new label was just added), so
174    // checking pool alone preserves the "check every transition"
175    // guarantee for the dimension that actually changed. (codex
176    // P2 PR #578 rounds 2 + 3.)
177    crate::launcher_ipc::report_pool_window_added(label.clone());
178    {
179        // Pool inventory (unpromoted ∪ queue), not unpromoted-only:
180        // the launcher's `state.pool` mirror is built from
181        // ReportPoolWindowAdded/Removed/Promoted events; the host's
182        // unpromoted→queue transition emits NO event, so the
183        // launcher retains queued labels in its pool set. Reporting
184        // unpromoted.len() under-counts and triggers spurious pool
185        // drift while a warm slot is queued. Atomic snapshot —
186        // single host_state lock.
187        let pool_count = {
188            let st = state.host_state.lock();
189            (st.pool.unpromoted.len() + st.pool.queue.len()) as u32
190        };
191        crate::launcher_ipc::report_host_pool_count(pool_count);
192    }
193
194    let ipc_port = *state.ipc_port.lock();
195    let ipc_token = &state.ipc_token;
196    let base_url = super::window::resolve_frontend_base_url(ipc_port);
197    let separator = if base_url.contains('?') { "&" } else { "?" };
198    // The `pool=1` flag tells the frontend to skip its standard
199    // workspace init and wait for a `pool:promote` event.
200    let url = format!(
201        "{}{}ipc_port={}&ipc_token={}&windowLabel={}&pool=1",
202        base_url, separator, ipc_port, ipc_token, label
203    );
204
205    // Phase B.5 (window_meta step d) — combined pre-create handoff.
206    // Pool windows graduate to tear-off destinations, which are
207    // FullInstance from the user's perspective.
208    //
209    // Phase F.1 — routed through the host reducer.
210    state.host_dispatch(
211        crate::reducer::HostCommand::EnqueuePendingWindowCreation {
212            entry: crate::state::PendingWindowCreation {
213                label: label.clone(),
214                kind: WindowKind::FullInstance,
215                parent_instance_id: None,
216            },
217        },
218    );
219
220    tracing::info!(
221        target: "dnd:tearoff:pool",
222        label = %label,
223        "[pool] spawning pool window"
224    );
225
226    // Spawn at off-screen coords. The window is technically
227    // visible (frameless) but well outside any monitor bounds, so
228    // the user never sees it; CEF still paints it because Windows
229    // considers it a normal HWND.
230    crate::ui_tasks::post_create_window(
231        state,
232        &url,
233        &label,
234        POOL_OFFSCREEN_X,
235        POOL_OFFSCREEN_Y,
236        POOL_WIDTH,
237        POOL_HEIGHT,
238        true,
239    );
240
241    // The window registers in `state.browsers` via on_after_created
242    // asynchronously. We don't add to `window_pool` here — the
243    // register completion handler does that (see register_pool_window
244    // below) so a window only enters the pool after it's actually
245    // alive.
246}
247
248/// Called from on_after_created when a pool window's browser is
249/// registered. Logs + applies WS_EX_TOOLWINDOW so the off-screen
250/// pool window doesn't show up in the taskbar / Alt+Tab. The
251/// promote path (`promote_pool_window`) clears it again so the
252/// torn-off window IS taskbar-visible.
253///
254/// Queue insertion still waits for `mark_pool_window_renderer_ready`
255/// (frontend-side handshake) — without that gate emit_event_to_window
256/// could race the renderer's listener install and drop the promote
257/// signal.
258pub fn register_pool_window(state: &Arc<AppState>, label: &str) {
259    if !label.starts_with("window-pool-") {
260        return;
261    }
262    #[cfg(target_os = "windows")]
263    {
264        // Look up the HWND under a short browsers lock; release
265        // before any Win32 FFI. The HWND should exist by the time
266        // on_after_created fires; if it doesn't, log and bail —
267        // the pool entry is harmless until promote re-checks.
268        use cef::{ImplBrowser, ImplBrowserHost};
269        // Phase H.2.b — reducer-aware lookup with fallback.
270        let raw_hwnd: Option<*mut std::ffi::c_void> = state
271            .get_browser(label)
272            .and_then(|browser| {
273                browser.host().and_then(|host| {
274                    let h = host.window_handle();
275                    if h.0.is_null() {
276                        None
277                    } else {
278                        Some(h.0 as *mut std::ffi::c_void)
279                    }
280                })
281            });
282        if let Some(hwnd) = raw_hwnd {
283            // Cache the HWND for promote-time use. CEF's
284            // `BrowserHost::window_handle()` returns null after the
285            // page loads (verified diagnostic run 2026-05-06), but
286            // the underlying Win32 window is still alive. The cache
287            // is the only reliable source for the HWND at promote.
288            pool_hwnd_cache()
289                .lock()
290                .unwrap()
291                .insert(label.to_string(), hwnd as usize);
292            set_taskbar_hidden(hwnd, true);
293        } else {
294            tracing::warn!(
295                target: "dnd:tearoff:pool",
296                label = %label,
297                "[pool] HWND null at register time — taskbar hide skipped, cache not populated"
298            );
299        }
300    }
301    tracing::debug!(
302        target: "dnd:tearoff:pool",
303        label = %label,
304        "[pool] browser registered, awaiting renderer-ready signal"
305    );
306}
307
308/// Toggle WS_EX_TOOLWINDOW on a window's extended style so it
309/// appears (or doesn't) in the taskbar / Alt+Tab. We use this so
310/// pre-warmed pool windows stay invisible to the user, then
311/// re-enter the taskbar when promoted to a real torn-off window.
312///
313/// Per Win32 docs, changing the ex-style after creation only
314/// updates the taskbar reliably if the window is hidden + reshown.
315/// We do that hide/show cycle here with SWP_FRAMECHANGED so
316/// the change takes effect even if SW_HIDE was already implicit.
317#[cfg(target_os = "windows")]
318fn set_taskbar_hidden(hwnd: *mut std::ffi::c_void, hidden: bool) {
319    unsafe {
320        use windows_sys::Win32::UI::WindowsAndMessaging::{
321            GetWindowLongPtrW, SetWindowLongPtrW, SetWindowPos, ShowWindow,
322            GWL_EXSTYLE, SWP_FRAMECHANGED, SWP_NOACTIVATE, SWP_NOMOVE, SWP_NOSIZE,
323            SWP_NOZORDER, SW_HIDE, SW_SHOWNA, WS_EX_APPWINDOW, WS_EX_TOOLWINDOW,
324        };
325        let mut ex = GetWindowLongPtrW(hwnd, GWL_EXSTYLE);
326        if hidden {
327            ex |= WS_EX_TOOLWINDOW as isize;
328            ex &= !(WS_EX_APPWINDOW as isize);
329        } else {
330            ex &= !(WS_EX_TOOLWINDOW as isize);
331            ex |= WS_EX_APPWINDOW as isize;
332        }
333        // Hide → write style → show forces the shell to re-evaluate
334        // the taskbar entry. Without the hide/show pair Windows often
335        // keeps the original taskbar state on style change.
336        let _ = ShowWindow(hwnd, SW_HIDE);
337        SetWindowLongPtrW(hwnd, GWL_EXSTYLE, ex);
338        let _ = SetWindowPos(
339            hwnd,
340            std::ptr::null_mut(),
341            0, 0, 0, 0,
342            SWP_NOMOVE | SWP_NOSIZE | SWP_NOZORDER | SWP_NOACTIVATE | SWP_FRAMECHANGED,
343        );
344        // Don't re-show pool windows — they should stay hidden until
345        // promote. Re-show only when transitioning OUT of toolwindow.
346        if !hidden {
347            let _ = ShowWindow(hwnd, SW_SHOWNA);
348        }
349    }
350}
351
352/// Called when a pool window is destroyed before it ever became
353/// renderer-ready (renderer crash mid-init, user closed at OS level,
354/// etc.). Without this clearing path the respawn semaphore would
355/// stay locked forever and the pool would never refill.
356pub fn on_pool_window_destroyed(state: &Arc<AppState>, label: &str) {
357    if !label.starts_with("window-pool-") {
358        return;
359    }
360    // Drop the cached HWND so the map can't grow unbounded across the
361    // process lifetime. Idempotent — fine if the entry isn't present
362    // (e.g. a window destroyed before register_pool_window populated
363    // the cache).
364    #[cfg(target_os = "windows")]
365    {
366        pool_hwnd_cache().lock().unwrap().remove(label);
367    }
368    // PR #5 H.4 — atomic remove-from-{unpromoted,queue} + clear
369    // respawn semaphore via reducer. The dispatch returns:
370    //   - `pool_destroyed_was_unpromoted`: distinguishes pre-promote
371    //     death (this fn owns the launcher mirror update) from
372    //     post-promote close (`on_before_close`'s window-close path
373    //     owns it). Without this gate, post-promote closes would
374    //     fire `ReportHostCounts` here (browsers already shrunk,
375    //     mirror hasn't seen the matching `ReportWindowClosed` yet),
376    //     causing a guaranteed transient windows-drift alert on
377    //     every normal promoted-window close. (codex P2 PR #578 round-1.)
378    //   - `pool_size_after`: queue length after removal — caller
379    //     decides refill against POOL_TARGET_SIZE.
380    let dispatch = state.host_dispatch(
381        crate::reducer::HostCommand::PoolWindowDestroyedBeforePromote {
382            label: label.to_string(),
383        },
384    );
385
386    if dispatch.pool_destroyed_was_unpromoted {
387        crate::launcher_ipc::report_pool_window_removed(label.to_string());
388        crate::launcher_ipc::compute_and_report_host_counts(state);
389    }
390
391    let needs_refill = dispatch
392        .pool_size_after
393        .map(|n| n < POOL_TARGET_SIZE)
394        .unwrap_or(false);
395    tracing::warn!(
396        target: "dnd:tearoff:pool",
397        label = %label,
398        "[pool] pool window destroyed before promote — releasing semaphore + refilling"
399    );
400    if needs_refill {
401        spawn_pool_window(state);
402    }
403}
404
405/// Called from the `pool_window_ready` IPC handler — fired by the
406/// frontend's awaitPoolPromote AFTER its `pool:promote` listener
407/// is installed. NOW it's safe to enqueue this window for
408/// promotion.
409pub fn mark_pool_window_renderer_ready(state: &Arc<AppState>, label: &str) {
410    if !label.starts_with("window-pool-") {
411        return;
412    }
413
414    // PR #5 H.4 — atomic move-from-unpromoted-to-queue + clear respawn
415    // semaphore via reducer. Idempotent against duplicate frontend
416    // signals (re-mount, hot reload). `pool_size_after` is the queue
417    // length after the move; caller refills if below target.
418    let dispatch = state.host_dispatch(
419        crate::reducer::HostCommand::PoolWindowReady { label: label.to_string() },
420    );
421    let pool_size = dispatch.pool_size_after.unwrap_or(0);
422
423    tracing::info!(
424        target: "dnd:tearoff:pool",
425        label = %label,
426        pool_size = %pool_size,
427        "[pool] pool window renderer ready, enqueued"
428    );
429
430    if pool_size < POOL_TARGET_SIZE {
431        spawn_pool_window(state);
432    }
433}
434
435/// Initialize the pool after primary-window first paint. Spawns
436/// `POOL_TARGET_SIZE` windows. Called once per app run from
437/// `on_after_created` for the "main" label.
438///
439/// Windows-only: promote_pool_window is a no-op on non-Windows
440/// platforms (Phase 7 will add equivalents). Spawning hidden pool
441/// windows that can never be consumed would just waste renderer
442/// processes, so we skip the whole init off-Win32.
443pub fn init_pool(state: &Arc<AppState>) {
444    #[cfg(not(target_os = "windows"))]
445    {
446        let _ = state;
447        return;
448    }
449    #[cfg(target_os = "windows")]
450    {
451        // PR #5 H.4 — read pool size via reducer-aware helper.
452        let current = state.pool_queue_size();
453        if current >= POOL_TARGET_SIZE {
454            return;
455        }
456        // First spawn — the rest are kicked off chain-style by
457        // register_pool_window when each new pool window registers.
458        // This sequencing keeps spawns serialised (one CEF window at
459        // a time) and avoids spawn pressure spikes at startup.
460        spawn_pool_window(state);
461    }
462}
463
464/// Promote a pool window for tear-off. Pops a label, sends a
465/// move-and-show task to the CEF UI thread, and emits
466/// `pool:promote` to the renderer with the workspace ID. Returns
467/// the promoted window's label so the caller can chain SC_MOVE
468/// against it. Returns None if the pool is empty (caller should
469/// fall back to the cold path).
470///
471/// Called from the IPC handler `tear_off_pool_promote`.
472#[cfg(target_os = "windows")]
473pub fn promote_pool_window(
474    state: &Arc<AppState>,
475    workspace_id: &str,
476    screen_x: i32,
477    screen_y: i32,
478    width: Option<i32>,
479    height: Option<i32>,
480    tab_anchor_x: Option<i32>,
481    tab_anchor_y: Option<i32>,
482) -> Option<String> {
483    // PR #5 H.4 — atomic pop+remove via reducer. The dispatch pops
484    // the front of the pool queue, removes the label from
485    // unpromoted, and clears `is_pool` on the corresponding
486    // BrowserHandle, all under one host_state lock. Returns None if
487    // the queue is empty (cold-path fallback).
488    let dispatch = state.host_dispatch(
489        crate::reducer::HostCommand::PopAndPromoteFrontPoolWindow,
490    );
491    let label = dispatch.promoted_pool_label?;
492
493    // Phase B.4 follow-up — pool inventory shrinks unconditionally on
494    // pop. The user-visible WindowOpened report is deferred until
495    // after HWND validation succeeds (codex P1 PR #577 round-1):
496    // emitting it before validation would record a `WindowOpened`
497    // for a label that may never become a real visible window in
498    // the failure path (HWND lookup returns None, function early-
499    // returns after refill), permanently desyncing the mirror.
500    crate::launcher_ipc::report_pool_window_removed(label.clone());
501
502    tracing::info!(
503        target: "dnd:tearoff:pool",
504        label = %label,
505        workspace_id = %workspace_id,
506        screen_x = %screen_x,
507        screen_y = %screen_y,
508        "[pool] promoting pool window"
509    );
510
511    // Resolve the HWND under a SHORT lock — drop the browsers mutex
512    // before any Win32 call so we don't hold a global state lock
513    // across FFI into the OS UI subsystem.
514    //
515    // Each None-returning step is a state-inconsistency bug, not an
516    // expected failure — log per-step at ERROR so an operator can
517    // tell which invariant broke.
518    use cef::{ImplBrowser, ImplBrowserHost};
519    use windows_sys::Win32::Foundation::HWND;
520    use windows_sys::Win32::UI::WindowsAndMessaging::IsWindow;
521    // Resolve the HWND. CEF's `BrowserHost::window_handle()` returns
522    // null after the page loads on Views-based browsers, even though
523    // the underlying Win32 window is alive (verified 2026-05-06,
524    // SPEC_POOL_WINDOW_HWND_NULL_2026_05_06.md). Use the cache we
525    // populated at `register_pool_window`; the CEF path is kept as a
526    // first-try in case some future CEF version starts returning the
527    // HWND consistently again.
528    let raw_hwnd: Option<*mut std::ffi::c_void> = match state.get_browser(&label) {
529        None => {
530            tracing::error!(
531                target: "dnd:tearoff:pool",
532                label = %label,
533                "[pool] promoted label not in browsers map (state inconsistency)"
534            );
535            None
536        }
537        Some(browser) => match browser.host() {
538            None => {
539                tracing::error!(
540                    target: "dnd:tearoff:pool",
541                    label = %label,
542                    "[pool] browser has no host (state inconsistency)"
543                );
544                None
545            }
546            Some(host) => {
547                let cef_hwnd = host.window_handle().0;
548                if !cef_hwnd.is_null() {
549                    Some(cef_hwnd as *mut std::ffi::c_void)
550                } else {
551                    // CEF lost the reference — fall back to cache.
552                    let cached = pool_hwnd_cache().lock().unwrap().get(&label).copied();
553                    match cached {
554                        None => {
555                            tracing::error!(
556                                target: "dnd:tearoff:pool",
557                                label = %label,
558                                "[pool] CEF HWND null AND no cache entry (state inconsistency)"
559                            );
560                            None
561                        }
562                        Some(h) => {
563                            // Verify the cached HWND is still a live
564                            // OS window. If the OS has reclaimed it,
565                            // the slot is genuinely dead; refuse the
566                            // promote and fall back to cold-path.
567                            let alive = unsafe { IsWindow(h as HWND) } != 0;
568                            if alive {
569                                tracing::debug!(
570                                    target: "dnd:tearoff:pool",
571                                    label = %label,
572                                    hwnd = format!("0x{:x}", h),
573                                    "[pool] using cached HWND (CEF returned null)"
574                                );
575                                Some(h as *mut std::ffi::c_void)
576                            } else {
577                                tracing::error!(
578                                    target: "dnd:tearoff:pool",
579                                    label = %label,
580                                    hwnd = format!("0x{:x}", h),
581                                    "[pool] cached HWND no longer a live window"
582                                );
583                                None
584                            }
585                        }
586                    }
587                }
588            }
589        },
590    };
591
592    // Pool-slot leak guard: if HWND lookup fails after we've already
593    // popped the label, capacity permanently shrinks unless we refill.
594    //
595    // Orphan cleanup (B.5c smoke test caught this): the popped label is
596    // still in `state.browsers` but is no longer in `unpromoted_pool_labels`
597    // (we removed it at the top of this fn) and never became a real
598    // user-visible window (`report_window_opened` is gated on the
599    // post-validation success path). Without explicit cleanup the
600    // host's `compute_and_report_host_counts` filter
601    // (`browsers - panes - unpromoted`) counts the orphan as a window,
602    // producing persistent windows-drift against the launcher mirror
603    // (which correctly never received a `WindowOpened` for it).
604    //
605    // `cleanup_failed_promote_orphan` is responsible for ALL recovery
606    // including pool refill — see its contract. We deliberately do NOT
607    // call `spawn_pool_window` here since the cleanup helper either
608    // (a) issues `close_browser` → `on_before_close` → `on_pool_window_destroyed`
609    // already triggers refill, or (b) does direct cleanup + refill
610    // itself. Calling refill from both paths produces double refill.
611    // (codex P1 PR #582 round-1.)
612    let raw_hwnd = match raw_hwnd {
613        Some(h) => h,
614        None => {
615            cleanup_failed_promote_orphan(state, &label);
616            return None;
617        }
618    };
619
620    // Phase F.5 — explicit promote signal sent BETWEEN the matching
621    // `report_pool_window_removed` (above) and `report_window_opened`
622    // (next). The launcher's pool-respawn saga starts on the
623    // resulting `Event::PoolWindowPromoted` and bracket the
624    // subsequent refill in `SagaStarted`/`SagaCompleted` so the
625    // renderer can buffer "you got a tear-off + the pool is
626    // refilling" atomically. Sent only on the validated-HWND path
627    // (mirrors `report_window_opened`'s contract); pre-promote
628    // destroy paths emit only `report_pool_window_removed` with no
629    // promote signal so the saga doesn't fire on non-promote drains.
630    crate::launcher_ipc::report_pool_window_promoted(label.clone());
631
632    // HWND validated — the label IS becoming a real user-visible
633    // window. NOW report the open to the launcher mirror so a
634    // failure path above can't leave the mirror with a phantom
635    // entry. (codex P1 PR #577 round-1.)
636    crate::launcher_ipc::report_window_opened(
637        label.clone(),
638        agentmux_common::ipc::WindowKind::FullInstance,
639        None,
640    );
641
642    // PR #664 codex P2 — explicit AUTHORITATIVE HWND link for
643    // promoted pool windows. Pool windows skip the explicit
644    // report_hwnd_opened branch in `client.rs::on_after_created`
645    // (gated on `!label.starts_with("window-pool-")`) because their
646    // initial registration happens before promotion. The launcher's
647    // drain-on-WindowOpened fallback (in `handle_report_window_opened`)
648    // would only link a recent pending HWND if one happened to be in
649    // the 2s window — pre-promote pool windows are usually older than
650    // that, leaving the mirror permanently hwnd=None. This explicit
651    // link guarantees the mirror tracks the HWND so WRR
652    // visibility/foreground/orphan-destroy drift detection works for
653    // every torn-off window. The accompanying repair logic in
654    // `apply_hwnd_opened` corrects any wrong drain-pick.
655    crate::launcher_ipc::report_hwnd_opened(
656        raw_hwnd as u64,
657        "Chrome_WidgetWin_1".to_string(),
658        label.clone(),
659        Some(label.clone()),
660    );
661
662    // Phase B.4 follow-up — drift check after the atomic
663    // pool→windows transition.
664    crate::launcher_ipc::compute_and_report_host_counts(state);
665
666    // Compute position outside the unsafe block — these are pure
667    // arithmetic, no FFI needed. Don't clamp with .max(0): Windows'
668    // virtual screen space is signed (secondary monitors to the left
669    // of or above the primary have negative coords), and clamping
670    // would push tear-offs onto the primary monitor when the user
671    // grabbed from a secondary.
672    // Use the source window's dimensions when provided (tear-off
673    // UX: new window matches the frame the user dragged from). Fall
674    // back to the pool default otherwise.
675    //
676    // DPI conversion (codex P2 / reagent P1 PR #727): the frontend
677    // sends `window.outerWidth/Height` in CSS/DIP pixels but Win32
678    // `SetWindowPos` expects PHYSICAL pixels. Use the DESTINATION
679    // monitor's DPI (the one under cursor at the drop point), NOT
680    // the pool HWND's current monitor — pool windows live at
681    // POOL_OFFSCREEN_X/Y which is typically the primary monitor, so
682    // on mixed-DPI multi-monitor the pool HWND's DPI doesn't match
683    // the user's actual drop target.
684    let dpi_scale: f32 = unsafe {
685        use windows_sys::Win32::Foundation::POINT;
686        use windows_sys::Win32::Graphics::Gdi::{
687            MonitorFromPoint, MONITOR_DEFAULTTONEAREST,
688        };
689        use windows_sys::Win32::UI::HiDpi::{GetDpiForMonitor, MDT_EFFECTIVE_DPI};
690        let pt = POINT { x: screen_x, y: screen_y };
691        let monitor = MonitorFromPoint(pt, MONITOR_DEFAULTTONEAREST);
692        let mut dpi_x: u32 = 0;
693        let mut dpi_y: u32 = 0;
694        let hr = GetDpiForMonitor(monitor, MDT_EFFECTIVE_DPI, &mut dpi_x, &mut dpi_y);
695        if hr != 0 || dpi_x == 0 { 1.0 } else { dpi_x as f32 / 96.0 }
696    };
697    let to_physical = |dip: i32| -> i32 { (dip as f32 * dpi_scale).round() as i32 };
698    let win_w_dip = width.unwrap_or(POOL_WIDTH);
699    let win_h_dip = height.unwrap_or(POOL_HEIGHT);
700    let win_w = if width.is_some() { to_physical(win_w_dip) } else { win_w_dip };
701    let win_h = if height.is_some() { to_physical(win_h_dip) } else { win_h_dip };
702
703    // Position the new window. With tab anchor (frontend captured the
704    // grab offset within the source tab and converted to a screen
705    // point): place the new window's first tab top-left at the anchor
706    // so the cursor stays on the same visual element across the
707    // handoff. Without anchor: cursor-centered title bar (legacy).
708    //
709    // Position units (codex P1 PR #730 round 1): the anchor and
710    // screen_x/y arrive in the SAME unit as the legacy `screen_x -
711    // win_w / 2` math (the unit CEF reports for `window.screenX`).
712    // The inset constants (`FIRST_TAB_INSET_X`, `TAB_STRIP_TOP_OFFSET_PX`)
713    // are LOGICAL/DIP pixels, but they're SMALL (8 / 16) and the
714    // cold-path drag.rs uses them raw too — converting them here in
715    // ONE path would make the warm and cold paths land at different
716    // offsets on HiDPI. Keep both paths consistent by using the
717    // constants raw. The width/height conversion above is independent
718    // and addresses the SetWindowPos-wants-physical-pixels constraint
719    // for SIZE; that conversion stays.
720    // No `.max(0)` clamp on the anchor branch (codex P2 PR #730 round
721    // 2): on multi-monitor setups where a secondary display is to the
722    // left of or above the primary, screen coords can legitimately be
723    // negative, and clamping to 0 would yank the window back onto the
724    // primary monitor. The legacy fallback also doesn't clamp.
725    //
726    // Anchor semantics (refined post-PR #730 smoke): tab_anchor_{x,y}
727    // is now the OUTER TOP-LEFT of the new window, not the screen
728    // position of the grabbed tab. Frontend computes
729    //   anchor = cursor_screen - grab_offset - source_chrome_inset
730    // so its hardcoded chrome inset (was FIRST_TAB_INSET_X /
731    // TAB_STRIP_TOP_OFFSET_PX) is gone — frontend measures the source
732    // window's actual chrome dynamically. Backend just places the
733    // window at anchor with no further offset.
734    let (pos_x, pos_y) = match (tab_anchor_x, tab_anchor_y) {
735        (Some(ax), Some(ay)) => (ax, ay),
736        _ => (
737            screen_x - win_w / 2,
738            screen_y - TITLE_BAR_OFFSET_PX,
739        ),
740    };
741
742    // Take the window out of WS_EX_TOOLWINDOW so the promoted window
743    // appears in the taskbar / Alt+Tab like any other AgentMux
744    // instance. Must run BEFORE the position/show below; otherwise
745    // the taskbar entry won't appear until the next style refresh.
746    set_taskbar_hidden(raw_hwnd, false);
747
748    // Reposition + raise to top + show. SWP_NOZORDER is intentionally
749    // *not* set — for tear-off we need the new window at the top of
750    // the Z-order so the subsequent SC_MOVE handshake routes the
751    // mouse-capture correctly. With SWP_NOZORDER set, HWND_TOP would
752    // be silently ignored.
753    unsafe {
754        use windows_sys::Win32::UI::WindowsAndMessaging::{
755            SetWindowPos, ShowWindow, HWND_TOP, SW_SHOW,
756        };
757        let pos_ok = SetWindowPos(
758            raw_hwnd,
759            HWND_TOP,
760            pos_x,
761            pos_y,
762            win_w,
763            win_h,
764            0, // no flags — apply move + size + Z-order all
765        );
766        if pos_ok == 0 {
767            // Non-fatal: the SC_MOVE handshake will still try to
768            // run, but the user may see a misplaced window. Log
769            // for diagnostics so we can detect a pattern.
770            let err = windows_sys::Win32::Foundation::GetLastError();
771            tracing::error!(
772                target: "dnd:tearoff:pool",
773                label = %label,
774                last_err = %err,
775                "[pool] SetWindowPos failed"
776            );
777        }
778        let _ = ShowWindow(raw_hwnd, SW_SHOW);
779    }
780
781    // Phase B.7.3.3 — the launcher's typed events drive the
782    // InstancePanel atoms via the CEF JS bridge. No sync emit here.
783
784    // Now tell the pool window's renderer to bootstrap the workspace.
785    crate::events::emit_event_to_window(
786        state,
787        &label,
788        "pool:promote",
789        &serde_json::json!({
790            "workspaceId": workspace_id,
791        }),
792    );
793
794    // Refill the pool in the background.
795    spawn_pool_window(state);
796
797    Some(label)
798}
799
800/// Phase B.5c follow-up — clean up an orphan pool window left behind
801/// when `promote_pool_window`'s HWND validation fails. Without this,
802/// the popped label sits in `state.browsers` but is no longer in
803/// `unpromoted_pool_labels` (promote removed it) and never became a
804/// real user window (no `WindowOpened` was reported). Host's
805/// `compute_and_report_host_counts` then counts it as a window, while
806/// the launcher mirror correctly does not — persistent off-by-one
807/// drift. (Caught by B.4b drift detection during B.5c smoke test on
808/// v0.33.461.)
809///
810/// Contract: this fn is responsible for ALL recovery including pool
811/// refill. The caller MUST NOT call `spawn_pool_window` itself —
812/// double refill would overshoot `POOL_TARGET_SIZE` and waste
813/// renderer capacity. (codex P1 PR #582 round-1.)
814///
815/// Two paths:
816///
817/// * **Graceful path** (browser+host alive in CEF): issue
818///   `close_browser(1)` and let CEF's `on_before_close` fire. That
819///   path runs the standard cleanup chain — drops from
820///   `state.browsers` + `window_meta`, calls
821///   `on_pool_window_destroyed` (which itself triggers refill via
822///   `spawn_pool_window` when pool size is below target), and
823///   triggers `compute_and_report_host_counts` from `client.rs`.
824/// * **Direct path** (browser or host already gone): `on_before_close`
825///   won't fire so we do its job inline — drop from browsers +
826///   window_meta, send `report_window_closed` (silent no-op in
827///   launcher reducer for an unknown label), spawn the refill
828///   ourselves, and emit a drift count snapshot so the orphan's
829///   removal is observable on the same tick. (reagent P2 PR #582
830///   round-1 — original direct path skipped the snapshot.)
831#[cfg(target_os = "windows")]
832fn cleanup_failed_promote_orphan(state: &Arc<AppState>, label: &str) {
833    use cef::{ImplBrowser, ImplBrowserHost};
834    // Phase H.2.b — reducer-aware lookup with fallback.
835    let mut browser_clone = state.get_browser(label);
836    if let Some(ref mut browser) = browser_clone {
837        if let Some(host) = browser.host() {
838            // force_close = 1: don't run beforeunload, we know
839            // this window never reached a useful state.
840            host.close_browser(1);
841            tracing::info!(
842                target: "dnd:tearoff:pool",
843                label = %label,
844                "[pool] orphan close_browser issued — on_before_close will run cleanup + refill"
845            );
846            return;
847        }
848    }
849    // Browser or host already gone — do `on_before_close`'s job
850    // inline since CEF won't fire it for this label.
851    // Phase H.2.d — legacy `state.browsers.lock().remove` removed;
852    // reducer's UnregisterBrowser is sole canonical mutation site.
853    state.host_dispatch(
854        crate::reducer::HostCommand::UnregisterBrowser {
855            label: label.to_string(),
856        },
857    );
858    state.window_meta.lock().remove(label);
859    crate::launcher_ipc::report_window_closed(label.to_string());
860    // Refill (graceful path gets this via on_pool_window_destroyed).
861    spawn_pool_window(state);
862    // Emit a count snapshot now so the orphan's removal is
863    // observable in the launcher's drift stream on the same tick.
864    crate::launcher_ipc::compute_and_report_host_counts(state);
865    tracing::warn!(
866        target: "dnd:tearoff:pool",
867        label = %label,
868        "[pool] orphan browser already gone — cleaned host state directly + refilled"
869    );
870}
871
872#[cfg(not(target_os = "windows"))]
873pub fn promote_pool_window(
874    _state: &Arc<AppState>,
875    _workspace_id: &str,
876    _screen_x: i32,
877    _screen_y: i32,
878    _width: Option<i32>,
879    _height: Option<i32>,
880    _tab_anchor_x: Option<i32>,
881    _tab_anchor_y: Option<i32>,
882) -> Option<String> {
883    // Non-Windows: pool isn't built yet (Phase 7). Caller falls
884    // back to the cold path.
885    None
886}