Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,12 @@ export function SessionComposerRegion(props: {
<Show when={controller.state.questionRequest()} keyed>
{(request) => (
<div>
<SessionQuestionDock request={request} onSubmit={controller.onResponseSubmit} />
<SessionQuestionDock
request={request}
directory={controller.state.questionLocation()?.directory}
workspace={controller.state.questionLocation()?.workspace}
onSubmit={controller.onResponseSubmit}
/>
</div>
)}
</Show>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 => {
Expand Down Expand Up @@ -188,6 +207,7 @@ export function createSessionComposerController(options?: { closeMs?: number | (
return {
blocked,
questionRequest,
questionLocation,
permissionRequest,
permissionResponding,
decide,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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()
},
Expand All @@ -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()
},
Expand Down
Original file line number Diff line number Diff line change
@@ -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"])
})
})
42 changes: 32 additions & 10 deletions packages/app/src/pages/session/composer/session-request-tree.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,6 @@
import type { PermissionRequest, QuestionRequest, Session } from "@opencode-ai/sdk/v2/client"

function sessionTreeRequest<T>(
session: Session[],
request: Record<string, T[] | undefined>,
sessionID?: string,
include: (item: T) => boolean = () => true,
) {
function sessionTreeIDs(session: Session[], sessionID?: string) {
if (!sessionID) return

const map = session.reduce((acc, item) => {
Expand All @@ -28,9 +23,18 @@ function sessionTreeRequest<T>(
}
}

const id = ids.find((id) => request[id]?.some(include))
if (!id) return
return request[id]?.find(include)
return ids
}

function sessionTreeRequest<T>(
session: Session[],
request: Record<string, T[] | undefined>,
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(
Expand All @@ -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<string, QuestionRequest[] | undefined>,
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]
}
33 changes: 26 additions & 7 deletions packages/tui/src/routes/session/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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<string>() }
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<string>()
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)
Expand Down Expand Up @@ -1286,11 +1302,14 @@ export function Session() {
directory={sync.session.get(permissions()[0].sessionID)?.directory}
/>
</Show>
<Show when={permissions().length === 0 && questions().length > 0}>
<QuestionPrompt
request={questions()[0]}
directory={sync.session.get(questions()[0].sessionID)?.directory}
/>
<Show when={permissions().length === 0 ? question() : undefined} keyed>
{(request) => (
<QuestionPrompt
request={request}
directory={sync.session.get(request.sessionID)?.directory}
workspace={sync.session.get(request.sessionID)?.workspaceID}
/>
)}
</Show>
<Show when={session()?.parentID}>
<SubagentFooter />
Expand Down
32 changes: 32 additions & 0 deletions packages/tui/src/routes/session/question-queue.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import type { QuestionRequest, Session } from "@opencode-ai/sdk/v2"

export function sessionQuestionQueue(
sessions: Session[],
questions: Record<string, QuestionRequest[] | undefined>,
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<string, string[]>())
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]
}
Loading
Loading