diff --git a/apps/desktop/electron/main.cjs b/apps/desktop/electron/main.cjs
index 4f21e8c2829..dab99e37404 100644
--- a/apps/desktop/electron/main.cjs
+++ b/apps/desktop/electron/main.cjs
@@ -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)
+ }
})
})
diff --git a/apps/desktop/electron/preload.cjs b/apps/desktop/electron/preload.cjs
index cf094e751c3..f45616c20fc 100644
--- a/apps/desktop/electron/preload.cjs
+++ b/apps/desktop/electron/preload.cjs
@@ -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),
diff --git a/apps/desktop/electron/session-windows.cjs b/apps/desktop/electron/session-windows.cjs
new file mode 100644
index 00000000000..8775feb1bce
--- /dev/null
+++ b/apps/desktop/electron/session-windows.cjs
@@ -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 }
diff --git a/apps/desktop/electron/session-windows.test.cjs b/apps/desktop/electron/session-windows.test.cjs
new file mode 100644
index 00000000000..3453971eb51
--- /dev/null
+++ b/apps/desktop/electron/session-windows.test.cjs
@@ -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)
+})
diff --git a/apps/desktop/package.json b/apps/desktop/package.json
index 22f7a9dd4b6..30945ecd0a6 100644
--- a/apps/desktop/package.json
+++ b/apps/desktop/package.json
@@ -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",
diff --git a/apps/desktop/src/app/chat/sidebar/session-actions-menu.tsx b/apps/desktop/src/app/chat/sidebar/session-actions-menu.tsx
index 7bd9471a91d..4d7ebf946ce 100644
--- a/apps/desktop/src/app/chat/sidebar/session-actions-menu.tsx
+++ b/apps/desktop/src/app/chat/sidebar/session-actions-menu.tsx
@@ -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',
diff --git a/apps/desktop/src/app/chat/sidebar/session-row.tsx b/apps/desktop/src/app/chat/sidebar/session-row.tsx
index 0ce047bfc83..cd21a63a6f9 100644
--- a/apps/desktop/src/app/chat/sidebar/session-row.tsx
+++ b/apps/desktop/src/app/chat/sidebar/session-row.tsx
@@ -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
}
diff --git a/apps/desktop/src/app/desktop-controller.tsx b/apps/desktop/src/app/desktop-controller.tsx
index b7f509463f0..b655042e49d 100644
--- a/apps/desktop/src/app/desktop-controller.tsx
+++ b/apps/desktop/src/app/desktop-controller.tsx
@@ -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 = (
<>
-
+ {!isSecondaryWindow() && }
{/* One PTY-backed terminal mounted forever; placeholders
decide where it shows. Toggling fullscreen never rebuilds the shell. */}
- {
- void refreshHermesConfig()
- void refreshCurrentModel()
- void queryClient.invalidateQueries({ queryKey: ['model-options'] })
- }}
- requestGateway={requestGateway}
- />
+ {!isSecondaryWindow() && (
+ {
+ void refreshHermesConfig()
+ void refreshCurrentModel()
+ void queryClient.invalidateQueries({ queryKey: ['model-options'] })
+ }}
+ requestGateway={requestGateway}
+ />
+ )}
@@ -957,20 +960,22 @@ export function DesktopController() {
statusbarItems={statusbarItems}
titlebarTools={titlebarToolGroups.flat.right}
>
-
+ {!isSecondaryWindow() && (
+
+ )}
diff --git a/apps/desktop/src/app/shell/app-shell.tsx b/apps/desktop/src/app/shell/app-shell.tsx
index 1c60e6411cf..c4d2e368eaf 100644
--- a/apps/desktop/src/app/shell/app-shell.tsx
+++ b/apps/desktop/src/app/shell/app-shell.tsx
@@ -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
diff --git a/apps/desktop/src/global.d.ts b/apps/desktop/src/global.d.ts
index 213fe5c08d5..5a7db905f07 100644
--- a/apps/desktop/src/global.d.ts
+++ b/apps/desktop/src/global.d.ts
@@ -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
+ // 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
getConnectionConfig: (profile?: null | string) => Promise
saveConnectionConfig: (payload: DesktopConnectionConfigInput) => Promise
diff --git a/apps/desktop/src/i18n/en.ts b/apps/desktop/src/i18n/en.ts
index 1050d974877..ccefe464c7e 100644
--- a/apps/desktop/src/i18n/en.ts
+++ b/apps/desktop/src/i18n/en.ts
@@ -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',
diff --git a/apps/desktop/src/i18n/ja.ts b/apps/desktop/src/i18n/ja.ts
index a0473762f23..0843d074a2d 100644
--- a/apps/desktop/src/i18n/ja.ts
+++ b/apps/desktop/src/i18n/ja.ts
@@ -1218,6 +1218,7 @@ export const ja = defineLocale({
export: 'エクスポート',
rename: '名前を変更',
archive: 'アーカイブ',
+ newWindow: '新しいウィンドウ',
copyIdFailed: 'セッション ID をコピーできませんでした',
actionsFor: title => `${title} のアクション`,
sessionActions: 'セッションアクション',
diff --git a/apps/desktop/src/i18n/types.ts b/apps/desktop/src/i18n/types.ts
index 488fddfd380..16d1a08d352 100644
--- a/apps/desktop/src/i18n/types.ts
+++ b/apps/desktop/src/i18n/types.ts
@@ -832,6 +832,7 @@ export interface Translations {
export: string
rename: string
archive: string
+ newWindow: string
copyIdFailed: string
actionsFor: (title: string) => string
sessionActions: string
diff --git a/apps/desktop/src/i18n/zh-hant.ts b/apps/desktop/src/i18n/zh-hant.ts
index 54905b258d5..821144be67b 100644
--- a/apps/desktop/src/i18n/zh-hant.ts
+++ b/apps/desktop/src/i18n/zh-hant.ts
@@ -1184,6 +1184,7 @@ export const zhHant = defineLocale({
export: '匯出',
rename: '重新命名',
archive: '封存',
+ newWindow: '新視窗',
copyIdFailed: '無法複製工作階段 ID',
actionsFor: title => `${title} 的動作`,
sessionActions: '工作階段動作',
diff --git a/apps/desktop/src/i18n/zh.ts b/apps/desktop/src/i18n/zh.ts
index 26b0702ef92..55b86dc1517 100644
--- a/apps/desktop/src/i18n/zh.ts
+++ b/apps/desktop/src/i18n/zh.ts
@@ -1271,6 +1271,7 @@ export const zh: Translations = {
export: '导出',
rename: '重命名',
archive: '归档',
+ newWindow: '新窗口',
copyIdFailed: '无法复制会话 ID',
actionsFor: title => `${title} 的操作`,
sessionActions: '会话操作',
diff --git a/apps/desktop/src/store/windows.test.ts b/apps/desktop/src/store/windows.test.ts
new file mode 100644
index 00000000000..18487480fcd
--- /dev/null
+++ b/apps/desktop/src/store/windows.test.ts
@@ -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)
+ })
+})
diff --git a/apps/desktop/src/store/windows.ts b/apps/desktop/src/store/windows.ts
new file mode 100644
index 00000000000..57a47bf0bca
--- /dev/null
+++ b/apps/desktop/src/store/windows.ts
@@ -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 {
+ 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')
+ }
+}