Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ All notable user-visible changes to Hunk are documented in this file.

### Fixed

- Expanded the diff window during rapid scrolling bursts so large reviews keep real rows mounted instead of falling back to blank placeholder regions.

## [0.14.1] - 2026-06-01

### Added
Expand Down
59 changes: 51 additions & 8 deletions src/ui/components/panes/DiffPane.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,10 @@ import {
reviewNoteSource,
type VisibleAgentNote,
} from "../../lib/agentAnnotations";
import {
computeRapidScrollOverscanRows,
RAPID_SCROLL_OVERSCAN_IDLE_MS,
} from "../../lib/adaptiveScrollOverscan";
import { computeHunkRevealScrollTop } from "../../lib/hunkScroll";
import {
measureDiffSectionGeometry,
Expand Down Expand Up @@ -123,12 +127,14 @@ function buildAdjacentPrefetchFileIds(files: DiffFile[], selectedFileId?: string
function buildHighlightPrefetchFileIds({
adjacentPrefetchFileIds,
fileSectionLayouts,
rapidScrollOverscanRows,
scrollTop,
viewportHeight,
selectedFileId,
}: {
adjacentPrefetchFileIds: Set<string>;
fileSectionLayouts: FileSectionLayout[];
rapidScrollOverscanRows: number;
scrollTop: number;
viewportHeight: number;
selectedFileId?: string;
Expand All @@ -140,7 +146,7 @@ function buildHighlightPrefetchFileIds({
}

const clampedViewportHeight = Math.max(1, viewportHeight);
const prefetchRows = Math.max(24, clampedViewportHeight * 3);
const prefetchRows = Math.max(24, clampedViewportHeight * 3, rapidScrollOverscanRows);
const minPrefetchY = Math.max(0, scrollTop - prefetchRows);
const maxPrefetchY = scrollTop + viewportHeight + prefetchRows;

Expand Down Expand Up @@ -406,6 +412,7 @@ export function DiffPane({
// other files can still use placeholders and viewport windowing.
const windowingEnabled = !wrapLines;
const [scrollViewport, setScrollViewport] = useState({ top: 0, height: 0 });
const [rapidScrollOverscanRows, setRapidScrollOverscanRows] = useState(0);
const [hoveredFileId, setHoveredFileId] = useState<string | null>(null);
const [copySelectionDrag, setCopySelectionDrag] = useState<CopySelectionDrag | null>(null);
// Mirror the drag state in a ref so updateCopySelection can suppress native selection
Expand All @@ -416,6 +423,7 @@ export function DiffPane({
const lastClickPointRef = useRef<CopySelectionPoint | null>(null);
const scrollbarRef = useRef<VerticalScrollbarHandle>(null);
const prevScrollTopRef = useRef(0);
const hasReadScrollViewportRef = useRef(false);
const previousSectionGeometryRef = useRef<DiffSectionGeometry[] | null>(null);
const previousFilesRef = useRef<DiffFile[]>(files);
const previousLayoutRef = useRef(layout);
Expand All @@ -429,11 +437,28 @@ export function DiffPane({
const suppressViewportSelectionSyncTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(
null,
);
const rapidScrollOverscanTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// Initialized to null so the first render never fires a selection change; a real scroll
// is required before passive viewport-follow selection can trigger.
const lastViewportSelectionTopRef = useRef<number | null>(null);
const lastViewportRowAnchorRef = useRef<ViewportRowAnchor | null>(null);

/** Temporarily widen the mounted diff window while scroll input is arriving in bursts. */
const activateRapidScrollOverscan = useCallback((overscanRows: number) => {
if (overscanRows <= 0) {
return;
}

setRapidScrollOverscanRows((current) => Math.max(current, overscanRows));
if (rapidScrollOverscanTimeoutRef.current) {
clearTimeout(rapidScrollOverscanTimeoutRef.current);
}
rapidScrollOverscanTimeoutRef.current = setTimeout(() => {
rapidScrollOverscanTimeoutRef.current = null;
setRapidScrollOverscanRows(0);
}, RAPID_SCROLL_OVERSCAN_IDLE_MS);
}, []);

/**
* Ignore viewport-follow selection updates while the pane is scrolling to an explicit selection.
* That lets direct hunk/file navigation own the viewport until the jump settles.
Expand All @@ -454,6 +479,9 @@ export function DiffPane({
if (suppressViewportSelectionSyncTimeoutRef.current) {
clearTimeout(suppressViewportSelectionSyncTimeoutRef.current);
}
if (rapidScrollOverscanTimeoutRef.current) {
clearTimeout(rapidScrollOverscanTimeoutRef.current);
}
};
}, []);

Expand All @@ -472,11 +500,23 @@ export function DiffPane({
const nextTop = scrollBox.scrollTop ?? 0;
const nextHeight = scrollBox.viewport.height ?? 0;

// Detect scroll activity, show scrollbar, and clear hover-only controls. The pointer may
// now sit over a different row, but only an actual mouse move should reveal row actions.
if (nextTop !== prevScrollTopRef.current) {
// The first viewport read is a baseline snapshot, not scroll input. The scroll box may retain
// a non-zero top across remounts, so do not treat that retained position as a rapid burst.
if (!hasReadScrollViewportRef.current) {
hasReadScrollViewportRef.current = true;
prevScrollTopRef.current = nextTop;
} else if (nextTop !== prevScrollTopRef.current) {
// Detect scroll activity, show scrollbar, and clear hover-only controls. The pointer may
// now sit over a different row, but only an actual mouse move should reveal row actions.
const previousTop = prevScrollTopRef.current;
scrollbarRef.current?.show();
clearAddNoteHoverForScroll();
activateRapidScrollOverscan(
computeRapidScrollOverscanRows({
deltaRows: nextTop - previousTop,
viewportHeight: nextHeight,
}),
);
prevScrollTopRef.current = nextTop;
}

Expand Down Expand Up @@ -524,7 +564,7 @@ export function DiffPane({
scrollBox.viewport.off("layout-changed", handleViewportChange);
scrollBox.viewport.off("resized", handleViewportChange);
};
}, [clearAddNoteHoverForScroll, files.length, scrollRef]);
}, [activateRapidScrollOverscan, clearAddNoteHoverForScroll, files.length, scrollRef]);

const sectionHeaderHeights = useMemo(() => buildInStreamFileHeaderHeights(files), [files]);

Expand Down Expand Up @@ -568,11 +608,11 @@ export function DiffPane({
);

const visibleViewportFileIds = useMemo(() => {
const overscanTerminalRows = 8;
const overscanTerminalRows = Math.max(8, rapidScrollOverscanRows);
const minVisibleY = Math.max(0, scrollViewport.top - overscanTerminalRows);
const maxVisibleY = scrollViewport.top + scrollViewport.height + overscanTerminalRows;
return collectIntersectingFileSectionIds(baseFileSectionLayouts, minVisibleY, maxVisibleY);
}, [baseFileSectionLayouts, scrollViewport.height, scrollViewport.top]);
}, [baseFileSectionLayouts, rapidScrollOverscanRows, scrollViewport.height, scrollViewport.top]);

const visibleAgentNotesByFile = useMemo(() => {
const next = new Map<string, VisibleAgentNote[]>();
Expand Down Expand Up @@ -970,13 +1010,15 @@ export function DiffPane({
buildHighlightPrefetchFileIds({
adjacentPrefetchFileIds,
fileSectionLayouts,
rapidScrollOverscanRows,
scrollTop: scrollViewport.top,
viewportHeight: scrollViewport.height,
selectedFileId,
}),
[
adjacentPrefetchFileIds,
fileSectionLayouts,
rapidScrollOverscanRows,
scrollViewport.height,
scrollViewport.top,
selectedFileId,
Expand Down Expand Up @@ -1076,7 +1118,7 @@ export function DiffPane({
return next;
}

const overscanTerminalRows = Math.max(24, scrollViewport.height * 2);
const overscanTerminalRows = Math.max(24, scrollViewport.height * 2, rapidScrollOverscanRows);

files.forEach((file, index) => {
const sectionLayout = fileSectionLayouts[index];
Expand Down Expand Up @@ -1115,6 +1157,7 @@ export function DiffPane({
}, [
fileSectionLayouts,
files,
rapidScrollOverscanRows,
scrollViewport.height,
scrollViewport.top,
sectionGeometry,
Expand Down
22 changes: 22 additions & 0 deletions src/ui/lib/adaptiveScrollOverscan.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { describe, expect, test } from "bun:test";
import { computeRapidScrollOverscanRows } from "./adaptiveScrollOverscan";

describe("computeRapidScrollOverscanRows", () => {
test("leaves slow row-by-row movement on the default window", () => {
expect(computeRapidScrollOverscanRows({ deltaRows: 1, viewportHeight: 30 })).toBe(0);
expect(computeRapidScrollOverscanRows({ deltaRows: -3, viewportHeight: 30 })).toBe(0);
});
Comment on lines +5 to +8
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 The exact threshold boundary deltaRows === RAPID_SCROLL_MIN_DELTA_ROWS (4) is untested. Since the guard is < 4 (strictly less than), deltaRows: 4 should trigger the calculation — but there's no assertion confirming that. Adding a case at 4 would pin the inclusive boundary explicitly.

Suggested change
test("leaves slow row-by-row movement on the default window", () => {
expect(computeRapidScrollOverscanRows({ deltaRows: 1, viewportHeight: 30 })).toBe(0);
expect(computeRapidScrollOverscanRows({ deltaRows: -3, viewportHeight: 30 })).toBe(0);
});
test("leaves slow row-by-row movement on the default window", () => {
expect(computeRapidScrollOverscanRows({ deltaRows: 1, viewportHeight: 30 })).toBe(0);
expect(computeRapidScrollOverscanRows({ deltaRows: -3, viewportHeight: 30 })).toBe(0);
// Exactly at the threshold (4) is the first value that should trigger expansion.
expect(computeRapidScrollOverscanRows({ deltaRows: 4, viewportHeight: 30 })).toBe(90);
});
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/ui/lib/adaptiveScrollOverscan.test.ts
Line: 5-8

Comment:
The exact threshold boundary `deltaRows === RAPID_SCROLL_MIN_DELTA_ROWS` (4) is untested. Since the guard is `< 4` (strictly less than), `deltaRows: 4` should trigger the calculation — but there's no assertion confirming that. Adding a case at `4` would pin the inclusive boundary explicitly.

```suggestion
  test("leaves slow row-by-row movement on the default window", () => {
    expect(computeRapidScrollOverscanRows({ deltaRows: 1, viewportHeight: 30 })).toBe(0);
    expect(computeRapidScrollOverscanRows({ deltaRows: -3, viewportHeight: 30 })).toBe(0);
    // Exactly at the threshold (4) is the first value that should trigger expansion.
    expect(computeRapidScrollOverscanRows({ deltaRows: 4, viewportHeight: 30 })).toBe(90);
  });
```

How can I resolve this? If you propose a fix, please make it concise.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added a boundary test for deltaRows === 4 so the inclusive burst threshold is explicit.

This comment was generated by Pi using OpenAI GPT-5


test("treats the minimum burst threshold as inclusive", () => {
expect(computeRapidScrollOverscanRows({ deltaRows: 4, viewportHeight: 30 })).toBe(90);
});

test("expands to at least three viewports during bursty scrolling", () => {
expect(computeRapidScrollOverscanRows({ deltaRows: 8, viewportHeight: 30 })).toBe(90);
});

test("scales with large coalesced jumps but stays bounded", () => {
expect(computeRapidScrollOverscanRows({ deltaRows: 80, viewportHeight: 20 })).toBe(160);
expect(computeRapidScrollOverscanRows({ deltaRows: -1_000, viewportHeight: 40 })).toBe(240);
});
});
31 changes: 31 additions & 0 deletions src/ui/lib/adaptiveScrollOverscan.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
export const RAPID_SCROLL_OVERSCAN_IDLE_MS = 160;

const RAPID_SCROLL_MIN_DELTA_ROWS = 4;
const RAPID_SCROLL_MIN_VIEWPORT_MULTIPLIER = 3;
const RAPID_SCROLL_MAX_OVERSCAN_ROWS = 240;

/**
* Return the temporary overscan halo to use after one coalesced scroll jump.
*
* Slow row-by-row movement keeps the default window small. Bursty wheel/page movement expands the
* mounted window for a short idle period so the terminal can keep showing real, slightly
* over-rendered rows instead of placeholders while scroll events outrun React commits.
*/
export function computeRapidScrollOverscanRows({
deltaRows,
viewportHeight,
}: {
deltaRows: number;
viewportHeight: number;
}) {
const absoluteDeltaRows = Math.abs(deltaRows);
if (absoluteDeltaRows < RAPID_SCROLL_MIN_DELTA_ROWS) {
return 0;
}

const viewportRows = Math.max(1, Math.floor(viewportHeight));
return Math.min(
RAPID_SCROLL_MAX_OVERSCAN_ROWS,
Math.max(absoluteDeltaRows * 2, viewportRows * RAPID_SCROLL_MIN_VIEWPORT_MULTIPLIER),
);
}