/** * Trace browsing page: the left-side directory lists Sessions grouped only by * Agent (titles come from the Sessions context, with unmanaged CLI/subagent * Sessions falling back to sessionId); the right side shows the selected * Session's Trace files (paged, most recent first by default) + performance * analysis (an execution timeline) + an event timeline. */ import { useEffect, useRef, useState } from "react"; import { useSearchParams } from "react-router"; import type { AgentTracesResponse } from "@prismshadow/penguin-server/api"; import * as api from "../../api/endpoints"; import { ApiError } from "../../api/client"; import { S } from "../../lib/strings"; import { useDocumentTitle } from "../../lib/use-document-title"; import { formatBytes } from "../../lib/format"; import { agentDisplayName, useProject } from "../../state/project"; import { AgentAvatar } from "../../components/ui/agent-avatar"; import { Chevron } from "../../components/ui/chevron"; import { Truncated } from "../../components/ui/truncated"; import { useSessions } from "../../state/sessions"; import { EmptyState } from "../../components/ui/empty-state"; import { SkeletonList } from "../../components/ui/skeleton"; import { TraceFileView } from "./trace-file-view"; import type { TraceHighlight } from "./timeline-chart"; interface TraceFileRef { index: number; date: string; sizeBytes: number; } interface SessionGroup { sessionId: string; files: TraceFileRef[]; } interface Selection { /** Details go through the Agent-level endpoint (not dependent on the sessions table's tracking), so the owning Agent must be carried along. */ agentId: string; sessionId: string; files: TraceFileRef[]; } /** Flatten by Session (merging a Session's files across dates); Sessions are sorted by id descending = newest first. */ function flattenSessions(data: AgentTracesResponse): SessionGroup[] { const bySession = new Map(); for (const d of data.dates) { for (const s of d.sessions) { const list = bySession.get(s.sessionId) ?? []; for (const f of s.files) list.push({ index: f.index, date: d.date, sizeBytes: f.sizeBytes }); bySession.set(s.sessionId, list); } } return [...bySession.entries()] .sort((a, b) => b[0].localeCompare(a[0])) .map(([sessionId, files]) => ({ sessionId, // Files sorted newest first by default (a higher index is newer). files: files.sort((a, b) => b.index - a.index), })); } /** A single Agent's expandable tree node (traces are fetched only when expanded; titles are mapped via the Sessions context). */ function AgentNode({ projectId, agentId, name, defaultOpen, focusSessionId, titleOf, selection, onSelect, }: { projectId: string; agentId: string; name: string; /** Initial expanded state: all expanded when there's no deep link; only the target Agent expanded when there's an ?agentId= deep link. */ defaultOpen: boolean; /** ?sessionId= deep link (jumped to directly from the evaluation center's runs): auto-selects that Session once the list is ready (only once). */ focusSessionId?: string; titleOf: (agentId: string, sessionId: string) => string | undefined; selection: Selection | null; onSelect: (sel: Selection) => void; }) { const [open, setOpen] = useState(defaultOpen); const [groups, setGroups] = useState(null); const [error, setError] = useState(null); useEffect(() => { if (!open || groups) return; api .getAgentTraces(projectId, agentId) .then((data) => setGroups(flattenSessions(data))) .catch((e: unknown) => setError(e instanceof ApiError ? e.message : S.common.unknownError)); }, [open, groups, projectId, agentId]); // The Session deep link is applied only once: it selects the target as // soon as the list is first ready (if not found, it just stays in the // list state without erroring); after that, the user's manual switches // are never pulled back by the deep-link parameter. const focusApplied = useRef(false); useEffect(() => { if (focusApplied.current || !focusSessionId || !groups) return; focusApplied.current = true; const target = groups.find((g) => g.sessionId === focusSessionId); if (target) onSelect({ agentId, sessionId: target.sessionId, files: target.files }); }, [groups, focusSessionId, agentId, onSelect]); // The group header and Session row styling matches the sidebar // (components/layout/sidebar.tsx): the same information appearing in two // places with a different shape would make it look like two different things. return (
  • {open && (
    {error &&

    {error}

    } {!groups && !error && (

    {S.common.loading}

    )} {groups && groups.length === 0 && (

    {S.traces.empty}

    )}
      {groups?.map((g) => { const active = selection?.agentId === agentId && selection.sessionId === g.sessionId; const title = titleOf(agentId, g.sessionId); return (
    • ); })}
    )}
  • ); } export function TracesPage() { useDocumentTitle(S.traces.title); const { currentProject, agents, agentsLoading } = useProject(); const { byAgent } = useSessions(); const projectId = currentProject?.projectId ?? null; // ?agentId= deep link (from the Agents page's "traces" entry point): only // the target Agent defaults to expanded, the rest collapse to keep focus on it. const [searchParams] = useSearchParams(); const focusAgentId = searchParams.get("agentId"); // ?sessionId= deep link (jumped to directly from the evaluation center's // runs): auto-selects once the target Agent's Session list is ready. const focusSessionId = searchParams.get("sessionId"); const [selection, setSelection] = useState(null); const [fileIndex, setFileIndex] = useState(null); // Linked highlighting between the trace observation view and the event list (keyed by tool_call_id). const [highlight, setHighlight] = useState(null); // Clear the selection when switching Project. useEffect(() => { setSelection(null); setFileIndex(null); }, [projectId]); // Clear the linked highlight when switching Session / Trace file. useEffect(() => { setHighlight(null); }, [selection, fileIndex]); const titleOf = (agentId: string, sessionId: string): string | undefined => byAgent.get(agentId)?.find((s) => s.sessionId === sessionId)?.title; if (!projectId) return null; const activeFile = selection === null ? null : (selection.files.find((f) => f.index === fileIndex) ?? selection.files[0] ?? null); return (
    {/* Directory tree: Agent → Session title (≥md left column;

    {S.traces.title}

    {agentsLoading ? ( ) : (
      {agents.map((a) => ( { setSelection(sel); setFileIndex(sel.files[0]?.index ?? null); }} /> ))}
    )}
    {selection && activeFile ? (
    {/* Header: Session title + Trace file pagination (newest first) */}

    {titleOf(selection.agentId, selection.sessionId) ?? ( {selection.sessionId} )}

    {S.traces.filesTitle} {selection.files.map((f) => ( ))}

    {selection.sessionId} · {activeFile.date} · {formatBytes(activeFile.sizeBytes)}

    ) : ( )}
    ); }