Files
Claude-Code-Monitor/client/src/components/lanes/LaneCard.tsx
T
nntrivi2001 9f13769fb4 feat(lanes): surface child worktrees on a lane card + detect Superpowers skills
Adopting the main repo as its own lane now gets stage detection
(cwd matches, same as any other lane), and its card lists every
managed-worktree lane provisioned from it with a jump-to link.
Also add the two missing Skill-tool detect rules (implement, ship)
so detection covers all four Superpowers workflow phases, not just
plan/review.
2026-08-06 15:33:07 +07:00

688 lines
26 KiB
TypeScript

/**
* @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: <stage>" 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ĩ <vinnt@smartgift.vn>
*/
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<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;
}
/**
* 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<LaneRuntime | null>(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<NamedLock[]>([]);
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<Record<string, boolean> | 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<string, boolean> = {};
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<Lane["liveness"], string> = {
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<string, unknown>) => 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<string | null>(null);
const [laneActionBusy, setLaneActionBusy] = useState<"agents" | "mcp" | "sync-check" | null>(
null
);
const [laneActionResult, setLaneActionResult] = useState<string | null>(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<string>
) => {
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 (
<>
<div
data-testid={`lane-card-${lane.id}`}
className="flex flex-col rounded-xl border border-border bg-surface-4 p-4 transition-colors hover:border-border-light"
>
{/* 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-fg-muted">
{t("cardId", { id: lane.id })}
</span>
<span className="flex items-center gap-1.5 text-[11px] font-medium uppercase tracking-wide text-fg-secondary">
<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-fg-primary">
{lane.title || lane.cwd}
</h3>
{/* 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. */}
<div className="mb-2 flex items-center gap-2 text-xs">
<div
className={`h-1 flex-1 overflow-hidden rounded-full ${
lane.progress > 0 ? "bg-surface-2" : "bg-transparent"
}`}
>
<div
data-testid="lane-progress-fill"
className="h-full rounded-full bg-blue-600 transition-[width]"
style={{ width: `${lane.progress}%` }}
/>
</div>
{lane.progress > 0 && (
<span className="tabular-nums text-fg-secondary">{lane.progress}%</span>
)}
<span className="tabular-nums text-fg-muted">{since(lane.stage_seconds)}</span>
</div>
{lane.ci_status && (
<div className="mb-3 flex flex-wrap items-center gap-1.5 text-[11px]">
<span className="rounded bg-surface-2 px-1.5 py-0.5 text-fg-secondary">
CI {lane.ci_status}
</span>
</div>
)}
{lane.needs_action && (
<div className="mb-3 rounded border border-status-warning/50 bg-status-warning/10 px-2 py-1.5 text-xs text-status-warning">
{lane.needs_action}
</div>
)}
{/* 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 && (
<div
data-testid={`lane-runtime-${lane.id}`}
className="mb-3 rounded border border-border bg-surface-2/50 px-2 py-1.5 font-mono text-[11px]"
>
<div className="mb-1 flex items-center justify-between">
<span className="text-fg-muted">{t("runtime.slot", { slot: runtime.slot })}</span>
<span
className={runtime.healthy ? "text-status-success" : "text-fg-muted"}
data-testid="lane-runtime-state"
>
{runtime.healthy
? t("runtime.healthy")
: runtime.up
? t("runtime.partial")
: t("runtime.down")}
</span>
</div>
{Object.entries(runtime.ports).map(([name, info]) => (
<div key={name} className="flex items-center gap-1.5 truncate">
<span
className={`h-1.5 w-1.5 shrink-0 rounded-full ${
info.listening ? "bg-status-success" : "bg-surface-4"
}`}
/>
<span className="text-fg-secondary">{name}</span>
<span className="text-fg-muted">:{info.port ?? "—"}</span>
{info.port !== null && info.port !== info.expected && (
<span
className="truncate text-status-warning/80"
title={t("runtime.steppedAsideTitle", { expected: info.expected })}
>
{info.expected}
</span>
)}
</div>
))}
{/* 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 && (
<div
data-testid="lane-runtime-bootline"
className="mt-1 truncate text-fg-muted"
title={bootLine}
>
{bootLine}
</div>
)}
{bootLine === null && runtime.lastError && (
<div
className="mt-1 truncate text-status-danger/90"
title={runtime.lastError.message}
>
{runtime.lastError.code || "error"}: {runtime.lastError.message}
</div>
)}
</div>
)}
{locks.length > 0 && (
<div
className="mb-3 flex items-center gap-1 text-xs text-status-warning"
data-testid={`lane-locks-${lane.id}`}
title={locks.map((l) => `${l.name} (${Math.floor(l.ageSec / 60)}m)`).join(", ")}
>
🔒 {t("locks.held", { count: locks.length })}
</div>
)}
<dl className="mb-3 space-y-1 font-mono text-[11px] text-fg-secondary">
{git?.available && (
<div data-testid="lane-git" className="space-y-1">
<div className="truncate">
{git.branch}
<span className="ml-2 text-fg-muted">{git.head}</span>
</div>
<div className="truncate text-fg-muted" title={git.subject}>
{git.subject}
</div>
{(git.dirty > 0 || git.untracked > 0) && (
<div className="text-status-warning/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-fg-muted" title={lane.cwd}>
{lane.cwd}
</div>
</dl>
{childWorktrees && childWorktrees.length > 0 && (
<div
data-testid="lane-child-worktrees"
className="mb-3 space-y-1 text-[11px] text-fg-secondary"
>
<div className="text-fg-muted">
{t("worktrees.heading", { count: childWorktrees.length })}
</div>
<ul className="space-y-0.5">
{childWorktrees.map((w) => (
<li key={w.id}>
<button
type="button"
onClick={() => onSelectLane?.(w.id)}
className="truncate text-left text-blue-400 hover:underline"
title={w.cwd}
>
#{w.id} {w.title || w.cwd} · {w.status}
</button>
</li>
))}
</ul>
</div>
)}
{integrations && (
<div className="mb-2 flex items-center gap-1.5 text-[10px]">
{(["tracker", "dev_qc", "ci_wait"] as const).map((name) => (
<span
key={name}
data-testid={`lane-integration-${name}`}
className={`rounded-full px-2 py-0.5 ${
integrations[name]
? "bg-status-success/10 text-status-success"
: "bg-surface-2 text-fg-muted"
}`}
>
{t(`integrations.${name}`)}
</span>
))}
</div>
)}
{/* 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-border 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-600/15 text-blue-400 hover:bg-blue-600/25"
: "text-fg-secondary hover:bg-surface-2 hover:text-fg-secondary"
}`}
title={a === "start" ? t("tooltipStart") : undefined}
>
{t(`action.${a}`)}
</button>
))}
{/* 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 && (
<button
type="button"
data-testid="lane-runtime-toggle"
disabled={runtimeBusy !== null}
onClick={(e) => {
e.stopPropagation();
void runtimeAction(runtime.provisioned && runtime.up ? "down" : "up");
}}
className="rounded px-2 py-1 text-fg-secondary transition-colors hover:bg-surface-2 disabled:opacity-50"
title={t("runtime.toggleTitle")}
>
{runtimeBusy
? t(`runtime.busy.${runtimeBusy}`)
: runtime.provisioned && runtime.up
? t("runtime.stop")
: t("runtime.boot")}
</button>
)}
{runtime?.available && (
<>
<button
type="button"
data-testid="lane-agents-install"
disabled={laneActionBusy !== null}
onClick={(e) => {
e.stopPropagation();
void handleAgentsInstall();
}}
className="rounded px-2 py-1 text-fg-secondary transition-colors hover:bg-surface-2 disabled:opacity-50"
>
{laneActionBusy === "agents" ? t("actions.busy") : t("actions.agentsInstall")}
</button>
<button
type="button"
data-testid="lane-mcp-sync"
disabled={laneActionBusy !== null}
onClick={(e) => {
e.stopPropagation();
void handleMcpSync();
}}
className="rounded px-2 py-1 text-fg-secondary transition-colors hover:bg-surface-2 disabled:opacity-50"
>
{laneActionBusy === "mcp" ? t("actions.busy") : t("actions.mcpSync")}
</button>
<button
type="button"
data-testid="lane-sync-check"
disabled={laneActionBusy !== null}
onClick={(e) => {
e.stopPropagation();
void handleSyncCheck();
}}
className="rounded px-2 py-1 text-fg-secondary transition-colors hover:bg-surface-2 disabled:opacity-50"
>
{laneActionBusy === "sync-check" ? t("actions.busy") : t("actions.syncCheck")}
</button>
</>
)}
{/* 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. */}
<div className="ml-auto flex items-center gap-1">
<button
type="button"
data-testid="lane-action-purge"
onClick={(e) => {
e.stopPropagation();
setDestructiveAction("purge");
}}
className="rounded px-2 py-1 text-status-danger/80 transition-colors hover:bg-status-danger/10 hover:text-status-danger"
>
{t("action.purge")}
</button>
<button
type="button"
data-testid="lane-action-remove"
onClick={(e) => {
e.stopPropagation();
setDestructiveAction("remove");
}}
className="rounded px-2 py-1 text-status-danger transition-colors hover:bg-status-danger/15 hover:text-status-danger"
>
{lane.kind === "adopted" ? t("action.forget") : t("action.remove")}
</button>
{/* 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" && (
<div className="relative">
<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-fg-muted hover:bg-surface-2 hover:text-fg-secondary"
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-border-light bg-surface-0 py-1 shadow-lg shadow-black/40"
>
<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-status-warning hover:bg-status-warning/10"
>
{t("action.reset")}
</button>
</div>
)}
</div>
)}
</div>
</div>
{laneActionResult && (
<p
data-testid="lane-action-result"
className="mt-1 truncate text-[11px] text-fg-secondary"
>
{laneActionResult}
</p>
)}
</div>
{destructiveAction && (
<DestructiveLaneModal
lane={lane}
action={destructiveAction}
open
onClose={() => setDestructiveAction(null)}
onConfirm={(body) => {
setDestructiveAction(null);
onAction(destructiveAction, body);
}}
/>
)}
</>
);
}