mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-23 16:36:23 +00:00
Add createInstanceWindow() — a full-chrome peer of the primary that renders the complete app (sidebar, routing, its own draft) against the shared backend, so several GUI windows can run at once. Mirrors the primary's window options + chatWindowWebPreferences (backgroundThrottling stays off so a streamed answer never stalls when blurred) but never overwrites the mainWindow global and doesn't respawn the backend — the renderer's getConnection() joins the running one. New windows cascade off their source via the pure, tested instanceWindowBounds(). Exposed via the hermes🪟openInstance IPC and a "New Window" File menu item. Per-window fullscreen state now targets the window itself, and titlebar/native-theme repaints reach every open chat window instead of only the primary. Retires the now-orphaned compact new-session pop-out (its only caller was ⌘⇧N, repointed in the follow-up commit): drops createNewSessionWindow, the hermes🪟openNewSession handler, and the newSession/new=1 URL flag.
146 lines
5.1 KiB
TypeScript
146 lines
5.1 KiB
TypeScript
// Secondary "session windows" — one extra OS window per chat so a user can
|
|
// work with multiple chats side by side. The pure, Electron-free pieces live
|
|
// here so they can be unit-tested with node --test (mirroring how the rest of
|
|
// electron/*.ts splits testable logic out of the main.ts monolith).
|
|
|
|
import { pathToFileURL } from 'node:url'
|
|
|
|
// Secondary windows open at the minimum usable size — a compact side panel for
|
|
// subagent watch / cmd-click session pop-out, not a second full desktop.
|
|
const SESSION_WINDOW_MIN_WIDTH = 420
|
|
const SESSION_WINDOW_MIN_HEIGHT = 620
|
|
|
|
// Shared webPreferences for every window that renders the chat transcript — the
|
|
// primary window AND the secondary session windows. Keeping it in one place is
|
|
// the whole point: the two BrowserWindow definitions in main.ts used to be
|
|
// hand-copied, and the secondary windows silently lost `backgroundThrottling:
|
|
// false`, so a streamed answer stalled until the window regained focus.
|
|
//
|
|
// `backgroundThrottling: false` is load-bearing: the transcript streams to the
|
|
// screen through a requestAnimationFrame-gated flush, which Chromium pauses for
|
|
// blurred/occluded windows. A streaming chat app must keep painting in the
|
|
// background, so every chat window opts out. The preload path is injected
|
|
// because it depends on the Electron entry's __dirname.
|
|
function chatWindowWebPreferences(preloadPath: string) {
|
|
return {
|
|
preload: preloadPath,
|
|
contextIsolation: true,
|
|
webviewTag: true,
|
|
sandbox: true,
|
|
nodeIntegration: false,
|
|
devTools: true,
|
|
backgroundThrottling: false
|
|
}
|
|
}
|
|
|
|
// Build the renderer URL for a secondary window. The renderer uses a
|
|
// HashRouter, so the session route lives after the '#'. The `?win=secondary`
|
|
// flag MUST sit in the query string BEFORE the '#': anything after the '#' is
|
|
// treated as the route by HashRouter and would break routeSessionId(). The
|
|
// renderer reads the flag from window.location.search to suppress the install /
|
|
// onboarding overlays and the global session sidebar. `watch=1` marks a
|
|
// spectator window (e.g. a running subagent's session): the renderer resumes it
|
|
// lazily so the gateway never builds an agent just to stream into it.
|
|
function buildSessionWindowUrl(sessionId: string, { devServer, rendererIndexPath, watch }: any = {}) {
|
|
const query = `?win=secondary${watch ? '&watch=1' : ''}`
|
|
const route = `#/${encodeURIComponent(sessionId)}`
|
|
|
|
if (devServer) {
|
|
const base = devServer.endsWith('/') ? devServer.slice(0, -1) : devServer
|
|
|
|
return `${base}/${query}${route}`
|
|
}
|
|
|
|
return `${pathToFileURL(rendererIndexPath).toString()}${query}${route}`
|
|
}
|
|
|
|
// Full "instance" windows (⌘⇧N / the "New Window" command) open a complete app
|
|
// peer, not a compact chat. Cascade each one off its source window's bounds so a
|
|
// new window doesn't land exactly on top of the one it was spawned from. Pure so
|
|
// it's unit-testable; the Electron glue (reading the focused window's bounds,
|
|
// constructing the BrowserWindow) stays in main.ts. `base` is the source
|
|
// window's current bounds, or null when there's no live source window — then the
|
|
// persisted primary geometry (`fallback`) is used as-is.
|
|
const INSTANCE_CASCADE_OFFSET = 32
|
|
|
|
function instanceWindowBounds(base: { x: number; y: number; width: number; height: number } | null, fallback: any) {
|
|
if (!base) {
|
|
return fallback
|
|
}
|
|
|
|
return {
|
|
width: base.width,
|
|
height: base.height,
|
|
x: base.x + INSTANCE_CASCADE_OFFSET,
|
|
y: base.y + INSTANCE_CASCADE_OFFSET
|
|
}
|
|
}
|
|
|
|
// A small registry keyed by sessionId that guarantees one window per chat:
|
|
// opening a session that already has a live window focuses it instead of
|
|
// spawning a duplicate, and a window removes itself from the registry when it
|
|
// closes. The actual BrowserWindow construction is injected (the `factory`) so
|
|
// this module stays free of Electron and is unit-testable.
|
|
function createSessionWindowRegistry() {
|
|
const windows = new Map()
|
|
|
|
function openOrFocus(sessionId, factory) {
|
|
const key = typeof sessionId === 'string' ? sessionId.trim() : ''
|
|
|
|
if (!key) {
|
|
return null
|
|
}
|
|
|
|
const existing = windows.get(key)
|
|
|
|
if (existing && !existing.isDestroyed()) {
|
|
// Focus-or-create: never duplicate a window for the same chat.
|
|
if (typeof existing.isMinimized === 'function' && existing.isMinimized()) {
|
|
existing.restore?.()
|
|
}
|
|
|
|
if (typeof existing.isVisible === 'function' && !existing.isVisible()) {
|
|
existing.show?.()
|
|
}
|
|
|
|
existing.focus?.()
|
|
|
|
return existing
|
|
}
|
|
|
|
const win = factory(key)
|
|
|
|
if (!win) {
|
|
return null
|
|
}
|
|
|
|
windows.set(key, win)
|
|
|
|
// Self-cleanup on close so the registry never holds a destroyed window.
|
|
win.on?.('closed', () => {
|
|
if (windows.get(key) === win) {
|
|
windows.delete(key)
|
|
}
|
|
})
|
|
|
|
return win
|
|
}
|
|
|
|
return {
|
|
openOrFocus,
|
|
get: key => windows.get(key),
|
|
has: key => windows.has(key),
|
|
get size() {
|
|
return windows.size
|
|
}
|
|
}
|
|
}
|
|
|
|
export {
|
|
buildSessionWindowUrl,
|
|
chatWindowWebPreferences,
|
|
createSessionWindowRegistry,
|
|
instanceWindowBounds,
|
|
SESSION_WINDOW_MIN_HEIGHT,
|
|
SESSION_WINDOW_MIN_WIDTH
|
|
}
|