import { useState, useEffect, useRef, useCallback } from "react"; import type { MutableRefObject, ComponentType } from "react"; import { View, Text, ScrollView, Alert, Pressable } from "react-native"; import { router, useLocalSearchParams } from "expo-router"; import { useFocusEffect } from "@react-navigation/native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { Buffer } from "buffer"; import { Sun, Moon, Monitor, ChevronDown, Globe, Settings, RotateCw, Trash2, Server, Keyboard, Stethoscope, Info, Shield, Puzzle, Blocks, Smartphone, } from "lucide-react-native"; import { useAppSettings, type AppSettings, type SendBehavior } from "@/hooks/use-settings"; import { THEME_SWATCHES, type ThemeName } from "@/styles/theme"; import type { HostProfile, HostConnection } from "@/types/host-connection"; import { useHosts, useHostMutations } from "@/runtime/host-runtime"; import { formatConnectionStatus, getConnectionStatusTone } from "@/utils/daemons"; import { confirmDialog } from "@/utils/confirm-dialog"; import { MenuHeader } from "@/components/headers/menu-header"; import { useSessionStore } from "@/stores/session-store"; import { getHostRuntimeStore, isHostRuntimeConnected, useHostRuntimeClient, useHostRuntimeIsConnected, useHostRuntimeSnapshot, } from "@/runtime/host-runtime"; 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 { AdaptiveModalSheet, AdaptiveTextInput } from "@/components/adaptive-modal-sheet"; import { DesktopPermissionsSection } from "@/desktop/components/desktop-permissions-section"; import { IntegrationsSection } from "@/desktop/components/integrations-section"; import { LocalDaemonSection } from "@/desktop/components/desktop-updates-section"; import { PairDeviceSection } from "@/desktop/components/pair-device-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 { useIsLocalDaemon } from "@/hooks/use-is-local-daemon"; import { useDaemonConfig } from "@/hooks/use-daemon-config"; import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot"; import { useIsCompactFormFactor } from "@/constants/layout"; import { AGENT_PROVIDER_DEFINITIONS } from "@server/server/agent/provider-manifest"; import { getProviderIcon } from "@/components/provider-icons"; import { ProviderDiagnosticSheet } from "@/components/provider-diagnostic-sheet"; import { StatusBadge } from "@/components/ui/status-badge"; import { isWeb } from "@/constants/platform"; // --------------------------------------------------------------------------- // Section definitions // --------------------------------------------------------------------------- type SettingsSectionId = | "hosts" | "general" | "shortcuts" | "integrations" | "providers" | "diagnostics" | "about" | "permissions" | "daemon" | "pair-device"; interface SettingsSectionDef { id: SettingsSectionId; label: string; icon: ComponentType<{ size: number; color: string }>; } function getSettingsSections(context: { isDesktopApp: boolean }): SettingsSectionDef[] { const sections: SettingsSectionDef[] = [ { id: "hosts", label: "Hosts", icon: Server }, { id: "general", label: "General", icon: Settings }, { id: "permissions", label: "Permissions", icon: Shield }, ]; if (context.isDesktopApp) { sections.push( { id: "shortcuts", label: "Shortcuts", icon: Keyboard }, { id: "integrations", label: "Integrations", icon: Puzzle }, { id: "pair-device", label: "Pair device", icon: Smartphone }, { id: "daemon", label: "Daemon", icon: Settings }, { id: "providers", label: "Providers", icon: Blocks }, ); } sections.push( { id: "diagnostics", label: "Diagnostics", icon: Stethoscope }, { id: "about", label: "About", icon: Info }, ); return sections; } // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- const delay = (ms: number) => new Promise((resolve) => { const timeout = setTimeout(() => { clearTimeout(timeout); resolve(); }, ms); }); function formatHostConnectionLabel(connection: HostConnection): string { if (connection.type === "relay") { return `Relay (${connection.relayEndpoint})`; } if (connection.type === "directSocket") { return `Local (${connection.path})`; } if (connection.type === "directPipe") { return `Local (${connection.path})`; } return `TCP (${connection.endpoint})`; } function formatActiveConnectionBadge(input: { activeConnection: { type: HostConnection["type"]; display: string } | null; theme: ReturnType["theme"]; }) { const { activeConnection, theme } = input; if (!activeConnection) { return null; } if (activeConnection.type === "relay") { return { icon: , text: "Relay", }; } if (activeConnection.type === "directSocket") { return { icon: , text: "Local", }; } if (activeConnection.type === "directPipe") { return { icon: , text: "Local", }; } return { icon: , text: activeConnection.display, }; } function formatDaemonVersionBadge(version: string | null): string | null { const daemonVersion = version?.trim(); if (!daemonVersion) { return null; } if (daemonVersion.startsWith("v")) { return daemonVersion; } return `v${daemonVersion}`; } // --------------------------------------------------------------------------- // Section content components // --------------------------------------------------------------------------- interface HostsSectionProps { daemons: HostProfile[]; settings: AppSettings; routeServerId: string; theme: ReturnType["theme"]; handleEditDaemon: (profile: HostProfile) => void; setIsAddHostMethodVisible: (visible: boolean) => void; isAddHostMethodVisible: boolean; isDirectHostVisible: boolean; isPasteLinkVisible: boolean; closeAddConnectionFlow: () => void; goBackToAddConnectionMethods: () => void; setIsDirectHostVisible: (visible: boolean) => void; setIsPasteLinkVisible: (visible: boolean) => void; pendingRemoveHost: HostProfile | null; setPendingRemoveHost: (host: HostProfile | null) => void; isRemovingHost: boolean; setIsRemovingHost: (removing: boolean) => void; removeHost: (serverId: string) => Promise; editingDaemonLive: HostProfile | null; isSavingEdit: boolean; handleCloseEditDaemon: () => void; handleSaveEditDaemon: (label: string) => Promise; handleRemoveConnection: (serverId: string, connectionId: string) => Promise; handleRemoveDaemon: (profile: HostProfile) => void; restartConfirmationMessage: string; waitForCondition: ( predicate: () => boolean, timeoutMs: number, intervalMs?: number, ) => Promise; isMountedRef: MutableRefObject; } function HostsSection(props: HostsSectionProps) { const { theme } = useUnistyles(); return ( <> Hosts {props.daemons.length === 0 ? ( No hosts configured ) : ( props.daemons.map((daemon) => { return ( ); }) )} { props.setIsAddHostMethodVisible(false); props.setIsDirectHostVisible(true); }} onPasteLink={() => { props.setIsAddHostMethodVisible(false); props.setIsPasteLinkVisible(true); }} onScanQr={() => { const sourceServerId = props.routeServerId || undefined; props.closeAddConnectionFlow(); router.push({ pathname: "/pair-scan", params: { source: "settings", sourceServerId }, }); }} /> {props.pendingRemoveHost ? ( { if (props.isRemovingHost) return; props.setPendingRemoveHost(null); }} testID="remove-host-confirm-modal" > Remove {props.pendingRemoveHost.label}? This will delete its saved connections. ) : null} void props.handleSaveEditDaemon(label)} onRemoveConnection={props.handleRemoveConnection} onRemoveHost={props.handleRemoveDaemon} restartConfirmationMessage={props.restartConfirmationMessage} waitForCondition={props.waitForCondition} isScreenMountedRef={props.isMountedRef} /> ); } interface GeneralSectionProps { routeServerId: string; settings: AppSettings; handleThemeChange: (theme: AppSettings["theme"]) => void; handleSendBehaviorChange: (behavior: SendBehavior) => void; } 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", }; function GeneralSection({ routeServerId, settings, handleThemeChange, handleSendBehaviorChange, }: GeneralSectionProps) { const { theme } = useUnistyles(); const isConnected = useHostRuntimeIsConnected(routeServerId); const { config, patchConfig } = useDaemonConfig(routeServerId); const iconSize = theme.iconSize.md; const iconColor = theme.colors.foregroundMuted; return ( General 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 {routeServerId.length > 0 && isConnected ? ( Inject Paseo tools Automatically inject Paseo MCP tools into new agents { void patchConfig({ mcp: { injectIntoAgents: value === "on", }, }); }} options={[ { value: "on", label: "On" }, { value: "off", label: "Off" }, ]} /> ) : null} ); } interface ProvidersSectionProps { routeServerId: string; } function ProvidersSection({ routeServerId }: ProvidersSectionProps) { const { theme } = useUnistyles(); const isConnected = useHostRuntimeIsConnected(routeServerId); const { entries, isLoading, isFetching, refresh } = useProvidersSnapshot(routeServerId); const [diagnosticProvider, setDiagnosticProvider] = useState(null); const hasServer = routeServerId.length > 0; return ( <> Providers {hasServer && isConnected ? ( Refresh ) : null} {!hasServer || !isConnected ? ( Connect to a host to see providers ) : isLoading ? ( Loading... ) : ( {AGENT_PROVIDER_DEFINITIONS.map((def) => { const entry = entries?.find((e) => e.provider === def.id); const status = entry?.status ?? "unavailable"; const ProviderIcon = getProviderIcon(def.id); const providerError = status === "error" && typeof entry?.error === "string" && entry.error.trim().length > 0 ? entry.error.trim() : null; return ( {def.label} {providerError ? ( {providerError} ) : null} ); })} )} {diagnosticProvider ? ( setDiagnosticProvider(null)} serverId={routeServerId} /> ) : null} ); } interface DiagnosticsSectionProps { voiceAudioEngine: ReturnType; isPlaybackTestRunning: boolean; playbackTestResult: string | null; handlePlaybackTest: () => Promise; } function DiagnosticsSection({ voiceAudioEngine, isPlaybackTestRunning, playbackTestResult, handlePlaybackTest, }: DiagnosticsSectionProps) { return ( Diagnostics Test audio {playbackTestResult ? ( {playbackTestResult} ) : null} ); } interface AboutSectionProps { appVersionText: string; isDesktopApp: boolean; } function AboutSection({ appVersionText, isDesktopApp }: AboutSectionProps) { return ( About Version {appVersionText} {isDesktopApp ? : null} ); } // --------------------------------------------------------------------------- // Section content switcher // --------------------------------------------------------------------------- interface SettingsSectionContentProps { sectionId: SettingsSectionId; hostsProps: HostsSectionProps; generalProps: GeneralSectionProps; providersProps: ProvidersSectionProps; diagnosticsProps: DiagnosticsSectionProps; aboutProps: AboutSectionProps; appVersion: string | null; isLocalDaemon: boolean; isDesktopApp: boolean; } function SettingsSectionContent({ sectionId, hostsProps, generalProps, providersProps, diagnosticsProps, aboutProps, appVersion, isLocalDaemon, isDesktopApp, }: SettingsSectionContentProps) { switch (sectionId) { case "hosts": return ; case "general": return ; case "shortcuts": return ; case "providers": return ; case "diagnostics": return ; case "about": return ; case "integrations": return isDesktopApp ? : null; case "permissions": return isDesktopApp ? : null; case "pair-device": return isDesktopApp ? : null; case "daemon": return isDesktopApp ? ( ) : null; } } // --------------------------------------------------------------------------- // Layouts // --------------------------------------------------------------------------- interface SettingsLayoutProps { sections: SettingsSectionDef[]; sectionContentProps: Omit; } function SettingsMobileLayout({ sections, sectionContentProps }: SettingsLayoutProps) { const insets = useSafeAreaInsets(); return ( {sections.map((section) => ( ))} ); } function SettingsDesktopLayout({ sections, sectionContentProps }: SettingsLayoutProps) { const { theme } = useUnistyles(); const insets = useSafeAreaInsets(); const [selectedSectionId, setSelectedSectionId] = useState("hosts"); return ( {sections.map((section) => { const isSelected = section.id === selectedSectionId; const IconComponent = section.icon; const showSeparator = section.id === "integrations" || section.id === "providers"; return ( {showSeparator ? : null} setSelectedSectionId(section.id)} accessibilityRole="button" accessibilityState={{ selected: isSelected }} > {section.label} ); })} ); } // --------------------------------------------------------------------------- // Desktop app update row (unchanged) // --------------------------------------------------------------------------- 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} ); } // --------------------------------------------------------------------------- // Main screen // --------------------------------------------------------------------------- export default function SettingsScreen() { const { theme } = useUnistyles(); const voiceAudioEngine = useVoiceAudioEngineOptional(); const params = useLocalSearchParams<{ editHost?: string; serverId?: string }>(); const routeServerId = typeof params.serverId === "string" ? params.serverId.trim() : ""; const { settings, isLoading: settingsLoading, updateSettings } = useAppSettings(); const { daemons, renameHost, removeHost, removeConnection } = { daemons: useHosts(), ...useHostMutations(), }; const [isAddHostMethodVisible, setIsAddHostMethodVisible] = useState(false); const [isDirectHostVisible, setIsDirectHostVisible] = useState(false); const [isPasteLinkVisible, setIsPasteLinkVisible] = useState(false); const [pendingRemoveHost, setPendingRemoveHost] = useState(null); const [isRemovingHost, setIsRemovingHost] = useState(false); const [editingDaemon, setEditingDaemon] = useState(null); const [isSavingEdit, setIsSavingEdit] = useState(false); const [isPlaybackTestRunning, setIsPlaybackTestRunning] = useState(false); const [playbackTestResult, setPlaybackTestResult] = useState(null); const isLoading = settingsLoading; const isMountedRef = useRef(true); const lastHandledEditHostRef = useRef(null); const isDesktopApp = isElectronRuntime(); const isLocalDaemon = useIsLocalDaemon(routeServerId); const appVersion = resolveAppVersion(); const appVersionText = formatVersionWithPrefix(appVersion); const editingServerId = editingDaemon?.serverId ?? null; const editingDaemonLive = editingServerId ? (daemons.find((daemon) => daemon.serverId === editingServerId) ?? null) : null; useEffect(() => { return () => { isMountedRef.current = false; }; }, []); // Keep the edit modal bound to live registry state. useEffect(() => { if (!editingServerId) return; if (editingDaemonLive) return; setEditingDaemon(null); }, [editingDaemonLive, editingServerId]); const waitForCondition = useCallback( async (predicate: () => boolean, timeoutMs: number, intervalMs = 250) => { const deadline = Date.now() + timeoutMs; while (Date.now() < deadline) { if (!isMountedRef.current) { return false; } if (predicate()) { return true; } await delay(intervalMs); } return predicate(); }, [], ); const handleEditDaemon = useCallback((profile: HostProfile) => { setEditingDaemon(profile); }, []); const handleCloseEditDaemon = useCallback(() => { if (isSavingEdit) return; setEditingDaemon(null); }, [isSavingEdit]); const closeAddConnectionFlow = useCallback(() => { setIsAddHostMethodVisible(false); setIsDirectHostVisible(false); setIsPasteLinkVisible(false); }, []); const goBackToAddConnectionMethods = useCallback(() => { setIsDirectHostVisible(false); setIsPasteLinkVisible(false); setIsAddHostMethodVisible(true); }, []); useEffect(() => { const editHost = typeof params.editHost === "string" ? params.editHost.trim() : ""; if (!editHost) return; if (lastHandledEditHostRef.current === editHost) return; const profile = daemons.find((daemon) => daemon.serverId === editHost) ?? null; if (!profile) return; lastHandledEditHostRef.current = editHost; handleEditDaemon(profile); }, [daemons, handleEditDaemon, params.editHost]); const handleSaveEditDaemon = useCallback( async (nextLabelRaw: string) => { if (!editingServerId) return; if (isSavingEdit) return; const nextLabel = nextLabelRaw.trim(); if (!nextLabel) { Alert.alert("Label required", "Enter a label for this host."); return; } try { setIsSavingEdit(true); await renameHost(editingServerId, nextLabel); handleCloseEditDaemon(); } catch (error) { console.error("[Settings] Failed to rename host", error); Alert.alert("Error", "Unable to save host"); } finally { setIsSavingEdit(false); } }, [editingServerId, handleCloseEditDaemon, isSavingEdit, renameHost], ); const handleRemoveConnection = useCallback( async (serverId: string, connectionId: string) => { await removeConnection(serverId, connectionId); }, [removeConnection], ); const handleRemoveDaemon = useCallback((profile: HostProfile) => { setEditingDaemon(null); setPendingRemoveHost(profile); }, []); const handleThemeChange = useCallback( (newTheme: AppSettings["theme"]) => { void updateSettings({ theme: newTheme }); }, [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 isCompactLayout = useIsCompactFormFactor(); const sections = getSettingsSections({ isDesktopApp }); const hostsProps: HostsSectionProps = { daemons, settings, routeServerId, theme, handleEditDaemon, setIsAddHostMethodVisible, isAddHostMethodVisible, isDirectHostVisible, isPasteLinkVisible, closeAddConnectionFlow, goBackToAddConnectionMethods, setIsDirectHostVisible, setIsPasteLinkVisible, pendingRemoveHost, setPendingRemoveHost, isRemovingHost, setIsRemovingHost, removeHost, editingDaemonLive, isSavingEdit, handleCloseEditDaemon, handleSaveEditDaemon, handleRemoveConnection, handleRemoveDaemon, restartConfirmationMessage: "This will restart the daemon. The app will reconnect automatically.", waitForCondition, isMountedRef, }; const generalProps: GeneralSectionProps = { routeServerId, settings, handleThemeChange, handleSendBehaviorChange, }; const diagnosticsProps: DiagnosticsSectionProps = { voiceAudioEngine, isPlaybackTestRunning, playbackTestResult, handlePlaybackTest, }; const aboutProps: AboutSectionProps = { appVersionText, isDesktopApp, }; const providersProps: ProvidersSectionProps = { routeServerId, }; const sectionContentProps: Omit = { hostsProps, generalProps, providersProps, diagnosticsProps, aboutProps, appVersion, isLocalDaemon, isDesktopApp, }; if (isLoading) { return ( Loading settings... ); } return ( {isCompactLayout ? ( ) : ( )} ); } // --------------------------------------------------------------------------- // HostDetailModal (unchanged) // --------------------------------------------------------------------------- interface HostDetailModalProps { visible: boolean; host: HostProfile | null; isSaving: boolean; onClose: () => void; onSave: (label: string) => void; onRemoveConnection: (serverId: string, connectionId: string) => Promise; onRemoveHost: (host: HostProfile) => void; restartConfirmationMessage: string; waitForCondition: ( predicate: () => boolean, timeoutMs: number, intervalMs?: number, ) => Promise; isScreenMountedRef: MutableRefObject; } function HostDetailModal({ visible, host, isSaving, onClose, onSave, onRemoveConnection, onRemoveHost, restartConfirmationMessage, waitForCondition, isScreenMountedRef, }: HostDetailModalProps) { const { theme } = useUnistyles(); const [draftLabel, setDraftLabel] = useState(""); const [pendingRemoveConnection, setPendingRemoveConnection] = useState<{ serverId: string; connectionId: string; title: string; } | null>(null); const [isRemovingConnection, setIsRemovingConnection] = useState(false); // Read per-connection probes from host runtime snapshots. const connections = host?.connections ?? []; // Restart logic (moved from DaemonCard) const runtimeSnapshot = useHostRuntimeSnapshot(host?.serverId ?? ""); const runtimeClient = useHostRuntimeClient(host?.serverId ?? ""); const isConnected = useHostRuntimeIsConnected(host?.serverId ?? ""); const runtime = getHostRuntimeStore(); const daemonClient = runtimeClient; const daemonVersion = useSessionStore((state) => host ? (state.sessions[host.serverId]?.serverInfo?.version ?? null) : null, ); const probeByConnectionId = runtimeSnapshot?.probeByConnectionId ?? new Map(); const connectionStatus = runtimeSnapshot?.connectionStatus ?? "connecting"; const activeConnection = runtimeSnapshot?.activeConnection ?? null; const lastError = runtimeSnapshot?.lastError ?? null; const [isRestarting, setIsRestarting] = useState(false); const isHostConnected = useCallback(() => { if (!host) { return false; } return isHostRuntimeConnected(runtime.getSnapshot(host.serverId)); }, [host, runtime]); const waitForDaemonRestart = useCallback(async () => { const disconnectTimeoutMs = 7000; const reconnectTimeoutMs = 30000; if (isHostConnected()) { await waitForCondition(() => !isHostConnected(), disconnectTimeoutMs); } const reconnected = await waitForCondition(() => isHostConnected(), reconnectTimeoutMs); if (isScreenMountedRef.current) { setIsRestarting(false); if (!reconnected && host) { Alert.alert( "Unable to reconnect", `${host.label} did not come back online. Please verify it restarted.`, ); } } }, [host, isHostConnected, isScreenMountedRef, waitForCondition]); const beginServerRestart = useCallback(() => { if (!daemonClient || !host) return; if (!isHostConnected()) { Alert.alert( "Host offline", "This host is offline. Paseo reconnects automatically—wait until it's back online before restarting.", ); return; } setIsRestarting(true); void daemonClient.restartServer(`settings_daemon_restart_${host.serverId}`).catch((error) => { console.error(`[Settings] Failed to restart daemon ${host.label}`, error); if (!isScreenMountedRef.current) return; setIsRestarting(false); Alert.alert( "Error", "Failed to send the restart request. Paseo reconnects automatically—try again once the host shows as online.", ); }); void waitForDaemonRestart(); }, [daemonClient, host, isHostConnected, isScreenMountedRef, waitForDaemonRestart]); const handleRestartPress = useCallback(() => { if (!daemonClient || !host) { Alert.alert( "Host unavailable", "This host is not connected. Wait for it to come online before restarting.", ); return; } void confirmDialog({ title: `Restart ${host.label}`, message: restartConfirmationMessage, confirmLabel: "Restart", cancelLabel: "Cancel", destructive: true, }) .then((confirmed) => { if (!confirmed) { return; } beginServerRestart(); }) .catch((error) => { console.error(`[Settings] Failed to open restart confirmation for ${host.label}`, error); Alert.alert("Error", "Unable to open the restart confirmation dialog."); }); }, [beginServerRestart, daemonClient, host, restartConfirmationMessage]); // Status display const statusLabel = formatConnectionStatus(connectionStatus); const statusTone = getConnectionStatusTone(connectionStatus); const statusColor = statusTone === "success" ? theme.colors.palette.green[400] : statusTone === "warning" ? theme.colors.palette.amber[500] : statusTone === "error" ? theme.colors.destructive : theme.colors.foregroundMuted; const statusPillBg = statusTone === "success" ? "rgba(74, 222, 128, 0.1)" : statusTone === "warning" ? "rgba(245, 158, 11, 0.1)" : statusTone === "error" ? "rgba(248, 113, 113, 0.1)" : "rgba(161, 161, 170, 0.1)"; const connectionBadge = (() => { return formatActiveConnectionBadge({ activeConnection, theme, }); })(); const versionBadgeText = formatDaemonVersionBadge(daemonVersion); const connectionError = typeof lastError === "string" && lastError.trim().length > 0 ? lastError.trim() : null; const handleDraftLabelChange = useCallback((nextValue: string) => { setDraftLabel(nextValue); }, []); useEffect(() => { if (!visible || !host) return; // Initialize once per modal open / host switch; keep user edits fully local while typing. setDraftLabel(host.label ?? ""); }, [visible, host?.serverId]); useEffect(() => { if (!visible) { setIsRestarting(false); setDraftLabel(""); } }, [visible]); return ( <> {/* Status row */} {statusLabel} {connectionBadge ? ( {connectionBadge.icon} {connectionBadge.text} ) : null} {versionBadgeText ? ( {versionBadgeText} ) : null} {connectionError ? ( {connectionError} ) : null} {/* Label */} Label {/* Connections */} {host ? ( Connections {host.connections.map((conn) => { const probe = probeByConnectionId.get(conn.id); return ( { const title = formatHostConnectionLabel(conn); setPendingRemoveConnection({ serverId: host.serverId, connectionId: conn.id, title, }); }} /> ); })} ) : null} {/* Save/Cancel + Advanced */} [styles.advancedTrigger, pressed && { opacity: 0.85 }]} > Advanced } status={isRestarting ? "pending" : "idle"} pendingLabel="Restarting..." disabled={!daemonClient || !isConnected} > Restart daemon { if (host) onRemoveHost(host); }} leading={} > Remove host {/* Remove connection confirmation */} {pendingRemoveConnection ? ( { if (isRemovingConnection) return; setPendingRemoveConnection(null); }} testID="remove-connection-confirm-modal" > Remove {pendingRemoveConnection.title}? This cannot be undone. ) : null} ); } // --------------------------------------------------------------------------- // ConnectionRow (unchanged) // --------------------------------------------------------------------------- function ConnectionRow({ connection, latencyMs, latencyLoading, latencyError, onRemove, }: { connection: HostConnection; latencyMs: number | null | undefined; latencyLoading: boolean; latencyError: boolean; onRemove: () => void; }) { const { theme } = useUnistyles(); const title = formatHostConnectionLabel(connection); const latencyText = (() => { if (latencyLoading) return "..."; if (latencyError) return "Timeout"; if (latencyMs != null) return `${latencyMs}ms`; return "\u2014"; })(); const latencyColor = latencyError ? theme.colors.palette.red[300] : theme.colors.foregroundMuted; return ( {title} {latencyText} ); } // --------------------------------------------------------------------------- // DaemonCard (unchanged) // --------------------------------------------------------------------------- interface DaemonCardProps { daemon: HostProfile; onOpenSettings: (daemon: HostProfile) => void; } function DaemonCard({ daemon, onOpenSettings }: DaemonCardProps) { const { theme } = useUnistyles(); const snapshot = useHostRuntimeSnapshot(daemon.serverId); const connectionStatus = snapshot?.connectionStatus ?? "connecting"; const activeConnection = snapshot?.activeConnection ?? null; const lastError = snapshot?.lastError ?? null; const daemonVersion = useSessionStore( useCallback( (state) => state.sessions[daemon.serverId]?.serverInfo?.version ?? null, [daemon.serverId], ), ); const statusLabel = formatConnectionStatus(connectionStatus); const statusTone = getConnectionStatusTone(connectionStatus); const statusColor = statusTone === "success" ? theme.colors.palette.green[400] : statusTone === "warning" ? theme.colors.palette.amber[500] : statusTone === "error" ? theme.colors.destructive : theme.colors.foregroundMuted; const badgeText = statusLabel; const connectionError = typeof lastError === "string" && lastError.trim().length > 0 ? lastError.trim() : null; const statusPillBg = statusTone === "success" ? "rgba(74, 222, 128, 0.1)" : statusTone === "warning" ? "rgba(245, 158, 11, 0.1)" : statusTone === "error" ? "rgba(248, 113, 113, 0.1)" : "rgba(161, 161, 170, 0.1)"; const connectionBadge = (() => { return formatActiveConnectionBadge({ activeConnection, theme, }); })(); const versionBadgeText = formatDaemonVersionBadge(daemonVersion); return ( {daemon.label} {isWeb ? ( {badgeText} ) : null} {connectionBadge ? ( {connectionBadge.icon} {isWeb ? ( {connectionBadge.text} ) : null} ) : null} {versionBadgeText ? ( {versionBadgeText} ) : null} onOpenSettings(daemon)} testID={`daemon-card-settings-${daemon.serverId}`} accessibilityLabel={`Open settings for ${daemon.label}`} accessibilityRole="button" > {({ hovered = false, pressed = false }) => ( )} {connectionError ? {connectionError} : null} ); } // --------------------------------------------------------------------------- // 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", }, label: { color: theme.colors.foregroundMuted, fontSize: theme.fontSize.xs, fontWeight: theme.fontWeight.normal, marginBottom: theme.spacing[2], }, input: { backgroundColor: theme.colors.surface0, color: theme.colors.foreground, padding: theme.spacing[3], borderRadius: theme.borderRadius.md, borderWidth: 1, borderColor: theme.colors.border, fontSize: theme.fontSize.base, }, // Host card styles hostCard: { marginBottom: theme.spacing[3], overflow: "hidden", }, hostCardContent: { padding: theme.spacing[4], gap: theme.spacing[2], }, hostHeaderRow: { flexDirection: "row", alignItems: "center", gap: theme.spacing[3], }, hostHeaderRight: { flexDirection: "row", alignItems: "center", gap: theme.spacing[1], flexShrink: 0, }, hostLabel: { color: theme.colors.foreground, fontSize: theme.fontSize.base, fontWeight: theme.fontWeight.normal, flex: 1, flexShrink: 1, }, hostError: { color: theme.colors.palette.red[300], fontSize: theme.fontSize.xs, }, // Status pill statusPill: { flexDirection: "row", alignItems: "center", gap: 6, paddingHorizontal: theme.spacing[2], paddingVertical: 4, borderRadius: theme.borderRadius.full, }, statusPillMobile: { alignItems: "center", justifyContent: "center", width: 14, height: 14, borderRadius: 7, }, statusDot: { width: 6, height: 6, borderRadius: theme.borderRadius.full, }, statusText: { fontSize: theme.fontSize.xs, fontWeight: theme.fontWeight.normal, }, connectionPill: { flexDirection: "row", alignItems: "center", gap: 6, paddingHorizontal: theme.spacing[2], paddingVertical: 4, borderRadius: theme.borderRadius.full, borderWidth: 1, borderColor: theme.colors.border, backgroundColor: theme.colors.surface3, maxWidth: 170, }, connectionPillMobile: { flexDirection: "row", alignItems: "center", borderRadius: theme.borderRadius.full, }, connectionText: { fontSize: theme.fontSize.xs, fontWeight: theme.fontWeight.normal, color: theme.colors.foregroundMuted, flexShrink: 1, }, versionPill: { flexDirection: "row", alignItems: "center", paddingHorizontal: theme.spacing[2], paddingVertical: 4, borderRadius: theme.borderRadius.full, borderWidth: 1, borderColor: theme.colors.border, backgroundColor: theme.colors.surface3, maxWidth: 200, }, hostSettingsButton: { width: 28, height: 28, paddingVertical: 0, paddingHorizontal: 0, borderRadius: theme.borderRadius.md, gap: 0, marginLeft: theme.spacing[2], alignItems: "center", justifyContent: "center", }, advancedTrigger: { flexDirection: "row", alignItems: "center", gap: theme.spacing[2], paddingVertical: theme.spacing[2], paddingHorizontal: theme.spacing[3], borderRadius: theme.borderRadius.md, borderWidth: 1, borderColor: theme.colors.border, backgroundColor: "transparent", }, advancedTriggerText: { color: theme.colors.foreground, fontSize: theme.fontSize.sm, fontWeight: theme.fontWeight.medium, }, 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, }, disabled: { opacity: theme.opacity[50], }, testResultText: { fontSize: theme.fontSize.xs, }, // Add host button addButton: { borderStyle: "dashed", }, addButtonText: { color: theme.colors.foregroundMuted, }, // Add/Edit form formCard: { padding: theme.spacing[4], marginBottom: theme.spacing[3], gap: theme.spacing[4], }, formTitle: { color: theme.colors.foreground, fontSize: theme.fontSize.base, fontWeight: theme.fontWeight.normal, }, formField: { gap: theme.spacing[2], }, formActionsRow: { flexDirection: "row", justifyContent: "flex-end", gap: theme.spacing[2], }, formButton: { paddingVertical: theme.spacing[2], paddingHorizontal: theme.spacing[4], borderRadius: theme.borderRadius.md, borderWidth: 1, borderColor: theme.colors.border, }, formButtonPrimary: { backgroundColor: theme.colors.palette.blue[500], borderColor: theme.colors.palette.blue[500], }, formButtonText: { color: theme.colors.foreground, fontSize: theme.fontSize.sm, fontWeight: theme.fontWeight.normal, }, formButtonPrimaryText: { color: theme.colors.palette.white, }, // Audio settings card audioCard: { overflow: "hidden", }, audioRow: { flexDirection: "row", alignItems: "center", justifyContent: "space-between", paddingVertical: theme.spacing[4], paddingHorizontal: theme.spacing[4], }, audioRowBorder: { borderTopWidth: 1, borderTopColor: theme.colors.border, }, audioRowContent: { flex: 1, marginRight: theme.spacing[3], }, audioRowTitle: { color: theme.colors.foreground, fontSize: theme.fontSize.base, }, audioRowSubtitle: { color: theme.colors.mutedForeground, fontSize: theme.fontSize.sm, marginTop: theme.spacing[1], }, providerActions: { flexDirection: "row", alignItems: "center", gap: theme.spacing[2], }, aboutValue: { color: theme.colors.foregroundMuted, fontSize: theme.fontSize.sm, fontWeight: theme.fontWeight.normal, }, aboutHintText: { color: theme.colors.foregroundMuted, fontSize: theme.fontSize.xs, marginTop: 2, }, aboutErrorText: { color: theme.colors.palette.red[300], fontSize: theme.fontSize.xs, marginTop: theme.spacing[1], }, aboutUpdateActions: { flexDirection: "row", alignItems: "center", gap: theme.spacing[2], }, // Empty state emptyCard: { padding: theme.spacing[4], marginBottom: theme.spacing[3], }, emptyText: { color: theme.colors.foregroundMuted, fontSize: theme.fontSize.sm, textAlign: "center", }, // Dev section devCard: { overflow: "hidden", }, devButton: { flexDirection: "row", alignItems: "center", gap: theme.spacing[3], paddingVertical: theme.spacing[4], paddingHorizontal: theme.spacing[4], }, devButtonBorder: { borderTopWidth: 1, borderTopColor: theme.colors.border, }, devButtonContent: { flex: 1, }, devButtonTitle: { color: theme.colors.foreground, fontSize: theme.fontSize.base, }, devButtonDescription: { color: theme.colors.foregroundMuted, fontSize: theme.fontSize.sm, marginTop: 2, }, })); const desktopStyles = StyleSheet.create((theme) => ({ row: { flex: 1, flexDirection: "row", }, sidebar: { width: 200, borderRightWidth: 1, borderRightColor: theme.colors.border, paddingVertical: theme.spacing[4], paddingHorizontal: theme.spacing[2], gap: theme.spacing[1], }, sidebarItem: { flexDirection: "row", alignItems: "center", gap: theme.spacing[2], paddingVertical: theme.spacing[2], paddingHorizontal: theme.spacing[3], borderRadius: theme.borderRadius.md, }, sidebarLabel: { fontSize: theme.fontSize.sm, color: theme.colors.foregroundMuted, fontWeight: theme.fontWeight.normal, }, sidebarSeparator: { borderBottomWidth: 1, borderBottomColor: theme.colors.border, marginVertical: theme.spacing[2], marginHorizontal: theme.spacing[3], }, contentPane: { flex: 1, }, }));