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(null) export function ReviewChatComposerProvider({ children, }: { children: React.ReactNode }) { const sinkRef = useRef<((attachment: ChatAttachment) => void) | null>(null) const pendingRef = useRef>([]) const value = useMemo( () => ({ 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 ( {children} ) } 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): 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 text: string } { const attachments: Array = [] 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 ( {label} {onRemove && ( )} ) } 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 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 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 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(() => 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 (

I've reviewed this PR. Ask me about the diff, the findings, or the surrounding code — I have read-only access to the repository.

Suggested prompts {SUGGESTED_PROMPTS.map((prompt) => ( ))}
) } function LoadingState() { return (
) } function ChatBody({ onUserSend, expectsHistory, }: { onUserSend: (text: string) => void expectsHistory: boolean }) { const composer = useReviewChatComposer() const stream = useStreamContext() const [value, setValue] = useState("") const [attachments, setAttachments] = useState>([]) const scrollRef = useRef(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) => { 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 (
{showLoading ? ( ) : showEmpty ? ( { send(prompt, attachments) setAttachments([]) }} /> ) : (
{visible.map((message, index) => { const isUser = messageType(message) === "human" if (!isUser) { return (
) } const parsed = parseUserMessage(messageText(message.content)) return (
{parsed.attachments.length > 0 && (
{parsed.attachments.map((attachment, i) => ( ))}
)} {parsed.text && ( {parsed.text} )}
) })} {busy && (
Thinking…
)}
)}
{attachments.length > 0 && (
{attachments.map((attachment) => ( removeAttachment(attachment.id)} /> ))}
)}