feat: refresh review diffs after external file changes

Watch reviewed files for external edits so stale full diffs reload safely, while local drafts get explicit conflict actions instead of silent overwrites.

Made-with: Cursor
This commit is contained in:
iliya 2026-03-15 12:47:52 +02:00
parent 217eafe6a2
commit a175566b83
11 changed files with 439 additions and 33 deletions

View file

@ -50,6 +50,7 @@ import { existsSync } from 'fs';
import { join } from 'path';
import { cleanupEditorState, setEditorMainWindow } from './ipc/editor';
import { setReviewMainWindow } from './ipc/review';
import { initializeIpcHandlers, removeIpcHandlers } from './ipc/handlers';
import { startEventLoopLagMonitor } from './services/infrastructure/EventLoopLagMonitor';
import { HttpServer } from './services/infrastructure/HttpServer';
@ -1219,6 +1220,7 @@ function createWindow(): void {
ptyTerminalService.setMainWindow(null);
}
setEditorMainWindow(null);
setReviewMainWindow(null);
cleanupEditorState();
});
@ -1242,6 +1244,7 @@ function createWindow(): void {
ptyTerminalService.setMainWindow(mainWindow);
}
setEditorMainWindow(mainWindow);
setReviewMainWindow(mainWindow);
logger.info('Main window created');
}

View file

@ -5,12 +5,14 @@
*/
import { createIpcWrapper } from '@main/ipc/ipcWrapper';
import { EditorFileWatcher } from '@main/services/editor';
import { ReviewDecisionStore } from '@main/services/team/ReviewDecisionStore';
import { validateFilePath } from '@main/utils/pathValidation';
import {
REVIEW_APPLY_DECISIONS,
REVIEW_CHECK_CONFLICT,
REVIEW_CLEAR_DECISIONS,
REVIEW_FILE_CHANGE,
REVIEW_GET_AGENT_CHANGES,
REVIEW_GET_CHANGE_STATS,
REVIEW_GET_FILE_CONTENT,
@ -23,9 +25,13 @@ import {
REVIEW_REJECT_HUNKS,
REVIEW_SAVE_DECISIONS,
REVIEW_SAVE_EDITED_FILE,
REVIEW_UNWATCH_FILES,
REVIEW_WATCH_FILES,
// eslint-disable-next-line boundaries/element-types -- IPC channel constants are shared between main and preload by design
} from '@preload/constants/ipcChannels';
import { createLogger } from '@shared/utils/logger';
import * as fs from 'fs/promises';
import * as path from 'path';
import type { ChangeExtractorService } from '@main/services/team/ChangeExtractorService';
import type { FileContentResolver } from '@main/services/team/FileContentResolver';
@ -44,7 +50,7 @@ import type {
SnippetDiff,
TaskChangeSetV2,
} from '@shared/types/review';
import type { IpcMain, IpcMainInvokeEvent } from 'electron';
import type { BrowserWindow, IpcMain, IpcMainInvokeEvent } from 'electron';
const wrapReviewHandler = createIpcWrapper('IPC:review');
const logger = createLogger('IPC:review');
@ -56,6 +62,9 @@ let reviewApplier: ReviewApplierService | null = null;
let fileContentResolver: FileContentResolver | null = null;
let gitDiffFallback: GitDiffFallback | null = null;
const reviewDecisionStore = new ReviewDecisionStore();
const reviewFileWatcher = new EditorFileWatcher();
let reviewWatcherProjectRoot: string | null = null;
let reviewMainWindowRef: BrowserWindow | null = null;
function getChangeExtractor(): ChangeExtractorService {
if (!changeExtractor) throw new Error('Review handlers not initialized');
@ -103,6 +112,8 @@ export function registerReviewHandlers(ipcMain: IpcMain): void {
ipcMain.handle(REVIEW_GET_FILE_CONTENT, handleGetFileContent);
// Editable diff
ipcMain.handle(REVIEW_SAVE_EDITED_FILE, handleSaveEditedFile);
ipcMain.handle(REVIEW_WATCH_FILES, handleWatchReviewFiles);
ipcMain.handle(REVIEW_UNWATCH_FILES, handleUnwatchReviewFiles);
// Phase 4
ipcMain.handle(REVIEW_GET_GIT_FILE_LOG, handleGetGitFileLog);
// Decision persistence
@ -126,12 +137,20 @@ export function removeReviewHandlers(ipcMain: IpcMain): void {
ipcMain.removeHandler(REVIEW_GET_FILE_CONTENT);
// Editable diff
ipcMain.removeHandler(REVIEW_SAVE_EDITED_FILE);
ipcMain.removeHandler(REVIEW_WATCH_FILES);
ipcMain.removeHandler(REVIEW_UNWATCH_FILES);
// Phase 4
ipcMain.removeHandler(REVIEW_GET_GIT_FILE_LOG);
// Decision persistence
ipcMain.removeHandler(REVIEW_LOAD_DECISIONS);
ipcMain.removeHandler(REVIEW_SAVE_DECISIONS);
ipcMain.removeHandler(REVIEW_CLEAR_DECISIONS);
reviewFileWatcher.stop();
reviewWatcherProjectRoot = null;
}
export function setReviewMainWindow(win: BrowserWindow | null): void {
reviewMainWindowRef = win;
}
// --- Phase 1 Handlers ---
@ -368,8 +387,56 @@ async function handleSaveEditedFile(
});
}
async function handleWatchReviewFiles(
_event: IpcMainInvokeEvent,
projectPath: string,
filePaths: string[]
): Promise<IpcResult<void>> {
return wrapReviewHandler('watchFiles', async () => {
const normalizedProjectPath = await validateReviewProjectPath(projectPath);
const shouldRestart =
reviewWatcherProjectRoot !== normalizedProjectPath || !reviewFileWatcher.isWatching();
if (shouldRestart) {
reviewFileWatcher.stop();
reviewWatcherProjectRoot = normalizedProjectPath;
reviewFileWatcher.start(normalizedProjectPath, (event) => {
if (reviewMainWindowRef && !reviewMainWindowRef.isDestroyed()) {
reviewMainWindowRef.webContents.send(REVIEW_FILE_CHANGE, event);
}
});
}
reviewFileWatcher.setWatchedFiles(Array.isArray(filePaths) ? filePaths : []);
});
}
async function handleUnwatchReviewFiles(): Promise<IpcResult<void>> {
return wrapReviewHandler('unwatchFiles', async () => {
reviewFileWatcher.stop();
reviewWatcherProjectRoot = null;
});
}
// --- Phase 4 Handlers ---
async function validateReviewProjectPath(projectPath: string): Promise<string> {
if (!projectPath || typeof projectPath !== 'string') {
throw new Error('Invalid project path');
}
if (!path.isAbsolute(projectPath)) {
throw new Error('Project path must be absolute');
}
const normalized = path.resolve(path.normalize(projectPath));
const stat = await fs.stat(normalized);
if (!stat.isDirectory()) {
throw new Error('Project path is not a directory');
}
return normalized;
}
async function handleGetGitFileLog(
_event: IpcMainInvokeEvent,
projectPath: string,

View file

@ -457,6 +457,15 @@ export const REVIEW_APPLY_DECISIONS = 'review:applyDecisions';
/** Получить полное содержимое файла для diff view */
export const REVIEW_GET_FILE_CONTENT = 'review:getFileContent';
/** Start/update focused file watcher for review surface */
export const REVIEW_WATCH_FILES = 'review:watchFiles';
/** Stop focused file watcher for review surface */
export const REVIEW_UNWATCH_FILES = 'review:unwatchFiles';
/** File change event for review watcher (main -> renderer) */
export const REVIEW_FILE_CHANGE = 'review:fileChange';
// Phase 4 — Git fallback
/** Save edited file content to disk */

View file

@ -55,6 +55,7 @@ import {
REVIEW_GET_AGENT_CHANGES,
REVIEW_GET_CHANGE_STATS,
REVIEW_GET_FILE_CONTENT,
REVIEW_FILE_CHANGE,
REVIEW_GET_GIT_FILE_LOG,
REVIEW_GET_TASK_CHANGES,
REVIEW_INVALIDATE_TASK_CHANGE_SUMMARIES,
@ -64,6 +65,8 @@ import {
REVIEW_REJECT_HUNKS,
REVIEW_SAVE_DECISIONS,
REVIEW_SAVE_EDITED_FILE,
REVIEW_UNWATCH_FILES,
REVIEW_WATCH_FILES,
SSH_CONNECT,
SSH_DISCONNECT,
SSH_GET_CONFIG_HOSTS,
@ -1206,6 +1209,20 @@ const electronAPI: ElectronAPI = {
projectPath
);
},
watchFiles: async (projectPath: string, filePaths: string[]) => {
return invokeIpcWithResult<void>(REVIEW_WATCH_FILES, projectPath, filePaths);
},
unwatchFiles: async () => {
return invokeIpcWithResult<void>(REVIEW_UNWATCH_FILES);
},
onExternalFileChange: (callback: (event: EditorFileChangeEvent) => void): (() => void) => {
const handler = (_event: Electron.IpcRendererEvent, data: EditorFileChangeEvent): void =>
callback(data);
ipcRenderer.on(REVIEW_FILE_CHANGE, handler);
return (): void => {
ipcRenderer.removeListener(REVIEW_FILE_CHANGE, handler);
};
},
// Decision persistence
loadDecisions: async (teamName: string, scopeKey: string) => {
return invokeIpcWithResult<{

View file

@ -983,6 +983,15 @@ export class HttpAPIClient implements ElectronAPI {
saveEditedFile: async (): Promise<never> => {
throw new Error('Review is not available in browser mode');
},
watchFiles: async (): Promise<never> => {
throw new Error('Review file watching is not available in browser mode');
},
unwatchFiles: async (): Promise<never> => {
throw new Error('Review file watching is not available in browser mode');
},
onExternalFileChange: (): (() => void) => {
return () => {};
},
// Decision persistence stubs
loadDecisions: async (): Promise<never> => {
throw new Error('Review is not available in browser mode');

View file

@ -2,7 +2,7 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { undo } from '@codemirror/commands';
import { rejectChunk } from '@codemirror/merge';
import { isElectronMode } from '@renderer/api';
import { api, isElectronMode } from '@renderer/api';
import { EditorSelectionMenu } from '@renderer/components/team/editor/EditorSelectionMenu';
import { useContinuousScrollNav } from '@renderer/hooks/useContinuousScrollNav';
import { useDiffNavigation } from '@renderer/hooks/useDiffNavigation';
@ -15,6 +15,7 @@ import { buildSelectionInfo, SELECTION_DEBOUNCE_MS } from '@renderer/utils/codem
import { sortItemsAsTree } from '@renderer/utils/fileTreeBuilder';
import { displayMemberName } from '@renderer/utils/memberHelpers';
import { type TaskChangeRequestOptions } from '@renderer/utils/taskChangeRequest';
import { normalizePathForComparison } from '@shared/utils/platformPath';
import { ChevronDown, Clock, X } from 'lucide-react';
import { ChangesLoadingAnimation } from './ChangesLoadingAnimation';
@ -42,6 +43,8 @@ interface RecentHunkUndoAction {
at: number;
}
const REVIEW_LOCAL_WRITE_COOLDOWN_MS = 2000;
interface ChangeReviewDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
@ -100,6 +103,9 @@ export const ChangeReviewDialog = ({
updateEditedContent,
discardFileEdits,
saveEditedFile,
reviewExternalChangesByFile,
clearReviewFileExternalChange,
reloadReviewFileFromDisk,
loadDecisionsFromDisk,
persistDecisions,
clearDecisionsFromDisk,
@ -165,11 +171,16 @@ export const ChangeReviewDialog = ({
{ file: FileChangeSummary; index: number; restoreContent: string; removedAt: number }[]
>([]);
const lastNewFileRemoveAtRef = useRef<number>(0);
const recentReviewWritesRef = useRef(new Map<string, number>());
// Proxy ref for useDiffNavigation (points to active file's editor)
const activeEditorViewRef = useRef<EditorView | null>(null);
const activeFilePathRef = useRef<string | null>(null);
const markRecentReviewWrite = useCallback((filePath: string): void => {
recentReviewWritesRef.current.set(normalizePathForComparison(filePath), Date.now());
}, []);
const getEditorFilePathForTarget = useCallback((target: Element | null): string | null => {
if (!target) return null;
for (const [filePath, view] of editorViewMapRef.current.entries()) {
@ -324,6 +335,49 @@ export const ChangeReviewDialog = ({
setActiveFilePath(filePath);
}, []);
useEffect(() => {
if (!open || !projectPath || !isElectronMode()) return;
const unsubscribe = api.review.onExternalFileChange((event) => {
const normalizedPath = normalizePathForComparison(event.path);
const recentWriteAt = recentReviewWritesRef.current.get(normalizedPath);
if (recentWriteAt && Date.now() - recentWriteAt < REVIEW_LOCAL_WRITE_COOLDOWN_MS) {
return;
}
const state = useStore.getState();
const active = state.activeChangeSet;
if (!active) return;
const file = active.files.find(
(entry) => normalizePathForComparison(entry.filePath) === normalizedPath
);
if (!file) return;
const changeType =
event.type === 'create' ? 'add' : event.type === 'delete' ? 'unlink' : 'change';
if (file.filePath in state.editedContents) {
state.markReviewFileExternallyChanged(file.filePath, changeType);
return;
}
state.clearReviewFileExternalChange(file.filePath);
state.invalidateResolvedFileContent(file.filePath);
void state.fetchFileContent(teamName, memberName, file.filePath);
});
void api.review.watchFiles(
projectPath,
sortedFiles.map((file) => file.filePath)
);
return () => {
unsubscribe();
void api.review.unwatchFiles();
};
}, [open, projectPath, sortedFiles, teamName, memberName]);
// Tree click → scroll to file
const handleTreeFileClick = useCallback(
(filePath: string) => {
@ -446,6 +500,7 @@ export const ChangeReviewDialog = ({
} else {
const hasErrorForFile = !!result?.errors.some((e) => e.filePath === filePath);
if (result && !hasErrorForFile) {
markRecentReviewWrite(filePath);
// Disk state is now authoritative. Clear stale decisions/cache so reopening
// doesn't try to re-apply and the diff can re-resolve from disk.
clearReviewStateForFile(filePath);
@ -465,6 +520,7 @@ export const ChangeReviewDialog = ({
teamName,
taskId,
memberName,
markRecentReviewWrite,
removeReviewFile,
fileContents,
clearReviewStateForFile,
@ -507,6 +563,7 @@ export const ChangeReviewDialog = ({
void applySingleFileDecision(teamName, filePath, taskId, memberName).then((result) => {
const hasErrorForFile = !!result?.errors.some((e) => e.filePath === filePath);
if (result && !hasErrorForFile) {
markRecentReviewWrite(filePath);
clearReviewStateForFile(filePath);
setDiscardCounters((prev) => ({ ...prev, [filePath]: (prev[filePath] ?? 0) + 1 }));
void fetchFileContent(teamName, memberName, filePath);
@ -520,6 +577,7 @@ export const ChangeReviewDialog = ({
teamName,
taskId,
memberName,
markRecentReviewWrite,
clearReviewStateForFile,
fetchFileContent,
]
@ -542,19 +600,43 @@ export const ChangeReviewDialog = ({
);
const handleSaveFile = useCallback(
(filePath: string) => {
void saveEditedFile(filePath, projectPath);
async (filePath: string) => {
await saveEditedFile(filePath, projectPath);
if (!useStore.getState().applyError) {
markRecentReviewWrite(filePath);
}
},
[saveEditedFile, projectPath]
[saveEditedFile, projectPath, markRecentReviewWrite]
);
const handleRestoreMissingFile = useCallback(
(filePath: string, content: string) => {
updateEditedContent(filePath, content);
// Ensure editedContents is set before saveEditedFile reads it.
void Promise.resolve().then(() => saveEditedFile(filePath, projectPath));
void Promise.resolve().then(async () => {
await saveEditedFile(filePath, projectPath);
if (!useStore.getState().applyError) {
markRecentReviewWrite(filePath);
}
});
},
[updateEditedContent, saveEditedFile, projectPath]
[updateEditedContent, saveEditedFile, projectPath, markRecentReviewWrite]
);
const handleReloadFromDisk = useCallback(
(filePath: string) => {
reloadReviewFileFromDisk(filePath);
setDiscardCounters((prev) => ({ ...prev, [filePath]: (prev[filePath] ?? 0) + 1 }));
void fetchFileContent(teamName, memberName, filePath);
},
[reloadReviewFileFromDisk, fetchFileContent, teamName, memberName]
);
const handleKeepDraft = useCallback(
(filePath: string) => {
clearReviewFileExternalChange(filePath);
},
[clearReviewFileExternalChange]
);
const handleDiscardFile = useCallback(
@ -644,8 +726,14 @@ export const ChangeReviewDialog = ({
// Save active file (for Cmd+S keyboard shortcut)
const handleSaveActiveFile = useCallback(() => {
if (activeFilePath) void saveEditedFile(activeFilePath, projectPath);
}, [activeFilePath, saveEditedFile, projectPath]);
if (!activeFilePath) return;
void (async () => {
await saveEditedFile(activeFilePath, projectPath);
if (!useStore.getState().applyError) {
markRecentReviewWrite(activeFilePath);
}
})();
}, [activeFilePath, saveEditedFile, projectPath, markRecentReviewWrite]);
// Continuous navigation options for cross-file hunk navigation
const continuousOptions = useMemo(
@ -910,7 +998,12 @@ export const ChangeReviewDialog = ({
scheduleScrollToFile(snap.file.filePath);
updateEditedContent(snap.file.filePath, snap.restoreContent);
// Ensure editedContents is set before saveEditedFile reads it.
void Promise.resolve().then(() => saveEditedFile(snap.file.filePath, projectPath));
void Promise.resolve().then(async () => {
await saveEditedFile(snap.file.filePath, projectPath);
if (!useStore.getState().applyError) {
markRecentReviewWrite(snap.file.filePath);
}
});
return;
}
@ -1240,6 +1333,7 @@ export const ChangeReviewDialog = ({
fileContents={fileContents}
fileContentsLoading={fileContentsLoading}
globalDiffLoadingState={globalDiffLoadingState}
reviewExternalChangesByFile={reviewExternalChangesByFile}
viewedSet={viewedSet}
editedContents={editedContents}
hunkDecisions={hunkDecisions}
@ -1255,6 +1349,8 @@ export const ChangeReviewDialog = ({
onContentChanged={handleContentChanged}
onDiscard={handleDiscardFile}
onSave={handleSaveFile}
onReloadFromDisk={handleReloadFromDisk}
onKeepDraft={handleKeepDraft}
onAcceptFile={handleAcceptFile}
onRejectFile={handleRejectFile}
onRestoreMissingFile={handleRestoreMissingFile}

View file

@ -28,6 +28,7 @@ interface ContinuousScrollViewProps {
snippetCount: number;
activeFileName?: string;
} | null;
reviewExternalChangesByFile: Record<string, { type: 'change' | 'add' | 'unlink' }>;
viewedSet: Set<string>;
editedContents: Record<string, string>;
hunkDecisions: Record<string, HunkDecision>;
@ -43,6 +44,8 @@ interface ContinuousScrollViewProps {
onContentChanged: (filePath: string, content: string) => void;
onDiscard: (filePath: string) => void;
onSave: (filePath: string) => void;
onReloadFromDisk: (filePath: string) => void;
onKeepDraft: (filePath: string) => void;
onAcceptFile: (filePath: string) => void;
onRejectFile: (filePath: string) => void;
onRestoreMissingFile?: (filePath: string, content: string) => void;
@ -74,6 +77,7 @@ export const ContinuousScrollView = ({
fileContents,
fileContentsLoading,
globalDiffLoadingState,
reviewExternalChangesByFile,
viewedSet,
editedContents,
hunkDecisions,
@ -89,6 +93,8 @@ export const ContinuousScrollView = ({
onContentChanged,
onDiscard,
onSave,
onReloadFromDisk,
onKeepDraft,
onAcceptFile,
onRejectFile,
onRestoreMissingFile,
@ -257,6 +263,7 @@ export const ContinuousScrollView = ({
file={file}
fileContent={content}
fileDecision={decision}
externalChange={reviewExternalChangesByFile[filePath]}
pathChangeLabel={pathChangeLabels?.[filePath]}
hasEdits={hasEdits}
applying={applying}
@ -264,6 +271,8 @@ export const ContinuousScrollView = ({
onToggleCollapse={handleToggleCollapse}
onDiscard={onDiscard}
onSave={onSave}
onReloadFromDisk={onReloadFromDisk}
onKeepDraft={onKeepDraft}
onAcceptFile={onAcceptFile}
onRejectFile={onRejectFile}
onRestoreMissingFile={onRestoreMissingFile}

View file

@ -1,5 +1,6 @@
import React from 'react';
import { FileIcon } from '@renderer/components/team/editor/FileIcon';
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
import { shortcutLabel } from '@renderer/utils/platformKeys';
import { ChevronDown, ChevronRight, FilePlus, Loader2, Save, Undo2 } from 'lucide-react';
@ -19,6 +20,7 @@ interface FileSectionHeaderProps {
file: FileChangeSummary;
fileContent: FileChangeWithContent | null;
fileDecision: HunkDecision | undefined;
externalChange?: { type: 'change' | 'add' | 'unlink' };
pathChangeLabel?:
| { kind: 'deleted' }
| { kind: 'moved' | 'renamed'; direction: 'from' | 'to'; otherPath: string };
@ -28,6 +30,8 @@ interface FileSectionHeaderProps {
onToggleCollapse: (filePath: string) => void;
onDiscard: (filePath: string) => void;
onSave: (filePath: string) => void;
onReloadFromDisk?: (filePath: string) => void;
onKeepDraft?: (filePath: string) => void;
onRestoreMissingFile?: (filePath: string, content: string) => void;
onAcceptFile?: (filePath: string) => void;
onRejectFile?: (filePath: string) => void;
@ -37,6 +41,7 @@ export const FileSectionHeader = ({
file,
fileContent,
fileDecision,
externalChange,
pathChangeLabel,
hasEdits,
applying,
@ -44,6 +49,8 @@ export const FileSectionHeader = ({
onToggleCollapse,
onDiscard,
onSave,
onReloadFromDisk,
onKeepDraft,
onRestoreMissingFile,
onAcceptFile,
onRejectFile,
@ -60,6 +67,14 @@ export const FileSectionHeader = ({
return writeSnippets[writeSnippets.length - 1].newString;
})();
const canRestore = !!onRestoreMissingFile && isPreviewOnly && !hasEdits && restoreContent != null;
const externalChangeLabel =
externalChange?.type === 'unlink'
? 'Deleted on disk'
: externalChange?.type === 'add'
? 'Recreated on disk'
: externalChange?.type === 'change'
? 'Changed on disk'
: null;
const handleHeaderClick = (e: React.MouseEvent): void => {
// Don't collapse when clicking action buttons
@ -85,6 +100,7 @@ export const FileSectionHeader = ({
<span className="flex shrink-0 items-center text-text-muted">
{isCollapsed ? <ChevronRight className="size-3.5" /> : <ChevronDown className="size-3.5" />}
</span>
<FileIcon fileName={file.relativePath} className="size-3.5" />
<span className="text-xs font-medium text-text">{file.relativePath}</span>
{file.isNewFile && (
@ -167,7 +183,32 @@ export const FileSectionHeader = ({
</span>
)}
{externalChangeLabel && (
<span className="rounded bg-amber-500/15 px-1.5 py-0.5 text-[10px] text-amber-300">
{externalChangeLabel}
</span>
)}
<div className="ml-auto flex items-center gap-1.5" data-no-collapse>
{externalChange && onReloadFromDisk && onKeepDraft && (
<div className="mr-1 flex items-center gap-1.5">
<button
onClick={() => onReloadFromDisk(file.filePath)}
disabled={applying}
className="rounded bg-blue-500/15 px-2 py-1 text-xs font-medium text-blue-300 transition-colors hover:bg-blue-500/25 disabled:opacity-50"
>
Reload from disk
</button>
<button
onClick={() => onKeepDraft(file.filePath)}
disabled={applying}
className="rounded bg-amber-500/15 px-2 py-1 text-xs font-medium text-amber-300 transition-colors hover:bg-amber-500/25 disabled:opacity-50"
>
Keep my draft
</button>
</div>
)}
{(onAcceptFile || onRejectFile) && (
<div className="mr-1 flex items-center gap-1.5">
{onAcceptFile && (

View file

@ -47,6 +47,10 @@ interface DecisionSnapshot {
fileDecisions: Record<string, HunkDecision>;
}
export interface ReviewExternalChange {
type: 'change' | 'add' | 'unlink';
}
type ReviewChangeSet = AgentChangeSet | TaskChangeSet | TaskChangeSetV2;
const MAX_REVIEW_UNDO_DEPTH = 10;
@ -95,6 +99,7 @@ export interface ChangeReviewSlice {
fileContentsLoading: Record<string, boolean>;
changeSetEpoch: number;
fileContentVersionByPath: Record<string, number>;
reviewExternalChangesByFile: Record<string, ReviewExternalChange>;
collapseUnchanged: boolean;
applyError: string | null;
applying: boolean;
@ -170,6 +175,10 @@ export interface ChangeReviewSlice {
* Prevents stale decisions from being re-applied later and forces fresh content resolve.
*/
clearReviewStateForFile: (filePath: string) => void;
invalidateResolvedFileContent: (filePath: string) => void;
markReviewFileExternallyChanged: (filePath: string, type: ReviewExternalChange['type']) => void;
clearReviewFileExternalChange: (filePath: string) => void;
reloadReviewFileFromDisk: (filePath: string) => void;
invalidateChangeStats: (teamName: string) => void;
// Editable diff actions
@ -312,6 +321,41 @@ export const createChangeReviewSlice: StateCreator<AppState, [], [], ChangeRevie
set,
get
) => {
const buildResolvedFileInvalidation = (
s: ChangeReviewSlice,
filePath: string
): Pick<
ChangeReviewSlice,
| 'fileChunkCounts'
| 'fileContents'
| 'fileContentsLoading'
| 'hunkContextHashesByFile'
| 'fileContentVersionByPath'
> => {
const nextFileChunkCounts = { ...s.fileChunkCounts };
delete nextFileChunkCounts[filePath];
const nextFileContents = { ...s.fileContents };
delete nextFileContents[filePath];
const nextFileContentsLoading = { ...s.fileContentsLoading };
delete nextFileContentsLoading[filePath];
const nextHunkContextHashesByFile = { ...s.hunkContextHashesByFile };
delete nextHunkContextHashesByFile[filePath];
return {
fileChunkCounts: nextFileChunkCounts,
fileContents: nextFileContents,
fileContentsLoading: nextFileContentsLoading,
hunkContextHashesByFile: nextHunkContextHashesByFile,
fileContentVersionByPath: {
...s.fileContentVersionByPath,
[filePath]: (s.fileContentVersionByPath[filePath] ?? 0) + 1,
},
};
};
const installActiveChangeSetForLoad = (
data: ReviewChangeSet,
extraState?: Partial<ChangeReviewSlice>
@ -327,6 +371,7 @@ export const createChangeReviewSlice: StateCreator<AppState, [], [], ChangeRevie
applyError: null,
changeSetEpoch: s.changeSetEpoch + 1,
fileContentVersionByPath: {},
reviewExternalChangesByFile: {},
...extraState,
}));
};
@ -350,6 +395,7 @@ export const createChangeReviewSlice: StateCreator<AppState, [], [], ChangeRevie
editedContents: {},
changeSetEpoch: s.changeSetEpoch + 1,
fileContentVersionByPath: {},
reviewExternalChangesByFile: {},
}));
};
@ -407,6 +453,7 @@ export const createChangeReviewSlice: StateCreator<AppState, [], [], ChangeRevie
fileContentsLoading: {},
changeSetEpoch: 0,
fileContentVersionByPath: {},
reviewExternalChangesByFile: {},
collapseUnchanged: true,
applyError: null,
applying: false,
@ -494,6 +541,7 @@ export const createChangeReviewSlice: StateCreator<AppState, [], [], ChangeRevie
fileContentsLoading: {},
changeSetEpoch: s.changeSetEpoch + 1,
fileContentVersionByPath: {},
reviewExternalChangesByFile: {},
applyError: null,
applying: false,
editedContents: {},
@ -515,6 +563,7 @@ export const createChangeReviewSlice: StateCreator<AppState, [], [], ChangeRevie
fileContentsLoading: {},
changeSetEpoch: s.changeSetEpoch + 1,
fileContentVersionByPath: {},
reviewExternalChangesByFile: {},
applyError: null,
applying: false,
editedContents: {},
@ -538,6 +587,7 @@ export const createChangeReviewSlice: StateCreator<AppState, [], [], ChangeRevie
fileContentsLoading: {},
changeSetEpoch: s.changeSetEpoch + 1,
fileContentVersionByPath: {},
reviewExternalChangesByFile: {},
applyError: null,
applying: false,
editedContents: {},
@ -1031,6 +1081,9 @@ export const createChangeReviewSlice: StateCreator<AppState, [], [], ChangeRevie
const nextHashes = { ...s.hunkContextHashesByFile };
delete nextHashes[filePath];
const nextReviewExternalChangesByFile = { ...s.reviewExternalChangesByFile };
delete nextReviewExternalChangesByFile[filePath];
const nextFileContentVersionByPath = {
...s.fileContentVersionByPath,
[filePath]: (s.fileContentVersionByPath[filePath] ?? 0) + 1,
@ -1058,6 +1111,7 @@ export const createChangeReviewSlice: StateCreator<AppState, [], [], ChangeRevie
editedContents: nextEditedContents,
hunkContextHashesByFile: nextHashes,
fileContentVersionByPath: nextFileContentVersionByPath,
reviewExternalChangesByFile: nextReviewExternalChangesByFile,
};
});
},
@ -1094,6 +1148,9 @@ export const createChangeReviewSlice: StateCreator<AppState, [], [], ChangeRevie
[file.filePath]: s.fileContentVersionByPath[file.filePath] ?? 0,
};
const nextReviewExternalChangesByFile = { ...s.reviewExternalChangesByFile };
delete nextReviewExternalChangesByFile[file.filePath];
return {
activeChangeSet: {
...s.activeChangeSet,
@ -1106,6 +1163,7 @@ export const createChangeReviewSlice: StateCreator<AppState, [], [], ChangeRevie
fileContents: nextFileContents,
fileContentsLoading: nextFileContentsLoading,
fileContentVersionByPath: nextFileContentVersionByPath,
reviewExternalChangesByFile: nextReviewExternalChangesByFile,
};
});
},
@ -1125,35 +1183,53 @@ export const createChangeReviewSlice: StateCreator<AppState, [], [], ChangeRevie
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 nextHunkContextHashesByFile = { ...s.hunkContextHashesByFile };
delete nextHunkContextHashesByFile[filePath];
const nextFileContentVersionByPath = {
...s.fileContentVersionByPath,
[filePath]: (s.fileContentVersionByPath[filePath] ?? 0) + 1,
};
const nextReviewExternalChangesByFile = { ...s.reviewExternalChangesByFile };
delete nextReviewExternalChangesByFile[filePath];
return {
hunkDecisions: nextHunkDecisions,
fileDecisions: nextFileDecisions,
fileChunkCounts: nextFileChunkCounts,
fileContents: nextFileContents,
fileContentsLoading: nextFileContentsLoading,
editedContents: nextEditedContents,
hunkContextHashesByFile: nextHunkContextHashesByFile,
fileContentVersionByPath: nextFileContentVersionByPath,
reviewExternalChangesByFile: nextReviewExternalChangesByFile,
...buildResolvedFileInvalidation(s, filePath),
};
});
},
invalidateResolvedFileContent: (filePath: string) => {
set((s) => buildResolvedFileInvalidation(s, filePath));
},
markReviewFileExternallyChanged: (filePath: string, type: ReviewExternalChange['type']) => {
set((s) => ({
reviewExternalChangesByFile: {
...s.reviewExternalChangesByFile,
[filePath]: { type },
},
}));
},
clearReviewFileExternalChange: (filePath: string) => {
set((s) => {
if (!(filePath in s.reviewExternalChangesByFile)) return s;
const next = { ...s.reviewExternalChangesByFile };
delete next[filePath];
return { reviewExternalChangesByFile: next };
});
},
reloadReviewFileFromDisk: (filePath: string) => {
set((s) => {
const nextEditedContents = { ...s.editedContents };
delete nextEditedContents[filePath];
const nextReviewExternalChangesByFile = { ...s.reviewExternalChangesByFile };
delete nextReviewExternalChangesByFile[filePath];
return {
editedContents: nextEditedContents,
reviewExternalChangesByFile: nextReviewExternalChangesByFile,
...buildResolvedFileInvalidation(s, filePath),
};
});
},
@ -1200,6 +1276,9 @@ export const createChangeReviewSlice: StateCreator<AppState, [], [], ChangeRevie
const nextHunkContextHashesByFile = { ...s.hunkContextHashesByFile };
delete nextHunkContextHashesByFile[filePath];
const nextReviewExternalChangesByFile = { ...s.reviewExternalChangesByFile };
delete nextReviewExternalChangesByFile[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.
@ -1217,6 +1296,7 @@ export const createChangeReviewSlice: StateCreator<AppState, [], [], ChangeRevie
fileChunkCounts: nextFileChunkCounts,
hunkContextHashesByFile: nextHunkContextHashesByFile,
fileContents: nextContents,
reviewExternalChangesByFile: nextReviewExternalChangesByFile,
applying: false,
};
});

View file

@ -9,7 +9,7 @@
import type { CliArgsValidationResult } from '../utils/cliArgsParser';
import type { CliInstallerAPI } from './cliInstaller';
import type { EditorAPI, ProjectAPI } from './editor';
import type { EditorAPI, EditorFileChangeEvent, ProjectAPI } from './editor';
import type { McpCatalogAPI, PluginCatalogAPI, ApiKeysAPI, SkillsCatalogAPI } from './extensions';
import type {
AppConfig,
@ -636,6 +636,9 @@ export interface ReviewAPI {
content: string,
projectPath?: string
) => Promise<{ success: boolean }>;
watchFiles: (projectPath: string, filePaths: string[]) => Promise<void>;
unwatchFiles: () => Promise<void>;
onExternalFileChange: (callback: (event: EditorFileChangeEvent) => void) => () => void;
// Decision persistence
loadDecisions: (
teamName: string,

View file

@ -643,6 +643,78 @@ describe('changeReviewSlice task changes', () => {
expect(store.getState().fileContentVersionByPath['/repo/file.ts']).toBe(1);
});
it('invalidates resolved file content without clearing draft or review decisions', async () => {
const store = createSliceStore();
store.setState({
activeChangeSet: makeAgentChangeSet('/repo/file.ts'),
hunkDecisions: { '/repo/file.ts:0': 'rejected' },
fileDecisions: { '/repo/file.ts': 'rejected' },
fileChunkCounts: { '/repo/file.ts': 2 },
hunkContextHashesByFile: { '/repo/file.ts': { 0: 'ctx' } },
fileContents: {
'/repo/file.ts': {
...makeFile('/repo/file.ts'),
originalFullContent: 'before',
modifiedFullContent: 'after',
contentSource: 'snippet-reconstruction',
},
},
fileContentsLoading: { '/repo/file.ts': true },
editedContents: { '/repo/file.ts': 'draft' },
reviewExternalChangesByFile: { '/repo/file.ts': { type: 'change' } },
fileContentVersionByPath: {},
});
store.getState().invalidateResolvedFileContent('/repo/file.ts');
expect(store.getState().fileContents).toEqual({});
expect(store.getState().fileContentsLoading).toEqual({});
expect(store.getState().fileChunkCounts).toEqual({});
expect(store.getState().hunkContextHashesByFile).toEqual({});
expect(store.getState().editedContents).toEqual({ '/repo/file.ts': 'draft' });
expect(store.getState().hunkDecisions).toEqual({ '/repo/file.ts:0': 'rejected' });
expect(store.getState().fileDecisions).toEqual({ '/repo/file.ts': 'rejected' });
expect(store.getState().reviewExternalChangesByFile).toEqual({
'/repo/file.ts': { type: 'change' },
});
expect(store.getState().fileContentVersionByPath['/repo/file.ts']).toBe(1);
});
it('reloadReviewFileFromDisk clears the draft but preserves review decisions', async () => {
const store = createSliceStore();
store.setState({
activeChangeSet: makeAgentChangeSet('/repo/file.ts'),
hunkDecisions: { '/repo/file.ts:0': 'rejected' },
fileDecisions: { '/repo/file.ts': 'rejected' },
fileChunkCounts: { '/repo/file.ts': 2 },
hunkContextHashesByFile: { '/repo/file.ts': { 0: 'ctx' } },
fileContents: {
'/repo/file.ts': {
...makeFile('/repo/file.ts'),
originalFullContent: 'before',
modifiedFullContent: 'after',
contentSource: 'snippet-reconstruction',
},
},
editedContents: { '/repo/file.ts': 'draft' },
reviewExternalChangesByFile: { '/repo/file.ts': { type: 'unlink' } },
fileContentVersionByPath: {},
});
store.getState().reloadReviewFileFromDisk('/repo/file.ts');
expect(store.getState().fileContents).toEqual({});
expect(store.getState().fileChunkCounts).toEqual({});
expect(store.getState().hunkContextHashesByFile).toEqual({});
expect(store.getState().editedContents).toEqual({});
expect(store.getState().reviewExternalChangesByFile).toEqual({});
expect(store.getState().hunkDecisions).toEqual({ '/repo/file.ts:0': 'rejected' });
expect(store.getState().fileDecisions).toEqual({ '/repo/file.ts': 'rejected' });
expect(store.getState().fileContentVersionByPath['/repo/file.ts']).toBe(1);
});
it('ignores stale fetchFileContent responses after removing a review file', async () => {
const store = createSliceStore();
const pending = deferred<any>();