fix(desktop): hold unproven warm transcripts off the view

A compressed runtime cache is a legal tail, not display history. Publishing
it on session switch then replacing it with the persisted lineage is the
warm-path flicker. Gate that paint on persisted-display provenance and keep
the previous/empty view until REST authority lands.

Co-authored-by: xrbs00 <178640517+xrbs00@users.noreply.github.com>
This commit is contained in:
Brooklyn Nicholson
2026-08-28 11:36:04 -05:00
committed by brooklyn!
parent 00bbfc6900
commit b6eb17d01c
8 changed files with 341 additions and 17 deletions
+2
View File
@@ -276,6 +276,7 @@ export function ContribWiring({ children }: { children: ReactNode }) {
activeSessionIdRef,
ensureSessionState,
getRuntimeIdForStoredSession,
holdSessionTranscriptView,
resetViewSync,
runtimeIdByStoredSessionIdRef,
selectedStoredSessionIdRef,
@@ -494,6 +495,7 @@ export function ContribWiring({ children }: { children: ReactNode }) {
ensureSessionState,
getRouteToken,
getRoutedStoredSessionId,
holdSessionTranscriptView,
navigate,
onFreshDraftRouteIntent: clearRoutedSessionIntent,
requestGateway,
@@ -318,6 +318,7 @@ function Harness({
selectedStoredSessionId,
selectedStoredSessionIdRef: cache.selectedStoredSessionIdRef,
sessionStateByRuntimeIdRef: cache.sessionStateByRuntimeIdRef,
holdSessionTranscriptView: cache.holdSessionTranscriptView,
syncSessionStateToView: cache.syncSessionStateToView,
updateSessionState: cache.updateSessionState
})
@@ -729,6 +729,7 @@ describe('createBackendSessionForSend profile routing', () => {
// succeeds must NOT leave the flag armed.
function ResumeHarness({
onStateUpdate,
onViewSync,
onReady,
requestGateway,
runtimeIdByStoredSessionIdRef,
@@ -736,6 +737,7 @@ function ResumeHarness({
sessionStateByRuntimeIdRef
}: {
onStateUpdate?: (sessionId: string, state: ClientSessionState) => void
onViewSync?: (sessionId: string, state: ClientSessionState) => void
onReady: (
resume: (storedSessionId: string, replaceRoute?: boolean, ownerRoute?: SessionProfileRoute) => Promise<unknown>
) => void
@@ -763,7 +765,7 @@ function ResumeHarness({
selectedStoredSessionId,
selectedStoredSessionIdRef: ref<string | null>(selectedStoredSessionId),
sessionStateByRuntimeIdRef: stateMapRef,
syncSessionStateToView: vi.fn(),
syncSessionStateToView: (sessionId, state) => onViewSync?.(sessionId, state),
updateSessionState: (sessionId, updater, storedSessionId) => {
// Full default shape (not a bare {} cast) so seeded/derived fields like
// turnStartedAt behave as in production state updates.
@@ -817,6 +819,7 @@ function ResumeTimerHarness({
selectedStoredSessionId: null,
selectedStoredSessionIdRef: cache.selectedStoredSessionIdRef,
sessionStateByRuntimeIdRef: cache.sessionStateByRuntimeIdRef,
holdSessionTranscriptView: cache.holdSessionTranscriptView,
syncSessionStateToView: cache.syncSessionStateToView,
getRoutedStoredSessionId: () => null,
updateSessionState: cache.updateSessionState
@@ -3485,6 +3488,102 @@ describe('resumeSession warm-cache mapping integrity', () => {
expect(runtimeIdByStoredSessionIdRef.current.get('stored-A')).toBe('rt-A')
expect(sessionStateByRuntimeIdRef.current.get('rt-A')?.messages[0]?.id).toBe('user-optimistic')
})
it('never publishes a tail-only warm cache before the full persisted history', async () => {
const cachedState = clientState('stored-1')
cachedState.messages = [
{
id: 'user-latest',
role: 'user',
parts: [{ type: 'text', text: 'latest question after long-context completion' }]
},
{
id: 'assistant-latest',
role: 'assistant',
parts: [{ type: 'text', text: 'latest answer after long-context completion' }]
}
]
const runtimeIdByStoredSessionIdRef = {
current: new Map([['stored-1', 'runtime-warm']])
} satisfies MutableRefObject<Map<string, string>>
const sessionStateByRuntimeIdRef = {
current: new Map([['runtime-warm', cachedState]])
} satisfies MutableRefObject<Map<string, ClientSessionState>>
const persistedAuthority = deferred<{
messages: Array<{ content: string; role: 'assistant' | 'user'; timestamp: number }>
session_id: string
}>()
const publications: Array<{ older: boolean; latest: boolean }> = []
setSessions([storedSession({ message_count: 4 })])
vi.mocked(getLatestSessionMessages).mockReturnValue(persistedAuthority.promise as never)
const requestGateway = vi.fn(async (method: string) => {
if (method === 'session.activate') {
return {
info: {},
message_count: 2,
messages: [],
messages_omitted: true,
resumed: 'stored-1',
running: false,
session_id: 'runtime-warm',
session_key: 'stored-1'
} as never
}
return {} as never
})
let resume: ((storedSessionId: string, replaceRoute?: boolean) => Promise<unknown>) | null = null
render(
<ResumeHarness
onReady={ready => (resume = ready)}
onViewSync={(_sessionId, state) => {
const snapshot = JSON.stringify(state.messages)
publications.push({
latest: snapshot.includes('latest question after long-context completion'),
older: snapshot.includes('earlier question before long-context completion')
})
}}
requestGateway={requestGateway}
runtimeIdByStoredSessionIdRef={runtimeIdByStoredSessionIdRef}
sessionStateByRuntimeIdRef={sessionStateByRuntimeIdRef}
/>
)
await waitFor(() => expect(resume).not.toBeNull())
const resumePromise = resume!('stored-1', true)
await waitFor(() =>
expect(requestGateway).toHaveBeenCalledWith(
'session.activate',
expect.objectContaining({ omit_messages: true, session_id: 'runtime-warm' })
)
)
expect(
publications.filter(snapshot => snapshot.latest && !snapshot.older),
`Tail-only warm-cache publication escaped before persisted authority: ${JSON.stringify(publications)}`
).toEqual([])
persistedAuthority.resolve({
messages: [
{ content: 'earlier question before long-context completion', role: 'user', timestamp: 1 },
{ content: 'earlier answer before long-context completion', role: 'assistant', timestamp: 2 },
{ content: 'latest question after long-context completion', role: 'user', timestamp: 3 },
{ content: 'latest answer after long-context completion', role: 'assistant', timestamp: 4 }
],
session_id: 'stored-1'
})
await resumePromise
expect(publications.some(snapshot => snapshot.latest && snapshot.older)).toBe(true)
expect(
publications.filter(snapshot => snapshot.latest && !snapshot.older),
`Tail-only warm-cache publication escaped: ${JSON.stringify(publications)}`
).toEqual([])
})
})
describe('createBackendSessionForSend workspace target', () => {
@@ -133,6 +133,12 @@ import { sessionContextDrift } from '../session-context-drift'
import { singleFlightSessionResume } from '../use-prompt-actions/single-flight-resume'
import { pendingClarifyToolPayload, restorePendingClarifyFromSnapshot } from './restore-pending-clarify'
import {
createPersistedDisplayTranscriptProvenance,
hasPersistedDisplayTranscriptProvenance,
suppressTranscriptForView,
withoutTranscriptProvenance
} from './transcript-provenance'
import {
appendLiveSessionProjection,
applyRuntimeInfo,
@@ -166,9 +172,10 @@ interface SessionActionsOptions {
busyRef: MutableRefObject<boolean>
creatingSessionRef: MutableRefObject<boolean>
ensureSessionState: (sessionId: string, storedSessionId?: string | null) => ClientSessionState
getRouteToken: () => string
getRoutedStoredSessionId: () => null | string
navigate: NavigateFunction
getRouteToken: () => string
getRoutedStoredSessionId: () => null | string
holdSessionTranscriptView?: (runtimeId: string) => () => void
navigate: NavigateFunction
onFreshDraftRouteIntent?: () => void
requestGateway: <T>(method: string, params?: Record<string, unknown>) => Promise<T>
resetViewSync: () => void
@@ -310,6 +317,7 @@ export function useSessionActions({
ensureSessionState,
getRouteToken,
getRoutedStoredSessionId,
holdSessionTranscriptView,
navigate,
onFreshDraftRouteIntent,
requestGateway,
@@ -1000,19 +1008,53 @@ export function useSessionActions({
publishSessionState(cachedRuntimeId, cachedViewState)
}
const expectedProvenance = stored
? createPersistedDisplayTranscriptProvenance({
lineageRootId: stored._lineage_root_id ?? null,
scope: sessionRestScope,
storedSessionId
})
: null
const hasValidProvenance = Boolean(
expectedProvenance && hasPersistedDisplayTranscriptProvenance(cachedViewState, expectedProvenance)
)
if (!hasValidProvenance) {
cachedViewState = withoutTranscriptProvenance(cachedViewState)
}
if (sessionShouldHaveTranscript(stored) && cachedViewState.messages.length === 0) {
runtimeIdByStoredSessionIdRef.current.delete(storedSessionId)
sessionStateByRuntimeIdRef.current.delete(cachedRuntimeId)
dropSessionState(cachedRuntimeId)
} else {
// Paint the warm cache immediately. The persisted transcript still
// needs a refresh because a resumed runtime may carry only the
// agent's compressed projection, but that read must start after
// session.activate reattaches the live transport. Otherwise a turn
// can finish between the early REST snapshot and the reattach: its
// terminal events go to the detached socket while the stale snapshot
// leaves Desktop showing only the pre-disconnect partial answer.
// Bind the warm runtime immediately so cwd/workspace ownership don't
// wait on session.activate (#71254). Unproven cache entries (no
// persisted-display provenance) stay off the view until REST
// authority lands — a compressed runtime tail is legal in cache and
// is exactly the session-switch flicker (#73646). Proven caches and
// same-session re-resumes still paint immediately. The persisted
// refresh itself still starts after activate reattaches the live
// transport, so a turn finishing between snapshot and reattach
// cannot leave a stale partial on screen.
const shouldRefreshPersistedTranscript = !isWatchWindow()
const suppressUnprovenWarmTranscript =
!resumedSameSelectedSession && shouldRefreshPersistedTranscript && !hasValidProvenance
let releaseHeldTranscriptView = suppressUnprovenWarmTranscript
? holdSessionTranscriptView?.(cachedRuntimeId)
: undefined
const releaseTranscriptView = () => {
releaseHeldTranscriptView?.()
releaseHeldTranscriptView = undefined
}
const publishDegradedWarmCache = () => {
releaseTranscriptView()
syncSessionStateToView(cachedRuntimeId, cachedViewState)
}
setFreshDraftReady(false)
clearNotifications()
@@ -1020,7 +1062,10 @@ export function useSessionActions({
selectedStoredSessionIdRef.current = storedSessionId
setActiveSessionId(cachedRuntimeId)
activeSessionIdRef.current = cachedRuntimeId
syncSessionStateToView(cachedRuntimeId, cachedViewState)
syncSessionStateToView(
cachedRuntimeId,
suppressTranscriptForView(cachedViewState, suppressUnprovenWarmTranscript)
)
setCurrentCwdTransient(cachedViewState.cwd)
// The warm cache IS this conversation's own workspace truth, so the
// switch is already re-homed here. This claim cannot wait for
@@ -1061,6 +1106,8 @@ export function useSessionActions({
setCurrentUsage(current => ({ ...current, ...usage }))
}
publishDegradedWarmCache()
return
}
@@ -1160,7 +1207,10 @@ export function useSessionActions({
busyRef.current = running
setBusy(running)
setAwaitingResponse(running && !pendingClarify)
syncSessionStateToView(cachedRuntimeId, activatedLivenessState)
syncSessionStateToView(
cachedRuntimeId,
suppressTranscriptForView(activatedLivenessState, suppressUnprovenWarmTranscript)
)
// session.activate is the ordering barrier for reconnect recovery:
// it atomically rebinds a running turn before returning. If the
@@ -1178,6 +1228,8 @@ export function useSessionActions({
// which is intentionally smaller than the user-visible conversation.
// Reconcile its in-flight/queued tail onto the complete transcript
// instead of replacing durable history while the turn is running.
let acceptedPersistedDisplayTranscript = false
if (persistedTranscriptPromise) {
const persisted = await persistedTranscriptPromise
@@ -1203,6 +1255,7 @@ export function useSessionActions({
persistedMatchesActivatedSession &&
(persisted.messages.length || !activatedMessages.length)
) {
acceptedPersistedDisplayTranscript = Boolean(expectedProvenance)
// The REST hydration is a newest-tail page; graft it onto any
// older pages the previous view already backfilled so
// re-activating a scrolled-back session keeps its history.
@@ -1253,11 +1306,17 @@ export function useSessionActions({
const visibleActivatedMessages =
pendingClarifyProjection?.messages ?? clearedClarifyProjection?.messages ?? activatedMessages
releaseTranscriptView()
const activatedState = updateSessionState(
cachedRuntimeId,
state => ({
...state,
messages: visibleActivatedMessages,
transcriptProvenance:
acceptedPersistedDisplayTranscript || hasValidProvenance
? (expectedProvenance ?? undefined)
: undefined,
...(pendingClarifyProjection
? {
awaitingResponse: false,
@@ -1304,12 +1363,16 @@ export function useSessionActions({
}
if (!isSessionGoneError(error)) {
publishDegradedWarmCache()
return
}
runtimeIdByStoredSessionIdRef.current.delete(storedSessionId)
sessionStateByRuntimeIdRef.current.delete(cachedRuntimeId)
dropSessionState(cachedRuntimeId)
} finally {
releaseTranscriptView()
}
}
}
@@ -1854,6 +1917,7 @@ export function useSessionActions({
activeSessionIdRef,
busyRef,
copy,
holdSessionTranscriptView,
requestGateway,
resetViewSync,
runtimeIdByStoredSessionIdRef,
@@ -0,0 +1,59 @@
import { describe, expect, it } from 'vitest'
import { createClientSessionState } from '@/lib/chat-runtime'
import {
createPersistedDisplayTranscriptProvenance,
hasPersistedDisplayTranscriptProvenance,
invalidatePersistedDisplayTranscriptAuthority,
suppressTranscriptForView,
withoutTranscriptProvenance
} from './transcript-provenance'
const expected = createPersistedDisplayTranscriptProvenance({
lineageRootId: 'root-1',
scope: { connectionId: 'conn-1', profile: 'coder' },
storedSessionId: 'stored-1'
})
describe('transcript provenance', () => {
it('matches only the same connection, profile, stored id, and lineage', () => {
const state = createClientSessionState('stored-1')
state.transcriptProvenance = expected
expect(hasPersistedDisplayTranscriptProvenance(state, expected)).toBe(true)
expect(
hasPersistedDisplayTranscriptProvenance(state, {
...expected,
lineageRootId: 'root-2'
})
).toBe(false)
expect(
hasPersistedDisplayTranscriptProvenance(state, {
...expected,
profile: 'default'
})
).toBe(false)
})
it('strips proof and bumps the authority epoch on invalidation', () => {
const state = createClientSessionState('stored-1')
state.transcriptProvenance = expected
state.transcriptAuthorityEpoch = 3
const next = invalidatePersistedDisplayTranscriptAuthority(state)
expect(next.transcriptProvenance).toBeUndefined()
expect(next.transcriptAuthorityEpoch).toBe(4)
expect(withoutTranscriptProvenance(state).transcriptProvenance).toBeUndefined()
})
it('hides messages from the view without dropping the cache entry', () => {
const state = createClientSessionState('stored-1')
state.messages = [{ id: 'u1', role: 'user', parts: [{ type: 'text', text: 'hi' }] }]
expect(suppressTranscriptForView(state, false)).toBe(state)
expect(suppressTranscriptForView(state, true).messages).toEqual([])
expect(state.messages).toHaveLength(1)
})
})
@@ -0,0 +1,69 @@
import type { ClientSessionState, PersistedDisplayTranscriptProvenance } from '../../../types'
export type TranscriptProvenanceScope =
string | null | undefined | { connectionId?: string | null; profile?: string | null }
export function createPersistedDisplayTranscriptProvenance({
lineageRootId,
scope,
storedSessionId
}: {
storedSessionId: string
lineageRootId: string | null
scope: TranscriptProvenanceScope
}): PersistedDisplayTranscriptProvenance {
const connectionId = typeof scope === 'object' && scope ? (scope.connectionId ?? '').trim() : ''
const rawProfile = typeof scope === 'string' ? scope : scope?.profile
return {
connectionId,
coverage: 'latest-page',
lineageRootId,
profile: rawProfile?.trim() || 'default',
source: 'persisted-display',
storedSessionId
}
}
export function hasPersistedDisplayTranscriptProvenance(
state: Pick<ClientSessionState, 'transcriptProvenance'>,
expected: PersistedDisplayTranscriptProvenance
): boolean {
const actual = state.transcriptProvenance
return Boolean(
actual &&
actual.source === expected.source &&
actual.connectionId === expected.connectionId &&
actual.profile === expected.profile &&
actual.storedSessionId === expected.storedSessionId &&
actual.lineageRootId === expected.lineageRootId &&
actual.coverage === expected.coverage
)
}
export function withoutTranscriptProvenance(state: ClientSessionState): ClientSessionState {
if (!state.transcriptProvenance) {
return state
}
const { transcriptProvenance: _transcriptProvenance, ...withoutProvenance } = state
return withoutProvenance
}
export function invalidatePersistedDisplayTranscriptAuthority(state: ClientSessionState): ClientSessionState {
return {
...state,
transcriptAuthorityEpoch: (state.transcriptAuthorityEpoch ?? 0) + 1,
transcriptProvenance: undefined
}
}
export function suppressTranscriptForView(state: ClientSessionState, suppress: boolean): ClientSessionState {
if (!suppress || state.messages.length === 0) {
return state
}
return { ...state, messages: [] }
}
@@ -25,6 +25,10 @@ import { $sessionStates, $sessionTiles, publishSessionState, releaseSessionTrans
import type { ClientSessionState } from '../../types'
import { SessionStateCache } from '../session-state-cache'
import {
invalidatePersistedDisplayTranscriptAuthority,
suppressTranscriptForView
} from './use-session-actions/transcript-provenance'
import { chatMessageArraysEquivalent } from './use-session-actions/utils'
interface SessionStateCacheOptions {
@@ -124,6 +128,7 @@ export function useSessionStateCache({
const sessionStateCache = sessionStateByRuntimeIdRef.current
const pendingViewStateRef = useRef<{ sessionId: string; state: ClientSessionState } | null>(null)
const viewSyncRafRef = useRef<number | null>(null)
const transcriptViewGateByRuntimeIdRef = useRef(new Map<string, symbol>())
// Runtime id whose transcript currently occupies `$messages` — lets the
// flush below tell a same-session refresh from a thread switch.
const viewSessionIdRef = useRef<string | null>(null)
@@ -142,7 +147,7 @@ export function useSessionStateCache({
// Stored id changed (e.g. auto-compression rotated it). Create a NEW
// state object rather than mutating in place — updateSessionState needs
// the PREVIOUS state to detect transitions (busy→idle, id rotation).
const updated = { ...existing, storedSessionId }
const updated = invalidatePersistedDisplayTranscriptAuthority({ ...existing, storedSessionId })
// Drop the obsolete stored→runtime reverse mapping as soon as the id
// rotates (e.g. auto-compression forks a continuation). Leaving the
@@ -202,6 +207,17 @@ export function useSessionStateCache({
}
}, [])
const holdSessionTranscriptView = useCallback((runtimeId: string): (() => void) => {
const token = Symbol(runtimeId)
transcriptViewGateByRuntimeIdRef.current.set(runtimeId, token)
return () => {
if (transcriptViewGateByRuntimeIdRef.current.get(runtimeId) === token) {
transcriptViewGateByRuntimeIdRef.current.delete(runtimeId)
}
}
}, [])
const flushPendingViewState = useCallback(() => {
const pending = pendingViewStateRef.current
pendingViewStateRef.current = null
@@ -265,8 +281,10 @@ export function useSessionStateCache({
return
}
syncRuntimeMetadataToView(state)
pendingViewStateRef.current = { sessionId, state }
const viewState = suppressTranscriptForView(state, transcriptViewGateByRuntimeIdRef.current.has(sessionId))
syncRuntimeMetadataToView(viewState)
pendingViewStateRef.current = { sessionId, state: viewState }
// Terminal / attention transitions (turn finished, error, or the agent is
// now waiting on the user) MUST reach the view immediately. Electron
@@ -278,7 +296,7 @@ export function useSessionStateCache({
// state anyway). The plain busy heartbeat stays RAF-batched: that
// coalescing exists only to keep periodic `session.info` updates from
// churning `$messages` and jerking the scroll position while reading.
const isCriticalTransition = !state.busy || state.needsInput
const isCriticalTransition = !viewState.busy || viewState.needsInput
if (isCriticalTransition) {
if (viewSyncRafRef.current !== null && typeof window !== 'undefined') {
@@ -382,6 +400,7 @@ export function useSessionStateCache({
activeSessionIdRef,
ensureSessionState,
getRuntimeIdForStoredSession,
holdSessionTranscriptView,
resetViewSync,
runtimeIdByStoredSessionIdRef,
selectedStoredSessionIdRef,
+11
View File
@@ -171,8 +171,19 @@ export interface SidebarNavItem {
keybindActionId?: string
}
export interface PersistedDisplayTranscriptProvenance {
source: 'persisted-display'
connectionId: string
profile: string
storedSessionId: string
lineageRootId: string | null
coverage: 'latest-page'
}
export interface ClientSessionState {
storedSessionId: string | null
transcriptAuthorityEpoch?: number
transcriptProvenance?: PersistedDisplayTranscriptProvenance
messages: ChatMessage[]
branch: string
cwd: string