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;
|
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: [] });
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
Reference in New Issue
Block a user