perf(team): batch task presence updates
This commit is contained in:
parent
ff0543caf9
commit
97eb98466a
6 changed files with 232 additions and 17 deletions
|
|
@ -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(() => {
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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'));
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Reference in a new issue