open-swe/ui/src/components/agents/ReviewChat.tsx
Johannes du Plessis 75e594c861
fix: reviewer reviews full diff; fix review UI scroll + dark-mode composer (#1575)
* fix: reviewer reviews full diff; fix review UI scroll + dark-mode composer

- Drop the 200K-char PR diff truncation. The head/tail slice silently dropped
  whole files out of the middle; the reviewer's large context window reviews
  the complete diff. The grouping pass uses the full diff too. Other #1567 caps
  (fetch_url, Slack, pagination, message queue) are kept.
- Sidebar file/group click now lands flush at the top under virtualization:
  re-assert alignment after the Virtualizer's mid-scroll height reconciliation
  settles, instead of trusting scrollIntoView's estimate-based target.
- Review chat composer textarea no longer shows a lighter square in dark mode
  (override the Textarea's dark:bg-input default with dark:bg-transparent).

* fix: anchored finding card sits flush in the diff gutter, no side-panel overlap

The card was positioned next to the anchor and clamped to window.innerWidth, so
when the anchor sat near the diff/side-panel boundary the 412px card spilled
across into the side panel. Pin it flush to the diff column's right edge and
clamp its width to the column so it never overlaps the side panel and shrinks to
fit a narrow column.

* fix: anchored finding card sits over the side panel, not the diff

Flip the horizontal anchor: place the card flush against the diff column's right
edge and extend it rightward over the side panel, instead of leftward over the
diff. Width still caps at the preferred size and shrinks to fit a narrow panel.

* fix: anchor finding card to diff content edge + small-screen fallback

- Anchor to the centered diff content's right edge instead of the full-width
  scroller, removing the centering-whitespace gap so the card sits closer to the
  diff.
- Below xl the side panel is hidden and the diff fills the viewport, leaving no
  gutter; positioning against the content edge would collapse the card to ~0px.
  Fall back to overlaying the diff flush-right when there's no usable gutter
  (addresses Open SWE review finding f_f6ea77527c).

* fix: anchor finding card next to the annotation, close to the hunk

Anchor the card's left to the finding's annotation (anchorRect.right) instead of
the diff content's right edge, so it sits right by the hunk and overlaps the diff
edge rather than parking out in the gutter. Extends right over the side panel,
clamped to stay on-screen (also keeps it readable below xl with no side panel).

* fix: finding card width shrinks to fit a small side panel

Size the card to the room to the right of the annotation (capped at the
preferred width) so it narrows as the side panel shrinks instead of overflowing.
Below a readable minimum, hold that width and shift left over the diff.
2026-06-18 19:24:52 -07:00

853 lines
27 KiB
TypeScript

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"
import {
ArrowClockwiseIcon,
ArrowUpIcon,
CheckIcon,
CodeIcon,
PlusIcon,
SparkleIcon,
TrashIcon,
XIcon,
} from "@phosphor-icons/react"
import { Menu } from "@base-ui/react/menu"
import type { BaseMessage } from "@langchain/core/messages"
import type { ReviewChatThread } from "@/lib/api"
import { Markdown } from "@/components/agents/ported"
import { IconButton } from "@/components/ui/button"
import { Textarea } from "@/components/ui/textarea"
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" })
// The SDK's internal Client issues some reads (getState, history) outside the
// transport's fetch; without this they drop the session cookie cross-origin.
overrideFetchImplementation(dashboardFetch)
const SUGGESTED_PROMPTS = [
"Summarize the changes in this PR",
"Walk me through the review findings",
"What are the riskiest parts of this change?",
]
// Kept in sync with the backend's `_derive_title` so the optimistic tab label
// matches the title the server persists for the thread.
const DEFAULT_TITLE = "New chat"
const TITLE_MAX_CHARS = 60
function deriveTitle(text: string): string {
const flattened = text.trim().split(/\s+/).join(" ")
return flattened ? flattened.slice(0, TITLE_MAX_CHARS) : DEFAULT_TITLE
}
function messageType(message: BaseMessage): string {
const candidate = message as unknown as {
getType?: () => string
type?: string
role?: string
}
return candidate.getType?.() ?? candidate.type ?? candidate.role ?? "ai"
}
function messageText(content: BaseMessage["content"]): string {
if (typeof content === "string") return content
if (!Array.isArray(content)) return ""
return content
.map((block) => {
if (typeof block === "string") return block
if (typeof block === "object" && "text" in block) {
const text = (block as { text?: unknown }).text
return typeof text === "string" ? text : ""
}
return ""
})
.filter(Boolean)
.join("\n")
}
// --- Conversation store ------------------------------------------------------
//
// The client owns the conversation list. Each conversation is a client-minted
// thread id; the thread is created server-side lazily on its first message.
// The server thread list is only used to recover conversations on a fresh
// browser and to reconcile titles — it is never the sole source for the tabs,
// so an empty/lagging server response can no longer make open chats vanish.
interface Conversation {
id: string
title: string
createdAt: number
}
interface ChatState {
conversations: Array<Conversation>
activeId: string
}
function storageKey(owner: string, repo: string, number: number): string {
return `osw:review-chat:${owner}/${repo}/${number}`
}
function newDraft(): Conversation {
return { id: crypto.randomUUID(), title: DEFAULT_TITLE, createdAt: Date.now() }
}
function isConversation(value: unknown): value is Conversation {
if (!value || typeof value !== "object") return false
const candidate = value as Record<string, unknown>
return (
typeof candidate.id === "string" &&
typeof candidate.title === "string" &&
typeof candidate.createdAt === "number"
)
}
function loadState(key: string): ChatState {
if (typeof window !== "undefined") {
try {
const raw = window.localStorage.getItem(key)
if (raw) {
const parsed: unknown = JSON.parse(raw)
const source = (parsed ?? {}) as Record<string, unknown>
const conversations = Array.isArray(source.conversations)
? source.conversations.filter(isConversation)
: []
const first = conversations[0]
if (first) {
const activeId =
typeof source.activeId === "string" &&
conversations.some((c) => c.id === source.activeId)
? source.activeId
: first.id
return { conversations, activeId }
}
}
} catch {
/* fall through to a fresh draft */
}
}
const draft = newDraft()
return { conversations: [draft], activeId: draft.id }
}
function saveState(key: string, state: ChatState): void {
if (typeof window === "undefined") return
try {
window.localStorage.setItem(key, JSON.stringify(state))
} catch {
/* ignore quota / availability errors */
}
}
function useConversations(key: string) {
const [state, setState] = useState<ChatState>(() => loadState(key))
const update = useCallback(
(fn: (prev: ChatState) => ChatState) => {
setState((prev) => {
const next = fn(prev)
if (next === prev) return prev
saveState(key, next)
return next
})
},
[key],
)
const select = useCallback(
(conversation: Conversation) => {
update((prev) => ({
conversations: prev.conversations.some((c) => c.id === conversation.id)
? prev.conversations
: [...prev.conversations, conversation],
activeId: conversation.id,
}))
},
[update],
)
const newChat = useCallback(() => {
update((prev) => {
const active = prev.conversations.find((c) => c.id === prev.activeId)
// Reuse a pristine, never-sent draft instead of stacking empty tabs.
if (active && active.title === DEFAULT_TITLE) return prev
const draft = newDraft()
return { conversations: [...prev.conversations, draft], activeId: draft.id }
})
}, [update])
// Names a conversation from its first message; later messages don't rename it.
const nameConversation = useCallback(
(id: string, title: string) => {
update((prev) => {
const current = prev.conversations.find((c) => c.id === id)
if (!current || current.title !== DEFAULT_TITLE) return prev
return {
...prev,
conversations: prev.conversations.map((c) =>
c.id === id ? { ...c, title } : c,
),
}
})
},
[update],
)
const close = useCallback(
(id: string) => {
update((prev) => {
const index = prev.conversations.findIndex((c) => c.id === id)
if (index === -1) return prev
const remaining = prev.conversations.filter((c) => c.id !== id)
const fallback = remaining[Math.max(0, index - 1)]
if (!fallback) {
const draft = newDraft()
return { conversations: [draft], activeId: draft.id }
}
return {
conversations: remaining,
activeId: prev.activeId === id ? fallback.id : prev.activeId,
}
})
},
[update],
)
return { ...state, select, newChat, nameConversation, close }
}
// --- View --------------------------------------------------------------------
function EmptyState({ onPick }: { onPick: (prompt: string) => void }) {
return (
<div className="flex flex-1 flex-col gap-4 p-4">
<p className="text-[13px] text-foreground">
I've reviewed this PR. Ask me about the diff, the findings, or the
surrounding code — I have read-only access to the repository.
</p>
<div className="flex flex-col gap-1.5">
<span className="text-xs font-medium text-muted-foreground">
Suggested prompts
</span>
{SUGGESTED_PROMPTS.map((prompt) => (
<button
key={prompt}
type="button"
onClick={() => onPick(prompt)}
className="flex items-center gap-2 rounded-md px-2 py-1.5 text-left text-[13px] text-foreground hover:bg-muted/60"
>
<SparkleIcon className="size-4 shrink-0 text-muted-foreground" />
{prompt}
</button>
))}
</div>
</div>
)
}
function LoadingState() {
return (
<div className="flex flex-1 flex-col gap-4 p-4">
<div className="flex justify-end">
<Skeleton className="h-12 w-2/5 rounded-lg" />
</div>
<div className="flex flex-col gap-2">
<Skeleton className="h-4 w-4/5" />
<Skeleton className="h-4 w-3/5" />
</div>
</div>
)
}
function ChatBody({
onUserSend,
expectsHistory,
}: {
onUserSend: (text: string) => void
expectsHistory: boolean
}) {
const composer = useReviewChatComposer()
const stream = useStreamContext()
const [value, setValue] = useState("")
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(() => {
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, atts: Array<ChatAttachment>) => {
const trimmed = text.trim()
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],
)
const visible = messages.filter((message) => {
const type = messageType(message)
if (type !== "human" && type !== "ai") return false
return messageText(message.content).trim().length > 0
})
const submitComposer = () => {
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={(prompt) => {
send(prompt, attachments)
setAttachments([])
}}
/>
) : (
<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"
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>
)
})}
{busy && (
<div className="flex justify-start">
<div className="px-3 py-2 text-xs text-muted-foreground">
Thinking…
</div>
</div>
)}
</div>
)}
<div className="p-3">
<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 dark:bg-transparent"
/>
<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>
)
}
function ChatPanel({
owner,
repo,
number,
assistantId,
}: {
owner: string
repo: string
number: number
assistantId: string
}) {
const qc = useQueryClient()
const threadsKey = useMemo(
() => ["review-chat-threads", owner, repo, number] as const,
[owner, repo, number],
)
const { conversations, activeId, select, newChat, nameConversation, close } =
useConversations(storageKey(owner, repo, number))
const threadsQuery = useQuery({
queryKey: threadsKey,
queryFn: () => api.listReviewChatThreads(owner, repo, number),
})
const serverThreads = useMemo(
() => threadsQuery.data?.threads ?? [],
[threadsQuery.data],
)
const invalidateThreads = useCallback(() => {
void qc.invalidateQueries({ queryKey: threadsKey })
}, [qc, threadsKey])
const deleteThread = useMutation({
mutationFn: (id: string) =>
api.deleteReviewChatThread(owner, repo, number, id),
onMutate: (id: string) => {
qc.setQueryData<{ threads: Array<ReviewChatThread> }>(threadsKey, (old) =>
old ? { threads: old.threads.filter((t) => t.thread_id !== id) } : old,
)
},
onSettled: invalidateThreads,
})
// Open tabs are the client's conversations (titles reconciled from the
// server). The history dropdown is the full set — open tabs plus every other
// conversation the server knows about (e.g. recovered on a fresh browser) —
// newest first, so past chats are reachable without cluttering the tab strip.
const { openTabs, historyItems } = useMemo(() => {
const byId = new Map(conversations.map((c) => [c.id, { ...c }]))
for (const thread of serverThreads) {
const existing = byId.get(thread.thread_id)
if (existing) {
if (thread.title && thread.title !== DEFAULT_TITLE) {
existing.title = thread.title
}
} else {
byId.set(thread.thread_id, {
id: thread.thread_id,
title: thread.title || DEFAULT_TITLE,
createdAt: thread.updated_at ? Date.parse(thread.updated_at) || 0 : 0,
})
}
}
return {
openTabs: conversations.map((c) => byId.get(c.id) ?? c),
historyItems: [...byId.values()].sort((a, b) => b.createdAt - a.createdAt),
}
}, [conversations, serverThreads])
const handleClose = useCallback(
(id: string) => {
if (serverThreads.some((t) => t.thread_id === id)) {
deleteThread.mutate(id)
}
close(id)
},
[serverThreads, deleteThread, close],
)
const handleUserSend = useCallback(
(text: string) => {
nameConversation(activeId, deriveTitle(text))
},
[nameConversation, activeId],
)
// Whether the active conversation should already have messages: it exists
// server-side, or it's been named by a sent message. Drives the hydration
// placeholder so an existing chat never flashes the intro state on load.
const expectsHistory = useMemo(() => {
if (serverThreads.some((t) => t.thread_id === activeId)) return true
const active = conversations.find((c) => c.id === activeId)
return active !== undefined && active.title !== DEFAULT_TITLE
}, [serverThreads, conversations, activeId])
return (
<div className="flex h-full flex-1 flex-col overflow-hidden">
<div className="flex items-center gap-1 border-b border-border px-2 py-1.5">
<div className="flex flex-1 items-center gap-1 overflow-x-auto">
{openTabs.map((tab) => (
<div
key={tab.id}
className={cn(
"flex shrink-0 items-center gap-1 rounded-md py-1 pl-2 pr-1 text-xs",
tab.id === activeId
? "bg-muted text-foreground"
: "text-muted-foreground hover:bg-muted/50",
)}
>
<button
type="button"
onClick={() => select(tab)}
className="max-w-[140px] truncate"
>
{tab.title}
</button>
<IconButton
type="button"
variant="ghost"
size="icon-xs"
aria-label="Close chat"
onClick={() => handleClose(tab.id)}
>
<XIcon />
</IconButton>
</div>
))}
</div>
<IconButton
type="button"
variant="ghost"
size="icon-sm"
aria-label="New chat"
onClick={newChat}
>
<PlusIcon />
</IconButton>
<Menu.Root
onOpenChange={(open) => {
if (open) void threadsQuery.refetch()
}}
>
<Menu.Trigger
render={
<IconButton
type="button"
variant="ghost"
size="icon-sm"
aria-label="Chat history"
>
<ArrowClockwiseIcon />
</IconButton>
}
/>
<Menu.Portal>
<Menu.Positioner align="end" sideOffset={6} className="z-50">
<Menu.Popup className="origin-(--transform-origin) max-h-80 w-64 overflow-y-auto rounded-lg bg-popover p-1 text-popover-foreground shadow-md ring-1 ring-foreground/10 outline-none">
{historyItems.length === 0 ? (
<div className="px-2 py-1.5 text-xs text-muted-foreground">
No conversations yet
</div>
) : (
historyItems.map((item) => (
<div
key={item.id}
className="group/hist relative flex items-stretch"
>
<Menu.Item
onClick={() => select(item)}
className="flex flex-1 cursor-default items-center gap-2 rounded-md py-1.5 pl-2 pr-8 text-xs outline-none data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground"
>
{item.id === activeId ? (
<CheckIcon className="size-3.5 shrink-0" />
) : (
<span className="size-3.5 shrink-0" />
)}
<span className="flex-1 truncate text-left">
{item.title}
</span>
</Menu.Item>
<IconButton
type="button"
variant="ghost"
size="icon-xs"
aria-label="Delete chat"
className="absolute right-1 top-1/2 -translate-y-1/2 opacity-0 group-hover/hist:opacity-100"
onClick={() => handleClose(item.id)}
>
<TrashIcon />
</IconButton>
</div>
))
)}
</Menu.Popup>
</Menu.Positioner>
</Menu.Portal>
</Menu.Root>
</div>
<StreamProvider
key={activeId}
apiUrl={reviewChatApiBase(owner, repo, number)}
assistantId={assistantId}
fetch={dashboardFetch}
threadId={activeId}
onCompleted={invalidateThreads}
>
<ChatBody onUserSend={handleUserSend} expectsHistory={expectsHistory} />
</StreamProvider>
</div>
)
}
export function ReviewChat({
owner,
repo,
number,
}: {
owner: string
repo: string
number: number
}) {
const meta = useQuery({
queryKey: ["review-chat", owner, repo, number],
queryFn: () => api.getReviewChat(owner, repo, number),
})
if (meta.isPending) {
return (
<div className="flex flex-1 flex-col gap-3 p-4">
<Skeleton className="h-4 w-2/3" />
<Skeleton className="h-4 w-1/2" />
</div>
)
}
if (meta.isError || !meta.data.available) {
return (
<div className="flex flex-1 items-center justify-center p-6 text-center text-xs text-muted-foreground">
Chat becomes available once the review has finished running.
</div>
)
}
return (
<ChatPanel
key={`${owner}/${repo}/${number}`}
owner={owner}
repo={repo}
number={number}
assistantId={meta.data.assistant_id}
/>
)
}