Files
paseo/packages/app/src/screens/settings-screen.tsx
T
Mohamed BoudraandGitHub 19a7d26349 feat(app): make the side panel user-directed (#3605)
* feat(app): let the file panel close its tree rail

The file panel's tree rail was permanent on desktop — the changes panel
had a folder-tree toggle in its toolbar, the file toolbar had nothing, so
the rail was there whether you wanted it or not.

The changes panel's private DesktopTreeToggle becomes a shared
TreeRailToggle so both toolbars render the same affordance; that also
drops the treeToggleStyle prop threaded through ChangesToolbarProps,
which recomputed exactly what the component already built internally.
The i18n keys move out of workspace.git.diff into workspace.tree, since a
shared component reaching into the git namespace would rot.

The two rails keep independent flags — the file rail in panel-store, the
changes rail in useChangesPreferences — so closing one leaves the other
alone. FileTreeToggle wires itself to the store rather than taking a
callback: the bar renders several components below FilePanel, where the
rail actually lives.

Both rails also get narrower (320 -> 260 default, 200 -> 180 minimum).
The persist version bump resets treeRailWidth on purpose: nearly every
existing install carries the old 320 default, so without the reset the
narrower rail would only ever reach new users. Anyone who dragged their
rail to a deliberate custom width loses it once.

Note that MIN_CONTENT_WIDTH still clamps the rail to
`containerWidth - 240`, so in a narrow pane the new default never
applies — that floor, not the default, is what sets the width there.

* feat(app): make the side panel an empty workspace surface

The fixed, hideable side panel keeps supporting context beside an agent without seeding content. Pane-local choices stay local, while implicit supporting opens follow a persisted routing preference.

* test(app): exercise side panel policy through its capability boundary

The browser rail journey already covers the real tree-toggle behavior. Side-panel policy tests now pass their platform capability through the public boundary instead of replacing a production module.

* test(app): expect an empty side panel after upgrade

* test(app): expect an empty first split pane

* test(app): account for the empty side panel in split retention

* feat(app): make workspace panel tabs independent

Give explicit New actions fresh tab instances, keep panel state opaque to tab management, preserve File navigation context, and stabilize uncached File transitions.

* fix(app): preserve file conflicts during preview refresh

Keep the current preview mounted for same-target reads and conversions so the editor retains its conflict observation state. Clear retained previews only when the file target changes.
2026-08-20 21:49:23 +02:00

1785 lines
58 KiB
TypeScript

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<SettingsView, { kind: "host" }>,
onHostRemoved: () => void,
): ReactNode {
switch (view.section) {
case "projects":
return <ProjectsScreen serverId={view.serverId} />;
case "connections":
return <HostConnectionsPage serverId={view.serverId} />;
case "pair-device":
return <HostPairDevicePage serverId={view.serverId} />;
case "agents":
return <HostAgentsPage serverId={view.serverId} />;
case "metadata":
return <MetadataGenerationPage 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 "plugins":
return <HostPluginsPage 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,
];
}
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<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;
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 (
<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,
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 (
<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={[settingsStyles.row, settingsStyles.rowBorder]}>
<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={[settingsStyles.row, settingsStyles.rowBorder]}>
<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={[settingsStyles.row, settingsStyles.rowBorder]}>
<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
initialValue={terminalScrollbackValue}
onChangeText={handleTerminalScrollbackChangeText}
onBlur={commitTerminalScrollback}
onSubmitEditing={commitTerminalScrollback}
keyboardType="number-pad"
inputMode="numeric"
selectTextOnFocus
style={styles.terminalScrollbackInput}
accessibilityLabel={t("settings.general.terminalScrollback.accessibilityLabel")}
/>
</View>
{supportsDesktopPaneSplits() ? (
<View style={[settingsStyles.row, settingsStyles.rowBorder]}>
<View style={settingsStyles.rowContent}>
<Text style={settingsStyles.rowTitle}>
{t("settings.general.sidePanelRouting.label")}
</Text>
<Text style={settingsStyles.rowHint}>
{t("settings.general.sidePanelRouting.description")}
</Text>
</View>
<Switch
value={settings.openSupportingTabsInSidePanel}
onValueChange={handleSidePanelRoutingChange}
accessibilityLabel={t("settings.general.sidePanelRouting.label")}
/>
</View>
) : null}
</View>
</SettingsSection>
);
}
interface DiagnosticsSectionProps {
useLegacyTerminalRenderer: boolean;
onUseLegacyTerminalRendererChange: (value: boolean) => void;
voiceAudioEngine: ReturnType<typeof useVoiceAudioEngineOptional>;
isPlaybackTestRunning: boolean;
playbackTestResult: string | null;
handlePlaybackTest: () => Promise<void>;
}
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 (
<SettingsSection title={t("settings.diagnostics.title")}>
<View style={settingsStyles.card}>
{isNative ? (
<View style={settingsStyles.row} testID="legacy-terminal-renderer-row">
<View style={settingsStyles.rowContent}>
<Text style={settingsStyles.rowTitle}>
{t("settings.diagnostics.legacyTerminalRenderer.label")}
</Text>
<Text style={settingsStyles.rowHint}>
{t("settings.diagnostics.legacyTerminalRenderer.description")}
</Text>
</View>
<Switch
value={useLegacyTerminalRenderer}
onValueChange={onUseLegacyTerminalRendererChange}
accessibilityLabel={t(
"settings.diagnostics.legacyTerminalRenderer.accessibilityLabel",
)}
testID="legacy-terminal-renderer-switch"
/>
</View>
) : null}
<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={[settingsStyles.row, settingsStyles.rowBorder]}>
<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={[settingsStyles.row, settingsStyles.rowBorder]}>
<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 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
* <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;
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 = (
<>
<View style={sidebarStyles.list}>
<Text style={sidebarStyles.groupLabel}>{t("settings.groups.app")}</Text>
{items.map((item) => (
<SidebarSectionButton
key={item.id}
itemId={item.id}
label={t(item.labelKey)}
icon={item.icon}
isSelected={selectedSectionId === item.id}
onSelect={onSelectSection}
/>
))}
</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
accessibilityLabel={t("settings.title")}
role="navigation"
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}
testID="settings-sidebar-scroll-body"
>
{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 lastWorkspaceSelection = useLastWorkspaceSelection();
const routedSettingsHostServerId =
view.kind === "host" || view.kind === "project" ? view.serverId : null;
const [selectedSettingsHostServerId, setSelectedSettingsHostServerId] = useState<string | null>(
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 (
<ProjectSettingsScreen
serverId={view.serverId}
projectId={view.projectId}
onBackToProjects={handleBackFromDetail}
showBackToProjects={!isCompactLayout}
/>
);
}
if (view.kind === "section") {
switch (view.section) {
case "general":
return (
<>
<GeneralSection
settings={settings}
isDesktopApp={isDesktopApp}
handleSendBehaviorChange={handleSendBehaviorChange}
handleServiceUrlBehaviorChange={handleServiceUrlBehaviorChange}
handleLanguageChange={handleLanguageChange}
handleTerminalScrollbackLinesChange={handleTerminalScrollbackLinesChange}
handleSidePanelRoutingChange={handleSidePanelRoutingChange}
/>
{isDesktopApp ? <BrowserDataSection /> : null}
</>
);
case "appearance":
return <AppearanceSection />;
case "editor":
return isWeb ? <EditorSection /> : null;
case "shortcuts":
return isDesktopApp ? <KeyboardShortcutsSection /> : null;
case "integrations":
return isDesktopApp ? <IntegrationsSection /> : null;
case "notifications":
return isDesktopApp ? <DesktopNotificationsSection /> : null;
case "permissions":
return isDesktopApp ? <DesktopPermissionsSection /> : null;
case "diagnostics":
return (
<DiagnosticsSection
useLegacyTerminalRenderer={settings.useLegacyTerminalRenderer}
onUseLegacyTerminalRendererChange={handleUseLegacyTerminalRendererChange}
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}
onAddHost={handleAddHost}
onBackToWorkspace={handleBackToWorkspace}
activeHostServerId={activeHostServerId}
layout="mobile"
/>
</ScrollView>
{addHostModals}
</View>
);
}
if (isCompactLayout) {
return (
<View style={styles.container}>
<BackHeader
title={detailHeader?.title}
titleAccessory={detailHeader?.titleAccessory}
onBack={handleBackFromDetail}
/>
<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}
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.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",
},
}));