From debb2abbd37abb5a51f74ed1d7ebdc4cd5e138c1 Mon Sep 17 00:00:00 2001 From: Jakob Stender Guldberg Date: Mon, 5 Oct 2026 10:47:24 +0200 Subject: [PATCH] feat(ui): per-file review tick-off Each file in a flow group or the Ungrouped bucket gets its own reviewed checkbox. `x` now toggles the selected file and `X` toggles its whole group; a group is ticked once all of its files are, and ticking a group ticks every file in it. The Ungrouped bucket can be ticked like a flow group and counts toward the "n/m reviewed" total. Reviewed files are dimmed in the collapsed group rail. File ticks reset wherever group ticks reset (new analysis, refinement applied, manifest import). --- crates/diffcore-tauri/ui/src/App.tsx | 138 +++++++++++++++--- crates/diffcore-tauri/ui/src/styles.css | 38 +++++ .../ui/tests/e2e/bugfixes.spec.ts | 72 ++++++++- 3 files changed, 226 insertions(+), 22 deletions(-) diff --git a/crates/diffcore-tauri/ui/src/App.tsx b/crates/diffcore-tauri/ui/src/App.tsx index 14532f4..fdef03d 100644 --- a/crates/diffcore-tauri/ui/src/App.tsx +++ b/crates/diffcore-tauri/ui/src/App.tsx @@ -470,6 +470,7 @@ export default function App() { // Flow review tick-off state (session-only) const [reviewedGroupIds, setReviewedGroupIds] = useState>(new Set()); + const [reviewedFilePaths, setReviewedFilePaths] = useState>(new Set()); // Empty groups that the user explicitly dismissed (session-only). const [dismissedEmptyGroupIds, setDismissedEmptyGroupIds] = useState>(new Set()); @@ -640,6 +641,7 @@ export default function App() { const selectedGroupRef = useRef(selectedGroup); const selectedFileRef = useRef(selectedFile); const sortedGroupsRef = useRef([]); + const reviewGroupsRef = useRef<{ id: string; files: string[] }[]>([]); const replayActiveRef = useRef(replayActive); const replayStepRef = useRef(replayStep); selectedGroupRef.current = selectedGroup; @@ -1224,6 +1226,7 @@ export default function App() { setNewCommitsAvailable(false); // Reset review tick-off state setReviewedGroupIds(new Set()); + setReviewedFilePaths(new Set()); setDismissedEmptyGroupIds(new Set()); // Reset infrastructure group state setInfraExpanded(false); @@ -1537,6 +1540,7 @@ export default function App() { : prev, ); setReviewedGroupIds(new Set()); + setReviewedFilePaths(new Set()); if (opts?.fromCache) { showToast("Restored cached refinement"); } else { @@ -1771,7 +1775,7 @@ export default function App() { }, setError: (msg: string | null) => setError(msg), showNewCommits: (v: boolean) => setNewCommitsAvailable(v), - clearAnalysis: () => { setAnalysis(null); setSelectedGroup(null); setSelectedFile(null); setFileDiff(null); setOverview(null); setDeepAnalyses({}); setOriginalGroups(null); setRefinedGroups(null); setRefinementResponse(null); setRefinementProvider(null); setRefinementModel(null); setRefinementHadChanges(null); setShowRefined(false); refinementApplied.current = false; setReviewedGroupIds(new Set()); setComments([]); setCommentInput(null); setCommentText(""); setRightPanelTab("annotations"); setSourceFocusRequest(null); setActivityJob(null); setActivityEntries([]); setActivityError(null); setActivityViewMode("stream"); setInspectedActivityId(null); }, + clearAnalysis: () => { setAnalysis(null); setSelectedGroup(null); setSelectedFile(null); setFileDiff(null); setOverview(null); setDeepAnalyses({}); setOriginalGroups(null); setRefinedGroups(null); setRefinementResponse(null); setRefinementProvider(null); setRefinementModel(null); setRefinementHadChanges(null); setShowRefined(false); refinementApplied.current = false; setReviewedGroupIds(new Set()); setReviewedFilePaths(new Set()); setComments([]); setCommentInput(null); setCommentText(""); setRightPanelTab("annotations"); setSourceFocusRequest(null); setActivityJob(null); setActivityEntries([]); setActivityError(null); setActivityViewMode("stream"); setInspectedActivityId(null); }, openAiSetup: (step: OnboardingStep = "recommended") => openAiSetup(step), dismissAiSetup: () => dismissAiSetup(), getAiSetupState: () => ({ open: aiSetupOpen, step: aiSetupStep }), @@ -1780,6 +1784,8 @@ export default function App() { getReplayState: () => ({ active: replayActive, step: replayStep, visited: Array.from(replayVisited) }), toggleGroupReviewed: (id: string) => toggleGroupReviewed(id), getReviewedGroupIds: () => Array.from(reviewedGroupIds), + toggleFileReviewed: (path: string) => toggleFileReviewed(path), + getReviewedFilePaths: () => Array.from(reviewedFilePaths), getActivityEntries: () => activityEntries, getActivityJob: () => activityJob, crashPanel: (name: string | null) => setCrashPanel(name), @@ -2029,6 +2035,15 @@ export default function App() { [sortedGroups, dismissedEmptyGroupIds], ); + // Everything that can be ticked off: flow groups plus the Ungrouped bucket (id "infra"). + const reviewGroups = useMemo(() => { + const groups = sortedGroups.map((g) => ({ id: g.id, files: g.files.map((f) => f.path) })); + const infra = analysis?.infrastructure_group; + if (infra && infra.files.length > 0) groups.push({ id: "infra", files: infra.files }); + return groups; + }, [sortedGroups, analysis]); + reviewGroupsRef.current = reviewGroups; + // Get the Pass 2 deep analysis for the currently selected group const groupDeepAnalysis: Pass2Response | undefined = selectedGroup ? deepAnalyses[selectedGroup.id] @@ -2174,18 +2189,54 @@ export default function App() { openAiSetup("api"); }, [llmSettings, openAiSetup]); - /** Toggle reviewed state for a flow group. */ + /** Toggle reviewed state for a flow group; all its files follow. */ const toggleGroupReviewed = useCallback((groupId: string) => { + const nowReviewed = !reviewedGroupIds.has(groupId); + const nextGroups = new Set(reviewedGroupIds); + if (nowReviewed) { + nextGroups.add(groupId); + } else { + nextGroups.delete(groupId); + } + setReviewedGroupIds(nextGroups); + const group = reviewGroupsRef.current.find((g) => g.id === groupId); + if (group) { + const nextFiles = new Set(reviewedFilePaths); + for (const path of group.files) { + if (nowReviewed) { + nextFiles.add(path); + } else { + nextFiles.delete(path); + } + } + setReviewedFilePaths(nextFiles); + } + }, [reviewedGroupIds, reviewedFilePaths]); + + /** Toggle reviewed state for a single file; the owning group follows once all its files are reviewed. */ + const toggleFileReviewed = useCallback((path: string) => { + const nowReviewed = !reviewedFilePaths.has(path); + const nextFiles = new Set(reviewedFilePaths); + if (nowReviewed) { + nextFiles.add(path); + } else { + nextFiles.delete(path); + } + setReviewedFilePaths(nextFiles); + const owner = reviewGroupsRef.current.find((g) => g.files.includes(path)); + if (!owner) return; + const allReviewed = owner.files.every((p) => nextFiles.has(p)); setReviewedGroupIds((prev) => { + if (allReviewed === prev.has(owner.id)) return prev; const next = new Set(prev); - if (next.has(groupId)) { - next.delete(groupId); + if (allReviewed) { + next.add(owner.id); } else { - next.add(groupId); + next.delete(owner.id); } return next; }); - }, []); + }, [reviewedFilePaths]); /** Build the absolute file path from repo path + relative path. */ const buildAbsolutePath = useCallback( @@ -2483,6 +2534,7 @@ export default function App() { setOriginalGroups(null); setShowRefined(false); setReviewedGroupIds(new Set()); + setReviewedFilePaths(new Set()); if (updated.groups.length > 0) { const sorted = [...updated.groups].sort((a, b) => a.review_order - b.review_order); handleSelectGroup(sorted[0]); @@ -2790,7 +2842,7 @@ export default function App() { // When Monaco has focus, only intercept known app shortcut keys. // Let other keys (arrows, Page Up/Down, etc.) pass through to Monaco for scrolling. if (isInMonaco) { - const appKeys = new Set(["j", "k", "J", "K", "r", "x", "y", "Y", "c", "C"]); + const appKeys = new Set(["j", "k", "J", "K", "r", "x", "X", "y", "Y", "c", "C"]); if (!appKeys.has(e.key)) { return; } @@ -2849,10 +2901,19 @@ export default function App() { return; } - // x toggles reviewed state on the currently selected group - if (e.key === "x" && !e.metaKey && !e.ctrlKey && group) { + // x toggles reviewed state on the currently selected file + if (e.key === "x" && !e.metaKey && !e.ctrlKey && file) { + consume(); + toggleFileReviewed(file); + return; + } + + // X (shift+x) toggles reviewed state on the currently selected group + if (e.key === "X" && !e.metaKey && !e.ctrlKey && (group || file)) { consume(); - toggleGroupReviewed(group.id); + const owner = file ? reviewGroupsRef.current.find((g) => g.files.includes(file)) : undefined; + const targetId = owner?.id ?? group?.id; + if (targetId) toggleGroupReviewed(targetId); return; } @@ -2959,7 +3020,7 @@ export default function App() { window.addEventListener("keydown", handleKeyDown, true); return () => window.removeEventListener("keydown", handleKeyDown, true); - }, [handleSelectFile, handleSelectFileDebounced, handleSelectGroup, enterReplay, exitReplay, goToReplayStep, toggleGroupReviewed, copyFilePath, copyFlowPaths, openCommentInput, exportComments, closeTab]); + }, [handleSelectFile, handleSelectFileDebounced, handleSelectGroup, enterReplay, exitReplay, goToReplayStep, toggleGroupReviewed, toggleFileReviewed, copyFilePath, copyFlowPaths, openCommentInput, exportComments, closeTab]); const handleSelectBase = useCallback((branch: string) => { refsPinned.current = true; @@ -3786,7 +3847,7 @@ export default function App() { {analysis.summary.total_groups} groups {reviewedGroupIds.size > 0 && ( - {" "}· {reviewedGroupIds.size}/{sortedGroups.length} reviewed + {" "}· {reviewedGroupIds.size}/{reviewGroups.length} reviewed )} @@ -4395,7 +4456,7 @@ export default function App() { {group.files.map((file) => (