import type { EnvironmentId, PullRequestDetailView, PullRequestRef, PullRequestReviewThread, PullRequestThreadComment, } from "@t3tools/contracts"; import { FileCode2Icon, MessageSquareIcon, XIcon } from "lucide-react"; import { useState } from "react"; import { reviewLineRangeLabel } from "@t3tools/client-runtime/review-selection"; import { Button } from "../ui/button"; import { Badge } from "../ui/badge"; import { Toggle, ToggleGroup } from "../ui/toggle-group"; import { Popover, PopoverPopup, PopoverTrigger, PopoverTitle } from "../ui/popover"; import { getReviewPositionAnchor, PendingReviewCommentCard, ReviewThreadCard, } from "./PullRequestReviewAnnotation"; import { usePendingReviewComments, type PendingReviewComment } from "./pullRequestReviewStore"; import { useReviewThreadActions } from "./useReviewThreadActions"; type CommentLocation = { readonly path: string; readonly line: number | null; readonly side: "left" | "right"; readonly threadId?: string; }; /** A PR-wide conversation checklist that stays reachable across all three review tabs. */ export function PullRequestCommentsPopover({ environmentId, reference, detail, onRefresh, onOpenComment, onAddThreadCommentToComposer, onAddPendingToComposer, }: { environmentId: EnvironmentId; reference: PullRequestRef; detail: PullRequestDetailView; onRefresh: () => void; onOpenComment: (location: CommentLocation) => void; onAddThreadCommentToComposer?: | ((thread: PullRequestReviewThread, comment: PullRequestThreadComment) => void) | undefined; onAddPendingToComposer?: ((comment: PendingReviewComment) => void) | undefined; }) { const [open, setOpen] = useState(false); const [trigger, setTrigger] = useState(null); const anchor = trigger ? { contextElement: trigger, getBoundingClientRect: () => { const button = trigger.getBoundingClientRect(); const panel = trigger.closest("[data-pull-request-panel]")?.getBoundingClientRect(); return new DOMRect( panel?.left ?? button.left, button.top, panel?.width ?? button.width, button.height, ); }, } : undefined; const [selectedStatus, setSelectedStatus] = useState<"resolved" | "unresolved" | "pending">( "unresolved", ); const pending = usePendingReviewComments(reference, detail.reviewThreads); const actions = useReviewThreadActions(environmentId, reference, detail, onRefresh); // Hosted drafts are represented by the merged pending list, so they get one checklist entry. const threads = detail.reviewThreads.flatMap((thread) => { const comments = thread.comments.filter((comment) => !comment.pendingReviewId); return comments.length ? [{ ...thread, comments }] : []; }); const unresolved = threads.filter((thread) => !thread.isResolved); const resolved = threads.filter((thread) => thread.isResolved); const openCount = unresolved.length + pending.length; const status = selectedStatus === "pending" && pending.length === 0 ? "unresolved" : selectedStatus; if (status !== selectedStatus) setSelectedStatus(status); const visibleThreads = status === "resolved" ? resolved : status === "unresolved" ? unresolved : []; const jump = (location: CommentLocation) => { setOpen(false); onOpenComment(location); }; return ( } aria-label={`Comments, ${openCount} open`} > Comments {openCount}
Comments
{ const next = values[0]; if (next === "resolved" || next === "unresolved" || next === "pending") setSelectedStatus(next); }} > Unresolved{" "} {unresolved.length} Resolved{" "} {resolved.length} {pending.length > 0 ? ( Pending{" "} {pending.length} ) : null}
{open ? ( <> {visibleThreads.map((thread) => (
onAddThreadCommentToComposer(thread, comment) : undefined } />
))} {(status === "pending" ? pending : []).map((comment) => (
onAddPendingToComposer(comment) : undefined } />
))} {visibleThreads.length === 0 && (status !== "pending" || pending.length === 0) ? (

No {status} comments.

) : null} ) : null}
); }