Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 12 additions & 6 deletions apps/desktop/src/app/session/hooks/use-message-stream.ts
Original file line number Diff line number Diff line change
Expand Up @@ -451,7 +451,8 @@ export function useMessageStream({
busy: false,
needsInput: false,
pendingBranchGroup: null,
streamId: null
streamId: null,
turnStartedAt: null
}
}

Expand Down Expand Up @@ -541,7 +542,8 @@ export function useMessageStream({
pendingBranchGroup: null,
awaitingResponse: false,
busy: false,
needsInput: false
needsInput: false,
turnStartedAt: null
}
})

Expand Down Expand Up @@ -599,7 +601,8 @@ export function useMessageStream({
sawAssistantPayload: true,
awaitingResponse: false,
busy: false,
needsInput: false
needsInput: false,
turnStartedAt: null
}
})
},
Expand Down Expand Up @@ -683,7 +686,8 @@ export function useMessageStream({
if (busy) {
return {
...state,
busy
busy,
turnStartedAt: state.turnStartedAt ?? Date.now()
}
}

Expand All @@ -696,7 +700,8 @@ export function useMessageStream({
awaitingResponse: false,
busy,
pendingBranchGroup: null,
streamId: null
streamId: null,
turnStartedAt: null
}
})
}
Expand Down Expand Up @@ -735,7 +740,8 @@ export function useMessageStream({
busy: true,
awaitingResponse: true,
sawAssistantPayload: false,
interrupted: false
interrupted: false,
turnStartedAt: Date.now()
}))

if (isActiveEvent) {
Expand Down
118 changes: 118 additions & 0 deletions apps/desktop/src/app/session/hooks/use-session-state-cache.test.tsx
Original file line number Diff line number Diff line change
@@ -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()
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down Expand Up @@ -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(
Expand Down
5 changes: 5 additions & 0 deletions apps/desktop/src/app/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
3 changes: 2 additions & 1 deletion apps/desktop/src/lib/chat-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,8 @@ export function createClientSessionState(
sawAssistantPayload: false,
pendingBranchGroup: null,
interrupted: false,
needsInput: false
needsInput: false,
turnStartedAt: null
}
}

Expand Down
Loading