feat(studio): add grouped preset module multiselect

Problem: Preset modules could only be added one at a time, with two different actions both labeled Add module.

Root cause: A native single-choice picker shared its label with manual blank-ID insertion, and catalog choices did not carry source provenance.

Fix: Add a shared searchable grouped checkbox picker, authoritative catalog provenance, batch selection and parameter-safe draft updates. Keep manual IDs under an explicit advanced entry and document selection semantics.

Verification: 219 frontend tests, typecheck, production build, census, owning Go packages including boot and builtin, focused Desktop preset/composition tests, vet, lint, make check and documentation checks passed. Browser checks covered themes, responsive layouts and save/reopen with MockHub.
This commit is contained in:
SivanCola committed 2026-10-21 12:15:04 +08:00
1 parent 596c3ada3c
commit c2a485ebc5
17 files changed
+442 -58

No files matched your search

+7
View File
@@ -66,6 +66,13 @@ export const ACTIONS: UIAction[] = [
{id:"agent-preset.module-parameters",kind:"view",target:"entity",proof:"interaction"},
{id:"agent-preset.module-add",kind:"view",target:"entity",proof:"interaction"},
{id:"agent-preset.module-choose",kind:"view",target:"entity",proof:"interaction"},
{id:"agent-preset.module-toggle",kind:"view",target:"entity",proof:"interaction"},
{id:"agent-preset.module-select-all",kind:"view",target:"entity",proof:"interaction"},
{id:"agent-preset.module-select-group",kind:"view",target:"entity",proof:"interaction"},
{id:"agent-preset.module-clear",kind:"view",target:"entity",proof:"interaction"},
{id:"agent-preset.module-search",kind:"view",target:"entity",proof:"interaction"},
{id:"agent-preset.module-navigate",kind:"view",target:"none",proof:"interaction"},
{id:"agent-preset.module-done",kind:"view",target:"none",proof:"interaction"},
{id:"agent-preset.module-remove",kind:"view",target:"entity",proof:"interaction"},
{id:"agent-preset.save",kind:"kernel-mutation",target:"none",proof:"authority-effect"},
{id:"agent-preset.default",kind:"kernel-mutation",target:"none",proof:"authority-effect"},
@@ -1,4 +1,19 @@
export const EN_COMPOSITION: Record<string,string> = {
"已选 {n} 项": "{n} selected",
"高级:手动输入模块 ID": "Advanced: enter a module ID",
"添加空白 ID": "Add blank ID",
"仅在模块未列出时使用;填写 ID 不会安装模块或授予权限。": "Use only for modules not listed. Entering an ID does not install a module or grant permission.",
"已选 {selected}/{total}": "{selected}/{total} selected",
"全选({n} 项)": "Select all ({n})",
"全选搜索结果({n} 项)": "Select all results ({n})",
"全选本组": "Select group",
"取消本组全选": "Deselect group",
"搜索模块": "Search modules",
"搜索模块名称或说明…": "Search module names or descriptions…",
"没有匹配的模块": "No matching modules",
"暂无可选模块": "No modules available",
"未分类": "Unclassified",
"全选仅包含当前列表;新增模块不会自动选中。": "Select all includes current results only. New modules are not selected automatically.",
"显示模式选择器": "Show mode picker",
"默认关闭。开启后在聊天输入框顶部显示模式选择器;关闭只隐藏入口,不改变会话模式。": "Off by default. Enable to show the mode picker above the chat input. Turning it off hides the picker without changing the conversation's mode.",
"无法保存模式选择器设置,请检查本地存储权限。": "Could not save the mode picker setting. Check local storage permissions.",
@@ -4,7 +4,7 @@ export type CompositionKind = "tools" | "skills" | "mcp" | "extensions" | "hooks
export interface CompositionModule { id: string; parameters?: Record<string, unknown> }
// A module the editor offers: the id is what a preset stores; the description is what the module
// says about itself, for the person choosing, and is never saved.
export interface CompositionChoice { id: string; description?: string }
export interface CompositionChoice { id: string; description?: string; source?: "builtin" | "custom" }
export interface CompositionSelection { mode: "inherit" | "select"; members: CompositionModule[] }
export interface AgentComposition {
schemaVersion: number;
@@ -7,16 +7,20 @@ type Library = { revision:number; user:AgentPresetVersion[]; projects:Map<string
const MOCK_MODULES: AgentPresetCatalog["modules"] = {
tools: [
{ id: "bash", description: "Run a shell command in the workspace and return its output." },
{ id: "bash_output", description: "Read new output from a shell command that is still running." },
{ id: "browser_act", description: "Click, type or scroll on the page open in the built-in browser." },
{ id: "edit_file", description: "Replace an exact piece of text in a file." },
{ id: "task" },
{ id: "view_image", description: "Read an image file." },
{ source: "builtin", id: "bash", description: "Run a shell command in the workspace and return its output." },
{ source: "builtin", id: "bash_output", description: "Read new output from a shell command that is still running." },
{ source: "builtin", id: "browser_act", description: "Click, type or scroll on the page open in the built-in browser." },
{ source: "builtin", id: "edit_file", description: "Replace an exact piece of text in a file." },
{ source: "builtin", id: "task" },
{ source: "builtin", id: "view_image", description: "Read an image file." },
],
skills: [
{ id: "review", description: "Review the changes on this branch and report problems by severity." },
{ id: "tidy-notes", description: "Tidy the notes folder" },
{ source: "builtin", id: "review", description: "Review the changes on this branch and report problems by severity." },
{ source: "builtin", id: "explore", description: "Explore the codebase and trace how a feature works." },
{ source: "builtin", id: "reasonix-guide", description: "Explain Reasonix configuration and workflows." },
{ source: "custom", id: "tidy-notes", description: "Tidy the notes folder" },
{ source: "custom", id: "api-check", description: "Validate project endpoints and response contracts." },
{ source: "custom", id: "release-check", description: "Run the team's release checklist." },
],
};
@@ -1675,6 +1675,9 @@ A sub-agent row, by the list's own width:
- Preset management separates two built-in mode cards from custom presets. Card selection inspects a definition; setting a default affects new conversations and never switches the active binding.
- Open floating controls follow responsive fold changes and recheck their anchor after layout transitions finish; window resize alone precedes the body observer's fold publication.
- Preset module selection uses one anchored, scroll-bounded checkbox popover with source groups, search, group selection and current-result selection. It stays open for repeated choices and returns focus on dismissal.
- Source groups come from the module catalog; older hosts without provenance use an unclassified group. Selection edits retain existing parameter drafts. Selecting all does not change the inheritance mode.
## Side question
- The panel stays anchored above its own composer. Collapsing it leaves a header with separate expand and close controls so the conversation can be read without discarding the side question.
@@ -0,0 +1,39 @@
.composition-module-controls { display: flex; align-items: center; flex-wrap: wrap; gap: 8px; }
.composition-module-controls > select { flex: 1 1 260px; }
.composition-module-trigger { display: flex; flex: 1 1 200px; align-items: center; justify-content: space-between; gap: 12px; min-width: 0; padding: 8px 12px; min-height: 40px; border: 1px solid var(--border); border-radius: var(--r-sm); background: var(--raised); color: var(--text); font: var(--w-reg) var(--fs-ui)/1.5 var(--ui); cursor: pointer; }
.composition-module-trigger > svg { width: 16px; height: 16px; flex: none; color: var(--muted); }
.composition-module-trigger[aria-expanded="true"] { border-color: var(--focus); }
.composition-capabilities .composition-manual { margin-top: 12px; }
.composition-capabilities .composition-manual > summary { min-height: 32px; color: var(--muted); font-size: var(--fs-meta); font-weight: var(--w-reg); list-style: disclosure-closed; display: list-item; }
.composition-capabilities .composition-manual[open] > summary { list-style: disclosure-open; }
.composition-module-picker { position: fixed; z-index: 1150; display: flex; flex-direction: column; width: min(480px, calc(100vw / var(--zoom, 1) - 24px)); max-height: calc(100dvh / var(--zoom, 1) - 24px); border: 1px solid var(--border); border-radius: var(--r-md); background: var(--surface); color: var(--text); box-shadow: var(--lit); font: var(--w-reg) var(--fs-ui)/1.5 var(--ui); overflow: hidden; }
.composition-picker-tools, .composition-picker-group-heading, .composition-picker-footer { display: flex; align-items: center; gap: 10px; padding: 10px 14px; flex-shrink: 0; }
.composition-picker-tools { border-bottom: 1px solid var(--hair); }
.composition-select-all { display: flex; align-items: center; gap: 8px; cursor: pointer; }
.composition-module-picker input[type="checkbox"] { flex: none; width: 16px; height: 16px; margin: 0; accent-color: var(--accent-ink); cursor: pointer; }
.composition-picker-action { margin-left: auto; border: 0; background: transparent; color: var(--muted); padding: 4px; font: inherit; font-size: var(--fs-meta); cursor: pointer; }
.composition-picker-action:hover { color: var(--text); }
.composition-picker-search { display: flex; align-items: center; gap: 8px; margin: 10px 12px; padding: 6px 10px; border: 1px solid var(--border); border-radius: var(--r-sm); background: var(--raised); flex-shrink: 0; }
.composition-picker-search > svg { width: 16px; height: 16px; flex: none; color: var(--muted); }
.composition-picker-search input { min-width: 0; width: 100%; border: 0; background: transparent; color: var(--text); font: inherit; }
.composition-picker-list { min-height: 0; max-height: 360px; overflow-y: auto; overscroll-behavior: contain; }
.composition-picker-group + .composition-picker-group { border-top: 1px solid var(--hair); margin-top: 6px; padding-top: 6px; }
.composition-picker-group-heading { padding-block: 4px; }
.composition-picker-group-heading strong { font-weight: var(--w-med); }
.composition-picker-group-heading small { color: var(--muted); font-size: var(--fs-meta); }
.composition-choice { display: flex; align-items: center; gap: 10px; min-height: 40px; padding: 8px 14px; cursor: pointer; }
.composition-choice:hover { background: var(--overlay); }
.composition-choice > svg { width: 16px; height: 16px; flex: none; color: var(--muted); }
.composition-choice-name { flex: 0 1 auto; min-width: 0; overflow-wrap: anywhere; }
.composition-choice-description { flex: 1; min-width: 0; color: var(--muted); font-size: var(--fs-meta); overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
.composition-picker-footer { justify-content: space-between; border-top: 1px solid var(--hair); margin-top: 8px; }
.composition-picker-footer .btn { min-width: 76px; }
.composition-picker-note { margin: 0; padding: 8px 14px 12px; border-top: 1px solid var(--hair); color: var(--muted); font-size: var(--fs-meta); flex-shrink: 0; }
.composition-picker-empty { padding: 16px; color: var(--muted); }
.composition-module-picker :is(button, input):focus-visible { outline: 2px solid var(--focus); outline-offset: 2px; }
.composition-module-picker :disabled, .composition-module-trigger:disabled { opacity: .55; cursor: default; }
@media (max-width: 700px) {
.composition-choice { display: grid; grid-template-columns: 16px 16px minmax(0, 1fr); gap: 4px 10px; }
.composition-choice-description { grid-column: 3; white-space: normal; }
.composition-picker-group-heading { flex-wrap: wrap; }
}
@@ -167,7 +167,8 @@ describe("Agent preset management", () => {
await user.click(screen.getByText("技能与扩展", { selector: "summary" }));
await user.selectOptions(screen.getByLabelText("MCP 服务 选择方式"),"select");
const group=screen.getByRole("group",{name:"MCP 服务"});
await user.click(group.querySelector("button")!);
await user.click(within(group).getByText("高级:手动输入模块 ID"));
await user.click(within(group).getByRole("button", { name: "添加空白 ID" }));
await user.type(screen.getByLabelText("MCP 服务 ID 1"),"docs");
await user.clear(screen.getByLabelText("MCP 服务 独立参数 1"));
await user.type(screen.getByLabelText("MCP 服务 独立参数 1"),"invalid");
@@ -26,17 +26,16 @@ async function open() {
it("shows what a module is for beside its name, and says nothing where its source says nothing", async () => {
await open();
const pick = screen.getAllByRole<HTMLSelectElement>("combobox").find((s) => /添加模块/.test(s.getAttribute("aria-label") ?? ""))!;
const labels = [...pick.options].map((o) => o.textContent);
expect(labels).toContain("bash — Run a shell command in the workspace and return its output.");
expect(labels).toContain("task");
const bash = [...pick.options].find((o) => o.value === "bash")!;
expect(bash.title).toBe("Run a shell command in the workspace and return its output.");
await userEvent.click(screen.getByText("工具", { selector: "summary" }));
await userEvent.click(screen.getByRole("button", { name: "工具 添加模块" }));
expect(screen.getByText("Run a shell command in the workspace and return its output.")).toBeTruthy();
expect(screen.getByRole("checkbox", { name: "task" }).getAttribute("aria-describedby")).toBeNull();
});
it("stores only the id of the module it adds, never its description", async () => {
const state = await open();
const pick = screen.getAllByRole<HTMLSelectElement>("combobox").find((s) => /添加模块/.test(s.getAttribute("aria-label") ?? ""))!;
await userEvent.selectOptions(pick, "bash");
await userEvent.click(screen.getByText("工具", { selector: "summary" }));
await userEvent.click(screen.getByRole("button", { name: "工具 添加模块" }));
await userEvent.click(screen.getByRole("checkbox", { name: "bash" }));
expect(state.saved.tools.members).toEqual([{ id: "bash" }]);
});
@@ -0,0 +1,136 @@
// @vitest-environment jsdom
import "./testkit";
import { cleanup, render, screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, expect, it } from "vitest";
import { CompositionEditor } from "./CompositionEditor";
import { CompositionModulePicker } from "./CompositionModulePicker";
import { builtinCompositions, type AgentComposition, type AgentPresetCatalog, type CompositionModule } from "../port/composition";
afterEach(cleanup);
const modules = {
tools: [{ id: "bash", description: "Run a shell command", source: "builtin" }, { id: "task", source: "builtin" }],
skills: [{ id: "review", description: "Review changes", source: "builtin" }, { id: "notes", description: "Tidy notes", source: "custom" }, { id: "release", source: "custom" }],
mcp: [{ id: "docs", source: "custom" }, { id: "repo", source: "custom" }],
} satisfies AgentPresetCatalog["modules"];
function open(initial?: (draft: AgentComposition) => void) {
let current = builtinCompositions()[0];
current.id = "user:picker";
current.skills = { mode: "select", members: [] };
initial?.(current);
let valid = true;
const props = () => ({ draft: current, modules, disabled: false, creating: true,
onChange: (next: AgentComposition) => { current = next; view.rerender(<CompositionEditor {...props()} />); },
onValidityChange: (next: boolean) => { valid = next; },
});
const view = render(<CompositionEditor {...props()} />);
return { get saved() { return current; }, get valid() { return valid; } };
}
async function picker(label = "技能 添加模块") {
await userEvent.click(screen.getByText("技能与扩展"));
await userEvent.click(screen.getByRole("button", { name: label }));
return screen.getByRole("dialog", { name: label });
}
it("keeps a grouped checkbox menu open for multiple selections and stores only ids", async () => {
const state = open();
const menu = await picker();
expect(within(menu).getByRole("group", { name: "内置" })).toBeTruthy();
expect(within(menu).getByRole("group", { name: "自定义" })).toBeTruthy();
expect(within(menu).getByText("Review changes")).toBeTruthy();
await userEvent.click(within(menu).getByRole("checkbox", { name: "review" }));
await userEvent.click(within(menu).getByRole("checkbox", { name: "notes" }));
expect(state.saved.skills.members).toEqual([{ id: "review" }, { id: "notes" }]);
expect(within(menu).getByRole<HTMLInputElement>("checkbox", { name: "全选(3 项)" }).indeterminate).toBe(true);
await userEvent.click(within(menu).getByRole("button", { name: "完成" }));
expect(screen.queryByRole("dialog", { name: "技能 添加模块" })).toBeNull();
expect(document.activeElement).toBe(screen.getByRole("button", { name: "技能 添加模块" }));
});
it("selects groups and filtered results without removing hidden selections", async () => {
const state = open();
const menu = await picker();
const custom = within(menu).getByRole("group", { name: "自定义" });
await userEvent.click(within(custom).getByRole("button", { name: "全选本组" }));
expect(state.saved.skills.members.map(m => m.id)).toEqual(["notes", "release"]);
await userEvent.click(within(menu).getByRole("checkbox", { name: "全选(3 项)" }));
expect(state.saved.skills.members).toHaveLength(3);
await userEvent.type(within(menu).getByRole("searchbox"), "Review");
await userEvent.click(within(menu).getByRole("checkbox", { name: "全选搜索结果(1 项)" }));
expect(state.saved.skills.members.map(m => m.id)).toEqual(["notes", "release"]);
await userEvent.clear(within(menu).getByRole("searchbox"));
await userEvent.type(within(menu).getByRole("searchbox"), "missing");
expect(within(menu).getByText("没有匹配的模块")).toBeTruthy();
expect(within(menu).getByRole<HTMLInputElement>("checkbox").disabled).toBe(true);
await userEvent.click(within(menu).getByRole("button", { name: "清空" }));
expect(state.saved.skills).toEqual({ mode: "select", members: [] });
});
it("preserves parameters and invalid JSON when earlier selections are removed", async () => {
const state = open(d => { d.mcp = { mode: "select", members: [{ id: "docs", parameters: { args: ["--docs"] } }, { id: "repo", parameters: { args: ["--repo"] } }] }; });
await userEvent.click(screen.getByText("技能与扩展"));
const editor = screen.getByRole("textbox", { name: "MCP 服务 独立参数 2" });
await userEvent.clear(editor);
await userEvent.type(editor, "invalid");
expect(state.valid).toBe(false);
await userEvent.click(screen.getByRole("button", { name: "MCP 服务 添加模块" }));
const menu = screen.getByRole("dialog", { name: "MCP 服务 添加模块" });
await userEvent.click(within(menu).getByRole("checkbox", { name: "docs" }));
await userEvent.click(within(menu).getByRole("button", { name: "完成" }));
expect(screen.getByRole<HTMLTextAreaElement>("textbox", { name: "MCP 服务 独立参数 1" }).value).toBe("invalid");
expect(state.saved.mcp.members).toEqual([{ id: "repo", parameters: { args: ["--repo"] } }]);
expect(state.valid).toBe(false);
});
it("supports Escape, keyboard selection, outside dismissal and unchanged inheritance", async () => {
const state = open();
let menu = await picker();
await userEvent.keyboard("{ArrowDown} ");
expect(state.saved.skills.members).toEqual([{ id: "review" }]);
await userEvent.keyboard("{Escape}");
expect(screen.queryByRole("dialog", { name: "技能 添加模块" })).toBeNull();
await userEvent.click(screen.getByRole("button", { name: "技能 添加模块" }));
menu = screen.getByRole("dialog", { name: "技能 添加模块" });
await userEvent.click(screen.getByRole("textbox", { name: "预设名称" }));
expect(menu.isConnected).toBe(false);
await userEvent.selectOptions(screen.getByRole("combobox", { name: "技能 选择方式" }), "inherit");
expect(state.saved.skills).toEqual({ mode: "inherit", members: [] });
expect(screen.queryByRole("button", { name: "技能 添加模块" })).toBeNull();
});
it("has one add-module picker and keeps manual IDs behind an explicit advanced entry", async () => {
const state = open();
await userEvent.click(screen.getByText("技能与扩展"));
const group = screen.getByRole("group", { name: "技能" });
expect(within(group).getAllByRole("button", { name: /添加模块/ })).toHaveLength(1);
expect(within(group).getByRole("button", { name: "添加空白 ID" }).closest("details")?.open).toBe(false);
await userEvent.click(within(group).getByText("高级:手动输入模块 ID"));
await userEvent.click(within(group).getByRole("button", { name: "添加空白 ID" }));
await userEvent.type(screen.getByRole("textbox", { name: "技能 ID 1" }), "private-skill");
expect(state.saved.skills.members).toEqual([{ id: "private-skill" }]);
});
it("keeps legacy catalog choices unclassified, deduplicates ids and closes when disabled", async () => {
let members: CompositionModule[] = [{ id: "saved-but-missing", parameters: { args: ["--keep"] } }];
const onChange = (next: typeof members) => { members = next; };
const props = { kind: "skills" as const, label: "技能", choices: [{ id: "old" }, { id: "old" }], members, disabled: false, onChange };
const view = render(<CompositionModulePicker {...props} />);
await userEvent.click(screen.getByRole("button", { name: "技能 添加模块" }));
const menu = screen.getByRole("dialog", { name: "技能 添加模块" });
expect(within(menu).getByRole("group", { name: "未分类" })).toBeTruthy();
expect(within(menu).getAllByRole("checkbox", { name: "old" })).toHaveLength(1);
await userEvent.click(within(menu).getByRole("checkbox", { name: "全选(1 项)" }));
expect(members).toEqual([{ id: "saved-but-missing", parameters: { args: ["--keep"] } }, { id: "old" }]);
view.rerender(<CompositionModulePicker {...props} disabled />);
expect(screen.queryByRole("dialog")).toBeNull();
});
it("shows empty catalogs without removing saved choices", async () => {
render(<CompositionModulePicker kind="skills" label="技能" choices={[]} members={[{ id: "saved" }]} disabled={false} onChange={() => {}} />);
await userEvent.click(screen.getByRole("button", { name: "技能 添加模块" }));
expect(screen.getByText("暂无可选模块")).toBeTruthy();
expect(screen.getByRole<HTMLInputElement>("checkbox").disabled).toBe(true);
expect(screen.getByRole("button", { name: "清空" })).toBeTruthy();
});
@@ -6,10 +6,12 @@ import type {
CompositionChoice,
CompositionKind,
CompositionSelection,
CompositionModule,
} from "../port/composition";
import { COMPOSITION_KINDS } from "../port/composition";
import { Switch } from "./Switch";
import { StudioIcon } from "./StudioIcon";
import { CompositionModulePicker } from "./CompositionModulePicker";
const labels: Record<CompositionKind, string> = {
tools: "工具",
@@ -163,16 +165,17 @@ function ModuleEditor({
setJsonErrors(errors);
onValidityChange(Object.values(errors).every((v) => !v));
};
const remove = (index: number) => {
const shift = <T,>(source: Record<number, T>): Record<number, T> =>
const changeMembers = (members: CompositionModule[]) => {
const remap = <T,>(source: Record<number, T>): Record<number, T> =>
Object.fromEntries(
Object.entries(source)
.filter(([key]) => Number(key) !== index)
.map(([key, v]) => [Number(key) > index ? Number(key) - 1 : Number(key), v]),
members.flatMap((member, index) => {
const old = value.members.indexOf(member);
return old in source ? [[index, source[old]]] : [];
}),
);
validate(shift(jsonErrors));
setRawParameters(shift(rawParameters));
onChange({ ...value, members: value.members.filter((_, i) => i !== index) });
validate(remap(jsonErrors));
setRawParameters(remap(rawParameters));
onChange({ ...value, members });
};
return (
<fieldset className="composition-modules" disabled={disabled}>
@@ -180,6 +183,7 @@ function ModuleEditor({
{kind === "hooks" && (
<p className="ds">{t("安全 Hook 和权限规则始终生效;此处仅选择非阻断型自动化。")}</p>
)}
<div className="composition-module-controls">
<select
aria-label={`${t(labels[kind])} ${t("选择方式")}`}
data-action-change="agent-preset.module-mode"
@@ -194,29 +198,10 @@ function ModuleEditor({
<option value="inherit">{t("跟随当前配置,包含之后启用的资源")}</option>
<option value="select">{t("仅使用所选项(空列表表示禁用)")}</option>
</select>
{value.mode === "select" && <CompositionModulePicker kind={kind} label={t(labels[kind])} choices={choices} members={value.members} disabled={disabled} onChange={changeMembers} />}
</div>
{value.mode === "select" && (
<>
{choices.length > 0 && (
<select
aria-label={`${t(labels[kind])} ${t("添加模块")}`}
data-action-change="agent-preset.module-choose"
data-target={kind}
value=""
onChange={(e) => {
if (e.target.value)
onChange({ ...value, members: [...value.members, { id: e.target.value }] });
}}
>
<option value="">{t("添加模块")}</option>
{choices
.filter((m) => !value.members.some((s) => s.id === m.id))
.map((m) => (
<option key={m.id} value={m.id} title={m.description || undefined}>
{m.description ? `${m.id} — ${m.description}` : m.id}
</option>
))}
</select>
)}
{value.members.map((module, index) => (
<div className="composition-module" key={index}>
<div className="composition-toolbar">
@@ -238,7 +223,7 @@ function ModuleEditor({
className="act"
data-action="agent-preset.module-remove"
data-target={kind}
onClick={() => remove(index)}
onClick={() => changeMembers(value.members.filter((_, i) => i !== index))}
>
{t("移除")}
</button>
@@ -275,6 +260,9 @@ function ModuleEditor({
)}
</div>
))}
<details className="composition-manual">
<summary>{t("高级:手动输入模块 ID")}</summary>
<p className="ds">{t("仅在模块未列出时使用;填写 ID 不会安装模块或授予权限。")}</p>
<button
type="button"
className="act"
@@ -282,8 +270,9 @@ function ModuleEditor({
data-target={kind}
onClick={() => onChange({ ...value, members: [...value.members, { id: "" }] })}
>
{t("添加模块")}
{t("添加空白 ID")}
</button>
</details>
</>
)}
</fieldset>
@@ -0,0 +1,111 @@
import { useCallback, useEffect, useId, useRef, useState, type KeyboardEvent } from "react";
import { createPortal } from "react-dom";
import { t } from "../i18n";
import type { CompositionChoice, CompositionKind, CompositionModule } from "../port/composition";
import { StudioIcon } from "./StudioIcon";
import { useDismiss } from "./dismiss";
import { beside, pinToViewport, useFollow, zoom } from "./place";
import "../styles/composition-modules.css";
interface Props {
kind: CompositionKind;
label: string;
choices: CompositionChoice[];
members: CompositionModule[];
disabled: boolean;
onChange: (members: CompositionModule[]) => void;
}
export function CompositionModulePicker({ kind, label, choices, members, disabled, onChange }: Props) {
const [open, setOpen] = useState(false);
const [query, setQuery] = useState("");
const trigger = useRef<HTMLButtonElement>(null);
const panel = useRef<HTMLDivElement>(null);
const search = useRef<HTMLInputElement>(null);
const all = useRef<HTMLInputElement>(null);
const id = useId();
const close = useCallback(() => { setOpen(false); trigger.current?.focus(); }, []);
const visible = open && !disabled;
const place = useCallback(() => {
if (!panel.current || !trigger.current) return;
const rect = trigger.current.getBoundingClientRect();
pinToViewport(panel.current, rect.left, beside(rect, panel.current.offsetHeight * zoom(), innerHeight, "below", 6), 12);
}, []);
useFollow(visible, place, query, { anchor: trigger, container: panel });
useDismiss(visible, panel, close, trigger);
useEffect(() => { if (disabled) setOpen(false); }, [disabled]);
useEffect(() => { if (visible) search.current?.focus(); }, [visible]);
useEffect(() => {
if (!visible || !panel.current) return;
const observer = new ResizeObserver(place);
observer.observe(panel.current);
return () => observer.disconnect();
}, [visible, place]);
const selected = new Set(members.map(m => m.id));
const unique = [...new Map(choices.map(choice => [choice.id, choice])).values()];
const needle = query.trim().toLocaleLowerCase();
const shown = unique.filter(choice => `${choice.id} ${choice.description ?? ""}`.toLocaleLowerCase().includes(needle));
const count = shown.filter(choice => selected.has(choice.id)).length;
useEffect(() => { if (all.current) all.current.indeterminate = count > 0 && count < shown.length; }, [count, shown.length, visible]);
const toggle = (items: CompositionChoice[], checked: boolean) => {
const ids = new Set(items.map(item => item.id));
onChange(checked ? [...members, ...items.filter(item => !selected.has(item.id)).map(item => ({ id: item.id }))] : members.filter(member => !ids.has(member.id)));
};
const navigate = (event: KeyboardEvent<HTMLDivElement>) => {
const stops = Array.from(panel.current?.querySelectorAll<HTMLElement>("button:not(:disabled), input:not(:disabled)") ?? []);
if (event.key === "Tab") {
const at = stops.indexOf(document.activeElement as HTMLElement);
if (event.shiftKey && at === 0 || !event.shiftKey && at === stops.length - 1) {
event.preventDefault(); (event.shiftKey ? stops.at(-1) : stops[0])?.focus();
}
}
if (event.key !== "ArrowDown" && event.key !== "ArrowUp") return;
const rows = Array.from(panel.current?.querySelectorAll<HTMLInputElement>(".composition-choice input") ?? []);
if (!rows.length) return;
const at = rows.indexOf(document.activeElement as HTMLInputElement);
event.preventDefault();
rows[at < 0 ? event.key === "ArrowDown" ? 0 : rows.length - 1 : (at + (event.key === "ArrowDown" ? 1 : -1) + rows.length) % rows.length]?.focus();
};
const name = `${label} ${t("添加模块")}`;
return <>
<button ref={trigger} type="button" className="composition-module-trigger" data-action="agent-preset.module-choose" data-target={kind}
aria-label={name} aria-haspopup="dialog" aria-expanded={visible} aria-controls={visible ? id : undefined} disabled={disabled}
onClick={() => { if (visible) close(); else { setQuery(""); setOpen(true); } }}>
<span>{t("添加模块")} · {t("已选 {n} 项", { n: members.length })}</span><StudioIcon name="down" />
</button>
{visible && createPortal(<div id={id} ref={panel} className="composition-module-picker" role="dialog" aria-label={name} aria-modal="true"
data-action-keydown="agent-preset.module-navigate" onKeyDown={navigate}>
<div className="composition-picker-tools">
<label className="composition-select-all"><input ref={all} type="checkbox" checked={shown.length > 0 && count === shown.length} disabled={!shown.length}
data-action-change="agent-preset.module-select-all" data-target={kind} onChange={event => toggle(shown, event.target.checked)} />
{t(needle ? "全选搜索结果({n} 项)" : "全选({n} 项)", { n: shown.length })}</label>
<button type="button" className="composition-picker-action" disabled={!members.length} data-action="agent-preset.module-clear" data-target={kind} onClick={() => onChange([])}>{t("清空")}</button>
</div>
<div className="composition-picker-search"><StudioIcon name="search" /><input ref={search} type="search" aria-label={t("搜索模块")} placeholder={t("搜索模块名称或说明…")}
data-action-change="agent-preset.module-search" data-target={kind} value={query} onChange={event => setQuery(event.target.value)} /></div>
<div className="composition-picker-list">
{(["builtin", "custom", "unknown"] as const).map(source => {
const items = shown.filter(choice => (choice.source === "builtin" || choice.source === "custom" ? choice.source : "unknown") === source);
if (!items.length) return null;
const chosen = items.filter(item => selected.has(item.id)).length;
const group = t(source === "builtin" ? "内置" : source === "custom" ? "自定义" : "未分类");
return <div role="group" aria-label={group} className="composition-picker-group" key={source}>
<div className="composition-picker-group-heading"><strong>{group}</strong><small>{t("已选 {selected}/{total}", { selected: chosen, total: items.length })}</small>
<button type="button" className="composition-picker-action" data-action="agent-preset.module-select-group" data-target={`${kind}:${source}`}
onClick={() => toggle(items, chosen !== items.length)}>{t(chosen === items.length ? "取消本组全选" : "全选本组")}</button></div>
{items.map((item, index) => <label key={item.id} className="composition-choice">
<input type="checkbox" aria-label={item.id} aria-describedby={item.description ? `${id}-${source}-${index}` : undefined} checked={selected.has(item.id)}
data-action-change="agent-preset.module-toggle" data-target={`${kind}:${item.id}`} onChange={event => toggle([item], event.target.checked)} />
<StudioIcon name={source === "builtin" ? "code" : "layers"} /><span className="composition-choice-name">{item.id}</span>
{item.description && <span className="composition-choice-description" id={`${id}-${source}-${index}`} title={item.description}>{item.description}</span>}
</label>)}
</div>;
})}
{!shown.length && <p className="composition-picker-empty">{t(unique.length ? "没有匹配的模块" : "暂无可选模块")}</p>}
</div>
<div className="composition-picker-footer"><span aria-live="polite">{t("已选 {n} 项", { n: members.length })}</span>
<button type="button" className="btn" data-primary="" data-action="agent-preset.module-done" onClick={close}>{t("完成")}</button></div>
<p className="composition-picker-note">{t("全选仅包含当前列表;新增模块不会自动选中。")}</p>
</div>, document.body)}
</>;
}
+14
View File
@@ -21,6 +21,20 @@ to bind a version.
## Choosing and editing
For **Selected modules only**, **Add modules** opens a checkbox menu. Select several
items without reopening it, search by ID or description, or select a whole source
group. Built-in and custom modules are grouped using catalog provenance; older
hosts without that metadata show an unclassified group.
**Select all** affects the current search results only. It preserves selections
outside the results and never selects future additions. **Clear** removes every
selected ID. **Done**, Escape and outside clicks close the menu while keeping the
draft; **Save preset** persists it. Inheritance remains a separate mode.
Existing module parameters survive bulk selection changes. Unlisted IDs remain
editable, and **Advanced: enter a module ID** retains manual entry without implying
that an ID installs a resource or grants authorization.
The mode picker is hidden by default. Enable **Show mode picker** on the Persona
management page to add it above the chat input in every pane. Settings search
also leads directly to this switch. Hiding it leaves the conversation's mode intact.
+11
View File
@@ -13,6 +13,17 @@ Studio 的「Agent 预设」把人设、工具、技能、MCP、扩展、Hook
## 选择和修改
选择「仅使用所选项」后,「添加模块」打开多选下拉菜单。可连续勾选多项、按 ID
或说明搜索,也可按来源分组全选。内置与自定义分组使用目录提供的来源信息;
旧服务端没有来源信息时显示「未分类」。
「全选」只作用于当前搜索结果,保留搜索结果之外的已选项,不会自动选择以后新增
的模块。「清空」移除全部已选 ID。「完成」、Escape 和点击外部都会关闭菜单并保留
草稿;点击「保存预设」才持久化。「继承当前配置」仍是独立模式。
批量修改保留已有模块的参数。目录中缺失的 ID 仍可编辑;「高级:手动输入模块 ID」
保留手动入口,填写 ID 不会安装资源或授予权限。
模式选择器默认隐藏。在「人设」管理页开启「显示模式选择器」后,所有窗格的聊天
输入框顶部显示选择器;设置搜索可直接定位此开关。关闭只隐藏入口,不改变会话模式。
@@ -36,38 +36,42 @@ func (c *Controller) compositionInventory() (map[string][]agentpreset.Choice, er
return nil, err
}
out := map[string][]agentpreset.Choice{}
add := func(kind, id, description string) {
out[kind] = append(out[kind], agentpreset.Choice{ID: id, Description: choiceDescription(description)})
add := func(kind, id, description, source string) {
out[kind] = append(out[kind], agentpreset.Choice{ID: id, Description: choiceDescription(description), Source: source})
}
for _, kind := range []string{"tools", "skills", "mcp", "extensions", "hooks", "commands"} {
out[kind] = []agentpreset.Choice{}
}
for _, t := range tool.Builtins() {
add("tools", t.Name(), t.Description())
add("tools", t.Name(), t.Description(), "builtin")
}
for _, name := range []string{"task", "run_skill", "read_only_skill", "use_capability"} {
add("tools", name, "")
add("tools", name, "", "builtin")
}
store := skill.New(skill.Options{ProjectRoot: s.Workspace, ReasonixHomeDir: s.Home, CustomPaths: cfg.SkillCustomPaths(), PluginPaths: cfg.PluginPackageSkillOwners(), PluginAgentPaths: cfg.PluginPackageAgentOwners(), ExcludedPaths: cfg.SkillExcludedPaths(), SuppressWarnings: true})
for _, sk := range store.CompositionSkills() {
add("skills", sk.SlashName(), sk.Description)
source := "custom"
if sk.Scope == skill.ScopeBuiltin {
source = "builtin"
}
add("skills", sk.SlashName(), sk.Description, source)
}
for _, m := range cfg.EnabledPlugins(s.Workspace, config.DefaultActivationStore()) {
add("mcp", m.Name, "")
add("mcp", m.Name, "", "custom")
}
installed, _ := pluginpkg.LoadInstalled(s.Home)
for _, p := range installed {
add("extensions", p.Installed.Name, "")
add("extensions", p.Installed.Name, "", "custom")
}
cmds, err := command.LoadRoots(cfg.Roots().CommandRootsForRoot(s.Workspace)...)
if err != nil {
return nil, err
}
for _, cmd := range cmds {
add("commands", cmd.Name, cmd.Description)
add("commands", cmd.Name, cmd.Description, "custom")
}
for i, h := range hook.Load(hook.LoadOptions{ProjectRoot: s.Workspace, ReasonixHomeDir: s.Home}) {
add("hooks", fmt.Sprintf("%s:%s:%d", h.Scope, h.Event, i), "")
add("hooks", fmt.Sprintf("%s:%s:%d", h.Scope, h.Event, i), "", "custom")
}
for _, items := range out {
sort.Slice(items, func(i, j int) bool { return items[i].ID < items[j].ID })
@@ -1,14 +1,62 @@
package control
import (
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
"reasonix/internal/ext/skill"
"reasonix/internal/runtime/agent/testutil"
)
func TestModuleChoicesDeclareTheirSourceWithoutGuessingNames(t *testing.T) {
c, _, _ := newOneTurnController(t, testutil.Turn{Text: "answer"})
testComposition(t, c)
s := c.PresetStore()
store := skill.New(skill.Options{ProjectRoot: s.Workspace, ReasonixHomeDir: s.Home, SuppressWarnings: true})
var builtin string
for _, sk := range store.CompositionSkills() {
if sk.Scope == skill.ScopeBuiltin {
builtin = sk.SlashName()
break
}
}
if builtin == "" {
t.Fatal("missing built-in skill fixture")
}
check := func(id, want string) {
t.Helper()
catalog, err := c.AgentPresets()
if err != nil {
t.Fatal(err)
}
for _, choice := range catalog.Modules["skills"] {
if choice.ID != id {
continue
}
raw, _ := json.Marshal(choice)
var fields map[string]any
_ = json.Unmarshal(raw, &fields)
if fields["source"] != want {
t.Fatalf("%s source = %v, want %s", id, fields["source"], want)
}
return
}
t.Fatalf("missing choice %s", id)
}
check(builtin, "builtin")
dir := filepath.Join(s.Home, "skills", builtin)
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, "SKILL.md"), []byte("---\nname: "+builtin+"\ndescription: Personal override\n---\nBody"), 0o600); err != nil {
t.Fatal(err)
}
check(builtin, "custom")
}
func choiceByID(t *testing.T, kind string, c *Controller, id string) (string, bool) {
t.Helper()
catalog, err := c.AgentPresets()
+1
View File
@@ -16,6 +16,7 @@ import (
type Choice struct {
ID string `json:"id"`
Description string `json:"description,omitempty"`
Source string `json:"source,omitempty"`
}
type Catalog struct {
@@ -0,0 +1,2 @@
- 预设模块选择支持多选、搜索、全选与内置/自定义分组;手动 ID 入口移至高级选项,避免两个“添加模块”含义不同。Refs #13988.
- Preset modules support grouped multi-selection, search and select all. Manual ID entry is explicitly labeled as advanced; existing parameters are preserved. Refs #13988.