From c5332b2f868d6d8ccfd6109c77c304308087f255 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Sat, 8 Aug 2026 03:52:17 -0500 Subject: [PATCH] fix(desktop): stop the HUD going click-through under its own dialogs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The window decided nothing was there whenever focus left the composer, which is exactly what opening a dialog or clicking a link does — a portalled overlay lives outside the shell, so `:focus-within` goes with it and the window turned mouse-transparent underneath the thing you had just opened. The old hit test only knew about the bar's rectangle, too. It asks the document instead. Everything the HUD deliberately doesn't catch is already `pointer-events: none`, so whatever comes back under the cursor is something real, and focus is read at the document rather than the shell. --- apps/desktop/src/app/hud/click-through.ts | 43 +++++++++++++++-------- 1 file changed, 28 insertions(+), 15 deletions(-) diff --git a/apps/desktop/src/app/hud/click-through.ts b/apps/desktop/src/app/hud/click-through.ts index 1c6eaf9ed93c2..d833529b8b9c2 100644 --- a/apps/desktop/src/app/hud/click-through.ts +++ b/apps/desktop/src/app/hud/click-through.ts @@ -14,10 +14,9 @@ import { type RefObject, useEffect } from 'react' * page-level property, and the click never reaches the page. * * So the window itself is made mouse-transparent except where it is genuinely - * interactive: the bar, always, and everything else only while the composer - * holds focus — the same line the band and its exit chip draw with - * `pointer-events`. `forward: true` keeps mousemove flowing while ignoring, - * which is what lets it re-arm when the cursor comes back to the bar. + * interactive: wherever the cursor is over something, and whenever anything in + * the window holds focus. `forward: true` keeps mousemove flowing while + * ignoring, which is what lets it re-arm when the cursor comes back to the bar. */ export function useHudClickThrough(rootRef: RefObject): void { useEffect(() => { @@ -34,20 +33,34 @@ export function useHudClickThrough(rootRef: RefObject): void // must not make the bar untouchable until you jiggle the mouse). let point: { x: number; y: number } | null = null - const overBar = () => { - const bar = root.querySelector('[data-slot="composer-dock"]') - - if (!bar || !point) { + // Hit-test rather than enumerate. Everything the HUD doesn't want to catch + // — the shell's dead space, the sheet, the faded band — is already + // `pointer-events: none`, so anything the document hands back at this point + // is something real: the bar, a control, the exit chip, a popover, a dialog. + // Listing those instead is how links and dialogs ended up unclickable, since + // portalled overlays live outside the shell and moving focus into one takes + // `:focus-within` with it. + const overSomething = () => { + if (!point) { return false } - const rect = bar.getBoundingClientRect() + const hit = document.elementFromPoint(point.x, point.y) - return point.x >= rect.left && point.x <= rect.right && point.y >= rect.top && point.y <= rect.bottom + return Boolean(hit) && hit !== root && hit !== document.body && hit !== document.documentElement } + // Focus is asked of the document, not of the shell. A dialog or popover is + // portalled to `document.body`, so focus entering one leaves the shell's + // `:focus-within` — and a HUD that decides it is unused the moment it opens + // a dialog goes mouse-transparent underneath it. Nothing but the HUD lives + // in this window, so any focus at all is the HUD in use. `hasFocus` gates it + // so a stale `activeElement` — the composer keeps it after you click away to + // another app — can't pin the HUD solid forever. + const focused = () => document.hasFocus() && document.activeElement !== document.body + const apply = () => { - const next = !root.matches(':focus-within') && !overBar() + const next = !focused() && !overSomething() if (ignoring !== next) { ignoring = next @@ -62,14 +75,14 @@ export function useHudClickThrough(rootRef: RefObject): void apply() window.addEventListener('mousemove', onMove) - root.addEventListener('focusin', apply) - root.addEventListener('focusout', apply) + document.addEventListener('focusin', apply) + document.addEventListener('focusout', apply) return () => { setIgnoreMouse(false) window.removeEventListener('mousemove', onMove) - root.removeEventListener('focusin', apply) - root.removeEventListener('focusout', apply) + document.removeEventListener('focusin', apply) + document.removeEventListener('focusout', apply) } }, [rootRef]) }