|
|
@@ -1,9 +1,10 @@
|
|
|
-import { For, Show, createMemo, onCleanup, onMount, type Component } from "solid-js"
|
|
|
+import { For, Show, createEffect, 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/session-ui/dock-prompt"
|
|
|
import { Icon } from "@opencode-ai/ui/icon"
|
|
|
+import { useSpring } from "@opencode-ai/ui/motion-spring"
|
|
|
import { showToast } from "@/utils/toast"
|
|
|
import type { QuestionAnswer, QuestionRequest } from "@opencode-ai/sdk/v2"
|
|
|
import { useLanguage } from "@/context/language"
|
|
|
@@ -77,9 +78,12 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
|
|
|
customOn: cached?.customOn ?? ([] as boolean[]),
|
|
|
editing: false,
|
|
|
focus: 0,
|
|
|
+ minimized: false,
|
|
|
+ optionsHeight: 180,
|
|
|
})
|
|
|
|
|
|
let root: HTMLDivElement | undefined
|
|
|
+ let optionsRef: HTMLDivElement | undefined
|
|
|
let customRef: HTMLButtonElement | undefined
|
|
|
let optsRef: HTMLButtonElement[] = []
|
|
|
let replied = false
|
|
|
@@ -96,11 +100,13 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
|
|
|
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 collapse = useSpring(() => (store.minimized ? 1 : 0), { visualDuration: 0.3, bounce: 0 })
|
|
|
+ const hidden = createMemo(() => Math.max(0, Math.min(1, collapse())))
|
|
|
+ const optionsOff = createMemo(() => hidden() > 0.98)
|
|
|
|
|
|
const customUpdate = (value: string, selected: boolean = on()) => {
|
|
|
const prev = input().trim()
|
|
|
@@ -192,6 +198,14 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
|
|
|
focus(pickFocus())
|
|
|
})
|
|
|
|
|
|
+ createEffect(() => {
|
|
|
+ const el = optionsRef
|
|
|
+ if (!el) return
|
|
|
+ const update = () => setStore("optionsHeight", (height) => Math.max(height, el.scrollHeight))
|
|
|
+ update()
|
|
|
+ createResizeObserver(el, update)
|
|
|
+ })
|
|
|
+
|
|
|
onCleanup(() => {
|
|
|
if (focusFrame !== undefined) cancelAnimationFrame(focusFrame)
|
|
|
if (replied) return
|
|
|
@@ -405,7 +419,7 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
|
|
|
const tab = store.tab + 1
|
|
|
setStore("tab", tab)
|
|
|
setStore("editing", false)
|
|
|
- focus(pickFocus(tab))
|
|
|
+ if (!store.minimized) focus(pickFocus(tab))
|
|
|
}
|
|
|
|
|
|
const back = () => {
|
|
|
@@ -414,163 +428,212 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
|
|
|
const tab = store.tab - 1
|
|
|
setStore("tab", tab)
|
|
|
setStore("editing", false)
|
|
|
- focus(pickFocus(tab))
|
|
|
+ if (!store.minimized) focus(pickFocus(tab))
|
|
|
}
|
|
|
|
|
|
const jump = (tab: number) => {
|
|
|
if (sending()) return
|
|
|
setStore("tab", tab)
|
|
|
setStore("editing", false)
|
|
|
- focus(pickFocus(tab))
|
|
|
+ if (!store.minimized) focus(pickFocus(tab))
|
|
|
+ }
|
|
|
+
|
|
|
+ const minimize = () => {
|
|
|
+ if (sending()) return
|
|
|
+ setStore("editing", false)
|
|
|
+ setStore("minimized", true)
|
|
|
+ }
|
|
|
+
|
|
|
+ const restore = () => {
|
|
|
+ if (sending()) return
|
|
|
+ setStore("minimized", false)
|
|
|
+ focus(pickFocus())
|
|
|
}
|
|
|
|
|
|
return (
|
|
|
- <DockPrompt
|
|
|
- kind="question"
|
|
|
- ref={(el) => (root = el)}
|
|
|
- onKeyDown={nav}
|
|
|
- header={
|
|
|
- <>
|
|
|
- <div data-slot="question-header-title">{summary()}</div>
|
|
|
- <Show when={total() > 1}>
|
|
|
- <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 data-component="session-question-dock">
|
|
|
+ <DockPrompt
|
|
|
+ kind="question"
|
|
|
+ ref={(el) => (root = el)}
|
|
|
+ onKeyDown={nav}
|
|
|
+ header={
|
|
|
+ <>
|
|
|
+ <div data-slot="question-header-title">{summary()}</div>
|
|
|
+ <div data-slot="question-header-actions">
|
|
|
+ <Show when={total() > 1}>
|
|
|
+ <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>
|
|
|
+ </Show>
|
|
|
+ <button
|
|
|
+ type="button"
|
|
|
+ data-component="icon-button"
|
|
|
+ data-icon="chevron-down"
|
|
|
+ data-size="normal"
|
|
|
+ data-variant="ghost"
|
|
|
+ disabled={sending()}
|
|
|
+ style={{ transform: `rotate(${hidden() * 180}deg)` }}
|
|
|
+ onClick={store.minimized ? restore : minimize}
|
|
|
+ aria-label={language.t(store.minimized ? "session.question.restore" : "session.question.minimize")}
|
|
|
+ >
|
|
|
+ <Icon name="chevron-down" size="small" />
|
|
|
+ </button>
|
|
|
</div>
|
|
|
- </Show>
|
|
|
- </>
|
|
|
- }
|
|
|
- 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")}
|
|
|
+ </>
|
|
|
+ }
|
|
|
+ footer={
|
|
|
+ <>
|
|
|
+ <Button variant="ghost" size="large" disabled={sending()} onClick={reject} aria-keyshortcuts="Escape">
|
|
|
+ {language.t("ui.common.dismiss")}
|
|
|
</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>
|
|
|
- }
|
|
|
+ <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"
|
|
|
+ style={{
|
|
|
+ display: store.minimized ? "-webkit-box" : undefined,
|
|
|
+ "-webkit-line-clamp": store.minimized ? "3" : undefined,
|
|
|
+ "-webkit-box-orient": store.minimized ? "vertical" : undefined,
|
|
|
+ overflow: store.minimized ? "hidden" : undefined,
|
|
|
+ }}
|
|
|
>
|
|
|
- <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}
|
|
|
+ {question()?.question}
|
|
|
+ </div>
|
|
|
+ <Show when={!store.minimized}>
|
|
|
+ <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>
|
|
|
+ </Show>
|
|
|
+ <div
|
|
|
+ ref={(el) => (optionsRef = el)}
|
|
|
+ data-slot="question-options"
|
|
|
+ aria-hidden={store.minimized || optionsOff() ? "true" : undefined}
|
|
|
+ classList={{ "pointer-events-none": hidden() > 0.1 }}
|
|
|
+ style={{
|
|
|
+ "max-height": `${Math.max(0, store.optionsHeight * (1 - hidden()))}px`,
|
|
|
+ opacity: `${Math.max(0, Math.min(1, 1 - hidden()))}`,
|
|
|
+ visibility: optionsOff() ? "hidden" : "visible",
|
|
|
+ }}
|
|
|
+ >
|
|
|
+ <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()}
|
|
|
- onKeyDown={(e) => {
|
|
|
- if (e.key === "Escape") {
|
|
|
+ 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()
|
|
|
- setStore("editing", false)
|
|
|
- focus(options().length)
|
|
|
return
|
|
|
}
|
|
|
- if ((e.metaKey || e.ctrlKey) && !e.altKey) return
|
|
|
- if (e.key !== "Enter" || e.shiftKey) 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()
|
|
|
}}
|
|
|
- onInput={(e) => {
|
|
|
- customUpdate(e.currentTarget.value)
|
|
|
- resizeInput(e.currentTarget)
|
|
|
- }}
|
|
|
- />
|
|
|
- </span>
|
|
|
- </form>
|
|
|
- </Show>
|
|
|
- </div>
|
|
|
- </DockPrompt>
|
|
|
+ >
|
|
|
+ <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>
|
|
|
+ </div>
|
|
|
)
|
|
|
}
|