/** * @file One lane's card: progress bar, branch/CI/PR facts, the "needs you" * banner sourced from Claude Code's Notification hook, and (for managed * lanes only) a ⋯ menu 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. The repo * card also carries the lane's AI Router picker — the Add-lane modal is the * only other place one is chosen, and a lane outlives its creation. The repo * tooling dropdown (`LaneToolsMenu`, exported from this file) and the * remove-lane button live in Workspace's page header / pipeline-picker row * instead of here, so they stay reachable and un-clipped regardless of this * card's own scroll area. * @author Nguyễn Ngọc Trí Vĩ */ import { useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; import { DestructiveLaneModal } from "./DestructiveLaneModal"; import { api } from "../../lib/api"; import { eventBus } from "../../lib/eventBus"; import { keepIfSame } from "../../lib/keepIfSame"; import type { AiRouter } from "../../lib/api"; import type { Lane, LaneGitFacts, LaneRuntime, NamedLock } 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; /** How often a mounted card re-probes its stack. Faster than the 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 git facts (slow, since lock state changes infrequently). */ const LOCKS_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(null); useEffect(() => { let alive = true; const read = () => { api.lanes .git(laneId) .then((f) => { if (alive) setFacts(keepIfSame(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(keepIfSame({ available: false })); }); }; read(); const timer = setInterval(read, GIT_REFRESH_MS); return () => { alive = false; clearInterval(timer); }; }, [laneId]); return facts; } /** * The lane's own application stack. Same shape and same silence as the git * facts above: 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(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(read, LOCKS_REFRESH_MS); return () => { alive = false; clearInterval(timer); }; }, [laneSlot]); return locks; } const INTEGRATION_NAMES = ["tracker", "dev_qc", "ci_wait"] as const; function useLaneIntegrations( laneId: number, available: boolean ): Record<(typeof INTEGRATION_NAMES)[number], boolean> | null { const [state, setState] = useState | null>(null); useEffect(() => { if (!available) { setState(null); return; } let alive = true; Promise.all(INTEGRATION_NAMES.map((name) => api.lanes.integration(laneId, name))) .then((results) => { if (!alive) return; const next: Record = {}; INTEGRATION_NAMES.forEach((name, i) => { next[name] = results[i]?.enabled ?? false; }); setState(next); }) .catch(() => { if (alive) setState(null); }); return () => { alive = false; }; }, [laneId, available]); return state as Record<(typeof INTEGRATION_NAMES)[number], boolean> | null; } /** * Which AI Router this lane runs against, and the one place it can be changed * after the lane exists — the Add-lane modal is the only other one, and a lane * outlives the moment it was created. * * Applying writes the lane's own config on disk as well as the DB column, so it * goes through `POST /lanes/:id/router/apply` and never a PATCH. A failure puts * the select back: the row must not claim a router the lane did not get. * * Rendered only when there is something to choose. A user who has never opened * the AI Router tab has exactly the built-in `native` profile, and a select * with one option is a control that cannot do anything. */ function LaneRouterRow({ lane }: { lane: Lane }) { const { t } = useTranslation(["lanes"]); const [routers, setRouters] = useState(null); const [current, setCurrent] = useState(lane.router_profile || "native"); const [busy, setBusy] = useState(false); const [note, setNote] = useState<{ ok: boolean; text: string } | null>(null); // The polled lane list is the source of truth; a switch elsewhere (the lane // was reset to native by a profile delete, say) has to land here too. useEffect(() => { setCurrent(lane.router_profile || "native"); setNote(null); }, [lane.id, lane.router_profile]); useEffect(() => { let alive = true; api.aiRouters .list() .then((r) => { if (alive) setRouters(r.items); }) .catch(() => { // Silent, same contract as the git/runtime pollers above: a card that // cannot list routers simply shows no router row. if (alive) setRouters([]); }); return () => { alive = false; }; }, []); if (!routers || routers.length <= 1) return null; const change = async (name: string) => { const previous = current; setCurrent(name); setBusy(true); setNote(null); try { await api.lanes.routerApply(lane.id, name); setNote({ ok: true, text: t("router.applied") }); } catch (err) { setCurrent(previous); setNote({ ok: false, text: err instanceof Error ? err.message : String(err) }); } finally { setBusy(false); } }; return (
{note && (

{note.text}

)}
); } export const LIVENESS_DOT: Record = { active: "bg-status-success", idle: "bg-surface-4", dead: "bg-status-danger", }; 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`; } /** * Repo tooling (agents install / MCP sync / dev-sync check) for one lane, * collapsed into a single dropdown. Lives outside LaneCard so it can be * placed in the Workspace page header, next to "Add lane" — it polls its * own runtime state independently of LaneCard's, same pattern as the * separately-polled git/locks facts elsewhere on this page. */ export function LaneToolsMenu({ lane }: { lane: Lane }) { const { t } = useTranslation(["lanes"]); const [toolsMenuOpen, setToolsMenuOpen] = useState(false); const [laneActionBusy, setLaneActionBusy] = useState<"agents" | "mcp" | "sync-check" | null>( null ); const [laneActionResult, setLaneActionResult] = useState(null); const runtime = useLaneRuntime(lane.id, 0); const runLaneAction = async ( which: "agents" | "mcp" | "sync-check", fn: () => Promise ) => { setLaneActionBusy(which); setLaneActionResult(null); try { setLaneActionResult(await fn()); } catch (err) { setLaneActionResult(err instanceof Error ? err.message : String(err)); } finally { setLaneActionBusy(null); } }; const handleAgentsInstall = () => runLaneAction("agents", async () => { const result = await api.lanes.agentsInstall(lane.id); return t("actions.agentsInstallResult", { files: result.installed.join(", ") }); }); const handleMcpSync = () => runLaneAction("mcp", async () => { const result = await api.lanes.mcpSync(lane.id); return t("actions.mcpSyncResult", { servers: result.servers.join(", ") || "none" }); }); const handleSyncCheck = () => runLaneAction("sync-check", async () => { const result = await api.lanes.syncBaseCheck(lane.id); if (result.code === 5 && result.collisions && result.collisions.length > 0) { const c = result.collisions[0]!; return t("actions.syncCheckCollision", { file: c.file, suggestion: c.suggestion }); } return t("actions.syncCheckClean", { count: result.devDelta?.length ?? 0, overlap: result.overlap?.length ? result.overlap.join(", ") : "none", }); }); if (!runtime?.available) return null; return (
{toolsMenuOpen && (
)} {laneActionResult && (

{laneActionResult}

)}
); } 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 [menuOpen, setMenuOpen] = useState(false); const [runtimeBump, setRuntimeBump] = useState(0); const [bootLine, setBootLine] = useState(null); const git = useLaneGitFacts(lane.id); const runtime = useLaneRuntime(lane.id, runtimeBump); const locks = useLaneLocks(lane.slot); const integrations = useLaneIntegrations(lane.id, runtime?.available === true); /** * 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 ( <>
{/* Progress line: how far through the pipeline. The stage's own name and how long it's been running are already in the Workspace header above; not repeated here. Omitted at 0% — an empty bar is not information, just a blank row above a dead/idle lane. */} {lane.progress > 0 && (
{lane.progress}%
)} {(lane.ci_status || lane.tool === "codex") && (
{lane.ci_status && ( CI {lane.ci_status} )} {lane.tool === "codex" && ( Codex )}
)} {lane.needs_action && (
⚠ {lane.needs_action}
)} {/* 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]) => (
{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 })}
)}
{git?.available && (
⑂ {git.branch} {git.head}
{git.subject}
{(git.dirty > 0 || git.untracked > 0) && (
{t("git.uncommitted", { dirty: git.dirty, untracked: git.untracked })}
)}
)} {/* 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 &&
⑂ {lane.branch}
}
{lane.cwd}
{childWorktrees && childWorktrees.length > 0 && (
{t("worktrees.heading", { count: childWorktrees.length })}
    {childWorktrees.map((w) => (
  • ))}
)} {integrations && (
{(["tracker", "dev_qc", "ci_wait"] as const).map((name) => ( {t(`integrations.${name}`)} ))}
)} {/* 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" && (
{menuOpen && (
)}
)}
{destructiveAction && ( setDestructiveAction(null)} onConfirm={(body) => { setDestructiveAction(null); onAction(destructiveAction, body); }} /> )} ); }