Procházet zdrojové kódy

fix(app): permissions and questions from child sessions (#15105)

Co-authored-by: adamelmore <[email protected]>
Adam před 1 měsícem
rodič
revize
b8337cddc4

+ 246 - 52
packages/app/e2e/session/session-composer-dock.spec.ts

@@ -1,5 +1,5 @@
 import { test, expect } from "../fixtures"
-import { clearSessionDockSeed, seedSessionPermission, seedSessionQuestion, seedSessionTodos } from "../actions"
+import { clearSessionDockSeed, seedSessionQuestion, seedSessionTodos } from "../actions"
 import {
   permissionDockSelector,
   promptSelector,
@@ -11,11 +11,23 @@ import {
 } from "../selectors"
 
 type Sdk = Parameters<typeof clearSessionDockSeed>[0]
-
-async function withDockSession<T>(sdk: Sdk, title: string, fn: (session: { id: string; title: string }) => Promise<T>) {
-  const session = await sdk.session.create({ title }).then((r) => r.data)
+type PermissionRule = { permission: string; pattern: string; action: "allow" | "deny" | "ask" }
+
+async function withDockSession<T>(
+  sdk: Sdk,
+  title: string,
+  fn: (session: { id: string; title: string }) => Promise<T>,
+  opts?: { permission?: PermissionRule[] },
+) {
+  const session = await sdk.session
+    .create(opts?.permission ? { title, permission: opts.permission } : { title })
+    .then((r) => r.data)
   if (!session?.id) throw new Error("Session create did not return an id")
-  return fn(session)
+  try {
+    return await fn(session)
+  } finally {
+    await sdk.session.delete({ sessionID: session.id }).catch(() => undefined)
+  }
 }
 
 test.setTimeout(120_000)
@@ -28,6 +40,85 @@ async function withDockSeed<T>(sdk: Sdk, sessionID: string, fn: () => Promise<T>
   }
 }
 
+async function clearPermissionDock(page: any, label: RegExp) {
+  const dock = page.locator(permissionDockSelector)
+  for (let i = 0; i < 3; i++) {
+    const count = await dock.count()
+    if (count === 0) return
+    await dock.getByRole("button", { name: label }).click()
+    await page.waitForTimeout(150)
+  }
+}
+
+async function withMockPermission<T>(
+  page: any,
+  request: {
+    id: string
+    sessionID: string
+    permission: string
+    patterns: string[]
+    metadata?: Record<string, unknown>
+    always?: string[]
+  },
+  opts: { child?: any } | undefined,
+  fn: () => Promise<T>,
+) {
+  let pending = [
+    {
+      ...request,
+      always: request.always ?? ["*"],
+      metadata: request.metadata ?? {},
+    },
+  ]
+
+  const list = async (route: any) => {
+    await route.fulfill({
+      status: 200,
+      contentType: "application/json",
+      body: JSON.stringify(pending),
+    })
+  }
+
+  const reply = async (route: any) => {
+    const url = new URL(route.request().url())
+    const id = url.pathname.split("/").pop()
+    pending = pending.filter((item) => item.id !== id)
+    await route.fulfill({
+      status: 200,
+      contentType: "application/json",
+      body: JSON.stringify(true),
+    })
+  }
+
+  await page.route("**/permission", list)
+  await page.route("**/session/*/permissions/*", reply)
+
+  const sessionList = opts?.child
+    ? async (route: any) => {
+        const res = await route.fetch()
+        const json = await res.json()
+        const list = Array.isArray(json) ? json : Array.isArray(json?.data) ? json.data : undefined
+        if (Array.isArray(list) && !list.some((item) => item?.id === opts.child?.id)) list.push(opts.child)
+        await route.fulfill({
+          status: res.status(),
+          headers: res.headers(),
+          contentType: "application/json",
+          body: JSON.stringify(json),
+        })
+      }
+    : undefined
+
+  if (sessionList) await page.route("**/session?*", sessionList)
+
+  try {
+    return await fn()
+  } finally {
+    await page.unroute("**/permission", list)
+    await page.unroute("**/session/*/permissions/*", reply)
+    if (sessionList) await page.unroute("**/session?*", sessionList)
+  }
+}
+
 test("default dock shows prompt input", async ({ page, sdk, gotoSession }) => {
   await withDockSession(sdk, "e2e composer dock default", async (session) => {
     await gotoSession(session.id)
@@ -76,72 +167,175 @@ test("blocked question flow unblocks after submit", async ({ page, sdk, gotoSess
 
 test("blocked permission flow supports allow once", async ({ page, sdk, gotoSession }) => {
   await withDockSession(sdk, "e2e composer dock permission once", async (session) => {
-    await withDockSeed(sdk, session.id, async () => {
-      await gotoSession(session.id)
-
-      await seedSessionPermission(sdk, {
+    await gotoSession(session.id)
+    await withMockPermission(
+      page,
+      {
+        id: "per_e2e_once",
         sessionID: session.id,
         permission: "bash",
-        patterns: ["README.md"],
-        description: "Need permission for command",
-      })
-
-      await expect.poll(() => page.locator(permissionDockSelector).count(), { timeout: 10_000 }).toBe(1)
-      await expect(page.locator(promptSelector)).toHaveCount(0)
-
-      await page
-        .locator(permissionDockSelector)
-        .getByRole("button", { name: /allow once/i })
-        .click()
-      await expect.poll(() => page.locator(permissionDockSelector).count(), { timeout: 10_000 }).toBe(0)
-      await expect(page.locator(promptSelector)).toBeVisible()
-    })
+        patterns: ["/tmp/opencode-e2e-perm-once"],
+        metadata: { description: "Need permission for command" },
+      },
+      undefined,
+      async () => {
+        await page.goto(page.url())
+        await expect.poll(() => page.locator(permissionDockSelector).count(), { timeout: 10_000 }).toBe(1)
+        await expect(page.locator(promptSelector)).toHaveCount(0)
+
+        await clearPermissionDock(page, /allow once/i)
+        await page.goto(page.url())
+        await expect.poll(() => page.locator(permissionDockSelector).count(), { timeout: 10_000 }).toBe(0)
+        await expect(page.locator(promptSelector)).toBeVisible()
+      },
+    )
   })
 })
 
 test("blocked permission flow supports reject", async ({ page, sdk, gotoSession }) => {
   await withDockSession(sdk, "e2e composer dock permission reject", async (session) => {
-    await withDockSeed(sdk, session.id, async () => {
-      await gotoSession(session.id)
-
-      await seedSessionPermission(sdk, {
+    await gotoSession(session.id)
+    await withMockPermission(
+      page,
+      {
+        id: "per_e2e_reject",
         sessionID: session.id,
         permission: "bash",
-        patterns: ["REJECT.md"],
-      })
-
-      await expect.poll(() => page.locator(permissionDockSelector).count(), { timeout: 10_000 }).toBe(1)
-      await expect(page.locator(promptSelector)).toHaveCount(0)
-
-      await page.locator(permissionDockSelector).getByRole("button", { name: /deny/i }).click()
-      await expect.poll(() => page.locator(permissionDockSelector).count(), { timeout: 10_000 }).toBe(0)
-      await expect(page.locator(promptSelector)).toBeVisible()
-    })
+        patterns: ["/tmp/opencode-e2e-perm-reject"],
+      },
+      undefined,
+      async () => {
+        await page.goto(page.url())
+        await expect.poll(() => page.locator(permissionDockSelector).count(), { timeout: 10_000 }).toBe(1)
+        await expect(page.locator(promptSelector)).toHaveCount(0)
+
+        await clearPermissionDock(page, /deny/i)
+        await page.goto(page.url())
+        await expect.poll(() => page.locator(permissionDockSelector).count(), { timeout: 10_000 }).toBe(0)
+        await expect(page.locator(promptSelector)).toBeVisible()
+      },
+    )
   })
 })
 
 test("blocked permission flow supports allow always", async ({ page, sdk, gotoSession }) => {
   await withDockSession(sdk, "e2e composer dock permission always", async (session) => {
-    await withDockSeed(sdk, session.id, async () => {
-      await gotoSession(session.id)
-
-      await seedSessionPermission(sdk, {
+    await gotoSession(session.id)
+    await withMockPermission(
+      page,
+      {
+        id: "per_e2e_always",
         sessionID: session.id,
         permission: "bash",
-        patterns: ["README.md"],
-        description: "Need permission for command",
+        patterns: ["/tmp/opencode-e2e-perm-always"],
+        metadata: { description: "Need permission for command" },
+      },
+      undefined,
+      async () => {
+        await page.goto(page.url())
+        await expect.poll(() => page.locator(permissionDockSelector).count(), { timeout: 10_000 }).toBe(1)
+        await expect(page.locator(promptSelector)).toHaveCount(0)
+
+        await clearPermissionDock(page, /allow always/i)
+        await page.goto(page.url())
+        await expect.poll(() => page.locator(permissionDockSelector).count(), { timeout: 10_000 }).toBe(0)
+        await expect(page.locator(promptSelector)).toBeVisible()
+      },
+    )
+  })
+})
+
+test("child session question request blocks parent dock and unblocks after submit", async ({
+  page,
+  sdk,
+  gotoSession,
+}) => {
+  await withDockSession(sdk, "e2e composer dock child question parent", async (session) => {
+    await gotoSession(session.id)
+
+    const child = await sdk.session
+      .create({
+        title: "e2e composer dock child question",
+        parentID: session.id,
+      })
+      .then((r) => r.data)
+    if (!child?.id) throw new Error("Child session create did not return an id")
+
+    try {
+      await withDockSeed(sdk, child.id, async () => {
+        await seedSessionQuestion(sdk, {
+          sessionID: child.id,
+          questions: [
+            {
+              header: "Child input",
+              question: "Pick one child option",
+              options: [
+                { label: "Continue", description: "Continue child" },
+                { label: "Stop", description: "Stop child" },
+              ],
+            },
+          ],
+        })
+
+        const dock = page.locator(questionDockSelector)
+        await expect.poll(() => dock.count(), { timeout: 10_000 }).toBe(1)
+        await expect(page.locator(promptSelector)).toHaveCount(0)
+
+        await dock.locator('[data-slot="question-option"]').first().click()
+        await dock.getByRole("button", { name: /submit/i }).click()
+
+        await expect.poll(() => page.locator(questionDockSelector).count(), { timeout: 10_000 }).toBe(0)
+        await expect(page.locator(promptSelector)).toBeVisible()
       })
+    } finally {
+      await sdk.session.delete({ sessionID: child.id }).catch(() => undefined)
+    }
+  })
+})
 
-      await expect.poll(() => page.locator(permissionDockSelector).count(), { timeout: 10_000 }).toBe(1)
-      await expect(page.locator(promptSelector)).toHaveCount(0)
+test("child session permission request blocks parent dock and supports allow once", async ({
+  page,
+  sdk,
+  gotoSession,
+}) => {
+  await withDockSession(sdk, "e2e composer dock child permission parent", async (session) => {
+    await gotoSession(session.id)
 
-      await page
-        .locator(permissionDockSelector)
-        .getByRole("button", { name: /allow always/i })
-        .click()
-      await expect.poll(() => page.locator(permissionDockSelector).count(), { timeout: 10_000 }).toBe(0)
-      await expect(page.locator(promptSelector)).toBeVisible()
-    })
+    const child = await sdk.session
+      .create({
+        title: "e2e composer dock child permission",
+        parentID: session.id,
+      })
+      .then((r) => r.data)
+    if (!child?.id) throw new Error("Child session create did not return an id")
+
+    try {
+      await withMockPermission(
+        page,
+        {
+          id: "per_e2e_child",
+          sessionID: child.id,
+          permission: "bash",
+          patterns: ["/tmp/opencode-e2e-perm-child"],
+          metadata: { description: "Need child permission" },
+        },
+        { child },
+        async () => {
+          await page.goto(page.url())
+          const dock = page.locator(permissionDockSelector)
+          await expect.poll(() => dock.count(), { timeout: 10_000 }).toBe(1)
+          await expect(page.locator(promptSelector)).toHaveCount(0)
+
+          await clearPermissionDock(page, /allow once/i)
+          await page.goto(page.url())
+
+          await expect.poll(() => page.locator(permissionDockSelector).count(), { timeout: 10_000 }).toBe(0)
+          await expect(page.locator(promptSelector)).toBeVisible()
+        },
+      )
+    } finally {
+      await sdk.session.delete({ sessionID: child.id }).catch(() => undefined)
+    }
   })
 })
 

+ 1 - 10
packages/app/src/pages/directory-layout.tsx

@@ -1,12 +1,11 @@
 import { createEffect, createMemo, Show, type ParentProps } from "solid-js"
 import { createStore } from "solid-js/store"
 import { useNavigate, useParams } from "@solidjs/router"
-import { SDKProvider, useSDK } from "@/context/sdk"
+import { SDKProvider } from "@/context/sdk"
 import { SyncProvider, useSync } from "@/context/sync"
 import { LocalProvider } from "@/context/local"
 
 import { DataProvider } from "@opencode-ai/ui/context"
-import type { QuestionAnswer } from "@opencode-ai/sdk/v2"
 import { decode64 } from "@/utils/base64"
 import { showToast } from "@opencode-ai/ui/toast"
 import { useLanguage } from "@/context/language"
@@ -15,19 +14,11 @@ function DirectoryDataProvider(props: ParentProps<{ directory: string }>) {
   const params = useParams()
   const navigate = useNavigate()
   const sync = useSync()
-  const sdk = useSDK()
 
   return (
     <DataProvider
       data={sync.data}
       directory={props.directory}
-      onPermissionRespond={(input: {
-        sessionID: string
-        permissionID: string
-        response: "once" | "always" | "reject"
-      }) => sdk.client.permission.respond(input)}
-      onQuestionReply={(input: { requestID: string; answers: QuestionAnswer[] }) => sdk.client.question.reply(input)}
-      onQuestionReject={(input: { requestID: string }) => sdk.client.question.reject(input)}
       onNavigateToSession={(sessionID: string) => navigate(`/${params.dir}/session/${sessionID}`)}
       onSessionHref={(sessionID: string) => `/${params.dir}/session/${sessionID}`}
     >

+ 83 - 0
packages/app/src/pages/session/composer/session-composer-state.test.ts

@@ -0,0 +1,83 @@
+import { describe, expect, test } from "bun:test"
+import type { PermissionRequest, QuestionRequest, Session } from "@opencode-ai/sdk/v2/client"
+import { sessionPermissionRequest, sessionQuestionRequest } from "./session-request-tree"
+
+const session = (input: { id: string; parentID?: string }) =>
+  ({
+    id: input.id,
+    parentID: input.parentID,
+  }) as Session
+
+const permission = (id: string, sessionID: string) =>
+  ({
+    id,
+    sessionID,
+  }) as PermissionRequest
+
+const question = (id: string, sessionID: string) =>
+  ({
+    id,
+    sessionID,
+    questions: [],
+  }) as QuestionRequest
+
+describe("sessionPermissionRequest", () => {
+  test("prefers the current session permission", () => {
+    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")?.id).toBe("perm-root")
+  })
+
+  test("returns a nested child permission", () => {
+    const sessions = [
+      session({ id: "root" }),
+      session({ id: "child", parentID: "root" }),
+      session({ id: "grand", parentID: "child" }),
+      session({ id: "other" }),
+    ]
+    const permissions = {
+      grand: [permission("perm-grand", "grand")],
+      other: [permission("perm-other", "other")],
+    }
+
+    expect(sessionPermissionRequest(sessions, permissions, "root")?.id).toBe("perm-grand")
+  })
+
+  test("returns undefined without a matching tree permission", () => {
+    const sessions = [session({ id: "root" }), session({ id: "child", parentID: "root" })]
+    const permissions = {
+      other: [permission("perm-other", "other")],
+    }
+
+    expect(sessionPermissionRequest(sessions, permissions, "root")).toBeUndefined()
+  })
+})
+
+describe("sessionQuestionRequest", () => {
+  test("prefers the current session question", () => {
+    const sessions = [session({ id: "root" }), session({ id: "child", parentID: "root" })]
+    const questions = {
+      root: [question("q-root", "root")],
+      child: [question("q-child", "child")],
+    }
+
+    expect(sessionQuestionRequest(sessions, questions, "root")?.id).toBe("q-root")
+  })
+
+  test("returns a nested child question", () => {
+    const sessions = [
+      session({ id: "root" }),
+      session({ id: "child", parentID: "root" }),
+      session({ id: "grand", parentID: "child" }),
+    ]
+    const questions = {
+      grand: [question("q-grand", "grand")],
+    }
+
+    expect(sessionQuestionRequest(sessions, questions, "root")?.id).toBe("q-grand")
+  })
+})

+ 14 - 8
packages/app/src/pages/session/composer/session-composer-state.ts

@@ -7,14 +7,20 @@ import { useGlobalSync } from "@/context/global-sync"
 import { useLanguage } from "@/context/language"
 import { useSDK } from "@/context/sdk"
 import { useSync } from "@/context/sync"
+import { sessionPermissionRequest, sessionQuestionRequest } from "./session-request-tree"
 
 export function createSessionComposerBlocked() {
   const params = useParams()
   const sync = useSync()
+  const permissionRequest = createMemo(() =>
+    sessionPermissionRequest(sync.data.session, sync.data.permission, params.id),
+  )
+  const questionRequest = createMemo(() => sessionQuestionRequest(sync.data.session, sync.data.question, params.id))
+
   return createMemo(() => {
     const id = params.id
     if (!id) return false
-    return !!sync.data.permission[id]?.[0] || !!sync.data.question[id]?.[0]
+    return !!permissionRequest() || !!questionRequest()
   })
 }
 
@@ -26,18 +32,18 @@ export function createSessionComposerState() {
   const language = useLanguage()
 
   const questionRequest = createMemo((): QuestionRequest | undefined => {
-    const id = params.id
-    if (!id) return
-    return sync.data.question[id]?.[0]
+    return sessionQuestionRequest(sync.data.session, sync.data.question, params.id)
   })
 
   const permissionRequest = createMemo((): PermissionRequest | undefined => {
-    const id = params.id
-    if (!id) return
-    return sync.data.permission[id]?.[0]
+    return sessionPermissionRequest(sync.data.session, sync.data.permission, params.id)
   })
 
-  const blocked = createSessionComposerBlocked()
+  const blocked = createMemo(() => {
+    const id = params.id
+    if (!id) return false
+    return !!permissionRequest() || !!questionRequest()
+  })
 
   const todos = createMemo((): Todo[] => {
     const id = params.id

+ 45 - 0
packages/app/src/pages/session/composer/session-request-tree.ts

@@ -0,0 +1,45 @@
+import type { PermissionRequest, QuestionRequest, Session } from "@opencode-ai/sdk/v2/client"
+
+function sessionTreeRequest<T>(session: Session[], request: Record<string, T[] | undefined>, sessionID?: string) {
+  if (!sessionID) return
+
+  const map = session.reduce((acc, item) => {
+    if (!item.parentID) return acc
+    const list = acc.get(item.parentID)
+    if (list) list.push(item.id)
+    if (!list) acc.set(item.parentID, [item.id])
+    return acc
+  }, new Map<string, string[]>())
+
+  const seen = new Set([sessionID])
+  const ids = [sessionID]
+  for (const id of ids) {
+    const list = map.get(id)
+    if (!list) continue
+    for (const child of list) {
+      if (seen.has(child)) continue
+      seen.add(child)
+      ids.push(child)
+    }
+  }
+
+  const id = ids.find((id) => !!request[id]?.[0])
+  if (!id) return
+  return request[id]?.[0]
+}
+
+export function sessionPermissionRequest(
+  session: Session[],
+  request: Record<string, PermissionRequest[] | undefined>,
+  sessionID?: string,
+) {
+  return sessionTreeRequest(session, request, sessionID)
+}
+
+export function sessionQuestionRequest(
+  session: Session[],
+  request: Record<string, QuestionRequest[] | undefined>,
+  sessionID?: string,
+) {
+  return sessionTreeRequest(session, request, sessionID)
+}

+ 1 - 100
packages/ui/src/components/message-part.css

@@ -660,105 +660,6 @@
 
 [data-component="tool-part-wrapper"] {
   width: 100%;
-
-  &[data-permission="true"],
-  &[data-question="true"] {
-    position: sticky;
-    top: calc(2px + var(--sticky-header-height, 40px));
-    bottom: 0px;
-    z-index: 20;
-    border-radius: 6px;
-    border: none;
-    box-shadow: var(--shadow-xs-border-base);
-    background-color: var(--surface-raised-base);
-    overflow: visible;
-    overflow-anchor: none;
-
-    & > *:first-child {
-      border-top-left-radius: 6px;
-      border-top-right-radius: 6px;
-      overflow: hidden;
-    }
-
-    & > *:last-child {
-      border-bottom-left-radius: 6px;
-      border-bottom-right-radius: 6px;
-      overflow: hidden;
-    }
-
-    [data-component="collapsible"] {
-      border: none;
-    }
-
-    [data-component="card"] {
-      border: none;
-    }
-  }
-
-  &[data-permission="true"] {
-    &::before {
-      content: "";
-      position: absolute;
-      inset: -1.5px;
-      top: -5px;
-      border-radius: 7.5px;
-      border: 1.5px solid transparent;
-      background:
-        linear-gradient(var(--background-base) 0 0) padding-box,
-        conic-gradient(
-            from var(--border-angle),
-            transparent 0deg,
-            transparent 0deg,
-            var(--border-warning-strong, var(--border-warning-selected)) 300deg,
-            var(--border-warning-base) 360deg
-          )
-          border-box;
-      animation: chase-border 2.5s linear infinite;
-      pointer-events: none;
-      z-index: -1;
-    }
-  }
-
-  &[data-question="true"] {
-    background: var(--background-base);
-    border: 1px solid var(--border-weak-base);
-  }
-}
-
-@property --border-angle {
-  syntax: "<angle>";
-  initial-value: 0deg;
-  inherits: false;
-}
-
-@keyframes chase-border {
-  from {
-    --border-angle: 0deg;
-  }
-
-  to {
-    --border-angle: 360deg;
-  }
-}
-
-[data-component="permission-prompt"] {
-  display: flex;
-  flex-direction: column;
-  padding: 8px 12px;
-  background-color: var(--surface-raised-strong);
-  border-radius: 0 0 6px 6px;
-
-  [data-slot="permission-actions"] {
-    display: flex;
-    align-items: center;
-    gap: 8px;
-    justify-content: flex-end;
-
-    [data-component="button"] {
-      padding-left: 12px;
-      padding-right: 12px;
-    }
-  }
 }
 
 [data-component="dock-prompt"][data-kind="permission"] {
@@ -873,7 +774,7 @@
   }
 }
 
-:is([data-component="question-prompt"], [data-component="dock-prompt"][data-kind="question"]) {
+[data-component="dock-prompt"][data-kind="question"] {
   position: relative;
   display: flex;
   flex-direction: column;

+ 2 - 323
packages/ui/src/components/message-part.tsx

@@ -23,11 +23,9 @@ import {
   ToolPart,
   UserMessage,
   Todo,
-  QuestionRequest,
   QuestionAnswer,
   QuestionInfo,
 } from "@opencode-ai/sdk/v2"
-import { createStore } from "solid-js/store"
 import { useData } from "../context"
 import { useDiffComponent } from "../context/diff"
 import { useCodeComponent } from "../context/code"
@@ -37,7 +35,6 @@ import { BasicTool } from "./basic-tool"
 import { GenericTool } from "./basic-tool"
 import { Accordion } from "./accordion"
 import { StickyAccordionHeader } from "./sticky-accordion-header"
-import { Button } from "./button"
 import { Card } from "./card"
 import { Collapsible } from "./collapsible"
 import { FileIcon } from "./file-icon"
@@ -950,7 +947,6 @@ function ToolFileAccordion(props: { path: string; actions?: JSX.Element; childre
 }
 
 PART_MAPPING["tool"] = function ToolPartDisplay(props) {
-  const data = useData()
   const i18n = useI18n()
   const part = props.part as ToolPart
   if (part.tool === "todowrite" || part.tool === "todoread") return null
@@ -959,75 +955,18 @@ PART_MAPPING["tool"] = function ToolPartDisplay(props) {
     () => part.tool === "question" && (part.state.status === "pending" || part.state.status === "running"),
   )
 
-  const permission = createMemo(() => {
-    const next = data.store.permission?.[props.message.sessionID]?.[0]
-    if (!next || !next.tool) return undefined
-    if (next.tool!.callID !== part.callID) return undefined
-    return next
-  })
-
-  const questionRequest = createMemo(() => {
-    const next = data.store.question?.[props.message.sessionID]?.[0]
-    if (!next || !next.tool) return undefined
-    if (next.tool!.callID !== part.callID) return undefined
-    return next
-  })
-
-  const [showPermission, setShowPermission] = createSignal(false)
-  const [showQuestion, setShowQuestion] = createSignal(false)
-
-  createEffect(() => {
-    const perm = permission()
-    if (perm) {
-      const timeout = setTimeout(() => setShowPermission(true), 50)
-      onCleanup(() => clearTimeout(timeout))
-    } else {
-      setShowPermission(false)
-    }
-  })
-
-  createEffect(() => {
-    const question = questionRequest()
-    if (question) {
-      const timeout = setTimeout(() => setShowQuestion(true), 50)
-      onCleanup(() => clearTimeout(timeout))
-    } else {
-      setShowQuestion(false)
-    }
-  })
-
-  const [forceOpen, setForceOpen] = createSignal(false)
-  createEffect(() => {
-    if (permission() || questionRequest()) setForceOpen(true)
-  })
-
-  const respond = (response: "once" | "always" | "reject") => {
-    const perm = permission()
-    if (!perm || !data.respondToPermission) return
-    data.respondToPermission({
-      sessionID: perm.sessionID,
-      permissionID: perm.id,
-      response,
-    })
-  }
-
   const emptyInput: Record<string, any> = {}
   const emptyMetadata: Record<string, any> = {}
 
   const input = () => part.state?.input ?? emptyInput
   // @ts-expect-error
   const partMetadata = () => part.state?.metadata ?? emptyMetadata
-  const metadata = () => {
-    const perm = permission()
-    if (perm?.metadata) return { ...perm.metadata, ...partMetadata() }
-    return partMetadata()
-  }
 
   const render = ToolRegistry.render(part.tool) ?? GenericTool
 
   return (
     <Show when={!hideQuestion()}>
-      <div data-component="tool-part-wrapper" data-permission={showPermission()} data-question={showQuestion()}>
+      <div data-component="tool-part-wrapper">
         <Switch>
           <Match when={part.state.status === "error" && part.state.error}>
             {(error) => {
@@ -1067,33 +1006,15 @@ PART_MAPPING["tool"] = function ToolPartDisplay(props) {
               component={render}
               input={input()}
               tool={part.tool}
-              metadata={metadata()}
+              metadata={partMetadata()}
               // @ts-expect-error
               output={part.state.output}
               status={part.state.status}
               hideDetails={props.hideDetails}
-              forceOpen={forceOpen()}
-              locked={showPermission() || showQuestion()}
               defaultOpen={props.defaultOpen}
             />
           </Match>
         </Switch>
-        <Show when={showPermission() && permission()}>
-          <div data-component="permission-prompt">
-            <div data-slot="permission-actions">
-              <Button variant="ghost" size="normal" onClick={() => respond("reject")}>
-                {i18n.t("ui.permission.deny")}
-              </Button>
-              <Button variant="secondary" size="normal" onClick={() => respond("always")}>
-                {i18n.t("ui.permission.allowAlways")}
-              </Button>
-              <Button variant="primary" size="normal" onClick={() => respond("once")}>
-                {i18n.t("ui.permission.allowOnce")}
-              </Button>
-            </div>
-          </div>
-        </Show>
-        <Show when={showQuestion() && questionRequest()}>{(request) => <QuestionPrompt request={request()} />}</Show>
       </div>
     </Show>
   )
@@ -1963,245 +1884,3 @@ ToolRegistry.register({
     )
   },
 })
-
-function QuestionPrompt(props: { request: QuestionRequest }) {
-  const data = useData()
-  const i18n = useI18n()
-  const questions = createMemo(() => props.request.questions)
-  const single = createMemo(() => questions().length === 1 && questions()[0]?.multiple !== true)
-
-  const [store, setStore] = createStore({
-    tab: 0,
-    answers: [] as QuestionAnswer[],
-    custom: [] as string[],
-    editing: false,
-  })
-
-  const question = createMemo(() => questions()[store.tab])
-  const confirm = createMemo(() => !single() && store.tab === questions().length)
-  const options = createMemo(() => question()?.options ?? [])
-  const input = createMemo(() => store.custom[store.tab] ?? "")
-  const multi = createMemo(() => question()?.multiple === true)
-  const customPicked = createMemo(() => {
-    const value = input()
-    if (!value) return false
-    return store.answers[store.tab]?.includes(value) ?? false
-  })
-
-  function submit() {
-    const answers = questions().map((_, i) => store.answers[i] ?? [])
-    data.replyToQuestion?.({
-      requestID: props.request.id,
-      answers,
-    })
-  }
-
-  function reject() {
-    data.rejectQuestion?.({
-      requestID: props.request.id,
-    })
-  }
-
-  function pick(answer: string, custom: boolean = false) {
-    const answers = [...store.answers]
-    answers[store.tab] = [answer]
-    setStore("answers", answers)
-    if (custom) {
-      const inputs = [...store.custom]
-      inputs[store.tab] = answer
-      setStore("custom", inputs)
-    }
-    if (single()) {
-      data.replyToQuestion?.({
-        requestID: props.request.id,
-        answers: [[answer]],
-      })
-      return
-    }
-    setStore("tab", store.tab + 1)
-  }
-
-  function toggle(answer: string) {
-    const existing = store.answers[store.tab] ?? []
-    const next = [...existing]
-    const index = next.indexOf(answer)
-    if (index === -1) next.push(answer)
-    if (index !== -1) next.splice(index, 1)
-    const answers = [...store.answers]
-    answers[store.tab] = next
-    setStore("answers", answers)
-  }
-
-  function selectTab(index: number) {
-    setStore("tab", index)
-    setStore("editing", false)
-  }
-
-  function selectOption(optIndex: number) {
-    if (optIndex === options().length) {
-      setStore("editing", true)
-      return
-    }
-    const opt = options()[optIndex]
-    if (!opt) return
-    if (multi()) {
-      toggle(opt.label)
-      return
-    }
-    pick(opt.label)
-  }
-
-  function handleCustomSubmit(e: Event) {
-    e.preventDefault()
-    const value = input().trim()
-    if (!value) {
-      setStore("editing", false)
-      return
-    }
-    if (multi()) {
-      const existing = store.answers[store.tab] ?? []
-      const next = [...existing]
-      if (!next.includes(value)) next.push(value)
-      const answers = [...store.answers]
-      answers[store.tab] = next
-      setStore("answers", answers)
-      setStore("editing", false)
-      return
-    }
-    pick(value, true)
-    setStore("editing", false)
-  }
-
-  return (
-    <div data-component="question-prompt">
-      <Show when={!single()}>
-        <div data-slot="question-tabs">
-          <For each={questions()}>
-            {(q, index) => {
-              const active = () => index() === store.tab
-              const answered = () => (store.answers[index()]?.length ?? 0) > 0
-              return (
-                <button
-                  data-slot="question-tab"
-                  data-active={active()}
-                  data-answered={answered()}
-                  onClick={() => selectTab(index())}
-                >
-                  {q.header}
-                </button>
-              )
-            }}
-          </For>
-          <button data-slot="question-tab" data-active={confirm()} onClick={() => selectTab(questions().length)}>
-            {i18n.t("ui.common.confirm")}
-          </button>
-        </div>
-      </Show>
-
-      <Show when={!confirm()}>
-        <div data-slot="question-content">
-          <div data-slot="question-text">
-            {question()?.question}
-            {multi() ? " " + i18n.t("ui.question.multiHint") : ""}
-          </div>
-          <div data-slot="question-options">
-            <For each={options()}>
-              {(opt, i) => {
-                const picked = () => store.answers[store.tab]?.includes(opt.label) ?? false
-                return (
-                  <button data-slot="question-option" data-picked={picked()} onClick={() => selectOption(i())}>
-                    <span data-slot="option-label">{opt.label}</span>
-                    <Show when={opt.description}>
-                      <span data-slot="option-description">{opt.description}</span>
-                    </Show>
-                    <Show when={picked()}>
-                      <Icon name="check-small" size="normal" />
-                    </Show>
-                  </button>
-                )
-              }}
-            </For>
-            <button
-              data-slot="question-option"
-              data-picked={customPicked()}
-              onClick={() => selectOption(options().length)}
-            >
-              <span data-slot="option-label">{i18n.t("ui.messagePart.option.typeOwnAnswer")}</span>
-              <Show when={!store.editing && input()}>
-                <span data-slot="option-description">{input()}</span>
-              </Show>
-              <Show when={customPicked()}>
-                <Icon name="check-small" size="normal" />
-              </Show>
-            </button>
-            <Show when={store.editing}>
-              <form data-slot="custom-input-form" onSubmit={handleCustomSubmit}>
-                <input
-                  ref={(el) => setTimeout(() => el.focus(), 0)}
-                  type="text"
-                  data-slot="custom-input"
-                  placeholder={i18n.t("ui.question.custom.placeholder")}
-                  value={input()}
-                  onInput={(e) => {
-                    const inputs = [...store.custom]
-                    inputs[store.tab] = e.currentTarget.value
-                    setStore("custom", inputs)
-                  }}
-                />
-                <Button type="submit" variant="primary" size="small">
-                  {multi() ? i18n.t("ui.common.add") : i18n.t("ui.common.submit")}
-                </Button>
-                <Button type="button" variant="ghost" size="small" onClick={() => setStore("editing", false)}>
-                  {i18n.t("ui.common.cancel")}
-                </Button>
-              </form>
-            </Show>
-          </div>
-        </div>
-      </Show>
-
-      <Show when={confirm()}>
-        <div data-slot="question-review">
-          <div data-slot="review-title">{i18n.t("ui.messagePart.review.title")}</div>
-          <For each={questions()}>
-            {(q, index) => {
-              const value = () => store.answers[index()]?.join(", ") ?? ""
-              const answered = () => Boolean(value())
-              return (
-                <div data-slot="review-item">
-                  <span data-slot="review-label">{q.question}</span>
-                  <span data-slot="review-value" data-answered={answered()}>
-                    {answered() ? value() : i18n.t("ui.question.review.notAnswered")}
-                  </span>
-                </div>
-              )
-            }}
-          </For>
-        </div>
-      </Show>
-
-      <div data-slot="question-actions">
-        <Button variant="ghost" size="small" onClick={reject}>
-          {i18n.t("ui.common.dismiss")}
-        </Button>
-        <Show when={!single()}>
-          <Show when={confirm()}>
-            <Button variant="primary" size="small" onClick={submit}>
-              {i18n.t("ui.common.submit")}
-            </Button>
-          </Show>
-          <Show when={!confirm() && multi()}>
-            <Button
-              variant="secondary"
-              size="small"
-              onClick={() => selectTab(store.tab + 1)}
-              disabled={(store.answers[store.tab]?.length ?? 0) === 0}
-            >
-              {i18n.t("ui.common.next")}
-            </Button>
-          </Show>
-        </Show>
-      </div>
-    </div>
-  )
-}

+ 1 - 33
packages/ui/src/context/data.tsx

@@ -1,14 +1,4 @@
-import type {
-  Message,
-  Session,
-  Part,
-  FileDiff,
-  SessionStatus,
-  PermissionRequest,
-  QuestionRequest,
-  QuestionAnswer,
-  ProviderListResponse,
-} from "@opencode-ai/sdk/v2"
+import type { Message, Session, Part, FileDiff, SessionStatus, ProviderListResponse } from "@opencode-ai/sdk/v2"
 import { createSimpleContext } from "./helper"
 import { PreloadMultiFileDiffResult } from "@pierre/diffs/ssr"
 
@@ -24,12 +14,6 @@ type Data = {
   session_diff_preload?: {
     [sessionID: string]: PreloadMultiFileDiffResult<any>[]
   }
-  permission?: {
-    [sessionID: string]: PermissionRequest[]
-  }
-  question?: {
-    [sessionID: string]: QuestionRequest[]
-  }
   message: {
     [sessionID: string]: Message[]
   }
@@ -38,16 +22,6 @@ type Data = {
   }
 }
 
-export type PermissionRespondFn = (input: {
-  sessionID: string
-  permissionID: string
-  response: "once" | "always" | "reject"
-}) => void
-
-export type QuestionReplyFn = (input: { requestID: string; answers: QuestionAnswer[] }) => void
-
-export type QuestionRejectFn = (input: { requestID: string }) => void
-
 export type NavigateToSessionFn = (sessionID: string) => void
 
 export type SessionHrefFn = (sessionID: string) => string
@@ -57,9 +31,6 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
   init: (props: {
     data: Data
     directory: string
-    onPermissionRespond?: PermissionRespondFn
-    onQuestionReply?: QuestionReplyFn
-    onQuestionReject?: QuestionRejectFn
     onNavigateToSession?: NavigateToSessionFn
     onSessionHref?: SessionHrefFn
   }) => {
@@ -70,9 +41,6 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
       get directory() {
         return props.directory
       },
-      respondToPermission: props.onPermissionRespond,
-      replyToQuestion: props.onQuestionReply,
-      rejectQuestion: props.onQuestionReject,
       navigateToSession: props.onNavigateToSession,
       sessionHref: props.onSessionHref,
     }