feat(lanes): add agents-install/mcp-sync/integration/sync-check to LaneCard (F4)

This commit is contained in:
2026-08-06 09:59:33 +07:00
parent bc7c5e5431
commit 6d8c5399ea
4 changed files with 171 additions and 0 deletions
+146
View File
@@ -130,6 +130,40 @@ function useLaneLocks(laneSlot: number | null): NamedLock[] {
return locks; 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> = { const LIVENESS_DOT: Record<Lane["liveness"], string> = {
active: "bg-status-success", active: "bg-status-success",
idle: "bg-surface-4", idle: "bg-surface-4",
@@ -158,9 +192,14 @@ export default function LaneCard({
const [runtimeBump, setRuntimeBump] = useState(0); const [runtimeBump, setRuntimeBump] = useState(0);
const [runtimeBusy, setRuntimeBusy] = useState<"up" | "down" | null>(null); const [runtimeBusy, setRuntimeBusy] = useState<"up" | "down" | null>(null);
const [bootLine, setBootLine] = useState<string | 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 git = useLaneGitFacts(lane.id);
const runtime = useLaneRuntime(lane.id, runtimeBump); const runtime = useLaneRuntime(lane.id, runtimeBump);
const locks = useLaneLocks(lane.slot); 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`: * Boot or stop the lane's stack. Deliberately NOT routed through `onAction`:
@@ -190,6 +229,46 @@ export default function LaneCard({
} }
}; };
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 * 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 * disabled button through all of it reads as a hang. The hook's own output
@@ -373,6 +452,24 @@ export default function LaneCard({
</div> </div>
</dl> </dl>
{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 {/* mt-auto pins the controls to the bottom so cards of differing height
in one grid row still line their buttons up. */} 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"> <div className="mt-auto flex items-center gap-1 border-t border-border pt-2 text-xs">
@@ -417,6 +514,46 @@ export default function LaneCard({
: t("runtime.boot")} : t("runtime.boot")}
</button> </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 {/* Deleting the lane and deleting its history are each their own
button, by request: hiding "delete" behind a ⋯ made it unfindable, button, by request: hiding "delete" behind a ⋯ made it unfindable,
and the one label that read as "delete" was `clear`. Neither fires and the one label that read as "delete" was `clear`. Neither fires
@@ -489,6 +626,15 @@ export default function LaneCard({
)} )}
</div> </div>
</div> </div>
{laneActionResult && (
<p
data-testid="lane-action-result"
className="mt-1 truncate text-[11px] text-fg-secondary"
>
{laneActionResult}
</p>
)}
</div> </div>
{destructiveAction && ( {destructiveAction && (
@@ -23,6 +23,7 @@ vi.mock("../../../lib/api", () => ({
up: vi.fn(), up: vi.fn(),
down: vi.fn(), down: vi.fn(),
preflight: vi.fn().mockResolvedValue({ blocked: [], warnings: [] }), preflight: vi.fn().mockResolvedValue({ blocked: [], warnings: [] }),
integration: vi.fn(),
}, },
locks: { locks: {
list: vi.fn(), list: vi.fn(),
@@ -37,6 +38,8 @@ beforeEach(() => {
// stack, so the runtime strip and its button stay absent unless a test opts in. // stack, so the runtime strip and its button stay absent unless a test opts in.
vi.mocked(api.lanes.runtime).mockReset(); vi.mocked(api.lanes.runtime).mockReset();
vi.mocked(api.lanes.runtime).mockResolvedValue({ available: false }); vi.mocked(api.lanes.runtime).mockResolvedValue({ available: false });
vi.mocked(api.lanes.integration).mockReset();
vi.mocked(api.lanes.integration).mockResolvedValue({ enabled: false });
}); });
vi.mocked(api.locks.list).mockReset(); vi.mocked(api.locks.list).mockReset();
vi.mocked(api.locks.list).mockResolvedValue({ locks: [] }); vi.mocked(api.locks.list).mockResolvedValue({ locks: [] });
+11
View File
@@ -8,6 +8,14 @@
"action.stop": "stop", "action.stop": "stop",
"actionError": "Lane action failed: {{message}}", "actionError": "Lane action failed: {{message}}",
"actionErrorUnknown": "Unknown error", "actionErrorUnknown": "Unknown error",
"actions.agentsInstall": "Install agents",
"actions.agentsInstallResult": "Installed: {{files}}",
"actions.busy": "…",
"actions.mcpSync": "Sync MCP",
"actions.mcpSyncResult": "Synced: {{servers}}",
"actions.syncCheck": "Check dev sync",
"actions.syncCheckClean": "DEV_DELTA: {{count}} file(s), overlap: {{overlap}}",
"actions.syncCheckCollision": "Migration collision: {{file}} → rename to {{suggestion}}",
"add": "Add lane", "add": "Add lane",
"addLane": "Create a lane from a working directory", "addLane": "Create a lane from a working directory",
"addLaneBaseLabel": "Branch to fork from", "addLaneBaseLabel": "Branch to fork from",
@@ -76,6 +84,9 @@
"runtime.steppedAsideTitle": "Stepped aside from {{expected}}, which was already in use", "runtime.steppedAsideTitle": "Stepped aside from {{expected}}, which was already in use",
"runtime.stop": "■ down", "runtime.stop": "■ down",
"runtime.toggleTitle": "Boot or stop this lane's own application stack (separate from its Claude run)", "runtime.toggleTitle": "Boot or stop this lane's own application stack (separate from its Claude run)",
"integrations.ci_wait": "CI wait",
"integrations.dev_qc": "dev QC",
"integrations.tracker": "tracker",
"stageUndeclared": "not declared", "stageUndeclared": "not declared",
"status.failed": "failed", "status.failed": "failed",
"status.idle": "idle", "status.idle": "idle",
+11
View File
@@ -8,6 +8,14 @@
"action.stop": "dừng", "action.stop": "dừng",
"actionError": "Thao tác làn đường thất bại: {{message}}", "actionError": "Thao tác làn đường thất bại: {{message}}",
"actionErrorUnknown": "Lỗi không xác định", "actionErrorUnknown": "Lỗi không xác định",
"actions.agentsInstall": "Cài agent",
"actions.agentsInstallResult": "Đã cài: {{files}}",
"actions.busy": "…",
"actions.mcpSync": "Đồng bộ MCP",
"actions.mcpSyncResult": "Đã đồng bộ: {{servers}}",
"actions.syncCheck": "Kiểm tra đồng bộ dev",
"actions.syncCheckClean": "DEV_DELTA: {{count}} file, trùng: {{overlap}}",
"actions.syncCheckCollision": "Trùng migration: {{file}} → đổi tên thành {{suggestion}}",
"add": "Thêm lane", "add": "Thêm lane",
"addLane": "Tạo lane từ một thư mục làm việc", "addLane": "Tạo lane từ một thư mục làm việc",
"addLaneBaseLabel": "Nhánh để tạo nhánh mới", "addLaneBaseLabel": "Nhánh để tạo nhánh mới",
@@ -76,6 +84,9 @@
"runtime.steppedAsideTitle": "Đã lùi khỏi {{expected}} vì cổng đó đang bận", "runtime.steppedAsideTitle": "Đã lùi khỏi {{expected}} vì cổng đó đang bận",
"runtime.stop": "■ dừng", "runtime.stop": "■ dừng",
"runtime.toggleTitle": "Chạy hoặc dừng stack ứng dụng của lane này (khác với phiên Claude của nó)", "runtime.toggleTitle": "Chạy hoặc dừng stack ứng dụng của lane này (khác với phiên Claude của nó)",
"integrations.ci_wait": "CI wait",
"integrations.dev_qc": "dev QC",
"integrations.tracker": "tracker",
"stageUndeclared": "chưa khai báo", "stageUndeclared": "chưa khai báo",
"status.failed": "thất bại", "status.failed": "thất bại",
"status.idle": "rảnh", "status.idle": "rảnh",