Merge pull request #68259 from NousResearch/bb/multi-window

feat(desktop): run multiple GUI windows (New Window)
This commit is contained in:
brooklyn! 2026-07-20 18:50:42 -05:00 committed by GitHub
commit fb0ed8396c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
18 changed files with 356 additions and 88 deletions

View file

@ -0,0 +1,37 @@
import assert from 'node:assert/strict'
import { test } from 'vitest'
import { createEventDeduper } from './event-dedupe'
test('collapses the same key inside the window (two windows, one event)', () => {
const isDup = createEventDeduper(1000)
assert.equal(isDup('input:s1', 0), false, 'first window claims')
assert.equal(isDup('input:s1', 5), true, 'second window is deduped')
})
test('distinct keys are independent', () => {
const isDup = createEventDeduper(1000)
assert.equal(isDup('input:s1', 0), false)
assert.equal(isDup('approval:s1', 0), false, 'different kind')
assert.equal(isDup('input:s2', 0), false, 'different session')
})
test('re-fires once the window elapses', () => {
const isDup = createEventDeduper(1000)
assert.equal(isDup('turnDone:s1', 0), false)
assert.equal(isDup('turnDone:s1', 999), true, 'still within window')
assert.equal(isDup('turnDone:s1', 1000), false, 'window elapsed → fires again')
})
test('prunes stale keys so the map cannot grow unbounded', () => {
const isDup = createEventDeduper(1000)
for (let i = 0; i < 100; i += 1) {
// Each far-apart key is pruned before the next, so none linger as duplicates.
assert.equal(isDup(`turnDone:s${i}`, i * 2000), false)
}
})

View file

@ -0,0 +1,32 @@
// Cross-window de-dupe for one-shot side-effects (OS notifications, the turn-end
// sound, spoken replies). Every desktop window is its own renderer process, so N
// open windows each independently react to the same backend event. The main
// process is the one place they all share and it handles IPC serially, so it's
// the race-free owner: the first window to claim a key within the interval wins;
// peers see it's taken and stay quiet. Pure + injectable clock, so it's
// unit-testable without Electron.
const DEDUPE_INTERVAL_MS = 1000
// Returns true when `key` was already claimed within the interval (caller drops
// this one). Self-evicting: stale keys are pruned on every call, so the map
// can't grow unbounded.
export function createEventDeduper(intervalMs = DEDUPE_INTERVAL_MS) {
const lastSeenAt = new Map<string, number>()
return function isDuplicate(key: string, now = Date.now()): boolean {
for (const [k, at] of lastSeenAt) {
if (now - at >= intervalMs) {
lastSeenAt.delete(k)
}
}
if (lastSeenAt.has(key)) {
return true
}
lastSeenAt.set(key, now)
return false
}
}

View file

@ -67,6 +67,7 @@ import {
uninstallArgsForMode
} from './desktop-uninstall'
import { installEmbedReferer } from './embed-referer'
import { createEventDeduper } from './event-dedupe'
import { readDirForIpc } from './fs-read-dir'
import { probeGatewayWebSocket } from './gateway-ws-probe'
import { scanGitRepos } from './git-repo-scan'
@ -112,6 +113,7 @@ import {
buildSessionWindowUrl,
chatWindowWebPreferences,
createSessionWindowRegistry,
instanceWindowBounds,
SESSION_WINDOW_MIN_HEIGHT,
SESSION_WINDOW_MIN_WIDTH
} from './session-windows'
@ -4613,9 +4615,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 +4718,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 +4770,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 +7339,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 +7384,7 @@ function spawnSecondaryWindow({
buildSessionWindowUrl(sessionId, {
devServer: DEV_SERVER,
rendererIndexPath: DEV_SERVER ? undefined : resolveRendererIndex(),
watch,
newSession
watch
})
)
@ -7391,11 +7396,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 +7659,9 @@ function createWindow() {
if (!nativeThemeListenerInstalled) {
nativeThemeListenerInstalled = true
nativeTheme.on('updated', () => {
applyTitleBarOverlay(mainWindow)
for (const win of BrowserWindow.getAllWindows()) {
applyTitleBarOverlay(win)
}
})
}
}
@ -7824,8 +7902,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 }
})
@ -8439,11 +8517,28 @@ ipcMain.handle('hermes:api', async (_event, request) => {
})
})
// One deduper per cross-window cue — the choke point every window shares. Main
// handles IPC serially, so the first window to claim a key wins with no race.
const isDuplicateNotification = createEventDeduper()
const claimedAmbientCue = createEventDeduper()
// A window asks "do I own this ambient cue (turn-end sound / spoken reply)?".
// The first caller within the window gets true; peers get false and stay quiet.
ipcMain.handle('hermes:ambient:claim', (_event, key) => !claimedAmbientCue(String(key ?? '')))
ipcMain.handle('hermes:notify', (_event, payload) => {
if (!Notification.isSupported()) {
return false
}
// Multiple full windows each run their own renderer throttle, so the same
// kind+session can arrive here twice. Collapse it at this single choke point.
// Return true (not false): a notification for the event IS being shown by the
// first caller, so the settings "send test" success probe stays honest.
if (isDuplicateNotification(`${payload?.kind ?? ''}:${payload?.sessionId ?? ''}`)) {
return true
}
// Action buttons render only on signed macOS builds; elsewhere they're dropped
// and the body click still works.
const actions = Array.isArray(payload?.actions) ? payload.actions : []
@ -8613,7 +8708,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

@ -6,7 +6,8 @@ contextBridge.exposeInMainWorld('hermesDesktop', {
touchBackend: profile => ipcRenderer.invoke('hermes:backend:touch', profile),
getGatewayWsUrl: profile => ipcRenderer.invoke('hermes:gateway:ws-url', profile),
openSessionWindow: (sessionId, opts) => ipcRenderer.invoke('hermes:window:openSession', sessionId, opts),
openNewSessionWindow: () => ipcRenderer.invoke('hermes:window:openNewSession'),
openWindow: () => ipcRenderer.invoke('hermes:window:openInstance'),
claimAmbientCue: key => ipcRenderer.invoke('hermes:ambient:claim', key),
petOverlay: {
// Main renderer → main process: window lifecycle + drag. `request` is
// `{ bounds, screen }`; resolves with the screen bounds it actually used.

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
}

View file

@ -2,6 +2,7 @@ import { useStore } from '@nanostores/react'
import { useEffect, useRef } from 'react'
import { playSpeechText } from '@/lib/voice-playback'
import { ownsAmbientCue } from '@/store/ambient'
import { notifyError } from '@/store/notifications'
import { $messages } from '@/store/session'
import { $voicePlayback } from '@/store/voice-playback'
@ -65,9 +66,16 @@ export function useAutoSpeakReplies({
}
markSpoken()
void playSpeechText(reply.text, { messageId: reply.id, source: 'read-aloud' }).catch(error =>
notifyError(error, failureLabel)
)
// Only one window voices a given reply when the same chat is open in
// several (reply.id is the shared backend message id). markSpoken already
// ran in every window, so peers just stay quiet.
void ownsAmbientCue(`speak:${reply.id}`).then(owns => {
if (owns) {
void playSpeechText(reply.text, { messageId: reply.id, source: 'read-aloud' }).catch(error =>
notifyError(error, failureLabel)
)
}
})
}
// Re-check on a reply completing ($messages) and on the prior clip ending

View file

@ -13,6 +13,7 @@ import { useI18n } from '@/i18n'
import { sessionTitle } from '@/lib/chat-runtime'
import {
Activity,
AppWindow,
Archive,
BarChart3,
ChevronLeft,
@ -59,6 +60,7 @@ import { openPetGenerate } from '@/store/pet-generate'
import { requestStartWorkSession } from '@/store/projects'
import { runGatewayRestart } from '@/store/system-actions'
import { applyBackendUpdate } from '@/store/updates'
import { canOpenNewWindow, openNewWindow } from '@/store/windows'
import { luminance } from '@/themes/color'
import { type ThemeMode, useTheme } from '@/themes/context'
import { isUserTheme, resolveTheme } from '@/themes/user-themes'
@ -413,6 +415,18 @@ export function CommandPalette() {
label: cc.nav.newChat.title,
run: go(NEW_CHAT_ROUTE)
},
...(canOpenNewWindow()
? [
{
action: 'session.newWindow',
icon: AppWindow,
id: 'nav-new-window',
keywords: ['window', 'instance', 'open', 'new'],
label: t.keybinds.actions['session.newWindow'],
run: () => void openNewWindow()
}
]
: []),
{
action: 'view.showTerminal',
icon: Terminal,

View file

@ -40,7 +40,7 @@ import {
switcherActive,
switcherJustClosed
} from '@/store/session-switcher'
import { openNewSessionInNewWindow } from '@/store/windows'
import { openNewWindow } from '@/store/windows'
import { useTheme } from '@/themes/context'
import { requestComposerFocus, requestVoiceToggle } from '../chat/composer/focus'
@ -145,7 +145,7 @@ export function useKeybinds(deps: KeybindRuntimeDeps): void {
window.dispatchEvent(new CustomEvent('hermes:new-session-shortcut'))
},
'session.newTab': () => deps.openNewSessionTab(),
'session.newWindow': () => void openNewSessionInNewWindow(),
'session.newWindow': () => void openNewWindow(),
// ⌃Tab cycles the focused session/main tab strip; only a non-tabbed focus
// falls through to the recent-session switcher.
'session.next': () => void (cycleTreeTabInFocusedZone(1) || stepSession(1)),

View file

@ -483,7 +483,8 @@ export function useGatewayEventHandler(deps: GatewayEventDeps) {
flushQueuedDeltas(sessionId)
playCompletionSound()
// Keyed by session so only one window beeps when several are open.
playCompletionSound(sessionId)
const finalText = coerceGatewayText(payload?.text) || coerceGatewayText(payload?.rendered)
completeAssistantMessage(sessionId, finalText, payload?.response_previewed)

View file

@ -31,8 +31,14 @@ declare global {
// a spectator window (lazy resume — no agent build) for live-streaming
// a running subagent's session.
openSessionWindow: (sessionId: string, opts?: { watch?: boolean }) => Promise<{ ok: boolean; error?: string }>
// Open (or focus) a compact secondary window on the new-session draft.
openNewSessionWindow: () => Promise<{ ok: boolean; error?: string }>
// Open a new full-chrome app window — a peer instance of the primary that
// renders the complete app against the shared backend, so the user can run
// multiple GUI windows at once.
openWindow: () => Promise<{ ok: boolean; error?: string }>
// Claim a one-shot cross-window ambient cue (turn-end sound / spoken
// reply). Resolves true for the first window to claim a key, false for
// peers — so N open windows don't all fire the same cue.
claimAmbientCue: (key: string) => Promise<boolean>
// The pop-out pet overlay: a transparent always-on-top window hosting only
// the mascot. The main renderer drives it (open/close/drag + state push);
// the overlay sends control messages back (pop-in, composer submit).

View file

@ -225,7 +225,7 @@ export const en: Translations = {
'nav.agents': 'Open agents',
'session.new': 'New session',
'session.newTab': 'New session tab',
'session.newWindow': 'New session in window',
'session.newWindow': 'New window',
'session.next': 'Next session',
'session.prev': 'Previous session',
'session.slot.1': 'Switch to recent session 1',

View file

@ -220,7 +220,7 @@ export const zh: Translations = {
'nav.agents': '打开智能体',
'session.new': '新建会话',
'session.newTab': '新建会话标签',
'session.newWindow': '在新窗口中新建会话',
'session.newWindow': '新建窗口',
'session.next': '下一个会话',
'session.prev': '上一个会话',
'session.slot.1': '切换到最近会话 1',

View file

@ -1,6 +1,7 @@
// Completion sound bank for agent turn-end cues.
// Fourteen curated presets for A/B in Settings → Appearance. Default is variant 1.
import { ownsAmbientCue } from '@/store/ambient'
import { $completionSoundVariantId, resolveCompletionSoundVariantId } from '@/store/completion-sound'
import { $hapticsMuted } from '@/store/haptics'
@ -452,13 +453,22 @@ export function previewCompletionSound(variantId?: number) {
playVariant(resolveCompletionSoundVariantId(variantId ?? $completionSoundVariantId.get()))
}
// Plays the selected completion cue on any `message.complete`.
export function playCompletionSound() {
// Plays the selected completion cue on any `message.complete`. Pass a dedupeKey
// (the session id) so only one window beeps when several are open — the mute
// check runs first, so a muted window never claims the cue out from under an
// audible peer.
export function playCompletionSound(dedupeKey?: string) {
if ($hapticsMuted.get()) {
return
}
playVariant($completionSoundVariantId.get())
const play = () => playVariant($completionSoundVariantId.get())
if (!dedupeKey) {
return play()
}
void ownsAmbientCue(`sound:${dedupeKey}`).then(owns => owns && play())
}
interface AirPuffSpec {

View file

@ -2,6 +2,7 @@ import {
IconActivity as Activity,
IconAlertCircle as AlertCircle,
IconAlertTriangle as AlertTriangle,
IconAppWindow as AppWindow,
IconArchive as Archive,
IconArchiveOff as ArchiveOff,
IconArrowUp as ArrowUp,
@ -120,6 +121,7 @@ export {
Activity,
AlertCircle,
AlertTriangle,
AppWindow,
Archive,
ArchiveOff,
ArrowUp,

View file

@ -0,0 +1,18 @@
// One window owns each cross-window ambient cue (turn-end sound, spoken reply)
// so N open full windows don't all fire it for the same backend event. The main
// process is the race-free owner (see electron/event-dedupe.ts). Off Electron —
// or when the bridge/claim fails — every window emits, preserving the
// single-window behavior rather than going silent.
export async function ownsAmbientCue(key: string): Promise<boolean> {
const claim = window.hermesDesktop?.claimAmbientCue
if (!claim) {
return true
}
try {
return await claim(key)
} catch {
return true
}
}

View file

@ -1,6 +1,6 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { canOpenSessionWindow, openNewSessionInNewWindow, openSessionInNewWindow } from './windows'
import { canOpenNewWindow, canOpenSessionWindow, openNewWindow, openSessionInNewWindow } from './windows'
const desktopWindow = window as unknown as { hermesDesktop?: Window['hermesDesktop'] }
const initialHermesDesktop = desktopWindow.hermesDesktop
@ -13,11 +13,11 @@ vi.mock('./notifications', () => ({
function installBridge(
openSessionWindow?: Window['hermesDesktop']['openSessionWindow'],
openNewSessionWindow?: Window['hermesDesktop']['openNewSessionWindow']
openWindow?: Window['hermesDesktop']['openWindow']
) {
desktopWindow.hermesDesktop = {
...(openSessionWindow ? { openSessionWindow } : {}),
...(openNewSessionWindow ? { openNewSessionWindow } : {})
...(openWindow ? { openWindow } : {})
} as unknown as Window['hermesDesktop']
}
@ -106,37 +106,54 @@ describe('openSessionInNewWindow', () => {
})
})
describe('openNewSessionInNewWindow', () => {
describe('canOpenNewWindow', () => {
it('is false when the desktop bridge is absent', () => {
delete desktopWindow.hermesDesktop
expect(canOpenNewWindow()).toBe(false)
})
it('is false when the bridge lacks openWindow', () => {
installBridge(vi.fn().mockResolvedValue({ ok: true }))
expect(canOpenNewWindow()).toBe(false)
})
it('is true when the bridge exposes openWindow', () => {
installBridge(undefined, vi.fn().mockResolvedValue({ ok: true }))
expect(canOpenNewWindow()).toBe(true)
})
})
describe('openNewWindow', () => {
it('no-ops gracefully when the bridge is absent (web fallback)', async () => {
delete desktopWindow.hermesDesktop
await openNewSessionInNewWindow()
await openNewWindow()
expect(notifyError).not.toHaveBeenCalled()
})
it('no-ops when openNewSessionWindow is missing', async () => {
it('no-ops when openWindow is missing', async () => {
installBridge(vi.fn().mockResolvedValue({ ok: true }))
await openNewSessionInNewWindow()
await openNewWindow()
expect(notifyError).not.toHaveBeenCalled()
})
it('invokes the bridge', async () => {
const openNew = vi.fn().mockResolvedValue({ ok: true })
installBridge(vi.fn().mockResolvedValue({ ok: true }), openNew)
const openWindow = vi.fn().mockResolvedValue({ ok: true })
installBridge(undefined, openWindow)
await openNewSessionInNewWindow()
await openNewWindow()
expect(openNew).toHaveBeenCalledTimes(1)
expect(openWindow).toHaveBeenCalledTimes(1)
expect(notifyError).not.toHaveBeenCalled()
})
it('notifies on an ok:false result', async () => {
installBridge(vi.fn().mockResolvedValue({ ok: true }), vi.fn().mockResolvedValue({ ok: false, error: 'nope' }))
installBridge(undefined, vi.fn().mockResolvedValue({ ok: false, error: 'nope' }))
await openNewSessionInNewWindow()
await openNewWindow()
expect(notifyError).toHaveBeenCalledTimes(1)
})

View file

@ -6,7 +6,6 @@ import { notifyError } from './notifications'
// never from the router. A "secondary" window renders a single chat without the
// global session sidebar or the install / onboarding overlays.
const SECONDARY_WINDOW_FLAG = 'secondary'
const NEW_SESSION_WINDOW_FLAG = '1'
let secondaryWindowCache: boolean | null = null
@ -28,26 +27,6 @@ export function isSecondaryWindow(): boolean {
return result
}
let newSessionWindowCache: boolean | null = null
export function isNewSessionWindow(): boolean {
if (newSessionWindowCache !== null) {
return newSessionWindowCache
}
let result = false
try {
result = new URLSearchParams(window.location.search).get('new') === NEW_SESSION_WINDOW_FLAG
} catch {
result = false
}
newSessionWindowCache = result
return result
}
let watchWindowCache: boolean | null = null
// A "watch" window spectates a session that is being driven elsewhere (a
@ -78,11 +57,16 @@ export function canOpenSessionWindow(): boolean {
return typeof window !== 'undefined' && typeof window.hermesDesktop?.openSessionWindow === 'function'
}
// True when the shell can open a full peer app window (⌘⇧N / "New Window").
export function canOpenNewWindow(): boolean {
return typeof window !== 'undefined' && typeof window.hermesDesktop?.openWindow === 'function'
}
type WindowOpenResult = { ok: boolean; error?: string } | undefined
// Run a window-open bridge call, surfacing any failure as a toast. Shared by the
// session pop-out and the new-session pop-out.
async function openWindow(call: () => Promise<WindowOpenResult>, failMessage: string): Promise<void> {
// session pop-out and the new-window opener.
async function runWindowOpen(call: () => Promise<WindowOpenResult>, failMessage: string): Promise<void> {
try {
const result = await call()
@ -102,14 +86,18 @@ export async function openSessionInNewWindow(sessionId: string, opts?: { watch?:
return
}
await openWindow(() => window.hermesDesktop.openSessionWindow(sessionId, opts), 'Could not open chat in a new window')
await runWindowOpen(
() => window.hermesDesktop.openSessionWindow(sessionId, opts),
'Could not open chat in a new window'
)
}
// Open a fresh compact window on the new-session draft.
export async function openNewSessionInNewWindow(): Promise<void> {
if (!canOpenSessionWindow() || typeof window.hermesDesktop.openNewSessionWindow !== 'function') {
// Open a new full-chrome app window — a peer instance of the primary that
// renders the complete app against the shared backend. No-ops outside Electron.
export async function openNewWindow(): Promise<void> {
if (!canOpenNewWindow()) {
return
}
await openWindow(() => window.hermesDesktop.openNewSessionWindow(), 'Could not open new session window')
await runWindowOpen(() => window.hermesDesktop.openWindow(), 'Could not open a new window')
}