fix(desktop): preserve runtime session timer across warm switches

This commit is contained in:
Jakub Wolniewicz
2026-07-15 15:15:25 +02:00
committed by brooklyn!
parent e2ee9a6978
commit 868ca90218
4 changed files with 107 additions and 7 deletions

View File

@@ -50,6 +50,7 @@ import {
$resumeFailedSessionId,
$selectedStoredSessionId,
$sessions,
$sessionStartedAt,
$turnStartedAt,
_resetSessionOwnerHintsForTests,
getSessionOwnerHint,
@@ -74,6 +75,7 @@ import {
setResumeFailedSessionId,
setSelectedStoredSessionId,
setSessions,
setSessionStartedAt,
setTurnStartedAt,
setUnlistedSessionOwnerRows
} from '@/store/session'
@@ -1107,6 +1109,7 @@ describe('resumeSession failure recovery', () => {
cleanup()
setActiveSessionId(null)
setResumeFailedSessionId(null)
setSessionStartedAt(null)
setMessages([])
setSessions([])
$removedSessionIds.set(new Set())
@@ -1774,6 +1777,7 @@ describe('resumeSession failure recovery', () => {
personality: '',
provider: '',
reasoningEffort: '',
runtimeStartedAt: 0,
sawAssistantPayload: false,
serviceTier: '',
storedSessionId: 'stored-1',
@@ -1892,6 +1896,7 @@ function BranchHarness({
navigate = vi.fn(),
onCurrentReady,
onReady,
onStateUpdate,
onRefs,
requestGateway,
selectedStoredSessionId = null
@@ -1900,6 +1905,7 @@ function BranchHarness({
navigate?: ReturnType<typeof vi.fn>
onCurrentReady?: (branchCurrentSession: (messageId?: string) => Promise<boolean>) => void
onReady: (branchStoredSession: (storedSessionId: string, sessionProfile?: string | null) => Promise<boolean>) => void
onStateUpdate?: (sessionId: string, state: ClientSessionState) => void
onRefs?: (refs: {
activeSessionIdRef: MutableRefObject<string | null>
selectedStoredSessionIdRef: MutableRefObject<string | null>
@@ -1929,7 +1935,12 @@ function BranchHarness({
selectedStoredSessionIdRef,
sessionStateByRuntimeIdRef: ref(new Map<string, ClientSessionState>()),
syncSessionStateToView: vi.fn(),
updateSessionState: () => ({}) as ClientSessionState
updateSessionState: (sessionId, updater) => {
const next = updater(createClientSessionState(null))
onStateUpdate?.(sessionId, next)
return next
}
})
useEffect(() => {
@@ -2053,6 +2064,46 @@ describe('branchStoredSession desktop source tagging', () => {
expect($sessionTiles.get().some(tile => tile.storedSessionId === 'branch-stored')).toBe(true)
})
it('keeps the branch runtime elapsed anchor in its tile state without stealing the primary session', async () => {
const runtimeStartedAt = 1_700_000_000_000
vi.spyOn(Date, 'now').mockReturnValue(runtimeStartedAt)
const requestGateway = vi.fn(async (method: string) => {
if (method === 'session.create') {
return { session_id: 'branch-runtime', stored_session_id: 'branch-stored' } as never
}
return {} as never
})
const stateUpdates: Array<{ sessionId: string; state: ClientSessionState }> = []
let branchStoredSession: ((storedSessionId: string) => Promise<boolean>) | null = null
setSessions([storedSession({ id: 'stored-parent', message_count: 1 })])
setSelectedStoredSessionId('stored-parent')
vi.mocked(getAllSessionMessages).mockResolvedValue({
messages: [{ content: 'branch me', role: 'user', timestamp: 1 }],
session_id: 'stored-parent'
} as never)
render(
<BranchHarness
onReady={branch => (branchStoredSession = branch)}
onStateUpdate={(sessionId, state) => stateUpdates.push({ sessionId, state })}
requestGateway={requestGateway}
/>
)
await waitFor(() => expect(branchStoredSession).not.toBeNull())
await expect(branchStoredSession!('stored-parent')).resolves.toBe(true)
expect(stateUpdates).toContainEqual(
expect.objectContaining({
sessionId: 'branch-runtime',
state: expect.objectContaining({ runtimeStartedAt })
})
)
expect($selectedStoredSessionId.get()).toBe('stored-parent')
})
// A branch belongs to the backend that OWNS its parent. Routing on profile
// alone silently sends session.create to whatever socket is active, so a
// remote-owned parent branched while another connection is active creates
@@ -2077,6 +2128,7 @@ describe('branchStoredSession desktop source tagging', () => {
setSessions([
storedSession({ connection_id: 'pandora', id: 'stored-parent', message_count: 1, profile: 'default' })
])
vi.mocked(getAllSessionMessages).mockResolvedValue({
messages: [{ content: 'branch me', role: 'user', timestamp: 1 }],
session_id: 'stored-parent'
@@ -2555,6 +2607,7 @@ describe('resumeSession warm-cache mapping integrity', () => {
cleanup()
setActiveSessionId(null)
setResumeFailedSessionId(null)
setSessionStartedAt(null)
setMessages([])
setSessions([])
vi.mocked(getSession).mockReset()
@@ -4633,6 +4686,42 @@ describe('resumeSession warm-cache mapping integrity', () => {
`Tail-only warm-cache publication escaped: ${JSON.stringify(publications)}`
).toEqual([])
})
it('keeps the runtime elapsed anchor when a warm-cached session is reselected', async () => {
const runtimeStartedAt = 1_700_000_000_000
const runtimeIdByStoredSessionIdRef: MutableRefObject<Map<string, string>> = {
current: new Map([['stored-A', 'rt-A']])
}
const sessionStateByRuntimeIdRef: MutableRefObject<Map<string, ClientSessionState>> = {
current: new Map([['rt-A', { ...clientState('stored-A'), runtimeStartedAt }]])
}
const requestGateway = vi.fn(async (method: string) => {
if (method === 'session.usage') {
return { input: 0, output: 0, total: 0 } as never
}
return {} as never
})
let resume: ((storedSessionId: string, replaceRoute?: boolean) => Promise<unknown>) | null = null
render(
<ResumeHarness
onReady={r => (resume = r)}
requestGateway={requestGateway}
runtimeIdByStoredSessionIdRef={runtimeIdByStoredSessionIdRef}
sessionStateByRuntimeIdRef={sessionStateByRuntimeIdRef}
/>
)
await waitFor(() => expect(resume).not.toBeNull())
vi.spyOn(Date, 'now').mockReturnValue(1_800_000_000_000)
await resume!('stored-A', true)
expect($sessionStartedAt.get()).toBe(runtimeStartedAt)
})
})
describe('createBackendSessionForSend workspace target', () => {

View File

@@ -788,13 +788,16 @@ export function useSessionActions({
setNewChatWorkspaceTarget(undefined)
setActiveSessionId(created.session_id)
setSelectedStoredSessionId(stored)
setSessionStartedAt(Date.now())
const runtimeStartedAt = Date.now()
setSessionStartedAt(runtimeStartedAt)
const yoloArmed = $yoloActive.get()
const runtimeInfo = applyRuntimeInfo(created.info)
if (runtimeInfo) {
updateSessionState(created.session_id, state => ({ ...state, ...runtimeInfo }), stored)
}
updateSessionState(
created.session_id,
state => ({ ...state, ...(runtimeInfo ?? {}), runtimeStartedAt }),
stored
)
// User may have armed YOLO on the new-chat draft before the runtime
// session existed — apply it to the freshly created session.
@@ -1349,7 +1352,7 @@ export function useSessionActions({
// un-owned for the life of the session (#71254).
setWorkspaceCwdOwner(storedSessionId)
setCurrentBranch(cachedViewState.branch)
setSessionStartedAt(Date.now())
setSessionStartedAt(cachedViewState.runtimeStartedAt)
try {
const replay = pendingSessionReplay(cachedRuntimeId)
@@ -1818,7 +1821,8 @@ export function useSessionActions({
clearNotifications()
setSelectedStoredSessionId(storedSessionId)
selectedStoredSessionIdRef.current = storedSessionId
setSessionStartedAt(Date.now())
const runtimeStartedAt = Date.now()
setSessionStartedAt(runtimeStartedAt)
const stored =
$sessions.get().find(session => sessionMatchesStoredId(session, storedSessionId)) ?? storedForProfile
@@ -2159,6 +2163,7 @@ export function useSessionActions({
state => ({
// The deferred build reports the session's own effort later (#79807).
...markReasoningEffortPending({ ...state, ...(runtimeInfo ?? {}) }),
runtimeStartedAt,
messages: visibleMessagesForView,
transcriptProvenance,
busy: resumedRunning,
@@ -2526,6 +2531,7 @@ export function useSessionActions({
const effectiveBranchMessages = responseBranchMessages.length ? responseBranchMessages : branchMessages
const routedSessionId = branched.stored_session_id ?? branched.session_id
const runtimeStartedAt = Date.now()
const preview = effectiveBranchMessages.map(({ content }) => content).find(Boolean) ?? null
// Record the exact owner and pin its socket THE MOMENT the create
@@ -2573,6 +2579,7 @@ export function useSessionActions({
branched.session_id,
state => ({
...state,
runtimeStartedAt,
messages: effectiveBranchMessages.map(({ source }) => source),
busy: false,
awaitingResponse: false

View File

@@ -189,6 +189,9 @@ export interface ClientSessionState {
/** A blocking clarify prompt is waiting on the user for this session. Drives
* the sidebar "needs input" indicator; cleared when the turn resumes/ends. */
needsInput: boolean
/** Epoch ms this renderer attached to the live runtime. Per-runtime so a
* cached session keeps its elapsed anchor when it becomes foreground again. */
runtimeStartedAt: number
/** Epoch ms the current turn started, or null when idle. Per-session so a
* background turn's elapsed timer keeps counting while another session is
* focused, and switching sessions doesn't zero a still-running turn's clock.

View File

@@ -43,6 +43,7 @@ export function createClientSessionState(
interrupted: false,
interimBoundaryPending: false,
needsInput: false,
runtimeStartedAt: Date.now(),
turnStartedAt: null,
turnLive: false,
usage: null