cc-haha/desktop/src/components/chat/MessageActionBar.tsx
程序员阿江(Relakkes) 0ab624bff4 fix(desktop): remove hidden chat action hover strip #642
The previous fix moved the hover group onto the message shell, but the action row still occupied layout space while invisible. That invisible row kept expanding the shell hit area, so moving the pointer into the empty metadata strip could still reveal copy and timestamp controls.

Collapse the action row to zero height by default and expand it only while the message shell is hovered or focused. Keep message content spacing separate from the action row so hidden controls do not create a hover target.

Constraint: Copy/fork controls must remain reachable after the bubble reveals them.
Rejected: Use display:none for the action row | would remove the keyboard focus path entirely.
Confidence: high
Scope-risk: narrow
Directive: Keep hidden message metadata rows out of normal hover hit-testing; do not reintroduce invisible layout space around chat bubbles.
Tested: cd desktop && bun run test src/components/chat/MessageList.test.tsx --run -t "keeps user actions anchored"
Tested: cd desktop && bun run test src/components/chat/MessageList.test.tsx --run
Tested: cd desktop && bun run test src/components/chat/UserMessage.test.tsx --run
Tested: bun run check:desktop
Tested: git diff --check
Not-tested: Live Tauri native hover smoke
Related: #642
2026-05-31 21:43:19 +08:00

84 lines
3.5 KiB
TypeScript

import { Check, Copy, GitFork } from 'lucide-react'
import { useTranslation } from '../../i18n'
import { useSettingsStore } from '../../stores/settingsStore'
import { formatExactMessageTimestamp, formatMessageTimestamp } from '../../lib/formatMessageTimestamp'
import { CopyButton } from '../shared/CopyButton'
export type MessageBranchAction = {
label: string
loading?: boolean
onBranch: () => void
}
type Props = {
copyText?: string
copyLabel: string
branchAction?: MessageBranchAction
align?: 'start' | 'end'
timestamp?: number
}
export function MessageActionBar({
copyText,
copyLabel,
branchAction,
align = 'start',
timestamp,
}: Props) {
const t = useTranslation()
const locale = useSettingsStore((state) => state.locale)
const hasCopy = Boolean(copyText?.trim())
const timeLabel = typeof timestamp === 'number'
? formatMessageTimestamp(timestamp, t, locale)
: ''
const exactTimeLabel = typeof timestamp === 'number'
? formatExactMessageTimestamp(timestamp, locale)
: ''
if (!hasCopy && !branchAction) return null
return (
<div
data-message-actions
data-align={align}
className={`pointer-events-none mt-0 flex h-0 w-full overflow-hidden opacity-0 transition-[height,margin-top,opacity] duration-150 group-hover:pointer-events-auto group-hover:mt-2 group-hover:h-7 group-hover:opacity-100 group-focus-within:pointer-events-auto group-focus-within:mt-2 group-focus-within:h-7 group-focus-within:opacity-100 ${
align === 'end' ? 'justify-end' : 'justify-start'
}`}
>
<div className="flex min-h-7 items-center gap-1.5">
{hasCopy ? (
<CopyButton
text={copyText!}
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}
{branchAction ? (
<button
type="button"
onClick={branchAction.onBranch}
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" />
</button>
) : null}
{timeLabel ? (
<span
className="ml-1 inline-flex items-center text-[11px] font-medium tabular-nums text-[var(--color-text-tertiary)]"
title={exactTimeLabel || timeLabel}
>
{timeLabel}
</span>
) : null}
</div>
</div>
)
}