fix(web): Replace anti-pattern of assigning role="button" to entire message div (#665)

* fix(web): replace message-level aria-expanded with explicit toggle button

Replace the anti-pattern of assigning role=\"button\" and aria-expanded to
the entire message content div. Instead, show an explicit \"Show info\" /
\"Hide info\" button next to the timestamp.

Also add a visible background container to MessageMetadata so users can
actually see when metadata expands/collapses.

Remove now-unused metadataToggle.ts and its tests.

* fix(web): include turnCount in hasMetadata and pass it through codexReview branch
This commit is contained in:
2026-05-22 21:28:28 +08:00
committed by GitHub
parent 9e35067aa9
commit 04d3d02627
5 changed files with 58 additions and 242 deletions
@@ -1,4 +1,4 @@
import { useCallback, useState, type KeyboardEvent, type MouseEvent } from 'react'
import { useState } from 'react'
import { MessagePrimitive, useAssistantState } from '@assistant-ui/react'
import { MarkdownText } from '@/components/assistant-ui/markdown-text'
import { Reasoning, ReasoningGroup } from '@/components/assistant-ui/reasoning'
@@ -10,7 +10,6 @@ import type { HappyChatMessageMetadata } from '@/lib/assistant-runtime'
import { getAssistantCopyText } from '@/components/AssistantChat/messages/assistantCopyText'
import { getConversationMessageAnchorId } from '@/chat/outline'
import { MessageMetadata } from '@/components/AssistantChat/messages/MessageMetadata'
import { isNestedInteractiveEvent } from '@/components/AssistantChat/messages/metadataToggle'
import { CodexReviewCard } from '@/components/AssistantChat/messages/CodexReviewCard'
import { MessageTimestamp } from '@/components/AssistantChat/messages/MessageTimestamp'
@@ -28,10 +27,6 @@ const MESSAGE_PART_COMPONENTS = {
export function HappyAssistantMessage() {
const { copied, copy } = useCopyToClipboard()
const [showMetadata, setShowMetadata] = useState(false)
const toggleMetadata = useCallback((event: MouseEvent<HTMLElement>) => {
if (isNestedInteractiveEvent(event)) return
setShowMetadata((open) => !open)
}, [])
const messageId = useAssistantState(({ message }) => message.id)
const isCliOutput = useAssistantState(({ message }) => {
const custom = message.metadata.custom as Partial<HappyChatMessageMetadata> | undefined
@@ -66,14 +61,7 @@ export function HappyAssistantMessage() {
|| (typeof durationMs === 'number' && durationMs >= 0)
|| usage != null
|| (messageModel != null && messageModel !== '')
const onMetadataKeyDown = useCallback((event: KeyboardEvent<HTMLDivElement>) => {
if (isNestedInteractiveEvent(event)) return
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault()
setShowMetadata((open) => !open)
}
}, [])
|| (typeof turnCount === 'number' && turnCount >= 2)
const rootClass = toolOnly
? 'py-1 min-w-0 max-w-full overflow-x-hidden'
@@ -95,7 +83,7 @@ export function HappyAssistantMessage() {
aria-expanded={showMetadata}
className="text-[10px] text-[var(--app-hint)] underline-offset-2 hover:text-[var(--app-fg)] hover:underline"
>
{showMetadata ? 'Hide metadata' : 'Show metadata'}
{showMetadata ? 'Hide info' : 'Show info'}
</button>
)}
</div>
@@ -106,7 +94,6 @@ export function HappyAssistantMessage() {
usage={usage}
model={messageModel ?? null}
turnCount={turnCount}
className="mt-1"
/>
)}
</MessagePrimitive.Root>
@@ -120,17 +107,20 @@ export function HappyAssistantMessage() {
className={`${rootClass} ${copyText ? 'group/msg' : ''} scroll-mt-4`}
>
<div className="flex items-start gap-2">
<div
className={hasMetadata ? 'min-w-0 flex-1 cursor-pointer' : 'min-w-0 flex-1'}
onClick={hasMetadata ? toggleMetadata : undefined}
onKeyDown={hasMetadata ? onMetadataKeyDown : undefined}
role={hasMetadata ? 'button' : undefined}
tabIndex={hasMetadata ? 0 : undefined}
aria-expanded={hasMetadata ? showMetadata : undefined}
>
<div className="min-w-0 flex-1">
<CodexReviewCard review={codexReview} />
<div className="mt-1 flex justify-start">
<div className="mt-1 flex items-center gap-2">
<MessageTimestamp className="text-[10px] leading-none text-[var(--app-hint)]" />
{hasMetadata && (
<button
type="button"
onClick={() => setShowMetadata((open) => !open)}
aria-expanded={showMetadata}
className="text-[10px] text-[var(--app-hint)] underline-offset-2 hover:text-[var(--app-fg)] hover:underline"
>
{showMetadata ? 'Hide info' : 'Show info'}
</button>
)}
</div>
{showMetadata && (
<MessageMetadata
@@ -138,7 +128,7 @@ export function HappyAssistantMessage() {
durationMs={durationMs}
usage={usage}
model={messageModel ?? null}
className="mt-1"
turnCount={turnCount}
/>
)}
</div>
@@ -167,17 +157,20 @@ export function HappyAssistantMessage() {
id={getConversationMessageAnchorId(messageId)}
className={`${rootClass} ${copyText ? 'group/msg' : ''} scroll-mt-4`}
>
<div
className={hasMetadata ? 'min-w-0 cursor-pointer' : 'min-w-0'}
onClick={hasMetadata ? toggleMetadata : undefined}
onKeyDown={hasMetadata ? onMetadataKeyDown : undefined}
role={hasMetadata ? 'button' : undefined}
tabIndex={hasMetadata ? 0 : undefined}
aria-expanded={hasMetadata ? showMetadata : undefined}
>
<div className="min-w-0">
<MessagePrimitive.Content components={MESSAGE_PART_COMPONENTS} />
<div className="mt-1 flex justify-start">
<div className="mt-1 flex items-center gap-2">
<MessageTimestamp className="text-[10px] leading-none text-[var(--app-hint)]" />
{hasMetadata && (
<button
type="button"
onClick={() => setShowMetadata((open) => !open)}
aria-expanded={showMetadata}
className="text-[10px] text-[var(--app-hint)] underline-offset-2 hover:text-[var(--app-fg)] hover:underline"
>
{showMetadata ? 'Hide info' : 'Show info'}
</button>
)}
</div>
{showMetadata && (
<MessageMetadata
@@ -186,7 +179,6 @@ export function HappyAssistantMessage() {
usage={usage}
model={messageModel ?? null}
turnCount={turnCount}
className="mt-1"
/>
)}
</div>
@@ -200,17 +192,20 @@ export function HappyAssistantMessage() {
className={`${rootClass} ${copyText ? 'group/msg' : ''} scroll-mt-4`}
>
<div className="flex items-start gap-2">
<div
className={hasMetadata ? 'min-w-0 flex-1 cursor-pointer' : 'min-w-0 flex-1'}
onClick={hasMetadata ? toggleMetadata : undefined}
onKeyDown={hasMetadata ? onMetadataKeyDown : undefined}
role={hasMetadata ? 'button' : undefined}
tabIndex={hasMetadata ? 0 : undefined}
aria-expanded={hasMetadata ? showMetadata : undefined}
>
<div className="min-w-0 flex-1">
<MessagePrimitive.Content components={MESSAGE_PART_COMPONENTS} />
<div className="mt-1 flex justify-start">
<div className="mt-1 flex items-center gap-2">
<MessageTimestamp className="text-[10px] leading-none text-[var(--app-hint)]" />
{hasMetadata && (
<button
type="button"
onClick={() => setShowMetadata((open) => !open)}
aria-expanded={showMetadata}
className="text-[10px] text-[var(--app-hint)] underline-offset-2 hover:text-[var(--app-fg)] hover:underline"
>
{showMetadata ? 'Hide info' : 'Show info'}
</button>
)}
</div>
{showMetadata && (
<MessageMetadata
@@ -219,7 +214,6 @@ export function HappyAssistantMessage() {
usage={usage}
model={messageModel ?? null}
turnCount={turnCount}
className="mt-1"
/>
)}
</div>
@@ -73,7 +73,7 @@ export function MessageMetadata({ invokedAt, durationMs, usage, model, turnCount
if (parts.length === 0) return null
return (
<div className={`text-[10px] text-[var(--app-hint)] flex flex-wrap gap-x-2 gap-y-0.5 mt-0.5 px-0.5 leading-tight opacity-60 ${className || ''}`}>
<div className={`text-[10px] text-[var(--app-hint)] bg-[var(--app-subtle-bg)] rounded px-2 py-1.5 flex flex-wrap gap-x-2 gap-y-0.5 mt-1 leading-tight ${className || ''}`}>
{parts.map((part, i) => (
<span key={i} className="whitespace-nowrap">{part}</span>
))}
@@ -1,4 +1,4 @@
import { useCallback, useState, type KeyboardEvent, type MouseEvent } from 'react'
import { useState } from 'react'
import { MessagePrimitive, useAssistantState } from '@assistant-ui/react'
import { useHappyChatContext } from '@/components/AssistantChat/context'
import type { HappyChatMessageMetadata } from '@/lib/assistant-runtime'
@@ -10,17 +10,12 @@ import { CopyIcon, CheckIcon } from '@/components/icons'
import { useCopyToClipboard } from '@/hooks/useCopyToClipboard'
import { getConversationMessageAnchorId } from '@/chat/outline'
import { MessageMetadata } from '@/components/AssistantChat/messages/MessageMetadata'
import { isNestedInteractiveEvent } from '@/components/AssistantChat/messages/metadataToggle'
import { MessageTimestamp } from '@/components/AssistantChat/messages/MessageTimestamp'
export function HappyUserMessage() {
const ctx = useHappyChatContext()
const { copied, copy } = useCopyToClipboard()
const [showMetadata, setShowMetadata] = useState(false)
const toggleMetadata = useCallback((event: MouseEvent<HTMLElement>) => {
if (isNestedInteractiveEvent(event)) return
setShowMetadata((open) => !open)
}, [])
const role = useAssistantState(({ message }) => message.role)
const messageId = useAssistantState(({ message }) => message.id)
const text = useAssistantState(({ message }) => {
@@ -55,14 +50,6 @@ export function HappyUserMessage() {
const hasMetadata = invokedAt != null
const onMetadataKeyDown = useCallback((event: KeyboardEvent<HTMLElement>) => {
if (isNestedInteractiveEvent(event)) return
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault()
setShowMetadata((open) => !open)
}
}, [])
if (role !== 'user') return null
const canRetry = status === 'failed' && typeof localId === 'string' && Boolean(ctx.onRetryMessage)
const onRetry = canRetry ? () => ctx.onRetryMessage!(localId) : undefined
@@ -85,12 +72,12 @@ export function HappyUserMessage() {
aria-expanded={showMetadata}
className="text-[10px] text-[var(--app-hint)] underline-offset-2 hover:text-[var(--app-fg)] hover:underline"
>
{showMetadata ? 'Hide metadata' : 'Show metadata'}
{showMetadata ? 'Hide info' : 'Show info'}
</button>
)}
</div>
{showMetadata && invokedAt != null && (
<MessageMetadata invokedAt={invokedAt} className="mt-1 justify-end" />
<MessageMetadata invokedAt={invokedAt} />
)}
</div>
</MessagePrimitive.Root>
@@ -103,12 +90,7 @@ export function HappyUserMessage() {
return (
<MessagePrimitive.Root
id={getConversationMessageAnchorId(messageId)}
className={`${getUserBubbleClassName(status)} group/msg scroll-mt-4 ${hasMetadata ? 'cursor-pointer' : ''}`}
onClick={hasMetadata ? toggleMetadata : undefined}
onKeyDown={hasMetadata ? onMetadataKeyDown : undefined}
role={hasMetadata ? 'button' : undefined}
tabIndex={hasMetadata ? 0 : undefined}
aria-expanded={hasMetadata ? showMetadata : undefined}
className={`${getUserBubbleClassName(status)} group/msg scroll-mt-4`}
>
<div className="flex flex-col gap-1">
<div className="flex items-start gap-2">
@@ -123,10 +105,7 @@ export function HappyUserMessage() {
type="button"
title="Copy"
className="rounded-md p-0.5 opacity-60 transition-[opacity,background-color] hover:bg-[var(--app-chat-user-chip-bg)] sm:opacity-0 sm:group-hover/msg:opacity-100"
onClick={(event) => {
event.stopPropagation()
copy(text)
}}
onClick={() => copy(text)}
>
{copied
? <CheckIcon className="h-3.5 w-3.5 text-green-500" />
@@ -137,11 +116,21 @@ export function HappyUserMessage() {
</div>
)}
</div>
<div className="flex justify-end">
<div className="flex justify-end items-center gap-2">
<MessageTimestamp className="text-[10px] leading-none text-[var(--app-hint)]" />
{hasMetadata && (
<button
type="button"
onClick={() => setShowMetadata((open) => !open)}
aria-expanded={showMetadata}
className="text-[10px] text-[var(--app-hint)] underline-offset-2 hover:text-[var(--app-fg)] hover:underline"
>
{showMetadata ? 'Hide info' : 'Show info'}
</button>
)}
</div>
{showMetadata && invokedAt != null && (
<MessageMetadata invokedAt={invokedAt} className="justify-end opacity-60" />
<MessageMetadata invokedAt={invokedAt} />
)}
</div>
</MessagePrimitive.Root>
@@ -1,138 +0,0 @@
import { describe, expect, it } from 'vitest'
import type { KeyboardEvent, MouseEvent } from 'react'
import { isNestedInteractiveEvent } from './metadataToggle'
function makeMouseEvent(target: HTMLElement, currentTarget?: HTMLElement): MouseEvent<HTMLElement> {
return { target, currentTarget } as unknown as MouseEvent<HTMLElement>
}
function makeKeyboardEvent(target: HTMLElement, currentTarget?: HTMLElement): KeyboardEvent<HTMLElement> {
return { target, currentTarget } as unknown as KeyboardEvent<HTMLElement>
}
describe('isNestedInteractiveEvent', () => {
it('returns true when the click target is itself a button', () => {
const button = document.createElement('button')
expect(isNestedInteractiveEvent(makeMouseEvent(button))).toBe(true)
})
it('returns true when the click target is nested inside a button (e.g. icon)', () => {
const button = document.createElement('button')
const icon = document.createElement('span')
button.appendChild(icon)
expect(isNestedInteractiveEvent(makeMouseEvent(icon))).toBe(true)
})
it('returns true for role="button" elements (Radix triggers, Markdown copy button)', () => {
const div = document.createElement('div')
div.setAttribute('role', 'button')
const inner = document.createElement('span')
div.appendChild(inner)
expect(isNestedInteractiveEvent(makeMouseEvent(inner))).toBe(true)
})
it('returns true for anchors and form controls', () => {
const a = document.createElement('a')
const input = document.createElement('input')
const textarea = document.createElement('textarea')
const select = document.createElement('select')
expect(isNestedInteractiveEvent(makeMouseEvent(a))).toBe(true)
expect(isNestedInteractiveEvent(makeMouseEvent(input))).toBe(true)
expect(isNestedInteractiveEvent(makeMouseEvent(textarea))).toBe(true)
expect(isNestedInteractiveEvent(makeMouseEvent(select))).toBe(true)
})
it('returns false for plain message body text', () => {
const root = document.createElement('div')
const paragraph = document.createElement('p')
paragraph.textContent = 'Hello'
root.appendChild(paragraph)
expect(isNestedInteractiveEvent(makeMouseEvent(paragraph))).toBe(false)
})
it('returns false when target is not an Element', () => {
expect(isNestedInteractiveEvent({ target: null } as unknown as MouseEvent<HTMLElement>)).toBe(false)
})
it('returns true when the click target is an SVG icon inside a button', () => {
// Icon-only controls (copy, retry, code-copy) render an <svg>/<path>
// child of the <button>. Clicking the icon makes the event target an
// SVGElement, which is not an HTMLElement — the guard must still walk
// up to the enclosing button via closest().
const button = document.createElement('button')
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg')
const path = document.createElementNS('http://www.w3.org/2000/svg', 'path')
svg.appendChild(path)
button.appendChild(svg)
expect(isNestedInteractiveEvent(makeMouseEvent(svg as unknown as HTMLElement))).toBe(true)
expect(isNestedInteractiveEvent(makeMouseEvent(path as unknown as HTMLElement))).toBe(true)
})
it('returns true when the click target is a native <summary> (tool-card details disclosure)', () => {
// Tool cards expand their bodies via native <details><summary>; a
// click on the summary must not flip the message metadata footer
// alongside expanding the disclosure.
const details = document.createElement('details')
const summary = document.createElement('summary')
summary.textContent = 'Task details'
details.appendChild(summary)
expect(isNestedInteractiveEvent(makeMouseEvent(summary))).toBe(true)
})
it('returns true when the click target is a status indicator (role="status")', () => {
// MessageStatusIndicator renders queued/sending icons inside a span
// with role="status"; clicks on those should not toggle metadata.
const statusSpan = document.createElement('span')
statusSpan.setAttribute('role', 'status')
const inner = document.createElement('span')
statusSpan.appendChild(inner)
expect(isNestedInteractiveEvent(makeMouseEvent(statusSpan))).toBe(true)
expect(isNestedInteractiveEvent(makeMouseEvent(inner))).toBe(true)
})
it('returns false when the only matching ancestor is the toggle wrapper itself', () => {
// Regression: AssistantMessage / UserMessage assign role="button" to the
// toggle wrapper for keyboard a11y. Without currentTarget exclusion,
// closest('[role="button"]') from any inner click matches the wrapper
// and the toggle bails out — the entire mouse-toggle path becomes dead.
const wrapper = document.createElement('div')
wrapper.setAttribute('role', 'button')
const text = document.createElement('span')
text.textContent = 'message body'
wrapper.appendChild(text)
expect(isNestedInteractiveEvent(makeMouseEvent(text, wrapper))).toBe(false)
})
it('still returns true when a real nested control sits between target and wrapper', () => {
// The wrapper-exclusion guard must not let descendants slip through
// — an actual button inside the wrapper still suppresses the toggle.
const wrapper = document.createElement('div')
wrapper.setAttribute('role', 'button')
const innerButton = document.createElement('button')
const icon = document.createElement('span')
innerButton.appendChild(icon)
wrapper.appendChild(innerButton)
expect(isNestedInteractiveEvent(makeMouseEvent(icon, wrapper))).toBe(true)
})
it('returns true for Enter/Space keyboard events bubbling from a nested button', () => {
// Keyboard parity with mouse path: pressing Enter on a focused
// descendant button (e.g. Markdown code-copy) bubbles a keydown to the
// wrapper. Without this guard the wrapper's onKeyDown would also flip
// the metadata footer alongside the descendant's own activation.
const wrapper = document.createElement('div')
wrapper.setAttribute('role', 'button')
const innerButton = document.createElement('button')
wrapper.appendChild(innerButton)
expect(isNestedInteractiveEvent(makeKeyboardEvent(innerButton, wrapper))).toBe(true)
})
it('returns false for keyboard events targeting the wrapper itself', () => {
// Wrapper-exclusion must apply to keyboard too — a keydown targeting
// the wrapper should still fire the toggle, otherwise keyboard users
// lose the ability to open metadata via the wrapper.
const wrapper = document.createElement('div')
wrapper.setAttribute('role', 'button')
expect(isNestedInteractiveEvent(makeKeyboardEvent(wrapper, wrapper))).toBe(false)
})
})
@@ -1,29 +0,0 @@
import type { KeyboardEvent, MouseEvent } from 'react'
const NESTED_INTERACTIVE_SELECTOR = 'button, a, input, textarea, select, summary, [role="button"], [role="status"]'
type NestedTargetEvent = Pick<MouseEvent<HTMLElement> | KeyboardEvent<HTMLElement>, 'target' | 'currentTarget'>
/**
* Returns true when the event originated on (or inside) an interactive
* descendant such as a tool-card button, retry button, dialog trigger, or the
* Markdown code-copy button. The metadata toggle handler should bail out so
* its bubble-level handler does not also flip the metadata footer — relevant
* for both mouse clicks and keyboard activation (Enter/Space) on those
* descendants.
*
* `Element` (not `HTMLElement`) so that events landing on the `<svg>` or
* `<path>` descendants of icon-only buttons are still walked back up to the
* enclosing button via `closest`.
*
* `currentTarget` (the toggle wrapper itself) is excluded — the wrapper carries
* `role="button"` for keyboard accessibility, so without this guard `closest`
* would always match the wrapper from any inner event and the toggle would
* never fire.
*/
export function isNestedInteractiveEvent(event: NestedTargetEvent): boolean {
const target = event.target
if (!(target instanceof Element)) return false
const match = target.closest(NESTED_INTERACTIVE_SELECTOR)
return match !== null && match !== event.currentTarget
}