Skip to content

Commit 9d71673

Browse files
committed
feat(desktop): support opening sessions in multiple windows (#40394)
1 parent 56236b1 commit 9d71673

11 files changed

Lines changed: 571 additions & 113 deletions

File tree

apps/desktop/electron/main.cjs

Lines changed: 225 additions & 113 deletions
Large diffs are not rendered by default.

apps/desktop/electron/preload.cjs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ contextBridge.exposeInMainWorld('hermesDesktop', {
4040
setPreviewShortcutActive: active => ipcRenderer.send('hermes:previewShortcutActive', Boolean(active)),
4141
openExternal: url => ipcRenderer.invoke('hermes:openExternal', url),
4242
fetchLinkTitle: url => ipcRenderer.invoke('hermes:fetchLinkTitle', url),
43+
openWindow: options => ipcRenderer.invoke('hermes:window:open', options),
4344
settings: {
4445
getDefaultProjectDir: () => ipcRenderer.invoke('hermes:setting:defaultProjectDir:get'),
4546
setDefaultProjectDir: dir => ipcRenderer.invoke('hermes:setting:defaultProjectDir:set', dir),
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
// @vitest-environment jsdom
2+
3+
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
4+
import { MemoryRouter } from 'react-router-dom'
5+
import { afterEach, describe, expect, it, vi } from 'vitest'
6+
7+
import { I18nProvider } from '@/i18n'
8+
9+
import { SessionActionsMenu } from './session-actions-menu'
10+
11+
vi.mock('@/hermes', () => ({
12+
renameSession: vi.fn()
13+
}))
14+
15+
vi.mock('@/lib/session-export', () => ({
16+
exportSession: vi.fn()
17+
}))
18+
19+
vi.mock('@/store/notifications', () => ({
20+
notify: vi.fn(),
21+
notifyError: vi.fn()
22+
}))
23+
24+
vi.mock('@/store/session', () => ({
25+
setSessions: vi.fn()
26+
}))
27+
28+
vi.mock('@/lib/haptics', () => ({
29+
triggerHaptic: vi.fn()
30+
}))
31+
32+
function installDesktopBridge(partial: Partial<Window['hermesDesktop']> = {}) {
33+
Object.defineProperty(window, 'hermesDesktop', {
34+
configurable: true,
35+
value: {
36+
openWindow: vi.fn().mockResolvedValue({ ok: true }),
37+
...partial
38+
} as Window['hermesDesktop']
39+
})
40+
}
41+
42+
function renderMenu() {
43+
return render(
44+
<MemoryRouter>
45+
<I18nProvider configClient={null}>
46+
<SessionActionsMenu
47+
onArchive={vi.fn()}
48+
onDelete={vi.fn()}
49+
onPin={vi.fn()}
50+
pinned={false}
51+
sessionId="session-123"
52+
title="Demo Session"
53+
>
54+
<button type="button">Open menu</button>
55+
</SessionActionsMenu>
56+
</I18nProvider>
57+
</MemoryRouter>
58+
)
59+
}
60+
61+
afterEach(() => {
62+
cleanup()
63+
vi.restoreAllMocks()
64+
delete (window as { hermesDesktop?: unknown }).hermesDesktop
65+
})
66+
67+
describe('SessionActionsMenu', () => {
68+
it('opens the session route in a new desktop window', () => {
69+
const openWindow = vi.fn().mockResolvedValue({ ok: true })
70+
installDesktopBridge({ openWindow })
71+
72+
renderMenu()
73+
74+
fireEvent.pointerDown(screen.getByRole('button', { name: 'Open menu' }), { button: 0, ctrlKey: false })
75+
fireEvent.click(screen.getByRole('menuitem', { name: /Open in New Window/i }))
76+
77+
expect(openWindow).toHaveBeenCalledWith({ route: '/session-123' })
78+
})
79+
})

apps/desktop/src/app/chat/sidebar/session-actions-menu.tsx

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import {
1515
} from '@/components/ui/dialog'
1616
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'
1717
import { Input } from '@/components/ui/input'
18+
import { sessionRoute } from '@/app/routes'
1819
import { renameSession } from '@/hermes'
1920
import { useI18n } from '@/i18n'
2021
import { triggerHaptic } from '@/lib/haptics'
@@ -58,6 +59,15 @@ function useSessionActions({ sessionId, title, pinned = false, profile, onPin, o
5859
onPin?.()
5960
}
6061
},
62+
{
63+
disabled: !sessionId || !window.hermesDesktop?.openWindow,
64+
icon: 'go-to-file',
65+
label: r.openInNewWindow,
66+
onSelect: () => {
67+
triggerHaptic('selection')
68+
void window.hermesDesktop?.openWindow?.({ route: sessionRoute(sessionId) })
69+
}
70+
},
6171
{
6272
disabled: !sessionId,
6373
icon: 'copy',

apps/desktop/src/app/desktop-controller.tsx

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -412,6 +412,27 @@ export function DesktopController() {
412412
[activeSessionIdRef, selectedStoredSessionIdRef, updateSessionState]
413413
)
414414

415+
useEffect(() => {
416+
const onFocus = () => {
417+
if (currentView !== 'chat' || gatewayState !== 'open' || busyRef.current) {
418+
return
419+
}
420+
421+
const storedSessionId = selectedStoredSessionIdRef.current
422+
const runtimeSessionId = activeSessionIdRef.current
423+
424+
if (!storedSessionId || !runtimeSessionId) {
425+
return
426+
}
427+
428+
void hydrateFromStoredSession(1, storedSessionId, runtimeSessionId)
429+
}
430+
431+
window.addEventListener('focus', onFocus)
432+
433+
return () => window.removeEventListener('focus', onFocus)
434+
}, [currentView, gatewayState, hydrateFromStoredSession, selectedStoredSessionIdRef, activeSessionIdRef])
435+
415436
const { handleGatewayEvent } = useMessageStream({
416437
activeSessionIdRef,
417438
hydrateFromStoredSession,
Lines changed: 211 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,211 @@
1+
import { act, cleanup, render } from '@testing-library/react'
2+
import { useEffect, useRef } from 'react'
3+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
4+
5+
import type { ClientSessionState } from '@/app/types'
6+
import { assistantTextPart, chatMessageText, type ChatMessage } from '@/lib/chat-messages'
7+
import { createClientSessionState } from '@/lib/chat-runtime'
8+
import { setSessions } from '@/store/session'
9+
import type { SessionMessagesResponse, UsageStats } from '@/types/hermes'
10+
11+
import { useSessionActions } from './use-session-actions'
12+
13+
const mocks = vi.hoisted(() => ({
14+
ensureGatewayProfile: vi.fn(async () => undefined),
15+
getProfiles: vi.fn(async () => ({ profiles: [] })),
16+
getSessionMessages: vi.fn<(...args: unknown[]) => Promise<SessionMessagesResponse>>(),
17+
setApiRequestProfile: vi.fn()
18+
}))
19+
20+
vi.mock('@/hermes', async importOriginal => {
21+
const actual = await importOriginal<typeof import('@/hermes')>()
22+
23+
return {
24+
...actual,
25+
getProfiles: mocks.getProfiles,
26+
getSessionMessages: mocks.getSessionMessages,
27+
setApiRequestProfile: mocks.setApiRequestProfile
28+
}
29+
})
30+
31+
vi.mock('@/store/profile', async importOriginal => {
32+
const actual = await importOriginal<typeof import('@/store/profile')>()
33+
34+
return {
35+
...actual,
36+
ensureGatewayProfile: mocks.ensureGatewayProfile
37+
}
38+
})
39+
40+
interface HarnessHandle {
41+
resumeSession: (storedSessionId: string, replaceRoute?: boolean) => Promise<void>
42+
}
43+
44+
function assistantMessage(id: string, text: string): ChatMessage {
45+
return {
46+
id,
47+
parts: [assistantTextPart(text)],
48+
role: 'assistant'
49+
}
50+
}
51+
52+
function cachedState(): ClientSessionState {
53+
return {
54+
...createClientSessionState('stored-session-1', [assistantMessage('cached-assistant', 'stale window copy')]),
55+
storedSessionId: 'stored-session-1'
56+
}
57+
}
58+
59+
function requestGatewayMock(usage: UsageStats) {
60+
const impl = async <T,>(method: string) => {
61+
if (method === 'session.usage') {
62+
return usage as T
63+
}
64+
65+
return {} as T
66+
}
67+
68+
return vi.fn(impl) as unknown as <T>(method: string, params?: Record<string, unknown>) => Promise<T>
69+
}
70+
71+
function Harness({
72+
onReady,
73+
onSync,
74+
requestGateway
75+
}: {
76+
onReady: (handle: HarnessHandle) => void
77+
onSync: (sessionId: string, state: ClientSessionState) => void
78+
requestGateway: <T>(method: string, params?: Record<string, unknown>) => Promise<T>
79+
}) {
80+
const activeSessionIdRef = useRef<string | null>('runtime-session-1')
81+
const busyRef = useRef(false)
82+
const creatingSessionRef = useRef(false)
83+
const runtimeIdByStoredSessionIdRef = useRef(new Map([['stored-session-1', 'runtime-session-1']]))
84+
const selectedStoredSessionIdRef = useRef<string | null>('stored-session-1')
85+
const sessionStateByRuntimeIdRef = useRef(new Map([['runtime-session-1', cachedState()]]))
86+
87+
const actions = useSessionActions({
88+
activeSessionId: null,
89+
activeSessionIdRef,
90+
busyRef,
91+
creatingSessionRef,
92+
ensureSessionState: sessionId => sessionStateByRuntimeIdRef.current.get(sessionId) ?? cachedState(),
93+
getRouteToken: () => '/stored-session-1',
94+
navigate: vi.fn(),
95+
requestGateway,
96+
runtimeIdByStoredSessionIdRef,
97+
selectedStoredSessionId: null,
98+
selectedStoredSessionIdRef,
99+
sessionStateByRuntimeIdRef,
100+
syncSessionStateToView: (sessionId, state) => {
101+
onSync(sessionId, state)
102+
},
103+
updateSessionState: (sessionId, updater) => {
104+
const current = sessionStateByRuntimeIdRef.current.get(sessionId) ?? cachedState()
105+
const next = updater(current)
106+
sessionStateByRuntimeIdRef.current.set(sessionId, next)
107+
onSync(sessionId, next)
108+
109+
return next
110+
}
111+
})
112+
113+
useEffect(() => {
114+
onReady({ resumeSession: actions.resumeSession })
115+
}, [actions.resumeSession, onReady])
116+
117+
return null
118+
}
119+
120+
describe('useSessionActions resumeSession', () => {
121+
beforeEach(() => {
122+
setSessions(() => [
123+
{
124+
ended_at: null,
125+
id: 'stored-session-1',
126+
input_tokens: 0,
127+
is_active: true,
128+
last_active: 0,
129+
message_count: 2,
130+
model: null,
131+
output_tokens: 0,
132+
preview: 'fresh from window A',
133+
profile: 'builder',
134+
source: 'cli',
135+
started_at: 0,
136+
title: 'Session 1',
137+
tool_call_count: 0
138+
}
139+
])
140+
mocks.ensureGatewayProfile.mockClear()
141+
mocks.getProfiles.mockClear()
142+
mocks.getSessionMessages.mockReset()
143+
mocks.setApiRequestProfile.mockClear()
144+
})
145+
146+
afterEach(() => {
147+
cleanup()
148+
vi.restoreAllMocks()
149+
})
150+
151+
it('rehydrates a cached session from stored messages so another window sees the latest transcript', async () => {
152+
mocks.getSessionMessages.mockResolvedValue({
153+
messages: [{ content: 'fresh from window A', role: 'assistant', timestamp: 1 }],
154+
session_id: 'stored-session-1'
155+
})
156+
157+
const requestGateway = requestGatewayMock({ calls: 1, input: 2, output: 3, total: 5 })
158+
const syncs: ClientSessionState[] = []
159+
let handle: HarnessHandle | null = null
160+
161+
render(
162+
<Harness
163+
onReady={next => {
164+
handle = next
165+
}}
166+
onSync={(_sessionId, state) => {
167+
syncs.push(state)
168+
}}
169+
requestGateway={requestGateway}
170+
/>
171+
)
172+
173+
await act(async () => {
174+
await handle!.resumeSession('stored-session-1')
175+
})
176+
177+
expect(mocks.ensureGatewayProfile).toHaveBeenCalledWith('builder')
178+
expect(mocks.getSessionMessages).toHaveBeenCalledWith('stored-session-1', 'builder')
179+
expect(syncs).toHaveLength(2)
180+
expect(chatMessageText(syncs.at(-1)!.messages[0]!)).toBe('fresh from window A')
181+
expect(requestGateway).toHaveBeenCalledWith('session.usage', { session_id: 'runtime-session-1' })
182+
})
183+
184+
it('keeps the warm cache visible when the stored rehydrate fails', async () => {
185+
mocks.getSessionMessages.mockRejectedValue(new Error('state.db busy'))
186+
187+
const requestGateway = requestGatewayMock({ calls: 0, input: 0, output: 0, total: 0 })
188+
const syncs: ClientSessionState[] = []
189+
let handle: HarnessHandle | null = null
190+
191+
render(
192+
<Harness
193+
onReady={next => {
194+
handle = next
195+
}}
196+
onSync={(_sessionId, state) => {
197+
syncs.push(state)
198+
}}
199+
requestGateway={requestGateway}
200+
/>
201+
)
202+
203+
await act(async () => {
204+
await handle!.resumeSession('stored-session-1')
205+
})
206+
207+
expect(mocks.getSessionMessages).toHaveBeenCalledWith('stored-session-1', 'builder')
208+
expect(syncs).toHaveLength(1)
209+
expect(chatMessageText(syncs[0]!.messages[0]!)).toBe('stale window copy')
210+
})
211+
})

apps/desktop/src/app/session/hooks/use-session-actions.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -460,6 +460,26 @@ export function useSessionActions({
460460
clearComposerDraft()
461461
clearComposerAttachments()
462462

463+
try {
464+
const latest = await getSessionMessages(storedSessionId, sessionProfile)
465+
466+
if (!isCurrentResume()) {
467+
return
468+
}
469+
470+
updateSessionState(
471+
cachedRuntimeId,
472+
state => ({
473+
...state,
474+
messages: preserveLocalAssistantErrors(toChatMessages(latest.messages), state.messages)
475+
}),
476+
storedSessionId
477+
)
478+
} catch {
479+
// Best-effort rehydrate only. Keep the warm runtime cache when the
480+
// stored snapshot is temporarily unavailable.
481+
}
482+
463483
try {
464484
const usage = await requestGateway<UsageStats>('session.usage', { session_id: cachedRuntimeId })
465485

apps/desktop/src/global.d.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ declare global {
4444
setPreviewShortcutActive?: (active: boolean) => void
4545
openExternal: (url: string) => Promise<void>
4646
fetchLinkTitle: (url: string) => Promise<string>
47+
openWindow?: (options?: { route?: string }) => Promise<{ ok: boolean }>
4748
settings: {
4849
getDefaultProjectDir: () => Promise<{ defaultLabel: string; dir: null | string }>
4950
pickDefaultProjectDir: () => Promise<{ canceled: boolean; dir: null | string }>

apps/desktop/src/i18n/en.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -603,6 +603,7 @@ export const en: Translations = {
603603
row: {
604604
pin: 'Pin',
605605
unpin: 'Unpin',
606+
openInNewWindow: 'Open in New Window',
606607
copyId: 'Copy ID',
607608
export: 'Export',
608609
rename: 'Rename',

0 commit comments

Comments
 (0)