From f8a094e4656594d9ec99feb688e5559268e23df2 Mon Sep 17 00:00:00 2001 From: yudshj Date: Wed, 21 Oct 2026 11:48:30 +0800 Subject: [PATCH] fix(ui-user-questions): label every copy toggle Expand or Collapse Option names made the toggle tooltips as long and unpredictable as the option labels themselves. Every question and description toggle now uses the same Expand and Collapse copy, and the question toggle, like the description toggles, is described by the text it controls. --- apps/web/tests/question-composer.e2e.ts | 9 ++++--- .../client/ui-user-questions/README.i18n.yaml | 4 +-- packages/client/ui-user-questions/README.md | 2 +- .../client/ui-user-questions/README.zh.md | 2 +- .../src/client/QuestionComposer.tsx | 5 ++-- .../ui-user-questions/src/client/locales.ts | 12 +++------ .../user-questions-composer.client.spec.tsx | 27 +++++++++---------- 7 files changed, 29 insertions(+), 32 deletions(-) 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()