/** * @file One lane's card: CI/PR facts, the "needs you" * banner sourced from Claude Code's Notification hook, and (for managed * lanes only) a ⋯ menu — also on a right-click — holding the rare `reset worktree` action behind * DestructiveLaneModal. 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. Stage, kind, and the "auto: " chip are * NOT repeated here — this card only ever renders inside the Workspace * lane-detail section, whose header above it already shows them. Neither is * progress: the pipeline map directly below this card draws the same number * as named, coloured nodes, so a bar and a percent beside it were a worse * second telling of it. The working copy — branch, uncommitted counts, the * git action bar — is not here either: it heads the Changes panel * (`LaneGitHeader`), beside the diff it describes. * @author Nguyễn Ngọc Trí Vĩ */ import { useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; import { DestructiveLaneModal } from "./DestructiveLaneModal"; import { ContextMenu, MenuItem, menuPoint, type MenuPoint } from "../ContextMenu"; import { api } from "../../lib/api"; import { eventBus } from "../../lib/eventBus"; import { keepIfSame } from "../../lib/keepIfSame"; import type { Lane, LaneRuntime, NamedLock } from "../../lib/types"; /** How often a mounted card re-probes its stack. Faster than the Changes panel's * git refresh because a stack dying is exactly what the user needs to see, and slower than * the lane poll because each call opens a socket per declared port. */ const RUNTIME_REFRESH_MS = 10_000; /** How often a mounted card re-reads locks held by this lane. Same refresh rate * as the Changes panel's git facts (slow, since lock state changes infrequently). */ const LOCKS_REFRESH_MS = 30_000; /** * The lane's own application stack. Same shape and same silence as the git * facts in LaneGitHeader: a lane without a `.agenthub/profile` simply has no runtime row, which * is the common case and not an error worth a banner. * * `bump` lets an up/down action re-read immediately instead of waiting out the * poll interval. */ export function useLaneRuntime(laneId: number, bump: number): LaneRuntime | null { const [runtime, setRuntime] = useState(null); useEffect(() => { let alive = true; const read = () => { api.lanes .runtime(laneId) .then((r) => { if (alive) setRuntime(keepIfSame(r)); }) .catch(() => { if (alive) setRuntime(keepIfSame({ available: false })); }); }; read(); const timer = setInterval( () => document.visibilityState === "visible" && read(), RUNTIME_REFRESH_MS ); return () => { alive = false; clearInterval(timer); }; }, [laneId, bump]); return runtime; } /** * Locks held by THIS lane, polled the same way runtime/git facts are. */ function useLaneLocks(laneSlot: number | null): NamedLock[] { const [locks, setLocks] = useState([]); useEffect(() => { if (!laneSlot) return; let alive = true; const holder = `lane${laneSlot}`; const read = () => { api.locks .list() .then((data) => { if (alive) setLocks(keepIfSame(data.locks.filter((l) => l.holder === holder))); }) .catch(() => { /* fails silently, same contract as the git/runtime pollers */ }); }; read(); const timer = setInterval( () => document.visibilityState === "visible" && read(), LOCKS_REFRESH_MS ); return () => { alive = false; clearInterval(timer); }; }, [laneSlot]); return locks; } export 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`; } export default function LaneCard({ lane, onAction, childWorktrees, onSelectLane, }: { lane: Lane; onAction: (action: string, body?: Record) => void; /** Other lanes whose `source_repo` is this lane's `cwd` — populated only * when this lane is itself a source repo (typically an adopted one) that * other lanes were provisioned as worktrees from. */ childWorktrees?: Lane[]; /** Jumps the Workspace page's selection to another lane's card. */ onSelectLane?: (id: number) => void; }) { const { t } = useTranslation(["lanes"]); const [destructiveAction, setDestructiveAction] = useState<"reset" | null>(null); const [menuAt, setMenuAt] = useState(null); const [runtimeBump, setRuntimeBump] = useState(0); const [bootLine, setBootLine] = useState(null); const runtime = useLaneRuntime(lane.id, runtimeBump); const locks = useLaneLocks(lane.slot); /** * Live boot feedback. A build can run for minutes, and a card showing only a * disabled button through all of it reads as a hang. The hook's own output * lines are the honest progress indicator. */ useEffect( () => eventBus.subscribe((msg) => { const data = msg.data as { laneId?: number; line?: string } | undefined; if (!data || data.laneId !== lane.id) return; if (msg.type === "lane_hook_output" && typeof data.line === "string") { setBootLine(data.line); } if (msg.type === "lane_runtime") { setBootLine(null); setRuntimeBump((n) => n + 1); } }), [lane.id] ); return ( <>
setMenuAt(menuPoint(e)) : undefined} // Every row below is conditional: a lane with nothing to report // renders no frame at all rather than an empty box. className="flex flex-col rounded-lg border border-border bg-surface-4 p-2.5 transition-colors hover:border-border-light empty:hidden" > {lane.ci_status && (
CI {lane.ci_status}
)} {/* No needs-you banner here: the strip card already flags it, and the console directly below is where the prompt actually waits. */} {/* Runtime strip: the lane's own app stack, shown only for lanes that declare a profile. A port that drifted from `base + slot` is called out — the number is otherwise predictable from the slot, and silently serving on a different one is exactly the surprise worth flagging. */} {runtime?.available && runtime.provisioned && (
{t("runtime.slot", { slot: runtime.slot })} {runtime.healthy ? t("runtime.healthy") : runtime.up ? t("runtime.partial") : t("runtime.down")}
{Object.entries(runtime.ports).map(([name, info]) => (
{info.listening ? t("runtime.listening") : t("runtime.notListening")} {name} :{info.port ?? "—"} {info.port !== null && info.port !== info.expected && ( ⚠ {info.expected} )}
))} {/* While a boot is in flight the hook's own latest line IS the progress bar — a build can take minutes, and a disabled button with nothing moving reads as a hang. */} {bootLine !== null && (
{bootLine}
)} {bootLine === null && runtime.lastError && (
{runtime.lastError.code || "error"}: {runtime.lastError.message}
)}
)} {locks.length > 0 && (
`${l.name} (${Math.floor(l.ageSec / 60)}m)`).join(", ")} > 🔒 {t("locks.held", { count: locks.length })}
)} {childWorktrees && childWorktrees.length > 0 && (
{t("worktrees.heading", { count: childWorktrees.length })}
    {childWorktrees.map((w) => (
  • ))}
)} {/* mt-auto pins the controls to the bottom so cards of differing height in one grid row still line their buttons up. Repo tooling and the remove button live in the Workspace page header/pipeline row now — `reset` is the one destructive action left here, since it only applies to managed lanes and is rare enough to stay behind ⋯. */} {lane.kind === "managed" && (
{/* The shared menu shell: outside click, Escape and focus. */}
)}
{menuAt && ( setMenuAt(null)}> { setMenuAt(null); setDestructiveAction("reset"); }} > {t("action.reset")} )} {destructiveAction && ( setDestructiveAction(null)} onConfirm={(body) => { setDestructiveAction(null); onAction(destructiveAction, body); }} /> )} ); }