From f519cc913dd63c51ec55ac09e4b2086554556f23 Mon Sep 17 00:00:00 2001 From: oliviaaaa7788 Date: Mon, 13 Jul 2026 15:53:30 +1000 Subject: [PATCH] fix(desktop): preserve profile when restoring sessions Remember Desktop's last open session per gateway profile, create branches in the parent session's profile, and pass the stored session profile when recovering after a session-not-found response. Tests: npm run test:ui --workspace apps/desktop -- src/store/session.test.ts src/app/session/hooks/use-session-actions.test.tsx src/app/session/hooks/use-prompt-actions/index.test.tsx; npm run typecheck --workspace apps/desktop --- apps/desktop/src/app/desktop-controller.tsx | 18 +-- .../hooks/use-prompt-actions/index.test.tsx | 17 ++- .../session/hooks/use-prompt-actions/index.ts | 20 +++- .../hooks/use-prompt-actions/submit.ts | 25 ++++- .../hooks/use-session-actions.test.tsx | 106 ++++++++++++------ .../hooks/use-session-actions/index.ts | 22 +++- apps/desktop/src/store/session.test.ts | 38 +++++++ apps/desktop/src/store/session.ts | 35 +++++- 8 files changed, 222 insertions(+), 59 deletions(-) diff --git a/apps/desktop/src/app/desktop-controller.tsx b/apps/desktop/src/app/desktop-controller.tsx index 033cae9b5fa8..66888531ffac 100644 --- a/apps/desktop/src/app/desktop-controller.tsx +++ b/apps/desktop/src/app/desktop-controller.tsx @@ -62,6 +62,7 @@ import { $selectedStoredSessionId, $sessions, getRememberedSessionId, + rememberedSessionProfile, sessionPinId, setAwaitingResponse, setBusy, @@ -201,6 +202,8 @@ export function DesktopController() { const previewPaneOpen = useStore($paneOpen(PREVIEW_PANE_ID)) const panesFlipped = useStore($panesFlipped) const profileScope = useStore($profileScope) + const activeGatewayProfile = useStore($activeGatewayProfile) + const sessions = useStore($sessions) // Below SIDEBAR_COLLAPSE_BREAKPOINT_PX there's no room for a docked rail — // collapse both sidebars (without touching their stored open state) so the // hover-reveal overlay becomes the way in. Restores once it's wide again. @@ -272,9 +275,9 @@ export function DesktopController() { // Remember the open chat so a relaunch reopens it instead of an empty new-chat. useEffect(() => { if (routedSessionId) { - setRememberedSessionId(routedSessionId) + setRememberedSessionId(routedSessionId, rememberedSessionProfile(sessions, routedSessionId, activeGatewayProfile)) } - }, [routedSessionId]) + }, [activeGatewayProfile, routedSessionId, sessions]) // Restore that chat once, on cold start only (we're at the new-chat route and // haven't navigated yet). A dead/deleted id self-clears via the exhausted latch @@ -286,18 +289,18 @@ export function DesktopController() { } restoredLastSessionRef.current = true - const last = getRememberedSessionId() + const last = getRememberedSessionId(activeGatewayProfile) if (last && location.pathname === NEW_CHAT_ROUTE) { navigate(sessionRoute(last), { replace: true }) } - }, [location.pathname, navigate]) + }, [activeGatewayProfile, location.pathname, navigate]) useEffect(() => { - if (resumeExhaustedSessionId && getRememberedSessionId() === resumeExhaustedSessionId) { - setRememberedSessionId(null) + if (resumeExhaustedSessionId && getRememberedSessionId(activeGatewayProfile) === resumeExhaustedSessionId) { + setRememberedSessionId(null, activeGatewayProfile) } - }, [resumeExhaustedSessionId]) + }, [activeGatewayProfile, resumeExhaustedSessionId]) // Notification click: the main process already focused the window; jump to its // session. Notifications are tagged with the gateway *runtime* session id, but @@ -655,7 +658,6 @@ export function DesktopController() { // without this the statusbar keeps showing the previous profile's model // (the "forgets the LLM setting" report). gatewayState stays 'open' across a // swap (background sockets persist), so the open→open effect won't re-run. - const activeGatewayProfile = useStore($activeGatewayProfile) const lastGatewayProfileRef = useRef(activeGatewayProfile) useEffect(() => { diff --git a/apps/desktop/src/app/session/hooks/use-prompt-actions/index.test.tsx b/apps/desktop/src/app/session/hooks/use-prompt-actions/index.test.tsx index 5f028876a8e3..308c597565fd 100644 --- a/apps/desktop/src/app/session/hooks/use-prompt-actions/index.test.tsx +++ b/apps/desktop/src/app/session/hooks/use-prompt-actions/index.test.tsx @@ -1082,6 +1082,8 @@ describe('usePromptActions sleep/wake session recovery', () => { return {} as never }) + setSessions(() => [sessionInfo({ id: STORED_SESSION_ID, profile: 'default' })]) + let handle: HarnessHandle | null = null render( { const ok = await handle!.submitText('message after wake') expect(ok).toBe(true) - // First submit (stale id) → session.resume (stored id) → retry submit (fresh id). + // First submit (stale id) → session.resume (stored id + owning profile) → retry submit (fresh id). expect(calls.map(c => c.method)).toEqual(['prompt.submit', 'session.resume', 'prompt.submit']) - expect(calls[1]?.params).toEqual({ session_id: STORED_SESSION_ID, source: 'desktop' }) + expect(calls[1]?.params).toEqual({ session_id: STORED_SESSION_ID, source: 'desktop', profile: 'default' }) expect(calls[2]?.params).toEqual({ session_id: RECOVERED_SESSION_ID, text: 'message after wake' }) }) @@ -1125,6 +1127,8 @@ describe('usePromptActions sleep/wake session recovery', () => { return {} as never }) + setSessions(() => [sessionInfo({ id: STORED_SESSION_ID, profile: 'default' })]) + let handle: HarnessHandle | null = null render( { expect(calls.map(c => c.method)).toEqual(['session.interrupt', 'session.resume', 'session.interrupt']) expect(calls[0]?.params).toEqual({ session_id: RUNTIME_SESSION_ID }) - expect(calls[1]?.params).toEqual({ session_id: STORED_SESSION_ID, source: 'desktop' }) + expect(calls[1]?.params).toEqual({ session_id: STORED_SESSION_ID, source: 'desktop', profile: 'default' }) expect(calls[2]?.params).toEqual({ session_id: RECOVERED_SESSION_ID }) }) @@ -1246,7 +1250,7 @@ describe('usePromptActions sleep/wake session recovery', () => { expect(ok).toBe(true) expect(calls.map(c => c.method)).toEqual(['prompt.submit', 'session.resume', 'prompt.submit']) - expect(calls[1]?.params).toEqual({ session_id: STORED_SESSION_ID, source: 'desktop' }) + expect(calls[1]?.params).toEqual({ session_id: STORED_SESSION_ID, source: 'desktop', profile: 'default' }) expect(calls[2]?.params).toEqual({ session_id: RECOVERED_SESSION_ID, text: 'message during starved loop' @@ -1289,7 +1293,7 @@ describe('usePromptActions sleep/wake session recovery', () => { expect(ok).toBe(true) expect(createBackendSessionForSend).not.toHaveBeenCalled() expect(calls.map(c => c.method)).toEqual(['session.resume', 'prompt.submit']) - expect(calls[0]?.params).toEqual({ session_id: STORED_SESSION_ID }) + expect(calls[0]?.params).toEqual({ session_id: STORED_SESSION_ID, source: 'desktop', profile: 'default' }) expect(calls[1]?.params).toMatchObject({ session_id: RECOVERED_SESSION_ID }) }) @@ -1383,7 +1387,8 @@ describe('usePromptActions submit session-context isolation (#54527)', () => { expect(await submitting).toBe(false) expect(calls.some(c => c.method === 'prompt.submit')).toBe(false) expect(calls.find(c => c.method === 'session.resume')?.params).toEqual({ - session_id: STORED_SESSION_A + session_id: STORED_SESSION_A, + source: 'desktop' }) }) diff --git a/apps/desktop/src/app/session/hooks/use-prompt-actions/index.ts b/apps/desktop/src/app/session/hooks/use-prompt-actions/index.ts index 51a3689ae4e1..63e167573077 100644 --- a/apps/desktop/src/app/session/hooks/use-prompt-actions/index.ts +++ b/apps/desktop/src/app/session/hooks/use-prompt-actions/index.ts @@ -21,7 +21,7 @@ import { resetSessionBackground } from '@/store/composer-status' import { clearNotifications, notify, notifyError } from '@/store/notifications' import { clearPreviewArtifacts } from '@/store/preview-status' import { clearAllPrompts } from '@/store/prompts' -import { $busy, $connection, $messages, setAwaitingResponse, setBusy, setMessages } from '@/store/session' +import { $busy, $connection, $messages, $sessions, setAwaitingResponse, setBusy, setMessages } from '@/store/session' import { clearSessionSubagents } from '@/store/subagents' import { clearSessionTodos } from '@/store/todos' @@ -59,6 +59,17 @@ interface HandoffResult { error?: string } +function storedSessionProfile(storedSessionId: string | null): string | null { + if (!storedSessionId) { + return null + } + + const stored = $sessions.get().find(session => session.id === storedSessionId || session._lineage_root_id === storedSessionId) + const profile = stored?.profile?.trim() + + return profile || null +} + /** * Stage one file/image attachment into the session workspace and return the * attachment rewritten with the gateway-side ref. Images upload their bytes in @@ -548,9 +559,12 @@ export function usePromptActions({ if (isSessionNotFoundError(err) && selectedStoredSessionIdRef.current) { try { + const storedSessionId = selectedStoredSessionIdRef.current + const profile = storedSessionProfile(storedSessionId) const resumed = await requestGateway<{ session_id: string }>('session.resume', { - session_id: selectedStoredSessionIdRef.current, - source: 'desktop' + session_id: storedSessionId, + source: 'desktop', + ...(profile ? { profile } : {}) }) const recoveredId = resumed?.session_id diff --git a/apps/desktop/src/app/session/hooks/use-prompt-actions/submit.ts b/apps/desktop/src/app/session/hooks/use-prompt-actions/submit.ts index 5dd01fc6d6d8..f9e64a7f91e2 100644 --- a/apps/desktop/src/app/session/hooks/use-prompt-actions/submit.ts +++ b/apps/desktop/src/app/session/hooks/use-prompt-actions/submit.ts @@ -13,7 +13,7 @@ import { } from '@/store/composer' import { clearNotifications, notify, notifyError } from '@/store/notifications' import { requestDesktopOnboarding } from '@/store/onboarding' -import { setAwaitingResponse, setBusy, setMessages } from '@/store/session' +import { $sessions, setAwaitingResponse, setBusy, setMessages } from '@/store/session' import type { ClientSessionState } from '../../../types' @@ -50,6 +50,17 @@ interface SubmitPromptDeps { ) => ClientSessionState } +function storedSessionProfile(storedSessionId: string | null): string | null { + if (!storedSessionId) { + return null + } + + const stored = $sessions.get().find(session => session.id === storedSessionId || session._lineage_root_id === storedSessionId) + const profile = stored?.profile?.trim() + + return profile || null +} + /** The prompt submit pipeline, extracted from usePromptActions. */ export function useSubmitPrompt(deps: SubmitPromptDeps) { const { @@ -243,8 +254,11 @@ export function useSubmitPrompt(deps: SubmitPromptDeps) { // to session creation when NO stored session is selected (a genuine // new-chat draft). try { + const profile = storedSessionProfile(startingStoredSessionId) const resumed = await requestGateway<{ session_id: string }>('session.resume', { - session_id: startingStoredSessionId + session_id: startingStoredSessionId, + source: 'desktop', + ...(profile ? { profile } : {}) }) if (sessionContextDrifted()) { @@ -331,9 +345,12 @@ export function useSubmitPrompt(deps: SubmitPromptDeps) { // backend loop (#55578 symptom d) rejects the submit even though // the stored session is fine — resume + retry instead of erroring // out and losing the session binding. + const storedSessionId = startingStoredSessionId + const profile = storedSessionProfile(storedSessionId) const resumed = await requestGateway<{ session_id: string }>('session.resume', { - session_id: startingStoredSessionId, - source: 'desktop' + session_id: storedSessionId, + source: 'desktop', + ...(profile ? { profile } : {}) }) if (sessionContextDrifted()) { diff --git a/apps/desktop/src/app/session/hooks/use-session-actions.test.tsx b/apps/desktop/src/app/session/hooks/use-session-actions.test.tsx index 3b681966cb84..6394efc354da 100644 --- a/apps/desktop/src/app/session/hooks/use-session-actions.test.tsx +++ b/apps/desktop/src/app/session/hooks/use-session-actions.test.tsx @@ -89,6 +89,39 @@ function Harness({ return null } +function BranchHarness({ + onReady, + requestGateway +}: { + onReady: (branchStoredSession: (storedSessionId: string, sessionProfile?: string | null) => Promise) => void + requestGateway: (method: string, params?: Record) => Promise +}) { + const ref = (value: T): MutableRefObject => ({ current: value }) + + const actions = useSessionActions({ + activeSessionId: null, + activeSessionIdRef: ref(null), + busyRef: ref(false), + creatingSessionRef: ref(false), + ensureSessionState: () => ({}) as ClientSessionState, + getRouteToken: () => 'token', + navigate: vi.fn() as never, + requestGateway, + runtimeIdByStoredSessionIdRef: ref(new Map()), + selectedStoredSessionId: null, + selectedStoredSessionIdRef: ref(null), + sessionStateByRuntimeIdRef: ref(new Map()), + syncSessionStateToView: vi.fn(), + updateSessionState: (_sessionId, updater) => updater({} as ClientSessionState) + }) + + useEffect(() => { + onReady(actions.branchStoredSession) + }, [actions.branchStoredSession, onReady]) + + return null +} + async function createWith( profileSetup: () => void, beforeCreate?: (handle: HarnessHandle) => Promise | void @@ -202,6 +235,47 @@ describe('createBackendSessionForSend profile routing', () => { }) }) +describe('branchStoredSession profile routing', () => { + afterEach(() => { + cleanup() + $activeGatewayProfile.set('default') + setMessages([]) + setSessions([]) + vi.restoreAllMocks() + }) + + it('creates the branch on the parent session profile instead of the currently active profile', async () => { + const createCalls: Record[] = [] + + vi.mocked(getSessionMessages).mockResolvedValue({ + messages: [{ content: 'parent text', role: 'user', timestamp: 1 }], + session_id: 'default-parent' + } as never) + + setSessions([storedSession({ id: 'default-parent', profile: 'default' })]) + $activeGatewayProfile.set('work-profile') + + const requestGateway = vi.fn(async (method: string, params?: Record) => { + if (method === 'session.create') { + createCalls.push(params ?? {}) + + return { session_id: 'runtime-branch', stored_session_id: 'stored-branch', messages: [] } as never + } + + return {} as never + }) + + let branchStoredSession: ((storedSessionId: string, sessionProfile?: string | null) => Promise) | null = null + render( (branchStoredSession = b)} requestGateway={requestGateway} />) + await waitFor(() => expect(branchStoredSession).not.toBeNull()) + + await expect(branchStoredSession!('default-parent', 'default')).resolves.toBe(true) + + expect(createCalls).toHaveLength(1) + expect(createCalls[0]).toMatchObject({ parent_session_id: 'default-parent', profile: 'default' }) + }) +}) + // ── Resume failure recovery (the "stuck loading session window" bug) ────────── // When session.resume rejects AND the REST transcript fallback ALSO fails, the // hook must (a) not throw out of the fallback (which stranded the loader), and @@ -458,38 +532,6 @@ describe('resumeSession failure recovery', () => { }) }) -function BranchHarness({ - onReady, - requestGateway -}: { - onReady: (branchStoredSession: (storedSessionId: string, sessionProfile?: string | null) => Promise) => void - requestGateway: (method: string, params?: Record) => Promise -}) { - const ref = (value: T): MutableRefObject => ({ current: value }) - - const actions = useSessionActions({ - activeSessionId: null, - activeSessionIdRef: ref(null), - busyRef: ref(false), - creatingSessionRef: ref(false), - ensureSessionState: () => ({}) as ClientSessionState, - getRouteToken: () => 'token', - navigate: vi.fn() as never, - requestGateway, - runtimeIdByStoredSessionIdRef: ref(new Map()), - selectedStoredSessionId: null, - selectedStoredSessionIdRef: ref(null), - sessionStateByRuntimeIdRef: ref(new Map()), - syncSessionStateToView: vi.fn(), - updateSessionState: () => ({}) as ClientSessionState - }) - - useEffect(() => { - onReady(actions.branchStoredSession) - }, [actions.branchStoredSession, onReady]) - - return null -} describe('branchStoredSession desktop source tagging', () => { afterEach(() => { diff --git a/apps/desktop/src/app/session/hooks/use-session-actions/index.ts b/apps/desktop/src/app/session/hooks/use-session-actions/index.ts index eec8db608117..b2fa7adfe300 100644 --- a/apps/desktop/src/app/session/hooks/use-session-actions/index.ts +++ b/apps/desktop/src/app/session/hooks/use-session-actions/index.ts @@ -679,17 +679,27 @@ export function useSessionActions({ // Shared fork: create a child session seeded with `branchMessages`, linked to // `parentStoredId` so it nests under its parent, then make it the active chat. const forkBranch = useCallback( - async (branchMessages: BranchMessage[], parentStoredId: null | string, cwd?: string): Promise => { + async ( + branchMessages: BranchMessage[], + parentStoredId: null | string, + cwd?: string, + profile?: null | string + ): Promise => { creatingSessionRef.current = true try { + const branchProfile = normalizeProfileKey(profile ?? $activeGatewayProfile.get()) + + await ensureGatewayProfile(branchProfile) + // No title: the backend auto-names the branch from its parent's lineage. const branched = await requestGateway('session.create', { cols: 96, source: 'desktop', ...(cwd && { cwd }), messages: branchMessages.map(({ content, role }) => ({ content, role })), - ...(parentStoredId && { parent_session_id: parentStoredId }) + ...(parentStoredId && { parent_session_id: parentStoredId }), + profile: branchProfile }) const routedSessionId = branched.stored_session_id ?? branched.session_id @@ -793,7 +803,11 @@ export function useSessionActions({ clearNotifications() - return forkBranch(branchMessages, selectedStoredSessionIdRef.current, $currentCwd.get().trim()) + const parent = selectedStoredSessionIdRef.current + ? $sessions.get().find(session => sessionMatchesStoredId(session, selectedStoredSessionIdRef.current!)) + : null + + return forkBranch(branchMessages, selectedStoredSessionIdRef.current, $currentCwd.get().trim(), parent?.profile) }, [activeSessionIdRef, busyRef, copy, forkBranch, selectedStoredSessionIdRef] ) @@ -819,7 +833,7 @@ export function useSessionActions({ return false } - return await forkBranch(branchMessages, stored?.id ?? storedSessionId, stored?.cwd?.trim()) + return await forkBranch(branchMessages, stored?.id ?? storedSessionId, stored?.cwd?.trim(), profile) } catch (err) { notifyError(err, copy.branchFailed) diff --git a/apps/desktop/src/store/session.test.ts b/apps/desktop/src/store/session.test.ts index 013ad0efd480..b832380d2d26 100644 --- a/apps/desktop/src/store/session.test.ts +++ b/apps/desktop/src/store/session.test.ts @@ -10,9 +10,13 @@ import { $workingSessionIds, applyConfiguredDefaultProjectDir, getRecentlySettledSessionIds, + getRememberedSessionId, mergeSessionPage, + rememberedSessionProfile, + rememberedSessionStorageKey, sessionPinId, setCurrentCwd, + setRememberedSessionId, setSessionAttention, setSessionWorking, workspaceCwdForNewSession @@ -37,6 +41,40 @@ const session = (over: Partial): SessionInfo => ({ ...over }) +describe('remembered last session', () => { + afterEach(() => { + window.localStorage.removeItem('hermes.desktop.lastSessionId') + window.localStorage.removeItem('hermes.desktop.lastSessionId.work-profile') + window.localStorage.removeItem('hermes.desktop.lastSessionId.profile%20with%20spaces') + }) + + it('keeps the last opened chat separate per profile', () => { + setRememberedSessionId('default-branch', 'default') + setRememberedSessionId('coding-session', 'work-profile') + + expect(getRememberedSessionId('default')).toBe('default-branch') + expect(getRememberedSessionId('work-profile')).toBe('coding-session') + }) + + it('uses the legacy key only for the default profile', () => { + expect(rememberedSessionStorageKey('default')).toBe('hermes.desktop.lastSessionId') + expect(rememberedSessionStorageKey('work-profile')).toBe('hermes.desktop.lastSessionId.work-profile') + expect(rememberedSessionStorageKey('profile with spaces')).toBe( + 'hermes.desktop.lastSessionId.profile%20with%20spaces' + ) + }) + + it('keys a routed session by its stored owner profile instead of the current gateway profile', () => { + const sessions = [session({ id: 'default-branch', profile: 'default' })] + + expect(rememberedSessionProfile(sessions, 'default-branch', 'work-profile')).toBe('default') + }) + + it('falls back to the current gateway profile before the routed session row is loaded', () => { + expect(rememberedSessionProfile([], 'not-loaded-yet', 'work-profile')).toBe('work-profile') + }) +}) + describe('setSessionAttention', () => { it('adds and removes a session id without duplicating it', () => { $attentionSessionIds.set([]) diff --git a/apps/desktop/src/store/session.ts b/apps/desktop/src/store/session.ts index 91b27ca56afd..62b8799f97d6 100644 --- a/apps/desktop/src/store/session.ts +++ b/apps/desktop/src/store/session.ts @@ -23,10 +23,41 @@ 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. +// +// Multi-profile Desktop must remember this per profile. A single global key lets +// opening a work-profile chat overwrite the default profile's last default +// branch, so a later profile/app switch can jump to an unrelated session. 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 normalizeRememberedProfile(profile?: null | string): string { + const value = (profile ?? '').trim() + + return value || 'default' +} + +export function rememberedSessionStorageKey(profile?: null | string): string { + const key = normalizeRememberedProfile(profile) + + return key === 'default' ? LAST_SESSION_KEY : `${LAST_SESSION_KEY}.${encodeURIComponent(key)}` +} + +export const getRememberedSessionId = (profile?: null | string): null | string => + storedString(rememberedSessionStorageKey(profile)) +export const setRememberedSessionId = (id: null | string, profile?: null | string) => + persistString(rememberedSessionStorageKey(profile), id) + +export function rememberedSessionProfile( + sessions: Pick[], + storedSessionId: null | string, + fallbackProfile?: null | string +): string { + const session = storedSessionId + ? sessions.find(item => item.id === storedSessionId || item._lineage_root_id === storedSessionId) + : null + const profile = session?.profile?.trim() || fallbackProfile?.trim() + + return profile || 'default' +} let configuredDefaultProjectDir = ''