feat(desktop): open multiple full app windows (electron)

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.
This commit is contained in:
Brooklyn Nicholson 2026-07-20 18:02:44 -05:00
parent a41d280f95
commit b586e4eff2
3 changed files with 149 additions and 33 deletions

View file

@ -112,6 +112,7 @@ import {
buildSessionWindowUrl,
chatWindowWebPreferences,
createSessionWindowRegistry,
instanceWindowBounds,
SESSION_WINDOW_MIN_HEIGHT,
SESSION_WINDOW_MIN_WIDTH
} from './session-windows'
@ -4613,9 +4614,9 @@ function getNativeOverlayWidth() {
return computeNativeOverlayWidth({ isWindows: IS_WINDOWS, isWsl: IS_WSL, isMac: IS_MAC })
}
function getWindowState() {
function getWindowState(win = mainWindow) {
return {
isFullscreen: Boolean(mainWindow?.isFullScreen?.()),
isFullscreen: Boolean(win?.isFullScreen?.()),
nativeOverlayWidth: getNativeOverlayWidth(),
windowButtonPosition: getWindowButtonPosition()
}
@ -4716,18 +4717,21 @@ function sendOpenUpdatesRequested() {
mainWindow.focus()
}
function sendWindowStateChanged(nextIsFullscreen?: boolean) {
if (!mainWindow || mainWindow.isDestroyed()) {
// Push titlebar/fullscreen chrome state to a window's renderer. Defaults to the
// primary, but any full chat window (primary or a secondary "instance" peer)
// passes itself so its own fullscreen toggle drives its own traffic-light inset.
function sendWindowStateChanged(nextIsFullscreen?: boolean, target = mainWindow) {
if (!target || target.isDestroyed()) {
return
}
const { webContents } = mainWindow
const { webContents } = target
if (!webContents || webContents.isDestroyed()) {
return
}
const state = getWindowState()
const state = getWindowState(target)
if (typeof nextIsFullscreen === 'boolean') {
state.isFullscreen = nextIsFullscreen
@ -4765,6 +4769,11 @@ function buildApplicationMenu() {
template.push({
label: 'File',
submenu: [
// No accelerator: ⌘⇧N is a rebindable renderer keybind (session.newWindow);
// a menu accelerator would fight the rebind panel and (on macOS) be
// swallowed before the renderer sees it. Here purely for discoverability.
{ click: () => createInstanceWindow(), label: 'New Window' },
{ type: 'separator' },
IS_MAC
? {
// NO accelerator: on macOS a registered ⌘W is consumed by the OS
@ -7329,11 +7338,7 @@ function focusWindow(win) {
win.focus()
}
function spawnSecondaryWindow({
sessionId,
watch,
newSession
}: { sessionId?: string; watch?: boolean; newSession?: boolean } = {}) {
function spawnSecondaryWindow({ sessionId, watch }: { sessionId?: string; watch?: boolean } = {}) {
const icon = getAppIconPath()
const win = new BrowserWindow({
@ -7378,8 +7383,7 @@ function spawnSecondaryWindow({
buildSessionWindowUrl(sessionId, {
devServer: DEV_SERVER,
rendererIndexPath: DEV_SERVER ? undefined : resolveRendererIndex(),
watch,
newSession
watch
})
)
@ -7391,11 +7395,82 @@ function createSessionWindow(sessionId, { watch = false } = {}) {
return sessionWindows.openOrFocus(sessionId, () => spawnSecondaryWindow({ sessionId, watch }))
}
// Open a fresh compact window on the new-session draft (#/). Not registry-keyed:
// like ⌘N in a browser, every press opens a new window — and a draft window that
// later converts to a real session must not get refocused as if it were blank.
function createNewSessionWindow() {
return spawnSecondaryWindow({ newSession: true })
// Additional full "instance" windows — peers of the primary that render the
// COMPLETE app (sidebar, routing, its own draft) against the shared backend, so
// a user can run multiple GUI windows at once (⌘⇧N / the "New Window" palette
// command). Unlike the compact session windows they carry no `?win` flag. The
// primary mainWindow stays the notification / deep-link / pet-overlay anchor and
// is NOT tracked here. The set holds a strong reference so an open peer isn't
// garbage-collected, and drops it on close.
const instanceWindows = new Set<any>()
// Cascade a new instance off whichever window spawned it so it doesn't land
// exactly on top of its source. Falls back to the persisted primary geometry
// when there's no live source window (e.g. all windows closed on macOS). The
// pure cascade math lives in session-windows.ts (instanceWindowBounds).
function nextInstanceBounds() {
const source = BrowserWindow.getFocusedWindow() || mainWindow
const fallback = computeWindowOptions(readWindowState(), screen.getAllDisplays())
const base = source && !source.isDestroyed() ? source.getBounds() : null
return instanceWindowBounds(base, fallback)
}
// Open a new full-chrome instance window. Mirrors createWindow()'s window
// options (shared chatWindowWebPreferences keeps backgroundThrottling:false so a
// streamed answer never stalls in the background) but is a peer, not the
// primary: it never overwrites the mainWindow global, doesn't start the backend
// (the renderer's getConnection() joins the already-running one), and loads the
// plain renderer URL so the full app renders.
function createInstanceWindow() {
const icon = getAppIconPath()
const win = new BrowserWindow({
...nextInstanceBounds(),
minWidth: WINDOW_MIN_WIDTH,
minHeight: WINDOW_MIN_HEIGHT,
title: 'Hermes',
titleBarStyle: 'hidden',
titleBarOverlay: getTitleBarOverlayOptions(),
trafficLightPosition: IS_MAC ? WINDOW_BUTTON_POSITION : undefined,
vibrancy: IS_MAC ? 'sidebar' : undefined,
opacity: windowOpacity(),
icon,
show: false,
backgroundColor: getWindowBackgroundColor(),
webPreferences: chatWindowWebPreferences(PRELOAD_PATH)
})
instanceWindows.add(win)
if (IS_MAC) {
win.setWindowButtonPosition?.(WINDOW_BUTTON_POSITION)
}
win.once('ready-to-show', () => {
if (!win.isDestroyed()) {
win.show()
}
})
// Per-window fullscreen chrome: send this window its own titlebar inset so its
// traffic lights hide/show independently of the primary.
win.on('enter-full-screen', () => sendWindowStateChanged(true, win))
win.on('leave-full-screen', () => sendWindowStateChanged(false, win))
wireCommonWindowHandlers(win, zoomWiringForWindowKind('chat'))
win.on('closed', () => {
instanceWindows.delete(win)
})
if (DEV_SERVER) {
win.loadURL(DEV_SERVER)
} else {
win.loadURL(pathToFileURL(resolveRendererIndex()).toString())
}
return win
}
// The pet overlay: a single transparent, frameless, always-on-top window that
@ -7583,7 +7658,9 @@ function createWindow() {
if (!nativeThemeListenerInstalled) {
nativeThemeListenerInstalled = true
nativeTheme.on('updated', () => {
applyTitleBarOverlay(mainWindow)
for (const win of BrowserWindow.getAllWindows()) {
applyTitleBarOverlay(win)
}
})
}
}
@ -7824,8 +7901,8 @@ ipcMain.handle('hermes:window:openSession', async (_event, sessionId, opts) => {
return { ok: true }
})
ipcMain.handle('hermes:window:openNewSession', async () => {
createNewSessionWindow()
ipcMain.handle('hermes:window:openInstance', async () => {
createInstanceWindow()
return { ok: true }
})
@ -8613,7 +8690,13 @@ ipcMain.on('hermes:titlebar-theme', (_event, payload) => {
background: payload.background,
foreground: payload.foreground
}
applyTitleBarOverlay(mainWindow)
// Repaint the native (Windows/Linux) titlebar overlay on every open chat
// window, not just the primary — instance peers and session windows share the
// one app theme. applyTitleBarOverlay no-ops on the frameless pet overlay.
for (const win of BrowserWindow.getAllWindows()) {
applyTitleBarOverlay(win)
}
})
// Pin the native appearance to the app theme (see NATIVE_THEME_CONFIG_PATH).

View file

@ -2,7 +2,12 @@ import assert from 'node:assert/strict'
import { test } from 'vitest'
import { buildSessionWindowUrl, chatWindowWebPreferences, createSessionWindowRegistry } from './session-windows'
import {
buildSessionWindowUrl,
chatWindowWebPreferences,
createSessionWindowRegistry,
instanceWindowBounds
} from './session-windows'
// A minimal fake BrowserWindow: tracks listeners + destroyed state and lets a
// test fire the 'closed' event, mirroring the slice of the Electron API the
@ -83,10 +88,16 @@ test('buildSessionWindowUrl adds the watch flag for spectator windows, before th
assert.equal(url, 'http://localhost:5173/?win=secondary&watch=1#/abc')
})
test('buildSessionWindowUrl routes new-session windows to the draft (#/)', () => {
const url = buildSessionWindowUrl(null, { devServer: 'http://localhost:5173', newSession: true })
test('instanceWindowBounds cascades a new window off its source bounds', () => {
const bounds = instanceWindowBounds({ x: 100, y: 120, width: 1400, height: 900 }, { width: 1, height: 1 })
assert.equal(url, 'http://localhost:5173/?win=secondary&new=1#/')
assert.deepEqual(bounds, { width: 1400, height: 900, x: 132, y: 152 })
})
test('instanceWindowBounds falls back to the persisted geometry with no source window', () => {
const fallback = { width: 1280, height: 800 }
assert.equal(instanceWindowBounds(null, fallback), fallback)
})
test('registry opens one window per session and focuses on re-open', () => {

View file

@ -38,13 +38,12 @@ function chatWindowWebPreferences(preloadPath: string) {
// 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. `new=1` marks the compact
// scratch window; `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, newSession }: any = {}) {
const query = `?win=secondary${newSession ? '&new=1' : ''}${watch ? '&watch=1' : ''}`
const route = newSession ? '#/' : `#/${encodeURIComponent(sessionId)}`
// 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
@ -55,6 +54,28 @@ function buildSessionWindowUrl(sessionId: string, { devServer, rendererIndexPath
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
@ -119,6 +140,7 @@ export {
buildSessionWindowUrl,
chatWindowWebPreferences,
createSessionWindowRegistry,
instanceWindowBounds,
SESSION_WINDOW_MIN_HEIGHT,
SESSION_WINDOW_MIN_WIDTH
}