fix(desktop): follow compression's stored-id rotation to prevent thread reload (#65984)

Auto-compression ends the SessionDB session and forks a continuation,
rotating the stored session id. The gateway emits `session.info` with
the new `stored_session_id`, and the desktop's cache entry was updated
via `ensureSessionState` — but the URL route and `$selectedStoredSessionId`
never followed the rotation.

On the next send, `getRuntimeIdForStoredSession(oldStoredId)` returned
null (the cache entry's `storedSessionId` no longer matched the old id),
so `routedSessionNeedsResume` evaluated true, triggering a full
`session.resume` + REST transcript prefetch — the whole thread reloaded.

Fix: a new `$activeSessionStoredId` atom is set in `ensureSessionState`
when the active session's stored id changes. A `useEffect` in
`use-session-actions` subscribes to it and re-anchors the route +
selection (`setSelectedStoredSessionId` + `navigate(replace: true)`),
and cleans up the stale stored→runtime mapping.

`replace: true` because it's the same conversation — compression is
transparent to the user, so back-button stays correct.
This commit is contained in:
ethernet 2026-07-16 19:12:55 -04:00 committed by GitHub
parent 75467998f9
commit 39a93dc633
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 50 additions and 2 deletions

View file

@ -1,5 +1,5 @@
import type { MutableRefObject } from 'react'
import { useCallback, useRef } from 'react'
import { useStore } from '@nanostores/react'
import { type MutableRefObject, useCallback, useEffect, useRef } from 'react'
import type { NavigateFunction } from 'react-router-dom'
import { revealTreePane } from '@/components/pane-shell/tree/store'
@ -13,6 +13,7 @@ import { clearNotifications, notify, notifyError } from '@/store/notifications'
import { $activeGatewayProfile, $newChatProfile, ensureGatewayProfile, normalizeProfileKey } from '@/store/profile'
import { resolveNewSessionCwd, tombstoneSessions, untombstoneSessions } from '@/store/projects'
import {
$activeSessionStoredId,
$currentCwd,
$currentFastMode,
$currentModel,
@ -177,6 +178,33 @@ export function useSessionActions({
const copy = t.desktop
const resumeRequestRef = useRef(0)
// Follow auto-compression's stored-id rotation. When the active session's
// stored id changes (compression ends the SessionDB session and forks a
// continuation), re-anchor the URL route + selection to the new id so the
// next send doesn't hit a stale stored→runtime mapping and trigger a full
// thread reload. replace: true — it's the same conversation, not a new
// history entry.
const rotatedStoredId = useStore($activeSessionStoredId)
useEffect(() => {
if (!rotatedStoredId || rotatedStoredId === selectedStoredSessionIdRef.current) {
return
}
const oldStoredId = selectedStoredSessionIdRef.current
setSelectedStoredSessionId(rotatedStoredId)
selectedStoredSessionIdRef.current = rotatedStoredId
navigate(sessionRoute(rotatedStoredId), { replace: true })
// Clean up the stale stored→runtime mapping so getRuntimeIdForStoredSession
// can't resolve the old id to this runtime (it would fail the storedSessionId
// check and return null, but leaving the stale key is sloppy).
if (oldStoredId) {
runtimeIdByStoredSessionIdRef.current.delete(oldStoredId)
}
}, [rotatedStoredId, navigate, runtimeIdByStoredSessionIdRef, selectedStoredSessionIdRef])
const startFreshSessionDraft = useCallback(
(options: boolean | FreshSessionDraftOptions = false) => {
const draftOptions = typeof options === 'boolean' ? { replaceRoute: options } : options

View file

@ -6,10 +6,12 @@ import { preserveLocalAssistantErrors } from '@/lib/chat-messages'
import { createClientSessionState } from '@/lib/chat-runtime'
import { setMutableRef } from '@/lib/mutable-ref'
import {
$activeSessionId,
$busy,
$messages,
noteSessionActivity,
onSessionWatchdogClear,
setActiveSessionStoredId,
setCurrentFastMode,
setCurrentModel,
setCurrentPersonality,
@ -115,6 +117,15 @@ export function useSessionStateCache({
if (previousStoredSessionId && previousStoredSessionId !== storedSessionId) {
setSessionWorking(previousStoredSessionId, false)
// Auto-compression rotated the stored id on the active session. Signal
// the route-following effect in use-session-actions so the URL + selection
// re-anchor to the continuation id — otherwise the next send hits a stale
// stored→runtime mapping (getRuntimeIdForStoredSession returns null) and
// triggers a full thread reload via resumeStoredSession.
if (sessionId === $activeSessionId.get()) {
setActiveSessionStoredId(storedSessionId)
}
}
}

View file

@ -248,6 +248,13 @@ export const $sessionsLoading = atom(true)
export const $workingSessionIds = atom<string[]>([])
export const $activeSessionId = atom<string | null>(null)
export const $selectedStoredSessionId = atom<string | null>(null)
// Reactive signal for when the active session's stored id rotates (auto-
// compression ends the SessionDB session and forks a continuation). The
// route + selection must follow the rotation so the next send doesn't
// trigger a full thread reload (getRuntimeIdForStoredSession would return
// null for the old stored id, forcing resumeStoredSession). Set in
// ensureSessionState when the cache entry's storedSessionId changes.
export const $activeSessionStoredId = atom<string | null>(null)
export const $messages = atom<ChatMessage[]>([])
// Streaming-stable derivations of $messages. During a token stream the array
@ -321,6 +328,8 @@ export const setSessionProfileTotals = (next: Updater<Record<string, number>>) =
export const setSessionsLoading = (next: Updater<boolean>) => updateAtom($sessionsLoading, next)
export const setWorkingSessionIds = (next: Updater<string[]>) => updateAtom($workingSessionIds, next)
export const setActiveSessionId = (next: Updater<string | null>) => updateAtom($activeSessionId, next)
export const setActiveSessionStoredId = (next: Updater<string | null>) =>
updateAtom($activeSessionStoredId, next)
export const setSelectedStoredSessionId = (next: Updater<string | null>) => {
updateAtom($selectedStoredSessionId, next)