library: hundreds of skills no longer make the Skills tab crawl — they're grouped by the GitHub repository they came from (owner/repo, how many, an Update N for those a check found changed, which agents have all or some of them, and a link to the repository), those on this computer in a group of their own, each group folded to its heading line and opened with a click, remembered in the browser; with more than 40 skills from several places the groups start folded. A filter over the skills (name, description, repository) shows when there are more than 8, opening the groups that have matches, and a filter or a fold draws only the groups again, not the page. Every row was drawn with a chip for each agent, 4400 icons for 400 skills, and each redraw, scroll and hover laid out and painted all of them: a row out of view is now skipped by the browser (content-visibility) and an agent's icon is made once and copied rather than made afresh (and probed) for every chip. On 400 skills from 6 repositories and 100 local ones, drawing the tab went from 131ms to 17ms folded (55ms with every group open), a pointer sweeping the list from 1.4–1.65s of main-thread work to 0.4s (0.67s all open), and a 6000px scroll from 0.65–0.71s to 0.29s (0.49s)

This commit is contained in:
yetone committed 2026-09-28 15:35:54 +08:00
1 parent cd373b19fb
commit 23847a8a56
3 files changed
+164 -6

No files matched your search

+9
View File
@@ -1137,6 +1137,15 @@ const I18N = {
"Update {n}": "更新 {n} 个",
"Fetch {name} from GitHub again": "从 GitHub 重新获取 {name}",
"Fetch the {n} skills GitHub changed again": "从 GitHub 重新获取有改动的 {n} 个技能",
"Filter {n} skills…": "筛选 {n} 个技能…",
"No skill matches “{q}”.": "没有与“{q}”匹配的技能。",
"On this computer": "本机",
"1 skill": "1 个技能",
"{n} of {total} skills": "{total} 个技能中的 {n} 个",
"{agent} has all of them": "{agent} 已有其中全部技能",
"{agent} has {n} of them": "{agent} 已有其中 {n} 个",
"Open {repo} on GitHub": "在 GitHub 上打开 {repo}",
"Show its skills": "展开技能",
"1 skill has an update": "1 个技能有更新",
"{n} skills have updates": "{n} 个技能有更新",
"Every skill is up to date": "所有技能都是最新的",
+13
View File
@@ -311,3 +311,16 @@ input.lib-setname:focus { border-color: var(--accent); box-shadow: 0 0 0 3px col
.lib-project .lib-projskill { border-top: 1px solid var(--line-2); }
.lib-projfoot { margin: 0; padding: 8px 12px 10px 49px; border-top: 1px solid var(--line-2); }
.lib-projfoot + .lib-projfoot { border-top: 0; padding-top: 0; }
/* the library's skills: a card for each GitHub repository they came from,
and one for those on this computer, folded to its heading line */
.lib-groups { display: flex; flex-direction: column; gap: 8px; }
.lib-grouphead .sub { font-size: 11px; }
.lib-grouphead > .lib-logo, .lib-grouphead > .lib-glyph { align-self: center; margin-top: 0; }
.lib-grouphead .lib-updall { height: 24px; padding: 0 9px; font-size: 12px; }
.lib-have .ic.some { opacity: .4; }
#view-library .row-head .lib-skillq { width: 190px; height: 24px; margin: 0; flex: 0 1 auto; min-width: 90px; }
.lib-skillq::-webkit-search-cancel-button { -webkit-appearance: none; }
/* a skill row out of view is neither laid out nor painted until it nears it */
.lib-skill, .lib-projskill { content-visibility: auto; contain-intrinsic-size: auto 76px; }
.lib-grouphead .lib-rowacts { min-width: 26px; }
+142 -6
View File
@@ -93,6 +93,16 @@
return s;
}
// An agent's icon, made once and copied: a list of hundreds of skills has
// a chip for each agent on every row, and making each icon afresh (its
// image, and a probe of whether it loads) was most of drawing the list.
const icons = new Map();
function agentIcon(name) {
let i = icons.get(name);
if (!i) icons.set(name, (i = icon(name)));
return i.cloneNode(true);
}
// Agent chips for a server or a skill: each agent that could have it, lit
// when it does. A chip whose agent couldn't be given it says why.
function agentChips(all, on, onChange, opts = {}) {
@@ -102,7 +112,7 @@
const has = on.includes(a.id);
const c = el("button", "lib-ag" + (has ? " on" : ""));
c.dataset.agent = a.id;
c.append(icon(a.icon));
c.append(agentIcon(a.icon));
if (opts.names) c.append(el("span", "n", a.name));
const problem = opts.problems?.[a.id];
const blocked = opts.blocked?.(a);
@@ -997,8 +1007,10 @@
if (lib.skills.length) {
const rh = el("div", "row-head");
rh.append(el("span", "label", t("In the library")));
const box = el("div", "lib-groups");
if (lib.skills.length > 8) rh.append(skillFilter(box, all));
const fresh = lib.skills.filter((s) => s.kind === "github" || s.origin);
if (fresh.length) rh.append(el("span", "grow"));
rh.append(el("span", "grow"));
if (lib.skills.some((s) => s.kind === "github")) {
const c = button(checking ? t("Checking…") : t("Check for updates"), "lib-updall", () => checkSkills());
c.title = t("Ask GitHub which skills changed since they were installed");
@@ -1029,9 +1041,8 @@
rh.append(u);
}
body.append(rh);
const list = el("div", "list lib-list");
for (const s of lib.skills) list.append(skillRow(s, all));
body.append(list);
drawSkills(box, all);
body.append(box);
}
if (lib.foundSkills.length) {
const rh = el("div", "row-head");
@@ -1047,6 +1058,131 @@
body.append(discover("skills"));
}
// ---------- the library's skills, by where they came from ----------
// Hundreds of skills were one list of rows, every one of them drawn with
// a chip for each agent — thousands of icons laid out and painted on each
// redraw, scroll and hover. They're grouped by the GitHub repository they
// came from (the ones on this computer together): a folded group draws
// only its heading, and a filter or a fold draws the groups again, not
// the page.
let skillQuery = ""; // what the filter over the skills holds
const unfiltered = new Set(); // groups folded while filtering, till the filter changes
let folds = {}; // group → true when folded, false when opened by hand
try { folds = JSON.parse(localStorage.getItem("magpie.libSkillFolds") || "{}") || {}; } catch {}
function saveFolds() { try { localStorage.setItem("magpie.libSkillFolds", JSON.stringify(folds)); } catch {} }
const repoOf = (u) => (u || "").replace(/^https:\/\/github\.com\//, "").split("/").slice(0, 2).join("/");
function skillGroups() {
const by = new Map();
for (const s of lib.skills) {
const repo = s.kind === "github" ? repoOf(s.source) : s.origin ? repoOf(s.origin) : "";
const key = repo ? "gh:" + repo.toLowerCase() : "local";
let g = by.get(key);
if (!g) by.set(key, (g = { key, repo, skills: [] }));
g.skills.push(s);
}
const out = [...by.values()].sort((a, b) => (a.key === "local") - (b.key === "local") || a.repo.localeCompare(b.repo));
for (const g of out) g.skills.sort((a, b) => a.name.localeCompare(b.name));
return out;
}
function skillFilter(box, all) {
const f = el("input", "lib-filter lib-skillq");
f.type = "search";
f.dataset.lib = "skillq";
f.placeholder = t("Filter {n} skills…", { n: lib.skills.length });
f.spellcheck = false;
f.autocomplete = "off";
f.value = skillQuery;
f.oninput = () => { skillQuery = f.value; unfiltered.clear(); drawSkills(box, all); };
f.onkeydown = (e) => {
e.stopPropagation();
if (e.key === "Escape" && f.value) { e.preventDefault(); f.value = ""; f.oninput(); }
};
return f;
}
function drawSkills(box, all) {
const groups = skillGroups();
const q = skillQuery.trim().toLowerCase();
const hitsOf = (g) => (q ? g.skills.filter((s) => (s.name + " " + (s.description || "") + " " + g.repo).toLowerCase().includes(q)) : g.skills);
box.replaceChildren();
let shown = 0;
if (groups.length === 1) {
// from one place only: the list as it always was
const hits = hitsOf(groups[0]);
if (hits.length) {
const list = el("div", "list lib-list");
for (const s of hits) list.append(skillRow(s, all));
box.append(list);
shown = hits.length;
}
} else {
// many skills from several places start folded, each group a line
const many = lib.skills.length > 40;
for (const g of groups) {
const hits = hitsOf(g);
if (!hits.length) continue;
shown++;
box.append(groupCard(g, hits, all, q ? unfiltered.has(g.key) : folds[g.key] ?? many, !!q));
}
}
if (!shown) box.append(el("div", "list lib-none", t("No skill matches “{q}”.", { q: skillQuery.trim() })));
}
function groupCard(g, hits, all, folded, filtering) {
const card = el("div", "list lib-card lib-group");
card.dataset.group = g.key;
const head = el("div", "row lib-row click lib-grouphead" + (folded ? "" : " open"));
const who = el("div", "who");
who.append(el("div", "name" + (g.repo ? " mono" : ""), g.repo || t("On this computer")));
const n = g.skills.length;
who.append(el("div", "sub", filtering && hits.length !== n ? t("{n} of {total} skills", { n: hits.length, total: n })
: n === 1 ? t("1 skill") : t("{n} skills", { n })));
const tags = el("div", "lib-tags");
const stale = g.skills.filter((s) => s.check?.status === "update");
if (stale.length) {
const u = button(t("Update {n}", { n: stale.length }), "action lib-updall", async (e, b) => {
b.classList.add("busy");
b.textContent = t("Updating…");
await updateAllSkills(stale.map((s) => s.name));
});
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);
}
// which agents have its skills, all of them or some
const have = el("div", "lib-have");
for (const a of all) {
const k = g.skills.filter((s) => s.agents.includes(a.id)).length;
if (!k) continue;
const i = agentIcon(a.icon);
i.title = k === n ? t("{agent} has all of them", { agent: a.name }) : t("{agent} has {n} of them", { agent: a.name, n: k });
if (k < n) i.classList.add("some");
have.append(i);
}
const acts = el("div", "lib-rowacts");
if (g.repo) {
const o = button("", "lib-icon", () => browse("https://github.com/" + g.repo));
o.append(svg(GLYPH.out, 13, 1.4));
o.title = t("Open {repo} on GitHub", { repo: g.repo });
acts.append(o);
}
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);
head.append(pic, who, tags, have, acts, chev);
head.title = folded ? t("Show its skills") : t("Hide its skills");
head.onclick = () => {
if (filtering) { if (folded) unfiltered.delete(g.key); else unfiltered.add(g.key); }
else { folds[g.key] = !folded; saveFolds(); }
card.replaceWith(groupCard(g, hits, all, !folded, filtering));
};
card.append(head);
if (!folded) for (const s of hits) card.append(skillRow(s, all));
return card;
}
// ---------- projects ----------
// A project is a folder of the user's whose agents get some of the
@@ -1468,7 +1604,7 @@
who.append(sub);
if (f.link) { const src = el("div", "lib-src"); src.append(el("span", "", t("linked from")), pathLink(f.link)); who.append(src); }
const have = el("div", "lib-have");
for (const id of f.agents) { const a = agentOf(id); if (a) { const i = icon(a.icon); i.title = a.name; have.append(i); } }
for (const id of f.agents) { const a = agentOf(id); if (a) { const i = agentIcon(a.icon); i.title = a.name; have.append(i); } }
row.append(glyph(GLYPH.skill), who, have);
if (f.others?.length) row.append(tag(t("differs in {agents}", { agents: f.others.map(nameOf).join(", ") }), "warn", t("{agents} has another skill by this name; bringing this one in leaves that one as it is", { agents: f.others.map(nameOf).join(", ") })));
const b = button(t("Bring in"), "action", () => change("skills/import", { name: f.name }, t("{name} is in the library now", { name: f.name })));