From 143942d497f8345d34b2168d0345f8f4ece1ef9c Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Wed, 22 Jul 2026 23:53:59 -0500 Subject: [PATCH] fix(desktop): scope the remembered session id per profile MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A single global `hermes.desktop.lastSessionId` key remembered ONE session across every profile, so a relaunch or cold start under profile B would try to restore a session owned by profile A — reinforcing the impression that a conversation had bled between profiles (#67603, second symptom). Key the remembered id by the session's owning profile (resolved from the session row's `profile`, falling back to the active gateway profile), read it back for the active profile on restore, and clear an exhausted session under its owner. The default profile keeps the original unsuffixed key so existing installs' remembered session survives the upgrade. Co-authored-by: oliviaaaa7788 --- .../contrib/hooks/use-desktop-integrations.ts | 27 ++++++-- apps/desktop/src/store/session.test.ts | 67 +++++++++++++++++++ apps/desktop/src/store/session.ts | 43 +++++++++++- 3 files changed, 130 insertions(+), 7 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 dd1fe42633df..3c3bbbddd7fa 100644 --- a/apps/desktop/src/app/contrib/hooks/use-desktop-integrations.ts +++ b/apps/desktop/src/app/contrib/hooks/use-desktop-integrations.ts @@ -3,7 +3,15 @@ import { useEffect, useRef } from 'react' import { closeActiveTab } from '@/app/chat/close-tab' import { storedSessionIdForNotification } from '@/lib/session-ids' import { respondToApprovalAction } from '@/store/native-notifications' -import { getRememberedRoute, getRememberedSessionId, setRememberedRoute, setRememberedSessionId } from '@/store/session' +import { $activeGatewayProfile } from '@/store/profile' +import { + $sessions, + getRememberedRoute, + getRememberedSessionId, + rememberedSessionProfile, + setRememberedRoute, + setRememberedSessionId +} from '@/store/session' import { onSessionsChanged } from '@/store/session-sync' import { openUpdatesWindow, startUpdatePoller, stopUpdatePoller } from '@/store/updates' import { isSecondaryWindow } from '@/store/windows' @@ -63,7 +71,10 @@ export function useDesktopIntegrations({ // you don't want to boot into a modal. useEffect(() => { if (routedSessionId) { - setRememberedSessionId(routedSessionId) + setRememberedSessionId( + routedSessionId, + rememberedSessionProfile($sessions.get(), routedSessionId, $activeGatewayProfile.get()) + ) } if (!isOverlayView(appViewForPath(locationPathname))) { @@ -92,7 +103,7 @@ export function useDesktopIntegrations({ return } - const last = getRememberedSessionId() + const last = getRememberedSessionId($activeGatewayProfile.get()) if (last) { navigate(sessionRoute(last), { replace: true }) @@ -100,8 +111,14 @@ export function useDesktopIntegrations({ }, [locationPathname, navigate]) useEffect(() => { - if (resumeExhaustedSessionId && getRememberedSessionId() === resumeExhaustedSessionId) { - setRememberedSessionId(null) + if (!resumeExhaustedSessionId) { + return + } + + const owner = rememberedSessionProfile($sessions.get(), resumeExhaustedSessionId, $activeGatewayProfile.get()) + + if (getRememberedSessionId(owner) === resumeExhaustedSessionId) { + setRememberedSessionId(null, owner) } }, [resumeExhaustedSessionId]) diff --git a/apps/desktop/src/store/session.test.ts b/apps/desktop/src/store/session.test.ts index 6de3e67ed092..1bb0d304300f 100644 --- a/apps/desktop/src/store/session.test.ts +++ b/apps/desktop/src/store/session.test.ts @@ -11,10 +11,13 @@ import { $selectedStoredSessionId, $unreadFinishedSessionIds, applyConfiguredDefaultProjectDir, + getRememberedSessionId, mergeSessionPage, + rememberedSessionProfile, resolveComposerSessionKey, sessionPinId, setCurrentCwd, + setRememberedSessionId, setSelectedStoredSessionId, workspaceCwdForNewSession } from './session' @@ -399,3 +402,67 @@ describe('unread finished sessions', () => { expect($unreadFinishedSessionIds.get()).toEqual([]) }) }) + +describe('remembered session id (per profile)', () => { + beforeEach(() => { + localStorage.clear() + }) + + afterEach(() => { + localStorage.clear() + }) + + it('scopes the remembered session by profile so one profile cannot read another', () => { + setRememberedSessionId('work-session', 'ai-engineer') + setRememberedSessionId('personal-session', 'default') + + expect(getRememberedSessionId('ai-engineer')).toBe('work-session') + expect(getRememberedSessionId('default')).toBe('personal-session') + // A profile with nothing remembered does not inherit another's session. + expect(getRememberedSessionId('research')).toBeNull() + }) + + it('keeps the default profile on the legacy unsuffixed key for back-compat', () => { + // An existing install remembered its session under the pre-per-profile key. + localStorage.setItem('hermes.desktop.lastSessionId', 'legacy-session') + + expect(getRememberedSessionId('default')).toBe('legacy-session') + // Absent/blank profile normalizes to the default key too. + expect(getRememberedSessionId(undefined)).toBe('legacy-session') + expect(getRememberedSessionId('')).toBe('legacy-session') + expect(getRememberedSessionId(null)).toBe('legacy-session') + }) + + it('clearing one profile leaves the others intact', () => { + setRememberedSessionId('work-session', 'ai-engineer') + setRememberedSessionId('personal-session', 'default') + + setRememberedSessionId(null, 'ai-engineer') + + expect(getRememberedSessionId('ai-engineer')).toBeNull() + expect(getRememberedSessionId('default')).toBe('personal-session') + }) +}) + +describe('rememberedSessionProfile', () => { + it('keys by the session row owning profile, not the active one', () => { + const sessions = [session({ id: 'stored-1', profile: 'ai-engineer' })] + + expect(rememberedSessionProfile(sessions, 'stored-1', 'default')).toBe('ai-engineer') + }) + + it('matches on the lineage root so a compressed tip resolves its owner', () => { + const sessions = [session({ _lineage_root_id: 'root-1', id: 'tip-2', profile: 'work' })] + + expect(rememberedSessionProfile(sessions, 'root-1', 'default')).toBe('work') + }) + + it('falls back to the active profile for a session not yet in the list', () => { + expect(rememberedSessionProfile([], 'uncached', 'research')).toBe('research') + }) + + it('normalizes a blank active profile to default', () => { + expect(rememberedSessionProfile([], null, '')).toBe('default') + expect(rememberedSessionProfile([], null, null)).toBe('default') + }) +}) diff --git a/apps/desktop/src/store/session.ts b/apps/desktop/src/store/session.ts index 0f0a64ae6b47..0044a7b34a6f 100644 --- a/apps/desktop/src/store/session.ts +++ b/apps/desktop/src/store/session.ts @@ -26,10 +26,49 @@ const COMPOSER_FAST_KEY = 'hermes.desktop.composer.fast' // The last chat the user had open, so a relaunch lands back on it instead of an // empty new-chat. Stored (not runtime) id — the route is keyed by stored id. +// +// Scoped per profile: a single global key remembered ONE session across every +// profile, so relaunching (or a cold start) under profile B would try to +// restore a session that belongs to profile A — one of the ways a conversation +// appears to bleed between profiles (#63590). Each profile now remembers its +// own last session. The default profile keeps the original unsuffixed key so +// existing installs' remembered session survives the upgrade. const LAST_SESSION_KEY = 'hermes.desktop.lastSessionId' -export const getRememberedSessionId = (): null | string => storedString(LAST_SESSION_KEY) -export const setRememberedSessionId = (id: null | string) => persistString(LAST_SESSION_KEY, id) +function rememberedSessionKey(profile?: null | string): string { + const key = (profile ?? '').trim() + + return !key || key === 'default' ? LAST_SESSION_KEY : `${LAST_SESSION_KEY}.${key}` +} + +export const getRememberedSessionId = (profile?: null | string): null | string => + storedString(rememberedSessionKey(profile)) +export const setRememberedSessionId = (id: null | string, profile?: null | string) => + persistString(rememberedSessionKey(profile), id) + +/** + * The profile a routed session belongs to, for keying the remembered id. + * + * Prefer the owning profile recorded on the session row (the cross-profile + * aggregator tags each row), so the session is remembered under ITS profile + * even while a different one is live. Falls back to the active gateway profile + * for a session not yet in the in-memory list. + */ +export function rememberedSessionProfile( + sessions: readonly SessionInfo[], + sessionId: null | string, + activeProfile: null | string +): string { + if (sessionId) { + const owner = sessions.find(session => sessionMatchesStoredId(session, sessionId))?.profile?.trim() + + if (owner) { + return owner + } + } + + return (activeProfile ?? '').trim() || 'default' +} // 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.