mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-10 08:32:09 +00:00
feat(desktop): open any chat in its own window (#43219)
Pops a session into a standalone, focused window for side-by-side work. A secondary window loads the renderer at the session route with a ?win=secondary flag (ahead of the HashRouter '#'); it drops the global sidebar plus the install/onboarding overlays and renders a single chat, sharing the one local gateway over WS (no backend duplication). The main process keys windows by sessionId so re-opening focuses the existing one and self-cleans on close. Open it via: - ⌘-click (mac) / ⌃-click (win/linux) a sidebar session — the universal "open in new window" gesture. Archive moves to the ⋯ / right-click menus only, off the easy-to-misfire modifier-click. - "New window" in the session ⋯ and context menus (link-external icon, i18n'd across en/ja/zh/zh-hant). A standalone window has no left rail, so AppShell treats its edge as uncovered and applies the titlebar inset — the chat title clears the macOS traffic lights instead of hiding behind them. Co-authored-by: tim404x <tim404x@users.noreply.github.com>
This commit is contained in:
parent
d33965396e
commit
b96bd4808d
17 changed files with 570 additions and 48 deletions
|
|
@ -26,6 +26,7 @@ const { fileURLToPath, pathToFileURL } = require('node:url')
|
|||
const { execFileSync, spawn } = require('node:child_process')
|
||||
const { detectRemoteDisplay, isWindowsBinaryPathInWsl, isWslEnvironment } = require('./bootstrap-platform.cjs')
|
||||
const { runBootstrap } = require('./bootstrap-runner.cjs')
|
||||
const { buildSessionWindowUrl, createSessionWindowRegistry } = require('./session-windows.cjs')
|
||||
const { canImportHermesCli, verifyHermesCli } = require('./backend-probes.cjs')
|
||||
const { probeGatewayWebSocket } = require('./gateway-ws-probe.cjs')
|
||||
const { serializeJsonBody, setJsonRequestHeaders } = require('./oauth-net-request.cjs')
|
||||
|
|
@ -4746,6 +4747,94 @@ async function startHermes() {
|
|||
return connectionPromise
|
||||
}
|
||||
|
||||
// Shared navigation guards + window chrome wiring applied to every window
|
||||
// (the primary plus any secondary session windows). Factored out of
|
||||
// createWindow() so secondary windows can't drift from the main window's
|
||||
// security posture: external links open in the OS browser, in-app navigation
|
||||
// stays confined to the dev server / packaged file URL, and the preview /
|
||||
// devtools / zoom / context-menu affordances behave identically everywhere.
|
||||
function wireCommonWindowHandlers(win) {
|
||||
installPreviewShortcut(win)
|
||||
installDevToolsShortcut(win)
|
||||
installZoomShortcuts(win)
|
||||
installContextMenu(win)
|
||||
win.webContents.setWindowOpenHandler(details => {
|
||||
openExternalUrl(details.url)
|
||||
|
||||
return { action: 'deny' }
|
||||
})
|
||||
win.webContents.on('will-navigate', (event, url) => {
|
||||
if ((DEV_SERVER && url.startsWith(DEV_SERVER)) || (!DEV_SERVER && url.startsWith('file:'))) {
|
||||
return
|
||||
}
|
||||
|
||||
event.preventDefault()
|
||||
openExternalUrl(url)
|
||||
})
|
||||
}
|
||||
|
||||
// Secondary "session windows" — one extra OS window per chat so a user can
|
||||
// work with multiple chats side by side. The registry guarantees one window
|
||||
// per sessionId (re-opening focuses the existing window) and self-cleans on
|
||||
// close. The primary mainWindow is never tracked here. Pure logic + the URL
|
||||
// builder live in session-windows.cjs so they stay unit-testable.
|
||||
const sessionWindows = createSessionWindowRegistry()
|
||||
|
||||
function focusWindow(win) {
|
||||
if (!win || win.isDestroyed()) return
|
||||
if (win.isMinimized()) win.restore()
|
||||
if (!win.isVisible()) win.show()
|
||||
win.focus()
|
||||
}
|
||||
|
||||
// Open (or focus) a standalone window for a single chat session.
|
||||
function createSessionWindow(sessionId) {
|
||||
return sessionWindows.openOrFocus(sessionId, () => {
|
||||
const icon = getAppIconPath()
|
||||
const win = new BrowserWindow({
|
||||
width: 480,
|
||||
height: 800,
|
||||
minWidth: 420,
|
||||
minHeight: 620,
|
||||
title: 'Hermes',
|
||||
titleBarStyle: 'hidden',
|
||||
titleBarOverlay: getTitleBarOverlayOptions(),
|
||||
trafficLightPosition: IS_MAC ? WINDOW_BUTTON_POSITION : undefined,
|
||||
vibrancy: IS_MAC ? 'sidebar' : undefined,
|
||||
icon,
|
||||
backgroundColor: '#f7f7f7',
|
||||
webPreferences: {
|
||||
preload: path.join(__dirname, 'preload.cjs'),
|
||||
contextIsolation: true,
|
||||
webviewTag: true,
|
||||
sandbox: true,
|
||||
nodeIntegration: false,
|
||||
devTools: true
|
||||
}
|
||||
})
|
||||
|
||||
if (IS_MAC) {
|
||||
win.setWindowButtonPosition?.(WINDOW_BUTTON_POSITION)
|
||||
}
|
||||
|
||||
win.on('will-enter-full-screen', () => sendWindowStateChanged(true))
|
||||
win.on('enter-full-screen', () => sendWindowStateChanged(true))
|
||||
win.on('will-leave-full-screen', () => sendWindowStateChanged(false))
|
||||
win.on('leave-full-screen', () => sendWindowStateChanged(false))
|
||||
|
||||
wireCommonWindowHandlers(win)
|
||||
|
||||
win.loadURL(
|
||||
buildSessionWindowUrl(sessionId, {
|
||||
devServer: DEV_SERVER,
|
||||
rendererIndexPath: DEV_SERVER ? undefined : resolveRendererIndex()
|
||||
})
|
||||
)
|
||||
|
||||
return win
|
||||
})
|
||||
}
|
||||
|
||||
function createWindow() {
|
||||
const icon = getAppIconPath()
|
||||
mainWindow = new BrowserWindow({
|
||||
|
|
@ -4806,23 +4895,7 @@ function createWindow() {
|
|||
mainWindow.on('will-leave-full-screen', () => sendWindowStateChanged(false))
|
||||
mainWindow.on('leave-full-screen', () => sendWindowStateChanged(false))
|
||||
|
||||
installPreviewShortcut(mainWindow)
|
||||
installDevToolsShortcut(mainWindow)
|
||||
installZoomShortcuts(mainWindow)
|
||||
installContextMenu(mainWindow)
|
||||
mainWindow.webContents.setWindowOpenHandler(details => {
|
||||
openExternalUrl(details.url)
|
||||
|
||||
return { action: 'deny' }
|
||||
})
|
||||
mainWindow.webContents.on('will-navigate', (event, url) => {
|
||||
if ((DEV_SERVER && url.startsWith(DEV_SERVER)) || (!DEV_SERVER && url.startsWith('file:'))) {
|
||||
return
|
||||
}
|
||||
|
||||
event.preventDefault()
|
||||
openExternalUrl(url)
|
||||
})
|
||||
wireCommonWindowHandlers(mainWindow)
|
||||
|
||||
mainWindow.webContents.on('render-process-gone', (_event, details) => {
|
||||
rememberLog(`[renderer] render-process-gone reason=${details?.reason} exitCode=${details?.exitCode}`)
|
||||
|
|
@ -4928,6 +5001,15 @@ ipcMain.handle('hermes:backend:touch', async (_event, profile) => {
|
|||
return { ok: true }
|
||||
})
|
||||
ipcMain.handle('hermes:gateway:ws-url', async (_event, profile) => freshGatewayWsUrl(profile))
|
||||
ipcMain.handle('hermes:window:openSession', async (_event, sessionId) => {
|
||||
if (typeof sessionId !== 'string' || !sessionId.trim()) {
|
||||
return { ok: false, error: 'invalid-session-id' }
|
||||
}
|
||||
|
||||
createSessionWindow(sessionId.trim())
|
||||
|
||||
return { ok: true }
|
||||
})
|
||||
ipcMain.handle('hermes:bootstrap:reset', async () => {
|
||||
// Renderer's "Reload and retry" path. Clear the latched failure and
|
||||
// reset connection state so the next startHermes() call restarts the
|
||||
|
|
@ -5895,7 +5977,14 @@ app.whenReady().then(() => {
|
|||
createWindow()
|
||||
|
||||
app.on('activate', () => {
|
||||
if (BrowserWindow.getAllWindows().length === 0) createWindow()
|
||||
// Recreate the primary window if it's gone. Guard on mainWindow directly
|
||||
// (not just total window count) so a dock click still restores the main
|
||||
// window when only secondary session windows remain open.
|
||||
if (!mainWindow || mainWindow.isDestroyed()) {
|
||||
createWindow()
|
||||
} else {
|
||||
focusWindow(mainWindow)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ contextBridge.exposeInMainWorld('hermesDesktop', {
|
|||
revalidateConnection: () => ipcRenderer.invoke('hermes:connection:revalidate'),
|
||||
touchBackend: profile => ipcRenderer.invoke('hermes:backend:touch', profile),
|
||||
getGatewayWsUrl: profile => ipcRenderer.invoke('hermes:gateway:ws-url', profile),
|
||||
openSessionWindow: sessionId => ipcRenderer.invoke('hermes:window:openSession', sessionId),
|
||||
getBootProgress: () => ipcRenderer.invoke('hermes:boot-progress:get'),
|
||||
getConnectionConfig: profile => ipcRenderer.invoke('hermes:connection-config:get', profile),
|
||||
saveConnectionConfig: payload => ipcRenderer.invoke('hermes:connection-config:save', payload),
|
||||
|
|
|
|||
86
apps/desktop/electron/session-windows.cjs
Normal file
86
apps/desktop/electron/session-windows.cjs
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
// Secondary "session windows" — one extra OS window per chat so a user can
|
||||
// work with multiple chats side by side. The pure, Electron-free pieces live
|
||||
// here so they can be unit-tested with node --test (mirroring how the rest of
|
||||
// electron/*.cjs splits testable logic out of the main.cjs monolith).
|
||||
|
||||
const { pathToFileURL } = require('node:url')
|
||||
|
||||
// Build the renderer URL for a secondary window. The renderer uses a
|
||||
// HashRouter, so the session route lives after the '#'. The `?win=secondary`
|
||||
// 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.
|
||||
function buildSessionWindowUrl(sessionId, { devServer, rendererIndexPath } = {}) {
|
||||
const route = `#/${encodeURIComponent(sessionId)}`
|
||||
|
||||
if (devServer) {
|
||||
const base = devServer.endsWith('/') ? devServer.slice(0, -1) : devServer
|
||||
|
||||
return `${base}/?win=secondary${route}`
|
||||
}
|
||||
|
||||
return `${pathToFileURL(rendererIndexPath).toString()}?win=secondary${route}`
|
||||
}
|
||||
|
||||
// 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
|
||||
// closes. The actual BrowserWindow construction is injected (the `factory`) so
|
||||
// this module stays free of Electron and is unit-testable.
|
||||
function createSessionWindowRegistry() {
|
||||
const windows = new Map()
|
||||
|
||||
function openOrFocus(sessionId, factory) {
|
||||
const key = typeof sessionId === 'string' ? sessionId.trim() : ''
|
||||
|
||||
if (!key) {
|
||||
return null
|
||||
}
|
||||
|
||||
const existing = windows.get(key)
|
||||
|
||||
if (existing && !existing.isDestroyed()) {
|
||||
// Focus-or-create: never duplicate a window for the same chat.
|
||||
if (typeof existing.isMinimized === 'function' && existing.isMinimized()) {
|
||||
existing.restore?.()
|
||||
}
|
||||
|
||||
if (typeof existing.isVisible === 'function' && !existing.isVisible()) {
|
||||
existing.show?.()
|
||||
}
|
||||
|
||||
existing.focus?.()
|
||||
|
||||
return existing
|
||||
}
|
||||
|
||||
const win = factory(key)
|
||||
|
||||
if (!win) {
|
||||
return null
|
||||
}
|
||||
|
||||
windows.set(key, win)
|
||||
|
||||
// Self-cleanup on close so the registry never holds a destroyed window.
|
||||
win.on?.('closed', () => {
|
||||
if (windows.get(key) === win) {
|
||||
windows.delete(key)
|
||||
}
|
||||
})
|
||||
|
||||
return win
|
||||
}
|
||||
|
||||
return {
|
||||
openOrFocus,
|
||||
get: key => windows.get(key),
|
||||
has: key => windows.has(key),
|
||||
get size() {
|
||||
return windows.size
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { buildSessionWindowUrl, createSessionWindowRegistry }
|
||||
165
apps/desktop/electron/session-windows.test.cjs
Normal file
165
apps/desktop/electron/session-windows.test.cjs
Normal file
|
|
@ -0,0 +1,165 @@
|
|||
const assert = require('node:assert/strict')
|
||||
const test = require('node:test')
|
||||
|
||||
const { buildSessionWindowUrl, createSessionWindowRegistry } = require('./session-windows.cjs')
|
||||
|
||||
// A minimal fake BrowserWindow: tracks listeners + destroyed state and lets a
|
||||
// test fire the 'closed' event, mirroring the slice of the Electron API the
|
||||
// registry actually touches.
|
||||
function makeFakeWindow() {
|
||||
const listeners = {}
|
||||
const calls = { focus: 0, show: 0, restore: 0 }
|
||||
let destroyed = false
|
||||
let minimized = false
|
||||
let visible = true
|
||||
|
||||
return {
|
||||
on(event, handler) {
|
||||
listeners[event] = handler
|
||||
|
||||
return this
|
||||
},
|
||||
emit(event) {
|
||||
listeners[event]?.()
|
||||
},
|
||||
isDestroyed: () => destroyed,
|
||||
destroy() {
|
||||
destroyed = true
|
||||
},
|
||||
isMinimized: () => minimized,
|
||||
setMinimized(value) {
|
||||
minimized = value
|
||||
},
|
||||
isVisible: () => visible,
|
||||
setVisible(value) {
|
||||
visible = value
|
||||
},
|
||||
restore() {
|
||||
calls.restore += 1
|
||||
minimized = false
|
||||
},
|
||||
show() {
|
||||
calls.show += 1
|
||||
visible = true
|
||||
},
|
||||
focus() {
|
||||
calls.focus += 1
|
||||
},
|
||||
calls
|
||||
}
|
||||
}
|
||||
|
||||
test('buildSessionWindowUrl puts the secondary flag before the hash route (dev server)', () => {
|
||||
const url = buildSessionWindowUrl('abc123', { devServer: 'http://localhost:5173' })
|
||||
|
||||
assert.equal(url, 'http://localhost:5173/?win=secondary#/abc123')
|
||||
})
|
||||
|
||||
test('buildSessionWindowUrl avoids a double slash when the dev server has a trailing slash', () => {
|
||||
const url = buildSessionWindowUrl('abc123', { devServer: 'http://localhost:5173/' })
|
||||
|
||||
assert.equal(url, 'http://localhost:5173/?win=secondary#/abc123')
|
||||
})
|
||||
|
||||
test('buildSessionWindowUrl encodes the session id in the hash route', () => {
|
||||
const url = buildSessionWindowUrl('a b/c', { devServer: 'http://localhost:5173' })
|
||||
|
||||
// The query flag must precede the '#' or HashRouter would swallow it as the
|
||||
// route; the id is URL-encoded so slashes/spaces survive routeSessionId().
|
||||
assert.equal(url, 'http://localhost:5173/?win=secondary#/a%20b%2Fc')
|
||||
assert.ok(url.indexOf('?win=secondary') < url.indexOf('#'))
|
||||
})
|
||||
|
||||
test('buildSessionWindowUrl builds a packaged file URL with the flag before the hash', () => {
|
||||
const url = buildSessionWindowUrl('abc', { rendererIndexPath: '/opt/app/index.html' })
|
||||
|
||||
assert.match(url, /^file:\/\/.*index\.html\?win=secondary#\/abc$/)
|
||||
})
|
||||
|
||||
test('registry opens one window per session and focuses on re-open', () => {
|
||||
const registry = createSessionWindowRegistry()
|
||||
let built = 0
|
||||
const win = makeFakeWindow()
|
||||
const factory = () => {
|
||||
built += 1
|
||||
|
||||
return win
|
||||
}
|
||||
|
||||
const first = registry.openOrFocus('s1', factory)
|
||||
const second = registry.openOrFocus('s1', factory)
|
||||
|
||||
assert.equal(built, 1, 'factory runs once for the same session')
|
||||
assert.equal(first, second)
|
||||
assert.equal(registry.size, 1)
|
||||
assert.equal(win.calls.focus, 1, 'second open focuses the existing window')
|
||||
})
|
||||
|
||||
test('registry restores + shows a minimized/hidden window on re-open', () => {
|
||||
const registry = createSessionWindowRegistry()
|
||||
const win = makeFakeWindow()
|
||||
registry.openOrFocus('s1', () => win)
|
||||
|
||||
win.setMinimized(true)
|
||||
win.setVisible(false)
|
||||
registry.openOrFocus('s1', () => win)
|
||||
|
||||
assert.equal(win.calls.restore, 1)
|
||||
assert.equal(win.calls.show, 1)
|
||||
assert.equal(win.calls.focus, 1)
|
||||
})
|
||||
|
||||
test('registry drops the entry when the window closes', () => {
|
||||
const registry = createSessionWindowRegistry()
|
||||
const win = makeFakeWindow()
|
||||
registry.openOrFocus('s1', () => win)
|
||||
assert.equal(registry.size, 1)
|
||||
|
||||
win.emit('closed')
|
||||
|
||||
assert.equal(registry.size, 0)
|
||||
assert.equal(registry.has('s1'), false)
|
||||
})
|
||||
|
||||
test('registry rebuilds a fresh window after the previous one was destroyed', () => {
|
||||
const registry = createSessionWindowRegistry()
|
||||
const first = makeFakeWindow()
|
||||
registry.openOrFocus('s1', () => first)
|
||||
first.destroy()
|
||||
|
||||
let built = 0
|
||||
const second = makeFakeWindow()
|
||||
const result = registry.openOrFocus('s1', () => {
|
||||
built += 1
|
||||
|
||||
return second
|
||||
})
|
||||
|
||||
assert.equal(built, 1, 'a destroyed window is replaced, not focused')
|
||||
assert.equal(result, second)
|
||||
})
|
||||
|
||||
test('registry ignores empty / non-string session ids', () => {
|
||||
const registry = createSessionWindowRegistry()
|
||||
let built = 0
|
||||
const factory = () => {
|
||||
built += 1
|
||||
|
||||
return makeFakeWindow()
|
||||
}
|
||||
|
||||
assert.equal(registry.openOrFocus('', factory), null)
|
||||
assert.equal(registry.openOrFocus(' ', factory), null)
|
||||
assert.equal(registry.openOrFocus(null, factory), null)
|
||||
assert.equal(registry.openOrFocus(42, factory), null)
|
||||
assert.equal(built, 0)
|
||||
assert.equal(registry.size, 0)
|
||||
})
|
||||
|
||||
test('registry trims the session id before keying', () => {
|
||||
const registry = createSessionWindowRegistry()
|
||||
const win = makeFakeWindow()
|
||||
registry.openOrFocus(' s1 ', () => win)
|
||||
|
||||
assert.equal(registry.has('s1'), true)
|
||||
})
|
||||
|
|
@ -35,7 +35,7 @@
|
|||
"test:desktop:nsis": "node scripts/test-desktop.mjs nsis",
|
||||
"test:desktop:existing": "node scripts/test-desktop.mjs existing",
|
||||
"test:desktop:fresh": "node scripts/test-desktop.mjs fresh",
|
||||
"test:desktop:platforms": "node --test electron/bootstrap-platform.test.cjs electron/hardening.test.cjs electron/backend-probes.test.cjs electron/bootstrap-runner.test.cjs electron/connection-config.test.cjs electron/gateway-ws-probe.test.cjs electron/oauth-net-request.test.cjs electron/desktop-uninstall.test.cjs",
|
||||
"test:desktop:platforms": "node --test electron/bootstrap-platform.test.cjs electron/hardening.test.cjs electron/backend-probes.test.cjs electron/bootstrap-runner.test.cjs electron/connection-config.test.cjs electron/gateway-ws-probe.test.cjs electron/oauth-net-request.test.cjs electron/desktop-uninstall.test.cjs electron/session-windows.test.cjs",
|
||||
"type-check": "tsc -b",
|
||||
"lint": "eslint src/ electron/",
|
||||
"lint:fix": "eslint src/ electron/ --fix",
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ import { triggerHaptic } from '@/lib/haptics'
|
|||
import { exportSession } from '@/lib/session-export'
|
||||
import { notify, notifyError } from '@/store/notifications'
|
||||
import { setSessions } from '@/store/session'
|
||||
import { canOpenSessionWindow, openSessionInNewWindow } from '@/store/windows'
|
||||
|
||||
interface SessionActions {
|
||||
sessionId: string
|
||||
|
|
@ -68,6 +69,19 @@ function useSessionActions({ sessionId, title, pinned = false, profile, onPin, o
|
|||
void writeClipboardText(sessionId).catch(err => notifyError(err, r.copyIdFailed))
|
||||
}
|
||||
},
|
||||
...(canOpenSessionWindow()
|
||||
? [
|
||||
{
|
||||
disabled: !sessionId,
|
||||
icon: 'link-external',
|
||||
label: r.newWindow,
|
||||
onSelect: () => {
|
||||
triggerHaptic('selection')
|
||||
void openSessionInNewWindow(sessionId)
|
||||
}
|
||||
}
|
||||
]
|
||||
: []),
|
||||
{
|
||||
disabled: !sessionId,
|
||||
icon: 'cloud-download',
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import { triggerHaptic } from '@/lib/haptics'
|
|||
import { handoffOriginSource, sessionSourceLabel } from '@/lib/session-source'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { $attentionSessionIds } from '@/store/session'
|
||||
import { canOpenSessionWindow, openSessionInNewWindow } from '@/store/windows'
|
||||
|
||||
import { SessionActionsMenu, SessionContextMenu } from './session-actions-menu'
|
||||
|
||||
|
|
@ -132,11 +133,15 @@ export function SidebarSessionRow({
|
|||
return
|
||||
}
|
||||
|
||||
if (event.metaKey || event.ctrlKey) {
|
||||
// ⌘-click (mac) / ⌃-click (win/linux) pops the chat into its own
|
||||
// window — the universal "open in a new window" gesture. Archive
|
||||
// lives in the row's ⋯ and right-click menus. Falls through to a
|
||||
// normal resume when standalone windows aren't available (web embed).
|
||||
if ((event.metaKey || event.ctrlKey) && canOpenSessionWindow()) {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
triggerHaptic('selection')
|
||||
onArchive()
|
||||
void openSessionInNewWindow(session.id)
|
||||
|
||||
return
|
||||
}
|
||||
|
|
|
|||
|
|
@ -50,9 +50,9 @@ import {
|
|||
$currentCwd,
|
||||
$freshDraftReady,
|
||||
$gatewayState,
|
||||
$messagingSessions,
|
||||
$selectedStoredSessionId,
|
||||
$sessions,
|
||||
$messagingSessions,
|
||||
$workingSessionIds,
|
||||
CRON_SECTION_LIMIT,
|
||||
getRecentlySettledSessionIds,
|
||||
|
|
@ -76,6 +76,7 @@ import {
|
|||
setSessionsTotal
|
||||
} from '../store/session'
|
||||
import { openUpdatesWindow, startUpdatePoller, stopUpdatePoller } from '../store/updates'
|
||||
import { isSecondaryWindow } from '../store/windows'
|
||||
|
||||
import { ChatView } from './chat'
|
||||
import { useComposerActions } from './chat/hooks/use-composer-actions'
|
||||
|
|
@ -791,19 +792,21 @@ export function DesktopController() {
|
|||
|
||||
const overlays = (
|
||||
<>
|
||||
<DesktopInstallOverlay />
|
||||
{!isSecondaryWindow() && <DesktopInstallOverlay />}
|
||||
{/* One PTY-backed terminal mounted forever; <TerminalSlot /> placeholders
|
||||
decide where it shows. Toggling fullscreen never rebuilds the shell. */}
|
||||
<PersistentTerminal cwd={currentCwd} onAddSelectionToChat={composer.addTerminalSelectionAttachment} />
|
||||
<DesktopOnboardingOverlay
|
||||
enabled={gatewayState === 'open'}
|
||||
onCompleted={() => {
|
||||
void refreshHermesConfig()
|
||||
void refreshCurrentModel()
|
||||
void queryClient.invalidateQueries({ queryKey: ['model-options'] })
|
||||
}}
|
||||
requestGateway={requestGateway}
|
||||
/>
|
||||
{!isSecondaryWindow() && (
|
||||
<DesktopOnboardingOverlay
|
||||
enabled={gatewayState === 'open'}
|
||||
onCompleted={() => {
|
||||
void refreshHermesConfig()
|
||||
void refreshCurrentModel()
|
||||
void queryClient.invalidateQueries({ queryKey: ['model-options'] })
|
||||
}}
|
||||
requestGateway={requestGateway}
|
||||
/>
|
||||
)}
|
||||
<ModelPickerOverlay gateway={gatewayRef.current || undefined} onSelect={selectModel} />
|
||||
<ModelVisibilityOverlay gateway={gatewayRef.current || undefined} onOpenProviders={openProviderSettings} />
|
||||
<UpdatesOverlay />
|
||||
|
|
@ -957,20 +960,22 @@ export function DesktopController() {
|
|||
statusbarItems={statusbarItems}
|
||||
titlebarTools={titlebarToolGroups.flat.right}
|
||||
>
|
||||
<Pane
|
||||
disabled={terminalTakeoverActive}
|
||||
forceCollapsed={narrowViewport}
|
||||
hoverReveal
|
||||
id="chat-sidebar"
|
||||
maxWidth={SIDEBAR_MAX_WIDTH}
|
||||
minWidth={SIDEBAR_DEFAULT_WIDTH}
|
||||
onOverlayActiveChange={setSidebarOverlayMounted}
|
||||
resizable
|
||||
side={sidebarSide}
|
||||
width={`${SIDEBAR_DEFAULT_WIDTH}px`}
|
||||
>
|
||||
{sidebar}
|
||||
</Pane>
|
||||
{!isSecondaryWindow() && (
|
||||
<Pane
|
||||
disabled={terminalTakeoverActive}
|
||||
forceCollapsed={narrowViewport}
|
||||
hoverReveal
|
||||
id="chat-sidebar"
|
||||
maxWidth={SIDEBAR_MAX_WIDTH}
|
||||
minWidth={SIDEBAR_DEFAULT_WIDTH}
|
||||
onOverlayActiveChange={setSidebarOverlayMounted}
|
||||
resizable
|
||||
side={sidebarSide}
|
||||
width={`${SIDEBAR_DEFAULT_WIDTH}px`}
|
||||
>
|
||||
{sidebar}
|
||||
</Pane>
|
||||
)}
|
||||
<PaneMain>
|
||||
<Routes>
|
||||
<Route element={terminalTakeoverActive ? takeoverTerminalView : chatView} index />
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ import {
|
|||
} from '@/store/layout'
|
||||
import { $paneWidthOverride } from '@/store/panes'
|
||||
import { $connection } from '@/store/session'
|
||||
import { isSecondaryWindow } from '@/store/windows'
|
||||
|
||||
import { SIDEBAR_COLLAPSE_MEDIA_QUERY } from '../layout-constants'
|
||||
|
||||
|
|
@ -77,8 +78,10 @@ export function AppShell({
|
|||
// window's left edge. Default layout: the sessions sidebar sits there.
|
||||
// Flipped layout: the file browser does instead. Below the collapse
|
||||
// breakpoint both rails are force-collapsed (hover-reveal overlay), so the
|
||||
// edge is uncovered regardless of their stored open state.
|
||||
const leftEdgePaneOpen = !narrowViewport && (panesFlipped ? fileBrowserOpen : sidebarOpen)
|
||||
// edge is uncovered regardless of their stored open state. A standalone
|
||||
// session window renders no sidebar at all, so its edge is always uncovered.
|
||||
const leftEdgePaneOpen =
|
||||
!narrowViewport && !isSecondaryWindow() && (panesFlipped ? fileBrowserOpen : sidebarOpen)
|
||||
|
||||
const titlebarContentInset = leftEdgePaneOpen
|
||||
? 0
|
||||
|
|
|
|||
4
apps/desktop/src/global.d.ts
vendored
4
apps/desktop/src/global.d.ts
vendored
|
|
@ -18,6 +18,10 @@ declare global {
|
|||
// reaper spares it while its chat is active.
|
||||
touchBackend: (profile?: string | null) => Promise<{ ok: boolean }>
|
||||
getGatewayWsUrl: (profile?: null | string) => Promise<string>
|
||||
// Open (or focus) a standalone OS window for a single chat session so
|
||||
// the user can work with multiple chats side by side. Returns ok:false
|
||||
// with an error code when the sessionId is empty/invalid.
|
||||
openSessionWindow: (sessionId: string) => Promise<{ ok: boolean; error?: string }>
|
||||
getBootProgress: () => Promise<DesktopBootProgress>
|
||||
getConnectionConfig: (profile?: null | string) => Promise<DesktopConnectionConfig>
|
||||
saveConnectionConfig: (payload: DesktopConnectionConfigInput) => Promise<DesktopConnectionConfig>
|
||||
|
|
|
|||
|
|
@ -1084,6 +1084,7 @@ export const en: Translations = {
|
|||
export: 'Export',
|
||||
rename: 'Rename',
|
||||
archive: 'Archive',
|
||||
newWindow: 'New window',
|
||||
copyIdFailed: 'Could not copy session ID',
|
||||
actionsFor: title => `Actions for ${title}`,
|
||||
sessionActions: 'Session actions',
|
||||
|
|
|
|||
|
|
@ -1218,6 +1218,7 @@ export const ja = defineLocale({
|
|||
export: 'エクスポート',
|
||||
rename: '名前を変更',
|
||||
archive: 'アーカイブ',
|
||||
newWindow: '新しいウィンドウ',
|
||||
copyIdFailed: 'セッション ID をコピーできませんでした',
|
||||
actionsFor: title => `${title} のアクション`,
|
||||
sessionActions: 'セッションアクション',
|
||||
|
|
|
|||
|
|
@ -832,6 +832,7 @@ export interface Translations {
|
|||
export: string
|
||||
rename: string
|
||||
archive: string
|
||||
newWindow: string
|
||||
copyIdFailed: string
|
||||
actionsFor: (title: string) => string
|
||||
sessionActions: string
|
||||
|
|
|
|||
|
|
@ -1184,6 +1184,7 @@ export const zhHant = defineLocale({
|
|||
export: '匯出',
|
||||
rename: '重新命名',
|
||||
archive: '封存',
|
||||
newWindow: '新視窗',
|
||||
copyIdFailed: '無法複製工作階段 ID',
|
||||
actionsFor: title => `${title} 的動作`,
|
||||
sessionActions: '工作階段動作',
|
||||
|
|
|
|||
|
|
@ -1271,6 +1271,7 @@ export const zh: Translations = {
|
|||
export: '导出',
|
||||
rename: '重命名',
|
||||
archive: '归档',
|
||||
newWindow: '新窗口',
|
||||
copyIdFailed: '无法复制会话 ID',
|
||||
actionsFor: title => `${title} 的操作`,
|
||||
sessionActions: '会话操作',
|
||||
|
|
|
|||
93
apps/desktop/src/store/windows.test.ts
Normal file
93
apps/desktop/src/store/windows.test.ts
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { canOpenSessionWindow, openSessionInNewWindow } from './windows'
|
||||
|
||||
const desktopWindow = window as unknown as { hermesDesktop?: Window['hermesDesktop'] }
|
||||
const initialHermesDesktop = desktopWindow.hermesDesktop
|
||||
|
||||
const notifyError = vi.fn()
|
||||
|
||||
vi.mock('./notifications', () => ({
|
||||
notifyError: (...args: unknown[]) => notifyError(...args)
|
||||
}))
|
||||
|
||||
function installBridge(openSessionWindow?: Window['hermesDesktop']['openSessionWindow']) {
|
||||
desktopWindow.hermesDesktop = {
|
||||
...(openSessionWindow ? { openSessionWindow } : {})
|
||||
} as unknown as Window['hermesDesktop']
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
notifyError.mockClear()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
if (initialHermesDesktop) {
|
||||
desktopWindow.hermesDesktop = initialHermesDesktop
|
||||
} else {
|
||||
delete desktopWindow.hermesDesktop
|
||||
}
|
||||
})
|
||||
|
||||
describe('canOpenSessionWindow', () => {
|
||||
it('is false when the desktop bridge is absent', () => {
|
||||
delete desktopWindow.hermesDesktop
|
||||
expect(canOpenSessionWindow()).toBe(false)
|
||||
})
|
||||
|
||||
it('is false when the bridge lacks openSessionWindow', () => {
|
||||
installBridge(undefined)
|
||||
expect(canOpenSessionWindow()).toBe(false)
|
||||
})
|
||||
|
||||
it('is true when the bridge exposes openSessionWindow', () => {
|
||||
installBridge(vi.fn().mockResolvedValue({ ok: true }))
|
||||
expect(canOpenSessionWindow()).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('openSessionInNewWindow', () => {
|
||||
it('no-ops without a session id', async () => {
|
||||
const open = vi.fn().mockResolvedValue({ ok: true })
|
||||
installBridge(open)
|
||||
|
||||
await openSessionInNewWindow('')
|
||||
|
||||
expect(open).not.toHaveBeenCalled()
|
||||
expect(notifyError).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('no-ops gracefully when the bridge is absent (web fallback)', async () => {
|
||||
delete desktopWindow.hermesDesktop
|
||||
|
||||
await openSessionInNewWindow('s1')
|
||||
|
||||
expect(notifyError).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('invokes the bridge with the session id', async () => {
|
||||
const open = vi.fn().mockResolvedValue({ ok: true })
|
||||
installBridge(open)
|
||||
|
||||
await openSessionInNewWindow('s1')
|
||||
|
||||
expect(open).toHaveBeenCalledWith('s1')
|
||||
expect(notifyError).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('notifies on an ok:false result', async () => {
|
||||
installBridge(vi.fn().mockResolvedValue({ ok: false, error: 'invalid-session-id' }))
|
||||
|
||||
await openSessionInNewWindow('s1')
|
||||
|
||||
expect(notifyError).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('notifies when the bridge throws', async () => {
|
||||
installBridge(vi.fn().mockRejectedValue(new Error('boom')))
|
||||
|
||||
await openSessionInNewWindow('s1')
|
||||
|
||||
expect(notifyError).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
52
apps/desktop/src/store/windows.ts
Normal file
52
apps/desktop/src/store/windows.ts
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
import { notifyError } from './notifications'
|
||||
|
||||
// Window flag set by the Electron main process when it opens a standalone
|
||||
// session window (see electron/main.cjs buildSessionWindowUrl). It rides in the
|
||||
// query string BEFORE the HashRouter '#', so we read it from location.search,
|
||||
// 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'
|
||||
|
||||
let secondaryWindowCache: boolean | null = null
|
||||
|
||||
export function isSecondaryWindow(): boolean {
|
||||
if (secondaryWindowCache !== null) {
|
||||
return secondaryWindowCache
|
||||
}
|
||||
|
||||
let result = false
|
||||
|
||||
try {
|
||||
result = new URLSearchParams(window.location.search).get('win') === SECONDARY_WINDOW_FLAG
|
||||
} catch {
|
||||
result = false
|
||||
}
|
||||
|
||||
secondaryWindowCache = result
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// True when running inside the Electron desktop shell (the preload bridge is
|
||||
// present). The "open in new window" affordance is desktop-only.
|
||||
export function canOpenSessionWindow(): boolean {
|
||||
return typeof window !== 'undefined' && typeof window.hermesDesktop?.openSessionWindow === 'function'
|
||||
}
|
||||
|
||||
// Open (or focus) a standalone OS window for a single chat session. No-ops
|
||||
// gracefully outside Electron so callers can wire it unconditionally.
|
||||
export async function openSessionInNewWindow(sessionId: string): Promise<void> {
|
||||
if (!sessionId || !canOpenSessionWindow()) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await window.hermesDesktop.openSessionWindow(sessionId)
|
||||
|
||||
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')
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue