Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 9 additions & 5 deletions apps/desktop/electron/main.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -5765,7 +5765,7 @@ function focusWindow(win) {
win.focus()
}

function spawnSecondaryWindow({ sessionId, watch, newSession } = {}) {
function spawnSecondaryWindow({ sessionId, watch, newSession, profile } = {}) {
const icon = getAppIconPath()
const win = new BrowserWindow({
width: SESSION_WINDOW_MIN_WIDTH,
Expand Down Expand Up @@ -5810,16 +5810,17 @@ function spawnSecondaryWindow({ sessionId, watch, newSession } = {}) {
devServer: DEV_SERVER,
rendererIndexPath: DEV_SERVER ? undefined : resolveRendererIndex(),
watch,
newSession
newSession,
profile
})
)

return win
}

// Open (or focus) a standalone window for a single chat session.
function createSessionWindow(sessionId, { watch = false } = {}) {
return sessionWindows.openOrFocus(sessionId, () => spawnSecondaryWindow({ sessionId, watch }))
function createSessionWindow(sessionId, { watch = false, profile = null } = {}) {
return sessionWindows.openOrFocus(sessionId, () => spawnSecondaryWindow({ sessionId, watch, profile }))
}

// Open a fresh compact window on the new-session draft (#/). Not registry-keyed:
Expand Down Expand Up @@ -6148,7 +6149,10 @@ ipcMain.handle('hermes:window:openSession', async (_event, sessionId, opts) => {
return { ok: false, error: 'invalid-session-id' }
}

createSessionWindow(sessionId.trim(), { watch: opts?.watch === true })
createSessionWindow(sessionId.trim(), {
watch: opts?.watch === true,
profile: typeof opts?.profile === 'string' ? opts.profile.trim() : null
})

return { ok: true }
})
Expand Down
23 changes: 20 additions & 3 deletions apps/desktop/electron/session-windows.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -41,9 +41,26 @@ function chatWindowWebPreferences(preloadPath) {
// onboarding overlays and the global session sidebar. `new=1` marks the compact
// scratch window; `watch=1` marks a spectator window (e.g. a running subagent's
// session): the renderer resumes it lazily so the gateway never builds an agent
// just to stream into it.
function buildSessionWindowUrl(sessionId, { devServer, rendererIndexPath, watch, newSession } = {}) {
const query = `?win=secondary${newSession ? '&new=1' : ''}${watch ? '&watch=1' : ''}`
// just to stream into it. `profile` is a routing hint for multi-profile installs:
// the new renderer process starts with empty in-memory stores, so it must not
// race its first route resume against the wrong default-profile backend.
function buildSessionWindowUrl(sessionId, { devServer, rendererIndexPath, watch, newSession, profile } = {}) {
const params = new URLSearchParams({ win: 'secondary' })
const profileHint = typeof profile === 'string' ? profile.trim() : ''

if (newSession) {
params.set('new', '1')
}

if (watch) {
params.set('watch', '1')
}

if (profileHint) {
params.set('profile', profileHint)
}

const query = `?${params.toString()}`
const route = newSession ? '#/' : `#/${encodeURIComponent(sessionId)}`

if (devServer) {
Expand Down
7 changes: 7 additions & 0 deletions apps/desktop/electron/session-windows.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,13 @@ test('buildSessionWindowUrl adds the watch flag for spectator windows, before th
assert.equal(url, 'http://localhost:5173/?win=secondary&watch=1#/abc')
})

test('buildSessionWindowUrl carries a profile hint before the hash route', () => {
const url = buildSessionWindowUrl('abc', { devServer: 'http://localhost:5173', profile: 'mission control' })

assert.equal(url, 'http://localhost:5173/?win=secondary&profile=mission+control#/abc')
assert.ok(url.indexOf('profile=mission+control') < url.indexOf('#'))
})

test('buildSessionWindowUrl routes new-session windows to the draft (#/)', () => {
const url = buildSessionWindowUrl(null, { devServer: 'http://localhost:5173', newSession: true })

Expand Down
1 change: 1 addition & 0 deletions apps/desktop/src/app/chat/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,7 @@ function ChatHeader({
onDelete={selectedSessionId ? onDeleteSelectedSession : undefined}
onPin={selectedSessionId ? onToggleSelectedPin : undefined}
pinned={selectedIsPinned}
profile={activeStoredSession?.profile}
sessionId={selectedSessionId || activeSessionId || ''}
sideOffset={8}
title={title}
Expand Down
2 changes: 1 addition & 1 deletion apps/desktop/src/app/chat/sidebar/session-actions-menu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,7 @@ function useSessionActions({
label: r.newWindow,
onSelect: () => {
triggerHaptic('selection')
void openSessionInNewWindow(sessionId)
void openSessionInNewWindow(sessionId, { profile })
}
}
]
Expand Down
2 changes: 1 addition & 1 deletion apps/desktop/src/app/chat/sidebar/session-row.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -171,7 +171,7 @@ export function SidebarSessionRow({
event.preventDefault()
event.stopPropagation()
triggerHaptic('selection')
void openSessionInNewWindow(session.id)
void openSessionInNewWindow(session.id, { profile: session.profile })

return
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ import {
workspaceCwdForNewSession
} from '@/store/session'
import { broadcastSessionsChanged } from '@/store/session-sync'
import { isWatchWindow } from '@/store/windows'
import { isWatchWindow, sessionWindowProfile } from '@/store/windows'
import type { SessionCreateResponse, SessionResumeResponse, UsageStats } from '@/types/hermes'

import { NEW_CHAT_ROUTE, sessionRoute, SETTINGS_ROUTE } from '../../../routes'
Expand Down Expand Up @@ -343,8 +343,9 @@ export function useSessionActions({
// gateway call (no-op when it's already on that profile / single-profile).
// resolveStoredSession finds the row by id (cheap), so an uncached pasted
// id loads as fast as a sidebar click instead of hanging on a list scan.
const storedForProfile = await resolveStoredSession(storedSessionId)
const sessionProfile = storedForProfile?.profile
const profileHint = sessionWindowProfile()
const storedForProfile = await resolveStoredSession(storedSessionId, profileHint)
const sessionProfile = storedForProfile?.profile ?? profileHint

if (resumeRequestRef.current !== requestId) {
return
Expand Down
Original file line number Diff line number Diff line change
@@ -1,12 +1,15 @@
import { describe, expect, it } from 'vitest'
import { afterEach, describe, expect, it, vi } from 'vitest'

import type { ChatMessage } from '@/lib/chat-messages'
import { $activeGatewayProfile, $profiles } from '@/store/profile'
import { $sessions } from '@/store/session'
import type { SessionInfo } from '@/types/hermes'

import {
chatMessageArraysEquivalent,
isSessionGoneError,
reconcileResumeMessages,
resolveStoredSession,
sessionMatchesStoredId,
sessionShouldHaveTranscript,
toBranchMessages
Expand All @@ -17,6 +20,14 @@ const msg = (id: string, role: ChatMessage['role'], text: string, extra: Partial

const session = (over: Partial<SessionInfo>): SessionInfo => over as SessionInfo

afterEach(() => {
$activeGatewayProfile.set('default')
$profiles.set([])
$sessions.set([])
vi.restoreAllMocks()
Reflect.deleteProperty(window, 'hermesDesktop')
})

describe('isSessionGoneError', () => {
it('is true for 404 / session-not-found, false otherwise', () => {
expect(isSessionGoneError(new Error('Request failed 404'))).toBe(true)
Expand All @@ -42,6 +53,25 @@ describe('sessionShouldHaveTranscript', () => {
})
})

describe('resolveStoredSession', () => {
it('uses a session-window profile hint before probing the active/default backend', async () => {
const api = vi.fn(async (request: { path: string; profile?: string | null }) => {
expect(request.profile).toBe('mission-control')
expect(request.path).toBe('/api/sessions/s1?profile=mission-control')

return session({ id: 's1', message_count: 2, title: 'Mission' })
})

;(window as unknown as { hermesDesktop?: unknown }).hermesDesktop = { api }

const resolved = await resolveStoredSession('s1', 'mission-control')

expect(api).toHaveBeenCalledTimes(1)
expect(resolved).toMatchObject({ id: 's1', profile: 'mission-control' })
expect($sessions.get()[0]).toMatchObject({ id: 's1', profile: 'mission-control' })
})
})

describe('toBranchMessages', () => {
it('keeps only user/assistant turns that carry text', () => {
const out = toBranchMessages([
Expand Down
28 changes: 26 additions & 2 deletions apps/desktop/src/app/session/hooks/use-session-actions/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -205,13 +205,37 @@ function upsertResolvedSession(session: SessionInfo, storedSessionId: string) {
])
}

export async function resolveStoredSession(storedSessionId: string): Promise<SessionInfo | undefined> {
function withResolvedProfile(session: SessionInfo, profile: string | null | undefined): SessionInfo {
const key = normalizeProfileKey(profile)

return session.profile ? session : { ...session, profile: key }
}

export async function resolveStoredSession(
storedSessionId: string,
profileHint?: string | null
): Promise<SessionInfo | undefined> {
const cached = $sessions.get().find(session => sessionMatchesStoredId(session, storedSessionId))

if (cached) {
return cached
}

const hintedProfile = profileHint?.trim()

if (hintedProfile) {
try {
const session = withResolvedProfile(await getSession(storedSessionId, hintedProfile), hintedProfile)

upsertResolvedSession(session, storedSessionId)

return session
} catch {
// Stale or invalid hint — fall through to the legacy active/cross-profile
// lookup so old windows and copied URLs remain recoverable.
}
}

// Direct by-id on the live backend — one row lookup, no list scan. Covers
// single-profile users and any id on the active profile (e.g. an old session
// past the sidebar's recent window). 404 just means it's not on this profile.
Expand All @@ -237,7 +261,7 @@ export async function resolveStoredSession(storedSessionId: string): Promise<Ses

for (const profile of otherProfiles) {
try {
const session = await getSession(storedSessionId, profile)
const session = withResolvedProfile(await getSession(storedSessionId, profile), profile)

upsertResolvedSession(session, storedSessionId)

Expand Down
5 changes: 4 additions & 1 deletion apps/desktop/src/global.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,10 @@ declare global {
// with an error code when the sessionId is empty/invalid. `watch` opens
// a spectator window (lazy resume — no agent build) for live-streaming
// a running subagent's session.
openSessionWindow: (sessionId: string, opts?: { watch?: boolean }) => Promise<{ ok: boolean; error?: string }>
openSessionWindow: (
sessionId: string,
opts?: { watch?: boolean; profile?: string | null }
) => Promise<{ ok: boolean; error?: string }>
// Open (or focus) a compact secondary window on the new-session draft.
openNewSessionWindow: () => Promise<{ ok: boolean; error?: string }>
// The pop-out pet overlay: a transparent always-on-top window hosting only
Expand Down
20 changes: 19 additions & 1 deletion apps/desktop/src/store/windows.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'

import { canOpenSessionWindow, openNewSessionInNewWindow, openSessionInNewWindow } from './windows'
import { canOpenSessionWindow, openNewSessionInNewWindow, openSessionInNewWindow, sessionWindowProfile } from './windows'

const desktopWindow = window as unknown as { hermesDesktop?: Window['hermesDesktop'] }
const initialHermesDesktop = desktopWindow.hermesDesktop
Expand Down Expand Up @@ -89,6 +89,16 @@ describe('openSessionInNewWindow', () => {
expect(notifyError).not.toHaveBeenCalled()
})

it('forwards the profile hint for multi-profile session windows', async () => {
const open = vi.fn().mockResolvedValue({ ok: true })
installBridge(open)

await openSessionInNewWindow('s1', { profile: 'mission-control' })

expect(open).toHaveBeenCalledWith('s1', { profile: 'mission-control' })
expect(notifyError).not.toHaveBeenCalled()
})

it('notifies on an ok:false result', async () => {
installBridge(vi.fn().mockResolvedValue({ ok: false, error: 'invalid-session-id' }))

Expand Down Expand Up @@ -141,3 +151,11 @@ describe('openNewSessionInNewWindow', () => {
expect(notifyError).toHaveBeenCalledTimes(1)
})
})

describe('sessionWindowProfile', () => {
it('reads the profile hint from the pre-hash query string', () => {
window.history.replaceState(null, '', '/?win=secondary&profile=mission-control#/s1')

expect(sessionWindowProfile()).toBe('mission-control')
})
})
26 changes: 25 additions & 1 deletion apps/desktop/src/store/windows.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { notifyError } from './notifications'
// global session sidebar or the install / onboarding overlays.
const SECONDARY_WINDOW_FLAG = 'secondary'
const NEW_SESSION_WINDOW_FLAG = '1'
const PROFILE_WINDOW_PARAM = 'profile'

let secondaryWindowCache: boolean | null = null

Expand Down Expand Up @@ -72,6 +73,26 @@ export function isWatchWindow(): boolean {
return result
}

let sessionWindowProfileCache: string | null | undefined

export function sessionWindowProfile(): string | null {
if (sessionWindowProfileCache !== undefined) {
return sessionWindowProfileCache
}

let result: string | null = null

try {
result = new URLSearchParams(window.location.search).get(PROFILE_WINDOW_PARAM)?.trim() || null
} catch {
result = null
}

sessionWindowProfileCache = 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 {
Expand All @@ -97,7 +118,10 @@ async function openWindow(call: () => Promise<WindowOpenResult>, failMessage: st
// Open (or focus) a standalone OS window for a single chat session. No-ops
// gracefully outside Electron so callers can wire it unconditionally.
// `watch: true` opens a spectator window (lazy resume, live-mirror stream).
export async function openSessionInNewWindow(sessionId: string, opts?: { watch?: boolean }): Promise<void> {
export async function openSessionInNewWindow(
sessionId: string,
opts?: { watch?: boolean; profile?: string | null }
): Promise<void> {
if (!sessionId || !canOpenSessionWindow()) {
return
}
Expand Down