feat: reviews block agenda, sticky headers, accurate diff scroll (#1653)

Rework the AI-sorted blocks experience on the PR reviews page into a
Google-Docs-style outline: the left sidebar is now a clean number+title
agenda with scroll-spy highlighting of the active block; each block shows
its title + description (sticky) above its diff; and diff rows are pinned to
a uniform height so scroll-to lands precisely via the virtualizer's own
geometry instead of an estimate-driven correction loop.

Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com>
(cherry picked from commit 0b76afdc955e33805c7623d1502a75a9c7c9c1b7)
This commit is contained in:
Caroline di Vittorio 2026-06-30 15:45:32 -07:00 • committed by Adam Moussa
parent e3c3ce3f8a
commit 4c567d8b29
No known key found for this signature in database
3 changed files with 211 additions and 141 deletions

View file

@ -41,6 +41,7 @@ import {
MultiFileDiff, MultiFileDiff,
Virtualizer, Virtualizer,
WorkerPoolContextProvider, WorkerPoolContextProvider,
useVirtualizer,
} from "@pierre/diffs/react" } from "@pierre/diffs/react"
import type { Icon } from "@phosphor-icons/react" import type { Icon } from "@phosphor-icons/react"
import type { FileContents } from "@pierre/diffs/react" import type { FileContents } from "@pierre/diffs/react"
@ -73,7 +74,10 @@ import {
ReviewChatComposerProvider, ReviewChatComposerProvider,
useReviewChatComposer, useReviewChatComposer,
} from "@/components/agents/ReviewChat" } from "@/components/agents/ReviewChat"
import { ReviewSidebarPanel } from "@/components/agents/ReviewSidebar" import {
ReviewSidebarPanel,
renderInlineCode,
} from "@/components/agents/ReviewSidebar"
import { import {
DIFF_VIRTUALIZER_CONFIG, DIFF_VIRTUALIZER_CONFIG,
DIFF_VIRTUAL_METRICS, DIFF_VIRTUAL_METRICS,
@ -255,6 +259,60 @@ function scrollCardToTop(el: HTMLElement, scroller: HTMLElement | null): void {
requestAnimationFrame(align) requestAnimationFrame(align)
} }
// The virtualizer instance returned by useVirtualizer(); exposes
// getOffsetInScrollContainer for accurate scroll targeting.
type DiffVirtualizer = NonNullable<ReturnType<typeof useVirtualizer>>
// Breathing room left above a block/file when it's scrolled to the top.
const SCROLL_TOP_GAP = 8
// Scroll a block / file card flush to the top of the diff scroller using the
// virtualizer's own geometry. getOffsetInScrollContainer returns the element's
// absolute offset within the scroll content; with uniform fixed-height rows
// (see diffUtils) that offset is stable, so a single smooth scroll lands
// precisely. As any not-yet-measured rows above the target reconcile during the
// scroll the offset can shift slightly, so once the scroll settles we re-read it
// and re-assert exactly once — no 240-frame correction loop needed.
function scrollCardToTopVirtual(
el: HTMLElement,
scroller: HTMLElement,
virtualizer: DiffVirtualizer
): void {
const targetTop = () =>
clampScrollTop(
scroller,
virtualizer.getOffsetInScrollContainer(el) - SCROLL_TOP_GAP
)
scroller.scrollTo({ top: targetTop(), behavior: "smooth" })
let frames = 0
let lastTop = Number.NaN
let stableFrames = 0
const settle = () => {
if (frames++ > 120) return
const top = scroller.scrollTop
if (top === lastTop) stableFrames++
else {
stableFrames = 0
lastTop = top
}
if (stableFrames < 3) {
requestAnimationFrame(settle)
return
}
const desired = targetTop()
if (Math.abs(desired - scroller.scrollTop) > 1) {
scroller.scrollTo({ top: desired, behavior: "auto" })
}
}
requestAnimationFrame(settle)
}
// Older stored summaries embed `[label](#loc=path:line)` diff links; render the
// label as inline code instead so no stale jump-links leak into the block body.
function stripLocationLinks(summary: string): string {
return summary.replace(/\[([^\]]+)\]\(#loc=[^)]*\)/g, "`$1`")
}
interface PositionedDiffInstance { interface PositionedDiffInstance {
getLinePosition: ( getLinePosition: (
lineNumber: number, lineNumber: number,
@ -561,8 +619,12 @@ function ReviewBodyInner({
range: SelectedLineRange range: SelectedLineRange
} | null>(null) } | null>(null)
const diffScrollElRef = useRef<HTMLDivElement | null>(null) const diffScrollElRef = useRef<HTMLDivElement | null>(null)
const virtualizerRef = useRef<DiffVirtualizer | null>(null)
const findingScrollRequestRef = useRef(0) const findingScrollRequestRef = useRef(0)
const groupRefs = useRef<Record<number, HTMLDivElement | null>>({}) const groupRefs = useRef<Record<number, HTMLDivElement | null>>({})
// The block pinned at the top of the diff (scroll-spy), highlighted in the
// agenda sidebar.
const [activeGroup, setActiveGroup] = useState<number | null>(null)
const [diffStyle, setDiffStyleState] = useState<DiffStyle>(() => const [diffStyle, setDiffStyleState] = useState<DiffStyle>(() =>
readStoredDiffStyle() readStoredDiffStyle()
) )
@ -726,11 +788,6 @@ function ReviewBodyInner({
return groupedView.map((group) => ({ return groupedView.map((group) => ({
index: group.index, index: group.index,
title: group.title, title: group.title,
summary: group.summary,
additions: group.additions,
deletions: group.deletions,
fileCount: group.files.length,
files: group.files.map((file) => file.path),
})) }))
}, [groupedView]) }, [groupedView])
@ -759,17 +816,60 @@ function ReviewBodyInner({
setExpandedFiles((prev) => ({ ...prev, [path]: true })) setExpandedFiles((prev) => ({ ...prev, [path]: true }))
requestAnimationFrame(() => { requestAnimationFrame(() => {
const el = fileRefs.current[path] const el = fileRefs.current[path]
if (el) scrollCardToTop(el, diffScrollElRef.current) const scroller = diffScrollElRef.current
if (!el || !scroller) return
if (virtualizerRef.current)
scrollCardToTopVirtual(el, scroller, virtualizerRef.current)
else scrollCardToTop(el, scroller)
}) })
}, []) }, [])
const scrollToGroup = useCallback((index: number) => { const scrollToGroup = useCallback((index: number) => {
requestAnimationFrame(() => { requestAnimationFrame(() => {
const el = groupRefs.current[index] const el = groupRefs.current[index]
if (el) scrollCardToTop(el, diffScrollElRef.current) const scroller = diffScrollElRef.current
if (!el || !scroller) return
if (virtualizerRef.current)
scrollCardToTopVirtual(el, scroller, virtualizerRef.current)
else scrollCardToTop(el, scroller)
}) })
}, []) }, [])
// Scroll-spy: track which block's header is currently pinned at the top of the
// diff scroller and surface it as the active agenda row (Google-Docs outline).
useEffect(() => {
if (view !== "ai" || !groupedView || groupedView.length === 0) {
setActiveGroup(null)
return
}
const scroller = diffScrollElRef.current
if (!scroller) return
let raf = 0
const compute = () => {
raf = 0
const top = scroller.getBoundingClientRect().top
let current = groupedView[0]?.index ?? null
for (const group of groupedView) {
const el = groupRefs.current[group.index]
if (!el) continue
if (el.getBoundingClientRect().top - top <= SCROLL_TOP_GAP + 2)
current = group.index
else break
}
setActiveGroup(current)
}
const onScroll = () => {
if (raf) return
raf = requestAnimationFrame(compute)
}
compute()
scroller.addEventListener("scroll", onScroll, { passive: true })
return () => {
scroller.removeEventListener("scroll", onScroll)
if (raf) cancelAnimationFrame(raf)
}
}, [view, groupedView])
const filesByPath = useMemo( const filesByPath = useMemo(
() => new Map((diffFiles ?? []).map((file) => [file.path, file])), () => new Map((diffFiles ?? []).map((file) => [file.path, file])),
[diffFiles] [diffFiles]
@ -1055,6 +1155,7 @@ function ReviewBodyInner({
view, view,
onViewChange: setView, onViewChange: setView,
onSelectGroup: scrollToGroup, onSelectGroup: scrollToGroup,
activeGroup,
}), }),
[ [
detail.number, detail.number,
@ -1066,6 +1167,7 @@ function ReviewBodyInner({
view, view,
setView, setView,
scrollToGroup, scrollToGroup,
activeGroup,
] ]
) )
@ -1122,7 +1224,10 @@ function ReviewBodyInner({
)} )}
config={DIFF_VIRTUALIZER_CONFIG} config={DIFF_VIRTUALIZER_CONFIG}
> >
<div ref={scrollerProbe} aria-hidden className="hidden" /> <VirtualizerBridge
probeRef={scrollerProbe}
instanceRef={virtualizerRef}
/>
<PrHeader <PrHeader
url={detail.url} url={detail.url}
title={detail.pr.title} title={detail.pr.title}
@ -1271,21 +1376,54 @@ function DiffStyleButton({
) )
} }
// Grabs the virtualizer instance from context (only available inside
// <Virtualizer>) and lifts it to the parent ref so scroll-to can read accurate
// offsets. Doubles as the hidden scroll-element probe.
function VirtualizerBridge({
probeRef,
instanceRef,
}: {
probeRef: (node: HTMLDivElement | null) => void
instanceRef: React.MutableRefObject<DiffVirtualizer | null>
}) {
const virtualizer = useVirtualizer()
useEffect(() => {
instanceRef.current = virtualizer ?? null
}, [virtualizer, instanceRef])
return <div ref={probeRef} aria-hidden className="hidden" />
}
// The block header: number + title + stats, then the block description. Pinned
// at the top of the diff scroller while scrolling the block (Google-Docs feel),
// stacked above Pierre's in-diff sticky header (z-index 4). A long description
// scrolls within the pinned header instead of consuming the viewport.
function GroupHeader({ group }: { group: ResolvedGroup }) { function GroupHeader({ group }: { group: ResolvedGroup }) {
const title = useMemo(() => renderInlineCode(group.title), [group.title])
const summary = useMemo(
() => (group.summary ? stripLocationLinks(group.summary) : ""),
[group.summary]
)
return ( return (
<div className="flex items-center gap-2"> <div className="sticky top-0 z-[5] border-b border-border bg-background pb-2">
<span className="flex size-5 shrink-0 items-center justify-center rounded bg-[var(--ui-panel-2)] text-[11px] font-medium text-muted-foreground"> <div className="flex items-center gap-2">
{group.index} <span className="flex size-5 shrink-0 items-center justify-center rounded bg-[var(--ui-panel-2)] text-[11px] font-medium text-muted-foreground">
</span> {group.index}
<h3 className="min-w-0 truncate text-sm font-medium">{group.title}</h3> </span>
<span className="flex shrink-0 items-center gap-1.5 font-mono text-[11px]"> <h3 className="min-w-0 flex-1 truncate text-sm font-medium">{title}</h3>
{group.additions > 0 && ( <span className="flex shrink-0 items-center gap-1.5 font-mono text-[11px]">
<span className="text-emerald-500">+{group.additions}</span> {group.additions > 0 && (
)} <span className="text-emerald-500">+{group.additions}</span>
{group.deletions > 0 && ( )}
<span className="text-red-500">-{group.deletions}</span> {group.deletions > 0 && (
)} <span className="text-red-500">-{group.deletions}</span>
</span> )}
</span>
</div>
{summary && (
<div className="mt-2 max-h-40 overflow-y-auto text-xs text-muted-foreground">
<Markdown content={summary} />
</div>
)}
</div> </div>
) )
} }

View file

@ -1,14 +1,10 @@
import { memo, useCallback, useEffect, useMemo, useState } from "react" import { memo, useCallback, useEffect, useMemo } from "react"
import { import {
FileTree, FileTree,
useFileTree, useFileTree,
useFileTreeSelection, useFileTreeSelection,
} from "@pierre/trees/react" } from "@pierre/trees/react"
import { import { ListBulletsIcon, TreeViewIcon } from "@phosphor-icons/react"
CaretRightIcon,
ListBulletsIcon,
TreeViewIcon,
} from "@phosphor-icons/react"
import type { ReactNode } from "react" import type { ReactNode } from "react"
import type { import type {
@ -17,7 +13,6 @@ import type {
GitStatusEntry, GitStatusEntry,
} from "@pierre/trees" } from "@pierre/trees"
import type { ReviewDiffFile } from "@/lib/api" import type { ReviewDiffFile } from "@/lib/api"
import { Markdown } from "@/components/agents/ported"
import { Skeleton } from "@/components/ui/skeleton" import { Skeleton } from "@/components/ui/skeleton"
import { import {
TREE_UNSAFE_CSS, TREE_UNSAFE_CSS,
@ -37,11 +32,6 @@ export type ReviewSidebarView = "ai" | "files"
export interface ReviewSidebarGroup { export interface ReviewSidebarGroup {
index: number index: number
title: string title: string
summary: string
additions: number
deletions: number
fileCount: number
files: Array<string>
} }
export interface ReviewSidebarData { export interface ReviewSidebarData {
@ -54,6 +44,9 @@ export interface ReviewSidebarData {
view: ReviewSidebarView view: ReviewSidebarView
onViewChange: (view: ReviewSidebarView) => void onViewChange: (view: ReviewSidebarView) => void
onSelectGroup: (index: number) => void onSelectGroup: (index: number) => void
// The block currently pinned at the top of the diff (scroll-spy), highlighted
// in the agenda. null when no block is active or the AI view isn't shown.
activeGroup: number | null
} }
export function ReviewSidebarPanel({ data }: { data: ReviewSidebarData }) { export function ReviewSidebarPanel({ data }: { data: ReviewSidebarData }) {
@ -73,8 +66,8 @@ export function ReviewSidebarPanel({ data }: { data: ReviewSidebarData }) {
{showAi ? ( {showAi ? (
<ReviewGroupList <ReviewGroupList
groups={data.groups ?? []} groups={data.groups ?? []}
activeGroup={data.activeGroup}
onSelectGroup={data.onSelectGroup} onSelectGroup={data.onSelectGroup}
onSelectFile={data.onSelect}
/> />
) : !data.files ? ( ) : !data.files ? (
<div className="px-4 pt-1"> <div className="px-4 pt-1">
@ -150,43 +143,31 @@ function ReviewViewToggleButton({
function ReviewGroupList({ function ReviewGroupList({
groups, groups,
activeGroup,
onSelectGroup, onSelectGroup,
onSelectFile,
}: { }: {
groups: Array<ReviewSidebarGroup> groups: Array<ReviewSidebarGroup>
activeGroup: number | null
onSelectGroup: (index: number) => void onSelectGroup: (index: number) => void
onSelectFile: (path: string) => void
}) { }) {
return ( return (
<div className="min-h-0 flex-1 divide-y divide-[var(--ui-border-subtle)] overflow-y-auto"> <div className="min-h-0 flex-1 overflow-y-auto py-1">
{groups.map((group) => ( {groups.map((group) => (
<ReviewGroupRow <ReviewGroupRow
key={group.index} key={group.index}
group={group} group={group}
active={group.index === activeGroup}
onSelectGroup={onSelectGroup} onSelectGroup={onSelectGroup}
onSelectFile={onSelectFile}
/> />
))} ))}
</div> </div>
) )
} }
function splitPath(path: string): { dir: string; base: string } {
const idx = path.lastIndexOf("/")
if (idx === -1) return { dir: "", base: path }
return { dir: path.slice(0, idx), base: path.slice(idx + 1) }
}
// Older stored summaries embed `[label](#loc=path:line)` diff links. Render the
// label as inline code instead so no stale jump-links leak into the explanation.
function stripLocationLinks(summary: string): string {
return summary.replace(/\[([^\]]+)\]\(#loc=[^)]*\)/g, "`$1`")
}
// Render a title with `backtick`-delimited spans as inline code chips, matching // Render a title with `backtick`-delimited spans as inline code chips, matching
// the Markdown component's inline-code styling, without pulling in the full // the Markdown component's inline-code styling, without pulling in the full
// block renderer for a single line. // block renderer for a single line.
function renderInlineCode(text: string): Array<ReactNode> { export function renderInlineCode(text: string): Array<ReactNode> {
return text.split(/(`[^`]+`)/g).map((part, i) => { return text.split(/(`[^`]+`)/g).map((part, i) => {
if (part.length >= 2 && part.startsWith("`") && part.endsWith("`")) { if (part.length >= 2 && part.startsWith("`") && part.endsWith("`")) {
return ( return (
@ -202,26 +183,20 @@ function renderInlineCode(text: string): Array<ReactNode> {
}) })
} }
// The whole card is the scroll-to-group target so clicks anywhere (including // A single agenda entry: just the block number + title, like a Google-Docs
// the expanded explanation body) focus the diff. Nested controls — the file // outline. Clicking (or Enter/Space) scrolls the diff to that block. The active
// links and the "Read explanation" toggle — stop propagation so they keep // block (scroll-spy) gets an accent rule + emphasis. memo'd so scroll-spy
// their own behavior. memo'd + memoized string processing so re-renders from // re-renders only repaint the rows whose active state actually changed.
// sibling state don't re-run Markdown/inline-code work.
const ReviewGroupRow = memo(function ReviewGroupRow({ const ReviewGroupRow = memo(function ReviewGroupRow({
group, group,
active,
onSelectGroup, onSelectGroup,
onSelectFile,
}: { }: {
group: ReviewSidebarGroup group: ReviewSidebarGroup
active: boolean
onSelectGroup: (index: number) => void onSelectGroup: (index: number) => void
onSelectFile: (path: string) => void
}) { }) {
const [expanded, setExpanded] = useState(false)
const title = useMemo(() => renderInlineCode(group.title), [group.title]) const title = useMemo(() => renderInlineCode(group.title), [group.title])
const summary = useMemo(
() => stripLocationLinks(group.summary),
[group.summary]
)
const selectGroup = useCallback( const selectGroup = useCallback(
() => onSelectGroup(group.index), () => onSelectGroup(group.index),
[onSelectGroup, group.index] [onSelectGroup, group.index]
@ -240,86 +215,29 @@ const ReviewGroupRow = memo(function ReviewGroupRow({
<div <div
role="button" role="button"
tabIndex={0} tabIndex={0}
aria-current={active ? "true" : undefined}
onClick={selectGroup} onClick={selectGroup}
onKeyDown={onKeyDown} onKeyDown={onKeyDown}
className="cursor-pointer px-3 py-3 transition-colors hover:bg-[var(--ui-sidebar-hover)]" className={cn(
"flex cursor-pointer items-start gap-2 border-l-2 px-3 py-1.5 text-left transition-colors",
active
? "border-[var(--ui-accent)] bg-[var(--ui-sidebar-hover)]"
: "border-transparent hover:bg-[var(--ui-sidebar-hover)]"
)}
> >
<div className="flex w-full items-start gap-2 text-left"> <span className="mt-px shrink-0 text-[11px] font-medium text-[var(--ui-text-dim)] tabular-nums">
<span className="mt-0.5 flex size-5 shrink-0 items-center justify-center rounded bg-[var(--ui-panel-2)] text-[11px] font-medium text-[var(--ui-text-dim)]"> {group.index}.
{group.index} </span>
</span> <span
<span className="min-w-0 flex-1"> className={cn(
<span className="block text-xs leading-5 font-medium text-[var(--ui-text)]"> "min-w-0 text-xs leading-5",
{title} active
</span> ? "font-medium text-[var(--ui-text)]"
<span className="mt-1 flex flex-wrap items-center gap-1.5 text-[11px] text-[var(--ui-text-dim)]"> : "text-[var(--ui-text-muted)]"
<span> )}
{group.fileCount} file{group.fileCount === 1 ? "" : "s"} >
</span> {title}
{group.additions > 0 && ( </span>
<span className="text-emerald-500">+{group.additions}</span>
)}
{group.deletions > 0 && (
<span className="text-red-500">-{group.deletions}</span>
)}
</span>
</span>
</div>
{group.files.length > 0 && (
<div className="mt-2 space-y-0.5 pl-7">
{group.files.map((path) => {
const { dir, base } = splitPath(path)
return (
<button
key={path}
type="button"
onClick={(event) => {
event.stopPropagation()
onSelectFile(path)
}}
title={path}
className="flex w-full items-baseline gap-1.5 text-left text-[11px] hover:text-[var(--ui-accent)]"
>
<span className="shrink-0 font-medium text-[var(--ui-text-muted)]">
{base}
</span>
{dir && (
<span className="min-w-0 truncate text-[var(--ui-text-dim)]">
{dir}
</span>
)}
</button>
)
})}
</div>
)}
{group.summary && (
<div className="mt-2">
<button
type="button"
onClick={(event) => {
event.stopPropagation()
setExpanded((value) => !value)
}}
className="inline-flex items-center gap-1 text-[11px] font-medium text-[var(--ui-accent)]"
>
<CaretRightIcon
className={cn(
"size-3 transition-transform",
expanded && "rotate-90"
)}
/>
Read explanation
</button>
{expanded && (
<div className="mt-1.5">
<Markdown content={summary} />
</div>
)}
</div>
)}
</div> </div>
) )
}) })

View file

@ -70,6 +70,18 @@ export const DIFF_UNSAFE_CSS = `
[data-gutter-buffer="annotation"][data-selected-line] { [data-gutter-buffer="annotation"][data-selected-line] {
--diffs-line-bg: var(--ui-panel) !important; --diffs-line-bg: var(--ui-panel) !important;
} }
/* Pin every code row to one exact, uniform height (kept in sync with
DIFF_VIRTUAL_METRICS.lineHeight below). In scroll mode code never wraps, so a
hard height won't clip content — it just makes the virtualizer's per-line
estimate match measured layout, so scroll-to lands precisely instead of
over/under-shooting as off-estimate rows reconcile while scrolling. */
[data-line] {
height: 18px !important;
min-height: 18px !important;
max-height: 18px !important;
line-height: 18px !important;
}
` `
export const diffOptions = { export const diffOptions = {
@ -104,6 +116,8 @@ export const DIFF_VIRTUALIZER_CONFIG = {
export const DIFF_VIRTUAL_METRICS = { export const DIFF_VIRTUAL_METRICS = {
hunkLineCount: 80, hunkLineCount: 80,
// Must match the hard `[data-line]` height pinned in DIFF_UNSAFE_CSS so the
// virtualizer's pre-measurement estimate equals the measured row height.
lineHeight: 18, lineHeight: 18,
diffHeaderHeight: 0, diffHeaderHeight: 0,
spacing: 8, spacing: 8,