= ({
) : (
}
>
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/session-sync.ts b/apps/desktop/src/store/session-sync.ts
new file mode 100644
index 00000000000..66bf6d08202
--- /dev/null
+++ b/apps/desktop/src/store/session-sync.ts
@@ -0,0 +1,25 @@
+// Cross-window session-list sync. Each desktop window is its own renderer
+// process with its own gateway socket and session store, so a mutation in one
+// (e.g. a new chat started in the compact pop-out) never reaches another
+// window. This bus pings every window to re-pull the shared session list; the
+// data already lives in the backend, the other window just doesn't know to look.
+const CHANNEL = 'hermes:sessions'
+
+const channel = typeof BroadcastChannel === 'undefined' ? null : new BroadcastChannel(CHANNEL)
+
+// A window that mutated the session list (created / titled a chat) tells the
+// others to refresh. A BroadcastChannel never delivers to its own poster, so the
+// caller refreshes locally as it already does.
+export function broadcastSessionsChanged(): void {
+ channel?.postMessage(1)
+}
+
+export function onSessionsChanged(handler: () => void): () => void {
+ if (!channel) {
+ return () => {}
+ }
+
+ channel.addEventListener('message', handler)
+
+ return () => channel.removeEventListener('message', handler)
+}
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')
}