agent-ecosystem/src/renderer/store/slices/changeReviewSlice.ts
iliya fd3176716b feat: enhance team management and review processes with new functionalities
- Added support for inferring team lead names from configuration, improving message attribution in task communications.
- Introduced new dependencies for CodeMirror language support, enhancing the editing experience for various programming languages.
- Implemented scoped item IDs in the CLI logs view to prevent cross-group collisions, improving UI clarity.
- Enhanced sorting logic in the team list view to prioritize alive teams and match current project paths.
- Added lazy-check functionality for task changes in Kanban cards, optimizing performance and user experience.
- Updated diff view components to support new language features and improve overall editing capabilities.
2026-02-25 11:40:01 +02:00

450 lines
14 KiB
TypeScript

import { api } from '@renderer/api';
import { createLogger } from '@shared/utils/logger';
/** Tracks in-flight checkTaskHasChanges calls to avoid duplicate requests */
const taskChangesCheckInFlight = new Set<string>();
/** Negative results cached with timestamp — recheck after 30s */
const taskChangesNegativeCache = new Map<string, number>();
const NEGATIVE_CACHE_TTL = 30_000;
import type { AppState } from '../types';
import type {
AgentChangeSet,
ApplyReviewRequest,
ChangeStats,
FileChangeWithContent,
FileReviewDecision,
HunkDecision,
TaskChangeSet,
TaskChangeSetV2,
} from '@shared/types';
import type { StateCreator } from 'zustand';
const logger = createLogger('changeReviewSlice');
function mapReviewError(error: unknown): string {
const message = error instanceof Error ? error.message : String(error);
if (message.includes('conflict')) return 'File has been modified since agent changes.';
if (message.includes('ENOENT')) return 'File no longer exists on disk.';
if (message.includes('EACCES') || message.includes('Permission')) return 'Permission denied.';
return message || 'Failed to apply review changes';
}
export interface ChangeReviewSlice {
// Phase 1 state
activeChangeSet: AgentChangeSet | TaskChangeSet | TaskChangeSetV2 | null;
changeSetLoading: boolean;
changeSetError: string | null;
selectedReviewFilePath: string | null;
changeStatsCache: Record<string, ChangeStats>;
// Phase 2 state
hunkDecisions: Record<string, HunkDecision>;
fileDecisions: Record<string, HunkDecision>;
fileContents: Record<string, FileChangeWithContent>;
fileContentsLoading: Record<string, boolean>;
collapseUnchanged: boolean;
applyError: string | null;
applying: boolean;
// Editable diff state
editedContents: Record<string, string>;
/** Cache: "teamName:taskId" → true/false (has file changes) */
taskHasChanges: Record<string, boolean>;
// Phase 1 actions
fetchAgentChanges: (teamName: string, memberName: string) => Promise<void>;
fetchTaskChanges: (teamName: string, taskId: string) => Promise<void>;
selectReviewFile: (filePath: string | null) => void;
clearChangeReview: () => void;
fetchChangeStats: (teamName: string, memberName: string) => Promise<void>;
// Phase 2 actions
setHunkDecision: (filePath: string, hunkIndex: number, decision: HunkDecision) => void;
setFileDecision: (filePath: string, decision: HunkDecision) => void;
acceptAllFile: (filePath: string) => void;
rejectAllFile: (filePath: string) => void;
acceptAll: () => void;
rejectAll: () => void;
setCollapseUnchanged: (collapse: boolean) => void;
fetchFileContent: (
teamName: string,
memberName: string | undefined,
filePath: string
) => Promise<void>;
applyReview: (teamName: string, taskId?: string, memberName?: string) => Promise<void>;
invalidateChangeStats: (teamName: string) => void;
// Editable diff actions
updateEditedContent: (filePath: string, content: string) => void;
discardFileEdits: (filePath: string) => void;
discardAllEdits: () => void;
saveEditedFile: (filePath: string) => Promise<void>;
// Task change availability
checkTaskHasChanges: (teamName: string, taskId: string) => Promise<void>;
}
export const createChangeReviewSlice: StateCreator<AppState, [], [], ChangeReviewSlice> = (
set,
get
) => ({
// Phase 1 initial state
activeChangeSet: null,
changeSetLoading: false,
changeSetError: null,
selectedReviewFilePath: null,
changeStatsCache: {},
// Phase 2 initial state
hunkDecisions: {},
fileDecisions: {},
fileContents: {},
fileContentsLoading: {},
collapseUnchanged: true,
applyError: null,
applying: false,
// Editable diff initial state
editedContents: {},
taskHasChanges: {},
fetchAgentChanges: async (teamName: string, memberName: string) => {
set({ changeSetLoading: true, changeSetError: null });
try {
const data = await api.review.getAgentChanges(teamName, memberName);
set({
activeChangeSet: data,
changeSetLoading: false,
selectedReviewFilePath: data.files[0]?.filePath ?? null,
});
} catch (error) {
const message = error instanceof Error ? error.message : 'Failed to fetch agent changes';
logger.error('fetchAgentChanges error:', message);
set({ changeSetError: message, changeSetLoading: false });
}
},
fetchTaskChanges: async (teamName: string, taskId: string) => {
set({ changeSetLoading: true, changeSetError: null });
try {
const data = await api.review.getTaskChanges(teamName, taskId);
const cacheKey = `${teamName}:${taskId}`;
set((s) => ({
activeChangeSet: data,
changeSetLoading: false,
selectedReviewFilePath: data.files[0]?.filePath ?? null,
taskHasChanges: { ...s.taskHasChanges, [cacheKey]: data.files.length > 0 },
}));
} catch (error) {
const message = error instanceof Error ? error.message : 'Failed to fetch task changes';
logger.error('fetchTaskChanges error:', message);
set({ changeSetError: message, changeSetLoading: false });
}
},
selectReviewFile: (filePath: string | null) => {
set({ selectedReviewFilePath: filePath });
},
clearChangeReview: () => {
set({
activeChangeSet: null,
changeSetLoading: false,
changeSetError: null,
selectedReviewFilePath: null,
hunkDecisions: {},
fileDecisions: {},
fileContents: {},
fileContentsLoading: {},
applyError: null,
applying: false,
editedContents: {},
});
},
fetchChangeStats: async (teamName: string, memberName: string) => {
try {
const stats = await api.review.getChangeStats(teamName, memberName);
const key = `${teamName}:${memberName}`;
set((state) => ({
changeStatsCache: { ...state.changeStatsCache, [key]: stats },
}));
} catch (error) {
logger.error('fetchChangeStats error:', error);
}
},
// ── Phase 2 actions ──
setHunkDecision: (filePath: string, hunkIndex: number, decision: HunkDecision) => {
const key = `${filePath}:${hunkIndex}`;
set((state) => ({
hunkDecisions: { ...state.hunkDecisions, [key]: decision },
}));
},
setFileDecision: (filePath: string, decision: HunkDecision) => {
set((state) => ({
fileDecisions: { ...state.fileDecisions, [filePath]: decision },
}));
},
acceptAllFile: (filePath: string) => {
const state = get();
const file = state.activeChangeSet?.files.find((f) => f.filePath === filePath);
if (!file) return;
const newHunkDecisions = { ...state.hunkDecisions };
for (let i = 0; i < file.snippets.length; i++) {
newHunkDecisions[`${filePath}:${i}`] = 'accepted';
}
set({
hunkDecisions: newHunkDecisions,
fileDecisions: { ...state.fileDecisions, [filePath]: 'accepted' },
});
},
rejectAllFile: (filePath: string) => {
const state = get();
const file = state.activeChangeSet?.files.find((f) => f.filePath === filePath);
if (!file) return;
const newHunkDecisions = { ...state.hunkDecisions };
for (let i = 0; i < file.snippets.length; i++) {
newHunkDecisions[`${filePath}:${i}`] = 'rejected';
}
set({
hunkDecisions: newHunkDecisions,
fileDecisions: { ...state.fileDecisions, [filePath]: 'rejected' },
});
},
acceptAll: () => {
const state = get();
if (!state.activeChangeSet) return;
const newHunkDecisions: Record<string, HunkDecision> = {};
const newFileDecisions: Record<string, HunkDecision> = {};
for (const file of state.activeChangeSet.files) {
newFileDecisions[file.filePath] = 'accepted';
for (let i = 0; i < file.snippets.length; i++) {
newHunkDecisions[`${file.filePath}:${i}`] = 'accepted';
}
}
set({ hunkDecisions: newHunkDecisions, fileDecisions: newFileDecisions });
},
rejectAll: () => {
const state = get();
if (!state.activeChangeSet) return;
const newHunkDecisions: Record<string, HunkDecision> = {};
const newFileDecisions: Record<string, HunkDecision> = {};
for (const file of state.activeChangeSet.files) {
newFileDecisions[file.filePath] = 'rejected';
for (let i = 0; i < file.snippets.length; i++) {
newHunkDecisions[`${file.filePath}:${i}`] = 'rejected';
}
}
set({ hunkDecisions: newHunkDecisions, fileDecisions: newFileDecisions });
},
setCollapseUnchanged: (collapse: boolean) => {
set({ collapseUnchanged: collapse });
},
fetchFileContent: async (teamName: string, memberName: string | undefined, filePath: string) => {
const state = get();
// Skip if already loaded or loading
if (state.fileContents[filePath] || state.fileContentsLoading[filePath]) return;
set((s) => ({
fileContentsLoading: { ...s.fileContentsLoading, [filePath]: true },
}));
try {
const content = await api.review.getFileContent(teamName, memberName, filePath);
set((s) => ({
fileContents: { ...s.fileContents, [filePath]: content },
fileContentsLoading: { ...s.fileContentsLoading, [filePath]: false },
}));
} catch (error) {
logger.error('fetchFileContent error:', error);
set((s) => ({
fileContentsLoading: { ...s.fileContentsLoading, [filePath]: false },
}));
}
},
applyReview: async (teamName: string, taskId?: string, memberName?: string) => {
set({ applying: true, applyError: null });
try {
// Stale check: re-fetch changes and compare content fingerprint
const state = get();
const current = state.activeChangeSet;
const fingerprint = (cs: {
totalFiles: number;
totalLinesAdded: number;
totalLinesRemoved: number;
files: { filePath: string }[];
}): string =>
`${cs.totalFiles}:${cs.totalLinesAdded}:${cs.totalLinesRemoved}:${cs.files.map((f) => f.filePath).join(',')}`;
if (memberName && current) {
const fresh = await api.review.getAgentChanges(teamName, memberName);
if (fingerprint(fresh) !== fingerprint(current)) {
set({
activeChangeSet: fresh,
applying: false,
applyError: 'Changes have been updated since you started reviewing. Please re-review.',
});
return;
}
} else if (taskId && current) {
const fresh = await api.review.getTaskChanges(teamName, taskId);
if (fingerprint(fresh) !== fingerprint(current)) {
set({
activeChangeSet: fresh,
applying: false,
applyError: 'Changes have been updated since you started reviewing. Please re-review.',
});
return;
}
}
// Build FileReviewDecision[] from hunkDecisions/fileDecisions
const { hunkDecisions, fileDecisions, activeChangeSet } = get();
if (!activeChangeSet) {
set({ applying: false });
return;
}
const decisions: FileReviewDecision[] = [];
for (const file of activeChangeSet.files) {
const fileDecision = fileDecisions[file.filePath] ?? 'pending';
const hunkDecs: Record<number, HunkDecision> = {};
for (let i = 0; i < file.snippets.length; i++) {
const key = `${file.filePath}:${i}`;
hunkDecs[i] = hunkDecisions[key] ?? 'pending';
}
// Only include files that have at least one rejected hunk
const hasRejected =
fileDecision === 'rejected' || Object.values(hunkDecs).some((d) => d === 'rejected');
if (hasRejected) {
decisions.push({
filePath: file.filePath,
fileDecision,
hunkDecisions: hunkDecs,
});
}
}
if (decisions.length === 0) {
set({ applying: false });
return;
}
const request: ApplyReviewRequest = {
teamName,
taskId,
memberName,
decisions,
};
await api.review.applyDecisions(request);
set({ applying: false });
} catch (error) {
logger.error('applyReview error:', error);
set({
applying: false,
applyError: mapReviewError(error),
});
}
},
// ── Editable diff actions ──
updateEditedContent: (filePath: string, content: string) => {
set((s) => ({
editedContents: { ...s.editedContents, [filePath]: content },
}));
},
discardFileEdits: (filePath: string) => {
set((s) => {
const next = { ...s.editedContents };
delete next[filePath];
return { editedContents: next };
});
},
discardAllEdits: () => set({ editedContents: {} }),
saveEditedFile: async (filePath: string) => {
const content = get().editedContents[filePath];
if (!(filePath in get().editedContents)) return;
set({ applying: true, applyError: null });
try {
await api.review.saveEditedFile(filePath, content);
set((s) => {
const next = { ...s.editedContents };
delete next[filePath];
return { editedContents: next, applying: false };
});
} catch (error) {
set({ applying: false, applyError: mapReviewError(error) });
}
},
checkTaskHasChanges: async (teamName: string, taskId: string) => {
const cacheKey = `${teamName}:${taskId}`;
// Positive results are final — no need to recheck
if (get().taskHasChanges[cacheKey] === true) return;
// Prevent duplicate in-flight requests
if (taskChangesCheckInFlight.has(cacheKey)) return;
// Negative results cached with TTL — avoid API spam for tasks that truly have no changes
const negativeTs = taskChangesNegativeCache.get(cacheKey);
if (negativeTs && Date.now() - negativeTs < NEGATIVE_CACHE_TTL) return;
taskChangesCheckInFlight.add(cacheKey);
try {
const data = await api.review.getTaskChanges(teamName, taskId);
if (data.files.length > 0) {
set((s) => ({
taskHasChanges: { ...s.taskHasChanges, [cacheKey]: true },
}));
taskChangesNegativeCache.delete(cacheKey);
} else {
taskChangesNegativeCache.set(cacheKey, Date.now());
}
} catch {
// Don't cache errors in store — allow retry when session data appears later
taskChangesNegativeCache.set(cacheKey, Date.now());
} finally {
taskChangesCheckInFlight.delete(cacheKey);
}
},
invalidateChangeStats: (teamName: string) => {
set((state) => {
const newCache = { ...state.changeStatsCache };
// Remove all entries for this team
for (const key of Object.keys(newCache)) {
if (key.startsWith(`${teamName}:`)) {
delete newCache[key];
}
}
return { changeStatsCache: newCache };
});
},
});