fix(desktop): gate the HUD resize frame's hit test on engagement (#108793)

The resize frame was unconditionally pointer-events: auto, so a
collapsed HUD kept an invisible ring of window — nothing painted,
every click and drag aimed at the app underneath eaten. The frame now
rides the band's engagement gates: the caret in the composer
(data-hud-typing, stamped live by useHudGlass), a held band, or a
solid-input host re-arm it; an in-flight drag keeps its own handle
live via data-hud-grabbing (pointer capture routes the moves).

Direction from #108796 (liuhao1024); behavioral test instead of that
PR's source-reading one.

Co-authored-by: liuhao1024 <noreply@github.com>
This commit is contained in:
Hermes Agent
2026-09-24 20:03:11 -05:00
committed by brooklyn!
parent df293160ce
commit b200d3a690
2 changed files with 112 additions and 2 deletions

View File

@@ -0,0 +1,91 @@
// @vitest-environment jsdom
import { act, cleanup, render } from '@testing-library/react'
import { MemoryRouter } from 'react-router'
import { afterEach, describe, expect, it, vi } from 'vitest'
vi.mock('../contrib/wiring', () => ({ WiredPane: () => null }))
class ResizeObserverStub {
observe() {}
unobserve() {}
disconnect() {}
}
Object.assign(globalThis, { ResizeObserver: ResizeObserverStub })
import { HudShell } from './hud-shell'
/** #108793: the resize frame's hit-testability rides the shell's engagement
* gates (styles.css keys `pointer-events` off them). The contract under test:
* an idle HUD carries NONE of the gates, so the invisible frame falls out of
* the hit test entirely; each arm — caret in the composer, a held band, a
* solid-input host — re-arms it on its own. */
describe('HudShell resize-frame engagement gates', () => {
afterEach(() => {
cleanup()
vi.restoreAllMocks()
delete (window as { hermesDesktop?: unknown }).hermesDesktop
})
const shellOf = (container: HTMLElement) => {
const shell = container.querySelector('[data-hud-shell]')
expect(shell).not.toBeNull()
return shell as HTMLElement
}
it('an idle HUD carries none of the engagement gates the frame keys on', () => {
const { container } = render(
<MemoryRouter>
<HudShell />
</MemoryRouter>
)
const shell = shellOf(container)
expect(shell.hasAttribute('data-hud-typing')).toBe(false)
expect(shell.hasAttribute('data-hud-held')).toBe(false)
expect(shell.getAttribute('data-hud-input')).toBe('click-through')
// The frame itself is mounted (the resize grammar is not conditional) but
// no handle is mid-drag.
const handles = shell.querySelectorAll('[data-hud-resize]')
expect(handles.length).toBeGreaterThan(0)
handles.forEach(handle => {
expect(handle.hasAttribute('data-hud-grabbing')).toBe(false)
})
})
it('focus in the composer arms the typing gate the frame re-arms on', () => {
const { container } = render(
<MemoryRouter>
<HudShell />
</MemoryRouter>
)
const shell = shellOf(container)
// useHudGlass stamps data-hud-typing live from the :focus query — the
// same gate the frost and the scrim run on.
const composer = document.createElement('input')
composer.setAttribute('data-slot', 'composer-rich-input')
act(() => {
shell.appendChild(composer)
composer.focus()
composer.dispatchEvent(new FocusEvent('focusin', { bubbles: true }))
})
expect(shell.hasAttribute('data-hud-typing')).toBe(true)
})
it('a solid-input host keeps the frame live unconditionally', () => {
Object.assign(window, { hermesDesktop: { hud: { windowing: { solid: true } } } })
const { container } = render(
<MemoryRouter>
<HudShell />
</MemoryRouter>
)
expect(shellOf(container).getAttribute('data-hud-input')).toBe('solid')
})
})

View File

@@ -3578,14 +3578,33 @@ button[data-slot='aui_msg-reactions'] svg {
CanvasTTY grammar: four edge strips + four larger corner targets, each with
its native resize cursor and each preserving the opposite edge. Deliberately
invisible chrome: no glyph or border over whatever app is below the HUD. */
invisible chrome: no glyph or border over whatever app is below the HUD.
Invisible chrome that is ALWAYS hit-testable is a dead zone: an idle HUD is
just the bar, the window hands every other point to the app underneath, and
the only thing left answering the cursor was this frame — a thin invisible
ring that swallowed clicks and drags aimed at the app behind it (#108793).
So the frame rides the band's engagement gates: while the HUD is being used
(caret in the composer — data-hud-typing, stamped live by useHudGlass — or a
held band waiting on an answer) its edges may be grabbed; an in-flight
resize keeps its own handle live via data-hud-grabbing, because pointer
capture routes the moves to the handle that started the drag. Solid-input
hosts (X11) never hand the window away in the first place, so their frame
stays live unconditionally. */
[data-hud-shell] [data-hud-resize] {
position: absolute;
z-index: 20;
pointer-events: auto;
pointer-events: none;
touch-action: none;
}
[data-hud-shell][data-hud-typing] [data-hud-resize],
[data-hud-shell][data-hud-held] [data-hud-resize],
[data-hud-shell][data-hud-input='solid'] [data-hud-resize],
[data-hud-shell] [data-hud-resize][data-hud-grabbing] {
pointer-events: auto;
}
[data-hud-shell] [data-hud-resize='n'] {
top: 0;
right: 1.25rem;