import React, { useEffect, useRef } from "react"; import { Button } from "@/src/components/ui/button"; import { MessageCircleMore, MessageCircle, X, Archive, Loader2, Check, } from "lucide-react"; import { type ControllerRenderProps, useFieldArray, useForm, type ErrorOption, } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage, } from "@/src/components/ui/form"; import { DrawerHeader, DrawerTitle } from "@/src/components/ui/drawer"; import { type APIScoreV2, isPresent, CreateAnnotationScoreData, UpdateAnnotationScoreData, type ValidatedScoreConfig, type ConfigCategory, } from "@langfuse/shared"; import { Input } from "@/src/components/ui/input"; import { Popover, PopoverClose, PopoverContent, PopoverTrigger, } from "@/src/components/ui/popover"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@/src/components/ui/select"; import { Textarea } from "@/src/components/ui/textarea"; import { HoverCardContent } from "@radix-ui/react-hover-card"; import { HoverCard, HoverCardTrigger } from "@/src/components/ui/hover-card"; import { ScoreConfigDetails } from "@/src/features/scores/components/ScoreConfigDetails"; import { formatAnnotateDescription, isNumericDataType, isScoreUnsaved, } from "@/src/features/scores/lib/helpers"; import { getDefaultAnnotationScoreData } from "@/src/features/scores/lib/getDefaultScoreData"; import { ToggleGroup, ToggleGroupItem } from "@/src/components/ui/toggle-group"; import Header from "@/src/components/layouts/header"; import { MultiSelectKeyValues } from "@/src/features/scores/components/multi-select-key-values"; import { useRouter } from "next/router"; import { usePostHogClientCapture } from "@/src/features/posthog-analytics/usePostHogClientCapture"; import { cn } from "@/src/utils/tailwind"; import { getScoreDataTypeIcon } from "@/src/features/scores/lib/scoreColumns"; import { DropdownMenuItem } from "@/src/components/ui/dropdown-menu"; import { type ScoreTarget, type AnnotateDrawerProps, type AnnotateFormSchemaType, type AnnotationScoreSchemaType, } from "@/src/features/scores/types"; import { useScoreValues } from "@/src/features/scores/hooks/useScoreValues"; import { useScoreMutations } from "@/src/features/scores/hooks/useScoreMutations"; import { AnnotateFormSchema } from "@/src/features/scores/schema"; const CHAR_CUTOFF = 6; const renderSelect = (categories: ConfigCategory[]) => { const hasMoreThanThreeCategories = categories.length > 3; const hasLongCategoryNames = categories.some( ({ label }) => label.length > CHAR_CUTOFF, ); return ( hasMoreThanThreeCategories || (categories.length > 1 && hasLongCategoryNames) ); }; const getFormError = ({ value, minValue, maxValue, }: { value?: number | null; minValue?: number | null; maxValue?: number | null; }): ErrorOption | null => { if ( (isPresent(maxValue) && Number(value) > maxValue) || (isPresent(minValue) && Number(value) < minValue) ) { return { type: "custom", message: `Not in range: [${minValue ?? "-∞"},${maxValue ?? "∞"}]`, }; } return null; }; function handleOnKeyDown(e: React.KeyboardEvent) { if (e.key === "Enter") { e.preventDefault(); const form = e.currentTarget.form; if (!form) return; const currentTabIndex = e.currentTarget.tabIndex; const nextElement = form.querySelector( `[tabindex="${currentTabIndex + 1}"]`, ); if (nextElement instanceof HTMLElement) { nextElement.focus(); } else { e.currentTarget.blur(); } } } function AnnotateHeader({ showSaving, actionButtons, description, }: { showSaving: boolean; actionButtons: React.ReactNode; description: string; }) { return (
{showSaving ? ( ) : ( )}
{showSaving ? "Saving score data" : "Score data saved"} , actionButtons, ]} /> ); } type AnnotateDrawerContentProps = AnnotateDrawerProps & { configs: ValidatedScoreConfig[]; isDrawerOpen: boolean; showSaving: boolean; setShowSaving: (showSaving: boolean) => void; isSelectHidden?: boolean; queueId?: string; actionButtons?: React.ReactNode; }; export function AnnotateDrawerContent({ scoreTarget, scores, configs, analyticsData, emptySelectedConfigIds, setEmptySelectedConfigIds, projectId, showSaving, setShowSaving, isDrawerOpen = true, isSelectHidden = false, queueId, actionButtons, environment, }: AnnotateDrawerContentProps) { const capture = usePostHogClientCapture(); const router = useRouter(); const form = useForm({ resolver: zodResolver(AnnotateFormSchema), defaultValues: { scoreData: getDefaultAnnotationScoreData({ scores, emptySelectedConfigIds, configs, scoreTarget, }), }, }); const { fields, remove, update, replace } = useFieldArray({ control: form.control, name: "scoreData", }); const prevEmptySelectedConfigIdsRef = useRef(emptySelectedConfigIds); const { optimisticScores, setOptimisticScore } = useScoreValues({ getValues: form.getValues, }); const { createMutation, updateMutation, deleteMutation } = useScoreMutations( scoreTarget, projectId, fields, update, remove, configs, isDrawerOpen, setShowSaving, ); useEffect(() => { // Only reset the form if emptySelectedConfigIds has changed, compare by value not reference if ( prevEmptySelectedConfigIdsRef.current.length !== emptySelectedConfigIds.length || !prevEmptySelectedConfigIdsRef.current.every( (id, index) => id === emptySelectedConfigIds[index], ) ) { form.reset({ scoreData: getDefaultAnnotationScoreData({ scores, emptySelectedConfigIds, configs, scoreTarget, }), }); } prevEmptySelectedConfigIdsRef.current = emptySelectedConfigIds; }, [emptySelectedConfigIds, scores, configs, scoreTarget, form]); const pendingCreates = useRef(new Map>()); const pendingDeletes = useRef(new Set()); // Track when deletion was initiated for each score ID const deletionTimestamps = useRef(new Map()); const description = formatAnnotateDescription(scoreTarget); async function handleScoreChange( score: AnnotationScoreSchemaType, index: number, value: number, stringValue: string | null, ) { // Check if this score is currently being deleted if (score.scoreId && pendingDeletes.current.has(score.scoreId)) { // Skip updates for scores that are being deleted return; } // Check if there was a recent deletion request for this score if (score.scoreId && deletionTimestamps.current.has(score.scoreId)) { const deleteTime = deletionTimestamps.current.get(score.scoreId) || 0; const now = Date.now(); // If deletion was requested in the last 5 seconds, ignore updates if (now - deleteTime < 5000) { return; } // Otherwise clear the old timestamp deletionTimestamps.current.delete(score.scoreId); } // Optimistically update the UI setOptimisticScore({ index, value, stringValue, }); try { // If we have an ID, straightforward update if (!!score.scoreId) { const validatedScore = UpdateAnnotationScoreData.parse({ id: score.scoreId, projectId, scoreTarget, name: score.name, dataType: score.dataType, configId: score.configId, stringValue: stringValue ?? score.stringValue, comment: score.comment, value, queueId, environment, }); await updateMutation.mutateAsync({ ...validatedScore, }); capture("score:update", { ...analyticsData, dataType: score.dataType, }); } else { const pendingCreate = pendingCreates.current.get(index); if (pendingCreate) { // Wait for the pending create to complete to get the ID const createdScore = await pendingCreate; const validatedScore = UpdateAnnotationScoreData.parse({ id: createdScore.id, projectId, scoreTarget, name: score.name, dataType: score.dataType, configId: score.configId, stringValue: stringValue ?? score.stringValue, comment: score.comment, value, queueId, environment, }); await updateMutation.mutateAsync({ ...validatedScore, }); capture("score:update", { ...analyticsData, dataType: score.dataType, }); } else { // If no pending create, straightforward create const validatedScore = CreateAnnotationScoreData.parse({ projectId, scoreTarget, name: score.name, dataType: score.dataType, configId: score.configId, stringValue: stringValue ?? score.stringValue, comment: score.comment, value, queueId, environment, }); const createPromise = createMutation.mutateAsync({ ...validatedScore, }); capture("score:create", { ...analyticsData, dataType: score.dataType, }); pendingCreates.current.set(index, createPromise); // Wait for creation and cleanup const createdScore = await createPromise; pendingCreates.current.delete(index); // Update the form with the new ID update(index, { ...score, scoreId: createdScore.id, value: createdScore.value, stringValue: createdScore.stringValue ?? undefined, }); } } } catch (error) { // Handle error and revert optimistic update console.error(error); setOptimisticScore({ index, value: score.value ?? null, stringValue: score.stringValue ?? null, }); } } function handleOnBlur({ config, field, index, score, }: { config: ValidatedScoreConfig; field: ControllerRenderProps< AnnotateFormSchemaType, `scoreData.${number}.value` >; index: number; score: AnnotationScoreSchemaType; }): React.FocusEventHandler | undefined { return async () => { const { maxValue, minValue, dataType } = config; if (isNumericDataType(dataType)) { const formError = getFormError({ value: field.value, maxValue, minValue, }); if (!!formError) { form.setError(`scoreData.${index}.value`, formError); return; } } form.clearErrors(`scoreData.${index}.value`); if (isPresent(field.value)) { await handleScoreChange(score, index, Number(field.value), null); } }; } useEffect(() => { if ( updateMutation.isPending || createMutation.isPending || deleteMutation.isPending ) { setShowSaving(true); } else { setShowSaving(false); } }, [ updateMutation.isPending, createMutation.isPending, deleteMutation.isPending, setShowSaving, ]); function handleOnCheckedChange( values: Record[], changedValueId?: string, ) { if (values.length === 0) { const populatedScoreFields = fields.filter(({ scoreId }) => !!scoreId); replace(populatedScoreFields); setEmptySelectedConfigIds( populatedScoreFields .filter(({ configId }) => !!configId) .map(({ configId }) => configId as string), ); return; } if (!changedValueId) return; const configToChange = configs.find(({ id }) => id === changedValueId); if (!configToChange) return; const { id, name, dataType } = configToChange; const index = fields.findIndex(({ configId }) => configId === id); if (index === -1) { setOptimisticScore({ index: fields.length, value: null, stringValue: null, scoreId: null, name, dataType, configId: id, }); replace([ ...fields, { name, dataType, configId: id, }, ]); setEmptySelectedConfigIds([...emptySelectedConfigIds, changedValueId]); } else { remove(index); setEmptySelectedConfigIds( emptySelectedConfigIds.filter((id) => id !== changedValueId), ); } } function handleOnValueChange( score: AnnotationScoreSchemaType, index: number, configCategories: ConfigCategory[], ): ((value: string) => void) | undefined { return async (stringValue) => { const selectedCategory = configCategories.find( ({ label }) => label === stringValue, ); if (selectedCategory) { const newValue = Number(selectedCategory.value); await handleScoreChange(score, index, newValue, stringValue); form.setValue(`scoreData.${index}.value`, newValue, { shouldValidate: true, }); } }; } function handleCommentUpdate({ field, score, comment, }: { field: ControllerRenderProps< AnnotateFormSchemaType, `scoreData.${number}.comment` >; score: AnnotationScoreSchemaType; comment?: string | null; }): React.MouseEventHandler | undefined { return async () => { const { value, scoreId } = score; if (!!field.value && !!scoreId && isPresent(value)) { const validatedScore = UpdateAnnotationScoreData.parse({ id: scoreId, projectId, scoreTarget, name: score.name, dataType: score.dataType, configId: score.configId, stringValue: score.stringValue, value, comment, queueId, environment, }); await updateMutation.mutateAsync({ ...validatedScore, }); capture( comment ? "score:update_comment" : "score:delete_comment", analyticsData, ); } }; } return (
{isSelectHidden ? ( ) : ( )} {!isSelectHidden && (
!config.isArchived || fields.find((field) => field.configId === config.id), ) .map((config) => ({ key: config.id, value: `${getScoreDataTypeIcon(config.dataType)} ${config.name}`, disabled: fields.some( (field) => !!field.scoreId && field.configId === config.id, ) || optimisticScores.some( (score) => score.configId === config.id && !!score.value, ) || deleteMutation.isPending, isArchived: config.isArchived, }))} values={fields .filter((field) => !!field.configId) .map((field) => ({ value: `${getScoreDataTypeIcon(field.dataType)} ${field.name}`, key: field.configId as string, }))} controlButtons={ { capture( "score_configs:manage_configs_item_click", analyticsData, ); router.push(`/project/${projectId}/settings/scores`); }} > Manage score configs } />
)}
( <> {fields.map((score, index) => { const config = configs.find( (config) => config.id === score.configId, ); if (!config) return null; const categories = (config.categories as ConfigCategory[]) ?? []; return (
{config.description || isPresent(config.maxValue) || isPresent(config.minValue) ? ( {score.name} ) : ( {score.name} )} ( Comment (optional) {!!field.value && field.value !== score.comment && ( Draft {!!score.comment && (

Saved comment:{" "} {score.comment}

)}
)} <>