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:
brooklyn!
2026-06-09 21:09:45 -05:00
committed by GitHub
co-authored by tim404x
parent d33965396e
commit b96bd4808d
17 changed files with 570 additions and 48 deletions
@@ -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
}
+30 -25
View File
@@ -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 />
+5 -2
View File
@@ -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
View File
@@ -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>
+1
View File
@@ -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',
+1
View File
@@ -1218,6 +1218,7 @@ export const ja = defineLocale({
export: 'エクスポート',
rename: '名前を変更',
archive: 'アーカイブ',
newWindow: '新しいウィンドウ',
copyIdFailed: 'セッション ID をコピーできませんでした',
actionsFor: title => `${title} のアクション`,
sessionActions: 'セッションアクション',
+1
View File
@@ -832,6 +832,7 @@ export interface Translations {
export: string
rename: string
archive: string
newWindow: string
copyIdFailed: string
actionsFor: (title: string) => string
sessionActions: string
+1
View File
@@ -1184,6 +1184,7 @@ export const zhHant = defineLocale({
export: '匯出',
rename: '重新命名',
archive: '封存',
newWindow: '新視窗',
copyIdFailed: '無法複製工作階段 ID',
actionsFor: title => `${title} 的動作`,
sessionActions: '工作階段動作',
+1
View File
@@ -1271,6 +1271,7 @@ export const zh: Translations = {
export: '导出',
rename: '重命名',
archive: '归档',
newWindow: '新窗口',
copyIdFailed: '无法复制会话 ID',
actionsFor: title => `${title} 的操作`,
sessionActions: '会话操作',
+93
View 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
View 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')
}
}