fix(desktop): make the running-turn timer per-session (#41182)

The desktop statusbar turn timer read a single process-global $turnStartedAt,
set/cleared only for the active session. With multiple same-profile sessions
running at once, switching to session B reset the one shared clock, so
session A's still-running turn "restarted from zero" the moment you left it —
exactly the behaviour @Da7_Tech reported after the profile-scoped session work.

Move turnStartedAt onto ClientSessionState so each session owns its own turn
clock. The global atom now just mirrors whichever session is focused, written
on view-sync (the flush that already stages the active session's state). A
backgrounded turn keeps counting in its own cache entry, and focusing it
restores its real elapsed time instead of zeroing it.

Set/clear sites: message.start (seed), message.complete + error + interrupted
bail (clear), and the session.info running-state path (seed if missing / clear
on stop) so a turn that goes busy via session.info — e.g. resuming a session
that's already running — also gets a clock.

Note: the agent loop itself never froze — every same-profile session runs in
its own backend thread and background deltas are buffered per-session. This
fixes the timer-reset symptom; the "no live progress until you return" is
inherent to a single-view transcript and is out of scope here.
This commit is contained in:
Teknium 2026-06-07 04:29:05 -07:00 committed by GitHub
parent 1a4010edf5
commit 9d72680ca3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 142 additions and 8 deletions

View file

@ -451,7 +451,8 @@ export function useMessageStream({
busy: false,
needsInput: false,
pendingBranchGroup: null,
streamId: null
streamId: null,
turnStartedAt: null
}
}
@ -541,7 +542,8 @@ export function useMessageStream({
pendingBranchGroup: null,
awaitingResponse: false,
busy: false,
needsInput: false
needsInput: false,
turnStartedAt: null
}
})
@ -599,7 +601,8 @@ export function useMessageStream({
sawAssistantPayload: true,
awaitingResponse: false,
busy: false,
needsInput: false
needsInput: false,
turnStartedAt: null
}
})
},
@ -683,7 +686,8 @@ export function useMessageStream({
if (busy) {
return {
...state,
busy
busy,
turnStartedAt: state.turnStartedAt ?? Date.now()
}
}
@ -696,7 +700,8 @@ export function useMessageStream({
awaitingResponse: false,
busy,
pendingBranchGroup: null,
streamId: null
streamId: null,
turnStartedAt: null
}
})
}
@ -735,7 +740,8 @@ export function useMessageStream({
busy: true,
awaitingResponse: true,
sawAssistantPayload: false,
interrupted: false
interrupted: false,
turnStartedAt: Date.now()
}))
if (isActiveEvent) {

View file

@ -0,0 +1,118 @@
import { act, cleanup, render } from '@testing-library/react'
import type { MutableRefObject } from 'react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { $turnStartedAt, setTurnStartedAt } from '@/store/session'
import { useSessionStateCache } from './use-session-state-cache'
type Cache = ReturnType<typeof useSessionStateCache>
interface HarnessProps {
activeSessionId: string | null
onReady: (cache: Cache) => void
selectedStoredSessionId: string | null
}
function Harness({ activeSessionId, onReady, selectedStoredSessionId }: HarnessProps) {
const busyRef: MutableRefObject<boolean> = { current: false }
const cache = useSessionStateCache({
activeSessionId,
busyRef,
selectedStoredSessionId,
setAwaitingResponse: () => undefined,
setBusy: () => undefined,
setMessages: () => undefined
})
onReady(cache)
return null
}
describe('useSessionStateCache — per-session turn timer', () => {
beforeEach(() => {
// The view-sync flush runs on a real rAF in the browser path; in jsdom we
// want it synchronous so the global mirror is observable immediately. The
// hook closes over `window.requestAnimationFrame`, so stub that exact ref.
// Return null (not a handle) so the hook's `viewSyncRafRef.current = rAF(...)`
// assignment doesn't overwrite the null the synchronous callback just set —
// otherwise the ref reads truthy and the NEXT sync is suppressed (a real
// browser returns a handle but runs the callback async, so this race is a
// test-only artifact of firing synchronously).
vi.spyOn(window, 'requestAnimationFrame').mockImplementation((cb: FrameRequestCallback) => {
cb(0)
return null as unknown as number
})
setTurnStartedAt(null)
})
afterEach(() => {
cleanup()
vi.restoreAllMocks()
setTurnStartedAt(null)
})
it("keeps a background session's running turn clock and never mirrors it to the view", () => {
let cache!: Cache
// Active session is "fg-runtime"; the turn starts on the BACKGROUND session.
render(
<Harness activeSessionId="fg-runtime" onReady={c => (cache = c)} selectedStoredSessionId="fg-stored" />
)
const startedAt = 1_700_000_000_000
act(() => {
cache.updateSessionState(
'bg-runtime',
state => ({ ...state, busy: true, turnStartedAt: startedAt }),
'bg-stored'
)
})
// The background session's own cache entry holds the clock...
expect(cache.sessionStateByRuntimeIdRef.current.get('bg-runtime')?.turnStartedAt).toBe(startedAt)
// ...but the global atom (statusbar timer) is untouched — a background turn
// must not drive the foreground timer.
expect($turnStartedAt.get()).toBeNull()
})
it("mirrors the focused session's turn clock into the global atom on view-sync", () => {
let cache!: Cache
render(<Harness activeSessionId="fg-runtime" onReady={c => (cache = c)} selectedStoredSessionId="fg-stored" />)
const startedAt = 1_700_000_111_000
// A turn on the ACTIVE session stages into the view; the flush mirrors its
// turnStartedAt into the global atom the statusbar reads.
act(() => {
cache.updateSessionState(
'fg-runtime',
state => ({ ...state, busy: true, turnStartedAt: startedAt }),
'fg-stored'
)
})
expect($turnStartedAt.get()).toBe(startedAt)
})
it('clears the global clock when the focused turn ends', () => {
let cache!: Cache
render(<Harness activeSessionId="fg-runtime" onReady={c => (cache = c)} selectedStoredSessionId="fg-stored" />)
act(() => {
cache.updateSessionState(
'fg-runtime',
state => ({ ...state, busy: true, turnStartedAt: 1_700_000_222_000 }),
'fg-stored'
)
})
expect($turnStartedAt.get()).toBe(1_700_000_222_000)
act(() => {
cache.updateSessionState('fg-runtime', state => ({ ...state, busy: false, turnStartedAt: null }))
})
expect($turnStartedAt.get()).toBeNull()
})
})

View file

@ -5,7 +5,7 @@ import type { ChatMessage } from '@/lib/chat-messages'
import { preserveLocalAssistantErrors } from '@/lib/chat-messages'
import { createClientSessionState } from '@/lib/chat-runtime'
import { setMutableRef } from '@/lib/mutable-ref'
import { $busy, $messages, noteSessionActivity, setSessionAttention, setSessionWorking } from '@/store/session'
import { $busy, $messages, noteSessionActivity, setSessionAttention, setSessionWorking, setTurnStartedAt } from '@/store/session'
import type { ClientSessionState } from '../../types'
@ -92,6 +92,10 @@ export function useSessionStateCache({
setBusy(pending.state.busy)
setMutableRef(busyRef, pending.state.busy)
setAwaitingResponse(pending.state.awaitingResponse)
// Mirror the focused session's per-session turn clock into the global
// atom the statusbar timer reads. Keeps a backgrounded turn's elapsed
// time intact on focus instead of zeroing it (the "timer restarts" bug).
setTurnStartedAt(pending.state.turnStartedAt)
}, [busyRef, setAwaitingResponse, setBusy, setMessages])
const syncSessionStateToView = useCallback(

View file

@ -91,4 +91,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 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.
* The global $turnStartedAt mirrors whichever session is currently viewed. */
turnStartedAt: number | null
}

View file

@ -46,7 +46,8 @@ export function createClientSessionState(
sawAssistantPayload: false,
pendingBranchGroup: null,
interrupted: false,
needsInput: false
needsInput: false,
turnStartedAt: null
}
}