diff --git a/apps/desktop/src/app/contrib/hooks/use-desktop-integrations.ts b/apps/desktop/src/app/contrib/hooks/use-desktop-integrations.ts index b6573431f4e..0c61eb1f795 100644 --- a/apps/desktop/src/app/contrib/hooks/use-desktop-integrations.ts +++ b/apps/desktop/src/app/contrib/hooks/use-desktop-integrations.ts @@ -71,15 +71,17 @@ export function useDesktopIntegrations({ // lands where you were. Overlays (settings/command-center/…) aren't stored — // you don't want to boot into a modal. useEffect(() => { + const routeProfile = rememberedSessionProfile($sessions.get(), routedSessionId, $activeGatewayProfile.get()) + if (routedSessionId) { - setRememberedSessionId( - routedSessionId, - rememberedSessionProfile($sessions.get(), routedSessionId, $activeGatewayProfile.get()) - ) + setRememberedSessionId(routedSessionId, routeProfile) } if (!isOverlayView(appViewForPath(locationPathname))) { - setRememberedRoute(locationPathname) + // Keyed by the same owner as the id above: a session route embeds a + // session id, so remembering it globally would restore another profile's + // conversation on cold start. + setRememberedRoute(locationPathname, routeProfile) } }, [locationPathname, routedSessionId]) @@ -97,7 +99,8 @@ export function useDesktopIntegrations({ } restoredRef.current = true - const route = getRememberedRoute() + const activeProfile = $activeGatewayProfile.get() + const route = getRememberedRoute(activeProfile) if (route && route !== NEW_CHAT_ROUTE && !isOverlayView(appViewForPath(route))) { navigate(route, { replace: true }) @@ -105,7 +108,7 @@ export function useDesktopIntegrations({ return } - const last = getRememberedSessionId($activeGatewayProfile.get()) + const last = getRememberedSessionId(activeProfile) if (last) { navigate(sessionRoute(last), { replace: true }) diff --git a/apps/desktop/src/store/session.test.ts b/apps/desktop/src/store/session.test.ts index f4dbac828ea..b44c17b2bd8 100644 --- a/apps/desktop/src/store/session.test.ts +++ b/apps/desktop/src/store/session.test.ts @@ -12,12 +12,14 @@ import { $sessions, $unreadFinishedSessionIds, applyConfiguredDefaultProjectDir, + getRememberedRoute, getRememberedSessionId, mergeSessionPage, rememberedSessionProfile, resolveComposerSessionKey, sessionPinId, setCurrentCwd, + setRememberedRoute, setRememberedSessionId, setSelectedStoredSessionId, setSessions, @@ -507,6 +509,59 @@ describe('remembered session id (per profile)', () => { }) }) +describe('remembered route (per profile)', () => { + beforeEach(() => { + localStorage.clear() + }) + + afterEach(() => { + localStorage.clear() + }) + + it('scopes the remembered route by profile so one profile cannot restore another', () => { + // A session route embeds a session id. Remembered globally, a cold start + // under 'default' would navigate straight into ai-engineer's conversation. + setRememberedRoute('/session/work-session', 'ai-engineer') + setRememberedRoute('/session/personal-session', 'default') + + expect(getRememberedRoute('ai-engineer')).toBe('/session/work-session') + expect(getRememberedRoute('default')).toBe('/session/personal-session') + expect(getRememberedRoute('research')).toBeNull() + }) + + it('keeps the default profile on the legacy unsuffixed key for back-compat', () => { + localStorage.setItem('hermes.desktop.lastRoute', '/skills') + + expect(getRememberedRoute('default')).toBe('/skills') + expect(getRememberedRoute(undefined)).toBe('/skills') + expect(getRememberedRoute('')).toBe('/skills') + expect(getRememberedRoute(null)).toBe('/skills') + }) + + it('clearing one profile leaves the others intact', () => { + setRememberedRoute('/session/work-session', 'ai-engineer') + setRememberedRoute('/session/personal-session', 'default') + + setRememberedRoute(null, 'ai-engineer') + + expect(getRememberedRoute('ai-engineer')).toBeNull() + expect(getRememberedRoute('default')).toBe('/session/personal-session') + }) + + it('route and session id agree on the owner, so restore cannot cross profiles', () => { + // The cold-start restore prefers the route over the id, so the two keys + // must be written under the same owner or the id scoping is bypassed. + const owner = rememberedSessionProfile([session({ id: 'stored-1', profile: 'ai-engineer' })], 'stored-1', 'default') + + setRememberedSessionId('stored-1', owner) + setRememberedRoute('/session/stored-1', owner) + + expect(getRememberedRoute('default')).toBeNull() + expect(getRememberedSessionId('default')).toBeNull() + expect(getRememberedRoute('ai-engineer')).toBe('/session/stored-1') + }) +}) + describe('rememberedSessionProfile', () => { it('keys by the session row owning profile, not the active one', () => { const sessions = [session({ id: 'stored-1', profile: 'ai-engineer' })] diff --git a/apps/desktop/src/store/session.ts b/apps/desktop/src/store/session.ts index 567b804e46b..62cf5ee6a9e 100644 --- a/apps/desktop/src/store/session.ts +++ b/apps/desktop/src/store/session.ts @@ -72,10 +72,26 @@ export function rememberedSessionProfile( // The last non-overlay route (a page like /skills, or a session route), so a // relaunch lands back where you were instead of a bare new-chat. +// +// Scoped per profile for the same reason the remembered session id is: a single +// global key remembered ONE route across every profile, and a session route +// carries a session id in its path. Restoring under profile B would navigate to +// a session owned by profile A — the remembered-id scoping above is bypassed +// entirely, because the route is preferred over the id on cold start +// (#67603 family). The default profile keeps the original unsuffixed key so +// existing installs' remembered route survives the upgrade. const LAST_ROUTE_KEY = 'hermes.desktop.lastRoute' -export const getRememberedRoute = (): null | string => storedString(LAST_ROUTE_KEY) -export const setRememberedRoute = (path: null | string) => persistString(LAST_ROUTE_KEY, path) +function rememberedRouteKey(profile?: null | string): string { + const key = (profile ?? '').trim() + + return !key || key === 'default' ? LAST_ROUTE_KEY : `${LAST_ROUTE_KEY}.${key}` +} + +export const getRememberedRoute = (profile?: null | string): null | string => + storedString(rememberedRouteKey(profile)) +export const setRememberedRoute = (path: null | string, profile?: null | string) => + persistString(rememberedRouteKey(profile), path) let configuredDefaultProjectDir = ''