feat(review): mark files viewed as you scroll past them - #1430
Conversation
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.
Four review findings on the auto-mark-viewed branch. Rule 5 fired on EVERY applied diff switch, not just the staleness refresh. The review app funnels every transition through one apply path, so entering the Commits detour (the rail auto-opens HEAD), switching base branch, and toggling hide-whitespace all un-viewed files whose per-path patch text legitimately differs, which contradicts both Rule 4's "a commit detour is inert" and Rule 5's own rationale. The apply path now goes through resolveDiffSwitchUnviews, which requires the caller to opt in (`contentRefresh`) and re-checks the identity of the diff on top of that: same selection, same base, and never a commit-family type on either side. Only the staleness refresh and the post-fetch base refresh opt in. The pure delta resolver is unchanged. A source-level test pins which call sites may opt in, since that is where the guarantee actually lives. The at-bottom branch fired on the mount tick. A diff shorter than the viewport is at-bottom from the very first report, and that report is the mount seed, so the file on screen marked itself about a second later with zero interaction and fired the first-time toast at a motionless page. It now requires a real scroll event on the current file set. Staging a file marked it viewed without clearing auto-view suppression, unlike v, the header button and the tree row, so a file the reviewer un-viewed and later staged stayed permanently off-limits to auto-view. Dwell accrued while the setting was off, so enabling mid-read could mark the current file instantly on time the reviewer spent with the feature deliberately disabled. Disabled is now fully inert: the clock does not accrue, and enabling starts a fresh one rather than replaying the gap. AI-assisted (Claude) under maintainer direction.
|
Review findings addressed in 1. HIGH: Rule 5 fired on every applied diff switchCorrect and worse than it looked: the Commits rail auto-opens HEAD on entry, so merely clicking the Commits segment applied a The apply path now calls Two layers, because the discriminator alone is not enough. The hide-whitespace toggle also re-fetches the same selection with the same base, so identity does not separate it from a refresh. The caller therefore has to opt in with Call-site scope is now pinned by tests, since that is where the guarantee actually lives:
Note on 2. MEDIUM: at-bottom fired on the mount tickConfirmed. The branch now requires New emission test: mount with a viewport-fitting diff, wait past the dwell floor, assert nothing emits; then one real scroll and assert the at-bottom mark proceeds. It fails against the previous commit. 3. LOW: staging did not clear suppression
4. LOW: dwell accrued while disabled
Not touched, per instruction: tree-click forward-jump marking, the Verification
Browser re-smoke of the two user-visible fixes, on a 2 file branch diff where the commit under the detour touches one of the viewed files: Screenshots: AI-assisted (Claude) under maintainer direction. |
# Conflicts: # packages/review-editor/App.tsx
Files check themselves off as you read a review. One invariant drives both surfaces: a file becomes 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.
On by default, with two off switches and a one time notice the first time it fires.
What shipped, rule by rule
Rule 1, all files surface: pass below plus dwell. A file marks when the tracked reading file moves to a later file AND the passed file's successor has reached the viewport top (it genuinely scrolled out above, not merely lost the active race) AND it has accumulated at least
AUTO_VIEW_DWELL_MS(1000ms) as the reading file this snapshot. Dwell is cumulative across visits, so comparing two files back and forth still accrues, while a momentum flick to the bottom marks nothing. Collapsed cards accrue no dwell and never emit. At the bottom of the container the currently active last file counts as passed once its dwell is met, because it can never scroll out above.Implementation note beyond the design: the pass is judged on a candidate that survives across scroll ticks. The active file rule switches at
scrollTop + 50while a pass demands the successor actually reach the top, so a single scroll frame can land inside that gap. Judging only on the transition tick skipped that file for the rest of the session, which the browser smoke run surfaced (5 of 6 files marked on a full read through). With the candidate it is 6 of 6.Rule 2, single file panel: navigate away plus dwell. Opening a file never marks it. Moving the panel off file A, to another file or to nothing, marks A if it was the panel's file for the same dwell. Keyboard file navigation drives the same panel switches, so keyboard only parity is automatic.
Rule 3, manual un-view suppresses. Un-viewing a file, by
v, the header button, or a tree row, adds it to a suppression set that auto-view never re-marks. Marking it viewed by hand clears the suppression. The set rides the code review draft as an additive optionalautoViewSuppressed?: string[]. Deliberate edge kept from the design: a draft whose only state is suppression is still empty and is still tombstoned, so #948's clear everything semantics are untouched.Rule 4, scope. Inert while the Guided Review takeover is open and while a
commit:<sha>detour is active. Dwell stops accruing rather than banking, so leaving a detour does not immediately mark the next file passed. It does run in PR mode, workspace, GitButler, and jj. PR marks batch into ONE/api/pr-viewedPOST (about 2s window); the checkmarks themselves apply immediately, only the remote sync waits.Rule 5, changed files un-view on refresh. Shipped, not severed. When a refresh or switch applies a new patch and a viewed file's per path patch text changed, it loses its checkmark, because a check on code the agent just rewrote is misleading. Gated on the setting: with auto-view off, today's manual semantics stay byte identical. Files absent from the new patch keep their viewed state. No platform sync, since GitHub applies the same rule to its own viewed state server side.
Rule 6, Guided Review independence. No link, as designed. Guide section reviewed state is untouched.
Rule 7, submission. No change. Nothing gates submit on viewed count and this does not introduce a gate.
Setting and notice
reviewAutoViewed, boolean, defaulttrue, cookie only (plannotator-review-auto-viewed), declared besidereviewShowViewedControlsin the settings registry. Two switches:The first time auto-view actually marks something, a toast says so and offers Turn off, which flips the setting and confirms. The toast is deferred, not lost, behind the guide takeover or any first run dialog: the file still marks and the next auto-view retries the toast, so it can never stack on the startup dialog chain. The seen marker is a versioned cookie, and it is also consumed by an explicit toggle from either switch, because someone holding the switch has demonstrably found it.
Footprint
The monolith stayed small. New logic lives in its own files;
AllFilesCodeViewgained one optional emission callback on the rAF path it already runs, andApp.tsxgained the hook mount, the suppression feed, and the toast.packages/review-editor/utils/autoViewed.ts(new, pure)packages/review-editor/hooks/useAutoViewed.ts(new)packages/review-editor/utils/autoViewedNotice.ts(new)packages/review-editor/App.tsxpackages/review-editor/components/AllFilesCodeView.tsxpackages/review-editor/components/PanelChrome.tsxpackages/ui/config/reviewView.tspackages/ui/hooks/useCodeAnnotationDraft.tspackages/ui/components/Settings.tsxpackages/ui/config/settings.tspackages/review-editor/components/{FileTree,SectionsPanel}.tsxpackages/review-editor/dock/{ReviewStateContext,panels/ReviewAllFilesDiffPanel}.tsxpackages/ui/config/index.tsEvery new prop is optional and defaults to today's behavior, per the published package rule.
@plannotator/uichanges are additive: one registry entry, one optional draft field, one Settings row, and the notice gate beside the setting (it lives inui/config/reviewView.tsbecause both setting writers must consume it). No server changes in either runtime; Pi and OpenCode get the feature through the built HTML.Tests
Every test names the regression it guards.
packages/review-editor/utils/autoViewed.test.ts(14, pure, no DOM, no timers): flick through five files marks nothing; read through marks and an upward move never does; cumulative dwell across two 600ms visits; collapsed cards accrue nothing;enabled: falseproduces zero marks from input that otherwise marks; un-view then re-pass is suppressed and a manual re-view clears it; snapshot reset clears dwell but keeps suppression; a suspended stretch accrues nothing; navigate away marks only after the floor; the batcher sends one call with every path and drops a pending batch on dispose; Rule 5 un-views only the changed file, un-views nothing when the setting is off, and keeps a file missing from the new patch.packages/review-editor/components/AllFilesCodeView.autoViewed.test.tsx(10, DOM): the emission contract against a fake CodeView geometry. Downward pass emits once the successor reaches the top; a predecessor still on screen emits nothing; the threshold gap resolves on a later tick; a candidate abandoned by scrolling back up is dropped; upward never emits; collapsed never emits, including at the bottom; the reading file report carries the collapsed flag; without the handler nothing changes.packages/review-editor/hooks/useAutoViewed.test.tsx(7, DOM): three files read through produce ONE platform sync carrying all three while the checkmarks appear immediately; the off switch severs the pipeline; suppression reaches the core; a suspended session marks nothing and banks nothing; the single file panel marks on navigate away and never on open; a new snapshot clears dwell; a file parked at the bottom matures without further scrolling.packages/review-editor/utils/autoViewedNotice.test.ts(5): needed once then never; a stale version re-opens the gate; the toast's Turn off action actually turns the setting off (the copy is not snapshotted, the action's effect is); an explicit toggle consumes the gate; the cookie round trips and the default is on.Extended:
packages/ui/codeAnnotationDraftPersistence.test.tsx(suppression round trips, a draft without the field restores empty, and suppression alone does not keep an empty draft alive) andpackages/review-editor/components/PanelChrome.test.tsx(the gear row appears only when a handler is supplied, and toggles).All storage in these tests goes through a memory backend or a temp
PLANNOTATOR_DATA_DIR; nothing touches the real~/.plannotator, and nothing mutates env or globals at module scope.Results
bun test: 4045 pass, 12 fail. The same 12 fail on the base commit (79cb0168), which had 4020 pass, 12 fail. They are thesemandbutbinaries being absent locally.bun run typecheck(all projects includingtsconfig.strict-consumer.json): clean.packages/review-editor/tsconfig.jsonis not a CI target and reports 164 pre-existing errors on both base and branch; the branch adds only the 4Cannot find module 'bun:test'lines every test file in that project already produces..github/workflows/test.ymlbeside the existing entries.Bundle:
apps/review/dist/index.html17,571,622 to 17,580,152 bytes, +8,530 (+0.049%).Browser smoke
Compiled binary, sandboxed
PLANNOTATOR_DATA_DIRunder a scratch dir, fixed port,PLANNOTATOR_BROWSER=/usr/bin/true, against a real 7 file diff (6 source files plus abun.lockthat Plannotator classifies as generated and seeds collapsed). Driven with Playwright.Rule 5 was driven separately by rewriting one viewed file on disk and clicking the staleness Refresh:
Screenshots from the run, on the maintainer's machine at
/private/tmp/claude-501/-Users-ramos-plannotator-plannotator/34d56d5a-33a8-4878-92b2-3f4318214ae2/scratchpad/shots/:04-first-time-toast.pngthe toast with Turn off, counter at 1/7,src/mod1.tschecked in the tree06-gear-popover.pngthe gear popover with the third row Auto-mark viewed / Check files off as you scroll past, off, and the confirmation toast10-settings-git.pngSettings > Git > Viewed files > Auto-mark viewed, on by default07-read-through.png,08-unviewed.png,09-suppression-held.png,11-rule5.pngthe remaining stepsNotes and deliberate limits
AI-assisted (Claude) under maintainer direction.