From c4212b94530025300166797cf0e406f857cc4645 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Wed, 29 Jul 2026 13:02:12 -0500 Subject: [PATCH] fix(desktop): scope the remembered route per profile `hermes.desktop.lastSessionId` is keyed by owning profile (143942d49), but `hermes.desktop.lastRoute` stayed global -- and cold-start restore prefers the route over the id. A session route embeds a session id in its path, so relaunching under profile B navigated straight into a session owned by profile A, bypassing the id scoping entirely (#67603 family). Key the remembered route by the same owner the id already uses (rememberedSessionProfile), read it back for the active profile on restore, and keep the default profile on the original unsuffixed key so existing installs' remembered route survives the upgrade. Salvaged from the profile-hint work in #49619, which threaded an explicit profile through every route/IPC/window surface to reach the same end. The server now stamps session ownership unconditionally (#74033), so the resolver already gets the right answer and only this persisted key was left crossing profiles. Co-authored-by: d31tcjg --- .../contrib/hooks/use-desktop-integrations.ts | 17 +++--- apps/desktop/src/store/session.test.ts | 55 +++++++++++++++++++ apps/desktop/src/store/session.ts | 20 ++++++- 3 files changed, 83 insertions(+), 9 deletions(-) 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 = ''