import { For, Show, createMemo, onCleanup, onMount, type Component } from "solid-js" import { createStore } from "solid-js/store" import { useMutation } from "@tanstack/solid-query" 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" import { makeEventListener } from "@solid-primitives/event-listener" import { createResizeObserver } from "@solid-primitives/resize-observer" const cache = new Map() function Mark(props: { multi: boolean; picked: boolean; onClick?: (event: MouseEvent) => void }) { return ( ) } function Option(props: { multi: boolean picked: boolean label: string description?: string disabled: boolean ref?: (el: HTMLButtonElement) => void onFocus?: VoidFunction onClick: VoidFunction }) { return ( ) } export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit: () => void }> = (props) => { const sdk = useSDK() const language = useLanguage() const questions = createMemo(() => props.request.questions) const total = createMemo(() => questions().length) const cached = cache.get(props.request.id) const [store, setStore] = createStore({ tab: cached?.tab ?? 0, answers: cached?.answers ?? ([] as QuestionAnswer[]), custom: cached?.custom ?? ([] as string[]), customOn: cached?.customOn ?? ([] as boolean[]), editing: false, focus: 0, }) let root: HTMLDivElement | undefined let customRef: HTMLButtonElement | undefined let optsRef: HTMLButtonElement[] = [] let replied = false let focusFrame: number | 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 count = createMemo(() => options().length + 1) const summary = createMemo(() => { const n = Math.min(store.tab + 1, total()) return language.t("session.question.progress", { current: n, total: total() }) }) const customLabel = () => language.t("ui.messagePart.option.typeOwnAnswer") const customPlaceholder = () => language.t("ui.question.custom.placeholder") 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(".scroll-view__viewport") 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`) } const clamp = (i: number) => Math.max(0, Math.min(count() - 1, i)) const pickFocus = (tab: number = store.tab) => { const list = questions()[tab]?.options ?? [] if (store.customOn[tab] === true) return list.length return Math.max( 0, list.findIndex((item) => store.answers[tab]?.includes(item.label) ?? false), ) } const focus = (i: number) => { const next = clamp(i) setStore("focus", next) if (store.editing) return if (focusFrame !== undefined) cancelAnimationFrame(focusFrame) focusFrame = requestAnimationFrame(() => { focusFrame = undefined const el = next === options().length ? customRef : optsRef[next] el?.focus() }) } onMount(() => { let raf: number | undefined const update = () => { if (raf !== undefined) cancelAnimationFrame(raf) raf = requestAnimationFrame(() => { raf = undefined measure() }) } update() makeEventListener(window, "resize", update) const dock = root?.closest('[data-component="session-prompt-dock"]') const scroller = document.querySelector(".scroll-view__viewport") createResizeObserver([dock, scroller], update) onCleanup(() => { if (raf !== undefined) cancelAnimationFrame(raf) }) focus(pickFocus()) }) onCleanup(() => { if (focusFrame !== undefined) cancelAnimationFrame(focusFrame) if (replied) return cache.set(props.request.id, { tab: store.tab, answers: store.answers.map((a) => (a ? [...a] : [])), custom: store.custom.map((s) => s ?? ""), customOn: store.customOn.map((b) => b ?? false), }) }) const fail = (err: unknown) => { const message = err instanceof Error ? err.message : String(err) showToast({ title: language.t("common.requestFailed"), description: message }) } const replyMutation = useMutation(() => ({ mutationFn: (answers: QuestionAnswer[]) => sdk.client.question.reply({ requestID: props.request.id, answers }), onMutate: () => { props.onSubmit() }, onSuccess: () => { replied = true cache.delete(props.request.id) }, onError: fail, })) const rejectMutation = useMutation(() => ({ mutationFn: () => sdk.client.question.reject({ requestID: props.request.id }), onMutate: () => { props.onSubmit() }, onSuccess: () => { replied = true cache.delete(props.request.id) }, onError: fail, })) const sending = createMemo(() => replyMutation.isPending || rejectMutation.isPending) const reply = async (answers: QuestionAnswer[]) => { if (sending()) return await replyMutation.mutateAsync(answers) } const reject = async () => { if (sending()) return await rejectMutation.mutateAsync() } const submit = () => void reply(questions().map((_, i) => store.answers[i] ?? [])) const answered = (i: number) => { if ((store.answers[i]?.length ?? 0) > 0) return true return store.customOn[i] === true && (store.custom[i] ?? "").trim().length > 0 } const picked = (answer: string) => store.answers[store.tab]?.includes(answer) ?? false 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 (sending()) return setStore("focus", options().length) 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) focus(options().length) } const customOpen = () => { if (sending()) return setStore("focus", options().length) if (!on()) setStore("customOn", store.tab, true) setStore("editing", true) customUpdate(input(), true) } const move = (step: number) => { if (store.editing || sending()) return focus(store.focus + step) } const nav = (event: KeyboardEvent) => { if (event.defaultPrevented) return if (event.key === "Escape") { event.preventDefault() void reject() return } const mod = (event.metaKey || event.ctrlKey) && !event.altKey if (mod && event.key === "Enter") { if (event.repeat) return event.preventDefault() next() return } const target = event.target instanceof HTMLElement ? event.target.closest('[data-slot="question-options"]') : undefined if (store.editing) return if (!(target instanceof HTMLElement)) return if (event.altKey || event.ctrlKey || event.metaKey) return if (event.key === "ArrowDown" || event.key === "ArrowRight") { event.preventDefault() move(1) return } if (event.key === "ArrowUp" || event.key === "ArrowLeft") { event.preventDefault() move(-1) return } if (event.key === "Home") { event.preventDefault() focus(0) return } if (event.key !== "End") return event.preventDefault() focus(count() - 1) } const selectOption = (optIndex: number) => { if (sending()) return if (optIndex === options().length) { customOpen() return } const opt = options()[optIndex] if (!opt) return if (multi()) { setStore("editing", false) toggle(opt.label) return } pick(opt.label) } const commitCustom = () => { setStore("editing", false) customUpdate(input()) focus(options().length) } const resizeInput = (el: HTMLTextAreaElement) => { el.style.height = "0px" el.style.height = `${el.scrollHeight}px` } const focusCustom = (el: HTMLTextAreaElement) => { setTimeout(() => { el.focus() resizeInput(el) }, 0) } const toggleCustomMark = (event: MouseEvent) => { event.preventDefault() event.stopPropagation() customToggle() } const next = () => { if (sending()) return if (store.editing) commitCustom() if (store.tab >= total() - 1) { submit() return } const tab = store.tab + 1 setStore("tab", tab) setStore("editing", false) focus(pickFocus(tab)) } const back = () => { if (sending()) return if (store.tab <= 0) return const tab = store.tab - 1 setStore("tab", tab) setStore("editing", false) focus(pickFocus(tab)) } const jump = (tab: number) => { if (sending()) return setStore("tab", tab) setStore("editing", false) focus(pickFocus(tab)) } return ( (root = el)} onKeyDown={nav} header={ <>
{summary()}
{(_, i) => (
} footer={ <>
0}>
} >
{question()?.question}
{language.t("ui.question.singleHint")}}>
{language.t("ui.question.multiHint")}
{(opt, i) => ( setStore("focus", options().length)} onClick={customOpen} > {customLabel()} {input() || customPlaceholder()} } >
{ if (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() }} > {customLabel()}