/** * Session Detail Page (Stories 7.2 + 7.5 + 14.3) * * Route: /sessions/:id * * Features: * - Header: agent name, status badge, duration, event/error count, tags * - Pulsing "running" indicator for active sessions * - Back button ← Sessions * - 404 message for missing sessions * - Timeline event type filter buttons (All, Tool Calls, Errors, Approvals, Custom) * - Client-side filtering with event count per filter * - Live timeline updates via SSE for active sessions (Story 14.3) * - Connection indicator (green dot / yellow warning) */ import React, { useCallback, useMemo, useState } from 'react'; import { Link, useParams } from 'react-router-dom'; import type { AgentLensEvent, Session, SessionStatus, CostTrackedPayload } from '@agentkitai/agentlens-core'; import { getSession, getSessionTimeline } from '../api/client'; import type { SessionTimeline } from '../api/client'; import { useApi } from '../hooks/useApi'; import { useSSE } from '../hooks/useSSE'; import { Timeline } from '../components/Timeline'; import { EventDetailPanel } from '../components/EventDetailPanel'; import { SearchBar } from '../components/search/SearchBar'; import { BudgetStatusBadge } from '../components/BudgetStatusBadge'; import { listBudgets, getBudgetStatus, type CostBudgetStatusData } from '../api/budgets'; import { ErrorNav } from '../components/navigation/ErrorNav'; import { ExportMenu } from '../components/export/ExportMenu'; import { useErrorIndices } from '../hooks/useErrorIndices'; import { diagnoseSession } from '../api/diagnose'; import type { DiagnosticReport } from '../api/diagnose'; import { DiagnosticPanel } from '../components/DiagnosticPanel'; // ─── Filter definitions ───────────────────────────────────────────── type FilterKey = 'all' | 'tool_calls' | 'errors' | 'approvals' | 'skills' | 'custom'; interface FilterDef { key: FilterKey; label: string; icon: string; match: (ev: AgentLensEvent) => boolean; } const FILTERS: FilterDef[] = [ { key: 'all', label: 'All', icon: '📋', match: () => true, }, { key: 'tool_calls', label: 'Tool Calls', icon: '🔧', match: (ev) => ev.eventType === 'tool_call' || ev.eventType === 'tool_response' || ev.eventType === 'tool_error', }, { key: 'errors', label: 'Errors', icon: '❌', match: (ev) => ev.severity === 'error' || ev.severity === 'critical' || ev.eventType === 'tool_error' || ev.eventType === 'alert_triggered', }, { key: 'approvals', label: 'Approvals', icon: '🔔', match: (ev) => ev.eventType === 'approval_requested' || ev.eventType === 'approval_granted' || ev.eventType === 'approval_denied' || ev.eventType === 'approval_expired', }, { key: 'skills', label: 'Skills', icon: '🧩', match: (ev) => ev.eventType === 'skill_activated', }, { key: 'custom', label: 'Custom', icon: '🔹', match: (ev) => ev.eventType === 'custom', }, ]; // ─── Status badges ────────────────────────────────────────────────── const STATUS_CONFIG: Record = { completed: { label: 'Completed', icon: '✅', className: 'bg-green-100 text-green-800 border-green-300' }, error: { label: 'Error', icon: '❌', className: 'bg-red-100 text-red-800 border-red-300' }, active: { label: 'Active', icon: '🔄', className: 'bg-blue-100 text-blue-800 border-blue-300' }, }; function StatusBadge({ status }: { status: SessionStatus }) { const cfg = STATUS_CONFIG[status]; return ( {status === 'active' && ( )} {cfg.icon} {cfg.label} ); } // ─── Duration formatting ──────────────────────────────────────────── function formatDuration(startedAt: string, endedAt?: string): string { const start = new Date(startedAt).getTime(); const end = endedAt ? new Date(endedAt).getTime() : Date.now(); const diffMs = end - start; if (diffMs < 1000) return `${diffMs}ms`; const secs = Math.floor(diffMs / 1000); if (secs < 60) return `${secs}s`; const mins = Math.floor(secs / 60); const remSecs = secs % 60; if (mins < 60) return `${mins}m ${remSecs}s`; const hrs = Math.floor(mins / 60); const remMins = mins % 60; return `${hrs}h ${remMins}m`; } // ─── Stat card ────────────────────────────────────────────────────── function StatCard({ label, value, className = '' }: { label: string; value: string | number; className?: string }) { return (
{label}
{value}
); } // ─── Main component ───────────────────────────────────────────────── export function SessionDetail(): React.ReactElement | null { const { id } = useParams<{ id: string }>(); const [activeFilter, setActiveFilter] = useState('all'); const [selectedEvent, setSelectedEvent] = useState(null); const [searchQuery, setSearchQuery] = useState(''); // Diagnostics state (Story 18.10) const [diagReport, setDiagReport] = useState(null); const [diagLoading, setDiagLoading] = useState(false); const [diagError, setDiagError] = useState(null); const [showDiag, setShowDiag] = useState(false); // SSE live events that arrive after initial load (Story 14.3) const [liveEvents, setLiveEvents] = useState([]); const [liveSession, setLiveSession] = useState(null); // Budget status for session (Story 8) const [budgetStatus, setBudgetStatus] = useState(null); // Fetch session info const { data: session, loading: sessionLoading, error: sessionError, } = useApi(() => getSession(id!), [id]); // Fetch timeline const { data: timeline, loading: timelineLoading, error: timelineError, refetch: refetchTimeline, } = useApi(() => getSessionTimeline(id!), [id]); // Load session budget status (Story 8) React.useEffect(() => { listBudgets({ scope: 'session', enabled: true }).then(({ budgets }) => { if (budgets.length > 0) { getBudgetStatus(budgets[0].id).then(setBudgetStatus).catch(() => {}); } }).catch(() => {}); }, []); // Determine effective session (live updates override initial fetch) const effectiveSession = liveSession ?? session; const isSessionActive = effectiveSession?.status === 'active'; // SSE connection for live updates (Story 14.3) // Only connect when session is active const { connected: sseConnected } = useSSE({ url: '/api/stream', params: { sessionId: id }, enabled: isSessionActive, onEvent: useCallback((data: unknown) => { const event = data as AgentLensEvent; setLiveEvents((prev) => { // Deduplicate by event id if (prev.some((e) => e.id === event.id)) return prev; return [...prev, event]; }); }, []), onSessionUpdate: useCallback((data: unknown) => { const updated = data as Session; setLiveSession(updated); }, []), }); // Reset live state when session id changes // (note: this is handled by the dependency array on useApi and useSSE, // but we also clear local live state) const prevIdRef = React.useRef(id); if (prevIdRef.current !== id) { prevIdRef.current = id; setLiveEvents([]); setLiveSession(null); } // Merge initial timeline events with SSE live events const allEvents = useMemo(() => { if (!timeline?.events) return liveEvents; const existingIds = new Set(timeline.events.map((e) => e.id)); const newEvents = liveEvents.filter((e) => !existingIds.has(e.id)); return [...timeline.events, ...newEvents]; }, [timeline?.events, liveEvents]); // Filter events client-side const currentFilter = useMemo( () => FILTERS.find((f) => f.key === activeFilter) ?? FILTERS[0], [activeFilter], ); const typeFilteredEvents = useMemo(() => { return allEvents.filter(currentFilter.match); }, [allEvents, currentFilter]); // [F11-S1] Search filter step const filteredEvents = useMemo(() => { if (!searchQuery) return typeFilteredEvents; const q = searchQuery.toLowerCase(); return typeFilteredEvents.filter( (ev) => JSON.stringify(ev.payload).toLowerCase().includes(q) || ev.eventType.toLowerCase().includes(q), ); }, [typeFilteredEvents, searchQuery]); // [F11-S2] Error navigation indices const errorIndices = useErrorIndices(filteredEvents); // Count per filter (uses merged events) const filterCounts = useMemo(() => { if (allEvents.length === 0) return new Map(); const counts = new Map(); for (const f of FILTERS) { counts.set(f.key, allEvents.filter(f.match).length); } return counts; }, [allEvents]); // Event click handler const handleEventClick = useCallback((event: AgentLensEvent) => { setSelectedEvent(event); }, []); // [F11-S2] Error navigation handler const handleErrorNavigate = useCallback( (index: number) => { const event = filteredEvents[index]; if (event) setSelectedEvent(event); }, [filteredEvents], ); const handleClosePanel = useCallback(() => { setSelectedEvent(null); }, []); // ── 404 ─────────────────────────────────────────────────────────── if (sessionError?.includes('404') || sessionError?.toLowerCase().includes('not found')) { return (
🔍

Session Not Found

The session {id} does not exist.

← Back to Sessions
); } // ── Loading ─────────────────────────────────────────────────────── if (sessionLoading && !session) { return (
); } if (sessionError) { return (
Error loading session: {sessionError}
); } if (!session) return null; // Use effectiveSession (with live updates) for rendering const displaySession = effectiveSession ?? session; // ── Render ──────────────────────────────────────────────────────── return (
{/* Back button + Replay link */}
← Sessions
{/* [F11-S3] Export */} {timeline && ( )} 🎬 Replay
{/* SSE Connection Indicator (Story 14.3) */} {isSessionActive && (
{sseConnected ? ( <> Live — receiving updates ) : ( <> Reconnecting… )}
)} {/* Header */}

{displaySession.agentName ?? displaySession.agentId}

{/* Tags */} {displaySession.tags.length > 0 && (
{displaySession.tags.map((tag) => ( {tag} ))}
)}
{/* Stats */}
0 ? 'border-red-200' : ''} />
{/* Cost Summary (Story 11.5) */} {displaySession.totalCostUsd > 0 && allEvents.length > 0 && (() => { const costEvents = allEvents.filter( (ev) => ev.eventType === 'cost_tracked', ); if (costEvents.length === 0) return null; // Group by model/provider const breakdown = new Map(); for (const ev of costEvents) { const p = ev.payload as CostTrackedPayload; const key = `${p.provider}/${p.model}`; const existing = breakdown.get(key); if (existing) { existing.inputTokens += p.inputTokens; existing.outputTokens += p.outputTokens; existing.totalTokens += p.totalTokens; existing.costUsd += p.costUsd; existing.count += 1; } else { breakdown.set(key, { provider: p.provider, model: p.model, inputTokens: p.inputTokens, outputTokens: p.outputTokens, totalTokens: p.totalTokens, costUsd: p.costUsd, count: 1, }); } } const totalInputTokens = costEvents.reduce((sum, ev) => sum + (ev.payload as CostTrackedPayload).inputTokens, 0); const totalOutputTokens = costEvents.reduce((sum, ev) => sum + (ev.payload as CostTrackedPayload).outputTokens, 0); return (

💰 Cost Breakdown

Total Cost
${displaySession.totalCostUsd.toFixed(4)}
Input Tokens
{totalInputTokens.toLocaleString()}
Output Tokens
{totalOutputTokens.toLocaleString()}
Cost Events
{costEvents.length}
{breakdown.size > 1 && (
{Array.from(breakdown.values()).map((b) => ( ))}
Provider / Model Cost Input Output Calls
{b.model} ({b.provider}) ${b.costUsd.toFixed(4)} {b.inputTokens.toLocaleString()} {b.outputTokens.toLocaleString()} {b.count}
)}
); })()} {/* Diagnose button + panel (Story 18.10) */} {displaySession.errorCount > 0 && (
{!showDiag && ( )} {showDiag && ( { setDiagLoading(true); setDiagError(null); try { const report = await diagnoseSession(id!, true); setDiagReport(report); } catch (err: any) { setDiagError(err?.message || 'Diagnosis failed'); } finally { setDiagLoading(false); } }} /> )}
)} {/* Budget Status (Story 8) */} {budgetStatus && (
Session Budget: {budgetStatus.breached && ( BREACHED )}
)} {/* [F11-S1] Search bar */} {/* Filter buttons (Story 7.5) + [F11-S2] Error nav */}
{/* [F11-S2] Error navigation */} {FILTERS.map((f) => { const count = filterCounts.get(f.key) ?? 0; const isActive = activeFilter === f.key; return ( ); })}
{/* Timeline */} {timelineError ? (

Error loading timeline: {timelineError}

) : timelineLoading && !timeline ? (
) : timeline ? ( ) : null} {/* Event detail side panel */}
); }