Compare commits

...
2 changed files with 184 additions and 110 deletions
@@ -3,6 +3,7 @@ import { createStore } from "solid-js/store"
import { Button } from "@opencode-ai/ui/button" import { Button } from "@opencode-ai/ui/button"
import { DockPrompt } from "@opencode-ai/ui/dock-prompt" import { DockPrompt } from "@opencode-ai/ui/dock-prompt"
import { Icon } from "@opencode-ai/ui/icon" import { Icon } from "@opencode-ai/ui/icon"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { showToast } from "@opencode-ai/ui/toast" import { showToast } from "@opencode-ai/ui/toast"
import type { QuestionAnswer, QuestionRequest } from "@opencode-ai/sdk/v2" import type { QuestionAnswer, QuestionRequest } from "@opencode-ai/sdk/v2"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
@@ -22,6 +23,7 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
customOn: [] as boolean[], customOn: [] as boolean[],
editing: false, editing: false,
sending: false, sending: false,
collapsed: false,
}) })
let root: HTMLDivElement | undefined let root: HTMLDivElement | undefined
@@ -31,6 +33,7 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
const input = createMemo(() => store.custom[store.tab] ?? "") const input = createMemo(() => store.custom[store.tab] ?? "")
const on = createMemo(() => store.customOn[store.tab] === true) const on = createMemo(() => store.customOn[store.tab] === true)
const multi = createMemo(() => question()?.multiple === true) const multi = createMemo(() => question()?.multiple === true)
const picked = createMemo(() => store.answers[store.tab]?.length ?? 0)
const summary = createMemo(() => { const summary = createMemo(() => {
const n = Math.min(store.tab + 1, total()) const n = Math.min(store.tab + 1, total())
@@ -39,6 +42,8 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
const last = createMemo(() => store.tab >= total() - 1) const last = createMemo(() => store.tab >= total() - 1)
const fold = () => setStore("collapsed", (value) => !value)
const customUpdate = (value: string, selected: boolean = on()) => { const customUpdate = (value: string, selected: boolean = on()) => {
const prev = input().trim() const prev = input().trim()
const next = value.trim() const next = value.trim()
@@ -239,9 +244,21 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
kind="question" kind="question"
ref={(el) => (root = el)} ref={(el) => (root = el)}
header={ header={
<> <div
data-action="session-question-toggle"
class="flex flex-1 min-w-0 items-center gap-2 cursor-default select-none"
role="button"
tabIndex={0}
style={{ margin: "0 -10px", padding: "0 0 0 10px" }}
onClick={fold}
onKeyDown={(event) => {
if (event.key !== "Enter" && event.key !== " ") return
event.preventDefault()
fold()
}}
>
<div data-slot="question-header-title">{summary()}</div> <div data-slot="question-header-title">{summary()}</div>
<div data-slot="question-progress"> <div data-slot="question-progress" class="ml-auto mr-1">
<For each={questions()}> <For each={questions()}>
{(_, i) => ( {(_, i) => (
<button <button
@@ -253,13 +270,38 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
(store.customOn[i()] === true && (store.custom[i()] ?? "").trim().length > 0) (store.customOn[i()] === true && (store.custom[i()] ?? "").trim().length > 0)
} }
disabled={store.sending} disabled={store.sending}
onClick={() => jump(i())} onMouseDown={(event) => {
event.preventDefault()
event.stopPropagation()
}}
onClick={(event) => {
event.stopPropagation()
jump(i())
}}
aria-label={`${language.t("ui.tool.questions")} ${i() + 1}`} aria-label={`${language.t("ui.tool.questions")} ${i() + 1}`}
/> />
)} )}
</For> </For>
</div> </div>
</> <div>
<IconButton
data-action="session-question-toggle-button"
icon="chevron-down"
size="normal"
variant="ghost"
classList={{ "rotate-180": store.collapsed }}
onMouseDown={(event) => {
event.preventDefault()
event.stopPropagation()
}}
onClick={(event) => {
event.stopPropagation()
fold()
}}
aria-label={store.collapsed ? language.t("session.todo.expand") : language.t("session.todo.collapse")}
/>
</div>
</div>
} }
footer={ footer={
<> <>
@@ -279,56 +321,121 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
</> </>
} }
> >
<div data-slot="question-text">{question()?.question}</div> <div
<Show when={multi()} fallback={<div data-slot="question-hint">{language.t("ui.question.singleHint")}</div>}> data-slot="question-text"
<div data-slot="question-hint">{language.t("ui.question.multiHint")}</div> class="cursor-default"
classList={{
"mb-6": store.collapsed && picked() === 0,
}}
role={store.collapsed ? "button" : undefined}
tabIndex={store.collapsed ? 0 : undefined}
onClick={fold}
onKeyDown={(event) => {
if (!store.collapsed) return
if (event.key !== "Enter" && event.key !== " ") return
event.preventDefault()
fold()
}}
>
{question()?.question}
</div>
<Show when={store.collapsed && picked() > 0}>
<div data-slot="question-hint" class="cursor-default mb-6">
{picked()} answer{picked() === 1 ? "" : "s"} selected
</div>
</Show> </Show>
<div data-slot="question-options"> <div data-slot="question-answers" hidden={store.collapsed} aria-hidden={store.collapsed}>
<For each={options()}> <Show when={multi()} fallback={<div data-slot="question-hint">{language.t("ui.question.singleHint")}</div>}>
{(opt, i) => { <div data-slot="question-hint">{language.t("ui.question.multiHint")}</div>
const picked = () => store.answers[store.tab]?.includes(opt.label) ?? false </Show>
return ( <div data-slot="question-options">
<For each={options()}>
{(opt, i) => {
const picked = () => store.answers[store.tab]?.includes(opt.label) ?? false
return (
<button
data-slot="question-option"
data-picked={picked()}
role={multi() ? "checkbox" : "radio"}
aria-checked={picked()}
disabled={store.sending}
onClick={() => selectOption(i())}
>
<span data-slot="question-option-check" aria-hidden="true">
<span
data-slot="question-option-box"
data-type={multi() ? "checkbox" : "radio"}
data-picked={picked()}
>
<Show when={multi()} fallback={<span data-slot="question-option-radio-dot" />}>
<Icon name="check-small" size="small" />
</Show>
</span>
</span>
<span data-slot="question-option-main">
<span data-slot="option-label">{opt.label}</span>
<Show when={opt.description}>
<span data-slot="option-description">{opt.description}</span>
</Show>
</span>
</button>
)
}}
</For>
<Show
when={store.editing}
fallback={
<button <button
data-slot="question-option" data-slot="question-option"
data-picked={picked()} data-custom="true"
data-picked={on()}
role={multi() ? "checkbox" : "radio"} role={multi() ? "checkbox" : "radio"}
aria-checked={picked()} aria-checked={on()}
disabled={store.sending} disabled={store.sending}
onClick={() => selectOption(i())} onClick={customOpen}
> >
<span data-slot="question-option-check" aria-hidden="true"> <span
<span data-slot="question-option-check"
data-slot="question-option-box" aria-hidden="true"
data-type={multi() ? "checkbox" : "radio"} onClick={(e) => {
data-picked={picked()} e.preventDefault()
> e.stopPropagation()
customToggle()
}}
>
<span data-slot="question-option-box" data-type={multi() ? "checkbox" : "radio"} data-picked={on()}>
<Show when={multi()} fallback={<span data-slot="question-option-radio-dot" />}> <Show when={multi()} fallback={<span data-slot="question-option-radio-dot" />}>
<Icon name="check-small" size="small" /> <Icon name="check-small" size="small" />
</Show> </Show>
</span> </span>
</span> </span>
<span data-slot="question-option-main"> <span data-slot="question-option-main">
<span data-slot="option-label">{opt.label}</span> <span data-slot="option-label">{language.t("ui.messagePart.option.typeOwnAnswer")}</span>
<Show when={opt.description}> <span data-slot="option-description">{input() || language.t("ui.question.custom.placeholder")}</span>
<span data-slot="option-description">{opt.description}</span>
</Show>
</span> </span>
</button> </button>
) }
}} >
</For> <form
<Show
when={store.editing}
fallback={
<button
data-slot="question-option" data-slot="question-option"
data-custom="true" data-custom="true"
data-picked={on()} data-picked={on()}
role={multi() ? "checkbox" : "radio"} role={multi() ? "checkbox" : "radio"}
aria-checked={on()} aria-checked={on()}
disabled={store.sending} onMouseDown={(e) => {
onClick={customOpen} 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()
}}
> >
<span <span
data-slot="question-option-check" data-slot="question-option-check"
@@ -347,80 +454,39 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
</span> </span>
<span data-slot="question-option-main"> <span data-slot="question-option-main">
<span data-slot="option-label">{language.t("ui.messagePart.option.typeOwnAnswer")}</span> <span data-slot="option-label">{language.t("ui.messagePart.option.typeOwnAnswer")}</span>
<span data-slot="option-description">{input() || language.t("ui.question.custom.placeholder")}</span> <textarea
</span> ref={(el) =>
</button> setTimeout(() => {
} el.focus()
> el.style.height = "0px"
<form el.style.height = `${el.scrollHeight}px`
data-slot="question-option" }, 0)
data-custom="true"
data-picked={on()}
role={multi() ? "checkbox" : "radio"}
aria-checked={on()}
onMouseDown={(e) => {
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()
}}
>
<span
data-slot="question-option-check"
aria-hidden="true"
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
customToggle()
}}
>
<span data-slot="question-option-box" data-type={multi() ? "checkbox" : "radio"} data-picked={on()}>
<Show when={multi()} fallback={<span data-slot="question-option-radio-dot" />}>
<Icon name="check-small" size="small" />
</Show>
</span>
</span>
<span data-slot="question-option-main">
<span data-slot="option-label">{language.t("ui.messagePart.option.typeOwnAnswer")}</span>
<textarea
ref={(el) =>
setTimeout(() => {
el.focus()
el.style.height = "0px"
el.style.height = `${el.scrollHeight}px`
}, 0)
}
data-slot="question-custom-input"
placeholder={language.t("ui.question.custom.placeholder")}
value={input()}
rows={1}
disabled={store.sending}
onKeyDown={(e) => {
if (e.key === "Escape") {
e.preventDefault()
setStore("editing", false)
return
} }
if (e.key !== "Enter" || e.shiftKey) return data-slot="question-custom-input"
e.preventDefault() placeholder={language.t("ui.question.custom.placeholder")}
commitCustom() value={input()}
}} rows={1}
onInput={(e) => { disabled={store.sending}
customUpdate(e.currentTarget.value) onKeyDown={(e) => {
e.currentTarget.style.height = "0px" if (e.key === "Escape") {
e.currentTarget.style.height = `${e.currentTarget.scrollHeight}px` e.preventDefault()
}} setStore("editing", false)
/> return
</span> }
</form> if (e.key !== "Enter" || e.shiftKey) return
</Show> e.preventDefault()
commitCustom()
}}
onInput={(e) => {
customUpdate(e.currentTarget.value)
e.currentTarget.style.height = "0px"
e.currentTarget.style.height = `${e.currentTarget.scrollHeight}px`
}}
/>
</span>
</form>
</Show>
</div>
</div> </div>
</DockPrompt> </DockPrompt>
) )
+10 -2
View File
@@ -785,7 +785,7 @@
[data-slot="question-body"] { [data-slot="question-body"] {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 16px; gap: 0;
flex: 1; flex: 1;
min-height: 0; min-height: 0;
padding: 8px 8px 0; padding: 8px 8px 0;
@@ -865,7 +865,7 @@
font-weight: var(--font-weight-medium); font-weight: var(--font-weight-medium);
line-height: var(--line-height-large); line-height: var(--line-height-large);
color: var(--text-strong); color: var(--text-strong);
padding: 0 10px; padding: 16px 10px 0;
} }
[data-slot="question-hint"] { [data-slot="question-hint"] {
@@ -1020,6 +1020,14 @@
white-space: normal; white-space: normal;
overflow-wrap: anywhere; overflow-wrap: anywhere;
} }
&[data-picked="true"] {
[data-slot="question-custom-input"]:focus-visible {
outline: none;
outline-offset: 0;
border-radius: 0;
}
}
} }
[data-slot="question-custom"] { [data-slot="question-custom"] {