fix: hide message actions after pointer clicks #642

Message action controls stay visible through focus-within for keyboard navigation, but pointer clicks left the copy/fork buttons focused after the mouse moved away. Release pointer focus on message actions so hover controls return to their quiet state while keeping Tab focus behavior intact.

Constraint: Message actions use focus-within for keyboard accessibility and should not remove that path.
Rejected: Remove focus-within reveal | would regress keyboard users who tab to copy or fork.
Confidence: high
Scope-risk: narrow
Directive: Keep pointer-only blur scoped to message action controls; do not remove focus-within without a replacement keyboard affordance.
Tested: cd desktop && bun run test src/components/chat/MessageList.test.tsx --run
Tested: bun run check:desktop
Not-tested: Live Tauri manual hover smoke
Related: #642
This commit is contained in:
程序员阿江(Relakkes) 2026-05-30 18:57:55 +08:00
parent 7f74de711f
commit fd25e5732f
3 changed files with 33 additions and 1 deletions

View File

@ -52,6 +52,7 @@ export function MessageActionBar({
label={copyLabel}
displayLabel={<Copy size={13} strokeWidth={2.2} aria-hidden="true" />}
displayCopiedLabel={<Check size={13} strokeWidth={2.4} aria-hidden="true" />}
onPointerUp={(event) => event.currentTarget.blur()}
className="inline-flex h-7 w-7 items-center justify-center rounded-full border border-transparent bg-transparent text-[var(--color-text-tertiary)] transition-colors hover:border-[var(--color-brand)]/30 hover:bg-[var(--color-surface-container-low)] hover:text-[var(--color-text-primary)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-brand)]/35"
/>
) : null}
@ -62,6 +63,7 @@ export function MessageActionBar({
disabled={branchAction.loading}
aria-label={branchAction.label}
title={branchAction.label}
onPointerUp={(event) => event.currentTarget.blur()}
className="inline-flex h-7 w-7 items-center justify-center rounded-full border border-transparent bg-transparent text-[var(--color-text-tertiary)] transition-colors hover:border-[var(--color-brand)]/30 hover:bg-[var(--color-surface-container-low)] hover:text-[var(--color-text-primary)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-brand)]/35 disabled:cursor-wait disabled:opacity-60"
>
<GitFork size={13} strokeWidth={2.2} aria-hidden="true" />

View File

@ -1580,6 +1580,33 @@ describe('MessageList nested tool calls', () => {
)
})
it('releases pointer focus from message actions after clicking copy', () => {
useChatStore.setState({
sessions: {
[ACTIVE_TAB]: makeSessionState({
messages: [
{
id: 'assistant-1',
type: 'assistant_text',
content: '离开 hover 后操作条应该恢复隐藏。',
timestamp: 1,
},
],
}),
},
})
render(<MessageList />)
const copyButton = screen.getByRole('button', { name: 'Copy reply' })
copyButton.focus()
expect(document.activeElement).toBe(copyButton)
fireEvent.pointerUp(copyButton)
expect(document.activeElement).not.toBe(copyButton)
})
it('adds selected user message text to the composer context', async () => {
useChatStore.setState({
sessions: {

View File

@ -1,4 +1,4 @@
import { useEffect, useState, type ReactNode } from 'react'
import { useEffect, useState, type PointerEventHandler, type ReactNode } from 'react'
import { copyTextToClipboard } from '../chat/clipboard'
type Props = {
@ -8,6 +8,7 @@ type Props = {
displayLabel?: ReactNode
displayCopiedLabel?: ReactNode
className?: string
onPointerUp?: PointerEventHandler<HTMLButtonElement>
}
export function CopyButton({
@ -17,6 +18,7 @@ export function CopyButton({
displayLabel,
displayCopiedLabel,
className = '',
onPointerUp,
}: Props) {
const [copied, setCopied] = useState(false)
@ -48,6 +50,7 @@ export function CopyButton({
<button
type="button"
onClick={handleCopy}
onPointerUp={onPointerUp}
className={className}
aria-label={currentLabel}
title={currentLabel}