refactor(ui-user-questions): let CSS wrap option descriptions

This commit is contained in:
yudshj committed 2026-10-21 16:20:02 +08:00
1 parent f3bb36263d
commit 2b6360c1d9
8 files changed
+58 -86

No files matched your search

+2 -2
View File
@@ -596,7 +596,7 @@ describe('web e2e: resident question composer round trip', () => {
if (label === null || description === null) return null
const style = getComputedStyle(description)
return {
sameLine: Math.abs(label.getBoundingClientRect().top - description.getBoundingClientRect().top) < 1,
sameLine: description.getBoundingClientRect().top < label.getBoundingClientRect().bottom,
fontSize: style.fontSize,
lineHeight: style.lineHeight,
hasDetails: row.querySelector('[data-option-details]') !== null,
@@ -605,7 +605,7 @@ describe('web e2e: resident question composer round trip', () => {
}))
expect(rows).toHaveLength(4)
expect(rows).toEqual(Array.from({ length: 4 }, () => ({
sameLine: true, fontSize: '14px', lineHeight: '24px', hasDetails: false, maskImage: 'none',
sameLine: true, fontSize: '13px', lineHeight: '20px', hasDetails: false, maskImage: 'none',
})))
await composer.getByRole('radio', { name: '中(medium)' }).click()
await composer.getByRole('button', { name: 'Submit', exact: true }).click()
@@ -18,8 +18,8 @@
en: 8d3815c19858ac85
zh: 94fc905fa202d334
/deepseek-ai-dsh-client-ui-user-questions/use-this-package/answering:
en: 02c47a01ae6fd4f4
zh: 7adb34361d02bc9d
en: e8d6e22798e572cd
zh: 26c9657013b014cd
/deepseek-ai-dsh-client-ui-user-questions/use-this-package/reading-a-settled-question-back:
en: 10975f68ff9c6fcc
zh: 92246aadc363f0eb
+1 -1
View File
@@ -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. Questions longer than three rendered lines fade at the end of the third line and show the same circled-ellipsis action for their complete text. The capped card keeps navigation and submission actions fixed while the question, long detail, and choices share an internal scroll region. A short option description stays beside its label on one line. Longer descriptions move below the label; descriptions truncated there fade out at the line end and show a circled-ellipsis action, which opens the complete selectable text on hover, keyboard focus, or click in a caret-linked callout that scrolls beyond 240px. "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. Questions longer than three rendered lines fade at the end of the third line and show the same circled-ellipsis action for their complete text. The capped card keeps navigation and submission actions fixed while the question, long detail, and choices share an internal scroll region. CSS wraps option descriptions beside or below their labels, using 13px text with a 20px line height in both positions. Overflow detection adds disclosure actions without changing text widths. Longer descriptions move below the label; descriptions truncated there fade out at the line end and show a circled-ellipsis action, which opens the complete selectable text on hover, keyboard focus, or click in a caret-linked callout that scrolls beyond 240px. "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 渲染与不受信任内容策略。超过实际排版三行的问题在第三行末尾渐隐,并通过相同的圈内三点按钮显示完整文本。限高卡片保持导航与提交动作固定,问题正文、超长详情和选项共享内部滚动区。简短的选项说明与标签同占一行;较长的说明移到标签下方,仅在下方仍被截断时让行尾文字渐隐,并显示圈内三点按钮。悬停、键盘聚焦或点击该按钮会在带指向角的气泡中打开可选择文本的完整说明,超过 240px 后在气泡内部滚动。「跳过」会保留其他草稿,并为该项发出既有的空 `{ selected: [] }` 结果。关闭不是一种回答:Host 以工具调用命名的问题只是离开编辑器位置,其 `ask_user_question` 工具调用行会把面板带回来;不携带工具调用的请求没有可返回的行,关闭它仍以 `ASK_CANCELLED` 拒绝整个等待。
用户打开或编辑自定义答案时,多选题草稿会保留已选中的标签,因此提交项可以同时携带 `selected` 与 `custom`;单选题的自定义答案仍保持互斥。问题详情复用助手输出的 `MarkdownText` 原语,包括其 GFM 渲染与不受信任内容策略。超过实际排版三行的问题在第三行末尾渐隐,并通过相同的圈内三点按钮显示完整文本。限高卡片保持导航与提交动作固定,问题正文、超长详情和选项共享内部滚动区。CSS 将选项说明排在标签旁或下方,两种位置均使用 13px 字号和 20px 行高。溢出检测会添加全文按钮,但不改变文本宽度。较长的说明移到标签下方,仅在下方仍被截断时让行尾文字渐隐,并显示圈内三点按钮。悬停、键盘聚焦或点击该按钮会在带指向角的气泡中打开可选择文本的完整说明,超过 240px 后在气泡内部滚动。「跳过」会保留其他草稿,并为该项发出既有的空 `{ selected: [] }` 结果。关闭不是一种回答:Host 以工具调用命名的问题只是离开编辑器位置,其 `ask_user_question` 工具调用行会把面板带回来;不携带工具调用的请求没有可返回的行,关闭它仍以 `ASK_CANCELLED` 拒绝整个等待。
### 回看已结束的问题
@@ -216,7 +216,6 @@
}
.optionRow {
--description-action-space: 30px;
position: relative;
width: 100%;
min-height: 40px;
@@ -243,10 +242,6 @@
cursor: pointer;
}
.optionWithDescription {
padding-right: calc(12px + var(--description-action-space));
}
.optionSelected {
background: var(--dsw-alias-interactive-bg-hover);
}
@@ -324,12 +319,17 @@
}
.optionCopy {
min-width: 0;
display: flex;
flex: 1;
flex-wrap: wrap;
align-items: baseline;
gap: 0 6px;
min-width: 0;
}
.optionLine {
display: flex;
max-width: 100%;
align-items: baseline;
flex-wrap: wrap;
gap: 2px 6px;
@@ -353,6 +353,8 @@
.description {
display: block;
width: max-content;
max-width: 100%;
min-width: 0;
overflow: hidden;
color: var(--dsw-alias-label-tertiary);
@@ -364,14 +366,7 @@
}
.descriptionTruncated {
mask-image: linear-gradient(to right, #000 calc(100% - 28px), transparent);
}
.descriptionInline {
color: var(--dsw-alias-label-tertiary);
font-size: 14px;
line-height: 24px;
font-weight: 400;
mask-image: linear-gradient(to right, #000 calc(100% - 52px), transparent calc(100% - 24px));
}
.descriptionAction {
@@ -586,10 +581,6 @@
padding: 8px 6px;
}
.optionWithDescription {
padding-right: calc(6px + var(--description-action-space));
}
.descriptionAction {
right: 6px;
}
@@ -1,5 +1,5 @@
import {
useEffect, useId, useLayoutEffect, useMemo, useRef, useState,
useEffect, useId, useMemo, useRef, useState,
type ChangeEvent, type FocusEvent, type KeyboardEvent,
} from 'react'
import clsx from 'clsx'
@@ -242,68 +242,46 @@ function QuestionFlow({ pending, t, useStore, useQuestionCard, actions }: Questi
// oxlint-disable-next-line typescript/no-non-null-assertion
const draft = drafts[index]!
const hasOptions = (question.options?.length ?? 0) > 0
const [questionTruncated, setQuestionTruncated] = useState(false)
const [descriptionModes, setDescriptionModes] = useState<Map<number, 'inline' | 'block' | 'truncated'> | null>(null)
const [overflow, setOverflow] = useState<{
question: typeof question
title: boolean
descriptions: Set<number>
} | null>(null)
const questionTruncated = overflow?.question === question && overflow.title
useLayoutEffect(() => {
useEffect(() => {
if (answerSurface.current !== null) answerSurface.current.scrollTop = 0
}, [index])
useLayoutEffect(() => {
useEffect(() => {
const surface = answerSurface.current
if (surface === null || minimized) return
const title = surface.querySelector<HTMLElement>('[data-question-title]')
const rows = [...surface.querySelectorAll<HTMLElement>('[data-option-row]')]
const descriptions = [...surface.querySelectorAll<HTMLElement>('[data-option-description]')]
const measure = (): void => {
if (title !== null) setQuestionTruncated(title.scrollHeight > title.clientHeight + 1)
const next = new Map<number, 'inline' | 'block' | 'truncated'>()
for (const row of rows) {
const description = row.querySelector<HTMLElement>('[data-option-description]')
if (description === null) continue
const optionIndex = Number(row.dataset.optionIndex)
const copy = row.querySelector<HTMLElement>('[data-option-copy]')
const line = row.querySelector<HTMLElement>('[data-option-line]')
if (copy === null || line === null || copy.clientWidth === 0) {
next.set(optionIndex, 'truncated')
continue
const titleTruncated = title !== null && title.scrollHeight > title.clientHeight + 1
const truncated = new Set<number>()
for (const description of descriptions) {
if (description.scrollWidth > description.clientWidth + 1) {
truncated.add(Number(description.dataset.optionIndex))
}
const actionSpace = parseFloat(getComputedStyle(row).getPropertyValue('--description-action-space'))
// The action's reserved width must not make a fitting description appear truncated.
const fullWidth = copy.clientWidth + (row.querySelector('[data-option-details]') === null ? 0 : actionSpace)
const probe = line.cloneNode(true) as HTMLElement
probe.querySelector('[data-option-description]')?.remove()
const probeDescription = description.cloneNode(true) as HTMLElement
probeDescription.removeAttribute('id')
probeDescription.className = clsx(css.descriptionInline)
probe.append(probeDescription)
probe.style.cssText = 'position:fixed;top:-10000px;left:0;width:max-content;flex-wrap:nowrap;visibility:hidden;pointer-events:none'
document.body.append(probe)
const inlineWidth = probe.getBoundingClientRect().width
probeDescription.className = clsx(css.description)
probeDescription.style.width = 'max-content'
const blockWidth = probeDescription.getBoundingClientRect().width
probe.remove()
next.set(optionIndex, inlineWidth <= fullWidth + 1 ? 'inline'
: blockWidth <= fullWidth + 1 ? 'block' : 'truncated')
}
setDescriptionModes(current => current !== null && current.size === next.size
&& [...next].every(([optionIndex, mode]) => current.get(optionIndex) === mode) ? current : next)
setOverflow(current => current?.question === question && current.title === titleTruncated
&& current.descriptions.size === truncated.size
&& [...truncated].every(optionIndex => current.descriptions.has(optionIndex))
? current : { question, title: titleTruncated, descriptions: truncated })
}
measure()
const observer = typeof ResizeObserver === 'undefined' ? null : new ResizeObserver(measure)
if (title !== null) observer?.observe(title)
for (const row of rows) {
observer?.observe(row)
const copy = row.querySelector('[data-option-copy]')
if (copy !== null) observer?.observe(copy)
}
for (const description of descriptions) observer?.observe(description)
const fonts = 'fonts' in document ? document.fonts : null
fonts?.addEventListener('loadingdone', measure)
return () => {
observer?.disconnect()
fonts?.removeEventListener('loadingdone', measure)
}
}, [index, minimized, question.question, question.options])
}, [minimized, question])
const replaceProgress = (nextIndex: number, nextDrafts: QuestionDraftAnswer[]): void => {
actions.replace(pending.key, {
@@ -598,8 +576,7 @@ function QuestionFlow({ pending, t, useStore, useQuestionCard, actions }: Questi
const selected = draft.selected.includes(option.label)
const display = parseRecommendedLabel(option.label)
const hasDescription = option.description !== undefined && option.description.trim() !== ''
const descriptionMode = descriptionModes?.get(optionIndex) ?? 'truncated'
const showDetails = hasDescription && descriptionMode === 'truncated'
const showDetails = hasDescription && overflow?.question === question && overflow.descriptions.has(optionIndex)
const descriptionId = `${descriptionIdPrefix}-question-${String(index)}-option-${String(optionIndex)}-description`
return (
<div
@@ -614,7 +591,7 @@ function QuestionFlow({ pending, t, useStore, useQuestionCard, actions }: Questi
>
<button
type="button"
className={clsx(css.option, showDetails && css.optionWithDescription)}
className={css.option}
role={question.multiSelect === true ? 'checkbox' : 'radio'}
aria-checked={selected}
aria-label={display.label}
@@ -640,15 +617,13 @@ function QuestionFlow({ pending, t, useStore, useQuestionCard, actions }: Questi
{display.recommended && (
<span className={css.badge}>{t('option.recommended')}</span>
)}
{hasDescription && descriptionMode === 'inline' && (
<span id={descriptionId} className={css.descriptionInline} data-option-description>{option.description}</span>
)}
</span>
{hasDescription && descriptionMode !== 'inline' && (
{hasDescription && (
<span
id={descriptionId}
className={clsx(css.description, showDetails && css.descriptionTruncated)}
data-option-description
data-option-index={optionIndex}
>{option.description}</span>
)}
</span>
@@ -16,17 +16,11 @@ function declarations(selector: string): string[] {
}
describe('QuestionComposer compact long-copy layout', () => {
it('keeps fitting descriptions in the original option-line typography', () => {
expect(declarations('.descriptionInline')).toEqual(expect.arrayContaining([
'font-size: 14px',
'line-height: 24px',
'font-weight: 400',
]))
})
it('keeps the option description to one clipped line and fades only truncated text', () => {
expect(declarations('.description')).toEqual(expect.arrayContaining([
'display: block',
'width: max-content',
'max-width: 100%',
'min-width: 0',
'overflow: hidden',
'font-size: 13px',
@@ -35,7 +29,7 @@ describe('QuestionComposer compact long-copy layout', () => {
'white-space: nowrap',
]))
expect(declarations('.descriptionTruncated')).toContain(
'mask-image: linear-gradient(to right, #000 calc(100% - 28px), transparent)',
'mask-image: linear-gradient(to right, #000 calc(100% - 52px), transparent calc(100% - 24px))',
)
})
@@ -327,7 +327,11 @@ describe('QuestionComposer', () => {
}
})
it('opens a full option description without choosing the option', () => {
it('opens a full option description without choosing the option', ({ onTestFinished }) => {
const width = vi.spyOn(HTMLElement.prototype, 'scrollWidth', 'get').mockImplementation(function (this: HTMLElement) {
return this.hasAttribute('data-option-description') ? 500 : 0
})
onTestFinished(() => { cleanup(); width.mockRestore() })
const description = 'Remove the experimental client from every shipped template and require an explicit profile install.'
const { carrier } = wait([{
id: 'release', question: 'How should the release graph change?',
@@ -356,7 +360,11 @@ describe('QuestionComposer', () => {
expect(screen.getAllByText(description)).toHaveLength(1)
})
it('resets scrolling and an open option description when the question changes', () => {
it('resets scrolling and an open option description when the question changes', ({ onTestFinished }) => {
const width = vi.spyOn(HTMLElement.prototype, 'scrollWidth', 'get').mockImplementation(function (this: HTMLElement) {
return this.hasAttribute('data-option-description') ? 500 : 0
})
onTestFinished(() => { cleanup(); width.mockRestore() })
const firstDescription = 'First question detail that was pinned open.'
const secondDescription = 'Second question detail starts closed.'
const { carrier } = wait([
@@ -959,7 +967,11 @@ function reviewCard(review: readonly AskUserQuestionAnswerItem[] = RECORDED) {
}
describe('review card', () => {
it('walks a settled call record with every answer surface frozen', () => {
it('walks a settled call record with every answer surface frozen', ({ onTestFinished }) => {
const width = vi.spyOn(HTMLElement.prototype, 'scrollWidth', 'get').mockImplementation(function (this: HTMLElement) {
return this.hasAttribute('data-option-description') ? 500 : 0
})
onTestFinished(() => { cleanup(); width.mockRestore() })
const { carrier } = reviewCard()
render(<QuestionComposer matched={carrier} {...kit} />)