feat(overlay): activate main window on orb click (#605) (#611)

* feat(tauri): add activate_main_window command for overlay (#605)

Exposes the existing show_main_window helper as a Tauri command so the
overlay webview can bring the main window to front. The command is
whitelisted in allow-core-process.toml so the overlay window capability
can invoke it.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat(overlay): activate main window on orb click (#605)

The overlay orb had no click behavior. Now clicking it in idle mode
invokes activate_main_window, bringing the main app window to the front
(mirrors the tray icon flow).

Since the overlay is an NSPanel NonactivatingPanel on macOS, React's
synthesized onClick does not fire. Instead we record the press position
on mousedown and emulate click on mouseup when the pointer stayed within
a 4px slop. Dragging is deferred to mousemove past the slop so startDragging
doesn't swallow the mouseup event.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(window): propagate show_main_window errors instead of swallowing them (#605)

`show_main_window` silently logged failures and returned `()`, so the
`activate_main_window` Tauri command could report success on a no-op. Thread
`Result<(), String>` through so JS `invoke().catch()` sees real failures, and
preserve the previous log-on-error behavior at the tray/Reopen call sites
where no caller consumes the result.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(overlay): stabilize orb click vs drag vs double-click (#605)

Two follow-ups to the deferred-drag pattern:

1. Drop stale pressRef when the primary button is no longer held during
   mousemove. Window-drag / focus changes can steal the mouseup, leaving
   the ref populated so the next idle hover would start a spurious drag.

2. Debounce the synthetic click by 250 ms so a follow-up dblclick can
   cancel it — the double-click-to-reset gesture was firing activate +
   reset together. Clear the timer on dblclick and on unmount.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
oxoxDev
2026-04-17 10:10:13 -07:00
committed by GitHub
co-authored by Claude Opus 4.6
parent 0c04314004
commit 5082f3e741
3 changed files with 122 additions and 26 deletions
@@ -13,5 +13,6 @@ allow = [
"service_uninstall_direct",
"register_dictation_hotkey",
"unregister_dictation_hotkey",
"activate_main_window",
]
deny = []
+32 -18
View File
@@ -404,20 +404,27 @@ fn is_daemon_mode() -> bool {
std::env::args().any(|arg| arg == "daemon" || arg == "--daemon")
}
fn show_main_window(app: &AppHandle) {
if let Some(window) = app.get_webview_window("main") {
if let Err(err) = window.show() {
log::error!("[tray] failed to show main window: {err}");
}
if let Err(err) = window.unminimize() {
log::error!("[tray] failed to unminimize main window: {err}");
}
if let Err(err) = window.set_focus() {
log::error!("[tray] failed to focus main window: {err}");
}
} else {
log::error!("[tray] main window not found");
}
/// Tauri command: bring the main window to front from any webview (e.g. overlay orb click).
#[tauri::command]
fn activate_main_window(app: AppHandle) -> Result<(), String> {
log::debug!("[window] activate_main_window called from overlay");
show_main_window(&app)
}
fn show_main_window(app: &AppHandle) -> Result<(), String> {
let window = app
.get_webview_window("main")
.ok_or_else(|| "main window not found".to_string())?;
window
.show()
.map_err(|err| format!("failed to show main window: {err}"))?;
window
.unminimize()
.map_err(|err| format!("failed to unminimize main window: {err}"))?;
window
.set_focus()
.map_err(|err| format!("failed to focus main window: {err}"))?;
Ok(())
}
fn setup_tray(app: &AppHandle) -> tauri::Result<()> {
@@ -444,7 +451,9 @@ fn setup_tray(app: &AppHandle) -> tauri::Result<()> {
.on_menu_event(|app, event| match event.id().as_ref() {
"tray_show_window" => {
log::info!("[tray] action=show_window source=menu");
show_main_window(app);
if let Err(err) = show_main_window(app) {
log::error!("[tray] failed to show main window from menu: {err}");
}
}
"tray_quit" => {
log::info!("[tray] action=quit source=menu");
@@ -460,7 +469,9 @@ fn setup_tray(app: &AppHandle) -> tauri::Result<()> {
} = event
{
log::info!("[tray] action=show_window source=left_click");
show_main_window(tray.app_handle());
if let Err(err) = show_main_window(tray.app_handle()) {
log::error!("[tray] failed to show main window from tray click: {err}");
}
}
})
.build(app)?;
@@ -567,7 +578,8 @@ pub fn run() {
service_status_direct,
service_uninstall_direct,
register_dictation_hotkey,
unregister_dictation_hotkey
unregister_dictation_hotkey,
activate_main_window
])
.build(tauri::generate_context!())
.expect("error while building tauri application")
@@ -589,7 +601,9 @@ pub fn run() {
#[cfg(target_os = "macos")]
RunEvent::Reopen { .. } => {
log::info!("[window] reopen event — showing main window");
show_main_window(app_handle);
if let Err(err) = show_main_window(app_handle) {
log::error!("[macos] failed to show main window on reopen: {err}");
}
}
RunEvent::Exit => {
if let Some(core) = app_handle.try_state::<core_process::CoreProcessHandle>() {
+89 -8
View File
@@ -194,6 +194,18 @@ export default function OverlayApp() {
setBubble(null);
}, [clearDismissTimer]);
/** Click handler for the orb: idle → bring main window to front; active → dismiss bubble. */
const handleOrbClick = useCallback(() => {
if (mode === 'idle') {
console.debug('[overlay] orb clicked while idle — activating main window');
invoke('activate_main_window').catch(err => {
console.error('[overlay] failed to activate main window:', err);
});
} else {
goIdle();
}
}, [mode, goIdle]);
// ── Dictation: pressed / released ──────────────────────────────────────
const handleDictationToggle = useCallback(
(payload: DictationTogglePayload) => {
@@ -420,16 +432,43 @@ export default function OverlayApp() {
userDraggedRef.current = false;
}, []);
/** Initiate native window drag on mouse-down. */
const handleDragStart = useCallback(
// NSPanel (non-activating overlay) doesn't deliver synthesized `click`
// events to the webview, and calling `startDragging()` eagerly on
// mouse-down blocks `mouseup` from firing. We instead arm the drag only
// after the pointer moves past a small threshold, so a pure click fires
// `mouseup` normally and we can activate the main window there.
const pressRef = useRef<{ x: number; y: number; dragStarted: boolean } | null>(null);
const CLICK_SLOP_PX = 4;
/** Pending single-click, deferred so a follow-up double-click can cancel it. */
const clickTimerRef = useRef<number | null>(null);
const CLICK_DOUBLE_CLICK_DELAY_MS = 250;
/** Record mouse-down position; defer drag until the pointer actually moves. */
const handleDragStart = useCallback((e: React.MouseEvent) => {
if (e.button !== 0) return;
pressRef.current = { x: e.screenX, y: e.screenY, dragStarted: false };
}, []);
/** If pointer moves past the slop, escalate into a native window drag. */
const handleMouseMove = useCallback(
async (e: React.MouseEvent) => {
// Only drag on primary button; ignore if a click handler should fire
if (e.button !== 0) return;
e.preventDefault();
const press = pressRef.current;
if (!press) return;
// If the primary button is no longer held, a prior mouseup was missed
// (window-drag steals it, focus change, etc). Drop the stale press so
// we don't spuriously start a drag on a hover.
if ((e.buttons & 1) === 0) {
pressRef.current = null;
return;
}
if (press.dragStarted) return;
const dx = Math.abs(e.screenX - press.x);
const dy = Math.abs(e.screenY - press.y);
if (dx <= CLICK_SLOP_PX && dy <= CLICK_SLOP_PX) return;
press.dragStarted = true;
try {
const appWindow = getCurrentWindow();
await appWindow.startDragging();
// After the drag completes, persist the new position
void persistPosition();
} catch {
// startDragging can fail if not supported — fall through silently
@@ -438,6 +477,47 @@ export default function OverlayApp() {
[persistPosition]
);
/**
* On mouse-up, treat as a click if no drag was initiated. Emulates
* `onClick` for the non-activating panel. The click is deferred briefly
* so a follow-up `dblclick` (used to reset position) can cancel it.
*/
const handleMouseUp = useCallback(
(e: React.MouseEvent) => {
const press = pressRef.current;
pressRef.current = null;
if (e.button !== 0 || !press) return;
if (press.dragStarted) return;
if (clickTimerRef.current !== null) {
window.clearTimeout(clickTimerRef.current);
}
clickTimerRef.current = window.setTimeout(() => {
clickTimerRef.current = null;
console.debug('[overlay] orb mouseup → click');
handleOrbClick();
}, CLICK_DOUBLE_CLICK_DELAY_MS);
},
[handleOrbClick]
);
/** Double-click resets position — cancel any pending single-click first. */
const handleDoubleClick = useCallback(() => {
if (clickTimerRef.current !== null) {
window.clearTimeout(clickTimerRef.current);
clickTimerRef.current = null;
}
resetPosition();
}, [resetPosition]);
useEffect(() => {
return () => {
if (clickTimerRef.current !== null) {
window.clearTimeout(clickTimerRef.current);
clickTimerRef.current = null;
}
};
}, []);
useEffect(() => {
const appWindow = getCurrentWindow();
const isActive = status === 'active';
@@ -551,9 +631,10 @@ export default function OverlayApp() {
? 'Attention message'
: 'OpenHuman overlay'
}
onClick={goIdle}
onMouseDown={handleDragStart}
onDoubleClick={resetPosition}
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
onDoubleClick={handleDoubleClick}
onMouseEnter={() => {
setIsHovered(true);
}}