diff --git a/ui-tui/src/__tests__/stateIsolation.test.ts b/ui-tui/src/__tests__/stateIsolation.test.ts new file mode 100644 index 0000000000..0a6b898f4a --- /dev/null +++ b/ui-tui/src/__tests__/stateIsolation.test.ts @@ -0,0 +1,46 @@ +import { beforeEach, describe, expect, it } from 'vitest' + +import { patchTurnState, resetTurnState } from '../app/turnStore.js' +import { $uiState, resetUiState } from '../app/uiStore.js' + +const shallowEqual = >(a: T, b: T) => + Object.keys(a).length === Object.keys(b).length && Object.keys(a).every(key => Object.is(a[key], b[key])) + +const subscribeSelected = >(selector: () => T) => { + let current = selector() + let calls = 0 + + const unsubscribe = $uiState.listen(() => { + const next = selector() + + if (shallowEqual(next, current)) { + return + } + + current = next + calls++ + }) + + return { calls: () => calls, unsubscribe } +} + +describe('TUI state isolation', () => { + beforeEach(() => { + resetUiState() + resetTurnState() + }) + + it('does not notify ui/composer subscribers for high-frequency turn updates', () => { + const composerRelevant = subscribeSelected(() => ({ busy: $uiState.get().busy, sid: $uiState.get().sid })) + + try { + for (let i = 0; i < 50; i++) { + patchTurnState({ streaming: `token ${i}` }) + } + } finally { + composerRelevant.unsubscribe() + } + + expect(composerRelevant.calls()).toBe(0) + }) +}) diff --git a/ui-tui/src/app/useLongRunToolCharms.ts b/ui-tui/src/app/useLongRunToolCharms.ts index a65898db2b..9135abf49e 100644 --- a/ui-tui/src/app/useLongRunToolCharms.ts +++ b/ui-tui/src/app/useLongRunToolCharms.ts @@ -5,6 +5,8 @@ import { pick, toolTrailLabel } from '../lib/text.js' import type { ActiveTool } from '../types.js' import { turnController } from './turnController.js' +import { useTurnSelector } from './turnStore.js' +import { getUiState } from './uiStore.js' const DELAY_MS = 8_000 const INTERVAL_MS = 10_000 @@ -15,21 +17,28 @@ interface Slot { lastAt: number } -export function useLongRunToolCharms(busy: boolean, tools: ActiveTool[]) { +export function useLongRunToolCharms() { + const tools = useTurnSelector(state => state.tools) const slots = useRef(new Map()) useEffect(() => { - if (!busy || !tools.length) { + if (!getUiState().busy || !tools.length) { slots.current.clear() return } const tick = () => { + if (!getUiState().busy) { + slots.current.clear() + + return + } + const now = Date.now() const liveIds = new Set(tools.map(t => t.id)) - for (const key of [...slots.current.keys()]) { + for (const key of Array.from(slots.current.keys())) { if (!liveIds.has(key)) { slots.current.delete(key) } @@ -57,5 +66,5 @@ export function useLongRunToolCharms(busy: boolean, tools: ActiveTool[]) { const id = setInterval(tick, 1000) return () => clearInterval(id) - }, [busy, tools]) + }, [tools]) } diff --git a/ui-tui/src/app/useMainApp.ts b/ui-tui/src/app/useMainApp.ts index 064d64ad59..2643126444 100644 --- a/ui-tui/src/app/useMainApp.ts +++ b/ui-tui/src/app/useMainApp.ts @@ -29,7 +29,7 @@ import { type GatewayRpc, type TranscriptRow } from './interfaces.js' import { $overlayState, patchOverlayState } from './overlayStore.js' import { scrollWithSelectionBy } from './scroll.js' import { turnController } from './turnController.js' -import { $turnState, patchTurnState, useTurnSelector } from './turnStore.js' +import { patchTurnState, useTurnSelector } from './turnStore.js' import { $uiState, getUiState, patchUiState } from './uiStore.js' import { useComposerState } from './useComposerState.js' import { useConfigSync } from './useConfigSync.js' @@ -107,8 +107,6 @@ export function useMainApp(gw: GatewayClient) { const ui = useStore($uiState) const overlay = useStore($overlayState) - const turn = useStore($turnState) - const turnLiveTailActive = useTurnSelector(state => Boolean( state.streaming || @@ -503,7 +501,7 @@ export function useMainApp(gw: GatewayClient) { } }, [gw, sys]) - useLongRunToolCharms(ui.busy, turn.tools) + useLongRunToolCharms() const slash = useMemo( () =>