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[0] +type PermissionRule = { permission: string; pattern: string; action: "allow" | "deny" | "ask" } -async function withDockSession(sdk: Sdk, title: string, fn: (session: { id: string; title: string }) => Promise) { - const session = await sdk.session.create({ title }).then((r) => r.data) +async function withDockSession( + sdk: Sdk, + title: string, + fn: (session: { id: string; title: string }) => Promise, + 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(sdk: Sdk, sessionID: string, fn: () => Promise } } +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( + page: any, + request: { + id: string + sessionID: string + permission: string + patterns: string[] + metadata?: Record + always?: string[] + }, + opts: { child?: any } | undefined, + fn: () => Promise, +) { + 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", - }) + 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 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() - }) + 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"], - }) + 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 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() - }) + 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") - await expect.poll(() => page.locator(permissionDockSelector).count(), { timeout: 10_000 }).toBe(1) - await expect(page.locator(promptSelector)).toHaveCount(0) + 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" }, + ], + }, + ], + }) - 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 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) + } + }) +}) + +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) + + 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 ( 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(session: Session[], request: Record, 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()) + + 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, + sessionID?: string, +) { + return sessionTreeRequest(session, request, sessionID) +} + +export function sessionQuestionRequest( + session: Session[], + request: Record, + 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: ""; - 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 = {} const emptyMetadata: Record = {} 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 ( -
+
{(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} /> - -
-
- - - -
-
-
- {(request) => }
) @@ -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 ( -
- -
- - {(q, index) => { - const active = () => index() === store.tab - const answered = () => (store.answers[index()]?.length ?? 0) > 0 - return ( - - ) - }} - - -
-
- - -
-
- {question()?.question} - {multi() ? " " + i18n.t("ui.question.multiHint") : ""} -
-
- - {(opt, i) => { - const picked = () => store.answers[store.tab]?.includes(opt.label) ?? false - return ( - - ) - }} - - - -
- 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) - }} - /> - - -
-
-
-
-
- - -
-
{i18n.t("ui.messagePart.review.title")}
- - {(q, index) => { - const value = () => store.answers[index()]?.join(", ") ?? "" - const answered = () => Boolean(value()) - return ( -
- {q.question} - - {answered() ? value() : i18n.t("ui.question.review.notAnswered")} - -
- ) - }} -
-
-
- -
- - - - - - - - - -
-
- ) -} 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[] } - 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, }