fix(desktop): stop HUD window growing on drag; add corner resize handle (#83091)

* fix(desktop): stop HUD window growing on drag; add corner resize handle

The HUD window is created frame:false + transparent:true + resizable:true.
On Windows, a transparent frameless window silently grows ~1px per
setPosition call (worse at >100% DPI scaling) — every drag of the composer
bar accumulated size drift, and the HUD could end up enormous (reported at
1385x1052 against a 620x320 default). Reading the size back mid-drag
compounds the drift because getSize() returns the already-drifted value.

Fix, mirroring the pet overlay's pattern:
- create the HUD window non-resizable (no system edge resize hot-zone)
- moveBy uses setBounds with a size snapshotted on the first move of each
  drag, so the OS can never accumulate drift (verified: 500 moveBy calls
  with zero size change on Electron 40 / Win11 / 175% DPI)
- add a bottom-right corner resize handle (resize-handle.ts) driving a new
  hermes:hud:set-bounds IPC that flips resizable on for the call, restoring
  the ability to resize a window that is otherwise non-resizable

* fix(desktop): pin HUD drag size in renderer, not main-process globals

The superseding pass drops hudDragWidth/hudDragHeight from main: composer
drag snapshots outerWidth/outerHeight when the hold arms (pet overlay
pattern) and passes them on every moveBy. Adds one test for that contract.

Supersedes #82455.

Co-authored-by: Ringo6107 <199014580+Ringo6107@users.noreply.github.com>

* fix(desktop): keep the HUD solid through a corner resize; drop dead handle state

The resize handle's `resizing` flag only fed a CSS rule that restated the
cursor it already had, so nothing pinned the window mid-gesture: click-through
hands the mouse away the moment the growing edge outruns the cursor. Raise the
composer drag's existing `data-hud-grabbing` instead — one flag for "a gesture
owns the window" — and cover it in click-through's tests.

Also drops the hook's always-true `enabled` param and routes teardown through a
`reset` callback, matching composer-drag.ts and clearing the atom-mirrored-ref
lint rule.

---------

Co-authored-by: Ringo6107 <199014580+Ringo6107@users.noreply.github.com>
This commit is contained in:
brooklyn! 2026-08-10 05:05:14 -05:00 committed by GitHub
parent 54e33c95a6
commit 75dad8b15c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 318 additions and 5 deletions

View File

@ -9330,7 +9330,15 @@ function spawnHudWindow(sessionId, profile) {
minHeight: 160,
frame: false,
transparent: true,
resizable: true,
// NOT resizable. A transparent frameless window on Windows keeps a
// system-level edge resize hot-zone while `resizable` is on — the OS
// interprets pointer capture near the edge as a resize gesture, so the
// window grows a few px every drag (worse at >100% DPI scaling). The
// composer drag calls setPosition, which must move the window, not resize
// it. Resizing is done by the renderer's corner handle through
// `hermes:hud:set-bounds`, which flips resizable on for the call — the
// same pattern the pet overlay uses for its wheel-scale.
resizable: false,
movable: true,
minimizable: false,
maximizable: false,
@ -10201,14 +10209,52 @@ ipcMain.on('hermes:hud:move-by', (event, delta) => {
const dx = Number(delta?.x)
const dy = Number(delta?.y)
const width = Number(delta?.width)
const height = Number(delta?.height)
if (!Number.isFinite(dx) || !Number.isFinite(dy)) {
if (!Number.isFinite(dx) || !Number.isFinite(dy) || !Number.isFinite(width) || !Number.isFinite(height)) {
return
}
const [x, y] = hudWindow.getPosition()
hudWindow.setPosition(Math.round(x + dx), Math.round(y + dy))
// setBounds — NOT setPosition: on Windows, a transparent frameless window
// silently grows ~1px per setPosition call (worse at >100% DPI). The renderer
// snapshots outerWidth/outerHeight when the composer drag arms and re-pins
// to that size on every moveBy (same pattern as the pet overlay drag).
hudWindow.setBounds({
x: Math.round(x + dx),
y: Math.round(y + dy),
width: Math.round(width),
height: Math.round(height)
})
})
// Resize from the HUD's corner handle. The window is created non-resizable
// (see spawnHudWindow — a transparent frameless window must not expose a
// system resize hot-zone, or dragging grows it), which on Windows/Linux also
// blocks programmatic setBounds sizing — so briefly flip resizable on while
// the size actually changes, exactly like the pet overlay's wheel-scale does.
ipcMain.on('hermes:hud:set-bounds', (event, bounds) => {
if (!hudWindow || hudWindow.isDestroyed() || event.sender !== hudWindow.webContents || !bounds) {
return
}
const win = hudWindow
const width = Math.max(380, Math.round(Number(bounds.width)))
const height = Math.max(160, Math.round(Number(bounds.height)))
const [curW, curH] = win.getSize()
const resizing = width !== curW || height !== curH
if (resizing && !win.isResizable()) {
win.setResizable(true)
}
win.setBounds({ x: Math.round(Number(bounds.x)), y: Math.round(Number(bounds.y)), width, height })
if (resizing) {
win.setResizable(false)
}
})
// The HUD renderer reporting which session it is on, so the close broadcast
@ -10218,6 +10264,7 @@ ipcMain.on('hermes:hud:session', (event, sessionId) => {
hudSessionId = typeof sessionId === 'string' && sessionId ? sessionId : null
}
})
ipcMain.handle('hermes:hud:close', async () => {
closeHudWindow()

View File

@ -54,6 +54,7 @@ contextBridge.exposeInMainWorld('hermesDesktop', {
close: () => ipcRenderer.invoke('hermes:hud:close'),
setIgnoreMouse: ignore => ipcRenderer.send('hermes:hud:ignore-mouse', ignore),
moveBy: delta => ipcRenderer.send('hermes:hud:move-by', delta),
setBounds: bounds => ipcRenderer.send('hermes:hud:set-bounds', bounds),
setVibrancy: on => ipcRenderer.invoke('hermes:hud:vibrancy', on),
// The HUD tells main which session it is on; main hands that back to the
// app window when the HUD closes, so the app can re-home onto it.

View File

@ -68,4 +68,16 @@ describe('hudIgnoresMouse', () => {
expect(hudIgnoresMouse(shell, mount, document.body, true)).toBe(true)
})
it('stays solid while a gesture owns the window, even once the hit test goes empty', () => {
const { mount, shell } = hud()
const handle = document.createElement('div')
handle.setAttribute('data-hud-grabbing', '')
shell.append(handle)
// The corner resize grows the window out from under the cursor, so the hit
// test reports the scaffolding — handing the mouse away mid-gesture.
expect(hudIgnoresMouse(shell, mount, null, true)).toBe(false)
expect(hudIgnoresMouse(shell, null, null, true)).toBe(false)
})
})

View File

@ -0,0 +1,92 @@
import { act, renderHook } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { useHudComposerDrag } from './composer-drag'
/** Matches LONG_PRESS_MS in composer-drag.ts. */
const LONG_PRESS_MS = 140
const desktopWindow = window as unknown as { hermesDesktop?: Window['hermesDesktop'] }
const initialHermesDesktop = desktopWindow.hermesDesktop
const moveBy = vi.fn()
function setWindowSize(width: number, height: number) {
Object.defineProperty(window, 'outerWidth', { configurable: true, value: width })
Object.defineProperty(window, 'outerHeight', { configurable: true, value: height })
}
/** jsdom has no pointer capture. */
function pressTarget() {
const target = document.createElement('div')
target.setPointerCapture = vi.fn()
target.hasPointerCapture = vi.fn(() => false)
target.releasePointerCapture = vi.fn()
document.body.append(target)
return target
}
beforeEach(() => {
vi.useFakeTimers()
moveBy.mockClear()
setWindowSize(620, 320)
desktopWindow.hermesDesktop = { hud: { moveBy } } as unknown as Window['hermesDesktop']
})
afterEach(() => {
vi.useRealTimers()
document.body.innerHTML = ''
if (initialHermesDesktop) {
desktopWindow.hermesDesktop = initialHermesDesktop
} else {
delete desktopWindow.hermesDesktop
}
})
describe('useHudComposerDrag', () => {
it('sends every move with the size snapshotted at press, so main can pin it', () => {
const target = pressTarget()
const { result } = renderHook(() => useHudComposerDrag(true))
act(() =>
result.current.onPointerDown({
button: 0,
currentTarget: target,
pointerId: 1,
screenX: 100,
screenY: 200
} as never)
)
act(() => void vi.advanceTimersByTime(LONG_PRESS_MS))
act(() => void window.dispatchEvent(new PointerEvent('pointermove', { pointerId: 1, screenX: 110, screenY: 210 })))
expect(moveBy).toHaveBeenCalledWith({ x: 10, y: 10, width: 620, height: 320 })
// A window that drifted wider mid-drag must not feed its new size back in —
// that is exactly how the Windows growth compounded.
setWindowSize(900, 500)
act(() => void window.dispatchEvent(new PointerEvent('pointermove', { pointerId: 1, screenX: 115, screenY: 215 })))
expect(moveBy).toHaveBeenLastCalledWith({ x: 5, y: 5, width: 620, height: 320 })
})
it('does not move the window until the hold arms', () => {
const target = pressTarget()
const { result } = renderHook(() => useHudComposerDrag(true))
act(() =>
result.current.onPointerDown({
button: 0,
currentTarget: target,
pointerId: 1,
screenX: 100,
screenY: 200
} as never)
)
act(() => void window.dispatchEvent(new PointerEvent('pointermove', { pointerId: 1, screenX: 102, screenY: 201 })))
expect(moveBy).not.toHaveBeenCalled()
})
})

View File

@ -12,6 +12,8 @@ interface PressState {
armed: boolean
lastX: number
lastY: number
originH: number
originW: number
pointerId: number
startX: number
startY: number
@ -30,6 +32,10 @@ interface PressState {
* Deltas are read in SCREEN coordinates. Client coordinates are relative to the
* window we are moving, so a window that keeps up with the cursor reports the
* same clientX every frame zero delta, and the drag dies one pixel in.
*
* The size is snapshotted at press and sent with every move, so main can pin it
* (see hermes:hud:move-by a transparent frameless window drifts wider on
* Windows otherwise). Same shape as the pet overlay's drag.
*/
export function useHudComposerDrag(enabled: boolean) {
const [grabbing, setGrabbing] = useState(false)
@ -64,6 +70,8 @@ export function useHudComposerDrag(enabled: boolean) {
armed: false,
lastX: event.screenX,
lastY: event.screenY,
originH: window.outerHeight,
originW: window.outerWidth,
pointerId: event.pointerId,
startX: event.screenX,
startY: event.screenY,
@ -128,7 +136,12 @@ export function useHudComposerDrag(enabled: boolean) {
state.lastX = event.screenX
state.lastY = event.screenY
window.hermesDesktop?.hud?.moveBy?.({ x: dx, y: dy })
window.hermesDesktop?.hud?.moveBy?.({
x: dx,
y: dy,
width: state.originW,
height: state.originH
})
}
const onUp = (event: PointerEvent) => {

View File

@ -18,6 +18,7 @@ import { titlebarButtonClass } from '../shell/titlebar'
import { useHudClickThrough } from './click-through'
import { useHudGlass } from './glass'
import { useHudGoto, useReportHudSession } from './handoff'
import { useHudResizeHandle } from './resize-handle'
import { useHudThreadFocus } from './thread-focus'
/** How long the transcript lingers at its glanceable opacity after a turn
@ -336,6 +337,12 @@ export function HudShell() {
useHudClickThrough(rootRef)
useHudThreadFocus(rootRef)
// Corner resize handle. The window is created non-resizable so dragging can
// never be misread as a resize gesture (the Windows transparent-frameless
// growth bug); the handle is the one sanctioned way to change size, driving
// the same flip-resizable-for-the-call pattern the pet overlay uses.
const { resizing: hudResizing, onPointerDown: onHudResizePointerDown } = useHudResizeHandle()
// Force the HOST layers transparent. index.html's pre-paint script writes an
// opaque themed background onto <html> as an INLINE style (the anti-white-
// flash trick), and an inline style beats any stylesheet rule — so without
@ -393,6 +400,20 @@ export function HudShell() {
<TitlebarIcon name="screen-normal" />
</Button>
</Tip>
{/* The resize handle: bottom-right corner, the one sanctioned way to
change the HUD's size. Invisible chrome a hot corner, not a
button so it never reads as part of the surface. `data-hud-grabbing`
is the same flag the composer drag raises: a gesture in progress owns
the window, so click-through can't hand the mouse away mid-resize
when the growing edge outruns the cursor. */}
<div
aria-hidden
className="absolute bottom-0 right-0 z-20"
data-hud-grabbing={hudResizing ? '' : undefined}
data-hud-resize=""
onPointerDown={onHudResizePointerDown}
/>
</div>
)
}

View File

@ -0,0 +1,109 @@
import { type PointerEvent as ReactPointerEvent, useCallback, useEffect, useRef, useState } from 'react'
/** Clamp to the same mins the window was created with (spawnHudWindow). */
const HUD_MIN_WIDTH = 380
const HUD_MIN_HEIGHT = 160
interface ResizeState {
startX: number
startY: number
originX: number
originY: number
originW: number
originH: number
pointerId: number
}
/**
* HUD-only: drag the corner handle to resize the window.
*
* The window is created `resizable: false` (see spawnHudWindow a transparent
* frameless window must not expose a system resize hot-zone, or every drag
* grows it), so resizing has to be programmatic: the handle reports absolute
* screen bounds and main flips resizable on for the setBounds call. Same
* pattern as the pet overlay's wheel-scale (`hermes:pet-overlay:set-bounds`).
*
* The top-left corner is anchored; only the bottom-right follows the pointer.
* Deltas are read in SCREEN coordinates, like the composer drag: client
* coordinates are relative to a window that is changing size, so they cannot
* be trusted mid-resize.
*/
export function useHudResizeHandle(): {
resizing: boolean
onPointerDown: (event: ReactPointerEvent<HTMLElement>) => void
} {
const [resizing, setResizing] = useState(false)
const stateRef = useRef<ResizeState | null>(null)
const reset = useCallback(() => {
stateRef.current = null
setResizing(false)
}, [])
const onPointerDown = useCallback((event: ReactPointerEvent<HTMLElement>) => {
if (event.button !== 0) {
return
}
stateRef.current = {
startX: event.screenX,
startY: event.screenY,
originX: window.screenX,
originY: window.screenY,
originW: window.outerWidth,
originH: window.outerHeight,
pointerId: event.pointerId
}
setResizing(true)
event.currentTarget.setPointerCapture(event.pointerId)
event.preventDefault()
}, [])
useEffect(() => {
const onMove = (event: PointerEvent) => {
const state = stateRef.current
if (!state || event.pointerId !== state.pointerId) {
return
}
event.preventDefault()
const dx = event.screenX - state.startX
const dy = event.screenY - state.startY
window.hermesDesktop?.hud?.setBounds?.({
x: state.originX,
y: state.originY,
width: Math.max(HUD_MIN_WIDTH, state.originW + dx),
height: Math.max(HUD_MIN_HEIGHT, state.originH + dy)
})
}
const onUp = (event: PointerEvent) => {
const state = stateRef.current
if (!state || event.pointerId !== state.pointerId) {
return
}
reset()
}
window.addEventListener('pointermove', onMove)
window.addEventListener('pointerup', onUp)
window.addEventListener('pointercancel', onUp)
return () => {
window.removeEventListener('pointermove', onMove)
window.removeEventListener('pointerup', onUp)
window.removeEventListener('pointercancel', onUp)
}
}, [reset])
// A resize interrupted by an unmount must not leave the state dangling.
useEffect(() => reset, [reset])
return { resizing, onPointerDown }
}

View File

@ -70,7 +70,8 @@ declare global {
open: (request?: { sessionId?: null | string; profile?: null | string }) => Promise<{ ok: boolean }>
close: () => Promise<{ ok: boolean }>
setIgnoreMouse: (ignore: boolean) => void
moveBy: (delta: { x: number; y: number }) => void
moveBy: (delta: { x: number; y: number; width: number; height: number }) => void
setBounds: (bounds: { x: number; y: number; width: number; height: number }) => void
setVibrancy: (on: boolean) => Promise<{ ok: boolean }>
setSession: (sessionId: null | string) => void
onGoto: (callback: (sessionId: string) => void) => () => void

View File

@ -2883,6 +2883,23 @@ button[data-slot='aui_msg-reactions'] svg {
opacity: 1;
}
/* The corner resize handle a hot corner, not a button. The window is created
non-resizable (the transparent-frameless Windows drag-growth bug), so this
is the one sanctioned way to change the HUD's size; it drives
`hermes:hud:set-bounds`, which flips resizable on for the call.
Deliberately invisible chrome: no glyph, no border the corner of the bar
reads as the affordance, and painting a handle on a surface that lives over
other apps would be a stray UI fragment. It opts in to pointer events the
same way every other HUD control does (the shell defaults to none). */
[data-hud-shell] [data-hud-resize] {
width: 1.25rem;
height: 1.25rem;
cursor: nwse-resize;
pointer-events: auto;
touch-action: none;
}
/* The composer's drop target is a full-window dashed sheet sized for the app's
chat column. In the HUD it is a white slab hanging under the bar on a fresh
thread, and there is nowhere to drop anything into a bar anyway. */