diff --git a/apps/desktop/electron/main.ts b/apps/desktop/electron/main.ts index 116c11bfc5330..4fc687ebd3091 100644 --- a/apps/desktop/electron/main.ts +++ b/apps/desktop/electron/main.ts @@ -9034,6 +9034,273 @@ 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 window to a rounded silhouette rather than a hard rectangle. + roundedCorners: true, + 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()) { + 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 +9969,37 @@ 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 } +}) + +// 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 @@ -11994,6 +12292,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..2406798e40a6a 100644 --- a/apps/desktop/electron/preload.ts +++ b/apps/desktop/electron/preload.ts @@ -46,6 +46,23 @@ 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), + // 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), + 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/hud/handoff.ts b/apps/desktop/src/app/hud/handoff.ts new file mode 100644 index 0000000000000..0b385f10b742b --- /dev/null +++ b/apps/desktop/src/app/hud/handoff.ts @@ -0,0 +1,81 @@ +/** + * 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 { isHudWindow } from '@/store/windows' + +import { openSession, type OpenSessionNavigate } from '../open-session' + +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 + + // The HUD switched sessions (or started one this window has never seen): + // route to it and let the route resume do the rest, including loading + // that session's draft as the composer's scope swaps. + if (target && target !== selected) { + 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: 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/global.d.ts b/apps/desktop/src/global.d.ts index fbea2424abf22..6d2a570a1db1e 100644 --- a/apps/desktop/src/global.d.ts +++ b/apps/desktop/src/global.d.ts @@ -62,6 +62,17 @@ 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 + setSession: (sessionId: null | string) => 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/store/hud.ts b/apps/desktop/src/store/hud.ts new file mode 100644 index 0000000000000..e16f1f11a7a2c --- /dev/null +++ b/apps/desktop/src/store/hud.ts @@ -0,0 +1,92 @@ +/** + * 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()) + +/** 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) + 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) + 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) + + 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 {