Merge origin/main into dev (second round)

Resolve 21 conflicts from latest main (provider profiles, Windows fixes, 0.1.55-rc.1).
Fix all type errors and syntax issues from merge artifacts.
This commit is contained in:
Mohamed Boudra
2026-04-14 04:07:36 +07:00
249 changed files with 9118 additions and 4973 deletions
@@ -1,7 +1,7 @@
import { forwardRef, useCallback, useEffect, useMemo, useRef } from "react";
import type { ReactNode } from "react";
import { createPortal } from "react-dom";
import { Modal, Platform, Pressable, ScrollView, Text, TextInput, View } from "react-native";
import { Modal, Pressable, ScrollView, Text, TextInput, View } from "react-native";
import type { TextInputProps } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useIsCompactFormFactor } from "@/constants/layout";
@@ -16,6 +16,7 @@ import {
import { X } from "lucide-react-native";
import { FileDropZone } from "@/components/file-drop-zone";
import type { ImageAttachment } from "@/components/message-input";
import { isWeb } from "@/constants/platform";
const styles = StyleSheet.create((theme) => ({
desktopOverlay: {
@@ -143,7 +144,7 @@ export function AdaptiveModalSheet({
const resolvedSnapPoints = useMemo(() => snapPoints ?? ["65%", "90%"], [snapPoints]);
useEffect(() => {
if (isMobile || !visible || Platform.OS !== "web" || typeof window === "undefined") return;
if (isMobile || !visible || !isWeb || typeof window === "undefined") return;
function handleKeyDown(e: KeyboardEvent) {
if (e.key === "Escape") {
e.preventDefault();
@@ -253,16 +254,16 @@ export function AdaptiveModalSheet({
/>
<View style={[styles.desktopCard, desktopMaxWidth != null && { maxWidth: desktopMaxWidth }]}>
{onFilesDropped ? (
<FileDropZone onFilesDropped={onFilesDropped}>
{cardInner}
</FileDropZone>
) : cardInner}
<FileDropZone onFilesDropped={onFilesDropped}>{cardInner}</FileDropZone>
) : (
cardInner
)}
</View>
</View>
);
// On web, use portal to overlay root for consistent stacking with toasts
if (Platform.OS === "web" && typeof document !== "undefined") {
if (isWeb && typeof document !== "undefined") {
if (!visible) return null;
return createPortal(desktopContent, getOverlayRoot());
}
@@ -1,8 +1,9 @@
import { useCallback } from "react";
import { Pressable, Text, View, Platform } from "react-native";
import { Pressable, Text, View } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { QrCode, Link2, ClipboardPaste } from "lucide-react-native";
import { AdaptiveModalSheet } from "./adaptive-modal-sheet";
import { isNative } from "@/constants/platform";
const styles = StyleSheet.create((theme) => ({
option: {
@@ -78,7 +79,7 @@ export function AddHostMethodModal({
</View>
</Pressable>
{Platform.OS !== "web" ? (
{isNative ? (
<Pressable style={styles.option} onPress={handleScan} accessibilityLabel="Scan QR code">
<QrCode size={18} color={theme.colors.foreground} />
<View style={styles.optionBody}>
@@ -1,6 +1,6 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { ReactElement, ReactNode } from "react";
import { View, Text, Pressable, TextInput, ActivityIndicator, Platform } from "react-native";
import { View, Text, Pressable, TextInput, ActivityIndicator } from "react-native";
import type { StyleProp, ViewStyle, TextProps } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import {
@@ -32,6 +32,7 @@ import type { AgentProviderDefinition } from "@server/server/agent/provider-mani
import { getModeVisuals, type AgentModeIcon } from "@server/server/agent/provider-manifest";
import { Combobox, ComboboxItem, ComboboxEmpty } from "@/components/ui/combobox";
import { baseColors } from "@/styles/theme";
import { isNative } from "@/constants/platform";
const MODE_ICON_MAP: Record<AgentModeIcon, typeof ShieldCheck> = {
ShieldCheck,
@@ -168,7 +169,7 @@ export function SelectField({
const handleKeyDown = useCallback(
(event: unknown) => {
if (Platform.OS !== "web") return;
if (isNative) return;
const key = getWebKey(event);
if (key === "Enter" || key === " ") {
preventWebDefault(event);
@@ -430,7 +431,7 @@ export function FormSelectTrigger({
const handleKeyDown = useCallback(
(event: unknown) => {
if (Platform.OS !== "web") return;
if (isNative) return;
const key = getWebKey(event);
if (key === "Enter" || key === " ") {
preventWebDefault(event);
@@ -556,7 +557,11 @@ export function AgentConfigRow({
const effectiveSelectedThinkingOption =
selectedThinkingOptionId || thinkingSelectOptions[0]?.id || "";
const selectedModeVisuals = getModeVisuals(selectedProvider, effectiveSelectedMode);
const selectedModeVisuals = getModeVisuals(
selectedProvider,
effectiveSelectedMode,
providerDefinitions,
);
const ModeIcon = MODE_ICON_MAP[selectedModeVisuals?.icon ?? "ShieldCheck"];
const modeIconColor = MODE_COLOR_MAP[selectedModeVisuals?.colorTier ?? "safe"];
+15 -17
View File
@@ -262,23 +262,20 @@ export function AgentList({
[isActionSheetVisible, onAgentSelect],
);
const handleAgentLongPress = useCallback(
(agent: AggregatedAgent) => {
const isRunning = agent.status === "running" || agent.status === "initializing";
if (isRunning) {
setActionAgent(agent);
return;
}
const handleAgentLongPress = useCallback((agent: AggregatedAgent) => {
const isRunning = agent.status === "running" || agent.status === "initializing";
if (isRunning) {
setActionAgent(agent);
return;
}
const client = useSessionStore.getState().sessions[agent.serverId]?.client ?? null;
if (!client) {
setActionAgent(agent);
return;
}
void client.archiveAgent(agent.id);
},
[],
);
const client = useSessionStore.getState().sessions[agent.serverId]?.client ?? null;
if (!client) {
setActionAgent(agent);
return;
}
void client.archiveAgent(agent.id);
}, []);
const handleCloseActionSheet = useCallback(() => {
setActionAgent(null);
@@ -288,7 +285,8 @@ export function AgentList({
if (!actionAgent || !actionClient) {
return;
}
void actionClient.archiveAgent(actionAgent.id);
// Timeout errors are swallowed — the daemon will still process the archive
void actionClient.archiveAgent(actionAgent.id).catch(() => {});
setActionAgent(null);
}, [actionAgent, actionClient]);
@@ -1,5 +1,5 @@
import { memo, useCallback, useMemo, useRef, useState } from "react";
import { View, Text, Platform, Pressable, Keyboard } from "react-native";
import { View, Text, Pressable, Keyboard } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useShallow } from "zustand/shallow";
import { useStoreWithEqualityFn } from "zustand/traditional";
@@ -17,6 +17,7 @@ import { getProviderIcon } from "@/components/provider-icons";
import { CombinedModelSelector } from "@/components/combined-model-selector";
import { useSessionStore } from "@/stores/session-store";
import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot";
import { resolveProviderDefinition } from "@/utils/provider-definitions";
import {
buildFavoriteModelKey,
mergeProviderPreferences,
@@ -40,7 +41,6 @@ import type {
} from "@server/server/agent/agent-sdk-types";
import type { AgentProviderDefinition } from "@server/server/agent/provider-manifest";
import {
AGENT_PROVIDER_DEFINITIONS,
getModeVisuals,
type AgentModeColorTier,
type AgentModeIcon,
@@ -51,6 +51,7 @@ import {
getStatusSelectorHint,
resolveAgentModelSelection,
} from "@/components/agent-status-bar.utils";
import { isWeb as platformIsWeb } from "@/constants/platform";
type StatusOption = {
id: string;
@@ -59,10 +60,6 @@ type StatusOption = {
type StatusSelector = "provider" | "mode" | "model" | "thinking" | `feature-${string}`;
const PROVIDER_DEFINITION_MAP = new Map(
AGENT_PROVIDER_DEFINITIONS.map((definition) => [definition.id, definition]),
);
type ControlledAgentStatusBarProps = {
provider: string;
providerOptions?: StatusOption[];
@@ -80,7 +77,7 @@ type ControlledAgentStatusBarProps = {
onSelectThinkingOption?: (thinkingOptionId: string) => void;
disabled?: boolean;
isModelLoading?: boolean;
providerDefinitions?: AgentProviderDefinition[];
providerDefinitions: AgentProviderDefinition[];
allProviderModels?: Map<string, AgentModelDefinition[]>;
canSelectModelProvider?: (providerId: string) => boolean;
favoriteKeys?: Set<string>;
@@ -222,7 +219,6 @@ function ControlledStatusBar({
onModelSelectorOpen,
}: ControlledAgentStatusBarProps) {
const { theme } = useUnistyles();
const isWeb = Platform.OS === "web";
const [prefsOpen, setPrefsOpen] = useState(false);
const [openSelector, setOpenSelector] = useState<StatusSelector | null>(null);
@@ -252,7 +248,9 @@ function ControlledStatusBar({
thinkingOptions?.[0]?.label ?? "Unknown",
);
const modeVisuals = selectedModeId ? getModeVisuals(provider, selectedModeId) : undefined;
const modeVisuals = selectedModeId
? getModeVisuals(provider, selectedModeId, providerDefinitions)
: undefined;
const ModeIconComponent = modeVisuals?.icon ? MODE_ICONS[modeVisuals.icon] : null;
const modeIconColor = getModeIconColor(modeVisuals?.colorTier, theme.colors.palette);
const ProviderIcon = getProviderIcon(provider);
@@ -300,9 +298,7 @@ function ControlledStatusBar({
);
return map;
}, [modelOptions, provider]);
const effectiveProviderDefinitions =
providerDefinitions ??
(PROVIDER_DEFINITION_MAP.has(provider) ? [PROVIDER_DEFINITION_MAP.get(provider)!] : []);
const effectiveProviderDefinitions = providerDefinitions;
const effectiveAllProviderModels = allProviderModels ?? fallbackAllProviderModels;
const canSelectProviderInModelMenu = canSelectModelProvider ?? (() => true);
const comboboxThinkingOptions = useMemo<ComboboxOption[]>(
@@ -322,7 +318,7 @@ function ControlledStatusBar({
active: boolean;
onPress: () => void;
}) => {
const visuals = getModeVisuals(provider, option.id);
const visuals = getModeVisuals(provider, option.id, providerDefinitions);
const IconComponent = visuals?.icon ? MODE_ICONS[visuals.icon] : ShieldCheck;
return (
<ComboboxItem
@@ -334,7 +330,7 @@ function ControlledStatusBar({
/>
);
},
[provider, theme.colors.foreground],
[provider, providerDefinitions, theme.colors.foreground],
);
const handleOpenChange = useCallback(
@@ -356,7 +352,7 @@ function ControlledStatusBar({
return (
<View style={styles.container}>
{isWeb ? (
{platformIsWeb ? (
<>
{providerOptions && providerOptions.length > 0 ? (
<>
@@ -745,7 +741,7 @@ function ControlledStatusBar({
</DropdownMenuTrigger>
<DropdownMenuContent side="top" align="start">
{modeOptions.map((mode) => {
const visuals = getModeVisuals(provider, mode.id);
const visuals = getModeVisuals(provider, mode.id, providerDefinitions);
const Icon = visuals?.icon ? MODE_ICONS[visuals.icon] : ShieldCheck;
return (
<DropdownMenuItem
@@ -896,9 +892,11 @@ export const AgentStatusBar = memo(function AgentStatusBar({
const models = snapshotModels;
const agentProviderDefinitions = useMemo(() => {
const definition = AGENT_PROVIDER_DEFINITIONS.find((d) => d.id === agent?.provider);
const definition = agent?.provider
? resolveProviderDefinition(agent.provider, snapshotEntries)
: undefined;
return definition ? [definition] : [];
}, [agent?.provider]);
}, [agent?.provider, snapshotEntries]);
const agentProviderModels = useMemo(() => {
const map = new Map<string, AgentModelDefinition[]>();
@@ -1077,7 +1075,6 @@ export function DraftAgentStatusBar({
onModelSelectorOpen,
disabled = false,
}: DraftAgentStatusBarProps) {
const isWeb = Platform.OS === "web";
const { preferences, updatePreferences } = useFormPreferences();
const mappedModeOptions = useMemo<StatusOption[]>(() => {
@@ -1105,7 +1102,7 @@ export function DraftAgentStatusBar({
const effectiveSelectedThinkingOption =
selectedThinkingOptionId || mappedThinkingOptions[0]?.id || undefined;
if (isWeb) {
if (platformIsWeb) {
return (
<View style={styles.container}>
<CombinedModelSelector
@@ -1129,6 +1126,7 @@ export function DraftAgentStatusBar({
/>
<ControlledStatusBar
provider={selectedProvider}
providerDefinitions={providerDefinitions}
modeOptions={mappedModeOptions}
selectedModeId={effectiveSelectedMode}
onSelectMode={onSelectMode}
@@ -74,6 +74,7 @@ import {
WORKING_INDICATOR_CYCLE_MS,
WORKING_INDICATOR_OFFSETS,
} from "@/utils/working-indicator";
import { isWeb } from "@/constants/platform";
const isUserMessageItem = (item?: StreamItem) => item?.kind === "user_message";
const isToolSequenceItem = (item?: StreamItem) =>
@@ -222,7 +223,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
return buildAgentStreamRenderModel({
tail: streamItems,
head: streamHead ?? [],
platform: Platform.OS === "web" ? "web" : "native",
platform: isWeb ? "web" : "native",
isMobileBreakpoint: isMobile,
});
}, [isMobile, streamHead, streamItems]);
@@ -4,17 +4,17 @@ import {
Text,
TextInput,
Pressable,
Platform,
ActivityIndicator,
type GestureResponderEvent,
} from "react-native";
import { BottomSheetTextInput } from "@gorhom/bottom-sheet";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useIsCompactFormFactor } from "@/constants/layout";
import { isWeb as platformIsWeb } from "@/constants/platform";
import { ArrowLeft, ChevronDown, ChevronRight, Search, Star } from "lucide-react-native";
import type { AgentModelDefinition, AgentProvider } from "@server/server/agent/agent-sdk-types";
import type { AgentProviderDefinition } from "@server/server/agent/provider-manifest";
const IS_WEB = Platform.OS === "web";
const IS_WEB = platformIsWeb;
import { Combobox, ComboboxItem } from "@/components/ui/combobox";
import { getProviderIcon } from "@/components/provider-icons";
@@ -349,7 +349,7 @@ function ProviderSearchInput({
const InputComponent = isMobile ? BottomSheetTextInput : TextInput;
useEffect(() => {
if (autoFocus && Platform.OS === "web" && inputRef.current) {
if (autoFocus && platformIsWeb && inputRef.current) {
const timer = setTimeout(() => {
inputRef.current?.focus();
}, 50);
@@ -363,7 +363,7 @@ function ProviderSearchInput({
<InputComponent
ref={inputRef as any}
// @ts-expect-error - outlineStyle is web-only
style={[styles.providerSearchInput, Platform.OS === "web" && { outlineStyle: "none" }]}
style={[styles.providerSearchInput, platformIsWeb && { outlineStyle: "none" }]}
placeholder="Search models..."
placeholderTextColor={theme.colors.foregroundMuted}
value={value}
@@ -518,10 +518,9 @@ export function CombinedModelSelector({
disabled = false,
}: CombinedModelSelectorProps) {
const { theme } = useUnistyles();
const isWeb = Platform.OS === "web";
const anchorRef = useRef<View>(null);
const [isOpen, setIsOpen] = useState(false);
const [isContentReady, setIsContentReady] = useState(isWeb);
const [isContentReady, setIsContentReady] = useState(platformIsWeb);
const [view, setView] = useState<SelectorView>({ kind: "all" });
const [searchQuery, setSearchQuery] = useState("");
@@ -601,7 +600,7 @@ export function CombinedModelSelector({
}, [selectedModelLabel]);
useEffect(() => {
if (isWeb) {
if (platformIsWeb) {
return;
}
@@ -615,7 +614,7 @@ export function CombinedModelSelector({
});
return () => cancelAnimationFrame(frame);
}, [isOpen, isWeb]);
}, [isOpen, platformIsWeb]);
return (
<>
@@ -678,7 +677,7 @@ export function CombinedModelSelector({
<ProviderSearchInput
value={searchQuery}
onChangeText={setSearchQuery}
autoFocus={Platform.OS === "web"}
autoFocus={platformIsWeb}
/>
</View>
) : undefined
@@ -793,8 +792,7 @@ const styles = StyleSheet.create((theme) => ({
fontSize: theme.fontSize.xs,
color: theme.colors.foregroundMuted,
},
level2Header: {
},
level2Header: {},
backButton: {
flexDirection: "row",
alignItems: "center",
@@ -1,4 +1,4 @@
import { Modal, Pressable, ScrollView, Text, TextInput, View, Platform } from "react-native";
import { Modal, Pressable, ScrollView, Text, TextInput, View } from "react-native";
import { memo, useEffect, useRef, type ReactNode } from "react";
import { Plus, Settings } from "lucide-react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
@@ -8,6 +8,7 @@ import { formatTimeAgo } from "@/utils/time";
import { shortenPath } from "@/utils/shorten-path";
import { AgentStatusDot } from "@/components/agent-status-dot";
import { Shortcut } from "@/components/ui/shortcut";
import { isNative } from "@/constants/platform";
function agentKey(agent: Pick<AggregatedAgent, "serverId" | "id">): string {
return `${agent.serverId}:${agent.id}`;
@@ -90,7 +91,7 @@ export function CommandCenter() {
}
}, [activeIndex, open]);
if (Platform.OS !== "web" || !open) return null;
if (isNative || !open) return null;
const actionItems = items.filter((item) => item.kind === "action");
const agentItems = items.filter((item) => item.kind === "agent");
+13 -7
View File
@@ -1,4 +1,4 @@
import { View, Pressable, Text, ActivityIndicator, Platform } from "react-native";
import { View, Pressable, Text, ActivityIndicator } from "react-native";
import { useState, useEffect, useRef, useCallback } from "react";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useIsCompactFormFactor } from "@/constants/layout";
@@ -50,6 +50,7 @@ import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler";
import type { KeyboardActionDefinition } from "@/keyboard/keyboard-action-dispatcher";
import { submitAgentInput } from "@/components/agent-input-submit";
import { useAppSettings } from "@/hooks/use-settings";
import { isWeb, isNative } from "@/constants/platform";
type QueuedMessage = {
id: string;
@@ -126,7 +127,7 @@ export function Composer({
}: ComposerProps) {
markScrollInvestigationRender(`Composer:${serverId}:${agentId}`);
const { theme } = useUnistyles();
const buttonIconSize = Platform.OS === "web" ? theme.iconSize.md : theme.iconSize.lg;
const buttonIconSize = isWeb ? theme.iconSize.md : theme.iconSize.lg;
const insets = useSafeAreaInsets();
const client = useHostRuntimeClient(serverId);
const isConnected = useHostRuntimeIsConnected(serverId);
@@ -164,7 +165,7 @@ export function Composer({
const setAgentStreamHead = useSessionStore((state) => state.setAgentStreamHead);
const isMobile = useIsCompactFormFactor();
const isDesktopWebBreakpoint = Platform.OS === "web" && !isMobile;
const isDesktopWebBreakpoint = isWeb && !isMobile;
const messagePlaceholder = isDesktopWebBreakpoint
? DESKTOP_MESSAGE_PLACEHOLDER
: MOBILE_MESSAGE_PLACEHOLDER;
@@ -225,7 +226,7 @@ export function Composer({
}, [addImages, onAddImages]);
const focusInput = useCallback(() => {
if (Platform.OS !== "web") return;
if (isNative) return;
focusWithRetries({
focus: () => messageInputRef.current?.focus(),
isFocused: () => {
@@ -438,7 +439,7 @@ export function Composer({
case "message-input.dictation-confirm":
return messageInputRef.current?.runKeyboardAction("dictation-confirm") ?? false;
case "message-input.focus":
if (Platform.OS !== "web") {
if (isNative) {
messageInputRef.current?.focus();
return true;
}
@@ -632,14 +633,19 @@ export function Composer({
style={({ hovered }) => [
styles.realtimeVoiceButton as any,
(hovered ? styles.iconButtonHovered : undefined) as any,
(!isConnected || voice?.isVoiceSwitching ? styles.buttonDisabled : undefined) as any,
(!isConnected || voice?.isVoiceSwitching
? styles.buttonDisabled
: undefined) as any,
]}
>
{({ hovered }) =>
voice?.isVoiceSwitching ? (
<ActivityIndicator size="small" color="white" />
) : (
<AudioLines size={buttonIconSize} color={hovered ? theme.colors.foreground : theme.colors.foregroundMuted} />
<AudioLines
size={buttonIconSize}
color={hovered ? theme.colors.foreground : theme.colors.foregroundMuted}
/>
)
}
</TooltipTrigger>
@@ -1,5 +1,5 @@
import { Platform } from "react-native";
import { getIsElectronRuntime } from "@/constants/layout";
import { isNative } from "@/constants/platform";
/**
* VS Code-style titlebar drag region for Electron.
@@ -22,7 +22,7 @@ import { getIsElectronRuntime } from "@/constants/layout";
* Place as FIRST child of any positioned container that should be draggable.
*/
export function TitlebarDragRegion() {
if (Platform.OS !== "web" || !getIsElectronRuntime()) {
if (isNative || !getIsElectronRuntime()) {
return null;
}
+4 -3
View File
@@ -1,12 +1,13 @@
import React from "react";
import { View, Text, Platform, ScrollView as RNScrollView } from "react-native";
import { View, Text, ScrollView as RNScrollView } from "react-native";
import { ScrollView as GHScrollView } from "react-native-gesture-handler";
import { StyleSheet } from "react-native-unistyles";
import { Fonts } from "@/constants/theme";
import type { DiffLine, DiffSegment } from "@/utils/tool-call-parsers";
import { getCodeInsets } from "./code-insets";
import { isWeb } from "@/constants/platform";
const ScrollView = Platform.OS === "web" ? RNScrollView : GHScrollView;
const ScrollView = isWeb ? RNScrollView : GHScrollView;
interface DiffViewerProps {
diffLines: DiffLine[];
@@ -130,7 +131,7 @@ const styles = StyleSheet.create((theme) => {
fontFamily: Fonts.mono,
fontSize: theme.fontSize.xs,
color: theme.colors.foreground,
...(Platform.OS === "web"
...(isWeb
? {
whiteSpace: "pre",
overflowWrap: "normal",
@@ -3,7 +3,6 @@ import {
View,
Text,
Pressable,
Platform,
useWindowDimensions,
StyleSheet as RNStyleSheet,
} from "react-native";
@@ -26,6 +25,7 @@ import { FileExplorerPane } from "./file-explorer-pane";
import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style";
import { useWindowControlsPadding } from "@/utils/desktop-window";
import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region";
import { isWeb } from "@/constants/platform";
const MIN_CHAT_WIDTH = 400;
function logExplorerSidebar(_event: string, _details: Record<string, unknown>): void {}
@@ -252,7 +252,7 @@ export function ExplorerSidebar({
// Mobile: full-screen overlay with gesture.
// On web, keep it interactive only while open so closed sidebars don't eat taps.
const overlayPointerEvents = Platform.OS === "web" ? (isOpen ? "auto" : "none") : "box-none";
const overlayPointerEvents = isWeb ? (isOpen ? "auto" : "none") : "box-none";
// Navigation stacks can keep previous screens mounted; hide sidebars for unfocused
// screens so only the active screen exposes explorer/terminal surfaces.
@@ -309,12 +309,7 @@ export function ExplorerSidebar({
<View style={[styles.desktopSidebarBorder, { flex: 1 }]}>
{/* Resize handle - absolutely positioned over left border */}
<GestureDetector gesture={resizeGesture}>
<View
style={[
styles.resizeHandle,
Platform.OS === "web" && ({ cursor: "col-resize" } as any),
]}
/>
<View style={[styles.resizeHandle, isWeb && ({ cursor: "col-resize" } as any)]} />
</GestureDetector>
<SidebarContent
@@ -1,10 +1,11 @@
import { View, Text, Platform } from "react-native";
import { View, Text } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import Animated, { useAnimatedStyle, withTiming, useSharedValue } from "react-native-reanimated";
import { useEffect } from "react";
import { Upload } from "lucide-react-native";
import { useFileDropZone } from "@/hooks/use-file-drop-zone";
import type { ImageAttachment } from "./message-input";
import { isWeb } from "@/constants/platform";
interface FileDropZoneProps {
children: React.ReactNode;
@@ -12,7 +13,7 @@ interface FileDropZoneProps {
disabled?: boolean;
}
const IS_WEB = Platform.OS === "web";
const IS_WEB = isWeb;
export function FileDropZone({ children, onFilesDropped, disabled = false }: FileDropZoneProps) {
const { theme } = useUnistyles();
@@ -7,7 +7,6 @@ import {
Pressable,
Text,
View,
Platform,
} from "react-native";
import { Gesture } from "react-native-gesture-handler";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
@@ -52,6 +51,7 @@ import { usePanelStore, type SortOption } from "@/stores/panel-store";
import { formatTimeAgo } from "@/utils/time";
import { buildAbsoluteExplorerPath } from "@/utils/explorer-paths";
import { useWebScrollViewScrollbar } from "@/components/use-web-scrollbar";
import { isWeb } from "@/constants/platform";
const SORT_OPTIONS: { value: SortOption; label: string }[] = [
{ value: "name", label: "Name" },
@@ -91,7 +91,7 @@ export function FileExplorerPane({
}: FileExplorerPaneProps) {
const { theme } = useUnistyles();
const isMobile = useIsCompactFormFactor();
const showDesktopWebScrollbar = Platform.OS === "web" && !isMobile;
const showDesktopWebScrollbar = isWeb && !isMobile;
const daemons = useHosts();
const daemonProfile = useMemo(
+2 -2
View File
@@ -6,7 +6,6 @@ import {
ScrollView as RNScrollView,
Text,
View,
Platform,
} from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useIsCompactFormFactor } from "@/constants/layout";
@@ -21,6 +20,7 @@ import {
type HighlightStyle,
} from "@getpaseo/highlight";
import { lineNumberGutterWidth } from "@/components/code-insets";
import { isWeb } from "@/constants/platform";
interface CodeLineProps {
tokens: HighlightToken[];
@@ -243,7 +243,7 @@ export function FilePane({
filePath: string;
}) {
const isMobile = useIsCompactFormFactor();
const showDesktopWebScrollbar = Platform.OS === "web" && !isMobile;
const showDesktopWebScrollbar = isWeb && !isMobile;
const client = useSessionStore((state) => state.sessions[serverId]?.client ?? null);
const normalizedWorkspaceRoot = useMemo(() => workspaceRoot.trim(), [workspaceRoot]);
@@ -7,7 +7,6 @@ import {
ActivityIndicator,
Pressable,
FlatList,
Platform,
type LayoutChangeEvent,
type NativeSyntheticEvent,
type NativeScrollEvent,
@@ -80,6 +79,7 @@ import {
formatDiffGutterText,
hasVisibleDiffTokens,
} from "@/utils/diff-rendering";
import { isWeb, isNative } from "@/constants/platform";
export type { GitActionId, GitAction, GitActions } from "@/components/git-actions-policy";
@@ -100,7 +100,7 @@ type WrappedWebTextStyle = TextStyle & {
};
function getWrappedTextStyle(wrapLines: boolean): WrappedWebTextStyle | undefined {
if (Platform.OS !== "web") {
if (isNative) {
return undefined;
}
return wrapLines
@@ -454,7 +454,7 @@ const DiffFileHeader = memo(function DiffFileHeader({
}}
onPressOut={(event) => {
if (
Platform.OS !== "web" &&
isNative &&
!pressHandledRef.current &&
layoutYRef.current === 0 &&
pressInRef.current
@@ -632,8 +632,8 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi
const { theme } = useUnistyles();
const toast = useToast();
const isMobile = useIsCompactFormFactor();
const showDesktopWebScrollbar = Platform.OS === "web" && !isMobile;
const canUseSplitLayout = Platform.OS === "web" && !isMobile;
const showDesktopWebScrollbar = isWeb && !isMobile;
const canUseSplitLayout = isWeb && !isMobile;
const router = useRouter();
const [diffModeOverride, setDiffModeOverride] = useState<"uncommitted" | "base" | null>(null);
const [postShipArchiveSuggested, setPostShipArchiveSuggested] = useState(false);
@@ -1,16 +1,10 @@
import type { ReactElement, ReactNode } from "react";
import {
Platform,
Text,
View,
type PressableProps,
type StyleProp,
type ViewStyle,
} from "react-native";
import { Text, View, type PressableProps, type StyleProp, type ViewStyle } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { Shortcut } from "@/components/ui/shortcut";
import type { ShortcutKey } from "@/utils/format-shortcut";
import { isWeb } from "@/constants/platform";
interface HeaderToggleButtonState {
hovered: boolean;
@@ -44,7 +38,7 @@ export function HeaderToggleButton({
: undefined;
const expandedState = (props.accessibilityState as { expanded?: boolean } | undefined)?.expanded;
const ariaExpandedProps =
Platform.OS === "web" && typeof expandedState === "boolean"
isWeb && typeof expandedState === "boolean"
? ({ "aria-expanded": expandedState } as any)
: null;
@@ -8,9 +8,7 @@ interface AiderIconProps {
export function AiderIcon({ size = 16, color = "currentColor" }: AiderIconProps) {
return (
<Svg width={size} height={size} viewBox="0 0 24 24" fill={color}>
<Path
d="M9 3.75h5.25V4.5H9V3.75Zm-.75.75h6v2.25h-6V4.5Zm6 3h2.25v3h-2.25v-3Zm-6 3h8.25v3h-8.25v-3Zm-1.5 3h1.5v3h-1.5v-3Zm7.5 0h2.25v3h-2.25v-3Zm-6 3h6v3h-6v-3Zm8.25 0H18v3h-1.5v-3Z"
/>
<Path d="M9 3.75h5.25V4.5H9V3.75Zm-.75.75h6v2.25h-6V4.5Zm6 3h2.25v3h-2.25v-3Zm-6 3h8.25v3h-8.25v-3Zm-1.5 3h1.5v3h-1.5v-3Zm7.5 0h2.25v3h-2.25v-3Zm-6 3h6v3h-6v-3Zm8.25 0H18v3h-1.5v-3Z" />
</Svg>
);
}
+3 -8
View File
@@ -14,7 +14,6 @@ import {
View,
Pressable,
Text,
Platform,
useWindowDimensions,
StyleSheet as RNStyleSheet,
} from "react-native";
@@ -59,6 +58,7 @@ import {
parseServerIdFromPathname,
} from "@/utils/host-routes";
import { useOpenProjectPicker } from "@/hooks/use-open-project-picker";
import { isWeb } from "@/constants/platform";
const MIN_CHAT_WIDTH = 400;
@@ -527,7 +527,7 @@ function MobileSidebar({
pointerEvents: backdropOpacity.value > 0.01 ? "auto" : "none",
}));
const overlayPointerEvents = Platform.OS === "web" ? (isOpen ? "auto" : "none") : "box-none";
const overlayPointerEvents = isWeb ? (isOpen ? "auto" : "none") : "box-none";
return (
<View style={StyleSheet.absoluteFillObject} pointerEvents={overlayPointerEvents}>
@@ -833,12 +833,7 @@ function DesktopSidebar({
{/* Resize handle - absolutely positioned over right border */}
<GestureDetector gesture={resizeGesture}>
<View
style={[
styles.resizeHandle,
Platform.OS === "web" && ({ cursor: "col-resize" } as any),
]}
/>
<View style={[styles.resizeHandle, isWeb && ({ cursor: "col-resize" } as any)]} />
</GestureDetector>
</View>
</Animated.View>
+54 -32
View File
@@ -9,7 +9,6 @@ import {
TextInputKeyPressEventData,
TextInputSelectionChangeEventData,
Image,
Platform,
BackHandler,
} from "react-native";
import {
@@ -48,6 +47,7 @@ import {
markScrollInvestigationEvent,
markScrollInvestigationRender,
} from "@/utils/scroll-jank-investigation";
import { isWeb } from "@/constants/platform";
export type ImageAttachment = AttachmentMetadata;
@@ -121,14 +121,16 @@ export interface MessageInputRef {
const MIN_INPUT_HEIGHT_MOBILE = 30;
const MIN_INPUT_HEIGHT_DESKTOP = 46;
const MAX_INPUT_HEIGHT = 160;
const IS_WEB = Platform.OS === "web";
const MIN_INPUT_HEIGHT = IS_WEB ? MIN_INPUT_HEIGHT_DESKTOP : MIN_INPUT_HEIGHT_MOBILE;
const MIN_INPUT_HEIGHT = isWeb ? MIN_INPUT_HEIGHT_DESKTOP : MIN_INPUT_HEIGHT_MOBILE;
type WebTextInputKeyPressEvent = NativeSyntheticEvent<
TextInputKeyPressEventData & {
metaKey?: boolean;
ctrlKey?: boolean;
shiftKey?: boolean;
// Web-only: present on DOM KeyboardEvent during IME composition (CJK input).
isComposing?: boolean;
keyCode?: number;
}
>;
@@ -232,7 +234,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
ref,
) {
const { theme } = useUnistyles();
const buttonIconSize = IS_WEB ? theme.iconSize.md : theme.iconSize.lg;
const buttonIconSize = isWeb ? theme.iconSize.md : theme.iconSize.lg;
const investigationComponentId = `MessageInput:${voiceServerId ?? "unknown-server"}:${voiceAgentId ?? "unknown-agent"}`;
markScrollInvestigationRender(investigationComponentId);
const toast = useToast();
@@ -303,7 +305,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
return false;
},
getNativeElement: () => {
if (!IS_WEB) return null;
if (!isWeb) return null;
const current = textInputRef.current as (TextInput & { getNativeRef?: () => unknown }) | null;
const native = typeof current?.getNativeRef === "function" ? current.getNativeRef() : current;
return native instanceof HTMLElement ? native : null;
@@ -338,7 +340,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
// Autofocus on web when autoFocus is true, and re-run when focus key changes.
useEffect(() => {
if (!IS_WEB || !autoFocus) return;
if (!isWeb || !autoFocus) return;
return focusWithRetries({
focus: () => textInputRef.current?.focus(),
isFocused: () => {
@@ -381,7 +383,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
onChangeText(nextValue);
}
if (IS_WEB && typeof requestAnimationFrame === "function") {
if (isWeb && typeof requestAnimationFrame === "function") {
requestAnimationFrame(() => {
measureWebInputHeight("dictation");
});
@@ -637,13 +639,13 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
const webTextareaRef = useRef<HTMLElement | null>(null);
useLayoutEffect(() => {
if (IS_WEB) {
if (isWeb) {
webTextareaRef.current = getWebTextArea() as HTMLElement | null;
}
}, [getWebTextArea]);
const inputScrollbar = useWebElementScrollbar(webTextareaRef, {
enabled: IS_WEB && inputHeight >= MAX_INPUT_HEIGHT,
enabled: isWeb && inputHeight >= MAX_INPUT_HEIGHT,
});
const getWebElement = useCallback((target: "root" | "wrapper"): HTMLElement | null => {
@@ -657,7 +659,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
}, []);
useEffect(() => {
if (!IS_WEB || !onAddImages) {
if (!isWeb || !onAddImages) {
return;
}
@@ -710,7 +712,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
]);
useEffect(() => {
if (!IS_WEB || typeof ResizeObserver === "undefined") {
if (!isWeb || typeof ResizeObserver === "undefined") {
return;
}
@@ -764,7 +766,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
}, [getWebElement, getWebTextArea]);
useEffect(() => {
if (!IS_WEB) {
if (!isWeb) {
return;
}
const textarea = getWebTextArea() as (HTMLTextAreaElement & TextAreaHandle) | null;
@@ -802,7 +804,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
}, [getWebTextArea]);
function measureWebInputHeight(source: string): boolean {
if (!IS_WEB) return false;
if (!isWeb) return false;
const textarea = getWebTextArea();
if (!textarea || typeof textarea.scrollHeight !== "number") return false;
const scrollHeight = textarea.scrollHeight ?? 0;
@@ -856,7 +858,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
event: NativeSyntheticEvent<TextInputContentSizeChangeEventData>,
) {
const contentHeight = event.nativeEvent.contentSize.height;
if (IS_WEB) {
if (isWeb) {
logWebStickyBottom("composer_content_size_change", {
reportedHeight: contentHeight,
});
@@ -876,7 +878,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
function handleSelectionChange(event: NativeSyntheticEvent<TextInputSelectionChangeEventData>) {
const start = event.nativeEvent.selection?.start ?? 0;
const end = event.nativeEvent.selection?.end ?? start;
if (IS_WEB) {
if (isWeb) {
const textarea = getWebTextArea();
logWebStickyBottom("composer_selection_changed", {
now: getDebugNow(),
@@ -890,12 +892,17 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
onSelectionChangeCallback?.({ start, end });
}
const shouldHandleDesktopSubmit = IS_WEB;
const shouldHandleDesktopSubmit = isWeb;
function handleDesktopKeyPress(event: WebTextInputKeyPressEvent) {
markScrollInvestigationEvent(investigationComponentId, "keyPress");
if (!shouldHandleDesktopSubmit) return;
// IME composition in progress (e.g. CJK input) — all key events belong to the
// IME, not the app. keyCode 229 is a Chromium fallback for when isComposing is
// cleared before the keydown fires.
if (event.nativeEvent.isComposing || event.nativeEvent.keyCode === 229) return;
// Allow parent to intercept key events (e.g., for autocomplete navigation)
if (onKeyPressCallback) {
const handled = onKeyPressCallback({
@@ -930,7 +937,8 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
const hasRealContent = value.trim().length > 0 || hasImages;
const hasSendableContent = hasRealContent || allowEmptySubmit;
const shouldShowSendButton = hasSendableContent || isSubmitLoading;
const showEmptySubmitLabel = allowEmptySubmit && emptySubmitLabel && !hasRealContent && !isSubmitLoading;
const showEmptySubmitLabel =
allowEmptySubmit && emptySubmitLabel && !hasRealContent && !isSubmitLoading;
const canPressLoadingButton = isSubmitLoading && typeof onSubmitLoadingPress === "function";
const isSendButtonDisabled =
disabled || (!canPressLoadingButton && (isSubmitDisabled || isSubmitLoading));
@@ -947,7 +955,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
(nextValue: string) => {
markScrollInvestigationEvent(investigationComponentId, "inputChange");
onChangeText(nextValue);
if (IS_WEB) {
if (isWeb) {
logWebStickyBottom("composer_text_changed", {
valueLength: nextValue.length,
lineCount: nextValue.split("\n").length,
@@ -960,7 +968,10 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
return (
<View ref={rootRef} style={styles.container} testID="message-input-root">
{/* Regular input */}
<Animated.View ref={inputWrapperRef} style={[styles.inputWrapper, inputWrapperStyle, inputAnimatedStyle]}>
<Animated.View
ref={inputWrapperRef}
style={[styles.inputWrapper, inputWrapperStyle, inputAnimatedStyle]}
>
{/* Image preview pills */}
{hasImages && (
<View style={styles.imagePreviewContainer} testID="message-input-image-preview">
@@ -978,7 +989,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
<View
style={[
styles.removeImageButton,
(hovered || !IS_WEB) && styles.removeImageButtonVisible,
(hovered || !isWeb) && styles.removeImageButtonVisible,
]}
>
<X size={theme.iconSize.md} color="white" />
@@ -1010,7 +1021,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
}}
style={[
styles.textInput,
IS_WEB
isWeb
? {
height: inputHeight,
minHeight: MIN_INPUT_HEIGHT,
@@ -1022,12 +1033,12 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
},
]}
multiline
scrollEnabled={IS_WEB ? inputHeight >= MAX_INPUT_HEIGHT : true}
scrollEnabled={isWeb ? inputHeight >= MAX_INPUT_HEIGHT : true}
onContentSizeChange={handleContentSizeChange}
editable={!isDictating && !isRealtimeVoiceForCurrentAgent && !disabled}
onKeyPress={shouldHandleDesktopSubmit ? handleDesktopKeyPress : undefined}
onSelectionChange={handleSelectionChange}
autoFocus={IS_WEB && autoFocus}
autoFocus={isWeb && autoFocus}
/>
{inputScrollbar}
</View>
@@ -1051,7 +1062,10 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
]}
>
{({ hovered }) => (
<Paperclip size={buttonIconSize} color={hovered ? theme.colors.foreground : theme.colors.foregroundMuted} />
<Paperclip
size={buttonIconSize}
color={hovered ? theme.colors.foreground : theme.colors.foregroundMuted}
/>
)}
</Pressable>
</TooltipTrigger>
@@ -1091,9 +1105,15 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
isDictating ? (
<Square size={buttonIconSize} color="white" fill="white" />
) : isRealtimeVoiceForCurrentAgent && voice?.isMuted ? (
<MicOff size={buttonIconSize} color={hovered ? theme.colors.foreground : theme.colors.foregroundMuted} />
<MicOff
size={buttonIconSize}
color={hovered ? theme.colors.foreground : theme.colors.foregroundMuted}
/>
) : (
<Mic size={buttonIconSize} color={hovered ? theme.colors.foreground : theme.colors.foregroundMuted} />
<Mic
size={buttonIconSize}
color={hovered ? theme.colors.foreground : theme.colors.foregroundMuted}
/>
)
}
</TooltipTrigger>
@@ -1150,7 +1170,9 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
<TooltipTrigger
onPress={canPressLoadingButton ? onSubmitLoadingPress : handleDefaultSendAction}
disabled={isSendButtonDisabled}
accessibilityLabel={showEmptySubmitLabel ? emptySubmitLabel : submitAccessibilityLabel}
accessibilityLabel={
showEmptySubmitLabel ? emptySubmitLabel : submitAccessibilityLabel
}
accessibilityRole="button"
style={[
showEmptySubmitLabel ? styles.emptySubmitButton : styles.sendButton,
@@ -1233,7 +1255,7 @@ const styles = StyleSheet.create(((theme: any) => ({
xs: theme.spacing[3],
md: theme.spacing[4],
},
...(IS_WEB
...(isWeb
? {
transitionProperty: "border-color",
transitionDuration: "200ms",
@@ -1252,7 +1274,7 @@ const styles = StyleSheet.create(((theme: any) => ({
borderWidth: 1,
borderColor: theme.colors.borderAccent,
overflow: "hidden",
...(IS_WEB
...(isWeb
? {
cursor: "pointer",
}
@@ -1277,7 +1299,7 @@ const styles = StyleSheet.create(((theme: any) => ({
justifyContent: "center",
backgroundColor: "rgba(0, 0, 0, 0.5)",
opacity: 0,
...(IS_WEB
...(isWeb
? {
transitionProperty: "opacity",
transitionDuration: "150ms",
@@ -1296,7 +1318,7 @@ const styles = StyleSheet.create(((theme: any) => ({
fontSize: theme.fontSize.base,
fontWeight: theme.fontWeight.normal,
lineHeight: theme.fontSize.base * 1.4,
...(IS_WEB
...(isWeb
? {
outlineStyle: "none" as const,
outlineWidth: 0,
@@ -1318,7 +1340,7 @@ const styles = StyleSheet.create(((theme: any) => ({
rightButtonGroup: {
flexDirection: "row",
alignItems: "center",
gap: Platform.OS === "web" ? theme.spacing[2] : theme.spacing[1],
gap: isWeb ? theme.spacing[2] : theme.spacing[1],
},
attachButton: {
width: 28,
+14 -13
View File
@@ -7,7 +7,6 @@ import {
type LayoutChangeEvent,
StyleProp,
ViewStyle,
Platform,
} from "react-native";
import * as React from "react";
import {
@@ -86,6 +85,7 @@ import { useToolCallSheet } from "./tool-call-sheet";
import { ToolCallDetailsContent } from "./tool-call-details";
import { useAttachmentPreviewUrl } from "@/attachments/use-attachment-preview-url";
import type { DaemonClient } from "@server/client/daemon-client";
import { isWeb, isNative } from "@/constants/platform";
interface UserMessageProps {
message: string;
@@ -134,7 +134,7 @@ const SCROLL_EDGE_EPSILON = 0.5;
type ScrollAxis = "x" | "y";
function ensureWebToolCallShimmerKeyframes() {
if (Platform.OS !== "web") {
if (isNative) {
return;
}
if (typeof document === "undefined") {
@@ -341,12 +341,13 @@ export const UserMessage = memo(function UserMessage({
isLastInGroup = true,
disableOuterSpacing,
}: UserMessageProps) {
const isCompact = useIsCompactFormFactor();
const [messageHovered, setMessageHovered] = useState(false);
const [copyButtonHovered, setCopyButtonHovered] = useState(false);
const resolvedDisableOuterSpacing = useDisableOuterSpacing(disableOuterSpacing);
const hasText = message.trim().length > 0;
const hasImages = images.length > 0;
const showCopyButton = hasText && (Platform.OS !== "web" || messageHovered || copyButtonHovered);
const showCopyButton = hasText && (isCompact || messageHovered || copyButtonHovered);
return (
<View
@@ -361,8 +362,8 @@ export const UserMessage = memo(function UserMessage({
>
<Pressable
style={userMessageStylesheet.content}
onHoverIn={Platform.OS === "web" ? () => setMessageHovered(true) : undefined}
onHoverOut={Platform.OS === "web" ? () => setMessageHovered(false) : undefined}
onHoverIn={() => setMessageHovered(true)}
onHoverOut={() => setMessageHovered(false)}
>
<View style={userMessageStylesheet.bubble}>
{hasImages ? (
@@ -434,7 +435,7 @@ export const assistantMessageStylesheet = StyleSheet.create((theme) => ({
color: theme.colors.foreground,
fontFamily: Fonts.mono,
fontSize: 13,
userSelect: Platform.OS === "web" ? "text" : "auto",
userSelect: isWeb ? "text" : "auto",
},
imageFrame: {
width: "100%",
@@ -657,7 +658,7 @@ function MarkdownLink({
children: ReactNode;
}) {
const [hovered, setHovered] = useState(false);
if (Platform.OS !== "web") {
if (isNative) {
return (
<Text accessibilityRole="link" onPress={() => onPress(href)} style={style}>
{children}
@@ -779,8 +780,8 @@ export const TurnCopyButton = memo(function TurnCopyButton({
return (
<Pressable
onPress={handleCopy}
onHoverIn={Platform.OS === "web" ? () => onHoverChange?.(true) : undefined}
onHoverOut={Platform.OS === "web" ? () => onHoverChange?.(false) : undefined}
onHoverIn={() => onHoverChange?.(true)}
onHoverOut={() => onHoverChange?.(false)}
style={[turnCopyButtonStylesheet.container, containerStyle]}
accessibilityRole="button"
accessibilityLabel={
@@ -1060,7 +1061,7 @@ export const AssistantMessage = memo(function AssistantMessage({
<Text
key={node.key}
onPress={() => parsed && onInlinePathPress?.(parsed)}
selectable={Platform.OS === "web" ? undefined : false}
selectable={isWeb ? undefined : false}
style={[assistantMessageStylesheet.pathChip, assistantMessageStylesheet.pathChipText]}
>
{content}
@@ -1653,9 +1654,9 @@ const ExpandableBadge = memo(function ExpandableBadge({
32,
Math.min(120, labelRowWidth > 0 ? labelRowWidth * 0.28 : 0),
);
const isWebShimmer = isLoading && Platform.OS === "web";
const isWebShimmer = isLoading && isWeb;
const shouldMeasureWebShimmer = isWebShimmer;
const shouldMeasureNativeShimmer = isLoading && Platform.OS !== "web";
const shouldMeasureNativeShimmer = isLoading && isNative;
const isNativeShimmer = shouldMeasureNativeShimmer && labelRowWidth > 0 && labelRowHeight > 0;
const webShimmerSpanStartX = labelOffsetX;
const webShimmerSpanEndX = secondaryLabel
@@ -1732,7 +1733,7 @@ const ExpandableBadge = memo(function ExpandableBadge({
}, [isNativeShimmer, labelRowWidth, nativeShimmerPeakWidth, shimmerDuration, shimmerTranslateX]);
useEffect(() => {
if (Platform.OS !== "web" || !isExpanded || !hasDetailContent) {
if (isNative || !isExpanded || !hasDetailContent) {
return;
}
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Modal, Pressable, ScrollView, Text, TextInput, View, Platform } from "react-native";
import { Modal, Pressable, ScrollView, Text, TextInput, View } from "react-native";
import { Folder } from "lucide-react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useQuery } from "@tanstack/react-query";
@@ -11,6 +11,7 @@ import { useHosts, useHostRuntimeClient, useHostRuntimeIsConnected } from "@/run
import { useOpenProject } from "@/hooks/use-open-project";
import { parseServerIdFromPathname } from "@/utils/host-routes";
import { buildWorkingDirectorySuggestions } from "@/utils/working-directory-suggestions";
import { isNative } from "@/constants/platform";
export function ProjectPickerModal() {
const { theme } = useUnistyles();
@@ -122,7 +123,7 @@ export function ProjectPickerModal() {
// Keyboard navigation
useEffect(() => {
if (!open || Platform.OS !== "web") return;
if (!open || isNative) return;
function handler(event: KeyboardEvent) {
const key = event.key;
@@ -2,9 +2,10 @@ import { useCallback, useEffect, useState } from "react";
import { View, Text, ActivityIndicator, ScrollView } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { AdaptiveModalSheet } from "@/components/adaptive-modal-sheet";
import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot";
import { useHostRuntimeClient } from "@/runtime/host-runtime";
import { resolveProviderLabel } from "@/utils/provider-definitions";
import type { AgentProvider } from "@server/server/agent/agent-sdk-types";
import { AGENT_PROVIDER_DEFINITIONS } from "@server/server/agent/provider-manifest";
interface ProviderDiagnosticSheetProps {
provider: string;
@@ -21,11 +22,11 @@ export function ProviderDiagnosticSheet({
}: ProviderDiagnosticSheetProps) {
const { theme } = useUnistyles();
const client = useHostRuntimeClient(serverId);
const { entries: snapshotEntries } = useProvidersSnapshot(serverId);
const [diagnostic, setDiagnostic] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const providerLabel =
AGENT_PROVIDER_DEFINITIONS.find((d) => d.id === provider)?.label ?? provider;
const providerLabel = resolveProviderLabel(provider, snapshotEntries);
const fetchDiagnostic = useCallback(async () => {
if (!client || !provider) return;
@@ -1,10 +1,11 @@
import { useState, useCallback } from "react";
import { View, Text, TextInput, Pressable, ActivityIndicator, Platform } from "react-native";
import { View, Text, TextInput, Pressable, ActivityIndicator } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useIsCompactFormFactor } from "@/constants/layout";
import { Check, CircleHelp, X } from "lucide-react-native";
import type { PendingPermission } from "@/types/shared";
import type { AgentPermissionResponse } from "@server/server/agent/agent-sdk-types";
import { isWeb } from "@/constants/platform";
interface QuestionOption {
label: string;
@@ -60,7 +61,7 @@ interface QuestionFormCardProps {
isResponding: boolean;
}
const IS_WEB = Platform.OS === "web";
const IS_WEB = isWeb;
export function QuestionFormCard({ permission, onRespond, isResponding }: QuestionFormCardProps) {
const { theme } = useUnistyles();
@@ -50,7 +50,10 @@ import type { DraggableListDragHandleProps } from "./draggable-list.types";
import { getHostRuntimeStore, isHostRuntimeConnected } from "@/runtime/host-runtime";
import { useIsCompactFormFactor } from "@/constants/layout";
import { projectIconQueryKey } from "@/hooks/use-project-icon-query";
import { buildHostNewWorkspaceRoute, parseHostWorkspaceRouteFromPathname } from "@/utils/host-routes";
import {
buildHostNewWorkspaceRoute,
parseHostWorkspaceRouteFromPathname,
} from "@/utils/host-routes";
import { prepareWorkspaceTab } from "@/utils/workspace-navigation";
import {
type SidebarProjectEntry,
@@ -99,6 +102,7 @@ import {
import { WorkspaceHoverCard } from "@/components/workspace-hover-card";
import { GitHubIcon } from "@/components/icons/github-icon";
import { createNameId } from "mnemonic-id";
import { isWeb as platformIsWeb, isNative as platformIsNative } from "@/constants/platform";
function toProjectIconDataUri(icon: { mimeType: string; data: string } | null): string | null {
if (!icon) {
@@ -214,18 +218,12 @@ export function PrBadge({ hint }: { hint: PrHint }) {
hitSlop={4}
onPressIn={handlePressIn}
onPress={handlePress}
onPointerEnter={() => setIsHovered(true)}
onPointerLeave={() => setIsHovered(false)}
style={({ pressed }) => [
prBadgeStyles.badge,
pressed && prBadgeStyles.badgePressed,
]}
onHoverIn={() => setIsHovered(true)}
onHoverOut={() => setIsHovered(false)}
style={({ pressed }) => [prBadgeStyles.badge, pressed && prBadgeStyles.badgePressed]}
>
<GitPullRequest size={12} color={activeColor} />
<Text
style={[prBadgeStyles.text, { color: activeColor }]}
numberOfLines={1}
>
<Text style={[prBadgeStyles.text, { color: activeColor }]} numberOfLines={1}>
#{hint.number}
</Text>
<ArrowUpRight size={10} color={activeColor} style={{ opacity: isHovered ? 1 : 0 }} />
@@ -281,7 +279,6 @@ const checksBadgeStyles = StyleSheet.create((theme) => ({
},
}));
function WorkspaceStatusIndicator({
bucket,
workspaceKind,
@@ -613,7 +610,7 @@ function useLongPressDragInteraction(input: {
input.drag();
}, DRAG_ARM_DELAY_MS);
if (!input.menuController || Platform.OS === "web") {
if (!input.menuController || platformIsWeb) {
return;
}
@@ -778,7 +775,9 @@ function ProjectHeaderRow({
if (!serverId) {
return;
}
router.navigate(buildHostNewWorkspaceRoute(serverId, project.iconWorkingDir, { displayName }) as any);
router.navigate(
buildHostNewWorkspaceRoute(serverId, project.iconWorkingDir, { displayName }) as any,
);
onWorkspacePress?.();
}, [displayName, onWorkspacePress, project.iconWorkingDir, serverId]);
const mergeWorkspaces = useSessionStore((state) => state.mergeWorkspaces);
@@ -836,15 +835,18 @@ function ProjectHeaderRow({
<NewWorktreeButton
displayName={displayName}
onPress={handleBeginWorkspaceSetup}
visible={isHovered || isMobileBreakpoint}
visible={isHovered || platformIsNative || isMobileBreakpoint}
showShortcutHint={isProjectActive}
testID={`sidebar-project-new-worktree-${project.projectKey}`}
/>
) : null}
{onRemoveProject ? (
<View
style={!(isHovered || isMobileBreakpoint) && styles.projectKebabButtonHidden}
pointerEvents={isHovered || isMobileBreakpoint ? "auto" : "none"}
style={
!(isHovered || platformIsNative || isMobileBreakpoint) &&
styles.projectKebabButtonHidden
}
pointerEvents={isHovered || platformIsNative || isMobileBreakpoint ? "auto" : "none"}
>
<DropdownMenu>
<DropdownMenuTrigger
@@ -954,8 +956,9 @@ function WorkspaceRowInner({
archiveShortcutKeys,
}: WorkspaceRowInnerProps) {
const { theme } = useUnistyles();
const isCompact = useIsCompactFormFactor();
const [isHovered, setIsHovered] = useState(false);
const isTouchPlatform = Platform.OS !== "web";
const isTouchPlatform = platformIsNative;
const workspaceDirectory = resolveWorkspaceExecutionDirectory({
workspaceDirectory: workspace.workspaceDirectory,
});
@@ -1080,7 +1083,9 @@ function WorkspaceRowInner({
<DropdownMenuItem
testID={`sidebar-workspace-menu-archive-${workspace.workspaceKey}`}
leading={<Archive size={14} color={theme.colors.foregroundMuted} />}
trailing={archiveShortcutKeys ? <Shortcut chord={archiveShortcutKeys} /> : null}
trailing={
archiveShortcutKeys ? <Shortcut chord={archiveShortcutKeys} /> : null
}
status={archiveStatus}
pendingLabel={archivePendingLabel}
onSelect={onArchive}
@@ -1870,7 +1875,6 @@ export function SidebarWorkspaceList({
parentGestureRef,
}: SidebarWorkspaceListProps) {
const isMobile = useIsCompactFormFactor();
const isNative = Platform.OS !== "web";
const pathname = usePathname();
const activeWorkspaceSelection = useNavigationActiveWorkspaceSelection();
const [creatingWorkspaceIds, setCreatingWorkspaceIds] = useState<Set<string>>(() => new Set());
@@ -2104,7 +2108,7 @@ export function SidebarWorkspaceList({
drag={drag}
isDragging={isActive}
dragHandleProps={dragHandleProps}
useNestable={isNative}
useNestable={platformIsNative}
creatingWorkspaceIds={creatingWorkspaceIds}
/>
);
@@ -2121,7 +2125,7 @@ export function SidebarWorkspaceList({
serverId,
shortcutIndexByWorkspaceKey,
showShortcutBadges,
isNative,
platformIsNative,
creatingWorkspaceIds,
],
);
@@ -2145,7 +2149,7 @@ export function SidebarWorkspaceList({
onDragEnd={handleProjectDragEnd}
scrollEnabled={false}
useDragHandle
nestable={isNative}
nestable={platformIsNative}
simultaneousGestureRef={parentGestureRef}
containerStyle={styles.projectListContainer}
/>
@@ -2156,7 +2160,7 @@ export function SidebarWorkspaceList({
return (
<View style={styles.container}>
{isNative ? (
{platformIsNative ? (
<NestableScrollContainer
style={styles.list}
contentContainerStyle={styles.listContent}
@@ -27,7 +27,7 @@ import {
type DragStartEvent,
} from "@dnd-kit/core";
import { arrayMove, sortableKeyboardCoordinates } from "@dnd-kit/sortable";
import { Platform, View, Text } from "react-native";
import { View, Text } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { ResizeHandle } from "@/components/resize-handle";
import { shouldFocusPaneFromEventTarget } from "@/components/split-container-pane-focus";
@@ -69,6 +69,7 @@ import {
} from "@/stores/workspace-layout-store";
import type { WorkspaceTab } from "@/stores/workspace-tabs-store";
import { workspaceTabTargetsEqual } from "@/utils/workspace-tab-identity";
import { isNative } from "@/constants/platform";
interface SplitContainerProps {
layout: WorkspaceLayout;
@@ -828,7 +829,7 @@ function SplitPaneView({
);
useEffect(() => {
if (Platform.OS !== "web") {
if (isNative) {
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 { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useIsCompactFormFactor } from "@/constants/layout";
import { isWeb } from "@/constants/platform";
import { AlertTriangle, CheckCircle2 } from "lucide-react-native";
import { getOverlayRoot, OVERLAY_Z } from "@/lib/overlay-root";
import {
@@ -234,8 +235,8 @@ export function ToastViewport({
<View style={styles.container} pointerEvents="box-none">
<Animated.View
testID={toast.testID ?? "app-toast"}
onPointerEnter={pauseDismiss}
onPointerLeave={resumeDismiss}
onPointerEnter={isWeb ? pauseDismiss : undefined}
onPointerLeave={isWeb ? resumeDismiss : undefined}
style={[
styles.toast,
toast.variant === "success" ? styles.toastSuccess : null,
@@ -265,7 +266,7 @@ export function ToastViewport({
</View>
);
if (placement === "app-shell" && Platform.OS === "web" && typeof document !== "undefined") {
if (placement === "app-shell" && isWeb && typeof document !== "undefined") {
return createPortal(content, getOverlayRoot());
}
@@ -1,5 +1,5 @@
import React, { useMemo, ReactNode } from "react";
import { View, Text, Platform, ScrollView as RNScrollView } from "react-native";
import { View, Text, ScrollView as RNScrollView } from "react-native";
import { ScrollView as GHScrollView } from "react-native-gesture-handler";
import { StyleSheet } from "react-native-unistyles";
import { Fonts } from "@/constants/theme";
@@ -8,8 +8,9 @@ import { buildLineDiff, parseUnifiedDiff } from "@/utils/tool-call-parsers";
import { hasMeaningfulToolCallDetail } from "@/utils/tool-call-detail-state";
import { DiffViewer } from "./diff-viewer";
import { getCodeInsets } from "./code-insets";
import { isWeb } from "@/constants/platform";
const ScrollView = Platform.OS === "web" ? RNScrollView : GHScrollView;
const ScrollView = isWeb ? RNScrollView : GHScrollView;
// ---- Content Component ----
@@ -511,7 +512,7 @@ const styles = StyleSheet.create((theme) => {
fontSize: theme.fontSize.xs,
color: theme.colors.foreground,
lineHeight: 18,
...(Platform.OS === "web"
...(isWeb
? {
whiteSpace: "pre",
overflowWrap: "normal",
+65 -27
View File
@@ -37,8 +37,9 @@ import {
shouldShowCustomComboboxOption,
} from "./combobox-options";
import type { ComboboxOptionModel } from "./combobox-options";
import { isWeb } from "@/constants/platform";
const IS_WEB = Platform.OS === "web";
const IS_WEB = isWeb;
export type ComboboxOption = ComboboxOptionModel;
@@ -107,6 +108,7 @@ export interface SearchInputProps {
onChangeText: (text: string) => void;
onSubmitEditing?: () => void;
autoFocus?: boolean;
useBottomSheetInput?: boolean;
}
export function SearchInput({
@@ -115,10 +117,11 @@ export function SearchInput({
onChangeText,
onSubmitEditing,
autoFocus = false,
useBottomSheetInput = false,
}: SearchInputProps): ReactElement {
const { theme } = useUnistyles();
const inputRef = useRef<TextInput>(null);
const InputComponent = Platform.OS === "web" ? TextInput : BottomSheetTextInput;
const InputComponent = useBottomSheetInput ? BottomSheetTextInput : TextInput;
useEffect(() => {
if (autoFocus && IS_WEB && inputRef.current) {
@@ -264,9 +267,8 @@ export function Combobox({
}: ComboboxProps): ReactElement {
const isMobile = useIsCompactFormFactor();
const effectiveOptionsPosition = isMobile ? "below-search" : optionsPosition;
const isDesktopAboveSearch =
!isMobile && Platform.OS === "web" && effectiveOptionsPosition === "above-search";
const { height: windowHeight } = useWindowDimensions();
const isDesktopAboveSearch = !isMobile && isWeb && effectiveOptionsPosition === "above-search";
const { height: windowHeight, width: windowWidth } = useWindowDimensions();
const bottomSheetRef = useRef<BottomSheetModal>(null);
const hasPresentedBottomSheetRef = useRef(false);
const snapPoints = useMemo(() => ["60%", "90%"], []);
@@ -274,11 +276,13 @@ export function Combobox({
null,
);
const [referenceWidth, setReferenceWidth] = useState<number | null>(null);
const [referenceLeft, setReferenceLeft] = useState<number | null>(null);
const [referenceTop, setReferenceTop] = useState<number | null>(null);
const [referenceAtOrigin, setReferenceAtOrigin] = useState(false);
const [searchQuery, setSearchQuery] = useState("");
const [activeIndex, setActiveIndex] = useState<number>(-1);
const desktopOptionsScrollRef = useRef<ScrollView>(null);
const [desktopContentWidth, setDesktopContentWidth] = useState<number | null>(null);
const isControlled = typeof open === "boolean";
const [internalOpen, setInternalOpen] = useState(false);
@@ -322,8 +326,8 @@ export function Combobox({
const middleware = useMemo(
() => [
floatingOffset(Platform.OS === "web" ? 5 : 4),
...(Platform.OS === "web" ? [] : [flip({ padding: collisionPadding })]),
floatingOffset(isWeb ? 5 : 4),
...(isWeb ? [] : [flip({ padding: collisionPadding })]),
...(isDesktopAboveSearch ? [] : [shift({ padding: collisionPadding })]),
floatingSize({
padding: collisionPadding,
@@ -336,6 +340,9 @@ export function Combobox({
});
setReferenceWidth((prev) => {
const next = rects.reference.width;
if (!(next > 0)) {
return prev;
}
if (prev === next) return prev;
return next;
});
@@ -346,7 +353,7 @@ export function Combobox({
);
const { refs, floatingStyles, update } = useFloating({
placement: Platform.OS === "web" ? desktopPlacement : "bottom-start",
placement: isWeb ? desktopPlacement : "bottom-start",
middleware,
sameScrollView: false,
elements: {
@@ -357,15 +364,18 @@ export function Combobox({
useEffect(() => {
if (!isOpen || isMobile) {
setAvailableSize(null);
setDesktopContentWidth(null);
setReferenceLeft(null);
setReferenceWidth(null);
return;
}
const raf = requestAnimationFrame(() => void update());
return () => cancelAnimationFrame(raf);
}, [desktopPlacement, isMobile, update, isOpen]);
}, [desktopPlacement, isMobile, isOpen, update]);
useEffect(() => {
if (!isOpen || isMobile) {
setReferenceLeft(null);
setReferenceAtOrigin(false);
setReferenceTop(null);
return;
@@ -379,9 +389,16 @@ export function Combobox({
}
const measure = () => {
referenceEl.measureInWindow((x, y) => {
referenceEl.measureInWindow((x, y, width, height) => {
setReferenceLeft((prev) => (prev === x ? prev : x));
setReferenceAtOrigin(Math.abs(x) <= 1 && Math.abs(y) <= 1);
setReferenceTop((prev) => (prev === y ? prev : y));
setReferenceWidth((prev) => {
if (!(width > 0)) {
return prev;
}
return prev === width ? prev : width;
});
});
};
@@ -396,32 +413,46 @@ export function Combobox({
isDesktopAboveSearch && referenceTop !== null
? Math.max(windowHeight - referenceTop, collisionPadding)
: null;
const hasResolvedDesktopPosition =
referenceWidth !== null &&
floatingLeft !== null &&
(isDesktopAboveSearch ? desktopAboveSearchBottom !== null : floatingTop !== null) &&
((floatingTop ?? 0) !== 0 || floatingLeft !== 0 || referenceAtOrigin);
const shouldHideDesktopContent = desktopPreventInitialFlash && !hasResolvedDesktopPosition;
const shouldUseDesktopFade = !desktopPreventInitialFlash;
// For top-placed popups: once position resolves, use bottom-based CSS positioning
// so height changes grow upward naturally without floating-ui needing to reposition.
const useStableBottom =
const hasNonZeroFloatingPosition = (floatingTop ?? 0) !== 0 || floatingLeft !== 0;
const useMeasuredTopStartPosition =
!isDesktopAboveSearch &&
IS_WEB &&
!isMobile &&
hasResolvedDesktopPosition &&
desktopPlacement.startsWith("top") &&
referenceTop !== null;
desktopPlacement === "top-start" &&
referenceTop !== null &&
referenceLeft !== null &&
desktopContentWidth !== null;
const clampedMeasuredTopStartLeft = useMeasuredTopStartPosition
? Math.max(
collisionPadding,
Math.min(windowWidth - desktopContentWidth - collisionPadding, referenceLeft),
)
: null;
const measuredTopStartBottom = useMeasuredTopStartPosition
? Math.max(windowHeight - referenceTop + 5, collisionPadding)
: null;
const hasResolvedDesktopPosition =
referenceWidth !== null &&
referenceWidth > 0 &&
(isDesktopAboveSearch
? floatingLeft !== null && desktopAboveSearchBottom !== null
: useMeasuredTopStartPosition
? clampedMeasuredTopStartLeft !== null && measuredTopStartBottom !== null
: floatingLeft !== null &&
floatingTop !== null &&
(hasNonZeroFloatingPosition || !referenceAtOrigin));
const shouldHideDesktopContent = desktopPreventInitialFlash && !hasResolvedDesktopPosition;
const shouldUseDesktopFade = !desktopPreventInitialFlash;
const desktopPositionStyle = isDesktopAboveSearch
? {
left: floatingLeft ?? 0,
bottom: desktopAboveSearchBottom ?? 0,
}
: useStableBottom
: useMeasuredTopStartPosition
? {
left: floatingLeft ?? 0,
bottom: Math.max(windowHeight - referenceTop!, collisionPadding),
left: clampedMeasuredTopStartLeft ?? 0,
bottom: measuredTopStartBottom ?? 0,
}
: floatingStyles;
@@ -626,6 +657,7 @@ export function Combobox({
onChangeText={setSearchQueryWithCallback}
onSubmitEditing={handleSubmitSearch}
autoFocus={!isMobile}
useBottomSheetInput={isMobile}
/>
);
@@ -725,7 +757,13 @@ export function Combobox({
]}
ref={refs.setFloating}
collapsable={false}
onLayout={() => update()}
onLayout={(event) => {
const { width, height } = event.nativeEvent.layout;
setDesktopContentWidth((prev) => (prev === width ? prev : width));
if (!useMeasuredTopStartPosition || !hasResolvedDesktopPosition) {
void update();
}
}}
>
{children ? (
<>
@@ -32,6 +32,7 @@ import { useIsCompactFormFactor } from "@/constants/layout";
import { Check, CheckCircle } from "lucide-react-native";
import { BottomSheetBackdrop, BottomSheetModal, BottomSheetScrollView } from "@gorhom/bottom-sheet";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { isWeb, isNative } from "@/constants/platform";
// Keep parity with dropdown-menu action statuses.
export type ActionStatus = "idle" | "pending" | "success";
@@ -254,8 +255,7 @@ export function ContextMenuTrigger({
>): ReactElement {
const ctx = useContextMenuContext("ContextMenuTrigger");
const shouldEnableOnThisPlatform =
enabled && (Platform.OS === "web" ? enabledOnWeb : enabledOnMobile);
const shouldEnableOnThisPlatform = enabled && (isWeb ? enabledOnWeb : enabledOnMobile);
const openAtEvent = useCallback(
(event: unknown) => {
@@ -294,7 +294,7 @@ export function ContextMenuTrigger({
disabled={disabled}
delayLongPress={longPressDelayMs}
onLongPress={(event) => {
if (Platform.OS === "web") {
if (isWeb) {
props.onLongPress?.(event);
return;
}
@@ -303,7 +303,7 @@ export function ContextMenuTrigger({
}}
// @ts-ignore - onContextMenu is web-only and not in RN types.
onContextMenu={(event: unknown) => {
if (Platform.OS !== "web") {
if (isNative) {
return;
}
const e: any = event;
+11 -22
View File
@@ -28,6 +28,8 @@ import { Portal } from "@gorhom/portal";
import { useBottomSheetModalInternal } from "@gorhom/bottom-sheet";
import Animated, { FadeIn, FadeOut } from "react-native-reanimated";
import { StyleSheet } from "react-native-unistyles";
import { useIsCompactFormFactor } from "@/constants/layout";
import { isWeb } from "@/constants/platform";
type Side = "top" | "bottom" | "left" | "right";
type Align = "start" | "center" | "end";
@@ -108,18 +110,6 @@ function measureElement(element: View): Promise<Rect> {
});
}
function isMobileTooltipEnvironment(): boolean {
if (Platform.OS !== "web") {
return true;
}
if (typeof navigator === "undefined") {
return false;
}
return /Mobi|Android|iPhone|iPad|iPod/i.test(navigator.userAgent ?? "");
}
function computePosition({
triggerRect,
contentSize,
@@ -227,8 +217,8 @@ export function Tooltip({
onOpenChange,
});
const isMobile = isMobileTooltipEnvironment();
const enabled = isMobile ? enabledOnMobile : enabledOnDesktop;
const isCompact = useIsCompactFormFactor();
const enabled = isCompact ? enabledOnMobile : enabledOnDesktop;
const value = useMemo<TooltipContextValue>(
() => ({
@@ -236,10 +226,10 @@ export function Tooltip({
setOpen: setIsOpen,
triggerRef,
enabled,
openOnPress: isMobile,
openOnPress: isCompact,
delayDuration,
}),
[isOpen, setIsOpen, enabled, isMobile, delayDuration],
[isOpen, setIsOpen, enabled, isCompact, delayDuration],
);
return <TooltipContext.Provider value={value}>{children}</TooltipContext.Provider>;
@@ -257,10 +247,9 @@ export function TooltipTrigger({
triggerRefProp = "ref",
...props
}: PressableProps & {
asChild?: boolean;
triggerRefProp?: string;
}
): ReactElement {
asChild?: boolean;
triggerRefProp?: string;
}): ReactElement {
const ctx = useTooltipContext("TooltipTrigger");
const openTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
@@ -353,7 +342,7 @@ export function TooltipTrigger({
onFocus: handleFocus,
onBlur: handleBlur,
onPress: handlePress,
...(Platform.OS === "web"
...(isWeb
? ({
// RN Web's hover handling can vary across environments; pointer events are the most reliable.
onPointerEnter: handleHoverIn,
@@ -472,7 +461,7 @@ export function TooltipContent({
// On web, avoid React Native's <Modal/> implementation (it uses <dialog> and can
// steal focus / disrupt hover). Rendering via Portal + position:fixed keeps the
// exact same positioning math as DropdownMenu, without hover feedback loops.
if (Platform.OS === "web") {
if (isWeb) {
return (
<Portal hostName={bottomSheetInternal?.hostName}>
<View pointerEvents="none" style={styles.portalOverlay}>
@@ -1,6 +1,5 @@
import { useCallback, useEffect, useState, type ReactNode, type RefObject } from "react";
import {
Platform,
type FlatList,
type LayoutChangeEvent,
type NativeScrollEvent,
@@ -12,6 +11,7 @@ import {
useWebDesktopScrollbarMetrics,
type ScrollbarMetrics,
} from "./web-desktop-scrollbar";
import { isWeb as platformIsWeb } from "@/constants/platform";
const METRICS_EPSILON = 0.5;
const HIDE_SCROLLBAR_STYLE_ID = "paseo-hide-scrollbar";
@@ -45,8 +45,7 @@ export function useWebElementScrollbar(
contentRef?: RefObject<HTMLElement | null>;
},
): ReactNode {
const isWeb = Platform.OS === "web";
const enabled = (options?.enabled ?? true) && isWeb;
const enabled = (options?.enabled ?? true) && platformIsWeb;
const contentRef = options?.contentRef;
const [metrics, setMetrics] = useState<ScrollbarMetrics>({
@@ -125,8 +124,7 @@ export function useWebScrollViewScrollbar(
scrollableRef: RefObject<ScrollView | FlatList | null>,
options?: { enabled?: boolean },
): WebScrollViewScrollbar {
const isWeb = Platform.OS === "web";
const enabled = (options?.enabled ?? true) && isWeb;
const enabled = (options?.enabled ?? true) && platformIsWeb;
const metricsHook = useWebDesktopScrollbarMetrics();
const onScrollToOffset = useCallback(
@@ -1,7 +1,6 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
PanResponder,
Platform,
View,
type LayoutChangeEvent,
type NativeScrollEvent,
@@ -12,6 +11,7 @@ import {
computeScrollOffsetFromDragDelta,
computeVerticalScrollbarGeometry,
} from "./web-desktop-scrollbar.math";
import { isWeb as platformIsWeb } from "@/constants/platform";
const METRICS_EPSILON = 0.5;
const HANDLE_WIDTH_IDLE = 6;
@@ -135,7 +135,6 @@ export function WebDesktopScrollbarOverlay({
maxScrollOffset: 0,
});
const onScrollToOffsetRef = useRef(onScrollToOffset);
const isWeb = Platform.OS === "web";
const maxScrollOffset = Math.max(0, metrics.contentSize - metrics.viewportSize);
const normalizedOffset = inverted
@@ -258,7 +257,7 @@ export function WebDesktopScrollbarOverlay({
);
const panResponder = useMemo(() => {
if (isWeb) {
if (platformIsWeb) {
return null;
}
@@ -280,11 +279,11 @@ export function WebDesktopScrollbarOverlay({
setIsDragging(false);
},
});
}, [applyDragDelta, isWeb]);
}, [applyDragDelta, platformIsWeb]);
const startWebDrag = useCallback(
(event: any) => {
if (!isWeb) {
if (!platformIsWeb) {
return;
}
const clientY = readClientY(event);
@@ -298,7 +297,7 @@ export function WebDesktopScrollbarOverlay({
dragStartClientYRef.current = clientY;
setIsDragging(true);
},
[isWeb],
[platformIsWeb],
);
const handleGrabHoverIn = useCallback(() => {
@@ -313,7 +312,7 @@ export function WebDesktopScrollbarOverlay({
}, []);
useEffect(() => {
if (!isWeb || !isDragging) {
if (!platformIsWeb || !isDragging) {
return;
}
@@ -335,7 +334,7 @@ export function WebDesktopScrollbarOverlay({
window.removeEventListener("pointerup", stopDragging);
window.removeEventListener("pointercancel", stopDragging);
};
}, [applyDragDelta, isDragging, isWeb]);
}, [applyDragDelta, isDragging, platformIsWeb]);
if (!enabled || !geometry.isVisible) {
return null;
@@ -371,7 +370,7 @@ export function WebDesktopScrollbarOverlay({
height: thumbRegionHeight,
transform: [{ translateY: thumbRegionOffset }],
},
isWeb &&
platformIsWeb &&
({
cursor: handleCursor,
touchAction: "none",
@@ -383,7 +382,7 @@ export function WebDesktopScrollbarOverlay({
]}
pointerEvents={handleVisible ? "auto" : "none"}
{...(panResponder?.panHandlers ?? {})}
{...(isWeb
{...(platformIsWeb
? ({
onPointerDown: startWebDrag,
onPointerEnter: handleGrabHoverIn,
@@ -403,7 +402,7 @@ export function WebDesktopScrollbarOverlay({
backgroundColor: handleColor,
opacity: handleOpacity,
},
isWeb &&
platformIsWeb &&
({
transitionProperty: "opacity, width, background-color",
transitionDuration: `${HANDLE_FADE_DURATION_MS}ms, ${HANDLE_WIDTH_TRANSITION_DURATION_MS}ms, ${HANDLE_FADE_DURATION_MS}ms`,
+49 -50
View File
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useState, useSyncExternalStore } from "react";
import { Pressable, Text, View, Platform, ScrollView } from "react-native";
import { Pressable, Text, View, ScrollView } from "react-native";
import { useRouter } from "expo-router";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { QrCode, Link2, ClipboardPaste, ExternalLink } from "lucide-react-native";
@@ -18,6 +18,7 @@ import { formatVersionWithPrefix } from "@/desktop/updates/desktop-updates";
import { buildHostRootRoute } from "@/utils/host-routes";
import { PaseoLogo } from "@/components/icons/paseo-logo";
import { openExternalUrl } from "@/utils/open-external-url";
import { isWeb, isNative } from "@/constants/platform";
type WelcomeAction = {
key: "scan-qr" | "direct-connection" | "paste-pairing-link";
@@ -242,8 +243,7 @@ export function WelcomeScreen({ onHostAdded }: WelcomeScreenProps) {
const anyOnlineServerId = useAnyHostOnline(hosts.map((h) => h.serverId));
useEffect(() => {
const currentPathname =
typeof window === "undefined" ? null : (window.location.pathname || null);
const currentPathname = typeof window === "undefined" ? null : window.location.pathname || null;
if (currentPathname && currentPathname !== "/welcome") {
return;
}
@@ -260,52 +260,51 @@ export function WelcomeScreen({ onHostAdded }: WelcomeScreenProps) {
[router],
);
const actions: WelcomeAction[] =
Platform.OS === "web"
? [
{
key: "direct-connection",
label: "Direct connection",
testID: "welcome-direct-connection",
primary: true,
icon: Link2,
onPress: () => setIsDirectOpen(true),
},
{
key: "paste-pairing-link",
label: "Paste pairing link",
testID: "welcome-paste-pairing-link",
primary: false,
icon: ClipboardPaste,
onPress: () => setIsPasteLinkOpen(true),
},
]
: [
{
key: "scan-qr",
label: "Scan QR code",
testID: "welcome-scan-qr",
primary: true,
icon: QrCode,
onPress: () => router.push("/pair-scan?source=onboarding"),
},
{
key: "direct-connection",
label: "Direct connection",
testID: "welcome-direct-connection",
primary: false,
icon: Link2,
onPress: () => setIsDirectOpen(true),
},
{
key: "paste-pairing-link",
label: "Paste pairing link",
testID: "welcome-paste-pairing-link",
primary: false,
icon: ClipboardPaste,
onPress: () => setIsPasteLinkOpen(true),
},
];
const actions: WelcomeAction[] = isWeb
? [
{
key: "direct-connection",
label: "Direct connection",
testID: "welcome-direct-connection",
primary: true,
icon: Link2,
onPress: () => setIsDirectOpen(true),
},
{
key: "paste-pairing-link",
label: "Paste pairing link",
testID: "welcome-paste-pairing-link",
primary: false,
icon: ClipboardPaste,
onPress: () => setIsPasteLinkOpen(true),
},
]
: [
{
key: "scan-qr",
label: "Scan QR code",
testID: "welcome-scan-qr",
primary: true,
icon: QrCode,
onPress: () => router.push("/pair-scan?source=onboarding"),
},
{
key: "direct-connection",
label: "Direct connection",
testID: "welcome-direct-connection",
primary: false,
icon: Link2,
onPress: () => setIsDirectOpen(true),
},
{
key: "paste-pairing-link",
label: "Paste pairing link",
testID: "welcome-paste-pairing-link",
primary: false,
icon: ClipboardPaste,
onPress: () => setIsPasteLinkOpen(true),
},
];
const showHostList = hosts.length > 0 && !anyOnlineServerId;
@@ -326,7 +325,7 @@ export function WelcomeScreen({ onHostAdded }: WelcomeScreenProps) {
{showHostList ? "Connecting to your hosts…" : "Connect to your host to start"}
</Text>
{!showHostList && Platform.OS !== "web" && (
{!showHostList && isNative && (
<>
<Text style={styles.setupHint}>
You need the Paseo desktop app or server running on your computer first.
@@ -289,10 +289,7 @@ function WorkspaceHoverCardContent({
<>
<View style={styles.separator} />
<Pressable
style={({ hovered }) => [
styles.checksSummaryRow,
hovered && styles.listRowHovered,
]}
style={({ hovered }) => [styles.checksSummaryRow, hovered && styles.listRowHovered]}
onPress={() => void openExternalUrl(`${prHint.url}/checks`)}
>
{({ hovered }) => {
@@ -333,10 +330,7 @@ function WorkspaceHoverCardContent({
},
]}
>
<ExternalLink
size={12}
color={theme.colors.foreground}
/>
<ExternalLink size={12} color={theme.colors.foreground} />
</View>
) : null}
</>
@@ -14,9 +14,7 @@ import { normalizeAgentSnapshot } from "@/utils/agent-snapshots";
import { encodeImages } from "@/utils/encode-images";
import { toErrorMessage } from "@/utils/error-messages";
import { projectIconPlaceholderLabelFromDisplayName } from "@/utils/project-display-name";
import {
requireWorkspaceExecutionAuthority,
} from "@/utils/workspace-execution";
import { requireWorkspaceExecutionAuthority } from "@/utils/workspace-execution";
import { navigateToPreparedWorkspaceTab } from "@/utils/workspace-navigation";
import type { ImageAttachment, MessagePayload } from "./message-input";
@@ -174,7 +172,9 @@ export function WorkspaceSetupDialog() {
}
const encodedImages = await encodeImages(images);
const workspaceDirectory = requireWorkspaceExecutionAuthority({ workspace }).workspaceDirectory;
const workspaceDirectory = requireWorkspaceExecutionAuthority({
workspace,
}).workspaceDirectory;
const agent = await connectedClient.createAgent({
provider: composerState.selectedProvider,
cwd: workspaceDirectory,
@@ -222,7 +222,6 @@ export function WorkspaceSetupDialog() {
],
);
const workspaceTitle =
workspace?.name ||
workspace?.projectDisplayName ||