import { For, Show, createMemo, onCleanup, onMount, type Component } from "solid-js" import { createStore } from "solid-js/store" import { Button } from "@opencode-ai/ui/button" import { DockPrompt } from "@opencode-ai/ui/dock-prompt" 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 total = createMemo(() => questions().length) const [store, setStore] = createStore({ tab: 0, answers: [] as QuestionAnswer[], custom: [] as string[], customOn: [] as boolean[], editing: false, sending: false, }) let root: HTMLDivElement | undefined const question = createMemo(() => questions()[store.tab]) const options = createMemo(() => question()?.options ?? []) const input = createMemo(() => store.custom[store.tab] ?? "") const on = createMemo(() => store.customOn[store.tab] === true) const multi = createMemo(() => question()?.multiple === true) const summary = createMemo(() => { const n = Math.min(store.tab + 1, total()) return `${n} of ${total()} questions` }) const last = createMemo(() => store.tab >= total() - 1) const customUpdate = (value: string, selected: boolean = on()) => { const prev = input().trim() const next = value.trim() setStore("custom", store.tab, value) if (!selected) return if (multi()) { setStore("answers", store.tab, (current = []) => { const removed = prev ? current.filter((item) => item.trim() !== prev) : current if (!next) return removed if (removed.some((item) => item.trim() === next)) return removed return [...removed, next] }) return } setStore("answers", store.tab, next ? [next] : []) } const measure = () => { if (!root) return const scroller = document.querySelector(".session-scroller") const head = scroller instanceof HTMLElement ? scroller.firstElementChild : undefined const top = head instanceof HTMLElement && head.classList.contains("sticky") ? head.getBoundingClientRect().bottom : 0 if (!top) { root.style.removeProperty("--question-prompt-max-height") return } const dock = root.closest('[data-component="session-prompt-dock"]') if (!(dock instanceof HTMLElement)) return const dockBottom = dock.getBoundingClientRect().bottom const below = Math.max(0, dockBottom - root.getBoundingClientRect().bottom) const gap = 8 const max = Math.max(240, Math.floor(dockBottom - top - gap - below)) root.style.setProperty("--question-prompt-max-height", `${max}px`) } onMount(() => { let raf: number | undefined const update = () => { if (raf !== undefined) cancelAnimationFrame(raf) raf = requestAnimationFrame(() => { raf = undefined measure() }) } update() window.addEventListener("resize", update) const dock = root?.closest('[data-component="session-prompt-dock"]') const scroller = document.querySelector(".session-scroller") const observer = new ResizeObserver(update) if (dock instanceof HTMLElement) observer.observe(dock) if (scroller instanceof HTMLElement) observer.observe(scroller) onCleanup(() => { window.removeEventListener("resize", update) observer.disconnect() if (raf !== undefined) cancelAnimationFrame(raf) }) }) const fail = (err: unknown) => { const message = err instanceof Error ? err.message : String(err) showToast({ title: language.t("common.requestFailed"), description: message }) } const reply = async (answers: QuestionAnswer[]) => { if (store.sending) return setStore("sending", true) try { await sdk.client.question.reply({ requestID: props.request.id, answers }) } catch (err) { fail(err) } finally { setStore("sending", false) } } const reject = async () => { if (store.sending) return setStore("sending", true) try { await sdk.client.question.reject({ requestID: props.request.id }) } catch (err) { fail(err) } finally { setStore("sending", false) } } const submit = () => void reply(questions().map((_, i) => store.answers[i] ?? [])) const pick = (answer: string, custom: boolean = false) => { setStore("answers", store.tab, [answer]) if (custom) setStore("custom", store.tab, answer) if (!custom) setStore("customOn", store.tab, false) setStore("editing", false) } const toggle = (answer: string) => { setStore("answers", store.tab, (current = []) => { if (current.includes(answer)) return current.filter((item) => item !== answer) return [...current, answer] }) } const customToggle = () => { if (store.sending) return if (!multi()) { setStore("customOn", store.tab, true) setStore("editing", true) customUpdate(input(), true) return } const next = !on() setStore("customOn", store.tab, next) if (next) { setStore("editing", true) customUpdate(input(), true) return } const value = input().trim() if (value) setStore("answers", store.tab, (current = []) => current.filter((item) => item.trim() !== value)) setStore("editing", false) } const customOpen = () => { if (store.sending) return if (!on()) setStore("customOn", store.tab, true) setStore("editing", true) customUpdate(input(), true) } const selectOption = (optIndex: number) => { if (store.sending) return if (optIndex === options().length) { customOpen() return } const opt = options()[optIndex] if (!opt) return if (multi()) { toggle(opt.label) return } pick(opt.label) } const commitCustom = () => { setStore("editing", false) customUpdate(input()) } const next = () => { if (store.sending) return if (store.editing) commitCustom() if (store.tab >= total() - 1) { submit() return } setStore("tab", store.tab + 1) setStore("editing", false) } const back = () => { if (store.sending) return if (store.tab <= 0) return setStore("tab", store.tab - 1) setStore("editing", false) } const jump = (tab: number) => { if (store.sending) return setStore("tab", tab) setStore("editing", false) } return ( (root = el)} header={ <>
{summary()}
{(_, i) => (
} footer={ <>
0}>
} >
{question()?.question}
{language.t("ui.question.singleHint")}}>
{language.t("ui.question.multiHint")}
{(opt, i) => { const picked = () => store.answers[store.tab]?.includes(opt.label) ?? false return ( ) }} {language.t("ui.messagePart.option.typeOwnAnswer")} {input() || language.t("ui.question.custom.placeholder")} } >
{ if (store.sending) { e.preventDefault() return } if (e.target instanceof HTMLTextAreaElement) return const input = e.currentTarget.querySelector('[data-slot="question-custom-input"]') if (input instanceof HTMLTextAreaElement) input.focus() }} onSubmit={(e) => { e.preventDefault() commitCustom() }} > {language.t("ui.messagePart.option.typeOwnAnswer")}