feat(lanes): add agents-install/mcp-sync/integration/sync-check to LaneCard (F4)
This commit is contained in:
@@ -130,6 +130,40 @@ function useLaneLocks(laneSlot: number | null): NamedLock[] {
|
||||
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",
|
||||
@@ -158,9 +192,14 @@ export default function LaneCard({
|
||||
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`:
|
||||
@@ -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
|
||||
* disabled button through all of it reads as a hang. The hook's own output
|
||||
@@ -373,6 +452,24 @@ export default function LaneCard({
|
||||
</div>
|
||||
</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
|
||||
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">
|
||||
@@ -417,6 +514,46 @@ export default function LaneCard({
|
||||
: 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
|
||||
@@ -489,6 +626,15 @@ export default function LaneCard({
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{laneActionResult && (
|
||||
<p
|
||||
data-testid="lane-action-result"
|
||||
className="mt-1 truncate text-[11px] text-fg-secondary"
|
||||
>
|
||||
{laneActionResult}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{destructiveAction && (
|
||||
|
||||
@@ -23,6 +23,7 @@ vi.mock("../../../lib/api", () => ({
|
||||
up: vi.fn(),
|
||||
down: vi.fn(),
|
||||
preflight: vi.fn().mockResolvedValue({ blocked: [], warnings: [] }),
|
||||
integration: vi.fn(),
|
||||
},
|
||||
locks: {
|
||||
list: vi.fn(),
|
||||
@@ -37,6 +38,8 @@ beforeEach(() => {
|
||||
// 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).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).mockResolvedValue({ locks: [] });
|
||||
|
||||
@@ -8,6 +8,14 @@
|
||||
"action.stop": "stop",
|
||||
"actionError": "Lane action failed: {{message}}",
|
||||
"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",
|
||||
"addLane": "Create a lane from a working directory",
|
||||
"addLaneBaseLabel": "Branch to fork from",
|
||||
@@ -76,6 +84,9 @@
|
||||
"runtime.steppedAsideTitle": "Stepped aside from {{expected}}, which was already in use",
|
||||
"runtime.stop": "■ down",
|
||||
"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",
|
||||
"status.failed": "failed",
|
||||
"status.idle": "idle",
|
||||
|
||||
@@ -8,6 +8,14 @@
|
||||
"action.stop": "dừng",
|
||||
"actionError": "Thao tác làn đường thất bại: {{message}}",
|
||||
"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",
|
||||
"addLane": "Tạo lane từ một thư mục làm việc",
|
||||
"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.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ó)",
|
||||
"integrations.ci_wait": "CI wait",
|
||||
"integrations.dev_qc": "dev QC",
|
||||
"integrations.tracker": "tracker",
|
||||
"stageUndeclared": "chưa khai báo",
|
||||
"status.failed": "thất bại",
|
||||
"status.idle": "rảnh",
|
||||
|
||||
Reference in New Issue
Block a user