diff --git a/apps/desktop/electron/main.ts b/apps/desktop/electron/main.ts index 77d93bea044b..ddcbb44b7105 100644 --- a/apps/desktop/electron/main.ts +++ b/apps/desktop/electron/main.ts @@ -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() diff --git a/apps/desktop/electron/preload.ts b/apps/desktop/electron/preload.ts index d461aeaeccf9..e1ee4c006127 100644 --- a/apps/desktop/electron/preload.ts +++ b/apps/desktop/electron/preload.ts @@ -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. diff --git a/apps/desktop/src/app/hud/click-through.test.ts b/apps/desktop/src/app/hud/click-through.test.ts index 249286b1319f..45e789492de9 100644 --- a/apps/desktop/src/app/hud/click-through.test.ts +++ b/apps/desktop/src/app/hud/click-through.test.ts @@ -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) + }) }) diff --git a/apps/desktop/src/app/hud/composer-drag.test.ts b/apps/desktop/src/app/hud/composer-drag.test.ts new file mode 100644 index 000000000000..3d60673c911d --- /dev/null +++ b/apps/desktop/src/app/hud/composer-drag.test.ts @@ -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() + }) +}) diff --git a/apps/desktop/src/app/hud/composer-drag.ts b/apps/desktop/src/app/hud/composer-drag.ts index 75756cc0d547..20e1e5c0450c 100644 --- a/apps/desktop/src/app/hud/composer-drag.ts +++ b/apps/desktop/src/app/hud/composer-drag.ts @@ -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) => { diff --git a/apps/desktop/src/app/hud/hud-shell.tsx b/apps/desktop/src/app/hud/hud-shell.tsx index 6585cb4b9c85..2ca5e90f2fe9 100644 --- a/apps/desktop/src/app/hud/hud-shell.tsx +++ b/apps/desktop/src/app/hud/hud-shell.tsx @@ -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 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() { + + {/* 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. */} +
) } diff --git a/apps/desktop/src/app/hud/resize-handle.ts b/apps/desktop/src/app/hud/resize-handle.ts new file mode 100644 index 000000000000..9cb919eb8ca6 --- /dev/null +++ b/apps/desktop/src/app/hud/resize-handle.ts @@ -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) => void +} { + const [resizing, setResizing] = useState(false) + const stateRef = useRef(null) + + const reset = useCallback(() => { + stateRef.current = null + setResizing(false) + }, []) + + const onPointerDown = useCallback((event: ReactPointerEvent) => { + 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 } +} diff --git a/apps/desktop/src/global.d.ts b/apps/desktop/src/global.d.ts index 3816afa69476..661e7b36ceb3 100644 --- a/apps/desktop/src/global.d.ts +++ b/apps/desktop/src/global.d.ts @@ -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 diff --git a/apps/desktop/src/styles.css b/apps/desktop/src/styles.css index 9cd9a70719b4..d6d0bfc815c7 100644 --- a/apps/desktop/src/styles.css +++ b/apps/desktop/src/styles.css @@ -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. */