perf(team): batch task presence updates

This commit is contained in:
777genius 2026-05-28 21:56:36 +03:00
parent ff0543caf9
commit 97eb98466a
6 changed files with 232 additions and 17 deletions

View file

@ -13,6 +13,7 @@ import {
TEAM_CHANGES_MAX_REQUESTS,
} from './teamChangesRequestPlan';
import type { TaskChangeRequestOptions } from '@renderer/utils/taskChangeRequest';
import type {
TaskChangePresenceState,
TaskChangeSetV2,
@ -64,6 +65,20 @@ interface UseTeamChangesSummariesInput {
sectionOpen: boolean;
}
type RecordTaskChangePresences = (
entries: {
teamName: string;
taskId: string;
options: TaskChangeRequestOptions;
presence: TaskChangePresenceState | null;
}[]
) => void;
type SetSelectedTeamTaskChangePresences = (
teamName: string,
presencesByTaskId: Record<string, TaskChangePresenceState>
) => void;
interface UseTeamChangesSummariesResult {
summariesByTaskId: Record<string, TeamChangeSummaryState>;
badgeCount: number | null;
@ -262,7 +277,20 @@ export function useTeamChangesSummaries({
sectionOpen,
}: UseTeamChangesSummariesInput): UseTeamChangesSummariesResult {
const recordTaskChangePresence = useStore((s) => s.recordTaskChangePresence);
const recordTaskChangePresences = useStore(
(s) =>
(s as unknown as { recordTaskChangePresences?: RecordTaskChangePresences })
.recordTaskChangePresences
);
const setSelectedTeamTaskChangePresence = useStore((s) => s.setSelectedTeamTaskChangePresence);
const setSelectedTeamTaskChangePresences = useStore(
(s) =>
(
s as unknown as {
setSelectedTeamTaskChangePresences?: SetSelectedTeamTaskChangePresences;
}
).setSelectedTeamTaskChangePresences
);
const [summariesByTaskId, setSummariesByTaskId] = useState<
Record<string, TeamChangeSummaryState>
>({});
@ -469,6 +497,14 @@ export function useTeamChangesSummaries({
});
setCounterLoaded(true);
const cachePresenceUpdates: {
teamName: string;
taskId: string;
options: TaskChangeRequestOptions;
presence: TaskChangePresenceState | null;
}[] = [];
const selectedPresenceUpdates: Record<string, TaskChangePresenceState> = {};
for (const item of responseItems) {
const changeSet = item.changeSet;
const options = plan.requestOptionsByTaskId.get(item.taskId);
@ -482,12 +518,41 @@ export function useTeamChangesSummaries({
task.changePresence !== 'unknown' &&
shouldClearSelectedTaskChangePresence(task, changeSet)
) {
setSelectedTeamTaskChangePresence(teamName, item.taskId, 'unknown');
selectedPresenceUpdates[item.taskId] = 'unknown';
}
continue;
}
recordTaskChangePresence(teamName, item.taskId, options, nextPresence);
setSelectedTeamTaskChangePresence(teamName, item.taskId, nextPresence);
cachePresenceUpdates.push({
teamName,
taskId: item.taskId,
options,
presence: nextPresence,
});
selectedPresenceUpdates[item.taskId] = nextPresence;
}
if (cachePresenceUpdates.length > 0) {
if (recordTaskChangePresences) {
recordTaskChangePresences(cachePresenceUpdates);
} else {
for (const update of cachePresenceUpdates) {
recordTaskChangePresence(
update.teamName,
update.taskId,
update.options,
update.presence
);
}
}
}
if (Object.keys(selectedPresenceUpdates).length > 0) {
if (setSelectedTeamTaskChangePresences) {
setSelectedTeamTaskChangePresences(teamName, selectedPresenceUpdates);
} else {
for (const [taskId, presence] of Object.entries(selectedPresenceUpdates)) {
setSelectedTeamTaskChangePresence(teamName, taskId, presence);
}
}
}
if (storeSummaries) {
@ -573,7 +638,14 @@ export function useTeamChangesSummaries({
}
}
},
[recordTaskChangePresence, setSelectedTeamTaskChangePresence, tasks, teamName]
[
recordTaskChangePresence,
recordTaskChangePresences,
setSelectedTeamTaskChangePresence,
setSelectedTeamTaskChangePresences,
tasks,
teamName,
]
);
useEffect(() => {

View file

@ -53,7 +53,6 @@ import type {
FileChangeWithContent,
FileReviewDecision,
HunkDecision,
SnippetDiff,
TaskChangePresenceState,
TaskChangeSet,
TaskChangeSetV2,
@ -148,6 +147,38 @@ function applyTaskChangePresenceCacheUpdate(
return nextTaskChangePresenceByKey;
}
interface TaskChangePresenceCacheUpdate {
cacheKey: string;
presence: TaskChangePresenceState | null;
}
function applyTaskChangePresenceCacheUpdates(
taskChangePresenceByKey: Record<string, Exclude<TaskChangePresenceState, 'unknown'>>,
updates: readonly TaskChangePresenceCacheUpdate[]
): Record<string, Exclude<TaskChangePresenceState, 'unknown'>> {
let nextTaskChangePresenceByKey = taskChangePresenceByKey;
for (const { cacheKey, presence } of updates) {
if (presence && presence !== 'unknown') {
if (nextTaskChangePresenceByKey[cacheKey] === presence) {
continue;
}
if (nextTaskChangePresenceByKey === taskChangePresenceByKey) {
nextTaskChangePresenceByKey = { ...taskChangePresenceByKey };
}
nextTaskChangePresenceByKey[cacheKey] = presence;
continue;
}
if (!(cacheKey in nextTaskChangePresenceByKey)) {
continue;
}
if (nextTaskChangePresenceByKey === taskChangePresenceByKey) {
nextTaskChangePresenceByKey = { ...taskChangePresenceByKey };
}
delete nextTaskChangePresenceByKey[cacheKey];
}
return nextTaskChangePresenceByKey;
}
function syncTaskChangeNegativeCache(
cacheKey: string,
presence: TaskChangePresenceState | null
@ -207,6 +238,14 @@ export interface ChangeReviewSlice {
options: TaskChangeRequestOptions,
presence: TaskChangePresenceState | null
) => void;
recordTaskChangePresences: (
entries: {
teamName: string;
taskId: string;
options: TaskChangeRequestOptions;
presence: TaskChangePresenceState | null;
}[]
) => void;
selectReviewFile: (filePath: string | null) => void;
clearChangeReview: () => void;
clearChangeReviewCache: () => void;
@ -570,17 +609,30 @@ export const createChangeReviewSlice: StateCreator<AppState, [], [], ChangeRevie
options: TaskChangeRequestOptions,
presence: TaskChangePresenceState | null
) => {
const cacheKey = buildTaskChangePresenceKey(teamName, taskId, options);
get().recordTaskChangePresences([{ teamName, taskId, options, presence }]);
},
recordTaskChangePresences: (entries) => {
if (entries.length === 0) {
return;
}
const updates = entries.map(({ teamName, taskId, options, presence }) => ({
cacheKey: buildTaskChangePresenceKey(teamName, taskId, options),
presence,
}));
set((s) => {
return {
taskChangePresenceByKey: applyTaskChangePresenceCacheUpdate(
s.taskChangePresenceByKey,
cacheKey,
presence
),
};
const nextTaskChangePresenceByKey = applyTaskChangePresenceCacheUpdates(
s.taskChangePresenceByKey,
updates
);
if (nextTaskChangePresenceByKey === s.taskChangePresenceByKey) {
return {};
}
return { taskChangePresenceByKey: nextTaskChangePresenceByKey };
});
syncTaskChangeNegativeCache(cacheKey, presence);
for (const update of updates) {
syncTaskChangeNegativeCache(update.cacheKey, update.presence);
}
},
fetchTaskChanges: async (

View file

@ -1002,6 +1002,10 @@ export interface TeamSlice {
taskId: string,
presence: TaskChangePresenceState
) => void;
setSelectedTeamTaskChangePresences: (
teamName: string,
presencesByTaskId: Record<string, TaskChangePresenceState>
) => void;
refreshTeamChangePresence: (teamName: string) => Promise<void>;
selectTeam: (
teamName: string,
@ -2065,14 +2069,24 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
},
setSelectedTeamTaskChangePresence: (teamName, taskId, presence) => {
get().setSelectedTeamTaskChangePresences(teamName, { [taskId]: presence });
},
setSelectedTeamTaskChangePresences: (teamName, presencesByTaskId) => {
set((state) => {
const updates = Object.entries(presencesByTaskId);
if (updates.length === 0) {
return {};
}
const presenceByTaskId = new Map(updates);
const currentTeamData = selectTeamDataForName(state, teamName);
let cacheChanged = false;
const nextTeamData = currentTeamData
? {
...currentTeamData,
tasks: currentTeamData.tasks.map((task) => {
if (task.id !== taskId || task.changePresence === presence) {
const presence = presenceByTaskId.get(task.id);
if (!presence || task.changePresence === presence) {
return task;
}
cacheChanged = true;
@ -2083,7 +2097,11 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
let globalChanged = false;
const nextGlobalTasks = state.globalTasks.map((task) => {
if (task.teamName !== teamName || task.id !== taskId || task.changePresence === presence) {
if (task.teamName !== teamName) {
return task;
}
const presence = presenceByTaskId.get(task.id);
if (!presence || task.changePresence === presence) {
return task;
}
globalChanged = true;

View file

@ -45,10 +45,12 @@ const resolvedMemberSelectorCache = new Map<
result: ResolvedTeamMember | null;
}
>();
let activeRawTeammateNameKeysCache = new WeakMap<TeamViewSnapshot['members'], string[]>();
export function clearResolvedMemberSelectorCaches(): void {
resolvedMembersSelectorCache.clear();
resolvedMemberSelectorCache.clear();
activeRawTeammateNameKeysCache = new WeakMap<TeamViewSnapshot['members'], string[]>();
}
export function clearResolvedMemberSelectorCachesForTeam(teamName: string): void {
@ -166,6 +168,10 @@ function getActiveRawTeammateNameKeys(snapshot: TeamViewSnapshot | null | undefi
if (!snapshot) {
return [];
}
const cached = activeRawTeammateNameKeysCache.get(snapshot.members);
if (cached) {
return cached;
}
const names = new Set<string>();
for (const member of snapshot.members) {
const name = member.name.trim();
@ -175,7 +181,9 @@ function getActiveRawTeammateNameKeys(snapshot: TeamViewSnapshot | null | undefi
}
names.add(key);
}
return Array.from(names).sort((left, right) => left.localeCompare(right));
const result = Array.from(names).sort((left, right) => left.localeCompare(right));
activeRawTeammateNameKeysCache.set(snapshot.members, result);
return result;
}
function hasActiveRawTeammateRoster(snapshot: TeamViewSnapshot | null | undefined): boolean {

View file

@ -213,6 +213,29 @@ describe('changeReviewSlice task changes', () => {
).toBe('no_changes');
});
it('records task change presence entries in one batch', () => {
const store = createSliceStore();
const keyA = buildTaskChangePresenceKey('team-a', 'task-a', OPTIONS_A);
const keyB = buildTaskChangePresenceKey('team-a', 'task-b', OPTIONS_B);
store.getState().recordTaskChangePresences([
{ teamName: 'team-a', taskId: 'task-a', options: OPTIONS_A, presence: 'has_changes' },
{ teamName: 'team-a', taskId: 'task-b', options: OPTIONS_B, presence: 'no_changes' },
]);
expect(store.getState().taskChangePresenceByKey[keyA]).toBe('has_changes');
expect(store.getState().taskChangePresenceByKey[keyB]).toBe('no_changes');
store
.getState()
.recordTaskChangePresences([
{ teamName: 'team-a', taskId: 'task-a', options: OPTIONS_A, presence: 'unknown' },
]);
expect(store.getState().taskChangePresenceByKey[keyA]).toBeUndefined();
expect(store.getState().taskChangePresenceByKey[keyB]).toBe('no_changes');
});
it('updates selected team task changePresence after a positive summary check', async () => {
const store = createSliceStore();
hoisted.getTaskChanges.mockResolvedValue(makeTaskChangeSet('presence-hit'));

View file

@ -382,6 +382,48 @@ describe('teamSlice actions', () => {
expect(window.localStorage.getItem('team:messagesPanelMode')).toBe('inline');
});
it('updates selected team task change presence in one batch', () => {
const store = createSliceStore();
const existingData = createTeamSnapshot({
teamName: 'my-team',
tasks: [
{ id: 'task-1', subject: 'One', changePresence: 'unknown' },
{ id: 'task-2', subject: 'Two', changePresence: 'unknown' },
],
});
store.setState({
selectedTeamName: 'my-team',
selectedTeamData: existingData,
teamDataCacheByName: { 'my-team': existingData },
globalTasks: [
{ teamName: 'my-team', id: 'task-1', changePresence: 'unknown' },
{ teamName: 'my-team', id: 'task-2', changePresence: 'unknown' },
{ teamName: 'other-team', id: 'task-1', changePresence: 'unknown' },
],
});
store.getState().setSelectedTeamTaskChangePresences('my-team', {
'task-1': 'no_changes',
'task-2': 'has_changes',
});
expect(
store
.getState()
.selectedTeamData.tasks.map((task: { changePresence?: string }) => task.changePresence)
).toEqual(['no_changes', 'has_changes']);
expect(
store
.getState()
.teamDataCacheByName[
'my-team'
].tasks.map((task: { changePresence?: string }) => task.changePresence)
).toEqual(['no_changes', 'has_changes']);
expect(
store.getState().globalTasks.map((task: { changePresence?: string }) => task.changePresence)
).toEqual(['no_changes', 'has_changes', 'unknown']);
});
it('records terminal provisioning fanout diagnostics without changing visible graph hydrate behavior', () => {
const store = createSliceStore();
const fetchTeams = vi.fn(async () => undefined);