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
parent 34ee94232f
commit cd00422298
4 changed files with 282 additions and 147 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(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()
const reply = page.waitForRequest(
(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.progress": "{{done}} of {{total}} todos completed",
"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.other": "{{count}} queued messages",
"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 { 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,17 +428,30 @@ 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 (
<div data-component="session-question-dock">
<DockPrompt
kind="question"
ref={(el) => (root = el)}
@@ -432,6 +459,7 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
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()}>
@@ -449,6 +477,20 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
</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>
</>
}
footer={
@@ -475,13 +517,33 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
</>
}
>
<div data-slot="question-text" class="overflow-auto">
<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>
<div data-slot="question-options">
</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
@@ -572,5 +634,6 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
</Show>
</div>
</DockPrompt>
</div>
)
}

View File

@@ -860,6 +860,13 @@
min-width: 0;
}
[data-slot="question-header-actions"] {
display: flex;
align-items: center;
gap: 8px;
flex-shrink: 0;
}
[data-slot="question-progress"] {
display: flex;
align-items: center;
@@ -918,6 +925,7 @@
line-height: var(--line-height-large);
color: var(--v2-text-text-base);
padding: 0 10px;
flex-shrink: 0;
-webkit-user-select: text;
user-select: text;
}
@@ -929,6 +937,7 @@
line-height: var(--line-height-large);
color: var(--v2-text-text-muted);
padding: 0 10px;
flex-shrink: 0;
}
[data-slot="question-options"] {
@@ -936,8 +945,8 @@
flex-direction: column;
gap: 6px;
margin-top: 12px;
padding: 1px 1px 8px;
flex-shrink: 0;
padding: 1px 1px 16px;
flex: 1 1 auto;
min-height: 0;
overflow-y: auto;
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"] {
display: flex;
flex-direction: column;