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:
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user