From 73ff6ee81a1a3e3abf3f652649f872fcd71d7f72 Mon Sep 17 00:00:00 2001 From: Desktop Contributor Date: Wed, 26 Aug 2026 14:45:48 +0000 Subject: [PATCH 1/2] fix(desktop): restore profile session continuity --- .../hooks/profile-switch-continuity.test.tsx | 346 +++++++++++++++++ .../hooks/use-desktop-integrations.test.tsx | 2 + .../contrib/hooks/use-desktop-integrations.ts | 18 +- .../hooks/use-profile-switch-continuity.ts | 358 ++++++++++++++++++ apps/desktop/src/app/contrib/wiring.tsx | 22 +- .../profile-rail-fresh-chat-owner.test.tsx | 137 ++++++- .../hooks/use-session-actions.test.tsx | 23 +- .../hooks/use-session-actions/index.ts | 4 + .../hooks/use-session-list-actions.test.tsx | 184 +++++++-- .../session/hooks/use-session-list-actions.ts | 18 +- .../src/app/settings/appearance-settings.tsx | 27 ++ .../src/app/settings/settings-search.ts | 1 + .../src/app/settings/use-settings-search.ts | 18 + apps/desktop/src/i18n/ar.ts | 5 + apps/desktop/src/i18n/en.ts | 5 + apps/desktop/src/i18n/ja.ts | 5 + apps/desktop/src/i18n/types.ts | 4 + apps/desktop/src/i18n/zh-hant.ts | 5 + apps/desktop/src/i18n/zh.ts | 5 + .../src/store/profile-select-source.test.ts | 146 ++++++- .../src/store/profile-switch-behavior.test.ts | 235 ++++++++++++ .../src/store/profile-switch-behavior.ts | 270 +++++++++++++ apps/desktop/src/store/profile.ts | 126 +++++- 23 files changed, 1884 insertions(+), 80 deletions(-) create mode 100644 apps/desktop/src/app/contrib/hooks/profile-switch-continuity.test.tsx create mode 100644 apps/desktop/src/app/contrib/hooks/use-profile-switch-continuity.ts create mode 100644 apps/desktop/src/store/profile-switch-behavior.test.ts create mode 100644 apps/desktop/src/store/profile-switch-behavior.ts diff --git a/apps/desktop/src/app/contrib/hooks/profile-switch-continuity.test.tsx b/apps/desktop/src/app/contrib/hooks/profile-switch-continuity.test.tsx new file mode 100644 index 0000000000000..245484d80970d --- /dev/null +++ b/apps/desktop/src/app/contrib/hooks/profile-switch-continuity.test.tsx @@ -0,0 +1,346 @@ +import { act, renderHook } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import { group, split } from '@/components/pane-shell/tree/model' +import { $layoutTree, activateTreePane, noteActiveTreeGroup } from '@/components/pane-shell/tree/store' +import { $pendingConnectionId } from '@/store/connections' +import { + $profileSwitchRestoreToken, + _resetProfileSwitchBehaviorForTests, + bindProfileSwitchRestore, + clearProfileSwitchRestore, + requestProfileSwitchRestore, + setProfileSwitchBehavior +} from '@/store/profile-switch-behavior' +import { $freshSessionRequest, requestFreshSession } from '@/store/profile' +import { _resetLegacyDiscardForTests, setRememberedSessionId } from '@/store/session' +import type { SessionOwnerScope } from '@/store/session-request-router' + +import { deferred } from '../../../test/deferred' + +import { useDesktopIntegrations } from './use-desktop-integrations' + +const mocks = vi.hoisted(() => { + const { atom } = require('nanostores') as typeof import('nanostores') + + return { + activeConnectionId: null as null | string, + activationEpoch: 0, + browser: false, + focusOpenSession: vi.fn<() => 'main' | 'tile' | null>(() => null), + focusedStoredSessionId: atom(null), + hud: false, + knownOwner: vi.fn<(sessionId: null | string | undefined) => SessionOwnerScope>(() => undefined), + markSelectionRestore: vi.fn(), + openSession: vi.fn(), + requestSessionResume: vi.fn(), + secondary: false + } +}) + +vi.mock('@/app/open-session', () => ({ openSession: mocks.openSession })) + +vi.mock('@/store/gateway', async importOriginal => ({ + ...(await importOriginal>()), + activeGatewayConnectionId: () => mocks.activeConnectionId, + gatewayActivationEpoch: () => mocks.activationEpoch +})) + +vi.mock('@/store/session', async importOriginal => ({ + ...(await importOriginal>()), + requestSessionResume: mocks.requestSessionResume +})) + +vi.mock('@/store/session-states', async importOriginal => ({ + ...(await importOriginal>()), + $focusedStoredSessionId: mocks.focusedStoredSessionId, + focusOpenSession: mocks.focusOpenSession, + knownOwnerForSession: mocks.knownOwner, + markSelectionRestore: mocks.markSelectionRestore +})) + +vi.mock('@/store/windows', async importOriginal => ({ + ...(await importOriginal>()), + isBrowserWindow: () => mocks.browser, + isHudWindow: () => mocks.hud, + isSecondaryWindow: () => mocks.secondary +})) + +const desktopWindow = window as unknown as { hermesDesktop?: Window['hermesDesktop'] } +const initialHermesDesktop = desktopWindow.hermesDesktop + +interface HookProps { + activeProfile: string + descriptorConnectionId: null | string + locationPathname: string + profileReady: boolean + refreshSessions: (shouldPublish?: () => boolean) => Promise +} + +describe('live profile-switch continuity', () => { + beforeEach(() => { + localStorage.clear() + _resetLegacyDiscardForTests() + _resetProfileSwitchBehaviorForTests() + $pendingConnectionId.set(null) + mocks.activeConnectionId = 'source-a' + mocks.activationEpoch = 7 + mocks.browser = false + mocks.hud = false + mocks.secondary = false + mocks.focusedStoredSessionId.set(null) + $layoutTree.set( + split('row', [ + group(['workspace'], { active: 'workspace', id: 'main-zone' }), + group(['preview', 'terminal'], { active: 'preview', id: 'native-zone' }) + ]) + ) + noteActiveTreeGroup(null) + mocks.focusOpenSession.mockReset() + mocks.focusOpenSession.mockReturnValue(null) + mocks.knownOwner.mockReset() + mocks.knownOwner.mockReturnValue(undefined) + mocks.markSelectionRestore.mockReset() + mocks.openSession.mockReset() + mocks.requestSessionResume.mockReset() + + desktopWindow.hermesDesktop = { + onClosePreviewRequested: vi.fn(), + onDeepLink: vi.fn(), + onFocusSession: vi.fn(), + onNotificationAction: vi.fn(), + onNotificationActivate: vi.fn(), + onOpenFolderRequested: vi.fn(), + onOpenUpdatesRequested: vi.fn(), + setPreviewShortcutActive: vi.fn(), + signalDeepLinkReady: vi.fn() + } as unknown as Window['hermesDesktop'] + }) + + afterEach(() => { + desktopWindow.hermesDesktop = initialHermesDesktop + }) + + function renderContinuity(initial: Partial = {}) { + const navigate = vi.fn() + const defaults: HookProps = { + activeProfile: 'alpha', + descriptorConnectionId: 'source-a', + locationPathname: '/settings', + profileReady: true, + refreshSessions: vi.fn(async () => true) + } + + const result = renderHook( + (overrides: Partial) => { + const props = { ...defaults, ...overrides } + + useDesktopIntegrations({ + activeProfile: props.activeProfile, + chatOpen: false, + descriptorConnectionId: props.descriptorConnectionId, + descriptorProfile: props.activeProfile, + hasPreview: false, + locationPathname: props.locationPathname, + navigate, + profileReady: props.profileReady, + refreshSessions: props.refreshSessions, + resumeExhaustedSessionId: null, + routedSessionId: null, + runtimeIdByStoredSessionId: { current: new Map() }, + sessions: [] + }) + }, + { initialProps: initial } + ) + + return { ...result, navigate } + } + + async function publishRestore( + profile = 'alpha', + requestedConnectionId: null | string = 'source-a', + liveGatewayConnectionId: null | string = requestedConnectionId, + descriptorConnectionId: null | string = requestedConnectionId + ) { + await act(async () => { + setProfileSwitchBehavior('restore_last_session') + const freshSessionRequestSequence = $freshSessionRequest.get() + 1 + const token = requestProfileSwitchRestore(profile, requestedConnectionId, freshSessionRequestSequence) + requestFreshSession(token.generation) + bindProfileSwitchRestore(token.generation, { + activationEpoch: mocks.activationEpoch, + descriptorConnectionId, + descriptorProfile: profile, + liveGatewayConnectionId, + profile + }) + await Promise.resolve() + }) + } + + it('lets pre-activation user navigation consume only its restore generation', async () => { + const refreshSessions = vi.fn(async () => true) + const result = renderContinuity({ locationPathname: '/session/source', refreshSessions }) + let oldGeneration = 0 + + await act(async () => { + setProfileSwitchBehavior('restore_last_session') + const token = requestProfileSwitchRestore('alpha', 'source-a', $freshSessionRequest.get() + 1) + oldGeneration = token.generation + requestFreshSession(token.generation) + }) + + result.rerender({ locationPathname: '/skills', refreshSessions }) + + expect($profileSwitchRestoreToken.get()).toBeNull() + expect(refreshSessions).not.toHaveBeenCalled() + expect(mocks.focusOpenSession).not.toHaveBeenCalled() + expect(mocks.requestSessionResume).not.toHaveBeenCalled() + expect(mocks.openSession).not.toHaveBeenCalled() + + let newerGeneration = 0 + + await act(async () => { + const token = requestProfileSwitchRestore('beta', 'source-a', $freshSessionRequest.get() + 1) + newerGeneration = token.generation + requestFreshSession(token.generation) + clearProfileSwitchRestore(oldGeneration) + }) + + expect($profileSwitchRestoreToken.get()?.generation).toBe(newerGeneration) + }) + + it('cancels when the user navigates before the committed refresh returns', async () => { + const refresh = deferred() + const refreshSessions = vi.fn(() => refresh.promise) + const result = renderContinuity({ locationPathname: '/', refreshSessions }) + + mocks.knownOwner.mockReturnValue({ connectionId: 'source-a', profile: 'alpha' }) + setRememberedSessionId('alpha-main', 'alpha') + await publishRestore() + + result.rerender({ locationPathname: '/skills', refreshSessions }) + await act(async () => refresh.resolve(true)) + + expect($profileSwitchRestoreToken.get()).toBeNull() + expect(mocks.focusOpenSession).not.toHaveBeenCalled() + expect(mocks.openSession).not.toHaveBeenCalled() + }) + + it('does not restore over native focus moved to a non-session pane during refresh', async () => { + const refresh = deferred() + const refreshSessions = vi.fn(() => refresh.promise) + const result = renderContinuity({ locationPathname: '/', refreshSessions }) + + mocks.knownOwner.mockReturnValue({ connectionId: 'source-a', profile: 'alpha' }) + setRememberedSessionId('alpha-main', 'alpha') + await publishRestore() + + expect(refreshSessions).toHaveBeenCalledOnce() + expect($profileSwitchRestoreToken.get()?.draftNavigationToken).toBeDefined() + + await act(async () => { + activateTreePane('native-zone', 'terminal') + noteActiveTreeGroup('native-zone') + await Promise.resolve() + }) + + expect($profileSwitchRestoreToken.get()).toBeNull() + + await act(async () => refresh.resolve(true)) + + expect(mocks.focusOpenSession).not.toHaveBeenCalled() + expect(mocks.requestSessionResume).not.toHaveBeenCalled() + expect(mocks.openSession).not.toHaveBeenCalled() + expect(result.navigate).not.toHaveBeenCalled() + }) + + it('does not seal a fresh draft when the null group marker masks an active terminal beside workspace', async () => { + $layoutTree.set(group(['workspace', 'terminal'], { active: 'terminal', id: 'main-zone' })) + noteActiveTreeGroup(null) + mocks.knownOwner.mockReturnValue({ connectionId: 'source-a', profile: 'alpha' }) + setRememberedSessionId('alpha-main', 'alpha') + const refreshSessions = vi.fn(async () => true) + + renderContinuity({ locationPathname: '/', refreshSessions }) + await publishRestore() + + expect($profileSwitchRestoreToken.get()).toBeNull() + expect(refreshSessions).not.toHaveBeenCalled() + expect(mocks.focusOpenSession).not.toHaveBeenCalled() + expect(mocks.requestSessionResume).not.toHaveBeenCalled() + expect(mocks.openSession).not.toHaveBeenCalled() + }) + + it('accepts workspace focus from the workspace group when the active group marker is null', async () => { + const refresh = deferred() + const refreshSessions = vi.fn(() => refresh.promise) + + $layoutTree.set(group(['workspace', 'terminal'], { active: 'workspace', id: 'main-zone' })) + noteActiveTreeGroup(null) + mocks.knownOwner.mockReturnValue({ connectionId: 'source-a', profile: 'alpha' }) + setRememberedSessionId('alpha-main', 'alpha') + + renderContinuity({ locationPathname: '/', refreshSessions }) + await publishRestore() + + expect(refreshSessions).toHaveBeenCalledOnce() + expect($profileSwitchRestoreToken.get()?.draftNavigationToken).toBeDefined() + + await act(async () => refresh.resolve(true)) + + expect(mocks.requestSessionResume).toHaveBeenCalledWith('alpha-main', { + connectionId: 'source-a', + profile: 'alpha' + }) + expect(mocks.openSession).toHaveBeenCalledOnce() + }) + + it.each([ + ['uncommitted refresh', vi.fn(async () => false)], + ['failed refresh', vi.fn(async () => Promise.reject(new Error('refresh failed')))] + ])('falls back to the fresh draft after %s', async (_label, refreshSessions) => { + mocks.knownOwner.mockReturnValue({ connectionId: 'source-a', profile: 'alpha' }) + setRememberedSessionId('alpha-main', 'alpha') + renderContinuity({ locationPathname: '/', refreshSessions }) + + await publishRestore() + + expect($profileSwitchRestoreToken.get()).toBeNull() + expect(mocks.openSession).not.toHaveBeenCalled() + }) + + it('does not accept the same profile name from another connection source', async () => { + const refreshSessions = vi.fn(async () => true) + + mocks.activeConnectionId = 'source-b' + mocks.knownOwner.mockReturnValue({ connectionId: 'source-a', profile: 'default' }) + setRememberedSessionId('shared-name', 'default') + renderContinuity({ + activeProfile: 'default', + descriptorConnectionId: 'source-b', + locationPathname: '/', + refreshSessions + }) + + await publishRestore('default', 'source-a') + + expect(refreshSessions).not.toHaveBeenCalled() + expect(mocks.openSession).not.toHaveBeenCalled() + }) + + it.each(['hud', 'browser', 'secondary'] as const)('never runs live restore effects in a %s window', async kind => { + mocks[kind] = true + mocks.knownOwner.mockReturnValue({ connectionId: 'source-a', profile: 'alpha' }) + setRememberedSessionId('alpha-main', 'alpha') + const refreshSessions = vi.fn(async () => true) + + renderContinuity({ locationPathname: '/', refreshSessions }) + await publishRestore() + + expect($profileSwitchRestoreToken.get()).toBeNull() + expect(refreshSessions).not.toHaveBeenCalled() + expect(mocks.openSession).not.toHaveBeenCalled() + }) +}) diff --git a/apps/desktop/src/app/contrib/hooks/use-desktop-integrations.test.tsx b/apps/desktop/src/app/contrib/hooks/use-desktop-integrations.test.tsx index 5dd54083cffc2..eb4510f0e013f 100644 --- a/apps/desktop/src/app/contrib/hooks/use-desktop-integrations.test.tsx +++ b/apps/desktop/src/app/contrib/hooks/use-desktop-integrations.test.tsx @@ -102,6 +102,8 @@ describe('useDesktopIntegrations', () => { useDesktopIntegrations({ activeProfile, chatOpen: false, + descriptorConnectionId: null, + descriptorProfile: null, hasPreview: false, locationPathname, navigate, 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 9cd069c25382c..190a1fea2a022 100644 --- a/apps/desktop/src/app/contrib/hooks/use-desktop-integrations.ts +++ b/apps/desktop/src/app/contrib/hooks/use-desktop-integrations.ts @@ -31,16 +31,20 @@ import type { SessionInfo } from '@/types/hermes' import { requestComposerFocus, requestComposerInsert } from '../../chat/composer/focus' import { appViewForPath, isOverlayView, NEW_CHAT_ROUTE, routeSessionId, sessionRoute } from '../../routes' +import { useProfileSwitchContinuity } from './use-profile-switch-continuity' + type RememberedSession = Pick interface DesktopIntegrationsParams { activeProfile: string chatOpen: boolean + descriptorConnectionId: null | string + descriptorProfile: null | string hasPreview: boolean locationPathname: string navigate: (to: string, options?: { replace?: boolean }) => void profileReady: boolean - refreshSessions: () => Promise | unknown + refreshSessions: (shouldPublish?: () => boolean) => Promise resumeExhaustedSessionId: null | string routedSessionId: null | string runtimeIdByStoredSessionId: { readonly current: Map } @@ -56,6 +60,8 @@ interface DesktopIntegrationsParams { */ export function useDesktopIntegrations({ activeProfile, + descriptorConnectionId, + descriptorProfile, locationPathname, navigate, profileReady, @@ -91,6 +97,16 @@ export function useDesktopIntegrations({ const restoredRef = useRef(false) + useProfileSwitchContinuity({ + activeProfile, + descriptorConnectionId, + descriptorProfile, + locationPathname, + navigate, + profileReady, + refreshSessions + }) + // Wait until boot has adopted the primary profile, then restore that profile's // navigation exactly once. The same effect owns subsequent writes so the // initial `/` cannot overwrite remembered history before it is read. diff --git a/apps/desktop/src/app/contrib/hooks/use-profile-switch-continuity.ts b/apps/desktop/src/app/contrib/hooks/use-profile-switch-continuity.ts new file mode 100644 index 0000000000000..81928bb5abfa0 --- /dev/null +++ b/apps/desktop/src/app/contrib/hooks/use-profile-switch-continuity.ts @@ -0,0 +1,358 @@ +import { useStore } from '@nanostores/react' +import { useEffect, useRef } from 'react' + +import { openSession } from '@/app/open-session' +import { findGroup, findGroupOfPane, type LayoutNode } from '@/components/pane-shell/tree/model' +import { $activeTreeGroup, $layoutTree } from '@/components/pane-shell/tree/store' +import { $pendingConnectionId } from '@/store/connections' +import { activeGatewayConnectionId, gatewayActivationEpoch } from '@/store/gateway' +import { + $profileSwitchBehavior, + $profileSwitchRestoreToken, + clearProfileSwitchRestore, + observeProfileSwitchRestoreNavigation, + profileSwitchNavigationToken, + registerProfileSwitchAnchorCapture, + type ProfileSwitchActivation, + type ProfileSwitchPaneFocus +} from '@/store/profile-switch-behavior' +import { $freshSessionRequest, profilePickConnectionId } from '@/store/profile' +import { getRememberedSessionId, requestSessionResume, setRememberedSessionId } from '@/store/session' +import { isSessionOwnerRoute, type SessionOwnerScope } from '@/store/session-request-router' +import { + $focusedStoredSessionId, + focusOpenSession, + knownOwnerForSession, + markSelectionRestore +} from '@/store/session-states' +import { isBrowserWindow, isHudWindow, isSecondaryWindow } from '@/store/windows' + +interface ProfileSwitchContinuityParams { + activeProfile: string + descriptorConnectionId: null | string + descriptorProfile: null | string + locationPathname: string + navigate: (to: string, options?: { replace?: boolean }) => void + profileReady: boolean + refreshSessions: (shouldPublish?: () => boolean) => Promise +} + +const normalizeConnectionId = (value: null | string | undefined): null | string => value?.trim() || null +const normalizeProfile = (value: string): string => value.trim() || 'default' + +function profileSwitchPaneFocus(groupId: null | string, tree: LayoutNode | null): ProfileSwitchPaneFocus { + const paneId = + groupId !== null + ? tree + ? (findGroup(tree, groupId)?.active ?? null) + : null + : tree + ? (findGroupOfPane(tree, 'workspace')?.active ?? null) + : 'workspace' + + return { + groupId, + paneId + } +} + +interface ProfileSwitchOwnerContext { + activation: ProfileSwitchActivation + requestedConnectionId: null | string +} + +function ownerMatchesSettledActivation( + owner: SessionOwnerScope, + { activation, requestedConnectionId }: ProfileSwitchOwnerContext +): boolean { + const profile = normalizeProfile(activation.profile) + const liveGatewayConnectionId = normalizeConnectionId(activation.liveGatewayConnectionId) + const descriptorConnectionId = normalizeConnectionId(activation.descriptorConnectionId) + const descriptorProfile = activation.descriptorProfile?.trim() || null + const descriptorProfileMismatch = + (descriptorConnectionId !== null || descriptorProfile !== null) && + normalizeProfile(descriptorProfile ?? '') !== profile + const liveDescriptorMismatch = liveGatewayConnectionId !== null && liveGatewayConnectionId !== descriptorConnectionId + + if (descriptorProfileMismatch || liveDescriptorMismatch) { + return false + } + + if (isSessionOwnerRoute(owner)) { + const exactConnectionId = + normalizeConnectionId(requestedConnectionId) ?? liveGatewayConnectionId ?? descriptorConnectionId + + return ( + exactConnectionId !== null && + owner.connectionId.trim() === exactConnectionId && + normalizeProfile(owner.profile) === profile + ) + } + + // A bare profile names the profile-keyed pool socket and is valid only when + // this intent used the profile-only door. An explicit registry request must + // always carry an exact owner route. + return ( + normalizeConnectionId(requestedConnectionId) === null && + typeof owner === 'string' && + normalizeProfile(owner) === profile + ) +} + +function currentActivation( + descriptorConnectionId: null | string, + descriptorProfile: null | string, + activeProfile: string +): ProfileSwitchActivation { + return { + activationEpoch: gatewayActivationEpoch(), + descriptorConnectionId: normalizeConnectionId(descriptorConnectionId), + descriptorProfile: descriptorProfile?.trim() || null, + liveGatewayConnectionId: normalizeConnectionId(activeGatewayConnectionId()), + profile: normalizeProfile(activeProfile) + } +} + +/** Restore the last natively focused session after a live profile activation. + * The fresh-draft barrier, activation epoch, descriptor/live publication and + * guarded list refresh must all still belong to the same user intent. */ +export function useProfileSwitchContinuity({ + activeProfile, + descriptorConnectionId, + descriptorProfile, + locationPathname, + navigate, + profileReady, + refreshSessions +}: ProfileSwitchContinuityParams): void { + const profileSwitchBehavior = useStore($profileSwitchBehavior) + const profileSwitchRestoreToken = useStore($profileSwitchRestoreToken) + const freshSessionRequest = useStore($freshSessionRequest) + const pendingConnectionId = useStore($pendingConnectionId) + const focusedStoredSessionId = useStore($focusedStoredSessionId) + const activeTreeGroup = useStore($activeTreeGroup) + const layoutTree = useStore($layoutTree) + const paneFocus = profileSwitchPaneFocus(activeTreeGroup, layoutTree) + const currentNavigationToken = profileSwitchNavigationToken({ + focusedStoredSessionId, + paneFocus, + pathname: locationPathname + }) + const anchorRef = useRef({ + activation: currentActivation(descriptorConnectionId, descriptorProfile, activeProfile), + focusedStoredSessionId, + paneFocus, + pathname: locationPathname, + requestedConnectionId: profilePickConnectionId() + }) + + anchorRef.current = { + activation: currentActivation(descriptorConnectionId, descriptorProfile, activeProfile), + focusedStoredSessionId, + paneFocus, + pathname: locationPathname, + requestedConnectionId: profilePickConnectionId() + } + + const normalMainWindow = !isHudWindow() && !isSecondaryWindow() && !isBrowserWindow() + + // Native pane activation does not change the router. Remember the focused + // stored id under the exact settled source/profile scope. + useEffect(() => { + if (!normalMainWindow || !profileReady || !focusedStoredSessionId) { + return + } + + const activation = currentActivation(descriptorConnectionId, descriptorProfile, activeProfile) + const requestedConnectionId = profilePickConnectionId() + + if ( + ownerMatchesSettledActivation(knownOwnerForSession(focusedStoredSessionId), { + activation, + requestedConnectionId + }) + ) { + setRememberedSessionId(focusedStoredSessionId, activeProfile) + } + }, [activeProfile, descriptorConnectionId, descriptorProfile, focusedStoredSessionId, normalMainWindow, profileReady]) + + // `selectProfile` crosses the fresh-draft barrier synchronously. Give it a + // synchronous read of the latest native focus so React cannot replace the + // departing profile's anchor before the request captures it. + useEffect(() => { + if (!normalMainWindow) { + return + } + + return registerProfileSwitchAnchorCapture(() => { + const anchor = anchorRef.current + const focused = anchor.focusedStoredSessionId + const requestedConnectionId = anchor.requestedConnectionId + + if ( + focused && + ownerMatchesSettledActivation(knownOwnerForSession(focused), { + activation: anchor.activation, + requestedConnectionId + }) + ) { + setRememberedSessionId(focused, anchor.activation.profile) + } + + return { focusedStoredSessionId: focused, paneFocus: anchor.paneFocus, pathname: anchor.pathname } + }) + }, [normalMainWindow]) + + // The native fresh-request sequence authorizes exactly one source -> + // null-selection -> fresh-route transition. A later New Chat supersedes it + // even if the pathname/focus pair is unchanged. + useEffect(() => { + const token = profileSwitchRestoreToken + + if (!token) { + return + } + + if (!normalMainWindow) { + clearProfileSwitchRestore(token.generation) + + return + } + + observeProfileSwitchRestoreNavigation( + token.generation, + { focusedStoredSessionId, paneFocus, pathname: locationPathname }, + freshSessionRequest + ) + }, [currentNavigationToken, freshSessionRequest, normalMainWindow, profileSwitchRestoreToken]) + + // Restore only after current-main's exact activation has published and its + // guarded session refresh has committed. Native focus/open APIs preserve the + // profile-keyed tile placement and avoid duplicate tabs. + useEffect(() => { + const token = profileSwitchRestoreToken + + if (!token) { + return + } + + if (!normalMainWindow || profileSwitchBehavior !== 'restore_last_session' || pendingConnectionId) { + clearProfileSwitchRestore(token.generation) + + return + } + + if (token.draftNavigationToken === undefined || token.activation === undefined || !profileReady) { + return + } + + const boundActivation = token.activation + const activation = currentActivation(descriptorConnectionId, descriptorProfile, activeProfile) + const activationMatches = + activation.activationEpoch === boundActivation.activationEpoch && + activation.descriptorConnectionId === boundActivation.descriptorConnectionId && + activation.descriptorProfile === boundActivation.descriptorProfile && + activation.liveGatewayConnectionId === boundActivation.liveGatewayConnectionId && + activation.profile === boundActivation.profile + + if ( + !activationMatches || + freshSessionRequest !== token.freshSessionRequestSequence || + currentNavigationToken !== token.draftNavigationToken + ) { + clearProfileSwitchRestore(token.generation) + + return + } + + const exactActivation = () => { + const current = $profileSwitchRestoreToken.get() + const settled = currentActivation(descriptorConnectionId, descriptorProfile, activeProfile) + const liveNavigationToken = profileSwitchNavigationToken({ + focusedStoredSessionId: $focusedStoredSessionId.get(), + paneFocus: profileSwitchPaneFocus($activeTreeGroup.get(), $layoutTree.get()), + pathname: locationPathname + }) + + return ( + current?.generation === token.generation && + current.draftNavigationToken === token.draftNavigationToken && + $freshSessionRequest.get() === token.freshSessionRequestSequence && + liveNavigationToken === token.draftNavigationToken && + settled.activationEpoch === boundActivation.activationEpoch && + settled.descriptorConnectionId === boundActivation.descriptorConnectionId && + settled.descriptorProfile === boundActivation.descriptorProfile && + settled.liveGatewayConnectionId === boundActivation.liveGatewayConnectionId && + settled.profile === boundActivation.profile + ) + } + + if (!exactActivation()) { + clearProfileSwitchRestore(token.generation) + + return + } + + let cancelled = false + + void (async () => { + const committed = await refreshSessions(() => !cancelled && exactActivation()) + + if (cancelled || committed !== true || !exactActivation()) { + clearProfileSwitchRestore(token.generation) + + return + } + + const remembered = getRememberedSessionId(boundActivation.profile) + const owner = remembered ? knownOwnerForSession(remembered) : undefined + + if ( + !remembered || + !ownerMatchesSettledActivation(owner, { + activation: boundActivation, + requestedConnectionId: token.requestedConnectionId + }) + ) { + clearProfileSwitchRestore(token.generation) + + return + } + + clearProfileSwitchRestore(token.generation) + + if (focusOpenSession(remembered)) { + return + } + + markSelectionRestore() + + if (isSessionOwnerRoute(owner)) { + requestSessionResume(remembered, owner) + } + + openSession(remembered, navigate, 'in-place') + })().catch(() => { + if (!cancelled) { + clearProfileSwitchRestore(token.generation) + } + }) + + return () => { + cancelled = true + } + }, [ + activeProfile, + currentNavigationToken, + descriptorConnectionId, + descriptorProfile, + freshSessionRequest, + navigate, + normalMainWindow, + pendingConnectionId, + profileReady, + profileSwitchBehavior, + profileSwitchRestoreToken, + refreshSessions + ]) +} diff --git a/apps/desktop/src/app/contrib/wiring.tsx b/apps/desktop/src/app/contrib/wiring.tsx index 723fabe89952f..af458a318d6f8 100644 --- a/apps/desktop/src/app/contrib/wiring.tsx +++ b/apps/desktop/src/app/contrib/wiring.tsx @@ -225,6 +225,7 @@ export function ContribWiring({ children }: { children: ReactNode }) { const messagingSessions = useStore($messagingSessions) const sessions = useStore($sessions) const activeConnectionId = useStore($activeConnectionId) + const connection = useStore($connection) const activeGatewayProfile = useStore($activeGatewayProfile) const profileScope = useStore($profileScope) const boot = useStore($desktopBoot) @@ -310,6 +311,12 @@ export function ContribWiring({ children }: { children: ReactNode }) { const { loadMoreMessagingForPlatform, loadMoreSessions, refreshCronJobs, refreshMessagingSessions, refreshSessions } = useSessionListActions({ profileScope }) + const refreshSessionsLegacy = useCallback( + async (shouldPublish?: () => boolean): Promise => { + await refreshSessions(shouldPublish) + }, + [refreshSessions] + ) const updateActiveSessionRuntimeInfo = useCallback( (info: { branch?: string; cwd?: string }) => { @@ -444,7 +451,7 @@ export function ContribWiring({ children }: { children: ReactNode }) { hydrateFromStoredSession, queryClient, refreshHermesConfig, - refreshSessions, + refreshSessions: refreshSessionsLegacy, sessionStateByRuntimeIdRef, updateSessionState }) @@ -517,7 +524,7 @@ export function ContribWiring({ children }: { children: ReactNode }) { } lastFreshRef.current = freshSessionRequest - startFreshSessionDraft() + startFreshSessionDraft({ profileSwitchRequestSequence: freshSessionRequest }) }, [freshSessionRequest, startFreshSessionDraft]) // Swapping the live gateway to another source or profile must re-pull that @@ -599,12 +606,12 @@ export function ContribWiring({ children }: { children: ReactNode }) { const branched = await branchCurrentSession(messageId) if (branched) { - await refreshSessions().catch(() => undefined) + await refreshSessionsLegacy().catch(() => undefined) } return branched }, - [branchCurrentSession, refreshSessions] + [branchCurrentSession, refreshSessionsLegacy] ) const handleSkinCommand = useSkinCommand() @@ -630,7 +637,7 @@ export function ContribWiring({ children }: { children: ReactNode }) { getRouteToken, handleSkinCommand, openMemoryGraph: openStarmap, - refreshSessions, + refreshSessions: refreshSessionsLegacy, requestGateway, resumeStoredSession: resumeSession, runtimeIdByStoredSessionIdRef, @@ -793,7 +800,7 @@ export function ContribWiring({ children }: { children: ReactNode }) { gatewayRef.current = g }, refreshHermesConfig, - refreshSessions + refreshSessions: refreshSessionsLegacy }) useEffect(() => { @@ -838,6 +845,8 @@ export function ContribWiring({ children }: { children: ReactNode }) { useDesktopIntegrations({ activeProfile: normalizeProfileKey(activeGatewayProfile), chatOpen, + descriptorConnectionId: activeConnectionId, + descriptorProfile: connection?.profile ?? null, hasPreview: Boolean(previewTarget), locationPathname: location.pathname, navigate, @@ -1084,7 +1093,6 @@ export function ContribWiring({ children }: { children: ReactNode }) { // (preview's monitor/devtools cluster, …) arrive as registry contributions. const leftTitlebarTools = useTitlebarToolContributions('left') const rightTitlebarTools = useTitlebarToolContributions('right') - const connection = useStore($connection) const controlsPos = titlebarControlsPosition(connection?.windowButtonPosition, Boolean(connection?.isFullscreen)) // Windows/WSLg reserve native min/max/close on the right (AppShell parity: // prefer the live WCO measurement, fall back to the static reservation). diff --git a/apps/desktop/src/app/session/hooks/profile-rail-fresh-chat-owner.test.tsx b/apps/desktop/src/app/session/hooks/profile-rail-fresh-chat-owner.test.tsx index a592fe0cf9f47..1f96f1c20d43e 100644 --- a/apps/desktop/src/app/session/hooks/profile-rail-fresh-chat-owner.test.tsx +++ b/apps/desktop/src/app/session/hooks/profile-rail-fresh-chat-owner.test.tsx @@ -1,11 +1,13 @@ import { type GatewayEvent, registryBackendScopeKey } from '@hermes/shared' import { useStore } from '@nanostores/react' import { act, cleanup, render, waitFor } from '@testing-library/react' -import { useEffect, useMemo, useRef } from 'react' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { afterEach, beforeEach, describe, expect, it, type Mock, vi } from 'vitest' import { createSessionRpcDispatcher } from '@/app/contrib/session-rpc-dispatcher' -import { getSession } from '@/hermes' +import { useProfileSwitchContinuity } from '@/app/contrib/hooks/use-profile-switch-continuity' +import { sessionRoute } from '@/app/routes' +import { getSession, type SidebarSessionsRequest, type SidebarSessionsResponse } from '@/hermes' import { activeGateway, activeGatewayConnectionId, @@ -19,10 +21,16 @@ import { $newChatConnectionId, $newChatProfile, $newChatRoute, + $freshSessionRequest, ensureGatewayAgent, newSessionInProfile, selectProfile } from '@/store/profile' +import { + $profileSwitchRestoreToken, + _resetProfileSwitchBehaviorForTests, + setProfileSwitchBehavior +} from '@/store/profile-switch-behavior' import { $activeSessionId, $connection, @@ -37,11 +45,13 @@ import { setBusy, setConnection, setMessages, + setRememberedSessionId, setSelectedStoredSessionId, setSessions } from '@/store/session' import { foregroundSessionScopes } from '@/store/session-states' import type { SessionInfo } from '@/types/hermes' +import { makeSessionInfo } from '@/test/session-info' import type { ClientSessionState } from '../../types' @@ -49,6 +59,7 @@ import { usePromptActions } from './use-prompt-actions' import { clearSingleFlightSessionResumeState } from './use-prompt-actions/single-flight-resume' import type { SubmitTextOptions } from './use-prompt-actions/utils' import { useSessionActions } from './use-session-actions' +import { useSessionListActions } from './use-session-list-actions' import { useSessionStateCache } from './use-session-state-cache' // ── The real profile-rail reproduction (#94071, Sessions mode) ─────────────── @@ -109,6 +120,9 @@ let ownerPort = OMAR_PORT * (the hint map is module state) can never satisfy another's assertions. */ let mintedRuntimeId = RUNTIME_ID let mintedStoredId = STORED_ID +const listSidebarSessions = vi.hoisted(() => + vi.fn<(request: SidebarSessionsRequest) => Promise>() +) const sessionScoped = (params: unknown) => typeof (params as { session_id?: unknown } | undefined)?.session_id === 'string' @@ -199,6 +213,8 @@ vi.mock('@/hermes', async importOriginal => ({ getSession: vi.fn(async () => { throw new Error('REST cross-profile probe must not be needed: the owner is known') }), + getCronJobs: vi.fn(async () => []), + listSidebarSessions: (request: SidebarSessionsRequest) => listSidebarSessions(request), setApiRequestConnection: vi.fn(), setApiRequestProfile: vi.fn() })) @@ -265,19 +281,38 @@ interface HarnessHandle { ) => ClientSessionState } +interface ContinuityHarnessOptions { + initialPathname: string + onNavigate: (pathname: string) => void +} + /** The window's real hook stack, wired the way contrib/wiring wires it. */ function Harness({ ambientRequest, + continuity, onReady }: { ambientRequest: MockGateway['request'] + continuity?: ContinuityHarnessOptions onReady: (h: HarnessHandle) => void }) { const activeSessionId = useStore($activeSessionId) + const activeGatewayProfile = useStore($activeGatewayProfile) + const connection = useStore($connection) + const freshSessionRequest = useStore($freshSessionRequest) const selectedStoredSessionId = useStore($selectedStoredSessionId) + const [locationPathname, setLocationPathname] = useState(continuity?.initialPathname ?? '/') const busyRef = useRef(false) const creatingSessionRef = useRef(false) - + const lastFreshSessionRequestRef = useRef(freshSessionRequest) + const { refreshSessions: refreshContinuitySessions } = useSessionListActions({ profileScope: activeGatewayProfile }) + const navigate = useCallback( + (pathname: string) => { + setLocationPathname(pathname) + continuity?.onNavigate(pathname) + }, + [continuity] + ) const cache = useSessionStateCache({ activeSessionId, busyRef, @@ -311,7 +346,7 @@ function Harness({ ensureSessionState: cache.ensureSessionState, getRouteToken: () => 'token', getRoutedStoredSessionId: () => null, - navigate: vi.fn() as never, + navigate: navigate as never, requestGateway, resetViewSync: cache.resetViewSync, runtimeIdByStoredSessionIdRef: cache.runtimeIdByStoredSessionIdRef, @@ -345,6 +380,25 @@ function Harness({ const { submitText } = promptActions + useEffect(() => { + if (freshSessionRequest === lastFreshSessionRequestRef.current) { + return + } + + lastFreshSessionRequestRef.current = freshSessionRequest + sessionActions.startFreshSessionDraft({ profileSwitchRequestSequence: freshSessionRequest }) + }, [freshSessionRequest, sessionActions.startFreshSessionDraft]) + + useProfileSwitchContinuity({ + activeProfile: activeGatewayProfile, + descriptorConnectionId: connection?.connectionId ?? null, + descriptorProfile: connection?.profile ?? null, + locationPathname, + navigate, + profileReady: true, + refreshSessions: refreshContinuitySessions + }) + useEffect(() => { onReady({ busyRef, @@ -375,6 +429,12 @@ describe('profile rail: a fresh Omar chat keeps its exact registry owner across ownerPort = OMAR_PORT mintedRuntimeId = RUNTIME_ID mintedStoredId = STORED_ID + listSidebarSessions.mockReset() + listSidebarSessions.mockResolvedValue({ + cron: { sessions: [] }, + messaging: { sessions: [] }, + recents: { sessions: [] } + }) clearSingleFlightSessionResumeState() // Wired exactly as useGatewayBoot: the published active descriptor carries // a registry-backed primary's source identity across a renderer reload. @@ -395,6 +455,7 @@ describe('profile rail: a fresh Omar chat keeps its exact registry owner across $newChatProfile.set(null) $newChatRoute.set(null) $newChatConnectionId.set(null) + _resetProfileSwitchBehaviorForTests() _resetSessionOwnerHintsForTests({ storage: true }) }) @@ -408,11 +469,79 @@ describe('profile rail: a fresh Omar chat keeps its exact registry owner across $newChatProfile.set(null) $newChatRoute.set(null) $newChatConnectionId.set(null) + setRememberedSessionId(null, 'omar') + _resetProfileSwitchBehaviorForTests() $activeGatewayProfile.set('default') vi.clearAllMocks() delete (window as unknown as { hermesDesktop?: unknown }).hermesDesktop }) + it('drives selectProfile through fresh publication, exact activation, guarded refresh, and native restore', async () => { + const sourceSessionId = 'source-session' + const targetSessionId = 'omar-restored-session' + const navigation: string[] = [] + const primary = makePrimary() + const desktop = window.hermesDesktop! + const getConnectionFor = desktop.getConnectionFor! + + desktop.getConnectionFor = vi.fn(async (route: { connectionId: string; profile: string }) => ({ + ...(await getConnectionFor(route)), + connectionId: route.connectionId, + mode: 'remote' as const, + profile: route.profile, + registryScoped: true + })) + + setPrimaryGateway(primary as never, 'default') + setConnection({ connectionId: SOURCE_ID, mode: 'remote', profile: 'default' } as never) + setSessions([makeSessionInfo({ connection_id: SOURCE_ID, id: sourceSessionId, profile: 'default' })]) + listSidebarSessions.mockResolvedValue({ + cron: { sessions: [] }, + messaging: { sessions: [] }, + recents: { + profiles_truncated: { omar: false }, + profiles_usage: { omar: { cost_usd: 0, tokens: 0 } }, + sessions: [makeSessionInfo({ connection_id: SOURCE_ID, id: targetSessionId, profile: 'omar' })] + } + }) + setSelectedStoredSessionId(sourceSessionId) + setRememberedSessionId(targetSessionId, 'omar') + setProfileSwitchBehavior('restore_last_session') + + render( + navigation.push(pathname) + }} + onReady={() => undefined} + /> + ) + + await act(async () => undefined) + selectProfile('omar') + + await waitFor(() => expect(activeGatewayProfileKey()).toBe('omar')) + await waitFor(() => expect(navigation).toContain(sessionRoute(targetSessionId))) + + expect(navigation[0]).toBe('/') + expect(navigation.at(-1)).toBe(sessionRoute(targetSessionId)) + expect(listSidebarSessions).toHaveBeenCalledOnce() + expect(listSidebarSessions).toHaveBeenCalledWith( + expect.objectContaining({ + recentsProfile: 'omar' + }) + ) + expect($sessions.get().find(session => session.id === targetSessionId)).toMatchObject({ + connection_id: SOURCE_ID, + profile: 'omar' + }) + expect($profileSwitchRestoreToken.get()).toBeNull() + expect(activeGatewayConnectionId()).toBe(SOURCE_ID) + expect($connection.get()?.connectionId).toBe(SOURCE_ID) + }) + /** Boot the exact field state: remote primary on `default`, `homelab` as * the active registry source, then selectProfile("omar") in the rail; mount * the window's real hook stack over the production dispatcher. */ 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 fca3f3134677e..c962a6805ab7c 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 @@ -25,6 +25,11 @@ import { clearSessionDraft, stashSessionDraft, takeSessionDraft } from '@/store/ import { requestGatewayForAgent, requestGatewayForProfile } from '@/store/gateway' import { $pinnedSessionIds } from '@/store/layout' import { $activeGatewayProfile, $newChatProfile, $newChatRoute, $profiles, ensureGatewayProfile } from '@/store/profile' +import { + $profileSwitchRestoreToken, + _resetProfileSwitchBehaviorForTests, + requestProfileSwitchRestore +} from '@/store/profile-switch-behavior' import { $projectScope, $projectTree, @@ -513,7 +518,23 @@ async function createWith( } describe('startFreshSessionDraft', () => { - afterEach(() => cleanup()) + afterEach(() => { + cleanup() + _resetProfileSwitchBehaviorForTests() + }) + + it('supersedes a pending profile restore even when route and focus are already fresh', async () => { + const requestGateway = vi.fn(async () => ({}) as never) + let handle: HarnessHandle | null = null + + requestProfileSwitchRestore('beta', null, 1) + render( (handle = value)} requestGateway={requestGateway} />) + await waitFor(() => expect(handle).not.toBeNull()) + + act(() => handle!.startFreshSessionDraft()) + + expect($profileSwitchRestoreToken.get()).toBeNull() + }) it('can reset machine-bound session state without closing the current overlay route', async () => { const navigate = vi.fn() 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 a7b3446c38edc..e9cce3ec05cb5 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 @@ -37,6 +37,7 @@ import { import { $gatewaySwitching } from '@/store/gateway-switch' import { $pinnedSessionIds } from '@/store/layout' import { clearNotifications, notify, notifyError } from '@/store/notifications' +import { supersedeProfileSwitchRestoreForFreshDraft } from '@/store/profile-switch-behavior' import { $activeGatewayProfile, $gatewaySwapTarget, @@ -274,6 +275,7 @@ async function desktopSessionCreateParams( interface FreshSessionDraftOptions { preserveRoute?: boolean + profileSwitchRequestSequence?: number replaceRoute?: boolean workspaceTarget?: NewChatWorkspaceTarget } @@ -391,6 +393,8 @@ export function useSessionActions({ const preserveRoute = draftOptions.preserveRoute ?? false const replaceRoute = draftOptions.replaceRoute ?? false + supersedeProfileSwitchRestoreForFreshDraft(draftOptions.profileSwitchRequestSequence) + const hasWorkspaceTarget = Object.hasOwn(draftOptions, 'workspaceTarget') && draftOptions.workspaceTarget !== undefined diff --git a/apps/desktop/src/app/session/hooks/use-session-list-actions.test.tsx b/apps/desktop/src/app/session/hooks/use-session-list-actions.test.tsx index 8a953a043a31e..8e9698fdf4952 100644 --- a/apps/desktop/src/app/session/hooks/use-session-list-actions.test.tsx +++ b/apps/desktop/src/app/session/hooks/use-session-list-actions.test.tsx @@ -5,6 +5,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { SessionInfo, SidebarSessionsResponse } from '@/hermes' import { $cronJobs, setCronJobs } from '@/store/cron' import { + $gatewaySwitching, beginGatewaySwitch, endGatewaySwitch, recoverActiveSourceAfterFailedGatewaySwitch, @@ -265,7 +266,7 @@ describe('refreshSessions identity + loading hygiene', () => { expect(loadingStates).toEqual([false, true, false]) }) - it('does not let a superseded owner publish or release a newer switch loading barrier', async () => { + it('releases its loading barrier when a guarded refresh is cancelled without a successor', async () => { const pending = deferred() let ownsRefresh = true @@ -277,47 +278,76 @@ describe('refreshSessions identity + loading hygiene', () => { expect($sessionsLoading.get()).toBe(true) ownsRefresh = false - setSessions([row('winner')]) - setCronSessions([row('winner-cron', { source: 'cron' })]) - setMessagingSessions([row('winner-message', { source: 'signal' })]) - setMessagingTruncated(true) - setSessionProfilesTruncated({ winner: true }) - setSessionProfilesUsage({ winner: { cost_usd: 2, tokens: 20 } }) - setSessionsLoading(true) - - await act(async () => { - pending.resolve({ - recents: { - profiles_truncated: { stale: true }, - profiles_usage: { stale: { cost_usd: 1, tokens: 10 } }, - sessions: [row('stale')] - }, - cron: { sessions: [row('stale-cron', { source: 'cron' })] }, - messaging: { sessions: [row('stale-message', { source: 'telegram' })] } - }) - await refresh + + let committed: boolean | undefined + + await act(async () => { + pending.resolve(sidebar({ sessions: [] })) + committed = await refresh }) - expect($sessions.get().map(session => session.id)).toEqual(['winner']) - expect($cronSessions.get().map(session => session.id)).toEqual(['winner-cron']) - expect($messagingSessions.get().map(session => session.id)).toEqual(['winner-message']) - expect($messagingTruncated.get()).toBe(true) - expect($sessionProfilesTruncated.get()).toEqual({ winner: true }) - expect($sessionProfilesUsage.get()).toEqual({ winner: { cost_usd: 2, tokens: 20 } }) + expect(committed).toBe(false) + expect($sessions.get()).toEqual([]) + expect($sessionsLoading.get()).toBe(false) + expect(getCronJobs).not.toHaveBeenCalled() + }) + + it('does not let a superseded request publish or release a newer refresh loading barrier', async () => { + const stale = deferred() + const successor = deferred() + let ownsStaleRefresh = true + + listSidebarSessions.mockReturnValueOnce(stale.promise).mockReturnValueOnce(successor.promise) + + const { result } = renderHook(() => useSessionListActions({ profileScope: 'default' })) + const staleRefresh = result.current.refreshSessions(() => ownsStaleRefresh) + + expect($sessionsLoading.get()).toBe(true) + + ownsStaleRefresh = false + const successorRefresh = result.current.refreshSessions() + + expect(listSidebarSessions).toHaveBeenCalledTimes(2) + + let staleCommitted: boolean | undefined + + await act(async () => { + stale.resolve(sidebar({ sessions: [row('stale')] })) + staleCommitted = await staleRefresh + }) + + expect(staleCommitted).toBe(false) + expect($sessions.get()).toEqual([]) expect($sessionsLoading.get()).toBe(true) expect(getCronJobs).not.toHaveBeenCalled() + + let successorCommitted: boolean | undefined + + await act(async () => { + successor.resolve(sidebar({ sessions: [row('winner')] })) + successorCommitted = await successorRefresh + }) + + expect(successorCommitted).toBe(true) + expect($sessions.get().map(session => session.id)).toEqual(['winner']) + expect($sessionsLoading.get()).toBe(false) }) it('keeps failed-switch recovery from publishing through a newer switch', async () => { - const pending = deferred() + const recovery = deferred() + const successor = deferred() - listSidebarSessions.mockReturnValue(pending.promise) + listSidebarSessions.mockReturnValueOnce(recovery.promise).mockReturnValueOnce(successor.promise) const { result } = renderHook(() => useSessionListActions({ profileScope: 'default' })) + let recoveryRefresh!: Promise const off = registerGatewaySwitchLifecycle({ beforeConnectionSwitch: () => undefined, - refreshSessions: result.current.refreshSessions + refreshSessions: async shouldPublish => { + recoveryRefresh = result.current.refreshSessions(shouldPublish) + await recoveryRefresh + } }) let newer: number | undefined @@ -332,9 +362,10 @@ describe('refreshSessions identity + loading hygiene', () => { // A newer switch owns the freshly wiped lists and loading barrier while // the failed switch's real sidebar publisher is still in flight. newer = beginGatewaySwitch() + const successorRefresh = result.current.refreshSessions() await act(async () => { - pending.resolve({ + recovery.resolve({ recents: { profiles_truncated: { stale: true }, profiles_usage: { stale: { cost_usd: 1, tokens: 10 } }, @@ -343,7 +374,7 @@ describe('refreshSessions identity + loading hygiene', () => { cron: { sessions: [row('stale-cron', { source: 'cron' })] }, messaging: { sessions: [row('stale-message', { source: 'telegram' })] } }) - await pending.promise + await recoveryRefresh }) expect($sessions.get()).toEqual([]) @@ -355,35 +386,98 @@ describe('refreshSessions identity + loading hygiene', () => { expect($sessionsLoading.get()).toBe(true) expect($cronJobs.get()).toEqual([]) expect(getCronJobs).not.toHaveBeenCalled() + + endGatewaySwitch(newer) + expect($gatewaySwitching.get()).toBe(false) + + await act(async () => { + successor.resolve(sidebar({ sessions: [row('winner')] })) + await successorRefresh + }) + + expect($sessions.get().map(session => session.id)).toEqual(['winner']) + expect($sessionsLoading.get()).toBe(false) } finally { endGatewaySwitch(newer) off() } }) + it('does not let a stale pre-switch request lower the real gateway-switch barrier', async () => { + const stale = deferred() + const recovery = deferred() + + listSidebarSessions.mockReturnValueOnce(stale.promise).mockReturnValueOnce(recovery.promise) + + const { result } = renderHook(() => useSessionListActions({ profileScope: 'default' })) + const staleRefresh = result.current.refreshSessions() + let recoveryRefresh!: Promise + + const off = registerGatewaySwitchLifecycle({ + beforeConnectionSwitch: () => undefined, + refreshSessions: async shouldPublish => { + recoveryRefresh = result.current.refreshSessions(shouldPublish) + await recoveryRefresh + } + }) + + let switchToken: number | undefined + + try { + switchToken = beginGatewaySwitch() + gatewayScope.epoch += 1 + + expect($gatewaySwitching.get()).toBe(true) + + await act(async () => { + stale.resolve(sidebar({ sessions: [row('stale')] })) + await staleRefresh + }) + + // The activation epoch rejects the pre-switch data, while the canonical + // switch barrier retains loading ownership until lifecycle recovery. + expect($sessions.get()).toEqual([]) + expect($sessionsLoading.get()).toBe(true) + + endGatewaySwitch(switchToken) + recoverActiveSourceAfterFailedGatewaySwitch(switchToken) + await vi.waitFor(() => expect(listSidebarSessions).toHaveBeenCalledTimes(2)) + + await act(async () => { + recovery.resolve(sidebar({ sessions: [row('winner')] })) + await recoveryRefresh + }) + + expect($sessions.get().map(session => session.id)).toEqual(['winner']) + expect($sessionsLoading.get()).toBe(false) + } finally { + endGatewaySwitch(switchToken) + off() + } + }) + it('clears initial loading after a failed source activation advances the gateway epoch', async () => { const pending = deferred() listSidebarSessions.mockReturnValue(pending.promise) const { result } = renderHook(() => useSessionListActions({ profileScope: 'default' })) - - let refresh!: Promise - - act(() => { - refresh = result.current.refreshSessions() - }) + const refresh = result.current.refreshSessions() expect($sessionsLoading.get()).toBe(true) // A source dial owns a new activation epoch even when it fails and leaves // the previous source active. Its in-flight session response is stale, but - // it still owns the initial loading state and must release that state. + // no connection switch took ownership of the initial loading state. gatewayScope.epoch += 1 + expect($gatewaySwitching.get()).toBe(false) + + let committed: boolean | undefined await act(async () => { pending.resolve(sidebar({ sessions: [row('stale')] })) - await refresh + committed = await refresh }) + expect(committed).toBe(false) expect($sessions.get()).toEqual([]) expect($sessionsLoading.get()).toBe(false) }) @@ -399,11 +493,14 @@ describe('refreshSessions batches slices into one request', () => { const { result } = renderHook(() => useSessionListActions({ profileScope: 'default' })) + let committed: boolean | undefined + await act(async () => { - await result.current.refreshSessions() + committed = await result.current.refreshSessions() }) // One batched call, not three separate listAllProfileSessions reads. + expect(committed).toBe(true) expect(listSidebarSessions).toHaveBeenCalledTimes(1) expect(listAllProfileSessions).not.toHaveBeenCalled() @@ -441,16 +538,19 @@ describe('refreshSessions batches slices into one request', () => { rerender({ profileScope: 'personal' }) + let committed: boolean | undefined + await act(async () => { - await staleRefresh() + committed = await staleRefresh() }) + expect(committed).toBe(false) expect(listSidebarSessions).not.toHaveBeenCalled() }) it('keeps the committed profile active when a later render is discarded', async () => { const never = new Promise(() => undefined) - let committedRefresh: (() => Promise) | undefined + let committedRefresh: (() => Promise) | undefined /** Expose only callbacks from committed renders; suspended renders are discarded. */ function Harness({ profileScope, suspend }: { profileScope: string; suspend: boolean }) { diff --git a/apps/desktop/src/app/session/hooks/use-session-list-actions.ts b/apps/desktop/src/app/session/hooks/use-session-list-actions.ts index 255e85189b882..a11793cc9dab3 100644 --- a/apps/desktop/src/app/session/hooks/use-session-list-actions.ts +++ b/apps/desktop/src/app/session/hooks/use-session-list-actions.ts @@ -9,6 +9,7 @@ import { normalizeSessionSource } from '@/lib/session-source' import { gatewayActivationEpoch } from '@/store/gateway' +import { $gatewaySwitching } from '@/store/gateway-switch' import { $pinnedSessionIds, $sessionsLimit, @@ -225,16 +226,17 @@ export function useSessionListActions({ profileScope }: UseSessionListActionsArg /** Refresh every sidebar session slice without committing an obsolete profile response. */ const refreshSessions = useCallback( - async (shouldPublish: () => boolean = () => true) => { + async (shouldPublish: () => boolean = () => true): Promise => { const sessionProfile = sidebarProfileForScope(profileScope) const activationEpoch = gatewayActivationEpoch() if (!shouldPublish() || sidebarProfileForScope(profileScopeRef.current) !== sessionProfile) { - return + return false } const requestId = refreshSessionsRequestRef.current + 1 refreshSessionsRequestRef.current = requestId + let committed = false // The loading flag exists to drive the initial skeletons (they only render // while the list is empty). Turn-complete / reconnect refreshes over a // populated list used to flip it true→false anyway, churning every @@ -332,12 +334,14 @@ export function useSessionListActions({ profileScope }: UseSessionListActionsArg setMessagingSessions(prev => (sameCronSignature(prev, messagingRows) ? prev : messagingRows)) // Hit the cap → at least one platform may have more on disk than loaded. setMessagingTruncated(result.messaging.sessions.length >= MESSAGING_SECTION_LIMIT) + committed = true } } finally { - // Request identity preserves the zero-argument refresh contract across a - // failed activation epoch; an explicit owner predicate is stronger and - // must never release a newer switch's loading barrier. - if (showLoading && shouldPublish() && refreshSessionsRequestRef.current === requestId) { + // Publication freshness and loading ownership are separate. A newer + // request takes loading ownership by request id; a connection switch + // takes it through the shared switch barrier. An activation epoch bump + // only makes this request's data stale. + if (showLoading && refreshSessionsRequestRef.current === requestId && !$gatewaySwitching.get()) { setSessionsLoading(false) } } @@ -346,6 +350,8 @@ export function useSessionListActions({ profileScope }: UseSessionListActionsArg if (shouldPublish() && sidebarProfileForScope(profileScopeRef.current) === sessionProfile) { void refreshCronJobs() } + + return committed }, [profileScope, refreshCronJobs] ) diff --git a/apps/desktop/src/app/settings/appearance-settings.tsx b/apps/desktop/src/app/settings/appearance-settings.tsx index 238978fb9b530..9466b61f8825b 100644 --- a/apps/desktop/src/app/settings/appearance-settings.tsx +++ b/apps/desktop/src/app/settings/appearance-settings.tsx @@ -18,6 +18,11 @@ import { $composerPopoutGesturesEnabled, setComposerPopoutGesturesEnabled } from import { $embedAllowed, $embedMode, clearEmbedAllowed, type EmbedMode, setEmbedMode } from '@/store/embed-consent' import { $introSplash, setIntroSplash } from '@/store/intro-splash' import { $activeGatewayProfile, $profiles, normalizeProfileKey } from '@/store/profile' +import { + $profileSwitchBehavior, + type ProfileSwitchBehavior, + setProfileSwitchBehavior +} from '@/store/profile-switch-behavior' import { $reactionsEnabled, setReactionsEnabled } from '@/store/reactions-enabled' import { $reasoningCollapsedByDefault, setReasoningCollapsedByDefault } from '@/store/reasoning-disclosure' import { $sessionListDensity, type SessionListDensity, setSessionListDensity } from '@/store/session-list-density' @@ -356,6 +361,7 @@ export function AppearanceSettings() { const glassMode = translucency.mode === 'glass' && GLASS_SUPPORTED const reactionsEnabled = useStore($reactionsEnabled) const vibeHeartsEnabled = useStore($vibeHeartsEnabled) + const profileSwitchBehavior = useStore($profileSwitchBehavior) const backdrop = useStore($backdrop) const introSplash = useStore($introSplash) const installs = useStore($marketplaceInstalls) @@ -730,6 +736,27 @@ export function AppearanceSettings() { title={a.introSplashTitle} /> + {profiles.length > 1 && ( + + onChange={id => { + triggerHaptic('selection') + setProfileSwitchBehavior(id) + }} + options={[ + { id: 'fresh_draft', label: a.profileSwitchFresh }, + { id: 'restore_last_session', label: a.profileSwitchRestore } + ]} + value={profileSwitchBehavior} + /> + } + description={a.profileSwitchDesc} + id={appearanceSettingElementId(APPEARANCE_SETTING_IDS.profileSwitch)} + title={a.profileSwitchTitle} + /> + )} + { if (enabled && gatewayState === 'open') { @@ -169,6 +171,22 @@ export function useSettingsSearchCatalog(enabled: boolean) { label: appearance.introSplashTitle, target: { setting: APPEARANCE_SETTING_IDS.introSplash, view: 'config:appearance' } }, + ...(profiles.length > 1 + ? [ + { + context: appearanceContext, + description: appearance.profileSwitchDesc, + icon: Palette, + id: `setting:${APPEARANCE_SETTING_IDS.profileSwitch}`, + keywords: ['profile', 'switch', 'session', 'restore', 'fresh draft'], + label: appearance.profileSwitchTitle, + target: { + setting: APPEARANCE_SETTING_IDS.profileSwitch, + view: 'config:appearance' as const + } + } + ] + : []), { context: appearanceContext, description: appearance.toolViewDesc, diff --git a/apps/desktop/src/i18n/ar.ts b/apps/desktop/src/i18n/ar.ts index f658ca540cbf6..3895599ff79f4 100644 --- a/apps/desktop/src/i18n/ar.ts +++ b/apps/desktop/src/i18n/ar.ts @@ -469,6 +469,11 @@ export const ar = defineLocale({ introSplashDesc: 'الشعار النصي والعبارة التمهيدية في محادثة فارغة.', reactionsTitle: 'تفاعلات الرسائل', reactionsDesc: 'تفاعلات إيموجي بأسلوب iMessage — تفاعل مع الرسائل، ويمكن لـ Hermes التفاعل مع رسائلك.', + profileSwitchTitle: 'تبديل الملف الشخصي', + profileSwitchDesc: + 'ينطبق على عميل سطح المكتب هذا فقط. يحافظ «مسودة جديدة» على سلوك Hermes الأصلي، بينما تعيد «الجلسة الأخيرة» فتح آخر جلسة مرئية عند العودة.', + profileSwitchFresh: 'مسودة جديدة', + profileSwitchRestore: 'الجلسة الأخيرة', composerPopoutTitle: 'محرر عائم', composerPopoutDesc: 'السماح بسحب محرر الرسائل خارج موضعه. عطّل هذا الخيار لإبقائه مثبتًا في الأسفل.', vibeHeartsTitle: 'قلوب المزاج', diff --git a/apps/desktop/src/i18n/en.ts b/apps/desktop/src/i18n/en.ts index a3c7fb1ce0ae8..c205839940f76 100644 --- a/apps/desktop/src/i18n/en.ts +++ b/apps/desktop/src/i18n/en.ts @@ -588,6 +588,11 @@ export const en: Translations = { introSplashDesc: 'The wordmark and prompt shown on an empty chat.', reactionsTitle: 'Message Reactions', reactionsDesc: 'iMessage-style emoji tapbacks — react to messages, and Hermes can react to yours.', + profileSwitchTitle: 'Profile switching', + profileSwitchDesc: + 'Applies only on this Desktop client. Start fresh keeps native Hermes behavior; Restore last session returns to the session you last viewed for that profile.', + profileSwitchFresh: 'Start fresh', + profileSwitchRestore: 'Restore last session', composerPopoutTitle: 'Floating Composer', composerPopoutDesc: 'Allow dragging the composer out of its dock. Turn this off to keep it locked at the bottom.', vibeHeartsTitle: 'Vibe Hearts', diff --git a/apps/desktop/src/i18n/ja.ts b/apps/desktop/src/i18n/ja.ts index d4131559edffa..28d2533c39499 100644 --- a/apps/desktop/src/i18n/ja.ts +++ b/apps/desktop/src/i18n/ja.ts @@ -411,6 +411,11 @@ export const ja = defineLocale({ reactionsTitle: 'メッセージリアクション', reactionsDesc: 'iMessage風の絵文字タップバック — メッセージにリアクションでき、Hermesもあなたのメッセージにリアクションします。', + profileSwitchTitle: 'プロフィール切り替え', + profileSwitchDesc: + 'このデスクトップクライアントにのみ適用されます。「新しい下書き」は Hermes の標準動作を維持し、「前回のセッション」は戻ったときに最後に表示していたセッションを再び開きます。', + profileSwitchFresh: '新しい下書き', + profileSwitchRestore: '前回のセッション', composerPopoutTitle: 'フローティング入力欄', composerPopoutDesc: '入力欄をドックからドラッグして外せるようにします。オフにすると画面下部に固定されます。', vibeHeartsTitle: 'バイブハート', diff --git a/apps/desktop/src/i18n/types.ts b/apps/desktop/src/i18n/types.ts index 4c2df3174084a..a5de4fe24cbcf 100644 --- a/apps/desktop/src/i18n/types.ts +++ b/apps/desktop/src/i18n/types.ts @@ -478,6 +478,10 @@ export interface Translations { introSplashDesc: string reactionsTitle: string reactionsDesc: string + profileSwitchTitle: string + profileSwitchDesc: string + profileSwitchFresh: string + profileSwitchRestore: string composerPopoutTitle: string composerPopoutDesc: string vibeHeartsTitle: string diff --git a/apps/desktop/src/i18n/zh-hant.ts b/apps/desktop/src/i18n/zh-hant.ts index f155f4050cf6a..8fae81893c36a 100644 --- a/apps/desktop/src/i18n/zh-hant.ts +++ b/apps/desktop/src/i18n/zh-hant.ts @@ -400,6 +400,11 @@ export const zhHant = defineLocale({ introSplashDesc: '空白對話中顯示的字標和提示語。', reactionsTitle: '訊息回應', reactionsDesc: 'iMessage 風格的表情回應 — 你可以對訊息做出回應,Hermes 也能回應你的訊息。', + profileSwitchTitle: '設定檔切換', + profileSwitchDesc: + '僅適用於這個桌面客戶端。「新草稿」會保留 Hermes 的原生行為;「上次工作階段」會在返回時重新開啟最後顯示的工作階段。', + profileSwitchFresh: '新草稿', + profileSwitchRestore: '上次工作階段', composerPopoutTitle: '懸浮輸入框', composerPopoutDesc: '允許將輸入框拖出底部停靠區。關閉後,輸入框會鎖定在底部。', vibeHeartsTitle: '心情愛心', diff --git a/apps/desktop/src/i18n/zh.ts b/apps/desktop/src/i18n/zh.ts index e5326688699fe..fa829ebec52a8 100644 --- a/apps/desktop/src/i18n/zh.ts +++ b/apps/desktop/src/i18n/zh.ts @@ -574,6 +574,11 @@ export const zh: Translations = { introSplashDesc: '空白对话中显示的字标和提示语。', reactionsTitle: '消息回应', reactionsDesc: 'iMessage 风格的表情回应 — 你可以给消息添加回应,Hermes 也能回应你的消息。', + profileSwitchTitle: '配置文件切换', + profileSwitchDesc: + '仅适用于当前桌面客户端。新建会话保留 Hermes 原生行为;恢复上次会话会返回该配置文件中最后查看的会话。', + profileSwitchFresh: '新建会话', + profileSwitchRestore: '恢复上次会话', composerPopoutTitle: '悬浮输入框', composerPopoutDesc: '允许将输入框拖出底部停靠区。关闭后,输入框会锁定在底部。', vibeHeartsTitle: '心情爱心', diff --git a/apps/desktop/src/store/profile-select-source.test.ts b/apps/desktop/src/store/profile-select-source.test.ts index 703723cc0afcb..6300ff33b86fd 100644 --- a/apps/desktop/src/store/profile-select-source.test.ts +++ b/apps/desktop/src/store/profile-select-source.test.ts @@ -12,6 +12,7 @@ const ensureGatewayForProfile = vi.fn(async (_profile: string) => undefined) const ensureGatewayForAgent = vi.fn(async (_connectionId: null | string, _profile: string) => true) const openGatewayForProfile = vi.fn(async (_profile: string) => undefined) const activeGatewayConnectionId = vi.fn<() => null | string>(() => null) +const gatewayActivationEpoch = vi.fn(() => 1) const $gateway = atom({ id: 'live-socket' }) const resetStarmapGraph = vi.fn() @@ -20,6 +21,7 @@ vi.mock('@/store/gateway', () => ({ activeGatewayConnectionId, ensureGatewayForAgent, ensureGatewayForProfile, + gatewayActivationEpoch, openGatewayForProfile })) vi.mock('@/hermes', () => ({ @@ -29,21 +31,56 @@ vi.mock('@/hermes', () => ({ vi.mock('@/lib/query-client', () => ({ invalidateProfileScopedQueries: vi.fn() })) vi.mock('@/store/starmap', () => ({ resetStarmapGraph })) -const { $activeGatewayProfile, newSessionInProfile, selectProfile } = await import('./profile') +const { + $activeGatewayProfile, + $freshSessionRequest, + ensureGatewayProfile, + newSessionInProfile, + requestFreshSession, + selectProfile +} = await import('./profile') +const { + $profileSwitchRestoreToken, + _resetProfileSwitchBehaviorForTests, + requestProfileSwitchRestore, + setProfileSwitchBehavior +} = await import('./profile-switch-behavior') +const { setConnection } = await import('./session') beforeEach(() => { ensureGatewayForProfile.mockClear() ensureGatewayForAgent.mockClear() + ensureGatewayForProfile.mockImplementation(async profile => { + activeGatewayConnectionId.mockReturnValue(null) + $activeGatewayProfile.set(profile) + }) + ensureGatewayForAgent.mockImplementation(async (_connectionId, profile) => { + $activeGatewayProfile.set(profile) + + return true + }) activeGatewayConnectionId.mockReset() activeGatewayConnectionId.mockReturnValue(null) $gateway.set({ id: 'live-socket' }) $activeGatewayProfile.set('default') + setConnection(null) + _resetProfileSwitchBehaviorForTests() // resolveConnectionForAgent is best-effort; without a bridge it resolves // null and the previous descriptor stays, which is fine here. ;(globalThis as { window?: unknown }).window = {} }) describe('selectProfile', () => { + const restoreGeneration = (): number => { + const generation = $profileSwitchRestoreToken.get()?.generation + + if (generation === undefined) { + throw new Error('expected an active profile-switch restore generation') + } + + return generation + } + it('activates the pick on the live registry source, not the primary', async () => { activeGatewayConnectionId.mockReturnValue('mini') @@ -70,6 +107,85 @@ describe('selectProfile', () => { await vi.waitFor(() => expect(ensureGatewayForProfile).toHaveBeenCalledWith('override-profile')) expect(ensureGatewayForAgent).not.toHaveBeenCalled() }) + + it('binds a profile-only restore to the remote override that actually activated', async () => { + activeGatewayConnectionId.mockReturnValue(null) + ensureGatewayForProfile.mockImplementationOnce(async profile => { + $activeGatewayProfile.set(profile) + setConnection({ connectionId: 'remote-override', mode: 'remote', profile } as never) + }) + setProfileSwitchBehavior('restore_last_session') + + selectProfile('ops') + + await vi.waitFor(() => expect($profileSwitchRestoreToken.get()?.activation?.activationEpoch).toBe(1)) + expect($profileSwitchRestoreToken.get()).toMatchObject({ + activation: { + descriptorConnectionId: 'remote-override', + liveGatewayConnectionId: null, + profile: 'ops' + }, + requestedConnectionId: null + }) + }) + + it('reverses an owned A -> B restore intent back to A behind the serialized activation', async () => { + let releaseBeta!: () => void + + setProfileSwitchBehavior('restore_last_session') + ensureGatewayForProfile.mockImplementationOnce( + profile => + new Promise(resolve => { + releaseBeta = () => { + $activeGatewayProfile.set(profile) + resolve(undefined) + } + }) + ) + + selectProfile('beta') + await vi.waitFor(() => expect(ensureGatewayForProfile).toHaveBeenCalledWith('beta')) + const betaGeneration = restoreGeneration() + + selectProfile('default') + const alphaGeneration = restoreGeneration() + + expect(alphaGeneration).toBeGreaterThan(betaGeneration) + expect(ensureGatewayForProfile).toHaveBeenCalledTimes(1) + + releaseBeta() + + await vi.waitFor(() => expect(ensureGatewayForProfile).toHaveBeenNthCalledWith(2, 'default')) + await vi.waitFor(() => expect($activeGatewayProfile.get()).toBe('default')) + expect($profileSwitchRestoreToken.get()).toMatchObject({ + activation: { profile: 'default' }, + generation: alphaGeneration, + requestedProfile: 'default' + }) + }) + + it('does not turn a same-profile click into a switch because another activation is in flight', async () => { + let release!: () => void + ensureGatewayForProfile.mockImplementationOnce( + () => + new Promise(resolve => { + release = () => resolve(undefined) + }) + ) + + const unrelatedActivation = ensureGatewayProfile('background') + await vi.waitFor(() => expect(ensureGatewayForProfile).toHaveBeenCalledWith('background')) + const freshGeneration = $freshSessionRequest.get() + + selectProfile('default') + + expect($freshSessionRequest.get()).toBe(freshGeneration) + expect($profileSwitchRestoreToken.get()).toBeNull() + + release() + await unrelatedActivation + expect(ensureGatewayForProfile).toHaveBeenCalledTimes(1) + }) }) describe('newSessionInProfile', () => { @@ -90,6 +206,29 @@ describe('newSessionInProfile', () => { await vi.waitFor(() => expect(ensureGatewayForProfile).toHaveBeenCalledWith('override-profile')) expect(ensureGatewayForAgent).not.toHaveBeenCalled() }) + + it('supersedes a pending restore even when the fresh route and focus do not change', async () => { + setProfileSwitchBehavior('restore_last_session') + selectProfile('pending') + const freshBefore = $freshSessionRequest.get() + + newSessionInProfile('designer') + + expect($profileSwitchRestoreToken.get()).toBeNull() + expect($freshSessionRequest.get()).toBe(freshBefore + 1) + }) +}) + +describe('fresh request generations', () => { + it('does not let an old profile-switch request clear or advance a newer generation', () => { + const old = requestProfileSwitchRestore('beta', null, $freshSessionRequest.get() + 1) + const current = requestProfileSwitchRestore('gamma', null, $freshSessionRequest.get() + 1) + const sequence = $freshSessionRequest.get() + + expect(requestFreshSession(old.generation)).toBe(sequence) + expect($freshSessionRequest.get()).toBe(sequence) + expect($profileSwitchRestoreToken.get()?.generation).toBe(current.generation) + }) }) describe('selectProfile startup preference (#79886)', () => { @@ -127,7 +266,10 @@ describe('selectProfile startup preference (#79886)', () => { ensureGatewayForProfile.mockImplementationOnce( () => new Promise(resolve => { - resolveGateway = () => resolve(undefined) + resolveGateway = () => { + $activeGatewayProfile.set('tilly') + resolve(undefined) + } }) ) diff --git a/apps/desktop/src/store/profile-switch-behavior.test.ts b/apps/desktop/src/store/profile-switch-behavior.test.ts new file mode 100644 index 0000000000000..1cd5e964c23f9 --- /dev/null +++ b/apps/desktop/src/store/profile-switch-behavior.test.ts @@ -0,0 +1,235 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import { + $profileSwitchBehavior, + $profileSwitchRestoreToken, + _resetProfileSwitchBehaviorForTests, + bindProfileSwitchRestore, + clearProfileSwitchRestore, + getProfileSwitchBehavior, + isCurrentProfileSwitchGeneration, + observeProfileSwitchRestoreNavigation, + registerProfileSwitchAnchorCapture, + requestProfileSwitchRestore, + setProfileSwitchBehavior, + supersedeProfileSwitchRestoreForFreshDraft +} from './profile-switch-behavior' + +describe('profile switch behavior', () => { + const sourcePaneFocus = { groupId: 'source-zone', paneId: 'session-tile:source-tab' } + const workspacePaneFocus = { groupId: null, paneId: 'workspace' } + + beforeEach(() => { + localStorage.clear() + _resetProfileSwitchBehaviorForTests() + }) + + afterEach(() => { + localStorage.clear() + _resetProfileSwitchBehaviorForTests() + }) + + it('defaults to and persists the fresh-draft preference', () => { + expect(getProfileSwitchBehavior()).toBe('fresh_draft') + + setProfileSwitchBehavior('restore_last_session') + + expect($profileSwitchBehavior.get()).toBe('restore_last_session') + expect(localStorage.getItem('hermes.desktop.profile-switch-behavior.v1')).toBe('restore_last_session') + }) + + it('allows the native source -> null selection -> fresh-route publication, then rejects a newer request', () => { + const capture = vi.fn(() => ({ + focusedStoredSessionId: 'source-tab', + paneFocus: sourcePaneFocus, + pathname: '/session/source' + })) + const unregister = registerProfileSwitchAnchorCapture(capture) + const token = requestProfileSwitchRestore('beta', null, 7) + + expect(capture).toHaveBeenCalledOnce() + expect( + observeProfileSwitchRestoreNavigation( + token.generation, + { focusedStoredSessionId: 'source-tab', paneFocus: sourcePaneFocus, pathname: '/session/source' }, + 7 + ) + ).toBe(false) + expect($profileSwitchRestoreToken.get()?.generation).toBe(token.generation) + + expect( + observeProfileSwitchRestoreNavigation( + token.generation, + { focusedStoredSessionId: null, paneFocus: workspacePaneFocus, pathname: '/session/source' }, + 7 + ) + ).toBe(false) + expect($profileSwitchRestoreToken.get()?.generation).toBe(token.generation) + + expect( + observeProfileSwitchRestoreNavigation( + token.generation, + { focusedStoredSessionId: null, paneFocus: workspacePaneFocus, pathname: '/' }, + 7 + ) + ).toBe(true) + expect($profileSwitchRestoreToken.get()?.generation).toBe(token.generation) + expect( + observeProfileSwitchRestoreNavigation( + token.generation, + { focusedStoredSessionId: null, paneFocus: workspacePaneFocus, pathname: '/' }, + 7 + ) + ).toBe(true) + + expect( + observeProfileSwitchRestoreNavigation( + token.generation, + { focusedStoredSessionId: null, paneFocus: workspacePaneFocus, pathname: '/' }, + 8 + ) + ).toBe(false) + expect($profileSwitchRestoreToken.get()).toBeNull() + unregister() + }) + + it('cancels when the initial fresh route is focused outside the workspace', () => { + const unregister = registerProfileSwitchAnchorCapture(() => ({ + focusedStoredSessionId: 'source-tab', + paneFocus: sourcePaneFocus, + pathname: '/session/source' + })) + const token = requestProfileSwitchRestore('beta', null, 7) + + expect( + observeProfileSwitchRestoreNavigation( + token.generation, + { focusedStoredSessionId: null, paneFocus: { groupId: 'native-zone', paneId: 'terminal' }, pathname: '/' }, + 7 + ) + ).toBe(false) + expect($profileSwitchRestoreToken.get()).toBeNull() + unregister() + }) + + it('cancels when a non-session pane takes focus during the native intermediate', () => { + const unregister = registerProfileSwitchAnchorCapture(() => ({ + focusedStoredSessionId: 'source-tab', + paneFocus: sourcePaneFocus, + pathname: '/session/source' + })) + const token = requestProfileSwitchRestore('beta', null, 7) + + expect( + observeProfileSwitchRestoreNavigation( + token.generation, + { + focusedStoredSessionId: null, + paneFocus: { groupId: 'native-zone', paneId: 'terminal' }, + pathname: '/session/source' + }, + 7 + ) + ).toBe(false) + expect($profileSwitchRestoreToken.get()).toBeNull() + unregister() + }) + + it.each([ + ['named local profile', null, 'local', 'beta'], + ['dedicated remote override', null, 'remote-override', 'beta'], + ['shared-primary descriptor', null, 'shared-primary', 'beta'], + ['legacy profile without a descriptor id', null, null, null], + ['published shared-primary route', 'shared-primary', 'shared-primary', 'beta'] + ] as const)( + 'binds a profile-only %s without conflating live and descriptor identity', + (_label, live, descriptor, descriptorProfile) => { + const token = requestProfileSwitchRestore('beta', null, 1) + + expect( + bindProfileSwitchRestore(token.generation, { + activationEpoch: 4, + descriptorConnectionId: descriptor, + descriptorProfile, + liveGatewayConnectionId: live, + profile: 'beta' + }) + ).toBe(true) + expect($profileSwitchRestoreToken.get()).toMatchObject({ + activation: { + activationEpoch: 4, + descriptorConnectionId: descriptor, + descriptorProfile, + liveGatewayConnectionId: live, + profile: 'beta' + } + }) + } + ) + + it('requires an explicit registry source in both live and committed domains', () => { + const liveMismatch = requestProfileSwitchRestore('default', 'source-a', 1) + + expect( + bindProfileSwitchRestore(liveMismatch.generation, { + activationEpoch: 4, + descriptorConnectionId: 'source-a', + descriptorProfile: 'default', + liveGatewayConnectionId: 'source-b', + profile: 'default' + }) + ).toBe(false) + + const descriptorMismatch = requestProfileSwitchRestore('default', 'source-a', 2) + + expect( + bindProfileSwitchRestore(descriptorMismatch.generation, { + activationEpoch: 5, + descriptorConnectionId: 'source-b', + descriptorProfile: 'default', + liveGatewayConnectionId: 'source-a', + profile: 'default' + }) + ).toBe(false) + expect($profileSwitchRestoreToken.get()).toBeNull() + }) + + it('allows only the latest generation to bind or clear', () => { + const toB = requestProfileSwitchRestore('beta', 'source-a', 1) + const toC = requestProfileSwitchRestore('gamma', 'source-a', 2) + + expect( + bindProfileSwitchRestore(toB.generation, { + activationEpoch: 10, + descriptorConnectionId: 'source-a', + descriptorProfile: 'beta', + liveGatewayConnectionId: 'source-a', + profile: 'beta' + }) + ).toBe(false) + clearProfileSwitchRestore(toB.generation) + expect($profileSwitchRestoreToken.get()?.generation).toBe(toC.generation) + + expect( + bindProfileSwitchRestore(toC.generation, { + activationEpoch: 11, + descriptorConnectionId: 'source-a', + descriptorProfile: 'gamma', + liveGatewayConnectionId: 'source-a', + profile: 'gamma' + }) + ).toBe(true) + }) + + it('makes a fresh-draft switch supersede pending restore work', () => { + const restore = requestProfileSwitchRestore('beta', null, 2) + + supersedeProfileSwitchRestoreForFreshDraft(1) + expect($profileSwitchRestoreToken.get()?.generation).toBe(restore.generation) + + supersedeProfileSwitchRestoreForFreshDraft(3) + + expect(isCurrentProfileSwitchGeneration(restore.generation)).toBe(false) + expect($profileSwitchRestoreToken.get()).toBeNull() + }) +}) diff --git a/apps/desktop/src/store/profile-switch-behavior.ts b/apps/desktop/src/store/profile-switch-behavior.ts new file mode 100644 index 0000000000000..dd43a61ca1590 --- /dev/null +++ b/apps/desktop/src/store/profile-switch-behavior.ts @@ -0,0 +1,270 @@ +import { atom } from 'nanostores' + +import { type Codec, persistentAtom } from '@/lib/persisted' +import { storedString } from '@/lib/storage' + +export type ProfileSwitchBehavior = 'fresh_draft' | 'restore_last_session' + +export interface ProfileSwitchPaneFocus { + groupId: null | string + paneId: null | string +} + +export interface ProfileSwitchNavigation { + focusedStoredSessionId: null | string + paneFocus: ProfileSwitchPaneFocus + pathname: string +} + +export interface ProfileSwitchActivation { + activationEpoch: number + descriptorConnectionId: null | string + descriptorProfile: null | string + liveGatewayConnectionId: null | string + profile: string +} + +export interface ProfileSwitchRestoreToken { + activation?: ProfileSwitchActivation + draftNavigationToken?: string + freshSessionRequestSequence: number + generation: number + requestedConnectionId: null | string + requestedProfile: string + sourceNavigation?: ProfileSwitchNavigation +} + +const STORAGE_KEY = 'hermes.desktop.profile-switch-behavior.v1' + +function normalizeProfileSwitchBehavior(value: null | string): ProfileSwitchBehavior { + return value === 'restore_last_session' ? value : 'fresh_draft' +} + +const behaviorCodec: Codec = { + decode: normalizeProfileSwitchBehavior, + encode: value => value +} + +export const $profileSwitchBehavior = persistentAtom(STORAGE_KEY, 'fresh_draft', behaviorCodec) + +export const $profileSwitchRestoreToken = atom(null) + +let switchGeneration = 0 +let captureAnchor: null | (() => ProfileSwitchNavigation) = null + +const normalizeConnectionId = (value: null | string | undefined): null | string => value?.trim() || null +const normalizeProfile = (value: string): string => value.trim() || 'default' +const isFreshDraftWorkspaceFocus = ({ groupId, paneId }: ProfileSwitchPaneFocus): boolean => + groupId === null && paneId === 'workspace' + +export const profileSwitchNavigationToken = ({ + focusedStoredSessionId, + paneFocus, + pathname +}: ProfileSwitchNavigation): string => + JSON.stringify([pathname, focusedStoredSessionId, paneFocus.groupId, paneFocus.paneId]) + +export function getProfileSwitchBehavior(): ProfileSwitchBehavior { + return $profileSwitchBehavior.get() +} + +export function setProfileSwitchBehavior(value: ProfileSwitchBehavior): void { + $profileSwitchBehavior.set(value) +} + +/** Capture the departing profile's native focus, then replace every older + * restore generation with one exact requested source/profile/fresh request. */ +export function requestProfileSwitchRestore( + requestedProfile: string, + requestedConnectionId: null | string, + freshSessionRequestSequence: number +): ProfileSwitchRestoreToken { + const sourceNavigation = captureAnchor?.() + + const token = { + freshSessionRequestSequence, + generation: ++switchGeneration, + requestedConnectionId: normalizeConnectionId(requestedConnectionId), + requestedProfile: normalizeProfile(requestedProfile), + ...(sourceNavigation === undefined ? {} : { sourceNavigation }) + } + + $profileSwitchRestoreToken.set(token) + + return token +} + +/** A newer fresh draft or profile-switch intent supersedes every older + * asynchronous restore. Old generations can never clear a newer token. */ +export function supersedeProfileSwitchRestore(): number { + const generation = ++switchGeneration + $profileSwitchRestoreToken.set(null) + + return generation +} + +/** Central fresh-draft cancellation seam. The profile switch's own native + * request may consume its authorized sequence; every other draft is newer + * user intent and supersedes the pending restore. */ +export function supersedeProfileSwitchRestoreForFreshDraft(freshSessionRequestSequence?: number): void { + const token = $profileSwitchRestoreToken.get() + + if ( + !token || + token.freshSessionRequestSequence === freshSessionRequestSequence || + (freshSessionRequestSequence !== undefined && freshSessionRequestSequence < token.freshSessionRequestSequence) + ) { + return + } + + supersedeProfileSwitchRestore() +} + +export function isCurrentProfileSwitchGeneration(generation: number): boolean { + return generation === switchGeneration +} + +/** Bind only the still-current request to the route that actually settled. + * Requested registry identity, live gateway scope identity, and Electron's + * committed descriptor identity are distinct domains and stay distinct. */ +export function bindProfileSwitchRestore(generation: number, activation: ProfileSwitchActivation): boolean { + const token = $profileSwitchRestoreToken.get() + + if (token?.generation !== generation) { + return false + } + + const requestedConnectionId = normalizeConnectionId(token.requestedConnectionId) + const liveGatewayConnectionId = normalizeConnectionId(activation.liveGatewayConnectionId) + const descriptorConnectionId = normalizeConnectionId(activation.descriptorConnectionId) + const descriptorProfile = activation.descriptorProfile?.trim() || null + const profile = normalizeProfile(activation.profile) + + const explicitSourceMismatch = + requestedConnectionId !== null && + (liveGatewayConnectionId !== requestedConnectionId || descriptorConnectionId !== requestedConnectionId) + + // Profile-only local/override pool sockets are keyed by profile and publish + // no live registry connection. Shared-primary routes publish the same source + // in both domains. A non-null live source with a missing/different committed + // descriptor is a torn publication and must fail closed. + const profileDoorPublicationMismatch = + requestedConnectionId === null && + liveGatewayConnectionId !== null && + liveGatewayConnectionId !== descriptorConnectionId + const descriptorProfileMismatch = + (descriptorConnectionId !== null || descriptorProfile !== null) && + normalizeProfile(descriptorProfile ?? '') !== profile + + if ( + explicitSourceMismatch || + profileDoorPublicationMismatch || + descriptorProfileMismatch || + token.requestedProfile !== profile + ) { + clearProfileSwitchRestore(generation) + + return false + } + + $profileSwitchRestoreToken.set({ + ...token, + activation: { + activationEpoch: activation.activationEpoch, + descriptorConnectionId, + descriptorProfile, + liveGatewayConnectionId, + profile + } + }) + + return true +} + +/** Observe the one allowed source -> null-selection -> fresh-draft sequence. + * The native fresh-request identity is authoritative: a later request cancels + * even when pathname and focus happen to remain byte-identical. */ +export function observeProfileSwitchRestoreNavigation( + generation: number, + navigation: ProfileSwitchNavigation, + freshSessionRequestSequence: number +): boolean { + const token = $profileSwitchRestoreToken.get() + + if (token?.generation !== generation) { + return false + } + + if (token.sourceNavigation === undefined || token.freshSessionRequestSequence !== freshSessionRequestSequence) { + clearProfileSwitchRestore(generation) + + return false + } + + const currentNavigationToken = profileSwitchNavigationToken(navigation) + + if (token.draftNavigationToken !== undefined) { + if (token.draftNavigationToken !== currentNavigationToken) { + clearProfileSwitchRestore(generation) + + return false + } + + return true + } + + if ( + navigation.pathname === '/' && + navigation.focusedStoredSessionId === null && + isFreshDraftWorkspaceFocus(navigation.paneFocus) + ) { + $profileSwitchRestoreToken.set({ ...token, draftNavigationToken: currentNavigationToken }) + + return true + } + + if (navigation.pathname === '/' && navigation.focusedStoredSessionId === null) { + clearProfileSwitchRestore(generation) + + return false + } + + const stillOnSource = currentNavigationToken === profileSwitchNavigationToken(token.sourceNavigation) + const nativeIntermediate = + navigation.pathname === token.sourceNavigation.pathname && + navigation.focusedStoredSessionId === null && + isFreshDraftWorkspaceFocus(navigation.paneFocus) + + if (!stillOnSource && !nativeIntermediate) { + clearProfileSwitchRestore(generation) + } + + return false +} + +/** Register the main renderer's synchronous native-focus capture. */ +export function registerProfileSwitchAnchorCapture(capture: () => ProfileSwitchNavigation): () => void { + captureAnchor = capture + + return () => { + if (captureAnchor === capture) { + captureAnchor = null + } + } +} + +export function clearProfileSwitchRestore(generation: number): void { + if ($profileSwitchRestoreToken.get()?.generation === generation) { + $profileSwitchRestoreToken.set(null) + } +} + +/** @internal Reset client-local preference and coordination state for tests. */ +export function _resetProfileSwitchBehaviorForTests(): void { + switchGeneration = 0 + captureAnchor = null + $profileSwitchBehavior.set( + typeof window === 'undefined' ? 'fresh_draft' : normalizeProfileSwitchBehavior(storedString(STORAGE_KEY)) + ) + $profileSwitchRestoreToken.set(null) +} diff --git a/apps/desktop/src/store/profile.ts b/apps/desktop/src/store/profile.ts index 1dbcfdfea11d3..91a3b7504591a 100644 --- a/apps/desktop/src/store/profile.ts +++ b/apps/desktop/src/store/profile.ts @@ -20,12 +20,22 @@ import { activeGatewayConnectionId, ensureGatewayForAgent, ensureGatewayForProfile, + gatewayActivationEpoch, openGatewayForAgent, openGatewayForProfile } from '@/store/gateway' import { notifyError } from '@/store/notifications' import { notifyRemoteOverrideAuthFailure } from '@/store/profile-remote-override' -import { setConnection } from '@/store/session' +import { + $profileSwitchRestoreToken, + bindProfileSwitchRestore, + clearProfileSwitchRestore, + getProfileSwitchBehavior, + isCurrentProfileSwitchGeneration, + requestProfileSwitchRestore, + supersedeProfileSwitchRestore +} from '@/store/profile-switch-behavior' +import { $connection, setConnection } from '@/store/session' import type { SessionOwnerRoute } from '@/store/session-request-router' import { resetStarmapGraph } from '@/store/starmap' import type { ProfileInfo } from '@/types/hermes' @@ -288,7 +298,7 @@ export function captureNewChatSource(connectionId: null | string = activeGateway * dials — capturing `local` for a pick would mint the session on the registry * entry local::x while the window shows the override's socket. */ -function profilePickConnectionId(): null | string { +export function profilePickConnectionId(): null | string { const connectionId = activeGatewayConnectionId() return connectionId && connectionId !== LOCAL_CONNECTION_ID ? connectionId : null @@ -333,8 +343,19 @@ export function resolveNewChatOwnerRoute(): AgentProfileRoute | null { // resets to the intro draft, so we never strand the user in an orphaned view. export const $freshSessionRequest = atom(0) -export function requestFreshSession(): void { - $freshSessionRequest.set($freshSessionRequest.get() + 1) +export function requestFreshSession(profileSwitchGeneration?: number): number { + if (profileSwitchGeneration !== undefined && !isCurrentProfileSwitchGeneration(profileSwitchGeneration)) { + return $freshSessionRequest.get() + } + + if (profileSwitchGeneration === undefined) { + supersedeProfileSwitchRestore() + } + + const sequence = $freshSessionRequest.get() + 1 + $freshSessionRequest.set(sequence) + + return sequence } // Route profile-scoped REST settings (config/env/skills/tools/model/…) to the @@ -461,7 +482,15 @@ async function resolveConnectionForProfile(profile: string): Promise { +export interface EnsureGatewayProfileOptions { + /** Wait out a pending profile activation before reasserting the current profile. */ + reassertAfterPending?: boolean +} + +export async function ensureGatewayProfile( + profile: string | null | undefined, + { reassertAfterPending = false }: EnsureGatewayProfileOptions = {} +): Promise { if (profile == null || !String(profile).trim()) { // "No explicit profile" = use the current gateway. But if an explicit swap // (e.g. the user just picked a profile in the switcher) is still in flight, @@ -475,8 +504,9 @@ export async function ensureGatewayProfile(profile: string | null | undefined): } const target = normalizeProfileKey(profile) + const waitForPendingReassertion = reassertAfterPending && gatewaySwitch !== null - if (normalizeProfileKey($activeGatewayProfile.get()) === target && $gateway.get()) { + if (normalizeProfileKey($activeGatewayProfile.get()) === target && $gateway.get() && !waitForPendingReassertion) { return } @@ -753,9 +783,20 @@ export const $profileScope = computed([$showAllProfiles, $activeGatewayProfile], // $activeGatewayProfile → name, so $profileScope follows). export function selectProfile(name: string): void { const target = normalizeProfileKey(name) + const activeProfile = normalizeProfileKey($activeGatewayProfile.get()) + const pendingRestore = $profileSwitchRestoreToken.get() + const reversingPendingRestore = + getProfileSwitchBehavior() === 'restore_last_session' && + pendingRestore !== null && + pendingRestore.requestedProfile !== target && + target === activeProfile // Switching profiles (or coming back from the all-profiles browse view) starts // fresh; re-tapping the profile you're already in leaves your session be. - const switching = $showAllProfiles.get() || target !== normalizeProfileKey($activeGatewayProfile.get()) + // The one exception is reversing THIS feature's pending restore intent: A + // clicked during A -> B must queue a newer A activation behind B, not let B + // become the final published route. Unrelated same-profile activations remain + // ordinary no-ops. + const switching = $showAllProfiles.get() || target !== activeProfile || reversingPendingRestore $showAllProfiles.set(false) $newChatProfile.set(target) $newChatRoute.set(null) @@ -763,10 +804,21 @@ export function selectProfile(name: string): void { // is made on the source the user is looking at (activateOnCurrentSource // dials exactly that pair), so the draft's exact owner is that pair — or the // legacy profile-only path when that is the door the pick takes. - captureNewChatSource(profilePickConnectionId()) - - if (switching) { - requestFreshSession() + const sourceConnectionId = profilePickConnectionId() + captureNewChatSource(sourceConnectionId) + + const freshSessionRequestSequence = $freshSessionRequest.get() + 1 + const restoreToken = + switching && getProfileSwitchBehavior() === 'restore_last_session' + ? requestProfileSwitchRestore(target, sourceConnectionId, freshSessionRequestSequence) + : null + const generation = switching ? (restoreToken?.generation ?? supersedeProfileSwitchRestore()) : null + + // Every real profile switch crosses the same fresh-draft barrier. Restore + // happens only after exact activation + a committed target-list refresh, so + // the departing transcript cannot flash while the target wakes up. + if (generation !== null) { + requestFreshSession(generation) } // A profile with a remote override can fail to activate because the remote @@ -783,11 +835,41 @@ export function selectProfile(name: string): void { // profiles, so only a primary-backend activation updates the startup // preference. const onPrimary = activeGatewayConnectionId() == null - const shouldRememberStartupProfile = onPrimary ? isLocalDesktopProfile(target) : Promise.resolve(false) + const activation = + reversingPendingRestore && sourceConnectionId === null + ? activateOnCurrentSource(target, sourceConnectionId, { reassertAfterPending: true }) + : activateOnCurrentSource(target, sourceConnectionId) - void Promise.all([activateOnCurrentSource(target), shouldRememberStartupProfile]) + void Promise.all([activation, shouldRememberStartupProfile]) .then(([, shouldRemember]) => { + if (generation !== null && !isCurrentProfileSwitchGeneration(generation)) { + return undefined + } + + const activeProfile = normalizeProfileKey($activeGatewayProfile.get()) + const activeConnection = activeGatewayConnectionId() + + const explicitSourceMismatch = sourceConnectionId !== null && activeConnection !== sourceConnectionId + + if (generation !== null && (activeProfile !== target || explicitSourceMismatch)) { + clearProfileSwitchRestore(generation) + + return undefined + } + + if (restoreToken) { + const descriptor = $connection.get() + + bindProfileSwitchRestore(restoreToken.generation, { + activationEpoch: gatewayActivationEpoch(), + descriptorConnectionId: descriptor?.connectionId ?? null, + descriptorProfile: descriptor?.profile ?? null, + liveGatewayConnectionId: activeConnection, + profile: activeProfile + }) + } + if (shouldRemember) { return window.hermesDesktop?.profile?.remember(target) } @@ -795,6 +877,14 @@ export function selectProfile(name: string): void { return undefined }) .catch((error: unknown) => { + if (generation !== null && !isCurrentProfileSwitchGeneration(generation)) { + return + } + + if (generation !== null) { + clearProfileSwitchRestore(generation) + } + if (!notifyRemoteOverrideAuthFailure(target, error)) { notifyError(error, `Failed to switch to profile "${target}"`) } @@ -829,10 +919,12 @@ async function isLocalDesktopProfile(target: string): Promise { // primary and explicit "local" source stay on the legacy profile-only path so // the main process can resolve a per-profile remote override before falling // back to a local backend. -function activateOnCurrentSource(target: string): Promise { - const connectionId = profilePickConnectionId() - - return connectionId ? ensureGatewayAgent(connectionId, target) : ensureGatewayProfile(target) +function activateOnCurrentSource( + target: string, + connectionId: null | string = profilePickConnectionId(), + profileOptions?: EnsureGatewayProfileOptions +): Promise { + return connectionId ? ensureGatewayAgent(connectionId, target) : ensureGatewayProfile(target, profileOptions) } // Start a fresh session in `name` WITHOUT collapsing the "All profiles" browse From 748a43cdc2f0c560ecf3ea7a9aff363e159471d9 Mon Sep 17 00:00:00 2001 From: Desktop Contributor Date: Wed, 26 Aug 2026 14:24:24 +0000 Subject: [PATCH 2/2] fix(desktop): keep session switching within profile --- apps/desktop/src/app/chat/sidebar/index.tsx | 2 +- .../sidebar => store}/profile-scope.test.ts | 2 +- .../chat/sidebar => store}/profile-scope.ts | 5 +- .../src/store/session-switcher.test.ts | 137 +++++++++++++++++- apps/desktop/src/store/session-switcher.ts | 29 +++- 5 files changed, 166 insertions(+), 9 deletions(-) rename apps/desktop/src/{app/chat/sidebar => store}/profile-scope.test.ts (97%) rename apps/desktop/src/{app/chat/sidebar => store}/profile-scope.ts (83%) diff --git a/apps/desktop/src/app/chat/sidebar/index.tsx b/apps/desktop/src/app/chat/sidebar/index.tsx index a9a868ad00d30..7d5cd563bd1c8 100644 --- a/apps/desktop/src/app/chat/sidebar/index.tsx +++ b/apps/desktop/src/app/chat/sidebar/index.tsx @@ -83,6 +83,7 @@ import { normalizeProfileKey, sidebarProfileForScope } from '@/store/profile' +import { filterSessionsByProfileScope } from '@/store/profile-scope' import { $activeProjectId, $projects, @@ -148,7 +149,6 @@ import { SidebarCronJobsSection } from './cron-jobs-section' import { SidebarFilterMenu } from './filter-menu' import { SidebarLoadMoreRow } from './load-more-row' import { orderByIds, reconcileOrderIds, resolveManualSessionOrderIds, sameIds } from './order' -import { filterSessionsByProfileScope } from './profile-scope' import { ProfileRail } from './profile-switcher' import { ProjectDialog } from './project-dialog' import { diff --git a/apps/desktop/src/app/chat/sidebar/profile-scope.test.ts b/apps/desktop/src/store/profile-scope.test.ts similarity index 97% rename from apps/desktop/src/app/chat/sidebar/profile-scope.test.ts rename to apps/desktop/src/store/profile-scope.test.ts index 68585af9865b1..35f361e589df8 100644 --- a/apps/desktop/src/app/chat/sidebar/profile-scope.test.ts +++ b/apps/desktop/src/store/profile-scope.test.ts @@ -1,8 +1,8 @@ import { describe, expect, it } from 'vitest' -import { ALL_PROFILES } from '@/store/profile' import type { SessionInfo } from '@/types/hermes' +import { ALL_PROFILES } from './profile' import { filterSessionsByProfileScope } from './profile-scope' /** Build the smallest session row needed by the profile-scope tests. */ diff --git a/apps/desktop/src/app/chat/sidebar/profile-scope.ts b/apps/desktop/src/store/profile-scope.ts similarity index 83% rename from apps/desktop/src/app/chat/sidebar/profile-scope.ts rename to apps/desktop/src/store/profile-scope.ts index d585b14c9b5d5..216b260c716ec 100644 --- a/apps/desktop/src/app/chat/sidebar/profile-scope.ts +++ b/apps/desktop/src/store/profile-scope.ts @@ -1,8 +1,9 @@ -import { ALL_PROFILES, normalizeProfileKey } from '@/store/profile' import type { SessionInfo } from '@/types/hermes' +import { ALL_PROFILES, normalizeProfileKey } from './profile' + /** - * Sessions visible in one sidebar profile scope. + * Sessions visible in one Desktop profile scope. * * ALL (`__all__`) returns the caller's list unchanged — including the * single-profile case, where Grouping → Profile persists that sentinel diff --git a/apps/desktop/src/store/session-switcher.test.ts b/apps/desktop/src/store/session-switcher.test.ts index 4e9da076362fa..16467ee760511 100644 --- a/apps/desktop/src/store/session-switcher.test.ts +++ b/apps/desktop/src/store/session-switcher.test.ts @@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { SessionInfo } from '@/types/hermes' +import { $activeGatewayProfile, $showAllProfiles } from './profile' import { $selectedStoredSessionId, $sessions } from './session' import { $switcherIndex, @@ -16,13 +17,19 @@ import { SWITCHER_REVEAL_MS } from './session-switcher' -const session = (id: string): SessionInfo => ({ id }) as SessionInfo +const session = (id: string, profile?: string, lineageRootId?: string): SessionInfo => + ({ id, profile, _lineage_root_id: lineageRootId }) as SessionInfo const seed = (ids: string[], selected: null | string) => { $sessions.set(ids.map(session)) $selectedStoredSessionId.set(selected) } +const seedRows = (rows: SessionInfo[], selected: null | string) => { + $sessions.set(rows) + $selectedStoredSessionId.set(selected) +} + const tabTap = (direction: 1 | -1 = 1) => { onSwitcherTabDown() const target = openOrAdvanceSwitcher(direction) @@ -36,10 +43,15 @@ beforeEach(() => { closeSwitcher() $switcherSessions.set([]) $switcherIndex.set(0) + $activeGatewayProfile.set('default') + $showAllProfiles.set(false) }) afterEach(() => { + closeSwitcher() seed([], null) + $activeGatewayProfile.set('default') + $showAllProfiles.set(false) }) describe('openOrAdvanceSwitcher', () => { @@ -50,6 +62,17 @@ describe('openOrAdvanceSwitcher', () => { expect(openOrAdvanceSwitcher(1)).toBeNull() }) + it('ignores foreign sessions when the active profile has only one session', () => { + $activeGatewayProfile.set('profile-a') + seedRows([session('a1', 'profile-a'), session('b1', 'profile-b'), session('b2', 'profile-b')], 'a1') + onSwitcherTabDown() + + expect(openOrAdvanceSwitcher(1)).toBeNull() + expect($switcherOpen.get()).toBe(false) + expect($switcherSessions.get()).toEqual([]) + expect(commitOnCtrlUp()).toBeNull() + }) + it('jumps immediately on a quick Tab tap without opening the HUD', () => { seed(['a', 'b', 'c'], 'a') @@ -102,6 +125,110 @@ describe('openOrAdvanceSwitcher', () => { expect(commitOnCtrlUp()).toBe('c') }) + + it('wraps forward from A2 to A1 within the active profile', () => { + $activeGatewayProfile.set('profile-a') + seedRows([session('a1', 'profile-a'), session('a2', 'profile-a'), session('b1', 'profile-b')], 'a2') + + expect(tabTap(1)).toBe('a1') + }) + + it('wraps backward from A1 to A2 within the active profile', () => { + $activeGatewayProfile.set('profile-a') + seedRows([session('a1', 'profile-a'), session('a2', 'profile-a'), session('b1', 'profile-b')], 'a1') + + expect(tabTap(-1)).toBe('a2') + }) + + it('keeps the held-switcher snapshot inside the active profile', () => { + vi.useFakeTimers() + $activeGatewayProfile.set('profile-a') + seedRows([session('a1', 'profile-a'), session('a2', 'profile-a'), session('b1', 'profile-b')], 'a1') + + onSwitcherTabDown() + expect(openOrAdvanceSwitcher(1)).toBe('a2') + vi.advanceTimersByTime(SWITCHER_REVEAL_MS) + + expect($switcherOpen.get()).toBe(true) + expect($switcherSessions.get().map(row => row.id)).toEqual(['a1', 'a2']) + onSwitcherTabUp() + }) + + it('fails closed in explicit All Profiles scope', () => { + vi.useFakeTimers() + $activeGatewayProfile.set('profile-a') + $showAllProfiles.set(true) + seedRows( + [session('a1', 'profile-a'), session('a2', 'profile-a'), session('b1', 'profile-b'), session('c1', 'profile-c')], + 'a2' + ) + + expect(tabTap(1)).toBeNull() + expect(tabTap(-1)).toBeNull() + expect(slotSessionId(1)).toBeNull() + expect(slotSessionId(2)).toBeNull() + onSwitcherTabDown() + expect(openOrAdvanceSwitcher(1)).toBeNull() + vi.advanceTimersByTime(SWITCHER_REVEAL_MS) + + expect($switcherOpen.get()).toBe(false) + expect($switcherSessions.get()).toEqual([]) + onSwitcherTabUp() + }) + + it('closes pending browsing when switching to All Profiles', () => { + vi.useFakeTimers() + $activeGatewayProfile.set('profile-a') + seedRows([session('a1', 'profile-a'), session('a2', 'profile-a'), session('b1', 'profile-b')], 'a1') + + onSwitcherTabDown() + expect(openOrAdvanceSwitcher(1)).toBe('a2') + $showAllProfiles.set(true) + + expect($switcherOpen.get()).toBe(false) + vi.advanceTimersByTime(SWITCHER_REVEAL_MS) + expect($switcherOpen.get()).toBe(false) + expect(commitOnCtrlUp()).toBeNull() + }) + + it('closes an open HUD on profile change and allows a fresh browse', () => { + vi.useFakeTimers() + $activeGatewayProfile.set('profile-a') + seedRows( + [session('a1', 'profile-a'), session('a2', 'profile-a'), session('b1', 'profile-b'), session('b2', 'profile-b')], + 'a1' + ) + + onSwitcherTabDown() + expect(openOrAdvanceSwitcher(1)).toBe('a2') + vi.advanceTimersByTime(SWITCHER_REVEAL_MS) + expect($switcherOpen.get()).toBe(true) + + $activeGatewayProfile.set('profile-b') + + expect($switcherOpen.get()).toBe(false) + expect(commitOnCtrlUp()).toBeNull() + $selectedStoredSessionId.set('b1') + expect(tabTap()).toBe('b2') + expect($switcherSessions.get().map(row => row.id)).toEqual(['b1', 'b2']) + }) + + it('normalizes blank and legacy profile values to default', () => { + seedRows([session('a1', ''), session('a2'), session('b1', 'profile-b')], 'a2') + + expect(tabTap(1)).toBe('a1') + expect($switcherSessions.get().map(row => row.id)).toEqual(['a1', 'a2']) + }) + + it('finds the selected row by its stored lineage identity', () => { + $activeGatewayProfile.set('profile-a') + seedRows( + [session('a1', 'profile-a'), session('a2-tip', 'profile-a', 'a2-root'), session('b1', 'profile-b')], + 'a2-root' + ) + + expect(tabTap(-1)).toBe('a1') + }) }) describe('slotSessionId', () => { @@ -112,4 +239,12 @@ describe('slotSessionId', () => { expect(slotSessionId(2)).toBe('b') }) + + it('uses the active profile candidate list while idle', () => { + $activeGatewayProfile.set('profile-a') + seedRows([session('a1', 'profile-a'), session('a2', 'profile-a'), session('b1', 'profile-b')], 'a1') + + expect(slotSessionId(2)).toBe('a2') + expect(slotSessionId(3)).toBeNull() + }) }) diff --git a/apps/desktop/src/store/session-switcher.ts b/apps/desktop/src/store/session-switcher.ts index ffbcccdace24c..9c1f0cf80fed0 100644 --- a/apps/desktop/src/store/session-switcher.ts +++ b/apps/desktop/src/store/session-switcher.ts @@ -2,7 +2,9 @@ import { atom } from 'nanostores' import type { SessionInfo } from '@/types/hermes' -import { $selectedStoredSessionId, $sessions } from './session' +import { $profileScope, ALL_PROFILES } from './profile' +import { filterSessionsByProfileScope } from './profile-scope' +import { $selectedStoredSessionId, $sessions, sessionMatchesStoredId } from './session' // Mac-style session switcher (^Tab). Quick tap jumps on keydown; the HUD opens // only when Tab is held past REVEAL_MS or tapped again while Ctrl is down. @@ -15,6 +17,16 @@ export const $switcherIndex = atom(0) const wrap = (index: number, length: number): number => ((index % length) + length) % length +const switcherCandidates = (): SessionInfo[] => { + const profileScope = $profileScope.get() + + if (profileScope === ALL_PROFILES) { + return [] + } + + return filterSessionsByProfileScope($sessions.get(), profileScope) +} + let pendingBrowse = false let revealTimer: ReturnType | null = null let tabHeld = false @@ -58,7 +70,7 @@ export function onSwitcherTabUp(): void { // First Tab returns a session id to jump to immediately; later Tabs move the // highlight (Ctrl↑ commits when the HUD is open). export function openOrAdvanceSwitcher(direction: 1 | -1): string | null { - const sessions = $sessions.get() + const sessions = switcherCandidates() if (sessions.length < 2) { return null @@ -74,7 +86,8 @@ export function openOrAdvanceSwitcher(direction: 1 | -1): string | null { return null } - const current = sessions.findIndex(session => session.id === $selectedStoredSessionId.get()) + const selected = $selectedStoredSessionId.get() + const current = selected === null ? -1 : sessions.findIndex(session => sessionMatchesStoredId(session, selected)) const start = current === -1 ? (direction === 1 ? -1 : 0) : current const nextIndex = wrap(start + direction, sessions.length) @@ -98,7 +111,7 @@ export function openOrAdvanceSwitcher(direction: 1 | -1): string | null { export const highlightedSessionId = (): string | null => $switcherSessions.get()[$switcherIndex.get()]?.id ?? null export const slotSessionId = (slot: number): string | null => - ($switcherOpen.get() || pendingBrowse ? $switcherSessions.get() : $sessions.get())[slot - 1]?.id ?? null + ($switcherOpen.get() || pendingBrowse ? $switcherSessions.get() : switcherCandidates())[slot - 1]?.id ?? null export function closeSwitcher(): void { closedAt = Date.now() @@ -125,3 +138,11 @@ export function commitOnCtrlUp(): string | null { export const switcherJustClosed = (): boolean => Date.now() - closedAt < 400 export const switcherActive = (): boolean => $switcherOpen.get() || pendingBrowse + +const unsubscribeProfileScope = $profileScope.listen(() => { + if (switcherActive()) { + closeSwitcher() + } +}) + +import.meta.hot?.dispose(unsubscribeProfileScope)