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

View file

@ -5,12 +5,14 @@
*/ */
import { createIpcWrapper } from '@main/ipc/ipcWrapper'; import { createIpcWrapper } from '@main/ipc/ipcWrapper';
import { EditorFileWatcher } from '@main/services/editor';
import { ReviewDecisionStore } from '@main/services/team/ReviewDecisionStore'; import { ReviewDecisionStore } from '@main/services/team/ReviewDecisionStore';
import { validateFilePath } from '@main/utils/pathValidation'; import { validateFilePath } from '@main/utils/pathValidation';
import { import {
REVIEW_APPLY_DECISIONS, REVIEW_APPLY_DECISIONS,
REVIEW_CHECK_CONFLICT, REVIEW_CHECK_CONFLICT,
REVIEW_CLEAR_DECISIONS, REVIEW_CLEAR_DECISIONS,
REVIEW_FILE_CHANGE,
REVIEW_GET_AGENT_CHANGES, REVIEW_GET_AGENT_CHANGES,
REVIEW_GET_CHANGE_STATS, REVIEW_GET_CHANGE_STATS,
REVIEW_GET_FILE_CONTENT, REVIEW_GET_FILE_CONTENT,
@ -23,9 +25,13 @@ import {
REVIEW_REJECT_HUNKS, REVIEW_REJECT_HUNKS,
REVIEW_SAVE_DECISIONS, REVIEW_SAVE_DECISIONS,
REVIEW_SAVE_EDITED_FILE, 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 // eslint-disable-next-line boundaries/element-types -- IPC channel constants are shared between main and preload by design
} from '@preload/constants/ipcChannels'; } from '@preload/constants/ipcChannels';
import { createLogger } from '@shared/utils/logger'; 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 { ChangeExtractorService } from '@main/services/team/ChangeExtractorService';
import type { FileContentResolver } from '@main/services/team/FileContentResolver'; import type { FileContentResolver } from '@main/services/team/FileContentResolver';
@ -44,7 +50,7 @@ import type {
SnippetDiff, SnippetDiff,
TaskChangeSetV2, TaskChangeSetV2,
} from '@shared/types/review'; } from '@shared/types/review';
import type { IpcMain, IpcMainInvokeEvent } from 'electron'; import type { BrowserWindow, IpcMain, IpcMainInvokeEvent } from 'electron';
const wrapReviewHandler = createIpcWrapper('IPC:review'); const wrapReviewHandler = createIpcWrapper('IPC:review');
const logger = createLogger('IPC:review'); const logger = createLogger('IPC:review');
@ -56,6 +62,9 @@ let reviewApplier: ReviewApplierService | null = null;
let fileContentResolver: FileContentResolver | null = null; let fileContentResolver: FileContentResolver | null = null;
let gitDiffFallback: GitDiffFallback | null = null; let gitDiffFallback: GitDiffFallback | null = null;
const reviewDecisionStore = new ReviewDecisionStore(); const reviewDecisionStore = new ReviewDecisionStore();
const reviewFileWatcher = new EditorFileWatcher();
let reviewWatcherProjectRoot: string | null = null;
let reviewMainWindowRef: BrowserWindow | null = null;
function getChangeExtractor(): ChangeExtractorService { function getChangeExtractor(): ChangeExtractorService {
if (!changeExtractor) throw new Error('Review handlers not initialized'); 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); ipcMain.handle(REVIEW_GET_FILE_CONTENT, handleGetFileContent);
// Editable diff // Editable diff
ipcMain.handle(REVIEW_SAVE_EDITED_FILE, handleSaveEditedFile); ipcMain.handle(REVIEW_SAVE_EDITED_FILE, handleSaveEditedFile);
ipcMain.handle(REVIEW_WATCH_FILES, handleWatchReviewFiles);
ipcMain.handle(REVIEW_UNWATCH_FILES, handleUnwatchReviewFiles);
// Phase 4 // Phase 4
ipcMain.handle(REVIEW_GET_GIT_FILE_LOG, handleGetGitFileLog); ipcMain.handle(REVIEW_GET_GIT_FILE_LOG, handleGetGitFileLog);
// Decision persistence // Decision persistence
@ -126,12 +137,20 @@ export function removeReviewHandlers(ipcMain: IpcMain): void {
ipcMain.removeHandler(REVIEW_GET_FILE_CONTENT); ipcMain.removeHandler(REVIEW_GET_FILE_CONTENT);
// Editable diff // Editable diff
ipcMain.removeHandler(REVIEW_SAVE_EDITED_FILE); ipcMain.removeHandler(REVIEW_SAVE_EDITED_FILE);
ipcMain.removeHandler(REVIEW_WATCH_FILES);
ipcMain.removeHandler(REVIEW_UNWATCH_FILES);
// Phase 4 // Phase 4
ipcMain.removeHandler(REVIEW_GET_GIT_FILE_LOG); ipcMain.removeHandler(REVIEW_GET_GIT_FILE_LOG);
// Decision persistence // Decision persistence
ipcMain.removeHandler(REVIEW_LOAD_DECISIONS); ipcMain.removeHandler(REVIEW_LOAD_DECISIONS);
ipcMain.removeHandler(REVIEW_SAVE_DECISIONS); ipcMain.removeHandler(REVIEW_SAVE_DECISIONS);
ipcMain.removeHandler(REVIEW_CLEAR_DECISIONS); ipcMain.removeHandler(REVIEW_CLEAR_DECISIONS);
reviewFileWatcher.stop();
reviewWatcherProjectRoot = null;
}
export function setReviewMainWindow(win: BrowserWindow | null): void {
reviewMainWindowRef = win;
} }
// --- Phase 1 Handlers --- // --- 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 --- // --- 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( async function handleGetGitFileLog(
_event: IpcMainInvokeEvent, _event: IpcMainInvokeEvent,
projectPath: string, projectPath: string,

View file

@ -457,6 +457,15 @@ export const REVIEW_APPLY_DECISIONS = 'review:applyDecisions';
/** Получить полное содержимое файла для diff view */ /** Получить полное содержимое файла для diff view */
export const REVIEW_GET_FILE_CONTENT = 'review:getFileContent'; 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 // Phase 4 — Git fallback
/** Save edited file content to disk */ /** Save edited file content to disk */

View file

@ -55,6 +55,7 @@ import {
REVIEW_GET_AGENT_CHANGES, REVIEW_GET_AGENT_CHANGES,
REVIEW_GET_CHANGE_STATS, REVIEW_GET_CHANGE_STATS,
REVIEW_GET_FILE_CONTENT, REVIEW_GET_FILE_CONTENT,
REVIEW_FILE_CHANGE,
REVIEW_GET_GIT_FILE_LOG, REVIEW_GET_GIT_FILE_LOG,
REVIEW_GET_TASK_CHANGES, REVIEW_GET_TASK_CHANGES,
REVIEW_INVALIDATE_TASK_CHANGE_SUMMARIES, REVIEW_INVALIDATE_TASK_CHANGE_SUMMARIES,
@ -64,6 +65,8 @@ import {
REVIEW_REJECT_HUNKS, REVIEW_REJECT_HUNKS,
REVIEW_SAVE_DECISIONS, REVIEW_SAVE_DECISIONS,
REVIEW_SAVE_EDITED_FILE, REVIEW_SAVE_EDITED_FILE,
REVIEW_UNWATCH_FILES,
REVIEW_WATCH_FILES,
SSH_CONNECT, SSH_CONNECT,
SSH_DISCONNECT, SSH_DISCONNECT,
SSH_GET_CONFIG_HOSTS, SSH_GET_CONFIG_HOSTS,
@ -1206,6 +1209,20 @@ const electronAPI: ElectronAPI = {
projectPath 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 // Decision persistence
loadDecisions: async (teamName: string, scopeKey: string) => { loadDecisions: async (teamName: string, scopeKey: string) => {
return invokeIpcWithResult<{ return invokeIpcWithResult<{

View file

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

View file

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

View file

@ -1,5 +1,6 @@
import React from 'react'; import React from 'react';
import { FileIcon } from '@renderer/components/team/editor/FileIcon';
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip'; import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
import { shortcutLabel } from '@renderer/utils/platformKeys'; import { shortcutLabel } from '@renderer/utils/platformKeys';
import { ChevronDown, ChevronRight, FilePlus, Loader2, Save, Undo2 } from 'lucide-react'; import { ChevronDown, ChevronRight, FilePlus, Loader2, Save, Undo2 } from 'lucide-react';
@ -19,6 +20,7 @@ interface FileSectionHeaderProps {
file: FileChangeSummary; file: FileChangeSummary;
fileContent: FileChangeWithContent | null; fileContent: FileChangeWithContent | null;
fileDecision: HunkDecision | undefined; fileDecision: HunkDecision | undefined;
externalChange?: { type: 'change' | 'add' | 'unlink' };
pathChangeLabel?: pathChangeLabel?:
| { kind: 'deleted' } | { kind: 'deleted' }
| { kind: 'moved' | 'renamed'; direction: 'from' | 'to'; otherPath: string }; | { kind: 'moved' | 'renamed'; direction: 'from' | 'to'; otherPath: string };
@ -28,6 +30,8 @@ interface FileSectionHeaderProps {
onToggleCollapse: (filePath: string) => void; onToggleCollapse: (filePath: string) => void;
onDiscard: (filePath: string) => void; onDiscard: (filePath: string) => void;
onSave: (filePath: string) => void; onSave: (filePath: string) => void;
onReloadFromDisk?: (filePath: string) => void;
onKeepDraft?: (filePath: string) => void;
onRestoreMissingFile?: (filePath: string, content: string) => void; onRestoreMissingFile?: (filePath: string, content: string) => void;
onAcceptFile?: (filePath: string) => void; onAcceptFile?: (filePath: string) => void;
onRejectFile?: (filePath: string) => void; onRejectFile?: (filePath: string) => void;
@ -37,6 +41,7 @@ export const FileSectionHeader = ({
file, file,
fileContent, fileContent,
fileDecision, fileDecision,
externalChange,
pathChangeLabel, pathChangeLabel,
hasEdits, hasEdits,
applying, applying,
@ -44,6 +49,8 @@ export const FileSectionHeader = ({
onToggleCollapse, onToggleCollapse,
onDiscard, onDiscard,
onSave, onSave,
onReloadFromDisk,
onKeepDraft,
onRestoreMissingFile, onRestoreMissingFile,
onAcceptFile, onAcceptFile,
onRejectFile, onRejectFile,
@ -60,6 +67,14 @@ export const FileSectionHeader = ({
return writeSnippets[writeSnippets.length - 1].newString; return writeSnippets[writeSnippets.length - 1].newString;
})(); })();
const canRestore = !!onRestoreMissingFile && isPreviewOnly && !hasEdits && restoreContent != null; 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 => { const handleHeaderClick = (e: React.MouseEvent): void => {
// Don't collapse when clicking action buttons // Don't collapse when clicking action buttons
@ -85,6 +100,7 @@ export const FileSectionHeader = ({
<span className="flex shrink-0 items-center text-text-muted"> <span className="flex shrink-0 items-center text-text-muted">
{isCollapsed ? <ChevronRight className="size-3.5" /> : <ChevronDown className="size-3.5" />} {isCollapsed ? <ChevronRight className="size-3.5" /> : <ChevronDown className="size-3.5" />}
</span> </span>
<FileIcon fileName={file.relativePath} className="size-3.5" />
<span className="text-xs font-medium text-text">{file.relativePath}</span> <span className="text-xs font-medium text-text">{file.relativePath}</span>
{file.isNewFile && ( {file.isNewFile && (
@ -167,7 +183,32 @@ export const FileSectionHeader = ({
</span> </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> <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) && ( {(onAcceptFile || onRejectFile) && (
<div className="mr-1 flex items-center gap-1.5"> <div className="mr-1 flex items-center gap-1.5">
{onAcceptFile && ( {onAcceptFile && (

View file

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

View file

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

View file

@ -643,6 +643,78 @@ describe('changeReviewSlice task changes', () => {
expect(store.getState().fileContentVersionByPath['/repo/file.ts']).toBe(1); 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 () => { it('ignores stale fetchFileContent responses after removing a review file', async () => {
const store = createSliceStore(); const store = createSliceStore();
const pending = deferred<any>(); const pending = deferred<any>();