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 new file mode 100644 index 000000000000..1eaeefaff566 --- /dev/null +++ b/apps/desktop/src/app/contrib/hooks/use-desktop-integrations.test.tsx @@ -0,0 +1,100 @@ +import { renderHook } from '@testing-library/react' +import { beforeEach, expect, test, vi } from 'vitest' + +import { NEW_CHAT_ROUTE } from '../../routes' + +import { useDesktopIntegrations } from './use-desktop-integrations' + +const sessionStore = vi.hoisted(() => ({ + $sessions: { get: vi.fn(() => []) }, + getRememberedRoute: vi.fn<() => null | string>(), + getRememberedSessionId: vi.fn<(_profile?: null | string) => null | string>(), + rememberedSessionProfile: vi.fn((_sessions: unknown[], _sessionId: null | string, profile: null | string) => profile), + setRememberedRoute: vi.fn(), + setRememberedSessionId: vi.fn() +})) + +const profileStore = vi.hoisted(() => ({ + $activeGatewayProfile: { get: vi.fn(() => 'default') } +})) + +vi.mock('@/app/chat/close-tab', () => ({ closeActiveTab: vi.fn() })) +vi.mock('@/store/native-notifications', () => ({ respondToApprovalAction: vi.fn() })) +vi.mock('@/store/profile', () => profileStore) +vi.mock('@/store/session', () => sessionStore) +vi.mock('@/store/session-sync', () => ({ onSessionsChanged: vi.fn(() => vi.fn()) })) +vi.mock('@/store/updates', () => ({ + openUpdatesWindow: vi.fn(), + startUpdatePoller: vi.fn(), + stopUpdatePoller: vi.fn() +})) +vi.mock('@/store/windows', () => ({ isSecondaryWindow: vi.fn(() => false) })) + +beforeEach(() => { + vi.clearAllMocks() + sessionStore.getRememberedRoute.mockReturnValue(NEW_CHAT_ROUTE) + sessionStore.getRememberedSessionId.mockReturnValue('old-session') +}) + +test('an explicitly remembered new chat clears stale session identity instead of restoring it', () => { + const navigate = vi.fn() + + renderHook(() => + useDesktopIntegrations({ + chatOpen: true, + hasPreview: false, + locationPathname: NEW_CHAT_ROUTE, + navigate, + refreshSessions: vi.fn(), + resumeExhaustedSessionId: null, + routedSessionId: null, + runtimeIdByStoredSessionId: { current: new Map() } + }) + ) + + expect(sessionStore.setRememberedSessionId).toHaveBeenCalledWith(null, 'default') + expect(navigate).not.toHaveBeenCalled() +}) + +test('restoring a remembered session does not clear its identity during the initial new-route render', () => { + const navigate = vi.fn() + + sessionStore.getRememberedRoute.mockReturnValue('/old-session') + + renderHook(() => + useDesktopIntegrations({ + chatOpen: true, + hasPreview: false, + locationPathname: NEW_CHAT_ROUTE, + navigate, + refreshSessions: vi.fn(), + resumeExhaustedSessionId: null, + routedSessionId: null, + runtimeIdByStoredSessionId: { current: new Map() } + }) + ) + + expect(navigate).toHaveBeenCalledWith('/old-session', { replace: true }) + expect(sessionStore.setRememberedSessionId).not.toHaveBeenCalledWith(null, 'default') +}) + +test('a fresh draft clears only the active profile remembered session', () => { + const navigate = vi.fn() + + profileStore.$activeGatewayProfile.get.mockReturnValue('work') + + renderHook(() => + useDesktopIntegrations({ + chatOpen: true, + hasPreview: false, + locationPathname: NEW_CHAT_ROUTE, + navigate, + refreshSessions: vi.fn(), + resumeExhaustedSessionId: null, + routedSessionId: null, + runtimeIdByStoredSessionId: { current: new Map() } + }) + ) + + expect(sessionStore.setRememberedSessionId).toHaveBeenCalledWith(null, 'work') +}) 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 cff5d86f4d54..1abacdeabaaf 100644 --- a/apps/desktop/src/app/contrib/hooks/use-desktop-integrations.ts +++ b/apps/desktop/src/app/contrib/hooks/use-desktop-integrations.ts @@ -67,26 +67,8 @@ export function useDesktopIntegrations({ window.hermesDesktop?.setPreviewShortcutActive?.(true) }, []) - // Remember the open chat (session id for notifications/resume) AND the last - // non-overlay route (a page like /skills, or a session route) so a relaunch - // lands where you were. Overlays (settings/command-center/…) aren't stored — - // you don't want to boot into a modal. - useEffect(() => { - const routeProfile = rememberedSessionProfile($sessions.get(), routedSessionId, $activeGatewayProfile.get()) - - if (routedSessionId) { - setRememberedSessionId(routedSessionId, routeProfile) - } - - if (!isOverlayView(appViewForPath(locationPathname))) { - // Keyed by the same owner as the id above: a session route embeds a - // session id, so remembering it globally would restore another profile's - // conversation on cold start. - setRememberedRoute(locationPathname, routeProfile) - } - }, [locationPathname, routedSessionId]) - const restoredRef = useRef(false) + const restoringRememberedRouteRef = useRef(false) // Restore once on cold start — only when the renderer booted at the default // route (a hidden-then-shown window keeps its own route). Prefer the full @@ -103,7 +85,15 @@ export function useDesktopIntegrations({ const activeProfile = $activeGatewayProfile.get() const route = getRememberedRoute(activeProfile) + // An explicitly remembered fresh draft outranks the legacy last-session + // fallback. Falling through here reopened that older session and painted + // its title over every new chat after a relaunch. + if (route === NEW_CHAT_ROUTE) { + return + } + if (route && route !== NEW_CHAT_ROUTE && !isOverlayView(appViewForPath(route))) { + restoringRememberedRouteRef.current = true navigate(route, { replace: true }) return @@ -112,10 +102,41 @@ export function useDesktopIntegrations({ const last = getRememberedSessionId(activeProfile) if (last) { + restoringRememberedRouteRef.current = true navigate(sessionRoute(last), { replace: true }) } }, [locationPathname, navigate]) + // Remember the open chat (session id for notifications/resume) AND the last + // non-overlay route (a page like /skills, or a session route) so a relaunch + // lands where you were. Overlays (settings/command-center/…) aren't stored — + // you don't want to boot into a modal. + useEffect(() => { + // The app boots at the new-chat route before restore navigation lands. Do + // not let that transient render erase the session we are actively restoring. + if (restoringRememberedRouteRef.current && locationPathname === NEW_CHAT_ROUTE) { + return + } + + restoringRememberedRouteRef.current = false + + if (routedSessionId) { + setRememberedSessionId( + routedSessionId, + rememberedSessionProfile($sessions.get(), routedSessionId, $activeGatewayProfile.get()) + ) + } else if (locationPathname === NEW_CHAT_ROUTE) { + // A deliberate fresh draft is durable intent too. Clear the older chat + // identity so neither cold-start restore nor title lookup can revive it. + setRememberedSessionId(null, $activeGatewayProfile.get()) + } + + if (!isOverlayView(appViewForPath(locationPathname))) { + const routeProfile = rememberedSessionProfile($sessions.get(), routedSessionId, $activeGatewayProfile.get()) + setRememberedRoute(locationPathname, routeProfile) + } + }, [locationPathname, routedSessionId]) + useEffect(() => { if (!resumeExhaustedSessionId) { return