From 6d8c5399eaabcfc8191e1034ee38837c2a7f6a76 Mon Sep 17 00:00:00 2001 From: nntrivi2001 Date: Thu, 6 Aug 2026 09:59:33 +0700 Subject: [PATCH] feat(lanes): add agents-install/mcp-sync/integration/sync-check to LaneCard (F4) --- client/src/components/lanes/LaneCard.tsx | 146 ++++++++++++++++++ .../lanes/__tests__/LaneCard.test.tsx | 3 + client/src/i18n/locales/en/lanes.json | 11 ++ client/src/i18n/locales/vi/lanes.json | 11 ++ 4 files changed, 171 insertions(+) diff --git a/client/src/components/lanes/LaneCard.tsx b/client/src/components/lanes/LaneCard.tsx index 9c654a1..a6c0570 100644 --- a/client/src/components/lanes/LaneCard.tsx +++ b/client/src/components/lanes/LaneCard.tsx @@ -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 | 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 = {}; + 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 = { 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(null); + const [laneActionBusy, setLaneActionBusy] = useState<"agents" | "mcp" | "sync-check" | null>( + null + ); + const [laneActionResult, setLaneActionResult] = useState(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 + ) => { + 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({ + {integrations && ( +
+ {(["tracker", "dev_qc", "ci_wait"] as const).map((name) => ( + + {t(`integrations.${name}`)} + + ))} +
+ )} + {/* mt-auto pins the controls to the bottom so cards of differing height in one grid row still line their buttons up. */}
@@ -417,6 +514,46 @@ export default function LaneCard({ : t("runtime.boot")} )} + {runtime?.available && ( + <> + + + + + )} {/* 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({ )}
+ + {laneActionResult && ( +

+ {laneActionResult} +

+ )} {destructiveAction && ( diff --git a/client/src/components/lanes/__tests__/LaneCard.test.tsx b/client/src/components/lanes/__tests__/LaneCard.test.tsx index 913ca68..216e4ef 100644 --- a/client/src/components/lanes/__tests__/LaneCard.test.tsx +++ b/client/src/components/lanes/__tests__/LaneCard.test.tsx @@ -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: [] }); diff --git a/client/src/i18n/locales/en/lanes.json b/client/src/i18n/locales/en/lanes.json index dc9ea9d..3ec93c3 100644 --- a/client/src/i18n/locales/en/lanes.json +++ b/client/src/i18n/locales/en/lanes.json @@ -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", diff --git a/client/src/i18n/locales/vi/lanes.json b/client/src/i18n/locales/vi/lanes.json index ec3612b..c58f7e5 100644 --- a/client/src/i18n/locales/vi/lanes.json +++ b/client/src/i18n/locales/vi/lanes.json @@ -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",