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,457 @@
/**
* @file primitives.tsx
* @description Presentational building blocks used by the per-tool input and
* response renderers. Each primitive is a pure component with a narrow,
* typed contract so they can be composed freely (Terminal + TerminalOutput for
* Bash; Terminal + UnifiedDiff for Edit; LineNumberedCode for Read/Write;
* FileList/MatchList for Grep/Glob; KeyValueCard for MCP tools).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
/* =============================================================================
* 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.
*
* ## 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`.
*
* ## Public surface
* - `CopyButton` — exported API; see TSDoc on the symbol for behavior.
* - `Terminal` — exported API; see TSDoc on the symbol for behavior.
* - `TerminalOutput` — exported API; see TSDoc on the symbol for behavior.
* - `LineNumberedCode` — exported API; see TSDoc on the symbol for behavior.
* - `DiffHunk` — exported API; see TSDoc on the symbol for behavior.
* - `UnifiedDiff` — exported API; see TSDoc on the symbol for behavior.
* - `KeyValueCard` — exported API; see TSDoc on the symbol for behavior.
* - `FileList` — exported API; see TSDoc on the symbol for behavior.
* - `GrepMatch` — exported API; see TSDoc on the symbol for behavior.
* - `MatchList` — 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).
* -----------------------------------------------------------------------------
* **CopyButton**
* 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.
*
* **Terminal**
* 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.
*
* **TerminalOutput**
* 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.
*
* **LineNumberedCode**
* 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.
*
* **DiffHunk**
* 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.
*
* **UnifiedDiff**
* 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.
*
* **KeyValueCard**
* 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.
*
* **FileList**
* 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.
*
* **GrepMatch**
* 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.
*
* **MatchList**
* 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 { useState } from "react";
import { useTranslation } from "react-i18next";
import { Copy, Check } from "lucide-react";
// ───────────────────────── Copy button ─────────────────────────
export function CopyButton({ text }: { text: string }) {
const { t } = useTranslation("common");
const [copied, setCopied] = useState(false);
async function copy() {
try {
await navigator.clipboard.writeText(text);
setCopied(true);
setTimeout(() => setCopied(false), 1500);
} catch {
// Clipboard API can fail in insecure contexts - silently ignore.
}
}
return (
<button
type="button"
onClick={copy}
className="flex items-center gap-1 text-[10px] py-0.5 px-1.5 rounded text-gray-400 hover:text-gray-200 hover:bg-surface-2 cursor-pointer"
aria-label={t("eventDetail.copy")}
>
{copied ? <Check className="w-3 h-3" /> : <Copy className="w-3 h-3" />}
{copied ? t("eventDetail.copied") : t("eventDetail.copy")}
</button>
);
}
// ───────────────────────── Terminal (command) ─────────────────────────
export function Terminal({ command, description }: { command: string; description?: string }) {
return (
<div className="relative bg-black/70 border border-border rounded font-mono text-[11px] overflow-hidden">
<div className="flex items-center justify-between px-3 py-1 border-b border-border bg-black/40">
<span className="text-gray-500 text-[10px] uppercase tracking-wide">terminal</span>
<CopyButton text={command} />
</div>
<pre className="px-3 py-2 text-gray-200 whitespace-pre-wrap break-words">
{description && <div className="text-gray-500 mb-1"># {description}</div>}
<div>
<span className="text-emerald-400 select-none">$ </span>
{command}
</div>
</pre>
</div>
);
}
// ───────────────────────── Terminal output (stdout/stderr) ─────────────────────────
export function TerminalOutput({
stdout,
stderr,
interrupted,
exitCode,
}: {
stdout?: string;
stderr?: string;
interrupted?: boolean;
exitCode?: number;
}) {
const hasStdout = typeof stdout === "string" && stdout.length > 0;
const hasStderr = typeof stderr === "string" && stderr.length > 0;
const flag =
interrupted === true
? { label: "interrupted", color: "text-red-400 border-red-500/40 bg-red-500/10" }
: typeof exitCode === "number" && exitCode !== 0
? {
label: `exit ${exitCode}`,
color: "text-red-400 border-red-500/40 bg-red-500/10",
}
: null;
return (
<div className="space-y-2">
{hasStdout && <OutputBlock label="stdout" text={stdout!} variant="out" />}
{hasStderr && <OutputBlock label="stderr" text={stderr!} variant="err" />}
{flag && (
<span
className={`inline-block text-[10px] px-2 py-0.5 rounded border font-mono ${flag.color}`}
>
{flag.label}
</span>
)}
</div>
);
}
function OutputBlock({
label,
text,
variant,
}: {
label: string;
text: string;
variant: "out" | "err";
}) {
const color = variant === "err" ? "text-red-300" : "text-gray-200";
return (
<div className="relative bg-black/70 border border-border rounded font-mono text-[11px] overflow-hidden">
<div className="flex items-center justify-between px-3 py-1 border-b border-border bg-black/40">
<span className="text-gray-500 text-[10px] uppercase tracking-wide">{label}</span>
<CopyButton text={text} />
</div>
<pre className={`px-3 py-2 whitespace-pre-wrap break-words max-h-96 overflow-auto ${color}`}>
{text}
</pre>
</div>
);
}
// ───────────────────────── Line-numbered code ─────────────────────────
export function LineNumberedCode({
text,
maxHeight = "24rem",
startLine = 1,
label,
}: {
text: string;
maxHeight?: string;
startLine?: number;
label?: string;
}) {
const lines = text.split(/\r?\n/);
return (
<div className="relative bg-black/70 border border-border rounded font-mono text-[11px] overflow-hidden">
{label && (
<div className="flex items-center justify-between px-3 py-1 border-b border-border bg-black/40">
<span className="text-gray-500 text-[10px] uppercase tracking-wide">{label}</span>
<CopyButton text={text} />
</div>
)}
<div className="overflow-auto" style={{ maxHeight }}>
<table className="w-full border-collapse">
<tbody>
{lines.map((line, i) => (
<tr key={i}>
<td className="px-2 text-right text-gray-600 select-none bg-black/40 border-r border-border align-top">
{i + startLine}
</td>
<td className="px-3 text-gray-200 whitespace-pre-wrap break-words">{line}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}
// ───────────────────────── Unified diff ─────────────────────────
export type DiffHunk = {
oldStart: number;
newStart: number;
oldLines: number;
newLines: number;
lines: string[];
};
export function UnifiedDiff({ hunks }: { hunks: DiffHunk[] }) {
if (hunks.length === 0) {
return <p className="text-[11px] text-gray-500 italic">no diff</p>;
}
return (
<div className="relative bg-black/70 border border-border rounded font-mono text-[11px] overflow-hidden">
<div className="overflow-auto max-h-96">
{hunks.map((hunk, i) => (
<HunkView key={i} hunk={hunk} />
))}
</div>
</div>
);
}
function HunkView({ hunk }: { hunk: DiffHunk }) {
let oldLine = hunk.oldStart;
let newLine = hunk.newStart;
return (
<div>
<div className="px-3 py-1 text-[10px] text-cyan-300 bg-cyan-500/10 border-y border-cyan-500/20 font-mono">
@@ -{hunk.oldStart},{hunk.oldLines} +{hunk.newStart},{hunk.newLines} @@
</div>
<table className="w-full border-collapse">
<tbody>
{hunk.lines.map((line, i) => {
const kind = line.startsWith("+") ? "add" : line.startsWith("-") ? "remove" : "ctx";
const body = line.slice(kind === "ctx" ? 0 : 1);
const showOld = kind !== "add";
const showNew = kind !== "remove";
const rowBg =
kind === "add"
? "bg-green-500/10 text-green-200"
: kind === "remove"
? "bg-red-500/10 text-red-200"
: "text-gray-300";
const oldCell = showOld ? oldLine++ : "";
const newCell = showNew ? newLine++ : "";
const sign = kind === "add" ? "+" : kind === "remove" ? "-" : " ";
return (
<tr key={i} className={rowBg}>
<td className="px-2 text-right text-gray-600 select-none border-r border-border/40 w-[36px]">
{oldCell}
</td>
<td className="px-2 text-right text-gray-600 select-none border-r border-border/40 w-[36px]">
{newCell}
</td>
<td className="px-1 text-center select-none w-[16px]">{sign}</td>
<td className="px-2 whitespace-pre-wrap break-words">{body}</td>
</tr>
);
})}
</tbody>
</table>
</div>
);
}
// ───────────────────────── Key-value card ─────────────────────────
export function KeyValueCard({
data,
priority = [],
}: {
data: Record<string, unknown>;
priority?: string[];
}) {
const entries = Object.entries(data);
const priorityEntries = priority
.map((k) => [k, data[k]] as [string, unknown])
.filter(([, v]) => v !== undefined);
const restEntries = entries.filter(([k]) => !priority.includes(k));
const ordered = [...priorityEntries, ...restEntries];
if (ordered.length === 0) {
return <p className="text-[11px] text-gray-500 italic">empty</p>;
}
return (
<table className="w-full text-[11px] border border-border rounded overflow-hidden">
<tbody>
{ordered.map(([k, v], i) => (
<tr key={k} className={i > 0 ? "border-t border-border" : ""}>
<td className="text-gray-500 align-top py-1.5 px-2 font-mono bg-surface-3/60 w-[28%] break-all">
{k}
</td>
<td className="text-gray-300 align-top py-1.5 px-2">
<ValueCell value={v} />
</td>
</tr>
))}
</tbody>
</table>
);
}
function ValueCell({ value }: { value: unknown }) {
if (value == null) return <span className="text-gray-500 italic">null</span>;
if (typeof value === "boolean")
return (
<span
className={`inline-block px-2 py-0.5 rounded border text-[11px] font-mono ${
value
? "text-green-400 border-green-500/30 bg-green-500/10"
: "text-gray-400 border-gray-500/30 bg-gray-500/10"
}`}
>
{String(value)}
</span>
);
if (typeof value === "number") return <span className="font-mono text-gray-300">{value}</span>;
if (typeof value === "string") {
if (value.length > 120 || value.includes("\n")) {
return (
<pre className="bg-surface-3 text-gray-300 text-[11px] font-mono p-2 rounded border border-border whitespace-pre-wrap break-words max-h-48 overflow-auto">
{value}
</pre>
);
}
return <span className="font-mono text-gray-300 break-all">{value}</span>;
}
if (Array.isArray(value)) {
if (value.length === 0) return <span className="text-gray-500 italic">[]</span>;
return (
<ol className="list-decimal pl-4 space-y-1">
{value.map((item, i) => (
<li key={i}>
<ValueCell value={item} />
</li>
))}
</ol>
);
}
return (
<pre className="bg-surface-3 text-gray-300 text-[11px] font-mono p-2 rounded border border-border whitespace-pre-wrap break-words max-h-48 overflow-auto">
{safeStringify(value)}
</pre>
);
}
function safeStringify(value: unknown): string {
try {
return JSON.stringify(value, null, 2);
} catch {
return String(value);
}
}
// ───────────────────────── File list / match list ─────────────────────────
export function FileList({ paths }: { paths: string[] }) {
if (paths.length === 0) return <p className="text-[11px] text-gray-500 italic">no files</p>;
return (
<ul className="divide-y divide-border border border-border rounded overflow-hidden text-[11px] max-h-80 overflow-y-auto">
{paths.map((p, i) => (
<li key={i} className="px-3 py-1 font-mono text-gray-300 break-all">
{p}
</li>
))}
</ul>
);
}
export type GrepMatch = {
file?: string;
line?: number;
text?: string;
};
export function MatchList({ matches }: { matches: GrepMatch[] }) {
if (matches.length === 0) return <p className="text-[11px] text-gray-500 italic">no matches</p>;
return (
<ul className="divide-y divide-border border border-border rounded overflow-hidden text-[11px] max-h-80 overflow-y-auto font-mono">
{matches.map((m, i) => (
<li key={i} className="px-3 py-1 text-gray-300 break-all">
{m.file && <span className="text-cyan-300">{m.file}</span>}
{m.line != null && <span className="text-gray-500">:{m.line}</span>}
{m.text && <span className="text-gray-400">: {m.text}</span>}
</li>
))}
</ul>
);
}