diff --git a/apps/web/tests/question-composer.e2e.ts b/apps/web/tests/question-composer.e2e.ts index a120479c59..0861e3a8ba 100644 --- a/apps/web/tests/question-composer.e2e.ts +++ b/apps/web/tests/question-composer.e2e.ts @@ -622,7 +622,7 @@ describe('web e2e: resident question composer round trip', () => { await composer.getByRole('heading', { name: '选择 HF provider 的思考等级?' }).waitFor() expect(await composer.getByRole('heading').evaluate(readCopy)) .toEqual({ lines: 1, clipped: false, overflows: false, toggle: undefined }) - expect(await composer.getByRole('button', { name: 'Show the full question' }).count()).toBe(0) + expect(await composer.getByRole('button', { name: 'Expand', exact: true }).count()).toBe(0) const rows = await composer.locator('[data-option-row]').evaluateAll(options => options.map((row) => { const label = row.querySelector('[data-option-line] > span:first-child') const description = row.querySelector('[data-option-description]') @@ -758,10 +758,11 @@ describe('web e2e: resident question composer round trip', () => { // scrollport while navigation and submission stay reachable. const questionToggle = await toggleFor(heading) expect(await questionToggle.getAttribute('aria-expanded')).toBe('false') - expect(await questionToggle.getAttribute('aria-label')).toBe('Show the full question') + expect(await questionToggle.getAttribute('aria-label')).toBe('Expand') + expect(await questionToggle.getAttribute('aria-describedby')).toBe(await heading.getAttribute('id')) await questionToggle.click() expect(await questionToggle.getAttribute('aria-expanded')).toBe('true') - expect(await questionToggle.getAttribute('aria-label')).toBe('Show less of the question') + expect(await questionToggle.getAttribute('aria-label')).toBe('Collapse') const expandedQuestion = await heading.evaluate(readCopy) expect(expandedQuestion).toMatchObject({ clipped: false, overflows: false, toggle: 'collapse' }) expect(expandedQuestion.lines).toBeGreaterThan(3) @@ -784,7 +785,7 @@ describe('web e2e: resident question composer round trip', () => { const descriptionId = await explicitText.getAttribute('id') expect(await explicit.getAttribute('aria-describedby')).toBe(descriptionId) expect(await descriptionToggle.getAttribute('aria-describedby')).toBe(descriptionId) - expect(await descriptionToggle.getAttribute('aria-label')).toBe('Show the full description of “Explicit install”') + expect(await descriptionToggle.getAttribute('aria-label')).toBe('Expand') await descriptionToggle.click() expect(await explicit.getAttribute('aria-checked')).toBe('false') const expandedDescription = await explicitText.evaluate(readCopy) diff --git a/packages/client/ui-user-questions/README.i18n.yaml b/packages/client/ui-user-questions/README.i18n.yaml index 974ab9c64e..09db781665 100644 --- a/packages/client/ui-user-questions/README.i18n.yaml +++ b/packages/client/ui-user-questions/README.i18n.yaml @@ -18,8 +18,8 @@ en: 8d3815c19858ac85 zh: 94fc905fa202d334 /deepseek-ai-dsh-client-ui-user-questions/use-this-package/answering: - en: 2173107a23e9f92a - zh: d3a6d17e50dd4b51 + en: 244a344475118aa3 + zh: 8ca447340de5d341 /deepseek-ai-dsh-client-ui-user-questions/use-this-package/reading-a-settled-question-back: en: 10975f68ff9c6fcc zh: 92246aadc363f0eb diff --git a/packages/client/ui-user-questions/README.md b/packages/client/ui-user-questions/README.md index 9f2fe46110..d730c104df 100644 --- a/packages/client/ui-user-questions/README.md +++ b/packages/client/ui-user-questions/README.md @@ -29,7 +29,7 @@ When the agent asks a question, the attached card temporarily occupies the compo ### Answering -A multi-select draft keeps its selected labels while the user opens or edits the custom answer, so its submitted item may carry both `selected` and `custom`; a single-select custom answer remains exclusive. Question detail reuses the assistant-output `MarkdownText` primitive, including its GFM rendering and untrusted-content policy. The capped card keeps navigation and submission actions fixed while the question, long detail, and choices share an internal scroll region. A question appears in full up to three rendered lines; a longer one shows two, fades at the end of the second, and places a downward-chevron expand button there that expands it in place. Option descriptions use 13px text with a 20px line height and sit beside their label when they fit, otherwise below it; a description appears in full up to two lines, and a longer one shows one line with the same fade and expand button. Expanded copy keeps an upward-chevron collapse button on a line below it. Both buttons carry a localized accessible name and a hover tooltip. CSS scroll timelines decide which copy overflows; a browser without them shows all copy in full. "Skip" retains other drafts and emits the existing blank `{ selected: [] }` result for that item. Close is not an answer: a question the Host named by tool call only leaves the composer seat, and its `ask_user_question` tool call row brings the panel back; a request that carries no tool call has no row to return from, so closing it rejects the whole wait as `ASK_CANCELLED`. +A multi-select draft keeps its selected labels while the user opens or edits the custom answer, so its submitted item may carry both `selected` and `custom`; a single-select custom answer remains exclusive. Question detail reuses the assistant-output `MarkdownText` primitive, including its GFM rendering and untrusted-content policy. The capped card keeps navigation and submission actions fixed while the question, long detail, and choices share an internal scroll region. A question appears in full up to three rendered lines; a longer one shows two, fades at the end of the second, and places a downward-chevron expand button there that expands it in place. Option descriptions use 13px text with a 20px line height and sit beside their label when they fit, otherwise below it; a description appears in full up to two lines, and a longer one shows one line with the same fade and expand button. Expanded copy keeps an upward-chevron collapse button on a line below it. Both buttons are named **Expand** or **Collapse**, show that name in a hover tooltip, and are described by the text they control. CSS scroll timelines decide which copy overflows; a browser without them shows all copy in full. "Skip" retains other drafts and emits the existing blank `{ selected: [] }` result for that item. Close is not an answer: a question the Host named by tool call only leaves the composer seat, and its `ask_user_question` tool call row brings the panel back; a request that carries no tool call has no row to return from, so closing it rejects the whole wait as `ASK_CANCELLED`. ### Reading a settled question back diff --git a/packages/client/ui-user-questions/README.zh.md b/packages/client/ui-user-questions/README.zh.md index 2ac78bf4c1..dba1010bec 100644 --- a/packages/client/ui-user-questions/README.zh.md +++ b/packages/client/ui-user-questions/README.zh.md @@ -29,7 +29,7 @@ Web 客户端在聊天输入框旁显示 agent(智能体)的提问。用户 ### 作答 -用户打开或编辑自定义答案时,多选题草稿会保留已选中的标签,因此提交项可以同时携带 `selected` 与 `custom`;单选题的自定义答案仍保持互斥。问题详情复用助手输出的 `MarkdownText` 原语,包括其 GFM 渲染与不受信任内容策略。限高卡片保持导航与提交动作固定,问题正文、超长详情和选项共享内部滚动区。问题在实际排版不超过三行时完整显示;更长的问题只显示两行,第二行末尾渐隐并放置向下箭头的展开按钮,点击后原地展开。选项说明使用 13px 字号和 20px 行高,放得下时排在标签旁,否则排在标签下方;说明不超过两行时完整显示,更长的说明只显示一行,同样渐隐并放置展开按钮。展开后的文字下方单独一行显示向上箭头的收起按钮。两种按钮都有本地化的无障碍名称和悬停提示。由 CSS 滚动时间线判断文字是否溢出;不支持该特性的浏览器完整显示全部文字。「跳过」会保留其他草稿,并为该项发出既有的空 `{ selected: [] }` 结果。关闭不是一种回答:Host 以工具调用命名的问题只是离开编辑器位置,其 `ask_user_question` 工具调用行会把面板带回来;不携带工具调用的请求没有可返回的行,关闭它仍以 `ASK_CANCELLED` 拒绝整个等待。 +用户打开或编辑自定义答案时,多选题草稿会保留已选中的标签,因此提交项可以同时携带 `selected` 与 `custom`;单选题的自定义答案仍保持互斥。问题详情复用助手输出的 `MarkdownText` 原语,包括其 GFM 渲染与不受信任内容策略。限高卡片保持导航与提交动作固定,问题正文、超长详情和选项共享内部滚动区。问题在实际排版不超过三行时完整显示;更长的问题只显示两行,第二行末尾渐隐并放置向下箭头的展开按钮,点击后原地展开。选项说明使用 13px 字号和 20px 行高,放得下时排在标签旁,否则排在标签下方;说明不超过两行时完整显示,更长的说明只显示一行,同样渐隐并放置展开按钮。展开后的文字下方单独一行显示向上箭头的收起按钮。两种按钮名为「展开」或「收起」,悬停时显示同样的提示,并以其控制的文字作为无障碍描述。由 CSS 滚动时间线判断文字是否溢出;不支持该特性的浏览器完整显示全部文字。「跳过」会保留其他草稿,并为该项发出既有的空 `{ selected: [] }` 结果。关闭不是一种回答:Host 以工具调用命名的问题只是离开编辑器位置,其 `ask_user_question` 工具调用行会把面板带回来;不携带工具调用的请求没有可返回的行,关闭它仍以 `ASK_CANCELLED` 拒绝整个等待。 ### 回看已结束的问题 diff --git a/packages/client/ui-user-questions/src/client/QuestionComposer.tsx b/packages/client/ui-user-questions/src/client/QuestionComposer.tsx index b8cdeae85b..5c00ccbf53 100644 --- a/packages/client/ui-user-questions/src/client/QuestionComposer.tsx +++ b/packages/client/ui-user-questions/src/client/QuestionComposer.tsx @@ -250,7 +250,7 @@ function QuestionFlow({ pending, t, useStore, useQuestionCard, actions }: Questi // CSS decides which copy is clipped; only the user's expansions are state. const [expanded, setExpanded] = useState(NOTHING_EXPANDED) - const questionToggleLabel = t(expanded.has('question') ? 'question.collapse' : 'question.expand') + const questionToggleLabel = t(expanded.has('question') ? 'text.collapse' : 'text.expand') const toggleCopy = (copy: 'question' | number): void => { setExpanded((current) => { @@ -533,6 +533,7 @@ function QuestionFlow({ pending, t, useStore, useQuestionCard, actions }: Questi aria-label={questionToggleLabel} aria-expanded={expanded.has('question')} aria-controls={`question-${pending.key}-${String(index)}`} + aria-describedby={`question-${pending.key}-${String(index)}`} onClick={() => { toggleCopy('question') }} > {expanded.has('question') ? : } @@ -548,7 +549,7 @@ function QuestionFlow({ pending, t, useStore, useQuestionCard, actions }: Questi const display = parseRecommendedLabel(option.label) const hasDescription = option.description !== undefined && option.description.trim() !== '' const descriptionExpanded = expanded.has(optionIndex) - const descriptionToggleLabel = t(descriptionExpanded ? 'option.collapse' : 'option.expand', { option: display.label }) + const descriptionToggleLabel = t(descriptionExpanded ? 'text.collapse' : 'text.expand') const descriptionId = `${descriptionIdPrefix}-question-${String(index)}-option-${String(optionIndex)}-description` return (
{ // CSS shows each toggle only while its copy overflows; every candidate is rendered. const heading = screen.getByRole('heading', { name: 'How should the release graph change?' }) - const questionToggle = screen.getByRole('button', { name: '展开完整问题' }) - const descriptionToggle = screen.getByRole('button', { name: '展开“Explicit install”的完整说明' }) - expect(screen.getAllByRole('button', { name: /^展开(完整问题|“)/ })).toEqual([questionToggle, descriptionToggle]) + const [questionToggle, descriptionToggle, ...rest] = screen.getAllByRole('button', { name: '展开' }) + expect(rest).toHaveLength(0) + if (questionToggle === undefined || descriptionToggle === undefined) throw new Error('Both copy toggles must render') + expect(questionToggle.getAttribute('aria-describedby')).toBe(heading.id) expect(questionToggle.getAttribute('aria-controls')).toBe(heading.id) const option = screen.getByRole('radio', { name: 'Explicit install' }) const text = screen.getByText(description) @@ -328,7 +329,7 @@ describe('QuestionComposer', () => { fireEvent.click(descriptionToggle) expect(descriptionToggle.getAttribute('aria-expanded')).toBe('true') - expect(descriptionToggle.getAttribute('aria-label')).toBe('收起“Explicit install”的说明') + expect(descriptionToggle.getAttribute('aria-label')).toBe('收起') expect(text.closest('[data-option-row]')?.hasAttribute('data-expanded')).toBe(true) expect(screen.getByText(description)).toBe(text) expect(option.getAttribute('aria-checked')).toBe('false') @@ -336,13 +337,13 @@ describe('QuestionComposer', () => { fireEvent.click(questionToggle) expect(questionToggle.getAttribute('aria-expanded')).toBe('true') - expect(questionToggle.getAttribute('aria-label')).toBe('收起问题') + expect(questionToggle.getAttribute('aria-label')).toBe('收起') expect(heading.parentElement?.hasAttribute('data-expanded')).toBe(true) fireEvent.click(questionToggle) fireEvent.click(descriptionToggle) expect(heading.parentElement?.hasAttribute('data-expanded')).toBe(false) expect(text.closest('[data-option-row]')?.hasAttribute('data-expanded')).toBe(false) - expect(screen.getAllByRole('button', { name: /^展开(完整问题|“)/ })).toEqual([questionToggle, descriptionToggle]) + expect(screen.getAllByRole('button', { name: '展开' })).toEqual([questionToggle, descriptionToggle]) }) it('resets scrolling and expanded copy when the question changes', () => { @@ -363,9 +364,8 @@ describe('QuestionComposer', () => { expect(screen.getByRole('region', { name: 'First question?' })).toBeTruthy() const firstScroll = screen.getByText('First question?').closest('[data-question-scroll]') as HTMLElement firstScroll.scrollTop = 80 - for (const toggle of screen.getAllByRole('button', { name: /^展开(完整问题|“)/ })) fireEvent.click(toggle) - expect(screen.getByRole('button', { name: '收起问题' }).getAttribute('aria-expanded')).toBe('true') - expect(screen.getByRole('button', { name: '收起“Continue”的说明' }).getAttribute('aria-expanded')).toBe('true') + for (const toggle of screen.getAllByRole('button', { name: '展开' })) fireEvent.click(toggle) + expect(screen.getAllByRole('button', { name: '收起' }).map(toggle => toggle.getAttribute('aria-expanded'))).toEqual(['true', 'true']) fireEvent.click(screen.getByRole('radio', { name: 'Continue' })) const secondScroll = screen.getByText('Second question?').closest('[data-question-scroll]') as HTMLElement @@ -373,12 +373,11 @@ describe('QuestionComposer', () => { expect(screen.getByRole('region', { name: 'Second question?' })).toBeTruthy() expect(secondScroll).toBe(firstScroll) expect(secondScroll.scrollTop).toBe(0) - expect(screen.queryByRole('button', { name: '收起问题' })).toBeNull() - expect(screen.queryByRole('button', { name: '收起“Continue”的说明' })).toBeNull() + expect(screen.queryByRole('button', { name: '收起' })).toBeNull() expect(secondScroll.querySelector('[data-expanded]')).toBeNull() expect(screen.queryByText(firstDescription)).toBeNull() - const descriptionToggle = screen.getByRole('button', { name: '展开“Continue”的完整说明' }) - expect(document.getElementById(descriptionToggle.getAttribute('aria-controls') ?? '')?.textContent).toBe(secondDescription) + const descriptionToggle = screen.getAllByRole('button', { name: '展开' })[1] + expect(document.getElementById(descriptionToggle?.getAttribute('aria-controls') ?? '')?.textContent).toBe(secondDescription) }) it('renders plan detail through the shared assistant Markdown primitive', () => { @@ -952,7 +951,7 @@ describe('review card', () => { expect(chosen.getAttribute('aria-checked')).toBe('true') expect(chosen.disabled).toBe(true) // Long copy stays readable on a frozen card. - expect(screen.getAllByRole('button', { name: /^展开(完整问题|“)/ }).map(toggle => toggle.disabled)).toEqual([false, false, false]) + expect(screen.getAllByRole('button', { name: '展开' }).map(toggle => toggle.disabled)).toEqual([false, false, false]) // Nothing is left to send, and the unused free-text field would read as // somewhere to type. expect(screen.queryByRole('button', { name: '跳过' })).toBeNull()