* 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
1728 lines
55 KiB
TypeScript
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",
|
|
},
|
|
}));
|