Centralize platform gating and fix iPad/tablet support

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

Fix sidebar hover crash on native (onPointerEnter → onHoverIn),
fix tooltip to use breakpoint instead of platform detection, make
sidebar action buttons always visible on native where hover doesn't
work, and document the platform gating decision matrix in CLAUDE.md.
This commit is contained in:
Mohamed Boudra
2026-04-13 13:24:00 +07:00
parent 964bf3e13f
commit 0f7fa55a0f
70 changed files with 551 additions and 515 deletions
+3 -2
View File
@@ -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;
}
+5 -4
View File
@@ -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<boole
}
function getDesktopApi() {
if (Platform.OS !== "web") {
if (isNative) {
return null;
}
return getDesktopHost();
@@ -67,7 +68,7 @@ function buildDesktopAskOptions(input: ConfirmDialogInput): DesktopDialogAskOpti
}
function blurActiveWebElement(): void {
if (Platform.OS !== "web") {
if (isNative) {
return;
}
const activeElement = (globalThis as { document?: Document }).document?.activeElement;
@@ -103,7 +104,7 @@ function showWebConfirmDialog(input: ConfirmDialogInput): boolean {
}
export async function confirmDialog(input: ConfirmDialogInput): Promise<boolean> {
if (Platform.OS !== "web") {
if (isNative) {
return showNativeConfirmDialog(input);
}
+2 -2
View File
@@ -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;
+2 -2
View File
@@ -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<void> {
if (Platform.OS === "web") {
if (isWeb) {
const opener = getDesktopHost()?.opener?.openUrl;
if (typeof opener === "function") {
await opener(url);
+3 -3
View File
@@ -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<boolean> {
}
export async function ensureOsNotificationPermission(): Promise<boolean> {
if (Platform.OS !== "web") {
if (isNative) {
return false;
}
return await ensureNotificationPermission();
@@ -154,7 +154,7 @@ function attachWebClickHandler(
export async function sendOsNotification(payload: OsNotificationPayload): Promise<boolean> {
// Mobile/native notifications should be remote push only.
if (Platform.OS !== "web") {
if (isNative) {
return false;
}
@@ -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 {
+2 -1
View File
@@ -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";
+2 -2
View File
@@ -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<ArrayBuffer> {
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}`);