From ef75b0086a03cc77b8ee0d9c26546bc73831c858 Mon Sep 17 00:00:00 2001 From: Arjun <6592213+arkml@users.noreply.github.com> Date: Fri, 21 Aug 2026 14:17:14 +0530 Subject: [PATCH] fix(x): the hover companion stops eating the desktop around it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The companion frame is far bigger than anything it paints: a tall transparent stage sits above the card so popovers can open upward without resizing, and the tucked Skipper is just the mascot in that same frame. But transparency is only PAINT — macOS routes a click to the topmost window by its RECT, not by pixel alpha — so that stage swallowed every click that landed on it: a ~500px square of dead desktop around the companion, and an invisible drag area that moved the Skipper when you pressed on what looked like empty space. The window is now created click-through and turns solid only while the cursor is over something actually drawn. Main polls the cursor from the OS and hands the point to the renderer, which hit-tests the DOM: mouse events are not a witness we can use here, because on macOS a drag region is a native view layered over the page, so moves across it never reach the renderer at all — and the mascot IS the drag handle. Driven by events alone it could be neither clicked nor dragged. The hit test is inverted on purpose: only the frame's own containers are marked passthrough, so anything else under the cursor — including menus portaled to , and anything added later — stays clickable by default. Wrong in that direction costs a dead pixel; wrong the other way costs an unclickable control. The mascot column is deliberately solid across its whole 132px footprint, so the Skipper is still grabbable anywhere on it. Folding the card no longer resizes the window either. The frame is bottom-right anchored, so shrinking a 504px square to a 225px one moved its origin by 279px, and on a transparent window the OS frame change and Chromium's repaint of the newly-sized viewport are not atomic — the mascot was composited against the wrong geometry for a frame or two, flashing well above where it lands. There is nothing left to shrink now that the space the card vacates is click-through. The pill keeps its tuck resize: it folds to a genuinely different layout, which only lands right in mascot-sized bounds. Co-Authored-By: Claude Opus 5 (1M context) --- apps/x/apps/main/src/ipc.ts | 5 + apps/x/apps/main/src/quick-ask.ts | 161 +++++++++++++++--- .../renderer/src/components/quick-ask-bar.tsx | 132 ++++++++++++-- apps/x/packages/shared/src/ipc.ts | 21 +++ 4 files changed, 288 insertions(+), 31 deletions(-) diff --git a/apps/x/apps/main/src/ipc.ts b/apps/x/apps/main/src/ipc.ts index 6e0e2eb74..a272cbcdc 100644 --- a/apps/x/apps/main/src/ipc.ts +++ b/apps/x/apps/main/src/ipc.ts @@ -42,6 +42,7 @@ import { resizeCompanionPinned, setCompanionPinned, setPinnedCollapsed, + setCompanionInteractive, setQuickAskShortcut, setShortcutCaptureActive, } from './quick-ask.js'; @@ -1203,6 +1204,10 @@ export function setupIpcHandlers() { setPinnedCollapsed(args.collapsed); return {}; }, + 'quickAsk:setInteractive': async (_event, args) => { + setCompanionInteractive(args.interactive); + return {}; + }, 'quickAsk:chatContext': async (_event, args) => { pushChatContext(args); return {}; diff --git a/apps/x/apps/main/src/quick-ask.ts b/apps/x/apps/main/src/quick-ask.ts index a1b91d2a9..67b636776 100644 --- a/apps/x/apps/main/src/quick-ask.ts +++ b/apps/x/apps/main/src/quick-ask.ts @@ -173,6 +173,105 @@ export function onAppWindowClosed() { let skipperCorner: { x: number; y: number } | null = null; let applyingBounds = false; +// --- Click-through --- +// The frame is far bigger than anything it paints: the card sits at the +// bottom with a tall transparent stage above it (so popovers open upward +// without resizing), and the tucked Skipper is just the mascot in that same +// frame. Transparency is only PAINT — macOS routes a click to the topmost +// window by its RECT, not by pixel alpha — so without this the invisible +// stage swallowed every click that landed on it: a ~500px square of dead +// desktop around the companion. The window is created click-through and the +// renderer flips it solid while the cursor is over something actually +// painted (quickAsk:setInteractive), using `forward` so mouse MOVES keep +// arriving while it is click-through — that is what makes the flip +// possible. +let companionInteractive = false; + +function applyInteractive(win: BrowserWindow, next: boolean) { + if (next === companionInteractive) return; + companionInteractive = next; + win.setIgnoreMouseEvents(!next, { forward: true }); +} + +/** + * The renderer's hit-test verdict: is the cursor over painted UI? Only a + * pinned companion may hold the cursor — anything else re-arms + * click-through, so a stale hover (window hidden mid-move, renderer + * reloaded) can never leave a dead rectangle behind. + */ +export function setCompanionInteractive(interactive: boolean) { + const win = getQuickAskWindow(); + if (!win) return; + applyInteractive(win, interactive && mode === 'pinned'); +} + +/** Re-arm click-through (leaving the pinned role, window going away). */ +function releaseMouse() { + const win = getQuickAskWindow(); + if (win) applyInteractive(win, false); +} + +// Where the cursor is, polled from the OS while the companion is up. +// +// Mouse EVENTS are not a reliable witness for this: on macOS a +// `-webkit-app-region: drag` area is a native view layered over the page, so +// moves across it never reach the renderer — and the mascot is exactly that +// area (it is the drag handle). Driven by events alone the mascot would stay +// click-through: neither clickable nor draggable, the one thing the user +// reaches for most. The OS always knows where the pointer is, so main asks +// it and hands the point to the renderer, which is the only side that knows +// whether that point is over paint. +let cursorWatch: ReturnType | null = null; +let cursorWasInside = false; +// Fast enough that the window is always solid by the time a hand that has +// arrived somewhere presses the button, slow enough to be free. +const CURSOR_WATCH_MS = 40; + +function pollCursor() { + const win = getQuickAskWindow(); + if (!win || mode !== 'pinned') { + // The role is gone — the belt for the braces in setCompanionPinned. + // This is the ONLY place main decides the flag on its own; everywhere + // else the renderer is the single authority, so its cached verdict can + // never drift out of sync with the window. (Leaving the pinned role + // also deactivates the hook, which resets that cache.) + stopCursorWatch(); + releaseMouse(); + return; + } + // Hidden: nothing is painted to hit-test and no click can reach the + // window anyway, so leave the flag alone rather than desyncing the + // renderer's cache. Keep polling — a re-show picks straight back up. + if (!win.isVisible()) return; + const p = screen.getCursorScreenPoint(); + const b = win.getBounds(); + const inside = p.x >= b.x && p.x < b.x + b.width && p.y >= b.y && p.y < b.y + b.height; + // Outside and already known to be outside: nothing to say. The one push + // AS it leaves carries an out-of-viewport point, which is how the + // renderer knows to hand the mouse back. + if (!inside && !cursorWasInside) return; + cursorWasInside = inside; + // Window bounds are DIP; the page is zoomed by SCALE, so its own CSS + // pixels are DIP / SCALE. + win.webContents.send('quick-ask:cursor', { + x: (p.x - b.x) / SCALE, + y: (p.y - b.y) / SCALE, + }); +} + +function startCursorWatch() { + if (cursorWatch) return; + cursorWasInside = false; + cursorWatch = setInterval(pollCursor, CURSOR_WATCH_MS); +} + +function stopCursorWatch() { + if (!cursorWatch) return; + clearInterval(cursorWatch); + cursorWatch = null; + cursorWasInside = false; +} + function setBoundsGuarded(win: BrowserWindow, bounds: Electron.Rectangle) { applyingBounds = true; win.setBounds(bounds); @@ -348,6 +447,11 @@ function createWindow(): BrowserWindow { preload: preloadPath, }, }); + // Click-through until the renderer says the cursor is over paint (see + // `applyInteractive`) — the transparent stage must never eat a click + // meant for whatever the user has underneath it. + win.setIgnoreMouseEvents(true, { forward: true }); + companionInteractive = false; // Float over fullscreen Spaces too, keeping the Dock icon // (skipTransformProcessType — without it, visibleOnFullScreen turns the // app into a macOS "agent" app while the window exists). macOS concepts — @@ -369,6 +473,8 @@ function createWindow(): BrowserWindow { }); win.on('closed', () => { if (quickAskWin === win) quickAskWin = null; + companionInteractive = false; + stopCursorWatch(); }); // Zoom factor resets on navigation — apply it once the page is in, and // replay the state the renderer needs to pick up where things stand. (The @@ -481,11 +587,14 @@ export function setCompanionPinned(pinned: boolean) { // shows the other. Recreate the window if it was destroyed — a live // call must never be left with no surface at all. const win0 = getQuickAskWindow() ?? createWindow(); - if (pinnedCollapsed) positionTucked(win0); + // A folded CARD keeps the expanded frame (see setPinnedCollapsed) — + // only the pill has tucked bounds of its own. + if (pinnedCollapsed && getExpandedSurface() !== 'card') positionTucked(win0); else applyExpandedSurface(win0, getExpandedSurface()); const seq0 = pushMode(win0); if (lastPopoutState) win0.webContents.send('video:popout-state', lastPopoutState); revealAfterMode(win0, seq0, { focus: false }); + startCursorWatch(); return; } let win = getQuickAskWindow(); @@ -521,6 +630,7 @@ export function setCompanionPinned(pinned: boolean) { // focus from the app the user switched to — that would be a focus grab // mid-work. revealAfterMode(win, seq, { focus: fromSummon }); + startCursorWatch(); } else { if (mode !== 'pinned') return; setMode('hidden', 'unpinned'); @@ -533,6 +643,8 @@ export function setCompanionPinned(pinned: boolean) { if (win) { pushMode(win); cancelPendingPaint(); + stopCursorWatch(); + releaseMouse(); if (win.isVisible()) win.hide(); } } @@ -555,27 +667,38 @@ export function setPinnedCollapsed(collapsed: boolean) { // and idempotent. pinnedCollapsed = collapsed; if (collapsed) { - // Fold: push the layout FIRST and shrink the window once it's painted — - // shrinking first squeezes the still-open card into the mascot-sized - // bounds for a frame (every control looks dead). The mascot sits at - // the anchor corner in both layouts, so the shrink itself is invisible. + // Fold the CARD: the frame does not change at all. The card simply + // stops painting beside the mascot and the space it leaves is + // click-through, so there is nothing to shrink — and nothing to flash. + // (Shrinking here is what made the fold flicker: the frame is + // bottom-right anchored, so a 504px square becoming a 225px one moves + // its origin by 279px. On a transparent window the OS frame change and + // Chromium's repaint of the newly-sized viewport are not atomic, so for + // a frame or two the tucked layout was composited against the other + // geometry — the mascot appearing well above where it lands.) const seq = pushMode(win); + // Decided on the surface just PUSHED (not the one whose geometry is + // currently applied), so the bounds always match the layout the + // renderer is about to paint — the two can disagree for a tick when a + // device flips mid-fold. + if (getExpandedSurface() === 'card') return; + // The PILL still resizes: it folds to a DIFFERENT layout (the centered + // TuckedMascot), which only lands right in mascot-sized bounds. Push + // the layout FIRST and shrink once it's painted — shrinking first + // squeezes the still-open pill into those bounds for a frame (every + // control looks dead). afterModePainted(win, seq, () => { if (mode !== 'pinned' || !pinnedCollapsed) return; - if (appliedExpandedSurface === 'card') { - positionTucked(win); - } else { - const b = win.getBounds(); - const wa = screen.getDisplayMatching(b).workArea; - const w = scaled(TUCKED_WIDTH); - const h = scaled(TUCKED_HEIGHT); - const inTopHalf = b.y + b.height / 2 < wa.y + wa.height / 2; - let x = b.x + b.width - w; - let y = inTopHalf ? b.y : b.y + b.height - h; - x = Math.max(wa.x + 8, Math.min(x, wa.x + wa.width - w - 8)); - y = Math.max(wa.y + 8, Math.min(y, wa.y + wa.height - h - 8)); - setBoundsGuarded(win, { x, y, width: w, height: h }); - } + const b = win.getBounds(); + const wa = screen.getDisplayMatching(b).workArea; + const w = scaled(TUCKED_WIDTH); + const h = scaled(TUCKED_HEIGHT); + const inTopHalf = b.y + b.height / 2 < wa.y + wa.height / 2; + let x = b.x + b.width - w; + let y = inTopHalf ? b.y : b.y + b.height - h; + x = Math.max(wa.x + 8, Math.min(x, wa.x + wa.width - w - 8)); + y = Math.max(wa.y + 8, Math.min(y, wa.y + wa.height - h - 8)); + setBoundsGuarded(win, { x, y, width: w, height: h }); }); return; } diff --git a/apps/x/apps/renderer/src/components/quick-ask-bar.tsx b/apps/x/apps/renderer/src/components/quick-ask-bar.tsx index 0097ad6cc..30477745e 100644 --- a/apps/x/apps/renderer/src/components/quick-ask-bar.tsx +++ b/apps/x/apps/renderer/src/components/quick-ask-bar.tsx @@ -203,6 +203,9 @@ export function QuickAskBar() { const surface = role?.surface ?? 'card' // The Skipper's text panel is open (mascot + card, the default landing). const callCard = pinned && !collapsed && surface === 'card' + // The frame is mostly transparent stage — hand the clicks that land on it + // back to whatever the user has underneath. + useClickThrough(pinned) // Mirrors callState.speakerMuted for the fold callback below (which is // deliberately dependency-free). @@ -228,6 +231,10 @@ export function QuickAskBar() { // stage. Tuck-on-stage-click only counts near the card — clicks in // visually-empty space must not steal the panel. const cardRef = useRef(null) + // Reached only where the window is still SOLID, i.e. the grace ring just + // outside the card (useClickThrough) — further out the click belongs to + // whatever is behind us. The band stays generous so the gesture never + // depends on the ring's exact width. const TUCK_BAND_PX = 80 const stageTuck = useCallback((e: React.MouseEvent) => { const card = cardRef.current?.getBoundingClientRect() @@ -575,15 +582,20 @@ export function QuickAskBar() { // is open or folded, so fold/unfold only adds/removes the card beside it // and the mascot never moves, resizes, or replays its entry animation. return ( -
- {/* The invisible stage: popovers open into this zone. With the text - open, only clicks NEAR the visible card tuck the panel (stageTuck - hit-test) — the rest of the invisible frame is inert, so clicking - what looks like empty desktop never steals the panel. Folded, the - stage is a drag area, part of "carry it around". */} +
+ {/* The invisible stage: popovers open into this zone. It is marked + passthrough, so clicks that land on it go to whatever the user has + BEHIND this window (useClickThrough) instead of being swallowed by + a transparent rectangle. The only gesture it still carries is + tucking the panel, and only NEAR the visible card (stageTuck + hit-test) — reachable because the grace ring keeps the window + solid just outside the card's edge. (It used to be a drag region + when folded; the mascot column is the drag handle in both states, + and a screen-sized invisible drag area is exactly how a click on + empty desktop ended up moving the Skipper.) */}
@@ -593,9 +605,9 @@ export function QuickAskBar() { a grey rectangle around the card). The paddings are IDENTICAL in both states — with the corner-anchored window, that pins the mascot to the exact same screen pixels across fold/unfold. */} -
+
{!collapsed && ( -
+
{/* Light skin (#810): near-white card, hairline dark border, dark text. The window's native shadow is off (it would outline the whole transparent frame) — the card draws its own. */} @@ -856,7 +868,10 @@ export function QuickAskBar() { bottom-right corner — which the corner-anchored window keeps fixed on screen, so fold/unfold moves NOTHING here; only the card beside it comes and goes. It is the control surface AND the drag - handle. */} + handle — which is why the column is deliberately NOT marked + passthrough (useClickThrough): the whole 132px footprint stays + solid so the Skipper can be grabbed anywhere on it, exactly as + before, instead of only where the artwork happens to paint. */}
, { label: string; speaking: { label: 'Speaking', dotClass: 'bg-sky-400 animate-pulse' }, } +/** + * Marks a container that only ever covers EMPTY space — the transparent + * frame's own scaffolding. See `useClickThrough`. + */ +const PASSTHROUGH_ATTR = 'data-qa-passthrough' + +/** + * Per-region click-through for the transparent frame. + * + * The window is far bigger than anything it paints: a tall invisible stage + * sits above the card so popovers can open upward without resizing, and the + * tucked Skipper is just the mascot in that same frame. But a transparent + * pixel is still a CLICKABLE pixel — macOS routes a click to the topmost + * window by its RECT, not by alpha — so that stage used to swallow every + * click that landed on it: a ~500px square of dead desktop. + * + * Main therefore keeps the window click-through and this hook flips it solid + * while the cursor is over something actually drawn. + * + * The cursor position comes from MAIN (`quick-ask:cursor`, polled from the + * OS), not from mouse events. Events cannot be trusted for this: on macOS a + * `-webkit-app-region: drag` area is a native view layered over the page, so + * moves across it never reach us — and the mascot is exactly that area. Off + * events alone it stayed click-through, so the Skipper could be neither + * clicked nor dragged. Local mousemoves are still handled, purely because + * they arrive sooner than the next poll where they do arrive at all. + * + * The test is INVERTED on purpose: only the frame's own containers are + * marked passthrough, so anything else under the cursor — including menus + * portaled to , and anything added later — counts as solid and stays + * clickable by default. Getting it wrong that way costs a dead pixel; + * getting it wrong the other way costs an unclickable control. + */ +function useClickThrough(active: boolean) { + useEffect(() => { + if (!active) return + let sent: boolean | null = null + const push = (interactive: boolean) => { + if (interactive === sent) return + sent = interactive + void window.ipc.invoke('quickAsk:setInteractive', { interactive }).catch(() => {}) + } + const solidAt = (x: number, y: number) => { + const el = document.elementFromPoint(x, y) + if (!el || el === document.documentElement || el === document.body) return false + if (el.id === 'root') return false + return !el.hasAttribute(PASSTHROUGH_ATTR) + } + // The flip is an IPC round-trip, so turn solid slightly BEFORE the + // cursor reaches paint: a fast move landing straight on a control must + // not have its click fall through the window. + const GRACE = 12 + // A menu, picker or dialog is open somewhere: stay solid wherever the + // cursor is, or the click that should DISMISS it would land in the app + // behind us and leave it open. Tooltips are excluded — they carry no + // dismiss gesture, and they are on screen exactly while the cursor is + // already over a control. + const dismissableOpen = () => + Array.from(document.querySelectorAll('[data-radix-popper-content-wrapper]')).some( + (wrapper) => !wrapper.querySelector('[role="tooltip"]'), + ) + const evaluate = (x: number, y: number) => { + // The cursor left the frame (main pushes one out-of-viewport point as + // it goes): hand the mouse straight back. Checked before anything + // else so the grace ring can't hold the window solid on the way out. + if (x < 0 || y < 0 || x > window.innerWidth || y > window.innerHeight) { + push(false) + return + } + if (dismissableOpen()) { + push(true) + return + } + push( + solidAt(x, y) || + solidAt(x - GRACE, y) || + solidAt(x + GRACE, y) || + solidAt(x, y - GRACE) || + solidAt(x, y + GRACE), + ) + } + const onMove = (e: MouseEvent) => evaluate(e.clientX, e.clientY) + const offCursor = window.ipc.on('quick-ask:cursor', (p) => evaluate(p.x, p.y)) + document.addEventListener('mousemove', onMove, true) + return () => { + offCursor() + document.removeEventListener('mousemove', onMove, true) + push(false) + } + }, [active]) +} + const dragRegion = { WebkitAppRegion: 'drag' } as React.CSSProperties const noDragRegion = { WebkitAppRegion: 'no-drag' } as React.CSSProperties @@ -1564,6 +1671,7 @@ function TuckedMascot({ return (
@@ -1616,13 +1724,13 @@ function TuckedMascot({
{/* Caption + status chip, readable over any desktop. */} -
+
{caption && ( {caption} )}
{/* Pure status line — the CONTROLS are the pins. */} -
+
diff --git a/apps/x/packages/shared/src/ipc.ts b/apps/x/packages/shared/src/ipc.ts index 910a720a7..80c2d5195 100644 --- a/apps/x/packages/shared/src/ipc.ts +++ b/apps/x/packages/shared/src/ipc.ts @@ -1392,6 +1392,27 @@ const ipcSchemas = { req: z.object({ collapsed: z.boolean() }), res: z.object({}), }, + // Companion → main: per-region click-through. The companion frame is far + // bigger than anything it paints (a tall transparent stage above the card + // so popovers can open upward), and transparency is only PAINT — the OS + // routes a click by the window rect — so the window is click-through by + // default and the renderer flips it solid while the cursor is actually + // over painted UI. Without this the invisible stage swallowed every click + // that landed on it. + 'quickAsk:setInteractive': { + req: z.object({ interactive: z.boolean() }), + res: z.object({}), + }, + // Main → companion: where the cursor is, in the window's own CSS pixels. + // Main polls it from the OS because mouse events are NOT a reliable + // witness here: macOS drag regions (the mascot IS one — it's the drag + // handle) are native views layered over the page, so moves across them + // never reach the renderer at all. The renderer hit-tests this point and + // answers on quickAsk:setInteractive. + 'quick-ask:cursor': { + req: z.object({ x: z.number(), y: z.number() }), + res: z.null(), + }, // (The old quickAsk:setTextMode / quick-ask:text-mode channels are gone: // whether a reply is SPOKEN now follows the question's modality — spoken // questions get spoken replies, typed ones stay silent — plus the