Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 50 additions & 3 deletions apps/desktop/electron/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand All @@ -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()

Expand Down
1 change: 1 addition & 0 deletions apps/desktop/electron/preload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
12 changes: 12 additions & 0 deletions apps/desktop/src/app/hud/click-through.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
})
})
92 changes: 92 additions & 0 deletions apps/desktop/src/app/hud/composer-drag.test.ts
Original file line number Diff line number Diff line change
@@ -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()
})
})
15 changes: 14 additions & 1 deletion apps/desktop/src/app/hud/composer-drag.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ interface PressState {
armed: boolean
lastX: number
lastY: number
originH: number
originW: number
pointerId: number
startX: number
startY: number
Expand All @@ -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)
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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) => {
Expand Down
21 changes: 21 additions & 0 deletions apps/desktop/src/app/hud/hud-shell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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>
)
}
109 changes: 109 additions & 0 deletions apps/desktop/src/app/hud/resize-handle.ts
Original file line number Diff line number Diff line change
@@ -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 }
}
Loading
Loading