From 59d9b2ebdd39c7af82f96f2f9482c0ccec2ee10b Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Sat, 18 Apr 2026 10:58:17 +0700 Subject: [PATCH] fix(app): bridge trigger and content in workspace hover card Closes the hover card via a safe-zone hook that covers the trigger, the content, and the rectangular bridge between them, so crossing the visual gap no longer drops the hover. Replaces the event-based close flow whose dropped pointerleave events left cards stuck open. --- .../src/components/workspace-hover-card.tsx | 45 +++++------ packages/app/src/hooks/use-hover-safe-zone.ts | 81 +++++++++++++++++++ 2 files changed, 101 insertions(+), 25 deletions(-) create mode 100644 packages/app/src/hooks/use-hover-safe-zone.ts diff --git a/packages/app/src/components/workspace-hover-card.tsx b/packages/app/src/components/workspace-hover-card.tsx index a42b8baed..730ba442b 100644 --- a/packages/app/src/components/workspace-hover-card.tsx +++ b/packages/app/src/components/workspace-hover-card.tsx @@ -19,6 +19,7 @@ import type { SidebarWorkspaceEntry } from "@/hooks/use-sidebar-workspaces-list" import type { PrHint } from "@/hooks/use-checkout-pr-status-query"; import { openExternalUrl } from "@/utils/open-external-url"; import { PrBadge } from "@/components/sidebar-workspace-list"; +import { useHoverSafeZone } from "@/hooks/use-hover-safe-zone"; interface Rect { x: number; @@ -99,10 +100,10 @@ function WorkspaceHoverCardDesktop({ children, }: PropsWithChildren): ReactElement { const triggerRef = useRef(null); + const contentRef = useRef(null); const [open, setOpen] = useState(false); const graceTimerRef = useRef | null>(null); const triggerHoveredRef = useRef(false); - const contentHoveredRef = useRef(false); const hasContent = prHint !== null || !!workspace.diffStat; @@ -114,14 +115,12 @@ function WorkspaceHoverCardDesktop({ }, []); const scheduleClose = useCallback(() => { - clearGraceTimer(); + if (graceTimerRef.current) return; graceTimerRef.current = setTimeout(() => { - if (!triggerHoveredRef.current && !contentHoveredRef.current) { - setOpen(false); - } graceTimerRef.current = null; + setOpen(false); }, HOVER_GRACE_MS); - }, [clearGraceTimer]); + }, []); const handleTriggerEnter = useCallback(() => { triggerHoveredRef.current = true; @@ -133,18 +132,18 @@ function WorkspaceHoverCardDesktop({ const handleTriggerLeave = useCallback(() => { triggerHoveredRef.current = false; - scheduleClose(); - }, [scheduleClose]); + }, []); - const handleContentEnter = useCallback(() => { - contentHoveredRef.current = true; - clearGraceTimer(); - }, [clearGraceTimer]); - - const handleContentLeave = useCallback(() => { - contentHoveredRef.current = false; - scheduleClose(); - }, [scheduleClose]); + // While open, the safe zone covers trigger + content + the bridge between + // them. Close only fires when the pointer leaves the safe zone; re-entering + // it (including the bridge) cancels the pending close. + useHoverSafeZone({ + enabled: open, + triggerRef, + contentRef, + onEnterSafeZone: clearGraceTimer, + onLeaveSafeZone: scheduleClose, + }); // Close when drag starts useEffect(() => { @@ -182,8 +181,7 @@ function WorkspaceHoverCardDesktop({ workspace={workspace} prHint={prHint} triggerRef={triggerRef} - onContentEnter={handleContentEnter} - onContentLeave={handleContentLeave} + contentRef={contentRef} /> ) : null} @@ -194,14 +192,12 @@ function WorkspaceHoverCardContent({ workspace, prHint, triggerRef, - onContentEnter, - onContentLeave, + contentRef, }: { workspace: SidebarWorkspaceEntry; prHint: PrHint | null; triggerRef: React.RefObject; - onContentEnter: () => void; - onContentLeave: () => void; + contentRef: React.RefObject; }): ReactElement | null { const { theme } = useUnistyles(); const bottomSheetInternal = useBottomSheetModalInternal(true); @@ -250,12 +246,11 @@ function WorkspaceHoverCardContent({ ; + contentRef: RefObject; + onEnterSafeZone: () => void; + onLeaveSafeZone: () => void; +} + +interface RectLike { + left: number; + right: number; + top: number; + bottom: number; +} + +// Tracks the pointer's position relative to a hover card's "safe zone": the +// trigger, the content, and the rectangular bridge between them. The bridge +// lets the pointer cross the visual gap without dropping the hover. Fires +// `onEnterSafeZone` / `onLeaveSafeZone` on transitions only. Web-only; no-op +// on native. +export function useHoverSafeZone({ + enabled, + triggerRef, + contentRef, + onEnterSafeZone, + onLeaveSafeZone, +}: UseHoverSafeZoneParams): void { + useEffect(() => { + if (!isWeb || !enabled) return; + + // The pointer opened the card, so we start inside. + let wasInside = true; + + function handlePointerMove(event: PointerEvent) { + const triggerNode = triggerRef.current as unknown as Element | null; + const contentNode = contentRef.current as unknown as Element | null; + const triggerRect = triggerNode ? triggerNode.getBoundingClientRect() : null; + const contentRect = contentNode ? contentNode.getBoundingClientRect() : null; + + const inside = isInsideSafeZone(triggerRect, contentRect, event.clientX, event.clientY); + if (inside === wasInside) return; + wasInside = inside; + if (inside) onEnterSafeZone(); + else onLeaveSafeZone(); + } + + document.addEventListener("pointermove", handlePointerMove); + return () => { + document.removeEventListener("pointermove", handlePointerMove); + }; + }, [enabled, triggerRef, contentRef, onEnterSafeZone, onLeaveSafeZone]); +} + +function isInsideRect(rect: RectLike | null, x: number, y: number): boolean { + if (!rect) return false; + return x >= rect.left && x <= rect.right && y >= rect.top && y <= rect.bottom; +} + +function isInsideSafeZone( + trigger: RectLike | null, + content: RectLike | null, + x: number, + y: number, +): boolean { + if (isInsideRect(trigger, x, y)) return true; + if (isInsideRect(content, x, y)) return true; + if (!trigger || !content) return false; + + // Bridge: the horizontal strip connecting trigger and content, stretched + // vertically to span both. If they overlap horizontally there's no bridge. + const bridgeLeft = Math.min(trigger.right, content.right); + const bridgeRight = Math.max(trigger.left, content.left); + if (bridgeLeft >= bridgeRight) return false; + const bridgeTop = Math.min(trigger.top, content.top); + const bridgeBottom = Math.max(trigger.bottom, content.bottom); + return x >= bridgeLeft && x <= bridgeRight && y >= bridgeTop && y <= bridgeBottom; +}