fix(tui): dismiss stale permission prompts (#40960)

This commit is contained in:
Kit Langton 2026-08-06 21:43:44 -04:00 committed by GitHub
parent d7651519f3
commit 047d434aa2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 92 additions and 33 deletions

View file

@ -15,6 +15,7 @@ import type {
ModelInfo,
PermissionSavedInfo,
PermissionRequest,
PermissionReplyInput,
Project,
ProviderInfo,
ReferenceInfo,
@ -31,6 +32,7 @@ import type {
OpenCodeEvent,
WebSearchProvider,
} from "@opencode-ai/client"
import { isPermissionNotFoundError } from "@opencode-ai/client"
import type { Plugin } from "@opencode-ai/plugin/tui"
import { createStore, produce, reconcile } from "solid-js/store"
import { createSimpleContext } from "./helper"
@ -176,6 +178,17 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
)
}
function removePermission(sessionID: string, requestID: string) {
const requests = store.session.permission[sessionID]
if (!requests?.some((request) => request.id === requestID)) return
setStore(
"session",
"permission",
sessionID,
requests.filter((request) => request.id !== requestID),
)
}
const message = {
update(sessionID: string, fn: (messages: SessionMessageInfo[], index: Map<string, number>) => void) {
setStore(
@ -840,14 +853,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
])
break
case "permission.replied":
setStore(
"session",
"permission",
event.data.sessionID,
(store.session.permission[event.data.sessionID] ?? []).filter(
(request) => request.id !== event.data.requestID,
),
)
removePermission(event.data.sessionID, event.data.requestID)
break
case "form.created":
if (store.session.form[event.data.form.sessionID]?.some((form) => form.id === event.data.form.id)) break
@ -1036,6 +1042,12 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
invalidate(sessionID: string) {
sync.invalidate(`session.permission:${sessionID}`)
},
async reply(input: PermissionReplyInput) {
await client.api.permission.reply(input).catch((error: unknown) => {
if (!isPermissionNotFoundError(error)) throw error
})
removePermission(input.sessionID, input.requestID)
},
},
form: {
list(sessionID: string, ref?: LocationRef) {

View file

@ -227,7 +227,7 @@ export function Session() {
permissions().forEach((request) => {
if (autoApproved.has(request.id)) return
autoApproved.add(request.id)
void client.api.permission
void data.session.permission
.reply({
sessionID: request.sessionID,
reply: "once",

View file

@ -3,8 +3,7 @@ import { createMemo, For, Match, Show, Switch } from "solid-js"
import { Portal, useRenderer, useTerminalDimensions, type JSX } from "@opentui/solid"
import type { TextareaRenderable } from "@opentui/core"
import { useTheme, useThemes } from "../../context/theme"
import type { PermissionRequest } from "@opencode-ai/client"
import { useClient } from "../../context/client"
import type { PermissionReply, PermissionRequest } from "@opencode-ai/client"
import { SplitBorder } from "../../ui/border"
import { useData } from "../../context/data"
import { filetype } from "../../util/filetype"
@ -15,6 +14,7 @@ import { Keymap } from "../../context/keymap"
import { usePathFormatter } from "../../context/path-format"
import { SimulationSemantics } from "../../simulation/semantics"
import { PatchDiff } from "../../component/patch-diff"
import { useToast } from "../../ui/toast"
type PermissionStage = "permission" | "always" | "reject"
@ -110,8 +110,8 @@ function EditBody(props: { file?: string; diff?: string; patch?: string }) {
}
export function PermissionPrompt(props: { request: PermissionRequest; directory?: string }) {
const client = useClient()
const data = useData()
const toast = useToast()
const [store, setStore] = createStore({
stage: "permission" as PermissionStage,
})
@ -132,6 +132,12 @@ export function PermissionPrompt(props: { request: PermissionRequest; directory?
const theme = useTheme()
function reply(value: PermissionReply, message?: string) {
void data.session.permission
.reply({ sessionID: props.request.sessionID, requestID: props.request.id, reply: value, message })
.catch((error: unknown) => toast.error(error))
}
return (
<Switch>
<Match when={store.stage === "always"}>
@ -151,11 +157,7 @@ export function PermissionPrompt(props: { request: PermissionRequest; directory?
onSelect={(option) => {
setStore("stage", "permission")
if (option === "cancel") return
void client.api.permission.reply({
sessionID: props.request.sessionID,
reply: "always",
requestID: props.request.id,
})
reply("always")
}}
/>
</Match>
@ -164,12 +166,7 @@ export function PermissionPrompt(props: { request: PermissionRequest; directory?
action={props.request.action}
instance={props.request.id}
onConfirm={(message) => {
void client.api.permission.reply({
sessionID: props.request.sessionID,
reply: "reject",
requestID: props.request.id,
message: message || undefined,
})
reply("reject", message || undefined)
}}
onCancel={() => {
setStore("stage", "permission")
@ -265,18 +262,10 @@ export function PermissionPrompt(props: { request: PermissionRequest; directory?
setStore("stage", "reject")
return
}
void client.api.permission.reply({
sessionID: props.request.sessionID,
reply: "reject",
requestID: props.request.id,
})
reply("reject")
return
}
void client.api.permission.reply({
sessionID: props.request.sessionID,
reply: "once",
requestID: props.request.id,
})
reply("once")
}}
/>
)

View file

@ -2067,6 +2067,64 @@ test("reconciles active session permissions when the event stream reconnects", a
}
})
test("dismisses a permission that expired before its reply", async () => {
const events = createEventStream()
const request = { id: "per_stale", sessionID: "ses_active", action: "read", resources: ["old.txt"] }
let replies = 0
const calls = createFetch((url, init) => {
if (url.pathname === "/api/session/ses_active/permission/per_stale/reply" && init.method === "POST") {
replies++
return json(
{
_tag: "PermissionNotFoundError",
requestID: request.id,
message: `Permission request not found: ${request.id}`,
},
{ status: 404 },
)
}
}, events)
let data!: ReturnType<typeof useData>
function Probe() {
data = useData()
return <box />
}
const app = await testRender(() => (
<TestTuiContexts>
<ClientProvider api={createApi(calls.fetch)}>
<ProjectProvider>
<DataProvider>
<Probe />
</DataProvider>
</ProjectProvider>
</ClientProvider>
</TestTuiContexts>
))
try {
emitEvent(events, {
id: "evt_permission_asked_stale",
created: 0,
type: "permission.asked",
data: request,
})
await wait(() => data.session.permission.list(request.sessionID)?.length === 1)
await data.session.permission.reply({
sessionID: request.sessionID,
requestID: request.id,
reply: "once",
})
expect(replies).toBe(1)
expect(data.session.permission.list(request.sessionID)).toEqual([])
} finally {
app.renderer.destroy()
}
})
test("adds, dismisses, and refreshes form requests", async () => {
const events = createEventStream()
const calls = createFetch((url) => {