diff --git a/apps/desktop/src/app/chat/index.tsx b/apps/desktop/src/app/chat/index.tsx index 2ed1169f4e..f8c2c86a67 100644 --- a/apps/desktop/src/app/chat/index.tsx +++ b/apps/desktop/src/app/chat/index.tsx @@ -43,7 +43,7 @@ import { sessionPinId, shouldMigrateComposerScope } from '@/store/session' -import { isSecondaryWindow, isWatchWindow } from '@/store/windows' +import { isAuxiliaryWindow, isWatchWindow } from '@/store/windows' import type { ModelOptionsResponse } from '@/types/hermes' import { primaryRouteSelectedSessionId, routeSessionId } from '../routes' @@ -135,7 +135,7 @@ function ChatHeader({ // Secondary windows (new-session scratch, subagent watch, cmd-click pop-out) // are compact side panels — they drop the session-actions header + border // entirely. A brand-new draft has nothing to pin/delete/rename either. - if (isSecondaryWindow() || (!selectedSessionId && !activeSessionId && !isRoutedSessionView)) { + if (isAuxiliaryWindow() || (!selectedSessionId && !activeSessionId && !isRoutedSessionView)) { return null } @@ -397,7 +397,7 @@ export const ChatView = memo(function ChatView({ // scratch window, not the full-height empty state. const showIntro = isPrimary && - !isSecondaryWindow() && + !isAuxiliaryWindow() && freshDraftReady && !isRoutedSessionView && !selectedSessionId && diff --git a/apps/desktop/src/app/contrib/controller.tsx b/apps/desktop/src/app/contrib/controller.tsx index c66ac36799..1bd11309a1 100644 --- a/apps/desktop/src/app/contrib/controller.tsx +++ b/apps/desktop/src/app/contrib/controller.tsx @@ -58,6 +58,7 @@ import { $reviewOpen, closeReview, openReview, REVIEW_PANE_ID } from '@/store/re import { $currentCwd, $selectedStoredSessionId, $sessions, $yoloActive, sessionMatchesStoredId } from '@/store/session' import { watchSessionPins } from '@/store/session-pin-sync' import { $statusbarVisible } from '@/store/statusbar-prefs' +import { isHudWindow } from '@/store/windows' import type { SessionDragPayload } from '../chat/composer/inline-refs' import { watchPreviewTiles } from '../chat/preview-tile' @@ -69,6 +70,7 @@ import { watchSessionTiles, WorkspaceTabMenu } from '../chat/session-tile' +import { HudShell } from '../hud/hud-shell' import { $terminalTakeover, setTerminalTakeover } from '../right-sidebar/store' import { $workspaceIsPage } from '../routes' @@ -688,6 +690,18 @@ export function ContribController() { const sidebarOpen = useStore($sidebarOpen) const statusbarVisible = useStore($statusbarVisible) + // HUD mode is the SAME app with its frame removed: the wiring (gateway, + // sessions, streams, submit) mounts identically, and only the shell around + // the chat surface differs. Branching here rather than at the window entry + // is what keeps the HUD's composer the real composer. + if (isHudWindow()) { + return ( + + + + ) + } + return ( { - if (isSecondaryWindow()) { + if (isAuxiliaryWindow()) { return } diff --git a/apps/desktop/src/app/contrib/hooks/use-quick-entry-bridge.ts b/apps/desktop/src/app/contrib/hooks/use-quick-entry-bridge.ts index dbd886e18f..5fbfd102b1 100644 --- a/apps/desktop/src/app/contrib/hooks/use-quick-entry-bridge.ts +++ b/apps/desktop/src/app/contrib/hooks/use-quick-entry-bridge.ts @@ -9,7 +9,7 @@ import { } from '@/store/quick-entry' import { $gatewayState, $sessions } from '@/store/session' import { sessionTileDelegate } from '@/store/session-states' -import { isSecondaryWindow } from '@/store/windows' +import { isAuxiliaryWindow } from '@/store/windows' interface QuickEntryBridgeParams { startFreshSessionDraft: () => void @@ -57,7 +57,7 @@ export function useQuickEntryBridge({ startFreshSessionDraft, submitText }: Quic startFreshRef.current = startFreshSessionDraft useEffect(() => { - if (isSecondaryWindow()) { + if (isAuxiliaryWindow()) { return } @@ -101,7 +101,7 @@ export function useQuickEntryBridge({ startFreshSessionDraft, submitText }: Quic // Push gateway truth into the quick window whenever it changes: connection // state gates its input; the recent-session list feeds its target picker. useEffect(() => { - if (isSecondaryWindow()) { + if (isAuxiliaryWindow()) { return } diff --git a/apps/desktop/src/app/contrib/wiring.tsx b/apps/desktop/src/app/contrib/wiring.tsx index 02f95d9765..fbb6558c53 100644 --- a/apps/desktop/src/app/contrib/wiring.tsx +++ b/apps/desktop/src/app/contrib/wiring.tsx @@ -67,7 +67,7 @@ import { } from '@/store/session' import { clearSessionTodos, setSessionTodos, todosForHydration } from '@/store/todos' import { armWakeWord, stopClientCapture } from '@/store/wake-word' -import { isSecondaryWindow } from '@/store/windows' +import { isAuxiliaryWindow, isHudWindow } from '@/store/windows' import { useSkinCommand } from '@/themes/use-skin-command' import { closeWorkspaceTab } from '../chat/close-tab' @@ -77,6 +77,7 @@ import { CommandPalette } from '../command-palette' import { useGatewayBoot } from '../gateway/hooks/use-gateway-boot' import { useGatewayRequest } from '../gateway/hooks/use-gateway-request' import { useKeybinds } from '../hooks/use-keybinds' +import { useHudHandoff } from '../hud/handoff' import { ModelPickerOverlay } from '../model-picker-overlay' import { ModelVisibilityOverlay } from '../model-visibility-overlay' import { mainChatOccupied, openSession } from '../open-session' @@ -624,6 +625,9 @@ export function ContribWiring({ children }: { children: ReactNode }) { // session / new session), and it hears gateway truth from this window. useQuickEntryBridge({ startFreshSessionDraft, submitText }) + // Leaving HUD mode hands this window the session back (see hud/handoff). + useHudHandoff({ navigate, resumeSession }) + // Clear a failed turn's red error banner. Errors are renderer-local (never // persisted): a bare error placeholder is dropped entirely; a partial-output // failure keeps its content and sheds the error. Both the runtime cache AND @@ -983,7 +987,7 @@ export function ContribWiring({ children }: { children: ReactNode }) { // Pane-registered tools (preview's monitor/devtools cluster) anchor flush // against the static system cluster — in the tree layout the titlebar band // sits ABOVE the grid, so AppShell's pane-width anchoring doesn't apply. - const SYSTEM_TOOL_COUNT = 4 + const SYSTEM_TOOL_COUNT = 5 const paneToolCount = rightTitlebarTools.filter(tool => !tool.hidden).length const systemToolsWidth = `calc(${SYSTEM_TOOL_COUNT} * (var(--titlebar-control-size) + 0.25rem))` @@ -1006,18 +1010,23 @@ export function ContribWiring({ children }: { children: ReactNode }) { } as CSSProperties } > - navigate(SETTINGS_ROUTE)} - tools={rightTitlebarTools} - /> + {/* HUD mode has no titlebar to hang these off — the clusters are + `fixed`, so without this they'd float over the chat as orphaned + buttons. Exits are the ⌘⇧H toggle and ⌘W. */} + {!isHudWindow() && ( + navigate(SETTINGS_ROUTE)} + tools={rightTitlebarTools} + /> + )} {children} {/* The full real overlay set (mirrors DesktopController's `overlays`). */} - {!isSecondaryWindow() && } - {!isSecondaryWindow() && ( + {!isAuxiliaryWindow() && } + {!isAuxiliaryWindow() && ( { @@ -1113,11 +1122,13 @@ export function ContribWiring({ children }: { children: ReactNode }) { {/* Toasts above everything. */} - {/* Petdex floating mascot — renders nothing unless installed + enabled. */} - + {/* Petdex floating mascot — renders nothing unless installed + enabled. + Never in the HUD: that window is the chat bar and nothing else. */} + {!isHudWindow() && } - {/* Single persistent xterm host chasing the terminal pane's slot rect. */} - + {/* Single persistent xterm host chasing the terminal pane's slot rect. + The HUD has no terminal pane, so it has nothing to chase. */} + {!isHudWindow() && } ) } diff --git a/apps/desktop/src/app/hud/click-through.ts b/apps/desktop/src/app/hud/click-through.ts new file mode 100644 index 0000000000..1c6eaf9ed9 --- /dev/null +++ b/apps/desktop/src/app/hud/click-through.ts @@ -0,0 +1,75 @@ +import { type RefObject, useEffect } from 'react' + +/** + * Let clicks fall through the HUD everywhere it isn't really there. + * + * The one thing about HUD mode that CSS cannot express, because it is a + * property of the OS WINDOW rather than of the page. It reads the engaged state + * off the DOM (`:focus-within`) rather than keeping a second copy, so there is + * one answer to "is the HUD in use" and the stylesheet owns it. + * + * An always-on-top window eats every click inside its rectangle, visible or + * not — and most of the HUD's rectangle is a faded-out band over whatever the + * user is actually working in. `pointer-events: none` doesn't help: that is a + * page-level property, and the click never reaches the page. + * + * So the window itself is made mouse-transparent except where it is genuinely + * interactive: the bar, always, and everything else only while the composer + * holds focus — the same line the band and its exit chip draw with + * `pointer-events`. `forward: true` keeps mousemove flowing while ignoring, + * which is what lets it re-arm when the cursor comes back to the bar. + */ +export function useHudClickThrough(rootRef: RefObject): void { + useEffect(() => { + const root = rootRef.current + const setIgnoreMouse = window.hermesDesktop?.hud?.setIgnoreMouse + + if (!root || !setIgnoreMouse) { + return + } + + let ignoring: boolean | null = null + // Where the cursor was last seen, so a focus change can re-decide without + // waiting for the next move (blurring with the cursor parked on the bar + // must not make the bar untouchable until you jiggle the mouse). + let point: { x: number; y: number } | null = null + + const overBar = () => { + const bar = root.querySelector('[data-slot="composer-dock"]') + + if (!bar || !point) { + return false + } + + const rect = bar.getBoundingClientRect() + + return point.x >= rect.left && point.x <= rect.right && point.y >= rect.top && point.y <= rect.bottom + } + + const apply = () => { + const next = !root.matches(':focus-within') && !overBar() + + if (ignoring !== next) { + ignoring = next + setIgnoreMouse(next) + } + } + + const onMove = (event: MouseEvent) => { + point = { x: event.clientX, y: event.clientY } + apply() + } + + apply() + window.addEventListener('mousemove', onMove) + root.addEventListener('focusin', apply) + root.addEventListener('focusout', apply) + + return () => { + setIgnoreMouse(false) + window.removeEventListener('mousemove', onMove) + root.removeEventListener('focusin', apply) + root.removeEventListener('focusout', apply) + } + }, [rootRef]) +} diff --git a/apps/desktop/src/app/hud/hud-shell.tsx b/apps/desktop/src/app/hud/hud-shell.tsx new file mode 100644 index 0000000000..49d3309b36 --- /dev/null +++ b/apps/desktop/src/app/hud/hud-shell.tsx @@ -0,0 +1,253 @@ +import { type CSSProperties, useEffect, useRef, useState } from 'react' + +import { Button } from '@/components/ui/button' +import { Codicon } from '@/components/ui/codicon' +import { Tip } from '@/components/ui/tooltip' +import { useI18n } from '@/i18n' +import { closeHud } from '@/store/hud' +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' + +/** 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 + +/** 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 + +/** + * 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 + * :focus-within — to decide whether the thread is visible; idle HUD mode is + * just the Spotlight bar. + * + * $messages replaces ~30×/s mid-stream, so activity RESTARTS the timer on + * every flush — the thread stays up while a reply is writing and for the hold + * window after it finishes, without a per-flush re-render (state only changes + * on the false↔true edges). + */ +function useRecentActivity(): boolean { + const [recent, setRecent] = useState(false) + const timerRef = useRef | null>(null) + + // eslint-disable-next-line no-restricted-syntax -- timer handle, not an atom mirror + useEffect(() => { + const bump = () => { + if (timerRef.current) { + clearTimeout(timerRef.current) + } + + setRecent(true) + timerRef.current = setTimeout(() => setRecent(false), HUD_RECENT_HOLD_MS) + } + + // subscribe() fires immediately, so a HUD opened onto an existing + // conversation starts with the thread showing, then fades. + const offMessages = $messages.subscribe(bump) + const offBusy = $busy.subscribe(busy => busy && bump()) + + return () => { + offMessages() + offBusy() + + if (timerRef.current) { + clearTimeout(timerRef.current) + } + } + }, []) + + return recent +} + +/** + * HUD mode's shell — the chrome-free floating chat. + * + * Deliberately almost nothing: it mounts the SAME wired chat surface the + * workspace pane does, so the composer here IS the app's composer (slash + * commands, `@` refs, attachments, queue, voice, model pill) and the transcript + * is the app's transcript, rendered by the app's renderer. Only the frame + * changes — no titlebar, no statusbar, no pane tree, no sidebars. + * + * The shape is macOS Spotlight: at rest, the centered composer bar is the + * whole interface. The thread renders as bare text above it and is + * visibility-gated like a game chat frame — shown while a turn is recent or the + * composer has focus, faded out otherwise (see the `[data-hud-shell]` CSS and + * `useRecentActivity`). + */ +export function HudShell() { + const { t } = useI18n() + const recent = useRecentActivity() + + // 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() + + // 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 + // (data-hud-edge). Computed here from window.screenY — no IPC: the renderer + // always knows where its window is. Polled because the DOM has no + // window-move event; 300ms is imperceptible for a layout flip. + // + // EDGE-tight, not a midpoint rule: the first cut compared topGap('bottom') + + useEffect(() => { + // ZERO tolerance by explicit request: top-mode only when the window 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. + const FLIP_ON = 0 + const FLIP_OFF = 4 + + const measure = () => { + // 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 topGap = window.screenY - availTop + + setEdge(prev => (topGap <= FLIP_ON ? 'top' : topGap >= FLIP_OFF ? 'bottom' : prev)) + } + + measure() + const timer = setInterval(measure, 300) + window.addEventListener('resize', measure) + + return () => { + clearInterval(timer) + window.removeEventListener('resize', measure) + } + }, []) + + // Whether the thread actually overflows its band. Gates the band's no-drag + // 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) + const rootRef = useRef(null) + + useEffect(() => { + const root = rootRef.current + + if (!root) { + return + } + + let viewport: HTMLElement | null = null + const ro = new ResizeObserver(() => measure()) + + const measure = () => { + const el = viewport ?? root.querySelector('[data-slot="aui_thread-viewport"]') + + if (el !== viewport) { + viewport = el + + if (el) { + ro.observe(el) + + if (el.firstElementChild) { + ro.observe(el.firstElementChild) + } + } + } + + setScrollable(Boolean(el && el.scrollHeight > el.clientHeight + 4)) + } + + // The viewport mounts async (lazy chat surface); poll briefly until it + // exists, then let the ResizeObserver own it. + measure() + const probe = setInterval(measure, 500) + + return () => { + clearInterval(probe) + ro.disconnect() + } + }, []) + + useHudClickThrough(rootRef) + + // Force the HOST layers transparent. index.html's pre-paint script writes an + // opaque themed background onto as an INLINE style (the anti-white- + // flash trick), and an inline style beats any stylesheet rule — so without + // this the window is a solid slab and every translucent panel below is just + // glass over a white wall. A style tag with `!important` is what the pet + // overlay and quick entry already do; they get it at mount because they are + // bespoke roots, and the HUD needs the same because it is not. + useEffect(() => { + const style = document.createElement('style') + style.textContent = 'html,body,#root{background:transparent !important;}' + document.head.appendChild(style) + + return () => style.remove() + }, []) + + return ( +
+ {/* The band's sheet, on a layer of its own so it can carry the fade + without the app's chat surface having to know about it. FIRST child so + it paints behind the transcript. */} +
+ + + + {/* The top fade band, as a drag handle. Its text is masked to nothing up + there, so handing the band's mouse input to the window manager costs + no readable content — and it gives the HUD a grab area that isn't the + composer. + + LAST child on purpose. Electron collects draggable regions by walking + the layout tree in order, uniting `drag` rects and subtracting + `no-drag` ones, so later elements win. Above `WiredPane` this strip + was silently subtracted away by the scrollback's full-height `no-drag` + rect (z-index does not enter into it — the region math is rect-based, + not paint-order-based). */} +
+ + {/* The way back. HUD mode has no titlebar, so without this the only + exits are ⌘⇧H and ⌘W — both invisible. Floats over the scrollback + (which is short and top-fades, so it rarely collides with text) and + carves itself out of the drag region so the click lands. */} + + + +
+ ) +} diff --git a/apps/desktop/src/components/assistant-ui/thread/list.tsx b/apps/desktop/src/components/assistant-ui/thread/list.tsx index 304b3eb3ec..0caf309e85 100644 --- a/apps/desktop/src/components/assistant-ui/thread/list.tsx +++ b/apps/desktop/src/components/assistant-ui/thread/list.tsx @@ -19,6 +19,7 @@ import { useI18n } from '@/i18n' import { messageRenderWeight } from '@/lib/render-weight' import { cn } from '@/lib/utils' import { + $threadScrolledUp, onScrollToBottomRequest, onThreadEditClose, onThreadEditOpen, @@ -383,6 +384,23 @@ const ThreadMessageListInner: FC = ({ // Floating jump button (outside this subtree) → return to the bottom. useEffect(() => onScrollToBottomRequest(() => void scrollToBottom()), [scrollToBottom]) + // Waking from display: hidden (HUD mode hides the main window; OS hide does + // the same to any window): rAF and ResizeObserver were frozen the whole + // time, so the virtualizer's measurements — and scrollTop itself — are + // stale. If the user was following the bottom, re-anchor once visible; + // leave a scrolled-up reader exactly where they were. + useEffect(() => { + const onVisible = () => { + if (document.visibilityState === 'visible' && !$threadScrolledUp.get()) { + requestAnimationFrame(() => void scrollToBottom()) + } + } + + document.addEventListener('visibilitychange', onVisible) + + return () => document.removeEventListener('visibilitychange', onVisible) + }, [scrollToBottom]) + const endEditHold = useCallback(() => { scrollRef.current?.removeAttribute('data-editing') }, [scrollRef]) diff --git a/apps/desktop/src/styles.css b/apps/desktop/src/styles.css index 2ee38e0b65..70188469a8 100644 --- a/apps/desktop/src/styles.css +++ b/apps/desktop/src/styles.css @@ -2264,3 +2264,632 @@ button[data-slot='aui_msg-reactions'] svg { opacity: 0.4; } } + +/* ── HUD mode ──────────────────────────────────────────────────────────────── + The chrome-free floating chat (`?win=hud`). Same renderer, same components, + same transcript as any other window — this block only removes the frame and + makes the window see-through. It deliberately does NOT restyle how messages + render: the transcript is the app's transcript, and a HUD that reinvents + bubbles is a second design to keep in sync. Layout and surface only. */ + +/* The HOST LAYERS are made transparent at mount, not here: index.html's + pre-paint script sets an opaque themed background on as an INLINE + style (the anti-white-flash trick), and an inline style beats a stylesheet + rule. Against that opaque wall every translucent panel below is just glass + over white — which is exactly how this shipped: a plain opaque slab. The + pet overlay and quick entry inject a style tag at mount for the same + reason; HudShell does it too. See `hud-shell.tsx`. */ + +[data-hud-shell] { + background: transparent; + /* Empty space drags the window; anything that needs the mouse carves itself + back out below. */ + -webkit-app-region: drag; + /* The band's fade, shared by the glass layer and the transcript so the two + can never ramp differently. + + Bottom-anchored and deliberately NOT linear — the WoW chat frame's shape. + A straight ramp starts eating the newest reply immediately, which is the + one thing that must stay legible, and it leaves the whole band looking + half-dissolved. Instead: most of it is FULLY solid, then it collapses in + one short stretch, and the top fifth is nothing at all. That empty cap is + what removes the edge — any alpha still alive at the window boundary reads + as a line, and with the frost riding this same mask it would be a line of + blur, which is worse. */ + --hud-thread-mask-stops: #000 0%, #000 58%, rgb(0 0 0 / 0.42) 71%, transparent 82%; + --hud-thread-mask: linear-gradient(to top, var(--hud-thread-mask-stops)); +} + +[data-hud-shell][data-hud-edge='top'] { + --hud-thread-mask: linear-gradient(to bottom, var(--hud-thread-mask-stops)); +} + +/* Chat surface carries nothing in HUD mode — the visual is the BAND below. */ +[data-hud-shell] [data-chat-surface] { + background: transparent !important; + overflow: hidden !important; +} + +/* The scrollback — the chat BAND, straight from the nous-viz2d chat (which is + the reference for this whole surface): a fixed-height smoked strip hugging + the input, tinted from the theme, with the text simply sitting in it. + + - viz2d: `bg-black/20` at rest → `bg-black/60` on hover / focus-within / + pending. Same mechanism here, theme-aware: the tint is the theme's own + background color, so it smokes dark on dark themes and frosts light on + light ones, and text keeps its normal contrast against it. + - VISIBILITY keeps the WoW fade this HUD already had: shown while a turn + is recent/streaming (`data-hud-recent`), on band hover, or while the + composer is focused; melted away otherwise. viz2d's band never hides — + ours does, because idle HUD mode is just the Spotlight bar. + - Focus steps the tint to fully solid: typing means reading conditions. + + Reveal is fast, fade-out is slow — the reveal is an answer to the user, the + fade is furniture leaving the room. */ +[data-hud-shell] [data-slot='composer-bounds'] { + position: absolute !important; + left: 0 !important; + right: 0 !important; + /* The band and the bar tile the window between them, with no dead margins — + the band runs edge to edge and the bar covers the bottom of it. There is + then no seam to get wrong: the band does not stop at the bar, it runs the + window's FULL height and the opaque bar sits on top of its bottom edge. + Every earlier attempt computed that edge from + --composer-surface-measured-height, which is the composer's published + height, rounded; the real box is fractional, so the math left a hairline + that appeared and vanished as the composer grew ("the gap is 0px until two + lines"). Nothing to compute, nothing to drift. What keeps the TEXT off the + bar is the app's own composer clearance, below. */ + inset: 0 !important; + height: auto !important; + width: 100% !important; + max-width: none !important; + flex: none !important; + border: 0 !important; + /* Rounded on top to match the window's own corners (roundedCorners clips the + frost to the same silhouette); square at the bottom, where the bar covers it. */ + border-radius: 0.75rem 0.75rem 0 0; + /* No fill and no blur of its own — the glass is its own layer behind this + one ([data-hud-glass]). Chromium will not honour `mask-image` and + `backdrop-filter` on the SAME element: the mask ends up applying to the + backdrop rather than the content, which shows up as the sheet fading on + the gradient while the text stays stubbornly solid. Two elements, two + masks, no interaction. */ + background: transparent !important; + opacity: 0; + /* WoW linger: reveal is quick, but on losing focus the band HOLDS, then fades + out slow. The timings come from hud-shell.tsx, which uses the same numbers + to decide when to drop the vibrancy layer. Reveal rules zero the delay. */ + transition: + opacity var(--hud-fade) ease var(--hud-fade-delay), + background-color 350ms ease; +} + +/* Shown: recent turn / streaming, or a focused composer. + + POINTER EVENTS ARE THE GATE: unfocused, the band is a ghost you can't touch + — clicks in that region fall through to the shell's drag region (so the + whole upper window is a drag handle at rest) and the band can't eat a + scroll or a stray click while you're aiming at the app behind it. Focus the + composer and it becomes a real scrollable surface. This also means hover + alone can't reveal the band anymore — hover is not engagement. */ +/* The band's sheet, behind the transcript: a tint wearing the same gradient + mask as the text, so the whole surface ramps out together and the band has no + top edge at all. + + There is no desktop blur under it, and that is a hard limit rather than a + gap. macOS vibrancy is composited by WindowServer BELOW the web contents, + after this process has finished drawing, so nothing in the DOM can shape it — + mask, clip-path, stacking, none of it reaches. Leaving vibrancy on under a + masked tint is worse than no blur: the frost stays a flat slab, so the top of + the band goes pale exactly where it should be disappearing. And CSS + backdrop-filter is not a substitute; verified on the real window rather than + assumed, it produces no blur whatsoever, because a transparent window's + backdrop root is the document and the desktop was never in it. + + The one real way out is native — NSVisualEffectView.maskImage takes an alpha + mask for precisely this — which needs a small addon over + getNativeWindowHandle() and buys a cross-dissolve between frosted and sharp + rather than a true blur-radius ramp. Its own change, not this one. */ +[data-hud-shell] [data-hud-glass] { + position: absolute; + inset: 0; + z-index: 0; + pointer-events: none; + border-radius: 0.75rem 0.75rem 0 0; + background: color-mix(in srgb, var(--dt-background) 62%, transparent); + opacity: 0; + transition: opacity var(--hud-fade) ease var(--hud-fade-delay); + -webkit-mask-image: var(--hud-thread-mask); + mask-image: var(--hud-thread-mask); + -webkit-mask-position: bottom; + mask-position: bottom; + -webkit-mask-repeat: no-repeat; + mask-repeat: no-repeat; + -webkit-mask-size: 100% 100%; + mask-size: 100% 100%; +} + +[data-hud-shell][data-hud-recent] [data-hud-glass], +[data-hud-shell]:focus-within [data-hud-glass] { + opacity: 1; + transition-duration: var(--hud-reveal); + transition-delay: 0s; +} + +/* Engaged, the ramp gets out of the way: you are reading the transcript, so + nothing in it should be dimmed. Done by SCALING the mask rather than swapping + it — `mask-image` animates discretely and would snap, while `mask-size` + interpolates. Blown up and pinned to the bottom, the band only ever shows the + gradient's solid end. Reveal-speed both ways, so letting go of the composer + brings the ramp straight back instead of waiting out the fade's hold. */ +[data-hud-shell]:focus-within [data-slot='aui_thread-viewport'] { + -webkit-mask-size: 100% 600%; + mask-size: 100% 600%; +} + +/* Engaged: the band goes solid — typing means reading conditions. */ +[data-hud-shell]:focus-within [data-hud-glass] { + background: var(--dt-background); +} + +[data-hud-shell][data-hud-edge='top'] [data-hud-glass] { + border-radius: 0 0 0.75rem 0.75rem; +} + +[data-hud-shell] [data-slot='composer-bounds'] { + pointer-events: none; +} + +[data-hud-shell]:focus-within [data-slot='composer-bounds'] { + pointer-events: auto; +} + +[data-hud-shell][data-hud-recent] [data-slot='composer-bounds'], +[data-hud-shell]:focus-within [data-slot='composer-bounds'] { + opacity: 1; + transition-duration: var(--hud-reveal); + transition-delay: 0s; +} + +/* Typing: the band goes fully solid — viz2d's focus step, taken to its end. */ +[data-hud-shell]:focus-within [data-slot='composer-bounds'] { + background: var(--dt-background) !important; +} + +/* User messages ride the band like every other line. In the app each sticky + user row paints an OPAQUE chat-surface slab behind it (so a stuck bubble can + slide over scrolled text) and the bubble is solid --dt-user-bubble — inside + the smoked band both read as fully opaque cards that ignore every fade. The + slab goes unconditionally; the bubble smokes at rest and returns solid when + engaged. ONE variable carries the fill so there is no specificity fight + between rest/engaged rules — the states just move the var. */ +[data-hud-shell] { + --hud-bubble-fill: color-mix(in srgb, var(--dt-user-bubble) 65%, transparent); +} + +/* ONE opacity law for the whole band: solid on focus, material otherwise. + Bubbles going solid on `recent` too made them opaque cards floating on the + 62% band mid-stream — the band only steps solid on focus, so nothing inside + it may step earlier. */ +[data-hud-shell]:focus-within { + --hud-bubble-fill: var(--dt-user-bubble); +} + +[data-hud-shell] [data-slot='aui_user-message-root'] { + background: transparent !important; + /* Not sticky in HUD mode: the band is a short log, and a pinned bubble + spends a third of it on a line you already read. Static rows scroll away + like everything else (the app keeps its sticky behavior — this is the + HUD's call, not a global one). */ + position: static !important; +} + +/* User bubbles: the ONE thing in the band that paints its own opaque fill + (bg-(--dt-user-bubble) on USER_BUBBLE_BASE_CLASS), so it survives every + fade the band applies to its own background and reads as a solid card + floating on glass. Two things are needed, and only doing one of them is + why this kept coming back: + - the FILL follows the band's state (--hud-bubble-fill), and + - the BORDER does too; an opaque hairline outlines the card even when + the fill is right. + Fill and border both ride the same variable, so they cannot desync. */ +[data-hud-shell] .composer-human-message { + background: var(--hud-bubble-fill) !important; + border-color: color-mix(in srgb, var(--ui-stroke-secondary) 45%, transparent) !important; + backdrop-filter: none !important; + -webkit-backdrop-filter: none !important; + transition: + background-color 350ms ease, + border-color 350ms ease; +} + +/* The bubble's own container also paints the chat surface behind it. */ +[data-hud-shell] .composer-human-message-container { + background: transparent !important; +} + +/* ONE glass element in HUD mode: [data-hud-glass]. Everything else drops its + backdrop-filter — the composer tree ships several Tailwind backdrop-blur + utilities (surface glass, fallback surface, attachment spinners, voice + pills) that are invisible inside the opaque app but paint as smeared halos + around the pill's rounded corners on a transparent window. + + Keep this exemption in step with whatever carries the glass: pointing it at + the wrong element silently deletes the blur, which looks exactly like + backdrop-filter not working at all. */ +[data-hud-shell] *:not([data-hud-glass], [data-hud-glass] *) { + backdrop-filter: none !important; + -webkit-backdrop-filter: none !important; +} + +/* No session header, no jump-to-bottom pill, no timeline rail. */ +[data-hud-shell] header, +[data-hud-shell] [data-slot='thread-timeline'], +[data-hud-shell] .thread-jump-button { + display: none !important; +} + +/* SCROLLING vs DRAGGING — pick one, per element, per STATE. + `-webkit-app-region: drag` hands an element's mouse input to the window + manager, wheel included — and the region math is rect-based, so a `no-drag` + band subtracts its rectangle even when it's pointer-transparent. The band is + most of the window, so giving its rect away permanently made the HUD nearly + immovable in practice (the composer almost always has focus while in use). + + So the carve-out needs BOTH: composer focused AND actual overflow to scroll + (data-hud-scrollable, set by HudShell). A short conversation leaves the + whole window grabbable; the band only becomes a scroll surface when + scrolling is a real thing you could do with it. */ +[data-hud-shell][data-hud-scrollable]:focus-within [data-slot='composer-bounds'] { + -webkit-app-region: no-drag; +} + +/* ── Edge flip ─────────────────────────────────────────────────────────────── + Parked in the top half of the screen (data-hud-edge='top', broadcast by + main on move/resize), the whole HUD mirrors vertically: composer hugs the + window's top edge, the band hangs BELOW it, text melts at the bottom, and + the drag strip moves to the bottom dead zone. Same four surfaces, same + variables — only the anchors swap. */ +[data-hud-shell][data-hud-edge='top'] [data-slot='composer-dock'] { + top: 0 !important; + bottom: auto !important; +} + +[data-hud-shell][data-hud-edge='top'] [data-slot='composer-bounds'] { + border-radius: 0 0 0.75rem 0.75rem; +} + +/* Flipped, the bar is at the TOP, so the clearance has to be too — the app's + clearance element only pads the end of the thread. */ +[data-hud-shell][data-hud-edge='top'] [data-chat-surface] { + --thread-last-message-clearance: 0.25rem; +} + +[data-hud-shell][data-hud-edge='top'] [data-slot='aui_thread-content'] { + padding-block-start: calc(var(--composer-measured-height) + 0.25rem) !important; +} + +[data-hud-shell][data-hud-edge='top'] [data-slot='aui_thread-viewport'] { + -webkit-mask-position: top; + mask-position: top; +} + +[data-hud-shell][data-hud-edge='top'] [data-hud-drag-strip] { + top: auto; + bottom: 0; +} + +/* Depth by age — the WoW chat frame read, as a MASK over the whole band. + Anchored at the bottom: the newest turn is fully solid against the bar and + everything above it thins out, so older lines read as ghosts and the band's + top edge melts into the desktop instead of stopping at a line. + (https://polypane.app/blog/my-take-on-fading-content-using-transparent-gradients-in-css/ + — a mask, not an overlaid gradient, because the HUD has no solid background + to fake the fade against.) + + On the BAND, not the thread viewport inside it, so the glass tint fades on + the same ramp as the text it is behind. Masking only the text leaves the + sheet ending in a hard rectangle with ghost words floating on it. + + The ramp is the DISENGAGED look; focus scales it away (see the glass layer's + focus rule, which does the same thing to the sheet). + + On the scroll VIEWPORT, not the band that contains it. A mask on the band + stopped reaching the transcript once the glass moved to its own + backdrop-filter layer — the text kept painting at full strength while the + sheet ramped. The viewport is the box the text actually lives in, and masking + it there survives whatever the compositor does with the layer above. */ +[data-hud-shell] [data-slot='aui_thread-viewport'] { + -webkit-mask-image: var(--hud-thread-mask); + mask-image: var(--hud-thread-mask); + -webkit-mask-position: bottom; + mask-position: bottom; + -webkit-mask-repeat: no-repeat; + mask-repeat: no-repeat; + -webkit-mask-size: 100% 100%; + mask-size: 100% 100%; + transition: + -webkit-mask-size var(--hud-reveal) ease, + mask-size var(--hud-reveal) ease; +} + +/* The drag handle is the top strip of the WINDOW, above the band. The band + itself scrolls (a drag region eats wheel events, so it can never be the + handle); this strip is dead space either way, so it drags. */ +[data-hud-shell] [data-hud-drag-strip] { + position: absolute; + top: 0; + left: 0; + right: 0; + height: 2rem; + z-index: 10; + -webkit-app-region: drag; +} + +/* Tighten the thread on every side. The docked chat's gutters (px-6 py-8 plus + a 1.5rem inline pad) are sized for a full window column; in a bar a few + lines tall they're most of the surface. Bottom pad drops to a hairline — + the band already ends at the bar, so any block-end padding here reads as a + gap between the last message and the composer. */ +[data-hud-shell] [data-slot='aui_thread-content'] { + padding-inline: 0.6rem !important; + padding-block: 0.8rem 0.25rem !important; + /* A chat frame fills from the bottom. The docked thread hangs from the top + because it is always taller than its content is short; the band is a few + lines tall, so a two-message conversation left a dead gap between the last + reply and the composer. Filling the viewport and packing to the end puts + that slack ABOVE the text, where it's just more transparent window. */ + min-height: 100%; + justify-content: flex-end; +} + +/* Parked at the top the band hangs below the bar, so it fills downward. */ +[data-hud-shell][data-hud-edge='top'] [data-slot='aui_thread-content'] { + justify-content: flex-start; +} + +/* Space under the last message — the app's own measured-dock clearance, minus + the 2rem of breathing room a full window can afford. This is what holds the + text off the bar now that the band runs the whole window height, and it is + measured from the live dock, so it tracks the composer growing to two, three, + ten rows with no hairline and no hole. */ +[data-hud-shell] [data-chat-surface] { + --thread-last-message-clearance: var(--composer-measured-height); +} + +/* Same for the dock's own bottom pad — this var also feeds + --composer-measured-height, so the thread and the composer stay in + agreement from one value. */ +[data-hud-shell] { + /* Conversation rhythm, tightened for a bar a few lines tall. The docked + thread's spacing is sized for a full-height column; in the HUD that air is + most of the band, and the ramp eats the older turns before you can read + them. Same knobs the app uses, just smaller here. */ + --conversation-turn-gap: 0.1875rem; + --turn-block-gap: 0.375rem; + --paragraph-gap: 0.4rem; + --composer-shell-pad-block-end: 0px; + /* The viewport fills the band, period. The app's calc shortens the viewport + to leave the dock's strips visible below it; the HUD hides those strips, so + applying it here just strands a dead zone at the end of the thread that + grows as you type. Room for the bar comes from the clearance instead. */ + --thread-viewport-height: 100%; +} + +/* The composer BAR is the drag handle: it's the one surface that isn't a + scroll container, so it's the only one that can be. Its frame drags; the + controls carve themselves back out (a drag region eats their clicks too). */ +[data-hud-shell] [data-slot='composer-dock'] { + -webkit-app-region: drag; +} + +[data-hud-shell] [data-slot='composer-rich-input'], +[data-hud-shell] [data-slot='composer-dock'] button, +[data-hud-shell] [data-slot='composer-dock'] input, +[data-hud-shell] [data-slot='composer-dock'] textarea, +[data-hud-shell] [data-slot='composer-dock'] [contenteditable], +[data-hud-shell] [data-slot='composer-dock'] [role='button'] { + -webkit-app-region: no-drag; +} + +/* HUD mode is the input and the log — nothing else. The dock stacks + [micro-action pills] · [status stack] · [composer] · [underside slot], and + only the composer belongs here: the status stack (todos, subagents, + background tasks, queue, the coding/cwd/git-branch row) and the strips + around it are ambient chrome for a full window, and in a bar a few lines + tall they'd shove the input off screen. + + Written as "keep the composer, hide its siblings" rather than a list of + selectors, because those siblings carry no data-slot of their own — a list + would silently miss whatever gets added to the dock next. */ +[data-hud-shell] [data-slot='composer-dock'] > *:not([data-slot='composer-root']) { + display: none !important; +} + +/* The coding/cwd/git-branch strip is NOT a dock sibling — it renders inside + `composer-surface` (it's meant to inherit the composer's width and top + radius), so the rule above never reaches it. Same intent, own selector. */ +[data-hud-shell] .coding-status-bar { + display: none !important; +} + +/* The composer is the Spotlight bar: centered, capped width, the ONE + permanent object on screen. When the thread is faded out, this is all HUD + mode is. + + The dock is `absolute bottom-0 left-1/2 -translate-x-1/2` against the chat + surface. Two traps live in that, and both silently ate earlier attempts: + - `margin-bottom` does NOTHING to an absolutely-positioned box anchored to + an edge; the lift has to come from `bottom`. + - Tailwind centers it with `left-1/2` + a translate, so BOTH `transform` + and `translate` (the standalone property) must be cancelled — absolute + centering here is left/right 0 + capped width + margin-inline auto. + + Do NOT restyle `position`: the controls row (model pill, mic, send) resolves + against the nearest positioned ancestor, so making the dock static re-anchors + that row and the composer tears in half — input in flow at the top, controls + stranded at the bottom. */ +[data-hud-shell] [data-slot='composer-dock'], +[data-hud-shell] [data-slot='composer-dock'][data-popped-out] { + left: 0 !important; + right: 0 !important; + bottom: 0 !important; + top: auto !important; + width: 100% !important; + max-width: none !important; + transform: none !important; + translate: none !important; + padding: 0 !important; +} + +/* The bar is never "away". In the docked app the composer dims to 30% while + you read back through the thread, and comes back on hover or focus — sensible + when it's a panel at the bottom of a big window you're scrolling past. Here + it IS the interface: the one permanent, fully-present object, whatever the + transcript above it is doing. */ +[data-hud-shell] [data-slot='composer-fade'] { + opacity: 1 !important; +} + +[data-hud-shell] [data-slot='composer-root'] { + /* Opaque, so anything docked to the composer (status stack, queue panel) + paints the same solid fill the bar does — they share this var. */ + --composer-fill: var(--dt-card); + /* The base rule adds 5px of transparent grab margin for the peel-out drag, + and the dock compensates with +10px of width. Neither applies here (the + HUD composer doesn't peel out), and left in they inset the card unevenly + inside its own float. */ + padding: 0 !important; + width: 100% !important; +} + +/* The composer surface paints TWO layers: the surface itself, plus an + `absolute inset-0 -z-10` fill+glass div behind it (composerFill / + composerSurfaceGlass). Two stacked fills read as a box inside a box, so the + backing layer goes and the surface carries the single fill. */ +[data-hud-shell] [data-slot='composer-surface'] > .pointer-events-none.absolute.inset-0 { + display: none !important; +} + +/* Opaque, pill-rounded — the Spotlight bar. Same surface tokens the composer + already uses; only the radius steps up so a lone floating bar reads as an + object rather than a snippet of app chrome. + + ALWAYS fully opaque. It's the input — the one thing that must never be + compromised by whatever's behind the window, in any state. The idle/engaged + breathing belongs to the thread and the chat surface; the bar just sits + there, solid, like Spotlight does. */ +[data-hud-shell] [data-slot='composer-surface'] { + border: 1px solid var(--ui-stroke-secondary) !important; + /* Same radius as the band's top and the window's own corners, so the HUD + reads as one object however the band is behaving. Its top corners let a + sliver of band through, which is the point — the glass wraps the bar's + shoulders instead of stopping in a straight line above it. */ + border-radius: 0.75rem !important; + background: var(--dt-card) !important; + backdrop-filter: none !important; + -webkit-backdrop-filter: none !important; + box-shadow: none !important; +} + +/* The exit button: a real control, visible at rest, on a card chip so it + reads against any backdrop. + + Square (aspect-ratio 1/1 on the app's titlebar button size) and INSET from + the band's corner — tucked flush it read as a mistake; a chip floating in + the corner with its own margin reads as intentional. anchor() to the band: + calc()-based attempts drifted because the composer's measured vars are + inline-scoped and stale outside [data-chat-surface]. */ +[data-hud-shell] [data-slot='composer-bounds'] { + anchor-name: --hud-band; +} + +[data-hud-shell] [data-hud-exit] { + position-anchor: --hud-band; + left: auto; + bottom: auto; + right: calc(anchor(right) + 0.5rem); + top: calc(anchor(top) + 0.5rem); + aspect-ratio: 1; + height: auto; + background: var(--dt-card) !important; + border: 1px solid var(--ui-stroke-tertiary); + border-radius: 0.5rem; + /* Part of the band, so it lives and dies with it — same states, same timing. + Left visible at rest it is a lone chip hovering in empty space a hundred + pixels above the bar; revealed on hovering the window it pops up whenever + the cursor crosses the HUD on its way somewhere else. */ + opacity: 0; + transition: opacity var(--hud-fade) ease var(--hud-fade-delay); + /* And it is untouchable on exactly the same terms as the band it sits in — + engaged or nothing. A faded-out control that still takes clicks is just an + invisible button floating over the app behind. */ + pointer-events: none; +} + +[data-hud-shell]:focus-within [data-hud-exit] { + pointer-events: auto; +} + +[data-hud-shell][data-hud-recent] [data-hud-exit], +[data-hud-shell]:focus-within [data-hud-exit] { + opacity: 0.75; + transition-duration: var(--hud-reveal); + transition-delay: 0s; +} + +[data-hud-shell][data-hud-recent] [data-hud-exit]:hover, +[data-hud-shell]:focus-within [data-hud-exit]:hover { + opacity: 1; +} + +/* The dock's fade-to-surface gradient assumes a chat column behind it; over a + transparent HUD it's a grey smear. */ +[data-hud-shell] [data-slot='composer-root'] > .pointer-events-none { + display: none !important; +} + +/* Portaled overlays (model picker, menus, dialogs, tooltips, the command + palette) mount into , OUTSIDE [data-hud-shell] — so they never inherit + its `no-drag` carve-outs. And Electron's draggable region is RECT math, not + paint order: the shell's window-sized `drag` rect covers wherever the menu + opens, and anything that doesn't explicitly subtract itself is handed to the + window manager. The menu rendered, sat correctly, and swallowed every click + ("I can't even select a model"). Carve every portal back out. */ +html:has([data-hud-shell]) body > *:not(#root) { + -webkit-app-region: no-drag; +} + +/* Popovers in a ~320px-tall window. Two separate problems: + + 1. Radix clamps the popover to --radix-*-available-height, but fixed + max-heights INSIDE the panels (the model catalog's max-h-[max(150px, + 30dvh)] list) don't shrink with it, so the list keeps its own floor and + the menu overflows the window. + 2. The window IS the collision boundary, so even a correctly-clamped menu + has only ~320px to live in. + + (1) is ours to fix in CSS; inner scrollers yield to the window instead of + their own floor. (2) is why the menus are compact here rather than roomy — + a HUD is a small window and a 400px menu cannot fit in it. */ +html:has([data-hud-shell]) [data-slot='dropdown-menu-content'] [class*='max-h-'], +html:has([data-hud-shell]) [data-slot='popover-content'] [class*='max-h-'] { + max-height: max(5rem, calc(100dvh - 9rem)) !important; +} + +/* The menu panels themselves: never taller than the window minus the bar. */ +html:has([data-hud-shell]) [data-slot='dropdown-menu-content'], +html:has([data-hud-shell]) [data-slot='popover-content'] { + max-height: calc(100dvh - 4.5rem) !important; + overflow-y: auto; +} + +/* Hide the scrollbar — a HUD with a visible track stops reading as an overlay. */ +[data-hud-shell] [data-slot='composer-bounds'] * { + scrollbar-width: none; +} + +[data-hud-shell] [data-slot='composer-bounds'] *::-webkit-scrollbar { + display: none; +}