diff --git a/packages/client/ui-user-questions/README.i18n.yaml b/packages/client/ui-user-questions/README.i18n.yaml index 56eee35590..a6a3841cbe 100644 --- a/packages/client/ui-user-questions/README.i18n.yaml +++ b/packages/client/ui-user-questions/README.i18n.yaml @@ -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 diff --git a/packages/client/ui-user-questions/README.md b/packages/client/ui-user-questions/README.md index 531f62131f..6d2b7b438f 100644 --- a/packages/client/ui-user-questions/README.md +++ b/packages/client/ui-user-questions/README.md @@ -25,7 +25,7 @@ The Web client shows an agent's question beside the chat input. Users choose opt ## 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 diff --git a/packages/client/ui-user-questions/README.zh.md b/packages/client/ui-user-questions/README.zh.md index 63069c4c2a..1384a68f3a 100644 --- a/packages/client/ui-user-questions/README.zh.md +++ b/packages/client/ui-user-questions/README.zh.md @@ -25,7 +25,7 @@ Web 客户端在聊天输入框旁显示 agent(智能体)的提问。用户 ## 使用本包 -当 agent 提问时,附着式卡片会暂时占用编辑器位置:回答每个问题、用翻页器导航,或跳过它。选择单选选项后会立即前进;Enter 继续流程,并在所有问题均已回答或跳过后提交,而 Shift+Enter 改为换行(IME 组合输入期间按 Enter 只会确认输入候选,不会前进)。 +当 agent 提问时,附着式卡片会暂时占用编辑器位置:回答每个问题、用翻页器导航,或跳过它。首个带推荐标记的选项会预先选中,但仍只是未提交的草稿,不会暂停计时倒计时。点击单选选项会立即前进。聚焦选项时按 Enter 会尝试提交整组答案,不会选中该选项;若有缺失答案,则回到第一道未完成的问题。文本答案中的 Enter 会继续流程,Shift+Enter 则换行(IME 组合输入期间按 Enter 只会确认输入候选,不会前进)。 ### 作答 diff --git a/packages/client/ui-user-questions/src/client/QuestionComposer.tsx b/packages/client/ui-user-questions/src/client/QuestionComposer.tsx index 7ec40a9389..33bbcbec0a 100644 --- a/packages/client/ui-user-questions/src/client/QuestionComposer.tsx +++ b/packages/client/ui-user-questions/src/client/QuestionComposer.tsx @@ -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(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) }} diff --git a/packages/client/ui-user-questions/tests/user-questions-composer.client.spec.tsx b/packages/client/ui-user-questions/tests/user-questions-composer.client.spec.tsx index a6334bc843..6e0fa4f418 100644 --- a/packages/client/ui-user-questions/tests/user-questions-composer.client.spec.tsx +++ b/packages/client/ui-user-questions/tests/user-questions-composer.client.spec.tsx @@ -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() + expect(screen.getByRole('radio', { name: /工程落地型/ }).getAttribute('aria-checked')).toBe('true') + expect(carrier.snapshot().countdown?.running).toBe(true) + + fireEvent.click(screen.getByLabelText('下一题')) + view.unmount() + render() + 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() + 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() @@ -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() - 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: '跳过' }))