From 98c294126bafb2ba5660a2dc817c97e7dc8c95bf Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Mon, 15 Jun 2026 20:59:23 -0500 Subject: [PATCH] feat(desktop): open new sessions in compact windows Add the Electron IPC bridge and rebindable shortcut for opening an unkeyed scratch window on the new-session draft. --- apps/desktop/electron/main.cjs | 127 ++++++++++-------- apps/desktop/electron/preload.cjs | 1 + apps/desktop/electron/session-windows.cjs | 13 +- .../desktop/electron/session-windows.test.cjs | 6 + apps/desktop/src/app/hooks/use-keybinds.ts | 2 + apps/desktop/src/global.d.ts | 2 + apps/desktop/src/i18n/en.ts | 1 + apps/desktop/src/i18n/zh.ts | 1 + apps/desktop/src/lib/keybinds/actions.ts | 1 + apps/desktop/src/store/windows.test.ts | 46 ++++++- apps/desktop/src/store/windows.ts | 56 ++++++-- 11 files changed, 182 insertions(+), 74 deletions(-) diff --git a/apps/desktop/electron/main.cjs b/apps/desktop/electron/main.cjs index 98b32f0532c..101cd0801f7 100644 --- a/apps/desktop/electron/main.cjs +++ b/apps/desktop/electron/main.cjs @@ -5083,65 +5083,75 @@ function focusWindow(win) { win.focus() } +function spawnSecondaryWindow({ sessionId, watch, newSession } = {}) { + const icon = getAppIconPath() + const win = new BrowserWindow({ + width: SESSION_WINDOW_MIN_WIDTH, + height: SESSION_WINDOW_MIN_HEIGHT, + minWidth: SESSION_WINDOW_MIN_WIDTH, + minHeight: SESSION_WINDOW_MIN_HEIGHT, + title: 'Hermes', + titleBarStyle: 'hidden', + titleBarOverlay: getTitleBarOverlayOptions(), + trafficLightPosition: IS_MAC ? WINDOW_BUTTON_POSITION : undefined, + vibrancy: IS_MAC ? 'sidebar' : undefined, + opacity: windowOpacity(), + icon, + // Don't show until the renderer's first themed paint is ready. macOS + // `vibrancy` ignores `backgroundColor` and paints a translucent OS + // material (which follows the OS appearance, not the app theme), so a + // dark-themed app on a light-mode Mac flashes white until the renderer + // covers it. ready-to-show fires after the boot-time paint in + // themes/context.tsx, so the window appears already themed. + show: false, + backgroundColor: getWindowBackgroundColor(), + webPreferences: { + preload: path.join(__dirname, 'preload.cjs'), + contextIsolation: true, + webviewTag: true, + sandbox: true, + nodeIntegration: false, + devTools: true + } + }) + + if (IS_MAC) { + win.setWindowButtonPosition?.(WINDOW_BUTTON_POSITION) + } + + win.once('ready-to-show', () => { + if (!win.isDestroyed()) win.show() + }) + + win.on('will-enter-full-screen', () => sendWindowStateChanged(true)) + win.on('enter-full-screen', () => sendWindowStateChanged(true)) + win.on('will-leave-full-screen', () => sendWindowStateChanged(false)) + win.on('leave-full-screen', () => sendWindowStateChanged(false)) + + wireCommonWindowHandlers(win) + + win.loadURL( + buildSessionWindowUrl(sessionId, { + devServer: DEV_SERVER, + rendererIndexPath: DEV_SERVER ? undefined : resolveRendererIndex(), + watch, + newSession + }) + ) + + return win +} + // Open (or focus) a standalone window for a single chat session. function createSessionWindow(sessionId, { watch = false } = {}) { - return sessionWindows.openOrFocus(sessionId, () => { - const icon = getAppIconPath() - const win = new BrowserWindow({ - width: SESSION_WINDOW_MIN_WIDTH, - height: SESSION_WINDOW_MIN_HEIGHT, - minWidth: SESSION_WINDOW_MIN_WIDTH, - minHeight: SESSION_WINDOW_MIN_HEIGHT, - title: 'Hermes', - titleBarStyle: 'hidden', - titleBarOverlay: getTitleBarOverlayOptions(), - trafficLightPosition: IS_MAC ? WINDOW_BUTTON_POSITION : undefined, - vibrancy: IS_MAC ? 'sidebar' : undefined, - opacity: windowOpacity(), - icon, - // Don't show until the renderer's first themed paint is ready. macOS - // `vibrancy` ignores `backgroundColor` and paints a translucent OS - // material (which follows the OS appearance, not the app theme), so a - // dark-themed app on a light-mode Mac flashes white until the renderer - // covers it. ready-to-show fires after the boot-time paint in - // themes/context.tsx, so the window appears already themed. - show: false, - backgroundColor: getWindowBackgroundColor(), - webPreferences: { - preload: path.join(__dirname, 'preload.cjs'), - contextIsolation: true, - webviewTag: true, - sandbox: true, - nodeIntegration: false, - devTools: true - } - }) + return sessionWindows.openOrFocus(sessionId, () => spawnSecondaryWindow({ sessionId, watch })) +} - if (IS_MAC) { - win.setWindowButtonPosition?.(WINDOW_BUTTON_POSITION) - } - - win.once('ready-to-show', () => { - if (!win.isDestroyed()) win.show() - }) - - win.on('will-enter-full-screen', () => sendWindowStateChanged(true)) - win.on('enter-full-screen', () => sendWindowStateChanged(true)) - win.on('will-leave-full-screen', () => sendWindowStateChanged(false)) - win.on('leave-full-screen', () => sendWindowStateChanged(false)) - - wireCommonWindowHandlers(win) - - win.loadURL( - buildSessionWindowUrl(sessionId, { - devServer: DEV_SERVER, - rendererIndexPath: DEV_SERVER ? undefined : resolveRendererIndex(), - watch - }) - ) - - return win - }) +// 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 }) } function createWindow() { @@ -5328,6 +5338,11 @@ ipcMain.handle('hermes:window:openSession', async (_event, sessionId, opts) => { return { ok: true } }) +ipcMain.handle('hermes:window:openNewSession', async () => { + createNewSessionWindow() + + 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 diff --git a/apps/desktop/electron/preload.cjs b/apps/desktop/electron/preload.cjs index 544037e7869..413abd77b32 100644 --- a/apps/desktop/electron/preload.cjs +++ b/apps/desktop/electron/preload.cjs @@ -6,6 +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'), getBootProgress: () => ipcRenderer.invoke('hermes:boot-progress:get'), getConnectionConfig: profile => ipcRenderer.invoke('hermes:connection-config:get', profile), saveConnectionConfig: payload => ipcRenderer.invoke('hermes:connection-config:save', payload), diff --git a/apps/desktop/electron/session-windows.cjs b/apps/desktop/electron/session-windows.cjs index 172ca16c757..929bf3ea9ae 100644 --- a/apps/desktop/electron/session-windows.cjs +++ b/apps/desktop/electron/session-windows.cjs @@ -15,12 +15,13 @@ const SESSION_WINDOW_MIN_HEIGHT = 620 // 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, { devServer, rendererIndexPath, watch } = {}) { - const query = `?win=secondary${watch ? '&watch=1' : ''}` - const route = `#/${encodeURIComponent(sessionId)}` +// 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, { devServer, rendererIndexPath, watch, newSession } = {}) { + const query = `?win=secondary${newSession ? '&new=1' : ''}${watch ? '&watch=1' : ''}` + const route = newSession ? '#/' : `#/${encodeURIComponent(sessionId)}` if (devServer) { const base = devServer.endsWith('/') ? devServer.slice(0, -1) : devServer diff --git a/apps/desktop/electron/session-windows.test.cjs b/apps/desktop/electron/session-windows.test.cjs index a668b0ac082..8261809dbf3 100644 --- a/apps/desktop/electron/session-windows.test.cjs +++ b/apps/desktop/electron/session-windows.test.cjs @@ -82,6 +82,12 @@ 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 }) + + assert.equal(url, 'http://localhost:5173/?win=secondary&new=1#/') +}) + test('registry opens one window per session and focuses on re-open', () => { const registry = createSessionWindowRegistry() let built = 0 diff --git a/apps/desktop/src/app/hooks/use-keybinds.ts b/apps/desktop/src/app/hooks/use-keybinds.ts index 1f02dccfec3..891c834c520 100644 --- a/apps/desktop/src/app/hooks/use-keybinds.ts +++ b/apps/desktop/src/app/hooks/use-keybinds.ts @@ -37,6 +37,7 @@ import { switcherActive, switcherJustClosed } from '@/store/session-switcher' +import { openNewSessionInNewWindow } from '@/store/windows' import { useTheme } from '@/themes/context' import { requestComposerFocus } from '../chat/composer/focus' @@ -132,6 +133,7 @@ export function useKeybinds(deps: KeybindRuntimeDeps): void { deps.startFreshSession() window.dispatchEvent(new CustomEvent('hermes:new-session-shortcut')) }, + 'session.newWindow': () => void openNewSessionInNewWindow(), 'session.next': () => stepSession(1), 'session.prev': () => stepSession(-1), ...sessionSlotHandlers, diff --git a/apps/desktop/src/global.d.ts b/apps/desktop/src/global.d.ts index 8c20dcffaac..c615ad2d61a 100644 --- a/apps/desktop/src/global.d.ts +++ b/apps/desktop/src/global.d.ts @@ -24,6 +24,8 @@ 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 }> getBootProgress: () => Promise getConnectionConfig: (profile?: null | string) => Promise saveConnectionConfig: (payload: DesktopConnectionConfigInput) => Promise diff --git a/apps/desktop/src/i18n/en.ts b/apps/desktop/src/i18n/en.ts index 7ac3ee35ad5..44c738da1b3 100644 --- a/apps/desktop/src/i18n/en.ts +++ b/apps/desktop/src/i18n/en.ts @@ -189,6 +189,7 @@ export const en: Translations = { 'nav.cron': 'Open scheduled jobs', 'nav.agents': 'Open agents', 'session.new': 'New session', + 'session.newWindow': 'New session in 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 7fac54c0f7d..2f3d22230a2 100644 --- a/apps/desktop/src/i18n/zh.ts +++ b/apps/desktop/src/i18n/zh.ts @@ -185,6 +185,7 @@ export const zh: Translations = { 'nav.cron': '打开定时任务', 'nav.agents': '打开智能体', 'session.new': '新建会话', + 'session.newWindow': '在新窗口中新建会话', 'session.next': '下一个会话', 'session.prev': '上一个会话', 'session.slot.1': '切换到最近会话 1', diff --git a/apps/desktop/src/lib/keybinds/actions.ts b/apps/desktop/src/lib/keybinds/actions.ts index 7c4a83f61aa..38eab936f09 100644 --- a/apps/desktop/src/lib/keybinds/actions.ts +++ b/apps/desktop/src/lib/keybinds/actions.ts @@ -66,6 +66,7 @@ export const KEYBIND_ACTIONS: readonly KeybindActionMeta[] = [ // ── Session ────────────────────────────────────────────────────────────── { id: 'session.new', category: 'session', defaults: ['mod+n', 'shift+n'] }, + { id: 'session.newWindow', category: 'session', defaults: ['mod+shift+n'] }, // ⌃Tab / ⌃⇧Tab — the universal tab-cycle chord. Literally Control, not Cmd // (macOS reserves Cmd+Tab for app switching); see `ctrl` in combo.ts. { id: 'session.next', category: 'session', defaults: ['ctrl+tab'] }, diff --git a/apps/desktop/src/store/windows.test.ts b/apps/desktop/src/store/windows.test.ts index 50c42dbf3af..28ae3cc39c9 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, openSessionInNewWindow } from './windows' +import { canOpenSessionWindow, openNewSessionInNewWindow, openSessionInNewWindow } from './windows' const desktopWindow = window as unknown as { hermesDesktop?: Window['hermesDesktop'] } const initialHermesDesktop = desktopWindow.hermesDesktop @@ -11,9 +11,13 @@ vi.mock('./notifications', () => ({ notifyError: (...args: unknown[]) => notifyError(...args) })) -function installBridge(openSessionWindow?: Window['hermesDesktop']['openSessionWindow']) { +function installBridge( + openSessionWindow?: Window['hermesDesktop']['openSessionWindow'], + openNewSessionWindow?: Window['hermesDesktop']['openNewSessionWindow'] +) { desktopWindow.hermesDesktop = { - ...(openSessionWindow ? { openSessionWindow } : {}) + ...(openSessionWindow ? { openSessionWindow } : {}), + ...(openNewSessionWindow ? { openNewSessionWindow } : {}) } as unknown as Window['hermesDesktop'] } @@ -101,3 +105,39 @@ describe('openSessionInNewWindow', () => { expect(notifyError).toHaveBeenCalledTimes(1) }) }) + +describe('openNewSessionInNewWindow', () => { + it('no-ops gracefully when the bridge is absent (web fallback)', async () => { + delete desktopWindow.hermesDesktop + + await openNewSessionInNewWindow() + + expect(notifyError).not.toHaveBeenCalled() + }) + + it('no-ops when openNewSessionWindow is missing', async () => { + installBridge(vi.fn().mockResolvedValue({ ok: true })) + + await openNewSessionInNewWindow() + + expect(notifyError).not.toHaveBeenCalled() + }) + + it('invokes the bridge', async () => { + const openNew = vi.fn().mockResolvedValue({ ok: true }) + installBridge(vi.fn().mockResolvedValue({ ok: true }), openNew) + + await openNewSessionInNewWindow() + + expect(openNew).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' })) + + await openNewSessionInNewWindow() + + expect(notifyError).toHaveBeenCalledTimes(1) + }) +}) diff --git a/apps/desktop/src/store/windows.ts b/apps/desktop/src/store/windows.ts index 461c6343823..c5b36ca6855 100644 --- a/apps/desktop/src/store/windows.ts +++ b/apps/desktop/src/store/windows.ts @@ -6,6 +6,7 @@ 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 @@ -27,6 +28,26 @@ 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 @@ -57,6 +78,22 @@ export function canOpenSessionWindow(): boolean { return typeof window !== 'undefined' && typeof window.hermesDesktop?.openSessionWindow === '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 { + try { + const result = await call() + + if (!result?.ok) { + notifyError(new Error(result?.error || 'unknown error'), failMessage) + } + } catch (err) { + notifyError(err, failMessage) + } +} + // Open (or focus) a standalone OS window for a single chat session. No-ops // gracefully outside Electron so callers can wire it unconditionally. // `watch: true` opens a spectator window (lazy resume, live-mirror stream). @@ -65,13 +102,14 @@ export async function openSessionInNewWindow(sessionId: string, opts?: { watch?: return } - try { - const result = await window.hermesDesktop.openSessionWindow(sessionId, opts) - - if (!result?.ok) { - notifyError(new Error(result?.error || 'unknown error'), 'Could not open chat in a new window') - } - } catch (err) { - notifyError(err, 'Could not open chat in a new window') - } + await openWindow(() => 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') { + return + } + + await openWindow(() => window.hermesDesktop.openNewSessionWindow(), 'Could not open new session window') }