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 && (