diff --git a/apps/desktop/electron/main.ts b/apps/desktop/electron/main.ts index a3f6a768a713d..60bc3a019edb9 100644 --- a/apps/desktop/electron/main.ts +++ b/apps/desktop/electron/main.ts @@ -9034,6 +9034,289 @@ function closePetOverlay() { petOverlayWindow = null } +// ── HUD mode ──────────────────────────────────────────────────────────────── +// +// The chrome-free floating chat: a transparent, frameless, always-on-top +// window showing only the composer and its scrollback, so Hermes can be driven +// while the user works in another app. +// +// Unlike the pet overlay / quick entry, this is a FULL app renderer with its +// own gateway — the same thing createInstanceWindow() spawns, reshaped. That +// is deliberate: the HUD renders the real chat surface, so its composer is the +// app's composer (slash commands, attachments, queue, voice) instead of a +// lookalike that drifts. Entering HUD mode hides the main window; leaving +// restores it. +let hudWindow = null + +// Whether the main window was visible when HUD mode was entered, so exiting +// puts the desktop back as it was rather than raising a window the user had +// already minimized. +let hudRestoreMainWindow = false + +// The session the HUD is currently on, reported by its renderer whenever the +// selection changes. Leaving HUD mode is a HANDOFF, not just a window close: +// the gateway binds a session's event stream to exactly one socket, so the +// turn the HUD started is streaming to the HUD's socket and the app window +// hears nothing. The app has to re-resume that session to take the stream +// back, and it can only do that if it knows which session to ask for — the +// HUD may have switched sessions, or started a new one the app has never +// seen. Main is the only party that outlives the HUD's renderer, so it holds +// the id and hands it over in the close broadcast. +let hudSessionId = null + +// A wide, short bar parked near the bottom of the active display — the shape +// of a game chat frame, and where one belongs. Defaults only: once the user +// moves or resizes the HUD, hud-state.json wins (same pattern as the main +// window's window-state.json). +const HUD_WIDTH = 620 +const HUD_HEIGHT = 320 +const HUD_BOTTOM_MARGIN = 72 +const HUD_STATE_PATH = path.join(app.getPath('userData'), 'hud-state.json') + +function readHudState() { + try { + const raw = JSON.parse(fs.readFileSync(HUD_STATE_PATH, 'utf8')) + + if ( + [raw?.x, raw?.y, raw?.width, raw?.height].every(v => Number.isFinite(v)) && + raw.width >= 380 && + raw.height >= 160 + ) { + return raw + } + } catch { + // First run / unreadable — fall through to defaults. + } + + return null +} + +function persistHudState() { + if (!hudWindow || hudWindow.isDestroyed()) { + return + } + + try { + const { x, y, width, height } = hudWindow.getNormalBounds() + fs.mkdirSync(path.dirname(HUD_STATE_PATH), { recursive: true }) + writeFileAtomic(HUD_STATE_PATH, JSON.stringify({ x, y, width, height }, null, 2)) + } catch (err) { + rememberLog(`[hud-state] persist failed: ${err?.message || err}`) + } +} + +const schedulePersistHudState = debounce(persistHudState, 250) + +function hudBounds() { + // Remembered spot first — validated against the LIVE displays so a HUD + // parked on an unplugged monitor comes back on-screen instead of lost. + const saved = readHudState() + + if (saved) { + const onScreen = screen.getAllDisplays().some(d => { + const a = d.workArea + + return ( + saved.x < a.x + a.width - 40 && + saved.x + saved.width > a.x + 40 && + saved.y < a.y + a.height - 40 && + saved.y + saved.height > a.y + 40 + ) + }) + + if (onScreen) { + return saved + } + } + + const display = screen.getDisplayNearestPoint(screen.getCursorScreenPoint()) + const area = display?.workArea + + if (!area) { + return { width: HUD_WIDTH, height: HUD_HEIGHT, x: undefined, y: undefined } + } + + const width = Math.min(HUD_WIDTH, area.width) + const height = Math.min(HUD_HEIGHT, area.height) + + return { + width, + height, + x: Math.round(area.x + (area.width - width) / 2), + y: Math.round(Math.max(area.y, area.y + area.height - height - HUD_BOTTOM_MARGIN)) + } +} + +function hudUrl(sessionId) { + const query = '?win=hud' + const route = sessionId ? `#/${encodeURIComponent(sessionId)}` : '#/' + + if (DEV_SERVER) { + return `${DEV_SERVER.endsWith('/') ? DEV_SERVER.slice(0, -1) : DEV_SERVER}/${query}${route}` + } + + return `${pathToFileURL(resolveRendererIndex()).toString()}${query}${route}` +} + +// Tell every window whether the HUD is up, so a toggle in any of them reads +// the truth even when the HUD is closed from its own side (⌘W / its exit row). +// Carries the HUD's session so the app window can re-home onto it on the way +// out (see hudSessionId). +function broadcastHudState(open) { + const payload = { open, sessionId: hudSessionId } + + for (const win of BrowserWindow.getAllWindows()) { + if (!win.isDestroyed()) { + win.webContents.send('hermes:hud:changed', payload) + } + } +} + +function spawnHudWindow(sessionId) { + const win = new BrowserWindow({ + ...hudBounds(), + minWidth: 380, + minHeight: 160, + frame: false, + transparent: true, + resizable: true, + movable: true, + minimizable: false, + maximizable: false, + fullscreenable: false, + // Same rationale as the pet overlay: on Windows/Linux keep the helper out + // of the taskbar/alt-tab list; on macOS use an NSPanel so the frameless + // window never becomes the app's cmd-tab anchor. + skipTaskbar: !IS_MAC, + hasShadow: false, + alwaysOnTop: true, + type: IS_MAC ? 'panel' : undefined, + // 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', + // The full chat webPreferences — this window streams a real transcript, so + // it needs everything a chat window needs (preload bridge, autoplay for + // voice, the shared throttling contract). + webPreferences: chatWindowWebPreferences(PRELOAD_PATH) + }) + + win.setAlwaysOnTop(true, IS_MAC ? 'floating' : 'screen-saver') + win.setHiddenInMissionControl?.(true) + + try { + win.setVisibleOnAllWorkspaces( + true, + IS_MAC ? { visibleOnFullScreen: true, skipTransformProcessType: true } : undefined + ) + } catch { + // Not supported everywhere — best effort. + } + + // Streaming into a window that is ALWAYS blurred (the user is in another + // app) is the entire feature, so it gets the same stream-aware unthrottling + // every chat window does. + streamThrottle.register(win) + wireCommonWindowHandlers(win, zoomWiringForWindowKind('chat')) + + // Remember where the user parks and sizes it (debounced — these fire many + // times mid-drag). + win.on('moved', schedulePersistHudState) + win.on('resized', schedulePersistHudState) + + win.once('ready-to-show', () => { + if (win.isDestroyed()) { + return + } + + win.show() + win.focus() + + // Step the app aside: the HUD IS the surface now. + if (hudRestoreMainWindow && mainWindow && !mainWindow.isDestroyed()) { + mainWindow.hide() + } + }) + + win.on('closed', () => { + if (hudWindow === win) { + hudWindow = null + } + + // Closed from its own side (⌘W) — put the app back so the user is never + // left with no surface, and correct every window's toggle. + restoreMainWindowFromHud() + broadcastHudState(false) + }) + + loadWindowUrl(win, hudUrl(sessionId), 'HUD') + + return win +} + +// Put the app window back the way HUD mode found it. +function restoreMainWindowFromHud() { + if (!hudRestoreMainWindow) { + return + } + + hudRestoreMainWindow = false + + if (mainWindow && !mainWindow.isDestroyed()) { + mainWindow.show() + } +} + +function openHudWindow(sessionId) { + if (hudWindow && !hudWindow.isDestroyed()) { + // Already up, but pointed somewhere else — switch it rather than just + // raising it. Asking for HUD mode from another tab means "put THIS + // conversation in the HUD", and a plain focus leaves the wrong one there. + if (sessionId && sessionId !== hudSessionId) { + hudSessionId = sessionId + hudWindow.webContents.send('hermes:hud:goto', sessionId) + // Keep every window's idea of where the HUD is pointed in step, so the + // toggle keeps reading "switch" vs "dismiss" correctly. + broadcastHudState(true) + } + + focusWindow(hudWindow) + + return hudWindow + } + + hudRestoreMainWindow = Boolean(mainWindow && !mainWindow.isDestroyed() && mainWindow.isVisible()) + hudSessionId = sessionId || null + hudWindow = spawnHudWindow(sessionId) + broadcastHudState(true) + + return hudWindow +} + +function closeHudWindow() { + const win = hudWindow + hudWindow = null + + if (win && !win.isDestroyed()) { + // Null'd first so the 'closed' handler doesn't broadcast a second time. + win.removeAllListeners('closed') + win.close() + } + + restoreMainWindowFromHud() + broadcastHudState(false) + + if (mainWindow && !mainWindow.isDestroyed()) { + focusWindow(mainWindow) + } +} + // ── Quick Entry ───────────────────────────────────────────────────────────── // // A global shortcut summons a small frameless always-on-top composer from @@ -9702,6 +9985,51 @@ ipcMain.on('hermes:pet-overlay:control', (_event, payload) => { mainWindow.webContents.send('hermes:pet-overlay:control', payload) }) + +// --- HUD mode (chrome-free floating chat) ----------------------------------- +ipcMain.handle('hermes:hud:open', async (_event, request) => { + openHudWindow(typeof request?.sessionId === 'string' ? request.sessionId : null) + + 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. +// `forward` keeps mousemove flowing so the renderer can re-arm when the cursor +// reaches the bar. +ipcMain.on('hermes:hud:ignore-mouse', (_event, ignore) => { + if (hudWindow && !hudWindow.isDestroyed()) { + hudWindow.setIgnoreMouseEvents(Boolean(ignore), { forward: true }) + } +}) + +// The HUD renderer reporting which session it is on, so the close broadcast +// can hand it back to the app window (see hudSessionId). +ipcMain.on('hermes:hud:session', (event, sessionId) => { + if (hudWindow && !hudWindow.isDestroyed() && event.sender === hudWindow.webContents) { + hudSessionId = typeof sessionId === 'string' && sessionId ? sessionId : null + } +}) +ipcMain.handle('hermes:hud:close', async () => { + closeHudWindow() + + return { ok: true } +}) ipcMain.handle('hermes:bootstrap:reset', async () => { // Renderer's "Reload and retry" path. Clear the latched failure and // reset connection state so the next startHermes() call restarts the @@ -12001,6 +12329,17 @@ app.on('before-quit', event => { closePetOverlay() wakeIndicatorController.close() + // Same for the HUD — an always-on-top panel outliving the app would leave a + // floating composer with nothing behind it. Close it directly rather than via + // closeHudWindow(): that also re-shows the main window, which is wrong on the + // way out (and `hudRestoreMainWindow` may still be armed from entering HUD). + if (hudWindow && !hudWindow.isDestroyed()) { + hudWindow.removeAllListeners('closed') + hudWindow.destroy() + } + + hudWindow = null + // Same for the Quick Entry composer — and release its global accelerator so a // quitting Hermes never keeps another app's chord hostage. closeQuickEntryWindow() diff --git a/apps/desktop/electron/preload.ts b/apps/desktop/electron/preload.ts index dd9537b26fd73..fbce8197951f1 100644 --- a/apps/desktop/electron/preload.ts +++ b/apps/desktop/electron/preload.ts @@ -46,6 +46,30 @@ contextBridge.exposeInMainWorld('hermesDesktop', { return () => ipcRenderer.removeListener('hermes:pet-overlay:control', listener) } }, + // HUD mode: the chrome-free floating chat. A full app renderer (own gateway) + // sized as a floating bar, so it mounts the real composer. Main owns the + // window; `onChanged` keeps every window's toggle truthful. + hud: { + open: request => ipcRenderer.invoke('hermes:hud:open', request), + close: () => ipcRenderer.invoke('hermes:hud:close'), + setIgnoreMouse: ignore => ipcRenderer.send('hermes:hud:ignore-mouse', ignore), + setVibrancy: on => ipcRenderer.invoke('hermes:hud:vibrancy', on), + // The HUD tells main which session it is on; main hands that back to the + // app window when the HUD closes, so the app can re-home onto it. + setSession: sessionId => ipcRenderer.send('hermes:hud:session', sessionId), + onGoto: callback => { + const listener = (_event, sessionId) => callback(sessionId) + ipcRenderer.on('hermes:hud:goto', listener) + + return () => ipcRenderer.removeListener('hermes:hud:goto', listener) + }, + onChanged: callback => { + const listener = (_event, state) => callback(state) + ipcRenderer.on('hermes:hud:changed', listener) + + return () => ipcRenderer.removeListener('hermes:hud:changed', listener) + } + }, // Quick Entry: the global-hotkey mini composer window. Main owns the OS // shortcut + the persisted preference; the quick window only captures text // and hands it back, and the primary renderer submits it through the normal diff --git a/apps/desktop/electron/zoom.ts b/apps/desktop/electron/zoom.ts index 4a6d0db6bbf6b..9dfdbbc527753 100644 --- a/apps/desktop/electron/zoom.ts +++ b/apps/desktop/electron/zoom.ts @@ -98,9 +98,10 @@ export function installZoomReassertOnWindowEvents(win, reassert, platform = proc } /** - * Zoom-wiring decision per window kind. Chat windows (main + session) keep - * global UI zoom; the pet overlay and the Quick Entry composer opt out because - * they size their own OS window and inheriting zoom would crop/overflow them. + * Zoom-wiring decision per window kind. Chat windows (main + session + the HUD) + * keep global UI zoom; the pet overlay and the Quick Entry composer opt out + * because they size their own OS window and inheriting zoom would crop or + * overflow them. * * Extracted so the "helper windows opt out, everything else opts in" contract is * unit-testable without booting a BrowserWindow or reading source. diff --git a/apps/desktop/src/app/chat/composer/composer-utils.ts b/apps/desktop/src/app/chat/composer/composer-utils.ts index 21e3c1ac4a12e..617e650511191 100644 --- a/apps/desktop/src/app/chat/composer/composer-utils.ts +++ b/apps/desktop/src/app/chat/composer/composer-utils.ts @@ -9,10 +9,18 @@ import type { TriggerState } from './text-utils' export const COMPOSER_STACK_BREAKPOINT_PX = 320 // Above the stack breakpoint but still cramped: the model pill sheds its label -// for its chevron icon (freeing ~120px) so the controls stop crowding the input -// before the whole row has to stack. Progressive collapse: full pill → icon -// pill → stacked. -export const COMPOSER_COMPACT_PILL_PX = 440 +// for its chevron icon so the controls stop crowding the input before the whole +// row has to stack. Progressive collapse: full pill → icon pill → stacked. +// +// Sized off what the controls actually cost, because guessing put the two +// stages on top of each other. With the full pill the controls take ~284px +// (pill 111 + the icon cluster), so at the old 440 the inline input was ~156px +// — barely over its 128px minimum. A few words wrapped, wrapping is what +// stacks the row, and the pill's chevron arrived at the same moment the row +// gave up, which is the one thing progressive collapse is supposed to avoid. +// At 560 the label goes while the input still has ~276px, and the ~110px the +// chevron frees is spent keeping the row single for another stretch. +export const COMPOSER_COMPACT_PILL_PX = 560 // A single editor line is ~28px (--composer-input-min-height 1.625rem + 0.5rem // vertical padding). Anything taller means the text wrapped to a second line, diff --git a/apps/desktop/src/app/chat/composer/hooks/use-composer-draft.ts b/apps/desktop/src/app/chat/composer/hooks/use-composer-draft.ts index b5bf87dc4660b..c32db0f6c2211 100644 --- a/apps/desktop/src/app/chat/composer/hooks/use-composer-draft.ts +++ b/apps/desktop/src/app/chat/composer/hooks/use-composer-draft.ts @@ -3,7 +3,14 @@ import { type RefObject, useCallback, useEffect, useLayoutEffect, useRef, useSta import { SLASH_COMMAND_RE } from '@/lib/chat-runtime' import { sanitizeComposerInput } from '@/lib/composer-input-sanitize' -import { type ComposerAttachment, stashSessionDraft, takeSessionDraft } from '@/store/composer' +import { + type ComposerAttachment, + type ComposerDraftSyncMode, + onComposerDraftSyncRequest, + reloadPersistedDrafts, + stashSessionDraft, + takeSessionDraft +} from '@/store/composer' import { isBrowsingHistory } from '@/store/composer-input-history' import { @@ -391,6 +398,38 @@ export function useComposerDraft({ } }, [activeQueueSessionKey]) // eslint-disable-line react-hooks/exhaustive-deps + // The HUD handoff's two verbs. Entering HUD mode flushes this editor's text + // into the shared stash so the HUD's composer boots with it; leaving repaints + // from the stash so whatever the HUD typed (or sent, clearing it) is what the + // app window shows. The per-session swap effect above can't cover either one: + // the session scope doesn't change, so it never re-consults the stash. + const syncDraft = (mode: ComposerDraftSyncMode) => { + if (mode === 'flush') { + window.clearTimeout(draftPersistTimerRef.current) + pendingDraftPersistRef.current = null + stashAt(draftScopeRef.current, syncDraftFromEditor()) + + return + } + + reloadPersistedDrafts() + const stashed = takeSessionDraft(draftScopeRef.current) + loadIntoComposer(stashed.text, stashed.attachments) + } + + const syncDraftRef = useRef(syncDraft) + syncDraftRef.current = syncDraft + + useEffect( + () => + onComposerDraftSyncRequest(({ mode, target: requested }) => { + if (requested === target) { + syncDraftRef.current(mode) + } + }), + [target] + ) + // pagehide is load-bearing: React skips effect cleanups on reload, so Cmd+R // inside the debounce/rAF window would drop trailing keystrokes without this. // eslint-disable-next-line no-restricted-syntax -- legitimate non-atom ref write (see eslint rule comment) diff --git a/apps/desktop/src/app/chat/composer/hooks/use-composer-metrics.ts b/apps/desktop/src/app/chat/composer/hooks/use-composer-metrics.ts index 7353dc102e6bb..8c09b2f45f84c 100644 --- a/apps/desktop/src/app/chat/composer/hooks/use-composer-metrics.ts +++ b/apps/desktop/src/app/chat/composer/hooks/use-composer-metrics.ts @@ -8,7 +8,6 @@ import { COMPOSER_SURFACE_HEIGHT_VAR, setSurfaceVar } from '@/app/chat/surface-vars' -import { useMediaQuery } from '@/hooks/use-media-query' import { useResizeObserver } from '@/hooks/use-resize-observer' import { COMPOSER_COMPACT_PILL_PX, COMPOSER_SINGLE_LINE_MAX_PX, COMPOSER_STACK_BREAKPOINT_PX } from '../composer-utils' @@ -43,7 +42,6 @@ export function useComposerMetrics({ const [tight, setTight] = useState(false) // Wider than `tight`: the pill goes icon-only before the row has to stack. const [compactPill, setCompactPill] = useState(false) - const narrow = useMediaQuery('(max-width: 30rem)') // Edge signals, not the live text: these only re-render when emptiness / the // presence of a non-trailing newline actually flips, so typing within a line @@ -191,7 +189,17 @@ export function useComposerMetrics({ } }, [composerRef]) - // Pill compacts on real width (tile/pane), OR when stacked for any reason - // (viewport-narrow / wrapped) so the controls row never over-runs. - return { compactPill: compactPill || narrow || tight, stacked: expanded || narrow || tight } + // Both decisions come from the composer's OWN measured width, never the + // viewport's. There used to be a `(max-width: 30rem)` media query in here as + // well, and it quietly outranked everything: any window under 480px stacked + // the row AND compacted the pill in the same instant, regardless of how much + // room the composer actually had. That collapsed the whole progressive ladder + // into one step for small windows — HUD mode is ~470px, so it never saw the + // ladder at all — and it disagreed with the measured breakpoints (320 to + // stack) by 160px. The ResizeObserver knows the real width; the viewport is + // not a proxy for it. + // + // The pill still compacts whenever the row stacks, so the controls row can't + // over-run once it has the width to itself. + return { compactPill: compactPill || tight, stacked: expanded || tight } } diff --git a/apps/desktop/src/app/chat/index.tsx b/apps/desktop/src/app/chat/index.tsx index 2ed1169f4e168..f8c2c86a6790c 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 c66ac3679959b..1bd11309a1063 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 dbd886e18fa98..5fbfd102b156e 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 f1394a262ffbe..37c04bd90836e 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 @@ -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/hooks/use-keybinds.ts b/apps/desktop/src/app/hooks/use-keybinds.ts index 8a30c3049ff1e..1e2107f517686 100644 --- a/apps/desktop/src/app/hooks/use-keybinds.ts +++ b/apps/desktop/src/app/hooks/use-keybinds.ts @@ -2,6 +2,7 @@ import { useEffect, useRef } from 'react' import { useNavigate } from 'react-router' import { closeActiveTab } from '@/app/chat/close-tab' +import { hudTargetSessionId } from '@/app/hud/handoff' import { setTerminalTakeover } from '@/app/right-sidebar/store' import { closeActiveTerminal, createTerminal, cycleTerminal } from '@/app/right-sidebar/terminal/terminals' import { @@ -24,6 +25,7 @@ import { findPrevious as findPreviousMatch, openFindBar } from '@/store/find-in-page' +import { toggleHud } from '@/store/hud' import { $capture, $comboIndex, endCapture, setBinding } from '@/store/keybinds' import { requestSessionSearchFocus, @@ -227,6 +229,7 @@ export function useKeybinds(deps: KeybindRuntimeDeps): void { 'view.toggleReview': toggleReview, 'view.toggleStatusbar': toggleStatusbarVisible, 'view.showFiles': showFiles, + 'view.toggleHud': () => toggleHud(hudTargetSessionId()), 'view.showTerminal': () => togglePaneVisible('terminal'), // Create first so the pane's open-effect ensure sees a non-empty set and // doesn't also spawn one — net effect is exactly one fresh terminal. 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 0000000000000..d833529b8b9c2 --- /dev/null +++ b/apps/desktop/src/app/hud/click-through.ts @@ -0,0 +1,88 @@ +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: wherever the cursor is over something, and whenever anything in + * the window holds focus. `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 + + // Hit-test rather than enumerate. Everything the HUD doesn't want to catch + // — the shell's dead space, the sheet, the faded band — is already + // `pointer-events: none`, so anything the document hands back at this point + // is something real: the bar, a control, the exit chip, a popover, a dialog. + // Listing those instead is how links and dialogs ended up unclickable, since + // portalled overlays live outside the shell and moving focus into one takes + // `:focus-within` with it. + const overSomething = () => { + if (!point) { + return false + } + + const hit = document.elementFromPoint(point.x, point.y) + + return Boolean(hit) && hit !== root && hit !== document.body && hit !== document.documentElement + } + + // Focus is asked of the document, not of the shell. A dialog or popover is + // portalled to `document.body`, so focus entering one leaves the shell's + // `:focus-within` — and a HUD that decides it is unused the moment it opens + // a dialog goes mouse-transparent underneath it. Nothing but the HUD lives + // in this window, so any focus at all is the HUD in use. `hasFocus` gates it + // so a stale `activeElement` — the composer keeps it after you click away to + // another app — can't pin the HUD solid forever. + const focused = () => document.hasFocus() && document.activeElement !== document.body + + const apply = () => { + const next = !focused() && !overSomething() + + if (ignoring !== next) { + ignoring = next + setIgnoreMouse(next) + } + } + + const onMove = (event: MouseEvent) => { + point = { x: event.clientX, y: event.clientY } + apply() + } + + apply() + window.addEventListener('mousemove', onMove) + document.addEventListener('focusin', apply) + document.addEventListener('focusout', apply) + + return () => { + setIgnoreMouse(false) + window.removeEventListener('mousemove', onMove) + document.removeEventListener('focusin', apply) + document.removeEventListener('focusout', apply) + } + }, [rootRef]) +} 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/handoff.ts b/apps/desktop/src/app/hud/handoff.ts new file mode 100644 index 0000000000000..dfd41a57f9a45 --- /dev/null +++ b/apps/desktop/src/app/hud/handoff.ts @@ -0,0 +1,126 @@ +/** + * HUD ⇄ app-window handoff. + * + * The gateway binds a session's event stream to exactly ONE socket — the last + * one to submit or resume it (`session["transport"]`). The HUD is a full + * renderer with its own socket, so entering HUD mode moves that binding to the + * HUD and the app window stops hearing the session entirely: no deltas, no + * turn-complete, no draft clear. Nothing to poll for either, since mid-turn + * there is nothing persisted to re-pull. + * + * So leaving HUD mode is a re-home, not a window close. The app window resumes + * the session the HUD ended on — the existing hydration path, which rebinds the + * transport, reconciles the transcript, and picks up an in-flight turn — and + * repaints its composer from the shared draft stash the HUD has been writing. + */ + +import { useStore } from '@nanostores/react' +import { useEffect, useRef } from 'react' + +import { reloadPersistedDrafts, requestComposerDraftSync } from '@/store/composer' +import { reportHudSession, watchHudState } from '@/store/hud' +import { $selectedStoredSessionId } from '@/store/session' +import { focusOpenSession, sessionTileDelegate } from '@/store/session-states' +import { isHudWindow } from '@/store/windows' + +import { getActiveComposer } from '../chat/composer/focus' +import { openSession, type OpenSessionNavigate } from '../open-session' +import { sessionRoute } from '../routes' + +/** Session tiles route on `tile:` (see session-tile.tsx). */ +const TILE_TARGET_PREFIX = 'tile:' + +/** + * The conversation HUD mode should open on: whichever chat surface the user is + * actually looking at. + * + * `$selectedStoredSessionId` is the WORKSPACE pane's session, so reading it + * alone sent the main tab into the HUD no matter which tile was fronted — the + * tabs exist precisely so that isn't the same question. `getActiveComposer()` + * already answers it for the focus bus, healing to the visible surface when its + * cached claim is buried or gone, and a tile's routing key IS its stored + * session id. + */ +export function hudTargetSessionId(): null | string { + const target = getActiveComposer() + const tile = target.startsWith(TILE_TARGET_PREFIX) ? target.slice(TILE_TARGET_PREFIX.length) : null + + return tile || $selectedStoredSessionId.get() +} + +interface HudHandoffParams { + navigate: OpenSessionNavigate + resumeSession: (storedSessionId: string) => unknown +} + +/** App-window side: take the session back when the HUD goes away. Also keeps + * the titlebar toggle honest when the HUD is closed from its own side. */ +export function useHudHandoff({ navigate, resumeSession }: HudHandoffParams): void { + const paramsRef = useRef({ navigate, resumeSession }) + paramsRef.current = { navigate, resumeSession } + + useEffect(() => { + // The HUD's own renderer mounts the same wiring; it is the window going + // away, so it has nothing to re-home. + if (isHudWindow()) { + return + } + + return watchHudState(hudSessionId => { + // The HUD may have typed or sent since this window last read the stash. + reloadPersistedDrafts() + + const selected = $selectedStoredSessionId.get() + const target = hudSessionId ?? selected + + // Somewhere other than the workspace pane. If it is an open tile, front + // it and re-resume THROUGH the tile delegate: the ordinary resume path + // enforces "a session is either main or a tile, never both" and would + // close the tile to take it into main, quietly rearranging tabs the user + // opened on purpose. Otherwise it's a session this window has never seen + // — route to it and let the route resume do the rest, including loading + // its draft as the composer's scope swaps. + if (target && target !== selected) { + const delegate = focusOpenSession(target) === 'tile' ? sessionTileDelegate() : null + + if (delegate) { + void delegate.resumeTile(target).catch(() => undefined) + + return + } + + openSession(target, paramsRef.current.navigate) + + return + } + + // Same session, so the composer's scope never changes and its + // per-session swap effect will never re-consult the stash. Repaint it. + requestComposerDraftSync('reload') + + if (target) { + void paramsRef.current.resumeSession(target) + } + }) + }, []) +} + +/** HUD side: follow a retarget. Asking for HUD mode from another tab while the + * HUD is already up switches the conversation showing in it. */ +export function useHudGoto(navigate: OpenSessionNavigate): void { + const navigateRef = useRef(navigate) + navigateRef.current = navigate + + useEffect(() => window.hermesDesktop?.hud?.onGoto?.(id => navigateRef.current(sessionRoute(id))), []) +} + +/** HUD side: keep main told which session this window is on. */ +export function useReportHudSession(): void { + const selectedStoredSessionId = useStore($selectedStoredSessionId) + + useEffect(() => { + if (isHudWindow()) { + reportHudSession(selectedStoredSessionId) + } + }, [selectedStoredSessionId]) +} 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 0000000000000..dee6a30af7c85 --- /dev/null +++ b/apps/desktop/src/app/hud/hud-shell.tsx @@ -0,0 +1,379 @@ +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 { useHudGlass } from './glass' +import { useHudGoto, useReportHudSession } from './handoff' + +/** 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 = 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 + * :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, () => 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) + } + + setRecent(true) + 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(onMessages) + const offBusy = $busy.subscribe(busy => busy && bump()) + + return () => { + offMessages() + offBusy() + + if (timerRef.current) { + clearTimeout(timerRef.current) + } + } + }, []) + + 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 +} + +/** + * 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, 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 + // (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('top') + + useEffect(() => { + // 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 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)) + + // How tall the band actually needs to be. The transcript is packed to the + // bottom, so this is the distance from the topmost visible row down to the + // bar — which is 0 on a fresh session, and the glass then collapses behind + // the bar instead of painting an empty slab over the whole window. + // + // Written straight to the element rather than through state: it changes on + // every stream flush, and the sheet resizing must not re-render the tree. + const rows = el?.querySelectorAll('[data-slot="aui_thread-content"] > *:not([data-slot])') + const box = el?.getBoundingClientRect() + + // Measured from the bar outward, so it flips with the layout: parked at + // the bottom the transcript grows up from the bar, parked at the top it + // hangs down from it. + const span = + !rows?.length || !box + ? 0 + : root.dataset.hudEdge === 'top' + ? rows[rows.length - 1].getBoundingClientRect().bottom - box.top + : box.bottom - rows[0].getBoundingClientRect().top + + root.style.setProperty('--hud-band-height', `${Math.max(0, Math.round(span))}px`) + + // …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 + // surface var that never lands here, so the clearance silently fell back + // to the root estimate and reserved ~20px more than the bar occupies — + // a visible hole under the last message. + const bar = root.querySelector('[data-slot="composer-dock"]') + const barHeight = bar?.getBoundingClientRect().height ?? 0 + + if (bar) { + ro.observe(bar) + root.style.setProperty('--hud-bar-height', `${Math.round(barHeight)}px`) + } + + void barHeight + } + + // 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() + } + }, []) + + useHudGlass(rootRef, recent || held) + 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/app/session/hooks/use-message-stream/index.ts b/apps/desktop/src/app/session/hooks/use-message-stream/index.ts index a73852f6e923f..979bbc1e2f5e2 100644 --- a/apps/desktop/src/app/session/hooks/use-message-stream/index.ts +++ b/apps/desktop/src/app/session/hooks/use-message-stream/index.ts @@ -656,12 +656,21 @@ export function useMessageStream({ const hasInlineError = nextMessages.some(m => m.role === 'assistant' && m.error && !m.hidden) const lastVisible = [...nextMessages].reverse().find(m => !m.hidden) const unresolvedUserTail = lastVisible?.role === 'user' + // Having streamed the reply normally means this window owns the whole + // turn and re-reading stored history would be wasted work. That only + // holds for a turn it STARTED: an adopted one (resumed onto a session + // already running elsewhere) arrives reply-first, with no prompt row, + // so it has to hydrate or the user's own message never shows up. shouldHydrate = - !completionError && !hasInlineError && !unresolvedUserTail && (!state.sawAssistantPayload || !finalText) + !completionError && + !hasInlineError && + !unresolvedUserTail && + (state.adoptedRunningTurn || !state.sawAssistantPayload || !finalText) return { ...state, messages: nextMessages, + adoptedRunningTurn: false, streamId: null, pendingBranchGroup: null, awaitingResponse: false, diff --git a/apps/desktop/src/app/session/hooks/use-session-actions.test.tsx b/apps/desktop/src/app/session/hooks/use-session-actions.test.tsx index 3d2cfa6447af4..0decc4c5bd556 100644 --- a/apps/desktop/src/app/session/hooks/use-session-actions.test.tsx +++ b/apps/desktop/src/app/session/hooks/use-session-actions.test.tsx @@ -963,6 +963,7 @@ describe('resumeSession failure recovery', () => { interimBoundaryPending: false, interrupted: false, messages: [], + adoptedRunningTurn: false, model: '', needsInput: false, pendingBranchGroup: null, diff --git a/apps/desktop/src/app/session/hooks/use-session-actions/index.ts b/apps/desktop/src/app/session/hooks/use-session-actions/index.ts index b9bb96024da54..bfa29501510f3 100644 --- a/apps/desktop/src/app/session/hooks/use-session-actions/index.ts +++ b/apps/desktop/src/app/session/hooks/use-session-actions/index.ts @@ -767,8 +767,17 @@ export function useSessionActions({ } else { const runtimeInfo = applyRuntimeInfo(activated.info) - let activatedMessages = - activated.messages.length || activated.inflight || activated.queued + // `omit_messages` means the response carries NO transcript, not + // an empty one — the cache is the base and the live projection is + // a tail to graft onto it. Reconciling against the empty list + // instead rebuilds the thread out of the projection alone, so + // activating a session that is mid-turn somewhere else (leaving + // HUD mode is exactly that) collapsed the whole conversation down + // to the in-flight prompt until the turn finished and the + // post-turn hydrate restored it. + let activatedMessages = activated.messages_omitted + ? appendLiveSessionProjection(cachedViewState.messages, activated) + : activated.messages.length || activated.inflight || activated.queued ? reconcileAuthoritativeMessages(activated.messages, cachedViewState.messages, activated) : cachedViewState.messages @@ -805,7 +814,11 @@ export function useSessionActions({ ...(runtimeInfo ?? {}), messages: activatedMessages, busy: running, - awaitingResponse: running + awaitingResponse: running, + // Adopting someone else's turn: we'll stream its reply + // without ever having received its prompt, so the settle + // path must not take the "I saw it all" shortcut. + adoptedRunningTurn: state.adoptedRunningTurn || running }), storedSessionId ) @@ -965,7 +978,15 @@ export function useSessionActions({ ? preserveLocalPendingTurnMessages(currentMessages, resumeStartMessages) : currentMessages - const resumedMessages = reconcileAuthoritativeMessages(resumed.messages, previousMessages, resumed) + // Omitted, not empty — same trap as the activate path above. + // The REST prefetch IS the transcript here; the resume payload + // only contributes the live tail, so graft rather than rebuild. + // (Without a usable prefetch there is nothing better to stand + // on, so the projection alone remains the degraded fallback.) + const resumedMessages = + resumed.messages_omitted && prefetchApplied && prefetchMatchesResumedSession + ? appendLiveSessionProjection(localSnapshot, resumed) + : reconcileAuthoritativeMessages(resumed.messages, previousMessages, resumed) return chatMessageArraysEquivalent(currentMessages, resumedMessages) ? currentMessages : resumedMessages })() @@ -1022,6 +1043,7 @@ export function useSessionActions({ messages: messagesForView, busy: resumedRunning, awaitingResponse: resumedRunning && !recoveredInFlightTail, + adoptedRunningTurn: state.adoptedRunningTurn || resumedRunning, ...(inFlightRecovery.applied ? { sawAssistantPayload: true, diff --git a/apps/desktop/src/app/shell/titlebar-controls.tsx b/apps/desktop/src/app/shell/titlebar-controls.tsx index 6c3c3e5fc5fdb..ee9b566bbdd63 100644 --- a/apps/desktop/src/app/shell/titlebar-controls.tsx +++ b/apps/desktop/src/app/shell/titlebar-controls.tsx @@ -2,6 +2,7 @@ import { useStore } from '@nanostores/react' import { type ComponentProps, type MouseEvent, type ReactNode, useEffect, useState } from 'react' import { useLocation, useNavigate } from 'react-router' +import { hudTargetSessionId } from '@/app/hud/handoff' import { toggleLayoutEditMode } from '@/components/pane-shell/edit-mode' import { resetLayoutTree } from '@/components/pane-shell/tree/store' import { Button } from '@/components/ui/button' @@ -11,6 +12,7 @@ import { useI18n } from '@/i18n' import { triggerHaptic } from '@/lib/haptics' import { cn } from '@/lib/utils' import { $hapticsMuted, toggleHapticsMuted } from '@/store/haptics' +import { toggleHud } from '@/store/hud' import { $fileBrowserOpen, $sidebarOpen, @@ -19,7 +21,7 @@ import { toggleSidebarOpen } from '@/store/layout' -import { appViewForPath, isOverlayView, SETTINGS_ROUTE } from '../routes' +import { appViewForPath, isOverlayView } from '../routes' import { titlebarButtonClass } from './titlebar' @@ -180,6 +182,20 @@ export function TitlebarControls({ leftTools = [], tools = [], onOpenSettings }: }, title: t.titlebar.layoutEditorTitle }, + { + // No `title`: TitlebarToolButton passes `title` to TipKeybindLabel as a + // text OVERRIDE, so a long sentence there replaces the short label and + // crowds the ⌘⇧H hint off the tooltip. Label only — the hint is appended + // from the action registry, same as every other tool here. + actionId: 'view.toggleHud', + icon: , + id: 'hud', + label: t.titlebar.enterHud, + onSelect: () => { + triggerHaptic('open') + toggleHud(hudTargetSessionId()) + } + }, { active: hapticsMuted, icon: , @@ -187,16 +203,6 @@ export function TitlebarControls({ leftTools = [], tools = [], onOpenSettings }: label: hapticsMuted ? t.titlebar.unmuteHaptics : t.titlebar.muteHaptics, onSelect: toggleHaptics }, - { - actionId: 'keybinds.openPanel', - icon: , - id: 'keybinds', - label: t.titlebar.openKeybinds, - onSelect: () => { - triggerHaptic('open') - navigate(`${SETTINGS_ROUTE}?tab=keybinds`) - } - }, { actionId: 'nav.settings', icon: , diff --git a/apps/desktop/src/app/types.ts b/apps/desktop/src/app/types.ts index f7452607f7c83..743c2b95be019 100644 --- a/apps/desktop/src/app/types.ts +++ b/apps/desktop/src/app/types.ts @@ -187,6 +187,12 @@ export interface ClientSessionState { awaitingResponse: boolean streamId: string | null sawAssistantPayload: boolean + /** This window picked up a turn it did not start — it resumed onto a session + * that was already running somewhere else (leaving HUD mode, opening a + * pop-out mid-turn). It therefore holds the reply but never received the + * prompt, so the usual "I streamed it, my transcript is complete" shortcut + * is false and the turn must hydrate from stored history when it settles. */ + adoptedRunningTurn: boolean pendingBranchGroup: string | null interrupted: boolean /** True after message.interim finalized a bubble in the still-running turn. */ diff --git a/apps/desktop/src/components/assistant-ui/thread/list.tsx b/apps/desktop/src/components/assistant-ui/thread/list.tsx index a2409ad80947b..f34884d1070c2 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 { messagePaintWeight } from '@/lib/render-weight' import { cn } from '@/lib/utils' import { + $threadScrolledUp, onScrollToBottomRequest, onThreadEditClose, onThreadEditOpen, @@ -410,6 +411,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/global.d.ts b/apps/desktop/src/global.d.ts index fbea2424abf22..d33d17f682ca2 100644 --- a/apps/desktop/src/global.d.ts +++ b/apps/desktop/src/global.d.ts @@ -62,6 +62,19 @@ declare global { onState: (callback: (payload: PetOverlayStatePayload) => void) => () => void onControl: (callback: (payload: PetOverlayControl) => void) => () => void } + // HUD mode: the chrome-free floating chat. A FULL app renderer with its + // own gateway (like an instance window), sized and skinned as a floating + // bar — so it mounts the real composer rather than a lookalike. Main + // owns the window; `onChanged` keeps every window's toggle truthful. + hud?: { + open: (request?: { sessionId?: null | string }) => Promise<{ ok: boolean }> + close: () => Promise<{ ok: boolean }> + setIgnoreMouse: (ignore: boolean) => void + setVibrancy: (on: boolean) => Promise<{ ok: boolean }> + setSession: (sessionId: null | string) => void + onGoto: (callback: (sessionId: string) => void) => () => void + onChanged: (callback: (state: { open: boolean; sessionId: null | string }) => void) => () => void + } // Quick Entry: a global-hotkey mini composer window. Main owns the OS // shortcut registration + the persisted preference (it must restore the // shortcut on a cold launch without the renderer visiting Settings), so diff --git a/apps/desktop/src/i18n/ar.ts b/apps/desktop/src/i18n/ar.ts index 65ba2f448dddf..9a6853609d06c 100644 --- a/apps/desktop/src/i18n/ar.ts +++ b/apps/desktop/src/i18n/ar.ts @@ -172,7 +172,8 @@ export const ar = defineLocale({ unmuteHaptics: 'تفعيل الاهتزازات', openSettings: 'فتح الإعدادات', openStarmap: 'فتح خريطة الذاكرة', - openKeybinds: 'اختصارات لوحة المفاتيح', + enterHud: 'وضع HUD', + exitHud: 'إنهاء وضع HUD', layoutEditor: 'محرر التخطيط', layoutEditorTitle: 'محرر التخطيط — انقر مع ⌘ لإعادة ضبط التخطيط' }, diff --git a/apps/desktop/src/i18n/en.ts b/apps/desktop/src/i18n/en.ts index ee3eeed7df751..f897e4c1ae04f 100644 --- a/apps/desktop/src/i18n/en.ts +++ b/apps/desktop/src/i18n/en.ts @@ -202,7 +202,8 @@ export const en: Translations = { unmuteHaptics: 'Unmute haptics', openSettings: 'Open settings', openStarmap: 'Open memory graph', - openKeybinds: 'Keyboard shortcuts', + enterHud: 'HUD mode', + exitHud: 'Exit HUD mode', layoutEditor: 'Layout editor', layoutEditorTitle: 'Layout editor — ⌘-click resets the layout' }, @@ -261,6 +262,7 @@ export const en: Translations = { 'view.toggleReview': 'Toggle review pane', 'view.toggleStatusbar': 'Toggle status bar', 'view.showFiles': 'Show file browser', + 'view.toggleHud': 'Toggle HUD mode', 'view.showTerminal': 'Toggle terminal', 'view.newTerminal': 'New terminal', 'view.nextTerminal': 'Next terminal', diff --git a/apps/desktop/src/i18n/types.ts b/apps/desktop/src/i18n/types.ts index d29f3d38e0178..9693ab312e7a9 100644 --- a/apps/desktop/src/i18n/types.ts +++ b/apps/desktop/src/i18n/types.ts @@ -244,7 +244,8 @@ export interface Translations { unmuteHaptics: string openSettings: string openStarmap: string - openKeybinds: string + enterHud: string + exitHud: string layoutEditor: string layoutEditorTitle: string } diff --git a/apps/desktop/src/i18n/zh.ts b/apps/desktop/src/i18n/zh.ts index bb063e5752d35..53d170e798ad1 100644 --- a/apps/desktop/src/i18n/zh.ts +++ b/apps/desktop/src/i18n/zh.ts @@ -197,7 +197,8 @@ export const zh: Translations = { unmuteHaptics: '开启触感反馈', openSettings: '打开设置', openStarmap: '打开记忆图谱', - openKeybinds: '键盘快捷键', + enterHud: 'HUD 模式', + exitHud: '退出 HUD 模式', layoutEditor: '布局编辑器', layoutEditorTitle: '布局编辑器 — ⌘ 点击重置布局' }, diff --git a/apps/desktop/src/lib/chat-runtime.ts b/apps/desktop/src/lib/chat-runtime.ts index 99585a3c7642f..c581daaea9c7e 100644 --- a/apps/desktop/src/lib/chat-runtime.ts +++ b/apps/desktop/src/lib/chat-runtime.ts @@ -52,6 +52,7 @@ export function createClientSessionState( awaitingResponse: false, streamId: null, sawAssistantPayload: false, + adoptedRunningTurn: false, pendingBranchGroup: null, interrupted: false, interimBoundaryPending: false, diff --git a/apps/desktop/src/lib/keybinds/actions.ts b/apps/desktop/src/lib/keybinds/actions.ts index 392292bd7c2f7..be2d9d1317674 100644 --- a/apps/desktop/src/lib/keybinds/actions.ts +++ b/apps/desktop/src/lib/keybinds/actions.ts @@ -116,6 +116,11 @@ export const KEYBIND_ACTIONS: readonly KeybindActionMeta[] = [ // ⌘G — "g" for git; the review pane is the source-control view. { id: 'view.toggleReview', category: 'view', defaults: ['mod+g'] }, { id: 'view.showFiles', category: 'view', defaults: [] }, + // ⌘⇧H — "h" for HUD. Enters/leaves the chrome-free floating chat: the app + // window steps aside and a composer + live reply float over whatever the + // user is working in. Ships bound because the whole point is leaving the app + // without reaching for it — but the titlebar button is the discoverable door. + { id: 'view.toggleHud', category: 'view', defaults: ['mod+shift+h'] }, // Control+` everywhere (literal `ctrl`, NOT `mod`): ⌘` is macOS-reserved for // cycling app windows, so VS Code/Cursor/Zed bind the terminal to Ctrl+` on // every platform. Off macOS `ctrl` folds to `mod` (= Ctrl), so it's unchanged. diff --git a/apps/desktop/src/store/composer.ts b/apps/desktop/src/store/composer.ts index 7f32348cd367e..6783db9637abd 100644 --- a/apps/desktop/src/store/composer.ts +++ b/apps/desktop/src/store/composer.ts @@ -151,6 +151,84 @@ function loadPersistedDraftTexts(): [string, SessionDraft][] { const draftsBySession = new Map(loadPersistedDraftTexts()) +/** + * Re-read the persisted drafts written by ANOTHER window into this one's map. + * + * Drafts are per-renderer state backed by shared localStorage, and the map + * above is read exactly once at module load. Two windows on the same session + * (HUD mode ⇄ the app window) therefore diverge the moment either one types: + * whichever window mounted first keeps its stale copy forever, so text typed + * in the HUD is simply gone when you return to the app. + * + * Merge, don't clobber — the local map may hold attachments (never persisted) + * that the incoming text-only snapshot can't know about. + */ +export function reloadPersistedDrafts(): void { + const incoming = new Map(loadPersistedDraftTexts()) + + for (const [key, draft] of incoming) { + const local = draftsBySession.get(key) + draftsBySession.set(key, local?.attachments.length ? { ...local, text: draft.text } : draft) + } + + // A key that vanished from storage was cleared (sent) in the other window. + for (const key of [...draftsBySession.keys()]) { + if (!incoming.has(key)) { + draftsBySession.delete(key) + } + } +} + +// localStorage `storage` events fire across Electron BrowserWindows of the +// same origin, so the other window's write is the sync signal. +if (typeof window !== 'undefined') { + window.addEventListener('storage', event => { + if (event.key === SESSION_DRAFTS_STORAGE_KEY) { + reloadPersistedDrafts() + } + }) +} + +/** + * Push a composer's live text into the shared stash (`flush`), or repaint it + * from the stash (`reload`). + * + * Both halves of the HUD handoff need this. The stash is the only draft state + * two windows share, but a mounted composer only consults it when its session + * scope changes — so entering HUD mode has to flush the app window's in-editor + * text down to the stash before the HUD boots and reads it, and leaving has to + * repaint the app's editor from whatever the HUD left behind (usually empty, + * because the HUD sent it). + * + * Dispatched synchronously, unlike the focus bus: the flush must complete + * before the HUD window is created. + */ +const DRAFT_SYNC_EVENT = 'hermes:composer-draft-sync' + +export type ComposerDraftSyncMode = 'flush' | 'reload' + +interface ComposerDraftSyncDetail { + mode: ComposerDraftSyncMode + target: string +} + +export function requestComposerDraftSync(mode: ComposerDraftSyncMode, target = 'main'): void { + if (typeof window !== 'undefined') { + window.dispatchEvent(new CustomEvent(DRAFT_SYNC_EVENT, { detail: { mode, target } })) + } +} + +export function onComposerDraftSyncRequest(handler: (detail: ComposerDraftSyncDetail) => void): () => void { + if (typeof window === 'undefined') { + return () => undefined + } + + const listener = (event: Event) => handler((event as CustomEvent).detail) + window.addEventListener(DRAFT_SYNC_EVENT, listener) + + return () => window.removeEventListener(DRAFT_SYNC_EVENT, listener) +} + function persistDraftTexts() { try { const entries = [...draftsBySession] diff --git a/apps/desktop/src/store/hud.ts b/apps/desktop/src/store/hud.ts new file mode 100644 index 0000000000000..6135081508e7d --- /dev/null +++ b/apps/desktop/src/store/hud.ts @@ -0,0 +1,99 @@ +/** + * HUD mode — the chrome-free floating chat. + * + * A transparent, frameless, always-on-top window showing nothing but the REAL + * composer with the reply scrolling above it, so Hermes can be driven while + * the user works in another app (Figma, a browser). + * + * It is NOT a puppet window. Unlike the pet overlay / quick entry, the HUD is + * a full app renderer with its own gateway — the same thing `openWindow()` + * spawns, just reshaped. That is the whole design: the HUD renders `ChatView` + * in its `hud` variant, so the composer IS the app's composer (attachments, + * slash commands, queue, voice, model pill) rather than a lookalike that + * drifts. This module owns only the mode flag and the window lifecycle. + */ + +import { atom } from 'nanostores' + +import { requestComposerDraftSync } from '@/store/composer' +import { isHudWindow } from '@/store/windows' + +/** Whether a HUD window is currently up. In the HUD's own renderer this is + * always true (it IS the HUD); in the main window it tracks the child so the + * titlebar toggle reads correctly. + * + * Deliberately NOT persisted. The HUD is a live window main owns, so it can + * never outlive the app — a remembered `true` from the last run just makes the + * first toggle a no-op ("the button does nothing after a restart"). Main + * broadcasts the truth on every change, which is the only authority there is. */ +export const $hudActive = atom(isHudWindow()) + +/** True only in the HUD window itself — the renderer flag that swaps the app + * shell for the slim floating layout. Constant for the window's life, so it + * never invalidates a render path mid-session. */ +export const $hudMode = atom(isHudWindow()) + +/** Which conversation the HUD is showing, as far as this window knows. Lets the + * toggle tell "switch the HUD to this tab" apart from "dismiss the HUD". */ +export const $hudSession = atom(null) + +/** True when the shell exposes HUD mode (desktop only). */ +export const canUseHud = (): boolean => + typeof window !== 'undefined' && typeof window.hermesDesktop?.hud?.open === 'function' + +export function openHud(sessionId?: null | string): void { + const api = window.hermesDesktop?.hud + + if (!api) { + return + } + + // Push whatever is half-typed here into the shared draft stash BEFORE the + // HUD window exists, so its composer boots with the text rather than racing + // a cross-window storage event that lands after it has already painted. + requestComposerDraftSync('flush') + + $hudActive.set(true) + $hudSession.set(sessionId ?? null) + void api.open({ sessionId: sessionId ?? null }) +} + +/** Leave HUD mode. Callable from either window — main closes the child, the + * HUD closes itself; both restore the app window. */ +export function closeHud(): void { + const api = window.hermesDesktop?.hud + + if (!api) { + return + } + + $hudActive.set(false) + $hudSession.set(null) + void api.close() +} + +export const toggleHud = (sessionId?: null | string) => ($hudActive.get() ? closeHud() : openHud(sessionId)) + +/** Tell main which session this HUD is on. Main holds it (the HUD's renderer + * doesn't outlive the window) and hands it back in the close broadcast so the + * app window knows what to re-home onto. */ +export const reportHudSession = (sessionId: null | string): void => window.hermesDesktop?.hud?.setSession?.(sessionId) + +/** + * Track the HUD window's real state so the titlebar toggle can't go stale when + * the HUD is closed from its own side (⌘W, its exit button), and hand the + * app window the session the HUD ended on. Returns a disposer; no-ops outside + * Electron. + */ +export function watchHudState(onClosed?: (sessionId: null | string) => void): () => void { + const off = window.hermesDesktop?.hud?.onChanged?.(({ open, sessionId }) => { + $hudActive.set(open) + $hudSession.set(open ? sessionId : null) + + if (!open) { + onClosed?.(sessionId) + } + }) + + return off ?? (() => {}) +} diff --git a/apps/desktop/src/store/windows.ts b/apps/desktop/src/store/windows.ts index 349b29d12ba0b..75d48097748a1 100644 --- a/apps/desktop/src/store/windows.ts +++ b/apps/desktop/src/store/windows.ts @@ -29,6 +29,31 @@ export function isSecondaryWindow(): boolean { let watchWindowCache: boolean | null = null +// A "hud" window is HUD mode: the chrome-free floating chat. Unlike the pet +// overlay / quick entry it is a FULL app renderer with its own gateway — the +// flag only tells the shell to render the slim floating layout (composer + +// scrollback) instead of the pane tree, so the composer it shows is the real +// one. Read from location.search for the same reason as the flag above. +let hudWindowCache: boolean | null = null + +export function isHudWindow(): boolean { + if (hudWindowCache !== null) { + return hudWindowCache + } + + let result = false + + try { + result = new URLSearchParams(window.location.search).get('win') === 'hud' + } catch { + result = false + } + + hudWindowCache = result + + return result +} + // A "watch" window spectates a session that is being driven elsewhere (a // running subagent). It resumes lazily — the gateway registers history + a // transport for the live mirror without building an agent, so opening it is @@ -51,6 +76,13 @@ export function isWatchWindow(): boolean { return result } +// True for any window that is NOT the primary app instance — a secondary +// session window or the HUD. Single-claim channels (the quick-entry capture +// bridge, the pet overlay control bridge) and the install/onboarding overlays +// belong to the primary alone: two windows answering one channel turns one +// keystroke into N prompts, and a HUD is the last place to paint onboarding. +export const isAuxiliaryWindow = (): boolean => isSecondaryWindow() || isHudWindow() + // True when running inside the Electron desktop shell (the preload bridge is // present). The "open in new window" affordance is desktop-only. export function canOpenSessionWindow(): boolean { diff --git a/apps/desktop/src/styles.css b/apps/desktop/src/styles.css index 2ee38e0b6584a..10b01d848b653 100644 --- a/apps/desktop/src/styles.css +++ b/apps/desktop/src/styles.css @@ -2264,3 +2264,619 @@ 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; + /* Arrive decisively and settle; leave gently and get out of the way. Linear + both directions is what made this feel mechanical. */ + --hud-ease-enter: cubic-bezier(0.16, 1, 0.3, 1); + --hud-ease-exit: cubic-bezier(0.4, 0, 0.7, 0.2); +} + +/* 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 colour, so it reads correctly on dark and light themes alike + and text keeps its normal contrast against it. + - VISIBILITY keeps the WoW fade: shown while a turn is recent/streaming + (`data-hud-recent`) or 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; + /* Anchored to the bar and sized to the transcript, exactly like the sheet + behind it — same box, same clock — so the text is clipped away as the panel + rolls rather than fading in place. + + Edge to edge with no dead margins, and it never computes where the bar's + top is: the bar simply covers this box's bottom. Every earlier attempt + derived that edge from --composer-surface-measured-height, which is the + composer's published height, rounded, while 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"). 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; square at the bottom, + where the bar covers it. */ + border-radius: 0.75rem 0.75rem 0 0; + /* No fill of its own — the sheet is its own layer behind this one + ([data-hud-glass]), so it can be sized to the transcript while this box + stays the full window for the drag region and the scroll container. */ + background: transparent !important; + /* Three opacity states: gone at rest, half strength when a turn is recent or + you've just let go of the composer (there to glance at, not to demand + attention), full while you're actually reading it. */ + opacity: 0; + translate: 0 var(--hud-exit-shift, 2.5rem); + transition: + opacity var(--hud-fade) var(--hud-ease-exit), + translate var(--hud-collapse) var(--hud-ease-exit); +} + +/* 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. + + No desktop blur under it, and that is a limit rather than an omission. CSS + backdrop-filter reaches nothing here — a transparent window's backdrop root + is the document, and the desktop was never in it (verified on the real + window, not assumed). macOS vibrancy does see the desktop, but WindowServer + composites it below the web contents after this process has finished drawing, + so no mask, clip or stacking order can shape it; left on under a fading tint + it stays a flat slab and the top of the band goes pale exactly where it + should be disappearing. The way through is NSVisualEffectView.maskImage + behind a small native addon, which is its own change. */ +[data-hud-shell] [data-hud-glass] { + /* The exit chip rides this box, not the band — the band is the whole window, + so anchoring there left the chip stranded in empty space above a short + transcript. */ + anchor-name: --hud-band; + position: absolute; + right: 0; + bottom: 0; + left: 0; + z-index: 0; + pointer-events: none; + border-radius: 0.75rem 0.75rem 0 0; + background: color-mix(in srgb, var(--dt-card) 55%, transparent); + /* Slides down behind the bar as it fades. A TRANSFORM, not height: it is + composited, so it stays smooth where animating height re-lays-out the panel + every frame and looked coarse — and it leaves the box alone, which matters + because the one time the transcript's own container was collapsed the + messages spilled out of a zero-height box. Translate rather than scale: the + panel keeps its proportions on the way out instead of being squashed. + + The height below still tracks the transcript; only the offset animates on + exit, and it finishes ahead of the fade so the panel has left while the last + of the text is still going. */ + height: min(100%, calc(var(--hud-band-height, 0px) + var(--hud-sheet-overhang, 0.75rem))); + translate: 0 var(--hud-exit-shift, 2.5rem); + opacity: 0; + transition: + opacity var(--hud-fade) var(--hud-ease-exit), + translate var(--hud-collapse) var(--hud-ease-exit), + height var(--hud-reveal) var(--hud-ease-enter), + background-color var(--hud-dim) var(--hud-ease-enter); +} + +/* Engaged: as tall as the transcript needs, capped at the window. An empty + session measures 0, so it stays rolled up. */ +[data-hud-shell][data-hud-recent] [data-hud-glass], +[data-hud-shell]:has([data-slot='composer-rich-input']:focus) [data-hud-glass] { + translate: none; + opacity: 1; + transition-duration: var(--hud-reveal); +} + +/* Engaged means the caret is in the composer, not merely that the window holds + focus somewhere. Activating a window restores focus to whatever had it last, + so `:focus-within` counted grabbing the bar to DRAG the HUD as sitting down + to use it, and the transcript flew open every time it was moved. Hit-testing + below stays on :focus-within — being permissive about what can be clicked is + harmless, being permissive about what lights up is not. */ +[data-hud-shell]:has([data-slot='composer-rich-input']:focus) [data-hud-glass] { + background: color-mix(in srgb, var(--dt-card) 92%, transparent); +} + +/* Flipped, the sheet hangs from the bar instead of standing on it. */ +[data-hud-shell][data-hud-edge='top'] [data-hud-glass] { + top: 0; + bottom: auto; + --hud-exit-shift: -2.5rem; + border-radius: 0 0 0.75rem 0.75rem; +} + +[data-hud-shell] [data-slot='composer-bounds'] { + pointer-events: none; +} + +/* Clickable whenever it's actually on screen, not only while the caret is in + the composer — a link in the transcript is worth clicking the moment you can + see it. Faded out it goes back to `none`, which is what lets clicks over the + HUD reach whatever you're really working in. */ +[data-hud-shell][data-hud-recent] [data-slot='composer-bounds'], +[data-hud-shell]:focus-within [data-slot='composer-bounds'] { + pointer-events: auto; +} + +/* Three states, not two. A turn landing brings the transcript up, but only part + way — it is there to be glanced at over whatever you are actually doing, and + full-strength text over another app reads as the HUD demanding attention it + hasn't earned. Focus is what promotes it to properly readable; the hold and + fade then take it away from wherever it was. */ +[data-hud-shell][data-hud-recent] [data-slot='composer-bounds'] { + opacity: 0.5; + translate: none; + /* Same duration AND curve as the sheet's tint step, or the two visibly ease + apart on the way down. */ + transition-duration: var(--hud-dim); + transition-timing-function: var(--hud-ease-enter); +} + +[data-hud-shell]:has([data-slot='composer-rich-input']:focus) [data-slot='composer-bounds'] { + opacity: 1; + translate: none; + transition-duration: var(--hud-reveal); + transition-delay: 0s; +} + +/* 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]:has([data-slot='composer-rich-input']:focus) { + --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 var(--hud-reveal) var(--hud-ease-enter), + 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(--hud-bar-height, var(--composer-fallback-height)) + 0.25rem) !important; +} + +[data-hud-shell][data-hud-edge='top'] [data-hud-drag-strip] { + top: auto; + bottom: 0; +} + +/* 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.35rem 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(--hud-bar-height, var(--composer-fallback-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; +} + +/* Focusables, not a list of the controls that happen to be in the bar today — + a drag region eats the clicks of anything it covers, so anything missed here + is a dead control. */ +[data-hud-shell] [data-slot='composer-rich-input'], +[data-hud-shell] + [data-slot='composer-dock'] + :is( + a[href], + button, + input, + label, + select, + textarea, + [contenteditable], + [role='button'], + [role='combobox'], + [role='menuitem'], + [role='switch'], + [tabindex]:not([tabindex='-1']) + ) { + -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-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) var(--hud-ease-exit); + /* And it takes clicks on exactly the same terms as the band it sits in — + visible or nothing. A faded-out control that still takes clicks is an + invisible button floating over the app behind; a lit one that doesn't is + the dead chip you get from keying this to focus alone. */ + pointer-events: none; +} + +[data-hud-shell][data-hud-recent] [data-hud-exit], +[data-hud-shell]:focus-within [data-hud-exit] { + pointer-events: auto; +} + +/* Flipped, the sheet's top corner is behind the bar — drop the chip clear of + it so it stays in the transcript rather than under the composer. */ +[data-hud-shell][data-hud-edge='top'] [data-hud-exit] { + top: calc(anchor(top) + var(--hud-bar-height, var(--composer-fallback-height)) + 0.5rem); +} + +[data-hud-shell][data-hud-recent] [data-hud-exit], +[data-hud-shell]:has([data-slot='composer-rich-input']:focus) [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]:has([data-slot='composer-rich-input']:focus) [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; +}