/** * Pull-request-specific annotations: conversations already on the host and comments queued for * the review being written. New comment composition uses the shared diff annotation. */ import type { EnvironmentId, PullRequestRef, PullRequestReviewThread, PullRequestThreadCommentsResult, PullRequestThreadComment, } from "@t3tools/contracts"; import { CheckCircle2Icon, CircleIcon, HammerIcon, MessageSquareIcon, PencilIcon, Trash2Icon, } from "lucide-react"; import { useRef, useState } from "react"; import { formatRelativeTimeLabel } from "~/timestampFormat"; import { cn } from "~/lib/utils"; import { Button } from "../ui/button"; import { Textarea } from "../ui/textarea"; import { isCommentSubmitShortcut } from "../diffs/commentSubmitShortcut"; import { editPullRequestThreadComment, mergePullRequestThreadComments, } from "./pullRequestDetail.logic"; import { PullRequestActorLabel } from "./pullRequestPresentation"; import { PullRequestMarkdown } from "./PullRequestMarkdown"; import { PullRequestMarkdownEditor } from "./PullRequestMarkdownEditor"; import { PullRequestReactionBar } from "./PullRequestReactions"; import type { PendingReviewComment } from "./pullRequestReviewStore"; const CARD_CLASS = "mx-3 my-2 rounded-xl border border-border/70 bg-background p-3 text-sm shadow-sm"; /** Sends a reply on ⌘/Ctrl+Enter and abandons it on Escape. */ function submitKeys(input: { readonly value: string; readonly pending: boolean; readonly onSubmit: () => void; readonly onCancel?: (() => void) | undefined; }) { return (event: React.KeyboardEvent) => { if (event.key === "Escape" && input.onCancel) { event.preventDefault(); input.onCancel(); } if (isCommentSubmitShortcut(event, input.value, input.pending)) { event.preventDefault(); input.onSubmit(); } }; } /** A comment waiting to be sent with the rest of the review. */ export function PendingReviewCommentCard({ comment, onRemove, }: { comment: PendingReviewComment; onRemove: () => void; }) { return (
event.stopPropagation()} >
Pending — sent when you submit the review

{comment.body}

); } /** A conversation already on the host, with whatever this host lets the reader do to it. */ export function ReviewThreadCard({ thread, workspaceRoot, canReply, canResolve, canReact, environmentId, reference, pending, fixPending, fixLabel = "Fix in a thread", onFix, onReply, onLoadMore, canEditComment, onEditComment, onToggleResolved, onReacted, }: { thread: PullRequestReviewThread; workspaceRoot: string; canReply: boolean; canResolve: boolean; canReact: boolean; environmentId: EnvironmentId; reference: PullRequestRef; pending: boolean; /** True while this thread's own hand-off is preparing, so only its button says so. */ fixPending?: boolean; fixLabel?: string; /** Absent where a thread is shown outside the pull request page's reach. */ onFix?: () => void; /** Resolves to whether the host took it, so a reply that failed keeps the words it was given. */ onReply: (body: string) => Promise; /** Reads one more page only after the reader asks for it. */ onLoadMore: (cursor: string) => Promise; /** Whether this reader wrote this remark, which is what rewriting one takes. */ canEditComment: (comment: PullRequestThreadComment) => boolean; /** Resolves to whether the host took it, like `onReply`. */ onEditComment: (commentId: string, body: string) => Promise; onToggleResolved: () => void; onReacted: () => void; }) { // A resolved thread is finished work, so it opens collapsed and stays one line until asked for. const [expanded, setExpanded] = useState(!thread.isResolved); const [replying, setReplying] = useState(false); const [reply, setReply] = useState(""); const [editingId, setEditingId] = useState(null); const [savingEdit, setSavingEdit] = useState(false); const sendingRef = useRef(false); const [loadedPage, setLoadedPage] = useState< (PullRequestThreadCommentsResult & { readonly threadId: string }) | null >(null); const [loadingMore, setLoadingMore] = useState(false); const currentPage = loadedPage?.threadId === thread.id ? loadedPage : null; const comments = mergePullRequestThreadComments(thread.comments, currentPage?.comments ?? []); const nextCommentsCursor = currentPage === null ? (thread.nextCommentsCursor ?? null) : currentPage.nextCursor; const commentCount = thread.commentCount ?? comments.length; const saveEdit = async (commentId: string, body: string) => { if (savingEdit) return; setSavingEdit(true); const saved = await onEditComment(commentId, body); setSavingEdit(false); if (saved) { setLoadedPage((previous) => previous?.threadId === thread.id ? { ...previous, comments: editPullRequestThreadComment(previous.comments, commentId, body), } : previous, ); setEditingId(null); } }; const send = async () => { const trimmed = reply.trim(); if (trimmed.length === 0 || pending || sendingRef.current) return; sendingRef.current = true; // Cleared only once the host has it. Otherwise a failed reply leaves an error toast and an // empty box, and the words have to be written again. try { if (await onReply(trimmed)) { // The mutation returns no comment. Keep what the reader loaded and reopen its cursor so // the new reply remains reachable without spending requests until they ask to load it. setLoadedPage((previous) => previous?.threadId === thread.id ? { ...previous, nextCursor: previous.nextCursor ?? thread.nextCommentsCursor ?? null, } : previous, ); setReply(""); setReplying(false); } } finally { sendingRef.current = false; } }; const loadMore = async () => { if (nextCommentsCursor === null || loadingMore) return; setLoadingMore(true); try { const page = await onLoadMore(nextCommentsCursor); if (page === null) return; setLoadedPage((previous) => ({ threadId: thread.id, comments: mergePullRequestThreadComments( previous?.threadId === thread.id ? previous.comments : [], page.comments, ), nextCursor: page.nextCursor, })); } finally { setLoadingMore(false); } }; return (
event.stopPropagation()} >
{thread.isResolved ? ( ) : ( )} {thread.isOutdated ? outdated : null} {onFix ? ( ) : null} {canResolve ? ( ) : null}
{expanded ? ( <>
{comments.map((comment) => (
{formatRelativeTimeLabel(comment.createdAt)}
{editingId === comment.id ? ( void saveEdit(comment.id, body)} onCancel={() => setEditingId(null)} /> ) : (
{canEditComment(comment) ? ( ) : null}
)}
))}
{nextCommentsCursor !== null ? (
) : null} {canReply ? ( replying ? (