/** * @file One lane's card: title, progress bar with time-on-phase, branch/CI/PR * facts, the "needs you" banner sourced from Claude Code's Notification hook, * and the control row — which ends in the two deletions (lane, history) as * plain buttons, each gated by 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. * @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 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(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; } /** * The lane's own application stack. Same shape and same silence as the git * facts above: a lane without a `.ccam/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. */ 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(r); }) .catch(() => { if (alive) setRuntime({ 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(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; } const LIVENESS_DOT: Record = { active: "bg-status-success", idle: "bg-surface-4", dead: "bg-status-danger", }; 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" | "remove" | "purge" | null>( null ); const [menuOpen, setMenuOpen] = useState(false); const [runtimeBump, setRuntimeBump] = useState(0); const [runtimeBusy, setRuntimeBusy] = useState<"up" | "down" | null>(null); const [bootLine, setBootLine] = useState(null); const [laneActionBusy, setLaneActionBusy] = useState<"agents" | "mcp" | "sync-check" | null>( null ); const [laneActionResult, setLaneActionResult] = 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); /** * Boot or stop the lane's stack. Deliberately NOT routed through `onAction`: * that prop drives the lane's Claude run, and folding a second lifecycle into * it would make "stop" ambiguous about which thing it stops. * * `up` answers 202 and keeps booting in the background, so the button stays * busy until the server's `lane_runtime` message says the attempt finished — * resolving the request is not the same as the stack being up. */ const runtimeAction = async (which: "up" | "down") => { setRuntimeBusy(which); setBootLine(null); try { if (which === "down") { await api.lanes.down(lane.id); setRuntimeBusy(null); } else { await api.lanes.up(lane.id); } } catch { // The failure surfaces as the runtime row's lastError on the next read; a // toast here would say the same thing twice. setRuntimeBusy(null); } finally { setRuntimeBump((n) => n + 1); } }; 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", }); }); /** * 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") { setRuntimeBusy(null); setBootLine(null); setRuntimeBump((n) => n + 1); } }), [lane.id] ); return ( <>
{/* Identity strip: which lane, and is it alive. Kept on one line and in uppercase so a wall of cards can be scanned vertically. */}
{t("cardId", { id: lane.id })} {/* 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 })}

{lane.title || lane.cwd}

{/* Progress line: how far through the pipeline and how long the current stage has been sitting there. The stage's own name is already in the Workspace header above; not repeated here. */}
0 ? "bg-surface-2" : "bg-transparent" }`} >
{lane.progress > 0 && ( {lane.progress}% )} {since(lane.stage_seconds)}
{lane.ci_status && (
CI {lane.ci_status}
)} {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. */}
{(["start", "stop", "clear"] as const).map((a) => ( ))} {/* Stack controls, only for a lane whose repo declares a profile. Separate from start/stop above: those drive the lane's Claude run, these drive the application it is working on. */} {runtime?.available && ( )} {runtime?.available && ( <> )} {/* Deleting the lane and deleting its history are each their own button, by request: hiding "delete" behind a ⋯ made it unfindable, and the one label that read as "delete" was `clear`. Neither fires directly — both open DestructiveLaneModal, which shows the exact counts and demands a confirmation. `reset` stays in the menu; it is the rare one and only exists for managed lanes. */}
{/* Adopted lanes have no worktree to reset, so the menu would be empty — it is not rendered at all rather than opening onto nothing. */} {lane.kind === "managed" && (
{menuOpen && (
)}
)}
{laneActionResult && (

{laneActionResult}

)}
{destructiveAction && ( setDestructiveAction(null)} onConfirm={(body) => { setDestructiveAction(null); onAction(destructiveAction, body); }} /> )} ); }