diff --git a/packages/app/src/pages/session/composer/session-composer-region.tsx b/packages/app/src/pages/session/composer/session-composer-region.tsx index 600ff41e3def..e3433d6b6eac 100644 --- a/packages/app/src/pages/session/composer/session-composer-region.tsx +++ b/packages/app/src/pages/session/composer/session-composer-region.tsx @@ -39,7 +39,12 @@ export function SessionComposerRegion(props: { {(request) => (
- +
)}
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 49efcc971a9b..40c56ba1f7b5 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 @@ -81,14 +81,14 @@ describe("sessionPermissionRequest", () => { }) describe("sessionQuestionRequest", () => { - test("prefers the current session question", () => { + test("returns the oldest question across the session tree", () => { const sessions = [session({ id: "root" }), session({ id: "child", parentID: "root" })] const questions = { - root: [question("q-root", "root")], - child: [question("q-child", "child")], + root: [question("que_02", "root")], + child: [question("que_01", "child")], } - expect(sessionQuestionRequest(sessions, questions, "root")?.id).toBe("q-root") + expect(sessionQuestionRequest(sessions, questions, "root")?.id).toBe("que_01") }) test("returns a nested child question", () => { 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 45f5e4cb26ff..e26e8c80d1ec 100644 --- a/packages/app/src/pages/session/composer/session-composer-state.ts +++ b/packages/app/src/pages/session/composer/session-composer-state.ts @@ -8,7 +8,7 @@ import { useLanguage } from "@/context/language" import { usePermission } from "@/context/permission" import { useSDK } from "@/context/sdk" import { useSync } from "@/context/sync" -import { sessionPermissionRequest, sessionQuestionRequest } from "./session-request-tree" +import { activeQuestion, sessionPermissionRequest, sessionQuestionRequests } from "./session-request-tree" export const todoState = (input: { count: number @@ -33,8 +33,27 @@ export function createSessionComposerController(options?: { closeMs?: number | ( const language = useLanguage() const permission = usePermission() + const questionSelection = { + sessionID: params.id, + requestID: undefined as string | undefined, + } + const questionRequests = createMemo(() => { + return sessionQuestionRequests(sync().data.session, sync().data.question, params.id) + }) const questionRequest = createMemo((): QuestionRequest | undefined => { - return sessionQuestionRequest(sync().data.session, sync().data.question, params.id) + if (questionSelection.sessionID !== params.id) { + questionSelection.sessionID = params.id + questionSelection.requestID = undefined + } + const request = activeQuestion(questionRequests(), questionSelection.requestID) + questionSelection.requestID = request?.id + return request + }) + const questionLocation = createMemo(() => { + const request = questionRequest() + if (!request) return + const session = serverSync().session.get(request.sessionID) + return { directory: session?.directory, workspace: session?.workspaceID } }) const permissionRequest = createMemo((): PermissionRequest | undefined => { @@ -188,6 +207,7 @@ export function createSessionComposerController(options?: { closeMs?: number | ( return { blocked, questionRequest, + questionLocation, permissionRequest, permissionResponding, decide, 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 445a9f47a082..1f4f275fb6c5 100644 --- a/packages/app/src/pages/session/composer/session-question-dock.tsx +++ b/packages/app/src/pages/session/composer/session-question-dock.tsx @@ -61,7 +61,12 @@ function Option(props: { ) } -export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit: () => void }> = (props) => { +export const SessionQuestionDock: Component<{ + request: QuestionRequest + directory?: string + workspace?: string + onSubmit: () => void +}> = (props) => { const sdk = useSDK() const serverSDK = useServerSDK() const language = useLanguage() @@ -223,7 +228,13 @@ 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.question.reply({ + requestID: props.request.id, + directory: props.directory, + workspace: props.workspace, + answers, + }), onMutate: () => { props.onSubmit() }, @@ -235,7 +246,12 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit })) const rejectMutation = useMutation(() => ({ - mutationFn: () => sdk().client.question.reject({ requestID: props.request.id }), + mutationFn: () => + sdk().client.question.reject({ + requestID: props.request.id, + directory: props.directory, + workspace: props.workspace, + }), onMutate: () => { props.onSubmit() }, diff --git a/packages/app/src/pages/session/composer/session-request-tree.test.ts b/packages/app/src/pages/session/composer/session-request-tree.test.ts new file mode 100644 index 000000000000..8e1b890fc90c --- /dev/null +++ b/packages/app/src/pages/session/composer/session-request-tree.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, test } from "bun:test" +import type { QuestionRequest, Session } from "@opencode-ai/sdk/v2/client" +import { activeQuestion, sessionQuestionRequests } from "./session-request-tree" + +const session = (id: string, parentID?: string) => ({ id, parentID }) as Session + +const question = (id: string, sessionID: string) => + ({ + id, + sessionID, + questions: [], + }) as QuestionRequest + +describe("sessionQuestionRequests", () => { + test("orders root and nested subagent questions globally", () => { + const sessions = [ + session("root"), + session("child-b", "root"), + session("child-a", "root"), + session("grand", "child-a"), + ] + const questions = { + root: [question("que_04", "root")], + "child-a": [question("que_03", "child-a")], + "child-b": [question("que_01", "child-b")], + grand: [question("que_02", "grand")], + } + + expect(sessionQuestionRequests(sessions, questions, "root").map((item) => item.id)).toEqual([ + "que_01", + "que_02", + "que_03", + "que_04", + ]) + }) + + test("keeps the active request until it leaves the queue", () => { + const current = question("que_02", "child-b") + const queue = [question("que_01", "child-a"), current, question("que_03", "root")] + + expect(activeQuestion(queue, current.id)).toBe(current) + expect( + activeQuestion( + queue.filter((item) => item.id !== current.id), + current.id, + )?.id, + ).toBe("que_01") + }) + + test("does not include questions from another session tree", () => { + const sessions = [session("root"), session("child", "root"), session("other")] + const questions = { + child: [question("que_01", "child")], + other: [question("que_00", "other")], + } + + expect(sessionQuestionRequests(sessions, questions, "root").map((item) => item.id)).toEqual(["que_01"]) + }) +}) diff --git a/packages/app/src/pages/session/composer/session-request-tree.ts b/packages/app/src/pages/session/composer/session-request-tree.ts index 03872c091c0e..ca9648cc9a72 100644 --- a/packages/app/src/pages/session/composer/session-request-tree.ts +++ b/packages/app/src/pages/session/composer/session-request-tree.ts @@ -1,11 +1,6 @@ import type { PermissionRequest, QuestionRequest, Session } from "@opencode-ai/sdk/v2/client" -function sessionTreeRequest( - session: Session[], - request: Record, - sessionID?: string, - include: (item: T) => boolean = () => true, -) { +function sessionTreeIDs(session: Session[], sessionID?: string) { if (!sessionID) return const map = session.reduce((acc, item) => { @@ -28,9 +23,18 @@ function sessionTreeRequest( } } - const id = ids.find((id) => request[id]?.some(include)) - if (!id) return - return request[id]?.find(include) + return ids +} + +function sessionTreeRequest( + session: Session[], + request: Record, + sessionID?: string, + include: (item: T) => boolean = () => true, +) { + const ids = sessionTreeIDs(session, sessionID) + if (!ids) return + return ids.flatMap((id) => request[id] ?? []).find(include) } export function sessionPermissionRequest( @@ -48,5 +52,23 @@ export function sessionQuestionRequest( sessionID?: string, include?: (item: QuestionRequest) => boolean, ) { - return sessionTreeRequest(session, request, sessionID, include) + return sessionQuestionRequests(session, request, sessionID, include)[0] +} + +export function sessionQuestionRequests( + session: Session[], + request: Record, + sessionID?: string, + include: (item: QuestionRequest) => boolean = () => true, +) { + const ids = sessionTreeIDs(session, sessionID) + if (!ids) return [] + return ids + .flatMap((id) => request[id] ?? []) + .filter(include) + .toSorted((a, b) => a.id.localeCompare(b.id)) +} + +export function activeQuestion(queue: QuestionRequest[], requestID?: string) { + return queue.find((request) => request.id === requestID) ?? queue[0] } diff --git a/packages/tui/src/routes/session/index.tsx b/packages/tui/src/routes/session/index.tsx index 6d77b0ea58fd..4ccd8e08238e 100644 --- a/packages/tui/src/routes/session/index.tsx +++ b/packages/tui/src/routes/session/index.tsx @@ -65,7 +65,8 @@ import { usePromptRef } from "../../context/prompt" import { useEpilogue } from "../../context/epilogue" import { normalizePath } from "../../util/path" import { PermissionPrompt } from "./permission" -import { QuestionPrompt } from "./question" +import { clearQuestionDraft, QuestionPrompt } from "./question" +import { activeQuestion, sessionQuestionQueue } from "./question-queue" import { DialogExportOptions } from "../../ui/dialog-export-options" import * as Model from "../../util/model" import { formatTranscript } from "../../util/transcript" @@ -230,7 +231,22 @@ export function Session() { }) const questions = createMemo(() => { if (session()?.parentID) return [] - return children().flatMap((x) => sync.data.question[x.id] ?? []) + return sessionQuestionQueue(sync.data.session, sync.data.question, session()?.id) + }) + const knownQuestions = { ids: new Set() } + createEffect(() => { + const next = new Set(questions().map((request) => request.id)) + for (const id of knownQuestions.ids) { + if (!next.has(id)) clearQuestionDraft(id) + } + knownQuestions.ids = next + }) + const [activeQuestionID, setActiveQuestionID] = createSignal() + const question = createMemo(() => activeQuestion(questions(), activeQuestionID())) + createEffect(() => { + const next = question()?.id + if (next === activeQuestionID()) return + setActiveQuestionID(next) }) const visible = createMemo(() => !session()?.parentID && permissions().length === 0 && questions().length === 0) const disabled = createMemo(() => permissions().length > 0 || questions().length > 0) @@ -1286,11 +1302,14 @@ export function Session() { directory={sync.session.get(permissions()[0].sessionID)?.directory} /> - 0}> - + + {(request) => ( + + )} diff --git a/packages/tui/src/routes/session/question-queue.ts b/packages/tui/src/routes/session/question-queue.ts new file mode 100644 index 000000000000..f4d3d41d14b1 --- /dev/null +++ b/packages/tui/src/routes/session/question-queue.ts @@ -0,0 +1,32 @@ +import type { QuestionRequest, Session } from "@opencode-ai/sdk/v2" + +export function sessionQuestionQueue( + sessions: Session[], + questions: Record, + sessionID?: string, +) { + if (!sessionID) return [] + + const children = sessions.reduce((result, session) => { + if (!session.parentID) return result + const existing = result.get(session.parentID) + if (existing) existing.push(session.id) + if (!existing) result.set(session.parentID, [session.id]) + return result + }, new Map()) + const ids = [sessionID] + const seen = new Set(ids) + for (const id of ids) { + for (const child of children.get(id) ?? []) { + if (seen.has(child)) continue + seen.add(child) + ids.push(child) + } + } + + return ids.flatMap((id) => questions[id] ?? []).toSorted((a, b) => a.id.localeCompare(b.id)) +} + +export function activeQuestion(queue: QuestionRequest[], requestID?: string) { + return queue.find((request) => request.id === requestID) ?? queue[0] +} diff --git a/packages/tui/src/routes/session/question.tsx b/packages/tui/src/routes/session/question.tsx index 191d0a936a91..19b46427932f 100644 --- a/packages/tui/src/routes/session/question.tsx +++ b/packages/tui/src/routes/session/question.tsx @@ -8,29 +8,64 @@ import { useSDK } from "../../context/sdk" import { SplitBorder } from "../../ui/border" import { useTuiConfig } from "../../config" import { useBindings, useOpencodeModeStack } from "../../keymap" +import { useToast } from "../../ui/toast" const QUESTION_MODE = "question" -export function QuestionPrompt(props: { request: QuestionRequest; directory?: string }) { +type Draft = { + tab: number + answers: QuestionAnswer[] + custom: string[] + buffers: (string | undefined)[] + selected: number + editing: boolean +} + +const drafts = new Map() + +export function clearQuestionDraft(requestID: string) { + drafts.delete(requestID) +} + +export function QuestionPrompt(props: { request: QuestionRequest; directory?: string; workspace?: string }) { const sdk = useSDK() + const toast = useToast() const { theme } = useTheme() const renderer = useRenderer() const tuiConfig = useTuiConfig() const modeStack = useOpencodeModeStack() + const draft = drafts.get(props.request.id) const questions = createMemo(() => props.request.questions) const single = createMemo(() => questions().length === 1 && questions()[0]?.multiple !== true) const tabs = createMemo(() => (single() ? 1 : questions().length + 1)) // questions + confirm tab (no confirm for single select) const [tabHover, setTabHover] = createSignal(null) const [store, setStore] = createStore({ - tab: 0, - answers: [] as QuestionAnswer[], - custom: [] as string[], - selected: 0, - editing: false, + tab: draft?.tab ?? 0, + answers: draft?.answers ?? ([] as QuestionAnswer[]), + custom: draft?.custom ?? ([] as string[]), + buffers: draft?.buffers ?? ([] as (string | undefined)[]), + selected: draft?.selected ?? 0, + editing: draft?.editing ?? false, + submitting: false, }) let textarea: TextareaRenderable | undefined + const lifecycle = { settled: false } + + onCleanup(() => { + if (lifecycle.settled) return + const buffers = [...store.buffers] + if (store.editing && textarea) buffers[store.tab] = textarea.plainText + drafts.set(props.request.id, { + tab: store.tab, + answers: store.answers.map((answer) => (answer ? [...answer] : [])), + custom: store.custom.map((value) => value ?? ""), + buffers, + selected: store.selected, + editing: store.editing, + }) + }) const question = createMemo(() => questions()[store.tab]) const confirm = createMemo(() => !single() && store.tab === questions().length) @@ -45,20 +80,44 @@ export function QuestionPrompt(props: { request: QuestionRequest; directory?: st return store.answers[store.tab]?.includes(value) ?? false }) + function reply(answers: QuestionAnswer[]) { + if (store.submitting) return + const requestID = props.request.id + const directory = props.directory + const workspace = props.workspace + setStore("submitting", true) + void sdk.client.question + .reply({ requestID, directory, workspace, answers }, { throwOnError: true }) + .then(() => { + lifecycle.settled = true + drafts.delete(requestID) + }) + .catch((error: unknown) => { + setStore("submitting", false) + toast.error(error) + }) + } + function submit() { - const answers = questions().map((_, i) => store.answers[i] ?? []) - void sdk.client.question.reply({ - requestID: props.request.id, - directory: props.directory, - answers, - }) + reply(questions().map((_, i) => store.answers[i] ?? [])) } function reject() { - void sdk.client.question.reject({ - requestID: props.request.id, - directory: props.directory, - }) + if (store.submitting) return + const requestID = props.request.id + const directory = props.directory + const workspace = props.workspace + setStore("submitting", true) + void sdk.client.question + .reject({ requestID, directory, workspace }, { throwOnError: true }) + .then(() => { + lifecycle.settled = true + drafts.delete(requestID) + }) + .catch((error: unknown) => { + setStore("submitting", false) + toast.error(error) + }) } function pick(answer: string, custom: boolean = false) { @@ -71,11 +130,7 @@ export function QuestionPrompt(props: { request: QuestionRequest; directory?: st setStore("custom", inputs) } if (single()) { - void sdk.client.question.reply({ - requestID: props.request.id, - directory: props.directory, - answers: [[answer]], - }) + reply([[answer]]) return } setStore("tab", store.tab + 1) @@ -141,6 +196,7 @@ export function QuestionPrompt(props: { request: QuestionRequest; directory?: st run() { const text = textarea?.plainText ?? "" if (!text) { + setStore("buffers", store.tab, undefined) setStore("editing", false) return } @@ -154,6 +210,7 @@ export function QuestionPrompt(props: { request: QuestionRequest; directory?: st desc: "Cancel answer edit", group: "Question", cmd: () => { + setStore("buffers", store.tab, undefined) setStore("editing", false) }, }, @@ -165,6 +222,7 @@ export function QuestionPrompt(props: { request: QuestionRequest; directory?: st cmd: () => { const text = textarea?.plainText?.trim() ?? "" const prev = store.custom[store.tab] + setStore("buffers", store.tab, undefined) if (!text) { if (prev) { @@ -433,7 +491,7 @@ export function QuestionPrompt(props: { request: QuestionRequest; directory?: st val.gotoLineEnd() }) }} - initialValue={input()} + initialValue={store.buffers[store.tab] ?? input()} placeholder="Type your own answer" placeholderColor={theme.textMuted} minHeight={1} diff --git a/packages/tui/test/routes/session/question-queue.test.ts b/packages/tui/test/routes/session/question-queue.test.ts new file mode 100644 index 000000000000..b2ce6739c28c --- /dev/null +++ b/packages/tui/test/routes/session/question-queue.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, test } from "bun:test" +import type { QuestionRequest, Session } from "@opencode-ai/sdk/v2" +import { activeQuestion, sessionQuestionQueue } from "../../../src/routes/session/question-queue" + +const session = (id: string, parentID?: string) => ({ id, parentID }) as Session + +const question = (id: string, sessionID: string) => + ({ + id, + sessionID, + questions: [], + }) as QuestionRequest + +describe("sessionQuestionQueue", () => { + test("orders root and nested subagent questions globally", () => { + const sessions = [ + session("root"), + session("child-b", "root"), + session("child-a", "root"), + session("grand", "child-a"), + ] + const questions = { + root: [question("que_04", "root")], + "child-a": [question("que_03", "child-a")], + "child-b": [question("que_01", "child-b")], + grand: [question("que_02", "grand")], + } + + expect(sessionQuestionQueue(sessions, questions, "root").map((item) => item.id)).toEqual([ + "que_01", + "que_02", + "que_03", + "que_04", + ]) + }) + + test("keeps the active request when another question arrives", () => { + const current = question("que_02", "child-b") + const queue = [question("que_01", "child-a"), current, question("que_03", "root")] + + expect(activeQuestion(queue, current.id)).toBe(current) + expect( + activeQuestion( + queue.filter((item) => item.id !== current.id), + current.id, + )?.id, + ).toBe("que_01") + }) + + test("does not include questions from another session tree", () => { + const sessions = [session("root"), session("child", "root"), session("other")] + const questions = { + child: [question("que_01", "child")], + other: [question("que_00", "other")], + } + + expect(sessionQuestionQueue(sessions, questions, "root").map((item) => item.id)).toEqual(["que_01"]) + }) +})