diff --git a/packages/ui/src/apps/runtimeEndpointReset.ts b/packages/ui/src/apps/runtimeEndpointReset.ts index add84eb3..22e9705b 100644 --- a/packages/ui/src/apps/runtimeEndpointReset.ts +++ b/packages/ui/src/apps/runtimeEndpointReset.ts @@ -15,7 +15,7 @@ import { useFilesViewTabsStore } from '@/stores/useFilesViewTabsStore'; import { useTerminalStore } from '@/stores/useTerminalStore'; import { useSessionUIStore } from '@/sync/session-ui-store'; import { resetStreamingState } from '@/sync/streaming'; -import { useGlobalSessionStatusStore } from '@/sync/global-session-status'; +import { useGlobalSessionStatusStore, replaceGlobalSessionStatusById } from '@/sync/global-session-status'; import { resetSessionOrdering } from '@/sync/session-ordering'; import { resetSessionActivityTiming } from '@/sync/session-activity-timing'; import { syncDesktopSettings } from '@/lib/persistence'; @@ -57,7 +57,7 @@ export const resetAppForRuntimeEndpointChange = (detail: RuntimeEndpointChangedD // Cross-project session list (mobile sessions sheet & co) belongs to the // previous instance — drop it so stale sessions can't linger after a switch. useGlobalSessionsStore.getState().resetForRuntimeSwitch(); - useGlobalSessionStatusStore.setState({ statusById: new Map() }); + replaceGlobalSessionStatusById(new Map()); resetSessionOrdering(); // Turn timings belong to the previous instance's sessions, and the reset also // restarts the resume window so the switch is treated as a fresh load. diff --git a/packages/ui/src/components/session/sidebar/list/sessionCollection.test.ts b/packages/ui/src/components/session/sidebar/list/sessionCollection.test.ts index 617c0dbb..66c9b12a 100644 --- a/packages/ui/src/components/session/sidebar/list/sessionCollection.test.ts +++ b/packages/ui/src/components/session/sidebar/list/sessionCollection.test.ts @@ -4,7 +4,7 @@ import type { Event } from '@opencode-ai/sdk/v2/client'; import React, { act } from 'react'; import { createRoot, type Root } from 'react-dom/client'; import { deriveRecentSessions } from '../recent/activitySections'; -import { applyGlobalSessionStatusEvent, useGlobalSessionStatusStore } from '@/sync/global-session-status'; +import { applyGlobalSessionStatusEvent, useGlobalSessionStatusStore , replaceGlobalSessionStatusById} from '@/sync/global-session-status'; import { buildSidebarSessionProjection, getDescendantIds, @@ -270,7 +270,7 @@ describe('useRecentSessionCollection', () => { }; try { - useGlobalSessionStatusStore.setState({ statusById: new Map() }); + replaceGlobalSessionStatusById(new Map()); await act(async () => root.render(React.createElement(Harness))); expect(renderedIds).toEqual([]); @@ -296,7 +296,7 @@ describe('useRecentSessionCollection', () => { expect(timeReadCount).toBe(activeDeriveOperationCount); } finally { await act(async () => root.unmount()); - useGlobalSessionStatusStore.setState({ statusById: new Map() }); + replaceGlobalSessionStatusById(new Map()); dom.restore(); } }); diff --git a/packages/ui/src/components/session/sidebar/sessions/collapsedActivityIndicator.behavior.test.tsx b/packages/ui/src/components/session/sidebar/sessions/collapsedActivityIndicator.behavior.test.tsx index 081c7667..a63095f7 100644 --- a/packages/ui/src/components/session/sidebar/sessions/collapsedActivityIndicator.behavior.test.tsx +++ b/packages/ui/src/components/session/sidebar/sessions/collapsedActivityIndicator.behavior.test.tsx @@ -2,7 +2,7 @@ import { describe, expect, test } from 'bun:test'; import React, { act } from 'react'; import { createRoot } from 'react-dom/client'; import type { Session } from '@opencode-ai/sdk/v2'; -import { useGlobalSessionStatusStore } from '@/sync/global-session-status'; +import { useGlobalSessionStatusStore , replaceGlobalSessionStatusById} from '@/sync/global-session-status'; import { useNotificationStore } from '@/sync/notification-store'; import { useCollapsedSessionActivityState } from './collapsedActivityState'; import type { SessionNode } from '../types'; @@ -15,7 +15,7 @@ describe('collapsed activity scalar selector', () => { test('does not rerender for unrelated updates and rerenders for relevant scalar changes', async () => { const dom = installHookTestDom(); const root = createRoot(dom.container); - useGlobalSessionStatusStore.setState({ statusById: new Map() }); + replaceGlobalSessionStatusById(new Map()); useNotificationStore.setState({ list: [], index: { session: { unseenCount: {}, unseenHasError: {} }, project: { unseenCount: {}, unseenHasError: {} } }, @@ -30,9 +30,7 @@ describe('collapsed activity scalar selector', () => { try { await act(async () => root.render(React.createElement(Harness))); const initialRenders = capture.renders; - await act(async () => useGlobalSessionStatusStore.setState({ - statusById: new Map([['unrelated', { status: { type: 'busy' }, directory: '/other' }]]), - })); + await act(async () => replaceGlobalSessionStatusById(new Map([['unrelated', { status: { type: 'busy' }, directory: '/other' }]]))); await act(async () => useNotificationStore.getState().append({ type: 'turn-complete', session: 'unrelated', time: Date.now(), viewed: false, })); @@ -43,14 +41,12 @@ describe('collapsed activity scalar selector', () => { })); expect(capture.state).toBe('unread'); const unreadRenders = capture.renders; - await act(async () => useGlobalSessionStatusStore.setState({ - statusById: new Map([['relevant', { status: { type: 'busy' }, directory: '/workspace' }]]), - })); + await act(async () => replaceGlobalSessionStatusById(new Map([['relevant', { status: { type: 'busy' }, directory: '/workspace' }]]))); expect(capture.state).toBe('active'); expect(capture.renders).toBe(unreadRenders + 1); } finally { await act(async () => root.unmount()); - useGlobalSessionStatusStore.setState({ statusById: new Map() }); + replaceGlobalSessionStatusById(new Map()); useNotificationStore.setState({ list: [], index: { session: { unseenCount: {}, unseenHasError: {} }, project: { unseenCount: {}, unseenHasError: {} } }, diff --git a/packages/ui/src/sync/global-session-status.test.ts b/packages/ui/src/sync/global-session-status.test.ts index 6f87c06b..5685a928 100644 --- a/packages/ui/src/sync/global-session-status.test.ts +++ b/packages/ui/src/sync/global-session-status.test.ts @@ -5,12 +5,13 @@ import { applyGlobalSessionStatusEvents, applyGlobalSessionStatusSnapshot, useGlobalSessionStatusStore, + replaceGlobalSessionStatusById, } from "./global-session-status" import { resetSessionOrdering, useSessionOrderingStore } from "./session-ordering" import { resetSessionActivityTiming, useSessionActivityTimingStore } from "./session-activity-timing" beforeEach(() => { - useGlobalSessionStatusStore.setState({ statusById: new Map() }) + replaceGlobalSessionStatusById(new Map()) resetSessionOrdering() resetSessionActivityTiming() }) @@ -162,7 +163,7 @@ describe("global session status index", () => { properties: { sessionID: "session-a", status: { type: "busy" } }, } as Event) - useGlobalSessionStatusStore.setState({ statusById: new Map() }) + replaceGlobalSessionStatusById(new Map()) expect(activeSessionIds()?.size).toBe(0) }) diff --git a/packages/ui/src/sync/global-session-status.ts b/packages/ui/src/sync/global-session-status.ts index c6646799..b513a1ec 100644 --- a/packages/ui/src/sync/global-session-status.ts +++ b/packages/ui/src/sync/global-session-status.ts @@ -40,61 +40,28 @@ const initialState: GlobalSessionStatusState = { export const useGlobalSessionStatusStore = create(() => initialState); useGlobalSessionStatusStore.subscribe(() => countSyncPerformance('globalStatusPublications')); -// Runtime switching currently replaces statusById directly. Keep that boundary -// synchronized without making normal status mutations derive membership again. -const storeSetState = useGlobalSessionStatusStore.setState; -type GlobalSessionStatusStateUpdate = GlobalSessionStatusState - | Partial - | ((state: GlobalSessionStatusState) => GlobalSessionStatusState | Partial); - -function setSynchronizedState( - partial: GlobalSessionStatusStateUpdate, - replace?: false, -): void; -function setSynchronizedState( - partial: GlobalSessionStatusState | ((state: GlobalSessionStatusState) => GlobalSessionStatusState), - replace: true, -): void; -function setSynchronizedState(partial: GlobalSessionStatusStateUpdate, replace?: boolean): void { - if (partial instanceof Function) { - if (replace === true) { - // SAFETY: Zustand's `replace: true` overload only accepts a complete state or a complete-state updater. - storeSetState(partial as GlobalSessionStatusState | ((state: GlobalSessionStatusState) => GlobalSessionStatusState), true); - } else { - storeSetState(partial, replace); - } - return; - } - if (partial.statusById === undefined || partial.activeSessionIds) { - if (replace === true) { - // SAFETY: Zustand's `replace: true` overload only accepts a complete state or a complete-state updater. - storeSetState(partial as GlobalSessionStatusState, true); - } else { - storeSetState(partial, replace); - } - return; - } - - const nextStatusById = partial.statusById; +/** + * Replaces the status map wholesale and derives active membership from it. + * This is the ONE sanctioned way to swap statusById from outside the event + * reducers (runtime switch, tests) — previously a setState monkeypatch + * derived membership for arbitrary callers, which silently trusted any + * caller passing both fields to keep them consistent. + */ +export const replaceGlobalSessionStatusById = (statusById: Map): void => { const current = useGlobalSessionStatusStore.getState(); const nextActiveSessionIds = new Set(); - for (const [sessionId, entry] of nextStatusById) { + for (const [sessionId, entry] of statusById) { if (entry.status.type === 'busy' || entry.status.type === 'retry') { nextActiveSessionIds.add(sessionId); } } const sameMembership = nextActiveSessionIds.size === current.activeSessionIds.size && [...nextActiveSessionIds].every((sessionId) => current.activeSessionIds.has(sessionId)); - const nextState = { - ...current, - ...partial, + useGlobalSessionStatusStore.setState({ + statusById, activeSessionIds: sameMembership ? current.activeSessionIds : nextActiveSessionIds, - }; - if (replace === true) storeSetState(nextState, true); - else storeSetState(nextState, replace); -} - -useGlobalSessionStatusStore.setState = setSynchronizedState; + }); +}; const normalizeStatusType = (type: string | undefined): ActiveStatusType | 'idle' => { if (type === 'busy') return 'busy';