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;
+}