summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorAdam <[email protected]>2026-02-05 14:42:56 -0600
committerGitHub <[email protected]>2026-02-05 14:42:56 -0600
commitd7c2d5db3bc261764b415f0e6c50f1d5908a99a6 (patch)
treedb9e325f714af673655fe4de342e437eeb92c196
parent1dd88aeae64fd52ed35d082b42fd7aa2c25975ca (diff)
downloadopencode-d7c2d5db3bc261764b415f0e6c50f1d5908a99a6.tar.gz
opencode-d7c2d5db3bc261764b415f0e6c50f1d5908a99a6.zip
fix(app): hide prompt input when there are perms or questions (#12339)
-rw-r--r--packages/app/src/components/question-dock.tsx295
-rw-r--r--packages/app/src/pages/session.tsx87
-rw-r--r--packages/ui/src/components/session-turn.tsx89
3 files changed, 378 insertions, 93 deletions
diff --git a/packages/app/src/components/question-dock.tsx b/packages/app/src/components/question-dock.tsx
new file mode 100644
index 000000000..f626fcc9b
--- /dev/null
+++ b/packages/app/src/components/question-dock.tsx
@@ -0,0 +1,295 @@
+import { For, Show, createMemo, type Component } from "solid-js"
+import { createStore } from "solid-js/store"
+import { Button } from "@opencode-ai/ui/button"
+import { Icon } from "@opencode-ai/ui/icon"
+import { showToast } from "@opencode-ai/ui/toast"
+import type { QuestionAnswer, QuestionRequest } from "@opencode-ai/sdk/v2"
+import { useLanguage } from "@/context/language"
+import { useSDK } from "@/context/sdk"
+
+export const QuestionDock: Component<{ request: QuestionRequest }> = (props) => {
+ const sdk = useSDK()
+ const language = useLanguage()
+
+ const questions = createMemo(() => props.request.questions)
+ const single = createMemo(() => questions().length === 1 && questions()[0]?.multiple !== true)
+
+ const [store, setStore] = createStore({
+ tab: 0,
+ answers: [] as QuestionAnswer[],
+ custom: [] as string[],
+ editing: false,
+ sending: false,
+ })
+
+ const question = createMemo(() => questions()[store.tab])
+ const confirm = createMemo(() => !single() && store.tab === questions().length)
+ const options = createMemo(() => question()?.options ?? [])
+ const input = createMemo(() => store.custom[store.tab] ?? "")
+ const multi = createMemo(() => question()?.multiple === true)
+ const customPicked = createMemo(() => {
+ const value = input()
+ if (!value) return false
+ return store.answers[store.tab]?.includes(value) ?? false
+ })
+
+ const fail = (err: unknown) => {
+ const message = err instanceof Error ? err.message : String(err)
+ showToast({ title: language.t("common.requestFailed"), description: message })
+ }
+
+ const reply = (answers: QuestionAnswer[]) => {
+ if (store.sending) return
+
+ setStore("sending", true)
+ sdk.client.question
+ .reply({ requestID: props.request.id, answers })
+ .catch(fail)
+ .finally(() => setStore("sending", false))
+ }
+
+ const reject = () => {
+ if (store.sending) return
+
+ setStore("sending", true)
+ sdk.client.question
+ .reject({ requestID: props.request.id })
+ .catch(fail)
+ .finally(() => setStore("sending", false))
+ }
+
+ const submit = () => {
+ reply(questions().map((_, i) => store.answers[i] ?? []))
+ }
+
+ const pick = (answer: string, custom: boolean = false) => {
+ const answers = [...store.answers]
+ answers[store.tab] = [answer]
+ setStore("answers", answers)
+
+ if (custom) {
+ const inputs = [...store.custom]
+ inputs[store.tab] = answer
+ setStore("custom", inputs)
+ }
+
+ if (single()) {
+ reply([[answer]])
+ return
+ }
+
+ setStore("tab", store.tab + 1)
+ }
+
+ const toggle = (answer: string) => {
+ const existing = store.answers[store.tab] ?? []
+ const next = [...existing]
+ const index = next.indexOf(answer)
+ if (index === -1) next.push(answer)
+ if (index !== -1) next.splice(index, 1)
+
+ const answers = [...store.answers]
+ answers[store.tab] = next
+ setStore("answers", answers)
+ }
+
+ const selectTab = (index: number) => {
+ setStore("tab", index)
+ setStore("editing", false)
+ }
+
+ const selectOption = (optIndex: number) => {
+ if (store.sending) return
+
+ if (optIndex === options().length) {
+ setStore("editing", true)
+ return
+ }
+
+ const opt = options()[optIndex]
+ if (!opt) return
+ if (multi()) {
+ toggle(opt.label)
+ return
+ }
+ pick(opt.label)
+ }
+
+ const handleCustomSubmit = (e: Event) => {
+ e.preventDefault()
+ if (store.sending) return
+
+ const value = input().trim()
+ if (!value) {
+ setStore("editing", false)
+ return
+ }
+
+ if (multi()) {
+ const existing = store.answers[store.tab] ?? []
+ const next = [...existing]
+ if (!next.includes(value)) next.push(value)
+
+ const answers = [...store.answers]
+ answers[store.tab] = next
+ setStore("answers", answers)
+ setStore("editing", false)
+ return
+ }
+
+ pick(value, true)
+ setStore("editing", false)
+ }
+
+ return (
+ <div data-component="question-prompt">
+ <Show when={!single()}>
+ <div data-slot="question-tabs">
+ <For each={questions()}>
+ {(q, index) => {
+ const active = () => index() === store.tab
+ const answered = () => (store.answers[index()]?.length ?? 0) > 0
+ return (
+ <button
+ data-slot="question-tab"
+ data-active={active()}
+ data-answered={answered()}
+ disabled={store.sending}
+ onClick={() => selectTab(index())}
+ >
+ {q.header}
+ </button>
+ )
+ }}
+ </For>
+ <button
+ data-slot="question-tab"
+ data-active={confirm()}
+ disabled={store.sending}
+ onClick={() => selectTab(questions().length)}
+ >
+ {language.t("ui.common.confirm")}
+ </button>
+ </div>
+ </Show>
+
+ <Show when={!confirm()}>
+ <div data-slot="question-content">
+ <div data-slot="question-text">
+ {question()?.question}
+ {multi() ? " " + language.t("ui.question.multiHint") : ""}
+ </div>
+ <div data-slot="question-options">
+ <For each={options()}>
+ {(opt, i) => {
+ const picked = () => store.answers[store.tab]?.includes(opt.label) ?? false
+ return (
+ <button
+ data-slot="question-option"
+ data-picked={picked()}
+ disabled={store.sending}
+ onClick={() => selectOption(i())}
+ >
+ <span data-slot="option-label">{opt.label}</span>
+ <Show when={opt.description}>
+ <span data-slot="option-description">{opt.description}</span>
+ </Show>
+ <Show when={picked()}>
+ <Icon name="check-small" size="normal" />
+ </Show>
+ </button>
+ )
+ }}
+ </For>
+ <button
+ data-slot="question-option"
+ data-picked={customPicked()}
+ disabled={store.sending}
+ onClick={() => selectOption(options().length)}
+ >
+ <span data-slot="option-label">{language.t("ui.messagePart.option.typeOwnAnswer")}</span>
+ <Show when={!store.editing && input()}>
+ <span data-slot="option-description">{input()}</span>
+ </Show>
+ <Show when={customPicked()}>
+ <Icon name="check-small" size="normal" />
+ </Show>
+ </button>
+ <Show when={store.editing}>
+ <form data-slot="custom-input-form" onSubmit={handleCustomSubmit}>
+ <input
+ ref={(el) => setTimeout(() => el.focus(), 0)}
+ type="text"
+ data-slot="custom-input"
+ placeholder={language.t("ui.question.custom.placeholder")}
+ value={input()}
+ disabled={store.sending}
+ onInput={(e) => {
+ const inputs = [...store.custom]
+ inputs[store.tab] = e.currentTarget.value
+ setStore("custom", inputs)
+ }}
+ />
+ <Button type="submit" variant="primary" size="small" disabled={store.sending}>
+ {multi() ? language.t("ui.common.add") : language.t("ui.common.submit")}
+ </Button>
+ <Button
+ type="button"
+ variant="ghost"
+ size="small"
+ disabled={store.sending}
+ onClick={() => setStore("editing", false)}
+ >
+ {language.t("ui.common.cancel")}
+ </Button>
+ </form>
+ </Show>
+ </div>
+ </div>
+ </Show>
+
+ <Show when={confirm()}>
+ <div data-slot="question-review">
+ <div data-slot="review-title">{language.t("ui.messagePart.review.title")}</div>
+ <For each={questions()}>
+ {(q, index) => {
+ const value = () => store.answers[index()]?.join(", ") ?? ""
+ const answered = () => Boolean(value())
+ return (
+ <div data-slot="review-item">
+ <span data-slot="review-label">{q.question}</span>
+ <span data-slot="review-value" data-answered={answered()}>
+ {answered() ? value() : language.t("ui.question.review.notAnswered")}
+ </span>
+ </div>
+ )
+ }}
+ </For>
+ </div>
+ </Show>
+
+ <div data-slot="question-actions">
+ <Button variant="ghost" size="small" onClick={reject} disabled={store.sending}>
+ {language.t("ui.common.dismiss")}
+ </Button>
+ <Show when={!single()}>
+ <Show when={confirm()}>
+ <Button variant="primary" size="small" onClick={submit} disabled={store.sending}>
+ {language.t("ui.common.submit")}
+ </Button>
+ </Show>
+ <Show when={!confirm() && multi()}>
+ <Button
+ variant="secondary"
+ size="small"
+ onClick={() => selectTab(store.tab + 1)}
+ disabled={store.sending || (store.answers[store.tab]?.length ?? 0) === 0}
+ >
+ {language.t("ui.common.next")}
+ </Button>
+ </Show>
+ </Show>
+ </div>
+ </div>
+ )
+}
diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx
index b0b955ed1..cb07c3b47 100644
--- a/packages/app/src/pages/session.tsx
+++ b/packages/app/src/pages/session.tsx
@@ -36,6 +36,7 @@ import { BasicTool } from "@opencode-ai/ui/basic-tool"
import { createAutoScroll } from "@opencode-ai/ui/hooks"
import { SessionReview } from "@opencode-ai/ui/session-review"
import { Mark } from "@opencode-ai/ui/logo"
+import { QuestionDock } from "@/components/question-dock"
import { DragDropProvider, DragDropSensors, DragOverlay, SortableProvider, closestCenter } from "@thisbeyond/solid-dnd"
import type { DragEvent } from "@thisbeyond/solid-dnd"
@@ -270,15 +271,20 @@ export default function Page() {
const comments = useComments()
const permission = usePermission()
- const request = createMemo(() => {
+ const permRequest = createMemo(() => {
const sessionID = params.id
if (!sessionID) return
- const next = sync.data.permission[sessionID]?.[0]
- if (!next) return
- if (next.tool) return
- return next
+ return sync.data.permission[sessionID]?.[0]
+ })
+
+ const questionRequest = createMemo(() => {
+ const sessionID = params.id
+ if (!sessionID) return
+ return sync.data.question[sessionID]?.[0]
})
+ const blocked = createMemo(() => !!permRequest() || !!questionRequest())
+
const [ui, setUi] = createStore({
responding: false,
pendingMessage: undefined as string | undefined,
@@ -292,14 +298,14 @@ export default function Page() {
createEffect(
on(
- () => request()?.id,
+ () => permRequest()?.id,
() => setUi("responding", false),
{ defer: true },
),
)
const decide = (response: "once" | "always" | "reject") => {
- const perm = request()
+ const perm = permRequest()
if (!perm) return
if (ui.responding) return
@@ -1351,6 +1357,7 @@ export default function Page() {
}
if (event.key.length === 1 && event.key !== "Unidentified" && !(event.ctrlKey || event.metaKey)) {
+ if (blocked()) return
inputRef?.focus()
}
}
@@ -2693,7 +2700,31 @@ export default function Page() {
"md:max-w-200 3xl:max-w-[1200px] 4xl:max-w-[1600px] 5xl:max-w-[1900px]": centered(),
}}
>
- <Show when={request()} keyed>
+ <Show when={questionRequest()} keyed>
+ {(req) => {
+ const count = req.questions.length
+ const subtitle =
+ count === 0
+ ? ""
+ : `${count} ${language.t(count > 1 ? "ui.common.question.other" : "ui.common.question.one")}`
+ return (
+ <div data-component="tool-part-wrapper" data-question="true" class="mb-3">
+ <BasicTool
+ icon="bubble-5"
+ locked
+ defaultOpen
+ trigger={{
+ title: language.t("ui.tool.questions"),
+ subtitle,
+ }}
+ />
+ <QuestionDock request={req} />
+ </div>
+ )
+ }}
+ </Show>
+
+ <Show when={permRequest()} keyed>
{(perm) => (
<div data-component="tool-part-wrapper" data-permission="true" class="mb-3">
<BasicTool
@@ -2743,25 +2774,27 @@ export default function Page() {
)}
</Show>
- <Show
- when={prompt.ready()}
- fallback={
- <div class="w-full min-h-32 md:min-h-40 rounded-md border border-border-weak-base bg-background-base/50 px-4 py-3 text-text-weak whitespace-pre-wrap pointer-events-none">
- {handoff.session.get(sessionKey())?.prompt || language.t("prompt.loading")}
- </div>
- }
- >
- <PromptInput
- ref={(el) => {
- inputRef = el
- }}
- newSessionWorktree={newSessionWorktree()}
- onNewSessionWorktreeReset={() => setStore("newSessionWorktree", "main")}
- onSubmit={() => {
- comments.clear()
- resumeScroll()
- }}
- />
+ <Show when={!blocked()}>
+ <Show
+ when={prompt.ready()}
+ fallback={
+ <div class="w-full min-h-32 md:min-h-40 rounded-md border border-border-weak-base bg-background-base/50 px-4 py-3 text-text-weak whitespace-pre-wrap pointer-events-none">
+ {handoff.session.get(sessionKey())?.prompt || language.t("prompt.loading")}
+ </div>
+ }
+ >
+ <PromptInput
+ ref={(el) => {
+ inputRef = el
+ }}
+ newSessionWorktree={newSessionWorktree()}
+ onNewSessionWorktreeReset={() => setStore("newSessionWorktree", "main")}
+ onSubmit={() => {
+ comments.clear()
+ resumeScroll()
+ }}
+ />
+ </Show>
</Show>
</div>
</div>
diff --git a/packages/ui/src/components/session-turn.tsx b/packages/ui/src/components/session-turn.tsx
index 5c4678701..5ea9f64bb 100644
--- a/packages/ui/src/components/session-turn.tsx
+++ b/packages/ui/src/components/session-turn.tsx
@@ -10,7 +10,6 @@ import {
} from "@opencode-ai/sdk/v2/client"
import { useData } from "../context"
import { type UiI18nKey, type UiI18nParams, useI18n } from "../context/i18n"
-import { findLast } from "@opencode-ai/util/array"
import { Binary } from "@opencode-ai/util/binary"
import { createEffect, createMemo, createSignal, For, Match, on, onCleanup, ParentProps, Show, Switch } from "solid-js"
@@ -84,6 +83,7 @@ function AssistantMessageItem(props: {
responsePartId: string | undefined
hideResponsePart: boolean
hideReasoning: boolean
+ hidden?: () => readonly { messageID: string; callID: string }[]
}) {
const data = useData()
const emptyParts: PartType[] = []
@@ -104,13 +104,22 @@ function AssistantMessageItem(props: {
parts = parts.filter((part) => part?.type !== "reasoning")
}
- if (!props.hideResponsePart) return parts
+ if (props.hideResponsePart) {
+ const responsePartId = props.responsePartId
+ if (responsePartId && responsePartId === lastTextPart()?.id) {
+ parts = parts.filter((part) => part?.id !== responsePartId)
+ }
+ }
- const responsePartId = props.responsePartId
- if (!responsePartId) return parts
- if (responsePartId !== lastTextPart()?.id) return parts
+ const hidden = props.hidden?.() ?? []
+ if (hidden.length === 0) return parts
- return parts.filter((part) => part?.id !== responsePartId)
+ const id = props.message.id
+ return parts.filter((part) => {
+ if (part?.type !== "tool") return true
+ const tool = part as ToolPart
+ return !hidden.some((h) => h.messageID === id && h.callID === tool.callID)
+ })
})
return <Message message={props.message} parts={filteredParts()} />
@@ -140,7 +149,6 @@ export function SessionTurn(
const emptyFiles: FilePart[] = []
const emptyAssistant: AssistantMessage[] = []
const emptyPermissions: PermissionRequest[] = []
- const emptyPermissionParts: { part: ToolPart; message: AssistantMessage }[] = []
const emptyQuestions: QuestionRequest[] = []
const emptyQuestionParts: { part: ToolPart; message: AssistantMessage }[] = []
const idle = { type: "idle" as const }
@@ -253,48 +261,18 @@ export function SessionTurn(
})
const permissions = createMemo(() => data.store.permission?.[props.sessionID] ?? emptyPermissions)
- const permissionCount = createMemo(() => permissions().length)
const nextPermission = createMemo(() => permissions()[0])
- const permissionParts = createMemo(() => {
- if (props.stepsExpanded) return emptyPermissionParts
-
- const next = nextPermission()
- if (!next || !next.tool) return emptyPermissionParts
-
- const message = findLast(assistantMessages(), (m) => m.id === next.tool!.messageID)
- if (!message) return emptyPermissionParts
-
- const parts = data.store.part[message.id] ?? emptyParts
- for (const part of parts) {
- if (part?.type !== "tool") continue
- const tool = part as ToolPart
- if (tool.callID === next.tool?.callID) return [{ part: tool, message }]
- }
-
- return emptyPermissionParts
- })
-
const questions = createMemo(() => data.store.question?.[props.sessionID] ?? emptyQuestions)
const nextQuestion = createMemo(() => questions()[0])
- const questionParts = createMemo(() => {
- if (props.stepsExpanded) return emptyQuestionParts
-
- const next = nextQuestion()
- if (!next || !next.tool) return emptyQuestionParts
-
- const message = findLast(assistantMessages(), (m) => m.id === next.tool!.messageID)
- if (!message) return emptyQuestionParts
-
- const parts = data.store.part[message.id] ?? emptyParts
- for (const part of parts) {
- if (part?.type !== "tool") continue
- const tool = part as ToolPart
- if (tool.callID === next.tool?.callID) return [{ part: tool, message }]
- }
-
- return emptyQuestionParts
+ const hidden = createMemo(() => {
+ const out: { messageID: string; callID: string }[] = []
+ const perm = nextPermission()
+ if (perm?.tool) out.push(perm.tool)
+ const question = nextQuestion()
+ if (question?.tool) out.push(question.tool)
+ return out
})
const answeredQuestionParts = createMemo(() => {
@@ -499,14 +477,6 @@ export function SessionTurn(
onCleanup(() => clearInterval(timer))
})
- createEffect(
- on(permissionCount, (count, prev) => {
- if (!count) return
- if (prev !== undefined && count <= prev) return
- autoScroll.forceScrollToBottom()
- }),
- )
-
let lastStatusChange = Date.now()
let statusTimeout: number | undefined
createEffect(() => {
@@ -664,6 +634,7 @@ export function SessionTurn(
responsePartId={responsePartId()}
hideResponsePart={hideResponsePart()}
hideReasoning={!working()}
+ hidden={hidden}
/>
)}
</For>
@@ -674,20 +645,6 @@ export function SessionTurn(
</Show>
</div>
</Show>
- <Show when={!props.stepsExpanded && permissionParts().length > 0}>
- <div data-slot="session-turn-permission-parts">
- <For each={permissionParts()}>
- {({ part, message }) => <Part part={part} message={message} />}
- </For>
- </div>
- </Show>
- <Show when={!props.stepsExpanded && questionParts().length > 0}>
- <div data-slot="session-turn-question-parts">
- <For each={questionParts()}>
- {({ part, message }) => <Part part={part} message={message} />}
- </For>
- </div>
- </Show>
<Show when={!props.stepsExpanded && answeredQuestionParts().length > 0}>
<div data-slot="session-turn-answered-question-parts">
<For each={answeredQuestionParts()}>