/** * @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 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 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 — agents * install / MCP sync / dev-sync check / create-update readme) 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. 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, useRef, 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, 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(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; } /** * 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. */ export 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; runId?: 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]); // One instance of this row serves every lane — it lives in the Workspace's // lane-detail header, and switching lanes swaps its `lane` prop rather than // remounting it. So an apply that is still in flight when the user moves on // would land its rollback and its note on whatever lane is showing by then: // lane B's failed switch putting lane B's old selection into lane A's picker. const shownLane = useRef(lane.id); useEffect(() => { shownLane.current = lane.id; }, [lane.id]); useEffect(() => { let alive = true; api.aiRouters .list() .then((r) => { if (alive) setRouters(r.items); }) .catch(() => { // Silent, same contract as the runtime poller 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 laneId = lane.id; const previous = current; setCurrent(name); setBusy(true); setNote(null); try { const res = await api.lanes.routerApply(laneId, name); if (shownLane.current !== laneId) return; // A live pane adopted the new base URL/key but kept the model names it // resolved at startup, so it would send the OLD router's model to the NEW // endpoint and fail with "model not found". Only that case gets the // warning and the restart button. setNote( res.restart_required && res.run_id ? { ok: false, text: t("router.appliedRestart"), runId: res.run_id } : { ok: true, text: t("router.applied") } ); } catch (err) { if (shownLane.current !== laneId) return; setCurrent(previous); setNote({ ok: false, text: err instanceof Error ? err.message : String(err) }); } finally { // `busy` belongs to the control on screen, not to the request, so it is // cleared either way — a lane switch must never leave the picker locked. setBusy(false); } }; /** * Kill the pane and open a fresh one for this lane. Started with the lane id * alone on purpose: the previous run's `--model` flag names a model of the * router the user just left, so replaying it would re-create the exact * mismatch this button exists to clear. */ const restart = async (runId: string) => { const laneId = lane.id; setBusy(true); try { await api.run.kill(runId); await api.run.start({ laneId }); if (shownLane.current !== laneId) return; setNote({ ok: true, text: t("router.restarted") }); } catch (err) { if (shownLane.current !== laneId) return; setNote({ ok: false, text: err instanceof Error ? err.message : String(err) }); } finally { setBusy(false); } }; return ( ); } 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, and the * create/update-readme run, which moved here from the Add-lane modal) 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" | "docs" | null >(null); const [laneActionResult, setLaneActionResult] = useState(null); const runtime = useLaneRuntime(lane.id, 0); const runLaneAction = async ( which: "agents" | "mcp" | "sync-check" | "docs", 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(", ") }); }); // A skill only exists inside a CLI session, so there is nothing server-side // to call: this opens the lane's own terminal run with the slash command as // its first message. Claude-only — codex has no slash commands. const handleWriteDocs = () => runLaneAction("docs", async () => { await api.run.start({ laneId: lane.id, cwd: lane.cwd, initialPrompt: "/sg-all:sg-md-writing", }); return t("actions.writeDocsResult"); }); 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 && (
{lane.tool !== "codex" && ( )}
)} {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 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 ( <>
{(lane.ci_status || lane.tool === "codex") && (
{lane.ci_status && ( CI {lane.ci_status} )} {lane.tool === "codex" && ( Codex )}
)} {/* 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]) => (
{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" && (
{menuOpen && (
)}
)}
{destructiveAction && ( setDestructiveAction(null)} onConfirm={(body) => { setDestructiveAction(null); onAction(destructiveAction, body); }} /> )} ); }