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() {