feat: render review findings as inline annotations (#1581)

Replace the floating finding cards with inline annotations in the PR diff.
An anchored finding shows a collapsed header at its line and expands the full
details in place; non-anchored findings expand inline in the side-panel row.
Expand state is lifted into a context so it survives Pierre's annotation
windowing and the side panel can drive it.

Removes the anchored/fallback floating cards and their rAF/ResizeObserver
scroll-tracking. Also stops the line-selection highlight from bleeding behind
the inline annotation row.
This commit is contained in:
Johannes du Plessis 2026-06-19 20:24:05 -07:00 • committed by GitHub
parent c84a7b64b1
commit 855d0fcb42
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 287 additions and 379 deletions

View file

@ -62,6 +62,14 @@ export const DIFF_UNSAFE_CSS = `
background-color: var(--ui-accent-bubble) !important; background-color: var(--ui-accent-bubble) !important;
color: var(--ui-text-dim) !important; color: var(--ui-text-dim) !important;
} }
/* A selected line propagates [data-selected-line] onto its annotation row and
gutter, bleeding the selection background behind inline annotation content.
Keep the code line highlighted, but hold the annotation row at the panel bg. */
[data-line-annotation][data-selected-line],
[data-gutter-buffer="annotation"][data-selected-line] {
--diffs-line-bg: var(--ui-panel) !important;
}
` `
export const diffOptions = { export const diffOptions = {

View file

@ -1,10 +1,11 @@
import { Link, Navigate, createFileRoute } from "@tanstack/react-router" import { Link, Navigate, createFileRoute } from "@tanstack/react-router"
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query" import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
import { import {
createContext,
memo, memo,
useCallback, useCallback,
useContext,
useEffect, useEffect,
useLayoutEffect,
useMemo, useMemo,
useRef, useRef,
useState, useState,
@ -24,7 +25,6 @@ import {
RowsIcon, RowsIcon,
SquareSplitHorizontalIcon, SquareSplitHorizontalIcon,
XCircleIcon, XCircleIcon,
XIcon,
} from "@phosphor-icons/react" } from "@phosphor-icons/react"
import { IoLogoGithub } from "react-icons/io5" import { IoLogoGithub } from "react-icons/io5"
import { import {
@ -106,7 +106,13 @@ function makeSideAttachment(
const language = file.path.includes(".") const language = file.path.includes(".")
? (file.path.split(".").pop() ?? "") ? (file.path.split(".").pop() ?? "")
: "" : ""
return { id: crypto.randomUUID(), path: file.path, lineLabel, language, snippet } return {
id: crypto.randomUUID(),
path: file.path,
lineLabel,
language,
snippet,
}
} }
// Build chat attachments from the selected range. A range can span from a // Build chat attachments from the selected range. A range can span from a
@ -237,6 +243,27 @@ function findingClipboardText(finding: ReviewFinding): string {
return lines.join("\n") return lines.join("\n")
} }
// Inline findings live inside Pierre's diff via React portals, so their
// expand/collapse state is lifted here and shared through context — surviving
// the annotation's mount/unmount as rows window in and out under
// virtualization, and letting the side panel drive the same expansion.
interface ExpandedFindingContextValue {
expandedId: string | null
reviewUrl: string
toggle: (finding: ReviewFinding) => void
registerAnnotation: (id: string, node: HTMLElement | null) => void
}
const ExpandedFindingContext =
createContext<ExpandedFindingContextValue | null>(null)
function useExpandedFinding(): ExpandedFindingContextValue {
const ctx = useContext(ExpandedFindingContext)
if (!ctx)
throw new Error("useExpandedFinding must be used within its provider")
return ctx
}
function ReviewDetailPage() { function ReviewDetailPage() {
const { owner, repo, number } = Route.useParams() const { owner, repo, number } = Route.useParams()
const prNumber = Number(number) const prNumber = Number(number)
@ -359,14 +386,12 @@ function ReviewBodyInner({
const [sideTab, setSideTab] = useState<SideTab>("info") const [sideTab, setSideTab] = useState<SideTab>("info")
const [selectedFile, setSelectedFile] = useState<string | null>(null) const [selectedFile, setSelectedFile] = useState<string | null>(null)
const fileRefs = useRef<Record<string, HTMLDivElement | null>>({}) const fileRefs = useRef<Record<string, HTMLDivElement | null>>({})
const anchorRefs = useRef<Record<string, HTMLElement | null>>({}) const annotationRefs = useRef<Record<string, HTMLElement | null>>({})
const [expandedFiles, setExpandedFiles] = useState<Record<string, boolean>>( const [expandedFiles, setExpandedFiles] = useState<Record<string, boolean>>(
{} {}
) )
const [focused, setFocused] = useState<ReviewFinding | null>(null) const [expandedId, setExpandedId] = useState<string | null>(null)
const [anchorEl, setAnchorEl] = useState<HTMLElement | null>(null)
const [userSelection, setUserSelection] = useState<UserSelection | null>(null) const [userSelection, setUserSelection] = useState<UserSelection | null>(null)
const [diffScrollEl, setDiffScrollEl] = useState<HTMLDivElement | null>(null)
const diffScrollElRef = useRef<HTMLDivElement | null>(null) const diffScrollElRef = useRef<HTMLDivElement | null>(null)
const groupRefs = useRef<Record<number, HTMLDivElement | null>>({}) const groupRefs = useRef<Record<number, HTMLDivElement | null>>({})
const [diffStyle, setDiffStyleState] = useState<DiffStyle>(() => const [diffStyle, setDiffStyleState] = useState<DiffStyle>(() =>
@ -386,8 +411,12 @@ function ReviewBodyInner({
// Latest-value refs so the callbacks below can stay referentially stable // Latest-value refs so the callbacks below can stay referentially stable
// (so memo(FileDiffCard) actually skips unrelated re-renders) while still // (so memo(FileDiffCard) actually skips unrelated re-renders) while still
// reading current state. // reading current state.
const focusedRef = useRef(focused) const expandedFinding = useMemo(
focusedRef.current = focused () => detail.findings.find((f) => f.id === expandedId) ?? null,
[detail.findings, expandedId]
)
const expandedFindingRef = useRef(expandedFinding)
expandedFindingRef.current = expandedFinding
const viewedStorageKey = `open-swe.review.viewed.${detail.owner}/${detail.repo}/${detail.number}.${detail.head_sha}` const viewedStorageKey = `open-swe.review.viewed.${detail.owner}/${detail.repo}/${detail.number}.${detail.head_sha}`
const [viewed, setViewed] = useState<Set<string>>(() => { const [viewed, setViewed] = useState<Set<string>>(() => {
@ -417,7 +446,8 @@ function ReviewBodyInner({
) )
return next return next
}) })
if (becomingViewed && focusedRef.current?.file === path) setFocused(null) if (becomingViewed && expandedFindingRef.current?.file === path)
setExpandedId(null)
setExpandedFiles((prev) => ({ ...prev, [path]: !becomingViewed })) setExpandedFiles((prev) => ({ ...prev, [path]: !becomingViewed }))
}, },
[viewedStorageKey] [viewedStorageKey]
@ -579,13 +609,12 @@ function ReviewBodyInner({
filesByPathRef.current = filesByPath filesByPathRef.current = filesByPath
// The Virtualizer doesn't forward a ref; grab its scroll element (the // The Virtualizer doesn't forward a ref; grab its scroll element (the
// grandparent of this hidden probe, which lives in its content div) so the // grandparent of this hidden probe, which lives in its content div) so
// anchored finding card can position against it. // scroll-to-file/group can align against it.
const scrollerProbe = useCallback((node: HTMLDivElement | null) => { const scrollerProbe = useCallback((node: HTMLDivElement | null) => {
const scroller = node?.parentElement?.parentElement const scroller = node?.parentElement?.parentElement
const el = scroller instanceof HTMLDivElement ? scroller : null diffScrollElRef.current =
diffScrollElRef.current = el scroller instanceof HTMLDivElement ? scroller : null
setDiffScrollEl(el)
}, []) }, [])
const registerSection = useCallback( const registerSection = useCallback(
@ -594,9 +623,9 @@ function ReviewBodyInner({
}, },
[] []
) )
const registerAnchor = useCallback( const registerAnnotation = useCallback(
(id: string, node: HTMLElement | null) => { (id: string, node: HTMLElement | null) => {
anchorRefs.current[id] = node annotationRefs.current[id] = node
}, },
[] []
) )
@ -604,7 +633,7 @@ function ReviewBodyInner({
const toggleExpanded = useCallback((path: string) => { const toggleExpanded = useCallback((path: string) => {
const current = expandedRef.current[path] ?? !viewedRef.current.has(path) const current = expandedRef.current[path] ?? !viewedRef.current.has(path)
const next = !current const next = !current
if (!next && focusedRef.current?.file === path) setFocused(null) if (!next && expandedFindingRef.current?.file === path) setExpandedId(null)
setExpandedFiles((prev) => ({ ...prev, [path]: next })) setExpandedFiles((prev) => ({ ...prev, [path]: next }))
}, []) }, [])
@ -612,7 +641,7 @@ function ReviewBodyInner({
(path: string, range: SelectedLineRange | null) => { (path: string, range: SelectedLineRange | null) => {
if (range) { if (range) {
setUserSelection({ file: path, range }) setUserSelection({ file: path, range })
if (focusedRef.current) setFocused(null) if (expandedFindingRef.current) setExpandedId(null)
} else { } else {
setUserSelection((prev) => (prev?.file === path ? null : prev)) setUserSelection((prev) => (prev?.file === path ? null : prev))
} }
@ -665,43 +694,49 @@ function ReviewBodyInner({
return () => window.removeEventListener("pointerdown", onPointerDown) return () => window.removeEventListener("pointerdown", onPointerDown)
}, []) }, [])
const openFinding = useCallback( // Toggle a finding from its in-diff header — it's already on-screen, so no
// scrolling is needed.
const toggleInline = useCallback(
(finding: ReviewFinding) => { (finding: ReviewFinding) => {
markRead(finding.id) markRead(finding.id)
setExpandedId((prev) => (prev === finding.id ? null : finding.id))
},
[markRead]
)
// Open a finding from the side panel. Anchored findings expand inline in the
// diff: open the file, scroll it into view, then poll a few frames for the
// annotation node (its diff rows window in/out under virtualization) and
// scroll that into view. Non-anchored findings expand inline in the panel.
const openFromPanel = useCallback(
(finding: ReviewFinding) => {
markRead(finding.id)
const willExpand = expandedFindingRef.current?.id !== finding.id
setUserSelection(null) setUserSelection(null)
setFocused(finding) setExpandedId(willExpand ? finding.id : null)
if (!isAnchored(finding)) { if (!willExpand || !isAnchored(finding)) return
setAnchorEl(null)
return
}
setExpandedFiles((prev) => ({ ...prev, [finding.file]: true })) setExpandedFiles((prev) => ({ ...prev, [finding.file]: true }))
// The file card header is always mounted, but its diff rows window in/out
// under virtualization — scroll the card into view, then poll a few frames
// for the finding's anchor element before positioning the card.
requestAnimationFrame(() => { requestAnimationFrame(() => {
fileRefs.current[finding.file]?.scrollIntoView({ block: "center" }) fileRefs.current[finding.file]?.scrollIntoView({ block: "center" })
let tries = 0 let tries = 0
const tryAnchor = () => { const tryScroll = () => {
const anchor = anchorRefs.current[finding.id] const node = annotationRefs.current[finding.id]
if (anchor) { if (node) {
setAnchorEl(anchor) node.scrollIntoView({ block: "center" })
return return
} }
if (tries++ < 30) requestAnimationFrame(tryAnchor) if (tries++ < 30) requestAnimationFrame(tryScroll)
else setAnchorEl(null)
} }
tryAnchor() tryScroll()
}) })
}, },
[markRead] [markRead]
) )
const closeFinding = useCallback(() => setFocused(null), [])
const renderFileCard = (file: ReviewDiffFile) => { const renderFileCard = (file: ReviewDiffFile) => {
const selectedLines = const selectedLines =
focused?.file === file.path && isAnchored(focused) expandedFinding?.file === file.path && isAnchored(expandedFinding)
? findingSelectedRange(focused) ? findingSelectedRange(expandedFinding)
: userSelection?.file === file.path : userSelection?.file === file.path
? userSelection.range ? userSelection.range
: null : null
@ -715,11 +750,9 @@ function ReviewBodyInner({
onToggleViewed={toggleViewed} onToggleViewed={toggleViewed}
expanded={expandedFiles[file.path] ?? !viewed.has(file.path)} expanded={expandedFiles[file.path] ?? !viewed.has(file.path)}
onToggleExpanded={toggleExpanded} onToggleExpanded={toggleExpanded}
onFindingClick={openFinding}
onSelectLines={selectLines} onSelectLines={selectLines}
onAddToChat={addToChat} onAddToChat={addToChat}
registerSection={registerSection} registerSection={registerSection}
registerAnchor={registerAnchor}
diffStyle={diffStyle} diffStyle={diffStyle}
/> />
) )
@ -752,133 +785,110 @@ function ReviewBodyInner({
useRegisterReviewSidebar(sidebarData) useRegisterReviewSidebar(sidebarData)
useEffect(() => { useEffect(() => {
if (!focused) return if (!expandedId) return
const onKeyDown = (event: KeyboardEvent) => { const onKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") setFocused(null) if (event.key === "Escape") setExpandedId(null)
}
const onPointerDown = (event: PointerEvent) => {
const target = event.target
if (target instanceof Element && target.closest("[data-finding-card]"))
return
setFocused(null)
} }
window.addEventListener("keydown", onKeyDown) window.addEventListener("keydown", onKeyDown)
window.addEventListener("pointerdown", onPointerDown) return () => window.removeEventListener("keydown", onKeyDown)
return () => { }, [expandedId])
window.removeEventListener("keydown", onKeyDown)
window.removeEventListener("pointerdown", onPointerDown)
}
}, [focused])
const isAnchoredCard = focused !== null && isAnchored(focused) && anchorEl const expandedFindingCtx = useMemo<ExpandedFindingContextValue>(
() => ({
expandedId,
reviewUrl: detail.url,
toggle: toggleInline,
registerAnnotation,
}),
[expandedId, detail.url, toggleInline, registerAnnotation]
)
return ( return (
<div className="flex min-h-0 flex-1 overflow-hidden"> <ExpandedFindingContext.Provider value={expandedFindingCtx}>
<main className="relative flex min-h-0 min-w-0 flex-1"> <div className="flex min-h-0 flex-1 overflow-hidden">
<WorkerPoolContextProvider <main className="relative flex min-h-0 min-w-0 flex-1">
poolOptions={DIFF_WORKER_POOL_OPTIONS} <WorkerPoolContextProvider
highlighterOptions={DIFF_WORKER_HIGHLIGHTER_OPTIONS} poolOptions={DIFF_WORKER_POOL_OPTIONS}
> highlighterOptions={DIFF_WORKER_HIGHLIGHTER_OPTIONS}
<Virtualizer
className="relative min-h-0 flex-1 overflow-y-auto"
contentClassName="mx-auto w-full max-w-6xl px-6 py-6"
config={DIFF_VIRTUALIZER_CONFIG}
> >
<div ref={scrollerProbe} aria-hidden className="hidden" /> <Virtualizer
<PrHeader detail={detail} /> className="relative min-h-0 flex-1 overflow-y-auto"
<div className="mt-4 rounded-lg border border-border bg-card p-4"> contentClassName="mx-auto w-full max-w-6xl px-6 py-6"
{detail.pr.body ? ( config={DIFF_VIRTUALIZER_CONFIG}
<Markdown content={detail.pr.body} /> >
) : ( <div ref={scrollerProbe} aria-hidden className="hidden" />
<p className="text-xs text-muted-foreground"> <PrHeader detail={detail} />
This PR has no description. <div className="mt-4 rounded-lg border border-border bg-card p-4">
</p> {detail.pr.body ? (
)} <Markdown content={detail.pr.body} />
</div> ) : (
<p className="text-xs text-muted-foreground">
<div className="mt-6"> This PR has no description.
<div className="mb-2 flex items-center justify-between gap-3"> </p>
<h2 className="text-sm font-medium">Changes</h2> )}
<div className="flex items-center gap-3">
{linesLeft !== null && (
<span className="text-xs text-muted-foreground">
{linesLeft === 0
? "All lines reviewed"
: `${linesLeft} lines left`}
</span>
)}
{diffFiles && diffFiles.length > 0 && (
<DiffStyleToggle value={diffStyle} onChange={setDiffStyle} />
)}
</div>
</div> </div>
{!diffFiles ? (
<Skeleton className="h-64 w-full" />
) : diffFiles.length === 0 ? (
<p className="text-xs text-muted-foreground">
No diff available.
</p>
) : view === "ai" && groupedView ? (
<div className="space-y-6">
{groupedView.map((group) => (
<div
key={group.index}
ref={(node) => {
groupRefs.current[group.index] = node
}}
className="scroll-mt-4 space-y-3"
>
<GroupHeader group={group} />
{group.files.map(renderFileCard)}
</div>
))}
</div>
) : (
<div className="space-y-3">
{diffFiles.map(renderFileCard)}
</div>
)}
</div>
</Virtualizer>
</WorkerPoolContextProvider>
</main>
<SidePanel <div className="mt-6">
detail={detail} <div className="mb-2 flex items-center justify-between gap-3">
tab={sideTab} <h2 className="text-sm font-medium">Changes</h2>
onTabChange={setSideTab} <div className="flex items-center gap-3">
read={read} {linesLeft !== null && (
dimmed={focused !== null} <span className="text-xs text-muted-foreground">
onMarkAllRead={markAllRead} {linesLeft === 0
onFindingClick={openFinding} ? "All lines reviewed"
/> : `${linesLeft} lines left`}
</span>
)}
{diffFiles && diffFiles.length > 0 && (
<DiffStyleToggle
value={diffStyle}
onChange={setDiffStyle}
/>
)}
</div>
</div>
{!diffFiles ? (
<Skeleton className="h-64 w-full" />
) : diffFiles.length === 0 ? (
<p className="text-xs text-muted-foreground">
No diff available.
</p>
) : view === "ai" && groupedView ? (
<div className="space-y-6">
{groupedView.map((group) => (
<div
key={group.index}
ref={(node) => {
groupRefs.current[group.index] = node
}}
className="scroll-mt-4 space-y-3"
>
<GroupHeader group={group} />
{group.files.map(renderFileCard)}
</div>
))}
</div>
) : (
<div className="space-y-3">
{diffFiles.map(renderFileCard)}
</div>
)}
</div>
</Virtualizer>
</WorkerPoolContextProvider>
</main>
{focused && isAnchoredCard && ( <SidePanel
<AnchoredFindingCard
key={focused.id}
detail={detail} detail={detail}
finding={focused} tab={sideTab}
anchorEl={anchorEl} onTabChange={setSideTab}
scrollEl={diffScrollEl} read={read}
onClose={closeFinding} expandedId={expandedId}
onMarkAllRead={markAllRead}
onFindingClick={openFromPanel}
/> />
)} </div>
</ExpandedFindingContext.Provider>
{focused && !isAnchoredCard && (
<div
data-finding-card
role="dialog"
aria-label={focused.title}
className={cn(FINDING_CARD_CLASS, "fixed top-24 right-1 z-50")}
>
<FindingCardContent
detail={detail}
finding={focused}
onClose={closeFinding}
/>
</div>
)}
</div>
) )
} }
@ -1014,11 +1024,9 @@ const FileDiffCard = memo(function FileDiffCard({
onToggleViewed, onToggleViewed,
expanded, expanded,
onToggleExpanded, onToggleExpanded,
onFindingClick,
onSelectLines, onSelectLines,
onAddToChat, onAddToChat,
registerSection, registerSection,
registerAnchor,
diffStyle, diffStyle,
}: { }: {
file: ReviewDiffFile file: ReviewDiffFile
@ -1028,11 +1036,9 @@ const FileDiffCard = memo(function FileDiffCard({
onToggleViewed: (path: string) => void onToggleViewed: (path: string) => void
expanded: boolean expanded: boolean
onToggleExpanded: (path: string) => void onToggleExpanded: (path: string) => void
onFindingClick: (finding: ReviewFinding) => void
onSelectLines: (path: string, range: SelectedLineRange | null) => void onSelectLines: (path: string, range: SelectedLineRange | null) => void
onAddToChat: (path: string, range: SelectedLineRange) => void onAddToChat: (path: string, range: SelectedLineRange) => void
registerSection: (path: string, node: HTMLDivElement | null) => void registerSection: (path: string, node: HTMLDivElement | null) => void
registerAnchor: (id: string, node: HTMLElement | null) => void
diffStyle: DiffStyle diffStyle: DiffStyle
}) { }) {
const diffOptions = useDiffOptions(diffStyle) const diffOptions = useDiffOptions(diffStyle)
@ -1129,13 +1135,9 @@ const FileDiffCard = memo(function FileDiffCard({
) )
const renderAnnotation = useCallback( const renderAnnotation = useCallback(
(annotation: DiffLineAnnotation<ReviewFinding>) => ( (annotation: DiffLineAnnotation<ReviewFinding>) => (
<FindingRailMarker <InlineFinding finding={annotation.metadata} />
finding={annotation.metadata}
onFindingClick={onFindingClick}
anchorRef={registerAnchor}
/>
), ),
[onFindingClick, registerAnchor] []
) )
return ( return (
@ -1273,167 +1275,65 @@ function AddToChatPopup({
) )
} }
function FindingRailMarker({ // The finding rendered inline in the diff (via Pierre's annotation portal). A
finding, // collapsed header sits at the line; clicking it expands the full details in
onFindingClick, // place. Expand state is shared through context so it survives the annotation
anchorRef, // remounting as rows window in/out, and so the side panel can drive it.
}: { function InlineFinding({ finding }: { finding: ReviewFinding }) {
finding: ReviewFinding const { expandedId, reviewUrl, toggle, registerAnnotation } =
onFindingClick: (finding: ReviewFinding) => void useExpandedFinding()
anchorRef: (id: string, node: HTMLElement | null) => void const expanded = expandedId === finding.id
}) {
const style = GROUP_STYLES[finding.group] const style = GROUP_STYLES[finding.group]
const Icon = style.Icon const Icon = style.Icon
return (
<div className="flex justify-end px-2 py-0.5">
<button
ref={(node) => anchorRef(finding.id, node)}
type="button"
onClick={() => onFindingClick(finding)}
aria-label={`Open finding: ${finding.title}`}
className={cn(
"inline-flex items-center gap-1 rounded border border-[var(--ui-border)] bg-[var(--ui-surface)] px-1.5 py-0.5 text-[10px]",
style.className
)}
>
<span className="font-sans">{finding.title}</span>
<Icon className="size-3 shrink-0" />
</button>
</div>
)
}
const FINDING_CARD_WIDTH = 412
const FINDING_CARD_GAP = 12
// If the room beside the annotation is tighter than this, hold this width and
// overlay the diff rather than shrinking into an unreadable sliver (e.g. a very
// narrow side panel, or no panel at all below `xl`).
const FINDING_CARD_MIN_WIDTH = 320
const FINDING_CARD_CLASS =
"flex max-h-[70vh] flex-col overflow-hidden rounded-lg border border-border bg-background shadow-2xl"
// Anchored just to the right of the finding's annotation, close to the hunk, and
// extending right over the side panel. Its width fits the room available to the
// right (capped at the preferred size), so it narrows as the side panel shrinks;
// when that room gets too tight to read it holds a minimum width and overlays
// the diff (e.g. below `xl`, with no side panel). Tracks the anchor as the diff
// scrolls (rAF-throttled); hidden while the anchor is out of view.
function AnchoredFindingCard({
detail,
finding,
anchorEl,
scrollEl,
onClose,
}: {
detail: ReviewDetail
finding: ReviewFinding
anchorEl: HTMLElement
scrollEl: HTMLDivElement | null
onClose: () => void
}) {
const cardRef = useRef<HTMLDivElement | null>(null)
useLayoutEffect(() => {
const card = cardRef.current
const scroller = scrollEl
if (!card || !scroller) return
let frame: number | null = null
const position = () => {
frame = null
const scrollerRect = scroller.getBoundingClientRect()
const anchorRect = anchorEl.getBoundingClientRect()
// Hide while the finding's line is scrolled out of the diff viewport.
if (
!anchorEl.isConnected ||
anchorRect.bottom < scrollerRect.top ||
anchorRect.top > scrollerRect.bottom
) {
card.style.visibility = "hidden"
return
}
// Sit just right of the finding's annotation (close to the hunk). Width
// fits the room to its right so the card narrows as the side panel shrinks
// instead of overflowing. When that room is too tight to read, hold a
// minimum width and shift left over the diff.
const rightBound = window.innerWidth - FINDING_CARD_GAP
const minLeft = scrollerRect.left + FINDING_CARD_GAP
let left = anchorRect.right + FINDING_CARD_GAP
let width = Math.min(FINDING_CARD_WIDTH, rightBound - left)
if (width < FINDING_CARD_MIN_WIDTH) {
width = Math.min(FINDING_CARD_WIDTH, rightBound - minLeft)
left = Math.max(minLeft, rightBound - width)
}
card.style.width = `${width}px`
const top = Math.max(
scrollerRect.top + FINDING_CARD_GAP,
Math.min(
anchorRect.top,
scrollerRect.bottom - card.offsetHeight - FINDING_CARD_GAP
)
)
card.style.visibility = "visible"
card.style.top = `${top}px`
card.style.left = `${left}px`
}
const schedule = () => {
if (frame == null) frame = window.requestAnimationFrame(position)
}
position()
scroller.addEventListener("scroll", schedule, { passive: true })
window.addEventListener("resize", schedule)
const observer = new ResizeObserver(schedule)
observer.observe(scroller)
const content = scroller.firstElementChild
if (content) observer.observe(content)
return () => {
scroller.removeEventListener("scroll", schedule)
window.removeEventListener("resize", schedule)
observer.disconnect()
if (frame != null) window.cancelAnimationFrame(frame)
}
}, [anchorEl, scrollEl])
return ( return (
<div <div
ref={cardRef} ref={(node) => registerAnnotation(finding.id, node)}
data-finding-card className="px-2 py-1 font-sans"
role="dialog"
aria-label={finding.title}
style={{ visibility: "hidden" }}
className={cn(FINDING_CARD_CLASS, "fixed z-50")}
> >
<FindingCardContent detail={detail} finding={finding} onClose={onClose} /> <div className="overflow-hidden rounded-md border border-[var(--ui-border)] bg-[var(--ui-surface)]">
<button
type="button"
onClick={() => toggle(finding)}
aria-expanded={expanded}
aria-label={`${expanded ? "Collapse" : "Expand"} finding: ${finding.title}`}
className="flex w-full items-center gap-1.5 px-2 py-1 text-left text-[11px]"
>
<Icon className={cn("size-3 shrink-0", style.className)} />
<span className={cn("font-medium", style.className)}>
{style.label}
</span>
<span className="min-w-0 flex-1 truncate text-foreground">
{finding.title}
</span>
{finding.outdated && <Badgeish>Outdated</Badgeish>}
{finding.status !== "open" && <Badgeish>{finding.status}</Badgeish>}
<CaretDownIcon
className={cn(
"size-3 shrink-0 text-muted-foreground transition-transform",
!expanded && "-rotate-90"
)}
/>
</button>
{expanded && <FindingDetails finding={finding} reviewUrl={reviewUrl} />}
</div>
</div> </div>
) )
} }
function FindingCardContent({ // The expandable body + actions of a finding, shared by the inline diff
detail, // annotation and the side-panel row (non-anchored findings).
function FindingDetails({
finding, finding,
onClose, reviewUrl,
}: { }: {
detail: ReviewDetail
finding: ReviewFinding finding: ReviewFinding
onClose: () => void reviewUrl: string
}) { }) {
const [copied, setCopied] = useState(false) const [copied, setCopied] = useState(false)
const style = GROUP_STYLES[finding.group]
const Icon = style.Icon
const githubUrl = const githubUrl =
finding.github_review_comment_id !== null finding.github_review_comment_id !== null
? `${detail.url}#discussion_r${finding.github_review_comment_id}` ? `${reviewUrl}#discussion_r${finding.github_review_comment_id}`
: null : null
const rangeLabel =
finding.end_line !== null
? `${finding.side === "LEFT" ? "L" : "R"}${finding.start_line ?? finding.end_line}${
finding.start_line !== null && finding.start_line !== finding.end_line
? `-${finding.end_line}`
: ""
}`
: finding.file
const copy = () => { const copy = () => {
void navigator.clipboard void navigator.clipboard
@ -1445,38 +1345,16 @@ function FindingCardContent({
} }
return ( return (
<> <div className="border-t border-[var(--ui-border)] px-3 py-2.5 font-sans">
<div className="flex items-center gap-2 border-b border-border px-4 py-2.5 text-xs"> <div className="text-xs text-muted-foreground">
<Icon className={cn("size-3.5", style.className)} /> <Markdown content={finding.description} />
<span className={cn("font-medium", style.className)}>
{style.label}
</span>
<span className="rounded border border-border px-1.5 py-0.5 font-mono text-[10px] text-muted-foreground">
{rangeLabel}
</span>
{finding.outdated && <Badgeish>Outdated</Badgeish>}
{finding.status !== "open" && <Badgeish>{finding.status}</Badgeish>}
<button
type="button"
onClick={onClose}
aria-label="Close finding"
className="ml-auto rounded p-1 text-muted-foreground hover:bg-muted hover:text-foreground"
>
<XIcon className="size-3.5" />
</button>
</div> </div>
<div className="min-h-0 flex-1 overflow-y-auto px-4 py-3"> {finding.resolution_note && (
<div className="text-sm font-medium">{finding.title}</div> <p className="mt-2 text-[11px] text-muted-foreground">
<div className="mt-1.5 text-xs text-muted-foreground"> Resolution: {finding.resolution_note}
<Markdown content={finding.description} /> </p>
</div> )}
{finding.resolution_note && ( <div className="mt-2.5 flex items-center gap-2">
<p className="mt-2 text-[11px] text-muted-foreground">
Resolution: {finding.resolution_note}
</p>
)}
</div>
<div className="flex items-center gap-2 border-t border-border px-4 py-2.5">
<button <button
type="button" type="button"
onClick={copy} onClick={copy}
@ -1497,7 +1375,7 @@ function FindingCardContent({
</a> </a>
)} )}
</div> </div>
</> </div>
) )
} }
@ -1602,7 +1480,7 @@ function SidePanel({
tab, tab,
onTabChange, onTabChange,
read, read,
dimmed, expandedId,
onMarkAllRead, onMarkAllRead,
onFindingClick, onFindingClick,
}: { }: {
@ -1610,7 +1488,7 @@ function SidePanel({
tab: SideTab tab: SideTab
onTabChange: (tab: SideTab) => void onTabChange: (tab: SideTab) => void
read: Set<string> read: Set<string>
dimmed: boolean expandedId: string | null
onMarkAllRead: () => void onMarkAllRead: () => void
onFindingClick: (finding: ReviewFinding) => void onFindingClick: (finding: ReviewFinding) => void
}) { }) {
@ -1657,12 +1535,7 @@ function SidePanel({
className="relative hidden h-full shrink-0 xl:flex" className="relative hidden h-full shrink-0 xl:flex"
> >
<ReviewPanelResizeHandle width={width} onResize={setWidth} /> <ReviewPanelResizeHandle width={width} onResize={setWidth} />
<aside <aside className="flex h-full w-full flex-col overflow-y-auto border-l border-border">
className={cn(
"flex h-full w-full flex-col overflow-y-auto border-l border-border transition-opacity",
dimmed && "pointer-events-none opacity-30"
)}
>
<div className="flex items-center gap-1 border-b border-border px-3 py-2"> <div className="flex items-center gap-1 border-b border-border px-3 py-2">
{( {(
[ [
@ -1730,6 +1603,8 @@ function SidePanel({
emptyLabel="No bugs found." emptyLabel="No bugs found."
findings={bugs} findings={bugs}
read={read} read={read}
expandedId={expandedId}
reviewUrl={detail.url}
onFindingClick={onFindingClick} onFindingClick={onFindingClick}
/> />
@ -1739,6 +1614,8 @@ function SidePanel({
emptyLabel="No issues found." emptyLabel="No issues found."
findings={flags} findings={flags}
read={read} read={read}
expandedId={expandedId}
reviewUrl={detail.url}
onFindingClick={onFindingClick} onFindingClick={onFindingClick}
action={ action={
detail.findings.length > 0 ? ( detail.findings.length > 0 ? (
@ -1789,6 +1666,8 @@ function FindingSection({
emptyLabel, emptyLabel,
findings, findings,
read, read,
expandedId,
reviewUrl,
onFindingClick, onFindingClick,
action, action,
}: { }: {
@ -1797,6 +1676,8 @@ function FindingSection({
emptyLabel: string emptyLabel: string
findings: Array<ReviewFinding> findings: Array<ReviewFinding>
read: Set<string> read: Set<string>
expandedId: string | null
reviewUrl: string
onFindingClick: (finding: ReviewFinding) => void onFindingClick: (finding: ReviewFinding) => void
action?: React.ReactNode action?: React.ReactNode
}) { }) {
@ -1830,43 +1711,62 @@ function FindingSection({
const Icon = style.Icon const Icon = style.Icon
const isRead = read.has(finding.id) const isRead = read.has(finding.id)
const muted = finding.status !== "open" || isRead const muted = finding.status !== "open" || isRead
const anchored = isAnchored(finding)
const expanded = expandedId === finding.id && !anchored
return ( return (
<button <div
key={finding.id} key={finding.id}
type="button"
onClick={() => onFindingClick(finding)}
className={cn( className={cn(
"block w-full rounded-md border border-transparent px-2 py-1.5 text-left transition-colors hover:border-border hover:bg-muted/40", "rounded-md border border-transparent transition-colors hover:border-border hover:bg-muted/40",
muted && "opacity-50" expanded && "border-border bg-muted/40",
muted && !expanded && "opacity-50"
)} )}
> >
<span className="flex items-start gap-1.5 text-xs"> <button
<Icon type="button"
className={cn( onClick={() => onFindingClick(finding)}
"mt-0.5 size-3.5 shrink-0", aria-expanded={anchored ? undefined : expanded}
style.className className="block w-full px-2 py-1.5 text-left"
)} >
/> <span className="flex items-start gap-1.5 text-xs">
<span className="min-w-0"> <Icon
<span className="line-clamp-1 font-medium text-foreground"> className={cn(
{finding.title || finding.description} "mt-0.5 size-3.5 shrink-0",
</span> style.className
<span className="mt-0.5 flex flex-wrap items-center gap-1.5 text-[11px] text-muted-foreground"> )}
<span className={style.className}>{style.label}</span> />
<span className="truncate font-mono"> <span className="min-w-0 flex-1">
{findingAnchorLabel(finding)} <span className="line-clamp-1 font-medium text-foreground">
{finding.title || finding.description}
</span>
<span className="mt-0.5 flex flex-wrap items-center gap-1.5 text-[11px] text-muted-foreground">
<span className={style.className}>{style.label}</span>
<span className="truncate font-mono">
{findingAnchorLabel(finding)}
</span>
{finding.outdated && <Badgeish>Outdated</Badgeish>}
{finding.status !== "open" && (
<Badgeish>{finding.status}</Badgeish>
)}
{isRead && finding.status === "open" && (
<span>• Read</span>
)}
</span> </span>
{finding.outdated && <Badgeish>Outdated</Badgeish>}
{finding.status !== "open" && (
<Badgeish>{finding.status}</Badgeish>
)}
{isRead && finding.status === "open" && (
<span>• Read</span>
)}
</span> </span>
{!anchored && (
<CaretDownIcon
className={cn(
"mt-0.5 size-3 shrink-0 text-muted-foreground transition-transform",
!expanded && "-rotate-90"
)}
/>
)}
</span> </span>
</span> </button>
</button> {expanded && (
<FindingDetails finding={finding} reviewUrl={reviewUrl} />
)}
</div>
) )
})} })}
</div> </div>