* 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.
1785 lines
58 KiB
TypeScript
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",
|
|
},
|
|
}));
|