question-dock.tsx 9.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295
  1. import { For, Show, createMemo, type Component } from "solid-js"
  2. import { createStore } from "solid-js/store"
  3. import { Button } from "@opencode-ai/ui/button"
  4. import { Icon } from "@opencode-ai/ui/icon"
  5. import { showToast } from "@opencode-ai/ui/toast"
  6. import type { QuestionAnswer, QuestionRequest } from "@opencode-ai/sdk/v2"
  7. import { useLanguage } from "@/context/language"
  8. import { useSDK } from "@/context/sdk"
  9. export const QuestionDock: Component<{ request: QuestionRequest }> = (props) => {
  10. const sdk = useSDK()
  11. const language = useLanguage()
  12. const questions = createMemo(() => props.request.questions)
  13. const single = createMemo(() => questions().length === 1 && questions()[0]?.multiple !== true)
  14. const [store, setStore] = createStore({
  15. tab: 0,
  16. answers: [] as QuestionAnswer[],
  17. custom: [] as string[],
  18. editing: false,
  19. sending: false,
  20. })
  21. const question = createMemo(() => questions()[store.tab])
  22. const confirm = createMemo(() => !single() && store.tab === questions().length)
  23. const options = createMemo(() => question()?.options ?? [])
  24. const input = createMemo(() => store.custom[store.tab] ?? "")
  25. const multi = createMemo(() => question()?.multiple === true)
  26. const customPicked = createMemo(() => {
  27. const value = input()
  28. if (!value) return false
  29. return store.answers[store.tab]?.includes(value) ?? false
  30. })
  31. const fail = (err: unknown) => {
  32. const message = err instanceof Error ? err.message : String(err)
  33. showToast({ title: language.t("common.requestFailed"), description: message })
  34. }
  35. const reply = (answers: QuestionAnswer[]) => {
  36. if (store.sending) return
  37. setStore("sending", true)
  38. sdk.client.question
  39. .reply({ requestID: props.request.id, answers })
  40. .catch(fail)
  41. .finally(() => setStore("sending", false))
  42. }
  43. const reject = () => {
  44. if (store.sending) return
  45. setStore("sending", true)
  46. sdk.client.question
  47. .reject({ requestID: props.request.id })
  48. .catch(fail)
  49. .finally(() => setStore("sending", false))
  50. }
  51. const submit = () => {
  52. reply(questions().map((_, i) => store.answers[i] ?? []))
  53. }
  54. const pick = (answer: string, custom: boolean = false) => {
  55. const answers = [...store.answers]
  56. answers[store.tab] = [answer]
  57. setStore("answers", answers)
  58. if (custom) {
  59. const inputs = [...store.custom]
  60. inputs[store.tab] = answer
  61. setStore("custom", inputs)
  62. }
  63. if (single()) {
  64. reply([[answer]])
  65. return
  66. }
  67. setStore("tab", store.tab + 1)
  68. }
  69. const toggle = (answer: string) => {
  70. const existing = store.answers[store.tab] ?? []
  71. const next = [...existing]
  72. const index = next.indexOf(answer)
  73. if (index === -1) next.push(answer)
  74. if (index !== -1) next.splice(index, 1)
  75. const answers = [...store.answers]
  76. answers[store.tab] = next
  77. setStore("answers", answers)
  78. }
  79. const selectTab = (index: number) => {
  80. setStore("tab", index)
  81. setStore("editing", false)
  82. }
  83. const selectOption = (optIndex: number) => {
  84. if (store.sending) return
  85. if (optIndex === options().length) {
  86. setStore("editing", true)
  87. return
  88. }
  89. const opt = options()[optIndex]
  90. if (!opt) return
  91. if (multi()) {
  92. toggle(opt.label)
  93. return
  94. }
  95. pick(opt.label)
  96. }
  97. const handleCustomSubmit = (e: Event) => {
  98. e.preventDefault()
  99. if (store.sending) return
  100. const value = input().trim()
  101. if (!value) {
  102. setStore("editing", false)
  103. return
  104. }
  105. if (multi()) {
  106. const existing = store.answers[store.tab] ?? []
  107. const next = [...existing]
  108. if (!next.includes(value)) next.push(value)
  109. const answers = [...store.answers]
  110. answers[store.tab] = next
  111. setStore("answers", answers)
  112. setStore("editing", false)
  113. return
  114. }
  115. pick(value, true)
  116. setStore("editing", false)
  117. }
  118. return (
  119. <div data-component="question-prompt">
  120. <Show when={!single()}>
  121. <div data-slot="question-tabs">
  122. <For each={questions()}>
  123. {(q, index) => {
  124. const active = () => index() === store.tab
  125. const answered = () => (store.answers[index()]?.length ?? 0) > 0
  126. return (
  127. <button
  128. data-slot="question-tab"
  129. data-active={active()}
  130. data-answered={answered()}
  131. disabled={store.sending}
  132. onClick={() => selectTab(index())}
  133. >
  134. {q.header}
  135. </button>
  136. )
  137. }}
  138. </For>
  139. <button
  140. data-slot="question-tab"
  141. data-active={confirm()}
  142. disabled={store.sending}
  143. onClick={() => selectTab(questions().length)}
  144. >
  145. {language.t("ui.common.confirm")}
  146. </button>
  147. </div>
  148. </Show>
  149. <Show when={!confirm()}>
  150. <div data-slot="question-content">
  151. <div data-slot="question-text">
  152. {question()?.question}
  153. {multi() ? " " + language.t("ui.question.multiHint") : ""}
  154. </div>
  155. <div data-slot="question-options">
  156. <For each={options()}>
  157. {(opt, i) => {
  158. const picked = () => store.answers[store.tab]?.includes(opt.label) ?? false
  159. return (
  160. <button
  161. data-slot="question-option"
  162. data-picked={picked()}
  163. disabled={store.sending}
  164. onClick={() => selectOption(i())}
  165. >
  166. <span data-slot="option-label">{opt.label}</span>
  167. <Show when={opt.description}>
  168. <span data-slot="option-description">{opt.description}</span>
  169. </Show>
  170. <Show when={picked()}>
  171. <Icon name="check-small" size="normal" />
  172. </Show>
  173. </button>
  174. )
  175. }}
  176. </For>
  177. <button
  178. data-slot="question-option"
  179. data-picked={customPicked()}
  180. disabled={store.sending}
  181. onClick={() => selectOption(options().length)}
  182. >
  183. <span data-slot="option-label">{language.t("ui.messagePart.option.typeOwnAnswer")}</span>
  184. <Show when={!store.editing && input()}>
  185. <span data-slot="option-description">{input()}</span>
  186. </Show>
  187. <Show when={customPicked()}>
  188. <Icon name="check-small" size="normal" />
  189. </Show>
  190. </button>
  191. <Show when={store.editing}>
  192. <form data-slot="custom-input-form" onSubmit={handleCustomSubmit}>
  193. <input
  194. ref={(el) => setTimeout(() => el.focus(), 0)}
  195. type="text"
  196. data-slot="custom-input"
  197. placeholder={language.t("ui.question.custom.placeholder")}
  198. value={input()}
  199. disabled={store.sending}
  200. onInput={(e) => {
  201. const inputs = [...store.custom]
  202. inputs[store.tab] = e.currentTarget.value
  203. setStore("custom", inputs)
  204. }}
  205. />
  206. <Button type="submit" variant="primary" size="small" disabled={store.sending}>
  207. {multi() ? language.t("ui.common.add") : language.t("ui.common.submit")}
  208. </Button>
  209. <Button
  210. type="button"
  211. variant="ghost"
  212. size="small"
  213. disabled={store.sending}
  214. onClick={() => setStore("editing", false)}
  215. >
  216. {language.t("ui.common.cancel")}
  217. </Button>
  218. </form>
  219. </Show>
  220. </div>
  221. </div>
  222. </Show>
  223. <Show when={confirm()}>
  224. <div data-slot="question-review">
  225. <div data-slot="review-title">{language.t("ui.messagePart.review.title")}</div>
  226. <For each={questions()}>
  227. {(q, index) => {
  228. const value = () => store.answers[index()]?.join(", ") ?? ""
  229. const answered = () => Boolean(value())
  230. return (
  231. <div data-slot="review-item">
  232. <span data-slot="review-label">{q.question}</span>
  233. <span data-slot="review-value" data-answered={answered()}>
  234. {answered() ? value() : language.t("ui.question.review.notAnswered")}
  235. </span>
  236. </div>
  237. )
  238. }}
  239. </For>
  240. </div>
  241. </Show>
  242. <div data-slot="question-actions">
  243. <Button variant="ghost" size="small" onClick={reject} disabled={store.sending}>
  244. {language.t("ui.common.dismiss")}
  245. </Button>
  246. <Show when={!single()}>
  247. <Show when={confirm()}>
  248. <Button variant="primary" size="small" onClick={submit} disabled={store.sending}>
  249. {language.t("ui.common.submit")}
  250. </Button>
  251. </Show>
  252. <Show when={!confirm() && multi()}>
  253. <Button
  254. variant="secondary"
  255. size="small"
  256. onClick={() => selectTab(store.tab + 1)}
  257. disabled={store.sending || (store.answers[store.tab]?.length ?? 0) === 0}
  258. >
  259. {language.t("ui.common.next")}
  260. </Button>
  261. </Show>
  262. </Show>
  263. </div>
  264. </div>
  265. )
  266. }