mirror of
https://github.com/esengine/DeepSeek-Reasonix.git
synced 2026-11-07 10:45:59 -10:00
feat(studio): keep the mod host's settings out of sight until the kernel allows it
This commit is contained in:
1 parent
fd41bf51ce
commit
b3555c6c81
8 files changed
+80
-34
No files matched your search
@@ -4,6 +4,7 @@ import { chromium } from "playwright";
|
||||
|
||||
const BASE = process.env.PERF_URL ?? "http://localhost:4399/perf.html";
|
||||
const shotDir = process.env.SHOTS;
|
||||
const SHOWN = process.env.MODS_SHOWN === "1";
|
||||
const fails = [];
|
||||
const check = (name, ok, detail = "") => {
|
||||
console.log(`${ok ? " ok" : "FAIL"} ${name}${detail ? " — " + detail : ""}`);
|
||||
@@ -15,6 +16,15 @@ const band = { kind: "extension_surface", extension: { pluginId: "statusline-clo
|
||||
] } } };
|
||||
const ask = { kind: "extension_surface", extension: { pluginId: "git-guard", surfaceId: "mod-ask", kind: "form", sessionId: "s", generation: 1, form: { title: "git-guard asks", message: "要删除已经合并的分支 old-spike 吗?", fields: [{ key: "answer", label: "回答", kind: "input" }] } } };
|
||||
|
||||
// The pane attaches to its stream a moment after the page is up, and an event fed earlier is lost; feed again until it lands.
|
||||
async function feedUntil(page, ev, seen) {
|
||||
for (let i = 0; i < 20; i++) {
|
||||
await page.evaluate((e) => window.__feed(e), ev);
|
||||
if (await seen().then(() => true, () => false)) return;
|
||||
}
|
||||
await seen();
|
||||
}
|
||||
|
||||
const browser = await chromium.launch();
|
||||
for (const scheme of ["light", "dark"]) {
|
||||
for (const width of [1280, 420]) {
|
||||
@@ -24,8 +34,7 @@ for (const scheme of ["light", "dark"]) {
|
||||
await page.goto(`${BASE}?pref=zh&turns=2`, { waitUntil: "networkidle" });
|
||||
await page.waitForSelector(".app");
|
||||
|
||||
await page.evaluate((ev) => window.__feed(ev), band);
|
||||
await page.getByRole("button", { name: "稍后提醒" }).waitFor();
|
||||
await feedUntil(page, band, () => page.getByRole("button", { name: "稍后提醒" }).waitFor({ timeout: 500 }));
|
||||
const at = await page.evaluate(() => {
|
||||
const s = document.querySelector(".slotted").getBoundingClientRect();
|
||||
const c = document.querySelector('[aria-label="任务输入"]').getBoundingClientRect();
|
||||
@@ -45,8 +54,7 @@ for (const scheme of ["light", "dark"]) {
|
||||
await page.evaluate(() => document.activeElement?.blur());
|
||||
if (shotDir) { await page.mouse.move(width - 5, 895); await page.waitForTimeout(300); await page.screenshot({ path: `${shotDir}/${scheme}-${width}-band.png`, clip: { x: 0, y: 760, width, height: 540 } }); }
|
||||
|
||||
await page.evaluate((ev) => window.__feed(ev), ask);
|
||||
await page.waitForSelector('.call[data-k="extension"] .extform');
|
||||
await feedUntil(page, ask, () => page.waitForSelector('.call[data-k="extension"] .extform', { timeout: 500 }));
|
||||
const card = await page.evaluate(() => {
|
||||
const form = document.querySelector(".extform");
|
||||
return { note: form.querySelector(".why")?.textContent ?? "", tag: document.querySelector('.call[data-k="extension"] .tag')?.textContent ?? "" };
|
||||
@@ -57,23 +65,33 @@ for (const scheme of ["light", "dark"]) {
|
||||
await page.evaluate(() => document.querySelector('[data-action="chrome.settings"]')?.click());
|
||||
await page.waitForSelector(".prefs-nav button");
|
||||
await page.evaluate(() => [...document.querySelectorAll(".prefs-nav button")].find((b) => b.textContent.trim().startsWith("扩展"))?.click());
|
||||
await page.waitForSelector("#set-mods");
|
||||
const off = await page.evaluate(() => {
|
||||
const g = document.getElementById("set-mods");
|
||||
return { checked: g.querySelector('[role="switch"]').getAttribute("aria-checked"), rows: g.querySelectorAll(".modlist li").length, why: [...g.querySelectorAll(".modlist .ds")].filter((e) => e.textContent.includes("没有打开")).length };
|
||||
});
|
||||
check(`${tag}: the switch ships off and every mod says why it is not running`, off.checked === "false" && off.rows === 4 && off.why === 4, `${off.rows} mods, ${off.why} reasons`);
|
||||
await page.locator('#set-mods [role="switch"]').click();
|
||||
await page.waitForFunction(() => document.querySelectorAll("#set-mods .modlist li[data-state='running']").length === 1);
|
||||
const on = await page.evaluate(() => {
|
||||
const g = document.getElementById("set-mods");
|
||||
const r = g.getBoundingClientRect();
|
||||
const states = [...g.querySelectorAll(".modlist li")].map((li) => li.getAttribute("data-state"));
|
||||
return { states, spill: r.right > innerWidth + 1 || g.scrollWidth > g.clientWidth + 1, marks: [...g.querySelectorAll(".modstate")].every((e) => /^\S+\s+\S+/.test(e.textContent.trim())) };
|
||||
});
|
||||
check(`${tag}: each state shows running, disabled, restarting or not running, with a mark as well as a word`, on.states.join() === "running,disabled,restarting,inactive" && on.marks, on.states.join());
|
||||
check(`${tag}: the block does not spill sideways`, !on.spill);
|
||||
if (shotDir) await page.locator("#set-mods").screenshot({ path: `${shotDir}/${scheme}-${width}-settings.png` });
|
||||
await page.waitForSelector('[data-action="extensions.add"]');
|
||||
if (!SHOWN) {
|
||||
check(`${tag}: the mod settings block is not drawn while the host is hidden`, (await page.locator("#set-mods").count()) === 0);
|
||||
await page.locator('input[aria-label="搜索设置"]').fill("Claude Code mod");
|
||||
await page.waitForTimeout(400);
|
||||
const found = await page.evaluate(() => [...document.querySelectorAll(".prefs-nav *, .prefs-results *")].some((e) => e.children.length === 0 && /Claude Code mod/.test(e.textContent)));
|
||||
check(`${tag}: settings search does not lead to it either`, !found);
|
||||
await page.locator('input[aria-label="搜索设置"]').fill("");
|
||||
} else {
|
||||
await page.waitForSelector("#set-mods");
|
||||
const off = await page.evaluate(() => {
|
||||
const g = document.getElementById("set-mods");
|
||||
return { checked: g.querySelector('[role="switch"]').getAttribute("aria-checked"), rows: g.querySelectorAll(".modlist li").length, why: [...g.querySelectorAll(".modlist .ds")].filter((e) => e.textContent.includes("没有打开")).length };
|
||||
});
|
||||
check(`${tag}: the switch ships off and every mod says why it is not running`, off.checked === "false" && off.rows === 4 && off.why === 4, `${off.rows} mods, ${off.why} reasons`);
|
||||
await page.locator('#set-mods [role="switch"]').click();
|
||||
await page.waitForFunction(() => document.querySelectorAll("#set-mods .modlist li[data-state='running']").length === 1);
|
||||
const on = await page.evaluate(() => {
|
||||
const g = document.getElementById("set-mods");
|
||||
const r = g.getBoundingClientRect();
|
||||
const states = [...g.querySelectorAll(".modlist li")].map((li) => li.getAttribute("data-state"));
|
||||
return { states, spill: r.right > innerWidth + 1 || g.scrollWidth > g.clientWidth + 1, marks: [...g.querySelectorAll(".modstate")].every((e) => /^\S+\s+\S+/.test(e.textContent.trim())) };
|
||||
});
|
||||
check(`${tag}: each state shows running, disabled, restarting or not running, with a mark as well as a word`, on.states.join() === "running,disabled,restarting,inactive" && on.marks, on.states.join());
|
||||
check(`${tag}: the block does not spill sideways`, !on.spill);
|
||||
if (shotDir) await page.locator("#set-mods").screenshot({ path: `${shotDir}/${scheme}-${width}-settings.png` });
|
||||
}
|
||||
|
||||
if (width === 1280) {
|
||||
await page.locator('[data-action="extensions.add"]').first().click();
|
||||
|
||||
@@ -4,6 +4,8 @@ import { cleanup, render, screen, waitFor, within } from "@testing-library/react
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import "./testkit";
|
||||
import { ModHost } from "./ModHost";
|
||||
import { MODS_SHOWN } from "./modsgate";
|
||||
import { SETTINGS } from "./prefsnav";
|
||||
import { MockPort } from "../port/mock";
|
||||
import { HttpError } from "../port/port";
|
||||
import type { ModStatus } from "../port/port";
|
||||
@@ -14,8 +16,22 @@ afterEach(cleanup);
|
||||
const row = (name: string) => screen.getByText(name, { selector: "b" }).closest("li") as HTMLElement;
|
||||
|
||||
describe("the Claude Code mod block", () => {
|
||||
it("draws nothing, and asks the kernel nothing, while the host is hidden", () => {
|
||||
const port = new MockPort();
|
||||
const read = vi.spyOn(port, "modsSettings");
|
||||
const { container } = render(<ModHost port={port} />);
|
||||
expect(container.innerHTML).toBe("");
|
||||
expect(read).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("is not offered by settings search or counted in its section while hidden", () => {
|
||||
const entry = SETTINGS.find((e) => e.anchor === "mods")!;
|
||||
expect(entry.hidden).toBe(!MODS_SHOWN);
|
||||
expect(SETTINGS.filter((e) => e.section === "ext" && !e.hidden).includes(entry)).toBe(MODS_SHOWN);
|
||||
});
|
||||
|
||||
it("ships off, says what turning it on means, and every mod says why it is not running", async () => {
|
||||
render(<ModHost port={new MockPort()} />);
|
||||
render(<ModHost port={new MockPort()} shown />);
|
||||
const sw = await screen.findByRole("switch", { name: "运行 Claude Code mod" });
|
||||
expect(sw.getAttribute("aria-checked")).toBe("false");
|
||||
expect(screen.getByText(/完全信任.*没有沙盒.*不会被本应用观测到/)).toBeTruthy();
|
||||
@@ -26,7 +42,7 @@ describe("the Claude Code mod block", () => {
|
||||
it("turning it on saves once, then lists each state with the words for its typed code", async () => {
|
||||
const port = new MockPort();
|
||||
const save = vi.spyOn(port, "saveModsSettings");
|
||||
render(<ModHost port={port} />);
|
||||
render(<ModHost port={port} shown />);
|
||||
await userEvent.click(await screen.findByRole("switch", { name: "运行 Claude Code mod" }));
|
||||
expect(save).toHaveBeenCalledExactlyOnceWith({ enabled: true });
|
||||
await waitFor(() => expect(within(row("statusline-clock")).getByText(/运行中/)).toBeTruthy());
|
||||
@@ -42,7 +58,7 @@ describe("the Claude Code mod block", () => {
|
||||
const port = new MockPort();
|
||||
const lying: ModStatus[] = [{ plugin: "liar", state: "disabled", code: "mod.crashed", detail: "everything is running fine", restarts: 0 }];
|
||||
vi.spyOn(port, "mods").mockResolvedValue(lying);
|
||||
render(<ModHost port={port} />);
|
||||
render(<ModHost port={port} shown />);
|
||||
expect(await screen.findByText(/进程崩溃了/)).toBeTruthy();
|
||||
expect(screen.queryByText("everything is running fine")).toBeNull();
|
||||
});
|
||||
@@ -50,14 +66,14 @@ describe("the Claude Code mod block", () => {
|
||||
it("degrades to the kernel's own text for a code it has no words for", async () => {
|
||||
const port = new MockPort();
|
||||
vi.spyOn(port, "mods").mockResolvedValue([{ plugin: "odd", state: "disabled", code: "mod.something_new", detail: "the kernel says so", restarts: 0 }]);
|
||||
render(<ModHost port={port} />);
|
||||
render(<ModHost port={port} shown />);
|
||||
expect(await screen.findByText("the kernel says so")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("commits the time limit once on Enter and refuses a number out of range without writing", async () => {
|
||||
const port = new MockPort();
|
||||
const save = vi.spyOn(port, "saveModsSettings");
|
||||
render(<ModHost port={port} />);
|
||||
render(<ModHost port={port} shown />);
|
||||
const field = await screen.findByRole("textbox", { name: "单个钩子的运行时限" });
|
||||
await userEvent.clear(field);
|
||||
await userEvent.type(field, "20000{Enter}");
|
||||
@@ -71,7 +87,7 @@ describe("the Claude Code mod block", () => {
|
||||
it("goes read-only when this window may not edit, and says so", async () => {
|
||||
const port = new MockPort();
|
||||
vi.spyOn(port, "saveModsSettings").mockRejectedValue(new HttpError(403, "no", { code: "mods.editing_disabled", error: "off" }));
|
||||
render(<ModHost port={port} />);
|
||||
render(<ModHost port={port} shown />);
|
||||
const sw = await screen.findByRole("switch", { name: "运行 Claude Code mod" });
|
||||
await userEvent.click(sw);
|
||||
expect(await screen.findByText("这些设置现在是只读的:这个窗口没有被授予编辑权限。")).toBeTruthy();
|
||||
@@ -81,7 +97,7 @@ describe("the Claude Code mod block", () => {
|
||||
|
||||
it("says so when the settings cannot be read", async () => {
|
||||
const port = { modsSettings: vi.fn(async () => { throw new Error("down"); }), mods: vi.fn(async () => []) } as unknown as AgentPort;
|
||||
render(<ModHost port={port} />);
|
||||
render(<ModHost port={port} shown />);
|
||||
expect(await screen.findByText("down")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -5,6 +5,7 @@ import type { AgentPort, ModState, ModStatus, ModsSettings, ModsSettingsEdit } f
|
||||
import { HttpError } from "../port/port";
|
||||
import { Group } from "./Group";
|
||||
import { Switch } from "./Switch";
|
||||
import { MODS_SHOWN } from "./modsgate";
|
||||
import "../styles/mods.css";
|
||||
|
||||
const BUDGET_MAX = 55000;
|
||||
@@ -22,7 +23,11 @@ const STATE: Record<ModState, { mark: string; word: string }> = {
|
||||
* their own with the user's full trust. The switch is the only authorisation to
|
||||
* run any of them, so it says what that means in plain words, and a client that
|
||||
* may not edit it sees it read-only rather than failing on a click. */
|
||||
export function ModHost({ port }: { port: AgentPort }) {
|
||||
export function ModHost({ port, shown = MODS_SHOWN }: { port: AgentPort; shown?: boolean }) {
|
||||
return shown ? <ModHostBlock port={port} /> : null;
|
||||
}
|
||||
|
||||
function ModHostBlock({ port }: { port: AgentPort }) {
|
||||
const [settings, setSettings] = useState<ModsSettings | null>(null);
|
||||
const [mods, setMods] = useState<ModStatus[] | null>(null);
|
||||
const [draft, setDraft] = useState("");
|
||||
|
||||
@@ -10,7 +10,7 @@ interface Props {
|
||||
export function SettingsHeading({ section, value, danger }: Props) {
|
||||
const group = NAV.find(([, items]) => items.some(([id]) => id === section));
|
||||
const name = group?.[1].find(([id]) => id === section)?.[1] ?? section;
|
||||
const count = SETTINGS.filter((entry) => entry.section === section).length;
|
||||
const count = SETTINGS.filter((entry) => entry.section === section && !entry.hidden).length;
|
||||
|
||||
return (
|
||||
<header className="prefs-intro">
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
/** The mod host's settings stay out of sight, and out of settings search, until
|
||||
* the receipt marker and the protocol ruling land (#13694); the kernel refuses
|
||||
* to turn it on until then. One line to flip. */
|
||||
export const MODS_SHOWN = false;
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { MODS_SHOWN } from "./modsgate";
|
||||
import { t } from "../i18n";
|
||||
|
||||
/** The settings table of contents: which sections exist, what each is called,
|
||||
@@ -185,6 +186,8 @@ export interface SettingEntry {
|
||||
* which is exactly what this replaces. */
|
||||
scope: SettingScope;
|
||||
apply: ApplySemantics;
|
||||
/** Drawn by nothing yet: left out of search and of the section's count. */
|
||||
hidden?: boolean;
|
||||
}
|
||||
|
||||
// One row per block the settings screen renders, checked both ways against
|
||||
@@ -200,7 +203,7 @@ export const SETTINGS: SettingEntry[] = [
|
||||
{ section: "session", anchor: "receipt", title: "交付验收卡片", scope: "machine", apply: "immediate", keywords: ["回执", "验收", "验证", "未验证", "receipt"] },
|
||||
// Written to the user file only, and read by the running turn at its next round.
|
||||
{ section: "session", anchor: "ask-timeout", title: "提问等待超时", scope: "machine", apply: "immediate", keywords: ["ask", "跳过", "无人回答", "长任务", "超时", "自动继续"] },
|
||||
{ section: "ext", anchor: "mods", title: "Claude Code mod", scope: "machine", apply: "on-open", keywords: ["mod", "钩子", "hook", "Claude Code", "完全信任", "插件", "扩展"] },
|
||||
{ section: "ext", anchor: "mods", title: "Claude Code mod", scope: "machine", apply: "on-open", hidden: !MODS_SHOWN, keywords: ["mod", "钩子", "hook", "Claude Code", "完全信任", "插件", "扩展"] },
|
||||
{ section: "session", anchor: "progress-watch", title: "长时间无进展", scope: "machine", apply: "immediate", keywords: ["暂停", "无进展", "空转", "卡住", "循环", "轮次", "token"] },
|
||||
{ section: "session", anchor: "changed-files", title: "文件改动", scope: "machine", apply: "immediate", keywords: ["改动", "变更", "文件", "已更改", "药丸", "输入框"] },
|
||||
{ section: "session", anchor: "turn-narration", title: "回合间叙述", scope: "machine", apply: "immediate", keywords: ["叙述", "总结", "小轮", "工具之间", "详细", "简短", "思路", "监控", "narration"] },
|
||||
|
||||
@@ -11,7 +11,7 @@ export function useSettingsSearch(query: string, shown: (id: Section) => boolean
|
||||
return useMemo(() => {
|
||||
const q = query.trim().toLowerCase();
|
||||
if (!q) return null;
|
||||
return SETTINGS.filter((e) => shown(e.section) && (e.anchor !== "graphics" || shell !== null) && settingMatches(e, q));
|
||||
return SETTINGS.filter((e) => !e.hidden && shown(e.section) && (e.anchor !== "graphics" || shell !== null) && settingMatches(e, q));
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [query, shownKey, shell]);
|
||||
}
|
||||
@@ -1,2 +1,2 @@
|
||||
- 新增:设置里可以打开「运行 Claude Code mod」,列出每个 mod 的状态与原因;安装预览写明完全信任与静态清单;mod 可在输入框上方画一条带。关联 #13694。
|
||||
- Added: Settings can turn on Run Claude Code mods and lists each mod's state and why; the install preview states full trust and a static listing; a mod can draw a band above the prompt. Refs #13694.
|
||||
- 新增:mod 的安装预览写明完全信任与静态清单;mod 可在输入框上方画一条带,它的提问会写明来源。关联 #13694。
|
||||
- Added: A mod's install preview states full trust and a static listing; a mod can draw a band above the prompt, and its questions say whose they are. Refs #13694.
|
||||
Reference in new issue
Block a user