import { Avatar, AvatarFallback } from "@agent-native/toolkit/ui/avatar"; import { Button } from "@agent-native/toolkit/ui/button"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, } from "@agent-native/toolkit/ui/dropdown-menu"; import { Input } from "@agent-native/toolkit/ui/input"; import { Skeleton } from "@agent-native/toolkit/ui/skeleton"; import { Spinner } from "@agent-native/toolkit/ui/spinner"; import { IconCircleCheck, IconDots, IconMessageCircle, IconSend, IconTrash, IconX, } from "@tabler/icons-react"; import { useMemo, useState, type ReactNode } from "react"; import type { ReviewComment, ReviewResolutionTarget, } from "../../review/types.js"; import { useFormatters } from "../i18n.js"; import { cn } from "../utils.js"; import { ReviewCommentComposer } from "./ReviewCommentComposer.js"; import { ReviewStatusBadge } from "./ReviewStatusBadge.js"; import { useCreateReviewComment, useDeleteReviewComment, useReplyReviewComment, useResolveReviewThread, useReviewComments, } from "./use-review.js"; export interface ReviewThread { root: ReviewComment; replies: ReviewComment[]; } export type ReviewThreadCapability = | boolean | ((thread: ReviewThread) => boolean); export type ReviewCommentCapability = | boolean | ((comment: ReviewComment, thread: ReviewThread) => boolean); export interface ReviewThreadPanelProps { resourceType: string; resourceId: string; targetId?: string | null; /** Persist new comments against this target while targetId continues to filter the list. */ composerTargetId?: string | null; /** Optional element/point anchor attached to new comments from the composer. */ composerAnchor?: unknown | null; /** Host metadata attached to new comments from the composer. */ composerMetadata?: Record; /** Visible label describing the element currently targeted by the composer. */ composerContextLabel?: string; title?: string; className?: string; includeResolved?: boolean; showComposer?: boolean; showHeader?: boolean; variant?: "card" | "plain"; placeholder?: string; emptyState?: string; loadingLabel?: string; replyLabel?: string; replyPlaceholder?: string; cancelReplyLabel?: string; resolveLabel?: string; deleteLabel?: string; moreActionsLabel?: string; resolvedLabel?: string; reviewerLabel?: string; onSelectThread?: (thread: ReviewThread) => void; onCommentCreated?: (comment: ReviewComment) => void; /** Allow signed-in commenters to reply. Omitted capabilities fail closed. */ canReply?: ReviewThreadCapability; /** Allow editors to resolve a thread. Omitted capabilities fail closed. */ canResolve?: ReviewThreadCapability; /** Allow deletion only for comments the caller has authorized. */ canDeleteComment?: ReviewCommentCapability; /** Extra per-thread controls rendered next to reply/resolve/delete. */ renderThreadActions?: (thread: ReviewThread) => ReactNode; /** Show a separate agent-submit action when the host supports agent routing. */ showComposerTargetPicker?: boolean; composerCommentLabel?: string; composerAgentLabel?: string; } export function ReviewThreadPanel({ resourceType, resourceId, targetId, composerTargetId, composerAnchor, composerMetadata, composerContextLabel, title = "Review", className, includeResolved = true, showComposer = true, showHeader = true, variant = "card", placeholder = "Add a comment...", emptyState = "No review comments yet.", loadingLabel = "Loading comments", replyLabel = "Reply", replyPlaceholder = "Reply...", cancelReplyLabel = "Cancel reply", resolveLabel = "Resolve", deleteLabel = "Delete comment", moreActionsLabel = "More actions", resolvedLabel = "Resolved", reviewerLabel = "Reviewer", onSelectThread, onCommentCreated, canReply = false, canResolve = false, canDeleteComment = false, renderThreadActions, showComposerTargetPicker = false, composerCommentLabel = "Comment", composerAgentLabel = "Send to agent", }: ReviewThreadPanelProps) { const [draft, setDraft] = useState(""); const [submittingTarget, setSubmittingTarget] = useState(null); const [replyingThreadId, setReplyingThreadId] = useState(null); const [replyDrafts, setReplyDrafts] = useState>({}); const { formatDate } = useFormatters(); const comments = useReviewComments({ resourceType, resourceId, targetId, includeResolved, }); const createComment = useCreateReviewComment(); const replyComment = useReplyReviewComment(); const resolveThread = useResolveReviewThread(); const deleteComment = useDeleteReviewComment(); const threads = useMemo( () => buildReviewThreads(comments.data?.comments ?? []), [comments.data?.comments], ); const submitDraft = (resolutionTarget: ReviewResolutionTarget) => { const body = draft.trim(); if (!body || createComment.isPending) return; setSubmittingTarget(resolutionTarget); createComment.mutate( { resourceType, resourceId, targetId: composerTargetId === undefined ? targetId : composerTargetId, ...(composerAnchor !== undefined ? { anchor: composerAnchor } : {}), ...(composerMetadata ? { metadata: composerMetadata } : {}), body, resolutionTarget: showComposerTargetPicker ? resolutionTarget : "human", }, { onSuccess: (comment) => { setDraft(""); onCommentCreated?.(comment); }, onSettled: () => setSubmittingTarget(null), }, ); }; return (
{showHeader ? (

{title}

) : null} {showComposer ? ( ) : null}
{comments.isLoading ? (
) : threads.length ? ( threads.map((thread) => { const replyDraft = replyDrafts[thread.root.id] ?? ""; const replying = replyingThreadId === thread.root.threadId; const threadIsOpen = thread.root.status === "open"; const replyAllowed = threadIsOpen && capabilityAllowsThread(canReply, thread); const resolveAllowed = threadIsOpen && capabilityAllowsThread(canResolve, thread); const deleteAllowed = capabilityAllowsComment( canDeleteComment, thread.root, thread, ); const threadActions = renderThreadActions?.(thread); const hasActions = replyAllowed || resolveAllowed || deleteAllowed || Boolean(threadActions); return (
onSelectThread?.(thread)} > {thread.replies.length ? (
{thread.replies.map((reply) => ( ))}
) : null} {replying && replyAllowed ? (
event.stopPropagation()} onSubmit={(event) => { event.preventDefault(); const body = replyDraft.trim(); if (!body || replyComment.isPending) return; replyComment.mutate( { resourceType, resourceId, commentId: thread.root.id, body, }, { onSuccess: () => { setReplyDrafts((current) => ({ ...current, [thread.root.id]: "", })); setReplyingThreadId(null); }, }, ); }} > setReplyDrafts((current) => ({ ...current, [thread.root.id]: event.currentTarget.value, })) } onKeyDown={(event) => { if (event.key === "Escape") { event.preventDefault(); event.stopPropagation(); setReplyingThreadId(null); } }} placeholder={replyPlaceholder} className="h-8 min-w-0 flex-1 text-sm" />
) : hasActions ? (
event.stopPropagation()} > {replyAllowed ? ( ) : null}
{threadActions} {resolveAllowed ? ( ) : null} {deleteAllowed ? ( deleteComment.mutate({ resourceType, resourceId, commentId: thread.root.id, }) } > {deleteLabel} ) : null}
) : null}
); }) ) : (
{emptyState}
)}
); } export function buildReviewThreads(comments: ReviewComment[]): ReviewThread[] { const roots: ReviewComment[] = []; const replies = new Map(); for (const comment of comments) { if (!comment.parentCommentId || comment.parentCommentId === comment.id) { roots.push(comment); continue; } const list = replies.get(comment.threadId) ?? []; list.push(comment); replies.set(comment.threadId, list); } return roots.map((root) => ({ root, replies: replies.get(root.threadId) ?? [], })); } function CommentBubble({ comment, compact = false, resolvedLabel, reviewerLabel, formatDate, }: { comment: ReviewComment; compact?: boolean; resolvedLabel: string; reviewerLabel: string; formatDate: ReturnType["formatDate"]; }) { const author = comment.authorName ?? comment.authorEmail ?? reviewerLabel; const resolutionNote = comment.status === "resolved" ? getReviewResolutionNote(comment) : null; const bodyIsResolutionNote = resolutionNote !== null && resolutionNote === comment.body.trim() && isResolutionNoteMetadata(comment.metadata); return (
{authorInitials(author)}
{author} {comment.status === "resolved" ? ( {resolvedLabel} ) : null}
{!bodyIsResolutionNote ? (

{comment.body}

) : null} {resolutionNote ? (

{resolutionNote}

) : null}
); } function capabilityAllowsThread( capability: ReviewThreadCapability, thread: ReviewThread, ): boolean { return typeof capability === "function" ? capability(thread) : capability; } function capabilityAllowsComment( capability: ReviewCommentCapability, comment: ReviewComment, thread: ReviewThread, ): boolean { return typeof capability === "function" ? capability(comment, thread) : capability; } function getReviewResolutionNote(comment: ReviewComment): string | null { const direct = comment.resolutionNote; const metadata = comment.metadata; const nestedResolution = metadata?.resolution; const nestedNote = isRecord(nestedResolution) && typeof nestedResolution.note === "string" ? nestedResolution.note : null; const metadataNote = typeof metadata?.resolutionNote === "string" ? metadata.resolutionNote : typeof metadata?.resolvedNote === "string" ? metadata.resolvedNote : isResolutionNoteMetadata(metadata) && typeof metadata?.note === "string" ? metadata.note : isResolutionNoteMetadata(metadata) ? comment.body : null; const candidate = typeof direct === "string" ? direct : (metadataNote ?? nestedNote); const normalized = candidate?.trim(); return normalized || null; } function isResolutionNoteMetadata( metadata: Record | null, ): boolean { const marker = metadata?.type ?? metadata?.kind; return ( marker === "resolution" || marker === "resolution_note" || marker === "resolution-note" ); } function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } function authorInitials(value: string): string { const normalized = value.split("@")[0]?.trim() ?? ""; const parts = normalized.split(/[\s._+-]+/).filter(Boolean); const initials = parts .slice(0, 2) .map((part) => part[0]?.toUpperCase()) .join(""); return initials || "R"; } function formatCommentDate( value: string, formatDate: ReturnType["formatDate"], ): string { const date = new Date(value); if (Number.isNaN(date.getTime())) return value; const now = new Date(); const sameDay = date.getFullYear() === now.getFullYear() && date.getMonth() === now.getMonth() && date.getDate() === now.getDate(); if (sameDay) { return formatDate(date, { hour: "numeric", minute: "2-digit" }); } if (date.getFullYear() === now.getFullYear()) { return formatDate(date, { month: "short", day: "numeric" }); } return formatDate(date, { month: "short", day: "numeric", year: "numeric", }); }