From 721b6a10dc8ad46917fdf71bdcf7cc206b735fff Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Sun, 2 Aug 2026 22:15:50 +0000 Subject: [PATCH] fix(composer): isolate web hover handlers --- .../app/src/composer/track-row-layout.tsx | 126 ++++++++++++++++++ packages/app/src/composer/track-row.tsx | 8 ++ packages/app/src/composer/track-row.web.tsx | 20 +++ packages/app/src/composer/tracks.tsx | 125 +---------------- 4 files changed, 158 insertions(+), 121 deletions(-) create mode 100644 packages/app/src/composer/track-row-layout.tsx create mode 100644 packages/app/src/composer/track-row.tsx create mode 100644 packages/app/src/composer/track-row.web.tsx diff --git a/packages/app/src/composer/track-row-layout.tsx b/packages/app/src/composer/track-row-layout.tsx new file mode 100644 index 0000000000..4c77d154b1 --- /dev/null +++ b/packages/app/src/composer/track-row-layout.tsx @@ -0,0 +1,126 @@ +import { useCallback, useState, type ReactElement, type ReactNode } from "react"; +import { Pressable, Text, View } from "react-native"; +import { StyleSheet } from "react-native-unistyles"; + +export interface ComposerTrackRowProps { + /** `link` for a row that leaves the agent, `button` for one that acts in place. */ + accessibilityRole: "button" | "link"; + accessibilityLabel: string; + testID: string; + onPress: () => void; + renderLeading?: () => ReactNode; + label: string; + /** Muted trailing context — a provider note, a cadence. */ + secondary?: string; + actions?: () => ReactNode; +} + +interface ComposerTrackRowLayoutProps extends ComposerTrackRowProps { + isHovered: boolean; + showActions: boolean; +} + +/** + * One row in a track. The row control and its actions are siblings so an + * action never activates the row's navigation. + */ +export function ComposerTrackRowLayout({ + accessibilityRole, + accessibilityLabel, + testID, + onPress, + renderLeading, + label, + secondary, + actions, + isHovered, + showActions, +}: ComposerTrackRowLayoutProps): ReactElement { + const [pressed, setPressed] = useState(false); + const handlePressIn = useCallback(() => setPressed(true), []); + const handlePressOut = useCallback(() => setPressed(false), []); + + return ( + + + {renderLeading?.()} + + {label} + + {secondary ? ( + + {secondary} + + ) : null} + + {actions ? ( + + {actions()} + + ) : null} + + ); +} + +const styles = StyleSheet.create((theme) => ({ + row: { + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[2], + paddingHorizontal: theme.spacing[3], + paddingVertical: theme.spacing[2], + }, + rowActive: { + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[2], + paddingHorizontal: theme.spacing[3], + paddingVertical: theme.spacing[2], + backgroundColor: theme.colors.surface2, + }, + rowMain: { + flex: 1, + minWidth: 0, + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[2], + }, + rowLabel: { + flex: 1, + minWidth: 0, + fontSize: theme.fontSize.sm, + color: theme.colors.foreground, + }, + // Keep secondary context bounded so the row's own name stays readable on + // compact screens. + rowSecondary: { + flexShrink: 1, + minWidth: 0, + maxWidth: "45%", + fontSize: theme.fontSize.xs, + color: theme.colors.foregroundMuted, + }, + actionClusterVisible: { + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[1], + opacity: 1, + }, + actionClusterHidden: { + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[1], + opacity: 0, + }, +})); diff --git a/packages/app/src/composer/track-row.tsx b/packages/app/src/composer/track-row.tsx new file mode 100644 index 0000000000..15c02209a8 --- /dev/null +++ b/packages/app/src/composer/track-row.tsx @@ -0,0 +1,8 @@ +import type { ReactElement } from "react"; +import { ComposerTrackRowLayout, type ComposerTrackRowProps } from "./track-row-layout"; + +export type { ComposerTrackRowProps }; + +export function ComposerTrackRow(props: ComposerTrackRowProps): ReactElement { + return ; +} diff --git a/packages/app/src/composer/track-row.web.tsx b/packages/app/src/composer/track-row.web.tsx new file mode 100644 index 0000000000..1ca0e3a1b8 --- /dev/null +++ b/packages/app/src/composer/track-row.web.tsx @@ -0,0 +1,20 @@ +import { useCallback, useState, type ReactElement } from "react"; +import { View } from "react-native"; +import { useIsCompactFormFactor } from "@/constants/layout"; +import { ComposerTrackRowLayout, type ComposerTrackRowProps } from "./track-row-layout"; + +export type { ComposerTrackRowProps }; + +export function ComposerTrackRow(props: ComposerTrackRowProps): ReactElement { + const isCompact = useIsCompactFormFactor(); + const [isHovered, setIsHovered] = useState(false); + const handlePointerEnter = useCallback(() => setIsHovered(true), []); + const handlePointerLeave = useCallback(() => setIsHovered(false), []); + const showActions = isHovered || isCompact; + + return ( + + + + ); +} diff --git a/packages/app/src/composer/tracks.tsx b/packages/app/src/composer/tracks.tsx index 7e196923e9..8ead68a9f5 100644 --- a/packages/app/src/composer/tracks.tsx +++ b/packages/app/src/composer/tracks.tsx @@ -17,9 +17,11 @@ import { import { ChevronDown, ChevronRight } from "lucide-react-native"; import { StyleSheet, withUnistyles } from "react-native-unistyles"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; -import { MAX_CONTENT_WIDTH, useIsCompactFormFactor } from "@/constants/layout"; -import { isNative } from "@/constants/platform"; +import { MAX_CONTENT_WIDTH } from "@/constants/layout"; import type { Theme } from "@/styles/theme"; +import { ComposerTrackRow, type ComposerTrackRowProps } from "./track-row"; + +export { ComposerTrackRow, type ComposerTrackRowProps }; const ThemedChevronDown = withUnistyles(ChevronDown); const ThemedChevronRight = withUnistyles(ChevronRight); @@ -140,76 +142,6 @@ function expandedAccessibilityState(expanded: boolean) { return expanded ? EXPANDED_STATE : COLLAPSED_STATE; } -export interface ComposerTrackRowProps { - /** `link` for a row that leaves the agent, `button` for one that acts in place. */ - accessibilityRole: "button" | "link"; - accessibilityLabel: string; - testID: string; - onPress: () => void; - renderLeading?: () => ReactNode; - label: string; - /** Muted trailing context — a provider note, a cadence. */ - secondary?: string; - actions?: () => ReactNode; -} - -/** - * One row in a track. The row control and its actions are siblings so an - * action never activates the row's navigation. - */ -export function ComposerTrackRow({ - accessibilityRole, - accessibilityLabel, - testID, - onPress, - renderLeading, - label, - secondary, - actions, -}: ComposerTrackRowProps): ReactElement { - const isCompact = useIsCompactFormFactor(); - const [hovered, setHovered] = useState(false); - const [pressed, setPressed] = useState(false); - const handlePointerEnter = useCallback(() => setHovered(true), []); - const handlePointerLeave = useCallback(() => setHovered(false), []); - const handlePressIn = useCallback(() => setPressed(true), []); - const handlePressOut = useCallback(() => setPressed(false), []); - const showActions = hovered || isNative || isCompact; - return ( - - - - {renderLeading?.()} - - {label} - - {secondary ? ( - - {secondary} - - ) : null} - - {actions ? ( - - {actions()} - - ) : null} - - - ); -} - export interface ComposerTrackActionButtonProps { accessibilityLabel: string; testID: string; @@ -321,55 +253,6 @@ const styles = StyleSheet.create((theme) => ({ scrollContent: { paddingVertical: theme.spacing[1], }, - row: { - flexDirection: "row", - alignItems: "center", - gap: theme.spacing[2], - paddingHorizontal: theme.spacing[3], - paddingVertical: theme.spacing[2], - }, - rowActive: { - flexDirection: "row", - alignItems: "center", - gap: theme.spacing[2], - paddingHorizontal: theme.spacing[3], - paddingVertical: theme.spacing[2], - backgroundColor: theme.colors.surface2, - }, - rowMain: { - flex: 1, - minWidth: 0, - flexDirection: "row", - alignItems: "center", - gap: theme.spacing[2], - }, - rowLabel: { - flex: 1, - minWidth: 0, - fontSize: theme.fontSize.sm, - color: theme.colors.foreground, - }, - // Keep secondary context bounded so the row's own name stays readable on - // compact screens. - rowSecondary: { - flexShrink: 1, - minWidth: 0, - maxWidth: "45%", - fontSize: theme.fontSize.xs, - color: theme.colors.foregroundMuted, - }, - actionClusterVisible: { - flexDirection: "row", - alignItems: "center", - gap: theme.spacing[1], - opacity: 1, - }, - actionClusterHidden: { - flexDirection: "row", - alignItems: "center", - gap: theme.spacing[1], - opacity: 0, - }, actionButton: { padding: theme.spacing[1], alignItems: "center",