From 4500b43914de4ef6fec8dc13f3a1189b43fd674d Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Sat, 8 Aug 2026 03:52:17 -0500 Subject: [PATCH] feat(desktop): frost the HUD band and fade it in three states MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The band is real macOS vibrancy now rather than backdrop-filter, which reaches nothing in a transparent window — its backdrop root is the document, and the desktop was never in it. Vibrancy composites below the web contents, so it can see the desktop, and it can't be masked or clipped from the page. That rules out the gradient the band used to carry and settles it as a flat panel: uniform tint, uniform frost. The fade does the work the gradient was doing. A landing turn brings the transcript half way up to be glanced at, focus promotes it to properly readable, and the hold takes it back down and then away — the panel sliding behind the bar as the last of the text goes. It only fades from an idle transcript. A running turn or a question waiting on you holds it open, because a prompt that fades out is one you can neither read nor answer, and the hold timer alone would expire through a long tool call that prints nothing. --- apps/desktop/electron/main.ts | 21 ++- apps/desktop/src/app/hud/glass.ts | 64 +++++++ apps/desktop/src/app/hud/hud-shell.tsx | 138 +++++++++++---- apps/desktop/src/styles.css | 221 ++++++++++++------------- 4 files changed, 291 insertions(+), 153 deletions(-) create mode 100644 apps/desktop/src/app/hud/glass.ts diff --git a/apps/desktop/electron/main.ts b/apps/desktop/electron/main.ts index 7be534f59b347..5cc26eceaed9e 100644 --- a/apps/desktop/electron/main.ts +++ b/apps/desktop/electron/main.ts @@ -9191,8 +9191,13 @@ function spawnHudWindow(sessionId) { hasShadow: false, alwaysOnTop: true, type: IS_MAC ? 'panel' : undefined, - // Clips the window to a rounded silhouette rather than a hard rectangle. + // Clips the vibrancy layer to the HUD's silhouette rather than a hard + // rectangle — the frost stops where the window's corners do. roundedCorners: true, + // Vibrancy must keep rendering while the window is BLURRED: streaming under + // another app is the whole feature, and the default 'followWindow' kills + // the frost the moment something else takes focus. + visualEffectState: 'active', hiddenInMissionControl: IS_MAC, show: false, backgroundColor: '#00000000', @@ -9988,6 +9993,20 @@ ipcMain.handle('hermes:hud:open', async (_event, request) => { return { ok: true } }) +// Real frosted glass behind the band — the thing CSS backdrop-filter cannot do, +// because Chromium composites a transparent window's page against nothing and +// the desktop is not in its backdrop root. Vibrancy IS the window's content +// view, so it frosts the whole rectangle; the HUD's layout leaves no dead +// margins for that reason, and the renderer only turns it on while the band is +// showing (idle HUD mode must be the bar and nothing else). +ipcMain.handle('hermes:hud:vibrancy', (_event, on) => { + if (hudWindow && !hudWindow.isDestroyed() && IS_MAC) { + hudWindow.setVibrancy(on ? 'hud' : null) + } + + return { ok: true } +}) + // Let clicks fall through the HUD wherever it isn't really there. An // always-on-top window eats every click inside its rectangle, and most of that // rectangle is a faded-out band over whatever the user is actually working in. diff --git a/apps/desktop/src/app/hud/glass.ts b/apps/desktop/src/app/hud/glass.ts new file mode 100644 index 0000000000000..a12692ef03702 --- /dev/null +++ b/apps/desktop/src/app/hud/glass.ts @@ -0,0 +1,64 @@ +import { type RefObject, useEffect } from 'react' + +/** The caret is in the composer — see the `:has()` rules in styles.css. */ +const TYPING_SELECTOR = '[data-slot="composer-rich-input"]:focus' + +/** + * Native frost behind the band. + * + * macOS vibrancy, not CSS — `backdrop-filter` reaches nothing here, because a + * transparent window's backdrop root is the document and the desktop was never + * in it. Vibrancy is composited by WindowServer BELOW the web contents, which + * is what lets it see the desktop and also what makes it untouchable from the + * page: no mask, clip or stacking order can shape it. + * + * That is survivable because the band is a flat panel. It was NOT survivable + * while the band carried a vertical gradient — the frost stayed a slab under a + * fading tint and the top went pale exactly where it should have been + * disappearing, which is what sent the whole thing round in circles. Uniform + * panel, uniform frost. + * + * It still cannot animate, so it is switched while the tint is at full strength + * and can hide the change: on the moment the band is engaged, off the moment the + * hold ends and the opacity fade begins. Letting it outlive the fade leaves bare + * untinted frost on screen — a grey blurred rectangle that pops out at the end + * instead of a band fading away. + * + * Engaged means the caret is in the composer, matching the stylesheet. Merely + * holding window focus does not count: activating a window restores focus to + * whatever had it last, so grabbing the bar to drag the HUD would otherwise + * 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. + */ +export function useHudGlass(rootRef: RefObject, engaged: boolean): void { + useEffect(() => { + const root = rootRef.current + const setVibrancy = window.hermesDesktop?.hud?.setVibrancy + + if (!root || !setVibrancy) { + return + } + + let on: boolean | null = null + + const apply = () => { + const next = engaged || root.querySelector(TYPING_SELECTOR) !== null + + if (on !== next) { + on = next + void setVibrancy(next) + } + } + + apply() + root.addEventListener('focusin', apply) + root.addEventListener('focusout', apply) + + return () => { + void setVibrancy(false) + root.removeEventListener('focusin', apply) + root.removeEventListener('focusout', apply) + } + }, [engaged, rootRef]) +} diff --git a/apps/desktop/src/app/hud/hud-shell.tsx b/apps/desktop/src/app/hud/hud-shell.tsx index c8b9dc76e3243..dee6a30af7c85 100644 --- a/apps/desktop/src/app/hud/hud-shell.tsx +++ b/apps/desktop/src/app/hud/hud-shell.tsx @@ -1,34 +1,54 @@ +import { useStore } from '@nanostores/react' import { type CSSProperties, useEffect, useRef, useState } from 'react' +import { useNavigate } from 'react-router' import { Button } from '@/components/ui/button' import { Codicon } from '@/components/ui/codicon' import { Tip } from '@/components/ui/tooltip' import { useI18n } from '@/i18n' +import { chatMessageText } from '@/lib/chat-messages' import { closeHud } from '@/store/hud' +import { $activeSessionAwaitingInput } from '@/store/prompts' import { $busy, $messages } from '@/store/session' import { WiredPane } from '../contrib/wiring' import { titlebarButtonClass } from '../shell/titlebar' import { useHudClickThrough } from './click-through' -import { useReportHudSession } from './handoff' +import { useHudGlass } from './glass' +import { useHudGoto, useReportHudSession } from './handoff' -/** How long the thread stays visible after the last activity before it starts - * fading (WoW chat frame behavior). Focus holds it open past this. */ -const HUD_RECENT_HOLD_MS = 6_000 +/** How long the transcript lingers at its glanceable opacity — after a turn + * lands, or after you let go of the composer — before it goes. This is the ONLY + * hold: the CSS carries no transition-delay, because two stacked holds read as + * a third fade state that nobody asked for. Focus keeps it open past this. */ +const HUD_RECENT_HOLD_MS = 700 /** Band visibility timings, published to CSS as custom properties so this * module and the stylesheet cannot drift apart. Reveal is quick — it is an * answer to the user; the fade lingers, then goes slowly. */ -const HUD_REVEAL_MS = 150 -const HUD_FADE_DELAY_MS = 3_000 -const HUD_FADE_MS = 1_200 +const HUD_REVEAL_MS = 110 +const HUD_FADE_MS = 180 + +/** The step DOWN to the glanceable opacity when you let go. Deliberately slower + * than the fade that follows it — easing off is a softer gesture than leaving, + * and matching them made the two read as one long dissolve. */ +const HUD_DIM_MS = Math.round(HUD_FADE_MS * 1.5) + +/** The sheet rolling shut. Shorter than the fade so the panel is already gone + * while the last of the text is still going — it reads as the transcript being + * drawn down into the bar rather than the two dissolving in lockstep. */ +const HUD_COLLAPSE_MS = Math.round(HUD_FADE_MS * 0.66) /** Breathing room the sheet keeps above the first row, so the fade has * somewhere to land. Published to CSS, and used here to work out how much of * the window the HUD actually occupies. */ const HUD_SHEET_OVERHANG_PX = 12 +/** Composer on top, transcript always hanging below it — Spotlight's shape, + * rather than flipping to follow the screen edge the HUD is parked against. */ +const HUD_THREAD_ALWAYS_BELOW = true + /** * True for a hold window after any conversation activity (a message landing, * a stream flushing, a turn starting or ending). The CSS uses it — alongside @@ -40,12 +60,16 @@ const HUD_SHEET_OVERHANG_PX = 12 * window after it finishes, without a per-flush re-render (state only changes * on the false↔true edges). */ -function useRecentActivity(): boolean { +function useRecentActivity(): [boolean, () => void] { const [recent, setRecent] = useState(false) const timerRef = useRef | null>(null) + const bumpRef = useRef(() => {}) + // eslint-disable-next-line no-restricted-syntax -- timer handle, not an atom mirror useEffect(() => { + let signature = '' + const bump = () => { if (timerRef.current) { clearTimeout(timerRef.current) @@ -55,9 +79,29 @@ function useRecentActivity(): boolean { timerRef.current = setTimeout(() => setRecent(false), HUD_RECENT_HOLD_MS) } + // Gated on the transcript actually CHANGING, not on the atom being written. + // $messages is republished for plenty of reasons that aren't new content + // (session sync, re-renders, relative timestamps), and re-arming the hold on + // every one of those latched the band open permanently — the fade simply + // never got to start. + const onMessages = () => { + const messages = $messages.get() + const last = messages[messages.length - 1] + const next = `${messages.length}:${last?.id ?? ''}:${last ? chatMessageText(last).length : 0}` + + if (next === signature) { + return + } + + signature = next + bump() + } + + bumpRef.current = bump + // subscribe() fires immediately, so a HUD opened onto an existing // conversation starts with the thread showing, then fades. - const offMessages = $messages.subscribe(bump) + const offMessages = $messages.subscribe(onMessages) const offBusy = $busy.subscribe(busy => busy && bump()) return () => { @@ -70,7 +114,27 @@ function useRecentActivity(): boolean { } }, []) - return recent + return [recent, () => bumpRef.current()] +} + +/** + * True while the HUD must stay up regardless of the hold timer. + * + * The fade is built for an idle transcript, and there are states where leaving + * is the wrong answer: a clarify/approval/sudo/secret prompt is a question you + * have to answer, and a running turn is progress you asked to watch. Letting + * either fade hands you a surface you cannot use — the band goes to zero opacity + * and the window goes mouse-transparent under it, so the prompt is neither + * readable nor clickable. + * + * `recent` alone doesn't cover it: it re-arms on transcript changes, so a long + * tool call with no visible output would time out mid-turn. + */ +function useHudHeld(): boolean { + const awaitingInput = useStore($activeSessionAwaitingInput) + const busy = useStore($busy) + + return awaitingInput || busy } /** @@ -90,11 +154,13 @@ function useRecentActivity(): boolean { */ export function HudShell() { const { t } = useI18n() - const recent = useRecentActivity() + const [recent, holdBand] = useRecentActivity() + const held = useHudHeld() // 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. useReportHudSession() + useHudGoto(useNavigate()) // Which screen EDGE the window is parked against. Parked tight to the top, // the composer flips to the window's top edge and the thread grows DOWN @@ -108,33 +174,33 @@ export function HudShell() { // flips to 'top' only when the HUD is actually parked against the top, and // back once it clearly leaves — the gap between the two thresholds is // hysteresis so the layout can't flutter while it's dragged along the line. - const [edge, setEdge] = useState<'bottom' | 'top'>('bottom') - // How much of the window the HUD actually occupies. The sheet is sized to the - // transcript, so a tall window can be mostly empty above it — the flip has to - // measure the visible panel, not a window edge that reached the screen top - // long before the bar looked anywhere near it. - const visibleHeightRef = useRef(0) + const [edge, setEdge] = useState<'bottom' | 'top'>('top') useEffect(() => { - // ZERO tolerance by explicit request: top-mode only when the HUD is flush - // against the usable top (gap 0 — macOS won't let it overlap the menu bar, - // so flush IS availTop). Tiny FLIP_OFF so the 300ms poll can't flutter on - // sub-pixel jitter while parked. - // Proportional to the display, not an absolute pixel count. The HUD hugs - // its bar, so its visible top can never actually reach the screen edge — - // an exact-flush rule would simply never fire. "Parked at the top" is a - // band near the top instead, with hysteresis so dragging along the line - // can't flutter the layout. - const usableHeight = (window.screen as { availHeight?: number }).availHeight || window.screen.height || 1 - const FLIP_ON = usableHeight * 0.12 - const FLIP_OFF = usableHeight * 0.18 + // Measured on the WINDOW, and flush-only. Flipping is what lets the bar + // reach the top of the screen at all: the window's top edge can sit against + // the menu bar, and the flip moves the composer to that edge. Keying it off + // the visible panel instead meant it could never fire — the panel hugs the + // bar at the bottom of the window — and took the top of the screen away + // with it. FLIP_OFF is just enough hysteresis that the 300ms poll can't + // flutter on sub-pixel jitter while parked. + const FLIP_ON = 0 + const FLIP_OFF = 4 const measure = () => { + // TRYING IT: the bar stays on top and the transcript always hangs below, + // wherever the HUD is parked. Flip the constant to re-enable the + // edge-aware layout (the CSS for both orientations is still here). + if (HUD_THREAD_ALWAYS_BELOW) { + setEdge('top') + + return + } + // availTop ≈ menu bar / notch inset on macOS; screenY is in full-screen // coordinates, so "parked at the top" means screenY ≈ availTop, not 0. const availTop = (window.screen as { availTop?: number }).availTop ?? 0 - const visibleTop = window.screenY + Math.max(0, window.innerHeight - visibleHeightRef.current) - const topGap = visibleTop - availTop + const topGap = window.screenY - availTop setEdge(prev => (topGap <= FLIP_ON ? 'top' : topGap >= FLIP_OFF ? 'bottom' : prev)) } @@ -231,6 +297,7 @@ export function HudShell() { } }, []) + useHudGlass(rootRef, recent || held) useHudClickThrough(rootRef) // Force the HOST layers transparent. index.html's pre-paint script writes an @@ -252,14 +319,19 @@ export function HudShell() {