mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-23 16:36:23 +00:00
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:
parent
b586e4eff2
commit
a90ca7fe34
9 changed files with 71 additions and 48 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)),
|
||||
|
|
|
|||
6
apps/desktop/src/global.d.ts
vendored
6
apps/desktop/src/global.d.ts
vendored
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue