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:
@@ -1,7 +1,7 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
|
||||
import { createNameId } from "mnemonic-id";
|
||||
import type { ImageAttachment } from "@/components/message-input";
|
||||
import { View, Text, Pressable, ScrollView, Keyboard, Platform } from "react-native";
|
||||
import { View, Text, Pressable, ScrollView, Keyboard } from "react-native";
|
||||
import { useLocalSearchParams, useRouter } from "expo-router";
|
||||
import { useIsFocused } from "@react-navigation/native";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
@@ -57,6 +57,7 @@ import { normalizeAgentSnapshot } from "@/utils/agent-snapshots";
|
||||
import { useAgentInputDraft } from "@/hooks/use-agent-input-draft";
|
||||
import { useDraftAgentCreateFlow } from "@/hooks/use-draft-agent-create-flow";
|
||||
import { useDraftAgentFeatures } from "@/hooks/use-draft-agent-features";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
|
||||
const EMPTY_PENDING_PERMISSIONS = new Map();
|
||||
const DRAFT_CAPABILITIES: AgentCapabilityFlags = {
|
||||
@@ -850,7 +851,7 @@ function DraftAgentScreenContent({
|
||||
},
|
||||
onBeforeSubmit: () => {
|
||||
void persistFormPreferences();
|
||||
if (Platform.OS === "web") {
|
||||
if (isWeb) {
|
||||
(document.activeElement as HTMLElement | null)?.blur?.();
|
||||
}
|
||||
Keyboard.dismiss();
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState, useEffect, useRef, useCallback } from "react";
|
||||
import type { MutableRefObject, ComponentType } from "react";
|
||||
import { View, Text, ScrollView, Alert, Platform, Pressable } from "react-native";
|
||||
import { View, Text, ScrollView, Alert, Pressable } from "react-native";
|
||||
import { router, useLocalSearchParams } from "expo-router";
|
||||
import { useFocusEffect } from "@react-navigation/native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
@@ -72,6 +72,7 @@ import { AGENT_PROVIDER_DEFINITIONS } from "@server/server/agent/provider-manife
|
||||
import { getProviderIcon } from "@/components/provider-icons";
|
||||
import { ProviderDiagnosticSheet } from "@/components/provider-diagnostic-sheet";
|
||||
import { StatusBadge } from "@/components/ui/status-badge";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Section definitions
|
||||
@@ -1672,22 +1673,20 @@ function DaemonCard({ daemon, onOpenSettings }: DaemonCardProps) {
|
||||
<View style={styles.hostHeaderRight}>
|
||||
<View
|
||||
style={[
|
||||
Platform.OS === "web" ? styles.statusPill : styles.statusPillMobile,
|
||||
isWeb ? styles.statusPill : styles.statusPillMobile,
|
||||
{ backgroundColor: statusPillBg },
|
||||
]}
|
||||
>
|
||||
<View style={[styles.statusDot, { backgroundColor: statusColor }]} />
|
||||
{Platform.OS === "web" ? (
|
||||
{isWeb ? (
|
||||
<Text style={[styles.statusText, { color: statusColor }]}>{badgeText}</Text>
|
||||
) : null}
|
||||
</View>
|
||||
|
||||
{connectionBadge ? (
|
||||
<View
|
||||
style={Platform.OS === "web" ? styles.connectionPill : styles.connectionPillMobile}
|
||||
>
|
||||
<View style={isWeb ? styles.connectionPill : styles.connectionPillMobile}>
|
||||
{connectionBadge.icon}
|
||||
{Platform.OS === "web" ? (
|
||||
{isWeb ? (
|
||||
<Text style={styles.connectionText} numberOfLines={1}>
|
||||
{connectionBadge.text}
|
||||
</Text>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { View, Text, Platform } from "react-native";
|
||||
import { View, Text } from "react-native";
|
||||
import { useIsFocused } from "@react-navigation/native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { settingsStyles } from "@/styles/settings";
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
|
||||
import { getShortcutOs } from "@/utils/shortcut-platform";
|
||||
import { getIsElectronRuntime } from "@/constants/layout";
|
||||
import { isNative } from "@/constants/platform";
|
||||
|
||||
function ShortcutSequence({
|
||||
chord,
|
||||
@@ -138,7 +139,7 @@ export function KeyboardShortcutsSection() {
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (Platform.OS !== "web") return;
|
||||
if (isNative) return;
|
||||
if (capturingBindingId === null) return;
|
||||
|
||||
function handleKeyDown(event: KeyboardEvent) {
|
||||
@@ -173,7 +174,7 @@ export function KeyboardShortcutsSection() {
|
||||
};
|
||||
}, [setCapturingShortcut]);
|
||||
|
||||
if (Platform.OS !== "web") {
|
||||
if (isNative) {
|
||||
return (
|
||||
<View style={settingsStyles.section}>
|
||||
<Text style={settingsStyles.sectionTitle}>Shortcuts</Text>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { ActivityIndicator, Platform, ScrollView, Text, View } from "react-native";
|
||||
import { ActivityIndicator, ScrollView, Text, View } from "react-native";
|
||||
import * as Clipboard from "expo-clipboard";
|
||||
import { openExternalUrl } from "@/utils/open-external-url";
|
||||
import { BookOpen, Check, Copy, RotateCw, TriangleAlert } from "lucide-react-native";
|
||||
@@ -9,6 +9,7 @@ import { Button } from "@/components/ui/button";
|
||||
import { Fonts } from "@/constants/theme";
|
||||
import { getDesktopDaemonLogs, type DesktopDaemonLogs } from "@/desktop/daemon/desktop-daemon";
|
||||
import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
|
||||
type StartupSplashScreenProps = {
|
||||
bootstrapState?: {
|
||||
@@ -42,7 +43,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
},
|
||||
errorScrollView: {
|
||||
flex: 1,
|
||||
...(Platform.OS === "web"
|
||||
...(isWeb
|
||||
? {
|
||||
overflowX: "auto",
|
||||
overflowY: "auto",
|
||||
@@ -144,7 +145,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
fontSize: theme.fontSize.xs,
|
||||
color: theme.colors.foreground,
|
||||
lineHeight: 18,
|
||||
...(Platform.OS === "web"
|
||||
...(isWeb
|
||||
? {
|
||||
whiteSpace: "pre",
|
||||
overflowWrap: "normal",
|
||||
|
||||
@@ -9,7 +9,6 @@ import {
|
||||
} from "react";
|
||||
import {
|
||||
ActivityIndicator,
|
||||
Platform,
|
||||
Pressable,
|
||||
ScrollView,
|
||||
Text,
|
||||
@@ -30,6 +29,7 @@ import {
|
||||
} from "lucide-react-native";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { SortableInlineList } from "@/components/sortable-inline-list";
|
||||
import { isNative, isWeb } from "@/constants/platform";
|
||||
import {
|
||||
ContextMenu,
|
||||
ContextMenuContent,
|
||||
@@ -113,6 +113,7 @@ function useMiddleClickClose(onClose: () => void) {
|
||||
const ref = useRef<View>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (isNative) return;
|
||||
const node = ref.current as unknown as HTMLElement | null;
|
||||
if (!node) return;
|
||||
|
||||
@@ -174,17 +175,16 @@ function TabChip({
|
||||
);
|
||||
const [hovered, setHovered] = useState(false);
|
||||
const isHighlighted = isActive || hovered || isCloseHovered;
|
||||
const closeButtonDragBlockers =
|
||||
Platform.OS === "web"
|
||||
? ({
|
||||
onPointerDown: (event: { stopPropagation?: () => void }) => {
|
||||
event.stopPropagation?.();
|
||||
},
|
||||
onMouseDown: (event: { stopPropagation?: () => void }) => {
|
||||
event.stopPropagation?.();
|
||||
},
|
||||
} as const)
|
||||
: undefined;
|
||||
const closeButtonDragBlockers = isWeb
|
||||
? ({
|
||||
onPointerDown: (event: { stopPropagation?: () => void }) => {
|
||||
event.stopPropagation?.();
|
||||
},
|
||||
onMouseDown: (event: { stopPropagation?: () => void }) => {
|
||||
event.stopPropagation?.();
|
||||
},
|
||||
} as const)
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<View ref={middleClickRef}>
|
||||
@@ -199,7 +199,7 @@ function TabChip({
|
||||
enabledOnMobile={false}
|
||||
style={({ hovered, pressed }) => [
|
||||
styles.tab,
|
||||
Platform.OS === "web" && isDragging && ({ cursor: "grabbing" } as const),
|
||||
isWeb && isDragging && ({ cursor: "grabbing" } as const),
|
||||
{
|
||||
minWidth: resolvedTabWidth,
|
||||
width: resolvedTabWidth,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useCallback, useEffect, useMemo, useRef } from "react";
|
||||
import { Keyboard, Platform, ScrollView, Text, View } from "react-native";
|
||||
import { Keyboard, ScrollView, Text, View } from "react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { AgentInputArea } from "@/components/agent-input-area";
|
||||
import { FileDropZone } from "@/components/file-drop-zone";
|
||||
@@ -19,6 +19,7 @@ import type {
|
||||
AgentSessionConfig,
|
||||
} from "@server/server/agent/agent-sdk-types";
|
||||
import type { AgentSnapshotPayload } from "@server/shared/messages";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
|
||||
const EMPTY_PENDING_PERMISSIONS = new Map();
|
||||
const DRAFT_CAPABILITIES: AgentCapabilityFlags = {
|
||||
@@ -155,7 +156,7 @@ export function WorkspaceDraftAgentTab({
|
||||
},
|
||||
onBeforeSubmit: () => {
|
||||
void persistFormPreferences();
|
||||
if (Platform.OS === "web") {
|
||||
if (isWeb) {
|
||||
(document.activeElement as HTMLElement | null)?.blur?.();
|
||||
}
|
||||
Keyboard.dismiss();
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useCallback, useEffect, useMemo } from "react";
|
||||
import { ActivityIndicator, Platform, Pressable, Text, View } from "react-native";
|
||||
import { ActivityIndicator, Pressable, Text, View } from "react-native";
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import { Check, ChevronDown } from "lucide-react-native";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
@@ -15,6 +15,7 @@ import { useToast } from "@/contexts/toast-context";
|
||||
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
||||
import { resolvePreferredEditorId, usePreferredEditor } from "@/hooks/use-preferred-editor";
|
||||
import { isAbsolutePath } from "@/utils/path";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
|
||||
interface WorkspaceOpenInEditorButtonProps {
|
||||
serverId: string;
|
||||
@@ -29,10 +30,7 @@ export function WorkspaceOpenInEditorButton({ serverId, cwd }: WorkspaceOpenInEd
|
||||
const { preferredEditorId, updatePreferredEditor } = usePreferredEditor();
|
||||
|
||||
const shouldLoadEditors =
|
||||
Platform.OS === "web" &&
|
||||
Boolean(client && isConnected) &&
|
||||
cwd.trim().length > 0 &&
|
||||
isAbsolutePath(cwd);
|
||||
isWeb && Boolean(client && isConnected) && cwd.trim().length > 0 && isAbsolutePath(cwd);
|
||||
|
||||
const availableEditorsQuery = useQuery<EditorTargetDescriptorPayload[]>({
|
||||
queryKey: ["available-editors", serverId],
|
||||
|
||||
@@ -1,15 +1,7 @@
|
||||
import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useStoreWithEqualityFn } from "zustand/traditional";
|
||||
import { useIsFocused } from "@react-navigation/native";
|
||||
import {
|
||||
ActivityIndicator,
|
||||
BackHandler,
|
||||
Keyboard,
|
||||
Platform,
|
||||
Pressable,
|
||||
Text,
|
||||
View,
|
||||
} from "react-native";
|
||||
import { ActivityIndicator, BackHandler, Keyboard, Pressable, Text, View } from "react-native";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import * as Clipboard from "expo-clipboard";
|
||||
import {
|
||||
@@ -118,6 +110,7 @@ import {
|
||||
} from "@/screens/workspace/workspace-bulk-close";
|
||||
import { findAdjacentPane } from "@/utils/split-navigation";
|
||||
import { useIsCompactFormFactor, supportsDesktopPaneSplits } from "@/constants/layout";
|
||||
import { isWeb, isNative } from "@/constants/platform";
|
||||
|
||||
const TERMINALS_QUERY_STALE_TIME = 5_000;
|
||||
const NEW_TAB_AGENT_OPTION_ID = "__new_tab_agent__";
|
||||
@@ -252,7 +245,7 @@ function WorkspaceDocumentTitleEffect({
|
||||
titleState: "ready" | "loading";
|
||||
}) {
|
||||
useEffect(() => {
|
||||
if (Platform.OS !== "web" || typeof document === "undefined") {
|
||||
if (isNative || typeof document === "undefined") {
|
||||
return;
|
||||
}
|
||||
const resolvedLabel = label.trim();
|
||||
@@ -810,7 +803,7 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (Platform.OS === "web" || !isExplorerOpen) {
|
||||
if (isWeb || !isExplorerOpen) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1709,7 +1702,7 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
|
||||
const canRenderDesktopPaneSplits = supportsDesktopPaneSplits();
|
||||
const shouldRenderDesktopPaneFallback = !isMobile && !canRenderDesktopPaneSplits;
|
||||
useEffect(() => {
|
||||
if (Platform.OS !== "web" || typeof document === "undefined" || activeTabDescriptor) {
|
||||
if (isNative || typeof document === "undefined" || activeTabDescriptor) {
|
||||
return;
|
||||
}
|
||||
document.title = "Workspace";
|
||||
@@ -1933,7 +1926,7 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
|
||||
|
||||
return (
|
||||
<View style={[styles.container, { backgroundColor: mainBackgroundColor }]}>
|
||||
{Platform.OS === "web" && activeTabDescriptor ? (
|
||||
{isWeb && activeTabDescriptor ? (
|
||||
<WorkspaceTabPresentationResolver
|
||||
tab={activeTabDescriptor}
|
||||
serverId={normalizedServerId}
|
||||
|
||||
Reference in New Issue
Block a user