@@ -39,6 +39,20 @@ import { VerticalScrollbar, type VerticalScrollbarHandle } from "../scrollbar/Ve
3939import { prefetchHighlightedDiff } from "../../diff/useHighlightedDiff" ;
4040
4141const EMPTY_VISIBLE_AGENT_NOTES : VisibleAgentNote [ ] = [ ] ;
42+ const EMPTY_VISIBLE_AGENT_NOTES_BY_FILE = new Map < string , VisibleAgentNote [ ] > ( ) ;
43+
44+ /**
45+ * Clamp one vertical scroll target into the currently reachable review-stream extent.
46+ *
47+ * Selection-driven scroll requests can legitimately aim past the last reachable row — for example
48+ * when the user selects a short trailing file but asks for that file body to own the viewport top.
49+ * Every settle check must compare against this clamped value, not the raw request, or the pane can
50+ * keep re-applying a bottom-edge scroll and trap manual upward scrolling.
51+ */
52+ function clampVerticalScrollTop ( scrollTop : number , contentHeight : number , viewportHeight : number ) {
53+ const maxScrollTop = Math . max ( 0 , contentHeight - viewportHeight ) ;
54+ return Math . min ( Math . max ( 0 , scrollTop ) , maxScrollTop ) ;
55+ }
4256
4357/** Keep syntax-highlight warm for the files immediately adjacent to the current selection. */
4458function buildAdjacentPrefetchFileIds ( files : DiffFile [ ] , selectedFileId ?: string ) {
@@ -365,7 +379,7 @@ export function DiffPane({
365379 const next = new Map < string , VisibleAgentNote [ ] > ( ) ;
366380
367381 if ( ! showAgentNotes ) {
368- return next ;
382+ return EMPTY_VISIBLE_AGENT_NOTES_BY_FILE ;
369383 }
370384
371385 const fileIdsToMeasure = new Set ( visibleViewportFileIds ) ;
@@ -426,6 +440,13 @@ export function DiffPane({
426440 ) ;
427441 const totalContentHeight = fileSectionLayouts [ fileSectionLayouts . length - 1 ] ?. sectionBottom ?? 0 ;
428442
443+ /** Clamp one requested review scroll target against the latest planned content height. */
444+ const clampReviewScrollTop = useCallback (
445+ ( requestedTop : number , viewportHeight : number ) =>
446+ clampVerticalScrollTop ( requestedTop , totalContentHeight , viewportHeight ) ,
447+ [ totalContentHeight ] ,
448+ ) ;
449+
429450 const highlightPrefetchFileIds = useMemo (
430451 ( ) =>
431452 buildHighlightPrefetchFileIds ( {
@@ -620,6 +641,12 @@ export function DiffPane({
620641 height : noteRow . height ,
621642 } ;
622643 } , [ scrollToNote , sectionGeometry , selectedEstimatedHunkBounds , selectedFileIndex ] ) ;
644+ const selectedEstimatedHunkTop = selectedEstimatedHunkBounds ?. top ?? null ;
645+ const selectedEstimatedHunkHeight = selectedEstimatedHunkBounds ?. height ?? null ;
646+ const selectedEstimatedHunkStartRowId = selectedEstimatedHunkBounds ?. startRowId ?? null ;
647+ const selectedEstimatedHunkEndRowId = selectedEstimatedHunkBounds ?. endRowId ?? null ;
648+ const selectedNoteTop = selectedNoteBounds ?. top ?? null ;
649+ const selectedNoteHeight = selectedNoteBounds ?. height ?? null ;
623650
624651 // Track the previous selected anchor to detect actual selection changes.
625652 const prevSelectedAnchorIdRef = useRef < string | null > ( null ) ;
@@ -644,11 +671,14 @@ export function DiffPane({
644671 return false ;
645672 }
646673
647- // The pinned header owns the top row, so align the review stream to the file body.
648- scrollBox . scrollTo ( targetSection . bodyTop ) ;
674+ const viewportHeight = Math . max ( scrollViewport . height , scrollBox . viewport . height ?? 0 ) ;
675+
676+ // The pinned header owns the top row, so align the review stream to the file body. Clamp the
677+ // request so short trailing files can still settle cleanly at the reachable bottom edge.
678+ scrollBox . scrollTo ( clampReviewScrollTop ( targetSection . bodyTop , viewportHeight ) ) ;
649679 return true ;
650680 } ,
651- [ fileSectionLayouts , scrollRef ] ,
681+ [ clampReviewScrollTop , fileSectionLayouts , scrollRef , scrollViewport . height ] ,
652682 ) ;
653683
654684 useLayoutEffect ( ( ) => {
@@ -789,7 +819,11 @@ export function DiffPane({
789819 return ;
790820 }
791821
792- const desiredTop = targetSection . bodyTop ;
822+ const viewportHeight = Math . max ( scrollViewport . height , scrollRef . current ?. viewport . height ?? 0 ) ;
823+ // Compare against the reachable target, not the raw file body top. The last short file often
824+ // cannot actually own the viewport top near EOF, and treating that unreachable top as pending
825+ // would keep snapping manual upward scrolling back down to the bottom edge.
826+ const desiredTop = clampReviewScrollTop ( targetSection . bodyTop , viewportHeight ) ;
793827
794828 const currentTop = scrollRef . current ?. scrollTop ?? scrollViewport . top ;
795829 if ( Math . abs ( currentTop - desiredTop ) <= 0.5 ) {
@@ -800,17 +834,18 @@ export function DiffPane({
800834 suppressViewportSelectionSync ( ) ;
801835 scrollFileHeaderToTop ( pendingFileId ) ;
802836 } , [
837+ clampReviewScrollTop ,
803838 clearPendingFileTopAlign ,
804839 fileSectionLayouts ,
805840 files ,
806841 scrollFileHeaderToTop ,
807842 scrollRef ,
843+ scrollViewport . height ,
808844 scrollViewport . top ,
809845 suppressViewportSelectionSync ,
810846 ] ) ;
811847
812848 useLayoutEffect ( ( ) => {
813- const pinnedHeaderFileId = pinnedHeaderFile ?. id ?? null ;
814849 const revealFollowsSelectionChange = selectedHunkRevealRequestId === undefined ;
815850 const revealRequested = revealFollowsSelectionChange
816851 ? prevSelectedAnchorIdRef . current !== selectedAnchorId
@@ -847,16 +882,20 @@ export function DiffPane({
847882 const preferredTopPadding = Math . max ( 2 , Math . floor ( viewportHeight * 0.25 ) ) ;
848883
849884 // When navigating comment-to-comment, scroll the inline note card near the viewport top
850- // instead of positioning the entire hunk. Uses the same reveal function so the padding
851- // behavior matches regular hunk navigation.
885+ // instead of positioning the entire hunk. Clamp the reveal target too: notes in the final
886+ // hunk can request a top offset that is no longer reachable once the viewport hits EOF.
887+ // Using the reachable value keeps the reveal logic from fighting later manual scrolling.
852888 if ( selectedNoteBounds ) {
853889 scrollBox . scrollTo (
854- computeHunkRevealScrollTop ( {
855- hunkTop : selectedNoteBounds . top ,
856- hunkHeight : selectedNoteBounds . height ,
857- preferredTopPadding,
890+ clampReviewScrollTop (
891+ computeHunkRevealScrollTop ( {
892+ hunkTop : selectedNoteBounds . top ,
893+ hunkHeight : selectedNoteBounds . height ,
894+ preferredTopPadding,
895+ viewportHeight,
896+ } ) ,
858897 viewportHeight ,
859- } ) ,
898+ ) ,
860899 ) ;
861900 return ;
862901 }
@@ -871,6 +910,8 @@ export function DiffPane({
871910
872911 // Prefer exact mounted bounds when both edges are available. If only one edge has mounted
873912 // so far, fall back to the planned bounds as one atomic estimate instead of mixing sources.
913+ // The final reveal target still gets clamped below so a bottom-edge hunk does not keep
914+ // re-requesting an impossible scrollTop after the selection settles.
874915 const renderedTop = startRow ? currentScrollTop + ( startRow . y - viewportTop ) : null ;
875916 const renderedBottom = endRow
876917 ? currentScrollTop + ( endRow . y + endRow . height - viewportTop )
@@ -882,12 +923,15 @@ export function DiffPane({
882923 : selectedEstimatedHunkBounds . height ;
883924
884925 scrollBox . scrollTo (
885- computeHunkRevealScrollTop ( {
886- hunkTop,
887- hunkHeight,
888- preferredTopPadding,
926+ clampReviewScrollTop (
927+ computeHunkRevealScrollTop ( {
928+ hunkTop,
929+ hunkHeight,
930+ preferredTopPadding,
931+ viewportHeight,
932+ } ) ,
889933 viewportHeight ,
890- } ) ,
934+ ) ,
891935 ) ;
892936 return ;
893937 }
@@ -916,15 +960,20 @@ export function DiffPane({
916960 }
917961 } ;
918962 } , [
919- pinnedHeaderFile ?. id ,
963+ clampReviewScrollTop ,
964+ pinnedHeaderFileId ,
920965 scrollRef ,
921966 scrollViewport . height ,
922967 selectedAnchorId ,
923- selectedEstimatedHunkBounds ,
968+ selectedEstimatedHunkEndRowId ,
969+ selectedEstimatedHunkHeight ,
970+ selectedEstimatedHunkStartRowId ,
971+ selectedEstimatedHunkTop ,
924972 selectedFileIndex ,
925973 selectedHunkIndex ,
926974 selectedHunkRevealRequestId ,
927- selectedNoteBounds ,
975+ selectedNoteHeight ,
976+ selectedNoteTop ,
928977 suppressViewportSelectionSync ,
929978 ] ) ;
930979
0 commit comments