docs(lanes): plan F4 — lane actions UI (LaneCard + Settings)

6 tasks: skills-install lib extraction + route, gc route, client API
methods, LaneCard additions (agents install/mcp sync/integration
badges/sync-base check — read-only, no merge button), Settings
additions (skills install + housekeeping), and test coverage
(LaneCard.test.tsx cases + screens snapshot regen).
This commit is contained in:
2026-08-06 09:17:37 +07:00
parent 5f114d7c6f
commit 99465d2095
@@ -0,0 +1,870 @@
# F4 — Lane Actions UI Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Surface `ccam lanes agents install`, `ccam lanes mcp sync`, `ccam lanes integration`, `ccam lanes sync-base --check`, `ccam skills install`, and `ccam lanes gc` as clickable UI, so they aren't terminal-only.
**Architecture:** Two new machine-wide backend routes (`POST /api/skills/install`, `POST /api/lanes/gc`) backed by a small extracted lib module (`server/lib/skills-install.js`) and the already-built `lane-gc.js`; six new client API methods; four additions to `LaneCard.tsx` (gated on `runtime?.available`); two additions to `Settings.tsx`; bilingual i18n keys; test coverage for both.
**Tech Stack:** Express routes (existing pattern), React function components + hooks (existing pattern), `react-i18next`, Vitest + Testing Library.
## Global Constraints
- Every applicable `.js`/`.tsx` source file MUST start with the project's authorship header — verify with `bash .claude/skills/file-headers/scripts/check-headers.sh`. `.json` i18n files do not need one (confirm against an existing locale file, which has none).
- **No merge button anywhere.** The "Check dev sync" button in `LaneCard.tsx` calls `sync-base` in `mode: "check"` ONLY — read-only preflight. Merging (`mode: "merge"` / `--continue`) is never exposed in this UI; that stays a session/skill action.
- `bin/ccam.js`'s `cmdSkillsInstall` must be refactored to CALL the extracted `server/lib/skills-install.js` function, not duplicate its logic — the CLI and the new route share one implementation.
- New routes follow this repo's existing error-shape convention: `200` with the resource on success, `4xx` with `{error: {code, message}}` on a documented failure, `500` with `{error: {code, message}}` on anything else.
- Never use `git add -A`. Stage exactly the files each task names.
- Run `npm run test:server` (full suite) plus `bash .claude/skills/file-headers/scripts/check-headers.sh` before every backend-touching commit; run `npm run test:client` before every frontend-touching commit.
- Both i18n locales (`en`, `vi`) get every new key, added together in the same commit — never leave one locale behind.
---
### Task 1: `server/lib/skills-install.js` + `POST /api/skills/install`
**Files:**
- Create: `server/lib/skills-install.js`
- Modify: `bin/ccam.js:1531-1542` (`cmdSkillsInstall`, refactor to call the new lib)
- Modify: `server/routes/lanes.js` (new route — see note below on router mounting)
- Modify: `server/index.js` or wherever routers are mounted (need to confirm — see Step 0)
**Interfaces:**
- Produces: `installShipFeatureLaneSkill({repoRoot}) => {installed: true, path: string}`. Throws with `.code === "ENOSKILLSRC"` if the source `.claude/skills/ship-feature-lane` doesn't exist under `repoRoot`.
- [ ] **Step 1: Create `server/lib/skills-install.js`**
```js
/**
* @file Installs .claude/skills/ship-feature-lane/ into ~/.claude/skills/, so
* /ship-feature-lane is discoverable from a session running inside any
* lane's own working directory — not just inside this repo, where it lives
* until installed. Shared by bin/ccam.js's `ccam skills install` CLI and the
* POST /api/skills/install route, so there is exactly one copy of this
* logic. Pure filesystem action.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
/**
* @param {{repoRoot: string}} options - `repoRoot` is this checkout's own
* root (bin/ccam.js already computes this as `REPO_ROOT`; the server
* computes its own equivalent — see the route for how).
* @returns {{installed: true, path: string}}
*/
function installShipFeatureLaneSkill({ repoRoot }) {
const src = path.join(repoRoot, ".claude", "skills", "ship-feature-lane");
if (!fs.existsSync(src)) {
throw Object.assign(new Error(`not found: ${src}`), { code: "ENOSKILLSRC" });
}
const dest = path.join(os.homedir(), ".claude", "skills", "ship-feature-lane");
fs.mkdirSync(path.dirname(dest), { recursive: true });
fs.cpSync(src, dest, { recursive: true, force: true });
return { installed: true, path: dest };
}
module.exports = { installShipFeatureLaneSkill };
```
- [ ] **Step 2: Refactor `bin/ccam.js`'s `cmdSkillsInstall` to call it**
Replace the current body (`bin/ccam.js:1531-1542`):
```js
function cmdSkillsInstall() {
const src = path.join(REPO_ROOT, ".claude", "skills", "ship-feature-lane");
if (!fs.existsSync(src)) {
console.error(`✖ not found: ${src}`);
process.exitCode = 1;
return;
}
const dest = path.join(require("node:os").homedir(), ".claude", "skills", "ship-feature-lane");
fs.mkdirSync(path.dirname(dest), { recursive: true });
fs.cpSync(src, dest, { recursive: true, force: true });
console.log(`${c.green("✔")} installed ship-feature-lane skill -> ${dest}`);
}
```
with:
```js
function cmdSkillsInstall() {
const { installShipFeatureLaneSkill } = require(
path.join(REPO_ROOT, "server", "lib", "skills-install.js")
);
try {
const result = installShipFeatureLaneSkill({ repoRoot: REPO_ROOT });
console.log(`${c.green("✔")} installed ship-feature-lane skill -> ${result.path}`);
} catch (err) {
console.error(`${err.message}`);
process.exitCode = 1;
}
}
```
- [ ] **Step 3: Add the route**
There is no existing `/api/skills` mount — confirmed by reading `server/index.js:88-109`'s full list of `app.use("/api/...")` lines (sessions, agents, events, stats, hooks, analytics, pricing, settings, workflows, push, import, updates, cc-config, run, lanes, locks, alerts, webhooks, remote-sources, metrics — no skills). Create a new one-route router file `server/routes/skills.js`:
```js
/**
* @file Machine-wide (not lane-scoped) skill installation.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const { Router } = require("express");
const path = require("node:path");
const { installShipFeatureLaneSkill } = require("../lib/skills-install");
const { sameOriginGuard } = require("./run");
const router = Router();
/** This server's own checkout root — server/routes/skills.js is two levels
* under it (server/routes/), same computation bin/ccam.js's REPO_ROOT
* already does from its own location. */
const REPO_ROOT = path.resolve(__dirname, "..", "..");
router.post("/install", sameOriginGuard, (req, res) => {
try {
res.json(installShipFeatureLaneSkill({ repoRoot: REPO_ROOT }));
} catch (err) {
if (err.code === "ENOSKILLSRC") {
return res.status(400).json({ error: { code: err.code, message: err.message } });
}
res.status(500).json({ error: { code: err.code || "ERUNTIME", message: err.message } });
}
});
module.exports = router;
```
Add the require line to `server/index.js` near its sibling router requires (after `const locksRouter = require("./routes/locks");`, `server/index.js:69`):
```js
const skillsRouter = require("./routes/skills");
```
Add the mount line after the last existing `app.use("/api/...")` line (`server/index.js:109`, `app.use("/api/metrics", metricsRouter);`):
```js
app.use("/api/skills", skillsRouter);
```
(`sameOriginGuard` is already exported from `./run` — every other mutating route in this codebase imports it from there the same way, confirmed by the existing `/:id/agents/install`/`/:id/mcp/sync` routes in `server/routes/lanes.js` both doing exactly this.)
- [ ] **Step 4: Manual smoke check**
```bash
npm run dev &
sleep 3
node bin/ccam.js skills install
echo "CLI exit: $?"
curl -s -X POST http://localhost:4820/api/skills/install | head -c 200
echo
```
Expected: CLI prints success and exits 0; curl returns `{"installed":true,"path":"..."}`. Stop the dev server afterward.
- [ ] **Step 5: Run the full suite + header check**
```bash
bash .claude/skills/file-headers/scripts/check-headers.sh
npm run test:server
```
- [ ] **Step 6: Commit**
```bash
git add server/lib/skills-install.js server/routes/skills.js server/index.js bin/ccam.js
git commit -m "feat(lanes): extract skills-install lib, add POST /api/skills/install (F4)"
```
---
### Task 2: `POST /api/lanes/gc`
**Files:**
- Modify: `server/routes/lanes.js`
**Interfaces:**
- Consumes: `reapOrphanMcp({dryRun}) => number[]`, `capOversizedLogs({dryRun}) => {path, sizeBefore}[]` from `require("../lib/lane-gc")` (both already exist, built in F3c).
- Produces: `POST /api/lanes/gc` — body `{dryRun?: boolean}`, `200` with `{reaped: number[], capped: {path: string, sizeBefore: number}[]}`.
- [ ] **Step 1: Add the import**
Near the other `lib` requires in `server/routes/lanes.js` (search for the `lane-agents`/`lane-mcp` requires added in E3/F1):
```js
const { reapOrphanMcp, capOversizedLogs } = require("../lib/lane-gc");
```
- [ ] **Step 2: Add the route**
Insert BEFORE the first `/:id`-pattern route (`server/routes/lanes.js:177`, `router.get("/:id", ...)`) — a literal single-segment path like `/gc` doesn't collide with any `/:id` route regardless of order (different HTTP semantics aside, Express matches literal segments before treating them as a param only when there's no more-specific literal route registered; putting it earlier is the conventional, unambiguous choice this repo's other non-`:id` routes like `/ensure` and `/worktree` already follow — insert near those):
```js
/**
* Machine-wide housekeeping — not scoped to one lane. Reaps orphaned
* Playwright MCP processes and caps oversized hook logs across every lane
* on this machine. See server/lib/lane-gc.js for what "orphaned" and
* "oversized" mean.
*/
router.post("/gc", sameOriginGuard, (req, res) => {
const dryRun = req.body?.dryRun === true;
try {
const reaped = reapOrphanMcp({ dryRun });
const capped = capOversizedLogs({ dryRun });
res.json({ reaped, capped });
} catch (err) {
res.status(500).json({ error: { code: err.code || "ERUNTIME", message: err.message } });
}
});
```
- [ ] **Step 3: Manual smoke check**
```bash
npm run dev &
sleep 3
curl -s -X POST http://localhost:4820/api/lanes/gc -H 'Content-Type: application/json' -d '{"dryRun":true}'
echo
```
Expected: `{"reaped":[],"capped":[]}` (or real entries if this machine happens to have orphans/oversized logs right now — either is correctly wired). Stop the dev server afterward.
- [ ] **Step 4: Run the full suite + header check**
```bash
bash .claude/skills/file-headers/scripts/check-headers.sh
npm run test:server
```
- [ ] **Step 5: Commit**
```bash
git add server/routes/lanes.js
git commit -m "feat(lanes): add POST /api/lanes/gc route (F4)"
```
---
### Task 3: Client API additions
**Files:**
- Modify: `client/src/lib/api.ts`
**Interfaces:**
- Consumes: Task 1's `POST /api/skills/install`, Task 2's `POST /api/lanes/gc`, and the already-existing `POST /lanes/:id/agents/install`, `POST /lanes/:id/mcp/sync`, `GET /lanes/:id/integrations/:name`, `POST /lanes/:id/sync-base`.
- Produces: `api.lanes.agentsInstall`, `api.lanes.mcpSync`, `api.lanes.integration`, `api.lanes.syncBaseCheck`, `api.settings.skillsInstall`, `api.settings.gc` — exact signatures below, consumed by Tasks 4 and 5.
- [ ] **Step 1: Add to the `lanes` object**
Inside `client/src/lib/api.ts`'s `lanes` object (find its closing brace around line 2066; add these four methods right before it, after the existing `proof` sub-object):
```typescript
agentsInstall: (id: number) =>
request<{ installed: string[] }>(`/lanes/${id}/agents/install`, {
method: "POST",
body: "{}",
}),
mcpSync: (id: number) =>
request<{ servers: string[]; profilesSeeded: string[] }>(`/lanes/${id}/mcp/sync`, {
method: "POST",
body: "{}",
}),
integration: (id: number, name: string) =>
request<{ enabled: boolean }>(`/lanes/${id}/integrations/${encodeURIComponent(name)}`),
syncBaseCheck: (id: number, branch?: string) =>
request<{
code: number;
devDelta?: string[] | null;
overlap?: string[] | null;
collisions?: { file: string; collidesWith: string; suggestion: string }[];
}>(`/lanes/${id}/sync-base`, {
method: "POST",
body: JSON.stringify({ mode: "check", branch }),
}),
```
- [ ] **Step 2: Add to the `settings` object**
Inside `client/src/lib/api.ts`'s `settings` object (find its closing brace around line 1075; add these two methods right before it, after `cleanup`):
```typescript
skillsInstall: () =>
request<{ installed: true; path: string }>("/skills/install", { method: "POST", body: "{}" }),
gc: (body: { dryRun?: boolean } = {}) =>
request<{ reaped: number[]; capped: { path: string; sizeBefore: number }[] }>("/lanes/gc", {
method: "POST",
body: JSON.stringify(body),
}),
```
Note: `api.settings.gc` calls `/lanes/gc`, not `/settings/gc` — the route lives under the lanes router (Task 2), even though the UI surfaces it from the Settings page. This mismatch between UI location and API namespace is intentional and matches the backend's actual route ownership (housekeeping is lane-domain logic, Settings is just where a human clicks it).
- [ ] **Step 3: Type-check**
```bash
cd client && npx tsc --noEmit
```
Expected: no new errors.
- [ ] **Step 4: Commit**
```bash
git add client/src/lib/api.ts
git commit -m "feat(lanes): add client API methods for F4 lane actions" -- client/src/lib/api.ts
```
(Run from repo root; the `--` before the path is only needed if `git commit` is invoked with stray trailing args — omit it if using the plain `git add` + `git commit -m` two-command form shown elsewhere in this plan.)
---
### Task 4: `LaneCard.tsx` additions
**Files:**
- Modify: `client/src/components/lanes/LaneCard.tsx`
- Modify: `client/src/i18n/locales/en/lanes.json`
- Modify: `client/src/i18n/locales/vi/lanes.json`
**Interfaces:**
- Consumes: `api.lanes.agentsInstall`, `api.lanes.mcpSync`, `api.lanes.integration`, `api.lanes.syncBaseCheck` (Task 3).
- [ ] **Step 1: Add i18n keys**
In `client/src/i18n/locales/en/lanes.json`, add (matching the file's existing flat dot-key convention — insert alphabetically near the existing `runtime.*` keys):
```json
"actions.agentsInstall": "Install agents",
"actions.agentsInstallResult": "Installed: {{files}}",
"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}}",
"actions.busy": "…",
"integrations.tracker": "tracker",
"integrations.dev_qc": "dev QC",
"integrations.ci_wait": "CI wait",
```
In `client/src/i18n/locales/vi/lanes.json`, add the parallel Vietnamese entries:
```json
"actions.agentsInstall": "Cài agent",
"actions.agentsInstallResult": "Đã cài: {{files}}",
"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}}",
"actions.busy": "…",
"integrations.tracker": "tracker",
"integrations.dev_qc": "dev QC",
"integrations.ci_wait": "CI wait",
```
- [ ] **Step 2: Add the integration-status hook + state**
In `client/src/components/lanes/LaneCard.tsx`, add a new hook near `useLaneRuntime`/`useLaneGitFacts` (around line 77):
```typescript
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;
});
setState(next);
})
.catch(() => {
if (alive) setState(null);
});
return () => {
alive = false;
};
}, [laneId, available]);
return state as Record<(typeof INTEGRATION_NAMES)[number], boolean> | null;
}
```
In the component body, alongside the existing `runtime`/`git`/`locks` hook calls (around line 161):
```typescript
const integrations = useLaneIntegrations(lane.id, runtime?.available === true);
```
Also add busy/result state for the three new buttons, near `runtimeBusy`/`bootLine` (around line 158-159):
```typescript
const [laneActionBusy, setLaneActionBusy] = useState<"agents" | "mcp" | "sync-check" | null>(null);
const [laneActionResult, setLaneActionResult] = useState<string | null>(null);
```
- [ ] **Step 3: Add the three click handlers**
Near the existing `runtimeAction` function (around line 174):
```typescript
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?.length) {
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",
});
});
```
- [ ] **Step 4: Add the JSX**
In the git-facts `<dl>` block (`client/src/components/lanes/LaneCard.tsx:351-374`), add the integration badges row right after the existing `<dl>` closes, before the `mt-auto` controls div:
```jsx
{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>
)}
```
In the `mt-auto` controls row (`client/src/components/lanes/LaneCard.tsx`, right after the existing `runtime?.available && (...)` up/down button block, around line 419), add the three new buttons, all gated the same way:
```jsx
{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>
</>
)}
```
Below the whole `mt-auto` controls div (still inside the card, after it closes), add the result line:
```jsx
{laneActionResult && (
<p data-testid="lane-action-result" className="mt-1 truncate text-[11px] text-fg-secondary">
{laneActionResult}
</p>
)}
```
- [ ] **Step 5: Manual verification**
```bash
npm run dev &
sleep 3
```
Open `http://localhost:5173`, navigate to Workspace, select a lane with a `.ccam/profile` (`runtime.available`). Confirm: three new buttons appear, three integration badges appear (gray = off, since no `integrations.env` exists yet on a fresh lane), clicking "Check dev sync" shows a result line. Stop the dev server afterward.
- [ ] **Step 6: Run the client suite**
```bash
cd client && npx vitest run
```
Expected: existing tests still pass (Task 6 adds NEW test cases separately — don't add them here, this step is just confirming nothing existing broke).
- [ ] **Step 7: Commit**
```bash
git add client/src/components/lanes/LaneCard.tsx client/src/i18n/locales/en/lanes.json client/src/i18n/locales/vi/lanes.json
git commit -m "feat(lanes): add agents-install/mcp-sync/integration/sync-check to LaneCard (F4)"
```
---
### Task 5: `Settings.tsx` additions
**Files:**
- Modify: `client/src/pages/Settings.tsx`
- Modify: `client/src/i18n/locales/en/settings.json`
- Modify: `client/src/i18n/locales/vi/settings.json`
**Interfaces:**
- Consumes: `api.settings.skillsInstall`, `api.settings.gc` (Task 3).
- [ ] **Step 1: Add i18n keys**
In `client/src/i18n/locales/en/settings.json`, add near the existing `hooks.*` keys:
```json
"lanesSection.title": "Lanes",
"lanesSection.description": "Machine-wide housekeeping and one-time setup for the ship-feature-lane pipeline.",
"lanesSection.installSkill": "Install ship-feature-lane skill",
"lanesSection.installSkillSuccess": "Installed to {{path}}",
"lanesSection.runHousekeeping": "Run housekeeping",
"lanesSection.dryRun": "Dry run",
"lanesSection.gcResult": "Reaped {{reaped}} process(es), capped {{capped}} log(s)",
```
In `client/src/i18n/locales/vi/settings.json`, add the parallel entries:
```json
"lanesSection.title": "Lanes",
"lanesSection.description": "Bảo trì toàn máy và cài đặt một lần cho pipeline ship-feature-lane.",
"lanesSection.installSkill": "Cài skill ship-feature-lane",
"lanesSection.installSkillSuccess": "Đã cài vào {{path}}",
"lanesSection.runHousekeeping": "Chạy dọn dẹp",
"lanesSection.dryRun": "Chạy thử (dry run)",
"lanesSection.gcResult": "Đã dọn {{reaped}} tiến trình, cắt {{capped}} log",
```
- [ ] **Step 2: Add the TOC entry**
In `SETTINGS_SECTIONS` (`client/src/pages/Settings.tsx:127-148`), add a new entry after the `hooks` one:
```typescript
{ id: "hooks", labelKey: "hooks.title", Icon: Plug },
{ id: "lanes", labelKey: "lanesSection.title", Icon: GitBranch },
```
(Confirm `GitBranch` is already imported from `lucide-react` at the top of the file — if not, add it to the existing `lucide-react` import line; every icon this file uses comes from that one package.)
- [ ] **Step 3: Add state + handlers**
Near `abandonHours`/`purgeDays` (`client/src/pages/Settings.tsx:469-470`):
```typescript
const [gcDryRun, setGcDryRun] = useState(false);
```
Near `handleReinstallHooks` (`client/src/pages/Settings.tsx:724-728`):
```typescript
const handleSkillsInstall = () =>
runAction("skills-install", async () => {
const res = await api.settings.skillsInstall();
return t("lanesSection.installSkillSuccess", { path: res.path });
});
const handleGc = () =>
runAction("gc", async () => {
const res = await api.settings.gc({ dryRun: gcDryRun });
return t("lanesSection.gcResult", { reaped: res.reaped.length, capped: res.capped.length });
});
```
- [ ] **Step 4: Add the section JSX**
Directly after the Hooks `</section>` closes (`client/src/pages/Settings.tsx`, end of the block shown at lines 1301-1358):
```jsx
<section id="lanes" className="scroll-mt-24">
<h3 className="text-sm font-medium text-fg-secondary flex items-center gap-2 mb-1">
<GitBranch className="w-4 h-4 text-fg-muted" />
{t("lanesSection.title")}
</h3>
<p className="text-xs text-fg-muted mb-4">{t("lanesSection.description")}</p>
<div className="card p-5 space-y-4">
<div className="flex items-center justify-between flex-wrap gap-3">
<span className="text-xs text-fg-secondary">{t("lanesSection.installSkill")}</span>
<button
onClick={handleSkillsInstall}
disabled={actionLoading !== null}
className="btn-ghost text-xs disabled:opacity-50"
>
{actionLoading === "skills-install" ? (
<RefreshCw className="w-3.5 h-3.5 animate-spin" />
) : (
<RotateCcw className="w-3.5 h-3.5" />
)}
{t("lanesSection.installSkill")}
</button>
</div>
{actionBanner(["skills-install"])}
<div className="flex items-center justify-between flex-wrap gap-3">
<label className="flex items-center gap-2 text-xs text-fg-secondary">
<input
type="checkbox"
checked={gcDryRun}
onChange={(e) => setGcDryRun(e.target.checked)}
/>
{t("lanesSection.dryRun")}
</label>
<button
onClick={handleGc}
disabled={actionLoading !== null}
className="btn-ghost text-xs disabled:opacity-50"
>
{actionLoading === "gc" ? (
<RefreshCw className="w-3.5 h-3.5 animate-spin" />
) : (
<RotateCcw className="w-3.5 h-3.5" />
)}
{t("lanesSection.runHousekeeping")}
</button>
</div>
{actionBanner(["gc"])}
</div>
</section>
```
- [ ] **Step 5: Manual verification**
```bash
npm run dev &
sleep 3
```
Open `http://localhost:5173/settings`, confirm a new "Lanes" section appears with both buttons, both TOC entries navigate correctly, clicking each shows a result banner. Stop the dev server afterward.
- [ ] **Step 6: Run the client suite**
```bash
cd client && npx vitest run
```
- [ ] **Step 7: Commit**
```bash
git add client/src/pages/Settings.tsx client/src/i18n/locales/en/settings.json client/src/i18n/locales/vi/settings.json
git commit -m "feat(lanes): add Lanes section (skills install, housekeeping) to Settings (F4)"
```
---
### Task 6: Tests
**Files:**
- Modify: `client/src/components/lanes/__tests__/LaneCard.test.tsx`
- Modify (regenerate): `client/src/pages/__tests__/__snapshots__/screens.snapshot.test.tsx.snap`
**Interfaces:** none — test-only, exercises Tasks 3-5's code.
- [ ] **Step 1: Extend the API mock**
In `client/src/components/lanes/__tests__/LaneCard.test.tsx`, extend the `vi.mock("../../../lib/api", ...)` block (currently mocking `git`, `runtime`, `up`, `down`, `preflight` under `lanes`, plus `locks.list`) to add the four new methods:
```typescript
vi.mock("../../../lib/api", () => ({
api: {
lanes: {
git: vi.fn(),
runtime: vi.fn(),
up: vi.fn(),
down: vi.fn(),
preflight: vi.fn().mockResolvedValue({ blocked: [], warnings: [] }),
agentsInstall: vi.fn(),
mcpSync: vi.fn(),
integration: vi.fn(),
syncBaseCheck: vi.fn(),
},
locks: {
list: vi.fn(),
},
},
}));
```
- [ ] **Step 2: Write the failing tests**
Add to the same file, following its existing `describe`/`it` + `render`/`waitFor` pattern:
```typescript
describe("agents install / mcp sync / integration badges / sync check", () => {
beforeEach(() => {
vi.mocked(api.lanes.runtime).mockResolvedValue({
available: true,
provisioned: true,
up: false,
slot: 1,
profileDir: "/work/demo/.ccam/profile",
ports: {},
});
vi.mocked(api.lanes.integration).mockImplementation((_id, name) =>
Promise.resolve({ enabled: name === "tracker" })
);
});
it("shows integration badges reflecting each toggle's state", async () => {
render(<LaneCard lane={makeLane()} onAction={vi.fn()} />);
const tracker = await screen.findByTestId("lane-integration-tracker");
const devQc = await screen.findByTestId("lane-integration-dev_qc");
expect(tracker.className).toContain("status-success");
expect(devQc.className).not.toContain("status-success");
});
it("clicking Install agents calls the API and shows the result", async () => {
vi.mocked(api.lanes.agentsInstall).mockResolvedValue({
installed: ["qc-local.md", "senior-gate-reviewer.md"],
});
render(<LaneCard lane={makeLane()} onAction={vi.fn()} />);
const button = await screen.findByTestId("lane-agents-install");
await userEvent.click(button);
await waitFor(() => expect(api.lanes.agentsInstall).toHaveBeenCalledWith(1));
expect(await screen.findByTestId("lane-action-result")).toHaveTextContent("qc-local.md");
});
it("clicking Sync MCP calls the API and shows the result", async () => {
vi.mocked(api.lanes.mcpSync).mockResolvedValue({
servers: ["playwright"],
profilesSeeded: [],
});
render(<LaneCard lane={makeLane()} onAction={vi.fn()} />);
const button = await screen.findByTestId("lane-mcp-sync");
await userEvent.click(button);
await waitFor(() => expect(api.lanes.mcpSync).toHaveBeenCalledWith(1));
expect(await screen.findByTestId("lane-action-result")).toHaveTextContent("playwright");
});
it("clicking Check dev sync reports a clean result", async () => {
vi.mocked(api.lanes.syncBaseCheck).mockResolvedValue({
code: 0,
devDelta: ["a.txt", "b.txt"],
overlap: [],
});
render(<LaneCard lane={makeLane()} onAction={vi.fn()} />);
const button = await screen.findByTestId("lane-sync-check");
await userEvent.click(button);
await waitFor(() => expect(api.lanes.syncBaseCheck).toHaveBeenCalledWith(1, undefined));
expect(await screen.findByTestId("lane-action-result")).toHaveTextContent("2");
});
it("clicking Check dev sync reports a migration collision", async () => {
vi.mocked(api.lanes.syncBaseCheck).mockResolvedValue({
code: 5,
collisions: [{ file: "002_a.sql", collidesWith: "002_b.sql", suggestion: "003_a.sql" }],
});
render(<LaneCard lane={makeLane()} onAction={vi.fn()} />);
const button = await screen.findByTestId("lane-sync-check");
await userEvent.click(button);
expect(await screen.findByTestId("lane-action-result")).toHaveTextContent("003_a.sql");
});
it("hides all four additions when the lane has no profile", async () => {
vi.mocked(api.lanes.runtime).mockResolvedValue({ available: false });
render(<LaneCard lane={makeLane()} onAction={vi.fn()} />);
await waitFor(() => expect(api.lanes.runtime).toHaveBeenCalled());
expect(screen.queryByTestId("lane-agents-install")).toBeNull();
expect(screen.queryByTestId("lane-mcp-sync")).toBeNull();
expect(screen.queryByTestId("lane-sync-check")).toBeNull();
expect(screen.queryByTestId("lane-integration-tracker")).toBeNull();
});
});
```
- [ ] **Step 3: Run the tests, fix any mismatches against Task 4's actual implementation**
Run: `cd client && npx vitest run src/components/lanes/__tests__/LaneCard.test.tsx`
Expected: all pass. If a `data-testid` or exact text doesn't match what Task 4 actually produced, fix the TEST to match the real (reviewed, working) component — the component is the source of truth once Task 4 has passed its own review, not this test file written from the plan's prose description.
- [ ] **Step 4: Regenerate the screens snapshot**
```bash
cd client && npx vitest run -u
```
Expected: `screens.snapshot.test.tsx`'s snapshot file updates to include the new Settings/LaneCard markup. **Review the diff** (`git diff client/src/pages/__tests__/__snapshots__/screens.snapshot.test.tsx.snap`) before accepting — confirm the ONLY changes are the new Lanes section in Settings and the new buttons/badges in LaneCard (visible if Workspace's snapshot renders a lane with `runtime.available: true` — if the snapshot's fixture lane has no profile, the four additions won't appear in it at all, which is also a correct, expected diff of zero-or-near-zero lines for that screen). Do not blindly accept a snapshot diff that touches unrelated screens — that would indicate a bug introduced elsewhere, and this task's implementer must investigate rather than force-accept it.
- [ ] **Step 5: Run the full client suite + header check**
```bash
cd client && npx vitest run
bash .claude/skills/file-headers/scripts/check-headers.sh
```
- [ ] **Step 6: Commit**
```bash
git add client/src/components/lanes/__tests__/LaneCard.test.tsx client/src/pages/__tests__/__snapshots__/screens.snapshot.test.tsx.snap
git commit -m "test(lanes): cover F4's LaneCard additions + regenerate screens snapshot"
```