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
41 changes: 40 additions & 1 deletion apps/desktop/src/app/desktop-controller-utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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> = {}): 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)
Expand All @@ -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()
})
})
33 changes: 33 additions & 0 deletions apps/desktop/src/app/desktop-controller-utils.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down
37 changes: 33 additions & 4 deletions apps/desktop/src/app/desktop-controller.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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 | string>(null)
useEffect(() => {
if (restoredLastSessionRef.current) {
return
Expand All @@ -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
Expand Down