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:
committed by
brooklyn!
parent
00bbfc6900
commit
b6eb17d01c
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user