feat: split view, add-to-chat, virtualization + scroll/grouping perf (#1574)

* feat: reviews page split view, add-to-chat, virtualization + perf

- Virtualize the diff (Pierre Virtualizer + worker pool), mirroring the agent
  chat panel, so large PRs window rows instead of materializing every line.
- Split chat and diff into independent scroll containers and make chat
  auto-scroll fully contained, so typing/streaming no longer moves the diff.
- Memoize FileDiffCard with stable callbacks so focusing a finding re-renders
  only the affected card.
- Add a persisted unified/split diff toggle.
- Add highlight-to-chat: select lines (drag / shift-click) + gutter "+" to drop
  a file:line snippet into the chat composer.
- Rebuild sidebar group rows: whole card scrolls to the group (incl. the
  expanded explanation), Read explanation stays a separate toggle, memoized.

* feat: add-to-chat uses attachment pills + selection popup / ⌘L

Replace the raw-snippet injection with a Cursor-style flow:
- Selecting lines shows a floating "Add to Chat ⌘L" popup at the pointer; ⌘L
  adds the current selection without it. Removes the auto-adding gutter "+".
- "Add to chat" now creates a removable attachment pill in the composer (and a
  pill in the sent message bubble) instead of pasting raw text. The code is
  still serialized into the message content so the model receives it as context.

* feat: restore gutter + drag-handle for line selection

Re-enable Pierre's gutter '+' as a click-and-drag line selector (with the
highlight growing as you drag) — the affordance that was lost when the
auto-adding gutter button was removed. It no longer auto-adds: the commit flows
through onLineSelected to the 'Add to Chat' popup / ⌘L.

* fix: live selection highlight while dragging + reposition Add to Chat popup

- Feed onLineSelectionChange into the controlled selection so rows highlight
  live as you drag, not just on release (Pierre only paints the controlled
  selection when the prop updates). Popup now fires on onLineSelectionEnd.
- Anchor the popup's bottom-left to the drag handle (drop horizontal centering)
  so it no longer overlaps the '+' button.

* fix: anchor Add to Chat popup to gutter handle + click-away to unselect

- Position the popup from the gutter '+' handle's rect (in the diff shadow DOM,
  placed on the selection's bottom line) instead of the pointer-release point,
  which landed inconsistently. Falls back to the pointer if not found.
- Clear the line selection (and popup) on any outside pointer-down.

* fix: sidebar-collapse header overlap + PR review comments

- Lift useSidebarLayout to AgentsShell (single source), share collapsed via
  context, and pad the reviews header left when the sidebar is collapsed so the
  fixed collapse toggle no longer overlaps the header content.
- add-to-chat: collect each diff side separately so a selection that spans a
  deletion->addition no longer pastes wrong-file lines (PR comment).
- chat: clear attachments after sending via a suggested prompt, so an attached
  snippet isn't silently resent on the next message (PR comment).

* fix: anchored finding card positions to the right of the diff again

The virtualization refactor moved the card inside the main-width Virtualizer
scroller, so it clamped over the diff. Render it in the outer container as a
viewport-fixed card clamped to window width (right gutter / over the side panel,
like prod) and track the finding as the diff scrolls (rAF-throttled), hiding
when the finding scrolls out of view.
This commit is contained in:
Johannes du Plessis 2026-06-18 16:54:07 -07:00 • committed by GitHub
parent 9db7eab134
commit ecf0898f51
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 1090 additions and 302 deletions

View file

@ -17,12 +17,7 @@ import {
GitPullRequestIcon, GitPullRequestIcon,
SidebarSimpleIcon, SidebarSimpleIcon,
} from "@phosphor-icons/react" } from "@phosphor-icons/react"
import type { import type { FileContents } from "@pierre/diffs/react"
FileContents,
VirtualFileMetrics,
WorkerInitializationRenderOptions,
WorkerPoolOptions,
} from "@pierre/diffs/react"
import type { GitStatus, GitStatusEntry } from "@pierre/trees" import type { GitStatus, GitStatusEntry } from "@pierre/trees"
import type { AgentThread, Message } from "@/lib/agents/types" import type { AgentThread, Message } from "@/lib/agents/types"
@ -30,7 +25,14 @@ import type { ThreadPrDiffFile } from "@/lib/agents/api"
import type { ChangedFileSummaryItem } from "@/components/agents/messages" import type { ChangedFileSummaryItem } from "@/components/agents/messages"
import { useAgentThreadPrDiff } from "@/lib/agents/queries" import { useAgentThreadPrDiff } from "@/lib/agents/queries"
import { buttonVariants } from "@/components/ui/button" import { buttonVariants } from "@/components/ui/button"
import { useDiffOptions } from "@/components/agents/utils/diffUtils" import {
DIFF_VIRTUALIZER_CONFIG,
DIFF_VIRTUAL_METRICS,
DIFF_WORKER_HIGHLIGHTER_OPTIONS,
DIFF_WORKER_POOL_OPTIONS,
fileContentsCacheKey,
useDiffOptions,
} from "@/components/agents/utils/diffUtils"
import { summarizeChangedFiles } from "@/components/agents/ported" import { summarizeChangedFiles } from "@/components/agents/ported"
import { Z } from "@/components/agents/z-index" import { Z } from "@/components/agents/z-index"
import { useIsMobile } from "@/lib/useIsMobile" import { useIsMobile } from "@/lib/useIsMobile"
@ -93,38 +95,6 @@ const PANEL_MIN_WIDTH = 320
// Exported so the chat column can enforce the same floor via min-width. // Exported so the chat column can enforce the same floor via min-width.
export const PANEL_MIN_CHAT_WIDTH = 360 export const PANEL_MIN_CHAT_WIDTH = 360
const DIFF_VIRTUALIZER_CONFIG = {
overscrollSize: 1200,
intersectionObserverMargin: 4800,
}
const DIFF_VIRTUAL_METRICS = {
hunkLineCount: 80,
lineHeight: 18,
diffHeaderHeight: 0,
spacing: 8,
} satisfies Partial<VirtualFileMetrics>
const DIFF_WORKER_POOL_OPTIONS = {
workerFactory: () =>
new Worker(
new URL("@pierre/diffs/worker/worker-portable.js", import.meta.url),
{
type: "module",
}
),
poolSize: 2,
totalASTLRUCacheSize: 120,
} satisfies WorkerPoolOptions
const DIFF_WORKER_HIGHLIGHTER_OPTIONS = {
theme: { light: "pierre-light", dark: "pierre-dark" },
lineDiffType: "word-alt",
maxLineDiffLength: 800,
tokenizeMaxLineLength: 1200,
langs: ["text"],
} satisfies WorkerInitializationRenderOptions
function getPanelMaxWidth(availableWidth?: number): number { function getPanelMaxWidth(availableWidth?: number): number {
if (typeof window === "undefined") return PANEL_DEFAULT_WIDTH if (typeof window === "undefined") return PANEL_DEFAULT_WIDTH
const available = availableWidth ?? window.innerWidth const available = availableWidth ?? window.innerWidth
@ -639,23 +609,6 @@ export function AgentGitPanel({ thread, messages }: AgentGitPanelProps) {
) )
} }
function hashFileContents(contents: string): string {
let hash = 0x811c9dc5
for (let i = 0; i < contents.length; i++) {
hash ^= contents.charCodeAt(i)
hash = Math.imul(hash, 0x01000193)
}
return (hash >>> 0).toString(36)
}
function fileContentsCacheKey(
path: string,
side: "old" | "new",
contents: string
): string {
return `${path}:${side}:${contents.length}:${hashFileContents(contents)}`
}
const FileDiffSection = memo( const FileDiffSection = memo(
function FileDiffSection({ function FileDiffSection({
file, file,

View file

@ -25,6 +25,7 @@ import type { ComponentType, SVGProps } from "react"
import type { SessionUser } from "@/lib/api" import type { SessionUser } from "@/lib/api"
import type { AgentSource, AgentThread } from "@/lib/agents/types" import type { AgentSource, AgentThread } from "@/lib/agents/types"
import type { SidebarLayout } from "@/components/sidebar-layout"
import { SidebarUserMenu } from "@/components/SidebarUserMenu" import { SidebarUserMenu } from "@/components/SidebarUserMenu"
import { import {
ReviewSidebarPanel, ReviewSidebarPanel,
@ -35,6 +36,7 @@ import { Button } from "@/components/ui/button"
import { import {
SidebarCollapseButton, SidebarCollapseButton,
SidebarFrame, SidebarFrame,
SidebarLayoutProvider,
useSidebarLayout, useSidebarLayout,
} from "@/components/sidebar-layout" } from "@/components/sidebar-layout"
import { groupThreads } from "@/lib/agents/api" import { groupThreads } from "@/lib/agents/api"
@ -90,6 +92,7 @@ const PR_STATE_META: Record<
interface AgentsSidebarProps { interface AgentsSidebarProps {
user: SessionUser user: SessionUser
activeThreadId?: string activeThreadId?: string
layout: SidebarLayout
} }
const NAV = [ const NAV = [
@ -98,7 +101,11 @@ const NAV = [
{ to: "/agents/reviews", label: "Reviews", icon: GitPullRequestIcon }, { to: "/agents/reviews", label: "Reviews", icon: GitPullRequestIcon },
] as const ] as const
export function AgentsSidebar({ user, activeThreadId }: AgentsSidebarProps) { export function AgentsSidebar({
user,
activeThreadId,
layout,
}: AgentsSidebarProps) {
const sidebar = useSidebarThreads(RESOLVED_SIDEBAR_LIMIT) const sidebar = useSidebarThreads(RESOLVED_SIDEBAR_LIMIT)
const activeThreads = sidebar.data?.active.items ?? [] const activeThreads = sidebar.data?.active.items ?? []
const resolvedThreads = sidebar.data?.resolved.items ?? [] const resolvedThreads = sidebar.data?.resolved.items ?? []
@ -107,7 +114,6 @@ export function AgentsSidebar({ user, activeThreadId }: AgentsSidebarProps) {
useSeedAgentThreadDetails(visibleThreads, activeThreadId) useSeedAgentThreadDetails(visibleThreads, activeThreadId)
useRunCompletionNotifier(visibleThreads, activeThreadId) useRunCompletionNotifier(visibleThreads, activeThreadId)
const groups = groupThreads(activeThreads) const groups = groupThreads(activeThreads)
const layout = useSidebarLayout()
const reviewSidebar = useReviewSidebarData() const reviewSidebar = useReviewSidebarData()
return ( return (
@ -538,12 +544,19 @@ export function AgentsShell({
activeThreadId?: string activeThreadId?: string
children: React.ReactNode children: React.ReactNode
}) { }) {
const layout = useSidebarLayout()
return ( return (
<ReviewSidebarProvider> <ReviewSidebarProvider>
<div className="agents-ui flex h-svh overflow-hidden bg-[var(--ui-bg)]"> <SidebarLayoutProvider value={layout}>
<AgentsSidebar user={user} activeThreadId={activeThreadId} /> <div className="agents-ui flex h-svh overflow-hidden bg-[var(--ui-bg)]">
<div className="flex min-w-0 flex-1">{children}</div> <AgentsSidebar
</div> user={user}
activeThreadId={activeThreadId}
layout={layout}
/>
<div className="flex min-w-0 flex-1">{children}</div>
</div>
</SidebarLayoutProvider>
</ReviewSidebarProvider> </ReviewSidebarProvider>
) )
} }

View file

@ -1,4 +1,13 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react" import {
createContext,
useCallback,
useContext,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
} from "react"
import { StreamProvider, useStreamContext } from "@langchain/react" import { StreamProvider, useStreamContext } from "@langchain/react"
import { overrideFetchImplementation } from "@langchain/langgraph-sdk" import { overrideFetchImplementation } from "@langchain/langgraph-sdk"
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query" import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
@ -6,6 +15,7 @@ import {
ArrowClockwiseIcon, ArrowClockwiseIcon,
ArrowUpIcon, ArrowUpIcon,
CheckIcon, CheckIcon,
CodeIcon,
PlusIcon, PlusIcon,
SparkleIcon, SparkleIcon,
TrashIcon, TrashIcon,
@ -22,6 +32,138 @@ import { Skeleton } from "@/components/ui/skeleton"
import { api, reviewChatApiBase } from "@/lib/api" import { api, reviewChatApiBase } from "@/lib/api"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
// --- Composer bridge ---------------------------------------------------------
//
// Lets the diff column (a separate subtree) attach a code reference to the
// active chat composer. The reference shows as a removable pill; the code is
// only serialized into the message text on send. The chat tab may not be
// mounted when "add to chat" fires, so attachments are stashed until ChatBody
// registers its sink.
export interface ChatAttachment {
id: string
path: string
// e.g. "R35-37" / "L12" — the side+line range shown after the filename.
lineLabel: string
language: string
snippet: string
}
interface ReviewChatComposer {
addAttachment: (attachment: ChatAttachment) => void
registerSink: (fn: ((attachment: ChatAttachment) => void) | null) => void
}
const ReviewChatComposerContext = createContext<ReviewChatComposer | null>(null)
export function ReviewChatComposerProvider({
children,
}: {
children: React.ReactNode
}) {
const sinkRef = useRef<((attachment: ChatAttachment) => void) | null>(null)
const pendingRef = useRef<Array<ChatAttachment>>([])
const value = useMemo<ReviewChatComposer>(
() => ({
addAttachment: (attachment) => {
if (sinkRef.current) sinkRef.current(attachment)
else pendingRef.current.push(attachment)
},
registerSink: (fn) => {
sinkRef.current = fn
if (fn && pendingRef.current.length > 0) {
for (const attachment of pendingRef.current) fn(attachment)
pendingRef.current = []
}
},
}),
[]
)
return (
<ReviewChatComposerContext.Provider value={value}>
{children}
</ReviewChatComposerContext.Provider>
)
}
export function useReviewChatComposer(): ReviewChatComposer | null {
return useContext(ReviewChatComposerContext)
}
function attachmentBasename(path: string): string {
const idx = path.lastIndexOf("/")
return idx === -1 ? path : path.slice(idx + 1)
}
function attachmentPillLabel(attachment: ChatAttachment): string {
return `${attachmentBasename(attachment.path)}:${attachment.lineLabel}`
}
// Serialize attachments as fenced code blocks ahead of the prose so the model
// receives the code as context. The UI renders pills instead (see parse below).
function serializeMessage(text: string, attachments: Array<ChatAttachment>): string {
const blocks = attachments.map(
(a) => `\`${a.path}:${a.lineLabel}\`\n\`\`\`${a.language}\n${a.snippet}\n\`\`\``
)
return [...blocks, text.trim()].filter(Boolean).join("\n\n")
}
interface ParsedAttachment {
label: string
language: string
code: string
}
// Pull the leading attachment blocks (which serializeMessage always writes
// first) back out of a sent message so the bubble can render them as pills.
function parseUserMessage(content: string): {
attachments: Array<ParsedAttachment>
text: string
} {
const attachments: Array<ParsedAttachment> = []
let rest = content
const re = /^`([^`\n]+)`\n```([\w.-]*)\n([\s\S]*?)\n```\n*/
let match = re.exec(rest)
while (match && match.index === 0) {
const loc = match[1] ?? ""
const slash = loc.lastIndexOf("/")
attachments.push({
label: slash === -1 ? loc : loc.slice(slash + 1),
language: match[2] ?? "",
code: match[3] ?? "",
})
rest = rest.slice(match[0].length)
match = re.exec(rest)
}
return { attachments, text: rest.trim() }
}
function AttachmentPill({
label,
onRemove,
}: {
label: string
onRemove?: () => void
}) {
return (
<span className="inline-flex max-w-[200px] items-center gap-1 rounded-md border border-border bg-muted/60 py-0.5 pl-1.5 pr-1 text-[11px] text-foreground">
<CodeIcon className="size-3 shrink-0 text-muted-foreground" />
<span className="truncate font-mono">{label}</span>
{onRemove && (
<IconButton
type="button"
variant="ghost"
size="icon-xs"
aria-label="Remove attachment"
onClick={onRemove}
>
<XIcon />
</IconButton>
)}
</span>
)
}
const dashboardFetch: typeof fetch = (input, init) => const dashboardFetch: typeof fetch = (input, init) =>
fetch(input, { ...init, credentials: "include" }) fetch(input, { ...init, credentials: "include" })
@ -271,25 +413,42 @@ function ChatBody({
onUserSend: (text: string) => void onUserSend: (text: string) => void
expectsHistory: boolean expectsHistory: boolean
}) { }) {
const composer = useReviewChatComposer()
const stream = useStreamContext() const stream = useStreamContext()
const [value, setValue] = useState("") const [value, setValue] = useState("")
const endRef = useRef<HTMLDivElement>(null) const [attachments, setAttachments] = useState<Array<ChatAttachment>>([])
const scrollRef = useRef<HTMLDivElement>(null)
const autoScrollRef = useRef(true)
const prevTopRef = useRef(0)
const messages = stream.messages const messages = stream.messages
const busy = stream.isLoading const busy = stream.isLoading
// True during the one-time getState hydration when switching to / loading an // True during the one-time getState hydration when switching to / loading an
// existing thread, before its messages have arrived. // existing thread, before its messages have arrived.
const hydrating = stream.isThreadLoading const hydrating = stream.isThreadLoading
// Receive "add to chat" attachments from the diff column as composer pills.
useEffect(() => { useEffect(() => {
endRef.current?.scrollIntoView({ behavior: "smooth" }) if (!composer) return
}, [messages]) composer.registerSink((attachment) =>
setAttachments((prev) =>
prev.some((a) => a.id === attachment.id) ? prev : [...prev, attachment]
)
)
return () => composer.registerSink(null)
}, [composer])
const removeAttachment = useCallback((id: string) => {
setAttachments((prev) => prev.filter((a) => a.id !== id))
}, [])
const send = useCallback( const send = useCallback(
(text: string) => { (text: string, atts: Array<ChatAttachment>) => {
const trimmed = text.trim() const trimmed = text.trim()
if (!trimmed || busy) return const first = atts[0]
onUserSend(trimmed) if ((!trimmed && !first) || busy) return
void stream.submit({ messages: [{ type: "human", content: trimmed }] }) const content = serializeMessage(trimmed, atts)
onUserSend(trimmed || (first ? attachmentPillLabel(first) : ""))
void stream.submit({ messages: [{ type: "human", content }] })
}, },
[busy, stream, onUserSend], [busy, stream, onUserSend],
) )
@ -301,44 +460,85 @@ function ChatBody({
}) })
const submitComposer = () => { const submitComposer = () => {
send(value) send(value, attachments)
setValue("") setValue("")
setAttachments([])
} }
// Show the loading placeholder (not the empty/intro state) while an existing // Show the loading placeholder (not the empty/intro state) while an existing
// conversation hydrates, so a chat with messages never flashes its greeting. // conversation hydrates, so a chat with messages never flashes its greeting.
const showEmpty = visible.length === 0 && !busy const showEmpty = visible.length === 0 && !busy
const showLoading = showEmpty && hydrating && expectsHistory const showLoading = showEmpty && hydrating && expectsHistory
const showMessages = !showEmpty && !showLoading
// Auto-scroll is fully contained to the messages list (scrollTop assignment,
// never scrollIntoView) so it can't bubble up and move the diff column. We
// pause auto-scroll when the user scrolls up and resume when near the bottom.
useLayoutEffect(() => {
if (!showMessages) return
const el = scrollRef.current
if (!el || !autoScrollRef.current) return
el.scrollTop = el.scrollHeight
prevTopRef.current = el.scrollTop
}, [showMessages, messages, busy])
useEffect(() => {
if (!showMessages) return
const el = scrollRef.current
if (!el) return
const onScroll = () => {
const top = el.scrollTop
const nearBottom = el.scrollHeight - top - el.clientHeight <= 24
if (top < prevTopRef.current - 1) autoScrollRef.current = false
else if (nearBottom) autoScrollRef.current = true
prevTopRef.current = top
}
el.addEventListener("scroll", onScroll, { passive: true })
return () => el.removeEventListener("scroll", onScroll)
}, [showMessages])
return ( return (
<div className="flex flex-1 flex-col overflow-hidden"> <div className="flex flex-1 flex-col overflow-hidden">
{showLoading ? ( {showLoading ? (
<LoadingState /> <LoadingState />
) : showEmpty ? ( ) : showEmpty ? (
<EmptyState onPick={send} /> <EmptyState
onPick={(prompt) => {
send(prompt, attachments)
setAttachments([])
}}
/>
) : ( ) : (
<div className="flex flex-1 flex-col gap-4 overflow-y-auto p-4"> <div
ref={scrollRef}
className="flex flex-1 flex-col gap-4 overflow-y-auto p-4"
>
{visible.map((message, index) => { {visible.map((message, index) => {
const isUser = messageType(message) === "human" const isUser = messageType(message) === "human"
return ( if (!isUser) {
<div return (
key={message.id ?? index} <div key={message.id ?? index} className="flex justify-start">
className={cn("flex", isUser ? "justify-end" : "justify-start")} <div className="w-full text-[13px] text-foreground">
>
<div
className={cn(
"text-[13px] text-foreground",
isUser
? "max-w-[85%] rounded-lg bg-muted px-3 py-2"
: "w-full",
)}
>
{isUser ? (
<span className="whitespace-pre-wrap">
{messageText(message.content)}
</span>
) : (
<Markdown content={messageText(message.content)} /> <Markdown content={messageText(message.content)} />
</div>
</div>
)
}
const parsed = parseUserMessage(messageText(message.content))
return (
<div key={message.id ?? index} className="flex justify-end">
<div className="flex max-w-[85%] flex-col items-end gap-1.5">
{parsed.attachments.length > 0 && (
<div className="flex flex-wrap justify-end gap-1">
{parsed.attachments.map((attachment, i) => (
<AttachmentPill key={i} label={attachment.label} />
))}
</div>
)}
{parsed.text && (
<span className="rounded-lg bg-muted px-3 py-2 text-[13px] whitespace-pre-wrap text-foreground">
{parsed.text}
</span>
)} )}
</div> </div>
</div> </div>
@ -351,34 +551,46 @@ function ChatBody({
</div> </div>
</div> </div>
)} )}
<div ref={endRef} />
</div> </div>
)} )}
<div className="p-3"> <div className="p-3">
<div className="flex items-end gap-2 rounded-2xl border border-border bg-background py-1.5 pl-3.5 pr-1.5 transition-colors focus-within:border-ring/60"> <div className="flex flex-col gap-1.5 rounded-2xl border border-border bg-background px-1.5 py-1.5 transition-colors focus-within:border-ring/60">
<Textarea {attachments.length > 0 && (
value={value} <div className="flex flex-wrap gap-1 pl-2 pt-0.5">
onChange={(event) => setValue(event.target.value)} {attachments.map((attachment) => (
onKeyDown={(event) => { <AttachmentPill
if (event.key === "Enter" && !event.shiftKey) { key={attachment.id}
event.preventDefault() label={attachmentPillLabel(attachment)}
submitComposer() onRemove={() => removeAttachment(attachment.id)}
} />
}} ))}
placeholder="Ask anything about this PR…" </div>
rows={1} )}
className="max-h-40 min-h-7 flex-1 resize-none rounded-none border-0 bg-transparent px-0 py-1 shadow-none focus-visible:border-transparent focus-visible:ring-0" <div className="flex items-end gap-2 pl-2">
/> <Textarea
<IconButton value={value}
type="button" onChange={(event) => setValue(event.target.value)}
onClick={submitComposer} onKeyDown={(event) => {
disabled={!value.trim() || busy} if (event.key === "Enter" && !event.shiftKey) {
aria-label="Send message" event.preventDefault()
className="rounded-full" submitComposer()
> }
<ArrowUpIcon className="size-4" /> }}
</IconButton> placeholder="Ask anything about this PR…"
rows={1}
className="max-h-40 min-h-7 flex-1 resize-none rounded-none border-0 bg-transparent px-0 py-1 shadow-none focus-visible:border-transparent focus-visible:ring-0"
/>
<IconButton
type="button"
onClick={submitComposer}
disabled={(!value.trim() && attachments.length === 0) || busy}
aria-label="Send message"
className="rounded-full"
>
<ArrowUpIcon className="size-4" />
</IconButton>
</div>
</div> </div>
</div> </div>
</div> </div>

View file

@ -1,4 +1,12 @@
import { createContext, useContext, useEffect, useMemo, useState } from "react" import {
createContext,
memo,
useCallback,
useContext,
useEffect,
useMemo,
useState,
} from "react"
import { import {
FileTree, FileTree,
useFileTree, useFileTree,
@ -192,7 +200,7 @@ function ReviewGroupList({
<ReviewGroupRow <ReviewGroupRow
key={group.index} key={group.index}
group={group} group={group}
onSelect={() => onSelectGroup(group.index)} onSelectGroup={onSelectGroup}
onSelectFile={onSelectFile} onSelectFile={onSelectFile}
/> />
))} ))}
@ -231,29 +239,55 @@ function renderInlineCode(text: string): Array<ReactNode> {
}) })
} }
function ReviewGroupRow({ // The whole card is the scroll-to-group target so clicks anywhere (including
// the expanded explanation body) focus the diff. Nested controls — the file
// links and the "Read explanation" toggle — stop propagation so they keep
// their own behavior. memo'd + memoized string processing so re-renders from
// sibling state don't re-run Markdown/inline-code work.
const ReviewGroupRow = memo(function ReviewGroupRow({
group, group,
onSelect, onSelectGroup,
onSelectFile, onSelectFile,
}: { }: {
group: ReviewSidebarGroup group: ReviewSidebarGroup
onSelect: () => void onSelectGroup: (index: number) => void
onSelectFile: (path: string) => void onSelectFile: (path: string) => void
}) { }) {
const [expanded, setExpanded] = useState(false) const [expanded, setExpanded] = useState(false)
const title = useMemo(() => renderInlineCode(group.title), [group.title])
const summary = useMemo(
() => stripLocationLinks(group.summary),
[group.summary]
)
const selectGroup = useCallback(
() => onSelectGroup(group.index),
[onSelectGroup, group.index]
)
const onKeyDown = useCallback(
(event: React.KeyboardEvent) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault()
onSelectGroup(group.index)
}
},
[onSelectGroup, group.index]
)
return ( return (
<div className="px-3 py-3 transition-colors hover:bg-[var(--ui-sidebar-hover)]"> <div
<button role="button"
type="button" tabIndex={0}
onClick={onSelect} onClick={selectGroup}
className="flex w-full items-start gap-2 text-left" onKeyDown={onKeyDown}
> className="cursor-pointer px-3 py-3 transition-colors hover:bg-[var(--ui-sidebar-hover)]"
>
<div className="flex w-full items-start gap-2 text-left">
<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)]"> <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 className="min-w-0 flex-1"> <span className="min-w-0 flex-1">
<span className="block text-xs leading-5 font-medium text-[var(--ui-text)]"> <span className="block text-xs leading-5 font-medium text-[var(--ui-text)]">
{renderInlineCode(group.title)} {title}
</span> </span>
<span className="mt-1 flex flex-wrap items-center gap-1.5 text-[11px] text-[var(--ui-text-dim)]"> <span className="mt-1 flex flex-wrap items-center gap-1.5 text-[11px] text-[var(--ui-text-dim)]">
<span> <span>
@ -267,7 +301,7 @@ function ReviewGroupRow({
)} )}
</span> </span>
</span> </span>
</button> </div>
{group.files.length > 0 && ( {group.files.length > 0 && (
<div className="mt-2 space-y-0.5 pl-7"> <div className="mt-2 space-y-0.5 pl-7">
@ -277,7 +311,10 @@ function ReviewGroupRow({
<button <button
key={path} key={path}
type="button" type="button"
onClick={() => onSelectFile(path)} onClick={(event) => {
event.stopPropagation()
onSelectFile(path)
}}
title={path} title={path}
className="flex w-full items-baseline gap-1.5 text-left text-[11px] hover:text-[var(--ui-accent)]" className="flex w-full items-baseline gap-1.5 text-left text-[11px] hover:text-[var(--ui-accent)]"
> >
@ -299,7 +336,10 @@ function ReviewGroupRow({
<div className="mt-2"> <div className="mt-2">
<button <button
type="button" type="button"
onClick={() => setExpanded((value) => !value)} onClick={(event) => {
event.stopPropagation()
setExpanded((value) => !value)
}}
className="inline-flex items-center gap-1 text-[11px] font-medium text-[var(--ui-accent)]" className="inline-flex items-center gap-1 text-[11px] font-medium text-[var(--ui-accent)]"
> >
<CaretRightIcon <CaretRightIcon
@ -312,14 +352,14 @@ function ReviewGroupRow({
</button> </button>
{expanded && ( {expanded && (
<div className="mt-1.5"> <div className="mt-1.5">
<Markdown content={stripLocationLinks(group.summary)} /> <Markdown content={summary} />
</div> </div>
)} )}
</div> </div>
)} )}
</div> </div>
) )
} })
function ReviewFileTreeExplorer({ function ReviewFileTreeExplorer({
files, files,

View file

@ -1,7 +1,14 @@
import { useMemo } from "react" import { useMemo } from "react"
import { preloadHighlighter } from "@pierre/diffs" import { preloadHighlighter } from "@pierre/diffs"
import type {
VirtualFileMetrics,
WorkerInitializationRenderOptions,
WorkerPoolOptions,
} from "@pierre/diffs/react"
import { useResolvedTheme } from "@/lib/theme" import { useResolvedTheme } from "@/lib/theme"
export type DiffStyle = "unified" | "split"
export const DIFF_UNSAFE_CSS = ` export const DIFF_UNSAFE_CSS = `
[data-diffs-header], [data-diffs-header],
[data-diff], [data-diff],
@ -71,14 +78,66 @@ export const diffOptions = {
tokenizeMaxLength: 120_000, tokenizeMaxLength: 120_000,
} }
export function useDiffOptions() { export function useDiffOptions(diffStyle: DiffStyle = "unified") {
const resolvedTheme = useResolvedTheme() const resolvedTheme = useResolvedTheme()
return useMemo( return useMemo(
() => ({ ...diffOptions, themeType: resolvedTheme }), () => ({ ...diffOptions, themeType: resolvedTheme, diffStyle }),
[resolvedTheme] [resolvedTheme, diffStyle]
) )
} }
// Shared virtualization + worker-pool config for <Virtualizer>/<MultiFileDiff>.
// Tuned for the agent git panel and the PR reviews page; keep them aligned so
// both viewers window rows and offload highlighting identically.
export const DIFF_VIRTUALIZER_CONFIG = {
overscrollSize: 1200,
intersectionObserverMargin: 4800,
}
export const DIFF_VIRTUAL_METRICS = {
hunkLineCount: 80,
lineHeight: 18,
diffHeaderHeight: 0,
spacing: 8,
} satisfies Partial<VirtualFileMetrics>
export const DIFF_WORKER_POOL_OPTIONS = {
workerFactory: () =>
new Worker(
new URL("@pierre/diffs/worker/worker-portable.js", import.meta.url),
{ type: "module" }
),
poolSize: 2,
totalASTLRUCacheSize: 120,
} satisfies WorkerPoolOptions
export const DIFF_WORKER_HIGHLIGHTER_OPTIONS = {
theme: { light: "pierre-light", dark: "pierre-dark" },
lineDiffType: "word-alt",
maxLineDiffLength: 800,
tokenizeMaxLineLength: 1200,
langs: ["text"],
} satisfies WorkerInitializationRenderOptions
function hashFileContents(contents: string): string {
let hash = 0x811c9dc5
for (let i = 0; i < contents.length; i++) {
hash ^= contents.charCodeAt(i)
hash = Math.imul(hash, 0x01000193)
}
return (hash >>> 0).toString(36)
}
// Stable per-file content key so the worker pool dedupes highlight work across
// re-renders instead of re-tokenizing identical content.
export function fileContentsCacheKey(
path: string,
side: "old" | "new",
contents: string
): string {
return `${path}:${side}:${contents.length}:${hashFileContents(contents)}`
}
let highlighterWarmup: Promise<void> | null = null let highlighterWarmup: Promise<void> | null = null
/** /**

View file

@ -1,4 +1,11 @@
import { useCallback, useEffect, useRef, useState } from "react"; import {
createContext,
useCallback,
useContext,
useEffect,
useRef,
useState,
} from "react";
import { SidebarSimpleIcon } from "@phosphor-icons/react"; import { SidebarSimpleIcon } from "@phosphor-icons/react";
import { useHotkey } from "@/lib/hotkeys"; import { useHotkey } from "@/lib/hotkeys";
@ -55,6 +62,30 @@ export function useSidebarLayout() {
return { width, collapsed, setWidth, setCollapsed, toggle, closeOnMobile }; return { width, collapsed, setWidth, setCollapsed, toggle, closeOnMobile };
} }
export type SidebarLayout = ReturnType<typeof useSidebarLayout>;
// Shares the single sidebar-layout instance with page content so it can react
// to the collapsed state (e.g. clear room for the fixed collapse toggle).
const SidebarLayoutContext = createContext<SidebarLayout | null>(null);
export function SidebarLayoutProvider({
value,
children,
}: {
value: SidebarLayout;
children: React.ReactNode;
}) {
return (
<SidebarLayoutContext.Provider value={value}>
{children}
</SidebarLayoutContext.Provider>
);
}
export function useSidebarCollapsed(): boolean {
return useContext(SidebarLayoutContext)?.collapsed ?? false;
}
interface SidebarFrameProps { interface SidebarFrameProps {
width: number; width: number;
setWidth: (next: number) => void; setWidth: (next: number) => void;

File diff suppressed because it is too large Load diff