diff --git a/apps/desktop/src/app/contrib/hooks/use-background-sync.test.ts b/apps/desktop/src/app/contrib/hooks/use-background-sync.test.ts index 404f5b536d..e42d95011d 100644 --- a/apps/desktop/src/app/contrib/hooks/use-background-sync.test.ts +++ b/apps/desktop/src/app/contrib/hooks/use-background-sync.test.ts @@ -14,9 +14,11 @@ import { } from '@/store/session' import { $attentionSessionIds, + $sessionTiles, $stalledSessionIds, $workingSessionIds, clearAllSessionStates, + publishSessionState, SESSION_WATCHDOG_TIMEOUT_MS } from '@/store/session-states' @@ -158,6 +160,7 @@ afterEach(() => { vi.clearAllMocks() vi.restoreAllMocks() clearAllSessionStates() + $sessionTiles.set([]) resetTypingActivityTracking() }) @@ -228,7 +231,6 @@ describe('active transcript refresh', () => { const signatureRef = { current: new Map() } const requestSequenceRef = { current: 0 } - const busyRef = { current: false } vi.mocked(getLatestSessionMessages).mockImplementation(async (storedId: string) => { if (storedId === TILE_STORED_ID) { @@ -250,7 +252,6 @@ describe('active transcript refresh', () => { await act(async () => { await reconcileTileTranscriptsForTest({ tiles: [{ storedSessionId: TILE_STORED_ID, runtimeId: TILE_RUNTIME_ID }], - busyRef, requestSequenceRef, signatureRef, updateSessionState @@ -262,6 +263,90 @@ describe('active transcript refresh', () => { expect(getLatestSessionMessages).toHaveBeenCalledWith(TILE_STORED_ID) }) + it('reconciles an idle tile while the main pane is busy', async () => { + const runtimeId = 'runtime-idle-tile' + const storedId = 'stored-idle-tile' + const idleState = createClientSessionState(storedId) + + setBusy(true) + publishSessionState(runtimeId, idleState) + vi.mocked(getLatestSessionMessages).mockResolvedValue(transcript('idle tile update', storedId) as never) + + const updateSessionState = vi.fn((sessionId: string, updater: (state: typeof idleState) => typeof idleState) => { + expect(sessionId).toBe(runtimeId) + + return updater(idleState) + }) + + await reconcileTileTranscriptsForTest({ + tiles: [{ runtimeId, storedSessionId: storedId }], + requestSequenceRef: { current: 0 }, + signatureRef: { current: new Map() }, + updateSessionState + }) + + expect(getLatestSessionMessages).toHaveBeenCalledWith(storedId) + expect(updateSessionState).toHaveBeenCalledTimes(1) + }) + + it('does not reconcile a busy tile when the main pane is idle', async () => { + const runtimeId = 'runtime-busy-tile' + const storedId = 'stored-busy-tile' + const liveState = createClientSessionState(storedId) + + liveState.busy = true + liveState.messages = [ + { + id: 'live-assistant', + parts: [{ text: 'streaming answer', type: 'text' }], + pending: true, + role: 'assistant' + } + ] + publishSessionState(runtimeId, liveState) + vi.mocked(getLatestSessionMessages).mockResolvedValue({ messages: [], session_id: storedId } as never) + + const updateSessionState = vi.fn() + + await reconcileTileTranscriptsForTest({ + tiles: [{ runtimeId, storedSessionId: storedId }], + requestSequenceRef: { current: 0 }, + signatureRef: { current: new Map() }, + updateSessionState + }) + + expect(getLatestSessionMessages).not.toHaveBeenCalled() + expect(updateSessionState).not.toHaveBeenCalled() + }) + + it('discards a tile snapshot when the tile closes during the read', async () => { + const runtimeId = 'runtime-closing-tile' + const storedId = 'stored-closing-tile' + let resolveRead: (value: unknown) => void = () => undefined + + $sessionTiles.set([{ runtimeId, storedSessionId: storedId }]) + publishSessionState(runtimeId, createClientSessionState(storedId)) + vi.mocked(getLatestSessionMessages).mockReturnValueOnce( + new Promise(resolve => { + resolveRead = resolve + }) as never + ) + + const updateSessionState = vi.fn() + + const reconcile = reconcileTileTranscriptsForTest({ + requestSequenceRef: { current: 0 }, + signatureRef: { current: new Map() }, + updateSessionState + }) + + $sessionTiles.set([]) + resolveRead(transcript('stale tile answer', storedId)) + await reconcile + + expect(updateSessionState).not.toHaveBeenCalled() + }) + it('skips the tile fetch entirely when nothing changed (signature-gated)', async () => { $changeEventsAvailable.set(true) @@ -287,13 +372,11 @@ describe('active transcript refresh', () => { signatureRef.current.set(`tile:${TILE_STORED_ID}`, preSignature) const updateSessionState = vi.fn() - const busyRef = { current: false } const requestSequenceRef = { current: 0 } await act(async () => { await reconcileTileTranscriptsForTest({ tiles: [{ storedSessionId: TILE_STORED_ID, runtimeId: TILE_RUNTIME_ID }], - busyRef, requestSequenceRef, signatureRef, updateSessionState diff --git a/apps/desktop/src/app/contrib/hooks/use-background-sync.ts b/apps/desktop/src/app/contrib/hooks/use-background-sync.ts index 452e1ddb67..cddb12649e 100644 --- a/apps/desktop/src/app/contrib/hooks/use-background-sync.ts +++ b/apps/desktop/src/app/contrib/hooks/use-background-sync.ts @@ -66,6 +66,12 @@ export interface ActiveTranscriptRefreshDeps { ) => ClientSessionState } +function tileRuntimeOwnsLiveState(runtimeId: string): boolean { + const state = $sessionStates.get()[runtimeId] + + return Boolean(state && (state.busy || state.awaitingResponse || state.needsInput || state.turnLive)) +} + /** * Reconcile the persisted transcripts of every open WORKSPACE TILE (#93942 * slice 1). Bot canonical chats live here — never in $sessions / @@ -84,12 +90,10 @@ export interface ActiveTranscriptRefreshDeps { */ export async function reconcileTileTranscripts({ requestSequenceRef, - busyRef, signatureRef, updateSessionState, tiles: tilesOverride }: { - busyRef: MutableRefObject requestSequenceRef: MutableRefObject signatureRef: MutableRefObject> tiles?: Array<{ storedSessionId: string; runtimeId?: string }> @@ -100,6 +104,13 @@ export async function reconcileTileTranscripts({ ) => ClientSessionState }): Promise { const tiles = tilesOverride ?? $sessionTiles.get() + const openSignatureKeys = new Set(tiles.map(tile => `tile:${tile.storedSessionId}`)) + + for (const signatureKey of signatureRef.current.keys()) { + if (!openSignatureKeys.has(signatureKey)) { + signatureRef.current.delete(signatureKey) + } + } for (const tile of tiles) { const storedSessionId = tile.storedSessionId @@ -110,7 +121,7 @@ export async function reconcileTileTranscripts({ continue } - if (!storedSessionId || !runtimeSessionId || busyRef.current) { + if (!storedSessionId || !runtimeSessionId || tileRuntimeOwnsLiveState(runtimeSessionId)) { continue } @@ -123,14 +134,15 @@ export async function reconcileTileTranscripts({ // With a tiles override (test path), the live $sessionTiles check can't // see the synthetic tile — treat override tiles as present. - const stillPresent = tilesOverride - ? tilesOverride.some(t => t.storedSessionId === storedSessionId && t.runtimeId === runtimeSessionId) - : $sessionTiles.get().some(t => t.storedSessionId === storedSessionId && t.runtimeId === runtimeSessionId) + const tileStillPresent = () => + tilesOverride + ? tilesOverride.some(t => t.storedSessionId === storedSessionId && t.runtimeId === runtimeSessionId) + : $sessionTiles.get().some(t => t.storedSessionId === storedSessionId && t.runtimeId === runtimeSessionId) try { const latest = await getLatestSessionMessages(storedSessionId) - if (requestId !== requestSequenceRef.current || busyRef.current || !stillPresent) { + if (requestId !== requestSequenceRef.current || tileRuntimeOwnsLiveState(runtimeSessionId) || !tileStillPresent()) { // Tile closed or superseded mid-read — discard AND prune its // signature so the map doesn't grow one entry per ever-opened tile // for the app's lifetime (#94255 review point 3). @@ -547,10 +559,8 @@ export function useBackgroundSync({ // transcript signatures, so no-change ticks and closed tiles cost nothing. const tileRequestSequenceRef = useRef(0) const tileSignatureRef = useRef(new Map()) - // Read $busy.get() directly inside the reconcile loop instead of mirroring - // the atom into a ref (lint: no-restricted-syntax — refs synced from atoms - // lag one render). The reconcile runs on tick, not render, so .get() is - // always current. + // Tile reconciliation reads each runtime's live state directly from + // $sessionStates; the primary chat's $busy atom has no authority over tiles. const requestActiveTranscriptRefresh = useCallback( (preservePending: boolean) => { @@ -697,11 +707,6 @@ export function useBackgroundSync({ // (#93942 scenario A). Signature-gated per tile, so no-change ticks // cost nothing. void reconcileTileTranscripts({ - busyRef: { - get current() { - return $busy.get() - } - }, requestSequenceRef: tileRequestSequenceRef, signatureRef: tileSignatureRef, updateSessionState