import { Fragment, useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore, } from "react"; import type { ComponentType, ReactElement, ReactNode } from "react"; import { Alert, Pressable, ScrollView, Text, TextInput, View, type PressableStateCallbackType, } 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, ChevronDown, Monitor, Settings, Palette, Server, Network, Bot, Boxes, Keyboard, Stethoscope, Info, Shield, Puzzle, Plus, FolderGit2, } 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 { AppearanceSection } from "@/screens/settings/appearance/appearance-section"; import { useAppSettings, useSettings, parseTerminalScrollbackLines, type AppSettings, type SendBehavior, type ServiceUrlBehavior, type Settings as EffectiveSettings, } from "@/hooks/use-settings"; import { getHostRuntimeStore, isHostRuntimeConnected, useHostRuntimeIsConnected, useHosts, } from "@/runtime/host-runtime"; import { useSessionStore } from "@/stores/session-store"; import type { HostProfile } from "@/types/host-connection"; 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 { CommunityLinks } from "@/components/community-links"; import { SegmentedControl } from "@/components/ui/segmented-control"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { Combobox, ComboboxItem, type ComboboxOption } from "@/components/ui/combobox"; import { DesktopPermissionsSection } from "@/desktop/components/desktop-permissions-section"; import { IntegrationsSection } from "@/desktop/components/integrations-section"; import { LocalDaemonSection } from "@/desktop/components/desktop-updates-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 { HostConnectionsPage, HostAgentsPage, HostSettingsPage, HostProvidersPage, HostWorkspacesPage, } from "@/screens/settings/host-page"; import ProjectsScreen from "@/screens/projects-screen"; import ProjectSettingsScreen from "@/screens/project-settings-screen"; import { useIsCompactFormFactor } from "@/constants/layout"; import { useLocalDaemonServerId } from "@/hooks/use-is-local-daemon"; import { useWebScrollbarStyle } from "@/hooks/use-web-scrollbar-style"; import { buildHostOpenProjectRoute, buildProjectsSettingsRoute, buildSettingsHostSectionRoute, buildSettingsSectionRoute, type HostSectionSlug, type SettingsSectionSlug, } from "@/utils/host-routes"; import { navigateToLastWorkspace } from "@/stores/navigation-active-workspace-store"; // --------------------------------------------------------------------------- // View model // --------------------------------------------------------------------------- export type SettingsView = | { kind: "root" } | { kind: "section"; section: SettingsSectionSlug } | { kind: "host"; serverId: string; section: HostSectionSlug } | { kind: "projects" } | { kind: "project"; projectKey: 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: "daemon", label: "Daemon", icon: Server, desktopOnly: true }, { id: "appearance", label: "Appearance", icon: Palette }, { 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 }, ]; interface HostSectionItem { id: HostSectionSlug; label: string; icon: ComponentType<{ size: number; color: string }>; } const HOST_SECTION_ITEMS: HostSectionItem[] = [ { id: "connections", label: "Connections", icon: Network }, { id: "agents", label: "Agents", icon: Bot }, { id: "workspaces", label: "Workspaces", icon: FolderGit2 }, { id: "providers", label: "Providers", icon: Boxes }, { id: "host", label: "Host", icon: Server }, ]; function renderHostSettingsContent( view: Extract, onHostRemoved: () => void, ): ReactNode { switch (view.section) { case "connections": return ; case "agents": return ; case "workspaces": return ; case "providers": return ; case "host": return ; } } // --------------------------------------------------------------------------- // Trigger + sidebar style helpers // --------------------------------------------------------------------------- function themeTriggerStyle({ pressed }: PressableStateCallbackType) { return [styles.themeTrigger, pressed && { opacity: 0.85 }]; } function sidebarItemStyle({ hovered }: PressableStateCallbackType & { hovered?: boolean }) { return [sidebarStyles.item, Boolean(hovered) && sidebarStyles.itemHovered]; } function selectedSidebarItemStyle({ hovered }: PressableStateCallbackType & { hovered?: boolean }) { return [ sidebarStyles.item, Boolean(hovered) && sidebarStyles.itemHovered, sidebarStyles.itemSelected, ]; } const ROW_WITH_BORDER_STYLE = [settingsStyles.row, settingsStyles.rowBorder]; const SEND_BEHAVIOR_OPTIONS = [ { value: "interrupt" as const, label: "Interrupt" }, { value: "queue" as const, label: "Queue" }, ]; const RELEASE_CHANNEL_OPTIONS = [ { value: "stable" as const, label: "Stable" }, { value: "beta" as const, label: "Beta" }, ]; const SERVICE_URL_BEHAVIOR_LABELS: Record = { ask: "Ask", "in-app": "In Paseo", external: "External browser", }; const SERVICE_URL_BEHAVIOR_VALUES: ServiceUrlBehavior[] = ["ask", "in-app", "external"]; // --------------------------------------------------------------------------- // Section components // --------------------------------------------------------------------------- interface GeneralSectionProps { settings: AppSettings; isDesktopApp: boolean; handleSendBehaviorChange: (behavior: SendBehavior) => void; handleServiceUrlBehaviorChange: (behavior: ServiceUrlBehavior) => void; handleTerminalScrollbackLinesChange: (lines: number) => void; } interface ServiceUrlBehaviorMenuItemProps { value: ServiceUrlBehavior; selected: boolean; onChange: (value: ServiceUrlBehavior) => void; } function ServiceUrlBehaviorMenuItem({ value, selected, onChange, }: ServiceUrlBehaviorMenuItemProps) { const handleSelect = useCallback(() => { onChange(value); }, [onChange, value]); return ( {SERVICE_URL_BEHAVIOR_LABELS[value]} ); } function GeneralSection({ settings, isDesktopApp, handleSendBehaviorChange, handleServiceUrlBehaviorChange, handleTerminalScrollbackLinesChange, }: GeneralSectionProps) { const { theme } = useUnistyles(); const iconColor = theme.colors.foregroundMuted; const [terminalScrollbackValue, setTerminalScrollbackValue] = useState( String(settings.terminalScrollbackLines), ); const handleTerminalScrollbackChangeText = useCallback((value: string) => { setTerminalScrollbackValue(value.replace(/[^\d]/g, "")); }, []); const commitTerminalScrollback = useCallback(() => { const parsed = parseTerminalScrollbackLines(terminalScrollbackValue); const nextValue = parsed ?? settings.terminalScrollbackLines; setTerminalScrollbackValue(String(nextValue)); if (nextValue !== settings.terminalScrollbackLines) { handleTerminalScrollbackLinesChange(nextValue); } }, [ handleTerminalScrollbackLinesChange, settings.terminalScrollbackLines, terminalScrollbackValue, ]); useEffect(() => { setTerminalScrollbackValue(String(settings.terminalScrollbackLines)); }, [settings.terminalScrollbackLines]); return ( Default send What happens when you press Enter while the agent is running {isDesktopApp ? ( Service URLs Where to open URLs from running scripts {SERVICE_URL_BEHAVIOR_LABELS[settings.serviceUrlBehavior]} {SERVICE_URL_BEHAVIOR_VALUES.map((value) => ( ))} ) : null} Terminal scrollback Lines kept in the built-in terminal buffer ); } interface DiagnosticsSectionProps { voiceAudioEngine: ReturnType; isPlaybackTestRunning: boolean; playbackTestResult: string | null; handlePlaybackTest: () => Promise; } function DiagnosticsSection({ voiceAudioEngine, isPlaybackTestRunning, playbackTestResult, handlePlaybackTest, }: DiagnosticsSectionProps) { const handlePlayPress = useCallback(() => { void handlePlaybackTest(); }, [handlePlaybackTest]); return ( Test audio {playbackTestResult ? ( {playbackTestResult} ) : null} ); } interface AboutSectionProps { appVersion: string | null; appVersionText: string; isDesktopApp: boolean; } function AboutSection({ appVersion, appVersionText, isDesktopApp }: AboutSectionProps) { return ( <> App version This device {appVersionText} {isDesktopApp ? : null} ); } function normalizeVersion(version: string | null | undefined): string | null { const trimmed = version?.trim(); if (!trimmed) return null; return trimmed.replace(/^v/i, ""); } function ConnectedHostsSection({ clientVersion }: { clientVersion: string | null }) { const hosts = useHosts(); if (hosts.length === 0) { return null; } return ( {hosts.map((host, index) => ( 0} clientVersion={clientVersion} /> ))} ); } function HostVersionRow({ host, showBorder, clientVersion, }: { host: HostProfile; showBorder: boolean; clientVersion: string | null; }) { const isConnected = useHostRuntimeIsConnected(host.serverId); const daemonVersion = useSessionStore( (state) => state.sessions[host.serverId]?.serverInfo?.version ?? null, ); const rowStyle = useMemo( () => [settingsStyles.row, showBorder && settingsStyles.rowBorder], [showBorder], ); const normalizedHost = normalizeVersion(daemonVersion); const normalizedClient = normalizeVersion(clientVersion); const isMismatch = normalizedHost !== null && normalizedClient !== null && normalizedHost !== normalizedClient; let valueText: string; if (!isConnected) { valueText = "Offline"; } else if (normalizedHost) { valueText = formatVersionWithPrefix(normalizedHost); } else { valueText = "—"; } const valueStyle = useMemo( () => [styles.aboutValue, isMismatch && styles.aboutVersionMismatch], [isMismatch], ); return ( {host.label} {isMismatch ? ( Version differs from this device ) : null} {valueText} ); } function getUpdateButtonLabel( isInstalling: boolean, latestVersion: string | null | undefined, ): string { if (isInstalling) return "Installing..."; if (latestVersion) return `Update to ${formatVersionWithPrefix(latestVersion)}`; return "Update"; } function DesktopAppUpdateRow() { const { settings, updateSettings } = useSettings(); 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 handleReleaseChannelChange = useCallback( (releaseChannel: EffectiveSettings["releaseChannel"]) => { void updateSettings({ releaseChannel }); }, [updateSettings], ); 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(); return; }) .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 ( <> Release channel Switch to Beta to get updates sooner and help shape them 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, ); } /** * Local daemon first, then remaining hosts in their existing order. Lets the * picker and the active-host resolver agree on a stable "first" host. */ function useSortedHosts(hosts: HostProfile[], localServerId: string | null): HostProfile[] { return 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]); } interface SidebarSectionButtonProps { itemId: SettingsSectionSlug; label: string; icon: ComponentType<{ size: number; color: string }>; isSelected: boolean; onSelect: (section: SettingsSectionSlug) => void; } function SidebarSectionButton({ itemId, label, icon: IconComponent, isSelected, onSelect, }: SidebarSectionButtonProps) { const { theme } = useUnistyles(); const handlePress = useCallback(() => { onSelect(itemId); }, [onSelect, itemId]); const accessibilityState = useMemo(() => ({ selected: isSelected }), [isSelected]); const labelStyle = useMemo( () => [sidebarStyles.label, isSelected && { color: theme.colors.foreground }], [isSelected, theme.colors.foreground], ); return ( {label} ); } interface SidebarHostSectionButtonProps { itemId: HostSectionSlug; label: string; icon: ComponentType<{ size: number; color: string }>; isSelected: boolean; onSelect: (section: HostSectionSlug) => void; } function SidebarHostSectionButton({ itemId, label, icon: IconComponent, isSelected, onSelect, }: SidebarHostSectionButtonProps) { const { theme } = useUnistyles(); const handlePress = useCallback(() => { onSelect(itemId); }, [onSelect, itemId]); const accessibilityState = useMemo(() => ({ selected: isSelected }), [isSelected]); const labelStyle = useMemo( () => [sidebarStyles.label, isSelected && { color: theme.colors.foreground }], [isSelected, theme.colors.foreground], ); return ( {label} ); } interface SidebarProjectsButtonProps { isSelected: boolean; onSelect: () => void; } function SidebarProjectsButton({ isSelected, onSelect }: SidebarProjectsButtonProps) { const { theme } = useUnistyles(); const accessibilityState = useMemo(() => ({ selected: isSelected }), [isSelected]); const labelStyle = useMemo( () => [sidebarStyles.label, isSelected && { color: theme.colors.foreground }], [isSelected, theme.colors.foreground], ); return ( Projects ); } // Sentinel option id for the "Add host" row appended to the picker list. const ADD_HOST_OPTION_ID = "__add_host__"; interface HostPickerOptionProps { serverId: string; label: string; isLocal: boolean; selected: boolean; active: boolean; onPress: () => void; } function HostPickerOption({ serverId, label, isLocal, selected, active, onPress, }: HostPickerOptionProps) { const { theme } = useUnistyles(); const leadingSlot = useMemo( () => , [theme.iconSize.sm, theme.colors.foregroundMuted], ); // The local host carries a "Local" marker; the active host is conveyed by the // row's selected check, so both can coexist on one row. const trailingSlot = useMemo( () => isLocal ? ( Local ) : undefined, [isLocal], ); return ( ); } function AddHostOption({ active, onPress }: { active: boolean; onPress: () => void }) { const { theme } = useUnistyles(); const leadingSlot = useMemo( () => , [theme.iconSize.sm, theme.colors.foregroundMuted], ); return ( ); } interface HostPickerProps { activeServerId: string | null; sortedHosts: HostProfile[]; localServerId: string | null; onSelectHost: (serverId: string) => void; onAddHost: () => void; } /** * Scopes the four host sections to a host. Reuses the canonical sidebar host * switcher pattern (left-sidebar.tsx): a quiet row-styled trigger opening a * . The local host is listed first and tagged "Local"; an "Add host" * row is always reachable from the list — even with a single host. */ function HostPicker({ activeServerId, sortedHosts, localServerId, onSelectHost, onAddHost, }: HostPickerProps) { const { theme } = useUnistyles(); const [isOpen, setIsOpen] = useState(false); const triggerRef = useRef(null); const activeHost = sortedHosts.find((host) => host.serverId === activeServerId) ?? sortedHosts[0] ?? null; const options = useMemo(() => { const hostOptions = sortedHosts.map((host) => ({ id: host.serverId, label: host.label })); return [...hostOptions, { id: ADD_HOST_OPTION_ID, label: "Add host" }]; }, [sortedHosts]); const handleSelect = useCallback( (id: string) => { if (id === ADD_HOST_OPTION_ID) { onAddHost(); return; } onSelectHost(id); }, [onAddHost, onSelectHost], ); const renderOption = useCallback( ({ option, selected, active, onPress, }: { option: ComboboxOption; selected: boolean; active: boolean; onPress: () => void; }): ReactElement => { if (option.id === ADD_HOST_OPTION_ID) { return ; } return ( ); }, [localServerId], ); const handleOpen = useCallback(() => setIsOpen(true), []); const triggerStyle = useCallback( ({ hovered = false }: PressableStateCallbackType & { hovered?: boolean }) => [ sidebarStyles.pickerTrigger, hovered && sidebarStyles.pickerTriggerHovered, ], [], ); return ( <> {activeHost?.label ?? "Host"} ); } interface SettingsSidebarProps { view: SettingsView; onSelectSection: (section: SettingsSectionSlug) => void; onSelectHostSection: (section: HostSectionSlug) => void; onSelectHost: (serverId: string) => void; onSelectProjects: () => void; onAddHost: () => void; onBackToWorkspace: () => void; activeHostServerId: string | null; layout: "desktop" | "mobile"; } function SettingsSidebar({ view, onSelectSection, onSelectHostSection, onSelectHost, onSelectProjects, onAddHost, onBackToWorkspace, activeHostServerId, layout, }: SettingsSidebarProps) { const { theme } = useUnistyles(); const hosts = useHosts(); const localServerId = useLocalDaemonServerId(); const sortedHosts = useSortedHosts(hosts, localServerId); const hasHosts = sortedHosts.length > 0; const isDesktopApp = isElectronRuntime(); const items = SIDEBAR_SECTION_ITEMS.filter((item) => !item.desktopOnly || isDesktopApp); const insets = useSafeAreaInsets(); const padding = useWindowControlsPadding("sidebar"); const isDesktop = layout === "desktop"; const containerStyle = useMemo( () => [ isDesktop ? sidebarStyles.desktopContainer : sidebarStyles.mobileContainer, isDesktop ? { paddingTop: insets.top } : null, ], [insets.top, isDesktop], ); const selectedSectionId = view.kind === "section" ? view.section : null; const selectedHostSection = view.kind === "host" ? view.section : null; const isProjectsSelected = view.kind === "projects" || view.kind === "project"; const paddingTopStyle = useMemo(() => ({ height: padding.top }), [padding.top]); return ( {isDesktop ? ( <> {padding.top > 0 ? : null} ) : null} {isDesktop ? ( ) : null} App {items.map((item) => ( {item.id === "general" ? ( ) : null} ))} {hasHosts ? ( Host {HOST_SECTION_ITEMS.map((item) => ( ))} ) : ( 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 insetBottomStyle = useMemo(() => ({ paddingBottom: insets.bottom }), [insets.bottom]); const webScrollbarStyle = useWebScrollbarStyle(); const scrollViewStyle = useMemo( () => [styles.scrollView, webScrollbarStyle], [webScrollbarStyle], ); const hosts = useHosts(); const localServerId = useLocalDaemonServerId(); const sortedHosts = useSortedHosts(hosts, localServerId); const hostServerIds = useMemo(() => hosts.map((host) => host.serverId), [hosts]); const anyOnlineServerId = useAnyOnlineHostServerId(hostServerIds); const [selectedSettingsHostServerId, setSelectedSettingsHostServerId] = useState( view.kind === "host" ? view.serverId : null, ); const knownSelectedSettingsHostServerId = useMemo(() => { if (!selectedSettingsHostServerId) { return null; } return hosts.some((host) => host.serverId === selectedSettingsHostServerId) ? selectedSettingsHostServerId : null; }, [hosts, selectedSettingsHostServerId]); useEffect(() => { if (view.kind === "host") { setSelectedSettingsHostServerId(view.serverId); } }, [view]); // The host the four sections scope to: the host on the active view, otherwise // the picker choice, otherwise the local daemon, otherwise the first host. const activeHostServerId = useMemo(() => { if (view.kind === "host") return view.serverId; return knownSelectedSettingsHostServerId ?? localServerId ?? sortedHosts[0]?.serverId ?? null; }, [view, knownSelectedSettingsHostServerId, localServerId, sortedHosts]); const handleSendBehaviorChange = useCallback( (behavior: SendBehavior) => { void updateSettings({ sendBehavior: behavior }); }, [updateSettings], ); const handleServiceUrlBehaviorChange = useCallback( (behavior: ServiceUrlBehavior) => { void updateSettings({ serviceUrlBehavior: behavior }); }, [updateSettings], ); const handleTerminalScrollbackLinesChange = useCallback( (terminalScrollbackLines: number) => { void updateSettings({ terminalScrollbackLines }); }, [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 handleSelectDirectConnection = useCallback(() => { setIsAddHostMethodVisible(false); setIsDirectHostVisible(true); }, []); const handleSelectPasteLink = useCallback(() => { setIsAddHostMethodVisible(false); setIsPasteLinkVisible(true); }, []); const handleHostAdded = useCallback( ({ serverId }: { serverId: string }) => { const target = buildSettingsHostSectionRoute(serverId, "connections"); 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], ); // Picker: choose the host for host-section rows. If the user is already on a // host detail route, keep that detail section and swap only the host segment. const handleSelectHost = useCallback( (serverId: string) => { setSelectedSettingsHostServerId(serverId); if (view.kind !== "host") { return; } const section: HostSectionSlug = view.section; const target = buildSettingsHostSectionRoute(serverId, section); if (isCompactLayout) { router.push(target); } else { router.replace(target); } }, [isCompactLayout, router, view], ); const handleSelectHostSection = useCallback( (section: HostSectionSlug) => { if (!activeHostServerId) { handleAddHost(); return; } const target = buildSettingsHostSectionRoute(activeHostServerId, section); if (isCompactLayout) { router.push(target); } else { router.replace(target); } }, [activeHostServerId, handleAddHost, isCompactLayout, router], ); const handleSelectProjects = useCallback(() => { const target = buildProjectsSettingsRoute(); 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 (navigateToLastWorkspace()) { 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 item = HOST_SECTION_ITEMS.find((s) => s.id === view.section); if (!item) return null; return { title: item.label, Icon: item.icon }; } 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 }; } if (view.kind === "project" || view.kind === "projects") { return { title: "Projects", Icon: FolderGit2 }; } return null; })(); const content = (() => { if (view.kind === "host") { return renderHostSettingsContent(view, handleHostRemoved); } if (view.kind === "projects") { return ; } if (view.kind === "project") { return ; } if (view.kind === "section") { switch (view.section) { case "general": return ( ); case "daemon": return ; case "appearance": 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 = ( <> ); // Mobile root: full-screen sidebar-as-list. if (isCompactLayout && view.kind === "root") { return ( {addHostModals} ); } // Mobile detail: full-screen content with a back header. Project detail uses // an app-level back (out of settings, to the workspace) since the in-body // "Back to projects" ghost button handles list-level back; other detail views // step back to the settings root. const detailBackHandler = view.kind === "project" ? handleBackToWorkspace : handleBackToRoot; 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.title} {detailHeader.titleAccessory} ) : null } leftStyle={desktopStyles.detailLeft} /> {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, }, aboutVersionMismatch: { color: theme.colors.palette.amber[500], }, aboutErrorText: { color: theme.colors.palette.red[300], fontSize: theme.fontSize.xs, marginTop: theme.spacing[1], }, aboutCommunity: { marginTop: theme.spacing[4], }, 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, }, terminalScrollbackInput: { width: 112, minHeight: 36, paddingVertical: theme.spacing[2], paddingHorizontal: theme.spacing[3], borderRadius: theme.borderRadius.md, borderWidth: 1, borderColor: theme.colors.border, backgroundColor: theme.colors.surface2, color: theme.colors.foreground, fontSize: theme.fontSize.sm, textAlign: "right", }, placeholder: { flex: 1, alignItems: "center", justifyContent: "center", paddingVertical: theme.spacing[8], }, placeholderText: { color: theme.colors.foregroundMuted, 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], }, groupLabel: { fontSize: theme.fontSize.sm, fontWeight: theme.fontWeight.medium, color: theme.colors.foregroundMuted, paddingHorizontal: theme.spacing[2], paddingVertical: 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, marginLeft: theme.spacing[1], }, pickerTrigger: { flexDirection: "row", alignItems: "center", gap: theme.spacing[2], minHeight: 36, paddingVertical: theme.spacing[2], paddingHorizontal: theme.spacing[2], borderRadius: theme.borderRadius.lg, }, pickerTriggerHovered: { backgroundColor: theme.colors.surfaceSidebarHover, }, pickerTriggerLabel: { flex: 1, minWidth: 0, fontSize: theme.fontSize.base, color: theme.colors.foreground, fontWeight: theme.fontWeight.normal, }, }));