import { useCallback, useEffect, useRef, useState } from "react"; import { Alert, Pressable, Text, TextInput, View } from "react-native"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { ChevronRight, Globe, Monitor, Pencil, RotateCw, Trash2 } from "lucide-react-native"; import type { HostConnection, HostProfile } from "@/types/host-connection"; import { getHostRuntimeStore, isHostRuntimeConnected, useHostRuntimeClient, useHostRuntimeIsConnected, useHostRuntimeSnapshot, useHostMutations, useHosts, } from "@/runtime/host-runtime"; import { useSessionStore } from "@/stores/session-store"; import { formatConnectionStatus, getConnectionStatusTone } from "@/utils/daemons"; import { confirmDialog } from "@/utils/confirm-dialog"; import { settingsStyles } from "@/styles/settings"; import { Button } from "@/components/ui/button"; import { SegmentedControl } from "@/components/ui/segmented-control"; import { AdaptiveModalSheet } from "@/components/adaptive-modal-sheet"; import { useDaemonConfig } from "@/hooks/use-daemon-config"; import { useIsLocalDaemon } from "@/hooks/use-is-local-daemon"; import { SettingsSection } from "@/screens/settings/settings-section"; import { ProvidersSection } from "@/screens/settings/providers-section"; import { PairDeviceModal } from "@/desktop/components/pair-device-modal"; import { LocalDaemonSection } from "@/desktop/components/desktop-updates-section"; const RESTART_CONFIRMATION_MESSAGE = "This will restart the daemon. Agents running on it will keep going; the app will reconnect automatically."; function formatHostConnectionLabel(connection: HostConnection): string { if (connection.type === "relay") { return `Relay (${connection.relayEndpoint})`; } if (connection.type === "directSocket" || connection.type === "directPipe") { return `Local (${connection.path})`; } return `TCP (${connection.endpoint})`; } function formatActiveConnectionBadge( activeConnection: { type: HostConnection["type"]; display: string } | null, theme: ReturnType["theme"], ): { icon: React.ReactNode; text: string } | null { if (!activeConnection) return null; if (activeConnection.type === "relay") { return { icon: , text: "Relay", }; } if (activeConnection.type === "directSocket" || activeConnection.type === "directPipe") { return { icon: , text: "Local", }; } return { icon: , text: activeConnection.display, }; } function formatDaemonVersionBadge(version: string | null): string | null { const trimmed = version?.trim(); if (!trimmed) return null; return trimmed.startsWith("v") ? trimmed : `v${trimmed}`; } export interface HostPageProps { serverId: string; onHostRemoved?: () => void; } export function HostPage({ serverId, onHostRemoved }: HostPageProps) { const daemons = useHosts(); const host = daemons.find((entry) => entry.serverId === serverId) ?? null; const { theme } = useUnistyles(); const snapshot = useHostRuntimeSnapshot(serverId); const isLocalDaemon = useIsLocalDaemon(serverId); const daemonVersion = useSessionStore( (state) => state.sessions[serverId]?.serverInfo?.version ?? null, ); const connectionStatus = snapshot?.connectionStatus ?? "connecting"; const activeConnection = snapshot?.activeConnection ?? null; const lastError = snapshot?.lastError ?? null; 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 = formatActiveConnectionBadge(activeConnection, theme); const versionBadgeText = formatDaemonVersionBadge(daemonVersion); const connectionError = typeof lastError === "string" && lastError.trim().length > 0 ? lastError.trim() : null; if (!host) { return ( Host not found ); } return ( {statusLabel} {connectionBadge ? ( {connectionBadge.icon} {connectionBadge.text} ) : null} {versionBadgeText ? ( {versionBadgeText} ) : null} {connectionError ? {connectionError} : null} ); } export function HostRenameButton({ host }: { host: HostProfile }) { const { theme } = useUnistyles(); const { renameHost } = useHostMutations(); const [isEditing, setIsEditing] = useState(false); const [draftLabel, setDraftLabel] = useState(host.label ?? ""); const [isSaving, setIsSaving] = useState(false); const inputRef = useRef(null); useEffect(() => { setDraftLabel(host.label ?? ""); }, [host.serverId, host.label]); useEffect(() => { if (isEditing) { const timeout = setTimeout(() => inputRef.current?.focus(), 50); return () => clearTimeout(timeout); } return undefined; }, [isEditing]); const handleSave = useCallback(async () => { const nextLabel = draftLabel.trim(); if (!nextLabel) { Alert.alert("Label required", "Enter a label for this host."); return; } if (isSaving) return; if (nextLabel === host.label.trim()) { setIsEditing(false); return; } try { setIsSaving(true); await renameHost(host.serverId, nextLabel); setIsEditing(false); } catch (error) { console.error("[HostPage] Failed to rename host", error); Alert.alert("Error", "Unable to save host"); } finally { setIsSaving(false); } }, [draftLabel, host.label, host.serverId, isSaving, renameHost]); const handleCancel = useCallback(() => { if (isSaving) return; setDraftLabel(host.label ?? ""); setIsEditing(false); }, [host.label, isSaving]); return ( <> { setDraftLabel(host.label ?? ""); setIsEditing(true); }} hitSlop={8} style={styles.identityEditButton} accessibilityRole="button" accessibilityLabel="Edit label" testID="host-page-label-edit-button" > void handleSave()} style={styles.renameInput} testID="host-page-label-input" /> ); } function ConnectionsSection({ host }: { host: HostProfile }) { const { removeConnection } = useHostMutations(); const snapshot = useHostRuntimeSnapshot(host.serverId); const probeByConnectionId = snapshot?.probeByConnectionId ?? new Map(); const [pendingRemoveConnection, setPendingRemoveConnection] = useState<{ connectionId: string; title: string; } | null>(null); const [isRemovingConnection, setIsRemovingConnection] = useState(false); return ( {host.connections.map((conn, index) => { const probe = probeByConnectionId.get(conn.id); return ( 0} latencyMs={probe?.status === "available" ? probe.latencyMs : undefined} latencyLoading={!probe || probe.status === "pending"} latencyError={probe?.status === "unavailable"} onRemove={() => { setPendingRemoveConnection({ connectionId: conn.id, title: formatHostConnectionLabel(conn), }); }} /> ); })} {pendingRemoveConnection ? ( { if (isRemovingConnection) return; setPendingRemoveConnection(null); }} testID="remove-connection-confirm-modal" > Remove {pendingRemoveConnection.title}? This cannot be undone. ) : null} ); } function ConnectionRow({ connection, showBorder, latencyMs, latencyLoading, latencyError, onRemove, }: { connection: HostConnection; showBorder: boolean; 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} ); } function DaemonSection({ host, isLocalDaemon }: { host: HostProfile; isLocalDaemon: boolean }) { return ( <> {isLocalDaemon ? ( ) : null} {isLocalDaemon ? : null} ); } const delay = (ms: number) => new Promise((resolve) => { const timeout = setTimeout(() => { clearTimeout(timeout); resolve(); }, ms); }); function RestartDaemonCard({ host }: { host: HostProfile }) { const { theme } = useUnistyles(); const daemonClient = useHostRuntimeClient(host.serverId); const isConnected = useHostRuntimeIsConnected(host.serverId); const runtime = getHostRuntimeStore(); const [isRestarting, setIsRestarting] = useState(false); const isMountedRef = useRef(true); useEffect(() => { return () => { isMountedRef.current = false; }; }, []); const isHostConnected = useCallback( () => isHostRuntimeConnected(runtime.getSnapshot(host.serverId)), [host.serverId, runtime], ); 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 waitForDaemonRestart = useCallback(async () => { const disconnectTimeoutMs = 7000; const reconnectTimeoutMs = 30000; if (isHostConnected()) { await waitForCondition(() => !isHostConnected(), disconnectTimeoutMs); } const reconnected = await waitForCondition(() => isHostConnected(), reconnectTimeoutMs); if (isMountedRef.current) { setIsRestarting(false); if (!reconnected) { Alert.alert( "Unable to reconnect", `${host.label} did not come back online. Please verify it restarted.`, ); } } }, [host.label, isHostConnected, waitForCondition]); const handleRestart = useCallback(() => { if (!daemonClient) { Alert.alert( "Host unavailable", "This host is not connected. Wait for it to come online before restarting.", ); return; } if (!isHostConnected()) { Alert.alert( "Host offline", "This host is offline. Paseo reconnects automatically—wait until it's back online before restarting.", ); return; } void confirmDialog({ title: `Restart ${host.label}`, message: RESTART_CONFIRMATION_MESSAGE, confirmLabel: "Restart", cancelLabel: "Cancel", destructive: true, }) .then((confirmed) => { if (!confirmed) return; setIsRestarting(true); void daemonClient .restartServer(`settings_daemon_restart_${host.serverId}`) .catch((error) => { console.error(`[HostPage] Failed to restart daemon ${host.label}`, error); if (!isMountedRef.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(); }) .catch((error) => { console.error(`[HostPage] Failed to open restart confirmation for ${host.label}`, error); Alert.alert("Error", "Unable to open the restart confirmation dialog."); }); }, [daemonClient, host.label, host.serverId, isHostConnected, waitForDaemonRestart]); return ( Restart daemon Restarts the daemon process. The app will reconnect automatically. ); } function InjectPaseoToolsCard({ serverId }: { serverId: string }) { const isConnected = useHostRuntimeIsConnected(serverId); const { config, patchConfig } = useDaemonConfig(serverId); if (!isConnected) return null; return ( 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" }, ]} /> ); } function PairDeviceRow() { const { theme } = useUnistyles(); const [isModalOpen, setIsModalOpen] = useState(false); return ( setIsModalOpen(true)} accessibilityRole="button" testID="host-page-pair-device-row" > Pair a device Scan a QR code or copy a link to connect your phone to this host setIsModalOpen(false)} testID="host-page-pair-device-card" /> ); } function RemoveHostSection({ host, onRemoved }: { host: HostProfile; onRemoved?: () => void }) { const { theme } = useUnistyles(); const { removeHost } = useHostMutations(); const [isConfirming, setIsConfirming] = useState(false); const [isRemoving, setIsRemoving] = useState(false); return ( Remove host Removes this host and its saved connections from this device {isConfirming ? ( { if (isRemoving) return; setIsConfirming(false); }} testID="remove-host-confirm-modal" > Remove {host.label}? This will delete its saved connections. ) : null} ); } const styles = StyleSheet.create((theme) => ({ identityEditButton: { padding: theme.spacing[1], borderRadius: theme.borderRadius.md, }, identityBadges: { flexDirection: "row", alignItems: "center", gap: theme.spacing[1], flexWrap: "wrap", marginBottom: theme.spacing[6], }, statusPill: { flexDirection: "row", alignItems: "center", gap: 6, paddingHorizontal: theme.spacing[2], paddingVertical: 4, borderRadius: theme.borderRadius.full, }, statusDot: { width: 6, height: 6, borderRadius: theme.borderRadius.full, }, statusText: { fontSize: theme.fontSize.xs, fontWeight: theme.fontWeight.normal, }, badgePill: { 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: 200, }, badgeText: { fontSize: theme.fontSize.xs, fontWeight: theme.fontWeight.normal, color: theme.colors.foregroundMuted, flexShrink: 1, }, errorText: { color: theme.colors.palette.red[300], fontSize: theme.fontSize.xs, marginBottom: theme.spacing[2], }, connectionLatency: { fontSize: theme.fontSize.sm, marginRight: theme.spacing[2], }, confirmText: { color: theme.colors.foregroundMuted, fontSize: theme.fontSize.sm, }, confirmActions: { flexDirection: "row", alignItems: "center", gap: theme.spacing[2], marginTop: theme.spacing[4], }, emptyCard: { padding: theme.spacing[4], alignItems: "center", }, emptyText: { color: theme.colors.foregroundMuted, fontSize: theme.fontSize.sm, }, renameBody: { gap: theme.spacing[3], paddingBottom: theme.spacing[2], }, renameInput: { backgroundColor: theme.colors.surface0, color: theme.colors.foreground, paddingVertical: theme.spacing[3], paddingHorizontal: theme.spacing[3], borderRadius: theme.borderRadius.md, borderWidth: 1, borderColor: theme.colors.border, fontSize: theme.fontSize.base, }, renameActions: { flexDirection: "row", alignItems: "center", gap: theme.spacing[2], }, }));