mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-23 16:36:23 +00:00
Merge pull request #68259 from NousResearch/bb/multi-window
feat(desktop): run multiple GUI windows (New Window)
This commit is contained in:
commit
fb0ed8396c
18 changed files with 356 additions and 88 deletions
37
apps/desktop/electron/event-dedupe.test.ts
Normal file
37
apps/desktop/electron/event-dedupe.test.ts
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
import assert from 'node:assert/strict'
|
||||
|
||||
import { test } from 'vitest'
|
||||
|
||||
import { createEventDeduper } from './event-dedupe'
|
||||
|
||||
test('collapses the same key inside the window (two windows, one event)', () => {
|
||||
const isDup = createEventDeduper(1000)
|
||||
|
||||
assert.equal(isDup('input:s1', 0), false, 'first window claims')
|
||||
assert.equal(isDup('input:s1', 5), true, 'second window is deduped')
|
||||
})
|
||||
|
||||
test('distinct keys are independent', () => {
|
||||
const isDup = createEventDeduper(1000)
|
||||
|
||||
assert.equal(isDup('input:s1', 0), false)
|
||||
assert.equal(isDup('approval:s1', 0), false, 'different kind')
|
||||
assert.equal(isDup('input:s2', 0), false, 'different session')
|
||||
})
|
||||
|
||||
test('re-fires once the window elapses', () => {
|
||||
const isDup = createEventDeduper(1000)
|
||||
|
||||
assert.equal(isDup('turnDone:s1', 0), false)
|
||||
assert.equal(isDup('turnDone:s1', 999), true, 'still within window')
|
||||
assert.equal(isDup('turnDone:s1', 1000), false, 'window elapsed → fires again')
|
||||
})
|
||||
|
||||
test('prunes stale keys so the map cannot grow unbounded', () => {
|
||||
const isDup = createEventDeduper(1000)
|
||||
|
||||
for (let i = 0; i < 100; i += 1) {
|
||||
// Each far-apart key is pruned before the next, so none linger as duplicates.
|
||||
assert.equal(isDup(`turnDone:s${i}`, i * 2000), false)
|
||||
}
|
||||
})
|
||||
32
apps/desktop/electron/event-dedupe.ts
Normal file
32
apps/desktop/electron/event-dedupe.ts
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
// Cross-window de-dupe for one-shot side-effects (OS notifications, the turn-end
|
||||
// sound, spoken replies). Every desktop window is its own renderer process, so N
|
||||
// open windows each independently react to the same backend event. The main
|
||||
// process is the one place they all share and it handles IPC serially, so it's
|
||||
// the race-free owner: the first window to claim a key within the interval wins;
|
||||
// peers see it's taken and stay quiet. Pure + injectable clock, so it's
|
||||
// unit-testable without Electron.
|
||||
|
||||
const DEDUPE_INTERVAL_MS = 1000
|
||||
|
||||
// Returns true when `key` was already claimed within the interval (caller drops
|
||||
// this one). Self-evicting: stale keys are pruned on every call, so the map
|
||||
// can't grow unbounded.
|
||||
export function createEventDeduper(intervalMs = DEDUPE_INTERVAL_MS) {
|
||||
const lastSeenAt = new Map<string, number>()
|
||||
|
||||
return function isDuplicate(key: string, now = Date.now()): boolean {
|
||||
for (const [k, at] of lastSeenAt) {
|
||||
if (now - at >= intervalMs) {
|
||||
lastSeenAt.delete(k)
|
||||
}
|
||||
}
|
||||
|
||||
if (lastSeenAt.has(key)) {
|
||||
return true
|
||||
}
|
||||
|
||||
lastSeenAt.set(key, now)
|
||||
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
|
@ -67,6 +67,7 @@ import {
|
|||
uninstallArgsForMode
|
||||
} from './desktop-uninstall'
|
||||
import { installEmbedReferer } from './embed-referer'
|
||||
import { createEventDeduper } from './event-dedupe'
|
||||
import { readDirForIpc } from './fs-read-dir'
|
||||
import { probeGatewayWebSocket } from './gateway-ws-probe'
|
||||
import { scanGitRepos } from './git-repo-scan'
|
||||
|
|
@ -112,6 +113,7 @@ import {
|
|||
buildSessionWindowUrl,
|
||||
chatWindowWebPreferences,
|
||||
createSessionWindowRegistry,
|
||||
instanceWindowBounds,
|
||||
SESSION_WINDOW_MIN_HEIGHT,
|
||||
SESSION_WINDOW_MIN_WIDTH
|
||||
} from './session-windows'
|
||||
|
|
@ -4613,9 +4615,9 @@ function getNativeOverlayWidth() {
|
|||
return computeNativeOverlayWidth({ isWindows: IS_WINDOWS, isWsl: IS_WSL, isMac: IS_MAC })
|
||||
}
|
||||
|
||||
function getWindowState() {
|
||||
function getWindowState(win = mainWindow) {
|
||||
return {
|
||||
isFullscreen: Boolean(mainWindow?.isFullScreen?.()),
|
||||
isFullscreen: Boolean(win?.isFullScreen?.()),
|
||||
nativeOverlayWidth: getNativeOverlayWidth(),
|
||||
windowButtonPosition: getWindowButtonPosition()
|
||||
}
|
||||
|
|
@ -4716,18 +4718,21 @@ function sendOpenUpdatesRequested() {
|
|||
mainWindow.focus()
|
||||
}
|
||||
|
||||
function sendWindowStateChanged(nextIsFullscreen?: boolean) {
|
||||
if (!mainWindow || mainWindow.isDestroyed()) {
|
||||
// Push titlebar/fullscreen chrome state to a window's renderer. Defaults to the
|
||||
// primary, but any full chat window (primary or a secondary "instance" peer)
|
||||
// passes itself so its own fullscreen toggle drives its own traffic-light inset.
|
||||
function sendWindowStateChanged(nextIsFullscreen?: boolean, target = mainWindow) {
|
||||
if (!target || target.isDestroyed()) {
|
||||
return
|
||||
}
|
||||
|
||||
const { webContents } = mainWindow
|
||||
const { webContents } = target
|
||||
|
||||
if (!webContents || webContents.isDestroyed()) {
|
||||
return
|
||||
}
|
||||
|
||||
const state = getWindowState()
|
||||
const state = getWindowState(target)
|
||||
|
||||
if (typeof nextIsFullscreen === 'boolean') {
|
||||
state.isFullscreen = nextIsFullscreen
|
||||
|
|
@ -4765,6 +4770,11 @@ function buildApplicationMenu() {
|
|||
template.push({
|
||||
label: 'File',
|
||||
submenu: [
|
||||
// No accelerator: ⌘⇧N is a rebindable renderer keybind (session.newWindow);
|
||||
// a menu accelerator would fight the rebind panel and (on macOS) be
|
||||
// swallowed before the renderer sees it. Here purely for discoverability.
|
||||
{ click: () => createInstanceWindow(), label: 'New Window' },
|
||||
{ type: 'separator' },
|
||||
IS_MAC
|
||||
? {
|
||||
// NO accelerator: on macOS a registered ⌘W is consumed by the OS
|
||||
|
|
@ -7329,11 +7339,7 @@ function focusWindow(win) {
|
|||
win.focus()
|
||||
}
|
||||
|
||||
function spawnSecondaryWindow({
|
||||
sessionId,
|
||||
watch,
|
||||
newSession
|
||||
}: { sessionId?: string; watch?: boolean; newSession?: boolean } = {}) {
|
||||
function spawnSecondaryWindow({ sessionId, watch }: { sessionId?: string; watch?: boolean } = {}) {
|
||||
const icon = getAppIconPath()
|
||||
|
||||
const win = new BrowserWindow({
|
||||
|
|
@ -7378,8 +7384,7 @@ function spawnSecondaryWindow({
|
|||
buildSessionWindowUrl(sessionId, {
|
||||
devServer: DEV_SERVER,
|
||||
rendererIndexPath: DEV_SERVER ? undefined : resolveRendererIndex(),
|
||||
watch,
|
||||
newSession
|
||||
watch
|
||||
})
|
||||
)
|
||||
|
||||
|
|
@ -7391,11 +7396,82 @@ function createSessionWindow(sessionId, { watch = false } = {}) {
|
|||
return sessionWindows.openOrFocus(sessionId, () => spawnSecondaryWindow({ sessionId, watch }))
|
||||
}
|
||||
|
||||
// Open a fresh compact window on the new-session draft (#/). Not registry-keyed:
|
||||
// like ⌘N in a browser, every press opens a new window — and a draft window that
|
||||
// later converts to a real session must not get refocused as if it were blank.
|
||||
function createNewSessionWindow() {
|
||||
return spawnSecondaryWindow({ newSession: true })
|
||||
// Additional full "instance" windows — peers of the primary that render the
|
||||
// COMPLETE app (sidebar, routing, its own draft) against the shared backend, so
|
||||
// a user can run multiple GUI windows at once (⌘⇧N / the "New Window" palette
|
||||
// command). Unlike the compact session windows they carry no `?win` flag. The
|
||||
// primary mainWindow stays the notification / deep-link / pet-overlay anchor and
|
||||
// is NOT tracked here. The set holds a strong reference so an open peer isn't
|
||||
// garbage-collected, and drops it on close.
|
||||
const instanceWindows = new Set<any>()
|
||||
|
||||
// Cascade a new instance off whichever window spawned it so it doesn't land
|
||||
// exactly on top of its source. Falls back to the persisted primary geometry
|
||||
// when there's no live source window (e.g. all windows closed on macOS). The
|
||||
// pure cascade math lives in session-windows.ts (instanceWindowBounds).
|
||||
function nextInstanceBounds() {
|
||||
const source = BrowserWindow.getFocusedWindow() || mainWindow
|
||||
const fallback = computeWindowOptions(readWindowState(), screen.getAllDisplays())
|
||||
const base = source && !source.isDestroyed() ? source.getBounds() : null
|
||||
|
||||
return instanceWindowBounds(base, fallback)
|
||||
}
|
||||
|
||||
// Open a new full-chrome instance window. Mirrors createWindow()'s window
|
||||
// options (shared chatWindowWebPreferences keeps backgroundThrottling:false so a
|
||||
// streamed answer never stalls in the background) but is a peer, not the
|
||||
// primary: it never overwrites the mainWindow global, doesn't start the backend
|
||||
// (the renderer's getConnection() joins the already-running one), and loads the
|
||||
// plain renderer URL so the full app renders.
|
||||
function createInstanceWindow() {
|
||||
const icon = getAppIconPath()
|
||||
|
||||
const win = new BrowserWindow({
|
||||
...nextInstanceBounds(),
|
||||
minWidth: WINDOW_MIN_WIDTH,
|
||||
minHeight: WINDOW_MIN_HEIGHT,
|
||||
title: 'Hermes',
|
||||
titleBarStyle: 'hidden',
|
||||
titleBarOverlay: getTitleBarOverlayOptions(),
|
||||
trafficLightPosition: IS_MAC ? WINDOW_BUTTON_POSITION : undefined,
|
||||
vibrancy: IS_MAC ? 'sidebar' : undefined,
|
||||
opacity: windowOpacity(),
|
||||
icon,
|
||||
show: false,
|
||||
backgroundColor: getWindowBackgroundColor(),
|
||||
webPreferences: chatWindowWebPreferences(PRELOAD_PATH)
|
||||
})
|
||||
|
||||
instanceWindows.add(win)
|
||||
|
||||
if (IS_MAC) {
|
||||
win.setWindowButtonPosition?.(WINDOW_BUTTON_POSITION)
|
||||
}
|
||||
|
||||
win.once('ready-to-show', () => {
|
||||
if (!win.isDestroyed()) {
|
||||
win.show()
|
||||
}
|
||||
})
|
||||
|
||||
// Per-window fullscreen chrome: send this window its own titlebar inset so its
|
||||
// traffic lights hide/show independently of the primary.
|
||||
win.on('enter-full-screen', () => sendWindowStateChanged(true, win))
|
||||
win.on('leave-full-screen', () => sendWindowStateChanged(false, win))
|
||||
|
||||
wireCommonWindowHandlers(win, zoomWiringForWindowKind('chat'))
|
||||
|
||||
win.on('closed', () => {
|
||||
instanceWindows.delete(win)
|
||||
})
|
||||
|
||||
if (DEV_SERVER) {
|
||||
win.loadURL(DEV_SERVER)
|
||||
} else {
|
||||
win.loadURL(pathToFileURL(resolveRendererIndex()).toString())
|
||||
}
|
||||
|
||||
return win
|
||||
}
|
||||
|
||||
// The pet overlay: a single transparent, frameless, always-on-top window that
|
||||
|
|
@ -7583,7 +7659,9 @@ function createWindow() {
|
|||
if (!nativeThemeListenerInstalled) {
|
||||
nativeThemeListenerInstalled = true
|
||||
nativeTheme.on('updated', () => {
|
||||
applyTitleBarOverlay(mainWindow)
|
||||
for (const win of BrowserWindow.getAllWindows()) {
|
||||
applyTitleBarOverlay(win)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -7824,8 +7902,8 @@ ipcMain.handle('hermes:window:openSession', async (_event, sessionId, opts) => {
|
|||
|
||||
return { ok: true }
|
||||
})
|
||||
ipcMain.handle('hermes:window:openNewSession', async () => {
|
||||
createNewSessionWindow()
|
||||
ipcMain.handle('hermes:window:openInstance', async () => {
|
||||
createInstanceWindow()
|
||||
|
||||
return { ok: true }
|
||||
})
|
||||
|
|
@ -8439,11 +8517,28 @@ ipcMain.handle('hermes:api', async (_event, request) => {
|
|||
})
|
||||
})
|
||||
|
||||
// One deduper per cross-window cue — the choke point every window shares. Main
|
||||
// handles IPC serially, so the first window to claim a key wins with no race.
|
||||
const isDuplicateNotification = createEventDeduper()
|
||||
const claimedAmbientCue = createEventDeduper()
|
||||
|
||||
// A window asks "do I own this ambient cue (turn-end sound / spoken reply)?".
|
||||
// The first caller within the window gets true; peers get false and stay quiet.
|
||||
ipcMain.handle('hermes:ambient:claim', (_event, key) => !claimedAmbientCue(String(key ?? '')))
|
||||
|
||||
ipcMain.handle('hermes:notify', (_event, payload) => {
|
||||
if (!Notification.isSupported()) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Multiple full windows each run their own renderer throttle, so the same
|
||||
// kind+session can arrive here twice. Collapse it at this single choke point.
|
||||
// Return true (not false): a notification for the event IS being shown by the
|
||||
// first caller, so the settings "send test" success probe stays honest.
|
||||
if (isDuplicateNotification(`${payload?.kind ?? ''}:${payload?.sessionId ?? ''}`)) {
|
||||
return true
|
||||
}
|
||||
|
||||
// Action buttons render only on signed macOS builds; elsewhere they're dropped
|
||||
// and the body click still works.
|
||||
const actions = Array.isArray(payload?.actions) ? payload.actions : []
|
||||
|
|
@ -8613,7 +8708,13 @@ ipcMain.on('hermes:titlebar-theme', (_event, payload) => {
|
|||
background: payload.background,
|
||||
foreground: payload.foreground
|
||||
}
|
||||
applyTitleBarOverlay(mainWindow)
|
||||
|
||||
// Repaint the native (Windows/Linux) titlebar overlay on every open chat
|
||||
// window, not just the primary — instance peers and session windows share the
|
||||
// one app theme. applyTitleBarOverlay no-ops on the frameless pet overlay.
|
||||
for (const win of BrowserWindow.getAllWindows()) {
|
||||
applyTitleBarOverlay(win)
|
||||
}
|
||||
})
|
||||
|
||||
// Pin the native appearance to the app theme (see NATIVE_THEME_CONFIG_PATH).
|
||||
|
|
|
|||
|
|
@ -6,7 +6,8 @@ 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'),
|
||||
claimAmbientCue: key => ipcRenderer.invoke('hermes:ambient:claim', key),
|
||||
petOverlay: {
|
||||
// Main renderer → main process: window lifecycle + drag. `request` is
|
||||
// `{ bounds, screen }`; resolves with the screen bounds it actually used.
|
||||
|
|
|
|||
|
|
@ -2,7 +2,12 @@ import assert from 'node:assert/strict'
|
|||
|
||||
import { test } from 'vitest'
|
||||
|
||||
import { buildSessionWindowUrl, chatWindowWebPreferences, createSessionWindowRegistry } from './session-windows'
|
||||
import {
|
||||
buildSessionWindowUrl,
|
||||
chatWindowWebPreferences,
|
||||
createSessionWindowRegistry,
|
||||
instanceWindowBounds
|
||||
} from './session-windows'
|
||||
|
||||
// A minimal fake BrowserWindow: tracks listeners + destroyed state and lets a
|
||||
// test fire the 'closed' event, mirroring the slice of the Electron API the
|
||||
|
|
@ -83,10 +88,16 @@ test('buildSessionWindowUrl adds the watch flag for spectator windows, before th
|
|||
assert.equal(url, 'http://localhost:5173/?win=secondary&watch=1#/abc')
|
||||
})
|
||||
|
||||
test('buildSessionWindowUrl routes new-session windows to the draft (#/)', () => {
|
||||
const url = buildSessionWindowUrl(null, { devServer: 'http://localhost:5173', newSession: true })
|
||||
test('instanceWindowBounds cascades a new window off its source bounds', () => {
|
||||
const bounds = instanceWindowBounds({ x: 100, y: 120, width: 1400, height: 900 }, { width: 1, height: 1 })
|
||||
|
||||
assert.equal(url, 'http://localhost:5173/?win=secondary&new=1#/')
|
||||
assert.deepEqual(bounds, { width: 1400, height: 900, x: 132, y: 152 })
|
||||
})
|
||||
|
||||
test('instanceWindowBounds falls back to the persisted geometry with no source window', () => {
|
||||
const fallback = { width: 1280, height: 800 }
|
||||
|
||||
assert.equal(instanceWindowBounds(null, fallback), fallback)
|
||||
})
|
||||
|
||||
test('registry opens one window per session and focuses on re-open', () => {
|
||||
|
|
|
|||
|
|
@ -38,13 +38,12 @@ function chatWindowWebPreferences(preloadPath: string) {
|
|||
// flag MUST sit in the query string BEFORE the '#': anything after the '#' is
|
||||
// treated as the route by HashRouter and would break routeSessionId(). The
|
||||
// renderer reads the flag from window.location.search to suppress the install /
|
||||
// onboarding overlays and the global session sidebar. `new=1` marks the compact
|
||||
// scratch window; `watch=1` marks a spectator window (e.g. a running subagent's
|
||||
// session): the renderer resumes it lazily so the gateway never builds an agent
|
||||
// just to stream into it.
|
||||
function buildSessionWindowUrl(sessionId: string, { devServer, rendererIndexPath, watch, newSession }: any = {}) {
|
||||
const query = `?win=secondary${newSession ? '&new=1' : ''}${watch ? '&watch=1' : ''}`
|
||||
const route = newSession ? '#/' : `#/${encodeURIComponent(sessionId)}`
|
||||
// onboarding overlays and the global session sidebar. `watch=1` marks a
|
||||
// spectator window (e.g. a running subagent's session): the renderer resumes it
|
||||
// lazily so the gateway never builds an agent just to stream into it.
|
||||
function buildSessionWindowUrl(sessionId: string, { devServer, rendererIndexPath, watch }: any = {}) {
|
||||
const query = `?win=secondary${watch ? '&watch=1' : ''}`
|
||||
const route = `#/${encodeURIComponent(sessionId)}`
|
||||
|
||||
if (devServer) {
|
||||
const base = devServer.endsWith('/') ? devServer.slice(0, -1) : devServer
|
||||
|
|
@ -55,6 +54,28 @@ function buildSessionWindowUrl(sessionId: string, { devServer, rendererIndexPath
|
|||
return `${pathToFileURL(rendererIndexPath).toString()}${query}${route}`
|
||||
}
|
||||
|
||||
// Full "instance" windows (⌘⇧N / the "New Window" command) open a complete app
|
||||
// peer, not a compact chat. Cascade each one off its source window's bounds so a
|
||||
// new window doesn't land exactly on top of the one it was spawned from. Pure so
|
||||
// it's unit-testable; the Electron glue (reading the focused window's bounds,
|
||||
// constructing the BrowserWindow) stays in main.ts. `base` is the source
|
||||
// window's current bounds, or null when there's no live source window — then the
|
||||
// persisted primary geometry (`fallback`) is used as-is.
|
||||
const INSTANCE_CASCADE_OFFSET = 32
|
||||
|
||||
function instanceWindowBounds(base: { x: number; y: number; width: number; height: number } | null, fallback: any) {
|
||||
if (!base) {
|
||||
return fallback
|
||||
}
|
||||
|
||||
return {
|
||||
width: base.width,
|
||||
height: base.height,
|
||||
x: base.x + INSTANCE_CASCADE_OFFSET,
|
||||
y: base.y + INSTANCE_CASCADE_OFFSET
|
||||
}
|
||||
}
|
||||
|
||||
// A small registry keyed by sessionId that guarantees one window per chat:
|
||||
// opening a session that already has a live window focuses it instead of
|
||||
// spawning a duplicate, and a window removes itself from the registry when it
|
||||
|
|
@ -119,6 +140,7 @@ export {
|
|||
buildSessionWindowUrl,
|
||||
chatWindowWebPreferences,
|
||||
createSessionWindowRegistry,
|
||||
instanceWindowBounds,
|
||||
SESSION_WINDOW_MIN_HEIGHT,
|
||||
SESSION_WINDOW_MIN_WIDTH
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { useStore } from '@nanostores/react'
|
|||
import { useEffect, useRef } from 'react'
|
||||
|
||||
import { playSpeechText } from '@/lib/voice-playback'
|
||||
import { ownsAmbientCue } from '@/store/ambient'
|
||||
import { notifyError } from '@/store/notifications'
|
||||
import { $messages } from '@/store/session'
|
||||
import { $voicePlayback } from '@/store/voice-playback'
|
||||
|
|
@ -65,9 +66,16 @@ export function useAutoSpeakReplies({
|
|||
}
|
||||
|
||||
markSpoken()
|
||||
void playSpeechText(reply.text, { messageId: reply.id, source: 'read-aloud' }).catch(error =>
|
||||
notifyError(error, failureLabel)
|
||||
)
|
||||
// Only one window voices a given reply when the same chat is open in
|
||||
// several (reply.id is the shared backend message id). markSpoken already
|
||||
// ran in every window, so peers just stay quiet.
|
||||
void ownsAmbientCue(`speak:${reply.id}`).then(owns => {
|
||||
if (owns) {
|
||||
void playSpeechText(reply.text, { messageId: reply.id, source: 'read-aloud' }).catch(error =>
|
||||
notifyError(error, failureLabel)
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Re-check on a reply completing ($messages) and on the prior clip ending
|
||||
|
|
|
|||
|
|
@ -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)),
|
||||
|
|
|
|||
|
|
@ -483,7 +483,8 @@ export function useGatewayEventHandler(deps: GatewayEventDeps) {
|
|||
|
||||
flushQueuedDeltas(sessionId)
|
||||
|
||||
playCompletionSound()
|
||||
// Keyed by session so only one window beeps when several are open.
|
||||
playCompletionSound(sessionId)
|
||||
|
||||
const finalText = coerceGatewayText(payload?.text) || coerceGatewayText(payload?.rendered)
|
||||
completeAssistantMessage(sessionId, finalText, payload?.response_previewed)
|
||||
|
|
|
|||
10
apps/desktop/src/global.d.ts
vendored
10
apps/desktop/src/global.d.ts
vendored
|
|
@ -31,8 +31,14 @@ 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 }>
|
||||
// Claim a one-shot cross-window ambient cue (turn-end sound / spoken
|
||||
// reply). Resolves true for the first window to claim a key, false for
|
||||
// peers — so N open windows don't all fire the same cue.
|
||||
claimAmbientCue: (key: string) => Promise<boolean>
|
||||
// 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',
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
// Completion sound bank for agent turn-end cues.
|
||||
// Fourteen curated presets for A/B in Settings → Appearance. Default is variant 1.
|
||||
|
||||
import { ownsAmbientCue } from '@/store/ambient'
|
||||
import { $completionSoundVariantId, resolveCompletionSoundVariantId } from '@/store/completion-sound'
|
||||
import { $hapticsMuted } from '@/store/haptics'
|
||||
|
||||
|
|
@ -452,13 +453,22 @@ export function previewCompletionSound(variantId?: number) {
|
|||
playVariant(resolveCompletionSoundVariantId(variantId ?? $completionSoundVariantId.get()))
|
||||
}
|
||||
|
||||
// Plays the selected completion cue on any `message.complete`.
|
||||
export function playCompletionSound() {
|
||||
// Plays the selected completion cue on any `message.complete`. Pass a dedupeKey
|
||||
// (the session id) so only one window beeps when several are open — the mute
|
||||
// check runs first, so a muted window never claims the cue out from under an
|
||||
// audible peer.
|
||||
export function playCompletionSound(dedupeKey?: string) {
|
||||
if ($hapticsMuted.get()) {
|
||||
return
|
||||
}
|
||||
|
||||
playVariant($completionSoundVariantId.get())
|
||||
const play = () => playVariant($completionSoundVariantId.get())
|
||||
|
||||
if (!dedupeKey) {
|
||||
return play()
|
||||
}
|
||||
|
||||
void ownsAmbientCue(`sound:${dedupeKey}`).then(owns => owns && play())
|
||||
}
|
||||
|
||||
interface AirPuffSpec {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
18
apps/desktop/src/store/ambient.ts
Normal file
18
apps/desktop/src/store/ambient.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
// One window owns each cross-window ambient cue (turn-end sound, spoken reply)
|
||||
// so N open full windows don't all fire it for the same backend event. The main
|
||||
// process is the race-free owner (see electron/event-dedupe.ts). Off Electron —
|
||||
// or when the bridge/claim fails — every window emits, preserving the
|
||||
// single-window behavior rather than going silent.
|
||||
export async function ownsAmbientCue(key: string): Promise<boolean> {
|
||||
const claim = window.hermesDesktop?.claimAmbientCue
|
||||
|
||||
if (!claim) {
|
||||
return true
|
||||
}
|
||||
|
||||
try {
|
||||
return await claim(key)
|
||||
} catch {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
|
@ -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