tweak(ui): collapse questions

pull/15697/head
David Hill 2026-02-27 18:47:53 +00:00
parent a94f564ff0
commit 724dd665ec
2 changed files with 164 additions and 172 deletions

View File

@ -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()
@ -228,38 +233,44 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
setStore("editing", false) setStore("editing", false)
} }
const jump = (tab: number) => {
if (store.sending) return
setStore("tab", tab)
setStore("editing", false)
}
return ( return (
<DockPrompt <DockPrompt
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 class="ml-auto">
<For each={questions()}> <IconButton
{(_, i) => ( data-action="session-question-toggle-button"
<button icon="chevron-down"
type="button" size="normal"
data-slot="question-progress-segment" variant="ghost"
data-active={i() === store.tab} classList={{ "rotate-180": store.collapsed }}
data-answered={ onMouseDown={(event) => {
(store.answers[i()]?.length ?? 0) > 0 || event.preventDefault()
(store.customOn[i()] === true && (store.custom[i()] ?? "").trim().length > 0) event.stopPropagation()
} }}
disabled={store.sending} onClick={(event) => {
onClick={() => jump(i())} event.stopPropagation()
aria-label={`${language.t("ui.tool.questions")} ${i() + 1}`} fold()
/> }}
)} aria-label={store.collapsed ? language.t("session.todo.expand") : language.t("session.todo.collapse")}
</For> />
</div> </div>
</> </div>
} }
footer={ footer={
<> <>
@ -279,56 +290,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 +423,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>
) )

View File

@ -808,49 +808,6 @@
min-width: 0; min-width: 0;
} }
[data-slot="question-progress"] {
display: flex;
align-items: center;
gap: 8px;
flex-shrink: 0;
}
[data-slot="question-progress-segment"] {
width: 16px;
height: 16px;
padding: 0;
border: 0;
background: transparent;
border-radius: 999px;
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
touch-action: manipulation;
&::after {
content: "";
width: 16px;
height: 2px;
border-radius: 999px;
background-color: var(--icon-weak-base);
transition: background-color 0.2s ease;
}
&[data-active="true"]::after {
background-color: var(--icon-strong-base);
}
&[data-answered="true"]::after {
background-color: var(--icon-interactive-base);
}
&:disabled {
cursor: not-allowed;
opacity: 0.6;
}
}
[data-slot="question-content"] { [data-slot="question-content"] {
display: flex; display: flex;
flex-direction: column; flex-direction: column;