fix(app): question UI fixes and UX improvements (#34116)

Co-authored-by: Brendan Allan <git@brendonovich.dev>
Co-authored-by: Brendan Allan <14191578+Brendonovich@users.noreply.github.com>
This commit is contained in:
Julian Coy 2026-07-01 04:15:31 -04:00 committed by GitHub
commit cd00422298
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 279 additions and 144 deletions

View file

@ -38,6 +38,29 @@ test("shows a pending question dock", async ({ page }) => {
await expect(question.getByRole("radio", { name: /Extended/ })).toBeVisible() await expect(question.getByRole("radio", { name: /Extended/ })).toBeVisible()
await expect(page.locator('[data-component="session-composer"]')).toHaveCount(0) await expect(page.locator('[data-component="session-composer"]')).toHaveCount(0)
const rejectRequests: string[] = []
page.on("request", (request) => {
if (request.method() !== "POST") return
if (new URL(request.url()).pathname === "/question/question-request/reject") rejectRequests.push(request.url())
})
await question.locator('[data-component="icon-button"][data-icon="chevron-down"]').click()
await expect(question).toBeVisible()
await expect(question.getByText("Which implementation should be used?")).toBeVisible()
await expect(question.getByText("Select one answer")).toBeHidden()
await expect(question.getByRole("radio", { name: /Minimal/ })).toBeHidden()
await expect(question.getByRole("radio", { name: /Extended/ })).toBeHidden()
await expect(question.getByRole("button", { name: "Dismiss" })).toBeVisible()
await expect(question.getByRole("button", { name: "Submit" })).toBeVisible()
await expect(page.locator('[data-component="question-minimized-dock"]')).toHaveCount(0)
expect(rejectRequests).toEqual([])
await question.locator('[data-component="icon-button"][data-icon="chevron-down"]').click()
await expect(question).toBeVisible()
await expect(question.getByText("Which implementation should be used?")).toBeVisible()
await expect(question.getByRole("radio", { name: /Minimal/ })).toBeVisible()
expect(rejectRequests).toEqual([])
await question.getByRole("radio", { name: /Minimal/ }).click() await question.getByRole("radio", { name: /Minimal/ }).click()
const reply = page.waitForRequest( const reply = page.waitForRequest(
(request) => request.method() === "POST" && new URL(request.url()).pathname === "/question/question-request/reply", (request) => request.method() === "POST" && new URL(request.url()).pathname === "/question/question-request/reply",

View file

@ -657,6 +657,10 @@ export const dict = {
"session.todo.expand": "Expand", "session.todo.expand": "Expand",
"session.todo.progress": "{{done}} of {{total}} todos completed", "session.todo.progress": "{{done}} of {{total}} todos completed",
"session.question.progress": "{{current}} of {{total}} questions", "session.question.progress": "{{current}} of {{total}} questions",
"session.question.minimize": "Minimize question",
"session.question.restore": "Restore question",
"session.question.pending.one": "{{count}} pending question",
"session.question.pending.other": "{{count}} pending questions",
"session.followupDock.summary.one": "{{count}} queued message", "session.followupDock.summary.one": "{{count}} queued message",
"session.followupDock.summary.other": "{{count}} queued messages", "session.followupDock.summary.other": "{{count}} queued messages",
"session.followupDock.sendNow": "Send now", "session.followupDock.sendNow": "Send now",

View file

@ -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 { createStore } from "solid-js/store"
import { useMutation } from "@tanstack/solid-query" import { useMutation } from "@tanstack/solid-query"
import { Button } from "@opencode-ai/ui/button" import { Button } from "@opencode-ai/ui/button"
import { DockPrompt } from "@opencode-ai/session-ui/dock-prompt" import { DockPrompt } from "@opencode-ai/session-ui/dock-prompt"
import { Icon } from "@opencode-ai/ui/icon" import { Icon } from "@opencode-ai/ui/icon"
import { useSpring } from "@opencode-ai/ui/motion-spring"
import { showToast } from "@/utils/toast" import { showToast } from "@/utils/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"
@ -77,9 +78,12 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
customOn: cached?.customOn ?? ([] as boolean[]), customOn: cached?.customOn ?? ([] as boolean[]),
editing: false, editing: false,
focus: 0, focus: 0,
minimized: false,
optionsHeight: 180,
}) })
let root: HTMLDivElement | undefined let root: HTMLDivElement | undefined
let optionsRef: HTMLDivElement | undefined
let customRef: HTMLButtonElement | undefined let customRef: HTMLButtonElement | undefined
let optsRef: HTMLButtonElement[] = [] let optsRef: HTMLButtonElement[] = []
let replied = false let replied = false
@ -96,11 +100,13 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
const n = Math.min(store.tab + 1, total()) const n = Math.min(store.tab + 1, total())
return language.t("session.question.progress", { current: n, total: total() }) return language.t("session.question.progress", { current: n, total: total() })
}) })
const customLabel = () => language.t("ui.messagePart.option.typeOwnAnswer") const customLabel = () => language.t("ui.messagePart.option.typeOwnAnswer")
const customPlaceholder = () => language.t("ui.question.custom.placeholder") const customPlaceholder = () => language.t("ui.question.custom.placeholder")
const last = createMemo(() => store.tab >= total() - 1) 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 customUpdate = (value: string, selected: boolean = on()) => {
const prev = input().trim() const prev = input().trim()
@ -192,6 +198,14 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
focus(pickFocus()) focus(pickFocus())
}) })
createEffect(() => {
const el = optionsRef
if (!el) return
const update = () => setStore("optionsHeight", (height) => Math.max(height, el.scrollHeight))
update()
createResizeObserver(el, update)
})
onCleanup(() => { onCleanup(() => {
if (focusFrame !== undefined) cancelAnimationFrame(focusFrame) if (focusFrame !== undefined) cancelAnimationFrame(focusFrame)
if (replied) return if (replied) return
@ -405,7 +419,7 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
const tab = store.tab + 1 const tab = store.tab + 1
setStore("tab", tab) setStore("tab", tab)
setStore("editing", false) setStore("editing", false)
focus(pickFocus(tab)) if (!store.minimized) focus(pickFocus(tab))
} }
const back = () => { const back = () => {
@ -414,163 +428,212 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
const tab = store.tab - 1 const tab = store.tab - 1
setStore("tab", tab) setStore("tab", tab)
setStore("editing", false) setStore("editing", false)
focus(pickFocus(tab)) if (!store.minimized) focus(pickFocus(tab))
} }
const jump = (tab: number) => { const jump = (tab: number) => {
if (sending()) return if (sending()) return
setStore("tab", tab) setStore("tab", tab)
setStore("editing", false) 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 ( return (
<DockPrompt <div data-component="session-question-dock">
kind="question" <DockPrompt
ref={(el) => (root = el)} kind="question"
onKeyDown={nav} ref={(el) => (root = el)}
header={ onKeyDown={nav}
<> header={
<div data-slot="question-header-title">{summary()}</div> <>
<Show when={total() > 1}> <div data-slot="question-header-title">{summary()}</div>
<div data-slot="question-progress"> <div data-slot="question-header-actions">
<For each={questions()}> <Show when={total() > 1}>
{(_, i) => ( <div data-slot="question-progress">
<button <For each={questions()}>
type="button" {(_, i) => (
data-slot="question-progress-segment" <button
data-active={i() === store.tab} type="button"
data-answered={answered(i())} data-slot="question-progress-segment"
disabled={sending()} data-active={i() === store.tab}
onClick={() => jump(i())} data-answered={answered(i())}
aria-label={`${language.t("ui.tool.questions")} ${i() + 1}`} disabled={sending()}
/> onClick={() => jump(i())}
)} aria-label={`${language.t("ui.tool.questions")} ${i() + 1}`}
</For> />
</div> )}
</Show> </For>
</> </div>
} </Show>
footer={ <button
<> type="button"
<Button variant="ghost" size="large" disabled={sending()} onClick={reject} aria-keyshortcuts="Escape"> data-component="icon-button"
{language.t("ui.common.dismiss")} data-icon="chevron-down"
</Button> data-size="normal"
<div data-slot="question-footer-actions"> data-variant="ghost"
<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()} disabled={sending()}
onKeyDown={(e) => { style={{ transform: `rotate(${hidden() * 180}deg)` }}
if (e.key === "Escape") { 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>
</>
}
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"
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,
}}
>
{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()}
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() e.preventDefault()
setStore("editing", false)
focus(options().length)
return return
} }
if ((e.metaKey || e.ctrlKey) && !e.altKey) return if (e.target instanceof HTMLTextAreaElement) return
if (e.key !== "Enter" || e.shiftKey) return const input = e.currentTarget.querySelector('[data-slot="question-custom-input"]')
if (input instanceof HTMLTextAreaElement) input.focus()
}}
onSubmit={(e) => {
e.preventDefault() e.preventDefault()
commitCustom() commitCustom()
}} }}
onInput={(e) => { >
customUpdate(e.currentTarget.value) <Mark multi={multi()} picked={on()} onClick={toggleCustomMark} />
resizeInput(e.currentTarget) <span data-slot="question-option-main">
}} <span data-slot="option-label">{customLabel()}</span>
/> <textarea
</span> ref={focusCustom}
</form> data-slot="question-custom-input"
</Show> placeholder={customPlaceholder()}
</div> value={input()}
</DockPrompt> 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>
) )
} }

View file

@ -860,6 +860,13 @@
min-width: 0; min-width: 0;
} }
[data-slot="question-header-actions"] {
display: flex;
align-items: center;
gap: 8px;
flex-shrink: 0;
}
[data-slot="question-progress"] { [data-slot="question-progress"] {
display: flex; display: flex;
align-items: center; align-items: center;
@ -918,6 +925,7 @@
line-height: var(--line-height-large); line-height: var(--line-height-large);
color: var(--v2-text-text-base); color: var(--v2-text-text-base);
padding: 0 10px; padding: 0 10px;
flex-shrink: 0;
-webkit-user-select: text; -webkit-user-select: text;
user-select: text; user-select: text;
} }
@ -929,6 +937,7 @@
line-height: var(--line-height-large); line-height: var(--line-height-large);
color: var(--v2-text-text-muted); color: var(--v2-text-text-muted);
padding: 0 10px; padding: 0 10px;
flex-shrink: 0;
} }
[data-slot="question-options"] { [data-slot="question-options"] {
@ -936,8 +945,8 @@
flex-direction: column; flex-direction: column;
gap: 6px; gap: 6px;
margin-top: 12px; margin-top: 12px;
padding: 1px 1px 8px; padding: 1px 1px 16px;
flex-shrink: 0; flex: 1 1 auto;
min-height: 0; min-height: 0;
overflow-y: auto; overflow-y: auto;
scrollbar-width: none; scrollbar-width: none;
@ -1126,6 +1135,42 @@
} }
} }
[data-component="question-minimized-dock"] {
margin-bottom: 8px;
[data-slot="question-minimized-trigger"] {
display: flex;
align-items: center;
gap: 10px;
width: 100%;
padding: 10px 12px;
border: 0;
background: transparent;
color: var(--v2-text-text-base);
text-align: left;
cursor: pointer;
touch-action: manipulation;
&:disabled {
cursor: not-allowed;
opacity: 0.6;
}
[data-component="icon"] {
transform: rotate(180deg);
}
}
[data-slot="question-minimized-summary"] {
flex: 1;
font-family: var(--font-family-sans);
font-size: 14px;
font-weight: var(--font-weight-medium);
line-height: var(--line-height-large);
color: var(--v2-text-text-base);
}
}
[data-component="question-answers"] { [data-component="question-answers"] {
display: flex; display: flex;
flex-direction: column; flex-direction: column;