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.
This commit is contained in:
Brooklyn Nicholson
2026-06-15 20:59:57 -05:00
parent 0a8f3e21b8
commit 98c294126b
11 changed files with 180 additions and 72 deletions
@@ -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,
+2
View File
@@ -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<DesktopBootProgress>
getConnectionConfig: (profile?: null | string) => Promise<DesktopConnectionConfig>
saveConnectionConfig: (payload: DesktopConnectionConfigInput) => Promise<DesktopConnectionConfig>
+1
View File
@@ -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',
+1
View File
@@ -185,6 +185,7 @@ export const zh: Translations = {
'nav.cron': '打开定时任务',
'nav.agents': '打开智能体',
'session.new': '新建会话',
'session.newWindow': '在新窗口中新建会话',
'session.next': '下一个会话',
'session.prev': '上一个会话',
'session.slot.1': '切换到最近会话 1',
+1
View File
@@ -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'] },
+43 -3
View File
@@ -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)
})
})
+45 -7
View File
@@ -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<WindowOpenResult>, failMessage: string): Promise<void> {
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)
await openWindow(() => window.hermesDesktop.openSessionWindow(sessionId, opts), 'Could not open chat in a new window')
}
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')
// Open a fresh compact window on the new-session draft.
export async function openNewSessionInNewWindow(): Promise<void> {
if (!canOpenSessionWindow() || typeof window.hermesDesktop.openNewSessionWindow !== 'function') {
return
}
await openWindow(() => window.hermesDesktop.openNewSessionWindow(), 'Could not open new session window')
}