fix(context): reset team caches on context changes

This commit is contained in:
777genius 2026-05-26 17:00:41 +03:00
parent 96478a604f
commit e46868b6d7
6 changed files with 576 additions and 3 deletions

View file

@ -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<AppState, [], [], ConnectionSli
focusedPaneId: 'pane-default',
},
...getFullResetState(),
...getContextScopedTeamResetState(),
}
: {}),
});
@ -107,6 +108,8 @@ export const createConnectionSlice: StateCreator<AppState, [], [], ConnectionSli
const state = get();
void state.fetchProjects();
void state.fetchRepositoryGroups();
void state.fetchTeams();
void state.fetchAllTasks();
// Save connection config (without password) for form pre-fill on next launch
const saved: SshLastConnection = {
@ -156,12 +159,15 @@ export const createConnectionSlice: StateCreator<AppState, [], [], ConnectionSli
focusedPaneId: 'pane-default',
},
...getFullResetState(),
...getContextScopedTeamResetState(),
});
// Re-fetch local data
const state = get();
void state.fetchProjects();
void state.fetchRepositoryGroups();
void state.fetchTeams();
void state.fetchAllTasks();
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
set({ connectionError: message });

View file

@ -9,7 +9,7 @@ import { api } from '@renderer/api';
import { contextStorage } from '@renderer/services/contextStorage';
import { draftStorage } from '@renderer/services/draftStorage';
import { getFullResetState } from '../utils/stateResetHelpers';
import { getContextScopedTeamResetState, getFullResetState } from '../utils/stateResetHelpers';
import type { AppState } from '../types';
import type { ContextSnapshot } from '@renderer/services/contextStorage';
@ -47,6 +47,7 @@ export interface ContextSlice {
function getEmptyContextState(): Partial<AppState> {
return {
...getFullResetState(),
...getContextScopedTeamResetState(),
projects: [],
projectsLoading: false,
projectsInitialized: false,
@ -259,11 +260,17 @@ export const createContextSlice: StateCreator<AppState, [], [], ContextSlice> =
// 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<AppState, [], [], ContextSlice> =
// 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<AppState, [], [], ContextSlice> =
// 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

View file

@ -236,6 +236,7 @@ const pendingFreshTeamMessagesHeadRefreshes = new Set<string>();
const inFlightTeamMemberActivityMetaRequests = new Map<string, Promise<void>>();
const pendingFreshTeamMemberActivityMetaRefreshes = new Set<string>();
const pendingTeamPendingReplyRefreshTimers = new Map<string, ReturnType<typeof setTimeout>>();
let latestTeamsFetchRequestId = 0;
let inFlightGlobalTasksRefresh: Promise<void> | 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<AppState, [], [], TeamSlice> = (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<AppState, [], [], TeamSlice> = (set,
TEAM_FETCH_TIMEOUT_MS,
'fetchTeams'
);
if (get().activeContextId !== requestContextId || latestTeamsFetchRequestId !== requestId) {
return;
}
const teamByName: Record<string, TeamSummary> = {};
const teamBySessionId: Record<string, TeamSummary> = {};
for (const team of teams) {
@ -1444,6 +1453,9 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (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<AppState, [], [], TeamSlice> = (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<AppState, [], [], TeamSlice> = (set,
globalTasksError: null,
});
} catch (error) {
if (get().activeContextId !== requestContextId) {
continue;
}
set({
globalTasksLoading: false,
globalTasksInitialized: true,

View file

@ -53,6 +53,74 @@ export function getProjectSelectionResetState(): Partial<AppState> {
};
}
/**
* 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<AppState> {
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.

View file

@ -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<unknown[]> => []),
getRepositoryGroups: vi.fn(async (): Promise<unknown[]> => []),
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);
});
});

View file

@ -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<T>(): {
promise: Promise<T>;
resolve: (value: T) => void;
} {
let resolve!: (value: T) => void;
const promise = new Promise<T>((innerResolve) => {
resolve = innerResolve;
});
return { promise, resolve };
}
function createSliceStore() {
return create<AppState>()((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<TeamSummaryLike[]>();
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<GlobalTaskLike[]>();
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);
});
});