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.
This commit is contained in:
1 parent
5d5bd8eb41
commit
f8a094e465
7 files changed
+29
-32
No files matched your search
@@ -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<HTMLElement>('[data-option-line] > span:first-child')
|
||||
const description = row.querySelector<HTMLElement>('[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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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` 拒绝整个等待。
|
||||
|
||||
### 回看已结束的问题
|
||||
|
||||
|
||||
@@ -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<ExpandedCopy>(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') ? <IconChevronUpOutlineRegular /> : <IconChevronDownOutlineRegular />}
|
||||
@@ -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 (
|
||||
<div
|
||||
|
||||
@@ -25,10 +25,8 @@ export const zh = {
|
||||
'nav.maximize': '展开问题卡片',
|
||||
'nav.cancel': '放弃整组问题',
|
||||
'nav.close': '收起问题面板,可从工具调用重新打开',
|
||||
'question.expand': '展开完整问题',
|
||||
'question.collapse': '收起问题',
|
||||
'option.expand': '展开“{option}”的完整说明',
|
||||
'option.collapse': '收起“{option}”的说明',
|
||||
'text.expand': '展开',
|
||||
'text.collapse': '收起',
|
||||
'option.recommended': '推荐',
|
||||
'custom.placeholder': '输入你的答案',
|
||||
'action.skip': '跳过',
|
||||
@@ -67,10 +65,8 @@ export const en = {
|
||||
'nav.maximize': 'Expand the question card',
|
||||
'nav.cancel': 'Dismiss all questions',
|
||||
'nav.close': 'Close the panel — reopen it from the tool call',
|
||||
'question.expand': 'Show the full question',
|
||||
'question.collapse': 'Show less of the question',
|
||||
'option.expand': 'Show the full description of “{option}”',
|
||||
'option.collapse': 'Show less of “{option}”',
|
||||
'text.expand': 'Expand',
|
||||
'text.collapse': 'Collapse',
|
||||
'option.recommended': 'Recommended',
|
||||
'custom.placeholder': 'Type your answer',
|
||||
'action.skip': 'Skip',
|
||||
|
||||
@@ -315,9 +315,10 @@ describe('QuestionComposer', () => {
|
||||
|
||||
// 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<HTMLButtonElement>('button', { name: /^展开(完整问题|“)/ }).map(toggle => toggle.disabled)).toEqual([false, false, false])
|
||||
expect(screen.getAllByRole<HTMLButtonElement>('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()
|
||||
|
||||
Reference in new issue
Block a user