fix(studio): group installed skills by source

This commit is contained in:
xiexian committed 2026-10-07 23:29:02 +08:00
1 parent 693d0fb9b7
commit 6545e66e36
4 files changed
+103 -5

No files matched your search

@@ -3702,6 +3702,8 @@ body { font-size: 14px; line-height: 1.75; }
.prefs-main { padding-top: 28px; }
.prefs-col { max-width: 820px; }
.grp { padding: 20px 22px 18px; }
.prefs .skgroup + .skgroup { margin-top: 20px; }
.prefs .skgroup > h4 { margin: 0 0 8px; font: var(--w-med) var(--prefs-body)/1.5 var(--ui); color: var(--text); overflow-wrap: anywhere; }
/* Progressive disclosure for source-specific compatibility. Most users only
need address, key and models; relay-only controls remain discoverable here. */
+2 -4
View File
@@ -20,7 +20,7 @@ import { ExtTabs, MarketGroup } from "./Market";
import { InstalledLocation } from "./InstalledLocation";
import { Switch } from "./Switch";
import { ServerRow } from "./ServerRow";
import { SkillRow } from "./SkillRow";
import { SkillGroups } from "./SkillRow";
import { Hooks } from "./Hooks";
import { Network } from "./Network";
import { Shell as ShellPicker } from "./Shell";
@@ -713,9 +713,7 @@ export function Settings({ hub, onError, port, networkPort, networkHost, status,
: "模型自动发现已关闭:仅显式指定的技能会运行。开关立即生效,自下一条消息起,无需新建会话。",
)}
>
{looseSkills.map((sk) => (
<SkillRow key={sk.name} sk={sk} implicit={implicit} port={port} onDone={afterExtChange} root={scopeAt} onFailed={setFailed} />
))}
<SkillGroups skills={looseSkills} implicit={implicit} port={port} onDone={afterExtChange} root={scopeAt} onFailed={setFailed} />
{extErrors.skills && <div className="rnote" data-s="bad" role="alert">{extErrors.skills} <button className="act" data-action="extensions.refresh" disabled={extRefreshing} onClick={reloadExt}>{t("重试")}</button></div>}
{looseSkills.length === 0 && !extRefreshing && !extErrors.skills && <div className="empty">{t("当前工作目录下没有技能。")}</div>}
</Group>
+16 -1
View File
@@ -7,7 +7,22 @@ import { reason } from "../i18n/kernel";
// One skill, and the two things a reader wants from it: whether the model can
// reach it at all, and which of the layers it was switched at.
const SCOPE: Record<string, string> = { project: "项目", custom: "自定义", global: "我的", builtin: "内置" };
const SCOPE: Record<string, string> = { global: "我的", project: "项目", custom: "自定义", builtin: "内置" };
export function SkillGroups({ skills, ...rowProps }: { skills: SkillEntry[] } & Omit<Parameters<typeof SkillRow>[0], "sk">) {
const groups = new Map(Object.keys(SCOPE).map((scope) => [scope, [] as SkillEntry[]]));
for (const sk of skills) {
const scope = sk.scope ?? "";
if (!groups.has(scope)) groups.set(scope, []);
groups.get(scope)!.push(sk);
}
return <>{Array.from(groups, ([scope, rows]) => rows.length > 0 && (
<section className="skgroup" key={scope} aria-label={t(SCOPE[scope] ?? scope) || t("其他")}>
<h4>{t(SCOPE[scope] ?? scope) || t("其他")}</h4>
{rows.map((sk) => <SkillRow key={sk.name} sk={sk} {...rowProps} />)}
</section>
))}</>;
}
// Which way the model can reach this skill, and whether it can reach it at all.
// The second was being read back off the first by comparing the sentence — a
@@ -0,0 +1,83 @@
// @vitest-environment jsdom
import { afterEach, expect, it, vi } from "vitest";
import { cleanup, render, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import "./testkit";
import { Settings } from "./Settings";
import { MockPort } from "../port/mock";
import { MockHub } from "../port/mock_hub";
import type { SkillEntry } from "../port/port";
afterEach(() => { cleanup(); vi.restoreAllMocks(); });
function draw(port: MockPort) {
return render(<Settings
hub={new MockHub()} onError={() => {}} port={port} status={null} theme="light" reloadThemes={() => {}}
onTheme={() => {}} contrast="" onContrast={() => {}} weight="" onWeight={() => {}}
look={{}} onLook={() => {}} onClose={() => {}} onChanged={() => {}} at="ext:installed" account={null}
accountUnread="" reloadAccount={() => {}}
/>);
}
const inventory: SkillEntry[] = [
{ name: "builtin-one", scope: "builtin", enabled: true },
{ name: "project-one", scope: "project", enabled: true },
{ name: "mine-one", scope: "global", enabled: true, switchScope: "project" },
{ name: "builtin-two", scope: "builtin", enabled: false },
{ name: "mine-two", scope: "global", enabled: false },
{ name: "package-skill", scope: "plugin", plugin: "review-kit", enabled: true },
];
it("groups the mixed skill catalog by source and retains disabled rows and catalog order within a group", async () => {
const port = new MockPort();
vi.spyOn(port, "skills").mockResolvedValue({ implicit: true, skills: inventory });
draw(port);
await screen.findByText("mine-one");
const skills = document.querySelector<HTMLElement>("#set-skills")!;
const groups = within(skills).getAllByRole("region");
expect(groups.map((group) => within(group).getByRole("heading").textContent)).toEqual(["我的", "项目", "内置"]);
expect(groups.map((group) => Array.from(group.querySelectorAll(".nm"), (row) => row.textContent))).toEqual([
["mine-one", "mine-two"], ["project-one"], ["builtin-one", "builtin-two"],
]);
expect(within(skills).queryByText("package-skill")).toBeNull();
expect(skills.querySelector(".now")?.textContent).toBe("3/5 已启用");
});
it("keeps source grouping independent of project overrides and preserves the skill switch and refresh", async () => {
const port = new MockPort();
let skills = inventory.map((skill) => ({ ...skill }));
const read = vi.spyOn(port, "skills").mockImplementation(async () => ({ implicit: false, skills }));
const toggle = vi.spyOn(port, "setSkillEnabled").mockImplementation(async (name, enabled) => {
skills = skills.map((skill) => skill.name === name ? { ...skill, enabled } : skill);
});
draw(port);
await screen.findByText("mine-one");
const mine = screen.getByRole("region", { name: "我的" });
expect(within(mine).getByRole("button", { name: "仅本项目" })).toBeTruthy();
expect(mine.textContent).toContain("调不到");
const control = within(mine).getByRole("switch", { name: "关闭 mine-one" });
control.focus();
await userEvent.keyboard("{Enter}");
expect(toggle).toHaveBeenCalledExactlyOnceWith("mine-one", false, "project", undefined);
await waitFor(() => expect(read).toHaveBeenCalledTimes(2));
expect(await within(mine).findByRole("switch", { name: "启用 mine-one" })).toBeTruthy();
expect(document.activeElement).toBe(control);
expect(document.querySelector("#set-skills .now")?.textContent).toBe("2/5 已启用");
});
it("shows nonempty custom and unlabelled sources without assigning them to mine or builtin", async () => {
const port = new MockPort();
vi.spyOn(port, "skills").mockResolvedValue({ implicit: true, skills: [
{ name: "custom-one", scope: "custom", enabled: true },
{ name: "unlabelled-one", enabled: true },
{ name: "compat-one", scope: "codex_compat", enabled: true },
] });
draw(port);
await screen.findByText("custom-one");
const pane = within(document.querySelector<HTMLElement>("#set-skills")!);
expect(within(pane.getByRole("region", { name: "自定义" })).getByText("custom-one")).toBeTruthy();
expect(within(pane.getByRole("region", { name: "其他" })).getByText("unlabelled-one")).toBeTruthy();
expect(within(pane.getByRole("region", { name: "codex_compat" })).getByText("compat-one")).toBeTruthy();
expect(pane.queryByRole("region", { name: "我的" })).toBeNull();
expect(pane.queryByRole("region", { name: "内置" })).toBeNull();
});