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.