diff --git a/desktop/frontend-next/src/styles/studio.css b/desktop/frontend-next/src/styles/studio.css index 165e7f280b..9b59ca4f79 100644 --- a/desktop/frontend-next/src/styles/studio.css +++ b/desktop/frontend-next/src/styles/studio.css @@ -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. */ diff --git a/desktop/frontend-next/src/ui/Settings.tsx b/desktop/frontend-next/src/ui/Settings.tsx index 52b6eb22a9..365f6b0d1c 100644 --- a/desktop/frontend-next/src/ui/Settings.tsx +++ b/desktop/frontend-next/src/ui/Settings.tsx @@ -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) => ( - - ))} + {extErrors.skills &&
{extErrors.skills}
} {looseSkills.length === 0 && !extRefreshing && !extErrors.skills &&
{t("当前工作目录下没有技能。")}
} diff --git a/desktop/frontend-next/src/ui/SkillRow.tsx b/desktop/frontend-next/src/ui/SkillRow.tsx index 0b6c54e3fe..3d6f394a41 100644 --- a/desktop/frontend-next/src/ui/SkillRow.tsx +++ b/desktop/frontend-next/src/ui/SkillRow.tsx @@ -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 = { project: "项目", custom: "自定义", global: "我的", builtin: "内置" }; +const SCOPE: Record = { global: "我的", project: "项目", custom: "自定义", builtin: "内置" }; + +export function SkillGroups({ skills, ...rowProps }: { skills: SkillEntry[] } & Omit[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 && ( +
+

{t(SCOPE[scope] ?? scope) || t("其他")}

+ {rows.map((sk) => )} +
+ ))}; +} // 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 diff --git a/desktop/frontend-next/src/ui/settings-skill-groups.interaction.test.tsx b/desktop/frontend-next/src/ui/settings-skill-groups.interaction.test.tsx new file mode 100644 index 0000000000..47e5a150b0 --- /dev/null +++ b/desktop/frontend-next/src/ui/settings-skill-groups.interaction.test.tsx @@ -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( {}} 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("#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("#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(); +});