diff --git a/internal/gui/assets/i18n.js b/internal/gui/assets/i18n.js index 0992a513..60f3afa5 100644 --- a/internal/gui/assets/i18n.js +++ b/internal/gui/assets/i18n.js @@ -2790,6 +2790,18 @@ const I18N = { "Give {agent} all of them": "为 {agent} 启用其中全部技能", "Every agent has all of them — click to take them from every one": "每个 Agent 都已有其中全部技能,点击全部关闭", "Give all of them to every agent": "为每个 Agent 启用其中全部技能", + "{name} is on for {agents}": "已为 {agents} 启用 {name}", + "{name} is off for {agents}": "已从 {agents} 中关闭 {name}", + "{n} skills are on for {agents}": "已为 {agents} 启用 {n} 个技能", + "{n} skills are off for {agents}": "已从 {agents} 中关闭 {n} 个技能", + "Select {name}": "选中 {name}", + "Clear": "清空", + "Stop picking skills": "结束多选", + "Pick some skills and turn them on or off together": "选中多个技能,一起开启或关闭", + "All {n} that match": "全部 {n} 个匹配项", + "All {n}": "全部 {n} 个", + "Pick skills to turn them on or off together": "勾选技能,再用右侧图标一起开启或关闭", + "Unpick them all": "取消全部选中", "{repo}'s skills are on for {agents}": "已为 {agents} 启用 {repo} 的全部技能", "{repo}'s skills are off for {agents}": "已从 {agents} 中关闭 {repo} 的全部技能", "Open {repo} on GitHub": "在 GitHub 上打开 {repo}", @@ -5724,6 +5736,18 @@ const I18N = { "Give {agent} all of them": "{agent} ですべてオンにする", "Every agent has all of them — click to take them from every one": "すべてのエージェントがすべて持っています。クリックですべてオフ", "Give all of them to every agent": "すべてのエージェントですべてオンにする", + "{name} is on for {agents}": "{agents} で {name} をオンにしました", + "{name} is off for {agents}": "{agents} で {name} をオフにしました", + "{n} skills are on for {agents}": "{agents} で {n} 個のスキルをオンにしました", + "{n} skills are off for {agents}": "{agents} で {n} 個のスキルをオフにしました", + "Select {name}": "{name} を選択", + "Clear": "クリア", + "Stop picking skills": "選択を終了", + "Pick some skills and turn them on or off together": "複数のスキルを選んでまとめてオン・オフする", + "All {n} that match": "一致する {n} 個すべて", + "All {n}": "{n} 個すべて", + "Pick skills to turn them on or off together": "スキルを選び、右のアイコンでまとめてオン・オフ", + "Unpick them all": "選択をすべて解除", "{repo}'s skills are on for {agents}": "{agents} で {repo} のスキルをすべてオンにしました", "{repo}'s skills are off for {agents}": "{agents} で {repo} のスキルをすべてオフにしました", "Open {repo} on GitHub": "GitHub で {repo} を開く", diff --git a/internal/gui/assets/library.css b/internal/gui/assets/library.css index 64990d25..ca9b1a4e 100644 --- a/internal/gui/assets/library.css +++ b/internal/gui/assets/library.css @@ -477,3 +477,13 @@ input.lib-setname:focus { border-color: var(--accent); box-shadow: 0 0 0 3px col .lib-row.lib-subhead:hover { background: color-mix(in srgb, var(--card-2) 70%, var(--pill)); } /* no color-mix() before Safari 16.2 (#220): the sticky head stays opaque */ @supports not (color: color-mix(in srgb, red 50%, blue)) { .lib-head { background: var(--bg); } } +/* skills picked to turn on or off together (#791): a box on each row and + heading, and a bar that stays in view under the list while it's long */ +.lib-pickbox { margin: 0; flex: none; } +.lib-vl > .row.lib-skill.picked { background: var(--accent-soft); } +.lib-pickbar { position: sticky; bottom: 12px; z-index: 3; margin-top: 8px; min-height: 44px; border: 1px solid var(--line); border-radius: 12px; background: var(--card); box-shadow: var(--shadow); } +.lib-pickall { display: inline-flex; align-items: center; gap: 8px; font-size: 12px; color: var(--fg-2); white-space: nowrap; } +.lib-pickn { font-size: 12px; font-weight: 500; color: var(--fg); white-space: nowrap; } +.lib-pickn.none { font-weight: 400; color: var(--faint); white-space: normal; } +.lib-pickbar .lib-updall { height: 24px; padding: 0 9px; font-size: 12px; flex: none; } +.lib-subhead > .lib-agents { margin-left: auto; } diff --git a/internal/gui/assets/library.js b/internal/gui/assets/library.js index 62dbbc0c..9208cb34 100644 --- a/internal/gui/assets/library.js +++ b/internal/gui/assets/library.js @@ -1693,6 +1693,8 @@ body.append(intro(t("A skill is a folder with a SKILL.md an agent loads when it's needed. The library keeps each one once and links it into the agents you pick."))); body.append(installCard()); const all = skillAgents(); + for (const n of picked) if (!skillNamed(n)) picked.delete(n); + if (lib.skills.length < 2) picking = false; if (lib.skills.length) { const rh = el("div", "row-head lib-skillshead"); rh.append(el("span", "label", t("In the library"))); @@ -1701,7 +1703,7 @@ const fresh = lib.skills.filter((s) => s.kind === "github" || s.origin); rh.append(el("span", "grow")); if (lib.skills.length > 1) rh.append(sortBy("libSkills", SKILL_SORTS(), () => { if (box.isConnected) drawSkills(box, all); })); - if (lib.skills.length > 1 && all.length) rh.append(...everySkillButtons(all)); + if (lib.skills.length > 1 && all.length) rh.append(pickButton(), ...everySkillButtons(all)); if (byAgentRows("skills").length) rh.append(byAgentButton("skills")); if (lib.skills.length > 1) rh.append(removeEverySkillButton()); if (lib.skills.some((s) => s.kind === "github")) { @@ -1736,6 +1738,7 @@ body.append(rh); drawSkills(box, all); body.append(box); + if (picking) body.append(pickBar(all)); } if (lib.foundSkills.length) { const rh = el("div", "row-head"); @@ -1779,6 +1782,8 @@ // drawn, the rest is room kept for them, so a scroll through a thousand // skills draws a few rows now and then instead of laying out and painting // each as it comes into view. + let picking = false; // the skills' rows have a box each, to pick some (#791) + const picked = new Set(); // the names picked let skillQuery = ""; // what the filter over the skills holds let skillTimer = 0; // the filter's redraw, waiting for typing to pause const unfiltered = new Set(); // groups folded while filtering, till the filter changes @@ -1921,7 +1926,7 @@ } if (!cards.length) cards.push(el("div", "list lib-none", t("No skill matches “{q}”.", { q: skillQuery.trim() }))); box.replaceChildren(...cards); - if (box.isConnected) syncLists(); + if (box.isConnected) { syncLists(); if (picking) syncPicks(); } // the bar's All counts what the filter shows } // A group's list: its skills, or its parts, each a heading over its own. @@ -1935,17 +1940,18 @@ if (!ph.length) continue; const fk = g.key + "\n" + p.key; const folded = filtering ? unfiltered.has(fk) : folds[fk] ?? g.skills.length > MANY; - items.push({ key: ["p", p.key, folded, filtering, ph.length].join("\n"), h: SUB_H, make: () => partHead(g, p, ph, folded, filtering, fk, redraw) }); + items.push({ key: ["p", p.key, folded, filtering, ph.length].join("\n"), h: SUB_H, make: () => partHead(g, p, ph, folded, filtering, fk, redraw, all) }); if (!folded) for (const s of ph) items.push(row(s)); } return items; } - function partHead(g, p, hits, folded, filtering, fk, redraw) { + function partHead(g, p, hits, folded, filtering, fk, redraw, all) { const head = el("div", "row lib-row click lib-subhead" + (folded ? "" : " open")); const chev = el("span", "chev"); chev.append(svg(CHEV_R, 10, 1.7)); const n = p.skills.length; + if (picking) head.append(pickAll(hits)); head.append(chev, el("span", "name" + (p.mono && p.key ? " mono" : ""), p.label), el("span", "sub", filtering && hits.length !== n ? t("{n} of {total}", { n: hits.length, total: n }) : String(n))); // a dot when GitHub changed some of its skills @@ -1955,6 +1961,11 @@ dot.title = stale === 1 ? t("1 skill has an update") : t("{n} skills have updates", { n: stale }); head.append(dot); } + // its skills on or off for an agent at once, as a group's are (#791) + if (all.length) { + const live = p.skills.map((s) => skillNamed(s.name) || s); + head.append(el("span", "grow"), groupChips(live, all, null)); + } head.title = folded ? t("Show its skills") : t("Hide its skills"); head.onclick = () => { if (filtering) { if (folded) unfiltered.delete(fk); else unfiltered.add(fk); } @@ -1992,7 +2003,7 @@ u.title = stale.length === 1 ? t("Fetch {name} from GitHub again", { name: stale[0].name }) : t("Fetch the {n} skills GitHub changed again", { n: stale.length }); tags.append(u); } - const have = groupChips(g, all); + const have = groupChips(g.skills, all, g.repo || t("On this computer")); const acts = el("div", "lib-rowacts"); if (g.repo) { const o = button("", "lib-icon", () => browse("https://github.com/" + g.repo)); @@ -2003,6 +2014,7 @@ const chev = el("span", "chev"); chev.append(svg(CHEV_R, 11, 1.7)); const pic = g.repo ? mark(g.skills.find((s) => s.icon)?.icon, GLYPH.skill) : glyph(GLYPH.folder); + if (picking) head.append(pickAll(hits)); head.append(pic, who, tags, have, acts, chev); head.title = folded ? t("Show its skills") : t("Hide its skills"); head.onclick = () => { @@ -2020,9 +2032,12 @@ // mintonight: a repository's skills were on or off one row at a time). // A chip is lit when its agent has them all; one with some is half lit, // and a click gives it the rest. All gives them to every agent shown. - function groupChips(g, all) { - const n = g.skills.length, names = g.skills.map((s) => s.name); - const count = (id) => g.skills.filter((s) => s.agents?.includes(id)).length; + // A part's chips (#791, mintonight: the lark-* skills of Feishu's pack + // on or off together) and a pick's are the same, said of so many skills + // rather than of a repository. + function groupChips(skills, all, repo) { + const n = skills.length, names = skills.map((s) => s.name); + const count = (id) => skills.filter((s) => s.agents?.includes(id)).length; const full = all.filter((a) => count(a.id) === n).map((a) => a.id); const box = agentChips(all, full, async (next) => { const on = next.filter((id) => !full.includes(id)), off = full.filter((id) => !next.includes(id)); @@ -2034,9 +2049,10 @@ if (!v) return; take(v); const who = (ids) => ids.map(nameOf).join(", "); - report(v.result, on.length - ? t("{repo}'s skills are on for {agents}", { repo: g.repo || t("On this computer"), agents: who(on) }) - : t("{repo}'s skills are off for {agents}", { repo: g.repo || t("On this computer"), agents: who(off) })); + report(v.result, repo != null + ? (on.length ? t("{repo}'s skills are on for {agents}", { repo, agents: who(on) }) : t("{repo}'s skills are off for {agents}", { repo, agents: who(off) })) + : n === 1 ? (on.length ? t("{name} is on for {agents}", { name: names[0], agents: who(on) }) : t("{name} is off for {agents}", { name: names[0], agents: who(off) })) + : on.length ? t("{n} skills are on for {agents}", { n, agents: who(on) }) : t("{n} skills are off for {agents}", { n, agents: who(off) })); } catch (e) { status(e.message, "err", 6000); } @@ -2055,6 +2071,77 @@ return box; } + // ---------- some skills, picked, on or off at once ---------- + + // Select puts a box on each skill's row, and on each heading for the + // skills under it, and a bar under the list turns the ones picked on or + // off for an agent at once (#791, mintonight: 能不能支持多选来开启或关闭): + // its chips work as a group's do. Done takes the boxes away. + function pickButton() { + const b = button(picking ? t("Done") : t("Select"), "action lib-updall lib-select", () => { + picking = !picking; + picked.clear(); + render(); + }); + b.title = picking ? t("Stop picking skills") : t("Pick some skills and turn them on or off together"); + return b; + } + + // the skills the filter shows, in every group, folded or not + function shownSkills() { + const q = skillQuery.trim().toLowerCase(); + return skillGroups().flatMap((g) => (q ? g.skills.filter((s) => g.text.get(s).includes(q)) : g.skills)); + } + + // A box for some skills: ticked when they're all picked, half when some + // are; a click picks them all, or none when they all were. + function pickBox(names) { + const box = el("input", "lib-pickbox"); + box.type = "checkbox"; + box._names = names; + paintPick(box); + box.onclick = (e) => { + e.stopPropagation(); + const every = names.length && names.every((n) => picked.has(n)); + for (const n of names) if (every) picked.delete(n); else picked.add(n); + syncPicks(); + }; + return box; + } + const pickAll = (skills) => pickBox(skills.map((s) => s.name)); + function paintPick(box) { + const k = box._names.filter((n) => picked.has(n)).length; + box.checked = k > 0 && k === box._names.length; + box.indeterminate = k > 0 && k < box._names.length; + const row = box.parentElement; + if (row?.classList.contains("lib-skill")) row.classList.toggle("picked", box.checked); + } + // every box drawn, and the bar, as the picks are now + function syncPicks() { + for (const b of page.querySelectorAll(".lib-pickbox")) paintPick(b); + const bar = page.querySelector(".lib-pickbar"); + if (bar) bar.replaceWith(pickBar(skillAgents())); + } + + function pickBar(all) { + const bar = el("div", "row lib-row lib-pickbar"); + const shown = shownSkills(), q = skillQuery.trim(); + const every = el("label", "lib-pickall"); + every.append(pickAll(shown), el("span", "", q ? t("All {n} that match", { n: shown.length }) : t("All {n}", { n: shown.length }))); + every.onclick = (e) => e.stopPropagation(); + const skills = lib.skills.filter((s) => picked.has(s.name)); + const n = skills.length; + bar.append(every, el("span", "lib-pickn" + (n ? "" : " none"), n ? t("{n} selected", { n }) : t("Pick skills to turn them on or off together")), el("span", "grow")); + if (n) { + bar.append(groupChips(skills, all, null)); + const c = button(t("Clear"), "lib-updall lib-pickclear", () => { picked.clear(); syncPicks(); }); + c.title = t("Unpick them all"); + bar.append(c); + } + bar.append(button(t("Done"), "action lib-updall lib-pickdone", () => { picking = false; picked.clear(); render(); })); + return bar; + } + // ---------- a long list, drawn near the view first ---------- // A list of items of known heights ({ key, h, make }) drawn in two goes: @@ -2876,6 +2963,14 @@ rm.title = t("Remove"); acts.append(rm); row.append(mark(s.icon, GLYPH.skill), who, acts, agentChips(all, s.agents, chipsChange("skills/agents", s.name, "skills", (x) => skillRow(x, all)), { problems: s.problems, via: viaFor(s), all: true, always: alwaysFor(s) })); + if (picking) { + const box = pickBox([s.name]); + row.prepend(box); + row.classList.toggle("picked", picked.has(s.name)); + row.onclick = () => box.click(); + row.title = t("Select {name}", { name: s.name }); + return row; + } row.onclick = () => viewSkill(s); row.title = t("Read {name}'s SKILL.md", { name: s.name }); return row; diff --git a/internal/gui/tests/pick-skills.test.cjs b/internal/gui/tests/pick-skills.test.cjs new file mode 100644 index 00000000..73e197db --- /dev/null +++ b/internal/gui/tests/pick-skills.test.cjs @@ -0,0 +1,152 @@ +// Run with Node's test runner and Playwright on the module path; see README.md. +// Many of the library's skills on or off at once (#791, mintonight: 能不能 +// 支持多选分组,或者支持多选来开启或关闭 … lark-* 我希望把他们归到一起统一来 +// 管理). Skills whose names start alike are a part of their list with a +// heading, and that heading's chips give an agent all of them or take them +// away; Select puts a box on every row and heading, and a bar's chips turn +// the ones picked on or off; the bar's box picks every one the filter +// shows. English and Chinese; the API is faked here. +const assert = require("node:assert/strict"); +const fs = require("node:fs/promises"); +const path = require("node:path"); +const { test } = require("node:test"); +const { chromium, webkit } = require("playwright"); + +const assets = path.resolve(__dirname, "../assets"); +const HOME = "/Users/me"; +const agent = (id, name, icon) => ({ id, name, icon, skills: `${HOME}/.${id}/skills`, mcp: `${HOME}/.${id}/mcp.json` }); +const local = (name, agents) => ({ name, kind: "folder", source: `${HOME}/.agents/skills/${name}`, description: "d", agents }); + +function server(lang, state, posts) { + return async (route) => { + const req = route.request(); + const url = new URL(req.url()); + const json = (data) => route.fulfill({ json: data }); + if (url.pathname === "/boot.js") return route.fulfill({ contentType: "text/javascript", body: `window.bootPrefs = {lang:"${lang}",theme:"light",web:true};` }); + if (url.pathname === "/wails/runtime.js") return route.fulfill({ contentType: "text/javascript", body: "export const Window = {};" }); + if (url.pathname === "/api/state") return json({ agents: [], profiles: [], settings: { lang, theme: "light" } }); + if (url.pathname === "/api/library") return json(state.lib); + if (url.pathname === "/api/library/skills/agents-some") { + const b = req.postDataJSON(); + posts.push(b); + for (const s of state.lib.skills) { + if (!b.names.includes(s.name)) continue; + const kept = s.agents.filter((a) => !b.agents.includes(a)); + s.agents = (b.on ? [...kept, ...b.agents] : kept).sort(); + } + return json({ ...state.lib, result: { changed: b.agents } }); + } + if (url.pathname === "/api/providers") return json({ providers: [], presets: [], excluded: [], gateway: { running: true, window: true }, plugins: [] }); + if (url.pathname === "/api/groups") return json({ groups: [], pools: [] }); + if (url.pathname === "/api/usage/quotas") return json([]); + if (url.pathname.startsWith("/api/")) return json({}); + if (url.host !== "magpie.test") return route.fulfill({ status: 404, body: "" }); + const file = path.join(assets, url.pathname === "/" ? "index.html" : url.pathname); + const contentType = { ".html": "text/html", ".js": "text/javascript", ".css": "text/css", ".svg": "image/svg+xml", ".png": "image/png" }[path.extname(file)]; + try { await route.fulfill({ body: await fs.readFile(file), contentType }); } catch { await route.fulfill({ status: 404, body: "" }); } + }; +} + +const L = { + en: { part: "4 skills are on for Codex", one: "1 selected", two: "2 selected", six: "6 selected", off: "2 skills are off for Claude Code", match: "All 4 that match", select: "Select", done: "Done" }, + zh: { part: "已为 Codex 启用 4 个技能", one: "已选 1 个", two: "已选 2 个", six: "已选 6 个", off: "已从 Claude Code 中关闭 2 个技能", match: "全部 4 个匹配项", select: "选择", done: "完成" }, +}; +const LARK = ["lark-apps", "lark-base", "lark-doc", "lark-drive"]; + +for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium", "webkit"])) { + test(engine + ": many skills on or off at once", async (t) => { + assert(["chromium", "webkit"].includes(engine), "BROWSER must be chromium or webkit"); + const browser = await (engine === "webkit" ? webkit.launch() : chromium.launch({ channel: process.env.PLAYWRIGHT_CHANNEL || "chromium" })); + t.after(() => browser.close()); + for (const lang of ["en", "zh"]) { + await t.test(lang, async () => { + const w = L[lang]; + const state = { + lib: { + dir: `${HOME}/.magpie/library`, backups: `${HOME}/.magpie/backups`, home: HOME, + agents: [agent("claude", "Claude Code", "claudecode-color"), agent("codex", "Codex", "openai")], + instructions: { agents: [], sets: [] }, servers: [], foundServers: [], projects: [], foundSkills: [], problems: [], + skills: [...LARK.map((n) => local(n, ["claude"])), ...["docx", "notes", "pdf", "web-search", "xlsx"].map((n) => local(n, ["claude"]))], + }, + }; + const posts = []; + const ctx = await browser.newContext({ viewport: { width: 1100, height: 760 }, reducedMotion: "reduce" }); + await ctx.addInitScript(() => { try { localStorage.setItem("magpie.libTab", "skills"); } catch {} }); + const page = await ctx.newPage(); + page.setDefaultTimeout(5000); + const errors = []; + page.on("pageerror", (e) => errors.push(e.message)); + await page.route("**/*", server(lang, state, posts)); + await page.goto("http://magpie.test/"); + await page.locator('button[data-view="library"]').click(); + const v = page.locator("#view-library"); + const part = v.locator(".lib-subhead").filter({ has: page.locator(".name", { hasText: /^lark$/ }) }); + await part.waitFor(); + const row = (name) => v.locator(".lib-skill").filter({ has: page.locator(".name", { hasText: new RegExp("^" + name + "$") }) }); + const agentsOf = (name) => state.lib.skills.find((s) => s.name === name).agents; + + // the lark part's chips: Codex is given all four, the rest are left alone + const chip = part.locator('.lib-agents .lib-ag[data-agent="codex"]'); + assert.equal(await chip.getAttribute("aria-pressed"), "false"); + await chip.click(); + await page.locator("#status").filter({ hasText: w.part }).waitFor(); + assert.deepEqual(posts.shift(), { names: LARK, agents: ["codex"], on: true }); + assert.deepEqual(agentsOf("pdf"), ["claude"]); + await page.waitForFunction(() => [...document.querySelectorAll("#view-library .lib-subhead")].find((h) => h.querySelector(".name")?.textContent === "lark")?.querySelector('.lib-ag[data-agent="codex"]')?.getAttribute("aria-pressed") === "true"); + assert.equal(await part.evaluate((h) => h.classList.contains("open")), true, "the click folded the part"); + + // Select: a box on each row; a row's click picks it rather than opening it + assert.equal(await v.locator(".lib-pickbox").count(), 0); + await v.locator(".lib-select").click(); + assert.equal((await v.locator(".lib-select").textContent()).trim(), w.done); + await row("pdf").locator(".lib-pickbox").waitFor(); + const top = await v.evaluate((x) => x.scrollTop); + await row("pdf").locator(".name").click(); + assert.equal(await row("pdf").locator(".lib-pickbox").isChecked(), true); + assert.equal(await page.locator(".lib-editor").count(), 0, "the click opened the skill"); + await v.locator(".lib-pickbar .lib-pickn").filter({ hasText: w.one }).waitFor(); + await row("docx").locator(".lib-pickbox").click(); + await v.locator(".lib-pickbar .lib-pickn").filter({ hasText: w.two }).waitFor(); + assert.equal(await v.evaluate((x) => x.scrollTop), top, "a pick scrolled the page"); + + // the bar's chips: Claude Code's are taken from the two picked + await v.locator('.lib-pickbar .lib-ag[data-agent="claude"]').click(); + await page.locator("#status").filter({ hasText: w.off }).waitFor(); + assert.deepEqual(posts.shift(), { names: ["docx", "pdf"], agents: ["claude"], on: false }); + assert.deepEqual(agentsOf("notes"), ["claude"]); + // still picking, the two still picked + await page.waitForFunction(() => document.querySelector('#view-library .lib-pickbar .lib-ag[data-agent="claude"]')?.getAttribute("aria-pressed") === "false"); + assert.equal(await row("docx").locator(".lib-pickbox").isChecked(), true); + + // a heading's box picks its part; half ticked with some + const pbox = part.locator(".lib-pickbox"); + await row("lark-doc").locator(".lib-pickbox").click(); + assert.equal(await pbox.evaluate((b) => b.indeterminate), true); + await pbox.click(); + assert.equal(await pbox.isChecked(), true); + for (const n of LARK) assert.equal(await row(n).locator(".lib-pickbox").isChecked(), true, n); + await v.locator(".lib-pickbar .lib-pickn").filter({ hasText: w.six }).waitFor(); + await v.locator(".lib-pickclear").click(); + await v.locator(".lib-pickbar .lib-pickn.none").waitFor(); + + // the bar's box picks every one the filter shows + await v.locator(".lib-skillq").fill("lark"); + await v.locator(".lib-pickall").filter({ hasText: w.match }).waitFor(); + await v.locator(".lib-pickall input").click(); + await v.locator(".lib-pickbar .lib-pickn").filter({ hasText: lang === "en" ? "4 selected" : "已选 4 个" }).waitFor(); + await v.locator('.lib-pickbar .lib-ag[data-agent="claude"]').click(); + await page.waitForFunction(() => document.querySelector('#view-library .lib-pickbar .lib-ag[data-agent="claude"]')?.getAttribute("aria-pressed") === "false"); + assert.deepEqual(posts.shift(), { names: LARK, agents: ["claude"], on: false }); + + // Done takes the boxes away; a row opens its skill again + await v.locator(".lib-pickdone").click(); + assert.equal(await v.locator(".lib-pickbox").count(), 0); + assert.equal(await v.locator(".lib-pickbar").count(), 0); + assert.equal((await v.locator(".lib-select").textContent()).trim(), w.select); + + assert.deepEqual(errors, []); + await ctx.close(); + }); + } + }); +}