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
This commit is contained in:
iliya 2026-03-04 14:36:05 +02:00
parent 887c7406d1
commit dfc2a43a91
7 changed files with 358 additions and 53 deletions

View file

@ -257,7 +257,19 @@ async function handleApplyDecisions(
fileContents.set(d.filePath, resolved); fileContents.set(d.filePath, resolved);
} }
return getApplier().applyReviewDecisions(request, fileContents); const result = await getApplier().applyReviewDecisions(request, fileContents);
// Invalidate resolved file content cache after applying decisions so subsequent
// diff operations read the latest disk state (avoids "stuck" decisions in instant-apply flows).
try {
for (const d of request.decisions) {
getContentResolver().invalidateFile(d.filePath);
}
} catch (error) {
logger.debug('applyDecisions cache invalidation failed:', error);
}
return result;
}); });
} }

View file

@ -83,6 +83,7 @@ export const ChangeReviewDialog = ({
applySingleFileDecision, applySingleFileDecision,
removeReviewFile, removeReviewFile,
addReviewFile, addReviewFile,
clearReviewStateForFile,
editedContents, editedContents,
updateEditedContent, updateEditedContent,
discardFileEdits, discardFileEdits,
@ -98,11 +99,33 @@ export const ChangeReviewDialog = ({
hunkContextHashesByFile, hunkContextHashesByFile,
} = useStore(); } = useStore();
// Build scope keys (pure values — safe to compute before hooks that depend on them)
const scopeKey = mode === 'task' ? `task:${taskId ?? ''}` : `agent:${memberName ?? ''}`;
// Filesystem-safe: use `-` instead of `:` for decision persistence key
const decisionScopeKey = mode === 'task' ? `task-${taskId ?? ''}` : `agent-${memberName ?? ''}`;
// Active file from scroll-spy (replaces selectedReviewFilePath for continuous scroll) // Active file from scroll-spy (replaces selectedReviewFilePath for continuous scroll)
const [activeFilePath, setActiveFilePath] = useState<string | null>(null); const [activeFilePath, setActiveFilePath] = useState<string | null>(null);
const [autoViewed, setAutoViewed] = useState(true); const [autoViewed, setAutoViewed] = useState(true);
const [timelineOpen, setTimelineOpen] = useState(false); const [timelineOpen, setTimelineOpen] = useState(false);
const [discardCounters, setDiscardCounters] = useState<Record<string, number>>({}); const [discardCounters, setDiscardCounters] = useState<Record<string, number>>({});
const collapseStorageKey = useMemo(
() => `review:collapsed:${teamName}:${decisionScopeKey}`,
[teamName, decisionScopeKey]
);
const [collapsedFiles, setCollapsedFiles] = useState<Set<string>>(() => {
if (typeof window === 'undefined') return new Set<string>();
try {
const raw = window.localStorage.getItem(collapseStorageKey);
const parsed = raw ? (JSON.parse(raw) as unknown) : null;
if (Array.isArray(parsed)) {
return new Set(parsed.filter((v): v is string => typeof v === 'string'));
}
} catch {
// ignore
}
return new Set<string>();
});
// Selection menu state // Selection menu state
const [selectionInfo, setSelectionInfo] = useState<EditorSelectionInfo | null>(null); const [selectionInfo, setSelectionInfo] = useState<EditorSelectionInfo | null>(null);
@ -148,18 +171,92 @@ export const ChangeReviewDialog = ({
scrollContainerRef, scrollContainerRef,
}); });
// Build scope key for viewed storage
const scopeKey = mode === 'task' ? `task:${taskId ?? ''}` : `agent:${memberName ?? ''}`;
// Build scope key for decision persistence (filesystem-safe: use `-` instead of `:`)
const decisionScopeKey = mode === 'task' ? `task-${taskId ?? ''}` : `agent-${memberName ?? ''}`;
// File paths for viewed tracking // File paths for viewed tracking
const allFilePaths = useMemo( const allFilePaths = useMemo(
() => (activeChangeSet?.files ?? []).map((f) => f.filePath), () => (activeChangeSet?.files ?? []).map((f) => f.filePath),
[activeChangeSet] [activeChangeSet]
); );
const pathChangeLabels = useMemo(() => {
if (!activeChangeSet)
return {} as Record<
string,
| { kind: 'deleted' }
| { kind: 'moved' | 'renamed'; direction: 'from' | 'to'; otherPath: string }
>;
const normalize = (s: string): string =>
s.replace(/\r\n/g, '\n').replace(/\r/g, '\n').trimEnd();
const hashFull = (s: string): string => {
// DJB2 (full string) — good enough for heuristic rename/move pairing
let h = 5381;
for (let i = 0; i < s.length; i++) h = ((h << 5) + h + s.charCodeAt(i)) | 0;
return (h >>> 0).toString(36);
};
const baseName = (p: string): string => p.split(/[\\/]/).filter(Boolean).pop() ?? p;
type Label =
| { kind: 'deleted' }
| { kind: 'moved' | 'renamed'; direction: 'from' | 'to'; otherPath: string };
const out: Record<string, Label> = {};
const deletedCandidates: { file: FileChangeSummary; hash: string }[] = [];
const newCandidates: { file: FileChangeSummary; hash: string }[] = [];
for (const f of activeChangeSet.files) {
const content = fileContents[f.filePath];
if (!content) continue;
const modified = content.modifiedFullContent;
const original = content.originalFullContent;
if (!f.isNewFile && modified == null) {
if (original != null) {
deletedCandidates.push({ file: f, hash: hashFull(normalize(original)) });
} else {
out[f.filePath] = { kind: 'deleted' };
}
}
if (f.isNewFile && modified != null) {
newCandidates.push({ file: f, hash: hashFull(normalize(modified)) });
}
}
const deletedByHash = new Map<string, { file: FileChangeSummary; count: number }>();
for (const d of deletedCandidates) {
const prev = deletedByHash.get(d.hash);
deletedByHash.set(d.hash, { file: d.file, count: (prev?.count ?? 0) + 1 });
}
const usedDeleted = new Set<string>();
for (const n of newCandidates) {
const entry = deletedByHash.get(n.hash);
if (!entry) continue;
if (entry.count !== 1) continue; // ambiguous
const oldFile = entry.file;
if (usedDeleted.has(oldFile.filePath)) continue;
usedDeleted.add(oldFile.filePath);
const oldName = baseName(oldFile.relativePath);
const newName = baseName(n.file.relativePath);
const kind: 'moved' | 'renamed' =
oldName === newName && oldFile.relativePath !== n.file.relativePath ? 'moved' : 'renamed';
out[n.file.filePath] = { kind, direction: 'from', otherPath: oldFile.relativePath };
out[oldFile.filePath] = { kind, direction: 'to', otherPath: n.file.relativePath };
}
for (const d of deletedCandidates) {
if (!usedDeleted.has(d.file.filePath) && !(d.file.filePath in out)) {
out[d.file.filePath] = { kind: 'deleted' };
}
}
return out;
}, [activeChangeSet, fileContents]);
const { const {
viewedSet, viewedSet,
isViewed, isViewed,
@ -296,6 +393,15 @@ export const ChangeReviewDialog = ({
lastNewFileRemoveAtRef.current = Date.now(); lastNewFileRemoveAtRef.current = Date.now();
removeReviewFile(filePath); removeReviewFile(filePath);
} }
} else {
const hasErrorForFile = !!result?.errors.some((e) => e.filePath === filePath);
if (result && !hasErrorForFile) {
// 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);
setDiscardCounters((prev) => ({ ...prev, [filePath]: (prev[filePath] ?? 0) + 1 }));
void fetchFileContent(teamName, memberName, filePath);
}
} }
} }
} finally { } finally {
@ -311,6 +417,8 @@ export const ChangeReviewDialog = ({
memberName, memberName,
removeReviewFile, removeReviewFile,
fileContents, fileContents,
clearReviewStateForFile,
fetchFileContent,
] ]
); );
@ -336,10 +444,25 @@ export const ChangeReviewDialog = ({
} }
hunkDecisionUndoStackRef.current[filePath].push(originalIndex); hunkDecisionUndoStackRef.current[filePath].push(originalIndex);
if (REVIEW_INSTANT_APPLY) { if (REVIEW_INSTANT_APPLY) {
void applySingleFileDecision(teamName, filePath, taskId, memberName); void applySingleFileDecision(teamName, filePath, taskId, memberName).then((result) => {
const hasErrorForFile = !!result?.errors.some((e) => e.filePath === filePath);
if (result && !hasErrorForFile) {
clearReviewStateForFile(filePath);
setDiscardCounters((prev) => ({ ...prev, [filePath]: (prev[filePath] ?? 0) + 1 }));
void fetchFileContent(teamName, memberName, filePath);
}
});
} }
}, },
[setHunkDecision, applySingleFileDecision, teamName, taskId, memberName] [
setHunkDecision,
applySingleFileDecision,
teamName,
taskId,
memberName,
clearReviewStateForFile,
fetchFileContent,
]
); );
const handleContentChanged = useCallback( const handleContentChanged = useCallback(
@ -490,6 +613,42 @@ export const ChangeReviewDialog = ({
getFileHunkCount(filePath, fallbackSnippetsLength, fileChunkCounts) getFileHunkCount(filePath, fallbackSnippetsLength, fileChunkCounts)
); );
const toggleCollapsedFile = useCallback((filePath: string) => {
setCollapsedFiles((prev) => {
const next = new Set(prev);
if (next.has(filePath)) next.delete(filePath);
else next.add(filePath);
return next;
});
}, []);
// Persist collapsed state (best-effort)
useEffect(() => {
if (!open) return;
if (typeof window === 'undefined') return;
const id = window.setTimeout(() => {
try {
window.localStorage.setItem(collapseStorageKey, JSON.stringify([...collapsedFiles]));
} catch {
// ignore
}
}, 200);
return () => window.clearTimeout(id);
}, [open, collapseStorageKey, collapsedFiles]);
// Prune collapsed entries to only current files to avoid stale growth
useEffect(() => {
if (!activeChangeSet) return;
const allowed = new Set(activeChangeSet.files.map((f) => f.filePath));
setCollapsedFiles((prev) => {
const next = new Set<string>();
for (const fp of prev) {
if (allowed.has(fp)) next.add(fp);
}
return next.size === prev.size ? prev : next;
});
}, [activeChangeSet]);
// Load data on open // Load data on open
useEffect(() => { useEffect(() => {
if (!open) return; if (!open) return;
@ -902,6 +1061,8 @@ export const ChangeReviewDialog = ({
<div className="w-64 shrink-0 overflow-y-auto border-r border-border bg-surface-sidebar"> <div className="w-64 shrink-0 overflow-y-auto border-r border-border bg-surface-sidebar">
<ReviewFileTree <ReviewFileTree
files={activeChangeSet.files} files={activeChangeSet.files}
fileContents={fileContents}
pathChangeLabels={pathChangeLabels}
selectedFilePath={null} selectedFilePath={null}
onSelectFile={handleTreeFileClick} onSelectFile={handleTreeFileClick}
viewedSet={viewedSet} viewedSet={viewedSet}
@ -964,6 +1125,9 @@ export const ChangeReviewDialog = ({
onAcceptFile={handleAcceptFile} onAcceptFile={handleAcceptFile}
onRejectFile={handleRejectFile} onRejectFile={handleRejectFile}
onRestoreMissingFile={handleRestoreMissingFile} onRestoreMissingFile={handleRestoreMissingFile}
pathChangeLabels={pathChangeLabels}
collapsedFiles={collapsedFiles}
onToggleCollapse={toggleCollapsedFile}
onVisibleFileChange={handleVisibleFileChange} onVisibleFileChange={handleVisibleFileChange}
scrollContainerRef={scrollContainerRef} scrollContainerRef={scrollContainerRef}
editorViewMapRef={editorViewMapRef} editorViewMapRef={editorViewMapRef}

View file

@ -41,6 +41,13 @@ interface ContinuousScrollViewProps {
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;
pathChangeLabels?: Record<
string,
{ kind: 'deleted' } | { kind: 'moved' | 'renamed'; direction: 'from' | 'to'; otherPath: string }
>;
/** Controlled collapsed state (persisted by parent). If omitted, component manages it locally. */
collapsedFiles?: Set<string>;
onToggleCollapse?: (filePath: string) => void;
onVisibleFileChange: (filePath: string) => void; onVisibleFileChange: (filePath: string) => void;
scrollContainerRef: React.RefObject<HTMLDivElement>; scrollContainerRef: React.RefObject<HTMLDivElement>;
editorViewMapRef: React.MutableRefObject<Map<string, EditorView>>; editorViewMapRef: React.MutableRefObject<Map<string, EditorView>>;
@ -77,6 +84,9 @@ export const ContinuousScrollView = ({
onAcceptFile, onAcceptFile,
onRejectFile, onRejectFile,
onRestoreMissingFile, onRestoreMissingFile,
pathChangeLabels,
collapsedFiles: collapsedFilesProp,
onToggleCollapse: onToggleCollapseProp,
onVisibleFileChange, onVisibleFileChange,
scrollContainerRef, scrollContainerRef,
editorViewMapRef, editorViewMapRef,
@ -87,19 +97,27 @@ export const ContinuousScrollView = ({
onSelectionChange, onSelectionChange,
}: ContinuousScrollViewProps): React.ReactElement => { }: ContinuousScrollViewProps): React.ReactElement => {
const setFileChunkCount = useStore((s) => s.setFileChunkCount); const setFileChunkCount = useStore((s) => s.setFileChunkCount);
const [collapsedFiles, setCollapsedFiles] = useState<Set<string>>(new Set()); const [localCollapsedFiles, setLocalCollapsedFiles] = useState<Set<string>>(() => new Set());
const collapsedFiles = collapsedFilesProp ?? localCollapsedFiles;
const handleToggleCollapse = useCallback((filePath: string) => { const handleToggleCollapse = useCallback(
setCollapsedFiles((prev) => { (filePath: string) => {
const next = new Set(prev); if (onToggleCollapseProp) {
if (next.has(filePath)) { onToggleCollapseProp(filePath);
next.delete(filePath); return;
} else {
next.add(filePath);
} }
return next; setLocalCollapsedFiles((prev) => {
}); const next = new Set(prev);
}, []); if (next.has(filePath)) {
next.delete(filePath);
} else {
next.add(filePath);
}
return next;
});
},
[onToggleCollapseProp]
);
const filePaths = useMemo(() => files.map((f) => f.filePath), [files]); const filePaths = useMemo(() => files.map((f) => f.filePath), [files]);
@ -222,6 +240,7 @@ export const ContinuousScrollView = ({
file={file} file={file}
fileContent={content} fileContent={content}
fileDecision={decision} fileDecision={decision}
pathChangeLabel={pathChangeLabels?.[filePath]}
hasEdits={hasEdits} hasEdits={hasEdits}
applying={applying} applying={applying}
isCollapsed={isCollapsed} isCollapsed={isCollapsed}

View file

@ -93,7 +93,7 @@ export const FileSectionDiff = ({
})(); })();
const resolvedOriginal = fileContent?.originalFullContent ?? null; const resolvedOriginal = fileContent?.originalFullContent ?? null;
const isMissingOnDisk = fileContent?.contentSource === 'unavailable'; const isMissingOnDisk = fileContent ? fileContent.modifiedFullContent == null : false;
// Show CodeMirror only when we have a trustworthy original baseline: // Show CodeMirror only when we have a trustworthy original baseline:
// - new files: original is legitimately empty // - new files: original is legitimately empty

View file

@ -19,6 +19,9 @@ interface FileSectionHeaderProps {
file: FileChangeSummary; file: FileChangeSummary;
fileContent: FileChangeWithContent | null; fileContent: FileChangeWithContent | null;
fileDecision: HunkDecision | undefined; fileDecision: HunkDecision | undefined;
pathChangeLabel?:
| { kind: 'deleted' }
| { kind: 'moved' | 'renamed'; direction: 'from' | 'to'; otherPath: string };
hasEdits: boolean; hasEdits: boolean;
applying: boolean; applying: boolean;
isCollapsed: boolean; isCollapsed: boolean;
@ -34,6 +37,7 @@ export const FileSectionHeader = ({
file, file,
fileContent, fileContent,
fileDecision, fileDecision,
pathChangeLabel,
hasEdits, hasEdits,
applying, applying,
isCollapsed, isCollapsed,
@ -44,7 +48,8 @@ export const FileSectionHeader = ({
onAcceptFile, onAcceptFile,
onRejectFile, onRejectFile,
}: FileSectionHeaderProps): React.ReactElement => { }: FileSectionHeaderProps): React.ReactElement => {
const isMissingOnDisk = fileContent?.contentSource === 'unavailable'; const isMissingOnDisk = fileContent ? fileContent.modifiedFullContent == null : false;
const isPreviewOnly = isMissingOnDisk || fileContent?.contentSource === 'unavailable';
const restoreContent = const restoreContent =
fileContent?.modifiedFullContent ?? fileContent?.modifiedFullContent ??
(() => { (() => {
@ -54,8 +59,7 @@ export const FileSectionHeader = ({
if (writeSnippets.length === 0) return null; if (writeSnippets.length === 0) return null;
return writeSnippets[writeSnippets.length - 1].newString; return writeSnippets[writeSnippets.length - 1].newString;
})(); })();
const canRestore = const canRestore = !!onRestoreMissingFile && isPreviewOnly && !hasEdits && restoreContent != null;
!!onRestoreMissingFile && isMissingOnDisk && !hasEdits && restoreContent != 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
@ -89,22 +93,41 @@ export const FileSectionHeader = ({
</span> </span>
)} )}
{pathChangeLabel?.kind === 'deleted' && (
<span className="rounded bg-red-500/20 px-1.5 py-0.5 text-[10px] text-red-300">
DELETED
</span>
)}
{pathChangeLabel && pathChangeLabel.kind !== 'deleted' && (
<Tooltip>
<TooltipTrigger asChild>
<span className="rounded bg-purple-500/20 px-1.5 py-0.5 text-[10px] text-purple-300">
{pathChangeLabel.kind.toUpperCase()}
</span>
</TooltipTrigger>
<TooltipContent side="bottom" className="max-w-xs">
{pathChangeLabel.direction === 'from' ? 'From' : 'To'} {pathChangeLabel.otherPath}
</TooltipContent>
</Tooltip>
)}
{fileContent?.contentSource && ( {fileContent?.contentSource && (
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<span <span
className={[ className={[
'rounded px-1.5 py-0.5 text-[10px]', 'rounded px-1.5 py-0.5 text-[10px]',
fileContent.contentSource === 'unavailable' isPreviewOnly ? 'bg-red-500/20 text-red-300' : 'bg-surface-raised text-text-muted',
? 'bg-red-500/20 text-red-300'
: 'bg-surface-raised text-text-muted',
].join(' ')} ].join(' ')}
> >
{CONTENT_SOURCE_LABELS[fileContent.contentSource] ?? fileContent.contentSource} {isPreviewOnly
? 'Missing on disk'
: (CONTENT_SOURCE_LABELS[fileContent.contentSource] ?? fileContent.contentSource)}
</span> </span>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent side="bottom" className="max-w-xs"> <TooltipContent side="bottom" className="max-w-xs">
{fileContent.contentSource === 'unavailable' ? ( {isPreviewOnly ? (
<div className="space-y-1"> <div className="space-y-1">
<div className="font-medium text-text">File is missing on disk</div> <div className="font-medium text-text">File is missing on disk</div>
<div className="text-text-muted"> <div className="text-text-muted">
@ -148,32 +171,54 @@ export const FileSectionHeader = ({
{(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 && (
<button <Tooltip>
onClick={() => onAcceptFile(file.filePath)} <TooltipTrigger asChild>
disabled={applying} <span>
className={[ <button
'rounded px-2 py-1 text-xs font-medium transition-colors disabled:opacity-50', onClick={() => onAcceptFile(file.filePath)}
fileDecision === 'accepted' disabled={applying || isPreviewOnly}
? 'bg-green-500/25 text-green-300' className={[
: 'bg-green-500/15 text-green-400 hover:bg-green-500/25', 'rounded px-2 py-1 text-xs font-medium transition-colors disabled:opacity-50',
].join(' ')} fileDecision === 'accepted'
> ? 'bg-green-500/25 text-green-300'
Accept : 'bg-green-500/15 text-green-400 hover:bg-green-500/25',
</button> ].join(' ')}
>
Accept
</button>
</span>
</TooltipTrigger>
{isPreviewOnly && (
<TooltipContent side="bottom">
Accept/Reject is disabled while the file is missing on disk.
</TooltipContent>
)}
</Tooltip>
)} )}
{onRejectFile && ( {onRejectFile && (
<button <Tooltip>
onClick={() => onRejectFile(file.filePath)} <TooltipTrigger asChild>
disabled={applying} <span>
className={[ <button
'rounded px-2 py-1 text-xs font-medium transition-colors disabled:opacity-50', onClick={() => onRejectFile(file.filePath)}
fileDecision === 'rejected' disabled={applying || isPreviewOnly}
? 'bg-red-500/25 text-red-300' className={[
: 'bg-red-500/15 text-red-400 hover:bg-red-500/25', 'rounded px-2 py-1 text-xs font-medium transition-colors disabled:opacity-50',
].join(' ')} fileDecision === 'rejected'
> ? 'bg-red-500/25 text-red-300'
Reject : 'bg-red-500/15 text-red-400 hover:bg-red-500/25',
</button> ].join(' ')}
>
Reject
</button>
</span>
</TooltipTrigger>
{isPreviewOnly && (
<TooltipContent side="bottom">
Accept/Reject is disabled while the file is missing on disk.
</TooltipContent>
)}
</Tooltip>
)} )}
</div> </div>
)} )}

View file

@ -20,10 +20,16 @@ import {
import type { TreeNode } from '@renderer/utils/fileTreeBuilder'; import type { TreeNode } from '@renderer/utils/fileTreeBuilder';
import type { HunkDecision } from '@shared/types'; import type { HunkDecision } from '@shared/types';
import type { FileChangeWithContent } from '@shared/types';
import type { FileChangeSummary } from '@shared/types/review'; import type { FileChangeSummary } from '@shared/types/review';
interface ReviewFileTreeProps { interface ReviewFileTreeProps {
files: FileChangeSummary[]; files: FileChangeSummary[];
fileContents?: Record<string, FileChangeWithContent>;
pathChangeLabels?: Record<
string,
{ kind: 'deleted' } | { kind: 'moved' | 'renamed'; direction: 'from' | 'to'; otherPath: string }
>;
selectedFilePath: string | null; selectedFilePath: string | null;
onSelectFile: (filePath: string) => void; onSelectFile: (filePath: string) => void;
viewedSet?: Set<string>; viewedSet?: Set<string>;
@ -108,6 +114,7 @@ const TreeItem = ({
viewedSet, viewedSet,
collapsedFolders, collapsedFolders,
onToggleFolder, onToggleFolder,
pathChangeLabels,
}: { }: {
node: TreeNode<FileChangeSummary>; node: TreeNode<FileChangeSummary>;
selectedFilePath: string | null; selectedFilePath: string | null;
@ -120,11 +127,13 @@ const TreeItem = ({
viewedSet?: Set<string>; viewedSet?: Set<string>;
collapsedFolders: Set<string>; collapsedFolders: Set<string>;
onToggleFolder: (fullPath: string) => void; onToggleFolder: (fullPath: string) => void;
pathChangeLabels?: ReviewFileTreeProps['pathChangeLabels'];
}): JSX.Element => { }): JSX.Element => {
if (node.isFile && node.data) { if (node.isFile && node.data) {
const isSelected = node.data.filePath === selectedFilePath; const isSelected = node.data.filePath === selectedFilePath;
const isActive = node.data.filePath === activeFilePath && !isSelected; const isActive = node.data.filePath === activeFilePath && !isSelected;
const status = getFileStatus(node.data, hunkDecisions, fileDecisions, fileChunkCounts); const status = getFileStatus(node.data, hunkDecisions, fileDecisions, fileChunkCounts);
const label = pathChangeLabels?.[node.data.filePath];
return ( return (
<button <button
data-tree-file={node.data.filePath} data-tree-file={node.data.filePath}
@ -164,6 +173,16 @@ const TreeItem = ({
new new
</span> </span>
)} )}
{label?.kind === 'deleted' && (
<span className="shrink-0 rounded bg-red-500/20 px-1.5 py-0.5 text-[10px] font-medium text-red-300">
deleted
</span>
)}
{label && label.kind !== 'deleted' && (
<span className="shrink-0 rounded bg-purple-500/20 px-1.5 py-0.5 text-[10px] font-medium text-purple-300">
{label.kind}
</span>
)}
<span className="ml-1 flex shrink-0 items-center gap-1"> <span className="ml-1 flex shrink-0 items-center gap-1">
{node.data.linesAdded > 0 && ( {node.data.linesAdded > 0 && (
<span className="text-green-400">+{node.data.linesAdded}</span> <span className="text-green-400">+{node.data.linesAdded}</span>
@ -210,6 +229,7 @@ const TreeItem = ({
viewedSet={viewedSet} viewedSet={viewedSet}
collapsedFolders={collapsedFolders} collapsedFolders={collapsedFolders}
onToggleFolder={onToggleFolder} onToggleFolder={onToggleFolder}
pathChangeLabels={pathChangeLabels}
/> />
))} ))}
</div> </div>
@ -248,6 +268,7 @@ function getAncestorFolderPaths(tree: TreeNode<FileChangeSummary>[], filePath: s
export const ReviewFileTree = ({ export const ReviewFileTree = ({
files, files,
pathChangeLabels,
selectedFilePath, selectedFilePath,
onSelectFile, onSelectFile,
viewedSet, viewedSet,
@ -441,6 +462,7 @@ export const ReviewFileTree = ({
viewedSet={viewedSet} viewedSet={viewedSet}
collapsedFolders={collapsedFolders} collapsedFolders={collapsedFolders}
onToggleFolder={toggleFolder} onToggleFolder={toggleFolder}
pathChangeLabels={pathChangeLabels}
/> />
))} ))}
</div> </div>

View file

@ -131,6 +131,11 @@ export interface ChangeReviewSlice {
file: FileChangeSummary, file: FileChangeSummary,
options?: { index?: number; content?: FileChangeWithContent } options?: { index?: number; content?: FileChangeWithContent }
) => void; ) => 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; invalidateChangeStats: (teamName: string) => void;
// Editable diff actions // Editable diff actions
@ -889,6 +894,44 @@ export const createChangeReviewSlice: StateCreator<AppState, [], [], ChangeRevie
}); });
}, },
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 ── // ── Editable diff actions ──
updateEditedContent: (filePath: string, content: string) => { updateEditedContent: (filePath: string, content: string) => {