From 0f7fa55a0f24bc1dbc7498cebe0475fa090ce0df Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Mon, 13 Apr 2026 13:24:00 +0700 Subject: [PATCH] Centralize platform gating and fix iPad/tablet support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- CLAUDE.md | 40 +++ packages/app/src/app/_layout.tsx | 13 +- .../workspace/[workspaceId]/_layout.tsx | 4 +- packages/app/src/app/pair-scan.tsx | 7 +- packages/app/src/attachments/store.ts | 4 +- .../src/components/adaptive-modal-sheet.tsx | 5 +- .../src/components/add-host-method-modal.tsx | 5 +- .../agent-form/agent-form-dropdowns.tsx | 7 +- .../app/src/components/agent-input-area.tsx | 11 +- .../app/src/components/agent-status-bar.tsx | 9 +- .../app/src/components/agent-stream-view.tsx | 3 +- .../components/combined-model-selector.tsx | 17 +- .../app/src/components/command-center.tsx | 5 +- .../desktop/titlebar-drag-region.tsx | 4 +- packages/app/src/components/diff-viewer.tsx | 7 +- .../app/src/components/explorer-sidebar.tsx | 11 +- .../app/src/components/file-drop-zone.tsx | 5 +- .../app/src/components/file-explorer-pane.tsx | 4 +- packages/app/src/components/file-pane.tsx | 4 +- packages/app/src/components/git-diff-pane.tsx | 10 +- .../headers/header-toggle-button.tsx | 12 +- packages/app/src/components/left-sidebar.tsx | 11 +- packages/app/src/components/message-input.tsx | 6 +- packages/app/src/components/message.tsx | 27 +- .../src/components/project-picker-modal.tsx | 5 +- .../app/src/components/question-form-card.tsx | 5 +- .../src/components/sidebar-workspace-list.tsx | 299 +++++++++--------- .../app/src/components/split-container.tsx | 5 +- packages/app/src/components/toast-host.tsx | 7 +- .../app/src/components/tool-call-details.tsx | 7 +- packages/app/src/components/ui/combobox.tsx | 17 +- .../app/src/components/ui/context-menu.tsx | 8 +- packages/app/src/components/ui/tooltip.tsx | 26 +- .../app/src/components/use-web-scrollbar.tsx | 8 +- .../src/components/web-desktop-scrollbar.tsx | 21 +- .../app/src/components/welcome-screen.tsx | 96 +++--- packages/app/src/constants/layout.ts | 46 +-- packages/app/src/constants/platform.ts | 49 +++ packages/app/src/contexts/session-context.tsx | 5 +- .../permissions/desktop-permissions.ts | 16 +- .../src/desktop/updates/desktop-updates.ts | 4 +- .../src/hooks/use-agent-attention-clear.ts | 5 +- .../app/src/hooks/use-agent-initialization.ts | 4 +- packages/app/src/hooks/use-app-visible.ts | 7 +- packages/app/src/hooks/use-client-activity.ts | 7 +- packages/app/src/hooks/use-favicon-status.ts | 14 +- packages/app/src/hooks/use-file-drop-zone.ts | 4 +- .../src/hooks/use-image-attachment-picker.ts | 3 +- .../app/src/hooks/use-keyboard-shortcuts.ts | 4 +- .../src/hooks/use-push-token-registration.ts | 5 +- packages/app/src/panels/agent-panel.tsx | 5 +- .../src/screens/agent/draft-agent-screen.tsx | 5 +- packages/app/src/screens/settings-screen.tsx | 13 +- .../settings/keyboard-shortcuts-section.tsx | 7 +- .../app/src/screens/startup-splash-screen.tsx | 7 +- .../workspace/workspace-desktop-tabs-row.tsx | 26 +- .../workspace/workspace-draft-agent-tab.tsx | 5 +- .../workspace-open-in-editor-button.tsx | 8 +- .../screens/workspace/workspace-screen.tsx | 19 +- packages/app/src/stores/download-store.ts | 8 +- packages/app/src/stores/panel-store.ts | 12 +- packages/app/src/styles/markdown-styles.ts | 4 +- packages/app/src/utils/app-visibility.ts | 5 +- packages/app/src/utils/confirm-dialog.ts | 9 +- packages/app/src/utils/desktop-window.ts | 4 +- packages/app/src/utils/open-external-url.ts | 4 +- packages/app/src/utils/os-notifications.ts | 6 +- .../src/utils/scroll-jank-investigation.ts | 4 +- packages/app/src/utils/shortcut-platform.ts | 3 +- packages/app/src/utils/thinking-tone.ts | 4 +- 70 files changed, 551 insertions(+), 515 deletions(-) create mode 100644 packages/app/src/constants/platform.ts diff --git a/CLAUDE.md b/CLAUDE.md index dfb9b88d0..e76ec0aa8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -55,6 +55,46 @@ See [docs/DEVELOPMENT.md](docs/DEVELOPMENT.md) for full setup, build sync requir - Never narrow a field's type (e.g. `string` → `enum`, `nullable` → non-null). - Test with: "does a 6-month-old client still parse this?" and "does a 6-month-old daemon still send something this client accepts?" +## Platform gating + +The app runs on iOS, Android, web (browser), and web (Electron desktop). Code is cross-platform by default. Gate only when you must. Import gates from `@/constants/platform`. + +### The four gates + +| Gate | Type | When to use | +|---|---|---| +| `isWeb` | constant | DOM APIs — `document`, `window`, `
`, `addEventListener`, `ResizeObserver`. This is the **exception**, not the default. | +| `isNative` | constant | Native-only APIs — Haptics, `StatusBar.currentHeight`, push tokens, camera/scanner, `expo-av`. | +| `getIsElectron()` | cached fn | Desktop wrapper features — file dialogs, titlebar drag region, daemon management, app updates, dock badges. | +| `useIsCompactFormFactor()` | hook | Layout decisions — sidebar overlay vs pinned, modal vs full screen, single-panel vs split. From `@/constants/layout`. | + +### Decision matrix + +| I need to... | Use | +|---|---| +| Access DOM (`document`, `window`, `
`, `addEventListener`) | `if (isWeb)` | +| Use a native-only API (Haptics, push tokens, camera) | `if (isNative)` | +| Use an Electron bridge (file dialog, titlebar, updates) | `if (getIsElectron())` | +| Switch layout between phone and tablet/desktop | `useIsCompactFormFactor()` | +| Show something on hover, always-visible on native | `isHovered \|\| isNative \|\| isCompact` (hover only works on web) | +| Gate to iOS or Android specifically | `Platform.OS === "ios"` / `Platform.OS === "android"` (rare, keep inline) | + +### Rules + +- **Default is cross-platform.** Don't gate unless you have a specific reason. +- **Prefer Metro file extensions over `if` statements.** When a module has fundamentally different implementations per platform, use `.web.ts` / `.native.ts` file extensions instead of runtime `if (isWeb)` branches. Metro resolves the correct file at build time — the unused platform code is never bundled. Reserve `if (isWeb)` for small, inline checks (a single line or a few props). If you find yourself writing a large `if (isWeb) { ... } else { ... }` block, split into separate files instead. + ``` + hooks/ + use-audio-recorder.web.ts ← uses Web Audio API + use-audio-recorder.native.ts ← uses expo-audio + ``` + Import as `@/hooks/use-audio-recorder` — Metro picks the right file automatically. +- **NEVER use raw DOM APIs without `isWeb` guard.** DOM APIs crash native. Casting a RN ref to `HTMLElement` is a red flag — ensure the block is web-only. +- **NEVER use `onPointerEnter`/`onPointerLeave`.** They don't fire on native iOS. +- **Hover only works on web.** React Native's `onHoverIn`/`onHoverOut` on `Pressable` does NOT fire on native iOS/iPad — the underlying W3C pointer events are behind disabled experimental flags. For hover-to-show UI (kebab menus, action buttons), use `isHovered || isNative || isCompact` so the controls are always visible on native and hover-to-show on web. +- **Don't use Platform.OS as a proxy for layout capabilities.** Use breakpoints for layout decisions, not platform checks. +- **Import `isWeb`/`isNative` from `@/constants/platform`.** Never write `const isWeb = Platform.OS === "web"` locally. + ## Debugging diff --git a/packages/app/src/app/_layout.tsx b/packages/app/src/app/_layout.tsx index 30fbb7df7..dd68ff244 100644 --- a/packages/app/src/app/_layout.tsx +++ b/packages/app/src/app/_layout.tsx @@ -81,6 +81,7 @@ import { parseWorkspaceOpenIntent, } from "@/utils/host-routes"; import { syncNavigationActiveWorkspace } from "@/stores/navigation-active-workspace-store"; +import { isWeb, isNative } from "@/constants/platform"; polyfillCrypto(); @@ -101,7 +102,7 @@ function PushNotificationRouter() { const lastHandledIdRef = useRef(null); useEffect(() => { - if (Platform.OS === "web") { + if (isWeb) { let removeDesktopNotificationListener: (() => void) | null = null; let cancelled = false; @@ -390,10 +391,10 @@ function AppContainer({ const screenW = UnistylesRuntime.screen.width; const screenH = UnistylesRuntime.screen.height; const isElectron = getIsElectronRuntime(); - const windowW = Platform.OS === "web" ? window.innerWidth : undefined; - const windowH = Platform.OS === "web" ? window.innerHeight : undefined; - const dpr = Platform.OS === "web" ? window.devicePixelRatio : undefined; - const ua = Platform.OS === "web" ? navigator.userAgent : undefined; + const windowW = isWeb ? window.innerWidth : undefined; + const windowH = isWeb ? window.innerHeight : undefined; + const dpr = isWeb ? window.devicePixelRatio : undefined; + const ua = isWeb ? navigator.userAgent : undefined; console.log( "[layout-debug]", @@ -577,7 +578,7 @@ function ProvidersWrapper({ children }: { children: ReactNode }) { }, [settingsLoading, settings.theme]); useEffect(() => { - if (settingsLoading || Platform.OS !== "web") { + if (settingsLoading || isNative) { return; } diff --git a/packages/app/src/app/h/[serverId]/workspace/[workspaceId]/_layout.tsx b/packages/app/src/app/h/[serverId]/workspace/[workspaceId]/_layout.tsx index 27fd8411f..bd584f8a0 100644 --- a/packages/app/src/app/h/[serverId]/workspace/[workspaceId]/_layout.tsx +++ b/packages/app/src/app/h/[serverId]/workspace/[workspaceId]/_layout.tsx @@ -1,6 +1,5 @@ import { useEffect, useRef, useState } from "react"; import { useGlobalSearchParams, useLocalSearchParams, useRootNavigationState } from "expo-router"; -import { Platform } from "react-native"; import { HostRouteBootstrapBoundary } from "@/components/host-route-bootstrap-boundary"; import type { WorkspaceTabTarget } from "@/stores/workspace-tabs-store"; import { WorkspaceScreen } from "@/screens/workspace/workspace-screen"; @@ -10,6 +9,7 @@ import { type WorkspaceOpenIntent, } from "@/utils/host-routes"; import { prepareWorkspaceTab } from "@/utils/workspace-navigation"; +import { isWeb } from "@/constants/platform"; function getParamValue(value: string | string[] | undefined): string { if (typeof value === "string") { @@ -88,7 +88,7 @@ function HostWorkspaceLayoutContent() { // Expo Router's replace ignores query-param-only changes (findDivergentState // skips search params). Strip ?open from the browser URL directly so the // address bar reflects the clean workspace route. - if (Platform.OS === "web" && typeof window !== "undefined") { + if (isWeb && typeof window !== "undefined") { const url = new URL(window.location.href); if (url.searchParams.has("open")) { url.searchParams.delete("open"); diff --git a/packages/app/src/app/pair-scan.tsx b/packages/app/src/app/pair-scan.tsx index 1594e2f20..fe647aa50 100644 --- a/packages/app/src/app/pair-scan.tsx +++ b/packages/app/src/app/pair-scan.tsx @@ -1,5 +1,5 @@ import { useCallback, useEffect, useRef, useState } from "react"; -import { Alert, Platform, Pressable, Text, View } from "react-native"; +import { Alert, Pressable, Text, View } from "react-native"; import { useLocalSearchParams, useRouter } from "expo-router"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; @@ -10,6 +10,7 @@ import { decodeOfferFragmentPayload, normalizeHostPort } from "@/utils/daemon-en import { connectToDaemon } from "@/utils/test-daemon-connection"; import { ConnectionOfferSchema } from "@server/shared/connection-offer"; import { buildHostRootRoute, buildHostSettingsRoute } from "@/utils/host-routes"; +import { isWeb } from "@/constants/platform"; const styles = StyleSheet.create((theme) => ({ container: { @@ -198,7 +199,7 @@ export default function PairScanScreen() { }, [router, source, sourceServerId, targetServerId]); useEffect(() => { - if (Platform.OS === "web") return; + if (isWeb) return; if (permission && permission.granted) return; void requestPermission().catch(() => undefined); }, [permission, requestPermission]); @@ -253,7 +254,7 @@ export default function PairScanScreen() { [daemons, isPairing, returnToSource, targetServerId, upsertDaemonFromOfferUrl], ); - if (Platform.OS === "web") { + if (isWeb) { return ( diff --git a/packages/app/src/attachments/store.ts b/packages/app/src/attachments/store.ts index ea6fd2dc1..ce0837766 100644 --- a/packages/app/src/attachments/store.ts +++ b/packages/app/src/attachments/store.ts @@ -1,11 +1,11 @@ -import { Platform } from "react-native"; import { isElectronRuntime } from "@/desktop/host"; import type { AttachmentStore } from "@/attachments/types"; +import { isWeb } from "@/constants/platform"; let attachmentStorePromise: Promise | null = null; async function createAttachmentStore(): Promise { - if (Platform.OS === "web") { + if (isWeb) { if (isElectronRuntime()) { const { createDesktopAttachmentStore } = await import( "../desktop/attachments/desktop-attachment-store" diff --git a/packages/app/src/components/adaptive-modal-sheet.tsx b/packages/app/src/components/adaptive-modal-sheet.tsx index c5f3edbff..f8837fcfe 100644 --- a/packages/app/src/components/adaptive-modal-sheet.tsx +++ b/packages/app/src/components/adaptive-modal-sheet.tsx @@ -1,7 +1,7 @@ import { forwardRef, useCallback, useEffect, useMemo, useRef } from "react"; import type { ReactNode } from "react"; import { createPortal } from "react-dom"; -import { Modal, Platform, Pressable, ScrollView, Text, TextInput, View } from "react-native"; +import { Modal, Pressable, ScrollView, Text, TextInput, View } from "react-native"; import type { TextInputProps } from "react-native"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { useIsCompactFormFactor } from "@/constants/layout"; @@ -14,6 +14,7 @@ import { type BottomSheetBackgroundProps, } from "@gorhom/bottom-sheet"; import { X } from "lucide-react-native"; +import { isWeb } from "@/constants/platform"; const styles = StyleSheet.create((theme) => ({ desktopOverlay: { @@ -216,7 +217,7 @@ export function AdaptiveModalSheet({ ); // On web, use portal to overlay root for consistent stacking with toasts - if (Platform.OS === "web" && typeof document !== "undefined") { + if (isWeb && typeof document !== "undefined") { if (!visible) return null; return createPortal(desktopContent, getOverlayRoot()); } diff --git a/packages/app/src/components/add-host-method-modal.tsx b/packages/app/src/components/add-host-method-modal.tsx index 35bc56255..e5364e06d 100644 --- a/packages/app/src/components/add-host-method-modal.tsx +++ b/packages/app/src/components/add-host-method-modal.tsx @@ -1,8 +1,9 @@ import { useCallback } from "react"; -import { Pressable, Text, View, Platform } from "react-native"; +import { Pressable, Text, View } from "react-native"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { QrCode, Link2, ClipboardPaste } from "lucide-react-native"; import { AdaptiveModalSheet } from "./adaptive-modal-sheet"; +import { isNative } from "@/constants/platform"; const styles = StyleSheet.create((theme) => ({ option: { @@ -78,7 +79,7 @@ export function AddHostMethodModal({ - {Platform.OS !== "web" ? ( + {isNative ? ( diff --git a/packages/app/src/components/agent-form/agent-form-dropdowns.tsx b/packages/app/src/components/agent-form/agent-form-dropdowns.tsx index 6e2d750bb..5c5b596c8 100644 --- a/packages/app/src/components/agent-form/agent-form-dropdowns.tsx +++ b/packages/app/src/components/agent-form/agent-form-dropdowns.tsx @@ -1,6 +1,6 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import type { ReactElement, ReactNode } from "react"; -import { View, Text, Pressable, TextInput, ActivityIndicator, Platform } from "react-native"; +import { View, Text, Pressable, TextInput, ActivityIndicator } from "react-native"; import type { StyleProp, ViewStyle, TextProps } from "react-native"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { @@ -32,6 +32,7 @@ import type { AgentProviderDefinition } from "@server/server/agent/provider-mani import { getModeVisuals, type AgentModeIcon } from "@server/server/agent/provider-manifest"; import { Combobox, ComboboxItem, ComboboxEmpty } from "@/components/ui/combobox"; import { baseColors } from "@/styles/theme"; +import { isNative } from "@/constants/platform"; const MODE_ICON_MAP: Record = { ShieldCheck, @@ -168,7 +169,7 @@ export function SelectField({ const handleKeyDown = useCallback( (event: unknown) => { - if (Platform.OS !== "web") return; + if (isNative) return; const key = getWebKey(event); if (key === "Enter" || key === " ") { preventWebDefault(event); @@ -430,7 +431,7 @@ export function FormSelectTrigger({ const handleKeyDown = useCallback( (event: unknown) => { - if (Platform.OS !== "web") return; + if (isNative) return; const key = getWebKey(event); if (key === "Enter" || key === " ") { preventWebDefault(event); diff --git a/packages/app/src/components/agent-input-area.tsx b/packages/app/src/components/agent-input-area.tsx index 6c57cac69..9bf29bc74 100644 --- a/packages/app/src/components/agent-input-area.tsx +++ b/packages/app/src/components/agent-input-area.tsx @@ -1,4 +1,4 @@ -import { View, Pressable, Text, ActivityIndicator, Platform } from "react-native"; +import { View, Pressable, Text, ActivityIndicator } from "react-native"; import { useState, useEffect, useRef, useCallback } from "react"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { useIsCompactFormFactor } from "@/constants/layout"; @@ -50,6 +50,7 @@ import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler"; import type { KeyboardActionDefinition } from "@/keyboard/keyboard-action-dispatcher"; import { submitAgentInput } from "@/components/agent-input-submit"; import { useAppSettings } from "@/hooks/use-settings"; +import { isWeb, isNative } from "@/constants/platform"; type QueuedMessage = { id: string; @@ -118,7 +119,7 @@ export function AgentInputArea({ }: AgentInputAreaProps) { markScrollInvestigationRender(`AgentInputArea:${serverId}:${agentId}`); const { theme } = useUnistyles(); - const buttonIconSize = Platform.OS === "web" ? theme.iconSize.md : theme.iconSize.lg; + const buttonIconSize = isWeb ? theme.iconSize.md : theme.iconSize.lg; const insets = useSafeAreaInsets(); const client = useHostRuntimeClient(serverId); const isConnected = useHostRuntimeIsConnected(serverId); @@ -156,7 +157,7 @@ export function AgentInputArea({ const setAgentStreamHead = useSessionStore((state) => state.setAgentStreamHead); const isMobile = useIsCompactFormFactor(); - const isDesktopWebBreakpoint = Platform.OS === "web" && !isMobile; + const isDesktopWebBreakpoint = isWeb && !isMobile; const messagePlaceholder = isDesktopWebBreakpoint ? DESKTOP_MESSAGE_PLACEHOLDER : MOBILE_MESSAGE_PLACEHOLDER; @@ -217,7 +218,7 @@ export function AgentInputArea({ }, [addImages, onAddImages]); const focusInput = useCallback(() => { - if (Platform.OS !== "web") return; + if (isNative) return; focusWithRetries({ focus: () => messageInputRef.current?.focus(), isFocused: () => { @@ -430,7 +431,7 @@ export function AgentInputArea({ case "message-input.dictation-confirm": return messageInputRef.current?.runKeyboardAction("dictation-confirm") ?? false; case "message-input.focus": - if (Platform.OS !== "web") { + if (isNative) { messageInputRef.current?.focus(); return true; } diff --git a/packages/app/src/components/agent-status-bar.tsx b/packages/app/src/components/agent-status-bar.tsx index 501963f7a..f18afc048 100644 --- a/packages/app/src/components/agent-status-bar.tsx +++ b/packages/app/src/components/agent-status-bar.tsx @@ -1,5 +1,5 @@ import { memo, useCallback, useMemo, useRef, useState } from "react"; -import { View, Text, Platform, Pressable, Keyboard } from "react-native"; +import { View, Text, Pressable, Keyboard } from "react-native"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { useShallow } from "zustand/shallow"; import { useStoreWithEqualityFn } from "zustand/traditional"; @@ -51,6 +51,7 @@ import { getStatusSelectorHint, resolveAgentModelSelection, } from "@/components/agent-status-bar.utils"; +import { isWeb as platformIsWeb } from "@/constants/platform"; type StatusOption = { id: string; @@ -222,7 +223,6 @@ function ControlledStatusBar({ onModelSelectorOpen, }: ControlledAgentStatusBarProps) { const { theme } = useUnistyles(); - const isWeb = Platform.OS === "web"; const [prefsOpen, setPrefsOpen] = useState(false); const [openSelector, setOpenSelector] = useState(null); @@ -356,7 +356,7 @@ function ControlledStatusBar({ return ( - {isWeb ? ( + {platformIsWeb ? ( <> {providerOptions && providerOptions.length > 0 ? ( <> @@ -1077,7 +1077,6 @@ export function DraftAgentStatusBar({ onModelSelectorOpen, disabled = false, }: DraftAgentStatusBarProps) { - const isWeb = Platform.OS === "web"; const { preferences, updatePreferences } = useFormPreferences(); const mappedModeOptions = useMemo(() => { @@ -1105,7 +1104,7 @@ export function DraftAgentStatusBar({ const effectiveSelectedThinkingOption = selectedThinkingOptionId || mappedThinkingOptions[0]?.id || undefined; - if (isWeb) { + if (platformIsWeb) { return ( item?.kind === "user_message"; const isToolSequenceItem = (item?: StreamItem) => @@ -216,7 +217,7 @@ const AgentStreamViewComponent = forwardRef { - if (autoFocus && Platform.OS === "web" && inputRef.current) { + if (autoFocus && platformIsWeb && inputRef.current) { const timer = setTimeout(() => { inputRef.current?.focus(); }, 50); @@ -363,7 +363,7 @@ function ProviderSearchInput({ (null); const [isOpen, setIsOpen] = useState(false); - const [isContentReady, setIsContentReady] = useState(isWeb); + const [isContentReady, setIsContentReady] = useState(platformIsWeb); const [view, setView] = useState({ kind: "all" }); const [searchQuery, setSearchQuery] = useState(""); @@ -591,7 +590,7 @@ export function CombinedModelSelector({ }, [selectedModelLabel, selectedProviderLabel]); useEffect(() => { - if (isWeb) { + if (platformIsWeb) { return; } @@ -605,7 +604,7 @@ export function CombinedModelSelector({ }); return () => cancelAnimationFrame(frame); - }, [isOpen, isWeb]); + }, [isOpen, platformIsWeb]); return ( <> @@ -668,7 +667,7 @@ export function CombinedModelSelector({ ) : undefined diff --git a/packages/app/src/components/command-center.tsx b/packages/app/src/components/command-center.tsx index 0854707f3..2f1bd0d83 100644 --- a/packages/app/src/components/command-center.tsx +++ b/packages/app/src/components/command-center.tsx @@ -1,4 +1,4 @@ -import { Modal, Pressable, ScrollView, Text, TextInput, View, Platform } from "react-native"; +import { Modal, Pressable, ScrollView, Text, TextInput, View } from "react-native"; import { memo, useEffect, useRef, type ReactNode } from "react"; import { Plus, Settings } from "lucide-react-native"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; @@ -8,6 +8,7 @@ import { formatTimeAgo } from "@/utils/time"; import { shortenPath } from "@/utils/shorten-path"; import { AgentStatusDot } from "@/components/agent-status-dot"; import { Shortcut } from "@/components/ui/shortcut"; +import { isNative } from "@/constants/platform"; function agentKey(agent: Pick): string { return `${agent.serverId}:${agent.id}`; @@ -90,7 +91,7 @@ export function CommandCenter() { } }, [activeIndex, open]); - if (Platform.OS !== "web" || !open) return null; + if (isNative || !open) return null; const actionItems = items.filter((item) => item.kind === "action"); const agentItems = items.filter((item) => item.kind === "agent"); diff --git a/packages/app/src/components/desktop/titlebar-drag-region.tsx b/packages/app/src/components/desktop/titlebar-drag-region.tsx index d51498a4b..a3067968d 100644 --- a/packages/app/src/components/desktop/titlebar-drag-region.tsx +++ b/packages/app/src/components/desktop/titlebar-drag-region.tsx @@ -1,5 +1,5 @@ -import { Platform } from "react-native"; import { getIsElectronRuntime } from "@/constants/layout"; +import { isNative } from "@/constants/platform"; /** * VS Code-style titlebar drag region for Electron. @@ -22,7 +22,7 @@ import { getIsElectronRuntime } from "@/constants/layout"; * Place as FIRST child of any positioned container that should be draggable. */ export function TitlebarDragRegion() { - if (Platform.OS !== "web" || !getIsElectronRuntime()) { + if (isNative || !getIsElectronRuntime()) { return null; } diff --git a/packages/app/src/components/diff-viewer.tsx b/packages/app/src/components/diff-viewer.tsx index 29a8d9fd9..7fe88e8df 100644 --- a/packages/app/src/components/diff-viewer.tsx +++ b/packages/app/src/components/diff-viewer.tsx @@ -1,12 +1,13 @@ import React from "react"; -import { View, Text, Platform, ScrollView as RNScrollView } from "react-native"; +import { View, Text, ScrollView as RNScrollView } from "react-native"; import { ScrollView as GHScrollView } from "react-native-gesture-handler"; import { StyleSheet } from "react-native-unistyles"; import { Fonts } from "@/constants/theme"; import type { DiffLine, DiffSegment } from "@/utils/tool-call-parsers"; import { getCodeInsets } from "./code-insets"; +import { isWeb } from "@/constants/platform"; -const ScrollView = Platform.OS === "web" ? RNScrollView : GHScrollView; +const ScrollView = isWeb ? RNScrollView : GHScrollView; interface DiffViewerProps { diffLines: DiffLine[]; @@ -130,7 +131,7 @@ const styles = StyleSheet.create((theme) => { fontFamily: Fonts.mono, fontSize: theme.fontSize.xs, color: theme.colors.foreground, - ...(Platform.OS === "web" + ...(isWeb ? { whiteSpace: "pre", overflowWrap: "normal", diff --git a/packages/app/src/components/explorer-sidebar.tsx b/packages/app/src/components/explorer-sidebar.tsx index 481aadb7a..99281e6a3 100644 --- a/packages/app/src/components/explorer-sidebar.tsx +++ b/packages/app/src/components/explorer-sidebar.tsx @@ -3,7 +3,6 @@ import { View, Text, Pressable, - Platform, useWindowDimensions, StyleSheet as RNStyleSheet, } from "react-native"; @@ -26,6 +25,7 @@ import { FileExplorerPane } from "./file-explorer-pane"; import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style"; import { useWindowControlsPadding } from "@/utils/desktop-window"; import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region"; +import { isWeb } from "@/constants/platform"; const MIN_CHAT_WIDTH = 400; function logExplorerSidebar(_event: string, _details: Record): void {} @@ -252,7 +252,7 @@ export function ExplorerSidebar({ // Mobile: full-screen overlay with gesture. // On web, keep it interactive only while open so closed sidebars don't eat taps. - const overlayPointerEvents = Platform.OS === "web" ? (isOpen ? "auto" : "none") : "box-none"; + const overlayPointerEvents = isWeb ? (isOpen ? "auto" : "none") : "box-none"; // Navigation stacks can keep previous screens mounted; hide sidebars for unfocused // screens so only the active screen exposes explorer/terminal surfaces. @@ -309,12 +309,7 @@ export function ExplorerSidebar({ {/* Resize handle - absolutely positioned over left border */} - + state.sessions[serverId]?.client ?? null); const normalizedWorkspaceRoot = useMemo(() => workspaceRoot.trim(), [workspaceRoot]); diff --git a/packages/app/src/components/git-diff-pane.tsx b/packages/app/src/components/git-diff-pane.tsx index 5208a4de4..cd873cc28 100644 --- a/packages/app/src/components/git-diff-pane.tsx +++ b/packages/app/src/components/git-diff-pane.tsx @@ -6,7 +6,6 @@ import { ActivityIndicator, Pressable, FlatList, - Platform, type LayoutChangeEvent, type NativeSyntheticEvent, type NativeScrollEvent, @@ -79,6 +78,7 @@ import { formatDiffGutterText, hasVisibleDiffTokens, } from "@/utils/diff-rendering"; +import { isWeb, isNative } from "@/constants/platform"; export type { GitActionId, GitAction, GitActions } from "@/components/git-actions-policy"; @@ -99,7 +99,7 @@ type WrappedWebTextStyle = TextStyle & { }; function getWrappedTextStyle(wrapLines: boolean): WrappedWebTextStyle | undefined { - if (Platform.OS !== "web") { + if (isNative) { return undefined; } return wrapLines @@ -453,7 +453,7 @@ const DiffFileHeader = memo(function DiffFileHeader({ }} onPressOut={(event) => { if ( - Platform.OS !== "web" && + isNative && !pressHandledRef.current && layoutYRef.current === 0 && pressInRef.current @@ -632,8 +632,8 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi const { theme } = useUnistyles(); const toast = useToast(); const isMobile = useIsCompactFormFactor(); - const showDesktopWebScrollbar = Platform.OS === "web" && !isMobile; - const canUseSplitLayout = Platform.OS === "web" && !isMobile; + const showDesktopWebScrollbar = isWeb && !isMobile; + const canUseSplitLayout = isWeb && !isMobile; const router = useRouter(); const [diffModeOverride, setDiffModeOverride] = useState<"uncommitted" | "base" | null>(null); const [postShipArchiveSuggested, setPostShipArchiveSuggested] = useState(false); diff --git a/packages/app/src/components/headers/header-toggle-button.tsx b/packages/app/src/components/headers/header-toggle-button.tsx index c6a8f1c66..93ebb21a2 100644 --- a/packages/app/src/components/headers/header-toggle-button.tsx +++ b/packages/app/src/components/headers/header-toggle-button.tsx @@ -1,16 +1,10 @@ import type { ReactElement, ReactNode } from "react"; -import { - Platform, - Text, - View, - type PressableProps, - type StyleProp, - type ViewStyle, -} from "react-native"; +import { Text, View, type PressableProps, type StyleProp, type ViewStyle } from "react-native"; import { StyleSheet } from "react-native-unistyles"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { Shortcut } from "@/components/ui/shortcut"; import type { ShortcutKey } from "@/utils/format-shortcut"; +import { isWeb } from "@/constants/platform"; interface HeaderToggleButtonState { hovered: boolean; @@ -44,7 +38,7 @@ export function HeaderToggleButton({ : undefined; const expandedState = (props.accessibilityState as { expanded?: boolean } | undefined)?.expanded; const ariaExpandedProps = - Platform.OS === "web" && typeof expandedState === "boolean" + isWeb && typeof expandedState === "boolean" ? ({ "aria-expanded": expandedState } as any) : null; diff --git a/packages/app/src/components/left-sidebar.tsx b/packages/app/src/components/left-sidebar.tsx index 2ddebe1a4..7ec33e7d0 100644 --- a/packages/app/src/components/left-sidebar.tsx +++ b/packages/app/src/components/left-sidebar.tsx @@ -14,7 +14,6 @@ import { View, Pressable, Text, - Platform, useWindowDimensions, StyleSheet as RNStyleSheet, } from "react-native"; @@ -59,6 +58,7 @@ import { parseServerIdFromPathname, } from "@/utils/host-routes"; import { useOpenProjectPicker } from "@/hooks/use-open-project-picker"; +import { isWeb } from "@/constants/platform"; const MIN_CHAT_WIDTH = 400; @@ -527,7 +527,7 @@ function MobileSidebar({ pointerEvents: backdropOpacity.value > 0.01 ? "auto" : "none", })); - const overlayPointerEvents = Platform.OS === "web" ? (isOpen ? "auto" : "none") : "box-none"; + const overlayPointerEvents = isWeb ? (isOpen ? "auto" : "none") : "box-none"; return ( @@ -833,12 +833,7 @@ function DesktopSidebar({ {/* Resize handle - absolutely positioned over right border */} - + diff --git a/packages/app/src/components/message-input.tsx b/packages/app/src/components/message-input.tsx index 649f6e2e1..80c9e2054 100644 --- a/packages/app/src/components/message-input.tsx +++ b/packages/app/src/components/message-input.tsx @@ -9,7 +9,6 @@ import { TextInputKeyPressEventData, TextInputSelectionChangeEventData, Image, - Platform, BackHandler, } from "react-native"; import { @@ -48,6 +47,7 @@ import { markScrollInvestigationEvent, markScrollInvestigationRender, } from "@/utils/scroll-jank-investigation"; +import { isWeb } from "@/constants/platform"; export type ImageAttachment = AttachmentMetadata; @@ -115,7 +115,7 @@ export interface MessageInputRef { const MIN_INPUT_HEIGHT = 30; const MAX_INPUT_HEIGHT = 160; -const IS_WEB = Platform.OS === "web"; +const IS_WEB = isWeb; type WebTextInputKeyPressEvent = NativeSyntheticEvent< TextInputKeyPressEventData & { @@ -1290,7 +1290,7 @@ const styles = StyleSheet.create(((theme: any) => ({ rightButtonGroup: { flexDirection: "row", alignItems: "center", - gap: Platform.OS === "web" ? theme.spacing[2] : theme.spacing[1], + gap: isWeb ? theme.spacing[2] : theme.spacing[1], }, attachButton: { width: 28, diff --git a/packages/app/src/components/message.tsx b/packages/app/src/components/message.tsx index 60a0a02dc..71c4531f8 100644 --- a/packages/app/src/components/message.tsx +++ b/packages/app/src/components/message.tsx @@ -7,7 +7,6 @@ import { type LayoutChangeEvent, StyleProp, ViewStyle, - Platform, } from "react-native"; import * as React from "react"; import { @@ -86,6 +85,7 @@ import { useToolCallSheet } from "./tool-call-sheet"; import { ToolCallDetailsContent } from "./tool-call-details"; import { useAttachmentPreviewUrl } from "@/attachments/use-attachment-preview-url"; import type { DaemonClient } from "@server/client/daemon-client"; +import { isWeb, isNative } from "@/constants/platform"; interface UserMessageProps { message: string; @@ -134,7 +134,7 @@ const SCROLL_EDGE_EPSILON = 0.5; type ScrollAxis = "x" | "y"; function ensureWebToolCallShimmerKeyframes() { - if (Platform.OS !== "web") { + if (isNative) { return; } if (typeof document === "undefined") { @@ -341,12 +341,13 @@ export const UserMessage = memo(function UserMessage({ isLastInGroup = true, disableOuterSpacing, }: UserMessageProps) { + const isCompact = useIsCompactFormFactor(); const [messageHovered, setMessageHovered] = useState(false); const [copyButtonHovered, setCopyButtonHovered] = useState(false); const resolvedDisableOuterSpacing = useDisableOuterSpacing(disableOuterSpacing); const hasText = message.trim().length > 0; const hasImages = images.length > 0; - const showCopyButton = hasText && (Platform.OS !== "web" || messageHovered || copyButtonHovered); + const showCopyButton = hasText && (isCompact || messageHovered || copyButtonHovered); return ( setMessageHovered(true) : undefined} - onHoverOut={Platform.OS === "web" ? () => setMessageHovered(false) : undefined} + onHoverIn={() => setMessageHovered(true)} + onHoverOut={() => setMessageHovered(false)} > {hasImages ? ( @@ -434,7 +435,7 @@ export const assistantMessageStylesheet = StyleSheet.create((theme) => ({ color: theme.colors.foreground, fontFamily: Fonts.mono, fontSize: 13, - userSelect: Platform.OS === "web" ? "text" : "auto", + userSelect: isWeb ? "text" : "auto", }, imageFrame: { width: "100%", @@ -657,7 +658,7 @@ function MarkdownLink({ children: ReactNode; }) { const [hovered, setHovered] = useState(false); - if (Platform.OS !== "web") { + if (isNative) { return ( onPress(href)} style={style}> {children} @@ -779,8 +780,8 @@ export const TurnCopyButton = memo(function TurnCopyButton({ return ( onHoverChange?.(true) : undefined} - onHoverOut={Platform.OS === "web" ? () => onHoverChange?.(false) : undefined} + onHoverIn={() => onHoverChange?.(true)} + onHoverOut={() => onHoverChange?.(false)} style={[turnCopyButtonStylesheet.container, containerStyle]} accessibilityRole="button" accessibilityLabel={ @@ -1060,7 +1061,7 @@ export const AssistantMessage = memo(function AssistantMessage({ parsed && onInlinePathPress?.(parsed)} - selectable={Platform.OS === "web" ? undefined : false} + selectable={isWeb ? undefined : false} style={[assistantMessageStylesheet.pathChip, assistantMessageStylesheet.pathChipText]} > {content} @@ -1653,9 +1654,9 @@ const ExpandableBadge = memo(function ExpandableBadge({ 32, Math.min(120, labelRowWidth > 0 ? labelRowWidth * 0.28 : 0), ); - const isWebShimmer = isLoading && Platform.OS === "web"; + const isWebShimmer = isLoading && isWeb; const shouldMeasureWebShimmer = isWebShimmer; - const shouldMeasureNativeShimmer = isLoading && Platform.OS !== "web"; + const shouldMeasureNativeShimmer = isLoading && isNative; const isNativeShimmer = shouldMeasureNativeShimmer && labelRowWidth > 0 && labelRowHeight > 0; const webShimmerSpanStartX = labelOffsetX; const webShimmerSpanEndX = secondaryLabel @@ -1732,7 +1733,7 @@ const ExpandableBadge = memo(function ExpandableBadge({ }, [isNativeShimmer, labelRowWidth, nativeShimmerPeakWidth, shimmerDuration, shimmerTranslateX]); useEffect(() => { - if (Platform.OS !== "web" || !isExpanded || !hasDetailContent) { + if (isNative || !isExpanded || !hasDetailContent) { return; } diff --git a/packages/app/src/components/project-picker-modal.tsx b/packages/app/src/components/project-picker-modal.tsx index 0556f2aec..4bc39860a 100644 --- a/packages/app/src/components/project-picker-modal.tsx +++ b/packages/app/src/components/project-picker-modal.tsx @@ -1,5 +1,5 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { Modal, Pressable, ScrollView, Text, TextInput, View, Platform } from "react-native"; +import { Modal, Pressable, ScrollView, Text, TextInput, View } from "react-native"; import { Folder } from "lucide-react-native"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { useQuery } from "@tanstack/react-query"; @@ -11,6 +11,7 @@ import { useHosts, useHostRuntimeClient, useHostRuntimeIsConnected } from "@/run import { useOpenProject } from "@/hooks/use-open-project"; import { parseServerIdFromPathname } from "@/utils/host-routes"; import { buildWorkingDirectorySuggestions } from "@/utils/working-directory-suggestions"; +import { isNative } from "@/constants/platform"; export function ProjectPickerModal() { const { theme } = useUnistyles(); @@ -122,7 +123,7 @@ export function ProjectPickerModal() { // Keyboard navigation useEffect(() => { - if (!open || Platform.OS !== "web") return; + if (!open || isNative) return; function handler(event: KeyboardEvent) { const key = event.key; diff --git a/packages/app/src/components/question-form-card.tsx b/packages/app/src/components/question-form-card.tsx index d6fa504a6..6609ac108 100644 --- a/packages/app/src/components/question-form-card.tsx +++ b/packages/app/src/components/question-form-card.tsx @@ -1,10 +1,11 @@ import { useState, useCallback } from "react"; -import { View, Text, TextInput, Pressable, ActivityIndicator, Platform } from "react-native"; +import { View, Text, TextInput, Pressable, ActivityIndicator } from "react-native"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { useIsCompactFormFactor } from "@/constants/layout"; import { Check, CircleHelp, X } from "lucide-react-native"; import type { PendingPermission } from "@/types/shared"; import type { AgentPermissionResponse } from "@server/server/agent/agent-sdk-types"; +import { isWeb } from "@/constants/platform"; interface QuestionOption { label: string; @@ -60,7 +61,7 @@ interface QuestionFormCardProps { isResponding: boolean; } -const IS_WEB = Platform.OS === "web"; +const IS_WEB = isWeb; export function QuestionFormCard({ permission, onRespond, isResponding }: QuestionFormCardProps) { const { theme } = useUnistyles(); diff --git a/packages/app/src/components/sidebar-workspace-list.tsx b/packages/app/src/components/sidebar-workspace-list.tsx index fb08566dd..a32ee33b9 100644 --- a/packages/app/src/components/sidebar-workspace-list.tsx +++ b/packages/app/src/components/sidebar-workspace-list.tsx @@ -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]} > @@ -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({ 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 ? ( setIsHovered(true)} onPointerLeave={() => setIsHovered(false)}> - [ - 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} - - - ); - } - - return ( - setIsHovered(true)} onPointerLeave={() => setIsHovered(false)}> - [ 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} - - + + ); + } + + return ( + [ + 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} + ); } @@ -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 ( - setIsHovered(true)} - onPointerLeave={() => setIsHovered(false)} + [ + 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}`} > - [ - 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}`} - > - - + + + - - - {workspace.name} - - - - {isCreating ? Creating... : null} - {onArchive && (isHovered || isTouchPlatform) ? ( - - [ - styles.kebabButton, - hovered && styles.kebabButtonHovered, - ]} - accessibilityRole="button" - accessibilityLabel="Workspace actions" - testID={`sidebar-workspace-kebab-${workspace.workspaceKey}`} - > - {({ hovered }) => ( - - )} - - - {onCopyPath ? ( - } - onSelect={onCopyPath} - > - Copy path - - ) : null} - {onCopyBranchName ? ( - } - onSelect={onCopyBranchName} - > - Copy branch name - - ) : null} - } - trailing={archiveShortcutKeys ? : null} - status={archiveStatus} - pendingLabel={archivePendingLabel} - onSelect={onArchive} - > - {archiveLabel ?? "Archive"} - - - - ) : workspace.diffStat ? ( - - +{workspace.diffStat.additions} - -{workspace.diffStat.deletions} - - ) : null} - {showShortcutBadge && shortcutNumber !== null ? ( - - {shortcutNumber} - - ) : null} - + {workspace.name} + - {prHint ? ( - - - - ) : null} - - + + {isCreating ? Creating... : null} + {onArchive && (isHovered || platformIsNative || isCompact) ? ( + + [ + styles.kebabButton, + hovered && styles.kebabButtonHovered, + ]} + accessibilityRole="button" + accessibilityLabel="Workspace actions" + testID={`sidebar-workspace-kebab-${workspace.workspaceKey}`} + > + {({ hovered }) => ( + + )} + + + {onCopyPath ? ( + } + onSelect={onCopyPath} + > + Copy path + + ) : null} + {onCopyBranchName ? ( + } + onSelect={onCopyBranchName} + > + Copy branch name + + ) : null} + } + trailing={archiveShortcutKeys ? : null} + status={archiveStatus} + pendingLabel={archivePendingLabel} + onSelect={onArchive} + > + {archiveLabel ?? "Archive"} + + + + ) : workspace.diffStat ? ( + + +{workspace.diffStat.additions} + -{workspace.diffStat.deletions} + + ) : null} + {showShortcutBadge && shortcutNumber !== null ? ( + + {shortcutNumber} + + ) : null} + + + {prHint ? ( + + + + ) : null} + ); } @@ -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>(() => 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 ( - {isNative ? ( + {platformIsNative ? ( { - if (Platform.OS !== "web") { + if (isNative) { return; } diff --git a/packages/app/src/components/toast-host.tsx b/packages/app/src/components/toast-host.tsx index 8b4916875..f2f23b235 100644 --- a/packages/app/src/components/toast-host.tsx +++ b/packages/app/src/components/toast-host.tsx @@ -4,6 +4,7 @@ import { Animated, Easing, Platform, Text, ToastAndroid, View } from "react-nati import { useSafeAreaInsets } from "react-native-safe-area-context"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { useIsCompactFormFactor } from "@/constants/layout"; +import { isWeb } from "@/constants/platform"; import { AlertTriangle, CheckCircle2 } from "lucide-react-native"; import { getOverlayRoot, OVERLAY_Z } from "@/lib/overlay-root"; import { @@ -234,8 +235,8 @@ export function ToastViewport({ ); - if (placement === "app-shell" && Platform.OS === "web" && typeof document !== "undefined") { + if (placement === "app-shell" && isWeb && typeof document !== "undefined") { return createPortal(content, getOverlayRoot()); } diff --git a/packages/app/src/components/tool-call-details.tsx b/packages/app/src/components/tool-call-details.tsx index 2e966327c..75c94aa2e 100644 --- a/packages/app/src/components/tool-call-details.tsx +++ b/packages/app/src/components/tool-call-details.tsx @@ -1,5 +1,5 @@ import React, { useMemo, ReactNode } from "react"; -import { View, Text, Platform, ScrollView as RNScrollView } from "react-native"; +import { View, Text, ScrollView as RNScrollView } from "react-native"; import { ScrollView as GHScrollView } from "react-native-gesture-handler"; import { StyleSheet } from "react-native-unistyles"; import { Fonts } from "@/constants/theme"; @@ -8,8 +8,9 @@ import { buildLineDiff, parseUnifiedDiff } from "@/utils/tool-call-parsers"; import { hasMeaningfulToolCallDetail } from "@/utils/tool-call-detail-state"; import { DiffViewer } from "./diff-viewer"; import { getCodeInsets } from "./code-insets"; +import { isWeb } from "@/constants/platform"; -const ScrollView = Platform.OS === "web" ? RNScrollView : GHScrollView; +const ScrollView = isWeb ? RNScrollView : GHScrollView; // ---- Content Component ---- @@ -511,7 +512,7 @@ const styles = StyleSheet.create((theme) => { fontSize: theme.fontSize.xs, color: theme.colors.foreground, lineHeight: 18, - ...(Platform.OS === "web" + ...(isWeb ? { whiteSpace: "pre", overflowWrap: "normal", diff --git a/packages/app/src/components/ui/combobox.tsx b/packages/app/src/components/ui/combobox.tsx index 7093d3b51..929dbb5e9 100644 --- a/packages/app/src/components/ui/combobox.tsx +++ b/packages/app/src/components/ui/combobox.tsx @@ -37,8 +37,9 @@ import { shouldShowCustomComboboxOption, } from "./combobox-options"; import type { ComboboxOptionModel } from "./combobox-options"; +import { isWeb } from "@/constants/platform"; -const IS_WEB = Platform.OS === "web"; +const IS_WEB = isWeb; export type ComboboxOption = ComboboxOptionModel; @@ -107,6 +108,7 @@ export interface SearchInputProps { onChangeText: (text: string) => void; onSubmitEditing?: () => void; autoFocus?: boolean; + useBottomSheetInput?: boolean; } export function SearchInput({ @@ -115,10 +117,11 @@ export function SearchInput({ onChangeText, onSubmitEditing, autoFocus = false, + useBottomSheetInput = false, }: SearchInputProps): ReactElement { const { theme } = useUnistyles(); const inputRef = useRef(null); - const InputComponent = Platform.OS === "web" ? TextInput : BottomSheetTextInput; + const InputComponent = useBottomSheetInput ? BottomSheetTextInput : TextInput; useEffect(() => { if (autoFocus && IS_WEB && inputRef.current) { @@ -264,8 +267,7 @@ export function Combobox({ }: ComboboxProps): ReactElement { const isMobile = useIsCompactFormFactor(); const effectiveOptionsPosition = isMobile ? "below-search" : optionsPosition; - const isDesktopAboveSearch = - !isMobile && Platform.OS === "web" && effectiveOptionsPosition === "above-search"; + const isDesktopAboveSearch = !isMobile && isWeb && effectiveOptionsPosition === "above-search"; const { height: windowHeight } = useWindowDimensions(); const bottomSheetRef = useRef(null); const hasPresentedBottomSheetRef = useRef(false); @@ -322,8 +324,8 @@ export function Combobox({ const middleware = useMemo( () => [ - floatingOffset(Platform.OS === "web" ? 0 : 4), - ...(Platform.OS === "web" ? [] : [flip({ padding: collisionPadding })]), + floatingOffset(isWeb ? 0 : 4), + ...(isWeb ? [] : [flip({ padding: collisionPadding })]), ...(isDesktopAboveSearch ? [] : [shift({ padding: collisionPadding })]), floatingSize({ padding: collisionPadding, @@ -346,7 +348,7 @@ export function Combobox({ ); const { refs, floatingStyles, update } = useFloating({ - placement: Platform.OS === "web" ? desktopPlacement : "bottom-start", + placement: isWeb ? desktopPlacement : "bottom-start", middleware, sameScrollView: false, elements: { @@ -626,6 +628,7 @@ export function Combobox({ onChangeText={setSearchQueryWithCallback} onSubmitEditing={handleSubmitSearch} autoFocus={!isMobile} + useBottomSheetInput={isMobile} /> ); diff --git a/packages/app/src/components/ui/context-menu.tsx b/packages/app/src/components/ui/context-menu.tsx index fef7b5435..a799ba9f4 100644 --- a/packages/app/src/components/ui/context-menu.tsx +++ b/packages/app/src/components/ui/context-menu.tsx @@ -32,6 +32,7 @@ import { useIsCompactFormFactor } from "@/constants/layout"; import { Check, CheckCircle } from "lucide-react-native"; import { BottomSheetBackdrop, BottomSheetModal, BottomSheetScrollView } from "@gorhom/bottom-sheet"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; +import { isWeb, isNative } from "@/constants/platform"; // Keep parity with dropdown-menu action statuses. export type ActionStatus = "idle" | "pending" | "success"; @@ -254,8 +255,7 @@ export function ContextMenuTrigger({ >): ReactElement { const ctx = useContextMenuContext("ContextMenuTrigger"); - const shouldEnableOnThisPlatform = - enabled && (Platform.OS === "web" ? enabledOnWeb : enabledOnMobile); + const shouldEnableOnThisPlatform = enabled && (isWeb ? enabledOnWeb : enabledOnMobile); const openAtEvent = useCallback( (event: unknown) => { @@ -294,7 +294,7 @@ export function ContextMenuTrigger({ disabled={disabled} delayLongPress={longPressDelayMs} onLongPress={(event) => { - if (Platform.OS === "web") { + if (isWeb) { props.onLongPress?.(event); return; } @@ -303,7 +303,7 @@ export function ContextMenuTrigger({ }} // @ts-ignore - onContextMenu is web-only and not in RN types. onContextMenu={(event: unknown) => { - if (Platform.OS !== "web") { + if (isNative) { return; } const e: any = event; diff --git a/packages/app/src/components/ui/tooltip.tsx b/packages/app/src/components/ui/tooltip.tsx index 5409a1a98..7214e5fb5 100644 --- a/packages/app/src/components/ui/tooltip.tsx +++ b/packages/app/src/components/ui/tooltip.tsx @@ -28,6 +28,8 @@ import { Portal } from "@gorhom/portal"; import { useBottomSheetModalInternal } from "@gorhom/bottom-sheet"; import Animated, { FadeIn, FadeOut } from "react-native-reanimated"; import { StyleSheet } from "react-native-unistyles"; +import { useIsCompactFormFactor } from "@/constants/layout"; +import { isWeb } from "@/constants/platform"; type Side = "top" | "bottom" | "left" | "right"; type Align = "start" | "center" | "end"; @@ -108,18 +110,6 @@ function measureElement(element: View): Promise { }); } -function isMobileTooltipEnvironment(): boolean { - if (Platform.OS !== "web") { - return true; - } - - if (typeof navigator === "undefined") { - return false; - } - - return /Mobi|Android|iPhone|iPad|iPod/i.test(navigator.userAgent ?? ""); -} - function computePosition({ triggerRect, contentSize, @@ -227,8 +217,8 @@ export function Tooltip({ onOpenChange, }); - const isMobile = isMobileTooltipEnvironment(); - const enabled = isMobile ? enabledOnMobile : enabledOnDesktop; + const isCompact = useIsCompactFormFactor(); + const enabled = isCompact ? enabledOnMobile : enabledOnDesktop; const value = useMemo( () => ({ @@ -236,10 +226,10 @@ export function Tooltip({ setOpen: setIsOpen, triggerRef, enabled, - openOnPress: isMobile, + openOnPress: isCompact, delayDuration, }), - [isOpen, setIsOpen, enabled, isMobile, delayDuration], + [isOpen, setIsOpen, enabled, isCompact, delayDuration], ); return {children}; @@ -354,7 +344,7 @@ export function TooltipTrigger({ onFocus: handleFocus, onBlur: handleBlur, onPress: handlePress, - ...(Platform.OS === "web" + ...(isWeb ? ({ // RN Web's hover handling can vary across environments; pointer events are the most reliable. onPointerEnter: handleHoverIn, @@ -473,7 +463,7 @@ export function TooltipContent({ // On web, avoid React Native's implementation (it uses and can // steal focus / disrupt hover). Rendering via Portal + position:fixed keeps the // exact same positioning math as DropdownMenu, without hover feedback loops. - if (Platform.OS === "web") { + if (isWeb) { return ( diff --git a/packages/app/src/components/use-web-scrollbar.tsx b/packages/app/src/components/use-web-scrollbar.tsx index b9a1f5f2c..469627700 100644 --- a/packages/app/src/components/use-web-scrollbar.tsx +++ b/packages/app/src/components/use-web-scrollbar.tsx @@ -1,6 +1,5 @@ import { useCallback, useEffect, useState, type ReactNode, type RefObject } from "react"; import { - Platform, type FlatList, type LayoutChangeEvent, type NativeScrollEvent, @@ -12,6 +11,7 @@ import { useWebDesktopScrollbarMetrics, type ScrollbarMetrics, } from "./web-desktop-scrollbar"; +import { isWeb as platformIsWeb } from "@/constants/platform"; const METRICS_EPSILON = 0.5; const HIDE_SCROLLBAR_STYLE_ID = "paseo-hide-scrollbar"; @@ -45,8 +45,7 @@ export function useWebElementScrollbar( contentRef?: RefObject; }, ): ReactNode { - const isWeb = Platform.OS === "web"; - const enabled = (options?.enabled ?? true) && isWeb; + const enabled = (options?.enabled ?? true) && platformIsWeb; const contentRef = options?.contentRef; const [metrics, setMetrics] = useState({ @@ -125,8 +124,7 @@ export function useWebScrollViewScrollbar( scrollableRef: RefObject, options?: { enabled?: boolean }, ): WebScrollViewScrollbar { - const isWeb = Platform.OS === "web"; - const enabled = (options?.enabled ?? true) && isWeb; + const enabled = (options?.enabled ?? true) && platformIsWeb; const metricsHook = useWebDesktopScrollbarMetrics(); const onScrollToOffset = useCallback( diff --git a/packages/app/src/components/web-desktop-scrollbar.tsx b/packages/app/src/components/web-desktop-scrollbar.tsx index 48a2211ef..cc4a944a2 100644 --- a/packages/app/src/components/web-desktop-scrollbar.tsx +++ b/packages/app/src/components/web-desktop-scrollbar.tsx @@ -1,7 +1,6 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { PanResponder, - Platform, View, type LayoutChangeEvent, type NativeScrollEvent, @@ -12,6 +11,7 @@ import { computeScrollOffsetFromDragDelta, computeVerticalScrollbarGeometry, } from "./web-desktop-scrollbar.math"; +import { isWeb as platformIsWeb } from "@/constants/platform"; const METRICS_EPSILON = 0.5; const HANDLE_WIDTH_IDLE = 6; @@ -135,7 +135,6 @@ export function WebDesktopScrollbarOverlay({ maxScrollOffset: 0, }); const onScrollToOffsetRef = useRef(onScrollToOffset); - const isWeb = Platform.OS === "web"; const maxScrollOffset = Math.max(0, metrics.contentSize - metrics.viewportSize); const normalizedOffset = inverted @@ -258,7 +257,7 @@ export function WebDesktopScrollbarOverlay({ ); const panResponder = useMemo(() => { - if (isWeb) { + if (platformIsWeb) { return null; } @@ -280,11 +279,11 @@ export function WebDesktopScrollbarOverlay({ setIsDragging(false); }, }); - }, [applyDragDelta, isWeb]); + }, [applyDragDelta, platformIsWeb]); const startWebDrag = useCallback( (event: any) => { - if (!isWeb) { + if (!platformIsWeb) { return; } const clientY = readClientY(event); @@ -298,7 +297,7 @@ export function WebDesktopScrollbarOverlay({ dragStartClientYRef.current = clientY; setIsDragging(true); }, - [isWeb], + [platformIsWeb], ); const handleGrabHoverIn = useCallback(() => { @@ -313,7 +312,7 @@ export function WebDesktopScrollbarOverlay({ }, []); useEffect(() => { - if (!isWeb || !isDragging) { + if (!platformIsWeb || !isDragging) { return; } @@ -335,7 +334,7 @@ export function WebDesktopScrollbarOverlay({ window.removeEventListener("pointerup", stopDragging); window.removeEventListener("pointercancel", stopDragging); }; - }, [applyDragDelta, isDragging, isWeb]); + }, [applyDragDelta, isDragging, platformIsWeb]); if (!enabled || !geometry.isVisible) { return null; @@ -371,7 +370,7 @@ export function WebDesktopScrollbarOverlay({ height: thumbRegionHeight, transform: [{ translateY: thumbRegionOffset }], }, - isWeb && + platformIsWeb && ({ cursor: handleCursor, touchAction: "none", @@ -383,7 +382,7 @@ export function WebDesktopScrollbarOverlay({ ]} pointerEvents={handleVisible ? "auto" : "none"} {...(panResponder?.panHandlers ?? {})} - {...(isWeb + {...(platformIsWeb ? ({ onPointerDown: startWebDrag, onPointerEnter: handleGrabHoverIn, @@ -403,7 +402,7 @@ export function WebDesktopScrollbarOverlay({ backgroundColor: handleColor, opacity: handleOpacity, }, - isWeb && + platformIsWeb && ({ transitionProperty: "opacity, width, background-color", transitionDuration: `${HANDLE_FADE_DURATION_MS}ms, ${HANDLE_WIDTH_TRANSITION_DURATION_MS}ms, ${HANDLE_FADE_DURATION_MS}ms`, diff --git a/packages/app/src/components/welcome-screen.tsx b/packages/app/src/components/welcome-screen.tsx index 2f93ba584..3cf520db8 100644 --- a/packages/app/src/components/welcome-screen.tsx +++ b/packages/app/src/components/welcome-screen.tsx @@ -1,5 +1,5 @@ import { useCallback, useEffect, useState, useSyncExternalStore } from "react"; -import { Pressable, Text, View, Platform, ScrollView } from "react-native"; +import { Pressable, Text, View, ScrollView } from "react-native"; import { useRouter } from "expo-router"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { QrCode, Link2, ClipboardPaste, ExternalLink } from "lucide-react-native"; @@ -18,6 +18,7 @@ import { formatVersionWithPrefix } from "@/desktop/updates/desktop-updates"; import { buildHostRootRoute } from "@/utils/host-routes"; import { PaseoLogo } from "@/components/icons/paseo-logo"; import { openExternalUrl } from "@/utils/open-external-url"; +import { isWeb, isNative } from "@/constants/platform"; type WelcomeAction = { key: "scan-qr" | "direct-connection" | "paste-pairing-link"; @@ -255,52 +256,51 @@ export function WelcomeScreen({ onHostAdded }: WelcomeScreenProps) { [router], ); - const actions: WelcomeAction[] = - Platform.OS === "web" - ? [ - { - key: "direct-connection", - label: "Direct connection", - testID: "welcome-direct-connection", - primary: true, - icon: Link2, - onPress: () => setIsDirectOpen(true), - }, - { - key: "paste-pairing-link", - label: "Paste pairing link", - testID: "welcome-paste-pairing-link", - primary: false, - icon: ClipboardPaste, - onPress: () => setIsPasteLinkOpen(true), - }, - ] - : [ - { - key: "scan-qr", - label: "Scan QR code", - testID: "welcome-scan-qr", - primary: true, - icon: QrCode, - onPress: () => router.push("/pair-scan?source=onboarding"), - }, - { - key: "direct-connection", - label: "Direct connection", - testID: "welcome-direct-connection", - primary: false, - icon: Link2, - onPress: () => setIsDirectOpen(true), - }, - { - key: "paste-pairing-link", - label: "Paste pairing link", - testID: "welcome-paste-pairing-link", - primary: false, - icon: ClipboardPaste, - onPress: () => setIsPasteLinkOpen(true), - }, - ]; + const actions: WelcomeAction[] = isWeb + ? [ + { + key: "direct-connection", + label: "Direct connection", + testID: "welcome-direct-connection", + primary: true, + icon: Link2, + onPress: () => setIsDirectOpen(true), + }, + { + key: "paste-pairing-link", + label: "Paste pairing link", + testID: "welcome-paste-pairing-link", + primary: false, + icon: ClipboardPaste, + onPress: () => setIsPasteLinkOpen(true), + }, + ] + : [ + { + key: "scan-qr", + label: "Scan QR code", + testID: "welcome-scan-qr", + primary: true, + icon: QrCode, + onPress: () => router.push("/pair-scan?source=onboarding"), + }, + { + key: "direct-connection", + label: "Direct connection", + testID: "welcome-direct-connection", + primary: false, + icon: Link2, + onPress: () => setIsDirectOpen(true), + }, + { + key: "paste-pairing-link", + label: "Paste pairing link", + testID: "welcome-paste-pairing-link", + primary: false, + icon: ClipboardPaste, + onPress: () => setIsPasteLinkOpen(true), + }, + ]; const showHostList = hosts.length > 0 && !anyOnlineServerId; @@ -321,7 +321,7 @@ export function WelcomeScreen({ onHostAdded }: WelcomeScreenProps) { {showHostList ? "Connecting to your hosts…" : "Connect to your host to start"} - {!showHostList && Platform.OS !== "web" && ( + {!showHostList && isNative && ( <> You need the Paseo desktop app or server running on your computer first. diff --git a/packages/app/src/constants/layout.ts b/packages/app/src/constants/layout.ts index 4a02ad47e..a8b077862 100644 --- a/packages/app/src/constants/layout.ts +++ b/packages/app/src/constants/layout.ts @@ -1,6 +1,5 @@ -import { Platform } from "react-native"; import { useUnistyles } from "react-native-unistyles"; -import { isElectronRuntime, isElectronRuntimeMac } from "@/desktop/host"; +import { isWeb } from "@/constants/platform"; export const FOOTER_HEIGHT = 75; @@ -24,43 +23,10 @@ export const DESKTOP_TRAFFIC_LIGHT_HEIGHT = 45; export const DESKTOP_WINDOW_CONTROLS_WIDTH = 140; export const DESKTOP_WINDOW_CONTROLS_HEIGHT = 48; -// Check if running in the Electron desktop runtime (any OS) -function isElectronDesktopRuntime(): boolean { - if (Platform.OS !== "web") return false; - return isElectronRuntime(); -} - -// Check if running in the Electron desktop runtime on macOS -function isElectronDesktopRuntimeMac(): boolean { - if (Platform.OS !== "web") return false; - return isElectronRuntimeMac(); -} - -// Cached result - only cache true, keep checking if false (in case desktop globals load later) -let _isElectronRuntimeMacCached: boolean | null = null; -let _isElectronRuntimeCached: boolean | null = null; - -export function getIsElectronRuntimeMac(): boolean { - if (_isElectronRuntimeMacCached === true) { - return true; - } - const result = isElectronDesktopRuntimeMac(); - if (result) { - _isElectronRuntimeMacCached = true; - } - return result; -} - -export function getIsElectronRuntime(): boolean { - if (_isElectronRuntimeCached === true) { - return true; - } - const result = isElectronDesktopRuntime(); - if (result) { - _isElectronRuntimeCached = true; - } - return result; -} +export { + getIsElectron as getIsElectronRuntime, + getIsElectronMac as getIsElectronRuntimeMac, +} from "./platform"; /** * Reactive hook — re-renders the component when the breakpoint changes. @@ -75,5 +41,5 @@ export function useIsCompactFormFactor(): boolean { // Keep that capability distinct from desktop-width layout so touch tablets // can use the desktop shell without entering web-only code paths. export function supportsDesktopPaneSplits(): boolean { - return Platform.OS === "web"; + return isWeb; } diff --git a/packages/app/src/constants/platform.ts b/packages/app/src/constants/platform.ts new file mode 100644 index 000000000..97fb6d932 --- /dev/null +++ b/packages/app/src/constants/platform.ts @@ -0,0 +1,49 @@ +import { Platform } from "react-native"; +import { isElectronRuntime, isElectronRuntimeMac } from "@/desktop/host"; + +// --------------------------------------------------------------------------- +// Runtime environment constants +// +// These are the ONLY platform gates in the app. See CLAUDE.md for the +// decision matrix on when to use each one. +// +// Default is cross-platform. Gate only when you must: +// isWeb → DOM APIs (document, window,
, addEventListener) +// isNative → Native-only APIs (Haptics, StatusBar, push tokens, camera) +// 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. +// --------------------------------------------------------------------------- + +/** Browser or Electron — the JS runtime has access to the DOM. */ +export const isWeb = Platform.OS === "web"; + +/** iOS or Android — the JS runtime is React Native. */ +export const isNative = Platform.OS !== "web"; + +// --------------------------------------------------------------------------- +// Electron detection (cached — only caches `true`, keeps checking if false +// because the desktop bridge may load after initial module evaluation) +// --------------------------------------------------------------------------- + +let _isElectronCached: boolean | null = null; +let _isElectronMacCached: boolean | null = null; + +/** Running inside the Electron desktop wrapper (any OS). */ +export function getIsElectron(): boolean { + if (_isElectronCached === true) return true; + if (!isWeb) return false; + const result = isElectronRuntime(); + if (result) _isElectronCached = true; + return result; +} + +/** Running inside the Electron desktop wrapper on macOS. */ +export function getIsElectronMac(): boolean { + if (_isElectronMacCached === true) return true; + if (!isWeb) return false; + const result = isElectronRuntimeMac(); + if (result) _isElectronMacCached = true; + return result; +} diff --git a/packages/app/src/contexts/session-context.tsx b/packages/app/src/contexts/session-context.tsx index 675caa210..32b3cd48d 100644 --- a/packages/app/src/contexts/session-context.tsx +++ b/packages/app/src/contexts/session-context.tsx @@ -1,6 +1,6 @@ import { useRef, ReactNode, useCallback, useEffect, useMemo } from "react"; import { Buffer } from "buffer"; -import { AppState, Platform } from "react-native"; +import { AppState } from "react-native"; import { useQueryClient } from "@tanstack/react-query"; import { useClientActivity } from "@/hooks/use-client-activity"; import { usePushTokenRegistration } from "@/hooks/use-push-token-registration"; @@ -52,6 +52,7 @@ import { resolveProjectPlacement } from "@/utils/project-placement"; import { buildDraftStoreKey } from "@/stores/draft-keys"; import type { AttachmentMetadata } from "@/attachments/types"; import { reconcilePreviousAgentStatuses } from "@/contexts/session-status-tracking"; +import { isNative } from "@/constants/platform"; // Re-export types from session-store and draft-store for backward compatibility export type { DraftInput } from "@/stores/draft-store"; @@ -547,7 +548,7 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider (awayMs: number) => { scheduleAuthoritativeRevalidation(); - if (Platform.OS !== "web") { + if (isNative) { const session = useSessionStore.getState().sessions[serverId]; const agentId = session?.focusedAgentId; const cursor = agentId ? session?.agentTimelineCursor.get(agentId) : undefined; diff --git a/packages/app/src/desktop/permissions/desktop-permissions.ts b/packages/app/src/desktop/permissions/desktop-permissions.ts index 6dd3d5974..9e10ca8ac 100644 --- a/packages/app/src/desktop/permissions/desktop-permissions.ts +++ b/packages/app/src/desktop/permissions/desktop-permissions.ts @@ -1,5 +1,5 @@ -import { Platform } from "react-native"; import { getDesktopHost } from "@/desktop/host"; +import { isWeb, isNative } from "@/constants/platform"; export type DesktopPermissionKind = "notifications" | "microphone"; @@ -45,7 +45,7 @@ type NavigatorLike = { }; export function shouldShowDesktopPermissionSection(): boolean { - return Platform.OS === "web" && getDesktopHost() !== null; + return isWeb && getDesktopHost() !== null; } function status(input: DesktopPermissionStatus): DesktopPermissionStatus { @@ -83,7 +83,7 @@ function isPermissionsQueryRuntimeUnsupported(error: unknown): boolean { } function getWebNotificationConstructor(): NotificationConstructorLike | null { - if (Platform.OS !== "web") { + if (isNative) { return null; } const NotificationConstructor = (globalThis as { Notification?: unknown }).Notification; @@ -97,7 +97,7 @@ function getWebNotificationConstructor(): NotificationConstructorLike | null { } function getNavigatorLike(): NavigatorLike | null { - if (Platform.OS !== "web") { + if (isNative) { return null; } const webNavigator = (globalThis as { navigator?: unknown }).navigator; @@ -133,7 +133,7 @@ function mapNotificationPermissionString(permission: string): DesktopPermissionS } async function getNotificationPermissionStatus(): Promise { - if (Platform.OS !== "web") { + if (isNative) { return status({ state: "unavailable", detail: "Desktop notification status is only available on web runtime.", @@ -167,7 +167,7 @@ async function getNotificationPermissionStatus(): Promise { - if (Platform.OS !== "web") { + if (isNative) { return status({ state: "unavailable", detail: "Desktop microphone status is only available on web runtime.", @@ -237,7 +237,7 @@ async function getMicrophonePermissionStatus(): Promise } async function requestNotificationPermissionStatus(): Promise { - if (Platform.OS !== "web") { + if (isNative) { return status({ state: "unavailable", detail: "Desktop notification requests are only available on web runtime.", @@ -264,7 +264,7 @@ async function requestNotificationPermissionStatus(): Promise { - if (Platform.OS !== "web") { + if (isNative) { return status({ state: "unavailable", detail: "Desktop microphone requests are only available on web runtime.", diff --git a/packages/app/src/desktop/updates/desktop-updates.ts b/packages/app/src/desktop/updates/desktop-updates.ts index 36e97e9a1..482a5de81 100644 --- a/packages/app/src/desktop/updates/desktop-updates.ts +++ b/packages/app/src/desktop/updates/desktop-updates.ts @@ -1,6 +1,6 @@ -import { Platform } from "react-native"; import { isElectronRuntime } from "@/desktop/host"; import { invokeDesktopCommand } from "@/desktop/electron/invoke"; +import { isWeb } from "@/constants/platform"; export interface DesktopAppUpdateCheckResult { hasUpdate: boolean; @@ -50,7 +50,7 @@ function toNumberOr(defaultValue: number, value: unknown): number { } export function shouldShowDesktopUpdateSection(): boolean { - return Platform.OS === "web" && isElectronRuntime(); + return isWeb && isElectronRuntime(); } export function parseLocalDaemonVersionResult(raw: unknown): LocalDaemonVersionResult { diff --git a/packages/app/src/hooks/use-agent-attention-clear.ts b/packages/app/src/hooks/use-agent-attention-clear.ts index 6282df340..78a1ea5eb 100644 --- a/packages/app/src/hooks/use-agent-attention-clear.ts +++ b/packages/app/src/hooks/use-agent-attention-clear.ts @@ -1,11 +1,12 @@ import { useCallback, useEffect, useRef, useState } from "react"; -import { AppState, Platform } from "react-native"; +import { AppState } from "react-native"; import type { DaemonClient } from "@server/client/daemon-client"; import { shouldClearAgentAttention, type AgentAttentionClearTrigger, } from "@/utils/agent-attention"; import { getIsAppActivelyVisible } from "@/utils/app-visibility"; +import { isWeb } from "@/constants/platform"; type AttentionReason = "finished" | "error" | "permission" | null | undefined; @@ -70,7 +71,7 @@ export function useAgentAttentionClear({ const appStateSubscription = AppState.addEventListener("change", updateVisibility); - if (Platform.OS === "web" && typeof document !== "undefined") { + if (isWeb && typeof document !== "undefined") { document.addEventListener("visibilitychange", updateVisibility); window.addEventListener("focus", updateVisibility); window.addEventListener("blur", updateVisibility); diff --git a/packages/app/src/hooks/use-agent-initialization.ts b/packages/app/src/hooks/use-agent-initialization.ts index 62ffa9a9d..dd1f9aafc 100644 --- a/packages/app/src/hooks/use-agent-initialization.ts +++ b/packages/app/src/hooks/use-agent-initialization.ts @@ -1,5 +1,4 @@ import { useCallback } from "react"; -import { Platform } from "react-native"; import { useSessionStore } from "@/stores/session-store"; import type { DaemonClient } from "@server/client/daemon-client"; import { @@ -10,13 +9,14 @@ import { rejectInitDeferred, } from "@/utils/agent-initialization"; import { deriveInitialTimelineRequest } from "@/contexts/session-timeline-bootstrap-policy"; +import { isWeb } from "@/constants/platform"; const INIT_TIMEOUT_MS = 5 * 60_000; const NATIVE_INITIAL_TIMELINE_LIMIT = 200; const UNBOUNDED_TIMELINE_LIMIT = 0; function resolveInitialTimelineLimit(): number { - return Platform.OS === "web" ? UNBOUNDED_TIMELINE_LIMIT : NATIVE_INITIAL_TIMELINE_LIMIT; + return isWeb ? UNBOUNDED_TIMELINE_LIMIT : NATIVE_INITIAL_TIMELINE_LIMIT; } export const __private__ = { diff --git a/packages/app/src/hooks/use-app-visible.ts b/packages/app/src/hooks/use-app-visible.ts index 1d584121a..953df7f61 100644 --- a/packages/app/src/hooks/use-app-visible.ts +++ b/packages/app/src/hooks/use-app-visible.ts @@ -1,6 +1,7 @@ import { useEffect, useSyncExternalStore } from "react"; -import { AppState, Platform } from "react-native"; +import { AppState } from "react-native"; import { getIsAppActivelyVisible } from "@/utils/app-visibility"; +import { isWeb } from "@/constants/platform"; let current = getIsAppActivelyVisible(); const listeners = new Set<() => void>(); @@ -29,7 +30,7 @@ export function useAppVisible(): boolean { useEffect(() => { const appStateSubscription = AppState.addEventListener("change", notify); - if (Platform.OS === "web" && typeof document !== "undefined") { + if (isWeb && typeof document !== "undefined") { document.addEventListener("visibilitychange", notify); window.addEventListener("focus", notify); window.addEventListener("blur", notify); @@ -37,7 +38,7 @@ export function useAppVisible(): boolean { return () => { appStateSubscription.remove(); - if (Platform.OS === "web" && typeof document !== "undefined") { + if (isWeb && typeof document !== "undefined") { document.removeEventListener("visibilitychange", notify); window.removeEventListener("focus", notify); window.removeEventListener("blur", notify); diff --git a/packages/app/src/hooks/use-client-activity.ts b/packages/app/src/hooks/use-client-activity.ts index 6347d08a1..2855219fc 100644 --- a/packages/app/src/hooks/use-client-activity.ts +++ b/packages/app/src/hooks/use-client-activity.ts @@ -1,6 +1,7 @@ import { useEffect, useRef, useCallback } from "react"; -import { AppState, Platform } from "react-native"; +import { AppState } from "react-native"; import type { DaemonClient } from "@server/client/daemon-client"; +import { isWeb, isNative } from "@/constants/platform"; const HEARTBEAT_INTERVAL_MS = 15_000; const ACTIVITY_HEARTBEAT_THROTTLE_MS = 5_000; @@ -32,7 +33,7 @@ export function useClientActivity({ const prevFocusedAgentIdRef = useRef(focusedAgentId); const lastImmediateHeartbeatAtRef = useRef(0); - const deviceType = Platform.OS === "web" ? "web" : "mobile"; + const deviceType = isWeb ? "web" : "mobile"; const recordUserActivity = useCallback(() => { lastActivityAtRef.current = new Date(); @@ -96,7 +97,7 @@ export function useClientActivity({ // Track user activity on web for accurate staleness. useEffect(() => { - if (Platform.OS !== "web") return; + if (isNative) return; if (typeof document === "undefined") return; const handleUserActivity = () => { diff --git a/packages/app/src/hooks/use-favicon-status.ts b/packages/app/src/hooks/use-favicon-status.ts index 5818a495a..078b8903c 100644 --- a/packages/app/src/hooks/use-favicon-status.ts +++ b/packages/app/src/hooks/use-favicon-status.ts @@ -1,5 +1,4 @@ import { useEffect, useRef, useState } from "react"; -import { Platform } from "react-native"; import { useShallow } from "zustand/shallow"; import { getIsElectronRuntimeMac } from "@/constants/layout"; import { useAggregatedAgents } from "./use-aggregated-agents"; @@ -9,6 +8,7 @@ import { deriveMacDockBadgeCountFromWorkspaceStatuses, type DesktopBadgeWorkspaceStatus, } from "@/utils/desktop-badge-state"; +import { isNative } from "@/constants/platform"; type FaviconStatus = "none" | "running" | "attention"; type ColorScheme = "dark" | "light"; @@ -76,18 +76,14 @@ function updateFavicon(status: FaviconStatus, colorScheme: ColorScheme) { } function getSystemColorScheme(): ColorScheme { - if ( - Platform.OS !== "web" || - typeof window === "undefined" || - typeof window.matchMedia !== "function" - ) { + if (isNative || typeof window === "undefined" || typeof window.matchMedia !== "function") { return "dark"; } return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light"; } async function updateMacDockBadge(count?: number) { - if (Platform.OS !== "web" || !getIsElectronRuntimeMac()) return; + if (isNative || !getIsElectronRuntimeMac()) return; const desktopWindow = getDesktopHost()?.window?.getCurrentWindow?.(); if (!desktopWindow || typeof desktopWindow.setBadgeCount !== "function") { @@ -119,7 +115,7 @@ export function useFaviconStatus() { // Listen for system color scheme changes useEffect(() => { - if (Platform.OS !== "web" || typeof window === "undefined") return; + if (isNative || typeof window === "undefined") return; const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)"); const handler = (e: MediaQueryListEvent) => { @@ -132,7 +128,7 @@ export function useFaviconStatus() { // Update favicon when agents or color scheme changes useEffect(() => { - if (Platform.OS !== "web") return; + if (isNative) return; const status = deriveFaviconStatus(agents); updateFavicon(status, colorScheme); diff --git a/packages/app/src/hooks/use-file-drop-zone.ts b/packages/app/src/hooks/use-file-drop-zone.ts index b9f5ada96..65ce91426 100644 --- a/packages/app/src/hooks/use-file-drop-zone.ts +++ b/packages/app/src/hooks/use-file-drop-zone.ts @@ -1,8 +1,8 @@ import { useState, useRef, useEffect } from "react"; -import { Platform } from "react-native"; import type { ImageAttachment } from "@/components/message-input"; import { getDesktopHost } from "@/desktop/host"; import { persistAttachmentFromBlob, persistAttachmentFromFileUri } from "@/attachments/service"; +import { isWeb } from "@/constants/platform"; interface UseFileDropZoneOptions { onFilesDropped: (files: ImageAttachment[]) => void; @@ -14,7 +14,7 @@ interface UseFileDropZoneReturn { containerRef: React.RefObject; } -const IS_WEB = Platform.OS === "web"; +const IS_WEB = isWeb; const IMAGE_MIME_BY_EXTENSION: Record = { ".png": "image/png", ".jpg": "image/jpeg", diff --git a/packages/app/src/hooks/use-image-attachment-picker.ts b/packages/app/src/hooks/use-image-attachment-picker.ts index 13009a44e..014ffa281 100644 --- a/packages/app/src/hooks/use-image-attachment-picker.ts +++ b/packages/app/src/hooks/use-image-attachment-picker.ts @@ -8,6 +8,7 @@ import { openImagePathsWithDesktopDialog, type PickedImageAttachmentInput, } from "@/hooks/image-attachment-picker"; +import { isWeb } from "@/constants/platform"; interface UseImageAttachmentPickerResult { pickImages: () => Promise; @@ -45,7 +46,7 @@ export function useImageAttachmentPicker(): UseImageAttachmentPickerResult { isPickingRef.current = true; try { - if (Platform.OS === "web" && isElectronRuntime()) { + if (isWeb && isElectronRuntime()) { const selectedPaths = await openImagePathsWithDesktopDialog(); if (selectedPaths.length === 0) { return null; diff --git a/packages/app/src/hooks/use-keyboard-shortcuts.ts b/packages/app/src/hooks/use-keyboard-shortcuts.ts index 4ec95766f..cf7cf4312 100644 --- a/packages/app/src/hooks/use-keyboard-shortcuts.ts +++ b/packages/app/src/hooks/use-keyboard-shortcuts.ts @@ -1,5 +1,4 @@ import { useEffect, useMemo, useRef } from "react"; -import { Platform } from "react-native"; import { usePathname } from "expo-router"; import { getIsElectronRuntime } from "@/constants/layout"; import { useHosts } from "@/runtime/host-runtime"; @@ -26,6 +25,7 @@ import { resolveKeyboardFocusScope } from "@/keyboard/focus-scope"; import { getShortcutOs } from "@/utils/shortcut-platform"; import { useOpenProjectPicker } from "@/hooks/use-open-project-picker"; import { useKeyboardShortcutOverrides } from "@/hooks/use-keyboard-shortcut-overrides"; +import { isNative } from "@/constants/platform"; export function useKeyboardShortcuts({ enabled, @@ -65,7 +65,7 @@ export function useKeyboardShortcuts({ useEffect(() => { if (!enabled) return; - if (Platform.OS !== "web") return; + if (isNative) return; if (isMobile) return; const isDesktopApp = getIsElectronRuntime(); diff --git a/packages/app/src/hooks/use-push-token-registration.ts b/packages/app/src/hooks/use-push-token-registration.ts index 61a686bf6..6b641f71d 100644 --- a/packages/app/src/hooks/use-push-token-registration.ts +++ b/packages/app/src/hooks/use-push-token-registration.ts @@ -4,6 +4,7 @@ import AsyncStorage from "@react-native-async-storage/async-storage"; import * as Notifications from "expo-notifications"; import Constants from "expo-constants"; import type { DaemonClient } from "@server/client/daemon-client"; +import { isWeb } from "@/constants/platform"; const STORAGE_PREFIX = "@paseo:expo-push-token:"; @@ -31,7 +32,7 @@ export function usePushTokenRegistration(params: { client: DaemonClient; serverI const lastSentTokenRef = useRef(null); const registerIfPossible = useCallback(async () => { - if (Platform.OS === "web") return; + if (isWeb) return; if (!client.isConnected) return; const token = tokenRef.current; if (!token) return; @@ -41,7 +42,7 @@ export function usePushTokenRegistration(params: { client: DaemonClient; serverI }, [client]); useEffect(() => { - if (Platform.OS === "web") return; + if (isWeb) return; const storageKey = `${STORAGE_PREFIX}${serverId}`; let cancelled = false; diff --git a/packages/app/src/panels/agent-panel.tsx b/packages/app/src/panels/agent-panel.tsx index 8b7e97bf3..1c2325663 100644 --- a/packages/app/src/panels/agent-panel.tsx +++ b/packages/app/src/panels/agent-panel.tsx @@ -1,5 +1,5 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { ActivityIndicator, Platform, Text, View } from "react-native"; +import { ActivityIndicator, Text, View } from "react-native"; import ReanimatedAnimated from "react-native-reanimated"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { useShallow } from "zustand/shallow"; @@ -47,6 +47,7 @@ import { deriveRouteBottomAnchorIntent, deriveRouteBottomAnchorRequest, } from "@/screens/agent/agent-ready-screen-bottom-anchor"; +import { isNative } from "@/constants/platform"; function formatProviderLabel(provider: Agent["provider"]): string { if (!provider) { @@ -595,7 +596,7 @@ function AgentPanelBody({ if (!isConnected || !hasSession) { return; } - const shouldSyncOnEntry = needsAuthoritativeSync || Platform.OS !== "web"; + const shouldSyncOnEntry = needsAuthoritativeSync || isNative; if (!shouldSyncOnEntry) { return; } diff --git a/packages/app/src/screens/agent/draft-agent-screen.tsx b/packages/app/src/screens/agent/draft-agent-screen.tsx index 696cd3705..6e19d7db9 100644 --- a/packages/app/src/screens/agent/draft-agent-screen.tsx +++ b/packages/app/src/screens/agent/draft-agent-screen.tsx @@ -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(); diff --git a/packages/app/src/screens/settings-screen.tsx b/packages/app/src/screens/settings-screen.tsx index 9c9e5ef54..a70f80cd7 100644 --- a/packages/app/src/screens/settings-screen.tsx +++ b/packages/app/src/screens/settings-screen.tsx @@ -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) { - {Platform.OS === "web" ? ( + {isWeb ? ( {badgeText} ) : null} {connectionBadge ? ( - + {connectionBadge.icon} - {Platform.OS === "web" ? ( + {isWeb ? ( {connectionBadge.text} diff --git a/packages/app/src/screens/settings/keyboard-shortcuts-section.tsx b/packages/app/src/screens/settings/keyboard-shortcuts-section.tsx index 9d760a311..dd3ba0d65 100644 --- a/packages/app/src/screens/settings/keyboard-shortcuts-section.tsx +++ b/packages/app/src/screens/settings/keyboard-shortcuts-section.tsx @@ -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 ( Shortcuts diff --git a/packages/app/src/screens/startup-splash-screen.tsx b/packages/app/src/screens/startup-splash-screen.tsx index a8e107aef..975c6a8ac 100644 --- a/packages/app/src/screens/startup-splash-screen.tsx +++ b/packages/app/src/screens/startup-splash-screen.tsx @@ -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", diff --git a/packages/app/src/screens/workspace/workspace-desktop-tabs-row.tsx b/packages/app/src/screens/workspace/workspace-desktop-tabs-row.tsx index 76c1c7f6d..0afe60268 100644 --- a/packages/app/src/screens/workspace/workspace-desktop-tabs-row.tsx +++ b/packages/app/src/screens/workspace/workspace-desktop-tabs-row.tsx @@ -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(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 ( @@ -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, diff --git a/packages/app/src/screens/workspace/workspace-draft-agent-tab.tsx b/packages/app/src/screens/workspace/workspace-draft-agent-tab.tsx index 84a559cea..a646b755c 100644 --- a/packages/app/src/screens/workspace/workspace-draft-agent-tab.tsx +++ b/packages/app/src/screens/workspace/workspace-draft-agent-tab.tsx @@ -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(); diff --git a/packages/app/src/screens/workspace/workspace-open-in-editor-button.tsx b/packages/app/src/screens/workspace/workspace-open-in-editor-button.tsx index 6204f280f..836c2469f 100644 --- a/packages/app/src/screens/workspace/workspace-open-in-editor-button.tsx +++ b/packages/app/src/screens/workspace/workspace-open-in-editor-button.tsx @@ -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({ queryKey: ["available-editors", serverId], diff --git a/packages/app/src/screens/workspace/workspace-screen.tsx b/packages/app/src/screens/workspace/workspace-screen.tsx index af6897336..c16090e9f 100644 --- a/packages/app/src/screens/workspace/workspace-screen.tsx +++ b/packages/app/src/screens/workspace/workspace-screen.tsx @@ -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 ( - {Platform.OS === "web" && activeTabDescriptor ? ( + {isWeb && activeTabDescriptor ? ( ()((set, get) => ({ const downloadUrl = buildDownloadUrl( downloadTarget.baseUrl, tokenResponse.token, - Platform.OS === "web" ? downloadTarget.authCredentials : null, + isWeb ? downloadTarget.authCredentials : null, ); - if (Platform.OS === "web") { + if (isWeb) { triggerBrowserDownload(downloadUrl, resolvedFileName); get().completeDownload(id); return; @@ -153,7 +153,7 @@ export const useDownloadStore = create()((set, get) => ({ } } catch (error) { const message = error instanceof Error ? error.message : "Failed to download file."; - if (Platform.OS === "web") { + if (isWeb) { console.warn("[DownloadStore] Download failed:", message); get().failDownload(id, message); return; diff --git a/packages/app/src/stores/panel-store.ts b/packages/app/src/stores/panel-store.ts index cf1551d2e..f68513832 100644 --- a/packages/app/src/stores/panel-store.ts +++ b/packages/app/src/stores/panel-store.ts @@ -1,7 +1,6 @@ import { create } from "zustand"; import { persist, createJSONStorage } from "zustand/middleware"; import AsyncStorage from "@react-native-async-storage/async-storage"; -import { Platform } from "react-native"; import { buildExplorerCheckoutKey, coerceExplorerTabForCheckout, @@ -9,6 +8,7 @@ import { resolveExplorerTabForCheckout, type ExplorerTab, } from "./explorer-tab-memory"; +import { isWeb } from "@/constants/platform"; export type { ExplorerTab } from "./explorer-tab-memory"; /** @@ -128,7 +128,7 @@ function resolveExplorerTabFromActiveCheckout(state: PanelState): ExplorerTab | }); } -const DEFAULT_DESKTOP_OPEN = Platform.OS === "web"; +const DEFAULT_DESKTOP_OPEN = isWeb; export const usePanelStore = create()( persist( @@ -318,11 +318,7 @@ export const usePanelStore = create()( const state = persistedState as Partial & Record; if (version < 2) { - if ( - Platform.OS === "web" && - typeof state.explorerWidth === "number" && - state.explorerWidth === 400 - ) { + if (isWeb && typeof state.explorerWidth === "number" && state.explorerWidth === 400) { state.explorerWidth = DEFAULT_EXPLORER_SIDEBAR_WIDTH; } @@ -337,7 +333,7 @@ export const usePanelStore = create()( if (version < 3) { if ( - Platform.OS === "web" && + isWeb && typeof state.explorerWidth === "number" && (state.explorerWidth === 400 || state.explorerWidth === 520) ) { diff --git a/packages/app/src/styles/markdown-styles.ts b/packages/app/src/styles/markdown-styles.ts index af7c0fd0f..81563eecb 100644 --- a/packages/app/src/styles/markdown-styles.ts +++ b/packages/app/src/styles/markdown-styles.ts @@ -1,8 +1,8 @@ -import { Platform } from "react-native"; import type { Theme } from "./theme"; import { Fonts } from "@/constants/theme"; +import { isWeb } from "@/constants/platform"; -const webSelectableTextStyle = Platform.OS === "web" ? { userSelect: "text" as const } : {}; +const webSelectableTextStyle = isWeb ? { userSelect: "text" as const } : {}; /** * Creates comprehensive markdown styles for react-native-markdown-display. diff --git a/packages/app/src/utils/app-visibility.ts b/packages/app/src/utils/app-visibility.ts index 6b29dd1ec..896d70eb1 100644 --- a/packages/app/src/utils/app-visibility.ts +++ b/packages/app/src/utils/app-visibility.ts @@ -1,11 +1,12 @@ -import { AppState, Platform } from "react-native"; +import { AppState } from "react-native"; +import { isNative } from "@/constants/platform"; export function getIsAppActivelyVisible(appState: string = AppState.currentState): boolean { if (appState !== "active") { return false; } - if (Platform.OS !== "web") { + if (isNative) { return true; } diff --git a/packages/app/src/utils/confirm-dialog.ts b/packages/app/src/utils/confirm-dialog.ts index 8806c9e36..a04c6e16b 100644 --- a/packages/app/src/utils/confirm-dialog.ts +++ b/packages/app/src/utils/confirm-dialog.ts @@ -1,5 +1,6 @@ -import { Alert, Platform } from "react-native"; +import { Alert } from "react-native"; import { getDesktopHost, type DesktopDialogAskOptions } from "@/desktop/host"; +import { isNative } from "@/constants/platform"; export interface ConfirmDialogInput { title: string; @@ -49,7 +50,7 @@ async function showNativeConfirmDialog(input: ConfirmDialogInput): Promise { - if (Platform.OS !== "web") { + if (isNative) { return showNativeConfirmDialog(input); } diff --git a/packages/app/src/utils/desktop-window.ts b/packages/app/src/utils/desktop-window.ts index 6d22d812c..e2b92289e 100644 --- a/packages/app/src/utils/desktop-window.ts +++ b/packages/app/src/utils/desktop-window.ts @@ -1,5 +1,4 @@ import { useEffect, useMemo, useState } from "react"; -import { Platform } from "react-native"; import { getIsElectronRuntimeMac, getIsElectronRuntime, @@ -10,6 +9,7 @@ import { } from "@/constants/layout"; import { getDesktopWindow } from "@/desktop/electron/window"; import { usePanelStore } from "@/stores/panel-store"; +import { isNative } from "@/constants/platform"; type RawWindowControlsPadding = { left: number; @@ -23,7 +23,7 @@ function useRawWindowControlsPadding(): RawWindowControlsPadding { const [isFullscreen, setIsFullscreen] = useState(false); useEffect(() => { - if (Platform.OS !== "web" || !getIsElectronRuntime()) return; + if (isNative || !getIsElectronRuntime()) return; let disposed = false; let cleanup: (() => void) | undefined; diff --git a/packages/app/src/utils/open-external-url.ts b/packages/app/src/utils/open-external-url.ts index 2de4c5f5a..64986ad68 100644 --- a/packages/app/src/utils/open-external-url.ts +++ b/packages/app/src/utils/open-external-url.ts @@ -1,9 +1,9 @@ import * as Linking from "expo-linking"; -import { Platform } from "react-native"; import { getDesktopHost } from "@/desktop/host"; +import { isWeb } from "@/constants/platform"; export async function openExternalUrl(url: string): Promise { - if (Platform.OS === "web") { + if (isWeb) { const opener = getDesktopHost()?.opener?.openUrl; if (typeof opener === "function") { await opener(url); diff --git a/packages/app/src/utils/os-notifications.ts b/packages/app/src/utils/os-notifications.ts index c2644a688..33765e4a1 100644 --- a/packages/app/src/utils/os-notifications.ts +++ b/packages/app/src/utils/os-notifications.ts @@ -1,7 +1,7 @@ import { Asset } from "expo-asset"; -import { Platform } from "react-native"; import { getDesktopHost } from "@/desktop/host"; import { buildNotificationRoute, resolveNotificationTarget } from "./notification-routing"; +import { isNative } from "@/constants/platform"; type OsNotificationPayload = { title: string; @@ -80,7 +80,7 @@ async function ensureNotificationPermission(): Promise { } export async function ensureOsNotificationPermission(): Promise { - if (Platform.OS !== "web") { + if (isNative) { return false; } return await ensureNotificationPermission(); @@ -154,7 +154,7 @@ function attachWebClickHandler( export async function sendOsNotification(payload: OsNotificationPayload): Promise { // Mobile/native notifications should be remote push only. - if (Platform.OS !== "web") { + if (isNative) { return false; } diff --git a/packages/app/src/utils/scroll-jank-investigation.ts b/packages/app/src/utils/scroll-jank-investigation.ts index 768e21bb2..50d66765b 100644 --- a/packages/app/src/utils/scroll-jank-investigation.ts +++ b/packages/app/src/utils/scroll-jank-investigation.ts @@ -1,4 +1,4 @@ -import { Platform } from "react-native"; +import { isWeb } from "@/constants/platform"; type ListenerStats = { adds: number; @@ -92,7 +92,7 @@ const SOURCE_LABEL = "[ScrollJankInvestigation]"; function shouldInstall(): boolean { const runtime = globalThis as ScrollInvestigationGlobal; const isDev = Boolean((globalThis as { __DEV__?: boolean }).__DEV__); - return Platform.OS === "web" && isDev && !runtime.__PASEO_SCROLL_JANK_INVESTIGATION_DISABLED__; + return isWeb && isDev && !runtime.__PASEO_SCROLL_JANK_INVESTIGATION_DISABLED__; } function normalizeCapture(options?: AddEventListenerOptions | boolean): boolean { diff --git a/packages/app/src/utils/shortcut-platform.ts b/packages/app/src/utils/shortcut-platform.ts index 11a187f37..40678364a 100644 --- a/packages/app/src/utils/shortcut-platform.ts +++ b/packages/app/src/utils/shortcut-platform.ts @@ -1,9 +1,10 @@ import { Platform } from "react-native"; import { getIsElectronRuntimeMac } from "@/constants/layout"; import type { ShortcutOs } from "@/utils/format-shortcut"; +import { isNative } from "@/constants/platform"; export function getShortcutOs(): ShortcutOs { - if (Platform.OS !== "web") { + if (isNative) { return Platform.OS === "ios" ? "mac" : "non-mac"; } if (getIsElectronRuntimeMac()) return "mac"; diff --git a/packages/app/src/utils/thinking-tone.ts b/packages/app/src/utils/thinking-tone.ts index 8b1efe378..87297c77b 100644 --- a/packages/app/src/utils/thinking-tone.ts +++ b/packages/app/src/utils/thinking-tone.ts @@ -1,6 +1,6 @@ import { Asset } from "expo-asset"; import { File } from "expo-file-system"; -import { Platform } from "react-native"; +import { isWeb } from "@/constants/platform"; export { parsePcm16Wav, type Pcm16Wav } from "@/utils/pcm16-wav"; export const THINKING_TONE_REPEAT_GAP_MS = 350; @@ -11,7 +11,7 @@ async function readThinkingToneArrayBuffer(): Promise { const toneModule = require("../../assets/audio/thinking-tone.wav"); const asset = Asset.fromModule(toneModule); - if (Platform.OS === "web") { + if (isWeb) { const response = await fetch(asset.uri); if (!response.ok) { throw new Error(`Failed to fetch thinking tone asset: ${response.status}`);