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 ( +
+ +
+ + {(q, index) => { + const active = () => index() === store.tab + const answered = () => (store.answers[index()]?.length ?? 0) > 0 + return ( + + ) + }} + + +
+
+ + +
+
+ {question()?.question} + {multi() ? " " + language.t("ui.question.multiHint") : ""} +
+
+ + {(opt, i) => { + const picked = () => store.answers[store.tab]?.includes(opt.label) ?? false + return ( + + ) + }} + + + +
+ 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) + }} + /> + + +
+
+
+
+
+ + +
+
{language.t("ui.messagePart.review.title")}
+ + {(q, index) => { + const value = () => store.answers[index()]?.join(", ") ?? "" + const answered = () => Boolean(value()) + return ( +
+ {q.question} + + {answered() ? value() : language.t("ui.question.review.notAnswered")} + +
+ ) + }} +
+
+
+ +
+ + + + + + + + + +
+
+ ) +} 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(), }} > - + + {(req) => { + const count = req.questions.length + const subtitle = + count === 0 + ? "" + : `${count} ${language.t(count > 1 ? "ui.common.question.other" : "ui.common.question.one")}` + return ( +
+ + +
+ ) + }} +
+ + {(perm) => (
- - {handoff.session.get(sessionKey())?.prompt || language.t("prompt.loading")} -
- } - > - { - inputRef = el - }} - newSessionWorktree={newSessionWorktree()} - onNewSessionWorktreeReset={() => setStore("newSessionWorktree", "main")} - onSubmit={() => { - comments.clear() - resumeScroll() - }} - /> + + + {handoff.session.get(sessionKey())?.prompt || language.t("prompt.loading")} + + } + > + { + inputRef = el + }} + newSessionWorktree={newSessionWorktree()} + onNewSessionWorktreeReset={() => setStore("newSessionWorktree", "main")} + onSubmit={() => { + comments.clear() + resumeScroll() + }} + /> + 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 @@ -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} /> )} @@ -674,20 +645,6 @@ export function SessionTurn(
- 0}> -
- - {({ part, message }) => } - -
-
- 0}> -
- - {({ part, message }) => } - -
-
0}>