From fd41bf51ced2f190b06cf2d0dd4bfd9f3cdcfaaf Mon Sep 17 00:00:00 2001 From: esengine <359807859@qq.com> Date: Sat, 17 Oct 2026 21:28:29 -0700 Subject: [PATCH] test(desktop): the mod UI guard, release note and the strip's focus-ring room --- .github/workflows/studio.yml | 1 + desktop/frontend-next/perf/mod-ui.mjs | 100 ++++++++++++++++++ desktop/frontend-next/src/port/mock_ext.ts | 2 + desktop/frontend-next/src/port/mock_mods.ts | 24 +++++ desktop/frontend-next/src/styles/app.css | 2 +- .../studio/unreleased/13694-mod-ui.md | 2 + 6 files changed, 130 insertions(+), 1 deletion(-) create mode 100644 desktop/frontend-next/perf/mod-ui.mjs create mode 100644 release-notes/studio/unreleased/13694-mod-ui.md diff --git a/.github/workflows/studio.yml b/.github/workflows/studio.yml index b790d80494..90edfd2e2c 100644 --- a/.github/workflows/studio.yml +++ b/.github/workflows/studio.yml @@ -144,6 +144,7 @@ jobs: node perf/jump-ends.mjs node perf/modelrows.mjs node perf/explorersize.mjs + node perf/mod-ui.mjs node perf/codeeditor-size.mjs node perf/action-icons.mjs node perf/rowhit.mjs diff --git a/desktop/frontend-next/perf/mod-ui.mjs b/desktop/frontend-next/perf/mod-ui.mjs new file mode 100644 index 0000000000..f191464691 --- /dev/null +++ b/desktop/frontend-next/perf/mod-ui.mjs @@ -0,0 +1,100 @@ +// Claude Code mods in the window: the band a mod draws above the prompt, a question a mod asks, the settings +// block (switch, time limit, each mod's state by code) and the install preview's full-trust listing. +import { chromium } from "playwright"; + +const BASE = process.env.PERF_URL ?? "http://localhost:4399/perf.html"; +const shotDir = process.env.SHOTS; +const fails = []; +const check = (name, ok, detail = "") => { + console.log(`${ok ? " ok" : "FAIL"} ${name}${detail ? " — " + detail : ""}`); + if (!ok) fails.push(name); +}; + +const band = { kind: "extension_surface", extension: { pluginId: "statusline-clock", surfaceId: "mod-above-prompt", kind: "view", view: { slot: "above_prompt", body: [ + { kind: "row", children: [{ kind: "pip", tone: "ok" }, { kind: "text", value: "statusline-clock", tone: "strong" }, { kind: "text", value: "12:30 · 今天还剩 3 个任务", tone: "dim" }, { kind: "button", label: "稍后提醒", actionId: "snooze:5" }] }, +] } } }; +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" }] } } }; + +const browser = await chromium.launch(); +for (const scheme of ["light", "dark"]) { + for (const width of [1280, 420]) { + const tag = `${scheme} ${width}px`; + const page = await browser.newPage({ viewport: { width, height: 1300 }, locale: "zh-CN", colorScheme: scheme, reducedMotion: "reduce" }); + page.on("pageerror", (e) => fails.push("页面异常: " + e.message)); + 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(); + const at = await page.evaluate(() => { + const s = document.querySelector(".slotted").getBoundingClientRect(); + const c = document.querySelector('[aria-label="任务输入"]').getBoundingClientRect(); + return { above: s.bottom <= c.top + 4, spill: s.right > innerWidth + 1 || document.documentElement.scrollWidth > innerWidth + 1 }; + }); + check(`${tag}: the band stands above the text the person types in`, at.above); + check(`${tag}: the band does not push the page sideways`, !at.spill); + await page.getByRole("button", { name: "稍后提醒" }).focus(); + check(`${tag}: its button can be reached from the keyboard`, await page.evaluate(() => document.activeElement?.textContent === "稍后提醒")); + const ring = await page.evaluate(() => { + const b = document.activeElement, rail = document.querySelector(".slotrail"); + const cs = getComputedStyle(b); + const room = Math.min(b.getBoundingClientRect().left - rail.getBoundingClientRect().left, rail.getBoundingClientRect().right - b.getBoundingClientRect().right); + return { ring: parseFloat(cs.outlineWidth) + Math.max(0, parseFloat(cs.outlineOffset)), room }; + }); + check(`${tag}: the focus ring of the band's button is not cut off by the strip it sits in`, ring.room >= ring.ring - 0.5, `${ring.room}px room for a ${ring.ring}px ring`); + 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'); + 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 ?? "" }; + }); + check(`${tag}: a mod's question says whose it is and that answering approves nothing`, card.note.includes("git-guard") && card.note.includes("不会批准任何工具调用或权限") && card.tag.includes("git-guard"), card.note); + if (shotDir && width === 1280) await page.locator('.call[data-k="extension"]').last().screenshot({ path: `${shotDir}/${scheme}-ask.png` }); + + 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` }); + + if (width === 1280) { + await page.locator('[data-action="extensions.add"]').first().click(); + const source = page.locator("textarea, input").filter({ hasNot: page.locator("[aria-label]") }).last(); + await page.locator('textarea[placeholder*="github.com"], input[placeholder*="github.com"]').first().fill("https://github.com/acme/claude-code mod git-guard"); + await page.locator('[data-action="extensions.inspect"]').first().click(); + await page.waitForSelector(".modpreview", { timeout: 10000 }); + const prev = await page.evaluate(() => { + const m = document.querySelector(".modpreview"); + const text = m.textContent; + return { trust: text.includes("完全信任"), static: text.includes("不是保证"), imports: text.includes("看不见"), unserved: text.includes("本应用不会触发的事件"), hash: !!m.querySelector("[title]") }; + }); + check(`${tag}: the install preview states full trust, that it is a static listing, hidden imports and unserved events`, prev.trust && prev.static && prev.imports && prev.unserved && prev.hash, JSON.stringify(prev)); + if (shotDir) await page.locator(".cand").first().screenshot({ path: `${shotDir}/${scheme}-preview.png` }); + } + await page.close(); + } +} +await browser.close(); +if (fails.length) { + console.error(`\n${fails.length} failed: ${fails.join("; ")}`); + process.exit(1); +} +console.log("\nall passed"); diff --git a/desktop/frontend-next/src/port/mock_ext.ts b/desktop/frontend-next/src/port/mock_ext.ts index e581c7cb0b..6acae986f8 100644 --- a/desktop/frontend-next/src/port/mock_ext.ts +++ b/desktop/frontend-next/src/port/mock_ext.ts @@ -1,4 +1,5 @@ import { download } from "./download"; +import { mockModPlan } from "./mock_mods"; import type { PluginExport, PluginInstallRequest, PluginPackage, PluginPlan, ScopeLayer, SkillCatalog, SkillEntry, SkillUpdatePlan } from "./port"; import { MockMarket } from "./mock_market"; @@ -102,6 +103,7 @@ export class MockExtensions extends MockMarket { // a hook and connects a server is what the confirmation page exists for. async planPlugin(req: PluginInstallRequest): Promise { const source = req.source; + if (/\bmod\b/i.test(source)) return mockModPlan(source); return { ok: true, status: "planned", applied: false, source, planId: "high:sha256:mock", actions: [ diff --git a/desktop/frontend-next/src/port/mock_mods.ts b/desktop/frontend-next/src/port/mock_mods.ts index 6399b955b6..6c39875f8e 100644 --- a/desktop/frontend-next/src/port/mock_mods.ts +++ b/desktop/frontend-next/src/port/mock_mods.ts @@ -1,4 +1,5 @@ import type { ModStatus } from "./boundary"; +import type { ModPreview, PluginPlan } from "./plugin"; const RUNTIME = { kind: "electron", path: "/Applications/Reasonix Studio.app/Contents/MacOS/Reasonix Studio", version: "v22.12.0" }; @@ -14,3 +15,26 @@ export function mockMods(enabled: boolean): ModStatus[] { { plugin: names[3], state: "inactive", code: "mod.runtime_missing", restarts: 0, events: ["turn.complete"], unserved: [] }, ]; } + +export const MOCK_MOD_PREVIEW: ModPreview = { + plugin: "git-guard", module: "hooks/index.js", sha256: "9f2c41b07ad35e8c61f0aa42d9be3377c0e1145f8a6d2b9c4e7710d3a5b8c6e2", listed: true, + events: [{ name: "tool.check" }, { name: "tool.after" }, { name: "turn.complete" }, { name: "x", dynamic: true }], + api: [{ namespace: "process", method: "run" }, { namespace: "fs", method: "readFile" }, { namespace: "ui", method: "toast" }], + imports: [{ spec: "./lib/rules.js", kind: "static" }, { spec: "minimatch", kind: "require" }], + findings: [{ kind: "computed_member" }], + unserved: ["tool.rewrite"], +}; + +/** The plan a source naming a mod gets: one package whose action carries the + * preview of its hooks module. */ +export function mockModPlan(source: string): PluginPlan { + return { + ok: true, status: "planned", applied: false, source, planId: "high:sha256:mockmod", + actions: [{ + kind: "plugin", action: "install_plugin_package", status: "planned", riskLevel: "high", name: "git-guard", version: "1.2.0", source, + target: "~/.reasonix/plugins/git-guard", manifestKind: "claude-code", skills: [], skillCount: 0, commandCount: 0, hookCount: 0, + riskReasons: ["FULL TRUST: ships a Claude Code mod whose code runs on this machine"], mod: MOCK_MOD_PREVIEW, + }], + next: "Review the plan, then install.", + }; +} diff --git a/desktop/frontend-next/src/styles/app.css b/desktop/frontend-next/src/styles/app.css index f4f3d615c9..fe27ded041 100644 --- a/desktop/frontend-next/src/styles/app.css +++ b/desktop/frontend-next/src/styles/app.css @@ -4124,7 +4124,7 @@ .slotrail { display: flex; align-items: center; gap: 14px; flex-wrap: wrap; - padding: 4px 2px 6px; max-height: 84px; overflow-y: auto; + padding: 4px 5px 6px; max-height: 84px; overflow-y: auto; } .slotrail .xview { flex-direction: row; align-items: center; gap: 8px } diff --git a/release-notes/studio/unreleased/13694-mod-ui.md b/release-notes/studio/unreleased/13694-mod-ui.md new file mode 100644 index 0000000000..f9e0201a6e --- /dev/null +++ b/release-notes/studio/unreleased/13694-mod-ui.md @@ -0,0 +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.