/** * @file SessionOverview.tsx * @description Real-time stats panel rendered at the top of the Agents tab on the * Session detail page. Shows tile counters (events, tool calls, subagents, errors, * compactions, duration), top-tool usage bars, subagent-type breakdown, and a token * flow strip. Live-refreshes on `new_event` (debounced) so counters track the running * session without spamming the backend. * @author Nguyễn Ngọc Trí Vĩ */ /* ============================================================================= * MODULE_GUIDE — extended in-file reference (comments only; safe to read, never executed) * ============================================================================= * **Purpose:** Dashboard module consumed by the React client, MCP tools, or desktop shell depending on deployment mode. * * ## Design constraints * - Local-first: no telemetry leaves the machine unless the user configures webhooks. * - Fail-safe hooks path on the server must never block Claude Code; UI mirrors that * philosophy by degrading gracefully (empty states, stale badges, reconnect loops). * - Destructive flows stay behind explicit confirmation modals and server-side gates. * - Internationalization: user-visible strings belong in i18n JSON, not literals here. * * ## Remote data & SSH * Remote Data Sources let operators aggregate multiple machines. SSH entries describe * how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every * scoped GET via `?sources=`. Health checks and import history surface in Settings. * * ## Internal dependencies * - `../lib/api` * - `../lib/eventBus` * - `../lib/format` * - `./conversation/toolStyle` * - `../lib/types` * * ## Public surface * - `SessionOverview` — exported API; see TSDoc on the symbol for behavior. * * ## Testing pointers * - Prefer colocated `__tests__` with Vitest + Testing Library for UI. * - Server contract changes require `npm run test:server` and OpenAPI sync. * - MCP edits: `npm run mcp:typecheck` and `npm run mcp:build`. * * ## Related docs * - `ARCHITECTURE.md` — hooks → API → SQLite → WebSocket → UI pipeline. * - `docs/API.md` — REST reference. * - `.claude/skills/file-headers/` — mandatory `@author` header policy. * ============================================================================= */ /* ----------------------------------------------------------------------------- * EXPORT CATALOG — quick index of symbols defined below (documentation only). * ----------------------------------------------------------------------------- * **SessionOverview** * Part of this module's public contract. Downstream imports should treat * the signature and return type as stable unless release notes say otherwise. * When behavior changes, update the `@file` overview and relevant tests. * * ----------------------------------------------------------------------------- */ import { useEffect, useMemo, useRef, useState } from "react"; import { Activity, Wrench, GitBranch, AlertTriangle, Layers, Clock, Coins, Bot, } from "lucide-react"; import { api } from "../lib/api"; import { eventBus } from "../lib/eventBus"; import { isRemoteDataRefreshMessage } from "../lib/remoteDataEvents"; import { fmt, formatDuration } from "../lib/format"; import { styleForTool } from "./conversation/toolStyle"; import type { Agent, Session, SessionStats } from "../lib/types"; interface SessionOverviewProps { session: Session; agents: Agent[]; } /** Debounce window for stats refresh - coalesces bursts of hook events into one fetch. */ const REFRESH_DEBOUNCE_MS = 600; /** Compact tile used in the top stat row. */ function StatTile({ label, value, hint, icon, tone = "default", }: { label: string; value: React.ReactNode; hint?: string; icon: React.ReactNode; tone?: "default" | "violet" | "emerald" | "amber" | "rose" | "cyan" | "blue"; }) { const palette = { default: "border-surface-3 bg-surface-2 text-fg-secondary", violet: "border-violet-500/20 bg-violet-500/5 text-violet-200", emerald: "border-status-success/20 bg-status-success/5 text-status-success", amber: "border-status-warning/20 bg-status-warning/5 text-status-warning", rose: "border-rose-500/20 bg-rose-500/5 text-rose-200", cyan: "border-cyan-500/20 bg-cyan-500/5 text-cyan-200", blue: "border-blue-600/20 bg-blue-600/5 text-blue-300", }[tone]; const iconTone = { default: "text-fg-muted", violet: "text-violet-400", emerald: "text-status-success", amber: "text-status-warning", rose: "text-rose-400", cyan: "text-cyan-400", blue: "text-blue-500", }[tone]; return (
{icon} {label}
{value}
{hint &&
{hint}
}
); } function ToolUsageRow({ toolName, count, max }: { toolName: string; count: number; max: number }) { const style = styleForTool(toolName); const Icon = style.Icon; const pct = max > 0 ? Math.max(2, Math.round((count / max) * 100)) : 0; return (
{toolName}
{count.toLocaleString()}
); } export function SessionOverview({ session, agents }: SessionOverviewProps) { const [stats, setStats] = useState(null); const refreshTimerRef = useRef | null>(null); const fetchingRef = useRef(false); // Tick clock every 30s so a still-active session's "duration" tile stays current. const [now, setNow] = useState(() => Date.now()); useEffect(() => { if (session.status !== "active") return; const id = window.setInterval(() => setNow(Date.now()), 30_000); return () => window.clearInterval(id); }, [session.status]); const fetchStats = async () => { if (fetchingRef.current) return; fetchingRef.current = true; try { const result = await api.sessions.stats(session.id); setStats(result); } catch { // Non-fatal - overview just won't update this round. } finally { fetchingRef.current = false; } }; // Initial load + reload when the session id changes. useEffect(() => { fetchStats(); // eslint-disable-next-line react-hooks/exhaustive-deps }, [session.id]); // Live refresh on websocket events (debounced). useEffect(() => { const unsubscribe = eventBus.subscribe((msg) => { if (isRemoteDataRefreshMessage(msg)) { if (refreshTimerRef.current) clearTimeout(refreshTimerRef.current); refreshTimerRef.current = setTimeout(() => { refreshTimerRef.current = null; fetchStats(); }, REFRESH_DEBOUNCE_MS); return; } const isRelevant = msg.type === "new_event" || msg.type === "agent_created" || msg.type === "agent_updated" || msg.type === "session_updated"; if (!isRelevant) return; const data = msg.data as { session_id?: string; id?: string }; // Match either by session_id (events) or by id (session_updated) const matchesSession = data.session_id === session.id || data.id === session.id; if (!matchesSession) return; if (refreshTimerRef.current) clearTimeout(refreshTimerRef.current); refreshTimerRef.current = setTimeout(() => { refreshTimerRef.current = null; fetchStats(); }, REFRESH_DEBOUNCE_MS); }); return () => { unsubscribe(); if (refreshTimerRef.current) clearTimeout(refreshTimerRef.current); }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [session.id]); // Tool calls = sum of all tool counts (PreToolUse+PostToolUse events have a tool_name). // We approximate "tool calls" as half of that (each call produces a Pre + Post event). const toolCallCount = useMemo(() => { if (!stats) return 0; const total = stats.tools_used.reduce((s, t) => s + t.count, 0); return Math.round(total / 2); }, [stats]); const maxToolCount = useMemo(() => { if (!stats) return 0; return stats.tools_used.reduce((m, t) => Math.max(m, t.count), 0); }, [stats]); // Active agent (if any) const activeAgent = useMemo(() => agents.find((a) => a.status === "working") ?? null, [agents]); // Duration: ended_at - started_at, or now - started_at if active const durationLabel = useMemo(() => { if (!session.started_at) return "-"; const end = session.ended_at ?? new Date(now).toISOString(); return formatDuration(session.started_at, end); }, [session.started_at, session.ended_at, now]); // Avg event rate (events / minute) const eventRate = useMemo(() => { if (!stats || !session.started_at) return null; const start = new Date(session.started_at).getTime(); const end = session.ended_at ? new Date(session.ended_at).getTime() : stats.last_event_at ? new Date(stats.last_event_at).getTime() : now; const minutes = Math.max(1, (end - start) / 60_000); return stats.total_events / minutes; }, [stats, session.started_at, session.ended_at, now]); if (!stats) { return (
{Array.from({ length: 6 }).map((_, i) => (
))}
); } const tokens = stats.tokens; const totalTokens = tokens.input_tokens + tokens.output_tokens + tokens.cache_read_tokens + tokens.cache_write_tokens; return (
{/* Active-agent banner - only shows when session is running */} {activeAgent && (
{activeAgent.name || "Agent"} {activeAgent.current_tool && ( running {activeAgent.current_tool} )} {activeAgent.task && ( · {activeAgent.task} )}
)} {/* Stat tiles */}
0 ? `${eventRate < 1 ? eventRate.toFixed(2) : Math.round(eventRate)}/min` : undefined } icon={} /> 0 ? `${stats.tools_used.length} unique` : undefined} icon={} tone="violet" /> 0 ? `+${stats.agents.main} main` : undefined} icon={} tone="cyan" /> } tone="blue" /> } tone={stats.error_count > 0 ? "rose" : "default"} /> } tone={session.status === "active" ? "emerald" : "default"} />
{/* Two-column layout: tools + subagent breakdown */}
{/* Tool usage */}

Top tools

{stats.tools_used.length} total
{stats.tools_used.length === 0 ? (
No tool calls yet.
) : (
{stats.tools_used.slice(0, 8).map((t) => ( ))}
)}
{/* Subagent breakdown. * * The /api/sessions/:id/stats endpoint deliberately strips compaction * agents from `subagent_types` so the workflow analytics don't lump * them in. But on this overview a session with only compactions still * has *something* to show - surfacing zero subagents while the agents * tab below clearly lists "Context Compaction" cards is confusing. * * We synthesize a compaction row from `stats.agents.compaction` and * render it alongside any real subagent types, distinguished by an * amber bar (matching the compaction iconography elsewhere in the * app) instead of cyan. */}
{(() => { type SubRow = { key: string; label: string; count: number; isCompaction: boolean }; const rows: SubRow[] = stats.subagent_types.map((s) => ({ key: s.subagent_type, label: s.subagent_type, count: s.count, isCompaction: false, })); if (stats.agents.compaction > 0) { rows.push({ key: "__compaction__", label: "Context Compaction", count: stats.agents.compaction, isCompaction: true, }); } const totalRuns = rows.reduce((s, r) => s + r.count, 0); const max = rows.reduce((m, r) => Math.max(m, r.count), 0); return ( <>

Subagents

{totalRuns} runs
{rows.length === 0 ? (
No subagents in this session.
) : (
{rows.slice(0, 8).map((r) => { const pct = max > 0 ? Math.max(4, Math.round((r.count / max) * 100)) : 0; const barClass = r.isCompaction ? "bg-status-warning/60" : "bg-cyan-500/60"; return (
{r.label}
{r.count}
); })}
)} ); })()}
{/* Token flow strip */} {totalTokens > 0 && (

Token flow

{fmt(totalTokens)} total
)} {/* Event-type breakdown - secondary, only top 6 */} {stats.events_by_type.length > 0 && (

Event mix

{stats.events_by_type.slice(0, 12).map((e) => ( {e.event_type} · {e.count.toLocaleString()} ))}
)}
); } function TokenFlowBar({ tokens, total }: { tokens: SessionStats["tokens"]; total: number }) { const segments = [ { key: "cache_read", label: "Cache read", value: tokens.cache_read_tokens, cls: "bg-sky-500", text: "text-sky-300", }, { key: "cache_write", label: "Cache write", value: tokens.cache_write_tokens, cls: "bg-violet-500", text: "text-violet-300", }, { key: "input", label: "Input", value: tokens.input_tokens, cls: "bg-status-success", text: "text-status-success", }, { key: "output", label: "Output", value: tokens.output_tokens, cls: "bg-orange-500", text: "text-orange-300", }, ]; return ( <>
{segments.map((s) => { const pct = total > 0 ? (s.value / total) * 100 : 0; if (pct === 0) return null; return (
); })}
{segments.map((s) => { const pct = total > 0 ? (s.value / total) * 100 : 0; return (
{s.label} {fmt(s.value)} {pct > 0 && ( {pct >= 1 ? Math.round(pct) : pct.toFixed(1)}% )}
); })}
); }