From b0fd582c8e36bbb82aadab78d4acec0f55e7930c Mon Sep 17 00:00:00 2001 From: Matt Pua Date: Tue, 28 Jul 2026 16:12:46 -0400 Subject: [PATCH 1/2] feat: filter viewed code review files Generated-By: PostHog Code Task-Id: f774fb55-18e5-4248-acb9-3f9bf3e7d3ee --- .../components/DiffSettingsMenu.tsx | 9 +++++ .../code-review/components/ReviewShell.tsx | 36 ++++++++++++++++--- .../code-review/components/ReviewToolbar.tsx | 29 ++++++++++----- .../code-review/reviewNavigationStore.test.ts | 15 ++++++++ .../code-review/reviewNavigationStore.ts | 13 +++++++ .../code-review/reviewShellParts.test.tsx | 31 ++++++++++++++++ .../features/code-review/reviewShellParts.tsx | 32 +++++++++++++++++ 7 files changed, 152 insertions(+), 13 deletions(-) diff --git a/packages/ui/src/features/code-review/components/DiffSettingsMenu.tsx b/packages/ui/src/features/code-review/components/DiffSettingsMenu.tsx index d4eb4f2590..bf7440a4a4 100644 --- a/packages/ui/src/features/code-review/components/DiffSettingsMenu.tsx +++ b/packages/ui/src/features/code-review/components/DiffSettingsMenu.tsx @@ -16,6 +16,8 @@ interface DiffSettingsMenuProps { unresolvedCommentedFileCount: number; commentFilter: CommentFileFilter; onCommentFilterChange?: (filter: CommentFileFilter) => void; + hideViewedFiles: boolean; + onHideViewedFilesChange: (hideViewed: boolean) => void; } export function DiffSettingsMenu({ @@ -23,6 +25,8 @@ export function DiffSettingsMenu({ unresolvedCommentedFileCount, commentFilter, onCommentFilterChange, + hideViewedFiles, + onHideViewedFilesChange, }: DiffSettingsMenuProps) { const wordWrap = useDiffViewerStore((s) => s.wordWrap); const toggleWordWrap = useDiffViewerStore((s) => s.toggleWordWrap); @@ -79,6 +83,11 @@ export function DiffSettingsMenu({ {hideWhitespaceChanges ? "Show whitespace" : "Hide whitespace"} + onHideViewedFilesChange(!hideViewedFiles)} + > + {hideViewedFiles ? "Show viewed files" : "Hide viewed files"} + {showReviewComments ? "Hide review comments" : "Show review comments"} diff --git a/packages/ui/src/features/code-review/components/ReviewShell.tsx b/packages/ui/src/features/code-review/components/ReviewShell.tsx index b5eb016370..e3133d04be 100644 --- a/packages/ui/src/features/code-review/components/ReviewShell.tsx +++ b/packages/ui/src/features/code-review/components/ReviewShell.tsx @@ -29,6 +29,7 @@ import { useReviewNavigationStore } from "../reviewNavigationStore"; import type { ReviewShellProps } from "../reviewShellParts"; import { buildItemIndex, + filterReviewItemsByViewedState, findActiveScrollKey, findRenderedScrollAnchor, isFileViewed, @@ -155,6 +156,12 @@ export function ReviewShell({ const setCommentFileFilter = useReviewNavigationStore( (state) => state.setCommentFileFilter, ); + const hideViewedFiles = useReviewNavigationStore( + (state) => state.hideViewedFiles[taskId] ?? false, + ); + const setHideViewedFiles = useReviewNavigationStore( + (state) => state.setHideViewedFiles, + ); const { activeFilter: activeCommentFilter, visibleItems, @@ -170,9 +177,21 @@ export function ReviewShell({ }), [commentFilter, commentedFilePaths, items, unresolvedCommentedFilePaths], ); + const filteredItems = useMemo(() => { + if (!hideViewedFiles) return visibleItems; + return filterReviewItemsByViewedState( + visibleItems, + currentSignatures, + viewedRecord, + ); + }, [currentSignatures, hideViewedFiles, viewedRecord, visibleItems]); + const filteredFileCount = useMemo( + () => filteredItems.filter((item) => item.filePaths).length, + [filteredItems], + ); const visibleItemIndexByFilePath = useMemo( - () => buildItemIndex(visibleItems), - [visibleItems], + () => buildItemIndex(filteredItems), + [filteredItems], ); const workerFactory = useCallback( @@ -348,11 +367,13 @@ export function ReviewShell({ ); - } else if (isEmpty || visibleItems.length === 0) { + } else if (isEmpty || filteredItems.length === 0) { reviewContent = ( - {getEmptyReviewMessage(activeCommentFilter)} + {hideViewedFiles + ? "No unviewed file changes" + : getEmptyReviewMessage(activeCommentFilter)} ); @@ -366,7 +387,7 @@ export function ReviewShell({ shift={false} style={{ scrollbarGutter: "stable" }} onScroll={handleScroll} - data={visibleItems} + data={filteredItems} > {renderItem} @@ -413,6 +434,11 @@ export function ReviewShell({ ? (filter) => setCommentFileFilter(taskId, filter) : undefined } + hideViewedFiles={hideViewedFiles} + filteredFileCount={filteredFileCount} + onHideViewedFilesChange={(hideViewed) => + setHideViewedFiles(taskId, hideViewed) + } linesAdded={linesAdded} linesRemoved={linesRemoved} allExpanded={allExpanded} diff --git a/packages/ui/src/features/code-review/components/ReviewToolbar.tsx b/packages/ui/src/features/code-review/components/ReviewToolbar.tsx index 5e8ca2ec16..de37aac1b9 100644 --- a/packages/ui/src/features/code-review/components/ReviewToolbar.tsx +++ b/packages/ui/src/features/code-review/components/ReviewToolbar.tsx @@ -28,6 +28,9 @@ interface ReviewToolbarProps { unresolvedCommentedFileCount: number; commentFilter: CommentFileFilter; onCommentFilterChange?: (filter: CommentFileFilter) => void; + hideViewedFiles: boolean; + filteredFileCount: number; + onHideViewedFilesChange: (hideViewed: boolean) => void; linesAdded: number; linesRemoved: number; allExpanded: boolean; @@ -82,6 +85,9 @@ export const ReviewToolbar = memo(function ReviewToolbar({ unresolvedCommentedFileCount, commentFilter, onCommentFilterChange, + hideViewedFiles, + filteredFileCount, + onHideViewedFilesChange, allExpanded, onExpandAll, onCollapseAll, @@ -108,13 +114,18 @@ export const ReviewToolbar = memo(function ReviewToolbar({ setReviewMode(taskId, "closed"); }; - const { count: visibleFileCount, label: fileCountLabel } = - getVisibleFileSummary( - commentFilter, - fileCount, - commentedFileCount, - unresolvedCommentedFileCount, - ); + const visibleFileSummary = getVisibleFileSummary( + commentFilter, + fileCount, + commentedFileCount, + unresolvedCommentedFileCount, + ); + const visibleFileCount = hideViewedFiles + ? filteredFileCount + : visibleFileSummary.count; + const fileCountLabel = hideViewedFiles + ? formatFileCount(filteredFileCount, "not viewed") + : visibleFileSummary.label; return ( {fileCountLabel} - {visibleFileCount > 0 && ( + {!hideViewedFiles && visibleFileCount > 0 && ( {viewedCount}/{visibleFileCount} viewed @@ -216,6 +227,8 @@ export const ReviewToolbar = memo(function ReviewToolbar({ unresolvedCommentedFileCount={unresolvedCommentedFileCount} commentFilter={commentFilter} onCommentFilterChange={onCommentFilterChange} + hideViewedFiles={hideViewedFiles} + onHideViewedFilesChange={onHideViewedFilesChange} /> diff --git a/packages/ui/src/features/code-review/reviewNavigationStore.test.ts b/packages/ui/src/features/code-review/reviewNavigationStore.test.ts index f6fc0dd004..55e9e17b9f 100644 --- a/packages/ui/src/features/code-review/reviewNavigationStore.test.ts +++ b/packages/ui/src/features/code-review/reviewNavigationStore.test.ts @@ -9,6 +9,7 @@ describe("reviewNavigationStore", () => { reviewModes: {}, selectedPrUrls: {}, commentFileFilters: {}, + hideViewedFiles: {}, }); }); @@ -28,6 +29,20 @@ describe("reviewNavigationStore", () => { ).toBeUndefined(); }); + it("stores and clears the viewed-file filter per task", () => { + const store = useReviewNavigationStore.getState(); + store.setHideViewedFiles("task-1", true); + + expect(useReviewNavigationStore.getState().hideViewedFiles["task-1"]).toBe( + true, + ); + + store.clearTask("task-1"); + expect(useReviewNavigationStore.getState().hideViewedFiles["task-1"]).toBe( + false, + ); + }); + it("clears the comment filter when navigating to a file", () => { const store = useReviewNavigationStore.getState(); store.setCommentFileFilter("task-1", "unresolved"); diff --git a/packages/ui/src/features/code-review/reviewNavigationStore.ts b/packages/ui/src/features/code-review/reviewNavigationStore.ts index dba01207ce..59670facee 100644 --- a/packages/ui/src/features/code-review/reviewNavigationStore.ts +++ b/packages/ui/src/features/code-review/reviewNavigationStore.ts @@ -9,6 +9,7 @@ interface ReviewNavigationStoreState { reviewModes: Record; selectedPrUrls: Record; commentFileFilters: Record; + hideViewedFiles: Record; } interface ReviewNavigationStoreActions { @@ -19,6 +20,7 @@ interface ReviewNavigationStoreActions { setReviewMode: (taskId: string, mode: ReviewMode) => void; setSelectedPrUrl: (taskId: string, url: string) => void; setCommentFileFilter: (taskId: string, filter: CommentFileFilter) => void; + setHideViewedFiles: (taskId: string, hideViewed: boolean) => void; getReviewMode: (taskId: string) => ReviewMode; } @@ -32,6 +34,7 @@ export const useReviewNavigationStore = create()( reviewModes: {}, selectedPrUrls: {}, commentFileFilters: {}, + hideViewedFiles: {}, setActiveFilePath: (taskId, path) => set((state) => ({ @@ -45,6 +48,7 @@ export const useReviewNavigationStore = create()( ...state.commentFileFilters, [taskId]: "none", }, + hideViewedFiles: { ...state.hideViewedFiles, [taskId]: false }, })), clearScrollRequest: (taskId) => @@ -61,6 +65,7 @@ export const useReviewNavigationStore = create()( [taskId]: "none", }, selectedPrUrls: { ...state.selectedPrUrls, [taskId]: undefined }, + hideViewedFiles: { ...state.hideViewedFiles, [taskId]: false }, })), setReviewMode: (taskId, mode) => @@ -87,6 +92,14 @@ export const useReviewNavigationStore = create()( }, })), + setHideViewedFiles: (taskId, hideViewed) => + set((state) => ({ + hideViewedFiles: { + ...state.hideViewedFiles, + [taskId]: hideViewed, + }, + })), + getReviewMode: (taskId) => get().reviewModes[taskId] ?? "closed", }), ); diff --git a/packages/ui/src/features/code-review/reviewShellParts.test.tsx b/packages/ui/src/features/code-review/reviewShellParts.test.tsx index 9fce3a6bfe..7a9dd9aa59 100644 --- a/packages/ui/src/features/code-review/reviewShellParts.test.tsx +++ b/packages/ui/src/features/code-review/reviewShellParts.test.tsx @@ -23,6 +23,7 @@ import { import { DeferredDiffPlaceholder, DiffFileHeader, + filterReviewItemsByViewedState, findActiveScrollKey, findRenderedScrollAnchor, } from "./reviewShellParts"; @@ -169,6 +170,36 @@ describe("review scroll anchors", () => { }); describe("commented file filtering", () => { + it("keeps only unviewed files and their section headers", () => { + const items: ReviewListItem[] = [ + { key: "section:staged", node: Staged }, + { + key: "staged:a.ts", + scrollKey: "staged:a.ts", + filePaths: ["a.ts"], + node: A, + }, + { key: "section:changes", node: Changes }, + { + key: "unstaged:b.ts", + scrollKey: "unstaged:b.ts", + filePaths: ["b.ts"], + node: B, + }, + ]; + + expect( + filterReviewItemsByViewedState( + items, + new Map([ + ["staged:a.ts", "signature-a"], + ["unstaged:b.ts", "signature-b"], + ]), + { "staged:a.ts": "signature-a" }, + ).map((item) => item.key), + ).toEqual(["section:changes", "unstaged:b.ts"]); + }); + it("collects paths for all and unresolved comment threads", () => { const commentedPaths = getCommentedFilePaths( new Map([ diff --git a/packages/ui/src/features/code-review/reviewShellParts.tsx b/packages/ui/src/features/code-review/reviewShellParts.tsx index 4f9da2ab26..5cb02177fe 100644 --- a/packages/ui/src/features/code-review/reviewShellParts.tsx +++ b/packages/ui/src/features/code-review/reviewShellParts.tsx @@ -301,6 +301,38 @@ export function isFileViewed( return storedSig === currentSig; } +export function filterReviewItemsByViewedState( + items: ReviewListItem[], + currentSignatures: ReadonlyMap, + viewedRecord: Readonly>, +): ReviewListItem[] { + const filteredItems: ReviewListItem[] = []; + let pendingSectionItems: ReviewListItem[] = []; + + for (const item of items) { + if (!item.filePaths) { + pendingSectionItems = [item]; + continue; + } + + const signature = item.scrollKey + ? currentSignatures.get(item.scrollKey) + : undefined; + if ( + signature !== undefined && + item.scrollKey !== undefined && + isFileViewed(viewedRecord[item.scrollKey], signature) + ) { + continue; + } + + filteredItems.push(...pendingSectionItems, item); + pendingSectionItems = []; + } + + return filteredItems; +} + function ViewedCheckbox({ viewedKey }: { viewedKey: string }) { const ctx = useReviewViewedContext(); if (!ctx) return null; From 7dc619dd0147fc3aa4e1083b3e3289c5c5cf4eb7 Mon Sep 17 00:00:00 2001 From: Matt Pua Date: Tue, 28 Jul 2026 16:12:47 -0400 Subject: [PATCH 2/2] fix: sync active file after review filtering Generated-By: PostHog Code Task-Id: f774fb55-18e5-4248-acb9-3f9bf3e7d3ee --- .../features/code-review/commentFileFilter.ts | 46 +++++++++++++++++++ .../code-review/components/ReviewShell.tsx | 23 +++++++++- .../code-review/reviewShellParts.test.tsx | 19 +++++++- .../features/code-review/reviewShellParts.tsx | 32 ------------- 4 files changed, 86 insertions(+), 34 deletions(-) diff --git a/packages/ui/src/features/code-review/commentFileFilter.ts b/packages/ui/src/features/code-review/commentFileFilter.ts index 4ea3a8f0a5..becf411f48 100644 --- a/packages/ui/src/features/code-review/commentFileFilter.ts +++ b/packages/ui/src/features/code-review/commentFileFilter.ts @@ -62,6 +62,52 @@ export function filterReviewItemsByFilePaths( return filteredItems; } +export function filterReviewItemsByViewedState( + items: ReviewListItem[], + currentSignatures: ReadonlyMap, + viewedRecord: Readonly>, +): ReviewListItem[] { + const filteredItems: ReviewListItem[] = []; + let pendingSectionItems: ReviewListItem[] = []; + + for (const item of items) { + if (!item.filePaths) { + pendingSectionItems = [item]; + continue; + } + + const signature = item.scrollKey + ? currentSignatures.get(item.scrollKey) + : undefined; + if ( + signature !== undefined && + item.scrollKey !== undefined && + viewedRecord[item.scrollKey] === signature + ) { + continue; + } + + filteredItems.push(...pendingSectionItems, item); + pendingSectionItems = []; + } + + return filteredItems; +} + +export function resolveVisibleActiveFilePath( + items: ReviewListItem[], + activeFilePath: string | null, +): string | null { + if ( + activeFilePath && + items.some((item) => item.filePaths?.includes(activeFilePath)) + ) { + return activeFilePath; + } + + return items.find((item) => item.scrollKey)?.scrollKey ?? null; +} + export function deriveCommentFileFilterState({ items, requestedFilter, diff --git a/packages/ui/src/features/code-review/components/ReviewShell.tsx b/packages/ui/src/features/code-review/components/ReviewShell.tsx index e3133d04be..3ee8c28b53 100644 --- a/packages/ui/src/features/code-review/components/ReviewShell.tsx +++ b/packages/ui/src/features/code-review/components/ReviewShell.tsx @@ -16,8 +16,10 @@ import { import { VList, type VListHandle } from "virtua"; import { deriveCommentFileFilterState, + filterReviewItemsByViewedState, getEmptyReviewMessage, type ReviewListItem, + resolveVisibleActiveFilePath, } from "../commentFileFilter"; import { REVIEW_LIST_BUFFER_PX, @@ -29,7 +31,6 @@ import { useReviewNavigationStore } from "../reviewNavigationStore"; import type { ReviewShellProps } from "../reviewShellParts"; import { buildItemIndex, - filterReviewItemsByViewedState, findActiveScrollKey, findRenderedScrollAnchor, isFileViewed, @@ -272,8 +273,28 @@ export function ReviewShell({ const setActiveFilePath = useReviewNavigationStore( (s) => s.setActiveFilePath, ); + const activeFilePath = useReviewNavigationStore( + (s) => s.activeFilePaths[taskId] ?? null, + ); const clearTask = useReviewNavigationStore((s) => s.clearTask); + useEffect(() => { + if (!hideViewedFiles || !activeFilePath) return; + const nextActiveFilePath = resolveVisibleActiveFilePath( + filteredItems, + activeFilePath, + ); + if (nextActiveFilePath === activeFilePath) return; + lastActiveRef.current = nextActiveFilePath; + setActiveFilePath(taskId, nextActiveFilePath); + }, [ + activeFilePath, + filteredItems, + hideViewedFiles, + setActiveFilePath, + taskId, + ]); + useEffect(() => { return () => { if (navigationFrameRef.current !== null) { diff --git a/packages/ui/src/features/code-review/reviewShellParts.test.tsx b/packages/ui/src/features/code-review/reviewShellParts.test.tsx index 7a9dd9aa59..7ee0f9ae3b 100644 --- a/packages/ui/src/features/code-review/reviewShellParts.test.tsx +++ b/packages/ui/src/features/code-review/reviewShellParts.test.tsx @@ -17,13 +17,14 @@ vi.mock("../../primitives/FileIcon", () => ({ import { deriveCommentFileFilterState, filterReviewItemsByFilePaths, + filterReviewItemsByViewedState, getCommentedFilePaths, type ReviewListItem, + resolveVisibleActiveFilePath, } from "./commentFileFilter"; import { DeferredDiffPlaceholder, DiffFileHeader, - filterReviewItemsByViewedState, findActiveScrollKey, findRenderedScrollAnchor, } from "./reviewShellParts"; @@ -200,6 +201,22 @@ describe("commented file filtering", () => { ).toEqual(["section:changes", "unstaged:b.ts"]); }); + it("selects the first visible file when the active file is filtered out", () => { + const items: ReviewListItem[] = [ + { key: "section:changes", node: Changes }, + { + key: "b.ts", + scrollKey: "b.ts", + filePaths: ["b.ts", "old-b.ts"], + node: B, + }, + ]; + + expect(resolveVisibleActiveFilePath(items, "a.ts")).toBe("b.ts"); + expect(resolveVisibleActiveFilePath(items, "old-b.ts")).toBe("old-b.ts"); + expect(resolveVisibleActiveFilePath([], "a.ts")).toBeNull(); + }); + it("collects paths for all and unresolved comment threads", () => { const commentedPaths = getCommentedFilePaths( new Map([ diff --git a/packages/ui/src/features/code-review/reviewShellParts.tsx b/packages/ui/src/features/code-review/reviewShellParts.tsx index 5cb02177fe..4f9da2ab26 100644 --- a/packages/ui/src/features/code-review/reviewShellParts.tsx +++ b/packages/ui/src/features/code-review/reviewShellParts.tsx @@ -301,38 +301,6 @@ export function isFileViewed( return storedSig === currentSig; } -export function filterReviewItemsByViewedState( - items: ReviewListItem[], - currentSignatures: ReadonlyMap, - viewedRecord: Readonly>, -): ReviewListItem[] { - const filteredItems: ReviewListItem[] = []; - let pendingSectionItems: ReviewListItem[] = []; - - for (const item of items) { - if (!item.filePaths) { - pendingSectionItems = [item]; - continue; - } - - const signature = item.scrollKey - ? currentSignatures.get(item.scrollKey) - : undefined; - if ( - signature !== undefined && - item.scrollKey !== undefined && - isFileViewed(viewedRecord[item.scrollKey], signature) - ) { - continue; - } - - filteredItems.push(...pendingSectionItems, item); - pendingSectionItems = []; - } - - return filteredItems; -} - function ViewedCheckbox({ viewedKey }: { viewedKey: string }) { const ctx = useReviewViewedContext(); if (!ctx) return null;