summaryrefslogtreecommitdiffhomepage
path: root/packages
diff options
context:
space:
mode:
authorAdam <[email protected]>2026-02-25 19:05:08 -0600
committerGitHub <[email protected]>2026-02-26 01:05:08 +0000
commitb8337cddc4269ba6e72f74c1e1f39aae41f56af3 (patch)
tree178a80a21af1a24f3568a0ef15e735a5d537c16f /packages
parent444178e079fb41ba2149a1cdfdd3040593715d70 (diff)
downloadopencode-b8337cddc4269ba6e72f74c1e1f39aae41f56af3.tar.gz
opencode-b8337cddc4269ba6e72f74c1e1f39aae41f56af3.zip
fix(app): permissions and questions from child sessions (#15105)
Co-authored-by: adamelmore <[email protected]>
Diffstat (limited to 'packages')
-rw-r--r--packages/app/e2e/session/session-composer-dock.spec.ts298
-rw-r--r--packages/app/src/pages/directory-layout.tsx11
-rw-r--r--packages/app/src/pages/session/composer/session-composer-state.test.ts83
-rw-r--r--packages/app/src/pages/session/composer/session-composer-state.ts22
-rw-r--r--packages/app/src/pages/session/composer/session-request-tree.ts45
-rw-r--r--packages/ui/src/components/message-part.css101
-rw-r--r--packages/ui/src/components/message-part.tsx325
-rw-r--r--packages/ui/src/context/data.tsx34
8 files changed, 393 insertions, 526 deletions
diff --git a/packages/app/e2e/session/session-composer-dock.spec.ts b/packages/app/e2e/session/session-composer-dock.spec.ts
index 6bf7714a6..e9cfc03e4 100644
--- a/packages/app/e2e/session/session-composer-dock.spec.ts
+++ b/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)
+ }
})
})
diff --git a/packages/app/src/pages/directory-layout.tsx b/packages/app/src/pages/directory-layout.tsx
index 4f1d93ab2..71b52180f 100644
--- a/packages/app/src/pages/directory-layout.tsx
+++ b/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}`}
>
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
new file mode 100644
index 000000000..7b6029eb3
--- /dev/null
+++ b/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")
+ })
+})
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 04c6f7e69..ed65867ef 100644
--- a/packages/app/src/pages/session/composer/session-composer-state.ts
+++ b/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
diff --git a/packages/app/src/pages/session/composer/session-request-tree.ts b/packages/app/src/pages/session/composer/session-request-tree.ts
new file mode 100644
index 000000000..f9673e254
--- /dev/null
+++ b/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)
+}
diff --git a/packages/ui/src/components/message-part.css b/packages/ui/src/components/message-part.css
index f063076e0..bea33ff54 100644
--- a/packages/ui/src/components/message-part.css
+++ b/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;
diff --git a/packages/ui/src/components/message-part.tsx b/packages/ui/src/components/message-part.tsx
index adba42ce9..8fbad45bd 100644
--- a/packages/ui/src/components/message-part.tsx
+++ b/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>
- )
-}
diff --git a/packages/ui/src/context/data.tsx b/packages/ui/src/context/data.tsx
index 2c44763f5..e116199eb 100644
--- a/packages/ui/src/context/data.tsx
+++ b/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,
}