From aa099a9b4069cdef1a487ac5bbe1098cbdc3f0cc Mon Sep 17 00:00:00 2001 From: yudshj Date: Thu, 1 Oct 2026 21:47:44 +0800 Subject: [PATCH 01/21] fix(ui-user-questions): keep long questions answerable --- apps/web/tests/question-composer.e2e.ts | 176 +++++++++++++++--- packages/client/ui-primitives/README.md | 2 + packages/client/ui-primitives/README.zh.md | 2 + .../client/ui-primitives/src/HoverCard.tsx | 119 +++++++++--- .../tests/hover-card.client.spec.tsx | 140 ++++++++++++++ .../client/ui-user-questions/README.i18n.yaml | 4 +- packages/client/ui-user-questions/README.md | 2 +- .../client/ui-user-questions/README.zh.md | 2 +- .../src/client/QuestionComposer.module.css | 100 ++++++++-- .../src/client/QuestionComposer.tsx | 116 ++++++++---- .../ui-user-questions/src/client/locales.ts | 2 + .../question-composer-styles.client.spec.ts | 37 ++++ .../user-questions-composer.client.spec.tsx | 81 +++++++- .../question-composer/composed.expected.md | 4 +- .../late-answered.expected.md | 7 +- .../web/question-composer/ui.expected.md | 4 +- 16 files changed, 682 insertions(+), 116 deletions(-) create mode 100644 packages/client/ui-user-questions/tests/question-composer-styles.client.spec.ts diff --git a/apps/web/tests/question-composer.e2e.ts b/apps/web/tests/question-composer.e2e.ts index eda964da3e..70fa1fd1b6 100644 --- a/apps/web/tests/question-composer.e2e.ts +++ b/apps/web/tests/question-composer.e2e.ts @@ -69,8 +69,8 @@ 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. +// verifies that each description remains a single ellipsized line while the +// complete text stays reachable through its information action. 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 { @@ -343,48 +343,64 @@ describe('web e2e: resident question composer round trip', () => { 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. 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( - '[role="radio"], [role="checkbox"], [aria-expanded]', - )] + const rows = [...card.querySelectorAll('[data-option-row]')] + const descriptions = [...card.querySelectorAll('[data-option-description]')] 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('[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, + ellipsized: descriptions.filter(item => item.scrollWidth > item.clientWidth).length, + descriptionLines: Math.max(...descriptions.map((item) => { + const style = getComputedStyle(item) + return item.clientHeight / parseFloat(style.lineHeight) + })), 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) + expect(squeeze.ellipsized).toBeGreaterThan(0) + expect(squeeze.descriptionLines).toBeLessThan(1.1) + 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) } + expect(scrollStates).toContain(true) await page.setViewportSize(original) } + const blue = composer.getByRole('checkbox', { name: 'Blue' }) + const blueDetail = 'A cool recessive hue that reads as calm and trustworthy in long reading sessions and dense dashboards.' + const blueInfo = composer.getByRole('button', { name: 'Show details for “Blue”' }) + await blueInfo.hover() + await expect.poll(() => page.getByText(blueDetail, { exact: true }).count()).toBe(2) + await composer.getByRole('heading', { name: 'Which color do you prefer?' }).hover() + await expect.poll(() => page.getByText(blueDetail, { exact: true }).count()).toBe(1) + await blueInfo.click() + expect(await blue.getAttribute('aria-checked')).toBe('false') + await expect.poll(() => page.getByText(blueDetail, { exact: true }).count()).toBe(2) + await composer.getByRole('heading', { name: 'Which color do you prefer?' }).click() + await expect.poll(() => page.getByText(blueDetail, { exact: true }).count()).toBe(1) + // 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 +431,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 +551,123 @@ 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 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 = '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 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 = 'Remove the experimental client from every shipped template and require users to install it explicitly in the profile where they need it.' + const controller = new AbortController() + const asked = scaffold.ctx.userQuestions.ask({ + agent: agent as NonNullable, + signal: controller.signal, + questions: [{ + id: 'release', + header: 'Release decision', + question, + detail, + multiSelect: true, + options: [ + { label: 'Explicit install', description }, + { label: 'Keep the default edge', description: 'Retain the current dependency and exempt it from the release graph invariant.' }, + { label: 'Move the package', description: 'Rename the package outside the experimental namespace without changing how profiles acquire it.' }, + ], + }], + }) + 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 }) + // Browser zoom reduces the CSS-pixel viewport. These dimensions model + // the same 640x420 window at 100%, 125%, and 150% without relying on a + // browser-global zoom preference that Playwright cannot isolate per page. + for (const viewport of [ + { width: 640, height: 420 }, + { width: 512, height: 336 }, + { width: 427, height: 280 }, + ]) { + await page.setViewportSize(viewport) + await expect.poll(() => composer.evaluate((card) => { + const scroll = card.querySelector('[data-question-scroll]') + const title = card.querySelector('h2') + const submit = [...card.querySelectorAll('button')] + .find(button => button.textContent === 'Submit') + 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), + } + })).toEqual({ + footerFits: true, + scrolls: true, + titleInScrollport: true, + }) + const questionLines = await composer.getByRole('heading', { name: question }).evaluate((title) => { + const style = getComputedStyle(title) + return title.getBoundingClientRect().height / parseFloat(style.lineHeight) + }) + expect(questionLines).toBeGreaterThan(2) + } + + 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: 'Submit', exact: true }).isVisible()).toBe(true) + + const info = composer.getByRole('button', { name: 'Show details for “Explicit install”' }) + const descriptionId = await info.getAttribute('aria-describedby') + expect(descriptionId).not.toBeNull() + expect(await page.locator(`[id=${JSON.stringify(descriptionId)}]`).textContent()).toBe(description) + await info.click() + expect(await composer.getByRole('checkbox', { name: 'Explicit install' }).getAttribute('aria-checked')).toBe('false') + expect(await info.getAttribute('aria-describedby')).toBe(descriptionId) + const preview = page.locator('[data-pinned="true"]') + await preview.getByText(description, { exact: true }).waitFor() + const previewBox = await preview.boundingBox() + const constrainedViewport = page.viewportSize() + expect(constrainedViewport).not.toBeNull() + expect(previewBox).not.toBeNull() + expect(previewBox?.x).toBeGreaterThanOrEqual(7.5) + expect(previewBox?.y).toBeGreaterThanOrEqual(7.5) + expect((previewBox?.x ?? 0) + (previewBox?.width ?? 0)) + .toBeLessThanOrEqual((constrainedViewport?.width ?? 0) - 7.5) + expect((previewBox?.y ?? 0) + (previewBox?.height ?? 0)) + .toBeLessThanOrEqual((constrainedViewport?.height ?? 0) - 7.5) + await page.keyboard.press('Escape') + + await composer.getByRole('checkbox', { name: 'Explicit install' }).click() + await composer.getByRole('textbox').fill('Keep the migration steps explicit.') + 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.', + }], + }, + }) + 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) + }) describe.skipIf(MODE === 'record')('web e2e: cancelled question transcript', () => { diff --git a/packages/client/ui-primitives/README.md b/packages/client/ui-primitives/README.md index 9e054e13cc..ab03603969 100644 --- a/packages/client/ui-primitives/README.md +++ b/packages/client/ui-primitives/README.md @@ -92,6 +92,8 @@ The catalog above lists what each export is for; this section covers the behavio `Tooltip` reads the anchor on hover or keyboard focus and fits its bubble from `ResizeObserver` border-box sizes. It stays hidden until its first fit, prefers the requested side, then tries the opposite and perpendicular sides, sliding along the anchor without covering it when a side fits. Horizontal placements keep a 10px gap; vertical placements use `gap` (8px by default). Width and height stay within a 12px viewport margin; text wraps, and content taller than the viewport scrolls when pinned. If no side fits, a stable viewport-bounded fallback can overlap the anchor. Viewport resize refreshes the anchor coordinates; label-size changes reuse them. Fitting does not synchronously measure the bubble or trigger a React render. Touch presses dismiss unpinned hints and do not trigger hover hints, including compatibility mouse events. Mouse entry opens hints only while `(any-hover: hover)` reports an available hover-capable input, excluding iOS mouse entry caused by a tapped icon changing. Keyboard focus and explicit click-to-open explanations remain available on touch-only devices. Opt-in `openOnClick` lets an informational button pin the bubble by click or tap and associates it with the anchor description. Another click or tap, Escape, Tab, or an outside pointerdown closes it. Ordinary action buttons execute their action directly without a touch tooltip. +Opt-in `HoverCard.openOnClick` requires one focusable React element that forwards ARIA props. Click and touch activation pin the card; pointer leave and blur keep it open so its text remains selectable; another anchor activation, Escape, Tab, or a pointerdown outside both anchor and card closes it. While open, the card joins the anchor's accessible description by default. Set `describeAnchor={false}` when the caller provides a stable `aria-describedby` relationship; the card then leaves that relationship unchanged. + `HoverCardGroup` owns private state for one mounted physical list. A `HoverCard` with `grouped` shares the first actually displayed card's dwell: subsequent cards replace the current card immediately during the same pointer interaction. The list and its active portaled card form one pointer interaction: the pointer may enter the card to read or copy, then return to another list item for immediate replacement. After the pointer leaves both, the shared dwell resets when the active card closes through its existing pointer-grace and close lifecycle; the group adds no separate timer. Leaving a list with no active card resets the dwell immediately. Opening an inline card by keyboard focus while the pointer is outside the list does not warm the next pointer visit. Exclusive display includes fading previews: ordinary preview dismissal retains its fade, but a group replacement removes the previous card immediately. Cards without `grouped` remain independent. diff --git a/packages/client/ui-primitives/README.zh.md b/packages/client/ui-primitives/README.zh.md index 96b9db405a..f02e0e6b22 100644 --- a/packages/client/ui-primitives/README.zh.md +++ b/packages/client/ui-primitives/README.zh.md @@ -92,6 +92,8 @@ kind: "package-library" `Tooltip` 在悬停或键盘聚焦时读取锚点位置,再根据 `ResizeObserver` 提供的边框盒尺寸调整气泡。首次定位前保持隐藏,优先使用指定方向,再尝试相反方向和垂直于该方向的两侧;只要某一侧容得下,就沿锚点平移而不遮挡锚点。左右放置保留 10px 间距,上下放置使用 `gap`(默认 8px)。宽高限制在视口内并保留 12px 边距;文本自动换行,超出视口高度的内容在固定气泡后可滚动。若所有方向都放不下,则使用稳定的视口内回退位置,可能遮挡锚点。视口尺寸变化会重新读取锚点坐标,标签尺寸变化则复用坐标;定位不会同步测量气泡,也不会触发 React 渲染。 触摸按下会关闭未固定的提示,且不会触发悬停提示,包括触摸后的兼容鼠标事件。只有 `(any-hover: hover)` 表明存在可悬停的输入设备时,鼠标进入才会打开提示,从而排除 iOS 因已点击图标变化而生成的鼠标进入事件。仅有触摸输入的设备仍可使用键盘聚焦提示和显式点击打开的说明。信息按钮可显式启用 `openOnClick`,通过鼠标或触摸点击固定气泡供阅读,并将内容关联为锚点的无障碍描述;再次点击、Escape、Tab 或外部 pointerdown 会关闭。普通操作按钮在触摸点击时直接执行操作,不显示 Tooltip。 +显式启用 `HoverCard.openOnClick` 时,锚点必须是单个可聚焦且会转发 ARIA props 的 React 元素。点击和触屏激活会固定卡片;指针离开和失焦不会关闭固定的卡片,因此文字仍可选择;再次激活锚点、按 Escape 或 Tab,或在锚点及卡片之外按下指针都会关闭卡片。卡片打开期间,默认会加入锚点的无障碍描述。调用方已通过 `aria-describedby` 提供稳定关联时设置 `describeAnchor={false}`;卡片将保持该关联不变。 + `HoverCardGroup` 为一个已挂载的物理列表持有私有状态。设置 `grouped` 的 `HoverCard` 共享首张实际显示卡片的等待:在同一次指针交互中,后续卡片立即替换当前卡片。列表与其当前活动的 portal 卡片构成一次指针交互:指针可进入卡片阅读或复制,再返回其他列表项并立即替换卡片。指针离开两者后,共享等待在活动卡片通过原有指针宽限及关闭生命周期关闭时重置;分组不另设计时器。列表没有活动卡片时,指针离开便立即重置等待。指针位于列表外时,通过键盘聚焦打开行内卡片不会让下一次指针进入跳过等待。互斥显示也包括正在淡出的预览:普通预览关闭保留淡出,但分组内替换会立即移除前一张卡片。未设置 `grouped` 的卡片保持独立。 diff --git a/packages/client/ui-primitives/src/HoverCard.tsx b/packages/client/ui-primitives/src/HoverCard.tsx index e30d671c36..218d86afc6 100644 --- a/packages/client/ui-primitives/src/HoverCard.tsx +++ b/packages/client/ui-primitives/src/HoverCard.tsx @@ -1,11 +1,12 @@ -import { useCallback, useContext, useEffect, useLayoutEffect, useRef, useState } from 'react' -import type { CSSProperties, ReactNode, RefObject, SyntheticEvent } from 'react' +import { cloneElement, useCallback, useContext, useEffect, useId, useLayoutEffect, useRef, useState } from 'react' +import type { CSSProperties, ReactElement, ReactNode, RefObject, SyntheticEvent } from 'react' import clsx from 'clsx' import { createPortal } from 'react-dom' import { writeClipboard } from './clipboard.ts' import { usePointerGrace } from './pointer-grace.ts' import { overlayTopMargin } from './overlay-top-margin.ts' import { HoverCardGroupContext } from './HoverCardGroup.tsx' +import { useDismissOnOutsidePointer } from './useDismissOnOutsidePointer.ts' import css from './HoverCard.module.css' /** Preview opacity transition and retained lifetime during dismissal. */ @@ -16,10 +17,37 @@ const INLINE_PREVIEW_WIDTH = 300 const ANCHOR_GAP = 8 const VIEWPORT_MARGIN = 8 +interface DescribedAnchorProps { + 'aria-describedby'?: string | undefined +} + +type HoverCardProps = { + /** Inline media preview using the shared menu material and keyboard focus. */ + inline?: boolean + content: ReactNode + openDelayMs?: number + disabled?: boolean + grouped?: boolean + variant?: 'compact' | 'preview' + widthAnchorRef?: RefObject +} & ({ + anchor: ReactElement + openOnClick: true + describeAnchor?: boolean +} | { + anchor: ReactNode + openOnClick?: false | undefined + describeAnchor?: never +}) & ({ copyText?: string | undefined; copyLabel: string; copiedLabel: string } | { + copyText?: undefined + copyLabel?: string + copiedLabel?: string +}) + /** * Render an anchor with a hover-triggered preview card independently of nested tooltips. - * Touch entry does not open a preview or intercept the anchor's action. - * @param props.anchor - the hover target (rendered in place inside a wrapper span). + * @param props.anchor - the hover target (rendered in place inside a wrapper span). With + * openOnClick, pass one focusable element that forwards ARIA props. * @param props.content - card content; the pointer may rest on it, so it is * readable and selectable, but it carries no dismissal affordance of its own. * @param props.openDelayMs - hover dwell before the card shows (default 500). @@ -30,30 +58,23 @@ const VIEWPORT_MARGIN = 8 * @param props.disabled - suppress opening; turning true dismisses an open card. * @param props.grouped - share first-display dwell across the surrounding list and active portal. * Group replacement removes the previous card immediately, including a fading preview. + * @param props.openOnClick - clicking or touching the anchor pins the card open. Another activation, + * Escape, Tab, or an outside pointerdown dismisses it; pointer leave and blur do not dismiss a pinned card. + * @param props.describeAnchor - with openOnClick, append the open card to the anchor's accessible + * description (default true). Set false when the caller supplies a stable description relationship. * @param props.copyText - optional primary value copied by activation and * included in the card's accessible name. * @param props.copyLabel - localized accessible activation-label prefix. * @param props.copiedLabel - localized visible success label. * @returns anchor wrapper with the conditional portaled card. */ -export function HoverCard({ - anchor, content, openDelayMs = 500, disabled = false, grouped = false, - copyText, copyLabel, copiedLabel, variant = 'compact', widthAnchorRef, inline = false, -}: { - anchor: ReactNode - /** Inline media preview using the shared menu material and keyboard focus. */ - inline?: boolean - content: ReactNode - openDelayMs?: number - disabled?: boolean - grouped?: boolean - variant?: 'compact' | 'preview' - widthAnchorRef?: RefObject -} & ({ copyText?: string | undefined; copyLabel: string; copiedLabel: string } | { - copyText?: undefined - copyLabel?: string - copiedLabel?: string -})) { +export function HoverCard(props: HoverCardProps) { + const { + content, openDelayMs = 500, disabled = false, grouped = false, describeAnchor = true, + copyText, copyLabel, copiedLabel, variant = 'compact', widthAnchorRef, inline = false, + } = props + const openOnClick = props.openOnClick === true + const id = useId() const rootRef = useRef(null) const cardRef = useRef(null) const timerRef = useRef | null>(null) @@ -71,6 +92,7 @@ export function HoverCard({ const parentGroup = useContext(HoverCardGroupContext) const group = grouped ? parentGroup : null const [groupOwner] = useState(() => Symbol('hover-card')) + const [pinned, setPinned] = useState(false) const clearCopied = useCallback(() => { if (copyTimerRef.current !== null) { @@ -84,10 +106,12 @@ export function HoverCard({ const close = useCallback(() => { copyEpochRef.current += 1 clearCopied() + setPinned(false) setPhase(current => variant === 'preview' && current !== 'closed' ? 'closing' : 'closed') }, [clearCopied, variant]) const { arm: armClose, cancel: cancelClose } = usePointerGrace(close) + useDismissOnOutsidePointer(rootRef, openOnClick && open, close, cardRef) const clearTimer = useCallback(() => { if (timerRef.current !== null) { @@ -140,7 +164,7 @@ export function HoverCard({ }, []) useEffect(() => { - if (!open || (variant !== 'preview' && !inline)) return + if (!open || openOnClick || (variant !== 'preview' && !inline)) return const dismiss = (event: KeyboardEvent): void => { if (event.key !== 'Escape') return if (inline) event.stopPropagation() @@ -150,7 +174,27 @@ export function HoverCard({ } window.addEventListener('keydown', dismiss, inline) return () => { window.removeEventListener('keydown', dismiss, inline) } - }, [open, variant, inline, cancelClose, close]) + }, [open, openOnClick, variant, inline, cancelClose, close]) + + useEffect(() => { + if (!openOnClick || !open) return + const dismiss = (event: KeyboardEvent): void => { + if (event.key !== 'Escape' && event.key !== 'Tab') return + if (event.key === 'Escape') { + event.preventDefault() + event.stopPropagation() + } + clearTimer() + cancelClose() + close() + } + document.addEventListener('keydown', dismiss, true) + return () => { document.removeEventListener('keydown', dismiss, true) } + }, [open, openOnClick, cancelClose, close]) + + useEffect(() => { + if (pinned && !openOnClick) close() + }, [close, openOnClick, pinned]) // Fixed-position from the anchor rect before paint; track the anchor while // open (capture-phase scroll catches nested panes), as in Menu portal mode. @@ -238,18 +282,32 @@ export function HoverCard({ } const copyable = copyText !== undefined + const describedAnchor = props.openOnClick === true && describeAnchor + ? cloneElement(props.anchor, { + 'aria-describedby': open + ? [props.anchor.props['aria-describedby'], id].filter(Boolean).join(' ') + : props.anchor.props['aria-describedby'], + }) + : props.anchor const dismissFromAnchor = (event: SyntheticEvent): void => { // Portal content is a React child, but its clicks and text selection do not activate the anchor. if (cardRef.current?.contains(event.target as Node)) return clearTimer() cancelClose() + if (openOnClick && !disabled && !pinned) { + setPinned(true) + setPhase('open') + return + } close() } const card = open && pos !== null && (
{ - if (!event.currentTarget.contains(event.relatedTarget)) { clearTimer(); cancelClose(); close() } + if (!pinned && !event.currentTarget.contains(event.relatedTarget)) { clearTimer(); cancelClose(); close() } } : undefined} onPointerEnter={(event) => { if (disabled || event.pointerType === 'touch') return @@ -304,13 +362,16 @@ export function HoverCard({ onPointerLeave={() => { clearTimer() // Leaving a closed card schedules a no-op close; only arm while - // open, matching Menu's shape. - if (open) armClose() + // open, matching Menu's shape. Keyboard focus remains an independent + // open trigger after the pointer leaves. + if (open && !pinned + && rootRef.current?.contains(document.activeElement) !== true + && cardRef.current?.contains(document.activeElement) !== true) armClose() }} - onPointerDownCapture={dismissFromAnchor} + onPointerDownCapture={openOnClick ? undefined : dismissFromAnchor} onClickCapture={dismissFromAnchor} > - {anchor} + {describedAnchor} {open && copyable && {copied ? copiedLabel : ''}} {card !== false && createPortal(card, document.body)} diff --git a/packages/client/ui-primitives/tests/hover-card.client.spec.tsx b/packages/client/ui-primitives/tests/hover-card.client.spec.tsx index 65fbad0a64..1059631263 100644 --- a/packages/client/ui-primitives/tests/hover-card.client.spec.tsx +++ b/packages/client/ui-primitives/tests/hover-card.client.spec.tsx @@ -225,6 +225,130 @@ describe('HoverCard', () => { expect(screen.getByText('card body')).toBeTruthy() }) + it('pins on click for touch access and keeps the card through pointer leave and blur', () => { + render( + <> +

Existing description

+ details} + content={
card body
} + /> + , + ) + const anchor = screen.getByRole('button', { name: 'details' }) + const wrapper = anchor.parentElement as HTMLElement + const touch = new Event('pointerover', { bubbles: true }) + Object.defineProperty(touch, 'pointerType', { value: 'touch' }) + fireEvent(wrapper, touch) + act(() => { vi.advanceTimersByTime(500) }) + expect(screen.queryByText('card body')).toBeNull() + + fireEvent.click(anchor) + const card = screen.getByText('card body').parentElement as HTMLElement + expect(card.getAttribute('data-pinned')).toBe('true') + expect(anchor.getAttribute('aria-describedby')).toBe(`existing-description ${card.id}`) + fireEvent.pointerLeave(wrapper) + act(() => { vi.advanceTimersByTime(POINTER_GRACE_MS) }) + fireEvent.blur(anchor, { relatedTarget: document.body }) + expect(screen.getByText('card body')).toBeTruthy() + + fireEvent.pointerDown(card) + expect(screen.getByText('card body')).toBeTruthy() + fireEvent.pointerDown(document.body) + expect(screen.queryByText('card body')).toBeNull() + expect(anchor.getAttribute('aria-describedby')).toBe('existing-description') + }) + + it('preserves a caller-owned accessible description while click-pinned', () => { + render(<> +

Stable description

+ details} + content={
card body
} + /> + ) + const anchor = screen.getByRole('button', { name: 'details' }) + expect(anchor.getAttribute('aria-describedby')).toBe('stable-description') + fireEvent.click(anchor) + expect(screen.getByText('card body')).toBeTruthy() + expect(anchor.getAttribute('aria-describedby')).toBe('stable-description') + fireEvent.pointerDown(document.body) + expect(screen.queryByText('card body')).toBeNull() + expect(anchor.getAttribute('aria-describedby')).toBe('stable-description') + }) + + it('dismisses a click-open card on another click, Escape, or Tab', () => { + const ownerKey = vi.fn() + render(
+ details} content={
card body
} /> +
) + const anchor = screen.getByRole('button', { name: 'details' }) + + fireEvent.click(anchor) + expect(screen.getByText('card body')).toBeTruthy() + fireEvent.click(anchor) + expect(screen.queryByText('card body')).toBeNull() + + fireEvent.click(anchor) + fireEvent.keyDown(anchor, { key: 'a' }) + expect(screen.getByText('card body')).toBeTruthy() + ownerKey.mockClear() + expect(fireEvent.keyDown(anchor, { key: 'Escape' })).toBe(false) + expect(ownerKey).not.toHaveBeenCalled() + expect(screen.queryByText('card body')).toBeNull() + + fireEvent.click(anchor) + expect(fireEvent.keyDown(anchor, { key: 'Tab' })).toBe(true) + expect(screen.queryByText('card body')).toBeNull() + }) + + it('drops a click pin when click opening is removed or the card is disabled', () => { + const pinnedCard = (disabled = false) => ( + details} content={
card body
} /> + ) + const hoverCard = details} content={
card body
} /> + const view = render(pinnedCard()) + const anchor = screen.getByRole('button', { name: 'details' }) + fireEvent.click(anchor) + expect(screen.getByText('card body')).toBeTruthy() + view.rerender(pinnedCard(true)) + expect(screen.queryByText('card body')).toBeNull() + + view.rerender(pinnedCard()) + fireEvent.click(anchor) + expect(screen.getByText('card body')).toBeTruthy() + view.rerender(hoverCard) + expect(screen.queryByText('card body')).toBeNull() + }) + + it('removes click-pin dismissal listeners on unmount', () => { + const view = render( + details} content={
card body
} />, + ) + const registrations = vi.spyOn(document, 'addEventListener') + const removals = vi.spyOn(document, 'removeEventListener') + fireEvent.click(screen.getByRole('button', { name: 'details' })) + expect(screen.getByText('card body')).toBeTruthy() + + const listeners = registrations.mock.calls.filter(([type]) => type === 'keydown' || type === 'pointerdown') + expect(listeners.map(([type]) => type).sort()).toEqual(['keydown', 'pointerdown']) + view.unmount() + + for (const [type, listener, options] of listeners) { + const removed = removals.mock.calls.find(([removedType, removedListener]) => + removedType === type && removedListener === listener) + expect(removed).toBeDefined() + expect(removed?.[2]).toBe(options) + } + expect(fireEvent.keyDown(document.body, { key: 'Escape' })).toBe(true) + fireEvent.pointerDown(document.body) + }) + it('dismisses on keyboard clicks and cancels a pending hover', () => { const { anchor, wrapper } = mount({ variant: 'preview' }) fireEvent.pointerEnter(wrapper) @@ -656,6 +780,22 @@ it('opens inline previews only for keyboard focus and closes when focus leaves t matches.mockRestore() }) +it('keeps an inline preview open when the pointer leaves its focused anchor', () => { + render(details} content={thumbnail} />) + const anchor = screen.getByRole('button', { name: 'details' }) + const matches = vi.spyOn(anchor, 'matches').mockReturnValue(true) + fireEvent.focus(anchor) + expect(screen.getByText('thumbnail')).toBeTruthy() + const activeElement = vi.spyOn(document, 'activeElement', 'get').mockReturnValue(anchor) + fireEvent.pointerLeave(anchor.parentElement as HTMLElement) + act(() => { vi.advanceTimersByTime(POINTER_GRACE_MS) }) + expect(screen.getByText('thumbnail')).toBeTruthy() + activeElement.mockRestore() + fireEvent.blur(anchor, { relatedTarget: document.body }) + expect(screen.queryByText('thumbnail')).toBeNull() + matches.mockRestore() +}) + it('dismisses an inline hover without anchor focus and lets the next Escape reach the owner', () => { const ownerKey = vi.fn() render(
diff --git a/packages/client/ui-user-questions/README.i18n.yaml b/packages/client/ui-user-questions/README.i18n.yaml index 78ff5697f1..b161fa2b89 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: cc19c065d0aeac1e - zh: 91c8cbe08b438e7f + en: 05caa3aa1846dc07 + zh: 8055a578d71d3f58 /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 d57e359de7..410455e5a8 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 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. Each option description occupies one ellipsized line; its information action opens the complete selectable text on hover, keyboard focus, or click. "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 2145663976..9a20a9e5f4 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 渲染与不受信任内容策略。限高卡片保持标题、导航与提交动作固定,超长的详情与选项共享内部滚动区。「跳过」会保留其他草稿,并为该项发出既有的空 `{ selected: [] }` 结果。关闭不是一种回答:Host 以工具调用命名的问题只是离开编辑器位置,其 `ask_user_question` 工具调用行会把面板带回来;不携带工具调用的请求没有可返回的行,关闭它仍以 `ASK_CANCELLED` 拒绝整个等待。 +用户打开或编辑自定义答案时,多选题草稿会保留已选中的标签,因此提交项可以同时携带 `selected` 与 `custom`;单选题的自定义答案仍保持互斥。问题详情复用助手输出的 `MarkdownText` 原语,包括其 GFM 渲染与不受信任内容策略。限高卡片保持导航与提交动作固定,问题正文、超长详情和选项共享内部滚动区。每段选项说明只占一行并在溢出时显示省略号;悬停、键盘聚焦或点击信息按钮会打开可选择文本的完整说明。「跳过」会保留其他草稿,并为该项发出既有的空 `{ 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 8de8c84028..5c6d8c013b 100644 --- a/packages/client/ui-user-questions/src/client/QuestionComposer.module.css +++ b/packages/client/ui-user-questions/src/client/QuestionComposer.module.css @@ -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; } @@ -97,14 +99,24 @@ } .title { - margin: 0; + flex-shrink: 0; + margin: 5px 24px 0; font-size: 16px; line-height: 22px; font-weight: 500; } +.cardMinimized .title { + display: -webkit-box; + margin: 0; + overflow: hidden; + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; +} + .detail { - margin: 0 2px 8px; + flex-shrink: 0; + margin: 0 14px 8px; } .footerActions { @@ -175,29 +187,35 @@ 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; +} + .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; +} + +.optionWithDescription { + padding-right: 42px; } .optionSelected { @@ -205,7 +223,7 @@ } @media (hover: hover) { - .option:hover:not(:disabled) { + .optionRowInteractive:hover { background: var(--dsw-alias-interactive-bg-hover); } } @@ -305,10 +323,48 @@ } .description { + display: block; + min-width: 0; + overflow: hidden; color: var(--dsw-alias-label-tertiary); - font-size: 14px; - line-height: 24px; + font-size: 13px; + line-height: 20px; font-weight: 400; + text-overflow: ellipsis; + white-space: nowrap; +} + +.descriptionAction { + position: absolute; + right: 12px; + bottom: 6px; + width: 24px; + height: 24px; +} + +.descriptionButton { + display: grid; + place-items: center; + width: 24px; + height: 24px; + padding: 0; + border: 0; + border-radius: 999px; + corner-shape: round; + background: transparent; + color: var(--dsw-alias-label-tertiary); + cursor: pointer; +} + +.descriptionButton:hover, +.descriptionButton:focus-visible { + background: var(--dsw-alias-interactive-bg-hover); + color: var(--dsw-alias-label-primary); +} + +.descriptionPreview { + font-size: 13px; + line-height: 20px; } /* Read-only note standing in for a question the user skipped: it takes the @@ -477,6 +533,14 @@ padding: 8px 6px; } + .optionWithDescription { + padding-right: 36px; + } + + .descriptionAction { + right: 6px; + } + .footer { align-items: flex-end; padding: 0 10px; @@ -488,7 +552,7 @@ } @media (prefers-reduced-motion: reduce) { - .option, + .optionRow, .customRow { transition: none; } diff --git a/packages/client/ui-user-questions/src/client/QuestionComposer.tsx b/packages/client/ui-user-questions/src/client/QuestionComposer.tsx index 8d3ad551ba..d447f96840 100644 --- a/packages/client/ui-user-questions/src/client/QuestionComposer.tsx +++ b/packages/client/ui-user-questions/src/client/QuestionComposer.tsx @@ -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, + HoverCard, IconEditOutlineRegular, IconInfoOutlineRegular, MarkdownText, } 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' @@ -157,6 +157,7 @@ type QuestionFlowProps = { pending: PendingQuestion } & Pick 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. @@ -436,12 +437,16 @@ function QuestionFlow({ pending, t, useStore, useQuestionCard, actions }: Questi aria-labelledby={`question-${pending.key}-${String(index)}`} >
-
- {question.header !== undefined &&
{question.header}
} -

- {question.question} -

-
+ {(question.header !== undefined || minimized) && ( +
+ {question.header !== undefined &&
{question.header}
} + {minimized && ( +

+ {question.question} +

+ )} +
+ )}
{countdown !== undefined && card.waitState !== 'waiting' && card.waitState !== 'editing' && card.waitState !== 'continued' && ( @@ -486,12 +491,16 @@ function QuestionFlow({ pending, t, useStore, useQuestionCard, actions }: Questi {!minimized && ( <>
+

+ {question.question} +

{question.detail !== undefined && (
)} @@ -499,40 +508,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 descriptionId = `${descriptionIdPrefix}-question-${String(index)}-option-${String(optionIndex)}-description` return ( - + + {hasDescription && ( + + + + + )} + content={} + /> + + )} +
) })} diff --git a/packages/client/ui-user-questions/src/client/locales.ts b/packages/client/ui-user-questions/src/client/locales.ts index d45fd56b4a..26aa017cc9 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': '收起问题面板,可从工具调用重新打开', + 'option.details': '查看“{option}”的选项说明', 'option.recommended': '推荐', 'custom.placeholder': '输入你的答案', 'action.skip': '跳过', @@ -63,6 +64,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', + 'option.details': 'Show details for “{option}”', 'option.recommended': 'Recommended', 'custom.placeholder': 'Type your answer', 'action.skip': 'Skip', diff --git a/packages/client/ui-user-questions/tests/question-composer-styles.client.spec.ts b/packages/client/ui-user-questions/tests/question-composer-styles.client.spec.ts new file mode 100644 index 0000000000..1bb0bb553e --- /dev/null +++ b/packages/client/ui-user-questions/tests/question-composer-styles.client.spec.ts @@ -0,0 +1,37 @@ +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, ' ') + +function declarations(selector: string): string[] { + const escaped = selector.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + const rule = new RegExp(`(?:^|\\})\\s*${escaped}\\s*\\{([^{}]*)\\}`).exec(stylesheet) + if (rule === null) throw new Error(`QuestionComposer.module.css has no \`${selector}\` rule`) + return (rule[1] ?? '').split(';').map(part => part.trim()).filter(Boolean) +} + +describe('QuestionComposer compact long-copy layout', () => { + it('keeps the option description to one ellipsized line', () => { + expect(declarations('.description')).toEqual(expect.arrayContaining([ + 'display: block', + 'min-width: 0', + 'overflow: hidden', + 'font-size: 13px', + 'line-height: 20px', + 'text-overflow: ellipsis', + 'white-space: nowrap', + ])) + }) + + it('keeps the question and choices in the card scrollport', () => { + expect(declarations('.body')).toEqual(expect.arrayContaining([ + 'min-height: 0', + 'overflow-y: auto', + ])) + expect(declarations('.title')).toContain('flex-shrink: 0') + }) +}) 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 8ece0da777..c5ce1dc757 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 @@ -24,7 +24,13 @@ afterEach(cleanup) const SID = 's1' as SessionId const seatOver = (dict: Record, common: Record): 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[0]>[0] type ConversationState = Parameters[0]>[0] @@ -257,6 +263,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: /工程落地型/ })) @@ -293,6 +300,77 @@ describe('QuestionComposer', () => { expect(screen.getByRole('button', { name: '正在提交…' }).disabled).toBe(true) }) + 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([{ + 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() + + const option = screen.getByRole('radio', { name: 'Explicit install' }) + const details = screen.getByRole('button', { name: '查看“Explicit install”的选项说明' }) + expect(screen.getAllByRole('button', { name: /选项说明/ })).toHaveLength(1) + expect(screen.getAllByText(description)).toHaveLength(1) + const describedBy = details.getAttribute('aria-describedby') + expect(describedBy).not.toBeNull() + expect(document.getElementById(describedBy as string)?.textContent).toBe(description) + fireEvent.click(details) + + expect(option.getAttribute('aria-checked')).toBe('false') + expect(details.getAttribute('aria-describedby')).toBe(describedBy) + expect(screen.getAllByText(description)).toHaveLength(2) + fireEvent.pointerDown(document.body) + expect(screen.getAllByText(description)).toHaveLength(1) + }) + + it('resets scrolling and an open option description when the question changes', () => { + const firstDescription = 'First question detail that was pinned open.' + const secondDescription = 'Second question detail starts closed.' + const { carrier } = wait([ + { + id: 'first', question: 'First question?', + options: [{ label: 'Continue', description: firstDescription }], + }, + { + id: 'second', question: 'Second question?', + options: [{ label: 'Continue', description: secondDescription }], + }, + ]) + render() + + expect(screen.getByRole('region', { name: 'First question?' })).toBeTruthy() + const firstScroll = screen.getByText('First question?').closest('[data-question-scroll]') as HTMLElement + firstScroll.scrollTop = 80 + const firstDetails = screen.getByRole('button', { name: '查看“Continue”的选项说明' }) + fireEvent.click(firstDetails) + expect(screen.getAllByText(firstDescription)).toHaveLength(2) + const firstDescribedIds = (firstDetails.getAttribute('aria-describedby') ?? '').split(/\s+/).filter(Boolean) + expect(firstDescribedIds.length).toBeGreaterThan(0) + expect(firstDescribedIds.every(id => document.getElementById(id) !== null)).toBe(true) + expect(firstDescribedIds.map(id => document.getElementById(id)?.textContent)).toContain(firstDescription) + 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).not.toBe(firstScroll) + expect(secondScroll.scrollTop).toBe(0) + expect(screen.queryByText(firstDescription)).toBeNull() + expect(screen.getAllByText(secondDescription)).toHaveLength(1) + const secondDetails = screen.getByRole('button', { name: '查看“Continue”的选项说明' }) + const describedIds = (secondDetails.getAttribute('aria-describedby') ?? '').split(/\s+/).filter(Boolean) + expect(describedIds.length).toBeGreaterThan(0) + expect(describedIds.every(id => document.getElementById(id) !== null)).toBe(true) + expect(describedIds.map(id => document.getElementById(id)?.textContent)).toContain(secondDescription) + expect(describedIds.map(id => document.getElementById(id)?.textContent)).not.toContain(firstDescription) + }) + it('renders plan detail through the shared assistant Markdown primitive', () => { const { carrier } = wait([{ id: 'plan', @@ -863,6 +941,7 @@ describe('review card', () => { const chosen = screen.getByRole('radio', { name: '工程落地型' }) expect(chosen.getAttribute('aria-checked')).toBe('true') expect(chosen.disabled).toBe(true) + expect(screen.getByRole('button', { name: '查看“工程落地型”的选项说明' }).disabled).toBe(false) // Nothing is left to send, and the unused free-text field would read as // somewhere to type. expect(screen.queryByRole('button', { name: '跳过' })).toBeNull() diff --git a/snapshots/web/question-composer/composed.expected.md b/snapshots/web/question-composer/composed.expected.md index 2b62217f60..e5a7b73860 100644 --- a/snapshots/web/question-composer/composed.expected.md +++ b/snapshots/web/question-composer/composed.expected.md @@ -1,11 +1,13 @@ - 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. + - button "Show details for “Blue”" - checkbox "Green": Green A restful mid-spectrum hue with the highest perceived brightness, easiest on the eye over long sessions. + - button "Show details for “Green”" - textbox "Type your answer": Include accessibility notes - button "Previous question" [disabled] - text: 1 / 1 diff --git a/snapshots/web/question-composer/late-answered.expected.md b/snapshots/web/question-composer/late-answered.expected.md index a2e1961f8d..49ac47eeb4 100644 --- a/snapshots/web/question-composer/late-answered.expected.md +++ b/snapshots/web/question-composer/late-answered.expected.md @@ -29,14 +29,15 @@ - 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. + - button "Show details for “Blue”" - checkbox "Green" [checked] [disabled]: Green A restful mid-spectrum hue with the highest perceived brightness, easiest on the eye over long sessions. + - button "Show details for “Green”" - textbox "Type your answer" [disabled]: Answered after the timeout - button "Previous question" [disabled] - text: 1 / 1 diff --git a/snapshots/web/question-composer/ui.expected.md b/snapshots/web/question-composer/ui.expected.md index e975922565..3188d73c5b 100644 --- a/snapshots/web/question-composer/ui.expected.md +++ b/snapshots/web/question-composer/ui.expected.md @@ -1,11 +1,13 @@ - 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. + - button "Show details for “Blue”" - checkbox "Green": Green A restful mid-spectrum hue with the highest perceived brightness, easiest on the eye over long sessions. + - button "Show details for “Green”" - textbox "Type your answer" - button "Previous question" [disabled] - text: 1 / 1 From 1cbf50d03e536ce322873bfa17bdbe992842184d Mon Sep 17 00:00:00 2001 From: yudshj Date: Thu, 1 Oct 2026 22:31:04 +0800 Subject: [PATCH 02/21] test(web): stress long question layouts --- apps/web/tests/question-composer.e2e.ts | 49 ++++++++++++++++++++----- 1 file changed, 40 insertions(+), 9 deletions(-) diff --git a/apps/web/tests/question-composer.e2e.ts b/apps/web/tests/question-composer.e2e.ts index 70fa1fd1b6..73b77e9fc5 100644 --- a/apps/web/tests/question-composer.e2e.ts +++ b/apps/web/tests/question-composer.e2e.ts @@ -559,7 +559,8 @@ describe('web e2e: resident question composer round trip', () => { 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 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 = 'Remove the experimental client from every shipped template and require users to install it explicitly in the profile where they need it.' + 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, @@ -584,16 +585,18 @@ describe('web e2e: resident question composer round trip', () => { let bodyCompleted = false try { await composer.waitFor({ timeout: 30_000 }) - // Browser zoom reduces the CSS-pixel viewport. These dimensions model - // the same 640x420 window at 100%, 125%, and 150% without relying on a - // browser-global zoom preference that Playwright cannot isolate per page. + // 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 }, ]) { await page.setViewportSize(viewport) - await expect.poll(() => composer.evaluate((card) => { + const readMetrics = () => composer.evaluate((card) => { const scroll = card.querySelector('[data-question-scroll]') const title = card.querySelector('h2') const submit = [...card.querySelectorAll('button')] @@ -605,17 +608,20 @@ describe('web e2e: resident question composer round trip', () => { scrolls: scroll.scrollHeight > scroll.clientHeight, titleInScrollport: scroll.contains(title), } - })).toEqual({ - footerFits: true, - scrolls: true, - titleInScrollport: true, }) + 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) const questionLines = await composer.getByRole('heading', { name: question }).evaluate((title) => { const style = getComputedStyle(title) return title.getBoundingClientRect().height / parseFloat(style.lineHeight) }) expect(questionLines).toBeGreaterThan(2) } + expect(scrollStates).toContain(false) + expect(scrollStates).toContain(true) const detailText = composer.getByText(detail, { exact: true }) expect(await detailText.evaluate(node => node.closest('[data-question-scroll]') !== null)).toBe(true) @@ -632,6 +638,13 @@ describe('web e2e: resident question composer round trip', () => { expect(await info.getAttribute('aria-describedby')).toBe(descriptionId) const preview = page.locator('[data-pinned="true"]') await preview.getByText(description, { exact: true }).waitFor() + expect(await page.locator(`[id=${JSON.stringify(descriptionId)}]`).evaluate((item) => { + const style = getComputedStyle(item) + return { + ellipsized: item.scrollWidth > item.clientWidth, + lines: item.clientHeight / parseFloat(style.lineHeight), + } + })).toEqual({ ellipsized: true, lines: 1 }) const previewBox = await preview.boundingBox() const constrainedViewport = page.viewportSize() expect(constrainedViewport).not.toBeNull() @@ -642,6 +655,24 @@ describe('web e2e: resident question composer round trip', () => { .toBeLessThanOrEqual((constrainedViewport?.width ?? 0) - 7.5) expect((previewBox?.y ?? 0) + (previewBox?.height ?? 0)) .toBeLessThanOrEqual((constrainedViewport?.height ?? 0) - 7.5) + expect(await preview.evaluate(card => ({ + overflowY: getComputedStyle(card).overflowY, + scrolls: card.scrollHeight > card.clientHeight, + }))).toEqual({ overflowY: 'auto', scrolls: true }) + expect(await preview.getByText(description, { exact: true }).evaluate((text) => { + const selection = window.getSelection() + const range = document.createRange() + range.selectNodeContents(text) + selection?.removeAllRanges() + selection?.addRange(range) + const selectedLength = selection?.toString().length ?? 0 + selection?.removeAllRanges() + return selectedLength + })).toBe(description.length) + expect(await preview.evaluate((card) => { + card.scrollTop = card.scrollHeight + return card.scrollTop + card.clientHeight >= card.scrollHeight - 1 + })).toBe(true) await page.keyboard.press('Escape') await composer.getByRole('checkbox', { name: 'Explicit install' }).click() From 5b96747630b7d82f4888ba71f377e999394a08e7 Mon Sep 17 00:00:00 2001 From: yudshj Date: Thu, 1 Oct 2026 23:09:03 +0800 Subject: [PATCH 03/21] fix(ui-user-questions): refine option detail callouts --- apps/web/tests/question-composer.e2e.ts | 32 +++++- .../ui-primitives/src/HoverCard.module.css | 50 ++++++++++ .../client/ui-primitives/src/HoverCard.tsx | 98 ++++++++++++++----- .../tests/hover-card.client.spec.tsx | 41 +++++++- .../client/ui-user-questions/README.i18n.yaml | 4 +- packages/client/ui-user-questions/README.md | 2 +- .../client/ui-user-questions/README.zh.md | 2 +- .../src/client/QuestionComposer.module.css | 3 + .../src/client/QuestionComposer.tsx | 2 +- 9 files changed, 204 insertions(+), 30 deletions(-) diff --git a/apps/web/tests/question-composer.e2e.ts b/apps/web/tests/question-composer.e2e.ts index 73b77e9fc5..d0c71ab3ab 100644 --- a/apps/web/tests/question-composer.e2e.ts +++ b/apps/web/tests/question-composer.e2e.ts @@ -633,6 +633,15 @@ describe('web e2e: resident question composer round trip', () => { const descriptionId = await info.getAttribute('aria-describedby') expect(descriptionId).not.toBeNull() expect(await page.locator(`[id=${JSON.stringify(descriptionId)}]`).textContent()).toBe(description) + await info.hover() + const hoveredPreview = page.locator('[data-placement]') + await hoveredPreview.getByText(description, { exact: true }).waitFor() + const hoveredBox = await hoveredPreview.boundingBox() + expect(hoveredBox).not.toBeNull() + await page.mouse.move((hoveredBox?.x ?? 0) + 24, (hoveredBox?.y ?? 0) + 24) + await page.waitForTimeout(250) + expect(await hoveredPreview.isVisible()).toBe(true) + await info.click() expect(await composer.getByRole('checkbox', { name: 'Explicit install' }).getAttribute('aria-checked')).toBe('false') expect(await info.getAttribute('aria-describedby')).toBe(descriptionId) @@ -655,10 +664,29 @@ describe('web e2e: resident question composer round trip', () => { .toBeLessThanOrEqual((constrainedViewport?.width ?? 0) - 7.5) expect((previewBox?.y ?? 0) + (previewBox?.height ?? 0)) .toBeLessThanOrEqual((constrainedViewport?.height ?? 0) - 7.5) - expect(await preview.evaluate(card => ({ + expect(previewBox?.height).toBeLessThanOrEqual(240) + const scrollport = preview.locator('[data-hover-card-scroll]') + expect(await scrollport.evaluate(card => ({ overflowY: getComputedStyle(card).overflowY, scrolls: card.scrollHeight > card.clientHeight, }))).toEqual({ overflowY: 'auto', scrolls: true }) + const caretMetrics = await preview.evaluate((card, infoButton) => { + const caret = card.querySelector('[data-hover-card-caret]') + if (!(infoButton instanceof HTMLElement) || caret === null) return null + const anchorBox = infoButton.getBoundingClientRect() + const caretBox = caret.getBoundingClientRect() + const placement = card.dataset.placement + return { + horizontalDelta: Math.abs((anchorBox.left + anchorBox.width / 2) - (caretBox.left + caretBox.width / 2)), + verticalGap: placement === 'above' + ? anchorBox.top - caretBox.bottom + : caretBox.top - anchorBox.bottom, + } + }, await info.elementHandle()) + expect(caretMetrics).not.toBeNull() + expect(caretMetrics?.horizontalDelta).toBeLessThan(1) + expect(caretMetrics?.verticalGap).toBeGreaterThanOrEqual(0) + expect(caretMetrics?.verticalGap).toBeLessThanOrEqual(3) expect(await preview.getByText(description, { exact: true }).evaluate((text) => { const selection = window.getSelection() const range = document.createRange() @@ -669,7 +697,7 @@ describe('web e2e: resident question composer round trip', () => { selection?.removeAllRanges() return selectedLength })).toBe(description.length) - expect(await preview.evaluate((card) => { + expect(await scrollport.evaluate((card) => { card.scrollTop = card.scrollHeight return card.scrollTop + card.clientHeight >= card.scrollHeight - 1 })).toBe(true) diff --git a/packages/client/ui-primitives/src/HoverCard.module.css b/packages/client/ui-primitives/src/HoverCard.module.css index 50c62fa828..0542763a8a 100644 --- a/packages/client/ui-primitives/src/HoverCard.module.css +++ b/packages/client/ui-primitives/src/HoverCard.module.css @@ -88,3 +88,53 @@ backdrop-filter: var(--dsw-menu-backdrop-filter); box-shadow: var(--dsw-elevation-panel); } + +.callout { + --dsh-scrollbar-thumb: var(--dsw-alias-scrollbar-bg-l2); + --dsh-scrollbar-thumb-hover: var(--dsw-alias-scrollbar-hover-l2); + isolation: isolate; + padding: 0; + border: 0; + background: transparent; + box-shadow: none; +} + +.calloutSurface { + position: relative; + z-index: 1; + padding: 4px; + overflow: hidden; + border: 0; + border-radius: var(--dsw-radius-xl); + background: var(--dsw-specific-bubble); + box-shadow: var(--dsw-elevation-panel); +} + +.calloutBody { + box-sizing: border-box; + max-height: max(0px, calc(var(--dsh-hover-card-max-height) - 8px)); + overflow-y: auto; + overscroll-behavior: contain; + padding: 4px 8px; + border-radius: var(--dsw-radius-lg); + color: var(--dsw-alias-label-primary); +} + +.calloutCaret { + position: absolute; + z-index: 2; + left: var(--dsh-hover-card-anchor-x); + width: 10px; + height: 10px; + background: var(--dsw-specific-bubble); + transform: translateX(-50%) rotate(45deg); + pointer-events: none; +} + +.callout[data-placement='below'] .calloutCaret { + top: -4px; +} + +.callout[data-placement='above'] .calloutCaret { + bottom: -4px; +} diff --git a/packages/client/ui-primitives/src/HoverCard.tsx b/packages/client/ui-primitives/src/HoverCard.tsx index 218d86afc6..6f6d7288e7 100644 --- a/packages/client/ui-primitives/src/HoverCard.tsx +++ b/packages/client/ui-primitives/src/HoverCard.tsx @@ -14,6 +14,10 @@ const PREVIEW_FADE_MS = 100 const PREVIEW_MAX_HEIGHT = 420 const PREVIEW_INSET = 24 const INLINE_PREVIEW_WIDTH = 300 +const CALLOUT_MAX_HEIGHT = 240 +const CALLOUT_WIDTH = 300 +const CALLOUT_ARROW_EDGE_INSET = 16 +const CALLOUT_SURFACE_INSET = 4 const ANCHOR_GAP = 8 const VIEWPORT_MARGIN = 8 @@ -28,7 +32,7 @@ type HoverCardProps = { openDelayMs?: number disabled?: boolean grouped?: boolean - variant?: 'compact' | 'preview' + variant?: 'compact' | 'preview' | 'callout' widthAnchorRef?: RefObject } & ({ anchor: ReactElement @@ -51,8 +55,8 @@ type HoverCardProps = { * @param props.content - card content; the pointer may rest on it, so it is * readable and selectable, but it carries no dismissal affordance of its own. * @param props.openDelayMs - hover dwell before the card shows (default 500). - * @param props.variant - compact card beside the anchor, or a preview above/below it - * with 24px side insets, a 420px height cap, frame-top clearance, and 100ms opacity transitions. + * @param props.variant - compact card beside the anchor; a preview with container-relative + * width and opacity transitions; or a 300px callout with an anchor caret and 240px height cap. * @param props.widthAnchorRef - optional element whose width and horizontal position size the preview. * @param props.inline - keep the anchor in prose; show a contained preview on hover or keyboard focus. * @param props.disabled - suppress opening; turning true dismisses an open card. @@ -74,9 +78,12 @@ export function HoverCard(props: HoverCardProps) { copyText, copyLabel, copiedLabel, variant = 'compact', widthAnchorRef, inline = false, } = props const openOnClick = props.openOnClick === true + const callout = variant === 'callout' + const anchored = inline || callout const id = useId() const rootRef = useRef(null) const cardRef = useRef(null) + const scrollRef = useRef(null) const timerRef = useRef | null>(null) const copyTimerRef = useRef | null>(null) const copyHeightRef = useRef(null) @@ -86,7 +93,14 @@ export function HoverCard(props: HoverCardProps) { const [phase, setPhase] = useState<'closed' | 'open' | 'closing'>('closed') const open = phase !== 'closed' const closing = phase === 'closing' - const [pos, setPos] = useState<{ left: number; top: number; width?: number; maxHeight?: number } | null>(null) + const [pos, setPos] = useState<{ + left: number + top: number + width?: number + maxHeight?: number + anchorX?: number + placement?: 'above' | 'below' + } | null>(null) const positioned = pos !== null const [copied, setCopied] = useState(false) const parentGroup = useContext(HoverCardGroupContext) @@ -164,7 +178,7 @@ export function HoverCard(props: HoverCardProps) { }, []) useEffect(() => { - if (!open || openOnClick || (variant !== 'preview' && !inline)) return + if (!open || openOnClick || (variant !== 'preview' && !anchored)) return const dismiss = (event: KeyboardEvent): void => { if (event.key !== 'Escape') return if (inline) event.stopPropagation() @@ -174,7 +188,7 @@ export function HoverCard(props: HoverCardProps) { } window.addEventListener('keydown', dismiss, inline) return () => { window.removeEventListener('keydown', dismiss, inline) } - }, [open, openOnClick, variant, inline, cancelClose, close]) + }, [open, openOnClick, variant, anchored, cancelClose, close]) useEffect(() => { if (!openOnClick || !open) return @@ -222,19 +236,34 @@ export function HoverCard(props: HoverCardProps) { }) return } - if (inline) { - const height = Math.max(h, cardRef.current?.scrollHeight ?? 0) - const width = Math.max(0, Math.min(INLINE_PREVIEW_WIDTH, window.innerWidth - VIEWPORT_MARGIN * 2)) + if (anchored) { + const height = callout + ? Math.max(h, (scrollRef.current?.scrollHeight ?? 0) + CALLOUT_SURFACE_INSET * 2) + : Math.max(h, cardRef.current?.scrollHeight ?? 0) + const width = Math.max(0, Math.min(callout ? CALLOUT_WIDTH : INLINE_PREVIEW_WIDTH, + window.innerWidth - VIEWPORT_MARGIN * 2)) const topMargin = overlayTopMargin(VIEWPORT_MARGIN) const belowTop = Math.max(topMargin, r.bottom + ANCHOR_GAP) const above = Math.max(0, r.top - ANCHOR_GAP - topMargin) const below = Math.max(0, window.innerHeight - belowTop - VIEWPORT_MARGIN) const onTop = height > below && above > below - const maxHeight = onTop ? above : below + const maxHeight = Math.min(callout ? CALLOUT_MAX_HEIGHT : Number.POSITIVE_INFINITY, + onTop ? above : below) + const anchorCenter = (r.left + r.right) / 2 + const idealLeft = callout ? anchorCenter - width / 2 : r.left + const left = Math.max(VIEWPORT_MARGIN, Math.min(idealLeft, + window.innerWidth - width - VIEWPORT_MARGIN)) + const arrowInset = Math.min(CALLOUT_ARROW_EDGE_INSET, width / 2) setPos({ - left: Math.max(VIEWPORT_MARGIN, Math.min(r.left, window.innerWidth - width - VIEWPORT_MARGIN)), + left, top: onTop ? r.top - ANCHOR_GAP - Math.min(height, maxHeight) : belowTop, width, maxHeight, + ...(callout + ? { + anchorX: Math.max(arrowInset, Math.min(anchorCenter - left, width - arrowInset)), + placement: onTop ? 'above' as const : 'below' as const, + } + : {}), }) return } @@ -242,8 +271,8 @@ export function HoverCard(props: HoverCardProps) { setPos({ left: r.right + ANCHOR_GAP, top }) } place() - const observer = (variant === 'preview' || inline) && typeof ResizeObserver !== 'undefined' ? new ResizeObserver(place) : null - for (const element of [cardRef.current, rootRef.current, widthAnchorRef?.current]) { + const observer = (variant === 'preview' || anchored) && typeof ResizeObserver !== 'undefined' ? new ResizeObserver(place) : null + for (const element of [cardRef.current, scrollRef.current, rootRef.current, widthAnchorRef?.current]) { if (element !== null && element !== undefined) observer?.observe(element) } window.addEventListener('scroll', place, true) @@ -253,19 +282,19 @@ export function HoverCard(props: HoverCardProps) { window.removeEventListener('scroll', place, true) window.removeEventListener('resize', place) } - }, [open, variant, widthAnchorRef, positioned, inline]) + }, [open, variant, widthAnchorRef, positioned, anchored, callout]) // The first placement ran before the card mounted (height read 0): once the // card's real height is measurable, correct the bottom-edge clamp. The // correction converges — a clamped top satisfies the guard, so it runs once. useLayoutEffect(() => { - if (!open || pos === null || variant === 'preview' || inline) return + if (!open || pos === null || variant === 'preview' || anchored) return /* v8 ignore next -- the card is mounted whenever pos is set, so the ref is attached here. */ const h = cardRef.current?.offsetHeight ?? 0 if (pos.top + h > window.innerHeight - VIEWPORT_MARGIN) { setPos({ left: pos.left, top: window.innerHeight - h - VIEWPORT_MARGIN }) } - }, [open, pos, variant, inline]) + }, [open, pos, variant, anchored]) const copy = async (text: string): Promise => { if (copied || copyingRef.current) return @@ -301,16 +330,23 @@ export function HoverCard(props: HoverCardProps) { } close() } + const cardContent = copied ? : content const card = open && pos !== null && (
{ + cancelClose() + setPhase('open') + }} + onPointerLeave={() => { + if (!pinned + && rootRef.current?.contains(document.activeElement) !== true + && cardRef.current?.contains(document.activeElement) !== true) armClose() + }} > - {copied ? : content} + {callout + ? ( + <> +
) return ( { + className={clsx(css.root, anchored && css.inline)} + onFocus={anchored ? (event) => { if (!disabled && event.target.matches(':focus-visible')) { cancelClose(); setPhase('open') } } : undefined} - onBlur={inline ? (event) => { + onBlur={anchored ? (event) => { if (!pinned && !event.currentTarget.contains(event.relatedTarget)) { clearTimer(); cancelClose(); close() } } : undefined} onPointerEnter={(event) => { diff --git a/packages/client/ui-primitives/tests/hover-card.client.spec.tsx b/packages/client/ui-primitives/tests/hover-card.client.spec.tsx index 1059631263..fa51eef33a 100644 --- a/packages/client/ui-primitives/tests/hover-card.client.spec.tsx +++ b/packages/client/ui-primitives/tests/hover-card.client.spec.tsx @@ -22,7 +22,7 @@ function stubAnchorRect(anchor: HTMLElement, rect: { top: number; right: number function mount(props: { widthAnchorRef?: { current: HTMLElement | null } - variant?: 'compact' | 'preview' + variant?: 'compact' | 'preview' | 'callout' openDelayMs?: number disabled?: boolean copyText?: string @@ -165,6 +165,27 @@ describe('HoverCard', () => { expect(screen.queryByText('card body')).toBeNull() }) + it('caps a callout, keeps its caret on the anchor, and flips it above when needed', () => { + const { wrapper } = mount({ variant: 'callout' }) + fireEvent.pointerEnter(wrapper) + act(() => { vi.advanceTimersByTime(500) }) + const card = screen.getByText('card body').closest('[data-placement]') as HTMLElement + const body = card.querySelector('[data-hover-card-scroll]') as HTMLElement + expect(card.style.width).toBe('300px') + expect(card.style.left).toBe('8px') + expect(card.style.top).toBe('82px') + expect(card.dataset.placement).toBe('below') + expect(card.style.getPropertyValue('--dsh-hover-card-max-height')).toBe('240px') + expect(card.style.getPropertyValue('--dsh-hover-card-anchor-x')).toBe('142px') + + Object.defineProperty(body, 'scrollHeight', { configurable: true, value: 600 }) + wrapper.getBoundingClientRect = () => DOMRect.fromRect({ x: 100, y: 700, width: 100, height: 34 }) + fireEvent(window, new Event('resize')) + expect(card.style.top).toBe('452px') + expect(card.dataset.placement).toBe('above') + expect(card.style.getPropertyValue('--dsh-hover-card-max-height')).toBe('240px') + }) + it('observes preview and container sizes until dismissal', () => { let resize = () => {} const observe = vi.fn() @@ -254,6 +275,9 @@ describe('HoverCard', () => { fireEvent.blur(anchor, { relatedTarget: document.body }) expect(screen.getByText('card body')).toBeTruthy() + fireEvent.pointerLeave(card) + act(() => { vi.advanceTimersByTime(POINTER_GRACE_MS) }) + expect(screen.getByText('card body')).toBeTruthy() fireEvent.pointerDown(card) expect(screen.getByText('card body')).toBeTruthy() fireEvent.pointerDown(document.body) @@ -425,6 +449,21 @@ describe('HoverCard', () => { expect(screen.getByText('card body')).toBeTruthy() }) + it('keeps the card open while its own pointer is inside and closes after leaving it', () => { + const { wrapper } = mount({ variant: 'callout' }) + fireEvent.pointerEnter(wrapper) + act(() => { vi.advanceTimersByTime(500) }) + const card = screen.getByText('card body').closest('[data-placement]') as HTMLElement + fireEvent.pointerLeave(wrapper) + act(() => { vi.advanceTimersByTime(POINTER_GRACE_MS - 50) }) + fireEvent.pointerEnter(card) + act(() => { vi.advanceTimersByTime(POINTER_GRACE_MS) }) + expect(screen.getByText('card body')).toBeTruthy() + fireEvent.pointerLeave(card) + act(() => { vi.advanceTimersByTime(POINTER_GRACE_MS) }) + expect(screen.queryByText('card body')).toBeNull() + }) + it('re-entering while open does not queue a second dwell', () => { const { wrapper } = mount() fireEvent.pointerEnter(wrapper) diff --git a/packages/client/ui-user-questions/README.i18n.yaml b/packages/client/ui-user-questions/README.i18n.yaml index b161fa2b89..5d9e49a032 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: 05caa3aa1846dc07 - zh: 8055a578d71d3f58 + en: 579cf44ad03380ee + zh: 5fe535a78d8ed0b8 /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 410455e5a8..f4b7b4abbc 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. Each option description occupies one ellipsized line; its information action opens the complete selectable text on hover, keyboard focus, or click. "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. Each option description occupies one ellipsized line; its information action 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 9a20a9e5f4..73ab6e76a5 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 渲染与不受信任内容策略。限高卡片保持导航与提交动作固定,问题正文、超长详情和选项共享内部滚动区。每段选项说明只占一行并在溢出时显示省略号;悬停、键盘聚焦或点击信息按钮会打开可选择文本的完整说明。「跳过」会保留其他草稿,并为该项发出既有的空 `{ 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 5c6d8c013b..996272b543 100644 --- a/packages/client/ui-user-questions/src/client/QuestionComposer.module.css +++ b/packages/client/ui-user-questions/src/client/QuestionComposer.module.css @@ -363,8 +363,11 @@ } .descriptionPreview { + display: block; font-size: 13px; line-height: 20px; + overflow-wrap: anywhere; + white-space: pre-wrap; } /* Read-only note standing in for a question the user skipped: it takes the diff --git a/packages/client/ui-user-questions/src/client/QuestionComposer.tsx b/packages/client/ui-user-questions/src/client/QuestionComposer.tsx index d447f96840..c813d9b17b 100644 --- a/packages/client/ui-user-questions/src/client/QuestionComposer.tsx +++ b/packages/client/ui-user-questions/src/client/QuestionComposer.tsx @@ -556,7 +556,7 @@ function QuestionFlow({ pending, t, useStore, useQuestionCard, actions }: Questi {hasDescription && ( Date: Thu, 1 Oct 2026 23:39:42 +0800 Subject: [PATCH 04/21] test(web): verify question callout in dark mode --- apps/web/tests/question-composer.e2e.ts | 38 +++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/apps/web/tests/question-composer.e2e.ts b/apps/web/tests/question-composer.e2e.ts index d0c71ab3ab..1bd0552f07 100644 --- a/apps/web/tests/question-composer.e2e.ts +++ b/apps/web/tests/question-composer.e2e.ts @@ -701,6 +701,44 @@ describe('web e2e: resident question composer round trip', () => { card.scrollTop = card.scrollHeight return card.scrollTop + card.clientHeight >= card.scrollHeight - 1 })).toBe(true) + 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 preview.evaluate((card) => { + const surface = card.querySelector('[class*="calloutSurface"]') + const body = card.querySelector('[data-hover-card-scroll]') + const caret = card.querySelector('[data-hover-card-caret]') + if (surface === null || body === null || caret === null) return null + return { + surface: getComputedStyle(surface).backgroundColor, + text: getComputedStyle(body).color, + caret: getComputedStyle(caret).backgroundColor, + } + }) + expect(colors).not.toBeNull() + expect(colors?.surface).toBe(colors?.caret) + if (colors === null) throw new Error('Callout colors are 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(4.5) + 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 page.keyboard.press('Escape') await composer.getByRole('checkbox', { name: 'Explicit install' }).click() From 935a891a45ad1770ac239f84acc38d9fdc9302d6 Mon Sep 17 00:00:00 2001 From: yudshj Date: Fri, 2 Oct 2026 00:35:59 +0800 Subject: [PATCH 05/21] fix(ui-user-questions): preserve inline short option descriptions --- apps/web/tests/question-composer.e2e.ts | 77 ++++++++++++++++++- .../client/ui-user-questions/README.i18n.yaml | 4 +- packages/client/ui-user-questions/README.md | 2 +- .../client/ui-user-questions/README.zh.md | 2 +- .../src/client/QuestionComposer.module.css | 12 ++- .../src/client/QuestionComposer.tsx | 76 ++++++++++++++++-- .../question-composer-styles.client.spec.ts | 8 ++ .../question-composer/composed.expected.md | 2 - .../late-answered.expected.md | 2 - .../web/question-composer/ui.expected.md | 2 - 10 files changed, 163 insertions(+), 24 deletions(-) diff --git a/apps/web/tests/question-composer.e2e.ts b/apps/web/tests/question-composer.e2e.ts index 1bd0552f07..3df60287d9 100644 --- a/apps/web/tests/question-composer.e2e.ts +++ b/apps/web/tests/question-composer.e2e.ts @@ -348,8 +348,8 @@ describe('web e2e: resident question composer round trip', () => { // 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: 640, height }) @@ -385,9 +385,10 @@ describe('web e2e: resident question composer round trip', () => { expect(squeeze.spill).toBeLessThan(0.6) } expect(scrollStates).toContain(true) - await page.setViewportSize(original) + await page.setViewportSize(originalViewport) } + await page.setViewportSize({ width: 640, height: 520 }) const blue = composer.getByRole('checkbox', { name: 'Blue' }) const blueDetail = 'A cool recessive hue that reads as calm and trustworthy in long reading sessions and dense dashboards.' const blueInfo = composer.getByRole('button', { name: 'Show details for “Blue”' }) @@ -400,6 +401,7 @@ describe('web e2e: resident question composer round trip', () => { await expect.poll(() => page.getByText(blueDetail, { exact: true }).count()).toBe(2) await composer.getByRole('heading', { name: 'Which color do you prefer?' }).click() await expect.poll(() => page.getByText(blueDetail, { exact: true }).count()).toBe(1) + 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 @@ -551,6 +553,68 @@ 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, + 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((heading) => { + const style = getComputedStyle(heading) + return heading.getBoundingClientRect().height / parseFloat(style.lineHeight) + })).toBe(1) + 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]') + if (label === null || description === null) return null + const style = getComputedStyle(description) + return { + sameLine: Math.abs(label.getBoundingClientRect().top - description.getBoundingClientRect().top) < 1, + fontSize: style.fontSize, + lineHeight: style.lineHeight, + hasDetails: row.querySelector('[data-option-details]') !== null, + } + })) + expect(rows).toHaveLength(4) + expect(rows).toEqual(Array.from({ length: 4 }, () => ({ + sameLine: true, fontSize: '14px', lineHeight: '24px', hasDetails: false, + }))) + 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 @@ -573,8 +637,8 @@ describe('web e2e: resident question composer round trip', () => { multiSelect: true, options: [ { label: 'Explicit install', description }, - { label: 'Keep the default edge', description: 'Retain the current dependency and exempt it from the release graph invariant.' }, - { label: 'Move the package', description: 'Rename the package outside the experimental namespace without changing how profiles acquire it.' }, + { 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.' }, ], }], }) @@ -614,6 +678,11 @@ describe('web e2e: resident question composer round trip', () => { expect(metrics?.footerFits).toBe(true) expect(metrics?.titleInScrollport).toBe(true) scrollStates.push(metrics?.scrolls ?? false) + expect(await composer.getByRole('button', { name: 'Show details for “Keep the default edge”' }).count()).toBe(0) + if (viewport.width === 1440 || viewport.width === 427) { + expect(await composer.getByRole('button', { name: 'Show details for “Move the package”' }).count()) + .toBe(viewport.width === 427 ? 1 : 0) + } const questionLines = await composer.getByRole('heading', { name: question }).evaluate((title) => { const style = getComputedStyle(title) return title.getBoundingClientRect().height / parseFloat(style.lineHeight) diff --git a/packages/client/ui-user-questions/README.i18n.yaml b/packages/client/ui-user-questions/README.i18n.yaml index 5d9e49a032..9ba61f789c 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: 579cf44ad03380ee - zh: 5fe535a78d8ed0b8 + en: 1087967ef7402ed0 + zh: e7edbbb967b98af4 /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 f4b7b4abbc..eb3bf89735 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. Each option description occupies one ellipsized line; its information action 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. 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; only descriptions truncated there show an information 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 73ab6e76a5..d60ebfc784 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 996272b543..994b8d3102 100644 --- a/packages/client/ui-user-questions/src/client/QuestionComposer.module.css +++ b/packages/client/ui-user-questions/src/client/QuestionComposer.module.css @@ -188,6 +188,7 @@ } .optionRow { + --description-action-space: 30px; position: relative; width: 100%; min-height: 40px; @@ -215,7 +216,7 @@ } .optionWithDescription { - padding-right: 42px; + padding-right: calc(12px + var(--description-action-space)); } .optionSelected { @@ -334,6 +335,13 @@ white-space: nowrap; } +.descriptionInline { + color: var(--dsw-alias-label-tertiary); + font-size: 14px; + line-height: 24px; + font-weight: 400; +} + .descriptionAction { position: absolute; right: 12px; @@ -537,7 +545,7 @@ } .optionWithDescription { - padding-right: 36px; + padding-right: calc(6px + var(--description-action-space)); } .descriptionAction { diff --git a/packages/client/ui-user-questions/src/client/QuestionComposer.tsx b/packages/client/ui-user-questions/src/client/QuestionComposer.tsx index c813d9b17b..baa439b40e 100644 --- a/packages/client/ui-user-questions/src/client/QuestionComposer.tsx +++ b/packages/client/ui-user-questions/src/client/QuestionComposer.tsx @@ -1,5 +1,5 @@ import { - useEffect, useId, useMemo, useRef, useState, + useEffect, useId, useLayoutEffect, useMemo, useRef, useState, type ChangeEvent, type FocusEvent, type KeyboardEvent, } from 'react' import clsx from 'clsx' @@ -242,6 +242,60 @@ 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 [descriptionModes, setDescriptionModes] = useState | null>(null) + + useLayoutEffect(() => { + const surface = answerSurface.current + if (surface === null || minimized) return + const rows = [...surface.querySelectorAll('[data-option-row]')] + const measure = (): void => { + const next = new Map() + for (const row of rows) { + const description = row.querySelector('[data-option-description]') + if (description === null) continue + const optionIndex = Number(row.dataset.optionIndex) + const copy = row.querySelector('[data-option-copy]') + const line = row.querySelector('[data-option-line]') + if (copy === null || line === null || copy.clientWidth === 0) { + next.set(optionIndex, 'truncated') + continue + } + 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) + } + measure() + const observer = typeof ResizeObserver === 'undefined' ? null : new ResizeObserver(measure) + for (const row of rows) { + observer?.observe(row) + const copy = row.querySelector('[data-option-copy]') + if (copy !== null) observer?.observe(copy) + } + const fonts = 'fonts' in document ? document.fonts : null + fonts?.addEventListener('loadingdone', measure) + return () => { + observer?.disconnect() + fonts?.removeEventListener('loadingdone', measure) + } + }, [index, minimized, question.options]) const replaceProgress = (nextIndex: number, nextDrafts: QuestionDraftAnswer[]): void => { actions.replace(pending.key, { @@ -509,20 +563,23 @@ 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 descriptionId = `${descriptionIdPrefix}-question-${String(index)}-option-${String(optionIndex)}-description` return (
- {hasDescription && ( - + {showDetails && ( + { + 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 ellipsized line', () => { expect(declarations('.description')).toEqual(expect.arrayContaining([ 'display: block', diff --git a/snapshots/web/question-composer/composed.expected.md b/snapshots/web/question-composer/composed.expected.md index e5a7b73860..830627029d 100644 --- a/snapshots/web/question-composer/composed.expected.md +++ b/snapshots/web/question-composer/composed.expected.md @@ -5,9 +5,7 @@ - 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. - - button "Show details for “Blue”" - checkbox "Green": Green A restful mid-spectrum hue with the highest perceived brightness, easiest on the eye over long sessions. - - button "Show details for “Green”" - textbox "Type your answer": Include accessibility notes - button "Previous question" [disabled] - text: 1 / 1 diff --git a/snapshots/web/question-composer/late-answered.expected.md b/snapshots/web/question-composer/late-answered.expected.md index 49ac47eeb4..91f1ca73c2 100644 --- a/snapshots/web/question-composer/late-answered.expected.md +++ b/snapshots/web/question-composer/late-answered.expected.md @@ -35,9 +35,7 @@ - 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. - - button "Show details for “Blue”" - checkbox "Green" [checked] [disabled]: Green A restful mid-spectrum hue with the highest perceived brightness, easiest on the eye over long sessions. - - button "Show details for “Green”" - textbox "Type your answer" [disabled]: Answered after the timeout - button "Previous question" [disabled] - text: 1 / 1 diff --git a/snapshots/web/question-composer/ui.expected.md b/snapshots/web/question-composer/ui.expected.md index 3188d73c5b..99c6c0e4bb 100644 --- a/snapshots/web/question-composer/ui.expected.md +++ b/snapshots/web/question-composer/ui.expected.md @@ -5,9 +5,7 @@ - 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. - - button "Show details for “Blue”" - checkbox "Green": Green A restful mid-spectrum hue with the highest perceived brightness, easiest on the eye over long sessions. - - button "Show details for “Green”" - textbox "Type your answer" - button "Previous question" [disabled] - text: 1 / 1 From 9d44ecefb74430651567e677a822eabac492cd01 Mon Sep 17 00:00:00 2001 From: yudshj Date: Fri, 2 Oct 2026 01:12:36 +0800 Subject: [PATCH 06/21] style(ui-user-questions): fade truncated option details --- apps/web/tests/question-composer.e2e.ts | 23 ++++++++++++------- .../client/ui-user-questions/README.i18n.yaml | 4 ++-- packages/client/ui-user-questions/README.md | 2 +- .../client/ui-user-questions/README.zh.md | 2 +- .../src/client/QuestionComposer.module.css | 15 +++++++++++- .../src/client/QuestionComposer.tsx | 10 +++++--- .../question-composer-styles.client.spec.ts | 7 ++++-- 7 files changed, 45 insertions(+), 18 deletions(-) diff --git a/apps/web/tests/question-composer.e2e.ts b/apps/web/tests/question-composer.e2e.ts index 3df60287d9..f16855916c 100644 --- a/apps/web/tests/question-composer.e2e.ts +++ b/apps/web/tests/question-composer.e2e.ts @@ -68,9 +68,8 @@ async function capMetrics(field: Locator): Promise<{ textLines: number; scrolls: }) } -// The options carry long descriptions on purpose: the squeeze assertion below -// verifies that each description remains a single ellipsized line while the -// complete text stays reachable through its information action. +// The options carry descriptions on purpose: the squeeze assertion below +// verifies that each description remains on one line 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 { @@ -366,7 +365,6 @@ describe('web e2e: resident question composer round trip', () => { return { rows: rows.length, spill: Math.max(...spill), - ellipsized: descriptions.filter(item => item.scrollWidth > item.clientWidth).length, descriptionLines: Math.max(...descriptions.map((item) => { const style = getComputedStyle(item) return item.clientHeight / parseFloat(style.lineHeight) @@ -376,7 +374,6 @@ describe('web e2e: resident question composer round trip', () => { } }) expect(squeeze.rows).toBeGreaterThan(0) - expect(squeeze.ellipsized).toBeGreaterThan(0) expect(squeeze.descriptionLines).toBeLessThan(1.1) scrollStates.push(squeeze.scrolls) expect(squeeze.titleInScrollport).toBe(true) @@ -595,11 +592,12 @@ describe('web e2e: resident question composer round trip', () => { fontSize: style.fontSize, lineHeight: style.lineHeight, hasDetails: row.querySelector('[data-option-details]') !== null, + maskImage: style.maskImage, } })) expect(rows).toHaveLength(4) expect(rows).toEqual(Array.from({ length: 4 }, () => ({ - sameLine: true, fontSize: '14px', lineHeight: '24px', hasDetails: false, + sameLine: true, fontSize: '14px', lineHeight: '24px', hasDetails: false, maskImage: 'none', }))) await composer.getByRole('radio', { name: '中(medium)' }).click() await composer.getByRole('button', { name: 'Submit', exact: true }).click() @@ -719,10 +717,19 @@ describe('web e2e: resident question composer round trip', () => { expect(await page.locator(`[id=${JSON.stringify(descriptionId)}]`).evaluate((item) => { const style = getComputedStyle(item) return { - ellipsized: item.scrollWidth > item.clientWidth, lines: item.clientHeight / parseFloat(style.lineHeight), + textOverflow: style.textOverflow, + fades: style.maskImage !== 'none', } - })).toEqual({ ellipsized: true, lines: 1 }) + })).toEqual({ lines: 1, textOverflow: 'clip', fades: true }) + expect(await info.evaluate((button) => { + const icon = button.querySelector('svg') + const ring = icon?.parentElement + return { + dots: icon?.querySelectorAll('path').length, + ring: ring instanceof HTMLElement ? getComputedStyle(ring).borderStyle : undefined, + } + })).toEqual({ dots: 3, ring: 'solid' }) const previewBox = await preview.boundingBox() const constrainedViewport = page.viewportSize() expect(constrainedViewport).not.toBeNull() diff --git a/packages/client/ui-user-questions/README.i18n.yaml b/packages/client/ui-user-questions/README.i18n.yaml index 9ba61f789c..89fe1a97f6 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: 1087967ef7402ed0 - zh: e7edbbb967b98af4 + en: 9a8f58ca1fcd87db + zh: be2587dc4bd54aa6 /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 eb3bf89735..8f8fa0e12c 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; only descriptions truncated there show an information 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. 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 d60ebfc784..0c0ed83103 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 994b8d3102..9d11ea12da 100644 --- a/packages/client/ui-user-questions/src/client/QuestionComposer.module.css +++ b/packages/client/ui-user-questions/src/client/QuestionComposer.module.css @@ -331,10 +331,14 @@ font-size: 13px; line-height: 20px; font-weight: 400; - text-overflow: ellipsis; + text-overflow: clip; white-space: nowrap; } +.descriptionTruncated { + mask-image: linear-gradient(to right, #000 calc(100% - 28px), transparent); +} + .descriptionInline { color: var(--dsw-alias-label-tertiary); font-size: 14px; @@ -370,6 +374,15 @@ color: var(--dsw-alias-label-primary); } +.descriptionMoreIcon { + display: grid; + place-items: center; + width: 16px; + height: 16px; + border: 1px solid currentColor; + border-radius: 50%; +} + .descriptionPreview { display: block; font-size: 13px; diff --git a/packages/client/ui-user-questions/src/client/QuestionComposer.tsx b/packages/client/ui-user-questions/src/client/QuestionComposer.tsx index baa439b40e..7fbe2e74d6 100644 --- a/packages/client/ui-user-questions/src/client/QuestionComposer.tsx +++ b/packages/client/ui-user-questions/src/client/QuestionComposer.tsx @@ -6,7 +6,7 @@ import clsx from 'clsx' import { Button, IconCheckOutlineRegular, IconChevronDownOutlineRegular, IconChevronLeftOutlineRegular, IconChevronRightOutlineRegular, IconChevronUpOutlineRegular, IconCloseOutlineRegular, - HoverCard, IconEditOutlineRegular, IconInfoOutlineRegular, MarkdownText, + HoverCard, IconEditOutlineRegular, IconEllipsisOutlineRegular, MarkdownText, } 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' @@ -609,7 +609,11 @@ function QuestionFlow({ pending, t, useStore, useQuestionCard, actions }: Questi )} {hasDescription && descriptionMode !== 'inline' && ( - {option.description} + {option.description} )} @@ -627,7 +631,7 @@ function QuestionFlow({ pending, t, useStore, useQuestionCard, actions }: Questi aria-label={t('option.details', { option: display.label })} aria-describedby={descriptionId} > - + )} content={} diff --git a/packages/client/ui-user-questions/tests/question-composer-styles.client.spec.ts b/packages/client/ui-user-questions/tests/question-composer-styles.client.spec.ts index 13c942868b..d55618b7e5 100644 --- a/packages/client/ui-user-questions/tests/question-composer-styles.client.spec.ts +++ b/packages/client/ui-user-questions/tests/question-composer-styles.client.spec.ts @@ -23,16 +23,19 @@ describe('QuestionComposer compact long-copy layout', () => { ])) }) - it('keeps the option description to one ellipsized line', () => { + it('keeps the option description to one clipped line and fades only truncated text', () => { expect(declarations('.description')).toEqual(expect.arrayContaining([ 'display: block', 'min-width: 0', 'overflow: hidden', 'font-size: 13px', 'line-height: 20px', - 'text-overflow: ellipsis', + 'text-overflow: clip', 'white-space: nowrap', ])) + expect(declarations('.descriptionTruncated')).toContain( + 'mask-image: linear-gradient(to right, #000 calc(100% - 28px), transparent)', + ) }) it('keeps the question and choices in the card scrollport', () => { From fbc2550f52b5799bed9c4d39cb098fbca2efd20a Mon Sep 17 00:00:00 2001 From: yudshj Date: Fri, 9 Oct 2026 13:45:34 +0800 Subject: [PATCH 07/21] fix(ui-user-questions): preview long questions in three lines --- apps/web/tests/question-composer.e2e.ts | 35 ++++++++++++++++- .../client/ui-user-questions/README.i18n.yaml | 4 +- packages/client/ui-user-questions/README.md | 2 +- .../client/ui-user-questions/README.zh.md | 2 +- .../src/client/QuestionComposer.module.css | 28 +++++++++++++ .../src/client/QuestionComposer.tsx | 39 +++++++++++++++++-- .../ui-user-questions/src/client/locales.ts | 2 + .../user-questions-composer.client.spec.tsx | 27 +++++++++++++ 8 files changed, 130 insertions(+), 9 deletions(-) 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([{ From 50a9e31c2c883c076798571a8201d4b1b218fa02 Mon Sep 17 00:00:00 2001 From: yudshj Date: Fri, 9 Oct 2026 14:59:34 +0800 Subject: [PATCH 08/21] fix(client): preserve question focus and dismiss hover cards --- apps/web/tests/question-composer.e2e.ts | 42 ++++++++++++---- packages/client/ui-primitives/README.md | 2 +- .../client/ui-primitives/src/HoverCard.tsx | 40 ++++++++++----- .../tests/hover-card.client.spec.tsx | 49 ++++++++++++++++--- .../client/ui-user-questions/README.i18n.yaml | 4 +- packages/client/ui-user-questions/README.md | 2 +- .../client/ui-user-questions/README.zh.md | 2 +- .../src/client/QuestionComposer.module.css | 4 +- .../src/client/QuestionComposer.tsx | 8 ++- .../question-composer-styles.client.spec.ts | 1 + .../user-questions-composer.client.spec.tsx | 24 ++++++++- .../web/question-composer/ui.expected.md | 1 + 12 files changed, 142 insertions(+), 37 deletions(-) diff --git a/apps/web/tests/question-composer.e2e.ts b/apps/web/tests/question-composer.e2e.ts index 96e88fb512..db6ae2e128 100644 --- a/apps/web/tests/question-composer.e2e.ts +++ b/apps/web/tests/question-composer.e2e.ts @@ -337,7 +337,13 @@ 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) } @@ -640,7 +646,7 @@ describe('web e2e: resident question composer round trip', () => { { 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 })) @@ -664,7 +670,7 @@ describe('web e2e: resident question composer round trip', () => { const scroll = card.querySelector('[data-question-scroll]') const title = card.querySelector('h2') const submit = [...card.querySelectorAll('button')] - .find(button => button.textContent === 'Submit') + .find(button => button.textContent === 'Next') if (scroll === null || title === null || submit === undefined) return null const submitBox = submit.getBoundingClientRect() return { @@ -720,6 +726,15 @@ describe('web e2e: resident question composer round trip', () => { }) expect(previewGeometry.fits).toBe(true) expect(previewGeometry.scrolls).toBe(true) + const questionScroll = questionPreview.locator('[data-hover-card-scroll]') + await questionInfo.focus() + await page.keyboard.press('End') + expect(await questionScroll.evaluate(node => node.scrollTop + node.clientHeight >= node.scrollHeight - 1)).toBe(true) + await page.keyboard.press('Home') + expect(await questionScroll.evaluate(node => node.scrollTop)).toBe(0) + await page.keyboard.press('PageDown') + expect(await questionScroll.evaluate(node => node.scrollTop)).toBeGreaterThan(0) + await composer.getByRole('heading', { name: question }).click() await expect.poll(() => questionPreview.count()).toBe(0) @@ -727,7 +742,7 @@ describe('web e2e: resident question composer round trip', () => { 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: 'Submit', exact: true }).isVisible()).toBe(true) + expect(await composer.getByRole('button', { name: 'Next', exact: true }).isVisible()).toBe(true) const info = composer.getByRole('button', { name: 'Show details for “Explicit install”' }) const descriptionId = await info.getAttribute('aria-describedby') @@ -806,10 +821,11 @@ describe('web e2e: resident question composer round trip', () => { selection?.removeAllRanges() return selectedLength })).toBe(description.length) - expect(await scrollport.evaluate((card) => { - card.scrollTop = card.scrollHeight - return card.scrollTop + card.clientHeight >= card.scrollHeight - 1 - })).toBe(true) + await info.focus() + await page.keyboard.press('Home') + expect(await scrollport.evaluate(card => card.scrollTop)).toBe(0) + await page.keyboard.press('End') + expect(await scrollport.evaluate(card => card.scrollTop + card.clientHeight >= card.scrollHeight - 1)).toBe(true) const palette: string[] = [] const originalDarkTheme = await page.locator('body').getAttribute('data-ds-dark-theme') try { @@ -851,7 +867,13 @@ describe('web e2e: resident question composer round trip', () => { await page.keyboard.press('Escape') await composer.getByRole('checkbox', { name: 'Explicit install' }).click() - await composer.getByRole('textbox').fill('Keep the migration steps explicit.') + 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: { @@ -859,7 +881,7 @@ describe('web e2e: resident question composer round trip', () => { 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) diff --git a/packages/client/ui-primitives/README.md b/packages/client/ui-primitives/README.md index ab03603969..eea4ee840d 100644 --- a/packages/client/ui-primitives/README.md +++ b/packages/client/ui-primitives/README.md @@ -92,7 +92,7 @@ The catalog above lists what each export is for; this section covers the behavio `Tooltip` reads the anchor on hover or keyboard focus and fits its bubble from `ResizeObserver` border-box sizes. It stays hidden until its first fit, prefers the requested side, then tries the opposite and perpendicular sides, sliding along the anchor without covering it when a side fits. Horizontal placements keep a 10px gap; vertical placements use `gap` (8px by default). Width and height stay within a 12px viewport margin; text wraps, and content taller than the viewport scrolls when pinned. If no side fits, a stable viewport-bounded fallback can overlap the anchor. Viewport resize refreshes the anchor coordinates; label-size changes reuse them. Fitting does not synchronously measure the bubble or trigger a React render. Touch presses dismiss unpinned hints and do not trigger hover hints, including compatibility mouse events. Mouse entry opens hints only while `(any-hover: hover)` reports an available hover-capable input, excluding iOS mouse entry caused by a tapped icon changing. Keyboard focus and explicit click-to-open explanations remain available on touch-only devices. Opt-in `openOnClick` lets an informational button pin the bubble by click or tap and associates it with the anchor description. Another click or tap, Escape, Tab, or an outside pointerdown closes it. Ordinary action buttons execute their action directly without a touch tooltip. -Opt-in `HoverCard.openOnClick` requires one focusable React element that forwards ARIA props. Click and touch activation pin the card; pointer leave and blur keep it open so its text remains selectable; another anchor activation, Escape, Tab, or a pointerdown outside both anchor and card closes it. While open, the card joins the anchor's accessible description by default. Set `describeAnchor={false}` when the caller provides a stable `aria-describedby` relationship; the card then leaves that relationship unchanged. +Opt-in `HoverCard.openOnClick` requires one focusable React element that forwards ARIA props. Click and touch activation pin the card; pointer leave and blur keep a pinned card open so its text remains selectable; another anchor activation, Escape, Tab, or a pointerdown outside both anchor and card closes it. While open, the card joins the anchor's accessible description by default. Set `describeAnchor={false}` when the caller provides a stable `aria-describedby` relationship; the card then leaves that relationship unchanged. diff --git a/packages/client/ui-primitives/src/HoverCard.tsx b/packages/client/ui-primitives/src/HoverCard.tsx index 6f6d7288e7..97305dfeea 100644 --- a/packages/client/ui-primitives/src/HoverCard.tsx +++ b/packages/client/ui-primitives/src/HoverCard.tsx @@ -64,6 +64,7 @@ type HoverCardProps = { * Group replacement removes the previous card immediately, including a fading preview. * @param props.openOnClick - clicking or touching the anchor pins the card open. Another activation, * Escape, Tab, or an outside pointerdown dismisses it; pointer leave and blur do not dismiss a pinned card. + * For callouts, focused anchors scroll the card with arrow keys, PageUp/PageDown, Home, and End. * @param props.describeAnchor - with openOnClick, append the open card to the anchor's accessible * description (default true). Set false when the caller supplies a stable description relationship. * @param props.copyText - optional primary value copied by activation and @@ -88,6 +89,7 @@ export function HoverCard(props: HoverCardProps) { const copyTimerRef = useRef | null>(null) const copyHeightRef = useRef(null) const copyEpochRef = useRef(0) + const keyboardFocus = useRef(false) const copyingRef = useRef(false) const mountedRef = useRef(true) const [phase, setPhase] = useState<'closed' | 'open' | 'closing'>('closed') @@ -118,6 +120,7 @@ export function HoverCard(props: HoverCardProps) { }, []) const close = useCallback(() => { + keyboardFocus.current = false copyEpochRef.current += 1 clearCopied() setPinned(false) @@ -206,10 +209,6 @@ export function HoverCard(props: HoverCardProps) { return () => { document.removeEventListener('keydown', dismiss, true) } }, [open, openOnClick, cancelClose, close]) - useEffect(() => { - if (pinned && !openOnClick) close() - }, [close, openOnClick, pinned]) - // Fixed-position from the anchor rect before paint; track the anchor while // open (capture-phase scroll catches nested panes), as in Menu portal mode. useLayoutEffect(() => { @@ -321,6 +320,7 @@ export function HoverCard(props: HoverCardProps) { const dismissFromAnchor = (event: SyntheticEvent): void => { // Portal content is a React child, but its clicks and text selection do not activate the anchor. if (cardRef.current?.contains(event.target as Node)) return + keyboardFocus.current = false clearTimer() cancelClose() if (openOnClick && !disabled && !pinned) { @@ -374,9 +374,7 @@ export function HoverCard(props: HoverCardProps) { setPhase('open') }} onPointerLeave={() => { - if (!pinned - && rootRef.current?.contains(document.activeElement) !== true - && cardRef.current?.contains(document.activeElement) !== true) armClose() + if (!pinned && !(anchored && keyboardFocus.current)) armClose() }} > {callout @@ -397,11 +395,33 @@ export function HoverCard(props: HoverCardProps) { ref={rootRef} className={clsx(css.root, anchored && css.inline)} onFocus={anchored ? (event) => { - if (!disabled && event.target.matches(':focus-visible')) { cancelClose(); setPhase('open') } + if (!disabled && event.target.matches(':focus-visible')) { + keyboardFocus.current = true + cancelClose() + setPhase('open') + } } : undefined} onBlur={anchored ? (event) => { + keyboardFocus.current = false if (!pinned && !event.currentTarget.contains(event.relatedTarget)) { clearTimer(); cancelClose(); close() } } : undefined} + onKeyDown={callout && open ? (event) => { + const scroll = scrollRef.current + if (scroll === null || !rootRef.current?.contains(event.target as Node)) return + let top: number + switch (event.key) { + case 'ArrowDown': top = scroll.scrollTop + scroll.clientHeight / 8; break + case 'ArrowUp': top = scroll.scrollTop - scroll.clientHeight / 8; break + case 'PageDown': top = scroll.scrollTop + scroll.clientHeight; break + case 'PageUp': top = scroll.scrollTop - scroll.clientHeight; break + case 'Home': top = 0; break + case 'End': top = scroll.scrollHeight; break + default: return + } + event.preventDefault() + event.stopPropagation() + scroll.scrollTop = top + } : undefined} onPointerEnter={(event) => { if (disabled || event.pointerType === 'touch') return if (event.pointerType === 'mouse' && !window.matchMedia('(any-hover: hover)').matches) return @@ -418,9 +438,7 @@ export function HoverCard(props: HoverCardProps) { // Leaving a closed card schedules a no-op close; only arm while // open, matching Menu's shape. Keyboard focus remains an independent // open trigger after the pointer leaves. - if (open && !pinned - && rootRef.current?.contains(document.activeElement) !== true - && cardRef.current?.contains(document.activeElement) !== true) armClose() + if (open && !pinned && !(anchored && keyboardFocus.current)) armClose() }} onPointerDownCapture={openOnClick ? undefined : dismissFromAnchor} onClickCapture={dismissFromAnchor} diff --git a/packages/client/ui-primitives/tests/hover-card.client.spec.tsx b/packages/client/ui-primitives/tests/hover-card.client.spec.tsx index fa51eef33a..e1730702db 100644 --- a/packages/client/ui-primitives/tests/hover-card.client.spec.tsx +++ b/packages/client/ui-primitives/tests/hover-card.client.spec.tsx @@ -331,11 +331,10 @@ describe('HoverCard', () => { expect(screen.queryByText('card body')).toBeNull() }) - it('drops a click pin when click opening is removed or the card is disabled', () => { + it('drops a click pin when the card is disabled', () => { const pinnedCard = (disabled = false) => ( details} content={
card body
} /> ) - const hoverCard = details} content={
card body
} /> const view = render(pinnedCard()) const anchor = screen.getByRole('button', { name: 'details' }) fireEvent.click(anchor) @@ -343,11 +342,6 @@ describe('HoverCard', () => { view.rerender(pinnedCard(true)) expect(screen.queryByText('card body')).toBeNull() - view.rerender(pinnedCard()) - fireEvent.click(anchor) - expect(screen.getByText('card body')).toBeTruthy() - view.rerender(hoverCard) - expect(screen.queryByText('card body')).toBeNull() }) it('removes click-pin dismissal listeners on unmount', () => { @@ -885,3 +879,44 @@ it('flips inline media above a bottom link and constrains it without covering th expect(card.style.top).toBe('42px') expect(Number.parseFloat(card.style.maxHeight)).toBe(window.innerHeight - 50) }) + +it('closes a compact copyable card after its mouse-focused card loses the pointer', () => { + const { wrapper } = mount({ copyText: 'value' }) + fireEvent.pointerEnter(wrapper) + act(() => { vi.advanceTimersByTime(500) }) + const card = screen.getByRole('button', { name: 'Copy: value' }) + act(() => { card.focus() }) + expect(document.activeElement).toBe(card) + fireEvent.pointerLeave(card) + act(() => { vi.advanceTimersByTime(POINTER_GRACE_MS) }) + expect(screen.queryByText('card body')).toBeNull() +}) + +it('closes a callout hover after click toggling leaves mouse focus on the anchor', () => { + render(details} content="body" />) + const anchor = screen.getByRole('button', { name: 'details' }) + act(() => { anchor.focus() }) + fireEvent.click(anchor) + fireEvent.click(anchor) + fireEvent.pointerEnter(anchor.parentElement as HTMLElement) + act(() => { vi.advanceTimersByTime(500) }) + expect(screen.getByText('body')).toBeTruthy() + fireEvent.pointerLeave(anchor.parentElement as HTMLElement) + act(() => { vi.advanceTimersByTime(POINTER_GRACE_MS) }) + expect(screen.queryByText('body')).toBeNull() +}) + +it('scrolls an open callout with the focused anchor keyboard controls', () => { + render(details} content="body" />) + const anchor = screen.getByRole('button', { name: 'details' }) + fireEvent.click(anchor) + const scroll = document.querySelector('[data-hover-card-scroll]') as HTMLElement + Object.defineProperty(scroll, 'clientHeight', { configurable: true, value: 160 }) + Object.defineProperty(scroll, 'scrollHeight', { configurable: true, value: 640 }) + for (const [key, top] of [['ArrowDown', 20], ['ArrowUp', 0], ['PageDown', 160], ['PageUp', 0], ['End', 640], ['Home', 0]] as const) { + expect(fireEvent.keyDown(anchor, { key })).toBe(false) + expect(scroll.scrollTop).toBe(top) + } + fireEvent.keyDown(anchor, { key: 'Tab' }) + expect(screen.queryByText('body')).toBeNull() +}) diff --git a/packages/client/ui-user-questions/README.i18n.yaml b/packages/client/ui-user-questions/README.i18n.yaml index 8608006f15..b5573fba8f 100644 --- a/packages/client/ui-user-questions/README.i18n.yaml +++ b/packages/client/ui-user-questions/README.i18n.yaml @@ -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: c2307453aedca0d9 + zh: 683e57c6ea0b947e /deepseek-ai-dsh-client-ui-user-questions/understand-the-implementation: en: 77e1b2585acdf67b zh: d433ec185c6b8f57 diff --git a/packages/client/ui-user-questions/README.md b/packages/client/ui-user-questions/README.md index 83e50f9533..5e7738a74a 100644 --- a/packages/client/ui-user-questions/README.md +++ b/packages/client/ui-user-questions/README.md @@ -43,7 +43,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 closes its disclosures. 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. diff --git a/packages/client/ui-user-questions/README.zh.md b/packages/client/ui-user-questions/README.zh.md index f92fe5cf37..27d34e7059 100644 --- a/packages/client/ui-user-questions/README.zh.md +++ b/packages/client/ui-user-questions/README.zh.md @@ -43,7 +43,7 @@ Web 客户端在聊天输入框旁显示 agent(智能体)的提问。用户 ### 失败与恢复 -回答输入框只有在回答通道就绪且没有倒计时时才自动聚焦。接手握手期间的手动聚焦与失焦会被保留:剩余时长到达时,只有回答区域仍持有焦点,倒计时才以暂停状态开始。 +回答输入框只有在回答通道就绪且没有倒计时时才自动聚焦。在带选项的问题间按 Enter 切题时,自定义回答框保留焦点;切题会重置问题滚动位置并关闭其说明浮层。接手握手期间的手动聚焦与失焦会被保留:剩余时长到达时,只有回答区域仍持有焦点,倒计时才以暂停状态开始。 本地返回回答不会释放前台接手记录:Host 接受 waterfall 结果后才关闭该 stream。插件在这段传输期间继续拥有接手记录。显式委托会在调用下一个回答方前释放它,插件卸载则取消剩余接手记录。 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 89852a34a6..730be0fa6f 100644 --- a/packages/client/ui-user-questions/src/client/QuestionComposer.module.css +++ b/packages/client/ui-user-questions/src/client/QuestionComposer.module.css @@ -144,7 +144,7 @@ .detail { flex-shrink: 0; - margin: 0 14px 8px; + margin: 0 24px 8px; } .footerActions { @@ -445,7 +445,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; } diff --git a/packages/client/ui-user-questions/src/client/QuestionComposer.tsx b/packages/client/ui-user-questions/src/client/QuestionComposer.tsx index 0421a8f718..0e3aa666fa 100644 --- a/packages/client/ui-user-questions/src/client/QuestionComposer.tsx +++ b/packages/client/ui-user-questions/src/client/QuestionComposer.tsx @@ -245,6 +245,10 @@ function QuestionFlow({ pending, t, useStore, useQuestionCard, actions }: Questi const [questionTruncated, setQuestionTruncated] = useState(false) const [descriptionModes, setDescriptionModes] = useState | null>(null) + useLayoutEffect(() => { + if (answerSurface.current !== null) answerSurface.current.scrollTop = 0 + }, [index]) + useLayoutEffect(() => { const surface = answerSurface.current if (surface === null || minimized) return @@ -549,7 +553,6 @@ function QuestionFlow({ pending, t, useStore, useQuestionCard, actions }: Questi {!minimized && ( <>
{ choose(option.label) }} onKeyDown={(event) => { @@ -651,6 +656,7 @@ function QuestionFlow({ pending, t, useStore, useQuestionCard, actions }: Questi {showDetails && ( { 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).not.toBe(firstScroll) + expect(secondScroll).toBe(firstScroll) expect(secondScroll.scrollTop).toBe(0) expect(screen.queryByText(firstDescription)).toBeNull() expect(screen.getAllByText(secondDescription)).toHaveLength(1) @@ -1043,3 +1043,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() + 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() +}) diff --git a/snapshots/web/question-composer/ui.expected.md b/snapshots/web/question-composer/ui.expected.md index 99c6c0e4bb..4b6193da5b 100644 --- a/snapshots/web/question-composer/ui.expected.md +++ b/snapshots/web/question-composer/ui.expected.md @@ -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."}] From f3bb36263def15e21e0092c6a108edd2ffbdd271 Mon Sep 17 00:00:00 2001 From: yudshj Date: Fri, 9 Oct 2026 15:34:01 +0800 Subject: [PATCH 09/21] fix(client): complete hover-card coverage and steering golden --- packages/client/ui-primitives/tests/hover-card.client.spec.tsx | 2 ++ .../ui-user-questions/src/client/QuestionComposer.module.css | 1 + snapshots/web/steering/mid-steer.expected.md | 2 +- 3 files changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/client/ui-primitives/tests/hover-card.client.spec.tsx b/packages/client/ui-primitives/tests/hover-card.client.spec.tsx index e1730702db..a555826d23 100644 --- a/packages/client/ui-primitives/tests/hover-card.client.spec.tsx +++ b/packages/client/ui-primitives/tests/hover-card.client.spec.tsx @@ -913,6 +913,8 @@ it('scrolls an open callout with the focused anchor keyboard controls', () => { const scroll = document.querySelector('[data-hover-card-scroll]') as HTMLElement Object.defineProperty(scroll, 'clientHeight', { configurable: true, value: 160 }) Object.defineProperty(scroll, 'scrollHeight', { configurable: true, value: 640 }) + expect(fireEvent.keyDown(scroll, { key: 'PageDown' })).toBe(true) + expect(scroll.scrollTop).toBe(0) for (const [key, top] of [['ArrowDown', 20], ['ArrowUp', 0], ['PageDown', 160], ['PageUp', 0], ['End', 640], ['Home', 0]] as const) { expect(fireEvent.keyDown(anchor, { key })).toBe(false) expect(scroll.scrollTop).toBe(top) 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 730be0fa6f..80841b8c3b 100644 --- a/packages/client/ui-user-questions/src/client/QuestionComposer.module.css +++ b/packages/client/ui-user-questions/src/client/QuestionComposer.module.css @@ -409,6 +409,7 @@ height: 16px; border: 1px solid currentColor; border-radius: 50%; + corner-shape: round; } .descriptionPreview { diff --git a/snapshots/web/steering/mid-steer.expected.md b/snapshots/web/steering/mid-steer.expected.md index 1527155f0b..267dbd18d5 100644 --- a/snapshots/web/steering/mid-steer.expected.md +++ b/snapshots/web/steering/mid-steer.expected.md @@ -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 From 2b6360c1d9f4d71e6d610e9d5ee88560127e3947 Mon Sep 17 00:00:00 2001 From: yudshj Date: Tue, 20 Oct 2026 21:42:39 +0800 Subject: [PATCH 10/21] refactor(ui-user-questions): let CSS wrap option descriptions --- apps/web/tests/question-composer.e2e.ts | 4 +- .../client/ui-user-questions/README.i18n.yaml | 4 +- packages/client/ui-user-questions/README.md | 2 +- .../client/ui-user-questions/README.zh.md | 2 +- .../src/client/QuestionComposer.module.css | 27 +++---- .../src/client/QuestionComposer.tsx | 75 +++++++------------ .../question-composer-styles.client.spec.ts | 12 +-- .../user-questions-composer.client.spec.tsx | 18 ++++- 8 files changed, 58 insertions(+), 86 deletions(-) diff --git a/apps/web/tests/question-composer.e2e.ts b/apps/web/tests/question-composer.e2e.ts index db6ae2e128..c8bd4a8a33 100644 --- a/apps/web/tests/question-composer.e2e.ts +++ b/apps/web/tests/question-composer.e2e.ts @@ -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() diff --git a/packages/client/ui-user-questions/README.i18n.yaml b/packages/client/ui-user-questions/README.i18n.yaml index b5573fba8f..997ea58421 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: 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 diff --git a/packages/client/ui-user-questions/README.md b/packages/client/ui-user-questions/README.md index 5e7738a74a..c959c3f286 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. 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 diff --git a/packages/client/ui-user-questions/README.zh.md b/packages/client/ui-user-questions/README.zh.md index 27d34e7059..d4f4e8d928 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 渲染与不受信任内容策略。超过实际排版三行的问题在第三行末尾渐隐,并通过相同的圈内三点按钮显示完整文本。限高卡片保持导航与提交动作固定,问题正文、超长详情和选项共享内部滚动区。CSS 将选项说明排在标签旁或下方,两种位置均使用 13px 字号和 20px 行高。溢出检测会添加全文按钮,但不改变文本宽度。较长的说明移到标签下方,仅在下方仍被截断时让行尾文字渐隐,并显示圈内三点按钮。悬停、键盘聚焦或点击该按钮会在带指向角的气泡中打开可选择文本的完整说明,超过 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 80841b8c3b..7bda79528f 100644 --- a/packages/client/ui-user-questions/src/client/QuestionComposer.module.css +++ b/packages/client/ui-user-questions/src/client/QuestionComposer.module.css @@ -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; } diff --git a/packages/client/ui-user-questions/src/client/QuestionComposer.tsx b/packages/client/ui-user-questions/src/client/QuestionComposer.tsx index 0e3aa666fa..20fb3bdfb7 100644 --- a/packages/client/ui-user-questions/src/client/QuestionComposer.tsx +++ b/packages/client/ui-user-questions/src/client/QuestionComposer.tsx @@ -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 | null>(null) + const [overflow, setOverflow] = useState<{ + question: typeof question + title: boolean + descriptions: Set + } | 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('[data-question-title]') - const rows = [...surface.querySelectorAll('[data-option-row]')] + const descriptions = [...surface.querySelectorAll('[data-option-description]')] 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]') - if (description === null) continue - const optionIndex = Number(row.dataset.optionIndex) - const copy = row.querySelector('[data-option-copy]') - const line = row.querySelector('[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() + 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 (
- )} - content={} - /> - - )} +
{question.detail !== undefined && (
@@ -576,18 +542,18 @@ 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 showDetails = hasDescription && overflow?.question === question && overflow.descriptions.has(optionIndex) + const descriptionExpanded = expanded.has(optionIndex) const descriptionId = `${descriptionIdPrefix}-question-${String(index)}-option-${String(optionIndex)}-description` return (
- {showDetails && ( - - - - - )} - content={} - /> - + {hasDescription && ( + )}
) diff --git a/packages/client/ui-user-questions/src/client/locales.ts b/packages/client/ui-user-questions/src/client/locales.ts index e8e42ac851..4995c365e4 100644 --- a/packages/client/ui-user-questions/src/client/locales.ts +++ b/packages/client/ui-user-questions/src/client/locales.ts @@ -25,8 +25,8 @@ export const zh = { 'nav.maximize': '展开问题卡片', 'nav.cancel': '放弃整组问题', 'nav.close': '收起问题面板,可从工具调用重新打开', - 'question.details': '查看完整问题', - 'option.details': '查看“{option}”的选项说明', + 'text.expand': '全部', + 'text.collapse': '收起', 'option.recommended': '推荐', 'custom.placeholder': '输入你的答案', 'action.skip': '跳过', @@ -65,8 +65,8 @@ export const en = { 'nav.maximize': 'Expand the question card', 'nav.cancel': 'Dismiss all questions', 'nav.close': 'Close the panel — reopen it from the tool call', - 'question.details': 'Show full question', - 'option.details': 'Show details for “{option}”', + 'text.expand': 'More', + 'text.collapse': 'Show less', 'option.recommended': 'Recommended', 'custom.placeholder': 'Type your answer', 'action.skip': 'Skip', diff --git a/packages/client/ui-user-questions/tests/question-composer-styles.client.spec.ts b/packages/client/ui-user-questions/tests/question-composer-styles.client.spec.ts index e65c8eb063..4774ce8e67 100644 --- a/packages/client/ui-user-questions/tests/question-composer-styles.client.spec.ts +++ b/packages/client/ui-user-questions/tests/question-composer-styles.client.spec.ts @@ -8,29 +8,95 @@ const stylesheet = readFileSync( 'utf8', ).replace(/\/\*[\s\S]*?\*\//g, ' ') -function declarations(selector: string): string[] { - const escaped = selector.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') - const rule = new RegExp(`(?:^|\\})\\s*${escaped}\\s*\\{([^{}]*)\\}`).exec(stylesheet) - if (rule === null) throw new Error(`QuestionComposer.module.css has no \`${selector}\` rule`) - return (rule[1] ?? '').split(';').map(part => part.trim()).filter(Boolean) +const escape = (text: string): string => text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + +function split(body: string | undefined): string[] { + return (body ?? '').split(';').map(part => part.trim()).filter(Boolean) } -describe('QuestionComposer compact long-copy layout', () => { - it('keeps the option description to one clipped line and fades only truncated text', () => { +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')).toContain('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)', + '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%', - 'min-width: 0', + 'max-height: calc(2lh + 2px)', + 'margin-bottom: -2px', + 'padding-bottom: 2px', 'overflow: hidden', 'font-size: 13px', 'line-height: 20px', - 'text-overflow: clip', - 'white-space: nowrap', + 'scroll-timeline: --description-copy block', + 'animation-name: description-clipped', + 'animation-fill-mode: both', + 'animation-timeline: --description-copy', ])) - expect(declarations('.descriptionTruncated')).toContain( - 'mask-image: linear-gradient(to right, #000 calc(100% - 52px), transparent calc(100% - 24px))', - ) + expect(keyframes('description-clipped')).toEqual(expect.arrayContaining([ + 'margin-bottom: calc(-1lh - 2px)', + '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', () => { 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 808d371502..c1ab2c6f7f 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,73 +300,7 @@ 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('shows details only for overflow and removes them when the description fits after resizing', ({ onTestFinished }) => { - let availableWidth = 300 - const scrollWidth = vi.spyOn(HTMLElement.prototype, 'scrollWidth', 'get').mockReturnValue(200) - const clientWidth = vi.spyOn(HTMLElement.prototype, 'clientWidth', 'get').mockImplementation(() => availableWidth) - const disconnect = vi.fn() - let resize = (): void => {} - vi.stubGlobal('ResizeObserver', class { - constructor(callback: () => void) { resize = callback } - observe(): void {} - disconnect = disconnect - }) - onTestFinished(() => { - cleanup() - scrollWidth.mockRestore() - clientWidth.mockRestore() - vi.unstubAllGlobals() - }) - const description = 'An explanation that fits in a wide row.' - const { carrier } = wait([{ - id: 'size', question: 'Which option?', options: [{ label: 'Option', description }], - }]) - const { unmount } = render() - const text = screen.getByText(description) - expect(screen.queryByRole('button', { name: '查看“Option”的选项说明' })).toBeNull() - availableWidth = 100 - act(() => { resize() }) - expect(screen.getByRole('button', { name: '查看“Option”的选项说明' })).toBeTruthy() - availableWidth = 300 - act(() => { resize() }) - expect(screen.queryByRole('button', { name: '查看“Option”的选项说明' })).toBeNull() - expect(screen.getByText(description)).toBe(text) - unmount() - expect(disconnect).toHaveBeenCalledOnce() - }) - - 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() }) + 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?', @@ -379,29 +313,39 @@ describe('QuestionComposer', () => { }]) render() + // 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) + expect(questionToggle?.getAttribute('aria-controls')).toBe(heading.id) const option = screen.getByRole('radio', { name: 'Explicit install' }) - const details = screen.getByRole('button', { name: '查看“Explicit install”的选项说明' }) - expect(screen.getAllByRole('button', { name: /选项说明/ })).toHaveLength(1) - expect(screen.getAllByText(description)).toHaveLength(1) - const describedBy = details.getAttribute('aria-describedby') - expect(describedBy).not.toBeNull() - expect(document.getElementById(describedBy as string)?.textContent).toBe(description) - fireEvent.click(details) + 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 as HTMLElement) + expect(descriptionToggle?.getAttribute('aria-expanded')).toBe('true') + expect(descriptionToggle?.textContent).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(details.getAttribute('aria-describedby')).toBe(describedBy) - expect(screen.getAllByText(description)).toHaveLength(2) - fireEvent.pointerDown(document.body) - expect(screen.getAllByText(description)).toHaveLength(1) + expect(questionToggle?.getAttribute('aria-expanded')).toBe('false') + + fireEvent.click(questionToggle as HTMLElement) + expect(questionToggle?.getAttribute('aria-expanded')).toBe('true') + expect(heading.parentElement?.hasAttribute('data-expanded')).toBe(true) + fireEvent.click(questionToggle as HTMLElement) + fireEvent.click(descriptionToggle as HTMLElement) + 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 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.' + 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?', @@ -417,13 +361,8 @@ describe('QuestionComposer', () => { expect(screen.getByRole('region', { name: 'First question?' })).toBeTruthy() const firstScroll = screen.getByText('First question?').closest('[data-question-scroll]') as HTMLElement firstScroll.scrollTop = 80 - const firstDetails = screen.getByRole('button', { name: '查看“Continue”的选项说明' }) - fireEvent.click(firstDetails) - expect(screen.getAllByText(firstDescription)).toHaveLength(2) - const firstDescribedIds = (firstDetails.getAttribute('aria-describedby') ?? '').split(/\s+/).filter(Boolean) - expect(firstDescribedIds.length).toBeGreaterThan(0) - expect(firstDescribedIds.every(id => document.getElementById(id) !== null)).toBe(true) - expect(firstDescribedIds.map(id => document.getElementById(id)?.textContent)).toContain(firstDescription) + for (const toggle of screen.getAllByRole('button', { name: '全部' })) fireEvent.click(toggle) + expect(screen.getAllByRole('button', { name: '收起' })).toHaveLength(2) fireEvent.click(screen.getByRole('radio', { name: 'Continue' })) const secondScroll = screen.getByText('Second question?').closest('[data-question-scroll]') as HTMLElement @@ -431,14 +370,11 @@ describe('QuestionComposer', () => { expect(screen.getByRole('region', { name: 'Second question?' })).toBeTruthy() expect(secondScroll).toBe(firstScroll) expect(secondScroll.scrollTop).toBe(0) + expect(screen.queryByRole('button', { name: '收起' })).toBeNull() + expect(secondScroll.querySelector('[data-expanded]')).toBeNull() expect(screen.queryByText(firstDescription)).toBeNull() - expect(screen.getAllByText(secondDescription)).toHaveLength(1) - const secondDetails = screen.getByRole('button', { name: '查看“Continue”的选项说明' }) - const describedIds = (secondDetails.getAttribute('aria-describedby') ?? '').split(/\s+/).filter(Boolean) - expect(describedIds.length).toBeGreaterThan(0) - expect(describedIds.every(id => document.getElementById(id) !== null)).toBe(true) - expect(describedIds.map(id => document.getElementById(id)?.textContent)).toContain(secondDescription) - expect(describedIds.map(id => document.getElementById(id)?.textContent)).not.toContain(firstDescription) + const descriptionToggle = screen.getAllByRole('button', { name: '全部' })[1] + expect(document.getElementById(descriptionToggle?.getAttribute('aria-controls') ?? '')?.textContent).toBe(secondDescription) }) it('renders plan detail through the shared assistant Markdown primitive', () => { @@ -1002,11 +938,7 @@ function reviewCard(review: readonly AskUserQuestionAnswerItem[] = RECORDED) { } describe('review card', () => { - 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() }) + it('walks a settled call record with every answer surface frozen', () => { const { carrier } = reviewCard() render() @@ -1015,7 +947,8 @@ describe('review card', () => { const chosen = screen.getByRole('radio', { name: '工程落地型' }) expect(chosen.getAttribute('aria-checked')).toBe('true') expect(chosen.disabled).toBe(true) - expect(screen.getByRole('button', { name: '查看“工程落地型”的选项说明' }).disabled).toBe(false) + // Long copy stays readable on a frozen card. + expect(screen.getAllByRole('button', { name: '全部' }).map(toggle => toggle.disabled)).toEqual([false, false, false]) // Nothing is left to send, and the unused free-text field would read as // somewhere to type. expect(screen.queryByRole('button', { name: '跳过' })).toBeNull() From 82b43da3dc63a501bd6163982208656bb9c0b244 Mon Sep 17 00:00:00 2001 From: yudshj Date: Wed, 21 Oct 2026 10:14:46 +0800 Subject: [PATCH 14/21] revert(ui-primitives): drop HoverCard callout and click pinning The question composer expands long copy in place, so no caller uses the callout variant, openOnClick pinning, describeAnchor, or the preview-to-anchor pointer handling this pull request added. HoverCard, its stylesheet, tests, and README return to their merge-base state. --- .../client/ui-primitives/README.i18n.yaml | 4 +- packages/client/ui-primitives/README.md | 4 +- packages/client/ui-primitives/README.zh.md | 4 +- .../ui-primitives/src/HoverCard.module.css | 50 ---- .../client/ui-primitives/src/HoverCard.tsx | 236 ++++-------------- .../tests/hover-card.client.spec.tsx | 234 +---------------- 6 files changed, 54 insertions(+), 478 deletions(-) diff --git a/packages/client/ui-primitives/README.i18n.yaml b/packages/client/ui-primitives/README.i18n.yaml index fc9c17df52..ab799d699a 100644 --- a/packages/client/ui-primitives/README.i18n.yaml +++ b/packages/client/ui-primitives/README.i18n.yaml @@ -21,8 +21,8 @@ en: cb874a0ca53a6747 zh: 31179193dd1d6e12 /deepseek-ai-dsh-client-ui-primitives/use-this-package/controls-and-icons: - en: 3c1f0a4c28aa7240 - zh: 8dbc3c8615c58260 + en: 35403eb2a55cc77d + zh: 8f4466f7e15ae473 /deepseek-ai-dsh-client-ui-primitives/use-this-package/rendering-agent-output: en: 3a31ad8de6dd0af8 zh: 6b22881a929c33df diff --git a/packages/client/ui-primitives/README.md b/packages/client/ui-primitives/README.md index b1fafe0b64..9e054e13cc 100644 --- a/packages/client/ui-primitives/README.md +++ b/packages/client/ui-primitives/README.md @@ -88,12 +88,10 @@ Writing your own component in your own package is fine when the need is genuinel ### Controls and icons -The catalog above lists what each export is for; this section covers the behavior that props alone do not show. Product icon names omit artboard sizes and end in `Regular` for the supplied one-pixel artwork or `Medium` for the same geometry at a 1.3px stroke; the `size` prop controls rendered dimensions. Named components keep the chosen weight visible at import sites; the library owns stroke geometry instead of consumer CSS overrides. Each product, reference, link, and permission glyph intentionally keeps both weight exports even when the current product uses only one, so callers can choose emphasis without adding another API later; fill-only pairs render identically. `IconWarningOutlineRegular`/`Medium` use a circle; `IconWarningTriangleOutlineRegular`/`Medium` use a rounded triangle. `FishLogo` and `BrandWordmark` fill brand slots. `FileTypeIcon` renders the traditional 28px spreadsheet, folder, HTML, image, Markdown, generic, PDF, PPT, video, and Word glyphs, and uses the imported square technology artwork for the established 48 code and configuration categories. The import replaces artwork only: archive-only categories do not extend `CodeFileType`. `classifyFileType` applies exact filename, prefix, suffix, optional project-context, and extension rules in that order; React names win over TypeScript/JavaScript, Angular suffixes win over their base extension, and a Dart file becomes Flutter only when the supplied project files contain a `pubspec.yaml` with `flutter:`. Markdown and SVG remain traditional Markdown and image files. Spreadsheet mappings include CSV, TSV, Excel workbooks and templates, OpenDocument spreadsheets, and Numbers; KEY maps to slides, while RTF/ODT/Pages map to documents. `fileExtension` exposes the same basename and final-dot parsing for adjacent metadata labels. Traditional glyphs use a solid category-colored sheet with a white mark and translucent white corner; the generic code glyph uses angle brackets and a slash at the supplied 1.35px stroke; the generic file uses a grey sheet and darker grey corner. Callers may override the sheet color through `--dsh-file-type-icon-color`. The full-color technology artwork is the deliberate exception and retains its embedded palette. All glyphs are decorative and carry no label. `LinkIconMedium` is the leading glyph for clickable artifact links — globe, folder, code, image, document, or plain paper, and for a `url` link the mark of a well-known site named by its `href` — the developer sites a transcript usually cites (GitHub, GitLab, npm, PyPI, Stack Overflow, MDN, Wikipedia, Hacker News, YouTube, X, Bilibili, Zhihu, Juejin, CSDN) and mainstream search, video, social, shopping, and reference sites (Google, Baidu, DuckDuckGo, TikTok, Netflix, Spotify, Facebook, Instagram, Reddit, Telegram, WhatsApp, WeChat, QQ, Weibo, Taobao, AliExpress, eBay, Quora, V2EX, Apple) — while `classifyLinkPath` folds the shared file types into that existing six-category vocabulary. `ConnectionIndicator` renders a warning-colored disconnected action whose permanent retry glyph marks the retry action beside the owner-supplied outage label, the shared ongoing loader in the same warning color as its label whose one-to-three dots advance every 500ms independently of retry timing, or a success-colored recovered status. Clicking either warning state requests an immediate reconnect; no hover interaction changes the copy. The pill fades in on appearance, fades out for 150ms before unmounting, and sizes to its current label. Its owner supplies visibility, the recovery hold, localized labels, and the immediate-reconnect callback; the primitive uses no native title tooltip. `useAnchoredPosition` and `useAnchoredMaxHeight` keep floating panels and bottom-anchored overlays clamped to the viewport and following their anchor; anchored overlays and portalled `Menu` add 20px to the frame's published top clearance, or keep 20px in native fullscreen, while preserving their own minimum margins in ordinary browsers. `HoverCard` keeps its portaled preview reachable across the anchor gap and can expose a copy button through the `copyText` prop. Moving directly from the preview back to its anchor keeps it open. Its `callout` variant draws an anchor caret, keeps an 8px viewport margin, and scrolls content beyond the available space or 240px height cap. With `openOnClick`, clicking or touching the anchor pins the card; pointer leave and blur keep a pinned card open. Another activation, Escape, Tab, or an outside pointerdown dismisses it. Focused callout anchors scroll the full text with arrow keys, PageUp/PageDown, Home, and End. Touch entry does not open its preview or intercept the anchor action. Mouse entry requires an available hover-capable pointer; keyboard focus remains independent of that capability. Its `preview` variant uses the anchor or `widthAnchorRef` element’s width minus 48px, centers the panel with 24px side insets, and places it above or below the row within the viewport. The panel keeps the frame’s top clearance, caps its height at 420px, and follows content and anchor resizing; Escape or pointer and keyboard activation of the anchor dismisses it. The whole preview fades in and out over 100ms; a closing preview stops receiving pointer input and unmounts after the fade. Re-entering the anchor during the fade restores it. Reduced-motion preferences suppress the transition. Copy labels are required only when copying is enabled. `HoverCard` and nested action tooltips display independently; use `Tooltip.portal` to layer the small bubble above the card. A visible nested `Tooltip` suppresses only its enclosing tooltip, not the hover card. `Toast` uses the owner's `holdMs` for both the fade delay and its hold-and-fade lifetime. Rerenders with unchanged `holdMs` do not restart that lifetime; completion uses the latest callback, and fully faded actions cannot receive input. A new component key restarts the banner. `rankByName` is the `/` menu's shared candidate ranker for the command and skill sources: the query must be a case-insensitive ordered subsequence of the name; prefix hits rank first, then alignment score, then source order. Portalled `Menu` lists follow their anchor during dragging and CSS transforms, and stop tracking when closed. `Menu.autoFocus` focuses its first enabled item, supports Arrow Up/Down and Home/End navigation, and focuses the first button in the anchor on Escape; it is opt-in for action menus. +The catalog above lists what each export is for; this section covers the behavior that props alone do not show. Product icon names omit artboard sizes and end in `Regular` for the supplied one-pixel artwork or `Medium` for the same geometry at a 1.3px stroke; the `size` prop controls rendered dimensions. Named components keep the chosen weight visible at import sites; the library owns stroke geometry instead of consumer CSS overrides. Each product, reference, link, and permission glyph intentionally keeps both weight exports even when the current product uses only one, so callers can choose emphasis without adding another API later; fill-only pairs render identically. `IconWarningOutlineRegular`/`Medium` use a circle; `IconWarningTriangleOutlineRegular`/`Medium` use a rounded triangle. `FishLogo` and `BrandWordmark` fill brand slots. `FileTypeIcon` renders the traditional 28px spreadsheet, folder, HTML, image, Markdown, generic, PDF, PPT, video, and Word glyphs, and uses the imported square technology artwork for the established 48 code and configuration categories. The import replaces artwork only: archive-only categories do not extend `CodeFileType`. `classifyFileType` applies exact filename, prefix, suffix, optional project-context, and extension rules in that order; React names win over TypeScript/JavaScript, Angular suffixes win over their base extension, and a Dart file becomes Flutter only when the supplied project files contain a `pubspec.yaml` with `flutter:`. Markdown and SVG remain traditional Markdown and image files. Spreadsheet mappings include CSV, TSV, Excel workbooks and templates, OpenDocument spreadsheets, and Numbers; KEY maps to slides, while RTF/ODT/Pages map to documents. `fileExtension` exposes the same basename and final-dot parsing for adjacent metadata labels. Traditional glyphs use a solid category-colored sheet with a white mark and translucent white corner; the generic code glyph uses angle brackets and a slash at the supplied 1.35px stroke; the generic file uses a grey sheet and darker grey corner. Callers may override the sheet color through `--dsh-file-type-icon-color`. The full-color technology artwork is the deliberate exception and retains its embedded palette. All glyphs are decorative and carry no label. `LinkIconMedium` is the leading glyph for clickable artifact links — globe, folder, code, image, document, or plain paper, and for a `url` link the mark of a well-known site named by its `href` — the developer sites a transcript usually cites (GitHub, GitLab, npm, PyPI, Stack Overflow, MDN, Wikipedia, Hacker News, YouTube, X, Bilibili, Zhihu, Juejin, CSDN) and mainstream search, video, social, shopping, and reference sites (Google, Baidu, DuckDuckGo, TikTok, Netflix, Spotify, Facebook, Instagram, Reddit, Telegram, WhatsApp, WeChat, QQ, Weibo, Taobao, AliExpress, eBay, Quora, V2EX, Apple) — while `classifyLinkPath` folds the shared file types into that existing six-category vocabulary. `ConnectionIndicator` renders a warning-colored disconnected action whose permanent retry glyph marks the retry action beside the owner-supplied outage label, the shared ongoing loader in the same warning color as its label whose one-to-three dots advance every 500ms independently of retry timing, or a success-colored recovered status. Clicking either warning state requests an immediate reconnect; no hover interaction changes the copy. The pill fades in on appearance, fades out for 150ms before unmounting, and sizes to its current label. Its owner supplies visibility, the recovery hold, localized labels, and the immediate-reconnect callback; the primitive uses no native title tooltip. `useAnchoredPosition` and `useAnchoredMaxHeight` keep floating panels and bottom-anchored overlays clamped to the viewport and following their anchor; anchored overlays and portalled `Menu` add 20px to the frame's published top clearance, or keep 20px in native fullscreen, while preserving their own minimum margins in ordinary browsers. `HoverCard` keeps its portaled preview reachable across the anchor gap and can expose a copy button through the `copyText` prop. Touch entry does not open its preview or intercept the anchor action. Mouse entry requires an available hover-capable pointer; keyboard focus remains independent of that capability. Its `preview` variant uses the anchor or `widthAnchorRef` element’s width minus 48px, centers the panel with 24px side insets, and places it above or below the row within the viewport. The panel keeps the frame’s top clearance, caps its height at 420px, and follows content and anchor resizing; Escape or pointer and keyboard activation of the anchor dismisses it. The whole preview fades in and out over 100ms; a closing preview stops receiving pointer input and unmounts after the fade. Re-entering the anchor during the fade restores it. Reduced-motion preferences suppress the transition. Copy labels are required only when copying is enabled. `HoverCard` and nested action tooltips display independently; use `Tooltip.portal` to layer the small bubble above the card. A visible nested `Tooltip` suppresses only its enclosing tooltip, not the hover card. `Toast` uses the owner's `holdMs` for both the fade delay and its hold-and-fade lifetime. Rerenders with unchanged `holdMs` do not restart that lifetime; completion uses the latest callback, and fully faded actions cannot receive input. A new component key restarts the banner. `rankByName` is the `/` menu's shared candidate ranker for the command and skill sources: the query must be a case-insensitive ordered subsequence of the name; prefix hits rank first, then alignment score, then source order. Portalled `Menu` lists follow their anchor during dragging and CSS transforms, and stop tracking when closed. `Menu.autoFocus` focuses its first enabled item, supports Arrow Up/Down and Home/End navigation, and focuses the first button in the anchor on Escape; it is opt-in for action menus. `Tooltip` reads the anchor on hover or keyboard focus and fits its bubble from `ResizeObserver` border-box sizes. It stays hidden until its first fit, prefers the requested side, then tries the opposite and perpendicular sides, sliding along the anchor without covering it when a side fits. Horizontal placements keep a 10px gap; vertical placements use `gap` (8px by default). Width and height stay within a 12px viewport margin; text wraps, and content taller than the viewport scrolls when pinned. If no side fits, a stable viewport-bounded fallback can overlap the anchor. Viewport resize refreshes the anchor coordinates; label-size changes reuse them. Fitting does not synchronously measure the bubble or trigger a React render. Touch presses dismiss unpinned hints and do not trigger hover hints, including compatibility mouse events. Mouse entry opens hints only while `(any-hover: hover)` reports an available hover-capable input, excluding iOS mouse entry caused by a tapped icon changing. Keyboard focus and explicit click-to-open explanations remain available on touch-only devices. Opt-in `openOnClick` lets an informational button pin the bubble by click or tap and associates it with the anchor description. Another click or tap, Escape, Tab, or an outside pointerdown closes it. Ordinary action buttons execute their action directly without a touch tooltip. -Opt-in `HoverCard.openOnClick` requires one focusable React element that forwards ARIA props. Click and touch activation pin the card; pointer leave and blur keep a pinned card open so its text remains selectable; another anchor activation, Escape, Tab, or a pointerdown outside both anchor and card closes it. While open, the card joins the anchor's accessible description by default. Set `describeAnchor={false}` when the caller provides a stable `aria-describedby` relationship; the card then leaves that relationship unchanged. - `HoverCardGroup` owns private state for one mounted physical list. A `HoverCard` with `grouped` shares the first actually displayed card's dwell: subsequent cards replace the current card immediately during the same pointer interaction. The list and its active portaled card form one pointer interaction: the pointer may enter the card to read or copy, then return to another list item for immediate replacement. After the pointer leaves both, the shared dwell resets when the active card closes through its existing pointer-grace and close lifecycle; the group adds no separate timer. Leaving a list with no active card resets the dwell immediately. Opening an inline card by keyboard focus while the pointer is outside the list does not warm the next pointer visit. Exclusive display includes fading previews: ordinary preview dismissal retains its fade, but a group replacement removes the previous card immediately. Cards without `grouped` remain independent. diff --git a/packages/client/ui-primitives/README.zh.md b/packages/client/ui-primitives/README.zh.md index bdd67dcce1..96b9db405a 100644 --- a/packages/client/ui-primitives/README.zh.md +++ b/packages/client/ui-primitives/README.zh.md @@ -88,12 +88,10 @@ kind: "package-library" ### 控件与图标 -上面的目录说明每个导出的用途;本节讲 props 本身看不出来的行为。产品图标名称不含画板尺寸,以 `Regular` 表示原始 1px 图形,以 `Medium` 表示同一几何的 1.3px 描边;`size` prop 控制渲染尺寸。具名组件让所选线重在导入处可见;描边几何由组件库拥有,而不是由消费方 CSS 覆盖。每个产品、引用、链接与权限图形都会有意保留两种线重导出,即使当前产品只使用其中一种,也让调用方无需再次扩展 API 就能选择强调程度;仅填充的成对图形外观相同。`IconWarningOutlineRegular`/`Medium` 使用圆形;`IconWarningTriangleOutlineRegular`/`Medium` 使用圆角三角形。`FishLogo` 与 `BrandWordmark` 填充品牌 slot。`FileTypeIcon` 渲染传统的 28px spreadsheet、folder、HTML、image、Markdown、generic、PDF、PPT、video 与 Word 图形,并为现有 48 个代码和配置类别使用导入的方形技术图形。该导入只替换图形:资源包中额外的类别不会扩展 `CodeFileType`。`classifyFileType` 按完整文件名、前缀、后缀、可选项目上下文、扩展名的顺序匹配;React 文件名优先于 TypeScript/JavaScript,Angular 后缀优先于基础扩展名,只有传入的项目文件包含带 `flutter:` 的 `pubspec.yaml` 时 Dart 文件才使用 Flutter。Markdown 与 SVG 仍分别使用传统 Markdown 与图片图形。表格映射包括 CSV、TSV、Excel 工作簿与模板、OpenDocument 表格和 Numbers;KEY 映射为幻灯片,RTF/ODT/Pages 映射为文档。`fileExtension` 为相邻元数据 label 暴露同一套 basename 与最终点号解析。传统图形使用实色分类底板、白色标记和半透明白色折角;通用代码图形使用尖括号与斜线,保留原图 1.35px 描边;通用文件使用灰色底板与较深灰色折角。调用方可通过 `--dsh-file-type-icon-color` 覆盖底板颜色。全彩技术图形是明确例外,会保留其内嵌调色板。所有图形都是装饰性的,不自带 label。`LinkIconMedium` 是可点击产物链接的前置图形——地球、文件夹、代码、图片、文档或纸张,`url` 链接的 `href` 指向已知站点时则改用该站点自己的标记——转写内容常引用的开发者站点(GitHub、GitLab、npm、PyPI、Stack Overflow、MDN、Wikipedia、Hacker News、YouTube、X、Bilibili、知乎、掘金、CSDN),以及主流搜索、视频、社交、购物与参考资料站点(Google、百度、DuckDuckGo、TikTok、Netflix、Spotify、Facebook、Instagram、Reddit、Telegram、WhatsApp、微信、QQ、微博、淘宝、速卖通、eBay、Quora、V2EX、Apple)——`classifyLinkPath` 把共享文件类型折叠进原有六类词汇。`ConnectionIndicator` 可渲染警告色的断联操作(常驻重试图形指明重试动作,断联文案由持有方提供)、与文案使用相同警告色的共享 ongoing loading 加一至三个点以独立于 retry 时序的 500ms 节奏推进的连接中状态,或成功色的恢复状态。点击任一警告状态都会请求立即重连;没有任何悬停交互会改变文案。药丸出现时淡入、卸载前淡出 150ms,宽度随当前 label 自适应。它的持有方提供可见性、恢复驻留时间、本地化 label 与立即重连回调;该原语不使用原生 title tooltip。`useAnchoredPosition` 与 `useAnchoredMaxHeight` 让浮动面板与底部锚定浮层始终钳制在视口内并跟随锚点;锚定浮层与 portal 模式的 `Menu` 在框架发布的顶部占用量上增加 20px,原生全屏时保留 20px,普通浏览器中沿用自身的最小边距。`HoverCard` 通过指针离开宽限期让采用 portal 的预览在跨过锚点间隙时仍可触及,并可通过 `copyText` prop 提供复制按钮。指针从预览直接移回其锚点时,预览保持打开。其 `callout` 变体绘制指向锚点的小角,保留 8px 视口边距,并在内容超过可用空间或 240px 高度上限时内部滚动。启用 `openOnClick` 后,点击或触屏激活锚点会固定卡片;指针移出与失焦不会关闭已固定的卡片。再次激活、Escape、Tab 或外部 pointerdown 会关闭它。聚焦的 callout 锚点支持通过方向键、PageUp/PageDown、Home 和 End 滚动全文。触摸进入不会打开预览,也不会拦截锚点操作。鼠标进入要求存在可悬停的指针设备;键盘聚焦不依赖该能力。其 `preview` 变体使用 anchor 或 `widthAnchorRef` 元素的宽度减去 48px,左右各内缩 24px,并在视口内放置于行的上方或下方。浮层避开框架顶部保留区,高度最多 420px,会跟随内容及 anchor 尺寸变化;Escape 或通过鼠标和键盘激活锚点可将其关闭。整个浮层的淡入和淡出各持续 100ms;关闭中的浮层停止接收指针输入,淡出后卸载。在淡出期间移回锚点可恢复显示。减少动态效果偏好会禁用过渡动画。只有启用复制时才必须提供复制标签。`HoverCard` 与嵌套的操作提示独立显示;使用 `Tooltip.portal` 将小气泡置于卡片上层。可见的嵌套 `Tooltip` 只隐藏外层 tooltip,不隐藏悬停卡片。`Toast` 使用调用方的 `holdMs` 同时控制淡出延迟与停留加淡出的总时长。`holdMs` 未变时,父组件重渲染不会重启该生命周期;完成时调用最新回调,完全淡出的操作不能接收输入。新的组件 key 会重新开始横幅周期。`rankByName` 是 `/` 菜单命令源与 skill(技能)源共享的候选排序器:查询必须是名字的不区分大小写的有序子序列;前缀命中排最前,其次按对齐分数,再按来源顺序。 portal 模式的 `Menu` 列表会在拖动和 CSS 变换期间跟随锚点,并在关闭时停止跟踪。`Menu.autoFocus` 聚焦首个启用项,支持上下方向键与 Home/End 导航,并在 Escape 时聚焦 anchor 内的第一个按钮;操作菜单可显式启用。 +上面的目录说明每个导出的用途;本节讲 props 本身看不出来的行为。产品图标名称不含画板尺寸,以 `Regular` 表示原始 1px 图形,以 `Medium` 表示同一几何的 1.3px 描边;`size` prop 控制渲染尺寸。具名组件让所选线重在导入处可见;描边几何由组件库拥有,而不是由消费方 CSS 覆盖。每个产品、引用、链接与权限图形都会有意保留两种线重导出,即使当前产品只使用其中一种,也让调用方无需再次扩展 API 就能选择强调程度;仅填充的成对图形外观相同。`IconWarningOutlineRegular`/`Medium` 使用圆形;`IconWarningTriangleOutlineRegular`/`Medium` 使用圆角三角形。`FishLogo` 与 `BrandWordmark` 填充品牌 slot。`FileTypeIcon` 渲染传统的 28px spreadsheet、folder、HTML、image、Markdown、generic、PDF、PPT、video 与 Word 图形,并为现有 48 个代码和配置类别使用导入的方形技术图形。该导入只替换图形:资源包中额外的类别不会扩展 `CodeFileType`。`classifyFileType` 按完整文件名、前缀、后缀、可选项目上下文、扩展名的顺序匹配;React 文件名优先于 TypeScript/JavaScript,Angular 后缀优先于基础扩展名,只有传入的项目文件包含带 `flutter:` 的 `pubspec.yaml` 时 Dart 文件才使用 Flutter。Markdown 与 SVG 仍分别使用传统 Markdown 与图片图形。表格映射包括 CSV、TSV、Excel 工作簿与模板、OpenDocument 表格和 Numbers;KEY 映射为幻灯片,RTF/ODT/Pages 映射为文档。`fileExtension` 为相邻元数据 label 暴露同一套 basename 与最终点号解析。传统图形使用实色分类底板、白色标记和半透明白色折角;通用代码图形使用尖括号与斜线,保留原图 1.35px 描边;通用文件使用灰色底板与较深灰色折角。调用方可通过 `--dsh-file-type-icon-color` 覆盖底板颜色。全彩技术图形是明确例外,会保留其内嵌调色板。所有图形都是装饰性的,不自带 label。`LinkIconMedium` 是可点击产物链接的前置图形——地球、文件夹、代码、图片、文档或纸张,`url` 链接的 `href` 指向已知站点时则改用该站点自己的标记——转写内容常引用的开发者站点(GitHub、GitLab、npm、PyPI、Stack Overflow、MDN、Wikipedia、Hacker News、YouTube、X、Bilibili、知乎、掘金、CSDN),以及主流搜索、视频、社交、购物与参考资料站点(Google、百度、DuckDuckGo、TikTok、Netflix、Spotify、Facebook、Instagram、Reddit、Telegram、WhatsApp、微信、QQ、微博、淘宝、速卖通、eBay、Quora、V2EX、Apple)——`classifyLinkPath` 把共享文件类型折叠进原有六类词汇。`ConnectionIndicator` 可渲染警告色的断联操作(常驻重试图形指明重试动作,断联文案由持有方提供)、与文案使用相同警告色的共享 ongoing loading 加一至三个点以独立于 retry 时序的 500ms 节奏推进的连接中状态,或成功色的恢复状态。点击任一警告状态都会请求立即重连;没有任何悬停交互会改变文案。药丸出现时淡入、卸载前淡出 150ms,宽度随当前 label 自适应。它的持有方提供可见性、恢复驻留时间、本地化 label 与立即重连回调;该原语不使用原生 title tooltip。`useAnchoredPosition` 与 `useAnchoredMaxHeight` 让浮动面板与底部锚定浮层始终钳制在视口内并跟随锚点;锚定浮层与 portal 模式的 `Menu` 在框架发布的顶部占用量上增加 20px,原生全屏时保留 20px,普通浏览器中沿用自身的最小边距。`HoverCard` 通过指针离开宽限期让采用 portal 的预览在跨过锚点间隙时仍可触及,并可通过 `copyText` prop 提供复制按钮。触摸进入不会打开预览,也不会拦截锚点操作。鼠标进入要求存在可悬停的指针设备;键盘聚焦不依赖该能力。其 `preview` 变体使用 anchor 或 `widthAnchorRef` 元素的宽度减去 48px,左右各内缩 24px,并在视口内放置于行的上方或下方。浮层避开框架顶部保留区,高度最多 420px,会跟随内容及 anchor 尺寸变化;Escape 或通过鼠标和键盘激活锚点可将其关闭。整个浮层的淡入和淡出各持续 100ms;关闭中的浮层停止接收指针输入,淡出后卸载。在淡出期间移回锚点可恢复显示。减少动态效果偏好会禁用过渡动画。只有启用复制时才必须提供复制标签。`HoverCard` 与嵌套的操作提示独立显示;使用 `Tooltip.portal` 将小气泡置于卡片上层。可见的嵌套 `Tooltip` 只隐藏外层 tooltip,不隐藏悬停卡片。`Toast` 使用调用方的 `holdMs` 同时控制淡出延迟与停留加淡出的总时长。`holdMs` 未变时,父组件重渲染不会重启该生命周期;完成时调用最新回调,完全淡出的操作不能接收输入。新的组件 key 会重新开始横幅周期。`rankByName` 是 `/` 菜单命令源与 skill(技能)源共享的候选排序器:查询必须是名字的不区分大小写的有序子序列;前缀命中排最前,其次按对齐分数,再按来源顺序。 portal 模式的 `Menu` 列表会在拖动和 CSS 变换期间跟随锚点,并在关闭时停止跟踪。`Menu.autoFocus` 聚焦首个启用项,支持上下方向键与 Home/End 导航,并在 Escape 时聚焦 anchor 内的第一个按钮;操作菜单可显式启用。 `Tooltip` 在悬停或键盘聚焦时读取锚点位置,再根据 `ResizeObserver` 提供的边框盒尺寸调整气泡。首次定位前保持隐藏,优先使用指定方向,再尝试相反方向和垂直于该方向的两侧;只要某一侧容得下,就沿锚点平移而不遮挡锚点。左右放置保留 10px 间距,上下放置使用 `gap`(默认 8px)。宽高限制在视口内并保留 12px 边距;文本自动换行,超出视口高度的内容在固定气泡后可滚动。若所有方向都放不下,则使用稳定的视口内回退位置,可能遮挡锚点。视口尺寸变化会重新读取锚点坐标,标签尺寸变化则复用坐标;定位不会同步测量气泡,也不会触发 React 渲染。 触摸按下会关闭未固定的提示,且不会触发悬停提示,包括触摸后的兼容鼠标事件。只有 `(any-hover: hover)` 表明存在可悬停的输入设备时,鼠标进入才会打开提示,从而排除 iOS 因已点击图标变化而生成的鼠标进入事件。仅有触摸输入的设备仍可使用键盘聚焦提示和显式点击打开的说明。信息按钮可显式启用 `openOnClick`,通过鼠标或触摸点击固定气泡供阅读,并将内容关联为锚点的无障碍描述;再次点击、Escape、Tab 或外部 pointerdown 会关闭。普通操作按钮在触摸点击时直接执行操作,不显示 Tooltip。 -显式启用 `HoverCard.openOnClick` 时,锚点必须是单个可聚焦且会转发 ARIA props 的 React 元素。点击和触屏激活会固定卡片;指针离开和失焦不会关闭固定的卡片,因此文字仍可选择;再次激活锚点、按 Escape 或 Tab,或在锚点及卡片之外按下指针都会关闭卡片。卡片打开期间,默认会加入锚点的无障碍描述。调用方已通过 `aria-describedby` 提供稳定关联时设置 `describeAnchor={false}`;卡片将保持该关联不变。 - `HoverCardGroup` 为一个已挂载的物理列表持有私有状态。设置 `grouped` 的 `HoverCard` 共享首张实际显示卡片的等待:在同一次指针交互中,后续卡片立即替换当前卡片。列表与其当前活动的 portal 卡片构成一次指针交互:指针可进入卡片阅读或复制,再返回其他列表项并立即替换卡片。指针离开两者后,共享等待在活动卡片通过原有指针宽限及关闭生命周期关闭时重置;分组不另设计时器。列表没有活动卡片时,指针离开便立即重置等待。指针位于列表外时,通过键盘聚焦打开行内卡片不会让下一次指针进入跳过等待。互斥显示也包括正在淡出的预览:普通预览关闭保留淡出,但分组内替换会立即移除前一张卡片。未设置 `grouped` 的卡片保持独立。 diff --git a/packages/client/ui-primitives/src/HoverCard.module.css b/packages/client/ui-primitives/src/HoverCard.module.css index 0542763a8a..50c62fa828 100644 --- a/packages/client/ui-primitives/src/HoverCard.module.css +++ b/packages/client/ui-primitives/src/HoverCard.module.css @@ -88,53 +88,3 @@ backdrop-filter: var(--dsw-menu-backdrop-filter); box-shadow: var(--dsw-elevation-panel); } - -.callout { - --dsh-scrollbar-thumb: var(--dsw-alias-scrollbar-bg-l2); - --dsh-scrollbar-thumb-hover: var(--dsw-alias-scrollbar-hover-l2); - isolation: isolate; - padding: 0; - border: 0; - background: transparent; - box-shadow: none; -} - -.calloutSurface { - position: relative; - z-index: 1; - padding: 4px; - overflow: hidden; - border: 0; - border-radius: var(--dsw-radius-xl); - background: var(--dsw-specific-bubble); - box-shadow: var(--dsw-elevation-panel); -} - -.calloutBody { - box-sizing: border-box; - max-height: max(0px, calc(var(--dsh-hover-card-max-height) - 8px)); - overflow-y: auto; - overscroll-behavior: contain; - padding: 4px 8px; - border-radius: var(--dsw-radius-lg); - color: var(--dsw-alias-label-primary); -} - -.calloutCaret { - position: absolute; - z-index: 2; - left: var(--dsh-hover-card-anchor-x); - width: 10px; - height: 10px; - background: var(--dsw-specific-bubble); - transform: translateX(-50%) rotate(45deg); - pointer-events: none; -} - -.callout[data-placement='below'] .calloutCaret { - top: -4px; -} - -.callout[data-placement='above'] .calloutCaret { - bottom: -4px; -} diff --git a/packages/client/ui-primitives/src/HoverCard.tsx b/packages/client/ui-primitives/src/HoverCard.tsx index 601f5782ea..e30d671c36 100644 --- a/packages/client/ui-primitives/src/HoverCard.tsx +++ b/packages/client/ui-primitives/src/HoverCard.tsx @@ -1,12 +1,11 @@ -import { cloneElement, useCallback, useContext, useEffect, useId, useLayoutEffect, useRef, useState } from 'react' -import type { CSSProperties, ReactElement, ReactNode, RefObject, SyntheticEvent } from 'react' +import { useCallback, useContext, useEffect, useLayoutEffect, useRef, useState } from 'react' +import type { CSSProperties, ReactNode, RefObject, SyntheticEvent } from 'react' import clsx from 'clsx' import { createPortal } from 'react-dom' import { writeClipboard } from './clipboard.ts' import { usePointerGrace } from './pointer-grace.ts' import { overlayTopMargin } from './overlay-top-margin.ts' import { HoverCardGroupContext } from './HoverCardGroup.tsx' -import { useDismissOnOutsidePointer } from './useDismissOnOutsidePointer.ts' import css from './HoverCard.module.css' /** Preview opacity transition and retained lifetime during dismissal. */ @@ -14,101 +13,64 @@ const PREVIEW_FADE_MS = 100 const PREVIEW_MAX_HEIGHT = 420 const PREVIEW_INSET = 24 const INLINE_PREVIEW_WIDTH = 300 -const CALLOUT_MAX_HEIGHT = 240 -const CALLOUT_WIDTH = 300 -const CALLOUT_ARROW_EDGE_INSET = 16 -const CALLOUT_SURFACE_INSET = 4 const ANCHOR_GAP = 8 const VIEWPORT_MARGIN = 8 -interface DescribedAnchorProps { - 'aria-describedby'?: string | undefined -} - -type HoverCardProps = { - /** Inline media preview using the shared menu material and keyboard focus. */ - inline?: boolean - content: ReactNode - openDelayMs?: number - disabled?: boolean - grouped?: boolean - variant?: 'compact' | 'preview' | 'callout' - widthAnchorRef?: RefObject -} & ({ - anchor: ReactElement - openOnClick: true - describeAnchor?: boolean -} | { - anchor: ReactNode - openOnClick?: false | undefined - describeAnchor?: never -}) & ({ copyText?: string | undefined; copyLabel: string; copiedLabel: string } | { - copyText?: undefined - copyLabel?: string - copiedLabel?: string -}) - /** * Render an anchor with a hover-triggered preview card independently of nested tooltips. - * @param props.anchor - the hover target (rendered in place inside a wrapper span). With - * openOnClick, pass one focusable element that forwards ARIA props. + * Touch entry does not open a preview or intercept the anchor's action. + * @param props.anchor - the hover target (rendered in place inside a wrapper span). * @param props.content - card content; the pointer may rest on it, so it is * readable and selectable, but it carries no dismissal affordance of its own. * @param props.openDelayMs - hover dwell before the card shows (default 500). - * @param props.variant - compact card beside the anchor; a preview with container-relative - * width and opacity transitions; or a 300px callout with an anchor caret and 240px height cap. + * @param props.variant - compact card beside the anchor, or a preview above/below it + * with 24px side insets, a 420px height cap, frame-top clearance, and 100ms opacity transitions. * @param props.widthAnchorRef - optional element whose width and horizontal position size the preview. * @param props.inline - keep the anchor in prose; show a contained preview on hover or keyboard focus. * @param props.disabled - suppress opening; turning true dismisses an open card. * @param props.grouped - share first-display dwell across the surrounding list and active portal. * Group replacement removes the previous card immediately, including a fading preview. - * @param props.openOnClick - clicking or touching the anchor pins the card open. Another activation, - * Escape, Tab, or an outside pointerdown dismisses it; pointer leave and blur do not dismiss a pinned card. - * For callouts, focused anchors scroll the card with arrow keys, PageUp/PageDown, Home, and End. - * @param props.describeAnchor - with openOnClick, append the open card to the anchor's accessible - * description (default true). Set false when the caller supplies a stable description relationship. * @param props.copyText - optional primary value copied by activation and * included in the card's accessible name. * @param props.copyLabel - localized accessible activation-label prefix. * @param props.copiedLabel - localized visible success label. * @returns anchor wrapper with the conditional portaled card. */ -export function HoverCard(props: HoverCardProps) { - const { - content, openDelayMs = 500, disabled = false, grouped = false, describeAnchor = true, - copyText, copyLabel, copiedLabel, variant = 'compact', widthAnchorRef, inline = false, - } = props - const openOnClick = props.openOnClick === true - const callout = variant === 'callout' - const anchored = inline || callout - const id = useId() +export function HoverCard({ + anchor, content, openDelayMs = 500, disabled = false, grouped = false, + copyText, copyLabel, copiedLabel, variant = 'compact', widthAnchorRef, inline = false, +}: { + anchor: ReactNode + /** Inline media preview using the shared menu material and keyboard focus. */ + inline?: boolean + content: ReactNode + openDelayMs?: number + disabled?: boolean + grouped?: boolean + variant?: 'compact' | 'preview' + widthAnchorRef?: RefObject +} & ({ copyText?: string | undefined; copyLabel: string; copiedLabel: string } | { + copyText?: undefined + copyLabel?: string + copiedLabel?: string +})) { const rootRef = useRef(null) const cardRef = useRef(null) - const scrollRef = useRef(null) const timerRef = useRef | null>(null) const copyTimerRef = useRef | null>(null) const copyHeightRef = useRef(null) const copyEpochRef = useRef(0) - const keyboardFocus = useRef(false) const copyingRef = useRef(false) const mountedRef = useRef(true) const [phase, setPhase] = useState<'closed' | 'open' | 'closing'>('closed') const open = phase !== 'closed' const closing = phase === 'closing' - const [pos, setPos] = useState<{ - left: number - top: number - width?: number - maxHeight?: number - anchorX?: number - placement?: 'above' | 'below' - } | null>(null) + const [pos, setPos] = useState<{ left: number; top: number; width?: number; maxHeight?: number } | null>(null) const positioned = pos !== null const [copied, setCopied] = useState(false) const parentGroup = useContext(HoverCardGroupContext) const group = grouped ? parentGroup : null const [groupOwner] = useState(() => Symbol('hover-card')) - const [pinned, setPinned] = useState(false) const clearCopied = useCallback(() => { if (copyTimerRef.current !== null) { @@ -120,15 +82,12 @@ export function HoverCard(props: HoverCardProps) { }, []) const close = useCallback(() => { - keyboardFocus.current = false copyEpochRef.current += 1 clearCopied() - setPinned(false) setPhase(current => variant === 'preview' && current !== 'closed' ? 'closing' : 'closed') }, [clearCopied, variant]) const { arm: armClose, cancel: cancelClose } = usePointerGrace(close) - useDismissOnOutsidePointer(rootRef, openOnClick && open, close, cardRef) const clearTimer = useCallback(() => { if (timerRef.current !== null) { @@ -181,7 +140,7 @@ export function HoverCard(props: HoverCardProps) { }, []) useEffect(() => { - if (!open || openOnClick || (variant !== 'preview' && !anchored)) return + if (!open || (variant !== 'preview' && !inline)) return const dismiss = (event: KeyboardEvent): void => { if (event.key !== 'Escape') return if (inline) event.stopPropagation() @@ -191,23 +150,7 @@ export function HoverCard(props: HoverCardProps) { } window.addEventListener('keydown', dismiss, inline) return () => { window.removeEventListener('keydown', dismiss, inline) } - }, [open, openOnClick, variant, anchored, cancelClose, close]) - - useEffect(() => { - if (!openOnClick || !open) return - const dismiss = (event: KeyboardEvent): void => { - if (event.key !== 'Escape' && event.key !== 'Tab') return - if (event.key === 'Escape') { - event.preventDefault() - event.stopPropagation() - } - clearTimer() - cancelClose() - close() - } - document.addEventListener('keydown', dismiss, true) - return () => { document.removeEventListener('keydown', dismiss, true) } - }, [open, openOnClick, cancelClose, close]) + }, [open, variant, inline, cancelClose, close]) // Fixed-position from the anchor rect before paint; track the anchor while // open (capture-phase scroll catches nested panes), as in Menu portal mode. @@ -235,34 +178,19 @@ export function HoverCard(props: HoverCardProps) { }) return } - if (anchored) { - const height = callout - ? Math.max(h, (scrollRef.current?.scrollHeight ?? 0) + CALLOUT_SURFACE_INSET * 2) - : Math.max(h, cardRef.current?.scrollHeight ?? 0) - const width = Math.max(0, Math.min(callout ? CALLOUT_WIDTH : INLINE_PREVIEW_WIDTH, - window.innerWidth - VIEWPORT_MARGIN * 2)) + if (inline) { + const height = Math.max(h, cardRef.current?.scrollHeight ?? 0) + const width = Math.max(0, Math.min(INLINE_PREVIEW_WIDTH, window.innerWidth - VIEWPORT_MARGIN * 2)) const topMargin = overlayTopMargin(VIEWPORT_MARGIN) const belowTop = Math.max(topMargin, r.bottom + ANCHOR_GAP) const above = Math.max(0, r.top - ANCHOR_GAP - topMargin) const below = Math.max(0, window.innerHeight - belowTop - VIEWPORT_MARGIN) const onTop = height > below && above > below - const maxHeight = Math.min(callout ? CALLOUT_MAX_HEIGHT : Number.POSITIVE_INFINITY, - onTop ? above : below) - const anchorCenter = (r.left + r.right) / 2 - const idealLeft = callout ? anchorCenter - width / 2 : r.left - const left = Math.max(VIEWPORT_MARGIN, Math.min(idealLeft, - window.innerWidth - width - VIEWPORT_MARGIN)) - const arrowInset = Math.min(CALLOUT_ARROW_EDGE_INSET, width / 2) + const maxHeight = onTop ? above : below setPos({ - left, + left: Math.max(VIEWPORT_MARGIN, Math.min(r.left, window.innerWidth - width - VIEWPORT_MARGIN)), top: onTop ? r.top - ANCHOR_GAP - Math.min(height, maxHeight) : belowTop, width, maxHeight, - ...(callout - ? { - anchorX: Math.max(arrowInset, Math.min(anchorCenter - left, width - arrowInset)), - placement: onTop ? 'above' as const : 'below' as const, - } - : {}), }) return } @@ -270,8 +198,8 @@ export function HoverCard(props: HoverCardProps) { setPos({ left: r.right + ANCHOR_GAP, top }) } place() - const observer = (variant === 'preview' || anchored) && typeof ResizeObserver !== 'undefined' ? new ResizeObserver(place) : null - for (const element of [cardRef.current, scrollRef.current, rootRef.current, widthAnchorRef?.current]) { + const observer = (variant === 'preview' || inline) && typeof ResizeObserver !== 'undefined' ? new ResizeObserver(place) : null + for (const element of [cardRef.current, rootRef.current, widthAnchorRef?.current]) { if (element !== null && element !== undefined) observer?.observe(element) } window.addEventListener('scroll', place, true) @@ -281,19 +209,19 @@ export function HoverCard(props: HoverCardProps) { window.removeEventListener('scroll', place, true) window.removeEventListener('resize', place) } - }, [open, variant, widthAnchorRef, positioned, anchored, callout]) + }, [open, variant, widthAnchorRef, positioned, inline]) // The first placement ran before the card mounted (height read 0): once the // card's real height is measurable, correct the bottom-edge clamp. The // correction converges — a clamped top satisfies the guard, so it runs once. useLayoutEffect(() => { - if (!open || pos === null || variant === 'preview' || anchored) return + if (!open || pos === null || variant === 'preview' || inline) return /* v8 ignore next -- the card is mounted whenever pos is set, so the ref is attached here. */ const h = cardRef.current?.offsetHeight ?? 0 if (pos.top + h > window.innerHeight - VIEWPORT_MARGIN) { setPos({ left: pos.left, top: window.innerHeight - h - VIEWPORT_MARGIN }) } - }, [open, pos, variant, anchored]) + }, [open, pos, variant, inline]) const copy = async (text: string): Promise => { if (copied || copyingRef.current) return @@ -310,43 +238,21 @@ export function HoverCard(props: HoverCardProps) { } const copyable = copyText !== undefined - const describedAnchor = props.openOnClick === true && describeAnchor - ? cloneElement(props.anchor, { - 'aria-describedby': open - ? [props.anchor.props['aria-describedby'], id].filter(Boolean).join(' ') - : props.anchor.props['aria-describedby'], - }) - : props.anchor const dismissFromAnchor = (event: SyntheticEvent): void => { // Portal content is a React child, but its clicks and text selection do not activate the anchor. if (cardRef.current?.contains(event.target as Node)) return - keyboardFocus.current = false clearTimer() cancelClose() - if (openOnClick && !disabled && !pinned) { - setPinned(true) - setPhase('open') - return - } close() } - const cardContent = copied ? : content const card = open && pos !== null && (
{ - cancelClose() - setPhase('open') - }} - onPointerLeave={(event) => { - // Portal-to-anchor movement stays within this card's interaction. - if (event.relatedTarget instanceof Node && rootRef.current?.contains(event.relatedTarget)) { - cancelClose() - return - } - if (!pinned && !(anchored && keyboardFocus.current)) armClose() - }} > - {callout - ? ( - <> -
) return ( { - if (!disabled && event.target.matches(':focus-visible')) { - keyboardFocus.current = true - cancelClose() - setPhase('open') - } + className={clsx(css.root, inline && css.inline)} + onFocus={inline ? (event) => { + if (!disabled && event.target.matches(':focus-visible')) { cancelClose(); setPhase('open') } } : undefined} - onBlur={anchored ? (event) => { - keyboardFocus.current = false - if (!pinned && !event.currentTarget.contains(event.relatedTarget)) { clearTimer(); cancelClose(); close() } - } : undefined} - onKeyDown={callout && open ? (event) => { - const scroll = scrollRef.current - if (scroll === null || !rootRef.current?.contains(event.target as Node)) return - let top: number - switch (event.key) { - case 'ArrowDown': top = scroll.scrollTop + scroll.clientHeight / 8; break - case 'ArrowUp': top = scroll.scrollTop - scroll.clientHeight / 8; break - case 'PageDown': top = scroll.scrollTop + scroll.clientHeight; break - case 'PageUp': top = scroll.scrollTop - scroll.clientHeight; break - case 'Home': top = 0; break - case 'End': top = scroll.scrollHeight; break - default: return - } - event.preventDefault() - event.stopPropagation() - scroll.scrollTop = top + onBlur={inline ? (event) => { + if (!event.currentTarget.contains(event.relatedTarget)) { clearTimer(); cancelClose(); close() } } : undefined} onPointerEnter={(event) => { if (disabled || event.pointerType === 'touch') return @@ -441,14 +304,13 @@ export function HoverCard(props: HoverCardProps) { onPointerLeave={() => { clearTimer() // Leaving a closed card schedules a no-op close; only arm while - // open, matching Menu's shape. Keyboard focus remains an independent - // open trigger after the pointer leaves. - if (open && !pinned && !(anchored && keyboardFocus.current)) armClose() + // open, matching Menu's shape. + if (open) armClose() }} - onPointerDownCapture={openOnClick ? undefined : dismissFromAnchor} + onPointerDownCapture={dismissFromAnchor} onClickCapture={dismissFromAnchor} > - {describedAnchor} + {anchor} {open && copyable && {copied ? copiedLabel : ''}} {card !== false && createPortal(card, document.body)} diff --git a/packages/client/ui-primitives/tests/hover-card.client.spec.tsx b/packages/client/ui-primitives/tests/hover-card.client.spec.tsx index cfdf11bc10..65fbad0a64 100644 --- a/packages/client/ui-primitives/tests/hover-card.client.spec.tsx +++ b/packages/client/ui-primitives/tests/hover-card.client.spec.tsx @@ -22,7 +22,7 @@ function stubAnchorRect(anchor: HTMLElement, rect: { top: number; right: number function mount(props: { widthAnchorRef?: { current: HTMLElement | null } - variant?: 'compact' | 'preview' | 'callout' + variant?: 'compact' | 'preview' openDelayMs?: number disabled?: boolean copyText?: string @@ -165,27 +165,6 @@ describe('HoverCard', () => { expect(screen.queryByText('card body')).toBeNull() }) - it('caps a callout, keeps its caret on the anchor, and flips it above when needed', () => { - const { wrapper } = mount({ variant: 'callout' }) - fireEvent.pointerEnter(wrapper) - act(() => { vi.advanceTimersByTime(500) }) - const card = screen.getByText('card body').closest('[data-placement]') as HTMLElement - const body = card.querySelector('[data-hover-card-scroll]') as HTMLElement - expect(card.style.width).toBe('300px') - expect(card.style.left).toBe('8px') - expect(card.style.top).toBe('82px') - expect(card.dataset.placement).toBe('below') - expect(card.style.getPropertyValue('--dsh-hover-card-max-height')).toBe('240px') - expect(card.style.getPropertyValue('--dsh-hover-card-anchor-x')).toBe('142px') - - Object.defineProperty(body, 'scrollHeight', { configurable: true, value: 600 }) - wrapper.getBoundingClientRect = () => DOMRect.fromRect({ x: 100, y: 700, width: 100, height: 34 }) - fireEvent(window, new Event('resize')) - expect(card.style.top).toBe('452px') - expect(card.dataset.placement).toBe('above') - expect(card.style.getPropertyValue('--dsh-hover-card-max-height')).toBe('240px') - }) - it('observes preview and container sizes until dismissal', () => { let resize = () => {} const observe = vi.fn() @@ -246,127 +225,6 @@ describe('HoverCard', () => { expect(screen.getByText('card body')).toBeTruthy() }) - it('pins on click for touch access and keeps the card through pointer leave and blur', () => { - render( - <> -

Existing description

- details} - content={
card body
} - /> - , - ) - const anchor = screen.getByRole('button', { name: 'details' }) - const wrapper = anchor.parentElement as HTMLElement - const touch = new Event('pointerover', { bubbles: true }) - Object.defineProperty(touch, 'pointerType', { value: 'touch' }) - fireEvent(wrapper, touch) - act(() => { vi.advanceTimersByTime(500) }) - expect(screen.queryByText('card body')).toBeNull() - - fireEvent.click(anchor) - const card = screen.getByText('card body').parentElement as HTMLElement - expect(card.getAttribute('data-pinned')).toBe('true') - expect(anchor.getAttribute('aria-describedby')).toBe(`existing-description ${card.id}`) - fireEvent.pointerLeave(wrapper) - act(() => { vi.advanceTimersByTime(POINTER_GRACE_MS) }) - fireEvent.blur(anchor, { relatedTarget: document.body }) - expect(screen.getByText('card body')).toBeTruthy() - - fireEvent.pointerLeave(card) - act(() => { vi.advanceTimersByTime(POINTER_GRACE_MS) }) - expect(screen.getByText('card body')).toBeTruthy() - fireEvent.pointerDown(card) - expect(screen.getByText('card body')).toBeTruthy() - fireEvent.pointerDown(document.body) - expect(screen.queryByText('card body')).toBeNull() - expect(anchor.getAttribute('aria-describedby')).toBe('existing-description') - }) - - it('preserves a caller-owned accessible description while click-pinned', () => { - render(<> -

Stable description

- details} - content={
card body
} - /> - ) - const anchor = screen.getByRole('button', { name: 'details' }) - expect(anchor.getAttribute('aria-describedby')).toBe('stable-description') - fireEvent.click(anchor) - expect(screen.getByText('card body')).toBeTruthy() - expect(anchor.getAttribute('aria-describedby')).toBe('stable-description') - fireEvent.pointerDown(document.body) - expect(screen.queryByText('card body')).toBeNull() - expect(anchor.getAttribute('aria-describedby')).toBe('stable-description') - }) - - it('dismisses a click-open card on another click, Escape, or Tab', () => { - const ownerKey = vi.fn() - render(
- details} content={
card body
} /> -
) - const anchor = screen.getByRole('button', { name: 'details' }) - - fireEvent.click(anchor) - expect(screen.getByText('card body')).toBeTruthy() - fireEvent.click(anchor) - expect(screen.queryByText('card body')).toBeNull() - - fireEvent.click(anchor) - fireEvent.keyDown(anchor, { key: 'a' }) - expect(screen.getByText('card body')).toBeTruthy() - ownerKey.mockClear() - expect(fireEvent.keyDown(anchor, { key: 'Escape' })).toBe(false) - expect(ownerKey).not.toHaveBeenCalled() - expect(screen.queryByText('card body')).toBeNull() - - fireEvent.click(anchor) - expect(fireEvent.keyDown(anchor, { key: 'Tab' })).toBe(true) - expect(screen.queryByText('card body')).toBeNull() - }) - - it('drops a click pin when the card is disabled', () => { - const pinnedCard = (disabled = false) => ( - details} content={
card body
} /> - ) - const view = render(pinnedCard()) - const anchor = screen.getByRole('button', { name: 'details' }) - fireEvent.click(anchor) - expect(screen.getByText('card body')).toBeTruthy() - view.rerender(pinnedCard(true)) - expect(screen.queryByText('card body')).toBeNull() - - }) - - it('removes click-pin dismissal listeners on unmount', () => { - const view = render( - details} content={
card body
} />, - ) - const registrations = vi.spyOn(document, 'addEventListener') - const removals = vi.spyOn(document, 'removeEventListener') - fireEvent.click(screen.getByRole('button', { name: 'details' })) - expect(screen.getByText('card body')).toBeTruthy() - - const listeners = registrations.mock.calls.filter(([type]) => type === 'keydown' || type === 'pointerdown') - expect(listeners.map(([type]) => type).sort()).toEqual(['keydown', 'pointerdown']) - view.unmount() - - for (const [type, listener, options] of listeners) { - const removed = removals.mock.calls.find(([removedType, removedListener]) => - removedType === type && removedListener === listener) - expect(removed).toBeDefined() - expect(removed?.[2]).toBe(options) - } - expect(fireEvent.keyDown(document.body, { key: 'Escape' })).toBe(true) - fireEvent.pointerDown(document.body) - }) - it('dismisses on keyboard clicks and cancels a pending hover', () => { const { anchor, wrapper } = mount({ variant: 'preview' }) fireEvent.pointerEnter(wrapper) @@ -443,37 +301,6 @@ describe('HoverCard', () => { expect(screen.getByText('card body')).toBeTruthy() }) - it('keeps the card open while its own pointer is inside and closes after leaving it', () => { - const { wrapper } = mount({ variant: 'callout' }) - fireEvent.pointerEnter(wrapper) - act(() => { vi.advanceTimersByTime(500) }) - const card = screen.getByText('card body').closest('[data-placement]') as HTMLElement - fireEvent.pointerLeave(wrapper) - act(() => { vi.advanceTimersByTime(POINTER_GRACE_MS - 50) }) - fireEvent.pointerEnter(card) - act(() => { vi.advanceTimersByTime(POINTER_GRACE_MS) }) - expect(screen.getByText('card body')).toBeTruthy() - fireEvent.pointerLeave(card) - act(() => { vi.advanceTimersByTime(POINTER_GRACE_MS) }) - expect(screen.queryByText('card body')).toBeNull() - }) - - it('keeps a preview open when the pointer returns directly from its portal to the anchor', () => { - const { anchor, wrapper } = mount({ variant: 'preview' }) - fireEvent.pointerEnter(wrapper) - act(() => { vi.advanceTimersByTime(500) }) - const card = screen.getByText('card body').parentElement as HTMLElement - fireEvent.pointerEnter(card) - fireEvent(card, new MouseEvent('pointerout', { bubbles: true, relatedTarget: anchor })) - act(() => { vi.advanceTimersByTime(POINTER_GRACE_MS + 100) }) - expect(card.hasAttribute('data-closing')).toBe(false) - expect(screen.getByText('card body')).toBeTruthy() - fireEvent.pointerLeave(wrapper) - act(() => { vi.advanceTimersByTime(POINTER_GRACE_MS) }) - act(() => { vi.advanceTimersByTime(100) }) - expect(screen.queryByText('card body')).toBeNull() - }) - it('re-entering while open does not queue a second dwell', () => { const { wrapper } = mount() fireEvent.pointerEnter(wrapper) @@ -829,22 +656,6 @@ it('opens inline previews only for keyboard focus and closes when focus leaves t matches.mockRestore() }) -it('keeps an inline preview open when the pointer leaves its focused anchor', () => { - render(details} content={thumbnail} />) - const anchor = screen.getByRole('button', { name: 'details' }) - const matches = vi.spyOn(anchor, 'matches').mockReturnValue(true) - fireEvent.focus(anchor) - expect(screen.getByText('thumbnail')).toBeTruthy() - const activeElement = vi.spyOn(document, 'activeElement', 'get').mockReturnValue(anchor) - fireEvent.pointerLeave(anchor.parentElement as HTMLElement) - act(() => { vi.advanceTimersByTime(POINTER_GRACE_MS) }) - expect(screen.getByText('thumbnail')).toBeTruthy() - activeElement.mockRestore() - fireEvent.blur(anchor, { relatedTarget: document.body }) - expect(screen.queryByText('thumbnail')).toBeNull() - matches.mockRestore() -}) - it('dismisses an inline hover without anchor focus and lets the next Escape reach the owner', () => { const ownerKey = vi.fn() render(
@@ -895,46 +706,3 @@ it('flips inline media above a bottom link and constrains it without covering th expect(card.style.top).toBe('42px') expect(Number.parseFloat(card.style.maxHeight)).toBe(window.innerHeight - 50) }) - -it('closes a compact copyable card after its mouse-focused card loses the pointer', () => { - const { wrapper } = mount({ copyText: 'value' }) - fireEvent.pointerEnter(wrapper) - act(() => { vi.advanceTimersByTime(500) }) - const card = screen.getByRole('button', { name: 'Copy: value' }) - act(() => { card.focus() }) - expect(document.activeElement).toBe(card) - fireEvent.pointerLeave(card) - act(() => { vi.advanceTimersByTime(POINTER_GRACE_MS) }) - expect(screen.queryByText('card body')).toBeNull() -}) - -it('closes a callout hover after click toggling leaves mouse focus on the anchor', () => { - render(details} content="body" />) - const anchor = screen.getByRole('button', { name: 'details' }) - act(() => { anchor.focus() }) - fireEvent.click(anchor) - fireEvent.click(anchor) - fireEvent.pointerEnter(anchor.parentElement as HTMLElement) - act(() => { vi.advanceTimersByTime(500) }) - expect(screen.getByText('body')).toBeTruthy() - fireEvent.pointerLeave(anchor.parentElement as HTMLElement) - act(() => { vi.advanceTimersByTime(POINTER_GRACE_MS) }) - expect(screen.queryByText('body')).toBeNull() -}) - -it('scrolls an open callout with the focused anchor keyboard controls', () => { - render(details} content="body" />) - const anchor = screen.getByRole('button', { name: 'details' }) - fireEvent.click(anchor) - const scroll = document.querySelector('[data-hover-card-scroll]') as HTMLElement - Object.defineProperty(scroll, 'clientHeight', { configurable: true, value: 160 }) - Object.defineProperty(scroll, 'scrollHeight', { configurable: true, value: 640 }) - expect(fireEvent.keyDown(scroll, { key: 'PageDown' })).toBe(true) - expect(scroll.scrollTop).toBe(0) - for (const [key, top] of [['ArrowDown', 20], ['ArrowUp', 0], ['PageDown', 160], ['PageUp', 0], ['End', 640], ['Home', 0]] as const) { - expect(fireEvent.keyDown(anchor, { key })).toBe(false) - expect(scroll.scrollTop).toBe(top) - } - fireEvent.keyDown(anchor, { key: 'Tab' }) - expect(screen.queryByText('body')).toBeNull() -}) From 012bfe03e2b34fee592785ffae2c78ae14f1f71a Mon Sep 17 00:00:00 2001 From: yudshj Date: Wed, 21 Oct 2026 10:47:02 +0800 Subject: [PATCH 15/21] style(ui-user-questions): expand long copy with chevron icon buttons The expand and collapse controls reuse the composer's 24px icon button with the existing downward and upward chevron icons instead of text. Each carries a localized accessible name, which a portaled Tooltip shows on hover. The fade at the end of clipped copy narrows to the 24px button. --- apps/web/tests/question-composer.e2e.ts | 39 ++++++++------- .../client/ui-user-questions/README.i18n.yaml | 4 +- packages/client/ui-user-questions/README.md | 2 +- .../client/ui-user-questions/README.zh.md | 2 +- .../src/client/QuestionComposer.module.css | 41 +++++----------- .../src/client/QuestionComposer.tsx | 49 +++++++++++-------- .../ui-user-questions/src/client/locales.ts | 12 +++-- .../user-questions-composer.client.spec.tsx | 46 +++++++++-------- 8 files changed, 99 insertions(+), 96 deletions(-) diff --git a/apps/web/tests/question-composer.e2e.ts b/apps/web/tests/question-composer.e2e.ts index c96d102bdd..f6a0acfa63 100644 --- a/apps/web/tests/question-composer.e2e.ts +++ b/apps/web/tests/question-composer.e2e.ts @@ -70,12 +70,12 @@ async function capMetrics(field: Locator): Promise<{ textLines: number; scrolls: /** * 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 the - * label of its toggle while that toggle is visible. + * 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: string | undefined } { +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 @@ -86,7 +86,7 @@ function readCopy(element: HTMLElement): { lines: number; clipped: boolean; over clipped: style.maskImage !== 'none', overflows: element.scrollHeight > element.clientHeight + 0.5, toggle: toggle instanceof HTMLElement && getComputedStyle(toggle).visibility === 'visible' - ? toggle.textContent ?? undefined + ? toggle.getAttribute('aria-expanded') === 'true' ? 'collapse' : 'expand' : undefined, } } @@ -414,7 +414,7 @@ describe('web e2e: resident question composer round trip', () => { const copy = await description.evaluate(readCopy) expect(copy.lines).toBeLessThanOrEqual(2) expect(copy.clipped).toBe(copy.overflows) - expect(copy.toggle).toBe(copy.clipped ? 'More' : undefined) + expect(copy.toggle).toBe(copy.clipped ? 'expand' : undefined) } scrollStates.push(squeeze.scrolls) expect(squeeze.titleInScrollport).toBe(true) @@ -432,14 +432,14 @@ describe('web e2e: resident question composer round trip', () => { 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: 'More' }) + await expect.poll(() => blueText.evaluate(readCopy)).toEqual({ lines: 1, clipped: true, overflows: true, toggle: 'expand' }) 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: 'Show less' }) + 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: 'More' }) + 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 @@ -622,7 +622,7 @@ describe('web e2e: resident question composer round trip', () => { await composer.getByRole('heading', { name: '选择 HF provider 的思考等级?' }).waitFor() expect(await composer.getByRole('heading').evaluate(readCopy)) .toEqual({ lines: 1, clipped: false, overflows: false, toggle: undefined }) - expect(await composer.getByRole('button', { name: 'More' }).count()).toBe(0) + expect(await composer.getByRole('button', { name: 'Show the full question' }).count()).toBe(0) const rows = await composer.locator('[data-option-row]').evaluateAll(options => options.map((row) => { const label = row.querySelector('[data-option-line] > span:first-child') const description = row.querySelector('[data-option-description]') @@ -726,15 +726,15 @@ describe('web e2e: resident question composer round trip', () => { // 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: 'More' }) + .toEqual({ lines: 2, clipped: true, overflows: true, toggle: 'expand' }) expect(await descriptionOf('Explicit install').evaluate(readCopy)) - .toEqual({ lines: 1, clipped: true, overflows: true, toggle: 'More' }) + .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 ? 'More' : undefined) + expect(drawn.toggle).toBe(drawn.clipped ? 'expand' : undefined) expect(drawn.lines).toBeLessThanOrEqual(drawn.clipped ? 1 : 2) } if (viewport.width === 512) { @@ -758,15 +758,17 @@ describe('web e2e: resident question composer round trip', () => { // scrollport while navigation and submission stay reachable. const questionToggle = await toggleFor(heading) expect(await questionToggle.getAttribute('aria-expanded')).toBe('false') + expect(await questionToggle.getAttribute('aria-label')).toBe('Show the full question') await questionToggle.click() expect(await questionToggle.getAttribute('aria-expanded')).toBe('true') + expect(await questionToggle.getAttribute('aria-label')).toBe('Show less of the question') const expandedQuestion = await heading.evaluate(readCopy) - expect(expandedQuestion).toMatchObject({ clipped: false, overflows: false, toggle: 'Show less' }) + 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: 'More' }) + 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) @@ -782,10 +784,11 @@ describe('web e2e: resident question composer round trip', () => { const descriptionId = await explicitText.getAttribute('id') expect(await explicit.getAttribute('aria-describedby')).toBe(descriptionId) expect(await descriptionToggle.getAttribute('aria-describedby')).toBe(descriptionId) + expect(await descriptionToggle.getAttribute('aria-label')).toBe('Show the full description of “Explicit install”') 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: 'Show less' }) + 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) => { @@ -794,7 +797,7 @@ describe('web e2e: resident question composer round trip', () => { child.getBoundingClientRect().bottom <= row.getBoundingClientRect().bottom + 0.5) })).toBe(true) - // The toggle's ink stays readable on the card in both themes. + // 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 { @@ -814,7 +817,7 @@ describe('web e2e: resident question composer round trip', () => { const foreground = luminance(colors.text) const background = luminance(colors.surface) expect((Math.max(foreground, background) + 0.05) / (Math.min(foreground, background) + 0.05)) - .toBeGreaterThanOrEqual(4.5) + .toBeGreaterThanOrEqual(3) palette.push(colors.surface) } expect(palette[0]).not.toBe(palette[1]) @@ -826,7 +829,7 @@ describe('web e2e: resident question composer round trip', () => { } await descriptionToggle.click() await expect.poll(() => explicitText.evaluate(readCopy)) - .toEqual({ lines: 1, clipped: true, overflows: true, toggle: 'More' }) + .toEqual({ lines: 1, clipped: true, overflows: true, toggle: 'expand' }) await composer.getByRole('checkbox', { name: 'Explicit install' }).click() const answer = composer.getByRole('textbox') diff --git a/packages/client/ui-user-questions/README.i18n.yaml b/packages/client/ui-user-questions/README.i18n.yaml index 68f0c3442a..974ab9c64e 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: fda2aac4a93d6550 - zh: aa5ba8322b566f37 + en: 2173107a23e9f92a + zh: d3a6d17e50dd4b51 /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 f31e20910f..9f2fe46110 100644 --- a/packages/client/ui-user-questions/README.md +++ b/packages/client/ui-user-questions/README.md @@ -29,7 +29,7 @@ When the agent asks a question, the attached card temporarily occupies the compo ### Answering -A multi-select draft keeps its selected labels while the user opens or edits the custom answer, so its submitted item may carry both `selected` and `custom`; a single-select custom answer remains exclusive. Question detail reuses the assistant-output `MarkdownText` primitive, including its GFM rendering and untrusted-content policy. The capped card keeps navigation and submission actions fixed while the question, long detail, and choices share an internal scroll region. A question appears in full up to three rendered lines; a longer one shows two, fades at the end of the second, and offers **More** there, which 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 **More**. Expanded copy keeps **Show less** on a line below it. CSS scroll timelines decide which copy overflows; a browser without them shows all copy in full. "Skip" retains other drafts and emits the existing blank `{ selected: [] }` result for that item. Close is not an answer: a question the Host named by tool call only leaves the composer seat, and its `ask_user_question` tool call row brings the panel back; a request that carries no tool call has no row to return from, so closing it rejects the whole wait as `ASK_CANCELLED`. +A multi-select draft keeps its selected labels while the user opens or edits the custom answer, so its submitted item may carry both `selected` and `custom`; a single-select custom answer remains exclusive. Question detail reuses the assistant-output `MarkdownText` primitive, including its GFM rendering and untrusted-content policy. The capped card keeps navigation and submission actions fixed while the question, long detail, and choices share an internal scroll region. A question appears in full up to three rendered lines; a longer one shows two, fades at the end of the second, and places a downward-chevron expand button there that expands it in place. Option descriptions use 13px text with a 20px line height and sit beside their label when they fit, otherwise below it; a description appears in full up to two lines, and a longer one shows one line with the same fade and expand button. Expanded copy keeps an upward-chevron collapse button on a line below it. Both buttons carry a localized accessible name and a hover tooltip. CSS scroll timelines decide which copy overflows; a browser without them shows all copy in full. "Skip" retains other drafts and emits the existing blank `{ selected: [] }` result for that item. Close is not an answer: a question the Host named by tool call only leaves the composer seat, and its `ask_user_question` tool call row brings the panel back; a request that carries no tool call has no row to return from, so closing it rejects the whole wait as `ASK_CANCELLED`. ### 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 c487cdc47b..2ac78bf4c1 100644 --- a/packages/client/ui-user-questions/README.zh.md +++ b/packages/client/ui-user-questions/README.zh.md @@ -29,7 +29,7 @@ Web 客户端在聊天输入框旁显示 agent(智能体)的提问。用户 ### 作答 -用户打开或编辑自定义答案时,多选题草稿会保留已选中的标签,因此提交项可以同时携带 `selected` 与 `custom`;单选题的自定义答案仍保持互斥。问题详情复用助手输出的 `MarkdownText` 原语,包括其 GFM 渲染与不受信任内容策略。限高卡片保持导航与提交动作固定,问题正文、超长详情和选项共享内部滚动区。问题在实际排版不超过三行时完整显示;更长的问题只显示两行,第二行末尾渐隐并提供「全部」,点击后原地展开。选项说明使用 13px 字号和 20px 行高,放得下时排在标签旁,否则排在标签下方;说明不超过两行时完整显示,更长的说明只显示一行,同样渐隐并提供「全部」。展开后的文字下方单独一行显示「收起」。由 CSS 滚动时间线判断文字是否溢出;不支持该特性的浏览器完整显示全部文字。「跳过」会保留其他草稿,并为该项发出既有的空 `{ selected: [] }` 结果。关闭不是一种回答:Host 以工具调用命名的问题只是离开编辑器位置,其 `ask_user_question` 工具调用行会把面板带回来;不携带工具调用的请求没有可返回的行,关闭它仍以 `ASK_CANCELLED` 拒绝整个等待。 +用户打开或编辑自定义答案时,多选题草稿会保留已选中的标签,因此提交项可以同时携带 `selected` 与 `custom`;单选题的自定义答案仍保持互斥。问题详情复用助手输出的 `MarkdownText` 原语,包括其 GFM 渲染与不受信任内容策略。限高卡片保持导航与提交动作固定,问题正文、超长详情和选项共享内部滚动区。问题在实际排版不超过三行时完整显示;更长的问题只显示两行,第二行末尾渐隐并放置向下箭头的展开按钮,点击后原地展开。选项说明使用 13px 字号和 20px 行高,放得下时排在标签旁,否则排在标签下方;说明不超过两行时完整显示,更长的说明只显示一行,同样渐隐并放置展开按钮。展开后的文字下方单独一行显示向上箭头的收起按钮。两种按钮都有本地化的无障碍名称和悬停提示。由 CSS 滚动时间线判断文字是否溢出;不支持该特性的浏览器完整显示全部文字。「跳过」会保留其他草稿,并为该项发出既有的空 `{ selected: [] }` 结果。关闭不是一种回答:Host 以工具调用命名的问题只是离开编辑器位置,其 `ask_user_question` 工具调用行会把面板带回来;不携带工具调用的请求没有可返回的行,关闭它仍以 `ASK_CANCELLED` 拒绝整个等待。 ### 回看已结束的问题 diff --git a/packages/client/ui-user-questions/src/client/QuestionComposer.module.css b/packages/client/ui-user-questions/src/client/QuestionComposer.module.css index a2de7c3ff6..fa75de76e4 100644 --- a/packages/client/ui-user-questions/src/client/QuestionComposer.module.css +++ b/packages/client/ui-user-questions/src/client/QuestionComposer.module.css @@ -109,7 +109,7 @@ /* 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 toggle there. Each box keeps its full budget, plus a 2px + 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. */ @@ -121,7 +121,7 @@ } .questionTitle[data-expanded] { - padding-bottom: 22px; + padding-bottom: 24px; } .titlePreview { @@ -142,7 +142,7 @@ @keyframes question-clipped { from, to { margin-bottom: calc(-1lh - 2px); - mask-image: linear-gradient(#000, #000), linear-gradient(to right, #000 calc(100% - 72px), transparent calc(100% - 48px)); + 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; @@ -151,8 +151,8 @@ .questionToggle { position: absolute; - right: -6px; - bottom: 0; + right: -4px; + bottom: -1px; animation-name: copy-toggle-shown; animation-fill-mode: both; animation-timeline: --question-copy; @@ -400,7 +400,7 @@ @keyframes description-clipped { from, to { margin-bottom: calc(-1lh - 2px); - mask-image: linear-gradient(to right, #000 calc(100% - 72px), transparent calc(100% - 48px)); + mask-image: linear-gradient(to right, #000 calc(100% - 56px), transparent calc(100% - 28px)); mask-size: 100% 1lh; mask-repeat: no-repeat; } @@ -408,16 +408,16 @@ .descriptionToggle { position: absolute; - right: 6px; - bottom: 7px; + right: 8px; + bottom: 6px; animation-name: copy-toggle-shown; animation-fill-mode: both; animation-timeline: --description-copy; } -/* Expanded copy keeps its toggle on a line of its own below the text. */ +/* Expanded copy keeps its collapse button on a line of its own below the text. */ .optionRow[data-expanded] .option { - padding-bottom: 30px; + padding-bottom: 32px; } .questionTitle[data-expanded] .titlePreview, @@ -426,18 +426,8 @@ animation: none; } +/* Shares .iconButton's 24px round control; hidden until its copy overflows or expands. */ .copyToggle { - padding: 1px 6px; - border: 0; - border-radius: var(--dsw-radius-sm); - background: transparent; - color: var(--dsw-alias-label-secondary); - font: inherit; - font-size: 13px; - line-height: 20px; - font-weight: 400; - white-space: nowrap; - cursor: pointer; visibility: hidden; } @@ -453,13 +443,6 @@ animation: none; } -@media (hover: hover) { - .copyToggle:hover { - background: var(--dsw-alias-interactive-bg-hover); - color: var(--dsw-alias-label-primary); - } -} - /* Without scroll timelines clipped copy cannot be told apart, so it shows in full. */ @supports not (animation-timeline: scroll()) { .titlePreview, @@ -640,7 +623,7 @@ } .descriptionToggle { - right: 0; + right: 2px; } .footer { diff --git a/packages/client/ui-user-questions/src/client/QuestionComposer.tsx b/packages/client/ui-user-questions/src/client/QuestionComposer.tsx index cfd8eeed0c..b8cdeae85b 100644 --- a/packages/client/ui-user-questions/src/client/QuestionComposer.tsx +++ b/packages/client/ui-user-questions/src/client/QuestionComposer.tsx @@ -6,7 +6,7 @@ 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' @@ -250,6 +250,8 @@ function QuestionFlow({ pending, t, useStore, useQuestionCard, actions }: Questi // CSS decides which copy is clipped; only the user's expansions are state. const [expanded, setExpanded] = useState(NOTHING_EXPANDED) + const questionToggleLabel = t(expanded.has('question') ? 'question.collapse' : 'question.expand') + const toggleCopy = (copy: 'question' | number): void => { setExpanded((current) => { const next = new Set(current) @@ -524,15 +526,18 @@ function QuestionFlow({ pending, t, useStore, useQuestionCard, actions }: Questi > {question.question} - + + +
{question.detail !== undefined && (
@@ -543,6 +548,7 @@ function QuestionFlow({ pending, t, useStore, useQuestionCard, actions }: Questi const display = parseRecommendedLabel(option.label) const hasDescription = option.description !== undefined && option.description.trim() !== '' const descriptionExpanded = expanded.has(optionIndex) + const descriptionToggleLabel = t(descriptionExpanded ? 'option.collapse' : 'option.expand', { option: display.label }) const descriptionId = `${descriptionIdPrefix}-question-${String(index)}-option-${String(optionIndex)}-description` return (
{hasDescription && ( - + + + )}
) diff --git a/packages/client/ui-user-questions/src/client/locales.ts b/packages/client/ui-user-questions/src/client/locales.ts index 4995c365e4..0e8d3e6bdf 100644 --- a/packages/client/ui-user-questions/src/client/locales.ts +++ b/packages/client/ui-user-questions/src/client/locales.ts @@ -25,8 +25,10 @@ export const zh = { 'nav.maximize': '展开问题卡片', 'nav.cancel': '放弃整组问题', 'nav.close': '收起问题面板,可从工具调用重新打开', - 'text.expand': '全部', - 'text.collapse': '收起', + 'question.expand': '展开完整问题', + 'question.collapse': '收起问题', + 'option.expand': '展开“{option}”的完整说明', + 'option.collapse': '收起“{option}”的说明', 'option.recommended': '推荐', 'custom.placeholder': '输入你的答案', 'action.skip': '跳过', @@ -65,8 +67,10 @@ 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': 'More', - 'text.collapse': 'Show less', + 'question.expand': 'Show the full question', + 'question.collapse': 'Show less of the question', + 'option.expand': 'Show the full description of “{option}”', + 'option.collapse': 'Show less of “{option}”', 'option.recommended': 'Recommended', 'custom.placeholder': 'Type your answer', 'action.skip': 'Skip', 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 c1ab2c6f7f..7562833067 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 @@ -315,32 +315,34 @@ describe('QuestionComposer', () => { // CSS shows each toggle only while its copy overflows; every candidate is rendered. const heading = screen.getByRole('heading', { name: 'How should the release graph change?' }) - const [questionToggle, descriptionToggle, ...rest] = screen.getAllByRole('button', { name: '全部' }) - expect(rest).toHaveLength(0) - expect(questionToggle?.getAttribute('aria-controls')).toBe(heading.id) + const questionToggle = screen.getByRole('button', { name: '展开完整问题' }) + const descriptionToggle = screen.getByRole('button', { name: '展开“Explicit install”的完整说明' }) + expect(screen.getAllByRole('button', { name: /^展开(完整问题|“)/ })).toEqual([questionToggle, descriptionToggle]) + 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') + 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 as HTMLElement) - expect(descriptionToggle?.getAttribute('aria-expanded')).toBe('true') - expect(descriptionToggle?.textContent).toBe('收起') + fireEvent.click(descriptionToggle) + expect(descriptionToggle.getAttribute('aria-expanded')).toBe('true') + expect(descriptionToggle.getAttribute('aria-label')).toBe('收起“Explicit install”的说明') 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') + expect(questionToggle.getAttribute('aria-expanded')).toBe('false') - fireEvent.click(questionToggle as HTMLElement) - expect(questionToggle?.getAttribute('aria-expanded')).toBe('true') + 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 as HTMLElement) - fireEvent.click(descriptionToggle as HTMLElement) + fireEvent.click(questionToggle) + fireEvent.click(descriptionToggle) expect(heading.parentElement?.hasAttribute('data-expanded')).toBe(false) expect(text.closest('[data-option-row]')?.hasAttribute('data-expanded')).toBe(false) - expect(screen.getAllByRole('button', { name: '全部' })).toEqual([questionToggle, descriptionToggle]) + expect(screen.getAllByRole('button', { name: /^展开(完整问题|“)/ })).toEqual([questionToggle, descriptionToggle]) }) it('resets scrolling and expanded copy when the question changes', () => { @@ -361,8 +363,9 @@ describe('QuestionComposer', () => { expect(screen.getByRole('region', { name: 'First question?' })).toBeTruthy() const firstScroll = screen.getByText('First question?').closest('[data-question-scroll]') as HTMLElement firstScroll.scrollTop = 80 - for (const toggle of screen.getAllByRole('button', { name: '全部' })) fireEvent.click(toggle) - expect(screen.getAllByRole('button', { name: '收起' })).toHaveLength(2) + for (const toggle of screen.getAllByRole('button', { name: /^展开(完整问题|“)/ })) fireEvent.click(toggle) + expect(screen.getByRole('button', { name: '收起问题' }).getAttribute('aria-expanded')).toBe('true') + expect(screen.getByRole('button', { name: '收起“Continue”的说明' }).getAttribute('aria-expanded')).toBe('true') fireEvent.click(screen.getByRole('radio', { name: 'Continue' })) const secondScroll = screen.getByText('Second question?').closest('[data-question-scroll]') as HTMLElement @@ -370,11 +373,12 @@ describe('QuestionComposer', () => { expect(screen.getByRole('region', { name: 'Second question?' })).toBeTruthy() expect(secondScroll).toBe(firstScroll) expect(secondScroll.scrollTop).toBe(0) - expect(screen.queryByRole('button', { name: '收起' })).toBeNull() + expect(screen.queryByRole('button', { name: '收起问题' })).toBeNull() + expect(screen.queryByRole('button', { name: '收起“Continue”的说明' })).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) + const descriptionToggle = screen.getByRole('button', { name: '展开“Continue”的完整说明' }) + expect(document.getElementById(descriptionToggle.getAttribute('aria-controls') ?? '')?.textContent).toBe(secondDescription) }) it('renders plan detail through the shared assistant Markdown primitive', () => { @@ -948,7 +952,7 @@ describe('review card', () => { expect(chosen.getAttribute('aria-checked')).toBe('true') expect(chosen.disabled).toBe(true) // Long copy stays readable on a frozen card. - expect(screen.getAllByRole('button', { name: '全部' }).map(toggle => toggle.disabled)).toEqual([false, false, false]) + expect(screen.getAllByRole('button', { name: /^展开(完整问题|“)/ }).map(toggle => toggle.disabled)).toEqual([false, false, false]) // Nothing is left to send, and the unused free-text field would read as // somewhere to type. expect(screen.queryByRole('button', { name: '跳过' })).toBeNull() From 5d5bd8eb412c1a520fa8ce95a5139afdb643fde3 Mon Sep 17 00:00:00 2001 From: yudshj Date: Wed, 21 Oct 2026 11:03:13 +0800 Subject: [PATCH 16/21] test(web): re-check long question copy across chat and window widths A long question with long, width-sensitive, and short option descriptions is driven through the transcript width handle at its narrowest and widest positions, an opened and collapsed right sidebar, and a smaller and restored window. At each settled width every collapsed copy follows its own overflow, the expanded description stays expanded, and the actions stay reachable; collapsing the sidebar or restoring the window returns the wide state exactly. --- apps/web/tests/question-composer.e2e.ts | 153 ++++++++++++++++++++++++ 1 file changed, 153 insertions(+) diff --git a/apps/web/tests/question-composer.e2e.ts b/apps/web/tests/question-composer.e2e.ts index f6a0acfa63..a120479c59 100644 --- a/apps/web/tests/question-composer.e2e.ts +++ b/apps/web/tests/question-composer.e2e.ts @@ -861,6 +861,159 @@ describe('web e2e: resident question composer round trip', () => { } }, 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, + 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('[data-question-scroll]') + const submit = [...section.querySelectorAll('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>['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', () => { From f8a094e4656594d9ec99feb688e5559268e23df2 Mon Sep 17 00:00:00 2001 From: yudshj Date: Wed, 21 Oct 2026 11:48:30 +0800 Subject: [PATCH 17/21] fix(ui-user-questions): label every copy toggle Expand or Collapse Option names made the toggle tooltips as long and unpredictable as the option labels themselves. Every question and description toggle now uses the same Expand and Collapse copy, and the question toggle, like the description toggles, is described by the text it controls. --- apps/web/tests/question-composer.e2e.ts | 9 ++++--- .../client/ui-user-questions/README.i18n.yaml | 4 +-- packages/client/ui-user-questions/README.md | 2 +- .../client/ui-user-questions/README.zh.md | 2 +- .../src/client/QuestionComposer.tsx | 5 ++-- .../ui-user-questions/src/client/locales.ts | 12 +++------ .../user-questions-composer.client.spec.tsx | 27 +++++++++---------- 7 files changed, 29 insertions(+), 32 deletions(-) diff --git a/apps/web/tests/question-composer.e2e.ts b/apps/web/tests/question-composer.e2e.ts index a120479c59..0861e3a8ba 100644 --- a/apps/web/tests/question-composer.e2e.ts +++ b/apps/web/tests/question-composer.e2e.ts @@ -622,7 +622,7 @@ describe('web e2e: resident question composer round trip', () => { await composer.getByRole('heading', { name: '选择 HF provider 的思考等级?' }).waitFor() expect(await composer.getByRole('heading').evaluate(readCopy)) .toEqual({ lines: 1, clipped: false, overflows: false, toggle: undefined }) - expect(await composer.getByRole('button', { name: 'Show the full question' }).count()).toBe(0) + expect(await composer.getByRole('button', { name: 'Expand', exact: true }).count()).toBe(0) const rows = await composer.locator('[data-option-row]').evaluateAll(options => options.map((row) => { const label = row.querySelector('[data-option-line] > span:first-child') const description = row.querySelector('[data-option-description]') @@ -758,10 +758,11 @@ describe('web e2e: resident question composer round trip', () => { // scrollport while navigation and submission stay reachable. const questionToggle = await toggleFor(heading) expect(await questionToggle.getAttribute('aria-expanded')).toBe('false') - expect(await questionToggle.getAttribute('aria-label')).toBe('Show the full question') + expect(await questionToggle.getAttribute('aria-label')).toBe('Expand') + expect(await questionToggle.getAttribute('aria-describedby')).toBe(await heading.getAttribute('id')) await questionToggle.click() expect(await questionToggle.getAttribute('aria-expanded')).toBe('true') - expect(await questionToggle.getAttribute('aria-label')).toBe('Show less of the question') + expect(await questionToggle.getAttribute('aria-label')).toBe('Collapse') const expandedQuestion = await heading.evaluate(readCopy) expect(expandedQuestion).toMatchObject({ clipped: false, overflows: false, toggle: 'collapse' }) expect(expandedQuestion.lines).toBeGreaterThan(3) @@ -784,7 +785,7 @@ describe('web e2e: resident question composer round trip', () => { const descriptionId = await explicitText.getAttribute('id') expect(await explicit.getAttribute('aria-describedby')).toBe(descriptionId) expect(await descriptionToggle.getAttribute('aria-describedby')).toBe(descriptionId) - expect(await descriptionToggle.getAttribute('aria-label')).toBe('Show the full description of “Explicit install”') + expect(await descriptionToggle.getAttribute('aria-label')).toBe('Expand') await descriptionToggle.click() expect(await explicit.getAttribute('aria-checked')).toBe('false') const expandedDescription = await explicitText.evaluate(readCopy) diff --git a/packages/client/ui-user-questions/README.i18n.yaml b/packages/client/ui-user-questions/README.i18n.yaml index 974ab9c64e..09db781665 100644 --- a/packages/client/ui-user-questions/README.i18n.yaml +++ b/packages/client/ui-user-questions/README.i18n.yaml @@ -18,8 +18,8 @@ en: 8d3815c19858ac85 zh: 94fc905fa202d334 /deepseek-ai-dsh-client-ui-user-questions/use-this-package/answering: - en: 2173107a23e9f92a - zh: d3a6d17e50dd4b51 + en: 244a344475118aa3 + zh: 8ca447340de5d341 /deepseek-ai-dsh-client-ui-user-questions/use-this-package/reading-a-settled-question-back: en: 10975f68ff9c6fcc zh: 92246aadc363f0eb diff --git a/packages/client/ui-user-questions/README.md b/packages/client/ui-user-questions/README.md index 9f2fe46110..d730c104df 100644 --- a/packages/client/ui-user-questions/README.md +++ b/packages/client/ui-user-questions/README.md @@ -29,7 +29,7 @@ When the agent asks a question, the attached card temporarily occupies the compo ### Answering -A multi-select draft keeps its selected labels while the user opens or edits the custom answer, so its submitted item may carry both `selected` and `custom`; a single-select custom answer remains exclusive. Question detail reuses the assistant-output `MarkdownText` primitive, including its GFM rendering and untrusted-content policy. The capped card keeps navigation and submission actions fixed while the question, long detail, and choices share an internal scroll region. A question appears in full up to three rendered lines; a longer one shows two, fades at the end of the second, and places a downward-chevron expand button there that expands it in place. Option descriptions use 13px text with a 20px line height and sit beside their label when they fit, otherwise below it; a description appears in full up to two lines, and a longer one shows one line with the same fade and expand button. Expanded copy keeps an upward-chevron collapse button on a line below it. Both buttons carry a localized accessible name and a hover tooltip. CSS scroll timelines decide which copy overflows; a browser without them shows all copy in full. "Skip" retains other drafts and emits the existing blank `{ selected: [] }` result for that item. Close is not an answer: a question the Host named by tool call only leaves the composer seat, and its `ask_user_question` tool call row brings the panel back; a request that carries no tool call has no row to return from, so closing it rejects the whole wait as `ASK_CANCELLED`. +A multi-select draft keeps its selected labels while the user opens or edits the custom answer, so its submitted item may carry both `selected` and `custom`; a single-select custom answer remains exclusive. Question detail reuses the assistant-output `MarkdownText` primitive, including its GFM rendering and untrusted-content policy. The capped card keeps navigation and submission actions fixed while the question, long detail, and choices share an internal scroll region. A question appears in full up to three rendered lines; a longer one shows two, fades at the end of the second, and places a downward-chevron expand button there that expands it in place. Option descriptions use 13px text with a 20px line height and sit beside their label when they fit, otherwise below it; a description appears in full up to two lines, and a longer one shows one line with the same fade and expand button. Expanded copy keeps an upward-chevron collapse button on a line below it. Both buttons are named **Expand** or **Collapse**, show that name in a hover tooltip, and are described by the text they control. CSS scroll timelines decide which copy overflows; a browser without them shows all copy in full. "Skip" retains other drafts and emits the existing blank `{ selected: [] }` result for that item. Close is not an answer: a question the Host named by tool call only leaves the composer seat, and its `ask_user_question` tool call row brings the panel back; a request that carries no tool call has no row to return from, so closing it rejects the whole wait as `ASK_CANCELLED`. ### Reading a settled question back diff --git a/packages/client/ui-user-questions/README.zh.md b/packages/client/ui-user-questions/README.zh.md index 2ac78bf4c1..dba1010bec 100644 --- a/packages/client/ui-user-questions/README.zh.md +++ b/packages/client/ui-user-questions/README.zh.md @@ -29,7 +29,7 @@ Web 客户端在聊天输入框旁显示 agent(智能体)的提问。用户 ### 作答 -用户打开或编辑自定义答案时,多选题草稿会保留已选中的标签,因此提交项可以同时携带 `selected` 与 `custom`;单选题的自定义答案仍保持互斥。问题详情复用助手输出的 `MarkdownText` 原语,包括其 GFM 渲染与不受信任内容策略。限高卡片保持导航与提交动作固定,问题正文、超长详情和选项共享内部滚动区。问题在实际排版不超过三行时完整显示;更长的问题只显示两行,第二行末尾渐隐并放置向下箭头的展开按钮,点击后原地展开。选项说明使用 13px 字号和 20px 行高,放得下时排在标签旁,否则排在标签下方;说明不超过两行时完整显示,更长的说明只显示一行,同样渐隐并放置展开按钮。展开后的文字下方单独一行显示向上箭头的收起按钮。两种按钮都有本地化的无障碍名称和悬停提示。由 CSS 滚动时间线判断文字是否溢出;不支持该特性的浏览器完整显示全部文字。「跳过」会保留其他草稿,并为该项发出既有的空 `{ selected: [] }` 结果。关闭不是一种回答:Host 以工具调用命名的问题只是离开编辑器位置,其 `ask_user_question` 工具调用行会把面板带回来;不携带工具调用的请求没有可返回的行,关闭它仍以 `ASK_CANCELLED` 拒绝整个等待。 +用户打开或编辑自定义答案时,多选题草稿会保留已选中的标签,因此提交项可以同时携带 `selected` 与 `custom`;单选题的自定义答案仍保持互斥。问题详情复用助手输出的 `MarkdownText` 原语,包括其 GFM 渲染与不受信任内容策略。限高卡片保持导航与提交动作固定,问题正文、超长详情和选项共享内部滚动区。问题在实际排版不超过三行时完整显示;更长的问题只显示两行,第二行末尾渐隐并放置向下箭头的展开按钮,点击后原地展开。选项说明使用 13px 字号和 20px 行高,放得下时排在标签旁,否则排在标签下方;说明不超过两行时完整显示,更长的说明只显示一行,同样渐隐并放置展开按钮。展开后的文字下方单独一行显示向上箭头的收起按钮。两种按钮名为「展开」或「收起」,悬停时显示同样的提示,并以其控制的文字作为无障碍描述。由 CSS 滚动时间线判断文字是否溢出;不支持该特性的浏览器完整显示全部文字。「跳过」会保留其他草稿,并为该项发出既有的空 `{ selected: [] }` 结果。关闭不是一种回答:Host 以工具调用命名的问题只是离开编辑器位置,其 `ask_user_question` 工具调用行会把面板带回来;不携带工具调用的请求没有可返回的行,关闭它仍以 `ASK_CANCELLED` 拒绝整个等待。 ### 回看已结束的问题 diff --git a/packages/client/ui-user-questions/src/client/QuestionComposer.tsx b/packages/client/ui-user-questions/src/client/QuestionComposer.tsx index b8cdeae85b..5c00ccbf53 100644 --- a/packages/client/ui-user-questions/src/client/QuestionComposer.tsx +++ b/packages/client/ui-user-questions/src/client/QuestionComposer.tsx @@ -250,7 +250,7 @@ function QuestionFlow({ pending, t, useStore, useQuestionCard, actions }: Questi // CSS decides which copy is clipped; only the user's expansions are state. const [expanded, setExpanded] = useState(NOTHING_EXPANDED) - const questionToggleLabel = t(expanded.has('question') ? 'question.collapse' : 'question.expand') + const questionToggleLabel = t(expanded.has('question') ? 'text.collapse' : 'text.expand') const toggleCopy = (copy: 'question' | number): void => { setExpanded((current) => { @@ -533,6 +533,7 @@ function QuestionFlow({ pending, t, useStore, useQuestionCard, actions }: Questi aria-label={questionToggleLabel} aria-expanded={expanded.has('question')} aria-controls={`question-${pending.key}-${String(index)}`} + aria-describedby={`question-${pending.key}-${String(index)}`} onClick={() => { toggleCopy('question') }} > {expanded.has('question') ? : } @@ -548,7 +549,7 @@ function QuestionFlow({ pending, t, useStore, useQuestionCard, actions }: Questi const display = parseRecommendedLabel(option.label) const hasDescription = option.description !== undefined && option.description.trim() !== '' const descriptionExpanded = expanded.has(optionIndex) - const descriptionToggleLabel = t(descriptionExpanded ? 'option.collapse' : 'option.expand', { option: display.label }) + const descriptionToggleLabel = t(descriptionExpanded ? 'text.collapse' : 'text.expand') const descriptionId = `${descriptionIdPrefix}-question-${String(index)}-option-${String(optionIndex)}-description` return (
{ // CSS shows each toggle only while its copy overflows; every candidate is rendered. const heading = screen.getByRole('heading', { name: 'How should the release graph change?' }) - const questionToggle = screen.getByRole('button', { name: '展开完整问题' }) - const descriptionToggle = screen.getByRole('button', { name: '展开“Explicit install”的完整说明' }) - expect(screen.getAllByRole('button', { name: /^展开(完整问题|“)/ })).toEqual([questionToggle, descriptionToggle]) + const [questionToggle, descriptionToggle, ...rest] = screen.getAllByRole('button', { name: '展开' }) + expect(rest).toHaveLength(0) + if (questionToggle === undefined || descriptionToggle === undefined) throw new Error('Both copy toggles must render') + expect(questionToggle.getAttribute('aria-describedby')).toBe(heading.id) expect(questionToggle.getAttribute('aria-controls')).toBe(heading.id) const option = screen.getByRole('radio', { name: 'Explicit install' }) const text = screen.getByText(description) @@ -328,7 +329,7 @@ describe('QuestionComposer', () => { fireEvent.click(descriptionToggle) expect(descriptionToggle.getAttribute('aria-expanded')).toBe('true') - expect(descriptionToggle.getAttribute('aria-label')).toBe('收起“Explicit install”的说明') + expect(descriptionToggle.getAttribute('aria-label')).toBe('收起') expect(text.closest('[data-option-row]')?.hasAttribute('data-expanded')).toBe(true) expect(screen.getByText(description)).toBe(text) expect(option.getAttribute('aria-checked')).toBe('false') @@ -336,13 +337,13 @@ describe('QuestionComposer', () => { fireEvent.click(questionToggle) expect(questionToggle.getAttribute('aria-expanded')).toBe('true') - expect(questionToggle.getAttribute('aria-label')).toBe('收起问题') + expect(questionToggle.getAttribute('aria-label')).toBe('收起') expect(heading.parentElement?.hasAttribute('data-expanded')).toBe(true) fireEvent.click(questionToggle) fireEvent.click(descriptionToggle) expect(heading.parentElement?.hasAttribute('data-expanded')).toBe(false) expect(text.closest('[data-option-row]')?.hasAttribute('data-expanded')).toBe(false) - expect(screen.getAllByRole('button', { name: /^展开(完整问题|“)/ })).toEqual([questionToggle, descriptionToggle]) + expect(screen.getAllByRole('button', { name: '展开' })).toEqual([questionToggle, descriptionToggle]) }) it('resets scrolling and expanded copy when the question changes', () => { @@ -363,9 +364,8 @@ describe('QuestionComposer', () => { expect(screen.getByRole('region', { name: 'First question?' })).toBeTruthy() const firstScroll = screen.getByText('First question?').closest('[data-question-scroll]') as HTMLElement firstScroll.scrollTop = 80 - for (const toggle of screen.getAllByRole('button', { name: /^展开(完整问题|“)/ })) fireEvent.click(toggle) - expect(screen.getByRole('button', { name: '收起问题' }).getAttribute('aria-expanded')).toBe('true') - expect(screen.getByRole('button', { name: '收起“Continue”的说明' }).getAttribute('aria-expanded')).toBe('true') + for (const toggle of screen.getAllByRole('button', { name: '展开' })) fireEvent.click(toggle) + expect(screen.getAllByRole('button', { name: '收起' }).map(toggle => toggle.getAttribute('aria-expanded'))).toEqual(['true', 'true']) fireEvent.click(screen.getByRole('radio', { name: 'Continue' })) const secondScroll = screen.getByText('Second question?').closest('[data-question-scroll]') as HTMLElement @@ -373,12 +373,11 @@ describe('QuestionComposer', () => { expect(screen.getByRole('region', { name: 'Second question?' })).toBeTruthy() expect(secondScroll).toBe(firstScroll) expect(secondScroll.scrollTop).toBe(0) - expect(screen.queryByRole('button', { name: '收起问题' })).toBeNull() - expect(screen.queryByRole('button', { name: '收起“Continue”的说明' })).toBeNull() + expect(screen.queryByRole('button', { name: '收起' })).toBeNull() expect(secondScroll.querySelector('[data-expanded]')).toBeNull() expect(screen.queryByText(firstDescription)).toBeNull() - const descriptionToggle = screen.getByRole('button', { name: '展开“Continue”的完整说明' }) - expect(document.getElementById(descriptionToggle.getAttribute('aria-controls') ?? '')?.textContent).toBe(secondDescription) + const descriptionToggle = screen.getAllByRole('button', { name: '展开' })[1] + expect(document.getElementById(descriptionToggle?.getAttribute('aria-controls') ?? '')?.textContent).toBe(secondDescription) }) it('renders plan detail through the shared assistant Markdown primitive', () => { @@ -952,7 +951,7 @@ describe('review card', () => { expect(chosen.getAttribute('aria-checked')).toBe('true') expect(chosen.disabled).toBe(true) // Long copy stays readable on a frozen card. - expect(screen.getAllByRole('button', { name: /^展开(完整问题|“)/ }).map(toggle => toggle.disabled)).toEqual([false, false, false]) + expect(screen.getAllByRole('button', { name: '展开' }).map(toggle => toggle.disabled)).toEqual([false, false, false]) // Nothing is left to send, and the unused free-text field would read as // somewhere to type. expect(screen.queryByRole('button', { name: '跳过' })).toBeNull() From fb290b388d92a73cb9ee189c8c44242bf8ec10fa Mon Sep 17 00:00:00 2001 From: yudshj Date: Wed, 21 Oct 2026 12:02:02 +0800 Subject: [PATCH 18/21] fix(ui-user-questions): clip dropped lines from hit testing A collapsed question or description keeps its full line budget and only masked its dropped line, so that transparent line still took clicks: it covered the first line of the question detail and the top edge of the custom answer row. clip-path now removes the line from hit testing as well as from view, and Web e2e hit tests pin both targets. Expansions also record the question they belong to and apply only while that question is shown, so a position change from another surface of the same Session no longer carries them to a different question. `.title` returns to its base margin; `.questionTitle` owns the flex sizing. --- apps/web/tests/question-composer.e2e.ts | 16 +++++++++++ .../src/client/QuestionComposer.module.css | 9 ++++--- .../src/client/QuestionComposer.tsx | 27 +++++++++++-------- .../question-composer-styles.client.spec.ts | 6 +++-- .../user-questions-composer.client.spec.tsx | 18 +++++++++++++ 5 files changed, 59 insertions(+), 17 deletions(-) diff --git a/apps/web/tests/question-composer.e2e.ts b/apps/web/tests/question-composer.e2e.ts index 0861e3a8ba..1e06d0cbb6 100644 --- a/apps/web/tests/question-composer.e2e.ts +++ b/apps/web/tests/question-composer.e2e.ts @@ -433,6 +433,15 @@ describe('web e2e: resident question composer round trip', () => { 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') @@ -754,6 +763,13 @@ describe('web e2e: resident question composer round trip', () => { 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) 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 fa75de76e4..5c530aef72 100644 --- a/packages/client/ui-user-questions/src/client/QuestionComposer.module.css +++ b/packages/client/ui-user-questions/src/client/QuestionComposer.module.css @@ -99,8 +99,7 @@ } .title { - flex-shrink: 0; - margin: 5px 24px 0; + margin: 0; font-size: 16px; line-height: 22px; font-weight: 500; @@ -112,7 +111,8 @@ 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. */ + 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; @@ -142,6 +142,7 @@ @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; @@ -160,7 +161,6 @@ .cardMinimized .title { display: -webkit-box; - margin: 0; overflow: hidden; -webkit-box-orient: vertical; -webkit-line-clamp: 2; @@ -400,6 +400,7 @@ @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; diff --git a/packages/client/ui-user-questions/src/client/QuestionComposer.tsx b/packages/client/ui-user-questions/src/client/QuestionComposer.tsx index 5c00ccbf53..0baf6e54c5 100644 --- a/packages/client/ui-user-questions/src/client/QuestionComposer.tsx +++ b/packages/client/ui-user-questions/src/client/QuestionComposer.tsx @@ -27,10 +27,15 @@ const REMOVED_CARD: QuestionCardSnapshot = { state: 'open', waitState: 'counting', countdown: undefined, channel: 'none', closed: true, } -/** Long copy the user expanded: the question itself or an option description by index. */ -type ExpandedCopy = ReadonlySet<'question' | number> +/** 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 = new Set() +const NOTHING_EXPANDED: ExpandedCopy['copies'] = new Set() /** * Split the conventional recommendation suffix without changing the answer value. @@ -247,24 +252,24 @@ 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. - const [expanded, setExpanded] = useState(NOTHING_EXPANDED) + // 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({ 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 => { - setExpanded((current) => { - const next = new Set(current) - if (!next.delete(copy)) next.add(copy) - return next - }) + 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 - setExpanded(NOTHING_EXPANDED) + setExpandedCopy({ index: nextIndex, copies: NOTHING_EXPANDED }) } actions.replace(pending.key, { index: nextIndex, diff --git a/packages/client/ui-user-questions/tests/question-composer-styles.client.spec.ts b/packages/client/ui-user-questions/tests/question-composer-styles.client.spec.ts index 4774ce8e67..7778302296 100644 --- a/packages/client/ui-user-questions/tests/question-composer-styles.client.spec.ts +++ b/packages/client/ui-user-questions/tests/question-composer-styles.client.spec.ts @@ -28,7 +28,7 @@ function keyframes(name: string): string[] { describe('QuestionComposer long-copy layout', () => { it('reads question overflow against three lines and keeps two once it overflows', () => { - expect(declarations('.questionTitle')).toContain('timeline-scope: --question-copy') + 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', @@ -41,6 +41,7 @@ describe('QuestionComposer long-copy layout', () => { ])) 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', ])) @@ -69,6 +70,7 @@ describe('QuestionComposer long-copy layout', () => { ])) 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([ @@ -104,6 +106,6 @@ describe('QuestionComposer long-copy layout', () => { 'min-height: 0', 'overflow-y: auto', ])) - expect(declarations('.title')).toContain('flex-shrink: 0') + expect(declarations('.questionTitle')).toContain('flex-shrink: 0') }) }) 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 86650560fb..dfa9541320 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 @@ -380,6 +380,24 @@ describe('QuestionComposer', () => { 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() + 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', From d12498798aaf223f0e6552be6324c870ea3afe44 Mon Sep 17 00:00:00 2001 From: yudshj Date: Wed, 21 Oct 2026 16:26:54 +0800 Subject: [PATCH 19/21] fix(conversation): add missing Japanese quote removal label The Japanese dictionary landed without the quote removal copy its English and Chinese dictionaries already carry, so the conversation dictionaries do not typecheck against the current master. --- packages/client/ui-conversation/src/client/locales.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/client/ui-conversation/src/client/locales.ts b/packages/client/ui-conversation/src/client/locales.ts index f908a94233..36edb38e56 100644 --- a/packages/client/ui-conversation/src/client/locales.ts +++ b/packages/client/ui-conversation/src/client/locales.ts @@ -772,6 +772,7 @@ export const en = { /** Japanese copy, checked against the English keys. */ export const ja = { + 'quote.remove': '引用を削除', 'shortcut.newline': '改行', 'shortcut.complementary': '補助のキュー / 実行中の指示変更を使用', 'shortcut.slash': 'コマンドメニューを開く', From cf620a98df920b747f858ff3e785f4f16a0cba94 Mon Sep 17 00:00:00 2001 From: yudshj Date: Wed, 21 Oct 2026 16:35:29 +0800 Subject: [PATCH 20/21] fix(message-quotes): register the Japanese dictionary The quote plugin's dictionaries predate the Japanese locale merge, so the locale dictionary parity gate rejects its object-form dictionary without Japanese copy. --- packages/session/message-quotes/src/client/index.ts | 4 ++-- packages/session/message-quotes/src/client/locales.ts | 5 +++++ 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/packages/session/message-quotes/src/client/index.ts b/packages/session/message-quotes/src/client/index.ts index 09e352267e..b0748db925 100644 --- a/packages/session/message-quotes/src/client/index.ts +++ b/packages/session/message-quotes/src/client/index.ts @@ -8,7 +8,7 @@ import type {} from '@deepseek-ai/dsh-client-ui-renderer/client' import { QuoteSelectionAction } from './QuoteSelection.tsx' import { QuotedUserText } from './QuotedUserText.tsx' import { quoteRule } from './quote-rule.ts' -import { en, NS, zh, type MessageQuotesKey } from './locales.ts' +import { en, ja, NS, zh, type MessageQuotesKey } from './locales.ts' export type {} from './quote-rule.ts' @@ -30,7 +30,7 @@ export const inject = ['slots', 'locale', 'sessions', 'uiConversation'] * @param ctx - Client root context owning every Quote contribution. */ export function apply(ctx: Context): void { - ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'message-quotes: dictionaries') + ctx.effect(() => ctx.locale.register(NS, { zh, en, ja }), 'message-quotes: dictionaries') ctx.effect(() => ctx.uiConversation.userText.register(quoteRule), 'message-quotes: UserText rule') ctx.effect(() => ctx.uiConversation.interactions.register({ id: 'quote', diff --git a/packages/session/message-quotes/src/client/locales.ts b/packages/session/message-quotes/src/client/locales.ts index c36e0d3599..fab1fbc782 100644 --- a/packages/session/message-quotes/src/client/locales.ts +++ b/packages/session/message-quotes/src/client/locales.ts @@ -15,3 +15,8 @@ export type MessageQuotesKey = keyof typeof zh export const en: Record = { 'quote.add': 'Quote text', } + +/** Japanese dictionary. */ +export const ja = { + 'quote.add': 'テキストを引用', +} satisfies Record From c47d3285b5f7f2394438502dba4606e90ac42d6a Mon Sep 17 00:00:00 2001 From: yudshj Date: Wed, 21 Oct 2026 16:50:06 +0800 Subject: [PATCH 21/21] test(snapshots): record the current present tool description The background-wait-steer pinned tool schemas still carry the pre-reword present description, so the snapshot header no longer matches the shipped tool. --- snapshots/sdk/background-wait-steer/tool-schemas.expected.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/snapshots/sdk/background-wait-steer/tool-schemas.expected.json b/snapshots/sdk/background-wait-steer/tool-schemas.expected.json index 90228febab..c9fb03068a 100644 --- a/snapshots/sdk/background-wait-steer/tool-schemas.expected.json +++ b/snapshots/sdk/background-wait-steer/tool-schemas.expected.json @@ -325,7 +325,7 @@ }, { "name": "present", - "description": "Declare existing files as final deliverables for the user. Use it when the user needs a separate file, especially Office documents, spreadsheets, and slide decks; prefer your final response when that suffices. The user opens the current files; their contents are not copied.", + "description": "Declare existing files as final deliverables for the user. Hosts may show presented files only after your turn ends, so call it once the files are final. If the user needs to review files earlier, such as before a tool call that waits for their answer, link them in your message instead. Use it when the user needs a separate file, especially Office documents, spreadsheets, and slide decks; prefer your final response when that suffices. The user opens the current files; their contents are not copied.", "parameters": { "type": "object", "properties": {