Buckets:
GHHG10/CodeServer / opencode /packages /app /src /pages /session /composer /session-question-dock.tsx
| 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 "@/utils/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" | |
| import { useServerSDK } from "@/context/server-sdk" | |
| import { ScopedKey } from "@/utils/server-scope" | |
| const cache = new Map<string, { tab: number; answers: QuestionAnswer[]; custom: string[]; customOn: boolean[] }>() | |
| function Mark(props: { multi: boolean; picked: boolean; onClick?: (event: MouseEvent) => void }) { | |
| return ( | |
| <span data-slot="question-option-check" aria-hidden="true" onClick={props.onClick}> | |
| <span data-slot="question-option-box" data-type={props.multi ? "checkbox" : "radio"} data-picked={props.picked}> | |
| <Show when={props.multi} fallback={<span data-slot="question-option-radio-dot" />}> | |
| <Icon name="check-small" size="small" /> | |
| </Show> | |
| </span> | |
| </span> | |
| ) | |
| } | |
| function Option(props: { | |
| multi: boolean | |
| picked: boolean | |
| label: string | |
| description?: string | |
| disabled: boolean | |
| ref?: (el: HTMLButtonElement) => void | |
| onFocus?: VoidFunction | |
| onClick: VoidFunction | |
| }) { | |
| return ( | |
| <button | |
| type="button" | |
| ref={props.ref} | |
| data-slot="question-option" | |
| data-picked={props.picked} | |
| role={props.multi ? "checkbox" : "radio"} | |
| aria-checked={props.picked} | |
| disabled={props.disabled} | |
| onFocus={props.onFocus} | |
| onClick={props.onClick} | |
| > | |
| <Mark multi={props.multi} picked={props.picked} /> | |
| <span data-slot="question-option-main"> | |
| <span data-slot="option-label">{props.label}</span> | |
| <Show when={props.description}> | |
| <span data-slot="option-description">{props.description}</span> | |
| </Show> | |
| </span> | |
| </button> | |
| ) | |
| } | |
| export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit: () => void }> = (props) => { | |
| const sdk = useSDK() | |
| const serverSDK = useServerSDK() | |
| const language = useLanguage() | |
| const cacheKey = ScopedKey.from(serverSDK.scope, props.request.id) | |
| const questions = createMemo(() => props.request.questions) | |
| const total = createMemo(() => questions().length) | |
| const cached = cache.get(cacheKey) | |
| 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(cacheKey, { | |
| 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(cacheKey) | |
| }, | |
| onError: fail, | |
| })) | |
| const rejectMutation = useMutation(() => ({ | |
| mutationFn: () => sdk.client.question.reject({ requestID: props.request.id }), | |
| onMutate: () => { | |
| props.onSubmit() | |
| }, | |
| onSuccess: () => { | |
| replied = true | |
| cache.delete(cacheKey) | |
| }, | |
| 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 ( | |
| <DockPrompt | |
| kind="question" | |
| ref={(el) => (root = el)} | |
| onKeyDown={nav} | |
| header={ | |
| <> | |
| <div data-slot="question-header-title">{summary()}</div> | |
| <div data-slot="question-progress"> | |
| <For each={questions()}> | |
| {(_, i) => ( | |
| <button | |
| type="button" | |
| data-slot="question-progress-segment" | |
| data-active={i() === store.tab} | |
| data-answered={answered(i())} | |
| disabled={sending()} | |
| onClick={() => jump(i())} | |
| aria-label={`${language.t("ui.tool.questions")} ${i() + 1}`} | |
| /> | |
| )} | |
| </For> | |
| </div> | |
| </> | |
| } | |
| footer={ | |
| <> | |
| <Button variant="ghost" size="large" disabled={sending()} onClick={reject} aria-keyshortcuts="Escape"> | |
| {language.t("ui.common.dismiss")} | |
| </Button> | |
| <div data-slot="question-footer-actions"> | |
| <Show when={store.tab > 0}> | |
| <Button variant="secondary" size="large" disabled={sending()} onClick={back}> | |
| {language.t("ui.common.back")} | |
| </Button> | |
| </Show> | |
| <Button | |
| variant={last() ? "primary" : "secondary"} | |
| size="large" | |
| disabled={sending()} | |
| onClick={next} | |
| aria-keyshortcuts="Meta+Enter Control+Enter" | |
| > | |
| {last() ? language.t("ui.common.submit") : language.t("ui.common.next")} | |
| </Button> | |
| </div> | |
| </> | |
| } | |
| > | |
| <div data-slot="question-text" class="overflow-auto"> | |
| {question()?.question} | |
| </div> | |
| <Show when={multi()} fallback={<div data-slot="question-hint">{language.t("ui.question.singleHint")}</div>}> | |
| <div data-slot="question-hint">{language.t("ui.question.multiHint")}</div> | |
| </Show> | |
| <div data-slot="question-options"> | |
| <For each={options()}> | |
| {(opt, i) => ( | |
| <Option | |
| multi={multi()} | |
| picked={picked(opt.label)} | |
| label={opt.label} | |
| description={opt.description} | |
| disabled={sending()} | |
| ref={(el) => (optsRef[i()] = el)} | |
| onFocus={() => setStore("focus", i())} | |
| onClick={() => selectOption(i())} | |
| /> | |
| )} | |
| </For> | |
| <Show | |
| when={store.editing} | |
| fallback={ | |
| <button | |
| type="button" | |
| ref={customRef} | |
| data-slot="question-option" | |
| data-custom="true" | |
| data-picked={on()} | |
| role={multi() ? "checkbox" : "radio"} | |
| aria-checked={on()} | |
| disabled={sending()} | |
| onFocus={() => setStore("focus", options().length)} | |
| onClick={customOpen} | |
| > | |
| <Mark multi={multi()} picked={on()} onClick={toggleCustomMark} /> | |
| <span data-slot="question-option-main"> | |
| <span data-slot="option-label">{customLabel()}</span> | |
| <span data-slot="option-description">{input() || customPlaceholder()}</span> | |
| </span> | |
| </button> | |
| } | |
| > | |
| <form | |
| data-slot="question-option" | |
| data-custom="true" | |
| data-picked={on()} | |
| role={multi() ? "checkbox" : "radio"} | |
| aria-checked={on()} | |
| onMouseDown={(e) => { | |
| 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() | |
| }} | |
| > | |
| <Mark multi={multi()} picked={on()} onClick={toggleCustomMark} /> | |
| <span data-slot="question-option-main"> | |
| <span data-slot="option-label">{customLabel()}</span> | |
| <textarea | |
| ref={focusCustom} | |
| data-slot="question-custom-input" | |
| placeholder={customPlaceholder()} | |
| value={input()} | |
| rows={1} | |
| disabled={sending()} | |
| onKeyDown={(e) => { | |
| if (e.key === "Escape") { | |
| e.preventDefault() | |
| setStore("editing", false) | |
| focus(options().length) | |
| return | |
| } | |
| if ((e.metaKey || e.ctrlKey) && !e.altKey) return | |
| if (e.key !== "Enter" || e.shiftKey) return | |
| e.preventDefault() | |
| commitCustom() | |
| }} | |
| onInput={(e) => { | |
| customUpdate(e.currentTarget.value) | |
| resizeInput(e.currentTarget) | |
| }} | |
| /> | |
| </span> | |
| </form> | |
| </Show> | |
| </div> | |
| </DockPrompt> | |
| ) | |
| } | |
Xet Storage Details
- Size:
- 17.7 kB
- Xet hash:
- a8d401051723448b78abbcaf8b7d3fb20d56e055a20510e7738815564a7f1c3d
·
Xet efficiently stores files, intelligently splitting them into unique chunks and accelerating uploads and downloads. More info.