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