feat(studio): the extension project scope moves into settings as an Extension scope row on the Permissions page; the extension page only says so when it is managing another project, as the new Set-scope board draws it

This commit is contained in:
yhh committed 2026-11-06 02:35:44 -08:00
1 parent 4c6ba31a1c
commit 5e00091026
10 files changed
+162 -45

No files matched your search

@@ -449,3 +449,13 @@
.ext-foot { display: flex; align-items: center; gap: 8px; margin-top: 12px; padding: 10px 14px; border: 1px solid var(--border); border-radius: var(--r-md); color: var(--muted); font: var(--w-reg) var(--fs-meta)/1.5 var(--ui); }
.ext-foot > .studio-icon { flex: none; width: 14px; height: 14px; }
.ext-foot > span { flex: 1; min-width: 0; }
.scoperow .scopeinfo { display: flex; align-items: baseline; flex-wrap: wrap; gap: 4px 10px; margin: 8px 0 12px; padding: 8px 12px; border-radius: var(--r-md); background: var(--page); color: var(--muted); font: var(--w-reg) var(--fs-meta)/1.6 var(--ui); }
.scoperow .scopeinfo .pt { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-family: var(--mono); }
.scoperow .scopeinfo .br { padding: 1px 7px; border-radius: var(--r-pill); background: var(--overlay); font: var(--w-reg) var(--fs-micro) var(--mono); }
.scoperow .scopeanchor { position: relative; }
.scoperow .scopeanchor .scope-pick { top: calc(100% + 4px); right: 0; left: auto; }
.scoperow .who .cv { margin-left: 6px; font-style: normal; font-size: var(--fs-micro); color: var(--faint); }
.scopeaway { display: flex; align-items: center; gap: 8px; margin: 0 0 12px; padding: 8px 10px 8px 12px; border-radius: var(--r-md); background: color-mix(in srgb, var(--warn) 12%, transparent); font: var(--w-reg) var(--fs-meta)/1.5 var(--ui); }
.scopeaway > span { flex: 1; min-width: 0; }
.scopeaway .act { height: 24px; padding: 0 10px; border: 1px solid var(--border); border-radius: var(--r-pill); background: transparent; color: var(--text); font: var(--w-reg) var(--fs-meta)/1 var(--ui); cursor: pointer; }
.scopeaway .act:hover { background: var(--overlay); }
@@ -35,6 +35,7 @@ export const EXT: Messages = {
"ext.awaiting_approval_declared_by_the": { zh: "待授权 · 来自项目声明", en: "Awaiting approval · declared by the project" },
"ext.back_to_list": { zh: "返回列表", en: "Back to list" },
"ext.back_to_my_packages": { zh: "返回我的发布", en: "Back to my packages" },
"ext.back_to_the_current_project": { zh: "回到当前项目", en: "Back to the current project" },
"ext.before_a_tool_runs": { zh: "工具执行前", en: "Before a tool runs" },
"ext.before_context_is_compacted": { zh: "压缩上下文前", en: "Before context is compacted" },
"ext.blank_starts_a_new_package": { zh: "留空时新包为 0.1.0;更新只自动递增纯数字三段版本的补丁号,其他版本请明确填写。", en: "Blank starts a new package at 0.1.0. Updates auto-increment only a numeric three-part version; enter other versions explicitly." },
@@ -98,6 +99,7 @@ export const EXT: Messages = {
"ext.export": { zh: "导出", en: "Export" },
"ext.exported": { zh: "导出完成。", en: "Exported." },
"ext.extension": { zh: "扩展", en: "Extension" },
"ext.extension_scope": { zh: "扩展范围", en: "Extension scope" },
"ext.external_services": { zh: "外部服务", en: "External services" },
"ext.feature_needs_it_so_it": { zh: "{feature}需要它,不能关闭。", en: "{feature} needs it, so it cannot be turned off." },
"ext.fix": { zh: "去修复", en: "Fix" },
@@ -140,6 +142,8 @@ export const EXT: Messages = {
"ext.loading_deferred_mcp_tools": { zh: "加载延后的 MCP 工具", en: "Loading deferred MCP tools" },
"ext.long_running_process": { zh: "常驻进程", en: "Long-running process" },
"ext.lowercase_letters_digits_dots_underscores": { zh: "小写字母、数字、点、下划线、连字符,最多 64 个字符。", en: "Lowercase letters, digits, dots, underscores and hyphens, up to 64 characters." },
"ext.manage_which_project": { zh: "管理哪个项目的扩展", en: "Manage extensions for which project" },
"ext.managing_other_project": { zh: "正在管理 {name} 的扩展,不是当前会话所在的项目。", en: "Managing the extensions of {name}, not the project this session is in." },
"ext.mine": { zh: "我的", en: "Mine" },
"ext.models_choice_only": { zh: "只能模型自选", en: "Model's choice only" },
"ext.modifies_data": { zh: "会修改数据", en: "Modifies data" },
@@ -276,6 +280,7 @@ export const EXT: Messages = {
"ext.saved_as_handle_visible_only": { zh: "以 @{handle} 的名义保存,仅自己可见,不提交审核。只收来源地址,不上传文件。", en: "Saved as @{handle}, visible only to you and not submitted for review. Only the source address is sent; no files are uploaded." },
"ext.saved_slug_version_visible_only": { zh: "已保存 {slug} {version},仅自己可见", en: "Saved {slug} {version}, visible only to you" },
"ext.saved_to_path": { zh: "已保存至 {path}。", en: "Saved to {path}." },
"ext.scope_hint": { zh: "技能和外部服务可以只对某个项目开关;这里选在为谁设置", en: "Skills and external services can be switched for one project only; choose who you are setting them for" },
"ext.search_installed_packages": { zh: "搜索已安装包", en: "Search installed packages" },
"ext.search_package_names_skills_commands": { zh: "搜索包名、技能、命令或服务", en: "Search package names, skills, commands or servers" },
"ext.search_projects": { zh: "搜索项目…", en: "Search projects…" },
@@ -14,44 +14,65 @@ export function Exception({ onClear, busy }: { onClear: () => void; busy: boolea
);
}
// Which folder this page is answering for. The shell holds several projects at
// once and the pane follows whichever one is in front, so the heading has to
// name it — otherwise switching tabs silently changes the list under an
// unchanged title. Worktrees of one repository share one answer, and saying how
// many is what stops "my settings vanished on a new branch". The name is also
// the way out: a project can be managed without the session moving to it.
export function ScopeBar({
// Which folder the extension lists answer for. The shell holds several projects
// at once and the pane follows whichever one is in front, so this is chosen on
// purpose here rather than inferred from the tab in front. Worktrees of one
// repository share one answer, and saying how many is what stops "my settings
// vanished on a new branch". Choosing a project manages it without the session
// moving to it.
export function ScopeRow({
scope, scopes, onPick,
}: {
scope: CapabilityScope; scopes: CapabilityScope[]; onPick: (root: string) => void;
}) {
const [open, setOpen] = useState(false);
const shared = scope.repo && (scope.trees ?? 1) > 1;
const elsewhere = !scope.current;
return (
<div className="scopebar" data-away={elsewhere ? "" : undefined}>
<button className="who" aria-expanded={open} onClick={() => setOpen(!open)}>
{scope.label || scope.name}
<i className="cv" aria-hidden>▾</i>
</button>
<span className="pt" title={scope.root}>{scope.root}</span>
{scope.branch && <span className="br">{scope.branch}</span>}
{shared && <span className="sh">{t("ext.n_worktrees_share_this_setting", { n: scope.trees ?? 1 })}</span>}
{!scope.repo && <span className="sh">{t("ext.not_a_git_repository_kept")}</span>}
<span className="ov">
{scope.overrides ? t("ext.n_settings_apply_only_here", { n: scope.overrides }) : t("ext.no_exceptions_for_this_project")}
</span>
{elsewhere && <span className="away">{t("ext.not_the_project_this_session")}</span>}
{open && (
<ScopePicker
scopes={scopes}
at={scope.root}
onPick={(root) => {
setOpen(false);
onPick(root);
}}
/>
)}
<div className="scoperow">
<div className="srow" data-setting="ext-scope">
<div className="srow-meta">
<span className="srow-name">{t("ext.manage_which_project")}</span>
<span className="srow-desc">{t("ext.scope_hint")}</span>
</div>
<div className="srow-ctl scopeanchor">
<button type="button" className="spill who" data-action="ext.scope" aria-expanded={open} onClick={() => setOpen(!open)}>
{scope.label || scope.name}
<i className="cv" aria-hidden>▾</i>
</button>
{open && (
<ScopePicker
scopes={scopes}
at={scope.root}
onPick={(root) => {
setOpen(false);
onPick(root);
}}
/>
)}
</div>
</div>
<div className="scopeinfo">
<span className="pt" title={scope.root}>{scope.root}</span>
{scope.branch && <span className="br">{scope.branch}</span>}
<span className="ds">
{[
shared ? t("ext.n_worktrees_share_this_setting", { n: scope.trees ?? 1 }) : null,
!scope.repo ? t("ext.not_a_git_repository_kept") : null,
scope.overrides ? t("ext.n_settings_apply_only_here", { n: scope.overrides }) : t("ext.no_exceptions_for_this_project"),
].filter(Boolean).join(" · ")}
</span>
</div>
</div>
);
}
// The extension page names whose list it is only when that is not the running
// project, so the same heading never quietly changes owner.
export function ScopeAway({ scope, onBack }: { scope: CapabilityScope; onBack: () => void }) {
return (
<div className="scopeaway" role="note">
<span>{t("ext.managing_other_project", { name: scope.label || scope.name })}</span>
<button type="button" className="act" data-action="ext.scope-back" onClick={onBack}>{t("ext.back_to_the_current_project")}</button>
</div>
);
}
@@ -29,8 +29,10 @@ it("keeps the selected project's skills when the previous read finishes late", a
accountUnread="" reloadAccount={() => {}}
/>);
await userEvent.click(await screen.findByRole("tab", { name: /权限与安全/ }));
await userEvent.click(await screen.findByRole("button", { name: /Project A/ }));
await userEvent.click(screen.getByRole("option", { name: /Project B/ }));
await userEvent.click(screen.getByRole("tab", { name: /^扩展/ }));
await screen.findByText("only-b");
await act(async () => finishA({ implicit: true, skills: [{ name: "only-a", enabled: true }] }));
expect(screen.getByText("only-b")).toBeTruthy();
+12 -4
View File
@@ -47,7 +47,8 @@ import { planProtocolSwitch } from "./protocolswitch";
import { Boundary } from "./Boundary";
import { DEFAULT_DAYS } from "./Usage";
import { Appearance, SCHEMES } from "./Appearance";
import { ScopeBar } from "./CapabilityScope";
import { ScopeAway, ScopeRow } from "./CapabilityScope";
import { SectionCard } from "./SectionCard";
import { savedNotApplied, reason } from "../i18n/kernel";
import type { SettingsProps as Props } from "./SettingsProps";
import { approvalName } from "./approvals";
@@ -457,8 +458,15 @@ export function Settings({ hub, onError, port, networkPort, localWorkspacePort,
)}
{at === "tools" && (
<SafetySettings port={port} mode={status?.toolApprovalMode} busy={busy} onChanged={onChanged}
onMode={(id) => run(id, () => port.setApprovalMode(id))} />
<>
<SafetySettings port={port} mode={status?.toolApprovalMode} busy={busy} onChanged={onChanged}
onMode={(id) => run(id, () => port.setApprovalMode(id))} />
{scope && (
<SectionCard title={t("ext.extension_scope")}>
<ScopeRow scope={scope} scopes={scopes} onPick={setScopeAt} />
</SectionCard>
)}
</>
)}
{at === "display" && <ChatDisplaySettings port={port} />}
@@ -480,7 +488,7 @@ export function Settings({ hub, onError, port, networkPort, localWorkspacePort,
updating={updatingPkg.name} onChanged={afterExtChange} onReloadError={reload.report} onReloaded={reload.applied}
onUpdate={(name) => setUpdatingPkg((p) => ({ ...p, name, applying: false }))} onFailed={setFailed} />
}>
{scope && <ScopeBar scope={scope} scopes={scopes} onPick={setScopeAt} />}
{scope && !scope.current && <ScopeAway scope={scope} onBack={() => setScopeAt("")} />}
<ModHost port={port} />
<Group id="plugins"
title={t("settings.plugin_packages")}
@@ -92,6 +92,16 @@ it.each(["first-exception", "issue-exception", "all-issues", "all-ready"])("noti
} else expect(screen.getByText(failure)).toBeTruthy();
});
// The project is chosen on the Permissions page, then the extension page is
// opened again: it names whose list it shows only when that is not the running project.
async function pickProject(from: string, to: string) {
await userEvent.click(screen.getByRole("tab", { name: /权限与安全/ }));
await userEvent.click(await screen.findByRole("button", { name: new RegExp(from) }));
await userEvent.click(screen.getByRole("option", { name: new RegExp(to) }));
await screen.findByText(`/workspace/${to}`);
await userEvent.click(screen.getByRole("tab", { name: /^扩展/ }));
}
it("refreshes the current managed project after an unmounted batch partially finishes", async () => {
const port = new MockPort() as unknown as AgentPort;
let fail!: (error: Error) => void;
@@ -106,9 +116,7 @@ it("refreshes the current managed project after an unmounted batch partially fin
await userEvent.click(await screen.findByRole("button", { name: "添加…" }));
await userEvent.click(await screen.findByRole("menuitem", { name: /接入外部服务/ }));
await preview();
await userEvent.click(within(document.querySelector<HTMLElement>(".scopebar")!).getByRole("button", { name: "main" }));
await userEvent.click(screen.getByRole("option", { name: /other/ }));
await screen.findByText("/workspace/other");
await pickProject("main", "other");
expect(document.querySelector(".addsrv")).toBeNull();
const reads = skills.mock.calls.length;
await act(async () => fail(new Error(failure)));
@@ -0,0 +1,51 @@
// @vitest-environment jsdom
import "./testkit";
import { afterEach, describe, expect, it, vi } from "vitest";
import { cleanup, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { ScopeAway, ScopeRow } from "./CapabilityScope";
import type { CapabilityScope } from "../port/port";
afterEach(cleanup);
const scope = (over: Partial<CapabilityScope> = {}): CapabilityScope => ({
key: "a", root: "/work/app", name: "app", label: "", repo: true, trees: 1, current: true, overrides: 0, branch: "main", ...over,
}) as CapabilityScope;
const scopes = [scope(), scope({ key: "b", root: "/work/site", name: "site", current: false, overrides: 2 })];
describe("the extension scope row in settings", () => {
it("names the project in a pill, with where it is and what it carries below", () => {
render(<ScopeRow scope={scope({ trees: 15 })} scopes={scopes} onPick={() => {}} />);
expect(screen.getByRole("button", { name: /app/ })).toBeTruthy();
expect(screen.getByText("管理哪个项目的扩展")).toBeTruthy();
const info = document.querySelector(".scopeinfo")!;
expect(info.textContent).toContain("/work/app");
expect(info.textContent).toContain("main");
expect(info.textContent).toContain("15 个工作树共用这份设置");
expect(info.textContent).toContain("没有本项目的例外");
});
it("counts the exceptions a project carries", () => {
render(<ScopeRow scope={scope({ overrides: 3 })} scopes={scopes} onPick={() => {}} />);
expect(document.querySelector(".scopeinfo")!.textContent).toContain("3 项只在这里生效");
});
it("opens the project list from the pill and hands back the chosen root", async () => {
const onPick = vi.fn();
render(<ScopeRow scope={scope()} scopes={scopes} onPick={onPick} />);
await userEvent.click(screen.getByRole("button", { name: /app/ }));
await userEvent.click(screen.getByRole("option", { name: /site/ }));
expect(onPick).toHaveBeenCalledWith("/work/site");
expect(screen.queryByRole("option")).toBeNull();
});
});
describe("the extension page naming another project", () => {
it("says whose list it is and offers the way back", async () => {
const onBack = vi.fn();
render(<ScopeAway scope={scope({ name: "site", current: false })} onBack={onBack} />);
expect(screen.getByRole("note").textContent).toContain("正在管理 site 的扩展,不是当前会话所在的项目。");
await userEvent.click(screen.getByRole("button", { name: "回到当前项目" }));
expect(onBack).toHaveBeenCalledOnce();
});
});
@@ -1,6 +1,6 @@
// @vitest-environment jsdom
import { afterEach, expect, it, vi } from "vitest";
import { act, cleanup, render, screen, within } from "@testing-library/react";
import { act, cleanup, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import "./testkit";
import { ServerRow } from "./ServerRow";
@@ -137,6 +137,16 @@ it.each(["port", "root"].flatMap((change) => [false, true].map((tools) => [chang
},
);
// The project is chosen on the Permissions page, then the extension page is
// opened again: it names whose list it shows only when that is not the running project.
async function pickProject(from: string, to: string) {
await userEvent.click(screen.getByRole("tab", { name: /权限与安全/ }));
await userEvent.click(await screen.findByRole("button", { name: new RegExp(from) }));
await userEvent.click(screen.getByRole("option", { name: new RegExp(to) }));
await screen.findByText(`/workspace/${to}`);
await userEvent.click(screen.getByRole("tab", { name: /^扩展/ }));
}
it("dismisses removal confirmation when choosing another managed project", async () => {
const port = new MockPort() as unknown as AgentPort;
const scopes: CapabilityScope[] = [
@@ -156,9 +166,7 @@ it("dismisses removal confirmation when choosing another managed project", async
/>);
await userEvent.click(await screen.findByRole("button", { name: "移除 docs" }));
expect(screen.getByRole("button", { name: "取消" })).toBeTruthy();
await userEvent.click(within(document.querySelector<HTMLElement>(".scopebar")!).getByRole("button", { name: "main" }));
await userEvent.click(screen.getByRole("option", { name: /other/ }));
await screen.findByText("/workspace/other");
await pickProject("main", "other");
expect(screen.queryByRole("button", { name: "取消" })).toBeNull();
expect(screen.queryByRole("button", { name: "移除" })).toBeNull();
expect(remove).not.toHaveBeenCalled();
@@ -35,7 +35,7 @@ describe("the permissions page", () => {
await waitFor(() => expect(shown("approval")).toBe(true));
expect(shown("sandbox")).toBe(true);
expect(shown("browser-tools")).toBe(true);
expect([...document.querySelectorAll(".prefs-card > header h3")].map((h) => h.textContent)).toEqual(["批准", "沙箱与执行", "联网"]);
expect([...document.querySelectorAll(".prefs-card > header h3")].map((h) => h.textContent)).toEqual(["批准", "沙箱与执行", "联网", "扩展范围"]);
expect(document.querySelectorAll(".prefs-page")).toHaveLength(0);
});
@@ -36,10 +36,14 @@ function settings(port: AgentPort, onChanged = vi.fn()) {
/>;
}
// The project is chosen on the Permissions page, then the extension page is
// opened again: it names whose list it shows only when that is not the running project.
async function chooseProject(from: string, to: string) {
await userEvent.click(within(document.querySelector<HTMLElement>(".scopebar")!).getByRole("button", { name: from }));
await userEvent.click(screen.getByRole("tab", { name: /权限与安全/ }));
await userEvent.click(await screen.findByRole("button", { name: new RegExp(from) }));
await userEvent.click(screen.getByRole("option", { name: new RegExp(to) }));
await screen.findByText(`/workspace/${to}`);
await userEvent.click(screen.getByRole("tab", { name: /^扩展/ }));
}
function deferred() {
@@ -142,7 +146,7 @@ it.each(["success", "failure"])("isolates a skill %s after choosing another proj
expect(control.disabled).toBe(false);
expect(onChanged).toHaveBeenCalledTimes(2);
expect(skills).toHaveBeenLastCalledWith("/workspace/other");
expect(within(document.querySelector<HTMLElement>(".scopebar")!).getByText("other")).toBeTruthy();
expect(within(document.querySelector<HTMLElement>(".scopeaway")!).getByText(/other/)).toBeTruthy();
});
it("refreshes a completed skill change after returning to its project", async () => {