From b586e4eff20de21df6a3aa1209d7d3b089df6bbc Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Mon, 20 Jul 2026 18:02:44 -0500 Subject: [PATCH 1/4] feat(desktop): open multiple full app windows (electron) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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:window: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:window:openNewSession handler, and the newSession/new=1 URL flag. --- apps/desktop/electron/main.ts | 127 +++++++++++++++--- apps/desktop/electron/session-windows.test.ts | 19 ++- apps/desktop/electron/session-windows.ts | 36 ++++- 3 files changed, 149 insertions(+), 33 deletions(-) diff --git a/apps/desktop/electron/main.ts b/apps/desktop/electron/main.ts index b67e8b69f3a7..09f8e0131e44 100644 --- a/apps/desktop/electron/main.ts +++ b/apps/desktop/electron/main.ts @@ -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() + +// 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). diff --git a/apps/desktop/electron/session-windows.test.ts b/apps/desktop/electron/session-windows.test.ts index fcfca8680730..5167593bfbf8 100644 --- a/apps/desktop/electron/session-windows.test.ts +++ b/apps/desktop/electron/session-windows.test.ts @@ -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', () => { diff --git a/apps/desktop/electron/session-windows.ts b/apps/desktop/electron/session-windows.ts index af55608b0f4e..48597ee9e0ea 100644 --- a/apps/desktop/electron/session-windows.ts +++ b/apps/desktop/electron/session-windows.ts @@ -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 } From a90ca7fe34b28d38eaef16672aeeef35ec3dde01 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Mon, 20 Jul 2026 18:02:48 -0500 Subject: [PATCH 2/4] =?UTF-8?q?feat(desktop):=20wire=20New=20Window=20to?= =?UTF-8?q?=20=E2=8C=98=E2=87=A7N=20+=20command=20palette?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Repoint session.newWindow (⌘⇧N) from the compact new-session pop-out to openNewWindow(), which opens a full peer instance via the new openWindow bridge, and add a "New Window" entry to the ⌘K palette (shown with its hotkey hint, gated on canOpenNewWindow()). Relabel the action "New window". Drops the retired openNewSessionWindow bridge and the vestigial isNewSessionWindow()/new=1 flag; renames the shared opener helper. --- apps/desktop/electron/preload.ts | 2 +- .../desktop/src/app/command-palette/index.tsx | 14 ++++++ apps/desktop/src/app/hooks/use-keybinds.ts | 4 +- apps/desktop/src/global.d.ts | 6 ++- apps/desktop/src/i18n/en.ts | 2 +- apps/desktop/src/i18n/zh.ts | 2 +- apps/desktop/src/lib/icons.ts | 2 + apps/desktop/src/store/windows.test.ts | 43 ++++++++++++------ apps/desktop/src/store/windows.ts | 44 +++++++------------ 9 files changed, 71 insertions(+), 48 deletions(-) diff --git a/apps/desktop/electron/preload.ts b/apps/desktop/electron/preload.ts index 732d13a53668..311c18637dc0 100644 --- a/apps/desktop/electron/preload.ts +++ b/apps/desktop/electron/preload.ts @@ -6,7 +6,7 @@ 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'), petOverlay: { // Main renderer → main process: window lifecycle + drag. `request` is // `{ bounds, screen }`; resolves with the screen bounds it actually used. diff --git a/apps/desktop/src/app/command-palette/index.tsx b/apps/desktop/src/app/command-palette/index.tsx index 05a4d804cb8c..e0a669bb300d 100644 --- a/apps/desktop/src/app/command-palette/index.tsx +++ b/apps/desktop/src/app/command-palette/index.tsx @@ -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, diff --git a/apps/desktop/src/app/hooks/use-keybinds.ts b/apps/desktop/src/app/hooks/use-keybinds.ts index 34a6bfa1e1c6..4df1edb202c4 100644 --- a/apps/desktop/src/app/hooks/use-keybinds.ts +++ b/apps/desktop/src/app/hooks/use-keybinds.ts @@ -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)), diff --git a/apps/desktop/src/global.d.ts b/apps/desktop/src/global.d.ts index 9202c28e7fc6..ee40c31cc17a 100644 --- a/apps/desktop/src/global.d.ts +++ b/apps/desktop/src/global.d.ts @@ -31,8 +31,10 @@ 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 }> // 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). diff --git a/apps/desktop/src/i18n/en.ts b/apps/desktop/src/i18n/en.ts index a17ecbb3bd60..049d5da5e74c 100644 --- a/apps/desktop/src/i18n/en.ts +++ b/apps/desktop/src/i18n/en.ts @@ -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', diff --git a/apps/desktop/src/i18n/zh.ts b/apps/desktop/src/i18n/zh.ts index 61d6ff602aab..5e165f6be0a5 100644 --- a/apps/desktop/src/i18n/zh.ts +++ b/apps/desktop/src/i18n/zh.ts @@ -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', diff --git a/apps/desktop/src/lib/icons.ts b/apps/desktop/src/lib/icons.ts index 0260ce82c6c7..fae9e8d76c09 100644 --- a/apps/desktop/src/lib/icons.ts +++ b/apps/desktop/src/lib/icons.ts @@ -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, diff --git a/apps/desktop/src/store/windows.test.ts b/apps/desktop/src/store/windows.test.ts index 28ae3cc39c9f..e6c3f4974a56 100644 --- a/apps/desktop/src/store/windows.test.ts +++ b/apps/desktop/src/store/windows.test.ts @@ -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) }) diff --git a/apps/desktop/src/store/windows.ts b/apps/desktop/src/store/windows.ts index a881aa4794c6..349b29d12ba0 100644 --- a/apps/desktop/src/store/windows.ts +++ b/apps/desktop/src/store/windows.ts @@ -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, failMessage: string): Promise { +// session pop-out and the new-window opener. +async function runWindowOpen(call: () => Promise, failMessage: string): Promise { 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 { - 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 { + 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') } From fb0c6d9ee15a4f9e079ba49ef58b860fc7098a60 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Mon, 20 Jul 2026 18:33:14 -0500 Subject: [PATCH 3/4] fix(desktop): de-dupe cross-window cues so peers don't spam With multiple full windows, each renderer independently reacts to the same backend event, so one-shot cues fired N times: OS notifications (the per-renderer throttle can't see other windows), the turn-end sound (playCompletionSound runs on every message.complete, ungated by focus), and auto-spoken replies (double voice when a chat is open in two windows). Add a single race-free owner in the main process (electron/event-dedupe.ts): main handles IPC serially, so the first window to claim a key within a short window wins and peers stay quiet. Notifications collapse at the hermes:notify choke point; the sound and spoken replies claim via a new hermes:ambient:claim IPC (keyed by session / reply id). Off Electron the claim falls back to "emit", preserving single-window behavior. The sound's mute check runs before the claim so a muted window can't win the cue and silence an audible peer. --- apps/desktop/electron/event-dedupe.test.ts | 37 +++++++++++++++++++ apps/desktop/electron/event-dedupe.ts | 35 ++++++++++++++++++ apps/desktop/electron/main.ts | 18 +++++++++ apps/desktop/electron/preload.ts | 1 + .../composer/hooks/use-auto-speak-replies.ts | 14 +++++-- .../hooks/use-message-stream/gateway-event.ts | 3 +- apps/desktop/src/global.d.ts | 4 ++ apps/desktop/src/lib/completion-sound.ts | 20 ++++++++-- apps/desktop/src/store/ambient.ts | 18 +++++++++ 9 files changed, 143 insertions(+), 7 deletions(-) create mode 100644 apps/desktop/electron/event-dedupe.test.ts create mode 100644 apps/desktop/electron/event-dedupe.ts create mode 100644 apps/desktop/src/store/ambient.ts diff --git a/apps/desktop/electron/event-dedupe.test.ts b/apps/desktop/electron/event-dedupe.test.ts new file mode 100644 index 000000000000..3c0f905587e3 --- /dev/null +++ b/apps/desktop/electron/event-dedupe.test.ts @@ -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) + } +}) diff --git a/apps/desktop/electron/event-dedupe.ts b/apps/desktop/electron/event-dedupe.ts new file mode 100644 index 000000000000..ec14d67e851b --- /dev/null +++ b/apps/desktop/electron/event-dedupe.ts @@ -0,0 +1,35 @@ +// 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 window wins; +// peers see it's taken and stay quiet. +// +// Pure + injectable clock so it's unit-testable without Electron. + +const DEDUPE_WINDOW_MS = 1000 + +// Returns true when `key` was already claimed within the window (caller drops +// this one). Self-evicting: stale keys are pruned on every call, so the map +// can't grow unbounded. +function createEventDeduper(windowMs = DEDUPE_WINDOW_MS) { + const lastSeenAt = new Map() + + return function isDuplicate(key: string, now = Date.now()): boolean { + for (const [k, at] of lastSeenAt) { + if (now - at >= windowMs) { + lastSeenAt.delete(k) + } + } + + if (lastSeenAt.has(key)) { + return true + } + + lastSeenAt.set(key, now) + + return false + } +} + +export { createEventDeduper, DEDUPE_WINDOW_MS } diff --git a/apps/desktop/electron/main.ts b/apps/desktop/electron/main.ts index 09f8e0131e44..141497cf6b4d 100644 --- a/apps/desktop/electron/main.ts +++ b/apps/desktop/electron/main.ts @@ -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' @@ -8516,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 : [] diff --git a/apps/desktop/electron/preload.ts b/apps/desktop/electron/preload.ts index 311c18637dc0..37f068ca986f 100644 --- a/apps/desktop/electron/preload.ts +++ b/apps/desktop/electron/preload.ts @@ -7,6 +7,7 @@ contextBridge.exposeInMainWorld('hermesDesktop', { getGatewayWsUrl: profile => ipcRenderer.invoke('hermes:gateway:ws-url', profile), openSessionWindow: (sessionId, opts) => ipcRenderer.invoke('hermes:window:openSession', sessionId, opts), 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. diff --git a/apps/desktop/src/app/chat/composer/hooks/use-auto-speak-replies.ts b/apps/desktop/src/app/chat/composer/hooks/use-auto-speak-replies.ts index c3268bc9cbd3..949b8f1020c2 100644 --- a/apps/desktop/src/app/chat/composer/hooks/use-auto-speak-replies.ts +++ b/apps/desktop/src/app/chat/composer/hooks/use-auto-speak-replies.ts @@ -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 diff --git a/apps/desktop/src/app/session/hooks/use-message-stream/gateway-event.ts b/apps/desktop/src/app/session/hooks/use-message-stream/gateway-event.ts index 09ba6bdb52ad..9d2ded2e5f6c 100644 --- a/apps/desktop/src/app/session/hooks/use-message-stream/gateway-event.ts +++ b/apps/desktop/src/app/session/hooks/use-message-stream/gateway-event.ts @@ -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) diff --git a/apps/desktop/src/global.d.ts b/apps/desktop/src/global.d.ts index ee40c31cc17a..fe2a3eaa6dc0 100644 --- a/apps/desktop/src/global.d.ts +++ b/apps/desktop/src/global.d.ts @@ -35,6 +35,10 @@ declare global { // 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 // 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). diff --git a/apps/desktop/src/lib/completion-sound.ts b/apps/desktop/src/lib/completion-sound.ts index 4457d912b78d..f1faa150bf0f 100644 --- a/apps/desktop/src/lib/completion-sound.ts +++ b/apps/desktop/src/lib/completion-sound.ts @@ -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,26 @@ 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()) + if (!dedupeKey) { + playVariant($completionSoundVariantId.get()) + + return + } + + void ownsAmbientCue(`sound:${dedupeKey}`).then(owns => { + if (owns) { + playVariant($completionSoundVariantId.get()) + } + }) } interface AirPuffSpec { diff --git a/apps/desktop/src/store/ambient.ts b/apps/desktop/src/store/ambient.ts new file mode 100644 index 000000000000..0c7a8ee31d6d --- /dev/null +++ b/apps/desktop/src/store/ambient.ts @@ -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 { + const claim = window.hermesDesktop?.claimAmbientCue + + if (!claim) { + return true + } + + try { + return await claim(key) + } catch { + return true + } +} From a41346f8aeafc2e8e8ec0d49dbb743a5dbec070a Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Mon, 20 Jul 2026 18:36:05 -0500 Subject: [PATCH 4/4] refactor(desktop): tidy the cross-window deduper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drop the unused DEDUPE_WINDOW_MS export and rename its interval so "window" isn't overloaded against BrowserWindow in a multi-window feature (windowMs → intervalMs). DRY the completion-sound play path. No behavior change. --- apps/desktop/electron/event-dedupe.ts | 17 +++++++---------- apps/desktop/src/lib/completion-sound.ts | 12 ++++-------- 2 files changed, 11 insertions(+), 18 deletions(-) diff --git a/apps/desktop/electron/event-dedupe.ts b/apps/desktop/electron/event-dedupe.ts index ec14d67e851b..a2d12a8f2cba 100644 --- a/apps/desktop/electron/event-dedupe.ts +++ b/apps/desktop/electron/event-dedupe.ts @@ -2,22 +2,21 @@ // 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 window wins; -// peers see it's taken and stay quiet. -// -// Pure + injectable clock so it's unit-testable without Electron. +// 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_WINDOW_MS = 1000 +const DEDUPE_INTERVAL_MS = 1000 -// Returns true when `key` was already claimed within the window (caller drops +// 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. -function createEventDeduper(windowMs = DEDUPE_WINDOW_MS) { +export function createEventDeduper(intervalMs = DEDUPE_INTERVAL_MS) { const lastSeenAt = new Map() return function isDuplicate(key: string, now = Date.now()): boolean { for (const [k, at] of lastSeenAt) { - if (now - at >= windowMs) { + if (now - at >= intervalMs) { lastSeenAt.delete(k) } } @@ -31,5 +30,3 @@ function createEventDeduper(windowMs = DEDUPE_WINDOW_MS) { return false } } - -export { createEventDeduper, DEDUPE_WINDOW_MS } diff --git a/apps/desktop/src/lib/completion-sound.ts b/apps/desktop/src/lib/completion-sound.ts index f1faa150bf0f..1557c58e419e 100644 --- a/apps/desktop/src/lib/completion-sound.ts +++ b/apps/desktop/src/lib/completion-sound.ts @@ -462,17 +462,13 @@ export function playCompletionSound(dedupeKey?: string) { return } - if (!dedupeKey) { - playVariant($completionSoundVariantId.get()) + const play = () => playVariant($completionSoundVariantId.get()) - return + if (!dedupeKey) { + return play() } - void ownsAmbientCue(`sound:${dedupeKey}`).then(owns => { - if (owns) { - playVariant($completionSoundVariantId.get()) - } - }) + void ownsAmbientCue(`sound:${dedupeKey}`).then(owns => owns && play()) } interface AirPuffSpec {