From 7ebd344fa263e6d6eb76cf7a08c202713e054bce Mon Sep 17 00:00:00 2001 From: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Date: Thu, 2 Jul 2026 17:15:18 -0500 Subject: [PATCH] feat(core): add session form service (#34855) --- .../regression/session-request-docks.spec.ts | 29 +- packages/app/e2e/utils/mock-server.ts | 12 + .../src/context/global-sync/bootstrap.test.ts | 5 +- .../app/src/context/global-sync/bootstrap.ts | 9 +- .../context/global-sync/event-reducer.test.ts | 25 +- .../src/context/global-sync/event-reducer.ts | 22 +- .../context/global-sync/session-cache.test.ts | 8 +- .../src/context/global-sync/session-cache.ts | 4 +- packages/app/src/context/global-sync/types.ts | 4 +- packages/app/src/context/server-session.ts | 18 +- packages/app/src/pages/layout.tsx | 14 +- .../composer/session-composer-state.test.ts | 9 +- .../composer/session-composer-state.ts | 5 +- .../composer/session-question-dock.tsx | 157 +- .../session/composer/session-request-tree.ts | 7 +- packages/app/src/utils/question-form.ts | 49 + packages/client/src/effect.ts | 2 +- .../client/src/generated-effect/client.ts | 428 +- packages/client/src/generated/client.ts | 165 +- packages/client/src/generated/types.ts | 1124 ++- packages/client/test/promise.test.ts | 49 +- packages/core/src/form.ts | 308 + packages/core/src/location-services.ts | 4 +- packages/core/src/question.ts | 151 - packages/core/src/session/runner/llm.ts | 6 +- packages/core/src/tool/question.ts | 83 +- packages/core/test/form.test.ts | 58 + packages/core/test/question.test.ts | 114 - packages/core/test/session-runner.test.ts | 22 +- packages/core/test/tool-question.test.ts | 56 +- packages/opencode/script/httpapi-exercise.ts | 3 + .../src/cli/cmd/run/noninteractive.ts | 8 +- packages/opencode/src/cli/cmd/run/runtime.ts | 7 +- .../src/cli/cmd/run/stream-v2.transport.ts | 20 +- .../src/server/routes/instance/httpapi/api.ts | 10 - .../server/routes/instance/httpapi/server.ts | 13 - .../test/cli/run/stream-v2.transport.test.ts | 2 +- .../test/server/httpapi-exercise/index.ts | 40 +- .../server/httpapi-public-openapi.test.ts | 15 +- .../plugin/src/v2/effect/generated/api.ts | 388 +- packages/protocol/src/api.ts | 64 +- packages/protocol/src/client.ts | 9 +- packages/protocol/src/errors.ts | 24 +- packages/protocol/src/groups/form.ts | 151 + packages/protocol/src/groups/question.ts | 84 - packages/schema/src/event-manifest.ts | 4 +- packages/schema/src/form.ts | 149 + packages/schema/src/index.ts | 2 +- packages/schema/src/question.ts | 86 - packages/schema/test/contract-hygiene.test.ts | 4 +- packages/schema/test/event-manifest.test.ts | 12 +- packages/sdk/js/script/build.ts | 109 +- packages/sdk/js/src/v2/gen/sdk.gen.ts | 855 +- packages/sdk/js/src/v2/gen/types.gen.ts | 7635 +++++++++-------- packages/sdk/js/test/session-history.test.ts | 10 +- packages/server/src/api.ts | 4 + packages/server/src/handlers.ts | 4 +- packages/server/src/handlers/form.ts | 136 + packages/server/src/handlers/question.ts | 62 - packages/server/src/location.ts | 4 +- .../server/src/middleware/form-location.ts | 76 + packages/server/src/routes.ts | 2 + packages/tui/src/context/data.tsx | 28 +- .../feature-plugins/system/notifications.ts | 16 + packages/tui/src/routes/session/question.tsx | 74 +- packages/tui/src/util/question-form.ts | 30 + packages/tui/test/cli/tui/data.test.tsx | 42 +- 67 files changed, 7884 insertions(+), 5245 deletions(-) create mode 100644 packages/app/src/utils/question-form.ts create mode 100644 packages/core/src/form.ts delete mode 100644 packages/core/src/question.ts create mode 100644 packages/core/test/form.test.ts delete mode 100644 packages/core/test/question.test.ts create mode 100644 packages/protocol/src/groups/form.ts delete mode 100644 packages/protocol/src/groups/question.ts create mode 100644 packages/schema/src/form.ts delete mode 100644 packages/schema/src/question.ts create mode 100644 packages/server/src/handlers/form.ts delete mode 100644 packages/server/src/handlers/question.ts create mode 100644 packages/server/src/middleware/form-location.ts create mode 100644 packages/tui/src/util/question-form.ts diff --git a/packages/app/e2e/regression/session-request-docks.spec.ts b/packages/app/e2e/regression/session-request-docks.spec.ts index 036eaaef422..6bcc7832817 100644 --- a/packages/app/e2e/regression/session-request-docks.spec.ts +++ b/packages/app/e2e/regression/session-request-docks.spec.ts @@ -10,17 +10,21 @@ const title = "Request dock regression" test("shows a pending question dock", async ({ page }) => { await mockServer(page, { - questions: [ + forms: [ { - id: "question-request", + id: "frm_question_request", sessionID, - questions: [ + mode: "form", + metadata: { kind: "question" }, + fields: [ { - header: "Implementation", - question: "Which implementation should be used?", + key: "question_0", + type: "string", + title: "Which implementation should be used?", + description: "Implementation", options: [ - { label: "Minimal", description: "Use the smallest correct change" }, - { label: "Extended", description: "Include additional behavior" }, + { value: "Minimal", label: "Minimal", description: "Use the smallest correct change" }, + { value: "Extended", label: "Extended", description: "Include additional behavior" }, ], }, ], @@ -41,7 +45,8 @@ test("shows a pending question dock", async ({ page }) => { const rejectRequests: string[] = [] page.on("request", (request) => { if (request.method() !== "POST") return - if (new URL(request.url()).pathname === "/question/question-request/reject") rejectRequests.push(request.url()) + if (new URL(request.url()).pathname === `/api/session/${sessionID}/form/frm_question_request/cancel`) + rejectRequests.push(request.url()) }) await question.locator('[data-component="icon-button"][data-icon="chevron-down"]').click() @@ -63,10 +68,12 @@ test("shows a pending question dock", async ({ page }) => { await question.getByRole("radio", { name: /Minimal/ }).click() const reply = page.waitForRequest( - (request) => request.method() === "POST" && new URL(request.url()).pathname === "/question/question-request/reply", + (request) => + request.method() === "POST" && + new URL(request.url()).pathname === `/api/session/${sessionID}/form/frm_question_request/reply`, ) await question.getByRole("button", { name: "Submit" }).click() - expect((await reply).postDataJSON()).toEqual({ answers: [["Minimal"]] }) + expect((await reply).postDataJSON()).toEqual({ answer: { question_0: "Minimal" } }) }) test("shows a pending permission dock", async ({ page }) => { @@ -105,6 +112,7 @@ async function mockServer( requests: { permissions?: unknown[] | (() => unknown[]) questions?: unknown[] | (() => unknown[]) + forms?: unknown[] | (() => unknown[]) }, ) { await mockOpenCodeServer(page, { @@ -148,6 +156,7 @@ async function mockServer( pageMessages: () => ({ items: [] }), permissions: requests.permissions, questions: requests.questions, + forms: requests.forms, }) await page.addInitScript(() => { localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } })) diff --git a/packages/app/e2e/utils/mock-server.ts b/packages/app/e2e/utils/mock-server.ts index 875c3b7a96c..d0978bed09a 100644 --- a/packages/app/e2e/utils/mock-server.ts +++ b/packages/app/e2e/utils/mock-server.ts @@ -17,6 +17,7 @@ export interface MockServerConfig { todos?: (sessionID: string) => unknown[] permissions?: unknown[] | (() => unknown[]) questions?: unknown[] | (() => unknown[]) + forms?: unknown[] | (() => unknown[]) } export async function mockOpenCodeServer(page: Page, config: MockServerConfig) { @@ -53,6 +54,17 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) { return json(route, typeof config.permissions === "function" ? config.permissions() : (config.permissions ?? [])) if (path === "/question") return json(route, typeof config.questions === "function" ? config.questions() : (config.questions ?? [])) + if (path === "/api/form/request") + return json(route, { + location: { directory: config.directory, project: config.project }, + data: typeof config.forms === "function" ? config.forms() : (config.forms ?? []), + }) + if (/^\/api\/session\/[^/]+\/form$/.test(path)) + return json(route, { + location: { directory: config.directory, project: config.project }, + data: typeof config.forms === "function" ? config.forms() : (config.forms ?? []), + }) + if (/^\/api\/session\/[^/]+\/form\/[^/]+\/(reply|cancel)$/.test(path)) return json(route, undefined, undefined, 204) if (path === "/vcs/diff" && config.vcsDiff) return json(route, config.vcsDiff) if (emptyObject.has(path)) return json(route, {}) if (emptyList.has(path)) return json(route, []) diff --git a/packages/app/src/context/global-sync/bootstrap.test.ts b/packages/app/src/context/global-sync/bootstrap.test.ts index 73de5b9ce6d..f857cfbed15 100644 --- a/packages/app/src/context/global-sync/bootstrap.test.ts +++ b/packages/app/src/context/global-sync/bootstrap.test.ts @@ -69,7 +69,10 @@ describe("bootstrapDirectory", () => { }, permission: { list: async () => ({ data: [] }) }, question: { list: async () => ({ data: [] }) }, - v2: { reference: { list: async () => ({ data: { data: [] } }) } }, + v2: { + form: { request: { list: async () => ({ data: { data: [] } }) } }, + reference: { list: async () => ({ data: { data: [] } }) }, + }, mcp: { status: async () => { mcpReads.push("status") diff --git a/packages/app/src/context/global-sync/bootstrap.ts b/packages/app/src/context/global-sync/bootstrap.ts index 8f253a975a2..320f4f9b436 100644 --- a/packages/app/src/context/global-sync/bootstrap.ts +++ b/packages/app/src/context/global-sync/bootstrap.ts @@ -5,7 +5,6 @@ import type { PermissionRequest, Project, ProviderAuthResponse, - QuestionRequest, ReferenceInfo, Session, } from "@opencode-ai/sdk/v2/client" @@ -22,6 +21,7 @@ import { QueryClient, queryOptions } from "@tanstack/solid-query" import { loadMcpQuery, loadMcpResourcesQuery } from "../server-sync" import { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context" import { ScopedKey, type ServerScope } from "@/utils/server-scope" +import { isQuestionForm, type QuestionForm } from "@/utils/question-form" type GlobalStore = { ready: boolean @@ -319,9 +319,10 @@ export async function bootstrapDirectory(input: { ), () => retry(() => - input.sdk.question.list().then((x) => { - const ids = (x.data ?? []).map((question) => question?.sessionID).filter((id): id is string => !!id) - const grouped = groupBySession((x.data ?? []).filter((q): q is QuestionRequest => !!q?.id && !!q.sessionID)) + input.sdk.v2.form.request.list().then((x) => { + const forms: QuestionForm[] = (x.data?.data ?? []).flatMap((form) => (isQuestionForm(form) ? [form] : [])) + const ids = forms.map((question) => question.sessionID) + const grouped = groupBySession(forms) const warm = input.session ? Promise.all(ids.map((sessionID) => input.session!.resolve(sessionID))).then(() => undefined) : warmSessions({ ids, store: input.store, setStore: input.setStore, sdk: input.sdk }) diff --git a/packages/app/src/context/global-sync/event-reducer.test.ts b/packages/app/src/context/global-sync/event-reducer.test.ts index 2811e6e3c6c..f47e2f55dc1 100644 --- a/packages/app/src/context/global-sync/event-reducer.test.ts +++ b/packages/app/src/context/global-sync/event-reducer.test.ts @@ -1,8 +1,9 @@ import { describe, expect, test } from "bun:test" -import type { Message, Part, PermissionRequest, Project, QuestionRequest, Session } from "@opencode-ai/sdk/v2/client" +import type { Message, Part, PermissionRequest, Project, Session } from "@opencode-ai/sdk/v2/client" import { createStore } from "solid-js/store" import type { State } from "./types" import { applyDirectoryEvent, applyGlobalEvent, cleanupDroppedSessionCaches } from "./event-reducer" +import type { QuestionForm } from "@/utils/question-form" const rootSession = (input: { id: string; parentID?: string; archived?: number }) => ({ @@ -48,14 +49,18 @@ const questionRequest = (id: string, sessionID: string, title = id) => ({ id, sessionID, - questions: [ + mode: "form", + metadata: { kind: "question" }, + fields: [ { - question: title, - header: title, - options: [{ label: title, description: title }], + key: "question_0", + title, + description: title, + type: "string", + options: [{ value: title, label: title, description: title }], }, ], - }) as QuestionRequest + }) as QuestionForm const baseState = (input: Partial = {}) => ({ @@ -505,7 +510,7 @@ describe("applyDirectoryEvent", () => { expect(store.permission[sessionID]?.map((x) => x.id)).toEqual(["perm_1", "perm_3"]) applyDirectoryEvent({ - event: { type: "question.asked", properties: questionRequest("q_2", sessionID) }, + event: { type: "form.created", properties: { form: questionRequest("q_2", sessionID) } }, store, setStore, push() {}, @@ -515,17 +520,17 @@ describe("applyDirectoryEvent", () => { expect(store.question[sessionID]?.map((x) => x.id)).toEqual(["q_1", "q_2", "q_3"]) applyDirectoryEvent({ - event: { type: "question.asked", properties: questionRequest("q_2", sessionID, "updated") }, + event: { type: "form.created", properties: { form: questionRequest("q_2", sessionID, "updated") } }, store, setStore, push() {}, directory: "/tmp", loadLsp() {}, }) - expect(store.question[sessionID]?.find((x) => x.id === "q_2")?.questions[0]?.header).toBe("updated") + expect(store.question[sessionID]?.find((x) => x.id === "q_2")?.fields[0]?.description).toBe("updated") applyDirectoryEvent({ - event: { type: "question.rejected", properties: { sessionID, requestID: "q_2" } }, + event: { type: "form.cancelled", properties: { sessionID, id: "q_2" } }, store, setStore, push() {}, diff --git a/packages/app/src/context/global-sync/event-reducer.ts b/packages/app/src/context/global-sync/event-reducer.ts index 15d125df391..74ea2ab0283 100644 --- a/packages/app/src/context/global-sync/event-reducer.ts +++ b/packages/app/src/context/global-sync/event-reducer.ts @@ -5,7 +5,6 @@ import type { Part, PermissionRequest, Project, - QuestionRequest, Session, SessionStatus, SnapshotFileDiff, @@ -15,6 +14,7 @@ import type { State, VcsCache } from "./types" import { trimSessions } from "./session-trim" import { dropSessionCaches } from "./session-cache" import { diffs as list, message as clean } from "@/utils/diffs" +import { isQuestionForm } from "@/utils/question-form" const SKIP_PARTS = new Set(["patch", "step-start", "step-finish"]) const SESSION_CONTENT_EVENTS = new Set([ @@ -28,9 +28,9 @@ const SESSION_CONTENT_EVENTS = new Set([ "message.part.delta", "permission.asked", "permission.replied", - "question.asked", - "question.replied", - "question.rejected", + "form.created", + "form.replied", + "form.cancelled", ]) export function applyGlobalEvent(input: { @@ -364,8 +364,10 @@ export function applyDirectoryEvent(input: { ) break } - case "question.asked": { - const question = event.properties as QuestionRequest + case "form.created": { + const properties = event.properties as { form?: unknown } + if (!isQuestionForm(properties.form)) break + const question = properties.form const questions = input.store.question[question.sessionID] if (!questions) { input.setStore("question", question.sessionID, [question]) @@ -385,12 +387,12 @@ export function applyDirectoryEvent(input: { ) break } - case "question.replied": - case "question.rejected": { - const props = event.properties as { sessionID: string; requestID: string } + case "form.replied": + case "form.cancelled": { + const props = event.properties as { sessionID: string; id: string } const questions = input.store.question[props.sessionID] if (!questions) break - const result = Binary.search(questions, props.requestID, (q) => q.id) + const result = Binary.search(questions, props.id, (q) => q.id) if (!result.found) break input.setStore( "question", diff --git a/packages/app/src/context/global-sync/session-cache.test.ts b/packages/app/src/context/global-sync/session-cache.test.ts index 4b2be505eaa..9ecd893d095 100644 --- a/packages/app/src/context/global-sync/session-cache.test.ts +++ b/packages/app/src/context/global-sync/session-cache.test.ts @@ -3,12 +3,12 @@ import type { Message, Part, PermissionRequest, - QuestionRequest, SessionStatus, SnapshotFileDiff, Todo, } from "@opencode-ai/sdk/v2/client" import { dropSessionCaches, pickSessionCacheEvictions } from "./session-cache" +import type { QuestionForm } from "@/utils/question-form" const msg = (id: string, sessionID: string) => ({ @@ -38,7 +38,7 @@ describe("app session cache", () => { message: Record part: Record permission: Record - question: Record + question: Record part_text_accum_delta: Record } = { session_status: { ses_1: { type: "busy" } as SessionStatus }, @@ -47,7 +47,7 @@ describe("app session cache", () => { message: {}, part: { msg_1: [part("prt_1", "ses_1", "msg_1")] }, permission: { ses_1: [] as PermissionRequest[] }, - question: { ses_1: [] as QuestionRequest[] }, + question: { ses_1: [] as QuestionForm[] }, part_text_accum_delta: { prt_1: "streamed text" }, } @@ -72,7 +72,7 @@ describe("app session cache", () => { message: Record part: Record permission: Record - question: Record + question: Record part_text_accum_delta: Record } = { session_status: {}, diff --git a/packages/app/src/context/global-sync/session-cache.ts b/packages/app/src/context/global-sync/session-cache.ts index 05cdc846438..a9d7bc4206a 100644 --- a/packages/app/src/context/global-sync/session-cache.ts +++ b/packages/app/src/context/global-sync/session-cache.ts @@ -2,11 +2,11 @@ import type { Message, Part, PermissionRequest, - QuestionRequest, SessionStatus, SnapshotFileDiff, Todo, } from "@opencode-ai/sdk/v2/client" +import type { QuestionForm } from "@/utils/question-form" export const SESSION_CACHE_LIMIT = 40 @@ -17,7 +17,7 @@ type SessionCache = { message: Record part: Record permission: Record - question: Record + question: Record part_text_accum_delta: Record } diff --git a/packages/app/src/context/global-sync/types.ts b/packages/app/src/context/global-sync/types.ts index 86b489cd090..d91a7861766 100644 --- a/packages/app/src/context/global-sync/types.ts +++ b/packages/app/src/context/global-sync/types.ts @@ -9,7 +9,6 @@ import type { Part, Path, PermissionRequest, - QuestionRequest, ReferenceInfo, Session, SessionStatus, @@ -17,6 +16,7 @@ import type { Todo, VcsInfo, } from "@opencode-ai/sdk/v2/client" +import type { QuestionForm } from "@/utils/question-form" import { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context" import type { Accessor } from "solid-js" import type { SetStoreFunction, Store } from "solid-js/store" @@ -60,7 +60,7 @@ export type State = { [sessionID: string]: PermissionRequest[] } question: { - [sessionID: string]: QuestionRequest[] + [sessionID: string]: QuestionForm[] } mcp_ready: boolean mcp: { diff --git a/packages/app/src/context/server-session.ts b/packages/app/src/context/server-session.ts index 9ba712ef256..226d66bcb75 100644 --- a/packages/app/src/context/server-session.ts +++ b/packages/app/src/context/server-session.ts @@ -5,12 +5,12 @@ import type { OpencodeClient, Part, PermissionRequest, - QuestionRequest, Session, SessionStatus, SnapshotFileDiff, Todo, } from "@opencode-ai/sdk/v2/client" +import { isQuestionForm, type QuestionForm } from "@/utils/question-form" import { batch } from "solid-js" import { createStore, produce, reconcile } from "solid-js/store" import { diffs as cleanDiffs, message as cleanMessage } from "@/utils/diffs" @@ -136,7 +136,7 @@ export function createServerSession(client: OpencodeClient, options?: { retry?: session_diff: {} as Record, todo: {} as Record, permission: {} as Record, - question: {} as Record, + question: {} as Record, message: {} as Record, part: {} as Record, part_text_accum_delta: {} as Record, @@ -932,8 +932,10 @@ export function createServerSession(client: OpencodeClient, options?: { retry?: ) return } - case "question.asked": { - const question = event.properties as QuestionRequest + case "form.created": { + const properties = event.properties as { form?: unknown } + if (!isQuestionForm(properties.form)) return + const question = properties.form const questions = data.question[question.sessionID] if (!questions) { setData("question", question.sessionID, [question]) @@ -949,15 +951,15 @@ export function createServerSession(client: OpencodeClient, options?: { retry?: ) return } - case "question.replied": - case "question.rejected": { - const props = event.properties as { sessionID: string; requestID: string } + case "form.replied": + case "form.cancelled": { + const props = event.properties as { sessionID: string; id: string } setData( "question", props.sessionID, produce((draft) => { if (!draft) return - const result = Binary.search(draft, props.requestID, (item) => item.id) + const result = Binary.search(draft, props.id, (item) => item.id) if (result.found) draft.splice(result.index, 1) }), ) diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx index 690460d3af6..448638771dd 100644 --- a/packages/app/src/pages/layout.tsx +++ b/packages/app/src/pages/layout.tsx @@ -48,6 +48,7 @@ import { setNavigate } from "@/utils/notification-click" import { Worktree as WorktreeState } from "@/utils/worktree" import { setSessionHandoff } from "@/pages/session/handoff" import { SessionRouteKey, SessionStateKey } from "@/utils/server-scope" +import { isQuestionForm } from "@/utils/question-form" import { useDialog } from "@opencode-ai/ui/context/dialog" import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme/context" @@ -403,25 +404,22 @@ export default function LegacyLayout(props: ParentProps) { return } - if ( - e.details?.type === "question.replied" || - e.details?.type === "question.rejected" || - e.details?.type === "permission.replied" - ) { + if (e.details?.type === "form.replied" || e.details?.type === "form.cancelled" || e.details?.type === "permission.replied") { const props = e.details.properties as { sessionID: string } const sessionKey = `${e.name}:${props.sessionID}` dismissSessionAlert(sessionKey) return } - if (e.details?.type !== "permission.asked" && e.details?.type !== "question.asked") return + const questionForm = e.details?.type === "form.created" && isQuestionForm(e.details.properties?.form) ? e.details.properties.form : undefined + if (e.details?.type !== "permission.asked" && !questionForm) return const title = e.details.type === "permission.asked" ? language.t("notification.permission.title") : language.t("notification.question.title") const icon = e.details.type === "permission.asked" ? ("checklist" as const) : ("bubble-5" as const) const directory = e.name - const props = e.details.properties + const props = questionForm ?? (e.details.properties as { sessionID: string }) if (e.details.type === "permission.asked" && permission.autoResponds(e.details.properties, directory)) return const [store] = serverSync().child(directory, { bootstrap: false }) @@ -450,7 +448,7 @@ export default function LegacyLayout(props: ParentProps) { } } - if (e.details.type === "question.asked") { + if (questionForm) { if (settings.notifications.agent()) { void platform.notify(title, description, href) } 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 index 49efcc971a9..3db22f347ba 100644 --- a/packages/app/src/pages/session/composer/session-composer-state.test.ts +++ b/packages/app/src/pages/session/composer/session-composer-state.test.ts @@ -1,5 +1,6 @@ import { describe, expect, test } from "bun:test" -import type { PermissionRequest, QuestionRequest, Session } from "@opencode-ai/sdk/v2/client" +import type { PermissionRequest, Session } from "@opencode-ai/sdk/v2/client" +import type { QuestionForm } from "@/utils/question-form" import { todoDockAtBoundary, todoState } from "./session-composer-state" import { sessionPermissionRequest, sessionQuestionRequest } from "./session-request-tree" @@ -19,8 +20,10 @@ const question = (id: string, sessionID: string) => ({ id, sessionID, - questions: [], - }) as QuestionRequest + mode: "form", + metadata: { kind: "question" }, + fields: [], + }) as QuestionForm describe("sessionPermissionRequest", () => { test("prefers the current session permission", () => { 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 45f5e4cb26f..aa3833e04b5 100644 --- a/packages/app/src/pages/session/composer/session-composer-state.ts +++ b/packages/app/src/pages/session/composer/session-composer-state.ts @@ -1,6 +1,7 @@ import { createEffect, createMemo, on, onCleanup } from "solid-js" import { createStore } from "solid-js/store" -import type { PermissionRequest, QuestionRequest, Todo } from "@opencode-ai/sdk/v2" +import type { PermissionRequest, Todo } from "@opencode-ai/sdk/v2" +import type { QuestionForm } from "@/utils/question-form" import { useParams } from "@solidjs/router" import { showToast } from "@/utils/toast" import { useServerSync } from "@/context/server-sync" @@ -33,7 +34,7 @@ export function createSessionComposerController(options?: { closeMs?: number | ( const language = useLanguage() const permission = usePermission() - const questionRequest = createMemo((): QuestionRequest | undefined => { + const questionRequest = createMemo((): QuestionForm | undefined => { return sessionQuestionRequest(sync().data.session, sync().data.question, params.id) }) diff --git a/packages/app/src/pages/session/composer/session-question-dock.tsx b/packages/app/src/pages/session/composer/session-question-dock.tsx index 445a9f47a08..f69939d1c39 100644 --- a/packages/app/src/pages/session/composer/session-question-dock.tsx +++ b/packages/app/src/pages/session/composer/session-question-dock.tsx @@ -6,13 +6,13 @@ import { DockPrompt } from "@opencode-ai/session-ui/dock-prompt" import { Icon } from "@opencode-ai/ui/icon" import { useSpring } from "@opencode-ai/ui/motion-spring" import { showToast } from "@/utils/toast" -import type { QuestionAnswer, QuestionRequest } from "@opencode-ai/sdk/v2" import { useLanguage } from "@/context/language" import { useSDK } from "@/context/sdk" import { makeEventListener } from "@solid-primitives/event-listener" import { createResizeObserver } from "@solid-primitives/resize-observer" import { useServerSDK } from "@/context/server-sdk" import { ScopedKey } from "@/utils/server-scope" +import { questionAnswer, type QuestionAnswer, type QuestionForm } from "@/utils/question-form" const cache = new Map() @@ -61,13 +61,13 @@ function Option(props: { ) } -export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit: () => void }> = (props) => { +export const SessionQuestionDock: Component<{ request: QuestionForm; onSubmit: () => void }> = (props) => { const sdk = useSDK() const serverSDK = useServerSDK() const language = useLanguage() const cacheKey = ScopedKey.from(serverSDK().scope, props.request.id) - const questions = createMemo(() => props.request.questions) + const questions = createMemo(() => props.request.fields) const total = createMemo(() => questions().length) const cached = cache.get(cacheKey) @@ -91,10 +91,11 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit const question = createMemo(() => questions()[store.tab]) const options = createMemo(() => question()?.options ?? []) + const custom = createMemo(() => question()?.custom !== false) const input = createMemo(() => store.custom[store.tab] ?? "") - const on = createMemo(() => store.customOn[store.tab] === true) - const multi = createMemo(() => question()?.multiple === true) - const count = createMemo(() => options().length + 1) + const on = createMemo(() => custom() && store.customOn[store.tab] === true) + const multi = createMemo(() => question()?.type === "multiselect") + const count = createMemo(() => options().length + (custom() ? 1 : 0)) const summary = createMemo(() => { const n = Math.min(store.tab + 1, total()) @@ -154,7 +155,7 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit const pickFocus = (tab: number = store.tab) => { const list = questions()[tab]?.options ?? [] - if (store.customOn[tab] === true) return list.length + if (questions()[tab]?.custom !== false && store.customOn[tab] === true) return list.length return Math.max( 0, list.findIndex((item) => store.answers[tab]?.includes(item.label) ?? false), @@ -223,7 +224,12 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit } const replyMutation = useMutation(() => ({ - mutationFn: (answers: QuestionAnswer[]) => sdk().client.question.reply({ requestID: props.request.id, answers }), + mutationFn: (answers: QuestionAnswer[]) => + sdk().client.v2.session.form.reply({ + sessionID: props.request.sessionID, + formID: props.request.id, + formReply: { answer: questionAnswer(props.request.fields, answers) }, + }), onMutate: () => { props.onSubmit() }, @@ -235,7 +241,7 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit })) const rejectMutation = useMutation(() => ({ - mutationFn: () => sdk().client.question.reject({ requestID: props.request.id }), + mutationFn: () => sdk().client.v2.session.form.cancel({ sessionID: props.request.sessionID, formID: props.request.id }), onMutate: () => { props.onSubmit() }, @@ -262,7 +268,7 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit const answered = (i: number) => { if ((store.answers[i]?.length ?? 0) > 0) return true - return store.customOn[i] === true && (store.custom[i] ?? "").trim().length > 0 + return questions()[i]?.custom !== false && store.customOn[i] === true && (store.custom[i] ?? "").trim().length > 0 } const picked = (answer: string) => store.answers[store.tab]?.includes(answer) ?? false @@ -283,6 +289,7 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit const customToggle = () => { if (sending()) return + if (!custom()) return setStore("focus", options().length) if (!multi()) { @@ -308,6 +315,7 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit const customOpen = () => { if (sending()) return + if (!custom()) return setStore("focus", options().length) if (!on()) setStore("customOn", store.tab, true) setStore("editing", true) @@ -369,6 +377,7 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit if (sending()) return if (optIndex === options().length) { + if (!custom()) return customOpen() return } @@ -526,7 +535,7 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit overflow: store.minimized ? "hidden" : undefined, }} > - {question()?.question} + {question()?.title} {language.t("ui.question.singleHint")}}> @@ -559,78 +568,80 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit )} - + setStore("focus", options().length)} + onClick={customOpen} + > + + + {customLabel()} + {input() || customPlaceholder()} + + + } + > +
setStore("focus", options().length)} - onClick={customOpen} + onMouseDown={(e) => { + if (sending()) { + e.preventDefault() + return + } + if (e.target instanceof HTMLTextAreaElement) return + const input = e.currentTarget.querySelector('[data-slot="question-custom-input"]') + if (input instanceof HTMLTextAreaElement) input.focus() + }} + onSubmit={(e) => { + e.preventDefault() + commitCustom() + }} > {customLabel()} - {input() || customPlaceholder()} - - - } - > - { - if (sending()) { - e.preventDefault() - return - } - if (e.target instanceof HTMLTextAreaElement) return - const input = e.currentTarget.querySelector('[data-slot="question-custom-input"]') - if (input instanceof HTMLTextAreaElement) input.focus() - }} - onSubmit={(e) => { - e.preventDefault() - commitCustom() - }} - > - - - {customLabel()} -