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,
|
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(() => {
|
||||||
|
|
|
||||||
|
|
@ -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 (
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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'));
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue