/** * dashboard-client/src/components/SessionContextGauges.tsx — Per-session context gauges (S40). * * Replaces the single launcher-only ContextGauge on the Overview tab with a * wrapping grid of compact gauges, one per active pi session. The launcher's * own session is always shown first + highlighted ("this session"), sourced * from the live snapshot (always available, never depends on heartbeat * timing). Other active sessions come from /api/sessions, sorted by lastSeen * descending, each labeled with repo basename + PID. * * Reuses the existing .gauge-bar / .gauge-fill / .gauge-{green,yellow,red} * classes from overview-events.css so the fill bars match the original * ContextGauge styling. * * PREVENT-PI-004: pure presentational component — no fetch, no network. Data * is passed in by the parent (OverviewTab) via its useApi(fetchSessions) + * useSSE() for real-time updates. */ import type React from "react"; import type { ActiveSession, SessionsResponse } from "@contracts"; import { Card, CardContent, CardHeader, CardTitle, } from "../components/ui/card"; export interface SessionContextGaugesProps { /** Active sessions from /api/sessions (may be null while loading). */ sessions: SessionsResponse | null; /** Whether the initial fetch is in flight. */ loading: boolean; /** Fetch error, if any. */ error: Error | null; /** Launcher's own session ID (from snapshot.session.id), to exclude it * from the "other sessions" list (it's always rendered separately). */ launcherSessionId: string | null; /** Launcher's own context values (from snapshot — always available). */ launcher: { tokens: number | null; percent: number | null; contextWindow: number; }; } function severityClass(percent: number): string { if (percent >= 90) return "gauge-red"; if (percent >= 70) return "gauge-yellow"; return "gauge-green"; } function fmtTokens(n: number | null): string { if (n == null) return "?"; if (n >= 100_000) return `${(n / 1000).toFixed(0)}k`; return n.toLocaleString(); } function repoBasename(repoRoot: string | null): string { if (!repoRoot) return "(unknown)"; const trimmed = repoRoot.replace(/[\\/]+$/, ""); if (!trimmed) return "(unknown)"; const parts = trimmed.split(/[\\/]/); return parts[parts.length - 1] || trimmed; } function fmtAge(ms: number): string { const s = Math.max(0, Math.round(ms / 1000)); if (s < 60) return `${s}s ago`; const m = Math.floor(s / 60); if (m < 60) return `${m}m ago`; const h = Math.floor(m / 60); return `${h}h ${m % 60}m ago`; } interface GaugeData { key: string; label: string; sublabel: string; tokens: number | null; percent: number | null; contextWindow: number; isSelf: boolean; } /** A single readable gauge: header label, fill bar, tokens/window sublabel. */ function SessionGauge({ data }: { data: GaugeData }): React.ReactElement { const pct = Math.round((data.percent ?? 0) * 10) / 10; const fillWidth = Math.max(pct, 1); const cls = severityClass(pct); const tokStr = fmtTokens(data.tokens); const windowStr = data.contextWindow.toLocaleString(); const sublabel = `${tokStr} / ${windowStr} tokens`; return (
{sublabel}
{data.sublabel && ({data.sublabel}
)}