agentmux_cef\commands/
drag.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3//
4// Cross-window drag-and-drop commands for the CEF host.
5// Ported from src-tauri/src/commands/drag.rs.
6//
7// These commands coordinate drag sessions that span multiple windows.
8// The source window escalates a local pragmatic-dnd drag to a cross-window
9// drag when the cursor leaves the window. Position updates are broadcast
10// to all windows via CEF execute_javascript events.
11
12use std::sync::Arc;
13
14use cef::{ImplBrowser, ImplBrowserHost};
15
16use crate::events;
17use crate::state::{AppState, DragPayload, DragSession, DragType};
18
19/// Sanity bounds for tear-off window dimensions. Frontend caps via
20/// `window.outerWidth/Height` (CSS/DIP) but a malformed or hostile
21/// arg should not be able to size the new window absurdly.
22const TEAROFF_MIN_DIM: i32 = 200;
23const TEAROFF_MAX_DIM: i32 = 8192;
24
25/// Start a cross-window drag session.
26pub fn start_cross_drag(state: &Arc<AppState>, args: &serde_json::Value) -> Result<serde_json::Value, String> {
27    let drag_type: DragType = serde_json::from_value(
28        args.get("dragType").cloned().unwrap_or_default()
29    ).map_err(|e| format!("Invalid dragType: {}", e))?;
30    let source_window = args.get("sourceWindow").and_then(|v| v.as_str()).unwrap_or("main").to_string();
31    let source_workspace_id = args.get("sourceWorkspaceId").and_then(|v| v.as_str()).unwrap_or("").to_string();
32    let source_tab_id = args.get("sourceTabId").and_then(|v| v.as_str()).unwrap_or("").to_string();
33    let payload: DragPayload = serde_json::from_value(
34        args.get("payload").cloned().unwrap_or_default()
35    ).unwrap_or(DragPayload { block_id: None, tab_id: None });
36
37    let drag_id = uuid::Uuid::new_v4().to_string();
38    let now = std::time::SystemTime::now()
39        .duration_since(std::time::UNIX_EPOCH)
40        .unwrap_or_default()
41        .as_millis() as u64;
42
43    tracing::info!(drag_id = %drag_id, drag_type = ?drag_type, source_window = %source_window, "[dnd:cef] start_cross_drag");
44
45    let session = DragSession {
46        drag_id: drag_id.clone(),
47        drag_type,
48        source_window,
49        source_workspace_id,
50        source_tab_id,
51        payload,
52        started_at: now,
53    };
54
55    // PR #5 H.3 — sole drag-state mutation entry point. Reducer enforces
56    // singleton invariant; if a drag is already active, the dispatch
57    // emits a HostEvent::Error and leaves state unchanged. Mirroring the
58    // pre-PR semantics, we still proceed with the renderer broadcast —
59    // the legacy code unconditionally overwrote, but that masked a
60    // genuine bug. Surface the singleton violation by checking the
61    // returned event.
62    let dispatch = state.host_dispatch(
63        crate::reducer::HostCommand::StartDrag { session: session.clone() },
64    );
65    if dispatch.events.iter().any(|e| matches!(e, crate::reducer::HostEvent::Error { .. })) {
66        return Err("a drag session is already active".to_string());
67    }
68    events::emit_event_all_windows(state, "cross-drag-start", &serde_json::to_value(&session).unwrap());
69
70    Ok(serde_json::json!(drag_id))
71}
72
73/// Update cross-window drag with current cursor position.
74pub fn update_cross_drag(state: &Arc<AppState>, args: &serde_json::Value) -> Result<serde_json::Value, String> {
75    let drag_id = args.get("dragId").and_then(|v| v.as_str()).unwrap_or("").to_string();
76    let screen_x = args.get("screenX").and_then(|v| v.as_f64()).unwrap_or(0.0);
77    let screen_y = args.get("screenY").and_then(|v| v.as_f64()).unwrap_or(0.0);
78
79    // PR #5 H.3 — read via reducer-aware helper.
80    let session = state
81        .get_drag_session(&drag_id)
82        .ok_or_else(|| "no active drag session or drag_id mismatch".to_string())?;
83
84    let target_window = hit_test_windows(state, screen_x, screen_y);
85
86    events::emit_event_all_windows(state, "cross-drag-update", &serde_json::json!({
87        "dragId": drag_id,
88        "dragType": session.drag_type,
89        "payload": session.payload,
90        "targetWindow": target_window,
91        "sourceWindow": session.source_window,
92        "screenX": screen_x,
93        "screenY": screen_y,
94    }));
95
96    Ok(serde_json::json!(target_window))
97}
98
99/// Complete a cross-window drag by committing the drop.
100pub fn complete_cross_drag(state: &Arc<AppState>, args: &serde_json::Value) -> Result<serde_json::Value, String> {
101    let drag_id = args.get("dragId").and_then(|v| v.as_str()).unwrap_or("").to_string();
102    let target_window = args.get("targetWindow").and_then(|v| v.as_str()).map(|s| s.to_string());
103    let screen_x = args.get("screenX").and_then(|v| v.as_f64()).unwrap_or(0.0);
104    let screen_y = args.get("screenY").and_then(|v| v.as_f64()).unwrap_or(0.0);
105
106    // PR #5 H.3 — atomic end-and-return via reducer. EndDrag returns
107    // `ended_drag_session: Some(_)` iff the drag_id matched and the
108    // session was actually consumed. None means: no session active OR
109    // drag_id mismatch — both surface as Err here.
110    let outcome = match &target_window {
111        Some(t) => crate::reducer::DragOutcome::Dropped { target_label: t.clone() },
112        None => crate::reducer::DragOutcome::TornOff { new_label: String::new() },
113    };
114    let dispatch = state.host_dispatch(
115        crate::reducer::HostCommand::EndDrag { drag_id: drag_id.clone(), outcome },
116    );
117    let session = dispatch
118        .ended_drag_session
119        .ok_or_else(|| "no active drag session or drag_id mismatch".to_string())?;
120
121    let result = if target_window.is_some() { "drop" } else { "tearoff" };
122    tracing::info!(drag_id = %drag_id, result = %result, "[dnd:cef] complete_cross_drag");
123
124    events::emit_event_all_windows(state, "cross-drag-end", &serde_json::json!({
125        "dragId": drag_id,
126        "result": result,
127        "targetWindow": target_window,
128        "screenX": screen_x,
129        "screenY": screen_y,
130        "payload": session.payload,
131        "dragType": session.drag_type,
132        "sourceWindow": session.source_window,
133        "sourceWorkspaceId": session.source_workspace_id,
134        "sourceTabId": session.source_tab_id,
135    }));
136
137    Ok(serde_json::Value::Null)
138}
139
140/// Cancel an active cross-window drag session.
141pub fn cancel_cross_drag(state: &Arc<AppState>, args: &serde_json::Value) -> Result<serde_json::Value, String> {
142    let drag_id = args.get("dragId").and_then(|v| v.as_str()).unwrap_or("").to_string();
143
144    // PR #5 H.3 — atomic cancel via reducer. EndDrag's
145    // `ended_drag_session.is_some()` distinguishes "actually ended"
146    // from "no session / drag_id mismatch".
147    let dispatch = state.host_dispatch(
148        crate::reducer::HostCommand::EndDrag {
149            drag_id: drag_id.clone(),
150            outcome: crate::reducer::DragOutcome::Cancelled,
151        },
152    );
153    if dispatch.ended_drag_session.is_none() {
154        return Err("no active drag session or drag_id mismatch".to_string());
155    }
156
157    events::emit_event_all_windows(state, "cross-drag-end", &serde_json::json!({
158        "dragId": drag_id,
159        "result": "cancel",
160    }));
161
162    tracing::info!(drag_id = %drag_id, "[dnd:cef] cancel_cross_drag");
163    Ok(serde_json::Value::Null)
164}
165
166/// Hit-test all open browser windows to find which one contains the cursor.
167#[cfg(target_os = "windows")]
168fn hit_test_windows(state: &Arc<AppState>, screen_x: f64, screen_y: f64) -> Option<String> {
169    use cef::ImplBrowserHost;
170    use windows_sys::Win32::Foundation::RECT;
171    use windows_sys::Win32::UI::WindowsAndMessaging::GetWindowRect;
172
173    // Phase H.2.b — reducer-aware iteration with fallback.
174    for (label, browser) in state.list_browsers() {
175        if let Some(host) = browser.host() {
176            let hwnd = host.window_handle();
177            if hwnd.0.is_null() { continue; }
178            unsafe {
179                let mut rect: RECT = std::mem::zeroed();
180                GetWindowRect(hwnd.0 as *mut std::ffi::c_void, &mut rect);
181                let x = rect.left as f64;
182                let y = rect.top as f64;
183                let w = (rect.right - rect.left) as f64;
184                let h = (rect.bottom - rect.top) as f64;
185                if screen_x >= x && screen_x <= x + w && screen_y >= y && screen_y <= y + h {
186                    return Some(label.clone());
187                }
188            }
189        }
190    }
191    None
192}
193
194#[cfg(not(target_os = "windows"))]
195fn hit_test_windows(_state: &Arc<AppState>, _screen_x: f64, _screen_y: f64) -> Option<String> {
196    None
197}
198
199/// Get the current cursor position on screen.
200pub fn get_cursor_point() -> Result<serde_json::Value, String> {
201    #[cfg(target_os = "windows")]
202    {
203        use windows_sys::Win32::Foundation::POINT;
204        use windows_sys::Win32::UI::WindowsAndMessaging::GetCursorPos;
205        unsafe {
206            let mut pt: POINT = std::mem::zeroed();
207            GetCursorPos(&mut pt);
208            return Ok(serde_json::json!({ "x": pt.x, "y": pt.y }));
209        }
210    }
211    #[allow(unreachable_code)]
212    Ok(serde_json::json!({ "x": 0, "y": 0 }))
213}
214
215/// Check whether the primary mouse button is currently pressed.
216pub fn get_mouse_button_state() -> Result<serde_json::Value, String> {
217    #[cfg(target_os = "windows")]
218    {
219        use windows_sys::Win32::UI::Input::KeyboardAndMouse::GetAsyncKeyState;
220        let state = unsafe { GetAsyncKeyState(0x01) }; // VK_LBUTTON
221        return Ok(serde_json::json!((state as u16 & 0x8000) != 0));
222    }
223    #[allow(unreachable_code)]
224    Ok(serde_json::json!(false))
225}
226
227/// Replace the system no-drop cursor with a crosshair during drag.
228pub fn set_drag_cursor() -> Result<serde_json::Value, String> {
229    #[cfg(target_os = "windows")]
230    {
231        use windows_sys::Win32::UI::WindowsAndMessaging::{
232            CopyIcon, LoadCursorW, SetSystemCursor, IDC_CROSS, OCR_NO,
233        };
234        unsafe {
235            let cross = LoadCursorW(std::ptr::null_mut(), IDC_CROSS);
236            if !cross.is_null() {
237                let copy = CopyIcon(cross);
238                if !copy.is_null() {
239                    SetSystemCursor(copy, OCR_NO);
240                }
241            }
242        }
243    }
244    Ok(serde_json::Value::Null)
245}
246
247/// Restore all system cursors to defaults.
248pub fn restore_drag_cursor() -> Result<serde_json::Value, String> {
249    #[cfg(target_os = "windows")]
250    {
251        use windows_sys::Win32::UI::WindowsAndMessaging::{SystemParametersInfoW, SPI_SETCURSORS};
252        unsafe {
253            SystemParametersInfoW(SPI_SETCURSORS, 0, std::ptr::null_mut(), 0);
254        }
255    }
256    Ok(serde_json::Value::Null)
257}
258
259/// Release mouse capture after an HTML5 drag ends outside the window.
260pub fn release_drag_capture(state: &Arc<AppState>) -> Result<serde_json::Value, String> {
261    #[cfg(target_os = "windows")]
262    {
263        use windows_sys::Win32::UI::Input::KeyboardAndMouse::ReleaseCapture;
264        use windows_sys::Win32::UI::WindowsAndMessaging::{
265            EnumChildWindows, PostMessageW, WM_CANCELMODE,
266        };
267        use windows_sys::Win32::Foundation::{BOOL, LPARAM};
268
269        // Use the main browser's HWND, or find_own_top_level_window as fallback.
270        // Phase H.2.b — reducer-aware lookup with fallback.
271        let hwnd = state
272            .get_browser("main")
273            .and_then(|b| b.host())
274            .map(|h| h.window_handle().0 as *mut std::ffi::c_void)
275            .unwrap_or_else(|| unsafe { super::window::find_own_top_level_window() });
276
277        if !hwnd.is_null() {
278            unsafe {
279                ReleaseCapture();
280                PostMessageW(hwnd, WM_CANCELMODE, 0, 0);
281                unsafe extern "system" fn cancel_child(child: *mut std::ffi::c_void, _: LPARAM) -> BOOL {
282                    PostMessageW(child, WM_CANCELMODE, 0, 0);
283                    1
284                }
285                EnumChildWindows(hwnd, Some(cancel_child), 0);
286            }
287        }
288    }
289    let _ = state;
290    Ok(serde_json::Value::Null)
291}
292
293/// Phase 6 — frontend signal that a pool window's renderer is
294/// ready to receive `pool:promote`. Called from awaitPoolPromote
295/// AFTER the listener is installed. Only after this signal does
296/// the window enter the pool queue (otherwise emit_event_to_window
297/// would race the listener install and lose promote events).
298pub fn pool_window_ready(
299    state: &Arc<AppState>,
300    args: &serde_json::Value,
301) -> Result<serde_json::Value, String> {
302    let label = args
303        .get("label")
304        .and_then(|v| v.as_str())
305        .ok_or_else(|| "missing label".to_string())?;
306    super::window_pool::mark_pool_window_renderer_ready(state, label);
307    Ok(serde_json::Value::Null)
308}
309
310/// Phase 6 — promote a pre-warmed pool window for tear-off.
311/// Returns the promoted window's label, or an error string if the
312/// pool was empty (caller should fall back to open_window_at_position).
313/// Args: { workspaceId, screenX, screenY }.
314pub fn tear_off_pool_promote(
315    state: &Arc<AppState>,
316    args: &serde_json::Value,
317) -> Result<serde_json::Value, String> {
318    let workspace_id = args
319        .get("workspaceId")
320        .and_then(|v| v.as_str())
321        .ok_or_else(|| "missing workspaceId".to_string())?;
322    let screen_x = args
323        .get("screenX")
324        .and_then(|v| v.as_f64())
325        .ok_or_else(|| "missing screenX".to_string())? as i32;
326    let screen_y = args
327        .get("screenY")
328        .and_then(|v| v.as_f64())
329        .ok_or_else(|| "missing screenY".to_string())? as i32;
330    // Optional source-window dimensions for size-matching tear-off.
331    let width = args
332        .get("width")
333        .and_then(|v| v.as_f64())
334        .map(|w| (w as i32).clamp(TEAROFF_MIN_DIM, TEAROFF_MAX_DIM));
335    let height = args
336        .get("height")
337        .and_then(|v| v.as_f64())
338        .map(|h| (h as i32).clamp(TEAROFF_MIN_DIM, TEAROFF_MAX_DIM));
339    // Optional tab anchor — the screen point where the user grabbed
340    // the tab. Backend positions the new window so its first tab lands
341    // at that point so the cursor stays on the same visual element
342    // across the handoff (Chrome-style no-teleport tear-off).
343    let tab_anchor_x = args.get("tabAnchorX").and_then(|v| v.as_f64()).map(|n| n as i32);
344    let tab_anchor_y = args.get("tabAnchorY").and_then(|v| v.as_f64()).map(|n| n as i32);
345
346    match super::window_pool::promote_pool_window(
347        state,
348        workspace_id,
349        screen_x,
350        screen_y,
351        width,
352        height,
353        tab_anchor_x,
354        tab_anchor_y,
355    ) {
356        Some(label) => Ok(serde_json::json!(label)),
357        None => {
358            // Per spec §0 the cold path is defence-in-depth, not an
359            // expected branch. Surface as an error so the frontend
360            // falls back deliberately and we can monitor `tear_off
361            // .pool_exhausted` events.
362            tracing::warn!(
363                target: "dnd:tearoff:pool",
364                workspace_id = %workspace_id,
365                "[pool] pool exhausted on tear-off — frontend will cold-path"
366            );
367            Err("pool_exhausted".to_string())
368        }
369    }
370}
371
372/// Open a new window at a specific screen position (tear-off).
373/// Creates a new CEF browser window positioned so the cursor lands in the title bar.
374pub fn open_window_at_position(state: &Arc<AppState>, args: &serde_json::Value) -> Result<serde_json::Value, String> {
375    // PR #6 H.7 — refuse top-level creation while any pane is mid-close.
376    // See `commands/window.rs::open_window_with_kind` for rationale.
377    if state.any_browser_pane_closing() {
378        tracing::warn!(
379            target: "wfr:gate",
380            "[wfr:gate] open_window_at_position refused — pane is mid-close (H.7 invariant)"
381        );
382        return Err("a pane is currently closing; retry shortly".to_string());
383    }
384
385    let screen_x = args.get("screenX").and_then(|v| v.as_f64()).unwrap_or(0.0);
386    let screen_y = args.get("screenY").and_then(|v| v.as_f64()).unwrap_or(0.0);
387    let workspace_id = args.get("workspaceId").and_then(|v| v.as_str()).unwrap_or("").to_string();
388
389    let window_id = uuid::Uuid::new_v4();
390    let label = format!("window-{}", window_id.simple());
391
392    // Source-window-matching tear-off size. Frontend captures
393    // window.outerWidth/Height of the dragged-from window and passes
394    // them through; cold path falls back to the historical default if
395    // the args are absent (manual host RPC, etc.).
396    let win_w = args
397        .get("width")
398        .and_then(|v| v.as_f64())
399        .map(|w| (w as i32).clamp(TEAROFF_MIN_DIM, TEAROFF_MAX_DIM))
400        .unwrap_or(1200);
401    let win_h = args
402        .get("height")
403        .and_then(|v| v.as_f64())
404        .map(|h| (h as i32).clamp(TEAROFF_MIN_DIM, TEAROFF_MAX_DIM))
405        .unwrap_or(800);
406
407    // Optional tab anchor — see warm-pool path comment.
408    let tab_anchor_x = args.get("tabAnchorX").and_then(|v| v.as_f64()).map(|n| n as i32);
409    let tab_anchor_y = args.get("tabAnchorY").and_then(|v| v.as_f64()).map(|n| n as i32);
410
411    // Anchor is the new window's outer top-left (frontend pre-computed,
412    // chrome inset already subtracted). See window_pool.rs for full
413    // rationale. Negative coords valid on multi-monitor.
414    let (pos_x, pos_y) = match (tab_anchor_x, tab_anchor_y) {
415        (Some(ax), Some(ay)) => (ax, ay),
416        _ => (
417            ((screen_x - win_w as f64 / 2.0).max(0.0)) as i32,
418            ((screen_y - 16.0).max(0.0)) as i32,
419        ),
420    };
421
422    tracing::info!(
423        label = %label, pos_x = %pos_x, pos_y = %pos_y,
424        workspace_id = %workspace_id,
425        "[dnd:cef] open_window_at_position"
426    );
427
428    // Build URL with IPC credentials and tear-off params
429    let ipc_port = *state.ipc_port.lock();
430    let ipc_token = &state.ipc_token;
431    let base_url = super::window::resolve_frontend_base_url(ipc_port);
432    let separator = if base_url.contains('?') { "&" } else { "?" };
433    let mut url = format!(
434        "{}{}ipc_port={}&ipc_token={}&windowLabel={}",
435        base_url, separator, ipc_port, ipc_token, label
436    );
437    if !workspace_id.is_empty() {
438        url.push_str(&format!("&workspaceId={}", workspace_id));
439    }
440
441    // Phase B.5 (window_meta step d) — push the pre-create
442    // handoff (label + kind + parent). Tear-offs are FullInstance
443    // with no parent. Replaces the previous parallel
444    // `window_meta.insert` + `pending_window_labels.push` pair.
445    //
446    // Phase F.1 — routed through the host reducer.
447    state.host_dispatch(
448        crate::reducer::HostCommand::EnqueuePendingWindowCreation {
449            entry: crate::state::PendingWindowCreation {
450                label: label.clone(),
451                kind: crate::state::WindowKind::FullInstance,
452                parent_instance_id: None,
453            },
454        },
455    );
456
457    // Post to CEF UI thread — window_create_top_level must run there.
458    // true = frameless: tear-off windows use the same custom title bar as main.
459    crate::ui_tasks::post_create_window(
460        state, &url, &label, pos_x, pos_y, win_w, win_h,
461        true,
462    );
463
464    // Phase B.7.3.3 — the launcher's typed events
465    // (`Event::WindowOpened` + `Event::WindowInstanceAssigned` +
466    // `Event::BackendWindowIdRegistered`) flow through the CEF JS
467    // bridge to drive the InstancePanel atoms. No sync emit here.
468
469    Ok(serde_json::json!(label))
470}
471
472/// Signal that a JS-level drag is starting or ending (Linux GTK guard).
473pub fn set_js_drag_active(_args: &serde_json::Value) -> Result<serde_json::Value, String> {
474    // No-op on Windows/macOS. Linux would need an atomic flag.
475    Ok(serde_json::Value::Null)
476}
477
478/// Tear-off Phase 2 — the Win32 SC_MOVE handshake.
479///
480/// Called from `requestTearOff` in tabbar.tsx AFTER the frontend has
481/// already (a) called WorkspaceService.TearOffTab to move the tab into
482/// a new workspace, and (b) called open_window_at_position to spawn
483/// the destination window. This handler waits for the destination
484/// window's HWND to register, then issues the Win32 SC_MOVE so the
485/// new window enters the OS modal move-loop and follows the cursor
486/// at full opacity (no ghost) until mouseup.
487///
488/// Per spec §0 the cold-path version (no warm pool) accepts a
489/// ~150-300 ms first-paint flash for Phase 2 verification only;
490/// Phase 6 replaces that with a pre-warmed pool to hit the 0 ms
491/// target. The ≤ 8 ms handshake budget from §0 applies from this
492/// phase onward and is measured by `tear_off.handshake_ms` —
493/// excluded from the budget is only the registration-wait, which
494/// goes away with the warm pool.
495///
496/// See docs/specs/SPEC_TAB_TEAR_OFF_SIZE_PRESERVATION_2026_04_26.
497pub fn tear_off_sc_move_handshake(
498    state: &Arc<AppState>,
499    args: &serde_json::Value,
500) -> Result<serde_json::Value, String> {
501    let t_start = std::time::Instant::now();
502
503    let source_label = args
504        .get("sourceWindowLabel")
505        .and_then(|v| v.as_str())
506        .ok_or_else(|| "missing sourceWindowLabel".to_string())?
507        .to_string();
508    let dest_label = args
509        .get("destWindowLabel")
510        .and_then(|v| v.as_str())
511        .ok_or_else(|| "missing destWindowLabel".to_string())?
512        .to_string();
513    // Error on missing/malformed coords rather than defaulting to (0,0):
514    // a silent default would put the new window at screen origin with no
515    // diagnostic, looking like a feature bug when it's actually a wire
516    // contract bug.
517    let cursor_x = args
518        .get("cursorX")
519        .and_then(|v| v.as_f64())
520        .ok_or_else(|| "missing or invalid cursorX".to_string())? as i32;
521    let cursor_y = args
522        .get("cursorY")
523        .and_then(|v| v.as_f64())
524        .ok_or_else(|| "missing or invalid cursorY".to_string())? as i32;
525
526    // Phase 4 args — drive the WH_MOUSE_LL hook + finalize event.
527    // Optional in case a future call site doesn't need merge detection;
528    // if any are missing/empty, we skip the hook install and the
529    // dragged window simply ends as a standalone after mouseup.
530    let tab_id = args
531        .get("tabId")
532        .and_then(|v| v.as_str())
533        .unwrap_or("")
534        .to_string();
535    let source_ws_id = args
536        .get("sourceWsId")
537        .and_then(|v| v.as_str())
538        .unwrap_or("")
539        .to_string();
540    let dest_ws_id = args
541        .get("destWsId")
542        .and_then(|v| v.as_str())
543        .unwrap_or("")
544        .to_string();
545    // Phase 5 — original tab index for cancel-back (ESC or drop on
546    // source strip). Defaults to 0 if missing — cancel-back will
547    // reinsert at start, which is best-effort if the caller didn't
548    // provide the real index.
549    let original_tab_index = args
550        .get("originalTabIndex")
551        .and_then(|v| v.as_u64())
552        .unwrap_or(0) as usize;
553    // Phase 5 — was the tab pinned in its source workspace?
554    // Threaded into HookContext so the cancel-back payload can tell
555    // the backend to restore into pinnedtabids vs tabids. Defaults
556    // to false (regular tab). (gemini PR #567 round-6 MEDIUM)
557    let was_pinned = args
558        .get("wasPinned")
559        .and_then(|v| v.as_bool())
560        .unwrap_or(false);
561
562    // Win32-only path. The HWND poll, ReleaseCapture, and the SC_MOVE
563    // post all live inside the cfg block — on macOS / Linux the
564    // function returns Ok(null) immediately (Phase 7 adds platform
565    // equivalents). Without this gate the 2s HWND-poll would run on
566    // every platform with no benefit.
567    #[cfg(target_os = "windows")]
568    let handshake_ms: f64 = {
569        // Wait for the destination browser's HWND to be available —
570        // the window-create posts to the CEF UI thread asynchronously
571        // and the browser is registered in state.browsers via
572        // on_after_created. Poll with the mutex released between
573        // checks. 2s deadline is generous; cold-path window creation
574        // typically completes in 150-300 ms. Phase 6 (warm pool) drops
575        // this to <16 ms.
576        let dest_hwnd = wait_for_browser_hwnd(state, &dest_label, std::time::Duration::from_millis(2000))
577            .ok_or_else(|| format!("dest window not registered within 2s: {}", dest_label))?;
578
579        // Phase 4 — install the WH_MOUSE_LL hook on a dedicated thread
580        // BEFORE PostMessageW(SC_MOVE) so the hook is armed when the
581        // user starts moving. Otherwise the first cursor positions of
582        // the move-loop would be missed. Skip if any merge-related
583        // arg is empty (Phase 2 callers).
584        if !tab_id.is_empty() && !source_ws_id.is_empty() && !dest_ws_id.is_empty() {
585            crate::commands::tear_off_hook::start_tear_off_tracking(
586                state.clone(),
587                source_label.clone(),
588                dest_label.clone(),
589                tab_id.clone(),
590                source_ws_id.clone(),
591                dest_ws_id.clone(),
592                original_tab_index,
593                was_pinned,
594            )?;
595        }
596
597        let t_handshake = std::time::Instant::now();
598
599        unsafe {
600            use windows_sys::Win32::UI::Input::KeyboardAndMouse::ReleaseCapture;
601            use windows_sys::Win32::UI::WindowsAndMessaging::{
602                PostMessageW, SetForegroundWindow, HTCAPTION, SC_MOVE, WM_SYSCOMMAND,
603            };
604
605            // Drop whatever capture this thread may hold (defensive —
606            // ReleaseCapture only affects the calling thread's
607            // capture, so this is a no-op for OLE-owned capture on
608            // the source webview's thread; harmless either way).
609            ReleaseCapture();
610
611            // Bring the destination forward. Windows grabs capture
612            // automatically when entering the SC_MOVE modal loop, so
613            // we don't call SetCapture explicitly here — it would
614            // fail anyway since `dest_hwnd` doesn't belong to this
615            // thread (Win32 SetCapture requires same-thread ownership).
616            // If empirically SC_MOVE turns out to need the capture
617            // pre-set, we'll post a UI-thread task to do it; for now
618            // the simpler path matches Chrome's observed behaviour.
619            SetForegroundWindow(dest_hwnd);
620
621            let lparam = ((cursor_y as i32 as u32) << 16) | (cursor_x as i32 as u32 & 0xFFFF);
622            // PostMessageW returns BOOL — 0 means the post failed (e.g.
623            // dest HWND went invalid between wait_for_browser_hwnd and
624            // here, or the message queue rejected the post). Return an
625            // error so the frontend doesn't silently treat tear-off as
626            // complete; the UI can fall back / log.
627            let post_ok = PostMessageW(
628                dest_hwnd,
629                WM_SYSCOMMAND,
630                (SC_MOVE as usize) | (HTCAPTION as usize),
631                lparam as isize,
632            );
633            if post_ok == 0 {
634                let last_err = windows_sys::Win32::Foundation::GetLastError();
635                return Err(format!(
636                    "PostMessageW(SC_MOVE) failed: GetLastError={}",
637                    last_err
638                ));
639            }
640        }
641
642        t_handshake.elapsed().as_micros() as f64 / 1000.0
643    };
644
645    #[cfg(not(target_os = "windows"))]
646    let handshake_ms: f64 = {
647        // Phase 7 adds macOS (NSWindow performWindowDragWithEvent) +
648        // Linux (_NET_WM_MOVERESIZE / xdg_toplevel.move) equivalents.
649        // For now the non-Windows path is a no-op so the IPC contract
650        // exists and the rest of the pipeline can be cross-platform.
651        let _ = (state, &dest_label);
652        0.0
653    };
654
655    let total_ms = t_start.elapsed().as_micros() as f64 / 1000.0;
656
657    tracing::info!(
658        target: "dnd:tearoff",
659        source = %source_label,
660        dest = %dest_label,
661        cursor_x = %cursor_x,
662        cursor_y = %cursor_y,
663        handshake_ms = %handshake_ms,
664        total_ms = %total_ms,
665        "[dnd:tearoff] SC_MOVE handshake complete"
666    );
667
668    Ok(serde_json::json!({
669        "handshakeMs": handshake_ms,
670        "totalMs": total_ms,
671    }))
672}
673
674/// Poll state.browsers for `label` until its host's HWND is non-null
675/// or the deadline elapses. Returns the HWND as a raw pointer.
676/// Releases the browsers mutex between polls so on_after_created can
677/// register on the UI thread.
678#[cfg(target_os = "windows")]
679fn wait_for_browser_hwnd(
680    state: &Arc<AppState>,
681    label: &str,
682    timeout: std::time::Duration,
683) -> Option<*mut std::ffi::c_void> {
684    let deadline = std::time::Instant::now() + timeout;
685    while std::time::Instant::now() < deadline {
686        // Phase H.2.b — reducer-aware lookup with fallback.
687        if let Some(browser) = state.get_browser(label) {
688            if let Some(host) = browser.host() {
689                let h = host.window_handle();
690                if !h.0.is_null() {
691                    return Some(h.0 as *mut std::ffi::c_void);
692                }
693            }
694        }
695        std::thread::sleep(std::time::Duration::from_millis(10));
696    }
697    None
698}