fix(ui-user-questions): refine option detail callouts

This commit is contained in:
yudshj committed 2026-10-21 16:20:01 +08:00
1 parent 1cbf50d03e
commit 5b96747630
9 files changed
+204 -30

No files matched your search

+30 -2
View File
@@ -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<HTMLElement>('[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)
@@ -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;
}
+76 -22
View File
@@ -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<HTMLElement | null>
} & ({
anchor: ReactElement<DescribedAnchorProps>
@@ -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<HTMLSpanElement>(null)
const cardRef = useRef<HTMLDivElement>(null)
const scrollRef = useRef<HTMLDivElement>(null)
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const copyTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const copyHeightRef = useRef<number | null>(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<void> => {
if (copied || copyingRef.current) return
@@ -301,16 +330,23 @@ export function HoverCard(props: HoverCardProps) {
}
close()
}
const cardContent = copied ? <span className={css.copied} aria-hidden="true">{copiedLabel}</span> : content
const card = open && pos !== null && (
<div
ref={cardRef}
id={openOnClick ? id : undefined}
className={clsx(css.card, variant === 'preview' && css.preview, inline && css.media, copyable && css.copyable, copied && css.feedback)}
className={clsx(css.card, variant === 'preview' && css.preview, inline && css.media,
callout && css.callout, copyable && css.copyable, copied && css.feedback)}
data-closing={closing || undefined}
data-pinned={pinned || undefined}
data-placement={callout ? pos.placement : undefined}
style={{
...pos, minHeight: copied && copyHeightRef.current !== null ? copyHeightRef.current : undefined,
left: pos.left, top: pos.top, width: pos.width,
maxHeight: callout ? undefined : pos.maxHeight,
minHeight: copied && copyHeightRef.current !== null ? copyHeightRef.current : undefined,
'--dsh-hover-preview-fade': `${PREVIEW_FADE_MS}ms`,
'--dsh-hover-card-max-height': pos.maxHeight === undefined ? undefined : `${pos.maxHeight}px`,
'--dsh-hover-card-anchor-x': pos.anchorX === undefined ? undefined : `${pos.anchorX}px`,
} as CSSProperties}
role={copyable ? 'button' : undefined}
tabIndex={copyable ? 0 : undefined}
@@ -333,19 +369,37 @@ export function HoverCard(props: HoverCardProps) {
void copy(copyText)
}
: undefined}
onPointerEnter={() => {
cancelClose()
setPhase('open')
}}
onPointerLeave={() => {
if (!pinned
&& rootRef.current?.contains(document.activeElement) !== true
&& cardRef.current?.contains(document.activeElement) !== true) armClose()
}}
>
{copied ? <span className={css.copied} aria-hidden="true">{copiedLabel}</span> : content}
{callout
? (
<>
<span className={css.calloutCaret} data-hover-card-caret aria-hidden="true" />
<div className={css.calloutSurface}>
<div ref={scrollRef} className={css.calloutBody} data-hover-card-scroll>{cardContent}</div>
</div>
</>
)
: cardContent}
</div>
)
return (
<span
ref={rootRef}
className={clsx(css.root, inline && css.inline)}
onFocus={inline ? (event) => {
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) => {
@@ -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<HTMLElement>('[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)
@@ -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
+1 -1
View File
@@ -29,7 +29,7 @@ When the agent asks a question, the attached card temporarily occupies the compo
### Answering
A multi-select draft keeps its selected labels while the user opens or edits the custom answer, so its submitted item may carry both `selected` and `custom`; a single-select custom answer remains exclusive. Question detail reuses the assistant-output `MarkdownText` primitive, including its GFM rendering and untrusted-content policy. 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
@@ -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` 拒绝整个等待。
### 回看已结束的问题
@@ -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
@@ -556,7 +556,7 @@ function QuestionFlow({ pending, t, useStore, useQuestionCard, actions }: Questi
{hasDescription && (
<span className={css.descriptionAction}>
<HoverCard
inline
variant="callout"
openOnClick
describeAnchor={false}
openDelayMs={200}