open-swe/ui/src/components/agents/ReviewChat.tsx

642 lines
20 KiB
TypeScript
Raw Normal View History

feat: chat with your PR on the review page (#1534) * feat: chat with your PR on the review page Add a sandbox-less `chat` graph that answers questions about a single PR from its diff, the published review findings, and read-only GitHub access. - agent/chat.py: deepagents graph, no sandbox (default StateBackend, file mutation + execute tools excluded). PR context is seeded as virtual files under /pr/; a repo-scoped App token is resolved in-graph. - tools: read_repo_file, search_repo_code, list_review_findings. - dashboard/review_chat_api.py + routes: per-user chat thread, LangGraph stream/commands/state/history proxy pinned to the chat assistant, seeds diff/findings/overview on first run. Gated by repo access. - UI: Chat tab wired to a chat-scoped StreamProvider (replaces Coming Soon). * feat: admin setting for review-chat default model Add a 'Open SWE Review Chat' default to team settings (default_chat_model / default_chat_reasoning_effort). get_team_default_model("chat") inherits the Agent default when unset; the chat graph resolves through it. Admin RolePicker gains an 'Agent default' inherit option that clears the override. * feat: multi-conversation review chat (tabs, new chat, history) Replace the single per-PR chat thread with multiple per-user conversations: - threads minted client-side; first message persists with a title derived from the prompt. - list + delete endpoints; chat panel gets a tab strip (history), new-chat (+), close (x), refresh, an intro greeting, and suggested prompts. - get_review_chat now returns availability only (ids are client-minted). * ui fixes * ui: review-chat history dropdown, full-width AI replies, resizable side panel * fix(review-chat): enforce per-user thread ownership on proxy endpoints; reseed PR context on head change --------- Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com>
2026-06-15 17:17:30 -07:00
import { useCallback, useEffect, 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,
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"
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-sm 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-sm 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 stream = useStreamContext()
const [value, setValue] = useState("")
const endRef = useRef<HTMLDivElement>(null)
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
useEffect(() => {
endRef.current?.scrollIntoView({ behavior: "smooth" })
}, [messages])
const send = useCallback(
(text: string) => {
const trimmed = text.trim()
if (!trimmed || busy) return
onUserSend(trimmed)
void stream.submit({ messages: [{ type: "human", content: trimmed }] })
},
[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)
setValue("")
}
// 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
return (
<div className="flex flex-1 flex-col overflow-hidden">
{showLoading ? (
<LoadingState />
) : showEmpty ? (
<EmptyState onPick={send} />
) : (
<div 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-sm 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)} />
)}
</div>
</div>
)
})}
{busy && (
<div className="flex justify-start">
<div className="px-3 py-2 text-sm text-muted-foreground">
Thinking…
</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>
</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}
/>
)
}