import React, { useEffect, useMemo, useState } from "react";
import { Button } from "@/src/components/ui/button";
import {
MessageCircleMore,
MessageCircle,
X,
Archive,
Loader2,
Check,
Trash,
} from "lucide-react";
import { useFieldArray, useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/src/components/ui/form";
import {
isPresent,
type ScoreConfigDomain,
type ScoreConfigCategoryDomain,
type UpdateAnnotationScoreData,
type CreateAnnotationScoreData,
} from "@langfuse/shared";
import { Input } from "@/src/components/ui/input";
import {
Popover,
PopoverClose,
PopoverContent,
PopoverTrigger,
} from "@/src/components/ui/popover";
import { Combobox } from "@/src/components/ui/combobox";
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 {
formatAnnotateDescription,
isNumericDataType,
isScoreUnsaved,
} from "@/src/features/scores/lib/helpers";
import { ToggleGroup, ToggleGroupItem } from "@/src/components/ui/toggle-group";
import Header from "@/src/components/layouts/header";
import { usePostHogClientCapture } from "@/src/features/posthog-analytics/usePostHogClientCapture";
import { cn } from "@/src/utils/tailwind";
import {
type AnnotationScoreFormData,
type InnerAnnotationFormProps,
type ScoreTarget,
type AnnotationForm as AnnotationFormType,
} from "@/src/features/scores/types";
import { AnnotateFormSchema } from "@/src/features/scores/schema";
import { ScoreConfigDetails } from "@/src/features/score-configs/components/ScoreConfigDetails";
import {
enrichCategoryOptionsWithStaleScoreValue,
resolveConfigValue,
validateNumericScore,
} from "@/src/features/scores/lib/annotationFormHelpers";
import { useMergedAnnotationScores } from "@/src/features/scores/lib/useMergedAnnotationScores";
import { transformToAnnotationScores } from "@/src/features/scores/lib/transformScores";
import { v4 as uuid } from "uuid";
import { useScoreMutations } from "@/src/features/scores/hooks/useScoreMutations";
import { MultiSelectKeyValues } from "@/src/features/scores/components/multi-select-key-values";
import { DropdownMenuItem } from "@/src/components/ui/dropdown-menu";
import { useScoreConfigSelection } from "@/src/features/scores/hooks/useScoreConfigSelection";
import { useRouter } from "next/router";
import { useAnnotationScoreConfigs } from "@/src/features/scores/hooks/useScoreConfigs";
import { Skeleton } from "@/src/components/ui/skeleton";
const CHAR_CUTOFF = 6;
function CommentField({
savedComment,
disabled,
loading,
onSave,
}: {
savedComment: string | null;
disabled: boolean;
loading: boolean;
onSave: (comment: string | null) => void;
}) {
const [localValue, setLocalValue] = useState(savedComment || "");
// Reset local value when saved comment changes (after mutation completes)
useEffect(() => {
setLocalValue(savedComment || "");
}, [savedComment]);
const hasChanges = localValue.trim() !== (savedComment || "");
return (
Score Comment
{savedComment && (
)}
);
}
const renderSelect = (categories: ScoreConfigCategoryDomain[]) => {
const hasMoreThanThreeCategories = categories.length > 3;
const hasLongCategoryNames = categories.some(
({ label }) => label.length > CHAR_CUTOFF,
);
return (
hasMoreThanThreeCategories ||
(categories.length > 1 && hasLongCategoryNames)
);
};
function AnnotateHeader({
showSaving,
actionButtons,
description,
}: {
showSaving: boolean;
actionButtons: React.ReactNode;
description: string;
}) {
return (
{showSaving ? (
) : (
)}
{showSaving ? "Saving score data" : "Score data saved"}
,
actionButtons,
]}
/>
);
}
const isInputDisabled = (config: ScoreConfigDomain) => {
return config.isArchived;
};
function InnerAnnotationForm({
scoreTarget,
initialFormData,
scoreMetadata,
analyticsData,
actionButtons,
configControl,
}: InnerAnnotationFormProps) {
const capture = usePostHogClientCapture();
const router = useRouter();
const { configs, allowManualSelection } = configControl;
// Initialize form with initial data (never updates)
const form = useForm({
resolver: zodResolver(AnnotateFormSchema),
defaultValues: { scoreData: initialFormData },
});
const { fields, update, remove, insert } = useFieldArray({
control: form.control,
name: "scoreData",
});
// Watch form values to keep fields in sync
const watchedScoreData = form.watch("scoreData");
const controlledFields = fields.map((field, index) => {
return {
...field,
...watchedScoreData[index],
};
});
const description = formatAnnotateDescription(scoreTarget);
// Mutations - write to cache but form doesn't consume cache updates
const { createMutation, updateMutation, deleteMutation } = useScoreMutations({
scoreTarget,
scoreMetadata,
});
// Config selection
const { selectionOptions, handleSelectionChange } = useScoreConfigSelection({
configs,
controlledFields,
isInputDisabled,
insert,
remove,
});
const [showSaving, setShowSaving] = useState(false);
useEffect(() => {
const isPending =
createMutation.isPending ||
updateMutation.isPending ||
deleteMutation.isPending;
setShowSaving(isPending);
}, [
createMutation.isPending,
updateMutation.isPending,
deleteMutation.isPending,
]);
const rollbackDeleteError = (
index: number,
field: (typeof controlledFields)[number],
previousScore: {
id: string | null;
value?: number | null;
stringValue?: string | null;
comment?: string | null;
timestamp?: Date | null;
},
) => {
// Rollback field array
update(index, {
name: field.name,
dataType: field.dataType,
configId: field.configId,
...previousScore,
});
// Rollback form values directly to ensure sync
form.setValue(`scoreData.${index}.id`, previousScore.id);
form.setValue(`scoreData.${index}.value`, previousScore.value);
form.setValue(`scoreData.${index}.stringValue`, previousScore.stringValue);
form.setValue(`scoreData.${index}.comment`, previousScore.comment);
form.setValue(`scoreData.${index}.timestamp`, previousScore.timestamp);
form.setError(`scoreData.${index}.value`, {
type: "server",
message: "Failed to delete score",
});
};
const handleDeleteScore = (index: number) => {
const field = controlledFields[index];
// Capture previous state for rollback
const previousScore = {
id: field.id,
value: field.value,
stringValue: field.stringValue,
comment: field.comment,
timestamp: field.timestamp,
};
// Optimistically clear form
form.clearErrors(`scoreData.${index}.value`);
update(index, {
name: field.name,
dataType: field.dataType,
configId: field.configId,
id: null,
value: null,
stringValue: null,
comment: null,
});
// Fire mutation with rollback
if (previousScore.id) {
deleteMutation.mutate(
{
id: previousScore.id,
projectId: scoreMetadata.projectId,
},
{
onError: () => rollbackDeleteError(index, field, previousScore),
},
);
}
// Capture delete event
capture("score:delete", analyticsData);
};
const rollbackUpdateError = (
index: number,
previousValue?: number | null,
previousStringValue?: string | null,
) => {
form.setValue(`scoreData.${index}.value`, previousValue);
form.setValue(`scoreData.${index}.stringValue`, previousStringValue);
form.setError(`scoreData.${index}.value`, {
type: "server",
message: "Failed to update score",
});
};
const rollbackCreateError = (
index: number,
previousValue?: number | null,
previousStringValue?: string | null,
previousId?: string | null,
previousTimestamp?: Date | null,
) => {
form.setValue(`scoreData.${index}.id`, previousId);
form.setValue(`scoreData.${index}.timestamp`, previousTimestamp);
form.setValue(`scoreData.${index}.value`, previousValue);
form.setValue(`scoreData.${index}.stringValue`, previousStringValue);
form.setError(`scoreData.${index}.value`, {
type: "server",
message: "Failed to create score",
});
};
const handleUpsert = (
index: number,
value: number | null,
stringValue: string | null,
) => {
const field = controlledFields[index];
if (!field) return;
// Capture previous form state for rollback
const previousValue = field.value;
const previousStringValue = field.stringValue;
const previousId = field.id;
const previousTimestamp = field.timestamp;
// Clear errors and update form optimistically
form.clearErrors(`scoreData.${index}.value`);
form.setValue(`scoreData.${index}.value`, value);
form.setValue(`scoreData.${index}.stringValue`, stringValue);
// Fire mutation
const {
id: scoreId,
timestamp: scoreTimestamp,
...fieldWithoutIdAndTimestamp
} = field;
const baseScoreData = {
...fieldWithoutIdAndTimestamp,
...scoreMetadata,
value,
stringValue,
scoreTarget,
};
if (scoreId) {
updateMutation.mutate(
{
...baseScoreData,
id: scoreId,
timestamp: scoreTimestamp ?? undefined,
} as UpdateAnnotationScoreData,
{
onError: () =>
rollbackUpdateError(index, previousValue, previousStringValue),
},
);
} else {
const id = uuid();
const timestamp = new Date();
form.setValue(`scoreData.${index}.id`, id);
form.setValue(`scoreData.${index}.timestamp`, timestamp);
createMutation.mutate(
{
...baseScoreData,
id,
timestamp,
} as CreateAnnotationScoreData,
{
onError: () =>
rollbackCreateError(
index,
previousValue,
previousStringValue,
previousId,
previousTimestamp,
),
},
);
}
};
const handleNumericUpsert = (index: number) => {
const field = controlledFields[index];
const config = configs.find((c) => c.id === field.configId);
if (!config || !field) return;
if (field.value === null || field.value === undefined) {
return; // Don't create/update score with empty value
}
// Client-side validation - don't fire mutation if invalid
const errorMessage = validateNumericScore({
value: field.value,
maxValue: config.maxValue,
minValue: config.minValue,
});
if (!!errorMessage) {
form.setError(`scoreData.${index}.value`, {
type: "custom",
message: errorMessage,
});
return;
}
form.clearErrors(`scoreData.${index}.value`);
handleUpsert(index, field.value as number, null);
};
const handleCategoricalUpsert = (index: number, stringValue: string) => {
const field = controlledFields[index];
const config = configs.find((c) => c.id === field.configId);
if (!config || !field) return;
const numericCategoryValue = config.categories?.find(
({ label }) => label === stringValue,
)?.value;
if (!isPresent(numericCategoryValue)) return;
handleUpsert(index, numericCategoryValue, stringValue);
};
const rollbackCommentError = (
index: number,
field: (typeof controlledFields)[number],
previousComment?: string | null,
) => {
update(index, {
...field,
comment: previousComment,
});
form.setError(`scoreData.${index}.comment`, {
type: "server",
message: "Failed to update comment",
});
};
const handleCommentUpdate = (index: number, newComment: string | null) => {
const field = controlledFields[index];
if (!field || !field.id) return;
const previousComment = field.comment;
// Optimistically update form
update(index, {
...field,
comment: newComment,
});
// Fire mutation
updateMutation.mutate(
{
...field,
...scoreMetadata,
scoreTarget,
comment: newComment,
} as UpdateAnnotationScoreData,
{
onError: () => rollbackCommentError(index, field, previousComment),
},
);
};
return (
{allowManualSelection ? (
!!field.configId)
.map((field) => ({
key: field.configId as string,
value: resolveConfigValue({
dataType: field.dataType,
name: field.name,
}),
}))}
controlButtons={
{
capture(
"score_configs:manage_configs_item_click",
analyticsData,
);
router.push(
`/project/${scoreMetadata.projectId}/settings/scores`,
);
}}
>
Manage score configs
}
/>
) : null}
);
}
export function AnnotationForm({
scoreTarget,
serverScores,
scoreMetadata,
analyticsData,
actionButtons,
configSelection = { mode: "selectable" },
}: AnnotationFormType) {
const { projectId } = scoreMetadata;
const { isLoading, availableConfigs, selectedConfigIds } =
useAnnotationScoreConfigs({
projectId,
configSelection,
});
// Step 1: Transform server scores to annotation scores
const serverAnnotationScores = useMemo(() => {
if (Array.isArray(serverScores)) {
// Flat scores from trace/session detail
return transformToAnnotationScores(serverScores, availableConfigs);
} else {
// Aggregates from compare view
return transformToAnnotationScores(
serverScores,
availableConfigs,
scoreTarget.type === "trace" ? scoreTarget.traceId : "",
scoreTarget.type === "trace" ? scoreTarget.observationId : undefined,
);
}
}, [serverScores, availableConfigs, scoreTarget]);
// Step 2: Merge with cache
const annotationScores = useMergedAnnotationScores(
serverAnnotationScores,
scoreTarget,
);
const initialFormData: AnnotationScoreFormData[] = [];
const configIds = new Set();
annotationScores.forEach((score) => {
configIds.add(score.configId);
initialFormData.push({
id: score.id,
configId: score.configId,
name: score.name,
dataType: score.dataType,
value: score.value,
stringValue: score.stringValue,
comment: score.comment,
timestamp: score.timestamp,
});
});
selectedConfigIds.forEach((configId) => {
if (!configIds.has(configId)) {
const config = availableConfigs.find((c) => c.id === configId);
if (!config) return;
initialFormData.push({
id: null,
configId,
name: config.name,
dataType: config.dataType,
value: null,
stringValue: null,
comment: null,
timestamp: null,
});
}
});
const sortedInitialFormData = initialFormData.sort((a, b) =>
a.name.localeCompare(b.name),
);
return isLoading ? (
) : (
);
}