feat(web): make the ask-user question card collapsible

The ask-user takeover rendered the pending question set as a bottom card
capped at min(60vh, 520px) with no way to reduce it, which buried the
conversation above while the user decided. Add a minimize toggle next to
the dismiss action: collapsed, the card becomes a header strip (title plus
the two icon buttons) and the option body and footer unmount; expanding
restores the full card. Drafts and the current question index live in
QuestionFlow local state, so collapse/expand never loses them. The
free-form textarea autofocuses only on first presentation, so re-expanding
does not steal focus from the toggle.

Agent Note: .agents/notes/implemented/feature/2026-08-11-collapsible-ask-user-question-card.{md,zh.md,i18n.yaml}
This commit is contained in:
GeeeekExplorer
2026-08-12 12:17:35 +08:00
parent 5bb600f9fb
commit 4b25b0e76d
6 changed files with 256 additions and 132 deletions

View File

@@ -39,6 +39,28 @@
box-sizing: border-box;
}
/* Collapsed to the header strip: drop the height cap and the inner scroll
seat so the card hugs the title row, freeing the viewport for the
conversation above while the question stays pending. */
.cardMinimized {
max-height: none;
}
/* The header strip is the whole card when collapsed: the title row needs
bottom padding once the body that normally carries it is hidden. */
.cardMinimized .header {
padding-bottom: 14px;
}
/* Header button group: minimize sits next to the close action, both on the
same 24px icon-button grid. */
.headerActions {
display: flex;
align-items: center;
gap: 4px;
flex-shrink: 0;
}
/* Figma 1019:36938 header, user-tuned: heading block left, close right; the
pager sits in the footer to balance the card. */
.header {

View File

@@ -1,8 +1,9 @@
import { useMemo, useState, type ChangeEvent, type KeyboardEvent } from 'react'
import { useMemo, useRef, useState, type ChangeEvent, type KeyboardEvent } from 'react'
import clsx from 'clsx'
import {
Button, IconCheckOutline14, IconChevronLeftOutline14, IconChevronRightOutline14,
IconCloseOutline16, IconEditOutline16, MarkdownText,
Button, IconCheckOutline14, IconChevronDownOutline14, IconChevronLeftOutline14,
IconChevronRightOutline14, IconChevronUpOutline14, IconCloseOutline16,
IconEditOutline16, MarkdownText,
} from '@deepseek-ai/dsh-client-ui-primitives'
import {
PendingQuestion, planReviewOf,
@@ -75,6 +76,13 @@ function QuestionFlow({ pending, t }: { pending: PendingQuestion } & Pick<Questi
})))
const [busy, setBusy] = useState<'answer' | 'cancel' | null>(null)
const [error, setError] = useState<Feedback | null>(null)
// Collapsed to the header strip so the conversation above stays readable
// while the user decides; the drafts survive because the state lives here.
const [minimized, setMinimized] = useState(false)
// The free-form textarea autofocuses on first presentation; re-expanding a
// collapsed question must not steal focus from the expand toggle back into
// the input, so focus is granted once per question index.
const focusedQuestions = useRef(new Set<number>())
// index stays in bounds (every setIndex site clamps) and drafts mirrors questions 1:1.
// oxlint-disable-next-line typescript/no-non-null-assertion
const question = questions[index]!
@@ -191,7 +199,10 @@ function QuestionFlow({ pending, t }: { pending: PendingQuestion } & Pick<Questi
return (
<div className={css.frame} data-question-key={pending.key}>
<section className={css.card} aria-labelledby={`question-${pending.key}-${String(index)}`}>
<section
className={clsx(css.card, minimized && css.cardMinimized)}
aria-labelledby={`question-${pending.key}-${String(index)}`}
>
<header className={css.header}>
<div className={css.headingBlock}>
{question.header !== undefined && <div className={css.eyebrow}>{question.header}</div>}
@@ -199,138 +210,155 @@ function QuestionFlow({ pending, t }: { pending: PendingQuestion } & Pick<Questi
{question.question}
</h2>
</div>
<button
type="button" className={css.iconButton} aria-label={t('nav.cancel')}
title={t('nav.cancel')}
disabled={busy !== null} onClick={cancelFlow}
>
<IconCloseOutline16 />
</button>
<div className={css.headerActions}>
<button
type="button" className={css.iconButton}
aria-label={t(minimized ? 'nav.maximize' : 'nav.minimize')}
title={t(minimized ? 'nav.maximize' : 'nav.minimize')}
aria-expanded={!minimized}
disabled={busy !== null}
onClick={() => { setMinimized(current => !current) }}
>
{minimized ? <IconChevronUpOutline14 /> : <IconChevronDownOutline14 />}
</button>
<button
type="button" className={css.iconButton} aria-label={t('nav.cancel')}
title={t('nav.cancel')}
disabled={busy !== null} onClick={cancelFlow}
>
<IconCloseOutline16 />
</button>
</div>
</header>
<div className={css.body} data-question-scroll>
{question.detail !== undefined && (
<div className={css.detail}><MarkdownText text={question.detail} /></div>
)}
<div className={css.options} role={question.multiSelect === true ? 'group' : 'radiogroup'}>
{(question.options ?? []).map((option, optionIndex) => {
const selected = draft.selected.includes(option.label)
const display = parseRecommendedLabel(option.label)
return (
<button
type="button" key={`${option.label}-${String(optionIndex)}`}
className={clsx(css.option, selected && question.multiSelect !== true && css.optionSelected)}
role={question.multiSelect === true ? 'checkbox' : 'radio'}
aria-checked={selected}
aria-label={display.label}
disabled={busy !== null}
onClick={() => { choose(option.label) }}
onKeyDown={(event) => {
if (event.key !== 'Enter' || !drafts.every(completed)) return
event.preventDefault()
submitDrafts(drafts)
}}
>
{question.multiSelect === true
? (
<span className={clsx(css.checkbox, selected && css.checkboxChecked)} aria-hidden="true">
{selected && <IconCheckOutline14 size={12} />}
</span>
)
: <span className={css.number}>{optionIndex + 1}</span>}
<span className={css.optionCopy}>
<span className={css.optionLine}>
<span className={css.optionLabel}>{display.label}</span>
{display.recommended && (
<span className={css.badge}>{t('option.recommended')}</span>
)}
{option.description !== undefined && (
<span className={css.description}>{option.description}</span>
)}
</span>
</span>
</button>
)
})}
{hasOptions
? (
<div className={clsx(css.customRow, draft.custom !== '' && css.customRowActive)}>
{question.multiSelect === true
? (
<span
className={clsx(css.checkbox, draft.custom !== '' && css.checkboxChecked)}
aria-hidden="true"
>
{draft.custom !== '' && <IconCheckOutline14 size={12} />}
</span>
)
: (
<span className={css.number} aria-hidden="true">
<IconEditOutline16 size={12} />
</span>
)}
<input
type="text"
className={css.customInput}
value={draft.custom}
disabled={busy !== null}
placeholder={t('custom.placeholder')}
onChange={draftCustom}
onKeyDown={continueFromCustom}
/>
</div>
)
: (
<textarea
autoFocus
className={css.customTextarea}
value={draft.custom}
disabled={busy !== null}
rows={2}
placeholder={t('custom.placeholder')}
onChange={draftCustom}
onKeyDown={continueFromCustom}
/>
{!minimized && (
<>
<div className={css.body} data-question-scroll>
{question.detail !== undefined && (
<div className={css.detail}><MarkdownText text={question.detail} /></div>
)}
</div>
</div>
<div className={css.options} role={question.multiSelect === true ? 'group' : 'radiogroup'}>
{(question.options ?? []).map((option, optionIndex) => {
const selected = draft.selected.includes(option.label)
const display = parseRecommendedLabel(option.label)
return (
<button
type="button" key={`${option.label}-${String(optionIndex)}`}
className={clsx(css.option, selected && question.multiSelect !== true && css.optionSelected)}
role={question.multiSelect === true ? 'checkbox' : 'radio'}
aria-checked={selected}
aria-label={display.label}
disabled={busy !== null}
onClick={() => { choose(option.label) }}
onKeyDown={(event) => {
if (event.key !== 'Enter' || !drafts.every(completed)) return
event.preventDefault()
submitDrafts(drafts)
}}
>
{question.multiSelect === true
? (
<span className={clsx(css.checkbox, selected && css.checkboxChecked)} aria-hidden="true">
{selected && <IconCheckOutline14 size={12} />}
</span>
)
: <span className={css.number}>{optionIndex + 1}</span>}
<span className={css.optionCopy}>
<span className={css.optionLine}>
<span className={css.optionLabel}>{display.label}</span>
{display.recommended && (
<span className={css.badge}>{t('option.recommended')}</span>
)}
{option.description !== undefined && (
<span className={css.description}>{option.description}</span>
)}
</span>
</span>
</button>
)
})}
<footer className={css.footer}>
<div className={css.pager}>
<button
type="button" className={css.iconButton} aria-label={t('nav.prev')}
disabled={index === 0 || busy !== null}
onClick={() => { setIndex(index - 1); setError(null) }}
>
<IconChevronLeftOutline14 />
</button>
<span className={css.progress}>{index + 1} / {questions.length}</span>
<button
type="button" className={css.iconButton} aria-label={t('nav.next')}
disabled={index === questions.length - 1 || busy !== null}
onClick={() => { setIndex(index + 1); setError(null) }}
>
<IconChevronRightOutline14 />
</button>
</div>
<div className={css.feedback} role="status">
{error === null ? null : 'key' in error ? t(error.key) : error.text}
</div>
<div className={css.footerActions}>
<Button variant="outline" disabled={busy !== null} onClick={skipQuestion}>
{t('action.skip')}
</Button>
<Button
variant="primary"
disabled={busy !== null || !answered(draft)} onClick={continueFlow}
>
{busy === 'answer'
? t('submitting')
: index === questions.length - 1 ? t('submit') : t('action.next')}
</Button>
</div>
</footer>
{hasOptions
? (
<div className={clsx(css.customRow, draft.custom !== '' && css.customRowActive)}>
{question.multiSelect === true
? (
<span
className={clsx(css.checkbox, draft.custom !== '' && css.checkboxChecked)}
aria-hidden="true"
>
{draft.custom !== '' && <IconCheckOutline14 size={12} />}
</span>
)
: (
<span className={css.number} aria-hidden="true">
<IconEditOutline16 size={12} />
</span>
)}
<input
type="text"
className={css.customInput}
value={draft.custom}
disabled={busy !== null}
placeholder={t('custom.placeholder')}
onChange={draftCustom}
onKeyDown={continueFromCustom}
/>
</div>
)
: (
<textarea
autoFocus={!focusedQuestions.current.has(index)}
className={css.customTextarea}
value={draft.custom}
disabled={busy !== null}
rows={2}
placeholder={t('custom.placeholder')}
onFocus={() => { focusedQuestions.current.add(index) }}
onChange={draftCustom}
onKeyDown={continueFromCustom}
/>
)}
</div>
</div>
<footer className={css.footer}>
<div className={css.pager}>
<button
type="button" className={css.iconButton} aria-label={t('nav.prev')}
disabled={index === 0 || busy !== null}
onClick={() => { setIndex(index - 1); setError(null) }}
>
<IconChevronLeftOutline14 />
</button>
<span className={css.progress}>{index + 1} / {questions.length}</span>
<button
type="button" className={css.iconButton} aria-label={t('nav.next')}
disabled={index === questions.length - 1 || busy !== null}
onClick={() => { setIndex(index + 1); setError(null) }}
>
<IconChevronRightOutline14 />
</button>
</div>
<div className={css.feedback} role="status">
{error === null ? null : 'key' in error ? t(error.key) : error.text}
</div>
<div className={css.footerActions}>
<Button variant="outline" disabled={busy !== null} onClick={skipQuestion}>
{t('action.skip')}
</Button>
<Button
variant="primary"
disabled={busy !== null || !answered(draft)} onClick={continueFlow}
>
{busy === 'answer'
? t('submitting')
: index === questions.length - 1 ? t('submit') : t('action.next')}
</Button>
</div>
</footer>
</>
)}
</section>
</div>
)

View File

@@ -6,6 +6,8 @@ export const zh = {
'error.unanswered': '请选择一个选项或填写自定义答案。',
'nav.prev': '上一题',
'nav.next': '下一题',
'nav.minimize': '收起问题卡片',
'nav.maximize': '展开问题卡片',
'nav.cancel': '放弃整组问题',
'option.recommended': '推荐',
'custom.placeholder': '输入你的答案',
@@ -26,6 +28,8 @@ export const en = {
'error.unanswered': 'Please select an option or enter a custom answer.',
'nav.prev': 'Previous question',
'nav.next': 'Next question',
'nav.minimize': 'Collapse the question card',
'nav.maximize': 'Expand the question card',
'nav.cancel': 'Dismiss all questions',
'option.recommended': 'Recommended',
'custom.placeholder': 'Type your answer',