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
+308
View File
@@ -0,0 +1,308 @@
/**
* @file One lane's card: title, stage badge with time-on-phase, progress bar,
* branch/CI/PR facts, the "needs you" banner sourced from Claude Code's
* Notification hook, and the control row. A dead lane (its driving session went
* silent while it should have been working) is called out loudly — that is the
* failure this view exists to catch. An "auto: <stage>" chip appears only when
* the server's detected stage is ahead of the agent's own declaration — when the
* declaration leads or matches, it stays the sole headline.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { DestructiveLaneModal } from "./DestructiveLaneModal";
import { api } from "../../lib/api";
import type { Lane, LaneGitFacts } from "../../lib/types";
/** How often a mounted card re-reads its working-copy facts. Slow on purpose:
* each call is three git subprocesses server-side, and a branch name does not
* change on the timescale the lane list is polled at. */
const GIT_REFRESH_MS = 30_000;
/**
* The lane's own working copy, fetched per card rather than folded into the
* polled lane list. Absent facts are not an error state: a lane may point at a
* plain directory, and it renders as a card without a git row.
*/
function useLaneGitFacts(laneId: number): LaneGitFacts | null {
const [facts, setFacts] = useState<LaneGitFacts | null>(null);
useEffect(() => {
let alive = true;
const read = () => {
api.lanes
.git(laneId)
.then((f) => {
if (alive) setFacts(f);
})
.catch(() => {
// Silent: a card that cannot read git shows the rest of itself. An
// error banner here would fire on every lane on every server blip.
if (alive) setFacts({ available: false });
});
};
read();
const timer = setInterval(read, GIT_REFRESH_MS);
return () => {
alive = false;
clearInterval(timer);
};
}, [laneId]);
return facts;
}
const LIVENESS_DOT: Record<Lane["liveness"], string> = {
active: "bg-emerald-400",
idle: "bg-neutral-500",
dead: "bg-red-500",
};
function since(sec: number | null): string {
if (sec === null) return "—";
if (sec < 60) return `${sec}s`;
if (sec < 3600) return `${Math.floor(sec / 60)}m ${sec % 60}s`;
return `${Math.floor(sec / 3600)}h ${Math.floor((sec % 3600) / 60)}m`;
}
/** Whether `lane.detected_stage` is strictly ahead of `lane.stage` in the
* pipeline's node order. Unknown ids sort as "not found" (-1), so an unmatched
* detected stage never outranks a matched declaration. */
function detectionLeadsDeclaration(lane: Lane): boolean {
if (!lane.detected_stage) return false;
const ids = lane.pipeline_nodes.map((n) => n.id);
return ids.indexOf(lane.detected_stage) > ids.indexOf(lane.stage);
}
export default function LaneCard({
lane,
onAction,
}: {
lane: Lane;
onAction: (action: string, body?: Record<string, unknown>) => void;
}) {
const { t } = useTranslation(["lanes"]);
const [destructiveAction, setDestructiveAction] = useState<"reset" | "remove" | "purge" | null>(
null
);
const [menuOpen, setMenuOpen] = useState(false);
const git = useLaneGitFacts(lane.id);
return (
<>
<div
data-testid={`lane-card-${lane.id}`}
className="flex flex-col rounded-xl border border-neutral-800 bg-neutral-900/70 p-4 transition-colors hover:border-neutral-700"
>
{/* Identity strip: which lane, and is it alive. Kept on one line and in
uppercase so a wall of cards can be scanned vertically. */}
<div className="mb-3 flex items-center justify-between">
<span className="text-[11px] font-semibold uppercase tracking-widest text-neutral-500">
{t("cardId", { id: lane.id })}
</span>
<span className="flex items-center gap-1.5 text-[11px] font-medium uppercase tracking-wide text-neutral-300">
<span className={`h-2 w-2 rounded-full ${LIVENESS_DOT[lane.liveness]}`} />
{/* i18next returns the KEY on a miss, so `|| raw` never fires and a
non-standard status rendered as the literal "status.foo".
defaultValue makes it degrade to the raw status instead. */}
{lane.liveness === "dead"
? t("statusDead")
: t(`status.${lane.status}`, { defaultValue: lane.status })}
</span>
</div>
<h3 className="mb-3 text-[15px] font-semibold leading-snug text-neutral-50">
{lane.title || lane.cwd}
</h3>
{/* Stage line: the declared stage, how far through, and how long it has
been sitting there — the three facts that say whether a lane is
moving. The inferred chip sits beside them, never instead of them. */}
<div className="mb-2 flex items-center gap-2 text-xs">
<span
data-testid="lane-stage"
className="rounded bg-neutral-800 px-2 py-0.5 font-medium text-neutral-200"
>
{lane.stage}
</span>
<div
className={`h-1 flex-1 overflow-hidden rounded-full ${
lane.progress > 0 ? "bg-neutral-800" : "bg-transparent"
}`}
>
<div
data-testid="lane-progress-fill"
className="h-full rounded-full bg-blue-500 transition-[width]"
style={{ width: `${lane.progress}%` }}
/>
</div>
{lane.progress > 0 && (
<span className="tabular-nums text-neutral-400">{lane.progress}%</span>
)}
<span className="tabular-nums text-neutral-600">{since(lane.stage_seconds)}</span>
</div>
<div className="mb-3 flex flex-wrap items-center gap-1.5 text-[11px]">
<span
className={`rounded px-1.5 py-0.5 ${
lane.kind === "managed"
? "bg-blue-500/15 text-blue-300"
: "bg-violet-500/15 text-violet-300"
}`}
>
{t(`kind.${lane.kind}`)}
</span>
{detectionLeadsDeclaration(lane) && (
<span
data-testid="lane-auto-stage"
title={lane.detected_signal || undefined}
className="rounded border border-dashed border-amber-500 px-1.5 py-0.5 text-amber-300"
>
{t("autoStage", { stage: lane.detected_stage })}
</span>
)}
{lane.ci_status && (
<span className="rounded bg-neutral-800 px-1.5 py-0.5 text-neutral-300">
CI {lane.ci_status}
</span>
)}
</div>
{lane.needs_action && (
<div className="mb-3 rounded border border-amber-600/50 bg-amber-500/10 px-2 py-1.5 text-xs text-amber-300">
{lane.needs_action}
</div>
)}
<dl className="mb-3 space-y-1 font-mono text-[11px] text-neutral-400">
{git?.available && (
<div data-testid="lane-git" className="space-y-1">
<div className="truncate">
{git.branch}
<span className="ml-2 text-neutral-500">{git.head}</span>
</div>
<div className="truncate text-neutral-500" title={git.subject}>
{git.subject}
</div>
{(git.dirty > 0 || git.untracked > 0) && (
<div className="text-amber-400/80">
{t("git.uncommitted", { dirty: git.dirty, untracked: git.untracked })}
</div>
)}
</div>
)}
{/* The lane's own recorded branch, shown only when git could not be
read — otherwise it duplicates the live branch above. */}
{!git?.available && lane.branch && <div className="truncate"> {lane.branch}</div>}
<div className="truncate text-neutral-500" title={lane.cwd}>
{lane.cwd}
</div>
</dl>
{/* mt-auto pins the controls to the bottom so cards of differing height
in one grid row still line their buttons up. */}
<div className="mt-auto flex items-center gap-1 border-t border-neutral-800 pt-2 text-xs">
{(["start", "stop", "clear"] as const).map((a) => (
<button
key={a}
type="button"
data-testid={`lane-action-${a}`}
onClick={(e) => {
e.stopPropagation();
onAction(a);
}}
className={`rounded px-2 py-1 transition-colors ${
a === "start"
? "bg-blue-500/15 text-blue-300 hover:bg-blue-500/25"
: "text-neutral-400 hover:bg-neutral-800 hover:text-neutral-200"
}`}
title={a === "start" ? t("tooltipStart") : undefined}
>
{t(`action.${a}`)}
</button>
))}
{/* Destructive verbs sit behind a menu so the card is not a wall of
red. Red is kept for the items inside, where it means something. */}
<div className="relative ml-auto">
<button
type="button"
data-testid="lane-more"
aria-haspopup="menu"
aria-expanded={menuOpen}
onClick={(e) => {
e.stopPropagation();
setMenuOpen((v) => !v);
}}
className="rounded px-2 py-1 text-neutral-500 hover:bg-neutral-800 hover:text-neutral-200"
title={t("moreActions")}
>
</button>
{menuOpen && (
<div
role="menu"
className="absolute right-0 z-10 mt-1 min-w-40 overflow-hidden rounded-md border border-neutral-700 bg-neutral-900 py-1 shadow-lg shadow-black/40"
>
{lane.kind === "managed" && (
<button
type="button"
role="menuitem"
data-testid="lane-action-reset"
onClick={(e) => {
e.stopPropagation();
setMenuOpen(false);
setDestructiveAction("reset");
}}
className="block w-full px-3 py-1.5 text-left text-amber-300 hover:bg-amber-500/10"
>
{t("action.reset")}
</button>
)}
<button
type="button"
role="menuitem"
data-testid="lane-action-remove"
onClick={(e) => {
e.stopPropagation();
setMenuOpen(false);
setDestructiveAction("remove");
}}
className="block w-full px-3 py-1.5 text-left text-red-400 hover:bg-red-500/10"
>
{lane.kind === "adopted" ? t("action.forget") : t("action.remove")}
</button>
<button
type="button"
role="menuitem"
data-testid="lane-action-purge"
onClick={(e) => {
e.stopPropagation();
setMenuOpen(false);
setDestructiveAction("purge");
}}
className="block w-full px-3 py-1.5 text-left text-red-400 hover:bg-red-500/10"
>
{t("action.purge")}
</button>
</div>
)}
</div>
</div>
</div>
{destructiveAction && (
<DestructiveLaneModal
lane={lane}
action={destructiveAction}
open
onClose={() => setDestructiveAction(null)}
onConfirm={(body) => {
setDestructiveAction(null);
onAction(destructiveAction, body);
}}
/>
)}
</>
);
}