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

View file

@ -53,7 +53,6 @@ import type {
FileChangeWithContent, FileChangeWithContent,
FileReviewDecision, FileReviewDecision,
HunkDecision, HunkDecision,
SnippetDiff,
TaskChangePresenceState, TaskChangePresenceState,
TaskChangeSet, TaskChangeSet,
TaskChangeSetV2, TaskChangeSetV2,
@ -148,6 +147,38 @@ function applyTaskChangePresenceCacheUpdate(
return nextTaskChangePresenceByKey; 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( function syncTaskChangeNegativeCache(
cacheKey: string, cacheKey: string,
presence: TaskChangePresenceState | null presence: TaskChangePresenceState | null
@ -207,6 +238,14 @@ export interface ChangeReviewSlice {
options: TaskChangeRequestOptions, options: TaskChangeRequestOptions,
presence: TaskChangePresenceState | null presence: TaskChangePresenceState | null
) => void; ) => void;
recordTaskChangePresences: (
entries: {
teamName: string;
taskId: string;
options: TaskChangeRequestOptions;
presence: TaskChangePresenceState | null;
}[]
) => void;
selectReviewFile: (filePath: string | null) => void; selectReviewFile: (filePath: string | null) => void;
clearChangeReview: () => void; clearChangeReview: () => void;
clearChangeReviewCache: () => void; clearChangeReviewCache: () => void;
@ -570,17 +609,30 @@ export const createChangeReviewSlice: StateCreator<AppState, [], [], ChangeRevie
options: TaskChangeRequestOptions, options: TaskChangeRequestOptions,
presence: TaskChangePresenceState | null 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) => { set((s) => {
return { const nextTaskChangePresenceByKey = applyTaskChangePresenceCacheUpdates(
taskChangePresenceByKey: applyTaskChangePresenceCacheUpdate( s.taskChangePresenceByKey,
s.taskChangePresenceByKey, updates
cacheKey, );
presence if (nextTaskChangePresenceByKey === s.taskChangePresenceByKey) {
), return {};
}; }
return { taskChangePresenceByKey: nextTaskChangePresenceByKey };
}); });
syncTaskChangeNegativeCache(cacheKey, presence); for (const update of updates) {
syncTaskChangeNegativeCache(update.cacheKey, update.presence);
}
}, },
fetchTaskChanges: async ( fetchTaskChanges: async (

View file

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

View file

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

View file

@ -213,6 +213,29 @@ describe('changeReviewSlice task changes', () => {
).toBe('no_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 () => { it('updates selected team task changePresence after a positive summary check', async () => {
const store = createSliceStore(); const store = createSliceStore();
hoisted.getTaskChanges.mockResolvedValue(makeTaskChangeSet('presence-hit')); hoisted.getTaskChanges.mockResolvedValue(makeTaskChangeSet('presence-hit'));

View file

@ -382,6 +382,48 @@ describe('teamSlice actions', () => {
expect(window.localStorage.getItem('team:messagesPanelMode')).toBe('inline'); 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', () => { it('records terminal provisioning fanout diagnostics without changing visible graph hydrate behavior', () => {
const store = createSliceStore(); const store = createSliceStore();
const fetchTeams = vi.fn(async () => undefined); const fetchTeams = vi.fn(async () => undefined);