import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import type { ComponentType, ReactNode } from "react"; import { Alert, Pressable, ScrollView, Text, View, type PressableStateCallbackType, } from "react-native"; import { EditingTextInput as TextInput } from "@/components/ui/text-input"; 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 { useTranslation } from "react-i18next"; import type { TFunction } from "i18next"; import { Buffer } from "buffer"; import { ArrowLeft, Settings, Palette, Server, Network, Bot, Boxes, Gauge, Keyboard, Stethoscope, Info, Bell, Shield, Puzzle, Plus, FolderGit2, SquareTerminal, Code2, Smartphone, Sparkles, Blocks, } from "lucide-react-native"; import { DropdownTrigger } from "@/components/ui/dropdown-trigger"; import { ComboboxTrigger } from "@/components/ui/combobox-trigger"; import { SidebarHeaderRow } from "@/components/sidebar/sidebar-header-row"; import { SidebarSeparator } from "@/components/sidebar/sidebar-separator"; import { HostPicker as SharedHostPicker } from "@/components/hosts/host-picker"; import { HostStatusDot } from "@/components/host-status-dot"; 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 { useHostRuntimeIsConnected, useHosts } from "@/runtime/host-runtime"; import { useSessionStore } from "@/stores/session-store"; import { orderHostsLocalFirst, resolveActiveHostServerId, type HostProfile, } from "@/types/host-connection"; import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region"; import { WindowChromeRegion, WindowChromeSafeArea } 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 { EditorSection } from "@/screens/settings/editor-section"; import { Button } from "@/components/ui/button"; import { Switch } from "@/components/ui/switch"; import { supportsDesktopPaneSplits } from "@/constants/layout"; import { CommunityLinks } from "@/components/community-links"; import { SegmentedControl } from "@/components/ui/segmented-control"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem } from "@/components/ui/dropdown-menu"; import { DesktopPermissionsSection } from "@/desktop/components/desktop-permissions-section"; import { DesktopNotificationsSection } from "@/desktop/components/desktop-notifications-section"; import { BrowserDataSection } from "@/desktop/browser/settings/browser-data-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 { useAppDiagnosticStore } from "@/diagnostics/store"; import { settingsStyles } from "@/styles/settings"; import { THINKING_TONE_NATIVE_PCM_BASE64 } from "@/utils/thinking-tone.native-pcm"; import { useVoiceAudioEngineOptional } from "@/contexts/voice-context"; import { LANGUAGE_OPTIONS, formatLanguageOptionLabel, parseAppLanguage, type AppLanguage, type SupportedLocale, } from "@/i18n/locales"; import { HostConnectionsPage, HostPairDevicePage, HostAgentsPage, HostSettingsPage, HostProvidersPage, HostUsagePage, HostWorkspacesPage, HostTerminalsPage, } from "@/screens/settings/host-page"; import { HostPluginsPage } from "@/screens/settings/plugins-page"; import { MetadataGenerationPage } from "@/screens/settings/metadata-generation-page"; import ProjectsScreen from "@/screens/projects-screen"; import ProjectSettingsScreen from "@/screens/project-settings-screen"; import { SETTINGS_DESKTOP_SIDEBAR_WIDTH, useIsCompactFormFactor } from "@/constants/layout"; import { useLocalDaemonServerId } from "@/hooks/use-is-local-daemon"; import { type EnableBuiltInDaemonOption, useEnableBuiltInDaemonOption, } from "@/desktop/hooks/use-enable-built-in-daemon-option"; import { buildSettingsHostSectionRoute, buildSettingsSectionRoute, type HostSectionSlug, type SettingsSectionSlug, } from "@/utils/host-routes"; import { useLastWorkspaceSelection } from "@/stores/navigation-active-workspace-store"; import { returnFromSettings, type SettingsView } from "@/navigation/settings-navigation"; import { isNative, isWeb } from "@/constants/platform"; // --------------------------------------------------------------------------- // View model // --------------------------------------------------------------------------- interface SidebarSectionItem { id: SettingsSectionSlug; labelKey: string; icon: ComponentType<{ size: number; color: string }>; desktopOnly?: boolean; webOnly?: boolean; } const SIDEBAR_SECTION_ITEMS: SidebarSectionItem[] = [ { id: "general", labelKey: "settings.sections.general", icon: Settings }, { id: "appearance", labelKey: "settings.sections.appearance", icon: Palette }, { id: "editor", labelKey: "settings.sections.editor", icon: Code2, webOnly: true }, { id: "shortcuts", labelKey: "settings.sections.shortcuts", icon: Keyboard, desktopOnly: true }, { id: "integrations", labelKey: "settings.sections.integrations", icon: Puzzle, desktopOnly: true, }, { id: "notifications", labelKey: "settings.sections.notifications", icon: Bell, desktopOnly: true, }, { id: "permissions", labelKey: "settings.sections.permissions", icon: Shield, desktopOnly: true, }, { id: "diagnostics", labelKey: "settings.sections.diagnostics", icon: Stethoscope }, { id: "about", labelKey: "settings.sections.about", icon: Info }, ]; interface HostSectionItem { id: HostSectionSlug; labelKey: string; icon: ComponentType<{ size: number; color: string }>; } const HOST_SECTION_ITEMS: HostSectionItem[] = [ { id: "host", labelKey: "settings.hostSections.host", icon: Server }, { id: "projects", labelKey: "settings.hostSections.projects", icon: FolderGit2 }, { id: "connections", labelKey: "settings.hostSections.connections", icon: Network }, { id: "pair-device", labelKey: "openProject.tiles.pairDevice.title", icon: Smartphone }, { id: "agents", labelKey: "settings.hostSections.agents", icon: Bot }, { id: "metadata", labelKey: "settings.hostSections.metadata", icon: Sparkles }, { id: "workspaces", labelKey: "settings.hostSections.workspaces", icon: FolderGit2 }, { id: "providers", labelKey: "settings.hostSections.providers", icon: Boxes }, { id: "usage", labelKey: "settings.hostSections.usage", icon: Gauge }, { id: "terminals", labelKey: "settings.hostSections.terminals", icon: SquareTerminal }, { id: "plugins", labelKey: "settings.hostSections.plugins", icon: Blocks }, ]; function renderHostSettingsContent( view: Extract, onHostRemoved: () => void, ): ReactNode { switch (view.section) { case "projects": return ; case "connections": return ; case "pair-device": return ; case "agents": return ; case "metadata": return ; case "workspaces": return ; case "providers": return ; case "usage": return ; case "terminals": return ; case "plugins": 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, ]; } function getSendBehaviorOptions(t: TFunction) { return [ { value: "interrupt" as const, label: t("settings.general.defaultSend.options.interrupt") }, { value: "steer" as const, label: t("settings.general.defaultSend.options.steer") }, { value: "queue" as const, label: t("settings.general.defaultSend.options.queue") }, ]; } function getServiceUrlBehaviorLabel(t: TFunction, value: ServiceUrlBehavior): string { const labels: Record = { ask: t("settings.general.serviceUrls.options.ask"), "in-app": t("settings.general.serviceUrls.options.inApp"), external: t("settings.general.serviceUrls.options.external"), }; return labels[value]; } function getActiveLocale(language: string | undefined): SupportedLocale { const parsed = parseAppLanguage(language); return parsed && parsed !== "system" ? parsed : "en"; } 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; handleLanguageChange: (language: AppLanguage) => void; handleTerminalScrollbackLinesChange: (lines: number) => void; handleSidePanelRoutingChange: (enabled: boolean) => void; } interface ServiceUrlBehaviorMenuItemProps { value: ServiceUrlBehavior; label: string; selected: boolean; onChange: (value: ServiceUrlBehavior) => void; } function ServiceUrlBehaviorMenuItem({ value, label, selected, onChange, }: ServiceUrlBehaviorMenuItemProps) { const handleSelect = useCallback(() => { onChange(value); }, [onChange, value]); return ( {label} ); } interface LanguageMenuItemProps { value: AppLanguage; activeLocale: SupportedLocale; selected: boolean; onChange: (value: AppLanguage) => void; } function LanguageMenuItem({ value, activeLocale, selected, onChange }: LanguageMenuItemProps) { const { t } = useTranslation(); const handleSelect = useCallback(() => { onChange(value); }, [onChange, value]); const option = LANGUAGE_OPTIONS.find((entry) => entry.value === value); const label = option ? formatLanguageOptionLabel(option, activeLocale, t(option.labelKey)) : value; return ( {label} ); } function GeneralSection({ settings, isDesktopApp, handleSendBehaviorChange, handleServiceUrlBehaviorChange, handleLanguageChange, handleTerminalScrollbackLinesChange, handleSidePanelRoutingChange, }: GeneralSectionProps) { const { t, i18n } = useTranslation(); const activeLocale = getActiveLocale(i18n.language); const sendBehaviorOptions = useMemo(() => getSendBehaviorOptions(t), [t]); const sendBehaviorDescriptionKey = `settings.general.defaultSend.descriptions.${settings.sendBehavior}`; const selectedLanguageOption = LANGUAGE_OPTIONS.find( (option) => option.value === settings.language, ); const selectedLanguageLabel = selectedLanguageOption ? formatLanguageOptionLabel( selectedLanguageOption, activeLocale, t(selectedLanguageOption.labelKey), ) : settings.language; 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 ( {t("settings.general.defaultSend.label")} {t(sendBehaviorDescriptionKey)} {t("settings.general.language.label")} {t("settings.general.language.description")} {selectedLanguageLabel} {LANGUAGE_OPTIONS.map((option) => ( ))} {isDesktopApp ? ( {t("settings.general.serviceUrls.label")} {t("settings.general.serviceUrls.description")} {getServiceUrlBehaviorLabel(t, settings.serviceUrlBehavior)} {SERVICE_URL_BEHAVIOR_VALUES.map((value) => ( ))} ) : null} {t("settings.general.terminalScrollback.label")} {t("settings.general.terminalScrollback.description")} {supportsDesktopPaneSplits() ? ( {t("settings.general.sidePanelRouting.label")} {t("settings.general.sidePanelRouting.description")} ) : null} ); } interface DiagnosticsSectionProps { useLegacyTerminalRenderer: boolean; onUseLegacyTerminalRendererChange: (value: boolean) => void; voiceAudioEngine: ReturnType; isPlaybackTestRunning: boolean; playbackTestResult: string | null; handlePlaybackTest: () => Promise; } function DiagnosticsSection({ useLegacyTerminalRenderer, onUseLegacyTerminalRendererChange, voiceAudioEngine, isPlaybackTestRunning, playbackTestResult, handlePlaybackTest, }: DiagnosticsSectionProps) { const { t } = useTranslation(); const openAppDiagnostic = useAppDiagnosticStore((state) => state.open); const handlePlayPress = useCallback(() => { void handlePlaybackTest(); }, [handlePlaybackTest]); return ( {isNative ? ( {t("settings.diagnostics.legacyTerminalRenderer.label")} {t("settings.diagnostics.legacyTerminalRenderer.description")} ) : null} {t("settings.diagnostics.app.rowTitle")} {t("settings.diagnostics.app.rowHint")} {t("settings.diagnostics.testAudio")} {playbackTestResult ? ( {playbackTestResult} ) : null} ); } interface AboutSectionProps { appVersion: string | null; appVersionText: string; isDesktopApp: boolean; } function AboutSection({ appVersion, appVersionText, isDesktopApp }: AboutSectionProps) { const { t } = useTranslation(); return ( <> {t("settings.about.appVersion")} {t("settings.about.thisDevice")} {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 { t } = useTranslation(); 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 { t } = useTranslation(); 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 = t("settings.about.offline"); } else if (normalizedHost) { valueText = formatVersionWithPrefix(normalizedHost); } else { valueText = "—"; } const valueStyle = useMemo( () => [styles.aboutValue, isMismatch && styles.aboutVersionMismatch], [isMismatch], ); return ( {host.label} {isMismatch ? ( {t("settings.about.versionDiffers")} ) : null} {valueText} ); } function getUpdateButtonLabel( t: TFunction, isInstalling: boolean, latestVersion: string | null | undefined, ): string { if (isInstalling) return t("settings.about.updates.installing"); if (latestVersion) { return t("settings.about.updates.updateTo", { version: formatVersionWithPrefix(latestVersion), }); } return t("settings.about.updates.update"); } function DesktopAppUpdateRow() { const { t } = useTranslation(); const { settings, updateSettings } = useSettings(); const { isDesktopApp, statusText, availableUpdate, errorMessage, isChecking, isInstalling, checkForUpdates, installUpdate, } = useDesktopAppUpdater(); useFocusEffect( useCallback(() => { if (!isDesktopApp) { return undefined; } void checkForUpdates({ intent: "automatic", 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 releaseChannelOptions = useMemo( () => [ { value: "stable" as const, label: t("settings.about.releaseChannel.stable") }, { value: "beta" as const, label: t("settings.about.releaseChannel.beta") }, ], [t], ); const handleInstallUpdate = useCallback(() => { if (!isDesktopApp) { return; } void confirmDialog({ title: t("settings.about.updates.installTitle"), message: t("settings.about.updates.installMessage"), confirmLabel: t("settings.about.updates.installConfirm"), cancelLabel: t("common.actions.cancel"), }) .then((confirmed) => { if (!confirmed) { return; } void installUpdate(); return; }) .catch((error) => { console.error("[Settings] Failed to open app update confirmation", error); Alert.alert( t("settings.about.updates.alertTitle"), t("settings.about.updates.alertMessage"), ); }); }, [installUpdate, isDesktopApp, t]); const isUpdateReady = availableUpdate?.readyToInstall === true; const readyUpdateVersion = isUpdateReady ? availableUpdate?.latestVersion : null; if (!isDesktopApp) { return null; } return ( <> {t("settings.about.releaseChannel.label")} {t("settings.about.releaseChannel.description")} {t("settings.about.updates.label")} {statusText} {readyUpdateVersion ? ( {t("settings.about.updates.readyToInstall", { version: formatVersionWithPrefix(readyUpdateVersion), })} ) : null} {errorMessage ? {errorMessage} : null} ); } // --------------------------------------------------------------------------- // Sidebar // --------------------------------------------------------------------------- /** * Local daemon first, then remaining hosts in their existing order. */ function useSortedHosts(hosts: HostProfile[], localServerId: string | null): HostProfile[] { return useMemo(() => orderHostsLocalFirst(hosts, localServerId), [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 HostPickerProps { activeServerId: string | null; sortedHosts: HostProfile[]; onSelectHost: (serverId: string) => void; onAddHost: () => void; enableBuiltInDaemonOption: EnableBuiltInDaemonOption; } /** * Scopes the 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, each row shows the connection it * is using right now; an "Add host" row is always reachable from the list — * even with a single host. */ function HostPicker({ activeServerId, sortedHosts, onSelectHost, onAddHost, enableBuiltInDaemonOption, }: HostPickerProps) { const { t } = useTranslation(); const [isOpen, setIsOpen] = useState(false); const triggerRef = useRef(null); const activeHost = sortedHosts.find((host) => host.serverId === activeServerId) ?? sortedHosts[0] ?? null; const handleOpen = useCallback(() => setIsOpen(true), []); const hostOptionTestID = useCallback( (serverId: string) => `settings-host-picker-item-${serverId}`, [], ); const triggerStyle = useCallback( ({ hovered = false }: PressableStateCallbackType & { hovered?: boolean }) => [ sidebarStyles.pickerTrigger, hovered && sidebarStyles.pickerTriggerHovered, ], [], ); return ( {activeHost ? ( ) : null} {activeHost?.label ?? t("settings.groups.host")} ); } interface SettingsSidebarProps { view: SettingsView; onSelectSection: (section: SettingsSectionSlug) => void; onSelectHostSection: (section: HostSectionSlug) => void; onSelectHost: (serverId: string) => void; onAddHost: () => void; onBackToWorkspace: () => void; activeHostServerId: string | null; layout: "desktop" | "mobile"; } function SettingsSidebar({ view, onSelectSection, onSelectHostSection, onSelectHost, onAddHost, onBackToWorkspace, activeHostServerId, layout, }: SettingsSidebarProps) { const { theme } = useUnistyles(); const { t } = useTranslation(); const hosts = useHosts(); const localServerId = useLocalDaemonServerId(); const sortedHosts = useSortedHosts(hosts, localServerId); const hasHosts = sortedHosts.length > 0; const enableBuiltInDaemonOption = useEnableBuiltInDaemonOption(); const isDesktopApp = isElectronRuntime(); const items = SIDEBAR_SECTION_ITEMS.filter( (item) => (!item.desktopOnly || isDesktopApp) && (!item.webOnly || isWeb), ); const insets = useSafeAreaInsets(); const isDesktop = layout === "desktop"; const outerContainerStyle = useMemo( () => [isDesktop ? sidebarStyles.desktopContainer : sidebarStyles.mobileContainer], [isDesktop], ); const innerContainerStyle = useMemo( () => [{ flex: 1 }, isDesktop ? { paddingTop: insets.top } : null], [insets.top, isDesktop], ); const selectedSectionId = view.kind === "section" ? view.section : null; let selectedHostSection: HostSectionSlug | null = null; if (view.kind === "host") selectedHostSection = view.section; if (view.kind === "project") selectedHostSection = "projects"; const sidebarBody = ( <> {t("settings.groups.app")} {items.map((item) => ( ))} {hasHosts ? ( {t("settings.groups.host")} {HOST_SECTION_ITEMS.map((item) => ( ))} ) : ( {t("settings.addHost")} {enableBuiltInDaemonOption.visible ? ( {t("settings.enableBuiltInDaemon")} ) : null} )} ); return ( {isDesktop ? ( {sidebarBody} ) : ( sidebarBody )} ); } // --------------------------------------------------------------------------- // Main screen // --------------------------------------------------------------------------- export interface SettingsScreenProps { view: SettingsView; openAddHostIntent?: string | null; } export default function SettingsScreen({ view, openAddHostIntent = null }: SettingsScreenProps) { const router = useRouter(); const { theme } = useUnistyles(); const { t } = useTranslation(); 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 lastOpenedAddHostIntentRef = useRef(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 hosts = useHosts(); const localServerId = useLocalDaemonServerId(); const sortedHosts = useSortedHosts(hosts, localServerId); const lastWorkspaceSelection = useLastWorkspaceSelection(); const routedSettingsHostServerId = view.kind === "host" || view.kind === "project" ? view.serverId : null; const [selectedSettingsHostServerId, setSelectedSettingsHostServerId] = useState( routedSettingsHostServerId ?? lastWorkspaceSelection?.serverId ?? null, ); useFocusEffect( useCallback(() => { setSelectedSettingsHostServerId( routedSettingsHostServerId ?? lastWorkspaceSelection?.serverId ?? null, ); }, [lastWorkspaceSelection?.serverId, routedSettingsHostServerId]), ); // The host the four sections scope to: the host on the active view, otherwise // the picker choice, otherwise the connected local daemon, otherwise the first host. const activeHostServerId = useMemo(() => { if (view.kind === "host" || view.kind === "project") return view.serverId; return resolveActiveHostServerId({ selectedServerId: selectedSettingsHostServerId, localServerId, hosts, orderedHosts: sortedHosts, }); }, [view, selectedSettingsHostServerId, localServerId, hosts, sortedHosts]); const handleSendBehaviorChange = useCallback( (behavior: SendBehavior) => { void updateSettings({ sendBehavior: behavior }); }, [updateSettings], ); const handleServiceUrlBehaviorChange = useCallback( (behavior: ServiceUrlBehavior) => { void updateSettings({ serviceUrlBehavior: behavior }); }, [updateSettings], ); const handleLanguageChange = useCallback( (language: AppLanguage) => { void updateSettings({ language }); }, [updateSettings], ); const handleTerminalScrollbackLinesChange = useCallback( (terminalScrollbackLines: number) => { void updateSettings({ terminalScrollbackLines }); }, [updateSettings], ); const handleSidePanelRoutingChange = useCallback( (openSupportingTabsInSidePanel: boolean) => { void updateSettings({ openSupportingTabsInSidePanel }); }, [updateSettings], ); const handleUseLegacyTerminalRendererChange = useCallback( (useLegacyTerminalRenderer: boolean) => { void updateSettings({ useLegacyTerminalRenderer }); }, [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(t("settings.diagnostics.playbackFailed", { message })); } finally { setIsPlaybackTestRunning(false); } }, [isPlaybackTestRunning, t, voiceAudioEngine]); const closeAddConnectionFlow = useCallback(() => { setIsAddHostMethodVisible(false); setIsDirectHostVisible(false); setIsPasteLinkVisible(false); }, []); const goBackToAddConnectionMethods = useCallback(() => { setIsDirectHostVisible(false); setIsPasteLinkVisible(false); setIsAddHostMethodVisible(true); }, []); const handleAddHost = useCallback(() => { setIsAddHostMethodVisible(true); }, []); useEffect(() => { if (!openAddHostIntent || lastOpenedAddHostIntentRef.current === openAddHostIntent) { return; } lastOpenedAddHostIntentRef.current = openAddHostIntent; handleAddHost(); }, [handleAddHost, openAddHostIntent]); 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 === "project") { const target = buildSettingsHostSectionRoute(serverId, "projects"); if (isCompactLayout) { router.push(target); } else { router.replace(target); } return; } if (view.kind !== "host") { return; } const target = buildSettingsHostSectionRoute(serverId, view.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 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 handleBackFromDetail = useCallback(() => { returnFromSettings(view); }, [view]); const handleBackToWorkspace = useCallback(() => { returnFromSettings({ kind: "root" }); }, []); 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: t(item.labelKey), Icon: item.icon }; } if (view.kind === "section") { const item = SIDEBAR_SECTION_ITEMS.find((s) => s.id === view.section); if (!item) return null; return { title: t(item.labelKey), Icon: item.icon }; } if (view.kind === "project") { return { title: t("settings.projects"), Icon: FolderGit2 }; } return null; })(); const content = (() => { if (view.kind === "host") { return renderHostSettingsContent(view, handleHostRemoved); } if (view.kind === "project") { return ( ); } if (view.kind === "section") { switch (view.section) { case "general": return ( <> {isDesktopApp ? : null} ); case "appearance": return ; case "editor": return isWeb ? : null; case "shortcuts": return isDesktopApp ? : null; case "integrations": return isDesktopApp ? : null; case "notifications": return isDesktopApp ? : null; case "permissions": return isDesktopApp ? : null; case "diagnostics": return ( ); case "about": return ( ); } } return null; })(); if (settingsLoading) { return ( {t("settings.loading")} ); } const desktopDetailHeaderLeft = detailHeader ? ( <> {detailHeader.title} {detailHeader.titleAccessory} ) : null; const addHostModals = ( <> ); // Mobile root: full-screen sidebar-as-list. if (isCompactLayout && view.kind === "root") { return ( {addHostModals} ); } 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 ( {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.base, }, 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.base, }, aboutVersionMismatch: { color: theme.colors.palette.amber[500], }, aboutErrorText: { color: theme.colors.palette.red[300], fontSize: theme.fontSize.sm, 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.base, }, 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.base, textAlign: "right", }, placeholder: { flex: 1, alignItems: "center", justifyContent: "center", paddingVertical: theme.spacing[8], }, placeholderText: { color: theme.colors.foregroundMuted, fontSize: theme.fontSize.base, }, })); 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: SETTINGS_DESKTOP_SIDEBAR_WIDTH, borderRightWidth: 1, borderRightColor: theme.colors.border, backgroundColor: theme.colors.surfaceSidebar, }, scrollBody: { flex: 1, }, sidebarDragArea: { position: "relative", }, 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.base, 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, }, 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, }, // Match the setting items' icon footprint so the host label aligns with them. pickerTriggerDot: { width: theme.iconSize.md, height: theme.iconSize.md, alignItems: "center", justifyContent: "center", }, }));