agent-ecosystem/src/renderer/components/team/review/ChangeReviewDialog.tsx
iliya 47dac2e8b5 feat: migrate to React 19
Upgrade React 18.3.1 → 19.2.4 with full type compatibility.

Changes:
- react, react-dom → ^19.0.0
- @types/react, @types/react-dom → ^19.0.0
- lucide-react → ^0.577.0 (React 19 type fixes)
- @tiptap/* → ^3.20.4 (React 19 support)
- useRef calls now require explicit initial value (undefined)
- RefObject types updated for React 19 (includes null)
- MutableRefObject → RefObject (deprecated in 19)
- act() import moved from react-dom/test-utils to react
- Scoped JSX namespace imports added where needed
2026-03-24 17:11:55 +02:00

1399 lines
49 KiB
TypeScript

import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { undo } from '@codemirror/commands';
import { rejectChunk } from '@codemirror/merge';
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';
import { useViewedFiles } from '@renderer/hooks/useViewedFiles';
import { cn } from '@renderer/lib/utils';
import { useStore } from '@renderer/store';
import { getFileHunkCount, REVIEW_INSTANT_APPLY } from '@renderer/store/slices/changeReviewSlice';
import { buildSelectionAction } from '@renderer/utils/buildSelectionAction';
import { buildSelectionInfo, SELECTION_DEBOUNCE_MS } from '@renderer/utils/codemirrorSelectionInfo';
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';
import { acceptAllChunks, computeChunkIndexAtPos, rejectAllChunks } from './CodeMirrorDiffUtils';
import { ContinuousScrollView } from './ContinuousScrollView';
import { FileEditTimeline } from './FileEditTimeline';
import { KeyboardShortcutsHelp } from './KeyboardShortcutsHelp';
import { ReviewFileTree } from './ReviewFileTree';
import { ReviewToolbar } from './ReviewToolbar';
import { ScopeWarningBanner } from './ScopeWarningBanner';
import { ViewedProgressBar } from './ViewedProgressBar';
import type { EditorView } from '@codemirror/view';
import type {
FileChangeSummary,
FileChangeWithContent,
HunkDecision,
TaskChangeSetV2,
} from '@shared/types';
import type { EditorSelectionAction, EditorSelectionInfo } from '@shared/types/editor';
interface RecentHunkUndoAction {
filePath: string;
originalIndex: number;
at: number;
}
const REVIEW_LOCAL_WRITE_COOLDOWN_MS = 2000;
interface ChangeReviewDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
teamName: string;
mode: 'agent' | 'task';
memberName?: string;
taskId?: string;
initialFilePath?: string;
taskChangeRequestOptions?: TaskChangeRequestOptions;
projectPath?: string;
onEditorAction?: (action: EditorSelectionAction) => void;
}
function isTaskChangeSetV2(cs: { teamName: string }): cs is TaskChangeSetV2 {
return 'scope' in cs;
}
export const ChangeReviewDialog = ({
open,
onOpenChange,
teamName,
mode,
memberName,
taskId,
initialFilePath,
taskChangeRequestOptions,
projectPath,
onEditorAction,
}: ChangeReviewDialogProps): React.ReactElement | null => {
const {
activeChangeSet,
changeSetLoading,
changeSetError,
fetchAgentChanges,
fetchTaskChanges,
clearChangeReviewCache,
hunkDecisions,
fileDecisions,
fileContents,
fileContentsLoading,
collapseUnchanged,
applying,
applyError,
setHunkDecision,
clearHunkDecisionByOriginalIndex,
setCollapseUnchanged,
fetchFileContent,
acceptAllFile,
rejectAllFile,
applyReview,
applySingleFileDecision,
removeReviewFile,
addReviewFile,
clearReviewStateForFile,
editedContents,
updateEditedContent,
discardFileEdits,
saveEditedFile,
reviewExternalChangesByFile,
clearReviewFileExternalChange,
reloadReviewFileFromDisk,
loadDecisionsFromDisk,
persistDecisions,
clearDecisionsFromDisk,
resetAllReviewState,
fileChunkCounts,
pushReviewUndoSnapshot,
undoBulkReview,
reviewUndoStack,
hunkContextHashesByFile,
globalTasks,
} = 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)
const [activeFilePath, setActiveFilePath] = useState<string | null>(null);
const [autoViewed, setAutoViewed] = useState(true);
const [timelineOpen, setTimelineOpen] = useState(false);
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
const [selectionInfo, setSelectionInfo] = useState<EditorSelectionInfo | null>(null);
const [containerRect, setContainerRect] = useState<DOMRect>(new DOMRect());
const diffContentRef = useRef<HTMLDivElement>(null);
const selectionTimerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
const activeSelectionFileRef = useRef<string | null>(null);
// EditorView map for all visible file editors
const editorViewMapRef = useRef(new Map<string, EditorView>());
const scrollContainerRef = useRef<HTMLDivElement>(null);
// Last focused CM editor — for Cmd+Z outside editor
const lastFocusedEditorRef = useRef<EditorView | null>(null);
// Timestamp of last bulk accept/reject-all operation (for Ctrl/Cmd+Z UX)
const lastBulkActionAtRef = useRef<number>(0);
// Track recent per-hunk actions so Ctrl/Cmd+Z can clear persisted decisions (reopen-safe)
const lastHunkActionAtRef = useRef<Record<string, number>>({});
const hunkDecisionUndoStackRef = useRef<Record<string, number[]>>({});
const recentHunkUndoActionsRef = useRef<RecentHunkUndoAction[]>([]);
const lastEditorInteractionAtRef = useRef<Record<string, number>>({});
const newFileApplyInFlightRef = useRef(new Set<string>());
const lastFileActionAtRef = useRef<number>(0);
const removedNewFileUndoStackRef = useRef<
{ 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()) {
if (view.dom.contains(target)) {
return filePath;
}
}
return null;
}, []);
// Keep refs in sync with activeFilePath
useEffect(() => {
activeFilePathRef.current = activeFilePath;
activeEditorViewRef.current = activeFilePath
? (editorViewMapRef.current.get(activeFilePath) ?? null)
: null;
}, [activeFilePath]);
// One-shot scroll-to-file ref (for initialFilePath)
const initialScrollDoneRef = useRef(false);
// Continuous scroll navigation
const { scrollToFile, isProgrammaticScroll } = useContinuousScrollNav({
scrollContainerRef,
});
// Sort files to match the visual order of the file tree (directories first, then alphabetical)
const sortedFiles = useMemo(
() => sortItemsAsTree(activeChangeSet?.files ?? [], (f) => f.relativePath),
[activeChangeSet]
);
const loadingFiles = useMemo(
() => sortedFiles.filter((file) => fileContentsLoading[file.filePath]),
[sortedFiles, fileContentsLoading]
);
const globalDiffLoadingState = useMemo(() => {
if (loadingFiles.length === 0) return null;
const preferredFile =
(activeFilePath
? loadingFiles.find((file) => file.filePath === activeFilePath)
: undefined) ?? loadingFiles[0];
const snippetCount = loadingFiles.reduce(
(sum, file) => sum + file.snippets.filter((snippet) => !snippet.isError).length,
0
);
return {
totalFilesCount: sortedFiles.length,
readyFilesCount: sortedFiles.filter((file) => file.filePath in fileContents).length,
loadingFilesCount: loadingFiles.length,
snippetCount,
activeFileName: preferredFile?.relativePath ?? preferredFile?.filePath,
};
}, [activeFilePath, loadingFiles, sortedFiles, fileContents]);
// File paths for viewed tracking
const allFilePaths = useMemo(() => sortedFiles.map((f) => f.filePath), [sortedFiles]);
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 {
viewedSet,
isViewed,
markViewed,
unmarkViewed,
viewedCount,
totalCount: viewedTotalCount,
progress: viewedProgress,
} = useViewedFiles(teamName, scopeKey, allFilePaths);
const editedCount = Object.keys(editedContents).length;
// Scroll-spy handler
const handleVisibleFileChange = useCallback((filePath: string) => {
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) => {
scrollToFile(filePath);
setActiveFilePath(filePath);
},
[scrollToFile]
);
// Double rAF to ensure DOM/layout is ready before scrolling (reduces nesting in keydown handler)
const scheduleScrollToFile = useCallback(
(path: string) => {
requestAnimationFrame(() => {
requestAnimationFrame(() => scrollToFile(path));
});
},
[scrollToFile]
);
// Accept/Reject all across all files
const handleAcceptAll = useCallback(() => {
if (!activeChangeSet) return;
pushReviewUndoSnapshot();
lastBulkActionAtRef.current = Date.now();
for (const file of activeChangeSet.files) {
acceptAllFile(file.filePath);
}
requestAnimationFrame(() => {
for (const view of editorViewMapRef.current.values()) {
acceptAllChunks(view);
}
});
}, [activeChangeSet, acceptAllFile, pushReviewUndoSnapshot]);
const handleRejectAll = useCallback(() => {
if (!activeChangeSet) return;
pushReviewUndoSnapshot();
lastBulkActionAtRef.current = Date.now();
for (const file of activeChangeSet.files) {
rejectAllFile(file.filePath);
}
requestAnimationFrame(() => {
for (const view of editorViewMapRef.current.values()) {
rejectAllChunks(view);
}
});
if (REVIEW_INSTANT_APPLY) {
// In instant-apply mode we don't show an "Apply" button, so bulk reject must
// be applied immediately to match Cursor-like UX (including deleting new files).
void applyReview(teamName, taskId, memberName);
}
}, [
activeChangeSet,
rejectAllFile,
pushReviewUndoSnapshot,
applyReview,
teamName,
taskId,
memberName,
]);
// File-level accept/reject (Cursor-style)
const handleAcceptFile = useCallback(
(filePath: string) => {
lastFileActionAtRef.current = Date.now();
acceptAllFile(filePath);
const view = editorViewMapRef.current.get(filePath);
if (view) {
requestAnimationFrame(() => acceptAllChunks(view));
}
},
[acceptAllFile]
);
const handleRejectFile = useCallback(
async (filePath: string) => {
if (newFileApplyInFlightRef.current.has(filePath)) return;
newFileApplyInFlightRef.current.add(filePath);
try {
const file = activeChangeSet?.files.find((f) => f.filePath === filePath);
const isNew = file?.isNewFile ?? false;
// Mark rejected in store + update CM view immediately for feedback
lastFileActionAtRef.current = Date.now();
rejectAllFile(filePath);
const view = editorViewMapRef.current.get(filePath);
if (view) {
requestAnimationFrame(() => rejectAllChunks(view));
}
if (REVIEW_INSTANT_APPLY) {
// Reject a whole file should apply immediately (restore original on disk),
// and NEW-file reject should delete it.
const result = await applySingleFileDecision(teamName, filePath, taskId, memberName);
if (isNew) {
const hasErrorForFile = !!result?.errors.some((e) => e.filePath === filePath);
if (result && !hasErrorForFile && file) {
// Keep undo payload so Ctrl/Cmd+Z can restore the file (and re-add it to the review list).
const cachedModified = fileContents[filePath]?.modifiedFullContent;
const restoreContent =
cachedModified ??
(() => {
const writeSnippets = file.snippets.filter(
(s) => !s.isError && (s.type === 'write-new' || s.type === 'write-update')
);
if (writeSnippets.length === 0) return '';
return writeSnippets[writeSnippets.length - 1].newString;
})();
const index = activeChangeSet?.files.findIndex((f) => f.filePath === filePath) ?? 0;
removedNewFileUndoStackRef.current.push({
file,
index: Math.max(0, index),
restoreContent,
removedAt: Date.now(),
});
lastNewFileRemoveAtRef.current = Date.now();
removeReviewFile(filePath);
}
} 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);
setDiscardCounters((prev) => ({ ...prev, [filePath]: (prev[filePath] ?? 0) + 1 }));
void fetchFileContent(teamName, memberName, filePath);
}
}
}
} finally {
newFileApplyInFlightRef.current.delete(filePath);
}
},
[
rejectAllFile,
activeChangeSet,
applySingleFileDecision,
teamName,
taskId,
memberName,
markRecentReviewWrite,
removeReviewFile,
fileContents,
clearReviewStateForFile,
fetchFileContent,
]
);
// Per-file callbacks for ContinuousScrollView
const handleHunkAccepted = useCallback(
(filePath: string, hunkIndex: number) => {
const originalIndex = setHunkDecision(filePath, hunkIndex, 'accepted');
lastHunkActionAtRef.current[filePath] = Date.now();
if (!hunkDecisionUndoStackRef.current[filePath]) {
hunkDecisionUndoStackRef.current[filePath] = [];
}
hunkDecisionUndoStackRef.current[filePath].push(originalIndex);
recentHunkUndoActionsRef.current.push({
filePath,
originalIndex,
at: Date.now(),
});
},
[setHunkDecision]
);
const handleHunkRejected = useCallback(
(filePath: string, hunkIndex: number) => {
const originalIndex = setHunkDecision(filePath, hunkIndex, 'rejected');
lastHunkActionAtRef.current[filePath] = Date.now();
if (!hunkDecisionUndoStackRef.current[filePath]) {
hunkDecisionUndoStackRef.current[filePath] = [];
}
hunkDecisionUndoStackRef.current[filePath].push(originalIndex);
recentHunkUndoActionsRef.current.push({
filePath,
originalIndex,
at: Date.now(),
});
if (REVIEW_INSTANT_APPLY) {
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);
}
});
}
},
[
setHunkDecision,
applySingleFileDecision,
teamName,
taskId,
memberName,
markRecentReviewWrite,
clearReviewStateForFile,
fetchFileContent,
]
);
const handleContentChanged = useCallback(
(filePath: string, content: string) => {
updateEditedContent(filePath, content);
},
[updateEditedContent]
);
const handleFullyViewed = useCallback(
(filePath: string) => {
if (autoViewed && !isViewed(filePath)) {
markViewed(filePath);
}
},
[autoViewed, isViewed, markViewed]
);
const handleSaveFile = useCallback(
async (filePath: string) => {
await saveEditedFile(filePath, projectPath);
if (!useStore.getState().applyError) {
markRecentReviewWrite(filePath);
}
},
[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(async () => {
await saveEditedFile(filePath, projectPath);
if (!useStore.getState().applyError) {
markRecentReviewWrite(filePath);
}
});
},
[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(
(filePath: string) => {
discardFileEdits(filePath);
setDiscardCounters((prev) => ({ ...prev, [filePath]: (prev[filePath] ?? 0) + 1 }));
},
[discardFileEdits]
);
// Undo last bulk review operation (Accept All / Reject All)
const handleUndoBulk = useCallback(() => {
const restored = undoBulkReview();
if (restored && activeChangeSet) {
// Nuclear reset: increment discard counters for all files to force CM remount
setDiscardCounters((prev) => {
const next = { ...prev };
for (const file of activeChangeSet.files) {
next[file.filePath] = (next[file.filePath] ?? 0) + 1;
}
return next;
});
}
}, [undoBulkReview, activeChangeSet]);
// Selection change handler (debounced for non-empty, immediate for clear)
const handleSelectionChange = useCallback((info: EditorSelectionInfo | null) => {
if (!info) {
if (selectionTimerRef.current) clearTimeout(selectionTimerRef.current);
setSelectionInfo(null);
return;
}
activeSelectionFileRef.current = info.filePath;
if (selectionTimerRef.current) clearTimeout(selectionTimerRef.current);
selectionTimerRef.current = setTimeout(() => {
setSelectionInfo(info);
}, SELECTION_DEBOUNCE_MS);
}, []);
// Scroll repositioning — re-query coords when parent scrolls (rAF-throttled)
const hasData = !changeSetLoading && !changeSetError && !!activeChangeSet;
useEffect(() => {
if (!hasData) return;
const container = scrollContainerRef.current;
if (!container) return;
let rafId = 0;
const onScroll = (): void => {
cancelAnimationFrame(rafId);
rafId = requestAnimationFrame(() => {
const fp = activeSelectionFileRef.current;
if (!fp) return;
const view = editorViewMapRef.current.get(fp);
if (!view) return;
const sel = view.state.selection.main;
if (sel.empty) {
setSelectionInfo(null);
return;
}
const info = buildSelectionInfo(view, sel);
if (info) {
setSelectionInfo({ ...info, filePath: fp });
} else {
setSelectionInfo(null);
}
});
};
container.addEventListener('scroll', onScroll, { passive: true });
return () => {
cancelAnimationFrame(rafId);
container.removeEventListener('scroll', onScroll);
};
}, [hasData]);
// Track container rect for menu positioning
useEffect(() => {
const el = diffContentRef.current;
if (!el) return;
const observer = new ResizeObserver(() => {
setContainerRect(el.getBoundingClientRect());
});
observer.observe(el);
setContainerRect(el.getBoundingClientRect());
return () => observer.disconnect();
}, [hasData]);
// Save active file (for Cmd+S keyboard shortcut)
const handleSaveActiveFile = useCallback(() => {
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(
() => ({
editorViewMapRef,
activeFilePath,
scrollToFile,
enabled: true,
}),
[activeFilePath, scrollToFile]
);
const diffNav = useDiffNavigation(
sortedFiles,
activeFilePath,
scrollToFile,
activeEditorViewRef,
open,
handleHunkAccepted,
handleHunkRejected,
() => onOpenChange(false),
handleSaveActiveFile,
continuousOptions,
(filePath, fallbackSnippetsLength) =>
getFileHunkCount(filePath, fallbackSnippetsLength, fileChunkCounts)
);
const reviewHunkOrder = useMemo(() => {
const offsets: Record<string, number> = {};
let total = 0;
for (const file of sortedFiles) {
offsets[file.filePath] = total;
total += getFileHunkCount(file.filePath, file.snippets.length, fileChunkCounts);
}
return { offsets, total };
}, [sortedFiles, 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
useEffect(() => {
if (!open) return;
// Load persisted decisions from disk
void loadDecisionsFromDisk(teamName, decisionScopeKey);
// Fetch changeSet
if (mode === 'agent' && memberName) {
void fetchAgentChanges(teamName, memberName);
} else if (mode === 'task' && taskId) {
void fetchTaskChanges(teamName, taskId, taskChangeRequestOptions ?? {});
}
// On close — clear only volatile cache, keep decisions in store
return () => clearChangeReviewCache();
}, [
open,
mode,
teamName,
memberName,
taskId,
taskChangeRequestOptions,
decisionScopeKey,
fetchAgentChanges,
fetchTaskChanges,
clearChangeReviewCache,
loadDecisionsFromDisk,
]);
// Persist decisions to disk on change (debounced via store action).
// When decisions go from non-empty to empty (e.g. undo to clean state),
// clear the persisted file so stale decisions don't reload on reopen.
const hasDecisions =
Object.keys(hunkDecisions).length > 0 || Object.keys(fileDecisions).length > 0;
const hadDecisionsRef = useRef(false);
useEffect(() => {
if (!open) return;
if (hasDecisions) {
hadDecisionsRef.current = true;
persistDecisions(teamName, decisionScopeKey);
} else if (hadDecisionsRef.current) {
hadDecisionsRef.current = false;
void clearDecisionsFromDisk(teamName, decisionScopeKey);
}
}, [
open,
hasDecisions,
hunkDecisions,
fileDecisions,
teamName,
decisionScopeKey,
persistDecisions,
clearDecisionsFromDisk,
]);
// Reset initial scroll flag when initialFilePath changes
useEffect(() => {
initialScrollDoneRef.current = false;
}, [initialFilePath]);
// Scroll to initialFilePath once data is loaded
useEffect(() => {
if (!activeChangeSet || !initialFilePath || initialScrollDoneRef.current) return;
const hasFile = activeChangeSet.files.some((f) => f.filePath === initialFilePath);
if (!hasFile) return;
initialScrollDoneRef.current = true;
requestAnimationFrame(() => {
requestAnimationFrame(() => scrollToFile(initialFilePath));
});
}, [activeChangeSet, initialFilePath, scrollToFile]);
// Clear selection state on close
useEffect(() => {
if (!open) {
setSelectionInfo(null);
}
}, [open]);
// Cleanup refs/timers on close
useEffect(() => {
if (!open) {
activeSelectionFileRef.current = null;
if (selectionTimerRef.current) clearTimeout(selectionTimerRef.current);
}
}, [open]);
// Escape to close
useEffect(() => {
if (!open) return;
const handler = (e: KeyboardEvent): void => {
if (e.key === 'Escape') onOpenChange(false);
};
document.addEventListener('keydown', handler);
return () => document.removeEventListener('keydown', handler);
}, [open, onOpenChange]);
// Track last focused CM editor for Cmd+Z outside editor
useEffect(() => {
if (!open) return;
const handleFocusIn = (e: FocusEvent): void => {
const target = e.target as Element | null;
if (!target?.closest?.('.cm-editor')) return;
const filePath = getEditorFilePathForTarget(target);
if (!filePath) return;
const view = editorViewMapRef.current.get(filePath);
if (view) {
lastFocusedEditorRef.current = view;
}
};
document.addEventListener('focusin', handleFocusIn);
return () => {
document.removeEventListener('focusin', handleFocusIn);
lastFocusedEditorRef.current = null;
};
}, [open, getEditorFilePathForTarget]);
useEffect(() => {
if (!open) return;
const markEditorInteraction = (target: EventTarget | null): void => {
const element = target instanceof Element ? target : null;
if (!element?.closest?.('.cm-editor')) return;
const filePath = getEditorFilePathForTarget(element);
if (!filePath) return;
lastEditorInteractionAtRef.current[filePath] = Date.now();
};
const handleMouseDown = (e: MouseEvent): void => {
markEditorInteraction(e.target);
};
const handleKeyDown = (e: KeyboardEvent): void => {
markEditorInteraction(e.target);
};
document.addEventListener('mousedown', handleMouseDown, true);
document.addEventListener('keydown', handleKeyDown, true);
return () => {
document.removeEventListener('mousedown', handleMouseDown, true);
document.removeEventListener('keydown', handleKeyDown, true);
};
}, [open, getEditorFilePathForTarget]);
// Cmd+Z: undo in last focused editor, or fall back to bulk review undo
useEffect(() => {
if (!open) return;
const handler = (e: KeyboardEvent): void => {
if ((e.metaKey || e.ctrlKey) && e.code === 'KeyZ' && !e.shiftKey) {
// Don't intercept native undo in input/textarea
const tag = document.activeElement?.tagName;
if (tag === 'INPUT' || tag === 'TEXTAREA') return;
// Prefer bulk undo (Accept All / Reject All) shortly after bulk action,
// even if focus is inside a CM editor (focus often remains there after clicking buttons).
const now = Date.now();
// Undo: rejected NEW file (deleted from disk + removed from review list)
const removedStack = removedNewFileUndoStackRef.current;
const lastHunkAt = Object.values(lastHunkActionAtRef.current).reduce(
(max, v) => Math.max(max, v),
0
);
const lastReviewActionAt = Math.max(
lastBulkActionAtRef.current,
lastHunkAt,
lastFileActionAtRef.current
);
const newFileWasLastAction = lastNewFileRemoveAtRef.current >= lastReviewActionAt;
const isInEditor = !!document.activeElement?.closest('.cm-editor');
const lastViewConnected = !!lastFocusedEditorRef.current?.dom.isConnected;
const shouldPreferEditorUndo = isInEditor && lastViewConnected;
if (newFileWasLastAction && removedStack.length > 0 && !shouldPreferEditorUndo) {
e.preventDefault();
e.stopPropagation();
const snap = removedStack.pop()!;
const restoredContent: FileChangeWithContent = {
...snap.file,
originalFullContent: '',
modifiedFullContent: snap.restoreContent,
contentSource: 'snippet-reconstruction',
};
addReviewFile(snap.file, { index: snap.index, content: restoredContent });
setActiveFilePath(snap.file.filePath);
scheduleScrollToFile(snap.file.filePath);
updateEditedContent(snap.file.filePath, snap.restoreContent);
// Ensure editedContents is set before saveEditedFile reads it.
void Promise.resolve().then(async () => {
await saveEditedFile(snap.file.filePath, projectPath);
if (!useStore.getState().applyError) {
markRecentReviewWrite(snap.file.filePath);
}
});
return;
}
const bulkRecently = now - lastBulkActionAtRef.current < 10_000;
if (bulkRecently && useStore.getState().reviewUndoStack.length > 0) {
e.preventDefault();
e.stopPropagation();
handleUndoBulk();
return;
}
const recentHunkAction =
recentHunkUndoActionsRef.current[recentHunkUndoActionsRef.current.length - 1];
const hunkOutsideEditor =
recentHunkAction &&
!isInEditor &&
now - recentHunkAction.at < 5_000 &&
(lastEditorInteractionAtRef.current[recentHunkAction.filePath] ?? 0) <=
recentHunkAction.at &&
!!editorViewMapRef.current.get(recentHunkAction.filePath)?.dom.isConnected;
if (hunkOutsideEditor) {
const action = recentHunkUndoActionsRef.current.pop()!;
const view = editorViewMapRef.current.get(action.filePath)!;
const fileStack = hunkDecisionUndoStackRef.current[action.filePath];
if (fileStack) {
const stackIndex = fileStack.lastIndexOf(action.originalIndex);
if (stackIndex !== -1) {
fileStack.splice(stackIndex, 1);
}
if (fileStack.length === 0) {
delete hunkDecisionUndoStackRef.current[action.filePath];
}
}
e.preventDefault();
e.stopPropagation();
undo(view);
clearHunkDecisionByOriginalIndex(action.filePath, action.originalIndex);
return;
}
// If the last action was a hunk keep/undo (accept/reject) and we're undoing immediately,
// we must also clear the persisted decision. Otherwise reopening the dialog will replay it.
if (document.activeElement?.closest('.cm-editor')) {
const lastView = lastFocusedEditorRef.current;
const fp = activeFilePathRef.current;
const stack = fp ? hunkDecisionUndoStackRef.current[fp] : undefined;
const lastAt = fp ? (lastHunkActionAtRef.current[fp] ?? 0) : 0;
const hunkRecently = fp ? now - lastAt < 5_000 : false;
if (fp && stack && stack.length > 0 && hunkRecently && lastView?.dom.isConnected) {
e.preventDefault();
e.stopPropagation();
undo(lastView);
const originalIndex = stack.pop()!;
for (let i = recentHunkUndoActionsRef.current.length - 1; i >= 0; i--) {
const action = recentHunkUndoActionsRef.current[i];
if (action.filePath === fp && action.originalIndex === originalIndex) {
recentHunkUndoActionsRef.current.splice(i, 1);
break;
}
}
clearHunkDecisionByOriginalIndex(fp, originalIndex);
return;
}
// Otherwise, let CM handle its own undo
return;
}
// Otherwise try to undo in the last focused CM editor
const lastView = lastFocusedEditorRef.current;
if (lastView?.dom.isConnected) {
e.preventDefault();
e.stopPropagation();
undo(lastView);
}
}
};
document.addEventListener('keydown', handler, true);
return () => document.removeEventListener('keydown', handler, true);
}, [
open,
handleUndoBulk,
clearHunkDecisionByOriginalIndex,
addReviewFile,
updateEditedContent,
saveEditedFile,
projectPath,
scheduleScrollToFile,
]);
// Cmd+N IPC listener (forwarded from main process)
useEffect(() => {
if (!open) return;
const cleanup = window.electronAPI?.review.onCmdN?.(() => {
const fp = activeFilePathRef.current;
if (!fp) return;
const view = editorViewMapRef.current.get(fp);
if (!view) return;
const cursorPos = view.state.selection.main.head;
const idx = computeChunkIndexAtPos(view.state, cursorPos);
handleHunkRejected(fp, idx);
rejectChunk(view);
requestAnimationFrame(() => diffNav.goToNextHunk());
});
return cleanup ?? undefined;
}, [open, diffNav, handleHunkRejected]);
// Compute toolbar stats using actual CM chunk count (not snippet count)
const reviewStats = useMemo(() => {
if (!activeChangeSet) return { pending: 0, accepted: 0, rejected: 0 };
let pending = 0;
let accepted = 0;
let rejected = 0;
for (const file of activeChangeSet.files) {
// File-level decision takes priority (set by Accept All / Reject All)
const fileDec = fileDecisions[file.filePath];
const count = getFileHunkCount(file.filePath, file.snippets.length, fileChunkCounts);
if (fileDec === 'accepted') {
accepted += count;
continue;
}
if (fileDec === 'rejected') {
rejected += count;
continue;
}
for (let i = 0; i < count; i++) {
const key = `${file.filePath}:${i}`;
const decision: HunkDecision = hunkDecisions[key] ?? 'pending';
if (decision === 'pending') pending++;
else if (decision === 'accepted') accepted++;
else if (decision === 'rejected') rejected++;
}
}
return { pending, accepted, rejected };
}, [activeChangeSet, hunkDecisions, fileDecisions, fileChunkCounts]);
const changeStats = useMemo(() => {
if (!activeChangeSet) return { linesAdded: 0, linesRemoved: 0, filesChanged: 0 };
return {
linesAdded: activeChangeSet.totalLinesAdded,
linesRemoved: activeChangeSet.totalLinesRemoved,
filesChanged: activeChangeSet.totalFiles,
};
}, [activeChangeSet]);
const handleApply = useCallback(async () => {
await applyReview(teamName, taskId, memberName);
// Only cleanup if apply succeeded (no error in store)
const state = useStore.getState();
if (!state.applyError) {
void clearDecisionsFromDisk(teamName, decisionScopeKey);
resetAllReviewState();
}
}, [
applyReview,
teamName,
taskId,
memberName,
clearDecisionsFromDisk,
decisionScopeKey,
resetAllReviewState,
]);
// Active file for timeline (derived from scroll-spy)
const activeFile = useMemo(() => {
if (!activeChangeSet || !activeFilePath) return null;
return activeChangeSet.files.find((f) => f.filePath === activeFilePath) ?? null;
}, [activeChangeSet, activeFilePath]);
const title = useMemo(() => {
if (mode === 'agent') return `Changes by ${displayMemberName(memberName ?? 'unknown')}`;
const task = taskId ? globalTasks.find((t) => t.id === taskId) : undefined;
const shortId = task?.displayId ?? taskId?.slice(0, 8) ?? '?';
const subject = task?.subject;
return subject ? `Changes for task #${shortId}${subject}` : `Changes for task #${shortId}`;
}, [mode, memberName, taskId, globalTasks]);
const isMacElectron =
isElectronMode() && window.navigator.userAgent.toLowerCase().includes('mac');
if (!open) return null;
return (
<div className="fixed inset-0 z-50 flex flex-col bg-surface">
{/* Header */}
<div
className="flex items-center justify-between border-b border-border bg-surface-sidebar px-4 py-3"
style={
{
paddingLeft: isMacElectron
? 'var(--macos-traffic-light-padding-left, 72px)'
: undefined,
WebkitAppRegion: isMacElectron ? 'drag' : undefined,
} as React.CSSProperties
}
>
<div className="flex items-center gap-3">
<h2 className="text-sm font-medium text-text">{title}</h2>
{activeChangeSet && (
<ViewedProgressBar
viewed={viewedCount}
total={viewedTotalCount}
progress={viewedProgress}
/>
)}
</div>
<button
onClick={() => onOpenChange(false)}
className="rounded p-1 text-text-muted transition-colors hover:bg-surface-raised hover:text-text"
style={{ WebkitAppRegion: 'no-drag' } as React.CSSProperties}
>
<X className="size-4" />
</button>
</div>
{/* Keyboard shortcuts help */}
<KeyboardShortcutsHelp
open={diffNav.showShortcutsHelp}
onOpenChange={diffNav.setShowShortcutsHelp}
/>
{/* Review toolbar */}
{!changeSetLoading &&
!changeSetError &&
activeChangeSet &&
activeChangeSet.files.length > 0 && (
<ReviewToolbar
stats={reviewStats}
changeStats={changeStats}
collapseUnchanged={collapseUnchanged}
applying={applying}
autoViewed={autoViewed}
onAutoViewedChange={setAutoViewed}
onAcceptAll={handleAcceptAll}
onRejectAll={handleRejectAll}
onApply={handleApply}
onCollapseUnchangedChange={setCollapseUnchanged}
instantApply={REVIEW_INSTANT_APPLY}
editedCount={editedCount}
canUndo={reviewUndoStack.length > 0}
onUndo={handleUndoBulk}
/>
)}
{/* Scope info / warnings + confidence badge */}
{mode === 'task' && activeChangeSet && isTaskChangeSetV2(activeChangeSet) && (
<ScopeWarningBanner
warnings={activeChangeSet.warnings}
confidence={activeChangeSet.scope.confidence}
/>
)}
{/* Apply error */}
{applyError && (
<div className="border-b border-red-500/20 bg-red-500/10 px-4 py-2 text-xs text-red-400">
{applyError}
</div>
)}
{/* Content */}
<div className="flex flex-1 overflow-hidden">
{changeSetLoading && <ChangesLoadingAnimation />}
{changeSetError && (
<div className="flex w-full items-center justify-center text-sm text-red-400">
{changeSetError}
</div>
)}
{!changeSetLoading && !changeSetError && activeChangeSet && (
<>
{/* File tree */}
<div className="w-64 shrink-0 overflow-y-auto border-r border-border bg-surface-sidebar">
<ReviewFileTree
files={activeChangeSet.files}
fileContents={fileContents}
pathChangeLabels={pathChangeLabels}
selectedFilePath={null}
onSelectFile={handleTreeFileClick}
viewedSet={viewedSet}
onMarkViewed={markViewed}
onUnmarkViewed={unmarkViewed}
activeFilePath={activeFilePath ?? undefined}
/>
{/* Edit Timeline for active file */}
{activeFile?.timeline && activeFile.timeline.events.length > 0 && (
<div className="border-t border-border">
<button
onClick={() => setTimelineOpen(!timelineOpen)}
className="flex w-full items-center gap-1.5 px-3 py-2 text-xs text-text-secondary hover:text-text"
>
<Clock className="size-3.5" />
<span>Edit Timeline ({activeFile.timeline.events.length})</span>
<ChevronDown
className={cn(
'ml-auto size-3 transition-transform',
timelineOpen && 'rotate-180'
)}
/>
</button>
{timelineOpen && (
<FileEditTimeline
timeline={activeFile.timeline}
onEventClick={(idx) => diffNav.goToHunk(idx)}
activeSnippetIndex={diffNav.currentHunkIndex}
/>
)}
</div>
)}
</div>
{/* Continuous scroll diff content with selection menu */}
<div
ref={diffContentRef}
className="relative flex min-h-0 flex-1 flex-col overflow-hidden"
>
<ContinuousScrollView
files={sortedFiles}
fileContents={fileContents}
fileContentsLoading={fileContentsLoading}
globalDiffLoadingState={globalDiffLoadingState}
reviewExternalChangesByFile={reviewExternalChangesByFile}
viewedSet={viewedSet}
editedContents={editedContents}
hunkDecisions={hunkDecisions}
fileDecisions={fileDecisions}
hunkContextHashesByFile={hunkContextHashesByFile}
collapseUnchanged={collapseUnchanged}
applying={applying}
autoViewed={autoViewed}
discardCounters={discardCounters}
onHunkAccepted={handleHunkAccepted}
onHunkRejected={handleHunkRejected}
onFullyViewed={handleFullyViewed}
onContentChanged={handleContentChanged}
onDiscard={handleDiscardFile}
onSave={handleSaveFile}
onReloadFromDisk={handleReloadFromDisk}
onKeepDraft={handleKeepDraft}
onAcceptFile={handleAcceptFile}
onRejectFile={handleRejectFile}
onRestoreMissingFile={handleRestoreMissingFile}
pathChangeLabels={pathChangeLabels}
collapsedFiles={collapsedFiles}
onToggleCollapse={toggleCollapsedFile}
onVisibleFileChange={handleVisibleFileChange}
scrollContainerRef={scrollContainerRef}
editorViewMapRef={editorViewMapRef}
isProgrammaticScroll={isProgrammaticScroll}
teamName={teamName}
memberName={memberName}
fetchFileContent={fetchFileContent}
onSelectionChange={onEditorAction ? handleSelectionChange : undefined}
globalHunkOffsets={reviewHunkOrder.offsets}
totalReviewHunks={reviewHunkOrder.total}
/>
{selectionInfo && onEditorAction && (
<EditorSelectionMenu
info={selectionInfo}
containerRect={containerRect}
onSendMessage={() => {
onEditorAction(buildSelectionAction('sendMessage', selectionInfo));
setSelectionInfo(null);
}}
onCreateTask={() => {
onEditorAction(buildSelectionAction('createTask', selectionInfo));
setSelectionInfo(null);
}}
/>
)}
</div>
</>
)}
{!changeSetLoading && !changeSetError && activeChangeSet?.files.length === 0 && (
<div className="flex w-full items-center justify-center text-sm text-text-muted">
No file changes detected
</div>
)}
</div>
</div>
);
};