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:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user