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}