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} > - - {sidebar} - + {!isSecondaryWindow() && ( + + {sidebar} + + )} 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') + } +}