diff --git a/apps/web/tests/question-composer.e2e.ts b/apps/web/tests/question-composer.e2e.ts index f16855916c..96e88fb512 100644 --- a/apps/web/tests/question-composer.e2e.ts +++ b/apps/web/tests/question-composer.e2e.ts @@ -582,6 +582,8 @@ describe('web e2e: resident question composer round trip', () => { const style = getComputedStyle(heading) return heading.getBoundingClientRect().height / parseFloat(style.lineHeight) })).toBe(1) + expect(await composer.getByRole('button', { name: 'Show full question' }).count()).toBe(0) + expect(await composer.getByRole('heading').evaluate(heading => getComputedStyle(heading).maskImage)).toBe('none') const rows = await composer.locator('[data-option-row]').evaluateAll(options => options.map((row) => { const label = row.querySelector('[data-option-line] > span:first-child') const description = row.querySelector('[data-option-description]') @@ -619,7 +621,7 @@ describe('web e2e: resident question composer round trip', () => { expect(sessionId).toBeDefined() const agent = scaffold.ctx.agents.get(sessionId as SessionId) expect(agent).toBeDefined() - const question = '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?' + 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(' ') @@ -686,10 +688,41 @@ describe('web e2e: resident question composer round trip', () => { return title.getBoundingClientRect().height / parseFloat(style.lineHeight) }) expect(questionLines).toBeGreaterThan(2) + expect(questionLines).toBeLessThanOrEqual(3.05) + const truncated = await composer.getByRole('heading', { name: question }).evaluate(title => + title.scrollHeight > title.clientHeight + 1) + await expect.poll(() => composer.getByRole('button', { name: 'Show full question' }).count()) + .toBe(truncated ? 1 : 0) } expect(scrollStates).toContain(false) expect(scrollStates).toContain(true) + const questionInfo = composer.getByRole('button', { name: 'Show full question' }) + await questionInfo.scrollIntoViewIfNeeded() + const questionTitle = composer.getByRole('heading', { name: question }) + expect(await questionInfo.getAttribute('aria-describedby')).toBe(await questionTitle.getAttribute('id')) + const questionMask = await questionTitle.evaluate(title => getComputedStyle(title).maskImage) + expect(questionMask).toContain('linear-gradient') + await page.keyboard.press('Tab') + await questionInfo.focus() + const questionPreview = page.locator('[data-placement]') + await questionPreview.getByText(question, { exact: true }).waitFor() + expect(await questionPreview.evaluate(node => node.closest('[data-question-scroll]') === null)).toBe(true) + await page.keyboard.press('Escape') + await expect.poll(() => questionPreview.count()).toBe(0) + await questionInfo.click() + await questionPreview.getByText(question, { exact: true }).waitFor() + const previewGeometry = await questionPreview.evaluate((node) => { + const rect = node.getBoundingClientRect() + const scroll = node.querySelector('[data-hover-card-scroll]') + return { fits: rect.left >= 0 && rect.right <= innerWidth && rect.top >= 0 && rect.bottom <= innerHeight, + scrolls: scroll !== null && scroll.scrollHeight > scroll.clientHeight } + }) + expect(previewGeometry.fits).toBe(true) + expect(previewGeometry.scrolls).toBe(true) + await composer.getByRole('heading', { name: question }).click() + await expect.poll(() => questionPreview.count()).toBe(0) + const detailText = composer.getByText(detail, { exact: true }) expect(await detailText.evaluate(node => node.closest('[data-question-scroll]') !== null)).toBe(true) await detailText.scrollIntoViewIfNeeded() diff --git a/packages/client/ui-user-questions/README.i18n.yaml b/packages/client/ui-user-questions/README.i18n.yaml index 89fe1a97f6..8608006f15 100644 --- a/packages/client/ui-user-questions/README.i18n.yaml +++ b/packages/client/ui-user-questions/README.i18n.yaml @@ -18,8 +18,8 @@ en: 8d3815c19858ac85 zh: 94fc905fa202d334 /deepseek-ai-dsh-client-ui-user-questions/use-this-package/answering: - en: 9a8f58ca1fcd87db - zh: be2587dc4bd54aa6 + en: 02c47a01ae6fd4f4 + zh: 7adb34361d02bc9d /deepseek-ai-dsh-client-ui-user-questions/use-this-package/reading-a-settled-question-back: en: 10975f68ff9c6fcc zh: 92246aadc363f0eb diff --git a/packages/client/ui-user-questions/README.md b/packages/client/ui-user-questions/README.md index 8f8fa0e12c..83e50f9533 100644 --- a/packages/client/ui-user-questions/README.md +++ b/packages/client/ui-user-questions/README.md @@ -29,7 +29,7 @@ When the agent asks a question, the attached card temporarily occupies the compo ### Answering -A multi-select draft keeps its selected labels while the user opens or edits the custom answer, so its submitted item may carry both `selected` and `custom`; a single-select custom answer remains exclusive. Question detail reuses the assistant-output `MarkdownText` primitive, including its GFM rendering and untrusted-content policy. The capped card keeps navigation and submission actions fixed while the question, long detail, and choices share an internal scroll region. A 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. 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`. ### Reading a settled question back diff --git a/packages/client/ui-user-questions/README.zh.md b/packages/client/ui-user-questions/README.zh.md index 0c0ed83103..f92fe5cf37 100644 --- a/packages/client/ui-user-questions/README.zh.md +++ b/packages/client/ui-user-questions/README.zh.md @@ -29,7 +29,7 @@ Web 客户端在聊天输入框旁显示 agent(智能体)的提问。用户 ### 作答 -用户打开或编辑自定义答案时,多选题草稿会保留已选中的标签,因此提交项可以同时携带 `selected` 与 `custom`;单选题的自定义答案仍保持互斥。问题详情复用助手输出的 `MarkdownText` 原语,包括其 GFM 渲染与不受信任内容策略。限高卡片保持导航与提交动作固定,问题正文、超长详情和选项共享内部滚动区。简短的选项说明与标签同占一行;较长的说明移到标签下方,仅在下方仍被截断时让行尾文字渐隐,并显示圈内三点按钮。悬停、键盘聚焦或点击该按钮会在带指向角的气泡中打开可选择文本的完整说明,超过 240px 后在气泡内部滚动。「跳过」会保留其他草稿,并为该项发出既有的空 `{ selected: [] }` 结果。关闭不是一种回答:Host 以工具调用命名的问题只是离开编辑器位置,其 `ask_user_question` 工具调用行会把面板带回来;不携带工具调用的请求没有可返回的行,关闭它仍以 `ASK_CANCELLED` 拒绝整个等待。 +用户打开或编辑自定义答案时,多选题草稿会保留已选中的标签,因此提交项可以同时携带 `selected` 与 `custom`;单选题的自定义答案仍保持互斥。问题详情复用助手输出的 `MarkdownText` 原语,包括其 GFM 渲染与不受信任内容策略。超过实际排版三行的问题在第三行末尾渐隐,并通过相同的圈内三点按钮显示完整文本。限高卡片保持导航与提交动作固定,问题正文、超长详情和选项共享内部滚动区。简短的选项说明与标签同占一行;较长的说明移到标签下方,仅在下方仍被截断时让行尾文字渐隐,并显示圈内三点按钮。悬停、键盘聚焦或点击该按钮会在带指向角的气泡中打开可选择文本的完整说明,超过 240px 后在气泡内部滚动。「跳过」会保留其他草稿,并为该项发出既有的空 `{ selected: [] }` 结果。关闭不是一种回答:Host 以工具调用命名的问题只是离开编辑器位置,其 `ask_user_question` 工具调用行会把面板带回来;不携带工具调用的请求没有可返回的行,关闭它仍以 `ASK_CANCELLED` 拒绝整个等待。 ### 回看已结束的问题 diff --git a/packages/client/ui-user-questions/src/client/QuestionComposer.module.css b/packages/client/ui-user-questions/src/client/QuestionComposer.module.css index 9d11ea12da..89852a34a6 100644 --- a/packages/client/ui-user-questions/src/client/QuestionComposer.module.css +++ b/packages/client/ui-user-questions/src/client/QuestionComposer.module.css @@ -106,6 +106,34 @@ font-weight: 500; } +/* Keep the full question accessible while only its first three lines are visible. */ +.questionTitle { + position: relative; + flex-shrink: 0; + margin: 5px 24px 0; +} + +.titlePreview { + margin: 0; + max-height: 3lh; + overflow: hidden; + overflow-wrap: anywhere; + white-space: pre-wrap; +} + +.titleTruncated { + mask-image: linear-gradient(#000, #000), linear-gradient(to right, #000 calc(100% - 74px), transparent calc(100% - 28px)); + mask-size: 100% 2lh, 100% 1lh; + mask-position: top left, bottom left; + mask-repeat: no-repeat; +} + +.questionAction { + position: absolute; + right: 0; + bottom: -1px; +} + .cardMinimized .title { display: -webkit-box; margin: 0; diff --git a/packages/client/ui-user-questions/src/client/QuestionComposer.tsx b/packages/client/ui-user-questions/src/client/QuestionComposer.tsx index 7fbe2e74d6..0421a8f718 100644 --- a/packages/client/ui-user-questions/src/client/QuestionComposer.tsx +++ b/packages/client/ui-user-questions/src/client/QuestionComposer.tsx @@ -242,13 +242,16 @@ 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 | null>(null) useLayoutEffect(() => { const surface = answerSurface.current if (surface === null || minimized) return + const title = surface.querySelector('[data-question-title]') const rows = [...surface.querySelectorAll('[data-option-row]')] const measure = (): void => { + if (title !== null) setQuestionTruncated(title.scrollHeight > title.clientHeight + 1) const next = new Map() for (const row of rows) { const description = row.querySelector('[data-option-description]') @@ -284,6 +287,7 @@ function QuestionFlow({ pending, t, useStore, useQuestionCard, actions }: Questi } 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]') @@ -295,7 +299,7 @@ function QuestionFlow({ pending, t, useStore, useQuestionCard, actions }: Questi observer?.disconnect() fonts?.removeEventListener('loadingdone', measure) } - }, [index, minimized, question.options]) + }, [index, minimized, question.question, question.options]) const replaceProgress = (nextIndex: number, nextDrafts: QuestionDraftAnswer[]): void => { actions.replace(pending.key, { @@ -552,9 +556,36 @@ function QuestionFlow({ pending, t, useStore, useQuestionCard, actions }: Questi onFocusCapture={focusAnswerSurface} onBlurCapture={blurAnswerSurface} > -

- {question.question} -

+
+

+ {question.question} +

+ {questionTruncated && ( + + + + + )} + content={} + /> + + )} +
{question.detail !== undefined && (
)} diff --git a/packages/client/ui-user-questions/src/client/locales.ts b/packages/client/ui-user-questions/src/client/locales.ts index 26aa017cc9..e8e42ac851 100644 --- a/packages/client/ui-user-questions/src/client/locales.ts +++ b/packages/client/ui-user-questions/src/client/locales.ts @@ -25,6 +25,7 @@ export const zh = { 'nav.maximize': '展开问题卡片', 'nav.cancel': '放弃整组问题', 'nav.close': '收起问题面板,可从工具调用重新打开', + 'question.details': '查看完整问题', 'option.details': '查看“{option}”的选项说明', 'option.recommended': '推荐', 'custom.placeholder': '输入你的答案', @@ -64,6 +65,7 @@ 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.details': 'Show full question', 'option.details': 'Show details for “{option}”', 'option.recommended': 'Recommended', 'custom.placeholder': 'Type your answer', diff --git a/packages/client/ui-user-questions/tests/user-questions-composer.client.spec.tsx b/packages/client/ui-user-questions/tests/user-questions-composer.client.spec.tsx index c5ce1dc757..34fc7525b6 100644 --- a/packages/client/ui-user-questions/tests/user-questions-composer.client.spec.tsx +++ b/packages/client/ui-user-questions/tests/user-questions-composer.client.spec.tsx @@ -300,6 +300,33 @@ describe('QuestionComposer', () => { expect(screen.getByRole('button', { name: '正在提交…' }).disabled).toBe(true) }) + it('opens the full truncated question and removes its action on the next fitting question', () => { + const question = 'First question with four rendered lines' + const scrollHeight = vi.spyOn(HTMLElement.prototype, 'scrollHeight', 'get').mockImplementation(function (this: HTMLElement) { + return this.hasAttribute('data-question-title') && this.textContent === question ? 88 : 66 + }) + const clientHeight = vi.spyOn(HTMLElement.prototype, 'clientHeight', 'get').mockReturnValue(66) + try { + const { carrier } = wait([ + { id: 'first', question, options: [{ label: 'Continue' }] }, + { id: 'second', question: 'Second question?', options: [{ label: 'Continue' }] }, + ]) + render() + const details = screen.getByRole('button', { name: '查看完整问题' }) + expect(details.getAttribute('aria-describedby')).toBe(screen.getByRole('heading', { name: question }).id) + fireEvent.click(details) + expect(screen.getAllByText(question)).toHaveLength(2) + expect(screen.getByRole('radio', { name: 'Continue' }).getAttribute('aria-checked')).toBe('false') + fireEvent.click(screen.getByRole('radio', { name: 'Continue' })) + expect(screen.queryByText(question)).toBeNull() + expect(screen.queryByRole('button', { name: '查看完整问题' })).toBeNull() + } finally { + cleanup() + scrollHeight.mockRestore() + clientHeight.mockRestore() + } + }) + it('opens a full option description without choosing the option', () => { const description = 'Remove the experimental client from every shipped template and require an explicit profile install.' const { carrier } = wait([{