import { useCallback, useMemo, useState, useSyncExternalStore } from "react"; import type { ComponentType, ReactNode } from "react"; import { Alert, Pressable, ScrollView, Text, View } from "react-native"; import { useRouter } from "expo-router"; import { useFocusEffect } from "@react-navigation/native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { Buffer } from "buffer"; import { ArrowLeft, Sun, Moon, Monitor, ChevronDown, Settings, Server, Keyboard, Stethoscope, Info, Shield, Puzzle, Plus, } from "lucide-react-native"; import { SidebarHeaderRow } from "@/components/sidebar/sidebar-header-row"; import { SidebarSeparator } from "@/components/sidebar/sidebar-separator"; import { ScreenTitle } from "@/components/headers/screen-title"; import { HeaderIconBadge } from "@/components/headers/header-icon-badge"; import { SettingsSection } from "@/screens/settings/settings-section"; import { useAppSettings, type AppSettings, type SendBehavior } from "@/hooks/use-settings"; import { THEME_SWATCHES } from "@/styles/theme"; import { getHostRuntimeStore, isHostRuntimeConnected, useHosts } from "@/runtime/host-runtime"; import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region"; import { useWindowControlsPadding } from "@/utils/desktop-window"; import { confirmDialog } from "@/utils/confirm-dialog"; import { BackHeader } from "@/components/headers/back-header"; import { ScreenHeader } from "@/components/headers/screen-header"; import { AddHostMethodModal } from "@/components/add-host-method-modal"; import { AddHostModal } from "@/components/add-host-modal"; import { PairLinkModal } from "@/components/pair-link-modal"; import { KeyboardShortcutsSection } from "@/screens/settings/keyboard-shortcuts-section"; import { Button } from "@/components/ui/button"; import { SegmentedControl } from "@/components/ui/segmented-control"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { DesktopPermissionsSection } from "@/desktop/components/desktop-permissions-section"; import { IntegrationsSection } from "@/desktop/components/integrations-section"; import { isElectronRuntime } from "@/desktop/host"; import { useDesktopAppUpdater } from "@/desktop/updates/use-desktop-app-updater"; import { formatVersionWithPrefix } from "@/desktop/updates/desktop-updates"; import { resolveAppVersion } from "@/utils/app-version"; import { settingsStyles } from "@/styles/settings"; import { THINKING_TONE_NATIVE_PCM_BASE64 } from "@/utils/thinking-tone.native-pcm"; import { useVoiceAudioEngineOptional } from "@/contexts/voice-context"; import { HostPage, HostRenameButton } from "@/screens/settings/host-page"; import { useIsCompactFormFactor } from "@/constants/layout"; import { useLocalDaemonServerId } from "@/hooks/use-is-local-daemon"; import { buildHostOpenProjectRoute, buildSettingsHostRoute, buildSettingsSectionRoute, type SettingsSectionSlug, } from "@/utils/host-routes"; // --------------------------------------------------------------------------- // View model // --------------------------------------------------------------------------- export type SettingsView = | { kind: "root" } | { kind: "section"; section: SettingsSectionSlug } | { kind: "host"; serverId: string }; interface SidebarSectionItem { id: SettingsSectionSlug; label: string; icon: ComponentType<{ size: number; color: string }>; desktopOnly?: boolean; } const SIDEBAR_SECTION_ITEMS: SidebarSectionItem[] = [ { id: "general", label: "General", icon: Settings }, { id: "shortcuts", label: "Shortcuts", icon: Keyboard, desktopOnly: true }, { id: "integrations", label: "Integrations", icon: Puzzle, desktopOnly: true }, { id: "permissions", label: "Permissions", icon: Shield, desktopOnly: true }, { id: "diagnostics", label: "Diagnostics", icon: Stethoscope }, { id: "about", label: "About", icon: Info }, ]; // --------------------------------------------------------------------------- // Theme helpers (General section) // --------------------------------------------------------------------------- function ThemeIcon({ theme, size, color, }: { theme: AppSettings["theme"]; size: number; color: string; }) { switch (theme) { case "light": return ; case "dark": return ; case "auto": return ; default: return ; } } function ThemeSwatch({ color, size }: { color: string; size: number }) { return ( ); } const THEME_LABELS: Record = { light: "Light", dark: "Dark", zinc: "Zinc", midnight: "Midnight", claude: "Claude", ghostty: "Ghostty", auto: "System", }; // --------------------------------------------------------------------------- // Section components // --------------------------------------------------------------------------- interface GeneralSectionProps { settings: AppSettings; handleThemeChange: (theme: AppSettings["theme"]) => void; handleSendBehaviorChange: (behavior: SendBehavior) => void; } function GeneralSection({ settings, handleThemeChange, handleSendBehaviorChange, }: GeneralSectionProps) { const { theme } = useUnistyles(); const iconSize = theme.iconSize.md; const iconColor = theme.colors.foregroundMuted; return ( Theme [styles.themeTrigger, pressed && { opacity: 0.85 }]} > {THEME_LABELS[settings.theme]} {(["light", "dark", "auto"] as const).map((t) => ( handleThemeChange(t)} leading={} > {THEME_LABELS[t]} ))} {(["zinc", "midnight", "claude", "ghostty"] as const).map((t) => ( handleThemeChange(t)} leading={} > {THEME_LABELS[t]} ))} Default send What happens when you press Enter while the agent is running ); } interface DiagnosticsSectionProps { voiceAudioEngine: ReturnType; isPlaybackTestRunning: boolean; playbackTestResult: string | null; handlePlaybackTest: () => Promise; } function DiagnosticsSection({ voiceAudioEngine, isPlaybackTestRunning, playbackTestResult, handlePlaybackTest, }: DiagnosticsSectionProps) { return ( Test audio {playbackTestResult ? ( {playbackTestResult} ) : null} ); } interface AboutSectionProps { appVersionText: string; isDesktopApp: boolean; } function AboutSection({ appVersionText, isDesktopApp }: AboutSectionProps) { return ( Version {appVersionText} {isDesktopApp ? : null} ); } function DesktopAppUpdateRow() { const { isDesktopApp, statusText, availableUpdate, errorMessage, isChecking, isInstalling, checkForUpdates, installUpdate, } = useDesktopAppUpdater(); useFocusEffect( useCallback(() => { if (!isDesktopApp) { return undefined; } void checkForUpdates({ silent: true }); return undefined; }, [checkForUpdates, isDesktopApp]), ); const handleCheckForUpdates = useCallback(() => { if (!isDesktopApp) { return; } void checkForUpdates(); }, [checkForUpdates, isDesktopApp]); const handleInstallUpdate = useCallback(() => { if (!isDesktopApp) { return; } void confirmDialog({ title: "Install desktop update", message: "This updates Paseo on this computer.", confirmLabel: "Install update", cancelLabel: "Cancel", }) .then((confirmed) => { if (!confirmed) { return; } void installUpdate(); }) .catch((error) => { console.error("[Settings] Failed to open app update confirmation", error); Alert.alert("Error", "Unable to open the update confirmation dialog."); }); }, [installUpdate, isDesktopApp]); if (!isDesktopApp) { return null; } return ( App updates {statusText} {availableUpdate?.latestVersion ? ( Ready to install: {formatVersionWithPrefix(availableUpdate.latestVersion)} ) : null} {errorMessage ? {errorMessage} : null} ); } // --------------------------------------------------------------------------- // Sidebar // --------------------------------------------------------------------------- function useAnyOnlineHostServerId(serverIds: string[]): string | null { const runtime = getHostRuntimeStore(); return useSyncExternalStore( (onStoreChange) => runtime.subscribeAll(onStoreChange), () => { let firstOnlineServerId: string | null = null; let firstOnlineAt: string | null = null; for (const serverId of serverIds) { const snapshot = runtime.getSnapshot(serverId); const lastOnlineAt = snapshot?.lastOnlineAt ?? null; if (!isHostRuntimeConnected(snapshot) || !lastOnlineAt) { continue; } if (!firstOnlineAt || lastOnlineAt < firstOnlineAt) { firstOnlineAt = lastOnlineAt; firstOnlineServerId = serverId; } } return firstOnlineServerId; }, () => null, ); } interface SettingsSidebarProps { view: SettingsView; onSelectSection: (section: SettingsSectionSlug) => void; onSelectHost: (serverId: string) => void; onAddHost: () => void; onBackToWorkspace: () => void; layout: "desktop" | "mobile"; } function SettingsSidebar({ view, onSelectSection, onSelectHost, onAddHost, onBackToWorkspace, layout, }: SettingsSidebarProps) { const { theme } = useUnistyles(); const hosts = useHosts(); const localServerId = useLocalDaemonServerId(); const sortedHosts = useMemo(() => { if (!localServerId) { return hosts; } const localIndex = hosts.findIndex((host) => host.serverId === localServerId); if (localIndex <= 0) { return hosts; } const next = hosts.slice(); const [local] = next.splice(localIndex, 1); next.unshift(local); return next; }, [hosts, localServerId]); const isDesktopApp = isElectronRuntime(); const items = SIDEBAR_SECTION_ITEMS.filter((item) => !item.desktopOnly || isDesktopApp); const padding = useWindowControlsPadding("sidebar"); const isDesktop = layout === "desktop"; const containerStyle = isDesktop ? sidebarStyles.desktopContainer : sidebarStyles.mobileContainer; const selectedSectionId = view.kind === "section" ? view.section : null; const selectedServerId = view.kind === "host" ? view.serverId : null; return ( {isDesktop ? ( <> {padding.top > 0 ? : null} ) : null} {isDesktop ? ( ) : null} {items.map((item) => { const isSelected = selectedSectionId === item.id; const IconComponent = item.icon; return ( onSelectSection(item.id)} style={({ hovered = false }) => [ sidebarStyles.item, hovered && sidebarStyles.itemHovered, isSelected && sidebarStyles.itemSelected, ]} > {item.label} ); })} {sortedHosts.map((host) => { const isSelected = selectedServerId === host.serverId; const isLocal = localServerId !== null && host.serverId === localServerId; return ( onSelectHost(host.serverId)} testID={`settings-host-entry-${host.serverId}`} style={({ hovered = false }) => [ sidebarStyles.item, hovered && sidebarStyles.itemHovered, isSelected && sidebarStyles.itemSelected, ]} > {host.label} {isLocal ? ( Local ) : null} ); })} [ sidebarStyles.item, hovered && sidebarStyles.itemHovered, ]} > Add host ); } // --------------------------------------------------------------------------- // Main screen // --------------------------------------------------------------------------- export interface SettingsScreenProps { view: SettingsView; } export default function SettingsScreen({ view }: SettingsScreenProps) { const router = useRouter(); const { theme } = useUnistyles(); const voiceAudioEngine = useVoiceAudioEngineOptional(); const { settings, isLoading: settingsLoading, updateSettings } = useAppSettings(); const [isAddHostMethodVisible, setIsAddHostMethodVisible] = useState(false); const [isDirectHostVisible, setIsDirectHostVisible] = useState(false); const [isPasteLinkVisible, setIsPasteLinkVisible] = useState(false); const [isPlaybackTestRunning, setIsPlaybackTestRunning] = useState(false); const [playbackTestResult, setPlaybackTestResult] = useState(null); const isDesktopApp = isElectronRuntime(); const appVersion = resolveAppVersion(); const appVersionText = formatVersionWithPrefix(appVersion); const isCompactLayout = useIsCompactFormFactor(); const insets = useSafeAreaInsets(); const hosts = useHosts(); const hostServerIds = useMemo(() => hosts.map((host) => host.serverId), [hosts]); const anyOnlineServerId = useAnyOnlineHostServerId(hostServerIds); const handleThemeChange = useCallback( (nextTheme: AppSettings["theme"]) => { void updateSettings({ theme: nextTheme }); }, [updateSettings], ); const handleSendBehaviorChange = useCallback( (behavior: SendBehavior) => { void updateSettings({ sendBehavior: behavior }); }, [updateSettings], ); const handlePlaybackTest = useCallback(async () => { if (!voiceAudioEngine || isPlaybackTestRunning) { return; } setIsPlaybackTestRunning(true); setPlaybackTestResult(null); try { const bytes = Buffer.from(THINKING_TONE_NATIVE_PCM_BASE64, "base64"); await voiceAudioEngine.initialize(); voiceAudioEngine.stop(); await voiceAudioEngine.play({ type: "audio/pcm;rate=16000;bits=16", size: bytes.byteLength, async arrayBuffer() { return Uint8Array.from(bytes).buffer; }, }); setPlaybackTestResult(null); } catch (error) { const message = error instanceof Error ? error.message : String(error); console.error("[Settings] Playback test failed", error); setPlaybackTestResult(`Playback failed: ${message}`); } finally { setIsPlaybackTestRunning(false); } }, [isPlaybackTestRunning, voiceAudioEngine]); const closeAddConnectionFlow = useCallback(() => { setIsAddHostMethodVisible(false); setIsDirectHostVisible(false); setIsPasteLinkVisible(false); }, []); const goBackToAddConnectionMethods = useCallback(() => { setIsDirectHostVisible(false); setIsPasteLinkVisible(false); setIsAddHostMethodVisible(true); }, []); const handleAddHost = useCallback(() => { setIsAddHostMethodVisible(true); }, []); const handleHostAdded = useCallback( ({ serverId }: { serverId: string }) => { const target = buildSettingsHostRoute(serverId); if (isCompactLayout) { router.push(target); } else { router.replace(target); } }, [isCompactLayout, router], ); const handleSelectSection = useCallback( (section: SettingsSectionSlug) => { const target = buildSettingsSectionRoute(section); if (isCompactLayout) { router.push(target); } else { router.replace(target); } }, [isCompactLayout, router], ); const handleSelectHost = useCallback( (serverId: string) => { const target = buildSettingsHostRoute(serverId); if (isCompactLayout) { router.push(target); } else { router.replace(target); } }, [isCompactLayout, router], ); const handleScanQr = useCallback(() => { closeAddConnectionFlow(); router.push({ pathname: "/pair-scan", params: { source: "settings" }, }); }, [closeAddConnectionFlow, router]); const handleHostRemoved = useCallback(() => { const fallback = buildSettingsSectionRoute("general"); if (isCompactLayout) { router.replace("/settings"); } else { router.replace(fallback); } }, [isCompactLayout, router]); const handleBackToRoot = useCallback(() => { if (router.canGoBack()) { router.back(); } else { router.replace("/settings"); } }, [router]); const handleBackToWorkspace = useCallback(() => { if (router.canGoBack()) { router.back(); return; } if (anyOnlineServerId) { router.replace(buildHostOpenProjectRoute(anyOnlineServerId)); return; } router.replace("/"); }, [anyOnlineServerId, router]); const detailHeader = ((): { title: string; Icon: ComponentType<{ size: number; color: string }>; titleAccessory?: ReactNode; } | null => { if (view.kind === "host") { const host = hosts.find((h) => h.serverId === view.serverId); if (!host) return null; return { title: host.label, Icon: Server, titleAccessory: , }; } if (view.kind === "section") { const item = SIDEBAR_SECTION_ITEMS.find((s) => s.id === view.section); if (!item) return null; return { title: item.label, Icon: item.icon }; } return null; })(); const content = (() => { if (view.kind === "host") { return ; } if (view.kind === "section") { switch (view.section) { case "general": return ( ); case "shortcuts": return isDesktopApp ? : null; case "integrations": return isDesktopApp ? : null; case "permissions": return isDesktopApp ? : null; case "diagnostics": return ( ); case "about": return ; } } return null; })(); if (settingsLoading) { return ( Loading settings... ); } const addHostModals = ( <> { setIsAddHostMethodVisible(false); setIsDirectHostVisible(true); }} onPasteLink={() => { setIsAddHostMethodVisible(false); setIsPasteLinkVisible(true); }} onScanQr={handleScanQr} /> ); // Mobile root: full-screen sidebar-as-list. if (isCompactLayout && view.kind === "root") { return ( {addHostModals} ); } // Mobile detail: full-screen content with a back header that returns to the list. if (isCompactLayout) { return ( {content} {addHostModals} ); } // Desktop split view — mirrors AppContainer: sidebar owns the titlebar drag // region + traffic-light padding; detail pane renders whatever header the // selected section provides. return ( {detailHeader ? ( {detailHeader.title} {detailHeader.titleAccessory} } leftStyle={desktopStyles.detailLeft} /> ) : null} {content} {addHostModals} ); } // --------------------------------------------------------------------------- // Styles // --------------------------------------------------------------------------- const styles = StyleSheet.create((theme) => ({ loadingContainer: { flex: 1, backgroundColor: theme.colors.surface0, alignItems: "center", justifyContent: "center", }, loadingText: { color: theme.colors.foreground, fontSize: theme.fontSize.lg, }, container: { flex: 1, backgroundColor: theme.colors.surface0, }, scrollView: { flex: 1, }, content: { padding: theme.spacing[4], paddingTop: theme.spacing[6], width: "100%", maxWidth: 720, alignSelf: "center", }, aboutValue: { color: theme.colors.foregroundMuted, fontSize: theme.fontSize.sm, }, aboutErrorText: { color: theme.colors.palette.red[300], fontSize: theme.fontSize.xs, marginTop: theme.spacing[1], }, aboutUpdateActions: { flexDirection: "row", alignItems: "center", gap: theme.spacing[2], }, themeTrigger: { flexDirection: "row", alignItems: "center", gap: theme.spacing[1], paddingVertical: theme.spacing[1], paddingHorizontal: theme.spacing[2], borderRadius: theme.borderRadius.md, borderWidth: 1, borderColor: theme.colors.border, }, themeTriggerText: { color: theme.colors.foreground, fontSize: theme.fontSize.sm, }, })); const desktopStyles = StyleSheet.create((theme) => ({ row: { flex: 1, flexDirection: "row", }, contentPane: { flex: 1, }, detailLeft: { gap: theme.spacing[2], }, })); const sidebarStyles = StyleSheet.create((theme) => ({ desktopContainer: { width: 320, borderRightWidth: 1, borderRightColor: theme.colors.border, backgroundColor: theme.colors.surfaceSidebar, }, mobileContainer: { paddingVertical: theme.spacing[2], paddingHorizontal: theme.spacing[2], }, list: { paddingVertical: theme.spacing[2], paddingHorizontal: theme.spacing[2], gap: theme.spacing[1], }, item: { flexDirection: "row", alignItems: "center", gap: theme.spacing[2], minHeight: 36, paddingVertical: theme.spacing[2], paddingHorizontal: theme.spacing[2], borderRadius: theme.borderRadius.lg, }, itemHovered: { backgroundColor: theme.colors.surfaceSidebarHover, }, itemSelected: { backgroundColor: theme.colors.surfaceSidebarHover, }, label: { fontSize: theme.fontSize.base, color: theme.colors.foregroundMuted, fontWeight: theme.fontWeight.normal, flex: 1, }, localMarker: { fontSize: theme.fontSize.xs, color: theme.colors.foregroundMuted, paddingHorizontal: theme.spacing[2], paddingVertical: 2, borderWidth: 1, borderColor: theme.colors.border, borderRadius: theme.borderRadius.full, backgroundColor: theme.colors.surface3, }, }));