From e46868b6d7f340ee75f1d96afda9d69b4d083392 Mon Sep 17 00:00:00 2001 From: 777genius Date: Tue, 26 May 2026 17:00:41 +0300 Subject: [PATCH] fix(context): reset team caches on context changes --- src/renderer/store/slices/connectionSlice.ts | 8 +- src/renderer/store/slices/contextSlice.ts | 12 +- src/renderer/store/slices/teamSlice.ts | 21 +- src/renderer/store/utils/stateResetHelpers.ts | 68 ++++ .../store/contextSliceTeamReset.test.ts | 304 ++++++++++++++++++ .../store/teamSliceContextRace.test.ts | 166 ++++++++++ 6 files changed, 576 insertions(+), 3 deletions(-) create mode 100644 test/renderer/store/contextSliceTeamReset.test.ts create mode 100644 test/renderer/store/teamSliceContextRace.test.ts diff --git a/src/renderer/store/slices/connectionSlice.ts b/src/renderer/store/slices/connectionSlice.ts index a16e3f6c..126852ec 100644 --- a/src/renderer/store/slices/connectionSlice.ts +++ b/src/renderer/store/slices/connectionSlice.ts @@ -7,7 +7,7 @@ import { api } from '@renderer/api'; -import { getFullResetState } from '../utils/stateResetHelpers'; +import { getContextScopedTeamResetState, getFullResetState } from '../utils/stateResetHelpers'; import type { AppState } from '../types'; import type { @@ -98,6 +98,7 @@ export const createConnectionSlice: StateCreator { return { ...getFullResetState(), + ...getContextScopedTeamResetState(), projects: [], projectsLoading: false, projectsInitialized: false, @@ -259,11 +260,17 @@ export const createContextSlice: StateCreator = // Fetch active context from main process const activeContextId = await api.context.getActive(); + const previousContextId = get().activeContextId; set({ + ...(activeContextId !== previousContextId ? getContextScopedTeamResetState() : {}), contextSnapshotsReady: true, activeContextId, }); + if (activeContextId !== previousContextId) { + void get().fetchTeams(); + void get().fetchAllTasks(); + } // Fetch available contexts await get().fetchAvailableContexts(); @@ -317,6 +324,7 @@ export const createContextSlice: StateCreator = // Step 2: Apply cached snapshot immediately for instant visual feedback if (targetSnapshot) { set({ + ...getContextScopedTeamResetState(), projects: targetSnapshot.projects, projectsLoading: false, projectsInitialized: true, @@ -402,6 +410,8 @@ export const createContextSlice: StateCreator = // Step 4: Fetch notifications in background void get().fetchNotifications(); + void get().fetchTeams(); + void get().fetchAllTasks(); } catch (error) { console.error('[contextSlice] Failed to switch context:', error); // Do NOT leave in broken state diff --git a/src/renderer/store/slices/teamSlice.ts b/src/renderer/store/slices/teamSlice.ts index 9fc75c6a..04e15f42 100644 --- a/src/renderer/store/slices/teamSlice.ts +++ b/src/renderer/store/slices/teamSlice.ts @@ -236,6 +236,7 @@ const pendingFreshTeamMessagesHeadRefreshes = new Set(); const inFlightTeamMemberActivityMetaRequests = new Map>(); const pendingFreshTeamMemberActivityMetaRefreshes = new Set(); const pendingTeamPendingReplyRefreshTimers = new Map>(); +let latestTeamsFetchRequestId = 0; let inFlightGlobalTasksRefresh: Promise | null = null; let pendingFreshGlobalTasksRefresh = false; interface RefreshTeamDataOptions { @@ -286,6 +287,9 @@ export function __resetTeamSliceModuleStateForTests(): void { clearTimeout(timer); } pendingTeamPendingReplyRefreshTimers.clear(); + latestTeamsFetchRequestId = 0; + inFlightGlobalTasksRefresh = null; + pendingFreshGlobalTasksRefresh = false; clearAllPendingReplyRefreshWaits(); clearAllLastResolvedTeamDataRefreshes(); clearAllTeamLocalStateEpochs(); @@ -1401,6 +1405,8 @@ export const createTeamSlice: StateCreator = (set, // Only effective during initial load (when teamsLoading is set to true below). // Refreshes are already serialized by the throttle timer in onTeamChange. if (get().teamsLoading) return; + const requestContextId = get().activeContextId; + const requestId = ++latestTeamsFetchRequestId; // Only show loading spinner on initial load — avoids flickering when refreshing const isInitialLoad = get().teams.length === 0; if (isInitialLoad) { @@ -1412,6 +1418,9 @@ export const createTeamSlice: StateCreator = (set, TEAM_FETCH_TIMEOUT_MS, 'fetchTeams' ); + if (get().activeContextId !== requestContextId || latestTeamsFetchRequestId !== requestId) { + return; + } const teamByName: Record = {}; const teamBySessionId: Record = {}; for (const team of teams) { @@ -1444,6 +1453,9 @@ export const createTeamSlice: StateCreator = (set, }; }); } catch (error) { + if (get().activeContextId !== requestContextId || latestTeamsFetchRequestId !== requestId) { + return; + } // On refresh failure, keep existing teams visible set({ teamsLoading: false, @@ -1475,15 +1487,19 @@ export const createTeamSlice: StateCreator = (set, if (isInitialLoad) { set({ globalTasksLoading: true, globalTasksError: null }); } + const requestContextId = get().activeContextId; const oldTasks = get().globalTasks; - const wasFirst = consumeFirstGlobalTasksFetchFlag(); try { const tasks = await withTimeout( unwrapIpc('team:getAllTasks', () => api.teams.getAllTasks()), TEAM_FETCH_TIMEOUT_MS, 'fetchAllTasks' ); + if (get().activeContextId !== requestContextId) { + continue; + } const notificationState = get(); + const wasFirst = consumeFirstGlobalTasksFetchFlag(); processGlobalTaskNotifications({ oldTasks, newTasks: tasks, @@ -1499,6 +1515,9 @@ export const createTeamSlice: StateCreator = (set, globalTasksError: null, }); } catch (error) { + if (get().activeContextId !== requestContextId) { + continue; + } set({ globalTasksLoading: false, globalTasksInitialized: true, diff --git a/src/renderer/store/utils/stateResetHelpers.ts b/src/renderer/store/utils/stateResetHelpers.ts index 60ecab71..fd6991cb 100644 --- a/src/renderer/store/utils/stateResetHelpers.ts +++ b/src/renderer/store/utils/stateResetHelpers.ts @@ -53,6 +53,74 @@ export function getProjectSelectionResetState(): Partial { }; } +/** + * Reset team/task data that belongs to the active main-process context. + * These caches are populated through context-aware IPC calls and must not + * survive a local/SSH context switch. + */ +export function getContextScopedTeamResetState(): Partial { + return { + teams: [], + teamByName: {}, + teamBySessionId: {}, + branchByPath: {}, + teamsLoading: false, + teamsError: null, + globalTasks: [], + globalTasksLoading: false, + globalTasksInitialized: false, + globalTasksError: null, + globalTaskDetail: null, + pendingMemberProfile: null, + pendingTeamSectionFocus: null, + pendingReviewRequest: null, + selectedTeamName: null, + selectedTeamData: null, + teamDataCacheByName: {}, + selectedTeamLoading: false, + selectedTeamLoadNonce: 0, + selectedTeamError: null, + sendingMessage: false, + sendMessageError: null, + sendMessageWarning: null, + sendMessageDebugDetails: null, + lastSendMessageResult: null, + reviewActionError: null, + graphLayoutModeByTeam: {}, + gridOwnerOrderByTeam: {}, + slotAssignmentsByTeam: {}, + teamMessagesByName: {}, + memberActivityMetaByTeam: {}, + graphLayoutSessionByTeam: {}, + provisioningRuns: {}, + provisioningSnapshotByTeam: {}, + currentProvisioningRunIdByTeam: {}, + currentRuntimeRunIdByTeam: {}, + ignoredProvisioningRunIds: {}, + ignoredRuntimeRunIds: {}, + provisioningStartedAtFloorByTeam: {}, + leadActivityByTeam: {}, + leadContextByTeam: {}, + activeTaskLogActivityByTeam: {}, + activeToolsByTeam: {}, + finishedVisibleByTeam: {}, + toolHistoryByTeam: {}, + memberSpawnStatusesByTeam: {}, + memberSpawnSnapshotsByTeam: {}, + teamAgentRuntimeByTeam: {}, + provisioningErrorByTeam: {}, + crossTeamTargets: [], + crossTeamTargetsLoading: false, + kanbanFilterQuery: null, + addingComment: false, + addCommentError: null, + deletedTasks: [], + deletedTasksLoading: false, + pendingApprovals: [], + resolvedApprovals: new Map(), + }; +} + /** * Full state reset (session + project + repository + conversation). * Used when closing all tabs or resetting to initial state. diff --git a/test/renderer/store/contextSliceTeamReset.test.ts b/test/renderer/store/contextSliceTeamReset.test.ts new file mode 100644 index 00000000..5da55851 --- /dev/null +++ b/test/renderer/store/contextSliceTeamReset.test.ts @@ -0,0 +1,304 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { createTestStore } from './storeTestUtils'; + +const apiMock = vi.hoisted(() => ({ + context: { + switch: vi.fn(async () => undefined), + list: vi.fn(async () => [{ id: 'local', type: 'local' }]), + getActive: vi.fn(async () => 'local'), + onChanged: vi.fn(() => () => undefined), + }, + getProjects: vi.fn(async (): Promise => []), + getRepositoryGroups: vi.fn(async (): Promise => []), + notifications: { + get: vi.fn(async () => ({ + notifications: [], + total: 0, + totalCount: 0, + unreadCount: 0, + hasMore: false, + })), + }, + teams: { + list: vi.fn(async () => []), + getAllTasks: vi.fn(async () => []), + showMessageNotification: vi.fn(async () => undefined), + }, + ssh: { + connect: vi.fn(async () => ({ state: 'connected', host: 'dev', error: null })), + disconnect: vi.fn(async () => ({ state: 'disconnected', host: null, error: null })), + saveLastConnection: vi.fn(async () => undefined), + }, +})); + +const contextStorageMock = vi.hoisted(() => ({ + saveSnapshot: vi.fn(async () => undefined), + loadSnapshot: vi.fn(), + cleanupExpired: vi.fn(async () => undefined), + isAvailable: vi.fn(async () => true), +})); + +const draftStorageMock = vi.hoisted(() => ({ + cleanupExpired: vi.fn(async () => undefined), +})); + +vi.mock('@renderer/api', () => ({ + api: apiMock, +})); + +vi.mock('@renderer/services/contextStorage', () => ({ + contextStorage: contextStorageMock, +})); + +vi.mock('@renderer/services/draftStorage', () => ({ + draftStorage: draftStorageMock, +})); + +function targetSnapshot() { + return { + projects: [ + { + id: 'ssh-project', + name: 'SSH Project', + path: '/ssh/project', + sessions: [], + createdAt: new Date(0).toISOString(), + updatedAt: new Date(0).toISOString(), + }, + ], + selectedProjectId: null, + repositoryGroups: [], + selectedRepositoryId: null, + selectedWorktreeId: null, + viewMode: 'flat' as const, + sessions: [], + selectedSessionId: null, + sessionsCursor: null, + sessionsHasMore: false, + sessionsTotalCount: 0, + pinnedSessionIds: [], + notifications: [], + unreadCount: 0, + openTabs: [], + activeTabId: null, + selectedTabIds: [], + activeProjectId: null, + paneLayout: { + panes: [ + { + id: 'pane-default', + tabs: [], + activeTabId: null, + selectedTabIds: [], + widthFraction: 1, + }, + ], + focusedPaneId: 'pane-default', + }, + sidebarCollapsed: false, + _metadata: { + contextId: 'ssh-dev', + capturedAt: Date.now(), + version: 1, + }, + }; +} + +describe('context slice team/task reset', () => { + beforeEach(() => { + vi.clearAllMocks(); + contextStorageMock.loadSnapshot.mockResolvedValue(targetSnapshot()); + apiMock.context.getActive.mockResolvedValue('local'); + apiMock.getProjects.mockResolvedValue(targetSnapshot().projects); + apiMock.getRepositoryGroups.mockResolvedValue([]); + apiMock.teams.list.mockResolvedValue([]); + apiMock.teams.getAllTasks.mockResolvedValue([]); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('drops previous-context team and task caches before refreshing the target context', async () => { + const store = createTestStore(); + store.setState({ + activeContextId: 'local', + teams: [ + { + teamName: 'local-team', + displayName: 'Local Team', + projectPath: '/local/project', + }, + ], + teamByName: { + 'local-team': { + teamName: 'local-team', + displayName: 'Local Team', + projectPath: '/local/project', + }, + }, + teamBySessionId: {}, + globalTasks: [ + { + id: 'local-task', + subject: 'Local task', + status: 'todo', + teamName: 'local-team', + teamDisplayName: 'Local Team', + projectPath: '/local/project', + comments: [], + }, + ], + globalTasksInitialized: true, + selectedTeamName: 'local-team', + selectedTeamData: { teamName: 'local-team' }, + teamDataCacheByName: { 'local-team': { teamName: 'local-team' } }, + } as never); + + await store.getState().switchContext('ssh-dev'); + + expect(store.getState().activeContextId).toBe('ssh-dev'); + expect(store.getState().teams).toEqual([]); + expect(store.getState().teamByName).toEqual({}); + expect(store.getState().globalTasks).toEqual([]); + expect(store.getState().selectedTeamName).toBeNull(); + expect(store.getState().selectedTeamData).toBeNull(); + expect(store.getState().teamDataCacheByName).toEqual({}); + expect(apiMock.teams.list).toHaveBeenCalledTimes(1); + expect(apiMock.teams.getAllTasks).toHaveBeenCalledTimes(1); + }); + + it('drops previous-context team and task caches when lazy context initialization changes context', async () => { + apiMock.context.getActive.mockResolvedValue('ssh-dev'); + const store = createTestStore(); + store.setState({ + activeContextId: 'local', + teams: [ + { + teamName: 'local-team', + displayName: 'Local Team', + projectPath: '/local/project', + }, + ], + teamByName: { + 'local-team': { + teamName: 'local-team', + displayName: 'Local Team', + projectPath: '/local/project', + }, + }, + globalTasks: [ + { + id: 'local-task', + subject: 'Local task', + status: 'todo', + teamName: 'local-team', + teamDisplayName: 'Local Team', + projectPath: '/local/project', + comments: [], + }, + ], + globalTasksInitialized: true, + } as never); + + await store.getState().initializeContextSystem(); + + expect(store.getState().activeContextId).toBe('ssh-dev'); + expect(store.getState().teams).toEqual([]); + expect(store.getState().teamByName).toEqual({}); + expect(store.getState().globalTasks).toEqual([]); + expect(apiMock.teams.list).toHaveBeenCalledTimes(1); + expect(apiMock.teams.getAllTasks).toHaveBeenCalledTimes(1); + }); + + it('drops previous-context team and task caches on direct SSH connect', async () => { + const store = createTestStore(); + store.setState({ + activeContextId: 'local', + teams: [ + { + teamName: 'local-team', + displayName: 'Local Team', + projectPath: '/local/project', + }, + ], + teamByName: { + 'local-team': { + teamName: 'local-team', + displayName: 'Local Team', + projectPath: '/local/project', + }, + }, + globalTasks: [ + { + id: 'local-task', + subject: 'Local task', + status: 'todo', + teamName: 'local-team', + teamDisplayName: 'Local Team', + projectPath: '/local/project', + comments: [], + }, + ], + globalTasksInitialized: true, + } as never); + + await store.getState().connectSsh({ + host: 'dev', + port: 22, + username: 'me', + authMethod: 'privateKey', + privateKeyPath: '/tmp/key', + }); + + expect(store.getState().activeContextId).toBe('ssh-dev'); + expect(store.getState().teams).toEqual([]); + expect(store.getState().teamByName).toEqual({}); + expect(store.getState().globalTasks).toEqual([]); + expect(apiMock.teams.list).toHaveBeenCalledTimes(1); + expect(apiMock.teams.getAllTasks).toHaveBeenCalledTimes(1); + }); + + it('drops previous-context team and task caches on direct SSH disconnect', async () => { + const store = createTestStore(); + store.setState({ + activeContextId: 'ssh-dev', + teams: [ + { + teamName: 'ssh-team', + displayName: 'SSH Team', + projectPath: '/ssh/project', + }, + ], + teamByName: { + 'ssh-team': { + teamName: 'ssh-team', + displayName: 'SSH Team', + projectPath: '/ssh/project', + }, + }, + globalTasks: [ + { + id: 'ssh-task', + subject: 'SSH task', + status: 'todo', + teamName: 'ssh-team', + teamDisplayName: 'SSH Team', + projectPath: '/ssh/project', + comments: [], + }, + ], + globalTasksInitialized: true, + } as never); + + await store.getState().disconnectSsh(); + + expect(store.getState().activeContextId).toBe('local'); + expect(store.getState().teams).toEqual([]); + expect(store.getState().teamByName).toEqual({}); + expect(store.getState().globalTasks).toEqual([]); + expect(apiMock.teams.list).toHaveBeenCalledTimes(1); + expect(apiMock.teams.getAllTasks).toHaveBeenCalledTimes(1); + }); +}); diff --git a/test/renderer/store/teamSliceContextRace.test.ts b/test/renderer/store/teamSliceContextRace.test.ts new file mode 100644 index 00000000..a9374ea5 --- /dev/null +++ b/test/renderer/store/teamSliceContextRace.test.ts @@ -0,0 +1,166 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { create } from 'zustand'; + +import { + __resetTeamSliceModuleStateForTests, + createTeamSlice, +} from '../../../src/renderer/store/slices/teamSlice'; + +import type { AppState } from '../../../src/renderer/store/types'; + +const apiMock = vi.hoisted(() => ({ + teams: { + list: vi.fn(), + getAllTasks: vi.fn(), + showMessageNotification: vi.fn(async () => undefined), + }, +})); + +interface TeamSummaryLike { + teamName: string; + displayName: string; + projectPath: string; +} + +interface GlobalTaskLike { + id: string; + subject: string; + status: string; + teamName: string; + teamDisplayName: string; + projectPath: string; + comments: []; +} + +vi.mock('@renderer/api', () => ({ + api: apiMock, +})); + +function deferred(): { + promise: Promise; + resolve: (value: T) => void; +} { + let resolve!: (value: T) => void; + const promise = new Promise((innerResolve) => { + resolve = innerResolve; + }); + return { promise, resolve }; +} + +function createSliceStore() { + return create()((set, get, store) => + ({ + ...createTeamSlice(set as never, get as never, store as never), + activeContextId: 'local', + appConfig: null, + paneLayout: { + focusedPaneId: 'pane-default', + panes: [ + { + id: 'pane-default', + widthFraction: 1, + tabs: [], + activeTabId: null, + }, + ], + }, + openTab: vi.fn(), + setActiveTab: vi.fn(), + updateTabLabel: vi.fn(), + getAllPaneTabs: vi.fn(() => []), + warmTaskChangeSummaries: vi.fn(async () => undefined), + invalidateTaskChangePresence: vi.fn(), + }) as unknown as AppState + ); +} + +describe('team slice context races', () => { + beforeEach(() => { + __resetTeamSliceModuleStateForTests(); + apiMock.teams.list.mockReset(); + apiMock.teams.getAllTasks.mockReset(); + apiMock.teams.showMessageNotification.mockClear(); + }); + + afterEach(() => { + __resetTeamSliceModuleStateForTests(); + vi.restoreAllMocks(); + }); + + it('ignores a team list response loaded for a previous context', async () => { + const store = createSliceStore(); + const localList = deferred(); + apiMock.teams.list.mockReturnValueOnce(localList.promise); + + const fetchPromise = store.getState().fetchTeams(); + expect(store.getState().teamsLoading).toBe(true); + + store.setState({ + activeContextId: 'ssh-dev', + teams: [], + teamByName: {}, + teamBySessionId: {}, + teamsLoading: false, + }); + localList.resolve([ + { + teamName: 'local-team', + displayName: 'Local Team', + projectPath: '/local/project', + }, + ]); + await fetchPromise; + + expect(store.getState().teams).toEqual([]); + expect(store.getState().teamByName).toEqual({}); + expect(store.getState().teamsLoading).toBe(false); + }); + + it('reruns a pending global task refresh for the current context instead of applying stale tasks', async () => { + const store = createSliceStore(); + const localTasks = deferred(); + apiMock.teams.getAllTasks.mockReturnValueOnce(localTasks.promise).mockResolvedValueOnce([ + { + id: 'ssh-task', + subject: 'SSH task', + status: 'todo', + teamName: 'ssh-team', + teamDisplayName: 'SSH Team', + projectPath: '/ssh/project', + comments: [], + }, + ]); + + const firstFetch = store.getState().fetchAllTasks(); + expect(store.getState().globalTasksLoading).toBe(true); + + store.setState({ + activeContextId: 'ssh-dev', + globalTasks: [], + globalTasksLoading: false, + globalTasksInitialized: false, + }); + const secondFetch = store.getState().fetchAllTasks(); + + localTasks.resolve([ + { + id: 'local-task', + subject: 'Local task', + status: 'todo', + teamName: 'local-team', + teamDisplayName: 'Local Team', + projectPath: '/local/project', + comments: [], + }, + ]); + + await Promise.all([firstFetch, secondFetch]); + + expect(apiMock.teams.getAllTasks).toHaveBeenCalledTimes(2); + expect(store.getState().globalTasks).toEqual([ + expect.objectContaining({ id: 'ssh-task', teamName: 'ssh-team' }), + ]); + expect(store.getState().globalTasksInitialized).toBe(true); + expect(store.getState().globalTasksLoading).toBe(false); + }); +});