/** * SessionList — Table display for session data (Story 7.1) * * Renders a sortable table with status badges, pagination, * and delegated filter controls. */ import React, { useMemo, useRef } from 'react'; import { useVirtualizer } from '@tanstack/react-virtual'; import { Link } from 'react-router-dom'; import type { Session, SessionStatus } from '@agentkitai/agentlens-core'; // ─── Types ────────────────────────────────────────────────────────── export type SortField = 'agentName' | 'status' | 'startedAt' | 'duration' | 'eventCount' | 'errorCount' | 'cost'; export type SortDir = 'asc' | 'desc'; export interface SessionListProps { sessions: Session[]; sortField: SortField; sortDir: SortDir; onSort: (field: SortField) => void; page: number; pageSize: number; total: number; onPageChange: (page: number) => void; } // ─── Helpers ──────────────────────────────────────────────────────── const STATUS_BADGES: Record = { completed: { label: 'Completed', icon: '✅', className: 'bg-green-100 text-green-800' }, error: { label: 'Error', icon: '❌', className: 'bg-red-100 text-red-800' }, active: { label: 'Active', icon: '🔄', className: 'bg-blue-100 text-blue-800' }, idle: { label: 'Idle', icon: '💤', className: 'bg-gray-100 text-gray-700' }, }; function StatusBadge({ status }: { status: SessionStatus }) { const badge = STATUS_BADGES[status] ?? STATUS_BADGES.active; return ( {badge.icon} {badge.label} ); } 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`; } function formatTimestamp(ts: string): string { const d = new Date(ts); return d.toLocaleString(undefined, { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit', second: '2-digit', }); } // ─── Sort indicator ───────────────────────────────────────────────── function SortIndicator({ field, sortField, sortDir }: { field: SortField; sortField: SortField; sortDir: SortDir }) { if (field !== sortField) return ; return {sortDir === 'asc' ? '↑' : '↓'}; } // ─── Column header ────────────────────────────────────────────────── function getAriaSortValue(field: SortField, sortField: SortField, sortDir: SortDir): 'ascending' | 'descending' | 'none' { if (field !== sortField) return 'none'; return sortDir === 'asc' ? 'ascending' : 'descending'; } function Th({ field, label, sortField, sortDir, onSort, className = '', }: { field: SortField; label: string; sortField: SortField; sortDir: SortDir; onSort: (f: SortField) => void; className?: string; }) { return ( ); } // ─── Component ────────────────────────────────────────────────────── const ESTIMATED_ROW_HEIGHT = 48; // Header and virtualized body live in two separate s, and the body rows // are position:absolute (out of table flow) — so left to the browser their 8 // columns size independently and don't line up. Pin an identical grid template on // the header row AND every body row so columns align regardless of content. const GRID_STYLE = { display: 'grid', gridTemplateColumns: 'minmax(140px,1.5fr) 112px 180px 96px 84px 84px 112px minmax(120px,1fr)', alignItems: 'center', } as const; export function SessionList({ sessions, sortField, sortDir, onSort, page, pageSize, total, onPageChange, }: SessionListProps) { const totalPages = useMemo(() => Math.max(1, Math.ceil(total / pageSize)), [total, pageSize]); const parentRef = useRef(null); const virtualizer = useVirtualizer({ count: sessions.length, getScrollElement: () => parentRef.current, estimateSize: () => ESTIMATED_ROW_HEIGHT, overscan: 10, }); if (sessions.length === 0) { return (

No sessions found

Try adjusting your filters

); } return (
{/* Table */}
Tags
{/* Body scroll container: vertical only. `overflow-auto` here scrolled horizontally too, on top of the outer `overflow-x-auto` wrapper — two horizontal scrollbars. Size it to content (w-max) so the body fits and the single horizontal scrollbar belongs to the outer wrapper (which scrolls header + body together). */}
{virtualizer.getVirtualItems().map((virtualRow) => { const s = sessions[virtualRow.index]; if (!s) return null; return ( ); })}
{s.agentName ?? s.agentId} {formatTimestamp(s.startedAt)} {formatDuration(s.startedAt, s.endedAt)} {s.eventCount} 0 ? 'text-red-600 font-medium' : 'text-gray-600'}> {s.errorCount} {s.totalCostUsd > 0 ? `$${s.totalCostUsd.toFixed(4)}` : '—'}
{s.tags.map((tag) => ( {tag} ))}
{/* Pagination */} {totalPages > 1 && (
Showing {page * pageSize + 1}–{Math.min((page + 1) * pageSize, total)} of {total}
Page {page + 1} of {totalPages}
)} ); }