diff --git a/apps/desktop/electron/main.cjs b/apps/desktop/electron/main.cjs index 2690ae450f7f2..581b032760dd5 100644 --- a/apps/desktop/electron/main.cjs +++ b/apps/desktop/electron/main.cjs @@ -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, @@ -5810,7 +5810,8 @@ function spawnSecondaryWindow({ sessionId, watch, newSession } = {}) { devServer: DEV_SERVER, rendererIndexPath: DEV_SERVER ? undefined : resolveRendererIndex(), watch, - newSession + newSession, + profile }) ) @@ -5818,8 +5819,8 @@ function spawnSecondaryWindow({ sessionId, watch, newSession } = {}) { } // 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: @@ -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 } }) diff --git a/apps/desktop/electron/session-windows.cjs b/apps/desktop/electron/session-windows.cjs index 5e2f3d4c680c7..ed072efaa9892 100644 --- a/apps/desktop/electron/session-windows.cjs +++ b/apps/desktop/electron/session-windows.cjs @@ -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) { diff --git a/apps/desktop/electron/session-windows.test.cjs b/apps/desktop/electron/session-windows.test.cjs index 78f19b859e411..53f1af3503cb1 100644 --- a/apps/desktop/electron/session-windows.test.cjs +++ b/apps/desktop/electron/session-windows.test.cjs @@ -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 }) diff --git a/apps/desktop/src/app/chat/index.tsx b/apps/desktop/src/app/chat/index.tsx index a5216210a7996..bdf65be094a49 100644 --- a/apps/desktop/src/app/chat/index.tsx +++ b/apps/desktop/src/app/chat/index.tsx @@ -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} 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 08c13550a4356..91d1efb9f423b 100644 --- a/apps/desktop/src/app/chat/sidebar/session-actions-menu.tsx +++ b/apps/desktop/src/app/chat/sidebar/session-actions-menu.tsx @@ -126,7 +126,7 @@ function useSessionActions({ label: r.newWindow, onSelect: () => { triggerHaptic('selection') - void openSessionInNewWindow(sessionId) + void openSessionInNewWindow(sessionId, { profile }) } } ] diff --git a/apps/desktop/src/app/chat/sidebar/session-row.tsx b/apps/desktop/src/app/chat/sidebar/session-row.tsx index d2543b9058aa7..f8b2940eff0cf 100644 --- a/apps/desktop/src/app/chat/sidebar/session-row.tsx +++ b/apps/desktop/src/app/chat/sidebar/session-row.tsx @@ -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 } diff --git a/apps/desktop/src/app/session/hooks/use-session-actions/index.ts b/apps/desktop/src/app/session/hooks/use-session-actions/index.ts index 0f88fc9481003..59e2c44ba91e7 100644 --- a/apps/desktop/src/app/session/hooks/use-session-actions/index.ts +++ b/apps/desktop/src/app/session/hooks/use-session-actions/index.ts @@ -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' @@ -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 diff --git a/apps/desktop/src/app/session/hooks/use-session-actions/utils.test.ts b/apps/desktop/src/app/session/hooks/use-session-actions/utils.test.ts index 680cc754286e0..821cd02c3510f 100644 --- a/apps/desktop/src/app/session/hooks/use-session-actions/utils.test.ts +++ b/apps/desktop/src/app/session/hooks/use-session-actions/utils.test.ts @@ -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 @@ -17,6 +20,14 @@ const msg = (id: string, role: ChatMessage['role'], text: string, extra: Partial const session = (over: Partial): 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) @@ -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([ diff --git a/apps/desktop/src/app/session/hooks/use-session-actions/utils.ts b/apps/desktop/src/app/session/hooks/use-session-actions/utils.ts index 254a58e1298a5..dc0fc72a406d1 100644 --- a/apps/desktop/src/app/session/hooks/use-session-actions/utils.ts +++ b/apps/desktop/src/app/session/hooks/use-session-actions/utils.ts @@ -205,13 +205,37 @@ function upsertResolvedSession(session: SessionInfo, storedSessionId: string) { ]) } -export async function resolveStoredSession(storedSessionId: string): Promise { +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 { 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. @@ -237,7 +261,7 @@ export async function resolveStoredSession(storedSessionId: string): Promise 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 diff --git a/apps/desktop/src/store/windows.test.ts b/apps/desktop/src/store/windows.test.ts index 28ae3cc39c9f0..8789c3809fae7 100644 --- a/apps/desktop/src/store/windows.test.ts +++ b/apps/desktop/src/store/windows.test.ts @@ -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 @@ -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' })) @@ -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') + }) +}) diff --git a/apps/desktop/src/store/windows.ts b/apps/desktop/src/store/windows.ts index c5b36ca68554c..ce500597b3940 100644 --- a/apps/desktop/src/store/windows.ts +++ b/apps/desktop/src/store/windows.ts @@ -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 @@ -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 { @@ -97,7 +118,10 @@ async function openWindow(call: () => Promise, 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 { +export async function openSessionInNewWindow( + sessionId: string, + opts?: { watch?: boolean; profile?: string | null } +): Promise { if (!sessionId || !canOpenSessionWindow()) { return }