Merge pull request #5609 from deepseek-harness/fix/question-composer-compact-details

fix(ui-user-questions): keep long questions answerable
This commit is contained in:
Yudong Han authored and GitHub committed 2026-10-21 18:40:46 +08:00
commit d70d52ceb2
14 files changed
+1083 -98

No files matched your search

+521 -26
View File
@@ -68,9 +68,43 @@ async function capMetrics(field: Locator): Promise<{ textLines: number; scrolls:
})
}
// The options carry long descriptions on purpose: the squeeze assertion below
// needs option copy that WRAPS, which is the only text layout that reproduces a
// collapsed row painting its copy outside its own box.
/**
* Read how the composer draws one long-copy element: the lines it shows,
* whether CSS clipped it, whether its text overflows the line budget, and what
* its toggle does while that toggle is visible.
* @param element - a question heading or an option description.
* @returns the drawn state of the copy.
*/
function readCopy(element: HTMLElement): { lines: number; clipped: boolean; overflows: boolean; toggle: 'expand' | 'collapse' | undefined } {
const style = getComputedStyle(element)
const toggle = element.tagName === 'H2'
? element.nextElementSibling
: element.closest('[data-option-row]')?.querySelector(':scope > button:not([role])')
return {
// A clipped box keeps its full budget and gives the dropped line back through its bottom margin.
lines: Math.round((element.getBoundingClientRect().height + parseFloat(style.marginBottom)) / parseFloat(style.lineHeight)),
clipped: style.maskImage !== 'none',
overflows: element.scrollHeight > element.clientHeight + 0.5,
toggle: toggle instanceof HTMLElement && getComputedStyle(toggle).visibility === 'visible'
? toggle.getAttribute('aria-expanded') === 'true' ? 'collapse' : 'expand'
: undefined,
}
}
/**
* Tell whether an expanded copy's toggle sits below the copy's last text line.
* @param toggle - the copy's toggle button.
* @param copy - the expanded question heading or option description.
* @returns whether the toggle starts at or below the bottom of the text; false without the copy.
*/
function belowText(toggle: HTMLElement, copy: Element | null): boolean {
if (copy === null) return false
const textBottom = copy.getBoundingClientRect().bottom - parseFloat(getComputedStyle(copy).paddingBottom)
return toggle.getBoundingClientRect().top >= textBottom - 0.5
}
// The options carry descriptions on purpose: the squeeze assertion below
// verifies that each description keeps to its two-line budget in the card scrollport.
const PROMPT = 'Use the ask_user_question tool to ask me exactly one multi-select question with id "color", question "Which color do you prefer?", header "Pick one", and two options: label "Blue" with description "A cool recessive hue that reads as calm and trustworthy in long reading sessions and dense dashboards.", and label "Green" with description "A restful mid-spectrum hue with the highest perceived brightness, easiest on the eye over long sessions." Set multi_select to true. After I answer, reply with the single word DONE and stop.'
function isRecord(value: unknown): value is Record<string, unknown> {
@@ -338,53 +372,85 @@ describe('web e2e: resident question composer round trip', () => {
// This golden owns the stable question surface; the answered-state
// golden below owns the resulting transcript.
const snapshot = await captureStableAria(page, '[data-question-key]', scaffold.workspaceCwd)
await compareOrRefreshGolden(UI_EXPECTED, snapshot, MODE)
const descriptions = await page.locator('[data-question-key] [role="checkbox"]').evaluateAll(nodes =>
nodes.map(node => ({
label: node.getAttribute('aria-label'),
description: (node.getAttribute('aria-describedby') ?? '').split(/\s+/)
.filter(Boolean).map(id => document.getElementById(id)?.textContent ?? '').join(' '),
})))
await compareOrRefreshGolden(UI_EXPECTED, `${snapshot}\nChoice descriptions: ${JSON.stringify(descriptions)}`, MODE)
const sidebar = await captureStableAria(page, '[role="treeitem"][aria-selected="true"]', scaffold.workspaceCwd)
await compareOrRefreshGolden(SIDEBAR_EXPECTED, sidebar, MODE)
}
// Squeezed card: the option rows are the capped card's scroll content, so
// shrinking the seat must push overflow into the option list, never
// collapse a row below the height its own copy needs — a collapsed row
// paints its centered copy outside the row box, over the title and the
// neighbouring rows. Measured on the live composer at seat heights that
// force the cap, then restored for the answer gesture below. Replay only:
// record mode must reach the recording write below, not abort on layout.
// Squeezed card: the question and compact option rows share one scrollport,
// while navigation and submission stay outside it. Measured on the live
// composer at seat heights that force the cap, then restored for the answer
// gesture below. Replay only: record mode must reach the recording write
// below, not abort on layout.
const originalViewport = page.viewportSize() ?? { width: 1680, height: 1000 }
if (MODE !== 'record') {
const original = page.viewportSize() ?? { width: 1680, height: 1000 }
const scrollStates: boolean[] = []
for (const height of [520, 440, 380]) {
await page.setViewportSize({ width: 900, height })
await page.setViewportSize({ width: 640, height })
const squeeze = await composer.evaluate((card) => {
// Role/ARIA selectors, not the CSS-module class names: the built
// client hashes those.
const rows = [...card.querySelectorAll<HTMLElement>(
'[role="radio"], [role="checkbox"], [aria-expanded]',
)]
const rows = [...card.querySelectorAll<HTMLElement>('[data-option-row]')]
const spill = rows.map(row => Math.max(...[...row.children].map((child) => {
const box = row.getBoundingClientRect()
const inner = child.getBoundingClientRect()
return Math.max(box.top - inner.top, inner.bottom - box.bottom)
})))
const list = card.querySelector<HTMLElement>('[data-question-scroll]')
const title = card.querySelector('h2')
return {
rows: rows.length,
spill: Math.max(...spill),
// Wrapped option text is what overflows a collapsed row, and a
// scrolling list proves the seat is genuinely capped. Without both,
// the spill assertion would hold vacuously.
wrappedRows: rows.filter(row => row.getBoundingClientRect().height > 42).length,
scrolls: list === null ? false : list.scrollHeight > list.clientHeight,
titleInScrollport: list?.contains(title) === true,
}
})
expect(squeeze.rows).toBeGreaterThan(0)
expect(squeeze.wrappedRows).toBeGreaterThan(0)
expect(squeeze.scrolls).toBe(true)
for (const description of await composer.locator('[data-option-description]').all()) {
const copy = await description.evaluate(readCopy)
expect(copy.lines).toBeLessThanOrEqual(2)
expect(copy.clipped).toBe(copy.overflows)
expect(copy.toggle).toBe(copy.clipped ? 'expand' : undefined)
}
scrollStates.push(squeeze.scrolls)
expect(squeeze.titleInScrollport).toBe(true)
expect(await composer.getByRole('button', { name: 'Submit', exact: true }).isVisible()).toBe(true)
// Sub-pixel tolerance: every row's copy stays inside its border box.
expect(squeeze.spill).toBeLessThan(0.6)
}
await page.setViewportSize(original)
expect(scrollStates).toContain(true)
await page.setViewportSize(originalViewport)
}
// The narrowest supported card clips Blue's description to one line; its
// toggle expands the text in place without choosing Blue.
await page.setViewportSize({ width: 390, height: 700 })
const blue = composer.getByRole('checkbox', { name: 'Blue' })
const blueText = composer.locator('[data-option-description]').first()
const blueToggle = composer.locator(`button[aria-controls=${JSON.stringify(await blueText.getAttribute('id'))}]`)
await expect.poll(() => blueText.evaluate(readCopy)).toEqual({ lines: 1, clipped: true, overflows: true, toggle: 'expand' })
// A clipped description's dropped line takes no clicks: the custom row's top edge belongs to it.
expect(await composer.locator('[data-option-description]').last().evaluate(readCopy)).toMatchObject({ clipped: true })
expect(await composer.locator('textarea').evaluate((field) => {
const row = field.closest('[class*="customRow"]')
if (!(row instanceof HTMLElement)) return false
const box = row.getBoundingClientRect()
const hit = document.elementFromPoint(box.left + box.width / 2, box.top + 2)
return hit !== null && row.contains(hit)
})).toBe(true)
await blueToggle.click()
expect(await blue.getAttribute('aria-checked')).toBe('false')
expect(await blueToggle.getAttribute('aria-expanded')).toBe('true')
expect(await blueText.evaluate(readCopy)).toMatchObject({ clipped: false, overflows: false, toggle: 'collapse' })
await blueToggle.click()
// A collapsed copy's scroll timeline reports the overflow from the next frame.
await expect.poll(() => blueText.evaluate(readCopy)).toEqual({ lines: 1, clipped: true, overflows: true, toggle: 'expand' })
await page.setViewportSize(originalViewport)
// Multi-line custom answer: the field is a textarea whose hidden mirror
// owns the box height, so a soft-wrapped or line-broken draft GROWS the
// field instead of scrolling one line, and Shift+Enter breaks the line
@@ -415,7 +481,6 @@ describe('web e2e: resident question composer round trip', () => {
await custom.fill('')
}
const blue = composer.getByRole('checkbox', { name: 'Blue' })
await blue.click()
await custom.fill('Include accessibility notes')
expect(await blue.getAttribute('aria-checked')).toBe('true')
@@ -536,6 +601,436 @@ describe('web e2e: resident question composer round trip', () => {
await expect.poll(() => page.locator('[data-question-key]').count(), { timeout: 10_000 }).toBe(0)
}, 60_000)
it.skipIf(MODE === 'record')('keeps short option explanations beside their labels', async () => {
const sessionId = answeredSession
expect(sessionId).toBeDefined()
const agent = scaffold.ctx.agents.get(sessionId as SessionId)
expect(agent).toBeDefined()
const controller = new AbortController()
const asked = scaffold.ctx.userQuestions.ask({
agent: agent as NonNullable<typeof agent>,
signal: controller.signal,
questions: [{
id: 'reasoning',
header: '确认思考等级',
question: '选择 HF provider 的思考等级?',
options: [
{ label: '高(high)', description: '倾向更充分的推理,通常更慢、消耗更多 token,需模型支持。' },
{ label: '中(medium)', description: '平衡推理深度与速度;需模型支持。' },
{ label: '低(low)', description: '优先速度与较低 token 消耗;需模型支持。' },
{ label: '先查看当前配置', description: '先确认 HF provider 的模型及其支持的思考参数,再决定。' },
],
}],
})
const outcome = asked.then(value => ({ value }), (error: unknown) => ({ error }))
const composer = page.locator('[data-question-key]')
const original = page.viewportSize() ?? { width: 1680, height: 1000 }
let completed = false
try {
await page.setViewportSize({ width: 1328, height: 720 })
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: '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]')
if (label === null || description === null) return null
const style = getComputedStyle(description)
return {
sameLine: description.getBoundingClientRect().top < label.getBoundingClientRect().bottom,
fontSize: style.fontSize,
lineHeight: style.lineHeight,
maskImage: style.maskImage,
}
}))
expect(rows).toHaveLength(4)
expect(rows).toEqual(Array.from({ length: 4 }, () => ({
sameLine: true, fontSize: '13px', lineHeight: '20px', maskImage: 'none',
})))
await composer.getByRole('radio', { name: '中(medium)' }).click()
await composer.getByRole('button', { name: 'Submit', exact: true }).click()
expect(await outcome).toEqual({ value: { answers: [{ id: 'reasoning', selected: ['中(medium)'] }] } })
completed = true
} finally {
controller.abort()
const cleanup = await Promise.allSettled([outcome, page.setViewportSize(original)])
if (completed) {
const failure = cleanup.find((result): result is PromiseRejectedResult => result.status === 'rejected')
if (failure !== undefined) throw failure.reason
}
}
}, 60_000)
it.skipIf(MODE === 'record')('keeps long questions answerable in short and zoomed viewports', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-question-long-copy'))
const sessionId = answeredSession
expect(sessionId).toBeDefined()
const agent = scaffold.ctx.agents.get(sessionId as SessionId)
expect(agent).toBeDefined()
const question = Array.from({ length: 8 }, () => 'The release gate reports that the installed application graph still contains an experimental package through a default dependency. Which approach should we use to preserve explicit installation while keeping the supported application graph valid?').join(' ')
const detail = 'Review the shipped profile, package ownership, upgrade impact, and the path users follow before choosing. The answer must preserve the existing release invariant, avoid a hidden compatibility path, and remain understandable to people who did not participate in the implementation discussion.'
const description = Array.from({ length: 48 }, (_, index) =>
`Migration step ${String(index + 1)} removes the experimental client from shipped templates, requires an explicit profile install, names the affected users, and records the cross-platform validation before rollout.`).join(' ')
const controller = new AbortController()
const asked = scaffold.ctx.userQuestions.ask({
agent: agent as NonNullable<typeof agent>,
signal: controller.signal,
questions: [{
id: 'release',
header: 'Release decision',
question,
detail,
multiSelect: true,
options: [
{ label: 'Explicit install', description },
{ label: 'Keep the default edge', description: 'Keep the current setup.' },
{ label: 'Move the package', description: 'Move it to a separately installed package with an explicit profile entry.' },
],
}, { id: 'followup', question: 'Who should own the rollout?', options: [{ label: 'Release team' }] }],
})
const outcome = asked.then(value => ({ value }), (error: unknown) => ({ error }))
const composer = page.locator('[data-question-key]')
const original = page.viewportSize() ?? { width: 1680, height: 1000 }
let bodyCompleted = false
try {
await composer.waitFor({ timeout: 30_000 })
const heading = composer.getByRole('heading', { name: question })
const descriptionOf = (label: string) => composer.locator('[data-option-row]', {
has: page.getByRole('checkbox', { name: label, exact: true }),
}).locator('[data-option-description]')
const toggleFor = async (copy: Locator) =>
composer.locator(`button[aria-controls=${JSON.stringify(await copy.getAttribute('id'))}]`)
const readMetrics = () => composer.evaluate((card) => {
const scroll = card.querySelector<HTMLElement>('[data-question-scroll]')
const title = card.querySelector<HTMLElement>('h2')
const submit = [...card.querySelectorAll<HTMLButtonElement>('button')]
.find(button => button.textContent === 'Next')
if (scroll === null || title === null || submit === undefined) return null
const submitBox = submit.getBoundingClientRect()
return {
footerFits: submitBox.top >= -0.5 && submitBox.bottom <= window.innerHeight + 0.5,
scrolls: scroll.scrollHeight > scroll.clientHeight,
titleInScrollport: scroll.contains(title),
}
})
// Cover common desktop sizes plus the CSS-pixel equivalents of a
// progressively zoomed 640x420 window without changing browser-global zoom.
const scrollStates: boolean[] = []
for (const viewport of [
{ width: 1440, height: 900 },
{ width: 900, height: 600 },
{ width: 640, height: 420 },
{ width: 512, height: 336 },
{ width: 427, height: 280 },
{ width: 390, height: 700 },
]) {
await page.setViewportSize(viewport)
await expect.poll(readMetrics).not.toBeNull()
const metrics = await readMetrics()
expect(metrics?.footerFits).toBe(true)
expect(metrics?.titleInScrollport).toBe(true)
scrollStates.push(metrics?.scrolls ?? false)
// Past its three-line budget the question keeps two lines; past its
// two-line budget a description keeps one. Copy within budget shows in full.
await expect.poll(() => heading.evaluate(readCopy))
.toEqual({ lines: 2, clipped: true, overflows: true, toggle: 'expand' })
expect(await descriptionOf('Explicit install').evaluate(readCopy))
.toEqual({ lines: 1, clipped: true, overflows: true, toggle: 'expand' })
expect(await descriptionOf('Keep the default edge').evaluate(readCopy))
.toEqual({ lines: 1, clipped: false, overflows: false, toggle: undefined })
for (const copy of await composer.locator('[data-option-description]').all()) {
const drawn = await copy.evaluate(readCopy)
expect(drawn.clipped).toBe(drawn.overflows)
expect(drawn.toggle).toBe(drawn.clipped ? 'expand' : undefined)
expect(drawn.lines).toBeLessThanOrEqual(drawn.clipped ? 1 : 2)
}
if (viewport.width === 512) {
expect(await descriptionOf('Move the package').evaluate(readCopy))
.toEqual({ lines: 2, clipped: false, overflows: false, toggle: undefined })
}
const textMetrics = await composer.locator('[data-option-description]').evaluateAll(nodes => nodes.map((node) => {
const style = getComputedStyle(node)
const rect = node.getBoundingClientRect()
return { fontSize: style.fontSize, lineHeight: style.lineHeight,
fitsViewport: rect.left >= 0 && rect.right <= innerWidth }
}))
expect(textMetrics).toEqual(Array.from({ length: 3 }, () => ({
fontSize: '13px', lineHeight: '20px', fitsViewport: true,
})))
}
expect(scrollStates).toContain(false)
expect(scrollStates).toContain(true)
// A clipped question's dropped line takes no clicks: the detail's first line belongs to the detail.
expect(await composer.getByText(detail, { exact: true }).evaluate((text) => {
const box = text.getBoundingClientRect()
const hit = document.elementFromPoint(box.left + 8, box.top + 3)
return hit !== null && text.contains(hit)
})).toBe(true)
// The question expands in place; its choices move down inside the
// 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('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('Collapse')
const expandedQuestion = await heading.evaluate(readCopy)
expect(expandedQuestion).toMatchObject({ clipped: false, overflows: false, toggle: 'collapse' })
expect(expandedQuestion.lines).toBeGreaterThan(3)
expect(await questionToggle.evaluate(belowText, await heading.elementHandle())).toBe(true)
expect(await readMetrics()).toEqual({ footerFits: true, scrolls: true, titleInScrollport: true })
await questionToggle.click()
await expect.poll(() => heading.evaluate(readCopy)).toEqual({ lines: 2, clipped: true, overflows: true, toggle: 'expand' })
const detailText = composer.getByText(detail, { exact: true })
expect(await detailText.evaluate(node => node.closest('[data-question-scroll]') !== null)).toBe(true)
await detailText.scrollIntoViewIfNeeded()
expect(await detailText.isVisible()).toBe(true)
expect(await composer.getByRole('button', { name: 'Next', exact: true }).isVisible()).toBe(true)
// A description expands in place without choosing its option, and its
// toggle stays described by the text it controls.
const explicit = composer.getByRole('checkbox', { name: 'Explicit install' })
const explicitText = descriptionOf('Explicit install')
const descriptionToggle = await toggleFor(explicitText)
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('Expand')
await descriptionToggle.click()
expect(await explicit.getAttribute('aria-checked')).toBe('false')
const expandedDescription = await explicitText.evaluate(readCopy)
expect(expandedDescription).toMatchObject({ clipped: false, overflows: false, toggle: 'collapse' })
expect(expandedDescription.lines).toBeGreaterThan(2)
expect(await descriptionToggle.evaluate(belowText, await explicitText.elementHandle())).toBe(true)
expect(await explicitText.evaluate((text) => {
const row = text.closest('[data-option-row]') as HTMLElement
return [...row.querySelectorAll<HTMLElement>('*')].every(child =>
child.getBoundingClientRect().bottom <= row.getBoundingClientRect().bottom + 0.5)
})).toBe(true)
// The toggle's icon keeps non-text contrast on the card in both themes.
const palette: string[] = []
const originalDarkTheme = await page.locator('body').getAttribute('data-ds-dark-theme')
try {
for (const dark of [false, true]) {
await page.evaluate((value) => { document.body.toggleAttribute('data-ds-dark-theme', value) }, dark)
const colors = await descriptionToggle.evaluate((toggle) => {
const card = toggle.closest('section')
return card === null ? null : { text: getComputedStyle(toggle).color, surface: getComputedStyle(card).backgroundColor }
})
if (colors === null) throw new Error('The question card is unavailable')
const channels = (color: string) => color.match(/[\d.]+/g)?.slice(0, 3).map(Number) ?? []
const luminance = (color: string) => channels(color).reduce((total, channel, index) => {
const normalized = channel / 255
const linear = normalized <= 0.04045 ? normalized / 12.92 : ((normalized + 0.055) / 1.055) ** 2.4
return total + linear * [0.2126, 0.7152, 0.0722][index]!
}, 0)
const foreground = luminance(colors.text)
const background = luminance(colors.surface)
expect((Math.max(foreground, background) + 0.05) / (Math.min(foreground, background) + 0.05))
.toBeGreaterThanOrEqual(3)
palette.push(colors.surface)
}
expect(palette[0]).not.toBe(palette[1])
} finally {
await page.evaluate((previous) => {
if (previous === null) document.body.removeAttribute('data-ds-dark-theme')
else document.body.setAttribute('data-ds-dark-theme', previous)
}, originalDarkTheme)
}
await descriptionToggle.click()
await expect.poll(() => explicitText.evaluate(readCopy))
.toEqual({ lines: 1, clipped: true, overflows: true, toggle: 'expand' })
await composer.getByRole('checkbox', { name: 'Explicit install' }).click()
const answer = composer.getByRole('textbox')
await answer.fill('Keep the migration steps explicit.')
await answer.press('Enter')
await composer.getByRole('heading', { name: 'Who should own the rollout?' }).waitFor()
expect(await answer.evaluate(node => document.activeElement === node)).toBe(true)
await page.keyboard.type('Release owner')
expect(await answer.inputValue()).toBe('Release owner')
await composer.getByRole('button', { name: 'Submit', exact: true }).click()
expect(await outcome).toEqual({
value: {
answers: [{
id: 'release',
selected: ['Explicit install'],
custom: 'Keep the migration steps explicit.',
}, { id: 'followup', selected: [], custom: 'Release owner' }],
},
})
await expect.poll(() => page.locator('[data-question-key]').count(), { timeout: 10_000 }).toBe(0)
bodyCompleted = true
} finally {
controller.abort()
const cleanup = await Promise.allSettled([outcome, page.setViewportSize(original)])
if (bodyCompleted) {
const failure = cleanup.find((result): result is PromiseRejectedResult => result.status === 'rejected')
if (failure !== undefined) throw failure.reason
}
}
}, 60_000)
it.skipIf(MODE === 'record')('re-evaluates long copy when the chat column or the window changes width', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-question-chat-width'))
const sessionId = answeredSession
expect(sessionId).toBeDefined()
const agent = scaffold.ctx.agents.get(sessionId as SessionId)
expect(agent).toBeDefined()
// Sized for the chat-width drag range: the question takes three lines or
// fewer in the widest chat column and more in the narrowest, and the staged
// description takes two lines or fewer and more.
const question = `${Array.from({ length: 2 }, () => 'The rollout plan must name the release owner, the verification lanes that gate it, and the rollback path before the candidate is cut.').join(' ')} Which plan should we adopt?`
const staged = 'Stage the rollout behind the release channel first, keep the experimental client out of every shipped template, ask plugin authors to declare the dependency explicitly, and publish the upgrade note together with the cross-platform validation results.'
const explicit = Array.from({ length: 24 }, (_, index) =>
`Step ${String(index + 1)} removes the experimental client from shipped templates and records the validation before rollout.`).join(' ')
const controller = new AbortController()
const asked = scaffold.ctx.userQuestions.ask({
agent: agent as NonNullable<typeof agent>,
signal: controller.signal,
questions: [{
id: 'rollout',
header: 'Rollout decision',
question,
options: [
{ label: 'Explicit install', description: explicit },
{ label: 'Stage the rollout', description: staged },
{ label: 'Keep the default edge', description: 'Keep the current setup.' },
],
}],
})
const outcome = asked.then(value => ({ value }), (error: unknown) => ({ error }))
const composer = page.locator('[data-question-key]')
const frame = page.locator('[style*="grid-template-columns"]').first()
const original = page.viewportSize() ?? { width: 1680, height: 1000 }
let bodyCompleted = false
try {
await page.setViewportSize({ width: 1680, height: 1000 })
await composer.waitFor({ timeout: 30_000 })
const heading = composer.getByRole('heading', { name: question })
const descriptionOf = (label: string) => composer.locator('[data-option-row]', {
has: page.getByRole('radio', { name: label, exact: true }),
}).locator('[data-option-description]')
const toggleFor = async (copy: Locator) =>
composer.locator(`button[aria-controls=${JSON.stringify(await copy.getAttribute('id'))}]`)
// Settled layout: no AppFrame column transition in flight, then every
// collapsed copy drawn as its own overflow decides, the expanded copy
// still expanded, and the actions reachable.
const settle = async () => {
await expect.poll(() => frame.evaluate(node => node.hasAttribute('data-animating'))).toBe(false)
const read = async () => {
const card = await composer.locator('section').evaluate((section) => {
const scroll = section.querySelector<HTMLElement>('[data-question-scroll]')
const submit = [...section.querySelectorAll<HTMLButtonElement>('button')]
.find(button => button.textContent === 'Submit')
const submitBox = submit?.getBoundingClientRect()
return {
width: Math.round(section.getBoundingClientRect().width),
actionsReachable: submitBox !== undefined && submitBox.top >= -0.5 && submitBox.bottom <= window.innerHeight + 0.5,
titleInScrollport: scroll?.contains(section.querySelector('h2')) === true,
}
})
return {
card,
question: await heading.evaluate(readCopy),
explicit: await descriptionOf('Explicit install').evaluate(readCopy),
staged: await descriptionOf('Stage the rollout').evaluate(readCopy),
short: await descriptionOf('Keep the default edge').evaluate(readCopy),
}
}
await expect.poll(async () => {
const state = await read()
const decided = (copy: Awaited<ReturnType<typeof read>>['question'], budget: number) =>
copy.clipped === copy.overflows && copy.toggle === (copy.clipped ? 'expand' : undefined)
&& copy.lines <= (copy.clipped ? budget - 1 : budget)
return state.card.actionsReachable && state.card.titleInScrollport
&& decided(state.question, 3) && decided(state.staged, 2) && decided(state.short, 2)
&& !state.explicit.clipped && !state.explicit.overflows && state.explicit.toggle === 'collapse'
}).toBe(true)
return await read()
}
// The right transcript handle widens the content column by twice its
// outward travel. The composer seat covers the handle's lower part, so
// the gesture grabs it beside the transcript.
const dragChatWidth = async (travel: number) => {
const box = await page.locator('[data-width-handle="right"]').boundingBox()
if (box === null) throw new Error('The chat width handle is not rendered')
const x = box.x + box.width / 2
const y = box.y + 120
await page.mouse.move(x, y)
await page.mouse.down()
try {
await page.mouse.move(x + travel, y, { steps: 8 })
} finally {
await page.mouse.up()
}
}
// Expanded copy is the user's choice and survives every width change below.
const explicitToggle = await toggleFor(descriptionOf('Explicit install'))
await explicitToggle.click()
expect(await explicitToggle.getAttribute('aria-expanded')).toBe('true')
expect(await composer.getByRole('radio', { name: 'Explicit install', exact: true }).getAttribute('aria-checked')).toBe('false')
await dragChatWidth(-400)
const narrow = await settle()
expect(narrow.question).toEqual({ lines: 2, clipped: true, overflows: true, toggle: 'expand' })
expect(narrow.staged).toEqual({ lines: 1, clipped: true, overflows: true, toggle: 'expand' })
await dragChatWidth(600)
const wide = await settle()
expect(wide.card.width).toBeGreaterThan(narrow.card.width)
expect(wide.question).toMatchObject({ clipped: false, overflows: false, toggle: undefined })
expect(wide.staged).toEqual({ lines: 2, clipped: false, overflows: false, toggle: undefined })
expect(wide.short).toEqual({ lines: 1, clipped: false, overflows: false, toggle: undefined })
// The right sidebar narrows the conversation column, and collapsing it restores the wide state.
await page.getByRole('button', { name: 'Open right sidebar', exact: true }).click()
const withSidebar = await settle()
expect(withSidebar.card.width).toBeLessThan(wide.card.width)
await page.getByRole('button', { name: 'Collapse right sidebar', exact: true }).click()
expect(await settle()).toEqual(wide)
// A smaller window clamps the dragged width; restoring the window restores it.
await page.setViewportSize({ width: 900, height: 700 })
const smallWindow = await settle()
expect(smallWindow.card.width).toBeLessThan(wide.card.width)
expect(smallWindow.question).toEqual({ lines: 2, clipped: true, overflows: true, toggle: 'expand' })
expect(smallWindow.staged).toEqual({ lines: 1, clipped: true, overflows: true, toggle: 'expand' })
await page.setViewportSize({ width: 1680, height: 1000 })
expect(await settle()).toEqual(wide)
await explicitToggle.click()
await expect.poll(() => descriptionOf('Explicit install').evaluate(readCopy))
.toEqual({ lines: 1, clipped: true, overflows: true, toggle: 'expand' })
await composer.getByRole('radio', { name: 'Stage the rollout', exact: true }).click()
await composer.getByRole('button', { name: 'Submit', exact: true }).click()
expect(await outcome).toEqual({ value: { answers: [{ id: 'rollout', selected: ['Stage the rollout'] }] } })
await expect.poll(() => page.locator('[data-question-key]').count(), { timeout: 10_000 }).toBe(0)
bodyCompleted = true
} finally {
controller.abort()
// The dragged width is a persisted preference; dropping it and resizing republishes the default.
const cleanup = await Promise.allSettled([
outcome,
page.evaluate(() => { localStorage.removeItem('dsh.conversation.contentWidth') })
.then(() => page.setViewportSize(original)),
])
if (bodyCompleted) {
const failure = cleanup.find((result): result is PromiseRejectedResult => result.status === 'rejected')
if (failure !== undefined) throw failure.reason
}
}
}, 90_000)
})
describe.skipIf(MODE === 'record')('web e2e: cancelled question transcript', () => {
+9 -1
View File
@@ -224,6 +224,8 @@ describe.skipIf(MODE === 'record')('web e2e: retained steering and a post-stop m
let hostClock: MockInstance<typeof Date.now> | undefined
const sessionEvents: SessionEvent[] = []
const requestInputs: string[][] = []
let firstRequestSignal: AbortSignal | undefined
const questionStarted = Promise.withResolvers<undefined>()
const releaseQuestion = Promise.withResolvers<undefined>()
const FOLLOWUP = 'Also acknowledge this second message.'
const RESPONSE = "Great, let's move forward. BANANA!"
@@ -239,9 +241,13 @@ describe.skipIf(MODE === 'record')('web e2e: retained steering and a post-stop m
message.role === 'user'
? message.content.flatMap(block => block.type === 'text' ? [block.text] : [])
: []))
if (requestInputs.length === 1) firstRequestSignal = options.signal
for await (const chunk of next()) {
yield chunk
if (requestInputs.length === 1 && chunk.type === 'block-start') await releaseQuestion.promise
if (requestInputs.length === 1 && chunk.type === 'block-start') {
questionStarted.resolve(undefined)
await releaseQuestion.promise
}
}
}, { prepend: true })
browser = await chromium.launch()
@@ -271,11 +277,13 @@ describe.skipIf(MODE === 'record')('web e2e: retained steering and a post-stop m
const input = page.locator('[data-composer-input][contenteditable="true"]').first()
await input.fill(PROMPT)
await input.press('Enter')
await questionStarted.promise
await expect.poll(() => requestInputs.length, { timeout: 10_000 }).toBe(1)
await input.fill(STEER)
await input.press('Meta+Enter')
await page.locator('[data-pending-steering]').filter({ hasText: STEER }).waitFor({ timeout: 10_000 })
await page.getByRole('button', { name: 'Stop generating', exact: true }).click()
await expect.poll(() => firstRequestSignal?.aborted).toBe(true)
releaseQuestion.resolve(undefined)
await expect.poll(() => sessionEvents.filter(event => event.type === 'turn/end').length, { timeout: 10_000 }).toBe(1)
expect(claimedMessages(sessionEvents, STEER)).toHaveLength(0)
@@ -18,8 +18,8 @@
en: 8d3815c19858ac85
zh: 94fc905fa202d334
/deepseek-ai-dsh-client-ui-user-questions/use-this-package/answering:
en: 0fa156d3d698cf0e
zh: cbcd082b9a188d6b
en: 8900fbf9cbd72083
zh: 67a951d58a029a58
/deepseek-ai-dsh-client-ui-user-questions/use-this-package/reading-a-settled-question-back:
en: 10975f68ff9c6fcc
zh: 92246aadc363f0eb
@@ -27,8 +27,8 @@
en: 6b12f1362c614a39
zh: 27d9cf54d4cbd5d2
/deepseek-ai-dsh-client-ui-user-questions/use-this-package/failure-and-recovery:
en: 3509678f4d183834
zh: 625542b79e434eb5
en: 3ad0e0fd224aabfc
zh: db15f400e6f2bfd7
/deepseek-ai-dsh-client-ui-user-questions/understand-the-implementation:
en: 77e1b2585acdf67b
zh: d433ec185c6b8f57
+2 -2
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. The capped card keeps its title, navigation, and submission actions fixed while long detail and choices share an internal scroll region. "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`.
Fixed approval questions use the same pending-question and answer channel. A feature plugin can supply a higher-priority composer presentation. When none matches, the generic form keeps the offered options answerable; the caller still decides which responses grant permission.
@@ -45,7 +45,7 @@ A `plan-review` intent — set by `dsh-plan-mode` on the `exit_plan_mode` review
### Failure and recovery
An answer field autofocuses only when its answer channel is ready and no countdown exists. Manual focus and blur during the claim handshake are retained: the countdown starts paused only if the answer surface still has focus when the remaining duration arrives.
An answer field autofocuses only when its answer channel is ready and no countdown exists. Custom-answer focus survives Enter navigation between option questions; navigation resets the question scroll position and collapses expanded copy. Manual focus and blur during the claim handshake are retained: the countdown starts paused only if the answer surface still has focus when the remaining duration arrives.
Returning a local answer does not release its foreground claim: the Host closes that stream after accepting the waterfall outcome. The plugin owns the claim through this delivery interval. Explicit delegation releases it before calling the next answerer, and plugin disposal cancels any remaining claims.
@@ -29,7 +29,7 @@ Web 客户端在聊天输入框旁显示 agent(智能体)的提问。用户
### 作答
用户打开或编辑自定义答案时,多选题草稿会保留已选中的标签,因此提交项可以同时携带 `selected` 与 `custom`;单选题的自定义答案仍保持互斥。问题详情复用助手输出的 `MarkdownText` 原语,包括其 GFM 渲染与不受信任内容策略。限高卡片保持标题、导航与提交动作固定,超长的详情与选项共享内部滚动区。「跳过」会保留其他草稿,并为该项发出既有的空 `{ selected: [] }` 结果。关闭不是一种回答:Host 以工具调用命名的问题只是离开编辑器位置,其 `ask_user_question` 工具调用行会把面板带回来;不携带工具调用的请求没有可返回的行,关闭它仍以 `ASK_CANCELLED` 拒绝整个等待。
用户打开或编辑自定义答案时,多选题草稿会保留已选中的标签,因此提交项可以同时携带 `selected` 与 `custom`;单选题的自定义答案仍保持互斥。问题详情复用助手输出的 `MarkdownText` 原语,包括其 GFM 渲染与不受信任内容策略。限高卡片保持导航与提交动作固定,问题正文、超长详情和选项共享内部滚动区。问题在实际排版不超过三行时完整显示;更长的问题只显示两行,第二行末尾渐隐并放置向下箭头的展开按钮,点击后原地展开。选项说明使用 13px 字号和 20px 行高,放得下时排在标签旁,否则排在标签下方;说明不超过两行时完整显示,更长的说明只显示一行,同样渐隐并放置展开按钮。展开后的文字下方单独一行显示向上箭头的收起按钮。两种按钮名为「展开」或「收起」,悬停时显示同样的提示,并以其控制的文字作为无障碍描述。由 CSS 滚动时间线判断文字是否溢出;不支持该特性的浏览器完整显示全部文字。「跳过」会保留其他草稿,并为该项发出既有的空 `{ selected: [] }` 结果。关闭不是一种回答:Host 以工具调用命名的问题只是离开编辑器位置,其 `ask_user_question` 工具调用行会把面板带回来;不携带工具调用的请求没有可返回的行,关闭它仍以 `ASK_CANCELLED` 拒绝整个等待。
固定审批问题复用待处理问题和回答通道。功能插件可以提供优先级更高的 composer;没有匹配的呈现时,通用表单仍可选择并提交原有选项,由调用方判断哪些回答可以授予权限。
@@ -45,7 +45,7 @@ Web 客户端在聊天输入框旁显示 agent(智能体)的提问。用户
### 失败与恢复
回答输入框只有在回答通道就绪且没有倒计时时才自动聚焦。接手握手期间的手动聚焦与失焦会被保留:剩余时长到达时,只有回答区域仍持有焦点,倒计时才以暂停状态开始。
回答输入框只有在回答通道就绪且没有倒计时时才自动聚焦。在带选项的问题间按 Enter 切题时,自定义回答框保留焦点;切题会重置问题滚动位置并收起已展开的文字。接手握手期间的手动聚焦与失焦会被保留:剩余时长到达时,只有回答区域仍持有焦点,倒计时才以暂停状态开始。
本地返回回答不会释放前台接手记录:Host 接受 waterfall 结果后才关闭该 stream。插件在这段传输期间继续拥有接手记录。显式委托会在调用下一个回答方前释放它,插件卸载则取消剩余接手记录。
@@ -60,6 +60,7 @@
align-items: center;
gap: 4px;
flex-shrink: 0;
margin-left: auto;
}
.waitButton {
@@ -85,6 +86,7 @@
}
.headingBlock {
flex: 1;
min-width: 0;
}
@@ -103,8 +105,70 @@
font-weight: 500;
}
/* Long copy keeps one line fewer than its budget once it overflows: a
question shows in full up to three lines, an option description up to two;
longer copy drops its last line, fades at the end of the line before, and
shows its expand button there. Each box keeps its full budget, plus a 2px
allowance for glyphs taller than the line, so the scroll timeline that
reports the overflow never depends on the collapsed layout. A negative
bottom margin, not a smaller box, gives the dropped line back to the row,
and clip-path removes that line from hit testing as well as from view. */
.questionTitle {
position: relative;
flex-shrink: 0;
margin: 5px 24px 0;
timeline-scope: --question-copy;
}
.questionTitle[data-expanded] {
padding-bottom: 24px;
}
.titlePreview {
max-height: calc(3lh + 2px);
margin: 0 0 -2px;
padding-bottom: 2px;
overflow: hidden;
overflow-wrap: anywhere;
white-space: pre-wrap;
scroll-timeline: --question-copy block;
/* Longhands: the bundled lightningcss folds animation-timeline into the
animation shorthand, a form browsers reject. */
animation-name: question-clipped;
animation-fill-mode: both;
animation-timeline: --question-copy;
}
@keyframes question-clipped {
from, to {
margin-bottom: calc(-1lh - 2px);
clip-path: inset(0 0 calc(1lh + 2px) 0);
mask-image: linear-gradient(#000, #000), linear-gradient(to right, #000 calc(100% - 56px), transparent calc(100% - 28px));
mask-size: 100% 1lh, 100% 1lh;
mask-position: 0 0, 0 1lh;
mask-repeat: no-repeat;
}
}
.questionToggle {
position: absolute;
right: -4px;
bottom: -1px;
animation-name: copy-toggle-shown;
animation-fill-mode: both;
animation-timeline: --question-copy;
}
.cardMinimized .title {
display: -webkit-box;
overflow: hidden;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
}
.detail {
margin: 0 2px 8px;
flex-shrink: 0;
margin: 0 24px 8px;
}
.footerActions {
@@ -175,29 +239,32 @@
padding: 4px 12px;
}
.optionRow {
position: relative;
width: 100%;
min-height: 40px;
flex-shrink: 0;
border: 0.5px solid transparent;
border-radius: var(--dsw-radius-md);
background: transparent;
transition: background-color 120ms ease, border-color 120ms ease;
timeline-scope: --description-copy;
}
.option {
display: flex;
/* flex-start, not center: with a wrapped description the indicator must
stay on the FIRST line (centering drifts it down the taller copy block).
The 8px padding makes a single-line row 40px exactly, so nothing reads
as top-heavy; .number/.checkbox re-center against the first line box. */
/* flex-start keeps the indicator aligned with the first label line. */
align-items: flex-start;
gap: 8px;
width: 100%;
min-width: 0;
min-height: 40px;
/* Rows are the scroll content, never the slack absorber: a shrinkable row
collapses to min-height while its wrapped copy keeps the taller
intrinsic height, and centered content then paints outside the row box —
over the title and the next row. Overflow belongs to .options. */
flex-shrink: 0;
padding: 8px 12px 8px 8px;
border: 1px solid transparent;
border-radius: var(--dsw-radius-md);
border: 0;
background: transparent;
color: inherit;
text-align: left;
cursor: pointer;
transition: background-color 120ms ease, border-color 120ms ease;
}
.optionSelected {
@@ -205,7 +272,7 @@
}
@media (hover: hover) {
.option:hover:not(:disabled) {
.optionRowInteractive:hover {
background: var(--dsw-alias-interactive-bg-hover);
}
}
@@ -277,12 +344,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;
@@ -305,10 +377,84 @@
}
.description {
display: block;
width: max-content;
max-width: 100%;
min-width: 0;
max-height: calc(2lh + 2px);
margin-bottom: -2px;
padding-bottom: 2px;
overflow: hidden;
overflow-wrap: anywhere;
white-space: pre-wrap;
color: var(--dsw-alias-label-tertiary);
font-size: 14px;
line-height: 24px;
font-size: 13px;
line-height: 20px;
font-weight: 400;
scroll-timeline: --description-copy block;
animation-name: description-clipped;
animation-fill-mode: both;
animation-timeline: --description-copy;
}
@keyframes description-clipped {
from, to {
margin-bottom: calc(-1lh - 2px);
clip-path: inset(0 0 calc(1lh + 2px) 0);
mask-image: linear-gradient(to right, #000 calc(100% - 56px), transparent calc(100% - 28px));
mask-size: 100% 1lh;
mask-repeat: no-repeat;
}
}
.descriptionToggle {
position: absolute;
right: 8px;
bottom: 6px;
animation-name: copy-toggle-shown;
animation-fill-mode: both;
animation-timeline: --description-copy;
}
/* Expanded copy keeps its collapse button on a line of its own below the text. */
.optionRow[data-expanded] .option {
padding-bottom: 32px;
}
.questionTitle[data-expanded] .titlePreview,
.optionRow[data-expanded] .description {
max-height: none;
animation: none;
}
/* Shares .iconButton's 24px round control; hidden until its copy overflows or expands. */
.copyToggle {
visibility: hidden;
}
@keyframes copy-toggle-shown {
from, to {
visibility: visible;
}
}
.questionTitle[data-expanded] .copyToggle,
.optionRow[data-expanded] .copyToggle {
visibility: visible;
animation: none;
}
/* Without scroll timelines clipped copy cannot be told apart, so it shows in full. */
@supports not (animation-timeline: scroll()) {
.titlePreview,
.description {
max-height: none;
animation: none;
}
.copyToggle {
display: none;
}
}
/* Read-only note standing in for a question the user skipped: it takes the
@@ -337,7 +483,7 @@
it pushes the inline input past the footer. */
flex-shrink: 0;
padding: 8px 12px 8px 8px;
border: 1px solid transparent;
border: 0.5px solid transparent;
border-radius: var(--dsw-radius-md);
transition: background-color 120ms ease, border-color 120ms ease;
}
@@ -477,6 +623,10 @@
padding: 8px 6px;
}
.descriptionToggle {
right: 2px;
}
.footer {
align-items: flex-end;
padding: 0 10px;
@@ -488,7 +638,7 @@
}
@media (prefers-reduced-motion: reduce) {
.option,
.optionRow,
.customRow {
transition: none;
}
@@ -1,12 +1,12 @@
import {
useEffect, useMemo, useRef, useState,
useEffect, useId, useMemo, useRef, useState,
type ChangeEvent, type FocusEvent, type KeyboardEvent,
} from 'react'
import clsx from 'clsx'
import {
Button, IconCheckOutlineRegular, IconChevronDownOutlineRegular, IconChevronLeftOutlineRegular,
IconChevronRightOutlineRegular, IconChevronUpOutlineRegular, IconCloseOutlineRegular,
IconEditOutlineRegular, MarkdownText,
IconEditOutlineRegular, MarkdownText, Tooltip,
} from '@deepseek-ai/dsh-client-ui-primitives'
import { planReviewOf, type QuestionAnswer, type QuestionCardSnapshot, type QuestionComposerProps } from './contract/slots.ts'
import type { PendingQuestion } from './contract/slots.ts'
@@ -27,6 +27,16 @@ const REMOVED_CARD: QuestionCardSnapshot = {
state: 'open', waitState: 'counting', countdown: undefined, channel: 'none', closed: true,
}
/** Long copy the user expanded on one question: the question itself or an option description by index. */
interface ExpandedCopy {
/** Index of the question the expansions belong to. */
readonly index: number
/** Expanded copy of that question: `'question'` or an option index. */
readonly copies: ReadonlySet<'question' | number>
}
const NOTHING_EXPANDED: ExpandedCopy['copies'] = new Set()
/**
* Split the conventional recommendation suffix without changing the answer value.
* @param label - Original option label returned if selected.
@@ -157,6 +167,7 @@ type QuestionFlowProps =
{ pending: PendingQuestion } & Pick<QuestionComposerProps, 't' | 'useStore' | 'useQuestionCard' | 'actions'>
function QuestionFlow({ pending, t, useStore, useQuestionCard, actions }: QuestionFlowProps) {
const descriptionIdPrefix = useId()
const questions = pending.questions
// A read-only card built from a settled call's transcript: the same panel
// over the recorded answers, with nothing left to submit.
@@ -241,8 +252,25 @@ 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
// CSS decides which copy is clipped; only the user's expansions are state,
// and they apply only while their question is the one shown.
const [expandedCopy, setExpandedCopy] = useState<ExpandedCopy>({ index, copies: NOTHING_EXPANDED })
const expanded = expandedCopy.index === index ? expandedCopy.copies : NOTHING_EXPANDED
const questionToggleLabel = t(expanded.has('question') ? 'text.collapse' : 'text.expand')
const toggleCopy = (copy: 'question' | number): void => {
const copies = new Set(expanded)
if (!copies.delete(copy)) copies.add(copy)
setExpandedCopy({ index, copies })
}
const replaceProgress = (nextIndex: number, nextDrafts: QuestionDraftAnswer[]): void => {
if (nextIndex !== index) {
// Another question starts at its top with its long copy collapsed.
if (answerSurface.current !== null) answerSurface.current.scrollTop = 0
setExpandedCopy({ index: nextIndex, copies: NOTHING_EXPANDED })
}
actions.replace(pending.key, {
index: nextIndex,
drafts: nextDrafts,
@@ -436,12 +464,16 @@ function QuestionFlow({ pending, t, useStore, useQuestionCard, actions }: Questi
aria-labelledby={`question-${pending.key}-${String(index)}`}
>
<header className={css.header}>
<div className={css.headingBlock}>
{question.header !== undefined && <div className={css.eyebrow}>{question.header}</div>}
<h2 className={css.title} id={`question-${pending.key}-${String(index)}`}>
{question.question}
</h2>
</div>
{(question.header !== undefined || minimized) && (
<div className={css.headingBlock}>
{question.header !== undefined && <div className={css.eyebrow}>{question.header}</div>}
{minimized && (
<h2 className={css.title} id={`question-${pending.key}-${String(index)}`}>
{question.question}
</h2>
)}
</div>
)}
<div className={css.headerActions}>
{countdown !== undefined && card.waitState !== 'waiting' && card.waitState !== 'editing' && card.waitState !== 'continued' && (
<span className={css.waitStatus}>
@@ -492,6 +524,27 @@ function QuestionFlow({ pending, t, useStore, useQuestionCard, actions }: Questi
onFocusCapture={focusAnswerSurface}
onBlurCapture={blurAnswerSurface}
>
<div className={css.questionTitle} data-expanded={expanded.has('question') || undefined}>
<h2
className={clsx(css.title, css.titlePreview)}
id={`question-${pending.key}-${String(index)}`}
>
{question.question}
</h2>
<Tooltip label={questionToggleLabel} side="top" portal>
<button
type="button"
className={clsx(css.iconButton, css.copyToggle, css.questionToggle)}
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 />}
</button>
</Tooltip>
</div>
{question.detail !== undefined && (
<div className={css.detail}><MarkdownText text={question.detail} labels={markdownLabels} /></div>
)}
@@ -499,40 +552,73 @@ function QuestionFlow({ pending, t, useStore, useQuestionCard, actions }: Questi
{(question.options ?? []).map((option, optionIndex) => {
const selected = draft.selected.includes(option.label)
const display = parseRecommendedLabel(option.label)
const hasDescription = option.description !== undefined && option.description.trim() !== ''
const descriptionExpanded = expanded.has(optionIndex)
const descriptionToggleLabel = t(descriptionExpanded ? 'text.collapse' : 'text.expand')
const descriptionId = `${descriptionIdPrefix}-question-${String(index)}-option-${String(optionIndex)}-description`
return (
<button
type="button" key={`${option.label}-${String(optionIndex)}`}
className={clsx(css.option, selected && question.multiSelect !== true && css.optionSelected)}
role={question.multiSelect === true ? 'checkbox' : 'radio'}
aria-checked={selected}
aria-label={display.label}
disabled={locked}
onClick={() => { choose(option.label) }}
onKeyDown={(event) => {
if (event.key !== 'Enter') return
event.preventDefault()
submitDrafts(drafts)
}}
<div
key={`${option.label}-${String(optionIndex)}`}
className={clsx(
css.optionRow,
!locked && css.optionRowInteractive,
selected && question.multiSelect !== true && css.optionSelected,
)}
data-option-row
data-expanded={descriptionExpanded || undefined}
>
{question.multiSelect === true
? (
<span className={clsx(css.checkbox, selected && css.checkboxChecked)} aria-hidden="true">
{selected && <IconCheckOutlineRegular size={12} />}
<button
type="button"
className={css.option}
role={question.multiSelect === true ? 'checkbox' : 'radio'}
aria-checked={selected}
aria-label={display.label}
aria-describedby={hasDescription ? descriptionId : undefined}
disabled={locked}
onClick={() => { choose(option.label) }}
onKeyDown={(event) => {
if (event.key !== 'Enter') return
event.preventDefault()
submitDrafts(drafts)
}}
>
{question.multiSelect === true
? (
<span className={clsx(css.checkbox, selected && css.checkboxChecked)} aria-hidden="true">
{selected && <IconCheckOutlineRegular size={12} />}
</span>
)
: <span className={css.number}>{optionIndex + 1}</span>}
<span className={css.optionCopy}>
<span className={css.optionLine} data-option-line>
<span className={css.optionLabel}>{display.label}</span>
{display.recommended && (
<span className={css.badge}>{t('option.recommended')}</span>
)}
</span>
)
: <span className={css.number}>{optionIndex + 1}</span>}
<span className={css.optionCopy}>
<span className={css.optionLine}>
<span className={css.optionLabel}>{display.label}</span>
{display.recommended && (
<span className={css.badge}>{t('option.recommended')}</span>
)}
{option.description !== undefined && (
<span className={css.description}>{option.description}</span>
{hasDescription && (
<span id={descriptionId} className={css.description} data-option-description>
{option.description}
</span>
)}
</span>
</span>
</button>
</button>
{hasDescription && (
<Tooltip label={descriptionToggleLabel} side="top" portal>
<button
type="button"
className={clsx(css.iconButton, css.copyToggle, css.descriptionToggle)}
aria-label={descriptionToggleLabel}
aria-expanded={descriptionExpanded}
aria-controls={descriptionId}
aria-describedby={descriptionId}
onClick={() => { toggleCopy(optionIndex) }}
>
{descriptionExpanded ? <IconChevronUpOutlineRegular /> : <IconChevronDownOutlineRegular />}
</button>
</Tooltip>
)}
</div>
)
})}
@@ -25,6 +25,8 @@ export const zh = {
'nav.maximize': '展开问题卡片',
'nav.cancel': '放弃整组问题',
'nav.close': '收起问题面板,可从工具调用重新打开',
'text.expand': '展开',
'text.collapse': '收起',
'option.recommended': '推荐',
'custom.placeholder': '输入你的答案',
'action.skip': '跳过',
@@ -63,6 +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',
'text.expand': 'Expand',
'text.collapse': 'Collapse',
'option.recommended': 'Recommended',
'custom.placeholder': 'Type your answer',
'action.skip': 'Skip',
@@ -98,6 +102,8 @@ export const ja = {
'nav.maximize': '質問カードを展開',
'nav.cancel': 'すべての質問を閉じる',
'nav.close': 'パネルを閉じる — ツール呼び出しから再度開けます',
'text.expand': '展開',
'text.collapse': '折りたたむ',
'option.recommended': '推奨',
'custom.placeholder': '回答を入力',
'action.skip': 'スキップ',
@@ -0,0 +1,111 @@
/** Checks CSS layout declarations that jsdom cannot measure. */
import { readFileSync } from 'node:fs'
import { fileURLToPath } from 'node:url'
import { describe, expect, it } from 'vitest'
const stylesheet = readFileSync(
fileURLToPath(new URL('../src/client/QuestionComposer.module.css', import.meta.url)),
'utf8',
).replace(/\/\*[\s\S]*?\*\//g, ' ')
const escape = (text: string): string => text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
function split(body: string | undefined): string[] {
return (body ?? '').split(';').map(part => part.trim()).filter(Boolean)
}
function declarations(selector: string): string[] {
const rule = new RegExp(`(?:^|\\})\\s*${escape(selector)}\\s*\\{([^{}]*)\\}`).exec(stylesheet)
if (rule === null) throw new Error(`QuestionComposer.module.css has no \`${selector}\` rule`)
return split(rule[1])
}
function keyframes(name: string): string[] {
const rule = new RegExp(`@keyframes ${escape(name)}\\s*\\{\\s*from, to\\s*\\{([^{}]*)\\}`).exec(stylesheet)
if (rule === null) throw new Error(`QuestionComposer.module.css has no \`${name}\` keyframes`)
return split(rule[1])
}
describe('QuestionComposer long-copy layout', () => {
it('reads question overflow against three lines and keeps two once it overflows', () => {
expect(declarations('.questionTitle')).toEqual(expect.arrayContaining(['flex-shrink: 0', 'timeline-scope: --question-copy']))
expect(declarations('.titlePreview')).toEqual(expect.arrayContaining([
'max-height: calc(3lh + 2px)',
'margin: 0 0 -2px',
'padding-bottom: 2px',
'overflow: hidden',
'scroll-timeline: --question-copy block',
'animation-name: question-clipped',
'animation-fill-mode: both',
'animation-timeline: --question-copy',
]))
expect(keyframes('question-clipped')).toEqual(expect.arrayContaining([
'margin-bottom: calc(-1lh - 2px)',
'clip-path: inset(0 0 calc(1lh + 2px) 0)',
'mask-size: 100% 1lh, 100% 1lh',
'mask-position: 0 0, 0 1lh',
]))
expect(declarations('.questionToggle')).toEqual(expect.arrayContaining([
'animation-name: copy-toggle-shown',
'animation-timeline: --question-copy',
]))
})
it('reads description overflow against two lines and keeps one once it overflows', () => {
expect(declarations('.optionRow')).toContain('timeline-scope: --description-copy')
expect(declarations('.description')).toEqual(expect.arrayContaining([
'display: block',
'width: max-content',
'max-width: 100%',
'max-height: calc(2lh + 2px)',
'margin-bottom: -2px',
'padding-bottom: 2px',
'overflow: hidden',
'font-size: 13px',
'line-height: 20px',
'scroll-timeline: --description-copy block',
'animation-name: description-clipped',
'animation-fill-mode: both',
'animation-timeline: --description-copy',
]))
expect(keyframes('description-clipped')).toEqual(expect.arrayContaining([
'margin-bottom: calc(-1lh - 2px)',
'clip-path: inset(0 0 calc(1lh + 2px) 0)',
'mask-size: 100% 1lh',
]))
expect(declarations('.descriptionToggle')).toEqual(expect.arrayContaining([
'animation-name: copy-toggle-shown',
'animation-timeline: --description-copy',
]))
})
it('shows a toggle only through the overflow timeline or an expansion', () => {
expect(declarations('.copyToggle')).toContain('visibility: hidden')
expect(keyframes('copy-toggle-shown')).toEqual(['visibility: visible'])
expect(declarations('.questionTitle[data-expanded] .titlePreview,\n.optionRow[data-expanded] .description'))
.toEqual(['max-height: none', 'animation: none'])
expect(declarations('.questionTitle[data-expanded] .copyToggle,\n.optionRow[data-expanded] .copyToggle'))
.toEqual(['visibility: visible', 'animation: none'])
})
it('names timelines through longhands that the bundled CSS keeps apart', () => {
// lightningcss merges `animation` with `animation-timeline` into a shorthand browsers reject.
const timed = [...stylesheet.matchAll(/\{([^{}]*animation-timeline[^{}]*)\}/g)].map(match => match[1] ?? '')
expect(timed).toHaveLength(4)
for (const body of timed) expect(split(body).some(part => part.startsWith('animation:'))).toBe(false)
})
it('shows all copy where scroll timelines are unsupported', () => {
const fallback = /@supports not \(animation-timeline: scroll\(\)\)\s*\{([\s\S]*?)\n\}/.exec(stylesheet)?.[1] ?? ''
expect(fallback).toMatch(/\.titlePreview,\s*\.description\s*\{\s*max-height: none;\s*animation: none;\s*\}/)
expect(fallback).toMatch(/\.copyToggle\s*\{\s*display: none;\s*\}/)
})
it('keeps the question and choices in the card scrollport', () => {
expect(declarations('.body')).toEqual(expect.arrayContaining([
'min-height: 0',
'overflow-y: auto',
]))
expect(declarations('.questionTitle')).toContain('flex-shrink: 0')
})
})
@@ -24,7 +24,13 @@ afterEach(cleanup)
const SID = 's1' as SessionId
const seatOver = (dict: Record<string, string>, common: Record<string, string>): QuestionComposerProps['t'] =>
(key => dict[key] ?? common[key] ?? key)
(key, params) => {
const template = dict[key] ?? common[key] ?? key
return params === undefined
? template
: template.replace(/\{(\w+)\}/g, (match, name: string) =>
name in params ? String(params[name]) : match)
}
type SessionState = Parameters<Parameters<QuestionComposerProps['useSession']>[0]>[0]
type ConversationState = Parameters<Parameters<QuestionComposerProps['useConversation']>[0]>[0]
@@ -258,6 +264,7 @@ describe('QuestionComposer', () => {
const detail = screen.getByText('按当前空缺岗位的优先级选择。')
const scrollRegion = detail.closest('[data-question-scroll]')
expect(scrollRegion).toBeTruthy()
expect(scrollRegion?.contains(screen.getByRole('heading', { name: '选择候选人类型' }))).toBe(true)
expect(scrollRegion?.contains(screen.getByRole('radio', { name: /工程落地型/ }))).toBe(true)
expect(scrollRegion?.contains(screen.getByText('下一题').closest('button'))).toBe(false)
fireEvent.click(screen.getByRole('radio', { name: /工程落地型/ }))
@@ -294,6 +301,104 @@ describe('QuestionComposer', () => {
expect(screen.getByRole<HTMLButtonElement>('button', { name: '正在提交…' }).disabled).toBe(true)
})
it('expands long copy in place without choosing an option', () => {
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?',
options: [
{ label: 'Explicit install', description },
{ label: 'Optional bundle' },
{ label: 'Empty description', description: '' },
{ label: 'Whitespace description', description: ' ' },
],
}])
render(<QuestionComposer matched={carrier} {...kit} />)
// 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, 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)
expect(option.getAttribute('aria-describedby')).toBe(text.id)
expect(descriptionToggle.getAttribute('aria-controls')).toBe(text.id)
expect(descriptionToggle.getAttribute('aria-describedby')).toBe(text.id)
expect(descriptionToggle.getAttribute('aria-expanded')).toBe('false')
fireEvent.click(descriptionToggle)
expect(descriptionToggle.getAttribute('aria-expanded')).toBe('true')
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')
expect(questionToggle.getAttribute('aria-expanded')).toBe('false')
fireEvent.click(questionToggle)
expect(questionToggle.getAttribute('aria-expanded')).toBe('true')
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])
})
it('resets scrolling and expanded copy when the question changes', () => {
const firstDescription = 'First question detail that was expanded.'
const secondDescription = 'Second question detail starts collapsed.'
const { carrier } = wait([
{
id: 'first', question: 'First question?',
options: [{ label: 'Continue', description: firstDescription }],
},
{
id: 'second', question: 'Second question?',
options: [{ label: 'Continue', description: secondDescription }],
},
])
render(<QuestionComposer matched={carrier} {...kit} />)
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.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
expect(screen.queryByRole('region', { name: 'First question?' })).toBeNull()
expect(screen.getByRole('region', { name: 'Second question?' })).toBeTruthy()
expect(secondScroll).toBe(firstScroll)
expect(secondScroll.scrollTop).toBe(0)
expect(screen.queryByRole('button', { name: '收起' })).toBeNull()
expect(secondScroll.querySelector('[data-expanded]')).toBeNull()
expect(screen.queryByText(firstDescription)).toBeNull()
const descriptionToggle = screen.getAllByRole('button', { name: '展开' })[1]
expect(document.getElementById(descriptionToggle?.getAttribute('aria-controls') ?? '')?.textContent).toBe(secondDescription)
})
it('keeps expansions with their question when the shared position moves elsewhere', () => {
const { carrier } = wait([
{ id: 'first', question: 'First question?', options: [{ label: 'Continue', description: 'First detail.' }] },
{ id: 'second', question: 'Second question?', options: [{ label: 'Continue', description: 'Second detail.' }] },
])
render(<QuestionComposer matched={carrier} {...kit} />)
fireEvent.click(screen.getAllByRole('button', { name: '展开' })[1] as HTMLElement)
expect(screen.getByRole('button', { name: '收起' })).toBeTruthy()
// Another surface of the same Session moves the shared question position.
const blank = { selected: [], custom: '', skipped: false }
act(() => { kit.actions.replace(carrier.key, { index: 1, drafts: [blank, blank] }) })
expect(screen.getByRole('region', { name: 'Second question?' })).toBeTruthy()
expect(screen.queryByRole('button', { name: '收起' })).toBeNull()
expect(document.querySelector('[data-expanded]')).toBeNull()
})
it('renders plan detail through the shared assistant Markdown primitive', () => {
const { carrier } = wait([{
id: 'plan',
@@ -864,6 +969,8 @@ describe('review card', () => {
const chosen = screen.getByRole<HTMLButtonElement>('radio', { name: '工程落地型' })
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])
// Nothing is left to send, and the unused free-text field would read as
// somewhere to type.
expect(screen.queryByRole('button', { name: '跳过' })).toBeNull()
@@ -938,3 +1045,25 @@ describe('review card', () => {
expect(screen.getByText(en['review.skipped'])).toBeTruthy()
})
})
it('retains custom-answer focus across Enter navigation between option questions', () => {
const { carrier } = wait([
{ id: 'one', question: 'First?', options: [{ label: 'One', description: 'First description' }] },
{ id: 'two', question: 'Second?', options: [{ label: 'Two' }] },
])
render(<QuestionComposer matched={carrier} {...kit} />)
const choice = screen.getByRole('radio', { name: 'One' })
expect(choice.getAttribute('aria-describedby')).toBeTruthy()
expect(document.getElementById(choice.getAttribute('aria-describedby') as string)?.textContent).toBe('First description')
const first = screen.getByRole('textbox')
act(() => { first.focus() })
fireEvent.change(first, { target: { value: 'first answer' } })
fireEvent.keyDown(first, { key: 'Enter' })
expect(screen.getByRole('heading', { name: 'Second?' })).toBeTruthy()
const second = screen.getByRole('textbox')
expect(second).toBe(first)
expect(document.activeElement).toBe(second)
fireEvent.change(second, { target: { value: 'second answer' } })
expect((second as HTMLTextAreaElement).value).toBe('second answer')
expect(screen.getByRole('radio', { name: 'Two' }).getAttribute('aria-describedby')).toBeNull()
})
@@ -1,8 +1,8 @@
- region "Which color do you prefer?":
- text: Pick one
- heading "Which color do you prefer?" [level=2]
- button "Collapse the question card" [expanded]
- button "Dismiss all questions"
- heading "Which color do you prefer?" [level=2]
- group:
- checkbox "Blue" [checked]: Blue A cool recessive hue that reads as calm and trustworthy in long reading sessions and dense dashboards.
- checkbox "Green": Green A restful mid-spectrum hue with the highest perceived brightness, easiest on the eye over long sessions.
@@ -29,11 +29,10 @@
- text: {{clock}}
- button "Copy"
- region "Which color do you prefer?":
- text: Pick one
- heading "Which color do you prefer?" [level=2]
- text: Answered
- text: Pick one Answered
- button "Collapse the question card" [expanded]
- button "Close the panel — reopen it from the tool call"
- heading "Which color do you prefer?" [level=2]
- group:
- checkbox "Blue" [disabled]: Blue A cool recessive hue that reads as calm and trustworthy in long reading sessions and dense dashboards.
- checkbox "Green" [checked] [disabled]: Green A restful mid-spectrum hue with the highest perceived brightness, easiest on the eye over long sessions.
@@ -1,8 +1,8 @@
- region "Which color do you prefer?":
- text: Pick one
- heading "Which color do you prefer?" [level=2]
- button "Collapse the question card" [expanded]
- button "Dismiss all questions"
- heading "Which color do you prefer?" [level=2]
- group:
- checkbox "Blue": Blue A cool recessive hue that reads as calm and trustworthy in long reading sessions and dense dashboards.
- checkbox "Green": Green A restful mid-spectrum hue with the highest perceived brightness, easiest on the eye over long sessions.
@@ -13,3 +13,4 @@
- status
- button "Skip"
- button "Submit" [disabled]
Choice descriptions: [{"label":"Blue","description":"A cool recessive hue that reads as calm and trustworthy in long reading sessions and dense dashboards."},{"label":"Green","description":"A restful mid-spectrum hue with the highest perceived brightness, easiest on the eye over long sessions."}]
+1 -1
View File
@@ -16,9 +16,9 @@
- button "Copy"
- region "Ready to continue?":
- text: Checkpoint
- heading "Ready to continue?" [level=2]
- button "Collapse the question card" [expanded]
- button "Dismiss all questions"
- heading "Ready to continue?" [level=2]
- radiogroup:
- radio "Yes": 1 Yes
- radio "No": 2 No