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
+40
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). - 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?" - 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 ## Debugging
+7 -6
View File
@@ -81,6 +81,7 @@ import {
parseWorkspaceOpenIntent, parseWorkspaceOpenIntent,
} from "@/utils/host-routes"; } from "@/utils/host-routes";
import { syncNavigationActiveWorkspace } from "@/stores/navigation-active-workspace-store"; import { syncNavigationActiveWorkspace } from "@/stores/navigation-active-workspace-store";
import { isWeb, isNative } from "@/constants/platform";
polyfillCrypto(); polyfillCrypto();
@@ -101,7 +102,7 @@ function PushNotificationRouter() {
const lastHandledIdRef = useRef<string | null>(null); const lastHandledIdRef = useRef<string | null>(null);
useEffect(() => { useEffect(() => {
if (Platform.OS === "web") { if (isWeb) {
let removeDesktopNotificationListener: (() => void) | null = null; let removeDesktopNotificationListener: (() => void) | null = null;
let cancelled = false; let cancelled = false;
@@ -390,10 +391,10 @@ function AppContainer({
const screenW = UnistylesRuntime.screen.width; const screenW = UnistylesRuntime.screen.width;
const screenH = UnistylesRuntime.screen.height; const screenH = UnistylesRuntime.screen.height;
const isElectron = getIsElectronRuntime(); const isElectron = getIsElectronRuntime();
const windowW = Platform.OS === "web" ? window.innerWidth : undefined; const windowW = isWeb ? window.innerWidth : undefined;
const windowH = Platform.OS === "web" ? window.innerHeight : undefined; const windowH = isWeb ? window.innerHeight : undefined;
const dpr = Platform.OS === "web" ? window.devicePixelRatio : undefined; const dpr = isWeb ? window.devicePixelRatio : undefined;
const ua = Platform.OS === "web" ? navigator.userAgent : undefined; const ua = isWeb ? navigator.userAgent : undefined;
console.log( console.log(
"[layout-debug]", "[layout-debug]",
@@ -577,7 +578,7 @@ function ProvidersWrapper({ children }: { children: ReactNode }) {
}, [settingsLoading, settings.theme]); }, [settingsLoading, settings.theme]);
useEffect(() => { useEffect(() => {
if (settingsLoading || Platform.OS !== "web") { if (settingsLoading || isNative) {
return; return;
} }
@@ -1,6 +1,5 @@
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { useGlobalSearchParams, useLocalSearchParams, useRootNavigationState } from "expo-router"; import { useGlobalSearchParams, useLocalSearchParams, useRootNavigationState } from "expo-router";
import { Platform } from "react-native";
import { HostRouteBootstrapBoundary } from "@/components/host-route-bootstrap-boundary"; import { HostRouteBootstrapBoundary } from "@/components/host-route-bootstrap-boundary";
import type { WorkspaceTabTarget } from "@/stores/workspace-tabs-store"; import type { WorkspaceTabTarget } from "@/stores/workspace-tabs-store";
import { WorkspaceScreen } from "@/screens/workspace/workspace-screen"; import { WorkspaceScreen } from "@/screens/workspace/workspace-screen";
@@ -10,6 +9,7 @@ import {
type WorkspaceOpenIntent, type WorkspaceOpenIntent,
} from "@/utils/host-routes"; } from "@/utils/host-routes";
import { prepareWorkspaceTab } from "@/utils/workspace-navigation"; import { prepareWorkspaceTab } from "@/utils/workspace-navigation";
import { isWeb } from "@/constants/platform";
function getParamValue(value: string | string[] | undefined): string { function getParamValue(value: string | string[] | undefined): string {
if (typeof value === "string") { if (typeof value === "string") {
@@ -88,7 +88,7 @@ function HostWorkspaceLayoutContent() {
// Expo Router's replace ignores query-param-only changes (findDivergentState // Expo Router's replace ignores query-param-only changes (findDivergentState
// skips search params). Strip ?open from the browser URL directly so the // skips search params). Strip ?open from the browser URL directly so the
// address bar reflects the clean workspace route. // 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); const url = new URL(window.location.href);
if (url.searchParams.has("open")) { if (url.searchParams.has("open")) {
url.searchParams.delete("open"); url.searchParams.delete("open");
+4 -3
View File
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useRef, useState } from "react"; 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 { useLocalSearchParams, useRouter } from "expo-router";
import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useSafeAreaInsets } from "react-native-safe-area-context";
import { StyleSheet, useUnistyles } from "react-native-unistyles"; 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 { connectToDaemon } from "@/utils/test-daemon-connection";
import { ConnectionOfferSchema } from "@server/shared/connection-offer"; import { ConnectionOfferSchema } from "@server/shared/connection-offer";
import { buildHostRootRoute, buildHostSettingsRoute } from "@/utils/host-routes"; import { buildHostRootRoute, buildHostSettingsRoute } from "@/utils/host-routes";
import { isWeb } from "@/constants/platform";
const styles = StyleSheet.create((theme) => ({ const styles = StyleSheet.create((theme) => ({
container: { container: {
@@ -198,7 +199,7 @@ export default function PairScanScreen() {
}, [router, source, sourceServerId, targetServerId]); }, [router, source, sourceServerId, targetServerId]);
useEffect(() => { useEffect(() => {
if (Platform.OS === "web") return; if (isWeb) return;
if (permission && permission.granted) return; if (permission && permission.granted) return;
void requestPermission().catch(() => undefined); void requestPermission().catch(() => undefined);
}, [permission, requestPermission]); }, [permission, requestPermission]);
@@ -253,7 +254,7 @@ export default function PairScanScreen() {
[daemons, isPairing, returnToSource, targetServerId, upsertDaemonFromOfferUrl], [daemons, isPairing, returnToSource, targetServerId, upsertDaemonFromOfferUrl],
); );
if (Platform.OS === "web") { if (isWeb) {
return ( return (
<View style={styles.container}> <View style={styles.container}>
<View style={[styles.header, { paddingTop: insets.top + theme.spacing[2] }]}> <View style={[styles.header, { paddingTop: insets.top + theme.spacing[2] }]}>
+2 -2
View File
@@ -1,11 +1,11 @@
import { Platform } from "react-native";
import { isElectronRuntime } from "@/desktop/host"; import { isElectronRuntime } from "@/desktop/host";
import type { AttachmentStore } from "@/attachments/types"; import type { AttachmentStore } from "@/attachments/types";
import { isWeb } from "@/constants/platform";
let attachmentStorePromise: Promise<AttachmentStore> | null = null; let attachmentStorePromise: Promise<AttachmentStore> | null = null;
async function createAttachmentStore(): Promise<AttachmentStore> { async function createAttachmentStore(): Promise<AttachmentStore> {
if (Platform.OS === "web") { if (isWeb) {
if (isElectronRuntime()) { if (isElectronRuntime()) {
const { createDesktopAttachmentStore } = await import( const { createDesktopAttachmentStore } = await import(
"../desktop/attachments/desktop-attachment-store" "../desktop/attachments/desktop-attachment-store"
@@ -1,7 +1,7 @@
import { forwardRef, useCallback, useEffect, useMemo, useRef } from "react"; import { forwardRef, useCallback, useEffect, useMemo, useRef } from "react";
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import { createPortal } from "react-dom"; 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 type { TextInputProps } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useIsCompactFormFactor } from "@/constants/layout"; import { useIsCompactFormFactor } from "@/constants/layout";
@@ -14,6 +14,7 @@ import {
type BottomSheetBackgroundProps, type BottomSheetBackgroundProps,
} from "@gorhom/bottom-sheet"; } from "@gorhom/bottom-sheet";
import { X } from "lucide-react-native"; import { X } from "lucide-react-native";
import { isWeb } from "@/constants/platform";
const styles = StyleSheet.create((theme) => ({ const styles = StyleSheet.create((theme) => ({
desktopOverlay: { desktopOverlay: {
@@ -216,7 +217,7 @@ export function AdaptiveModalSheet({
); );
// On web, use portal to overlay root for consistent stacking with toasts // 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; if (!visible) return null;
return createPortal(desktopContent, getOverlayRoot()); return createPortal(desktopContent, getOverlayRoot());
} }
@@ -1,8 +1,9 @@
import { useCallback } from "react"; 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 { StyleSheet, useUnistyles } from "react-native-unistyles";
import { QrCode, Link2, ClipboardPaste } from "lucide-react-native"; import { QrCode, Link2, ClipboardPaste } from "lucide-react-native";
import { AdaptiveModalSheet } from "./adaptive-modal-sheet"; import { AdaptiveModalSheet } from "./adaptive-modal-sheet";
import { isNative } from "@/constants/platform";
const styles = StyleSheet.create((theme) => ({ const styles = StyleSheet.create((theme) => ({
option: { option: {
@@ -78,7 +79,7 @@ export function AddHostMethodModal({
</View> </View>
</Pressable> </Pressable>
{Platform.OS !== "web" ? ( {isNative ? (
<Pressable style={styles.option} onPress={handleScan} accessibilityLabel="Scan QR code"> <Pressable style={styles.option} onPress={handleScan} accessibilityLabel="Scan QR code">
<QrCode size={18} color={theme.colors.foreground} /> <QrCode size={18} color={theme.colors.foreground} />
<View style={styles.optionBody}> <View style={styles.optionBody}>
@@ -1,6 +1,6 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { ReactElement, ReactNode } 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 type { StyleProp, ViewStyle, TextProps } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { 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 { getModeVisuals, type AgentModeIcon } from "@server/server/agent/provider-manifest";
import { Combobox, ComboboxItem, ComboboxEmpty } from "@/components/ui/combobox"; import { Combobox, ComboboxItem, ComboboxEmpty } from "@/components/ui/combobox";
import { baseColors } from "@/styles/theme"; import { baseColors } from "@/styles/theme";
import { isNative } from "@/constants/platform";
const MODE_ICON_MAP: Record<AgentModeIcon, typeof ShieldCheck> = { const MODE_ICON_MAP: Record<AgentModeIcon, typeof ShieldCheck> = {
ShieldCheck, ShieldCheck,
@@ -168,7 +169,7 @@ export function SelectField({
const handleKeyDown = useCallback( const handleKeyDown = useCallback(
(event: unknown) => { (event: unknown) => {
if (Platform.OS !== "web") return; if (isNative) return;
const key = getWebKey(event); const key = getWebKey(event);
if (key === "Enter" || key === " ") { if (key === "Enter" || key === " ") {
preventWebDefault(event); preventWebDefault(event);
@@ -430,7 +431,7 @@ export function FormSelectTrigger({
const handleKeyDown = useCallback( const handleKeyDown = useCallback(
(event: unknown) => { (event: unknown) => {
if (Platform.OS !== "web") return; if (isNative) return;
const key = getWebKey(event); const key = getWebKey(event);
if (key === "Enter" || key === " ") { if (key === "Enter" || key === " ") {
preventWebDefault(event); preventWebDefault(event);
@@ -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 { useState, useEffect, useRef, useCallback } from "react";
import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useIsCompactFormFactor } from "@/constants/layout"; 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 type { KeyboardActionDefinition } from "@/keyboard/keyboard-action-dispatcher";
import { submitAgentInput } from "@/components/agent-input-submit"; import { submitAgentInput } from "@/components/agent-input-submit";
import { useAppSettings } from "@/hooks/use-settings"; import { useAppSettings } from "@/hooks/use-settings";
import { isWeb, isNative } from "@/constants/platform";
type QueuedMessage = { type QueuedMessage = {
id: string; id: string;
@@ -118,7 +119,7 @@ export function AgentInputArea({
}: AgentInputAreaProps) { }: AgentInputAreaProps) {
markScrollInvestigationRender(`AgentInputArea:${serverId}:${agentId}`); markScrollInvestigationRender(`AgentInputArea:${serverId}:${agentId}`);
const { theme } = useUnistyles(); 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 insets = useSafeAreaInsets();
const client = useHostRuntimeClient(serverId); const client = useHostRuntimeClient(serverId);
const isConnected = useHostRuntimeIsConnected(serverId); const isConnected = useHostRuntimeIsConnected(serverId);
@@ -156,7 +157,7 @@ export function AgentInputArea({
const setAgentStreamHead = useSessionStore((state) => state.setAgentStreamHead); const setAgentStreamHead = useSessionStore((state) => state.setAgentStreamHead);
const isMobile = useIsCompactFormFactor(); const isMobile = useIsCompactFormFactor();
const isDesktopWebBreakpoint = Platform.OS === "web" && !isMobile; const isDesktopWebBreakpoint = isWeb && !isMobile;
const messagePlaceholder = isDesktopWebBreakpoint const messagePlaceholder = isDesktopWebBreakpoint
? DESKTOP_MESSAGE_PLACEHOLDER ? DESKTOP_MESSAGE_PLACEHOLDER
: MOBILE_MESSAGE_PLACEHOLDER; : MOBILE_MESSAGE_PLACEHOLDER;
@@ -217,7 +218,7 @@ export function AgentInputArea({
}, [addImages, onAddImages]); }, [addImages, onAddImages]);
const focusInput = useCallback(() => { const focusInput = useCallback(() => {
if (Platform.OS !== "web") return; if (isNative) return;
focusWithRetries({ focusWithRetries({
focus: () => messageInputRef.current?.focus(), focus: () => messageInputRef.current?.focus(),
isFocused: () => { isFocused: () => {
@@ -430,7 +431,7 @@ export function AgentInputArea({
case "message-input.dictation-confirm": case "message-input.dictation-confirm":
return messageInputRef.current?.runKeyboardAction("dictation-confirm") ?? false; return messageInputRef.current?.runKeyboardAction("dictation-confirm") ?? false;
case "message-input.focus": case "message-input.focus":
if (Platform.OS !== "web") { if (isNative) {
messageInputRef.current?.focus(); messageInputRef.current?.focus();
return true; return true;
} }
@@ -1,5 +1,5 @@
import { memo, useCallback, useMemo, useRef, useState } from "react"; 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 { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useShallow } from "zustand/shallow"; import { useShallow } from "zustand/shallow";
import { useStoreWithEqualityFn } from "zustand/traditional"; import { useStoreWithEqualityFn } from "zustand/traditional";
@@ -51,6 +51,7 @@ import {
getStatusSelectorHint, getStatusSelectorHint,
resolveAgentModelSelection, resolveAgentModelSelection,
} from "@/components/agent-status-bar.utils"; } from "@/components/agent-status-bar.utils";
import { isWeb as platformIsWeb } from "@/constants/platform";
type StatusOption = { type StatusOption = {
id: string; id: string;
@@ -222,7 +223,6 @@ function ControlledStatusBar({
onModelSelectorOpen, onModelSelectorOpen,
}: ControlledAgentStatusBarProps) { }: ControlledAgentStatusBarProps) {
const { theme } = useUnistyles(); const { theme } = useUnistyles();
const isWeb = Platform.OS === "web";
const [prefsOpen, setPrefsOpen] = useState(false); const [prefsOpen, setPrefsOpen] = useState(false);
const [openSelector, setOpenSelector] = useState<StatusSelector | null>(null); const [openSelector, setOpenSelector] = useState<StatusSelector | null>(null);
@@ -356,7 +356,7 @@ function ControlledStatusBar({
return ( return (
<View style={styles.container}> <View style={styles.container}>
{isWeb ? ( {platformIsWeb ? (
<> <>
{providerOptions && providerOptions.length > 0 ? ( {providerOptions && providerOptions.length > 0 ? (
<> <>
@@ -1077,7 +1077,6 @@ export function DraftAgentStatusBar({
onModelSelectorOpen, onModelSelectorOpen,
disabled = false, disabled = false,
}: DraftAgentStatusBarProps) { }: DraftAgentStatusBarProps) {
const isWeb = Platform.OS === "web";
const { preferences, updatePreferences } = useFormPreferences(); const { preferences, updatePreferences } = useFormPreferences();
const mappedModeOptions = useMemo<StatusOption[]>(() => { const mappedModeOptions = useMemo<StatusOption[]>(() => {
@@ -1105,7 +1104,7 @@ export function DraftAgentStatusBar({
const effectiveSelectedThinkingOption = const effectiveSelectedThinkingOption =
selectedThinkingOptionId || mappedThinkingOptions[0]?.id || undefined; selectedThinkingOptionId || mappedThinkingOptions[0]?.id || undefined;
if (isWeb) { if (platformIsWeb) {
return ( return (
<View style={styles.container}> <View style={styles.container}>
<CombinedModelSelector <CombinedModelSelector
@@ -73,6 +73,7 @@ import {
WORKING_INDICATOR_CYCLE_MS, WORKING_INDICATOR_CYCLE_MS,
WORKING_INDICATOR_OFFSETS, WORKING_INDICATOR_OFFSETS,
} from "@/utils/working-indicator"; } from "@/utils/working-indicator";
import { isWeb } from "@/constants/platform";
const isUserMessageItem = (item?: StreamItem) => item?.kind === "user_message"; const isUserMessageItem = (item?: StreamItem) => item?.kind === "user_message";
const isToolSequenceItem = (item?: StreamItem) => const isToolSequenceItem = (item?: StreamItem) =>
@@ -216,7 +217,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
return buildAgentStreamRenderModel({ return buildAgentStreamRenderModel({
tail: streamItems, tail: streamItems,
head: streamHead ?? [], head: streamHead ?? [],
platform: Platform.OS === "web" ? "web" : "native", platform: isWeb ? "web" : "native",
isMobileBreakpoint: isMobile, isMobileBreakpoint: isMobile,
}); });
}, [isMobile, streamHead, streamItems]); }, [isMobile, streamHead, streamItems]);
@@ -4,17 +4,17 @@ import {
Text, Text,
TextInput, TextInput,
Pressable, Pressable,
Platform,
ActivityIndicator, ActivityIndicator,
type GestureResponderEvent, type GestureResponderEvent,
} from "react-native"; } from "react-native";
import { BottomSheetTextInput } from "@gorhom/bottom-sheet"; import { BottomSheetTextInput } from "@gorhom/bottom-sheet";
import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useIsCompactFormFactor } from "@/constants/layout"; import { useIsCompactFormFactor } from "@/constants/layout";
import { isWeb as platformIsWeb } from "@/constants/platform";
import { ArrowLeft, ChevronDown, ChevronRight, Search, Star } from "lucide-react-native"; import { ArrowLeft, ChevronDown, ChevronRight, Search, Star } from "lucide-react-native";
import type { AgentModelDefinition, AgentProvider } from "@server/server/agent/agent-sdk-types"; import type { AgentModelDefinition, AgentProvider } from "@server/server/agent/agent-sdk-types";
import type { AgentProviderDefinition } from "@server/server/agent/provider-manifest"; 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 { Combobox, ComboboxItem } from "@/components/ui/combobox";
import { getProviderIcon } from "@/components/provider-icons"; import { getProviderIcon } from "@/components/provider-icons";
@@ -349,7 +349,7 @@ function ProviderSearchInput({
const InputComponent = isMobile ? BottomSheetTextInput : TextInput; const InputComponent = isMobile ? BottomSheetTextInput : TextInput;
useEffect(() => { useEffect(() => {
if (autoFocus && Platform.OS === "web" && inputRef.current) { if (autoFocus && platformIsWeb && inputRef.current) {
const timer = setTimeout(() => { const timer = setTimeout(() => {
inputRef.current?.focus(); inputRef.current?.focus();
}, 50); }, 50);
@@ -363,7 +363,7 @@ function ProviderSearchInput({
<InputComponent <InputComponent
ref={inputRef as any} ref={inputRef as any}
// @ts-expect-error - outlineStyle is web-only // @ts-expect-error - outlineStyle is web-only
style={[styles.providerSearchInput, Platform.OS === "web" && { outlineStyle: "none" }]} style={[styles.providerSearchInput, platformIsWeb && { outlineStyle: "none" }]}
placeholder="Search models..." placeholder="Search models..."
placeholderTextColor={theme.colors.foregroundMuted} placeholderTextColor={theme.colors.foregroundMuted}
value={value} value={value}
@@ -518,10 +518,9 @@ export function CombinedModelSelector({
disabled = false, disabled = false,
}: CombinedModelSelectorProps) { }: CombinedModelSelectorProps) {
const { theme } = useUnistyles(); const { theme } = useUnistyles();
const isWeb = Platform.OS === "web";
const anchorRef = useRef<View>(null); const anchorRef = useRef<View>(null);
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const [isContentReady, setIsContentReady] = useState(isWeb); const [isContentReady, setIsContentReady] = useState(platformIsWeb);
const [view, setView] = useState<SelectorView>({ kind: "all" }); const [view, setView] = useState<SelectorView>({ kind: "all" });
const [searchQuery, setSearchQuery] = useState(""); const [searchQuery, setSearchQuery] = useState("");
@@ -591,7 +590,7 @@ export function CombinedModelSelector({
}, [selectedModelLabel, selectedProviderLabel]); }, [selectedModelLabel, selectedProviderLabel]);
useEffect(() => { useEffect(() => {
if (isWeb) { if (platformIsWeb) {
return; return;
} }
@@ -605,7 +604,7 @@ export function CombinedModelSelector({
}); });
return () => cancelAnimationFrame(frame); return () => cancelAnimationFrame(frame);
}, [isOpen, isWeb]); }, [isOpen, platformIsWeb]);
return ( return (
<> <>
@@ -668,7 +667,7 @@ export function CombinedModelSelector({
<ProviderSearchInput <ProviderSearchInput
value={searchQuery} value={searchQuery}
onChangeText={setSearchQuery} onChangeText={setSearchQuery}
autoFocus={Platform.OS === "web"} autoFocus={platformIsWeb}
/> />
</View> </View>
) : undefined ) : undefined
@@ -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 { memo, useEffect, useRef, type ReactNode } from "react";
import { Plus, Settings } from "lucide-react-native"; import { Plus, Settings } from "lucide-react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { StyleSheet, useUnistyles } from "react-native-unistyles";
@@ -8,6 +8,7 @@ import { formatTimeAgo } from "@/utils/time";
import { shortenPath } from "@/utils/shorten-path"; import { shortenPath } from "@/utils/shorten-path";
import { AgentStatusDot } from "@/components/agent-status-dot"; import { AgentStatusDot } from "@/components/agent-status-dot";
import { Shortcut } from "@/components/ui/shortcut"; import { Shortcut } from "@/components/ui/shortcut";
import { isNative } from "@/constants/platform";
function agentKey(agent: Pick<AggregatedAgent, "serverId" | "id">): string { function agentKey(agent: Pick<AggregatedAgent, "serverId" | "id">): string {
return `${agent.serverId}:${agent.id}`; return `${agent.serverId}:${agent.id}`;
@@ -90,7 +91,7 @@ export function CommandCenter() {
} }
}, [activeIndex, open]); }, [activeIndex, open]);
if (Platform.OS !== "web" || !open) return null; if (isNative || !open) return null;
const actionItems = items.filter((item) => item.kind === "action"); const actionItems = items.filter((item) => item.kind === "action");
const agentItems = items.filter((item) => item.kind === "agent"); const agentItems = items.filter((item) => item.kind === "agent");
@@ -1,5 +1,5 @@
import { Platform } from "react-native";
import { getIsElectronRuntime } from "@/constants/layout"; import { getIsElectronRuntime } from "@/constants/layout";
import { isNative } from "@/constants/platform";
/** /**
* VS Code-style titlebar drag region for Electron. * 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. * Place as FIRST child of any positioned container that should be draggable.
*/ */
export function TitlebarDragRegion() { export function TitlebarDragRegion() {
if (Platform.OS !== "web" || !getIsElectronRuntime()) { if (isNative || !getIsElectronRuntime()) {
return null; return null;
} }
+4 -3
View File
@@ -1,12 +1,13 @@
import React from "react"; 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 { ScrollView as GHScrollView } from "react-native-gesture-handler";
import { StyleSheet } from "react-native-unistyles"; import { StyleSheet } from "react-native-unistyles";
import { Fonts } from "@/constants/theme"; import { Fonts } from "@/constants/theme";
import type { DiffLine, DiffSegment } from "@/utils/tool-call-parsers"; import type { DiffLine, DiffSegment } from "@/utils/tool-call-parsers";
import { getCodeInsets } from "./code-insets"; import { getCodeInsets } from "./code-insets";
import { isWeb } from "@/constants/platform";
const ScrollView = Platform.OS === "web" ? RNScrollView : GHScrollView; const ScrollView = isWeb ? RNScrollView : GHScrollView;
interface DiffViewerProps { interface DiffViewerProps {
diffLines: DiffLine[]; diffLines: DiffLine[];
@@ -130,7 +131,7 @@ const styles = StyleSheet.create((theme) => {
fontFamily: Fonts.mono, fontFamily: Fonts.mono,
fontSize: theme.fontSize.xs, fontSize: theme.fontSize.xs,
color: theme.colors.foreground, color: theme.colors.foreground,
...(Platform.OS === "web" ...(isWeb
? { ? {
whiteSpace: "pre", whiteSpace: "pre",
overflowWrap: "normal", overflowWrap: "normal",
@@ -3,7 +3,6 @@ import {
View, View,
Text, Text,
Pressable, Pressable,
Platform,
useWindowDimensions, useWindowDimensions,
StyleSheet as RNStyleSheet, StyleSheet as RNStyleSheet,
} from "react-native"; } from "react-native";
@@ -26,6 +25,7 @@ import { FileExplorerPane } from "./file-explorer-pane";
import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style"; import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style";
import { useWindowControlsPadding } from "@/utils/desktop-window"; import { useWindowControlsPadding } from "@/utils/desktop-window";
import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region"; import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region";
import { isWeb } from "@/constants/platform";
const MIN_CHAT_WIDTH = 400; const MIN_CHAT_WIDTH = 400;
function logExplorerSidebar(_event: string, _details: Record<string, unknown>): void {} function logExplorerSidebar(_event: string, _details: Record<string, unknown>): void {}
@@ -252,7 +252,7 @@ export function ExplorerSidebar({
// Mobile: full-screen overlay with gesture. // Mobile: full-screen overlay with gesture.
// On web, keep it interactive only while open so closed sidebars don't eat taps. // 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 // Navigation stacks can keep previous screens mounted; hide sidebars for unfocused
// screens so only the active screen exposes explorer/terminal surfaces. // screens so only the active screen exposes explorer/terminal surfaces.
@@ -309,12 +309,7 @@ export function ExplorerSidebar({
<View style={[styles.desktopSidebarBorder, { flex: 1 }]}> <View style={[styles.desktopSidebarBorder, { flex: 1 }]}>
{/* Resize handle - absolutely positioned over left border */} {/* Resize handle - absolutely positioned over left border */}
<GestureDetector gesture={resizeGesture}> <GestureDetector gesture={resizeGesture}>
<View <View style={[styles.resizeHandle, isWeb && ({ cursor: "col-resize" } as any)]} />
style={[
styles.resizeHandle,
Platform.OS === "web" && ({ cursor: "col-resize" } as any),
]}
/>
</GestureDetector> </GestureDetector>
<SidebarContent <SidebarContent
@@ -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 { StyleSheet, useUnistyles } from "react-native-unistyles";
import Animated, { useAnimatedStyle, withTiming, useSharedValue } from "react-native-reanimated"; import Animated, { useAnimatedStyle, withTiming, useSharedValue } from "react-native-reanimated";
import { useEffect } from "react"; import { useEffect } from "react";
import { Upload } from "lucide-react-native"; import { Upload } from "lucide-react-native";
import { useFileDropZone } from "@/hooks/use-file-drop-zone"; import { useFileDropZone } from "@/hooks/use-file-drop-zone";
import type { ImageAttachment } from "./message-input"; import type { ImageAttachment } from "./message-input";
import { isWeb } from "@/constants/platform";
interface FileDropZoneProps { interface FileDropZoneProps {
children: React.ReactNode; children: React.ReactNode;
@@ -12,7 +13,7 @@ interface FileDropZoneProps {
disabled?: boolean; disabled?: boolean;
} }
const IS_WEB = Platform.OS === "web"; const IS_WEB = isWeb;
export function FileDropZone({ children, onFilesDropped, disabled = false }: FileDropZoneProps) { export function FileDropZone({ children, onFilesDropped, disabled = false }: FileDropZoneProps) {
const { theme } = useUnistyles(); const { theme } = useUnistyles();
@@ -7,7 +7,6 @@ import {
Pressable, Pressable,
Text, Text,
View, View,
Platform,
} from "react-native"; } from "react-native";
import { Gesture } from "react-native-gesture-handler"; import { Gesture } from "react-native-gesture-handler";
import { StyleSheet, useUnistyles } from "react-native-unistyles"; 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 { formatTimeAgo } from "@/utils/time";
import { buildAbsoluteExplorerPath } from "@/utils/explorer-paths"; import { buildAbsoluteExplorerPath } from "@/utils/explorer-paths";
import { useWebScrollViewScrollbar } from "@/components/use-web-scrollbar"; import { useWebScrollViewScrollbar } from "@/components/use-web-scrollbar";
import { isWeb } from "@/constants/platform";
const SORT_OPTIONS: { value: SortOption; label: string }[] = [ const SORT_OPTIONS: { value: SortOption; label: string }[] = [
{ value: "name", label: "Name" }, { value: "name", label: "Name" },
@@ -91,7 +91,7 @@ export function FileExplorerPane({
}: FileExplorerPaneProps) { }: FileExplorerPaneProps) {
const { theme } = useUnistyles(); const { theme } = useUnistyles();
const isMobile = useIsCompactFormFactor(); const isMobile = useIsCompactFormFactor();
const showDesktopWebScrollbar = Platform.OS === "web" && !isMobile; const showDesktopWebScrollbar = isWeb && !isMobile;
const daemons = useHosts(); const daemons = useHosts();
const daemonProfile = useMemo( const daemonProfile = useMemo(
+2 -2
View File
@@ -6,7 +6,6 @@ import {
ScrollView as RNScrollView, ScrollView as RNScrollView,
Text, Text,
View, View,
Platform,
} from "react-native"; } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useIsCompactFormFactor } from "@/constants/layout"; import { useIsCompactFormFactor } from "@/constants/layout";
@@ -21,6 +20,7 @@ import {
type HighlightStyle, type HighlightStyle,
} from "@getpaseo/highlight"; } from "@getpaseo/highlight";
import { lineNumberGutterWidth } from "@/components/code-insets"; import { lineNumberGutterWidth } from "@/components/code-insets";
import { isWeb } from "@/constants/platform";
interface CodeLineProps { interface CodeLineProps {
tokens: HighlightToken[]; tokens: HighlightToken[];
@@ -243,7 +243,7 @@ export function FilePane({
filePath: string; filePath: string;
}) { }) {
const isMobile = useIsCompactFormFactor(); const isMobile = useIsCompactFormFactor();
const showDesktopWebScrollbar = Platform.OS === "web" && !isMobile; const showDesktopWebScrollbar = isWeb && !isMobile;
const client = useSessionStore((state) => state.sessions[serverId]?.client ?? null); const client = useSessionStore((state) => state.sessions[serverId]?.client ?? null);
const normalizedWorkspaceRoot = useMemo(() => workspaceRoot.trim(), [workspaceRoot]); const normalizedWorkspaceRoot = useMemo(() => workspaceRoot.trim(), [workspaceRoot]);
@@ -6,7 +6,6 @@ import {
ActivityIndicator, ActivityIndicator,
Pressable, Pressable,
FlatList, FlatList,
Platform,
type LayoutChangeEvent, type LayoutChangeEvent,
type NativeSyntheticEvent, type NativeSyntheticEvent,
type NativeScrollEvent, type NativeScrollEvent,
@@ -79,6 +78,7 @@ import {
formatDiffGutterText, formatDiffGutterText,
hasVisibleDiffTokens, hasVisibleDiffTokens,
} from "@/utils/diff-rendering"; } from "@/utils/diff-rendering";
import { isWeb, isNative } from "@/constants/platform";
export type { GitActionId, GitAction, GitActions } from "@/components/git-actions-policy"; export type { GitActionId, GitAction, GitActions } from "@/components/git-actions-policy";
@@ -99,7 +99,7 @@ type WrappedWebTextStyle = TextStyle & {
}; };
function getWrappedTextStyle(wrapLines: boolean): WrappedWebTextStyle | undefined { function getWrappedTextStyle(wrapLines: boolean): WrappedWebTextStyle | undefined {
if (Platform.OS !== "web") { if (isNative) {
return undefined; return undefined;
} }
return wrapLines return wrapLines
@@ -453,7 +453,7 @@ const DiffFileHeader = memo(function DiffFileHeader({
}} }}
onPressOut={(event) => { onPressOut={(event) => {
if ( if (
Platform.OS !== "web" && isNative &&
!pressHandledRef.current && !pressHandledRef.current &&
layoutYRef.current === 0 && layoutYRef.current === 0 &&
pressInRef.current pressInRef.current
@@ -632,8 +632,8 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi
const { theme } = useUnistyles(); const { theme } = useUnistyles();
const toast = useToast(); const toast = useToast();
const isMobile = useIsCompactFormFactor(); const isMobile = useIsCompactFormFactor();
const showDesktopWebScrollbar = Platform.OS === "web" && !isMobile; const showDesktopWebScrollbar = isWeb && !isMobile;
const canUseSplitLayout = Platform.OS === "web" && !isMobile; const canUseSplitLayout = isWeb && !isMobile;
const router = useRouter(); const router = useRouter();
const [diffModeOverride, setDiffModeOverride] = useState<"uncommitted" | "base" | null>(null); const [diffModeOverride, setDiffModeOverride] = useState<"uncommitted" | "base" | null>(null);
const [postShipArchiveSuggested, setPostShipArchiveSuggested] = useState(false); const [postShipArchiveSuggested, setPostShipArchiveSuggested] = useState(false);
@@ -1,16 +1,10 @@
import type { ReactElement, ReactNode } from "react"; import type { ReactElement, ReactNode } from "react";
import { import { Text, View, type PressableProps, type StyleProp, type ViewStyle } from "react-native";
Platform,
Text,
View,
type PressableProps,
type StyleProp,
type ViewStyle,
} from "react-native";
import { StyleSheet } from "react-native-unistyles"; import { StyleSheet } from "react-native-unistyles";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { Shortcut } from "@/components/ui/shortcut"; import { Shortcut } from "@/components/ui/shortcut";
import type { ShortcutKey } from "@/utils/format-shortcut"; import type { ShortcutKey } from "@/utils/format-shortcut";
import { isWeb } from "@/constants/platform";
interface HeaderToggleButtonState { interface HeaderToggleButtonState {
hovered: boolean; hovered: boolean;
@@ -44,7 +38,7 @@ export function HeaderToggleButton({
: undefined; : undefined;
const expandedState = (props.accessibilityState as { expanded?: boolean } | undefined)?.expanded; const expandedState = (props.accessibilityState as { expanded?: boolean } | undefined)?.expanded;
const ariaExpandedProps = const ariaExpandedProps =
Platform.OS === "web" && typeof expandedState === "boolean" isWeb && typeof expandedState === "boolean"
? ({ "aria-expanded": expandedState } as any) ? ({ "aria-expanded": expandedState } as any)
: null; : null;
+3 -8
View File
@@ -14,7 +14,6 @@ import {
View, View,
Pressable, Pressable,
Text, Text,
Platform,
useWindowDimensions, useWindowDimensions,
StyleSheet as RNStyleSheet, StyleSheet as RNStyleSheet,
} from "react-native"; } from "react-native";
@@ -59,6 +58,7 @@ import {
parseServerIdFromPathname, parseServerIdFromPathname,
} from "@/utils/host-routes"; } from "@/utils/host-routes";
import { useOpenProjectPicker } from "@/hooks/use-open-project-picker"; import { useOpenProjectPicker } from "@/hooks/use-open-project-picker";
import { isWeb } from "@/constants/platform";
const MIN_CHAT_WIDTH = 400; const MIN_CHAT_WIDTH = 400;
@@ -527,7 +527,7 @@ function MobileSidebar({
pointerEvents: backdropOpacity.value > 0.01 ? "auto" : "none", 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 ( return (
<View style={StyleSheet.absoluteFillObject} pointerEvents={overlayPointerEvents}> <View style={StyleSheet.absoluteFillObject} pointerEvents={overlayPointerEvents}>
@@ -833,12 +833,7 @@ function DesktopSidebar({
{/* Resize handle - absolutely positioned over right border */} {/* Resize handle - absolutely positioned over right border */}
<GestureDetector gesture={resizeGesture}> <GestureDetector gesture={resizeGesture}>
<View <View style={[styles.resizeHandle, isWeb && ({ cursor: "col-resize" } as any)]} />
style={[
styles.resizeHandle,
Platform.OS === "web" && ({ cursor: "col-resize" } as any),
]}
/>
</GestureDetector> </GestureDetector>
</View> </View>
</Animated.View> </Animated.View>
@@ -9,7 +9,6 @@ import {
TextInputKeyPressEventData, TextInputKeyPressEventData,
TextInputSelectionChangeEventData, TextInputSelectionChangeEventData,
Image, Image,
Platform,
BackHandler, BackHandler,
} from "react-native"; } from "react-native";
import { import {
@@ -48,6 +47,7 @@ import {
markScrollInvestigationEvent, markScrollInvestigationEvent,
markScrollInvestigationRender, markScrollInvestigationRender,
} from "@/utils/scroll-jank-investigation"; } from "@/utils/scroll-jank-investigation";
import { isWeb } from "@/constants/platform";
export type ImageAttachment = AttachmentMetadata; export type ImageAttachment = AttachmentMetadata;
@@ -115,7 +115,7 @@ export interface MessageInputRef {
const MIN_INPUT_HEIGHT = 30; const MIN_INPUT_HEIGHT = 30;
const MAX_INPUT_HEIGHT = 160; const MAX_INPUT_HEIGHT = 160;
const IS_WEB = Platform.OS === "web"; const IS_WEB = isWeb;
type WebTextInputKeyPressEvent = NativeSyntheticEvent< type WebTextInputKeyPressEvent = NativeSyntheticEvent<
TextInputKeyPressEventData & { TextInputKeyPressEventData & {
@@ -1290,7 +1290,7 @@ const styles = StyleSheet.create(((theme: any) => ({
rightButtonGroup: { rightButtonGroup: {
flexDirection: "row", flexDirection: "row",
alignItems: "center", alignItems: "center",
gap: Platform.OS === "web" ? theme.spacing[2] : theme.spacing[1], gap: isWeb ? theme.spacing[2] : theme.spacing[1],
}, },
attachButton: { attachButton: {
width: 28, width: 28,
+14 -13
View File
@@ -7,7 +7,6 @@ import {
type LayoutChangeEvent, type LayoutChangeEvent,
StyleProp, StyleProp,
ViewStyle, ViewStyle,
Platform,
} from "react-native"; } from "react-native";
import * as React from "react"; import * as React from "react";
import { import {
@@ -86,6 +85,7 @@ import { useToolCallSheet } from "./tool-call-sheet";
import { ToolCallDetailsContent } from "./tool-call-details"; import { ToolCallDetailsContent } from "./tool-call-details";
import { useAttachmentPreviewUrl } from "@/attachments/use-attachment-preview-url"; import { useAttachmentPreviewUrl } from "@/attachments/use-attachment-preview-url";
import type { DaemonClient } from "@server/client/daemon-client"; import type { DaemonClient } from "@server/client/daemon-client";
import { isWeb, isNative } from "@/constants/platform";
interface UserMessageProps { interface UserMessageProps {
message: string; message: string;
@@ -134,7 +134,7 @@ const SCROLL_EDGE_EPSILON = 0.5;
type ScrollAxis = "x" | "y"; type ScrollAxis = "x" | "y";
function ensureWebToolCallShimmerKeyframes() { function ensureWebToolCallShimmerKeyframes() {
if (Platform.OS !== "web") { if (isNative) {
return; return;
} }
if (typeof document === "undefined") { if (typeof document === "undefined") {
@@ -341,12 +341,13 @@ export const UserMessage = memo(function UserMessage({
isLastInGroup = true, isLastInGroup = true,
disableOuterSpacing, disableOuterSpacing,
}: UserMessageProps) { }: UserMessageProps) {
const isCompact = useIsCompactFormFactor();
const [messageHovered, setMessageHovered] = useState(false); const [messageHovered, setMessageHovered] = useState(false);
const [copyButtonHovered, setCopyButtonHovered] = useState(false); const [copyButtonHovered, setCopyButtonHovered] = useState(false);
const resolvedDisableOuterSpacing = useDisableOuterSpacing(disableOuterSpacing); const resolvedDisableOuterSpacing = useDisableOuterSpacing(disableOuterSpacing);
const hasText = message.trim().length > 0; const hasText = message.trim().length > 0;
const hasImages = images.length > 0; const hasImages = images.length > 0;
const showCopyButton = hasText && (Platform.OS !== "web" || messageHovered || copyButtonHovered); const showCopyButton = hasText && (isCompact || messageHovered || copyButtonHovered);
return ( return (
<View <View
@@ -361,8 +362,8 @@ export const UserMessage = memo(function UserMessage({
> >
<Pressable <Pressable
style={userMessageStylesheet.content} style={userMessageStylesheet.content}
onHoverIn={Platform.OS === "web" ? () => setMessageHovered(true) : undefined} onHoverIn={() => setMessageHovered(true)}
onHoverOut={Platform.OS === "web" ? () => setMessageHovered(false) : undefined} onHoverOut={() => setMessageHovered(false)}
> >
<View style={userMessageStylesheet.bubble}> <View style={userMessageStylesheet.bubble}>
{hasImages ? ( {hasImages ? (
@@ -434,7 +435,7 @@ export const assistantMessageStylesheet = StyleSheet.create((theme) => ({
color: theme.colors.foreground, color: theme.colors.foreground,
fontFamily: Fonts.mono, fontFamily: Fonts.mono,
fontSize: 13, fontSize: 13,
userSelect: Platform.OS === "web" ? "text" : "auto", userSelect: isWeb ? "text" : "auto",
}, },
imageFrame: { imageFrame: {
width: "100%", width: "100%",
@@ -657,7 +658,7 @@ function MarkdownLink({
children: ReactNode; children: ReactNode;
}) { }) {
const [hovered, setHovered] = useState(false); const [hovered, setHovered] = useState(false);
if (Platform.OS !== "web") { if (isNative) {
return ( return (
<Text accessibilityRole="link" onPress={() => onPress(href)} style={style}> <Text accessibilityRole="link" onPress={() => onPress(href)} style={style}>
{children} {children}
@@ -779,8 +780,8 @@ export const TurnCopyButton = memo(function TurnCopyButton({
return ( return (
<Pressable <Pressable
onPress={handleCopy} onPress={handleCopy}
onHoverIn={Platform.OS === "web" ? () => onHoverChange?.(true) : undefined} onHoverIn={() => onHoverChange?.(true)}
onHoverOut={Platform.OS === "web" ? () => onHoverChange?.(false) : undefined} onHoverOut={() => onHoverChange?.(false)}
style={[turnCopyButtonStylesheet.container, containerStyle]} style={[turnCopyButtonStylesheet.container, containerStyle]}
accessibilityRole="button" accessibilityRole="button"
accessibilityLabel={ accessibilityLabel={
@@ -1060,7 +1061,7 @@ export const AssistantMessage = memo(function AssistantMessage({
<Text <Text
key={node.key} key={node.key}
onPress={() => parsed && onInlinePathPress?.(parsed)} onPress={() => parsed && onInlinePathPress?.(parsed)}
selectable={Platform.OS === "web" ? undefined : false} selectable={isWeb ? undefined : false}
style={[assistantMessageStylesheet.pathChip, assistantMessageStylesheet.pathChipText]} style={[assistantMessageStylesheet.pathChip, assistantMessageStylesheet.pathChipText]}
> >
{content} {content}
@@ -1653,9 +1654,9 @@ const ExpandableBadge = memo(function ExpandableBadge({
32, 32,
Math.min(120, labelRowWidth > 0 ? labelRowWidth * 0.28 : 0), Math.min(120, labelRowWidth > 0 ? labelRowWidth * 0.28 : 0),
); );
const isWebShimmer = isLoading && Platform.OS === "web"; const isWebShimmer = isLoading && isWeb;
const shouldMeasureWebShimmer = isWebShimmer; const shouldMeasureWebShimmer = isWebShimmer;
const shouldMeasureNativeShimmer = isLoading && Platform.OS !== "web"; const shouldMeasureNativeShimmer = isLoading && isNative;
const isNativeShimmer = shouldMeasureNativeShimmer && labelRowWidth > 0 && labelRowHeight > 0; const isNativeShimmer = shouldMeasureNativeShimmer && labelRowWidth > 0 && labelRowHeight > 0;
const webShimmerSpanStartX = labelOffsetX; const webShimmerSpanStartX = labelOffsetX;
const webShimmerSpanEndX = secondaryLabel const webShimmerSpanEndX = secondaryLabel
@@ -1732,7 +1733,7 @@ const ExpandableBadge = memo(function ExpandableBadge({
}, [isNativeShimmer, labelRowWidth, nativeShimmerPeakWidth, shimmerDuration, shimmerTranslateX]); }, [isNativeShimmer, labelRowWidth, nativeShimmerPeakWidth, shimmerDuration, shimmerTranslateX]);
useEffect(() => { useEffect(() => {
if (Platform.OS !== "web" || !isExpanded || !hasDetailContent) { if (isNative || !isExpanded || !hasDetailContent) {
return; return;
} }
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; 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 { Folder } from "lucide-react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
@@ -11,6 +11,7 @@ import { useHosts, useHostRuntimeClient, useHostRuntimeIsConnected } from "@/run
import { useOpenProject } from "@/hooks/use-open-project"; import { useOpenProject } from "@/hooks/use-open-project";
import { parseServerIdFromPathname } from "@/utils/host-routes"; import { parseServerIdFromPathname } from "@/utils/host-routes";
import { buildWorkingDirectorySuggestions } from "@/utils/working-directory-suggestions"; import { buildWorkingDirectorySuggestions } from "@/utils/working-directory-suggestions";
import { isNative } from "@/constants/platform";
export function ProjectPickerModal() { export function ProjectPickerModal() {
const { theme } = useUnistyles(); const { theme } = useUnistyles();
@@ -122,7 +123,7 @@ export function ProjectPickerModal() {
// Keyboard navigation // Keyboard navigation
useEffect(() => { useEffect(() => {
if (!open || Platform.OS !== "web") return; if (!open || isNative) return;
function handler(event: KeyboardEvent) { function handler(event: KeyboardEvent) {
const key = event.key; const key = event.key;
@@ -1,10 +1,11 @@
import { useState, useCallback } from "react"; 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 { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useIsCompactFormFactor } from "@/constants/layout"; import { useIsCompactFormFactor } from "@/constants/layout";
import { Check, CircleHelp, X } from "lucide-react-native"; import { Check, CircleHelp, X } from "lucide-react-native";
import type { PendingPermission } from "@/types/shared"; import type { PendingPermission } from "@/types/shared";
import type { AgentPermissionResponse } from "@server/server/agent/agent-sdk-types"; import type { AgentPermissionResponse } from "@server/server/agent/agent-sdk-types";
import { isWeb } from "@/constants/platform";
interface QuestionOption { interface QuestionOption {
label: string; label: string;
@@ -60,7 +61,7 @@ interface QuestionFormCardProps {
isResponding: boolean; isResponding: boolean;
} }
const IS_WEB = Platform.OS === "web"; const IS_WEB = isWeb;
export function QuestionFormCard({ permission, onRespond, isResponding }: QuestionFormCardProps) { export function QuestionFormCard({ permission, onRespond, isResponding }: QuestionFormCardProps) {
const { theme } = useUnistyles(); const { theme } = useUnistyles();
@@ -83,6 +83,7 @@ import { normalizeWorkspaceDescriptor, useSessionStore } from "@/stores/session-
import { createNameId } from "mnemonic-id"; import { createNameId } from "mnemonic-id";
import { buildWorkspaceArchiveRedirectRoute } from "@/utils/workspace-archive-navigation"; import { buildWorkspaceArchiveRedirectRoute } from "@/utils/workspace-archive-navigation";
import { openExternalUrl } from "@/utils/open-external-url"; 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 { function toProjectIconDataUri(icon: { mimeType: string; data: string } | null): string | null {
if (!icon) { if (!icon) {
@@ -199,8 +200,8 @@ function WorkspacePrBadge({ hint }: { hint: PrHint }) {
hitSlop={4} hitSlop={4}
onPressIn={handlePressIn} onPressIn={handlePressIn}
onPress={handlePress} onPress={handlePress}
onPointerEnter={() => setIsHovered(true)} onHoverIn={() => setIsHovered(true)}
onPointerLeave={() => setIsHovered(false)} onHoverOut={() => setIsHovered(false)}
style={({ pressed }) => [styles.workspacePrBadge, pressed && styles.workspacePrBadgePressed]} style={({ pressed }) => [styles.workspacePrBadge, pressed && styles.workspacePrBadgePressed]}
> >
<GitPullRequest size={12} color={iconColor} /> <GitPullRequest size={12} color={iconColor} />
@@ -543,7 +544,7 @@ function useLongPressDragInteraction(input: {
input.drag(); input.drag();
}, DRAG_ARM_DELAY_MS); }, DRAG_ARM_DELAY_MS);
if (!input.menuController || Platform.OS === "web") { if (!input.menuController || platformIsWeb) {
return; return;
} }
@@ -793,7 +794,7 @@ function ProjectHeaderRow({
<NewWorktreeButton <NewWorktreeButton
displayName={displayName} displayName={displayName}
onPress={() => createWorktreeMutation.mutate()} onPress={() => createWorktreeMutation.mutate()}
visible={isHovered || isMobileBreakpoint} visible={isHovered || platformIsNative || isMobileBreakpoint}
loading={createWorktreeMutation.isPending} loading={createWorktreeMutation.isPending}
showShortcutHint={isProjectActive} showShortcutHint={isProjectActive}
testID={`sidebar-project-new-worktree-${project.projectKey}`} testID={`sidebar-project-new-worktree-${project.projectKey}`}
@@ -801,8 +802,8 @@ function ProjectHeaderRow({
) : null} ) : null}
{onRemoveProject ? ( {onRemoveProject ? (
<View <View
style={!(isHovered || isMobileBreakpoint) && styles.projectKebabButtonHidden} style={!(isHovered || platformIsNative || isMobileBreakpoint) && styles.projectKebabButtonHidden}
pointerEvents={isHovered || isMobileBreakpoint ? "auto" : "none"} pointerEvents={isHovered || platformIsNative || isMobileBreakpoint ? "auto" : "none"}
> >
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger <DropdownMenuTrigger
@@ -847,31 +848,8 @@ function ProjectHeaderRow({
if (menuController) { if (menuController) {
return ( return (
<View onPointerEnter={() => setIsHovered(true)} onPointerLeave={() => setIsHovered(false)}> <ContextMenuTrigger
<ContextMenuTrigger enabledOnMobile={false}
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
style={({ pressed }) => [ style={({ pressed }) => [
styles.projectRow, styles.projectRow,
isDragging && styles.projectRowDragging, isDragging && styles.projectRowDragging,
@@ -879,6 +857,8 @@ function ProjectHeaderRow({
isHovered && styles.projectRowHovered, isHovered && styles.projectRowHovered,
pressed && styles.projectRowPressed, pressed && styles.projectRowPressed,
]} ]}
onHoverIn={() => setIsHovered(true)}
onHoverOut={() => setIsHovered(false)}
onPressIn={interaction.handlePressIn} onPressIn={interaction.handlePressIn}
onTouchMove={interaction.handleTouchMove} onTouchMove={interaction.handleTouchMove}
onPressOut={interaction.handlePressOut} onPressOut={interaction.handlePressOut}
@@ -886,8 +866,29 @@ function ProjectHeaderRow({
testID={`sidebar-project-row-${project.projectKey}`} testID={`sidebar-project-row-${project.projectKey}`}
> >
{rowChildren} {rowChildren}
</Pressable> </ContextMenuTrigger>
</View> );
}
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, archiveShortcutKeys,
}: WorkspaceRowInnerProps) { }: WorkspaceRowInnerProps) {
const { theme } = useUnistyles(); const { theme } = useUnistyles();
const isCompact = useIsCompactFormFactor();
const [isHovered, setIsHovered] = useState(false); const [isHovered, setIsHovered] = useState(false);
const isTouchPlatform = Platform.OS !== "web";
const prHint = useWorkspacePrHint({ const prHint = useWorkspacePrHint({
serverId: workspace.serverId, serverId: workspace.serverId,
cwd: workspace.workspaceId, cwd: workspace.workspaceId,
@@ -933,121 +934,118 @@ function WorkspaceRowInner({
}, [interaction.didLongPressRef, onPress]); }, [interaction.didLongPressRef, onPress]);
return ( return (
<View <Pressable
style={styles.workspaceRowContainer} disabled={isArchiving}
onPointerEnter={() => setIsHovered(true)} style={({ pressed }) => [
onPointerLeave={() => setIsHovered(false)} styles.workspaceRowContainer,
styles.workspaceRow,
isDragging && styles.workspaceRowDragging,
selected && styles.sidebarRowSelected,
isHovered && styles.workspaceRowHovered,
pressed && styles.workspaceRowPressed,
]}
onHoverIn={() => setIsHovered(true)}
onHoverOut={() => setIsHovered(false)}
onPressIn={interaction.handlePressIn}
onTouchMove={interaction.handleTouchMove}
onPressOut={interaction.handlePressOut}
onPress={handlePress}
testID={`sidebar-workspace-row-${workspace.workspaceKey}`}
> >
<Pressable <View style={styles.workspaceRowMain}>
disabled={isArchiving} <View
style={({ pressed }) => [ {...(dragHandleProps?.attributes as any)}
styles.workspaceRow, {...(dragHandleProps?.listeners as any)}
isDragging && styles.workspaceRowDragging, ref={dragHandleProps?.setActivatorNodeRef as any}
selected && styles.sidebarRowSelected, style={styles.workspaceRowLeft}
isHovered && styles.workspaceRowHovered, >
pressed && styles.workspaceRowPressed, <WorkspaceStatusIndicator
]} bucket={workspace.statusBucket}
onPressIn={interaction.handlePressIn} workspaceKind={workspace.workspaceKind}
onTouchMove={interaction.handleTouchMove} loading={isArchiving || isCreating}
onPressOut={interaction.handlePressOut} />
onPress={handlePress} <Text
testID={`sidebar-workspace-row-${workspace.workspaceKey}`} style={[
> styles.workspaceBranchText,
<View style={styles.workspaceRowMain}> isHovered && styles.workspaceBranchTextHovered,
<View isCreating && styles.workspaceBranchTextCreating,
{...(dragHandleProps?.attributes as any)} ]}
{...(dragHandleProps?.listeners as any)} numberOfLines={1}
ref={dragHandleProps?.setActivatorNodeRef as any}
style={styles.workspaceRowLeft}
> >
<WorkspaceStatusIndicator {workspace.name}
bucket={workspace.statusBucket} </Text>
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>
</View> </View>
{prHint ? ( <View style={styles.workspaceRowRight}>
<View style={styles.workspacePrBadgeRow}> {isCreating ? <Text style={styles.workspaceCreatingText}>Creating...</Text> : null}
<WorkspacePrBadge hint={prHint} /> {onArchive && (isHovered || platformIsNative || isCompact) ? (
</View> <DropdownMenu>
) : null} <DropdownMenuTrigger
</Pressable> hitSlop={8}
</View> 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, parentGestureRef,
}: SidebarWorkspaceListProps) { }: SidebarWorkspaceListProps) {
const isMobile = useIsCompactFormFactor(); const isMobile = useIsCompactFormFactor();
const isNative = Platform.OS !== "web";
const pathname = usePathname(); const pathname = usePathname();
const activeWorkspaceSelection = useNavigationActiveWorkspaceSelection(); const activeWorkspaceSelection = useNavigationActiveWorkspaceSelection();
const [creatingWorkspaceIds, setCreatingWorkspaceIds] = useState<Set<string>>(() => new Set()); const [creatingWorkspaceIds, setCreatingWorkspaceIds] = useState<Set<string>>(() => new Set());
@@ -1831,7 +1828,7 @@ export function SidebarWorkspaceList({
drag={drag} drag={drag}
isDragging={isActive} isDragging={isActive}
dragHandleProps={dragHandleProps} dragHandleProps={dragHandleProps}
useNestable={isNative} useNestable={platformIsNative}
creatingWorkspaceIds={creatingWorkspaceIds} creatingWorkspaceIds={creatingWorkspaceIds}
/> />
); );
@@ -1848,7 +1845,7 @@ export function SidebarWorkspaceList({
serverId, serverId,
shortcutIndexByWorkspaceKey, shortcutIndexByWorkspaceKey,
showShortcutBadges, showShortcutBadges,
isNative, platformIsNative,
creatingWorkspaceIds, creatingWorkspaceIds,
], ],
); );
@@ -1872,7 +1869,7 @@ export function SidebarWorkspaceList({
onDragEnd={handleProjectDragEnd} onDragEnd={handleProjectDragEnd}
scrollEnabled={false} scrollEnabled={false}
useDragHandle useDragHandle
nestable={isNative} nestable={platformIsNative}
simultaneousGestureRef={parentGestureRef} simultaneousGestureRef={parentGestureRef}
containerStyle={styles.projectListContainer} containerStyle={styles.projectListContainer}
/> />
@@ -1883,7 +1880,7 @@ export function SidebarWorkspaceList({
return ( return (
<View style={styles.container}> <View style={styles.container}>
{isNative ? ( {platformIsNative ? (
<NestableScrollContainer <NestableScrollContainer
style={styles.list} style={styles.list}
contentContainerStyle={styles.listContent} contentContainerStyle={styles.listContent}
@@ -27,7 +27,7 @@ import {
type DragStartEvent, type DragStartEvent,
} from "@dnd-kit/core"; } from "@dnd-kit/core";
import { arrayMove, sortableKeyboardCoordinates } from "@dnd-kit/sortable"; 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 { StyleSheet, useUnistyles } from "react-native-unistyles";
import { ResizeHandle } from "@/components/resize-handle"; import { ResizeHandle } from "@/components/resize-handle";
import { shouldFocusPaneFromEventTarget } from "@/components/split-container-pane-focus"; import { shouldFocusPaneFromEventTarget } from "@/components/split-container-pane-focus";
@@ -69,6 +69,7 @@ import {
} from "@/stores/workspace-layout-store"; } from "@/stores/workspace-layout-store";
import type { WorkspaceTab } from "@/stores/workspace-tabs-store"; import type { WorkspaceTab } from "@/stores/workspace-tabs-store";
import { workspaceTabTargetsEqual } from "@/utils/workspace-tab-identity"; import { workspaceTabTargetsEqual } from "@/utils/workspace-tab-identity";
import { isNative } from "@/constants/platform";
interface SplitContainerProps { interface SplitContainerProps {
layout: WorkspaceLayout; layout: WorkspaceLayout;
@@ -838,7 +839,7 @@ function SplitPaneView({
); );
useEffect(() => { useEffect(() => {
if (Platform.OS !== "web") { if (isNative) {
return; return;
} }
+4 -3
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 { useSafeAreaInsets } from "react-native-safe-area-context";
import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useIsCompactFormFactor } from "@/constants/layout"; import { useIsCompactFormFactor } from "@/constants/layout";
import { isWeb } from "@/constants/platform";
import { AlertTriangle, CheckCircle2 } from "lucide-react-native"; import { AlertTriangle, CheckCircle2 } from "lucide-react-native";
import { getOverlayRoot, OVERLAY_Z } from "@/lib/overlay-root"; import { getOverlayRoot, OVERLAY_Z } from "@/lib/overlay-root";
import { import {
@@ -234,8 +235,8 @@ export function ToastViewport({
<View style={styles.container} pointerEvents="box-none"> <View style={styles.container} pointerEvents="box-none">
<Animated.View <Animated.View
testID={toast.testID ?? "app-toast"} testID={toast.testID ?? "app-toast"}
onPointerEnter={pauseDismiss} onPointerEnter={isWeb ? pauseDismiss : undefined}
onPointerLeave={resumeDismiss} onPointerLeave={isWeb ? resumeDismiss : undefined}
style={[ style={[
styles.toast, styles.toast,
toast.variant === "success" ? styles.toastSuccess : null, toast.variant === "success" ? styles.toastSuccess : null,
@@ -265,7 +266,7 @@ export function ToastViewport({
</View> </View>
); );
if (placement === "app-shell" && Platform.OS === "web" && typeof document !== "undefined") { if (placement === "app-shell" && isWeb && typeof document !== "undefined") {
return createPortal(content, getOverlayRoot()); return createPortal(content, getOverlayRoot());
} }
@@ -1,5 +1,5 @@
import React, { useMemo, ReactNode } from "react"; 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 { ScrollView as GHScrollView } from "react-native-gesture-handler";
import { StyleSheet } from "react-native-unistyles"; import { StyleSheet } from "react-native-unistyles";
import { Fonts } from "@/constants/theme"; 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 { hasMeaningfulToolCallDetail } from "@/utils/tool-call-detail-state";
import { DiffViewer } from "./diff-viewer"; import { DiffViewer } from "./diff-viewer";
import { getCodeInsets } from "./code-insets"; import { getCodeInsets } from "./code-insets";
import { isWeb } from "@/constants/platform";
const ScrollView = Platform.OS === "web" ? RNScrollView : GHScrollView; const ScrollView = isWeb ? RNScrollView : GHScrollView;
// ---- Content Component ---- // ---- Content Component ----
@@ -511,7 +512,7 @@ const styles = StyleSheet.create((theme) => {
fontSize: theme.fontSize.xs, fontSize: theme.fontSize.xs,
color: theme.colors.foreground, color: theme.colors.foreground,
lineHeight: 18, lineHeight: 18,
...(Platform.OS === "web" ...(isWeb
? { ? {
whiteSpace: "pre", whiteSpace: "pre",
overflowWrap: "normal", overflowWrap: "normal",
+10 -7
View File
@@ -37,8 +37,9 @@ import {
shouldShowCustomComboboxOption, shouldShowCustomComboboxOption,
} from "./combobox-options"; } from "./combobox-options";
import type { ComboboxOptionModel } 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; export type ComboboxOption = ComboboxOptionModel;
@@ -107,6 +108,7 @@ export interface SearchInputProps {
onChangeText: (text: string) => void; onChangeText: (text: string) => void;
onSubmitEditing?: () => void; onSubmitEditing?: () => void;
autoFocus?: boolean; autoFocus?: boolean;
useBottomSheetInput?: boolean;
} }
export function SearchInput({ export function SearchInput({
@@ -115,10 +117,11 @@ export function SearchInput({
onChangeText, onChangeText,
onSubmitEditing, onSubmitEditing,
autoFocus = false, autoFocus = false,
useBottomSheetInput = false,
}: SearchInputProps): ReactElement { }: SearchInputProps): ReactElement {
const { theme } = useUnistyles(); const { theme } = useUnistyles();
const inputRef = useRef<TextInput>(null); const inputRef = useRef<TextInput>(null);
const InputComponent = Platform.OS === "web" ? TextInput : BottomSheetTextInput; const InputComponent = useBottomSheetInput ? BottomSheetTextInput : TextInput;
useEffect(() => { useEffect(() => {
if (autoFocus && IS_WEB && inputRef.current) { if (autoFocus && IS_WEB && inputRef.current) {
@@ -264,8 +267,7 @@ export function Combobox({
}: ComboboxProps): ReactElement { }: ComboboxProps): ReactElement {
const isMobile = useIsCompactFormFactor(); const isMobile = useIsCompactFormFactor();
const effectiveOptionsPosition = isMobile ? "below-search" : optionsPosition; const effectiveOptionsPosition = isMobile ? "below-search" : optionsPosition;
const isDesktopAboveSearch = const isDesktopAboveSearch = !isMobile && isWeb && effectiveOptionsPosition === "above-search";
!isMobile && Platform.OS === "web" && effectiveOptionsPosition === "above-search";
const { height: windowHeight } = useWindowDimensions(); const { height: windowHeight } = useWindowDimensions();
const bottomSheetRef = useRef<BottomSheetModal>(null); const bottomSheetRef = useRef<BottomSheetModal>(null);
const hasPresentedBottomSheetRef = useRef(false); const hasPresentedBottomSheetRef = useRef(false);
@@ -322,8 +324,8 @@ export function Combobox({
const middleware = useMemo( const middleware = useMemo(
() => [ () => [
floatingOffset(Platform.OS === "web" ? 0 : 4), floatingOffset(isWeb ? 0 : 4),
...(Platform.OS === "web" ? [] : [flip({ padding: collisionPadding })]), ...(isWeb ? [] : [flip({ padding: collisionPadding })]),
...(isDesktopAboveSearch ? [] : [shift({ padding: collisionPadding })]), ...(isDesktopAboveSearch ? [] : [shift({ padding: collisionPadding })]),
floatingSize({ floatingSize({
padding: collisionPadding, padding: collisionPadding,
@@ -346,7 +348,7 @@ export function Combobox({
); );
const { refs, floatingStyles, update } = useFloating({ const { refs, floatingStyles, update } = useFloating({
placement: Platform.OS === "web" ? desktopPlacement : "bottom-start", placement: isWeb ? desktopPlacement : "bottom-start",
middleware, middleware,
sameScrollView: false, sameScrollView: false,
elements: { elements: {
@@ -626,6 +628,7 @@ export function Combobox({
onChangeText={setSearchQueryWithCallback} onChangeText={setSearchQueryWithCallback}
onSubmitEditing={handleSubmitSearch} onSubmitEditing={handleSubmitSearch}
autoFocus={!isMobile} autoFocus={!isMobile}
useBottomSheetInput={isMobile}
/> />
); );
@@ -32,6 +32,7 @@ import { useIsCompactFormFactor } from "@/constants/layout";
import { Check, CheckCircle } from "lucide-react-native"; import { Check, CheckCircle } from "lucide-react-native";
import { BottomSheetBackdrop, BottomSheetModal, BottomSheetScrollView } from "@gorhom/bottom-sheet"; import { BottomSheetBackdrop, BottomSheetModal, BottomSheetScrollView } from "@gorhom/bottom-sheet";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { isWeb, isNative } from "@/constants/platform";
// Keep parity with dropdown-menu action statuses. // Keep parity with dropdown-menu action statuses.
export type ActionStatus = "idle" | "pending" | "success"; export type ActionStatus = "idle" | "pending" | "success";
@@ -254,8 +255,7 @@ export function ContextMenuTrigger({
>): ReactElement { >): ReactElement {
const ctx = useContextMenuContext("ContextMenuTrigger"); const ctx = useContextMenuContext("ContextMenuTrigger");
const shouldEnableOnThisPlatform = const shouldEnableOnThisPlatform = enabled && (isWeb ? enabledOnWeb : enabledOnMobile);
enabled && (Platform.OS === "web" ? enabledOnWeb : enabledOnMobile);
const openAtEvent = useCallback( const openAtEvent = useCallback(
(event: unknown) => { (event: unknown) => {
@@ -294,7 +294,7 @@ export function ContextMenuTrigger({
disabled={disabled} disabled={disabled}
delayLongPress={longPressDelayMs} delayLongPress={longPressDelayMs}
onLongPress={(event) => { onLongPress={(event) => {
if (Platform.OS === "web") { if (isWeb) {
props.onLongPress?.(event); props.onLongPress?.(event);
return; return;
} }
@@ -303,7 +303,7 @@ export function ContextMenuTrigger({
}} }}
// @ts-ignore - onContextMenu is web-only and not in RN types. // @ts-ignore - onContextMenu is web-only and not in RN types.
onContextMenu={(event: unknown) => { onContextMenu={(event: unknown) => {
if (Platform.OS !== "web") { if (isNative) {
return; return;
} }
const e: any = event; const e: any = event;
+8 -18
View File
@@ -28,6 +28,8 @@ import { Portal } from "@gorhom/portal";
import { useBottomSheetModalInternal } from "@gorhom/bottom-sheet"; import { useBottomSheetModalInternal } from "@gorhom/bottom-sheet";
import Animated, { FadeIn, FadeOut } from "react-native-reanimated"; import Animated, { FadeIn, FadeOut } from "react-native-reanimated";
import { StyleSheet } from "react-native-unistyles"; import { StyleSheet } from "react-native-unistyles";
import { useIsCompactFormFactor } from "@/constants/layout";
import { isWeb } from "@/constants/platform";
type Side = "top" | "bottom" | "left" | "right"; type Side = "top" | "bottom" | "left" | "right";
type Align = "start" | "center" | "end"; 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({ function computePosition({
triggerRect, triggerRect,
contentSize, contentSize,
@@ -227,8 +217,8 @@ export function Tooltip({
onOpenChange, onOpenChange,
}); });
const isMobile = isMobileTooltipEnvironment(); const isCompact = useIsCompactFormFactor();
const enabled = isMobile ? enabledOnMobile : enabledOnDesktop; const enabled = isCompact ? enabledOnMobile : enabledOnDesktop;
const value = useMemo<TooltipContextValue>( const value = useMemo<TooltipContextValue>(
() => ({ () => ({
@@ -236,10 +226,10 @@ export function Tooltip({
setOpen: setIsOpen, setOpen: setIsOpen,
triggerRef, triggerRef,
enabled, enabled,
openOnPress: isMobile, openOnPress: isCompact,
delayDuration, delayDuration,
}), }),
[isOpen, setIsOpen, enabled, isMobile, delayDuration], [isOpen, setIsOpen, enabled, isCompact, delayDuration],
); );
return <TooltipContext.Provider value={value}>{children}</TooltipContext.Provider>; return <TooltipContext.Provider value={value}>{children}</TooltipContext.Provider>;
@@ -354,7 +344,7 @@ export function TooltipTrigger({
onFocus: handleFocus, onFocus: handleFocus,
onBlur: handleBlur, onBlur: handleBlur,
onPress: handlePress, onPress: handlePress,
...(Platform.OS === "web" ...(isWeb
? ({ ? ({
// RN Web's hover handling can vary across environments; pointer events are the most reliable. // RN Web's hover handling can vary across environments; pointer events are the most reliable.
onPointerEnter: handleHoverIn, onPointerEnter: handleHoverIn,
@@ -473,7 +463,7 @@ export function TooltipContent({
// On web, avoid React Native's <Modal/> implementation (it uses <dialog> and can // On web, avoid React Native's <Modal/> implementation (it uses <dialog> and can
// steal focus / disrupt hover). Rendering via Portal + position:fixed keeps the // steal focus / disrupt hover). Rendering via Portal + position:fixed keeps the
// exact same positioning math as DropdownMenu, without hover feedback loops. // exact same positioning math as DropdownMenu, without hover feedback loops.
if (Platform.OS === "web") { if (isWeb) {
return ( return (
<Portal hostName={bottomSheetInternal?.hostName}> <Portal hostName={bottomSheetInternal?.hostName}>
<View pointerEvents="none" style={styles.portalOverlay}> <View pointerEvents="none" style={styles.portalOverlay}>
@@ -1,6 +1,5 @@
import { useCallback, useEffect, useState, type ReactNode, type RefObject } from "react"; import { useCallback, useEffect, useState, type ReactNode, type RefObject } from "react";
import { import {
Platform,
type FlatList, type FlatList,
type LayoutChangeEvent, type LayoutChangeEvent,
type NativeScrollEvent, type NativeScrollEvent,
@@ -12,6 +11,7 @@ import {
useWebDesktopScrollbarMetrics, useWebDesktopScrollbarMetrics,
type ScrollbarMetrics, type ScrollbarMetrics,
} from "./web-desktop-scrollbar"; } from "./web-desktop-scrollbar";
import { isWeb as platformIsWeb } from "@/constants/platform";
const METRICS_EPSILON = 0.5; const METRICS_EPSILON = 0.5;
const HIDE_SCROLLBAR_STYLE_ID = "paseo-hide-scrollbar"; const HIDE_SCROLLBAR_STYLE_ID = "paseo-hide-scrollbar";
@@ -45,8 +45,7 @@ export function useWebElementScrollbar(
contentRef?: RefObject<HTMLElement | null>; contentRef?: RefObject<HTMLElement | null>;
}, },
): ReactNode { ): ReactNode {
const isWeb = Platform.OS === "web"; const enabled = (options?.enabled ?? true) && platformIsWeb;
const enabled = (options?.enabled ?? true) && isWeb;
const contentRef = options?.contentRef; const contentRef = options?.contentRef;
const [metrics, setMetrics] = useState<ScrollbarMetrics>({ const [metrics, setMetrics] = useState<ScrollbarMetrics>({
@@ -125,8 +124,7 @@ export function useWebScrollViewScrollbar(
scrollableRef: RefObject<ScrollView | FlatList | null>, scrollableRef: RefObject<ScrollView | FlatList | null>,
options?: { enabled?: boolean }, options?: { enabled?: boolean },
): WebScrollViewScrollbar { ): WebScrollViewScrollbar {
const isWeb = Platform.OS === "web"; const enabled = (options?.enabled ?? true) && platformIsWeb;
const enabled = (options?.enabled ?? true) && isWeb;
const metricsHook = useWebDesktopScrollbarMetrics(); const metricsHook = useWebDesktopScrollbarMetrics();
const onScrollToOffset = useCallback( const onScrollToOffset = useCallback(
@@ -1,7 +1,6 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { import {
PanResponder, PanResponder,
Platform,
View, View,
type LayoutChangeEvent, type LayoutChangeEvent,
type NativeScrollEvent, type NativeScrollEvent,
@@ -12,6 +11,7 @@ import {
computeScrollOffsetFromDragDelta, computeScrollOffsetFromDragDelta,
computeVerticalScrollbarGeometry, computeVerticalScrollbarGeometry,
} from "./web-desktop-scrollbar.math"; } from "./web-desktop-scrollbar.math";
import { isWeb as platformIsWeb } from "@/constants/platform";
const METRICS_EPSILON = 0.5; const METRICS_EPSILON = 0.5;
const HANDLE_WIDTH_IDLE = 6; const HANDLE_WIDTH_IDLE = 6;
@@ -135,7 +135,6 @@ export function WebDesktopScrollbarOverlay({
maxScrollOffset: 0, maxScrollOffset: 0,
}); });
const onScrollToOffsetRef = useRef(onScrollToOffset); const onScrollToOffsetRef = useRef(onScrollToOffset);
const isWeb = Platform.OS === "web";
const maxScrollOffset = Math.max(0, metrics.contentSize - metrics.viewportSize); const maxScrollOffset = Math.max(0, metrics.contentSize - metrics.viewportSize);
const normalizedOffset = inverted const normalizedOffset = inverted
@@ -258,7 +257,7 @@ export function WebDesktopScrollbarOverlay({
); );
const panResponder = useMemo(() => { const panResponder = useMemo(() => {
if (isWeb) { if (platformIsWeb) {
return null; return null;
} }
@@ -280,11 +279,11 @@ export function WebDesktopScrollbarOverlay({
setIsDragging(false); setIsDragging(false);
}, },
}); });
}, [applyDragDelta, isWeb]); }, [applyDragDelta, platformIsWeb]);
const startWebDrag = useCallback( const startWebDrag = useCallback(
(event: any) => { (event: any) => {
if (!isWeb) { if (!platformIsWeb) {
return; return;
} }
const clientY = readClientY(event); const clientY = readClientY(event);
@@ -298,7 +297,7 @@ export function WebDesktopScrollbarOverlay({
dragStartClientYRef.current = clientY; dragStartClientYRef.current = clientY;
setIsDragging(true); setIsDragging(true);
}, },
[isWeb], [platformIsWeb],
); );
const handleGrabHoverIn = useCallback(() => { const handleGrabHoverIn = useCallback(() => {
@@ -313,7 +312,7 @@ export function WebDesktopScrollbarOverlay({
}, []); }, []);
useEffect(() => { useEffect(() => {
if (!isWeb || !isDragging) { if (!platformIsWeb || !isDragging) {
return; return;
} }
@@ -335,7 +334,7 @@ export function WebDesktopScrollbarOverlay({
window.removeEventListener("pointerup", stopDragging); window.removeEventListener("pointerup", stopDragging);
window.removeEventListener("pointercancel", stopDragging); window.removeEventListener("pointercancel", stopDragging);
}; };
}, [applyDragDelta, isDragging, isWeb]); }, [applyDragDelta, isDragging, platformIsWeb]);
if (!enabled || !geometry.isVisible) { if (!enabled || !geometry.isVisible) {
return null; return null;
@@ -371,7 +370,7 @@ export function WebDesktopScrollbarOverlay({
height: thumbRegionHeight, height: thumbRegionHeight,
transform: [{ translateY: thumbRegionOffset }], transform: [{ translateY: thumbRegionOffset }],
}, },
isWeb && platformIsWeb &&
({ ({
cursor: handleCursor, cursor: handleCursor,
touchAction: "none", touchAction: "none",
@@ -383,7 +382,7 @@ export function WebDesktopScrollbarOverlay({
]} ]}
pointerEvents={handleVisible ? "auto" : "none"} pointerEvents={handleVisible ? "auto" : "none"}
{...(panResponder?.panHandlers ?? {})} {...(panResponder?.panHandlers ?? {})}
{...(isWeb {...(platformIsWeb
? ({ ? ({
onPointerDown: startWebDrag, onPointerDown: startWebDrag,
onPointerEnter: handleGrabHoverIn, onPointerEnter: handleGrabHoverIn,
@@ -403,7 +402,7 @@ export function WebDesktopScrollbarOverlay({
backgroundColor: handleColor, backgroundColor: handleColor,
opacity: handleOpacity, opacity: handleOpacity,
}, },
isWeb && platformIsWeb &&
({ ({
transitionProperty: "opacity, width, background-color", transitionProperty: "opacity, width, background-color",
transitionDuration: `${HANDLE_FADE_DURATION_MS}ms, ${HANDLE_WIDTH_TRANSITION_DURATION_MS}ms, ${HANDLE_FADE_DURATION_MS}ms`, transitionDuration: `${HANDLE_FADE_DURATION_MS}ms, ${HANDLE_WIDTH_TRANSITION_DURATION_MS}ms, ${HANDLE_FADE_DURATION_MS}ms`,
+48 -48
View File
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useState, useSyncExternalStore } from "react"; 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 { useRouter } from "expo-router";
import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { QrCode, Link2, ClipboardPaste, ExternalLink } from "lucide-react-native"; 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 { buildHostRootRoute } from "@/utils/host-routes";
import { PaseoLogo } from "@/components/icons/paseo-logo"; import { PaseoLogo } from "@/components/icons/paseo-logo";
import { openExternalUrl } from "@/utils/open-external-url"; import { openExternalUrl } from "@/utils/open-external-url";
import { isWeb, isNative } from "@/constants/platform";
type WelcomeAction = { type WelcomeAction = {
key: "scan-qr" | "direct-connection" | "paste-pairing-link"; key: "scan-qr" | "direct-connection" | "paste-pairing-link";
@@ -255,52 +256,51 @@ export function WelcomeScreen({ onHostAdded }: WelcomeScreenProps) {
[router], [router],
); );
const actions: WelcomeAction[] = const actions: WelcomeAction[] = isWeb
Platform.OS === "web" ? [
? [ {
{ key: "direct-connection",
key: "direct-connection", label: "Direct connection",
label: "Direct connection", testID: "welcome-direct-connection",
testID: "welcome-direct-connection", primary: true,
primary: true, icon: Link2,
icon: Link2, onPress: () => setIsDirectOpen(true),
onPress: () => setIsDirectOpen(true), },
}, {
{ key: "paste-pairing-link",
key: "paste-pairing-link", label: "Paste pairing link",
label: "Paste pairing link", testID: "welcome-paste-pairing-link",
testID: "welcome-paste-pairing-link", primary: false,
primary: false, icon: ClipboardPaste,
icon: ClipboardPaste, onPress: () => setIsPasteLinkOpen(true),
onPress: () => setIsPasteLinkOpen(true), },
}, ]
] : [
: [ {
{ key: "scan-qr",
key: "scan-qr", label: "Scan QR code",
label: "Scan QR code", testID: "welcome-scan-qr",
testID: "welcome-scan-qr", primary: true,
primary: true, icon: QrCode,
icon: QrCode, onPress: () => router.push("/pair-scan?source=onboarding"),
onPress: () => router.push("/pair-scan?source=onboarding"), },
}, {
{ key: "direct-connection",
key: "direct-connection", label: "Direct connection",
label: "Direct connection", testID: "welcome-direct-connection",
testID: "welcome-direct-connection", primary: false,
primary: false, icon: Link2,
icon: Link2, onPress: () => setIsDirectOpen(true),
onPress: () => setIsDirectOpen(true), },
}, {
{ key: "paste-pairing-link",
key: "paste-pairing-link", label: "Paste pairing link",
label: "Paste pairing link", testID: "welcome-paste-pairing-link",
testID: "welcome-paste-pairing-link", primary: false,
primary: false, icon: ClipboardPaste,
icon: ClipboardPaste, onPress: () => setIsPasteLinkOpen(true),
onPress: () => setIsPasteLinkOpen(true), },
}, ];
];
const showHostList = hosts.length > 0 && !anyOnlineServerId; const showHostList = hosts.length > 0 && !anyOnlineServerId;
@@ -321,7 +321,7 @@ export function WelcomeScreen({ onHostAdded }: WelcomeScreenProps) {
{showHostList ? "Connecting to your hosts…" : "Connect to your host to start"} {showHostList ? "Connecting to your hosts…" : "Connect to your host to start"}
</Text> </Text>
{!showHostList && Platform.OS !== "web" && ( {!showHostList && isNative && (
<> <>
<Text style={styles.setupHint}> <Text style={styles.setupHint}>
You need the Paseo desktop app or server running on your computer first. You need the Paseo desktop app or server running on your computer first.
+6 -40
View File
@@ -1,6 +1,5 @@
import { Platform } from "react-native";
import { useUnistyles } from "react-native-unistyles"; import { useUnistyles } from "react-native-unistyles";
import { isElectronRuntime, isElectronRuntimeMac } from "@/desktop/host"; import { isWeb } from "@/constants/platform";
export const FOOTER_HEIGHT = 75; 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_WIDTH = 140;
export const DESKTOP_WINDOW_CONTROLS_HEIGHT = 48; export const DESKTOP_WINDOW_CONTROLS_HEIGHT = 48;
// Check if running in the Electron desktop runtime (any OS) export {
function isElectronDesktopRuntime(): boolean { getIsElectron as getIsElectronRuntime,
if (Platform.OS !== "web") return false; getIsElectronMac as getIsElectronRuntimeMac,
return isElectronRuntime(); } from "./platform";
}
// 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;
}
/** /**
* Reactive hook — re-renders the component when the breakpoint changes. * 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 // Keep that capability distinct from desktop-width layout so touch tablets
// can use the desktop shell without entering web-only code paths. // can use the desktop shell without entering web-only code paths.
export function supportsDesktopPaneSplits(): boolean { export function supportsDesktopPaneSplits(): boolean {
return Platform.OS === "web"; return isWeb;
} }
+49
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;
}
@@ -1,6 +1,6 @@
import { useRef, ReactNode, useCallback, useEffect, useMemo } from "react"; import { useRef, ReactNode, useCallback, useEffect, useMemo } from "react";
import { Buffer } from "buffer"; import { Buffer } from "buffer";
import { AppState, Platform } from "react-native"; import { AppState } from "react-native";
import { useQueryClient } from "@tanstack/react-query"; import { useQueryClient } from "@tanstack/react-query";
import { useClientActivity } from "@/hooks/use-client-activity"; import { useClientActivity } from "@/hooks/use-client-activity";
import { usePushTokenRegistration } from "@/hooks/use-push-token-registration"; 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 { buildDraftStoreKey } from "@/stores/draft-keys";
import type { AttachmentMetadata } from "@/attachments/types"; import type { AttachmentMetadata } from "@/attachments/types";
import { reconcilePreviousAgentStatuses } from "@/contexts/session-status-tracking"; import { reconcilePreviousAgentStatuses } from "@/contexts/session-status-tracking";
import { isNative } from "@/constants/platform";
// Re-export types from session-store and draft-store for backward compatibility // Re-export types from session-store and draft-store for backward compatibility
export type { DraftInput } from "@/stores/draft-store"; export type { DraftInput } from "@/stores/draft-store";
@@ -547,7 +548,7 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
(awayMs: number) => { (awayMs: number) => {
scheduleAuthoritativeRevalidation(); scheduleAuthoritativeRevalidation();
if (Platform.OS !== "web") { if (isNative) {
const session = useSessionStore.getState().sessions[serverId]; const session = useSessionStore.getState().sessions[serverId];
const agentId = session?.focusedAgentId; const agentId = session?.focusedAgentId;
const cursor = agentId ? session?.agentTimelineCursor.get(agentId) : undefined; const cursor = agentId ? session?.agentTimelineCursor.get(agentId) : undefined;
@@ -1,5 +1,5 @@
import { Platform } from "react-native";
import { getDesktopHost } from "@/desktop/host"; import { getDesktopHost } from "@/desktop/host";
import { isWeb, isNative } from "@/constants/platform";
export type DesktopPermissionKind = "notifications" | "microphone"; export type DesktopPermissionKind = "notifications" | "microphone";
@@ -45,7 +45,7 @@ type NavigatorLike = {
}; };
export function shouldShowDesktopPermissionSection(): boolean { export function shouldShowDesktopPermissionSection(): boolean {
return Platform.OS === "web" && getDesktopHost() !== null; return isWeb && getDesktopHost() !== null;
} }
function status(input: DesktopPermissionStatus): DesktopPermissionStatus { function status(input: DesktopPermissionStatus): DesktopPermissionStatus {
@@ -83,7 +83,7 @@ function isPermissionsQueryRuntimeUnsupported(error: unknown): boolean {
} }
function getWebNotificationConstructor(): NotificationConstructorLike | null { function getWebNotificationConstructor(): NotificationConstructorLike | null {
if (Platform.OS !== "web") { if (isNative) {
return null; return null;
} }
const NotificationConstructor = (globalThis as { Notification?: unknown }).Notification; const NotificationConstructor = (globalThis as { Notification?: unknown }).Notification;
@@ -97,7 +97,7 @@ function getWebNotificationConstructor(): NotificationConstructorLike | null {
} }
function getNavigatorLike(): NavigatorLike | null { function getNavigatorLike(): NavigatorLike | null {
if (Platform.OS !== "web") { if (isNative) {
return null; return null;
} }
const webNavigator = (globalThis as { navigator?: unknown }).navigator; const webNavigator = (globalThis as { navigator?: unknown }).navigator;
@@ -133,7 +133,7 @@ function mapNotificationPermissionString(permission: string): DesktopPermissionS
} }
async function getNotificationPermissionStatus(): Promise<DesktopPermissionStatus> { async function getNotificationPermissionStatus(): Promise<DesktopPermissionStatus> {
if (Platform.OS !== "web") { if (isNative) {
return status({ return status({
state: "unavailable", state: "unavailable",
detail: "Desktop notification status is only available on web runtime.", detail: "Desktop notification status is only available on web runtime.",
@@ -167,7 +167,7 @@ async function getNotificationPermissionStatus(): Promise<DesktopPermissionStatu
} }
async function getMicrophonePermissionStatus(): Promise<DesktopPermissionStatus> { async function getMicrophonePermissionStatus(): Promise<DesktopPermissionStatus> {
if (Platform.OS !== "web") { if (isNative) {
return status({ return status({
state: "unavailable", state: "unavailable",
detail: "Desktop microphone status is only available on web runtime.", detail: "Desktop microphone status is only available on web runtime.",
@@ -237,7 +237,7 @@ async function getMicrophonePermissionStatus(): Promise<DesktopPermissionStatus>
} }
async function requestNotificationPermissionStatus(): Promise<DesktopPermissionStatus> { async function requestNotificationPermissionStatus(): Promise<DesktopPermissionStatus> {
if (Platform.OS !== "web") { if (isNative) {
return status({ return status({
state: "unavailable", state: "unavailable",
detail: "Desktop notification requests are only available on web runtime.", detail: "Desktop notification requests are only available on web runtime.",
@@ -264,7 +264,7 @@ async function requestNotificationPermissionStatus(): Promise<DesktopPermissionS
} }
async function requestMicrophonePermissionStatus(): Promise<DesktopPermissionStatus> { async function requestMicrophonePermissionStatus(): Promise<DesktopPermissionStatus> {
if (Platform.OS !== "web") { if (isNative) {
return status({ return status({
state: "unavailable", state: "unavailable",
detail: "Desktop microphone requests are only available on web runtime.", detail: "Desktop microphone requests are only available on web runtime.",
@@ -1,6 +1,6 @@
import { Platform } from "react-native";
import { isElectronRuntime } from "@/desktop/host"; import { isElectronRuntime } from "@/desktop/host";
import { invokeDesktopCommand } from "@/desktop/electron/invoke"; import { invokeDesktopCommand } from "@/desktop/electron/invoke";
import { isWeb } from "@/constants/platform";
export interface DesktopAppUpdateCheckResult { export interface DesktopAppUpdateCheckResult {
hasUpdate: boolean; hasUpdate: boolean;
@@ -50,7 +50,7 @@ function toNumberOr(defaultValue: number, value: unknown): number {
} }
export function shouldShowDesktopUpdateSection(): boolean { export function shouldShowDesktopUpdateSection(): boolean {
return Platform.OS === "web" && isElectronRuntime(); return isWeb && isElectronRuntime();
} }
export function parseLocalDaemonVersionResult(raw: unknown): LocalDaemonVersionResult { export function parseLocalDaemonVersionResult(raw: unknown): LocalDaemonVersionResult {
@@ -1,11 +1,12 @@
import { useCallback, useEffect, useRef, useState } from "react"; 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 type { DaemonClient } from "@server/client/daemon-client";
import { import {
shouldClearAgentAttention, shouldClearAgentAttention,
type AgentAttentionClearTrigger, type AgentAttentionClearTrigger,
} from "@/utils/agent-attention"; } from "@/utils/agent-attention";
import { getIsAppActivelyVisible } from "@/utils/app-visibility"; import { getIsAppActivelyVisible } from "@/utils/app-visibility";
import { isWeb } from "@/constants/platform";
type AttentionReason = "finished" | "error" | "permission" | null | undefined; type AttentionReason = "finished" | "error" | "permission" | null | undefined;
@@ -70,7 +71,7 @@ export function useAgentAttentionClear({
const appStateSubscription = AppState.addEventListener("change", updateVisibility); const appStateSubscription = AppState.addEventListener("change", updateVisibility);
if (Platform.OS === "web" && typeof document !== "undefined") { if (isWeb && typeof document !== "undefined") {
document.addEventListener("visibilitychange", updateVisibility); document.addEventListener("visibilitychange", updateVisibility);
window.addEventListener("focus", updateVisibility); window.addEventListener("focus", updateVisibility);
window.addEventListener("blur", updateVisibility); window.addEventListener("blur", updateVisibility);
@@ -1,5 +1,4 @@
import { useCallback } from "react"; import { useCallback } from "react";
import { Platform } from "react-native";
import { useSessionStore } from "@/stores/session-store"; import { useSessionStore } from "@/stores/session-store";
import type { DaemonClient } from "@server/client/daemon-client"; import type { DaemonClient } from "@server/client/daemon-client";
import { import {
@@ -10,13 +9,14 @@ import {
rejectInitDeferred, rejectInitDeferred,
} from "@/utils/agent-initialization"; } from "@/utils/agent-initialization";
import { deriveInitialTimelineRequest } from "@/contexts/session-timeline-bootstrap-policy"; import { deriveInitialTimelineRequest } from "@/contexts/session-timeline-bootstrap-policy";
import { isWeb } from "@/constants/platform";
const INIT_TIMEOUT_MS = 5 * 60_000; const INIT_TIMEOUT_MS = 5 * 60_000;
const NATIVE_INITIAL_TIMELINE_LIMIT = 200; const NATIVE_INITIAL_TIMELINE_LIMIT = 200;
const UNBOUNDED_TIMELINE_LIMIT = 0; const UNBOUNDED_TIMELINE_LIMIT = 0;
function resolveInitialTimelineLimit(): number { 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__ = { export const __private__ = {
+4 -3
View File
@@ -1,6 +1,7 @@
import { useEffect, useSyncExternalStore } from "react"; import { useEffect, useSyncExternalStore } from "react";
import { AppState, Platform } from "react-native"; import { AppState } from "react-native";
import { getIsAppActivelyVisible } from "@/utils/app-visibility"; import { getIsAppActivelyVisible } from "@/utils/app-visibility";
import { isWeb } from "@/constants/platform";
let current = getIsAppActivelyVisible(); let current = getIsAppActivelyVisible();
const listeners = new Set<() => void>(); const listeners = new Set<() => void>();
@@ -29,7 +30,7 @@ export function useAppVisible(): boolean {
useEffect(() => { useEffect(() => {
const appStateSubscription = AppState.addEventListener("change", notify); const appStateSubscription = AppState.addEventListener("change", notify);
if (Platform.OS === "web" && typeof document !== "undefined") { if (isWeb && typeof document !== "undefined") {
document.addEventListener("visibilitychange", notify); document.addEventListener("visibilitychange", notify);
window.addEventListener("focus", notify); window.addEventListener("focus", notify);
window.addEventListener("blur", notify); window.addEventListener("blur", notify);
@@ -37,7 +38,7 @@ export function useAppVisible(): boolean {
return () => { return () => {
appStateSubscription.remove(); appStateSubscription.remove();
if (Platform.OS === "web" && typeof document !== "undefined") { if (isWeb && typeof document !== "undefined") {
document.removeEventListener("visibilitychange", notify); document.removeEventListener("visibilitychange", notify);
window.removeEventListener("focus", notify); window.removeEventListener("focus", notify);
window.removeEventListener("blur", notify); window.removeEventListener("blur", notify);
@@ -1,6 +1,7 @@
import { useEffect, useRef, useCallback } from "react"; 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 type { DaemonClient } from "@server/client/daemon-client";
import { isWeb, isNative } from "@/constants/platform";
const HEARTBEAT_INTERVAL_MS = 15_000; const HEARTBEAT_INTERVAL_MS = 15_000;
const ACTIVITY_HEARTBEAT_THROTTLE_MS = 5_000; const ACTIVITY_HEARTBEAT_THROTTLE_MS = 5_000;
@@ -32,7 +33,7 @@ export function useClientActivity({
const prevFocusedAgentIdRef = useRef<string | null>(focusedAgentId); const prevFocusedAgentIdRef = useRef<string | null>(focusedAgentId);
const lastImmediateHeartbeatAtRef = useRef<number>(0); const lastImmediateHeartbeatAtRef = useRef<number>(0);
const deviceType = Platform.OS === "web" ? "web" : "mobile"; const deviceType = isWeb ? "web" : "mobile";
const recordUserActivity = useCallback(() => { const recordUserActivity = useCallback(() => {
lastActivityAtRef.current = new Date(); lastActivityAtRef.current = new Date();
@@ -96,7 +97,7 @@ export function useClientActivity({
// Track user activity on web for accurate staleness. // Track user activity on web for accurate staleness.
useEffect(() => { useEffect(() => {
if (Platform.OS !== "web") return; if (isNative) return;
if (typeof document === "undefined") return; if (typeof document === "undefined") return;
const handleUserActivity = () => { const handleUserActivity = () => {
+5 -9
View File
@@ -1,5 +1,4 @@
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { Platform } from "react-native";
import { useShallow } from "zustand/shallow"; import { useShallow } from "zustand/shallow";
import { getIsElectronRuntimeMac } from "@/constants/layout"; import { getIsElectronRuntimeMac } from "@/constants/layout";
import { useAggregatedAgents } from "./use-aggregated-agents"; import { useAggregatedAgents } from "./use-aggregated-agents";
@@ -9,6 +8,7 @@ import {
deriveMacDockBadgeCountFromWorkspaceStatuses, deriveMacDockBadgeCountFromWorkspaceStatuses,
type DesktopBadgeWorkspaceStatus, type DesktopBadgeWorkspaceStatus,
} from "@/utils/desktop-badge-state"; } from "@/utils/desktop-badge-state";
import { isNative } from "@/constants/platform";
type FaviconStatus = "none" | "running" | "attention"; type FaviconStatus = "none" | "running" | "attention";
type ColorScheme = "dark" | "light"; type ColorScheme = "dark" | "light";
@@ -76,18 +76,14 @@ function updateFavicon(status: FaviconStatus, colorScheme: ColorScheme) {
} }
function getSystemColorScheme(): ColorScheme { function getSystemColorScheme(): ColorScheme {
if ( if (isNative || typeof window === "undefined" || typeof window.matchMedia !== "function") {
Platform.OS !== "web" ||
typeof window === "undefined" ||
typeof window.matchMedia !== "function"
) {
return "dark"; return "dark";
} }
return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light"; return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
} }
async function updateMacDockBadge(count?: number) { async function updateMacDockBadge(count?: number) {
if (Platform.OS !== "web" || !getIsElectronRuntimeMac()) return; if (isNative || !getIsElectronRuntimeMac()) return;
const desktopWindow = getDesktopHost()?.window?.getCurrentWindow?.(); const desktopWindow = getDesktopHost()?.window?.getCurrentWindow?.();
if (!desktopWindow || typeof desktopWindow.setBadgeCount !== "function") { if (!desktopWindow || typeof desktopWindow.setBadgeCount !== "function") {
@@ -119,7 +115,7 @@ export function useFaviconStatus() {
// Listen for system color scheme changes // Listen for system color scheme changes
useEffect(() => { useEffect(() => {
if (Platform.OS !== "web" || typeof window === "undefined") return; if (isNative || typeof window === "undefined") return;
const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)"); const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
const handler = (e: MediaQueryListEvent) => { const handler = (e: MediaQueryListEvent) => {
@@ -132,7 +128,7 @@ export function useFaviconStatus() {
// Update favicon when agents or color scheme changes // Update favicon when agents or color scheme changes
useEffect(() => { useEffect(() => {
if (Platform.OS !== "web") return; if (isNative) return;
const status = deriveFaviconStatus(agents); const status = deriveFaviconStatus(agents);
updateFavicon(status, colorScheme); updateFavicon(status, colorScheme);
+2 -2
View File
@@ -1,8 +1,8 @@
import { useState, useRef, useEffect } from "react"; import { useState, useRef, useEffect } from "react";
import { Platform } from "react-native";
import type { ImageAttachment } from "@/components/message-input"; import type { ImageAttachment } from "@/components/message-input";
import { getDesktopHost } from "@/desktop/host"; import { getDesktopHost } from "@/desktop/host";
import { persistAttachmentFromBlob, persistAttachmentFromFileUri } from "@/attachments/service"; import { persistAttachmentFromBlob, persistAttachmentFromFileUri } from "@/attachments/service";
import { isWeb } from "@/constants/platform";
interface UseFileDropZoneOptions { interface UseFileDropZoneOptions {
onFilesDropped: (files: ImageAttachment[]) => void; onFilesDropped: (files: ImageAttachment[]) => void;
@@ -14,7 +14,7 @@ interface UseFileDropZoneReturn {
containerRef: React.RefObject<HTMLElement | null>; containerRef: React.RefObject<HTMLElement | null>;
} }
const IS_WEB = Platform.OS === "web"; const IS_WEB = isWeb;
const IMAGE_MIME_BY_EXTENSION: Record<string, string> = { const IMAGE_MIME_BY_EXTENSION: Record<string, string> = {
".png": "image/png", ".png": "image/png",
".jpg": "image/jpeg", ".jpg": "image/jpeg",
@@ -8,6 +8,7 @@ import {
openImagePathsWithDesktopDialog, openImagePathsWithDesktopDialog,
type PickedImageAttachmentInput, type PickedImageAttachmentInput,
} from "@/hooks/image-attachment-picker"; } from "@/hooks/image-attachment-picker";
import { isWeb } from "@/constants/platform";
interface UseImageAttachmentPickerResult { interface UseImageAttachmentPickerResult {
pickImages: () => Promise<PickedImageAttachmentInput[] | null>; pickImages: () => Promise<PickedImageAttachmentInput[] | null>;
@@ -45,7 +46,7 @@ export function useImageAttachmentPicker(): UseImageAttachmentPickerResult {
isPickingRef.current = true; isPickingRef.current = true;
try { try {
if (Platform.OS === "web" && isElectronRuntime()) { if (isWeb && isElectronRuntime()) {
const selectedPaths = await openImagePathsWithDesktopDialog(); const selectedPaths = await openImagePathsWithDesktopDialog();
if (selectedPaths.length === 0) { if (selectedPaths.length === 0) {
return null; return null;
@@ -1,5 +1,4 @@
import { useEffect, useMemo, useRef } from "react"; import { useEffect, useMemo, useRef } from "react";
import { Platform } from "react-native";
import { usePathname } from "expo-router"; import { usePathname } from "expo-router";
import { getIsElectronRuntime } from "@/constants/layout"; import { getIsElectronRuntime } from "@/constants/layout";
import { useHosts } from "@/runtime/host-runtime"; import { useHosts } from "@/runtime/host-runtime";
@@ -26,6 +25,7 @@ import { resolveKeyboardFocusScope } from "@/keyboard/focus-scope";
import { getShortcutOs } from "@/utils/shortcut-platform"; import { getShortcutOs } from "@/utils/shortcut-platform";
import { useOpenProjectPicker } from "@/hooks/use-open-project-picker"; import { useOpenProjectPicker } from "@/hooks/use-open-project-picker";
import { useKeyboardShortcutOverrides } from "@/hooks/use-keyboard-shortcut-overrides"; import { useKeyboardShortcutOverrides } from "@/hooks/use-keyboard-shortcut-overrides";
import { isNative } from "@/constants/platform";
export function useKeyboardShortcuts({ export function useKeyboardShortcuts({
enabled, enabled,
@@ -65,7 +65,7 @@ export function useKeyboardShortcuts({
useEffect(() => { useEffect(() => {
if (!enabled) return; if (!enabled) return;
if (Platform.OS !== "web") return; if (isNative) return;
if (isMobile) return; if (isMobile) return;
const isDesktopApp = getIsElectronRuntime(); const isDesktopApp = getIsElectronRuntime();
@@ -4,6 +4,7 @@ import AsyncStorage from "@react-native-async-storage/async-storage";
import * as Notifications from "expo-notifications"; import * as Notifications from "expo-notifications";
import Constants from "expo-constants"; import Constants from "expo-constants";
import type { DaemonClient } from "@server/client/daemon-client"; import type { DaemonClient } from "@server/client/daemon-client";
import { isWeb } from "@/constants/platform";
const STORAGE_PREFIX = "@paseo:expo-push-token:"; 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 lastSentTokenRef = useRef<string | null>(null);
const registerIfPossible = useCallback(async () => { const registerIfPossible = useCallback(async () => {
if (Platform.OS === "web") return; if (isWeb) return;
if (!client.isConnected) return; if (!client.isConnected) return;
const token = tokenRef.current; const token = tokenRef.current;
if (!token) return; if (!token) return;
@@ -41,7 +42,7 @@ export function usePushTokenRegistration(params: { client: DaemonClient; serverI
}, [client]); }, [client]);
useEffect(() => { useEffect(() => {
if (Platform.OS === "web") return; if (isWeb) return;
const storageKey = `${STORAGE_PREFIX}${serverId}`; const storageKey = `${STORAGE_PREFIX}${serverId}`;
let cancelled = false; let cancelled = false;
+3 -2
View File
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; 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 ReanimatedAnimated from "react-native-reanimated";
import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useShallow } from "zustand/shallow"; import { useShallow } from "zustand/shallow";
@@ -47,6 +47,7 @@ import {
deriveRouteBottomAnchorIntent, deriveRouteBottomAnchorIntent,
deriveRouteBottomAnchorRequest, deriveRouteBottomAnchorRequest,
} from "@/screens/agent/agent-ready-screen-bottom-anchor"; } from "@/screens/agent/agent-ready-screen-bottom-anchor";
import { isNative } from "@/constants/platform";
function formatProviderLabel(provider: Agent["provider"]): string { function formatProviderLabel(provider: Agent["provider"]): string {
if (!provider) { if (!provider) {
@@ -595,7 +596,7 @@ function AgentPanelBody({
if (!isConnected || !hasSession) { if (!isConnected || !hasSession) {
return; return;
} }
const shouldSyncOnEntry = needsAuthoritativeSync || Platform.OS !== "web"; const shouldSyncOnEntry = needsAuthoritativeSync || isNative;
if (!shouldSyncOnEntry) { if (!shouldSyncOnEntry) {
return; return;
} }
@@ -1,7 +1,7 @@
import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react"; import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
import { createNameId } from "mnemonic-id"; import { createNameId } from "mnemonic-id";
import type { ImageAttachment } from "@/components/message-input"; 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 { useLocalSearchParams, useRouter } from "expo-router";
import { useIsFocused } from "@react-navigation/native"; import { useIsFocused } from "@react-navigation/native";
import { StyleSheet, useUnistyles } from "react-native-unistyles"; 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 { useAgentInputDraft } from "@/hooks/use-agent-input-draft";
import { useDraftAgentCreateFlow } from "@/hooks/use-draft-agent-create-flow"; import { useDraftAgentCreateFlow } from "@/hooks/use-draft-agent-create-flow";
import { useDraftAgentFeatures } from "@/hooks/use-draft-agent-features"; import { useDraftAgentFeatures } from "@/hooks/use-draft-agent-features";
import { isWeb } from "@/constants/platform";
const EMPTY_PENDING_PERMISSIONS = new Map(); const EMPTY_PENDING_PERMISSIONS = new Map();
const DRAFT_CAPABILITIES: AgentCapabilityFlags = { const DRAFT_CAPABILITIES: AgentCapabilityFlags = {
@@ -850,7 +851,7 @@ function DraftAgentScreenContent({
}, },
onBeforeSubmit: () => { onBeforeSubmit: () => {
void persistFormPreferences(); void persistFormPreferences();
if (Platform.OS === "web") { if (isWeb) {
(document.activeElement as HTMLElement | null)?.blur?.(); (document.activeElement as HTMLElement | null)?.blur?.();
} }
Keyboard.dismiss(); Keyboard.dismiss();
+6 -7
View File
@@ -1,6 +1,6 @@
import { useState, useEffect, useRef, useCallback } from "react"; import { useState, useEffect, useRef, useCallback } from "react";
import type { MutableRefObject, ComponentType } 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 { router, useLocalSearchParams } from "expo-router";
import { useFocusEffect } from "@react-navigation/native"; import { useFocusEffect } from "@react-navigation/native";
import { useSafeAreaInsets } from "react-native-safe-area-context"; 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 { getProviderIcon } from "@/components/provider-icons";
import { ProviderDiagnosticSheet } from "@/components/provider-diagnostic-sheet"; import { ProviderDiagnosticSheet } from "@/components/provider-diagnostic-sheet";
import { StatusBadge } from "@/components/ui/status-badge"; import { StatusBadge } from "@/components/ui/status-badge";
import { isWeb } from "@/constants/platform";
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Section definitions // Section definitions
@@ -1672,22 +1673,20 @@ function DaemonCard({ daemon, onOpenSettings }: DaemonCardProps) {
<View style={styles.hostHeaderRight}> <View style={styles.hostHeaderRight}>
<View <View
style={[ style={[
Platform.OS === "web" ? styles.statusPill : styles.statusPillMobile, isWeb ? styles.statusPill : styles.statusPillMobile,
{ backgroundColor: statusPillBg }, { backgroundColor: statusPillBg },
]} ]}
> >
<View style={[styles.statusDot, { backgroundColor: statusColor }]} /> <View style={[styles.statusDot, { backgroundColor: statusColor }]} />
{Platform.OS === "web" ? ( {isWeb ? (
<Text style={[styles.statusText, { color: statusColor }]}>{badgeText}</Text> <Text style={[styles.statusText, { color: statusColor }]}>{badgeText}</Text>
) : null} ) : null}
</View> </View>
{connectionBadge ? ( {connectionBadge ? (
<View <View style={isWeb ? styles.connectionPill : styles.connectionPillMobile}>
style={Platform.OS === "web" ? styles.connectionPill : styles.connectionPillMobile}
>
{connectionBadge.icon} {connectionBadge.icon}
{Platform.OS === "web" ? ( {isWeb ? (
<Text style={styles.connectionText} numberOfLines={1}> <Text style={styles.connectionText} numberOfLines={1}>
{connectionBadge.text} {connectionBadge.text}
</Text> </Text>
@@ -1,5 +1,5 @@
import { useState, useEffect } from "react"; 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 { useIsFocused } from "@react-navigation/native";
import { StyleSheet } from "react-native-unistyles"; import { StyleSheet } from "react-native-unistyles";
import { settingsStyles } from "@/styles/settings"; import { settingsStyles } from "@/styles/settings";
@@ -20,6 +20,7 @@ import {
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store"; import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
import { getShortcutOs } from "@/utils/shortcut-platform"; import { getShortcutOs } from "@/utils/shortcut-platform";
import { getIsElectronRuntime } from "@/constants/layout"; import { getIsElectronRuntime } from "@/constants/layout";
import { isNative } from "@/constants/platform";
function ShortcutSequence({ function ShortcutSequence({
chord, chord,
@@ -138,7 +139,7 @@ export function KeyboardShortcutsSection() {
} }
useEffect(() => { useEffect(() => {
if (Platform.OS !== "web") return; if (isNative) return;
if (capturingBindingId === null) return; if (capturingBindingId === null) return;
function handleKeyDown(event: KeyboardEvent) { function handleKeyDown(event: KeyboardEvent) {
@@ -173,7 +174,7 @@ export function KeyboardShortcutsSection() {
}; };
}, [setCapturingShortcut]); }, [setCapturingShortcut]);
if (Platform.OS !== "web") { if (isNative) {
return ( return (
<View style={settingsStyles.section}> <View style={settingsStyles.section}>
<Text style={settingsStyles.sectionTitle}>Shortcuts</Text> <Text style={settingsStyles.sectionTitle}>Shortcuts</Text>
@@ -1,5 +1,5 @@
import { useEffect, useMemo, useState } from "react"; 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 * as Clipboard from "expo-clipboard";
import { openExternalUrl } from "@/utils/open-external-url"; import { openExternalUrl } from "@/utils/open-external-url";
import { BookOpen, Check, Copy, RotateCw, TriangleAlert } from "lucide-react-native"; 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 { Fonts } from "@/constants/theme";
import { getDesktopDaemonLogs, type DesktopDaemonLogs } from "@/desktop/daemon/desktop-daemon"; import { getDesktopDaemonLogs, type DesktopDaemonLogs } from "@/desktop/daemon/desktop-daemon";
import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region"; import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region";
import { isWeb } from "@/constants/platform";
type StartupSplashScreenProps = { type StartupSplashScreenProps = {
bootstrapState?: { bootstrapState?: {
@@ -42,7 +43,7 @@ const styles = StyleSheet.create((theme) => ({
}, },
errorScrollView: { errorScrollView: {
flex: 1, flex: 1,
...(Platform.OS === "web" ...(isWeb
? { ? {
overflowX: "auto", overflowX: "auto",
overflowY: "auto", overflowY: "auto",
@@ -144,7 +145,7 @@ const styles = StyleSheet.create((theme) => ({
fontSize: theme.fontSize.xs, fontSize: theme.fontSize.xs,
color: theme.colors.foreground, color: theme.colors.foreground,
lineHeight: 18, lineHeight: 18,
...(Platform.OS === "web" ...(isWeb
? { ? {
whiteSpace: "pre", whiteSpace: "pre",
overflowWrap: "normal", overflowWrap: "normal",
@@ -9,7 +9,6 @@ import {
} from "react"; } from "react";
import { import {
ActivityIndicator, ActivityIndicator,
Platform,
Pressable, Pressable,
ScrollView, ScrollView,
Text, Text,
@@ -30,6 +29,7 @@ import {
} from "lucide-react-native"; } from "lucide-react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { SortableInlineList } from "@/components/sortable-inline-list"; import { SortableInlineList } from "@/components/sortable-inline-list";
import { isNative, isWeb } from "@/constants/platform";
import { import {
ContextMenu, ContextMenu,
ContextMenuContent, ContextMenuContent,
@@ -113,6 +113,7 @@ function useMiddleClickClose(onClose: () => void) {
const ref = useRef<View>(null); const ref = useRef<View>(null);
useEffect(() => { useEffect(() => {
if (isNative) return;
const node = ref.current as unknown as HTMLElement | null; const node = ref.current as unknown as HTMLElement | null;
if (!node) return; if (!node) return;
@@ -174,17 +175,16 @@ function TabChip({
); );
const [hovered, setHovered] = useState(false); const [hovered, setHovered] = useState(false);
const isHighlighted = isActive || hovered || isCloseHovered; const isHighlighted = isActive || hovered || isCloseHovered;
const closeButtonDragBlockers = const closeButtonDragBlockers = isWeb
Platform.OS === "web" ? ({
? ({ onPointerDown: (event: { stopPropagation?: () => void }) => {
onPointerDown: (event: { stopPropagation?: () => void }) => { event.stopPropagation?.();
event.stopPropagation?.(); },
}, onMouseDown: (event: { stopPropagation?: () => void }) => {
onMouseDown: (event: { stopPropagation?: () => void }) => { event.stopPropagation?.();
event.stopPropagation?.(); },
}, } as const)
} as const) : undefined;
: undefined;
return ( return (
<View ref={middleClickRef}> <View ref={middleClickRef}>
@@ -199,7 +199,7 @@ function TabChip({
enabledOnMobile={false} enabledOnMobile={false}
style={({ hovered, pressed }) => [ style={({ hovered, pressed }) => [
styles.tab, styles.tab,
Platform.OS === "web" && isDragging && ({ cursor: "grabbing" } as const), isWeb && isDragging && ({ cursor: "grabbing" } as const),
{ {
minWidth: resolvedTabWidth, minWidth: resolvedTabWidth,
width: resolvedTabWidth, width: resolvedTabWidth,
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useMemo, useRef } from "react"; 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 { StyleSheet } from "react-native-unistyles";
import { AgentInputArea } from "@/components/agent-input-area"; import { AgentInputArea } from "@/components/agent-input-area";
import { FileDropZone } from "@/components/file-drop-zone"; import { FileDropZone } from "@/components/file-drop-zone";
@@ -19,6 +19,7 @@ import type {
AgentSessionConfig, AgentSessionConfig,
} from "@server/server/agent/agent-sdk-types"; } from "@server/server/agent/agent-sdk-types";
import type { AgentSnapshotPayload } from "@server/shared/messages"; import type { AgentSnapshotPayload } from "@server/shared/messages";
import { isWeb } from "@/constants/platform";
const EMPTY_PENDING_PERMISSIONS = new Map(); const EMPTY_PENDING_PERMISSIONS = new Map();
const DRAFT_CAPABILITIES: AgentCapabilityFlags = { const DRAFT_CAPABILITIES: AgentCapabilityFlags = {
@@ -155,7 +156,7 @@ export function WorkspaceDraftAgentTab({
}, },
onBeforeSubmit: () => { onBeforeSubmit: () => {
void persistFormPreferences(); void persistFormPreferences();
if (Platform.OS === "web") { if (isWeb) {
(document.activeElement as HTMLElement | null)?.blur?.(); (document.activeElement as HTMLElement | null)?.blur?.();
} }
Keyboard.dismiss(); Keyboard.dismiss();
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useMemo } from "react"; 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 { useMutation, useQuery } from "@tanstack/react-query";
import { Check, ChevronDown } from "lucide-react-native"; import { Check, ChevronDown } from "lucide-react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles"; 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 { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
import { resolvePreferredEditorId, usePreferredEditor } from "@/hooks/use-preferred-editor"; import { resolvePreferredEditorId, usePreferredEditor } from "@/hooks/use-preferred-editor";
import { isAbsolutePath } from "@/utils/path"; import { isAbsolutePath } from "@/utils/path";
import { isWeb } from "@/constants/platform";
interface WorkspaceOpenInEditorButtonProps { interface WorkspaceOpenInEditorButtonProps {
serverId: string; serverId: string;
@@ -29,10 +30,7 @@ export function WorkspaceOpenInEditorButton({ serverId, cwd }: WorkspaceOpenInEd
const { preferredEditorId, updatePreferredEditor } = usePreferredEditor(); const { preferredEditorId, updatePreferredEditor } = usePreferredEditor();
const shouldLoadEditors = const shouldLoadEditors =
Platform.OS === "web" && isWeb && Boolean(client && isConnected) && cwd.trim().length > 0 && isAbsolutePath(cwd);
Boolean(client && isConnected) &&
cwd.trim().length > 0 &&
isAbsolutePath(cwd);
const availableEditorsQuery = useQuery<EditorTargetDescriptorPayload[]>({ const availableEditorsQuery = useQuery<EditorTargetDescriptorPayload[]>({
queryKey: ["available-editors", serverId], queryKey: ["available-editors", serverId],
@@ -1,15 +1,7 @@
import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useStoreWithEqualityFn } from "zustand/traditional"; import { useStoreWithEqualityFn } from "zustand/traditional";
import { useIsFocused } from "@react-navigation/native"; import { useIsFocused } from "@react-navigation/native";
import { import { ActivityIndicator, BackHandler, Keyboard, Pressable, Text, View } from "react-native";
ActivityIndicator,
BackHandler,
Keyboard,
Platform,
Pressable,
Text,
View,
} from "react-native";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import * as Clipboard from "expo-clipboard"; import * as Clipboard from "expo-clipboard";
import { import {
@@ -118,6 +110,7 @@ import {
} from "@/screens/workspace/workspace-bulk-close"; } from "@/screens/workspace/workspace-bulk-close";
import { findAdjacentPane } from "@/utils/split-navigation"; import { findAdjacentPane } from "@/utils/split-navigation";
import { useIsCompactFormFactor, supportsDesktopPaneSplits } from "@/constants/layout"; import { useIsCompactFormFactor, supportsDesktopPaneSplits } from "@/constants/layout";
import { isWeb, isNative } from "@/constants/platform";
const TERMINALS_QUERY_STALE_TIME = 5_000; const TERMINALS_QUERY_STALE_TIME = 5_000;
const NEW_TAB_AGENT_OPTION_ID = "__new_tab_agent__"; const NEW_TAB_AGENT_OPTION_ID = "__new_tab_agent__";
@@ -252,7 +245,7 @@ function WorkspaceDocumentTitleEffect({
titleState: "ready" | "loading"; titleState: "ready" | "loading";
}) { }) {
useEffect(() => { useEffect(() => {
if (Platform.OS !== "web" || typeof document === "undefined") { if (isNative || typeof document === "undefined") {
return; return;
} }
const resolvedLabel = label.trim(); const resolvedLabel = label.trim();
@@ -810,7 +803,7 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
}); });
useEffect(() => { useEffect(() => {
if (Platform.OS === "web" || !isExplorerOpen) { if (isWeb || !isExplorerOpen) {
return; return;
} }
@@ -1709,7 +1702,7 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
const canRenderDesktopPaneSplits = supportsDesktopPaneSplits(); const canRenderDesktopPaneSplits = supportsDesktopPaneSplits();
const shouldRenderDesktopPaneFallback = !isMobile && !canRenderDesktopPaneSplits; const shouldRenderDesktopPaneFallback = !isMobile && !canRenderDesktopPaneSplits;
useEffect(() => { useEffect(() => {
if (Platform.OS !== "web" || typeof document === "undefined" || activeTabDescriptor) { if (isNative || typeof document === "undefined" || activeTabDescriptor) {
return; return;
} }
document.title = "Workspace"; document.title = "Workspace";
@@ -1933,7 +1926,7 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
return ( return (
<View style={[styles.container, { backgroundColor: mainBackgroundColor }]}> <View style={[styles.container, { backgroundColor: mainBackgroundColor }]}>
{Platform.OS === "web" && activeTabDescriptor ? ( {isWeb && activeTabDescriptor ? (
<WorkspaceTabPresentationResolver <WorkspaceTabPresentationResolver
tab={activeTabDescriptor} tab={activeTabDescriptor}
serverId={normalizedServerId} serverId={normalizedServerId}
+4 -4
View File
@@ -1,11 +1,11 @@
import { create } from "zustand"; import { create } from "zustand";
import { Platform } from "react-native";
import { File as FSFile, Paths } from "expo-file-system"; import { File as FSFile, Paths } from "expo-file-system";
import * as LegacyFileSystem from "expo-file-system/legacy"; import * as LegacyFileSystem from "expo-file-system/legacy";
import * as Sharing from "expo-sharing"; import * as Sharing from "expo-sharing";
import type { HostProfile } from "@/types/host-connection"; import type { HostProfile } from "@/types/host-connection";
import { buildDaemonWebSocketUrl } from "@/utils/daemon-endpoints"; import { buildDaemonWebSocketUrl } from "@/utils/daemon-endpoints";
import { openExternalUrl } from "@/utils/open-external-url"; import { openExternalUrl } from "@/utils/open-external-url";
import { isWeb } from "@/constants/platform";
interface DownloadProgress { interface DownloadProgress {
percent: number; percent: number;
@@ -97,10 +97,10 @@ export const useDownloadStore = create<DownloadState>()((set, get) => ({
const downloadUrl = buildDownloadUrl( const downloadUrl = buildDownloadUrl(
downloadTarget.baseUrl, downloadTarget.baseUrl,
tokenResponse.token, tokenResponse.token,
Platform.OS === "web" ? downloadTarget.authCredentials : null, isWeb ? downloadTarget.authCredentials : null,
); );
if (Platform.OS === "web") { if (isWeb) {
triggerBrowserDownload(downloadUrl, resolvedFileName); triggerBrowserDownload(downloadUrl, resolvedFileName);
get().completeDownload(id); get().completeDownload(id);
return; return;
@@ -153,7 +153,7 @@ export const useDownloadStore = create<DownloadState>()((set, get) => ({
} }
} catch (error) { } catch (error) {
const message = error instanceof Error ? error.message : "Failed to download file."; const message = error instanceof Error ? error.message : "Failed to download file.";
if (Platform.OS === "web") { if (isWeb) {
console.warn("[DownloadStore] Download failed:", message); console.warn("[DownloadStore] Download failed:", message);
get().failDownload(id, message); get().failDownload(id, message);
return; return;
+4 -8
View File
@@ -1,7 +1,6 @@
import { create } from "zustand"; import { create } from "zustand";
import { persist, createJSONStorage } from "zustand/middleware"; import { persist, createJSONStorage } from "zustand/middleware";
import AsyncStorage from "@react-native-async-storage/async-storage"; import AsyncStorage from "@react-native-async-storage/async-storage";
import { Platform } from "react-native";
import { import {
buildExplorerCheckoutKey, buildExplorerCheckoutKey,
coerceExplorerTabForCheckout, coerceExplorerTabForCheckout,
@@ -9,6 +8,7 @@ import {
resolveExplorerTabForCheckout, resolveExplorerTabForCheckout,
type ExplorerTab, type ExplorerTab,
} from "./explorer-tab-memory"; } from "./explorer-tab-memory";
import { isWeb } from "@/constants/platform";
export type { ExplorerTab } from "./explorer-tab-memory"; 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>()( export const usePanelStore = create<PanelState>()(
persist( persist(
@@ -318,11 +318,7 @@ export const usePanelStore = create<PanelState>()(
const state = persistedState as Partial<PanelState> & Record<string, unknown>; const state = persistedState as Partial<PanelState> & Record<string, unknown>;
if (version < 2) { if (version < 2) {
if ( if (isWeb && typeof state.explorerWidth === "number" && state.explorerWidth === 400) {
Platform.OS === "web" &&
typeof state.explorerWidth === "number" &&
state.explorerWidth === 400
) {
state.explorerWidth = DEFAULT_EXPLORER_SIDEBAR_WIDTH; state.explorerWidth = DEFAULT_EXPLORER_SIDEBAR_WIDTH;
} }
@@ -337,7 +333,7 @@ export const usePanelStore = create<PanelState>()(
if (version < 3) { if (version < 3) {
if ( if (
Platform.OS === "web" && isWeb &&
typeof state.explorerWidth === "number" && typeof state.explorerWidth === "number" &&
(state.explorerWidth === 400 || state.explorerWidth === 520) (state.explorerWidth === 400 || state.explorerWidth === 520)
) { ) {
+2 -2
View File
@@ -1,8 +1,8 @@
import { Platform } from "react-native";
import type { Theme } from "./theme"; import type { Theme } from "./theme";
import { Fonts } from "@/constants/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. * Creates comprehensive markdown styles for react-native-markdown-display.
+3 -2
View File
@@ -1,11 +1,12 @@
import { AppState, Platform } from "react-native"; import { AppState } from "react-native";
import { isNative } from "@/constants/platform";
export function getIsAppActivelyVisible(appState: string = AppState.currentState): boolean { export function getIsAppActivelyVisible(appState: string = AppState.currentState): boolean {
if (appState !== "active") { if (appState !== "active") {
return false; return false;
} }
if (Platform.OS !== "web") { if (isNative) {
return true; return true;
} }
+5 -4
View File
@@ -1,5 +1,6 @@
import { Alert, Platform } from "react-native"; import { Alert } from "react-native";
import { getDesktopHost, type DesktopDialogAskOptions } from "@/desktop/host"; import { getDesktopHost, type DesktopDialogAskOptions } from "@/desktop/host";
import { isNative } from "@/constants/platform";
export interface ConfirmDialogInput { export interface ConfirmDialogInput {
title: string; title: string;
@@ -49,7 +50,7 @@ async function showNativeConfirmDialog(input: ConfirmDialogInput): Promise<boole
} }
function getDesktopApi() { function getDesktopApi() {
if (Platform.OS !== "web") { if (isNative) {
return null; return null;
} }
return getDesktopHost(); return getDesktopHost();
@@ -67,7 +68,7 @@ function buildDesktopAskOptions(input: ConfirmDialogInput): DesktopDialogAskOpti
} }
function blurActiveWebElement(): void { function blurActiveWebElement(): void {
if (Platform.OS !== "web") { if (isNative) {
return; return;
} }
const activeElement = (globalThis as { document?: Document }).document?.activeElement; 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> { export async function confirmDialog(input: ConfirmDialogInput): Promise<boolean> {
if (Platform.OS !== "web") { if (isNative) {
return showNativeConfirmDialog(input); return showNativeConfirmDialog(input);
} }
+2 -2
View File
@@ -1,5 +1,4 @@
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { Platform } from "react-native";
import { import {
getIsElectronRuntimeMac, getIsElectronRuntimeMac,
getIsElectronRuntime, getIsElectronRuntime,
@@ -10,6 +9,7 @@ import {
} from "@/constants/layout"; } from "@/constants/layout";
import { getDesktopWindow } from "@/desktop/electron/window"; import { getDesktopWindow } from "@/desktop/electron/window";
import { usePanelStore } from "@/stores/panel-store"; import { usePanelStore } from "@/stores/panel-store";
import { isNative } from "@/constants/platform";
type RawWindowControlsPadding = { type RawWindowControlsPadding = {
left: number; left: number;
@@ -23,7 +23,7 @@ function useRawWindowControlsPadding(): RawWindowControlsPadding {
const [isFullscreen, setIsFullscreen] = useState(false); const [isFullscreen, setIsFullscreen] = useState(false);
useEffect(() => { useEffect(() => {
if (Platform.OS !== "web" || !getIsElectronRuntime()) return; if (isNative || !getIsElectronRuntime()) return;
let disposed = false; let disposed = false;
let cleanup: (() => void) | undefined; let cleanup: (() => void) | undefined;
+2 -2
View File
@@ -1,9 +1,9 @@
import * as Linking from "expo-linking"; import * as Linking from "expo-linking";
import { Platform } from "react-native";
import { getDesktopHost } from "@/desktop/host"; import { getDesktopHost } from "@/desktop/host";
import { isWeb } from "@/constants/platform";
export async function openExternalUrl(url: string): Promise<void> { export async function openExternalUrl(url: string): Promise<void> {
if (Platform.OS === "web") { if (isWeb) {
const opener = getDesktopHost()?.opener?.openUrl; const opener = getDesktopHost()?.opener?.openUrl;
if (typeof opener === "function") { if (typeof opener === "function") {
await opener(url); await opener(url);
+3 -3
View File
@@ -1,7 +1,7 @@
import { Asset } from "expo-asset"; import { Asset } from "expo-asset";
import { Platform } from "react-native";
import { getDesktopHost } from "@/desktop/host"; import { getDesktopHost } from "@/desktop/host";
import { buildNotificationRoute, resolveNotificationTarget } from "./notification-routing"; import { buildNotificationRoute, resolveNotificationTarget } from "./notification-routing";
import { isNative } from "@/constants/platform";
type OsNotificationPayload = { type OsNotificationPayload = {
title: string; title: string;
@@ -80,7 +80,7 @@ async function ensureNotificationPermission(): Promise<boolean> {
} }
export async function ensureOsNotificationPermission(): Promise<boolean> { export async function ensureOsNotificationPermission(): Promise<boolean> {
if (Platform.OS !== "web") { if (isNative) {
return false; return false;
} }
return await ensureNotificationPermission(); return await ensureNotificationPermission();
@@ -154,7 +154,7 @@ function attachWebClickHandler(
export async function sendOsNotification(payload: OsNotificationPayload): Promise<boolean> { export async function sendOsNotification(payload: OsNotificationPayload): Promise<boolean> {
// Mobile/native notifications should be remote push only. // Mobile/native notifications should be remote push only.
if (Platform.OS !== "web") { if (isNative) {
return false; return false;
} }
@@ -1,4 +1,4 @@
import { Platform } from "react-native"; import { isWeb } from "@/constants/platform";
type ListenerStats = { type ListenerStats = {
adds: number; adds: number;
@@ -92,7 +92,7 @@ const SOURCE_LABEL = "[ScrollJankInvestigation]";
function shouldInstall(): boolean { function shouldInstall(): boolean {
const runtime = globalThis as ScrollInvestigationGlobal; const runtime = globalThis as ScrollInvestigationGlobal;
const isDev = Boolean((globalThis as { __DEV__?: boolean }).__DEV__); 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 { function normalizeCapture(options?: AddEventListenerOptions | boolean): boolean {
+2 -1
View File
@@ -1,9 +1,10 @@
import { Platform } from "react-native"; import { Platform } from "react-native";
import { getIsElectronRuntimeMac } from "@/constants/layout"; import { getIsElectronRuntimeMac } from "@/constants/layout";
import type { ShortcutOs } from "@/utils/format-shortcut"; import type { ShortcutOs } from "@/utils/format-shortcut";
import { isNative } from "@/constants/platform";
export function getShortcutOs(): ShortcutOs { export function getShortcutOs(): ShortcutOs {
if (Platform.OS !== "web") { if (isNative) {
return Platform.OS === "ios" ? "mac" : "non-mac"; return Platform.OS === "ios" ? "mac" : "non-mac";
} }
if (getIsElectronRuntimeMac()) return "mac"; if (getIsElectronRuntimeMac()) return "mac";
+2 -2
View File
@@ -1,6 +1,6 @@
import { Asset } from "expo-asset"; import { Asset } from "expo-asset";
import { File } from "expo-file-system"; 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 { parsePcm16Wav, type Pcm16Wav } from "@/utils/pcm16-wav";
export const THINKING_TONE_REPEAT_GAP_MS = 350; 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 toneModule = require("../../assets/audio/thinking-tone.wav");
const asset = Asset.fromModule(toneModule); const asset = Asset.fromModule(toneModule);
if (Platform.OS === "web") { if (isWeb) {
const response = await fetch(asset.uri); const response = await fetch(asset.uri);
if (!response.ok) { if (!response.ok) {
throw new Error(`Failed to fetch thinking tone asset: ${response.status}`); throw new Error(`Failed to fetch thinking tone asset: ${response.status}`);