Files
paseo/packages/app/src/screens/settings-screen.tsx
T
Mohamed BoudraandGitHub 50ed0d0ab1 Fix desktop layout and window controls at half-screen width (#1983)
* fix(app): preserve desktop layout in half-screen windows

Treat native window controls as physical corner obstructions instead of deriving clearance from compact layout state. Keep 751px windows in desktop mode while preserving usable content when persisted sidebars are wide.

* fix(app): retain sidebar width across window resizes

* fix(app): yield app sidebar to narrow settings

* test(app): wait for mobile sidebar motion to settle

* fix(app): yield navigation to narrow workspace explorer

* fix(app): mark sidebar sizing helpers as worklets

* fix(app): hide app navigation when space is constrained
2026-07-14 23:01:14 +02:00

1728 lines
55 KiB
TypeScript

import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { ComponentType, 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 { 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,
Shield,
Puzzle,
Plus,
FolderGit2,
SquareTerminal,
} 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, 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 { Button } from "@/components/ui/button";
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 { 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,
HostAgentsPage,
HostSettingsPage,
HostProvidersPage,
HostUsagePage,
HostWorkspacesPage,
HostTerminalsPage,
} from "@/screens/settings/host-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 {
buildOpenProjectRoute,
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;
labelKey: string;
icon: ComponentType<{ size: number; color: string }>;
desktopOnly?: boolean;
}
const SIDEBAR_SECTION_ITEMS: SidebarSectionItem[] = [
{ id: "general", labelKey: "settings.sections.general", icon: Settings },
{ id: "appearance", labelKey: "settings.sections.appearance", icon: Palette },
{ id: "shortcuts", labelKey: "settings.sections.shortcuts", icon: Keyboard, desktopOnly: true },
{
id: "integrations",
labelKey: "settings.sections.integrations",
icon: Puzzle,
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: "connections", labelKey: "settings.hostSections.connections", icon: Network },
{ id: "agents", labelKey: "settings.hostSections.agents", icon: Bot },
{ 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 },
];
function renderHostSettingsContent(
view: Extract<SettingsView, { kind: "host" }>,
onHostRemoved: () => void,
): ReactNode {
switch (view.section) {
case "connections":
return <HostConnectionsPage serverId={view.serverId} />;
case "agents":
return <HostAgentsPage serverId={view.serverId} />;
case "workspaces":
return <HostWorkspacesPage serverId={view.serverId} />;
case "providers":
return <HostProvidersPage serverId={view.serverId} />;
case "usage":
return <HostUsagePage serverId={view.serverId} />;
case "terminals":
return <HostTerminalsPage serverId={view.serverId} />;
case "host":
return <HostSettingsPage serverId={view.serverId} onHostRemoved={onHostRemoved} />;
}
}
// ---------------------------------------------------------------------------
// 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];
function getSendBehaviorOptions(t: TFunction) {
return [
{ value: "interrupt" as const, label: t("settings.general.defaultSend.options.interrupt") },
{ value: "queue" as const, label: t("settings.general.defaultSend.options.queue") },
];
}
function getServiceUrlBehaviorLabel(t: TFunction, value: ServiceUrlBehavior): string {
const labels: Record<ServiceUrlBehavior, string> = {
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;
}
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 (
<DropdownMenuItem selected={selected} onSelect={handleSelect}>
{label}
</DropdownMenuItem>
);
}
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 (
<DropdownMenuItem selected={selected} onSelect={handleSelect}>
{label}
</DropdownMenuItem>
);
}
function GeneralSection({
settings,
isDesktopApp,
handleSendBehaviorChange,
handleServiceUrlBehaviorChange,
handleLanguageChange,
handleTerminalScrollbackLinesChange,
}: GeneralSectionProps) {
const { t, i18n } = useTranslation();
const activeLocale = getActiveLocale(i18n.language);
const sendBehaviorOptions = useMemo(() => getSendBehaviorOptions(t), [t]);
const sendBehaviorDescriptionKey =
settings.sendBehavior === "interrupt"
? "settings.general.defaultSend.descriptions.interrupt"
: "settings.general.defaultSend.descriptions.queue";
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 (
<SettingsSection title={t("settings.general.title")}>
<View style={settingsStyles.card}>
<View style={settingsStyles.row}>
<View style={settingsStyles.rowContent}>
<Text style={settingsStyles.rowTitle}>{t("settings.general.defaultSend.label")}</Text>
<Text style={settingsStyles.rowHint}>{t(sendBehaviorDescriptionKey)}</Text>
</View>
<SegmentedControl
size="sm"
value={settings.sendBehavior}
onValueChange={handleSendBehaviorChange}
options={sendBehaviorOptions}
/>
</View>
<View style={ROW_WITH_BORDER_STYLE}>
<View style={settingsStyles.rowContent}>
<Text style={settingsStyles.rowTitle}>{t("settings.general.language.label")}</Text>
<Text style={settingsStyles.rowHint}>{t("settings.general.language.description")}</Text>
</View>
<DropdownMenu>
<DropdownTrigger
accessibilityRole="button"
accessibilityLabel={selectedLanguageLabel}
style={themeTriggerStyle}
>
<Text style={styles.themeTriggerText}>{selectedLanguageLabel}</Text>
</DropdownTrigger>
<DropdownMenuContent side="bottom" align="end" width={300}>
{LANGUAGE_OPTIONS.map((option) => (
<LanguageMenuItem
key={option.value}
value={option.value}
activeLocale={activeLocale}
selected={settings.language === option.value}
onChange={handleLanguageChange}
/>
))}
</DropdownMenuContent>
</DropdownMenu>
</View>
{isDesktopApp ? (
<View style={ROW_WITH_BORDER_STYLE}>
<View style={settingsStyles.rowContent}>
<Text style={settingsStyles.rowTitle}>{t("settings.general.serviceUrls.label")}</Text>
<Text style={settingsStyles.rowHint}>
{t("settings.general.serviceUrls.description")}
</Text>
</View>
<DropdownMenu>
<DropdownTrigger style={themeTriggerStyle}>
<Text style={styles.themeTriggerText}>
{getServiceUrlBehaviorLabel(t, settings.serviceUrlBehavior)}
</Text>
</DropdownTrigger>
<DropdownMenuContent side="bottom" align="end" width={200}>
{SERVICE_URL_BEHAVIOR_VALUES.map((value) => (
<ServiceUrlBehaviorMenuItem
key={value}
value={value}
label={getServiceUrlBehaviorLabel(t, value)}
selected={settings.serviceUrlBehavior === value}
onChange={handleServiceUrlBehaviorChange}
/>
))}
</DropdownMenuContent>
</DropdownMenu>
</View>
) : null}
<View style={ROW_WITH_BORDER_STYLE}>
<View style={settingsStyles.rowContent}>
<Text style={settingsStyles.rowTitle}>
{t("settings.general.terminalScrollback.label")}
</Text>
<Text style={settingsStyles.rowHint}>
{t("settings.general.terminalScrollback.description")}
</Text>
</View>
<TextInput
value={terminalScrollbackValue}
onChangeText={handleTerminalScrollbackChangeText}
onBlur={commitTerminalScrollback}
onSubmitEditing={commitTerminalScrollback}
keyboardType="number-pad"
inputMode="numeric"
selectTextOnFocus
style={styles.terminalScrollbackInput}
accessibilityLabel={t("settings.general.terminalScrollback.accessibilityLabel")}
/>
</View>
</View>
</SettingsSection>
);
}
interface DiagnosticsSectionProps {
voiceAudioEngine: ReturnType<typeof useVoiceAudioEngineOptional>;
isPlaybackTestRunning: boolean;
playbackTestResult: string | null;
handlePlaybackTest: () => Promise<void>;
}
function DiagnosticsSection({
voiceAudioEngine,
isPlaybackTestRunning,
playbackTestResult,
handlePlaybackTest,
}: DiagnosticsSectionProps) {
const { t } = useTranslation();
const openAppDiagnostic = useAppDiagnosticStore((state) => state.open);
const handlePlayPress = useCallback(() => {
void handlePlaybackTest();
}, [handlePlaybackTest]);
return (
<SettingsSection title={t("settings.diagnostics.title")}>
<View style={settingsStyles.card}>
<View style={settingsStyles.row} testID="app-diagnostic-row">
<View style={settingsStyles.rowContent}>
<Text style={settingsStyles.rowTitle}>{t("settings.diagnostics.app.rowTitle")}</Text>
<Text style={settingsStyles.rowHint}>{t("settings.diagnostics.app.rowHint")}</Text>
</View>
<Button variant="secondary" size="sm" onPress={openAppDiagnostic}>
{t("settings.diagnostics.app.run")}
</Button>
</View>
<View style={settingsStyles.row}>
<View style={settingsStyles.rowContent}>
<Text style={settingsStyles.rowTitle}>{t("settings.diagnostics.testAudio")}</Text>
{playbackTestResult ? (
<Text style={settingsStyles.rowHint}>{playbackTestResult}</Text>
) : null}
</View>
<Button
variant="secondary"
size="sm"
onPress={handlePlayPress}
disabled={!voiceAudioEngine || isPlaybackTestRunning}
>
{isPlaybackTestRunning
? t("settings.diagnostics.playing")
: t("settings.diagnostics.playTest")}
</Button>
</View>
</View>
</SettingsSection>
);
}
interface AboutSectionProps {
appVersion: string | null;
appVersionText: string;
isDesktopApp: boolean;
}
function AboutSection({ appVersion, appVersionText, isDesktopApp }: AboutSectionProps) {
const { t } = useTranslation();
return (
<>
<SettingsSection title={t("settings.about.title")}>
<View style={settingsStyles.card}>
<View style={settingsStyles.row}>
<View style={settingsStyles.rowContent}>
<Text style={settingsStyles.rowTitle}>{t("settings.about.appVersion")}</Text>
<Text style={settingsStyles.rowHint}>{t("settings.about.thisDevice")}</Text>
</View>
<Text style={styles.aboutValue}>{appVersionText}</Text>
</View>
{isDesktopApp ? <DesktopAppUpdateRow /> : null}
</View>
</SettingsSection>
<ConnectedHostsSection clientVersion={appVersion} />
<View style={styles.aboutCommunity}>
<CommunityLinks />
</View>
</>
);
}
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 (
<SettingsSection title={t("settings.about.connectedHosts")}>
<View style={settingsStyles.card}>
{hosts.map((host, index) => (
<HostVersionRow
key={host.serverId}
host={host}
showBorder={index > 0}
clientVersion={clientVersion}
/>
))}
</View>
</SettingsSection>
);
}
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 (
<View style={rowStyle}>
<View style={settingsStyles.rowContent}>
<Text style={settingsStyles.rowTitle} numberOfLines={1}>
{host.label}
</Text>
{isMismatch ? (
<Text style={settingsStyles.rowHint}>{t("settings.about.versionDiffers")}</Text>
) : null}
</View>
<Text style={valueStyle}>{valueText}</Text>
</View>
);
}
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 (
<>
<View style={ROW_WITH_BORDER_STYLE}>
<View style={settingsStyles.rowContent}>
<Text style={settingsStyles.rowTitle}>{t("settings.about.releaseChannel.label")}</Text>
<Text style={settingsStyles.rowHint}>
{t("settings.about.releaseChannel.description")}
</Text>
</View>
<SegmentedControl
size="sm"
value={settings.releaseChannel}
onValueChange={handleReleaseChannelChange}
options={releaseChannelOptions}
/>
</View>
<View style={ROW_WITH_BORDER_STYLE}>
<View style={settingsStyles.rowContent}>
<Text style={settingsStyles.rowTitle}>{t("settings.about.updates.label")}</Text>
<Text style={settingsStyles.rowHint}>{statusText}</Text>
{readyUpdateVersion ? (
<Text style={settingsStyles.rowHint}>
{t("settings.about.updates.readyToInstall", {
version: formatVersionWithPrefix(readyUpdateVersion),
})}
</Text>
) : null}
{errorMessage ? <Text style={styles.aboutErrorText}>{errorMessage}</Text> : null}
</View>
<View style={styles.aboutUpdateActions}>
<Button
variant="outline"
size="sm"
onPress={handleCheckForUpdates}
disabled={isChecking || isInstalling}
>
{isChecking ? t("settings.about.updates.checking") : t("settings.about.updates.check")}
</Button>
<Button
variant="default"
size="sm"
onPress={handleInstallUpdate}
disabled={isChecking || isInstalling || !isUpdateReady}
>
{getUpdateButtonLabel(t, isInstalling, readyUpdateVersion)}
</Button>
</View>
</View>
</>
);
}
// ---------------------------------------------------------------------------
// 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 (
<Pressable
accessibilityRole="button"
accessibilityState={accessibilityState}
onPress={handlePress}
style={isSelected ? selectedSidebarItemStyle : sidebarItemStyle}
>
<IconComponent
size={theme.iconSize.md}
color={isSelected ? theme.colors.foreground : theme.colors.foregroundMuted}
/>
<Text style={labelStyle} numberOfLines={1}>
{label}
</Text>
</Pressable>
);
}
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 (
<Pressable
accessibilityRole="button"
accessibilityState={accessibilityState}
onPress={handlePress}
testID={`settings-host-section-${itemId}`}
style={isSelected ? selectedSidebarItemStyle : sidebarItemStyle}
>
<IconComponent
size={theme.iconSize.md}
color={isSelected ? theme.colors.foreground : theme.colors.foregroundMuted}
/>
<Text style={labelStyle} numberOfLines={1}>
{label}
</Text>
</Pressable>
);
}
interface SidebarProjectsButtonProps {
isSelected: boolean;
onSelect: () => void;
}
function SidebarProjectsButton({ isSelected, onSelect }: SidebarProjectsButtonProps) {
const { theme } = useUnistyles();
const { t } = useTranslation();
const accessibilityState = useMemo(() => ({ selected: isSelected }), [isSelected]);
const labelStyle = useMemo(
() => [sidebarStyles.label, isSelected && { color: theme.colors.foreground }],
[isSelected, theme.colors.foreground],
);
return (
<Pressable
accessibilityRole="button"
accessibilityState={accessibilityState}
onPress={onSelect}
testID="settings-projects"
style={isSelected ? selectedSidebarItemStyle : sidebarItemStyle}
>
<FolderGit2
size={theme.iconSize.md}
color={isSelected ? theme.colors.foreground : theme.colors.foregroundMuted}
/>
<Text style={labelStyle} numberOfLines={1}>
{t("settings.projects")}
</Text>
</Pressable>
);
}
interface HostPickerProps {
activeServerId: string | null;
sortedHosts: HostProfile[];
onSelectHost: (serverId: string) => void;
onAddHost: () => void;
enableBuiltInDaemonOption: EnableBuiltInDaemonOption;
}
/**
* 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
* <Combobox>. 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<View | null>(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 (
<SharedHostPicker
hosts={sortedHosts}
value={activeServerId ?? ""}
onSelect={onSelectHost}
open={isOpen}
onOpenChange={setIsOpen}
anchorRef={triggerRef}
includeAddHost
onAddHost={onAddHost}
includeEnableBuiltInDaemon={enableBuiltInDaemonOption.visible}
onEnableBuiltInDaemon={enableBuiltInDaemonOption.onPress}
showActiveConnection
searchable={false}
title={t("settings.hostPicker.switchHost")}
desktopMinWidth={240}
addHostTestID="settings-add-host"
hostOptionTestID={hostOptionTestID}
>
<ComboboxTrigger
ref={triggerRef}
block
style={triggerStyle}
onPress={handleOpen}
accessibilityRole="button"
accessibilityLabel={t("settings.hostPicker.switchHost")}
testID="settings-host-picker"
>
{activeHost ? (
<View style={sidebarStyles.pickerTriggerDot}>
<HostStatusDot serverId={activeHost.serverId} />
</View>
) : null}
<Text style={sidebarStyles.pickerTriggerLabel} numberOfLines={1}>
{activeHost?.label ?? t("settings.groups.host")}
</Text>
</ComboboxTrigger>
</SharedHostPicker>
);
}
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 { 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);
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;
const selectedHostSection = view.kind === "host" ? view.section : null;
const isProjectsSelected = view.kind === "projects" || view.kind === "project";
const sidebarBody = (
<>
<View style={sidebarStyles.list}>
<Text style={sidebarStyles.groupLabel}>{t("settings.groups.app")}</Text>
{items.map((item) => (
<Fragment key={item.id}>
<SidebarSectionButton
itemId={item.id}
label={t(item.labelKey)}
icon={item.icon}
isSelected={selectedSectionId === item.id}
onSelect={onSelectSection}
/>
{item.id === "general" ? (
<SidebarProjectsButton isSelected={isProjectsSelected} onSelect={onSelectProjects} />
) : null}
</Fragment>
))}
</View>
<SidebarSeparator />
{hasHosts ? (
<View style={sidebarStyles.list}>
<Text style={sidebarStyles.groupLabel}>{t("settings.groups.host")}</Text>
<HostPicker
activeServerId={activeHostServerId}
sortedHosts={sortedHosts}
onSelectHost={onSelectHost}
onAddHost={onAddHost}
enableBuiltInDaemonOption={enableBuiltInDaemonOption}
/>
{HOST_SECTION_ITEMS.map((item) => (
<SidebarHostSectionButton
key={item.id}
itemId={item.id}
label={t(item.labelKey)}
icon={item.icon}
isSelected={selectedHostSection === item.id}
onSelect={onSelectHostSection}
/>
))}
</View>
) : (
<View style={sidebarStyles.list}>
<Pressable
accessibilityRole="button"
accessibilityLabel={t("settings.addHost")}
onPress={onAddHost}
testID="settings-add-host"
style={sidebarItemStyle}
>
<Plus size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
<Text style={sidebarStyles.label} numberOfLines={1}>
{t("settings.addHost")}
</Text>
</Pressable>
{enableBuiltInDaemonOption.visible ? (
<Pressable
accessibilityRole="button"
accessibilityLabel={t("settings.enableBuiltInDaemon")}
onPress={enableBuiltInDaemonOption.onPress}
testID="settings-enable-built-in-daemon"
style={sidebarItemStyle}
>
<Server size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
<Text style={sidebarStyles.label} numberOfLines={1}>
{t("settings.enableBuiltInDaemon")}
</Text>
</Pressable>
) : null}
</View>
)}
</>
);
return (
<View style={outerContainerStyle} testID="settings-sidebar">
{isDesktop ? (
<View style={innerContainerStyle}>
<View style={sidebarStyles.sidebarDragArea}>
<TitlebarDragRegion />
<WindowChromeSafeArea placement="below" />
<SidebarHeaderRow
icon={ArrowLeft}
label={t("settings.backToWorkspace")}
onPress={onBackToWorkspace}
testID="settings-back-to-workspace"
/>
</View>
<ScrollView style={sidebarStyles.scrollBody} showsVerticalScrollIndicator={false}>
{sidebarBody}
</ScrollView>
</View>
) : (
sidebarBody
)}
</View>
);
}
// ---------------------------------------------------------------------------
// 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<string | null>(null);
const lastOpenedAddHostIntentRef = useRef<string | null>(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 [selectedSettingsHostServerId, setSelectedSettingsHostServerId] = useState<string | null>(
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 handleLanguageChange = useCallback(
(language: AppLanguage) => {
void updateSettings({ language });
},
[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(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 !== "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;
}
router.replace(buildOpenProjectRoute());
}, [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: 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" || view.kind === "projects") {
return { title: t("settings.projects"), Icon: FolderGit2 };
}
return null;
})();
const content = (() => {
if (view.kind === "host") {
return renderHostSettingsContent(view, handleHostRemoved);
}
if (view.kind === "projects") {
return <ProjectsScreen view={view} />;
}
if (view.kind === "project") {
return <ProjectSettingsScreen projectKey={view.projectKey} />;
}
if (view.kind === "section") {
switch (view.section) {
case "general":
return (
<GeneralSection
settings={settings}
isDesktopApp={isDesktopApp}
handleSendBehaviorChange={handleSendBehaviorChange}
handleServiceUrlBehaviorChange={handleServiceUrlBehaviorChange}
handleLanguageChange={handleLanguageChange}
handleTerminalScrollbackLinesChange={handleTerminalScrollbackLinesChange}
/>
);
case "appearance":
return <AppearanceSection />;
case "shortcuts":
return isDesktopApp ? <KeyboardShortcutsSection /> : null;
case "integrations":
return isDesktopApp ? <IntegrationsSection /> : null;
case "permissions":
return isDesktopApp ? <DesktopPermissionsSection /> : null;
case "diagnostics":
return (
<DiagnosticsSection
voiceAudioEngine={voiceAudioEngine}
isPlaybackTestRunning={isPlaybackTestRunning}
playbackTestResult={playbackTestResult}
handlePlaybackTest={handlePlaybackTest}
/>
);
case "about":
return (
<AboutSection
appVersion={appVersion}
appVersionText={appVersionText}
isDesktopApp={isDesktopApp}
/>
);
}
}
return null;
})();
if (settingsLoading) {
return (
<View style={styles.loadingContainer}>
<Text style={styles.loadingText}>{t("settings.loading")}</Text>
</View>
);
}
const desktopDetailHeaderLeft = detailHeader ? (
<>
<HeaderIconBadge>
<detailHeader.Icon size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
</HeaderIconBadge>
<ScreenTitle testID="settings-detail-header-title">{detailHeader.title}</ScreenTitle>
{detailHeader.titleAccessory}
</>
) : null;
const addHostModals = (
<>
<AddHostMethodModal
visible={isAddHostMethodVisible}
onClose={closeAddConnectionFlow}
onDirectConnection={handleSelectDirectConnection}
onPasteLink={handleSelectPasteLink}
onScanQr={handleScanQr}
/>
<AddHostModal
visible={isDirectHostVisible}
onClose={closeAddConnectionFlow}
onCancel={goBackToAddConnectionMethods}
onSaved={handleHostAdded}
/>
<PairLinkModal
visible={isPasteLinkVisible}
onClose={closeAddConnectionFlow}
onCancel={goBackToAddConnectionMethods}
onSaved={handleHostAdded}
/>
</>
);
// Mobile root: full-screen sidebar-as-list.
if (isCompactLayout && view.kind === "root") {
return (
<View style={styles.container}>
<BackHeader title={t("settings.title")} onBack={handleBackToWorkspace} />
<ScrollView style={styles.scrollView} contentContainerStyle={insetBottomStyle}>
<SettingsSidebar
view={view}
onSelectSection={handleSelectSection}
onSelectHostSection={handleSelectHostSection}
onSelectHost={handleSelectHost}
onSelectProjects={handleSelectProjects}
onAddHost={handleAddHost}
onBackToWorkspace={handleBackToWorkspace}
activeHostServerId={activeHostServerId}
layout="mobile"
/>
</ScrollView>
{addHostModals}
</View>
);
}
// 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 (
<View style={styles.container}>
<BackHeader
title={detailHeader?.title}
titleAccessory={detailHeader?.titleAccessory}
onBack={detailBackHandler}
/>
<ScrollView style={styles.scrollView} contentContainerStyle={insetBottomStyle}>
<View style={styles.content}>{content}</View>
</ScrollView>
{addHostModals}
</View>
);
}
// Desktop split view — mirrors AppContainer: sidebar owns the titlebar drag
// region + traffic-light padding; detail pane renders whatever header the
// selected section provides.
return (
<View style={styles.container}>
<View style={desktopStyles.row}>
<WindowChromeRegion corners="top-left">
<SettingsSidebar
view={view}
onSelectSection={handleSelectSection}
onSelectHostSection={handleSelectHostSection}
onSelectHost={handleSelectHost}
onSelectProjects={handleSelectProjects}
onAddHost={handleAddHost}
onBackToWorkspace={handleBackToWorkspace}
activeHostServerId={activeHostServerId}
layout="desktop"
/>
</WindowChromeRegion>
<WindowChromeRegion corners="top-right">
<View style={desktopStyles.contentPane} testID="settings-detail-pane">
<ScreenHeader
borderless={!detailHeader}
left={desktopDetailHeaderLeft}
leftStyle={desktopStyles.detailLeft}
/>
<ScrollView style={styles.scrollView} contentContainerStyle={insetBottomStyle}>
<View style={styles.content}>{content}</View>
</ScrollView>
</View>
</WindowChromeRegion>
</View>
{addHostModals}
</View>
);
}
// ---------------------------------------------------------------------------
// 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: 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.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,
},
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",
},
}));