fix(desktop): keep busy tile transcripts stable
This commit is contained in:
@@ -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<string, string>() }
|
||||
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
|
||||
|
||||
@@ -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<boolean>
|
||||
requestSequenceRef: MutableRefObject<number>
|
||||
signatureRef: MutableRefObject<Map<string, string>>
|
||||
tiles?: Array<{ storedSessionId: string; runtimeId?: string }>
|
||||
@@ -100,6 +104,13 @@ export async function reconcileTileTranscripts({
|
||||
) => ClientSessionState
|
||||
}): Promise<void> {
|
||||
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<string, string>())
|
||||
// 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
|
||||
|
||||
Reference in New Issue
Block a user