diff --git a/src/renderer/components/team/useTeamChangesSummaries.ts b/src/renderer/components/team/useTeamChangesSummaries.ts index 01a4a500..8d46a311 100644 --- a/src/renderer/components/team/useTeamChangesSummaries.ts +++ b/src/renderer/components/team/useTeamChangesSummaries.ts @@ -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 +) => void; + interface UseTeamChangesSummariesResult { summariesByTaskId: Record; 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 >({}); @@ -469,6 +497,14 @@ export function useTeamChangesSummaries({ }); setCounterLoaded(true); + const cachePresenceUpdates: { + teamName: string; + taskId: string; + options: TaskChangeRequestOptions; + presence: TaskChangePresenceState | null; + }[] = []; + const selectedPresenceUpdates: Record = {}; + 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(() => { diff --git a/src/renderer/store/slices/changeReviewSlice.ts b/src/renderer/store/slices/changeReviewSlice.ts index 48707f6c..2c9fde50 100644 --- a/src/renderer/store/slices/changeReviewSlice.ts +++ b/src/renderer/store/slices/changeReviewSlice.ts @@ -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>, + updates: readonly TaskChangePresenceCacheUpdate[] +): Record> { + 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 { - 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 ( diff --git a/src/renderer/store/slices/teamSlice.ts b/src/renderer/store/slices/teamSlice.ts index 602629c4..ebbd95a2 100644 --- a/src/renderer/store/slices/teamSlice.ts +++ b/src/renderer/store/slices/teamSlice.ts @@ -1002,6 +1002,10 @@ export interface TeamSlice { taskId: string, presence: TaskChangePresenceState ) => void; + setSelectedTeamTaskChangePresences: ( + teamName: string, + presencesByTaskId: Record + ) => void; refreshTeamChangePresence: (teamName: string) => Promise; selectTeam: ( teamName: string, @@ -2065,14 +2069,24 @@ export const createTeamSlice: StateCreator = (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 = (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; diff --git a/src/renderer/store/team/teamResolvedMembers.ts b/src/renderer/store/team/teamResolvedMembers.ts index 1f5b093f..266a039d 100644 --- a/src/renderer/store/team/teamResolvedMembers.ts +++ b/src/renderer/store/team/teamResolvedMembers.ts @@ -45,10 +45,12 @@ const resolvedMemberSelectorCache = new Map< result: ResolvedTeamMember | null; } >(); +let activeRawTeammateNameKeysCache = new WeakMap(); export function clearResolvedMemberSelectorCaches(): void { resolvedMembersSelectorCache.clear(); resolvedMemberSelectorCache.clear(); + activeRawTeammateNameKeysCache = new WeakMap(); } 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(); 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 { diff --git a/test/renderer/store/changeReviewSlice.test.ts b/test/renderer/store/changeReviewSlice.test.ts index 7b9597d0..135d80d2 100644 --- a/test/renderer/store/changeReviewSlice.test.ts +++ b/test/renderer/store/changeReviewSlice.test.ts @@ -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')); diff --git a/test/renderer/store/teamSlice.test.ts b/test/renderer/store/teamSlice.test.ts index ffd422fe..d91bcb3f 100644 --- a/test/renderer/store/teamSlice.test.ts +++ b/test/renderer/store/teamSlice.test.ts @@ -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);