fix(desktop): keep busy tile transcripts stable

This commit is contained in:
Dolverin
2026-08-27 14:40:00 +02:00
committed by Teknium
parent 782dd635fe
commit 51953a302f
2 changed files with 108 additions and 20 deletions

View File

@@ -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

View File

@@ -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