Merge pull request #2308 from deepseek-harness/feat/web-collapsible-question-composer

feat(web): make the ask-user question card collapsible
This commit is contained in:
Tianyi Cui
2026-08-17 13:54:50 +08:00
committed by GitHub
10 changed files with 301 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',

View File

@@ -306,6 +306,45 @@ describe('PendingQuestion domain face', () => {
expect(question.key).toBe('q:rk')
expect(question.questions).toBe(wait('rk').carrier.payload.questions)
})
it('collapses the card to the header strip and expands it back', () => {
const { carrier } = wait()
render(<QuestionComposer matched={carrier} interactions={[carrier]} {...kit} />)
// Expanded: the option list is visible.
expect(screen.getByRole('radiogroup')).toBeTruthy()
// Collapse: options leave the tree; the title and minimize toggle stay.
fireEvent.click(screen.getByLabelText(zh['nav.minimize']))
expect(screen.queryByRole('radiogroup')).toBeNull()
expect(screen.getByText('选择候选人类型')).toBeTruthy()
// Expand: the options return (the toggle label flips while collapsed).
fireEvent.click(screen.getByLabelText(zh['nav.maximize']))
expect(screen.getByRole('radiogroup')).toBeTruthy()
// Expanded again: the toggle reports expanded and the option list is back.
expect(screen.getByLabelText(zh['nav.minimize']).getAttribute('aria-expanded')).toBe('true')
})
it('keeps the collapse toggle out of the cancel path and preserves drafts across collapse', () => {
const { carrier, respond } = wait()
render(<QuestionComposer matched={carrier} interactions={[carrier]} {...kit} />)
fireEvent.click(screen.getByRole('radio', { name: /工程落地型/ }))
// Single-select auto-advances to the second question; collapse and expand
// must not lose either the picked option or the current position.
fireEvent.click(screen.getByLabelText(zh['nav.minimize']))
fireEvent.click(screen.getByLabelText(zh['nav.maximize']))
const custom = screen.getByPlaceholderText(zh['custom.placeholder'])
fireEvent.change(custom, { target: { value: '要能独立排查线上问题' } })
// Re-expanding must not steal focus back into the textarea: it was
// autofocused on first presentation, so focus stays on the expand toggle.
expect(document.activeElement).not.toBe(custom)
fireEvent.click(screen.getByLabelText('下一题'))
fireEvent.click(screen.getByRole('checkbox', { name: '系统设计' }))
fireEvent.click(screen.getByRole('button', { name: '提交' }))
expect(respond).toHaveBeenCalledWith(answeredEnvelope('question-1', [
{ id: 'profile', selected: ['工程落地型 (Recommended)'] },
{ id: 'detail', custom: '要能独立排查线上问题', selected: [] },
{ id: 'signals', selected: ['系统设计'] },
]))
})
})
describe('parseRecommendedLabel', () => {