feat(desktop): wire New Window to ⌘⇧N + command palette

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.
This commit is contained in:
Brooklyn Nicholson 2026-07-20 18:02:48 -05:00
parent b586e4eff2
commit a90ca7fe34
9 changed files with 71 additions and 48 deletions

View file

@ -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.

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

@ -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).

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

@ -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

@ -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')
}