/** * @file CompactionImpact.tsx * @description Visualizes how context compaction is spread across sessions. * Compaction is when Claude Code compresses older conversation history into a * summary once a session's context window fills up. This panel surfaces the * at-a-glance stats (total events, sessions affected, average and peak per * session) and a histogram answering "how many sessions compacted N times?" * so the distribution is legible regardless of how many sessions exist. * @author Nguyễn Ngọc Trí Vĩ */ /* ============================================================================= * MODULE_GUIDE — extended in-file reference (comments only; safe to read, never executed) * ============================================================================= * **Purpose:** Workflow analytics visualization built on D3; consumes aggregated session/run metrics from the workflows API. * * ## 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/types` * * ## Public surface * - `CompactionImpactProps` — exported API; see TSDoc on the symbol for behavior. * - `CompactionImpact` — 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). * ----------------------------------------------------------------------------- * **CompactionImpactProps** * 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. * * **CompactionImpact** * 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 { useRef, useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; import * as d3 from "d3"; import type { CompactionImpactData } from "../../lib/types"; // ── Helpers ─────────────────────────────────────────────────────────────────── function fmtTokens(n: number): string { if (n >= 1_000_000_000) return `${(n / 1_000_000_000).toFixed(1)}B`; if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`; if (n >= 1_000) return `${(n / 1_000).toFixed(1)}K`; return String(n); } /** Roll the per-session list into a histogram: for each compaction count k * (1..peak), how many sessions compacted exactly k times. */ function toHistogram(perSession: CompactionImpactData["perSession"]): Array<{ count: number; sessions: number; }> { const peak = perSession.reduce((m, s) => Math.max(m, s.compactions), 0); const buckets: Array<{ count: number; sessions: number }> = []; for (let k = 1; k <= peak; k++) { buckets.push({ count: k, sessions: perSession.filter((s) => s.compactions === k).length }); } return buckets; } // ── Chart constants ─────────────────────────────────────────────────────────── const MARGIN = { top: 18, right: 16, bottom: 46, left: 48 }; const CHART_HEIGHT = 200; // ── D3 renderer ─────────────────────────────────────────────────────────────── interface HistogramBucket { count: number; sessions: number; } interface HistogramOpts { x: string; y: string; onHover: (e: MouseEvent, d: HistogramBucket) => void; onMove: (e: MouseEvent) => void; onLeave: () => void; } function renderHistogram(svg: SVGSVGElement, histo: HistogramBucket[], opts: HistogramOpts): void { const container = svg.parentElement; const width = container ? container.clientWidth : 400; const innerW = width - MARGIN.left - MARGIN.right; const innerH = CHART_HEIGHT - MARGIN.top - MARGIN.bottom; const root = d3.select(svg); root.selectAll("*").remove(); root.attr("viewBox", `0 0 ${width} ${CHART_HEIGHT}`).attr("preserveAspectRatio", "xMidYMid meet"); const defs = root.append("defs"); const grad = defs .append("linearGradient") .attr("id", "compact-bar-grad") .attr("x1", "0%") .attr("y1", "0%") .attr("x2", "0%") .attr("y2", "100%"); grad.append("stop").attr("offset", "0%").attr("stop-color", "#818cf8"); grad .append("stop") .attr("offset", "100%") .attr("stop-color", "#3730a3") .attr("stop-opacity", 0.7); const g = root.append("g").attr("transform", `translate(${MARGIN.left},${MARGIN.top})`); const maxSessions = d3.max(histo, (d) => d.sessions) ?? 1; const xScale = d3 .scaleBand() .domain(histo.map((d) => d.count)) .range([0, innerW]) .padding(histo.length > 12 ? 0.18 : 0.34); const yScale = d3.scaleLinear().domain([0, maxSessions]).nice().range([innerH, 0]); // Horizontal grid lines (integer session counts) const yTicks = yScale.ticks(Math.min(4, maxSessions)).filter((d) => Number.isInteger(d)); g.selectAll(".grid-line") .data(yTicks) .join("line") .attr("class", "grid-line") .attr("x1", 0) .attr("x2", innerW) .attr("y1", (d) => yScale(d)) .attr("y2", (d) => yScale(d)) .attr("stroke", "#2a2a3d") .attr("stroke-width", 1); // Y axis (sessions) g.append("g") .call( d3 .axisLeft(yScale) .tickValues(yTicks) .tickSize(0) .tickPadding(8) .tickFormat((d) => String(d)) ) .call((ax) => ax.select(".domain").remove()) .selectAll("text") .attr("fill", "#6b7280") .attr("font-size", 10) .attr("font-family", "Inter, sans-serif"); // X axis (compactions per session - one tick per bucket) g.append("g") .attr("transform", `translate(0,${innerH})`) .call(d3.axisBottom(xScale).tickSize(0).tickPadding(8)) .call((ax) => ax.select(".domain").remove()) .selectAll("text") .attr("fill", "#9ca3af") .attr("font-size", 10) .attr("font-family", "Inter, sans-serif"); // Axis titles g.append("text") .attr("x", innerW / 2) .attr("y", innerH + 38) .attr("text-anchor", "middle") .attr("fill", "#6b7280") .attr("font-size", 10) .attr("font-weight", 500) .attr("font-family", "Inter, sans-serif") .text(opts.x); g.append("text") .attr("transform", "rotate(-90)") .attr("x", -innerH / 2) .attr("y", -38) .attr("text-anchor", "middle") .attr("fill", "#6b7280") .attr("font-size", 10) .attr("font-weight", 500) .attr("font-family", "Inter, sans-serif") .text(opts.y); // Bars + count labels + rich hover tooltip (full-height hit-area so every // column - including empty buckets - responds, matching the other charts). histo.forEach((d) => { const bx = xScale(d.count); if (bx === undefined) return; const bw = xScale.bandwidth(); const by = yScale(d.sessions); const barH = innerH - by; const bg = g.append("g"); let bar: d3.Selection | null = null; if (d.sessions > 0) { bar = bg .append("rect") .attr("x", bx) .attr("y", by) .attr("width", bw) .attr("height", barH) .attr("rx", Math.min(4, bw / 2)) .attr("fill", "url(#compact-bar-grad)") .style("transition", "fill 120ms ease"); bg.append("text") .attr("x", bx + bw / 2) .attr("y", by - 5) .attr("text-anchor", "middle") .attr("fill", "#a5b4fc") .attr("font-size", 10) .attr("font-weight", "600") .attr("font-family", "Inter, sans-serif") .attr("pointer-events", "none") .text(d.sessions); } else { // Empty bucket: faint baseline tick so the gap reads as "zero", not missing bg.append("rect") .attr("x", bx) .attr("y", innerH - 1) .attr("width", bw) .attr("height", 1) .attr("fill", "#2a2a3d"); } // Transparent, full-height hover target on top of the bar. bg.append("rect") .attr("x", bx) .attr("y", 0) .attr("width", bw) .attr("height", innerH) .attr("fill", "transparent") .style("cursor", "pointer") .on("mouseenter", (event: MouseEvent) => { if (bar) bar.attr("fill", "#a5b4fc"); opts.onHover(event, d); }) .on("mousemove", (event: MouseEvent) => opts.onMove(event)) .on("mouseleave", () => { if (bar) bar.attr("fill", "url(#compact-bar-grad)"); opts.onLeave(); }); }); } // ── Stat box ────────────────────────────────────────────────────────────────── interface StatBoxProps { label: string; value: string; sub?: string; accent?: string; } function StatBox({ label, value, sub, accent = "text-accent" }: StatBoxProps) { return (
{value} {label} {sub && {sub}}
); } // ── Component ───────────────────────────────────────────────────────────────── export interface CompactionImpactProps { data: CompactionImpactData; } export function CompactionImpact({ data }: CompactionImpactProps) { const { t } = useTranslation("workflows"); const svgRef = useRef(null); const [tip, setTip] = useState<{ x: number; y: number; title: string; detail: string } | null>( null ); const hasData = data.totalCompactions > 0; const affected = data.sessionsWithCompactions; const sessionPct = data.totalSessions > 0 ? Math.round((affected / data.totalSessions) * 100) : 0; const avgPerSession = affected > 0 ? data.totalCompactions / affected : 0; const peak = data.perSession.reduce((m, s) => Math.max(m, s.compactions), 0); useEffect(() => { if (!svgRef.current || !hasData) return; const histo = toHistogram(data.perSession); const affectedN = data.sessionsWithCompactions; renderHistogram(svgRef.current, histo, { x: t("compaction.xAxis"), y: t("compaction.yAxis"), onHover: (e, d) => { const pct = affectedN > 0 ? Math.round((d.sessions / affectedN) * 100) : 0; setTip({ x: e.clientX, y: e.clientY, title: t("compaction.tipTitle", { count: d.count }), detail: t("compaction.tipDetail", { sessions: d.sessions, pct }), }); }, onMove: (e) => setTip((p) => (p ? { ...p, x: e.clientX, y: e.clientY } : p)), onLeave: () => setTip(null), }); }, [data, hasData, t]); if (!hasData) { return (
{t("compaction.noData")}
); } return (
{/* What compaction is - one line so the numbers below make sense */}

{t("compaction.help")}

{/* Stat tiles */}
{/* Histogram: sessions by compaction count */}

{t("compaction.distribution")}

{/* Plain-English summary + (when present) tokens freed */}

{t("compaction.summary", { affected: affected.toLocaleString(), total: data.totalSessions.toLocaleString(), pct: sessionPct, })} {data.tokensRecovered > 0 && ( <> {t("compaction.tokensFreed", { tokens: fmtTokens(data.tokensRecovered) })} )}

{/* Hover tooltip (matches the app's other chart tooltips) */} {tip && (
window.innerWidth - 220 ? tip.x - 14 : tip.x + 14, top: tip.y - 10, transform: tip.x > window.innerWidth - 220 ? "translateX(-100%)" : undefined, }} >
{tip.title}
{tip.detail}
)}
); }