fix(desktop): scope the remembered session id per profile

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 <oliviaaaa7788@users.noreply.github.com>
This commit is contained in:
Brooklyn Nicholson 2026-07-22 23:53:59 -05:00
parent 5fccc9aae9
commit 143942d497
3 changed files with 130 additions and 7 deletions

View file

@ -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])

View file

@ -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')
})
})

View file

@ -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.