import { Card } from "@/src/components/ui/card"; import { Skeleton } from "@/src/components/ui/skeleton"; import { useHasProjectAccess } from "@/src/features/rbac/utils/checkProjectAccess"; import { api } from "@/src/utils/api"; import { type RouterOutput } from "@/src/utils/types"; import { AnnotationQueueStatus, AnnotationQueueObjectType, } from "@langfuse/shared"; import { ArrowLeft, ArrowRight, SearchXIcon } from "lucide-react"; import { useRouter } from "next/router"; import { useEffect, useMemo, useState } from "react"; import { Button } from "@/src/components/ui/button"; import { useAnnotationQueueData } from "./shared/hooks/useAnnotationQueueData"; import { useAnnotationObjectData } from "./shared/hooks/useAnnotationObjectData"; import { TraceAnnotationProcessor } from "./processors/TraceAnnotationProcessor"; import { SessionAnnotationProcessor } from "./processors/SessionAnnotationProcessor"; import { ObjectNotFoundCard } from "@/src/components/ui/object-not-found-card"; export const AnnotationQueueItemPage: React.FC<{ annotationQueueId: string; projectId: string; view: "showTree" | "hideTree"; queryItemId?: string; }> = ({ annotationQueueId, projectId, view, queryItemId }) => { const router = useRouter(); const isSingleItem = router.query.singleItem === "true"; const [nextItemData, setNextItemData] = useState< RouterOutput["annotationQueues"]["fetchAndLockNext"] | null >(null); const [seenItemIds, setSeenItemIds] = useState([]); const [progressIndex, setProgressIndex] = useState(0); const hasAccess = useHasProjectAccess({ projectId, scope: "annotationQueues:CUD", }); const itemId = isSingleItem ? queryItemId : seenItemIds[progressIndex]; const seenItemData = api.annotationQueueItems.byId.useQuery( { projectId, itemId: itemId as string }, { enabled: !!itemId, refetchOnMount: false }, ); const fetchAndLockNextMutation = api.annotationQueues.fetchAndLockNext.useMutation(); // Effects useEffect(() => { async function fetchNextItem() { if (!itemId && !isSingleItem) { const nextItem = await fetchAndLockNextMutation.mutateAsync({ queueId: annotationQueueId, projectId, seenItemIds, }); setNextItemData(nextItem); } } fetchNextItem(); // eslint-disable-next-line react-hooks/exhaustive-deps }, []); const { configs } = useAnnotationQueueData({ annotationQueueId, projectId }); const unseenPendingItemCount = api.annotationQueueItems.unseenPendingItemCountByQueueId.useQuery( { queueId: annotationQueueId, projectId, seenItemIds, }, { refetchOnWindowFocus: false }, ); const utils = api.useUtils(); const completeMutation = api.annotationQueueItems.complete.useMutation({ onSuccess: async () => { utils.annotationQueueItems.invalidate(); if (isSingleItem) { return; } if (progressIndex >= seenItemIds.length - 1) { const nextItem = await fetchAndLockNextMutation.mutateAsync({ queueId: annotationQueueId, projectId, seenItemIds, }); setNextItemData(nextItem); } if (progressIndex + 1 < totalItems) { setProgressIndex(Math.max(progressIndex + 1, 0)); } }, }); const totalItems = useMemo(() => { return seenItemIds.length + (unseenPendingItemCount.data ?? 0); }, [unseenPendingItemCount.data, seenItemIds.length]); const relevantItem = useMemo(() => { if (isSingleItem) return seenItemData.data; else return progressIndex < seenItemIds.length ? seenItemData.data : nextItemData; }, [ progressIndex, seenItemIds.length, seenItemData.data, nextItemData, isSingleItem, ]); const objectData = useAnnotationObjectData(relevantItem ?? null, projectId); useEffect(() => { if (relevantItem && router.query.itemId !== relevantItem.id) { router.push( { pathname: `/project/${projectId}/annotation-queues/${annotationQueueId}/items/${relevantItem.id}`, }, undefined, ); } }, [relevantItem, router, projectId, annotationQueueId]); useEffect(() => { if ( relevantItem && !seenItemIds.includes(relevantItem.id) && !isSingleItem ) { setSeenItemIds((prev) => [...prev, relevantItem.id]); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [relevantItem]); if ( (seenItemData.isPending && itemId) || (fetchAndLockNextMutation.isPending && !itemId) || unseenPendingItemCount.isPending || objectData.isLoading ) { return ; } if (!relevantItem && !(itemId && seenItemIds.includes(itemId))) { return
No more items left to annotate!
; } const isNextItemAvailable = totalItems > progressIndex + 1; const handleNavigateBack = () => { setProgressIndex(progressIndex - 1); }; const handleNavigateNext = async () => { if (progressIndex >= seenItemIds.length - 1) { const nextItem = await fetchAndLockNextMutation.mutateAsync({ queueId: annotationQueueId, projectId, seenItemIds, }); setNextItemData(nextItem); } setProgressIndex(Math.max(progressIndex + 1, 0)); }; const handleComplete = async () => { if (!relevantItem) return; await completeMutation.mutateAsync({ itemId: relevantItem.id, projectId, }); }; const renderContent = () => { // Handle deleted object (trace/observation/session not found) if (objectData.isError && objectData.errorCode === "NOT_FOUND") { return ( ); } // Handle deleted queue item if (!relevantItem) { return ( Item has been deleted from annotation queue. Previously added scores and underlying reference trace are unaffected by this action. ); } switch (relevantItem.objectType) { case AnnotationQueueObjectType.TRACE: case AnnotationQueueObjectType.OBSERVATION: return ( ); case AnnotationQueueObjectType.SESSION: return ( ); default: throw new Error(`Unsupported object type: ${relevantItem.objectType}`); } }; return (
{renderContent()}
{!isSingleItem && (
{progressIndex + 1} / {totalItems}
)}
{!isSingleItem && ( )} {!!relevantItem && (relevantItem.status === AnnotationQueueStatus.PENDING ? ( ) : (
Completed
))}
); };