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:
@@ -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,
|
||||
|
||||
Vendored
+2
@@ -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>
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -185,6 +185,7 @@ export const zh: Translations = {
|
||||
'nav.cron': '打开定时任务',
|
||||
'nav.agents': '打开智能体',
|
||||
'session.new': '新建会话',
|
||||
'session.newWindow': '在新窗口中新建会话',
|
||||
'session.next': '下一个会话',
|
||||
'session.prev': '上一个会话',
|
||||
'session.slot.1': '切换到最近会话 1',
|
||||
|
||||
@@ -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'] },
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user