Centralize platform gating and fix iPad/tablet support

Add `packages/app/src/constants/platform.ts` with canonical gates
(isWeb, isNative, getIsElectron) and refactor all ~100 ad-hoc
Platform.OS checks across 69 files to use shared imports.

Fix sidebar hover crash on native (onPointerEnter → onHoverIn),
fix tooltip to use breakpoint instead of platform detection, make
sidebar action buttons always visible on native where hover doesn't
work, and document the platform gating decision matrix in CLAUDE.md.
This commit is contained in:
Mohamed Boudra
2026-04-13 13:24:00 +07:00
parent 964bf3e13f
commit 0f7fa55a0f
70 changed files with 551 additions and 515 deletions
@@ -83,6 +83,7 @@ import { normalizeWorkspaceDescriptor, useSessionStore } from "@/stores/session-
import { createNameId } from "mnemonic-id";
import { buildWorkspaceArchiveRedirectRoute } from "@/utils/workspace-archive-navigation";
import { openExternalUrl } from "@/utils/open-external-url";
import { isWeb as platformIsWeb, isNative as platformIsNative } from "@/constants/platform";
function toProjectIconDataUri(icon: { mimeType: string; data: string } | null): string | null {
if (!icon) {
@@ -199,8 +200,8 @@ function WorkspacePrBadge({ hint }: { hint: PrHint }) {
hitSlop={4}
onPressIn={handlePressIn}
onPress={handlePress}
onPointerEnter={() => setIsHovered(true)}
onPointerLeave={() => setIsHovered(false)}
onHoverIn={() => setIsHovered(true)}
onHoverOut={() => setIsHovered(false)}
style={({ pressed }) => [styles.workspacePrBadge, pressed && styles.workspacePrBadgePressed]}
>
<GitPullRequest size={12} color={iconColor} />
@@ -543,7 +544,7 @@ function useLongPressDragInteraction(input: {
input.drag();
}, DRAG_ARM_DELAY_MS);
if (!input.menuController || Platform.OS === "web") {
if (!input.menuController || platformIsWeb) {
return;
}
@@ -793,7 +794,7 @@ function ProjectHeaderRow({
<NewWorktreeButton
displayName={displayName}
onPress={() => createWorktreeMutation.mutate()}
visible={isHovered || isMobileBreakpoint}
visible={isHovered || platformIsNative || isMobileBreakpoint}
loading={createWorktreeMutation.isPending}
showShortcutHint={isProjectActive}
testID={`sidebar-project-new-worktree-${project.projectKey}`}
@@ -801,8 +802,8 @@ function ProjectHeaderRow({
) : null}
{onRemoveProject ? (
<View
style={!(isHovered || isMobileBreakpoint) && styles.projectKebabButtonHidden}
pointerEvents={isHovered || isMobileBreakpoint ? "auto" : "none"}
style={!(isHovered || platformIsNative || isMobileBreakpoint) && styles.projectKebabButtonHidden}
pointerEvents={isHovered || platformIsNative || isMobileBreakpoint ? "auto" : "none"}
>
<DropdownMenu>
<DropdownMenuTrigger
@@ -847,31 +848,8 @@ function ProjectHeaderRow({
if (menuController) {
return (
<View onPointerEnter={() => setIsHovered(true)} onPointerLeave={() => setIsHovered(false)}>
<ContextMenuTrigger
enabledOnMobile={false}
style={({ pressed }) => [
styles.projectRow,
isDragging && styles.projectRowDragging,
selected && styles.sidebarRowSelected,
isHovered && styles.projectRowHovered,
pressed && styles.projectRowPressed,
]}
onPressIn={interaction.handlePressIn}
onTouchMove={interaction.handleTouchMove}
onPressOut={interaction.handlePressOut}
onPress={handlePress}
testID={`sidebar-project-row-${project.projectKey}`}
>
{rowChildren}
</ContextMenuTrigger>
</View>
);
}
return (
<View onPointerEnter={() => setIsHovered(true)} onPointerLeave={() => setIsHovered(false)}>
<Pressable
<ContextMenuTrigger
enabledOnMobile={false}
style={({ pressed }) => [
styles.projectRow,
isDragging && styles.projectRowDragging,
@@ -879,6 +857,8 @@ function ProjectHeaderRow({
isHovered && styles.projectRowHovered,
pressed && styles.projectRowPressed,
]}
onHoverIn={() => setIsHovered(true)}
onHoverOut={() => setIsHovered(false)}
onPressIn={interaction.handlePressIn}
onTouchMove={interaction.handleTouchMove}
onPressOut={interaction.handlePressOut}
@@ -886,8 +866,29 @@ function ProjectHeaderRow({
testID={`sidebar-project-row-${project.projectKey}`}
>
{rowChildren}
</Pressable>
</View>
</ContextMenuTrigger>
);
}
return (
<Pressable
style={({ pressed }) => [
styles.projectRow,
isDragging && styles.projectRowDragging,
selected && styles.sidebarRowSelected,
isHovered && styles.projectRowHovered,
pressed && styles.projectRowPressed,
]}
onHoverIn={() => setIsHovered(true)}
onHoverOut={() => setIsHovered(false)}
onPressIn={interaction.handlePressIn}
onTouchMove={interaction.handleTouchMove}
onPressOut={interaction.handlePressOut}
onPress={handlePress}
testID={`sidebar-project-row-${project.projectKey}`}
>
{rowChildren}
</Pressable>
);
}
@@ -912,8 +913,8 @@ function WorkspaceRowInner({
archiveShortcutKeys,
}: WorkspaceRowInnerProps) {
const { theme } = useUnistyles();
const isCompact = useIsCompactFormFactor();
const [isHovered, setIsHovered] = useState(false);
const isTouchPlatform = Platform.OS !== "web";
const prHint = useWorkspacePrHint({
serverId: workspace.serverId,
cwd: workspace.workspaceId,
@@ -933,121 +934,118 @@ function WorkspaceRowInner({
}, [interaction.didLongPressRef, onPress]);
return (
<View
style={styles.workspaceRowContainer}
onPointerEnter={() => setIsHovered(true)}
onPointerLeave={() => setIsHovered(false)}
<Pressable
disabled={isArchiving}
style={({ pressed }) => [
styles.workspaceRowContainer,
styles.workspaceRow,
isDragging && styles.workspaceRowDragging,
selected && styles.sidebarRowSelected,
isHovered && styles.workspaceRowHovered,
pressed && styles.workspaceRowPressed,
]}
onHoverIn={() => setIsHovered(true)}
onHoverOut={() => setIsHovered(false)}
onPressIn={interaction.handlePressIn}
onTouchMove={interaction.handleTouchMove}
onPressOut={interaction.handlePressOut}
onPress={handlePress}
testID={`sidebar-workspace-row-${workspace.workspaceKey}`}
>
<Pressable
disabled={isArchiving}
style={({ pressed }) => [
styles.workspaceRow,
isDragging && styles.workspaceRowDragging,
selected && styles.sidebarRowSelected,
isHovered && styles.workspaceRowHovered,
pressed && styles.workspaceRowPressed,
]}
onPressIn={interaction.handlePressIn}
onTouchMove={interaction.handleTouchMove}
onPressOut={interaction.handlePressOut}
onPress={handlePress}
testID={`sidebar-workspace-row-${workspace.workspaceKey}`}
>
<View style={styles.workspaceRowMain}>
<View
{...(dragHandleProps?.attributes as any)}
{...(dragHandleProps?.listeners as any)}
ref={dragHandleProps?.setActivatorNodeRef as any}
style={styles.workspaceRowLeft}
<View style={styles.workspaceRowMain}>
<View
{...(dragHandleProps?.attributes as any)}
{...(dragHandleProps?.listeners as any)}
ref={dragHandleProps?.setActivatorNodeRef as any}
style={styles.workspaceRowLeft}
>
<WorkspaceStatusIndicator
bucket={workspace.statusBucket}
workspaceKind={workspace.workspaceKind}
loading={isArchiving || isCreating}
/>
<Text
style={[
styles.workspaceBranchText,
isHovered && styles.workspaceBranchTextHovered,
isCreating && styles.workspaceBranchTextCreating,
]}
numberOfLines={1}
>
<WorkspaceStatusIndicator
bucket={workspace.statusBucket}
workspaceKind={workspace.workspaceKind}
loading={isArchiving || isCreating}
/>
<Text
style={[
styles.workspaceBranchText,
isHovered && styles.workspaceBranchTextHovered,
isCreating && styles.workspaceBranchTextCreating,
]}
numberOfLines={1}
>
{workspace.name}
</Text>
</View>
<View style={styles.workspaceRowRight}>
{isCreating ? <Text style={styles.workspaceCreatingText}>Creating...</Text> : null}
{onArchive && (isHovered || isTouchPlatform) ? (
<DropdownMenu>
<DropdownMenuTrigger
hitSlop={8}
style={({ hovered = false }) => [
styles.kebabButton,
hovered && styles.kebabButtonHovered,
]}
accessibilityRole="button"
accessibilityLabel="Workspace actions"
testID={`sidebar-workspace-kebab-${workspace.workspaceKey}`}
>
{({ hovered }) => (
<MoreVertical
size={14}
color={hovered ? theme.colors.foreground : theme.colors.foregroundMuted}
/>
)}
</DropdownMenuTrigger>
<DropdownMenuContent align="end" width={260}>
{onCopyPath ? (
<DropdownMenuItem
testID={`sidebar-workspace-menu-copy-path-${workspace.workspaceKey}`}
leading={<Copy size={14} color={theme.colors.foregroundMuted} />}
onSelect={onCopyPath}
>
Copy path
</DropdownMenuItem>
) : null}
{onCopyBranchName ? (
<DropdownMenuItem
testID={`sidebar-workspace-menu-copy-branch-name-${workspace.workspaceKey}`}
leading={<Copy size={14} color={theme.colors.foregroundMuted} />}
onSelect={onCopyBranchName}
>
Copy branch name
</DropdownMenuItem>
) : null}
<DropdownMenuItem
testID={`sidebar-workspace-menu-archive-${workspace.workspaceKey}`}
leading={<Archive size={14} color={theme.colors.foregroundMuted} />}
trailing={archiveShortcutKeys ? <Shortcut chord={archiveShortcutKeys} /> : null}
status={archiveStatus}
pendingLabel={archivePendingLabel}
onSelect={onArchive}
>
{archiveLabel ?? "Archive"}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
) : workspace.diffStat ? (
<View style={styles.diffStatRow}>
<Text style={styles.diffStatAdditions}>+{workspace.diffStat.additions}</Text>
<Text style={styles.diffStatDeletions}>-{workspace.diffStat.deletions}</Text>
</View>
) : null}
{showShortcutBadge && shortcutNumber !== null ? (
<View style={styles.shortcutBadge}>
<Text style={styles.shortcutBadgeText}>{shortcutNumber}</Text>
</View>
) : null}
</View>
{workspace.name}
</Text>
</View>
{prHint ? (
<View style={styles.workspacePrBadgeRow}>
<WorkspacePrBadge hint={prHint} />
</View>
) : null}
</Pressable>
</View>
<View style={styles.workspaceRowRight}>
{isCreating ? <Text style={styles.workspaceCreatingText}>Creating...</Text> : null}
{onArchive && (isHovered || platformIsNative || isCompact) ? (
<DropdownMenu>
<DropdownMenuTrigger
hitSlop={8}
style={({ hovered = false }) => [
styles.kebabButton,
hovered && styles.kebabButtonHovered,
]}
accessibilityRole="button"
accessibilityLabel="Workspace actions"
testID={`sidebar-workspace-kebab-${workspace.workspaceKey}`}
>
{({ hovered }) => (
<MoreVertical
size={14}
color={hovered ? theme.colors.foreground : theme.colors.foregroundMuted}
/>
)}
</DropdownMenuTrigger>
<DropdownMenuContent align="end" width={260}>
{onCopyPath ? (
<DropdownMenuItem
testID={`sidebar-workspace-menu-copy-path-${workspace.workspaceKey}`}
leading={<Copy size={14} color={theme.colors.foregroundMuted} />}
onSelect={onCopyPath}
>
Copy path
</DropdownMenuItem>
) : null}
{onCopyBranchName ? (
<DropdownMenuItem
testID={`sidebar-workspace-menu-copy-branch-name-${workspace.workspaceKey}`}
leading={<Copy size={14} color={theme.colors.foregroundMuted} />}
onSelect={onCopyBranchName}
>
Copy branch name
</DropdownMenuItem>
) : null}
<DropdownMenuItem
testID={`sidebar-workspace-menu-archive-${workspace.workspaceKey}`}
leading={<Archive size={14} color={theme.colors.foregroundMuted} />}
trailing={archiveShortcutKeys ? <Shortcut chord={archiveShortcutKeys} /> : null}
status={archiveStatus}
pendingLabel={archivePendingLabel}
onSelect={onArchive}
>
{archiveLabel ?? "Archive"}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
) : workspace.diffStat ? (
<View style={styles.diffStatRow}>
<Text style={styles.diffStatAdditions}>+{workspace.diffStat.additions}</Text>
<Text style={styles.diffStatDeletions}>-{workspace.diffStat.deletions}</Text>
</View>
) : null}
{showShortcutBadge && shortcutNumber !== null ? (
<View style={styles.shortcutBadge}>
<Text style={styles.shortcutBadgeText}>{shortcutNumber}</Text>
</View>
) : null}
</View>
</View>
{prHint ? (
<View style={styles.workspacePrBadgeRow}>
<WorkspacePrBadge hint={prHint} />
</View>
) : null}
</Pressable>
);
}
@@ -1597,7 +1595,6 @@ export function SidebarWorkspaceList({
parentGestureRef,
}: SidebarWorkspaceListProps) {
const isMobile = useIsCompactFormFactor();
const isNative = Platform.OS !== "web";
const pathname = usePathname();
const activeWorkspaceSelection = useNavigationActiveWorkspaceSelection();
const [creatingWorkspaceIds, setCreatingWorkspaceIds] = useState<Set<string>>(() => new Set());
@@ -1831,7 +1828,7 @@ export function SidebarWorkspaceList({
drag={drag}
isDragging={isActive}
dragHandleProps={dragHandleProps}
useNestable={isNative}
useNestable={platformIsNative}
creatingWorkspaceIds={creatingWorkspaceIds}
/>
);
@@ -1848,7 +1845,7 @@ export function SidebarWorkspaceList({
serverId,
shortcutIndexByWorkspaceKey,
showShortcutBadges,
isNative,
platformIsNative,
creatingWorkspaceIds,
],
);
@@ -1872,7 +1869,7 @@ export function SidebarWorkspaceList({
onDragEnd={handleProjectDragEnd}
scrollEnabled={false}
useDragHandle
nestable={isNative}
nestable={platformIsNative}
simultaneousGestureRef={parentGestureRef}
containerStyle={styles.projectListContainer}
/>
@@ -1883,7 +1880,7 @@ export function SidebarWorkspaceList({
return (
<View style={styles.container}>
{isNative ? (
{platformIsNative ? (
<NestableScrollContainer
style={styles.list}
contentContainerStyle={styles.listContent}