, 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}`);