- Invalidate main-process file content cache after applying review decisions - Clear stale per-file review state after instant-apply to prevent re-apply conflicts - Disable accept/reject for preview-only files and surface deleted/renamed/moved badges - Persist per-file collapse state across reopen Made-with: Cursor
1022 lines
35 KiB
TypeScript
1022 lines
35 KiB
TypeScript
import { api } from '@renderer/api';
|
|
import { computeDiffContextHash } from '@shared/utils/diffContextHash';
|
|
import { createLogger } from '@shared/utils/logger';
|
|
import { structuredPatch } from 'diff';
|
|
|
|
/** 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;
|
|
|
|
/** Debounce timer for persisting decisions to disk */
|
|
let persistDebounceTimer: ReturnType<typeof setTimeout> | null = null;
|
|
const PERSIST_DEBOUNCE_MS = 500;
|
|
|
|
import type { AppState } from '../types';
|
|
import type {
|
|
AgentChangeSet,
|
|
ApplyReviewRequest,
|
|
ApplyReviewResult,
|
|
ChangeStats,
|
|
FileChangeSummary,
|
|
FileChangeWithContent,
|
|
FileReviewDecision,
|
|
HunkDecision,
|
|
TaskChangeSet,
|
|
TaskChangeSetV2,
|
|
} from '@shared/types';
|
|
import type { StateCreator } from 'zustand';
|
|
|
|
const logger = createLogger('changeReviewSlice');
|
|
|
|
/** Snapshot of review decisions for undo support */
|
|
interface DecisionSnapshot {
|
|
hunkDecisions: Record<string, HunkDecision>;
|
|
fileDecisions: Record<string, HunkDecision>;
|
|
}
|
|
|
|
const MAX_REVIEW_UNDO_DEPTH = 10;
|
|
|
|
/**
|
|
* When true, rejected hunks are immediately applied to disk (no need for "Apply All Changes").
|
|
* When false, decisions are batched and applied manually via "Apply All Changes" button.
|
|
*/
|
|
export const REVIEW_INSTANT_APPLY = true;
|
|
|
|
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>;
|
|
/** Actual CodeMirror chunk count per file (may differ from snippets.length) */
|
|
fileChunkCounts: Record<string, number>;
|
|
/** Undo stack for bulk review operations (Accept All / Reject All) */
|
|
reviewUndoStack: DecisionSnapshot[];
|
|
/** filePath -> (hunkIndex -> contextHash), persisted for robust replay */
|
|
hunkContextHashesByFile: Record<string, Record<number, string>>;
|
|
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;
|
|
clearChangeReviewCache: () => void;
|
|
resetAllReviewState: () => void;
|
|
fetchChangeStats: (teamName: string, memberName: string) => Promise<void>;
|
|
|
|
// Decision persistence actions
|
|
loadDecisionsFromDisk: (teamName: string, scopeKey: string) => Promise<void>;
|
|
persistDecisions: (teamName: string, scopeKey: string) => void;
|
|
clearDecisionsFromDisk: (teamName: string, scopeKey: string) => Promise<void>;
|
|
|
|
// Phase 2 actions
|
|
/**
|
|
* Set decision for a hunk at the current (visible) CM index.
|
|
* Returns the stable/original hunk index used as the decision key.
|
|
*/
|
|
setHunkDecision: (filePath: string, hunkIndex: number, decision: HunkDecision) => number;
|
|
/** Clear a persisted decision using the stable/original hunk index */
|
|
clearHunkDecisionByOriginalIndex: (filePath: string, originalIndex: number) => void;
|
|
setFileDecision: (filePath: string, decision: HunkDecision) => void;
|
|
setFileChunkCount: (filePath: string, count: number) => void;
|
|
pushReviewUndoSnapshot: () => void;
|
|
undoBulkReview: () => boolean;
|
|
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>;
|
|
applySingleFileDecision: (
|
|
teamName: string,
|
|
filePath: string,
|
|
taskId?: string,
|
|
memberName?: string
|
|
) => Promise<ApplyReviewResult | null>;
|
|
/** Remove a file from the current review set (used for rejecting new files) */
|
|
removeReviewFile: (filePath: string) => void;
|
|
/** Re-add a file to the current review set (used for undoing new-file reject) */
|
|
addReviewFile: (
|
|
file: FileChangeSummary,
|
|
options?: { index?: number; content?: FileChangeWithContent }
|
|
) => void;
|
|
/**
|
|
* Clear in-memory review state for a single file after applying changes to disk.
|
|
* Prevents stale decisions from being re-applied later and forces fresh content resolve.
|
|
*/
|
|
clearReviewStateForFile: (filePath: string) => void;
|
|
invalidateChangeStats: (teamName: string) => void;
|
|
|
|
// Editable diff actions
|
|
updateEditedContent: (filePath: string, content: string) => void;
|
|
discardFileEdits: (filePath: string) => void;
|
|
discardAllEdits: () => void;
|
|
saveEditedFile: (filePath: string, projectPath?: string) => Promise<void>;
|
|
|
|
// Task change availability
|
|
checkTaskHasChanges: (teamName: string, taskId: string) => Promise<void>;
|
|
}
|
|
|
|
/**
|
|
* Map a current CM chunk index to its original index, accounting for chunks
|
|
* that have been accepted/rejected (removed from CM view, causing index shifts).
|
|
*
|
|
* When chunk 0 is accepted, CM removes it — old chunk 1 becomes new chunk 0.
|
|
* This function reverses that shift so decisions are stored with stable indices.
|
|
*/
|
|
function mapCurrentToOriginalIndex(
|
|
filePath: string,
|
|
currentIdx: number,
|
|
hunkDecisions: Record<string, HunkDecision>,
|
|
totalChunks: number
|
|
): number {
|
|
const decided = new Set<number>();
|
|
for (let i = 0; i < totalChunks; i++) {
|
|
if (`${filePath}:${i}` in hunkDecisions) {
|
|
decided.add(i);
|
|
}
|
|
}
|
|
|
|
// Walk original indices, skip already-decided, count undecided until currentIdx
|
|
let undecidedSeen = 0;
|
|
for (let orig = 0; orig < totalChunks; orig++) {
|
|
if (decided.has(orig)) continue;
|
|
if (undecidedSeen === currentIdx) return orig;
|
|
undecidedSeen++;
|
|
}
|
|
|
|
return currentIdx;
|
|
}
|
|
|
|
/** Get the hunk count for a file: prefer actual CM chunk count, fallback to snippet count */
|
|
export function getFileHunkCount(
|
|
filePath: string,
|
|
snippetsLength: number,
|
|
fileChunkCounts: Record<string, number>
|
|
): number {
|
|
return fileChunkCounts[filePath] ?? snippetsLength;
|
|
}
|
|
|
|
function getMaxDecisionIndexForFile(
|
|
filePath: string,
|
|
hunkDecisions: Record<string, HunkDecision>
|
|
): number {
|
|
let max = -1;
|
|
const prefix = `${filePath}:`;
|
|
for (const key of Object.keys(hunkDecisions)) {
|
|
if (!key.startsWith(prefix)) continue;
|
|
const raw = key.slice(prefix.length);
|
|
const idx = Number.parseInt(raw, 10);
|
|
if (!Number.isNaN(idx)) {
|
|
max = Math.max(max, idx);
|
|
}
|
|
}
|
|
return max;
|
|
}
|
|
|
|
function buildHunkContextHashesForFile(
|
|
original: string | null | undefined,
|
|
modified: string | null | undefined,
|
|
expectedHunkCount: number
|
|
): Record<number, string> | undefined {
|
|
if (original === null || original === undefined) return undefined;
|
|
if (modified === null || modified === undefined) return undefined;
|
|
|
|
const patch = structuredPatch('file', 'file', original, modified);
|
|
const hunks = patch.hunks ?? [];
|
|
if (hunks.length === 0) return undefined;
|
|
if (hunks.length !== expectedHunkCount) return undefined;
|
|
|
|
const out: Record<number, string> = {};
|
|
for (let i = 0; i < hunks.length; i++) {
|
|
const hunk = hunks[i];
|
|
const oldSideContent = hunk.lines
|
|
.filter((l) => !l.startsWith('+'))
|
|
.map((l) => l.slice(1))
|
|
.join('\n');
|
|
const newSideContent = hunk.lines
|
|
.filter((l) => !l.startsWith('-'))
|
|
.map((l) => l.slice(1))
|
|
.join('\n');
|
|
out[i] = computeDiffContextHash(oldSideContent, newSideContent);
|
|
}
|
|
return out;
|
|
}
|
|
|
|
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: {},
|
|
fileChunkCounts: {},
|
|
reviewUndoStack: [],
|
|
hunkContextHashesByFile: {},
|
|
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: {},
|
|
fileChunkCounts: {},
|
|
reviewUndoStack: [],
|
|
hunkContextHashesByFile: {},
|
|
fileContents: {},
|
|
fileContentsLoading: {},
|
|
applyError: null,
|
|
applying: false,
|
|
editedContents: {},
|
|
});
|
|
},
|
|
|
|
clearChangeReviewCache: () => {
|
|
set({
|
|
activeChangeSet: null,
|
|
changeSetLoading: false,
|
|
changeSetError: null,
|
|
selectedReviewFilePath: null,
|
|
fileChunkCounts: {},
|
|
reviewUndoStack: [],
|
|
hunkContextHashesByFile: {},
|
|
fileContents: {},
|
|
fileContentsLoading: {},
|
|
applyError: null,
|
|
applying: false,
|
|
editedContents: {},
|
|
});
|
|
},
|
|
|
|
resetAllReviewState: () => {
|
|
set({
|
|
activeChangeSet: null,
|
|
changeSetLoading: false,
|
|
changeSetError: null,
|
|
selectedReviewFilePath: null,
|
|
hunkDecisions: {},
|
|
fileDecisions: {},
|
|
fileChunkCounts: {},
|
|
reviewUndoStack: [],
|
|
hunkContextHashesByFile: {},
|
|
fileContents: {},
|
|
fileContentsLoading: {},
|
|
applyError: null,
|
|
applying: false,
|
|
editedContents: {},
|
|
});
|
|
},
|
|
|
|
// ── Decision persistence ──
|
|
|
|
loadDecisionsFromDisk: async (teamName: string, scopeKey: string) => {
|
|
try {
|
|
const data = await api.review.loadDecisions(teamName, scopeKey);
|
|
// Always set decisions — even to empty if no saved file exists.
|
|
// This prevents stale decisions from a previous scope leaking through.
|
|
set({
|
|
hunkDecisions: data?.hunkDecisions ?? {},
|
|
fileDecisions: data?.fileDecisions ?? {},
|
|
hunkContextHashesByFile: data?.hunkContextHashesByFile ?? {},
|
|
});
|
|
} catch (error) {
|
|
logger.error('loadDecisionsFromDisk error:', error);
|
|
}
|
|
},
|
|
|
|
persistDecisions: (teamName: string, scopeKey: string) => {
|
|
if (persistDebounceTimer) {
|
|
clearTimeout(persistDebounceTimer);
|
|
}
|
|
persistDebounceTimer = setTimeout(() => {
|
|
const {
|
|
hunkDecisions,
|
|
fileDecisions,
|
|
hunkContextHashesByFile,
|
|
activeChangeSet,
|
|
fileContents,
|
|
fileChunkCounts,
|
|
} = get();
|
|
|
|
const computed: Record<string, Record<number, string>> = {};
|
|
for (const file of activeChangeSet?.files ?? []) {
|
|
const fp = file.filePath;
|
|
const content = fileContents[fp];
|
|
if (!content) continue;
|
|
const expected = getFileHunkCount(fp, file.snippets.length, fileChunkCounts);
|
|
const hashes = buildHunkContextHashesForFile(
|
|
content.originalFullContent,
|
|
content.modifiedFullContent,
|
|
expected
|
|
);
|
|
if (hashes) computed[fp] = hashes;
|
|
}
|
|
|
|
// Prune to only files in the current scope. This avoids persisting stale file paths
|
|
// (e.g. from older sessions) that could confuse future replays.
|
|
const mergedHashes: Record<string, Record<number, string>> = {};
|
|
for (const file of activeChangeSet?.files ?? []) {
|
|
const fp = file.filePath;
|
|
mergedHashes[fp] = computed[fp] ?? hunkContextHashesByFile[fp] ?? {};
|
|
}
|
|
// Keep store in sync so replay can use hashes without reload.
|
|
set({ hunkContextHashesByFile: mergedHashes });
|
|
|
|
void api.review.saveDecisions(teamName, scopeKey, hunkDecisions, fileDecisions, mergedHashes);
|
|
}, PERSIST_DEBOUNCE_MS);
|
|
},
|
|
|
|
clearDecisionsFromDisk: async (teamName: string, scopeKey: string) => {
|
|
try {
|
|
await api.review.clearDecisions(teamName, scopeKey);
|
|
} catch (error) {
|
|
logger.error('clearDecisionsFromDisk error:', error);
|
|
}
|
|
},
|
|
|
|
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 state = get();
|
|
const totalChunks = state.fileChunkCounts[filePath] ?? 0;
|
|
// Map current chunk index to original: after accept/reject, chunks shift in CM.
|
|
// We need the original index to keep decisions stable across shifts.
|
|
const originalIndex =
|
|
totalChunks > 0
|
|
? mapCurrentToOriginalIndex(filePath, hunkIndex, state.hunkDecisions, totalChunks)
|
|
: hunkIndex;
|
|
const key = `${filePath}:${originalIndex}`;
|
|
set((s) => ({
|
|
hunkDecisions: { ...s.hunkDecisions, [key]: decision },
|
|
}));
|
|
return originalIndex;
|
|
},
|
|
|
|
clearHunkDecisionByOriginalIndex: (filePath: string, originalIndex: number) => {
|
|
const key = `${filePath}:${originalIndex}`;
|
|
set((s) => {
|
|
if (!(key in s.hunkDecisions)) return s;
|
|
const next = { ...s.hunkDecisions };
|
|
delete next[key];
|
|
return { hunkDecisions: next };
|
|
});
|
|
},
|
|
|
|
setFileDecision: (filePath: string, decision: HunkDecision) => {
|
|
set((state) => ({
|
|
fileDecisions: { ...state.fileDecisions, [filePath]: decision },
|
|
}));
|
|
},
|
|
|
|
setFileChunkCount: (filePath: string, count: number) => {
|
|
set((s) => ({
|
|
fileChunkCounts: { ...s.fileChunkCounts, [filePath]: count },
|
|
}));
|
|
},
|
|
|
|
pushReviewUndoSnapshot: () => {
|
|
const state = get();
|
|
const snapshot: DecisionSnapshot = {
|
|
hunkDecisions: { ...state.hunkDecisions },
|
|
fileDecisions: { ...state.fileDecisions },
|
|
};
|
|
const stack = [...state.reviewUndoStack, snapshot];
|
|
if (stack.length > MAX_REVIEW_UNDO_DEPTH) {
|
|
stack.shift();
|
|
}
|
|
set({ reviewUndoStack: stack });
|
|
},
|
|
|
|
undoBulkReview: () => {
|
|
const state = get();
|
|
if (state.reviewUndoStack.length === 0) return false;
|
|
const stack = [...state.reviewUndoStack];
|
|
const snapshot = stack.pop()!;
|
|
set({
|
|
hunkDecisions: snapshot.hunkDecisions,
|
|
fileDecisions: snapshot.fileDecisions,
|
|
reviewUndoStack: stack,
|
|
});
|
|
return true;
|
|
},
|
|
|
|
acceptAllFile: (filePath: string) => {
|
|
const state = get();
|
|
const file = state.activeChangeSet?.files.find((f) => f.filePath === filePath);
|
|
if (!file) return;
|
|
|
|
const count = getFileHunkCount(filePath, file.snippets.length, state.fileChunkCounts);
|
|
const newHunkDecisions = { ...state.hunkDecisions };
|
|
for (let i = 0; i < count; 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 count = getFileHunkCount(filePath, file.snippets.length, state.fileChunkCounts);
|
|
const newHunkDecisions = { ...state.hunkDecisions };
|
|
for (let i = 0; i < count; 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';
|
|
const count = getFileHunkCount(file.filePath, file.snippets.length, state.fileChunkCounts);
|
|
for (let i = 0; i < count; 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';
|
|
const count = getFileHunkCount(file.filePath, file.snippets.length, state.fileChunkCounts);
|
|
for (let i = 0; i < count; 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 {
|
|
// Lookup snippets from activeChangeSet so backend can use them for reconstruction
|
|
const activeChangeSet = get().activeChangeSet;
|
|
const fileEntry = activeChangeSet?.files.find((f) => f.filePath === filePath);
|
|
const snippets = fileEntry?.snippets ?? [];
|
|
|
|
const content = await api.review.getFileContent(teamName, memberName, filePath, snippets);
|
|
set((s) => {
|
|
const result: Partial<ChangeReviewSlice> = {
|
|
fileContents: { ...s.fileContents, [filePath]: content },
|
|
fileContentsLoading: { ...s.fileContentsLoading, [filePath]: false },
|
|
};
|
|
|
|
// Update activeChangeSet stats if original was successfully resolved
|
|
if (
|
|
content.contentSource !== 'unavailable' &&
|
|
content.contentSource !== 'disk-current' &&
|
|
s.activeChangeSet
|
|
) {
|
|
const updatedFiles = s.activeChangeSet.files.map((f) =>
|
|
f.filePath === filePath
|
|
? { ...f, linesAdded: content.linesAdded, linesRemoved: content.linesRemoved }
|
|
: f
|
|
);
|
|
const totalLinesAdded = updatedFiles.reduce((sum, f) => sum + f.linesAdded, 0);
|
|
const totalLinesRemoved = updatedFiles.reduce((sum, f) => sum + f.linesRemoved, 0);
|
|
result.activeChangeSet = {
|
|
...s.activeChangeSet,
|
|
files: updatedFiles,
|
|
totalLinesAdded,
|
|
totalLinesRemoved,
|
|
};
|
|
}
|
|
|
|
return result;
|
|
});
|
|
} 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;
|
|
// Fingerprint uses file count + file paths only (not line counts)
|
|
// because line counts may be corrected by lazy-loaded content resolution
|
|
const fingerprint = (cs: { totalFiles: number; files: { filePath: string }[] }): string =>
|
|
`${cs.totalFiles}:${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, fileChunkCounts, activeChangeSet, fileContents } =
|
|
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> = {};
|
|
|
|
const baseCount = getFileHunkCount(file.filePath, file.snippets.length, fileChunkCounts);
|
|
const maxIdx = getMaxDecisionIndexForFile(file.filePath, hunkDecisions);
|
|
const count = Math.max(baseCount, maxIdx + 1);
|
|
for (let i = 0; i < count; 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) {
|
|
const content = fileContents[file.filePath];
|
|
const hunkContextHashes =
|
|
maxIdx < baseCount
|
|
? buildHunkContextHashesForFile(
|
|
content?.originalFullContent,
|
|
content?.modifiedFullContent,
|
|
baseCount
|
|
)
|
|
: undefined;
|
|
decisions.push({
|
|
filePath: file.filePath,
|
|
fileDecision,
|
|
hunkDecisions: hunkDecs,
|
|
hunkContextHashes,
|
|
// Provide optional context so main can apply without re-resolving.
|
|
// If full contents are missing (lazy not loaded yet), still pass snippets.
|
|
snippets: content?.snippets ?? file.snippets,
|
|
originalFullContent: content?.originalFullContent,
|
|
modifiedFullContent: content?.modifiedFullContent,
|
|
isNewFile: content?.isNewFile ?? file.isNewFile,
|
|
});
|
|
}
|
|
}
|
|
|
|
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),
|
|
});
|
|
}
|
|
},
|
|
|
|
applySingleFileDecision: async (
|
|
teamName: string,
|
|
filePath: string,
|
|
taskId?: string,
|
|
memberName?: string
|
|
) => {
|
|
const { hunkDecisions, fileDecisions, fileChunkCounts, activeChangeSet, fileContents } = get();
|
|
if (!activeChangeSet) return null;
|
|
|
|
const file = activeChangeSet.files.find((f) => f.filePath === filePath);
|
|
if (!file) return null;
|
|
|
|
const fileDecision = fileDecisions[filePath] ?? 'pending';
|
|
const hunkDecs: Record<number, HunkDecision> = {};
|
|
const baseCount = getFileHunkCount(filePath, file.snippets.length, fileChunkCounts);
|
|
const maxIdx = getMaxDecisionIndexForFile(filePath, hunkDecisions);
|
|
const count = Math.max(baseCount, maxIdx + 1);
|
|
for (let i = 0; i < count; i++) {
|
|
hunkDecs[i] = hunkDecisions[`${filePath}:${i}`] ?? 'pending';
|
|
}
|
|
|
|
const hasRejected =
|
|
fileDecision === 'rejected' || Object.values(hunkDecs).some((d) => d === 'rejected');
|
|
if (!hasRejected) return null;
|
|
|
|
try {
|
|
const content = fileContents[filePath];
|
|
const innerBaseCount = getFileHunkCount(filePath, file.snippets.length, fileChunkCounts);
|
|
const innerMaxIdx = getMaxDecisionIndexForFile(filePath, hunkDecisions);
|
|
const hunkContextHashes =
|
|
innerMaxIdx < innerBaseCount
|
|
? buildHunkContextHashesForFile(
|
|
content?.originalFullContent,
|
|
content?.modifiedFullContent,
|
|
innerBaseCount
|
|
)
|
|
: undefined;
|
|
const result = await api.review.applyDecisions({
|
|
teamName,
|
|
taskId,
|
|
memberName,
|
|
decisions: [
|
|
{
|
|
filePath,
|
|
fileDecision,
|
|
hunkDecisions: hunkDecs,
|
|
hunkContextHashes,
|
|
snippets: content?.snippets ?? file.snippets,
|
|
originalFullContent: content?.originalFullContent,
|
|
modifiedFullContent: content?.modifiedFullContent,
|
|
isNewFile: content?.isNewFile ?? file.isNewFile,
|
|
},
|
|
],
|
|
});
|
|
return result;
|
|
} catch (error) {
|
|
logger.error('applySingleFileDecision error:', error);
|
|
set({ applyError: mapReviewError(error) });
|
|
return null;
|
|
}
|
|
},
|
|
|
|
removeReviewFile: (filePath: string) => {
|
|
set((s) => {
|
|
if (!s.activeChangeSet) return s;
|
|
const existing = s.activeChangeSet.files.find((f) => f.filePath === filePath);
|
|
if (!existing) return s;
|
|
|
|
const nextFiles = s.activeChangeSet.files.filter((f) => f.filePath !== filePath);
|
|
const totalLinesAdded = nextFiles.reduce((sum, f) => sum + f.linesAdded, 0);
|
|
const totalLinesRemoved = nextFiles.reduce((sum, f) => sum + f.linesRemoved, 0);
|
|
|
|
const nextHunkDecisions = { ...s.hunkDecisions };
|
|
const prefix = `${filePath}:`;
|
|
for (const key of Object.keys(nextHunkDecisions)) {
|
|
if (key.startsWith(prefix)) delete nextHunkDecisions[key];
|
|
}
|
|
|
|
const nextFileDecisions = { ...s.fileDecisions };
|
|
delete nextFileDecisions[filePath];
|
|
|
|
const nextFileChunkCounts = { ...s.fileChunkCounts };
|
|
delete nextFileChunkCounts[filePath];
|
|
|
|
const nextFileContents = { ...s.fileContents };
|
|
delete nextFileContents[filePath];
|
|
|
|
const nextFileContentsLoading = { ...s.fileContentsLoading };
|
|
delete nextFileContentsLoading[filePath];
|
|
|
|
const nextEditedContents = { ...s.editedContents };
|
|
delete nextEditedContents[filePath];
|
|
|
|
const nextHashes = { ...s.hunkContextHashesByFile };
|
|
delete nextHashes[filePath];
|
|
|
|
const nextSelected =
|
|
s.selectedReviewFilePath === filePath
|
|
? (nextFiles[0]?.filePath ?? null)
|
|
: s.selectedReviewFilePath;
|
|
|
|
return {
|
|
activeChangeSet: {
|
|
...s.activeChangeSet,
|
|
files: nextFiles,
|
|
totalFiles: nextFiles.length,
|
|
totalLinesAdded,
|
|
totalLinesRemoved,
|
|
},
|
|
selectedReviewFilePath: nextSelected,
|
|
hunkDecisions: nextHunkDecisions,
|
|
fileDecisions: nextFileDecisions,
|
|
fileChunkCounts: nextFileChunkCounts,
|
|
fileContents: nextFileContents,
|
|
fileContentsLoading: nextFileContentsLoading,
|
|
editedContents: nextEditedContents,
|
|
hunkContextHashesByFile: nextHashes,
|
|
};
|
|
});
|
|
},
|
|
|
|
addReviewFile: (
|
|
file: FileChangeSummary,
|
|
options?: { index?: number; content?: FileChangeWithContent }
|
|
) => {
|
|
set((s) => {
|
|
if (!s.activeChangeSet) return s;
|
|
if (s.activeChangeSet.files.some((f) => f.filePath === file.filePath)) return s;
|
|
|
|
const idxRaw = options?.index;
|
|
const idx =
|
|
typeof idxRaw === 'number' && Number.isFinite(idxRaw)
|
|
? Math.max(0, Math.min(idxRaw, s.activeChangeSet.files.length))
|
|
: s.activeChangeSet.files.length;
|
|
|
|
const nextFiles = [...s.activeChangeSet.files];
|
|
nextFiles.splice(idx, 0, file);
|
|
const totalLinesAdded = nextFiles.reduce((sum, f) => sum + f.linesAdded, 0);
|
|
const totalLinesRemoved = nextFiles.reduce((sum, f) => sum + f.linesRemoved, 0);
|
|
|
|
const nextFileContents = options?.content
|
|
? { ...s.fileContents, [file.filePath]: options.content }
|
|
: s.fileContents;
|
|
|
|
const nextFileContentsLoading = options?.content
|
|
? { ...s.fileContentsLoading, [file.filePath]: false }
|
|
: s.fileContentsLoading;
|
|
|
|
return {
|
|
activeChangeSet: {
|
|
...s.activeChangeSet,
|
|
files: nextFiles,
|
|
totalFiles: nextFiles.length,
|
|
totalLinesAdded,
|
|
totalLinesRemoved,
|
|
},
|
|
selectedReviewFilePath: s.selectedReviewFilePath ?? file.filePath,
|
|
fileContents: nextFileContents,
|
|
fileContentsLoading: nextFileContentsLoading,
|
|
};
|
|
});
|
|
},
|
|
|
|
clearReviewStateForFile: (filePath: string) => {
|
|
set((s) => {
|
|
const nextHunkDecisions = { ...s.hunkDecisions };
|
|
const prefix = `${filePath}:`;
|
|
for (const key of Object.keys(nextHunkDecisions)) {
|
|
if (key.startsWith(prefix) && nextHunkDecisions[key] === 'rejected') {
|
|
delete nextHunkDecisions[key];
|
|
}
|
|
}
|
|
|
|
const nextFileDecisions = { ...s.fileDecisions };
|
|
if (nextFileDecisions[filePath] === 'rejected') {
|
|
delete nextFileDecisions[filePath];
|
|
}
|
|
|
|
const nextFileChunkCounts = { ...s.fileChunkCounts };
|
|
delete nextFileChunkCounts[filePath];
|
|
|
|
const nextFileContents = { ...s.fileContents };
|
|
delete nextFileContents[filePath];
|
|
|
|
const nextFileContentsLoading = { ...s.fileContentsLoading };
|
|
delete nextFileContentsLoading[filePath];
|
|
|
|
const nextEditedContents = { ...s.editedContents };
|
|
delete nextEditedContents[filePath];
|
|
|
|
return {
|
|
hunkDecisions: nextHunkDecisions,
|
|
fileDecisions: nextFileDecisions,
|
|
fileChunkCounts: nextFileChunkCounts,
|
|
fileContents: nextFileContents,
|
|
fileContentsLoading: nextFileContentsLoading,
|
|
editedContents: nextEditedContents,
|
|
};
|
|
});
|
|
},
|
|
|
|
// ── 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, projectPath?: string) => {
|
|
const content = get().editedContents[filePath];
|
|
if (!(filePath in get().editedContents)) return;
|
|
set({ applying: true, applyError: null });
|
|
try {
|
|
await api.review.saveEditedFile(filePath, content, projectPath);
|
|
set((s) => {
|
|
const nextEdited = { ...s.editedContents };
|
|
delete nextEdited[filePath];
|
|
// Update cached content in-place to avoid skeleton flash.
|
|
// Replace modifiedFullContent with saved version so CodeMirror
|
|
// reflects the new baseline without a full re-fetch cycle.
|
|
const nextContents = { ...s.fileContents };
|
|
const existing = nextContents[filePath];
|
|
if (existing) {
|
|
nextContents[filePath] = {
|
|
...existing,
|
|
modifiedFullContent: content,
|
|
contentSource: 'disk-current',
|
|
};
|
|
}
|
|
return { editedContents: nextEdited, fileContents: nextContents, 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 };
|
|
});
|
|
},
|
|
});
|