diff --git a/apps/desktop/src/app/desktop-controller-utils.test.ts b/apps/desktop/src/app/desktop-controller-utils.test.ts index ac39395db09e..d74a3e1bef34 100644 --- a/apps/desktop/src/app/desktop-controller-utils.test.ts +++ b/apps/desktop/src/app/desktop-controller-utils.test.ts @@ -2,10 +2,13 @@ import { describe, expect, it } from 'vitest' import type { SessionInfo } from '@/hermes' -import { sameCronSignature } from './desktop-controller-utils' +import { pickMostRecentSessionId, sameCronSignature } from './desktop-controller-utils' const session = (id: string, title: string | null): SessionInfo => ({ id, title }) as SessionInfo +const recent = (id: string, over: Partial = {}): SessionInfo => + ({ id, archived: false, is_active: false, last_active: 0, ...over }) as SessionInfo + describe('sameCronSignature', () => { it('is false when the lengths differ', () => { expect(sameCronSignature([session('a', 't')], [])).toBe(false) @@ -29,3 +32,39 @@ describe('sameCronSignature', () => { expect(sameCronSignature(a, b)).toBe(false) }) }) + +describe('pickMostRecentSessionId', () => { + it('returns null for an empty list', () => { + expect(pickMostRecentSessionId([], null)).toBeNull() + }) + + it('picks the newest session by last_active', () => { + const sessions = [recent('old', { last_active: 100 }), recent('new', { last_active: 300 }), recent('mid', { last_active: 200 })] + expect(pickMostRecentSessionId(sessions, null)).toBe('new') + }) + + it('prefers an active session over a newer inactive one', () => { + const sessions = [recent('live', { is_active: true, last_active: 100 }), recent('newer-but-ended', { last_active: 900 })] + expect(pickMostRecentSessionId(sessions, null)).toBe('live') + }) + + it('skips archived sessions', () => { + const sessions = [recent('archived-new', { archived: true, last_active: 900 }), recent('plain-old', { last_active: 100 })] + expect(pickMostRecentSessionId(sessions, null)).toBe('plain-old') + }) + + it('excludes the stale session by stored id and by lineage root', () => { + const sessions = [ + recent('dead', { last_active: 900 }), + recent('dead-tip', { _lineage_root_id: 'dead', last_active: 800 }), + recent('alive', { last_active: 100 }) + ] + + expect(pickMostRecentSessionId(sessions, 'dead')).toBe('alive') + }) + + it('returns null when everything is excluded or archived', () => { + const sessions = [recent('dead', { last_active: 900 }), recent('gone', { archived: true, last_active: 800 })] + expect(pickMostRecentSessionId(sessions, 'dead')).toBeNull() + }) +}) diff --git a/apps/desktop/src/app/desktop-controller-utils.ts b/apps/desktop/src/app/desktop-controller-utils.ts index 5754d69ef81a..8bdaa95f2457 100644 --- a/apps/desktop/src/app/desktop-controller-utils.ts +++ b/apps/desktop/src/app/desktop-controller-utils.ts @@ -1,5 +1,38 @@ import type { SessionInfo } from '@/hermes' +/** + * Engine-most-recent fallback for a cold-boot restore whose pinned session id + * turned out to be dead (deleted/rotated while the app was closed). Prefers a + * currently-active session (a runtime is live on it right now), then the + * newest by last_active; skips archived rows and the dead session itself — + * matched by stored id or lineage root, mirroring sessionMatchesStoredId(). + * Returns null when nothing qualifies (caller falls back to the new-chat + * route). + */ +export function pickMostRecentSessionId(sessions: SessionInfo[], excludeStoredId: null | string): null | string { + let best: null | SessionInfo = null + + for (const session of sessions) { + if (session.archived) { + continue + } + + if (excludeStoredId && (session.id === excludeStoredId || session._lineage_root_id === excludeStoredId)) { + continue + } + + if ( + !best || + (session.is_active && !best.is_active) || + (session.is_active === best.is_active && (session.last_active ?? 0) > (best.last_active ?? 0)) + ) { + best = session + } + } + + return best?.id ?? null +} + // Cheap signature compare so a poll only swaps the atom (and re-renders the // sidebar) when the visible rows actually changed. export function sameCronSignature(a: SessionInfo[], b: SessionInfo[]): boolean { diff --git a/apps/desktop/src/app/desktop-controller.tsx b/apps/desktop/src/app/desktop-controller.tsx index 4b8c249bced6..45f3e6c4b704 100644 --- a/apps/desktop/src/app/desktop-controller.tsx +++ b/apps/desktop/src/app/desktop-controller.tsx @@ -88,6 +88,7 @@ import { } from './chat/right-rail' import { ChatSidebar } from './chat/sidebar' import { CommandPalette } from './command-palette' +import { pickMostRecentSessionId } from './desktop-controller-utils' import { useGatewayBoot } from './gateway/hooks/use-gateway-boot' import { useGatewayRequest } from './gateway/hooks/use-gateway-request' import { useKeybinds } from './hooks/use-keybinds' @@ -278,9 +279,11 @@ export function DesktopController() { }, [routedSessionId]) // Restore that chat once, on cold start only (we're at the new-chat route and - // haven't navigated yet). A dead/deleted id self-clears via the exhausted latch - // below, so we never boot-loop into an error screen. + // haven't navigated yet). The restore is optimistic — the pinned id is not + // validated first, so the happy path costs no round trip and no new-chat + // flash. A dead/deleted id self-heals via the exhausted latch below. const restoredLastSessionRef = useRef(false) + const bootRestoredSessionIdRef = useRef(null) useEffect(() => { if (restoredLastSessionRef.current) { return @@ -290,15 +293,41 @@ export function DesktopController() { const last = getRememberedSessionId() if (last && location.pathname === NEW_CHAT_ROUTE) { + bootRestoredSessionIdRef.current = last navigate(sessionRoute(last), { replace: true }) } }, [location.pathname, navigate]) + // Once the user routes anywhere else, the boot restore is no longer ours to + // correct — coming back to that session later is a deliberate open and keeps + // the normal error + Retry UI if it turns out dead. useEffect(() => { - if (resumeExhaustedSessionId && getRememberedSessionId() === resumeExhaustedSessionId) { + if (bootRestoredSessionIdRef.current && routedSessionId && routedSessionId !== bootRestoredSessionIdRef.current) { + bootRestoredSessionIdRef.current = null + } + }, [routedSessionId]) + + useEffect(() => { + if (!resumeExhaustedSessionId) { + return + } + + if (getRememberedSessionId() === resumeExhaustedSessionId) { setRememberedSessionId(null) } - }, [resumeExhaustedSessionId]) + + // The session this window cold-booted onto proved dead (the exhausted + // latch only arms with the gateway open, after the bounded resume retries + // gave up — so this is a stale pin, not a down backend). Fall back to the + // engine's most recent session instead of stranding the boot on an error + // screen the user never chose. Sessions the user opened deliberately keep + // the explicit error + Retry UI — this fires only for the boot restore. + if (bootRestoredSessionIdRef.current === resumeExhaustedSessionId && routedSessionId === resumeExhaustedSessionId) { + bootRestoredSessionIdRef.current = null + const fallback = pickMostRecentSessionId($sessions.get(), resumeExhaustedSessionId) + navigate(fallback ? sessionRoute(fallback) : NEW_CHAT_ROUTE, { replace: true }) + } + }, [resumeExhaustedSessionId, routedSessionId, navigate]) // Notification click: the main process already focused the window; jump to its // session. Notifications are tagged with the gateway *runtime* session id, but