import { type TreeNode } from "./lib/types"; import { cn } from "@/src/utils/tailwind"; import { type APIScoreV2, ObservationLevel, type ObservationLevelType, } from "@langfuse/shared"; import { Fragment, useMemo, useRef, useEffect } from "react"; import { InfoIcon, ChevronRight } from "lucide-react"; import { Button } from "@/src/components/ui/button"; import { ItemBadge } from "@/src/components/ItemBadge"; import { usePostHogClientCapture } from "@/src/features/posthog-analytics/usePostHogClientCapture"; import { calculateDisplayTotalCost, unnestObservation, } from "@/src/components/trace/lib/helpers"; import type Decimal from "decimal.js"; import { SpanItem } from "@/src/components/trace/SpanItem"; export const TraceTree = ({ tree, collapsedNodes, toggleCollapsedNode, scores, currentNodeId, setCurrentNodeId, showMetrics, showScores, colorCodeMetrics, nodeCommentCounts, className, showComments, hiddenObservationsCount, minLevel, setMinLevel, }: { tree: TreeNode; collapsedNodes: string[]; toggleCollapsedNode: (id: string) => void; scores: APIScoreV2[]; currentNodeId: string | undefined; setCurrentNodeId: (id: string | undefined) => void; showMetrics: boolean; showScores: boolean; colorCodeMetrics: boolean; nodeCommentCounts?: Map; className?: string; showComments: boolean; hiddenObservationsCount?: number; minLevel?: ObservationLevelType; setMinLevel?: React.Dispatch>; }) => { const totalCost = useMemo(() => { // For unified tree, we need to calculate total cost differently // Convert TreeNode back to observation format for cost calculation const convertTreeNodeToObservation = (node: TreeNode): any => ({ ...node, children: node.children.map(convertTreeNodeToObservation), }); if (tree.type === "TRACE") { // For trace root, calculate from all children const allObservations = tree.children.flatMap((child) => unnestObservation(convertTreeNodeToObservation(child)), ); return calculateDisplayTotalCost({ allObservations }); } return calculateDisplayTotalCost({ allObservations: [convertTreeNodeToObservation(tree)], }); }, [tree]); return (
{minLevel && hiddenObservationsCount && hiddenObservationsCount > 0 ? (

{hiddenObservationsCount}{" "} {hiddenObservationsCount === 1 ? "observation" : "observations"}{" "} below {minLevel} level are hidden.{" "} setMinLevel?.(ObservationLevel.DEBUG)} > Show all

) : null}
); }; type TreeNodeComponentProps = { node: TreeNode; collapsedNodes: string[]; toggleCollapsedNode: (id: string) => void; scores: APIScoreV2[]; comments?: Map; indentationLevel: number; currentNodeId: string | undefined; setCurrentNodeId: (id: string | undefined) => void; showMetrics: boolean; showScores: boolean; colorCodeMetrics: boolean; parentTotalCost?: Decimal; parentTotalDuration?: number; showComments: boolean; treeLines: boolean[]; // Track which levels need vertical lines isLastSibling: boolean; }; const UnmemoizedTreeNodeComponent = ({ node, collapsedNodes, toggleCollapsedNode, scores, comments, indentationLevel, currentNodeId, setCurrentNodeId, showMetrics, showScores, colorCodeMetrics, parentTotalCost, parentTotalDuration, showComments, treeLines, isLastSibling, }: TreeNodeComponentProps) => { const capture = usePostHogClientCapture(); const collapsed = collapsedNodes.includes(node.id); // Convert TreeNode back to observation format for cost calculation (only for root parent totals outside) const currentNodeRef = useRef(null); useEffect(() => { if (currentNodeId && currentNodeRef.current && currentNodeId === node.id) { currentNodeRef.current.scrollIntoView({ behavior: "smooth", block: "center", }); } }, [currentNodeId, node.id]); const isSelected = currentNodeId === node.id || (!currentNodeId && node.type === "TRACE"); return (
{ // Only handle clicks that aren't on the expand/collapse button if (!e.currentTarget?.closest("[data-expand-button]")) { setCurrentNodeId(node.type === "TRACE" ? undefined : node.id); } }} >
{/* 1. Indents: ancestor level indicators */} {indentationLevel > 0 && (
{Array.from({ length: indentationLevel - 1 }, (_, i) => (
{treeLines[i] && (
)}
))}
)} {/* 2. Current element bars: up/down/horizontal connectors */} {indentationLevel > 0 && (
<> {/* Vertical bar connecting upwards */}
{/* Vertical bar connecting downwards if not last sibling */} {!isLastSibling && (
)} {/* Horizontal bar connecting to icon */}
)} {/* 3. Icon + child connector: fixed width container */}
{/* Vertical bar downwards if there are expanded children */} {node.children.length > 0 && !collapsed && (
)} {/* Root node downward connector */} {indentationLevel === 0 && node.children.length > 0 && !collapsed && (
)}
{/* 4. Content button: just the text/metrics content */} {/* eslint-disable-next-line jsx-a11y/role-supports-aria-props */} {/* 5. Expand/Collapse button */} {node.children.length > 0 && (
)}
{/* Render children */} {node.children.length > 0 && (
{node.children .sort((a, b) => a.startTime.getTime() - b.startTime.getTime()) .map((childNode, index) => { const isChildLastSibling = index === node.children.length - 1; // Add to treeLines: whether there are more children after this one (determines if vertical line should continue) const childTreeLines = [...treeLines, !isChildLastSibling]; return ( ); })}
)} ); }; const TreeNodeComponent = UnmemoizedTreeNodeComponent;