Files
paseo/packages/app/src/app/_layout.tsx
T

492 lines
16 KiB
TypeScript

import "@/styles/unistyles";
import { polyfillCrypto } from "@/polyfills/crypto";
import { Stack, usePathname, useRouter } from "expo-router";
import { SafeAreaProvider } from "react-native-safe-area-context";
import { KeyboardProvider } from "react-native-keyboard-controller";
import { GestureHandlerRootView, Gesture, GestureDetector } from "react-native-gesture-handler";
import { BottomSheetModalProvider } from "@gorhom/bottom-sheet";
import { PortalProvider } from "@gorhom/portal";
import { VoiceProvider } from "@/contexts/voice-context";
import { useAppSettings } from "@/hooks/use-settings";
import { useFaviconStatus } from "@/hooks/use-favicon-status";
import { View, ActivityIndicator, Text } from "react-native";
import { UnistylesRuntime, useUnistyles } from "react-native-unistyles";
import { darkTheme } from "@/styles/theme";
import { DaemonRegistryProvider, useDaemonRegistry } from "@/contexts/daemon-registry-context";
import { MultiDaemonSessionHost } from "@/components/multi-daemon-session-host";
import { QueryClientProvider } from "@tanstack/react-query";
import { useState, useEffect, type ReactNode, useMemo, useRef } from "react";
import { Platform } from "react-native";
import * as Linking from "expo-linking";
import * as Notifications from "expo-notifications";
import { LeftSidebar } from "@/components/left-sidebar";
import { DownloadToast } from "@/components/download-toast";
import { ToastProvider } from "@/contexts/toast-context";
import { usePanelStore } from "@/stores/panel-store";
import { runOnJS, interpolate, Extrapolation, useSharedValue } from "react-native-reanimated";
import {
SidebarAnimationProvider,
useSidebarAnimation,
} from "@/contexts/sidebar-animation-context";
import {
HorizontalScrollProvider,
useHorizontalScrollOptional,
} from "@/contexts/horizontal-scroll-context";
import { getIsTauri, getIsTauriMac } from "@/constants/layout";
import { useTrafficLightPadding } from "@/utils/tauri-window";
import { CommandCenter } from "@/components/command-center";
import { KeyboardShortcutsDialog } from "@/components/keyboard-shortcuts-dialog";
import { useKeyboardShortcuts } from "@/hooks/use-keyboard-shortcuts";
import { queryClient } from "@/query/query-client";
import {
WEB_NOTIFICATION_CLICK_EVENT,
type WebNotificationClickDetail,
ensureOsNotificationPermission,
} from "@/utils/os-notifications";
import { buildNotificationRoute } from "@/utils/notification-routing";
import {
buildHostAgentDraftRoute,
parseHostAgentRouteFromPathname,
} from "@/utils/host-routes";
import { getTauri } from "@/utils/tauri";
import { PerfDiagnosticsProvider } from "@/runtime/perf-diagnostics";
polyfillCrypto();
const IS_DEV = Boolean((globalThis as { __DEV__?: boolean }).__DEV__);
function logLeftSidebarOpenGesture(
event: string,
details: Record<string, unknown>
): void {
if (!IS_DEV) {
return;
}
console.log(`[LeftSidebarOpenGesture] ${event}`, details);
}
function PushNotificationRouter() {
const router = useRouter();
const lastHandledIdRef = useRef<string | null>(null);
useEffect(() => {
if (Platform.OS === "web") {
if (getTauri()) {
void ensureOsNotificationPermission().then((granted) => {
console.log(
"[OSNotifications][Tauri] Startup permission preflight result:",
granted ? "granted" : "not-granted"
);
});
}
const target = globalThis as unknown as EventTarget;
const openFromWebClick = (event: Event) => {
const customEvent = event as CustomEvent<WebNotificationClickDetail>;
const route = buildNotificationRoute(customEvent.detail?.data);
event.preventDefault();
router.push(route as any);
};
target.addEventListener(
WEB_NOTIFICATION_CLICK_EVENT,
openFromWebClick as EventListener
);
return () => {
target.removeEventListener(
WEB_NOTIFICATION_CLICK_EVENT,
openFromWebClick as EventListener
);
};
}
Notifications.setNotificationHandler({
handleNotification: async () => ({
// When the app is open, don't show OS banners.
shouldShowAlert: false,
shouldShowBanner: false,
shouldShowList: false,
shouldPlaySound: false,
shouldSetBadge: false,
}),
});
const openFromResponse = (response: Notifications.NotificationResponse) => {
const identifier = response.notification.request.identifier;
if (lastHandledIdRef.current === identifier) {
return;
}
lastHandledIdRef.current = identifier;
const data = response.notification.request.content.data as
| Record<string, unknown>
| undefined;
router.push(buildNotificationRoute(data) as any);
};
const subscription =
Notifications.addNotificationResponseReceivedListener(openFromResponse);
void Notifications.getLastNotificationResponseAsync().then((response) => {
if (response) {
openFromResponse(response);
}
});
return () => {
subscription.remove();
};
}, [router]);
return null;
}
function QueryProvider({ children }: { children: ReactNode }) {
return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>;
}
interface AppContainerProps {
children: ReactNode;
selectedAgentId?: string;
}
function AppContainer({ children, selectedAgentId }: AppContainerProps) {
const { theme } = useUnistyles();
const { daemons } = useDaemonRegistry();
const mobileView = usePanelStore((state) => state.mobileView);
const desktopAgentListOpen = usePanelStore((state) => state.desktop.agentListOpen);
const openAgentList = usePanelStore((state) => state.openAgentList);
const toggleAgentList = usePanelStore((state) => state.toggleAgentList);
const toggleFileExplorer = usePanelStore((state) => state.toggleFileExplorer);
const horizontalScroll = useHorizontalScrollOptional();
const isMobile =
UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
const chromeEnabled = daemons.length > 0;
const isOpen = chromeEnabled
? isMobile
? mobileView === "agent-list"
: desktopAgentListOpen
: false;
const openGestureEnabled =
chromeEnabled && isMobile && mobileView === "agent";
useKeyboardShortcuts({
enabled: chromeEnabled,
isMobile,
toggleAgentList,
selectedAgentId,
toggleFileExplorer,
});
const {
translateX,
backdropOpacity,
windowWidth,
animateToOpen,
animateToClose,
isGesturing,
} = useSidebarAnimation();
// Track initial touch position for manual activation
const touchStartX = useSharedValue(0);
// Open gesture: swipe right from anywhere to open sidebar (interactive drag)
// If any horizontal scroll is scrolled right, let the scroll view handle the gesture first
const openGesture = useMemo(
() =>
Gesture.Pan()
.enabled(openGestureEnabled)
.manualActivation(true)
// Fail if 10px vertical movement happens first (allow vertical scroll)
.failOffsetY([-10, 10])
.onTouchesDown((event) => {
const touch = event.changedTouches[0];
if (touch) {
touchStartX.value = touch.absoluteX;
}
})
.onTouchesMove((event, stateManager) => {
const touch = event.changedTouches[0];
if (!touch || event.numberOfTouches !== 1) return;
const deltaX = touch.absoluteX - touchStartX.value;
// If horizontal scroll is scrolled right, fail so ScrollView handles it
if (horizontalScroll?.isAnyScrolledRight.value) {
stateManager.fail();
return;
}
// Activate after 15px rightward movement
if (deltaX > 15) {
stateManager.activate();
}
})
.onStart(() => {
isGesturing.value = true;
runOnJS(logLeftSidebarOpenGesture)("start", {
mobileView,
openGestureEnabled,
});
})
.onUpdate((event) => {
// Start from closed position (-windowWidth) and move towards 0
const newTranslateX = Math.min(0, -windowWidth + event.translationX);
translateX.value = newTranslateX;
backdropOpacity.value = interpolate(
newTranslateX,
[-windowWidth, 0],
[0, 1],
Extrapolation.CLAMP
);
})
.onEnd((event) => {
isGesturing.value = false;
// Open if dragged more than 1/3 of sidebar or fast swipe
const shouldOpen = event.translationX > windowWidth / 3 || event.velocityX > 500;
runOnJS(logLeftSidebarOpenGesture)("end", {
translationX: event.translationX,
velocityX: event.velocityX,
shouldOpen,
mobileView,
openGestureEnabled,
});
if (shouldOpen) {
animateToOpen();
runOnJS(openAgentList)();
} else {
animateToClose();
}
})
.onFinalize(() => {
isGesturing.value = false;
}),
[
openGestureEnabled,
windowWidth,
translateX,
backdropOpacity,
animateToOpen,
animateToClose,
openAgentList,
mobileView,
isGesturing,
horizontalScroll?.isAnyScrolledRight,
touchStartX,
]
);
// When sidebar is collapsed on desktop Tauri macOS, add left padding for traffic lights
const trafficLightPadding = useTrafficLightPadding();
const needsTrafficLightPadding = !isMobile && !isOpen && getIsTauriMac();
const content = (
<View style={{ flex: 1, backgroundColor: theme.colors.surface0 }}>
<View style={{ flex: 1, flexDirection: "row" }}>
{!isMobile && chromeEnabled && <LeftSidebar selectedAgentId={selectedAgentId} />}
<View style={{ flex: 1, paddingLeft: needsTrafficLightPadding ? trafficLightPadding.left : 0 }}>
{children}
</View>
</View>
{isMobile && chromeEnabled && <LeftSidebar selectedAgentId={selectedAgentId} />}
<DownloadToast />
<CommandCenter />
<KeyboardShortcutsDialog />
</View>
);
if (!isMobile) {
return content;
}
return (
<GestureDetector gesture={openGesture} touchAction="pan-y">
{content}
</GestureDetector>
);
}
function ProvidersWrapper({ children }: { children: ReactNode }) {
const { settings, isLoading: settingsLoading } = useAppSettings();
const { daemons, isLoading: registryLoading, upsertDaemonFromOfferUrl } = useDaemonRegistry();
const isLoading = settingsLoading || registryLoading;
// Apply theme setting on mount and when it changes
useEffect(() => {
if (isLoading) return;
if (settings.theme === "auto") {
UnistylesRuntime.setAdaptiveThemes(true);
} else {
UnistylesRuntime.setAdaptiveThemes(false);
UnistylesRuntime.setTheme(settings.theme);
}
}, [isLoading, settings.theme]);
if (isLoading) {
return <LoadingView />;
}
return (
<VoiceProvider>
<OfferLinkListener upsertDaemonFromOfferUrl={upsertDaemonFromOfferUrl} />
{children}
</VoiceProvider>
);
}
function OfferLinkListener({
upsertDaemonFromOfferUrl,
}: {
upsertDaemonFromOfferUrl: (offerUrlOrFragment: string) => Promise<unknown>;
}) {
const router = useRouter();
useEffect(() => {
let cancelled = false;
const handleUrl = (url: string | null) => {
if (!url) return;
if (!url.includes("#offer=")) return;
void upsertDaemonFromOfferUrl(url)
.then((profile) => {
if (cancelled) return;
const serverId = (profile as any)?.serverId;
if (typeof serverId !== "string" || !serverId) return;
router.replace(buildHostAgentDraftRoute(serverId) as any);
})
.catch((error) => {
if (cancelled) return;
console.warn("[Linking] Failed to import pairing offer", error);
});
};
void Linking.getInitialURL().then(handleUrl).catch(() => undefined);
const subscription = Linking.addEventListener("url", (event) => {
handleUrl(event.url);
});
return () => {
cancelled = true;
subscription.remove();
};
}, [router, upsertDaemonFromOfferUrl]);
return null;
}
function AppWithSidebar({ children }: { children: ReactNode }) {
const pathname = usePathname();
useFaviconStatus();
// Parse selectedAgentKey directly from pathname
// useLocalSearchParams doesn't update when navigating between same-pattern routes
const selectedAgentKey = useMemo(() => {
const match = parseHostAgentRouteFromPathname(pathname);
return match ? `${match.serverId}:${match.agentId}` : undefined;
}, [pathname]);
return (
<AppContainer selectedAgentId={selectedAgentKey}>{children}</AppContainer>
);
}
function LoadingView({ message }: { message?: string } = {}) {
return (
<View
style={{
flex: 1,
justifyContent: "center",
alignItems: "center",
backgroundColor: darkTheme.colors.surface0,
}}
>
<ActivityIndicator size="large" color={darkTheme.colors.foreground} />
{message ? (
<Text
style={{
color: darkTheme.colors.foregroundMuted,
marginTop: 16,
fontSize: 14,
}}
>
{message}
</Text>
) : null}
</View>
);
}
function MissingDaemonView() {
return (
<View
style={{
flex: 1,
justifyContent: "center",
alignItems: "center",
padding: 24,
backgroundColor: darkTheme.colors.surface0,
}}
>
<ActivityIndicator size="small" color={darkTheme.colors.foreground} />
<Text
style={{
color: darkTheme.colors.foreground,
marginTop: 16,
textAlign: "center",
}}
>
No host configured. Open Settings to add a server URL.
</Text>
</View>
);
}
export default function RootLayout() {
return (
<GestureHandlerRootView style={{ flex: 1 }}>
<PerfDiagnosticsProvider scope="root_layout">
<PortalProvider>
<SafeAreaProvider>
<KeyboardProvider>
<BottomSheetModalProvider>
<QueryProvider>
<DaemonRegistryProvider>
<PushNotificationRouter />
<MultiDaemonSessionHost />
<ProvidersWrapper>
<SidebarAnimationProvider>
<HorizontalScrollProvider>
<ToastProvider>
<AppWithSidebar>
<Stack
screenOptions={{
headerShown: false,
animation: "none",
}}
>
<Stack.Screen name="index" />
<Stack.Screen
name="h/[serverId]/agent/[agentId]"
options={{ gestureEnabled: false }}
/>
<Stack.Screen name="h/[serverId]/index" />
<Stack.Screen name="h/[serverId]/agent/index" />
<Stack.Screen name="h/[serverId]/agents" />
<Stack.Screen name="h/[serverId]/settings" />
<Stack.Screen name="pair-scan" />
</Stack>
</AppWithSidebar>
</ToastProvider>
</HorizontalScrollProvider>
</SidebarAnimationProvider>
</ProvidersWrapper>
</DaemonRegistryProvider>
</QueryProvider>
</BottomSheetModalProvider>
</KeyboardProvider>
</SafeAreaProvider>
</PortalProvider>
</PerfDiagnosticsProvider>
</GestureHandlerRootView>
);
}