diff --git a/apps/desktop/src/app/session/hooks/use-message-stream.ts b/apps/desktop/src/app/session/hooks/use-message-stream.ts index a77114b9778..fe89c8b5055 100644 --- a/apps/desktop/src/app/session/hooks/use-message-stream.ts +++ b/apps/desktop/src/app/session/hooks/use-message-stream.ts @@ -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) { diff --git a/apps/desktop/src/app/session/hooks/use-session-state-cache.test.tsx b/apps/desktop/src/app/session/hooks/use-session-state-cache.test.tsx new file mode 100644 index 00000000000..e865205d828 --- /dev/null +++ b/apps/desktop/src/app/session/hooks/use-session-state-cache.test.tsx @@ -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 + +interface HarnessProps { + activeSessionId: string | null + onReady: (cache: Cache) => void + selectedStoredSessionId: string | null +} + +function Harness({ activeSessionId, onReady, selectedStoredSessionId }: HarnessProps) { + const busyRef: MutableRefObject = { 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( + (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( (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( (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() + }) +}) diff --git a/apps/desktop/src/app/session/hooks/use-session-state-cache.ts b/apps/desktop/src/app/session/hooks/use-session-state-cache.ts index 8683aa4a80c..c0a78da300e 100644 --- a/apps/desktop/src/app/session/hooks/use-session-state-cache.ts +++ b/apps/desktop/src/app/session/hooks/use-session-state-cache.ts @@ -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( diff --git a/apps/desktop/src/app/types.ts b/apps/desktop/src/app/types.ts index bc4495f3525..fc39a6b80e2 100644 --- a/apps/desktop/src/app/types.ts +++ b/apps/desktop/src/app/types.ts @@ -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 } diff --git a/apps/desktop/src/lib/chat-runtime.ts b/apps/desktop/src/lib/chat-runtime.ts index a5f9d95c413..2599fb0dad3 100644 --- a/apps/desktop/src/lib/chat-runtime.ts +++ b/apps/desktop/src/lib/chat-runtime.ts @@ -46,7 +46,8 @@ export function createClientSessionState( sawAssistantPayload: false, pendingBranchGroup: null, interrupted: false, - needsInput: false + needsInput: false, + turnStartedAt: null } }