Preselect recommended question choices without stopping the timer

This commit is contained in:
Magolor authored and imccyu committed 2026-09-28 23:21:38 +08:00
1 parent 050944fc8e
commit 1c6c462633
5 files changed
+51 -12

No files matched your search

@@ -15,8 +15,8 @@
en: d152484eb41ac6b4
zh: 09388d293f9be9cb
/deepseek-ai-dsh-client-ui-user-questions/use-this-package:
en: 636c8c2ba17dbdc3
zh: 88f64832d26eb076
en: 8d3815c19858ac85
zh: 94fc905fa202d334
/deepseek-ai-dsh-client-ui-user-questions/use-this-package/answering:
en: cc19c065d0aeac1e
zh: 91c8cbe08b438e7f
+1 -1
View File
@@ -25,7 +25,7 @@ The Web client shows an agent's question beside the chat input. Users choose opt
<a id="use-this-package"></a>
## Use this package
When the agent asks a question, the attached card temporarily occupies the composer seat: answer each question, navigate with the pager, or skip it. Single-select choices advance immediately; Enter continues the flow and submits once every question is answered or skipped, while Shift+Enter breaks a line instead (during IME composition Enter only confirms the input candidate without advancing).
When the agent asks a question, the attached card temporarily occupies the composer seat: answer each question, navigate with the pager, or skip it. A first choice marked “Recommended” starts selected, but remains an unsubmitted draft and does not pause a timed countdown. Clicking a single-select choice advances immediately. Enter on a focused option attempts to submit the batch without selecting that option; missing answers return to the first incomplete question. Enter in a text answer continues the flow, while Shift+Enter breaks a line instead (during IME composition Enter only confirms the input candidate without advancing).
### Answering
@@ -25,7 +25,7 @@ Web 客户端在聊天输入框旁显示 agent(智能体)的提问。用户
<a id="use-this-package"></a>
## 使用本包
当 agent 提问时,附着式卡片会暂时占用编辑器位置:回答每个问题、用翻页器导航,或跳过它。选择单选选项后会立即前进;Enter 继续流程,并在所有问题均已回答或跳过后提交,而 Shift+Enter 改为换行(IME 组合输入期间按 Enter 只会确认输入候选,不会前进)。
当 agent 提问时,附着式卡片会暂时占用编辑器位置:回答每个问题、用翻页器导航,或跳过它。首个带推荐标记的选项会预先选中,但仍只是未提交的草稿,不会暂停计时倒计时。点击单选选项会立即前进。聚焦选项时按 Enter 会尝试提交整组答案,不会选中该选项;若有缺失答案,则回到第一道未完成的问题。文本答案中的 Enter 会继续流程,Shift+Enter 则换行(IME 组合输入期间按 Enter 只会确认输入候选,不会前进)。
### 作答
@@ -39,6 +39,12 @@ export function parseRecommendedLabel(label: string): { label: string; recommend
: { label, recommended: false }
}
/** Only a marked first choice is an implicit draft; the user still submits it. */
function recommendedFirstOption(question: PendingQuestion['questions'][number]): string | undefined {
const label = question.options?.[0]?.label
return label !== undefined && parseRecommendedLabel(label).recommended ? label : undefined
}
/** Accept persisted progress only when it still describes this question batch. */
function isQuestionDraftProgress(value: unknown, questionCount: number): value is QuestionDraftProgress {
if (typeof value !== 'object' || value === null || Array.isArray(value)) return false
@@ -165,8 +171,9 @@ function QuestionFlow({ pending, t, useStore, useQuestionCard, actions }: Questi
return questions.map((item) => {
const answer = recorded.get(item.id)
const custom = answer?.custom ?? ''
const recommended = review === undefined ? recommendedFirstOption(item) : undefined
return {
selected: [...answer?.selected ?? []],
selected: answer === undefined && recommended !== undefined ? [recommended] : [...answer?.selected ?? []],
custom,
// A recorded answer with no selection and no custom text was skipped.
skipped: answer !== undefined && answer.selected.length === 0 && custom === '',
@@ -181,7 +188,10 @@ function QuestionFlow({ pending, t, useStore, useQuestionCard, actions }: Questi
const index = validStored?.index ?? 0
const drafts = storedProgress?.drafts ?? initialDrafts
const restoredWait = storedProgress?.wait
?? (storedProgress?.drafts.some(item => item.selected.length > 0 || item.custom !== '' || item.skipped) === true
?? (storedProgress?.drafts.some((item, itemIndex) =>
item.selected.length !== initialDrafts[itemIndex]?.selected.length
|| item.selected.some((label, labelIndex) => label !== initialDrafts[itemIndex]?.selected[labelIndex])
|| item.custom !== '' || item.skipped) === true
? 'editing' : undefined)
const [busy, setBusy] = useState<'answer' | 'cancel' | null>(null)
const [error, setError] = useState<Feedback | null>(null)
@@ -499,7 +509,7 @@ function QuestionFlow({ pending, t, useStore, useQuestionCard, actions }: Questi
disabled={locked}
onClick={() => { choose(option.label) }}
onKeyDown={(event) => {
if (event.key !== 'Enter' || !drafts.every(completed)) return
if (event.key !== 'Enter') return
event.preventDefault()
submitDrafts(drafts)
}}
@@ -182,9 +182,9 @@ const QUESTIONS: PendingQuestion['questions'] = [
]
/** Pending waterfall fixture with observable Client response methods. */
function wait(questions: PendingQuestion['questions'] = QUESTIONS) {
function wait(questions: PendingQuestion['questions'] = QUESTIONS, deadline?: number) {
const carrier = new PendingQuestion(SID, questions)
const request = createWaterfallRequest(undefined, undefined, (channel) => { carrier.detachWaterfall(channel) })
const request = createWaterfallRequest(deadline, undefined, (channel) => { carrier.detachWaterfall(channel) })
carrier.attachWaterfall(request.channel)
const answer = vi.spyOn(carrier, 'answer')
const dismiss = vi.spyOn(carrier, 'dismiss')
@@ -212,6 +212,35 @@ describe('QuestionComposer', () => {
expect(screen.getByRole('button', { name: '放弃整组问题' })).toBeTruthy()
})
it('keeps a recommended default selected without pausing a timed wait', () => {
const { carrier } = wait(QUESTIONS, Date.now() + 30_000)
try {
const view = render(<QuestionComposer matched={carrier} {...kit} />)
expect(screen.getByRole('radio', { name: /工程落地型/ }).getAttribute('aria-checked')).toBe('true')
expect(carrier.snapshot().countdown?.running).toBe(true)
fireEvent.click(screen.getByLabelText('下一题'))
view.unmount()
render(<QuestionComposer matched={carrier} {...kit} />)
expect(carrier.snapshot().countdown?.running).toBe(true)
expect(screen.getByText('2 / 3')).toBeTruthy()
} finally {
carrier.timeout()
}
})
it('treats Enter on an unselected option as submit, without selecting it', () => {
const { carrier, answer } = wait([{
id: 'mode', question: 'Which mode?', options: [{ label: 'Alpha' }, { label: 'Beta' }],
}])
render(<QuestionComposer matched={carrier} {...kit} />)
const option = screen.getByRole('radio', { name: 'Alpha' })
expect(fireEvent.keyDown(option, { key: 'Enter' })).toBe(false)
expect(option.getAttribute('aria-checked')).toBe('false')
expect(screen.getByRole('status').textContent).toBe('请先完成这道问题。')
expect(answer).not.toHaveBeenCalled()
})
it('collects single, custom, and multi-select answers before one batch submit', () => {
const { carrier, answer } = wait()
render(<QuestionComposer matched={carrier} {...kit} />)
@@ -220,13 +249,12 @@ describe('QuestionComposer', () => {
expect(screen.getByText('1 / 3')).toBeTruthy()
expect(screen.getByText('推荐')).toBeTruthy()
expect(screen.getByText('工程落地型')).toBeTruthy()
expect(screen.getByRole('radio', { name: /工程落地型/ }).getAttribute('aria-checked')).toBe('true')
const detail = screen.getByText('按当前空缺岗位的优先级选择。')
const scrollRegion = detail.closest('[data-question-scroll]')
expect(scrollRegion).toBeTruthy()
expect(scrollRegion?.contains(screen.getByRole('radio', { name: /工程落地型/ }))).toBe(true)
expect(scrollRegion?.contains(screen.getByText('下一题').closest('button'))).toBe(false)
fireEvent.keyDown(screen.getByRole('radio', { name: /工程落地型/ }), { key: 'Enter' })
expect(answer).not.toHaveBeenCalled()
fireEvent.click(screen.getByRole('radio', { name: /工程落地型/ }))
expect(screen.getByText('2 / 3')).toBeTruthy()
@@ -280,7 +308,8 @@ describe('QuestionComposer', () => {
const { carrier, answer } = wait()
render(<QuestionComposer matched={carrier} {...kit} />)
expect((screen.getByText('下一题').closest('button') as HTMLButtonElement).disabled).toBe(true)
expect(screen.getByRole('radio', { name: /工程落地型/ }).getAttribute('aria-checked')).toBe('true')
expect((screen.getByText('下一题').closest('button') as HTMLButtonElement).disabled).toBe(false)
fireEvent.click(screen.getByRole('radio', { name: '研究潜力型' }))
expect(screen.getByText('2 / 3')).toBeTruthy()
fireEvent.click(screen.getByRole('button', { name: '跳过' }))