diff --git a/packages/app/e2e/snap/fixtures/permission-dock-fixture.tsx b/packages/app/e2e/snap/fixtures/permission-dock-fixture.tsx new file mode 100644 index 000000000..a3c13c3fb --- /dev/null +++ b/packages/app/e2e/snap/fixtures/permission-dock-fixture.tsx @@ -0,0 +1,59 @@ +import { render } from "solid-js/web" +import type { PermissionRequest } from "@opencode-ai/sdk/v2" +import { LanguageProvider } from "@/context/language" +import { type Platform, PlatformProvider } from "@/context/platform" +import { SessionPermissionContent } from "@/pages/session/composer/session-permission-dock" + +const platform: Platform = { + platform: "web", + openLink: () => {}, + restart: async () => {}, + back: () => {}, + forward: () => {}, + notify: async () => {}, +} + +function deleteRequest(input: { id: string; title: string }): PermissionRequest { + return { + id: `perm_${input.id}`, + sessionID: "ses_permission_snap", + permission: "automate_manage", + patterns: [input.id], + always: [], + metadata: { action: "delete", id: input.id, title: input.title }, + } +} + +const persistableRequest: PermissionRequest = { + id: "perm_bash_echo", + sessionID: "ses_permission_snap", + permission: "bash", + patterns: ["echo ok"], + always: ["echo ok"], + metadata: {}, +} + +function Block(props: { snap: string; request: PermissionRequest }) { + return ( +
+ {}} /> +
+ ) +} + +function PermissionDockFixture() { + return ( + + +
+ + +
+
+
+ ) +} + +export function mountPermissionDockFixture(root: HTMLElement) { + render(() => , root) +} diff --git a/packages/app/e2e/snap/permission-dock.snap.ts b/packages/app/e2e/snap/permission-dock.snap.ts new file mode 100644 index 000000000..54e6804ad --- /dev/null +++ b/packages/app/e2e/snap/permission-dock.snap.ts @@ -0,0 +1,57 @@ +import { expect, type Locator } from "@playwright/test" +import { fileURLToPath } from "node:url" +import { test } from "../fixtures" +import { composeGrid, snapOutputPath, type Shot } from "./_compose" + +test.use({ viewport: { width: 760, height: 420 }, deviceScaleFactor: 2 }) + +const fixturePath = fileURLToPath(new URL("./fixtures/permission-dock-fixture.tsx", import.meta.url)) + +async function captureBlock(name: string, block: Locator): Promise { + await expect(block).toBeVisible({ timeout: 30_000 }) + return { name, buf: await block.screenshot() } +} + +async function waitForThemeBoot(page: import("@playwright/test").Page): Promise { + await page.waitForFunction( + () => getComputedStyle(document.documentElement).getPropertyValue("--bg-base").trim().length > 0, + null, + { timeout: 30_000 }, + ) +} + +test("permission-dock", async ({ page }) => { + test.setTimeout(180_000) + + await page.goto("/") + await waitForThemeBoot(page) + await page.addStyleTag({ + content: + 'aside[aria-label="Development performance diagnostics"], [data-component="toast-region"] { display: none; }', + }) + await page.evaluate(async (path) => { + const mod = await import(path) + mod.mountPermissionDockFixture(document.body) + }, `/@fs/${fixturePath}`) + + const deleteOnce = page.locator('[data-snap="delete-once"]') + await expect(deleteOnce).toContainText('Delete automation "Daily repo brief" (aut_daily)', { timeout: 30_000 }) + await expect(deleteOnce.getByRole("button", { name: "Allow once" })).toBeVisible() + await expect(deleteOnce.getByRole("button", { name: "Deny" })).toBeVisible() + await expect(deleteOnce.getByRole("button", { name: "Allow always" })).toHaveCount(0) + + const persistable = page.locator('[data-snap="persistable"]') + await expect(persistable).toContainText("echo ok", { timeout: 30_000 }) + await expect(persistable.getByRole("button", { name: "Allow always" })).toBeVisible() + + const out = snapOutputPath("permission-dock") + await composeGrid( + [ + await captureBlock("delete-once", deleteOnce), + await captureBlock("persistable", persistable), + ], + out, + { cols: 1 }, + ) + process.stdout.write(`\n[snap] permission-dock grid -> ${out}\n\n`) +}) diff --git a/packages/app/src/i18n/en.ts b/packages/app/src/i18n/en.ts index 3682dd0ac..aff34dd7e 100644 --- a/packages/app/src/i18n/en.ts +++ b/packages/app/src/i18n/en.ts @@ -635,6 +635,7 @@ export const dict = { "ui.sessionReview.title.git": "Changes", "ui.sessionReview.title.branch": "Branch", "ui.sessionReview.title.lastTurn": "Last Turn", + "ui.permission.automateManageDelete": 'Delete automation "{{title}}" ({{id}})', "session.files.selectToOpen": "Select a file to open", "session.files.all": "All files", diff --git a/packages/app/src/i18n/zh.ts b/packages/app/src/i18n/zh.ts index ebf948155..e83baa9b5 100644 --- a/packages/app/src/i18n/zh.ts +++ b/packages/app/src/i18n/zh.ts @@ -596,6 +596,7 @@ export const dict = { "ui.sessionReview.title.git": "文件变更", "ui.sessionReview.title.branch": "分支变更", "ui.sessionReview.title.lastTurn": "上轮变更", + "ui.permission.automateManageDelete": "删除自动化「{{title}}」({{id}})", "session.files.selectToOpen": "选择要打开的文件", "session.files.all": "所有文件", "session.files.empty": "无文件", diff --git a/packages/app/src/pages/session/composer/session-permission-dock.test.ts b/packages/app/src/pages/session/composer/session-permission-dock.test.ts new file mode 100644 index 000000000..316876984 --- /dev/null +++ b/packages/app/src/pages/session/composer/session-permission-dock.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, test } from "bun:test" +import type { PermissionRequest } from "@opencode-ai/sdk/v2" +import { canPersistPermission, permissionMetadataLines } from "./session-permission-dock" + +const request = (always: string[]): PermissionRequest => + ({ + id: "perm_1", + sessionID: "ses_1", + permission: "automate_manage", + patterns: ["aut_123"], + always, + metadata: { action: "delete", id: "aut_123", title: "Daily repo brief" }, + }) as PermissionRequest + +describe("canPersistPermission", () => { + test("returns false when the request has no always patterns", () => { + expect(canPersistPermission(request([]))).toBe(false) + }) + + test("returns true when the request has at least one always pattern", () => { + expect(canPersistPermission(request(["*"]))).toBe(true) + }) +}) + +describe("permissionMetadataLines", () => { + test("renders automate_manage delete metadata as a readable confirmation line", () => { + const t = (key: string | number, params?: Record) => + `${key}:${params?.title}:${params?.id}` + + expect(permissionMetadataLines(request([]), t)).toEqual([ + "ui.permission.automateManageDelete:Daily repo brief:aut_123", + ]) + }) +}) diff --git a/packages/app/src/pages/session/composer/session-permission-dock.tsx b/packages/app/src/pages/session/composer/session-permission-dock.tsx index 01e15cd1a..023f33b00 100644 --- a/packages/app/src/pages/session/composer/session-permission-dock.tsx +++ b/packages/app/src/pages/session/composer/session-permission-dock.tsx @@ -4,6 +4,25 @@ import { Button } from "@opencode-ai/ui/button" import { Icon } from "@opencode-ai/ui/icon" import { useLanguage } from "@/context/language" +type Translate = ReturnType["t"] + +export function canPersistPermission(request: Pick) { + return request.always.length > 0 +} + +export function permissionMetadataLines(request: PermissionRequest, t: Translate): string[] { + const metadata = request.metadata ?? {} + if ( + request.permission === "automate_manage" && + metadata["action"] === "delete" && + typeof metadata["title"] === "string" && + typeof metadata["id"] === "string" + ) { + return [t("ui.permission.automateManageDelete", { title: metadata["title"], id: metadata["id"] })] + } + return [] +} + export function SessionPermissionContent(props: { request: PermissionRequest responding: boolean @@ -17,6 +36,7 @@ export function SessionPermissionContent(props: { if (value === key) return "" return value } + const metadataLines = () => permissionMetadataLines(props.request, language.t) return (
@@ -38,6 +58,15 @@ export function SessionPermissionContent(props: {
+ + {(line) => ( +
+
+ )} +
+ 0}>