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

View File

@@ -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`, `<div>`, `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`, `<div>`, `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

View File

@@ -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<string | null>(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;
}

View File

@@ -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");

View File

@@ -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 (
<View style={styles.container}>
<View style={[styles.header, { paddingTop: insets.top + theme.spacing[2] }]}>

View File

@@ -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<AttachmentStore> | null = null;
async function createAttachmentStore(): Promise<AttachmentStore> {
if (Platform.OS === "web") {
if (isWeb) {
if (isElectronRuntime()) {
const { createDesktopAttachmentStore } = await import(
"../desktop/attachments/desktop-attachment-store"

View File

@@ -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());
}

View File

@@ -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({
</View>
</Pressable>
{Platform.OS !== "web" ? (
{isNative ? (
<Pressable style={styles.option} onPress={handleScan} accessibilityLabel="Scan QR code">
<QrCode size={18} color={theme.colors.foreground} />
<View style={styles.optionBody}>

View File

@@ -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<AgentModeIcon, typeof ShieldCheck> = {
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);

View File

@@ -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;
}

View File

@@ -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<StatusSelector | null>(null);
@@ -356,7 +356,7 @@ function ControlledStatusBar({
return (
<View style={styles.container}>
{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<StatusOption[]>(() => {
@@ -1105,7 +1104,7 @@ export function DraftAgentStatusBar({
const effectiveSelectedThinkingOption =
selectedThinkingOptionId || mappedThinkingOptions[0]?.id || undefined;
if (isWeb) {
if (platformIsWeb) {
return (
<View style={styles.container}>
<CombinedModelSelector

View File

@@ -73,6 +73,7 @@ import {
WORKING_INDICATOR_CYCLE_MS,
WORKING_INDICATOR_OFFSETS,
} from "@/utils/working-indicator";
import { isWeb } from "@/constants/platform";
const isUserMessageItem = (item?: StreamItem) => item?.kind === "user_message";
const isToolSequenceItem = (item?: StreamItem) =>
@@ -216,7 +217,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
return buildAgentStreamRenderModel({
tail: streamItems,
head: streamHead ?? [],
platform: Platform.OS === "web" ? "web" : "native",
platform: isWeb ? "web" : "native",
isMobileBreakpoint: isMobile,
});
}, [isMobile, streamHead, streamItems]);

View File

@@ -4,17 +4,17 @@ import {
Text,
TextInput,
Pressable,
Platform,
ActivityIndicator,
type GestureResponderEvent,
} from "react-native";
import { BottomSheetTextInput } from "@gorhom/bottom-sheet";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useIsCompactFormFactor } from "@/constants/layout";
import { isWeb as platformIsWeb } from "@/constants/platform";
import { ArrowLeft, ChevronDown, ChevronRight, Search, Star } from "lucide-react-native";
import type { AgentModelDefinition, AgentProvider } from "@server/server/agent/agent-sdk-types";
import type { AgentProviderDefinition } from "@server/server/agent/provider-manifest";
const IS_WEB = Platform.OS === "web";
const IS_WEB = platformIsWeb;
import { Combobox, ComboboxItem } from "@/components/ui/combobox";
import { getProviderIcon } from "@/components/provider-icons";
@@ -349,7 +349,7 @@ function ProviderSearchInput({
const InputComponent = isMobile ? BottomSheetTextInput : TextInput;
useEffect(() => {
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({
<InputComponent
ref={inputRef as any}
// @ts-expect-error - outlineStyle is web-only
style={[styles.providerSearchInput, Platform.OS === "web" && { outlineStyle: "none" }]}
style={[styles.providerSearchInput, platformIsWeb && { outlineStyle: "none" }]}
placeholder="Search models..."
placeholderTextColor={theme.colors.foregroundMuted}
value={value}
@@ -518,10 +518,9 @@ export function CombinedModelSelector({
disabled = false,
}: CombinedModelSelectorProps) {
const { theme } = useUnistyles();
const isWeb = Platform.OS === "web";
const anchorRef = useRef<View>(null);
const [isOpen, setIsOpen] = useState(false);
const [isContentReady, setIsContentReady] = useState(isWeb);
const [isContentReady, setIsContentReady] = useState(platformIsWeb);
const [view, setView] = useState<SelectorView>({ 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({
<ProviderSearchInput
value={searchQuery}
onChangeText={setSearchQuery}
autoFocus={Platform.OS === "web"}
autoFocus={platformIsWeb}
/>
</View>
) : undefined

View File

@@ -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<AggregatedAgent, "serverId" | "id">): 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");

View File

@@ -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;
}

View File

@@ -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",

View File

@@ -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<string, unknown>): 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({
<View style={[styles.desktopSidebarBorder, { flex: 1 }]}>
{/* Resize handle - absolutely positioned over left border */}
<GestureDetector gesture={resizeGesture}>
<View
style={[
styles.resizeHandle,
Platform.OS === "web" && ({ cursor: "col-resize" } as any),
]}
/>
<View style={[styles.resizeHandle, isWeb && ({ cursor: "col-resize" } as any)]} />
</GestureDetector>
<SidebarContent

View File

@@ -1,10 +1,11 @@
import { View, Text, Platform } from "react-native";
import { View, Text } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import Animated, { useAnimatedStyle, withTiming, useSharedValue } from "react-native-reanimated";
import { useEffect } from "react";
import { Upload } from "lucide-react-native";
import { useFileDropZone } from "@/hooks/use-file-drop-zone";
import type { ImageAttachment } from "./message-input";
import { isWeb } from "@/constants/platform";
interface FileDropZoneProps {
children: React.ReactNode;
@@ -12,7 +13,7 @@ interface FileDropZoneProps {
disabled?: boolean;
}
const IS_WEB = Platform.OS === "web";
const IS_WEB = isWeb;
export function FileDropZone({ children, onFilesDropped, disabled = false }: FileDropZoneProps) {
const { theme } = useUnistyles();

View File

@@ -7,7 +7,6 @@ import {
Pressable,
Text,
View,
Platform,
} from "react-native";
import { Gesture } from "react-native-gesture-handler";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
@@ -52,6 +51,7 @@ import { usePanelStore, type SortOption } from "@/stores/panel-store";
import { formatTimeAgo } from "@/utils/time";
import { buildAbsoluteExplorerPath } from "@/utils/explorer-paths";
import { useWebScrollViewScrollbar } from "@/components/use-web-scrollbar";
import { isWeb } from "@/constants/platform";
const SORT_OPTIONS: { value: SortOption; label: string }[] = [
{ value: "name", label: "Name" },
@@ -91,7 +91,7 @@ export function FileExplorerPane({
}: FileExplorerPaneProps) {
const { theme } = useUnistyles();
const isMobile = useIsCompactFormFactor();
const showDesktopWebScrollbar = Platform.OS === "web" && !isMobile;
const showDesktopWebScrollbar = isWeb && !isMobile;
const daemons = useHosts();
const daemonProfile = useMemo(

View File

@@ -6,7 +6,6 @@ import {
ScrollView as RNScrollView,
Text,
View,
Platform,
} from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useIsCompactFormFactor } from "@/constants/layout";
@@ -21,6 +20,7 @@ import {
type HighlightStyle,
} from "@getpaseo/highlight";
import { lineNumberGutterWidth } from "@/components/code-insets";
import { isWeb } from "@/constants/platform";
interface CodeLineProps {
tokens: HighlightToken[];
@@ -243,7 +243,7 @@ export function FilePane({
filePath: string;
}) {
const isMobile = useIsCompactFormFactor();
const showDesktopWebScrollbar = Platform.OS === "web" && !isMobile;
const showDesktopWebScrollbar = isWeb && !isMobile;
const client = useSessionStore((state) => state.sessions[serverId]?.client ?? null);
const normalizedWorkspaceRoot = useMemo(() => workspaceRoot.trim(), [workspaceRoot]);

View File

@@ -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);

View File

@@ -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;

View File

@@ -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 (
<View style={StyleSheet.absoluteFillObject} pointerEvents={overlayPointerEvents}>
@@ -833,12 +833,7 @@ function DesktopSidebar({
{/* Resize handle - absolutely positioned over right border */}
<GestureDetector gesture={resizeGesture}>
<View
style={[
styles.resizeHandle,
Platform.OS === "web" && ({ cursor: "col-resize" } as any),
]}
/>
<View style={[styles.resizeHandle, isWeb && ({ cursor: "col-resize" } as any)]} />
</GestureDetector>
</View>
</Animated.View>

View File

@@ -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,

View File

@@ -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 (
<View
@@ -361,8 +362,8 @@ export const UserMessage = memo(function UserMessage({
>
<Pressable
style={userMessageStylesheet.content}
onHoverIn={Platform.OS === "web" ? () => setMessageHovered(true) : undefined}
onHoverOut={Platform.OS === "web" ? () => setMessageHovered(false) : undefined}
onHoverIn={() => setMessageHovered(true)}
onHoverOut={() => setMessageHovered(false)}
>
<View style={userMessageStylesheet.bubble}>
{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 (
<Text accessibilityRole="link" onPress={() => onPress(href)} style={style}>
{children}
@@ -779,8 +780,8 @@ export const TurnCopyButton = memo(function TurnCopyButton({
return (
<Pressable
onPress={handleCopy}
onHoverIn={Platform.OS === "web" ? () => 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({
<Text
key={node.key}
onPress={() => 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;
}

View File

@@ -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;

View File

@@ -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();

View File

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

View File

@@ -27,7 +27,7 @@ import {
type DragStartEvent,
} from "@dnd-kit/core";
import { arrayMove, sortableKeyboardCoordinates } from "@dnd-kit/sortable";
import { Platform, View, Text } from "react-native";
import { View, Text } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { ResizeHandle } from "@/components/resize-handle";
import { shouldFocusPaneFromEventTarget } from "@/components/split-container-pane-focus";
@@ -69,6 +69,7 @@ import {
} from "@/stores/workspace-layout-store";
import type { WorkspaceTab } from "@/stores/workspace-tabs-store";
import { workspaceTabTargetsEqual } from "@/utils/workspace-tab-identity";
import { isNative } from "@/constants/platform";
interface SplitContainerProps {
layout: WorkspaceLayout;
@@ -838,7 +839,7 @@ function SplitPaneView({
);
useEffect(() => {
if (Platform.OS !== "web") {
if (isNative) {
return;
}

View File

@@ -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({
<View style={styles.container} pointerEvents="box-none">
<Animated.View
testID={toast.testID ?? "app-toast"}
onPointerEnter={pauseDismiss}
onPointerLeave={resumeDismiss}
onPointerEnter={isWeb ? pauseDismiss : undefined}
onPointerLeave={isWeb ? resumeDismiss : undefined}
style={[
styles.toast,
toast.variant === "success" ? styles.toastSuccess : null,
@@ -265,7 +266,7 @@ export function ToastViewport({
</View>
);
if (placement === "app-shell" && Platform.OS === "web" && typeof document !== "undefined") {
if (placement === "app-shell" && isWeb && typeof document !== "undefined") {
return createPortal(content, getOverlayRoot());
}

View File

@@ -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",

View File

@@ -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<TextInput>(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<BottomSheetModal>(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}
/>
);

View File

@@ -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;

View File

@@ -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<Rect> {
});
}
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<TooltipContextValue>(
() => ({
@@ -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 <TooltipContext.Provider value={value}>{children}</TooltipContext.Provider>;
@@ -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 <Modal/> implementation (it uses <dialog> 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 (
<Portal hostName={bottomSheetInternal?.hostName}>
<View pointerEvents="none" style={styles.portalOverlay}>

View File

@@ -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<HTMLElement | null>;
},
): 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<ScrollbarMetrics>({
@@ -125,8 +124,7 @@ export function useWebScrollViewScrollbar(
scrollableRef: RefObject<ScrollView | FlatList | null>,
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(

View File

@@ -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`,

View File

@@ -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"}
</Text>
{!showHostList && Platform.OS !== "web" && (
{!showHostList && isNative && (
<>
<Text style={styles.setupHint}>
You need the Paseo desktop app or server running on your computer first.

View File

@@ -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;
}

View File

@@ -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, <div>, 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;
}

View File

@@ -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;

View File

@@ -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<DesktopPermissionStatus> {
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<DesktopPermissionStatu
}
async function getMicrophonePermissionStatus(): Promise<DesktopPermissionStatus> {
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<DesktopPermissionStatus>
}
async function requestNotificationPermissionStatus(): Promise<DesktopPermissionStatus> {
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<DesktopPermissionS
}
async function requestMicrophonePermissionStatus(): Promise<DesktopPermissionStatus> {
if (Platform.OS !== "web") {
if (isNative) {
return status({
state: "unavailable",
detail: "Desktop microphone requests are only available on web runtime.",

View File

@@ -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 {

View File

@@ -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);

View File

@@ -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__ = {

View File

@@ -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);

View File

@@ -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<string | null>(focusedAgentId);
const lastImmediateHeartbeatAtRef = useRef<number>(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 = () => {

View File

@@ -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);

View File

@@ -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<HTMLElement | null>;
}
const IS_WEB = Platform.OS === "web";
const IS_WEB = isWeb;
const IMAGE_MIME_BY_EXTENSION: Record<string, string> = {
".png": "image/png",
".jpg": "image/jpeg",

View File

@@ -8,6 +8,7 @@ import {
openImagePathsWithDesktopDialog,
type PickedImageAttachmentInput,
} from "@/hooks/image-attachment-picker";
import { isWeb } from "@/constants/platform";
interface UseImageAttachmentPickerResult {
pickImages: () => Promise<PickedImageAttachmentInput[] | null>;
@@ -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;

View File

@@ -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();

View File

@@ -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<string | null>(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;

View File

@@ -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;
}

View File

@@ -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();

View File

@@ -1,6 +1,6 @@
import { useState, useEffect, useRef, useCallback } from "react";
import type { MutableRefObject, ComponentType } from "react";
import { View, Text, ScrollView, Alert, Platform, Pressable } from "react-native";
import { View, Text, ScrollView, Alert, Pressable } from "react-native";
import { router, useLocalSearchParams } from "expo-router";
import { useFocusEffect } from "@react-navigation/native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
@@ -72,6 +72,7 @@ import { AGENT_PROVIDER_DEFINITIONS } from "@server/server/agent/provider-manife
import { getProviderIcon } from "@/components/provider-icons";
import { ProviderDiagnosticSheet } from "@/components/provider-diagnostic-sheet";
import { StatusBadge } from "@/components/ui/status-badge";
import { isWeb } from "@/constants/platform";
// ---------------------------------------------------------------------------
// Section definitions
@@ -1672,22 +1673,20 @@ function DaemonCard({ daemon, onOpenSettings }: DaemonCardProps) {
<View style={styles.hostHeaderRight}>
<View
style={[
Platform.OS === "web" ? styles.statusPill : styles.statusPillMobile,
isWeb ? styles.statusPill : styles.statusPillMobile,
{ backgroundColor: statusPillBg },
]}
>
<View style={[styles.statusDot, { backgroundColor: statusColor }]} />
{Platform.OS === "web" ? (
{isWeb ? (
<Text style={[styles.statusText, { color: statusColor }]}>{badgeText}</Text>
) : null}
</View>
{connectionBadge ? (
<View
style={Platform.OS === "web" ? styles.connectionPill : styles.connectionPillMobile}
>
<View style={isWeb ? styles.connectionPill : styles.connectionPillMobile}>
{connectionBadge.icon}
{Platform.OS === "web" ? (
{isWeb ? (
<Text style={styles.connectionText} numberOfLines={1}>
{connectionBadge.text}
</Text>

View File

@@ -1,5 +1,5 @@
import { useState, useEffect } from "react";
import { View, Text, Platform } from "react-native";
import { View, Text } from "react-native";
import { useIsFocused } from "@react-navigation/native";
import { StyleSheet } from "react-native-unistyles";
import { settingsStyles } from "@/styles/settings";
@@ -20,6 +20,7 @@ import {
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
import { getShortcutOs } from "@/utils/shortcut-platform";
import { getIsElectronRuntime } from "@/constants/layout";
import { isNative } from "@/constants/platform";
function ShortcutSequence({
chord,
@@ -138,7 +139,7 @@ export function KeyboardShortcutsSection() {
}
useEffect(() => {
if (Platform.OS !== "web") return;
if (isNative) return;
if (capturingBindingId === null) return;
function handleKeyDown(event: KeyboardEvent) {
@@ -173,7 +174,7 @@ export function KeyboardShortcutsSection() {
};
}, [setCapturingShortcut]);
if (Platform.OS !== "web") {
if (isNative) {
return (
<View style={settingsStyles.section}>
<Text style={settingsStyles.sectionTitle}>Shortcuts</Text>

View File

@@ -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",

View File

@@ -9,7 +9,6 @@ import {
} from "react";
import {
ActivityIndicator,
Platform,
Pressable,
ScrollView,
Text,
@@ -30,6 +29,7 @@ import {
} from "lucide-react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { SortableInlineList } from "@/components/sortable-inline-list";
import { isNative, isWeb } from "@/constants/platform";
import {
ContextMenu,
ContextMenuContent,
@@ -113,6 +113,7 @@ function useMiddleClickClose(onClose: () => void) {
const ref = useRef<View>(null);
useEffect(() => {
if (isNative) return;
const node = ref.current as unknown as HTMLElement | null;
if (!node) return;
@@ -174,17 +175,16 @@ function TabChip({
);
const [hovered, setHovered] = useState(false);
const isHighlighted = isActive || hovered || isCloseHovered;
const closeButtonDragBlockers =
Platform.OS === "web"
? ({
onPointerDown: (event: { stopPropagation?: () => void }) => {
event.stopPropagation?.();
},
onMouseDown: (event: { stopPropagation?: () => void }) => {
event.stopPropagation?.();
},
} as const)
: undefined;
const closeButtonDragBlockers = isWeb
? ({
onPointerDown: (event: { stopPropagation?: () => void }) => {
event.stopPropagation?.();
},
onMouseDown: (event: { stopPropagation?: () => void }) => {
event.stopPropagation?.();
},
} as const)
: undefined;
return (
<View ref={middleClickRef}>
@@ -199,7 +199,7 @@ function TabChip({
enabledOnMobile={false}
style={({ hovered, pressed }) => [
styles.tab,
Platform.OS === "web" && isDragging && ({ cursor: "grabbing" } as const),
isWeb && isDragging && ({ cursor: "grabbing" } as const),
{
minWidth: resolvedTabWidth,
width: resolvedTabWidth,

View File

@@ -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();

View File

@@ -1,5 +1,5 @@
import { useCallback, useEffect, useMemo } from "react";
import { ActivityIndicator, Platform, Pressable, Text, View } from "react-native";
import { ActivityIndicator, Pressable, Text, View } from "react-native";
import { useMutation, useQuery } from "@tanstack/react-query";
import { Check, ChevronDown } from "lucide-react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
@@ -15,6 +15,7 @@ import { useToast } from "@/contexts/toast-context";
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
import { resolvePreferredEditorId, usePreferredEditor } from "@/hooks/use-preferred-editor";
import { isAbsolutePath } from "@/utils/path";
import { isWeb } from "@/constants/platform";
interface WorkspaceOpenInEditorButtonProps {
serverId: string;
@@ -29,10 +30,7 @@ export function WorkspaceOpenInEditorButton({ serverId, cwd }: WorkspaceOpenInEd
const { preferredEditorId, updatePreferredEditor } = usePreferredEditor();
const shouldLoadEditors =
Platform.OS === "web" &&
Boolean(client && isConnected) &&
cwd.trim().length > 0 &&
isAbsolutePath(cwd);
isWeb && Boolean(client && isConnected) && cwd.trim().length > 0 && isAbsolutePath(cwd);
const availableEditorsQuery = useQuery<EditorTargetDescriptorPayload[]>({
queryKey: ["available-editors", serverId],

View File

@@ -1,15 +1,7 @@
import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useStoreWithEqualityFn } from "zustand/traditional";
import { useIsFocused } from "@react-navigation/native";
import {
ActivityIndicator,
BackHandler,
Keyboard,
Platform,
Pressable,
Text,
View,
} from "react-native";
import { ActivityIndicator, BackHandler, Keyboard, Pressable, Text, View } from "react-native";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import * as Clipboard from "expo-clipboard";
import {
@@ -118,6 +110,7 @@ import {
} from "@/screens/workspace/workspace-bulk-close";
import { findAdjacentPane } from "@/utils/split-navigation";
import { useIsCompactFormFactor, supportsDesktopPaneSplits } from "@/constants/layout";
import { isWeb, isNative } from "@/constants/platform";
const TERMINALS_QUERY_STALE_TIME = 5_000;
const NEW_TAB_AGENT_OPTION_ID = "__new_tab_agent__";
@@ -252,7 +245,7 @@ function WorkspaceDocumentTitleEffect({
titleState: "ready" | "loading";
}) {
useEffect(() => {
if (Platform.OS !== "web" || typeof document === "undefined") {
if (isNative || typeof document === "undefined") {
return;
}
const resolvedLabel = label.trim();
@@ -810,7 +803,7 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
});
useEffect(() => {
if (Platform.OS === "web" || !isExplorerOpen) {
if (isWeb || !isExplorerOpen) {
return;
}
@@ -1709,7 +1702,7 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
const canRenderDesktopPaneSplits = supportsDesktopPaneSplits();
const shouldRenderDesktopPaneFallback = !isMobile && !canRenderDesktopPaneSplits;
useEffect(() => {
if (Platform.OS !== "web" || typeof document === "undefined" || activeTabDescriptor) {
if (isNative || typeof document === "undefined" || activeTabDescriptor) {
return;
}
document.title = "Workspace";
@@ -1933,7 +1926,7 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
return (
<View style={[styles.container, { backgroundColor: mainBackgroundColor }]}>
{Platform.OS === "web" && activeTabDescriptor ? (
{isWeb && activeTabDescriptor ? (
<WorkspaceTabPresentationResolver
tab={activeTabDescriptor}
serverId={normalizedServerId}

View File

@@ -1,11 +1,11 @@
import { create } from "zustand";
import { Platform } from "react-native";
import { File as FSFile, Paths } from "expo-file-system";
import * as LegacyFileSystem from "expo-file-system/legacy";
import * as Sharing from "expo-sharing";
import type { HostProfile } from "@/types/host-connection";
import { buildDaemonWebSocketUrl } from "@/utils/daemon-endpoints";
import { openExternalUrl } from "@/utils/open-external-url";
import { isWeb } from "@/constants/platform";
interface DownloadProgress {
percent: number;
@@ -97,10 +97,10 @@ export const useDownloadStore = create<DownloadState>()((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<DownloadState>()((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;

View File

@@ -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<PanelState>()(
persist(
@@ -318,11 +318,7 @@ export const usePanelStore = create<PanelState>()(
const state = persistedState as Partial<PanelState> & Record<string, unknown>;
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<PanelState>()(
if (version < 3) {
if (
Platform.OS === "web" &&
isWeb &&
typeof state.explorerWidth === "number" &&
(state.explorerWidth === 400 || state.explorerWidth === 520)
) {

View File

@@ -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.

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;
}

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

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;

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);

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;
}

View File

@@ -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 {

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";

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