docs: lock down hover pattern, fix user message bubble flicker
The user message bubble had hover on a `Pressable` wrapping the bubble, with the timestamp and copy button as siblings outside it — moving the cursor onto them lost hover. Wrapping both in a single Pressable then flickered, because `TurnCopyButton` is itself a Pressable and nested Pressables fight over hover state. Rewrite `UserMessage` to match the workspace row pattern: hover-tracking on a plain outer `View` with `onPointerEnter`/`onPointerLeave`, nested Pressables freely inside. Pointer events are non-bubbling DOM events, so descendants can't disrupt the outer hover. Rewrite `docs/hover.md` with the workspace row as THE canonical pattern, not one option among several. Names the three failure modes that come from skipping it — including "nested Pressables fight over hover" — and explicitly bounds when `Pressable.onHoverIn`/`onHoverOut` is acceptable (self-styling only). Update `constants/platform.ts` so its hover guidance points at the doc instead of recommending the broken pattern.
This commit is contained in:
@@ -450,19 +450,18 @@ export const UserMessage = memo(function UserMessage({
|
||||
}: UserMessageProps) {
|
||||
const isCompact = useIsCompactFormFactor();
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
const [isCopyHovered, setIsCopyHovered] = useState(false);
|
||||
const resolvedDisableOuterSpacing = useDisableOuterSpacing(disableOuterSpacing);
|
||||
const hasText = message.trim().length > 0;
|
||||
const hasImages = images.length > 0;
|
||||
const hasAttachments = attachments.length > 0;
|
||||
const showTrailingRow = hasText && (isCompact || isNative || isHovered || isCopyHovered);
|
||||
const showTrailingRow = hasText && (isCompact || isNative || isHovered);
|
||||
const formattedTimestamp = useMemo(
|
||||
() => formatMessageTimestamp(new Date(timestamp)),
|
||||
[timestamp],
|
||||
);
|
||||
|
||||
const handleHoverIn = useCallback(() => setIsHovered(true), []);
|
||||
const handleHoverOut = useCallback(() => setIsHovered(false), []);
|
||||
const handlePointerEnter = useCallback(() => setIsHovered(true), []);
|
||||
const handlePointerLeave = useCallback(() => setIsHovered(false), []);
|
||||
const getMessageContent = useCallback(() => message, [message]);
|
||||
|
||||
const containerStyle = useMemo(
|
||||
@@ -502,39 +501,41 @@ export const UserMessage = memo(function UserMessage({
|
||||
|
||||
return (
|
||||
<View style={containerStyle}>
|
||||
<View style={userMessageStylesheet.content}>
|
||||
<Pressable onHoverIn={handleHoverIn} onHoverOut={handleHoverOut}>
|
||||
<View style={userMessageStylesheet.bubble}>
|
||||
{hasImages ? (
|
||||
<View style={imagePreviewContainerStyle}>
|
||||
{images.map((image) => (
|
||||
<View key={image.id} style={userMessageStylesheet.imagePill}>
|
||||
<UserMessageAttachmentThumbnail image={image} />
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
) : null}
|
||||
{hasAttachments ? (
|
||||
<View style={attachmentPreviewContainerStyle}>
|
||||
{attachments.map((attachment, index) => (
|
||||
<View
|
||||
key={`${attachment.type}:${"number" in attachment ? attachment.number : index}`}
|
||||
style={userMessageStylesheet.structuredAttachmentPill}
|
||||
>
|
||||
<Text style={userMessageStylesheet.structuredAttachmentText} numberOfLines={1}>
|
||||
{getUserMessageAttachmentLabel(attachment)}
|
||||
</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
) : null}
|
||||
{hasText ? (
|
||||
<Text selectable style={userMessageStylesheet.text}>
|
||||
{message}
|
||||
</Text>
|
||||
) : null}
|
||||
</View>
|
||||
</Pressable>
|
||||
<View
|
||||
style={userMessageStylesheet.content}
|
||||
onPointerEnter={handlePointerEnter}
|
||||
onPointerLeave={handlePointerLeave}
|
||||
>
|
||||
<View style={userMessageStylesheet.bubble}>
|
||||
{hasImages ? (
|
||||
<View style={imagePreviewContainerStyle}>
|
||||
{images.map((image) => (
|
||||
<View key={image.id} style={userMessageStylesheet.imagePill}>
|
||||
<UserMessageAttachmentThumbnail image={image} />
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
) : null}
|
||||
{hasAttachments ? (
|
||||
<View style={attachmentPreviewContainerStyle}>
|
||||
{attachments.map((attachment, index) => (
|
||||
<View
|
||||
key={`${attachment.type}:${"number" in attachment ? attachment.number : index}`}
|
||||
style={userMessageStylesheet.structuredAttachmentPill}
|
||||
>
|
||||
<Text style={userMessageStylesheet.structuredAttachmentText} numberOfLines={1}>
|
||||
{getUserMessageAttachmentLabel(attachment)}
|
||||
</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
) : null}
|
||||
{hasText ? (
|
||||
<Text selectable style={userMessageStylesheet.text}>
|
||||
{message}
|
||||
</Text>
|
||||
) : null}
|
||||
</View>
|
||||
{hasText ? (
|
||||
<View style={trailingRowStyle} pointerEvents={showTrailingRow ? "auto" : "none"}>
|
||||
<Text style={userMessageStylesheet.timestampText}>{formattedTimestamp}</Text>
|
||||
@@ -542,7 +543,6 @@ export const UserMessage = memo(function UserMessage({
|
||||
getContent={getMessageContent}
|
||||
containerStyle={userMessageStylesheet.copyButton}
|
||||
accessibilityLabel="Copy message"
|
||||
onHoverChange={setIsCopyHovered}
|
||||
/>
|
||||
</View>
|
||||
) : null}
|
||||
@@ -1202,7 +1202,6 @@ interface TurnCopyButtonProps {
|
||||
containerStyle?: StyleProp<ViewStyle>;
|
||||
accessibilityLabel?: string;
|
||||
copiedAccessibilityLabel?: string;
|
||||
onHoverChange?: (hovered: boolean) => void;
|
||||
}
|
||||
|
||||
export const TurnCopyButton = memo(function TurnCopyButton({
|
||||
@@ -1210,7 +1209,6 @@ export const TurnCopyButton = memo(function TurnCopyButton({
|
||||
containerStyle,
|
||||
accessibilityLabel,
|
||||
copiedAccessibilityLabel,
|
||||
onHoverChange,
|
||||
}: TurnCopyButtonProps) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
const copyTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
@@ -1242,8 +1240,6 @@ export const TurnCopyButton = memo(function TurnCopyButton({
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleHoverIn = useCallback(() => onHoverChange?.(true), [onHoverChange]);
|
||||
const handleHoverOut = useCallback(() => onHoverChange?.(false), [onHoverChange]);
|
||||
const pressableStyle = useMemo(
|
||||
() => [turnCopyButtonStylesheet.container, containerStyle],
|
||||
[containerStyle],
|
||||
@@ -1252,8 +1248,6 @@ export const TurnCopyButton = memo(function TurnCopyButton({
|
||||
return (
|
||||
<Pressable
|
||||
onPress={handleCopy}
|
||||
onHoverIn={handleHoverIn}
|
||||
onHoverOut={handleHoverOut}
|
||||
style={pressableStyle}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={
|
||||
|
||||
@@ -14,7 +14,9 @@ import { isElectronRuntime, isElectronRuntimeMac } from "@/desktop/host";
|
||||
// isElectron → Desktop wrapper features (file dialogs, titlebar, updates)
|
||||
//
|
||||
// For layout decisions, use useIsCompactFormFactor() from constants/layout.ts.
|
||||
// For hover, use onHoverIn/onHoverOut on Pressable — no platform gate needed.
|
||||
// For hover-tracking, see docs/hover.md — the short answer is `onPointerEnter`/
|
||||
// `onPointerLeave` on a plain `View`, with any press behavior on a separate
|
||||
// inner `Pressable`. No platform gate needed.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Browser or Electron — the JS runtime has access to the DOM. */
|
||||
|
||||
Reference in New Issue
Block a user