/** * @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. * * ## Related docs * - `ARCHITECTURE.md` — hooks → API → SQLite → WebSocket → UI pipeline. * - `docs/API.md` — REST reference. * - `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 { useLiveRefresh } from "../hooks/useLiveRefresh"; import { useTranslation } from "react-i18next"; import { isRemoteDataRefreshMessage } from "../lib/remoteDataEvents"; import { fmt, formatDuration, parseDate } from "../lib/format"; import { effectiveSessionStatus } from "../lib/types"; import { styleForTool } from "./conversation/toolStyle"; import type { Agent, Session, SessionStats } from "../lib/types"; interface SessionOverviewProps { session: Session; agents: Agent[]; /** Frozen stats (a session snapshot): shown as given, never fetched or refreshed. */ stats?: SessionStats; } /** Throttle window for stats refresh - coalesces bursts of hook events into one fetch. */ const REFRESH_THROTTLE_MS = 5000; /** Compact tile used in the top stat row. */ function StatTile({ label, value, hint, help, icon, tone = "default", }: { label: string; value: React.ReactNode; hint?: string; /** Plain-language explanation, shown as the label's tooltip. */ help?: string; icon: React.ReactNode; tone?: "default" | "violet" | "emerald" | "amber" | "rose" | "cyan" | "blue"; }) { // Theme tokens only: status colours for states, syntax hues for categories. const palette = { default: "border-surface-3 bg-surface-2 text-fg-secondary", violet: "border-syntax-keyword/20 bg-syntax-keyword/5 text-syntax-keyword", 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-status-danger/20 bg-status-danger/5 text-status-danger", cyan: "border-syntax-property/20 bg-syntax-property/5 text-syntax-property", blue: "border-syntax-builtin/20 bg-syntax-builtin/5 text-syntax-builtin", }[tone]; const iconTone = { default: "text-fg-muted", violet: "text-syntax-keyword", emerald: "text-status-success", amber: "text-status-warning", rose: "text-status-danger", cyan: "text-syntax-property", blue: "text-syntax-builtin", }[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, stats: frozen }: SessionOverviewProps) { const { t } = useTranslation("sessions"); const [fetched, setStats] = useState(null); const stats = frozen ?? fetched; 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 (frozen || session.status !== "active") return; const id = window.setInterval(() => setNow(Date.now()), 30_000); return () => window.clearInterval(id); }, [frozen, session.status]); // A refresh asked for while one is in flight is not dropped: it runs once // when the current one settles (that is the throttle's trailing call). const pendingRef = useRef(false); const fetchStats = async () => { if (fetchingRef.current) { pendingRef.current = true; 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; if (pendingRef.current) { pendingRef.current = false; void fetchStats(); } } }; // Initial load + reload when the session id changes. useEffect(() => { if (frozen) return; fetchStats(); // eslint-disable-next-line react-hooks/exhaustive-deps }, [session.id]); // Live refresh on this session's websocket events, throttled (a steady // stream still refreshes) and again after a reconnect. useLiveRefresh(() => void fetchStats(), { throttleMs: REFRESH_THROTTLE_MS, enabled: !frozen, match: (msg) => { if (isRemoteDataRefreshMessage(msg)) return true; if ( msg.type !== "new_event" && msg.type !== "agent_created" && msg.type !== "agent_updated" && msg.type !== "session_updated" ) return false; const data = msg.data as { session_id?: string; id?: string }; // Match either by session_id (events) or by id (session_updated) return data.session_id === session.id || data.id === session.id; }, }); // Exact totals from the server (`tools_used` is only the top 15). An older // server lacks them: fall back to the visible list's count. const toolCallCount = stats?.tool_call_count ?? 0; const distinctTools = stats?.distinct_tool_count ?? stats?.tools_used.length ?? 0; 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 = parseDate(session.started_at).getTime(); const end = session.ended_at ? parseDate(session.ended_at).getTime() : stats.last_event_at ? parseDate(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 || t("overview.agent")} {activeAgent.current_tool && ( {t("overview.running")} {activeAgent.current_tool} )} {activeAgent.task && ( · {activeAgent.task} )}
)} {/* Stat tiles */}
0 ? t("overview.perMin", { rate: eventRate < 1 ? eventRate.toFixed(2) : Math.round(eventRate), }) : undefined } icon={} /> 0 ? t("overview.unique", { count: distinctTools }) : undefined} icon={} tone="violet" /> 0 ? t("overview.plusMain", { count: stats.agents.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 */}

{t("overview.topTools")}

{t("overview.distinctTotal", { count: distinctTools })}
{stats.tools_used.length === 0 ? (
{t("overview.noToolCalls")}
) : (
{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: t("overview.contextCompaction"), 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 ( <>

{t("overview.subagents")}

{t("overview.runs", { count: totalRuns })}
{rows.length === 0 ? (
{t("overview.noSubagents")}
) : (
{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-syntax-property/60"; return (
{r.label}
{r.count}
); })}
)} ); })()}
{/* Token flow strip */} {totalTokens > 0 && (

{t("overview.tokenFlow")}

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

{t("overview.eventMix")}

{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 { t } = useTranslation(); const segments = [ { key: "cache_read", label: t("common:token.cacheRead"), value: tokens.cache_read_tokens, cls: "bg-syntax-builtin", text: "text-syntax-builtin", }, { key: "cache_write", label: t("common:token.cacheWrite"), value: tokens.cache_write_tokens, cls: "bg-syntax-keyword", text: "text-syntax-keyword", }, { key: "input", label: t("common:token.input"), value: tokens.input_tokens, cls: "bg-status-success", text: "text-status-success", }, { key: "output", label: t("common:token.output"), value: tokens.output_tokens, cls: "bg-syntax-number", text: "text-syntax-number", }, ]; 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)}% )}
); })}
); }