Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
100 changes: 100 additions & 0 deletions apps/desktop/src/app/contrib/hooks/use-desktop-integrations.test.tsx
Original file line number Diff line number Diff line change
@@ -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')
})
59 changes: 40 additions & 19 deletions apps/desktop/src/app/contrib/hooks/use-desktop-integrations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,26 +67,8 @@
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
Expand All @@ -103,7 +85,15 @@
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
Expand All @@ -112,10 +102,41 @@
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(() => {

Check failure on line 114 in apps/desktop/src/app/contrib/hooks/use-desktop-integrations.ts

View workflow job for this annotation

GitHub Actions / JS & TS checks / apps/desktop / check:lint

Do not mirror reactive values into refs via useEffect. Read $atom.get() directly in callbacks instead — refs synced from atoms lag one render and cause stale-read bugs
// 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
Expand Down
Loading