agentmux_cef\commands/
tear_off_hook.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3//
4// Tear-off Phase 4 — WH_MOUSE_LL low-level mouse hook for cross-window
5// merge detection during the SC_MOVE modal move-loop.
6//
7// Background: while Windows runs the modal SC_MOVE loop (entered via
8// `commands/drag.rs::tear_off_sc_move_handshake`), AgentMux's normal
9// renderer message handlers DON'T fire — Windows owns the cursor
10// until mouseup. To detect "is the cursor over another AgentMux
11// window's tab strip?" we install a global low-level mouse hook on a
12// dedicated thread with its own GetMessage loop.
13//
14// The hook callback runs on the install thread (not arbitrary
15// threads). It uses thread-local storage to access the hook context
16// (Arc<AppState>, source/dest labels, tab id, etc.) without risking
17// re-entrant locking issues that a global Mutex would have.
18//
19// Architecture:
20//   * `start_tear_off_tracking()` is called from the IPC handler
21//     BEFORE the SC_MOVE post. Spawns a thread, installs the hook,
22//     returns a `TrackingHandle` that's dropped when the user releases
23//     the mouse (the thread's GetMessage loop sees WM_LBUTTONUP and
24//     calls PostQuitMessage).
25//   * On every WM_MOUSEMOVE, the callback does WindowFromPoint →
26//     GetAncestor(GA_ROOT) and looks the HWND up in `state.browsers`
27//     (skipping the dragged window itself). If the candidate target
28//     changed, emits `tearoff:hover-changed` IPC events to the new
29//     and old candidate's renderers.
30//   * On WM_LBUTTONUP, emits `tearoff:finalize` to the source window's
31//     renderer with the final candidate label + cursor position. The
32//     source-side frontend handles the merge (calls
33//     MoveTabToWorkspace + closes the dragged window).
34//
35// Spec: docs/specs/SPEC_TAB_TEAR_OFF_SIZE_PRESERVATION_2026_04_26 §4.3-§4.4
36// Phase 5 (cancel-back-to-source) reuses the same finalize event
37// with a different source-side handler.
38
39#[cfg(target_os = "windows")]
40use std::cell::RefCell;
41#[cfg(target_os = "windows")]
42use std::sync::Arc;
43
44#[cfg(target_os = "windows")]
45use crate::state::AppState;
46
47#[cfg(target_os = "windows")]
48struct HookContext {
49    state: Arc<AppState>,
50    /// Label of the source window (the one the tab originated from).
51    /// Used to skip self-detection during cursor tracking, and as the
52    /// destination for the `tearoff:finalize` event.
53    source_label: String,
54    /// Label of the dragged-window-now-following-the-cursor. Also
55    /// excluded from candidate detection — landing on the dragged
56    /// window's own strip would be a no-op.
57    dragged_label: String,
58    /// The tab being torn off. Echoed back in the finalize payload so
59    /// the source frontend doesn't have to track per-drag state.
60    tab_id: String,
61    /// Source workspace ID. Echoed back so the frontend can call
62    /// `MoveTabToWorkspace` from a different window context if needed.
63    source_ws_id: String,
64    /// Destination workspace ID (the new workspace TearOffTab created).
65    /// Cancel-back uses this as the `fromWsId` when restoring.
66    dest_ws_id: String,
67    /// Phase 5 — tab's original index in the source workspace at the
68    /// moment of tear-off. Used by cancel-back (ESC or drop on source
69    /// strip) to reinsert the tab where it was, not at the end.
70    /// Index is into `pinnedtabids` if `was_pinned`, else into `tabids`.
71    original_tab_index: usize,
72    /// Phase 5 — true if the tab was pinned in its source workspace.
73    /// Threaded through to the cancel-back payload so the backend can
74    /// restore into `pinnedtabids` and preserve pinned status. Without
75    /// this, a pinned tab torn off + cancel-backed would silently
76    /// come back unpinned. (gemini PR #567 round-6 MEDIUM)
77    was_pinned: bool,
78    /// Last-known candidate target label, or None when over a non-
79    /// AgentMux window or the desktop. Used to emit hover-clear events
80    /// when the cursor leaves a candidate.
81    current_target: RefCell<Option<String>>,
82    /// Set true the moment a finalisation event has been emitted
83    /// (cancel-back via ESC, merge, or standalone). Subsequent hook
84    /// callbacks bail without emitting — without this guard, a
85    /// post-ESC mouseup would fire a second redundant event.
86    finalized: RefCell<bool>,
87}
88
89#[cfg(target_os = "windows")]
90thread_local! {
91    static HOOK_CTX: RefCell<Option<HookContext>> = const { RefCell::new(None) };
92}
93
94/// Spawn a hook thread for the duration of a tear-off gesture.
95/// Returns Ok once the hook is installed; the thread runs in the
96/// background until WM_LBUTTONUP arrives or `stop_tear_off_tracking`
97/// is called.
98///
99/// Safe to call from a Tokio worker thread — the hook thread is
100/// independent and runs its own message loop.
101#[cfg(target_os = "windows")]
102pub fn start_tear_off_tracking(
103    state: Arc<AppState>,
104    source_label: String,
105    dragged_label: String,
106    tab_id: String,
107    source_ws_id: String,
108    dest_ws_id: String,
109    original_tab_index: usize,
110    was_pinned: bool,
111) -> Result<(), String> {
112    use std::sync::mpsc;
113
114    // Use a oneshot channel so the spawn returns only after the hook
115    // is fully installed. Otherwise PostMessageW(SC_MOVE) could fire
116    // before the hook is ready and we'd miss the first few mouse
117    // events of the move-loop.
118    let (ready_tx, ready_rx) = mpsc::channel::<Result<(), String>>();
119
120    std::thread::Builder::new()
121        .name("tear-off-hook".to_string())
122        .spawn(move || {
123            let ctx = HookContext {
124                state,
125                source_label,
126                dragged_label,
127                tab_id,
128                source_ws_id,
129                dest_ws_id,
130                original_tab_index,
131                was_pinned,
132                current_target: RefCell::new(None),
133                finalized: RefCell::new(false),
134            };
135            HOOK_CTX.with(|cell| *cell.borrow_mut() = Some(ctx));
136
137            unsafe {
138                use windows_sys::Win32::UI::WindowsAndMessaging::{
139                    DispatchMessageW, GetMessageW, SetWindowsHookExW,
140                    TranslateMessage, UnhookWindowsHookEx, MSG,
141                    WH_KEYBOARD_LL, WH_MOUSE_LL,
142                };
143
144                // hMod: pass our own module handle (defensive — the
145                // OS accepts NULL for WH_MOUSE_LL/WH_KEYBOARD_LL but
146                // the contract technically requires the module
147                // containing the hook proc).
148                let h_module = windows_sys::Win32::System::LibraryLoader::GetModuleHandleW(
149                    std::ptr::null(),
150                );
151
152                let mouse_hook = SetWindowsHookExW(
153                    WH_MOUSE_LL,
154                    Some(low_level_mouse_proc),
155                    h_module,
156                    0,
157                );
158                if mouse_hook.is_null() {
159                    let err = windows_sys::Win32::Foundation::GetLastError();
160                    HOOK_CTX.with(|cell| *cell.borrow_mut() = None);
161                    let _ = ready_tx.send(Err(format!(
162                        "SetWindowsHookExW(WH_MOUSE_LL) failed: GetLastError={}",
163                        err
164                    )));
165                    return;
166                }
167
168                // ESC during the SC_MOVE modal loop cancels the move
169                // but Windows sends no WM_LBUTTONUP — without a
170                // keyboard hook the mouse hook would survive forever
171                // (and worse, the next unrelated WM_LBUTTONUP anywhere
172                // on the desktop would fire handle_button_up with
173                // stale tear-off context, silently merging the wrong
174                // tab into the wrong window). The keyboard hook
175                // catches VK_ESCAPE and treats it as a standalone
176                // finalisation. (reagent PR #565 P1)
177                let kb_hook = SetWindowsHookExW(
178                    WH_KEYBOARD_LL,
179                    Some(low_level_keyboard_proc),
180                    h_module,
181                    0,
182                );
183                if kb_hook.is_null() {
184                    let err = windows_sys::Win32::Foundation::GetLastError();
185                    UnhookWindowsHookEx(mouse_hook);
186                    HOOK_CTX.with(|cell| *cell.borrow_mut() = None);
187                    let _ = ready_tx.send(Err(format!(
188                        "SetWindowsHookExW(WH_KEYBOARD_LL) failed: GetLastError={}",
189                        err
190                    )));
191                    return;
192                }
193
194                let _ = ready_tx.send(Ok(()));
195
196                tracing::info!(
197                    target: "dnd:tearoff",
198                    "[dnd:tearoff] hooks installed (mouse + keyboard), entering message loop"
199                );
200
201                // Standard GetMessage pump. The loop exits when a
202                // hook callback posts WM_QUIT after WM_LBUTTONUP or
203                // VK_ESCAPE.
204                let mut msg: MSG = std::mem::zeroed();
205                loop {
206                    let r = GetMessageW(&mut msg, std::ptr::null_mut(), 0, 0);
207                    if r <= 0 {
208                        break; // 0 = WM_QUIT, -1 = error
209                    }
210                    let _ = TranslateMessage(&msg);
211                    DispatchMessageW(&msg);
212                }
213
214                UnhookWindowsHookEx(kb_hook);
215                UnhookWindowsHookEx(mouse_hook);
216                HOOK_CTX.with(|cell| *cell.borrow_mut() = None);
217
218                tracing::info!(
219                    target: "dnd:tearoff",
220                    "[dnd:tearoff] hooks uninstalled, thread exiting"
221                );
222            }
223        })
224        .map_err(|e| format!("failed to spawn hook thread: {}", e))?;
225
226    // Block until the hook thread either installs the hook or fails.
227    // ~milliseconds latency on success.
228    ready_rx
229        .recv()
230        .map_err(|e| format!("hook ready channel closed: {}", e))?
231}
232
233/// No-op stub for non-Windows builds. Phase 7 adds platform
234/// equivalents (CGEventTap on macOS, polled XQueryPointer on X11).
235#[cfg(not(target_os = "windows"))]
236pub fn start_tear_off_tracking(
237    _state: std::sync::Arc<crate::state::AppState>,
238    _source_label: String,
239    _dragged_label: String,
240    _tab_id: String,
241    _source_ws_id: String,
242    _dest_ws_id: String,
243    _original_tab_index: usize,
244    _was_pinned: bool,
245) -> Result<(), String> {
246    Ok(())
247}
248
249#[cfg(target_os = "windows")]
250unsafe extern "system" fn low_level_keyboard_proc(
251    n_code: i32,
252    w_param: windows_sys::Win32::Foundation::WPARAM,
253    l_param: windows_sys::Win32::Foundation::LPARAM,
254) -> windows_sys::Win32::Foundation::LRESULT {
255    use windows_sys::Win32::UI::Input::KeyboardAndMouse::VK_ESCAPE;
256    use windows_sys::Win32::UI::WindowsAndMessaging::{
257        CallNextHookEx, KBDLLHOOKSTRUCT, PostQuitMessage, WM_KEYDOWN, WM_SYSKEYDOWN,
258    };
259
260    if n_code < 0 {
261        return CallNextHookEx(std::ptr::null_mut(), n_code, w_param, l_param);
262    }
263
264    let msg_id = w_param as u32;
265    if msg_id == WM_KEYDOWN || msg_id == WM_SYSKEYDOWN {
266        let kb = &*(l_param as *const KBDLLHOOKSTRUCT);
267        if kb.vkCode == VK_ESCAPE as u32 {
268            // Phase 5: ESC = cancel-back. The dragged window is
269            // destroyed and the tab reinserts at its original index
270            // in the source workspace.
271            HOOK_CTX.with(|cell| {
272                let ctx_ref = cell.borrow();
273                if let Some(ctx) = ctx_ref.as_ref() {
274                    if *ctx.finalized.borrow() {
275                        return;
276                    }
277                    *ctx.finalized.borrow_mut() = true;
278                    tracing::info!(
279                        target: "dnd:tearoff",
280                        tab_id = %ctx.tab_id,
281                        original_index = %ctx.original_tab_index,
282                        "[dnd:tearoff] ESC pressed — cancel-back to source"
283                    );
284                    crate::events::emit_event_to_window(
285                        &ctx.state,
286                        &ctx.source_label,
287                        "tearoff:cancel-back",
288                        &serde_json::json!({
289                            "tabId": ctx.tab_id,
290                            "fromWsId": ctx.dest_ws_id,
291                            "originalSourceWsId": ctx.source_ws_id,
292                            "draggedWindowLabel": ctx.dragged_label,
293                            "originalIndex": ctx.original_tab_index,
294                            "wasPinned": ctx.was_pinned,
295                            "reason": "esc",
296                        }),
297                    );
298                }
299            });
300            PostQuitMessage(0);
301        }
302    }
303
304    CallNextHookEx(std::ptr::null_mut(), n_code, w_param, l_param)
305}
306
307#[cfg(target_os = "windows")]
308unsafe extern "system" fn low_level_mouse_proc(
309    n_code: i32,
310    w_param: windows_sys::Win32::Foundation::WPARAM,
311    l_param: windows_sys::Win32::Foundation::LPARAM,
312) -> windows_sys::Win32::Foundation::LRESULT {
313    use windows_sys::Win32::UI::WindowsAndMessaging::{
314        CallNextHookEx, MSLLHOOKSTRUCT, WM_LBUTTONUP, WM_MOUSEMOVE,
315    };
316
317    if n_code < 0 {
318        return CallNextHookEx(std::ptr::null_mut(), n_code, w_param, l_param);
319    }
320
321    let msg_id = w_param as u32;
322    let hook_struct = &*(l_param as *const MSLLHOOKSTRUCT);
323    let cursor_x = hook_struct.pt.x;
324    let cursor_y = hook_struct.pt.y;
325
326    match msg_id {
327        WM_MOUSEMOVE => {
328            handle_mouse_move(cursor_x, cursor_y);
329        }
330        WM_LBUTTONUP => {
331            handle_button_up(cursor_x, cursor_y);
332            // Tell our message loop to exit — the move-loop is over.
333            use windows_sys::Win32::UI::WindowsAndMessaging::PostQuitMessage;
334            PostQuitMessage(0);
335        }
336        _ => {}
337    }
338
339    CallNextHookEx(std::ptr::null_mut(), n_code, w_param, l_param)
340}
341
342#[cfg(target_os = "windows")]
343fn handle_mouse_move(cursor_x: i32, cursor_y: i32) {
344    HOOK_CTX.with(|cell| {
345        let ctx_ref = cell.borrow();
346        let Some(ctx) = ctx_ref.as_ref() else {
347            return;
348        };
349
350        // Single browsers-lock acquisition: detect candidate AND
351        // pre-clone the browser handles for prev/next targets in
352        // one critical section. Lock held only here, never spanning
353        // emit_event calls below.
354        let (candidate, prev_browser, next_browser, candidate_changed) = {
355            use cef::Browser;
356            // Phase H.2.b — reducer-aware browser snapshot. Materializes
357            // a HashMap so the existing candidate_label_under_cursor_locked
358            // helper signature stays stable. Collected once per hover tick
359            // (~16 ms cadence); allocation is negligible.
360            let browsers: std::collections::HashMap<String, Browser> = ctx
361                .state
362                .list_browsers()
363                .into_iter()
364                .collect();
365            let candidate = candidate_label_under_cursor_locked(ctx, &browsers, cursor_x, cursor_y);
366            let prev_label = ctx.current_target.borrow().clone();
367            let candidate_changed = prev_label != candidate;
368            let prev_browser: Option<Browser> = if candidate_changed {
369                prev_label.as_ref().and_then(|l| browsers.get(l).cloned())
370            } else {
371                None
372            };
373            let next_browser: Option<Browser> = candidate
374                .as_ref()
375                .and_then(|l| browsers.get(l).cloned());
376            (candidate, prev_browser, next_browser, candidate_changed)
377        };
378
379        // Lock released — emit events without re-locking.
380        if let Some(b) = prev_browser.as_ref() {
381            crate::events::emit_event(b, "tearoff:hover-cleared", &serde_json::json!({}));
382        }
383        // Always emit hover-changed when over a candidate, not just on
384        // candidate-change. The destination's insertion indicator
385        // tracks the cursor X within the strip — without per-move
386        // updates the indicator would lock to wherever the cursor
387        // entered and never slide as the user traverses the strip.
388        // (reagent PR #565 P1)
389        if let Some(b) = next_browser.as_ref() {
390            crate::events::emit_event(
391                b,
392                "tearoff:hover-changed",
393                &serde_json::json!({
394                    "cursorX": cursor_x,
395                    "cursorY": cursor_y,
396                    "tabId": ctx.tab_id,
397                }),
398            );
399        }
400        if candidate_changed {
401            *ctx.current_target.borrow_mut() = candidate;
402        }
403    });
404}
405
406#[cfg(target_os = "windows")]
407fn handle_button_up(cursor_x: i32, cursor_y: i32) {
408    HOOK_CTX.with(|cell| {
409        let ctx_ref = cell.borrow();
410        let Some(ctx) = ctx_ref.as_ref() else {
411            return;
412        };
413        // If a previous handler already finalized (e.g. ESC fired and
414        // posted WM_QUIT, but this mouseup arrived first), bail.
415        if *ctx.finalized.borrow() {
416            return;
417        }
418        *ctx.finalized.borrow_mut() = true;
419
420        // Phase H.2.b — reducer-aware browser snapshot for the finalize
421        // candidate lookup. Same materialize-into-HashMap pattern as
422        // on_mouse_move above.
423        let candidate = {
424            use cef::Browser;
425            let browsers: std::collections::HashMap<String, Browser> = ctx
426                .state
427                .list_browsers()
428                .into_iter()
429                .collect();
430            candidate_label_under_cursor_locked(ctx, &browsers, cursor_x, cursor_y)
431        };
432
433        tracing::info!(
434            target: "dnd:tearoff",
435            tab_id = %ctx.tab_id,
436            cursor_x = %cursor_x,
437            cursor_y = %cursor_y,
438            target = ?candidate,
439            "[dnd:tearoff] mouseup — finalize"
440        );
441
442        match &candidate {
443            Some(target_label) if target_label == &ctx.source_label => {
444                // Phase 5 cancel-back path. The cursor is over the
445                // source window — but candidate_label_under_cursor only
446                // identifies the top-level HWND, not which sub-region
447                // (strip vs content vs sidebar). The frontend's
448                // cancel-back handler does the same strip hit-test
449                // the merge handler does, and falls through to
450                // standalone behaviour if the cursor isn't on the
451                // strip. We pass cursorY so the frontend can decide.
452                tracing::info!(
453                    target: "dnd:tearoff",
454                    tab_id = %ctx.tab_id,
455                    original_index = %ctx.original_tab_index,
456                    "[dnd:tearoff] drop on source window — cancel-back candidate"
457                );
458                crate::events::emit_event_to_window(
459                    &ctx.state,
460                    &ctx.source_label,
461                    "tearoff:cancel-back",
462                    &serde_json::json!({
463                        "tabId": ctx.tab_id,
464                        "fromWsId": ctx.dest_ws_id,
465                        "originalSourceWsId": ctx.source_ws_id,
466                        "draggedWindowLabel": ctx.dragged_label,
467                        "originalIndex": ctx.original_tab_index,
468                        "wasPinned": ctx.was_pinned,
469                        "cursorX": cursor_x,
470                        "cursorY": cursor_y,
471                        "reason": "drop-on-source",
472                    }),
473                );
474            }
475            Some(target_label) => {
476                // Merge path. Tell the candidate's renderer to pull the
477                // tab in from `dest_ws_id` (the temporary workspace the
478                // dragged window owns). The candidate has its own
479                // workspace ID locally and can compute the insertion
480                // index from `cursorX` against its tab strip geometry.
481                // After the merge the candidate calls closeWindowByLabel
482                // on the dragged window to clean up.
483                crate::events::emit_event_to_window(
484                    &ctx.state,
485                    target_label,
486                    "tearoff:merge",
487                    &serde_json::json!({
488                        "tabId": ctx.tab_id,
489                        "fromWsId": ctx.dest_ws_id,
490                        "draggedWindowLabel": ctx.dragged_label,
491                        "cursorX": cursor_x,
492                        "cursorY": cursor_y,
493                    }),
494                );
495            }
496            None => {
497                // Standalone path. The dragged window simply stays
498                // where the user released. Inform the source renderer
499                // (informational only — no UI state to update on the
500                // source side; the tab is already gone).
501                crate::events::emit_event_to_window(
502                    &ctx.state,
503                    &ctx.source_label,
504                    "tearoff:standalone",
505                    &serde_json::json!({
506                        "tabId": ctx.tab_id,
507                        "draggedWindowLabel": ctx.dragged_label,
508                    }),
509                );
510            }
511        }
512    });
513}
514
515/// Find the AgentMux window label whose top-level HWND contains the
516/// cursor position. Excludes the dragged window itself (landing on
517/// the dragged window's strip would be a no-op merge). Takes the
518/// browsers lock guard from the caller so we don't re-acquire on
519/// the WM_MOUSEMOVE hot path.
520#[cfg(target_os = "windows")]
521fn candidate_label_under_cursor_locked(
522    ctx: &HookContext,
523    browsers: &std::collections::HashMap<String, cef::Browser>,
524    x: i32,
525    y: i32,
526) -> Option<String> {
527    use windows_sys::Win32::Foundation::POINT;
528    use windows_sys::Win32::UI::WindowsAndMessaging::{
529        GetAncestor, WindowFromPoint, GA_ROOT,
530    };
531
532    let pt = POINT { x, y };
533    let hwnd = unsafe { WindowFromPoint(pt) };
534    if hwnd.is_null() {
535        return None;
536    }
537    let root = unsafe { GetAncestor(hwnd, GA_ROOT) };
538    let root = if root.is_null() { hwnd } else { root };
539
540    for (label, browser) in browsers.iter() {
541        if label == &ctx.dragged_label {
542            continue;
543        }
544        if !is_instance_label(label) {
545            continue;
546        }
547        use cef::{ImplBrowser, ImplBrowserHost};
548        if let Some(host) = browser.host() {
549            let h = host.window_handle();
550            if !h.0.is_null() && h.0 as *mut std::ffi::c_void == root {
551                return Some(label.clone());
552            }
553        }
554    }
555    None
556}
557
558#[cfg(target_os = "windows")]
559fn is_instance_label(label: &str) -> bool {
560    label == "main" || label.starts_with("window-")
561}