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:
parent
54e33c95a6
commit
75dad8b15c
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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. */
|
||||
|
|
|
|||
Loading…
Reference in New Issue