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