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,193 @@
/**
* @file AddLaneModal.tsx
* @description The "+ Add lane" flow: pick a SOURCE repo (not a folder to
* adopt), pick which of its branches to fork from, name the feature, and the
* dashboard provisions a managed git worktree via `POST /api/lanes/worktree`
* — the dashboard invents the lane's own directory and branch name, the same
* way Shipyard's "+ Add lane" never asks a human to name a folder. The lane
* returned is `status: "provisioning"`; the existing `lane_update` WebSocket
* subscription in the Workspace page flips it to idle when the worktree is
* actually ready, so this component does not poll.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useCallback, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { ConfirmModal } from "../ConfirmModal";
import { CwdAutocomplete } from "../run/RunSetup";
import { api } from "../../lib/api";
import type { CwdSuggestion } from "../../lib/api";
import type { Lane } from "../../lib/types";
export function AddLaneModal({
open,
onClose,
onAdded,
cwdSuggestions,
}: {
open: boolean;
onClose: () => void;
/** Called with the newly provisioned (still-provisioning) lane. */
onAdded: (lane: Lane) => void;
/** The same suggestion list the Run form already fetched (dashboard cwd,
* home, recently-used paths) — reused rather than fetched a second time. */
cwdSuggestions: CwdSuggestion[];
}) {
const { t } = useTranslation(["lanes"]);
const [sourceRepo, setSourceRepo] = useState("");
const [title, setTitle] = useState("");
const [branches, setBranches] = useState<string[] | null>(null);
const [base, setBase] = useState("");
const [branchesError, setBranchesError] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const reset = () => {
setSourceRepo("");
setTitle("");
setBranches(null);
setBase("");
setBranchesError(null);
setError(null);
setBusy(false);
};
// Look up the repo's branches once the path settles - debounced so every
// keystroke while typing a path doesn't fire a request against a path that
// isn't finished yet.
const lookedUpFor = useRef<string>("");
useEffect(() => {
const path = sourceRepo.trim();
if (!path) {
setBranches(null);
setBase("");
setBranchesError(null);
return;
}
const timer = window.setTimeout(async () => {
lookedUpFor.current = path;
try {
const r = await api.lanes.branches(path);
if (lookedUpFor.current !== path) return; // a newer path superseded this one
setBranches(r.branches);
setBase(r.current || r.branches[0] || "");
setBranchesError(null);
} catch {
if (lookedUpFor.current !== path) return;
// Not yet a valid repo path (still being typed, or genuinely wrong) -
// quiet by design, the same way CwdAutocomplete never errors either.
setBranches(null);
setBase("");
setBranchesError(t("addLaneNotARepo"));
}
}, 300);
return () => window.clearTimeout(timer);
}, [sourceRepo, t]);
const submit = async () => {
const repo = sourceRepo.trim();
const name = title.trim();
if (!repo || !branches || !name) return;
setBusy(true);
setError(null);
try {
const result = await api.lanes.worktree({
sourceRepo: repo,
title: name,
base: base || undefined,
});
reset();
onAdded(result.lane);
onClose();
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
setBusy(false);
}
};
// ConfirmModal re-focuses its Cancel button in an effect keyed on `onCancel`'s
// identity. Every keystroke in the fields below re-renders this component; an
// inline `() => {...}` handed to `onCancel` would get a new identity each
// time, re-running that effect and yanking focus off the field being typed
// into after the very first character. useCallback keeps the identity stable
// across renders so only mount/unmount (and a real onClose change) refocuses.
const handleCancel = useCallback(() => {
reset();
onClose();
}, [onClose]);
return (
<ConfirmModal
open={open}
title={t("addLane")}
confirmLabel={t("add")}
cancelLabel={t("destructive.cancel")}
destructive={false}
busy={busy}
disabled={!sourceRepo.trim() || !title.trim() || !branches}
onConfirm={submit}
onCancel={handleCancel}
>
<div className="space-y-3">
<div>
<label className="mb-1 block text-xs text-neutral-400" htmlFor="add-lane-repo">
{t("addLaneRepoLabel")}
</label>
<CwdAutocomplete
inputId="add-lane-repo"
value={sourceRepo}
onChange={setSourceRepo}
suggestions={cwdSuggestions}
/>
<p className="mt-1 text-[10px] text-neutral-500">{t("addLaneRepoHint")}</p>
</div>
<div>
<label className="mb-1 block text-xs text-neutral-400" htmlFor="add-lane-title">
{t("addLaneTitleLabel")}
</label>
<input
id="add-lane-title"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder={t("addLaneTitlePlaceholder")}
className="w-full rounded-md border border-neutral-700 bg-neutral-900 px-3 py-1.5 text-xs text-neutral-100 placeholder:text-neutral-600 focus:border-blue-400 focus:outline-none"
/>
</div>
{branches && (
<div>
<label className="mb-1 block text-xs text-neutral-400" htmlFor="add-lane-base">
{t("addLaneBaseLabel")}
</label>
{branches.length === 0 ? (
<p className="text-[10px] text-neutral-500">{t("addLaneNoBranches")}</p>
) : (
<select
id="add-lane-base"
value={base}
onChange={(e) => setBase(e.target.value)}
className="w-full rounded-md border border-neutral-700 bg-neutral-900 px-3 py-1.5 text-xs text-neutral-100 focus:border-blue-400 focus:outline-none"
>
{branches.map((b) => (
<option key={b} value={b}>
{b}
</option>
))}
</select>
)}
</div>
)}
{branchesError && !branches && (
<p className="text-[10px] text-amber-400">{branchesError}</p>
)}
{error && (
<p role="alert" className="text-xs text-red-400">
{error}
</p>
)}
</div>
</ConfirmModal>
);
}
@@ -0,0 +1,229 @@
/**
* @file DestructiveLaneModal.tsx
* @description Fetches and displays the exact lane lifecycle preflight facts
* before delegating confirmation controls and accessibility to ConfirmModal.
*
* The server is the authority on what is permitted; this modal must never be
* stricter than it. A blocker that does not actually stop the chosen action is
* rendered as context (see HARD_BLOCKERS and noticesFor), not as a refusal —
* treating every blocker as fatal is what left "Forget" dead for adopted lanes.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { ConfirmModal } from "../ConfirmModal";
import { api } from "../../lib/api";
import type { Lane, LanePreflight } from "../../lib/types";
type DestructiveAction = "reset" | "remove" | "purge";
export interface DestructiveLaneModalProps {
lane: Lane;
action: DestructiveAction;
open: boolean;
onClose: () => void;
onConfirm: (body: { expect: Record<string, string | number | null>; force?: true }) => void;
}
function expectFor(preflight: LanePreflight): Record<string, string | number | null> {
if (preflight.action === "purge") {
return {
sessions: preflight.sessions,
events: preflight.events,
tokenRows: preflight.tokenRows,
};
}
return {
head: preflight.head,
dirty: preflight.dirty,
untracked: preflight.untracked,
unpushed: preflight.unpushed,
};
}
/**
* Which preflight blockers genuinely prevent each action in the UI.
*
* The server is the authority, and it permits EVERY shape of `remove`: an
* adopted lane's record is forgotten with its directory untouched, a
* hand-deleted worktree takes the prune path, an unreadable one is force
* -removed. So `remove` is blocked here by nothing — treating `adopted` or
* `missing` as blockers left the Forget button permanently dead with no
* fallback. `reset` really is impossible in all three shapes.
*
* `unpushed-commits` is force-overridable and is gated by the Force checkbox
* instead of by this list.
*/
const HARD_BLOCKERS: Record<"reset" | "remove", readonly string[]> = {
reset: ["adopted", "missing", "unreadable"],
remove: [],
};
function blockingReason(preflight: LanePreflight | null): string | null {
if (!preflight || preflight.action === "purge") return null;
const hard = HARD_BLOCKERS[preflight.action];
return preflight.blocked.find((blocker) => hard.includes(blocker)) || null;
}
/**
* Blockers that do not prevent THIS action but still change what it does, shown
* as context rather than as an obstacle — e.g. forgetting an adopted lane leaves
* its directory alone. `unpushed-commits` is excluded: the Force copy covers it.
*/
function noticesFor(preflight: LanePreflight | null): string[] {
if (!preflight || preflight.action === "purge") return [];
const hard = HARD_BLOCKERS[preflight.action];
return preflight.blocked.filter(
(blocker) => blocker !== "unpushed-commits" && !hard.includes(blocker)
);
}
/** Rough byte estimate as a short human string; the server's own number is a guess. */
function formatBytes(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
function warningsFor(preflight: LanePreflight | null): string[] {
if (!preflight || preflight.action === "purge") return [];
return preflight.warnings;
}
export function DestructiveLaneModal({
lane,
action,
open,
onClose,
onConfirm,
}: DestructiveLaneModalProps) {
const { t } = useTranslation(["lanes"]);
const [preflight, setPreflight] = useState<LanePreflight | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [force, setForce] = useState(false);
useEffect(() => {
if (!open) return;
let current = true;
setLoading(true);
setError(null);
setPreflight(null);
setForce(false);
void api.lanes
.preflight(lane.id, action)
.then((result) => current && setPreflight(result))
.catch((err) => current && setError(err instanceof Error ? err.message : t("preflightError")))
.finally(() => current && setLoading(false));
return () => {
current = false;
};
}, [action, lane.id, open, t]);
const blocked = blockingReason(preflight);
// Mirrors the server's gate exactly (server/routes/lanes.js): force is required
// for `reset`, and for `remove` only when a real worktree is at risk. Forgetting
// an adopted lane risks nothing on disk, so it needs no force even with unpushed
// commits. Never offered alongside a hard blocker, where confirming is
// impossible anyway.
const requiresForce =
!blocked &&
preflight !== null &&
preflight.action !== "purge" &&
preflight.blocked.includes("unpushed-commits") &&
(preflight.action === "reset" || preflight.kind === "managed");
const disabled =
loading || !preflight || Boolean(error) || Boolean(blocked) || (requiresForce && !force);
const facts = preflight ? Object.entries(expectFor(preflight)) : [];
const purge = preflight?.action === "purge" ? preflight : null;
return (
<ConfirmModal
open={open}
title={t(`destructive.${action}.title`)}
message={t(`destructive.${action}.message`)}
confirmLabel={t(`destructive.${action}.confirm`)}
cancelLabel={t("destructive.cancel")}
busy={loading}
disabled={disabled}
onCancel={onClose}
onConfirm={() => {
if (!preflight || disabled) return;
onConfirm({ expect: expectFor(preflight), ...(force ? { force: true as const } : {}) });
}}
>
{loading && <p className="mt-3 text-xs text-neutral-400">{t("destructive.loading")}</p>}
{error && (
<p className="mt-3 text-xs text-red-300">
{t("preflightErrorWithMessage", { message: error })}
</p>
)}
{preflight && (
<table className="mt-3 w-full text-left text-xs text-neutral-300">
<tbody>
{facts.map(([name, value]) => (
<tr key={name} className="border-t border-neutral-800">
<th scope="row" className="py-1.5 font-medium text-neutral-400">
{t(`destructive.count.${name}`)}
</th>
<td className="py-1.5 text-right tabular-nums">{value ?? "—"}</td>
</tr>
))}
{/* Not part of `expect`: an estimate the server never verifies. */}
{purge && (
<tr className="border-t border-neutral-800">
<th scope="row" className="py-1.5 font-medium text-neutral-400">
{t("destructive.count.bytesEstimate")}
</th>
<td className="py-1.5 text-right tabular-nums">
{formatBytes(purge.bytesEstimate)}
</td>
</tr>
)}
</tbody>
</table>
)}
{blocked && (
<p className="mt-3 rounded border border-amber-700/60 bg-amber-950/30 px-2 py-1.5 text-xs text-amber-200">
{t(`destructive.blocked.${blocked}`)}
</p>
)}
{purge?.activeSessionSkipped && (
<p className="mt-3 rounded border border-neutral-700 bg-neutral-800/50 px-2 py-1.5 text-xs text-neutral-300">
{t("destructive.notice.activeSessionSkipped")}
</p>
)}
{noticesFor(preflight).map((notice) => (
<p
key={notice}
className="mt-3 rounded border border-neutral-700 bg-neutral-800/50 px-2 py-1.5 text-xs text-neutral-300"
>
{t(`destructive.notice.${notice}`)}
</p>
))}
{warningsFor(preflight).map((warning) => (
<p
key={warning}
className="mt-3 rounded border border-neutral-700 bg-neutral-800/50 px-2 py-1.5 text-xs text-neutral-300"
>
{t(`destructive.warning.${warning}`)}
</p>
))}
{requiresForce && (
<label className="mt-3 flex items-start gap-2 text-xs text-amber-200">
<input
type="checkbox"
checked={force}
onChange={(event) => setForce(event.target.checked)}
className="mt-0.5"
/>
{t("destructive.force")}
</label>
)}
{action === "reset" && (
<p className="mt-3 text-xs text-neutral-400">{t("destructive.reset.survives")}</p>
)}
</ConfirmModal>
);
}
+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);
}}
/>
)}
</>
);
}
@@ -0,0 +1,92 @@
/**
* @file The compact lane tile used in the Workspace carousel. It carries only
* what you need to pick a lane — which lane, is it alive, what stage, how far —
* because the full card, its controls and its working-copy facts live in the
* detail panel below. Keeping the tile small is what lets a dozen lanes stay
* scannable in one horizontal row.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useTranslation } from "react-i18next";
import type { Lane } from "../../lib/types";
const LIVENESS_DOT: Record<Lane["liveness"], string> = {
active: "bg-emerald-400",
idle: "bg-neutral-500",
dead: "bg-red-500",
};
/** Whether the inferred stage sits ahead of the declared one in node order. */
function detectionLeads(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 LaneStripCard({
lane,
selected,
onSelect,
}: {
lane: Lane;
selected: boolean;
onSelect: () => void;
}) {
const { t } = useTranslation(["lanes"]);
return (
<button
type="button"
data-testid={`lane-tile-${lane.id}`}
data-selected={selected ? "true" : undefined}
aria-pressed={selected}
onClick={onSelect}
title={lane.cwd}
className={`w-56 shrink-0 snap-start rounded-lg border p-3 text-left transition-colors ${
selected
? "border-blue-400/70 bg-blue-500/[0.07]"
: "border-neutral-800 bg-neutral-900/60 hover:border-neutral-700"
}`}
>
<div className="mb-1.5 flex items-center gap-1.5">
<span className={`h-2 w-2 shrink-0 rounded-full ${LIVENESS_DOT[lane.liveness]}`} />
<span className="text-[10px] font-semibold uppercase tracking-widest text-neutral-500">
{t("cardId", { id: lane.id })}
</span>
{lane.needs_action && (
<span className="ml-auto text-amber-400" title={lane.needs_action}>
</span>
)}
</div>
<div className="mb-2 truncate text-[13px] font-medium text-neutral-100">
{lane.title || lane.cwd}
</div>
<div className="flex items-center gap-1.5 text-[11px]">
<span className="truncate rounded bg-neutral-800 px-1.5 py-0.5 text-neutral-300">
{lane.stage}
</span>
{detectionLeads(lane) && (
<span
data-testid={`lane-tile-auto-${lane.id}`}
title={lane.detected_signal || undefined}
className="shrink-0 rounded border border-dashed border-amber-500 px-1 text-amber-300"
>
{lane.detected_stage}
</span>
)}
{lane.progress > 0 && (
<span className="ml-auto shrink-0 tabular-nums text-neutral-500">{lane.progress}%</span>
)}
</div>
{lane.progress > 0 && (
<div className="mt-1.5 h-0.5 overflow-hidden rounded-full bg-neutral-800">
<div className="h-full rounded-full bg-blue-500" style={{ width: `${lane.progress}%` }} />
</div>
)}
</button>
);
}
@@ -0,0 +1,62 @@
/**
* @file The lane pipeline map: a horizontal chain of stage nodes coloured by
* state. Layout is computed from the node list (flex + connectors), never from
* hardcoded coordinates, so a lane can use a longer or shorter template without
* touching this component. "passed without evidence" is deliberately its own
* colour: a stage the agent claimed but left no artifact for is not the same as
* a stage that is genuinely done. A `detected` node (the server's heuristic saw
* tool-event evidence but the agent never declared it) gets a FOURTH treatment —
* dashed amber, overriding whatever `state` it carries — because it must never
* be mistaken for the solid green of a real "done".
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import type { LaneNode } from "../../lib/types";
const STATE_CLASS: Record<LaneNode["state"], string> = {
done: "border-emerald-500 text-emerald-400 bg-emerald-500/10",
current: "border-blue-400 text-blue-300 bg-blue-500/20 ring-2 ring-blue-400/40",
"passed-no-evidence": "border-amber-500 text-amber-400 bg-amber-500/10",
failed: "border-red-500 text-red-400 bg-red-500/10",
pending: "border-neutral-700 text-neutral-500 bg-transparent",
};
// Dashed border distinguishes an inferred stage from every other class above,
// including the solid amber of "passed-no-evidence" — never let it read as done.
const DETECTED_CLASS = "border-dashed border-amber-400 text-amber-300 bg-amber-500/5";
export default function PipelineMap({
nodes,
detectedSignal,
}: {
nodes: LaneNode[];
detectedSignal?: string | null;
}) {
if (!nodes.length) return <div className="text-xs text-neutral-500">no pipeline</div>;
return (
// The map spans the panel: every node takes an equal share and the
// connectors absorb the slack, so the pipeline reads as one track across
// the width rather than a short cluster hugging the left edge.
<div className="flex w-full items-center py-1">
{nodes.map((n, i) => (
<div key={n.id} className="flex min-w-0 flex-1 items-center">
<div
data-testid={`pipeline-node-${n.id}`}
data-state={n.state}
data-detected={n.detected ? "true" : undefined}
title={
n.detected && detectedSignal
? `${n.label}${detectedSignal}`
: `${n.label}${n.state}`
}
className={`flex min-w-0 flex-1 items-center justify-center gap-1.5 rounded-full border px-2 py-1.5 text-[11px] ${n.detected ? DETECTED_CLASS : STATE_CLASS[n.state]}`}
>
<span className="shrink-0 text-[13px] leading-none">{n.icon}</span>
<span className="truncate">{n.label}</span>
</div>
{i < nodes.length - 1 && <div className="h-px w-2 shrink-0 bg-neutral-700 sm:w-3" />}
</div>
))}
</div>
);
}
@@ -0,0 +1,172 @@
/**
* @file AddLaneModal.test.tsx
* @description Pins the "+ Add lane" flow after it was rebuilt around a source
* repo instead of an existing folder: picking or typing a repo path triggers a
* branch lookup, the base-branch picker only appears once that lookup resolves,
* confirm submits through the provisioning endpoint (not the adopt/ensure one),
* an unresolvable path degrades to a quiet hint instead of blocking the form,
* and a server error surfaces instead of closing the modal.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { AddLaneModal } from "../AddLaneModal";
import { api } from "../../../lib/api";
import type { CwdSuggestion } from "../../../lib/api";
import type { Lane } from "../../../lib/types";
vi.mock("../../../lib/api", () => ({
api: { lanes: { branches: vi.fn(), worktree: vi.fn() } },
}));
function laneFixture(over: Partial<Lane> = {}): Lane {
return {
id: 9,
title: "",
cwd: "/lanes/repo__feature",
branch: "feat/feature",
kind: "managed",
pipeline: "default",
session_id: null,
run_id: null,
stage: "idle",
stage_since: null,
status: "provisioning",
gate_decision: null,
ci_status: null,
needs_action: null,
links: {},
stages: {},
notes: null,
pipeline_name: "Default",
pipeline_nodes: [],
progress: 0,
stage_seconds: null,
last_event_seconds: null,
liveness: "idle",
detected_stage: null,
detected_signal: null,
...over,
};
}
const SUGGESTIONS: CwdSuggestion[] = [
{ kind: "home", path: "/Users/tester", label: "Home" },
{ kind: "recent", path: "/Users/tester/projects/repo", label: "repo" },
];
function renderModal(over: Partial<React.ComponentProps<typeof AddLaneModal>> = {}) {
return render(
<AddLaneModal
open
onClose={vi.fn()}
onAdded={vi.fn()}
cwdSuggestions={SUGGESTIONS}
{...over}
/>
);
}
/** ConfirmModal focuses its Cancel button on a 0ms timer after mount, which
* races userEvent.type() and can eat the first keystroke. Let that timer fire,
* then click the field to reclaim focus before typing. */
async function focusField(user: ReturnType<typeof userEvent.setup>, el: HTMLElement) {
await new Promise((r) => setTimeout(r, 0));
await user.click(el);
}
beforeEach(() => {
vi.mocked(api.lanes.branches).mockReset();
vi.mocked(api.lanes.worktree).mockReset();
});
describe("AddLaneModal", () => {
it("disables confirm until a repo, a title, and a resolved branch list are all present", () => {
renderModal();
expect(screen.getByRole("button", { name: "Add lane" })).toBeDisabled();
});
it("looks up branches once the repo path settles, and shows them as a picker", async () => {
vi.mocked(api.lanes.branches).mockResolvedValue({
branches: ["main", "feat/other"],
current: "main",
});
renderModal();
const user = userEvent.setup();
const repoField = screen.getByLabelText("Source repository");
await focusField(user, repoField);
await user.type(repoField, "/Users/tester/projects/repo");
await waitFor(() => expect(api.lanes.branches).toHaveBeenCalledWith("/Users/tester/projects/repo"));
const base = await screen.findByLabelText("Branch to fork from");
expect(base).toHaveValue("main"); // the repo's current branch is preselected
expect(screen.getByRole("option", { name: "feat/other" })).toBeInTheDocument();
});
it("stays disabled and shows a quiet hint when the path is not a resolvable repo", async () => {
vi.mocked(api.lanes.branches).mockRejectedValue(new Error("EBADSOURCEREPO"));
renderModal();
const user = userEvent.setup();
const repoField = screen.getByLabelText("Source repository");
await focusField(user, repoField);
await user.type(repoField, "/not/a/repo");
await waitFor(() => expect(api.lanes.branches).toHaveBeenCalled());
expect(await screen.findByText(/Not a git repository/)).toBeInTheDocument();
expect(screen.queryByLabelText("Branch to fork from")).toBeNull();
expect(screen.getByRole("button", { name: "Add lane" })).toBeDisabled();
});
it("submits through the worktree provisioning endpoint, not ensure", async () => {
vi.mocked(api.lanes.branches).mockResolvedValue({ branches: ["main"], current: "main" });
vi.mocked(api.lanes.worktree).mockResolvedValue({ lane: laneFixture({ id: 9 }) });
const onAdded = vi.fn();
const onClose = vi.fn();
renderModal({ onClose, onAdded });
const user = userEvent.setup();
const repoField = screen.getByLabelText("Source repository");
await focusField(user, repoField);
await user.type(repoField, "/Users/tester/projects/repo");
await screen.findByLabelText("Branch to fork from");
await user.type(screen.getByLabelText("Title"), "New feature");
await user.click(screen.getByRole("button", { name: "Add lane" }));
await waitFor(() => {
expect(api.lanes.worktree).toHaveBeenCalledWith({
sourceRepo: "/Users/tester/projects/repo",
title: "New feature",
base: "main",
});
});
expect(onAdded).toHaveBeenCalledWith(expect.objectContaining({ id: 9, status: "provisioning" }));
expect(onClose).toHaveBeenCalled();
});
it("shows a server error and leaves the modal open instead of closing silently", async () => {
vi.mocked(api.lanes.branches).mockResolvedValue({ branches: ["main"], current: "main" });
vi.mocked(api.lanes.worktree).mockRejectedValue(new Error("EWORKTREEDIRCOLLISION"));
const onClose = vi.fn();
renderModal({ onClose });
const user = userEvent.setup();
const repoField = screen.getByLabelText("Source repository");
await focusField(user, repoField);
await user.type(repoField, "/Users/tester/projects/repo");
await screen.findByLabelText("Branch to fork from");
await user.type(screen.getByLabelText("Title"), "New feature");
await user.click(screen.getByRole("button", { name: "Add lane" }));
expect(await screen.findByText("EWORKTREEDIRCOLLISION")).toBeInTheDocument();
expect(onClose).not.toHaveBeenCalled();
});
it("renders nothing when closed", () => {
renderModal({ open: false });
expect(screen.queryByRole("dialog")).toBeNull();
});
});
@@ -0,0 +1,457 @@
/**
* @file Tests for DestructiveLaneModal: the preflight-gated confirmation for
* reset/remove/purge. Covers that the displayed counts are exactly the
* preflight facts, that `reset` is refused for adopted/missing/unreadable
* lanes while `remove` stays available for all of them (the server permits it,
* so the UI must not be stricter), that the Force checkbox appears exactly when
* the server would demand force, and that confirming echoes back exactly the
* `expect` block the modal displayed.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
const { preflightMock } = vi.hoisted(() => ({ preflightMock: vi.fn() }));
vi.mock("../../../lib/api", () => ({
api: { lanes: { preflight: preflightMock } },
}));
import { DestructiveLaneModal } from "../DestructiveLaneModal";
import type { Lane, LanePurgePreflight, LaneWorktreePreflight } from "../../../lib/types";
function makeLane(overrides: Partial<Lane> = {}): Lane {
return {
id: 1,
title: "demo",
cwd: "/work/demo",
branch: "lane/demo",
kind: "managed",
pipeline: "default",
session_id: null,
run_id: null,
stage: "plan",
stage_since: null,
status: "idle",
gate_decision: null,
ci_status: null,
needs_action: null,
links: {},
stages: {},
notes: null,
pipeline_name: "Default",
pipeline_nodes: [],
progress: 0,
stage_seconds: null,
last_event_seconds: null,
liveness: "idle",
detected_stage: null,
detected_signal: null,
...overrides,
};
}
function worktreePreflight(overrides: Partial<LaneWorktreePreflight> = {}): LaneWorktreePreflight {
return {
action: "reset",
lane: 1,
kind: "managed",
branch: "lane/demo",
head: "abc1234",
dirty: 2,
untracked: 3,
unpushed: 0,
blocked: [],
warnings: [],
...overrides,
};
}
beforeEach(() => {
preflightMock.mockReset();
});
describe("DestructiveLaneModal", () => {
it("renders exactly the counts the preflight returned", async () => {
preflightMock.mockResolvedValue(
worktreePreflight({ head: "deadbee", dirty: 4, untracked: 5, unpushed: 0 })
);
render(
<DestructiveLaneModal
lane={makeLane()}
action="reset"
open
onClose={vi.fn()}
onConfirm={vi.fn()}
/>
);
expect(await screen.findByText("deadbee")).toBeInTheDocument();
expect(screen.getByText("4")).toBeInTheDocument();
expect(screen.getByText("5")).toBeInTheDocument();
});
it("disables RESET for an adopted lane (a worktree action can never touch it)", async () => {
preflightMock.mockResolvedValue(
worktreePreflight({ action: "reset", kind: "adopted", blocked: ["adopted"] })
);
render(
<DestructiveLaneModal
lane={makeLane({ kind: "adopted" })}
action="reset"
open
onClose={vi.fn()}
onConfirm={vi.fn()}
/>
);
const confirmButton = await screen.findByRole("button", { name: "Reset worktree" });
await waitFor(() => expect(confirmButton).toBeDisabled());
});
it("ENABLES remove for an adopted lane and sends the payload — the server forgets the row and leaves the directory alone", async () => {
preflightMock.mockResolvedValue(
worktreePreflight({
action: "remove",
kind: "adopted",
head: "adopt01",
dirty: 0,
untracked: 0,
unpushed: 0,
blocked: ["adopted"],
})
);
const onConfirm = vi.fn();
render(
<DestructiveLaneModal
lane={makeLane({ kind: "adopted" })}
action="remove"
open
onClose={vi.fn()}
onConfirm={onConfirm}
/>
);
const confirmButton = await screen.findByRole("button", { name: "Remove lane" });
await waitFor(() => expect(confirmButton).not.toBeDisabled());
// "adopted" is shown as context, not as a refusal.
expect(screen.getByText(/Only the dashboard's record of it is dropped/)).toBeInTheDocument();
fireEvent.click(confirmButton);
expect(onConfirm).toHaveBeenCalledWith({
expect: { head: "adopt01", dirty: 0, untracked: 0, unpushed: 0 },
});
});
it("forgets an adopted lane with unpushed commits without offering Force — the server does not require it", async () => {
preflightMock.mockResolvedValue(
worktreePreflight({
action: "remove",
kind: "adopted",
head: "adopt02",
dirty: 0,
untracked: 0,
unpushed: 7,
blocked: ["adopted", "unpushed-commits"],
})
);
const onConfirm = vi.fn();
render(
<DestructiveLaneModal
lane={makeLane({ kind: "adopted" })}
action="remove"
open
onClose={vi.fn()}
onConfirm={onConfirm}
/>
);
const confirmButton = await screen.findByRole("button", { name: "Remove lane" });
await waitFor(() => expect(confirmButton).not.toBeDisabled());
expect(screen.queryByRole("checkbox")).not.toBeInTheDocument();
fireEvent.click(confirmButton);
expect(onConfirm).toHaveBeenCalledWith({
expect: { head: "adopt02", dirty: 0, untracked: 0, unpushed: 7 },
});
});
it("disables RESET when the worktree directory is missing", async () => {
preflightMock.mockResolvedValue(worktreePreflight({ action: "reset", blocked: ["missing"] }));
render(
<DestructiveLaneModal
lane={makeLane()}
action="reset"
open
onClose={vi.fn()}
onConfirm={vi.fn()}
/>
);
const confirmButton = await screen.findByRole("button", { name: "Reset worktree" });
await waitFor(() => expect(confirmButton).toBeDisabled());
});
it("ENABLES remove when the worktree directory is missing — the server takes the prune path", async () => {
preflightMock.mockResolvedValue(
worktreePreflight({
action: "remove",
head: null,
dirty: 0,
untracked: 0,
unpushed: 0,
blocked: ["missing"],
})
);
const onConfirm = vi.fn();
render(
<DestructiveLaneModal
lane={makeLane()}
action="remove"
open
onClose={vi.fn()}
onConfirm={onConfirm}
/>
);
const confirmButton = await screen.findByRole("button", { name: "Remove lane" });
await waitFor(() => expect(confirmButton).not.toBeDisabled());
expect(screen.getByText(/The lane directory is already gone/)).toBeInTheDocument();
fireEvent.click(confirmButton);
expect(onConfirm).toHaveBeenCalledWith({
expect: { head: null, dirty: 0, untracked: 0, unpushed: 0 },
});
});
it("disables RESET when the worktree directory is unreadable", async () => {
preflightMock.mockResolvedValue(
worktreePreflight({ action: "reset", blocked: ["unreadable"] })
);
render(
<DestructiveLaneModal
lane={makeLane()}
action="reset"
open
onClose={vi.fn()}
onConfirm={vi.fn()}
/>
);
const confirmButton = await screen.findByRole("button", { name: "Reset worktree" });
await waitFor(() => expect(confirmButton).toBeDisabled());
});
it("ENABLES remove when the worktree directory is unreadable — the server attempts removal and falls back to deregistering it", async () => {
preflightMock.mockResolvedValue(
worktreePreflight({
action: "remove",
head: null,
dirty: 0,
untracked: 0,
unpushed: 0,
blocked: ["unreadable"],
})
);
const onConfirm = vi.fn();
render(
<DestructiveLaneModal
lane={makeLane()}
action="remove"
open
onClose={vi.fn()}
onConfirm={onConfirm}
/>
);
const confirmButton = await screen.findByRole("button", { name: "Remove lane" });
await waitFor(() => expect(confirmButton).not.toBeDisabled());
expect(screen.getByText(/cannot be read as a Git worktree/)).toBeInTheDocument();
fireEvent.click(confirmButton);
expect(onConfirm).toHaveBeenCalledWith({
expect: { head: null, dirty: 0, untracked: 0, unpushed: 0 },
});
});
it("shows the Force checkbox only when the sole blocker is unpushed commits, and ticking it enables confirm", async () => {
preflightMock.mockResolvedValue(
worktreePreflight({ action: "remove", unpushed: 3, blocked: ["unpushed-commits"] })
);
render(
<DestructiveLaneModal
lane={makeLane()}
action="remove"
open
onClose={vi.fn()}
onConfirm={vi.fn()}
/>
);
const confirmButton = await screen.findByRole("button", { name: "Remove lane" });
await waitFor(() => expect(confirmButton).toBeDisabled());
fireEvent.click(screen.getByRole("checkbox"));
expect(confirmButton).not.toBeDisabled();
});
it("a local-only managed lane (no-remote warning, unpushed-commits blocker) is resettable with Force", async () => {
preflightMock.mockResolvedValue(
worktreePreflight({
action: "reset",
unpushed: 3,
blocked: ["unpushed-commits"],
warnings: ["no-remote"],
})
);
render(
<DestructiveLaneModal
lane={makeLane()}
action="reset"
open
onClose={vi.fn()}
onConfirm={vi.fn()}
/>
);
// The warning is shown as context, not as an obstacle.
expect(await screen.findByText(/No Git remote is configured/)).toBeInTheDocument();
const confirmButton = await screen.findByRole("button", { name: "Reset worktree" });
await waitFor(() => expect(confirmButton).toBeDisabled());
fireEvent.click(screen.getByRole("checkbox"));
expect(confirmButton).not.toBeDisabled();
});
it("does not offer the Force checkbox when a hard blocker makes confirming impossible anyway", async () => {
preflightMock.mockResolvedValue(
worktreePreflight({ action: "reset", unpushed: 3, blocked: ["unpushed-commits", "missing"] })
);
render(
<DestructiveLaneModal
lane={makeLane()}
action="reset"
open
onClose={vi.fn()}
onConfirm={vi.fn()}
/>
);
const confirmButton = await screen.findByRole("button", { name: "Reset worktree" });
await waitFor(() => expect(confirmButton).toBeDisabled());
expect(screen.queryByRole("checkbox")).not.toBeInTheDocument();
});
it("confirming a worktree action passes back exactly the expect block that was displayed", async () => {
preflightMock.mockResolvedValue(
worktreePreflight({ head: "cafefeed", dirty: 1, untracked: 2, unpushed: 0, blocked: [] })
);
const onConfirm = vi.fn();
render(
<DestructiveLaneModal
lane={makeLane()}
action="reset"
open
onClose={vi.fn()}
onConfirm={onConfirm}
/>
);
const confirmButton = await screen.findByRole("button", { name: "Reset worktree" });
await waitFor(() => expect(confirmButton).not.toBeDisabled());
fireEvent.click(confirmButton);
expect(onConfirm).toHaveBeenCalledWith({
expect: { head: "cafefeed", dirty: 1, untracked: 2, unpushed: 0 },
});
});
it("confirming an unpushed-commits removal with Force ticked sends force:true plus the same expect block", async () => {
preflightMock.mockResolvedValue(
worktreePreflight({
action: "remove",
head: "abc0000",
dirty: 0,
untracked: 0,
unpushed: 2,
blocked: ["unpushed-commits"],
})
);
const onConfirm = vi.fn();
render(
<DestructiveLaneModal
lane={makeLane()}
action="remove"
open
onClose={vi.fn()}
onConfirm={onConfirm}
/>
);
const confirmButton = await screen.findByRole("button", { name: "Remove lane" });
fireEvent.click(screen.getByRole("checkbox"));
await waitFor(() => expect(confirmButton).not.toBeDisabled());
fireEvent.click(confirmButton);
expect(onConfirm).toHaveBeenCalledWith({
expect: { head: "abc0000", dirty: 0, untracked: 0, unpushed: 2 },
force: true,
});
});
it("confirming a purge passes back the purge-specific expect block", async () => {
const purgePreflight: LanePurgePreflight = {
action: "purge",
lane: 1,
sessions: 4,
events: 120,
tokenRows: 30,
bytesEstimate: 4096,
activeSessionSkipped: false,
};
preflightMock.mockResolvedValue(purgePreflight);
const onConfirm = vi.fn();
render(
<DestructiveLaneModal
lane={makeLane()}
action="purge"
open
onClose={vi.fn()}
onConfirm={onConfirm}
/>
);
const confirmButton = await screen.findByRole("button", { name: "Purge history" });
await waitFor(() => expect(confirmButton).not.toBeDisabled());
fireEvent.click(confirmButton);
expect(onConfirm).toHaveBeenCalledWith({
expect: { sessions: 4, events: 120, tokenRows: 30 },
});
});
it("shows the purge size estimate and says when a live session was spared", async () => {
const purgePreflight: LanePurgePreflight = {
action: "purge",
lane: 1,
sessions: 2,
events: 8,
tokenRows: 2,
bytesEstimate: 5120,
activeSessionSkipped: true,
};
preflightMock.mockResolvedValue(purgePreflight);
render(
<DestructiveLaneModal
lane={makeLane()}
action="purge"
open
onClose={vi.fn()}
onConfirm={vi.fn()}
/>
);
expect(await screen.findByText("5.0 KB")).toBeInTheDocument();
expect(screen.getByText(/still active and will be kept/)).toBeInTheDocument();
});
it("does not claim a session was spared when none was", async () => {
const purgePreflight: LanePurgePreflight = {
action: "purge",
lane: 1,
sessions: 1,
events: 1,
tokenRows: 0,
bytesEstimate: 512,
activeSessionSkipped: false,
};
preflightMock.mockResolvedValue(purgePreflight);
render(
<DestructiveLaneModal
lane={makeLane()}
action="purge"
open
onClose={vi.fn()}
onConfirm={vi.fn()}
/>
);
expect(await screen.findByText("512 B")).toBeInTheDocument();
expect(screen.queryByText(/still active and will be kept/)).not.toBeInTheDocument();
});
});
@@ -0,0 +1,262 @@
/**
* @file Regression test for the lane card's status badge. Every lane status
* the server can set must have a real translated word behind
* `t("status." + lane.status)`; before this test existed, no locale defined
* any `status.*` key, and i18next's default missing-key behavior (return the
* key itself) hid that from the `||` fallback, so the badge showed literal
* text like `status.active`.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import LaneCard from "../LaneCard";
import type { Lane } from "../../../lib/types";
import { api } from "../../../lib/api";
vi.mock("../../../lib/api", () => ({
api: {
lanes: { git: vi.fn(), preflight: vi.fn().mockResolvedValue({ blocked: [], warnings: [] }) },
},
}));
beforeEach(() => {
vi.mocked(api.lanes.git).mockReset();
vi.mocked(api.lanes.git).mockResolvedValue({ available: false });
});
function makeLane(overrides: Partial<Lane> = {}): Lane {
return {
id: 1,
title: "demo",
cwd: "/work/demo",
branch: "lane/demo",
kind: "adopted",
pipeline: "default",
session_id: null,
run_id: null,
stage: "plan",
stage_since: null,
status: "idle",
gate_decision: null,
ci_status: null,
needs_action: null,
links: {},
stages: {},
notes: null,
pipeline_name: "Default",
pipeline_nodes: [],
progress: 0,
stage_seconds: null,
last_event_seconds: null,
liveness: "idle",
detected_stage: null,
detected_signal: null,
...overrides,
};
}
describe("LaneCard status badge", () => {
for (const status of ["idle", "running", "provisioning", "failed"] as const) {
it(`renders a real word for status "${status}", not the raw key`, () => {
render(<LaneCard lane={makeLane({ status })} onAction={vi.fn()} />);
expect(screen.queryByText(`status.${status}`)).not.toBeInTheDocument();
expect(screen.queryByText(status.toUpperCase())).not.toBeInTheDocument();
});
}
});
const pipelineNodes: Lane["pipeline_nodes"] = [
{ id: "intake", label: "intake", icon: "📥", gate: false, state: "done" },
{ id: "plan", label: "plan", icon: "🧭", gate: false, state: "done" },
{ id: "implement", label: "implement", icon: "🛠", gate: false, state: "current" },
{ id: "tests", label: "tests", icon: "🧪", gate: false, state: "pending" },
];
describe("LaneCard auto: chip", () => {
it("shows the auto chip when the detected stage is ahead of the declared stage", () => {
render(
<LaneCard
lane={makeLane({ stage: "plan", pipeline_nodes: pipelineNodes, detected_stage: "tests" })}
onAction={vi.fn()}
/>
);
expect(screen.getByText("auto: tests")).toBeInTheDocument();
});
it("hides the auto chip when the detected stage matches the declared stage", () => {
render(
<LaneCard
lane={makeLane({ stage: "plan", pipeline_nodes: pipelineNodes, detected_stage: "plan" })}
onAction={vi.fn()}
/>
);
expect(screen.queryByText("auto: plan")).not.toBeInTheDocument();
});
it("hides the auto chip when the detected stage trails the declared stage", () => {
render(
<LaneCard
lane={makeLane({
stage: "implement",
pipeline_nodes: pipelineNodes,
detected_stage: "intake",
})}
onAction={vi.fn()}
/>
);
expect(screen.queryByText("auto: intake")).not.toBeInTheDocument();
});
it("hides the auto chip when nothing is detected", () => {
render(
<LaneCard
lane={makeLane({ stage: "plan", pipeline_nodes: pipelineNodes, detected_stage: null })}
onAction={vi.fn()}
/>
);
expect(screen.queryByText(/^auto:/)).not.toBeInTheDocument();
});
});
describe("LaneCard rebuilt layout", () => {
const full = (over: Partial<Lane> = {}) =>
makeLane({
id: 7,
title: "Rename Metric to Rule",
kind: "managed",
status: "running",
liveness: "active",
stage: "plan",
stage_seconds: 152,
progress: 48,
ci_status: "green",
pipeline_nodes: pipelineNodes,
...over,
});
it("labels the card with the lane id", () => {
render(<LaneCard lane={full()} onAction={vi.fn()} />);
expect(screen.getByTestId("lane-card-7")).toBeInTheDocument();
});
it("shows the declared stage, the progress percentage and the time on stage", () => {
render(<LaneCard lane={full()} onAction={vi.fn()} />);
expect(screen.getByTestId("lane-stage").textContent).toBe("plan");
expect(screen.getByText("48%")).toBeInTheDocument();
expect(screen.getByText("2m 32s")).toBeInTheDocument();
});
it("gives the progress bar a width matching the lane's progress", () => {
render(<LaneCard lane={full()} onAction={vi.fn()} />);
expect(screen.getByTestId("lane-progress-fill").getAttribute("style")).toContain("48%");
});
it("surfaces a needs-you message", () => {
render(<LaneCard lane={full({ needs_action: "waiting on approval" })} onAction={vi.fn()} />);
expect(screen.getByText(/waiting on approval/)).toBeInTheDocument();
});
it("fires a plain action with its own name", async () => {
const onAction = vi.fn();
render(<LaneCard lane={full()} onAction={onAction} />);
await userEvent.setup().click(screen.getByTestId("lane-action-stop"));
expect(onAction).toHaveBeenCalledWith("stop");
});
it("keeps the destructive verbs out of the card until the menu is opened", async () => {
render(<LaneCard lane={full()} onAction={vi.fn()} />);
// A wall of red buttons makes none of them read as the dangerous one, so
// reset/remove/purge live behind the ⋯ menu.
expect(screen.queryByTestId("lane-action-reset")).toBeNull();
expect(screen.queryByTestId("lane-action-remove")).toBeNull();
await userEvent.setup().click(screen.getByTestId("lane-more"));
expect(screen.getByTestId("lane-action-reset")).toBeInTheDocument();
expect(screen.getByTestId("lane-action-remove")).toBeInTheDocument();
});
it("routes reset through the confirmation modal rather than firing it", async () => {
const onAction = vi.fn();
render(<LaneCard lane={full()} onAction={onAction} />);
const user = userEvent.setup();
await user.click(screen.getByTestId("lane-more"));
await user.click(screen.getByTestId("lane-action-reset"));
expect(onAction).not.toHaveBeenCalled();
});
it("offers reset only for a managed lane", async () => {
const user = userEvent.setup();
const { unmount } = render(<LaneCard lane={full()} onAction={vi.fn()} />);
await user.click(screen.getByTestId("lane-more"));
expect(screen.getByTestId("lane-action-reset")).toBeInTheDocument();
unmount();
render(<LaneCard lane={full({ kind: "adopted" })} onAction={vi.fn()} />);
await user.click(screen.getByTestId("lane-more"));
expect(screen.queryByTestId("lane-action-reset")).toBeNull();
});
});
describe("LaneCard git block", () => {
const facts = {
available: true as const,
branch: "feat/rename-metric",
head: "9b3e74a",
subject: "free-text rule mode in the form",
dirty: 2,
untracked: 1,
};
it("renders the live branch, head, subject and uncommitted counts", async () => {
vi.mocked(api.lanes.git).mockResolvedValueOnce(facts);
render(<LaneCard lane={makeLane({ branch: "stale/recorded" })} onAction={vi.fn()} />);
const block = await screen.findByTestId("lane-git");
expect(block.textContent).toContain("feat/rename-metric");
expect(block.textContent).toContain("9b3e74a");
expect(block.textContent).toContain("free-text rule mode in the form");
expect(block.textContent).toContain("2");
// The live branch replaces the lane's recorded one rather than doubling it.
expect(screen.queryByText(/stale\/recorded/)).toBeNull();
});
it("omits the uncommitted line when the tree is clean", async () => {
vi.mocked(api.lanes.git).mockResolvedValueOnce({ ...facts, dirty: 0, untracked: 0 });
render(<LaneCard lane={makeLane()} onAction={vi.fn()} />);
const block = await screen.findByTestId("lane-git");
expect(block.textContent).not.toContain("modified");
});
it("renders the card with no git block and no error when git is unavailable", async () => {
vi.mocked(api.lanes.git).mockResolvedValueOnce({ available: false });
render(<LaneCard lane={makeLane({ title: "plain dir lane" })} onAction={vi.fn()} />);
expect(await screen.findByText("plain dir lane")).toBeInTheDocument();
expect(screen.queryByTestId("lane-git")).toBeNull();
});
it("swallows a rejected request instead of surfacing an error", async () => {
vi.mocked(api.lanes.git).mockRejectedValueOnce(new Error("network down"));
render(<LaneCard lane={makeLane({ title: "offline lane" })} onAction={vi.fn()} />);
expect(await screen.findByText("offline lane")).toBeInTheDocument();
expect(screen.queryByTestId("lane-git")).toBeNull();
expect(screen.queryByText(/network down/)).toBeNull();
});
it("stops polling once the card unmounts", async () => {
vi.useFakeTimers();
try {
vi.mocked(api.lanes.git).mockResolvedValue({ available: false });
const { unmount } = render(<LaneCard lane={makeLane()} onAction={vi.fn()} />);
expect(api.lanes.git).toHaveBeenCalledTimes(1);
unmount();
vi.advanceTimersByTime(120_000);
expect(api.lanes.git).toHaveBeenCalledTimes(1);
} finally {
vi.useRealTimers();
}
});
});
@@ -0,0 +1,106 @@
/**
* @file Rendering tests for the lane pipeline map: every node renders with a
* state-specific class so "done", "current" and "passed without evidence" stay
* visually distinguishable, and the amber state is never conflated with done.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect } from "vitest";
import { render, screen } from "@testing-library/react";
import PipelineMap from "../PipelineMap";
import type { LaneNode } from "../../../lib/types";
const nodes: LaneNode[] = [
{ id: "plan", label: "plan", icon: "🧭", gate: false, state: "done" },
{ id: "implement", label: "implement", icon: "🛠", gate: false, state: "passed-no-evidence" },
{ id: "review", label: "review", icon: "👀", gate: true, state: "current" },
{ id: "gate", label: "gate", icon: "🚦", gate: true, state: "failed" },
{ id: "done", label: "done", icon: "✅", gate: false, state: "pending" },
];
describe("PipelineMap", () => {
it("renders one element per node, labelled by state", () => {
render(<PipelineMap nodes={nodes} />);
expect(screen.getAllByTestId(/^pipeline-node-/)).toHaveLength(5);
expect(screen.getByTestId("pipeline-node-plan")).toHaveAttribute("data-state", "done");
expect(screen.getByTestId("pipeline-node-implement")).toHaveAttribute(
"data-state",
"passed-no-evidence"
);
expect(screen.getByTestId("pipeline-node-review")).toHaveAttribute("data-state", "current");
expect(screen.getByTestId("pipeline-node-gate")).toHaveAttribute("data-state", "failed");
expect(screen.getByTestId("pipeline-node-done")).toHaveAttribute("data-state", "pending");
});
it("gives amber nodes a different class from done nodes", () => {
render(<PipelineMap nodes={nodes} />);
const done = screen.getByTestId("pipeline-node-plan").className;
const amber = screen.getByTestId("pipeline-node-implement").className;
// The done node must contain emerald colour token and the amber node must contain amber token.
expect(done).toContain("emerald");
expect(amber).toContain("amber");
expect(done).not.toEqual(amber);
});
it("renders nothing but an empty hint when there are no nodes", () => {
render(<PipelineMap nodes={[]} />);
expect(screen.queryAllByTestId(/^pipeline-node-/)).toHaveLength(0);
});
describe("detected (inferred) nodes", () => {
const detectedNodes: LaneNode[] = [
{ id: "intake", label: "intake", icon: "📥", gate: false, state: "pending", detected: true },
{ id: "tests", label: "tests", icon: "🧪", gate: false, state: "pending", detected: true },
{ id: "plan", label: "plan", icon: "🧭", gate: false, state: "done" },
{
id: "implement",
label: "implement",
icon: "🛠",
gate: false,
state: "passed-no-evidence",
},
];
it("marks a detected node with data-detected and a dashed-border class token", () => {
render(<PipelineMap nodes={detectedNodes} detectedSignal="npm run test:server" />);
const node = screen.getByTestId("pipeline-node-tests");
expect(node).toHaveAttribute("data-detected", "true");
expect(node.className).toContain("border-dashed");
});
it("gives a detected node a class different from both done and plain passed-no-evidence", () => {
render(<PipelineMap nodes={detectedNodes} detectedSignal="npm run test:server" />);
const detected = screen.getByTestId("pipeline-node-tests").className;
const done = screen.getByTestId("pipeline-node-plan").className;
const amber = screen.getByTestId("pipeline-node-implement").className;
expect(detected).not.toEqual(done);
expect(detected).not.toEqual(amber);
});
it("names the signal in the detected node's tooltip", () => {
render(<PipelineMap nodes={detectedNodes} detectedSignal="npm run test:server" />);
const node = screen.getByTestId("pipeline-node-tests");
expect(node).toHaveAttribute("title", "tests ← npm run test:server");
});
it("PREMISE GUARD: detected wins over state=done — dashed amber, never emerald", () => {
// The server never emits this pair, and this is the guard that says the
// component would not paint an inference green even if it did. Asserting
// against a fixture whose detected nodes are already `pending` would only
// re-assert the fixture.
const impossible: LaneNode[] = [
{ id: "tests", label: "tests", icon: "🧪", gate: false, state: "done", detected: true },
];
render(<PipelineMap nodes={impossible} detectedSignal="npm run test:server" />);
const node = screen.getByTestId("pipeline-node-tests");
expect(node.className).toContain("border-dashed");
expect(node.className).toContain("amber");
expect(node.className).not.toContain("emerald");
});
it("non-detected nodes carry no data-detected attribute", () => {
render(<PipelineMap nodes={detectedNodes} detectedSignal="npm run test:server" />);
expect(screen.getByTestId("pipeline-node-plan")).not.toHaveAttribute("data-detected");
});
});
});