Skip to content

Commit 0f9e76d

Browse files
authored
feat(code-review): mark files as viewed in diff review (#2762)
1 parent 34bb259 commit 0f9e76d

21 files changed

Lines changed: 759 additions & 105 deletions

packages/core/src/archive/archiveOrchestration.test.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ class Harness {
2929
stopCloudRun: vi.fn().mockResolvedValue(true),
3030
disconnectFromTask: vi.fn().mockResolvedValue(undefined),
3131
archive: vi.fn().mockResolvedValue(undefined),
32+
clearViewedState: vi.fn(),
3233
logError: vi.fn(),
3334
cache: {
3435
cancelPathFilter: vi.fn().mockResolvedValue(undefined),
@@ -59,10 +60,19 @@ describe("archiveTask", () => {
5960

6061
expect(harness.deps.archive).toHaveBeenCalledWith(TASK_ID);
6162
expect(harness.deps.disconnectFromTask).toHaveBeenCalledWith(TASK_ID);
63+
expect(harness.deps.clearViewedState).toHaveBeenCalledWith(TASK_ID);
6264
expect(harness.ids).toContain(TASK_ID);
6365
expect(harness.list.some((a) => a.taskId === TASK_ID)).toBe(true);
6466
});
6567

68+
it("does not clear read state when the archive request fails", async () => {
69+
harness.deps.archive = vi.fn().mockRejectedValue(new Error("boom"));
70+
71+
await expect(archiveTask(TASK_ID, harness.deps)).rejects.toThrow("boom");
72+
73+
expect(harness.deps.clearViewedState).not.toHaveBeenCalled();
74+
});
75+
6676
it("with optimistic:false, defers cache writes until archive resolves", async () => {
6777
let idsWhenArchiveCalled: string[] = ["sentinel"];
6878
harness.deps.archive = vi.fn().mockImplementation(async () => {

packages/core/src/archive/archiveOrchestration.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ export interface ArchiveOrchestrationDeps {
3939
stopCloudRun(taskId: string, runId?: string): Promise<boolean>;
4040
disconnectFromTask(taskId: string): Promise<void>;
4141
archive(taskId: string): Promise<void>;
42+
clearViewedState(taskId: string): void;
4243
logError(message: string, error: unknown): void;
4344
cache: ArchiveCacheWriter;
4445
}
@@ -103,9 +104,8 @@ export async function archiveTask(
103104
try {
104105
await deps.disconnectFromTask(taskId);
105106
await deps.archive(taskId);
106-
// Destroying terminals is irreversible, so it waits for the archive to
107-
// commit; a failed archive keeps its live terminals.
108107
deps.clearTerminalStates(taskId);
108+
deps.clearViewedState(taskId);
109109
// Non-optimistic flows keep the row visible during the request, then remove
110110
// it the moment the archive succeeds.
111111
if (!optimistic) {

packages/core/src/git/router-schemas.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ export const changedFileSchema = z.object({
4242
linesRemoved: z.number().optional(),
4343
staged: z.boolean().optional(),
4444
patch: z.string().optional(),
45+
sha: z.string().optional(),
4546
});
4647

4748
export type ChangedFile = z.infer<typeof changedFileSchema>;

packages/shared/src/domain-types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -364,6 +364,7 @@ export interface ChangedFile {
364364
linesRemoved?: number;
365365
staged?: boolean;
366366
patch?: string; // Unified diff patch from GitHub API
367+
sha?: string;
367368
}
368369

369370
// External apps detection types

packages/ui/src/features/archive/useArchiveTask.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import {
1616
type HostTrpcClient,
1717
} from "@posthog/host-router/client";
1818
import { useHostTRPC } from "@posthog/host-router/react";
19+
import { useReviewViewedStore } from "@posthog/ui/features/code-review/reviewViewedStore";
1920
import { useCommandCenterStore } from "@posthog/ui/features/command-center/commandCenterStore";
2021
import { useFocusStore } from "@posthog/ui/features/focus/focusStore";
2122
import { pinnedTasksApi } from "@posthog/ui/features/sidebar/taskMetaApi";
@@ -127,6 +128,8 @@ function makeOrchestrationDeps(
127128
),
128129
archive: (taskId) =>
129130
hostClient.archive.archive.mutate({ taskId }).then(() => undefined),
131+
clearViewedState: (taskId) =>
132+
useReviewViewedStore.getState().clearTasks([taskId]),
130133
logError: (message, error) => log.error(message, error),
131134
cache: makeCacheWriter(queryClient, keys),
132135
};

packages/ui/src/features/code-review/components/CloudReviewPage.tsx

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import {
1515
ReviewShell,
1616
useReviewState,
1717
} from "./ReviewShell";
18+
import { changedFileSignature } from "./reviewItemBuilders";
1819

1920
interface CloudReviewPageProps {
2021
task: Task;
@@ -50,7 +51,19 @@ export function CloudReviewPage({ task }: CloudReviewPageProps) {
5051
expandAll,
5152
collapseAll,
5253
uncollapseFile,
53-
} = useReviewState(reviewFiles, allPaths);
54+
collapseFiles,
55+
viewedRecord,
56+
toggleViewed,
57+
} = useReviewState(reviewFiles, allPaths, taskId);
58+
59+
const currentSignatures = useMemo(() => {
60+
const map = new Map<string, string>();
61+
for (const f of reviewFiles) {
62+
const signature = changedFileSignature(f);
63+
if (signature) map.set(f.path, signature);
64+
}
65+
return map;
66+
}, [reviewFiles]);
5467

5568
const toolCallFallbacks = useMemo(
5669
() =>
@@ -81,6 +94,7 @@ export function CloudReviewPage({ task }: CloudReviewPageProps) {
8194
commentThreads={showReviewComments ? commentThreads : undefined}
8295
fallback={toolCallFallbacks?.get(file.path) ?? null}
8396
externalUrl={githubFileUrl}
97+
viewedKey={file.path}
8498
/>
8599
),
86100
};
@@ -130,8 +144,12 @@ export function CloudReviewPage({ task }: CloudReviewPageProps) {
130144
onExpandAll={expandAll}
131145
onCollapseAll={collapseAll}
132146
onUncollapseFile={uncollapseFile}
147+
onCollapseFiles={collapseFiles}
133148
items={items}
134149
itemIndexByFilePath={itemIndexByFilePath}
150+
currentSignatures={currentSignatures}
151+
viewedRecord={viewedRecord}
152+
onToggleViewed={toggleViewed}
135153
/>
136154
);
137155
}

packages/ui/src/features/code-review/components/PatchedFileDiff.tsx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ interface PatchedFileDiffProps {
1717
externalUrl?: string;
1818
prUrl?: string | null;
1919
commentThreads?: Map<number, PrCommentThread>;
20+
viewedKey?: string;
2021
/** Extra controls in the file header row (e.g. a "Viewed" toggle). */
2122
headerTrailing?: ReactNode;
2223
}
@@ -31,6 +32,7 @@ export function PatchedFileDiff({
3132
externalUrl,
3233
prUrl,
3334
commentThreads,
35+
viewedKey,
3436
headerTrailing,
3537
}: PatchedFileDiffProps) {
3638
const fileDiff = useMemo((): FileDiffMetadata | undefined => {
@@ -64,6 +66,7 @@ export function PatchedFileDiff({
6466
collapsed={collapsed}
6567
onToggle={onToggle}
6668
externalUrl={externalUrl}
69+
viewedKey={viewedKey}
6770
commentCount={commentCount}
6871
headerTrailing={headerTrailing}
6972
/>
@@ -80,6 +83,7 @@ export function PatchedFileDiff({
8083
collapsed={collapsed}
8184
onToggle={onToggle}
8285
externalUrl={externalUrl}
86+
viewedKey={viewedKey}
8387
commentCount={commentCount}
8488
headerTrailing={headerTrailing}
8589
/>
@@ -98,6 +102,7 @@ export function PatchedFileDiff({
98102
fileDiff={fd}
99103
collapsed={collapsed}
100104
onToggle={onToggle}
105+
viewedKey={viewedKey}
101106
commentCount={commentCount}
102107
trailing={headerTrailing}
103108
/>

packages/ui/src/features/code-review/components/ReviewPage.tsx

Lines changed: 54 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,8 @@ import {
3333
buildPatchReviewItems,
3434
buildRemoteReviewItems,
3535
buildUntrackedReviewItems,
36+
changedFileSignature,
37+
patchFileSignature,
3638
} from "./reviewItemBuilders";
3739

3840
const EMPTY_CHANGED_FILES: ChangedFile[] = [];
@@ -138,7 +140,10 @@ export function ReviewPage({ task }: ReviewPageProps) {
138140
expandAll,
139141
collapseAll,
140142
uncollapseFile,
141-
} = useReviewState(changedFiles, allPaths);
143+
collapseFiles,
144+
viewedRecord,
145+
toggleViewed,
146+
} = useReviewState(changedFiles, allPaths, taskId);
142147

143148
const stagedPathSet = useMemo(
144149
() => new Set(stagedParsedFiles.map((f) => f.name ?? f.prevName ?? "")),
@@ -191,6 +196,9 @@ export function ReviewPage({ task }: ReviewPageProps) {
191196
expandAll={expandAll}
192197
collapseAll={collapseAll}
193198
uncollapseFile={uncollapseFile}
199+
collapseFiles={collapseFiles}
200+
viewedRecord={viewedRecord}
201+
toggleViewed={toggleViewed}
194202
refetch={refetch}
195203
hasStagedFiles={hasStagedFiles}
196204
stagedParsedFiles={stagedParsedFiles}
@@ -224,6 +232,9 @@ function LocalReviewContent({
224232
expandAll,
225233
collapseAll,
226234
uncollapseFile,
235+
collapseFiles,
236+
viewedRecord,
237+
toggleViewed,
227238
refetch,
228239
hasStagedFiles,
229240
stagedParsedFiles,
@@ -253,6 +264,9 @@ function LocalReviewContent({
253264
expandAll: () => void;
254265
collapseAll: () => void;
255266
uncollapseFile: (filePath: string) => void;
267+
collapseFiles: (keys: string[]) => void;
268+
viewedRecord: Record<string, string>;
269+
toggleViewed: (key: string, sig: string | null) => void;
256270
refetch: () => void;
257271
hasStagedFiles: boolean;
258272
stagedParsedFiles: ReturnType<typeof parsePatchFiles>[number]["files"];
@@ -295,6 +309,27 @@ function LocalReviewContent({
295309
[filesByKey, stageToggle],
296310
);
297311

312+
const currentSignatures = useMemo(() => {
313+
const map = new Map<string, string>();
314+
for (const f of stagedParsedFiles) {
315+
map.set(
316+
makeFileKey(true, f.name ?? f.prevName ?? ""),
317+
patchFileSignature(f),
318+
);
319+
}
320+
for (const f of unstagedParsedFiles) {
321+
map.set(
322+
makeFileKey(false, f.name ?? f.prevName ?? ""),
323+
patchFileSignature(f),
324+
);
325+
}
326+
for (const f of untrackedFiles) {
327+
const signature = changedFileSignature(f);
328+
if (signature) map.set(makeFileKey(f.staged, f.path), signature);
329+
}
330+
return map;
331+
}, [stagedParsedFiles, unstagedParsedFiles, untrackedFiles]);
332+
298333
const items = useMemo<ReviewListItem[]>(() => {
299334
const reviewItems: ReviewListItem[] = [];
300335

@@ -393,6 +428,7 @@ function LocalReviewContent({
393428
onExpandAll={expandAll}
394429
onCollapseAll={collapseAll}
395430
onUncollapseFile={uncollapseFile}
431+
onCollapseFiles={collapseFiles}
396432
onRefresh={refetch}
397433
onDiscardAll={totalFileCount > 0 ? discardAllChanges : undefined}
398434
effectiveSource={effectiveSource}
@@ -401,6 +437,9 @@ function LocalReviewContent({
401437
defaultBranch={defaultBranch}
402438
items={items}
403439
itemIndexByFilePath={itemIndexByFilePath}
440+
currentSignatures={currentSignatures}
441+
viewedRecord={viewedRecord}
442+
onToggleViewed={toggleViewed}
404443
/>
405444
);
406445
}
@@ -455,7 +494,16 @@ function RemoteReviewPage({
455494
: prLoading && files.length === 0;
456495

457496
const allPaths = useMemo(() => files.map((f) => f.path), [files]);
458-
const reviewState = useReviewState(files, allPaths);
497+
const reviewState = useReviewState(files, allPaths, taskId);
498+
499+
const currentSignatures = useMemo(() => {
500+
const map = new Map<string, string>();
501+
for (const f of files) {
502+
const signature = changedFileSignature(f);
503+
if (signature) map.set(f.path, signature);
504+
}
505+
return map;
506+
}, [files]);
459507

460508
const items = useMemo(
461509
() =>
@@ -492,13 +540,17 @@ function RemoteReviewPage({
492540
onExpandAll={reviewState.expandAll}
493541
onCollapseAll={reviewState.collapseAll}
494542
onUncollapseFile={reviewState.uncollapseFile}
543+
onCollapseFiles={reviewState.collapseFiles}
495544
onRefresh={onRefresh}
496545
effectiveSource={effectiveSource}
497546
branchSourceAvailable={branchSourceAvailable}
498547
prSourceAvailable={prSourceAvailable}
499548
defaultBranch={defaultBranch}
500549
items={items}
501550
itemIndexByFilePath={itemIndexByFilePath}
551+
currentSignatures={currentSignatures}
552+
viewedRecord={reviewState.viewedRecord}
553+
onToggleViewed={reviewState.toggleViewed}
502554
/>
503555
);
504556
}

packages/ui/src/features/code-review/components/ReviewRows.tsx

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -83,9 +83,10 @@ export const PatchRow = memo(function PatchRow({
8383
onDiscard={onDiscard}
8484
onStage={onStage}
8585
staged={staged}
86+
viewedKey={itemKey}
8687
/>
8788
),
88-
[collapsed, onToggle, onOpenFile, onDiscard, onStage, staged],
89+
[collapsed, onToggle, onOpenFile, onDiscard, onStage, staged, itemKey],
8990
);
9091

9192
// Binary files (images, video, archives, …) have no meaningful textual diff;
@@ -176,6 +177,7 @@ export const UntrackedRow = memo(function UntrackedRow({
176177
onDiscard={onDiscard}
177178
onStage={onStage}
178179
taskId={taskId}
180+
viewedKey={itemKey}
179181
/>
180182
);
181183
});
@@ -215,6 +217,7 @@ export const RemoteRow = memo(function RemoteRow({
215217
onToggle={onToggle}
216218
commentThreads={commentThreads}
217219
externalUrl={externalUrl}
220+
viewedKey={file.path}
218221
/>
219222
);
220223
});
@@ -228,6 +231,7 @@ function UntrackedFileDiff({
228231
onToggle,
229232
onDiscard,
230233
onStage,
234+
viewedKey,
231235
}: {
232236
file: ChangedFile;
233237
repoPath: string;
@@ -237,6 +241,7 @@ function UntrackedFileDiff({
237241
onToggle: () => void;
238242
onDiscard?: () => void;
239243
onStage?: () => void;
244+
viewedKey?: string;
240245
}) {
241246
const [containerRef, inView] = useInView<HTMLDivElement>({
242247
rootMargin: REVIEW_PREFETCH_ROOT_MARGIN,
@@ -278,6 +283,7 @@ function UntrackedFileDiff({
278283
reason="line-limit"
279284
collapsed={collapsed}
280285
onToggle={onToggle}
286+
viewedKey={viewedKey}
281287
/>
282288
);
283289
}
@@ -301,6 +307,7 @@ function UntrackedFileDiff({
301307
onDiscard={onDiscard}
302308
onStage={onStage}
303309
staged={false}
310+
viewedKey={viewedKey}
304311
/>
305312
)}
306313
/>
@@ -312,6 +319,7 @@ function UntrackedFileDiff({
312319
deletions={0}
313320
collapsed={collapsed}
314321
onToggle={onToggle}
322+
viewedKey={viewedKey}
315323
/>
316324
)}
317325
</div>

0 commit comments

Comments
 (0)