fix(ui-user-questions): refine option detail callouts
This commit is contained in:
1 parent
1cbf50d03e
commit
5b96747630
9 files changed
+204
-30
No files matched your search
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in new issue
Block a user