diff --git a/apps/web/src/components/DiffPanel.tsx b/apps/web/src/components/DiffPanel.tsx
index d10cb39f0e36..62f187862235 100644
--- a/apps/web/src/components/DiffPanel.tsx
+++ b/apps/web/src/components/DiffPanel.tsx
@@ -26,6 +26,7 @@ import { openDiffFilePrimaryAction } from "../diffFileActions";
import { useCheckpointDiff } from "~/lib/checkpointDiffState";
import { cn } from "~/lib/utils";
import { selectThreadDiffPanelSelection, useDiffPanelStore } from "../diffPanelStore";
+import { selectViewedFileKeys, useDiffViewedStore } from "../diffViewedStore";
import { useTheme } from "../hooks/useTheme";
import {
buildFileDiffRenderKey,
@@ -47,6 +48,7 @@ import { AnnotatableCodeView, type AnnotatableCodeViewHandle } from "./diffs/Ann
import { Button } from "./ui/button";
import { ToggleGroup, Toggle } from "./ui/toggle-group";
import { Switch } from "./ui/switch";
+import { Checkbox } from "./ui/checkbox";
import {
Combobox,
ComboboxEmpty,
@@ -302,6 +304,10 @@ export default function DiffPanel({
collapsedDiffFiles.scopeKey === collapseScopeKey
? collapsedDiffFiles.fileKeys
: EMPTY_COLLAPSED_DIFF_FILE_KEYS;
+ const viewedFileKeys = useDiffViewedStore((state) =>
+ selectViewedFileKeys(state.viewedByScope, collapseScopeKey),
+ );
+ const viewedFileKeySet = useMemo(() => new Set(viewedFileKeys), [viewedFileKeys]);
const reviewSectionTitle = selectedTurn
? `Turn ${selectedCheckpointTurnCount ?? "?"}`
: selectedGitScope === "unstaged"
@@ -505,6 +511,25 @@ export default function DiffPanel({
[collapseScopeKey],
);
+ const toggleFileViewed = useCallback(
+ (fileKey: string) => {
+ if (!collapseScopeKey) return;
+ const nowViewed = !viewedFileKeySet.has(fileKey);
+ useDiffViewedStore.getState().toggleFileViewed(collapseScopeKey, fileKey);
+ // Collapse a file when it is marked viewed, and expand it when unmarked.
+ setCollapsedDiffFiles((current) => {
+ const next = new Set(current.scopeKey === collapseScopeKey ? current.fileKeys : []);
+ if (nowViewed) {
+ next.add(fileKey);
+ } else {
+ next.delete(fileKey);
+ }
+ return { scopeKey: collapseScopeKey, fileKeys: next };
+ });
+ },
+ [collapseScopeKey, viewedFileKeySet],
+ );
+
const toggleDiffFileCollapse = useCallback(() => {
setCollapsedDiffFiles((current) => {
const currentKeys =
@@ -882,37 +907,63 @@ export default function DiffPanel({
sectionId={reviewSectionId}
sectionTitle={reviewSectionTitle}
composerDraftTarget={composerDraftTarget}
+ viewedFileKeys={viewedFileKeySet}
renderHeaderPrefix={(fileDiff, fileKey, collapsed) => {
const filePath = resolveFileDiffPath(fileDiff);
+ const viewed = viewedFileKeySet.has(fileKey);
return (
-
- {
- event.stopPropagation();
- toggleDiffFileCollapsed(fileKey);
- }}
- />
- }
- >
- {collapsed ? (
-
- ) : (
-
- )}
-
-
- {collapsed ? "Expand diff" : "Collapse diff"}
-
-
+ <>
+
+ {
+ event.stopPropagation();
+ toggleDiffFileCollapsed(fileKey);
+ }}
+ />
+ }
+ >
+ {collapsed ? (
+
+ ) : (
+
+ )}
+
+
+ {collapsed ? "Expand diff" : "Collapse diff"}
+
+
+
+ event.stopPropagation()}
+ onCheckedChange={() => toggleFileViewed(fileKey)}
+ />
+ }
+ />
+
+ {viewed ? "Mark as not viewed" : "Mark as viewed"}
+
+
+ >
);
}}
options={{
diff --git a/apps/web/src/components/diffs/AnnotatableCodeView.tsx b/apps/web/src/components/diffs/AnnotatableCodeView.tsx
index 6cea64fb5702..9f90e7cdbda7 100644
--- a/apps/web/src/components/diffs/AnnotatableCodeView.tsx
+++ b/apps/web/src/components/diffs/AnnotatableCodeView.tsx
@@ -83,6 +83,7 @@ interface AnnotatableCodeViewProps {
options: NonNullable["options"]>;
viewerRef?: Ref;
className?: string;
+ viewedFileKeys?: ReadonlySet;
renderHeaderPrefix: (
fileDiff: FileDiffMetadata,
fileKey: string,
@@ -90,6 +91,8 @@ interface AnnotatableCodeViewProps {
) => ReactNode;
}
+const EMPTY_VIEWED_FILE_KEYS: ReadonlySet = new Set();
+
interface DiffSelectionContext {
item: CodeViewItem;
}
@@ -102,6 +105,7 @@ export function AnnotatableCodeView({
options,
viewerRef,
className,
+ viewedFileKeys = EMPTY_VIEWED_FILE_KEYS,
renderHeaderPrefix,
}: AnnotatableCodeViewProps) {
const addReviewComment = useComposerDraftStore((store) => store.addReviewComment);
@@ -142,6 +146,7 @@ export function AnnotatableCodeView({
}, []);
const annotations =
draft?.fileKey === fileKey ? [...persisted, draft.annotation] : persisted;
+ const viewed = viewedFileKeys.has(fileKey);
return {
id: fileKey,
type: "diff",
@@ -149,7 +154,7 @@ export function AnnotatableCodeView({
annotations,
collapsed,
version: fnv1a32(
- `${collapsed ? "1" : "0"}:${annotations
+ `${collapsed ? "1" : "0"}:${viewed ? "1" : "0"}:${annotations
.flatMap((annotation) =>
annotation.metadata.entries.map(
(entry) => `${entry.id}:${entry.rangeLabel}:${entry.text}`,
@@ -159,7 +164,7 @@ export function AnnotatableCodeView({
),
};
}),
- [draft, files, reviewComments, sectionId],
+ [draft, files, reviewComments, sectionId, viewedFileKeys],
);
const removeEntry = useCallback(
diff --git a/apps/web/src/diffViewedStore.ts b/apps/web/src/diffViewedStore.ts
new file mode 100644
index 000000000000..7b4f4be6eded
--- /dev/null
+++ b/apps/web/src/diffViewedStore.ts
@@ -0,0 +1,62 @@
+import { create } from "zustand";
+import { createJSONStorage, persist } from "zustand/middleware";
+
+import { resolveStorage } from "./lib/storage";
+
+interface DiffViewedStoreState {
+ /** Maps a diff scope key to the set of file keys marked as viewed. */
+ viewedByScope: Record;
+ toggleFileViewed: (scopeKey: string, fileKey: string) => void;
+ setFileViewed: (scopeKey: string, fileKey: string, viewed: boolean) => void;
+ clearScope: (scopeKey: string) => void;
+}
+
+const EMPTY_VIEWED_FILE_KEYS: ReadonlyArray = [];
+
+export const useDiffViewedStore = create()(
+ persist(
+ (set) => ({
+ viewedByScope: {},
+ toggleFileViewed: (scopeKey, fileKey) =>
+ set((state) => {
+ const current = state.viewedByScope[scopeKey] ?? EMPTY_VIEWED_FILE_KEYS;
+ const next = current.includes(fileKey)
+ ? current.filter((key) => key !== fileKey)
+ : [...current, fileKey];
+ return { viewedByScope: { ...state.viewedByScope, [scopeKey]: next } };
+ }),
+ setFileViewed: (scopeKey, fileKey, viewed) =>
+ set((state) => {
+ const current = state.viewedByScope[scopeKey] ?? EMPTY_VIEWED_FILE_KEYS;
+ const alreadyViewed = current.includes(fileKey);
+ if (viewed === alreadyViewed) return state;
+ const next = viewed
+ ? [...current, fileKey]
+ : current.filter((key) => key !== fileKey);
+ return { viewedByScope: { ...state.viewedByScope, [scopeKey]: next } };
+ }),
+ clearScope: (scopeKey) =>
+ set((state) => {
+ if (!(scopeKey in state.viewedByScope)) return state;
+ const { [scopeKey]: _removed, ...viewedByScope } = state.viewedByScope;
+ return { viewedByScope };
+ }),
+ }),
+ {
+ name: "t3code:diff-viewed-state:v1",
+ version: 1,
+ storage: createJSONStorage(() =>
+ resolveStorage(typeof window !== "undefined" ? window.localStorage : undefined),
+ ),
+ partialize: (state) => ({ viewedByScope: state.viewedByScope }),
+ },
+ ),
+);
+
+export function selectViewedFileKeys(
+ viewedByScope: Record,
+ scopeKey: string | null,
+): ReadonlyArray {
+ if (scopeKey === null) return EMPTY_VIEWED_FILE_KEYS;
+ return viewedByScope[scopeKey] ?? EMPTY_VIEWED_FILE_KEYS;
+}