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"])
+ })
+})