import { Avatar, AvatarFallback, AvatarImage, } from "@/src/components/ui/avatar"; import { Button } from "@/src/components/ui/button"; import { Form, FormControl, FormField, FormItem, FormMessage, } from "@/src/components/ui/form"; import { HoverCard, HoverCardContent, HoverCardTrigger, } from "@/src/components/ui/hover-card"; import { Tooltip, TooltipContent, TooltipTrigger, } from "@/src/components/ui/tooltip"; import { MarkdownView } from "@/src/components/ui/MarkdownViewer"; import { Textarea } from "@/src/components/ui/textarea"; import { Input } from "@/src/components/ui/input"; import { useHasProjectAccess } from "@/src/features/rbac/utils/checkProjectAccess"; import { api } from "@/src/utils/api"; import { getRelativeTimestampFromNow } from "@/src/utils/dates"; import { cn } from "@/src/utils/tailwind"; import { zodResolver } from "@hookform/resolvers/zod"; import { type CommentObjectType, CreateCommentData } from "@langfuse/shared"; import { ArrowUpToLine, LoaderCircle, Search, Trash, X } from "lucide-react"; import { useSession } from "next-auth/react"; import React, { useCallback, useEffect, useMemo, useRef, useState, } from "react"; import { useForm } from "react-hook-form"; import { type z } from "zod/v4"; import { useMentionAutocomplete } from "@/src/features/comments/hooks/useMentionAutocomplete"; import { MentionAutocomplete } from "@/src/features/comments/components/MentionAutocomplete"; import { useRouter } from "next/router"; import { ReactionPicker } from "@/src/features/comments/ReactionPicker"; import { ReactionBar } from "@/src/features/comments/ReactionBar"; import { stripMarkdown } from "@/src/utils/markdown"; import { MENTION_USER_PREFIX } from "@/src/features/comments/lib/mentionParser"; import { type SelectionData } from "./contexts/InlineCommentSelectionContext"; import { Badge } from "@/src/components/ui/badge"; import { useTheme } from "next-themes"; // IO field background colors - same as IOPreviewJSON.tsx const IO_FIELD_COLORS = { input: { light: "rgb(249, 252, 255)", dark: "rgb(15, 23, 42)" }, output: { light: "rgb(248, 253, 250)", dark: "rgb(20, 30, 41)" }, metadata: { light: "rgb(253, 251, 254)", dark: "rgb(30, 20, 40)" }, } as const; /** * Convert JSON path to human-readable format * $.messages[1].text → messages › 1 › text * $ → (root) */ function humanizeJsonPath(path: string): string { if (path === "$") return "(root)"; return path .replace(/^\$\.?/, "") // remove leading $. or $ .replace(/\[(\d+)\]/g, ".$1") // [0] → .0 .split(".") .filter(Boolean) .join(" › "); } const useIsomorphicLayoutEffect = typeof window !== "undefined" ? React.useLayoutEffect : React.useEffect; export function CommentList({ projectId, objectId, objectType, cardView = false, className, onDraftChange, onMentionDropdownChange, isDrawerOpen = false, pendingSelection, onSelectionUsed, }: { projectId: string; objectId: string; objectType: CommentObjectType; cardView?: boolean; className?: string; onDraftChange?: (hasDraft: boolean) => void; onMentionDropdownChange?: (isOpen: boolean) => void; isDrawerOpen?: boolean; pendingSelection?: SelectionData | null; onSelectionUsed?: () => void; }) { const session = useSession(); const router = useRouter(); const { resolvedTheme } = useTheme(); const isDark = resolvedTheme === "dark"; const [cursorPosition, setCursorPosition] = useState(0); const [searchQuery, setSearchQuery] = useState(""); const commentsContainerRef = useRef(null); const textareaRef = useRef(null); const searchInputRef = useRef(null); const didInitialAutoscrollRef = useRef(false); // Extract comment ID from hash for highlighting const highlightedCommentId = useMemo(() => { if (typeof window === "undefined") return null; const hash = window.location.hash; if (hash.startsWith("#comment-")) { return hash.replace("#comment-", ""); } return null; // eslint-disable-next-line react-hooks/exhaustive-deps }, [router.asPath]); const hasReadAccess = useHasProjectAccess({ projectId, scope: "comments:read", }); const hasWriteAccess = useHasProjectAccess({ projectId, scope: "comments:CUD", }); const hasMembersReadAccess = useHasProjectAccess({ projectId, scope: "projectMembers:read", }); const canTagUsers = hasWriteAccess && hasMembersReadAccess; const comments = api.comments.getByObjectId.useQuery( { projectId, objectId, objectType, }, { enabled: hasReadAccess && session.status === "authenticated" }, ); const form = useForm({ resolver: zodResolver(CreateCommentData), defaultValues: { content: "", projectId, objectId, objectType, }, }); useEffect(() => { form.reset({ content: "", projectId, objectId, objectType }); setSearchQuery(""); // Reset search when switching objects // eslint-disable-next-line react-hooks/exhaustive-deps }, [objectId, objectType]); // Mention autocomplete - useCallback to ensure stable reference const getTextareaValue = useCallback(() => { return form.getValues("content"); }, [form]); const mentionAutocomplete = useMentionAutocomplete({ projectId, getTextareaValue, cursorPosition, enabled: canTagUsers, }); // Notify parent when mention dropdown state changes useEffect(() => { onMentionDropdownChange?.(mentionAutocomplete.showDropdown); }, [mentionAutocomplete.showDropdown, onMentionDropdownChange]); const handleTextareaResize = useCallback((target: HTMLTextAreaElement) => { // Use requestAnimationFrame for optimal performance requestAnimationFrame(() => { if (target) { target.style.height = "auto"; const newHeight = Math.min(target.scrollHeight, 100); target.style.height = `${newHeight}px`; } }); }, []); const debouncedResize = useCallback(() => { let timeoutId: NodeJS.Timeout; const debounced = (target: HTMLTextAreaElement) => { clearTimeout(timeoutId); timeoutId = setTimeout(() => handleTextareaResize(target), 16); // ~60fps }; return { resize: debounced, cleanup: () => clearTimeout(timeoutId), }; }, [handleTextareaResize]); const resizeHandler = useMemo(() => debouncedResize(), [debouncedResize]); useEffect(() => { return () => { resizeHandler.cleanup(); }; }, [resizeHandler]); // Notify parent when a draft comment exists in the textarea const watchedContent = form.watch("content"); useEffect(() => { if (!onDraftChange) return; onDraftChange(Boolean(watchedContent && watchedContent.trim().length > 0)); // eslint-disable-next-line react-hooks/exhaustive-deps }, [watchedContent]); // Scroll to bottom on initial load to show latest comments + input. // Skip auto-scroll if there's a highlighted comment (deeplink takes precedence). useIsomorphicLayoutEffect(() => { if ( didInitialAutoscrollRef.current || !comments.data || !commentsContainerRef.current || highlightedCommentId ) { return; } const el = commentsContainerRef.current; // Do it synchronously post-DOM mutation to avoid flicker el.scrollTop = el.scrollHeight; // Fallback after paint in case content height changes (markdown, fonts, images) requestAnimationFrame(() => { el.scrollTop = el.scrollHeight; }); didInitialAutoscrollRef.current = true; }, [comments.data, highlightedCommentId]); // If a highlighted comment is specified (via hash), scroll it into view within the container useIsomorphicLayoutEffect(() => { if (!highlightedCommentId || !comments.data) return; const node = document.getElementById(`comment-${highlightedCommentId}`); if (node) { node.scrollIntoView({ block: "center", behavior: "smooth" }); } }, [comments.data, highlightedCommentId]); // Focus textarea when pendingSelection changes (user clicked comment button) useEffect(() => { if (pendingSelection && textareaRef.current) { // Delay to allow drawer animation to complete setTimeout(() => { textareaRef.current?.focus(); }, 150); } }, [pendingSelection]); // CMD+F keyboard shortcut to focus search (only when drawer is open) useEffect(() => { if (!isDrawerOpen) return; // Only capture when drawer is open const handleKeyDown = (event: KeyboardEvent) => { // Don't trigger if user is typing in input/textarea if ( event.target instanceof HTMLInputElement || event.target instanceof HTMLTextAreaElement || (event.target instanceof HTMLElement && event.target.getAttribute("role") === "textbox") ) { return; } // Capture CMD+F or Ctrl+F if ((event.metaKey || event.ctrlKey) && event.key === "f") { event.preventDefault(); event.stopPropagation(); searchInputRef.current?.focus(); } }; // Use capture phase to intercept before browser default handler window.addEventListener("keydown", handleKeyDown, { capture: true }); return () => window.removeEventListener("keydown", handleKeyDown, { capture: true }); }, [isDrawerOpen]); const utils = api.useUtils(); const createCommentMutation = api.comments.create.useMutation({ onSuccess: async () => { await Promise.all([utils.comments.invalidate()]); form.reset(); // Clear pending selection after successful comment creation onSelectionUsed?.(); // Reset textarea height if (textareaRef.current) { textareaRef.current.style.height = "auto"; } // Scroll to bottom of comments list (newest comment in chronological order) if (commentsContainerRef.current) { commentsContainerRef.current.scrollTo({ top: commentsContainerRef.current.scrollHeight, behavior: "smooth", }); } }, }); // Insert mention at cursor position const insertMention = useCallback( (userId: string, displayName: string) => { if (!textareaRef.current || mentionAutocomplete.mentionStartPos === null) return; const textarea = textareaRef.current; const currentValue = form.getValues("content"); const cursorPos = textarea.selectionStart; // Replace from @ to cursor with mention const before = currentValue.substring( 0, mentionAutocomplete.mentionStartPos, ); const after = currentValue.substring(cursorPos); const mention = `@[${displayName}](${MENTION_USER_PREFIX}${userId}) `; const newText = before + mention + after; const newCursorPos = mentionAutocomplete.mentionStartPos + mention.length; // Update form value form.setValue("content", newText, { shouldDirty: true }); // Update cursor position setTimeout(() => { textarea.setSelectionRange(newCursorPos, newCursorPos); textarea.focus(); setCursorPosition(newCursorPos); }, 0); // Close dropdown mentionAutocomplete.closeDropdown(); }, [form, mentionAutocomplete], ); const deleteCommentMutation = api.comments.delete.useMutation({ onSuccess: async () => { await Promise.all([utils.comments.invalidate()]); }, }); const addReactionMutation = api.commentReactions.add.useMutation({ onSuccess: async () => { await Promise.all([utils.commentReactions.invalidate()]); }, }); const removeReactionMutation = api.commentReactions.remove.useMutation({ onSuccess: async () => { await Promise.all([utils.commentReactions.invalidate()]); }, }); const commentsWithFormattedTimestamp = useMemo(() => { return comments.data ?.map((comment) => ({ ...comment, timestamp: getRelativeTimestampFromNow(comment.createdAt), strippedLower: stripMarkdown(comment.content).toLowerCase(), authorLower: ( comment.authorUserName || comment.authorUserId || "" ).toLowerCase(), })) .sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime()); }, [comments.data]); // stripMarkdown imported from utils // Client-side filtering based on search query const filteredComments = useMemo(() => { if (!searchQuery.trim()) { return commentsWithFormattedTimestamp; } const query = searchQuery.toLowerCase(); return commentsWithFormattedTimestamp?.filter((comment) => { const contentMatch = comment.strippedLower.includes(query); const authorMatch = comment.authorLower.includes(query); return contentMatch || authorMatch; }); }, [commentsWithFormattedTimestamp, searchQuery]); if ( !hasReadAccess || (!hasWriteAccess && comments.data?.length === 0) || session.status !== "authenticated" ) return null; function onSubmit(values: z.infer) { createCommentMutation.mutateAsync({ ...values, dataField: pendingSelection?.dataField, path: pendingSelection?.path, rangeStart: pendingSelection?.rangeStart, rangeEnd: pendingSelection?.rangeEnd, }); } const handleKeyDown = (event: React.KeyboardEvent) => { // Submit on Cmd+Enter (handle first, before dropdown checks) if (event.key === "Enter" && event.metaKey) { event.preventDefault(); form.handleSubmit(onSubmit)(); return; } // Handle autocomplete navigation for mentions if (!mentionAutocomplete.showDropdown) { return; } if (mentionAutocomplete.users.length === 0) { return; } if (event.key === "ArrowDown") { event.preventDefault(); const newIndex = (mentionAutocomplete.selectedIndex + 1) % mentionAutocomplete.users.length; mentionAutocomplete.setSelectedIndex(newIndex); return; } else if (event.key === "ArrowUp") { event.preventDefault(); const newIndex = mentionAutocomplete.selectedIndex === 0 ? mentionAutocomplete.users.length - 1 : mentionAutocomplete.selectedIndex - 1; mentionAutocomplete.setSelectedIndex(newIndex); return; } else if (event.key === "Enter" || event.key === "Tab") { event.preventDefault(); const user = mentionAutocomplete.users[mentionAutocomplete.selectedIndex]; if (user) { const displayName = user.name || user.email || "User"; insertMention(user.id, displayName); } return; } else if (event.key === "Escape") { event.preventDefault(); event.stopPropagation(); // we don't want the sheet to close event.nativeEvent.stopImmediatePropagation(); // stops other event listeners mentionAutocomplete.closeDropdown(); return; } }; if (comments.isPending) return (
Loading comments...
); return (
{cardView && (
Comments ({comments.data?.length ?? 0})
)}
{!cardView && (
Comments
setSearchQuery(e.target.value)} className="h-7 pl-7 pr-7 text-xs" /> {searchQuery && ( )} {!searchQuery && ( {typeof navigator !== "undefined" && navigator.platform.toLowerCase().includes("mac") ? ( <> F ) : ( <>Ctrl+F )} )}
{searchQuery.trim() ? filteredComments && filteredComments.length > 0 ? `Showing ${filteredComments.length} of ${comments.data?.length ?? 0} comments` : "No comments match your search" : `${comments.data?.length ?? 0} comments`}
)}
{filteredComments?.map((comment) => (
{comment.authorUserName ? comment.authorUserName .split(" ") .map((word) => word[0]) .slice(0, 2) .concat("") : (comment.authorUserId ?? "U")}
{/* Name + timestamp inline */}
{comment.authorUserName ?? comment.authorUserId ?? "User"} · {comment.timestamp}
{/* Comment content with CSS overrides for markdown */} {/* Inline comment position indicator */} {"dataField" in comment && comment.dataField && "path" in comment && Array.isArray(comment.path) && comment.path.length > 0 && (
{comment.dataField.toUpperCase()} {humanizeJsonPath(comment.path[0])}
The location of the text commented on
)} {/* Reactions */}
{ if (hasReacted) { removeReactionMutation.mutate({ projectId, commentId: comment.id, emoji, }); } else { addReactionMutation.mutate({ projectId, commentId: comment.id, emoji, }); } }} /> {hasWriteAccess && ( { addReactionMutation.mutate({ projectId, commentId: comment.id, emoji, }); }} /> )}
{/* Actions - absolute positioned */} {session.data?.user?.id === comment.authorUserId && (
)}
))}
{hasWriteAccess && ( <>
New comment Markdown and @-mentions support
{/* Visually hidden header for accessibility */}
(