agent-ecosystem/src/renderer/store/slices/changeReviewSlice.ts
iliya dfc2a43a91 fix: stabilize diff review apply state and missing-file UX
- 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
2026-03-04 14:36:05 +02:00

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 };
});
},
});