import { GroupedScoreBadges } from "@/src/components/grouped-score-badge";
import { ErrorPage } from "@/src/components/error-page";
import { PublishSessionSwitch } from "@/src/components/publish-object-switch";
import { StarSessionToggle } from "@/src/components/star-toggle";
import { IOPreview } from "@/src/components/trace2/components/IOPreview/IOPreview";
import { JsonSkeleton } from "@/src/components/ui/CodeJsonViewer";
import { Badge } from "@/src/components/ui/badge";
import { DetailPageNav } from "@/src/features/navigate-detail-pages/DetailPageNav";
import { useDetailPageLists } from "@/src/features/navigate-detail-pages/context";
import { api } from "@/src/utils/api";
import { usdFormatter } from "@/src/utils/numbers";
import { getNumberFromMap } from "@/src/utils/map-utils";
import Link from "next/link";
import { useRouter } from "next/router";
import React, { useEffect, useState, useCallback, useRef } from "react";
import { useVirtualizer } from "@tanstack/react-virtual";
import { AnnotateDrawer } from "@/src/features/scores/components/AnnotateDrawer";
import { Button } from "@/src/components/ui/button";
import { CommentDrawerButton } from "@/src/features/comments/CommentDrawerButton";
import { useSession } from "next-auth/react";
import { Download, ExternalLinkIcon } from "lucide-react";
import { usePostHogClientCapture } from "@/src/features/posthog-analytics/usePostHogClientCapture";
import Page from "@/src/components/layouts/page";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/src/components/ui/popover";
import { ScrollArea } from "@/src/components/ui/scroll-area";
import { Label } from "@/src/components/ui/label";
import { AnnotationQueueObjectType, type ScoreDomain } from "@langfuse/shared";
import { CreateNewAnnotationQueueItem } from "@/src/features/annotation-queues/components/CreateNewAnnotationQueueItem";
import { TablePeekView } from "@/src/components/table/peek";
import { PeekViewTraceDetail } from "@/src/components/table/peek/peek-trace-detail";
import { usePeekNavigation } from "@/src/components/table/peek/hooks/usePeekNavigation";
import { type WithStringifiedMetadata } from "@/src/utils/clientSideDomainTypes";
import { LazyTraceRow } from "@/src/components/session/TraceRow";
import { useParsedTrace } from "@/src/hooks/useParsedTrace";
import useLocalStorage from "@/src/components/useLocalStorage";
import { Switch } from "@/src/components/ui/switch";
// some projects have thousands of users in a session, paginate to avoid rendering all at once
const INITIAL_USERS_DISPLAY_COUNT = 10;
const USERS_PER_PAGE_IN_POPOVER = 50;
export function SessionUsers({
projectId,
users,
}: {
projectId: string;
users?: string[];
}) {
const [page, setPage] = useState(0);
if (!users) return null;
const initialUsers = users?.slice(0, INITIAL_USERS_DISPLAY_COUNT);
const remainingUsers = users?.slice(INITIAL_USERS_DISPLAY_COUNT);
return (
{initialUsers.map((userId: string) => (
User ID: {userId}
))}
{remainingUsers.length > 0 && (
+{remainingUsers.length} more users
Session Users
{remainingUsers
.slice(
page * USERS_PER_PAGE_IN_POPOVER,
(page + 1) * USERS_PER_PAGE_IN_POPOVER,
)
.map((userId: string) => (
User ID: {userId}
))}
{remainingUsers.length > USERS_PER_PAGE_IN_POPOVER && (
setPage((p) => Math.max(0, p - 1))}
disabled={page === 0}
>
Previous
Page {page + 1} of{" "}
{Math.ceil(remainingUsers.length / USERS_PER_PAGE_IN_POPOVER)}
setPage((p) => p + 1)}
disabled={
(page + 1) * USERS_PER_PAGE_IN_POPOVER >=
remainingUsers.length
}
>
Next
)}
)}
);
}
const SessionScores = ({
scores,
}: {
scores: WithStringifiedMetadata[];
}) => {
return (
);
};
export const SessionPage: React.FC<{
sessionId: string;
projectId: string;
}> = ({ sessionId, projectId }) => {
const router = useRouter();
const { setDetailPageList } = useDetailPageLists();
const userSession = useSession();
const capture = usePostHogClientCapture();
const utils = api.useUtils();
const parentRef = useRef(null);
const session = api.sessions.byIdWithScores.useQuery(
{
sessionId,
projectId: projectId,
},
{
retry(failureCount, error) {
if (
error.data?.code === "UNAUTHORIZED" ||
error.data?.code === "NOT_FOUND"
)
return false;
return failureCount < 3;
},
},
);
const [showCorrections, setShowCorrections] = useLocalStorage(
"showCorrections",
false,
);
const sessionComments = api.comments.getByObjectId.useQuery({
projectId,
objectId: sessionId,
objectType: "SESSION",
});
const downloadSessionAsJson = useCallback(async () => {
// Fetch fresh session and trace comments data
const [sessionCommentsData, traceCommentsData] = await Promise.all([
sessionComments.refetch(),
utils.comments.getTraceCommentsBySessionId.fetch({
projectId,
sessionId,
}),
]);
// Add comments to each trace
const sessionWithTraceComments = session.data
? {
...session.data,
traces: session.data.traces.map((trace) => ({
...trace,
comments: traceCommentsData[trace.id] ?? [],
})),
}
: session.data;
const exportData = {
...sessionWithTraceComments,
comments: sessionCommentsData.data ?? [],
};
const jsonString = JSON.stringify(exportData, null, 2);
const blob = new Blob([jsonString], {
type: "application/json; charset=utf-8",
});
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = `session-${sessionId}.json`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
capture("session_detail:download_button_click");
}, [session.data, sessionId, projectId, capture, sessionComments, utils]);
const { openPeek, closePeek, resolveDetailNavigationPath, expandPeek } =
usePeekNavigation({
expandConfig: {
// Expand peeked traces to the trace detail route; sessions list traces
basePath: `/project/${projectId}/traces`,
},
queryParams: ["observation", "display", "timestamp"],
extractParamsValuesFromRow: (row: any) => ({
timestamp: row.timestamp.toISOString(),
}),
});
useEffect(() => {
if (session.isSuccess) {
setDetailPageList(
"traces",
session.data.traces.map((t) => ({
id: t.id,
params: { timestamp: t.timestamp.toISOString() },
})),
);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [session.isSuccess, session.data]);
const sessionCommentCounts = api.comments.getCountByObjectId.useQuery(
{
projectId,
objectId: sessionId,
objectType: "SESSION",
},
{ enabled: session.isSuccess && userSession.status === "authenticated" },
);
const traceCommentCounts =
api.comments.getTraceCommentCountsBySessionId.useQuery(
{
projectId,
sessionId,
},
{ enabled: session.isSuccess && userSession.status === "authenticated" },
);
// Virtualizer measures cheap skeleton, then updates once when hydrated
const virtualizer = useVirtualizer({
count: session.data?.traces.length ?? 0,
getScrollElement: () => parentRef.current,
estimateSize: () => 300,
overscan: 1, // Render 1 item above/below viewport
getItemKey: (index) => session.data?.traces[index]?.id ?? index,
measureElement:
typeof window !== "undefined"
? (element) => element.getBoundingClientRect().height
: undefined,
});
if (session.error?.data?.code === "UNAUTHORIZED")
return ;
if (session.error?.data?.code === "NOT_FOUND")
return (
void window.location.reload(),
}}
/>
);
return (
),
actionButtonsRight: (
<>
{!router.query.peek && (
`/project/${projectId}/sessions/${encodeURIComponent(entry.id)}`
}
listKey="sessions"
/>
)}
Show corrections
>
),
}}
>
{session.data?.users?.length ? (
) : null}
Total traces: {session.data?.traces.length}
{session.data && (
Total cost: {usdFormatter(session.data.totalCost, 2)}
)}
{virtualizer.getVirtualItems().map((virtualItem) => {
const trace = session.data?.traces[virtualItem.index];
if (!trace) return null;
return (
{
// Force virtualizer to remeasure this specific item
virtualizer.measureElement(
document.querySelector(
`[data-index="${virtualItem.index}"]`,
) as HTMLElement,
);
}}
/>
);
})}
,
tableDataUpdatedAt: session.dataUpdatedAt,
}}
/>
);
};
export const SessionIO = ({
traceId,
projectId,
timestamp,
showCorrections,
}: {
traceId: string;
projectId: string;
timestamp: Date;
showCorrections: boolean;
}) => {
const trace = api.traces.byId.useQuery(
{ traceId, projectId, timestamp },
{
enabled: typeof traceId === "string",
trpc: {
context: {
skipBatch: true,
},
},
refetchOnMount: false,
},
);
// Parse trace data in Web Worker (non-blocking)
const { parsedInput, parsedOutput, isParsing } = useParsedTrace({
traceId,
input: trace.data?.input,
output: trace.data?.output,
metadata: undefined,
});
return (
{!trace.data ? (
) : trace.data.input || trace.data.output ? (
) : (
This trace has no input or output.
)}
);
};