feat: Claude Code Monitor — lanes, pipelines and a merged workspace

Internal SmartGift build of a Claude Code monitoring dashboard.

Lanes: a durable unit of parallel agent work, one per working directory,
tracked across session restarts. Managed lanes are git worktrees the
dashboard provisions and can reset or remove behind a three-check destroy
guard and a counted preflight; adopted lanes are directories you already
own and are never destroyable.

Pipelines: a lane moves through pipeline stages. A stage the agent declares
with evidence renders green; a stage inferred from the tool-event stream
renders dashed amber and never counts as done. Detection is forward-only
within a 30-minute window, and never writes the declared stage.

Workspace: one page at /run with a lane grid, the selected lane's pipeline,
and a full Claude console behind a disclosure.
This commit is contained in:
2026-07-29 17:07:45 +07:00
commit 57dc91585d
783 changed files with 221743 additions and 0 deletions
@@ -0,0 +1,429 @@
/**
* @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ĩ <vinnt@smartgift.vn>
*/
/* =============================================================================
* 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.
*
* ## Observability
* Prometheus scrapes `GET /api/metrics` (see `monitoring/`). Grafana ships four
* provisioned boards (overview, sessions, tools, alerts). Native npm scripts and
* Docker Compose profiles are documented in `monitoring/README.md`.
*
* ## 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<number>()
.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<SVGLineElement, number>(".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<SVGRectElement, unknown, null, undefined> | 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 (
<div className="flex flex-col gap-1 bg-surface-3 border border-border rounded-xl px-4 py-3.5 flex-1 min-w-0">
<span className={`text-2xl font-semibold tabular-nums ${accent}`}>{value}</span>
<span className="text-[11px] font-medium text-gray-500 uppercase tracking-wider leading-tight">
{label}
</span>
{sub && <span className="text-[11px] text-gray-600 tabular-nums">{sub}</span>}
</div>
);
}
// ── Component ─────────────────────────────────────────────────────────────────
export interface CompactionImpactProps {
data: CompactionImpactData;
}
export function CompactionImpact({ data }: CompactionImpactProps) {
const { t } = useTranslation("workflows");
const svgRef = useRef<SVGSVGElement>(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 (
<div className="flex flex-col items-center justify-center py-16 gap-3 text-gray-500">
<svg
width="40"
height="40"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
aria-hidden="true"
>
<path d="M9 17H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11a2 2 0 0 1 2 2v3" />
<path d="M13 21l2-2 4 4" />
<path d="M17 21v-6" />
<path d="M21 17h-6" />
</svg>
<span className="text-sm">{t("compaction.noData")}</span>
</div>
);
}
return (
<div className="flex flex-col gap-5">
{/* What compaction is - one line so the numbers below make sense */}
<p className="text-xs text-gray-500 leading-relaxed">{t("compaction.help")}</p>
{/* Stat tiles */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3">
<StatBox
label={t("compaction.totalCompactions")}
value={data.totalCompactions.toLocaleString()}
accent="text-accent-hover"
/>
<StatBox
label={t("compaction.sessionsAffected")}
value={affected.toLocaleString()}
sub={t("compaction.ofTotal", { total: data.totalSessions.toLocaleString() })}
accent="text-violet-300"
/>
<StatBox
label={t("compaction.avgPerSession")}
value={avgPerSession.toFixed(1)}
accent="text-blue-300"
/>
<StatBox
label={t("compaction.peakSession")}
value={peak.toLocaleString()}
accent="text-emerald-400"
/>
</div>
{/* Histogram: sessions by compaction count */}
<div className="w-full overflow-hidden">
<p className="text-xs font-medium text-gray-500 uppercase tracking-wider mb-2">
{t("compaction.distribution")}
</p>
<svg
ref={svgRef}
className="w-full"
style={{ height: CHART_HEIGHT }}
aria-label={t("compaction.ariaLabel")}
role="img"
/>
</div>
{/* Plain-English summary + (when present) tokens freed */}
<p className="text-xs text-gray-500 leading-relaxed">
{t("compaction.summary", {
affected: affected.toLocaleString(),
total: data.totalSessions.toLocaleString(),
pct: sessionPct,
})}
{data.tokensRecovered > 0 && (
<> {t("compaction.tokensFreed", { tokens: fmtTokens(data.tokensRecovered) })}</>
)}
</p>
{/* Hover tooltip (matches the app's other chart tooltips) */}
{tip && (
<div
className="fixed z-50 pointer-events-none rounded-md border border-[#2a2a4a] bg-[#12121f] px-2.5 py-1.5 text-xs shadow-xl"
style={{
left: tip.x > window.innerWidth - 220 ? tip.x - 14 : tip.x + 14,
top: tip.y - 10,
transform: tip.x > window.innerWidth - 220 ? "translateX(-100%)" : undefined,
}}
>
<div className="font-medium text-gray-100">{tip.title}</div>
<div className="mt-0.5 text-gray-400">{tip.detail}</div>
</div>
)}
</div>
);
}