Skip to content

Pin the current file header while scrolling the review pane#141

Merged
benvinegar merged 4 commits intomodem-dev:mainfrom
tanvesh01:tanvesh01/issue-112-plan
Mar 31, 2026
Merged

Pin the current file header while scrolling the review pane#141
benvinegar merged 4 commits intomodem-dev:mainfrom
tanvesh01:tanvesh01/issue-112-plan

Conversation

@tanvesh01
Copy link
Copy Markdown
Contributor

@tanvesh01 tanvesh01 commented Mar 30, 2026

Closes #112

image

Summary

  • pin the current file header at the top of the diff pane until the next file header takes over
  • align sidebar file selection so the chosen file header owns the top of the review pane
  • share section layout math between sticky-header ownership, scroll targeting, and placeholder geometry
  • cover the sticky-header handoff and sidebar-selection behavior with pane/app tests

Testing

  • bun run typecheck
  • bun test test/ui-components.test.tsx test/app-interactions.test.tsx
  • attempted bun test, but Bun 1.3.5 segfaulted on this machine after running a large portion of the suite
  • attempted bun run test:tty-smoke, but the local macOS script binary here rejects -f (script: illegal option -- f)

Notes

  • kept the scope focused on the review-pane scrolling behavior described in the issue and extracted the shared file-header row/layout helpers needed to support it

@greptile-apps
Copy link
Copy Markdown

greptile-apps bot commented Mar 30, 2026

Greptile Summary

This PR implements sticky file-header pinning in the review pane: as you scroll past a file header it stays anchored at the top of the diff pane until the next file's header scrolls into view. Sidebar clicks now also align the selected file's header to exactly the top of the viewport. The implementation is well-structured — layout geometry is extracted into a new sectionLayout.ts module shared by sticky-header ownership, scroll targeting, and placeholder geometry, and the file-header markup is pulled into a DiffFileHeaderRow component used identically in DiffSection, DiffSectionPlaceholder, and the sticky overlay.

Key changes:

  • src/ui/lib/sectionLayout.ts (new): buildSectionLayoutMetrics, findHeaderOwningSection (binary search), resolveFileHeaderTop — centralises section-coordinate math previously duplicated across several inline loops.
  • src/ui/components/panes/DiffFileHeaderRow.tsx (new): Shared header-row component consumed by both the scroll stream and the sticky overlay.
  • src/ui/components/panes/DiffPane.tsx: Adds stickyHeaderFile computation, a selectedFileTopAlignRequestId counter prop from App, two new useLayoutEffect hooks (first-attempt scroll + pending-retry on metric change), and a fix to the existing selection auto-scroll effect — prevSelectedAnchorIdRef is now updated when the suppress flag is consumed, preventing a subsequent render from re-centering the selected hunk after either a top-align or a wrap-toggle.
  • test/: New createTwoFileHunkBootstrap, createTallDiffFile, and waitForFrame helpers; two new integration tests covering sticky-header handoff and sidebar-to-top alignment.

Minor notes:

  • effectiveScrollTop reads from scrollRef.current during render — functional but worth a clarifying comment.
  • pendingFileTopAlignFileIdRef can stay set indefinitely if scrollFileHeaderToTop never returns true (e.g. file removed from changeset), causing a permanent no-op retry loop.
  • The sidebar-click integration test uses a hard-coded Bun.sleep(80) rather than the waitForFrame predicate-polling helper introduced in the same PR.

Confidence Score: 5/5

Safe to merge — all findings are P2 style/robustness concerns with no functional impact on the primary review-pane scrolling path.

The sticky-header ownership logic is correct (binary search correctly attributes separator rows to the previous section, first-section edge case handled, single-file suppression is intentional). The counter-increment pattern for top-align requests safely handles repeated sidebar clicks on the same file. The addition of prevSelectedAnchorIdRef update when consuming the suppress flag is a genuine correctness improvement that also fixes a latent race in the wrap-toggle path. The two remaining P2 notes (pending-ref linger on deleted file, hard-coded sleep in one test) do not affect normal usage.

src/ui/components/panes/DiffPane.tsx (pendingFileTopAlignFileIdRef retry loop) and test/app-interactions.test.tsx (Bun.sleep timing)

Important Files Changed

Filename Overview
src/ui/components/panes/DiffPane.tsx Core change: adds sticky header overlay, selectedFileTopAlignRequestId-driven top-align on sidebar click, and a pending-retry mechanism for when section metrics haven't settled yet. effectiveScrollTop reads from a mutable DOM ref during render — see comment. The pendingFileTopAlignFileIdRef can linger if scrollFileHeaderToTop never succeeds.
src/ui/lib/sectionLayout.ts New module centralizing section layout geometry: buildSectionLayoutMetrics, findHeaderOwningSection (binary search), and resolveFileHeaderTop — clean, well-scoped, and correctly handles separator rows, edge-case empty arrays, and first-section headerTop = 0.
src/ui/components/panes/DiffFileHeaderRow.tsx New shared header-row component extracted from DiffSection and DiffSectionPlaceholder; rendering is identical to the original inline markup.
src/ui/App.tsx jumpToFile extended with { alignFileHeaderTop } option; sidebar onSelectFile now passes the option; selectedFileTopAlignRequestId counter threaded down to DiffPane. The counter increment pattern (current + 1) safely distinguishes repeated clicks on the same file.
src/ui/components/panes/DiffSection.tsx Header row replaced by DiffFileHeaderRow component; fitText import retained correctly for separator line rendering.
src/ui/components/panes/DiffSectionPlaceholder.tsx Header row replaced by DiffFileHeaderRow; separator line correctly keeps fitText; placeholder geometry (bodyHeight) is unchanged, so sticky-header ownership math stays stable.
src/ui/components/scrollbar/VerticalScrollbar.tsx Single-line change: adds zIndex: 2 to the scrollbar track so it renders above the new sticky header overlay.
test/ui-components.test.tsx New createTallDiffFile helper and waitForFrame retry helper added; sticky-header handoff test covers pin-on-scroll, separator-row ownership, and next-header takeover with predicate polling instead of raw sleeps.
test/app-interactions.test.tsx New createTwoFileHunkBootstrap and two new integration tests added; sidebar test uses a hard-coded Bun.sleep(80) wait rather than a predicate-polling helper, which may be fragile in CI.

Sequence Diagram

sequenceDiagram
    participant User
    participant Sidebar
    participant App
    participant DiffPane
    participant ScrollBox

    User->>Sidebar: click file row
    Sidebar->>App: onSelectFile(fileId)
    App->>App: setSelectedFileId(fileId)
    App->>App: setSelectedFileTopAlignRequestId(id + 1)
    App->>DiffPane: re-render with new selectedFileTopAlignRequestId

    DiffPane->>DiffPane: useLayoutEffect [selectedFileTopAlignRequestId changed]
    DiffPane->>DiffPane: suppressNextSelectionAutoScroll = true
    DiffPane->>DiffPane: pendingFileTopAlignFileId = fileId
    DiffPane->>ScrollBox: scrollTo(headerTop)

    DiffPane->>DiffPane: useLayoutEffect [sectionLayoutMetrics] (retry)
    alt scrollFileHeaderToTop succeeds
        DiffPane->>ScrollBox: scrollTo(headerTop)
        DiffPane->>DiffPane: clearPendingFileTopAlign()
    end

    DiffPane->>DiffPane: useLayoutEffect selection auto-scroll
    DiffPane->>DiffPane: suppress flag set → skip auto-scroll
    DiffPane->>DiffPane: prevSelectedAnchorId = selectedAnchorId

    User->>ScrollBox: scroll (any direction)
    ScrollBox-->>DiffPane: scrollViewport state update (50ms poll)
    DiffPane->>DiffPane: effectiveScrollTop = scrollRef.current.scrollTop
    DiffPane->>DiffPane: stickyHeaderFile = findHeaderOwningSection(...)
    alt scrollTop > owner.headerTop
        DiffPane->>DiffPane: render sticky DiffFileHeaderRow overlay
    else scrollTop <= owner.headerTop
        DiffPane->>DiffPane: hide sticky overlay
    end
Loading

Reviews (1): Last reviewed commit: "Pin file headers in the review pane" | Re-trigger Greptile

Comment on lines +367 to +383
const effectiveScrollTop = scrollRef.current?.scrollTop ?? scrollViewport.top;

// Calculate total content height including separators and headers
const totalContentHeight = useMemo(() => {
let total = 0;
for (let index = 0; index < files.length; index += 1) {
// Separator between files (except first)
if (index > 0) {
total += 1;
}
// File header
total += 1;
// File body
total += estimatedBodyHeights[index] ?? 0;
const totalContentHeight =
sectionLayoutMetrics[sectionLayoutMetrics.length - 1]?.sectionBottom ?? 0;

const stickyHeaderFile = useMemo(() => {
if (files.length < 2) {
return null;
}
return total;
}, [files.length, estimatedBodyHeights]);

const owner = findHeaderOwningSection(sectionLayoutMetrics, effectiveScrollTop);
if (!owner || effectiveScrollTop <= owner.headerTop) {
return null;
}

return files[owner.sectionIndex] ?? null;
}, [effectiveScrollTop, files, sectionLayoutMetrics]);
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Reading a mutable DOM ref during render

effectiveScrollTop is computed inline from scrollRef.current?.scrollTop during the render phase. This works correctly in practice because scroll events drive setScrollViewport state updates (via the 50 ms polling interval), which trigger re-renders that re-read the freshest DOM value before the stickyHeaderFile memo re-evaluates. However, the coupling between the polled state update and the ref read is implicit. A brief clarifying comment here would help future readers understand why scrollRef.current?.scrollTop is preferred over scrollViewport.top (it avoids up-to-50 ms lag between a programmatic scroll and the sticky-header handoff).

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Added an inline comment explaining why the sticky header reads the live scrollbox value during render: it keeps header ownership in sync with imperative scroll-to-header jumps instead of waiting for the polled viewport snapshot.

This comment was generated by Pi using gpt-5

Comment on lines +535 to +544
useLayoutEffect(() => {
const pendingFileId = pendingFileTopAlignFileIdRef.current;
if (!pendingFileId) {
return;
}

if (scrollFileHeaderToTop(pendingFileId)) {
clearPendingFileTopAlign();
}
}, [clearPendingFileTopAlign, scrollFileHeaderToTop, sectionLayoutMetrics]);
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 pendingFileTopAlignFileIdRef can linger if scrollFileHeaderToTop never returns true

scrollFileHeaderToTop returns false when either resolveFileHeaderTop finds no matching metric or scrollRef.current is null. In either case clearPendingFileTopAlign is never called, so pendingFileTopAlignFileIdRef.current stays set indefinitely. Every subsequent sectionLayoutMetrics change (e.g. as windowed sections mount and report measured heights) would re-trigger the effect and keep retrying — harmless scrolls if the file eventually appears, but a permanent no-op loop if it never does (e.g. a file deleted from the changeset between click and metrics settlement).

A simple guard — clearing the pending when files no longer contains pendingFileId — would bound the retry lifetime:

useLayoutEffect(() => {
  const pendingFileId = pendingFileTopAlignFileIdRef.current;
  if (!pendingFileId) {
    return;
  }

  // Stop retrying if the file was removed from the changeset.
  const fileStillPresent = sectionLayoutMetrics.some((m) => m.fileId === pendingFileId);
  if (!fileStillPresent || scrollFileHeaderToTop(pendingFileId)) {
    clearPendingFileTopAlign();
  }
}, [clearPendingFileTopAlign, scrollFileHeaderToTop, sectionLayoutMetrics]);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Good catch. I now clear the pending top-align request if the target file disappears before the retry can land, so we stop retrying a stale sidebar selection.

This comment was generated by Pi using gpt-5

Comment on lines +1260 to +1270
// Move partway into the first file so ownership can visibly change on sidebar selection.
for (let index = 0; index < 8; index += 1) {
await act(async () => {
await setup.mockInput.pressArrow("down");
});
await flush(setup);
}

await act(async () => {
// Click inside the second file row in the left sidebar.
await setup.mockMouse.click(6, 4);
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Hard-coded sleep may be fragile in CI

The surrounding ui-components.test.tsx defines a waitForFrame predicate-polling helper (added in this same PR) that retries up to 8 times with 50 ms gaps. Using a hard-coded Bun.sleep(80) here gives only one chance and assumes the scroll always settles within that window. Under load or in a slow CI runner this can fail spuriously. Replicating (or importing) the waitForFrame pattern used in ui-components.test.tsx would make this assertion as robust as the component-level sticky-header test.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Replaced the fixed sleep in this interaction test with the existing frame-polling helper, so the assertion waits for the pinned-header handoff instead of assuming a single 80 ms settle window.

This comment was generated by Pi using gpt-5

@benvinegar benvinegar force-pushed the tanvesh01/issue-112-plan branch from ba27c6d to 2caa253 Compare March 31, 2026 03:20
@benvinegar benvinegar merged commit 3cd541c into modem-dev:main Mar 31, 2026
3 checks passed
@benvinegar
Copy link
Copy Markdown
Member

@tanvesh01 - wanted this, thanks!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Pin the current file header to the top of the review pane while scrolling

2 participants