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