Bläddra i källkod

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

Kit Langton 1 vecka sedan
förälder
incheckning
047d434aa2

+ 20 - 8
packages/tui/src/context/data.tsx

@@ -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) {

+ 1 - 1
packages/tui/src/routes/session/index.tsx

@@ -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",

+ 13 - 24
packages/tui/src/routes/session/permission.tsx

@@ -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")
               }}
             />
           )

+ 58 - 0
packages/tui/test/cli/tui/data.test.tsx

@@ -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) => {