mirror of
https://github.com/Sea-Haven-Industries/open-swe.git
synced 2026-09-30 12:43:16 +00:00
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:
parent
9db7eab134
commit
ecf0898f51
7 changed files with 1090 additions and 302 deletions
|
|
@ -17,12 +17,7 @@ import {
|
|||
GitPullRequestIcon,
|
||||
SidebarSimpleIcon,
|
||||
} from "@phosphor-icons/react"
|
||||
import type {
|
||||
FileContents,
|
||||
VirtualFileMetrics,
|
||||
WorkerInitializationRenderOptions,
|
||||
WorkerPoolOptions,
|
||||
} from "@pierre/diffs/react"
|
||||
import type { FileContents } from "@pierre/diffs/react"
|
||||
import type { GitStatus, GitStatusEntry } from "@pierre/trees"
|
||||
|
||||
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 { useAgentThreadPrDiff } from "@/lib/agents/queries"
|
||||
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 { Z } from "@/components/agents/z-index"
|
||||
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.
|
||||
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 {
|
||||
if (typeof window === "undefined") return PANEL_DEFAULT_WIDTH
|
||||
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(
|
||||
function FileDiffSection({
|
||||
file,
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ import type { ComponentType, SVGProps } from "react"
|
|||
|
||||
import type { SessionUser } from "@/lib/api"
|
||||
import type { AgentSource, AgentThread } from "@/lib/agents/types"
|
||||
import type { SidebarLayout } from "@/components/sidebar-layout"
|
||||
import { SidebarUserMenu } from "@/components/SidebarUserMenu"
|
||||
import {
|
||||
ReviewSidebarPanel,
|
||||
|
|
@ -35,6 +36,7 @@ import { Button } from "@/components/ui/button"
|
|||
import {
|
||||
SidebarCollapseButton,
|
||||
SidebarFrame,
|
||||
SidebarLayoutProvider,
|
||||
useSidebarLayout,
|
||||
} from "@/components/sidebar-layout"
|
||||
import { groupThreads } from "@/lib/agents/api"
|
||||
|
|
@ -90,6 +92,7 @@ const PR_STATE_META: Record<
|
|||
interface AgentsSidebarProps {
|
||||
user: SessionUser
|
||||
activeThreadId?: string
|
||||
layout: SidebarLayout
|
||||
}
|
||||
|
||||
const NAV = [
|
||||
|
|
@ -98,7 +101,11 @@ const NAV = [
|
|||
{ to: "/agents/reviews", label: "Reviews", icon: GitPullRequestIcon },
|
||||
] as const
|
||||
|
||||
export function AgentsSidebar({ user, activeThreadId }: AgentsSidebarProps) {
|
||||
export function AgentsSidebar({
|
||||
user,
|
||||
activeThreadId,
|
||||
layout,
|
||||
}: AgentsSidebarProps) {
|
||||
const sidebar = useSidebarThreads(RESOLVED_SIDEBAR_LIMIT)
|
||||
const activeThreads = sidebar.data?.active.items ?? []
|
||||
const resolvedThreads = sidebar.data?.resolved.items ?? []
|
||||
|
|
@ -107,7 +114,6 @@ export function AgentsSidebar({ user, activeThreadId }: AgentsSidebarProps) {
|
|||
useSeedAgentThreadDetails(visibleThreads, activeThreadId)
|
||||
useRunCompletionNotifier(visibleThreads, activeThreadId)
|
||||
const groups = groupThreads(activeThreads)
|
||||
const layout = useSidebarLayout()
|
||||
const reviewSidebar = useReviewSidebarData()
|
||||
|
||||
return (
|
||||
|
|
@ -538,12 +544,19 @@ export function AgentsShell({
|
|||
activeThreadId?: string
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
const layout = useSidebarLayout()
|
||||
return (
|
||||
<ReviewSidebarProvider>
|
||||
<div className="agents-ui flex h-svh overflow-hidden bg-[var(--ui-bg)]">
|
||||
<AgentsSidebar user={user} activeThreadId={activeThreadId} />
|
||||
<div className="flex min-w-0 flex-1">{children}</div>
|
||||
</div>
|
||||
<SidebarLayoutProvider value={layout}>
|
||||
<div className="agents-ui flex h-svh overflow-hidden bg-[var(--ui-bg)]">
|
||||
<AgentsSidebar
|
||||
user={user}
|
||||
activeThreadId={activeThreadId}
|
||||
layout={layout}
|
||||
/>
|
||||
<div className="flex min-w-0 flex-1">{children}</div>
|
||||
</div>
|
||||
</SidebarLayoutProvider>
|
||||
</ReviewSidebarProvider>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 { overrideFetchImplementation } from "@langchain/langgraph-sdk"
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
|
||||
|
|
@ -6,6 +15,7 @@ import {
|
|||
ArrowClockwiseIcon,
|
||||
ArrowUpIcon,
|
||||
CheckIcon,
|
||||
CodeIcon,
|
||||
PlusIcon,
|
||||
SparkleIcon,
|
||||
TrashIcon,
|
||||
|
|
@ -22,6 +32,138 @@ import { Skeleton } from "@/components/ui/skeleton"
|
|||
import { api, reviewChatApiBase } from "@/lib/api"
|
||||
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) =>
|
||||
fetch(input, { ...init, credentials: "include" })
|
||||
|
||||
|
|
@ -271,25 +413,42 @@ function ChatBody({
|
|||
onUserSend: (text: string) => void
|
||||
expectsHistory: boolean
|
||||
}) {
|
||||
const composer = useReviewChatComposer()
|
||||
const stream = useStreamContext()
|
||||
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 busy = stream.isLoading
|
||||
// True during the one-time getState hydration when switching to / loading an
|
||||
// existing thread, before its messages have arrived.
|
||||
const hydrating = stream.isThreadLoading
|
||||
|
||||
// Receive "add to chat" attachments from the diff column as composer pills.
|
||||
useEffect(() => {
|
||||
endRef.current?.scrollIntoView({ behavior: "smooth" })
|
||||
}, [messages])
|
||||
if (!composer) return
|
||||
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(
|
||||
(text: string) => {
|
||||
(text: string, atts: Array<ChatAttachment>) => {
|
||||
const trimmed = text.trim()
|
||||
if (!trimmed || busy) return
|
||||
onUserSend(trimmed)
|
||||
void stream.submit({ messages: [{ type: "human", content: trimmed }] })
|
||||
const first = atts[0]
|
||||
if ((!trimmed && !first) || busy) return
|
||||
const content = serializeMessage(trimmed, atts)
|
||||
onUserSend(trimmed || (first ? attachmentPillLabel(first) : ""))
|
||||
void stream.submit({ messages: [{ type: "human", content }] })
|
||||
},
|
||||
[busy, stream, onUserSend],
|
||||
)
|
||||
|
|
@ -301,44 +460,85 @@ function ChatBody({
|
|||
})
|
||||
|
||||
const submitComposer = () => {
|
||||
send(value)
|
||||
send(value, attachments)
|
||||
setValue("")
|
||||
setAttachments([])
|
||||
}
|
||||
|
||||
// Show the loading placeholder (not the empty/intro state) while an existing
|
||||
// conversation hydrates, so a chat with messages never flashes its greeting.
|
||||
const showEmpty = visible.length === 0 && !busy
|
||||
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 (
|
||||
<div className="flex flex-1 flex-col overflow-hidden">
|
||||
{showLoading ? (
|
||||
<LoadingState />
|
||||
) : 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) => {
|
||||
const isUser = messageType(message) === "human"
|
||||
return (
|
||||
<div
|
||||
key={message.id ?? index}
|
||||
className={cn("flex", isUser ? "justify-end" : "justify-start")}
|
||||
>
|
||||
<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>
|
||||
) : (
|
||||
if (!isUser) {
|
||||
return (
|
||||
<div key={message.id ?? index} className="flex justify-start">
|
||||
<div className="w-full text-[13px] text-foreground">
|
||||
<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>
|
||||
|
|
@ -351,34 +551,46 @@ function ChatBody({
|
|||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div ref={endRef} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<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">
|
||||
<Textarea
|
||||
value={value}
|
||||
onChange={(event) => setValue(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter" && !event.shiftKey) {
|
||||
event.preventDefault()
|
||||
submitComposer()
|
||||
}
|
||||
}}
|
||||
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() || busy}
|
||||
aria-label="Send message"
|
||||
className="rounded-full"
|
||||
>
|
||||
<ArrowUpIcon className="size-4" />
|
||||
</IconButton>
|
||||
<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">
|
||||
{attachments.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1 pl-2 pt-0.5">
|
||||
{attachments.map((attachment) => (
|
||||
<AttachmentPill
|
||||
key={attachment.id}
|
||||
label={attachmentPillLabel(attachment)}
|
||||
onRemove={() => removeAttachment(attachment.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-end gap-2 pl-2">
|
||||
<Textarea
|
||||
value={value}
|
||||
onChange={(event) => setValue(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter" && !event.shiftKey) {
|
||||
event.preventDefault()
|
||||
submitComposer()
|
||||
}
|
||||
}}
|
||||
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>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,12 @@
|
|||
import { createContext, useContext, useEffect, useMemo, useState } from "react"
|
||||
import {
|
||||
createContext,
|
||||
memo,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
} from "react"
|
||||
import {
|
||||
FileTree,
|
||||
useFileTree,
|
||||
|
|
@ -192,7 +200,7 @@ function ReviewGroupList({
|
|||
<ReviewGroupRow
|
||||
key={group.index}
|
||||
group={group}
|
||||
onSelect={() => onSelectGroup(group.index)}
|
||||
onSelectGroup={onSelectGroup}
|
||||
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,
|
||||
onSelect,
|
||||
onSelectGroup,
|
||||
onSelectFile,
|
||||
}: {
|
||||
group: ReviewSidebarGroup
|
||||
onSelect: () => void
|
||||
onSelectGroup: (index: number) => void
|
||||
onSelectFile: (path: string) => void
|
||||
}) {
|
||||
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 (
|
||||
<div className="px-3 py-3 transition-colors hover:bg-[var(--ui-sidebar-hover)]">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSelect}
|
||||
className="flex w-full items-start gap-2 text-left"
|
||||
>
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={selectGroup}
|
||||
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)]">
|
||||
{group.index}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block text-xs leading-5 font-medium text-[var(--ui-text)]">
|
||||
{renderInlineCode(group.title)}
|
||||
{title}
|
||||
</span>
|
||||
<span className="mt-1 flex flex-wrap items-center gap-1.5 text-[11px] text-[var(--ui-text-dim)]">
|
||||
<span>
|
||||
|
|
@ -267,7 +301,7 @@ function ReviewGroupRow({
|
|||
)}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{group.files.length > 0 && (
|
||||
<div className="mt-2 space-y-0.5 pl-7">
|
||||
|
|
@ -277,7 +311,10 @@ function ReviewGroupRow({
|
|||
<button
|
||||
key={path}
|
||||
type="button"
|
||||
onClick={() => onSelectFile(path)}
|
||||
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)]"
|
||||
>
|
||||
|
|
@ -299,7 +336,10 @@ function ReviewGroupRow({
|
|||
<div className="mt-2">
|
||||
<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)]"
|
||||
>
|
||||
<CaretRightIcon
|
||||
|
|
@ -312,14 +352,14 @@ function ReviewGroupRow({
|
|||
</button>
|
||||
{expanded && (
|
||||
<div className="mt-1.5">
|
||||
<Markdown content={stripLocationLinks(group.summary)} />
|
||||
<Markdown content={summary} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
function ReviewFileTreeExplorer({
|
||||
files,
|
||||
|
|
|
|||
|
|
@ -1,7 +1,14 @@
|
|||
import { useMemo } from "react"
|
||||
import { preloadHighlighter } from "@pierre/diffs"
|
||||
import type {
|
||||
VirtualFileMetrics,
|
||||
WorkerInitializationRenderOptions,
|
||||
WorkerPoolOptions,
|
||||
} from "@pierre/diffs/react"
|
||||
import { useResolvedTheme } from "@/lib/theme"
|
||||
|
||||
export type DiffStyle = "unified" | "split"
|
||||
|
||||
export const DIFF_UNSAFE_CSS = `
|
||||
[data-diffs-header],
|
||||
[data-diff],
|
||||
|
|
@ -71,14 +78,66 @@ export const diffOptions = {
|
|||
tokenizeMaxLength: 120_000,
|
||||
}
|
||||
|
||||
export function useDiffOptions() {
|
||||
export function useDiffOptions(diffStyle: DiffStyle = "unified") {
|
||||
const resolvedTheme = useResolvedTheme()
|
||||
return useMemo(
|
||||
() => ({ ...diffOptions, themeType: resolvedTheme }),
|
||||
[resolvedTheme]
|
||||
() => ({ ...diffOptions, themeType: resolvedTheme, diffStyle }),
|
||||
[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
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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 { useHotkey } from "@/lib/hotkeys";
|
||||
|
|
@ -55,6 +62,30 @@ export function useSidebarLayout() {
|
|||
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 {
|
||||
width: number;
|
||||
setWidth: (next: number) => void;
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
Loading…
Add table
Reference in a new issue