From e9a7c7114184d0092c114ce7a7d9446cf0d366cc Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Thu, 26 Feb 2026 20:36:10 -0600 Subject: [PATCH] fix(app): permission notifications --- .../context/permission-auto-respond.test.ts | 42 +++++++++++++++++++ .../src/context/permission-auto-respond.ts | 36 ++++++++++++++++ packages/app/src/context/permission.tsx | 18 ++++---- .../composer/session-composer-state.test.ts | 22 ++++++++++ .../composer/session-composer-state.ts | 12 +++++- .../session/composer/session-request-tree.ts | 17 +++++--- 6 files changed, 131 insertions(+), 16 deletions(-) create mode 100644 packages/app/src/context/permission-auto-respond.test.ts create mode 100644 packages/app/src/context/permission-auto-respond.ts diff --git a/packages/app/src/context/permission-auto-respond.test.ts b/packages/app/src/context/permission-auto-respond.test.ts new file mode 100644 index 000000000..1fa1ff3de --- /dev/null +++ b/packages/app/src/context/permission-auto-respond.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, test } from "bun:test" +import type { PermissionRequest, Session } from "@opencode-ai/sdk/v2/client" +import { base64Encode } from "@opencode-ai/util/encode" +import { autoRespondsPermission } from "./permission-auto-respond" + +const session = (input: { id: string; parentID?: string }) => + ({ + id: input.id, + parentID: input.parentID, + }) as Session + +const permission = (sessionID: string) => + ({ + sessionID, + }) as Pick + +describe("autoRespondsPermission", () => { + test("uses a parent session's directory-scoped auto-accept", () => { + const directory = "/tmp/project" + const sessions = [session({ id: "root" }), session({ id: "child", parentID: "root" })] + const autoAccept = { + [`${base64Encode(directory)}/root`]: true, + } + + expect(autoRespondsPermission(autoAccept, sessions, permission("child"), directory)).toBe(true) + }) + + test("uses a parent session's legacy auto-accept key", () => { + const sessions = [session({ id: "root" }), session({ id: "child", parentID: "root" })] + + expect(autoRespondsPermission({ root: true }, sessions, permission("child"), "/tmp/project")).toBe(true) + }) + + test("ignores auto-accept from unrelated sessions", () => { + const sessions = [session({ id: "root" }), session({ id: "child", parentID: "root" }), session({ id: "other" })] + const autoAccept = { + other: true, + } + + expect(autoRespondsPermission(autoAccept, sessions, permission("child"), "/tmp/project")).toBe(false) + }) +}) diff --git a/packages/app/src/context/permission-auto-respond.ts b/packages/app/src/context/permission-auto-respond.ts new file mode 100644 index 000000000..e45e5f51c --- /dev/null +++ b/packages/app/src/context/permission-auto-respond.ts @@ -0,0 +1,36 @@ +import { base64Encode } from "@opencode-ai/util/encode" + +export function acceptKey(sessionID: string, directory?: string) { + if (!directory) return sessionID + return `${base64Encode(directory)}/${sessionID}` +} + +function sessionLineage(session: { id: string; parentID?: string }[], sessionID: string) { + const parent = session.reduce((acc, item) => { + if (item.parentID) acc.set(item.id, item.parentID) + return acc + }, new Map()) + const seen = new Set([sessionID]) + const ids = [sessionID] + + for (const id of ids) { + const parentID = parent.get(id) + if (!parentID || seen.has(parentID)) continue + seen.add(parentID) + ids.push(parentID) + } + + return ids +} + +export function autoRespondsPermission( + autoAccept: Record, + session: { id: string; parentID?: string }[], + permission: { sessionID: string }, + directory?: string, +) { + return sessionLineage(session, permission.sessionID).some((id) => { + const key = acceptKey(id, directory) + return autoAccept[key] ?? autoAccept[id] ?? false + }) +} diff --git a/packages/app/src/context/permission.tsx b/packages/app/src/context/permission.tsx index ccfda5e69..d63d4d568 100644 --- a/packages/app/src/context/permission.tsx +++ b/packages/app/src/context/permission.tsx @@ -6,8 +6,8 @@ import { Persist, persisted } from "@/utils/persist" import { useGlobalSDK } from "@/context/global-sdk" import { useGlobalSync } from "./global-sync" import { useParams } from "@solidjs/router" -import { base64Encode } from "@opencode-ai/util/encode" import { decode64 } from "@/utils/base64" +import { acceptKey, autoRespondsPermission } from "./permission-auto-respond" type PermissionRespondFn = (input: { sessionID: string @@ -114,16 +114,16 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple }) } - function acceptKey(sessionID: string, directory?: string) { - if (!directory) return sessionID - return `${base64Encode(directory)}/${sessionID}` - } - function isAutoAccepting(sessionID: string, directory?: string) { const key = acceptKey(sessionID, directory) return store.autoAccept[key] ?? store.autoAccept[sessionID] ?? false } + function shouldAutoRespond(permission: PermissionRequest, directory?: string) { + const session = directory ? globalSync.child(directory, { bootstrap: false })[0].session : [] + return autoRespondsPermission(store.autoAccept, session, permission, directory) + } + function bumpEnableVersion(sessionID: string, directory?: string) { const key = acceptKey(sessionID, directory) const next = (enableVersion.get(key) ?? 0) + 1 @@ -136,7 +136,7 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple if (event?.type !== "permission.asked") return const perm = event.properties - if (!isAutoAccepting(perm.sessionID, e.name)) return + if (!shouldAutoRespond(perm, e.name)) return respondOnce(perm, e.name) }) @@ -159,7 +159,7 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple if (!isAutoAccepting(sessionID, directory)) return for (const perm of x.data ?? []) { if (!perm?.id) continue - if (perm.sessionID !== sessionID) continue + if (!shouldAutoRespond(perm, directory)) continue respondOnce(perm, directory) } }) @@ -181,7 +181,7 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple ready, respond, autoResponds(permission: PermissionRequest, directory?: string) { - return isAutoAccepting(permission.sessionID, directory) + return shouldAutoRespond(permission, directory) }, isAutoAccepting, toggleAutoAccept(sessionID: string, directory: string) { diff --git a/packages/app/src/pages/session/composer/session-composer-state.test.ts b/packages/app/src/pages/session/composer/session-composer-state.test.ts index 7b6029eb3..934d3152a 100644 --- a/packages/app/src/pages/session/composer/session-composer-state.test.ts +++ b/packages/app/src/pages/session/composer/session-composer-state.test.ts @@ -55,6 +55,28 @@ describe("sessionPermissionRequest", () => { expect(sessionPermissionRequest(sessions, permissions, "root")).toBeUndefined() }) + + test("skips filtered permissions in the current tree", () => { + const sessions = [session({ id: "root" }), session({ id: "child", parentID: "root" })] + const permissions = { + root: [permission("perm-root", "root")], + child: [permission("perm-child", "child")], + } + + expect(sessionPermissionRequest(sessions, permissions, "root", (item) => item.id !== "perm-root"))?.toMatchObject({ + id: "perm-child", + }) + }) + + test("returns undefined when all tree permissions are filtered out", () => { + const sessions = [session({ id: "root" }), session({ id: "child", parentID: "root" })] + const permissions = { + root: [permission("perm-root", "root")], + child: [permission("perm-child", "child")], + } + + expect(sessionPermissionRequest(sessions, permissions, "root", () => false)).toBeUndefined() + }) }) describe("sessionQuestionRequest", () => { diff --git a/packages/app/src/pages/session/composer/session-composer-state.ts b/packages/app/src/pages/session/composer/session-composer-state.ts index ed65867ef..201846177 100644 --- a/packages/app/src/pages/session/composer/session-composer-state.ts +++ b/packages/app/src/pages/session/composer/session-composer-state.ts @@ -5,15 +5,20 @@ import { useParams } from "@solidjs/router" import { showToast } from "@opencode-ai/ui/toast" import { useGlobalSync } from "@/context/global-sync" import { useLanguage } from "@/context/language" +import { usePermission } from "@/context/permission" import { useSDK } from "@/context/sdk" import { useSync } from "@/context/sync" import { sessionPermissionRequest, sessionQuestionRequest } from "./session-request-tree" export function createSessionComposerBlocked() { const params = useParams() + const permission = usePermission() + const sdk = useSDK() const sync = useSync() const permissionRequest = createMemo(() => - sessionPermissionRequest(sync.data.session, sync.data.permission, params.id), + sessionPermissionRequest(sync.data.session, sync.data.permission, params.id, (item) => { + return !permission.autoResponds(item, sdk.directory) + }), ) const questionRequest = createMemo(() => sessionQuestionRequest(sync.data.session, sync.data.question, params.id)) @@ -30,13 +35,16 @@ export function createSessionComposerState() { const sync = useSync() const globalSync = useGlobalSync() const language = useLanguage() + const permission = usePermission() const questionRequest = createMemo((): QuestionRequest | undefined => { return sessionQuestionRequest(sync.data.session, sync.data.question, params.id) }) const permissionRequest = createMemo((): PermissionRequest | undefined => { - return sessionPermissionRequest(sync.data.session, sync.data.permission, params.id) + return sessionPermissionRequest(sync.data.session, sync.data.permission, params.id, (item) => { + return !permission.autoResponds(item, sdk.directory) + }) }) const blocked = createMemo(() => { diff --git a/packages/app/src/pages/session/composer/session-request-tree.ts b/packages/app/src/pages/session/composer/session-request-tree.ts index f9673e254..03872c091 100644 --- a/packages/app/src/pages/session/composer/session-request-tree.ts +++ b/packages/app/src/pages/session/composer/session-request-tree.ts @@ -1,6 +1,11 @@ import type { PermissionRequest, QuestionRequest, Session } from "@opencode-ai/sdk/v2/client" -function sessionTreeRequest(session: Session[], request: Record, sessionID?: string) { +function sessionTreeRequest( + session: Session[], + request: Record, + sessionID?: string, + include: (item: T) => boolean = () => true, +) { if (!sessionID) return const map = session.reduce((acc, item) => { @@ -23,23 +28,25 @@ function sessionTreeRequest(session: Session[], request: Record !!request[id]?.[0]) + const id = ids.find((id) => request[id]?.some(include)) if (!id) return - return request[id]?.[0] + return request[id]?.find(include) } export function sessionPermissionRequest( session: Session[], request: Record, sessionID?: string, + include?: (item: PermissionRequest) => boolean, ) { - return sessionTreeRequest(session, request, sessionID) + return sessionTreeRequest(session, request, sessionID, include) } export function sessionQuestionRequest( session: Session[], request: Record, sessionID?: string, + include?: (item: QuestionRequest) => boolean, ) { - return sessionTreeRequest(session, request, sessionID) + return sessionTreeRequest(session, request, sessionID, include) }