From f03fc468459a8539dc2ea419a3d359a4b23c13df Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Sat, 8 Aug 2026 13:08:48 -0500 Subject: [PATCH] fix(desktop): don't frost the HUD window the sheet isn't covering MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The frost is native vibrancy, which is the window's content view rather than an element — it fills the whole rectangle and nothing in the page can clip it to the sheet. That was only ever right while the sheet covered the window; anywhere it falls short, the difference is frost over empty space. On a fresh thread the sheet is zero and the difference is the entire window, which is the grey slab that appears the moment you put the caret in the composer. Gating the caller's `engaged` was not enough, and is why the first attempt at this missed: the hook turns the frost on for a focused composer by itself, independent of what the caller passes. The veto belongs inside, next to that check. --- apps/desktop/src/app/hud/glass.ts | 12 +++++++++--- apps/desktop/src/app/hud/hud-shell.tsx | 18 ++++++++++++------ 2 files changed, 21 insertions(+), 9 deletions(-) diff --git a/apps/desktop/src/app/hud/glass.ts b/apps/desktop/src/app/hud/glass.ts index a12692ef03702..572f68b58f22d 100644 --- a/apps/desktop/src/app/hud/glass.ts +++ b/apps/desktop/src/app/hud/glass.ts @@ -30,8 +30,14 @@ const TYPING_SELECTOR = '[data-slot="composer-rich-input"]:focus' * read as sitting down to use it. Queried live rather than tracked from * document.activeElement, which stays put when the window is blurred and would * latch the frost on forever once the user had ever typed here. + * + * `backing` is the veto over both of those. Because the frost is the window and + * not the sheet, it is only ever right when the sheet covers the window; short + * of that the excess is frost over empty space. Gating the caller's `engaged` + * alone would not do it — focus turns the frost on by itself, which is how a + * brand new thread still frosted its whole empty window. */ -export function useHudGlass(rootRef: RefObject, engaged: boolean): void { +export function useHudGlass(rootRef: RefObject, engaged: boolean, backing: boolean): void { useEffect(() => { const root = rootRef.current const setVibrancy = window.hermesDesktop?.hud?.setVibrancy @@ -43,7 +49,7 @@ export function useHudGlass(rootRef: RefObject, engaged: boo let on: boolean | null = null const apply = () => { - const next = engaged || root.querySelector(TYPING_SELECTOR) !== null + const next = backing && (engaged || root.querySelector(TYPING_SELECTOR) !== null) if (on !== next) { on = next @@ -60,5 +66,5 @@ export function useHudGlass(rootRef: RefObject, engaged: boo root.removeEventListener('focusin', apply) root.removeEventListener('focusout', apply) } - }, [engaged, rootRef]) + }, [backing, engaged, rootRef]) } diff --git a/apps/desktop/src/app/hud/hud-shell.tsx b/apps/desktop/src/app/hud/hud-shell.tsx index 348f3af3a403a..8422ee9f3ad2f 100644 --- a/apps/desktop/src/app/hud/hud-shell.tsx +++ b/apps/desktop/src/app/hud/hud-shell.tsx @@ -9,7 +9,7 @@ import { useI18n } from '@/i18n' import { chatMessageText } from '@/lib/chat-messages' import { closeHud } from '@/store/hud' import { $activeSessionAwaitingInput } from '@/store/prompts' -import { $busy, $messages, $messagesEmpty } from '@/store/session' +import { $busy, $messages } from '@/store/session' import { WiredPane } from '../contrib/wiring' import { titlebarButtonClass } from '../shell/titlebar' @@ -156,10 +156,6 @@ export function HudShell() { const { t } = useI18n() const [recent, holdBand] = useRecentActivity() const held = useHudHeld() - // Nothing to back yet. Vibrancy is the window's whole content view, so on a - // fresh thread it frosts the empty space above the bar into a blank slab — - // the transcript's backing showing up before the transcript does. - const empty = useStore($messagesEmpty) // Main holds the session id on this window's behalf, so leaving HUD mode can // hand the app window back whatever conversation ended up here. @@ -223,6 +219,13 @@ export function HudShell() { // carve-out (styles.css): a band with nothing to scroll stays part of the // window's drag region, so a short conversation never blocks moving the HUD. const [scrollable, setScrollable] = useState(false) + // Whether the sheet reaches the top of the window. Gates the frost, which is + // native vibrancy and therefore the WINDOW's content view — it fills the whole + // rectangle and nothing in the page can clip it to the sheet. Whenever the + // sheet is shorter than the window, the difference is frost over empty space: + // a grey slab hanging under the bar with nothing in it, worst on a fresh + // thread where the sheet is zero and the slab is the entire window. + const [filled, setFilled] = useState(false) const rootRef = useRef(null) useEffect(() => { @@ -274,6 +277,9 @@ export function HudShell() { : box.bottom - rows[0].getBoundingClientRect().top) root.style.setProperty('--hud-band-height', `${Math.max(0, Math.round(span))}px`) + // The sheet is capped at the window (`min(100%, …)`), so reaching the + // window's height is the same question as covering it. + setFilled(span >= window.innerHeight) // …and the bar's real height, which is what the thread has to clear. // --composer-measured-height would be the obvious source, but it is a @@ -302,7 +308,7 @@ export function HudShell() { } }, []) - useHudGlass(rootRef, (recent || held) && !empty) + useHudGlass(rootRef, recent || held, filled) useHudClickThrough(rootRef) // Force the HOST layers transparent. index.html's pre-paint script writes an