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",