fix(core): revert form service and mcp elicitation (#35080)
This commit is contained in:
parent
1de3c6e4a6
commit
ef2140d121
73 changed files with 6587 additions and 6492 deletions
|
|
@ -30,13 +30,11 @@ export const createDirSyncContext = (
|
|||
const data = new Proxy({} as State, {
|
||||
get(_, property: keyof State) {
|
||||
if (property === "session_working") return serverSync.session.data.session_working.bind(serverSync.session.data)
|
||||
if (property === "question") return { ...serverSync.session.data.question, global: current()[0].question.global ?? [] }
|
||||
if (sessionFields.has(property)) return serverSync.session.data[property as keyof typeof serverSync.session.data]
|
||||
return current()[0][property]
|
||||
},
|
||||
})
|
||||
const set = ((...input: unknown[]) => {
|
||||
if (input[0] === "question" && input[1] === "global") return (current()[1] as (...args: unknown[]) => unknown)(...input)
|
||||
if (typeof input[0] === "string" && sessionFields.has(input[0])) {
|
||||
return (serverSync.session.set as (...args: unknown[]) => unknown)(...input)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -69,10 +69,7 @@ describe("bootstrapDirectory", () => {
|
|||
},
|
||||
permission: { list: async () => ({ data: [] }) },
|
||||
question: { list: async () => ({ data: [] }) },
|
||||
v2: {
|
||||
form: { request: { list: async () => ({ data: { data: [] } }) } },
|
||||
reference: { list: async () => ({ data: { data: [] } }) },
|
||||
},
|
||||
v2: { reference: { list: async () => ({ data: { data: [] } }) } },
|
||||
mcp: {
|
||||
status: async () => {
|
||||
mcpReads.push("status")
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import type {
|
|||
PermissionRequest,
|
||||
Project,
|
||||
ProviderAuthResponse,
|
||||
QuestionRequest,
|
||||
ReferenceInfo,
|
||||
Session,
|
||||
} from "@opencode-ai/sdk/v2/client"
|
||||
|
|
@ -21,7 +22,6 @@ 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,11 +319,9 @@ export async function bootstrapDirectory(input: {
|
|||
),
|
||||
() =>
|
||||
retry(() =>
|
||||
input.sdk.v2.form.request.list().then((x) => {
|
||||
const forms: QuestionForm[] = (x.data?.data ?? []).flatMap((form) => (isQuestionForm(form) ? [form] : []))
|
||||
const global = forms.filter((question) => question.sessionID === "global")
|
||||
const grouped = groupBySession(forms.filter((question) => question.sessionID !== "global"))
|
||||
const ids = Object.keys(grouped)
|
||||
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))
|
||||
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 })
|
||||
|
|
@ -331,22 +329,11 @@ export async function bootstrapDirectory(input: {
|
|||
batch(() => {
|
||||
const current = input.session?.data.question ?? input.store.question
|
||||
for (const sessionID of Object.keys(current)) {
|
||||
if (sessionID === "global") continue
|
||||
if (grouped[sessionID]) continue
|
||||
if (input.session?.get(sessionID)?.directory !== input.directory) continue
|
||||
if (input.session) input.session.set("question", sessionID, [])
|
||||
if (!input.session) input.setStore("question", sessionID, [])
|
||||
}
|
||||
if (global.length > 0 || input.store.question.global) {
|
||||
input.setStore(
|
||||
"question",
|
||||
"global",
|
||||
reconcile(
|
||||
global.filter((q) => !!q?.id).sort((a, b) => cmp(a.id, b.id)),
|
||||
{ key: "id" },
|
||||
),
|
||||
)
|
||||
}
|
||||
for (const [sessionID, questions] of Object.entries(grouped)) {
|
||||
const value = reconcile(
|
||||
questions.filter((q) => !!q?.id).sort((a, b) => cmp(a.id, b.id)),
|
||||
|
|
|
|||
|
|
@ -1,9 +1,8 @@
|
|||
import { describe, expect, test } from "bun:test"
|
||||
import type { Message, Part, PermissionRequest, Project, Session } from "@opencode-ai/sdk/v2/client"
|
||||
import type { Message, Part, PermissionRequest, Project, QuestionRequest, 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 }) =>
|
||||
({
|
||||
|
|
@ -49,18 +48,14 @@ const questionRequest = (id: string, sessionID: string, title = id) =>
|
|||
({
|
||||
id,
|
||||
sessionID,
|
||||
mode: "form",
|
||||
metadata: { kind: "question" },
|
||||
fields: [
|
||||
questions: [
|
||||
{
|
||||
key: "question_0",
|
||||
title,
|
||||
description: title,
|
||||
type: "string",
|
||||
options: [{ value: title, label: title, description: title }],
|
||||
question: title,
|
||||
header: title,
|
||||
options: [{ label: title, description: title }],
|
||||
},
|
||||
],
|
||||
}) as QuestionForm
|
||||
}) as QuestionRequest
|
||||
|
||||
const baseState = (input: Partial<State> = {}) =>
|
||||
({
|
||||
|
|
@ -510,7 +505,7 @@ describe("applyDirectoryEvent", () => {
|
|||
expect(store.permission[sessionID]?.map((x) => x.id)).toEqual(["perm_1", "perm_3"])
|
||||
|
||||
applyDirectoryEvent({
|
||||
event: { type: "form.created", properties: { form: questionRequest("q_2", sessionID) } },
|
||||
event: { type: "question.asked", properties: questionRequest("q_2", sessionID) },
|
||||
store,
|
||||
setStore,
|
||||
push() {},
|
||||
|
|
@ -520,18 +515,17 @@ describe("applyDirectoryEvent", () => {
|
|||
expect(store.question[sessionID]?.map((x) => x.id)).toEqual(["q_1", "q_2", "q_3"])
|
||||
|
||||
applyDirectoryEvent({
|
||||
event: { type: "form.created", properties: { form: questionRequest("q_2", sessionID, "updated") } },
|
||||
event: { type: "question.asked", properties: questionRequest("q_2", sessionID, "updated") },
|
||||
store,
|
||||
setStore,
|
||||
push() {},
|
||||
directory: "/tmp",
|
||||
loadLsp() {},
|
||||
})
|
||||
const form = store.question[sessionID]?.find((x) => x.id === "q_2")
|
||||
expect(form?.mode === "form" ? form.fields[0]?.description : undefined).toBe("updated")
|
||||
expect(store.question[sessionID]?.find((x) => x.id === "q_2")?.questions[0]?.header).toBe("updated")
|
||||
|
||||
applyDirectoryEvent({
|
||||
event: { type: "form.cancelled", properties: { sessionID, id: "q_2" } },
|
||||
event: { type: "question.rejected", properties: { sessionID, requestID: "q_2" } },
|
||||
store,
|
||||
setStore,
|
||||
push() {},
|
||||
|
|
@ -541,34 +535,6 @@ describe("applyDirectoryEvent", () => {
|
|||
expect(store.question[sessionID]?.map((x) => x.id)).toEqual(["q_1", "q_3"])
|
||||
})
|
||||
|
||||
test("tracks global form lifecycles when session content is delegated", () => {
|
||||
const [store, setStore] = createStore(baseState())
|
||||
|
||||
applyDirectoryEvent({
|
||||
event: { type: "form.created", properties: { form: questionRequest("q_1", "global") } },
|
||||
store,
|
||||
setStore,
|
||||
push() {},
|
||||
directory: "/tmp",
|
||||
loadLsp() {},
|
||||
sessionContent: false,
|
||||
})
|
||||
|
||||
expect(store.question.global?.map((x) => x.id)).toEqual(["q_1"])
|
||||
|
||||
applyDirectoryEvent({
|
||||
event: { type: "form.cancelled", properties: { sessionID: "global", id: "q_1" } },
|
||||
store,
|
||||
setStore,
|
||||
push() {},
|
||||
directory: "/tmp",
|
||||
loadLsp() {},
|
||||
sessionContent: false,
|
||||
})
|
||||
|
||||
expect(store.question.global).toEqual([])
|
||||
})
|
||||
|
||||
test("updates vcs branch in store and cache", () => {
|
||||
const [store, setStore] = createStore(baseState({ vcs: { branch: "main", default_branch: "main" } }))
|
||||
const [cacheStore, setCacheStore] = createStore({
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import type {
|
|||
Part,
|
||||
PermissionRequest,
|
||||
Project,
|
||||
QuestionRequest,
|
||||
Session,
|
||||
SessionStatus,
|
||||
SnapshotFileDiff,
|
||||
|
|
@ -14,7 +15,6 @@ 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",
|
||||
"form.created",
|
||||
"form.replied",
|
||||
"form.cancelled",
|
||||
"question.asked",
|
||||
"question.replied",
|
||||
"question.rejected",
|
||||
])
|
||||
|
||||
export function applyGlobalEvent(input: {
|
||||
|
|
@ -120,7 +120,7 @@ export function applyDirectoryEvent(input: {
|
|||
permission?: State["permission"]
|
||||
}) {
|
||||
const event = input.event
|
||||
if (input.sessionContent === false && SESSION_CONTENT_EVENTS.has(event.type) && !isGlobalQuestionEvent(event)) return
|
||||
if (input.sessionContent === false && SESSION_CONTENT_EVENTS.has(event.type)) return
|
||||
const limit = Math.max(input.store.limit, input.retainedLimit ?? 0)
|
||||
switch (event.type) {
|
||||
case "server.instance.disposed": {
|
||||
|
|
@ -364,10 +364,8 @@ export function applyDirectoryEvent(input: {
|
|||
)
|
||||
break
|
||||
}
|
||||
case "form.created": {
|
||||
const properties = event.properties as { form?: unknown }
|
||||
if (!isQuestionForm(properties.form)) break
|
||||
const question = properties.form
|
||||
case "question.asked": {
|
||||
const question = event.properties as QuestionRequest
|
||||
const questions = input.store.question[question.sessionID]
|
||||
if (!questions) {
|
||||
input.setStore("question", question.sessionID, [question])
|
||||
|
|
@ -387,12 +385,12 @@ export function applyDirectoryEvent(input: {
|
|||
)
|
||||
break
|
||||
}
|
||||
case "form.replied":
|
||||
case "form.cancelled": {
|
||||
const props = event.properties as { sessionID: string; id: string }
|
||||
case "question.replied":
|
||||
case "question.rejected": {
|
||||
const props = event.properties as { sessionID: string; requestID: string }
|
||||
const questions = input.store.question[props.sessionID]
|
||||
if (!questions) break
|
||||
const result = Binary.search(questions, props.id, (q) => q.id)
|
||||
const result = Binary.search(questions, props.requestID, (q) => q.id)
|
||||
if (!result.found) break
|
||||
input.setStore(
|
||||
"question",
|
||||
|
|
@ -413,13 +411,3 @@ export function applyDirectoryEvent(input: {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function isGlobalQuestionEvent(event: { type: string; properties?: unknown }) {
|
||||
if (event.type === "form.created") {
|
||||
const form = (event.properties as { form?: unknown } | undefined)?.form
|
||||
return isQuestionForm(form) && form.sessionID === "global"
|
||||
}
|
||||
if (event.type !== "form.replied" && event.type !== "form.cancelled") return false
|
||||
const properties = event.properties as { sessionID?: unknown } | undefined
|
||||
return properties?.sessionID === "global"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<string, Message[] | undefined>
|
||||
part: Record<string, Part[] | undefined>
|
||||
permission: Record<string, PermissionRequest[] | undefined>
|
||||
question: Record<string, QuestionForm[] | undefined>
|
||||
question: Record<string, QuestionRequest[] | undefined>
|
||||
part_text_accum_delta: Record<string, string | undefined>
|
||||
} = {
|
||||
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 QuestionForm[] },
|
||||
question: { ses_1: [] as QuestionRequest[] },
|
||||
part_text_accum_delta: { prt_1: "streamed text" },
|
||||
}
|
||||
|
||||
|
|
@ -72,7 +72,7 @@ describe("app session cache", () => {
|
|||
message: Record<string, Message[] | undefined>
|
||||
part: Record<string, Part[] | undefined>
|
||||
permission: Record<string, PermissionRequest[] | undefined>
|
||||
question: Record<string, QuestionForm[] | undefined>
|
||||
question: Record<string, QuestionRequest[] | undefined>
|
||||
part_text_accum_delta: Record<string, string | undefined>
|
||||
} = {
|
||||
session_status: {},
|
||||
|
|
|
|||
|
|
@ -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<string, Message[] | undefined>
|
||||
part: Record<string, Part[] | undefined>
|
||||
permission: Record<string, PermissionRequest[] | undefined>
|
||||
question: Record<string, QuestionForm[] | undefined>
|
||||
question: Record<string, QuestionRequest[] | undefined>
|
||||
part_text_accum_delta: Record<string, string | undefined>
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import type {
|
|||
Part,
|
||||
Path,
|
||||
PermissionRequest,
|
||||
QuestionRequest,
|
||||
ReferenceInfo,
|
||||
Session,
|
||||
SessionStatus,
|
||||
|
|
@ -16,7 +17,6 @@ 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]: QuestionForm[]
|
||||
[sessionID: string]: QuestionRequest[]
|
||||
}
|
||||
mcp_ready: boolean
|
||||
mcp: {
|
||||
|
|
|
|||
|
|
@ -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<string, SnapshotFileDiff[]>,
|
||||
todo: {} as Record<string, Todo[]>,
|
||||
permission: {} as Record<string, PermissionRequest[]>,
|
||||
question: {} as Record<string, QuestionForm[]>,
|
||||
question: {} as Record<string, QuestionRequest[]>,
|
||||
message: {} as Record<string, Message[]>,
|
||||
part: {} as Record<string, Part[]>,
|
||||
part_text_accum_delta: {} as Record<string, string>,
|
||||
|
|
@ -932,10 +932,8 @@ export function createServerSession(client: OpencodeClient, options?: { retry?:
|
|||
)
|
||||
return
|
||||
}
|
||||
case "form.created": {
|
||||
const properties = event.properties as { form?: unknown }
|
||||
if (!isQuestionForm(properties.form)) return
|
||||
const question = properties.form
|
||||
case "question.asked": {
|
||||
const question = event.properties as QuestionRequest
|
||||
const questions = data.question[question.sessionID]
|
||||
if (!questions) {
|
||||
setData("question", question.sessionID, [question])
|
||||
|
|
@ -951,15 +949,15 @@ export function createServerSession(client: OpencodeClient, options?: { retry?:
|
|||
)
|
||||
return
|
||||
}
|
||||
case "form.replied":
|
||||
case "form.cancelled": {
|
||||
const props = event.properties as { sessionID: string; id: string }
|
||||
case "question.replied":
|
||||
case "question.rejected": {
|
||||
const props = event.properties as { sessionID: string; requestID: string }
|
||||
setData(
|
||||
"question",
|
||||
props.sessionID,
|
||||
produce((draft) => {
|
||||
if (!draft) return
|
||||
const result = Binary.search(draft, props.id, (item) => item.id)
|
||||
const result = Binary.search(draft, props.requestID, (item) => item.id)
|
||||
if (result.found) draft.splice(result.index, 1)
|
||||
}),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -25,7 +25,7 @@ import {
|
|||
loadReferencesQuery,
|
||||
} from "./global-sync/bootstrap"
|
||||
import { createChildStoreManager } from "./global-sync/child-store"
|
||||
import { applyDirectoryEvent, applyGlobalEvent, isGlobalQuestionEvent } from "./global-sync/event-reducer"
|
||||
import { applyDirectoryEvent, applyGlobalEvent } from "./global-sync/event-reducer"
|
||||
import { estimateRootSessionTotal, loadRootSessionsWithFallback } from "./global-sync/session-load"
|
||||
import { trimSessions } from "./global-sync/session-trim"
|
||||
import type { ProjectMeta } from "./global-sync/types"
|
||||
|
|
@ -375,7 +375,7 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
|
|||
const event = e.details
|
||||
const recent = bootingRoot || Date.now() - bootedAt < 1500
|
||||
|
||||
if (!isGlobalQuestionEvent(event)) session.apply(event)
|
||||
session.apply(event)
|
||||
|
||||
if (directory === "global") {
|
||||
applyGlobalEvent({
|
||||
|
|
|
|||
|
|
@ -48,7 +48,6 @@ 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"
|
||||
|
|
@ -404,22 +403,25 @@ export default function LegacyLayout(props: ParentProps) {
|
|||
return
|
||||
}
|
||||
|
||||
if (e.details?.type === "form.replied" || e.details?.type === "form.cancelled" || e.details?.type === "permission.replied") {
|
||||
if (
|
||||
e.details?.type === "question.replied" ||
|
||||
e.details?.type === "question.rejected" ||
|
||||
e.details?.type === "permission.replied"
|
||||
) {
|
||||
const props = e.details.properties as { sessionID: string }
|
||||
const sessionKey = `${e.name}:${props.sessionID}`
|
||||
dismissSessionAlert(sessionKey)
|
||||
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
|
||||
if (e.details?.type !== "permission.asked" && e.details?.type !== "question.asked") 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 = questionForm ?? (e.details.properties as { sessionID: string })
|
||||
const props = e.details.properties
|
||||
if (e.details.type === "permission.asked" && permission.autoResponds(e.details.properties, directory)) return
|
||||
|
||||
const [store] = serverSync().child(directory, { bootstrap: false })
|
||||
|
|
@ -448,7 +450,7 @@ export default function LegacyLayout(props: ParentProps) {
|
|||
}
|
||||
}
|
||||
|
||||
if (questionForm) {
|
||||
if (e.details.type === "question.asked") {
|
||||
if (settings.notifications.agent()) {
|
||||
void platform.notify(title, description, href)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
import { describe, expect, test } from "bun:test"
|
||||
import type { PermissionRequest, Session } from "@opencode-ai/sdk/v2/client"
|
||||
import type { QuestionForm } from "@/utils/question-form"
|
||||
import type { PermissionRequest, QuestionRequest, Session } from "@opencode-ai/sdk/v2/client"
|
||||
import { todoDockAtBoundary, todoState } from "./session-composer-state"
|
||||
import { sessionPermissionRequest, sessionQuestionRequest } from "./session-request-tree"
|
||||
|
||||
|
|
@ -20,10 +19,8 @@ const question = (id: string, sessionID: string) =>
|
|||
({
|
||||
id,
|
||||
sessionID,
|
||||
mode: "form",
|
||||
metadata: { kind: "question" },
|
||||
fields: [],
|
||||
}) as QuestionForm
|
||||
questions: [],
|
||||
}) as QuestionRequest
|
||||
|
||||
describe("sessionPermissionRequest", () => {
|
||||
test("prefers the current session permission", () => {
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
import { createEffect, createMemo, on, onCleanup } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import type { PermissionRequest, Todo } from "@opencode-ai/sdk/v2"
|
||||
import type { QuestionForm } from "@/utils/question-form"
|
||||
import type { PermissionRequest, QuestionRequest, Todo } from "@opencode-ai/sdk/v2"
|
||||
import { useParams } from "@solidjs/router"
|
||||
import { showToast } from "@/utils/toast"
|
||||
import { useServerSync } from "@/context/server-sync"
|
||||
|
|
@ -34,7 +33,7 @@ export function createSessionComposerController(options?: { closeMs?: number | (
|
|||
const language = useLanguage()
|
||||
const permission = usePermission()
|
||||
|
||||
const questionRequest = createMemo((): QuestionForm | undefined => {
|
||||
const questionRequest = createMemo((): QuestionRequest | undefined => {
|
||||
return sessionQuestionRequest(sync().data.session, sync().data.question, params.id)
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -6,21 +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 {
|
||||
questionAllowsCustom,
|
||||
questionAnswer,
|
||||
questionLabel,
|
||||
questionMessage,
|
||||
questionOptions,
|
||||
type QuestionAnswer,
|
||||
type QuestionForm,
|
||||
} from "@/utils/question-form"
|
||||
|
||||
const cache = new Map<string, { tab: number; answers: QuestionAnswer[]; custom: string[]; customOn: boolean[] }>()
|
||||
|
||||
|
|
@ -69,14 +61,14 @@ function Option(props: {
|
|||
)
|
||||
}
|
||||
|
||||
export const SessionQuestionDock: Component<{ request: QuestionForm; onSubmit: () => void }> = (props) => {
|
||||
export const SessionQuestionDock: Component<{ request: QuestionRequest; 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.mode === "form" ? props.request.fields : []))
|
||||
const total = createMemo(() => (props.request.mode === "url" ? 1 : questions().length))
|
||||
const questions = createMemo(() => props.request.questions)
|
||||
const total = createMemo(() => questions().length)
|
||||
|
||||
const cached = cache.get(cacheKey)
|
||||
const [store, setStore] = createStore({
|
||||
|
|
@ -98,23 +90,16 @@ export const SessionQuestionDock: Component<{ request: QuestionForm; onSubmit: (
|
|||
let focusFrame: number | undefined
|
||||
|
||||
const question = createMemo(() => questions()[store.tab])
|
||||
const options = createMemo(() => questionOptions(question()))
|
||||
const custom = createMemo(() => questionAllowsCustom(question()))
|
||||
const options = createMemo(() => question()?.options ?? [])
|
||||
const input = createMemo(() => store.custom[store.tab] ?? "")
|
||||
const on = createMemo(() => custom() && store.customOn[store.tab] === true)
|
||||
const multi = createMemo(() => question()?.type === "multiselect")
|
||||
const count = createMemo(() => options().length + (custom() ? 1 : 0))
|
||||
const on = createMemo(() => store.customOn[store.tab] === true)
|
||||
const multi = createMemo(() => question()?.multiple === true)
|
||||
const count = createMemo(() => options().length + 1)
|
||||
|
||||
const summary = createMemo(() => {
|
||||
if (props.request.title) return props.request.title
|
||||
const n = Math.min(store.tab + 1, total())
|
||||
return language.t("session.question.progress", { current: n, total: total() })
|
||||
})
|
||||
const body = createMemo(() => {
|
||||
if (props.request.mode === "url") return props.request.title ?? "Open URL request"
|
||||
return questionLabel(question())
|
||||
})
|
||||
const message = createMemo(() => questionMessage(props.request))
|
||||
const customLabel = () => language.t("ui.messagePart.option.typeOwnAnswer")
|
||||
const customPlaceholder = () => language.t("ui.question.custom.placeholder")
|
||||
|
||||
|
|
@ -168,11 +153,11 @@ export const SessionQuestionDock: Component<{ request: QuestionForm; onSubmit: (
|
|||
const clamp = (i: number) => Math.max(0, Math.min(count() - 1, i))
|
||||
|
||||
const pickFocus = (tab: number = store.tab) => {
|
||||
const list = questionOptions(questions()[tab])
|
||||
if (questionAllowsCustom(questions()[tab]) && store.customOn[tab] === true) return list.length
|
||||
const list = questions()[tab]?.options ?? []
|
||||
if (store.customOn[tab] === true) return list.length
|
||||
return Math.max(
|
||||
0,
|
||||
list.findIndex((item) => store.answers[tab]?.includes(item.value) ?? false),
|
||||
list.findIndex((item) => store.answers[tab]?.includes(item.label) ?? false),
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -238,12 +223,7 @@ export const SessionQuestionDock: Component<{ request: QuestionForm; onSubmit: (
|
|||
}
|
||||
|
||||
const replyMutation = useMutation(() => ({
|
||||
mutationFn: (answers: QuestionAnswer[]) =>
|
||||
sdk().client.v2.session.form.reply({
|
||||
sessionID: props.request.sessionID,
|
||||
formID: props.request.id,
|
||||
formReply: { answer: props.request.mode === "url" ? {} : questionAnswer(props.request.fields, answers) },
|
||||
}),
|
||||
mutationFn: (answers: QuestionAnswer[]) => sdk().client.question.reply({ requestID: props.request.id, answers }),
|
||||
onMutate: () => {
|
||||
props.onSubmit()
|
||||
},
|
||||
|
|
@ -255,7 +235,7 @@ export const SessionQuestionDock: Component<{ request: QuestionForm; onSubmit: (
|
|||
}))
|
||||
|
||||
const rejectMutation = useMutation(() => ({
|
||||
mutationFn: () => sdk().client.v2.session.form.cancel({ sessionID: props.request.sessionID, formID: props.request.id }),
|
||||
mutationFn: () => sdk().client.question.reject({ requestID: props.request.id }),
|
||||
onMutate: () => {
|
||||
props.onSubmit()
|
||||
},
|
||||
|
|
@ -282,7 +262,7 @@ export const SessionQuestionDock: Component<{ request: QuestionForm; onSubmit: (
|
|||
|
||||
const answered = (i: number) => {
|
||||
if ((store.answers[i]?.length ?? 0) > 0) return true
|
||||
return questionAllowsCustom(questions()[i]) && store.customOn[i] === true && (store.custom[i] ?? "").trim().length > 0
|
||||
return store.customOn[i] === true && (store.custom[i] ?? "").trim().length > 0
|
||||
}
|
||||
|
||||
const picked = (answer: string) => store.answers[store.tab]?.includes(answer) ?? false
|
||||
|
|
@ -303,7 +283,6 @@ export const SessionQuestionDock: Component<{ request: QuestionForm; onSubmit: (
|
|||
|
||||
const customToggle = () => {
|
||||
if (sending()) return
|
||||
if (!custom()) return
|
||||
setStore("focus", options().length)
|
||||
|
||||
if (!multi()) {
|
||||
|
|
@ -329,7 +308,6 @@ export const SessionQuestionDock: Component<{ request: QuestionForm; onSubmit: (
|
|||
|
||||
const customOpen = () => {
|
||||
if (sending()) return
|
||||
if (!custom()) return
|
||||
setStore("focus", options().length)
|
||||
if (!on()) setStore("customOn", store.tab, true)
|
||||
setStore("editing", true)
|
||||
|
|
@ -391,7 +369,6 @@ export const SessionQuestionDock: Component<{ request: QuestionForm; onSubmit: (
|
|||
if (sending()) return
|
||||
|
||||
if (optIndex === options().length) {
|
||||
if (!custom()) return
|
||||
customOpen()
|
||||
return
|
||||
}
|
||||
|
|
@ -400,10 +377,10 @@ export const SessionQuestionDock: Component<{ request: QuestionForm; onSubmit: (
|
|||
if (!opt) return
|
||||
if (multi()) {
|
||||
setStore("editing", false)
|
||||
toggle(opt.value)
|
||||
toggle(opt.label)
|
||||
return
|
||||
}
|
||||
pick(opt.value)
|
||||
pick(opt.label)
|
||||
}
|
||||
|
||||
const commitCustom = () => {
|
||||
|
|
@ -549,24 +526,11 @@ export const SessionQuestionDock: Component<{ request: QuestionForm; onSubmit: (
|
|||
overflow: store.minimized ? "hidden" : undefined,
|
||||
}}
|
||||
>
|
||||
{body()}
|
||||
{question()?.question}
|
||||
</div>
|
||||
<Show when={message()}>
|
||||
<div data-slot="question-hint">{message()}</div>
|
||||
</Show>
|
||||
<Show when={!store.minimized}>
|
||||
<Show
|
||||
when={props.request.mode === "url"}
|
||||
fallback={
|
||||
<Show when={multi()} fallback={<div data-slot="question-hint">{language.t("ui.question.singleHint")}</div>}>
|
||||
<div data-slot="question-hint">{language.t("ui.question.multiHint")}</div>
|
||||
</Show>
|
||||
}
|
||||
>
|
||||
<div data-slot="question-hint">Open this URL, complete the request, then submit.</div>
|
||||
<a href={props.request.mode === "url" ? props.request.url : undefined} target="_blank" rel="noreferrer">
|
||||
{props.request.mode === "url" ? props.request.url : ""}
|
||||
</a>
|
||||
<Show when={multi()} fallback={<div data-slot="question-hint">{language.t("ui.question.singleHint")}</div>}>
|
||||
<div data-slot="question-hint">{language.t("ui.question.multiHint")}</div>
|
||||
</Show>
|
||||
</Show>
|
||||
<div
|
||||
|
|
@ -584,7 +548,7 @@ export const SessionQuestionDock: Component<{ request: QuestionForm; onSubmit: (
|
|||
{(opt, i) => (
|
||||
<Option
|
||||
multi={multi()}
|
||||
picked={picked(opt.value)}
|
||||
picked={picked(opt.label)}
|
||||
label={opt.label}
|
||||
description={opt.description}
|
||||
disabled={sending()}
|
||||
|
|
@ -595,80 +559,78 @@ export const SessionQuestionDock: Component<{ request: QuestionForm; onSubmit: (
|
|||
)}
|
||||
</For>
|
||||
|
||||
<Show when={custom()}>
|
||||
<Show
|
||||
when={store.editing}
|
||||
fallback={
|
||||
<button
|
||||
type="button"
|
||||
ref={customRef}
|
||||
data-slot="question-option"
|
||||
data-custom="true"
|
||||
data-picked={on()}
|
||||
role={multi() ? "checkbox" : "radio"}
|
||||
aria-checked={on()}
|
||||
disabled={sending()}
|
||||
onFocus={() => setStore("focus", options().length)}
|
||||
onClick={customOpen}
|
||||
>
|
||||
<Mark multi={multi()} picked={on()} onClick={toggleCustomMark} />
|
||||
<span data-slot="question-option-main">
|
||||
<span data-slot="option-label">{customLabel()}</span>
|
||||
<span data-slot="option-description">{input() || customPlaceholder()}</span>
|
||||
</span>
|
||||
</button>
|
||||
}
|
||||
>
|
||||
<form
|
||||
<Show
|
||||
when={store.editing}
|
||||
fallback={
|
||||
<button
|
||||
type="button"
|
||||
ref={customRef}
|
||||
data-slot="question-option"
|
||||
data-custom="true"
|
||||
data-picked={on()}
|
||||
role={multi() ? "checkbox" : "radio"}
|
||||
aria-checked={on()}
|
||||
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()
|
||||
}}
|
||||
disabled={sending()}
|
||||
onFocus={() => setStore("focus", options().length)}
|
||||
onClick={customOpen}
|
||||
>
|
||||
<Mark multi={multi()} picked={on()} onClick={toggleCustomMark} />
|
||||
<span data-slot="question-option-main">
|
||||
<span data-slot="option-label">{customLabel()}</span>
|
||||
<textarea
|
||||
ref={focusCustom}
|
||||
data-slot="question-custom-input"
|
||||
placeholder={customPlaceholder()}
|
||||
value={input()}
|
||||
rows={1}
|
||||
disabled={sending()}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault()
|
||||
setStore("editing", false)
|
||||
focus(options().length)
|
||||
return
|
||||
}
|
||||
if ((e.metaKey || e.ctrlKey) && !e.altKey) return
|
||||
if (e.key !== "Enter" || e.shiftKey) return
|
||||
e.preventDefault()
|
||||
commitCustom()
|
||||
}}
|
||||
onInput={(e) => {
|
||||
customUpdate(e.currentTarget.value)
|
||||
resizeInput(e.currentTarget)
|
||||
}}
|
||||
/>
|
||||
<span data-slot="option-description">{input() || customPlaceholder()}</span>
|
||||
</span>
|
||||
</form>
|
||||
</Show>
|
||||
</button>
|
||||
}
|
||||
>
|
||||
<form
|
||||
data-slot="question-option"
|
||||
data-custom="true"
|
||||
data-picked={on()}
|
||||
role={multi() ? "checkbox" : "radio"}
|
||||
aria-checked={on()}
|
||||
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()
|
||||
}}
|
||||
>
|
||||
<Mark multi={multi()} picked={on()} onClick={toggleCustomMark} />
|
||||
<span data-slot="question-option-main">
|
||||
<span data-slot="option-label">{customLabel()}</span>
|
||||
<textarea
|
||||
ref={focusCustom}
|
||||
data-slot="question-custom-input"
|
||||
placeholder={customPlaceholder()}
|
||||
value={input()}
|
||||
rows={1}
|
||||
disabled={sending()}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault()
|
||||
setStore("editing", false)
|
||||
focus(options().length)
|
||||
return
|
||||
}
|
||||
if ((e.metaKey || e.ctrlKey) && !e.altKey) return
|
||||
if (e.key !== "Enter" || e.shiftKey) return
|
||||
e.preventDefault()
|
||||
commitCustom()
|
||||
}}
|
||||
onInput={(e) => {
|
||||
customUpdate(e.currentTarget.value)
|
||||
resizeInput(e.currentTarget)
|
||||
}}
|
||||
/>
|
||||
</span>
|
||||
</form>
|
||||
</Show>
|
||||
</div>
|
||||
</DockPrompt>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
import type { PermissionRequest, Session } from "@opencode-ai/sdk/v2/client"
|
||||
import type { QuestionForm } from "@/utils/question-form"
|
||||
import type { PermissionRequest, QuestionRequest, Session } from "@opencode-ai/sdk/v2/client"
|
||||
|
||||
function sessionTreeRequest<T>(
|
||||
session: Session[],
|
||||
|
|
@ -45,9 +44,9 @@ export function sessionPermissionRequest(
|
|||
|
||||
export function sessionQuestionRequest(
|
||||
session: Session[],
|
||||
request: Record<string, QuestionForm[] | undefined>,
|
||||
request: Record<string, QuestionRequest[] | undefined>,
|
||||
sessionID?: string,
|
||||
include?: (item: QuestionForm) => boolean,
|
||||
include?: (item: QuestionRequest) => boolean,
|
||||
) {
|
||||
return sessionTreeRequest(session, request, sessionID, include) ?? request.global?.find(include ?? (() => true))
|
||||
return sessionTreeRequest(session, request, sessionID, include)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,36 +0,0 @@
|
|||
import { describe, expect, test } from "bun:test"
|
||||
import { questionAnswer, type QuestionField } from "./question-form"
|
||||
|
||||
describe("questionAnswer", () => {
|
||||
test("falls back to field defaults", () => {
|
||||
const fields = [
|
||||
{ key: "name", type: "string", default: "Ada" },
|
||||
{ key: "age", type: "integer", default: 42 },
|
||||
{ key: "newsletter", type: "boolean", default: false },
|
||||
{ key: "colors", type: "multiselect", options: [], default: ["red"] },
|
||||
] satisfies QuestionField[]
|
||||
|
||||
expect(questionAnswer(fields, [[], [], [], []])).toEqual({
|
||||
name: "Ada",
|
||||
age: 42,
|
||||
newsletter: false,
|
||||
colors: ["red"],
|
||||
})
|
||||
})
|
||||
|
||||
test("uses explicit answers over defaults", () => {
|
||||
const fields = [
|
||||
{ key: "name", type: "string", default: "Ada" },
|
||||
{ key: "age", type: "number", default: 42 },
|
||||
{ key: "newsletter", type: "boolean", default: false },
|
||||
{ key: "colors", type: "multiselect", options: [], default: ["red"] },
|
||||
] satisfies QuestionField[]
|
||||
|
||||
expect(questionAnswer(fields, [["Grace"], ["36"], ["true"], ["blue"]])).toEqual({
|
||||
name: "Grace",
|
||||
age: 36,
|
||||
newsletter: true,
|
||||
colors: ["blue"],
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -1,72 +0,0 @@
|
|||
import type { FormAnswer, FormFormInfo, FormUrlInfo } from "@opencode-ai/sdk/v2"
|
||||
|
||||
export type QuestionOption = {
|
||||
value: string
|
||||
label: string
|
||||
description?: string
|
||||
}
|
||||
|
||||
export type QuestionField = FormFormInfo["fields"][number]
|
||||
|
||||
export type QuestionForm = FormFormInfo | FormUrlInfo
|
||||
|
||||
export type QuestionAnswer = string[]
|
||||
|
||||
export function isQuestionForm(value: unknown): value is QuestionForm {
|
||||
if (typeof value !== "object" || value === null) return false
|
||||
const form = value as { id?: unknown; sessionID?: unknown; mode?: unknown; fields?: unknown; url?: unknown }
|
||||
if (typeof form.id !== "string" || typeof form.sessionID !== "string") return false
|
||||
if (form.mode === "url") return typeof form.url === "string"
|
||||
if (form.mode !== "form") return false
|
||||
return Array.isArray(form.fields) && form.fields.every(isQuestionField)
|
||||
}
|
||||
|
||||
function isQuestionField(value: unknown): value is QuestionField {
|
||||
if (typeof value !== "object" || value === null) return false
|
||||
const field = value as { type?: unknown }
|
||||
return ["string", "number", "integer", "boolean", "multiselect"].includes(String(field.type))
|
||||
}
|
||||
|
||||
export function questionAnswer(fields: ReadonlyArray<QuestionField>, answers: ReadonlyArray<QuestionAnswer>): FormAnswer {
|
||||
const entries = fields.flatMap((field, index): ReadonlyArray<readonly [string, FormAnswer[string]]> => {
|
||||
const answer = answers[index] ?? []
|
||||
if (answer.length === 0) {
|
||||
if (field.default === undefined) return []
|
||||
if (field.type === "multiselect") return [[field.key, [...field.default]]]
|
||||
return [[field.key, field.default]]
|
||||
}
|
||||
if (field.type === "multiselect") return [[field.key, answer]]
|
||||
if (field.type === "boolean") return [[field.key, answer[0] === "true"]]
|
||||
if (field.type === "number" || field.type === "integer") return [[field.key, Number(answer[0])]]
|
||||
return [[field.key, answer[0] ?? ""]]
|
||||
})
|
||||
return Object.fromEntries(entries)
|
||||
}
|
||||
|
||||
export function questionOptions(field: QuestionField | undefined): QuestionOption[] {
|
||||
if (!field) return []
|
||||
if (field.type === "boolean")
|
||||
return [
|
||||
{ value: "false", label: "No" },
|
||||
{ value: "true", label: "Yes" },
|
||||
]
|
||||
if (field.type === "string") return field.options ?? []
|
||||
if (field.type === "multiselect") return field.options
|
||||
return []
|
||||
}
|
||||
|
||||
export function questionAllowsCustom(field: QuestionField | undefined) {
|
||||
if (!field) return false
|
||||
if (field.type === "number" || field.type === "integer") return true
|
||||
if (field.type !== "string" && field.type !== "multiselect") return false
|
||||
return questionOptions(field).length === 0 || field.custom === true
|
||||
}
|
||||
|
||||
export function questionLabel(field: QuestionField | undefined) {
|
||||
return field?.title ?? field?.description ?? field?.key ?? "Form"
|
||||
}
|
||||
|
||||
export function questionMessage(form: QuestionForm) {
|
||||
const message = form.metadata?.message
|
||||
return typeof message === "string" && message !== form.title ? message : undefined
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue