feat(desktop): HUD mode window and its session handoff
A transparent, frameless, always-on-top window that renders the real chat surface, so its composer is the app's composer rather than a lookalike that drifts. Main owns the window, its remembered geometry, and click-through. Leaving is a handoff, not a window close. The gateway binds a session's event stream to exactly one socket, so a turn started in the HUD streams only there and the app window hears nothing — no deltas, no turn-complete, no draft clear, and nothing to poll for mid-turn. So the app re-resumes the session the HUD ended on, which rebinds the transport, reconciles the transcript, and picks up an in-flight turn. Main carries the session id across, since it is the only party that outlives the HUD's renderer.
This commit is contained in:
parent
8560dc6b97
commit
7b0dbd2242
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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<HudHandoffParams>({ 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])
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 ?? (() => {})
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Reference in New Issue