Files
backnotprop__plannotator/packages/ui/hooks/useCodeAnnotationDraft.ts
Michael Ramos 43dd79c3d2 feat(review): mark files viewed as you scroll past them
Reviewers reading the all-files diff top to bottom had to check every file
off by hand. Now a file marks itself viewed when the reviewer MOVES ON from
it, after its content was actually on screen long enough to have been read.
Arriving at a file never marks it; leaving it downward does.

- All-files surface: a file marks when the reader scrolls past it (its
  successor has reached the viewport top, so it genuinely scrolled out above)
  and has accumulated at least 1000ms as the reported reading file. Dwell is
  cumulative per diff snapshot, so bouncing between two files still accrues,
  while a momentum flick to the bottom marks nothing. The last file, which can
  never scroll out above, marks on reaching the end of the diff.
- Single-file panel: opening a file never marks it; navigating away after the
  same dwell floor does. Keyboard file navigation drives the same panel
  switches, so keyboard-only parity is automatic.
- Collapsed cards never mark. Generated files seed collapsed, so nobody
  reviews a lockfile by scrolling past its folded header.
- Un-viewing a file suppresses auto-view for it until it is marked viewed by
  hand again. That set rides the review draft as an additive optional field.
- Inert inside the Guided Review takeover and on a commit detour, where the
  files on screen are not the change under review.
- A viewed file whose patch changes under a refresh loses its checkmark, but
  only while auto-view is on, so the off state stays byte-identical to today.
- PR sessions batch the marks into one /api/pr-viewed request rather than one
  per file.

The setting is reviewAutoViewed, cookie-only and on by default, with two off
switches: Settings > Git and a row in the file-list gear popover. The first
time auto-view actually fires, a toast says so and offers Turn off; using
either switch consumes that one-time notice.

The decision core is pure and clock-injected (utils/autoViewed.ts), the
binding is a hook (hooks/useAutoViewed.ts), and AllFilesCodeView only gains
one optional emission callback on the rAF path it already runs. No server
changes in either runtime.

AI-assisted (Claude) under maintainer direction.
2026-08-30 22:35:52 -07:00

207 lines
8.5 KiB
TypeScript

/**
* Auto-save code review annotation drafts to the server.
*
* Similar to useAnnotationDraft but stores CodeAnnotation[] directly
* (they're already compact — no tuple conversion needed).
*/
import { useState, useEffect, useCallback, useRef } from 'react';
import type { CodeAnnotation, Annotation, CommentAnnotation } from '../types';
import { getDraftTransport } from './useAnnotationDraft';
const DEBOUNCE_MS = 500;
interface DraftData {
codeAnnotations: CodeAnnotation[];
descriptionAnnotations?: Annotation[];
commentAnnotations?: CommentAnnotation[];
viewedFiles?: string[];
/**
* Files the reviewer manually un-viewed, which auto-mark-viewed must never
* re-check (the "come back to this" contract). Additive and optional: a
* draft written before this field restores fine, and a draft carrying it is
* ignored gracefully by an older build.
*
* Deliberately absent from `isEmpty` and from the engagement signal — a
* session whose only state is suppression is still an empty draft and is
* still cleared, keeping #948's clear-everything semantics untouched.
*/
autoViewSuppressed?: string[];
draftGeneration?: number;
ts: number;
}
function readDraftGeneration(value: unknown): number | null {
return typeof value === 'number' && Number.isInteger(value) && value >= 0 ? value : null;
}
function formatTimeAgo(ts: number): string {
const seconds = Math.floor((Date.now() - ts) / 1000);
if (seconds < 60) return 'just now';
const minutes = Math.floor(seconds / 60);
if (minutes < 60) return `${minutes} minute${minutes !== 1 ? 's' : ''} ago`;
const hours = Math.floor(minutes / 60);
if (hours < 24) return `${hours} hour${hours !== 1 ? 's' : ''} ago`;
const days = Math.floor(hours / 24);
return `${days} day${days !== 1 ? 's' : ''} ago`;
}
interface UseCodeAnnotationDraftOptions {
annotations: CodeAnnotation[];
descriptionAnnotations?: Annotation[];
commentAnnotations?: CommentAnnotation[];
viewedFiles: Set<string>;
autoViewSuppressed?: Set<string>;
isApiMode: boolean;
submitted: boolean;
}
interface UseCodeAnnotationDraftResult {
draftBanner: { count: number; viewedCount: number; timeAgo: string } | null;
restoreDraft: () => { annotations: CodeAnnotation[]; descriptionAnnotations: Annotation[]; commentAnnotations: CommentAnnotation[]; viewedFiles: string[]; autoViewSuppressed: string[] };
getDraftGeneration: () => number;
dismissDraft: () => void;
}
export function useCodeAnnotationDraft({
annotations,
descriptionAnnotations = [],
commentAnnotations = [],
viewedFiles,
autoViewSuppressed,
isApiMode,
submitted,
}: UseCodeAnnotationDraftOptions): UseCodeAnnotationDraftResult {
const [draftBanner, setDraftBanner] = useState<{ count: number; viewedCount: number; timeAgo: string } | null>(null);
const draftDataRef = useRef<DraftData | null>(null);
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const hasMountedRef = useRef(false);
const draftGenerationRef = useRef(0);
// True once the user has actually had annotations this session. Used to decide
// whether an empty state is a real "cleared everything" edit (persist it) vs a
// fresh/unengaged session (leave the server alone). Keyed on annotations only —
// see the autosave effect for why viewedFiles must not count.
const hasHadAnnotationsRef = useRef(false);
// Load draft on mount
useEffect(() => {
if (!isApiMode) return;
getDraftTransport().load()
.then(({ data, generation }) => {
if (generation !== null) {
draftGenerationRef.current = Math.max(draftGenerationRef.current, generation);
}
return data as DraftData | null;
})
.then((data: DraftData | null) => {
const generation = readDraftGeneration(data?.draftGeneration);
if (generation !== null) {
draftGenerationRef.current = Math.max(draftGenerationRef.current, generation);
}
const annotationCount = (Array.isArray(data?.codeAnnotations) ? data.codeAnnotations.length : 0)
+ (Array.isArray(data?.descriptionAnnotations) ? data.descriptionAnnotations.length : 0)
+ (Array.isArray(data?.commentAnnotations) ? data.commentAnnotations.length : 0);
const viewedCount = Array.isArray(data?.viewedFiles) ? data.viewedFiles.length : 0;
if (annotationCount > 0 || viewedCount > 0) {
draftDataRef.current = data;
setDraftBanner({
count: annotationCount,
viewedCount,
timeAgo: formatTimeAgo(data?.ts || 0),
});
}
hasMountedRef.current = true;
})
.catch(() => {
hasMountedRef.current = true;
});
}, [isApiMode]);
// Debounced auto-save on annotation/viewed changes
useEffect(() => {
if (!isApiMode || submitted) return;
if (!hasMountedRef.current) return;
// Track engagement on USER-AUTHORED annotations only. Two things that arrive
// without user action must NOT count as "had content", or a later empty state
// would look like the user deleted everything and wrongly delete the draft:
// - viewedFiles are seeded from GitHub's already-viewed state on mount
// (review App.tsx) before the user does anything.
// - external/SSE annotations (source-tagged, e.g. an eslint plugin) arrive
// via `allAnnotations` and have their own lifecycle, separate from the draft.
if (annotations.some((a) => !a.source) || descriptionAnnotations.length > 0 || commentAnnotations.length > 0) hasHadAnnotationsRef.current = true;
const isEmpty = annotations.length === 0 && descriptionAnnotations.length === 0 && commentAnnotations.length === 0 && viewedFiles.size === 0;
// Leave the server alone for an empty state until the user has actually had
// annotations this session. This preserves an unrestored draft sitting on disk
// at mount (the draft-recovery banner can still offer it).
if (isEmpty && !hasHadAnnotationsRef.current) return;
if (timerRef.current) clearTimeout(timerRef.current);
timerRef.current = setTimeout(() => {
const draftGeneration = draftGenerationRef.current + 1;
draftGenerationRef.current = draftGeneration;
if (isEmpty) {
// The user cleared everything (#948). Delete the draft with a generation
// tombstone so it can't resurface on refresh and a late save can't revive
// it. Mirrors useAnnotationDraft.persistNow — routed through the draft
// transport seam so a host backend tombstones its own stored draft too.
getDraftTransport().remove(draftGeneration, { keepalive: false }).catch(() => {});
return;
}
const payload: DraftData = {
codeAnnotations: annotations,
descriptionAnnotations,
commentAnnotations,
viewedFiles: [...viewedFiles],
...(autoViewSuppressed && autoViewSuppressed.size > 0
? { autoViewSuppressed: [...autoViewSuppressed] }
: {}),
draftGeneration,
ts: Date.now(),
};
getDraftTransport().save(payload, { keepalive: false }).catch(() => {});
}, DEBOUNCE_MS);
return () => {
if (timerRef.current) clearTimeout(timerRef.current);
};
}, [annotations, descriptionAnnotations, commentAnnotations, viewedFiles, autoViewSuppressed, isApiMode, submitted]);
const restoreDraft = useCallback(() => {
// Cancel any pending autosave so it can't fire with pre-restore state and
// overwrite what we're about to restore.
if (timerRef.current) clearTimeout(timerRef.current);
const data = draftDataRef.current;
setDraftBanner(null);
draftDataRef.current = null;
return {
annotations: data?.codeAnnotations ?? [],
descriptionAnnotations: data?.descriptionAnnotations ?? [],
commentAnnotations: data?.commentAnnotations ?? [],
viewedFiles: data?.viewedFiles ?? [],
autoViewSuppressed: data?.autoViewSuppressed ?? [],
};
}, []);
const getDraftGeneration = useCallback(() => draftGenerationRef.current + 1, []);
const dismissDraft = useCallback(() => {
// Cancel any pending autosave so a late save can't revive the draft the user
// just dismissed.
if (timerRef.current) clearTimeout(timerRef.current);
const deletedGeneration = draftGenerationRef.current + 1;
draftGenerationRef.current = deletedGeneration;
setDraftBanner(null);
draftDataRef.current = null;
getDraftTransport().remove(deletedGeneration, { keepalive: false }).catch(() => {});
}, []);
return { draftBanner, restoreDraft, getDraftGeneration, dismissDraft };
}